From e5d7a11444b75eabce781c5f2ae7682cbe5a4a3d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 11:29:46 +0100 Subject: [PATCH 001/192] ignore apps --- scripts/bundle_apps.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/bundle_apps.sh b/scripts/bundle_apps.sh index d22724d..e939ebc 100755 --- a/scripts/bundle_apps.sh +++ b/scripts/bundle_apps.sh @@ -21,7 +21,8 @@ rm "$outputjson" # com.micropythonos.showfonts is slow to open # com.micropythonos.draw isnt very useful # com.micropythonos.errortest is an intentional bad app for testing (caught by tests/test_graphical_launch_all_apps.py) -blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.confetti com.micropythonos.showfonts com.micropythonos.draw com.micropythonos.errortest" +# com.micropythonos.showbattery is just a test +blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.confetti com.micropythonos.showfonts com.micropythonos.draw com.micropythonos.errortest com.micropythonos.showbattery" echo "[" | tee -a "$outputjson" From 28147fb3129889dc9dca9b891b947e30472e906d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 16:32:41 +0100 Subject: [PATCH 002/192] Disable wifi when reading ADC2 voltage --- .../assets/osupdate.py | 2 +- .../lib/mpos/battery_voltage.py | 167 +++++++++++++++--- .../lib/mpos/board/fri3d_2024.py | 5 +- internal_filesystem/lib/mpos/board/linux.py | 7 +- internal_filesystem/lib/mpos/ui/topmenu.py | 18 +- 5 files changed, 162 insertions(+), 37 deletions(-) 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 ee5e6a6..ab5c518 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -528,7 +528,7 @@ def download_and_install(self, url, progress_callback=None, should_continue_call except Exception as e: result['error'] = str(e) - print(f"UpdateDownloader: Error during download: {e}") + print(f"UpdateDownloader: Error during download: {e}") # -113 when wifi disconnected return result diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py index e25aaf0..f6bc357 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -5,40 +5,153 @@ adc = None scale_factor = 0 +adc_pin = None + +# Cache to reduce WiFi interruptions (ADC2 requires WiFi to be disabled) +_cached_raw_adc = None +_last_read_time = 0 +CACHE_DURATION_MS = 30000 # 30 seconds + + +def _is_adc2_pin(pin): + """Check if pin is on ADC2 (ESP32-S3: GPIO11-20).""" + return 11 <= pin <= 20 + -# This gets called by (the device-specific) boot*.py def init_adc(pinnr, sf): - global adc, scale_factor + """ + Initialize ADC for battery voltage monitoring. + + IMPORTANT for ESP32-S3: ADC2 (GPIO11-20) doesn't work when WiFi is active! + Use ADC1 pins (GPIO1-10) for battery monitoring if possible. + If using ADC2, WiFi will be temporarily disabled during readings. + + Args: + pinnr: GPIO pin number + sf: Scale factor to convert raw ADC (0-4095) to battery voltage + """ + global adc, scale_factor, adc_pin + scale_factor = sf + adc_pin = pinnr try: - print(f"Initializing ADC pin {pinnr} with scale_factor {scale_factor}") - from machine import ADC, Pin # do this inside the try because it will fail on desktop + print(f"Initializing ADC pin {pinnr} with scale_factor {sf}") + if _is_adc2_pin(pinnr): + print(f" WARNING: GPIO{pinnr} is on ADC2 - WiFi will be disabled during readings") + from machine import ADC, Pin adc = ADC(Pin(pinnr)) - # Set ADC to 11dB attenuation for 0–3.3V range (common for ESP32) - adc.atten(ADC.ATTN_11DB) - scale_factor = sf + adc.atten(ADC.ATTN_11DB) # 0-3.3V range except Exception as e: - print("Info: this platform has no ADC for measuring battery voltage") + print(f"Info: this platform has no ADC for measuring battery voltage: {e}") + + +def read_raw_adc(force_refresh=False): + """ + Read raw ADC value (0-4095) with caching. -def read_battery_voltage(): + On ESP32-S3 with ADC2, WiFi is temporarily disabled during reading. + Raises RuntimeError if WifiService is busy (connecting/scanning) when using ADC2. + + Args: + force_refresh: Bypass cache and force fresh reading + + Returns: + float: Raw ADC value (0-4095) + + Raises: + RuntimeError: If WifiService is busy (only when using ADC2) + """ + global _cached_raw_adc, _last_read_time + + # Desktop mode - return random value if not adc: import random - random_voltage = random.randint(round(MIN_VOLTAGE*100),round(MAX_VOLTAGE*100)) / 100 - #print(f"returning random voltage: {random_voltage}") - return random_voltage - # Read raw ADC value - total = 0 - # Read multiple times to try to reduce variability. - # Reading 10 times takes around 3ms so it's fine... - for _ in range(10): - total = total + adc.read() - raw_value = total / 10 - #print(f"read_battery_voltage raw_value: {raw_value}") - voltage = raw_value * scale_factor - # Clamp to 0–4.2V range for LiPo battery - voltage = max(0, min(voltage, MAX_VOLTAGE)) - return voltage - -# Could be interesting to keep a "rolling average" of the percentage so that it doesn't fluctuate too quickly + return random.randint(1900, 2600) if scale_factor == 0 else random.randint( + int(MIN_VOLTAGE / scale_factor), int(MAX_VOLTAGE / scale_factor) + ) + + # Check cache + current_time = time.ticks_ms() + if not force_refresh and _cached_raw_adc is not None: + age = time.ticks_diff(current_time, _last_read_time) + if age < CACHE_DURATION_MS: + return _cached_raw_adc + + # Check if this is an ADC2 pin (requires WiFi disable) + needs_wifi_disable = adc_pin is not None and _is_adc2_pin(adc_pin) + + # Import WifiService only if needed + WifiService = None + if needs_wifi_disable: + try: + from mpos.net.wifi_service import WifiService + except ImportError: + pass + + # Check if WiFi operations are in progress + if WifiService and WifiService.wifi_busy: + raise RuntimeError("Cannot read battery voltage: WifiService is busy") + + # Disable WiFi for ADC2 reading + wifi_was_connected = False + if needs_wifi_disable and WifiService: + wifi_was_connected = WifiService.is_connected() + WifiService.wifi_busy = True + WifiService.disconnect() + time.sleep(0.05) # Brief delay for WiFi to fully disable + + try: + # Read ADC (average of 10 samples) + total = sum(adc.read() for _ in range(10)) + raw_value = total / 10.0 + + # Update cache + _cached_raw_adc = raw_value + _last_read_time = current_time + + return raw_value + + finally: + # Re-enable WiFi (only if we disabled it) + if needs_wifi_disable and WifiService: + WifiService.wifi_busy = False + if wifi_was_connected: + # Trigger reconnection in background thread + try: + import _thread + _thread.start_new_thread(WifiService.auto_connect, ()) + except Exception as e: + print(f"battery_voltage: Failed to start reconnect thread: {e}") + + +def read_battery_voltage(force_refresh=False): + """ + Read battery voltage in volts. + + Args: + force_refresh: Bypass cache and force fresh reading + + Returns: + float: Battery voltage in volts (clamped to 0-MAX_VOLTAGE) + """ + raw = read_raw_adc(force_refresh) + voltage = raw * scale_factor + return max(0.0, min(voltage, MAX_VOLTAGE)) + + def get_battery_percentage(): - return (read_battery_voltage() - MIN_VOLTAGE) * 100 / (MAX_VOLTAGE - MIN_VOLTAGE) + """ + Get battery charge percentage. + + Returns: + float: Battery percentage (0-100) + """ + voltage = read_battery_voltage() + percentage = (voltage - MIN_VOLTAGE) * 100.0 / (MAX_VOLTAGE - MIN_VOLTAGE) + return max(0.0, min(100.0, percentage)) + +def clear_cache(): + """Clear the battery voltage cache to force fresh reading on next call.""" + global _cached_raw_adc, _last_read_time + _cached_raw_adc = None + _last_read_time = 0 diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 243c75c..db3c4eb 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -258,8 +258,11 @@ def keypad_read_cb(indev, data): indev.enable(True) # NOQA # Battery voltage ADC measuring +# NOTE: GPIO13 is on ADC2, which requires WiFi to be disabled during reading on ESP32-S3. +# battery_voltage.py handles this automatically: disables WiFi, reads ADC, reconnects WiFi. +# Readings are cached for 30 seconds to minimize WiFi interruptions. import mpos.battery_voltage -mpos.battery_voltage.init_adc(13, 2 / 1000) +mpos.battery_voltage.init_adc(13, 3.3 * 2 / 4095) import mpos.sdcard mpos.sdcard.init(spi_bus, cs_pin=14) diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 3256f53..54f2ab2 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -85,7 +85,12 @@ def catch_escape_key(indev, indev_data): # print(f"boot_unix: code={event_code}") # target={event.get_target()}, user_data={event.get_user_data()}, param={event.get_param()} #keyboard.add_event_cb(keyboard_cb, lv.EVENT.ALL, None) -print("boot_unix.py finished") + +# Simulated battery voltage ADC measuring +import mpos.battery_voltage +mpos.battery_voltage.init_adc(999, (3.3 / 4095) * 2) + +print("linux.py finished") diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index ac59bbc..715047a 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -11,7 +11,7 @@ CLOCK_UPDATE_INTERVAL = 1000 # 10 or even 1 ms doesn't seem to change the framerate but 100ms is enough WIFI_ICON_UPDATE_INTERVAL = 1500 -BATTERY_ICON_UPDATE_INTERVAL = 5000 +BATTERY_ICON_UPDATE_INTERVAL = 30000 # not too often, because on fri3d_2024, this briefly disables wifi TEMPERATURE_UPDATE_INTERVAL = 2000 MEMFREE_UPDATE_INTERVAL = 5000 # not too frequent because there's a forced gc.collect() to give it a reliable value @@ -92,9 +92,10 @@ def create_notification_bar(): temp_label = lv.label(notification_bar) temp_label.set_text("00°C") temp_label.align_to(time_label, lv.ALIGN.OUT_RIGHT_MID, mpos.ui.pct_of_display_width(7) , 0) - memfree_label = lv.label(notification_bar) - memfree_label.set_text("") - memfree_label.align_to(temp_label, lv.ALIGN.OUT_RIGHT_MID, mpos.ui.pct_of_display_width(7), 0) + if False: + memfree_label = lv.label(notification_bar) + memfree_label.set_text("") + memfree_label.align_to(temp_label, lv.ALIGN.OUT_RIGHT_MID, mpos.ui.pct_of_display_width(7), 0) #style = lv.style_t() #style.init() #style.set_text_font(lv.font_montserrat_8) # tiny font @@ -134,7 +135,11 @@ def update_time(timer): print("Warning: could not check WLAN status:", str(e)) def update_battery_icon(timer=None): - percent = mpos.battery_voltage.get_battery_percentage() + try: + percent = mpos.battery_voltage.get_battery_percentage() + except Exception as e: + print(f"battery_voltage.get_battery_percentage got exception, not updating battery_icon: {e}") + return if percent > 80: # 4.1V battery_icon.set_text(lv.SYMBOL.BATTERY_FULL) elif percent > 60: # 4.0V @@ -149,7 +154,6 @@ def update_battery_icon(timer=None): # Percentage is not shown for now: #battery_label.set_text(f"{round(percent)}%") #battery_label.remove_flag(lv.obj.FLAG.HIDDEN) - update_battery_icon() # run it immediately instead of waiting for the timer def update_wifi_icon(timer): from mpos.net.wifi_service import WifiService @@ -182,7 +186,7 @@ def update_memfree(timer): lv.timer_create(update_time, CLOCK_UPDATE_INTERVAL, None) lv.timer_create(update_temperature, TEMPERATURE_UPDATE_INTERVAL, None) - lv.timer_create(update_memfree, MEMFREE_UPDATE_INTERVAL, None) + #lv.timer_create(update_memfree, MEMFREE_UPDATE_INTERVAL, None) lv.timer_create(update_wifi_icon, WIFI_ICON_UPDATE_INTERVAL, None) lv.timer_create(update_battery_icon, BATTERY_ICON_UPDATE_INTERVAL, None) From ce8b36e3a8548f4619e79dfb3358a79935be414f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 18:03:02 +0100 Subject: [PATCH 003/192] OSUpdate: pause when wifi goes away, then redownload --- .../assets/osupdate.py | 72 ++++++++++++++--- .../lib/mpos/net/wifi_service.py | 3 +- tests/network_test_helper.py | 33 +++++--- tests/test_osupdate.py | 79 +++++++++++++++++++ 4 files changed, 168 insertions(+), 19 deletions(-) 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 ab5c518..15f2cc5 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -308,7 +308,9 @@ def update_with_lvgl(self, url): while elapsed < max_wait and self.has_foreground(): if self.connectivity_manager.is_online(): - print("OSUpdate: Network reconnected, resuming download") + print("OSUpdate: Network reconnected, waiting for stabilization...") + time.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 @@ -398,6 +400,33 @@ def __init__(self, requests_module=None, partition_module=None, connectivity_man print("UpdateDownloader: Partition module not available, will simulate") self.simulate = True + def _is_network_error(self, exception): + """Check if exception is a network connectivity error that should trigger pause. + + Args: + exception: Exception to check + + Returns: + bool: True if this is a recoverable network error + """ + error_str = str(exception).lower() + error_repr = repr(exception).lower() + + # Check for common network error codes and messages + # -113 = ECONNABORTED (connection aborted) + # -104 = ECONNRESET (connection reset by peer) + # -110 = ETIMEDOUT (connection timed out) + # -118 = EHOSTUNREACH (no route to host) + network_indicators = [ + '-113', '-104', '-110', '-118', # Error codes + 'econnaborted', 'econnreset', 'etimedout', 'ehostunreach', # Error names + 'connection reset', 'connection aborted', # Error messages + 'broken pipe', 'network unreachable', 'host unreachable' + ] + + return any(indicator in error_str or indicator in error_repr + for indicator in network_indicators) + def download_and_install(self, url, progress_callback=None, should_continue_callback=None): """Download firmware and install to OTA partition. @@ -467,18 +496,16 @@ def download_and_install(self, url, progress_callback=None, should_continue_call response.close() return result - # Check network connection (if monitoring enabled) + # Check network connection before reading if self.connectivity_manager: is_online = self.connectivity_manager.is_online() elif ConnectivityManager._instance: - # Use global instance if available is_online = ConnectivityManager._instance.is_online() else: - # No connectivity checking available is_online = True if not is_online: - print("UpdateDownloader: Network lost, pausing download") + print("UpdateDownloader: Network lost (pre-check), pausing download") self.is_paused = True self.bytes_written_so_far = bytes_written result['paused'] = True @@ -486,8 +513,26 @@ def download_and_install(self, url, progress_callback=None, should_continue_call response.close() return result - # Read next chunk - chunk = response.raw.read(chunk_size) + # 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 @@ -527,8 +572,17 @@ def download_and_install(self, url, progress_callback=None, should_continue_call print(f"UpdateDownloader: {result['error']}") except Exception as e: - result['error'] = str(e) - print(f"UpdateDownloader: Error during download: {e}") # -113 when wifi disconnected + # Check if this is a network error that should trigger pause + if self._is_network_error(e): + print(f"UpdateDownloader: Network error ({e}), pausing download") + self.is_paused = True + self.bytes_written_so_far = result.get('bytes_written', self.bytes_written_so_far) + result['paused'] = True + result['bytes_written'] = self.bytes_written_so_far + else: + # Non-network error + result['error'] = str(e) + print(f"UpdateDownloader: Error during download: {e}") return result diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index bfa7618..927760e 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -247,7 +247,8 @@ def disconnect(network_module=None): wlan.active(False) print("WifiService: Disconnected and WiFi disabled") except Exception as e: - print(f"WifiService: Error disconnecting: {e}") + #print(f"WifiService: Error disconnecting: {e}") # probably "Wifi Not Started" so harmless + pass @staticmethod def get_saved_networks(): diff --git a/tests/network_test_helper.py b/tests/network_test_helper.py index e3e60b2..c811c1f 100644 --- a/tests/network_test_helper.py +++ b/tests/network_test_helper.py @@ -122,15 +122,17 @@ class MockRaw: Simulates the 'raw' attribute of requests.Response for chunked reading. """ - def __init__(self, content): + 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): """ @@ -141,7 +143,14 @@ def read(self, size): 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 @@ -154,7 +163,7 @@ class MockResponse: Simulates requests.Response object with status code, text, headers, etc. """ - def __init__(self, status_code=200, text='', headers=None, content=b''): + def __init__(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): """ Initialize mock response. @@ -163,6 +172,7 @@ def __init__(self, status_code=200, text='', headers=None, content=b''): 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 @@ -171,7 +181,7 @@ def __init__(self, status_code=200, text='', headers=None, content=b''): self._closed = False # Mock raw attribute for streaming - self.raw = MockRaw(content) + self.raw = MockRaw(content, fail_after_bytes=fail_after_bytes) def close(self): """Close the response.""" @@ -197,6 +207,7 @@ def __init__(self): 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 = [] @@ -222,14 +233,17 @@ def get(self, url, stream=False, timeout=None, headers=None): self.last_timeout = timeout self.last_stream = stream - # Record call in history - self.call_history.append({ + # Store full request info + self.last_request = { 'method': 'GET', 'url': url, 'stream': stream, 'timeout': timeout, - 'headers': headers - }) + 'headers': headers or {} + } + + # Record call in history + self.call_history.append(self.last_request.copy()) if self.raise_exception: exc = self.raise_exception @@ -287,7 +301,7 @@ def post(self, url, data=None, json=None, timeout=None, headers=None): return MockResponse() - def set_next_response(self, status_code=200, text='', headers=None, content=b''): + def set_next_response(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): """ Configure the next response to return. @@ -296,11 +310,12 @@ def set_next_response(self, status_code=200, text='', headers=None, content=b'') 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) + self.next_response = MockResponse(status_code, text, headers, content, fail_after_bytes=fail_after_bytes) return self.next_response def set_exception(self, exception): diff --git a/tests/test_osupdate.py b/tests/test_osupdate.py index a087f07..e5a888b 100644 --- a/tests/test_osupdate.py +++ b/tests/test_osupdate.py @@ -381,4 +381,83 @@ def test_download_exact_chunk_multiple(self): self.assertEqual(result['total_size'], 8192) self.assertEqual(result['bytes_written'], 8192) + def test_network_error_detection_econnaborted(self): + """Test that ECONNABORTED error is detected as network error.""" + error = OSError(-113, "ECONNABORTED") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_econnreset(self): + """Test that ECONNRESET error is detected as network error.""" + error = OSError(-104, "ECONNRESET") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_etimedout(self): + """Test that ETIMEDOUT error is detected as network error.""" + error = OSError(-110, "ETIMEDOUT") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_ehostunreach(self): + """Test that EHOSTUNREACH error is detected as network error.""" + error = OSError(-118, "EHOSTUNREACH") + self.assertTrue(self.downloader._is_network_error(error)) + + def test_network_error_detection_by_message(self): + """Test that network errors are detected by message.""" + self.assertTrue(self.downloader._is_network_error(Exception("Connection reset by peer"))) + self.assertTrue(self.downloader._is_network_error(Exception("Connection aborted"))) + self.assertTrue(self.downloader._is_network_error(Exception("Broken pipe"))) + + def test_non_network_error_not_detected(self): + """Test that non-network errors are not detected as network errors.""" + self.assertFalse(self.downloader._is_network_error(ValueError("Invalid data"))) + self.assertFalse(self.downloader._is_network_error(Exception("File not found"))) + self.assertFalse(self.downloader._is_network_error(KeyError("missing"))) + + def test_download_pauses_on_network_error_during_read(self): + """Test that download pauses when network error occurs during read.""" + # Set up mock to raise network error after first chunk + test_data = b'G' * 16384 # 4 chunks + self.mock_requests.set_next_response( + status_code=200, + headers={'Content-Length': '16384'}, + content=test_data, + fail_after_bytes=4096 # Fail after first chunk + ) + + result = self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + self.assertFalse(result['success']) + self.assertTrue(result['paused']) + self.assertEqual(result['bytes_written'], 4096) # Should have written first chunk + self.assertIsNone(result['error']) # Pause, not error + + def test_download_resumes_from_saved_position(self): + """Test that download resumes from the last written position.""" + # Simulate partial download + 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 + 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 + ) + + result = self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + 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-') + From 61379985e9a59e650f65226f6170ca781bf2d370 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 18:20:06 +0100 Subject: [PATCH 004/192] OSUpdate app: resume when wifi reconnects --- CHANGELOG.md | 5 ++++ .../assets/osupdate.py | 6 ++++- tests/test_osupdate.py | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d03f979..fe84e8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +0.5.1 +===== +- OSUpdate app: pause download when wifi is lost, resume when reconnected +- Fri3d Camp 2024 Badge: workaround ADC2+WiFi conflict by disconnecting WiFi to measure battery level + 0.5.0 ===== - ESP32: one build to rule them all; instead of 2 builds per supported board, there is now one single build that identifies and initializes the board at runtime! diff --git a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py index 15f2cc5..3af8c3d 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -576,9 +576,13 @@ def download_and_install(self, url, progress_callback=None, should_continue_call if self._is_network_error(e): print(f"UpdateDownloader: Network error ({e}), pausing download") self.is_paused = True - self.bytes_written_so_far = result.get('bytes_written', self.bytes_written_so_far) + # 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 else: # Non-network error result['error'] = str(e) diff --git a/tests/test_osupdate.py b/tests/test_osupdate.py index e5a888b..16e52fd 100644 --- a/tests/test_osupdate.py +++ b/tests/test_osupdate.py @@ -460,4 +460,27 @@ def test_download_resumes_from_saved_position(self): self.assertIn('Range', last_request['headers']) self.assertEqual(last_request['headers']['Range'], 'bytes=8192-') + def test_resume_failure_preserves_state(self): + """Test that resume failures preserve download state for retry.""" + # Simulate partial download state + self.downloader.bytes_written_so_far = 245760 # 60 chunks already downloaded + self.downloader.total_size_expected = 3391488 + + # Resume attempt fails immediately with EHOSTUNREACH (network not ready) + self.mock_requests.set_exception(OSError(-118, "EHOSTUNREACH")) + + result = self.downloader.download_and_install( + "http://example.com/update.bin" + ) + + # Should pause, not fail + self.assertFalse(result['success']) + self.assertTrue(result['paused']) + self.assertIsNone(result['error']) + + # Critical: Must preserve progress for next retry + self.assertEqual(result['bytes_written'], 245760, "Must preserve bytes_written") + self.assertEqual(result['total_size'], 3391488, "Must preserve total_size") + self.assertEqual(self.downloader.bytes_written_so_far, 245760, "Must preserve internal state") + From f4bd4d0a2b33fc94198cbe6216c84e8590056f76 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 18:52:49 +0100 Subject: [PATCH 005/192] Improve wifi handling --- .../lib/mpos/battery_voltage.py | 22 ++----- .../lib/mpos/board/fri3d_2024.py | 19 ++++++- .../lib/mpos/net/wifi_service.py | 57 +++++++++++++++++++ 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py index f6bc357..9b943f6 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -87,16 +87,11 @@ def read_raw_adc(force_refresh=False): except ImportError: pass - # Check if WiFi operations are in progress - if WifiService and WifiService.wifi_busy: - raise RuntimeError("Cannot read battery voltage: WifiService is busy") - - # Disable WiFi for ADC2 reading - wifi_was_connected = False + # Temporarily disable WiFi for ADC2 reading + was_connected = False if needs_wifi_disable and WifiService: - wifi_was_connected = WifiService.is_connected() - WifiService.wifi_busy = True - WifiService.disconnect() + # This will raise RuntimeError if WiFi is already busy + was_connected = WifiService.temporarily_disable() time.sleep(0.05) # Brief delay for WiFi to fully disable try: @@ -113,14 +108,7 @@ def read_raw_adc(force_refresh=False): finally: # Re-enable WiFi (only if we disabled it) if needs_wifi_disable and WifiService: - WifiService.wifi_busy = False - if wifi_was_connected: - # Trigger reconnection in background thread - try: - import _thread - _thread.start_new_thread(WifiService.auto_connect, ()) - except Exception as e: - print(f"battery_voltage: Failed to start reconnect thread: {e}") + WifiService.temporarily_enable(was_connected) def read_battery_voltage(force_refresh=False): diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index db3c4eb..c79c652 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -260,9 +260,24 @@ def keypad_read_cb(indev, data): # Battery voltage ADC measuring # NOTE: GPIO13 is on ADC2, which requires WiFi to be disabled during reading on ESP32-S3. # battery_voltage.py handles this automatically: disables WiFi, reads ADC, reconnects WiFi. -# Readings are cached for 30 seconds to minimize WiFi interruptions. import mpos.battery_voltage -mpos.battery_voltage.init_adc(13, 3.3 * 2 / 4095) +""" +best fit on battery power: +2482 is 4.180 +2470 is 4.170 +2457 is 4.147 +2433 is 4.109 +2429 is 4.102 +2393 is 4.044 +2369 is 4.000 +2343 is 3.957 +2319 is 3.916 +2269 is 3.831 +""" +def adc_to_voltage(adc_value): + return (-0.0016237 * adc_value + 8.2035) +#mpos.battery_voltage.init_adc(13, adc_to_voltage) +mpos.battery_voltage.init_adc(13, 1/616) # simple scaling has an error of ~0.01V vs the adc_to_voltage() method import mpos.sdcard mpos.sdcard.init(spi_bus, cs_pin=14) diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 927760e..25d777a 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -197,6 +197,63 @@ def auto_connect(network_module=None, time_module=None): WifiService.wifi_busy = False print("WifiService: Auto-connect thread finished") + @staticmethod + def temporarily_disable(network_module=None): + """ + Temporarily disable WiFi for operations that require it (e.g., ESP32-S3 ADC2). + + This method sets wifi_busy flag and disconnects WiFi if connected. + Caller must call temporarily_enable() in a finally block. + + Args: + network_module: Network module for dependency injection (testing) + + Returns: + bool: True if WiFi was connected before disabling, False otherwise + + Raises: + RuntimeError: If WiFi operations are already in progress + """ + if WifiService.wifi_busy: + raise RuntimeError("Cannot disable WiFi: WifiService is already busy") + + # Check actual connection status BEFORE setting wifi_busy + was_connected = False + if HAS_NETWORK_MODULE or network_module: + try: + net = network_module if network_module else network + wlan = net.WLAN(net.STA_IF) + was_connected = wlan.isconnected() + except Exception as e: + print(f"WifiService: Error checking connection: {e}") + + # Now set busy flag and disconnect + WifiService.wifi_busy = True + WifiService.disconnect(network_module=network_module) + + return was_connected + + @staticmethod + def temporarily_enable(was_connected, network_module=None): + """ + Re-enable WiFi after temporary disable operation. + + Must be called in a finally block after temporarily_disable(). + + Args: + was_connected: Return value from temporarily_disable() + network_module: Network module for dependency injection (testing) + """ + WifiService.wifi_busy = False + + # Only reconnect if WiFi was connected before we disabled it + if was_connected: + try: + import _thread + _thread.start_new_thread(WifiService.auto_connect, ()) + except Exception as e: + print(f"WifiService: Failed to start reconnect thread: {e}") + @staticmethod def is_connected(network_module=None): """ From 7d722f7f4a31c7caf70abae288088d5be39e45c5 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 18:53:00 +0100 Subject: [PATCH 006/192] Add tests/test_battery_voltage.py --- tests/test_battery_voltage.py | 424 ++++++++++++++++++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 tests/test_battery_voltage.py diff --git a/tests/test_battery_voltage.py b/tests/test_battery_voltage.py new file mode 100644 index 0000000..2e7afe7 --- /dev/null +++ b/tests/test_battery_voltage.py @@ -0,0 +1,424 @@ +""" +Unit tests for mpos.battery_voltage module. + +Tests ADC1/ADC2 detection, caching, WiFi coordination, and voltage calculations. +""" + +import unittest +import sys + +# Add parent directory to path for imports +sys.path.insert(0, '../internal_filesystem') + +# Mock modules before importing battery_voltage +class MockADC: + """Mock ADC for testing.""" + ATTN_11DB = 3 + + def __init__(self, pin): + self.pin = pin + self._atten = None + self._read_value = 2048 # Default mid-range value + + def atten(self, value): + self._atten = value + + def read(self): + return self._read_value + + def set_read_value(self, value): + """Test helper to set ADC reading.""" + self._read_value = value + + +class MockPin: + """Mock Pin for testing.""" + def __init__(self, pin_num): + self.pin_num = pin_num + + +class MockMachine: + """Mock machine module.""" + ADC = MockADC + Pin = MockPin + + +class MockWifiService: + """Mock WifiService for testing.""" + wifi_busy = False + _connected = False + _temporarily_disabled = False + + @classmethod + def is_connected(cls): + return cls._connected + + @classmethod + def disconnect(cls): + cls._connected = False + + @classmethod + def temporarily_disable(cls): + """Temporarily disable WiFi and return whether it was connected.""" + if cls.wifi_busy: + raise RuntimeError("Cannot disable WiFi: WifiService is already busy") + was_connected = cls._connected + cls.wifi_busy = True + cls._connected = False + cls._temporarily_disabled = True + return was_connected + + @classmethod + def temporarily_enable(cls, was_connected): + """Re-enable WiFi and reconnect if it was connected before.""" + cls.wifi_busy = False + cls._temporarily_disabled = False + if was_connected: + cls._connected = True # Simulate reconnection + + @classmethod + def reset(cls): + """Test helper to reset state.""" + cls.wifi_busy = False + cls._connected = False + cls._temporarily_disabled = False + + +# Inject mocks +sys.modules['machine'] = MockMachine +sys.modules['mpos.net.wifi_service'] = type('module', (), {'WifiService': MockWifiService})() + +# Now import battery_voltage +import mpos.battery_voltage as bv + + +class TestADC2Detection(unittest.TestCase): + """Test ADC1 vs ADC2 pin detection.""" + + def test_adc1_pins_detected(self): + """Test that ADC1 pins (GPIO1-10) are detected correctly.""" + for pin in range(1, 11): + self.assertFalse(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC1") + + def test_adc2_pins_detected(self): + """Test that ADC2 pins (GPIO11-20) are detected correctly.""" + for pin in range(11, 21): + self.assertTrue(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC2") + + def test_out_of_range_pins(self): + """Test pins outside ADC range.""" + self.assertFalse(bv._is_adc2_pin(0)) + self.assertFalse(bv._is_adc2_pin(21)) + self.assertFalse(bv._is_adc2_pin(30)) + self.assertFalse(bv._is_adc2_pin(100)) + + +class TestInitADC(unittest.TestCase): + """Test ADC initialization.""" + + def setUp(self): + """Reset module state.""" + bv.adc = None + bv.scale_factor = 0 + bv.adc_pin = None + + def test_init_adc1_pin(self): + """Test initializing with ADC1 pin.""" + bv.init_adc(5, 0.00161) + + self.assertIsNotNone(bv.adc) + self.assertEqual(bv.scale_factor, 0.00161) + self.assertEqual(bv.adc_pin, 5) + self.assertEqual(bv.adc._atten, MockADC.ATTN_11DB) + + def test_init_adc2_pin(self): + """Test initializing with ADC2 pin (should warn but work).""" + bv.init_adc(13, 0.00197) + + self.assertIsNotNone(bv.adc) + self.assertEqual(bv.scale_factor, 0.00197) + self.assertEqual(bv.adc_pin, 13) + + def test_scale_factor_stored(self): + """Test that scale factor is stored correctly.""" + bv.init_adc(5, 0.12345) + self.assertEqual(bv.scale_factor, 0.12345) + + +class TestCaching(unittest.TestCase): + """Test caching mechanism.""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + bv.init_adc(5, 0.00161) # Use ADC1 to avoid WiFi complexity + MockWifiService.reset() + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + + def test_cache_miss_on_first_read(self): + """Test that first read doesn't use cache.""" + self.assertIsNone(bv._cached_raw_adc) + raw = bv.read_raw_adc() + self.assertIsNotNone(bv._cached_raw_adc) + self.assertEqual(raw, bv._cached_raw_adc) + + def test_cache_hit_within_duration(self): + """Test that subsequent reads use cache within duration.""" + raw1 = bv.read_raw_adc() + + # Change ADC value but should still get cached value + bv.adc.set_read_value(3000) + raw2 = bv.read_raw_adc() + + self.assertEqual(raw1, raw2, "Should return cached value") + + def test_force_refresh_bypasses_cache(self): + """Test that force_refresh bypasses cache.""" + bv.adc.set_read_value(2000) + raw1 = bv.read_raw_adc() + + # Change value and force refresh + bv.adc.set_read_value(3000) + raw2 = bv.read_raw_adc(force_refresh=True) + + self.assertNotEqual(raw1, raw2, "force_refresh should bypass cache") + self.assertEqual(raw2, 3000.0) + + def test_clear_cache_works(self): + """Test that clear_cache() clears the cache.""" + bv.read_raw_adc() + self.assertIsNotNone(bv._cached_raw_adc) + + bv.clear_cache() + self.assertIsNone(bv._cached_raw_adc) + self.assertEqual(bv._last_read_time, 0) + + +class TestADC1Reading(unittest.TestCase): + """Test ADC reading with ADC1 (no WiFi interference).""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + bv.init_adc(5, 0.00161) # GPIO5 is ADC1 + MockWifiService.reset() + MockWifiService._connected = True + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + MockWifiService.reset() + + def test_adc1_doesnt_disable_wifi(self): + """Test that ADC1 reading doesn't disable WiFi.""" + MockWifiService._connected = True + + bv.read_raw_adc(force_refresh=True) + + # WiFi should still be connected + self.assertTrue(MockWifiService.is_connected()) + self.assertFalse(MockWifiService.wifi_busy) + + def test_adc1_ignores_wifi_busy(self): + """Test that ADC1 reading works even if WiFi is busy.""" + MockWifiService.wifi_busy = True + + # Should not raise error + try: + raw = bv.read_raw_adc(force_refresh=True) + self.assertIsNotNone(raw) + except RuntimeError: + self.fail("ADC1 should not raise error when WiFi is busy") + + +class TestADC2Reading(unittest.TestCase): + """Test ADC reading with ADC2 (requires WiFi disable).""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + bv.init_adc(13, 0.00197) # GPIO13 is ADC2 + MockWifiService.reset() + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + MockWifiService.reset() + + def test_adc2_disables_wifi_when_connected(self): + """Test that ADC2 reading disables WiFi when connected.""" + MockWifiService._connected = True + + bv.read_raw_adc(force_refresh=True) + + # WiFi should be reconnected after reading (if it was connected before) + self.assertTrue(MockWifiService.is_connected()) + + def test_adc2_sets_wifi_busy_flag(self): + """Test that ADC2 reading sets wifi_busy flag.""" + MockWifiService._connected = False + + # wifi_busy should be False before + self.assertFalse(MockWifiService.wifi_busy) + + bv.read_raw_adc(force_refresh=True) + + # wifi_busy should be False after (cleared in finally) + self.assertFalse(MockWifiService.wifi_busy) + + def test_adc2_raises_error_if_wifi_busy(self): + """Test that ADC2 reading raises error if WiFi is busy.""" + MockWifiService.wifi_busy = True + + with self.assertRaises(RuntimeError) as ctx: + bv.read_raw_adc(force_refresh=True) + + self.assertIn("WifiService is already busy", str(ctx.exception)) + + def test_adc2_uses_cache_when_wifi_busy(self): + """Test that ADC2 uses cache even when WiFi is busy.""" + # First read to populate cache + MockWifiService.wifi_busy = False + raw1 = bv.read_raw_adc(force_refresh=True) + + # Now set WiFi busy + MockWifiService.wifi_busy = True + + # Should return cached value without error + raw2 = bv.read_raw_adc() + self.assertEqual(raw1, raw2) + + def test_adc2_only_reconnects_if_was_connected(self): + """Test that ADC2 only reconnects WiFi if it was connected before.""" + # WiFi is NOT connected + MockWifiService._connected = False + + bv.read_raw_adc(force_refresh=True) + + # WiFi should still be disconnected (no unwanted reconnection) + self.assertFalse(MockWifiService.is_connected()) + + +class TestVoltageCalculations(unittest.TestCase): + """Test voltage and percentage calculations.""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + bv.init_adc(5, 0.00161) # ADC1 pin, scale factor for 2:1 divider + bv.adc.set_read_value(2048) # Mid-range + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + + def test_read_battery_voltage_applies_scale_factor(self): + """Test that voltage is calculated correctly.""" + bv.adc.set_read_value(2048) # Mid-range + bv.clear_cache() + + voltage = bv.read_battery_voltage(force_refresh=True) + expected = 2048 * 0.00161 + self.assertAlmostEqual(voltage, expected, places=4) + + def test_voltage_clamped_to_max(self): + """Test that voltage is clamped to MAX_VOLTAGE.""" + bv.adc.set_read_value(4095) # Maximum ADC + bv.clear_cache() + + voltage = bv.read_battery_voltage(force_refresh=True) + self.assertLessEqual(voltage, bv.MAX_VOLTAGE) + + def test_voltage_clamped_to_zero(self): + """Test that negative voltage is clamped to 0.""" + bv.adc.set_read_value(0) + bv.clear_cache() + + voltage = bv.read_battery_voltage(force_refresh=True) + self.assertGreaterEqual(voltage, 0.0) + + def test_get_battery_percentage_calculation(self): + """Test percentage calculation.""" + # Set voltage to mid-range between MIN and MAX + mid_voltage = (bv.MIN_VOLTAGE + bv.MAX_VOLTAGE) / 2 + raw_adc = mid_voltage / bv.scale_factor + bv.adc.set_read_value(int(raw_adc)) + bv.clear_cache() + + percentage = bv.get_battery_percentage() + self.assertAlmostEqual(percentage, 50.0, places=0) + + def test_percentage_clamped_to_0_100(self): + """Test that percentage is clamped to 0-100 range.""" + # Test minimum + bv.adc.set_read_value(0) + bv.clear_cache() + percentage = bv.get_battery_percentage() + self.assertGreaterEqual(percentage, 0.0) + self.assertLessEqual(percentage, 100.0) + + # Test maximum + bv.adc.set_read_value(4095) + bv.clear_cache() + percentage = bv.get_battery_percentage() + self.assertGreaterEqual(percentage, 0.0) + self.assertLessEqual(percentage, 100.0) + + +class TestAveragingLogic(unittest.TestCase): + """Test that ADC readings are averaged.""" + + def setUp(self): + """Reset module state.""" + bv.clear_cache() + bv.init_adc(5, 0.00161) + + def tearDown(self): + """Clean up.""" + bv.clear_cache() + + def test_adc_read_averages_10_samples(self): + """Test that 10 samples are averaged.""" + bv.adc.set_read_value(2000) + bv.clear_cache() + + raw = bv.read_raw_adc(force_refresh=True) + + # Should be average of 10 reads + self.assertEqual(raw, 2000.0) + + +class TestDesktopMode(unittest.TestCase): + """Test behavior when ADC is not available (desktop mode).""" + + def setUp(self): + """Disable ADC.""" + bv.adc = None + bv.scale_factor = 0.00161 + + def test_read_raw_adc_returns_random_value(self): + """Test that desktop mode returns random ADC value.""" + raw = bv.read_raw_adc() + self.assertIsNotNone(raw) + self.assertTrue(raw > 0, f"Expected raw > 0, got {raw}") + self.assertTrue(raw < 4096, f"Expected raw < 4096, got {raw}") + + def test_read_battery_voltage_works_without_adc(self): + """Test that voltage reading works in desktop mode.""" + voltage = bv.read_battery_voltage() + self.assertIsNotNone(voltage) + self.assertTrue(voltage > 0, f"Expected voltage > 0, got {voltage}") + + def test_get_battery_percentage_works_without_adc(self): + """Test that percentage reading works in desktop mode.""" + percentage = bv.get_battery_percentage() + self.assertIsNotNone(percentage) + self.assertGreaterEqual(percentage, 0) + self.assertLessEqual(percentage, 100) + + +if __name__ == '__main__': + unittest.main() From 69386509e2285500fd1cb5dfd970abd1bab607eb Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 19:04:57 +0100 Subject: [PATCH 007/192] OSUpdate: improve wifi handling --- .../assets/osupdate.py | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) 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 3af8c3d..deceb59 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -128,12 +128,21 @@ def network_changed(self, online): elif self.current_state == UpdateState.IDLE or self.current_state == UpdateState.CHECKING_UPDATE: # Was checking for updates when network dropped self.set_state(UpdateState.WAITING_WIFI) + elif self.current_state == UpdateState.ERROR: + # Was in error state, might be network-related + # Update UI to show we're waiting for network + self.set_state(UpdateState.WAITING_WIFI) else: # Went online if self.current_state == UpdateState.IDLE or self.current_state == UpdateState.WAITING_WIFI: # Was waiting for network, now can check for updates self.set_state(UpdateState.CHECKING_UPDATE) self.schedule_show_update_info() + elif self.current_state == UpdateState.ERROR: + # Was in error state (possibly network error), retry now that network is back + print("OSUpdate: Retrying update check after network came back online") + self.set_state(UpdateState.CHECKING_UPDATE) + self.schedule_show_update_info() elif self.current_state == UpdateState.DOWNLOAD_PAUSED: # Download was paused, will auto-resume in download thread pass @@ -193,7 +202,7 @@ def show_update_info(self, timer=None): update_info["changelog"] ) except ValueError as e: - # JSON parsing or validation error + # JSON parsing or validation error (not network related) self.set_state(UpdateState.ERROR) self.status_label.set_text(self._get_user_friendly_error(e)) except RuntimeError as e: @@ -202,9 +211,15 @@ def show_update_info(self, timer=None): self.status_label.set_text(self._get_user_friendly_error(e)) except Exception as e: print(f"show_update_info got exception: {e}") - # Unexpected error - self.set_state(UpdateState.ERROR) - self.status_label.set_text(self._get_user_friendly_error(e)) + # Check if this is a network connectivity error + if self.update_downloader._is_network_error(e): + # Network not available - wait for it to come back + print("OSUpdate: Network error while checking for updates, waiting for WiFi") + self.set_state(UpdateState.WAITING_WIFI) + else: + # Other unexpected error + self.set_state(UpdateState.ERROR) + self.status_label.set_text(self._get_user_friendly_error(e)) def handle_update_info(self, version, download_url, changelog): self.download_update_url = download_url @@ -286,7 +301,7 @@ def update_with_lvgl(self, url): # Update succeeded - set boot partition and restart self.update_ui_threadsafe_if_foreground(self.status_label.set_text,"Update finished! Restarting...") # Small delay to show the message - time.sleep_ms(2000) + time.sleep(5) self.update_downloader.set_boot_partition_and_restart() return From 51e8977b12f598121a71a1d19c9bfe60df7ece75 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 19:49:03 +0100 Subject: [PATCH 008/192] battery_voltage: slow refresh for ADC2 because need to disable wifi --- internal_filesystem/lib/mpos/battery_voltage.py | 16 ++++++++++------ internal_filesystem/lib/mpos/ui/topmenu.py | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py index 9b943f6..bdc77fc 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -10,7 +10,8 @@ # Cache to reduce WiFi interruptions (ADC2 requires WiFi to be disabled) _cached_raw_adc = None _last_read_time = 0 -CACHE_DURATION_MS = 30000 # 30 seconds +CACHE_DURATION_ADC2_MS = 300000 # 300 seconds (expensive: requires WiFi disable) +CACHE_DURATION_ADC1_MS = 30000 # 30 seconds (cheaper: no WiFi interference) def _is_adc2_pin(pin): @@ -46,7 +47,7 @@ def init_adc(pinnr, sf): def read_raw_adc(force_refresh=False): """ - Read raw ADC value (0-4095) with caching. + Read raw ADC value (0-4095) with adaptive caching. On ESP32-S3 with ADC2, WiFi is temporarily disabled during reading. Raises RuntimeError if WifiService is busy (connecting/scanning) when using ADC2. @@ -69,16 +70,19 @@ def read_raw_adc(force_refresh=False): int(MIN_VOLTAGE / scale_factor), int(MAX_VOLTAGE / scale_factor) ) + # Check if this is an ADC2 pin (requires WiFi disable) + needs_wifi_disable = adc_pin is not None and _is_adc2_pin(adc_pin) + + # Use different cache durations based on cost + cache_duration = CACHE_DURATION_ADC2_MS if needs_wifi_disable else CACHE_DURATION_ADC1_MS + # Check cache current_time = time.ticks_ms() if not force_refresh and _cached_raw_adc is not None: age = time.ticks_diff(current_time, _last_read_time) - if age < CACHE_DURATION_MS: + if age < cache_duration: return _cached_raw_adc - # Check if this is an ADC2 pin (requires WiFi disable) - needs_wifi_disable = adc_pin is not None and _is_adc2_pin(adc_pin) - # Import WifiService only if needed WifiService = None if needs_wifi_disable: diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index 715047a..4516976 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -11,7 +11,7 @@ CLOCK_UPDATE_INTERVAL = 1000 # 10 or even 1 ms doesn't seem to change the framerate but 100ms is enough WIFI_ICON_UPDATE_INTERVAL = 1500 -BATTERY_ICON_UPDATE_INTERVAL = 30000 # not too often, because on fri3d_2024, this briefly disables wifi +BATTERY_ICON_UPDATE_INTERVAL = 15000 # not too often, but not too short, otherwise it takes a while to appear TEMPERATURE_UPDATE_INTERVAL = 2000 MEMFREE_UPDATE_INTERVAL = 5000 # not too frequent because there's a forced gc.collect() to give it a reliable value From 54b0aa04ac004b21341f45dff669bda545c82308 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 19:50:03 +0100 Subject: [PATCH 009/192] Update CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe84e8e..886a980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ 0.5.1 ===== - OSUpdate app: pause download when wifi is lost, resume when reconnected -- Fri3d Camp 2024 Badge: workaround ADC2+WiFi conflict by disconnecting WiFi to measure battery level +- Fri3d Camp 2024 Badge: workaround ADC2+WiFi conflict by temporarily disable WiFi to measure battery level 0.5.0 ===== From 60b68efd797225e1b5f38038b792d7e6f1c5af25 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 22:07:18 +0100 Subject: [PATCH 010/192] Fri3d Camp 2024 Badge: improve battery monitor calibration --- .../lib/mpos/battery_voltage.py | 34 +++++++++++-------- .../lib/mpos/board/fri3d_2024.py | 9 +++-- internal_filesystem/lib/mpos/board/linux.py | 7 +++- .../board/waveshare_esp32_s3_touch_lcd_2.py | 14 +++++++- internal_filesystem/lib/mpos/ui/topmenu.py | 1 + 5 files changed, 46 insertions(+), 19 deletions(-) diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py index bdc77fc..c292d49 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -4,7 +4,7 @@ MAX_VOLTAGE = 4.15 adc = None -scale_factor = 0 +conversion_func = None # Conversion function: ADC value -> voltage adc_pin = None # Cache to reduce WiFi interruptions (ADC2 requires WiFi to be disabled) @@ -19,7 +19,7 @@ def _is_adc2_pin(pin): return 11 <= pin <= 20 -def init_adc(pinnr, sf): +def init_adc(pinnr, adc_to_voltage_func): """ Initialize ADC for battery voltage monitoring. @@ -29,13 +29,16 @@ def init_adc(pinnr, sf): Args: pinnr: GPIO pin number - sf: Scale factor to convert raw ADC (0-4095) to battery voltage + adc_to_voltage_func: Conversion function that takes raw ADC value (0-4095) + and returns battery voltage in volts """ - global adc, scale_factor, adc_pin - scale_factor = sf + global adc, conversion_func, adc_pin + + conversion_func = adc_to_voltage_func adc_pin = pinnr + try: - print(f"Initializing ADC pin {pinnr} with scale_factor {sf}") + print(f"Initializing ADC pin {pinnr} with conversion function") if _is_adc2_pin(pinnr): print(f" WARNING: GPIO{pinnr} is on ADC2 - WiFi will be disabled during readings") from machine import ADC, Pin @@ -44,6 +47,9 @@ def init_adc(pinnr, sf): except Exception as e: print(f"Info: this platform has no ADC for measuring battery voltage: {e}") + initial_adc_value = read_raw_adc() + print("Reading ADC at init to fill cache: {initial_adc_value} => {read_battery_voltage(raw_adc_value=initial_adc_value)}V => {get_battery_percentage(raw_adc_value=initial_adc_value)}%") + def read_raw_adc(force_refresh=False): """ @@ -63,12 +69,10 @@ def read_raw_adc(force_refresh=False): """ global _cached_raw_adc, _last_read_time - # Desktop mode - return random value + # Desktop mode - return random value in typical ADC range if not adc: import random - return random.randint(1900, 2600) if scale_factor == 0 else random.randint( - int(MIN_VOLTAGE / scale_factor), int(MAX_VOLTAGE / scale_factor) - ) + return random.randint(1900, 2600) # Check if this is an ADC2 pin (requires WiFi disable) needs_wifi_disable = adc_pin is not None and _is_adc2_pin(adc_pin) @@ -115,7 +119,7 @@ def read_raw_adc(force_refresh=False): WifiService.temporarily_enable(was_connected) -def read_battery_voltage(force_refresh=False): +def read_battery_voltage(force_refresh=False, raw_adc_value=None): """ Read battery voltage in volts. @@ -125,19 +129,19 @@ def read_battery_voltage(force_refresh=False): Returns: float: Battery voltage in volts (clamped to 0-MAX_VOLTAGE) """ - raw = read_raw_adc(force_refresh) - voltage = raw * scale_factor + raw = raw_adc_value if raw_adc_value else read_raw_adc(force_refresh) + voltage = conversion_func(raw) if conversion_func else 0.0 return max(0.0, min(voltage, MAX_VOLTAGE)) -def get_battery_percentage(): +def get_battery_percentage(raw_adc_value=None): """ Get battery charge percentage. Returns: float: Battery percentage (0-100) """ - voltage = read_battery_voltage() + voltage = read_battery_voltage(raw_adc_value=raw_adc_value) percentage = (voltage - MIN_VOLTAGE) * 100.0 / (MAX_VOLTAGE - MIN_VOLTAGE) return max(0.0, min(100.0, percentage)) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index c79c652..276fd71 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -275,9 +275,14 @@ def keypad_read_cb(indev, data): 2269 is 3.831 """ def adc_to_voltage(adc_value): + """ + Convert raw ADC value to battery voltage using calibrated linear function. + Calibration data shows linear relationship: voltage = -0.0016237 * adc + 8.2035 + This is ~10x more accurate than simple scaling (error ~0.01V vs ~0.1V). + """ return (-0.0016237 * adc_value + 8.2035) -#mpos.battery_voltage.init_adc(13, adc_to_voltage) -mpos.battery_voltage.init_adc(13, 1/616) # simple scaling has an error of ~0.01V vs the adc_to_voltage() method + +mpos.battery_voltage.init_adc(13, adc_to_voltage) import mpos.sdcard mpos.sdcard.init(spi_bus, cs_pin=14) diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 54f2ab2..190a428 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -88,7 +88,12 @@ def catch_escape_key(indev, indev_data): # Simulated battery voltage ADC measuring import mpos.battery_voltage -mpos.battery_voltage.init_adc(999, (3.3 / 4095) * 2) + +def adc_to_voltage(adc_value): + """Convert simulated ADC value to voltage.""" + return adc_value * (3.3 / 4095) * 2 + +mpos.battery_voltage.init_adc(999, adc_to_voltage) 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 6f6b0cb..46342af 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 @@ -81,7 +81,19 @@ # Battery voltage ADC measuring import mpos.battery_voltage -mpos.battery_voltage.init_adc(5, 262 / 100000) + +def adc_to_voltage(adc_value): + """ + Convert raw ADC value to battery voltage. + Currently uses simple linear scaling: voltage = adc * 0.00262 + + This could be improved with calibration data similar to Fri3d board. + To calibrate: measure actual battery voltages and corresponding ADC readings, + then fit a linear or polynomial function. + """ + return adc_value * 0.00262 + +mpos.battery_voltage.init_adc(5, adc_to_voltage) # On the Waveshare ESP32-S3-Touch-LCD-2, the camera is hard-wired to power on, # so it needs a software power off to prevent it from staying hot all the time and quickly draining the battery. diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index 4516976..11dc807 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -154,6 +154,7 @@ def update_battery_icon(timer=None): # Percentage is not shown for now: #battery_label.set_text(f"{round(percent)}%") #battery_label.remove_flag(lv.obj.FLAG.HIDDEN) + update_battery_icon() # run it immediately instead of waiting for the timer def update_wifi_icon(timer): from mpos.net.wifi_service import WifiService From cccfe320d2d30c233432a40809531dd8754f3811 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 22:08:00 +0100 Subject: [PATCH 011/192] Fix tests/test_battery_voltage.py --- tests/test_battery_voltage.py | 60 ++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/tests/test_battery_voltage.py b/tests/test_battery_voltage.py index 2e7afe7..4b4be2b 100644 --- a/tests/test_battery_voltage.py +++ b/tests/test_battery_voltage.py @@ -119,30 +119,39 @@ class TestInitADC(unittest.TestCase): def setUp(self): """Reset module state.""" bv.adc = None - bv.scale_factor = 0 + bv.conversion_func = None bv.adc_pin = None def test_init_adc1_pin(self): """Test initializing with ADC1 pin.""" - bv.init_adc(5, 0.00161) + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + + bv.init_adc(5, adc_to_voltage) self.assertIsNotNone(bv.adc) - self.assertEqual(bv.scale_factor, 0.00161) + self.assertEqual(bv.conversion_func, adc_to_voltage) self.assertEqual(bv.adc_pin, 5) self.assertEqual(bv.adc._atten, MockADC.ATTN_11DB) def test_init_adc2_pin(self): """Test initializing with ADC2 pin (should warn but work).""" - bv.init_adc(13, 0.00197) + def adc_to_voltage(adc_value): + return adc_value * 0.00197 + + bv.init_adc(13, adc_to_voltage) self.assertIsNotNone(bv.adc) - self.assertEqual(bv.scale_factor, 0.00197) + self.assertIsNotNone(bv.conversion_func) self.assertEqual(bv.adc_pin, 13) - def test_scale_factor_stored(self): - """Test that scale factor is stored correctly.""" - bv.init_adc(5, 0.12345) - self.assertEqual(bv.scale_factor, 0.12345) + def test_conversion_func_stored(self): + """Test that conversion function is stored correctly.""" + def my_conversion(adc_value): + return adc_value * 0.12345 + + bv.init_adc(5, my_conversion) + self.assertEqual(bv.conversion_func, my_conversion) class TestCaching(unittest.TestCase): @@ -151,16 +160,18 @@ class TestCaching(unittest.TestCase): def setUp(self): """Reset module state.""" bv.clear_cache() - bv.init_adc(5, 0.00161) # Use ADC1 to avoid WiFi complexity + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) # Use ADC1 to avoid WiFi complexity MockWifiService.reset() def tearDown(self): """Clean up.""" bv.clear_cache() - def test_cache_miss_on_first_read(self): - """Test that first read doesn't use cache.""" - self.assertIsNone(bv._cached_raw_adc) + def test_cache_hit_on_first_read(self): + """Test that first read already has a cache (because of read during init) """ + self.assertIsNotNone(bv._cached_raw_adc) raw = bv.read_raw_adc() self.assertIsNotNone(bv._cached_raw_adc) self.assertEqual(raw, bv._cached_raw_adc) @@ -203,7 +214,9 @@ class TestADC1Reading(unittest.TestCase): def setUp(self): """Reset module state.""" bv.clear_cache() - bv.init_adc(5, 0.00161) # GPIO5 is ADC1 + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) # GPIO5 is ADC1 MockWifiService.reset() MockWifiService._connected = True @@ -240,7 +253,9 @@ class TestADC2Reading(unittest.TestCase): def setUp(self): """Reset module state.""" bv.clear_cache() - bv.init_adc(13, 0.00197) # GPIO13 is ADC2 + def adc_to_voltage(adc_value): + return adc_value * 0.00197 + bv.init_adc(13, adc_to_voltage) # GPIO13 is ADC2 MockWifiService.reset() def tearDown(self): @@ -308,7 +323,9 @@ class TestVoltageCalculations(unittest.TestCase): def setUp(self): """Reset module state.""" bv.clear_cache() - bv.init_adc(5, 0.00161) # ADC1 pin, scale factor for 2:1 divider + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) # ADC1 pin, scale factor for 2:1 divider bv.adc.set_read_value(2048) # Mid-range def tearDown(self): @@ -344,7 +361,8 @@ def test_get_battery_percentage_calculation(self): """Test percentage calculation.""" # Set voltage to mid-range between MIN and MAX mid_voltage = (bv.MIN_VOLTAGE + bv.MAX_VOLTAGE) / 2 - raw_adc = mid_voltage / bv.scale_factor + # Inverse of conversion function: if voltage = adc * 0.00161, then adc = voltage / 0.00161 + raw_adc = mid_voltage / 0.00161 bv.adc.set_read_value(int(raw_adc)) bv.clear_cache() @@ -374,7 +392,9 @@ class TestAveragingLogic(unittest.TestCase): def setUp(self): """Reset module state.""" bv.clear_cache() - bv.init_adc(5, 0.00161) + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.init_adc(5, adc_to_voltage) def tearDown(self): """Clean up.""" @@ -397,7 +417,9 @@ class TestDesktopMode(unittest.TestCase): def setUp(self): """Disable ADC.""" bv.adc = None - bv.scale_factor = 0.00161 + def adc_to_voltage(adc_value): + return adc_value * 0.00161 + bv.conversion_func = adc_to_voltage def test_read_raw_adc_returns_random_value(self): """Test that desktop mode returns random ADC value.""" From 4732e4f80f8f79c874b3125d064f16025b69a6ff Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 22:08:35 +0100 Subject: [PATCH 012/192] scripts/install.sh: disable wifi first --- scripts/install.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 9946e89..7dd1511 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -15,6 +15,10 @@ mpremote=$(readlink -f "$mydir/../lvgl_micropython/lib/micropython/tools/mpremot pushd internal_filesystem/ +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()" +sleep 2 + if [ ! -z "$appname" ]; then echo "Installing one app: $appname" appdir="apps/$appname/" From 142c23256ce0516f1261ced531389a3df57fa792 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 22:12:27 +0100 Subject: [PATCH 013/192] AppStore app: remove unnecessary scrollbar over publisher's name --- CHANGELOG.md | 2 ++ .../builtin/apps/com.micropythonos.appstore/assets/appstore.py | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 886a980..75cde3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ===== - OSUpdate app: pause download when wifi is lost, resume when reconnected - Fri3d Camp 2024 Badge: workaround ADC2+WiFi conflict by temporarily disable WiFi to measure battery level +- Fri3d Camp 2024 Badge: improve battery monitor calibration +- AppStore app: remove unnecessary scrollbar over publisher's name 0.5.0 ===== 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 3efecac..ff1674d 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -206,6 +206,7 @@ def onCreate(self): detail_cont.set_style_pad_all(0, 0) detail_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) detail_cont.set_size(lv.pct(75), lv.SIZE_CONTENT) + detail_cont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) name_label = lv.label(detail_cont) name_label.set_text(app.name) name_label.set_style_text_font(lv.font_montserrat_24, 0) From fa80b7ce133163aef781cc2bbe0fe56d1aa5386e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 22:12:44 +0100 Subject: [PATCH 014/192] Add showbattery app for testing --- .../META-INF/MANIFEST.JSON | 24 +++++ .../assets/hello.py | 87 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON new file mode 100644 index 0000000..63fbca9 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "ShowBattery", +"publisher": "MicroPythonOS", +"short_description": "Minimal app", +"long_description": "Demonstrates the simplest app.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.helloworld/icons/com.micropythonos.helloworld_0.0.2_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.helloworld/mpks/com.micropythonos.helloworld_0.0.2.mpk", +"fullname": "com.micropythonos.showbattery", +"version": "0.0.2", +"category": "development", +"activities": [ + { + "entrypoint": "assets/hello.py", + "classname": "Hello", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py b/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py new file mode 100644 index 0000000..7e0ac09 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py @@ -0,0 +1,87 @@ +""" +8:44 4.15V +8:46 4.13V + +import time +v = mpos.battery_voltage.read_battery_voltage() +percent = mpos.battery_voltage.get_battery_percentage() +text = f"{time.localtime()}: {v}V is {percent}%" +text + +from machine import ADC, Pin # do this inside the try because it will fail on desktop +adc = ADC(Pin(13)) +# Set ADC to 11dB attenuation for 0–3.3V range (common for ESP32) +adc.atten(ADC.ATTN_11DB) +adc.read() + +scale factor 0.002 is (4.15 / 4095) * 2 +BUT shows 4.90 instead of 4.13 +BUT shows 5.018 instead of 4.65 (raw ADC read: 2366) +SO substract 0.77 +# at 2366 + +2506 is 4.71 (not 4.03) +scale factor 0.002 is (4.15 / 4095) * 2 +BUT shows 4.90 instead of 4.13 +BUT shows 5.018 instead of 4.65 (raw ADC read: 2366) +SO substract 0.77 +# at 2366 + +USB power: +2506 is 4.71 (not 4.03) +2498 +2491 + +battery power: +2482 is 4.180 +2470 is 4.170 +2457 is 4.147 +2433 is 4.109 +2429 is 4.102 +2393 is 4.044 +2369 is 4.000 +2343 is 3.957 +2319 is 3.916 +2269 is 3.831 + +""" + +import lvgl as lv +import time + +import mpos.battery_voltage +from mpos.apps import Activity + +class Hello(Activity): + + refresh_timer = None + + # Widgets: + raw_label = None + + def onCreate(self): + s = lv.obj() + self.raw_label = lv.label(s) + self.raw_label.set_text("starting...") + self.raw_label.center() + self.setContentView(s) + + def onResume(self, screen): + super().onResume(screen) + + def update_bat(timer): + #global l + r = mpos.battery_voltage.read_raw_adc() + v = mpos.battery_voltage.read_battery_voltage() + percent = mpos.battery_voltage.get_battery_percentage() + text = f"{time.localtime()}\n{r}\n{v}V\n{percent}%" + #text = f"{time.localtime()}: {r}" + print(text) + self.update_ui_threadsafe_if_foreground(self.raw_label.set_text, text) + + self.refresh_timer = lv.timer_create(update_bat,1000,None) #.set_repeat_count(10) + + def onPause(self, screen): + super().onPause(screen) + if self.refresh_timer: + self.refresh_timer.delete() From 1ab4970dc75c558159aa7da0c1f0f5da66413e7d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 22:54:26 +0100 Subject: [PATCH 015/192] Work towards changing camera resolution --- c_mpos/src/webcam.c | 140 +++++++++-- .../META-INF/MANIFEST.JSON | 5 + .../assets/camera_app.py | 236 +++++++++++++++++- .../com.micropythonos.wifi/assets/wifi.py | 2 +- 4 files changed, 346 insertions(+), 37 deletions(-) diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index 4ae1599..8b0e919 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -30,17 +30,24 @@ typedef struct _webcam_obj_t { int frame_count; unsigned char *gray_buffer; // For grayscale uint16_t *rgb565_buffer; // For RGB565 + int input_width; // Webcam capture width (from V4L2) + int input_height; // Webcam capture height (from V4L2) + int output_width; // Configurable output width (default OUTPUT_WIDTH) + int output_height; // Configurable output height (default OUTPUT_HEIGHT) } webcam_obj_t; -static void yuyv_to_rgb565_240x240(unsigned char *yuyv, uint16_t *rgb565, int in_width, int in_height) { - int crop_size = 480; +static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int in_width, int in_height, int out_width, int out_height) { + // Crop to largest square that fits in the input frame + int crop_size = (in_width < in_height) ? in_width : in_height; int crop_x_offset = (in_width - crop_size) / 2; int crop_y_offset = (in_height - crop_size) / 2; - float x_ratio = (float)crop_size / OUTPUT_WIDTH; - float y_ratio = (float)crop_size / OUTPUT_HEIGHT; - for (int y = 0; y < OUTPUT_HEIGHT; y++) { - for (int x = 0; x < OUTPUT_WIDTH; x++) { + // Calculate scaling ratios + float x_ratio = (float)crop_size / out_width; + float y_ratio = (float)crop_size / out_height; + + for (int y = 0; y < out_height; y++) { + for (int x = 0; x < out_width; x++) { int src_x = (int)(x * x_ratio) + crop_x_offset; int src_y = (int)(y * y_ratio) + crop_y_offset; int src_index = (src_y * in_width + src_x) * 2; @@ -65,24 +72,27 @@ static void yuyv_to_rgb565_240x240(unsigned char *yuyv, uint16_t *rgb565, int in uint16_t g6 = (g >> 2) & 0x3F; uint16_t b5 = (b >> 3) & 0x1F; - rgb565[y * OUTPUT_WIDTH + x] = (r5 << 11) | (g6 << 5) | b5; + rgb565[y * out_width + x] = (r5 << 11) | (g6 << 5) | b5; } } } -static void yuyv_to_grayscale_240x240(unsigned char *yuyv, unsigned char *gray, int in_width, int in_height) { - int crop_size = 480; +static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, int in_width, int in_height, int out_width, int out_height) { + // Crop to largest square that fits in the input frame + int crop_size = (in_width < in_height) ? in_width : in_height; int crop_x_offset = (in_width - crop_size) / 2; int crop_y_offset = (in_height - crop_size) / 2; - float x_ratio = (float)crop_size / OUTPUT_WIDTH; - float y_ratio = (float)crop_size / OUTPUT_HEIGHT; - for (int y = 0; y < OUTPUT_HEIGHT; y++) { - for (int x = 0; x < OUTPUT_WIDTH; x++) { + // Calculate scaling ratios + float x_ratio = (float)crop_size / out_width; + float y_ratio = (float)crop_size / out_height; + + for (int y = 0; y < out_height; y++) { + for (int x = 0; x < out_width; x++) { int src_x = (int)(x * x_ratio) + crop_x_offset; int src_y = (int)(y * y_ratio) + crop_y_offset; int src_index = (src_y * in_width + src_x) * 2; - gray[y * OUTPUT_WIDTH + x] = yuyv[src_index]; + gray[y * out_width + x] = yuyv[src_index]; } } } @@ -174,8 +184,22 @@ static int init_webcam(webcam_obj_t *self, const char *device) { } self->frame_count = 0; - self->gray_buffer = (unsigned char *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(unsigned char)); - self->rgb565_buffer = (uint16_t *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(uint16_t)); + + // Store the input dimensions from V4L2 format + self->input_width = WIDTH; + self->input_height = HEIGHT; + + // Initialize output dimensions with defaults if not already set + if (self->output_width == 0) self->output_width = OUTPUT_WIDTH; + if (self->output_height == 0) self->output_height = OUTPUT_HEIGHT; + + WEBCAM_DEBUG_PRINT("Webcam initialized: input %dx%d, output %dx%d\n", + self->input_width, self->input_height, + self->output_width, self->output_height); + + // Allocate buffers with configured output dimensions + self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); + self->rgb565_buffer = (uint16_t *)malloc(self->output_width * self->output_height * sizeof(uint16_t)); if (!self->gray_buffer || !self->rgb565_buffer) { WEBCAM_DEBUG_PRINT("Cannot allocate buffers: %s\n", strerror(errno)); free(self->gray_buffer); @@ -227,13 +251,13 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { } if (!self->gray_buffer) { - self->gray_buffer = (unsigned char *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(unsigned char)); + self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); if (!self->gray_buffer) { mp_raise_OSError(MP_ENOMEM); } } if (!self->rgb565_buffer) { - self->rgb565_buffer = (uint16_t *)malloc(OUTPUT_WIDTH * OUTPUT_HEIGHT * sizeof(uint16_t)); + self->rgb565_buffer = (uint16_t *)malloc(self->output_width * self->output_height * sizeof(uint16_t)); if (!self->rgb565_buffer) { mp_raise_OSError(MP_ENOMEM); } @@ -241,22 +265,26 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { const char *fmt = mp_obj_str_get_str(format); if (strcmp(fmt, "grayscale") == 0) { - yuyv_to_grayscale_240x240(self->buffers[buf.index], self->gray_buffer, WIDTH, HEIGHT); + yuyv_to_grayscale(self->buffers[buf.index], self->gray_buffer, + self->input_width, self->input_height, + self->output_width, self->output_height); // char filename[32]; // snprintf(filename, sizeof(filename), "frame_%03d.raw", self->frame_count++); - // save_raw(filename, self->gray_buffer, OUTPUT_WIDTH, OUTPUT_HEIGHT); - mp_obj_t result = mp_obj_new_memoryview('b', OUTPUT_WIDTH * OUTPUT_HEIGHT, self->gray_buffer); + // save_raw(filename, self->gray_buffer, self->output_width, self->output_height); + mp_obj_t result = mp_obj_new_memoryview('b', self->output_width * self->output_height, self->gray_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); } return result; } else { - yuyv_to_rgb565_240x240(self->buffers[buf.index], self->rgb565_buffer, WIDTH, HEIGHT); + yuyv_to_rgb565(self->buffers[buf.index], self->rgb565_buffer, + self->input_width, self->input_height, + self->output_width, self->output_height); // char filename[32]; // snprintf(filename, sizeof(filename), "frame_%03d.rgb565", self->frame_count++); - // save_raw_rgb565(filename, self->rgb565_buffer, OUTPUT_WIDTH, OUTPUT_HEIGHT); - mp_obj_t result = mp_obj_new_memoryview('b', OUTPUT_WIDTH * OUTPUT_HEIGHT * 2, self->rgb565_buffer); + // save_raw_rgb565(filename, self->rgb565_buffer, self->output_width, self->output_height); + mp_obj_t result = mp_obj_new_memoryview('b', self->output_width * self->output_height * 2, self->rgb565_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); @@ -277,6 +305,10 @@ static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *args) { self->fd = -1; self->gray_buffer = NULL; self->rgb565_buffer = NULL; + self->input_width = 0; // Will be set from V4L2 format in init_webcam + self->input_height = 0; // Will be set from V4L2 format in init_webcam + self->output_width = 0; // Will use default OUTPUT_WIDTH in init_webcam + self->output_height = 0; // Will use default OUTPUT_HEIGHT in init_webcam int res = init_webcam(self, device); if (res < 0) { @@ -309,6 +341,65 @@ static mp_obj_t webcam_capture_frame(mp_obj_t self_in, mp_obj_t format) { } MP_DEFINE_CONST_FUN_OBJ_2(webcam_capture_frame_obj, webcam_capture_frame); +static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + // NOTE: This function only changes OUTPUT resolution (what Python receives). + // The INPUT resolution (what the webcam captures from V4L2) remains fixed at 640x480. + // The conversion functions will crop/scale from input to output resolution. + // TODO: Add support for changing input resolution (requires V4L2 reinit) + + enum { ARG_self, ARG_output_width, ARG_output_height }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_self, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_output_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_output_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + }; + + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + webcam_obj_t *self = MP_OBJ_TO_PTR(args[ARG_self].u_obj); + + // Get new dimensions (keep current if not specified) + int new_width = args[ARG_output_width].u_int; + int new_height = args[ARG_output_height].u_int; + + if (new_width == 0) new_width = self->output_width; + if (new_height == 0) new_height = self->output_height; + + // Validate dimensions + if (new_width <= 0 || new_height <= 0 || new_width > 1920 || new_height > 1920) { + mp_raise_ValueError(MP_ERROR_TEXT("Invalid output dimensions")); + } + + // If dimensions changed, reallocate buffers + if (new_width != self->output_width || new_height != self->output_height) { + // Free old buffers + free(self->gray_buffer); + free(self->rgb565_buffer); + + // Update dimensions + self->output_width = new_width; + self->output_height = new_height; + + // Allocate new buffers + self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); + self->rgb565_buffer = (uint16_t *)malloc(self->output_width * self->output_height * sizeof(uint16_t)); + + if (!self->gray_buffer || !self->rgb565_buffer) { + free(self->gray_buffer); + free(self->rgb565_buffer); + self->gray_buffer = NULL; + self->rgb565_buffer = NULL; + mp_raise_OSError(MP_ENOMEM); + } + + WEBCAM_DEBUG_PRINT("Webcam reconfigured to %dx%d\n", self->output_width, self->output_height); + } + + return mp_const_none; +} +MP_DEFINE_CONST_FUN_OBJ_KW(webcam_reconfigure_obj, 1, webcam_reconfigure); + static const mp_obj_type_t webcam_type = { { &mp_type_type }, .name = MP_QSTR_Webcam, @@ -321,6 +412,7 @@ static const mp_rom_map_elem_t mp_module_webcam_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_capture_frame), MP_ROM_PTR(&webcam_capture_frame_obj) }, { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&webcam_deinit_obj) }, { MP_ROM_QSTR(MP_QSTR_free_buffer), MP_ROM_PTR(&webcam_free_buffer_obj) }, + { MP_ROM_QSTR(MP_QSTR_reconfigure), MP_ROM_PTR(&webcam_reconfigure_obj) }, }; static MP_DEFINE_CONST_DICT(mp_module_webcam_globals, mp_module_webcam_globals_table); diff --git a/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON index 360dd3c..1a2cde4 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON @@ -22,6 +22,11 @@ "category": "default" } ] + }, + { + "entrypoint": "assets/camera_app.py", + "classname": "CameraSettingsActivity", + "intent_filters": [] } ] } diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 3d9eb8b..6890f0f 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -13,6 +13,8 @@ print(f"Info: could not import webcam module: {e}") from mpos.apps import Activity +from mpos.config import SharedPreferences +from mpos.content.intent import Intent import mpos.time class CameraApp(Activity): @@ -20,6 +22,9 @@ class CameraApp(Activity): width = 240 height = 240 + # Resolution preferences + prefs = None + status_label_text = "No camera found." status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and QR size (4-12cm). Ensure proper lighting." status_label_text_found = "Decoding QR..." @@ -42,7 +47,24 @@ class CameraApp(Activity): status_label = None status_label_cont = None + def load_resolution_preference(self): + """Load resolution preference from SharedPreferences and update width/height.""" + if not self.prefs: + self.prefs = SharedPreferences("com.micropythonos.camera") + + resolution_str = self.prefs.get_string("resolution", "240x240") + try: + width_str, height_str = resolution_str.split('x') + self.width = int(width_str) + self.height = int(height_str) + print(f"Camera resolution loaded: {self.width}x{self.height}") + except Exception as e: + print(f"Error parsing resolution '{resolution_str}': {e}, using default 240x240") + self.width = 240 + self.height = 240 + def onCreate(self): + self.load_resolution_preference() self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") main_screen = lv.obj() main_screen.set_style_pad_all(0, 0) @@ -56,6 +78,16 @@ def onCreate(self): close_label.set_text(lv.SYMBOL.CLOSE) close_label.center() close_button.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) + + # Settings button + settings_button = lv.button(main_screen) + settings_button.set_size(60,60) + settings_button.align(lv.ALIGN.TOP_LEFT, 0, 0) + settings_label = lv.label(settings_button) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.center() + settings_button.add_event_cb(lambda e: self.open_settings(),lv.EVENT.CLICKED,None) + self.snap_button = lv.button(main_screen) self.snap_button.set_size(60, 60) self.snap_button.align(lv.ALIGN.RIGHT_MID, 0, 0) @@ -103,10 +135,10 @@ def onCreate(self): self.setContentView(main_screen) def onResume(self, screen): - self.cam = init_internal_cam() + self.cam = init_internal_cam(self.width, self.height) if not self.cam: # try again because the manual i2c poweroff leaves it in a bad state - self.cam = init_internal_cam() + self.cam = init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees else: @@ -114,6 +146,9 @@ def onResume(self, screen): try: self.cam = webcam.init("/dev/video0") self.use_webcam = True + # Reconfigure webcam to use saved resolution + print(f"Reconfiguring webcam to {self.width}x{self.height}") + webcam.reconfigure(self.cam, output_width=self.width, output_height=self.height) except Exception as e: print(f"camera app: webcam exception: {e}") if self.cam: @@ -241,7 +276,42 @@ def qr_button_click(self, e): self.start_qr_decoding() else: self.stop_qr_decoding() - + + def open_settings(self): + """Launch the camera settings activity.""" + intent = Intent(activity_class=CameraSettingsActivity) + self.startActivityForResult(intent, self.handle_settings_result) + + def handle_settings_result(self, result): + """Handle result from settings activity.""" + if result.get("result_code") == True: + print("Settings changed, reloading resolution...") + # Reload resolution preference + self.load_resolution_preference() + + # Recreate image descriptor with new dimensions + self.image_dsc["header"]["w"] = self.width + self.image_dsc["header"]["h"] = self.height + self.image_dsc["header"]["stride"] = self.width * 2 + self.image_dsc["data_size"] = self.width * self.height * 2 + + # Reconfigure camera if active + if self.cam: + if self.use_webcam: + print(f"Reconfiguring webcam to {self.width}x{self.height}") + webcam.reconfigure(self.cam, output_width=self.width, output_height=self.height) + else: + # For internal camera, need to reinitialize + print(f"Reinitializing internal camera to {self.width}x{self.height}") + if self.capture_timer: + self.capture_timer.delete() + self.cam.deinit() + self.cam = init_internal_cam(self.width, self.height) + if self.cam: + self.capture_timer = lv.timer_create(self.try_capture, 100, None) + + self.set_image_size() + def try_capture(self, event): #print("capturing camera frame") try: @@ -262,9 +332,36 @@ def try_capture(self, event): # Non-class functions: -def init_internal_cam(): +def init_internal_cam(width=240, height=240): + """Initialize internal camera with specified resolution.""" try: from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling + + # Map resolution to FrameSize enum + # Format: (width, height): FrameSize + resolution_map = { + (96, 96): FrameSize.R96X96, + (160, 120): FrameSize.QQVGA, + (128, 128): FrameSize.R128X128, + (176, 144): FrameSize.QCIF, + (240, 176): FrameSize.HQVGA, + (240, 240): FrameSize.R240X240, + (320, 240): FrameSize.QVGA, + (320, 320): FrameSize.R320X320, + (400, 296): FrameSize.CIF, + (480, 320): FrameSize.HVGA, + (640, 480): FrameSize.VGA, + (800, 600): FrameSize.SVGA, + (1024, 768): FrameSize.XGA, + (1280, 720): FrameSize.HD, + (1280, 1024): FrameSize.SXGA, + (1600, 1200): FrameSize.UXGA, + (1920, 1080): FrameSize.FHD, + } + + frame_size = resolution_map.get((width, height), FrameSize.R240X240) + print(f"init_internal_cam: Using FrameSize for {width}x{height}") + cam = Camera( data_pins=[12,13,15,11,14,10,7,2], vsync_pin=6, @@ -277,15 +374,9 @@ def init_internal_cam(): powerdown_pin=-1, reset_pin=-1, pixel_format=PixelFormat.RGB565, - #pixel_format=PixelFormat.GRAYSCALE, - frame_size=FrameSize.R240X240, - grab_mode=GrabMode.LATEST + frame_size=frame_size, + grab_mode=GrabMode.LATEST ) - #cam.init() automatically done when creating the Camera() - #cam.reconfigure(frame_size=FrameSize.HVGA) - #frame_size=FrameSize.HVGA, # 480x320 - #frame_size=FrameSize.QVGA, # 320x240 - #frame_size=FrameSize.QQVGA # 160x120 cam.set_vflip(True) return cam except Exception as e: @@ -311,3 +402,124 @@ def remove_bom(buffer): if buffer.startswith(bom): return buffer[3:] return buffer + + +class CameraSettingsActivity(Activity): + """Settings activity for camera resolution configuration.""" + + # Resolution options for desktop/webcam + WEBCAM_RESOLUTIONS = [ + ("160x120", "160x120"), + ("240x240", "240x240"), # Default + ("320x240", "320x240"), + ("480x320", "480x320"), + ("640x480", "640x480"), + ("800x600", "800x600"), + ("1024x768", "1024x768"), + ("1280x720", "1280x720"), + ] + + # Resolution options for internal camera (ESP32) - all available FrameSize options + ESP32_RESOLUTIONS = [ + ("96x96", "96x96"), + ("160x120", "160x120"), + ("128x128", "128x128"), + ("176x144", "176x144"), + ("240x176", "240x176"), + ("240x240", "240x240"), # Default + ("320x240", "320x240"), + ("320x320", "320x320"), + ("400x296", "400x296"), + ("480x320", "480x320"), + ("640x480", "640x480"), + ("800x600", "800x600"), + ("1024x768", "1024x768"), + ("1280x720", "1280x720"), + ("1280x1024", "1280x1024"), + ("1600x1200", "1600x1200"), + ("1920x1080", "1920x1080"), + ] + + dropdown = None + current_resolution = None + + def onCreate(self): + # Load preferences + prefs = SharedPreferences("com.micropythonos.camera") + self.current_resolution = prefs.get_string("resolution", "240x240") + + # Create main screen + screen = lv.obj() + screen.set_size(lv.pct(100), lv.pct(100)) + screen.set_style_pad_all(10, 0) + + # Title + title = lv.label(screen) + title.set_text("Camera Settings") + title.align(lv.ALIGN.TOP_MID, 0, 10) + + # Resolution label + resolution_label = lv.label(screen) + resolution_label.set_text("Resolution:") + resolution_label.align(lv.ALIGN.TOP_LEFT, 0, 50) + + # Detect if we're on desktop or ESP32 based on available modules + try: + import webcam + resolutions = self.WEBCAM_RESOLUTIONS + print("Using webcam resolutions") + except: + resolutions = self.ESP32_RESOLUTIONS + print("Using ESP32 camera resolutions") + + # Create dropdown + self.dropdown = lv.dropdown(screen) + self.dropdown.set_size(200, 40) + self.dropdown.align(lv.ALIGN.TOP_LEFT, 0, 80) + + # Build dropdown options string + options_str = "\n".join([label for label, _ in resolutions]) + self.dropdown.set_options(options_str) + + # Set current selection + for idx, (label, value) in enumerate(resolutions): + if value == self.current_resolution: + self.dropdown.set_selected(idx) + break + + # Save button + save_button = lv.button(screen) + save_button.set_size(100, 50) + save_button.align(lv.ALIGN.BOTTOM_MID, -60, -10) + save_button.add_event_cb(lambda e: self.save_and_close(resolutions), lv.EVENT.CLICKED, None) + save_label = lv.label(save_button) + save_label.set_text("Save") + save_label.center() + + # Cancel button + cancel_button = lv.button(screen) + cancel_button.set_size(100, 50) + cancel_button.align(lv.ALIGN.BOTTOM_MID, 60, -10) + cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + cancel_label = lv.label(cancel_button) + cancel_label.set_text("Cancel") + cancel_label.center() + + self.setContentView(screen) + + def save_and_close(self, resolutions): + """Save selected resolution and return result.""" + selected_idx = self.dropdown.get_selected() + _, new_resolution = resolutions[selected_idx] + + # Save to preferences + prefs = SharedPreferences("com.micropythonos.camera") + editor = prefs.edit() + editor.put_string("resolution", new_resolution) + editor.commit() + + print(f"Camera resolution saved: {new_resolution}") + + # Return success result + self.setResult(True, {"resolution": new_resolution}) + self.finish() 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 3b98029..9e19357 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py @@ -160,7 +160,7 @@ def select_ssid_cb(self,ssid): def password_page_result_cb(self, result): print(f"PasswordPage finished, result: {result}") - if result.get("result_code"): + if result.get("result_code") is True: data = result.get("data") if data: self.start_attempt_connecting(data.get("ssid"), data.get("password")) From 7e4585e91e57671cb708c6f89338c3a65058fa5e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 24 Nov 2025 23:35:50 +0100 Subject: [PATCH 016/192] Camera: support more resolutions It's a bit unstable, as it crashes if the settings button is clicked after startup, but not when closing and then re-opening. Seems to work for 640x480, including QR decoding. --- c_mpos/src/webcam.c | 200 ++++++++++++-- .../assets/camera_app.py | 67 +++-- tests/analyze_screenshot.py | 150 ++++++++++ tests/test_graphical_camera_settings.py | 258 ++++++++++++++++++ 4 files changed, 633 insertions(+), 42 deletions(-) create mode 100755 tests/analyze_screenshot.py create mode 100644 tests/test_graphical_camera_settings.py diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index 8b0e919..ca06773 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -50,12 +50,25 @@ static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int in_width, for (int x = 0; x < out_width; x++) { int src_x = (int)(x * x_ratio) + crop_x_offset; int src_y = (int)(y * y_ratio) + crop_y_offset; - int src_index = (src_y * in_width + src_x) * 2; - - int y0 = yuyv[src_index]; - int u = yuyv[src_index + 1]; - int v = yuyv[src_index + 3]; + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) + // Ensure we're aligned to even pixel boundary + int src_x_even = (src_x / 2) * 2; + int src_base_index = (src_y * in_width + src_x_even) * 2; + + // Extract Y, U, V values + int y0; + if (src_x % 2 == 0) { + // Even pixel: use Y0 + y0 = yuyv[src_base_index]; + } else { + // Odd pixel: use Y1 + y0 = yuyv[src_base_index + 2]; + } + int u = yuyv[src_base_index + 1]; + int v = yuyv[src_base_index + 3]; + + // YUV to RGB conversion (ITU-R BT.601) int c = y0 - 16; int d = u - 128; int e = v - 128; @@ -64,10 +77,12 @@ static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int in_width, int g = (298 * c - 100 * d - 208 * e + 128) >> 8; int b = (298 * c + 516 * d + 128) >> 8; + // Clamp to valid range r = r < 0 ? 0 : (r > 255 ? 255 : r); g = g < 0 ? 0 : (g > 255 ? 255 : g); b = b < 0 ? 0 : (b > 255 ? 255 : b); + // Convert to RGB565 uint16_t r5 = (r >> 3) & 0x1F; uint16_t g6 = (g >> 2) & 0x3F; uint16_t b5 = (b >> 3) & 0x1F; @@ -91,8 +106,23 @@ static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, int in_w for (int x = 0; x < out_width; x++) { int src_x = (int)(x * x_ratio) + crop_x_offset; int src_y = (int)(y * y_ratio) + crop_y_offset; - int src_index = (src_y * in_width + src_x) * 2; - gray[y * out_width + x] = yuyv[src_index]; + + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) + // Ensure we're aligned to even pixel boundary + int src_x_even = (src_x / 2) * 2; + int src_base_index = (src_y * in_width + src_x_even) * 2; + + // Extract Y value + unsigned char y_val; + if (src_x % 2 == 0) { + // Even pixel: use Y0 + y_val = yuyv[src_base_index]; + } else { + // Odd pixel: use Y1 + y_val = yuyv[src_base_index + 2]; + } + + gray[y * out_width + x] = y_val; } } } @@ -342,14 +372,25 @@ static mp_obj_t webcam_capture_frame(mp_obj_t self_in, mp_obj_t format) { MP_DEFINE_CONST_FUN_OBJ_2(webcam_capture_frame_obj, webcam_capture_frame); static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { - // NOTE: This function only changes OUTPUT resolution (what Python receives). - // The INPUT resolution (what the webcam captures from V4L2) remains fixed at 640x480. - // The conversion functions will crop/scale from input to output resolution. - // TODO: Add support for changing input resolution (requires V4L2 reinit) - - enum { ARG_self, ARG_output_width, ARG_output_height }; + /* + * Reconfigure webcam resolution. + * + * Supports changing both INPUT resolution (V4L2 capture format) and + * OUTPUT resolution (conversion buffers). If input resolution changes, + * this will stop streaming, reconfigure V4L2, and restart streaming. + * + * Parameters: + * input_width, input_height: V4L2 capture resolution (optional) + * output_width, output_height: Output buffer resolution (optional) + * + * If not specified, dimensions remain unchanged. + */ + + enum { ARG_self, ARG_input_width, ARG_input_height, ARG_output_width, ARG_output_height }; static const mp_arg_t allowed_args[] = { { MP_QSTR_self, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_input_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_input_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, { MP_QSTR_output_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, { MP_QSTR_output_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, }; @@ -360,26 +401,135 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m webcam_obj_t *self = MP_OBJ_TO_PTR(args[ARG_self].u_obj); // Get new dimensions (keep current if not specified) - int new_width = args[ARG_output_width].u_int; - int new_height = args[ARG_output_height].u_int; + int new_input_width = args[ARG_input_width].u_int; + int new_input_height = args[ARG_input_height].u_int; + int new_output_width = args[ARG_output_width].u_int; + int new_output_height = args[ARG_output_height].u_int; - if (new_width == 0) new_width = self->output_width; - if (new_height == 0) new_height = self->output_height; + if (new_input_width == 0) new_input_width = self->input_width; + if (new_input_height == 0) new_input_height = self->input_height; + if (new_output_width == 0) new_output_width = self->output_width; + if (new_output_height == 0) new_output_height = self->output_height; // Validate dimensions - if (new_width <= 0 || new_height <= 0 || new_width > 1920 || new_height > 1920) { + if (new_input_width <= 0 || new_input_height <= 0 || new_input_width > 1920 || new_input_height > 1920) { + mp_raise_ValueError(MP_ERROR_TEXT("Invalid input dimensions")); + } + if (new_output_width <= 0 || new_output_height <= 0 || new_output_width > 1920 || new_output_height > 1920) { mp_raise_ValueError(MP_ERROR_TEXT("Invalid output dimensions")); } - // If dimensions changed, reallocate buffers - if (new_width != self->output_width || new_height != self->output_height) { + bool input_changed = (new_input_width != self->input_width || new_input_height != self->input_height); + bool output_changed = (new_output_width != self->output_width || new_output_height != self->output_height); + + // If input resolution changed, need to reconfigure V4L2 + if (input_changed) { + WEBCAM_DEBUG_PRINT("Reconfiguring V4L2: %dx%d -> %dx%d\n", + self->input_width, self->input_height, + new_input_width, new_input_height); + + // 1. Stop streaming + enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + if (ioctl(self->fd, VIDIOC_STREAMOFF, &type) < 0) { + WEBCAM_DEBUG_PRINT("STREAMOFF failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + // 2. Unmap old buffers + for (int i = 0; i < NUM_BUFFERS; i++) { + if (self->buffers[i] != MAP_FAILED && self->buffers[i] != NULL) { + munmap(self->buffers[i], self->buffer_length); + self->buffers[i] = MAP_FAILED; + } + } + + // 3. Set new V4L2 format + struct v4l2_format fmt = {0}; + fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + fmt.fmt.pix.width = new_input_width; + fmt.fmt.pix.height = new_input_height; + fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; + fmt.fmt.pix.field = V4L2_FIELD_ANY; + + if (ioctl(self->fd, VIDIOC_S_FMT, &fmt) < 0) { + WEBCAM_DEBUG_PRINT("S_FMT failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + // Verify format was set (driver may adjust dimensions) + if (fmt.fmt.pix.width != new_input_width || fmt.fmt.pix.height != new_input_height) { + WEBCAM_DEBUG_PRINT("Warning: Driver adjusted format to %dx%d\n", + fmt.fmt.pix.width, fmt.fmt.pix.height); + new_input_width = fmt.fmt.pix.width; + new_input_height = fmt.fmt.pix.height; + } + + // 4. Request new buffers + struct v4l2_requestbuffers req = {0}; + req.count = NUM_BUFFERS; + req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + req.memory = V4L2_MEMORY_MMAP; + + if (ioctl(self->fd, VIDIOC_REQBUFS, &req) < 0) { + WEBCAM_DEBUG_PRINT("REQBUFS failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + // 5. Map new buffers + for (int i = 0; i < NUM_BUFFERS; i++) { + struct v4l2_buffer buf = {0}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = i; + + if (ioctl(self->fd, VIDIOC_QUERYBUF, &buf) < 0) { + WEBCAM_DEBUG_PRINT("QUERYBUF failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + self->buffer_length = buf.length; + self->buffers[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, + MAP_SHARED, self->fd, buf.m.offset); + + if (self->buffers[i] == MAP_FAILED) { + WEBCAM_DEBUG_PRINT("mmap failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + } + + // 6. Queue buffers + for (int i = 0; i < NUM_BUFFERS; i++) { + struct v4l2_buffer buf = {0}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = i; + + if (ioctl(self->fd, VIDIOC_QBUF, &buf) < 0) { + WEBCAM_DEBUG_PRINT("QBUF failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + } + + // 7. Restart streaming + if (ioctl(self->fd, VIDIOC_STREAMON, &type) < 0) { + WEBCAM_DEBUG_PRINT("STREAMON failed: %s\n", strerror(errno)); + mp_raise_OSError(errno); + } + + // Update stored input dimensions + self->input_width = new_input_width; + self->input_height = new_input_height; + } + + // If output resolution changed (or input changed which may affect output), reallocate output buffers + if (output_changed || input_changed) { // Free old buffers free(self->gray_buffer); free(self->rgb565_buffer); // Update dimensions - self->output_width = new_width; - self->output_height = new_height; + self->output_width = new_output_width; + self->output_height = new_output_height; // Allocate new buffers self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); @@ -392,10 +542,12 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m self->rgb565_buffer = NULL; mp_raise_OSError(MP_ENOMEM); } - - WEBCAM_DEBUG_PRINT("Webcam reconfigured to %dx%d\n", self->output_width, self->output_height); } + WEBCAM_DEBUG_PRINT("Webcam reconfigured: input %dx%d, output %dx%d\n", + self->input_width, self->input_height, + self->output_width, self->output_height); + return mp_const_none; } MP_DEFINE_CONST_FUN_OBJ_KW(webcam_reconfigure_obj, 1, webcam_reconfigure); diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 6890f0f..aba0015 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -82,7 +82,7 @@ def onCreate(self): # Settings button settings_button = lv.button(main_screen) settings_button.set_size(60,60) - settings_button.align(lv.ALIGN.TOP_LEFT, 0, 0) + settings_button.align(lv.ALIGN.TOP_RIGHT, 0, 60) settings_label = lv.label(settings_button) settings_label.set_text(lv.SYMBOL.SETTINGS) settings_label.center() @@ -167,7 +167,7 @@ def onResume(self, screen): self.finish() - def onStop(self, screen): + def onPause(self, screen): print("camera app backgrounded, cleaning up...") if self.capture_timer: self.capture_timer.delete() @@ -289,26 +289,48 @@ def handle_settings_result(self, result): # Reload resolution preference self.load_resolution_preference() - # Recreate image descriptor with new dimensions - self.image_dsc["header"]["w"] = self.width - self.image_dsc["header"]["h"] = self.height - self.image_dsc["header"]["stride"] = self.width * 2 - self.image_dsc["data_size"] = self.width * self.height * 2 + # CRITICAL: Pause capture timer to prevent race conditions during reconfiguration + if self.capture_timer: + self.capture_timer.delete() + self.capture_timer = None + print("Capture timer paused") + + # Clear stale data pointer to prevent segfault during LVGL rendering + self.image_dsc.data = None + self.current_cam_buffer = None + print("Image data cleared") + + # Update image descriptor with new dimensions + # Note: image_dsc is an LVGL struct, use attribute access not dictionary access + self.image_dsc.header.w = self.width + self.image_dsc.header.h = self.height + self.image_dsc.header.stride = self.width * 2 + self.image_dsc.data_size = self.width * self.height * 2 + print(f"Image descriptor updated to {self.width}x{self.height}") # Reconfigure camera if active if self.cam: if self.use_webcam: - print(f"Reconfiguring webcam to {self.width}x{self.height}") - webcam.reconfigure(self.cam, output_width=self.width, output_height=self.height) + print(f"Reconfiguring webcam: input={self.width}x{self.height}, output={self.width}x{self.height}") + # Configure both V4L2 input and output to the same resolution for best quality + webcam.reconfigure( + self.cam, + input_width=self.width, + input_height=self.height, + output_width=self.width, + output_height=self.height + ) + # Resume capture timer for webcam + self.capture_timer = lv.timer_create(self.try_capture, 100, None) + print("Webcam reconfigured (V4L2 + output buffers), capture timer resumed") else: # For internal camera, need to reinitialize print(f"Reinitializing internal camera to {self.width}x{self.height}") - if self.capture_timer: - self.capture_timer.delete() self.cam.deinit() self.cam = init_internal_cam(self.width, self.height) if self.cam: self.capture_timer = lv.timer_create(self.try_capture, 100, None) + print("Internal camera reinitialized, capture timer resumed") self.set_image_size() @@ -319,14 +341,23 @@ def try_capture(self, event): self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565") elif self.cam.frame_available(): self.current_cam_buffer = self.cam.capture() + if self.current_cam_buffer and len(self.current_cam_buffer): - self.image_dsc.data = self.current_cam_buffer - #image.invalidate() # does not work so do this: - self.image.set_src(self.image_dsc) - if not self.use_webcam: - self.cam.free_buffer() # Free the old buffer - if self.keepliveqrdecoding: - self.qrdecode_one() + # Defensive check: verify buffer size matches expected dimensions + expected_size = self.width * self.height * 2 # RGB565 = 2 bytes per pixel + actual_size = len(self.current_cam_buffer) + + if actual_size == expected_size: + self.image_dsc.data = self.current_cam_buffer + #image.invalidate() # does not work so do this: + self.image.set_src(self.image_dsc) + if not self.use_webcam: + self.cam.free_buffer() # Free the old buffer + if self.keepliveqrdecoding: + self.qrdecode_one() + else: + print(f"Warning: Buffer size mismatch! Expected {expected_size} bytes, got {actual_size} bytes") + print(f" Resolution: {self.width}x{self.height}, discarding frame") except Exception as e: print(f"Camera capture exception: {e}") diff --git a/tests/analyze_screenshot.py b/tests/analyze_screenshot.py new file mode 100755 index 0000000..328c19e --- /dev/null +++ b/tests/analyze_screenshot.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Analyze RGB565 screenshots for color correctness. + +Usage: + python3 analyze_screenshot.py screenshot.raw [width] [height] + +Checks: +- Color channel distribution (detect pale/washed out colors) +- Histogram analysis +- Average brightness +- Color saturation levels +""" + +import sys +import struct +from pathlib import Path + +def rgb565_to_rgb888(pixel): + """Convert RGB565 pixel to RGB888.""" + r5 = (pixel >> 11) & 0x1F + g6 = (pixel >> 5) & 0x3F + b5 = pixel & 0x1F + + r8 = (r5 << 3) | (r5 >> 2) + g8 = (g6 << 2) | (g6 >> 4) + b8 = (b5 << 3) | (b5 >> 2) + + return r8, g8, b8 + +def analyze_screenshot(filepath, width=320, height=240): + """Analyze RGB565 screenshot file.""" + print(f"Analyzing: {filepath}") + print(f"Dimensions: {width}x{height}") + + # Read raw data + try: + with open(filepath, 'rb') as f: + data = f.read() + except FileNotFoundError: + print(f"ERROR: File not found: {filepath}") + return + + expected_size = width * height * 2 + if len(data) != expected_size: + print(f"ERROR: File size mismatch. Expected {expected_size}, got {len(data)}") + print(f" Note: Expected size is for {width}x{height} RGB565 format") + return + + # Parse RGB565 pixels + pixels = [] + for i in range(0, len(data), 2): + # Little-endian RGB565 + pixel = struct.unpack(' 200: + print(" ⚠ WARNING: Very high brightness (overexposed)") + elif avg_brightness < 40: + print(" ⚠ WARNING: Very low brightness (underexposed)") + + # Simple histogram (10 bins) + print(f"\nChannel Histograms:") + for channel_name, channel_values in [('Red', red_values), ('Green', green_values), ('Blue', blue_values)]: + print(f" {channel_name}:") + + # Create 10 bins + bins = [0] * 10 + for val in channel_values: + bin_idx = min(9, val // 26) # 256 / 10 ≈ 26 + bins[bin_idx] += 1 + + for i, count in enumerate(bins): + bar_length = int((count / len(channel_values)) * 50) + bar = '█' * bar_length + bin_start = i * 26 + bin_end = (i + 1) * 26 - 1 + print(f" {bin_start:3d}-{bin_end:3d}: {bar} ({count})") + + # Detect common YUV conversion issues + print(f"\nYUV Conversion Checks:") + + # Check if colors are clamped (many pixels at 0 or 255) + clamped_count = sum(1 for r, g, b in pixels if r == 0 or r == 255 or g == 0 or g == 255 or b == 0 or b == 255) + total_pixels = len(pixels) + clamp_percent = (clamped_count / total_pixels) * 100 + print(f" Clamped pixels: {clamp_percent:.1f}%") + if clamp_percent > 5: + print(" ⚠ WARNING: High clamp rate suggests color conversion overflow") + + # Check for green tint (common YUYV issue) + avg_red = sum(red_values) / len(red_values) + avg_green = sum(green_values) / len(green_values) + avg_blue = sum(blue_values) / len(blue_values) + + green_dominance = avg_green - ((avg_red + avg_blue) / 2) + if green_dominance > 20: + print(f" ⚠ WARNING: Green channel dominance ({green_dominance:.1f}) - possible YUYV U/V swap") + + # Sample pixels for visual inspection + print(f"\nSample Pixels (first 10):") + for i in range(min(10, len(pixels))): + r, g, b = pixels[i] + print(f" Pixel {i}: RGB({r:3d}, {g:3d}, {b:3d})") + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Usage: python3 analyze_screenshot.py [width] [height]") + print("") + print("Examples:") + print(" python3 analyze_screenshot.py camera_capture.raw") + print(" python3 analyze_screenshot.py camera_640x480.raw 640 480") + sys.exit(1) + + filepath = sys.argv[1] + width = int(sys.argv[2]) if len(sys.argv) > 2 else 320 + height = int(sys.argv[3]) if len(sys.argv) > 3 else 240 + + analyze_screenshot(filepath, width, height) diff --git a/tests/test_graphical_camera_settings.py b/tests/test_graphical_camera_settings.py new file mode 100644 index 0000000..53ff342 --- /dev/null +++ b/tests/test_graphical_camera_settings.py @@ -0,0 +1,258 @@ +""" +Graphical test for Camera app settings functionality. + +This test verifies that: +1. The camera app settings button can be clicked without crashing +2. The settings dialog opens correctly +3. Resolution can be changed without causing segfault +4. The camera continues to work after resolution change + +This specifically tests the fixes for: +- Segfault when clicking settings button +- Pale colors after resolution change +- Buffer size mismatches + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_camera_settings.py + Device: ./tests/unittest.sh tests/test_graphical_camera_settings.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.apps +import mpos.ui +import os +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, + find_label_with_text, + find_button_with_text, + verify_text_present, + print_screen_labels, + simulate_click, + get_widget_coords +) + + +class TestGraphicalCameraSettings(unittest.TestCase): + """Test suite for Camera app settings verification.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Check if webcam module is available + try: + import webcam + self.has_webcam = True + except: + try: + import camera + self.has_webcam = False # Has internal camera instead + except: + self.skipTest("No camera module available (webcam or internal)") + + # Get absolute path to screenshots directory + import sys + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "../tests/screenshots" + + # Ensure screenshots directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass # Directory already exists + + def tearDown(self): + """Clean up after each test method.""" + # Navigate back to launcher (closes the camera app) + try: + mpos.ui.back_screen() + wait_for_render(10) # Allow navigation and cleanup to complete + except: + pass # Already on launcher or error + + def test_settings_button_click_no_crash(self): + """ + Test that clicking the settings button doesn't cause a segfault. + + This is the critical test that verifies the fix for the segfault + that occurred when clicking settings due to stale image_dsc.data pointer. + + Steps: + 1. Start camera app + 2. Wait for camera to initialize + 3. Capture initial screenshot + 4. Click settings button (top-right corner) + 5. Verify settings dialog opened + 6. If we get here without crash, test passes + """ + print("\n=== Testing settings button click (no crash) ===") + + # Start the Camera app + result = mpos.apps.start_app("com.micropythonos.camera") + self.assertTrue(result, "Failed to start Camera app") + + # Wait for camera to initialize and first frame to render + wait_for_render(iterations=30) + + # Get current screen + screen = lv.screen_active() + + # Debug: Print all text on screen + print("\nInitial screen labels:") + print_screen_labels(screen) + + # Capture screenshot before clicking settings + screenshot_path = f"{self.screenshot_dir}/camera_before_settings.raw" + print(f"\nCapturing initial screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Find and click settings button + # The settings button is positioned at TOP_RIGHT with offset (0, 60) + # On a 320x240 screen, this is approximately x=260, y=90 + # We'll click slightly inside the button to ensure we hit it + settings_x = 290 # Right side of screen, inside the 60px button + settings_y = 90 # 60px down from top, center of 60px button + + print(f"\nClicking settings button at ({settings_x}, {settings_y})") + simulate_click(settings_x, settings_y, press_duration_ms=100) + + # Wait for settings dialog to appear + wait_for_render(iterations=20) + + # Get screen again (might have changed after navigation) + screen = lv.screen_active() + + # Debug: Print labels after clicking + print("\nScreen labels after clicking settings:") + print_screen_labels(screen) + + # Verify settings screen opened + # Look for "Camera Settings" or "resolution" text + has_settings_ui = ( + verify_text_present(screen, "Camera Settings") or + verify_text_present(screen, "Resolution") or + verify_text_present(screen, "resolution") or + verify_text_present(screen, "Save") or + verify_text_present(screen, "Cancel") + ) + + self.assertTrue( + has_settings_ui, + "Settings screen did not open (no expected UI elements found)" + ) + + # Capture screenshot of settings dialog + screenshot_path = f"{self.screenshot_dir}/camera_settings_dialog.raw" + print(f"\nCapturing settings dialog screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # If we got here without segfault, the test passes! + print("\n✓ Settings button clicked successfully without crash!") + + def test_resolution_change_no_crash(self): + """ + Test that changing resolution doesn't cause a crash. + + This tests the full resolution change workflow: + 1. Start camera app + 2. Open settings + 3. Change resolution + 4. Save settings + 5. Verify camera continues working + + This verifies fixes for: + - Segfault during reconfiguration + - Buffer size mismatches + - Stale data pointers + """ + print("\n=== Testing resolution change (no crash) ===") + + # Start the Camera app + result = mpos.apps.start_app("com.micropythonos.camera") + self.assertTrue(result, "Failed to start Camera app") + + # Wait for camera to initialize + wait_for_render(iterations=30) + + # Click settings button + print("\nOpening settings...") + simulate_click(290, 90, press_duration_ms=100) + wait_for_render(iterations=20) + + screen = lv.screen_active() + + # Try to find the dropdown/resolution selector + # The CameraSettingsActivity creates a dropdown widget + # Let's look for any dropdown on screen + print("\nLooking for resolution dropdown...") + + # Find all clickable objects (dropdowns are clickable) + # We'll try clicking in the middle area where the dropdown should be + # Dropdown is typically centered, so around x=160, y=120 + dropdown_x = 160 + dropdown_y = 120 + + print(f"Clicking dropdown area at ({dropdown_x}, {dropdown_y})") + simulate_click(dropdown_x, dropdown_y, press_duration_ms=100) + wait_for_render(iterations=15) + + # The dropdown should now be open showing resolution options + # Let's capture what we see + screenshot_path = f"{self.screenshot_dir}/camera_dropdown_open.raw" + print(f"Capturing dropdown screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + screen = lv.screen_active() + print("\nScreen after opening dropdown:") + print_screen_labels(screen) + + # Try to select a different resolution + # Options are typically stacked vertically + # Let's click a bit lower to select a different option + option_x = 160 + option_y = 150 # Below the current selection + + print(f"\nSelecting different resolution at ({option_x}, {option_y})") + simulate_click(option_x, option_y, press_duration_ms=100) + wait_for_render(iterations=15) + + # Now find and click the Save button + print("\nLooking for Save button...") + save_button = find_button_with_text(lv.screen_active(), "Save") + + if save_button: + coords = get_widget_coords(save_button) + print(f"Found Save button at {coords}") + simulate_click(coords['center_x'], coords['center_y'], press_duration_ms=100) + else: + # Fallback: Save button is typically at bottom-left + # Based on CameraSettingsActivity code: ALIGN.BOTTOM_LEFT + print("Save button not found via text, trying bottom-left corner") + simulate_click(80, 220, press_duration_ms=100) + + # Wait for reconfiguration to complete + print("\nWaiting for reconfiguration...") + wait_for_render(iterations=30) + + # Capture screenshot after reconfiguration + screenshot_path = f"{self.screenshot_dir}/camera_after_resolution_change.raw" + print(f"Capturing post-change screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # If we got here without segfault, the test passes! + print("\n✓ Resolution changed successfully without crash!") + + # Verify camera is still showing something + screen = lv.screen_active() + # The camera app should still be active (not crashed back to launcher) + # We can check this by looking for camera-specific UI elements + # or just the fact that we haven't crashed + + print("\n✓ Camera app still running after resolution change!") + + +if __name__ == '__main__': + # Note: Don't include unittest.main() - handled by unittest.sh + pass From 31e61e7d88e031ebba9baea58d672d379dc4987f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 07:59:48 +0100 Subject: [PATCH 017/192] Improve camera --- c_mpos/src/webcam.c | 200 ++++++------------ .../assets/camera_app.py | 7 +- .../lib/mpos/board/fri3d_2024.py | 3 +- 3 files changed, 64 insertions(+), 146 deletions(-) diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index ca06773..31d3b10 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -25,6 +25,7 @@ static const mp_obj_type_t webcam_type; typedef struct _webcam_obj_t { mp_obj_base_t base; int fd; + char device[64]; // Device path (e.g., "/dev/video0") void *buffers[NUM_BUFFERS]; size_t buffer_length; int frame_count; @@ -147,8 +148,11 @@ static void save_raw_rgb565(const char *filename, uint16_t *data, int width, int fclose(fp); } -static int init_webcam(webcam_obj_t *self, const char *device) { - //WEBCAM_DEBUG_PRINT("webcam.c: init_webcam\n"); +static int init_webcam(webcam_obj_t *self, const char *device, int width, int height) { + // Store device path for later use (e.g., reconfigure) + strncpy(self->device, device, sizeof(self->device) - 1); + self->device[sizeof(self->device) - 1] = '\0'; + self->fd = open(device, O_RDWR); if (self->fd < 0) { WEBCAM_DEBUG_PRINT("Cannot open device: %s\n", strerror(errno)); @@ -157,8 +161,8 @@ static int init_webcam(webcam_obj_t *self, const char *device) { struct v4l2_format fmt = {0}; fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - fmt.fmt.pix.width = WIDTH; - fmt.fmt.pix.height = HEIGHT; + fmt.fmt.pix.width = width; + fmt.fmt.pix.height = height; fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field = V4L2_FIELD_ANY; if (ioctl(self->fd, VIDIOC_S_FMT, &fmt) < 0) { @@ -167,6 +171,10 @@ static int init_webcam(webcam_obj_t *self, const char *device) { return -errno; } + // Store actual format (driver may adjust dimensions) + width = fmt.fmt.pix.width; + height = fmt.fmt.pix.height; + struct v4l2_requestbuffers req = {0}; req.count = NUM_BUFFERS; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; @@ -215,9 +223,9 @@ static int init_webcam(webcam_obj_t *self, const char *device) { self->frame_count = 0; - // Store the input dimensions from V4L2 format - self->input_width = WIDTH; - self->input_height = HEIGHT; + // Store the input dimensions (actual values from V4L2, may be adjusted by driver) + self->input_width = width; + self->input_height = height; // Initialize output dimensions with defaults if not already set if (self->output_width == 0) self->output_width = OUTPUT_WIDTH; @@ -323,13 +331,25 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { } } -static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *args) { - mp_arg_check_num(n_args, 0, 0, 1, false); +static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_device, ARG_width, ARG_height }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_device, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = WIDTH} }, + { MP_QSTR_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = HEIGHT} }, + }; + + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + const char *device = "/dev/video0"; - if (n_args == 1) { - device = mp_obj_str_get_str(args[0]); + if (args[ARG_device].u_obj != MP_OBJ_NULL) { + device = mp_obj_str_get_str(args[ARG_device].u_obj); } + int width = args[ARG_width].u_int; + int height = args[ARG_height].u_int; + webcam_obj_t *self = m_new_obj(webcam_obj_t); self->base.type = &webcam_type; self->fd = -1; @@ -340,14 +360,14 @@ static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *args) { self->output_width = 0; // Will use default OUTPUT_WIDTH in init_webcam self->output_height = 0; // Will use default OUTPUT_HEIGHT in init_webcam - int res = init_webcam(self, device); + int res = init_webcam(self, device, width, height); if (res < 0) { mp_raise_OSError(-res); } return MP_OBJ_FROM_PTR(self); } -MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(webcam_init_obj, 0, 1, webcam_init); +MP_DEFINE_CONST_FUN_OBJ_KW(webcam_init_obj, 0, webcam_init); static mp_obj_t webcam_deinit(mp_obj_t self_in) { webcam_obj_t *self = MP_OBJ_TO_PTR(self_in); @@ -373,11 +393,10 @@ MP_DEFINE_CONST_FUN_OBJ_2(webcam_capture_frame_obj, webcam_capture_frame); static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { /* - * Reconfigure webcam resolution. + * Reconfigure webcam resolution by reinitializing. * - * Supports changing both INPUT resolution (V4L2 capture format) and - * OUTPUT resolution (conversion buffers). If input resolution changes, - * this will stop streaming, reconfigure V4L2, and restart streaming. + * This elegantly reuses deinit_webcam() and init_webcam() instead of + * duplicating V4L2 setup code. * * Parameters: * input_width, input_height: V4L2 capture resolution (optional) @@ -412,141 +431,40 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m if (new_output_height == 0) new_output_height = self->output_height; // Validate dimensions - if (new_input_width <= 0 || new_input_height <= 0 || new_input_width > 1920 || new_input_height > 1920) { + if (new_input_width <= 0 || new_input_height <= 0 || new_input_width > 3840 || new_input_height > 2160) { mp_raise_ValueError(MP_ERROR_TEXT("Invalid input dimensions")); } - if (new_output_width <= 0 || new_output_height <= 0 || new_output_width > 1920 || new_output_height > 1920) { + if (new_output_width <= 0 || new_output_height <= 0 || new_output_width > 3840 || new_output_height > 2160) { mp_raise_ValueError(MP_ERROR_TEXT("Invalid output dimensions")); } - bool input_changed = (new_input_width != self->input_width || new_input_height != self->input_height); - bool output_changed = (new_output_width != self->output_width || new_output_height != self->output_height); - - // If input resolution changed, need to reconfigure V4L2 - if (input_changed) { - WEBCAM_DEBUG_PRINT("Reconfiguring V4L2: %dx%d -> %dx%d\n", - self->input_width, self->input_height, - new_input_width, new_input_height); - - // 1. Stop streaming - enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - if (ioctl(self->fd, VIDIOC_STREAMOFF, &type) < 0) { - WEBCAM_DEBUG_PRINT("STREAMOFF failed: %s\n", strerror(errno)); - mp_raise_OSError(errno); - } - - // 2. Unmap old buffers - for (int i = 0; i < NUM_BUFFERS; i++) { - if (self->buffers[i] != MAP_FAILED && self->buffers[i] != NULL) { - munmap(self->buffers[i], self->buffer_length); - self->buffers[i] = MAP_FAILED; - } - } - - // 3. Set new V4L2 format - struct v4l2_format fmt = {0}; - fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - fmt.fmt.pix.width = new_input_width; - fmt.fmt.pix.height = new_input_height; - fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; - fmt.fmt.pix.field = V4L2_FIELD_ANY; - - if (ioctl(self->fd, VIDIOC_S_FMT, &fmt) < 0) { - WEBCAM_DEBUG_PRINT("S_FMT failed: %s\n", strerror(errno)); - mp_raise_OSError(errno); - } - - // Verify format was set (driver may adjust dimensions) - if (fmt.fmt.pix.width != new_input_width || fmt.fmt.pix.height != new_input_height) { - WEBCAM_DEBUG_PRINT("Warning: Driver adjusted format to %dx%d\n", - fmt.fmt.pix.width, fmt.fmt.pix.height); - new_input_width = fmt.fmt.pix.width; - new_input_height = fmt.fmt.pix.height; - } - - // 4. Request new buffers - struct v4l2_requestbuffers req = {0}; - req.count = NUM_BUFFERS; - req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - req.memory = V4L2_MEMORY_MMAP; - - if (ioctl(self->fd, VIDIOC_REQBUFS, &req) < 0) { - WEBCAM_DEBUG_PRINT("REQBUFS failed: %s\n", strerror(errno)); - mp_raise_OSError(errno); - } - - // 5. Map new buffers - for (int i = 0; i < NUM_BUFFERS; i++) { - struct v4l2_buffer buf = {0}; - buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - buf.memory = V4L2_MEMORY_MMAP; - buf.index = i; - - if (ioctl(self->fd, VIDIOC_QUERYBUF, &buf) < 0) { - WEBCAM_DEBUG_PRINT("QUERYBUF failed: %s\n", strerror(errno)); - mp_raise_OSError(errno); - } - - self->buffer_length = buf.length; - self->buffers[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, - MAP_SHARED, self->fd, buf.m.offset); - - if (self->buffers[i] == MAP_FAILED) { - WEBCAM_DEBUG_PRINT("mmap failed: %s\n", strerror(errno)); - mp_raise_OSError(errno); - } - } - - // 6. Queue buffers - for (int i = 0; i < NUM_BUFFERS; i++) { - struct v4l2_buffer buf = {0}; - buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - buf.memory = V4L2_MEMORY_MMAP; - buf.index = i; - - if (ioctl(self->fd, VIDIOC_QBUF, &buf) < 0) { - WEBCAM_DEBUG_PRINT("QBUF failed: %s\n", strerror(errno)); - mp_raise_OSError(errno); - } - } - - // 7. Restart streaming - if (ioctl(self->fd, VIDIOC_STREAMON, &type) < 0) { - WEBCAM_DEBUG_PRINT("STREAMON failed: %s\n", strerror(errno)); - mp_raise_OSError(errno); - } - - // Update stored input dimensions - self->input_width = new_input_width; - self->input_height = new_input_height; + // Check if anything changed + if (new_input_width == self->input_width && + new_input_height == self->input_height && + new_output_width == self->output_width && + new_output_height == self->output_height) { + return mp_const_none; // Nothing to do } - // If output resolution changed (or input changed which may affect output), reallocate output buffers - if (output_changed || input_changed) { - // Free old buffers - free(self->gray_buffer); - free(self->rgb565_buffer); + WEBCAM_DEBUG_PRINT("Reconfiguring webcam: %dx%d -> %dx%d (input), %dx%d -> %dx%d (output)\n", + self->input_width, self->input_height, new_input_width, new_input_height, + self->output_width, self->output_height, new_output_width, new_output_height); - // Update dimensions - self->output_width = new_output_width; - self->output_height = new_output_height; + // Remember device path before deinit (which closes fd) + char device[64]; + strncpy(device, self->device, sizeof(device)); - // Allocate new buffers - self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); - self->rgb565_buffer = (uint16_t *)malloc(self->output_width * self->output_height * sizeof(uint16_t)); + // Set desired output dimensions before reinit + self->output_width = new_output_width; + self->output_height = new_output_height; - if (!self->gray_buffer || !self->rgb565_buffer) { - free(self->gray_buffer); - free(self->rgb565_buffer); - self->gray_buffer = NULL; - self->rgb565_buffer = NULL; - mp_raise_OSError(MP_ENOMEM); - } - } + // Clean shutdown and reinitialize with new input dimensions + deinit_webcam(self); + int res = init_webcam(self, device, new_input_width, new_input_height); - WEBCAM_DEBUG_PRINT("Webcam reconfigured: input %dx%d, output %dx%d\n", - self->input_width, self->input_height, - self->output_width, self->output_height); + if (res < 0) { + mp_raise_OSError(-res); + } return mp_const_none; } diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index aba0015..478772f 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -144,11 +144,10 @@ def onResume(self, screen): else: print("camera app: no internal camera found, trying webcam on /dev/video0") try: - self.cam = webcam.init("/dev/video0") + # Initialize webcam with desired resolution directly + print(f"Initializing webcam at {self.width}x{self.height}") + self.cam = webcam.init("/dev/video0", width=self.width, height=self.height) self.use_webcam = True - # Reconfigure webcam to use saved resolution - print(f"Reconfiguring webcam to {self.width}x{self.height}") - webcam.reconfigure(self.cam, output_width=self.width, output_height=self.height) except Exception as e: print(f"camera app: webcam exception: {e}") if self.cam: diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 276fd71..74f20d3 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -266,6 +266,7 @@ def keypad_read_cb(indev, data): 2482 is 4.180 2470 is 4.170 2457 is 4.147 +# 2444 is 4.12 2433 is 4.109 2429 is 4.102 2393 is 4.044 @@ -280,7 +281,7 @@ def adc_to_voltage(adc_value): Calibration data shows linear relationship: voltage = -0.0016237 * adc + 8.2035 This is ~10x more accurate than simple scaling (error ~0.01V vs ~0.1V). """ - return (-0.0016237 * adc_value + 8.2035) + return (0.001651* adc_value + 0.08709) mpos.battery_voltage.init_adc(13, adc_to_voltage) From 5bf790ed6119f90e1edfabbb14adf8d03f81d674 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 08:19:11 +0100 Subject: [PATCH 018/192] Simplify: no scaling --- c_mpos/src/webcam.c | 222 ++++++------------ .../assets/camera_app.py | 14 +- .../lib/mpos/board/fri3d_2024.py | 1 + 3 files changed, 81 insertions(+), 156 deletions(-) diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index 31d3b10..4ebe3a2 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -29,47 +29,27 @@ typedef struct _webcam_obj_t { void *buffers[NUM_BUFFERS]; size_t buffer_length; int frame_count; - unsigned char *gray_buffer; // For grayscale - uint16_t *rgb565_buffer; // For RGB565 - int input_width; // Webcam capture width (from V4L2) - int input_height; // Webcam capture height (from V4L2) - int output_width; // Configurable output width (default OUTPUT_WIDTH) - int output_height; // Configurable output height (default OUTPUT_HEIGHT) + unsigned char *gray_buffer; // For grayscale conversion + uint16_t *rgb565_buffer; // For RGB565 conversion + int width; // Resolution width + int height; // Resolution height } webcam_obj_t; -static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int in_width, int in_height, int out_width, int out_height) { - // Crop to largest square that fits in the input frame - int crop_size = (in_width < in_height) ? in_width : in_height; - int crop_x_offset = (in_width - crop_size) / 2; - int crop_y_offset = (in_height - crop_size) / 2; - - // Calculate scaling ratios - float x_ratio = (float)crop_size / out_width; - float y_ratio = (float)crop_size / out_height; - - for (int y = 0; y < out_height; y++) { - for (int x = 0; x < out_width; x++) { - int src_x = (int)(x * x_ratio) + crop_x_offset; - int src_y = (int)(y * y_ratio) + crop_y_offset; - - // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) - // Ensure we're aligned to even pixel boundary - int src_x_even = (src_x / 2) * 2; - int src_base_index = (src_y * in_width + src_x_even) * 2; - - // Extract Y, U, V values - int y0; - if (src_x % 2 == 0) { - // Even pixel: use Y0 - y0 = yuyv[src_base_index]; - } else { - // Odd pixel: use Y1 - y0 = yuyv[src_base_index + 2]; - } - int u = yuyv[src_base_index + 1]; - int v = yuyv[src_base_index + 3]; - - // YUV to RGB conversion (ITU-R BT.601) +static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int width, int height) { + // Convert YUYV to RGB565 without scaling + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels, chroma shared) + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x += 2) { + // Process 2 pixels at a time (one YUYV quad) + int base_index = (y * width + x) * 2; + + int y0 = yuyv[base_index + 0]; + int u = yuyv[base_index + 1]; + int y1 = yuyv[base_index + 2]; + int v = yuyv[base_index + 3]; + + // YUV to RGB conversion (ITU-R BT.601) for first pixel int c = y0 - 16; int d = u - 128; int e = v - 128; @@ -88,42 +68,36 @@ static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int in_width, uint16_t g6 = (g >> 2) & 0x3F; uint16_t b5 = (b >> 3) & 0x1F; - rgb565[y * out_width + x] = (r5 << 11) | (g6 << 5) | b5; + rgb565[y * width + x] = (r5 << 11) | (g6 << 5) | b5; + + // Second pixel (shares U/V with first) + c = y1 - 16; + + r = (298 * c + 409 * e + 128) >> 8; + g = (298 * c - 100 * d - 208 * e + 128) >> 8; + b = (298 * c + 516 * d + 128) >> 8; + + r = r < 0 ? 0 : (r > 255 ? 255 : r); + g = g < 0 ? 0 : (g > 255 ? 255 : g); + b = b < 0 ? 0 : (b > 255 ? 255 : b); + + r5 = (r >> 3) & 0x1F; + g6 = (g >> 2) & 0x3F; + b5 = (b >> 3) & 0x1F; + + rgb565[y * width + x + 1] = (r5 << 11) | (g6 << 5) | b5; } } } -static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, int in_width, int in_height, int out_width, int out_height) { - // Crop to largest square that fits in the input frame - int crop_size = (in_width < in_height) ? in_width : in_height; - int crop_x_offset = (in_width - crop_size) / 2; - int crop_y_offset = (in_height - crop_size) / 2; - - // Calculate scaling ratios - float x_ratio = (float)crop_size / out_width; - float y_ratio = (float)crop_size / out_height; - - for (int y = 0; y < out_height; y++) { - for (int x = 0; x < out_width; x++) { - int src_x = (int)(x * x_ratio) + crop_x_offset; - int src_y = (int)(y * y_ratio) + crop_y_offset; - - // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) - // Ensure we're aligned to even pixel boundary - int src_x_even = (src_x / 2) * 2; - int src_base_index = (src_y * in_width + src_x_even) * 2; - - // Extract Y value - unsigned char y_val; - if (src_x % 2 == 0) { - // Even pixel: use Y0 - y_val = yuyv[src_base_index]; - } else { - // Odd pixel: use Y1 - y_val = yuyv[src_base_index + 2]; - } - - gray[y * out_width + x] = y_val; +static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, int width, int height) { + // Extract Y (luminance) values from YUYV without scaling + // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + // Y values are at even indices in YUYV + gray[y * width + x] = yuyv[(y * width + x) * 2]; } } } @@ -223,21 +197,15 @@ static int init_webcam(webcam_obj_t *self, const char *device, int width, int he self->frame_count = 0; - // Store the input dimensions (actual values from V4L2, may be adjusted by driver) - self->input_width = width; - self->input_height = height; - - // Initialize output dimensions with defaults if not already set - if (self->output_width == 0) self->output_width = OUTPUT_WIDTH; - if (self->output_height == 0) self->output_height = OUTPUT_HEIGHT; + // Store resolution (actual values from V4L2, may be adjusted by driver) + self->width = width; + self->height = height; - WEBCAM_DEBUG_PRINT("Webcam initialized: input %dx%d, output %dx%d\n", - self->input_width, self->input_height, - self->output_width, self->output_height); + WEBCAM_DEBUG_PRINT("Webcam initialized: %dx%d\n", self->width, self->height); - // Allocate buffers with configured output dimensions - self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); - self->rgb565_buffer = (uint16_t *)malloc(self->output_width * self->output_height * sizeof(uint16_t)); + // Allocate conversion buffers + self->gray_buffer = (unsigned char *)malloc(self->width * self->height * sizeof(unsigned char)); + self->rgb565_buffer = (uint16_t *)malloc(self->width * self->height * sizeof(uint16_t)); if (!self->gray_buffer || !self->rgb565_buffer) { WEBCAM_DEBUG_PRINT("Cannot allocate buffers: %s\n", strerror(errno)); free(self->gray_buffer); @@ -288,28 +256,16 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { mp_raise_OSError(-res); } - if (!self->gray_buffer) { - self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); - if (!self->gray_buffer) { - mp_raise_OSError(MP_ENOMEM); - } - } - if (!self->rgb565_buffer) { - self->rgb565_buffer = (uint16_t *)malloc(self->output_width * self->output_height * sizeof(uint16_t)); - if (!self->rgb565_buffer) { - mp_raise_OSError(MP_ENOMEM); - } + // Buffers should already be allocated in init_webcam + if (!self->gray_buffer || !self->rgb565_buffer) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Buffers not allocated")); } const char *fmt = mp_obj_str_get_str(format); if (strcmp(fmt, "grayscale") == 0) { yuyv_to_grayscale(self->buffers[buf.index], self->gray_buffer, - self->input_width, self->input_height, - self->output_width, self->output_height); - // char filename[32]; - // snprintf(filename, sizeof(filename), "frame_%03d.raw", self->frame_count++); - // save_raw(filename, self->gray_buffer, self->output_width, self->output_height); - mp_obj_t result = mp_obj_new_memoryview('b', self->output_width * self->output_height, self->gray_buffer); + self->width, self->height); + mp_obj_t result = mp_obj_new_memoryview('b', self->width * self->height, self->gray_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); @@ -317,12 +273,8 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { return result; } else { yuyv_to_rgb565(self->buffers[buf.index], self->rgb565_buffer, - self->input_width, self->input_height, - self->output_width, self->output_height); - // char filename[32]; - // snprintf(filename, sizeof(filename), "frame_%03d.rgb565", self->frame_count++); - // save_raw_rgb565(filename, self->rgb565_buffer, self->output_width, self->output_height); - mp_obj_t result = mp_obj_new_memoryview('b', self->output_width * self->output_height * 2, self->rgb565_buffer); + self->width, self->height); + mp_obj_t result = mp_obj_new_memoryview('b', self->width * self->height * 2, self->rgb565_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); @@ -355,10 +307,8 @@ static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *pos_args, mp_map_t *k self->fd = -1; self->gray_buffer = NULL; self->rgb565_buffer = NULL; - self->input_width = 0; // Will be set from V4L2 format in init_webcam - self->input_height = 0; // Will be set from V4L2 format in init_webcam - self->output_width = 0; // Will use default OUTPUT_WIDTH in init_webcam - self->output_height = 0; // Will use default OUTPUT_HEIGHT in init_webcam + self->width = 0; // Will be set from V4L2 format in init_webcam + self->height = 0; // Will be set from V4L2 format in init_webcam int res = init_webcam(self, device, width, height); if (res < 0) { @@ -399,19 +349,14 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m * duplicating V4L2 setup code. * * Parameters: - * input_width, input_height: V4L2 capture resolution (optional) - * output_width, output_height: Output buffer resolution (optional) - * - * If not specified, dimensions remain unchanged. + * width, height: Resolution (optional, keeps current if not specified) */ - enum { ARG_self, ARG_input_width, ARG_input_height, ARG_output_width, ARG_output_height }; + enum { ARG_self, ARG_width, ARG_height }; static const mp_arg_t allowed_args[] = { { MP_QSTR_self, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, - { MP_QSTR_input_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, - { MP_QSTR_input_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, - { MP_QSTR_output_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, - { MP_QSTR_output_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, }; mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; @@ -420,47 +365,32 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m webcam_obj_t *self = MP_OBJ_TO_PTR(args[ARG_self].u_obj); // Get new dimensions (keep current if not specified) - int new_input_width = args[ARG_input_width].u_int; - int new_input_height = args[ARG_input_height].u_int; - int new_output_width = args[ARG_output_width].u_int; - int new_output_height = args[ARG_output_height].u_int; + int new_width = args[ARG_width].u_int; + int new_height = args[ARG_height].u_int; - if (new_input_width == 0) new_input_width = self->input_width; - if (new_input_height == 0) new_input_height = self->input_height; - if (new_output_width == 0) new_output_width = self->output_width; - if (new_output_height == 0) new_output_height = self->output_height; + if (new_width == 0) new_width = self->width; + if (new_height == 0) new_height = self->height; // Validate dimensions - if (new_input_width <= 0 || new_input_height <= 0 || new_input_width > 3840 || new_input_height > 2160) { - mp_raise_ValueError(MP_ERROR_TEXT("Invalid input dimensions")); - } - if (new_output_width <= 0 || new_output_height <= 0 || new_output_width > 3840 || new_output_height > 2160) { - mp_raise_ValueError(MP_ERROR_TEXT("Invalid output dimensions")); + if (new_width <= 0 || new_height <= 0 || new_width > 3840 || new_height > 2160) { + mp_raise_ValueError(MP_ERROR_TEXT("Invalid dimensions")); } // Check if anything changed - if (new_input_width == self->input_width && - new_input_height == self->input_height && - new_output_width == self->output_width && - new_output_height == self->output_height) { + if (new_width == self->width && new_height == self->height) { return mp_const_none; // Nothing to do } - WEBCAM_DEBUG_PRINT("Reconfiguring webcam: %dx%d -> %dx%d (input), %dx%d -> %dx%d (output)\n", - self->input_width, self->input_height, new_input_width, new_input_height, - self->output_width, self->output_height, new_output_width, new_output_height); + WEBCAM_DEBUG_PRINT("Reconfiguring webcam: %dx%d -> %dx%d\n", + self->width, self->height, new_width, new_height); // Remember device path before deinit (which closes fd) char device[64]; strncpy(device, self->device, sizeof(device)); - // Set desired output dimensions before reinit - self->output_width = new_output_width; - self->output_height = new_output_height; - - // Clean shutdown and reinitialize with new input dimensions + // Clean shutdown and reinitialize with new resolution deinit_webcam(self); - int res = init_webcam(self, device, new_input_width, new_input_height); + int res = init_webcam(self, device, new_width, new_height); if (res < 0) { mp_raise_OSError(-res); diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 478772f..21e607a 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -310,18 +310,12 @@ def handle_settings_result(self, result): # Reconfigure camera if active if self.cam: if self.use_webcam: - print(f"Reconfiguring webcam: input={self.width}x{self.height}, output={self.width}x{self.height}") - # Configure both V4L2 input and output to the same resolution for best quality - webcam.reconfigure( - self.cam, - input_width=self.width, - input_height=self.height, - output_width=self.width, - output_height=self.height - ) + print(f"Reconfiguring webcam to {self.width}x{self.height}") + # Reconfigure webcam resolution (input and output are the same) + webcam.reconfigure(self.cam, width=self.width, height=self.height) # Resume capture timer for webcam self.capture_timer = lv.timer_create(self.try_capture, 100, None) - print("Webcam reconfigured (V4L2 + output buffers), capture timer resumed") + print("Webcam reconfigured, capture timer resumed") else: # For internal camera, need to reinitialize print(f"Reinitializing internal camera to {self.width}x{self.height}") diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 74f20d3..922ecf4 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -274,6 +274,7 @@ def keypad_read_cb(indev, data): 2343 is 3.957 2319 is 3.916 2269 is 3.831 +2227 is 3.769 """ def adc_to_voltage(adc_value): """ From 858df97372607d6a3d5040e37a499dfdb446163b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 08:23:07 +0100 Subject: [PATCH 019/192] Default to 320x240 --- .../com.micropythonos.camera/assets/camera_app.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 21e607a..6e255f0 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -19,7 +19,7 @@ class CameraApp(Activity): - width = 240 + width = 320 height = 240 # Resolution preferences @@ -52,15 +52,15 @@ def load_resolution_preference(self): if not self.prefs: self.prefs = SharedPreferences("com.micropythonos.camera") - resolution_str = self.prefs.get_string("resolution", "240x240") + resolution_str = self.prefs.get_string("resolution", "320x240") try: width_str, height_str = resolution_str.split('x') self.width = int(width_str) self.height = int(height_str) print(f"Camera resolution loaded: {self.width}x{self.height}") except Exception as e: - print(f"Error parsing resolution '{resolution_str}': {e}, using default 240x240") - self.width = 240 + print(f"Error parsing resolution '{resolution_str}': {e}, using default 320x240") + self.width = 320 self.height = 240 def onCreate(self): @@ -356,7 +356,7 @@ def try_capture(self, event): # Non-class functions: -def init_internal_cam(width=240, height=240): +def init_internal_cam(width=320, height=240): """Initialize internal camera with specified resolution.""" try: from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling @@ -383,7 +383,7 @@ def init_internal_cam(width=240, height=240): (1920, 1080): FrameSize.FHD, } - frame_size = resolution_map.get((width, height), FrameSize.R240X240) + frame_size = resolution_map.get((width, height), FrameSize.QVGA) print(f"init_internal_cam: Using FrameSize for {width}x{height}") cam = Camera( @@ -470,7 +470,7 @@ class CameraSettingsActivity(Activity): def onCreate(self): # Load preferences prefs = SharedPreferences("com.micropythonos.camera") - self.current_resolution = prefs.get_string("resolution", "240x240") + self.current_resolution = prefs.get_string("resolution", "320x240") # Create main screen screen = lv.obj() From f8da36b630cbe7df7f90373d9579002eafa0905b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 09:05:56 +0100 Subject: [PATCH 020/192] camera: fix crash and other bugs --- c_mpos/src/webcam.c | 2 - .../assets/camera_app.py | 79 ++++++++++--------- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index 4ebe3a2..6ea446a 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -15,8 +15,6 @@ #define WIDTH 640 #define HEIGHT 480 #define NUM_BUFFERS 1 -#define OUTPUT_WIDTH 240 -#define OUTPUT_HEIGHT 240 #define WEBCAM_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 6e255f0..afbcb4f 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -22,9 +22,6 @@ class CameraApp(Activity): width = 320 height = 240 - # Resolution preferences - prefs = None - status_label_text = "No camera found." status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and QR size (4-12cm). Ensure proper lighting." status_label_text_found = "Decoding QR..." @@ -41,6 +38,7 @@ class CameraApp(Activity): capture_timer = None # Widgets: + main_screen = None qr_label = None qr_button = None snap_button = None @@ -49,10 +47,8 @@ class CameraApp(Activity): def load_resolution_preference(self): """Load resolution preference from SharedPreferences and update width/height.""" - if not self.prefs: - self.prefs = SharedPreferences("com.micropythonos.camera") - - resolution_str = self.prefs.get_string("resolution", "320x240") + prefs = SharedPreferences("com.micropythonos.camera") + resolution_str = prefs.get_string("resolution", "320x240") try: width_str, height_str = resolution_str.split('x') self.width = int(width_str) @@ -66,21 +62,22 @@ def load_resolution_preference(self): def onCreate(self): self.load_resolution_preference() self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") - main_screen = lv.obj() - main_screen.set_style_pad_all(0, 0) - main_screen.set_style_border_width(0, 0) - main_screen.set_size(lv.pct(100), lv.pct(100)) - main_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - close_button = lv.button(main_screen) + self.main_screen = lv.obj() + self.main_screen.set_style_pad_all(0, 0) + self.main_screen.set_style_border_width(0, 0) + self.main_screen.set_size(lv.pct(100), lv.pct(100)) + self.main_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + # Initialize LVGL image widget + self.create_preview_image() + close_button = lv.button(self.main_screen) close_button.set_size(60,60) close_button.align(lv.ALIGN.TOP_RIGHT, 0, 0) close_label = lv.label(close_button) close_label.set_text(lv.SYMBOL.CLOSE) close_label.center() close_button.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) - # Settings button - settings_button = lv.button(main_screen) + settings_button = lv.button(self.main_screen) settings_button.set_size(60,60) settings_button.align(lv.ALIGN.TOP_RIGHT, 0, 60) settings_label = lv.label(settings_button) @@ -88,7 +85,7 @@ def onCreate(self): settings_label.center() settings_button.add_event_cb(lambda e: self.open_settings(),lv.EVENT.CLICKED,None) - self.snap_button = lv.button(main_screen) + self.snap_button = lv.button(self.main_screen) self.snap_button.set_size(60, 60) self.snap_button.align(lv.ALIGN.RIGHT_MID, 0, 0) self.snap_button.add_flag(lv.obj.FLAG.HIDDEN) @@ -96,7 +93,7 @@ def onCreate(self): snap_label = lv.label(self.snap_button) snap_label.set_text(lv.SYMBOL.OK) snap_label.center() - self.qr_button = lv.button(main_screen) + self.qr_button = lv.button(self.main_screen) self.qr_button.set_size(60, 60) self.qr_button.add_flag(lv.obj.FLAG.HIDDEN) self.qr_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) @@ -104,24 +101,7 @@ def onCreate(self): self.qr_label = lv.label(self.qr_button) self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) self.qr_label.center() - # Initialize LVGL image widget - self.image = lv.image(main_screen) - self.image.align(lv.ALIGN.LEFT_MID, 0, 0) - # Create image descriptor once - self.image_dsc = lv.image_dsc_t({ - "header": { - "magic": lv.IMAGE_HEADER_MAGIC, - "w": self.width, - "h": self.height, - "stride": self.width * 2, - "cf": lv.COLOR_FORMAT.RGB565 - #"cf": lv.COLOR_FORMAT.L8 - }, - 'data_size': self.width * self.height * 2, - 'data': None # Will be updated per frame - }) - self.image.set_src(self.image_dsc) - self.status_label_cont = lv.obj(main_screen) + self.status_label_cont = lv.obj(self.main_screen) self.status_label_cont.set_size(lv.pct(66),lv.pct(60)) self.status_label_cont.align(lv.ALIGN.LEFT_MID, lv.pct(5), 0) self.status_label_cont.set_style_bg_color(lv.color_white(), 0) @@ -132,9 +112,10 @@ def onCreate(self): self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) self.status_label.set_width(lv.pct(100)) self.status_label.center() - self.setContentView(main_screen) + self.setContentView(self.main_screen) def onResume(self, screen): + self.create_preview_image() self.cam = init_internal_cam(self.width, self.height) if not self.cam: # try again because the manual i2c poweroff leaves it in a bad state @@ -191,6 +172,7 @@ def onPause(self, screen): print("camera app cleanup done.") def set_image_size(self): + #return disp = lv.display_get_default() target_h = disp.get_vertical_resolution() target_w = target_h @@ -205,6 +187,26 @@ def set_image_size(self): #self.image.set_scale(max(scale_factor_w,scale_factor_h)) # fills the entire screen but cuts off borders self.image.set_scale(min(scale_factor_w,scale_factor_h)) + def create_preview_image(self): + self.image = lv.image(self.main_screen) + self.image.align(lv.ALIGN.LEFT_MID, 0, 0) + # Create image descriptor once + self.image_dsc = lv.image_dsc_t({ + "header": { + "magic": lv.IMAGE_HEADER_MAGIC, + "w": self.width, + "h": self.height, + "stride": self.width * 2, + "cf": lv.COLOR_FORMAT.RGB565 + #"cf": lv.COLOR_FORMAT.L8 + }, + 'data_size': self.width * self.height * 2, + 'data': None # Will be updated per frame + }) + self.image.set_src(self.image_dsc) + #self.image.set_size(160, 120) + + def qrdecode_one(self): try: import qrdecode @@ -277,11 +279,14 @@ def qr_button_click(self, e): self.stop_qr_decoding() def open_settings(self): + #self.main_screen.clean() + self.image.delete() """Launch the camera settings activity.""" intent = Intent(activity_class=CameraSettingsActivity) self.startActivityForResult(intent, self.handle_settings_result) def handle_settings_result(self, result): + print(f"handle_settings_result: {result}") """Handle result from settings activity.""" if result.get("result_code") == True: print("Settings changed, reloading resolution...") @@ -495,7 +500,7 @@ def onCreate(self): except: resolutions = self.ESP32_RESOLUTIONS print("Using ESP32 camera resolutions") - + # Create dropdown self.dropdown = lv.dropdown(screen) self.dropdown.set_size(200, 40) From 01f7a1f84faf08b44ada6de0064b9bd39ac572a1 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 09:25:36 +0100 Subject: [PATCH 021/192] Improve camera --- c_mpos/src/webcam.c | 6 ++---- .../com.micropythonos.camera/assets/camera_app.py | 14 +++++++------- tests/test_graphical_camera_settings.py | 4 ++-- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index 6ea446a..f1c71ad 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -12,8 +12,6 @@ #include "py/runtime.h" #include "py/mperrno.h" -#define WIDTH 640 -#define HEIGHT 480 #define NUM_BUFFERS 1 #define WEBCAM_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) @@ -285,8 +283,8 @@ static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *pos_args, mp_map_t *k enum { ARG_device, ARG_width, ARG_height }; static const mp_arg_t allowed_args[] = { { MP_QSTR_device, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, - { MP_QSTR_width, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = WIDTH} }, - { MP_QSTR_height, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = HEIGHT} }, + { MP_QSTR_width, MP_ARG_REQUIRED | MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_height, MP_ARG_REQUIRED | MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, }; mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index afbcb4f..6284766 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -19,6 +19,7 @@ class CameraApp(Activity): + button_width = 40 width = 320 height = 240 @@ -70,7 +71,7 @@ def onCreate(self): # Initialize LVGL image widget self.create_preview_image() close_button = lv.button(self.main_screen) - close_button.set_size(60,60) + close_button.set_size(self.button_width,40) close_button.align(lv.ALIGN.TOP_RIGHT, 0, 0) close_label = lv.label(close_button) close_label.set_text(lv.SYMBOL.CLOSE) @@ -78,15 +79,15 @@ def onCreate(self): close_button.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) # Settings button settings_button = lv.button(self.main_screen) - settings_button.set_size(60,60) - settings_button.align(lv.ALIGN.TOP_RIGHT, 0, 60) + settings_button.set_size(self.button_width,40) + settings_button.align(lv.ALIGN.TOP_RIGHT, 0, 50) settings_label = lv.label(settings_button) settings_label.set_text(lv.SYMBOL.SETTINGS) settings_label.center() settings_button.add_event_cb(lambda e: self.open_settings(),lv.EVENT.CLICKED,None) self.snap_button = lv.button(self.main_screen) - self.snap_button.set_size(60, 60) + self.snap_button.set_size(self.button_width, 40) self.snap_button.align(lv.ALIGN.RIGHT_MID, 0, 0) self.snap_button.add_flag(lv.obj.FLAG.HIDDEN) self.snap_button.add_event_cb(self.snap_button_click,lv.EVENT.CLICKED,None) @@ -94,7 +95,7 @@ def onCreate(self): snap_label.set_text(lv.SYMBOL.OK) snap_label.center() self.qr_button = lv.button(self.main_screen) - self.qr_button.set_size(60, 60) + self.qr_button.set_size(self.button_width, 40) self.qr_button.add_flag(lv.obj.FLAG.HIDDEN) self.qr_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) self.qr_button.add_event_cb(self.qr_button_click,lv.EVENT.CLICKED,None) @@ -172,10 +173,9 @@ def onPause(self, screen): print("camera app cleanup done.") def set_image_size(self): - #return disp = lv.display_get_default() target_h = disp.get_vertical_resolution() - target_w = target_h + target_w = disp.get_horizontal_resolution() - self.button_width - 5 # leave 5px for border if target_w == self.width and target_h == self.height: print("Target width and height are the same as native image, no scaling required.") return diff --git a/tests/test_graphical_camera_settings.py b/tests/test_graphical_camera_settings.py index 53ff342..ab75afa 100644 --- a/tests/test_graphical_camera_settings.py +++ b/tests/test_graphical_camera_settings.py @@ -112,8 +112,8 @@ def test_settings_button_click_no_crash(self): # 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 = 290 # Right side of screen, inside the 60px button - settings_y = 90 # 60px down from top, center of 60px button + settings_x = 300 # Right side of screen, inside the 60px button + settings_y = 60 # 60px down from top, center of 60px button print(f"\nClicking settings button at ({settings_x}, {settings_y})") simulate_click(settings_x, settings_y, press_duration_ms=100) From b0592f8e221f8cc2a7a950db0e9b1e95a66ae316 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 09:30:55 +0100 Subject: [PATCH 022/192] Webcam: only supported resolutions --- .../com.micropythonos.camera/assets/camera_app.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 6284766..a30d30c 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -439,13 +439,12 @@ class CameraSettingsActivity(Activity): # Resolution options for desktop/webcam WEBCAM_RESOLUTIONS = [ ("160x120", "160x120"), - ("240x240", "240x240"), # Default + ("320x180", "320x180"), ("320x240", "320x240"), - ("480x320", "480x320"), - ("640x480", "640x480"), - ("800x600", "800x600"), - ("1024x768", "1024x768"), - ("1280x720", "1280x720"), + ("640x360", "640x360"), + ("640x480 (30 fps)", "640x480"), + ("1280x720 (10 fps)", "1280x720"), + ("1920x1080 (5 fps)", "1920x1080"), ] # Resolution options for internal camera (ESP32) - all available FrameSize options From de35a9dd39c56ccdd678fe906bcf66f0f7eaf279 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 09:45:39 +0100 Subject: [PATCH 023/192] Camera app: tweak layout --- .../com.micropythonos.camera/assets/camera_app.py | 13 ++++++++----- internal_filesystem/lib/mpos/ui/display.py | 4 ++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index a30d30c..950fa03 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -103,8 +103,12 @@ def onCreate(self): self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) self.qr_label.center() self.status_label_cont = lv.obj(self.main_screen) - self.status_label_cont.set_size(lv.pct(66),lv.pct(60)) - self.status_label_cont.align(lv.ALIGN.LEFT_MID, lv.pct(5), 0) + width = mpos.ui.pct_of_display_width(70) + height = mpos.ui.pct_of_display_width(60) + self.status_label_cont.set_size(width,height) + center_w = round((mpos.ui.pct_of_display_width(100) - self.button_width - 5 - width)/2) + center_h = round((mpos.ui.pct_of_display_height(100) - height)/2) + self.status_label_cont.set_pos(center_w,center_h) self.status_label_cont.set_style_bg_color(lv.color_white(), 0) self.status_label_cont.set_style_bg_opa(66, 0) self.status_label_cont.set_style_border_width(0, 0) @@ -116,7 +120,6 @@ def onCreate(self): self.setContentView(self.main_screen) def onResume(self, screen): - self.create_preview_image() self.cam = init_internal_cam(self.width, self.height) if not self.cam: # try again because the manual i2c poweroff leaves it in a bad state @@ -279,8 +282,8 @@ def qr_button_click(self, e): self.stop_qr_decoding() def open_settings(self): - #self.main_screen.clean() - self.image.delete() + self.image_dsc.data = None + self.current_cam_buffer = None """Launch the camera settings activity.""" intent = Intent(activity_class=CameraSettingsActivity) self.startActivityForResult(intent, self.handle_settings_result) diff --git a/internal_filesystem/lib/mpos/ui/display.py b/internal_filesystem/lib/mpos/ui/display.py index 50ae7fa..991e165 100644 --- a/internal_filesystem/lib/mpos/ui/display.py +++ b/internal_filesystem/lib/mpos/ui/display.py @@ -24,9 +24,13 @@ def get_pointer_xy(): return -1, -1 def pct_of_display_width(pct): + if pct == 100: + return _horizontal_resolution return round(_horizontal_resolution * pct / 100) def pct_of_display_height(pct): + if pct == 100: + return _vertical_resolution return round(_vertical_resolution * pct / 100) def min_resolution(): From 385d551f3dd1619dc47948c079254528c91ad612 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 13:46:47 +0100 Subject: [PATCH 024/192] Fix camera_app R128x128 vs R128X128 --- .../apps/com.micropythonos.camera/assets/camera_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 950fa03..0bb080f 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -374,7 +374,7 @@ def init_internal_cam(width=320, height=240): resolution_map = { (96, 96): FrameSize.R96X96, (160, 120): FrameSize.QQVGA, - (128, 128): FrameSize.R128X128, + #(128, 128): FrameSize.R128X128, it's actually FrameSize.R128x128 but let's ignore it to be safe (176, 144): FrameSize.QCIF, (240, 176): FrameSize.HQVGA, (240, 240): FrameSize.R240X240, From 7679db36072cfbe35b59a535a7f1afb58c9f7dad Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 14:28:09 +0100 Subject: [PATCH 025/192] Refactor camera code: DRY, fix memory leak, improve error handling - Extract RGB conversion helper to eliminate duplication - Unify save functions - Fix buffer cleanup on error (memory leak) - Move retry logic into init_internal_cam() - Add error handling for failed camera reinitialization - Consolidate button sizing with class variables - Remove redundant initialization and unnecessary copies --- c_mpos/src/webcam.c | 92 +++++++------------ .../assets/camera_app.py | 69 ++++++++------ 2 files changed, 76 insertions(+), 85 deletions(-) diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index f1c71ad..83f08c3 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -31,6 +31,25 @@ typedef struct _webcam_obj_t { int height; // Resolution height } webcam_obj_t; +// Helper function to convert single YUV pixel to RGB565 +static inline uint16_t yuv_to_rgb565(int y_val, int u, int v) { + int c = y_val - 16; + int d = u - 128; + int e = v - 128; + + int r = (298 * c + 409 * e + 128) >> 8; + int g = (298 * c - 100 * d - 208 * e + 128) >> 8; + int b = (298 * c + 516 * d + 128) >> 8; + + // Clamp to valid range + r = r < 0 ? 0 : (r > 255 ? 255 : r); + g = g < 0 ? 0 : (g > 255 ? 255 : g); + b = b < 0 ? 0 : (b > 255 ? 255 : b); + + // Convert to RGB565 + return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); +} + static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int width, int height) { // Convert YUYV to RGB565 without scaling // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels, chroma shared) @@ -45,43 +64,9 @@ static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int width, int int y1 = yuyv[base_index + 2]; int v = yuyv[base_index + 3]; - // YUV to RGB conversion (ITU-R BT.601) for first pixel - int c = y0 - 16; - int d = u - 128; - int e = v - 128; - - int r = (298 * c + 409 * e + 128) >> 8; - int g = (298 * c - 100 * d - 208 * e + 128) >> 8; - int b = (298 * c + 516 * d + 128) >> 8; - - // Clamp to valid range - r = r < 0 ? 0 : (r > 255 ? 255 : r); - g = g < 0 ? 0 : (g > 255 ? 255 : g); - b = b < 0 ? 0 : (b > 255 ? 255 : b); - - // Convert to RGB565 - uint16_t r5 = (r >> 3) & 0x1F; - uint16_t g6 = (g >> 2) & 0x3F; - uint16_t b5 = (b >> 3) & 0x1F; - - rgb565[y * width + x] = (r5 << 11) | (g6 << 5) | b5; - - // Second pixel (shares U/V with first) - c = y1 - 16; - - r = (298 * c + 409 * e + 128) >> 8; - g = (298 * c - 100 * d - 208 * e + 128) >> 8; - b = (298 * c + 516 * d + 128) >> 8; - - r = r < 0 ? 0 : (r > 255 ? 255 : r); - g = g < 0 ? 0 : (g > 255 ? 255 : g); - b = b < 0 ? 0 : (b > 255 ? 255 : b); - - r5 = (r >> 3) & 0x1F; - g6 = (g >> 2) & 0x3F; - b5 = (b >> 3) & 0x1F; - - rgb565[y * width + x + 1] = (r5 << 11) | (g6 << 5) | b5; + // Convert both pixels (sharing U/V chroma) + rgb565[y * width + x] = yuv_to_rgb565(y0, u, v); + rgb565[y * width + x + 1] = yuv_to_rgb565(y1, u, v); } } } @@ -98,23 +83,13 @@ static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, int widt } } -static void save_raw(const char *filename, unsigned char *data, int width, int height) { +static void save_raw_generic(const char *filename, void *data, size_t elem_size, int width, int height) { FILE *fp = fopen(filename, "wb"); if (!fp) { WEBCAM_DEBUG_PRINT("Cannot open file %s: %s\n", filename, strerror(errno)); return; } - fwrite(data, 1, width * height, fp); - fclose(fp); -} - -static void save_raw_rgb565(const char *filename, uint16_t *data, int width, int height) { - FILE *fp = fopen(filename, "wb"); - if (!fp) { - WEBCAM_DEBUG_PRINT("Cannot open file %s: %s\n", filename, strerror(errno)); - return; - } - fwrite(data, sizeof(uint16_t), width * height, fp); + fwrite(data, elem_size, width * height, fp); fclose(fp); } @@ -162,6 +137,10 @@ static int init_webcam(webcam_obj_t *self, const char *device, int width, int he buf.index = i; if (ioctl(self->fd, VIDIOC_QUERYBUF, &buf) < 0) { WEBCAM_DEBUG_PRINT("Cannot query buffer: %s\n", strerror(errno)); + // Unmap any already-mapped buffers + for (int j = 0; j < i; j++) { + munmap(self->buffers[j], self->buffer_length); + } close(self->fd); return -errno; } @@ -169,6 +148,10 @@ static int init_webcam(webcam_obj_t *self, const char *device, int width, int he self->buffers[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, self->fd, buf.m.offset); if (self->buffers[i] == MAP_FAILED) { WEBCAM_DEBUG_PRINT("Cannot map buffer: %s\n", strerror(errno)); + // Unmap any already-mapped buffers + for (int j = 0; j < i; j++) { + munmap(self->buffers[j], self->buffer_length); + } close(self->fd); return -errno; } @@ -301,10 +284,6 @@ static mp_obj_t webcam_init(size_t n_args, const mp_obj_t *pos_args, mp_map_t *k webcam_obj_t *self = m_new_obj(webcam_obj_t); self->base.type = &webcam_type; self->fd = -1; - self->gray_buffer = NULL; - self->rgb565_buffer = NULL; - self->width = 0; // Will be set from V4L2 format in init_webcam - self->height = 0; // Will be set from V4L2 format in init_webcam int res = init_webcam(self, device, width, height); if (res < 0) { @@ -380,13 +359,10 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m WEBCAM_DEBUG_PRINT("Reconfiguring webcam: %dx%d -> %dx%d\n", self->width, self->height, new_width, new_height); - // Remember device path before deinit (which closes fd) - char device[64]; - strncpy(device, self->device, sizeof(device)); - // Clean shutdown and reinitialize with new resolution + // Note: deinit_webcam doesn't touch self->device, so it's safe to use directly deinit_webcam(self); - int res = init_webcam(self, device, new_width, new_height); + int res = init_webcam(self, self->device, new_width, new_height); if (res < 0) { mp_raise_OSError(-res); diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 0bb080f..6ae0d6e 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -20,6 +20,7 @@ class CameraApp(Activity): button_width = 40 + button_height = 40 width = 320 height = 240 @@ -71,7 +72,7 @@ def onCreate(self): # Initialize LVGL image widget self.create_preview_image() close_button = lv.button(self.main_screen) - close_button.set_size(self.button_width,40) + close_button.set_size(self.button_width, self.button_height) close_button.align(lv.ALIGN.TOP_RIGHT, 0, 0) close_label = lv.label(close_button) close_label.set_text(lv.SYMBOL.CLOSE) @@ -79,15 +80,15 @@ def onCreate(self): close_button.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) # Settings button settings_button = lv.button(self.main_screen) - settings_button.set_size(self.button_width,40) - settings_button.align(lv.ALIGN.TOP_RIGHT, 0, 50) + settings_button.set_size(self.button_width, self.button_height) + settings_button.align(lv.ALIGN.TOP_RIGHT, 0, self.button_height + 10) settings_label = lv.label(settings_button) settings_label.set_text(lv.SYMBOL.SETTINGS) settings_label.center() settings_button.add_event_cb(lambda e: self.open_settings(),lv.EVENT.CLICKED,None) self.snap_button = lv.button(self.main_screen) - self.snap_button.set_size(self.button_width, 40) + self.snap_button.set_size(self.button_width, self.button_height) self.snap_button.align(lv.ALIGN.RIGHT_MID, 0, 0) self.snap_button.add_flag(lv.obj.FLAG.HIDDEN) self.snap_button.add_event_cb(self.snap_button_click,lv.EVENT.CLICKED,None) @@ -95,7 +96,7 @@ def onCreate(self): snap_label.set_text(lv.SYMBOL.OK) snap_label.center() self.qr_button = lv.button(self.main_screen) - self.qr_button.set_size(self.button_width, 40) + self.qr_button.set_size(self.button_width, self.button_height) self.qr_button.add_flag(lv.obj.FLAG.HIDDEN) self.qr_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) self.qr_button.add_event_cb(self.qr_button_click,lv.EVENT.CLICKED,None) @@ -121,9 +122,6 @@ def onCreate(self): def onResume(self, screen): self.cam = init_internal_cam(self.width, self.height) - if not self.cam: - # try again because the manual i2c poweroff leaves it in a bad state - self.cam = init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees else: @@ -332,6 +330,11 @@ def handle_settings_result(self, result): if self.cam: self.capture_timer = lv.timer_create(self.try_capture, 100, None) print("Internal camera reinitialized, capture timer resumed") + else: + print("ERROR: Failed to reinitialize camera after resolution change") + self.status_label.set_text("Failed to reinitialize camera.\nPlease restart the app.") + self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) + return # Don't continue if camera failed self.set_image_size() @@ -364,8 +367,11 @@ def try_capture(self, event): # Non-class functions: -def init_internal_cam(width=320, height=240): - """Initialize internal camera with specified resolution.""" +def init_internal_cam(width, height): + """Initialize internal camera with specified resolution. + + Automatically retries once if initialization fails (to handle I2C poweroff issue). + """ try: from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling @@ -394,23 +400,32 @@ def init_internal_cam(width=320, height=240): frame_size = resolution_map.get((width, height), FrameSize.QVGA) print(f"init_internal_cam: Using FrameSize for {width}x{height}") - cam = Camera( - data_pins=[12,13,15,11,14,10,7,2], - vsync_pin=6, - href_pin=4, - sda_pin=21, - scl_pin=16, - pclk_pin=9, - xclk_pin=8, - xclk_freq=20000000, - powerdown_pin=-1, - reset_pin=-1, - pixel_format=PixelFormat.RGB565, - frame_size=frame_size, - grab_mode=GrabMode.LATEST - ) - cam.set_vflip(True) - return cam + # Try to initialize, with one retry for I2C poweroff issue + for attempt in range(2): + try: + cam = Camera( + data_pins=[12,13,15,11,14,10,7,2], + vsync_pin=6, + href_pin=4, + sda_pin=21, + scl_pin=16, + pclk_pin=9, + xclk_pin=8, + xclk_freq=20000000, + powerdown_pin=-1, + reset_pin=-1, + pixel_format=PixelFormat.RGB565, + frame_size=frame_size, + grab_mode=GrabMode.LATEST + ) + cam.set_vflip(True) + return cam + except Exception as e: + if attempt == 0: + print(f"init_cam attempt {attempt + 1} failed: {e}, retrying...") + else: + print(f"init_cam exception: {e}") + return None except Exception as e: print(f"init_cam exception: {e}") return None From 3e9fbc380c748c66e443d2c5089c65c1958b6ec7 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 25 Nov 2025 16:14:49 +0100 Subject: [PATCH 026/192] Camera: retry more --- .../apps/com.micropythonos.camera/assets/camera_app.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 6ae0d6e..81b7afa 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -401,7 +401,8 @@ def init_internal_cam(width, height): print(f"init_internal_cam: Using FrameSize for {width}x{height}") # Try to initialize, with one retry for I2C poweroff issue - for attempt in range(2): + max_attempts = 3 + for attempt in range(max_attempts): try: cam = Camera( data_pins=[12,13,15,11,14,10,7,2], @@ -421,10 +422,10 @@ def init_internal_cam(width, height): cam.set_vflip(True) return cam except Exception as e: - if attempt == 0: - print(f"init_cam attempt {attempt + 1} failed: {e}, retrying...") + if attempt < max_attempts-1: + print(f"init_cam attempt {attempt} failed: {e}, retrying...") else: - print(f"init_cam exception: {e}") + print(f"init_cam final exception: {e}") return None except Exception as e: print(f"init_cam exception: {e}") From 8c1903d05d6f8f430df43cee74709ab3fa48a31c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 26 Nov 2025 08:05:26 +0100 Subject: [PATCH 027/192] Camera app: add experimental zoom button --- .../assets/camera_app.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 81b7afa..77c5ea8 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -95,6 +95,16 @@ def onCreate(self): snap_label = lv.label(self.snap_button) snap_label.set_text(lv.SYMBOL.OK) snap_label.center() + self.zoom_button = lv.button(self.main_screen) + self.zoom_button.set_size(self.button_width, self.button_height) + self.zoom_button.align(lv.ALIGN.RIGHT_MID, 0, self.button_height + 10) + #self.zoom_button.add_flag(lv.obj.FLAG.HIDDEN) + self.zoom_button.add_event_cb(self.zoom_button_click,lv.EVENT.CLICKED,None) + zoom_label = lv.label(self.zoom_button) + zoom_label.set_text("Z") + zoom_label.center() + + self.qr_button = lv.button(self.main_screen) self.qr_button.set_size(self.button_width, self.button_height) self.qr_button.add_flag(lv.obj.FLAG.HIDDEN) @@ -279,6 +289,12 @@ def qr_button_click(self, e): else: self.stop_qr_decoding() + def zoom_button_click(self, e): + print("zooming...") + if self.cam: + # This might work as it's what works in the C code: + self.cam.set_res_raw(startX=0,startY=0,endX=2623,endY=1951,offsetX=992,offsetY=736,totalX=2844,totalY=2844,outputX=640,outputY=480,scale=False,binning=False) + def open_settings(self): self.image_dsc.data = None self.current_cam_buffer = None From ef0cb980f2dede3eb5ab0706d6086e5fe30a4d15 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 26 Nov 2025 09:25:43 +0100 Subject: [PATCH 028/192] Settings app: fix un-checking of radio button --- .../com.micropythonos.settings/assets/settings.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 37b84e5..51262e7 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -260,18 +260,18 @@ def radio_event_handler(self, event): target_obj_state = target_obj.get_state() print(f"target_obj state {target_obj.get_text()} is {target_obj_state}") checked = target_obj_state & lv.STATE.CHECKED + current_checkbox_index = target_obj.get_index() + print(f"current_checkbox_index: {current_checkbox_index}") if not checked: - print("it's not checked, nothing to do!") + if self.active_radio_index == current_checkbox_index: + print(f"unchecking {current_checkbox_index}") + self.active_radio_index = -1 # nothing checked return else: - new_checked = target_obj.get_index() - print(f"new_checked: {new_checked}") - if self.active_radio_index >= 0: + if self.active_radio_index >= 0: # is there something to uncheck? old_checked = self.radio_container.get_child(self.active_radio_index) old_checked.remove_state(lv.STATE.CHECKED) - new_checked_obj = self.radio_container.get_child(new_checked) - new_checked_obj.add_state(lv.STATE.CHECKED) - self.active_radio_index = new_checked + self.active_radio_index = current_checkbox_index def create_radio_button(self, parent, text, index): cb = lv.checkbox(parent) From 4f18d8491d07649b52f79b46ce18d6eca88ae130 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 26 Nov 2025 09:26:49 +0100 Subject: [PATCH 029/192] Update CHANGELOG --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75cde3c..534a603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ 0.5.1 ===== -- OSUpdate app: pause download when wifi is lost, resume when reconnected - Fri3d Camp 2024 Badge: workaround ADC2+WiFi conflict by temporarily disable WiFi to measure battery level -- Fri3d Camp 2024 Badge: improve battery monitor calibration +- Fri3d Camp 2024 Badge: improve battery monitor calibration to fix 0.1V delta - AppStore app: remove unnecessary scrollbar over publisher's name +- OSUpdate app: pause download when wifi is lost, resume when reconnected +- Settings app: fix un-checking of radio button 0.5.0 ===== From d798aff80ec5d2f070329da85feb679866cacbbf Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 26 Nov 2025 10:35:25 +0100 Subject: [PATCH 030/192] Add camera settings --- CLAUDE.md | 25 +- .../assets/camera_app.py | 557 ++++++++++++++++-- 2 files changed, 515 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f7aa3b0..a8f4917 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,26 +73,23 @@ The OS supports: The main build script is `scripts/build_mpos.sh`: ```bash -# Development build (no frozen filesystem, requires ./scripts/install.sh after flashing) -./scripts/build_mpos.sh unix dev +# Build for desktop (Linux) +./scripts/build_mpos.sh unix -# Production build (with frozen filesystem) -./scripts/build_mpos.sh unix prod +# Build for desktop (macOS) +./scripts/build_mpos.sh macOS -# ESP32 builds (specify hardware variant) -./scripts/build_mpos.sh esp32 dev waveshare-esp32-s3-touch-lcd-2 -./scripts/build_mpos.sh esp32 prod fri3d-2024 +# Build for ESP32-S3 hardware (works on both waveshare and fri3d variants) +./scripts/build_mpos.sh esp32 ``` -**Build types**: -- `dev`: No preinstalled files or builtin filesystem. Boots to black screen until you run `./scripts/install.sh` -- `prod`: Files from `manifest*.py` are frozen into firmware. Run `./scripts/freezefs_mount_builtin.sh` before building - **Targets**: -- `esp32`: ESP32-S3 hardware (requires subtarget: `waveshare-esp32-s3-touch-lcd-2` or `fri3d-2024`) +- `esp32`: ESP32-S3 hardware (supports waveshare-esp32-s3-touch-lcd-2 and fri3d-2024) - `unix`: Linux desktop - `macOS`: macOS desktop +**Note**: The build system automatically includes the frozen filesystem with all built-in apps and libraries. No separate dev/prod distinction exists anymore. + The build system uses `lvgl_micropython/make.py` which wraps MicroPython's build system. It: 1. Fetches SDL tags for desktop builds 2. Patches manifests to include camera and asyncio support @@ -312,10 +309,10 @@ See `internal_filesystem/apps/com.micropythonos.helloworld/` for a minimal examp For rapid iteration on desktop: ```bash # Build desktop version (only needed once) -./scripts/build_mpos.sh unix dev +./scripts/build_mpos.sh unix # Install filesystem to device (run after code changes) -./scripts/install.sh waveshare-esp32-s3-touch-lcd-2 +./scripts/install.sh # Or run directly on desktop ./scripts/run_desktop.sh com.example.myapp diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 77c5ea8..c5afd68 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -134,6 +134,8 @@ def onResume(self, screen): self.cam = init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees + # Apply saved camera settings + apply_camera_settings(self.cam, self.use_webcam) else: print("camera app: no internal camera found, trying webcam on /dev/video0") try: @@ -344,6 +346,8 @@ def handle_settings_result(self, result): self.cam.deinit() self.cam = init_internal_cam(self.width, self.height) if self.cam: + # Apply all camera settings + apply_camera_settings(self.cam, self.use_webcam) self.capture_timer = lv.timer_create(self.try_capture, 100, None) print("Internal camera reinitialized, capture timer resumed") else: @@ -468,8 +472,127 @@ def remove_bom(buffer): return buffer +def apply_camera_settings(cam, use_webcam): + """Apply all saved camera settings from SharedPreferences to ESP32 camera. + + Only applies settings when use_webcam is False (ESP32 camera). + Settings are applied in dependency order (master switches before dependent values). + + Args: + cam: Camera object + use_webcam: Boolean indicating if using webcam + """ + if not cam or use_webcam: + print("apply_camera_settings: Skipping (no camera or webcam mode)") + return + + prefs = SharedPreferences("com.micropythonos.camera") + + try: + # Basic image adjustments + brightness = prefs.get_int("brightness", 0) + cam.set_brightness(brightness) + + contrast = prefs.get_int("contrast", 0) + cam.set_contrast(contrast) + + saturation = prefs.get_int("saturation", 0) + cam.set_saturation(saturation) + + # Orientation + hmirror = prefs.get_bool("hmirror", False) + cam.set_hmirror(hmirror) + + vflip = prefs.get_bool("vflip", True) + cam.set_vflip(vflip) + + # Special effect + special_effect = prefs.get_int("special_effect", 0) + cam.set_special_effect(special_effect) + + # Exposure control (apply master switch first, then manual value) + exposure_ctrl = prefs.get_bool("exposure_ctrl", True) + cam.set_exposure_ctrl(exposure_ctrl) + + if not exposure_ctrl: + aec_value = prefs.get_int("aec_value", 300) + cam.set_aec_value(aec_value) + + ae_level = prefs.get_int("ae_level", 0) + cam.set_ae_level(ae_level) + + aec2 = prefs.get_bool("aec2", False) + cam.set_aec2(aec2) + + # Gain control (apply master switch first, then manual value) + gain_ctrl = prefs.get_bool("gain_ctrl", True) + cam.set_gain_ctrl(gain_ctrl) + + if not gain_ctrl: + agc_gain = prefs.get_int("agc_gain", 0) + cam.set_agc_gain(agc_gain) + + gainceiling = prefs.get_int("gainceiling", 0) + cam.set_gainceiling(gainceiling) + + # White balance (apply master switch first, then mode) + whitebal = prefs.get_bool("whitebal", True) + cam.set_whitebal(whitebal) + + if not whitebal: + wb_mode = prefs.get_int("wb_mode", 0) + cam.set_wb_mode(wb_mode) + + awb_gain = prefs.get_bool("awb_gain", True) + cam.set_awb_gain(awb_gain) + + # Sensor-specific settings (try/except for unsupported sensors) + try: + sharpness = prefs.get_int("sharpness", 0) + cam.set_sharpness(sharpness) + except: + pass # Not supported on OV2640 + + try: + denoise = prefs.get_int("denoise", 0) + cam.set_denoise(denoise) + except: + pass # Not supported on OV2640 + + # Advanced corrections + colorbar = prefs.get_bool("colorbar", False) + cam.set_colorbar(colorbar) + + dcw = prefs.get_bool("dcw", True) + cam.set_dcw(dcw) + + bpc = prefs.get_bool("bpc", False) + cam.set_bpc(bpc) + + wpc = prefs.get_bool("wpc", True) + cam.set_wpc(wpc) + + raw_gma = prefs.get_bool("raw_gma", True) + cam.set_raw_gma(raw_gma) + + lenc = prefs.get_bool("lenc", True) + cam.set_lenc(lenc) + + # JPEG quality (only relevant for JPEG format) + try: + quality = prefs.get_int("quality", 85) + cam.set_quality(quality) + except: + pass # Not in JPEG mode + + print("Camera settings applied successfully") + + except Exception as e: + print(f"Error applying camera settings: {e}") + + class CameraSettingsActivity(Activity): - """Settings activity for camera resolution configuration.""" + """Settings activity for comprehensive camera configuration.""" # Resolution options for desktop/webcam WEBCAM_RESOLUTIONS = [ @@ -482,14 +605,14 @@ class CameraSettingsActivity(Activity): ("1920x1080 (5 fps)", "1920x1080"), ] - # Resolution options for internal camera (ESP32) - all available FrameSize options + # Resolution options for internal camera (ESP32) ESP32_RESOLUTIONS = [ ("96x96", "96x96"), ("160x120", "160x120"), ("128x128", "128x128"), ("176x144", "176x144"), ("240x176", "240x176"), - ("240x240", "240x240"), # Default + ("240x240", "240x240"), ("320x240", "320x240"), ("320x320", "320x320"), ("400x296", "400x296"), @@ -503,66 +626,73 @@ class CameraSettingsActivity(Activity): ("1920x1080", "1920x1080"), ] - dropdown = None - current_resolution = None + def __init__(self): + super().__init__() + self.ui_controls = {} + self.control_metadata = {} # Store pref_key and option_values for each control + self.dependent_controls = {} + self.is_webcam = False + self.resolutions = [] def onCreate(self): # Load preferences prefs = SharedPreferences("com.micropythonos.camera") - self.current_resolution = prefs.get_string("resolution", "320x240") + + # Detect platform (webcam vs ESP32) + try: + import webcam + self.is_webcam = True + self.resolutions = self.WEBCAM_RESOLUTIONS + print("Using webcam resolutions") + except: + self.resolutions = self.ESP32_RESOLUTIONS + print("Using ESP32 camera resolutions") # Create main screen screen = lv.obj() screen.set_size(lv.pct(100), lv.pct(100)) - screen.set_style_pad_all(10, 0) + screen.set_style_pad_all(5, 0) # Title title = lv.label(screen) title.set_text("Camera Settings") - title.align(lv.ALIGN.TOP_MID, 0, 10) + title.align(lv.ALIGN.TOP_MID, 0, 5) - # Resolution label - resolution_label = lv.label(screen) - resolution_label.set_text("Resolution:") - resolution_label.align(lv.ALIGN.TOP_LEFT, 0, 50) + # Create tabview + tabview = lv.tabview(screen) + tabview.set_size(lv.pct(100), lv.pct(82)) + tabview.align(lv.ALIGN.TOP_MID, 0, 30) - # Detect if we're on desktop or ESP32 based on available modules - try: - import webcam - resolutions = self.WEBCAM_RESOLUTIONS - print("Using webcam resolutions") - except: - resolutions = self.ESP32_RESOLUTIONS - print("Using ESP32 camera resolutions") - - # Create dropdown - self.dropdown = lv.dropdown(screen) - self.dropdown.set_size(200, 40) - self.dropdown.align(lv.ALIGN.TOP_LEFT, 0, 80) - - # Build dropdown options string - options_str = "\n".join([label for label, _ in resolutions]) - self.dropdown.set_options(options_str) - - # Set current selection - for idx, (label, value) in enumerate(resolutions): - if value == self.current_resolution: - self.dropdown.set_selected(idx) - break + # Create Basic tab (always) + basic_tab = tabview.add_tab("Basic") + self.create_basic_tab(basic_tab, prefs) + + # Create Advanced and Expert tabs only for ESP32 camera + if not self.is_webcam: + advanced_tab = tabview.add_tab("Advanced") + self.create_advanced_tab(advanced_tab, prefs) + + expert_tab = tabview.add_tab("Expert") + self.create_expert_tab(expert_tab, prefs) - # Save button - save_button = lv.button(screen) - save_button.set_size(100, 50) - save_button.align(lv.ALIGN.BOTTOM_MID, -60, -10) - save_button.add_event_cb(lambda e: self.save_and_close(resolutions), lv.EVENT.CLICKED, None) + # Save/Cancel buttons at bottom + button_cont = lv.obj(screen) + button_cont.set_size(lv.pct(100), 50) + button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) + button_cont.set_style_border_width(0, 0) + button_cont.set_style_bg_opa(0, 0) + + save_button = lv.button(button_cont) + save_button.set_size(100, 40) + save_button.align(lv.ALIGN.CENTER, -60, 0) + save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) save_label = lv.label(save_button) save_label.set_text("Save") save_label.center() - # Cancel button - cancel_button = lv.button(screen) - cancel_button.set_size(100, 50) - cancel_button.align(lv.ALIGN.BOTTOM_MID, 60, -10) + cancel_button = lv.button(button_cont) + cancel_button.set_size(100, 40) + cancel_button.align(lv.ALIGN.CENTER, 60, 0) cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) cancel_label = lv.label(cancel_button) cancel_label.set_text("Cancel") @@ -570,19 +700,340 @@ def onCreate(self): self.setContentView(screen) - def save_and_close(self, resolutions): - """Save selected resolution and return result.""" - selected_idx = self.dropdown.get_selected() - _, new_resolution = resolutions[selected_idx] + def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_key): + """Create slider with label showing current value.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(95), 50) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(f"{label_text}: {default_val}") + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + slider = lv.slider(cont) + slider.set_size(lv.pct(90), 15) + slider.set_range(min_val, max_val) + slider.set_value(default_val, False) + slider.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + + def slider_changed(e): + val = slider.get_value() + label.set_text(f"{label_text}: {val}") + + slider.add_event_cb(slider_changed, lv.EVENT.VALUE_CHANGED, None) + + # Store metadata separately + self.control_metadata[id(slider)] = {"pref_key": pref_key, "type": "slider"} + + return slider, label, cont - # Save to preferences + def create_checkbox(self, parent, label_text, default_val, pref_key): + """Create checkbox with label.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(95), 35) + cont.set_style_pad_all(3, 0) + + checkbox = lv.checkbox(cont) + checkbox.set_text(label_text) + if default_val: + checkbox.add_state(lv.STATE.CHECKED) + checkbox.align(lv.ALIGN.LEFT_MID, 0, 0) + + # Store metadata separately + self.control_metadata[id(checkbox)] = {"pref_key": pref_key, "type": "checkbox"} + + return checkbox, cont + + def create_dropdown(self, parent, label_text, options, default_idx, pref_key): + """Create dropdown with label.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(95), 60) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(label_text) + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + dropdown = lv.dropdown(cont) + dropdown.set_size(lv.pct(90), 30) + dropdown.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + + options_str = "\n".join([text for text, _ in options]) + dropdown.set_options(options_str) + dropdown.set_selected(default_idx) + + # Store metadata separately + option_values = [val for _, val in options] + self.control_metadata[id(dropdown)] = { + "pref_key": pref_key, + "type": "dropdown", + "option_values": option_values + } + + return dropdown, cont + + def create_basic_tab(self, tab, prefs): + """Create Basic settings tab.""" + tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_style_pad_all(5, 0) + + # Resolution dropdown + current_resolution = prefs.get_string("resolution", "320x240") + resolution_idx = 0 + for idx, (_, value) in enumerate(self.resolutions): + if value == current_resolution: + resolution_idx = idx + break + + dropdown, cont = self.create_dropdown(tab, "Resolution:", self.resolutions, + resolution_idx, "resolution") + self.ui_controls["resolution"] = dropdown + + # Brightness + brightness = prefs.get_int("brightness", 0) + slider, label, cont = self.create_slider(tab, "Brightness", -2, 2, brightness, "brightness") + self.ui_controls["brightness"] = slider + + # Contrast + contrast = prefs.get_int("contrast", 0) + slider, label, cont = self.create_slider(tab, "Contrast", -2, 2, contrast, "contrast") + self.ui_controls["contrast"] = slider + + # Saturation + saturation = prefs.get_int("saturation", 0) + slider, label, cont = self.create_slider(tab, "Saturation", -2, 2, saturation, "saturation") + self.ui_controls["saturation"] = slider + + # Horizontal Mirror + hmirror = prefs.get_bool("hmirror", False) + checkbox, cont = self.create_checkbox(tab, "Horizontal Mirror", hmirror, "hmirror") + self.ui_controls["hmirror"] = checkbox + + # Vertical Flip + vflip = prefs.get_bool("vflip", True) + checkbox, cont = self.create_checkbox(tab, "Vertical Flip", vflip, "vflip") + self.ui_controls["vflip"] = checkbox + + # Special Effect + special_effect_options = [ + ("None", 0), ("Negative", 1), ("B&W", 2), + ("Reddish", 3), ("Greenish", 4), ("Blue", 5), ("Retro", 6) + ] + special_effect = prefs.get_int("special_effect", 0) + dropdown, cont = self.create_dropdown(tab, "Special Effect:", special_effect_options, + special_effect, "special_effect") + self.ui_controls["special_effect"] = dropdown + + def create_advanced_tab(self, tab, prefs): + """Create Advanced settings tab.""" + tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_style_pad_all(5, 0) + + # Auto Exposure Control (master switch) + exposure_ctrl = prefs.get_bool("exposure_ctrl", True) + checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") + self.ui_controls["exposure_ctrl"] = checkbox + + # Manual Exposure Value (dependent) + aec_value = prefs.get_int("aec_value", 300) + slider, label, cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") + self.ui_controls["aec_value"] = slider + + # Set initial state + if exposure_ctrl: + slider.add_state(lv.STATE.DISABLED) + slider.set_style_bg_opa(128, 0) + + # Add dependency handler + def exposure_ctrl_changed(e): + is_auto = checkbox.get_state() & lv.STATE.CHECKED + if is_auto: + slider.add_state(lv.STATE.DISABLED) + slider.set_style_bg_opa(128, 0) + else: + slider.remove_state(lv.STATE.DISABLED) + slider.set_style_bg_opa(255, 0) + + checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + + # Auto Exposure Level + ae_level = prefs.get_int("ae_level", 0) + slider, label, cont = self.create_slider(tab, "AE Level", -2, 2, ae_level, "ae_level") + self.ui_controls["ae_level"] = slider + + # Night Mode (AEC2) + aec2 = prefs.get_bool("aec2", False) + checkbox, cont = self.create_checkbox(tab, "Night Mode (AEC2)", aec2, "aec2") + self.ui_controls["aec2"] = checkbox + + # Auto Gain Control (master switch) + gain_ctrl = prefs.get_bool("gain_ctrl", True) + checkbox, cont = self.create_checkbox(tab, "Auto Gain", gain_ctrl, "gain_ctrl") + self.ui_controls["gain_ctrl"] = checkbox + + # Manual Gain Value (dependent) + agc_gain = prefs.get_int("agc_gain", 0) + slider, label, cont = self.create_slider(tab, "Manual Gain", 0, 30, agc_gain, "agc_gain") + self.ui_controls["agc_gain"] = slider + + if gain_ctrl: + slider.add_state(lv.STATE.DISABLED) + slider.set_style_bg_opa(128, 0) + + def gain_ctrl_changed(e): + is_auto = checkbox.get_state() & lv.STATE.CHECKED + gain_slider = self.ui_controls["agc_gain"] + if is_auto: + gain_slider.add_state(lv.STATE.DISABLED) + gain_slider.set_style_bg_opa(128, 0) + else: + gain_slider.remove_state(lv.STATE.DISABLED) + gain_slider.set_style_bg_opa(255, 0) + + checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + + # Gain Ceiling + gainceiling_options = [ + ("2X", 0), ("4X", 1), ("8X", 2), ("16X", 3), + ("32X", 4), ("64X", 5), ("128X", 6) + ] + gainceiling = prefs.get_int("gainceiling", 0) + dropdown, cont = self.create_dropdown(tab, "Gain Ceiling:", gainceiling_options, + gainceiling, "gainceiling") + self.ui_controls["gainceiling"] = dropdown + + # Auto White Balance (master switch) + whitebal = prefs.get_bool("whitebal", True) + checkbox, cont = self.create_checkbox(tab, "Auto White Balance", whitebal, "whitebal") + self.ui_controls["whitebal"] = checkbox + + # White Balance Mode (dependent) + wb_mode_options = [ + ("Auto", 0), ("Sunny", 1), ("Cloudy", 2), ("Office", 3), ("Home", 4) + ] + wb_mode = prefs.get_int("wb_mode", 0) + dropdown, cont = self.create_dropdown(tab, "WB Mode:", wb_mode_options, wb_mode, "wb_mode") + self.ui_controls["wb_mode"] = dropdown + + if whitebal: + dropdown.add_state(lv.STATE.DISABLED) + + def whitebal_changed(e): + is_auto = checkbox.get_state() & lv.STATE.CHECKED + wb_dropdown = self.ui_controls["wb_mode"] + if is_auto: + wb_dropdown.add_state(lv.STATE.DISABLED) + else: + wb_dropdown.remove_state(lv.STATE.DISABLED) + + checkbox.add_event_cb(whitebal_changed, lv.EVENT.VALUE_CHANGED, None) + + # AWB Gain + awb_gain = prefs.get_bool("awb_gain", True) + checkbox, cont = self.create_checkbox(tab, "AWB Gain", awb_gain, "awb_gain") + self.ui_controls["awb_gain"] = checkbox + + def create_expert_tab(self, tab, prefs): + """Create Expert settings tab.""" + tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_style_pad_all(5, 0) + + # Note: Sensor detection would require camera access + # For now, show sharpness/denoise with note + supports_sharpness = False # Conservative default + + # Sharpness + sharpness = prefs.get_int("sharpness", 0) + slider, label, cont = self.create_slider(tab, "Sharpness", -3, 3, sharpness, "sharpness") + self.ui_controls["sharpness"] = slider + + if not supports_sharpness: + slider.add_state(lv.STATE.DISABLED) + slider.set_style_bg_opa(128, 0) + note = lv.label(cont) + note.set_text("(Not available on this sensor)") + note.set_style_text_color(lv.color_hex(0x808080), 0) + note.align(lv.ALIGN.TOP_RIGHT, 0, 0) + + # Denoise + denoise = prefs.get_int("denoise", 0) + slider, label, cont = self.create_slider(tab, "Denoise", 0, 8, denoise, "denoise") + self.ui_controls["denoise"] = slider + + if not supports_sharpness: + slider.add_state(lv.STATE.DISABLED) + slider.set_style_bg_opa(128, 0) + note = lv.label(cont) + note.set_text("(Not available on this sensor)") + note.set_style_text_color(lv.color_hex(0x808080), 0) + note.align(lv.ALIGN.TOP_RIGHT, 0, 0) + + # JPEG Quality + quality = prefs.get_int("quality", 85) + slider, label, cont = self.create_slider(tab, "JPEG Quality", 0, 100, quality, "quality") + self.ui_controls["quality"] = slider + + # Color Bar + colorbar = prefs.get_bool("colorbar", False) + checkbox, cont = self.create_checkbox(tab, "Color Bar Test", colorbar, "colorbar") + self.ui_controls["colorbar"] = checkbox + + # DCW Mode + dcw = prefs.get_bool("dcw", True) + checkbox, cont = self.create_checkbox(tab, "DCW Mode", dcw, "dcw") + self.ui_controls["dcw"] = checkbox + + # Black Point Compensation + bpc = prefs.get_bool("bpc", False) + checkbox, cont = self.create_checkbox(tab, "Black Point Compensation", bpc, "bpc") + self.ui_controls["bpc"] = checkbox + + # White Point Compensation + wpc = prefs.get_bool("wpc", True) + checkbox, cont = self.create_checkbox(tab, "White Point Compensation", wpc, "wpc") + self.ui_controls["wpc"] = checkbox + + # Raw Gamma Mode + raw_gma = prefs.get_bool("raw_gma", True) + checkbox, cont = self.create_checkbox(tab, "Raw Gamma Mode", raw_gma, "raw_gma") + self.ui_controls["raw_gma"] = checkbox + + # Lens Correction + lenc = prefs.get_bool("lenc", True) + checkbox, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") + self.ui_controls["lenc"] = checkbox + + def save_and_close(self): + """Save all settings to SharedPreferences and return result.""" prefs = SharedPreferences("com.micropythonos.camera") editor = prefs.edit() - editor.put_string("resolution", new_resolution) - editor.commit() - print(f"Camera resolution saved: {new_resolution}") + # Save all UI control values + for pref_key, control in self.ui_controls.items(): + control_id = id(control) + metadata = self.control_metadata.get(control_id, {}) + + if isinstance(control, lv.slider): + value = control.get_value() + editor.put_int(pref_key, value) + elif isinstance(control, lv.checkbox): + is_checked = control.get_state() & lv.STATE.CHECKED + editor.put_bool(pref_key, bool(is_checked)) + elif isinstance(control, lv.dropdown): + selected_idx = control.get_selected() + option_values = metadata.get("option_values", []) + if pref_key == "resolution": + # Resolution stored as string + value = option_values[selected_idx] + editor.put_string(pref_key, value) + else: + # Other dropdowns store integer enum values + value = option_values[selected_idx] + editor.put_int(pref_key, value) + + editor.commit() + print("Camera settings saved") # Return success result - self.setResult(True, {"resolution": new_resolution}) + self.setResult(True, {"settings_changed": True}) self.finish() From 2b8ea889610e2d882cb90543deca8af6d0057d54 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 26 Nov 2025 11:07:59 +0100 Subject: [PATCH 031/192] Camera app: improve settings UI --- .../assets/camera_app.py | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index c5afd68..521eb95 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -653,15 +653,10 @@ def onCreate(self): screen.set_size(lv.pct(100), lv.pct(100)) screen.set_style_pad_all(5, 0) - # Title - title = lv.label(screen) - title.set_text("Camera Settings") - title.align(lv.ALIGN.TOP_MID, 0, 5) - # Create tabview tabview = lv.tabview(screen) - tabview.set_size(lv.pct(100), lv.pct(82)) - tabview.align(lv.ALIGN.TOP_MID, 0, 30) + tabview.set_tab_bar_size(mpos.ui.pct_of_display_height(10)) + tabview.set_size(lv.pct(100), mpos.ui.pct_of_display_height(85)) # Create Basic tab (always) basic_tab = tabview.add_tab("Basic") @@ -677,13 +672,14 @@ def onCreate(self): # Save/Cancel buttons at bottom button_cont = lv.obj(screen) - button_cont.set_size(lv.pct(100), 50) + button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(15)) + button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) button_cont.set_style_border_width(0, 0) button_cont.set_style_bg_opa(0, 0) save_button = lv.button(button_cont) - save_button.set_size(100, 40) + save_button.set_size(100, mpos.ui.pct_of_display_height(14)) save_button.align(lv.ALIGN.CENTER, -60, 0) save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) save_label = lv.label(save_button) @@ -691,7 +687,7 @@ def onCreate(self): save_label.center() cancel_button = lv.button(button_cont) - cancel_button.set_size(100, 40) + cancel_button.set_size(100, mpos.ui.pct_of_display_height(15)) cancel_button.align(lv.ALIGN.CENTER, 60, 0) cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) cancel_label = lv.label(cancel_button) @@ -703,7 +699,7 @@ def onCreate(self): def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_key): """Create slider with label showing current value.""" cont = lv.obj(parent) - cont.set_size(lv.pct(95), 50) + cont.set_size(lv.pct(100), 60) cont.set_style_pad_all(3, 0) label = lv.label(cont) @@ -714,7 +710,7 @@ def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_ slider.set_size(lv.pct(90), 15) slider.set_range(min_val, max_val) slider.set_value(default_val, False) - slider.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + slider.align(lv.ALIGN.BOTTOM_MID, 0, -10) def slider_changed(e): val = slider.get_value() @@ -730,7 +726,7 @@ def slider_changed(e): def create_checkbox(self, parent, label_text, default_val, pref_key): """Create checkbox with label.""" cont = lv.obj(parent) - cont.set_size(lv.pct(95), 35) + cont.set_size(lv.pct(100), 35) cont.set_style_pad_all(3, 0) checkbox = lv.checkbox(cont) @@ -747,7 +743,7 @@ def create_checkbox(self, parent, label_text, default_val, pref_key): def create_dropdown(self, parent, label_text, options, default_idx, pref_key): """Create dropdown with label.""" cont = lv.obj(parent) - cont.set_size(lv.pct(95), 60) + cont.set_size(lv.pct(100), 60) cont.set_style_pad_all(3, 0) label = lv.label(cont) @@ -774,8 +770,8 @@ def create_dropdown(self, parent, label_text, options, default_idx, pref_key): def create_basic_tab(self, tab, prefs): """Create Basic settings tab.""" + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) - tab.set_style_pad_all(5, 0) # Resolution dropdown current_resolution = prefs.get_string("resolution", "320x240") @@ -827,7 +823,7 @@ def create_basic_tab(self, tab, prefs): def create_advanced_tab(self, tab, prefs): """Create Advanced settings tab.""" tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) - tab.set_style_pad_all(5, 0) + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) # Auto Exposure Control (master switch) exposure_ctrl = prefs.get_bool("exposure_ctrl", True) @@ -936,7 +932,7 @@ def whitebal_changed(e): def create_expert_tab(self, tab, prefs): """Create Expert settings tab.""" tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) - tab.set_style_pad_all(5, 0) + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) # Note: Sensor detection would require camera access # For now, show sharpness/denoise with note From 9ae929aad95b73f38ded429b7eb28ea405e9f0f6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 26 Nov 2025 11:41:42 +0100 Subject: [PATCH 032/192] SharedPreferences: add erase_all() functionality --- internal_filesystem/lib/mpos/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal_filesystem/lib/mpos/config.py b/internal_filesystem/lib/mpos/config.py index 1331a59..99821c3 100644 --- a/internal_filesystem/lib/mpos/config.py +++ b/internal_filesystem/lib/mpos/config.py @@ -193,6 +193,10 @@ def remove_dict_item(self, dict_key, item_key): pass return self + def remove_all(self): + self.temp_data = {} + return self + def apply(self): """Save changes to the file asynchronously (emulated).""" self.preferences.data = self.temp_data.copy() From 5c2fee33f7abacf5e86beeb1d64f2dee79c1928d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 26 Nov 2025 11:42:25 +0100 Subject: [PATCH 033/192] Camera app: add "Erase" button and tweak UI --- CHANGELOG.md | 1 + .../assets/camera_app.py | 65 +++++++++++++------ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534a603..ef495f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - AppStore app: remove unnecessary scrollbar over publisher's name - OSUpdate app: pause download when wifi is lost, resume when reconnected - Settings app: fix un-checking of radio button +- API: SharedPreferences: add erase_all() functionality 0.5.0 ===== diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 521eb95..8fd0bba 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -65,7 +65,7 @@ def onCreate(self): self.load_resolution_preference() self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") self.main_screen = lv.obj() - self.main_screen.set_style_pad_all(0, 0) + self.main_screen.set_style_pad_all(1, 0) self.main_screen.set_style_border_width(0, 0) self.main_screen.set_size(lv.pct(100), lv.pct(100)) self.main_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) @@ -651,49 +651,60 @@ def onCreate(self): # Create main screen screen = lv.obj() screen.set_size(lv.pct(100), lv.pct(100)) - screen.set_style_pad_all(5, 0) + screen.set_style_pad_all(1, 0) # Create tabview tabview = lv.tabview(screen) tabview.set_tab_bar_size(mpos.ui.pct_of_display_height(10)) - tabview.set_size(lv.pct(100), mpos.ui.pct_of_display_height(85)) + tabview.set_size(lv.pct(100), mpos.ui.pct_of_display_height(80)) # Create Basic tab (always) basic_tab = tabview.add_tab("Basic") self.create_basic_tab(basic_tab, prefs) # Create Advanced and Expert tabs only for ESP32 camera - if not self.is_webcam: + if not self.is_webcam or True: # for now, show all tabs advanced_tab = tabview.add_tab("Advanced") self.create_advanced_tab(advanced_tab, prefs) expert_tab = tabview.add_tab("Expert") self.create_expert_tab(expert_tab, prefs) + raw_tab = tabview.add_tab("Raw") + self.create_raw_tab(raw_tab, prefs) + # Save/Cancel buttons at bottom button_cont = lv.obj(screen) - button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(15)) + button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(20)) button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) button_cont.set_style_border_width(0, 0) button_cont.set_style_bg_opa(0, 0) save_button = lv.button(button_cont) - save_button.set_size(100, mpos.ui.pct_of_display_height(14)) - save_button.align(lv.ALIGN.CENTER, -60, 0) + save_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + save_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) save_label = lv.label(save_button) save_label.set_text("Save") save_label.center() cancel_button = lv.button(button_cont) - cancel_button.set_size(100, mpos.ui.pct_of_display_height(15)) - cancel_button.align(lv.ALIGN.CENTER, 60, 0) + cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) cancel_label = lv.label(cancel_button) cancel_label.set_text("Cancel") cancel_label.center() + erase_button = lv.button(button_cont) + erase_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + erase_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + erase_button.add_event_cb(lambda e: self.erase_and_close(), lv.EVENT.CLICKED, None) + erase_label = lv.label(erase_button) + erase_label.set_text("Erase") + erase_label.center() + self.setContentView(screen) def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_key): @@ -771,7 +782,8 @@ def create_dropdown(self, parent, label_text, options, default_idx, pref_key): def create_basic_tab(self, tab, prefs): """Create Basic settings tab.""" tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) - tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_style_pad_all(1, 0) # Resolution dropdown current_resolution = prefs.get_string("resolution", "320x240") @@ -781,8 +793,7 @@ def create_basic_tab(self, tab, prefs): resolution_idx = idx break - dropdown, cont = self.create_dropdown(tab, "Resolution:", self.resolutions, - resolution_idx, "resolution") + dropdown, cont = self.create_dropdown(tab, "Resolution:", self.resolutions, resolution_idx, "resolution") self.ui_controls["resolution"] = dropdown # Brightness @@ -822,8 +833,9 @@ def create_basic_tab(self, tab, prefs): def create_advanced_tab(self, tab, prefs): """Create Advanced settings tab.""" - tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(1, 0) # Auto Exposure Control (master switch) exposure_ctrl = prefs.get_bool("exposure_ctrl", True) @@ -854,7 +866,7 @@ def exposure_ctrl_changed(e): # Auto Exposure Level ae_level = prefs.get_int("ae_level", 0) - slider, label, cont = self.create_slider(tab, "AE Level", -2, 2, ae_level, "ae_level") + slider, label, cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") self.ui_controls["ae_level"] = slider # Night Mode (AEC2) @@ -931,12 +943,13 @@ def whitebal_changed(e): def create_expert_tab(self, tab, prefs): """Create Expert settings tab.""" - tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(1, 0) - # Note: Sensor detection would require camera access + # Note: Sensor detection isn't performed right now # For now, show sharpness/denoise with note - supports_sharpness = False # Conservative default + supports_sharpness = True # Assume yes # Sharpness sharpness = prefs.get_int("sharpness", 0) @@ -965,9 +978,10 @@ def create_expert_tab(self, tab, prefs): note.align(lv.ALIGN.TOP_RIGHT, 0, 0) # JPEG Quality - quality = prefs.get_int("quality", 85) - slider, label, cont = self.create_slider(tab, "JPEG Quality", 0, 100, quality, "quality") - self.ui_controls["quality"] = slider + # Disabled because JPEG is not used right now + #quality = prefs.get_int("quality", 85) + #slider, label, cont = self.create_slider(tab, "JPEG Quality", 0, 100, quality, "quality") + #self.ui_controls["quality"] = slider # Color Bar colorbar = prefs.get_bool("colorbar", False) @@ -999,6 +1013,17 @@ def create_expert_tab(self, tab, prefs): checkbox, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") self.ui_controls["lenc"] = checkbox + def create_raw_tab(self, tab, prefs): + startX = prefs.get_bool("startX", 0) + #startX, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") + startX, label, cont = self.create_slider(tab, "startX", 0, 2844, startX, "startX") + self.ui_controls["statX"] = startX + + def erase_and_close(self): + SharedPreferences("com.micropythonos.camera").edit().remove_all().commit() + self.setResult(True, {"settings_changed": True}) + self.finish() + def save_and_close(self): """Save all settings to SharedPreferences and return result.""" prefs = SharedPreferences("com.micropythonos.camera") From 920edd8f51f11f2ae4fb395927c615826349ce57 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 26 Nov 2025 12:15:37 +0100 Subject: [PATCH 034/192] Work towards "raw" tab --- .../assets/camera_app.py | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 8fd0bba..28d001c 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -6,6 +6,7 @@ # and the performance impact of converting RGB565 to grayscale is probably minimal anyway. import lvgl as lv +from mpos.ui.keyboard import MposKeyboard try: import webcam @@ -626,6 +627,9 @@ class CameraSettingsActivity(Activity): ("1920x1080", "1920x1080"), ] + # Widgets: + button_cont = None + def __init__(self): super().__init__() self.ui_controls = {} @@ -674,14 +678,14 @@ def onCreate(self): self.create_raw_tab(raw_tab, prefs) # Save/Cancel buttons at bottom - button_cont = lv.obj(screen) - button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(20)) - button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) - button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) - button_cont.set_style_border_width(0, 0) - button_cont.set_style_bg_opa(0, 0) - - save_button = lv.button(button_cont) + self.button_cont = lv.obj(screen) + self.button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(20)) + self.button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) + self.button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) + self.button_cont.set_style_border_width(0, 0) + self.button_cont.set_style_bg_opa(0, 0) + + save_button = lv.button(self.button_cont) save_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) save_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) @@ -689,7 +693,7 @@ def onCreate(self): save_label.set_text("Save") save_label.center() - cancel_button = lv.button(button_cont) + cancel_button = lv.button(self.button_cont) cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) @@ -697,7 +701,7 @@ def onCreate(self): cancel_label.set_text("Cancel") cancel_label.center() - erase_button = lv.button(button_cont) + erase_button = lv.button(self.button_cont) erase_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) erase_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) erase_button.add_event_cb(lambda e: self.erase_and_close(), lv.EVENT.CLICKED, None) @@ -729,9 +733,6 @@ def slider_changed(e): slider.add_event_cb(slider_changed, lv.EVENT.VALUE_CHANGED, None) - # Store metadata separately - self.control_metadata[id(slider)] = {"pref_key": pref_key, "type": "slider"} - return slider, label, cont def create_checkbox(self, parent, label_text, default_val, pref_key): @@ -746,9 +747,6 @@ def create_checkbox(self, parent, label_text, default_val, pref_key): checkbox.add_state(lv.STATE.CHECKED) checkbox.align(lv.ALIGN.LEFT_MID, 0, 0) - # Store metadata separately - self.control_metadata[id(checkbox)] = {"pref_key": pref_key, "type": "checkbox"} - return checkbox, cont def create_dropdown(self, parent, label_text, options, default_idx, pref_key): @@ -779,6 +777,46 @@ def create_dropdown(self, parent, label_text, options, default_idx, pref_key): return dropdown, cont + def create_textarea(self, parent, label_text, min_val, max_val, default_val, pref_key): + cont = lv.obj(parent) + cont.set_size(lv.pct(100), 60) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(f"{label_text}: {default_val}") + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + textarea = lv.textarea(parent) + textarea.set_width(lv.pct(90)) + textarea.set_one_line(True) # might not be good for all settings but it's good for most + textarea.set_text(str(default_val)) + + # Initialize keyboard (hidden initially) + keyboard = MposKeyboard(parent) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + keyboard.set_textarea(textarea) + keyboard.add_event_cb(lambda e, kbd=keyboard: self.hide_keyboard(kbd), lv.EVENT.READY, None) + keyboard.add_event_cb(lambda e, kbd=keyboard: self.hide_keyboard(kbd), lv.EVENT.CANCEL, None) + textarea.add_event_cb(lambda e, kbd=keyboard: self.show_keyboard(kbd), lv.EVENT.CLICKED, None) + + return textarea, cont + + def show_keyboard(self, kbd): + self.button_cont.add_flag(lv.obj.FLAG.HIDDEN) + mpos.ui.anim.smooth_show(kbd) + focusgroup = lv.group_get_default() + if focusgroup: + # move the focus to the keyboard to save the user a "next" button press (optional but nice) + # this is focusing on the right thing (keyboard) but the focus is not "active" (shown or used) somehow + #print(f"current focus object: {lv.group_get_default().get_focused()}") + focusgroup.focus_next() + #print(f"current focus object: {lv.group_get_default().get_focused()}") + + def hide_keyboard(self, kbd): + mpos.ui.anim.smooth_hide(kbd) + self.button_cont.remove_flag(lv.obj.FLAG.HIDDEN) + def create_basic_tab(self, tab, prefs): """Create Basic settings tab.""" tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) @@ -1014,10 +1052,10 @@ def create_expert_tab(self, tab, prefs): self.ui_controls["lenc"] = checkbox def create_raw_tab(self, tab, prefs): - startX = prefs.get_bool("startX", 0) - #startX, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") - startX, label, cont = self.create_slider(tab, "startX", 0, 2844, startX, "startX") - self.ui_controls["statX"] = startX + startX = prefs.get_int("startX", 0) + #startX, label, cont = self.create_slider(tab, "startX", 0, 2844, startX, "startX") + textarea, cont = self.create_textarea(tab, "startX", 0, 2844, startX, "startX") + self.ui_controls["startX"] = startX def erase_and_close(self): SharedPreferences("com.micropythonos.camera").edit().remove_all().commit() @@ -1040,6 +1078,9 @@ def save_and_close(self): elif isinstance(control, lv.checkbox): is_checked = control.get_state() & lv.STATE.CHECKED editor.put_bool(pref_key, bool(is_checked)) + elif isinstance(control, lv.textarea): + value = int(control.get_value()) + editor.put_int(pref_key, value) elif isinstance(control, lv.dropdown): selected_idx = control.get_selected() option_values = metadata.get("option_values", []) From bfbf52b48d1840d9d4b3123519c3fdfb84ca5417 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 26 Nov 2025 16:16:34 +0100 Subject: [PATCH 035/192] Zoomed on center and more resolutions --- .../assets/camera_app.py | 207 +++++++++++++----- 1 file changed, 153 insertions(+), 54 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 28d001c..ac6165d 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -20,8 +20,8 @@ class CameraApp(Activity): - button_width = 40 - button_height = 40 + button_width = 60 + button_height = 45 width = 320 height = 240 @@ -82,7 +82,7 @@ def onCreate(self): # Settings button settings_button = lv.button(self.main_screen) settings_button.set_size(self.button_width, self.button_height) - settings_button.align(lv.ALIGN.TOP_RIGHT, 0, self.button_height + 10) + settings_button.align(lv.ALIGN.TOP_RIGHT, 0, self.button_height + 5) settings_label = lv.label(settings_button) settings_label.set_text(lv.SYMBOL.SETTINGS) settings_label.center() @@ -98,8 +98,7 @@ def onCreate(self): snap_label.center() self.zoom_button = lv.button(self.main_screen) self.zoom_button.set_size(self.button_width, self.button_height) - self.zoom_button.align(lv.ALIGN.RIGHT_MID, 0, self.button_height + 10) - #self.zoom_button.add_flag(lv.obj.FLAG.HIDDEN) + self.zoom_button.align(lv.ALIGN.RIGHT_MID, 0, self.button_height + 5) self.zoom_button.add_event_cb(self.zoom_button_click,lv.EVENT.CLICKED,None) zoom_label = lv.label(self.zoom_button) zoom_label.set_text("Z") @@ -135,7 +134,7 @@ def onResume(self, screen): self.cam = init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees - # Apply saved camera settings + # Apply saved camera settings, only for internal camera for now: apply_camera_settings(self.cam, self.use_webcam) else: print("camera app: no internal camera found, trying webcam on /dev/video0") @@ -294,9 +293,26 @@ def qr_button_click(self, e): def zoom_button_click(self, e): print("zooming...") + if self.use_webcam: + print("zoom_button_click is not supported for webcam") + return if self.cam: - # This might work as it's what works in the C code: - self.cam.set_res_raw(startX=0,startY=0,endX=2623,endY=1951,offsetX=992,offsetY=736,totalX=2844,totalY=2844,outputX=640,outputY=480,scale=False,binning=False) + prefs = SharedPreferences("com.micropythonos.camera") + startX = prefs.get_int("startX", CameraSettingsActivity.startX_default) + startY = prefs.get_int("startX", CameraSettingsActivity.startY_default) + endX = prefs.get_int("startX", CameraSettingsActivity.endX_default) + endY = prefs.get_int("startX", CameraSettingsActivity.endY_default) + offsetX = prefs.get_int("startX", CameraSettingsActivity.offsetX_default) + offsetY = prefs.get_int("startX", CameraSettingsActivity.offsetY_default) + totalX = prefs.get_int("startX", CameraSettingsActivity.totalX_default) + totalY = prefs.get_int("startX", CameraSettingsActivity.totalY_default) + outputX = prefs.get_int("startX", CameraSettingsActivity.outputX_default) + outputY = prefs.get_int("startX", CameraSettingsActivity.outputY_default) + scale = prefs.get_bool("scale", CameraSettingsActivity.scale_default) + binning = prefs.get_bool("binning", CameraSettingsActivity.binning_default) + # This works as it's what works in the C code: + result = self.cam.set_res_raw(startX,startY,endX,endY,offsetX,offsetY,totalX,totalY,outputX,outputY,scale,binning) + print(f"self.cam.set_res_raw returned {result}") def open_settings(self): self.image_dsc.data = None @@ -401,7 +417,7 @@ def init_internal_cam(width, height): resolution_map = { (96, 96): FrameSize.R96X96, (160, 120): FrameSize.QQVGA, - #(128, 128): FrameSize.R128X128, it's actually FrameSize.R128x128 but let's ignore it to be safe + (128, 128): FrameSize.R128X128, (176, 144): FrameSize.QCIF, (240, 176): FrameSize.HQVGA, (240, 240): FrameSize.R240X240, @@ -409,7 +425,9 @@ def init_internal_cam(width, height): (320, 320): FrameSize.R320X320, (400, 296): FrameSize.CIF, (480, 320): FrameSize.HVGA, + (480, 480): FrameSize.R480X480, (640, 480): FrameSize.VGA, + (640, 640): FrameSize.R640X640, (800, 600): FrameSize.SVGA, (1024, 768): FrameSize.XGA, (1280, 720): FrameSize.HD, @@ -595,6 +613,21 @@ def apply_camera_settings(cam, use_webcam): class CameraSettingsActivity(Activity): """Settings activity for comprehensive camera configuration.""" + # Original: { 2560, 1920, 0, 0, 2623, 1951, 32, 16, 2844, 1968 } + # Worked for digital zoom in C: { 2560, 1920, 0, 0, 2623, 1951, 992, 736, 2844, 1968 } + startX_default=0 + startY_default=0 + endX_default=2623 + endY_default=1951 + offsetX_default=32 + offsetY_default=16 + totalX_default=2844 + totalY_default=1968 + outputX_default=640 + outputY_default=480 + scale_default=False + binning_default=False + # Resolution options for desktop/webcam WEBCAM_RESOLUTIONS = [ ("160x120", "160x120"), @@ -618,10 +651,12 @@ class CameraSettingsActivity(Activity): ("320x320", "320x320"), ("400x296", "400x296"), ("480x320", "480x320"), + ("480x480", "480x480"), ("640x480", "640x480"), + ("640x640", "640x640"), ("800x600", "800x600"), ("1024x768", "1024x768"), - ("1280x720", "1280x720"), + ("1280x720", "1280x720"), # binned 2x2 ("1280x1024", "1280x1024"), ("1600x1200", "1600x1200"), ("1920x1080", "1920x1080"), @@ -659,8 +694,8 @@ def onCreate(self): # Create tabview tabview = lv.tabview(screen) - tabview.set_tab_bar_size(mpos.ui.pct_of_display_height(10)) - tabview.set_size(lv.pct(100), mpos.ui.pct_of_display_height(80)) + tabview.set_tab_bar_size(mpos.ui.pct_of_display_height(15)) + #tabview.set_size(lv.pct(100), mpos.ui.pct_of_display_height(80)) # Create Basic tab (always) basic_tab = tabview.add_tab("Basic") @@ -677,38 +712,6 @@ def onCreate(self): raw_tab = tabview.add_tab("Raw") self.create_raw_tab(raw_tab, prefs) - # Save/Cancel buttons at bottom - self.button_cont = lv.obj(screen) - self.button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(20)) - self.button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) - self.button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) - self.button_cont.set_style_border_width(0, 0) - self.button_cont.set_style_bg_opa(0, 0) - - save_button = lv.button(self.button_cont) - save_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) - save_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) - save_label = lv.label(save_button) - save_label.set_text("Save") - save_label.center() - - cancel_button = lv.button(self.button_cont) - cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) - cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) - cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) - cancel_label = lv.label(cancel_button) - cancel_label.set_text("Cancel") - cancel_label.center() - - erase_button = lv.button(self.button_cont) - erase_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) - erase_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) - erase_button.add_event_cb(lambda e: self.erase_and_close(), lv.EVENT.CLICKED, None) - erase_label = lv.label(erase_button) - erase_label.set_text("Erase") - erase_label.center() - self.setContentView(screen) def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_key): @@ -779,17 +782,18 @@ def create_dropdown(self, parent, label_text, options, default_idx, pref_key): def create_textarea(self, parent, label_text, min_val, max_val, default_val, pref_key): cont = lv.obj(parent) - cont.set_size(lv.pct(100), 60) + cont.set_size(lv.pct(100), lv.SIZE_CONTENT) cont.set_style_pad_all(3, 0) label = lv.label(cont) - label.set_text(f"{label_text}: {default_val}") + label.set_text(f"{label_text}:") label.align(lv.ALIGN.TOP_LEFT, 0, 0) - textarea = lv.textarea(parent) - textarea.set_width(lv.pct(90)) + textarea = lv.textarea(cont) + textarea.set_width(lv.pct(50)) textarea.set_one_line(True) # might not be good for all settings but it's good for most textarea.set_text(str(default_val)) + textarea.align(lv.ALIGN.TOP_RIGHT, 0, 0) # Initialize keyboard (hidden initially) keyboard = MposKeyboard(parent) @@ -803,7 +807,7 @@ def create_textarea(self, parent, label_text, min_val, max_val, default_val, pre return textarea, cont def show_keyboard(self, kbd): - self.button_cont.add_flag(lv.obj.FLAG.HIDDEN) + #self.button_cont.add_flag(lv.obj.FLAG.HIDDEN) mpos.ui.anim.smooth_show(kbd) focusgroup = lv.group_get_default() if focusgroup: @@ -815,7 +819,41 @@ def show_keyboard(self, kbd): def hide_keyboard(self, kbd): mpos.ui.anim.smooth_hide(kbd) - self.button_cont.remove_flag(lv.obj.FLAG.HIDDEN) + #self.button_cont.remove_flag(lv.obj.FLAG.HIDDEN) + + def add_buttons(self, parent): + # Save/Cancel buttons at bottom + button_cont = lv.obj(parent) + button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(20)) + button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) + button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) + button_cont.set_style_border_width(0, 0) + button_cont.set_style_bg_opa(0, 0) + + save_button = lv.button(button_cont) + save_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + save_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) + save_label = lv.label(save_button) + save_label.set_text("Save") + save_label.center() + + cancel_button = lv.button(button_cont) + cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) + cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + cancel_label = lv.label(cancel_button) + cancel_label.set_text("Cancel") + cancel_label.center() + + erase_button = lv.button(button_cont) + erase_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + erase_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + erase_button.add_event_cb(lambda e: self.erase_and_close(), lv.EVENT.CLICKED, None) + erase_label = lv.label(erase_button) + erase_label.set_text("Erase") + erase_label.center() + def create_basic_tab(self, tab, prefs): """Create Basic settings tab.""" @@ -869,6 +907,8 @@ def create_basic_tab(self, tab, prefs): special_effect, "special_effect") self.ui_controls["special_effect"] = dropdown + self.add_buttons(tab) + def create_advanced_tab(self, tab, prefs): """Create Advanced settings tab.""" #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) @@ -979,6 +1019,8 @@ def whitebal_changed(e): checkbox, cont = self.create_checkbox(tab, "AWB Gain", awb_gain, "awb_gain") self.ui_controls["awb_gain"] = checkbox + self.add_buttons(tab) + def create_expert_tab(self, tab, prefs): """Create Expert settings tab.""" #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) @@ -1051,11 +1093,64 @@ def create_expert_tab(self, tab, prefs): checkbox, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") self.ui_controls["lenc"] = checkbox + self.add_buttons(tab) + def create_raw_tab(self, tab, prefs): - startX = prefs.get_int("startX", 0) + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(0, 0) + + # This would be nice but does not provide adequate resolution: #startX, label, cont = self.create_slider(tab, "startX", 0, 2844, startX, "startX") + + startX = prefs.get_int("startX", self.startX_default) textarea, cont = self.create_textarea(tab, "startX", 0, 2844, startX, "startX") - self.ui_controls["startX"] = startX + self.ui_controls["startX"] = textarea + + startY = prefs.get_int("startY", self.startY_default) + textarea, cont = self.create_textarea(tab, "startY", 0, 2844, startY, "startY") + self.ui_controls["startY"] = textarea + + endX = prefs.get_int("endX", self.endX_default) + textarea, cont = self.create_textarea(tab, "endX", 0, 2844, endX, "endX") + self.ui_controls["endX"] = textarea + + endY = prefs.get_int("endY", self.endY_default) + textarea, cont = self.create_textarea(tab, "endY", 0, 2844, endY, "endY") + self.ui_controls["endY"] = textarea + + offsetX = prefs.get_int("offsetX", self.offsetX_default) + textarea, cont = self.create_textarea(tab, "offsetX", 0, 2844, offsetX, "offsetX") + self.ui_controls["offsetX"] = textarea + + offsetY = prefs.get_int("offsetY", self.offsetY_default) + textarea, cont = self.create_textarea(tab, "offsetY", 0, 2844, offsetY, "offsetY") + self.ui_controls["offsetY"] = textarea + + totalX = prefs.get_int("totalX", self.totalX_default) + textarea, cont = self.create_textarea(tab, "totalX", 0, 2844, totalX, "totalX") + self.ui_controls["totalX"] = textarea + + totalY = prefs.get_int("totalY", self.totalY_default) + textarea, cont = self.create_textarea(tab, "totalY", 0, 2844, totalY, "totalY") + self.ui_controls["totalY"] = textarea + + outputX = prefs.get_int("outputX", self.outputX_default) + textarea, cont = self.create_textarea(tab, "outputX", 0, 2844, outputX, "outputX") + self.ui_controls["outputX"] = textarea + + outputY = prefs.get_int("outputY", self.outputY_default) + textarea, cont = self.create_textarea(tab, "outputY", 0, 2844, outputY, "outputY") + self.ui_controls["outputY"] = textarea + + scale = prefs.get_bool("scale", self.scale_default) + checkbox, cont = self.create_checkbox(tab, "Scale?", scale, "scale") + self.ui_controls["scale"] = checkbox + + binning = prefs.get_bool("binning", self.binning_default) + checkbox, cont = self.create_checkbox(tab, "Binning?", binning, "binning") + self.ui_controls["binning"] = checkbox + + self.add_buttons(tab) def erase_and_close(self): SharedPreferences("com.micropythonos.camera").edit().remove_all().commit() @@ -1069,6 +1164,7 @@ def save_and_close(self): # Save all UI control values for pref_key, control in self.ui_controls.items(): + print(f"saving {pref_key} with {control}") control_id = id(control) metadata = self.control_metadata.get(control_id, {}) @@ -1079,8 +1175,11 @@ def save_and_close(self): is_checked = control.get_state() & lv.STATE.CHECKED editor.put_bool(pref_key, bool(is_checked)) elif isinstance(control, lv.textarea): - value = int(control.get_value()) - editor.put_int(pref_key, value) + try: + value = int(control.get_text()) + editor.put_int(pref_key, value) + except Exception as e: + print(f"Error while trying to save {pref_key}: {e}") elif isinstance(control, lv.dropdown): selected_idx = control.get_selected() option_values = metadata.get("option_values", []) From e8665d0ce9952bd7dfaefe6e75da7d2dae02695b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 27 Nov 2025 10:49:24 +0100 Subject: [PATCH 036/192] Camera app: eliminate tearing by copying buffer --- .../assets/camera_app.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index ac6165d..a9ccb6a 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -31,6 +31,7 @@ class CameraApp(Activity): cam = None current_cam_buffer = None # Holds the current memoryview to prevent garbage collection + current_cam_buffer_copy = None # Holds a copy so that the memoryview can be free'd image = None image_dsc = None @@ -188,7 +189,8 @@ def onPause(self, screen): def set_image_size(self): disp = lv.display_get_default() target_h = disp.get_vertical_resolution() - target_w = disp.get_horizontal_resolution() - self.button_width - 5 # leave 5px for border + #target_w = disp.get_horizontal_resolution() - self.button_width - 5 # leave 5px for border + target_w = target_h # leave 5px for border if target_w == self.width and target_h == self.height: print("Target width and height are the same as native image, no scaling required.") return @@ -225,7 +227,7 @@ def qrdecode_one(self): import qrdecode import utime before = utime.ticks_ms() - result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) + result = qrdecode.qrdecode_rgb565(self.current_cam_buffer_copy, self.width, self.height) after = utime.ticks_ms() #result = bytearray("INSERT_QR_HERE", "utf-8") if not result: @@ -261,12 +263,12 @@ def snap_button_click(self, e): os.mkdir("data/images") except OSError: pass - if self.current_cam_buffer is not None: + if self.current_cam_buffer_copy is not None: filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_RGB565.raw" try: with open(filename, 'wb') as f: - f.write(self.current_cam_buffer) - print(f"Successfully wrote current_cam_buffer to {filename}") + f.write(self.current_cam_buffer_copy) + print(f"Successfully wrote current_cam_buffer_copy to {filename}") except OSError as e: print(f"Error writing to file: {e}") @@ -380,16 +382,19 @@ def try_capture(self, event): try: if self.use_webcam: self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565") + self.current_cam_buffer_copy = bytes(self.current_cam_buffer) elif self.cam.frame_available(): self.current_cam_buffer = self.cam.capture() + self.current_cam_buffer_copy = bytes(self.current_cam_buffer) + self.cam.free_buffer() - if self.current_cam_buffer and len(self.current_cam_buffer): + if self.current_cam_buffer_copy and len(self.current_cam_buffer_copy): # Defensive check: verify buffer size matches expected dimensions expected_size = self.width * self.height * 2 # RGB565 = 2 bytes per pixel - actual_size = len(self.current_cam_buffer) + actual_size = len(self.current_cam_buffer_copy) if actual_size == expected_size: - self.image_dsc.data = self.current_cam_buffer + self.image_dsc.data = self.current_cam_buffer_copy #image.invalidate() # does not work so do this: self.image.set_src(self.image_dsc) if not self.use_webcam: @@ -456,7 +461,8 @@ def init_internal_cam(width, height): reset_pin=-1, pixel_format=PixelFormat.RGB565, frame_size=frame_size, - grab_mode=GrabMode.LATEST + grab_mode=GrabMode.WHEN_EMPTY, + fb_count=1 ) cam.set_vflip(True) return cam @@ -899,7 +905,7 @@ def create_basic_tab(self, tab, prefs): # Special Effect special_effect_options = [ - ("None", 0), ("Negative", 1), ("B&W", 2), + ("None", 0), ("Negative", 1), ("Grayscale", 2), ("Reddish", 3), ("Greenish", 4), ("Blue", 5), ("Retro", 6) ] special_effect = prefs.get_int("special_effect", 0) @@ -1070,7 +1076,7 @@ def create_expert_tab(self, tab, prefs): # DCW Mode dcw = prefs.get_bool("dcw", True) - checkbox, cont = self.create_checkbox(tab, "DCW Mode", dcw, "dcw") + checkbox, cont = self.create_checkbox(tab, "Downsize Crop Window", dcw, "dcw") self.ui_controls["dcw"] = checkbox # Black Point Compensation From ef06b58ed64a92348cb33aced3e35d4df3d19d1f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 27 Nov 2025 13:57:57 +0100 Subject: [PATCH 037/192] Camera app: more resolutions, less memory use --- c_mpos/src/quirc_decode.c | 26 +++++++++++-- .../assets/camera_app.py | 39 ++++++++++++------- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/c_mpos/src/quirc_decode.c b/c_mpos/src/quirc_decode.c index 68bcccb..69721e6 100644 --- a/c_mpos/src/quirc_decode.c +++ b/c_mpos/src/quirc_decode.c @@ -151,13 +151,33 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { free(gray_buffer); } else { QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Exception caught, freeing gray_buffer\n"); - free(gray_buffer); + // Cleanup + if (gray_buffer) { + free(gray_buffer); + gray_buffer = NULL; + } + //mp_raise_TypeError(MP_ERROR_TEXT("qrdecode_rgb565: failed to decode QR code")); // Re-raising the exception results in an Unhandled exception in thread started by // which isn't caught, even when catching Exception, so this looks like a bug in MicroPython... - //nlr_pop(); - //nlr_raise(exception_handler.ret_val); + nlr_pop(); + nlr_raise(exception_handler.ret_val); + // Re-raise the original exception with optional additional message + /* + mp_raise_msg_and_obj( + mp_obj_exception_get_type(exception_handler.ret_val), + MP_OBJ_NEW_QSTR(qstr_from_str("qrdecode_rgb565: failed during processing")), + exception_handler.ret_val + ); + */ + // Re-raise as new exception of same type, with message + original as arg + // (embeds original for traceback chaining) + // crashes: + //const mp_obj_type_t *exc_type = mp_obj_get_type(exception_handler.ret_val); + //mp_raise_msg_varg(exc_type, MP_ERROR_TEXT("qrdecode_rgb565: failed during processing: %q"), exception_handler.ret_val); } + //nlr_pop(); maybe it needs to be done after instead of before the re-raise? + return result; } diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index a9ccb6a..920eec1 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -227,7 +227,7 @@ def qrdecode_one(self): import qrdecode import utime before = utime.ticks_ms() - result = qrdecode.qrdecode_rgb565(self.current_cam_buffer_copy, self.width, self.height) + result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) after = utime.ticks_ms() #result = bytearray("INSERT_QR_HERE", "utf-8") if not result: @@ -263,12 +263,12 @@ def snap_button_click(self, e): os.mkdir("data/images") except OSError: pass - if self.current_cam_buffer_copy is not None: + if self.current_cam_buffer is not None: filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_RGB565.raw" try: with open(filename, 'wb') as f: - f.write(self.current_cam_buffer_copy) - print(f"Successfully wrote current_cam_buffer_copy to {filename}") + f.write(self.current_cam_buffer) + print(f"Successfully wrote current_cam_buffer to {filename}") except OSError as e: print(f"Error writing to file: {e}") @@ -382,25 +382,30 @@ def try_capture(self, event): try: if self.use_webcam: self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565") - self.current_cam_buffer_copy = bytes(self.current_cam_buffer) + #self.current_cam_buffer_copy = bytes(self.current_cam_buffer) elif self.cam.frame_available(): + self.cam.free_buffer() self.current_cam_buffer = self.cam.capture() - self.current_cam_buffer_copy = bytes(self.current_cam_buffer) + #self.current_cam_buffer_copy = bytes(self.current_cam_buffer) self.cam.free_buffer() - if self.current_cam_buffer_copy and len(self.current_cam_buffer_copy): + if self.current_cam_buffer and len(self.current_cam_buffer): # Defensive check: verify buffer size matches expected dimensions expected_size = self.width * self.height * 2 # RGB565 = 2 bytes per pixel - actual_size = len(self.current_cam_buffer_copy) + actual_size = len(self.current_cam_buffer) if actual_size == expected_size: - self.image_dsc.data = self.current_cam_buffer_copy + #self.image_dsc.data = self.current_cam_buffer_copy + self.image_dsc.data = self.current_cam_buffer #image.invalidate() # does not work so do this: self.image.set_src(self.image_dsc) if not self.use_webcam: self.cam.free_buffer() # Free the old buffer - if self.keepliveqrdecoding: - self.qrdecode_one() + try: + if self.keepliveqrdecoding: + self.qrdecode_one() + except Exception as qre: + print(f"try_capture: qrdecode_one got exception: {qre}") else: print(f"Warning: Buffer size mismatch! Expected {expected_size} bytes, got {actual_size} bytes") print(f" Resolution: {self.width}x{self.height}, discarding frame") @@ -433,8 +438,12 @@ def init_internal_cam(width, height): (480, 480): FrameSize.R480X480, (640, 480): FrameSize.VGA, (640, 640): FrameSize.R640X640, + (720, 720): FrameSize.R720X720, (800, 600): FrameSize.SVGA, + (800, 800): FrameSize.R800X800, + (960, 960): FrameSize.R960X960, (1024, 768): FrameSize.XGA, + (1024,1024): FrameSize.R1024X1024, (1280, 720): FrameSize.HD, (1280, 1024): FrameSize.SXGA, (1600, 1200): FrameSize.UXGA, @@ -660,9 +669,13 @@ class CameraSettingsActivity(Activity): ("480x480", "480x480"), ("640x480", "640x480"), ("640x640", "640x640"), + ("720x720", "720x720"), ("800x600", "800x600"), - ("1024x768", "1024x768"), - ("1280x720", "1280x720"), # binned 2x2 + ("800x800", "800x800"), + ("960x960", "960x960"), + ("1024x768", "1024x768"), + ("1024x1024","1024x1024"), + ("1280x720", "1280x720"), # binned 2x2 (in default ov5640.c) ("1280x1024", "1280x1024"), ("1600x1200", "1600x1200"), ("1920x1080", "1920x1080"), From a3db12f322aa541d3171e647826c59d3fd9135c4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 09:56:05 +0100 Subject: [PATCH 038/192] Fix image resolution setting --- .../apps/com.micropythonos.camera/assets/camera_app.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 920eec1..6f4f8fe 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -190,10 +190,7 @@ def set_image_size(self): disp = lv.display_get_default() target_h = disp.get_vertical_resolution() #target_w = disp.get_horizontal_resolution() - self.button_width - 5 # leave 5px for border - target_w = target_h # leave 5px for border - if target_w == self.width and target_h == self.height: - print("Target width and height are the same as native image, no scaling required.") - return + target_w = target_h # square print(f"scaling to size: {target_w}x{target_h}") scale_factor_w = round(target_w * 256 / self.width) scale_factor_h = round(target_h * 256 / self.height) From 1457ede0ca32ac490e014eedf73f1b806333c433 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 12:35:45 +0100 Subject: [PATCH 039/192] Work Camera app - Add 1280x1280 resolution - Fix dependent settings enablement - Use grayscale for now --- .../assets/camera_app.py | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 6f4f8fe..e822c0c 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -20,10 +20,12 @@ class CameraApp(Activity): + DEFAULT_WIDTH = 320 # 240 would be better but webcam doesn't support this (yet) + DEFAULT_HEIGHT = 240 + button_width = 60 button_height = 45 - width = 320 - height = 240 + graymode = True status_label_text = "No camera found." status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and QR size (4-12cm). Ensure proper lighting." @@ -31,7 +33,8 @@ class CameraApp(Activity): cam = None current_cam_buffer = None # Holds the current memoryview to prevent garbage collection - current_cam_buffer_copy = None # Holds a copy so that the memoryview can be free'd + width = None + height = None image = None image_dsc = None @@ -52,7 +55,7 @@ class CameraApp(Activity): def load_resolution_preference(self): """Load resolution preference from SharedPreferences and update width/height.""" prefs = SharedPreferences("com.micropythonos.camera") - resolution_str = prefs.get_string("resolution", "320x240") + resolution_str = prefs.get_string("resolution", f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}") try: width_str, height_str = resolution_str.split('x') self.width = int(width_str) @@ -60,8 +63,8 @@ def load_resolution_preference(self): print(f"Camera resolution loaded: {self.width}x{self.height}") except Exception as e: print(f"Error parsing resolution '{resolution_str}': {e}, using default 320x240") - self.width = 320 - self.height = 240 + self.width = self.DEFAULT_WIDTH + self.height = self.DEFAULT_HEIGHT def onCreate(self): self.load_resolution_preference() @@ -88,7 +91,6 @@ def onCreate(self): settings_label.set_text(lv.SYMBOL.SETTINGS) settings_label.center() settings_button.add_event_cb(lambda e: self.open_settings(),lv.EVENT.CLICKED,None) - self.snap_button = lv.button(self.main_screen) self.snap_button.set_size(self.button_width, self.button_height) self.snap_button.align(lv.ALIGN.RIGHT_MID, 0, 0) @@ -104,8 +106,6 @@ def onCreate(self): zoom_label = lv.label(self.zoom_button) zoom_label.set_text("Z") zoom_label.center() - - self.qr_button = lv.button(self.main_screen) self.qr_button.set_size(self.button_width, self.button_height) self.qr_button.add_flag(lv.obj.FLAG.HIDDEN) @@ -161,7 +161,6 @@ def onResume(self, screen): if self.scanqr_mode: self.finish() - def onPause(self, screen): print("camera app backgrounded, cleaning up...") if self.capture_timer: @@ -208,11 +207,13 @@ def create_preview_image(self): "magic": lv.IMAGE_HEADER_MAGIC, "w": self.width, "h": self.height, - "stride": self.width * 2, - "cf": lv.COLOR_FORMAT.RGB565 - #"cf": lv.COLOR_FORMAT.L8 + #"stride": self.width * 2, # RGB565 + "stride": self.width, # RGB565 + #"cf": lv.COLOR_FORMAT.RGB565 + "cf": lv.COLOR_FORMAT.L8 }, - 'data_size': self.width * self.height * 2, + #'data_size': self.width * self.height * 2, # RGB565 + 'data_size': self.width * self.height, # gray 'data': None # Will be updated per frame }) self.image.set_src(self.image_dsc) @@ -224,7 +225,7 @@ def qrdecode_one(self): import qrdecode import utime before = utime.ticks_ms() - result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) + result = qrdecode.qrdecode(self.current_cam_buffer, self.width, self.height) after = utime.ticks_ms() #result = bytearray("INSERT_QR_HERE", "utf-8") if not result: @@ -343,8 +344,10 @@ def handle_settings_result(self, result): # Note: image_dsc is an LVGL struct, use attribute access not dictionary access self.image_dsc.header.w = self.width self.image_dsc.header.h = self.height - self.image_dsc.header.stride = self.width * 2 - self.image_dsc.data_size = self.width * self.height * 2 + #self.image_dsc.header.stride = self.width * 2 # RGB565 + #self.image_dsc.data_size = self.width * self.height * 2 #RGB565 + self.image_dsc.header.stride = self.width + self.image_dsc.data_size = self.width * self.height print(f"Image descriptor updated to {self.width}x{self.height}") # Reconfigure camera if active @@ -379,25 +382,23 @@ def try_capture(self, event): try: if self.use_webcam: self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565") - #self.current_cam_buffer_copy = bytes(self.current_cam_buffer) elif self.cam.frame_available(): self.cam.free_buffer() self.current_cam_buffer = self.cam.capture() - #self.current_cam_buffer_copy = bytes(self.current_cam_buffer) - self.cam.free_buffer() + #self.cam.free_buffer() if self.current_cam_buffer and len(self.current_cam_buffer): # Defensive check: verify buffer size matches expected dimensions - expected_size = self.width * self.height * 2 # RGB565 = 2 bytes per pixel + #expected_size = self.width * self.height * 2 # RGB565 = 2 bytes per pixel + expected_size = self.width * self.height # Grayscale = 1 byte per pixel actual_size = len(self.current_cam_buffer) if actual_size == expected_size: - #self.image_dsc.data = self.current_cam_buffer_copy self.image_dsc.data = self.current_cam_buffer #image.invalidate() # does not work so do this: self.image.set_src(self.image_dsc) - if not self.use_webcam: - self.cam.free_buffer() # Free the old buffer + #if not self.use_webcam: + # self.cam.free_buffer() # Free the old buffer try: if self.keepliveqrdecoding: self.qrdecode_one() @@ -443,6 +444,7 @@ def init_internal_cam(width, height): (1024,1024): FrameSize.R1024X1024, (1280, 720): FrameSize.HD, (1280, 1024): FrameSize.SXGA, + (1280, 1280): FrameSize.R1280X1280, (1600, 1200): FrameSize.UXGA, (1920, 1080): FrameSize.FHD, } @@ -465,7 +467,8 @@ def init_internal_cam(width, height): xclk_freq=20000000, powerdown_pin=-1, reset_pin=-1, - pixel_format=PixelFormat.RGB565, + #pixel_format=PixelFormat.RGB565, + pixel_format=PixelFormat.GRAYSCALE, frame_size=frame_size, grab_mode=GrabMode.WHEN_EMPTY, fb_count=1 @@ -674,6 +677,7 @@ class CameraSettingsActivity(Activity): ("1024x1024","1024x1024"), ("1280x720", "1280x720"), # binned 2x2 (in default ov5640.c) ("1280x1024", "1280x1024"), + ("1280x1280", "1280x1280"), ("1600x1200", "1600x1200"), ("1920x1080", "1920x1080"), ] @@ -933,30 +937,30 @@ def create_advanced_tab(self, tab, prefs): # Auto Exposure Control (master switch) exposure_ctrl = prefs.get_bool("exposure_ctrl", True) - checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") - self.ui_controls["exposure_ctrl"] = checkbox + aec_checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") + self.ui_controls["exposure_ctrl"] = aec_checkbox # Manual Exposure Value (dependent) aec_value = prefs.get_int("aec_value", 300) - slider, label, cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") - self.ui_controls["aec_value"] = slider + me_slider, label, cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") + self.ui_controls["aec_value"] = me_slider # Set initial state if exposure_ctrl: - slider.add_state(lv.STATE.DISABLED) - slider.set_style_bg_opa(128, 0) + me_slider.add_state(lv.STATE.DISABLED) + me_slider.set_style_bg_opa(128, 0) # Add dependency handler def exposure_ctrl_changed(e): - is_auto = checkbox.get_state() & lv.STATE.CHECKED + is_auto = aec_checkbox.get_state() & lv.STATE.CHECKED if is_auto: - slider.add_state(lv.STATE.DISABLED) - slider.set_style_bg_opa(128, 0) + me_slider.add_state(lv.STATE.DISABLED) + me_slider.set_style_bg_opa(128, 0) else: - slider.remove_state(lv.STATE.DISABLED) - slider.set_style_bg_opa(255, 0) + me_slider.remove_state(lv.STATE.DISABLED) + me_slider.set_style_bg_opa(255, 0) - checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + aec_checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) # Auto Exposure Level ae_level = prefs.get_int("ae_level", 0) @@ -970,8 +974,8 @@ def exposure_ctrl_changed(e): # Auto Gain Control (master switch) gain_ctrl = prefs.get_bool("gain_ctrl", True) - checkbox, cont = self.create_checkbox(tab, "Auto Gain", gain_ctrl, "gain_ctrl") - self.ui_controls["gain_ctrl"] = checkbox + agc_checkbox, cont = self.create_checkbox(tab, "Auto Gain", gain_ctrl, "gain_ctrl") + self.ui_controls["gain_ctrl"] = agc_checkbox # Manual Gain Value (dependent) agc_gain = prefs.get_int("agc_gain", 0) @@ -983,7 +987,7 @@ def exposure_ctrl_changed(e): slider.set_style_bg_opa(128, 0) def gain_ctrl_changed(e): - is_auto = checkbox.get_state() & lv.STATE.CHECKED + is_auto = agc_checkbox.get_state() & lv.STATE.CHECKED gain_slider = self.ui_controls["agc_gain"] if is_auto: gain_slider.add_state(lv.STATE.DISABLED) @@ -992,7 +996,7 @@ def gain_ctrl_changed(e): gain_slider.remove_state(lv.STATE.DISABLED) gain_slider.set_style_bg_opa(255, 0) - checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + agc_checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) # Gain Ceiling gainceiling_options = [ @@ -1000,14 +1004,13 @@ def gain_ctrl_changed(e): ("32X", 4), ("64X", 5), ("128X", 6) ] gainceiling = prefs.get_int("gainceiling", 0) - dropdown, cont = self.create_dropdown(tab, "Gain Ceiling:", gainceiling_options, - gainceiling, "gainceiling") + dropdown, cont = self.create_dropdown(tab, "Gain Ceiling:", gainceiling_options, gainceiling, "gainceiling") self.ui_controls["gainceiling"] = dropdown # Auto White Balance (master switch) whitebal = prefs.get_bool("whitebal", True) - checkbox, cont = self.create_checkbox(tab, "Auto White Balance", whitebal, "whitebal") - self.ui_controls["whitebal"] = checkbox + wbcheckbox, cont = self.create_checkbox(tab, "Auto White Balance", whitebal, "whitebal") + self.ui_controls["whitebal"] = wbcheckbox # White Balance Mode (dependent) wb_mode_options = [ @@ -1021,14 +1024,14 @@ def gain_ctrl_changed(e): dropdown.add_state(lv.STATE.DISABLED) def whitebal_changed(e): - is_auto = checkbox.get_state() & lv.STATE.CHECKED + is_auto = wbcheckbox.get_state() & lv.STATE.CHECKED wb_dropdown = self.ui_controls["wb_mode"] if is_auto: wb_dropdown.add_state(lv.STATE.DISABLED) else: wb_dropdown.remove_state(lv.STATE.DISABLED) - checkbox.add_event_cb(whitebal_changed, lv.EVENT.VALUE_CHANGED, None) + wbcheckbox.add_event_cb(whitebal_changed, lv.EVENT.VALUE_CHANGED, None) # AWB Gain awb_gain = prefs.get_bool("awb_gain", True) From e42aa7d85bdce78539849983f7ed5d269e5d2beb Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 14:14:59 +0100 Subject: [PATCH 040/192] quirc.c: comments --- c_mpos/quirc/lib/quirc.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/c_mpos/quirc/lib/quirc.c b/c_mpos/quirc/lib/quirc.c index 208746e..8f9da73 100644 --- a/c_mpos/quirc/lib/quirc.c +++ b/c_mpos/quirc/lib/quirc.c @@ -64,7 +64,7 @@ int quirc_resize(struct quirc *q, int w, int h) /* * alloc a new buffer for q->image. We avoid realloc(3) because we want - * on failure to be leave `q` in a consistant, unmodified state. + * on failure to be leaving `q` in a consistent, unmodified state. */ image = ps_malloc(w * h); if (!image) @@ -72,7 +72,7 @@ int quirc_resize(struct quirc *q, int w, int h) /* compute the "old" (i.e. currently allocated) and the "new" (i.e. requested) image dimensions */ - size_t olddim = q->w * q->h; + size_t olddim = q->w * q->h; // these are initialized to 0 by quirc_new() size_t newdim = w * h; size_t min = (olddim < newdim ? olddim : newdim); From 97a4a920f404025c6c3a543f00700304e31d15c6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 15:03:48 +0100 Subject: [PATCH 041/192] Camera: re-enable QR decoding after settings --- .../apps/com.micropythonos.camera/assets/camera_app.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index e822c0c..36dc4bb 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -151,7 +151,7 @@ def onResume(self, screen): self.set_image_size() self.capture_timer = lv.timer_create(self.try_capture, 100, None) self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) - if self.scanqr_mode: + if self.scanqr_mode or self.keepliveqrdecoding: self.start_qr_decoding() else: self.qr_button.remove_flag(lv.obj.FLAG.HIDDEN) @@ -383,7 +383,7 @@ def try_capture(self, event): if self.use_webcam: self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565") elif self.cam.frame_available(): - self.cam.free_buffer() + #self.cam.free_buffer() self.current_cam_buffer = self.cam.capture() #self.cam.free_buffer() @@ -470,7 +470,8 @@ def init_internal_cam(width, height): #pixel_format=PixelFormat.RGB565, pixel_format=PixelFormat.GRAYSCALE, frame_size=frame_size, - grab_mode=GrabMode.WHEN_EMPTY, + #grab_mode=GrabMode.WHEN_EMPTY, + grab_mode=GrabMode.LATEST, fb_count=1 ) cam.set_vflip(True) From 8f4b3c5fbefec9eaf0fe16e3245b87f3e09a1860 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 15:11:12 +0100 Subject: [PATCH 042/192] quirc_decode.c: attempt zero-copy but crashes and black artifacts --- c_mpos/src/quirc_decode.c | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/c_mpos/src/quirc_decode.c b/c_mpos/src/quirc_decode.c index 69721e6..5433760 100644 --- a/c_mpos/src/quirc_decode.c +++ b/c_mpos/src/quirc_decode.c @@ -17,6 +17,7 @@ size_t uxTaskGetStackHighWaterMark(void * unused) { #endif #include "../quirc/lib/quirc.h" +#include "../quirc/lib/quirc_internal.h" // Exposes full struct quirc #define QRDECODE_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) @@ -46,23 +47,39 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { if (!qr) { mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc object\n"); if (quirc_resize(qr, width, height) < 0) { quirc_destroy(qr); mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Resized quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Resized quirc object\n"); - uint8_t *image; - image = quirc_begin(qr, NULL, NULL); - memcpy(image, bufinfo.buf, width * height); + uint8_t *image = quirc_begin(qr, NULL, NULL); + //memcpy(image, bufinfo.buf, width * height); + uint8_t *temp_image = image; + //image = bufinfo.buf; // use existing buffer, rather than memcpy - but this doesnt find any images anymore :-/ + qr->image = bufinfo.buf; // if this works then we can also eliminate quirc's ps_alloc() quirc_end(qr); + qr->image = temp_image; // restore, because quirc will try to free it + + /* + // Pointer swap - NO memcpy, NO internal.h needed + uint8_t *quirc_buffer = quirc_begin(qr, NULL, NULL); + uint8_t *saved_bufinfo = bufinfo.buf; + bufinfo.buf = quirc_buffer; // quirc now uses your buffer + quirc_end(qr); // QR detection works! + // Restore your buffer pointer + //bufinfo.buf = saved_bufinfo; + */ + + // now num_grids is set, as well as others, probably int count = quirc_count(qr); if (count == 0) { + // Restore your buffer pointer quirc_destroy(qr); - QRDECODE_DEBUG_PRINT("qrdecode: No QR code found, freed quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: No QR code found, freed quirc object\n"); mp_raise_ValueError(MP_ERROR_TEXT("no QR code found")); } @@ -71,8 +88,10 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { quirc_destroy(qr); mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_code\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_code\n"); quirc_extract(qr, 0, code); + // the code struct now contains the corners of the QR code, as well as the bitmap of the values + // this could be used to display debug info to the user - they might even be able to see which modules are being misidentified! struct quirc_data *data = (struct quirc_data *)malloc(sizeof(struct quirc_data)); if (!data) { @@ -80,7 +99,7 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { quirc_destroy(qr); mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_data\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Allocated quirc_data\n"); int err = quirc_decode(code, data); if (err != QUIRC_SUCCESS) { From 6b8b72a7a0f85fbcd59e14f783e478d66e13290d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 17:55:02 +0100 Subject: [PATCH 043/192] quirc_decode: back to memcpy for stability --- c_mpos/src/quirc_decode.c | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/c_mpos/src/quirc_decode.c b/c_mpos/src/quirc_decode.c index 5433760..32eee10 100644 --- a/c_mpos/src/quirc_decode.c +++ b/c_mpos/src/quirc_decode.c @@ -56,12 +56,15 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { //QRDECODE_DEBUG_PRINT("qrdecode: Resized quirc object\n"); uint8_t *image = quirc_begin(qr, NULL, NULL); - //memcpy(image, bufinfo.buf, width * height); - uint8_t *temp_image = image; - //image = bufinfo.buf; // use existing buffer, rather than memcpy - but this doesnt find any images anymore :-/ - qr->image = bufinfo.buf; // if this works then we can also eliminate quirc's ps_alloc() + memcpy(image, bufinfo.buf, width * height); + // would be nice to be able to use the existing buffer (bufinfo.buf) here, avoiding memcpy, + // but that buffer is also being filled by image capture and displayed by lvgl + // and that becomes unstable... it shows black artifacts and crashes sometimes... + //uint8_t *temp_image = image; + //image = bufinfo.buf; + //qr->image = bufinfo.buf; // if this works then we can also eliminate quirc's ps_alloc() quirc_end(qr); - qr->image = temp_image; // restore, because quirc will try to free it + //qr->image = temp_image; // restore, because quirc will try to free it /* // Pointer swap - NO memcpy, NO internal.h needed From 1b0eb8d83707166ca7416f92dbc2f65d7bdbfd8b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 17:55:41 +0100 Subject: [PATCH 044/192] Add colormode option and move special effect to advanced tab --- .../assets/camera_app.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 36dc4bb..a00ced7 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -25,7 +25,7 @@ class CameraApp(Activity): button_width = 60 button_height = 45 - graymode = True + colormode = False status_label_text = "No camera found." status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and QR size (4-12cm). Ensure proper lighting." @@ -56,6 +56,7 @@ def load_resolution_preference(self): """Load resolution preference from SharedPreferences and update width/height.""" prefs = SharedPreferences("com.micropythonos.camera") resolution_str = prefs.get_string("resolution", f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}") + self.colormode = prefs.get_bool("colormode", False) try: width_str, height_str = resolution_str.split('x') self.width = int(width_str) @@ -397,8 +398,8 @@ def try_capture(self, event): self.image_dsc.data = self.current_cam_buffer #image.invalidate() # does not work so do this: self.image.set_src(self.image_dsc) - #if not self.use_webcam: - # self.cam.free_buffer() # Free the old buffer + if not self.use_webcam: + self.cam.free_buffer() # Free the old buffer try: if self.keepliveqrdecoding: self.qrdecode_one() @@ -882,6 +883,11 @@ def create_basic_tab(self, tab, prefs): #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) tab.set_style_pad_all(1, 0) + # Color Mode + colormode = prefs.get_bool("colormode", False) + checkbox, cont = self.create_checkbox(tab, "Color Mode (slower)", colormode, "colormode") + self.ui_controls["colormode"] = checkbox + # Resolution dropdown current_resolution = prefs.get_string("resolution", "320x240") resolution_idx = 0 @@ -918,6 +924,14 @@ def create_basic_tab(self, tab, prefs): checkbox, cont = self.create_checkbox(tab, "Vertical Flip", vflip, "vflip") self.ui_controls["vflip"] = checkbox + self.add_buttons(tab) + + def create_advanced_tab(self, tab, prefs): + """Create Advanced settings tab.""" + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(1, 0) + # Special Effect special_effect_options = [ ("None", 0), ("Negative", 1), ("Grayscale", 2), @@ -928,14 +942,6 @@ def create_basic_tab(self, tab, prefs): special_effect, "special_effect") self.ui_controls["special_effect"] = dropdown - self.add_buttons(tab) - - def create_advanced_tab(self, tab, prefs): - """Create Advanced settings tab.""" - #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) - tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) - tab.set_style_pad_all(1, 0) - # Auto Exposure Control (master switch) exposure_ctrl = prefs.get_bool("exposure_ctrl", True) aec_checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") From 55b5c66941115dfb27b92d7ee22467df0883e5da Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 18:14:54 +0100 Subject: [PATCH 045/192] Add "colormode" option --- .../assets/camera_app.py | 43 +++++++------------ 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index a00ced7..8e63011 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -1,10 +1,3 @@ -# This code grabs images from the camera in RGB565 format (2 bytes per pixel) -# and sends that to the QR decoder if QR decoding is enabled. -# The QR decoder then converts the RGB565 to grayscale, as that's what quirc operates on. -# It would be slightly more efficient to capture the images from the camera in L8/grayscale format, -# or in YUV format and discarding the U and V planes, but then the image will be gray (not great UX) -# and the performance impact of converting RGB565 to grayscale is probably minimal anyway. - import lvgl as lv from mpos.ui.keyboard import MposKeyboard @@ -76,7 +69,8 @@ def onCreate(self): self.main_screen.set_size(lv.pct(100), lv.pct(100)) self.main_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) # Initialize LVGL image widget - self.create_preview_image() + self.image = lv.image(self.main_screen) + self.image.align(lv.ALIGN.LEFT_MID, 0, 0) close_button = lv.button(self.main_screen) close_button.set_size(self.button_width, self.button_height) close_button.align(lv.ALIGN.TOP_RIGHT, 0, 0) @@ -149,6 +143,7 @@ def onResume(self, screen): print(f"camera app: webcam exception: {e}") if self.cam: print("Camera app initialized, continuing...") + self.create_preview_image() self.set_image_size() self.capture_timer = lv.timer_create(self.try_capture, 100, None) self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) @@ -200,25 +195,19 @@ def set_image_size(self): self.image.set_scale(min(scale_factor_w,scale_factor_h)) def create_preview_image(self): - self.image = lv.image(self.main_screen) - self.image.align(lv.ALIGN.LEFT_MID, 0, 0) # Create image descriptor once self.image_dsc = lv.image_dsc_t({ "header": { "magic": lv.IMAGE_HEADER_MAGIC, "w": self.width, "h": self.height, - #"stride": self.width * 2, # RGB565 - "stride": self.width, # RGB565 - #"cf": lv.COLOR_FORMAT.RGB565 - "cf": lv.COLOR_FORMAT.L8 + "stride": self.width * (2 if self.colormode else 1), + "cf": lv.COLOR_FORMAT.RGB565 if self.colormode else lv.COLOR_FORMAT.L8 }, - #'data_size': self.width * self.height * 2, # RGB565 - 'data_size': self.width * self.height, # gray + 'data_size': self.width * self.height * (2 if self.colormode else 1), 'data': None # Will be updated per frame }) self.image.set_src(self.image_dsc) - #self.image.set_size(160, 120) def qrdecode_one(self): @@ -263,7 +252,8 @@ def snap_button_click(self, e): except OSError: pass if self.current_cam_buffer is not None: - filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_RGB565.raw" + colorname = "RGB565" if self.colormode else "GRAY" + filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" try: with open(filename, 'wb') as f: f.write(self.current_cam_buffer) @@ -345,10 +335,8 @@ def handle_settings_result(self, result): # Note: image_dsc is an LVGL struct, use attribute access not dictionary access self.image_dsc.header.w = self.width self.image_dsc.header.h = self.height - #self.image_dsc.header.stride = self.width * 2 # RGB565 - #self.image_dsc.data_size = self.width * self.height * 2 #RGB565 - self.image_dsc.header.stride = self.width - self.image_dsc.data_size = self.width * self.height + self.image_dsc.header.stride = self.width * (2 if self.colormode else 1) + self.image_dsc.data_size = self.width * self.height * (2 if self.colormode else 1) print(f"Image descriptor updated to {self.width}x{self.height}") # Reconfigure camera if active @@ -376,13 +364,14 @@ def handle_settings_result(self, result): self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) return # Don't continue if camera failed + self.create_preview_image() self.set_image_size() def try_capture(self, event): #print("capturing camera frame") try: if self.use_webcam: - self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565") + self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") elif self.cam.frame_available(): #self.cam.free_buffer() self.current_cam_buffer = self.cam.capture() @@ -390,13 +379,12 @@ def try_capture(self, event): if self.current_cam_buffer and len(self.current_cam_buffer): # Defensive check: verify buffer size matches expected dimensions - #expected_size = self.width * self.height * 2 # RGB565 = 2 bytes per pixel - expected_size = self.width * self.height # Grayscale = 1 byte per pixel + expected_size = self.width * self.height * (2 if self.colormode else 1) actual_size = len(self.current_cam_buffer) if actual_size == expected_size: self.image_dsc.data = self.current_cam_buffer - #image.invalidate() # does not work so do this: + #self.image.invalidate() # does not work so do this: self.image.set_src(self.image_dsc) if not self.use_webcam: self.cam.free_buffer() # Free the old buffer @@ -468,8 +456,7 @@ def init_internal_cam(width, height): xclk_freq=20000000, powerdown_pin=-1, reset_pin=-1, - #pixel_format=PixelFormat.RGB565, - pixel_format=PixelFormat.GRAYSCALE, + pixel_format=PixelFormat.RGB565 if self.colormode else PixelFormat.GRAYSCALE, frame_size=frame_size, #grab_mode=GrabMode.WHEN_EMPTY, grab_mode=GrabMode.LATEST, From 7bca660b3bbdd414a915e0b1089fce917d34e8bb Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 18:17:09 +0100 Subject: [PATCH 046/192] Simplify --- .../assets/camera_app.py | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 8e63011..34b34cc 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -143,8 +143,7 @@ def onResume(self, screen): print(f"camera app: webcam exception: {e}") if self.cam: print("Camera app initialized, continuing...") - self.create_preview_image() - self.set_image_size() + self.update_preview_image() self.capture_timer = lv.timer_create(self.try_capture, 100, None) self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) if self.scanqr_mode or self.keepliveqrdecoding: @@ -181,21 +180,7 @@ def onPause(self, screen): print(f"Warning: powering off camera got exception: {e}") print("camera app cleanup done.") - def set_image_size(self): - disp = lv.display_get_default() - target_h = disp.get_vertical_resolution() - #target_w = disp.get_horizontal_resolution() - self.button_width - 5 # leave 5px for border - target_w = target_h # square - print(f"scaling to size: {target_w}x{target_h}") - scale_factor_w = round(target_w * 256 / self.width) - scale_factor_h = round(target_h * 256 / self.height) - print(f"scale_factors: {scale_factor_w},{scale_factor_h}") - self.image.set_size(target_w, target_h) - #self.image.set_scale(max(scale_factor_w,scale_factor_h)) # fills the entire screen but cuts off borders - self.image.set_scale(min(scale_factor_w,scale_factor_h)) - - def create_preview_image(self): - # Create image descriptor once + def update_preview_image(self): self.image_dsc = lv.image_dsc_t({ "header": { "magic": lv.IMAGE_HEADER_MAGIC, @@ -208,7 +193,17 @@ def create_preview_image(self): 'data': None # Will be updated per frame }) self.image.set_src(self.image_dsc) - + disp = lv.display_get_default() + target_h = disp.get_vertical_resolution() + #target_w = disp.get_horizontal_resolution() - self.button_width - 5 # leave 5px for border + target_w = target_h # square + print(f"scaling to size: {target_w}x{target_h}") + scale_factor_w = round(target_w * 256 / self.width) + scale_factor_h = round(target_h * 256 / self.height) + print(f"scale_factors: {scale_factor_w},{scale_factor_h}") + self.image.set_size(target_w, target_h) + #self.image.set_scale(max(scale_factor_w,scale_factor_h)) # fills the entire screen but cuts off borders + self.image.set_scale(min(scale_factor_w,scale_factor_h)) def qrdecode_one(self): try: @@ -364,8 +359,7 @@ def handle_settings_result(self, result): self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) return # Don't continue if camera failed - self.create_preview_image() - self.set_image_size() + self.update_preview_image() def try_capture(self, event): #print("capturing camera frame") From 5a0fc809d605cfb3d215939c47dbcb51c2865ca2 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 20:04:07 +0100 Subject: [PATCH 047/192] Camera app: simplify --- .../assets/camera_app.py | 62 +------------------ 1 file changed, 3 insertions(+), 59 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 34b34cc..2ccca3f 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -61,7 +61,6 @@ def load_resolution_preference(self): self.height = self.DEFAULT_HEIGHT def onCreate(self): - self.load_resolution_preference() self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") self.main_screen = lv.obj() self.main_screen.set_style_pad_all(1, 0) @@ -127,11 +126,12 @@ def onCreate(self): self.setContentView(self.main_screen) def onResume(self, screen): + self.load_resolution_preference() # needs to be done BEFORE the camera is initialized self.cam = init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees # Apply saved camera settings, only for internal camera for now: - apply_camera_settings(self.cam, self.use_webcam) + apply_camera_settings(self.cam, self.use_webcam) # needs to be done AFTER the camera is initialized else: print("camera app: no internal camera found, trying webcam on /dev/video0") try: @@ -296,70 +296,14 @@ def zoom_button_click(self, e): outputY = prefs.get_int("startX", CameraSettingsActivity.outputY_default) scale = prefs.get_bool("scale", CameraSettingsActivity.scale_default) binning = prefs.get_bool("binning", CameraSettingsActivity.binning_default) - # This works as it's what works in the C code: result = self.cam.set_res_raw(startX,startY,endX,endY,offsetX,offsetY,totalX,totalY,outputX,outputY,scale,binning) print(f"self.cam.set_res_raw returned {result}") def open_settings(self): self.image_dsc.data = None self.current_cam_buffer = None - """Launch the camera settings activity.""" intent = Intent(activity_class=CameraSettingsActivity) - self.startActivityForResult(intent, self.handle_settings_result) - - def handle_settings_result(self, result): - print(f"handle_settings_result: {result}") - """Handle result from settings activity.""" - if result.get("result_code") == True: - print("Settings changed, reloading resolution...") - # Reload resolution preference - self.load_resolution_preference() - - # CRITICAL: Pause capture timer to prevent race conditions during reconfiguration - if self.capture_timer: - self.capture_timer.delete() - self.capture_timer = None - print("Capture timer paused") - - # Clear stale data pointer to prevent segfault during LVGL rendering - self.image_dsc.data = None - self.current_cam_buffer = None - print("Image data cleared") - - # Update image descriptor with new dimensions - # Note: image_dsc is an LVGL struct, use attribute access not dictionary access - self.image_dsc.header.w = self.width - self.image_dsc.header.h = self.height - self.image_dsc.header.stride = self.width * (2 if self.colormode else 1) - self.image_dsc.data_size = self.width * self.height * (2 if self.colormode else 1) - print(f"Image descriptor updated to {self.width}x{self.height}") - - # Reconfigure camera if active - if self.cam: - if self.use_webcam: - print(f"Reconfiguring webcam to {self.width}x{self.height}") - # Reconfigure webcam resolution (input and output are the same) - webcam.reconfigure(self.cam, width=self.width, height=self.height) - # Resume capture timer for webcam - self.capture_timer = lv.timer_create(self.try_capture, 100, None) - print("Webcam reconfigured, capture timer resumed") - else: - # For internal camera, need to reinitialize - print(f"Reinitializing internal camera to {self.width}x{self.height}") - self.cam.deinit() - self.cam = init_internal_cam(self.width, self.height) - if self.cam: - # Apply all camera settings - apply_camera_settings(self.cam, self.use_webcam) - self.capture_timer = lv.timer_create(self.try_capture, 100, None) - print("Internal camera reinitialized, capture timer resumed") - else: - print("ERROR: Failed to reinitialize camera after resolution change") - self.status_label.set_text("Failed to reinitialize camera.\nPlease restart the app.") - self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) - return # Don't continue if camera failed - - self.update_preview_image() + self.startActivity(intent) def try_capture(self, event): #print("capturing camera frame") From 2884ef614ea7e80e675db6a3bf25a398b8e4e4cb Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 20:06:45 +0100 Subject: [PATCH 048/192] Camera app: Acitvity lifecycle functions on top --- .../assets/camera_app.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 2ccca3f..fe433c0 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -45,21 +45,6 @@ class CameraApp(Activity): status_label = None status_label_cont = None - def load_resolution_preference(self): - """Load resolution preference from SharedPreferences and update width/height.""" - prefs = SharedPreferences("com.micropythonos.camera") - resolution_str = prefs.get_string("resolution", f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}") - self.colormode = prefs.get_bool("colormode", False) - try: - width_str, height_str = resolution_str.split('x') - self.width = int(width_str) - self.height = int(height_str) - print(f"Camera resolution loaded: {self.width}x{self.height}") - except Exception as e: - print(f"Error parsing resolution '{resolution_str}': {e}, using default 320x240") - self.width = self.DEFAULT_WIDTH - self.height = self.DEFAULT_HEIGHT - def onCreate(self): self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") self.main_screen = lv.obj() @@ -180,6 +165,21 @@ def onPause(self, screen): print(f"Warning: powering off camera got exception: {e}") print("camera app cleanup done.") + def load_resolution_preference(self): + """Load resolution preference from SharedPreferences and update width/height.""" + prefs = SharedPreferences("com.micropythonos.camera") + resolution_str = prefs.get_string("resolution", f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}") + self.colormode = prefs.get_bool("colormode", False) + try: + width_str, height_str = resolution_str.split('x') + self.width = int(width_str) + self.height = int(height_str) + print(f"Camera resolution loaded: {self.width}x{self.height}") + except Exception as e: + print(f"Error parsing resolution '{resolution_str}': {e}, using default 320x240") + self.width = self.DEFAULT_WIDTH + self.height = self.DEFAULT_HEIGHT + def update_preview_image(self): self.image_dsc = lv.image_dsc_t({ "header": { From 8e0063c2362c5d46f88282f3dc75133e06282c79 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 21:18:18 +0100 Subject: [PATCH 049/192] Camera app: cleanups --- c_mpos/src/quirc_decode.c | 2 +- .../assets/camera_app.py | 74 +++++++++---------- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/c_mpos/src/quirc_decode.c b/c_mpos/src/quirc_decode.c index 32eee10..3607ea9 100644 --- a/c_mpos/src/quirc_decode.c +++ b/c_mpos/src/quirc_decode.c @@ -118,7 +118,7 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { free(data); free(code); quirc_destroy(qr); - QRDECODE_DEBUG_PRINT("qrdecode: Freed data, code, and quirc object, returning result\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Freed data, code, and quirc object, returning result\n"); return result; } diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index fe433c0..b8a2522 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -552,6 +552,12 @@ def apply_camera_settings(cam, use_webcam): print(f"Error applying camera settings: {e}") + + + + + + class CameraSettingsActivity(Activity): """Settings activity for comprehensive camera configuration.""" @@ -656,8 +662,8 @@ def onCreate(self): expert_tab = tabview.add_tab("Expert") self.create_expert_tab(expert_tab, prefs) - raw_tab = tabview.add_tab("Raw") - self.create_raw_tab(raw_tab, prefs) + #raw_tab = tabview.add_tab("Raw") + #self.create_raw_tab(raw_tab, prefs) self.setContentView(screen) @@ -754,19 +760,10 @@ def create_textarea(self, parent, label_text, min_val, max_val, default_val, pre return textarea, cont def show_keyboard(self, kbd): - #self.button_cont.add_flag(lv.obj.FLAG.HIDDEN) mpos.ui.anim.smooth_show(kbd) - focusgroup = lv.group_get_default() - if focusgroup: - # move the focus to the keyboard to save the user a "next" button press (optional but nice) - # this is focusing on the right thing (keyboard) but the focus is not "active" (shown or used) somehow - #print(f"current focus object: {lv.group_get_default().get_focused()}") - focusgroup.focus_next() - #print(f"current focus object: {lv.group_get_default().get_focused()}") def hide_keyboard(self, kbd): mpos.ui.anim.smooth_hide(kbd) - #self.button_cont.remove_flag(lv.obj.FLAG.HIDDEN) def add_buttons(self, parent): # Save/Cancel buttons at bottom @@ -775,7 +772,6 @@ def add_buttons(self, parent): button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) button_cont.set_style_border_width(0, 0) - button_cont.set_style_bg_opa(0, 0) save_button = lv.button(button_cont) save_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) @@ -857,16 +853,6 @@ def create_advanced_tab(self, tab, prefs): tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) tab.set_style_pad_all(1, 0) - # Special Effect - special_effect_options = [ - ("None", 0), ("Negative", 1), ("Grayscale", 2), - ("Reddish", 3), ("Greenish", 4), ("Blue", 5), ("Retro", 6) - ] - special_effect = prefs.get_int("special_effect", 0) - dropdown, cont = self.create_dropdown(tab, "Special Effect:", special_effect_options, - special_effect, "special_effect") - self.ui_controls["special_effect"] = dropdown - # Auto Exposure Control (master switch) exposure_ctrl = prefs.get_bool("exposure_ctrl", True) aec_checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") @@ -877,27 +863,27 @@ def create_advanced_tab(self, tab, prefs): me_slider, label, cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") self.ui_controls["aec_value"] = me_slider - # Set initial state - if exposure_ctrl: - me_slider.add_state(lv.STATE.DISABLED) - me_slider.set_style_bg_opa(128, 0) + # Auto Exposure Level (dependent) + ae_level = prefs.get_int("ae_level", 0) + ae_slider, label, cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") + self.ui_controls["ae_level"] = ae_slider # Add dependency handler - def exposure_ctrl_changed(e): + def exposure_ctrl_changed(e=None): is_auto = aec_checkbox.get_state() & lv.STATE.CHECKED if is_auto: me_slider.add_state(lv.STATE.DISABLED) - me_slider.set_style_bg_opa(128, 0) + me_slider.set_style_opa(128, 0) + ae_slider.remove_state(lv.STATE.DISABLED) + ae_slider.set_style_opa(255, 0) else: me_slider.remove_state(lv.STATE.DISABLED) - me_slider.set_style_bg_opa(255, 0) + me_slider.set_style_opa(255, 0) + ae_slider.add_state(lv.STATE.DISABLED) + ae_slider.set_style_opa(128, 0) aec_checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) - - # Auto Exposure Level - ae_level = prefs.get_int("ae_level", 0) - slider, label, cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") - self.ui_controls["ae_level"] = slider + exposure_ctrl_changed() # Night Mode (AEC2) aec2 = prefs.get_bool("aec2", False) @@ -916,17 +902,17 @@ def exposure_ctrl_changed(e): if gain_ctrl: slider.add_state(lv.STATE.DISABLED) - slider.set_style_bg_opa(128, 0) + slider.set_style_opa(128, 0) def gain_ctrl_changed(e): is_auto = agc_checkbox.get_state() & lv.STATE.CHECKED gain_slider = self.ui_controls["agc_gain"] if is_auto: gain_slider.add_state(lv.STATE.DISABLED) - gain_slider.set_style_bg_opa(128, 0) + gain_slider.set_style_opa(128, 0) else: gain_slider.remove_state(lv.STATE.DISABLED) - gain_slider.set_style_bg_opa(255, 0) + gain_slider.set_style_opa(255, 0) agc_checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) @@ -972,6 +958,16 @@ def whitebal_changed(e): self.add_buttons(tab) + # Special Effect + special_effect_options = [ + ("None", 0), ("Negative", 1), ("Grayscale", 2), + ("Reddish", 3), ("Greenish", 4), ("Blue", 5), ("Retro", 6) + ] + special_effect = prefs.get_int("special_effect", 0) + dropdown, cont = self.create_dropdown(tab, "Special Effect:", special_effect_options, + special_effect, "special_effect") + self.ui_controls["special_effect"] = dropdown + def create_expert_tab(self, tab, prefs): """Create Expert settings tab.""" #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) @@ -989,7 +985,7 @@ def create_expert_tab(self, tab, prefs): if not supports_sharpness: slider.add_state(lv.STATE.DISABLED) - slider.set_style_bg_opa(128, 0) + slider.set_style_opa(128, 0) note = lv.label(cont) note.set_text("(Not available on this sensor)") note.set_style_text_color(lv.color_hex(0x808080), 0) @@ -1002,7 +998,7 @@ def create_expert_tab(self, tab, prefs): if not supports_sharpness: slider.add_state(lv.STATE.DISABLED) - slider.set_style_bg_opa(128, 0) + slider.set_style_opa(128, 0) note = lv.label(cont) note.set_text("(Not available on this sensor)") note.set_style_text_color(lv.color_hex(0x808080), 0) From 06d98ceabdb69c030c12419505dc625a87e74833 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 28 Nov 2025 21:51:09 +0100 Subject: [PATCH 050/192] Camera app: cleanup, add animations --- .../assets/camera_app.py | 68 +++++-------------- internal_filesystem/lib/mpos/ui/anim.py | 61 +++++------------ 2 files changed, 35 insertions(+), 94 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index b8a2522..acf7084 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -849,7 +849,6 @@ def create_basic_tab(self, tab, prefs): def create_advanced_tab(self, tab, prefs): """Create Advanced settings tab.""" - #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) tab.set_style_pad_all(1, 0) @@ -860,27 +859,23 @@ def create_advanced_tab(self, tab, prefs): # Manual Exposure Value (dependent) aec_value = prefs.get_int("aec_value", 300) - me_slider, label, cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") + me_slider, label, me_cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") self.ui_controls["aec_value"] = me_slider # Auto Exposure Level (dependent) ae_level = prefs.get_int("ae_level", 0) - ae_slider, label, cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") + ae_slider, label, ae_cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") self.ui_controls["ae_level"] = ae_slider # Add dependency handler def exposure_ctrl_changed(e=None): is_auto = aec_checkbox.get_state() & lv.STATE.CHECKED if is_auto: - me_slider.add_state(lv.STATE.DISABLED) - me_slider.set_style_opa(128, 0) - ae_slider.remove_state(lv.STATE.DISABLED) - ae_slider.set_style_opa(255, 0) + mpos.ui.anim.smooth_hide(me_cont, duration=1000) + mpos.ui.anim.smooth_show(ae_cont, delay=1000) else: - me_slider.remove_state(lv.STATE.DISABLED) - me_slider.set_style_opa(255, 0) - ae_slider.add_state(lv.STATE.DISABLED) - ae_slider.set_style_opa(128, 0) + mpos.ui.anim.smooth_hide(ae_cont, duration=1000) + mpos.ui.anim.smooth_show(me_cont, delay=1000) aec_checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) exposure_ctrl_changed() @@ -897,24 +892,19 @@ def exposure_ctrl_changed(e=None): # Manual Gain Value (dependent) agc_gain = prefs.get_int("agc_gain", 0) - slider, label, cont = self.create_slider(tab, "Manual Gain", 0, 30, agc_gain, "agc_gain") + slider, label, agc_cont = self.create_slider(tab, "Manual Gain", 0, 30, agc_gain, "agc_gain") self.ui_controls["agc_gain"] = slider - if gain_ctrl: - slider.add_state(lv.STATE.DISABLED) - slider.set_style_opa(128, 0) - - def gain_ctrl_changed(e): + def gain_ctrl_changed(e=None): is_auto = agc_checkbox.get_state() & lv.STATE.CHECKED gain_slider = self.ui_controls["agc_gain"] if is_auto: - gain_slider.add_state(lv.STATE.DISABLED) - gain_slider.set_style_opa(128, 0) + mpos.ui.anim.smooth_hide(agc_cont, duration=1000) else: - gain_slider.remove_state(lv.STATE.DISABLED) - gain_slider.set_style_opa(255, 0) + mpos.ui.anim.smooth_show(agc_cont, duration=1000) agc_checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + gain_ctrl_changed() # Gain Ceiling gainceiling_options = [ @@ -935,21 +925,17 @@ def gain_ctrl_changed(e): ("Auto", 0), ("Sunny", 1), ("Cloudy", 2), ("Office", 3), ("Home", 4) ] wb_mode = prefs.get_int("wb_mode", 0) - dropdown, cont = self.create_dropdown(tab, "WB Mode:", wb_mode_options, wb_mode, "wb_mode") - self.ui_controls["wb_mode"] = dropdown + wb_dropdown, wb_cont = self.create_dropdown(tab, "WB Mode:", wb_mode_options, wb_mode, "wb_mode") + self.ui_controls["wb_mode"] = wb_dropdown - if whitebal: - dropdown.add_state(lv.STATE.DISABLED) - - def whitebal_changed(e): + def whitebal_changed(e=None): is_auto = wbcheckbox.get_state() & lv.STATE.CHECKED - wb_dropdown = self.ui_controls["wb_mode"] if is_auto: - wb_dropdown.add_state(lv.STATE.DISABLED) + mpos.ui.anim.smooth_hide(wb_cont, duration=1000) else: - wb_dropdown.remove_state(lv.STATE.DISABLED) - + mpos.ui.anim.smooth_show(wb_cont, duration=1000) wbcheckbox.add_event_cb(whitebal_changed, lv.EVENT.VALUE_CHANGED, None) + whitebal_changed() # AWB Gain awb_gain = prefs.get_bool("awb_gain", True) @@ -974,36 +960,16 @@ def create_expert_tab(self, tab, prefs): tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) tab.set_style_pad_all(1, 0) - # Note: Sensor detection isn't performed right now - # For now, show sharpness/denoise with note - supports_sharpness = True # Assume yes - # Sharpness sharpness = prefs.get_int("sharpness", 0) slider, label, cont = self.create_slider(tab, "Sharpness", -3, 3, sharpness, "sharpness") self.ui_controls["sharpness"] = slider - if not supports_sharpness: - slider.add_state(lv.STATE.DISABLED) - slider.set_style_opa(128, 0) - note = lv.label(cont) - note.set_text("(Not available on this sensor)") - note.set_style_text_color(lv.color_hex(0x808080), 0) - note.align(lv.ALIGN.TOP_RIGHT, 0, 0) - # Denoise denoise = prefs.get_int("denoise", 0) slider, label, cont = self.create_slider(tab, "Denoise", 0, 8, denoise, "denoise") self.ui_controls["denoise"] = slider - if not supports_sharpness: - slider.add_state(lv.STATE.DISABLED) - slider.set_style_opa(128, 0) - note = lv.label(cont) - note.set_text("(Not available on this sensor)") - note.set_style_text_color(lv.color_hex(0x808080), 0) - note.align(lv.ALIGN.TOP_RIGHT, 0, 0) - # JPEG Quality # Disabled because JPEG is not used right now #quality = prefs.get_int("quality", 85) diff --git a/internal_filesystem/lib/mpos/ui/anim.py b/internal_filesystem/lib/mpos/ui/anim.py index 0ae5068..1f8310a 100644 --- a/internal_filesystem/lib/mpos/ui/anim.py +++ b/internal_filesystem/lib/mpos/ui/anim.py @@ -41,19 +41,18 @@ class WidgetAnimator: # show_widget and hide_widget could have a (lambda) callback that sets the final state (eg: drawer_open) at the end @staticmethod def show_widget(widget, anim_type="fade", duration=500, delay=0): - """Show a widget with an animation (fade or slide).""" - lv.anim_delete(widget, None) # stop all ongoing animations to prevent visual glitches - widget.remove_flag(lv.obj.FLAG.HIDDEN) # Clear HIDDEN flag to make widget visible for animation + anim = lv.anim_t() + anim.init() + anim.set_var(widget) + anim.set_delay(delay) + anim.set_duration(duration) + # Clear HIDDEN flag to make widget visible for animation: + anim.set_start_cb(lambda *args: safe_widget_access(lambda: widget.remove_flag(lv.obj.FLAG.HIDDEN))) if anim_type == "fade": # Create fade-in animation (opacity from 0 to 255) - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(0, 255) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_style_opa(value, 0))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Ensure opacity is reset after animation @@ -63,50 +62,38 @@ def show_widget(widget, anim_type="fade", duration=500, delay=0): # Create slide-down animation (y from -height to original y) original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y - height, original_y) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Reset y position after animation anim.set_completed_cb(lambda *args: safe_widget_access(lambda: widget.set_y(original_y))) - elif anim_type == "slide_up": + else: # "slide_up": # Create slide-up animation (y from +height to original y) # Seems to cause scroll bars to be added somehow if done to a keyboard at the bottom of the screen... original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y + height, original_y) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Reset y position after animation anim.set_completed_cb(lambda *args: safe_widget_access(lambda: widget.set_y(original_y))) - # Store and start animation - #self.animations[widget] = anim anim.start() return anim @staticmethod def hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=True): lv.anim_delete(widget, None) # stop all ongoing animations to prevent visual glitches + anim = lv.anim_t() + anim.init() + anim.set_var(widget) + anim.set_duration(duration) + anim.set_delay(delay) """Hide a widget with an animation (fade or slide).""" if anim_type == "fade": # Create fade-out animation (opacity from 255 to 0) - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(255, 0) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_style_opa(value, 0))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Set HIDDEN flag after animation @@ -116,34 +103,22 @@ def hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=True): # Seems to cause scroll bars to be added somehow if done to a keyboard at the bottom of the screen... original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y, original_y + height) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Set HIDDEN flag after animation anim.set_completed_cb(lambda *args: safe_widget_access(lambda: WidgetAnimator.hide_complete_cb(widget, original_y, hide))) - elif anim_type == "slide_up": + else: # "slide_up": print("hide with slide_up") # Create slide-up animation (y from original y to -height) original_y = widget.get_y() height = widget.get_height() - anim = lv.anim_t() - anim.init() - anim.set_var(widget) anim.set_values(original_y, original_y - height) - anim.set_duration(duration) - anim.set_delay(delay) anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Set HIDDEN flag after animation anim.set_completed_cb(lambda *args: safe_widget_access(lambda: WidgetAnimator.hide_complete_cb(widget, original_y, hide))) - # Store and start animation - #self.animations[widget] = anim anim.start() return anim @@ -156,8 +131,8 @@ def hide_complete_cb(widget, original_y=None, hide=True): widget.set_y(original_y) # in case it shifted slightly due to rounding etc -def smooth_show(widget): - return WidgetAnimator.show_widget(widget, anim_type="fade", duration=500, delay=0) +def smooth_show(widget, duration=500, delay=0): + return WidgetAnimator.show_widget(widget, anim_type="fade", duration=duration, delay=delay) -def smooth_hide(widget, hide=True): - return WidgetAnimator.hide_widget(widget, anim_type="fade", duration=500, delay=0, hide=hide) +def smooth_hide(widget, hide=True, duration=500, delay=0): + return WidgetAnimator.hide_widget(widget, anim_type="fade", duration=duration, delay=delay, hide=hide) From cee0b926ab7e71e9d619845de1d8e8270884c7c4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 08:06:12 +0100 Subject: [PATCH 051/192] Camera app: extract variable --- CHANGELOG.md | 1 + .../assets/camera_app.py | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef495f0..512c080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - OSUpdate app: pause download when wifi is lost, resume when reconnected - Settings app: fix un-checking of radio button - API: SharedPreferences: add erase_all() functionality +- API: improve and cleanup animations 0.5.0 ===== diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index acf7084..4b071f6 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -15,14 +15,17 @@ class CameraApp(Activity): DEFAULT_WIDTH = 320 # 240 would be better but webcam doesn't support this (yet) DEFAULT_HEIGHT = 240 + APPNAME = "com.micropythonos.camera" + #DEFAULT_CONFIG = "config.json" + #QRCODE_CONFIG = "config_qrmode.json" button_width = 60 button_height = 45 colormode = False status_label_text = "No camera found." - status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and QR size (4-12cm). Ensure proper lighting." - status_label_text_found = "Decoding QR..." + status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and make the QR code big (4-12cm). Ensure proper lighting." + status_label_text_found = "Found QR, trying to decode... hold still..." cam = None current_cam_buffer = None # Holds the current memoryview to prevent garbage collection @@ -167,7 +170,7 @@ def onPause(self, screen): def load_resolution_preference(self): """Load resolution preference from SharedPreferences and update width/height.""" - prefs = SharedPreferences("com.micropythonos.camera") + prefs = SharedPreferences(CameraApp.APPNAME) resolution_str = prefs.get_string("resolution", f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}") self.colormode = prefs.get_bool("colormode", False) try: @@ -283,7 +286,7 @@ def zoom_button_click(self, e): print("zoom_button_click is not supported for webcam") return if self.cam: - prefs = SharedPreferences("com.micropythonos.camera") + prefs = SharedPreferences(CameraApp.APPNAME) startX = prefs.get_int("startX", CameraSettingsActivity.startX_default) startY = prefs.get_int("startX", CameraSettingsActivity.startY_default) endX = prefs.get_int("startX", CameraSettingsActivity.endX_default) @@ -447,7 +450,7 @@ def apply_camera_settings(cam, use_webcam): print("apply_camera_settings: Skipping (no camera or webcam mode)") return - prefs = SharedPreferences("com.micropythonos.camera") + prefs = SharedPreferences(CameraApp.APPNAME) try: # Basic image adjustments @@ -628,7 +631,7 @@ def __init__(self): def onCreate(self): # Load preferences - prefs = SharedPreferences("com.micropythonos.camera") + prefs = SharedPreferences(CameraApp.APPNAME) # Detect platform (webcam vs ESP32) try: @@ -1066,13 +1069,13 @@ def create_raw_tab(self, tab, prefs): self.add_buttons(tab) def erase_and_close(self): - SharedPreferences("com.micropythonos.camera").edit().remove_all().commit() + SharedPreferences(CameraApp.APPNAME).edit().remove_all().commit() self.setResult(True, {"settings_changed": True}) self.finish() def save_and_close(self): """Save all settings to SharedPreferences and return result.""" - prefs = SharedPreferences("com.micropythonos.camera") + prefs = SharedPreferences(CameraApp.APPNAME) editor = prefs.edit() # Save all UI control values From 918561595ac6d7e2b25c4594732d9f78e111b920 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 08:16:23 +0100 Subject: [PATCH 052/192] Camera app: scanqr_mode and use_webcam aware --- .../assets/camera_app.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 4b071f6..240ebac 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -305,7 +305,7 @@ def zoom_button_click(self, e): def open_settings(self): self.image_dsc.data = None self.current_cam_buffer = None - intent = Intent(activity_class=CameraSettingsActivity) + intent = Intent(activity_class=CameraSettingsActivity, extras={"use_webcam": self.use_webcam, "scanqr_mode": self.scanqr_mode}) self.startActivity(intent) def try_capture(self, event): @@ -618,6 +618,9 @@ class CameraSettingsActivity(Activity): ("1920x1080", "1920x1080"), ] + use_webcam = False + scanqr_mode = False + # Widgets: button_cont = None @@ -630,19 +633,18 @@ def __init__(self): self.resolutions = [] def onCreate(self): - # Load preferences - prefs = SharedPreferences(CameraApp.APPNAME) - - # Detect platform (webcam vs ESP32) - try: - import webcam - self.is_webcam = True + self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") + self.use_webcam = self.getIntent().extras.get("use_webcam") + if self.use_webcam: self.resolutions = self.WEBCAM_RESOLUTIONS print("Using webcam resolutions") - except: + else: self.resolutions = self.ESP32_RESOLUTIONS print("Using ESP32 camera resolutions") + # Load preferences + prefs = SharedPreferences(CameraApp.APPNAME) + # Create main screen screen = lv.obj() screen.set_size(lv.pct(100), lv.pct(100)) @@ -658,7 +660,7 @@ def onCreate(self): self.create_basic_tab(basic_tab, prefs) # Create Advanced and Expert tabs only for ESP32 camera - if not self.is_webcam or True: # for now, show all tabs + if not self.use_webcam or True: # for now, show all tabs advanced_tab = tabview.add_tab("Advanced") self.create_advanced_tab(advanced_tab, prefs) From e3157a7d320401e0fe8893e44c0b0e257b9daa04 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 08:30:52 +0100 Subject: [PATCH 053/192] Move CameraSettingsActivity to its own file It's big enough to stand on its own now. --- .../assets/camera_app.py | 571 +----------------- .../assets/camera_settings.py | 567 +++++++++++++++++ 2 files changed, 572 insertions(+), 566 deletions(-) create mode 100644 internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 240ebac..2932ae3 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -1,15 +1,16 @@ import lvgl as lv -from mpos.ui.keyboard import MposKeyboard try: import webcam except Exception as e: print(f"Info: could not import webcam module: {e}") +import mpos.time from mpos.apps import Activity from mpos.config import SharedPreferences from mpos.content.intent import Intent -import mpos.time + +from camera_settings import CameraSettingsActivity class CameraApp(Activity): @@ -212,9 +213,9 @@ def qrdecode_one(self): try: import qrdecode import utime - before = utime.ticks_ms() + before = time.ticks_ms() result = qrdecode.qrdecode(self.current_cam_buffer, self.width, self.height) - after = utime.ticks_ms() + after = time.ticks_ms() #result = bytearray("INSERT_QR_HERE", "utf-8") if not result: self.status_label.set_text(self.status_label_text_searching) @@ -554,565 +555,3 @@ def apply_camera_settings(cam, use_webcam): except Exception as e: print(f"Error applying camera settings: {e}") - - - - - - - -class CameraSettingsActivity(Activity): - """Settings activity for comprehensive camera configuration.""" - - # Original: { 2560, 1920, 0, 0, 2623, 1951, 32, 16, 2844, 1968 } - # Worked for digital zoom in C: { 2560, 1920, 0, 0, 2623, 1951, 992, 736, 2844, 1968 } - startX_default=0 - startY_default=0 - endX_default=2623 - endY_default=1951 - offsetX_default=32 - offsetY_default=16 - totalX_default=2844 - totalY_default=1968 - outputX_default=640 - outputY_default=480 - scale_default=False - binning_default=False - - # Resolution options for desktop/webcam - WEBCAM_RESOLUTIONS = [ - ("160x120", "160x120"), - ("320x180", "320x180"), - ("320x240", "320x240"), - ("640x360", "640x360"), - ("640x480 (30 fps)", "640x480"), - ("1280x720 (10 fps)", "1280x720"), - ("1920x1080 (5 fps)", "1920x1080"), - ] - - # Resolution options for internal camera (ESP32) - ESP32_RESOLUTIONS = [ - ("96x96", "96x96"), - ("160x120", "160x120"), - ("128x128", "128x128"), - ("176x144", "176x144"), - ("240x176", "240x176"), - ("240x240", "240x240"), - ("320x240", "320x240"), - ("320x320", "320x320"), - ("400x296", "400x296"), - ("480x320", "480x320"), - ("480x480", "480x480"), - ("640x480", "640x480"), - ("640x640", "640x640"), - ("720x720", "720x720"), - ("800x600", "800x600"), - ("800x800", "800x800"), - ("960x960", "960x960"), - ("1024x768", "1024x768"), - ("1024x1024","1024x1024"), - ("1280x720", "1280x720"), # binned 2x2 (in default ov5640.c) - ("1280x1024", "1280x1024"), - ("1280x1280", "1280x1280"), - ("1600x1200", "1600x1200"), - ("1920x1080", "1920x1080"), - ] - - use_webcam = False - scanqr_mode = False - - # Widgets: - button_cont = None - - def __init__(self): - super().__init__() - self.ui_controls = {} - self.control_metadata = {} # Store pref_key and option_values for each control - self.dependent_controls = {} - self.is_webcam = False - self.resolutions = [] - - def onCreate(self): - self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") - self.use_webcam = self.getIntent().extras.get("use_webcam") - if self.use_webcam: - self.resolutions = self.WEBCAM_RESOLUTIONS - print("Using webcam resolutions") - else: - self.resolutions = self.ESP32_RESOLUTIONS - print("Using ESP32 camera resolutions") - - # Load preferences - prefs = SharedPreferences(CameraApp.APPNAME) - - # Create main screen - screen = lv.obj() - screen.set_size(lv.pct(100), lv.pct(100)) - screen.set_style_pad_all(1, 0) - - # Create tabview - tabview = lv.tabview(screen) - tabview.set_tab_bar_size(mpos.ui.pct_of_display_height(15)) - #tabview.set_size(lv.pct(100), mpos.ui.pct_of_display_height(80)) - - # Create Basic tab (always) - basic_tab = tabview.add_tab("Basic") - self.create_basic_tab(basic_tab, prefs) - - # Create Advanced and Expert tabs only for ESP32 camera - if not self.use_webcam or True: # for now, show all tabs - advanced_tab = tabview.add_tab("Advanced") - self.create_advanced_tab(advanced_tab, prefs) - - expert_tab = tabview.add_tab("Expert") - self.create_expert_tab(expert_tab, prefs) - - #raw_tab = tabview.add_tab("Raw") - #self.create_raw_tab(raw_tab, prefs) - - self.setContentView(screen) - - def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_key): - """Create slider with label showing current value.""" - cont = lv.obj(parent) - cont.set_size(lv.pct(100), 60) - cont.set_style_pad_all(3, 0) - - label = lv.label(cont) - label.set_text(f"{label_text}: {default_val}") - label.align(lv.ALIGN.TOP_LEFT, 0, 0) - - slider = lv.slider(cont) - slider.set_size(lv.pct(90), 15) - slider.set_range(min_val, max_val) - slider.set_value(default_val, False) - slider.align(lv.ALIGN.BOTTOM_MID, 0, -10) - - def slider_changed(e): - val = slider.get_value() - label.set_text(f"{label_text}: {val}") - - slider.add_event_cb(slider_changed, lv.EVENT.VALUE_CHANGED, None) - - return slider, label, cont - - def create_checkbox(self, parent, label_text, default_val, pref_key): - """Create checkbox with label.""" - cont = lv.obj(parent) - cont.set_size(lv.pct(100), 35) - cont.set_style_pad_all(3, 0) - - checkbox = lv.checkbox(cont) - checkbox.set_text(label_text) - if default_val: - checkbox.add_state(lv.STATE.CHECKED) - checkbox.align(lv.ALIGN.LEFT_MID, 0, 0) - - return checkbox, cont - - def create_dropdown(self, parent, label_text, options, default_idx, pref_key): - """Create dropdown with label.""" - cont = lv.obj(parent) - cont.set_size(lv.pct(100), 60) - cont.set_style_pad_all(3, 0) - - label = lv.label(cont) - label.set_text(label_text) - label.align(lv.ALIGN.TOP_LEFT, 0, 0) - - dropdown = lv.dropdown(cont) - dropdown.set_size(lv.pct(90), 30) - dropdown.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - - options_str = "\n".join([text for text, _ in options]) - dropdown.set_options(options_str) - dropdown.set_selected(default_idx) - - # Store metadata separately - option_values = [val for _, val in options] - self.control_metadata[id(dropdown)] = { - "pref_key": pref_key, - "type": "dropdown", - "option_values": option_values - } - - return dropdown, cont - - def create_textarea(self, parent, label_text, min_val, max_val, default_val, pref_key): - cont = lv.obj(parent) - cont.set_size(lv.pct(100), lv.SIZE_CONTENT) - cont.set_style_pad_all(3, 0) - - label = lv.label(cont) - label.set_text(f"{label_text}:") - label.align(lv.ALIGN.TOP_LEFT, 0, 0) - - textarea = lv.textarea(cont) - textarea.set_width(lv.pct(50)) - textarea.set_one_line(True) # might not be good for all settings but it's good for most - textarea.set_text(str(default_val)) - textarea.align(lv.ALIGN.TOP_RIGHT, 0, 0) - - # Initialize keyboard (hidden initially) - keyboard = MposKeyboard(parent) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - keyboard.add_flag(lv.obj.FLAG.HIDDEN) - keyboard.set_textarea(textarea) - keyboard.add_event_cb(lambda e, kbd=keyboard: self.hide_keyboard(kbd), lv.EVENT.READY, None) - keyboard.add_event_cb(lambda e, kbd=keyboard: self.hide_keyboard(kbd), lv.EVENT.CANCEL, None) - textarea.add_event_cb(lambda e, kbd=keyboard: self.show_keyboard(kbd), lv.EVENT.CLICKED, None) - - return textarea, cont - - def show_keyboard(self, kbd): - mpos.ui.anim.smooth_show(kbd) - - def hide_keyboard(self, kbd): - mpos.ui.anim.smooth_hide(kbd) - - def add_buttons(self, parent): - # Save/Cancel buttons at bottom - button_cont = lv.obj(parent) - button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(20)) - button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) - button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) - button_cont.set_style_border_width(0, 0) - - save_button = lv.button(button_cont) - save_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) - save_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) - save_label = lv.label(save_button) - save_label.set_text("Save") - save_label.center() - - cancel_button = lv.button(button_cont) - cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) - cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) - cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) - cancel_label = lv.label(cancel_button) - cancel_label.set_text("Cancel") - cancel_label.center() - - erase_button = lv.button(button_cont) - erase_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) - erase_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) - erase_button.add_event_cb(lambda e: self.erase_and_close(), lv.EVENT.CLICKED, None) - erase_label = lv.label(erase_button) - erase_label.set_text("Erase") - erase_label.center() - - - def create_basic_tab(self, tab, prefs): - """Create Basic settings tab.""" - tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) - #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) - tab.set_style_pad_all(1, 0) - - # Color Mode - colormode = prefs.get_bool("colormode", False) - checkbox, cont = self.create_checkbox(tab, "Color Mode (slower)", colormode, "colormode") - self.ui_controls["colormode"] = checkbox - - # Resolution dropdown - current_resolution = prefs.get_string("resolution", "320x240") - resolution_idx = 0 - for idx, (_, value) in enumerate(self.resolutions): - if value == current_resolution: - resolution_idx = idx - break - - dropdown, cont = self.create_dropdown(tab, "Resolution:", self.resolutions, resolution_idx, "resolution") - self.ui_controls["resolution"] = dropdown - - # Brightness - brightness = prefs.get_int("brightness", 0) - slider, label, cont = self.create_slider(tab, "Brightness", -2, 2, brightness, "brightness") - self.ui_controls["brightness"] = slider - - # Contrast - contrast = prefs.get_int("contrast", 0) - slider, label, cont = self.create_slider(tab, "Contrast", -2, 2, contrast, "contrast") - self.ui_controls["contrast"] = slider - - # Saturation - saturation = prefs.get_int("saturation", 0) - slider, label, cont = self.create_slider(tab, "Saturation", -2, 2, saturation, "saturation") - self.ui_controls["saturation"] = slider - - # Horizontal Mirror - hmirror = prefs.get_bool("hmirror", False) - checkbox, cont = self.create_checkbox(tab, "Horizontal Mirror", hmirror, "hmirror") - self.ui_controls["hmirror"] = checkbox - - # Vertical Flip - vflip = prefs.get_bool("vflip", True) - checkbox, cont = self.create_checkbox(tab, "Vertical Flip", vflip, "vflip") - self.ui_controls["vflip"] = checkbox - - self.add_buttons(tab) - - def create_advanced_tab(self, tab, prefs): - """Create Advanced settings tab.""" - tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) - tab.set_style_pad_all(1, 0) - - # Auto Exposure Control (master switch) - exposure_ctrl = prefs.get_bool("exposure_ctrl", True) - aec_checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") - self.ui_controls["exposure_ctrl"] = aec_checkbox - - # Manual Exposure Value (dependent) - aec_value = prefs.get_int("aec_value", 300) - me_slider, label, me_cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") - self.ui_controls["aec_value"] = me_slider - - # Auto Exposure Level (dependent) - ae_level = prefs.get_int("ae_level", 0) - ae_slider, label, ae_cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") - self.ui_controls["ae_level"] = ae_slider - - # Add dependency handler - def exposure_ctrl_changed(e=None): - is_auto = aec_checkbox.get_state() & lv.STATE.CHECKED - if is_auto: - mpos.ui.anim.smooth_hide(me_cont, duration=1000) - mpos.ui.anim.smooth_show(ae_cont, delay=1000) - else: - mpos.ui.anim.smooth_hide(ae_cont, duration=1000) - mpos.ui.anim.smooth_show(me_cont, delay=1000) - - aec_checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) - exposure_ctrl_changed() - - # Night Mode (AEC2) - aec2 = prefs.get_bool("aec2", False) - checkbox, cont = self.create_checkbox(tab, "Night Mode (AEC2)", aec2, "aec2") - self.ui_controls["aec2"] = checkbox - - # Auto Gain Control (master switch) - gain_ctrl = prefs.get_bool("gain_ctrl", True) - agc_checkbox, cont = self.create_checkbox(tab, "Auto Gain", gain_ctrl, "gain_ctrl") - self.ui_controls["gain_ctrl"] = agc_checkbox - - # Manual Gain Value (dependent) - agc_gain = prefs.get_int("agc_gain", 0) - slider, label, agc_cont = self.create_slider(tab, "Manual Gain", 0, 30, agc_gain, "agc_gain") - self.ui_controls["agc_gain"] = slider - - def gain_ctrl_changed(e=None): - is_auto = agc_checkbox.get_state() & lv.STATE.CHECKED - gain_slider = self.ui_controls["agc_gain"] - if is_auto: - mpos.ui.anim.smooth_hide(agc_cont, duration=1000) - else: - mpos.ui.anim.smooth_show(agc_cont, duration=1000) - - agc_checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) - gain_ctrl_changed() - - # Gain Ceiling - gainceiling_options = [ - ("2X", 0), ("4X", 1), ("8X", 2), ("16X", 3), - ("32X", 4), ("64X", 5), ("128X", 6) - ] - gainceiling = prefs.get_int("gainceiling", 0) - dropdown, cont = self.create_dropdown(tab, "Gain Ceiling:", gainceiling_options, gainceiling, "gainceiling") - self.ui_controls["gainceiling"] = dropdown - - # Auto White Balance (master switch) - whitebal = prefs.get_bool("whitebal", True) - wbcheckbox, cont = self.create_checkbox(tab, "Auto White Balance", whitebal, "whitebal") - self.ui_controls["whitebal"] = wbcheckbox - - # White Balance Mode (dependent) - wb_mode_options = [ - ("Auto", 0), ("Sunny", 1), ("Cloudy", 2), ("Office", 3), ("Home", 4) - ] - wb_mode = prefs.get_int("wb_mode", 0) - wb_dropdown, wb_cont = self.create_dropdown(tab, "WB Mode:", wb_mode_options, wb_mode, "wb_mode") - self.ui_controls["wb_mode"] = wb_dropdown - - def whitebal_changed(e=None): - is_auto = wbcheckbox.get_state() & lv.STATE.CHECKED - if is_auto: - mpos.ui.anim.smooth_hide(wb_cont, duration=1000) - else: - mpos.ui.anim.smooth_show(wb_cont, duration=1000) - wbcheckbox.add_event_cb(whitebal_changed, lv.EVENT.VALUE_CHANGED, None) - whitebal_changed() - - # AWB Gain - awb_gain = prefs.get_bool("awb_gain", True) - checkbox, cont = self.create_checkbox(tab, "AWB Gain", awb_gain, "awb_gain") - self.ui_controls["awb_gain"] = checkbox - - self.add_buttons(tab) - - # Special Effect - special_effect_options = [ - ("None", 0), ("Negative", 1), ("Grayscale", 2), - ("Reddish", 3), ("Greenish", 4), ("Blue", 5), ("Retro", 6) - ] - special_effect = prefs.get_int("special_effect", 0) - dropdown, cont = self.create_dropdown(tab, "Special Effect:", special_effect_options, - special_effect, "special_effect") - self.ui_controls["special_effect"] = dropdown - - def create_expert_tab(self, tab, prefs): - """Create Expert settings tab.""" - #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) - tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) - tab.set_style_pad_all(1, 0) - - # Sharpness - sharpness = prefs.get_int("sharpness", 0) - slider, label, cont = self.create_slider(tab, "Sharpness", -3, 3, sharpness, "sharpness") - self.ui_controls["sharpness"] = slider - - # Denoise - denoise = prefs.get_int("denoise", 0) - slider, label, cont = self.create_slider(tab, "Denoise", 0, 8, denoise, "denoise") - self.ui_controls["denoise"] = slider - - # JPEG Quality - # Disabled because JPEG is not used right now - #quality = prefs.get_int("quality", 85) - #slider, label, cont = self.create_slider(tab, "JPEG Quality", 0, 100, quality, "quality") - #self.ui_controls["quality"] = slider - - # Color Bar - colorbar = prefs.get_bool("colorbar", False) - checkbox, cont = self.create_checkbox(tab, "Color Bar Test", colorbar, "colorbar") - self.ui_controls["colorbar"] = checkbox - - # DCW Mode - dcw = prefs.get_bool("dcw", True) - checkbox, cont = self.create_checkbox(tab, "Downsize Crop Window", dcw, "dcw") - self.ui_controls["dcw"] = checkbox - - # Black Point Compensation - bpc = prefs.get_bool("bpc", False) - checkbox, cont = self.create_checkbox(tab, "Black Point Compensation", bpc, "bpc") - self.ui_controls["bpc"] = checkbox - - # White Point Compensation - wpc = prefs.get_bool("wpc", True) - checkbox, cont = self.create_checkbox(tab, "White Point Compensation", wpc, "wpc") - self.ui_controls["wpc"] = checkbox - - # Raw Gamma Mode - raw_gma = prefs.get_bool("raw_gma", True) - checkbox, cont = self.create_checkbox(tab, "Raw Gamma Mode", raw_gma, "raw_gma") - self.ui_controls["raw_gma"] = checkbox - - # Lens Correction - lenc = prefs.get_bool("lenc", True) - checkbox, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") - self.ui_controls["lenc"] = checkbox - - self.add_buttons(tab) - - def create_raw_tab(self, tab, prefs): - tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) - tab.set_style_pad_all(0, 0) - - # This would be nice but does not provide adequate resolution: - #startX, label, cont = self.create_slider(tab, "startX", 0, 2844, startX, "startX") - - startX = prefs.get_int("startX", self.startX_default) - textarea, cont = self.create_textarea(tab, "startX", 0, 2844, startX, "startX") - self.ui_controls["startX"] = textarea - - startY = prefs.get_int("startY", self.startY_default) - textarea, cont = self.create_textarea(tab, "startY", 0, 2844, startY, "startY") - self.ui_controls["startY"] = textarea - - endX = prefs.get_int("endX", self.endX_default) - textarea, cont = self.create_textarea(tab, "endX", 0, 2844, endX, "endX") - self.ui_controls["endX"] = textarea - - endY = prefs.get_int("endY", self.endY_default) - textarea, cont = self.create_textarea(tab, "endY", 0, 2844, endY, "endY") - self.ui_controls["endY"] = textarea - - offsetX = prefs.get_int("offsetX", self.offsetX_default) - textarea, cont = self.create_textarea(tab, "offsetX", 0, 2844, offsetX, "offsetX") - self.ui_controls["offsetX"] = textarea - - offsetY = prefs.get_int("offsetY", self.offsetY_default) - textarea, cont = self.create_textarea(tab, "offsetY", 0, 2844, offsetY, "offsetY") - self.ui_controls["offsetY"] = textarea - - totalX = prefs.get_int("totalX", self.totalX_default) - textarea, cont = self.create_textarea(tab, "totalX", 0, 2844, totalX, "totalX") - self.ui_controls["totalX"] = textarea - - totalY = prefs.get_int("totalY", self.totalY_default) - textarea, cont = self.create_textarea(tab, "totalY", 0, 2844, totalY, "totalY") - self.ui_controls["totalY"] = textarea - - outputX = prefs.get_int("outputX", self.outputX_default) - textarea, cont = self.create_textarea(tab, "outputX", 0, 2844, outputX, "outputX") - self.ui_controls["outputX"] = textarea - - outputY = prefs.get_int("outputY", self.outputY_default) - textarea, cont = self.create_textarea(tab, "outputY", 0, 2844, outputY, "outputY") - self.ui_controls["outputY"] = textarea - - scale = prefs.get_bool("scale", self.scale_default) - checkbox, cont = self.create_checkbox(tab, "Scale?", scale, "scale") - self.ui_controls["scale"] = checkbox - - binning = prefs.get_bool("binning", self.binning_default) - checkbox, cont = self.create_checkbox(tab, "Binning?", binning, "binning") - self.ui_controls["binning"] = checkbox - - self.add_buttons(tab) - - def erase_and_close(self): - SharedPreferences(CameraApp.APPNAME).edit().remove_all().commit() - self.setResult(True, {"settings_changed": True}) - self.finish() - - def save_and_close(self): - """Save all settings to SharedPreferences and return result.""" - prefs = SharedPreferences(CameraApp.APPNAME) - editor = prefs.edit() - - # Save all UI control values - for pref_key, control in self.ui_controls.items(): - print(f"saving {pref_key} with {control}") - control_id = id(control) - metadata = self.control_metadata.get(control_id, {}) - - if isinstance(control, lv.slider): - value = control.get_value() - editor.put_int(pref_key, value) - elif isinstance(control, lv.checkbox): - is_checked = control.get_state() & lv.STATE.CHECKED - editor.put_bool(pref_key, bool(is_checked)) - elif isinstance(control, lv.textarea): - try: - value = int(control.get_text()) - editor.put_int(pref_key, value) - except Exception as e: - print(f"Error while trying to save {pref_key}: {e}") - elif isinstance(control, lv.dropdown): - selected_idx = control.get_selected() - option_values = metadata.get("option_values", []) - if pref_key == "resolution": - # Resolution stored as string - value = option_values[selected_idx] - editor.put_string(pref_key, value) - else: - # Other dropdowns store integer enum values - value = option_values[selected_idx] - editor.put_int(pref_key, value) - - editor.commit() - print("Camera settings saved") - - # Return success result - self.setResult(True, {"settings_changed": True}) - self.finish() diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py new file mode 100644 index 0000000..c238007 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -0,0 +1,567 @@ +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard + +import mpos.ui +from mpos.apps import Activity +from mpos.config import SharedPreferences +from mpos.content.intent import Intent + +#from camera_app import CameraApp + +class CameraSettingsActivity(Activity): + """Settings activity for comprehensive camera configuration.""" + + PACKAGE = "com.micropythonos.camera" + + # Original: { 2560, 1920, 0, 0, 2623, 1951, 32, 16, 2844, 1968 } + # Worked for digital zoom in C: { 2560, 1920, 0, 0, 2623, 1951, 992, 736, 2844, 1968 } + startX_default=0 + startY_default=0 + endX_default=2623 + endY_default=1951 + offsetX_default=32 + offsetY_default=16 + totalX_default=2844 + totalY_default=1968 + outputX_default=640 + outputY_default=480 + scale_default=False + binning_default=False + + # Resolution options for desktop/webcam + WEBCAM_RESOLUTIONS = [ + ("160x120", "160x120"), + ("320x180", "320x180"), + ("320x240", "320x240"), + ("640x360", "640x360"), + ("640x480 (30 fps)", "640x480"), + ("1280x720 (10 fps)", "1280x720"), + ("1920x1080 (5 fps)", "1920x1080"), + ] + + # Resolution options for internal camera (ESP32) + ESP32_RESOLUTIONS = [ + ("96x96", "96x96"), + ("160x120", "160x120"), + ("128x128", "128x128"), + ("176x144", "176x144"), + ("240x176", "240x176"), + ("240x240", "240x240"), + ("320x240", "320x240"), + ("320x320", "320x320"), + ("400x296", "400x296"), + ("480x320", "480x320"), + ("480x480", "480x480"), + ("640x480", "640x480"), + ("640x640", "640x640"), + ("720x720", "720x720"), + ("800x600", "800x600"), + ("800x800", "800x800"), + ("960x960", "960x960"), + ("1024x768", "1024x768"), + ("1024x1024","1024x1024"), + ("1280x720", "1280x720"), # binned 2x2 (in default ov5640.c) + ("1280x1024", "1280x1024"), + ("1280x1280", "1280x1280"), + ("1600x1200", "1600x1200"), + ("1920x1080", "1920x1080"), + ] + + use_webcam = False + scanqr_mode = False + + # Widgets: + button_cont = None + + def __init__(self): + super().__init__() + self.ui_controls = {} + self.control_metadata = {} # Store pref_key and option_values for each control + self.dependent_controls = {} + self.is_webcam = False + self.resolutions = [] + + def onCreate(self): + self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") + self.use_webcam = self.getIntent().extras.get("use_webcam") + if self.use_webcam: + self.resolutions = self.WEBCAM_RESOLUTIONS + print("Using webcam resolutions") + else: + self.resolutions = self.ESP32_RESOLUTIONS + print("Using ESP32 camera resolutions") + + # Load preferences + prefs = SharedPreferences(self.PACKAGE) + + # Create main screen + screen = lv.obj() + screen.set_size(lv.pct(100), lv.pct(100)) + screen.set_style_pad_all(1, 0) + + # Create tabview + tabview = lv.tabview(screen) + tabview.set_tab_bar_size(mpos.ui.pct_of_display_height(15)) + #tabview.set_size(lv.pct(100), mpos.ui.pct_of_display_height(80)) + + # Create Basic tab (always) + basic_tab = tabview.add_tab("Basic") + self.create_basic_tab(basic_tab, prefs) + + # Create Advanced and Expert tabs only for ESP32 camera + if not self.use_webcam or True: # for now, show all tabs + advanced_tab = tabview.add_tab("Advanced") + self.create_advanced_tab(advanced_tab, prefs) + + expert_tab = tabview.add_tab("Expert") + self.create_expert_tab(expert_tab, prefs) + + #raw_tab = tabview.add_tab("Raw") + #self.create_raw_tab(raw_tab, prefs) + + self.setContentView(screen) + + def create_slider(self, parent, label_text, min_val, max_val, default_val, pref_key): + """Create slider with label showing current value.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(100), 60) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(f"{label_text}: {default_val}") + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + slider = lv.slider(cont) + slider.set_size(lv.pct(90), 15) + slider.set_range(min_val, max_val) + slider.set_value(default_val, False) + slider.align(lv.ALIGN.BOTTOM_MID, 0, -10) + + def slider_changed(e): + val = slider.get_value() + label.set_text(f"{label_text}: {val}") + + slider.add_event_cb(slider_changed, lv.EVENT.VALUE_CHANGED, None) + + return slider, label, cont + + def create_checkbox(self, parent, label_text, default_val, pref_key): + """Create checkbox with label.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(100), 35) + cont.set_style_pad_all(3, 0) + + checkbox = lv.checkbox(cont) + checkbox.set_text(label_text) + if default_val: + checkbox.add_state(lv.STATE.CHECKED) + checkbox.align(lv.ALIGN.LEFT_MID, 0, 0) + + return checkbox, cont + + def create_dropdown(self, parent, label_text, options, default_idx, pref_key): + """Create dropdown with label.""" + cont = lv.obj(parent) + cont.set_size(lv.pct(100), 60) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(label_text) + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + dropdown = lv.dropdown(cont) + dropdown.set_size(lv.pct(90), 30) + dropdown.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + + options_str = "\n".join([text for text, _ in options]) + dropdown.set_options(options_str) + dropdown.set_selected(default_idx) + + # Store metadata separately + option_values = [val for _, val in options] + self.control_metadata[id(dropdown)] = { + "pref_key": pref_key, + "type": "dropdown", + "option_values": option_values + } + + return dropdown, cont + + def create_textarea(self, parent, label_text, min_val, max_val, default_val, pref_key): + cont = lv.obj(parent) + cont.set_size(lv.pct(100), lv.SIZE_CONTENT) + cont.set_style_pad_all(3, 0) + + label = lv.label(cont) + label.set_text(f"{label_text}:") + label.align(lv.ALIGN.TOP_LEFT, 0, 0) + + textarea = lv.textarea(cont) + textarea.set_width(lv.pct(50)) + textarea.set_one_line(True) # might not be good for all settings but it's good for most + textarea.set_text(str(default_val)) + textarea.align(lv.ALIGN.TOP_RIGHT, 0, 0) + + # Initialize keyboard (hidden initially) + keyboard = MposKeyboard(parent) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + keyboard.set_textarea(textarea) + keyboard.add_event_cb(lambda e, kbd=keyboard: self.hide_keyboard(kbd), lv.EVENT.READY, None) + keyboard.add_event_cb(lambda e, kbd=keyboard: self.hide_keyboard(kbd), lv.EVENT.CANCEL, None) + textarea.add_event_cb(lambda e, kbd=keyboard: self.show_keyboard(kbd), lv.EVENT.CLICKED, None) + + return textarea, cont + + def show_keyboard(self, kbd): + mpos.ui.anim.smooth_show(kbd) + + def hide_keyboard(self, kbd): + mpos.ui.anim.smooth_hide(kbd) + + def add_buttons(self, parent): + # Save/Cancel buttons at bottom + button_cont = lv.obj(parent) + button_cont.set_size(lv.pct(100), mpos.ui.pct_of_display_height(20)) + button_cont.remove_flag(lv.obj.FLAG.SCROLLABLE) + button_cont.align(lv.ALIGN.BOTTOM_MID, 0, 0) + button_cont.set_style_border_width(0, 0) + + save_button = lv.button(button_cont) + save_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + save_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) + save_label = lv.label(save_button) + save_label.set_text("Save") + save_label.center() + + cancel_button = lv.button(button_cont) + cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) + cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + cancel_label = lv.label(cancel_button) + cancel_label.set_text("Cancel") + cancel_label.center() + + erase_button = lv.button(button_cont) + erase_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + erase_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + erase_button.add_event_cb(lambda e: self.erase_and_close(), lv.EVENT.CLICKED, None) + erase_label = lv.label(erase_button) + erase_label.set_text("Erase") + erase_label.center() + + + def create_basic_tab(self, tab, prefs): + """Create Basic settings tab.""" + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_style_pad_all(1, 0) + + # Color Mode + colormode = prefs.get_bool("colormode", False) + checkbox, cont = self.create_checkbox(tab, "Color Mode (slower)", colormode, "colormode") + self.ui_controls["colormode"] = checkbox + + # Resolution dropdown + current_resolution = prefs.get_string("resolution", "320x240") + resolution_idx = 0 + for idx, (_, value) in enumerate(self.resolutions): + if value == current_resolution: + resolution_idx = idx + break + + dropdown, cont = self.create_dropdown(tab, "Resolution:", self.resolutions, resolution_idx, "resolution") + self.ui_controls["resolution"] = dropdown + + # Brightness + brightness = prefs.get_int("brightness", 0) + slider, label, cont = self.create_slider(tab, "Brightness", -2, 2, brightness, "brightness") + self.ui_controls["brightness"] = slider + + # Contrast + contrast = prefs.get_int("contrast", 0) + slider, label, cont = self.create_slider(tab, "Contrast", -2, 2, contrast, "contrast") + self.ui_controls["contrast"] = slider + + # Saturation + saturation = prefs.get_int("saturation", 0) + slider, label, cont = self.create_slider(tab, "Saturation", -2, 2, saturation, "saturation") + self.ui_controls["saturation"] = slider + + # Horizontal Mirror + hmirror = prefs.get_bool("hmirror", False) + checkbox, cont = self.create_checkbox(tab, "Horizontal Mirror", hmirror, "hmirror") + self.ui_controls["hmirror"] = checkbox + + # Vertical Flip + vflip = prefs.get_bool("vflip", True) + checkbox, cont = self.create_checkbox(tab, "Vertical Flip", vflip, "vflip") + self.ui_controls["vflip"] = checkbox + + self.add_buttons(tab) + + def create_advanced_tab(self, tab, prefs): + """Create Advanced settings tab.""" + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(1, 0) + + # Auto Exposure Control (master switch) + exposure_ctrl = prefs.get_bool("exposure_ctrl", True) + aec_checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") + self.ui_controls["exposure_ctrl"] = aec_checkbox + + # Manual Exposure Value (dependent) + aec_value = prefs.get_int("aec_value", 300) + me_slider, label, me_cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") + self.ui_controls["aec_value"] = me_slider + + # Auto Exposure Level (dependent) + ae_level = prefs.get_int("ae_level", 0) + ae_slider, label, ae_cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") + self.ui_controls["ae_level"] = ae_slider + + # Add dependency handler + def exposure_ctrl_changed(e=None): + is_auto = aec_checkbox.get_state() & lv.STATE.CHECKED + if is_auto: + mpos.ui.anim.smooth_hide(me_cont, duration=1000) + mpos.ui.anim.smooth_show(ae_cont, delay=1000) + else: + mpos.ui.anim.smooth_hide(ae_cont, duration=1000) + mpos.ui.anim.smooth_show(me_cont, delay=1000) + + aec_checkbox.add_event_cb(exposure_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + exposure_ctrl_changed() + + # Night Mode (AEC2) + aec2 = prefs.get_bool("aec2", False) + checkbox, cont = self.create_checkbox(tab, "Night Mode (AEC2)", aec2, "aec2") + self.ui_controls["aec2"] = checkbox + + # Auto Gain Control (master switch) + gain_ctrl = prefs.get_bool("gain_ctrl", True) + agc_checkbox, cont = self.create_checkbox(tab, "Auto Gain", gain_ctrl, "gain_ctrl") + self.ui_controls["gain_ctrl"] = agc_checkbox + + # Manual Gain Value (dependent) + agc_gain = prefs.get_int("agc_gain", 0) + slider, label, agc_cont = self.create_slider(tab, "Manual Gain", 0, 30, agc_gain, "agc_gain") + self.ui_controls["agc_gain"] = slider + + def gain_ctrl_changed(e=None): + is_auto = agc_checkbox.get_state() & lv.STATE.CHECKED + gain_slider = self.ui_controls["agc_gain"] + if is_auto: + mpos.ui.anim.smooth_hide(agc_cont, duration=1000) + else: + mpos.ui.anim.smooth_show(agc_cont, duration=1000) + + agc_checkbox.add_event_cb(gain_ctrl_changed, lv.EVENT.VALUE_CHANGED, None) + gain_ctrl_changed() + + # Gain Ceiling + gainceiling_options = [ + ("2X", 0), ("4X", 1), ("8X", 2), ("16X", 3), + ("32X", 4), ("64X", 5), ("128X", 6) + ] + gainceiling = prefs.get_int("gainceiling", 0) + dropdown, cont = self.create_dropdown(tab, "Gain Ceiling:", gainceiling_options, gainceiling, "gainceiling") + self.ui_controls["gainceiling"] = dropdown + + # Auto White Balance (master switch) + whitebal = prefs.get_bool("whitebal", True) + wbcheckbox, cont = self.create_checkbox(tab, "Auto White Balance", whitebal, "whitebal") + self.ui_controls["whitebal"] = wbcheckbox + + # White Balance Mode (dependent) + wb_mode_options = [ + ("Auto", 0), ("Sunny", 1), ("Cloudy", 2), ("Office", 3), ("Home", 4) + ] + wb_mode = prefs.get_int("wb_mode", 0) + wb_dropdown, wb_cont = self.create_dropdown(tab, "WB Mode:", wb_mode_options, wb_mode, "wb_mode") + self.ui_controls["wb_mode"] = wb_dropdown + + def whitebal_changed(e=None): + is_auto = wbcheckbox.get_state() & lv.STATE.CHECKED + if is_auto: + mpos.ui.anim.smooth_hide(wb_cont, duration=1000) + else: + mpos.ui.anim.smooth_show(wb_cont, duration=1000) + wbcheckbox.add_event_cb(whitebal_changed, lv.EVENT.VALUE_CHANGED, None) + whitebal_changed() + + # AWB Gain + awb_gain = prefs.get_bool("awb_gain", True) + checkbox, cont = self.create_checkbox(tab, "AWB Gain", awb_gain, "awb_gain") + self.ui_controls["awb_gain"] = checkbox + + self.add_buttons(tab) + + # Special Effect + special_effect_options = [ + ("None", 0), ("Negative", 1), ("Grayscale", 2), + ("Reddish", 3), ("Greenish", 4), ("Blue", 5), ("Retro", 6) + ] + special_effect = prefs.get_int("special_effect", 0) + dropdown, cont = self.create_dropdown(tab, "Special Effect:", special_effect_options, + special_effect, "special_effect") + self.ui_controls["special_effect"] = dropdown + + def create_expert_tab(self, tab, prefs): + """Create Expert settings tab.""" + #tab.set_scrollbar_mode(lv.SCROLLBAR_MODE.AUTO) + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(1, 0) + + # Sharpness + sharpness = prefs.get_int("sharpness", 0) + slider, label, cont = self.create_slider(tab, "Sharpness", -3, 3, sharpness, "sharpness") + self.ui_controls["sharpness"] = slider + + # Denoise + denoise = prefs.get_int("denoise", 0) + slider, label, cont = self.create_slider(tab, "Denoise", 0, 8, denoise, "denoise") + self.ui_controls["denoise"] = slider + + # JPEG Quality + # Disabled because JPEG is not used right now + #quality = prefs.get_int("quality", 85) + #slider, label, cont = self.create_slider(tab, "JPEG Quality", 0, 100, quality, "quality") + #self.ui_controls["quality"] = slider + + # Color Bar + colorbar = prefs.get_bool("colorbar", False) + checkbox, cont = self.create_checkbox(tab, "Color Bar Test", colorbar, "colorbar") + self.ui_controls["colorbar"] = checkbox + + # DCW Mode + dcw = prefs.get_bool("dcw", True) + checkbox, cont = self.create_checkbox(tab, "Downsize Crop Window", dcw, "dcw") + self.ui_controls["dcw"] = checkbox + + # Black Point Compensation + bpc = prefs.get_bool("bpc", False) + checkbox, cont = self.create_checkbox(tab, "Black Point Compensation", bpc, "bpc") + self.ui_controls["bpc"] = checkbox + + # White Point Compensation + wpc = prefs.get_bool("wpc", True) + checkbox, cont = self.create_checkbox(tab, "White Point Compensation", wpc, "wpc") + self.ui_controls["wpc"] = checkbox + + # Raw Gamma Mode + raw_gma = prefs.get_bool("raw_gma", True) + checkbox, cont = self.create_checkbox(tab, "Raw Gamma Mode", raw_gma, "raw_gma") + self.ui_controls["raw_gma"] = checkbox + + # Lens Correction + lenc = prefs.get_bool("lenc", True) + checkbox, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") + self.ui_controls["lenc"] = checkbox + + self.add_buttons(tab) + + def create_raw_tab(self, tab, prefs): + tab.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tab.set_style_pad_all(0, 0) + + # This would be nice but does not provide adequate resolution: + #startX, label, cont = self.create_slider(tab, "startX", 0, 2844, startX, "startX") + + startX = prefs.get_int("startX", self.startX_default) + textarea, cont = self.create_textarea(tab, "startX", 0, 2844, startX, "startX") + self.ui_controls["startX"] = textarea + + startY = prefs.get_int("startY", self.startY_default) + textarea, cont = self.create_textarea(tab, "startY", 0, 2844, startY, "startY") + self.ui_controls["startY"] = textarea + + endX = prefs.get_int("endX", self.endX_default) + textarea, cont = self.create_textarea(tab, "endX", 0, 2844, endX, "endX") + self.ui_controls["endX"] = textarea + + endY = prefs.get_int("endY", self.endY_default) + textarea, cont = self.create_textarea(tab, "endY", 0, 2844, endY, "endY") + self.ui_controls["endY"] = textarea + + offsetX = prefs.get_int("offsetX", self.offsetX_default) + textarea, cont = self.create_textarea(tab, "offsetX", 0, 2844, offsetX, "offsetX") + self.ui_controls["offsetX"] = textarea + + offsetY = prefs.get_int("offsetY", self.offsetY_default) + textarea, cont = self.create_textarea(tab, "offsetY", 0, 2844, offsetY, "offsetY") + self.ui_controls["offsetY"] = textarea + + totalX = prefs.get_int("totalX", self.totalX_default) + textarea, cont = self.create_textarea(tab, "totalX", 0, 2844, totalX, "totalX") + self.ui_controls["totalX"] = textarea + + totalY = prefs.get_int("totalY", self.totalY_default) + textarea, cont = self.create_textarea(tab, "totalY", 0, 2844, totalY, "totalY") + self.ui_controls["totalY"] = textarea + + outputX = prefs.get_int("outputX", self.outputX_default) + textarea, cont = self.create_textarea(tab, "outputX", 0, 2844, outputX, "outputX") + self.ui_controls["outputX"] = textarea + + outputY = prefs.get_int("outputY", self.outputY_default) + textarea, cont = self.create_textarea(tab, "outputY", 0, 2844, outputY, "outputY") + self.ui_controls["outputY"] = textarea + + scale = prefs.get_bool("scale", self.scale_default) + checkbox, cont = self.create_checkbox(tab, "Scale?", scale, "scale") + self.ui_controls["scale"] = checkbox + + binning = prefs.get_bool("binning", self.binning_default) + checkbox, cont = self.create_checkbox(tab, "Binning?", binning, "binning") + self.ui_controls["binning"] = checkbox + + self.add_buttons(tab) + + def erase_and_close(self): + SharedPreferences(self.PACKAGE).edit().remove_all().commit() + self.setResult(True, {"settings_changed": True}) + self.finish() + + def save_and_close(self): + """Save all settings to SharedPreferences and return result.""" + prefs = SharedPreferences(self.PACKAGE) + editor = prefs.edit() + + # Save all UI control values + for pref_key, control in self.ui_controls.items(): + print(f"saving {pref_key} with {control}") + control_id = id(control) + metadata = self.control_metadata.get(control_id, {}) + + if isinstance(control, lv.slider): + value = control.get_value() + editor.put_int(pref_key, value) + elif isinstance(control, lv.checkbox): + is_checked = control.get_state() & lv.STATE.CHECKED + editor.put_bool(pref_key, bool(is_checked)) + elif isinstance(control, lv.textarea): + try: + value = int(control.get_text()) + editor.put_int(pref_key, value) + except Exception as e: + print(f"Error while trying to save {pref_key}: {e}") + elif isinstance(control, lv.dropdown): + selected_idx = control.get_selected() + option_values = metadata.get("option_values", []) + if pref_key == "resolution": + # Resolution stored as string + value = option_values[selected_idx] + editor.put_string(pref_key, value) + else: + # Other dropdowns store integer enum values + value = option_values[selected_idx] + editor.put_int(pref_key, value) + + editor.commit() + print("Camera settings saved") + + # Return success result + self.setResult(True, {"settings_changed": True}) + self.finish() From d4239b660881d9ed237f34445fd5a90eb07970d4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 08:51:07 +0100 Subject: [PATCH 054/192] Camera app: improve settings handling --- .../assets/camera_app.py | 104 +++++++++--------- .../assets/camera_settings.py | 17 ++- 2 files changed, 58 insertions(+), 63 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 2932ae3..1d38836 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -7,7 +7,6 @@ import mpos.time from mpos.apps import Activity -from mpos.config import SharedPreferences from mpos.content.intent import Intent from camera_settings import CameraSettingsActivity @@ -16,7 +15,7 @@ class CameraApp(Activity): DEFAULT_WIDTH = 320 # 240 would be better but webcam doesn't support this (yet) DEFAULT_HEIGHT = 240 - APPNAME = "com.micropythonos.camera" + PACKAGE = "com.micropythonos.camera" #DEFAULT_CONFIG = "config.json" #QRCODE_CONFIG = "config_qrmode.json" @@ -50,7 +49,10 @@ class CameraApp(Activity): status_label_cont = None def onCreate(self): + from mpos.config import SharedPreferences + self.prefs = SharedPreferences(self.PACKAGE) self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") + self.main_screen = lv.obj() self.main_screen.set_style_pad_all(1, 0) self.main_screen.set_style_border_width(0, 0) @@ -115,7 +117,8 @@ def onCreate(self): self.setContentView(self.main_screen) def onResume(self, screen): - self.load_resolution_preference() # needs to be done BEFORE the camera is initialized + self.parse_camera_init_preferences() + # Init camera: self.cam = init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees @@ -130,6 +133,7 @@ def onResume(self, screen): self.use_webcam = True except Exception as e: print(f"camera app: webcam exception: {e}") + # Start refreshing: if self.cam: print("Camera app initialized, continuing...") self.update_preview_image() @@ -169,11 +173,9 @@ def onPause(self, screen): print(f"Warning: powering off camera got exception: {e}") print("camera app cleanup done.") - def load_resolution_preference(self): - """Load resolution preference from SharedPreferences and update width/height.""" - prefs = SharedPreferences(CameraApp.APPNAME) - resolution_str = prefs.get_string("resolution", f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}") - self.colormode = prefs.get_bool("colormode", False) + def parse_camera_init_preferences(self): + resolution_str = self.prefs.get_string("resolution", f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}") + self.colormode = self.prefs.get_bool("colormode", False) try: width_str, height_str = resolution_str.split('x') self.width = int(width_str) @@ -287,26 +289,25 @@ def zoom_button_click(self, e): print("zoom_button_click is not supported for webcam") return if self.cam: - prefs = SharedPreferences(CameraApp.APPNAME) - startX = prefs.get_int("startX", CameraSettingsActivity.startX_default) - startY = prefs.get_int("startX", CameraSettingsActivity.startY_default) - endX = prefs.get_int("startX", CameraSettingsActivity.endX_default) - endY = prefs.get_int("startX", CameraSettingsActivity.endY_default) - offsetX = prefs.get_int("startX", CameraSettingsActivity.offsetX_default) - offsetY = prefs.get_int("startX", CameraSettingsActivity.offsetY_default) - totalX = prefs.get_int("startX", CameraSettingsActivity.totalX_default) - totalY = prefs.get_int("startX", CameraSettingsActivity.totalY_default) - outputX = prefs.get_int("startX", CameraSettingsActivity.outputX_default) - outputY = prefs.get_int("startX", CameraSettingsActivity.outputY_default) - scale = prefs.get_bool("scale", CameraSettingsActivity.scale_default) - binning = prefs.get_bool("binning", CameraSettingsActivity.binning_default) + startX = self.prefs.get_int("startX", CameraSettingsActivity.startX_default) + startY = self.prefs.get_int("startX", CameraSettingsActivity.startY_default) + endX = self.prefs.get_int("startX", CameraSettingsActivity.endX_default) + endY = self.prefs.get_int("startX", CameraSettingsActivity.endY_default) + offsetX = self.prefs.get_int("startX", CameraSettingsActivity.offsetX_default) + offsetY = self.prefs.get_int("startX", CameraSettingsActivity.offsetY_default) + totalX = self.prefs.get_int("startX", CameraSettingsActivity.totalX_default) + totalY = self.prefs.get_int("startX", CameraSettingsActivity.totalY_default) + outputX = self.prefs.get_int("startX", CameraSettingsActivity.outputX_default) + outputY = self.prefs.get_int("startX", CameraSettingsActivity.outputY_default) + scale = self.prefs.get_bool("scale", CameraSettingsActivity.scale_default) + binning = self.prefs.get_bool("binning", CameraSettingsActivity.binning_default) result = self.cam.set_res_raw(startX,startY,endX,endY,offsetX,offsetY,totalX,totalY,outputX,outputY,scale,binning) print(f"self.cam.set_res_raw returned {result}") def open_settings(self): self.image_dsc.data = None self.current_cam_buffer = None - intent = Intent(activity_class=CameraSettingsActivity, extras={"use_webcam": self.use_webcam, "scanqr_mode": self.scanqr_mode}) + intent = Intent(activity_class=CameraSettingsActivity, extras={"prefs": self.prefs, "use_webcam": self.use_webcam, "scanqr_mode": self.scanqr_mode}) self.startActivity(intent) def try_capture(self, event): @@ -315,9 +316,7 @@ def try_capture(self, event): if self.use_webcam: self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") elif self.cam.frame_available(): - #self.cam.free_buffer() self.current_cam_buffer = self.cam.capture() - #self.cam.free_buffer() if self.current_cam_buffer and len(self.current_cam_buffer): # Defensive check: verify buffer size matches expected dimensions @@ -329,7 +328,7 @@ def try_capture(self, event): #self.image.invalidate() # does not work so do this: self.image.set_src(self.image_dsc) if not self.use_webcam: - self.cam.free_buffer() # Free the old buffer + self.cam.free_buffer() # Free the old buffer, otherwise the camera doesn't provide a new one try: if self.keepliveqrdecoding: self.qrdecode_one() @@ -438,7 +437,7 @@ def remove_bom(buffer): def apply_camera_settings(cam, use_webcam): - """Apply all saved camera settings from SharedPreferences to ESP32 camera. + """Apply all saved camera settings to the camera. Only applies settings when use_webcam is False (ESP32 camera). Settings are applied in dependency order (master switches before dependent values). @@ -451,101 +450,99 @@ def apply_camera_settings(cam, use_webcam): print("apply_camera_settings: Skipping (no camera or webcam mode)") return - prefs = SharedPreferences(CameraApp.APPNAME) - try: # Basic image adjustments - brightness = prefs.get_int("brightness", 0) + brightness = self.prefs.get_int("brightness", 0) cam.set_brightness(brightness) - contrast = prefs.get_int("contrast", 0) + contrast = self.prefs.get_int("contrast", 0) cam.set_contrast(contrast) - saturation = prefs.get_int("saturation", 0) + saturation = self.prefs.get_int("saturation", 0) cam.set_saturation(saturation) # Orientation - hmirror = prefs.get_bool("hmirror", False) + hmirror = self.prefs.get_bool("hmirror", False) cam.set_hmirror(hmirror) - vflip = prefs.get_bool("vflip", True) + vflip = self.prefs.get_bool("vflip", True) cam.set_vflip(vflip) # Special effect - special_effect = prefs.get_int("special_effect", 0) + special_effect = self.prefs.get_int("special_effect", 0) cam.set_special_effect(special_effect) # Exposure control (apply master switch first, then manual value) - exposure_ctrl = prefs.get_bool("exposure_ctrl", True) + exposure_ctrl = self.prefs.get_bool("exposure_ctrl", True) cam.set_exposure_ctrl(exposure_ctrl) if not exposure_ctrl: - aec_value = prefs.get_int("aec_value", 300) + aec_value = self.prefs.get_int("aec_value", 300) cam.set_aec_value(aec_value) - ae_level = prefs.get_int("ae_level", 0) + ae_level = self.prefs.get_int("ae_level", 0) cam.set_ae_level(ae_level) - aec2 = prefs.get_bool("aec2", False) + aec2 = self.prefs.get_bool("aec2", False) cam.set_aec2(aec2) # Gain control (apply master switch first, then manual value) - gain_ctrl = prefs.get_bool("gain_ctrl", True) + gain_ctrl = self.prefs.get_bool("gain_ctrl", True) cam.set_gain_ctrl(gain_ctrl) if not gain_ctrl: - agc_gain = prefs.get_int("agc_gain", 0) + agc_gain = self.prefs.get_int("agc_gain", 0) cam.set_agc_gain(agc_gain) - gainceiling = prefs.get_int("gainceiling", 0) + gainceiling = self.prefs.get_int("gainceiling", 0) cam.set_gainceiling(gainceiling) # White balance (apply master switch first, then mode) - whitebal = prefs.get_bool("whitebal", True) + whitebal = self.prefs.get_bool("whitebal", True) cam.set_whitebal(whitebal) if not whitebal: - wb_mode = prefs.get_int("wb_mode", 0) + wb_mode = self.prefs.get_int("wb_mode", 0) cam.set_wb_mode(wb_mode) - awb_gain = prefs.get_bool("awb_gain", True) + awb_gain = self.prefs.get_bool("awb_gain", True) cam.set_awb_gain(awb_gain) # Sensor-specific settings (try/except for unsupported sensors) try: - sharpness = prefs.get_int("sharpness", 0) + sharpness = self.prefs.get_int("sharpness", 0) cam.set_sharpness(sharpness) except: pass # Not supported on OV2640 try: - denoise = prefs.get_int("denoise", 0) + denoise = self.prefs.get_int("denoise", 0) cam.set_denoise(denoise) except: pass # Not supported on OV2640 # Advanced corrections - colorbar = prefs.get_bool("colorbar", False) + colorbar = self.prefs.get_bool("colorbar", False) cam.set_colorbar(colorbar) - dcw = prefs.get_bool("dcw", True) + dcw = self.prefs.get_bool("dcw", True) cam.set_dcw(dcw) - bpc = prefs.get_bool("bpc", False) + bpc = self.prefs.get_bool("bpc", False) cam.set_bpc(bpc) - wpc = prefs.get_bool("wpc", True) + wpc = self.prefs.get_bool("wpc", True) cam.set_wpc(wpc) - raw_gma = prefs.get_bool("raw_gma", True) + raw_gma = self.prefs.get_bool("raw_gma", True) cam.set_raw_gma(raw_gma) - lenc = prefs.get_bool("lenc", True) + lenc = self.prefs.get_bool("lenc", True) cam.set_lenc(lenc) # JPEG quality (only relevant for JPEG format) try: - quality = prefs.get_int("quality", 85) + quality = self.prefs.get_int("quality", 85) cam.set_quality(quality) except: pass # Not in JPEG mode @@ -554,4 +551,3 @@ def apply_camera_settings(cam, use_webcam): except Exception as e: print(f"Error applying camera settings: {e}") - diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index c238007..c94e1a3 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -67,8 +67,10 @@ class CameraSettingsActivity(Activity): ("1920x1080", "1920x1080"), ] + # These are taken from the Intent: use_webcam = False scanqr_mode = False + prefs = None # Widgets: button_cont = None @@ -84,6 +86,7 @@ def __init__(self): def onCreate(self): self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") self.use_webcam = self.getIntent().extras.get("use_webcam") + self.prefs = self.getIntent().extras.get("prefs") if self.use_webcam: self.resolutions = self.WEBCAM_RESOLUTIONS print("Using webcam resolutions") @@ -91,9 +94,6 @@ def onCreate(self): self.resolutions = self.ESP32_RESOLUTIONS print("Using ESP32 camera resolutions") - # Load preferences - prefs = SharedPreferences(self.PACKAGE) - # Create main screen screen = lv.obj() screen.set_size(lv.pct(100), lv.pct(100)) @@ -106,18 +106,18 @@ def onCreate(self): # Create Basic tab (always) basic_tab = tabview.add_tab("Basic") - self.create_basic_tab(basic_tab, prefs) + self.create_basic_tab(basic_tab, self.prefs) # Create Advanced and Expert tabs only for ESP32 camera if not self.use_webcam or True: # for now, show all tabs advanced_tab = tabview.add_tab("Advanced") - self.create_advanced_tab(advanced_tab, prefs) + self.create_advanced_tab(advanced_tab, self.prefs) expert_tab = tabview.add_tab("Expert") - self.create_expert_tab(expert_tab, prefs) + self.create_expert_tab(expert_tab, self.prefs) #raw_tab = tabview.add_tab("Raw") - #self.create_raw_tab(raw_tab, prefs) + #self.create_raw_tab(raw_tab, self.prefs) self.setContentView(screen) @@ -526,8 +526,7 @@ def erase_and_close(self): def save_and_close(self): """Save all settings to SharedPreferences and return result.""" - prefs = SharedPreferences(self.PACKAGE) - editor = prefs.edit() + editor = self.prefs.edit() # Save all UI control values for pref_key, control in self.ui_controls.items(): From 4ef4f6682435b6391a38d7381c0d3ee591c3ee47 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 08:58:27 +0100 Subject: [PATCH 055/192] Camera app: simplify --- .../assets/camera_app.py | 63 +++++++++---------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 1d38836..1390a8a 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -28,7 +28,6 @@ class CameraApp(Activity): status_label_text_found = "Found QR, trying to decode... hold still..." cam = None - current_cam_buffer = None # Holds the current memoryview to prevent garbage collection width = None height = None @@ -171,6 +170,7 @@ def onPause(self, screen): i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) except Exception as e: print(f"Warning: powering off camera got exception: {e}") + self.image_dsc.data = None print("camera app cleanup done.") def parse_camera_init_preferences(self): @@ -212,11 +212,14 @@ def update_preview_image(self): self.image.set_scale(min(scale_factor_w,scale_factor_h)) def qrdecode_one(self): + if self.image_dsc.data is None: + print("qrdecode_one: can't decode empty image") + return try: import qrdecode import utime before = time.ticks_ms() - result = qrdecode.qrdecode(self.current_cam_buffer, self.width, self.height) + result = qrdecode.qrdecode(self.image_dsc.data, self.width, self.height) after = time.ticks_ms() #result = bytearray("INSERT_QR_HERE", "utf-8") if not result: @@ -252,15 +255,17 @@ def snap_button_click(self, e): os.mkdir("data/images") except OSError: pass - if self.current_cam_buffer is not None: - colorname = "RGB565" if self.colormode else "GRAY" - filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" - try: - with open(filename, 'wb') as f: - f.write(self.current_cam_buffer) - print(f"Successfully wrote current_cam_buffer to {filename}") - except OSError as e: - print(f"Error writing to file: {e}") + if self.image_dsc.data is None: + print("snap_button_click: won't save empty image") + return + colorname = "RGB565" if self.colormode else "GRAY" + filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" + try: + with open(filename, 'wb') as f: + f.write(self.image_dsc.data) + print(f"Successfully wrote image to {filename}") + except OSError as e: + print(f"Error writing to file: {e}") def start_qr_decoding(self): print("Activating live QR decoding...") @@ -305,8 +310,6 @@ def zoom_button_click(self, e): print(f"self.cam.set_res_raw returned {result}") def open_settings(self): - self.image_dsc.data = None - self.current_cam_buffer = None intent = Intent(activity_class=CameraSettingsActivity, extras={"prefs": self.prefs, "use_webcam": self.use_webcam, "scanqr_mode": self.scanqr_mode}) self.startActivity(intent) @@ -314,31 +317,21 @@ def try_capture(self, event): #print("capturing camera frame") try: if self.use_webcam: - self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") + self.image_dsc.data = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") elif self.cam.frame_available(): - self.current_cam_buffer = self.cam.capture() - - if self.current_cam_buffer and len(self.current_cam_buffer): - # Defensive check: verify buffer size matches expected dimensions - expected_size = self.width * self.height * (2 if self.colormode else 1) - actual_size = len(self.current_cam_buffer) - - if actual_size == expected_size: - self.image_dsc.data = self.current_cam_buffer - #self.image.invalidate() # does not work so do this: - self.image.set_src(self.image_dsc) - if not self.use_webcam: - self.cam.free_buffer() # Free the old buffer, otherwise the camera doesn't provide a new one - try: - if self.keepliveqrdecoding: - self.qrdecode_one() - except Exception as qre: - print(f"try_capture: qrdecode_one got exception: {qre}") - else: - print(f"Warning: Buffer size mismatch! Expected {expected_size} bytes, got {actual_size} bytes") - print(f" Resolution: {self.width}x{self.height}, discarding frame") + self.image_dsc.data = self.cam.capture() except Exception as e: print(f"Camera capture exception: {e}") + # Display the image: + #self.image.invalidate() # does not work so do this: + self.image.set_src(self.image_dsc) + if not self.use_webcam: + self.cam.free_buffer() # Free the old buffer, otherwise the camera doesn't provide a new one + try: + if self.keepliveqrdecoding: + self.qrdecode_one() + except Exception as qre: + print(f"try_capture: qrdecode_one got exception: {qre}") # Non-class functions: From 3bc51514070ea2635e6a184bb56b50806ad8a730 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 09:19:48 +0100 Subject: [PATCH 056/192] Fix colormode QR decoding But somehow buffer size is 8 bytes... --- c_mpos/src/quirc_decode.c | 3 ++- .../com.micropythonos.camera/assets/camera_app.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/c_mpos/src/quirc_decode.c b/c_mpos/src/quirc_decode.c index 3607ea9..dfb72e6 100644 --- a/c_mpos/src/quirc_decode.c +++ b/c_mpos/src/quirc_decode.c @@ -39,10 +39,10 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { if (width <= 0 || height <= 0) { mp_raise_ValueError(MP_ERROR_TEXT("width and height must be positive")); } + QRDECODE_DEBUG_PRINT("qrdecode bufsize: %u bytes\n", bufinfo.len); if (bufinfo.len != (size_t)(width * height)) { mp_raise_ValueError(MP_ERROR_TEXT("buffer size must match width * height")); } - struct quirc *qr = quirc_new(); if (!qr) { mp_raise_OSError(MP_ENOMEM); @@ -139,6 +139,7 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { if (width <= 0 || height <= 0) { mp_raise_ValueError(MP_ERROR_TEXT("width and height must be positive")); } + QRDECODE_DEBUG_PRINT("qrdecode bufsize: %u bytes\n", bufinfo.len); if (bufinfo.len != (size_t)(width * height * 2)) { mp_raise_ValueError(MP_ERROR_TEXT("buffer size must match width * height * 2 for RGB565")); } diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 1390a8a..cecf8e8 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -1,4 +1,5 @@ import lvgl as lv +import time try: import webcam @@ -16,8 +17,8 @@ class CameraApp(Activity): DEFAULT_WIDTH = 320 # 240 would be better but webcam doesn't support this (yet) DEFAULT_HEIGHT = 240 PACKAGE = "com.micropythonos.camera" - #DEFAULT_CONFIG = "config.json" - #QRCODE_CONFIG = "config_qrmode.json" + CONFIGFILE = "config.json" + SCANQR_CONFIG = "config_scanqr_mode.json" button_width = 60 button_height = 45 @@ -48,9 +49,9 @@ class CameraApp(Activity): status_label_cont = None def onCreate(self): - from mpos.config import SharedPreferences - self.prefs = SharedPreferences(self.PACKAGE) self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") + from mpos.config import SharedPreferences + self.prefs = SharedPreferences(self.PACKAGE, filename=self.SCANQR_CONFIG if self.scanqr_mode else self.CONFIGFILE) self.main_screen = lv.obj() self.main_screen.set_style_pad_all(1, 0) @@ -219,7 +220,10 @@ def qrdecode_one(self): import qrdecode import utime before = time.ticks_ms() - result = qrdecode.qrdecode(self.image_dsc.data, self.width, self.height) + if self.colormode: + result = qrdecode.qrdecode_rgb565(self.image_dsc.data, self.width, self.height) + else: + result = qrdecode.qrdecode(self.image_dsc.data, self.width, self.height) after = time.ticks_ms() #result = bytearray("INSERT_QR_HERE", "utf-8") if not result: From 9e598d71f31ad6c54fa58bd9ecb0536b05dd8d8d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 11:17:32 +0100 Subject: [PATCH 057/192] Fix QR scanning --- .../assets/camera_app.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index cecf8e8..fba24e6 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -29,6 +29,7 @@ class CameraApp(Activity): status_label_text_found = "Found QR, trying to decode... hold still..." cam = None + current_cam_buffer = None # Holds the current memoryview to prevent garba width = None height = None @@ -171,7 +172,6 @@ def onPause(self, screen): i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) except Exception as e: print(f"Warning: powering off camera got exception: {e}") - self.image_dsc.data = None print("camera app cleanup done.") def parse_camera_init_preferences(self): @@ -213,19 +213,16 @@ def update_preview_image(self): self.image.set_scale(min(scale_factor_w,scale_factor_h)) def qrdecode_one(self): - if self.image_dsc.data is None: - print("qrdecode_one: can't decode empty image") - return try: import qrdecode import utime before = time.ticks_ms() if self.colormode: - result = qrdecode.qrdecode_rgb565(self.image_dsc.data, self.width, self.height) + result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) else: - result = qrdecode.qrdecode(self.image_dsc.data, self.width, self.height) + result = qrdecode.qrdecode(self.current_cam_buffer, self.width, self.height) after = time.ticks_ms() - #result = bytearray("INSERT_QR_HERE", "utf-8") + #result = bytearray("INSERT_TEST_QR_DATA_HERE", "utf-8") if not result: self.status_label.set_text(self.status_label_text_searching) else: @@ -259,14 +256,14 @@ def snap_button_click(self, e): os.mkdir("data/images") except OSError: pass - if self.image_dsc.data is None: + if self.current_cam_buffer is None: print("snap_button_click: won't save empty image") return colorname = "RGB565" if self.colormode else "GRAY" filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" try: with open(filename, 'wb') as f: - f.write(self.image_dsc.data) + f.write(self.current_cam_buffer) print(f"Successfully wrote image to {filename}") except OSError as e: print(f"Error writing to file: {e}") @@ -321,12 +318,14 @@ def try_capture(self, event): #print("capturing camera frame") try: if self.use_webcam: - self.image_dsc.data = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") + self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") elif self.cam.frame_available(): - self.image_dsc.data = self.cam.capture() + self.current_cam_buffer = self.cam.capture() except Exception as e: print(f"Camera capture exception: {e}") + return # Display the image: + self.image_dsc.data = self.current_cam_buffer #self.image.invalidate() # does not work so do this: self.image.set_src(self.image_dsc) if not self.use_webcam: From 3d36e80a8b7f89deb5ebe5331c8f324bbf03fa4a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 11:43:27 +0100 Subject: [PATCH 058/192] Fix camera bugs --- .../assets/camera_app.py | 422 +++++++++--------- 1 file changed, 211 insertions(+), 211 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index fba24e6..93890b0 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -120,11 +120,11 @@ def onCreate(self): def onResume(self, screen): self.parse_camera_init_preferences() # Init camera: - self.cam = init_internal_cam(self.width, self.height) + self.cam = self.init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees # Apply saved camera settings, only for internal camera for now: - apply_camera_settings(self.cam, self.use_webcam) # needs to be done AFTER the camera is initialized + self.apply_camera_settings(self.cam, self.use_webcam) # needs to be done AFTER the camera is initialized else: print("camera app: no internal camera found, trying webcam on /dev/video0") try: @@ -227,8 +227,8 @@ def qrdecode_one(self): self.status_label.set_text(self.status_label_text_searching) else: print(f"SUCCESSFUL QR DECODE TOOK: {after-before}ms") - result = remove_bom(result) - result = print_qr_buffer(result) + result = self.remove_bom(result) + result = self.print_qr_buffer(result) print(f"QR decoding found: {result}") if self.scanqr_mode: self.setResult(True, result) @@ -336,214 +336,214 @@ def try_capture(self, event): except Exception as qre: print(f"try_capture: qrdecode_one got exception: {qre}") - -# Non-class functions: -def init_internal_cam(width, height): - """Initialize internal camera with specified resolution. - - Automatically retries once if initialization fails (to handle I2C poweroff issue). - """ - try: - from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling - - # Map resolution to FrameSize enum - # Format: (width, height): FrameSize - resolution_map = { - (96, 96): FrameSize.R96X96, - (160, 120): FrameSize.QQVGA, - (128, 128): FrameSize.R128X128, - (176, 144): FrameSize.QCIF, - (240, 176): FrameSize.HQVGA, - (240, 240): FrameSize.R240X240, - (320, 240): FrameSize.QVGA, - (320, 320): FrameSize.R320X320, - (400, 296): FrameSize.CIF, - (480, 320): FrameSize.HVGA, - (480, 480): FrameSize.R480X480, - (640, 480): FrameSize.VGA, - (640, 640): FrameSize.R640X640, - (720, 720): FrameSize.R720X720, - (800, 600): FrameSize.SVGA, - (800, 800): FrameSize.R800X800, - (960, 960): FrameSize.R960X960, - (1024, 768): FrameSize.XGA, - (1024,1024): FrameSize.R1024X1024, - (1280, 720): FrameSize.HD, - (1280, 1024): FrameSize.SXGA, - (1280, 1280): FrameSize.R1280X1280, - (1600, 1200): FrameSize.UXGA, - (1920, 1080): FrameSize.FHD, - } - - frame_size = resolution_map.get((width, height), FrameSize.QVGA) - print(f"init_internal_cam: Using FrameSize for {width}x{height}") - - # Try to initialize, with one retry for I2C poweroff issue - max_attempts = 3 - for attempt in range(max_attempts): - try: - cam = Camera( - data_pins=[12,13,15,11,14,10,7,2], - vsync_pin=6, - href_pin=4, - sda_pin=21, - scl_pin=16, - pclk_pin=9, - xclk_pin=8, - xclk_freq=20000000, - powerdown_pin=-1, - reset_pin=-1, - pixel_format=PixelFormat.RGB565 if self.colormode else PixelFormat.GRAYSCALE, - frame_size=frame_size, - #grab_mode=GrabMode.WHEN_EMPTY, - grab_mode=GrabMode.LATEST, - fb_count=1 - ) - cam.set_vflip(True) - return cam - except Exception as e: - if attempt < max_attempts-1: - print(f"init_cam attempt {attempt} failed: {e}, retrying...") - else: - print(f"init_cam final exception: {e}") - return None - except Exception as e: - print(f"init_cam exception: {e}") - return None - -def print_qr_buffer(buffer): - try: - # Try to decode buffer as a UTF-8 string - result = buffer.decode('utf-8') - # Check if the string is printable (ASCII printable characters) - if all(32 <= ord(c) <= 126 for c in result): - return result - except Exception as e: - pass - # If not a valid string or not printable, convert to hex - hex_str = ' '.join([f'{b:02x}' for b in buffer]) - return hex_str.lower() - -# Byte-Order-Mark is added sometimes -def remove_bom(buffer): - bom = b'\xEF\xBB\xBF' - if buffer.startswith(bom): - return buffer[3:] - return buffer - - -def apply_camera_settings(cam, use_webcam): - """Apply all saved camera settings to the camera. - - Only applies settings when use_webcam is False (ESP32 camera). - Settings are applied in dependency order (master switches before dependent values). - - Args: - cam: Camera object - use_webcam: Boolean indicating if using webcam - """ - if not cam or use_webcam: - print("apply_camera_settings: Skipping (no camera or webcam mode)") - return - - try: - # Basic image adjustments - brightness = self.prefs.get_int("brightness", 0) - cam.set_brightness(brightness) - - contrast = self.prefs.get_int("contrast", 0) - cam.set_contrast(contrast) - - saturation = self.prefs.get_int("saturation", 0) - cam.set_saturation(saturation) - - # Orientation - hmirror = self.prefs.get_bool("hmirror", False) - cam.set_hmirror(hmirror) - - vflip = self.prefs.get_bool("vflip", True) - cam.set_vflip(vflip) - - # Special effect - special_effect = self.prefs.get_int("special_effect", 0) - cam.set_special_effect(special_effect) - - # Exposure control (apply master switch first, then manual value) - exposure_ctrl = self.prefs.get_bool("exposure_ctrl", True) - cam.set_exposure_ctrl(exposure_ctrl) - - if not exposure_ctrl: - aec_value = self.prefs.get_int("aec_value", 300) - cam.set_aec_value(aec_value) - - ae_level = self.prefs.get_int("ae_level", 0) - cam.set_ae_level(ae_level) - - aec2 = self.prefs.get_bool("aec2", False) - cam.set_aec2(aec2) - - # Gain control (apply master switch first, then manual value) - gain_ctrl = self.prefs.get_bool("gain_ctrl", True) - cam.set_gain_ctrl(gain_ctrl) - - if not gain_ctrl: - agc_gain = self.prefs.get_int("agc_gain", 0) - cam.set_agc_gain(agc_gain) - - gainceiling = self.prefs.get_int("gainceiling", 0) - cam.set_gainceiling(gainceiling) - - # White balance (apply master switch first, then mode) - whitebal = self.prefs.get_bool("whitebal", True) - cam.set_whitebal(whitebal) - - if not whitebal: - wb_mode = self.prefs.get_int("wb_mode", 0) - cam.set_wb_mode(wb_mode) - - awb_gain = self.prefs.get_bool("awb_gain", True) - cam.set_awb_gain(awb_gain) - - # Sensor-specific settings (try/except for unsupported sensors) + def init_internal_cam(self, width, height): + """Initialize internal camera with specified resolution. + + Automatically retries once if initialization fails (to handle I2C poweroff issue). + """ try: - sharpness = self.prefs.get_int("sharpness", 0) - cam.set_sharpness(sharpness) - except: - pass # Not supported on OV2640 + from camera import Camera, GrabMode, PixelFormat, FrameSize, GainCeiling + + # Map resolution to FrameSize enum + # Format: (width, height): FrameSize + resolution_map = { + (96, 96): FrameSize.R96X96, + (160, 120): FrameSize.QQVGA, + (128, 128): FrameSize.R128X128, + (176, 144): FrameSize.QCIF, + (240, 176): FrameSize.HQVGA, + (240, 240): FrameSize.R240X240, + (320, 240): FrameSize.QVGA, + (320, 320): FrameSize.R320X320, + (400, 296): FrameSize.CIF, + (480, 320): FrameSize.HVGA, + (480, 480): FrameSize.R480X480, + (640, 480): FrameSize.VGA, + (640, 640): FrameSize.R640X640, + (720, 720): FrameSize.R720X720, + (800, 600): FrameSize.SVGA, + (800, 800): FrameSize.R800X800, + (960, 960): FrameSize.R960X960, + (1024, 768): FrameSize.XGA, + (1024,1024): FrameSize.R1024X1024, + (1280, 720): FrameSize.HD, + (1280, 1024): FrameSize.SXGA, + (1280, 1280): FrameSize.R1280X1280, + (1600, 1200): FrameSize.UXGA, + (1920, 1080): FrameSize.FHD, + } + + frame_size = resolution_map.get((width, height), FrameSize.QVGA) + print(f"init_internal_cam: Using FrameSize for {width}x{height}") + + # Try to initialize, with one retry for I2C poweroff issue + max_attempts = 3 + for attempt in range(max_attempts): + try: + cam = Camera( + data_pins=[12,13,15,11,14,10,7,2], + vsync_pin=6, + href_pin=4, + sda_pin=21, + scl_pin=16, + pclk_pin=9, + xclk_pin=8, + xclk_freq=20000000, + powerdown_pin=-1, + reset_pin=-1, + pixel_format=PixelFormat.RGB565 if self.colormode else PixelFormat.GRAYSCALE, + frame_size=frame_size, + #grab_mode=GrabMode.WHEN_EMPTY, + grab_mode=GrabMode.LATEST, + fb_count=1 + ) + cam.set_vflip(True) + return cam + except Exception as e: + if attempt < max_attempts-1: + print(f"init_cam attempt {attempt} failed: {e}, retrying...") + else: + print(f"init_cam final exception: {e}") + return None + except Exception as e: + print(f"init_cam exception: {e}") + return None + def print_qr_buffer(self, buffer): try: - denoise = self.prefs.get_int("denoise", 0) - cam.set_denoise(denoise) - except: - pass # Not supported on OV2640 - - # Advanced corrections - colorbar = self.prefs.get_bool("colorbar", False) - cam.set_colorbar(colorbar) - - dcw = self.prefs.get_bool("dcw", True) - cam.set_dcw(dcw) - - bpc = self.prefs.get_bool("bpc", False) - cam.set_bpc(bpc) - - wpc = self.prefs.get_bool("wpc", True) - cam.set_wpc(wpc) - - raw_gma = self.prefs.get_bool("raw_gma", True) - cam.set_raw_gma(raw_gma) - - lenc = self.prefs.get_bool("lenc", True) - cam.set_lenc(lenc) - - # JPEG quality (only relevant for JPEG format) + # Try to decode buffer as a UTF-8 string + result = buffer.decode('utf-8') + # Check if the string is printable (ASCII printable characters) + if all(32 <= ord(c) <= 126 for c in result): + return result + except Exception as e: + pass + # If not a valid string or not printable, convert to hex + hex_str = ' '.join([f'{b:02x}' for b in buffer]) + return hex_str.lower() + + # Byte-Order-Mark is added sometimes + def remove_bom(self, buffer): + bom = b'\xEF\xBB\xBF' + if buffer.startswith(bom): + return buffer[3:] + return buffer + + + def apply_camera_settings(self, cam, use_webcam): + """Apply all saved camera settings to the camera. + + Only applies settings when use_webcam is False (ESP32 camera). + Settings are applied in dependency order (master switches before dependent values). + + Args: + cam: Camera object + use_webcam: Boolean indicating if using webcam + """ + if not cam or use_webcam: + print("apply_camera_settings: Skipping (no camera or webcam mode)") + return + try: - quality = self.prefs.get_int("quality", 85) - cam.set_quality(quality) - except: - pass # Not in JPEG mode - - print("Camera settings applied successfully") - - except Exception as e: - print(f"Error applying camera settings: {e}") + # Basic image adjustments + brightness = self.prefs.get_int("brightness", 0) + cam.set_brightness(brightness) + + contrast = self.prefs.get_int("contrast", 0) + cam.set_contrast(contrast) + + saturation = self.prefs.get_int("saturation", 0) + cam.set_saturation(saturation) + + # Orientation + hmirror = self.prefs.get_bool("hmirror", False) + cam.set_hmirror(hmirror) + + vflip = self.prefs.get_bool("vflip", True) + cam.set_vflip(vflip) + + # Special effect + special_effect = self.prefs.get_int("special_effect", 0) + cam.set_special_effect(special_effect) + + # Exposure control (apply master switch first, then manual value) + exposure_ctrl = self.prefs.get_bool("exposure_ctrl", True) + cam.set_exposure_ctrl(exposure_ctrl) + + if not exposure_ctrl: + aec_value = self.prefs.get_int("aec_value", 300) + cam.set_aec_value(aec_value) + + ae_level = self.prefs.get_int("ae_level", 0) + cam.set_ae_level(ae_level) + + aec2 = self.prefs.get_bool("aec2", False) + cam.set_aec2(aec2) + + # Gain control (apply master switch first, then manual value) + gain_ctrl = self.prefs.get_bool("gain_ctrl", True) + cam.set_gain_ctrl(gain_ctrl) + + if not gain_ctrl: + agc_gain = self.prefs.get_int("agc_gain", 0) + cam.set_agc_gain(agc_gain) + + gainceiling = self.prefs.get_int("gainceiling", 0) + cam.set_gainceiling(gainceiling) + + # White balance (apply master switch first, then mode) + whitebal = self.prefs.get_bool("whitebal", True) + cam.set_whitebal(whitebal) + + if not whitebal: + wb_mode = self.prefs.get_int("wb_mode", 0) + cam.set_wb_mode(wb_mode) + + awb_gain = self.prefs.get_bool("awb_gain", True) + cam.set_awb_gain(awb_gain) + + # Sensor-specific settings (try/except for unsupported sensors) + try: + sharpness = self.prefs.get_int("sharpness", 0) + cam.set_sharpness(sharpness) + except: + pass # Not supported on OV2640 + + try: + denoise = self.prefs.get_int("denoise", 0) + cam.set_denoise(denoise) + except: + pass # Not supported on OV2640 + + # Advanced corrections + colorbar = self.prefs.get_bool("colorbar", False) + cam.set_colorbar(colorbar) + + dcw = self.prefs.get_bool("dcw", True) + cam.set_dcw(dcw) + + bpc = self.prefs.get_bool("bpc", False) + cam.set_bpc(bpc) + + wpc = self.prefs.get_bool("wpc", True) + cam.set_wpc(wpc) + + raw_gma = self.prefs.get_bool("raw_gma", True) + print(f"applying raw_gma: {raw_gma}") + cam.set_raw_gma(raw_gma) + + lenc = self.prefs.get_bool("lenc", True) + cam.set_lenc(lenc) + + # JPEG quality (only relevant for JPEG format) + try: + quality = self.prefs.get_int("quality", 85) + cam.set_quality(quality) + except: + pass # Not in JPEG mode + + print("Camera settings applied successfully") + + except Exception as e: + print(f"Error applying camera settings: {e}") + From be020014ea50f625ca125e71fd7e4a271e7f202b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 12:46:03 +0100 Subject: [PATCH 059/192] battery_voltage.py: don't limit to max_voltage --- internal_filesystem/lib/mpos/battery_voltage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py index c292d49..b700b6b 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -131,7 +131,7 @@ def read_battery_voltage(force_refresh=False, raw_adc_value=None): """ raw = raw_adc_value if raw_adc_value else read_raw_adc(force_refresh) voltage = conversion_func(raw) if conversion_func else 0.0 - return max(0.0, min(voltage, MAX_VOLTAGE)) + return voltage def get_battery_percentage(raw_adc_value=None): @@ -143,7 +143,7 @@ def get_battery_percentage(raw_adc_value=None): """ voltage = read_battery_voltage(raw_adc_value=raw_adc_value) percentage = (voltage - MIN_VOLTAGE) * 100.0 / (MAX_VOLTAGE - MIN_VOLTAGE) - return max(0.0, min(100.0, percentage)) + return abs(min(100.0, percentage)) # limit to 100.0% and make sure it's positive def clear_cache(): From d7f7b33cfc09c5f9d6fe75dd6041ae18208de58e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 12:51:46 +0100 Subject: [PATCH 060/192] Remove comments --- internal_filesystem/lib/mpos/ui/topmenu.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index 11dc807..b37a123 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -140,15 +140,15 @@ def update_battery_icon(timer=None): except Exception as e: print(f"battery_voltage.get_battery_percentage got exception, not updating battery_icon: {e}") return - if percent > 80: # 4.1V + if percent > 80: battery_icon.set_text(lv.SYMBOL.BATTERY_FULL) - elif percent > 60: # 4.0V + elif percent > 60: battery_icon.set_text(lv.SYMBOL.BATTERY_3) - elif percent > 40: # 3.9V + elif percent > 40: battery_icon.set_text(lv.SYMBOL.BATTERY_2) - elif percent > 20: # 3.8V + elif percent > 20: battery_icon.set_text(lv.SYMBOL.BATTERY_1) - else: # > 3.7V + else: battery_icon.set_text(lv.SYMBOL.BATTERY_EMPTY) battery_icon.remove_flag(lv.obj.FLAG.HIDDEN) # Percentage is not shown for now: From d43ec571d177721f6a70cf0ae84c19831dc0f7b6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 12:54:01 +0100 Subject: [PATCH 061/192] quirc_decode.c: less debug --- c_mpos/src/quirc_decode.c | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/c_mpos/src/quirc_decode.c b/c_mpos/src/quirc_decode.c index dfb72e6..06c0e3c 100644 --- a/c_mpos/src/quirc_decode.c +++ b/c_mpos/src/quirc_decode.c @@ -22,8 +22,8 @@ size_t uxTaskGetStackHighWaterMark(void * unused) { #define QRDECODE_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { - QRDECODE_DEBUG_PRINT("qrdecode: Starting\n"); - QRDECODE_DEBUG_PRINT("qrdecode: Stack high-water mark: %u bytes\n", uxTaskGetStackHighWaterMark(NULL)); + //QRDECODE_DEBUG_PRINT("qrdecode: Starting\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Stack high-water mark: %u bytes\n", uxTaskGetStackHighWaterMark(NULL)); if (n_args != 3) { mp_raise_ValueError(MP_ERROR_TEXT("quirc_decode expects 3 arguments: buffer, width, height")); @@ -34,13 +34,13 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { mp_int_t width = mp_obj_get_int(args[1]); mp_int_t height = mp_obj_get_int(args[2]); - QRDECODE_DEBUG_PRINT("qrdecode: Width=%u, Height=%u\n", width, height); + //QRDECODE_DEBUG_PRINT("qrdecode: Width=%u, Height=%u\n", width, height); if (width <= 0 || height <= 0) { mp_raise_ValueError(MP_ERROR_TEXT("width and height must be positive")); } - QRDECODE_DEBUG_PRINT("qrdecode bufsize: %u bytes\n", bufinfo.len); if (bufinfo.len != (size_t)(width * height)) { + QRDECODE_DEBUG_PRINT("qrdecode wrong bufsize: %u bytes\n", bufinfo.len); mp_raise_ValueError(MP_ERROR_TEXT("buffer size must match width * height")); } struct quirc *qr = quirc_new(); @@ -109,7 +109,7 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { free(data); free(code); quirc_destroy(qr); - QRDECODE_DEBUG_PRINT("qrdecode: Decode failed, freed data, code, and quirc object\n"); + //QRDECODE_DEBUG_PRINT("qrdecode: Decode failed, freed data, code, and quirc object\n"); mp_raise_TypeError(MP_ERROR_TEXT("failed to decode QR code")); } @@ -123,7 +123,7 @@ static mp_obj_t qrdecode(mp_uint_t n_args, const mp_obj_t *args) { } static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Starting\n"); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Starting\n"); if (n_args != 3) { mp_raise_ValueError(MP_ERROR_TEXT("qrdecode_rgb565 expects 3 arguments: buffer, width, height")); @@ -134,13 +134,13 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { mp_int_t width = mp_obj_get_int(args[1]); mp_int_t height = mp_obj_get_int(args[2]); - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Width=%u, Height=%u\n", width, height); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Width=%u, Height=%u\n", width, height); if (width <= 0 || height <= 0) { mp_raise_ValueError(MP_ERROR_TEXT("width and height must be positive")); } - QRDECODE_DEBUG_PRINT("qrdecode bufsize: %u bytes\n", bufinfo.len); if (bufinfo.len != (size_t)(width * height * 2)) { + QRDECODE_DEBUG_PRINT("qrdecode_rgb565 wrong bufsize: %u bytes\n", bufinfo.len); mp_raise_ValueError(MP_ERROR_TEXT("buffer size must match width * height * 2 for RGB565")); } @@ -148,7 +148,7 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { if (!gray_buffer) { mp_raise_OSError(MP_ENOMEM); } - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Allocated gray_buffer (%u bytes)\n", width * height * sizeof(uint8_t)); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Allocated gray_buffer (%u bytes)\n", width * height * sizeof(uint8_t)); uint16_t *rgb565 = (uint16_t *)bufinfo.buf; for (size_t i = 0; i < (size_t)(width * height); i++) { @@ -170,10 +170,10 @@ static mp_obj_t qrdecode_rgb565(mp_uint_t n_args, const mp_obj_t *args) { if (nlr_push(&exception_handler) == 0) { result = qrdecode(3, gray_args); nlr_pop(); - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: qrdecode succeeded, freeing gray_buffer\n"); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: qrdecode succeeded, freeing gray_buffer\n"); free(gray_buffer); } else { - QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Exception caught, freeing gray_buffer\n"); + //QRDECODE_DEBUG_PRINT("qrdecode_rgb565: Exception caught, freeing gray_buffer\n"); // Cleanup if (gray_buffer) { free(gray_buffer); From 8abb706ae7c8862bfcc3fb42eb5858c630b544b7 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 13:04:52 +0100 Subject: [PATCH 062/192] Update micropython-camera-API --- .gitmodules | 3 ++- micropython-camera-API | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 7ea092a..36f11e8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,7 +10,8 @@ url = https://github.com/MicroPythonOS/lvgl_micropython [submodule "micropython-camera-API"] path = micropython-camera-API - url = https://github.com/cnadler86/micropython-camera-API + #url = https://github.com/cnadler86/micropython-camera-API + url = https://github.com/MicroPythonOS/micropython-camera-API [submodule "micropython-nostr"] path = micropython-nostr url = https://github.com/MicroPythonOS/micropython-nostr diff --git a/micropython-camera-API b/micropython-camera-API index 2dd9711..a84c845 160000 --- a/micropython-camera-API +++ b/micropython-camera-API @@ -1 +1 @@ -Subproject commit 2dd97117359d00729d50448df19404d18f67ac30 +Subproject commit a84c84595b415894b9b4ca3dc05ffd3d7d9d9a22 From 3bd9ce55f9d619b754fc0d11d9fdaf73236ea415 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 14:59:14 +0100 Subject: [PATCH 063/192] Fix unit tests --- internal_filesystem/lib/mpos/battery_voltage.py | 4 +++- tests/test_battery_voltage.py | 8 -------- tests/test_graphical_keyboard_animation.py | 6 +++++- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py index b700b6b..616a725 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -143,7 +143,9 @@ def get_battery_percentage(raw_adc_value=None): """ voltage = read_battery_voltage(raw_adc_value=raw_adc_value) percentage = (voltage - MIN_VOLTAGE) * 100.0 / (MAX_VOLTAGE - MIN_VOLTAGE) - return abs(min(100.0, percentage)) # limit to 100.0% and make sure it's positive + print(f"percentage = {percentage}") + print(f"min = {min(100.0, percentage)}") + return max(0,min(100.0, percentage)) # limit to 100.0% and make sure it's positive def clear_cache(): diff --git a/tests/test_battery_voltage.py b/tests/test_battery_voltage.py index 4b4be2b..3f3336a 100644 --- a/tests/test_battery_voltage.py +++ b/tests/test_battery_voltage.py @@ -341,14 +341,6 @@ def test_read_battery_voltage_applies_scale_factor(self): expected = 2048 * 0.00161 self.assertAlmostEqual(voltage, expected, places=4) - def test_voltage_clamped_to_max(self): - """Test that voltage is clamped to MAX_VOLTAGE.""" - bv.adc.set_read_value(4095) # Maximum ADC - bv.clear_cache() - - voltage = bv.read_battery_voltage(force_refresh=True) - self.assertLessEqual(voltage, bv.MAX_VOLTAGE) - def test_voltage_clamped_to_zero(self): """Test that negative voltage is clamped to 0.""" bv.adc.set_read_value(0) diff --git a/tests/test_graphical_keyboard_animation.py b/tests/test_graphical_keyboard_animation.py index 548cfe0..f1e0c54 100644 --- a/tests/test_graphical_keyboard_animation.py +++ b/tests/test_graphical_keyboard_animation.py @@ -11,9 +11,10 @@ import unittest import lvgl as lv +import time import mpos.ui.anim from mpos.ui.keyboard import MposKeyboard - +from mpos.ui.testing import wait_for_render class TestKeyboardAnimation(unittest.TestCase): """Test MposKeyboard compatibility with animation system.""" @@ -86,6 +87,7 @@ def test_keyboard_smooth_show(self): # This should work without raising AttributeError try: mpos.ui.anim.smooth_show(keyboard) + wait_for_render(100) print("smooth_show called successfully") except AttributeError as e: self.fail(f"smooth_show raised AttributeError: {e}\n" @@ -144,6 +146,7 @@ def test_keyboard_show_hide_cycle(self): # Show keyboard (simulates textarea click) try: mpos.ui.anim.smooth_show(keyboard) + wait_for_render(100) except AttributeError as e: self.fail(f"Failed during smooth_show: {e}") @@ -153,6 +156,7 @@ def test_keyboard_show_hide_cycle(self): # Hide keyboard (simulates pressing Enter) try: mpos.ui.anim.smooth_hide(keyboard) + wait_for_render(100) except AttributeError as e: self.fail(f"Failed during smooth_hide: {e}") From a7712f058b0ecf9ba2c25ccc015a2754427d89d7 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 29 Nov 2025 17:45:32 +0100 Subject: [PATCH 064/192] Remove comments --- internal_filesystem/lib/mpos/battery_voltage.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py index 616a725..ca28427 100644 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ b/internal_filesystem/lib/mpos/battery_voltage.py @@ -143,8 +143,6 @@ def get_battery_percentage(raw_adc_value=None): """ voltage = read_battery_voltage(raw_adc_value=raw_adc_value) percentage = (voltage - MIN_VOLTAGE) * 100.0 / (MAX_VOLTAGE - MIN_VOLTAGE) - print(f"percentage = {percentage}") - print(f"min = {min(100.0, percentage)}") return max(0,min(100.0, percentage)) # limit to 100.0% and make sure it's positive From 8819afd80a4daf27aa159ab31504fec4066e0a19 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 15:29:04 +0100 Subject: [PATCH 065/192] Camera app: simplify --- .../assets/camera_app.py | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 93890b0..b63387c 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -214,28 +214,15 @@ def update_preview_image(self): def qrdecode_one(self): try: - import qrdecode - import utime + result = None before = time.ticks_ms() + import qrdecode if self.colormode: result = qrdecode.qrdecode_rgb565(self.current_cam_buffer, self.width, self.height) else: result = qrdecode.qrdecode(self.current_cam_buffer, self.width, self.height) after = time.ticks_ms() - #result = bytearray("INSERT_TEST_QR_DATA_HERE", "utf-8") - if not result: - self.status_label.set_text(self.status_label_text_searching) - else: - print(f"SUCCESSFUL QR DECODE TOOK: {after-before}ms") - result = self.remove_bom(result) - result = self.print_qr_buffer(result) - print(f"QR decoding found: {result}") - if self.scanqr_mode: - self.setResult(True, result) - self.finish() - else: - self.status_label.set_text(result) # in the future, the status_label text should be copy-paste-able - self.stop_qr_decoding() + print(f"qrdecode took {after-before}ms") except ValueError as e: print("QR ValueError: ", e) self.status_label.set_text(self.status_label_text_searching) @@ -244,6 +231,18 @@ def qrdecode_one(self): self.status_label.set_text(self.status_label_text_found) except Exception as e: print("QR got other error: ", e) + #result = bytearray("INSERT_TEST_QR_DATA_HERE", "utf-8") + if result is None: + return + result = self.remove_bom(result) + result = self.print_qr_buffer(result) + print(f"QR decoding found: {result}") + self.stop_qr_decoding() + if self.scanqr_mode: + self.setResult(True, result) + self.finish() + else: + self.status_label.set_text(result) # in the future, the status_label text should be copy-paste-able def snap_button_click(self, e): print("Picture taken!") @@ -280,7 +279,7 @@ def stop_qr_decoding(self): self.keepliveqrdecoding = False self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) self.status_label_text = self.status_label.get_text() - if self.status_label_text in (self.status_label_text_searching or self.status_label_text_found): # if it found a QR code, leave it + if self.status_label_text not in (self.status_label_text_searching or self.status_label_text_found): # if it found a QR code, leave it self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) def qr_button_click(self, e): @@ -328,13 +327,10 @@ def try_capture(self, event): self.image_dsc.data = self.current_cam_buffer #self.image.invalidate() # does not work so do this: self.image.set_src(self.image_dsc) + if self.keepliveqrdecoding: + self.qrdecode_one() if not self.use_webcam: - self.cam.free_buffer() # Free the old buffer, otherwise the camera doesn't provide a new one - try: - if self.keepliveqrdecoding: - self.qrdecode_one() - except Exception as qre: - print(f"try_capture: qrdecode_one got exception: {qre}") + self.cam.free_buffer() # After QR decoding, free the old buffer, otherwise the camera doesn't provide a new one def init_internal_cam(self, width, height): """Initialize internal camera with specified resolution. From 059e1e51eafda6fc221c16e890593b086c91133d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 15:29:20 +0100 Subject: [PATCH 066/192] ImageView app: add support for grayscale images --- .../apps/com.micropythonos.imageview/assets/imageview.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py index 072160e..ab51b89 100644 --- a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py +++ b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py @@ -214,7 +214,9 @@ def show_image(self, name): print(f"Raw image has width: {width}, Height: {height}, Color Format: {color_format}") stride = width * 2 cf = lv.COLOR_FORMAT.RGB565 - if color_format != "RGB565": + if color_format == "GRAY": + cf = lv.COLOR_FORMAT.L8 + else: print(f"WARNING: unknown color format {color_format}, assuming RGB565...") self.current_image_dsc = lv.image_dsc_t({ "header": { From e40fe8fdb2439627d6f90e75058d4eb170d66d0e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 15:30:32 +0100 Subject: [PATCH 067/192] About app: add free, used and total storage space info --- CHANGELOG.md | 2 ++ .../com.micropythonos.about/assets/about.py | 25 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 512c080..7494f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,11 @@ ===== - Fri3d Camp 2024 Badge: workaround ADC2+WiFi conflict by temporarily disable WiFi to measure battery level - Fri3d Camp 2024 Badge: improve battery monitor calibration to fix 0.1V delta +- About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name - OSUpdate app: pause download when wifi is lost, resume when reconnected - Settings app: fix un-checking of radio button +- ImageView app: add support for grayscale images - API: SharedPreferences: add erase_all() functionality - API: improve and cleanup animations 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 d278f52..7ec5cce 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py @@ -85,7 +85,26 @@ def onCreate(self): print("main.py: WARNING: could not import/run freezefs_mount_builtin: ", e) label11 = lv.label(screen) label11.set_text(f"freezefs_mount_builtin exception (normal on dev builds): {e}") - # TODO: - # - add total size, used and free space on internal storage - # - add total size, used and free space on SD card + # Disk usage: + import os + stat = os.statvfs('/') + total_space = stat[0] * stat[2] + free_space = stat[0] * stat[3] + used_space = total_space - free_space + label20 = lv.label(screen) + label20.set_text(f"Total space in /: {total_space} bytes") + label21 = lv.label(screen) + label21.set_text(f"Free space in /: {free_space} bytes") + label22 = lv.label(screen) + label22.set_text(f"Used space in /: {used_space} bytes") + stat = os.statvfs('/sdcard') + total_space = stat[0] * stat[2] + free_space = stat[0] * stat[3] + used_space = total_space - free_space + label23 = lv.label(screen) + label23.set_text(f"Total space /sdcard: {total_space} bytes") + label24 = lv.label(screen) + label24.set_text(f"Free space /sdcard: {free_space} bytes") + label25 = lv.label(screen) + label25.set_text(f"Used space /sdcard: {used_space} bytes") self.setContentView(screen) From e1a97f65e626776ce9078e393dfdbeb386e3c1fc Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 15:35:50 +0100 Subject: [PATCH 068/192] Add scripts/convert_raw_to_png.sh --- scripts/convert_raw_to_png.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 scripts/convert_raw_to_png.sh diff --git a/scripts/convert_raw_to_png.sh b/scripts/convert_raw_to_png.sh new file mode 100644 index 0000000..ae1c535 --- /dev/null +++ b/scripts/convert_raw_to_png.sh @@ -0,0 +1,12 @@ +inputfile="$1" +if [ -z "$inputfile" ]; then + echo "Usage: $0 inputfile" + echo "Example: $0 camera_capture_1764503331_960x960_GRAY.raw" + exit 1 +fi + +outputfile="$inputfile".png +echo "Converting $inputfile to $outputfile" + +# For now it's pretty hard coded but the format could be extracted from the filename... +convert -size 960x960 -depth 8 gray:"$inputfile" "$outputfile" From c69342b6aa66a441da691724c95c316008e53216 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 15:49:51 +0100 Subject: [PATCH 069/192] Comments and output --- .../apps/com.micropythonos.camera/assets/camera_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index b63387c..a23f45e 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -245,7 +245,7 @@ def qrdecode_one(self): self.status_label.set_text(result) # in the future, the status_label text should be copy-paste-able def snap_button_click(self, e): - print("Picture taken!") + print("Taking picture...") import os try: os.mkdir("data") @@ -262,7 +262,7 @@ def snap_button_click(self, e): filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" try: with open(filename, 'wb') as f: - f.write(self.current_cam_buffer) + f.write(self.current_cam_buffer) # This takes around 17 seconds to store 921600 bytes, so ~50KB/s, so would be nice to show some progress bar print(f"Successfully wrote image to {filename}") except OSError as e: print(f"Error writing to file: {e}") From e8faef1743e8cb203ec9617ce8a76a2e29f468a9 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 15:52:47 +0100 Subject: [PATCH 070/192] Comments --- .../apps/com.micropythonos.camera/assets/camera_app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index a23f45e..cc55e10 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -246,6 +246,7 @@ def qrdecode_one(self): def snap_button_click(self, e): print("Taking picture...") + # Would be nice to check that there's enough free space here, and show an error if not... import os try: os.mkdir("data") From 054ac7438a310de5761fef1b7d5cfa53f80a6f97 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 18:50:02 +0100 Subject: [PATCH 071/192] Comments --- .../apps/com.micropythonos.camera/assets/camera_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index c94e1a3..ce502bc 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -60,7 +60,7 @@ class CameraSettingsActivity(Activity): ("960x960", "960x960"), ("1024x768", "1024x768"), ("1024x1024","1024x1024"), - ("1280x720", "1280x720"), # binned 2x2 (in default ov5640.c) + ("1280x720", "1280x720"), ("1280x1024", "1280x1024"), ("1280x1280", "1280x1280"), ("1600x1200", "1600x1200"), From f861412098ca503ee01e12842de9866a74d0c5b8 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 23:11:31 +0100 Subject: [PATCH 072/192] Camera app: different settings for QR scanning --- .../assets/camera_app.py | 116 +++++++++++------- .../assets/camera_settings.py | 52 +++++--- internal_filesystem/lib/mpos/config.py | 2 +- 3 files changed, 108 insertions(+), 62 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index cc55e10..5249c2d 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -14,15 +14,12 @@ class CameraApp(Activity): - DEFAULT_WIDTH = 320 # 240 would be better but webcam doesn't support this (yet) - DEFAULT_HEIGHT = 240 PACKAGE = "com.micropythonos.camera" CONFIGFILE = "config.json" SCANQR_CONFIG = "config_scanqr_mode.json" button_width = 60 button_height = 45 - colormode = False status_label_text = "No camera found." status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and make the QR code big (4-12cm). Ensure proper lighting." @@ -32,17 +29,20 @@ class CameraApp(Activity): current_cam_buffer = None # Holds the current memoryview to prevent garba width = None height = None + colormode = False - image = None image_dsc = None - scanqr_mode = None + scanqr_mode = False + scanqr_intent = False use_webcam = False - keepliveqrdecoding = False - capture_timer = None + + prefs = None # regular prefs + scanqr_prefs = None # qr code scanning prefs # Widgets: main_screen = None + image = None qr_label = None qr_button = None snap_button = None @@ -50,10 +50,7 @@ class CameraApp(Activity): status_label_cont = None def onCreate(self): - self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") - from mpos.config import SharedPreferences - self.prefs = SharedPreferences(self.PACKAGE, filename=self.SCANQR_CONFIG if self.scanqr_mode else self.CONFIGFILE) - + self.scanqr_intent = self.getIntent().extras.get("scanqr_intent") self.main_screen = lv.obj() self.main_screen.set_style_pad_all(1, 0) self.main_screen.set_style_border_width(0, 0) @@ -118,13 +115,31 @@ def onCreate(self): self.setContentView(self.main_screen) def onResume(self, screen): - self.parse_camera_init_preferences() + self.load_settings_cached() + self.start_cam() + if not self.cam and self.scanqr_mode: + print("No camera found, stopping camera app") + self.finish() + # Camera is running and refreshing + self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) + if self.scanqr_mode: + self.start_qr_decoding() + else: + self.qr_button.remove_flag(lv.obj.FLAG.HIDDEN) + self.snap_button.remove_flag(lv.obj.FLAG.HIDDEN) + + def onPause(self, screen): + print("camera app backgrounded, cleaning up...") + self.stop_cam() + print("camera app cleanup done.") + + def start_cam(self): # Init camera: self.cam = self.init_internal_cam(self.width, self.height) if self.cam: self.image.set_rotation(900) # internal camera is rotated 90 degrees # Apply saved camera settings, only for internal camera for now: - self.apply_camera_settings(self.cam, self.use_webcam) # needs to be done AFTER the camera is initialized + self.apply_camera_settings(self.scanqr_prefs if self.scanqr_mode else self.prefs, self.cam, self.use_webcam) # needs to be done AFTER the camera is initialized else: print("camera app: no internal camera found, trying webcam on /dev/video0") try: @@ -139,19 +154,8 @@ def onResume(self, screen): print("Camera app initialized, continuing...") self.update_preview_image() self.capture_timer = lv.timer_create(self.try_capture, 100, None) - self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) - if self.scanqr_mode or self.keepliveqrdecoding: - self.start_qr_decoding() - else: - self.qr_button.remove_flag(lv.obj.FLAG.HIDDEN) - self.snap_button.remove_flag(lv.obj.FLAG.HIDDEN) - else: - print("No camera found, stopping camera app") - if self.scanqr_mode: - self.finish() - def onPause(self, screen): - print("camera app backgrounded, cleaning up...") + def stop_cam(self): if self.capture_timer: self.capture_timer.delete() if self.use_webcam: @@ -172,20 +176,24 @@ def onPause(self, screen): i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) except Exception as e: print(f"Warning: powering off camera got exception: {e}") - print("camera app cleanup done.") + print("emptying self.current_cam_buffer...") + self.image_dsc.data = None # it's important to delete the image when stopping the camera, otherwise LVGL might try to display it and crash - def parse_camera_init_preferences(self): - resolution_str = self.prefs.get_string("resolution", f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}") - self.colormode = self.prefs.get_bool("colormode", False) - try: - width_str, height_str = resolution_str.split('x') - self.width = int(width_str) - self.height = int(height_str) - print(f"Camera resolution loaded: {self.width}x{self.height}") - except Exception as e: - print(f"Error parsing resolution '{resolution_str}': {e}, using default 320x240") - self.width = self.DEFAULT_WIDTH - self.height = self.DEFAULT_HEIGHT + def load_settings_cached(self): + from mpos.config import SharedPreferences + if self.scanqr_mode: + print("loading scanqr settings...") + if not self.scanqr_prefs: + self.scanqr_prefs = SharedPreferences(self.PACKAGE, filename=self.SCANQR_CONFIG) + self.width = self.scanqr_prefs.get_int("resolution_width", CameraSettingsActivity.DEFAULT_SCANQR_WIDTH) + self.height = self.scanqr_prefs.get_int("resolution_height", CameraSettingsActivity.DEFAULT_SCANQR_HEIGHT) + self.colormode = self.scanqr_prefs.get_bool("colormode", CameraSettingsActivity.DEFAULT_SCANQR_COLORMODE) + else: + if not self.prefs: + self.prefs = SharedPreferences(self.PACKAGE) + self.width = self.prefs.get_int("resolution_width", CameraSettingsActivity.DEFAULT_WIDTH) + self.height = self.prefs.get_int("resolution_height", CameraSettingsActivity.DEFAULT_HEIGHT) + self.colormode = self.prefs.get_bool("colormode", CameraSettingsActivity.DEFAULT_COLORMODE) def update_preview_image(self): self.image_dsc = lv.image_dsc_t({ @@ -238,7 +246,7 @@ def qrdecode_one(self): result = self.print_qr_buffer(result) print(f"QR decoding found: {result}") self.stop_qr_decoding() - if self.scanqr_mode: + if self.scanqr_intent: self.setResult(True, result) self.finish() else: @@ -270,21 +278,40 @@ def snap_button_click(self, e): def start_qr_decoding(self): print("Activating live QR decoding...") - self.keepliveqrdecoding = True + self.scanqr_mode = True + oldwidth = self.width + oldheight = self.height + oldcolormode = self.colormode + # Activate QR mode settings + self.load_settings_cached() + # Check if it's necessary to restart the camera: + if self.width != oldwidth or self.height != oldheight or self.colormode != oldcolormode: + self.stop_cam() + self.start_cam() self.qr_label.set_text(lv.SYMBOL.EYE_CLOSE) self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) self.status_label.set_text(self.status_label_text_searching) def stop_qr_decoding(self): print("Deactivating live QR decoding...") - self.keepliveqrdecoding = False + self.scanqr_mode = False self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) self.status_label_text = self.status_label.get_text() if self.status_label_text not in (self.status_label_text_searching or self.status_label_text_found): # if it found a QR code, leave it self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) + # Check if it's necessary to restart the camera: + oldwidth = self.width + oldheight = self.height + oldcolormode = self.colormode + # Activate non-QR mode settings + self.load_settings_cached() + # Check if it's necessary to restart the camera: + if self.width != oldwidth or self.height != oldheight or self.colormode != oldcolormode: + self.stop_cam() + self.start_cam() def qr_button_click(self, e): - if not self.keepliveqrdecoding: + if not self.scanqr_mode: self.start_qr_decoding() else: self.stop_qr_decoding() @@ -311,11 +338,10 @@ def zoom_button_click(self, e): print(f"self.cam.set_res_raw returned {result}") def open_settings(self): - intent = Intent(activity_class=CameraSettingsActivity, extras={"prefs": self.prefs, "use_webcam": self.use_webcam, "scanqr_mode": self.scanqr_mode}) + intent = Intent(activity_class=CameraSettingsActivity, extras={"prefs": self.prefs if not self.scanqr_mode else self.scanqr_prefs, "use_webcam": self.use_webcam, "scanqr_mode": self.scanqr_mode}) self.startActivity(intent) def try_capture(self, event): - #print("capturing camera frame") try: if self.use_webcam: self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") @@ -328,7 +354,7 @@ def try_capture(self, event): self.image_dsc.data = self.current_cam_buffer #self.image.invalidate() # does not work so do this: self.image.set_src(self.image_dsc) - if self.keepliveqrdecoding: + if self.scanqr_mode: self.qrdecode_one() if not self.use_webcam: self.cam.free_buffer() # After QR decoding, free the old buffer, otherwise the camera doesn't provide a new one diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index ce502bc..36eeac4 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -6,12 +6,15 @@ from mpos.config import SharedPreferences from mpos.content.intent import Intent -#from camera_app import CameraApp - class CameraSettingsActivity(Activity): """Settings activity for comprehensive camera configuration.""" - PACKAGE = "com.micropythonos.camera" + DEFAULT_WIDTH = 320 # 240 would be better but webcam doesn't support this (yet) + DEFAULT_HEIGHT = 240 + DEFAULT_COLORMODE = True + DEFAULT_SCANQR_WIDTH = 960 + DEFAULT_SCANQR_HEIGHT = 960 + DEFAULT_SCANQR_COLORMODE = False # Original: { 2560, 1920, 0, 0, 2623, 1951, 32, 16, 2844, 1968 } # Worked for digital zoom in C: { 2560, 1920, 0, 0, 2623, 1951, 992, 736, 2844, 1968 } @@ -69,8 +72,8 @@ class CameraSettingsActivity(Activity): # These are taken from the Intent: use_webcam = False - scanqr_mode = False prefs = None + scanqr_mode = False # Widgets: button_cont = None @@ -84,9 +87,9 @@ def __init__(self): self.resolutions = [] def onCreate(self): - self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") self.use_webcam = self.getIntent().extras.get("use_webcam") self.prefs = self.getIntent().extras.get("prefs") + self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") if self.use_webcam: self.resolutions = self.WEBCAM_RESOLUTIONS print("Using webcam resolutions") @@ -228,23 +231,29 @@ def add_buttons(self, parent): button_cont.set_style_border_width(0, 0) save_button = lv.button(button_cont) - save_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + save_button.set_size(lv.SIZE_CONTENT, lv.SIZE_CONTENT) save_button.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) save_button.add_event_cb(lambda e: self.save_and_close(), lv.EVENT.CLICKED, None) save_label = lv.label(save_button) - save_label.set_text("Save") + savetext = "Save" + if self.scanqr_mode: + savetext += " QR tweaks" + save_label.set_text(savetext) save_label.center() cancel_button = lv.button(button_cont) cancel_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) - cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) + if self.scanqr_mode: + cancel_button.align(lv.ALIGN.BOTTOM_MID, mpos.ui.pct_of_display_width(10), 0) + else: + cancel_button.align(lv.ALIGN.BOTTOM_MID, 0, 0) cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) cancel_label = lv.label(cancel_button) cancel_label.set_text("Cancel") cancel_label.center() erase_button = lv.button(button_cont) - erase_button.set_size(mpos.ui.pct_of_display_width(25), lv.SIZE_CONTENT) + erase_button.set_size(mpos.ui.pct_of_display_width(20), lv.SIZE_CONTENT) erase_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) erase_button.add_event_cb(lambda e: self.erase_and_close(), lv.EVENT.CLICKED, None) erase_label = lv.label(erase_button) @@ -259,16 +268,22 @@ def create_basic_tab(self, tab, prefs): tab.set_style_pad_all(1, 0) # Color Mode - colormode = prefs.get_bool("colormode", False) + colormode = prefs.get_bool("colormode", False if self.scanqr_mode else True) checkbox, cont = self.create_checkbox(tab, "Color Mode (slower)", colormode, "colormode") self.ui_controls["colormode"] = checkbox # Resolution dropdown - current_resolution = prefs.get_string("resolution", "320x240") + print(f"self.scanqr_mode: {self.scanqr_mode}") + current_resolution_width = prefs.get_string("resolution_width", self.DEFAULT_SCANQR_WIDTH if self.scanqr_mode else self.DEFAULT_WIDTH) + current_resolution_height = prefs.get_string("resolution_width", self.DEFAULT_SCANQR_HEIGHT if self.scanqr_mode else self.DEFAULT_HEIGHT) + dropdown_value = f"{current_resolution_width}x{current_resolution_height}" + print(f"looking for {dropdown_value}") resolution_idx = 0 for idx, (_, value) in enumerate(self.resolutions): - if value == current_resolution: + print(f"got {value}") + if value == dropdown_value: resolution_idx = idx + print(f"found it! {idx}") break dropdown, cont = self.create_dropdown(tab, "Resolution:", self.resolutions, resolution_idx, "resolution") @@ -520,7 +535,7 @@ def create_raw_tab(self, tab, prefs): self.add_buttons(tab) def erase_and_close(self): - SharedPreferences(self.PACKAGE).edit().remove_all().commit() + self.prefs.edit().remove_all().commit() self.setResult(True, {"settings_changed": True}) self.finish() @@ -550,9 +565,14 @@ def save_and_close(self): selected_idx = control.get_selected() option_values = metadata.get("option_values", []) if pref_key == "resolution": - # Resolution stored as string - value = option_values[selected_idx] - editor.put_string(pref_key, value) + try: + # Resolution stored as 2 ints + value = option_values[selected_idx] + width_str, height_str = value.split('x') + editor.put_int("resolution_width", int(width_str)) + editor.put_int("resolution_height", int(height_str)) + except Exception as e: + print(f"Error parsing resolution '{value}': {e}") else: # Other dropdowns store integer enum values value = option_values[selected_idx] diff --git a/internal_filesystem/lib/mpos/config.py b/internal_filesystem/lib/mpos/config.py index 99821c3..dd626d6 100644 --- a/internal_filesystem/lib/mpos/config.py +++ b/internal_filesystem/lib/mpos/config.py @@ -28,7 +28,7 @@ def load(self): try: with open(self.filepath, 'r') as f: self.data = ujson.load(f) - print(f"load: Loaded preferences: {self.data}") + print(f"load: Loaded preferences from {self.filepath}: {self.data}") except Exception as e: print(f"SharedPreferences.load didn't find preferences: {e}") self.data = {} From 01faf1d20bafbf86e53e1c303e9c9b477d81fafc Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 23:14:04 +0100 Subject: [PATCH 073/192] Set specific defaults for QR scanning --- .../apps/com.micropythonos.camera/assets/camera_app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 5249c2d..ca3de6e 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -497,7 +497,7 @@ def apply_camera_settings(self, cam, use_webcam): aec_value = self.prefs.get_int("aec_value", 300) cam.set_aec_value(aec_value) - ae_level = self.prefs.get_int("ae_level", 0) + ae_level = self.prefs.get_int("ae_level", 2 if self.scanqr_mode else 0) cam.set_ae_level(ae_level) aec2 = self.prefs.get_bool("aec2", False) @@ -530,13 +530,13 @@ def apply_camera_settings(self, cam, use_webcam): sharpness = self.prefs.get_int("sharpness", 0) cam.set_sharpness(sharpness) except: - pass # Not supported on OV2640 + pass # Not supported on OV2640? try: denoise = self.prefs.get_int("denoise", 0) cam.set_denoise(denoise) except: - pass # Not supported on OV2640 + pass # Not supported on OV2640? # Advanced corrections colorbar = self.prefs.get_bool("colorbar", False) @@ -551,7 +551,7 @@ def apply_camera_settings(self, cam, use_webcam): wpc = self.prefs.get_bool("wpc", True) cam.set_wpc(wpc) - raw_gma = self.prefs.get_bool("raw_gma", True) + raw_gma = self.prefs.get_bool("raw_gma", False if self.scanqr_mode else True) print(f"applying raw_gma: {raw_gma}") cam.set_raw_gma(raw_gma) From eff01581aae4cd359d9506276576d9713d411732 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 30 Nov 2025 23:17:54 +0100 Subject: [PATCH 074/192] About app: more robust --- .../com.micropythonos.about/assets/about.py | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) 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 7ec5cce..00c9767 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py @@ -87,24 +87,30 @@ def onCreate(self): label11.set_text(f"freezefs_mount_builtin exception (normal on dev builds): {e}") # Disk usage: import os - stat = os.statvfs('/') - total_space = stat[0] * stat[2] - free_space = stat[0] * stat[3] - used_space = total_space - free_space - label20 = lv.label(screen) - label20.set_text(f"Total space in /: {total_space} bytes") - label21 = lv.label(screen) - label21.set_text(f"Free space in /: {free_space} bytes") - label22 = lv.label(screen) - label22.set_text(f"Used space in /: {used_space} bytes") - stat = os.statvfs('/sdcard') - total_space = stat[0] * stat[2] - free_space = stat[0] * stat[3] - used_space = total_space - free_space - label23 = lv.label(screen) - label23.set_text(f"Total space /sdcard: {total_space} bytes") - label24 = lv.label(screen) - label24.set_text(f"Free space /sdcard: {free_space} bytes") - label25 = lv.label(screen) - label25.set_text(f"Used space /sdcard: {used_space} bytes") + try: + stat = os.statvfs('/') + total_space = stat[0] * stat[2] + free_space = stat[0] * stat[3] + used_space = total_space - free_space + label20 = lv.label(screen) + label20.set_text(f"Total space in /: {total_space} bytes") + label21 = lv.label(screen) + label21.set_text(f"Free space in /: {free_space} bytes") + label22 = lv.label(screen) + label22.set_text(f"Used space in /: {used_space} bytes") + except Exception as e: + print(f"About app could not get info on / filesystem: {e}") + try: + stat = os.statvfs('/sdcard') + total_space = stat[0] * stat[2] + free_space = stat[0] * stat[3] + used_space = total_space - free_space + label23 = lv.label(screen) + label23.set_text(f"Total space /sdcard: {total_space} bytes") + label24 = lv.label(screen) + label24.set_text(f"Free space /sdcard: {free_space} bytes") + label25 = lv.label(screen) + label25.set_text(f"Used space /sdcard: {used_space} bytes") + except Exception as e: + print(f"About app could not get info on /sdcard filesystem: {e}") self.setContentView(screen) From 4a8f11dc80efebc0ae0ab35f907a1322d69828b6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 09:05:06 +0100 Subject: [PATCH 075/192] Camera app: use correct preferences argument --- .../assets/camera_app.py | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index ca3de6e..39b732d 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -176,8 +176,9 @@ def stop_cam(self): i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) except Exception as e: print(f"Warning: powering off camera got exception: {e}") - print("emptying self.current_cam_buffer...") - self.image_dsc.data = None # it's important to delete the image when stopping the camera, otherwise LVGL might try to display it and crash + if self.image_dsc: # it's important to delete the image when stopping the camera, otherwise LVGL might try to display it and crash + print("emptying self.current_cam_buffer...") + self.image_dsc.data = None def load_settings_cached(self): from mpos.config import SharedPreferences @@ -453,7 +454,7 @@ def remove_bom(self, buffer): return buffer - def apply_camera_settings(self, cam, use_webcam): + def apply_camera_settings(self, prefs, cam, use_webcam): """Apply all saved camera settings to the camera. Only applies settings when use_webcam is False (ESP32 camera). @@ -469,101 +470,101 @@ def apply_camera_settings(self, cam, use_webcam): try: # Basic image adjustments - brightness = self.prefs.get_int("brightness", 0) + brightness = prefs.get_int("brightness", 0) cam.set_brightness(brightness) - contrast = self.prefs.get_int("contrast", 0) + contrast = prefs.get_int("contrast", 0) cam.set_contrast(contrast) - saturation = self.prefs.get_int("saturation", 0) + saturation = prefs.get_int("saturation", 0) cam.set_saturation(saturation) # Orientation - hmirror = self.prefs.get_bool("hmirror", False) + hmirror = prefs.get_bool("hmirror", False) cam.set_hmirror(hmirror) - vflip = self.prefs.get_bool("vflip", True) + vflip = prefs.get_bool("vflip", True) cam.set_vflip(vflip) # Special effect - special_effect = self.prefs.get_int("special_effect", 0) + special_effect = prefs.get_int("special_effect", 0) cam.set_special_effect(special_effect) # Exposure control (apply master switch first, then manual value) - exposure_ctrl = self.prefs.get_bool("exposure_ctrl", True) + exposure_ctrl = prefs.get_bool("exposure_ctrl", True) cam.set_exposure_ctrl(exposure_ctrl) if not exposure_ctrl: - aec_value = self.prefs.get_int("aec_value", 300) + aec_value = prefs.get_int("aec_value", 300) cam.set_aec_value(aec_value) - ae_level = self.prefs.get_int("ae_level", 2 if self.scanqr_mode else 0) + ae_level = prefs.get_int("ae_level", 2 if self.scanqr_mode else 0) cam.set_ae_level(ae_level) - aec2 = self.prefs.get_bool("aec2", False) + aec2 = prefs.get_bool("aec2", False) cam.set_aec2(aec2) # Gain control (apply master switch first, then manual value) - gain_ctrl = self.prefs.get_bool("gain_ctrl", True) + gain_ctrl = prefs.get_bool("gain_ctrl", True) cam.set_gain_ctrl(gain_ctrl) if not gain_ctrl: - agc_gain = self.prefs.get_int("agc_gain", 0) + agc_gain = prefs.get_int("agc_gain", 0) cam.set_agc_gain(agc_gain) - gainceiling = self.prefs.get_int("gainceiling", 0) + gainceiling = prefs.get_int("gainceiling", 0) cam.set_gainceiling(gainceiling) # White balance (apply master switch first, then mode) - whitebal = self.prefs.get_bool("whitebal", True) + whitebal = prefs.get_bool("whitebal", True) cam.set_whitebal(whitebal) if not whitebal: - wb_mode = self.prefs.get_int("wb_mode", 0) + wb_mode = prefs.get_int("wb_mode", 0) cam.set_wb_mode(wb_mode) - awb_gain = self.prefs.get_bool("awb_gain", True) + awb_gain = prefs.get_bool("awb_gain", True) cam.set_awb_gain(awb_gain) # Sensor-specific settings (try/except for unsupported sensors) try: - sharpness = self.prefs.get_int("sharpness", 0) + sharpness = prefs.get_int("sharpness", 0) cam.set_sharpness(sharpness) except: pass # Not supported on OV2640? try: - denoise = self.prefs.get_int("denoise", 0) + denoise = prefs.get_int("denoise", 0) cam.set_denoise(denoise) except: pass # Not supported on OV2640? # Advanced corrections - colorbar = self.prefs.get_bool("colorbar", False) + colorbar = prefs.get_bool("colorbar", False) cam.set_colorbar(colorbar) - dcw = self.prefs.get_bool("dcw", True) + dcw = prefs.get_bool("dcw", True) cam.set_dcw(dcw) - bpc = self.prefs.get_bool("bpc", False) + bpc = prefs.get_bool("bpc", False) cam.set_bpc(bpc) - wpc = self.prefs.get_bool("wpc", True) + wpc = prefs.get_bool("wpc", True) cam.set_wpc(wpc) - raw_gma = self.prefs.get_bool("raw_gma", False if self.scanqr_mode else True) + raw_gma = prefs.get_bool("raw_gma", False if self.scanqr_mode else True) print(f"applying raw_gma: {raw_gma}") cam.set_raw_gma(raw_gma) - lenc = self.prefs.get_bool("lenc", True) + lenc = prefs.get_bool("lenc", True) cam.set_lenc(lenc) # JPEG quality (only relevant for JPEG format) - try: - quality = self.prefs.get_int("quality", 85) - cam.set_quality(quality) - except: - pass # Not in JPEG mode + #try: + # quality = prefs.get_int("quality", 85) + # cam.set_quality(quality) + #except: + # pass # Not in JPEG mode print("Camera settings applied successfully") From df5152697535def0e30eb41662e3870294b88416 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 09:23:51 +0100 Subject: [PATCH 076/192] Camera app: reduce vertical screen usage --- .../assets/camera_settings.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index 36eeac4..b84133b 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -165,16 +165,17 @@ def create_checkbox(self, parent, label_text, default_val, pref_key): def create_dropdown(self, parent, label_text, options, default_idx, pref_key): """Create dropdown with label.""" cont = lv.obj(parent) - cont.set_size(lv.pct(100), 60) - cont.set_style_pad_all(3, 0) + cont.set_size(lv.pct(100), lv.SIZE_CONTENT) + cont.set_style_pad_all(2, 0) label = lv.label(cont) label.set_text(label_text) - label.align(lv.ALIGN.TOP_LEFT, 0, 0) + label.set_size(lv.pct(50), lv.SIZE_CONTENT) + label.align(lv.ALIGN.LEFT_MID, 0, 0) dropdown = lv.dropdown(cont) - dropdown.set_size(lv.pct(90), 30) - dropdown.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) + dropdown.set_size(lv.pct(50), lv.SIZE_CONTENT) + dropdown.align(lv.ALIGN.RIGHT_MID, 0, 0) options_str = "\n".join([text for text, _ in options]) dropdown.set_options(options_str) From 32603cde8e5b6a1d1c23f5e0561f273ec17ba4fc Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 09:42:37 +0100 Subject: [PATCH 077/192] Fix scanqr intent handling --- .../assets/camera_app.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 39b732d..31f9eb3 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -50,7 +50,6 @@ class CameraApp(Activity): status_label_cont = None def onCreate(self): - self.scanqr_intent = self.getIntent().extras.get("scanqr_intent") self.main_screen = lv.obj() self.main_screen.set_style_pad_all(1, 0) self.main_screen.set_style_border_width(0, 0) @@ -115,16 +114,16 @@ def onCreate(self): self.setContentView(self.main_screen) def onResume(self, screen): - self.load_settings_cached() - self.start_cam() - if not self.cam and self.scanqr_mode: - print("No camera found, stopping camera app") - self.finish() - # Camera is running and refreshing + self.scanqr_intent = self.getIntent().extras.get("scanqr_intent") self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) - if self.scanqr_mode: + if self.scanqr_mode or self.scanqr_intent: self.start_qr_decoding() + if not self.cam and self.scanqr_mode: + print("No camera found, stopping camera app") + self.finish() else: + self.load_settings_cached() + self.start_cam() self.qr_button.remove_flag(lv.obj.FLAG.HIDDEN) self.snap_button.remove_flag(lv.obj.FLAG.HIDDEN) @@ -176,6 +175,7 @@ def stop_cam(self): i2c.writeto(camera_addr, bytes([reg_high, reg_low, power_off_command])) except Exception as e: print(f"Warning: powering off camera got exception: {e}") + self.cam = None if self.image_dsc: # it's important to delete the image when stopping the camera, otherwise LVGL might try to display it and crash print("emptying self.current_cam_buffer...") self.image_dsc.data = None @@ -286,8 +286,9 @@ def start_qr_decoding(self): # Activate QR mode settings self.load_settings_cached() # Check if it's necessary to restart the camera: - if self.width != oldwidth or self.height != oldheight or self.colormode != oldcolormode: - self.stop_cam() + if not self.cam or self.width != oldwidth or self.height != oldheight or self.colormode != oldcolormode: + if self.cam: + self.stop_cam() self.start_cam() self.qr_label.set_text(lv.SYMBOL.EYE_CLOSE) self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) From b20b64173c963af6c60d864efe9217c680e32171 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 09:55:58 +0100 Subject: [PATCH 078/192] Camera app: improve button layout --- .../assets/camera_app.py | 91 ++++++++++--------- .../assets/camera_settings.py | 2 +- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 31f9eb3..780ef49 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -18,8 +18,8 @@ class CameraApp(Activity): CONFIGFILE = "config.json" SCANQR_CONFIG = "config_scanqr_mode.json" - button_width = 60 - button_height = 45 + button_width = 75 + button_height = 50 status_label_text = "No camera found." status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and make the QR code big (4-12cm). Ensure proper lighting." @@ -68,26 +68,18 @@ def onCreate(self): # Settings button settings_button = lv.button(self.main_screen) settings_button.set_size(self.button_width, self.button_height) - settings_button.align(lv.ALIGN.TOP_RIGHT, 0, self.button_height + 5) + settings_button.align_to(close_button, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) settings_label = lv.label(settings_button) settings_label.set_text(lv.SYMBOL.SETTINGS) settings_label.center() settings_button.add_event_cb(lambda e: self.open_settings(),lv.EVENT.CLICKED,None) - self.snap_button = lv.button(self.main_screen) - self.snap_button.set_size(self.button_width, self.button_height) - self.snap_button.align(lv.ALIGN.RIGHT_MID, 0, 0) - self.snap_button.add_flag(lv.obj.FLAG.HIDDEN) - self.snap_button.add_event_cb(self.snap_button_click,lv.EVENT.CLICKED,None) - snap_label = lv.label(self.snap_button) - snap_label.set_text(lv.SYMBOL.OK) - snap_label.center() - self.zoom_button = lv.button(self.main_screen) - self.zoom_button.set_size(self.button_width, self.button_height) - self.zoom_button.align(lv.ALIGN.RIGHT_MID, 0, self.button_height + 5) - self.zoom_button.add_event_cb(self.zoom_button_click,lv.EVENT.CLICKED,None) - zoom_label = lv.label(self.zoom_button) - zoom_label.set_text("Z") - zoom_label.center() + #self.zoom_button = lv.button(self.main_screen) + #self.zoom_button.set_size(self.button_width, self.button_height) + #self.zoom_button.align(lv.ALIGN.RIGHT_MID, 0, self.button_height + 5) + #self.zoom_button.add_event_cb(self.zoom_button_click,lv.EVENT.CLICKED,None) + #zoom_label = lv.label(self.zoom_button) + #zoom_label.set_text("Z") + #zoom_label.center() self.qr_button = lv.button(self.main_screen) self.qr_button.set_size(self.button_width, self.button_height) self.qr_button.add_flag(lv.obj.FLAG.HIDDEN) @@ -96,6 +88,17 @@ def onCreate(self): self.qr_label = lv.label(self.qr_button) self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) self.qr_label.center() + + self.snap_button = lv.button(self.main_screen) + self.snap_button.set_size(self.button_width, self.button_height) + self.snap_button.align_to(self.qr_button, lv.ALIGN.OUT_TOP_MID, 0, -10) + self.snap_button.add_flag(lv.obj.FLAG.HIDDEN) + self.snap_button.add_event_cb(self.snap_button_click,lv.EVENT.CLICKED,None) + snap_label = lv.label(self.snap_button) + snap_label.set_text(lv.SYMBOL.OK) + snap_label.center() + + self.status_label_cont = lv.obj(self.main_screen) width = mpos.ui.pct_of_display_width(70) height = mpos.ui.pct_of_display_width(60) @@ -318,36 +321,15 @@ def qr_button_click(self, e): else: self.stop_qr_decoding() - def zoom_button_click(self, e): - print("zooming...") - if self.use_webcam: - print("zoom_button_click is not supported for webcam") - return - if self.cam: - startX = self.prefs.get_int("startX", CameraSettingsActivity.startX_default) - startY = self.prefs.get_int("startX", CameraSettingsActivity.startY_default) - endX = self.prefs.get_int("startX", CameraSettingsActivity.endX_default) - endY = self.prefs.get_int("startX", CameraSettingsActivity.endY_default) - offsetX = self.prefs.get_int("startX", CameraSettingsActivity.offsetX_default) - offsetY = self.prefs.get_int("startX", CameraSettingsActivity.offsetY_default) - totalX = self.prefs.get_int("startX", CameraSettingsActivity.totalX_default) - totalY = self.prefs.get_int("startX", CameraSettingsActivity.totalY_default) - outputX = self.prefs.get_int("startX", CameraSettingsActivity.outputX_default) - outputY = self.prefs.get_int("startX", CameraSettingsActivity.outputY_default) - scale = self.prefs.get_bool("scale", CameraSettingsActivity.scale_default) - binning = self.prefs.get_bool("binning", CameraSettingsActivity.binning_default) - result = self.cam.set_res_raw(startX,startY,endX,endY,offsetX,offsetY,totalX,totalY,outputX,outputY,scale,binning) - print(f"self.cam.set_res_raw returned {result}") - def open_settings(self): intent = Intent(activity_class=CameraSettingsActivity, extras={"prefs": self.prefs if not self.scanqr_mode else self.scanqr_prefs, "use_webcam": self.use_webcam, "scanqr_mode": self.scanqr_mode}) self.startActivity(intent) def try_capture(self, event): try: - if self.use_webcam: + if self.use_webcam and self.cam: self.current_cam_buffer = webcam.capture_frame(self.cam, "rgb565" if self.colormode else "grayscale") - elif self.cam.frame_available(): + elif self.cam and self.cam.frame_available(): self.current_cam_buffer = self.cam.capture() except Exception as e: print(f"Camera capture exception: {e}") @@ -358,7 +340,7 @@ def try_capture(self, event): self.image.set_src(self.image_dsc) if self.scanqr_mode: self.qrdecode_one() - if not self.use_webcam: + if not self.use_webcam and self.cam: self.cam.free_buffer() # After QR decoding, free the old buffer, otherwise the camera doesn't provide a new one def init_internal_cam(self, width, height): @@ -572,3 +554,28 @@ def apply_camera_settings(self, prefs, cam, use_webcam): except Exception as e: print(f"Error applying camera settings: {e}") + + + +""" + def zoom_button_click_unused(self, e): + print("zooming...") + if self.use_webcam: + print("zoom_button_click is not supported for webcam") + return + if self.cam: + startX = self.prefs.get_int("startX", CameraSettingsActivity.startX_default) + startY = self.prefs.get_int("startX", CameraSettingsActivity.startY_default) + endX = self.prefs.get_int("startX", CameraSettingsActivity.endX_default) + endY = self.prefs.get_int("startX", CameraSettingsActivity.endY_default) + offsetX = self.prefs.get_int("startX", CameraSettingsActivity.offsetX_default) + offsetY = self.prefs.get_int("startX", CameraSettingsActivity.offsetY_default) + totalX = self.prefs.get_int("startX", CameraSettingsActivity.totalX_default) + totalY = self.prefs.get_int("startX", CameraSettingsActivity.totalY_default) + outputX = self.prefs.get_int("startX", CameraSettingsActivity.outputX_default) + outputY = self.prefs.get_int("startX", CameraSettingsActivity.outputY_default) + scale = self.prefs.get_bool("scale", CameraSettingsActivity.scale_default) + binning = self.prefs.get_bool("binning", CameraSettingsActivity.binning_default) + result = self.cam.set_res_raw(startX,startY,endX,endY,offsetX,offsetY,totalX,totalY,outputX,outputY,scale,binning) + print(f"self.cam.set_res_raw returned {result}") +""" diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index b84133b..0c87415 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -276,7 +276,7 @@ def create_basic_tab(self, tab, prefs): # Resolution dropdown print(f"self.scanqr_mode: {self.scanqr_mode}") current_resolution_width = prefs.get_string("resolution_width", self.DEFAULT_SCANQR_WIDTH if self.scanqr_mode else self.DEFAULT_WIDTH) - current_resolution_height = prefs.get_string("resolution_width", self.DEFAULT_SCANQR_HEIGHT if self.scanqr_mode else self.DEFAULT_HEIGHT) + current_resolution_height = prefs.get_string("resolution_height", self.DEFAULT_SCANQR_HEIGHT if self.scanqr_mode else self.DEFAULT_HEIGHT) dropdown_value = f"{current_resolution_width}x{current_resolution_height}" print(f"looking for {dropdown_value}") resolution_idx = 0 From ed860a38ffa1e1fad2f766e755e8e5e5cd7a4f5e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 11:23:34 +0100 Subject: [PATCH 079/192] ImageView app: bigger buttons --- .../apps/com.micropythonos.imageview/assets/imageview.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py index ab51b89..f717308 100644 --- a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py +++ b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py @@ -21,6 +21,7 @@ class ImageView(Activity): def onCreate(self): screen = lv.obj() + screen.remove_flag(lv.obj.FLAG.SCROLLABLE) self.image = lv.image(screen) self.image.center() self.image.add_flag(lv.obj.FLAG.CLICKABLE) @@ -39,6 +40,7 @@ def onCreate(self): self.prev_button.add_event_cb(lambda e: self.show_prev_image(),lv.EVENT.CLICKED,None) prev_label = lv.label(self.prev_button) prev_label.set_text(lv.SYMBOL.LEFT) + prev_label.set_style_text_font(lv.font_montserrat_16, 0) self.play_button = lv.button(screen) self.play_button.align(lv.ALIGN.BOTTOM_MID,0,0) self.play_button.set_style_opa(lv.OPA.TRANSP, 0) @@ -55,6 +57,7 @@ def onCreate(self): self.next_button.add_event_cb(lambda e: self.show_next_image(),lv.EVENT.CLICKED,None) next_label = lv.label(self.next_button) next_label.set_text(lv.SYMBOL.RIGHT) + next_label.set_style_text_font(lv.font_montserrat_16, 0) #screen.add_event_cb(self.print_events, lv.EVENT.ALL, None) self.setContentView(screen) @@ -216,6 +219,7 @@ def show_image(self, name): cf = lv.COLOR_FORMAT.RGB565 if color_format == "GRAY": cf = lv.COLOR_FORMAT.L8 + stride = width else: print(f"WARNING: unknown color format {color_format}, assuming RGB565...") self.current_image_dsc = lv.image_dsc_t({ From 9270c9ae9aa2f18d797cf6e897033336febca6e8 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 11:51:54 +0100 Subject: [PATCH 080/192] ImageView app: add delete button --- .../assets/imageview.py | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py index f717308..4433b50 100644 --- a/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py +++ b/internal_filesystem/apps/com.micropythonos.imageview/assets/imageview.py @@ -34,6 +34,7 @@ def onCreate(self): self.label = lv.label(screen) self.label.set_text(f"Loading images from\n{self.imagedir}") self.label.align(lv.ALIGN.TOP_MID,0,0) + self.label.set_width(lv.pct(80)) self.prev_button = lv.button(screen) self.prev_button.align(lv.ALIGN.BOTTOM_LEFT,0,0) self.prev_button.add_event_cb(lambda e: self.show_prev_image_if_fullscreen(),lv.EVENT.FOCUSED,None) @@ -50,6 +51,12 @@ def onCreate(self): #self.play_button.add_event_cb(lambda e: self.play(),lv.EVENT.CLICKED,None) #play_label = lv.label(self.play_button) #play_label.set_text(lv.SYMBOL.PLAY) + self.delete_button = lv.button(screen) + self.delete_button.align(lv.ALIGN.BOTTOM_MID,0,0) + self.delete_button.add_event_cb(lambda e: self.delete_image(),lv.EVENT.CLICKED,None) + delete_label = lv.label(self.delete_button) + delete_label.set_text(lv.SYMBOL.TRASH) + delete_label.set_style_text_font(lv.font_montserrat_16, 0) self.next_button = lv.button(screen) self.next_button.align(lv.ALIGN.BOTTOM_RIGHT,0,0) #self.next_button.add_event_cb(self.print_events, lv.EVENT.ALL, None) @@ -79,10 +86,12 @@ def onResume(self, screen): self.images.append(fullname) self.images.sort() - # Begin with one image: - self.show_next_image() - self.stop_fullscreen() - #self.image_timer = lv.timer_create(self.show_next_image, 1000, None) + if len(self.images) == 0: + self.no_image_mode() + else: + # Begin with one image: + self.show_next_image() + self.stop_fullscreen() except Exception as e: print(f"ImageView encountered exception for {self.imagedir}: {e}") @@ -93,9 +102,16 @@ def onStop(self, screen): print("ImageView: deleting image_timer") self.image_timer.delete() + def no_image_mode(self): + self.label.set_text(f"No images found in {self.imagedir}...") + mpos.ui.anim.smooth_hide(self.prev_button) + mpos.ui.anim.smooth_hide(self.delete_button) + mpos.ui.anim.smooth_hide(self.next_button) + def show_prev_image(self, event=None): print("showing previous image...") if len(self.images) < 1: + self.no_image_mode() return if self.image_nr is None or self.image_nr == 0: self.image_nr = len(self.images) - 1 @@ -119,6 +135,7 @@ def stop_fullscreen(self): print("stopping fullscreen") mpos.ui.anim.smooth_show(self.label) mpos.ui.anim.smooth_show(self.prev_button) + mpos.ui.anim.smooth_show(self.delete_button) #mpos.ui.anim.smooth_show(self.play_button) self.play_button.add_flag(lv.obj.FLAG.HIDDEN) # make it not accepting focus mpos.ui.anim.smooth_show(self.next_button) @@ -127,6 +144,7 @@ def start_fullscreen(self): print("starting fullscreen") mpos.ui.anim.smooth_hide(self.label) mpos.ui.anim.smooth_hide(self.prev_button, hide=False) + mpos.ui.anim.smooth_hide(self.delete_button, hide=False) #mpos.ui.anim.smooth_hide(self.play_button, hide=False) self.play_button.remove_flag(lv.obj.FLAG.HIDDEN) # make it accepting focus mpos.ui.anim.smooth_hide(self.next_button, hide=False) @@ -170,6 +188,7 @@ def unfocus(self): def show_next_image(self, event=None): print("showing next image...") if len(self.images) < 1: + self.no_image_mode() return if self.image_nr is None or self.image_nr >= len(self.images) - 1: self.image_nr = 0 @@ -179,6 +198,16 @@ def show_next_image(self, event=None): print(f"show_next_image showing {name}") self.show_image(name) + def delete_image(self, event=None): + filename = self.images[self.image_nr] + try: + os.remove(filename) + self.clear_image() + self.label.set_text(f"Deleted\n{filename}") + del self.images[self.image_nr] + except Exception as e: + print(f"Error deleting {filename}: {e}") + def extract_dimensions_and_format(self, filename): # Split the filename by '_' parts = filename.split('_') @@ -191,6 +220,7 @@ def extract_dimensions_and_format(self, filename): return width, height, color_format.upper() def show_image(self, name): + self.current_image = name try: self.label.set_text(name) self.clear_image() @@ -220,7 +250,7 @@ def show_image(self, name): if color_format == "GRAY": cf = lv.COLOR_FORMAT.L8 stride = width - else: + elif color_format != "RGB565": print(f"WARNING: unknown color format {color_format}, assuming RGB565...") self.current_image_dsc = lv.image_dsc_t({ "header": { @@ -242,7 +272,7 @@ def scale_image(self): if self.fullscreen: pct = 100 else: - pct = 90 + pct = 70 lvgl_w = mpos.ui.pct_of_display_width(pct) lvgl_h = mpos.ui.pct_of_display_height(pct) print(f"scaling to size: {lvgl_w}x{lvgl_h}") @@ -265,6 +295,7 @@ def scale_image(self): def clear_image(self): """Clear current image or GIF source to free memory.""" + self.image.set_src(None) #if self.current_image_dsc: # self.current_image_dsc = None # Release reference to descriptor #self.image.set_src(None) # Clear image source From 35cd1b9a3963b61b022bb7b9b8c77fc688269265 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 12:01:15 +0100 Subject: [PATCH 081/192] Camera app: check enough free space --- CHANGELOG.md | 4 ++++ .../com.micropythonos.camera/assets/camera_app.py | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7494f78..9254409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - Fri3d Camp 2024 Badge: improve battery monitor calibration to fix 0.1V delta - About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name +- Camera app: massive overhaul! + - Lots of settings (basic, advanced, expert) + - Enable high density QR code scanning from mobile phone screens +- ImageView app: add delete functionality - OSUpdate app: pause download when wifi is lost, resume when reconnected - Settings app: fix un-checking of radio button - ImageView app: add support for grayscale images diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 780ef49..12f4d49 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -271,12 +271,24 @@ def snap_button_click(self, e): if self.current_cam_buffer is None: print("snap_button_click: won't save empty image") return + # Check enough free space? + stat = os.statvfs("data/images") + free_space = stat[0] * stat[3] + size_needed = len(self.current_cam_buffer) + print(f"Free space {free_space} and size needed {size_needed}") + if free_space < size_needed: + self.status_label.set_text(f"Free storage space is {free_space}, need {size_needed}, not saving...") + self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) + return colorname = "RGB565" if self.colormode else "GRAY" filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" try: with open(filename, 'wb') as f: f.write(self.current_cam_buffer) # This takes around 17 seconds to store 921600 bytes, so ~50KB/s, so would be nice to show some progress bar - print(f"Successfully wrote image to {filename}") + report = f"Successfully wrote image to {filename}" + print(report) + self.status_label.set_text(report) + self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) except OSError as e: print(f"Error writing to file: {e}") From 031d502e3746ad20f967fe1893b08f46fe9c124e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 12:08:25 +0100 Subject: [PATCH 082/192] Fix tests/test_graphical_camera_settings.py --- tests/test_graphical_camera_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_graphical_camera_settings.py b/tests/test_graphical_camera_settings.py index ab75afa..9ccd795 100644 --- a/tests/test_graphical_camera_settings.py +++ b/tests/test_graphical_camera_settings.py @@ -113,7 +113,7 @@ def test_settings_button_click_no_crash(self): # 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 = 60 # 60px down from top, center of 60px button + settings_y = 100 # 60px down from top, center of 60px button print(f"\nClicking settings button at ({settings_x}, {settings_y})") simulate_click(settings_x, settings_y, press_duration_ms=100) From 4cf2dbf1b8e22fda577a749b8d94ecb855d35f91 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 12:39:13 +0100 Subject: [PATCH 083/192] Camera app: don't repeat yourself --- .../apps/com.micropythonos.camera/assets/camera_app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 12f4d49..054016d 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -260,12 +260,13 @@ def snap_button_click(self, e): print("Taking picture...") # Would be nice to check that there's enough free space here, and show an error if not... import os + path = "data/images" try: os.mkdir("data") except OSError: pass try: - os.mkdir("data/images") + os.mkdir(path) except OSError: pass if self.current_cam_buffer is None: @@ -281,7 +282,7 @@ def snap_button_click(self, e): self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) return colorname = "RGB565" if self.colormode else "GRAY" - filename=f"data/images/camera_capture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" + filename=f"{path}/picture_{mpos.time.epoch_seconds()}_{self.width}x{self.height}_{colorname}.raw" try: with open(filename, 'wb') as f: f.write(self.current_cam_buffer) # This takes around 17 seconds to store 921600 bytes, so ~50KB/s, so would be nice to show some progress bar From 39c92ec903e1347765ff74c7c8975eb3c0ca4ba6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 13:21:23 +0100 Subject: [PATCH 084/192] Increment version number --- internal_filesystem/lib/mpos/info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/info.py b/internal_filesystem/lib/mpos/info.py index fc1e04e..22bb09c 100644 --- a/internal_filesystem/lib/mpos/info.py +++ b/internal_filesystem/lib/mpos/info.py @@ -1,4 +1,4 @@ -CURRENT_OS_VERSION = "0.5.0" +CURRENT_OS_VERSION = "0.5.1" # Unique string that defines the hardware, used by OSUpdate and the About app _hardware_id = "missing-hardware-info" From 7def3b3bb365923b7fc53641c73c6620917d3ba4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 1 Dec 2025 19:08:21 +0100 Subject: [PATCH 085/192] Camera app: Fix status label text handling --- .../assets/camera_app.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 054016d..e7d5185 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -21,9 +21,9 @@ class CameraApp(Activity): button_width = 75 button_height = 50 - status_label_text = "No camera found." - status_label_text_searching = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and make the QR code big (4-12cm). Ensure proper lighting." - status_label_text_found = "Found QR, trying to decode... hold still..." + STATUS_NO_CAMERA = "No camera found." + STATUS_SEARCHING_QR = "Searching QR codes...\n\nHold still and try varying scan distance (10-25cm) and make the QR code big (4-12cm). Ensure proper lighting." + STATUS_FOUND_QR = "Found QR, trying to decode... hold still..." cam = None current_cam_buffer = None # Holds the current memoryview to prevent garba @@ -110,7 +110,7 @@ def onCreate(self): self.status_label_cont.set_style_bg_opa(66, 0) self.status_label_cont.set_style_border_width(0, 0) self.status_label = lv.label(self.status_label_cont) - self.status_label.set_text("No camera found.") + self.status_label.set_text(self.STATUS_NO_CAMERA) self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) self.status_label.set_width(lv.pct(100)) self.status_label.center() @@ -237,10 +237,10 @@ def qrdecode_one(self): print(f"qrdecode took {after-before}ms") except ValueError as e: print("QR ValueError: ", e) - self.status_label.set_text(self.status_label_text_searching) + self.status_label.set_text(self.STATUS_SEARCHING_QR) except TypeError as e: print("QR TypeError: ", e) - self.status_label.set_text(self.status_label_text_found) + self.status_label.set_text(self.STATUS_FOUND_QR) except Exception as e: print("QR got other error: ", e) #result = bytearray("INSERT_TEST_QR_DATA_HERE", "utf-8") @@ -308,14 +308,15 @@ def start_qr_decoding(self): self.start_cam() self.qr_label.set_text(lv.SYMBOL.EYE_CLOSE) self.status_label_cont.remove_flag(lv.obj.FLAG.HIDDEN) - self.status_label.set_text(self.status_label_text_searching) + self.status_label.set_text(self.STATUS_SEARCHING_QR) def stop_qr_decoding(self): print("Deactivating live QR decoding...") self.scanqr_mode = False self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) - self.status_label_text = self.status_label.get_text() - if self.status_label_text not in (self.status_label_text_searching or self.status_label_text_found): # if it found a QR code, leave it + status_label_text = self.status_label.get_text() + if status_label_text in (self.STATUS_NO_CAMERA or self.STATUS_SEARCHING_QR or self.STATUS_FOUND_QR): # if it found a QR code, leave it + print(f"status label text {status_label_text} is a known message, not a QR code, hiding it...") self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) # Check if it's necessary to restart the camera: oldwidth = self.width From 00d0cb1952ece803ff51da4ccbb5631ee3ec0f63 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 2 Dec 2025 11:51:55 +0100 Subject: [PATCH 086/192] Update CHANGELOG --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9254409..a9cadaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,18 @@ ===== - Fri3d Camp 2024 Badge: workaround ADC2+WiFi conflict by temporarily disable WiFi to measure battery level - Fri3d Camp 2024 Badge: improve battery monitor calibration to fix 0.1V delta +- API: improve and cleanup animations +- API: SharedPreferences: add erase_all() function - About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name - Camera app: massive overhaul! - Lots of settings (basic, advanced, expert) - - Enable high density QR code scanning from mobile phone screens + - Enable decoding of high density QR codes (like Nostr Wallet Connect) from small sizes (like mobile phone screens) + - Even dotted, logo-ridden and scratched *pictures* of QR codes are now decoded properly! - ImageView app: add delete functionality +- ImageView app: add support for grayscale images - OSUpdate app: pause download when wifi is lost, resume when reconnected - Settings app: fix un-checking of radio button -- ImageView app: add support for grayscale images -- API: SharedPreferences: add erase_all() functionality -- API: improve and cleanup animations 0.5.0 ===== From 27d1af9931384ab43cfc0f9424819a34d6033496 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 2 Dec 2025 12:08:47 +0100 Subject: [PATCH 087/192] API: add defaults handling to SharedPreferences and only save non-defaults --- CLAUDE.md | 24 +- .../assets/camera_app.py | 2 +- .../assets/camera_settings.py | 8 +- internal_filesystem/lib/mpos/config.py | 95 ++++++-- tests/test_shared_preferences.py | 209 ++++++++++++++++++ 5 files changed, 320 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a8f4917..28a8296 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -410,7 +410,7 @@ Current stable version: 0.3.3 (as of latest CHANGELOG entry) ```python from mpos.config import SharedPreferences -# Load preferences +# Basic usage prefs = SharedPreferences("com.example.myapp") value = prefs.get_string("key", "default_value") number = prefs.get_int("count", 0) @@ -422,6 +422,28 @@ editor.put_string("key", "value") editor.put_int("count", 42) editor.put_dict("data", {"key": "value"}) editor.commit() + +# Using constructor defaults (reduces config file size) +# Values matching defaults are not saved to disk +prefs = SharedPreferences("com.example.myapp", defaults={ + "brightness": -1, + "volume": 50, + "theme": "dark" +}) + +# Returns constructor default (-1) if not stored +brightness = prefs.get_int("brightness") # Returns -1 + +# Method defaults override constructor defaults +brightness = prefs.get_int("brightness", 100) # Returns 100 + +# Stored values override all defaults +prefs.edit().put_int("brightness", 75).commit() +brightness = prefs.get_int("brightness") # Returns 75 + +# Setting to default value removes it from storage (auto-cleanup) +prefs.edit().put_int("brightness", -1).commit() +# brightness is no longer stored in config.json, saves space ``` **Intent system**: Launch activities and pass data diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index e7d5185..ee6dc78 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -467,7 +467,7 @@ def apply_camera_settings(self, prefs, cam, use_webcam): try: # Basic image adjustments - brightness = prefs.get_int("brightness", 0) + brightness = prefs.get_int("brightness", CameraSettingsActivity.DEFAULTS.get("brightness")) cam.set_brightness(brightness) contrast = prefs.get_int("contrast", 0) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index 0c87415..7e78894 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -9,7 +9,7 @@ class CameraSettingsActivity(Activity): """Settings activity for comprehensive camera configuration.""" - DEFAULT_WIDTH = 320 # 240 would be better but webcam doesn't support this (yet) + DEFAULT_WIDTH = 240 # 240 would be better but webcam doesn't support this (yet) DEFAULT_HEIGHT = 240 DEFAULT_COLORMODE = True DEFAULT_SCANQR_WIDTH = 960 @@ -31,6 +31,10 @@ class CameraSettingsActivity(Activity): scale_default=False binning_default=False + DEFAULTS = { + "brightness": 1, + } + # Resolution options for desktop/webcam WEBCAM_RESOLUTIONS = [ ("160x120", "160x120"), @@ -291,7 +295,7 @@ def create_basic_tab(self, tab, prefs): self.ui_controls["resolution"] = dropdown # Brightness - brightness = prefs.get_int("brightness", 0) + brightness = prefs.get_int("brightness", self.DEFAULTS.get("brightness")) slider, label, cont = self.create_slider(tab, "Brightness", -2, 2, brightness, "brightness") self.ui_controls["brightness"] = slider diff --git a/internal_filesystem/lib/mpos/config.py b/internal_filesystem/lib/mpos/config.py index dd626d6..e42f45e 100644 --- a/internal_filesystem/lib/mpos/config.py +++ b/internal_filesystem/lib/mpos/config.py @@ -2,10 +2,11 @@ import os class SharedPreferences: - def __init__(self, appname, filename="config.json"): - """Initialize with appname and filename for preferences.""" + def __init__(self, appname, filename="config.json", defaults=None): + """Initialize with appname, filename, and optional defaults for preferences.""" self.appname = appname self.filename = filename + self.defaults = defaults if defaults is not None else {} self.filepath = f"data/{self.appname}/{self.filename}" self.data = {} self.load() @@ -36,31 +37,80 @@ def load(self): def get_string(self, key, default=None): """Retrieve a string value for the given key, with a default if not found.""" to_return = self.data.get(key) - if to_return is None and default is not None: - to_return = default + if to_return is None: + # Method default takes precedence + if default is not None: + to_return = default + # Fall back to constructor default + elif key in self.defaults: + to_return = self.defaults[key] return to_return def get_int(self, key, default=0): """Retrieve an integer value for the given key, with a default if not found.""" - try: - return int(self.data.get(key, default)) - except (TypeError, ValueError): + if key in self.data: + try: + return int(self.data[key]) + except (TypeError, ValueError): + return default + # Key not in stored data, check defaults + # Method default takes precedence if explicitly provided (not the hardcoded 0) + # Otherwise use constructor default if exists + if default != 0: return default + if key in self.defaults: + try: + return int(self.defaults[key]) + except (TypeError, ValueError): + return 0 + return 0 def get_bool(self, key, default=False): """Retrieve a boolean value for the given key, with a default if not found.""" - try: - return bool(self.data.get(key, default)) - except (TypeError, ValueError): + if key in self.data: + try: + return bool(self.data[key]) + except (TypeError, ValueError): + return default + # Key not in stored data, check defaults + # Method default takes precedence if explicitly provided (not the hardcoded False) + # Otherwise use constructor default if exists + if default != False: return default + if key in self.defaults: + try: + return bool(self.defaults[key]) + except (TypeError, ValueError): + return False + return False def get_list(self, key, default=None): """Retrieve a list for the given key, with a default if not found.""" - return self.data.get(key, default if default is not None else []) + if key in self.data: + return self.data[key] + # Key not in stored data, check defaults + # Method default takes precedence if provided + if default is not None: + return default + # Fall back to constructor default + if key in self.defaults: + return self.defaults[key] + # Return empty list as hardcoded fallback + return [] def get_dict(self, key, default=None): """Retrieve a dictionary for the given key, with a default if not found.""" - return self.data.get(key, default if default is not None else {}) + if key in self.data: + return self.data[key] + # Key not in stored data, check defaults + # Method default takes precedence if provided + if default is not None: + return default + # Fall back to constructor default + if key in self.defaults: + return self.defaults[key] + # Return empty dict as hardcoded fallback + return {} def edit(self): """Return an Editor object to modify preferences.""" @@ -197,14 +247,31 @@ def remove_all(self): self.temp_data = {} return self + def _filter_defaults(self, data): + """Remove keys from data that match constructor defaults.""" + if not self.preferences.defaults: + return data + + filtered = {} + for key, value in data.items(): + if key in self.preferences.defaults: + if value != self.preferences.defaults[key]: + filtered[key] = value + # else: skip saving, matches default + else: + filtered[key] = value # No default, always save + return filtered + def apply(self): """Save changes to the file asynchronously (emulated).""" - self.preferences.data = self.temp_data.copy() + filtered_data = self._filter_defaults(self.temp_data) + self.preferences.data = filtered_data self.preferences.save_config() def commit(self): """Save changes to the file synchronously.""" - self.preferences.data = self.temp_data.copy() + filtered_data = self._filter_defaults(self.temp_data) + self.preferences.data = filtered_data self.preferences.save_config() return True diff --git a/tests/test_shared_preferences.py b/tests/test_shared_preferences.py index 04c47e8..f8e2821 100644 --- a/tests/test_shared_preferences.py +++ b/tests/test_shared_preferences.py @@ -475,4 +475,213 @@ def test_large_nested_structure(self): self.assertEqual(loaded["settings"]["theme"], "dark") self.assertEqual(loaded["settings"]["limits"][2], 30) + # Tests for default values feature + def test_constructor_defaults_basic(self): + """Test that constructor defaults are returned when key is missing.""" + defaults = {"brightness": -1, "enabled": True, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # No values stored yet, should return constructor defaults + self.assertEqual(prefs.get_int("brightness"), -1) + self.assertEqual(prefs.get_bool("enabled"), True) + self.assertEqual(prefs.get_string("name"), "default") + + def test_method_default_precedence(self): + """Test that method defaults override constructor defaults.""" + defaults = {"brightness": -1, "enabled": False, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Method defaults should take precedence when different from hardcoded defaults + self.assertEqual(prefs.get_int("brightness", 50), 50) + # For booleans, we can only test when method default differs from hardcoded False + self.assertEqual(prefs.get_bool("enabled", True), True) + self.assertEqual(prefs.get_string("name", "override"), "override") + + def test_stored_value_precedence(self): + """Test that stored values override all defaults.""" + defaults = {"brightness": -1, "enabled": True, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Store some values + prefs.edit().put_int("brightness", 75).put_bool("enabled", False).put_string("name", "stored").commit() + + # Reload and verify stored values override defaults + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_int("brightness"), 75) + self.assertEqual(prefs2.get_bool("enabled"), False) + self.assertEqual(prefs2.get_string("name"), "stored") + + # Method defaults should not override stored values + self.assertEqual(prefs2.get_int("brightness", 100), 75) + self.assertEqual(prefs2.get_bool("enabled", True), False) + self.assertEqual(prefs2.get_string("name", "method"), "stored") + + def test_default_values_not_saved(self): + """Test that values matching defaults are not written to disk.""" + defaults = {"brightness": -1, "enabled": True, "name": "default"} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Set values matching defaults + prefs.edit().put_int("brightness", -1).put_bool("enabled", True).put_string("name", "default").commit() + + # Reload and verify values are returned correctly + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_int("brightness"), -1) + self.assertEqual(prefs2.get_bool("enabled"), True) + self.assertEqual(prefs2.get_string("name"), "default") + + # Verify raw data doesn't contain the keys (they weren't saved) + self.assertFalse("brightness" in prefs2.data) + self.assertFalse("enabled" in prefs2.data) + self.assertFalse("name" in prefs2.data) + + def test_cleanup_removes_defaults(self): + """Test that setting a value to its default removes it from storage.""" + defaults = {"brightness": -1} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Store a non-default value + prefs.edit().put_int("brightness", 75).commit() + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertIn("brightness", prefs2.data) + self.assertEqual(prefs2.get_int("brightness"), 75) + + # Change it back to default + prefs2.edit().put_int("brightness", -1).commit() + + # Reload and verify it's been removed from storage + prefs3 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("brightness" in prefs3.data) + self.assertEqual(prefs3.get_int("brightness"), -1) + + def test_none_as_valid_default(self): + """Test that None can be used as a constructor default value.""" + defaults = {"optional_string": None, "optional_list": None} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Should return None for these keys + self.assertIsNone(prefs.get_string("optional_string")) + self.assertIsNone(prefs.get_list("optional_list")) + + # Store some values + prefs.edit().put_string("optional_string", "value").put_list("optional_list", [1, 2]).commit() + + # Reload + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_string("optional_string"), "value") + self.assertEqual(prefs2.get_list("optional_list"), [1, 2]) + + def test_empty_collection_defaults(self): + """Test empty lists and dicts as constructor defaults.""" + defaults = {"items": [], "settings": {}} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Should return empty collections + self.assertEqual(prefs.get_list("items"), []) + self.assertEqual(prefs.get_dict("settings"), {}) + + # These should not be saved to disk + prefs.edit().put_list("items", []).put_dict("settings", {}).commit() + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("items" in prefs2.data) + self.assertFalse("settings" in prefs2.data) + + def test_defaults_with_nested_structures(self): + """Test that defaults work with complex nested structures.""" + defaults = { + "config": {"theme": "dark", "size": 12}, + "items": [1, 2, 3] + } + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Constructor defaults should work + self.assertEqual(prefs.get_dict("config"), {"theme": "dark", "size": 12}) + self.assertEqual(prefs.get_list("items"), [1, 2, 3]) + + # Exact match should not be saved + prefs.edit().put_dict("config", {"theme": "dark", "size": 12}).commit() + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("config" in prefs2.data) + + # Modified value should be saved + prefs2.edit().put_dict("config", {"theme": "light", "size": 12}).commit() + prefs3 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertIn("config", prefs3.data) + self.assertEqual(prefs3.get_dict("config")["theme"], "light") + + def test_backward_compatibility(self): + """Test that existing code without defaults parameter still works.""" + # Old style initialization (no defaults parameter) + prefs = SharedPreferences(self.test_app_name) + + # Should work exactly as before + prefs.edit().put_string("key", "value").put_int("count", 42).commit() + + prefs2 = SharedPreferences(self.test_app_name) + self.assertEqual(prefs2.get_string("key"), "value") + self.assertEqual(prefs2.get_int("count"), 42) + + def test_type_conversion_with_defaults(self): + """Test type conversion works correctly with constructor defaults.""" + defaults = {"number": -1, "flag": True} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Store string representations + prefs.edit().put_string("number", "123").put_string("flag", "false").commit() + + # get_int and get_bool should handle conversion + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + # Note: the stored values are strings, not ints/bools, so they're different from defaults + self.assertIn("number", prefs2.data) + self.assertIn("flag", prefs2.data) + + def test_multiple_editors_with_defaults(self): + """Test that multiple edit sessions work correctly with defaults.""" + defaults = {"brightness": -1, "volume": 50} + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # First editor session + editor1 = prefs.edit() + editor1.put_int("brightness", 75) + editor1.commit() + + # Second editor session + editor2 = prefs.edit() + editor2.put_int("volume", 80) + editor2.commit() + + # Verify both values + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertEqual(prefs2.get_int("brightness"), 75) + self.assertEqual(prefs2.get_int("volume"), 80) + self.assertIn("brightness", prefs2.data) + self.assertIn("volume", prefs2.data) + + # Set one back to default + prefs2.edit().put_int("brightness", -1).commit() + prefs3 = SharedPreferences(self.test_app_name, defaults=defaults) + self.assertFalse("brightness" in prefs3.data) + self.assertEqual(prefs3.get_int("brightness"), -1) + + def test_partial_defaults(self): + """Test that some keys can have defaults while others don't.""" + defaults = {"brightness": -1} # Only brightness has a default + prefs = SharedPreferences(self.test_app_name, defaults=defaults) + + # Save multiple values + prefs.edit().put_int("brightness", -1).put_int("volume", 50).put_string("name", "test").commit() + + # Reload + prefs2 = SharedPreferences(self.test_app_name, defaults=defaults) + + # brightness matches default, should not be in data + self.assertFalse("brightness" in prefs2.data) + self.assertEqual(prefs2.get_int("brightness"), -1) + + # volume and name have no defaults, should be in data + self.assertIn("volume", prefs2.data) + self.assertIn("name", prefs2.data) + self.assertEqual(prefs2.get_int("volume"), 50) + self.assertEqual(prefs2.get_string("name"), "test") + From 52a4fccd9ec86b9a8bb9293763383eefb158e8d6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 2 Dec 2025 15:00:56 +0100 Subject: [PATCH 088/192] Camera app: improve default setting handling, only save when non-default --- CHANGELOG.md | 1 + CLAUDE.md | 72 +++++++++++ .../assets/camera_app.py | 118 ++++++++++-------- .../assets/camera_settings.py | 106 +++++++++++----- 4 files changed, 217 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9cadaf..f006759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Fri3d Camp 2024 Badge: improve battery monitor calibration to fix 0.1V delta - API: improve and cleanup animations - API: SharedPreferences: add erase_all() function +- API: add defaults handling to SharedPreferences and only save non-defaults - About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name - Camera app: massive overhaul! diff --git a/CLAUDE.md b/CLAUDE.md index 28a8296..9ac1155 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -446,6 +446,78 @@ prefs.edit().put_int("brightness", -1).commit() # brightness is no longer stored in config.json, saves space ``` +**Multi-mode apps with merged defaults**: + +Apps with multiple operating modes can define separate defaults dictionaries and merge them based on the current mode. The camera app demonstrates this pattern with normal and QR scanning modes: + +```python +# Define defaults in your settings class +class CameraSettingsActivity: + # Common defaults shared by all modes + COMMON_DEFAULTS = { + "brightness": 1, + "contrast": 0, + "saturation": 0, + "hmirror": False, + "vflip": True, + # ... 20 more common settings + } + + # Normal mode specific defaults + NORMAL_DEFAULTS = { + "resolution_width": 240, + "resolution_height": 240, + "colormode": True, + "ae_level": 0, + "raw_gma": True, + } + + # QR scanning mode specific defaults + SCANQR_DEFAULTS = { + "resolution_width": 960, + "resolution_height": 960, + "colormode": False, # Grayscale for better QR detection + "ae_level": 2, # Higher exposure + "raw_gma": False, # Better contrast + } + +# Merge defaults based on mode when initializing +def load_settings(self): + if self.scanqr_mode: + # Merge common + scanqr defaults + scanqr_defaults = {} + scanqr_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) + scanqr_defaults.update(CameraSettingsActivity.SCANQR_DEFAULTS) + self.prefs = SharedPreferences( + self.PACKAGE, + filename="config_scanqr.json", + defaults=scanqr_defaults + ) + else: + # Merge common + normal defaults + normal_defaults = {} + normal_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) + normal_defaults.update(CameraSettingsActivity.NORMAL_DEFAULTS) + self.prefs = SharedPreferences( + self.PACKAGE, + defaults=normal_defaults + ) + + # Now all get_*() calls can omit default arguments + width = self.prefs.get_int("resolution_width") # Mode-specific default + brightness = self.prefs.get_int("brightness") # Common default +``` + +**Benefits of this pattern**: +- Single source of truth for all 30 camera settings defaults +- Mode-specific config files (`config.json`, `config_scanqr.json`) +- ~90% reduction in config file size (only non-default values stored) +- Eliminates hardcoded defaults throughout the codebase +- No need to pass defaults to every `get_int()`/`get_bool()` call +- Self-documenting code with clear defaults dictionaries + +**Note**: Use `dict.update()` instead of `{**dict1, **dict2}` for MicroPython compatibility (dictionary unpacking syntax not supported). + **Intent system**: Launch activities and pass data ```python from mpos.content.intent import Intent diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index ee6dc78..26faadb 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -188,16 +188,30 @@ def load_settings_cached(self): if self.scanqr_mode: print("loading scanqr settings...") if not self.scanqr_prefs: - self.scanqr_prefs = SharedPreferences(self.PACKAGE, filename=self.SCANQR_CONFIG) - self.width = self.scanqr_prefs.get_int("resolution_width", CameraSettingsActivity.DEFAULT_SCANQR_WIDTH) - self.height = self.scanqr_prefs.get_int("resolution_height", CameraSettingsActivity.DEFAULT_SCANQR_HEIGHT) - self.colormode = self.scanqr_prefs.get_bool("colormode", CameraSettingsActivity.DEFAULT_SCANQR_COLORMODE) + # Merge common and scanqr-specific defaults + scanqr_defaults = {} + scanqr_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) + scanqr_defaults.update(CameraSettingsActivity.SCANQR_DEFAULTS) + self.scanqr_prefs = SharedPreferences( + self.PACKAGE, + filename=self.SCANQR_CONFIG, + defaults=scanqr_defaults + ) + # Defaults come from constructor, no need to pass them here + self.width = self.scanqr_prefs.get_int("resolution_width") + self.height = self.scanqr_prefs.get_int("resolution_height") + self.colormode = self.scanqr_prefs.get_bool("colormode") else: if not self.prefs: - self.prefs = SharedPreferences(self.PACKAGE) - self.width = self.prefs.get_int("resolution_width", CameraSettingsActivity.DEFAULT_WIDTH) - self.height = self.prefs.get_int("resolution_height", CameraSettingsActivity.DEFAULT_HEIGHT) - self.colormode = self.prefs.get_bool("colormode", CameraSettingsActivity.DEFAULT_COLORMODE) + # Merge common and normal-specific defaults + normal_defaults = {} + normal_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) + normal_defaults.update(CameraSettingsActivity.NORMAL_DEFAULTS) + self.prefs = SharedPreferences(self.PACKAGE, defaults=normal_defaults) + # Defaults come from constructor, no need to pass them here + self.width = self.prefs.get_int("resolution_width") + self.height = self.prefs.get_int("resolution_height") + self.colormode = self.prefs.get_bool("colormode") def update_preview_image(self): self.image_dsc = lv.image_dsc_t({ @@ -467,93 +481,95 @@ def apply_camera_settings(self, prefs, cam, use_webcam): try: # Basic image adjustments - brightness = prefs.get_int("brightness", CameraSettingsActivity.DEFAULTS.get("brightness")) + brightness = prefs.get_int("brightness") cam.set_brightness(brightness) - contrast = prefs.get_int("contrast", 0) + contrast = prefs.get_int("contrast") cam.set_contrast(contrast) - saturation = prefs.get_int("saturation", 0) + saturation = prefs.get_int("saturation") cam.set_saturation(saturation) - + # Orientation - hmirror = prefs.get_bool("hmirror", False) + hmirror = prefs.get_bool("hmirror") cam.set_hmirror(hmirror) - - vflip = prefs.get_bool("vflip", True) + + vflip = prefs.get_bool("vflip") cam.set_vflip(vflip) - + # Special effect - special_effect = prefs.get_int("special_effect", 0) + special_effect = prefs.get_int("special_effect") cam.set_special_effect(special_effect) - + # Exposure control (apply master switch first, then manual value) - exposure_ctrl = prefs.get_bool("exposure_ctrl", True) + exposure_ctrl = prefs.get_bool("exposure_ctrl") cam.set_exposure_ctrl(exposure_ctrl) - + if not exposure_ctrl: - aec_value = prefs.get_int("aec_value", 300) + aec_value = prefs.get_int("aec_value") cam.set_aec_value(aec_value) - - ae_level = prefs.get_int("ae_level", 2 if self.scanqr_mode else 0) + + # Mode-specific default comes from constructor + ae_level = prefs.get_int("ae_level") cam.set_ae_level(ae_level) - - aec2 = prefs.get_bool("aec2", False) + + aec2 = prefs.get_bool("aec2") cam.set_aec2(aec2) # Gain control (apply master switch first, then manual value) - gain_ctrl = prefs.get_bool("gain_ctrl", True) + gain_ctrl = prefs.get_bool("gain_ctrl") cam.set_gain_ctrl(gain_ctrl) - + if not gain_ctrl: - agc_gain = prefs.get_int("agc_gain", 0) + agc_gain = prefs.get_int("agc_gain") cam.set_agc_gain(agc_gain) - - gainceiling = prefs.get_int("gainceiling", 0) + + gainceiling = prefs.get_int("gainceiling") cam.set_gainceiling(gainceiling) - + # White balance (apply master switch first, then mode) - whitebal = prefs.get_bool("whitebal", True) + whitebal = prefs.get_bool("whitebal") cam.set_whitebal(whitebal) - + if not whitebal: - wb_mode = prefs.get_int("wb_mode", 0) + wb_mode = prefs.get_int("wb_mode") cam.set_wb_mode(wb_mode) - - awb_gain = prefs.get_bool("awb_gain", True) + + awb_gain = prefs.get_bool("awb_gain") cam.set_awb_gain(awb_gain) # Sensor-specific settings (try/except for unsupported sensors) try: - sharpness = prefs.get_int("sharpness", 0) + sharpness = prefs.get_int("sharpness") cam.set_sharpness(sharpness) except: pass # Not supported on OV2640? - + try: - denoise = prefs.get_int("denoise", 0) + denoise = prefs.get_int("denoise") cam.set_denoise(denoise) except: pass # Not supported on OV2640? - + # Advanced corrections - colorbar = prefs.get_bool("colorbar", False) + colorbar = prefs.get_bool("colorbar") cam.set_colorbar(colorbar) - - dcw = prefs.get_bool("dcw", True) + + dcw = prefs.get_bool("dcw") cam.set_dcw(dcw) - - bpc = prefs.get_bool("bpc", False) + + bpc = prefs.get_bool("bpc") cam.set_bpc(bpc) - - wpc = prefs.get_bool("wpc", True) + + wpc = prefs.get_bool("wpc") cam.set_wpc(wpc) - - raw_gma = prefs.get_bool("raw_gma", False if self.scanqr_mode else True) + + # Mode-specific default comes from constructor + raw_gma = prefs.get_bool("raw_gma") print(f"applying raw_gma: {raw_gma}") cam.set_raw_gma(raw_gma) - - lenc = prefs.get_bool("lenc", True) + + lenc = prefs.get_bool("lenc") cam.set_lenc(lenc) # JPEG quality (only relevant for JPEG format) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index 7e78894..da62567 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -31,8 +31,56 @@ class CameraSettingsActivity(Activity): scale_default=False binning_default=False - DEFAULTS = { - "brightness": 1, + # Common defaults shared by both normal and scanqr modes (25 settings) + COMMON_DEFAULTS = { + # Basic image adjustments + "brightness": 0, + "contrast": 0, + "saturation": 0, + # Orientation + "hmirror": False, + "vflip": True, + # Visual effects + "special_effect": 0, + # Exposure control + "exposure_ctrl": True, + "aec_value": 300, + "aec2": False, + # Gain control + "gain_ctrl": True, + "agc_gain": 0, + "gainceiling": 0, + # White balance + "whitebal": True, + "wb_mode": 0, + "awb_gain": True, + # Sensor-specific + "sharpness": 0, + "denoise": 0, + # Advanced corrections + "colorbar": False, + "dcw": True, + "bpc": False, + "wpc": True, + "lenc": True, + } + + # Normal mode specific defaults (5 settings) + NORMAL_DEFAULTS = { + "resolution_width": DEFAULT_WIDTH, # 240 + "resolution_height": DEFAULT_HEIGHT, # 240 + "colormode": DEFAULT_COLORMODE, # True + "ae_level": 0, + "raw_gma": True, + } + + # Scanqr mode specific defaults (5 settings, optimized for QR detection) + SCANQR_DEFAULTS = { + "resolution_width": DEFAULT_SCANQR_WIDTH, # 960 + "resolution_height": DEFAULT_SCANQR_HEIGHT, # 960 + "colormode": DEFAULT_SCANQR_COLORMODE, # False (grayscale) + "ae_level": 2, # Higher exposure compensation + "raw_gma": False, # Disable gamma for better contrast } # Resolution options for desktop/webcam @@ -273,14 +321,14 @@ def create_basic_tab(self, tab, prefs): tab.set_style_pad_all(1, 0) # Color Mode - colormode = prefs.get_bool("colormode", False if self.scanqr_mode else True) + colormode = prefs.get_bool("colormode") checkbox, cont = self.create_checkbox(tab, "Color Mode (slower)", colormode, "colormode") self.ui_controls["colormode"] = checkbox # Resolution dropdown print(f"self.scanqr_mode: {self.scanqr_mode}") - current_resolution_width = prefs.get_string("resolution_width", self.DEFAULT_SCANQR_WIDTH if self.scanqr_mode else self.DEFAULT_WIDTH) - current_resolution_height = prefs.get_string("resolution_height", self.DEFAULT_SCANQR_HEIGHT if self.scanqr_mode else self.DEFAULT_HEIGHT) + current_resolution_width = prefs.get_int("resolution_width") + current_resolution_height = prefs.get_int("resolution_height") dropdown_value = f"{current_resolution_width}x{current_resolution_height}" print(f"looking for {dropdown_value}") resolution_idx = 0 @@ -295,27 +343,27 @@ def create_basic_tab(self, tab, prefs): self.ui_controls["resolution"] = dropdown # Brightness - brightness = prefs.get_int("brightness", self.DEFAULTS.get("brightness")) + brightness = prefs.get_int("brightness") slider, label, cont = self.create_slider(tab, "Brightness", -2, 2, brightness, "brightness") self.ui_controls["brightness"] = slider # Contrast - contrast = prefs.get_int("contrast", 0) + contrast = prefs.get_int("contrast") slider, label, cont = self.create_slider(tab, "Contrast", -2, 2, contrast, "contrast") self.ui_controls["contrast"] = slider # Saturation - saturation = prefs.get_int("saturation", 0) + saturation = prefs.get_int("saturation") slider, label, cont = self.create_slider(tab, "Saturation", -2, 2, saturation, "saturation") self.ui_controls["saturation"] = slider # Horizontal Mirror - hmirror = prefs.get_bool("hmirror", False) + hmirror = prefs.get_bool("hmirror") checkbox, cont = self.create_checkbox(tab, "Horizontal Mirror", hmirror, "hmirror") self.ui_controls["hmirror"] = checkbox # Vertical Flip - vflip = prefs.get_bool("vflip", True) + vflip = prefs.get_bool("vflip") checkbox, cont = self.create_checkbox(tab, "Vertical Flip", vflip, "vflip") self.ui_controls["vflip"] = checkbox @@ -327,17 +375,17 @@ def create_advanced_tab(self, tab, prefs): tab.set_style_pad_all(1, 0) # Auto Exposure Control (master switch) - exposure_ctrl = prefs.get_bool("exposure_ctrl", True) + exposure_ctrl = prefs.get_bool("exposure_ctrl") aec_checkbox, cont = self.create_checkbox(tab, "Auto Exposure", exposure_ctrl, "exposure_ctrl") self.ui_controls["exposure_ctrl"] = aec_checkbox # Manual Exposure Value (dependent) - aec_value = prefs.get_int("aec_value", 300) + aec_value = prefs.get_int("aec_value") me_slider, label, me_cont = self.create_slider(tab, "Manual Exposure", 0, 1200, aec_value, "aec_value") self.ui_controls["aec_value"] = me_slider # Auto Exposure Level (dependent) - ae_level = prefs.get_int("ae_level", 0) + ae_level = prefs.get_int("ae_level") ae_slider, label, ae_cont = self.create_slider(tab, "Auto Exposure Level", -2, 2, ae_level, "ae_level") self.ui_controls["ae_level"] = ae_slider @@ -355,17 +403,17 @@ def exposure_ctrl_changed(e=None): exposure_ctrl_changed() # Night Mode (AEC2) - aec2 = prefs.get_bool("aec2", False) + aec2 = prefs.get_bool("aec2") checkbox, cont = self.create_checkbox(tab, "Night Mode (AEC2)", aec2, "aec2") self.ui_controls["aec2"] = checkbox # Auto Gain Control (master switch) - gain_ctrl = prefs.get_bool("gain_ctrl", True) + gain_ctrl = prefs.get_bool("gain_ctrl") agc_checkbox, cont = self.create_checkbox(tab, "Auto Gain", gain_ctrl, "gain_ctrl") self.ui_controls["gain_ctrl"] = agc_checkbox # Manual Gain Value (dependent) - agc_gain = prefs.get_int("agc_gain", 0) + agc_gain = prefs.get_int("agc_gain") slider, label, agc_cont = self.create_slider(tab, "Manual Gain", 0, 30, agc_gain, "agc_gain") self.ui_controls["agc_gain"] = slider @@ -385,12 +433,12 @@ def gain_ctrl_changed(e=None): ("2X", 0), ("4X", 1), ("8X", 2), ("16X", 3), ("32X", 4), ("64X", 5), ("128X", 6) ] - gainceiling = prefs.get_int("gainceiling", 0) + gainceiling = prefs.get_int("gainceiling") dropdown, cont = self.create_dropdown(tab, "Gain Ceiling:", gainceiling_options, gainceiling, "gainceiling") self.ui_controls["gainceiling"] = dropdown # Auto White Balance (master switch) - whitebal = prefs.get_bool("whitebal", True) + whitebal = prefs.get_bool("whitebal") wbcheckbox, cont = self.create_checkbox(tab, "Auto White Balance", whitebal, "whitebal") self.ui_controls["whitebal"] = wbcheckbox @@ -398,7 +446,7 @@ def gain_ctrl_changed(e=None): wb_mode_options = [ ("Auto", 0), ("Sunny", 1), ("Cloudy", 2), ("Office", 3), ("Home", 4) ] - wb_mode = prefs.get_int("wb_mode", 0) + wb_mode = prefs.get_int("wb_mode") wb_dropdown, wb_cont = self.create_dropdown(tab, "WB Mode:", wb_mode_options, wb_mode, "wb_mode") self.ui_controls["wb_mode"] = wb_dropdown @@ -412,7 +460,7 @@ def whitebal_changed(e=None): whitebal_changed() # AWB Gain - awb_gain = prefs.get_bool("awb_gain", True) + awb_gain = prefs.get_bool("awb_gain") checkbox, cont = self.create_checkbox(tab, "AWB Gain", awb_gain, "awb_gain") self.ui_controls["awb_gain"] = checkbox @@ -423,7 +471,7 @@ def whitebal_changed(e=None): ("None", 0), ("Negative", 1), ("Grayscale", 2), ("Reddish", 3), ("Greenish", 4), ("Blue", 5), ("Retro", 6) ] - special_effect = prefs.get_int("special_effect", 0) + special_effect = prefs.get_int("special_effect") dropdown, cont = self.create_dropdown(tab, "Special Effect:", special_effect_options, special_effect, "special_effect") self.ui_controls["special_effect"] = dropdown @@ -435,12 +483,12 @@ def create_expert_tab(self, tab, prefs): tab.set_style_pad_all(1, 0) # Sharpness - sharpness = prefs.get_int("sharpness", 0) + sharpness = prefs.get_int("sharpness") slider, label, cont = self.create_slider(tab, "Sharpness", -3, 3, sharpness, "sharpness") self.ui_controls["sharpness"] = slider # Denoise - denoise = prefs.get_int("denoise", 0) + denoise = prefs.get_int("denoise") slider, label, cont = self.create_slider(tab, "Denoise", 0, 8, denoise, "denoise") self.ui_controls["denoise"] = slider @@ -451,32 +499,32 @@ def create_expert_tab(self, tab, prefs): #self.ui_controls["quality"] = slider # Color Bar - colorbar = prefs.get_bool("colorbar", False) + colorbar = prefs.get_bool("colorbar") checkbox, cont = self.create_checkbox(tab, "Color Bar Test", colorbar, "colorbar") self.ui_controls["colorbar"] = checkbox # DCW Mode - dcw = prefs.get_bool("dcw", True) + dcw = prefs.get_bool("dcw") checkbox, cont = self.create_checkbox(tab, "Downsize Crop Window", dcw, "dcw") self.ui_controls["dcw"] = checkbox # Black Point Compensation - bpc = prefs.get_bool("bpc", False) + bpc = prefs.get_bool("bpc") checkbox, cont = self.create_checkbox(tab, "Black Point Compensation", bpc, "bpc") self.ui_controls["bpc"] = checkbox # White Point Compensation - wpc = prefs.get_bool("wpc", True) + wpc = prefs.get_bool("wpc") checkbox, cont = self.create_checkbox(tab, "White Point Compensation", wpc, "wpc") self.ui_controls["wpc"] = checkbox # Raw Gamma Mode - raw_gma = prefs.get_bool("raw_gma", True) + raw_gma = prefs.get_bool("raw_gma") checkbox, cont = self.create_checkbox(tab, "Raw Gamma Mode", raw_gma, "raw_gma") self.ui_controls["raw_gma"] = checkbox # Lens Correction - lenc = prefs.get_bool("lenc", True) + lenc = prefs.get_bool("lenc") checkbox, cont = self.create_checkbox(tab, "Lens Correction", lenc, "lenc") self.ui_controls["lenc"] = checkbox From 5d100dc0267680834542b31f32e90fcc4a46a3a6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 2 Dec 2025 19:09:35 +0100 Subject: [PATCH 089/192] Support more webcam resolutions --- CLAUDE.md | 46 +++ c_mpos/src/webcam.c | 331 +++++++++++++++--- .../assets/camera_settings.py | 28 +- 3 files changed, 352 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ac1155..27d33b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,52 @@ The OS supports: - Platform detection via `sys.platform` ("esp32" vs others) - Different boot files per hardware variant (boot_fri3d-2024.py, etc.) +### Webcam Module (Desktop Only) + +The `c_mpos/src/webcam.c` module provides webcam support for desktop builds using the V4L2 API. + +**Resolution Adaptation**: +- Automatically queries supported YUYV resolutions from the webcam using V4L2 API +- Supports all 23 ESP32 camera resolutions via intelligent cropping/padding +- **Center cropping**: When requesting smaller than available (e.g., 240x240 from 320x240) +- **Black border padding**: When requesting larger than maximum supported +- Always returns exactly the requested dimensions for API consistency + +**Behavior**: +- On first init, queries device for supported resolutions using `VIDIOC_ENUM_FRAMESIZES` +- Selects smallest capture resolution ≥ requested dimensions (minimizes memory/bandwidth) +- Converts YUYV to RGB565 (color) or grayscale during capture +- Caches supported resolutions to avoid re-querying device + +**Examples**: + +*Cropping (common case)*: +- Request: 240x240 (not natively supported) +- Capture: 320x240 (nearest supported YUYV resolution) +- Process: Extract center 240x240 region +- Result: 240x240 frame with centered content + +*Padding (rare case)*: +- Request: 1920x1080 +- Capture: 1280x720 (webcam maximum) +- Process: Center 1280x720 content in 1920x1080 buffer with black borders +- Result: 1920x1080 frame (API contract maintained) + +**Performance**: +- Exact matches use fast path (no cropping overhead) +- Cropped resolutions add ~5-10% CPU overhead +- Padded resolutions add ~3-5% CPU overhead (memset + center placement) +- V4L2 buffers sized for capture resolution, conversion buffers sized for output + +**Implementation Details**: +- YUYV format: 2 pixels per macropixel (4 bytes: Y0 U Y1 V) +- Crop offsets must be even for proper YUYV alignment +- Center crop formula: `offset = (capture_dim - output_dim) / 2`, then align to even +- Supported resolutions cached in `supported_resolutions_t` structure +- Separate tracking of `capture_width/height` (from V4L2) vs `output_width/height` (user requested) + +**File Location**: `c_mpos/src/webcam.c` (C extension module) + ## Build System ### Building Firmware diff --git a/c_mpos/src/webcam.c b/c_mpos/src/webcam.c index 83f08c3..6667b3b 100644 --- a/c_mpos/src/webcam.c +++ b/c_mpos/src/webcam.c @@ -8,16 +8,30 @@ #include #include #include +#include #include "py/obj.h" #include "py/runtime.h" #include "py/mperrno.h" #define NUM_BUFFERS 1 +#define MAX_SUPPORTED_RESOLUTIONS 32 #define WEBCAM_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) static const mp_obj_type_t webcam_type; +// Resolution structure for storing supported formats +typedef struct { + int width; + int height; +} resolution_t; + +// Cache of supported resolutions from V4L2 device +typedef struct { + resolution_t resolutions[MAX_SUPPORTED_RESOLUTIONS]; + int count; +} supported_resolutions_t; + typedef struct _webcam_obj_t { mp_obj_base_t base; int fd; @@ -27,8 +41,15 @@ typedef struct _webcam_obj_t { int frame_count; unsigned char *gray_buffer; // For grayscale conversion uint16_t *rgb565_buffer; // For RGB565 conversion - int width; // Resolution width - int height; // Resolution height + + // Separate capture and output dimensions + int capture_width; // What V4L2 actually captures + int capture_height; + int output_width; // What user requested + int output_height; + + // Supported resolutions cache + supported_resolutions_t supported_res; } webcam_obj_t; // Helper function to convert single YUV pixel to RGB565 @@ -50,35 +71,98 @@ static inline uint16_t yuv_to_rgb565(int y_val, int u, int v) { return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); } -static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, int width, int height) { - // Convert YUYV to RGB565 without scaling +static void yuyv_to_rgb565(unsigned char *yuyv, uint16_t *rgb565, + int capture_width, int capture_height, + int output_width, int output_height) { + // Convert YUYV to RGB565 with cropping or padding support // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels, chroma shared) - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x += 2) { - // Process 2 pixels at a time (one YUYV quad) - int base_index = (y * width + x) * 2; - - int y0 = yuyv[base_index + 0]; - int u = yuyv[base_index + 1]; - int y1 = yuyv[base_index + 2]; - int v = yuyv[base_index + 3]; - - // Convert both pixels (sharing U/V chroma) - rgb565[y * width + x] = yuv_to_rgb565(y0, u, v); - rgb565[y * width + x + 1] = yuv_to_rgb565(y1, u, v); + // Clear entire output buffer to black (RGB565 0x0000) + memset(rgb565, 0, output_width * output_height * sizeof(uint16_t)); + + if (output_width <= capture_width && output_height <= capture_height) { + // Cropping case: extract center region from capture + int offset_x = (capture_width - output_width) / 2; + int offset_y = (capture_height - output_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < output_height; y++) { + for (int x = 0; x < output_width; x += 2) { + int src_y = offset_y + y; + int src_x = offset_x + x; + int src_index = (src_y * capture_width + src_x) * 2; + + int y0 = yuyv[src_index + 0]; + int u = yuyv[src_index + 1]; + int y1 = yuyv[src_index + 2]; + int v = yuyv[src_index + 3]; + + int dst_index = y * output_width + x; + rgb565[dst_index] = yuv_to_rgb565(y0, u, v); + rgb565[dst_index + 1] = yuv_to_rgb565(y1, u, v); + } + } + } else { + // Padding case: center capture in larger output buffer + int offset_x = (output_width - capture_width) / 2; + int offset_y = (output_height - capture_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < capture_height; y++) { + for (int x = 0; x < capture_width; x += 2) { + int src_index = (y * capture_width + x) * 2; + + int y0 = yuyv[src_index + 0]; + int u = yuyv[src_index + 1]; + int y1 = yuyv[src_index + 2]; + int v = yuyv[src_index + 3]; + + int dst_y = offset_y + y; + int dst_x = offset_x + x; + int dst_index = dst_y * output_width + dst_x; + rgb565[dst_index] = yuv_to_rgb565(y0, u, v); + rgb565[dst_index + 1] = yuv_to_rgb565(y1, u, v); + } } } } -static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, int width, int height) { - // Extract Y (luminance) values from YUYV without scaling +static void yuyv_to_grayscale(unsigned char *yuyv, unsigned char *gray, + int capture_width, int capture_height, + int output_width, int output_height) { + // Extract Y (luminance) values from YUYV with cropping or padding support // YUYV format: Y0 U Y1 V (4 bytes for 2 pixels) - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - // Y values are at even indices in YUYV - gray[y * width + x] = yuyv[(y * width + x) * 2]; + // Clear entire output buffer to black (0x00) + memset(gray, 0, output_width * output_height); + + if (output_width <= capture_width && output_height <= capture_height) { + // Cropping case: extract center region from capture + int offset_x = (capture_width - output_width) / 2; + int offset_y = (capture_height - output_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < output_height; y++) { + for (int x = 0; x < output_width; x++) { + int src_y = offset_y + y; + int src_x = offset_x + x; + // Y values are at even indices in YUYV + gray[y * output_width + x] = yuyv[(src_y * capture_width + src_x) * 2]; + } + } + } else { + // Padding case: center capture in larger output buffer + int offset_x = (output_width - capture_width) / 2; + int offset_y = (output_height - capture_height) / 2; + offset_x = (offset_x / 2) * 2; // YUYV alignment (even offset) + + for (int y = 0; y < capture_height; y++) { + for (int x = 0; x < capture_width; x++) { + int dst_y = offset_y + y; + int dst_x = offset_x + x; + // Y values are at even indices in YUYV + gray[dst_y * output_width + dst_x] = yuyv[(y * capture_width + x) * 2]; + } } } } @@ -93,7 +177,119 @@ static void save_raw_generic(const char *filename, void *data, size_t elem_size, fclose(fp); } -static int init_webcam(webcam_obj_t *self, const char *device, int width, int height) { +// Query supported YUYV resolutions from V4L2 device +static int query_supported_resolutions(int fd, supported_resolutions_t *supported) { + struct v4l2_fmtdesc fmt_desc; + struct v4l2_frmsizeenum frmsize; + int found_yuyv = 0; + + supported->count = 0; + + // First, check if device supports YUYV format + memset(&fmt_desc, 0, sizeof(fmt_desc)); + fmt_desc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + + for (fmt_desc.index = 0; ; fmt_desc.index++) { + if (ioctl(fd, VIDIOC_ENUM_FMT, &fmt_desc) < 0) { + break; + } + if (fmt_desc.pixelformat == V4L2_PIX_FMT_YUYV) { + found_yuyv = 1; + break; + } + } + + if (!found_yuyv) { + WEBCAM_DEBUG_PRINT("Warning: YUYV format not found\n"); + return -1; + } + + // Enumerate frame sizes for YUYV + memset(&frmsize, 0, sizeof(frmsize)); + frmsize.pixel_format = V4L2_PIX_FMT_YUYV; + + for (frmsize.index = 0; supported->count < MAX_SUPPORTED_RESOLUTIONS; frmsize.index++) { + if (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &frmsize) < 0) { + break; + } + + if (frmsize.type == V4L2_FRMSIZE_TYPE_DISCRETE) { + supported->resolutions[supported->count].width = frmsize.discrete.width; + supported->resolutions[supported->count].height = frmsize.discrete.height; + supported->count++; + WEBCAM_DEBUG_PRINT(" Found resolution: %dx%d\n", + frmsize.discrete.width, frmsize.discrete.height); + } + } + + if (supported->count == 0) { + WEBCAM_DEBUG_PRINT("Warning: No discrete YUYV resolutions found, using common defaults\n"); + // Fallback to common resolutions if enumeration fails + const resolution_t defaults[] = { + {160, 120}, {320, 240}, {640, 480}, {1280, 720}, {1920, 1080} + }; + for (int i = 0; i < 5 && i < MAX_SUPPORTED_RESOLUTIONS; i++) { + supported->resolutions[i] = defaults[i]; + supported->count++; + } + } + + WEBCAM_DEBUG_PRINT("Total supported resolutions: %d\n", supported->count); + return 0; +} + +// Find the best capture resolution for the requested output size +static resolution_t find_best_capture_resolution(int requested_width, int requested_height, + supported_resolutions_t *supported) { + resolution_t best; + int found_candidate = 0; + int min_area = INT_MAX; + + // Check for exact match first + for (int i = 0; i < supported->count; i++) { + if (supported->resolutions[i].width == requested_width && + supported->resolutions[i].height == requested_height) { + WEBCAM_DEBUG_PRINT("Found exact resolution match: %dx%d\n", + requested_width, requested_height); + return supported->resolutions[i]; + } + } + + // Find smallest resolution that contains the requested size + for (int i = 0; i < supported->count; i++) { + if (supported->resolutions[i].width >= requested_width && + supported->resolutions[i].height >= requested_height) { + int area = supported->resolutions[i].width * supported->resolutions[i].height; + if (area < min_area) { + min_area = area; + best = supported->resolutions[i]; + found_candidate = 1; + } + } + } + + if (found_candidate) { + WEBCAM_DEBUG_PRINT("Best capture resolution for %dx%d: %dx%d (will crop)\n", + requested_width, requested_height, best.width, best.height); + return best; + } + + // No containing resolution found, use largest available (will need padding) + best = supported->resolutions[0]; + for (int i = 1; i < supported->count; i++) { + int area = supported->resolutions[i].width * supported->resolutions[i].height; + int best_area = best.width * best.height; + if (area > best_area) { + best = supported->resolutions[i]; + } + } + + WEBCAM_DEBUG_PRINT("Warning: Requested %dx%d exceeds max supported, capturing at %dx%d (will pad with black)\n", + requested_width, requested_height, best.width, best.height); + return best; +} + +static int init_webcam(webcam_obj_t *self, const char *device, int requested_width, int requested_height) { // Store device path for later use (e.g., reconfigure) strncpy(self->device, device, sizeof(self->device) - 1); self->device[sizeof(self->device) - 1] = '\0'; @@ -104,10 +300,28 @@ static int init_webcam(webcam_obj_t *self, const char *device, int width, int he return -errno; } + // Query supported resolutions (first time only) + if (self->supported_res.count == 0) { + WEBCAM_DEBUG_PRINT("Querying supported resolutions...\n"); + if (query_supported_resolutions(self->fd, &self->supported_res) < 0) { + // Query failed, but continue with fallback defaults + WEBCAM_DEBUG_PRINT("Resolution query failed, continuing with defaults\n"); + } + } + + // Find best capture resolution for requested output + resolution_t best = find_best_capture_resolution(requested_width, requested_height, + &self->supported_res); + + // Store requested output dimensions + self->output_width = requested_width; + self->output_height = requested_height; + + // Configure V4L2 with capture resolution struct v4l2_format fmt = {0}; fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; - fmt.fmt.pix.width = width; - fmt.fmt.pix.height = height; + fmt.fmt.pix.width = best.width; + fmt.fmt.pix.height = best.height; fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field = V4L2_FIELD_ANY; if (ioctl(self->fd, VIDIOC_S_FMT, &fmt) < 0) { @@ -116,9 +330,9 @@ static int init_webcam(webcam_obj_t *self, const char *device, int width, int he return -errno; } - // Store actual format (driver may adjust dimensions) - width = fmt.fmt.pix.width; - height = fmt.fmt.pix.height; + // Store actual capture dimensions (driver may adjust) + self->capture_width = fmt.fmt.pix.width; + self->capture_height = fmt.fmt.pix.height; struct v4l2_requestbuffers req = {0}; req.count = NUM_BUFFERS; @@ -176,17 +390,15 @@ static int init_webcam(webcam_obj_t *self, const char *device, int width, int he self->frame_count = 0; - // Store resolution (actual values from V4L2, may be adjusted by driver) - self->width = width; - self->height = height; - - WEBCAM_DEBUG_PRINT("Webcam initialized: %dx%d\n", self->width, self->height); + WEBCAM_DEBUG_PRINT("Webcam initialized: capture=%dx%d, output=%dx%d\n", + self->capture_width, self->capture_height, + self->output_width, self->output_height); - // Allocate conversion buffers - self->gray_buffer = (unsigned char *)malloc(self->width * self->height * sizeof(unsigned char)); - self->rgb565_buffer = (uint16_t *)malloc(self->width * self->height * sizeof(uint16_t)); + // Allocate conversion buffers based on OUTPUT dimensions + self->gray_buffer = (unsigned char *)malloc(self->output_width * self->output_height * sizeof(unsigned char)); + self->rgb565_buffer = (uint16_t *)malloc(self->output_width * self->output_height * sizeof(uint16_t)); if (!self->gray_buffer || !self->rgb565_buffer) { - WEBCAM_DEBUG_PRINT("Cannot allocate buffers: %s\n", strerror(errno)); + WEBCAM_DEBUG_PRINT("Cannot allocate conversion buffers: %s\n", strerror(errno)); free(self->gray_buffer); free(self->rgb565_buffer); close(self->fd); @@ -212,6 +424,9 @@ static void deinit_webcam(webcam_obj_t *self) { free(self->rgb565_buffer); self->rgb565_buffer = NULL; + // Clear resolution cache (device may change on reconnect) + self->supported_res.count = 0; + close(self->fd); self->fd = -1; } @@ -242,18 +457,38 @@ static mp_obj_t capture_frame(mp_obj_t self_in, mp_obj_t format) { const char *fmt = mp_obj_str_get_str(format); if (strcmp(fmt, "grayscale") == 0) { - yuyv_to_grayscale(self->buffers[buf.index], self->gray_buffer, - self->width, self->height); - mp_obj_t result = mp_obj_new_memoryview('b', self->width * self->height, self->gray_buffer); + // Pass all 6 dimensions: capture (source) and output (destination) + yuyv_to_grayscale( + self->buffers[buf.index], + self->gray_buffer, + self->capture_width, // Source dimensions + self->capture_height, + self->output_width, // Destination dimensions + self->output_height + ); + // Return memoryview with OUTPUT dimensions + mp_obj_t result = mp_obj_new_memoryview('b', + self->output_width * self->output_height, + self->gray_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); } return result; } else { - yuyv_to_rgb565(self->buffers[buf.index], self->rgb565_buffer, - self->width, self->height); - mp_obj_t result = mp_obj_new_memoryview('b', self->width * self->height * 2, self->rgb565_buffer); + // Pass all 6 dimensions: capture (source) and output (destination) + yuyv_to_rgb565( + self->buffers[buf.index], + self->rgb565_buffer, + self->capture_width, // Source dimensions + self->capture_height, + self->output_width, // Destination dimensions + self->output_height + ); + // Return memoryview with OUTPUT dimensions + mp_obj_t result = mp_obj_new_memoryview('b', + self->output_width * self->output_height * 2, + self->rgb565_buffer); res = ioctl(self->fd, VIDIOC_QBUF, &buf); if (res < 0) { mp_raise_OSError(-res); @@ -343,8 +578,8 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m int new_width = args[ARG_width].u_int; int new_height = args[ARG_height].u_int; - if (new_width == 0) new_width = self->width; - if (new_height == 0) new_height = self->height; + if (new_width == 0) new_width = self->output_width; + if (new_height == 0) new_height = self->output_height; // Validate dimensions if (new_width <= 0 || new_height <= 0 || new_width > 3840 || new_height > 2160) { @@ -352,12 +587,12 @@ static mp_obj_t webcam_reconfigure(size_t n_args, const mp_obj_t *pos_args, mp_m } // Check if anything changed - if (new_width == self->width && new_height == self->height) { + if (new_width == self->output_width && new_height == self->output_height) { return mp_const_none; // Nothing to do } WEBCAM_DEBUG_PRINT("Reconfiguring webcam: %dx%d -> %dx%d\n", - self->width, self->height, new_width, new_height); + self->output_width, self->output_height, new_width, new_height); // Clean shutdown and reinitialize with new resolution // Note: deinit_webcam doesn't touch self->device, so it's safe to use directly diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index da62567..336821c 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -84,14 +84,32 @@ class CameraSettingsActivity(Activity): } # Resolution options for desktop/webcam + # Now supports all ESP32 resolutions via automatic cropping/padding WEBCAM_RESOLUTIONS = [ + ("96x96", "96x96"), ("160x120", "160x120"), - ("320x180", "320x180"), + ("128x128", "128x128"), + ("176x144", "176x144"), + ("240x176", "240x176"), + ("240x240", "240x240"), ("320x240", "320x240"), - ("640x360", "640x360"), - ("640x480 (30 fps)", "640x480"), - ("1280x720 (10 fps)", "1280x720"), - ("1920x1080 (5 fps)", "1920x1080"), + ("320x320", "320x320"), + ("400x296", "400x296"), + ("480x320", "480x320"), + ("480x480", "480x480"), + ("640x480", "640x480"), + ("640x640", "640x640"), + ("720x720", "720x720"), + ("800x600", "800x600"), + ("800x800", "800x800"), + ("960x960", "960x960"), + ("1024x768", "1024x768"), + ("1024x1024","1024x1024"), + ("1280x720", "1280x720"), + ("1280x1024", "1280x1024"), + ("1280x1280", "1280x1280"), + ("1600x1200", "1600x1200"), + ("1920x1080", "1920x1080"), ] # Resolution options for internal camera (ESP32) From a657a3bdfab164e4b611faf80c6956c62bdea3c3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 2 Dec 2025 20:32:38 +0100 Subject: [PATCH 090/192] Camera app: simplify by using same resolutions list --- .../assets/camera_settings.py | 46 ++----------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index 336821c..df68679 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -83,37 +83,9 @@ class CameraSettingsActivity(Activity): "raw_gma": False, # Disable gamma for better contrast } - # Resolution options for desktop/webcam - # Now supports all ESP32 resolutions via automatic cropping/padding - WEBCAM_RESOLUTIONS = [ - ("96x96", "96x96"), - ("160x120", "160x120"), - ("128x128", "128x128"), - ("176x144", "176x144"), - ("240x176", "240x176"), - ("240x240", "240x240"), - ("320x240", "320x240"), - ("320x320", "320x320"), - ("400x296", "400x296"), - ("480x320", "480x320"), - ("480x480", "480x480"), - ("640x480", "640x480"), - ("640x640", "640x640"), - ("720x720", "720x720"), - ("800x600", "800x600"), - ("800x800", "800x800"), - ("960x960", "960x960"), - ("1024x768", "1024x768"), - ("1024x1024","1024x1024"), - ("1280x720", "1280x720"), - ("1280x1024", "1280x1024"), - ("1280x1280", "1280x1280"), - ("1600x1200", "1600x1200"), - ("1920x1080", "1920x1080"), - ] - - # Resolution options for internal camera (ESP32) - ESP32_RESOLUTIONS = [ + # Resolution options for both ESP32 and webcam + # Webcam supports all ESP32 resolutions via automatic cropping/padding + RESOLUTIONS = [ ("96x96", "96x96"), ("160x120", "160x120"), ("128x128", "128x128"), @@ -153,19 +125,11 @@ def __init__(self): self.ui_controls = {} self.control_metadata = {} # Store pref_key and option_values for each control self.dependent_controls = {} - self.is_webcam = False - self.resolutions = [] def onCreate(self): self.use_webcam = self.getIntent().extras.get("use_webcam") self.prefs = self.getIntent().extras.get("prefs") self.scanqr_mode = self.getIntent().extras.get("scanqr_mode") - if self.use_webcam: - self.resolutions = self.WEBCAM_RESOLUTIONS - print("Using webcam resolutions") - else: - self.resolutions = self.ESP32_RESOLUTIONS - print("Using ESP32 camera resolutions") # Create main screen screen = lv.obj() @@ -350,14 +314,14 @@ def create_basic_tab(self, tab, prefs): dropdown_value = f"{current_resolution_width}x{current_resolution_height}" print(f"looking for {dropdown_value}") resolution_idx = 0 - for idx, (_, value) in enumerate(self.resolutions): + for idx, (_, value) in enumerate(self.RESOLUTIONS): print(f"got {value}") if value == dropdown_value: resolution_idx = idx print(f"found it! {idx}") break - dropdown, cont = self.create_dropdown(tab, "Resolution:", self.resolutions, resolution_idx, "resolution") + dropdown, cont = self.create_dropdown(tab, "Resolution:", self.RESOLUTIONS, resolution_idx, "resolution") self.ui_controls["resolution"] = dropdown # Brightness From 518bb209676243c04e74dc8ee300e45489122d6d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 2 Dec 2025 20:35:38 +0100 Subject: [PATCH 091/192] Camera app: simplify --- .../assets/camera_settings.py | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index df68679..338bbd1 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -7,14 +7,6 @@ from mpos.content.intent import Intent class CameraSettingsActivity(Activity): - """Settings activity for comprehensive camera configuration.""" - - DEFAULT_WIDTH = 240 # 240 would be better but webcam doesn't support this (yet) - DEFAULT_HEIGHT = 240 - DEFAULT_COLORMODE = True - DEFAULT_SCANQR_WIDTH = 960 - DEFAULT_SCANQR_HEIGHT = 960 - DEFAULT_SCANQR_COLORMODE = False # Original: { 2560, 1920, 0, 0, 2623, 1951, 32, 16, 2844, 1968 } # Worked for digital zoom in C: { 2560, 1920, 0, 0, 2623, 1951, 992, 736, 2844, 1968 } @@ -65,22 +57,22 @@ class CameraSettingsActivity(Activity): "lenc": True, } - # Normal mode specific defaults (5 settings) + # Normal mode specific defaults NORMAL_DEFAULTS = { - "resolution_width": DEFAULT_WIDTH, # 240 - "resolution_height": DEFAULT_HEIGHT, # 240 - "colormode": DEFAULT_COLORMODE, # True + "resolution_width": 240, + "resolution_height": 240, + "colormode": True, "ae_level": 0, "raw_gma": True, } - # Scanqr mode specific defaults (5 settings, optimized for QR detection) + # Scanqr mode specific defaults SCANQR_DEFAULTS = { - "resolution_width": DEFAULT_SCANQR_WIDTH, # 960 - "resolution_height": DEFAULT_SCANQR_HEIGHT, # 960 - "colormode": DEFAULT_SCANQR_COLORMODE, # False (grayscale) - "ae_level": 2, # Higher exposure compensation - "raw_gma": False, # Disable gamma for better contrast + "resolution_width": 960, + "resolution_height": 960, + "colormode": False, + "ae_level": 2, # Higher auto-exposure compensation + "raw_gma": False, # Disable raw gamma for better contrast } # Resolution options for both ESP32 and webcam From 72caf6799cc69fa45af688bab1e94d61fb1b965c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 2 Dec 2025 20:58:59 +0100 Subject: [PATCH 092/192] API: restore sys.path after starting app --- internal_filesystem/lib/mpos/apps.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/lib/mpos/apps.py b/internal_filesystem/lib/mpos/apps.py index 366d914..a66102e 100644 --- a/internal_filesystem/lib/mpos/apps.py +++ b/internal_filesystem/lib/mpos/apps.py @@ -37,7 +37,7 @@ def execute_script(script_source, is_file, cwd=None, classname=None): } print(f"Thread {thread_id}: starting script") import sys - path_before = sys.path + path_before = sys.path[:] # Make a copy, not a reference if cwd: sys.path.append(cwd) try: @@ -74,8 +74,10 @@ def execute_script(script_source, is_file, cwd=None, classname=None): tb = getattr(e, '__traceback__', None) traceback.print_exception(type(e), e, tb) return False - print(f"Thread {thread_id}: script {compile_name} finished, restoring sys.path to {sys.path}") - sys.path = path_before + finally: + # Always restore sys.path, even if we return early or raise an exception + print(f"Thread {thread_id}: script {compile_name} finished, restoring sys.path from {sys.path} to {path_before}") + sys.path = path_before return True except Exception as e: print(f"Thread {thread_id}: error:") From 2b4e57b257510fda37ead457b92abfef53d1a071 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 2 Dec 2025 20:59:24 +0100 Subject: [PATCH 093/192] Camera app: fix status label visibility --- CHANGELOG.md | 1 + .../apps/com.micropythonos.camera/assets/camera_app.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f006759..15cfd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - 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 - About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name - Camera app: massive overhaul! diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py index 26faadb..2367528 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_app.py @@ -329,8 +329,7 @@ def stop_qr_decoding(self): self.scanqr_mode = False self.qr_label.set_text(lv.SYMBOL.EYE_OPEN) status_label_text = self.status_label.get_text() - if status_label_text in (self.STATUS_NO_CAMERA or self.STATUS_SEARCHING_QR or self.STATUS_FOUND_QR): # if it found a QR code, leave it - print(f"status label text {status_label_text} is a known message, not a QR code, hiding it...") + if status_label_text in (self.STATUS_NO_CAMERA, self.STATUS_SEARCHING_QR, self.STATUS_FOUND_QR): # if it found a QR code, leave it self.status_label_cont.add_flag(lv.obj.FLAG.HIDDEN) # Check if it's necessary to restart the camera: oldwidth = self.width From 82f55e06989daa9c41cd3426ee352d79208393d8 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 2 Dec 2025 21:22:38 +0100 Subject: [PATCH 094/192] Wifi app: simplify keyboard handling code --- .../assets/camera_settings.py | 11 +------ .../com.micropythonos.wifi/assets/wifi.py | 29 ------------------- internal_filesystem/lib/mpos/ui/keyboard.py | 16 ++++++++-- 3 files changed, 15 insertions(+), 41 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py index 338bbd1..8bf90ec 100644 --- a/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py +++ b/internal_filesystem/apps/com.micropythonos.camera/assets/camera_settings.py @@ -1,5 +1,4 @@ import lvgl as lv -from mpos.ui.keyboard import MposKeyboard import mpos.ui from mpos.apps import Activity @@ -233,22 +232,14 @@ def create_textarea(self, parent, label_text, min_val, max_val, default_val, pre textarea.align(lv.ALIGN.TOP_RIGHT, 0, 0) # Initialize keyboard (hidden initially) + from mpos.ui.keyboard import MposKeyboard keyboard = MposKeyboard(parent) keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) keyboard.add_flag(lv.obj.FLAG.HIDDEN) keyboard.set_textarea(textarea) - keyboard.add_event_cb(lambda e, kbd=keyboard: self.hide_keyboard(kbd), lv.EVENT.READY, None) - keyboard.add_event_cb(lambda e, kbd=keyboard: self.hide_keyboard(kbd), lv.EVENT.CANCEL, None) - textarea.add_event_cb(lambda e, kbd=keyboard: self.show_keyboard(kbd), lv.EVENT.CLICKED, None) return textarea, cont - def show_keyboard(self, kbd): - mpos.ui.anim.smooth_show(kbd) - - def hide_keyboard(self, kbd): - mpos.ui.anim.smooth_hide(kbd) - def add_buttons(self, parent): # Save/Cancel buttons at bottom button_cont = lv.obj(parent) 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 9e19357..82aeab8 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py @@ -237,7 +237,6 @@ def onCreate(self): self.password_ta.set_width(lv.pct(90)) self.password_ta.set_one_line(True) self.password_ta.align_to(label, lv.ALIGN.OUT_BOTTOM_MID, 0, 5) - self.password_ta.add_event_cb(lambda *args: self.show_keyboard(), lv.EVENT.CLICKED, None) print("PasswordPage: Creating Connect button") self.connect_button=lv.button(password_page) self.connect_button.set_size(100,40) @@ -262,16 +261,10 @@ def onCreate(self): self.keyboard=MposKeyboard(password_page) self.keyboard.align(lv.ALIGN.BOTTOM_MID,0,0) self.keyboard.set_textarea(self.password_ta) - self.keyboard.add_event_cb(lambda *args: self.hide_keyboard(), lv.EVENT.READY, None) - self.keyboard.add_event_cb(lambda *args: self.hide_keyboard(), lv.EVENT.CANCEL, None) self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) - self.keyboard.add_event_cb(self.handle_keyboard_events, lv.EVENT.VALUE_CHANGED, None) print("PasswordPage: Loading password page") self.setContentView(password_page) - def onStop(self, screen): - self.hide_keyboard() - def connect_cb(self, event): global access_points print("connect_cb: Connect button clicked") @@ -290,28 +283,6 @@ def cancel_cb(self, event): print("cancel_cb: Cancel button clicked") self.finish() - def show_keyboard(self): - self.connect_button.add_flag(lv.obj.FLAG.HIDDEN) - self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) - mpos.ui.anim.smooth_show(self.keyboard) - focusgroup = lv.group_get_default() - if focusgroup: - focusgroup.focus_next() # move the focus to the keyboard to save the user a "next" button press (optional but nice) - - def hide_keyboard(self): - mpos.ui.anim.smooth_hide(self.keyboard) - self.connect_button.remove_flag(lv.obj.FLAG.HIDDEN) - self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) - - def handle_keyboard_events(self, event): - target_obj=event.get_target_obj() # keyboard - button = target_obj.get_selected_button() - text = target_obj.get_button_text(button) - #print(f"button {button} and text {text}") - if text == lv.SYMBOL.NEW_LINE: - print("Newline pressed, closing the keyboard...") - self.hide_keyboard() - @staticmethod def setPassword(ssid, password): global access_points diff --git a/internal_filesystem/lib/mpos/ui/keyboard.py b/internal_filesystem/lib/mpos/ui/keyboard.py index 6d47d07..50164b4 100644 --- a/internal_filesystem/lib/mpos/ui/keyboard.py +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -125,8 +125,13 @@ def __init__(self, parent): self._keyboard.set_style_min_height(175, 0) def _handle_events(self, event): - # Only process VALUE_CHANGED events for actual typing - if event.get_code() != lv.EVENT.VALUE_CHANGED: + code = event.get_code() + #print(f"keyboard event code = {code}") + if code == lv.EVENT.READY or code == lv.EVENT.CANCEL: + self.hide_keyboard() + return + # Process VALUE_CHANGED events for actual typing + if code != lv.EVENT.VALUE_CHANGED: return # Get the pressed button and its text @@ -207,6 +212,7 @@ def set_textarea(self, textarea): self._textarea = textarea # NOTE: We deliberately DO NOT call self._keyboard.set_textarea() # to avoid LVGL's automatic character insertion + self._textarea.add_event_cb(lambda *args: self.show_keyboard(), lv.EVENT.CLICKED, None) def get_textarea(self): """ @@ -243,3 +249,9 @@ def __getattr__(self, name): """ # Forward to the underlying keyboard object return getattr(self._keyboard, name) + + def show_keyboard(self): + mpos.ui.anim.smooth_show(self._keyboard) + + def hide_keyboard(self): + mpos.ui.anim.smooth_hide(self._keyboard) From f37ca70a89cd2d29e2f1e9987a1bf3d473bc073d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 3 Dec 2025 22:32:36 +0100 Subject: [PATCH 095/192] API: add AudioFlinger for audio playback (i2s DAC and buzzer) API: add LightsManager for multicolor LEDs --- CLAUDE.md | 206 +++++++++++ .../assets/music_player.py | 32 +- .../assets/settings.py | 29 ++ .../lib/mpos/audio/__init__.py | 55 +++ .../lib/mpos/audio/audioflinger.py | 330 ++++++++++++++++++ .../lib/mpos/audio/stream_rtttl.py | 231 ++++++++++++ .../mpos/audio/stream_wav.py} | 297 +++++++++------- .../lib/mpos/board/fri3d_2024.py | 29 ++ internal_filesystem/lib/mpos/board/linux.py | 15 + .../board/waveshare_esp32_s3_touch_lcd_2.py | 16 + .../lib/mpos/hardware/fri3d/__init__.py | 8 + .../lib/mpos/hardware/fri3d/buzzer.py | 11 + .../lib/mpos/hardware/fri3d/leds.py | 10 + .../lib/mpos/hardware/fri3d/rtttl_data.py | 18 + internal_filesystem/lib/mpos/lights.py | 153 ++++++++ tests/mocks/hardware_mocks.py | 102 ++++++ tests/test_audioflinger.py | 243 +++++++++++++ tests/test_lightsmanager.py | 126 +++++++ tests/test_rtttl.py | 173 +++++++++ tests/test_syspath_restore.py | 78 +++++ 20 files changed, 2019 insertions(+), 143 deletions(-) create mode 100644 internal_filesystem/lib/mpos/audio/__init__.py create mode 100644 internal_filesystem/lib/mpos/audio/audioflinger.py create mode 100644 internal_filesystem/lib/mpos/audio/stream_rtttl.py rename internal_filesystem/{apps/com.micropythonos.musicplayer/assets/audio_player.py => lib/mpos/audio/stream_wav.py} (51%) create mode 100644 internal_filesystem/lib/mpos/hardware/fri3d/__init__.py create mode 100644 internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py create mode 100644 internal_filesystem/lib/mpos/hardware/fri3d/leds.py create mode 100644 internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py create mode 100644 internal_filesystem/lib/mpos/lights.py create mode 100644 tests/mocks/hardware_mocks.py create mode 100644 tests/test_audioflinger.py create mode 100644 tests/test_lightsmanager.py create mode 100644 tests/test_rtttl.py create mode 100644 tests/test_syspath_restore.py diff --git a/CLAUDE.md b/CLAUDE.md index 27d33b9..083bee2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -643,6 +643,212 @@ def defocus_handler(self, obj): - `mpos.clipboard`: System clipboard access - `mpos.battery_voltage`: Battery level reading (ESP32 only) +## Audio System (AudioFlinger) + +MicroPythonOS provides a centralized audio service called **AudioFlinger** (Android-inspired) that manages audio playback across different hardware outputs. + +### Supported Audio Devices + +- **I2S**: Digital audio output for WAV file playback (Fri3d badge, Waveshare board) +- **Buzzer**: PWM-based tone/ringtone playback (Fri3d badge only) +- **Both**: Simultaneous I2S and buzzer support +- **Null**: No audio (desktop/Linux) + +### Basic Usage + +**Playing WAV files**: +```python +import mpos.audio.audioflinger as AudioFlinger + +# Play music file +success = AudioFlinger.play_wav( + "M:/sdcard/music/song.wav", + stream_type=AudioFlinger.STREAM_MUSIC, + volume=80, + on_complete=lambda msg: print(msg) +) + +if not success: + print("Audio playback rejected (higher priority stream active)") +``` + +**Playing RTTTL ringtones**: +```python +# Play notification sound via buzzer +rtttl = "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d,8p,8b,8a,8c#,8e" +AudioFlinger.play_rtttl( + rtttl, + stream_type=AudioFlinger.STREAM_NOTIFICATION +) +``` + +**Volume control**: +```python +AudioFlinger.set_volume(70) # 0-100 +volume = AudioFlinger.get_volume() +``` + +**Stopping playback**: +```python +AudioFlinger.stop() +``` + +### Audio Focus Priority + +AudioFlinger implements priority-based audio focus (Android-inspired): +- **STREAM_ALARM** (priority 2): Highest priority +- **STREAM_NOTIFICATION** (priority 1): Medium priority +- **STREAM_MUSIC** (priority 0): Lowest priority + +Higher priority streams automatically interrupt lower priority streams. Equal or lower priority streams are rejected while a stream is playing. + +### Hardware Support Matrix + +| Board | I2S | Buzzer | LEDs | +|-------|-----|--------|------| +| Fri3d 2024 Badge | ✓ (GPIO 2, 47, 16) | ✓ (GPIO 46) | ✓ (5 RGB, GPIO 12) | +| Waveshare ESP32-S3 | ✓ (GPIO 2, 47, 16) | ✗ | ✗ | +| Linux/macOS | ✗ | ✗ | ✗ | + +### Configuration + +Audio device preference is configured in Settings app under "Advanced Settings": +- **Auto-detect**: Use available hardware (default) +- **I2S (Digital Audio)**: Digital audio only +- **Buzzer (PWM Tones)**: Tones/ringtones only +- **Both I2S and Buzzer**: Use both devices +- **Disabled**: No audio + +**Note**: Changing the audio device requires a restart to take effect. + +### Implementation Details + +- **Location**: `lib/mpos/audio/audioflinger.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Thread-safe**: Uses locks for concurrent access +- **Background playback**: Runs in separate thread +- **WAV support**: 8/16/24/32-bit PCM, mono/stereo, auto-upsampling to ≥22050 Hz +- **RTTTL parser**: Full Ring Tone Text Transfer Language support with exponential volume curve + +## LED Control (LightsManager) + +MicroPythonOS provides a simple LED control service for NeoPixel RGB LEDs (Fri3d badge only). + +### Basic Usage + +**Check availability**: +```python +import mpos.lights as LightsManager + +if LightsManager.is_available(): + print(f"LEDs available: {LightsManager.get_led_count()}") +``` + +**Control individual LEDs**: +```python +# Set LED 0 to red (buffered) +LightsManager.set_led(0, 255, 0, 0) + +# Set LED 1 to green +LightsManager.set_led(1, 0, 255, 0) + +# Update hardware +LightsManager.write() +``` + +**Control all LEDs**: +```python +# Set all LEDs to blue +LightsManager.set_all(0, 0, 255) +LightsManager.write() + +# Clear all LEDs (black) +LightsManager.clear() +LightsManager.write() +``` + +**Notification colors**: +```python +# Convenience method for common colors +LightsManager.set_notification_color("red") +LightsManager.set_notification_color("green") +# Available: red, green, blue, yellow, orange, purple, white +``` + +### Custom Animations + +LightsManager provides one-shot control only (no built-in animations). Apps implement custom animations using the `update_frame()` pattern: + +```python +import time +import mpos.lights as LightsManager + +def blink_pattern(): + for _ in range(5): + LightsManager.set_all(255, 0, 0) + LightsManager.write() + time.sleep_ms(200) + + LightsManager.clear() + LightsManager.write() + time.sleep_ms(200) + +def rainbow_cycle(): + colors = [ + (255, 0, 0), # Red + (255, 128, 0), # Orange + (255, 255, 0), # Yellow + (0, 255, 0), # Green + (0, 0, 255), # Blue + ] + + for i, color in enumerate(colors): + LightsManager.set_led(i, *color) + + LightsManager.write() +``` + +**For frame-based LED animations**, use the TaskHandler event system: + +```python +import mpos.ui +import time + +class LEDAnimationActivity(Activity): + last_time = 0 + led_index = 0 + + def onResume(self, screen): + self.last_time = time.ticks_ms() + mpos.ui.task_handler.add_event_cb(self.update_frame, 1) + + def onPause(self, screen): + mpos.ui.task_handler.remove_event_cb(self.update_frame) + LightsManager.clear() + LightsManager.write() + + def update_frame(self, a, b): + current_time = time.ticks_ms() + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 + self.last_time = current_time + + # Update animation every 0.5 seconds + if delta_time > 0.5: + LightsManager.clear() + LightsManager.set_led(self.led_index, 0, 255, 0) + LightsManager.write() + self.led_index = (self.led_index + 1) % LightsManager.get_led_count() +``` + +### Implementation Details + +- **Location**: `lib/mpos/lights.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Hardware**: 5 NeoPixel RGB LEDs on GPIO 12 (Fri3d badge) +- **Buffered**: LED colors are buffered until `write()` is called +- **Thread-safe**: No locking (single-threaded usage recommended) +- **Desktop**: Functions return `False` (no-op) on desktop builds + ## Animations and Game Loops MicroPythonOS supports frame-based animations and game loops using the TaskHandler event system. This pattern is used for games, particle effects, and smooth animations. 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 75ba010..1438093 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -1,13 +1,11 @@ import machine import os -import _thread import time from mpos.apps import Activity, Intent import mpos.sdcard import mpos.ui - -from audio_player import AudioPlayer +import mpos.audio.audioflinger as AudioFlinger class MusicPlayer(Activity): @@ -68,17 +66,17 @@ def onCreate(self): self._filename = self.getIntent().extras.get("filename") qr_screen = lv.obj() self._slider_label=lv.label(qr_screen) - self._slider_label.set_text(f"Volume: {AudioPlayer.get_volume()}%") + self._slider_label.set_text(f"Volume: {AudioFlinger.get_volume()}%") self._slider_label.align(lv.ALIGN.TOP_MID,0,lv.pct(4)) self._slider=lv.slider(qr_screen) self._slider.set_range(0,100) - self._slider.set_value(AudioPlayer.get_volume(), False) + self._slider.set_value(AudioFlinger.get_volume(), 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() self._slider_label.set_text(f"Volume: {volume_int}%") - AudioPlayer.set_volume(volume_int) + AudioFlinger.set_volume(volume_int) self._slider.add_event_cb(volume_slider_changed,lv.EVENT.VALUE_CHANGED,None) self._filename_label = lv.label(qr_screen) self._filename_label.align(lv.ALIGN.CENTER,0,0) @@ -104,11 +102,23 @@ def onResume(self, screen): if not self._filename: print("Not playing any file...") else: - print("Starting thread to play file {self._filename}") - AudioPlayer.stop_playing() + print(f"Playing file {self._filename}") + AudioFlinger.stop() time.sleep(0.1) - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(AudioPlayer.play_wav, (self._filename,self.player_finished,)) + + success = AudioFlinger.play_wav( + self._filename, + stream_type=AudioFlinger.STREAM_MUSIC, + on_complete=self.player_finished + ) + + if not success: + error_msg = "Error: Audio device unavailable or busy" + print(error_msg) + self.update_ui_threadsafe_if_foreground( + self._filename_label.set_text, + error_msg + ) def focus_obj(self, obj): obj.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) @@ -118,7 +128,7 @@ def defocus_obj(self, obj): obj.set_style_border_width(0, lv.PART.MAIN) def stop_button_clicked(self, event): - AudioPlayer.stop_playing() + AudioFlinger.stop() self.finish() def player_finished(self, result=None): diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index 51262e7..5633191 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -43,6 +43,7 @@ def __init__(self): {"title": "Theme Color", "key": "theme_primary_color", "value_label": None, "cont": None, "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors}, {"title": "Timezone", "key": "timezone", "value_label": None, "cont": None, "ui": "dropdown", "ui_options": self.get_timezone_tuples(), "changed_callback": lambda : mpos.time.refresh_timezone_preference()}, # Advanced settings, alphabetically: + {"title": "Audio Output Device", "key": "audio_device", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Auto-detect", "auto"), ("I2S (Digital Audio)", "i2s"), ("Buzzer (PWM Tones)", "buzzer"), ("Both I2S and Buzzer", "both"), ("Disabled", "null")], "changed_callback": self.audio_device_changed}, {"title": "Auto Start App", "key": "auto_start_app", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [(app.name, app.fullname) for app in PackageManager.get_app_list()]}, {"title": "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 @@ -111,6 +112,34 @@ def startSettingActivity(self, setting): def get_timezone_tuples(): return [(tz, tz) for tz in mpos.time.get_timezones()] + def audio_device_changed(self): + """ + Called when audio device setting changes. + Note: Changing device type at runtime requires a restart for full effect. + AudioFlinger initialization happens at boot. + """ + import mpos.audio.audioflinger as AudioFlinger + + new_value = self.prefs.get_string("audio_device", "auto") + print(f"Audio device setting changed to: {new_value}") + print("Note: Restart required for audio device change to take effect") + + # Map setting values to device types + device_map = { + "auto": AudioFlinger.get_device_type(), # Keep current + "i2s": AudioFlinger.DEVICE_I2S, + "buzzer": AudioFlinger.DEVICE_BUZZER, + "both": AudioFlinger.DEVICE_BOTH, + "null": AudioFlinger.DEVICE_NULL, + } + + desired_device = device_map.get(new_value, AudioFlinger.get_device_type()) + current_device = AudioFlinger.get_device_type() + + if desired_device != current_device: + print(f"Desired device type ({desired_device}) differs from current ({current_device})") + print("Full device type change requires restart - current session continues with existing device") + def focus_container(self, container): print(f"container {container} focused, setting border...") container.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) diff --git a/internal_filesystem/lib/mpos/audio/__init__.py b/internal_filesystem/lib/mpos/audio/__init__.py new file mode 100644 index 0000000..86526aa --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/__init__.py @@ -0,0 +1,55 @@ +# AudioFlinger - Centralized Audio Management Service for MicroPythonOS +# Android-inspired audio routing with priority-based audio focus + +from . import audioflinger + +# Re-export main API +from .audioflinger import ( + # Device types + DEVICE_NULL, + DEVICE_I2S, + DEVICE_BUZZER, + DEVICE_BOTH, + + # Stream types + STREAM_MUSIC, + STREAM_NOTIFICATION, + STREAM_ALARM, + + # Core functions + init, + play_wav, + play_rtttl, + stop, + pause, + resume, + set_volume, + get_volume, + get_device_type, + is_playing, +) + +__all__ = [ + # Device types + 'DEVICE_NULL', + 'DEVICE_I2S', + 'DEVICE_BUZZER', + 'DEVICE_BOTH', + + # Stream types + 'STREAM_MUSIC', + 'STREAM_NOTIFICATION', + 'STREAM_ALARM', + + # Functions + 'init', + 'play_wav', + 'play_rtttl', + 'stop', + 'pause', + 'resume', + 'set_volume', + 'get_volume', + 'get_device_type', + 'is_playing', +] diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py new file mode 100644 index 0000000..47dfcd9 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -0,0 +1,330 @@ +# AudioFlinger - Core Audio Management Service +# Centralized audio routing with priority-based audio focus (Android-inspired) +# Supports I2S (digital audio) and PWM buzzer (tones/ringtones) + +# Device type constants +DEVICE_NULL = 0 # No audio hardware (desktop fallback) +DEVICE_I2S = 1 # Digital audio output (WAV playback) +DEVICE_BUZZER = 2 # PWM buzzer (tones/RTTTL) +DEVICE_BOTH = 3 # Both I2S and buzzer available + +# Stream type constants (priority order: higher number = higher priority) +STREAM_MUSIC = 0 # Background music (lowest priority) +STREAM_NOTIFICATION = 1 # Notification sounds (medium priority) +STREAM_ALARM = 2 # Alarms/alerts (highest priority) + +# Module-level state (singleton pattern, follows battery_voltage.py) +_device_type = DEVICE_NULL +_i2s_pins = None # I2S pin configuration dict (created per-stream) +_buzzer_instance = None # PWM buzzer instance +_current_stream = None # Currently playing stream +_volume = 70 # System volume (0-100) +_stream_lock = None # Thread lock for stream management + + +def init(device_type, i2s_pins=None, buzzer_instance=None): + """ + Initialize AudioFlinger with hardware configuration. + + Args: + device_type: One of DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH + i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S devices) + buzzer_instance: PWM instance for buzzer (for buzzer devices) + """ + global _device_type, _i2s_pins, _buzzer_instance, _stream_lock + + _device_type = device_type + _i2s_pins = i2s_pins + _buzzer_instance = buzzer_instance + + # Initialize thread lock for stream management + try: + import _thread + _stream_lock = _thread.allocate_lock() + except ImportError: + # Desktop mode - no threading support + _stream_lock = None + + device_names = { + DEVICE_NULL: "NULL (no audio)", + DEVICE_I2S: "I2S (digital audio)", + DEVICE_BUZZER: "Buzzer (PWM tones)", + DEVICE_BOTH: "Both (I2S + Buzzer)" + } + + print(f"AudioFlinger initialized: {device_names.get(device_type, 'Unknown')}") + + +def _check_audio_focus(stream_type): + """ + Check if a stream with the given type can start playback. + Implements priority-based audio focus (Android-inspired). + + Args: + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + + Returns: + bool: True if stream can start, False if rejected + """ + global _current_stream + + if not _current_stream: + return True # No stream playing, OK to start + + if not _current_stream.is_playing(): + return True # Current stream finished, OK to start + + # Check priority + if stream_type <= _current_stream.stream_type: + print(f"AudioFlinger: Stream rejected (priority {stream_type} <= current {_current_stream.stream_type})") + return False + + # Higher priority stream - interrupt current + print(f"AudioFlinger: Interrupting stream (priority {stream_type} > current {_current_stream.stream_type})") + _current_stream.stop() + return True + + +def _playback_thread(stream): + """ + Background thread function for audio playback. + + Args: + stream: Stream instance (WAVStream or RTTTLStream) + """ + global _current_stream + + # Acquire lock and set as current stream + if _stream_lock: + _stream_lock.acquire() + _current_stream = stream + if _stream_lock: + _stream_lock.release() + + try: + # Run playback (blocks until complete or stopped) + stream.play() + except Exception as e: + print(f"AudioFlinger: Playback error: {e}") + finally: + # Clear current stream + if _stream_lock: + _stream_lock.acquire() + if _current_stream == stream: + _current_stream = None + if _stream_lock: + _stream_lock.release() + + +def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None): + """ + Play WAV file via I2S. + + Args: + file_path: Path to WAV file (e.g., "M:/sdcard/music/song.wav") + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Override volume (0-100), or None to use system volume + on_complete: Callback function(message) called when playback finishes + + Returns: + bool: True if playback started, False if rejected or unavailable + """ + if _device_type not in (DEVICE_I2S, DEVICE_BOTH): + print("AudioFlinger: play_wav() failed - no I2S device available") + return False + + if not _i2s_pins: + print("AudioFlinger: play_wav() failed - I2S pins not configured") + return False + + # Check audio focus + if _stream_lock: + _stream_lock.acquire() + can_start = _check_audio_focus(stream_type) + if _stream_lock: + _stream_lock.release() + + if not can_start: + return False + + # Create stream and start playback in background thread + try: + from mpos.audio.stream_wav import WAVStream + import _thread + import mpos.apps + + stream = WAVStream( + file_path=file_path, + stream_type=stream_type, + volume=volume if volume is not None else _volume, + i2s_pins=_i2s_pins, + on_complete=on_complete + ) + + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_playback_thread, (stream,)) + return True + + except Exception as e: + print(f"AudioFlinger: play_wav() failed: {e}") + return False + + +def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_complete=None): + """ + Play RTTTL ringtone via buzzer. + + Args: + rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:8e6,8d6...") + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Override volume (0-100), or None to use system volume + on_complete: Callback function(message) called when playback finishes + + Returns: + bool: True if playback started, False if rejected or unavailable + """ + if _device_type not in (DEVICE_BUZZER, DEVICE_BOTH): + print("AudioFlinger: play_rtttl() failed - no buzzer device available") + return False + + if not _buzzer_instance: + print("AudioFlinger: play_rtttl() failed - buzzer not initialized") + return False + + # Check audio focus + if _stream_lock: + _stream_lock.acquire() + can_start = _check_audio_focus(stream_type) + if _stream_lock: + _stream_lock.release() + + if not can_start: + return False + + # Create stream and start playback in background thread + try: + from mpos.audio.stream_rtttl import RTTTLStream + import _thread + import mpos.apps + + stream = RTTTLStream( + rtttl_string=rtttl_string, + stream_type=stream_type, + volume=volume if volume is not None else _volume, + buzzer_instance=_buzzer_instance, + on_complete=on_complete + ) + + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_playback_thread, (stream,)) + return True + + except Exception as e: + print(f"AudioFlinger: play_rtttl() failed: {e}") + return False + + +def stop(): + """Stop current audio playback.""" + global _current_stream + + if _stream_lock: + _stream_lock.acquire() + + if _current_stream: + _current_stream.stop() + print("AudioFlinger: Playback stopped") + else: + print("AudioFlinger: No playback to stop") + + if _stream_lock: + _stream_lock.release() + + +def pause(): + """ + Pause current audio playback (if supported by stream). + Note: Most streams don't support pause, only stop. + """ + global _current_stream + + if _stream_lock: + _stream_lock.acquire() + + if _current_stream and hasattr(_current_stream, 'pause'): + _current_stream.pause() + print("AudioFlinger: Playback paused") + else: + print("AudioFlinger: Pause not supported or no playback active") + + if _stream_lock: + _stream_lock.release() + + +def resume(): + """ + Resume paused audio playback (if supported by stream). + Note: Most streams don't support resume, only play. + """ + global _current_stream + + if _stream_lock: + _stream_lock.acquire() + + if _current_stream and hasattr(_current_stream, 'resume'): + _current_stream.resume() + print("AudioFlinger: Playback resumed") + else: + print("AudioFlinger: Resume not supported or no playback active") + + if _stream_lock: + _stream_lock.release() + + +def set_volume(volume): + """ + Set system volume (affects new streams, not current playback). + + Args: + volume: Volume level (0-100) + """ + global _volume + _volume = max(0, min(100, volume)) + + +def get_volume(): + """ + Get system volume. + + Returns: + int: Current system volume (0-100) + """ + return _volume + + +def get_device_type(): + """ + Get configured audio device type. + + Returns: + int: Device type (DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH) + """ + return _device_type + + +def is_playing(): + """ + Check if audio is currently playing. + + Returns: + bool: True if playback active, False otherwise + """ + if _stream_lock: + _stream_lock.acquire() + + result = _current_stream is not None and _current_stream.is_playing() + + if _stream_lock: + _stream_lock.release() + + return result diff --git a/internal_filesystem/lib/mpos/audio/stream_rtttl.py b/internal_filesystem/lib/mpos/audio/stream_rtttl.py new file mode 100644 index 0000000..00bae75 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_rtttl.py @@ -0,0 +1,231 @@ +# RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger +# Ring Tone Text Transfer Language parser and player +# Ported from Fri3d Camp 2024 Badge firmware + +import math +import time + + +class RTTTLStream: + """ + RTTTL (Ring Tone Text Transfer Language) parser and player. + Format: "name:defaults:notes" + Example: "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d" + + See: https://en.wikipedia.org/wiki/Ring_Tone_Text_Transfer_Language + """ + + # Note frequency table (A-G, with sharps) + _NOTES = [ + 440.0, # A + 493.9, # B or H + 261.6, # C + 293.7, # D + 329.6, # E + 349.2, # F + 392.0, # G + 0.0, # pad + + 466.2, # A# + 0.0, # pad + 277.2, # C# + 311.1, # D# + 0.0, # pad + 370.0, # F# + 415.3, # G# + 0.0, # pad + ] + + def __init__(self, rtttl_string, stream_type, volume, buzzer_instance, on_complete): + """ + Initialize RTTTL stream. + + Args: + rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:...") + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Volume level (0-100) + buzzer_instance: PWM buzzer instance + on_complete: Callback function(message) when playback finishes + """ + self.stream_type = stream_type + self.volume = volume + self.buzzer = buzzer_instance + self.on_complete = on_complete + self._keep_running = True + self._is_playing = False + + # Parse RTTTL format + tune_pieces = rtttl_string.split(':') + if len(tune_pieces) != 3: + raise ValueError('RTTTL should contain exactly 2 colons') + + self.name = tune_pieces[0] + self.tune = tune_pieces[2] + self.tune_idx = 0 + self._parse_defaults(tune_pieces[1]) + + def is_playing(self): + """Check if stream is currently playing.""" + return self._is_playing + + def stop(self): + """Stop playback.""" + self._keep_running = False + + def _parse_defaults(self, defaults): + """ + Parse default values from RTTTL format. + Example: "d=4,o=5,b=140" + """ + self.default_duration = 4 + self.default_octave = 5 + self.bpm = 120 + + for item in defaults.split(','): + setting = item.split('=') + if len(setting) != 2: + continue + + key = setting[0].strip() + value = int(setting[1].strip()) + + if key == 'o': + self.default_octave = value + elif key == 'd': + self.default_duration = value + elif key == 'b': + self.bpm = value + + # Calculate milliseconds per whole note + # 240000 = 60 sec/min * 4 beats/whole-note * 1000 msec/sec + self.msec_per_whole_note = 240000.0 / self.bpm + + def _next_char(self): + """Get next character from tune string.""" + if self.tune_idx < len(self.tune): + char = self.tune[self.tune_idx] + self.tune_idx += 1 + if char == ',': + char = ' ' + return char + return '|' # End marker + + def _notes(self): + """ + Generator that yields (frequency, duration_ms) tuples. + + Yields: + tuple: (frequency_hz, duration_ms) for each note + """ + while True: + # Skip blank characters and commas + char = self._next_char() + while char == ' ': + char = self._next_char() + + # Parse duration (if present) + # Duration of 1 = whole note, 8 = 1/8 note + duration = 0 + while char.isdigit(): + duration *= 10 + duration += ord(char) - ord('0') + char = self._next_char() + + if duration == 0: + duration = self.default_duration + + if char == '|': # End of tune + return + + # Parse note letter + note = char.lower() + if 'a' <= note <= 'g': + note_idx = ord(note) - ord('a') + elif note == 'h': + note_idx = 1 # H is equivalent to B + elif note == 'p': + note_idx = 7 # Pause + else: + note_idx = 7 # Unknown = pause + + char = self._next_char() + + # Check for sharp + if char == '#': + note_idx += 8 + char = self._next_char() + + # Check for duration modifier (dot) before octave + duration_multiplier = 1.0 + if char == '.': + duration_multiplier = 1.5 + char = self._next_char() + + # Check for octave + if '4' <= char <= '7': + octave = ord(char) - ord('0') + char = self._next_char() + else: + octave = self.default_octave + + # Check for duration modifier (dot) after octave + if char == '.': + duration_multiplier = 1.5 + char = self._next_char() + + # Calculate frequency and duration + freq = self._NOTES[note_idx] * (1 << (octave - 4)) + msec = (self.msec_per_whole_note / duration) * duration_multiplier + + yield freq, msec + + def play(self): + """Play RTTTL tune via buzzer (runs in background thread).""" + self._is_playing = True + + # Calculate exponential duty cycle for perceptually linear volume + if self.volume <= 0: + duty = 0 + else: + volume = min(100, self.volume) + + # Exponential volume curve + # Maximum volume is at 50% duty cycle (32768 when using duty_u16) + # Minimum is 4 (absolute minimum for audible PWM) + divider = 10 + duty = int( + ((math.exp(volume / divider) - math.exp(0.1)) / + (math.exp(10) - math.exp(0.1)) * (32768 - 4)) + 4 + ) + + print(f"RTTTLStream: Playing '{self.name}' (volume {self.volume}%)") + + try: + for freq, msec in self._notes(): + if not self._keep_running: + print("RTTTLStream: Playback stopped by user") + break + + # Play tone + if freq > 0: + self.buzzer.freq(int(freq)) + self.buzzer.duty_u16(duty) + + # Play for 90% of duration, silent for 10% (note separation) + time.sleep_ms(int(msec * 0.9)) + self.buzzer.duty_u16(0) + time.sleep_ms(int(msec * 0.1)) + + print(f"RTTTLStream: Finished playing '{self.name}'") + if self.on_complete: + self.on_complete(f"Finished: {self.name}") + + except Exception as e: + print(f"RTTTLStream: Error: {e}") + if self.on_complete: + self.on_complete(f"Error: {e}") + + finally: + # Ensure buzzer is off + self.buzzer.duty_u16(0) + self._is_playing = False diff --git a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/audio_player.py b/internal_filesystem/lib/mpos/audio/stream_wav.py similarity index 51% rename from internal_filesystem/apps/com.micropythonos.musicplayer/assets/audio_player.py rename to internal_filesystem/lib/mpos/audio/stream_wav.py index 0b29873..4c52706 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/audio_player.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -1,29 +1,83 @@ +# 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 + import machine import os import time -import micropython - - -# ---------------------------------------------------------------------- -# AudioPlayer – robust, volume-controllable WAV player -# Supports 8 / 16 / 24 / 32-bit PCM, mono + stereo -# Auto-up-samples any rate < 22050 Hz to >=22050 Hz -# ---------------------------------------------------------------------- -class AudioPlayer: - _i2s = None - _volume = 50 # 0-100 - _keep_running = True - - # ------------------------------------------------------------------ - # WAV header parser – returns bit-depth - # ------------------------------------------------------------------ +import sys + +# Volume scaling function - regular Python version +# Note: Viper optimization removed because @micropython.viper decorator +# causes cross-compiler errors on Unix/macOS builds even inside conditionals +def _scale_audio(buf, num_bytes, scale_fixed): + """Volume scaling for 16-bit audio samples.""" + for i in range(0, num_bytes, 2): + lo = buf[i] + hi = buf[i + 1] + sample = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample = (sample * scale_fixed) // 32768 + if sample > 32767: + sample = 32767 + elif sample < -32768: + sample = -32768 + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + + +class WAVStream: + """ + WAV file playback stream with I2S output. + Supports 8/16/24/32-bit PCM, mono and stereo, auto-upsampling to >=22050 Hz. + """ + + def __init__(self, file_path, stream_type, volume, i2s_pins, on_complete): + """ + Initialize WAV stream. + + Args: + file_path: Path to WAV file + stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM) + volume: Volume level (0-100) + i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers + on_complete: Callback function(message) when playback finishes + """ + self.file_path = file_path + self.stream_type = stream_type + self.volume = volume + self.i2s_pins = i2s_pins + self.on_complete = on_complete + self._keep_running = True + self._is_playing = False + self._i2s = None + + def is_playing(self): + """Check if stream is currently playing.""" + return self._is_playing + + def stop(self): + """Stop playback.""" + self._keep_running = False + + # ---------------------------------------------------------------------- + # WAV header parser - returns bit-depth and format info + # ---------------------------------------------------------------------- @staticmethod - def find_data_chunk(f): - """Return (data_start, data_size, sample_rate, channels, bits_per_sample)""" + def _find_data_chunk(f): + """ + Parse WAV header and find data chunk. + + Returns: + tuple: (data_start, data_size, sample_rate, channels, bits_per_sample) + """ f.seek(0) if f.read(4) != b'RIFF': raise ValueError("Not a RIFF (standard .wav) file") + file_size = int.from_bytes(f.read(4), 'little') + 8 + if f.read(4) != b'WAVE': raise ValueError("Not a WAVE (standard .wav) file") @@ -31,87 +85,61 @@ def find_data_chunk(f): sample_rate = None channels = None bits_per_sample = None + while pos < file_size: f.seek(pos) chunk_id = f.read(4) if len(chunk_id) < 4: break + chunk_size = int.from_bytes(f.read(4), 'little') + if chunk_id == b'fmt ': fmt = f.read(chunk_size) if len(fmt) < 16: raise ValueError("Invalid fmt chunk") + if int.from_bytes(fmt[0:2], 'little') != 1: raise ValueError("Only PCM supported") + channels = int.from_bytes(fmt[2:4], 'little') if channels not in (1, 2): raise ValueError("Only mono or stereo supported") + sample_rate = int.from_bytes(fmt[4:8], 'little') bits_per_sample = int.from_bytes(fmt[14:16], 'little') + if bits_per_sample not in (8, 16, 24, 32): raise ValueError("Only 8/16/24/32-bit PCM supported") + elif chunk_id == b'data': return f.tell(), chunk_size, sample_rate, channels, bits_per_sample + pos += 8 + chunk_size if chunk_size % 2: pos += 1 - raise ValueError("No 'data' chunk found") - # ------------------------------------------------------------------ - # Volume control - # ------------------------------------------------------------------ - @classmethod - def set_volume(cls, volume: int): - volume = max(0, min(100, volume)) - cls._volume = volume - - @classmethod - def get_volume(cls) -> int: - return cls._volume - - @classmethod - def stop_playing(cls): - print("stop_playing()") - cls._keep_running = False - - # ------------------------------------------------------------------ - # 1. Up-sample 16-bit buffer (zero-order-hold) - # ------------------------------------------------------------------ - @staticmethod - def _upsample_buffer(raw: bytearray, factor: int) -> bytearray: - if factor == 1: - return raw - upsampled = bytearray(len(raw) * factor) - out_idx = 0 - for i in range(0, len(raw), 2): - lo = raw[i] - hi = raw[i + 1] - for _ in range(factor): - upsampled[out_idx] = lo - upsampled[out_idx + 1] = hi - out_idx += 2 - return upsampled + raise ValueError("No 'data' chunk found") - # ------------------------------------------------------------------ - # 2. Convert 8-bit to 16-bit (non-viper, Viper-safe) - # ------------------------------------------------------------------ + # ---------------------------------------------------------------------- + # Bit depth conversion functions + # ---------------------------------------------------------------------- @staticmethod - def _convert_8_to_16(buf: bytearray) -> bytearray: + def _convert_8_to_16(buf): + """Convert 8-bit unsigned PCM to 16-bit signed PCM.""" out = bytearray(len(buf) * 2) j = 0 for i in range(len(buf)): u8 = buf[i] s16 = (u8 - 128) << 8 - out[j] = s16 & 0xFF + out[j] = s16 & 0xFF out[j + 1] = (s16 >> 8) & 0xFF j += 2 return out - # ------------------------------------------------------------------ - # 3. Convert 24-bit to 16-bit (non-viper) - # ------------------------------------------------------------------ @staticmethod - def _convert_24_to_16(buf: bytearray) -> bytearray: + def _convert_24_to_16(buf): + """Convert 24-bit PCM to 16-bit PCM.""" samples = len(buf) // 3 out = bytearray(samples * 2) j = 0 @@ -123,16 +151,14 @@ def _convert_24_to_16(buf: bytearray) -> bytearray: if b2 & 0x80: s24 -= 0x1000000 s16 = s24 >> 8 - out[i * 2] = s16 & 0xFF + out[i * 2] = s16 & 0xFF out[i * 2 + 1] = (s16 >> 8) & 0xFF j += 3 return out - # ------------------------------------------------------------------ - # 4. Convert 32-bit to 16-bit (non-viper) - # ------------------------------------------------------------------ @staticmethod - def _convert_32_to_16(buf: bytearray) -> bytearray: + def _convert_32_to_16(buf): + """Convert 32-bit PCM to 16-bit PCM.""" samples = len(buf) // 4 out = bytearray(samples * 2) j = 0 @@ -145,28 +171,49 @@ def _convert_32_to_16(buf: bytearray) -> bytearray: if b3 & 0x80: s32 -= 0x100000000 s16 = s32 >> 16 - out[i * 2] = s16 & 0xFF + out[i * 2] = s16 & 0xFF out[i * 2 + 1] = (s16 >> 8) & 0xFF j += 4 return out - # ------------------------------------------------------------------ + # ---------------------------------------------------------------------- + # Upsampling (zero-order-hold) + # ---------------------------------------------------------------------- + @staticmethod + def _upsample_buffer(raw, factor): + """Upsample 16-bit buffer by repeating samples.""" + if factor == 1: + return raw + + upsampled = bytearray(len(raw) * factor) + out_idx = 0 + for i in range(0, len(raw), 2): + lo = raw[i] + hi = raw[i + 1] + for _ in range(factor): + upsampled[out_idx] = lo + upsampled[out_idx + 1] = hi + out_idx += 2 + return upsampled + + # ---------------------------------------------------------------------- # Main playback routine - # ------------------------------------------------------------------ - @classmethod - def play_wav(cls, filename, result_callback=None): - cls._keep_running = True + # ---------------------------------------------------------------------- + def play(self): + """Main playback routine (runs in background thread).""" + self._is_playing = True + try: - with open(filename, 'rb') as f: - st = os.stat(filename) + with open(self.file_path, 'rb') as f: + st = os.stat(self.file_path) file_size = st[6] - print(f"File size: {file_size} bytes") + print(f"WAVStream: Playing {self.file_path} ({file_size} bytes)") - # ----- parse header ------------------------------------------------ + # Parse WAV header data_start, data_size, original_rate, channels, bits_per_sample = \ - cls.find_data_chunk(f) + self._find_data_chunk(f) - # ----- decide playback rate (force >=22050 Hz) -------------------- + # Decide playback rate (force >=22050 Hz) target_rate = 22050 if original_rate >= target_rate: playback_rate = original_rate @@ -175,20 +222,20 @@ def play_wav(cls, filename, result_callback=None): upsample_factor = (target_rate + original_rate - 1) // original_rate playback_rate = original_rate * upsample_factor - print(f"Original: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch " - f"to Playback: {playback_rate} Hz (factor {upsample_factor})") + print(f"WAVStream: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch") + print(f"WAVStream: Playback at {playback_rate} Hz (factor {upsample_factor})") if data_size > file_size - data_start: data_size = file_size - data_start - # ----- I2S init (always 16-bit) ---------------------------------- + # Initialize I2S (always 16-bit output) try: i2s_format = machine.I2S.MONO if channels == 1 else machine.I2S.STEREO - cls._i2s = machine.I2S( + self._i2s = machine.I2S( 0, - sck=machine.Pin(2, machine.Pin.OUT), - ws =machine.Pin(47, machine.Pin.OUT), - sd =machine.Pin(16, machine.Pin.OUT), + sck=machine.Pin(self.i2s_pins['sck'], machine.Pin.OUT), + ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT), + sd=machine.Pin(self.i2s_pins['sd'], machine.Pin.OUT), mode=machine.I2S.TX, bits=16, format=i2s_format, @@ -196,38 +243,22 @@ def play_wav(cls, filename, result_callback=None): ibuf=32000 ) except Exception as e: - print(f"Warning: simulating playback (I2S init failed): {e}") + print(f"WAVStream: I2S init failed: {e}") + return - print(f"Playing {data_size} original bytes (vol {cls._volume}%) ...") + print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)") f.seek(data_start) - # ----- Viper volume scaler (16-bit only) ------------------------- - @micropython.viper # throws "invalid micropython decorator" on macOS / darwin - def scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): - for i in range(0, num_bytes, 2): - lo = int(buf[i]) - hi = int(buf[i+1]) - sample = (hi << 8) | lo - if hi & 128: - sample -= 65536 - sample = (sample * scale_fixed) // 32768 - if sample > 32767: - sample = 32767 - elif sample < -32768: - sample = -32768 - buf[i] = sample & 255 - buf[i+1] = (sample >> 8) & 255 - chunk_size = 4096 bytes_per_original_sample = (bits_per_sample // 8) * channels total_original = 0 while total_original < data_size: - if not cls._keep_running: - print("Playback stopped by user.") + if not self._keep_running: + print("WAVStream: Playback stopped by user") break - # ---- read a whole-sample chunk of original data ------------- + # Read chunk of original data to_read = min(chunk_size, data_size - total_original) to_read -= (to_read % bytes_per_original_sample) if to_read <= 0: @@ -237,44 +268,46 @@ def scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): if not raw: break - # ---- 1. Convert bit-depth to 16-bit (non-viper) ------------- + # 1. Convert bit-depth to 16-bit if bits_per_sample == 8: - raw = cls._convert_8_to_16(raw) + raw = self._convert_8_to_16(raw) elif bits_per_sample == 24: - raw = cls._convert_24_to_16(raw) + raw = self._convert_24_to_16(raw) elif bits_per_sample == 32: - raw = cls._convert_32_to_16(raw) - # 16-bit to unchanged + raw = self._convert_32_to_16(raw) + # 16-bit unchanged - # ---- 2. Up-sample if needed --------------------------------- + # 2. Upsample if needed if upsample_factor > 1: - raw = cls._upsample_buffer(raw, upsample_factor) + raw = self._upsample_buffer(raw, upsample_factor) - # ---- 3. Volume scaling -------------------------------------- - scale = cls._volume / 100.0 + # 3. Volume scaling + scale = self.volume / 100.0 if scale < 1.0: scale_fixed = int(scale * 32768) - scale_audio(raw, len(raw), scale_fixed) + _scale_audio(raw, len(raw), scale_fixed) - # ---- 4. Output --------------------------------------------- - if cls._i2s: - cls._i2s.write(raw) + # 4. Output to I2S + if self._i2s: + self._i2s.write(raw) else: + # Simulate playback timing if no I2S num_samples = len(raw) // (2 * channels) time.sleep(num_samples / playback_rate) total_original += to_read - print(f"Finished playing {filename}") - if result_callback: - result_callback(f"Finished playing {filename}") - except Exception as e: - print(f"Error: {e}\nwhile playing {filename}") - if result_callback: - result_callback(f"Error: {e}\nwhile playing {filename}") - finally: - if cls._i2s: - cls._i2s.deinit() - cls._i2s = None + print(f"WAVStream: Finished playing {self.file_path}") + if self.on_complete: + self.on_complete(f"Finished: {self.file_path}") + except Exception as e: + print(f"WAVStream: Error: {e}") + if self.on_complete: + self.on_complete(f"Error: {e}") + finally: + self._is_playing = False + if self._i2s: + self._i2s.deinit() + self._i2s = None diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 922ecf4..2ae6689 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -289,4 +289,33 @@ def adc_to_voltage(adc_value): import mpos.sdcard mpos.sdcard.init(spi_bus, cs_pin=14) +# === AUDIO HARDWARE === +from machine import PWM, Pin +import mpos.audio.audioflinger as AudioFlinger + +# Initialize buzzer (GPIO 46) +buzzer = PWM(Pin(46), freq=550, duty=0) + +# I2S pin configuration (GPIO 2, 47, 16) +# Note: I2S is created per-stream, not at boot (only one instance can exist) +i2s_pins = { + 'sck': 2, + 'ws': 47, + 'sd': 16, +} + +# Initialize AudioFlinger (both I2S and buzzer available) +AudioFlinger.init( + device_type=AudioFlinger.DEVICE_BOTH, + i2s_pins=i2s_pins, + buzzer_instance=buzzer +) + +# === LED HARDWARE === +import mpos.lights as LightsManager + +# Initialize 5 NeoPixel LEDs (GPIO 12) +LightsManager.init(neopixel_pin=12, num_leds=5) + +print("Fri3d hardware: Audio and LEDs initialized") print("boot.py finished") diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 190a428..913a16d 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -95,6 +95,21 @@ def adc_to_voltage(adc_value): mpos.battery_voltage.init_adc(999, adc_to_voltage) +# === AUDIO HARDWARE === +import mpos.audio.audioflinger as AudioFlinger + +# Note: Desktop builds have no audio hardware +# AudioFlinger functions will return False (no-op) +AudioFlinger.init( + device_type=AudioFlinger.DEVICE_NULL, + i2s_pins=None, + buzzer_instance=None +) + +# === LED HARDWARE === +# Note: Desktop builds have no LED hardware +# LightsManager will not be initialized (functions will return False) + 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 46342af..c2133f6 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 @@ -110,4 +110,20 @@ def adc_to_voltage(adc_value): except Exception as e: print(f"Warning: powering off camera got exception: {e}") +# === AUDIO HARDWARE === +import mpos.audio.audioflinger as AudioFlinger + +# Note: Waveshare board has no buzzer or LEDs, only I2S audio +# I2S pin configuration will be determined by the board's audio hardware +# For now, initialize with I2S only (pins will be configured per-stream if available) +AudioFlinger.init( + device_type=AudioFlinger.DEVICE_I2S, + i2s_pins={'sck': 2, 'ws': 47, 'sd': 16}, # Default ESP32-S3 I2S pins + buzzer_instance=None +) + +# === LED HARDWARE === +# Note: Waveshare board has no NeoPixel LEDs +# LightsManager will not be initialized (functions will return False) + print("boot.py finished") diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py b/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py new file mode 100644 index 0000000..18919b1 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/__init__.py @@ -0,0 +1,8 @@ +# Fri3d Camp 2024 Badge Hardware Drivers +# These are simple wrappers that can be used by services like AudioFlinger + +from .buzzer import BuzzerConfig +from .leds import LEDConfig +from .rtttl_data import RTTTL_SONGS + +__all__ = ['BuzzerConfig', 'LEDConfig', 'RTTTL_SONGS'] diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py b/internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py new file mode 100644 index 0000000..2ebfa98 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/buzzer.py @@ -0,0 +1,11 @@ +# Fri3d Camp 2024 Badge - Buzzer Configuration + +class BuzzerConfig: + """Configuration for PWM buzzer hardware.""" + + # GPIO pin for buzzer + PIN = 46 + + # Default PWM settings + DEFAULT_FREQ = 550 # Hz + DEFAULT_DUTY = 0 # Off by default diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/leds.py b/internal_filesystem/lib/mpos/hardware/fri3d/leds.py new file mode 100644 index 0000000..f14b740 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/leds.py @@ -0,0 +1,10 @@ +# Fri3d Camp 2024 Badge - LED Configuration + +class LEDConfig: + """Configuration for NeoPixel RGB LED hardware.""" + + # GPIO pin for NeoPixel data line + PIN = 12 + + # Number of NeoPixel LEDs on badge + NUM_LEDS = 5 diff --git a/internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py b/internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py new file mode 100644 index 0000000..3817489 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/fri3d/rtttl_data.py @@ -0,0 +1,18 @@ +# RTTTL Song Catalog +# Ring Tone Text Transfer Language songs for buzzer playback +# Format: "name:defaults:notes" +# Ported from Fri3d Camp 2024 Badge firmware + +RTTTL_SONGS = { + "nokia": "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d,8p,8b,8a,8c#,8e,8a,8p", + + "macarena": "Macarena:d=4,o=5,b=180:f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,c,8c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c,p,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,p,2c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c", + + "takeonme": "TakeOnMe:d=4,o=4,b=160:8f#5,8f#5,8f#5,8d5,8p,8b,8p,8e5,8p,8e5,8p,8e5,8g#5,8g#5,8a5,8b5,8a5,8a5,8a5,8e5,8p,8d5,8p,8f#5,8p,8f#5,8p,8f#5,8e5,8e5,8f#5,8e5", + + "goodbadugly": "TheGoodTheBad:d=4,o=5,b=160:c,8d,8e,8d,c,8d,8e,8d,c,8d,e,8f,2g,8p,a,b,c6,8b,8a,8g,8f,e,8f,g,8e,8d,8c", + + "creeps": "Creeps:d=4,o=5,b=120:8c,8d,8e,8f,g,8e,8f,g,8f,8e,8d,c,8d,8e,f,8d,8e,f,8e,8d,8c,8b4", + + "william_tell": "WilliamTell:d=4,o=5,b=125:8e,8e,8e,2p,8e,8e,8e,2p,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,e" +} diff --git a/internal_filesystem/lib/mpos/lights.py b/internal_filesystem/lib/mpos/lights.py new file mode 100644 index 0000000..2f0d7b7 --- /dev/null +++ b/internal_filesystem/lib/mpos/lights.py @@ -0,0 +1,153 @@ +# LightsManager - Simple LED Control Service for MicroPythonOS +# Provides one-shot LED control for NeoPixel RGB LEDs +# Apps implement custom animations using the update_frame() pattern + +# Module-level state (singleton pattern) +_neopixel = None +_num_leds = 0 + + +def init(neopixel_pin, num_leds=5): + """ + Initialize NeoPixel LEDs. + + Args: + neopixel_pin: GPIO pin number for NeoPixel data line + num_leds: Number of LEDs in the strip (default 5 for Fri3d badge) + """ + global _neopixel, _num_leds + + try: + from machine import Pin + from neopixel import NeoPixel + + _neopixel = NeoPixel(Pin(neopixel_pin, Pin.OUT), num_leds) + _num_leds = num_leds + + # Clear all LEDs on initialization + for i in range(num_leds): + _neopixel[i] = (0, 0, 0) + _neopixel.write() + + print(f"LightsManager initialized: {num_leds} LEDs on GPIO {neopixel_pin}") + except Exception as e: + print(f"LightsManager: Failed to initialize LEDs: {e}") + print(" - LED functions will return False (no-op)") + + +def is_available(): + """ + Check if LED hardware is available. + + Returns: + bool: True if LEDs are initialized and available + """ + return _neopixel is not None + + +def get_led_count(): + """ + Get the number of LEDs. + + Returns: + int: Number of LEDs, or 0 if not initialized + """ + return _num_leds + + +def set_led(index, r, g, b): + """ + Set a single LED color (buffered until write() is called). + + Args: + index: LED index (0 to num_leds-1) + r: Red value (0-255) + g: Green value (0-255) + b: Blue value (0-255) + + Returns: + bool: True if successful, False if LEDs unavailable or invalid index + """ + if not _neopixel: + return False + + if index < 0 or index >= _num_leds: + print(f"LightsManager: Invalid LED index {index} (valid range: 0-{_num_leds-1})") + return False + + _neopixel[index] = (r, g, b) + return True + + +def set_all(r, g, b): + """ + Set all LEDs to the same color (buffered until write() is called). + + Args: + r: Red value (0-255) + g: Green value (0-255) + b: Blue value (0-255) + + Returns: + bool: True if successful, False if LEDs unavailable + """ + if not _neopixel: + return False + + for i in range(_num_leds): + _neopixel[i] = (r, g, b) + return True + + +def clear(): + """ + Clear all LEDs (set to black, buffered until write() is called). + + Returns: + bool: True if successful, False if LEDs unavailable + """ + return set_all(0, 0, 0) + + +def write(): + """ + Update hardware with buffered LED colors. + Must be called after set_led(), set_all(), or clear() to make changes visible. + + Returns: + bool: True if successful, False if LEDs unavailable + """ + if not _neopixel: + return False + + _neopixel.write() + return True + + +def set_notification_color(color_name): + """ + Convenience method to set all LEDs to a common color and update immediately. + + Args: + color_name: Color name (red, green, blue, yellow, orange, purple, white) + + Returns: + bool: True if successful, False if LEDs unavailable or unknown color + """ + colors = { + "red": (255, 0, 0), + "green": (0, 255, 0), + "blue": (0, 0, 255), + "yellow": (255, 255, 0), + "orange": (255, 128, 0), + "purple": (128, 0, 255), + "white": (255, 255, 255), + } + + color = colors.get(color_name.lower()) + if not color: + print(f"LightsManager: Unknown color '{color_name}'") + print(f" - Available colors: {', '.join(colors.keys())}") + return False + + return set_all(*color) and write() diff --git a/tests/mocks/hardware_mocks.py b/tests/mocks/hardware_mocks.py new file mode 100644 index 0000000..b2d2e97 --- /dev/null +++ b/tests/mocks/hardware_mocks.py @@ -0,0 +1,102 @@ +# Hardware Mocks for Testing AudioFlinger and LightsManager +# Provides mock implementations of PWM, I2S, NeoPixel, and Pin classes + + +class MockPin: + """Mock machine.Pin for testing.""" + + IN = 0 + OUT = 1 + PULL_UP = 2 + + def __init__(self, pin_number, mode=None, pull=None): + self.pin_number = pin_number + self.mode = mode + self.pull = pull + self._value = 0 + + def value(self, val=None): + if val is not None: + self._value = val + return self._value + + +class MockPWM: + """Mock machine.PWM for testing buzzer.""" + + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + self.freq_history = [] + self.duty_history = [] + + def freq(self, value=None): + """Set or get frequency.""" + if value is not None: + self.last_freq = value + self.freq_history.append(value) + return self.last_freq + + def duty_u16(self, value=None): + """Set or get duty cycle (0-65535).""" + if value is not None: + self.last_duty = value + self.duty_history.append(value) + return self.last_duty + + +class MockI2S: + """Mock machine.I2S for testing audio playback.""" + + TX = 0 + MONO = 1 + STEREO = 2 + + def __init__(self, id, sck, ws, sd, mode, bits, format, rate, ibuf): + self.id = id + self.sck = sck + self.ws = ws + self.sd = sd + self.mode = mode + self.bits = bits + self.format = format + self.rate = rate + self.ibuf = ibuf + self.written_bytes = [] + self.total_bytes_written = 0 + + def write(self, buf): + """Simulate writing to I2S hardware.""" + self.written_bytes.append(bytes(buf)) + self.total_bytes_written += len(buf) + return len(buf) + + def deinit(self): + """Deinitialize I2S.""" + pass + + +class MockNeoPixel: + """Mock neopixel.NeoPixel for testing LEDs.""" + + def __init__(self, pin, num_leds): + self.pin = pin + self.num_leds = num_leds + self.pixels = [(0, 0, 0)] * num_leds + self.write_count = 0 + + def __setitem__(self, index, value): + """Set LED color (R, G, B) tuple.""" + if 0 <= index < self.num_leds: + self.pixels[index] = value + + def __getitem__(self, index): + """Get LED color.""" + if 0 <= index < self.num_leds: + return self.pixels[index] + return (0, 0, 0) + + def write(self): + """Update hardware (mock - just increment counter).""" + self.write_count += 1 diff --git a/tests/test_audioflinger.py b/tests/test_audioflinger.py new file mode 100644 index 0000000..039d6b1 --- /dev/null +++ b/tests/test_audioflinger.py @@ -0,0 +1,243 @@ +# Unit tests for AudioFlinger service +import unittest +import sys + + +# Mock hardware before importing +class MockPWM: + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + + def freq(self, value=None): + if value is not None: + self.last_freq = value + return self.last_freq + + def duty_u16(self, value=None): + if value is not None: + self.last_duty = value + return self.last_duty + + +class MockPin: + IN = 0 + OUT = 1 + + def __init__(self, pin_number, mode=None): + self.pin_number = pin_number + self.mode = mode + + +# Inject mocks +class MockMachine: + PWM = MockPWM + Pin = MockPin +sys.modules['machine'] = MockMachine() + +class MockLock: + def acquire(self): + pass + def release(self): + pass + +class MockThread: + @staticmethod + def allocate_lock(): + return MockLock() + @staticmethod + def start_new_thread(func, args, **kwargs): + pass # No-op for testing + @staticmethod + def stack_size(size=None): + return 16384 if size is None else None + +sys.modules['_thread'] = MockThread() + +class MockMposApps: + @staticmethod + def good_stack_size(): + return 16384 + +sys.modules['mpos.apps'] = MockMposApps() + + +# Now import the module to test +import mpos.audio.audioflinger as AudioFlinger + + +class TestAudioFlinger(unittest.TestCase): + """Test cases for AudioFlinger service.""" + + def setUp(self): + """Initialize AudioFlinger before each test.""" + self.buzzer = MockPWM(MockPin(46)) + self.i2s_pins = {'sck': 2, 'ws': 47, 'sd': 16} + + # Reset volume to default before each test + AudioFlinger.set_volume(70) + + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_BOTH, + i2s_pins=self.i2s_pins, + buzzer_instance=self.buzzer + ) + + def tearDown(self): + """Clean up after each test.""" + AudioFlinger.stop() + + def test_initialization(self): + """Test that AudioFlinger initializes correctly.""" + self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BOTH) + self.assertEqual(AudioFlinger._i2s_pins, self.i2s_pins) + self.assertEqual(AudioFlinger._buzzer_instance, self.buzzer) + + def test_device_types(self): + """Test device type constants.""" + self.assertEqual(AudioFlinger.DEVICE_NULL, 0) + self.assertEqual(AudioFlinger.DEVICE_I2S, 1) + self.assertEqual(AudioFlinger.DEVICE_BUZZER, 2) + self.assertEqual(AudioFlinger.DEVICE_BOTH, 3) + + def test_stream_types(self): + """Test stream type constants and priority order.""" + self.assertEqual(AudioFlinger.STREAM_MUSIC, 0) + self.assertEqual(AudioFlinger.STREAM_NOTIFICATION, 1) + self.assertEqual(AudioFlinger.STREAM_ALARM, 2) + + # Higher number = higher priority + self.assertTrue(AudioFlinger.STREAM_MUSIC < AudioFlinger.STREAM_NOTIFICATION) + self.assertTrue(AudioFlinger.STREAM_NOTIFICATION < AudioFlinger.STREAM_ALARM) + + def test_volume_control(self): + """Test volume get/set operations.""" + # Set volume + AudioFlinger.set_volume(50) + self.assertEqual(AudioFlinger.get_volume(), 50) + + # Test clamping to 0-100 range + AudioFlinger.set_volume(150) + self.assertEqual(AudioFlinger.get_volume(), 100) + + AudioFlinger.set_volume(-10) + self.assertEqual(AudioFlinger.get_volume(), 0) + + def test_device_null_rejects_playback(self): + """Test that DEVICE_NULL rejects all playback requests.""" + # Re-initialize with no device + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_NULL, + i2s_pins=None, + buzzer_instance=None + ) + + # WAV should be rejected + result = AudioFlinger.play_wav("test.wav") + self.assertFalse(result) + + # RTTTL should be rejected + result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") + self.assertFalse(result) + + def test_device_i2s_only_rejects_rtttl(self): + """Test that DEVICE_I2S rejects buzzer playback.""" + # Re-initialize with I2S only + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_I2S, + i2s_pins=self.i2s_pins, + buzzer_instance=None + ) + + # RTTTL should be rejected (no buzzer) + result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") + self.assertFalse(result) + + def test_device_buzzer_only_rejects_wav(self): + """Test that DEVICE_BUZZER rejects I2S playback.""" + # Re-initialize with buzzer only + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_BUZZER, + i2s_pins=None, + buzzer_instance=self.buzzer + ) + + # WAV should be rejected (no I2S) + result = AudioFlinger.play_wav("test.wav") + self.assertFalse(result) + + def test_missing_i2s_pins_rejects_wav(self): + """Test that missing I2S pins rejects WAV playback.""" + # Re-initialize with I2S device but no pins + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_I2S, + i2s_pins=None, + buzzer_instance=None + ) + + result = AudioFlinger.play_wav("test.wav") + self.assertFalse(result) + + def test_is_playing_initially_false(self): + """Test that is_playing() returns False initially.""" + self.assertFalse(AudioFlinger.is_playing()) + + def test_stop_with_no_playback(self): + """Test that stop() can be called when nothing is playing.""" + # Should not raise exception + AudioFlinger.stop() + self.assertFalse(AudioFlinger.is_playing()) + + def test_get_device_type(self): + """Test that get_device_type() returns correct value.""" + # Test DEVICE_BOTH + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_BOTH, + i2s_pins=self.i2s_pins, + buzzer_instance=self.buzzer + ) + self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BOTH) + + # Test DEVICE_I2S + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_I2S, + i2s_pins=self.i2s_pins, + buzzer_instance=None + ) + self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_I2S) + + # Test DEVICE_BUZZER + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_BUZZER, + i2s_pins=None, + buzzer_instance=self.buzzer + ) + self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BUZZER) + + # Test DEVICE_NULL + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_NULL, + i2s_pins=None, + buzzer_instance=None + ) + self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_NULL) + + def test_audio_focus_check_no_current_stream(self): + """Test audio focus allows playback when no stream is active.""" + result = AudioFlinger._check_audio_focus(AudioFlinger.STREAM_MUSIC) + self.assertTrue(result) + + def test_init_creates_lock(self): + """Test that initialization creates a stream lock.""" + self.assertIsNotNone(AudioFlinger._stream_lock) + + def test_volume_default_value(self): + """Test that default volume is reasonable.""" + # After init, volume should be at default (70) + AudioFlinger.init( + device_type=AudioFlinger.DEVICE_NULL, + i2s_pins=None, + buzzer_instance=None + ) + self.assertEqual(AudioFlinger.get_volume(), 70) diff --git a/tests/test_lightsmanager.py b/tests/test_lightsmanager.py new file mode 100644 index 0000000..016ccf6 --- /dev/null +++ b/tests/test_lightsmanager.py @@ -0,0 +1,126 @@ +# Unit tests for LightsManager service +import unittest +import sys + + +# Mock hardware before importing LightsManager +class MockPin: + IN = 0 + OUT = 1 + + def __init__(self, pin_number, mode=None): + self.pin_number = pin_number + self.mode = mode + + +class MockNeoPixel: + def __init__(self, pin, num_leds): + self.pin = pin + self.num_leds = num_leds + self.pixels = [(0, 0, 0)] * num_leds + self.write_count = 0 + + def __setitem__(self, index, value): + if 0 <= index < self.num_leds: + self.pixels[index] = value + + def __getitem__(self, index): + if 0 <= index < self.num_leds: + return self.pixels[index] + return (0, 0, 0) + + def write(self): + self.write_count += 1 + + +# Inject mocks +sys.modules['machine'] = type('module', (), {'Pin': MockPin})() +sys.modules['neopixel'] = type('module', (), {'NeoPixel': MockNeoPixel})() + + +# Now import the module to test +import mpos.lights as LightsManager + + +class TestLightsManager(unittest.TestCase): + """Test cases for LightsManager service.""" + + def setUp(self): + """Initialize LightsManager before each test.""" + LightsManager.init(neopixel_pin=12, num_leds=5) + + def test_initialization(self): + """Test that LightsManager initializes correctly.""" + self.assertTrue(LightsManager.is_available()) + self.assertEqual(LightsManager.get_led_count(), 5) + + def test_set_single_led(self): + """Test setting a single LED color.""" + result = LightsManager.set_led(0, 255, 0, 0) + self.assertTrue(result) + + # Verify color was set (via internal _neopixel mock) + neopixel = LightsManager._neopixel + self.assertEqual(neopixel[0], (255, 0, 0)) + + def test_set_led_invalid_index(self): + """Test that invalid LED indices are rejected.""" + # Negative index + result = LightsManager.set_led(-1, 255, 0, 0) + self.assertFalse(result) + + # Index too large + result = LightsManager.set_led(10, 255, 0, 0) + self.assertFalse(result) + + def test_set_all_leds(self): + """Test setting all LEDs to same color.""" + result = LightsManager.set_all(0, 255, 0) + self.assertTrue(result) + + # Verify all LEDs were set + neopixel = LightsManager._neopixel + for i in range(5): + self.assertEqual(neopixel[i], (0, 255, 0)) + + def test_clear(self): + """Test clearing all LEDs.""" + # First set some colors + LightsManager.set_all(255, 255, 255) + + # Then clear + result = LightsManager.clear() + self.assertTrue(result) + + # Verify all LEDs are black + neopixel = LightsManager._neopixel + for i in range(5): + self.assertEqual(neopixel[i], (0, 0, 0)) + + def test_write(self): + """Test that write() updates hardware.""" + neopixel = LightsManager._neopixel + initial_count = neopixel.write_count + + result = LightsManager.write() + self.assertTrue(result) + + # Verify write was called + self.assertEqual(neopixel.write_count, initial_count + 1) + + def test_notification_colors(self): + """Test convenience notification color method.""" + # Valid colors + self.assertTrue(LightsManager.set_notification_color("red")) + self.assertTrue(LightsManager.set_notification_color("green")) + self.assertTrue(LightsManager.set_notification_color("blue")) + + # Invalid color + result = LightsManager.set_notification_color("invalid_color") + self.assertFalse(result) + + def test_case_insensitive_colors(self): + """Test that color names are case-insensitive.""" + self.assertTrue(LightsManager.set_notification_color("RED")) + self.assertTrue(LightsManager.set_notification_color("Green")) + self.assertTrue(LightsManager.set_notification_color("BLUE")) diff --git a/tests/test_rtttl.py b/tests/test_rtttl.py new file mode 100644 index 0000000..07dbc80 --- /dev/null +++ b/tests/test_rtttl.py @@ -0,0 +1,173 @@ +# Unit tests for RTTTL parser (RTTTLStream) +import unittest +import sys + + +# Mock hardware before importing +class MockPWM: + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + self.freq_history = [] + self.duty_history = [] + + def freq(self, value=None): + if value is not None: + self.last_freq = value + self.freq_history.append(value) + return self.last_freq + + def duty_u16(self, value=None): + if value is not None: + self.last_duty = value + self.duty_history.append(value) + return self.last_duty + + +# Inject mock +sys.modules['machine'] = type('module', (), {'PWM': MockPWM, 'Pin': lambda x: x})() + + +# Now import the module to test +from mpos.audio.stream_rtttl import RTTTLStream + + +class TestRTTTL(unittest.TestCase): + """Test cases for RTTTL parser.""" + + def setUp(self): + """Create a mock buzzer before each test.""" + self.buzzer = MockPWM(46) + + def test_parse_simple_rtttl(self): + """Test parsing a simple RTTTL string.""" + rtttl = "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + self.assertEqual(stream.name, "Nokia") + self.assertEqual(stream.default_duration, 4) + self.assertEqual(stream.default_octave, 5) + self.assertEqual(stream.bpm, 225) + + def test_parse_defaults(self): + """Test parsing default values.""" + rtttl = "Test:d=8,o=6,b=180:c" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + self.assertEqual(stream.default_duration, 8) + self.assertEqual(stream.default_octave, 6) + self.assertEqual(stream.bpm, 180) + + # Check calculated msec_per_whole_note + # 240000 / 180 = 1333.33... + self.assertAlmostEqual(stream.msec_per_whole_note, 1333.33, places=1) + + def test_invalid_rtttl_format(self): + """Test that invalid RTTTL format raises ValueError.""" + # Missing colons + with self.assertRaises(ValueError): + RTTTLStream("invalid", 0, 100, self.buzzer, None) + + # Too many colons + with self.assertRaises(ValueError): + RTTTLStream("a:b:c:d", 0, 100, self.buzzer, None) + + def test_note_parsing(self): + """Test parsing individual notes.""" + rtttl = "Test:d=4,o=5,b=120:c,d,e" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + # Generate notes + notes = list(stream._notes()) + + # Should have 3 notes + self.assertEqual(len(notes), 3) + + # Each note should be a tuple of (frequency, duration) + for freq, duration in notes: + self.assertTrue(freq > 0, "Frequency should be non-zero") + self.assertTrue(duration > 0, "Duration should be non-zero") + + def test_sharp_notes(self): + """Test parsing sharp notes.""" + rtttl = "Test:d=4,o=5,b=120:c#,d#,f#" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 3) + + # Sharp notes should have different frequencies than natural notes + # (can't test exact values without knowing frequency table) + + def test_pause_notes(self): + """Test parsing pause notes.""" + rtttl = "Test:d=4,o=5,b=120:c,p,e" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 3) + + # Pause (p) should have frequency 0 + freq, duration = notes[1] + self.assertEqual(freq, 0.0) + + def test_duration_modifiers(self): + """Test note duration modifiers (dots).""" + rtttl = "Test:d=4,o=5,b=120:c,c." + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 2) + + # Dotted note should be 1.5x longer + normal_duration = notes[0][1] + dotted_duration = notes[1][1] + self.assertAlmostEqual(dotted_duration / normal_duration, 1.5, places=1) + + def test_octave_variations(self): + """Test notes with different octaves.""" + rtttl = "Test:d=4,o=5,b=120:c4,c5,c6,c7" + stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None) + + notes = list(stream._notes()) + self.assertEqual(len(notes), 4) + + # Higher octaves should have higher frequencies + freqs = [freq for freq, dur in notes] + self.assertTrue(freqs[0] < freqs[1], "c4 should be lower than c5") + self.assertTrue(freqs[1] < freqs[2], "c5 should be lower than c6") + self.assertTrue(freqs[2] < freqs[3], "c6 should be lower than c7") + + def test_volume_scaling(self): + """Test volume to duty cycle conversion.""" + # Test various volume levels + for volume in [0, 25, 50, 75, 100]: + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, volume, self.buzzer, None) + + # Volume 0 should result in duty 0 + if volume == 0: + # Note: play() method calculates duty, not __init__ + pass # Can't easily test without calling play() + else: + # Volume > 0 should result in duty > 0 + # (duty calculation happens in play() method) + pass + + def test_stream_type(self): + """Test that stream type is stored correctly.""" + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 2, 100, self.buzzer, None) + self.assertEqual(stream.stream_type, 2) + + def test_stop_flag(self): + """Test that stop flag can be set.""" + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, 100, self.buzzer, None) + self.assertTrue(stream._keep_running) + + stream.stop() + self.assertFalse(stream._keep_running) + + def test_is_playing_flag(self): + """Test playing flag is initially false.""" + stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, 100, self.buzzer, None) + self.assertFalse(stream.is_playing()) diff --git a/tests/test_syspath_restore.py b/tests/test_syspath_restore.py new file mode 100644 index 0000000..36d668d --- /dev/null +++ b/tests/test_syspath_restore.py @@ -0,0 +1,78 @@ +import unittest +import sys +import os + +class TestSysPathRestore(unittest.TestCase): + """Test that sys.path is properly restored after execute_script""" + + def test_syspath_restored_after_execute_script(self): + """Test that sys.path is restored to original state after script execution""" + # Import here to ensure we're in the right context + import mpos.apps + + # Capture original sys.path + original_path = sys.path[:] + original_length = len(sys.path) + + # Create a test directory path that would be added + test_cwd = "apps/com.test.app/assets/" + + # Verify the test path is not already in sys.path + self.assertFalse(test_cwd in original_path, + f"Test path {test_cwd} should not be in sys.path initially") + + # Create a simple test script + test_script = ''' +import sys +# Just a simple script that does nothing +x = 42 +''' + + # Call execute_script with cwd parameter + # Note: This will fail because there's no Activity to start, + # but that's fine - we're testing the sys.path restoration + result = mpos.apps.execute_script( + test_script, + is_file=False, + cwd=test_cwd, + classname="NonExistentClass" + ) + + # After execution, sys.path should be restored + current_path = sys.path + current_length = len(sys.path) + + # Verify sys.path has been restored to original + self.assertEqual(current_length, original_length, + f"sys.path length should be restored. Original: {original_length}, Current: {current_length}") + + # Verify the test directory is not in sys.path anymore + self.assertFalse(test_cwd in current_path, + f"Test path {test_cwd} should not be in sys.path after execution. sys.path={current_path}") + + # Verify sys.path matches original + self.assertEqual(current_path, original_path, + f"sys.path should match original.\nOriginal: {original_path}\nCurrent: {current_path}") + + def test_syspath_not_affected_when_no_cwd(self): + """Test that sys.path is unchanged when cwd is None""" + import mpos.apps + + # Capture original sys.path + original_path = sys.path[:] + + test_script = ''' +x = 42 +''' + + # Call without cwd parameter + result = mpos.apps.execute_script( + test_script, + is_file=False, + cwd=None, + classname="NonExistentClass" + ) + + # sys.path should be unchanged + self.assertEqual(sys.path, original_path, + "sys.path should be unchanged when cwd is None") From f37337f65bc2bf3b33c413fc510ff65b8579ffec Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 3 Dec 2025 22:33:36 +0100 Subject: [PATCH 096/192] Update CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cfd40..269851f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - 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 - About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name - Camera app: massive overhaul! From 21311a61f62105795417ca3f10b5ba428a643a87 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 3 Dec 2025 23:10:28 +0100 Subject: [PATCH 097/192] Fri3d Camp 2024 Board: add startup light and sound --- .../lib/mpos/board/fri3d_2024.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 2ae6689..45edf50 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -318,4 +318,64 @@ def adc_to_voltage(adc_value): LightsManager.init(neopixel_pin=12, num_leds=5) print("Fri3d hardware: Audio and LEDs initialized") + +# === STARTUP "WOW" EFFECT === +import time +import _thread + +def startup_wow_effect(): + """ + Epic startup effect with rainbow LED chase and upbeat startup jingle. + Runs in background thread to avoid blocking boot. + """ + try: + # Startup jingle: Happy upbeat sequence (ascending scale with flourish) + startup_jingle = "Startup:d=8,o=6,b=200:c,d,e,g,4c7,4e,4c7" + + # Start the jingle + AudioFlinger.play_rtttl( + startup_jingle, + stream_type=AudioFlinger.STREAM_NOTIFICATION, + volume=60 + ) + + # Rainbow colors for the 5 LEDs + rainbow = [ + (255, 0, 0), # Red + (255, 128, 0), # Orange + (255, 255, 0), # Yellow + (0, 255, 0), # Green + (0, 0, 255), # Blue + ] + + # Rainbow sweep effect (3 passes, getting faster) + for pass_num in range(3): + for i in range(5): + # Light up LEDs progressively + for j in range(i + 1): + LightsManager.set_led(j, *rainbow[j]) + LightsManager.write() + time.sleep_ms(80 - pass_num * 20) # Speed up each pass + + # Flash all LEDs bright white + LightsManager.set_all(255, 255, 255) + LightsManager.write() + time.sleep_ms(150) + + # Rainbow finale + for i in range(5): + LightsManager.set_led(i, *rainbow[i]) + LightsManager.write() + time.sleep_ms(300) + + # Fade out + LightsManager.clear() + LightsManager.write() + + except Exception as e: + print(f"Startup effect error: {e}") + +_thread.stack_size(mpos.apps.good_stack_size()) +_thread.start_new_thread(startup_wow_effect, ()) + print("boot.py finished") From 4e7baf4ec6b18caffbe359ee0e41195c8c870599 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 3 Dec 2025 23:11:22 +0100 Subject: [PATCH 098/192] AudioFlinger: re-add viper optimizations These make a notable difference when playing audio on ESP32. Without them, each UI action causes a stutter, so it's not fun to listen to audio while doing anything on the device. With them, most UI actions don't cause a stutter. Long maxed out CPU runs and storage access still do, though. --- CHANGELOG.md | 5 +++-- internal_filesystem/lib/mpos/audio/stream_wav.py | 16 +++++++++------- scripts/build_mpos.sh | 11 +++++++++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 269851f..05c98b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ 0.5.1 ===== -- Fri3d Camp 2024 Badge: workaround ADC2+WiFi conflict by temporarily disable WiFi to measure battery level -- Fri3d Camp 2024 Badge: improve battery monitor calibration to fix 0.1V delta +- 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 - API: improve and cleanup animations - API: SharedPreferences: add erase_all() function - API: add defaults handling to SharedPreferences and only save non-defaults diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index 4c52706..884d936 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -7,14 +7,16 @@ import time import sys -# Volume scaling function - regular Python version -# Note: Viper optimization removed because @micropython.viper decorator -# causes cross-compiler errors on Unix/macOS builds even inside conditionals -def _scale_audio(buf, num_bytes, scale_fixed): - """Volume scaling for 16-bit audio samples.""" +# 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).""" for i in range(0, num_bytes, 2): - lo = buf[i] - hi = buf[i + 1] + lo = int(buf[i]) + hi = int(buf[i + 1]) sample = (hi << 8) | lo if hi & 128: sample -= 65536 diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 7b77ee4..4ee5748 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -101,12 +101,23 @@ if [ "$target" == "esp32" ]; then elif [ "$target" == "unix" -o "$target" == "macOS" ]; then manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" + + # Comment out @micropython.viper decorator for Unix/macOS builds + # (cross-compiler doesn't support Viper native code emitter) + echo "Temporarily commenting out @micropython.viper decorator for Unix/macOS build..." + stream_wav_file="$codebasedir"/internal_filesystem/lib/mpos/audio/stream_wav.py + sed -i 's/^@micropython\.viper$/#@micropython.viper/' "$stream_wav_file" + # LV_CFLAGS are passed to USER_C_MODULES # STRIP= makes it so that debug symbols are kept pushd "$codebasedir"/lvgl_micropython/ # USER_C_MODULE doesn't seem to work properly so there are symlinks in lvgl_micropython/extmod/ python3 make.py "$target" LV_CFLAGS="-g -O0 -ggdb -ljpeg" STRIP= DISPLAY=sdl_display INDEV=sdl_pointer INDEV=sdl_keyboard "$frozenmanifest" popd + + # Restore @micropython.viper decorator after build + echo "Restoring @micropython.viper decorator..." + sed -i 's/^#@micropython\.viper$/@micropython.viper/' "$stream_wav_file" else echo "invalid target $target" fi From ce981d790fbd8b27b6511f4bb4b4d3b416cedbad Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 4 Dec 2025 13:21:38 +0100 Subject: [PATCH 099/192] Fix unit tests --- CLAUDE.md | 170 +++++ .../apps/com.micropythonos.imu/assets/imu.py | 75 ++- .../lib/mpos/board/fri3d_2024.py | 12 +- internal_filesystem/lib/mpos/board/linux.py | 5 + .../board/waveshare_esp32_s3_touch_lcd_2.py | 7 + .../lib/mpos/hardware/drivers/__init__.py | 1 + .../{ => mpos/hardware/drivers}/qmi8658.py | 0 .../lib/mpos/hardware/drivers/wsen_isds.py | 435 +++++++++++++ .../lib/mpos/sensor_manager.py | 603 ++++++++++++++++++ internal_filesystem/lib/mpos/ui/topmenu.py | 24 +- 10 files changed, 1299 insertions(+), 33 deletions(-) create mode 100644 internal_filesystem/lib/mpos/hardware/drivers/__init__.py rename internal_filesystem/lib/{ => mpos/hardware/drivers}/qmi8658.py (100%) create mode 100644 internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py create mode 100644 internal_filesystem/lib/mpos/sensor_manager.py diff --git a/CLAUDE.md b/CLAUDE.md index 083bee2..f6bacf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -449,6 +449,8 @@ Current stable version: 0.3.3 (as of latest CHANGELOG entry) - Config/preferences: `internal_filesystem/lib/mpos/config.py` - Top menu/drawer: `internal_filesystem/lib/mpos/ui/topmenu.py` - Activity navigation: `internal_filesystem/lib/mpos/activity_navigator.py` +- Sensor management: `internal_filesystem/lib/mpos/sensor_manager.py` +- IMU drivers: `internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py` and `wsen_isds.py` ## Common Utilities and Helpers @@ -642,6 +644,7 @@ def defocus_handler(self, obj): - `mpos.sdcard.SDCardManager`: SD card mounting and management - `mpos.clipboard`: System clipboard access - `mpos.battery_voltage`: Battery level reading (ESP32 only) +- `mpos.sensor_manager`: Unified sensor access (accelerometer, gyroscope, temperature) ## Audio System (AudioFlinger) @@ -849,6 +852,173 @@ class LEDAnimationActivity(Activity): - **Thread-safe**: No locking (single-threaded usage recommended) - **Desktop**: Functions return `False` (no-op) on desktop builds +## Sensor System (SensorManager) + +MicroPythonOS provides a unified sensor framework called **SensorManager** (Android-inspired) that provides easy access to motion sensors (accelerometer, gyroscope) and temperature sensors across different hardware platforms. + +### Supported Sensors + +**IMU Sensors:** +- **QMI8658** (Waveshare ESP32-S3): Accelerometer, Gyroscope, Temperature +- **WSEN_ISDS** (Fri3d Camp 2024 Badge): Accelerometer, Gyroscope + +**Temperature Sensors:** +- **ESP32 MCU Temperature**: Internal SoC temperature sensor +- **IMU Chip Temperature**: QMI8658 chip temperature + +### Basic Usage + +**Check availability and read sensors**: +```python +import mpos.sensor_manager as SensorManager + +# Check if sensors are available +if SensorManager.is_available(): + # Get sensors + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + temp = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) + + # Read data (returns standard SI units) + accel_data = SensorManager.read_sensor(accel) # Returns (x, y, z) in m/s² + gyro_data = SensorManager.read_sensor(gyro) # Returns (x, y, z) in deg/s + temperature = SensorManager.read_sensor(temp) # Returns °C + + if accel_data: + ax, ay, az = accel_data + print(f"Acceleration: {ax:.2f}, {ay:.2f}, {az:.2f} m/s²") +``` + +### Sensor Types + +```python +# Motion sensors +SensorManager.TYPE_ACCELEROMETER # m/s² (meters per second squared) +SensorManager.TYPE_GYROSCOPE # deg/s (degrees per second) + +# Temperature sensors +SensorManager.TYPE_SOC_TEMPERATURE # °C (MCU internal temperature) +SensorManager.TYPE_IMU_TEMPERATURE # °C (IMU chip temperature) +``` + +### Tilt-Controlled Game Example + +```python +from mpos.app.activity import Activity +import mpos.sensor_manager as SensorManager +import mpos.ui +import time + +class TiltBallActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + + # Get accelerometer + self.accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + + # Create ball UI + self.ball = lv.obj(self.screen) + self.ball.set_size(20, 20) + self.ball.set_style_radius(10, 0) + + # Physics state + self.ball_x = 160.0 + self.ball_y = 120.0 + self.ball_vx = 0.0 + self.ball_vy = 0.0 + self.last_time = time.ticks_ms() + + self.setContentView(self.screen) + + def onResume(self, screen): + self.last_time = time.ticks_ms() + mpos.ui.task_handler.add_event_cb(self.update_physics, 1) + + def onPause(self, screen): + mpos.ui.task_handler.remove_event_cb(self.update_physics) + + def update_physics(self, a, b): + current_time = time.ticks_ms() + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 + self.last_time = current_time + + # Read accelerometer + accel = SensorManager.read_sensor(self.accel) + if accel: + ax, ay, az = accel + + # Apply acceleration to velocity + self.ball_vx += (ax * 5.0) * delta_time + self.ball_vy -= (ay * 5.0) * delta_time # Flip Y + + # Update position + self.ball_x += self.ball_vx + self.ball_y += self.ball_vy + + # Update ball position + self.ball.set_pos(int(self.ball_x), int(self.ball_y)) +``` + +### Calibration + +Calibration removes sensor drift and improves accuracy. The device must be **stationary** during calibration. + +```python +# Calibrate accelerometer and gyroscope +accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) +gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + +# Calibrate (100 samples, device must be flat and still) +accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) +gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) + +# Calibration is automatically saved to SharedPreferences +# and loaded on next boot +``` + +### Performance Recommendations + +**Polling rate recommendations:** +- **Games**: 20-30 Hz (responsive but not excessive) +- **UI feedback**: 10-15 Hz (smooth for tilt UI) +- **Background monitoring**: 1-5 Hz (screen rotation, pedometer) + +```python +# ❌ BAD: Poll every frame (60 Hz) +def update_frame(self, a, b): + accel = SensorManager.read_sensor(self.accel) # Too frequent! + +# ✅ GOOD: Poll every other frame (30 Hz) +def update_frame(self, a, b): + self.frame_count += 1 + if self.frame_count % 2 == 0: + accel = SensorManager.read_sensor(self.accel) +``` + +### Hardware Support Matrix + +| Platform | Accelerometer | Gyroscope | IMU Temp | MCU Temp | +|----------|---------------|-----------|----------|----------| +| Waveshare ESP32-S3 | ✅ QMI8658 | ✅ QMI8658 | ✅ QMI8658 | ✅ ESP32 | +| Fri3d 2024 Badge | ✅ WSEN_ISDS | ✅ WSEN_ISDS | ❌ | ✅ ESP32 | +| Desktop/Linux | ❌ | ❌ | ❌ | ❌ | + +### Implementation Details + +- **Location**: `lib/mpos/sensor_manager.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Units**: Standard SI (m/s² for acceleration, deg/s for gyroscope, °C for temperature) +- **Calibration**: Persistent via SharedPreferences (`data/com.micropythonos.sensors/config.json`) +- **Thread-safe**: Uses locks for concurrent access +- **Auto-detection**: Identifies IMU type via chip ID registers +- **Desktop**: Functions return `None` (graceful fallback) on desktop builds + +### Driver Locations + +- **QMI8658**: `lib/mpos/hardware/drivers/qmi8658.py` +- **WSEN_ISDS**: `lib/mpos/hardware/drivers/wsen_isds.py` +- **Board init**: `lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py` and `lib/mpos/board/fri3d_2024.py` + ## Animations and Game Loops MicroPythonOS supports frame-based animations and game loops using the TaskHandler event system. This pattern is used for games, particle effects, and smooth animations. diff --git a/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py b/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py index 569c47e..4cf3cb5 100644 --- a/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py +++ b/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py @@ -1,8 +1,11 @@ from mpos.apps import Activity +import mpos.sensor_manager as SensorManager class IMU(Activity): - sensor = None + accel_sensor = None + gyro_sensor = None + temp_sensor = None refresh_timer = None # widgets: @@ -30,12 +33,16 @@ def onCreate(self): self.slidergz = lv.slider(screen) self.slidergz.align(lv.ALIGN.CENTER, 0, 90) try: - from machine import Pin, I2C - from qmi8658 import QMI8658 - import machine - self.sensor = QMI8658(I2C(0, sda=machine.Pin(48), scl=machine.Pin(47))) - print("IMU sensor initialized") - #print(f"{self.sensor.temperature=} {self.sensor.acceleration=} {self.sensor.gyro=}") + if SensorManager.is_available(): + self.accel_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.gyro_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + # Get IMU temperature (not MCU temperature) + self.temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + print("IMU sensors initialized via SensorManager") + print(f"Available sensors: {SensorManager.get_sensor_list()}") + else: + print("Warning: No IMU sensors available") + self.templabel.set_text("No IMU sensors available") except Exception as e: warning = f"Warning: could not initialize IMU hardware:\n{e}" print(warning) @@ -68,22 +75,45 @@ def convert_percentage(self, value: float) -> int: def refresh(self, timer): #print("refresh timer") - if self.sensor: - #print(f"{self.sensor.temperature=} {self.sensor.acceleration=} {self.sensor.gyro=}") - temp = self.sensor.temperature - ax = self.sensor.acceleration[0] - axp = int((ax * 100 + 100)/2) - ay = self.sensor.acceleration[1] - ayp = int((ay * 100 + 100)/2) - az = self.sensor.acceleration[2] - azp = int((az * 100 + 100)/2) - # values between -200 and 200 => /4 becomes -50 and 50 => +50 becomes 0 and 100 - gx = self.convert_percentage(self.sensor.gyro[0]) - gy = self.convert_percentage(self.sensor.gyro[1]) - gz = self.convert_percentage(self.sensor.gyro[2]) - self.templabel.set_text(f"IMU chip temperature: {temp:.2f}°C") + if self.accel_sensor and self.gyro_sensor: + # Read sensor data via SensorManager (returns m/s² for accel, deg/s for gyro) + accel = SensorManager.read_sensor(self.accel_sensor) + gyro = SensorManager.read_sensor(self.gyro_sensor) + temp = SensorManager.read_sensor(self.temp_sensor) if self.temp_sensor else None + + if accel and gyro: + # Convert m/s² to G for display (divide by 9.80665) + # Range: ±8G → ±1G = ±10% of range → map to 0-100 + ax, ay, az = accel + ax_g = ax / 9.80665 # Convert m/s² to G + ay_g = ay / 9.80665 + az_g = az / 9.80665 + axp = int((ax_g * 100 + 100)/2) # Map ±1G to 0-100 + ayp = int((ay_g * 100 + 100)/2) + azp = int((az_g * 100 + 100)/2) + + # Gyro already in deg/s, map ±200 DPS to 0-100 + gx, gy, gz = gyro + gx = self.convert_percentage(gx) + gy = self.convert_percentage(gy) + gz = self.convert_percentage(gz) + + if temp is not None: + self.templabel.set_text(f"IMU chip temperature: {temp:.2f}°C") + else: + self.templabel.set_text("IMU active (no temperature sensor)") + else: + # Sensor read failed, show random data + import random + randomnr = random.randint(0,100) + axp = randomnr + ayp = 50 + azp = 75 + gx = 45 + gy = 50 + gz = 55 else: - #temp = 12.34 + # No sensors available, show random data import random randomnr = random.randint(0,100) axp = randomnr @@ -92,6 +122,7 @@ def refresh(self, timer): gx = 45 gy = 50 gz = 55 + self.sliderx.set_value(axp, False) self.slidery.set_value(ayp, False) self.sliderz.set_value(azp, False) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 45edf50..0a510c4 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -317,7 +317,15 @@ def adc_to_voltage(adc_value): # Initialize 5 NeoPixel LEDs (GPIO 12) LightsManager.init(neopixel_pin=12, num_leds=5) -print("Fri3d hardware: Audio and LEDs initialized") +# === SENSOR HARDWARE === +import mpos.sensor_manager as SensorManager + +# Create I2C bus for IMU (different pins from display) +from machine import I2C +imu_i2c = I2C(0, sda=Pin(9), scl=Pin(18)) +SensorManager.init(imu_i2c, address=0x6B) + +print("Fri3d hardware: Audio, LEDs, and sensors initialized") # === STARTUP "WOW" EFFECT === import time @@ -375,7 +383,7 @@ def startup_wow_effect(): except Exception as e: print(f"Startup effect error: {e}") -_thread.stack_size(mpos.apps.good_stack_size()) +_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") diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 913a16d..d5c3b6e 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -110,6 +110,11 @@ def adc_to_voltage(adc_value): # Note: Desktop builds have no LED hardware # LightsManager will not be initialized (functions will return False) +# === SENSOR HARDWARE === +# Note: Desktop builds have no sensor hardware +import mpos.sensor_manager as SensorManager +# Don't call init() - SensorManager functions will return None/False + 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 c2133f6..096e64c 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 @@ -126,4 +126,11 @@ def adc_to_voltage(adc_value): # Note: Waveshare board has no NeoPixel LEDs # LightsManager will not be initialized (functions will return False) +# === SENSOR HARDWARE === +import mpos.sensor_manager as SensorManager + +# IMU is on I2C0 (same bus as touch): SDA=48, SCL=47, addr=0x6B +# i2c_bus was created on line 75 for touch, reuse it for IMU +SensorManager.init(i2c_bus, address=0x6B) + print("boot.py finished") diff --git a/internal_filesystem/lib/mpos/hardware/drivers/__init__.py b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py new file mode 100644 index 0000000..119fb43 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py @@ -0,0 +1 @@ +# IMU and sensor drivers for MicroPythonOS diff --git a/internal_filesystem/lib/qmi8658.py b/internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py similarity index 100% rename from internal_filesystem/lib/qmi8658.py rename to internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py new file mode 100644 index 0000000..eaefeb7 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -0,0 +1,435 @@ +"""WSEN_ISDS 6-axis IMU driver for MicroPython. + +This driver is for the Würth Elektronik WSEN-ISDS IMU sensor. +Source: https://github.com/Fri3dCamp/badge_2024_micropython/pull/10 + +MIT License + +Copyright (c) 2024 Fri3d Camp contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import time + + +class Wsen_Isds: + """Driver for WSEN-ISDS 6-axis IMU (accelerometer + gyroscope).""" + + _ISDS_STATUS_REG = 0x1E # Status data register + _ISDS_WHO_AM_I = 0x0F # WHO_AM_I register + + _REG_G_X_OUT_L = 0x22 + _REG_G_Y_OUT_L = 0x24 + _REG_G_Z_OUT_L = 0x26 + + _REG_A_X_OUT_L = 0x28 + _REG_A_Y_OUT_L = 0x2A + _REG_A_Z_OUT_L = 0x2C + + _REG_A_TAP_CFG = 0x58 + + _options = { + 'acc_range': { + 'reg': 0x10, 'mask': 0b11110011, 'shift_left': 2, + 'val_to_bits': {"2g": 0b00, "4g": 0b10, "8g": 0b11, "16g": 0b01} + }, + 'acc_data_rate': { + 'reg': 0x10, 'mask': 0b00001111, 'shift_left': 4, + 'val_to_bits': { + "0": 0b0000, "1.6Hz": 0b1011, "12.5Hz": 0b0001, + "26Hz": 0b0010, "52Hz": 0b0011, "104Hz": 0b0100, + "208Hz": 0b0101, "416Hz": 0b0110, "833Hz": 0b0111, + "1.66kHz": 0b1000, "3.33kHz": 0b1001, "6.66kHz": 0b1010} + }, + 'gyro_range': { + 'reg': 0x11, 'mask': 0b11110000, 'shift_left': 0, + 'val_to_bits': { + "125dps": 0b0010, "250dps": 0b0000, + "500dps": 0b0100, "1000dps": 0b1000, "2000dps": 0b1100} + }, + 'gyro_data_rate': { + 'reg': 0x11, 'mask': 0b00001111, 'shift_left': 4, + 'val_to_bits': { + "0": 0b0000, "12.5Hz": 0b0001, "26Hz": 0b0010, + "52Hz": 0b0011, "104Hz": 0b0100, "208Hz": 0b0101, + "416Hz": 0b0110, "833Hz": 0b0111, "1.66kHz": 0b1000, + "3.33kHz": 0b1001, "6.66kHz": 0b1010} + }, + 'tap_double_enable': { + 'reg': 0x5B, 'mask': 0b01111111, 'shift_left': 7, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + 'tap_threshold': { + 'reg': 0x59, 'mask': 0b11100000, 'shift_left': 0, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11, 4: 0b100, 5: 0b101, + 6: 0b110, 7: 0b111, 8: 0b1000, 9: 0b1001} + }, + 'tap_quiet_time': { + 'reg': 0x5A, 'mask': 0b11110011, 'shift_left': 2, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11} + }, + 'tap_duration_time': { + 'reg': 0x5A, 'mask': 0b00001111, 'shift_left': 2, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11, 4: 0b100, 5: 0b101, + 6: 0b110, 7: 0b111, 8: 0b1000, 9: 0b1001} + }, + 'tap_shock_time': { + 'reg': 0x5A, 'mask': 0b11111100, 'shift_left': 0, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11} + }, + 'tap_single_to_int0': { + 'reg': 0x5E, 'mask': 0b10111111, 'shift_left': 6, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'tap_double_to_int0': { + 'reg': 0x5E, 'mask': 0b11110111, 'shift_left': 3, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'int1_on_int0': { + 'reg': 0x13, 'mask': 0b11011111, 'shift_left': 5, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'ctrl_do_soft_reset': { + 'reg': 0x12, 'mask': 0b11111110, 'shift_left': 0, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + 'ctrl_do_reboot': { + 'reg': 0x12, 'mask': 0b01111111, 'shift_left': 7, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + } + + def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", + gyro_range="125dps", gyro_data_rate="12.5Hz"): + """Initialize WSEN-ISDS IMU. + + Args: + i2c: I2C bus instance + address: I2C address (default 0x6B) + acc_range: Accelerometer range ("2g", "4g", "8g", "16g") + acc_data_rate: Accelerometer data rate ("0", "1.6Hz", "12.5Hz", ...) + gyro_range: Gyroscope range ("125dps", "250dps", "500dps", "1000dps", "2000dps") + gyro_data_rate: Gyroscope data rate ("0", "12.5Hz", "26Hz", ...) + """ + self.i2c = i2c + self.address = address + + self.acc_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) + self.set_gyro_range(gyro_range) + self.set_gyro_data_rate(gyro_data_rate) + + def get_chip_id(self): + """Get chip ID for detection. Returns WHO_AM_I register value.""" + try: + return self.i2c.readfrom_mem(self.address, self._ISDS_WHO_AM_I, 1)[0] + except: + return 0 + + def _write_option(self, option, value): + """Write configuration option to sensor register.""" + opt = Wsen_Isds._options[option] + try: + bits = opt["val_to_bits"][value] + config_value = self.i2c.readfrom_mem(self.address, opt["reg"], 1)[0] + config_value &= opt["mask"] + config_value |= (bits << opt["shift_left"]) + self.i2c.writeto_mem(self.address, opt["reg"], bytes([config_value])) + except KeyError as err: + print(f"Invalid option: {option}, or invalid option value: {value}.", err) + + def set_acc_range(self, acc_range): + """Set accelerometer range.""" + self._write_option('acc_range', acc_range) + self.acc_range = acc_range + self._acc_calc_sensitivity() + + def set_acc_data_rate(self, acc_rate): + """Set accelerometer data rate.""" + self._write_option('acc_data_rate', acc_rate) + + def set_gyro_range(self, gyro_range): + """Set gyroscope range.""" + self._write_option('gyro_range', gyro_range) + self.gyro_range = gyro_range + self._gyro_calc_sensitivity() + + def set_gyro_data_rate(self, gyro_rate): + """Set gyroscope data rate.""" + self._write_option('gyro_data_rate', gyro_rate) + + def _gyro_calc_sensitivity(self): + """Calculate gyroscope sensitivity based on range.""" + sensitivity_mapping = { + "125dps": 4.375, + "250dps": 8.75, + "500dps": 17.5, + "1000dps": 35, + "2000dps": 70 + } + + if self.gyro_range in sensitivity_mapping: + self.gyro_sensitivity = sensitivity_mapping[self.gyro_range] + else: + print("Invalid range value:", self.gyro_range) + + def soft_reset(self): + """Perform soft reset of the sensor.""" + self._write_option('ctrl_do_soft_reset', True) + + def reboot(self): + """Reboot the sensor.""" + self._write_option('ctrl_do_reboot', True) + + def set_interrupt(self, interrupts_enable=False, inact_en=False, slope_fds=False, + tap_x_en=True, tap_y_en=True, tap_z_en=True): + """Configure interrupt for tap gestures on INT0 pad.""" + config_value = 0b00000000 + + if interrupts_enable: + config_value |= (1 << 7) + if inact_en: + inact_en = 0x01 + config_value |= (inact_en << 5) + if slope_fds: + config_value |= (1 << 4) + if tap_x_en: + config_value |= (1 << 3) + if tap_y_en: + config_value |= (1 << 2) + if tap_z_en: + config_value |= (1 << 1) + + self.i2c.writeto_mem(self.address, Wsen_Isds._REG_A_TAP_CFG, + bytes([config_value])) + + self._write_option('tap_double_enable', False) + self._write_option('tap_threshold', 9) + self._write_option('tap_quiet_time', 1) + self._write_option('tap_duration_time', 5) + self._write_option('tap_shock_time', 2) + self._write_option('tap_single_to_int0', 1) + self._write_option('tap_double_to_int0', 1) + self._write_option('int1_on_int0', 1) + + def acc_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 = { + "2g": 0.061, + "4g": 0.122, + "8g": 0.244, + "16g": 0.488 + } + if self.acc_range in sensitivity_mapping: + self.acc_sensitivity = sensitivity_mapping[self.acc_range] + else: + print("Invalid range value:", self.acc_range) + + def read_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(): + raise Exception("sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_A_X_OUT_L, 6) + + raw_a_x = self._convert_from_raw(raw[0], raw[1]) + raw_a_y = self._convert_from_raw(raw[2], raw[3]) + raw_a_z = self._convert_from_raw(raw[4], raw[5]) + + return raw_a_x, raw_a_y, raw_a_z + + def gyro_calibrate(self, samples=None): + """Calibrate gyroscope by averaging samples while device is stationary. + + Args: + samples: Number of samples to average (default: GYRO_NUM_SAMPLES_CALIBRATION) + """ + if samples is None: + samples = self.GYRO_NUM_SAMPLES_CALIBRATION + + self.gyro_offset_x = 0 + self.gyro_offset_y = 0 + self.gyro_offset_z = 0 + + for _ in range(samples): + x, y, z = self._read_raw_angular_velocities() + self.gyro_offset_x += x + self.gyro_offset_y += y + self.gyro_offset_z += z + time.sleep_ms(self.GYRO_CALIBRATION_DELAY_MS) + + self.gyro_offset_x //= samples + self.gyro_offset_y //= samples + self.gyro_offset_z //= samples + + def read_angular_velocities(self): + """Read calibrated gyroscope data. + + Returns: + Tuple (x, y, z) in mdps (milli-degrees per second) + """ + raw_g_x, raw_g_y, raw_g_z = self._read_raw_angular_velocities() + + 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 + + def _read_raw_angular_velocities(self): + """Read raw gyroscope data.""" + if not self._gyro_data_ready(): + raise Exception("sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_G_X_OUT_L, 6) + + raw_g_x = self._convert_from_raw(raw[0], raw[1]) + raw_g_y = self._convert_from_raw(raw[2], raw[3]) + raw_g_z = self._convert_from_raw(raw[4], raw[5]) + + return raw_g_x, 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 + + @staticmethod + def _convert_from_raw(b_l, b_h): + """Convert two bytes (little-endian) to signed 16-bit integer.""" + c = (b_h << 8) | b_l + if c & (1 << 15): + c -= 1 << 16 + return c + + def _acc_data_ready(self): + """Check if accelerometer data is ready.""" + return self._get_status_reg()[0] + + def _gyro_data_ready(self): + """Check if gyroscope data is ready.""" + return self._get_status_reg()[1] + + def _acc_gyro_data_ready(self): + """Check if both accelerometer and gyroscope data are ready.""" + status_reg = self._get_status_reg() + return status_reg[0], status_reg[1] + + def _get_status_reg(self): + """Read status register. + + Returns: + Tuple (acc_data_ready, gyro_data_ready, temp_data_ready) + """ + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._ISDS_STATUS_REG, 4) + + acc_data_ready = True if raw[0] == 1 else False + gyro_data_ready = True if raw[1] == 1 else False + temp_data_ready = True if raw[2] == 1 else False + + return acc_data_ready, gyro_data_ready, temp_data_ready diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py new file mode 100644 index 0000000..4bca56e --- /dev/null +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -0,0 +1,603 @@ +"""Android-inspired SensorManager for MicroPythonOS. + +Provides unified access to IMU sensors (QMI8658, WSEN_ISDS) and other sensors. +Follows module-level singleton pattern (like AudioFlinger, LightsManager). + +Example usage: + import mpos.sensor_manager as SensorManager + + # In board init file: + SensorManager.init(i2c_bus, address=0x6B) + + # In app: + if SensorManager.is_available(): + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + ax, ay, az = SensorManager.read_sensor(accel) # Returns m/s² + +MIT License +Copyright (c) 2024 MicroPythonOS contributors +""" + +import time +try: + import _thread + _lock = _thread.allocate_lock() +except ImportError: + _lock = None + +# Sensor type constants (matching Android SensorManager) +TYPE_ACCELEROMETER = 1 # Units: m/s² (meters per second squared) +TYPE_GYROSCOPE = 4 # Units: deg/s (degrees per second) +TYPE_TEMPERATURE = 13 # Units: °C (generic, returns first available - deprecated) +TYPE_IMU_TEMPERATURE = 14 # Units: °C (IMU chip temperature) +TYPE_SOC_TEMPERATURE = 15 # Units: °C (MCU/SoC internal temperature) + +# Gravity constant for unit conversions +_GRAVITY = 9.80665 # m/s² + +# Module state +_initialized = False +_imu_driver = None +_sensor_list = [] +_i2c_bus = None +_i2c_address = None +_has_mcu_temperature = False + + +class Sensor: + """Sensor metadata (lightweight data class, Android-inspired).""" + + def __init__(self, name, sensor_type, vendor, version, max_range, resolution, power_ma): + """Initialize sensor metadata. + + Args: + name: Human-readable sensor name + sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + vendor: Sensor vendor/manufacturer + version: Driver version + max_range: Maximum measurement range (with units) + resolution: Measurement resolution (with units) + power_ma: Power consumption in mA (or 0 if unknown) + """ + self.name = name + self.type = sensor_type + self.vendor = vendor + self.version = version + self.max_range = max_range + self.resolution = resolution + self.power = power_ma + + def __repr__(self): + return f"Sensor({self.name}, type={self.type})" + + +def init(i2c_bus, address=0x6B): + """Initialize SensorManager with I2C bus. Auto-detects IMU type and MCU temperature. + + Tries to detect QMI8658 (chip ID 0x05) or WSEN_ISDS (WHO_AM_I 0x6A). + Also detects ESP32 MCU internal temperature sensor. + Loads calibration from SharedPreferences if available. + + Args: + i2c_bus: machine.I2C instance (can be None if only MCU temperature needed) + address: I2C address (default 0x6B for both QMI8658 and WSEN_ISDS) + + Returns: + bool: True if any sensor detected and initialized successfully + """ + global _initialized, _imu_driver, _sensor_list, _i2c_bus, _i2c_address, _has_mcu_temperature + + if _initialized: + print("[SensorManager] Already initialized") + return True + + _i2c_bus = i2c_bus + _i2c_address = address + imu_detected = False + + # Try QMI8658 first (Waveshare board) + if i2c_bus: + try: + from mpos.hardware.drivers.qmi8658 import QMI8658, _QMI8685_PARTID, _REG_PARTID + chip_id = i2c_bus.readfrom_mem(address, _REG_PARTID, 1)[0] + if chip_id == _QMI8685_PARTID: + print("[SensorManager] Detected QMI8658 IMU") + _imu_driver = _QMI8658Driver(i2c_bus, address) + _register_qmi8658_sensors() + _load_calibration() + imu_detected = True + except Exception as e: + print(f"[SensorManager] QMI8658 detection failed: {e}") + + # Try WSEN_ISDS (Fri3d badge) + if not imu_detected: + try: + from mpos.hardware.drivers.wsen_isds import Wsen_Isds + chip_id = i2c_bus.readfrom_mem(address, 0x0F, 1)[0] # WHO_AM_I register + if chip_id == 0x6A: # WSEN_ISDS WHO_AM_I value + print("[SensorManager] Detected WSEN_ISDS IMU") + _imu_driver = _WsenISDSDriver(i2c_bus, address) + _register_wsen_isds_sensors() + _load_calibration() + imu_detected = True + except Exception as e: + print(f"[SensorManager] WSEN_ISDS detection failed: {e}") + + # Try MCU internal temperature sensor (ESP32) + try: + import esp32 + # Test if mcu_temperature() is available + _ = esp32.mcu_temperature() + _has_mcu_temperature = True + _register_mcu_temperature_sensor() + print("[SensorManager] Detected MCU internal temperature sensor") + except Exception as e: + print(f"[SensorManager] MCU temperature not available: {e}") + + _initialized = True + + if not imu_detected and not _has_mcu_temperature: + print("[SensorManager] No sensors detected") + return False + + return True + + +def is_available(): + """Check if sensors are available. + + Returns: + bool: True if SensorManager is initialized with hardware + """ + return _initialized and _imu_driver is not None + + +def get_sensor_list(): + """Get list of all available sensors. + + Returns: + list: List of Sensor objects + """ + return _sensor_list.copy() if _sensor_list else [] + + +def get_default_sensor(sensor_type): + """Get default sensor of given type. + + Args: + sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + + Returns: + Sensor object or None if not available + """ + for sensor in _sensor_list: + if sensor.type == sensor_type: + return sensor + return None + + +def read_sensor(sensor): + """Read sensor data synchronously. + + Args: + sensor: Sensor object from get_default_sensor() + + Returns: + For motion sensors: tuple (x, y, z) in appropriate units + For scalar sensors: single value + None if sensor not available or error + """ + if sensor is None: + return None + + if _lock: + _lock.acquire() + + try: + if sensor.type == TYPE_ACCELEROMETER: + if _imu_driver: + return _imu_driver.read_acceleration() + elif sensor.type == TYPE_GYROSCOPE: + if _imu_driver: + return _imu_driver.read_gyroscope() + elif sensor.type == TYPE_IMU_TEMPERATURE: + if _imu_driver: + return _imu_driver.read_temperature() + elif sensor.type == TYPE_SOC_TEMPERATURE: + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + elif sensor.type == TYPE_TEMPERATURE: + # Generic temperature - return first available (backward compatibility) + if _imu_driver: + temp = _imu_driver.read_temperature() + if temp is not None: + return temp + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + return None + except Exception as e: + print(f"[SensorManager] Error reading sensor {sensor.name}: {e}") + return None + finally: + if _lock: + _lock.release() + + +def calibrate_sensor(sensor, samples=100): + """Calibrate sensor and save to SharedPreferences. + + Device must be stationary for accelerometer/gyroscope calibration. + + Args: + sensor: Sensor object to calibrate + samples: Number of samples to average (default 100) + + Returns: + tuple: Calibration offsets (x, y, z) or None if failed + """ + if not is_available() or sensor is None: + return None + + if _lock: + _lock.acquire() + + try: + offsets = None + if sensor.type == TYPE_ACCELEROMETER: + offsets = _imu_driver.calibrate_accelerometer(samples) + print(f"[SensorManager] Accelerometer calibrated: {offsets}") + elif sensor.type == TYPE_GYROSCOPE: + offsets = _imu_driver.calibrate_gyroscope(samples) + print(f"[SensorManager] Gyroscope calibrated: {offsets}") + else: + print(f"[SensorManager] Sensor type {sensor.type} does not support calibration") + return None + + # Save calibration + if offsets: + _save_calibration() + + return offsets + except Exception as e: + print(f"[SensorManager] Error calibrating sensor {sensor.name}: {e}") + return None + finally: + if _lock: + _lock.release() + + +# ============================================================================ +# Internal driver abstraction layer +# ============================================================================ + +class _IMUDriver: + """Base class for IMU drivers (internal use only).""" + + def read_acceleration(self): + """Returns (x, y, z) in m/s²""" + raise NotImplementedError + + def read_gyroscope(self): + """Returns (x, y, z) in deg/s""" + raise NotImplementedError + + def read_temperature(self): + """Returns temperature in °C""" + raise NotImplementedError + + def calibrate_accelerometer(self, samples): + """Calibrate accel, return (x, y, z) offsets in m/s²""" + raise NotImplementedError + + def calibrate_gyroscope(self, samples): + """Calibrate gyro, return (x, y, z) offsets in deg/s""" + raise NotImplementedError + + def get_calibration(self): + """Return dict with 'accel_offsets' and 'gyro_offsets' keys""" + raise NotImplementedError + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration offsets from saved values""" + raise NotImplementedError + + +class _QMI8658Driver(_IMUDriver): + """Wrapper for QMI8658 IMU (Waveshare board).""" + + def __init__(self, i2c_bus, address): + from mpos.hardware.drivers.qmi8658 import QMI8658, _ACCELSCALE_RANGE_8G, _GYROSCALE_RANGE_256DPS + self.sensor = QMI8658( + i2c_bus, + address=address, + accel_scale=_ACCELSCALE_RANGE_8G, + gyro_scale=_GYROSCALE_RANGE_256DPS + ) + # Software calibration offsets (QMI8658 has no built-in calibration) + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] + + def read_acceleration(self): + """Read acceleration in m/s² (converts from G).""" + ax, ay, az = self.sensor.acceleration + # Convert G to m/s² and apply calibration + return ( + (ax * _GRAVITY) - self.accel_offset[0], + (ay * _GRAVITY) - self.accel_offset[1], + (az * _GRAVITY) - self.accel_offset[2] + ) + + def read_gyroscope(self): + """Read gyroscope in deg/s (already in correct units).""" + gx, gy, gz = self.sensor.gyro + # Apply calibration + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2] + ) + + def read_temperature(self): + """Read temperature in °C.""" + return self.sensor.temperature + + def calibrate_accelerometer(self, samples): + """Calibrate accelerometer (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + ax, ay, az = self.sensor.acceleration + # Convert to m/s² + sum_x += ax * _GRAVITY + sum_y += ay * _GRAVITY + sum_z += az * _GRAVITY + time.sleep_ms(10) + + # Average offsets (assuming Z-axis should read +9.8 m/s²) + self.accel_offset[0] = sum_x / samples + self.accel_offset[1] = sum_y / samples + self.accel_offset[2] = (sum_z / samples) - _GRAVITY # Expect +1G on Z + + return tuple(self.accel_offset) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self.sensor.gyro + sum_x += gx + sum_y += gy + sum_z += gz + time.sleep_ms(10) + + # Average offsets (should be 0 when stationary) + self.gyro_offset[0] = sum_x / samples + self.gyro_offset[1] = sum_y / samples + self.gyro_offset[2] = sum_z / samples + + return tuple(self.gyro_offset) + + def get_calibration(self): + """Get current calibration.""" + return { + 'accel_offsets': self.accel_offset, + 'gyro_offsets': self.gyro_offset + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration from saved values.""" + if accel_offsets: + self.accel_offset = list(accel_offsets) + if gyro_offsets: + self.gyro_offset = list(gyro_offsets) + + +class _WsenISDSDriver(_IMUDriver): + """Wrapper for WSEN_ISDS IMU (Fri3d badge).""" + + def __init__(self, i2c_bus, address): + from mpos.hardware.drivers.wsen_isds import Wsen_Isds + self.sensor = Wsen_Isds( + i2c_bus, + address=address, + acc_range="8g", + acc_data_rate="104Hz", + gyro_range="500dps", + gyro_data_rate="104Hz" + ) + + def read_acceleration(self): + """Read acceleration in m/s² (converts from mg).""" + ax, ay, az = self.sensor.read_accelerations() + # Convert mg to m/s²: mg → g → m/s² + return ( + (ax / 1000.0) * _GRAVITY, + (ay / 1000.0) * _GRAVITY, + (az / 1000.0) * _GRAVITY + ) + + def read_gyroscope(self): + """Read gyroscope in deg/s (converts from mdps).""" + gx, gy, gz = self.sensor.read_angular_velocities() + # Convert mdps to deg/s + return ( + gx / 1000.0, + gy / 1000.0, + gz / 1000.0 + ) + + def read_temperature(self): + """Read temperature in °C (not implemented in WSEN_ISDS driver).""" + # WSEN_ISDS has temperature sensor but not exposed in current driver + return None + + def calibrate_accelerometer(self, samples): + """Calibrate accelerometer using hardware calibration.""" + self.sensor.acc_calibrate(samples) + # Return offsets in m/s² (convert from raw offsets) + return ( + (self.sensor.acc_offset_x * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY, + (self.sensor.acc_offset_y * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY, + (self.sensor.acc_offset_z * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY + ) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope using hardware calibration.""" + self.sensor.gyro_calibrate(samples) + # Return offsets in deg/s (convert from raw offsets) + return ( + (self.sensor.gyro_offset_x * self.sensor.gyro_sensitivity) / 1000.0, + (self.sensor.gyro_offset_y * self.sensor.gyro_sensitivity) / 1000.0, + (self.sensor.gyro_offset_z * self.sensor.gyro_sensitivity) / 1000.0 + ) + + def get_calibration(self): + """Get current calibration (raw offsets from hardware).""" + return { + 'accel_offsets': [ + self.sensor.acc_offset_x, + self.sensor.acc_offset_y, + self.sensor.acc_offset_z + ], + 'gyro_offsets': [ + self.sensor.gyro_offset_x, + self.sensor.gyro_offset_y, + self.sensor.gyro_offset_z + ] + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration from saved values (raw offsets).""" + if accel_offsets: + self.sensor.acc_offset_x = accel_offsets[0] + self.sensor.acc_offset_y = accel_offsets[1] + self.sensor.acc_offset_z = accel_offsets[2] + if gyro_offsets: + self.sensor.gyro_offset_x = gyro_offsets[0] + self.sensor.gyro_offset_y = gyro_offsets[1] + self.sensor.gyro_offset_z = gyro_offsets[2] + + +# ============================================================================ +# Sensor registration (internal) +# ============================================================================ + +def _register_qmi8658_sensors(): + """Register QMI8658 sensors in sensor list.""" + global _sensor_list + _sensor_list = [ + Sensor( + name="QMI8658 Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="QST Corporation", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2 + ), + Sensor( + name="QMI8658 Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="QST Corporation", + version=1, + max_range="±256 deg/s", + resolution="0.002 deg/s", + power_ma=0.7 + ), + Sensor( + name="QMI8658 Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="QST Corporation", + version=1, + max_range="-40°C to +85°C", + resolution="0.004°C", + power_ma=0 + ) + ] + + +def _register_wsen_isds_sensors(): + """Register WSEN_ISDS sensors in sensor list.""" + global _sensor_list + _sensor_list = [ + Sensor( + name="WSEN_ISDS Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="Würth Elektronik", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2 + ), + Sensor( + name="WSEN_ISDS Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="Würth Elektronik", + version=1, + max_range="±500 deg/s", + resolution="0.0175 deg/s", + power_ma=0.65 + ) + ] + + +def _register_mcu_temperature_sensor(): + """Register MCU internal temperature sensor in sensor list.""" + global _sensor_list + _sensor_list.append( + Sensor( + name="ESP32 MCU Temperature", + sensor_type=TYPE_SOC_TEMPERATURE, + vendor="Espressif", + version=1, + max_range="-40°C to +125°C", + resolution="0.5°C", + power_ma=0 + ) + ) + + +# ============================================================================ +# Calibration persistence (internal) +# ============================================================================ + +def _load_calibration(): + """Load calibration from SharedPreferences.""" + if not _imu_driver: + return + + try: + from mpos.config import SharedPreferences + prefs = SharedPreferences("com.micropythonos.sensors") + + accel_offsets = prefs.get_list("accel_offsets") + gyro_offsets = prefs.get_list("gyro_offsets") + + if accel_offsets or gyro_offsets: + _imu_driver.set_calibration(accel_offsets, gyro_offsets) + print(f"[SensorManager] Loaded calibration: accel={accel_offsets}, gyro={gyro_offsets}") + except Exception as e: + print(f"[SensorManager] Failed to load calibration: {e}") + + +def _save_calibration(): + """Save calibration to SharedPreferences.""" + if not _imu_driver: + return + + try: + from mpos.config import SharedPreferences + prefs = SharedPreferences("com.micropythonos.sensors") + editor = prefs.edit() + + cal = _imu_driver.get_calibration() + editor.put_list("accel_offsets", list(cal['accel_offsets'])) + editor.put_list("gyro_offsets", list(cal['gyro_offsets'])) + editor.commit() + + print(f"[SensorManager] Saved calibration: accel={cal['accel_offsets']}, gyro={cal['gyro_offsets']}") + except Exception as e: + print(f"[SensorManager] Failed to save calibration: {e}") diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index b37a123..7911c95 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -163,16 +163,22 @@ def update_wifi_icon(timer): else: wifi_icon.add_flag(lv.obj.FLAG.HIDDEN) - can_check_temperature = False - try: - import esp32 - can_check_temperature = True - except Exception as e: - print("Warning: can't check temperature sensor:", str(e)) - + # Get temperature sensor via SensorManager + import mpos.sensor_manager as SensorManager + temp_sensor = None + if SensorManager.is_available(): + # Prefer MCU temperature (more stable) over IMU temperature + temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) + if not temp_sensor: + temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + def update_temperature(timer): - if can_check_temperature: - temp_label.set_text(f"{esp32.mcu_temperature()}°C") + if temp_sensor: + temp = SensorManager.read_sensor(temp_sensor) + if temp is not None: + temp_label.set_text(f"{round(temp)}°C") + else: + temp_label.set_text("--°C") else: temp_label.set_text("42°C") From eaa2ee34d563818822f8a72d76339b0db5fcd8b0 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 4 Dec 2025 13:21:58 +0100 Subject: [PATCH 100/192] Add tests/test_sensor_manager.py --- tests/test_sensor_manager.py | 376 +++++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 tests/test_sensor_manager.py diff --git a/tests/test_sensor_manager.py b/tests/test_sensor_manager.py new file mode 100644 index 0000000..1584e22 --- /dev/null +++ b/tests/test_sensor_manager.py @@ -0,0 +1,376 @@ +# Unit tests for SensorManager service +import unittest +import sys + + +# Mock hardware before importing SensorManager +class MockI2C: + """Mock I2C bus for testing.""" + def __init__(self, bus_id, sda=None, scl=None): + self.bus_id = bus_id + self.sda = sda + self.scl = scl + self.memory = {} # addr -> {reg -> value} + + def readfrom_mem(self, addr, reg, nbytes): + """Read from memory (simulates I2C read).""" + if addr not in self.memory: + raise OSError("I2C device not found") + if reg not in self.memory[addr]: + return bytes([0] * nbytes) + return bytes(self.memory[addr][reg]) + + def writeto_mem(self, addr, reg, data): + """Write to memory (simulates I2C write).""" + if addr not in self.memory: + self.memory[addr] = {} + self.memory[addr][reg] = list(data) + + +class MockQMI8658: + """Mock QMI8658 IMU sensor.""" + def __init__(self, i2c_bus, address=0x6B, accel_scale=0b10, gyro_scale=0b100): + self.i2c = i2c_bus + self.address = address + self.accel_scale = accel_scale + self.gyro_scale = gyro_scale + + @property + def temperature(self): + """Return mock temperature.""" + return 25.5 # Mock temperature in °C + + @property + def acceleration(self): + """Return mock acceleration (in G).""" + return (0.0, 0.0, 1.0) # At rest, Z-axis = 1G + + @property + def gyro(self): + """Return mock gyroscope (in deg/s).""" + return (0.0, 0.0, 0.0) # Stationary + + +class MockWsenIsds: + """Mock WSEN_ISDS IMU sensor.""" + def __init__(self, i2c, address=0x6B, acc_range="8g", acc_data_rate="104Hz", + gyro_range="500dps", gyro_data_rate="104Hz"): + self.i2c = i2c + self.address = address + self.acc_range = acc_range + self.gyro_range = gyro_range + self.acc_sensitivity = 0.244 # mg/digit for 8g + self.gyro_sensitivity = 17.5 # mdps/digit for 500dps + self.acc_offset_x = 0 + self.acc_offset_y = 0 + self.acc_offset_z = 0 + self.gyro_offset_x = 0 + self.gyro_offset_y = 0 + self.gyro_offset_z = 0 + + def get_chip_id(self): + """Return WHO_AM_I value.""" + return 0x6A + + def read_accelerations(self): + """Return mock acceleration (in mg).""" + return (0.0, 0.0, 1000.0) # At rest, Z-axis = 1000 mg + + def read_angular_velocities(self): + """Return mock gyroscope (in mdps).""" + return (0.0, 0.0, 0.0) + + def acc_calibrate(self, samples=None): + """Mock calibration.""" + pass + + def gyro_calibrate(self, samples=None): + """Mock calibration.""" + pass + + +# Mock constants from drivers +_QMI8685_PARTID = 0x05 +_REG_PARTID = 0x00 +_ACCELSCALE_RANGE_8G = 0b10 +_GYROSCALE_RANGE_256DPS = 0b100 + + +# Create mock modules +mock_machine = type('module', (), { + 'I2C': MockI2C, + 'Pin': type('Pin', (), {}) +})() + +mock_qmi8658 = type('module', (), { + 'QMI8658': MockQMI8658, + '_QMI8685_PARTID': _QMI8685_PARTID, + '_REG_PARTID': _REG_PARTID, + '_ACCELSCALE_RANGE_8G': _ACCELSCALE_RANGE_8G, + '_GYROSCALE_RANGE_256DPS': _GYROSCALE_RANGE_256DPS +})() + +mock_wsen_isds = type('module', (), { + 'Wsen_Isds': MockWsenIsds +})() + +# Mock esp32 module +def _mock_mcu_temperature(*args, **kwargs): + """Mock MCU temperature sensor.""" + return 42.0 + +mock_esp32 = type('module', (), { + 'mcu_temperature': _mock_mcu_temperature +})() + +# Inject mocks into sys.modules +sys.modules['machine'] = mock_machine +sys.modules['mpos.hardware.drivers.qmi8658'] = mock_qmi8658 +sys.modules['mpos.hardware.drivers.wsen_isds'] = mock_wsen_isds +sys.modules['esp32'] = mock_esp32 + +# Mock _thread for thread safety testing +try: + import _thread +except ImportError: + mock_thread = type('module', (), { + 'allocate_lock': lambda: type('lock', (), { + 'acquire': lambda self: None, + 'release': lambda self: None + })() + })() + sys.modules['_thread'] = mock_thread + +# Now import the module to test +import mpos.sensor_manager as SensorManager + + +class TestSensorManagerQMI8658(unittest.TestCase): + """Test cases for SensorManager with QMI8658 IMU.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with QMI8658 + self.i2c_bus = MockI2C(0, sda=48, scl=47) + # Set QMI8658 chip ID + self.i2c_bus.memory[0x6B] = {_REG_PARTID: [_QMI8685_PARTID]} + + def test_initialization_qmi8658(self): + """Test that SensorManager initializes with QMI8658.""" + result = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result) + self.assertTrue(SensorManager.is_available()) + + def test_sensor_list_qmi8658(self): + """Test getting sensor list for QMI8658.""" + SensorManager.init(self.i2c_bus, address=0x6B) + sensors = SensorManager.get_sensor_list() + + # QMI8658 provides: Accelerometer, Gyroscope, IMU Temperature, MCU Temperature + self.assertGreaterEqual(len(sensors), 3) + + # Check sensor types present + sensor_types = [s.type for s in sensors] + self.assertIn(SensorManager.TYPE_ACCELEROMETER, sensor_types) + self.assertIn(SensorManager.TYPE_GYROSCOPE, sensor_types) + self.assertIn(SensorManager.TYPE_IMU_TEMPERATURE, sensor_types) + + def test_get_default_sensor(self): + """Test getting default sensor by type.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.assertIsNotNone(accel) + self.assertEqual(accel.type, SensorManager.TYPE_ACCELEROMETER) + + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + self.assertIsNotNone(gyro) + self.assertEqual(gyro.type, SensorManager.TYPE_GYROSCOPE) + + def test_get_nonexistent_sensor(self): + """Test getting a sensor type that doesn't exist.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + # Type 999 doesn't exist + sensor = SensorManager.get_default_sensor(999) + self.assertIsNone(sensor) + + def test_read_accelerometer(self): + """Test reading accelerometer data.""" + SensorManager.init(self.i2c_bus, address=0x6B) + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + + data = SensorManager.read_sensor(accel) + self.assertTrue(data is not None, f"read_sensor returned None, expected tuple") + self.assertEqual(len(data), 3) # (x, y, z) + + ax, ay, az = data + # At rest, Z should be ~9.8 m/s² (1G converted to m/s²) + self.assertAlmostEqual(az, 9.80665, places=2) + + def test_read_gyroscope(self): + """Test reading gyroscope data.""" + SensorManager.init(self.i2c_bus, address=0x6B) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + data = SensorManager.read_sensor(gyro) + self.assertTrue(data is not None, f"read_sensor returned None, expected tuple") + self.assertEqual(len(data), 3) # (x, y, z) + + gx, gy, gz = data + # Stationary, all should be ~0 deg/s + self.assertAlmostEqual(gx, 0.0, places=1) + self.assertAlmostEqual(gy, 0.0, places=1) + self.assertAlmostEqual(gz, 0.0, places=1) + + def test_read_temperature(self): + """Test reading temperature data.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + # Try IMU temperature + imu_temp = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + if imu_temp: + temp = SensorManager.read_sensor(imu_temp) + self.assertIsNotNone(temp) + self.assertIsInstance(temp, (int, float)) + + # Try MCU temperature + mcu_temp = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) + if mcu_temp: + temp = SensorManager.read_sensor(mcu_temp) + self.assertIsNotNone(temp) + self.assertEqual(temp, 42.0) # Mock value + + def test_read_sensor_without_init(self): + """Test reading sensor without initialization.""" + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.assertIsNone(accel) + + def test_is_available_before_init(self): + """Test is_available before initialization.""" + self.assertFalse(SensorManager.is_available()) + + +class TestSensorManagerWsenIsds(unittest.TestCase): + """Test cases for SensorManager with WSEN_ISDS IMU.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with WSEN_ISDS + self.i2c_bus = MockI2C(0, sda=9, scl=18) + # Set WSEN_ISDS WHO_AM_I + self.i2c_bus.memory[0x6B] = {0x0F: [0x6A]} + + def test_initialization_wsen_isds(self): + """Test that SensorManager initializes with WSEN_ISDS.""" + result = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result) + self.assertTrue(SensorManager.is_available()) + + def test_sensor_list_wsen_isds(self): + """Test getting sensor list for WSEN_ISDS.""" + SensorManager.init(self.i2c_bus, address=0x6B) + sensors = SensorManager.get_sensor_list() + + # WSEN_ISDS provides: Accelerometer, Gyroscope, MCU Temperature + # (no IMU temperature) + self.assertGreaterEqual(len(sensors), 2) + + # Check sensor types + sensor_types = [s.type for s in sensors] + self.assertIn(SensorManager.TYPE_ACCELEROMETER, sensor_types) + self.assertIn(SensorManager.TYPE_GYROSCOPE, sensor_types) + + def test_read_accelerometer_wsen_isds(self): + """Test reading accelerometer from WSEN_ISDS.""" + SensorManager.init(self.i2c_bus, address=0x6B) + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + + data = SensorManager.read_sensor(accel) + self.assertTrue(data is not None, f"read_sensor returned None, expected tuple") + self.assertEqual(len(data), 3) + + ax, ay, az = data + # WSEN_ISDS mock returns 1000mg = 1G = 9.80665 m/s² + self.assertAlmostEqual(az, 9.80665, places=2) + + +class TestSensorManagerNoHardware(unittest.TestCase): + """Test cases for SensorManager without hardware (desktop mode).""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with no devices + self.i2c_bus = MockI2C(0, sda=48, scl=47) + # No chip ID registered - simulates no hardware + + def test_no_imu_detected(self): + """Test behavior when no IMU is present.""" + result = SensorManager.init(self.i2c_bus, address=0x6B) + # Returns True if MCU temp is available (even without IMU) + self.assertTrue(result) + + def test_graceful_degradation(self): + """Test graceful degradation when no sensors available.""" + SensorManager.init(self.i2c_bus, address=0x6B) + + # Should have at least MCU temperature + sensors = SensorManager.get_sensor_list() + self.assertGreaterEqual(len(sensors), 0) + + # Reading non-existent sensor should return None + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + if accel is None: + # Expected when no IMU + pass + else: + # If somehow initialized, reading should handle gracefully + data = SensorManager.read_sensor(accel) + # Should either work or return None, not crash + self.assertTrue(data is None or len(data) == 3) + + +class TestSensorManagerMultipleInit(unittest.TestCase): + """Test cases for multiple initialization calls.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with QMI8658 + self.i2c_bus = MockI2C(0, sda=48, scl=47) + self.i2c_bus.memory[0x6B] = {_REG_PARTID: [_QMI8685_PARTID]} + + def test_multiple_init_calls(self): + """Test that multiple init calls are handled gracefully.""" + result1 = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result1) + + # Second init should return True but not re-initialize + result2 = SensorManager.init(self.i2c_bus, address=0x6B) + self.assertTrue(result2) + + # Should still work normally + self.assertTrue(SensorManager.is_available()) From b4577d0f66df5d74073f80539c94d667c5703883 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 4 Dec 2025 13:51:52 +0100 Subject: [PATCH 101/192] Fix SensorManager --- CHANGELOG.md | 1 + CLAUDE.md | 3 ++- internal_filesystem/lib/mpos/board/linux.py | 5 ++++- internal_filesystem/lib/mpos/sensor_manager.py | 10 ++++++++-- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c98b1..4dc2681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 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. - About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name - Camera app: massive overhaul! diff --git a/CLAUDE.md b/CLAUDE.md index f6bacf3..e61a1e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1010,8 +1010,9 @@ def update_frame(self, a, b): - **Units**: Standard SI (m/s² for acceleration, deg/s for gyroscope, °C for temperature) - **Calibration**: Persistent via SharedPreferences (`data/com.micropythonos.sensors/config.json`) - **Thread-safe**: Uses locks for concurrent access -- **Auto-detection**: Identifies IMU type via chip ID registers +- **Auto-detection**: Identifies IMU type via chip ID registers (QMI8658: chip_id=0x05 at reg=0x00, WSEN_ISDS: chip_id=0x6A at reg=0x0F) - **Desktop**: Functions return `None` (graceful fallback) on desktop builds +- **Important**: Driver constants defined with `const()` cannot be imported at runtime - SensorManager uses hardcoded values instead ### Driver Locations diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index d5c3b6e..a82a12c 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -113,7 +113,10 @@ def adc_to_voltage(adc_value): # === SENSOR HARDWARE === # Note: Desktop builds have no sensor hardware import mpos.sensor_manager as SensorManager -# Don't call init() - SensorManager functions will return None/False + +# Initialize with no I2C bus - will detect MCU temp if available +# (On Linux desktop, this will fail gracefully but set _initialized flag) +SensorManager.init(None) print("linux.py finished") diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 4bca56e..0f0d956 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -98,7 +98,10 @@ def init(i2c_bus, address=0x6B): # Try QMI8658 first (Waveshare board) if i2c_bus: try: - from mpos.hardware.drivers.qmi8658 import QMI8658, _QMI8685_PARTID, _REG_PARTID + from mpos.hardware.drivers.qmi8658 import QMI8658 + # QMI8658 constants (can't import const() values) + _QMI8685_PARTID = 0x05 + _REG_PARTID = 0x00 chip_id = i2c_bus.readfrom_mem(address, _REG_PARTID, 1)[0] if chip_id == _QMI8685_PARTID: print("[SensorManager] Detected QMI8658 IMU") @@ -308,7 +311,10 @@ class _QMI8658Driver(_IMUDriver): """Wrapper for QMI8658 IMU (Waveshare board).""" def __init__(self, i2c_bus, address): - from mpos.hardware.drivers.qmi8658 import QMI8658, _ACCELSCALE_RANGE_8G, _GYROSCALE_RANGE_256DPS + from mpos.hardware.drivers.qmi8658 import QMI8658 + # QMI8658 scale constants (can't import const() values) + _ACCELSCALE_RANGE_8G = 0b10 + _GYROSCALE_RANGE_256DPS = 0b100 self.sensor = QMI8658( i2c_bus, address=address, From 92c2fcfec7bd4627a375d32f26700b3bb1c38639 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 4 Dec 2025 14:25:36 +0100 Subject: [PATCH 102/192] Move CLAUDE.md stuff to docs/ --- CLAUDE.md | 497 ++++++------------------------------------------------ 1 file changed, 47 insertions(+), 450 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e61a1e8..410f941 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ The OS supports: **Content Management**: - `PackageManager`: Install/uninstall/query apps - `Intent`: Launch activities with action/category filters -- `SharedPreferences`: Per-app key-value storage (similar to Android) +- `SharedPreferences`: Per-app key-value storage (similar to Android) - see [docs/frameworks/preferences.md](../docs/docs/frameworks/preferences.md) **Hardware Abstraction**: - `boot.py` configures SPI, I2C, display (ST7789), touchscreen (CST816S), and battery ADC @@ -446,125 +446,21 @@ Current stable version: 0.3.3 (as of latest CHANGELOG entry) - Intent system: `internal_filesystem/lib/mpos/content/intent.py` - UI initialization: `internal_filesystem/main.py` - Hardware init: `internal_filesystem/boot.py` -- Config/preferences: `internal_filesystem/lib/mpos/config.py` +- Config/preferences: `internal_filesystem/lib/mpos/config.py` - see [docs/frameworks/preferences.md](../docs/docs/frameworks/preferences.md) +- Audio system: `internal_filesystem/lib/mpos/audio/audioflinger.py` - see [docs/frameworks/audioflinger.md](../docs/docs/frameworks/audioflinger.md) +- LED control: `internal_filesystem/lib/mpos/lights.py` - see [docs/frameworks/lights-manager.md](../docs/docs/frameworks/lights-manager.md) +- Sensor management: `internal_filesystem/lib/mpos/sensor_manager.py` - see [docs/frameworks/sensor-manager.md](../docs/docs/frameworks/sensor-manager.md) - Top menu/drawer: `internal_filesystem/lib/mpos/ui/topmenu.py` - Activity navigation: `internal_filesystem/lib/mpos/activity_navigator.py` -- Sensor management: `internal_filesystem/lib/mpos/sensor_manager.py` - IMU drivers: `internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py` and `wsen_isds.py` ## Common Utilities and Helpers **SharedPreferences**: Persistent key-value storage per app -```python -from mpos.config import SharedPreferences - -# Basic usage -prefs = SharedPreferences("com.example.myapp") -value = prefs.get_string("key", "default_value") -number = prefs.get_int("count", 0) -data = prefs.get_dict("data", {}) - -# Save preferences -editor = prefs.edit() -editor.put_string("key", "value") -editor.put_int("count", 42) -editor.put_dict("data", {"key": "value"}) -editor.commit() - -# Using constructor defaults (reduces config file size) -# Values matching defaults are not saved to disk -prefs = SharedPreferences("com.example.myapp", defaults={ - "brightness": -1, - "volume": 50, - "theme": "dark" -}) - -# Returns constructor default (-1) if not stored -brightness = prefs.get_int("brightness") # Returns -1 - -# Method defaults override constructor defaults -brightness = prefs.get_int("brightness", 100) # Returns 100 - -# Stored values override all defaults -prefs.edit().put_int("brightness", 75).commit() -brightness = prefs.get_int("brightness") # Returns 75 - -# Setting to default value removes it from storage (auto-cleanup) -prefs.edit().put_int("brightness", -1).commit() -# brightness is no longer stored in config.json, saves space -``` - -**Multi-mode apps with merged defaults**: - -Apps with multiple operating modes can define separate defaults dictionaries and merge them based on the current mode. The camera app demonstrates this pattern with normal and QR scanning modes: - -```python -# Define defaults in your settings class -class CameraSettingsActivity: - # Common defaults shared by all modes - COMMON_DEFAULTS = { - "brightness": 1, - "contrast": 0, - "saturation": 0, - "hmirror": False, - "vflip": True, - # ... 20 more common settings - } - - # Normal mode specific defaults - NORMAL_DEFAULTS = { - "resolution_width": 240, - "resolution_height": 240, - "colormode": True, - "ae_level": 0, - "raw_gma": True, - } - # QR scanning mode specific defaults - SCANQR_DEFAULTS = { - "resolution_width": 960, - "resolution_height": 960, - "colormode": False, # Grayscale for better QR detection - "ae_level": 2, # Higher exposure - "raw_gma": False, # Better contrast - } - -# Merge defaults based on mode when initializing -def load_settings(self): - if self.scanqr_mode: - # Merge common + scanqr defaults - scanqr_defaults = {} - scanqr_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) - scanqr_defaults.update(CameraSettingsActivity.SCANQR_DEFAULTS) - self.prefs = SharedPreferences( - self.PACKAGE, - filename="config_scanqr.json", - defaults=scanqr_defaults - ) - else: - # Merge common + normal defaults - normal_defaults = {} - normal_defaults.update(CameraSettingsActivity.COMMON_DEFAULTS) - normal_defaults.update(CameraSettingsActivity.NORMAL_DEFAULTS) - self.prefs = SharedPreferences( - self.PACKAGE, - defaults=normal_defaults - ) - - # Now all get_*() calls can omit default arguments - width = self.prefs.get_int("resolution_width") # Mode-specific default - brightness = self.prefs.get_int("brightness") # Common default -``` - -**Benefits of this pattern**: -- Single source of truth for all 30 camera settings defaults -- Mode-specific config files (`config.json`, `config_scanqr.json`) -- ~90% reduction in config file size (only non-default values stored) -- Eliminates hardcoded defaults throughout the codebase -- No need to pass defaults to every `get_int()`/`get_bool()` call -- Self-documenting code with clear defaults dictionaries +📖 User Documentation: See [docs/frameworks/preferences.md](../docs/docs/frameworks/preferences.md) for complete guide with constructor defaults, multi-mode patterns, and auto-cleanup behavior. -**Note**: Use `dict.update()` instead of `{**dict1, **dict2}` for MicroPython compatibility (dictionary unpacking syntax not supported). +**Implementation**: `lib/mpos/config.py` - SharedPreferences class with get/put methods for strings, ints, bools, lists, and dicts. Values matching constructor defaults are automatically removed from storage (space optimization). **Intent system**: Launch activities and pass data ```python @@ -644,381 +540,82 @@ def defocus_handler(self, obj): - `mpos.sdcard.SDCardManager`: SD card mounting and management - `mpos.clipboard`: System clipboard access - `mpos.battery_voltage`: Battery level reading (ESP32 only) -- `mpos.sensor_manager`: Unified sensor access (accelerometer, gyroscope, temperature) +- `mpos.sensor_manager`: Unified sensor access - see [docs/frameworks/sensor-manager.md](../docs/docs/frameworks/sensor-manager.md) +- `mpos.audio.audioflinger`: Audio playback service - see [docs/frameworks/audioflinger.md](../docs/docs/frameworks/audioflinger.md) +- `mpos.lights`: LED control - see [docs/frameworks/lights-manager.md](../docs/docs/frameworks/lights-manager.md) ## Audio System (AudioFlinger) -MicroPythonOS provides a centralized audio service called **AudioFlinger** (Android-inspired) that manages audio playback across different hardware outputs. - -### Supported Audio Devices - -- **I2S**: Digital audio output for WAV file playback (Fri3d badge, Waveshare board) -- **Buzzer**: PWM-based tone/ringtone playback (Fri3d badge only) -- **Both**: Simultaneous I2S and buzzer support -- **Null**: No audio (desktop/Linux) - -### Basic Usage - -**Playing WAV files**: -```python -import mpos.audio.audioflinger as AudioFlinger - -# Play music file -success = AudioFlinger.play_wav( - "M:/sdcard/music/song.wav", - stream_type=AudioFlinger.STREAM_MUSIC, - volume=80, - on_complete=lambda msg: print(msg) -) - -if not success: - print("Audio playback rejected (higher priority stream active)") -``` - -**Playing RTTTL ringtones**: -```python -# Play notification sound via buzzer -rtttl = "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d,8p,8b,8a,8c#,8e" -AudioFlinger.play_rtttl( - rtttl, - stream_type=AudioFlinger.STREAM_NOTIFICATION -) -``` - -**Volume control**: -```python -AudioFlinger.set_volume(70) # 0-100 -volume = AudioFlinger.get_volume() -``` - -**Stopping playback**: -```python -AudioFlinger.stop() -``` - -### Audio Focus Priority - -AudioFlinger implements priority-based audio focus (Android-inspired): -- **STREAM_ALARM** (priority 2): Highest priority -- **STREAM_NOTIFICATION** (priority 1): Medium priority -- **STREAM_MUSIC** (priority 0): Lowest priority - -Higher priority streams automatically interrupt lower priority streams. Equal or lower priority streams are rejected while a stream is playing. +MicroPythonOS provides a centralized audio service called **AudioFlinger** for managing audio playback. -### Hardware Support Matrix +**📖 User Documentation**: See [docs/frameworks/audioflinger.md](../docs/docs/frameworks/audioflinger.md) for complete API reference, examples, and troubleshooting. -| Board | I2S | Buzzer | LEDs | -|-------|-----|--------|------| -| Fri3d 2024 Badge | ✓ (GPIO 2, 47, 16) | ✓ (GPIO 46) | ✓ (5 RGB, GPIO 12) | -| Waveshare ESP32-S3 | ✓ (GPIO 2, 47, 16) | ✗ | ✗ | -| Linux/macOS | ✗ | ✗ | ✗ | - -### Configuration - -Audio device preference is configured in Settings app under "Advanced Settings": -- **Auto-detect**: Use available hardware (default) -- **I2S (Digital Audio)**: Digital audio only -- **Buzzer (PWM Tones)**: Tones/ringtones only -- **Both I2S and Buzzer**: Use both devices -- **Disabled**: No audio - -**Note**: Changing the audio device requires a restart to take effect. - -### Implementation Details +### Implementation Details (for Claude Code) - **Location**: `lib/mpos/audio/audioflinger.py` - **Pattern**: Module-level singleton (similar to `battery_voltage.py`) - **Thread-safe**: Uses locks for concurrent access -- **Background playback**: Runs in separate thread -- **WAV support**: 8/16/24/32-bit PCM, mono/stereo, auto-upsampling to ≥22050 Hz -- **RTTTL parser**: Full Ring Tone Text Transfer Language support with exponential volume curve - -## LED Control (LightsManager) - -MicroPythonOS provides a simple LED control service for NeoPixel RGB LEDs (Fri3d badge only). - -### Basic Usage - -**Check availability**: -```python -import mpos.lights as LightsManager - -if LightsManager.is_available(): - print(f"LEDs available: {LightsManager.get_led_count()}") -``` - -**Control individual LEDs**: -```python -# Set LED 0 to red (buffered) -LightsManager.set_led(0, 255, 0, 0) - -# Set LED 1 to green -LightsManager.set_led(1, 0, 255, 0) - -# Update hardware -LightsManager.write() -``` +- **Hardware abstraction**: Supports I2S (GPIO 2, 47, 16) and Buzzer (GPIO 46 on Fri3d) +- **Audio focus**: 3-tier priority system (ALARM > NOTIFICATION > MUSIC) +- **Configuration**: `data/com.micropythonos.settings/config.json` key: `audio_device` -**Control all LEDs**: -```python -# Set all LEDs to blue -LightsManager.set_all(0, 0, 255) -LightsManager.write() - -# Clear all LEDs (black) -LightsManager.clear() -LightsManager.write() -``` +### Critical Code Locations -**Notification colors**: -```python -# Convenience method for common colors -LightsManager.set_notification_color("red") -LightsManager.set_notification_color("green") -# Available: red, green, blue, yellow, orange, purple, white -``` +- Audio service: `lib/mpos/audio/audioflinger.py` +- I2S implementation: `lib/mpos/audio/i2s_audio.py` +- Buzzer implementation: `lib/mpos/audio/buzzer.py` +- RTTTL parser: `lib/mpos/audio/rtttl.py` +- Board init (Waveshare): `lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py` (line ~105) +- Board init (Fri3d): `lib/mpos/board/fri3d_2024.py` (line ~300) -### Custom Animations - -LightsManager provides one-shot control only (no built-in animations). Apps implement custom animations using the `update_frame()` pattern: - -```python -import time -import mpos.lights as LightsManager - -def blink_pattern(): - for _ in range(5): - LightsManager.set_all(255, 0, 0) - LightsManager.write() - time.sleep_ms(200) - - LightsManager.clear() - LightsManager.write() - time.sleep_ms(200) - -def rainbow_cycle(): - colors = [ - (255, 0, 0), # Red - (255, 128, 0), # Orange - (255, 255, 0), # Yellow - (0, 255, 0), # Green - (0, 0, 255), # Blue - ] - - for i, color in enumerate(colors): - LightsManager.set_led(i, *color) - - LightsManager.write() -``` - -**For frame-based LED animations**, use the TaskHandler event system: - -```python -import mpos.ui -import time - -class LEDAnimationActivity(Activity): - last_time = 0 - led_index = 0 +## LED Control (LightsManager) - def onResume(self, screen): - self.last_time = time.ticks_ms() - mpos.ui.task_handler.add_event_cb(self.update_frame, 1) +MicroPythonOS provides LED control for NeoPixel RGB LEDs (Fri3d badge only). - def onPause(self, screen): - mpos.ui.task_handler.remove_event_cb(self.update_frame) - LightsManager.clear() - LightsManager.write() +**📖 User Documentation**: See [docs/frameworks/lights-manager.md](../docs/docs/frameworks/lights-manager.md) for complete API reference, animation patterns, and examples. - def update_frame(self, a, b): - current_time = time.ticks_ms() - delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 - self.last_time = current_time - - # Update animation every 0.5 seconds - if delta_time > 0.5: - LightsManager.clear() - LightsManager.set_led(self.led_index, 0, 255, 0) - LightsManager.write() - self.led_index = (self.led_index + 1) % LightsManager.get_led_count() -``` - -### Implementation Details +### Implementation Details (for Claude Code) - **Location**: `lib/mpos/lights.py` - **Pattern**: Module-level singleton (similar to `battery_voltage.py`) -- **Hardware**: 5 NeoPixel RGB LEDs on GPIO 12 (Fri3d badge) -- **Buffered**: LED colors are buffered until `write()` is called +- **Hardware**: 5 NeoPixel RGB LEDs on GPIO 12 (Fri3d badge only) +- **Buffered**: LED colors buffered until `write()` is called - **Thread-safe**: No locking (single-threaded usage recommended) - **Desktop**: Functions return `False` (no-op) on desktop builds -## Sensor System (SensorManager) - -MicroPythonOS provides a unified sensor framework called **SensorManager** (Android-inspired) that provides easy access to motion sensors (accelerometer, gyroscope) and temperature sensors across different hardware platforms. - -### Supported Sensors - -**IMU Sensors:** -- **QMI8658** (Waveshare ESP32-S3): Accelerometer, Gyroscope, Temperature -- **WSEN_ISDS** (Fri3d Camp 2024 Badge): Accelerometer, Gyroscope - -**Temperature Sensors:** -- **ESP32 MCU Temperature**: Internal SoC temperature sensor -- **IMU Chip Temperature**: QMI8658 chip temperature - -### Basic Usage - -**Check availability and read sensors**: -```python -import mpos.sensor_manager as SensorManager - -# Check if sensors are available -if SensorManager.is_available(): - # Get sensors - accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) - gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) - temp = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) - - # Read data (returns standard SI units) - accel_data = SensorManager.read_sensor(accel) # Returns (x, y, z) in m/s² - gyro_data = SensorManager.read_sensor(gyro) # Returns (x, y, z) in deg/s - temperature = SensorManager.read_sensor(temp) # Returns °C - - if accel_data: - ax, ay, az = accel_data - print(f"Acceleration: {ax:.2f}, {ay:.2f}, {az:.2f} m/s²") -``` - -### Sensor Types - -```python -# Motion sensors -SensorManager.TYPE_ACCELEROMETER # m/s² (meters per second squared) -SensorManager.TYPE_GYROSCOPE # deg/s (degrees per second) - -# Temperature sensors -SensorManager.TYPE_SOC_TEMPERATURE # °C (MCU internal temperature) -SensorManager.TYPE_IMU_TEMPERATURE # °C (IMU chip temperature) -``` - -### Tilt-Controlled Game Example - -```python -from mpos.app.activity import Activity -import mpos.sensor_manager as SensorManager -import mpos.ui -import time - -class TiltBallActivity(Activity): - def onCreate(self): - self.screen = lv.obj() - - # Get accelerometer - self.accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) - - # Create ball UI - self.ball = lv.obj(self.screen) - self.ball.set_size(20, 20) - self.ball.set_style_radius(10, 0) - - # Physics state - self.ball_x = 160.0 - self.ball_y = 120.0 - self.ball_vx = 0.0 - self.ball_vy = 0.0 - self.last_time = time.ticks_ms() - - self.setContentView(self.screen) - - def onResume(self, screen): - self.last_time = time.ticks_ms() - mpos.ui.task_handler.add_event_cb(self.update_physics, 1) - - def onPause(self, screen): - mpos.ui.task_handler.remove_event_cb(self.update_physics) - - def update_physics(self, a, b): - current_time = time.ticks_ms() - delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 - self.last_time = current_time - - # Read accelerometer - accel = SensorManager.read_sensor(self.accel) - if accel: - ax, ay, az = accel - - # Apply acceleration to velocity - self.ball_vx += (ax * 5.0) * delta_time - self.ball_vy -= (ay * 5.0) * delta_time # Flip Y +### Critical Code Locations - # Update position - self.ball_x += self.ball_vx - self.ball_y += self.ball_vy +- LED service: `lib/mpos/lights.py` +- Board init (Fri3d): `lib/mpos/board/fri3d_2024.py` (line ~290) +- NeoPixel dependency: Uses `neopixel` module from MicroPython - # Update ball position - self.ball.set_pos(int(self.ball_x), int(self.ball_y)) -``` - -### Calibration - -Calibration removes sensor drift and improves accuracy. The device must be **stationary** during calibration. - -```python -# Calibrate accelerometer and gyroscope -accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) -gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) - -# Calibrate (100 samples, device must be flat and still) -accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) -gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) - -# Calibration is automatically saved to SharedPreferences -# and loaded on next boot -``` - -### Performance Recommendations - -**Polling rate recommendations:** -- **Games**: 20-30 Hz (responsive but not excessive) -- **UI feedback**: 10-15 Hz (smooth for tilt UI) -- **Background monitoring**: 1-5 Hz (screen rotation, pedometer) - -```python -# ❌ BAD: Poll every frame (60 Hz) -def update_frame(self, a, b): - accel = SensorManager.read_sensor(self.accel) # Too frequent! - -# ✅ GOOD: Poll every other frame (30 Hz) -def update_frame(self, a, b): - self.frame_count += 1 - if self.frame_count % 2 == 0: - accel = SensorManager.read_sensor(self.accel) -``` +## Sensor System (SensorManager) -### Hardware Support Matrix +MicroPythonOS provides a unified sensor framework called **SensorManager** for motion sensors (accelerometer, gyroscope) and temperature sensors. -| Platform | Accelerometer | Gyroscope | IMU Temp | MCU Temp | -|----------|---------------|-----------|----------|----------| -| Waveshare ESP32-S3 | ✅ QMI8658 | ✅ QMI8658 | ✅ QMI8658 | ✅ ESP32 | -| Fri3d 2024 Badge | ✅ WSEN_ISDS | ✅ WSEN_ISDS | ❌ | ✅ ESP32 | -| Desktop/Linux | ❌ | ❌ | ❌ | ❌ | +📖 User Documentation: See [docs/frameworks/sensor-manager.md](../docs/docs/frameworks/sensor-manager.md) for complete API reference, calibration guide, game examples, and troubleshooting. -### Implementation Details +### Implementation Details (for Claude Code) - **Location**: `lib/mpos/sensor_manager.py` - **Pattern**: Module-level singleton (similar to `battery_voltage.py`) - **Units**: Standard SI (m/s² for acceleration, deg/s for gyroscope, °C for temperature) - **Calibration**: Persistent via SharedPreferences (`data/com.micropythonos.sensors/config.json`) - **Thread-safe**: Uses locks for concurrent access -- **Auto-detection**: Identifies IMU type via chip ID registers (QMI8658: chip_id=0x05 at reg=0x00, WSEN_ISDS: chip_id=0x6A at reg=0x0F) +- **Auto-detection**: Identifies IMU type via chip ID registers + - QMI8658: chip_id=0x05 at reg=0x00 + - WSEN_ISDS: chip_id=0x6A at reg=0x0F - **Desktop**: Functions return `None` (graceful fallback) on desktop builds - **Important**: Driver constants defined with `const()` cannot be imported at runtime - SensorManager uses hardcoded values instead -### Driver Locations +### Critical Code Locations -- **QMI8658**: `lib/mpos/hardware/drivers/qmi8658.py` -- **WSEN_ISDS**: `lib/mpos/hardware/drivers/wsen_isds.py` -- **Board init**: `lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py` and `lib/mpos/board/fri3d_2024.py` +- Sensor service: `lib/mpos/sensor_manager.py` +- QMI8658 driver: `lib/mpos/hardware/drivers/qmi8658.py` +- WSEN_ISDS driver: `lib/mpos/hardware/drivers/wsen_isds.py` +- Board init (Waveshare): `lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py` (line ~130) +- Board init (Fri3d): `lib/mpos/board/fri3d_2024.py` (line ~320) +- Board init (Linux): `lib/mpos/board/linux.py` (line ~115) ## Animations and Game Loops From 02a35e65aaec5f2eb6093d02fcfdf61606d7fd30 Mon Sep 17 00:00:00 2001 From: MarkPiazuelo Date: Fri, 5 Dec 2025 13:37:11 +0100 Subject: [PATCH 103/192] TopMenu Fix Fixed a bug where the "drawerOpen" variable would not be updated in gesture_navigation.py. Also added the back gesture as a way to exit the drawer. --- .DS_Store | Bin 0 -> 8196 bytes .../lib/mpos/ui/gesture_navigation.py | 22 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4d2b0bfa37a618f5096150da05aabe835f8c6fec GIT binary patch literal 8196 zcmeHMYitx%6uxI#;Er|Z-L`1UQXIPi6-p>gL3s$<2a&dDk!`!%Qe9?u#&*JVrtHk_ z7Ar}O&uBu7@$C;W{zalDCPqy}G-@P9B@Ky^_=qO{5r3%YKjXP`X9=`65TXX+OfvV} zd+s@B=6-v=d-v=TLZCgbuO+0G5JK_hl2u^yHy5Ah_pD0_H1kjb`V*2SW5gs`k|WM6 z>rfFQ5F!vF5F!vF5F&6nAb@8!zvvw2zL*W$5P=YZ|0M!^e^Bw}G9Jh&A^oib8@~iV zS&nM|!amjkzKA0q6I`-g@HqmEHczibH1)W(|sUg?Nc^!V-G-G+!*kxc?vtV>$aEw~TAKW|6Bf0}d z&P5rEHw(bzBbBxF4a-+GuiLn_bNh~+(=1X|U9(70hVV17J@anU$n_UZ-5VX$+^k{i zrah7@n68H3ynaNoXR?5W4InysN13)lzmL^;?LfpxnA$MVF!c?L#h99qMo~K(@oF8$*Ks8M%7+Q2YIkIUFUIpWulK#b^<>U(=M3E z6@*<-hQ{7il$f5LpIfQ3*A4CLm!7HO-rUAjXWlG4&1@%~bYrNig1OWKFyiy>aH8%f9JAfDRQ-QBZ8 zx&2Ba-j|hvYS&y_dp+mhhAkauvsC1DDV5Kqh|h}i_~f&~Pn((PjD%cLzf@8Ckv7J} zTr6e_I6>$%w{D0jDw~JI62ldZIGm5962qp|s>&p!uNbavQ59B(OqHjXL>Jeo>gt;@ z;lU5Iag(C3a^x(|EsoYHaiv}6I|U>DbmumV#2HBcc`kfSek7;K835!$HPk{qG{HL9 z1Z|l43FwCu48jm*zX2mK>NCK@{4c@;+z0m~2OdHeJPuF5lkgNg4KKnWc*$qN5uXXK z!`tuO3Vwjo@C*DpBjbB#WIX@+dclk@ByzUp*du6LV$S(t zE^SmM+-iCKzYSj_{2k!Za16ad1g>NRpu98D*^VoiYjfeXwu<*2y!plLriAoeu<^@r slzusm^6Vdm*jLe%`@{n|B_wL_`p=!2kdN literal 0 HcmV?d00001 diff --git a/internal_filesystem/lib/mpos/ui/gesture_navigation.py b/internal_filesystem/lib/mpos/ui/gesture_navigation.py index c43a25a..22236e4 100644 --- a/internal_filesystem/lib/mpos/ui/gesture_navigation.py +++ b/internal_filesystem/lib/mpos/ui/gesture_navigation.py @@ -2,7 +2,8 @@ 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 .topmenu import open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT from .display import get_display_width, get_display_height downbutton = None @@ -31,10 +32,6 @@ def _passthrough_click(x, y, indev): print(f"Object to click is gone: {e}") def _back_swipe_cb(event): - if drawer_open: - print("ignoring back gesture because drawer is open") - return - global backbutton, back_start_y, back_start_x, backbutton_visible event_code = event.get_code() indev = lv.indev_active() @@ -61,13 +58,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 +99,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 +107,10 @@ def _top_swipe_cb(event): def handle_back_swipe(): global backbutton rect = lv.obj(lv.layer_top()) - rect.set_size(NOTIFICATION_BAR_HEIGHT, lv.layer_top().get_height()-NOTIFICATION_BAR_HEIGHT) # narrow because it overlaps buttons + rect.set_size(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 +138,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() From 56b7cc17e9ea5b7737d393d4122cf7ed30ce624d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 5 Dec 2025 20:48:00 +0100 Subject: [PATCH 104/192] Settings app: add IMU calibration with check --- .../assets/calibrate_imu.py | 362 ++++++++++++++++++ .../assets/check_imu_calibration.py | 238 ++++++++++++ .../assets/settings.py | 21 + .../lib/mpos/sensor_manager.py | 265 ++++++++++++- tests/test_graphical_imu_calibration.py | 220 +++++++++++ 5 files changed, 1099 insertions(+), 7 deletions(-) create mode 100644 internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py create mode 100644 internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py create mode 100644 tests/test_graphical_imu_calibration.py diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py new file mode 100644 index 0000000..a563d34 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py @@ -0,0 +1,362 @@ +"""Calibrate IMU Activity. + +Guides user through IMU calibration process: +1. Check current calibration quality +2. Ask if user wants to recalibrate +3. Check stationarity +4. Perform calibration +5. Verify results +6. Save to new location +""" + +import lvgl as lv +import time +import _thread +import sys +from mpos.app.activity import Activity +import mpos.ui +import mpos.sensor_manager as SensorManager +import mpos.apps + + +class CalibrationState: + """Enum for calibration states.""" + IDLE = 0 + CHECKING_QUALITY = 1 + AWAITING_CONFIRMATION = 2 + CHECKING_STATIONARITY = 3 + CALIBRATING = 4 + VERIFYING = 5 + COMPLETE = 6 + ERROR = 7 + + +class CalibrateIMUActivity(Activity): + """Guide user through IMU calibration process.""" + + # State + current_state = CalibrationState.IDLE + calibration_thread = None + + # Widgets + title_label = None + status_label = None + progress_bar = None + detail_label = None + action_button = None + action_button_label = None + cancel_button = None + + def __init__(self): + super().__init__() + self.is_desktop = sys.platform != "esp32" + + def onCreate(self): + screen = lv.obj() + screen.set_style_pad_all(mpos.ui.pct_of_display_width(3), 0) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + screen.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER) + + # Title + self.title_label = lv.label(screen) + self.title_label.set_text("IMU Calibration") + self.title_label.set_style_text_font(lv.font_montserrat_20, 0) + + # Status label + self.status_label = lv.label(screen) + self.status_label.set_text("Initializing...") + self.status_label.set_style_text_font(lv.font_montserrat_16, 0) + self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.status_label.set_width(lv.pct(90)) + + # Progress bar (hidden initially) + self.progress_bar = lv.bar(screen) + self.progress_bar.set_size(lv.pct(90), 20) + self.progress_bar.set_value(0, False) + self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) + + # Detail label (for additional info) + self.detail_label = lv.label(screen) + self.detail_label.set_text("") + self.detail_label.set_style_text_font(lv.font_montserrat_12, 0) + self.detail_label.set_style_text_color(lv.color_hex(0x888888), 0) + self.detail_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.detail_label.set_width(lv.pct(90)) + + # Button container + btn_cont = lv.obj(screen) + btn_cont.set_width(lv.pct(100)) + btn_cont.set_height(lv.SIZE_CONTENT) + btn_cont.set_style_border_width(0, 0) + btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + + # Action button + self.action_button = lv.button(btn_cont) + self.action_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + self.action_button_label = lv.label(self.action_button) + self.action_button_label.set_text("Start") + self.action_button_label.center() + self.action_button.add_event_cb(self.action_button_clicked, lv.EVENT.CLICKED, None) + + # Cancel button + self.cancel_button = lv.button(btn_cont) + self.cancel_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + cancel_label = lv.label(self.cancel_button) + cancel_label.set_text("Cancel") + cancel_label.center() + self.cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + + # Check if IMU is available + if not self.is_desktop and not SensorManager.is_available(): + self.set_state(CalibrationState.ERROR) + self.status_label.set_text("IMU not available on this device") + self.action_button.add_state(lv.STATE.DISABLED) + return + + # Start by checking current quality + self.set_state(CalibrationState.IDLE) + self.action_button_label.set_text("Check Quality") + + def onPause(self, screen): + # Stop any running calibration + if self.current_state == CalibrationState.CALIBRATING: + # Calibration will detect activity is no longer in foreground + pass + super().onPause(screen) + + def set_state(self, new_state): + """Update state and UI accordingly.""" + self.current_state = new_state + self.update_ui_for_state() + + def update_ui_for_state(self): + """Update UI based on current state.""" + if self.current_state == CalibrationState.IDLE: + self.status_label.set_text("Ready to check calibration quality") + self.action_button_label.set_text("Check Quality") + self.action_button.remove_state(lv.STATE.DISABLED) + self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) + + elif self.current_state == CalibrationState.CHECKING_QUALITY: + self.status_label.set_text("Checking current calibration...") + self.action_button.add_state(lv.STATE.DISABLED) + self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) + self.progress_bar.set_value(20, True) + + elif self.current_state == CalibrationState.AWAITING_CONFIRMATION: + # Status will be set by quality check result + self.action_button_label.set_text("Calibrate Now") + self.action_button.remove_state(lv.STATE.DISABLED) + self.progress_bar.set_value(30, True) + + elif self.current_state == CalibrationState.CHECKING_STATIONARITY: + self.status_label.set_text("Checking if device is stationary...") + self.detail_label.set_text("Keep device still on flat surface") + self.action_button.add_state(lv.STATE.DISABLED) + self.progress_bar.set_value(40, True) + + elif self.current_state == CalibrationState.CALIBRATING: + self.status_label.set_text("Calibrating IMU...") + self.detail_label.set_text("Do not move device!\nCollecting samples...") + self.action_button.add_state(lv.STATE.DISABLED) + self.progress_bar.set_value(60, True) + + elif self.current_state == CalibrationState.VERIFYING: + self.status_label.set_text("Verifying calibration...") + self.action_button.add_state(lv.STATE.DISABLED) + self.progress_bar.set_value(90, True) + + elif self.current_state == CalibrationState.COMPLETE: + self.status_label.set_text("Calibration complete!") + self.action_button_label.set_text("Done") + self.action_button.remove_state(lv.STATE.DISABLED) + self.progress_bar.set_value(100, True) + + elif self.current_state == CalibrationState.ERROR: + self.action_button_label.set_text("Retry") + self.action_button.remove_state(lv.STATE.DISABLED) + self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) + + def action_button_clicked(self, event): + """Handle action button clicks based on current state.""" + if self.current_state == CalibrationState.IDLE: + self.start_quality_check() + elif self.current_state == CalibrationState.AWAITING_CONFIRMATION: + self.start_calibration_process() + elif self.current_state == CalibrationState.COMPLETE: + self.finish() + elif self.current_state == CalibrationState.ERROR: + self.set_state(CalibrationState.IDLE) + + def start_quality_check(self): + """Check current calibration quality.""" + self.set_state(CalibrationState.CHECKING_QUALITY) + + # Run in background thread + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(self.quality_check_thread, ()) + + def quality_check_thread(self): + """Background thread for quality check.""" + try: + if self.is_desktop: + quality = self.get_mock_quality() + else: + quality = SensorManager.check_calibration_quality(samples=50) + + if quality is None: + self.update_ui_threadsafe_if_foreground(self.handle_quality_error, "Failed to read IMU") + return + + # Update UI with results + self.update_ui_threadsafe_if_foreground(self.show_quality_results, quality) + + except Exception as e: + print(f"[CalibrateIMU] Quality check error: {e}") + self.update_ui_threadsafe_if_foreground(self.handle_quality_error, str(e)) + + def show_quality_results(self, quality): + """Show quality check results and ask for confirmation.""" + rating = quality['quality_rating'] + score = quality['quality_score'] + issues = quality['issues'] + + # Build status message + if rating == "Good": + msg = f"Current calibration: {rating} ({score*100:.0f}%)\n\nCalibration looks good!" + else: + msg = f"Current calibration: {rating} ({score*100:.0f}%)\n\nRecommend recalibrating." + + if issues: + msg += "\n\nIssues found:\n" + "\n".join(f"- {issue}" for issue in issues[:3]) # Show first 3 + + self.status_label.set_text(msg) + self.set_state(CalibrationState.AWAITING_CONFIRMATION) + + def handle_quality_error(self, error_msg): + """Handle error during quality check.""" + self.set_state(CalibrationState.ERROR) + self.status_label.set_text(f"Error: {error_msg}") + self.detail_label.set_text("Check IMU connection and try again") + + def start_calibration_process(self): + """Start the calibration process.""" + self.set_state(CalibrationState.CHECKING_STATIONARITY) + + # Run in background thread + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(self.calibration_thread_func, ()) + + def calibration_thread_func(self): + """Background thread for calibration process.""" + try: + # Step 1: Check stationarity + if self.is_desktop: + stationarity = {'is_stationary': True, 'message': 'Mock: Stationary'} + else: + stationarity = SensorManager.check_stationarity(samples=30) + + if stationarity is None or not stationarity['is_stationary']: + msg = stationarity['message'] if stationarity else "Stationarity check failed" + self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, + f"Device not stationary!\n\n{msg}\n\nPlace on flat surface and try again.") + return + + # Step 2: Perform calibration + self.update_ui_threadsafe_if_foreground(lambda: self.set_state(CalibrationState.CALIBRATING)) + time.sleep(0.5) # Brief pause for user to see status change + + if self.is_desktop: + # Mock calibration + time.sleep(2) + accel_offsets = (0.1, -0.05, 0.15) + gyro_offsets = (0.2, -0.1, 0.05) + else: + # Real calibration + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + if accel: + accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) + else: + accel_offsets = None + + if gyro: + gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) + else: + gyro_offsets = None + + # Step 3: Verify results + self.update_ui_threadsafe_if_foreground(lambda: self.set_state(CalibrationState.VERIFYING)) + time.sleep(0.5) + + if self.is_desktop: + verify_quality = self.get_mock_quality(good=True) + else: + verify_quality = SensorManager.check_calibration_quality(samples=50) + + if verify_quality is None: + self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, + "Calibration completed but verification failed") + return + + # Step 4: Show results + rating = verify_quality['quality_rating'] + score = verify_quality['quality_score'] + + result_msg = f"Calibration successful!\n\nNew quality: {rating} ({score*100:.0f}%)" + if accel_offsets: + result_msg += f"\n\nAccel offsets:\nX:{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}" + + self.update_ui_threadsafe_if_foreground(self.show_calibration_complete, result_msg) + + except Exception as e: + print(f"[CalibrateIMU] Calibration error: {e}") + self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, str(e)) + + def show_calibration_complete(self, result_msg): + """Show calibration completion message.""" + self.status_label.set_text(result_msg) + self.detail_label.set_text("Calibration saved to Settings") + self.set_state(CalibrationState.COMPLETE) + + def handle_calibration_error(self, error_msg): + """Handle error during calibration.""" + self.set_state(CalibrationState.ERROR) + self.status_label.set_text(f"Calibration failed:\n\n{error_msg}") + self.detail_label.set_text("") + + def get_mock_quality(self, good=False): + """Generate mock quality data for desktop testing.""" + import random + + if good: + # Simulate excellent calibration after calibration + return { + 'accel_mean': (random.uniform(-0.05, 0.05), random.uniform(-0.05, 0.05), 9.8 + random.uniform(-0.1, 0.1)), + 'accel_variance': (random.uniform(0.001, 0.02), random.uniform(0.001, 0.02), random.uniform(0.001, 0.02)), + 'gyro_mean': (random.uniform(-0.1, 0.1), random.uniform(-0.1, 0.1), random.uniform(-0.1, 0.1)), + 'gyro_variance': (random.uniform(0.01, 0.2), random.uniform(0.01, 0.2), random.uniform(0.01, 0.2)), + 'quality_score': random.uniform(0.90, 0.99), + 'quality_rating': "Good", + 'issues': [] + } + else: + # Simulate mediocre calibration before calibration + return { + 'accel_mean': (random.uniform(-1.0, 1.0), random.uniform(-1.0, 1.0), 9.8 + random.uniform(-2.0, 2.0)), + 'accel_variance': (random.uniform(0.2, 0.5), random.uniform(0.2, 0.5), random.uniform(0.2, 0.5)), + 'gyro_mean': (random.uniform(-3.0, 3.0), random.uniform(-3.0, 3.0), random.uniform(-3.0, 3.0)), + 'gyro_variance': (random.uniform(2.0, 5.0), random.uniform(2.0, 5.0), random.uniform(2.0, 5.0)), + 'quality_score': random.uniform(0.4, 0.6), + 'quality_rating': "Fair", + 'issues': ["High accelerometer variance", "Gyro not near zero"] + } diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py new file mode 100644 index 0000000..18c0bf4 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py @@ -0,0 +1,238 @@ +"""Check IMU Calibration Activity. + +Shows current IMU calibration quality with real-time sensor values, +variance, expected value comparison, and overall quality score. +""" + +import lvgl as lv +import time +import sys +from mpos.app.activity import Activity +import mpos.ui +import mpos.sensor_manager as SensorManager + + +class CheckIMUCalibrationActivity(Activity): + """Display IMU calibration quality with real-time monitoring.""" + + # Update interval for real-time display (milliseconds) + UPDATE_INTERVAL = 100 + + # State + updating = False + update_timer = None + + # Widgets + status_label = None + quality_label = None + accel_labels = [] # [x_label, y_label, z_label] + gyro_labels = [] + issues_label = None + quality_score_label = None + + def __init__(self): + super().__init__() + self.is_desktop = sys.platform != "esp32" + + def onCreate(self): + screen = lv.obj() + screen.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + # Title + title = lv.label(screen) + title.set_text("IMU Calibration Check") + title.set_style_text_font(lv.font_montserrat_20, 0) + + # Status label + self.status_label = lv.label(screen) + self.status_label.set_text("Checking...") + self.status_label.set_style_text_font(lv.font_montserrat_14, 0) + + # Separator + sep1 = lv.obj(screen) + sep1.set_size(lv.pct(100), 2) + sep1.set_style_bg_color(lv.color_hex(0x666666), 0) + + # Quality score (large, prominent) + self.quality_score_label = lv.label(screen) + self.quality_score_label.set_text("Quality: --") + self.quality_score_label.set_style_text_font(lv.font_montserrat_20, 0) + + # Accelerometer section + accel_title = lv.label(screen) + accel_title.set_text("Accelerometer (m/s²)") + accel_title.set_style_text_font(lv.font_montserrat_14, 0) + + for axis in ['X', 'Y', 'Z']: + label = lv.label(screen) + label.set_text(f"{axis}: --") + label.set_style_text_font(lv.font_montserrat_12, 0) + self.accel_labels.append(label) + + # Gyroscope section + gyro_title = lv.label(screen) + gyro_title.set_text("Gyroscope (deg/s)") + gyro_title.set_style_text_font(lv.font_montserrat_14, 0) + + for axis in ['X', 'Y', 'Z']: + label = lv.label(screen) + label.set_text(f"{axis}: --") + label.set_style_text_font(lv.font_montserrat_12, 0) + self.gyro_labels.append(label) + + # Separator + sep2 = lv.obj(screen) + sep2.set_size(lv.pct(100), 2) + sep2.set_style_bg_color(lv.color_hex(0x666666), 0) + + # Issues label + self.issues_label = lv.label(screen) + self.issues_label.set_text("Issues: None") + self.issues_label.set_style_text_font(lv.font_montserrat_12, 0) + self.issues_label.set_style_text_color(lv.color_hex(0xFF6666), 0) + self.issues_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.issues_label.set_width(lv.pct(95)) + + # Button container + btn_cont = lv.obj(screen) + btn_cont.set_width(lv.pct(100)) + btn_cont.set_height(lv.SIZE_CONTENT) + btn_cont.set_style_border_width(0, 0) + btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) + + # Back button + back_btn = lv.button(btn_cont) + back_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) + back_label = lv.label(back_btn) + back_label.set_text("Back") + back_label.center() + back_btn.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None) + + # Calibrate button + calibrate_btn = lv.button(btn_cont) + calibrate_btn.set_size(lv.pct(45), lv.SIZE_CONTENT) + calibrate_label = lv.label(calibrate_btn) + calibrate_label.set_text("Calibrate") + calibrate_label.center() + calibrate_btn.add_event_cb(self.start_calibration, lv.EVENT.CLICKED, None) + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + + # Check if IMU is available + if not self.is_desktop and not SensorManager.is_available(): + self.status_label.set_text("IMU not available on this device") + self.quality_score_label.set_text("N/A") + return + + # Start real-time updates + self.updating = True + self.update_timer = lv.timer_create(self.update_display, self.UPDATE_INTERVAL, None) + + def onPause(self, screen): + # Stop updates + self.updating = False + if self.update_timer: + self.update_timer.delete() + self.update_timer = None + super().onPause(screen) + + def update_display(self, timer=None): + """Update display with current sensor values and quality.""" + if not self.updating: + return + + try: + # Get quality check (desktop or hardware) + if self.is_desktop: + quality = self.get_mock_quality() + else: + quality = SensorManager.check_calibration_quality(samples=30) + + if quality is None: + self.status_label.set_text("Error reading IMU") + return + + # Update quality score + score = quality['quality_score'] + rating = quality['quality_rating'] + self.quality_score_label.set_text(f"Quality: {rating} ({score*100:.0f}%)") + + # Color based on rating + if rating == "Good": + color = 0x66FF66 # Green + elif rating == "Fair": + color = 0xFFFF66 # Yellow + else: + color = 0xFF6666 # Red + self.quality_score_label.set_style_text_color(lv.color_hex(color), 0) + + # Update accelerometer values + accel_mean = quality['accel_mean'] + accel_var = quality['accel_variance'] + for i, (mean, var) in enumerate(zip(accel_mean, accel_var)): + axis = ['X', 'Y', 'Z'][i] + self.accel_labels[i].set_text(f"{axis}: {mean:6.2f} (var: {var:.3f})") + + # Update gyroscope values + gyro_mean = quality['gyro_mean'] + gyro_var = quality['gyro_variance'] + for i, (mean, var) in enumerate(zip(gyro_mean, gyro_var)): + axis = ['X', 'Y', 'Z'][i] + self.gyro_labels[i].set_text(f"{axis}: {mean:6.2f} (var: {var:.3f})") + + # Update issues + issues = quality['issues'] + if issues: + issues_text = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues) + else: + issues_text = "Issues: None - calibration looks good!" + self.issues_label.set_text(issues_text) + + self.status_label.set_text("Real-time monitoring (place on flat surface)") + except: + # Widgets were deleted (activity closed), stop updating + self.updating = False + + def get_mock_quality(self): + """Generate mock quality data for desktop testing.""" + import random + + # Simulate good calibration with small random noise + return { + 'accel_mean': ( + random.uniform(-0.2, 0.2), + random.uniform(-0.2, 0.2), + 9.8 + random.uniform(-0.3, 0.3) + ), + 'accel_variance': ( + random.uniform(0.01, 0.1), + random.uniform(0.01, 0.1), + random.uniform(0.01, 0.1) + ), + 'gyro_mean': ( + random.uniform(-0.5, 0.5), + random.uniform(-0.5, 0.5), + random.uniform(-0.5, 0.5) + ), + 'gyro_variance': ( + random.uniform(0.1, 1.0), + random.uniform(0.1, 1.0), + random.uniform(0.1, 1.0) + ), + 'quality_score': random.uniform(0.75, 0.95), + 'quality_rating': "Good", + 'issues': [] + } + + def start_calibration(self, event): + """Navigate to calibration activity.""" + from mpos.content.intent import Intent + from calibrate_imu import CalibrateIMUActivity + + intent = Intent(activity_class=CalibrateIMUActivity) + self.startActivity(intent) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index 5633191..8dac942 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -1,3 +1,4 @@ +import lvgl as lv from mpos.apps import Activity, Intent from mpos.activity_navigator import ActivityNavigator @@ -7,6 +8,10 @@ import mpos.ui import mpos.time +# Import IMU calibration activities +from check_imu_calibration import CheckIMUCalibrationActivity +from calibrate_imu import CalibrateIMUActivity + # Used to list and edit all settings: class SettingsActivity(Activity): def __init__(self): @@ -39,6 +44,8 @@ def __init__(self): ] self.settings = [ # Novice settings, alphabetically: + {"title": "Calibrate IMU", "key": "calibrate_imu", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CalibrateIMUActivity"}, + {"title": "Check IMU Calibration", "key": "check_imu_calibration", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CheckIMUCalibrationActivity"}, {"title": "Light/Dark Theme", "key": "theme_light_dark", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")]}, {"title": "Theme Color", "key": "theme_primary_color", "value_label": None, "cont": None, "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors}, {"title": "Timezone", "key": "timezone", "value_label": None, "cont": None, "ui": "dropdown", "ui_options": self.get_timezone_tuples(), "changed_callback": lambda : mpos.time.refresh_timezone_preference()}, @@ -104,6 +111,20 @@ def onResume(self, screen): focusgroup.add_obj(setting_cont) def startSettingActivity(self, setting): + ui_type = setting.get("ui") + + # Handle activity-based settings (NEW) + if ui_type == "activity": + activity_class_name = setting.get("activity_class") + if activity_class_name == "CheckIMUCalibrationActivity": + intent = Intent(activity_class=CheckIMUCalibrationActivity) + self.startActivity(intent) + elif activity_class_name == "CalibrateIMUActivity": + intent = Intent(activity_class=CalibrateIMUActivity) + self.startActivity(intent) + return + + # Handle traditional settings (existing code) intent = Intent(activity_class=SettingActivity) intent.putExtra("setting", setting) self.startActivity(intent) diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 0f0d956..ee2be06 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -271,6 +271,238 @@ def calibrate_sensor(sensor, samples=100): _lock.release() +# Helper functions for calibration quality checking (module-level to avoid nested def issues) +def _calc_mean_variance(samples_list): + """Calculate mean and variance for a list of samples.""" + if not samples_list: + return 0.0, 0.0 + n = len(samples_list) + mean = sum(samples_list) / n + variance = sum((x - mean) ** 2 for x in samples_list) / n + return mean, variance + + +def _calc_variance(samples_list): + """Calculate variance for a list of samples.""" + if not samples_list: + return 0.0 + n = len(samples_list) + mean = sum(samples_list) / n + return sum((x - mean) ** 2 for x in samples_list) / n + + +def check_calibration_quality(samples=50): + """Check quality of current calibration. + + Args: + samples: Number of samples to collect (default 50) + + Returns: + dict with: + - accel_mean: (x, y, z) mean values in m/s² + - accel_variance: (x, y, z) variance values + - gyro_mean: (x, y, z) mean values in deg/s + - gyro_variance: (x, y, z) variance values + - quality_score: float 0.0-1.0 (1.0 = perfect) + - quality_rating: string ("Good", "Fair", "Poor") + - issues: list of strings describing problems + None if IMU not available + """ + if not is_available(): + return None + + if _lock: + _lock.acquire() + + try: + accel = get_default_sensor(TYPE_ACCELEROMETER) + gyro = get_default_sensor(TYPE_GYROSCOPE) + + # Collect samples + accel_samples = [[], [], []] # x, y, z lists + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = read_sensor(accel) + if data: + ax, ay, az = data + accel_samples[0].append(ax) + accel_samples[1].append(ay) + accel_samples[2].append(az) + if gyro: + data = read_sensor(gyro) + if data: + gx, gy, gz = data + gyro_samples[0].append(gx) + gyro_samples[1].append(gy) + gyro_samples[2].append(gz) + time.sleep_ms(10) + + # Calculate statistics using module-level helper + accel_stats = [_calc_mean_variance(s) for s in accel_samples] + gyro_stats = [_calc_mean_variance(s) for s in gyro_samples] + + accel_mean = tuple(s[0] for s in accel_stats) + accel_variance = tuple(s[1] for s in accel_stats) + gyro_mean = tuple(s[0] for s in gyro_stats) + gyro_variance = tuple(s[1] for s in gyro_stats) + + # Calculate quality score (0.0 - 1.0) + issues = [] + scores = [] + + # Check accelerometer + if accel: + # Variance check (lower is better) + accel_max_variance = max(accel_variance) + variance_score = max(0.0, 1.0 - (accel_max_variance / 1.0)) # 1.0 m/s² variance threshold + scores.append(variance_score) + if accel_max_variance > 0.5: + issues.append(f"High accelerometer variance: {accel_max_variance:.3f} m/s²") + + # Expected values check (X≈0, Y≈0, Z≈9.8) + ax, ay, az = accel_mean + xy_error = (abs(ax) + abs(ay)) / 2.0 + z_error = abs(az - _GRAVITY) + expected_score = max(0.0, 1.0 - ((xy_error + z_error) / 5.0)) # 5.0 m/s² error threshold + scores.append(expected_score) + if xy_error > 1.0: + issues.append(f"Accel X/Y not near zero: X={ax:.2f}, Y={ay:.2f} m/s²") + if z_error > 1.0: + issues.append(f"Accel Z not near 9.8: Z={az:.2f} m/s²") + + # Check gyroscope + if gyro: + # Variance check + gyro_max_variance = max(gyro_variance) + variance_score = max(0.0, 1.0 - (gyro_max_variance / 10.0)) # 10 deg/s variance threshold + scores.append(variance_score) + if gyro_max_variance > 5.0: + issues.append(f"High gyroscope variance: {gyro_max_variance:.3f} deg/s") + + # Expected values check (all ≈0) + gx, gy, gz = gyro_mean + error = (abs(gx) + abs(gy) + abs(gz)) / 3.0 + expected_score = max(0.0, 1.0 - (error / 10.0)) # 10 deg/s error threshold + scores.append(expected_score) + if error > 2.0: + issues.append(f"Gyro not near zero: X={gx:.2f}, Y={gy:.2f}, Z={gz:.2f} deg/s") + + # Overall quality score + quality_score = sum(scores) / len(scores) if scores else 0.0 + + # Rating + if quality_score >= 0.8: + quality_rating = "Good" + elif quality_score >= 0.5: + quality_rating = "Fair" + else: + quality_rating = "Poor" + + return { + 'accel_mean': accel_mean, + 'accel_variance': accel_variance, + 'gyro_mean': gyro_mean, + 'gyro_variance': gyro_variance, + 'quality_score': quality_score, + 'quality_rating': quality_rating, + 'issues': issues + } + + except Exception as e: + print(f"[SensorManager] Error checking calibration quality: {e}") + return None + finally: + if _lock: + _lock.release() + + +def check_stationarity(samples=30, variance_threshold_accel=0.5, variance_threshold_gyro=5.0): + """Check if device is stationary (required for calibration). + + Args: + samples: Number of samples to collect (default 30) + variance_threshold_accel: Max acceptable accel variance in m/s² (default 0.5) + variance_threshold_gyro: Max acceptable gyro variance in deg/s (default 5.0) + + Returns: + dict with: + - is_stationary: bool + - accel_variance: max variance across axes + - gyro_variance: max variance across axes + - message: string describing result + None if IMU not available + """ + if not is_available(): + return None + + if _lock: + _lock.acquire() + + try: + accel = get_default_sensor(TYPE_ACCELEROMETER) + gyro = get_default_sensor(TYPE_GYROSCOPE) + + # Collect samples + accel_samples = [[], [], []] + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = read_sensor(accel) + if data: + ax, ay, az = data + accel_samples[0].append(ax) + accel_samples[1].append(ay) + accel_samples[2].append(az) + if gyro: + data = read_sensor(gyro) + if data: + gx, gy, gz = data + gyro_samples[0].append(gx) + gyro_samples[1].append(gy) + gyro_samples[2].append(gz) + time.sleep_ms(10) + + # Calculate variance using module-level helper + accel_var = [_calc_variance(s) for s in accel_samples] + gyro_var = [_calc_variance(s) for s in gyro_samples] + + max_accel_var = max(accel_var) if accel_var else 0.0 + max_gyro_var = max(gyro_var) if gyro_var else 0.0 + + # Check thresholds + accel_stationary = max_accel_var < variance_threshold_accel + gyro_stationary = max_gyro_var < variance_threshold_gyro + is_stationary = accel_stationary and gyro_stationary + + # Generate message + if is_stationary: + message = "Device is stationary - ready to calibrate" + else: + problems = [] + if not accel_stationary: + problems.append(f"movement detected (accel variance: {max_accel_var:.3f})") + if not gyro_stationary: + problems.append(f"rotation detected (gyro variance: {max_gyro_var:.3f})") + message = f"Device NOT stationary: {', '.join(problems)}" + + return { + 'is_stationary': is_stationary, + 'accel_variance': max_accel_var, + 'gyro_variance': max_gyro_var, + 'message': message + } + + except Exception as e: + print(f"[SensorManager] Error checking stationarity: {e}") + return None + finally: + if _lock: + _lock.release() + + # ============================================================================ # Internal driver abstraction layer # ============================================================================ @@ -571,16 +803,34 @@ def _register_mcu_temperature_sensor(): # ============================================================================ def _load_calibration(): - """Load calibration from SharedPreferences.""" + """Load calibration from SharedPreferences (with migration support).""" if not _imu_driver: return try: from mpos.config import SharedPreferences - prefs = SharedPreferences("com.micropythonos.sensors") - accel_offsets = prefs.get_list("accel_offsets") - gyro_offsets = prefs.get_list("gyro_offsets") + # Try NEW location first + prefs_new = SharedPreferences("com.micropythonos.settings", filename="sensors.json") + accel_offsets = prefs_new.get_list("accel_offsets") + gyro_offsets = prefs_new.get_list("gyro_offsets") + + # If not found, try OLD location and migrate + if not accel_offsets and not gyro_offsets: + prefs_old = SharedPreferences("com.micropythonos.sensors") + accel_offsets = prefs_old.get_list("accel_offsets") + gyro_offsets = prefs_old.get_list("gyro_offsets") + + if accel_offsets or gyro_offsets: + print("[SensorManager] Migrating calibration from old to new location...") + # 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() + print("[SensorManager] Migration complete") if accel_offsets or gyro_offsets: _imu_driver.set_calibration(accel_offsets, gyro_offsets) @@ -590,13 +840,14 @@ def _load_calibration(): def _save_calibration(): - """Save calibration to SharedPreferences.""" + """Save calibration to SharedPreferences (new location).""" if not _imu_driver: return try: from mpos.config import SharedPreferences - prefs = SharedPreferences("com.micropythonos.sensors") + # NEW LOCATION: com.micropythonos.settings/sensors.json + prefs = SharedPreferences("com.micropythonos.settings", filename="sensors.json") editor = prefs.edit() cal = _imu_driver.get_calibration() @@ -604,6 +855,6 @@ def _save_calibration(): editor.put_list("gyro_offsets", list(cal['gyro_offsets'])) editor.commit() - print(f"[SensorManager] Saved calibration: accel={cal['accel_offsets']}, gyro={cal['gyro_offsets']}") + print(f"[SensorManager] Saved calibration to settings: accel={cal['accel_offsets']}, gyro={cal['gyro_offsets']}") except Exception as e: print(f"[SensorManager] Failed to save calibration: {e}") diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py new file mode 100644 index 0000000..56087a1 --- /dev/null +++ b/tests/test_graphical_imu_calibration.py @@ -0,0 +1,220 @@ +""" +Graphical test for IMU calibration activities. + +Tests both CheckIMUCalibrationActivity and CalibrateIMUActivity +with mock data on desktop. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_imu_calibration.py + Device: ./tests/unittest.sh tests/test_graphical_imu_calibration.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.apps +import mpos.ui +import os +import sys +import time +from mpos.ui.testing import ( + wait_for_render, + capture_screenshot, + find_label_with_text, + verify_text_present, + print_screen_labels, + simulate_click, + get_widget_coords, + find_button_with_text +) + + +class TestIMUCalibration(unittest.TestCase): + """Test suite for IMU calibration activities.""" + + def setUp(self): + """Set up test fixtures.""" + # Get screenshot directory + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "/home/user/MicroPythonOS/tests/screenshots" + + # Ensure directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass + + def tearDown(self): + """Clean up after test.""" + # Navigate back to launcher + try: + for _ in range(3): # May need multiple backs + mpos.ui.back_screen() + wait_for_render(5) + except: + pass + + def test_check_calibration_activity_loads(self): + """Test that CheckIMUCalibrationActivity loads and displays.""" + print("\n=== Testing CheckIMUCalibrationActivity ===") + + # Navigate: Launcher -> Settings -> Check IMU Calibration + result = mpos.apps.start_app("com.micropythonos.settings") + self.assertTrue(result, "Failed to start Settings app") + wait_for_render(15) + + # Initialize touch device with dummy click + simulate_click(10, 10) + wait_for_render(10) + + # 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) + + # Verify CheckIMUCalibrationActivity loaded + screen = lv.screen_active() + self.assertTrue(verify_text_present(screen, "IMU Calibration Check"), + "CheckIMUCalibrationActivity title not found") + + # Wait for real-time updates to populate + wait_for_render(20) + + # Verify key elements are present + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Quality:"), + "Quality label not found") + self.assertTrue(verify_text_present(screen, "Accelerometer"), + "Accelerometer label not found") + self.assertTrue(verify_text_present(screen, "Gyroscope"), + "Gyroscope label not found") + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/check_imu_calibration.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path) + + # Verify screenshot saved + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + + print("=== CheckIMUCalibrationActivity test complete ===") + + def test_calibrate_activity_flow(self): + """Test CalibrateIMUActivity full calibration flow.""" + print("\n=== Testing CalibrateIMUActivity Flow ===") + + # Navigate: Launcher -> Settings -> Calibrate IMU + result = mpos.apps.start_app("com.micropythonos.settings") + self.assertTrue(result, "Failed to start Settings app") + wait_for_render(15) + + # Initialize touch device with dummy click + simulate_click(10, 10) + wait_for_render(10) + + # 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) + + # Verify activity loaded + screen = lv.screen_active() + self.assertTrue(verify_text_present(screen, "IMU Calibration"), + "CalibrateIMUActivity title not found") + + # Capture initial state + screenshot_path = f"{self.screenshot_dir}/calibrate_imu_01_initial.raw" + capture_screenshot(screenshot_path) + + # Step 1: Click "Check Quality" button + check_btn = find_button_with_text(screen, "Check Quality") + self.assertIsNotNone(check_btn, "Could not find 'Check Quality' button") + coords = get_widget_coords(check_btn) + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(10) + + # Wait for quality check to complete (mock is fast) + time.sleep(2.5) # Allow thread to complete + wait_for_render(15) + + # Verify quality check completed + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Current calibration:"), + "Quality check results not shown") + + # Capture after quality check + screenshot_path = f"{self.screenshot_dir}/calibrate_imu_02_quality.raw" + capture_screenshot(screenshot_path) + + # Step 2: Click "Calibrate Now" button + calibrate_btn = find_button_with_text(screen, "Calibrate Now") + self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate Now' button") + coords = get_widget_coords(calibrate_btn) + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(10) + + # Wait for calibration to complete (mock takes ~3 seconds) + time.sleep(4.0) + wait_for_render(15) + + # Verify calibration completed + screen = lv.screen_active() + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Calibration successful!") or + verify_text_present(screen, "Calibration complete!"), + "Calibration completion message not found") + + # Capture completion state + screenshot_path = f"{self.screenshot_dir}/calibrate_imu_03_complete.raw" + capture_screenshot(screenshot_path) + + print("=== CalibrateIMUActivity flow test complete ===") + + def test_navigation_from_check_to_calibrate(self): + """Test navigation from Check to Calibrate activity via button.""" + print("\n=== Testing Check -> Calibrate Navigation ===") + + # Navigate to Check activity + result = mpos.apps.start_app("com.micropythonos.settings") + self.assertTrue(result) + wait_for_render(15) + + # Initialize touch device with dummy click + simulate_click(10, 10) + wait_for_render(10) + + 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 + + # Click "Calibrate" button + screen = lv.screen_active() + calibrate_btn = find_button_with_text(screen, "Calibrate") + self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate' button") + + coords = get_widget_coords(calibrate_btn) + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(15) + + # Verify CalibrateIMUActivity loaded + screen = lv.screen_active() + self.assertTrue(verify_text_present(screen, "Check Quality"), + "Did not navigate to CalibrateIMUActivity") + + print("=== Navigation test complete ===") From 0f2bbd5fa971fd658fc6726a17988d0006b45e4a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 6 Dec 2025 11:03:15 +0100 Subject: [PATCH 105/192] Fri3d 2024 Board: add support for WSEN ISDS IMU --- CLAUDE.md | 27 +++ .../assets/calibrate_imu.py | 25 ++ .../assets/check_imu_calibration.py | 3 +- .../lib/mpos/hardware/drivers/wsen_isds.py | 49 +++- .../lib/mpos/sensor_manager.py | 225 ++++++++++++------ 5 files changed, 251 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 410f941..f05ac0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,33 @@ The `c_mpos/src/webcam.c` module provides webcam support for desktop builds usin ## Build System +### Development Workflow (IMPORTANT) + +**For most development, you do NOT need to rebuild the firmware!** + +When you run `scripts/install.sh`, it copies files from `internal_filesystem/` to the device storage. These files override the frozen filesystem because the storage paths are first in `sys.path`. This means: + +```bash +# Fast development cycle (recommended): +# 1. Edit Python files in internal_filesystem/ +# 2. Install to device: +./scripts/install.sh waveshare-esp32-s3-touch-lcd-2 + +# That's it! Your changes are live on the device. +``` + +**You only need to rebuild firmware (`./scripts/build_mpos.sh esp32`) when:** +- Testing the frozen `lib/` for production releases +- Modifying C extension modules (`c_mpos/`, `secp256k1-embedded-ecdh/`) +- Changing MicroPython core or LVGL bindings +- Creating a fresh firmware image for distribution + +**Desktop development** always uses the unfrozen files, so you never need to rebuild for Python changes: +```bash +# Edit internal_filesystem/ files +./scripts/run_desktop.sh # Changes are immediately active +``` + ### Building Firmware The main build script is `scripts/build_mpos.sh`: 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 a563d34..190d888 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 @@ -256,57 +256,78 @@ def start_calibration_process(self): def calibration_thread_func(self): """Background thread for calibration process.""" try: + print("[CalibrateIMU] === Calibration thread started ===") + # Step 1: Check stationarity + print("[CalibrateIMU] Step 1: Checking stationarity...") if self.is_desktop: stationarity = {'is_stationary': True, 'message': 'Mock: Stationary'} else: + print("[CalibrateIMU] Calling SensorManager.check_stationarity(samples=30)...") stationarity = SensorManager.check_stationarity(samples=30) + print(f"[CalibrateIMU] Stationarity result: {stationarity}") if stationarity is None or not stationarity['is_stationary']: msg = stationarity['message'] if stationarity else "Stationarity check failed" + print(f"[CalibrateIMU] Device not stationary: {msg}") self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, f"Device not stationary!\n\n{msg}\n\nPlace on flat surface and try again.") return + print("[CalibrateIMU] Device is stationary, proceeding to calibration") + # Step 2: Perform calibration + print("[CalibrateIMU] Step 2: Performing calibration...") self.update_ui_threadsafe_if_foreground(lambda: self.set_state(CalibrationState.CALIBRATING)) time.sleep(0.5) # Brief pause for user to see status change if self.is_desktop: # Mock calibration + print("[CalibrateIMU] Mock calibration (desktop)") time.sleep(2) accel_offsets = (0.1, -0.05, 0.15) gyro_offsets = (0.2, -0.1, 0.05) else: # Real calibration + print("[CalibrateIMU] Real calibration (hardware)") accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + print(f"[CalibrateIMU] Accel sensor: {accel}, Gyro sensor: {gyro}") if accel: + print("[CalibrateIMU] Calibrating accelerometer (100 samples)...") accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) + print(f"[CalibrateIMU] Accel offsets: {accel_offsets}") else: accel_offsets = None if gyro: + print("[CalibrateIMU] Calibrating gyroscope (100 samples)...") gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) + print(f"[CalibrateIMU] Gyro offsets: {gyro_offsets}") else: gyro_offsets = None # Step 3: Verify results + print("[CalibrateIMU] Step 3: Verifying calibration...") self.update_ui_threadsafe_if_foreground(lambda: self.set_state(CalibrationState.VERIFYING)) time.sleep(0.5) if self.is_desktop: verify_quality = self.get_mock_quality(good=True) else: + print("[CalibrateIMU] Checking calibration quality (50 samples)...") verify_quality = SensorManager.check_calibration_quality(samples=50) + print(f"[CalibrateIMU] Verification quality: {verify_quality}") if verify_quality is None: + print("[CalibrateIMU] Verification failed") self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, "Calibration completed but verification failed") return # Step 4: Show results + print("[CalibrateIMU] Step 4: Showing results...") rating = verify_quality['quality_rating'] score = verify_quality['quality_score'] @@ -316,10 +337,14 @@ def calibration_thread_func(self): if gyro_offsets: result_msg += f"\n\nGyro offsets:\nX:{gyro_offsets[0]:.3f} Y:{gyro_offsets[1]:.3f} Z:{gyro_offsets[2]:.3f}" + print(f"[CalibrateIMU] Calibration complete! Result: {result_msg[:80]}") self.update_ui_threadsafe_if_foreground(self.show_calibration_complete, result_msg) + print("[CalibrateIMU] === Calibration thread finished ===") except Exception as e: print(f"[CalibrateIMU] Calibration error: {e}") + import sys + sys.print_exception(e) self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, str(e)) def show_calibration_complete(self, result_msg): diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py index 18c0bf4..d9f0a7b 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py @@ -151,7 +151,8 @@ def update_display(self, timer=None): if self.is_desktop: quality = self.get_mock_quality() else: - quality = SensorManager.check_calibration_quality(samples=30) + # Use only 5 samples for real-time display (faster, less blocking) + quality = SensorManager.check_calibration_quality(samples=5) if quality is None: self.status_label.set_text("Error reading IMU") diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py index eaefeb7..631910a 100644 --- a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -126,8 +126,9 @@ def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", acc_range: Accelerometer range ("2g", "4g", "8g", "16g") acc_data_rate: Accelerometer data rate ("0", "1.6Hz", "12.5Hz", ...) gyro_range: Gyroscope range ("125dps", "250dps", "500dps", "1000dps", "2000dps") - gyro_data_rate: Gyroscope data rate ("0", "12.5Hz", "26Hz", ...) + gyro_data_rate: Gyroscope data rate ("0", "12.5Hz", "26Hz", ...") """ + print(f"[WSEN_ISDS] __init__ called with address={hex(address)}, acc_range={acc_range}, acc_data_rate={acc_data_rate}, gyro_range={gyro_range}, gyro_data_rate={gyro_data_rate}") self.i2c = i2c self.address = address @@ -149,10 +150,31 @@ def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", self.GYRO_NUM_SAMPLES_CALIBRATION = 5 self.GYRO_CALIBRATION_DELAY_MS = 10 + print("[WSEN_ISDS] Configuring accelerometer...") self.set_acc_range(acc_range) self.set_acc_data_rate(acc_data_rate) + print("[WSEN_ISDS] Accelerometer configured") + + print("[WSEN_ISDS] Configuring gyroscope...") self.set_gyro_range(gyro_range) self.set_gyro_data_rate(gyro_data_rate) + print("[WSEN_ISDS] Gyroscope configured") + + # Give sensors time to stabilize and start producing data + # Especially important for gyroscope which may need warmup time + print("[WSEN_ISDS] Waiting 100ms for sensors to stabilize...") + time.sleep_ms(100) + + # Debug: Read all control registers to see full sensor state + print("[WSEN_ISDS] === Sensor State After Initialization ===") + for reg_addr in [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19]: + try: + reg_val = self.i2c.readfrom_mem(self.address, reg_addr, 1)[0] + print(f"[WSEN_ISDS] Reg 0x{reg_addr:02x} (CTRL{reg_addr-0x0f}): 0x{reg_val:02x} = 0b{reg_val:08b}") + except: + pass + + print("[WSEN_ISDS] Initialization complete") def get_chip_id(self): """Get chip ID for detection. Returns WHO_AM_I register value.""" @@ -166,10 +188,12 @@ def _write_option(self, option, value): opt = Wsen_Isds._options[option] try: bits = opt["val_to_bits"][value] - config_value = self.i2c.readfrom_mem(self.address, opt["reg"], 1)[0] + old_value = self.i2c.readfrom_mem(self.address, opt["reg"], 1)[0] + config_value = old_value config_value &= opt["mask"] config_value |= (bits << opt["shift_left"]) self.i2c.writeto_mem(self.address, opt["reg"], bytes([config_value])) + print(f"[WSEN_ISDS] _write_option: {option}={value} → reg {hex(opt['reg'])}: {hex(old_value)} → {hex(config_value)}") except KeyError as err: print(f"Invalid option: {option}, or invalid option value: {value}.", err) @@ -300,15 +324,19 @@ def read_accelerations(self): def _read_raw_accelerations(self): """Read raw accelerometer data.""" + print("[WSEN_ISDS] _read_raw_accelerations: checking data ready...") if not self._acc_data_ready(): + print("[WSEN_ISDS] _read_raw_accelerations: DATA NOT READY!") raise Exception("sensor data not ready") + print("[WSEN_ISDS] _read_raw_accelerations: data ready, reading registers...") raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_A_X_OUT_L, 6) raw_a_x = self._convert_from_raw(raw[0], raw[1]) raw_a_y = self._convert_from_raw(raw[2], raw[3]) raw_a_z = self._convert_from_raw(raw[4], raw[5]) + print(f"[WSEN_ISDS] _read_raw_accelerations: raw values = ({raw_a_x}, {raw_a_y}, {raw_a_z})") return raw_a_x, raw_a_y, raw_a_z def gyro_calibrate(self, samples=None): @@ -351,15 +379,19 @@ def read_angular_velocities(self): def _read_raw_angular_velocities(self): """Read raw gyroscope data.""" + print("[WSEN_ISDS] _read_raw_angular_velocities: checking data ready...") if not self._gyro_data_ready(): + print("[WSEN_ISDS] _read_raw_angular_velocities: DATA NOT READY!") raise Exception("sensor data not ready") + print("[WSEN_ISDS] _read_raw_angular_velocities: data ready, reading registers...") raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_G_X_OUT_L, 6) raw_g_x = self._convert_from_raw(raw[0], raw[1]) raw_g_y = self._convert_from_raw(raw[2], raw[3]) raw_g_z = self._convert_from_raw(raw[4], raw[5]) + print(f"[WSEN_ISDS] _read_raw_angular_velocities: raw values = ({raw_g_x}, {raw_g_y}, {raw_g_z})") return raw_g_x, raw_g_y, raw_g_z def read_angular_velocities_accelerations(self): @@ -426,10 +458,15 @@ def _get_status_reg(self): Returns: Tuple (acc_data_ready, gyro_data_ready, temp_data_ready) """ - raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._ISDS_STATUS_REG, 4) + # STATUS_REG (0x1E) is a single byte with bit flags: + # Bit 0: XLDA (accelerometer data available) + # Bit 1: GDA (gyroscope data available) + # Bit 2: TDA (temperature data available) + status = self.i2c.readfrom_mem(self.address, Wsen_Isds._ISDS_STATUS_REG, 1)[0] - acc_data_ready = True if raw[0] == 1 else False - gyro_data_ready = True if raw[1] == 1 else False - temp_data_ready = True if raw[2] == 1 else False + acc_data_ready = bool(status & 0x01) # Bit 0 + gyro_data_ready = bool(status & 0x02) # Bit 1 + temp_data_ready = bool(status & 0x04) # Bit 2 + print(f"[WSEN_ISDS] Status register: 0x{status:02x} = 0b{status:08b}, acc_ready={acc_data_ready}, gyro_ready={gyro_data_ready}, temp_ready={temp_data_ready}") return acc_data_ready, gyro_data_ready, temp_data_ready diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index ee2be06..0d58548 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -72,27 +72,55 @@ def __repr__(self): def init(i2c_bus, address=0x6B): - """Initialize SensorManager with I2C bus. Auto-detects IMU type and MCU temperature. - - Tries to detect QMI8658 (chip ID 0x05) or WSEN_ISDS (WHO_AM_I 0x6A). - Also detects ESP32 MCU internal temperature sensor. - Loads calibration from SharedPreferences if available. + """Initialize SensorManager. MCU temperature initializes immediately, IMU initializes on first use. Args: i2c_bus: machine.I2C instance (can be None if only MCU temperature needed) address: I2C address (default 0x6B for both QMI8658 and WSEN_ISDS) Returns: - bool: True if any sensor detected and initialized successfully + bool: True if initialized successfully """ - global _initialized, _imu_driver, _sensor_list, _i2c_bus, _i2c_address, _has_mcu_temperature - - if _initialized: - print("[SensorManager] Already initialized") - return True + global _i2c_bus, _i2c_address, _initialized, _has_mcu_temperature _i2c_bus = i2c_bus _i2c_address = address + + # Initialize MCU temperature sensor immediately (fast, no I2C needed) + try: + import esp32 + # Test if mcu_temperature() is available + _ = esp32.mcu_temperature() + _has_mcu_temperature = True + _register_mcu_temperature_sensor() + print("[SensorManager] Detected MCU internal temperature sensor") + except Exception as e: + print(f"[SensorManager] MCU temperature not available: {e}") + + # Mark as initialized (but IMU driver is still None - will be initialized lazily) + _initialized = True + print("[SensorManager] init() called - IMU initialization deferred until first use") + return True + + +def _ensure_imu_initialized(): + """Perform IMU initialization on first use (lazy initialization). + + Tries to detect QMI8658 (chip ID 0x05) or WSEN_ISDS (WHO_AM_I 0x6A). + Loads calibration from SharedPreferences if available. + + Returns: + bool: True if IMU detected and initialized successfully + """ + global _imu_driver, _sensor_list, _i2c_bus, _i2c_address + + # If already initialized, return + if _imu_driver is not None: + return True + + print("[SensorManager] _ensure_imu_initialized: Starting lazy IMU initialization...") + i2c_bus = _i2c_bus + address = _i2c_address imu_detected = False # Try QMI8658 first (Waveshare board) @@ -114,65 +142,71 @@ def init(i2c_bus, address=0x6B): # Try WSEN_ISDS (Fri3d badge) if not imu_detected: + print(f"[SensorManager] Trying to detect WSEN_ISDS at address {hex(address)}...") try: from mpos.hardware.drivers.wsen_isds import Wsen_Isds + print("[SensorManager] Reading WHO_AM_I register (0x0F)...") chip_id = i2c_bus.readfrom_mem(address, 0x0F, 1)[0] # WHO_AM_I register + print(f"[SensorManager] WHO_AM_I = {hex(chip_id)}") if chip_id == 0x6A: # WSEN_ISDS WHO_AM_I value - print("[SensorManager] Detected WSEN_ISDS IMU") + print("[SensorManager] Detected WSEN_ISDS IMU - initializing driver...") _imu_driver = _WsenISDSDriver(i2c_bus, address) + print("[SensorManager] WSEN_ISDS driver initialized, registering sensors...") _register_wsen_isds_sensors() + print("[SensorManager] Loading calibration...") _load_calibration() imu_detected = True + print("[SensorManager] WSEN_ISDS initialization complete!") + else: + print(f"[SensorManager] Chip ID {hex(chip_id)} doesn't match WSEN_ISDS (expected 0x6A)") except Exception as e: print(f"[SensorManager] WSEN_ISDS detection failed: {e}") + import sys + sys.print_exception(e) - # Try MCU internal temperature sensor (ESP32) - try: - import esp32 - # Test if mcu_temperature() is available - _ = esp32.mcu_temperature() - _has_mcu_temperature = True - _register_mcu_temperature_sensor() - print("[SensorManager] Detected MCU internal temperature sensor") - except Exception as e: - print(f"[SensorManager] MCU temperature not available: {e}") - - _initialized = True - - if not imu_detected and not _has_mcu_temperature: - print("[SensorManager] No sensors detected") - return False - - return True + print(f"[SensorManager] _ensure_imu_initialized: IMU initialization complete, success={imu_detected}") + return imu_detected def is_available(): """Check if sensors are available. + Does NOT trigger IMU initialization (to avoid boot-time initialization). + Use get_default_sensor() or read_sensor() to lazily initialize IMU. + Returns: - bool: True if SensorManager is initialized with hardware + bool: True if SensorManager is initialized (may only have MCU temp, not IMU) """ - return _initialized and _imu_driver is not None + return _initialized def get_sensor_list(): """Get list of all available sensors. + Performs lazy IMU initialization on first call. + Returns: list: List of Sensor objects """ + _ensure_imu_initialized() return _sensor_list.copy() if _sensor_list else [] def get_default_sensor(sensor_type): """Get default sensor of given type. + Performs lazy IMU initialization on first call. + Args: sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) Returns: Sensor object or None if not available """ + # Only initialize IMU if requesting IMU sensor types + if sensor_type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + _ensure_imu_initialized() + for sensor in _sensor_list: if sensor.type == sensor_type: return sensor @@ -182,6 +216,8 @@ def get_default_sensor(sensor_type): def read_sensor(sensor): """Read sensor data synchronously. + Performs lazy IMU initialization on first call for IMU sensors. + Args: sensor: Sensor object from get_default_sensor() @@ -193,35 +229,58 @@ def read_sensor(sensor): if sensor is None: return None + # Only initialize IMU if reading IMU sensor + if sensor.type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + _ensure_imu_initialized() + if _lock: _lock.acquire() try: - if sensor.type == TYPE_ACCELEROMETER: - if _imu_driver: - return _imu_driver.read_acceleration() - elif sensor.type == TYPE_GYROSCOPE: - if _imu_driver: - return _imu_driver.read_gyroscope() - elif sensor.type == TYPE_IMU_TEMPERATURE: - if _imu_driver: - return _imu_driver.read_temperature() - elif sensor.type == TYPE_SOC_TEMPERATURE: - if _has_mcu_temperature: - import esp32 - return esp32.mcu_temperature() - elif sensor.type == TYPE_TEMPERATURE: - # Generic temperature - return first available (backward compatibility) - if _imu_driver: - temp = _imu_driver.read_temperature() - if temp is not None: - return temp - if _has_mcu_temperature: - import esp32 - return esp32.mcu_temperature() - return None - except Exception as e: - print(f"[SensorManager] Error reading sensor {sensor.name}: {e}") + # Retry logic for "sensor data not ready" (WSEN_ISDS needs time after init) + max_retries = 3 + retry_delay_ms = 20 # Wait 20ms between retries + + for attempt in range(max_retries): + try: + if sensor.type == TYPE_ACCELEROMETER: + if _imu_driver: + return _imu_driver.read_acceleration() + elif sensor.type == TYPE_GYROSCOPE: + if _imu_driver: + return _imu_driver.read_gyroscope() + elif sensor.type == TYPE_IMU_TEMPERATURE: + if _imu_driver: + return _imu_driver.read_temperature() + elif sensor.type == TYPE_SOC_TEMPERATURE: + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + elif sensor.type == TYPE_TEMPERATURE: + # Generic temperature - return first available (backward compatibility) + if _imu_driver: + temp = _imu_driver.read_temperature() + if temp is not None: + return temp + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + return None + except Exception as e: + error_msg = str(e) + # Retry if sensor data not ready, otherwise fail immediately + if "data not ready" in error_msg and attempt < max_retries - 1: + import time + time.sleep_ms(retry_delay_ms) + continue + else: + # Final attempt failed or different error + if attempt == max_retries - 1: + print(f"[SensorManager] Error reading sensor {sensor.name} after {max_retries} retries: {e}") + else: + print(f"[SensorManager] Error reading sensor {sensor.name}: {e}") + return None + return None finally: if _lock: @@ -231,6 +290,7 @@ def read_sensor(sensor): def calibrate_sensor(sensor, samples=100): """Calibrate sensor and save to SharedPreferences. + Performs lazy IMU initialization on first call. Device must be stationary for accelerometer/gyroscope calibration. Args: @@ -240,18 +300,25 @@ def calibrate_sensor(sensor, samples=100): Returns: tuple: Calibration offsets (x, y, z) or None if failed """ + print(f"[SensorManager] calibrate_sensor called for {sensor.name} with {samples} samples") + _ensure_imu_initialized() if not is_available() or sensor is None: + print("[SensorManager] calibrate_sensor: sensor not available") return None + print("[SensorManager] calibrate_sensor: acquiring lock...") if _lock: _lock.acquire() + print("[SensorManager] calibrate_sensor: lock acquired") try: offsets = None if sensor.type == TYPE_ACCELEROMETER: + print(f"[SensorManager] Calling _imu_driver.calibrate_accelerometer({samples})...") offsets = _imu_driver.calibrate_accelerometer(samples) print(f"[SensorManager] Accelerometer calibrated: {offsets}") elif sensor.type == TYPE_GYROSCOPE: + print(f"[SensorManager] Calling _imu_driver.calibrate_gyroscope({samples})...") offsets = _imu_driver.calibrate_gyroscope(samples) print(f"[SensorManager] Gyroscope calibrated: {offsets}") else: @@ -260,15 +327,21 @@ def calibrate_sensor(sensor, samples=100): # Save calibration if offsets: + print("[SensorManager] Saving calibration...") _save_calibration() + print("[SensorManager] Calibration saved") return offsets except Exception as e: print(f"[SensorManager] Error calibrating sensor {sensor.name}: {e}") + import sys + sys.print_exception(e) return None finally: + print("[SensorManager] calibrate_sensor: releasing lock...") if _lock: _lock.release() + print("[SensorManager] calibrate_sensor: lock released") # Helper functions for calibration quality checking (module-level to avoid nested def issues) @@ -294,6 +367,8 @@ def _calc_variance(samples_list): def check_calibration_quality(samples=50): """Check quality of current calibration. + Performs lazy IMU initialization on first call. + Args: samples: Number of samples to collect (default 50) @@ -308,12 +383,12 @@ def check_calibration_quality(samples=50): - issues: list of strings describing problems None if IMU not available """ + _ensure_imu_initialized() if not is_available(): return None - if _lock: - _lock.acquire() - + # Don't acquire lock here - let read_sensor() handle it per-read + # (avoids deadlock since read_sensor also acquires the lock) try: accel = get_default_sensor(TYPE_ACCELEROMETER) gyro = get_default_sensor(TYPE_GYROSCOPE) @@ -413,9 +488,6 @@ def check_calibration_quality(samples=50): except Exception as e: print(f"[SensorManager] Error checking calibration quality: {e}") return None - finally: - if _lock: - _lock.release() def check_stationarity(samples=30, variance_threshold_accel=0.5, variance_threshold_gyro=5.0): @@ -434,12 +506,12 @@ def check_stationarity(samples=30, variance_threshold_accel=0.5, variance_thresh - message: string describing result None if IMU not available """ + _ensure_imu_initialized() if not is_available(): return None - if _lock: - _lock.acquire() - + # Don't acquire lock here - let read_sensor() handle it per-read + # (avoids deadlock since read_sensor also acquires the lock) try: accel = get_default_sensor(TYPE_ACCELEROMETER) gyro = get_default_sensor(TYPE_GYROSCOPE) @@ -498,9 +570,6 @@ def check_stationarity(samples=30, variance_threshold_accel=0.5, variance_thresh except Exception as e: print(f"[SensorManager] Error checking stationarity: {e}") return None - finally: - if _lock: - _lock.release() # ============================================================================ @@ -583,39 +652,53 @@ def read_temperature(self): def calibrate_accelerometer(self, samples): """Calibrate accelerometer (device must be stationary).""" + print(f"[QMI8658Driver] calibrate_accelerometer: starting with {samples} samples") sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - for _ in range(samples): + for i in range(samples): + if i % 10 == 0: + print(f"[QMI8658Driver] Sample {i}/{samples}: about to read acceleration...") ax, ay, az = self.sensor.acceleration + if i % 10 == 0: + print(f"[QMI8658Driver] Sample {i}/{samples}: read complete, values=({ax:.3f}, {ay:.3f}, {az:.3f}), sleeping...") # Convert to m/s² sum_x += ax * _GRAVITY sum_y += ay * _GRAVITY sum_z += az * _GRAVITY time.sleep_ms(10) + if i % 10 == 0: + print(f"[QMI8658Driver] Sample {i}/{samples}: sleep complete") + print(f"[QMI8658Driver] All {samples} samples collected, calculating offsets...") # 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 # Expect +1G on Z + print(f"[QMI8658Driver] Calibration complete: offsets = {tuple(self.accel_offset)}") return tuple(self.accel_offset) def calibrate_gyroscope(self, samples): """Calibrate gyroscope (device must be stationary).""" + print(f"[QMI8658Driver] calibrate_gyroscope: starting with {samples} samples") sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - for _ in range(samples): + for i in range(samples): + if i % 20 == 0: + print(f"[QMI8658Driver] Reading sample {i}/{samples}...") gx, gy, gz = self.sensor.gyro sum_x += gx sum_y += gy sum_z += gz time.sleep_ms(10) + print(f"[QMI8658Driver] All {samples} samples collected, calculating offsets...") # 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 + print(f"[QMI8658Driver] Calibration complete: offsets = {tuple(self.gyro_offset)}") return tuple(self.gyro_offset) def get_calibration(self): From 5199a923947266f3f7f7cae22bd38e2b138731b0 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 6 Dec 2025 11:16:19 +0100 Subject: [PATCH 106/192] Reduce debug output --- .../assets/calibrate_imu.py | 8 ++--- .../lib/mpos/hardware/drivers/wsen_isds.py | 31 ++++++------------- .../lib/mpos/sensor_manager.py | 20 +++++------- 3 files changed, 21 insertions(+), 38 deletions(-) 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 190d888..18a1d22 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 @@ -295,15 +295,15 @@ def calibration_thread_func(self): print(f"[CalibrateIMU] Accel sensor: {accel}, Gyro sensor: {gyro}") if accel: - print("[CalibrateIMU] Calibrating accelerometer (100 samples)...") - accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) + print("[CalibrateIMU] Calibrating accelerometer (30 samples)...") + accel_offsets = SensorManager.calibrate_sensor(accel, samples=30) print(f"[CalibrateIMU] Accel offsets: {accel_offsets}") else: accel_offsets = None if gyro: - print("[CalibrateIMU] Calibrating gyroscope (100 samples)...") - gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) + print("[CalibrateIMU] Calibrating gyroscope (30 samples)...") + gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=30) print(f"[CalibrateIMU] Gyro offsets: {gyro_offsets}") else: gyro_offsets = None diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py index 631910a..8372fb4 100644 --- a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -165,15 +165,6 @@ def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", print("[WSEN_ISDS] Waiting 100ms for sensors to stabilize...") time.sleep_ms(100) - # Debug: Read all control registers to see full sensor state - print("[WSEN_ISDS] === Sensor State After Initialization ===") - for reg_addr in [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19]: - try: - reg_val = self.i2c.readfrom_mem(self.address, reg_addr, 1)[0] - print(f"[WSEN_ISDS] Reg 0x{reg_addr:02x} (CTRL{reg_addr-0x0f}): 0x{reg_val:02x} = 0b{reg_val:08b}") - except: - pass - print("[WSEN_ISDS] Initialization complete") def get_chip_id(self): @@ -193,7 +184,6 @@ def _write_option(self, option, value): config_value &= opt["mask"] config_value |= (bits << opt["shift_left"]) self.i2c.writeto_mem(self.address, opt["reg"], bytes([config_value])) - print(f"[WSEN_ISDS] _write_option: {option}={value} → reg {hex(opt['reg'])}: {hex(old_value)} → {hex(config_value)}") except KeyError as err: print(f"Invalid option: {option}, or invalid option value: {value}.", err) @@ -280,11 +270,14 @@ def acc_calibrate(self, samples=None): if samples is None: samples = self.ACC_NUM_SAMPLES_CALIBRATION + print(f"[WSEN_ISDS] Calibrating accelerometer with {samples} samples...") self.acc_offset_x = 0 self.acc_offset_y = 0 self.acc_offset_z = 0 - for _ in range(samples): + for i in range(samples): + if i % 10 == 0: + print(f"[WSEN_ISDS] Accel sample {i}/{samples}") x, y, z = self._read_raw_accelerations() self.acc_offset_x += x self.acc_offset_y += y @@ -294,6 +287,7 @@ def acc_calibrate(self, samples=None): self.acc_offset_x //= samples self.acc_offset_y //= samples self.acc_offset_z //= samples + print(f"[WSEN_ISDS] Accelerometer calibration complete: offsets=({self.acc_offset_x}, {self.acc_offset_y}, {self.acc_offset_z})") def _acc_calc_sensitivity(self): """Calculate accelerometer sensitivity based on range (in mg/digit).""" @@ -324,19 +318,15 @@ def read_accelerations(self): def _read_raw_accelerations(self): """Read raw accelerometer data.""" - print("[WSEN_ISDS] _read_raw_accelerations: checking data ready...") if not self._acc_data_ready(): - print("[WSEN_ISDS] _read_raw_accelerations: DATA NOT READY!") raise Exception("sensor data not ready") - print("[WSEN_ISDS] _read_raw_accelerations: data ready, reading registers...") raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_A_X_OUT_L, 6) raw_a_x = self._convert_from_raw(raw[0], raw[1]) raw_a_y = self._convert_from_raw(raw[2], raw[3]) raw_a_z = self._convert_from_raw(raw[4], raw[5]) - print(f"[WSEN_ISDS] _read_raw_accelerations: raw values = ({raw_a_x}, {raw_a_y}, {raw_a_z})") return raw_a_x, raw_a_y, raw_a_z def gyro_calibrate(self, samples=None): @@ -348,11 +338,14 @@ def gyro_calibrate(self, samples=None): if samples is None: samples = self.GYRO_NUM_SAMPLES_CALIBRATION + print(f"[WSEN_ISDS] Calibrating gyroscope with {samples} samples...") self.gyro_offset_x = 0 self.gyro_offset_y = 0 self.gyro_offset_z = 0 - for _ in range(samples): + for i in range(samples): + if i % 10 == 0: + print(f"[WSEN_ISDS] Gyro sample {i}/{samples}") x, y, z = self._read_raw_angular_velocities() self.gyro_offset_x += x self.gyro_offset_y += y @@ -362,6 +355,7 @@ def gyro_calibrate(self, samples=None): self.gyro_offset_x //= samples self.gyro_offset_y //= samples self.gyro_offset_z //= samples + print(f"[WSEN_ISDS] Gyroscope calibration complete: offsets=({self.gyro_offset_x}, {self.gyro_offset_y}, {self.gyro_offset_z})") def read_angular_velocities(self): """Read calibrated gyroscope data. @@ -379,19 +373,15 @@ def read_angular_velocities(self): def _read_raw_angular_velocities(self): """Read raw gyroscope data.""" - print("[WSEN_ISDS] _read_raw_angular_velocities: checking data ready...") if not self._gyro_data_ready(): - print("[WSEN_ISDS] _read_raw_angular_velocities: DATA NOT READY!") raise Exception("sensor data not ready") - print("[WSEN_ISDS] _read_raw_angular_velocities: data ready, reading registers...") raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_G_X_OUT_L, 6) raw_g_x = self._convert_from_raw(raw[0], raw[1]) raw_g_y = self._convert_from_raw(raw[2], raw[3]) raw_g_z = self._convert_from_raw(raw[4], raw[5]) - print(f"[WSEN_ISDS] _read_raw_angular_velocities: raw values = ({raw_g_x}, {raw_g_y}, {raw_g_z})") return raw_g_x, raw_g_y, raw_g_z def read_angular_velocities_accelerations(self): @@ -468,5 +458,4 @@ def _get_status_reg(self): gyro_data_ready = bool(status & 0x02) # Bit 1 temp_data_ready = bool(status & 0x04) # Bit 2 - print(f"[WSEN_ISDS] Status register: 0x{status:02x} = 0b{status:08b}, acc_ready={acc_data_ready}, gyro_ready={gyro_data_ready}, temp_ready={temp_data_ready}") return acc_data_ready, gyro_data_ready, temp_data_ready diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 0d58548..60e5a3d 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -652,53 +652,47 @@ def read_temperature(self): def calibrate_accelerometer(self, samples): """Calibrate accelerometer (device must be stationary).""" - print(f"[QMI8658Driver] calibrate_accelerometer: starting with {samples} samples") + print(f"[QMI8658Driver] Calibrating accelerometer with {samples} samples...") sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 for i in range(samples): if i % 10 == 0: - print(f"[QMI8658Driver] Sample {i}/{samples}: about to read acceleration...") + print(f"[QMI8658Driver] Accel sample {i}/{samples}") ax, ay, az = self.sensor.acceleration - if i % 10 == 0: - print(f"[QMI8658Driver] Sample {i}/{samples}: read complete, values=({ax:.3f}, {ay:.3f}, {az:.3f}), sleeping...") # Convert to m/s² sum_x += ax * _GRAVITY sum_y += ay * _GRAVITY sum_z += az * _GRAVITY time.sleep_ms(10) - if i % 10 == 0: - print(f"[QMI8658Driver] Sample {i}/{samples}: sleep complete") - print(f"[QMI8658Driver] All {samples} samples collected, calculating offsets...") # 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 # Expect +1G on Z - print(f"[QMI8658Driver] Calibration complete: offsets = {tuple(self.accel_offset)}") + print(f"[QMI8658Driver] Accelerometer calibration complete: offsets = {tuple(self.accel_offset)}") return tuple(self.accel_offset) def calibrate_gyroscope(self, samples): """Calibrate gyroscope (device must be stationary).""" - print(f"[QMI8658Driver] calibrate_gyroscope: starting with {samples} samples") + print(f"[QMI8658Driver] Calibrating gyroscope with {samples} samples...") sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 for i in range(samples): - if i % 20 == 0: - print(f"[QMI8658Driver] Reading sample {i}/{samples}...") + if i % 10 == 0: + print(f"[QMI8658Driver] Gyro sample {i}/{samples}") gx, gy, gz = self.sensor.gyro sum_x += gx sum_y += gy sum_z += gz time.sleep_ms(10) - print(f"[QMI8658Driver] All {samples} samples collected, calculating offsets...") # 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 - print(f"[QMI8658Driver] Calibration complete: offsets = {tuple(self.gyro_offset)}") + print(f"[QMI8658Driver] Gyroscope calibration complete: offsets = {tuple(self.gyro_offset)}") return tuple(self.gyro_offset) def get_calibration(self): From 7dbc813f4fa439c2847ac341c0026567404fcd42 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 6 Dec 2025 11:57:43 +0100 Subject: [PATCH 107/192] Fix calibration --- .../assets/calibrate_imu.py | 110 ++++++++++++++++-- 1 file changed, 102 insertions(+), 8 deletions(-) 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 18a1d22..6c7d6cf 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 @@ -17,6 +17,7 @@ import mpos.ui import mpos.sensor_manager as SensorManager import mpos.apps +from mpos.ui.testing import wait_for_render class CalibrationState: @@ -246,14 +247,106 @@ def handle_quality_error(self, error_msg): self.detail_label.set_text("Check IMU connection and try again") def start_calibration_process(self): - """Start the calibration process.""" - self.set_state(CalibrationState.CHECKING_STATIONARITY) + """Start the calibration process. - # Run in background thread - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.calibration_thread_func, ()) + Note: Runs in main thread - UI will freeze during calibration (~1 second). + This avoids threading issues with I2C/sensor access. + """ + try: + print("[CalibrateIMU] === Calibration started ===") + + # Step 1: Check stationarity + print("[CalibrateIMU] Step 1: Checking stationarity...") + self.set_state(CalibrationState.CHECKING_STATIONARITY) + wait_for_render() # Let UI update + + if self.is_desktop: + stationarity = {'is_stationary': True, 'message': 'Mock: Stationary'} + else: + print("[CalibrateIMU] Calling SensorManager.check_stationarity(samples=30)...") + stationarity = SensorManager.check_stationarity(samples=30) + print(f"[CalibrateIMU] Stationarity result: {stationarity}") + + if stationarity is None or not stationarity['is_stationary']: + msg = stationarity['message'] if stationarity else "Stationarity check failed" + print(f"[CalibrateIMU] Device not stationary: {msg}") + self.handle_calibration_error( + f"Device not stationary!\n\n{msg}\n\nPlace on flat surface and try again.") + return + + print("[CalibrateIMU] Device is stationary, proceeding to calibration") + + # Step 2: Perform calibration + print("[CalibrateIMU] Step 2: Performing calibration...") + self.set_state(CalibrationState.CALIBRATING) + self.status_label.set_text("Calibrating IMU...\n\nUI will freeze for ~2 seconds\nPlease wait...") + wait_for_render() # Let UI update before blocking + + if self.is_desktop: + print("[CalibrateIMU] Mock calibration (desktop)") + time.sleep(2) + accel_offsets = (0.1, -0.05, 0.15) + gyro_offsets = (0.2, -0.1, 0.05) + else: + # Real calibration - UI will freeze here + print("[CalibrateIMU] Real calibration (hardware)") + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + print(f"[CalibrateIMU] Accel sensor: {accel}, Gyro sensor: {gyro}") + + if accel: + print("[CalibrateIMU] Calibrating accelerometer (100 samples)...") + accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) + print(f"[CalibrateIMU] Accel offsets: {accel_offsets}") + else: + accel_offsets = None - def calibration_thread_func(self): + if gyro: + print("[CalibrateIMU] Calibrating gyroscope (100 samples)...") + gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) + print(f"[CalibrateIMU] Gyro offsets: {gyro_offsets}") + else: + gyro_offsets = None + + # Step 3: Verify results + print("[CalibrateIMU] Step 3: Verifying calibration...") + self.set_state(CalibrationState.VERIFYING) + wait_for_render() + + if self.is_desktop: + verify_quality = self.get_mock_quality(good=True) + else: + print("[CalibrateIMU] Checking calibration quality (50 samples)...") + verify_quality = SensorManager.check_calibration_quality(samples=50) + print(f"[CalibrateIMU] Verification quality: {verify_quality}") + + if verify_quality is None: + print("[CalibrateIMU] Verification failed") + self.handle_calibration_error("Calibration completed but verification failed") + return + + # Step 4: Show results + print("[CalibrateIMU] Step 4: Showing results...") + rating = verify_quality['quality_rating'] + score = verify_quality['quality_score'] + + result_msg = f"Calibration successful!\n\nNew quality: {rating} ({score*100:.0f}%)" + if accel_offsets: + result_msg += f"\n\nAccel offsets:\nX:{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}" + + print(f"[CalibrateIMU] Calibration complete! Result: {result_msg[:80]}") + self.show_calibration_complete(result_msg) + print("[CalibrateIMU] === Calibration finished ===") + + except Exception as e: + print(f"[CalibrateIMU] Calibration error: {e}") + import sys + sys.print_exception(e) + self.handle_calibration_error(str(e)) + + def old_calibration_thread_func_UNUSED(self): """Background thread for calibration process.""" try: print("[CalibrateIMU] === Calibration thread started ===") @@ -337,8 +430,9 @@ def calibration_thread_func(self): if gyro_offsets: result_msg += f"\n\nGyro offsets:\nX:{gyro_offsets[0]:.3f} Y:{gyro_offsets[1]:.3f} Z:{gyro_offsets[2]:.3f}" - print(f"[CalibrateIMU] Calibration complete! Result: {result_msg[:80]}") + print(f"[CalibrateIMU] Calibration compl ete! Result: {result_msg[:80]}") self.update_ui_threadsafe_if_foreground(self.show_calibration_complete, result_msg) + print("[CalibrateIMU] === Calibration thread finished ===") except Exception as e: @@ -346,7 +440,7 @@ def calibration_thread_func(self): import sys sys.print_exception(e) self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, str(e)) - + def show_calibration_complete(self, result_msg): """Show calibration completion message.""" self.status_label.set_text(result_msg) From 421140cd7bdbd52ae1f12b341c7df43a7c9309ec Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 6 Dec 2025 12:59:56 +0100 Subject: [PATCH 108/192] Calibration: fix cancel button visibility --- .../com.micropythonos.settings/assets/calibrate_imu.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 6c7d6cf..7e5a859 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 @@ -143,46 +143,54 @@ def update_ui_for_state(self): self.action_button_label.set_text("Check Quality") self.action_button.remove_state(lv.STATE.DISABLED) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) + self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.CHECKING_QUALITY: self.status_label.set_text("Checking current calibration...") self.action_button.add_state(lv.STATE.DISABLED) self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) self.progress_bar.set_value(20, True) + self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.AWAITING_CONFIRMATION: # Status will be set by quality check result self.action_button_label.set_text("Calibrate Now") self.action_button.remove_state(lv.STATE.DISABLED) self.progress_bar.set_value(30, True) + self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.CHECKING_STATIONARITY: self.status_label.set_text("Checking if device is stationary...") self.detail_label.set_text("Keep device still on flat surface") self.action_button.add_state(lv.STATE.DISABLED) self.progress_bar.set_value(40, True) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.CALIBRATING: self.status_label.set_text("Calibrating IMU...") self.detail_label.set_text("Do not move device!\nCollecting samples...") self.action_button.add_state(lv.STATE.DISABLED) self.progress_bar.set_value(60, True) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.VERIFYING: self.status_label.set_text("Verifying calibration...") self.action_button.add_state(lv.STATE.DISABLED) self.progress_bar.set_value(90, True) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.COMPLETE: self.status_label.set_text("Calibration complete!") self.action_button_label.set_text("Done") self.action_button.remove_state(lv.STATE.DISABLED) self.progress_bar.set_value(100, True) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.ERROR: self.action_button_label.set_text("Retry") self.action_button.remove_state(lv.STATE.DISABLED) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) + self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) def action_button_clicked(self, event): """Handle action button clicks based on current state.""" @@ -444,7 +452,7 @@ def old_calibration_thread_func_UNUSED(self): def show_calibration_complete(self, result_msg): """Show calibration completion message.""" self.status_label.set_text(result_msg) - self.detail_label.set_text("Calibration saved to Settings") + self.detail_label.set_text("Calibration saved to storage.") self.set_state(CalibrationState.COMPLETE) def handle_calibration_error(self, error_msg): From 331cf14178f0380cc65b6edded9b6788b6035b5d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 09:12:31 +0100 Subject: [PATCH 109/192] Simplify --- CLAUDE.md | 40 ++- .../assets/calibrate_imu.py | 302 ++---------------- .../assets/check_imu_calibration.py | 36 ++- .../lib/mpos/hardware/drivers/wsen_isds.py | 23 +- .../lib/mpos/sensor_manager.py | 128 ++------ 5 files changed, 115 insertions(+), 414 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f05ac0a..05137f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,31 +116,41 @@ The `c_mpos/src/webcam.c` module provides webcam support for desktop builds usin ### Development Workflow (IMPORTANT) -**For most development, you do NOT need to rebuild the firmware!** +**⚠️ CRITICAL: Desktop vs Hardware Testing** -When you run `scripts/install.sh`, it copies files from `internal_filesystem/` to the device storage. These files override the frozen filesystem because the storage paths are first in `sys.path`. This means: +📖 **See**: [docs/os-development/running-on-desktop.md](../docs/docs/os-development/running-on-desktop.md) for complete guide. +**Desktop testing (recommended for ALL Python development):** ```bash -# Fast development cycle (recommended): -# 1. Edit Python files in internal_filesystem/ -# 2. Install to device: -./scripts/install.sh waveshare-esp32-s3-touch-lcd-2 +# 1. Edit files in internal_filesystem/ +nano internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py + +# 2. Run on desktop - changes are IMMEDIATELY active! +./scripts/run_desktop.sh -# That's it! Your changes are live on the device. +# That's it! NO build, NO install needed. ``` -**You only need to rebuild firmware (`./scripts/build_mpos.sh esp32`) when:** -- Testing the frozen `lib/` for production releases -- Modifying C extension modules (`c_mpos/`, `secp256k1-embedded-ecdh/`) -- Changing MicroPython core or LVGL bindings -- Creating a fresh firmware image for distribution +**❌ DO NOT run `./scripts/install.sh` for desktop testing!** It's only for hardware deployment. + +The desktop binary runs **directly from `internal_filesystem/`**, so any Python file changes are instantly available. This is the fastest development cycle. -**Desktop development** always uses the unfrozen files, so you never need to rebuild for Python changes: +**Hardware deployment (only after desktop testing):** ```bash -# Edit internal_filesystem/ files -./scripts/run_desktop.sh # Changes are immediately active +# Deploy to physical ESP32 device via USB/serial +./scripts/install.sh waveshare-esp32-s3-touch-lcd-2 ``` +This copies files from `internal_filesystem/` to device storage, which overrides the frozen filesystem. + +**When you need to rebuild firmware (`./scripts/build_mpos.sh`):** +- Modifying C extension modules (`c_mops/`, `secp256k1-embedded-ecdh/`) +- Changing MicroPython core or LVGL bindings +- Testing frozen filesystem for production releases +- Creating firmware for distribution + +**For 99% of development work on Python code**: Just edit `internal_filesystem/` and run `./scripts/run_desktop.sh`. + ### Building Firmware The main build script is `scripts/build_mpos.sh`: 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 7e5a859..45d67c1 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 @@ -1,42 +1,34 @@ """Calibrate IMU Activity. Guides user through IMU calibration process: -1. Check current calibration quality -2. Ask if user wants to recalibrate -3. Check stationarity -4. Perform calibration -5. Verify results -6. Save to new location +1. Show calibration instructions +2. Check stationarity when user clicks "Calibrate Now" +3. Perform calibration +4. Show results """ import lvgl as lv import time -import _thread import sys from mpos.app.activity import Activity import mpos.ui import mpos.sensor_manager as SensorManager -import mpos.apps from mpos.ui.testing import wait_for_render class CalibrationState: """Enum for calibration states.""" - IDLE = 0 - CHECKING_QUALITY = 1 - AWAITING_CONFIRMATION = 2 - CHECKING_STATIONARITY = 3 - CALIBRATING = 4 - VERIFYING = 5 - COMPLETE = 6 - ERROR = 7 + READY = 0 + CALIBRATING = 1 + COMPLETE = 2 + ERROR = 3 class CalibrateIMUActivity(Activity): """Guide user through IMU calibration process.""" # State - current_state = CalibrationState.IDLE + current_state = CalibrationState.READY calibration_thread = None # Widgets @@ -120,9 +112,8 @@ def onResume(self, screen): self.action_button.add_state(lv.STATE.DISABLED) return - # Start by checking current quality - self.set_state(CalibrationState.IDLE) - self.action_button_label.set_text("Check Quality") + # Show calibration instructions + self.set_state(CalibrationState.READY) def onPause(self, screen): # Stop any running calibration @@ -138,55 +129,31 @@ def set_state(self, new_state): def update_ui_for_state(self): """Update UI based on current state.""" - if self.current_state == CalibrationState.IDLE: - self.status_label.set_text("Ready to check calibration quality") - self.action_button_label.set_text("Check Quality") - self.action_button.remove_state(lv.STATE.DISABLED) - self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) - self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) - - elif self.current_state == CalibrationState.CHECKING_QUALITY: - self.status_label.set_text("Checking current calibration...") - self.action_button.add_state(lv.STATE.DISABLED) - self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) - self.progress_bar.set_value(20, True) - self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) - - elif self.current_state == CalibrationState.AWAITING_CONFIRMATION: - # Status will be set by quality check result + if self.current_state == CalibrationState.READY: + self.status_label.set_text("Place device on flat, stable surface\n\nKeep device completely still during calibration") + self.detail_label.set_text("Calibration will take ~2 seconds\nUI will freeze during calibration") self.action_button_label.set_text("Calibrate Now") self.action_button.remove_state(lv.STATE.DISABLED) - self.progress_bar.set_value(30, True) + self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) - elif self.current_state == CalibrationState.CHECKING_STATIONARITY: - self.status_label.set_text("Checking if device is stationary...") - self.detail_label.set_text("Keep device still on flat surface") - self.action_button.add_state(lv.STATE.DISABLED) - self.progress_bar.set_value(40, True) - self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) - elif self.current_state == CalibrationState.CALIBRATING: self.status_label.set_text("Calibrating IMU...") - self.detail_label.set_text("Do not move device!\nCollecting samples...") + self.detail_label.set_text("Do not move device!") self.action_button.add_state(lv.STATE.DISABLED) - self.progress_bar.set_value(60, True) - self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) - - elif self.current_state == CalibrationState.VERIFYING: - self.status_label.set_text("Verifying calibration...") - self.action_button.add_state(lv.STATE.DISABLED) - self.progress_bar.set_value(90, True) + self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) + self.progress_bar.set_value(50, True) self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.COMPLETE: - self.status_label.set_text("Calibration complete!") + # Status text will be set by calibration results self.action_button_label.set_text("Done") self.action_button.remove_state(lv.STATE.DISABLED) self.progress_bar.set_value(100, True) self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.ERROR: + # Status text will be set by error handler self.action_button_label.set_text("Retry") self.action_button.remove_state(lv.STATE.DISABLED) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) @@ -194,261 +161,70 @@ def update_ui_for_state(self): def action_button_clicked(self, event): """Handle action button clicks based on current state.""" - if self.current_state == CalibrationState.IDLE: - self.start_quality_check() - elif self.current_state == CalibrationState.AWAITING_CONFIRMATION: + if self.current_state == CalibrationState.READY: self.start_calibration_process() elif self.current_state == CalibrationState.COMPLETE: self.finish() elif self.current_state == CalibrationState.ERROR: - self.set_state(CalibrationState.IDLE) - - def start_quality_check(self): - """Check current calibration quality.""" - self.set_state(CalibrationState.CHECKING_QUALITY) - - # Run in background thread - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(self.quality_check_thread, ()) - - def quality_check_thread(self): - """Background thread for quality check.""" - try: - if self.is_desktop: - quality = self.get_mock_quality() - else: - quality = SensorManager.check_calibration_quality(samples=50) - - if quality is None: - self.update_ui_threadsafe_if_foreground(self.handle_quality_error, "Failed to read IMU") - return - - # Update UI with results - self.update_ui_threadsafe_if_foreground(self.show_quality_results, quality) - - except Exception as e: - print(f"[CalibrateIMU] Quality check error: {e}") - self.update_ui_threadsafe_if_foreground(self.handle_quality_error, str(e)) - - def show_quality_results(self, quality): - """Show quality check results and ask for confirmation.""" - rating = quality['quality_rating'] - score = quality['quality_score'] - issues = quality['issues'] - - # Build status message - if rating == "Good": - msg = f"Current calibration: {rating} ({score*100:.0f}%)\n\nCalibration looks good!" - else: - msg = f"Current calibration: {rating} ({score*100:.0f}%)\n\nRecommend recalibrating." + self.set_state(CalibrationState.READY) - if issues: - msg += "\n\nIssues found:\n" + "\n".join(f"- {issue}" for issue in issues[:3]) # Show first 3 - - self.status_label.set_text(msg) - self.set_state(CalibrationState.AWAITING_CONFIRMATION) - - def handle_quality_error(self, error_msg): - """Handle error during quality check.""" - self.set_state(CalibrationState.ERROR) - self.status_label.set_text(f"Error: {error_msg}") - self.detail_label.set_text("Check IMU connection and try again") def start_calibration_process(self): """Start the calibration process. - Note: Runs in main thread - UI will freeze during calibration (~1 second). + Note: Runs in main thread - UI will freeze during calibration (~2 seconds). This avoids threading issues with I2C/sensor access. """ try: - print("[CalibrateIMU] === Calibration started ===") - # Step 1: Check stationarity - print("[CalibrateIMU] Step 1: Checking stationarity...") - self.set_state(CalibrationState.CHECKING_STATIONARITY) + self.set_state(CalibrationState.CALIBRATING) wait_for_render() # Let UI update if self.is_desktop: stationarity = {'is_stationary': True, 'message': 'Mock: Stationary'} else: - print("[CalibrateIMU] Calling SensorManager.check_stationarity(samples=30)...") stationarity = SensorManager.check_stationarity(samples=30) - print(f"[CalibrateIMU] Stationarity result: {stationarity}") if stationarity is None or not stationarity['is_stationary']: msg = stationarity['message'] if stationarity else "Stationarity check failed" - print(f"[CalibrateIMU] Device not stationary: {msg}") self.handle_calibration_error( f"Device not stationary!\n\n{msg}\n\nPlace on flat surface and try again.") return - print("[CalibrateIMU] Device is stationary, proceeding to calibration") - # Step 2: Perform calibration - print("[CalibrateIMU] Step 2: Performing calibration...") - self.set_state(CalibrationState.CALIBRATING) - self.status_label.set_text("Calibrating IMU...\n\nUI will freeze for ~2 seconds\nPlease wait...") - wait_for_render() # Let UI update before blocking - if self.is_desktop: - print("[CalibrateIMU] Mock calibration (desktop)") time.sleep(2) accel_offsets = (0.1, -0.05, 0.15) gyro_offsets = (0.2, -0.1, 0.05) else: # Real calibration - UI will freeze here - print("[CalibrateIMU] Real calibration (hardware)") accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) - print(f"[CalibrateIMU] Accel sensor: {accel}, Gyro sensor: {gyro}") if accel: - print("[CalibrateIMU] Calibrating accelerometer (100 samples)...") accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) - print(f"[CalibrateIMU] Accel offsets: {accel_offsets}") else: accel_offsets = None if gyro: - print("[CalibrateIMU] Calibrating gyroscope (100 samples)...") gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) - print(f"[CalibrateIMU] Gyro offsets: {gyro_offsets}") else: gyro_offsets = None - # Step 3: Verify results - print("[CalibrateIMU] Step 3: Verifying calibration...") - self.set_state(CalibrationState.VERIFYING) - wait_for_render() - - if self.is_desktop: - verify_quality = self.get_mock_quality(good=True) - else: - print("[CalibrateIMU] Checking calibration quality (50 samples)...") - verify_quality = SensorManager.check_calibration_quality(samples=50) - print(f"[CalibrateIMU] Verification quality: {verify_quality}") - - if verify_quality is None: - print("[CalibrateIMU] Verification failed") - self.handle_calibration_error("Calibration completed but verification failed") - return - - # Step 4: Show results - print("[CalibrateIMU] Step 4: Showing results...") - rating = verify_quality['quality_rating'] - score = verify_quality['quality_score'] - - result_msg = f"Calibration successful!\n\nNew quality: {rating} ({score*100:.0f}%)" + # 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}" if gyro_offsets: result_msg += f"\n\nGyro offsets:\nX:{gyro_offsets[0]:.3f} Y:{gyro_offsets[1]:.3f} Z:{gyro_offsets[2]:.3f}" - print(f"[CalibrateIMU] Calibration complete! Result: {result_msg[:80]}") self.show_calibration_complete(result_msg) - print("[CalibrateIMU] === Calibration finished ===") except Exception as e: - print(f"[CalibrateIMU] Calibration error: {e}") import sys sys.print_exception(e) self.handle_calibration_error(str(e)) - def old_calibration_thread_func_UNUSED(self): - """Background thread for calibration process.""" - try: - print("[CalibrateIMU] === Calibration thread started ===") - - # Step 1: Check stationarity - print("[CalibrateIMU] Step 1: Checking stationarity...") - if self.is_desktop: - stationarity = {'is_stationary': True, 'message': 'Mock: Stationary'} - else: - print("[CalibrateIMU] Calling SensorManager.check_stationarity(samples=30)...") - stationarity = SensorManager.check_stationarity(samples=30) - print(f"[CalibrateIMU] Stationarity result: {stationarity}") - - if stationarity is None or not stationarity['is_stationary']: - msg = stationarity['message'] if stationarity else "Stationarity check failed" - print(f"[CalibrateIMU] Device not stationary: {msg}") - self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, - f"Device not stationary!\n\n{msg}\n\nPlace on flat surface and try again.") - return - - print("[CalibrateIMU] Device is stationary, proceeding to calibration") - - # Step 2: Perform calibration - print("[CalibrateIMU] Step 2: Performing calibration...") - self.update_ui_threadsafe_if_foreground(lambda: self.set_state(CalibrationState.CALIBRATING)) - time.sleep(0.5) # Brief pause for user to see status change - - if self.is_desktop: - # Mock calibration - print("[CalibrateIMU] Mock calibration (desktop)") - time.sleep(2) - accel_offsets = (0.1, -0.05, 0.15) - gyro_offsets = (0.2, -0.1, 0.05) - else: - # Real calibration - print("[CalibrateIMU] Real calibration (hardware)") - accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) - gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) - print(f"[CalibrateIMU] Accel sensor: {accel}, Gyro sensor: {gyro}") - - if accel: - print("[CalibrateIMU] Calibrating accelerometer (30 samples)...") - accel_offsets = SensorManager.calibrate_sensor(accel, samples=30) - print(f"[CalibrateIMU] Accel offsets: {accel_offsets}") - else: - accel_offsets = None - - if gyro: - print("[CalibrateIMU] Calibrating gyroscope (30 samples)...") - gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=30) - print(f"[CalibrateIMU] Gyro offsets: {gyro_offsets}") - else: - gyro_offsets = None - - # Step 3: Verify results - print("[CalibrateIMU] Step 3: Verifying calibration...") - self.update_ui_threadsafe_if_foreground(lambda: self.set_state(CalibrationState.VERIFYING)) - time.sleep(0.5) - - if self.is_desktop: - verify_quality = self.get_mock_quality(good=True) - else: - print("[CalibrateIMU] Checking calibration quality (50 samples)...") - verify_quality = SensorManager.check_calibration_quality(samples=50) - print(f"[CalibrateIMU] Verification quality: {verify_quality}") - - if verify_quality is None: - print("[CalibrateIMU] Verification failed") - self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, - "Calibration completed but verification failed") - return - - # Step 4: Show results - print("[CalibrateIMU] Step 4: Showing results...") - rating = verify_quality['quality_rating'] - score = verify_quality['quality_score'] - - result_msg = f"Calibration successful!\n\nNew quality: {rating} ({score*100:.0f}%)" - if accel_offsets: - result_msg += f"\n\nAccel offsets:\nX:{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}" - - print(f"[CalibrateIMU] Calibration compl ete! Result: {result_msg[:80]}") - self.update_ui_threadsafe_if_foreground(self.show_calibration_complete, result_msg) - - print("[CalibrateIMU] === Calibration thread finished ===") - - except Exception as e: - print(f"[CalibrateIMU] Calibration error: {e}") - import sys - sys.print_exception(e) - self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, str(e)) - def show_calibration_complete(self, result_msg): """Show calibration completion message.""" self.status_label.set_text(result_msg) @@ -461,29 +237,3 @@ def handle_calibration_error(self, error_msg): self.status_label.set_text(f"Calibration failed:\n\n{error_msg}") self.detail_label.set_text("") - def get_mock_quality(self, good=False): - """Generate mock quality data for desktop testing.""" - import random - - if good: - # Simulate excellent calibration after calibration - return { - 'accel_mean': (random.uniform(-0.05, 0.05), random.uniform(-0.05, 0.05), 9.8 + random.uniform(-0.1, 0.1)), - 'accel_variance': (random.uniform(0.001, 0.02), random.uniform(0.001, 0.02), random.uniform(0.001, 0.02)), - 'gyro_mean': (random.uniform(-0.1, 0.1), random.uniform(-0.1, 0.1), random.uniform(-0.1, 0.1)), - 'gyro_variance': (random.uniform(0.01, 0.2), random.uniform(0.01, 0.2), random.uniform(0.01, 0.2)), - 'quality_score': random.uniform(0.90, 0.99), - 'quality_rating': "Good", - 'issues': [] - } - else: - # Simulate mediocre calibration before calibration - return { - 'accel_mean': (random.uniform(-1.0, 1.0), random.uniform(-1.0, 1.0), 9.8 + random.uniform(-2.0, 2.0)), - 'accel_variance': (random.uniform(0.2, 0.5), random.uniform(0.2, 0.5), random.uniform(0.2, 0.5)), - 'gyro_mean': (random.uniform(-3.0, 3.0), random.uniform(-3.0, 3.0), random.uniform(-3.0, 3.0)), - 'gyro_variance': (random.uniform(2.0, 5.0), random.uniform(2.0, 5.0), random.uniform(2.0, 5.0)), - 'quality_score': random.uniform(0.4, 0.6), - 'quality_rating': "Fair", - 'issues': ["High accelerometer variance", "Gyro not near zero"] - } diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py index d9f0a7b..b7cf7b2 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py @@ -38,6 +38,18 @@ def onCreate(self): screen = lv.obj() screen.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0) screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + print(f"[CheckIMU] onResume called, is_desktop={self.is_desktop}") + + # Clear the screen and recreate UI (to avoid stale widget references) + screen.clean() + + # Reset widget lists + self.accel_labels = [] + self.gyro_labels = [] # Title title = lv.label(screen) @@ -118,20 +130,18 @@ def onCreate(self): calibrate_label.center() calibrate_btn.add_event_cb(self.start_calibration, lv.EVENT.CLICKED, None) - self.setContentView(screen) - - def onResume(self, screen): - super().onResume(screen) - # Check if IMU is available if not self.is_desktop and not SensorManager.is_available(): + print("[CheckIMU] IMU not available, stopping") self.status_label.set_text("IMU not available on this device") self.quality_score_label.set_text("N/A") return # Start real-time updates + print("[CheckIMU] Starting real-time updates") self.updating = True self.update_timer = lv.timer_create(self.update_display, self.UPDATE_INTERVAL, None) + print(f"[CheckIMU] Timer created: {self.update_timer}") def onPause(self, screen): # Stop updates @@ -195,8 +205,17 @@ def update_display(self, timer=None): self.issues_label.set_text(issues_text) self.status_label.set_text("Real-time monitoring (place on flat surface)") - except: - # Widgets were deleted (activity closed), stop updating + except Exception as e: + # Log the actual error for debugging + print(f"[CheckIMU] Error in update_display: {e}") + import sys + sys.print_exception(e) + # If widgets were deleted (activity closed), stop updating + try: + self.status_label.set_text(f"Error: {str(e)}") + except: + # Widgets really were deleted + pass self.updating = False def get_mock_quality(self): @@ -232,8 +251,11 @@ def get_mock_quality(self): def start_calibration(self, event): """Navigate to calibration activity.""" + print("[CheckIMU] start_calibration called!") from mpos.content.intent import Intent from calibrate_imu import CalibrateIMUActivity intent = Intent(activity_class=CalibrateIMUActivity) + print("[CheckIMU] Starting CalibrateIMUActivity...") self.startActivity(intent) + print("[CheckIMU] startActivity returned") diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py index 8372fb4..97cf7d0 100644 --- a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -128,7 +128,6 @@ def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", gyro_range: Gyroscope range ("125dps", "250dps", "500dps", "1000dps", "2000dps") gyro_data_rate: Gyroscope data rate ("0", "12.5Hz", "26Hz", ...") """ - print(f"[WSEN_ISDS] __init__ called with address={hex(address)}, acc_range={acc_range}, acc_data_rate={acc_data_rate}, gyro_range={gyro_range}, gyro_data_rate={gyro_data_rate}") self.i2c = i2c self.address = address @@ -150,23 +149,15 @@ def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", self.GYRO_NUM_SAMPLES_CALIBRATION = 5 self.GYRO_CALIBRATION_DELAY_MS = 10 - print("[WSEN_ISDS] Configuring accelerometer...") self.set_acc_range(acc_range) self.set_acc_data_rate(acc_data_rate) - print("[WSEN_ISDS] Accelerometer configured") - print("[WSEN_ISDS] Configuring gyroscope...") self.set_gyro_range(gyro_range) self.set_gyro_data_rate(gyro_data_rate) - print("[WSEN_ISDS] Gyroscope configured") - # Give sensors time to stabilize and start producing data - # Especially important for gyroscope which may need warmup time - print("[WSEN_ISDS] Waiting 100ms for sensors to stabilize...") + # Give sensors time to stabilize time.sleep_ms(100) - print("[WSEN_ISDS] Initialization complete") - def get_chip_id(self): """Get chip ID for detection. Returns WHO_AM_I register value.""" try: @@ -270,14 +261,11 @@ def acc_calibrate(self, samples=None): if samples is None: samples = self.ACC_NUM_SAMPLES_CALIBRATION - print(f"[WSEN_ISDS] Calibrating accelerometer with {samples} samples...") self.acc_offset_x = 0 self.acc_offset_y = 0 self.acc_offset_z = 0 - for i in range(samples): - if i % 10 == 0: - print(f"[WSEN_ISDS] Accel sample {i}/{samples}") + for _ in range(samples): x, y, z = self._read_raw_accelerations() self.acc_offset_x += x self.acc_offset_y += y @@ -287,7 +275,6 @@ def acc_calibrate(self, samples=None): self.acc_offset_x //= samples self.acc_offset_y //= samples self.acc_offset_z //= samples - print(f"[WSEN_ISDS] Accelerometer calibration complete: offsets=({self.acc_offset_x}, {self.acc_offset_y}, {self.acc_offset_z})") def _acc_calc_sensitivity(self): """Calculate accelerometer sensitivity based on range (in mg/digit).""" @@ -338,14 +325,11 @@ def gyro_calibrate(self, samples=None): if samples is None: samples = self.GYRO_NUM_SAMPLES_CALIBRATION - print(f"[WSEN_ISDS] Calibrating gyroscope with {samples} samples...") self.gyro_offset_x = 0 self.gyro_offset_y = 0 self.gyro_offset_z = 0 - for i in range(samples): - if i % 10 == 0: - print(f"[WSEN_ISDS] Gyro sample {i}/{samples}") + for _ in range(samples): x, y, z = self._read_raw_angular_velocities() self.gyro_offset_x += x self.gyro_offset_y += y @@ -355,7 +339,6 @@ def gyro_calibrate(self, samples=None): self.gyro_offset_x //= samples self.gyro_offset_y //= samples self.gyro_offset_z //= samples - print(f"[WSEN_ISDS] Gyroscope calibration complete: offsets=({self.gyro_offset_x}, {self.gyro_offset_y}, {self.gyro_offset_z})") def read_angular_velocities(self): """Read calibrated gyroscope data. diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 60e5a3d..b71a382 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -89,17 +89,13 @@ def init(i2c_bus, address=0x6B): # Initialize MCU temperature sensor immediately (fast, no I2C needed) try: import esp32 - # Test if mcu_temperature() is available _ = esp32.mcu_temperature() _has_mcu_temperature = True _register_mcu_temperature_sensor() - print("[SensorManager] Detected MCU internal temperature sensor") - except Exception as e: - print(f"[SensorManager] MCU temperature not available: {e}") + except: + pass - # Mark as initialized (but IMU driver is still None - will be initialized lazily) _initialized = True - print("[SensorManager] init() called - IMU initialization deferred until first use") return True @@ -112,60 +108,37 @@ def _ensure_imu_initialized(): Returns: bool: True if IMU detected and initialized successfully """ - global _imu_driver, _sensor_list, _i2c_bus, _i2c_address - - # If already initialized, return - if _imu_driver is not None: - return True + global _imu_driver, _sensor_list - print("[SensorManager] _ensure_imu_initialized: Starting lazy IMU initialization...") - i2c_bus = _i2c_bus - address = _i2c_address - imu_detected = False + if not _initialized or _imu_driver is not None: + return _imu_driver is not None # Try QMI8658 first (Waveshare board) - if i2c_bus: + if _i2c_bus: try: from mpos.hardware.drivers.qmi8658 import QMI8658 - # QMI8658 constants (can't import const() values) - _QMI8685_PARTID = 0x05 - _REG_PARTID = 0x00 - chip_id = i2c_bus.readfrom_mem(address, _REG_PARTID, 1)[0] - if chip_id == _QMI8685_PARTID: - print("[SensorManager] Detected QMI8658 IMU") - _imu_driver = _QMI8658Driver(i2c_bus, address) + chip_id = _i2c_bus.readfrom_mem(_i2c_address, 0x00, 1)[0] # PARTID register + if chip_id == 0x05: # QMI8685_PARTID + _imu_driver = _QMI8658Driver(_i2c_bus, _i2c_address) _register_qmi8658_sensors() _load_calibration() - imu_detected = True - except Exception as e: - print(f"[SensorManager] QMI8658 detection failed: {e}") + return True + except: + pass # Try WSEN_ISDS (Fri3d badge) - if not imu_detected: - print(f"[SensorManager] Trying to detect WSEN_ISDS at address {hex(address)}...") - try: - from mpos.hardware.drivers.wsen_isds import Wsen_Isds - print("[SensorManager] Reading WHO_AM_I register (0x0F)...") - chip_id = i2c_bus.readfrom_mem(address, 0x0F, 1)[0] # WHO_AM_I register - print(f"[SensorManager] WHO_AM_I = {hex(chip_id)}") - if chip_id == 0x6A: # WSEN_ISDS WHO_AM_I value - print("[SensorManager] Detected WSEN_ISDS IMU - initializing driver...") - _imu_driver = _WsenISDSDriver(i2c_bus, address) - print("[SensorManager] WSEN_ISDS driver initialized, registering sensors...") - _register_wsen_isds_sensors() - print("[SensorManager] Loading calibration...") - _load_calibration() - imu_detected = True - print("[SensorManager] WSEN_ISDS initialization complete!") - else: - print(f"[SensorManager] Chip ID {hex(chip_id)} doesn't match WSEN_ISDS (expected 0x6A)") - except Exception as e: - print(f"[SensorManager] WSEN_ISDS detection failed: {e}") - import sys - sys.print_exception(e) + try: + from mpos.hardware.drivers.wsen_isds import Wsen_Isds + chip_id = _i2c_bus.readfrom_mem(_i2c_address, 0x0F, 1)[0] # WHO_AM_I register + if chip_id == 0x6A: # WSEN_ISDS WHO_AM_I + _imu_driver = _WsenISDSDriver(_i2c_bus, _i2c_address) + _register_wsen_isds_sensors() + _load_calibration() + return True + except: + pass - print(f"[SensorManager] _ensure_imu_initialized: IMU initialization complete, success={imu_detected}") - return imu_detected + return False def is_available(): @@ -274,11 +247,6 @@ def read_sensor(sensor): time.sleep_ms(retry_delay_ms) continue else: - # Final attempt failed or different error - if attempt == max_retries - 1: - print(f"[SensorManager] Error reading sensor {sensor.name} after {max_retries} retries: {e}") - else: - print(f"[SensorManager] Error reading sensor {sensor.name}: {e}") return None return None @@ -300,48 +268,31 @@ def calibrate_sensor(sensor, samples=100): Returns: tuple: Calibration offsets (x, y, z) or None if failed """ - print(f"[SensorManager] calibrate_sensor called for {sensor.name} with {samples} samples") _ensure_imu_initialized() if not is_available() or sensor is None: - print("[SensorManager] calibrate_sensor: sensor not available") return None - print("[SensorManager] calibrate_sensor: acquiring lock...") if _lock: _lock.acquire() - print("[SensorManager] calibrate_sensor: lock acquired") try: - offsets = None if sensor.type == TYPE_ACCELEROMETER: - print(f"[SensorManager] Calling _imu_driver.calibrate_accelerometer({samples})...") offsets = _imu_driver.calibrate_accelerometer(samples) - print(f"[SensorManager] Accelerometer calibrated: {offsets}") elif sensor.type == TYPE_GYROSCOPE: - print(f"[SensorManager] Calling _imu_driver.calibrate_gyroscope({samples})...") offsets = _imu_driver.calibrate_gyroscope(samples) - print(f"[SensorManager] Gyroscope calibrated: {offsets}") else: - print(f"[SensorManager] Sensor type {sensor.type} does not support calibration") return None - # Save calibration if offsets: - print("[SensorManager] Saving calibration...") _save_calibration() - print("[SensorManager] Calibration saved") return offsets except Exception as e: - print(f"[SensorManager] Error calibrating sensor {sensor.name}: {e}") - import sys - sys.print_exception(e) + print(f"[SensorManager] Calibration error: {e}") return None finally: - print("[SensorManager] calibrate_sensor: releasing lock...") if _lock: _lock.release() - print("[SensorManager] calibrate_sensor: lock released") # Helper functions for calibration quality checking (module-level to avoid nested def issues) @@ -652,14 +603,10 @@ def read_temperature(self): def calibrate_accelerometer(self, samples): """Calibrate accelerometer (device must be stationary).""" - print(f"[QMI8658Driver] Calibrating accelerometer with {samples} samples...") sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - for i in range(samples): - if i % 10 == 0: - print(f"[QMI8658Driver] Accel sample {i}/{samples}") + for _ in range(samples): ax, ay, az = self.sensor.acceleration - # Convert to m/s² sum_x += ax * _GRAVITY sum_y += ay * _GRAVITY sum_z += az * _GRAVITY @@ -668,19 +615,15 @@ def calibrate_accelerometer(self, samples): # 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 # Expect +1G on Z + self.accel_offset[2] = (sum_z / samples) - _GRAVITY - print(f"[QMI8658Driver] Accelerometer calibration complete: offsets = {tuple(self.accel_offset)}") return tuple(self.accel_offset) def calibrate_gyroscope(self, samples): """Calibrate gyroscope (device must be stationary).""" - print(f"[QMI8658Driver] Calibrating gyroscope with {samples} samples...") sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - for i in range(samples): - if i % 10 == 0: - print(f"[QMI8658Driver] Gyro sample {i}/{samples}") + for _ in range(samples): gx, gy, gz = self.sensor.gyro sum_x += gx sum_y += gy @@ -692,7 +635,6 @@ def calibrate_gyroscope(self, samples): self.gyro_offset[1] = sum_y / samples self.gyro_offset[2] = sum_z / samples - print(f"[QMI8658Driver] Gyroscope calibration complete: offsets = {tuple(self.gyro_offset)}") return tuple(self.gyro_offset) def get_calibration(self): @@ -899,7 +841,6 @@ def _load_calibration(): gyro_offsets = prefs_old.get_list("gyro_offsets") if accel_offsets or gyro_offsets: - print("[SensorManager] Migrating calibration from old to new location...") # Save to new location editor = prefs_new.edit() if accel_offsets: @@ -907,23 +848,20 @@ def _load_calibration(): if gyro_offsets: editor.put_list("gyro_offsets", gyro_offsets) editor.commit() - print("[SensorManager] Migration complete") if accel_offsets or gyro_offsets: _imu_driver.set_calibration(accel_offsets, gyro_offsets) - print(f"[SensorManager] Loaded calibration: accel={accel_offsets}, gyro={gyro_offsets}") - except Exception as e: - print(f"[SensorManager] Failed to load calibration: {e}") + except: + pass def _save_calibration(): - """Save calibration to SharedPreferences (new location).""" + """Save calibration to SharedPreferences.""" if not _imu_driver: return try: from mpos.config import SharedPreferences - # NEW LOCATION: com.micropythonos.settings/sensors.json prefs = SharedPreferences("com.micropythonos.settings", filename="sensors.json") editor = prefs.edit() @@ -931,7 +869,5 @@ def _save_calibration(): editor.put_list("accel_offsets", list(cal['accel_offsets'])) editor.put_list("gyro_offsets", list(cal['gyro_offsets'])) editor.commit() - - print(f"[SensorManager] Saved calibration to settings: accel={cal['accel_offsets']}, gyro={cal['gyro_offsets']}") - except Exception as e: - print(f"[SensorManager] Failed to save calibration: {e}") + except: + pass From e94c8ab08483d8996fa49157700cb6b9a623553c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 09:13:56 +0100 Subject: [PATCH 110/192] More tests --- tests/test_calibration_check_bug.py | 162 +++++++++++++++++++ tests/test_imu_calibration_ui_bug.py | 230 +++++++++++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 tests/test_calibration_check_bug.py create mode 100755 tests/test_imu_calibration_ui_bug.py diff --git a/tests/test_calibration_check_bug.py b/tests/test_calibration_check_bug.py new file mode 100644 index 0000000..14e72d8 --- /dev/null +++ b/tests/test_calibration_check_bug.py @@ -0,0 +1,162 @@ +"""Test for calibration check bug after calibrating. + +Reproduces issue where check_calibration_quality() returns None after calibration. +""" +import unittest +import sys + +# Mock hardware before importing SensorManager +class MockI2C: + def __init__(self, bus_id, sda=None, scl=None): + self.bus_id = bus_id + self.sda = sda + self.scl = scl + self.memory = {} + + def readfrom_mem(self, addr, reg, nbytes): + if addr not in self.memory: + raise OSError("I2C device not found") + if reg not in self.memory[addr]: + return bytes([0] * nbytes) + return bytes(self.memory[addr][reg]) + + def writeto_mem(self, addr, reg, data): + if addr not in self.memory: + self.memory[addr] = {} + self.memory[addr][reg] = list(data) + + +class MockQMI8658: + def __init__(self, i2c_bus, address=0x6B, accel_scale=0b10, gyro_scale=0b100): + self.i2c = i2c_bus + self.address = address + self.accel_scale = accel_scale + self.gyro_scale = gyro_scale + + @property + def temperature(self): + return 25.5 + + @property + def acceleration(self): + return (0.0, 0.0, 1.0) # At rest, Z-axis = 1G + + @property + def gyro(self): + return (0.0, 0.0, 0.0) # Stationary + + +# Mock constants +_QMI8685_PARTID = 0x05 +_REG_PARTID = 0x00 +_ACCELSCALE_RANGE_8G = 0b10 +_GYROSCALE_RANGE_256DPS = 0b100 + +# Create mock modules +mock_machine = type('module', (), { + 'I2C': MockI2C, + 'Pin': type('Pin', (), {}) +})() + +mock_qmi8658 = type('module', (), { + 'QMI8658': MockQMI8658, + '_QMI8685_PARTID': _QMI8685_PARTID, + '_REG_PARTID': _REG_PARTID, + '_ACCELSCALE_RANGE_8G': _ACCELSCALE_RANGE_8G, + '_GYROSCALE_RANGE_256DPS': _GYROSCALE_RANGE_256DPS +})() + +def _mock_mcu_temperature(*args, **kwargs): + return 42.0 + +mock_esp32 = type('module', (), { + 'mcu_temperature': _mock_mcu_temperature +})() + +# Inject mocks +sys.modules['machine'] = mock_machine +sys.modules['mpos.hardware.drivers.qmi8658'] = mock_qmi8658 +sys.modules['esp32'] = mock_esp32 + +try: + import _thread +except ImportError: + mock_thread = type('module', (), { + 'allocate_lock': lambda: type('lock', (), { + 'acquire': lambda self: None, + 'release': lambda self: None + })() + })() + sys.modules['_thread'] = mock_thread + +# Now import the module to test +import mpos.sensor_manager as SensorManager + + +class TestCalibrationCheckBug(unittest.TestCase): + """Test case for calibration check bug.""" + + def setUp(self): + """Set up test fixtures before each test.""" + # Reset SensorManager state + SensorManager._initialized = False + SensorManager._imu_driver = None + SensorManager._sensor_list = [] + SensorManager._has_mcu_temperature = False + + # Create mock I2C bus with QMI8658 + self.i2c_bus = MockI2C(0, sda=48, scl=47) + self.i2c_bus.memory[0x6B] = {_REG_PARTID: [_QMI8685_PARTID]} + + def test_check_quality_after_calibration(self): + """Test that check_calibration_quality() works after calibration. + + This reproduces the bug where check_calibration_quality() returns + None or shows "--" after performing calibration. + """ + # Initialize + SensorManager.init(self.i2c_bus, address=0x6B) + + # Step 1: Check calibration quality BEFORE calibration (should work) + print("\n=== Step 1: Check quality BEFORE calibration ===") + quality_before = SensorManager.check_calibration_quality(samples=10) + self.assertIsNotNone(quality_before, "Quality check BEFORE calibration should return data") + self.assertIn('quality_score', quality_before) + print(f"Quality before: {quality_before['quality_rating']} ({quality_before['quality_score']:.2f})") + + # Step 2: Calibrate sensors + print("\n=== Step 2: Calibrate sensors ===") + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + self.assertIsNotNone(accel, "Accelerometer should be available") + self.assertIsNotNone(gyro, "Gyroscope should be available") + + accel_offsets = SensorManager.calibrate_sensor(accel, samples=10) + print(f"Accel offsets: {accel_offsets}") + self.assertIsNotNone(accel_offsets, "Accelerometer calibration should succeed") + + gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=10) + print(f"Gyro offsets: {gyro_offsets}") + self.assertIsNotNone(gyro_offsets, "Gyroscope calibration should succeed") + + # Step 3: Check calibration quality AFTER calibration (BUG: returns None) + print("\n=== Step 3: Check quality AFTER calibration ===") + quality_after = SensorManager.check_calibration_quality(samples=10) + self.assertIsNotNone(quality_after, "Quality check AFTER calibration should return data (BUG: returns None)") + self.assertIn('quality_score', quality_after) + print(f"Quality after: {quality_after['quality_rating']} ({quality_after['quality_score']:.2f})") + + # Verify sensor reads still work + print("\n=== Step 4: Verify sensor reads still work ===") + accel_data = SensorManager.read_sensor(accel) + self.assertIsNotNone(accel_data, "Accelerometer should still be readable") + print(f"Accel data: {accel_data}") + + gyro_data = SensorManager.read_sensor(gyro) + self.assertIsNotNone(gyro_data, "Gyroscope should still be readable") + print(f"Gyro data: {gyro_data}") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_imu_calibration_ui_bug.py b/tests/test_imu_calibration_ui_bug.py new file mode 100755 index 0000000..59e55d7 --- /dev/null +++ b/tests/test_imu_calibration_ui_bug.py @@ -0,0 +1,230 @@ +#!/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) From 7a8cc9235060d09164ab9a760a565819c6659c33 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 09:17:24 +0100 Subject: [PATCH 111/192] Fix unit tests --- .../assets/check_imu_calibration.py | 15 +---- tests/test_graphical_imu_calibration.py | 59 ++++++++----------- 2 files changed, 27 insertions(+), 47 deletions(-) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py index b7cf7b2..10d7956 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py @@ -42,7 +42,6 @@ def onCreate(self): def onResume(self, screen): super().onResume(screen) - print(f"[CheckIMU] onResume called, is_desktop={self.is_desktop}") # Clear the screen and recreate UI (to avoid stale widget references) screen.clean() @@ -132,16 +131,13 @@ def onResume(self, screen): # Check if IMU is available if not self.is_desktop and not SensorManager.is_available(): - print("[CheckIMU] IMU not available, stopping") self.status_label.set_text("IMU not available on this device") self.quality_score_label.set_text("N/A") return # Start real-time updates - print("[CheckIMU] Starting real-time updates") self.updating = True self.update_timer = lv.timer_create(self.update_display, self.UPDATE_INTERVAL, None) - print(f"[CheckIMU] Timer created: {self.update_timer}") def onPause(self, screen): # Stop updates @@ -206,16 +202,7 @@ def update_display(self, timer=None): self.status_label.set_text("Real-time monitoring (place on flat surface)") except Exception as e: - # Log the actual error for debugging - print(f"[CheckIMU] Error in update_display: {e}") - import sys - sys.print_exception(e) - # If widgets were deleted (activity closed), stop updating - try: - self.status_label.set_text(f"Error: {str(e)}") - except: - # Widgets really were deleted - pass + # If widgets were deleted (activity closed), stop updating silently self.updating = False def get_mock_quality(self): diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py index 56087a1..8447154 100644 --- a/tests/test_graphical_imu_calibration.py +++ b/tests/test_graphical_imu_calibration.py @@ -130,37 +130,19 @@ def test_calibrate_activity_flow(self): simulate_click(coords['center_x'], coords['center_y']) wait_for_render(30) - # Verify activity loaded + # Verify activity loaded and shows instructions screen = lv.screen_active() + print_screen_labels(screen) self.assertTrue(verify_text_present(screen, "IMU Calibration"), "CalibrateIMUActivity title not found") + self.assertTrue(verify_text_present(screen, "Place device on flat"), + "Instructions not shown") # Capture initial state screenshot_path = f"{self.screenshot_dir}/calibrate_imu_01_initial.raw" capture_screenshot(screenshot_path) - # Step 1: Click "Check Quality" button - check_btn = find_button_with_text(screen, "Check Quality") - self.assertIsNotNone(check_btn, "Could not find 'Check Quality' button") - coords = get_widget_coords(check_btn) - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(10) - - # Wait for quality check to complete (mock is fast) - time.sleep(2.5) # Allow thread to complete - wait_for_render(15) - - # Verify quality check completed - screen = lv.screen_active() - print_screen_labels(screen) - self.assertTrue(verify_text_present(screen, "Current calibration:"), - "Quality check results not shown") - - # Capture after quality check - screenshot_path = f"{self.screenshot_dir}/calibrate_imu_02_quality.raw" - capture_screenshot(screenshot_path) - - # Step 2: Click "Calibrate Now" button + # 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) @@ -168,18 +150,22 @@ def test_calibrate_activity_flow(self): wait_for_render(10) # Wait for calibration to complete (mock takes ~3 seconds) - time.sleep(4.0) - wait_for_render(15) + time.sleep(3.5) + wait_for_render(20) # Verify calibration completed screen = lv.screen_active() print_screen_labels(screen) - self.assertTrue(verify_text_present(screen, "Calibration successful!") or - verify_text_present(screen, "Calibration complete!"), + self.assertTrue(verify_text_present(screen, "Calibration successful!"), "Calibration completion message not found") + # Verify offsets are shown + self.assertTrue(verify_text_present(screen, "Accel offsets") or + verify_text_present(screen, "offsets"), + "Calibration offsets not shown") + # Capture completion state - screenshot_path = f"{self.screenshot_dir}/calibrate_imu_03_complete.raw" + screenshot_path = f"{self.screenshot_dir}/calibrate_imu_02_complete.raw" capture_screenshot(screenshot_path) print("=== CalibrateIMUActivity flow test complete ===") @@ -203,18 +189,25 @@ def test_navigation_from_check_to_calibrate(self): simulate_click(coords['center_x'], coords['center_y']) wait_for_render(30) # Wait for real-time updates - # Click "Calibrate" button + # Verify Check activity loaded screen = lv.screen_active() + self.assertTrue(verify_text_present(screen, "IMU Calibration Check"), + "Check activity did not load") + + # Click "Calibrate" button to navigate to Calibrate activity calibrate_btn = find_button_with_text(screen, "Calibrate") self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate' button") - coords = get_widget_coords(calibrate_btn) - simulate_click(coords['center_x'], coords['center_y']) - wait_for_render(15) + # Use send_event instead of simulate_click (more reliable for navigation) + calibrate_btn.send_event(lv.EVENT.CLICKED, None) + wait_for_render(30) # Verify CalibrateIMUActivity loaded screen = lv.screen_active() - self.assertTrue(verify_text_present(screen, "Check Quality"), + print_screen_labels(screen) + self.assertTrue(verify_text_present(screen, "Calibrate Now"), "Did not navigate to CalibrateIMUActivity") + self.assertTrue(verify_text_present(screen, "Place device on flat"), + "CalibrateIMUActivity instructions not shown") print("=== Navigation test complete ===") From f61ca5632d5ebea9dab06ca4a49ab2556b39d968 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 09:18:32 +0100 Subject: [PATCH 112/192] Remove debug --- .../com.micropythonos.settings/assets/check_imu_calibration.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py index 10d7956..d727cb2 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py @@ -238,11 +238,8 @@ def get_mock_quality(self): def start_calibration(self, event): """Navigate to calibration activity.""" - print("[CheckIMU] start_calibration called!") from mpos.content.intent import Intent from calibrate_imu import CalibrateIMUActivity intent = Intent(activity_class=CalibrateIMUActivity) - print("[CheckIMU] Starting CalibrateIMUActivity...") self.startActivity(intent) - print("[CheckIMU] startActivity returned") From e581843469b47b06d45265f064ee0a5e584433f6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 14:43:22 +0100 Subject: [PATCH 113/192] SensorManager: add mounted_position to IMUs --- .../assets/calibrate_imu.py | 10 ++- .../assets/check_imu_calibration.py | 63 +++++++++++++------ .../lib/mpos/board/fri3d_2024.py | 2 +- .../lib/mpos/sensor_manager.py | 14 ++++- 4 files changed, 63 insertions(+), 26 deletions(-) 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 45d67c1..a0b67a8 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 @@ -49,16 +49,19 @@ def onCreate(self): screen.set_style_pad_all(mpos.ui.pct_of_display_width(3), 0) screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) screen.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(screen) # Title self.title_label = lv.label(screen) self.title_label.set_text("IMU Calibration") - self.title_label.set_style_text_font(lv.font_montserrat_20, 0) + self.title_label.set_style_text_font(lv.font_montserrat_16, 0) # Status label self.status_label = lv.label(screen) self.status_label.set_text("Initializing...") - self.status_label.set_style_text_font(lv.font_montserrat_16, 0) + 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)) @@ -71,7 +74,7 @@ def onCreate(self): # Detail label (for additional info) self.detail_label = lv.label(screen) self.detail_label.set_text("") - self.detail_label.set_style_text_font(lv.font_montserrat_12, 0) + self.detail_label.set_style_text_font(lv.font_montserrat_10, 0) self.detail_label.set_style_text_color(lv.color_hex(0x888888), 0) self.detail_label.set_long_mode(lv.label.LONG_MODE.WRAP) self.detail_label.set_width(lv.pct(90)) @@ -82,6 +85,7 @@ def onCreate(self): btn_cont.set_height(lv.SIZE_CONTENT) btn_cont.set_style_border_width(0, 0) btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + btn_cont.set_style_pad_all(1,0) btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) # Action button diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py index d727cb2..097aa75 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py @@ -36,8 +36,12 @@ def __init__(self): def onCreate(self): screen = lv.obj() - screen.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0) + screen.set_style_pad_all(mpos.ui.pct_of_display_width(1), 0) + #screen.set_style_pad_all(0, 0) screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(screen) self.setContentView(screen) def onResume(self, screen): @@ -50,11 +54,6 @@ def onResume(self, screen): self.accel_labels = [] self.gyro_labels = [] - # Title - title = lv.label(screen) - title.set_text("IMU Calibration Check") - title.set_style_text_font(lv.font_montserrat_20, 0) - # Status label self.status_label = lv.label(screen) self.status_label.set_text("Checking...") @@ -68,34 +67,57 @@ def onResume(self, screen): # Quality score (large, prominent) self.quality_score_label = lv.label(screen) self.quality_score_label.set_text("Quality: --") - self.quality_score_label.set_style_text_font(lv.font_montserrat_20, 0) + self.quality_score_label.set_style_text_font(lv.font_montserrat_16, 0) + + data_cont = lv.obj(screen) + data_cont.set_width(lv.pct(100)) + data_cont.set_height(lv.SIZE_CONTENT) + data_cont.set_style_pad_all(0, 0) + data_cont.set_style_bg_opa(lv.OPA.TRANSP, 0) + data_cont.set_style_border_width(0, 0) + data_cont.set_flex_flow(lv.FLEX_FLOW.ROW) + data_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) # Accelerometer section - accel_title = lv.label(screen) - accel_title.set_text("Accelerometer (m/s²)") - accel_title.set_style_text_font(lv.font_montserrat_14, 0) + acc_cont = lv.obj(data_cont) + acc_cont.set_height(lv.SIZE_CONTENT) + acc_cont.set_width(lv.pct(45)) + acc_cont.set_style_border_width(0, 0) + acc_cont.set_style_pad_all(0, 0) + acc_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + accel_title = lv.label(acc_cont) + accel_title.set_text("Accel. (m/s^2)") + accel_title.set_style_text_font(lv.font_montserrat_12, 0) for axis in ['X', 'Y', 'Z']: - label = lv.label(screen) + label = lv.label(acc_cont) label.set_text(f"{axis}: --") - label.set_style_text_font(lv.font_montserrat_12, 0) + label.set_style_text_font(lv.font_montserrat_10, 0) self.accel_labels.append(label) # Gyroscope section - gyro_title = lv.label(screen) - gyro_title.set_text("Gyroscope (deg/s)") - gyro_title.set_style_text_font(lv.font_montserrat_14, 0) + gyro_cont = lv.obj(data_cont) + gyro_cont.set_width(mpos.ui.pct_of_display_width(45)) + gyro_cont.set_height(lv.SIZE_CONTENT) + gyro_cont.set_style_border_width(0, 0) + gyro_cont.set_style_pad_all(0, 0) + gyro_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + gyro_title = lv.label(gyro_cont) + gyro_title.set_text("Gyro (deg/s)") + gyro_title.set_style_text_font(lv.font_montserrat_12, 0) for axis in ['X', 'Y', 'Z']: - label = lv.label(screen) + label = lv.label(gyro_cont) label.set_text(f"{axis}: --") - label.set_style_text_font(lv.font_montserrat_12, 0) + label.set_style_text_font(lv.font_montserrat_10, 0) self.gyro_labels.append(label) # Separator - sep2 = lv.obj(screen) - sep2.set_size(lv.pct(100), 2) - sep2.set_style_bg_color(lv.color_hex(0x666666), 0) + #sep2 = lv.obj(screen) + #sep2.set_size(lv.pct(100), 2) + #sep2.set_style_bg_color(lv.color_hex(0x666666), 0) # Issues label self.issues_label = lv.label(screen) @@ -107,6 +129,7 @@ def onResume(self, screen): # Button container btn_cont = lv.obj(screen) + btn_cont.set_style_pad_all(5, 0) btn_cont.set_width(lv.pct(100)) btn_cont.set_height(lv.SIZE_CONTENT) btn_cont.set_style_border_width(0, 0) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 0a510c4..88f7e13 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -323,7 +323,7 @@ def adc_to_voltage(adc_value): # Create I2C bus for IMU (different pins from display) from machine import I2C imu_i2c = I2C(0, sda=Pin(9), scl=Pin(18)) -SensorManager.init(imu_i2c, address=0x6B) +SensorManager.init(imu_i2c, address=0x6B, mounted_position=SensorManager.FACING_EARTH) print("Fri3d hardware: Audio, LEDs, and sensors initialized") diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index b71a382..ce9cf6b 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -25,6 +25,7 @@ except ImportError: _lock = None + # Sensor type constants (matching Android SensorManager) TYPE_ACCELEROMETER = 1 # Units: m/s² (meters per second squared) TYPE_GYROSCOPE = 4 # Units: deg/s (degrees per second) @@ -32,6 +33,10 @@ TYPE_IMU_TEMPERATURE = 14 # Units: °C (IMU chip temperature) TYPE_SOC_TEMPERATURE = 15 # Units: °C (MCU/SoC internal temperature) +# mounted_position: +FACING_EARTH = 20 # underside of PCB, like fri3d_2024 +FACING_SKY = 21 # top of PCB, like waveshare_esp32_s3_lcd_touch_2 (default) + # Gravity constant for unit conversions _GRAVITY = 9.80665 # m/s² @@ -41,6 +46,7 @@ _sensor_list = [] _i2c_bus = None _i2c_address = None +_mounted_position = FACING_SKY _has_mcu_temperature = False @@ -71,7 +77,7 @@ def __repr__(self): return f"Sensor({self.name}, type={self.type})" -def init(i2c_bus, address=0x6B): +def init(i2c_bus, address=0x6B, mounted_position=FACING_SKY): """Initialize SensorManager. MCU temperature initializes immediately, IMU initializes on first use. Args: @@ -85,6 +91,7 @@ def init(i2c_bus, address=0x6B): _i2c_bus = i2c_bus _i2c_address = address + _mounted_position = mounted_position # Initialize MCU temperature sensor immediately (fast, no I2C needed) try: @@ -218,7 +225,10 @@ def read_sensor(sensor): try: if sensor.type == TYPE_ACCELEROMETER: if _imu_driver: - return _imu_driver.read_acceleration() + ax, ay, az = _imu_driver.read_acceleration() + if _mounted_position == SensorManager.FACING_EARTH: + az += _GRAVITY + return (ax, ay, az) elif sensor.type == TYPE_GYROSCOPE: if _imu_driver: return _imu_driver.read_gyroscope() From 219f55f3106674e5d2b85969e0910d2e0ecff3f1 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 14:56:56 +0100 Subject: [PATCH 114/192] IMU: fix mounted_position handling --- .../com.micropythonos.settings/assets/calibrate_imu.py | 9 ++++----- internal_filesystem/lib/mpos/board/linux.py | 2 +- internal_filesystem/lib/mpos/sensor_manager.py | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) 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 a0b67a8..bd43fc9 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 @@ -85,7 +85,6 @@ def onCreate(self): btn_cont.set_height(lv.SIZE_CONTENT) btn_cont.set_style_border_width(0, 0) btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW) - btn_cont.set_style_pad_all(1,0) btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0) # Action button @@ -135,7 +134,7 @@ def update_ui_for_state(self): """Update UI based on current state.""" if self.current_state == CalibrationState.READY: self.status_label.set_text("Place device on flat, stable surface\n\nKeep device completely still during calibration") - self.detail_label.set_text("Calibration will take ~2 seconds\nUI will freeze during calibration") + self.detail_label.set_text("Calibration will take ~1 seconds\nUI will freeze during calibration") self.action_button_label.set_text("Calibrate Now") self.action_button.remove_state(lv.STATE.DISABLED) self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) @@ -187,7 +186,7 @@ def start_calibration_process(self): if self.is_desktop: stationarity = {'is_stationary': True, 'message': 'Mock: Stationary'} else: - stationarity = SensorManager.check_stationarity(samples=30) + stationarity = SensorManager.check_stationarity(samples=25) if stationarity is None or not stationarity['is_stationary']: msg = stationarity['message'] if stationarity else "Stationarity check failed" @@ -206,12 +205,12 @@ def start_calibration_process(self): gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) if accel: - accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) + accel_offsets = SensorManager.calibrate_sensor(accel, samples=50) else: accel_offsets = None if gyro: - gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) + gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=50) else: gyro_offsets = None diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index a82a12c..0b05556 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -116,7 +116,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) +SensorManager.init(None, mounted_position=SensorManager.FACING_EARTH) print("linux.py finished") diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index ce9cf6b..cf10b70 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -87,7 +87,7 @@ def init(i2c_bus, address=0x6B, mounted_position=FACING_SKY): Returns: bool: True if initialized successfully """ - global _i2c_bus, _i2c_address, _initialized, _has_mcu_temperature + global _i2c_bus, _i2c_address, _initialized, _has_mcu_temperature, _mounted_position _i2c_bus = i2c_bus _i2c_address = address @@ -226,7 +226,7 @@ def read_sensor(sensor): if sensor.type == TYPE_ACCELEROMETER: if _imu_driver: ax, ay, az = _imu_driver.read_acceleration() - if _mounted_position == SensorManager.FACING_EARTH: + if _mounted_position == FACING_EARTH: az += _GRAVITY return (ax, ay, az) elif sensor.type == TYPE_GYROSCOPE: From f74838bb83b75609c2dfb64db5eaab4a34ae7e4d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 14:58:08 +0100 Subject: [PATCH 115/192] IMU Calibration: remove useless progress bar --- .../assets/calibrate_imu.py | 12 ------------ 1 file changed, 12 deletions(-) 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 bd43fc9..009a2e7 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 @@ -34,7 +34,6 @@ class CalibrateIMUActivity(Activity): # Widgets title_label = None status_label = None - progress_bar = None detail_label = None action_button = None action_button_label = None @@ -65,12 +64,6 @@ def onCreate(self): self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) self.status_label.set_width(lv.pct(90)) - # Progress bar (hidden initially) - self.progress_bar = lv.bar(screen) - self.progress_bar.set_size(lv.pct(90), 20) - self.progress_bar.set_value(0, False) - self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) - # Detail label (for additional info) self.detail_label = lv.label(screen) self.detail_label.set_text("") @@ -137,29 +130,24 @@ def update_ui_for_state(self): self.detail_label.set_text("Calibration will take ~1 seconds\nUI will freeze during calibration") self.action_button_label.set_text("Calibrate Now") self.action_button.remove_state(lv.STATE.DISABLED) - self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) self.cancel_button.remove_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.CALIBRATING: self.status_label.set_text("Calibrating IMU...") self.detail_label.set_text("Do not move device!") self.action_button.add_state(lv.STATE.DISABLED) - self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) - self.progress_bar.set_value(50, True) self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.COMPLETE: # Status text will be set by calibration results self.action_button_label.set_text("Done") self.action_button.remove_state(lv.STATE.DISABLED) - self.progress_bar.set_value(100, True) self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) elif self.current_state == CalibrationState.ERROR: # Status text will be set by error handler self.action_button_label.set_text("Retry") self.action_button.remove_state(lv.STATE.DISABLED) - self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN) self.cancel_button.add_flag(lv.obj.FLAG.HIDDEN) def action_button_clicked(self, event): From 41db1b0fef4f2fa235a0432c099016ff5584ded4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 15:11:47 +0100 Subject: [PATCH 116/192] Fix failing unit tests --- tests/test_graphical_imu_calibration.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py index 8447154..be761b3 100644 --- a/tests/test_graphical_imu_calibration.py +++ b/tests/test_graphical_imu_calibration.py @@ -37,7 +37,7 @@ def setUp(self): if sys.platform == "esp32": self.screenshot_dir = "tests/screenshots" else: - self.screenshot_dir = "/home/user/MicroPythonOS/tests/screenshots" + self.screenshot_dir = "../tests/screenshots" # it runs from internal_filesystem/ # Ensure directory exists try: @@ -79,22 +79,12 @@ def test_check_calibration_activity_loads(self): simulate_click(coords['center_x'], coords['center_y']) wait_for_render(30) - # Verify CheckIMUCalibrationActivity loaded - screen = lv.screen_active() - self.assertTrue(verify_text_present(screen, "IMU Calibration Check"), - "CheckIMUCalibrationActivity title not found") - - # Wait for real-time updates to populate - wait_for_render(20) - # Verify key elements are present + screen = lv.screen_active() print_screen_labels(screen) - self.assertTrue(verify_text_present(screen, "Quality:"), - "Quality label not found") - self.assertTrue(verify_text_present(screen, "Accelerometer"), - "Accelerometer label not found") - self.assertTrue(verify_text_present(screen, "Gyroscope"), - "Gyroscope label not found") + self.assertTrue(verify_text_present(screen, "Quality:"), "Quality label not found") + self.assertTrue(verify_text_present(screen, "Accel."), "Accel. label not found") + self.assertTrue(verify_text_present(screen, "Gyro"), "Gyro label not found") # Capture screenshot screenshot_path = f"{self.screenshot_dir}/check_imu_calibration.raw" @@ -191,8 +181,7 @@ def test_navigation_from_check_to_calibrate(self): # Verify Check activity loaded screen = lv.screen_active() - self.assertTrue(verify_text_present(screen, "IMU Calibration Check"), - "Check activity did not load") + self.assertTrue(verify_text_present(screen, "on flat surface"), "Check activity did not load") # Click "Calibrate" button to navigate to Calibrate activity calibrate_btn = find_button_with_text(screen, "Calibrate") From c60712f97d3516a9186977521d5a2af279544371 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 15:43:28 +0100 Subject: [PATCH 117/192] WSEN-ISDS: add support for temperature sensor --- CHANGELOG.md | 3 ++- .../lib/mpos/hardware/drivers/wsen_isds.py | 20 +++++++++++++++++++ .../lib/mpos/sensor_manager.py | 13 +++++++++--- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc2681..bf479db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,14 @@ - 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 - About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name - Camera app: massive overhaul! diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py index 97cf7d0..f29f1c2 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 @@ -354,6 +356,20 @@ def read_angular_velocities(self): return g_x, g_y, g_z + @property + def temperature(self) -> float: + temp_raw = self._read_raw_temperature() + return ((temp_raw / 256.0) + 25.0) + + def _read_raw_temperature(self): + """Read raw temperature data.""" + if not self._temp_data_ready(): + raise Exception("temp sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_TEMP_OUT_L, 2) + raw_temp = self._convert_from_raw(raw[0], raw[1]) + return raw_temp + def _read_raw_angular_velocities(self): """Read raw gyroscope data.""" if not self._gyro_data_ready(): @@ -420,6 +436,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/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index cf10b70..ce2d8b3 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -697,9 +697,7 @@ def read_gyroscope(self): ) 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.""" @@ -807,6 +805,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 ) ] From 169d1cccb1c7cbf5efe26ef5ded8031829676c7f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 15:46:48 +0100 Subject: [PATCH 118/192] Cleanup --- internal_filesystem/lib/mpos/board/linux.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 0b05556..a82a12c 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -116,7 +116,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") From d720e3be3274077d3ca0e84b8c5283e97b37e9b5 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 7 Dec 2025 16:42:41 +0100 Subject: [PATCH 119/192] Tweak settings and boards --- .../com.micropythonos.settings/assets/settings.py | 9 +++++---- internal_filesystem/lib/mpos/board/fri3d_2024.py | 2 +- .../lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py | 12 +++--------- 3 files changed, 9 insertions(+), 14 deletions(-) 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 8dac942..4687430 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": "Recalibrate 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/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 88f7e13..b1c33dd 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -386,4 +386,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/waveshare_esp32_s3_touch_lcd_2.py b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py index 096e64c..e1fada4 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 @@ -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(device_type=AudioFlinger.DEVICE_NULL) # === LED HARDWARE === # Note: Waveshare board has no NeoPixel LEDs @@ -133,4 +127,4 @@ def adc_to_voltage(adc_value): # i2c_bus was created on line 75 for touch, reuse it for IMU SensorManager.init(i2c_bus, address=0x6B) -print("boot.py finished") +print("waveshare_esp32_s3_touch_lcd_2.py finished") From 8b6883880a7b9a6aabe839f2a0d7b9ecc1289c88 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 09:00:57 +0100 Subject: [PATCH 120/192] SensorManager: improve calibration (not perfect yet) --- .../lib/mpos/sensor_manager.py | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index ce2d8b3..12b8cf6 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 @@ -702,12 +707,17 @@ def read_temperature(self): def calibrate_accelerometer(self, samples): """Calibrate accelerometer using hardware calibration.""" self.sensor.acc_calibrate(samples) + return_x = (self.sensor.acc_offset_x * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY + return_y = (self.sensor.acc_offset_y * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY + return_z = (self.sensor.acc_offset_z * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY + print(f"normal return_z: {return_z}") + if _mounted_position == FACING_EARTH: + return_z *= -1 + print(f"sensor is facing earth so returning inverse: {return_z}") + return_z -= _GRAVITY + print(f"returning: {return_x},{return_y},{return_z}") # 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 - ) + return (return_x, return_y, return_z) def calibrate_gyroscope(self, samples): """Calibrate gyroscope using hardware calibration.""" @@ -847,25 +857,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: @@ -879,7 +874,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() From 141fc208367d3f9172f00525847d46b095fe0ea4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 09:56:54 +0100 Subject: [PATCH 121/192] Fix WSEN-ISDS calibration --- .../lib/mpos/hardware/drivers/wsen_isds.py | 16 ++-- .../lib/mpos/sensor_manager.py | 86 +++++++++++-------- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py index f29f1c2..c9d08db 100644 --- a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -299,9 +299,9 @@ def read_accelerations(self): """ 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 + a_x = (raw_a_x - self.acc_offset_x) + a_y = (raw_a_y - self.acc_offset_y) + a_z = (raw_a_z - self.acc_offset_z) return a_x, a_y, a_z @@ -316,7 +316,7 @@ 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. @@ -350,9 +350,9 @@ def read_angular_velocities(self): """ 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 + g_x = (raw_g_x - self.gyro_offset_x) + g_y = (raw_g_y - self.gyro_offset_y) + g_z = (raw_g_z - self.gyro_offset_z) return g_x, g_y, g_z @@ -381,7 +381,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 + return raw_g_x * self.gyro_sensitivity, raw_g_y * self.gyro_sensitivity, raw_g_z * self.gyro_sensitivity def read_angular_velocities_accelerations(self): """Read both gyroscope and accelerometer in one call. diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 12b8cf6..6efb1f3 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -680,6 +680,9 @@ 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).""" @@ -705,55 +708,62 @@ def read_temperature(self): return self.sensor.temperature def calibrate_accelerometer(self, samples): - """Calibrate accelerometer using hardware calibration.""" - self.sensor.acc_calibrate(samples) - return_x = (self.sensor.acc_offset_x * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY - return_y = (self.sensor.acc_offset_y * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY - return_z = (self.sensor.acc_offset_z * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY - print(f"normal return_z: {return_z}") + """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: - return_z *= -1 - print(f"sensor is facing earth so returning inverse: {return_z}") - return_z -= _GRAVITY - print(f"returning: {return_x},{return_y},{return_z}") - # Return offsets in m/s² (convert from raw offsets) - return (return_x, return_y, return_z) + sum_z *= -1 + z_offset = (1000 / samples) + _GRAVITY + 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 - z_offset + 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 + sum_y += gy + sum_z += gz + time.sleep_ms(10) + + # Average offsets (should be 0 when stationary) + self.gyro_offset[0] = sum_x / samples + self.gyro_offset[1] = sum_y / samples + self.gyro_offset[2] = sum_z / samples + + return tuple(self.gyro_offset) def get_calibration(self): - """Get current calibration (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) # ============================================================================ From e3c461fd94345c96ce114e5cbd8a9a7d1a20b83a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 10:49:12 +0100 Subject: [PATCH 122/192] Waveshare IMU is also facing down --- .../lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e1fada4..ef2b06d 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 @@ -125,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("waveshare_esp32_s3_touch_lcd_2.py finished") From 3cd1e79f9d3740df9012066532b142eb359c4ec0 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 10:49:28 +0100 Subject: [PATCH 123/192] SensorManager: simplify IMU --- .../lib/mpos/hardware/drivers/wsen_isds.py | 90 ------------------- 1 file changed, 90 deletions(-) diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py index c9d08db..e5ef79a 100644 --- a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -145,12 +145,6 @@ def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", 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) @@ -254,30 +248,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 = { @@ -318,29 +288,6 @@ def _read_raw_accelerations(self): 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) - - self.gyro_offset_x //= samples - self.gyro_offset_y //= samples - self.gyro_offset_z //= samples def read_angular_velocities(self): """Read calibrated gyroscope data. @@ -383,43 +330,6 @@ def _read_raw_angular_velocities(self): return raw_g_x * self.gyro_sensitivity, raw_g_y * self.gyro_sensitivity, raw_g_z * self.gyro_sensitivity - 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 - @staticmethod def _convert_from_raw(b_l, b_h): """Convert two bytes (little-endian) to signed 16-bit integer.""" From dadf4e8f4fb68b467954ed2b702ea600568344c3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 11:04:00 +0100 Subject: [PATCH 124/192] SensorManager: cleanup calibration --- .../assets/calibrate_imu.py | 2 +- .../lib/mpos/hardware/drivers/wsen_isds.py | 34 ------------------- .../lib/mpos/sensor_manager.py | 31 +++++++++-------- 3 files changed, 17 insertions(+), 50 deletions(-) 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 009a2e7..4dfcfb4 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) diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py index e5ef79a..7f6f7be 100644 --- a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -133,15 +133,9 @@ 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 @@ -261,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) - a_y = (raw_a_y - self.acc_offset_y) - a_z = (raw_a_z - self.acc_offset_z) - - return a_x, a_y, a_z - def _read_raw_accelerations(self): """Read raw accelerometer data.""" if not self._acc_data_ready(): @@ -289,20 +269,6 @@ def _read_raw_accelerations(self): return raw_a_x * self.acc_sensitivity, raw_a_y * self.acc_sensitivity, raw_a_z * self.acc_sensitivity - 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) - g_y = (raw_g_y - self.gyro_offset_y) - g_z = (raw_g_z - self.gyro_offset_z) - - return g_x, g_y, g_z - @property def temperature(self) -> float: temp_raw = self._read_raw_temperature() diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 6efb1f3..ccd8fdb 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -684,24 +684,26 @@ def __init__(self, i2c_bus, address): 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): @@ -722,13 +724,12 @@ def calibrate_accelerometer(self, samples): z_offset = 0 if _mounted_position == FACING_EARTH: sum_z *= -1 - z_offset = (1000 / samples) + _GRAVITY 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 - z_offset + self.accel_offset[2] = (sum_z / samples) - _GRAVITY print(f"offsets: {self.accel_offset}") return tuple(self.accel_offset) @@ -739,9 +740,9 @@ def calibrate_gyroscope(self, samples): for _ in range(samples): gx, gy, gz = self.sensor._read_raw_angular_velocities() - sum_x += gx - sum_y += gy - sum_z += gz + 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) From 79cce1ec11b507edce0f1c9e5537d3cce196c882 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 11:07:50 +0100 Subject: [PATCH 125/192] Style IMU Calibration --- .../apps/com.micropythonos.settings/assets/calibrate_imu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 4dfcfb4..750fa5c 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 @@ -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) From aa449a58e74e8a5b0a2c8c1672598c99e33f4acb Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 11:08:29 +0100 Subject: [PATCH 126/192] Settings: rename label --- .../builtin/apps/com.micropythonos.settings/assets/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4687430..05acca6 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -51,7 +51,7 @@ def __init__(self): #{"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": "Recalibrate IMU", "key": "calibrate_imu", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CalibrateIMUActivity"}, + {"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 From 11867dd74f7455d20d0f8db3b0da66f125bd6bc8 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 11:52:27 +0100 Subject: [PATCH 127/192] Rework tests --- internal_filesystem/lib/mpos/ui/testing.py | 40 ++++ tests/test_graphical_imu_calibration.py | 43 ++-- tests/test_imu_calibration_ui_bug.py | 230 --------------------- 3 files changed, 54 insertions(+), 259 deletions(-) delete mode 100755 tests/test_imu_calibration_ui_bug.py diff --git a/internal_filesystem/lib/mpos/ui/testing.py b/internal_filesystem/lib/mpos/ui/testing.py index dc3fa06..df061f7 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 @@ -579,3 +580,42 @@ def release_timer_cb(timer): # 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): + """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: + print("Scrolling label to view...") + label.scroll_to_view_recursive(True) + wait_for_render(iterations=50) # needs quite a bit of time + 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 diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py index be761b3..3eb84a3 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() @@ -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_imu_calibration_ui_bug.py b/tests/test_imu_calibration_ui_bug.py deleted file mode 100755 index 59e55d7..0000000 --- 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) From 6f3fe0af9fe4d175ffe1e1cce3feb5cfadf69d0e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 12:03:32 +0100 Subject: [PATCH 128/192] Fix test_sensor_manager.py --- internal_filesystem/lib/mpos/sensor_manager.py | 2 ++ tests/test_sensor_manager.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index ccd8fdb..8068c73 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -686,8 +686,10 @@ def __init__(self, i2c_bus, address): def read_acceleration(self): + """Read acceleration in m/s² (converts from mg).""" ax, ay, az = self.sensor._read_raw_accelerations() + # Convert G to m/s² and apply calibration return ( ((ax / 1000) * _GRAVITY) - self.accel_offset[0], diff --git a/tests/test_sensor_manager.py b/tests/test_sensor_manager.py index 1584e22..85e7770 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 From ede56750daa40f3bbdc67fe78dd65e7c41933d82 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 12:09:39 +0100 Subject: [PATCH 129/192] Try to fix macOS build It has a weird "sed" program that doesn't allow -i or something. --- scripts/build_mpos.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 4ee5748..5f0903e 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 From 5366f37de38c7a6b05d2273649940a8122e9152c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 12:13:39 +0100 Subject: [PATCH 130/192] Remove comments --- .DS_Store | Bin 8196 -> 0 bytes .gitignore | 3 +++ .../lib/mpos/ui/gesture_navigation.py | 1 - 3 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 4d2b0bfa37a618f5096150da05aabe835f8c6fec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMYitx%6uxI#;Er|Z-L`1UQXIPi6-p>gL3s$<2a&dDk!`!%Qe9?u#&*JVrtHk_ z7Ar}O&uBu7@$C;W{zalDCPqy}G-@P9B@Ky^_=qO{5r3%YKjXP`X9=`65TXX+OfvV} zd+s@B=6-v=d-v=TLZCgbuO+0G5JK_hl2u^yHy5Ah_pD0_H1kjb`V*2SW5gs`k|WM6 z>rfFQ5F!vF5F!vF5F&6nAb@8!zvvw2zL*W$5P=YZ|0M!^e^Bw}G9Jh&A^oib8@~iV zS&nM|!amjkzKA0q6I`-g@HqmEHczibH1)W(|sUg?Nc^!V-G-G+!*kxc?vtV>$aEw~TAKW|6Bf0}d z&P5rEHw(bzBbBxF4a-+GuiLn_bNh~+(=1X|U9(70hVV17J@anU$n_UZ-5VX$+^k{i zrah7@n68H3ynaNoXR?5W4InysN13)lzmL^;?LfpxnA$MVF!c?L#h99qMo~K(@oF8$*Ks8M%7+Q2YIkIUFUIpWulK#b^<>U(=M3E z6@*<-hQ{7il$f5LpIfQ3*A4CLm!7HO-rUAjXWlG4&1@%~bYrNig1OWKFyiy>aH8%f9JAfDRQ-QBZ8 zx&2Ba-j|hvYS&y_dp+mhhAkauvsC1DDV5Kqh|h}i_~f&~Pn((PjD%cLzf@8Ckv7J} zTr6e_I6>$%w{D0jDw~JI62ldZIGm5962qp|s>&p!uNbavQ59B(OqHjXL>Jeo>gt;@ z;lU5Iag(C3a^x(|EsoYHaiv}6I|U>DbmumV#2HBcc`kfSek7;K835!$HPk{qG{HL9 z1Z|l43FwCu48jm*zX2mK>NCK@{4c@;+z0m~2OdHeJPuF5lkgNg4KKnWc*$qN5uXXK z!`tuO3Vwjo@C*DpBjbB#WIX@+dclk@ByzUp*du6LV$S(t zE^SmM+-iCKzYSj_{2k!Za16ad1g>NRpu98D*^VoiYjfeXwu<*2y!plLriAoeu<^@r slzusm^6Vdm*jLe%`@{n|B_wL_`p=!2kdN diff --git a/.gitignore b/.gitignore index 5e87af8..6491091 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/internal_filesystem/lib/mpos/ui/gesture_navigation.py b/internal_filesystem/lib/mpos/ui/gesture_navigation.py index 22236e4..df95f6e 100644 --- a/internal_filesystem/lib/mpos/ui/gesture_navigation.py +++ b/internal_filesystem/lib/mpos/ui/gesture_navigation.py @@ -3,7 +3,6 @@ from .anim import smooth_show, smooth_hide from .view import back_screen from mpos.ui import topmenu as topmenu -#from .topmenu import open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT from .display import get_display_width, get_display_height downbutton = None From f3a5faba83b6078c75a50bb29fe6ae9611685323 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 13:56:30 +0100 Subject: [PATCH 131/192] Try to fix tests/test_graphical_imu_calibration.py --- tests/test_graphical_imu_calibration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py index 3eb84a3..601905a 100644 --- a/tests/test_graphical_imu_calibration.py +++ b/tests/test_graphical_imu_calibration.py @@ -130,8 +130,8 @@ def test_calibrate_activity_flow(self): 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() From 32de7bb6d9ce4e76e92a80b03dd1869502035ea6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 13:56:39 +0100 Subject: [PATCH 132/192] Rearrange --- .../lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ef2b06d..e2075c6 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) From d7a7312b3026bda2bd454927a363b4bc04953fcc Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 13:56:58 +0100 Subject: [PATCH 133/192] Add tests/test_graphical_imu_calibration_ui_bug.py --- .../test_graphical_imu_calibration_ui_bug.py | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100755 tests/test_graphical_imu_calibration_ui_bug.py 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 0000000..c71df2f --- /dev/null +++ b/tests/test_graphical_imu_calibration_ui_bug.py @@ -0,0 +1,183 @@ +#!/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") + + 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=20) + + 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 + + From e9b5aa75b83fa588095d909ae747c4ba2f5eeb81 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 14:16:14 +0100 Subject: [PATCH 134/192] Comments --- internal_filesystem/lib/mpos/board/fri3d_2024.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index b1c33dd..19cc307 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() From c1c35a18c8737fc4189f1c37e4a21058edca9c5c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 14:33:12 +0100 Subject: [PATCH 135/192] Update CHANGELOG and MANIFESTs --- CHANGELOG.md | 2 ++ .../apps/com.micropythonos.camera/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.imu/META-INF/MANIFEST.JSON | 6 +++--- .../com.micropythonos.musicplayer/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.about/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.settings/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON | 6 +++--- 10 files changed, 29 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf479db..034157a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,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/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.camera/META-INF/MANIFEST.JSON index 1a2cde4..0405e83 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 a0a333f..0ed67dc 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 21563c5..2c4601e 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 e7bf0e1..b1d428f 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/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON index 457f349..a09cd92 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.appstore/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON index 1671324..f7afe5a 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.osupdate/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON index 87781fe..e4d6240 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.settings/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.settings/META-INF/MANIFEST.JSON index 8bdf123..65bce84 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.wifi/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.wifi/META-INF/MANIFEST.JSON index 0c09327..6e23afc 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": [ { From 756136fbdcd1ba88dccd2fea5f792c3647231dad Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 8 Dec 2025 20:18:41 +0100 Subject: [PATCH 136/192] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 034157a..0b3b0f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - API: add AudioFlinger for audio playback (i2s DAC and buzzer) - API: add LightsManager for multicolor LEDs - API: add SensorManager for generic handling of IMUs and temperature sensors +- UI: back swipe gesture closes topmenu when open (thanks, @Mark19000 !) - About app: add free, used and total storage space info - AppStore app: remove unnecessary scrollbar over publisher's name - Camera app: massive overhaul! From 91127dfadd7901610be1f48d5d3fead95133adf3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 9 Dec 2025 12:00:05 +0100 Subject: [PATCH 137/192] AudioFlinger: optimize WAV volume scaling --- .../lib/mpos/audio/audioflinger.py | 4 +- .../lib/mpos/audio/stream_rtttl.py | 3 + .../lib/mpos/audio/stream_wav.py | 134 +++++++++++++++++- 3 files changed, 138 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py index 47dfcd9..5d76b55 100644 --- a/internal_filesystem/lib/mpos/audio/audioflinger.py +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -18,7 +18,7 @@ _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) +_volume = 25 # System volume (0-100) _stream_lock = None # Thread lock for stream management @@ -290,6 +290,8 @@ def set_volume(volume): """ global _volume _volume = max(0, min(100, volume)) + if _current_stream: + _current_stream.set_volume(_volume) def get_volume(): diff --git a/internal_filesystem/lib/mpos/audio/stream_rtttl.py b/internal_filesystem/lib/mpos/audio/stream_rtttl.py index 00bae75..ea8d0a4 100644 --- a/internal_filesystem/lib/mpos/audio/stream_rtttl.py +++ b/internal_filesystem/lib/mpos/audio/stream_rtttl.py @@ -229,3 +229,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 884d936..e08261b 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -28,6 +28,128 @@ def _scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): buf[i] = sample & 255 buf[i + 1] = (sample >> 8) & 255 +import micropython +@micropython.viper +def _scale_audio_optimized(buf: ptr8, num_bytes: int, scale_fixed: int): + """ + Very fast 16-bit volume scaling using only shifts + adds. + - 100 % and above → no change + - < ~12.5 % → pure right-shift (fastest) + - otherwise → high-quality shift/add approximation + """ + if scale_fixed >= 32768: # 100 % or more + return + if scale_fixed <= 0: # muted + for i in range(num_bytes): + buf[i] = 0 + return + + # -------------------------------------------------------------- + # Very low volumes → simple right-shift (super cheap) + # -------------------------------------------------------------- + if scale_fixed < 4096: # < ~12.5 % + shift: int = 0 + tmp: int = 32768 + while tmp > scale_fixed: + shift += 1 + tmp >>= 1 + for i in range(0, num_bytes, 2): + lo: int = int(buf[i]) + hi: int = int(buf[i + 1]) + s: int = (hi << 8) | lo + if hi & 128: # sign extend + s -= 65536 + s >>= shift + buf[i] = s & 255 + buf[i + 1] = (s >> 8) & 255 + return + + # -------------------------------------------------------------- + # Medium → high volumes: sample * scale_fixed // 32768 + # approximated with shifts + adds only + # -------------------------------------------------------------- + # Build a 16-bit mask: + # bit 0 → add (s >> 15) + # bit 1 → add (s >> 14) + # ... + # bit 15 → add s (>> 0) + mask: int = 0 + bit_value: int = 16384 # starts at 2^-1 + remaining: int = scale_fixed + + shift_idx: int = 1 # corresponds to >>1 + while bit_value > 0: + if remaining >= bit_value: + mask |= (1 << (16 - shift_idx)) # correct bit position + remaining -= bit_value + bit_value >>= 1 + shift_idx += 1 + + # Apply the mask + for i in range(0, num_bytes, 2): + lo: int = int(buf[i]) + hi: int = int(buf[i + 1]) + s: int = (hi << 8) | lo + if hi & 128: + s -= 65536 + + result: int = 0 + if mask & 0x8000: result += s # >>0 + if mask & 0x4000: result += (s >> 1) + if mask & 0x2000: result += (s >> 2) + if mask & 0x1000: result += (s >> 3) + if mask & 0x0800: result += (s >> 4) + if mask & 0x0400: result += (s >> 5) + if mask & 0x0200: result += (s >> 6) + if mask & 0x0100: result += (s >> 7) + if mask & 0x0080: result += (s >> 8) + if mask & 0x0040: result += (s >> 9) + if mask & 0x0020: result += (s >>10) + if mask & 0x0010: result += (s >>11) + if mask & 0x0008: result += (s >>12) + if mask & 0x0004: result += (s >>13) + if mask & 0x0002: result += (s >>14) + if mask & 0x0001: result += (s >>15) + + # Clamp to 16-bit signed range + if result > 32767: + result = 32767 + elif result < -32768: + result = -32768 + + buf[i] = result & 255 + buf[i + 1] = (result >> 8) & 255 + +import micropython +@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 class WAVStream: """ @@ -251,7 +373,12 @@ def play(self): print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)") f.seek(data_start) - chunk_size = 4096 + # smaller chunk size means less jerks but buffer can run empty + # at 22050 Hz, 16-bit, 2-ch, 4096/4 = 1024 samples / 22050 = 46ms + # 4096 => audio stutters during quasibird + # 8192 => no audio stutters and quasibird runs at ~16 fps => good compromise! + # 16384 => no audio stutters during quasibird but low framerate (~8fps) + chunk_size = 4096*2 bytes_per_original_sample = (bits_per_sample // 8) * channels total_original = 0 @@ -287,7 +414,7 @@ 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 if self._i2s: @@ -313,3 +440,6 @@ def play(self): if self._i2s: self._i2s.deinit() self._i2s = None + + def set_volume(self, vol): + self.volume = vol From b9e5f0d47541eb9256875bd1946106f81d747f3c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 9 Dec 2025 12:42:01 +0100 Subject: [PATCH 138/192] AudioFlinger: improve optimized audio scaling --- CHANGELOG.md | 4 + .../lib/mpos/audio/stream_wav.py | 118 +++++------------- scripts/install.sh | 3 +- 3 files changed, 40 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3b0f9..9c0cd8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +0.5.2 +===== +- AudioFlinger: optimize WAV volume scaling + 0.5.1 ===== - Fri3d Camp 2024 Board: add startup light and sound diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index e08261b..8472380 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -3,6 +3,7 @@ # Ported from MusicPlayer's AudioPlayer class import machine +import micropython import os import time import sys @@ -10,7 +11,6 @@ # 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,99 +28,46 @@ def _scale_audio(buf: ptr8, num_bytes: int, scale_fixed: int): buf[i] = sample & 255 buf[i + 1] = (sample >> 8) & 255 -import micropython @micropython.viper def _scale_audio_optimized(buf: ptr8, num_bytes: int, scale_fixed: int): - """ - Very fast 16-bit volume scaling using only shifts + adds. - - 100 % and above → no change - - < ~12.5 % → pure right-shift (fastest) - - otherwise → high-quality shift/add approximation - """ - if scale_fixed >= 32768: # 100 % or more + if scale_fixed >= 32768: return - if scale_fixed <= 0: # muted + if scale_fixed <= 0: for i in range(num_bytes): buf[i] = 0 return - # -------------------------------------------------------------- - # Very low volumes → simple right-shift (super cheap) - # -------------------------------------------------------------- - if scale_fixed < 4096: # < ~12.5 % - shift: int = 0 - tmp: int = 32768 - while tmp > scale_fixed: - shift += 1 - tmp >>= 1 - for i in range(0, num_bytes, 2): - lo: int = int(buf[i]) - hi: int = int(buf[i + 1]) - s: int = (hi << 8) | lo - if hi & 128: # sign extend - s -= 65536 - s >>= shift - buf[i] = s & 255 - buf[i + 1] = (s >> 8) & 255 - return + mask: int = scale_fixed - # -------------------------------------------------------------- - # Medium → high volumes: sample * scale_fixed // 32768 - # approximated with shifts + adds only - # -------------------------------------------------------------- - # Build a 16-bit mask: - # bit 0 → add (s >> 15) - # bit 1 → add (s >> 14) - # ... - # bit 15 → add s (>> 0) - mask: int = 0 - bit_value: int = 16384 # starts at 2^-1 - remaining: int = scale_fixed - - shift_idx: int = 1 # corresponds to >>1 - while bit_value > 0: - if remaining >= bit_value: - mask |= (1 << (16 - shift_idx)) # correct bit position - remaining -= bit_value - bit_value >>= 1 - shift_idx += 1 - - # Apply the mask for i in range(0, num_bytes, 2): - lo: int = int(buf[i]) - hi: int = int(buf[i + 1]) - s: int = (hi << 8) | lo - if hi & 128: - s -= 65536 - - result: int = 0 - if mask & 0x8000: result += s # >>0 - if mask & 0x4000: result += (s >> 1) - if mask & 0x2000: result += (s >> 2) - if mask & 0x1000: result += (s >> 3) - if mask & 0x0800: result += (s >> 4) - if mask & 0x0400: result += (s >> 5) - if mask & 0x0200: result += (s >> 6) - if mask & 0x0100: result += (s >> 7) - if mask & 0x0080: result += (s >> 8) - if mask & 0x0040: result += (s >> 9) - if mask & 0x0020: result += (s >>10) - if mask & 0x0010: result += (s >>11) - if mask & 0x0008: result += (s >>12) - if mask & 0x0004: result += (s >>13) - if mask & 0x0002: result += (s >>14) - if mask & 0x0001: result += (s >>15) - - # Clamp to 16-bit signed range - if result > 32767: - result = 32767 - elif result < -32768: - result = -32768 - - buf[i] = result & 255 - buf[i + 1] = (result >> 8) & 255 + 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 -import micropython @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.""" @@ -375,9 +322,12 @@ def play(self): # smaller chunk size means less jerks but buffer can run empty # at 22050 Hz, 16-bit, 2-ch, 4096/4 = 1024 samples / 22050 = 46ms + # with rough volume scaling: # 4096 => audio stutters during quasibird # 8192 => no audio stutters and quasibird runs at ~16 fps => good compromise! # 16384 => no audio stutters during quasibird but low framerate (~8fps) + # with optimized volume scaling: + # 8192 => no audio stutters and quasibird runs at ~12fps chunk_size = 4096*2 bytes_per_original_sample = (bits_per_sample // 8) * channels total_original = 0 diff --git a/scripts/install.sh b/scripts/install.sh index 7dd1511..984121a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -47,6 +47,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,7 +61,6 @@ 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/ From a60a7cd8d1abefaab934657d528a86b339aec023 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 9 Dec 2025 13:35:22 +0100 Subject: [PATCH 139/192] Comments --- CHANGELOG.md | 2 +- internal_filesystem/lib/mpos/audio/stream_wav.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c0cd8b..05fe5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ 0.5.2 ===== -- AudioFlinger: optimize WAV volume scaling +- AudioFlinger: optimize WAV volume scaling for speed and immediately set volume 0.5.1 ===== diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index 8472380..a57cf27 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -323,12 +323,14 @@ def play(self): # smaller chunk size means less jerks but buffer can run empty # at 22050 Hz, 16-bit, 2-ch, 4096/4 = 1024 samples / 22050 = 46ms # with rough volume scaling: - # 4096 => audio stutters during quasibird + # 4096 => audio stutters during quasibird at ~20fps # 8192 => no audio stutters and quasibird runs at ~16 fps => good compromise! # 16384 => no audio stutters during quasibird but low framerate (~8fps) # with optimized volume scaling: - # 8192 => no audio stutters and quasibird runs at ~12fps - chunk_size = 4096*2 + # 6144 => audio stutters and quasibird at ~17fps + # 7168 => audio slightly stutters and quasibird at ~16fps + # 8192 => no audio stutters and quasibird runs at ~15fps + chunk_size = 8192 bytes_per_original_sample = (bits_per_sample // 8) * channels total_original = 0 From b2f441a8bb5b017c068d41b715d45542c5f74116 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 9 Dec 2025 14:06:12 +0100 Subject: [PATCH 140/192] AudioFlinger: optimize volume scaling further --- .../assets/music_player.py | 6 +- .../lib/mpos/audio/stream_wav.py | 55 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) 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 1438093..428f773 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/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index a57cf27..799871a 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -98,6 +98,49 @@ def _scale_audio_rough(buf: ptr8, num_bytes: int, scale_fixed: int): buf[i] = sample & 255 buf[i + 1] = (sample >> 8) & 255 +@micropython.viper +def _scale_audio_shift(buf: ptr8, num_bytes: int, shift: int): + """Rough volume scaling for 16-bit audio samples using right shifts for performance.""" + if shift <= 0: + return + + # If shift is 16 or more, set buffer to zero (volume too low) + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Apply right shift to each 16-bit sample + for i in range(0, num_bytes, 2): + lo: int = int(buf[i]) + hi: int = int(buf[i + 1]) + sample: int = (hi << 8) | lo + if hi & 128: + sample -= 65536 + sample >>= shift + buf[i] = sample & 255 + buf[i + 1] = (sample >> 8) & 255 + +@micropython.viper +def _scale_audio_powers_of_2(buf: ptr8, num_bytes: int, shift: int): + if shift <= 0: + return + if shift >= 16: + for i in range(num_bytes): + buf[i] = 0 + return + + # Unroll the sign-extend + shift into one tight loop with no inner branch + inv_shift: int = 16 - shift + for i in range(0, num_bytes, 2): + s: int = int(buf[i]) | (int(buf[i+1]) << 8) + if s & 0x8000: # only one branch, highly predictable when shift fixed shift + s |= -65536 # sign extend using OR (faster than subtract!) + s <<= inv_shift # bring the bits we want into lower 16 + s >>= 16 # arithmetic shift right by 'shift' amount + buf[i] = s & 0xFF + buf[i+1] = (s >> 8) & 0xFF + class WAVStream: """ WAV file playback stream with I2S output. @@ -330,6 +373,12 @@ def play(self): # 6144 => audio stutters and quasibird at ~17fps # 7168 => audio slightly stutters and quasibird at ~16fps # 8192 => no audio stutters and quasibird runs at ~15fps + # with shift volume scaling: + # 6144 => audio slightly stutters and quasibird at ~16fps?! + # 8192 => no audio stutters, quasibird runs at ~13fps?! + # with power of 2 thing: + # 6144 => audio sutters and quasibird at ~18fps + # 8192 => no audio stutters, quasibird runs at ~14fps chunk_size = 8192 bytes_per_original_sample = (bits_per_sample // 8) * channels total_original = 0 @@ -363,10 +412,8 @@ def play(self): raw = self._upsample_buffer(raw, upsample_factor) # 3. Volume scaling - scale = self.volume / 100.0 - if scale < 1.0: - scale_fixed = int(scale * 32768) - _scale_audio_optimized(raw, len(raw), scale_fixed) + shift = 16 - int(self.volume / 6.25) + _scale_audio_powers_of_2(raw, len(raw), shift) # 4. Output to I2S if self._i2s: From 776410bc99c26f9f42505de9091b85131b03df61 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 9 Dec 2025 14:18:57 +0100 Subject: [PATCH 141/192] Comments --- internal_filesystem/lib/mpos/audio/audioflinger.py | 2 +- internal_filesystem/lib/mpos/audio/stream_wav.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py index 5d76b55..167eea5 100644 --- a/internal_filesystem/lib/mpos/audio/audioflinger.py +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -18,7 +18,7 @@ _i2s_pins = None # I2S pin configuration dict (created per-stream) _buzzer_instance = None # PWM buzzer instance _current_stream = None # Currently playing stream -_volume = 25 # System volume (0-100) +_volume = 50 # System volume (0-100) _stream_lock = None # Thread lock for stream management diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index 799871a..634ea61 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -372,7 +372,7 @@ def play(self): # with optimized volume scaling: # 6144 => audio stutters and quasibird at ~17fps # 7168 => audio slightly stutters and quasibird at ~16fps - # 8192 => no audio stutters and quasibird runs at ~15fps + # 8192 => no audio stutters and quasibird runs at ~15-17fps => this is probably best # with shift volume scaling: # 6144 => audio slightly stutters and quasibird at ~16fps?! # 8192 => no audio stutters, quasibird runs at ~13fps?! @@ -412,8 +412,12 @@ def play(self): raw = self._upsample_buffer(raw, upsample_factor) # 3. Volume scaling - shift = 16 - int(self.volume / 6.25) - _scale_audio_powers_of_2(raw, len(raw), shift) + #shift = 16 - int(self.volume / 6.25) + #_scale_audio_powers_of_2(raw, len(raw), shift) + scale = self.volume / 100.0 + if scale < 1.0: + scale_fixed = int(scale * 32768) + _scale_audio_optimized(raw, len(raw), scale_fixed) # 4. Output to I2S if self._i2s: From 73fa096bd74a735af5c655af08c0e0f750c9b165 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 9 Dec 2025 15:36:19 +0100 Subject: [PATCH 142/192] Comments --- internal_filesystem/lib/mpos/audio/stream_wav.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index 634ea61..b5a7104 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -327,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 From 2a6aaab583eb4e71b634eed74ba6d659d9df991c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 12:43:00 +0100 Subject: [PATCH 143/192] API: add TaskManager that wraps asyncio --- internal_filesystem/lib/mpos/__init__.py | 1 + internal_filesystem/lib/mpos/main.py | 3 ++ internal_filesystem/lib/mpos/task_manager.py | 35 ++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 internal_filesystem/lib/mpos/task_manager.py diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 6111795..464207b 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -5,6 +5,7 @@ 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 diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 36ea885..0d00127 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 @@ -71,6 +72,8 @@ def custom_exception_handler(e): # This will throw an exception if there is already a "/builtin" folder present print("main.py: WARNING: could not import/run freezefs_mount_builtin: ", e) +mpos.TaskManager() + try: from mpos.net.wifi_service import WifiService _thread.stack_size(mpos.apps.good_stack_size()) diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py new file mode 100644 index 0000000..2fd7b76 --- /dev/null +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -0,0 +1,35 @@ +import asyncio # this is the only place where asyncio is allowed to be imported - apps should not use it directly but use this TaskManager +import _thread + +class TaskManager: + + task_list = [] # might be good to periodically remove tasks that are done, to prevent this list from growing huge + + def __init__(self): + print("TaskManager starting asyncio_thread") + _thread.stack_size(1024) # tiny stack size is enough for this simple thread + _thread.start_new_thread(asyncio.run, (self._asyncio_thread(), )) + + async def _asyncio_thread(self): + print("asyncio_thread started") + while True: + #print("asyncio_thread tick") + await asyncio.sleep_ms(100) # This delay determines how quickly new tasks can be started, so keep it below human reaction speed + print("WARNING: asyncio_thread exited, this shouldn't happen because now asyncio.create_task() won't work anymore!") + + @classmethod + def create_task(cls, coroutine): + cls.task_list.append(asyncio.create_task(coroutine)) + + @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) From eec5c7ce3a8b7680359721d989942acd5e46a911 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 14:25:12 +0100 Subject: [PATCH 144/192] Comments --- internal_filesystem/lib/mpos/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/apps.py b/internal_filesystem/lib/mpos/apps.py index a66102e..551e811 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 From 5936dafd7e27dab528503c8d45c61aa75d5b89aa Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 14:37:41 +0100 Subject: [PATCH 145/192] TaskManager: normal stack size for asyncio thread --- internal_filesystem/lib/mpos/task_manager.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py index 2fd7b76..1de1edf 100644 --- a/internal_filesystem/lib/mpos/task_manager.py +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -1,5 +1,6 @@ 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: @@ -7,7 +8,7 @@ class TaskManager: def __init__(self): print("TaskManager starting asyncio_thread") - _thread.stack_size(1024) # tiny stack size is enough for this simple thread + _thread.stack_size(mpos.apps.good_stack_size()) # tiny stack size of 1024 is fine for tasks that do nothing but for real-world usage, it needs more _thread.start_new_thread(asyncio.run, (self._asyncio_thread(), )) async def _asyncio_thread(self): @@ -33,3 +34,7 @@ def sleep_ms(ms): @staticmethod def sleep(s): return asyncio.sleep(s) + + @staticmethod + def notify_event(): + return asyncio.Event() From c0b9f68ae8b402c102888ab6aaa5495aa8f96135 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 14:38:12 +0100 Subject: [PATCH 146/192] AppStore app: eliminate thread --- CHANGELOG.md | 1 + .../assets/appstore.py | 91 ++++++++++--------- internal_filesystem/lib/mpos/app/activity.py | 8 +- 3 files changed, 53 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05fe5fd..5136df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ 0.5.2 ===== - AudioFlinger: optimize WAV volume scaling for speed and immediately set volume +- API: add TaskManager that wraps asyncio 0.5.1 ===== 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 ff1674d..41f18d9 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -1,3 +1,4 @@ +import aiohttp import lvgl as lv import json import requests @@ -6,8 +7,10 @@ import time import _thread + from mpos.apps import Activity, Intent from mpos.app import App +from mpos import TaskManager import mpos.ui from mpos.content.package_manager import PackageManager @@ -16,6 +19,7 @@ class AppStore(Activity): apps = [] app_index_url = "https://apps.micropythonos.com/app_index.json" can_check_network = True + aiohttp_session = None # one session for the whole app is more performant # Widgets: main_screen = None @@ -26,6 +30,7 @@ class AppStore(Activity): progress_bar = None def onCreate(self): + self.aiohttp_session = aiohttp.ClientSession() self.main_screen = lv.obj() self.please_wait_label = lv.label(self.main_screen) self.please_wait_label.set_text("Downloading app index...") @@ -43,38 +48,39 @@ 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,)) + TaskManager.create_task(self.download_app_index(self.app_index_url)) + + def onDestroy(self, screen): + await self.aiohttp_session.close() - def download_app_index(self, json_url): + async def download_app_index(self, json_url): + response = await self.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) + for app in json.loads(response): + 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("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() + 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("Hiding please wait label...") + self.update_ui_threadsafe_if_foreground(self.please_wait_label.add_flag, lv.obj.FLAG.HIDDEN) + print("Creating apps list...") + created_app_list_event = TaskManager.notify_event() # wait for the list to be shown before downloading the icons + self.update_ui_threadsafe_if_foreground(self.create_apps_list, event=created_app_list_event) + await created_app_list_event.wait() + print("awaiting self.download_icons()") + await self.download_icons() def create_apps_list(self): print("create_apps_list") @@ -119,14 +125,15 @@ 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.") break - if not app.icon_data: - app.icon_data = self.download_icon_data(app.icon_url) + #if not app.icon_data: + app.icon_data = await self.download_url(app.icon_url) if app.icon_data: print("download_icons has icon_data, showing it...") image_icon_widget = None @@ -147,20 +154,16 @@ def show_app_detail(self, app): intent.putExtra("app", app) self.startActivity(intent) - @staticmethod - def download_icon_data(url): - print(f"Downloading icon from {url}") + async def download_url(self, url): + print(f"Downloading {url}") + #await TaskManager.sleep(1) 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) + async with self.aiohttp_session.get(url) as response: + if response.status >= 200 and response.status < 400: + return await response.read() + print(f"Done downloading {url}") except Exception as e: - print(f"Exception during download of icon: {e}") - return None + print(f"download_url got exception {e}") class AppDetail(Activity): diff --git a/internal_filesystem/lib/mpos/app/activity.py b/internal_filesystem/lib/mpos/app/activity.py index c837371..e0cd71c 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 From 7ba45e692ea852a4a47b77f3c870816f1a3bf1af Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 19:02:19 +0100 Subject: [PATCH 147/192] TaskManager: without new thread works but blocks REPL aiorepl (asyncio REPL) works but it's pretty limited It's probably fine for production, but it means the user has to sys.exit() in aiorepl before landing on the real interactive REPL, with asyncio tasks stopped. --- .../assets/appstore.py | 14 ++++++---- internal_filesystem/lib/mpos/main.py | 28 +++++++++++++++---- internal_filesystem/lib/mpos/task_manager.py | 15 +++++++--- internal_filesystem/lib/mpos/ui/topmenu.py | 1 + 4 files changed, 44 insertions(+), 14 deletions(-) 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 41f18d9..bcb73cc 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -130,10 +130,14 @@ 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_threadsafe is needed break - #if not app.icon_data: - app.icon_data = await self.download_url(app.icon_url) + if not app.icon_data: + try: + app.icon_data = await TaskManager.wait_for(self.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 @@ -146,7 +150,7 @@ async 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' + self.update_ui_threadsafe_if_foreground(image_icon_widget.set_src, image_dsc) # add update_ui_threadsafe() for background? print("Finished downloading icons.") def show_app_detail(self, app): @@ -156,7 +160,7 @@ def show_app_detail(self, app): async def download_url(self, url): print(f"Downloading {url}") - #await TaskManager.sleep(1) + #await TaskManager.sleep(4) # test slowness try: async with self.aiohttp_session.get(url) as response: if response.status >= 200 and response.status < 400: diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 0d00127..c82d77d 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -72,8 +72,6 @@ def custom_exception_handler(e): # This will throw an exception if there is already a "/builtin" folder present print("main.py: WARNING: could not import/run freezefs_mount_builtin: ", e) -mpos.TaskManager() - try: from mpos.net.wifi_service import WifiService _thread.stack_size(mpos.apps.good_stack_size()) @@ -89,11 +87,31 @@ def custom_exception_handler(e): if auto_start_app and launcher_app.fullname != auto_start_app: mpos.apps.start_app(auto_start_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 +print("Starting very limited asyncio REPL task. Use sys.exit() to stop all asyncio tasks and go to real REPL...") +mpos.TaskManager.create_task(aiorepl.task()) # only gets started when mpos.TaskManager() 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 + +while True: + try: + mpos.TaskManager() # 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 + break + except Exception as e: + print(f"mpos.TaskManager() got exception: {e}") + print("Restarting mpos.TaskManager() after 10 seconds...") + import time + time.sleep(10) diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py index 1de1edf..bd41b3d 100644 --- a/internal_filesystem/lib/mpos/task_manager.py +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -8,14 +8,17 @@ class TaskManager: def __init__(self): print("TaskManager starting asyncio_thread") - _thread.stack_size(mpos.apps.good_stack_size()) # tiny stack size of 1024 is fine for tasks that do nothing but for real-world usage, it needs more - _thread.start_new_thread(asyncio.run, (self._asyncio_thread(), )) + # tiny stack size of 1024 is fine for tasks that do nothing + # but for real-world usage, it needs more: + #_thread.stack_size(mpos.apps.good_stack_size()) + #_thread.start_new_thread(asyncio.run, (self._asyncio_thread(100), )) + asyncio.run(self._asyncio_thread(10)) # this actually works, but it blocks the real REPL (aiorepl works, but that's limited) - async def _asyncio_thread(self): + async def _asyncio_thread(self, ms_to_sleep): print("asyncio_thread started") while True: #print("asyncio_thread tick") - await asyncio.sleep_ms(100) # This delay determines how quickly new tasks can be started, so keep it below human reaction speed + await asyncio.sleep_ms(ms_to_sleep) # This delay determines how quickly new tasks can be started, so keep it below human reaction speed print("WARNING: asyncio_thread exited, this shouldn't happen because now asyncio.create_task() won't work anymore!") @classmethod @@ -38,3 +41,7 @@ def 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/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index 7911c95..9648642 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) From 70cd00b50ee7e5eb19aefc8390e67f0bc9fecd00 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 19:29:50 +0100 Subject: [PATCH 148/192] Improve name of aiorepl coroutine --- internal_filesystem/lib/mpos/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index c82d77d..1c38641 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -89,8 +89,10 @@ def custom_exception_handler(e): # Create limited aiorepl because it's better than nothing: import aiorepl -print("Starting very limited asyncio REPL task. Use sys.exit() to stop all asyncio tasks and go to real REPL...") -mpos.TaskManager.create_task(aiorepl.task()) # only gets started when mpos.TaskManager() is created +async def asyncio_repl(): + print("Starting very limited asyncio REPL task. Use sys.exit() to stop all asyncio tasks and go to real REPL...") + await aiorepl.task() +mpos.TaskManager.create_task(asyncio_repl()) # only gets started when mpos.TaskManager() is created async def ota_rollback_cancel(): try: From e1964abfa2b132b656694842e00ba806ab9fe344 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 19:42:16 +0100 Subject: [PATCH 149/192] Add /lib/aiorepl.py --- internal_filesystem/lib/aiorepl.mpy | Bin 0 -> 3144 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 internal_filesystem/lib/aiorepl.mpy diff --git a/internal_filesystem/lib/aiorepl.mpy b/internal_filesystem/lib/aiorepl.mpy new file mode 100644 index 0000000000000000000000000000000000000000..a8689549888e8af04a38bb682ca6252ad2b018ab GIT binary patch literal 3144 zcmb7FTW}NC89uwVWFcI#UN%aC30btZBuiKrv#3B~uy@zCB-_|4Uxi}}v057yTk=RE zh7iK8FARm3J|yi-d1#+I(;4|D+n6>Cz5q!`VfsKdZD2a7)0uXrmzZ`koj&wmU5sAZ znXav~|M@T9`TzfX=WLrEy){ru1;f#pJT~GSyM$g*ht;y;n2hxCOL1gKghyqxD;U2N zk-|~5ONx$;g-2vW_7Bz#)M*1UR9By%k+H^E>#Rk)}TGM_|E3D0(4* zCOk#zWg-!l&c_3zaYSXMPxc%_Fn7qji5dG3bT!%0=~w8r>&#i*M;_Ja+9yUEw9KJn_JtthE|l3 z8#+5Z&8LuwcQ^O~e3!2^&`>zx3MYKwL@1mzB2ysno*auqKG07?HveIS$C1jY`@`KH zb$p_(bSwruNWj8@@aR}HrnO#;>PMm36FRk5KN`Ga5`h8|F`RFgSP%)_4^Igr)#Q@1ptdanWdzT7#@k9 z4UPawj5IvJ6*eXl2@}j^~_>#sKU}E@Qe563npQS-?_TKBmfu zbUO>&kq$T*j3vU6;d~tJYwT!sI-N*&IKvHk6jpni<`c1zYMxF+=`6tyWHo}O845?j z@pHzyx;i5=;yN7Y1_Oum2Qgn@K2BjF&`YZ9>oqz2W*O_=yelQ3m!oHjMpB2%eI z80~oHVa&A0JcaC7=V9E$))twL5<5-JqE!5G;^t$#r_}B7DsNgjrkOqJsHyT^H%Mo! zHkocUn=LlMVz$|a%x0T8A_|El^&J=DIZ<-|8zXilzZ8~L@&xrt(uFmakS8-j@2b$p z6j8(+RW=D~J-k&HmUQ8Fxd>UGcj?Hvr-norHL(1rUR%B@Qcisz(WvSjzG&jsG^#RkMh%H%?RcgZ4H z{NB<++W_~z=b+n6;?!%9CHRo7$r%=h__pVQ+pjGIvjMNMmpqk+)10q@^MJRqmu!D7 z3_kEsWC!s&MUdhWi|u}6$%V6+UlbqM3Q zWLj`1Gx>=US1C$&JDxqp{)zAL_>?o&$|}azVll%*o9zsYc7`!q%+~5A#$F-OhZw%b z?BIo}CWpo8VA?7wj-9XZS)G-3rV{=b2V-G03*TZjS2`-29By}&{gBt%;;~g*9Tg6z zmB|d2BUZ+If#&(6wLW_b*}>=`{iOIz@Q|HEIaIXwuIG>XeA&?NH%Z;@eJ<>t=)kVM zSXI`Pb!9`juRH+tTNFJ5y&?8L?D$mqY&Ku$=XZFX`^mM`BeLrbi)}1^LFy^Sh3=<* z`jmI4%*uzq5oV@SH`2mQ^|l~paqa-l@}13x+#jZY0hV?MsaX7_3;*b;z;lnMR|H?y zkEo=G6&D-_zw%j5W6?r|E6a29J!JRvg8B`Mug|OBT>Ey7EL~2{vPIDQUwtAz`?cWD zMvy(9uo^-^JomS$)b(^GyQ^v8D`i>uow6cqD8zosx-70dQMa6U_wvdU5ngs6-@{z2 zZ5t+M+@2Fv`9wM2vUnBZ@-cSs;eI}uQqw{kc@%*5C0X3h{be#Wk%nTwnoI#tx^9E` z+sVQdA5EsF(!!c@TNwR63j0wZ25hGTPKPL^rqe=47Mv-8e0-mBw{3e8kO5YDvFLUu1KX?ya3 z^N@YYlDY+A7BAnCWpL(xtb+f+v~X@E1Ep>~6;7|HoB2(y*@mtlH1|I%;C?7AoLSM_ zzW#4+YnoeaS{Pihlx1-9j@s^2lx=00#09|Va9ZdCr)^dN&nn8Y=6bH4djxd&AkPNa z%sY896m63MH0qW{knL0pIB@D^%Q7^7a*12HB<8OoxHfL~{Rv}PuU~oLcn9uahOt^L zUS}m!Wj3E;D%x{>KC+EXo}MwQSN}S5{qL%yh{Y(E3%(Zqj(f@~SXgY0*|E<3UYSw2 zdrI$WGIKOH$gUKN-Cvwc^Nou@B`FI^_D^Mw1Ly^wJS8v8i*r!L=DP15*Sa`A*Q0Ls zoqozMUG@8C`SykR&GlR|--QKdg;yl6_?-Bb~vq-Z5v^^n?!7Q8b8re0^V(SZVQdX6@4b7z& zDu81$2&E|9ECw|0OsU<(@ig3DY?8%Rxi1v1`z35HRSlDiEkI;jPNUr#inIVVHv%r# zvj7dr+uqi^o9E^?SGj+DNcPULn35K9pv+D%zQlJs$d)6SA{RbpHi#PxP literal 0 HcmV?d00001 From 6a9ae7238e140ac8f79a1d950ceacd815ad2b0f8 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 19:42:46 +0100 Subject: [PATCH 150/192] Appstore app: allow time to update UI --- .../apps/com.micropythonos.appstore/assets/appstore.py | 9 ++++++--- internal_filesystem/lib/README.md | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) 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 bcb73cc..3bc2c24 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -66,24 +66,27 @@ async def download_app_index(self, json_url): except Exception as e: print(f"Warning: could not add app from {json_url} to apps list: {e}") except Exception as e: - self.please_wait_label.set_text(f"ERROR: could not parse reponse.text JSON: {e}") + self.update_ui_threadsafe_if_foreground(self.please_wait_label.set_text, f"ERROR: could not parse reponse.text JSON: {e}") return + self.update_ui_threadsafe_if_foreground(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("Hiding please wait label...") - self.update_ui_threadsafe_if_foreground(self.please_wait_label.add_flag, lv.obj.FLAG.HIDDEN) print("Creating apps list...") created_app_list_event = TaskManager.notify_event() # wait for the list to be shown before downloading the icons self.update_ui_threadsafe_if_foreground(self.create_apps_list, event=created_app_list_event) await created_app_list_event.wait() + 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) diff --git a/internal_filesystem/lib/README.md b/internal_filesystem/lib/README.md index 078e0c7..a5d0eaf 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") From e24f8ef61843e3a1460f8a6bd4dce040d250618c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 19:44:22 +0100 Subject: [PATCH 151/192] AppStore app: remove unneeded event handling --- .../apps/com.micropythonos.appstore/assets/appstore.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 3bc2c24..04e15c5 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -76,9 +76,7 @@ async def download_app_index(self, json_url): 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...") - created_app_list_event = TaskManager.notify_event() # wait for the list to be shown before downloading the icons - self.update_ui_threadsafe_if_foreground(self.create_apps_list, event=created_app_list_event) - await created_app_list_event.wait() + self.update_ui_threadsafe_if_foreground(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() From 8a72f3f343b32f7a494a76443cd59b8c860e2b33 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 21:01:29 +0100 Subject: [PATCH 152/192] TaskManager: add stop and start functions --- internal_filesystem/lib/mpos/main.py | 17 ++++----- internal_filesystem/lib/mpos/task_manager.py | 37 +++++++++++++------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 1c38641..c1c80e0 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -106,14 +106,9 @@ async def ota_rollback_cancel(): else: mpos.TaskManager.create_task(ota_rollback_cancel()) # only gets started when mpos.TaskManager() is created -while True: - try: - mpos.TaskManager() # 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 - break - except Exception as e: - print(f"mpos.TaskManager() got exception: {e}") - print("Restarting mpos.TaskManager() after 10 seconds...") - import time - time.sleep(10) +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/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py index bd41b3d..0b5f2a8 100644 --- a/internal_filesystem/lib/mpos/task_manager.py +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -5,21 +5,34 @@ class TaskManager: task_list = [] # might be good to periodically remove tasks that are done, to prevent this list from growing huge + keep_running = True - def __init__(self): - print("TaskManager starting asyncio_thread") - # tiny stack size of 1024 is fine for tasks that do nothing - # but for real-world usage, it needs more: - #_thread.stack_size(mpos.apps.good_stack_size()) - #_thread.start_new_thread(asyncio.run, (self._asyncio_thread(100), )) - asyncio.run(self._asyncio_thread(10)) # this actually works, but it blocks the real REPL (aiorepl works, but that's limited) - - async def _asyncio_thread(self, ms_to_sleep): + @classmethod + async def _asyncio_thread(cls, ms_to_sleep): print("asyncio_thread started") - while True: - #print("asyncio_thread tick") + while TaskManager.should_keep_running() is True: + #while self.keep_running is True: + #print(f"asyncio_thread tick because {self.keep_running}") + print(f"asyncio_thread tick because {TaskManager.should_keep_running()}") await asyncio.sleep_ms(ms_to_sleep) # This delay determines how quickly new tasks can be started, so keep it below human reaction speed - print("WARNING: asyncio_thread exited, this shouldn't happen because now asyncio.create_task() won't work anymore!") + print("WARNING: asyncio_thread exited, now asyncio.create_task() won't work anymore") + + @classmethod + def start(cls): + #asyncio.run_until_complete(TaskManager._asyncio_thread(100)) # this actually works, but it blocks the real REPL (aiorepl works, but that's limited) + asyncio.run(TaskManager._asyncio_thread(1000)) # this actually works, but it blocks the real REPL (aiorepl works, but that's limited) + + @classmethod + def stop(cls): + cls.keep_running = False + + @classmethod + def should_keep_running(cls): + return cls.keep_running + + @classmethod + def set_keep_running(cls, value): + cls.keep_running = value @classmethod def create_task(cls, coroutine): From 3cd66da3c4f5eaf5fb2c1ec4a646f7bb04965c25 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 21:11:59 +0100 Subject: [PATCH 153/192] TaskManager: simplify --- internal_filesystem/lib/mpos/main.py | 4 ++-- internal_filesystem/lib/mpos/task_manager.py | 24 ++++++++------------ 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index c1c80e0..f7785b6 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -90,9 +90,9 @@ def custom_exception_handler(e): # Create limited aiorepl because it's better than nothing: import aiorepl async def asyncio_repl(): - print("Starting very limited asyncio REPL task. Use sys.exit() to stop all asyncio tasks and go to real 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() is created +mpos.TaskManager.create_task(asyncio_repl()) # only gets started when mpos.TaskManager.start() is created async def ota_rollback_cancel(): try: diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py index 0b5f2a8..cee6fb6 100644 --- a/internal_filesystem/lib/mpos/task_manager.py +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -5,35 +5,29 @@ class TaskManager: task_list = [] # might be good to periodically remove tasks that are done, to prevent this list from growing huge - keep_running = True + keep_running = None @classmethod async def _asyncio_thread(cls, ms_to_sleep): print("asyncio_thread started") - while TaskManager.should_keep_running() is True: - #while self.keep_running is True: - #print(f"asyncio_thread tick because {self.keep_running}") - print(f"asyncio_thread tick because {TaskManager.should_keep_running()}") + while cls.keep_running is True: + #print(f"asyncio_thread tick because {cls.keep_running}") await asyncio.sleep_ms(ms_to_sleep) # This delay determines how quickly new tasks can be started, so keep it below human reaction speed print("WARNING: asyncio_thread exited, now asyncio.create_task() won't work anymore") @classmethod def start(cls): - #asyncio.run_until_complete(TaskManager._asyncio_thread(100)) # this actually works, but it blocks the real REPL (aiorepl works, but that's limited) - asyncio.run(TaskManager._asyncio_thread(1000)) # this actually works, but it blocks the real REPL (aiorepl works, but that's limited) + 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 should_keep_running(cls): - return cls.keep_running - - @classmethod - def set_keep_running(cls, value): - cls.keep_running = value - @classmethod def create_task(cls, coroutine): cls.task_list.append(asyncio.create_task(coroutine)) From b8f44efa1e3bf8e5fea9dc7e4ba0b85a4edc55ac Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 21:27:38 +0100 Subject: [PATCH 154/192] /scripts/run_desktop.sh: simplify and fix --- scripts/run_desktop.sh | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/run_desktop.sh b/scripts/run_desktop.sh index 177cd29..1284cf4 100755 --- a/scripts/run_desktop.sh +++ b/scripts/run_desktop.sh @@ -56,15 +56,15 @@ 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" + # 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)" +fi popd From 10b14dbd0d3629bb97b4c39a082d9fc2b6696d4c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 22:06:19 +0100 Subject: [PATCH 155/192] AppStore app: simplify as it's threadsafe by default --- .../apps/com.micropythonos.appstore/assets/appstore.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 04e15c5..cf6ac06 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -66,9 +66,9 @@ async def download_app_index(self, json_url): except Exception as e: print(f"Warning: could not add app from {json_url} to apps list: {e}") except Exception as e: - self.update_ui_threadsafe_if_foreground(self.please_wait_label.set_text, f"ERROR: could not parse reponse.text JSON: {e}") + self.please_wait_label.set_text(f"ERROR: could not parse reponse.text JSON: {e}") return - self.update_ui_threadsafe_if_foreground(self.please_wait_label.set_text, f"Download successful, building list...") + 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() @@ -76,7 +76,7 @@ async def download_app_index(self, json_url): 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.update_ui_threadsafe_if_foreground(self.create_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() @@ -151,7 +151,7 @@ async 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) # add update_ui_threadsafe() for background? + image_icon_widget.set_src(image_dsc) # add update_ui_threadsafe() for background? print("Finished downloading icons.") def show_app_detail(self, app): From 7b4d08d4326cc7a234268edfc4bf965eec51a1d5 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 11 Dec 2025 22:07:04 +0100 Subject: [PATCH 156/192] TaskManager: add disable() functionality and fix unit tests --- internal_filesystem/lib/mpos/main.py | 4 +++- internal_filesystem/lib/mpos/task_manager.py | 8 ++++++++ tests/test_graphical_imu_calibration_ui_bug.py | 2 +- tests/unittest.sh | 10 +++++----- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index f7785b6..e576195 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -85,7 +85,9 @@ 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") # Create limited aiorepl because it's better than nothing: import aiorepl diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py index cee6fb6..1158e53 100644 --- a/internal_filesystem/lib/mpos/task_manager.py +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -6,6 +6,7 @@ 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, ms_to_sleep): @@ -17,6 +18,9 @@ async def _asyncio_thread(cls, ms_to_sleep): @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()) @@ -28,6 +32,10 @@ def start(cls): def stop(cls): cls.keep_running = False + @classmethod + def disable(cls): + cls.disabled = True + @classmethod def create_task(cls, coroutine): cls.task_list.append(asyncio.create_task(coroutine)) diff --git a/tests/test_graphical_imu_calibration_ui_bug.py b/tests/test_graphical_imu_calibration_ui_bug.py index c71df2f..1dcb66f 100755 --- a/tests/test_graphical_imu_calibration_ui_bug.py +++ b/tests/test_graphical_imu_calibration_ui_bug.py @@ -73,7 +73,7 @@ def test_imu_calibration_bug_test(self): # 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=20) + wait_for_render(iterations=40) print("Step 5: Checking BEFORE calibration...") print("Current screen content:") diff --git a/tests/unittest.sh b/tests/unittest.sh index f93cc11..6cb669a 100755 --- a/tests/unittest.sh +++ b/tests/unittest.sh @@ -59,14 +59,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 +86,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 +96,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(): From 61ae548e4c5ae5a70f2838738c1d1c6ad9fa4fa4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 12 Dec 2025 09:58:25 +0100 Subject: [PATCH 157/192] /scripts/install.sh: fix import --- scripts/install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index 984121a..0eafb9c 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 From 658b999929c20ca00e107769bd7868307d858f41 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 12 Dec 2025 09:58:34 +0100 Subject: [PATCH 158/192] TaskManager: comments --- internal_filesystem/lib/mpos/task_manager.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py index 1158e53..38d493c 100644 --- a/internal_filesystem/lib/mpos/task_manager.py +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -9,11 +9,15 @@ class TaskManager: disabled = False @classmethod - async def _asyncio_thread(cls, ms_to_sleep): + 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}") - await asyncio.sleep_ms(ms_to_sleep) # This delay determines how quickly new tasks can be started, so keep it below human reaction speed + #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 From 382a366a7479d17774820bebff71152a6a885bea Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 14 Dec 2025 20:15:12 +0100 Subject: [PATCH 159/192] AppStore app: add support for badgehub (disabled) --- .../assets/appstore.py | 226 ++++++++++++++---- 1 file changed, 183 insertions(+), 43 deletions(-) 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 cf6ac06..48e39db 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -7,17 +7,28 @@ import time import _thread - from mpos.apps import Activity, Intent from mpos.app import App from mpos import TaskManager 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 aiohttp_session = None # one session for the whole app is more performant @@ -48,7 +59,10 @@ 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: - TaskManager.create_task(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 onDestroy(self, screen): await self.aiohttp_session.close() @@ -60,9 +74,14 @@ async def download_app_index(self, json_url): return print(f"Got response text: {response[0:20]}") try: - for app in json.loads(response): + parsed = json.loads(response) + print(f"parsed json: {parsed}") + for app in parsed: 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"])) + 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: @@ -157,6 +176,7 @@ async def download_icons(self): def show_app_detail(self, app): intent = Intent(activity_class=AppDetail) intent.putExtra("app", app) + intent.putExtra("appstore", self) self.startActivity(intent) async def download_url(self, url): @@ -170,6 +190,86 @@ async def download_url(self, url): except Exception as e: print(f"download_url got exception {e}") + @staticmethod + 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: + 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 self.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") + break # only one .mpk per app is supported + except Exception as e: + print(f"Could not get files from version: {e}") + try: + app_metadata = version.get("app_metadata") + except Exception as e: + print(f"Could not get app_metadata object from version object: {e}") + return + try: + author = app_metadata.get("author") + print("Using author as publisher because that's all the backend supports...") + app_obj.publisher = author + except Exception as e: + print(f"Could not get author from version object: {e}") + try: + app_version = app_metadata.get("version") + print(f"what: {version.get('app_metadata')}") + print(f"app has app_version: {app_version}") + app_obj.version = app_version + except Exception as e: + print(f"Could not get version from app_metadata: {e}") + except Exception as e: + err = f"ERROR: could not parse app details JSON: {e}" + print(err) + self.please_wait_label.set_text(err) + return + + class AppDetail(Activity): action_label_install = "Install" @@ -182,10 +282,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)) @@ -200,10 +309,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: @@ -216,54 +325,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: @@ -292,8 +427,11 @@ 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: @@ -309,7 +447,9 @@ def toggle_install(self, download_url, fullname): except Exception as e: print("Could not start uninstall_app thread: ", e) - 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) From a28bb4c727bce53205d066630c8528cf2e88e6c5 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 15 Dec 2025 10:02:37 +0100 Subject: [PATCH 160/192] AppStore app: rewrite install/update to asyncio to eliminate thread --- .../assets/appstore.py | 78 +++++++++++-------- 1 file changed, 45 insertions(+), 33 deletions(-) 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 48e39db..29711ca 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -179,16 +179,39 @@ def show_app_detail(self, app): intent.putExtra("appstore", self) self.startActivity(intent) - async def download_url(self, url): + async def download_url(self, url, outfile=None): print(f"Downloading {url}") #await TaskManager.sleep(4) # test slowness try: async with self.aiohttp_session.get(url) as response: - if response.status >= 200 and response.status < 400: + if response.status < 200 or response.status >= 400: + return None + if not outfile: return await response.read() - print(f"Done downloading {url}") + else: + # Would be good to check free available space first + chunk_size = 1024 + print("headers:") ; print(response.headers) + total_size = response.headers.get('Content-Length') # some servers don't send this + print(f"download_url writing to {outfile} of {total_size} bytes in chunks of size {chunk_size}") + with open(outfile, 'wb') as fd: + print("opened file...") + print(dir(response.content)) + while True: + #print("reading next chunk...") + # Would be better to use wait_for() to handle timeouts: + chunk = await response.content.read(chunk_size) + #print(f"got chunk: {chunk}") + if not chunk: + break + #print("writing chunk...") + fd.write(chunk) + #print("wrote chunk") + print(f"Done downloading {url}") + return True except Exception as e: print(f"download_url got exception {e}") + return False @staticmethod def badgehub_app_to_mpos_app(bhapp): @@ -435,8 +458,7 @@ def toggle_install(self, app_obj): 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)) + TaskManager.create_task(self.download_and_install(download_url, f"apps/{fullname}", fullname)) except Exception as e: print("Could not start download_and_install thread: ", e) elif label_text == self.action_label_uninstall or label_text == self.action_label_restore: @@ -478,48 +500,38 @@ 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 download_and_install(self, zip_url, dest_folder, app_fullname): self.install_button.add_state(lv.STATE.DISABLED) self.install_label.set_text("Please wait...") self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) self.progress_bar.set_value(20, True) - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + 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: + os.remove(temp_zip_path) + except Exception: + pass 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() + os.mkdir("tmp") + except Exception: + pass + self.progress_bar.set_value(40, True) + temp_zip_path = "tmp/temp.mpk" + print(f"Downloading .mpk file from: {zip_url} to {temp_zip_path}") + try: + result = await self.appstore.download_url(zip_url, outfile=temp_zip_path) + if result is not True: + print("Download failed...") self.set_install_label(app_fullname) - self.progress_bar.set_value(40, True) - # Save the .mpk file to a temporary location - try: - os.remove(temp_zip_path) - except Exception: - pass - try: - os.mkdir("tmp") - except Exception: - pass - temp_zip_path = "tmp/temp.mpk" - print(f"Writing to temporary mpk path: {temp_zip_path}") - # TODO: check free available space first! - with open(temp_zip_path, "wb") as f: - f.write(response.content) self.progress_bar.set_value(60, True) - response.close() print("Downloaded .mpk file, size:", os.stat(temp_zip_path)[6], "bytes") except Exception as e: print("Download failed:", str(e)) # Would be good to show error message here if it fails... - finally: - if 'response' in locals(): - response.close() # Step 2: install it: PackageManager.install_mpk(temp_zip_path, dest_folder) # ERROR: temp_zip_path might not be set if download failed! # Success: - time.sleep(1) # seems silly but otherwise it goes so quickly that the user can't tell something happened and gets confused + 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) From 2d8a26b3cba22affaba742ad49ee6edc10cf05cf Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 15 Dec 2025 11:59:47 +0100 Subject: [PATCH 161/192] TaskManager: return task just like asyncio.create_task() --- internal_filesystem/lib/mpos/task_manager.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/task_manager.py b/internal_filesystem/lib/mpos/task_manager.py index 38d493c..995bb5b 100644 --- a/internal_filesystem/lib/mpos/task_manager.py +++ b/internal_filesystem/lib/mpos/task_manager.py @@ -36,13 +36,19 @@ def start(cls): 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): - cls.task_list.append(asyncio.create_task(coroutine)) + task = asyncio.create_task(coroutine) + cls.task_list.append(task) + return task @classmethod def list_tasks(cls): From ac7daa0018ae05067e18581c9966f52fa8eddfe2 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 15 Dec 2025 12:00:27 +0100 Subject: [PATCH 162/192] WebSocket: fix asyncio task not always stopping --- internal_filesystem/lib/websocket.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/lib/websocket.py b/internal_filesystem/lib/websocket.py index 0193027..c76d1e7 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") From 361f8b86d656f8e1858fe21835c34b795b2ae2a2 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 15 Dec 2025 12:01:13 +0100 Subject: [PATCH 163/192] Fix test_websocket.py --- tests/test_websocket.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 8f7cd4c..ed81e8e 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()) From b7844edfca7f4260eaac3d8f669632f7ce1c8f2a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 15 Dec 2025 12:01:31 +0100 Subject: [PATCH 164/192] Fix unittest.sh for aiorepl --- tests/unittest.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unittest.sh b/tests/unittest.sh index 6cb669a..b7959cb 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 @@ -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") From ad735da3cfd8c235b29bce4560da307722e22599 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 15 Dec 2025 12:59:01 +0100 Subject: [PATCH 165/192] AppStore: eliminate last thread! --- .../assets/appstore.py | 40 +++++++------------ 1 file changed, 14 insertions(+), 26 deletions(-) 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 29711ca..1992265 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -4,8 +4,6 @@ import requests import gc import os -import time -import _thread from mpos.apps import Activity, Intent from mpos.app import App @@ -150,7 +148,7 @@ 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.") # maybe this can continue? but then update_ui_threadsafe is needed + 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: try: @@ -170,7 +168,7 @@ async def download_icons(self): 'data_size': len(app.icon_data), 'data': app.icon_data }) - image_icon_widget.set_src(image_dsc) # add update_ui_threadsafe() for background? + 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): @@ -199,7 +197,7 @@ async def download_url(self, url, outfile=None): print(dir(response.content)) while True: #print("reading next chunk...") - # Would be better to use wait_for() to handle timeouts: + # Would be better to use (TaskManager.)wait_for() to handle timeouts: chunk = await response.content.read(chunk_size) #print(f"got chunk: {chunk}") if not chunk: @@ -457,17 +455,11 @@ def toggle_install(self, app_obj): print(f"With {download_url} and fullname {fullname}") label_text = self.install_label.get_text() if label_text == self.action_label_install: - try: - TaskManager.create_task(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(download_url, f"apps/{fullname}", 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, app_obj): download_url = app_obj.download_url @@ -475,22 +467,18 @@ def update_button_click(self, app_obj): 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(download_url, f"apps/{fullname}", 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) @@ -505,7 +493,7 @@ async def download_and_install(self, zip_url, dest_folder, app_fullname): self.install_label.set_text("Please wait...") self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN) self.progress_bar.set_value(20, True) - TaskManager.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 # Download the .mpk file to temporary location try: os.remove(temp_zip_path) @@ -531,7 +519,7 @@ async def download_and_install(self, zip_url, dest_folder, app_fullname): # Step 2: install it: PackageManager.install_mpk(temp_zip_path, dest_folder) # ERROR: temp_zip_path might not be set if download failed! # Success: - TaskManager.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) From 7732435f3a8095f8c79c7ea8b7f14538ec58935d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 15 Dec 2025 13:04:17 +0100 Subject: [PATCH 166/192] AppStore: retry failed chunk 3 times before aborting --- .../assets/appstore.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 1992265..05a6268 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -196,12 +196,20 @@ async def download_url(self, url, outfile=None): print("opened file...") print(dir(response.content)) while True: - #print("reading next chunk...") - # Would be better to use (TaskManager.)wait_for() to handle timeouts: - chunk = await response.content.read(chunk_size) - #print(f"got chunk: {chunk}") + #print("Downloading next chunk...") + tries_left=3 + chunk = None + while tries_left > 0: + try: + chunk = await TaskManager.wait_for(response.content.read(chunk_size), 10) + break + except Exception as e: + print(f"Waiting for response.content.read of next chunk got error: {e}") + tries_left -= 1 + #print(f"Downloaded chunk: {chunk}") if not chunk: - break + print("ERROR: failed to download chunk, even with retries!") + return False #print("writing chunk...") fd.write(chunk) #print("wrote chunk") From 5867a7ed1d3d7ada6a745b2f4439828c6783bbf9 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 15 Dec 2025 13:15:08 +0100 Subject: [PATCH 167/192] AppStore: fix error handling --- .../assets/appstore.py | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) 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 05a6268..63327a1 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -206,15 +206,19 @@ async def download_url(self, url, outfile=None): except Exception as e: print(f"Waiting for response.content.read of next chunk got error: {e}") tries_left -= 1 - #print(f"Downloaded chunk: {chunk}") - if not chunk: + if tries_left == 0: print("ERROR: failed to download chunk, even with retries!") return False - #print("writing chunk...") - fd.write(chunk) - #print("wrote chunk") - print(f"Done downloading {url}") - return True + else: + print(f"Downloaded chunk: {chunk}") + if chunk: + #print("writing chunk...") + fd.write(chunk) + #print("wrote chunk") + else: + print("chunk is None while there was no error so this was the last one") + print(f"Done downloading {url}") + return True except Exception as e: print(f"download_url got exception {e}") return False @@ -504,6 +508,7 @@ async def download_and_install(self, zip_url, dest_folder, app_fullname): 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: + # Make sure there's no leftover file filling the storage os.remove(temp_zip_path) except Exception: pass @@ -514,18 +519,19 @@ async def download_and_install(self, zip_url, dest_folder, app_fullname): self.progress_bar.set_value(40, True) temp_zip_path = "tmp/temp.mpk" print(f"Downloading .mpk file from: {zip_url} to {temp_zip_path}") - try: - result = await self.appstore.download_url(zip_url, outfile=temp_zip_path) - if result is not True: - print("Download failed...") - self.set_install_label(app_fullname) + result = await self.appstore.download_url(zip_url, outfile=temp_zip_path) + if result is not True: + print("Download failed...") # Would be good to show an error to the user if this failed... + else: self.progress_bar.set_value(60, True) 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... - # 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) + # Make sure there's no leftover file filling the storage: + try: + os.remove(temp_zip_path) + except Exception: + pass # Success: 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) From 581d6a69a97f640fe2e1cde1e1f80a7026a42227 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 16 Dec 2025 20:51:19 +0100 Subject: [PATCH 168/192] Update changelog, disable comments, add wifi config to install script --- CHANGELOG.md | 3 +++ .../apps/com.micropythonos.appstore/assets/appstore.py | 2 +- scripts/install.sh | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5136df5..9d53af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ===== - AudioFlinger: optimize WAV volume scaling for speed and immediately set volume - API: add TaskManager that wraps asyncio +- AppStore app: eliminate all thread by using TaskManager +- AppStore app: add support for BadgeHub backend + 0.5.1 ===== 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 63327a1..7d2bdcc 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -210,7 +210,7 @@ async def download_url(self, url, outfile=None): print("ERROR: failed to download chunk, even with retries!") return False else: - print(f"Downloaded chunk: {chunk}") + #print(f"Downloaded chunk: {chunk}") if chunk: #print("writing chunk...") fd.write(chunk) diff --git a/scripts/install.sh b/scripts/install.sh index 0eafb9c..9e4aa66 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -66,6 +66,10 @@ $mpremote fs cp -r builtin :/ #$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) From baf00fe0f5e185e68bbf397a0e69c9adcf96355f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 16 Dec 2025 22:15:59 +0100 Subject: [PATCH 169/192] AppStore app: improve download_url() function --- .../assets/appstore.py | 82 ++++++++++--------- 1 file changed, 45 insertions(+), 37 deletions(-) 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 7d2bdcc..430103f 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -183,45 +183,53 @@ async def download_url(self, url, outfile=None): try: async with self.aiohttp_session.get(url) as response: if response.status < 200 or response.status >= 400: - return None - if not outfile: - return await response.read() - else: - # Would be good to check free available space first - chunk_size = 1024 - print("headers:") ; print(response.headers) - total_size = response.headers.get('Content-Length') # some servers don't send this - print(f"download_url writing to {outfile} of {total_size} bytes in chunks of size {chunk_size}") - with open(outfile, 'wb') as fd: - print("opened file...") - print(dir(response.content)) - while True: - #print("Downloading next chunk...") - tries_left=3 - chunk = None - while tries_left > 0: - try: - chunk = await TaskManager.wait_for(response.content.read(chunk_size), 10) - break - except Exception as e: - print(f"Waiting for response.content.read of next chunk got error: {e}") - tries_left -= 1 - if tries_left == 0: - print("ERROR: failed to download chunk, even with retries!") - return False - else: - #print(f"Downloaded chunk: {chunk}") - if chunk: - #print("writing chunk...") - fd.write(chunk) - #print("wrote chunk") - else: - print("chunk is None while there was no error so this was the last one") - print(f"Done downloading {url}") - return True + return False if outfile else None + + # Always use chunked downloading + chunk_size = 1024 + print("headers:") ; print(response.headers) + total_size = response.headers.get('Content-Length') # some servers don't send this + print(f"download_url {'writing to ' + outfile if outfile else 'reading'} {total_size} bytes in chunks of size {chunk_size}") + + fd = open(outfile, 'wb') if outfile else None + chunks = [] if not outfile else None + + if fd: + print("opened file...") + + while True: + tries_left = 3 + chunk = None + while tries_left > 0: + try: + chunk = await TaskManager.wait_for(response.content.read(chunk_size), 10) + break + except Exception as e: + print(f"Waiting for response.content.read of next chunk got error: {e}") + tries_left -= 1 + + if tries_left == 0: + print("ERROR: failed to download chunk, even with retries!") + if fd: + fd.close() + return False if outfile else None + + if chunk: + if fd: + fd.write(chunk) + else: + chunks.append(chunk) + else: + print("chunk is None while there was no error so this was the last one") + print(f"Done downloading {url}") + if fd: + fd.close() + return True + else: + return b''.join(chunks) except Exception as e: print(f"download_url got exception {e}") - return False + return False if outfile else None @staticmethod def badgehub_app_to_mpos_app(bhapp): From ffc0cd98a0c24b4c9ec42d5c0c71292239c02170 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 16 Dec 2025 22:28:54 +0100 Subject: [PATCH 170/192] AppStore app: show progress in debug log --- .../assets/appstore.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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 430103f..e68ca87 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -177,7 +177,7 @@ def show_app_detail(self, app): intent.putExtra("appstore", self) self.startActivity(intent) - async def download_url(self, url, outfile=None): + async def download_url(self, url, outfile=None, total_size=None): print(f"Downloading {url}") #await TaskManager.sleep(4) # test slowness try: @@ -188,11 +188,13 @@ async def download_url(self, url, outfile=None): # Always use chunked downloading chunk_size = 1024 print("headers:") ; print(response.headers) - total_size = response.headers.get('Content-Length') # some servers don't send this + if total_size is None: + total_size = response.headers.get('Content-Length') # some servers don't send this in the headers print(f"download_url {'writing to ' + outfile if outfile else 'reading'} {total_size} bytes in chunks of size {chunk_size}") fd = open(outfile, 'wb') if outfile else None chunks = [] if not outfile else None + partial_size = 0 if fd: print("opened file...") @@ -215,6 +217,8 @@ async def download_url(self, url, outfile=None): return False if outfile else None if chunk: + partial_size += len(chunk) + print(f"progress: {partial_size} / {total_size} bytes") if fd: fd.write(chunk) else: @@ -283,6 +287,7 @@ async def fetch_badgehub_app_details(self, app_obj): 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}") @@ -476,7 +481,7 @@ def toggle_install(self, app_obj): label_text = self.install_label.get_text() if label_text == self.action_label_install: print("Starting install task...") - TaskManager.create_task(self.download_and_install(download_url, f"apps/{fullname}", fullname)) + TaskManager.create_task(self.download_and_install(app_obj, f"apps/{fullname}")) elif label_text == self.action_label_uninstall or label_text == self.action_label_restore: print("Starting uninstall task...") TaskManager.create_task(self.uninstall_app(fullname)) @@ -487,7 +492,7 @@ def update_button_click(self, app_obj): print(f"Update button clicked for {download_url} and fullname {fullname}") self.update_button.add_flag(lv.obj.FLAG.HIDDEN) self.install_button.set_size(lv.pct(100), 40) - TaskManager.create_task(self.download_and_install(download_url, f"apps/{fullname}", fullname)) + TaskManager.create_task(self.download_and_install(app_obj, f"apps/{fullname}")) async def uninstall_app(self, app_fullname): self.install_button.add_state(lv.STATE.DISABLED) @@ -508,7 +513,10 @@ async 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 - async def download_and_install(self, zip_url, dest_folder, app_fullname): + async def download_and_install(self, app_obj, dest_folder): + zip_url = app_obj.download_url + app_fullname = app_obj.fullname + 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) @@ -527,7 +535,7 @@ async def download_and_install(self, zip_url, dest_folder, app_fullname): self.progress_bar.set_value(40, True) temp_zip_path = "tmp/temp.mpk" print(f"Downloading .mpk file from: {zip_url} to {temp_zip_path}") - result = await self.appstore.download_url(zip_url, outfile=temp_zip_path) + result = await self.appstore.download_url(zip_url, outfile=temp_zip_path, total_size=download_url_size) if result is not True: print("Download failed...") # Would be good to show an error to the user if this failed... else: From 0977ab2c9d6ddadf420e5156fe702035950300bc Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 16 Dec 2025 23:30:58 +0100 Subject: [PATCH 171/192] AppStore: accurate progress bar for download --- .../assets/appstore.py | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) 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 e68ca87..6bd232b 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -177,7 +177,7 @@ def show_app_detail(self, app): intent.putExtra("appstore", self) self.startActivity(intent) - async def download_url(self, url, outfile=None, total_size=None): + async def download_url(self, url, outfile=None, total_size=None, progress_callback=None): print(f"Downloading {url}") #await TaskManager.sleep(4) # test slowness try: @@ -218,7 +218,11 @@ async def download_url(self, url, outfile=None, total_size=None): if chunk: partial_size += len(chunk) - print(f"progress: {partial_size} / {total_size} bytes") + progress_pct = round((partial_size * 100) / int(total_size)) + print(f"progress: {partial_size} / {total_size} bytes = {progress_pct}%") + if progress_callback: + await progress_callback(progress_pct) + #await TaskManager.sleep(1) # test slowness if fd: fd.write(chunk) else: @@ -513,14 +517,26 @@ async 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 + 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 = app_obj.download_url_size + 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) + 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: @@ -532,17 +548,16 @@ async def download_and_install(self, app_obj, dest_folder): os.mkdir("tmp") except Exception: pass - self.progress_bar.set_value(40, True) temp_zip_path = "tmp/temp.mpk" print(f"Downloading .mpk file from: {zip_url} to {temp_zip_path}") - result = await self.appstore.download_url(zip_url, outfile=temp_zip_path, total_size=download_url_size) + result = await self.appstore.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: - self.progress_bar.set_value(60, True) print("Downloaded .mpk file, size:", os.stat(temp_zip_path)[6], "bytes") # Install it: - PackageManager.install_mpk(temp_zip_path, dest_folder) + 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) From c80fa05a771e41d93e79f070513faac4d58d84d9 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 09:47:28 +0100 Subject: [PATCH 172/192] Add chunk_callback to download_url() --- .../assets/appstore.py | 69 +++++++++++++------ 1 file changed, 48 insertions(+), 21 deletions(-) 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 6bd232b..df00403 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -177,7 +177,23 @@ def show_app_detail(self, app): intent.putExtra("appstore", self) self.startActivity(intent) - async def download_url(self, url, outfile=None, total_size=None, progress_callback=None): + ''' + 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 + + Optionally: + - progress_callback is called with the % (0-100) progress + - if total_size is not provided, it will be taken from the response headers (if present) or default to 100KB + + Can return either: + - the actual content + - None: if the content failed to download + - True: if the URL was successfully downloaded (and written to outfile, if provided) + - False: if the URL was not successfully download and written to outfile + ''' + async def download_url(self, url, outfile=None, total_size=None, progress_callback=None, chunk_callback=None): print(f"Downloading {url}") #await TaskManager.sleep(4) # test slowness try: @@ -185,54 +201,65 @@ async def download_url(self, url, outfile=None, total_size=None, progress_callba if response.status < 200 or response.status >= 400: return False if outfile else None - # Always use chunked downloading - chunk_size = 1024 + # Figure out total size print("headers:") ; print(response.headers) if total_size is None: total_size = response.headers.get('Content-Length') # some servers don't send this in the headers - print(f"download_url {'writing to ' + outfile if outfile else 'reading'} {total_size} bytes in chunks of size {chunk_size}") - - fd = open(outfile, 'wb') if outfile else None - chunks = [] if not outfile else None + if total_size is None: + print("WARNING: Unable to determine total_size from server's reply and function arguments, assuming 100KB") + total_size = 100 * 1024 + + fd = None + if outfile: + fd = open(outfile, 'wb') + if not fd: + print("WARNING: could not open {outfile} for writing!") + return False + chunks = [] partial_size = 0 + chunk_size = 1024 - if fd: - print("opened file...") + print(f"download_url {'writing to ' + outfile if outfile else 'downloading'} {total_size} bytes in chunks of size {chunk_size}") while True: tries_left = 3 - chunk = None + chunk_data = None while tries_left > 0: try: - chunk = await TaskManager.wait_for(response.content.read(chunk_size), 10) + chunk_data = await TaskManager.wait_for(response.content.read(chunk_size), 10) break except Exception as e: - print(f"Waiting for response.content.read of next chunk got error: {e}") + print(f"Waiting for response.content.read of next chunk_data got error: {e}") tries_left -= 1 if tries_left == 0: - print("ERROR: failed to download chunk, even with retries!") + print("ERROR: failed to download chunk_data, even with retries!") if fd: fd.close() return False if outfile else None - if chunk: - partial_size += len(chunk) + if chunk_data: + # Output + if fd: + fd.write(chunk_data) + elif chunk_callback: + await chunk_callback(chunk_data) + else: + chunks.append(chunk_data) + # Report progress + partial_size += len(chunk_data) progress_pct = round((partial_size * 100) / int(total_size)) print(f"progress: {partial_size} / {total_size} bytes = {progress_pct}%") if progress_callback: await progress_callback(progress_pct) #await TaskManager.sleep(1) # test slowness - if fd: - fd.write(chunk) - else: - chunks.append(chunk) else: - print("chunk is None while there was no error so this was the last one") - print(f"Done downloading {url}") + print("chunk_data is None while there was no error so this was the last one.\n Finished downloading {url}") if fd: fd.close() return True + elif chunk_callback: + return True else: return b''.join(chunks) except Exception as e: From 7cdea5fe65e0a5d66f641dd95088107770742c8f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 09:52:18 +0100 Subject: [PATCH 173/192] download_url: add headers argument --- .../apps/com.micropythonos.appstore/assets/appstore.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 df00403..fd6e38e 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -186,6 +186,7 @@ def show_app_detail(self, app): Optionally: - progress_callback is called with the % (0-100) progress - if total_size is not provided, it will be taken from the response headers (if present) or default to 100KB + - a dict of headers can be passed, for example: headers['Range'] = f'bytes={self.bytes_written_so_far}-' Can return either: - the actual content @@ -193,11 +194,11 @@ def show_app_detail(self, app): - True: if the URL was successfully downloaded (and written to outfile, if provided) - False: if the URL was not successfully download and written to outfile ''' - async def download_url(self, url, outfile=None, total_size=None, progress_callback=None, chunk_callback=None): + async def download_url(self, url, outfile=None, total_size=None, progress_callback=None, chunk_callback=None, headers=None): print(f"Downloading {url}") #await TaskManager.sleep(4) # test slowness try: - async with self.aiohttp_session.get(url) as response: + async with self.aiohttp_session.get(url, headers=headers) as response: if response.status < 200 or response.status >= 400: return False if outfile else None From 5dd24090f4efa78432d0c6d70721f123662665e4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 12:26:02 +0100 Subject: [PATCH 174/192] Move download_url() to DownloadManager --- .../assets/appstore.py | 106 +---- internal_filesystem/lib/mpos/__init__.py | 5 +- internal_filesystem/lib/mpos/net/__init__.py | 2 + .../lib/mpos/net/download_manager.py | 352 +++++++++++++++ tests/test_download_manager.py | 417 ++++++++++++++++++ 5 files changed, 779 insertions(+), 103 deletions(-) create mode 100644 internal_filesystem/lib/mpos/net/download_manager.py create mode 100644 tests/test_download_manager.py 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 fd6e38e..d02a53e 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/assets/appstore.py @@ -1,4 +1,3 @@ -import aiohttp import lvgl as lv import json import requests @@ -7,7 +6,7 @@ from mpos.apps import Activity, Intent from mpos.app import App -from mpos import TaskManager +from mpos import TaskManager, DownloadManager import mpos.ui from mpos.content.package_manager import PackageManager @@ -28,7 +27,6 @@ class AppStore(Activity): app_index_url_badgehub = _BADGEHUB_API_BASE_URL + "/" + _BADGEHUB_LIST app_detail_url_badgehub = _BADGEHUB_API_BASE_URL + "/" + _BADGEHUB_DETAILS can_check_network = True - aiohttp_session = None # one session for the whole app is more performant # Widgets: main_screen = None @@ -39,7 +37,6 @@ class AppStore(Activity): progress_bar = None def onCreate(self): - self.aiohttp_session = aiohttp.ClientSession() self.main_screen = lv.obj() self.please_wait_label = lv.label(self.main_screen) self.please_wait_label.set_text("Downloading app index...") @@ -62,11 +59,8 @@ def onResume(self, screen): else: TaskManager.create_task(self.download_app_index(self.app_index_url_github)) - def onDestroy(self, screen): - await self.aiohttp_session.close() - async def download_app_index(self, json_url): - response = await self.download_url(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 @@ -152,7 +146,7 @@ async def download_icons(self): break if not app.icon_data: try: - app.icon_data = await TaskManager.wait_for(self.download_url(app.icon_url), 5) # max 5 seconds per icon + 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 @@ -177,96 +171,6 @@ def show_app_detail(self, app): intent.putExtra("appstore", self) self.startActivity(intent) - ''' - 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 - - Optionally: - - progress_callback is called with the % (0-100) progress - - if total_size is not provided, it will be taken from the response headers (if present) or default to 100KB - - a dict of headers can be passed, for example: headers['Range'] = f'bytes={self.bytes_written_so_far}-' - - Can return either: - - the actual content - - None: if the content failed to download - - True: if the URL was successfully downloaded (and written to outfile, if provided) - - False: if the URL was not successfully download and written to outfile - ''' - async def download_url(self, url, outfile=None, total_size=None, progress_callback=None, chunk_callback=None, headers=None): - print(f"Downloading {url}") - #await TaskManager.sleep(4) # test slowness - try: - async with self.aiohttp_session.get(url, headers=headers) as response: - if response.status < 200 or response.status >= 400: - return False if outfile else None - - # Figure out total size - print("headers:") ; print(response.headers) - if total_size is None: - total_size = response.headers.get('Content-Length') # some servers don't send this in the headers - if total_size is None: - print("WARNING: Unable to determine total_size from server's reply and function arguments, assuming 100KB") - total_size = 100 * 1024 - - fd = None - if outfile: - fd = open(outfile, 'wb') - if not fd: - print("WARNING: could not open {outfile} for writing!") - return False - chunks = [] - partial_size = 0 - chunk_size = 1024 - - print(f"download_url {'writing to ' + outfile if outfile else 'downloading'} {total_size} bytes in chunks of size {chunk_size}") - - while True: - tries_left = 3 - chunk_data = None - while tries_left > 0: - try: - chunk_data = await TaskManager.wait_for(response.content.read(chunk_size), 10) - break - except Exception as e: - print(f"Waiting for response.content.read of next chunk_data got error: {e}") - tries_left -= 1 - - if tries_left == 0: - print("ERROR: failed to download chunk_data, even with retries!") - if fd: - fd.close() - return False if outfile else None - - if chunk_data: - # Output - if fd: - fd.write(chunk_data) - elif chunk_callback: - await chunk_callback(chunk_data) - else: - chunks.append(chunk_data) - # Report progress - partial_size += len(chunk_data) - progress_pct = round((partial_size * 100) / int(total_size)) - print(f"progress: {partial_size} / {total_size} bytes = {progress_pct}%") - if progress_callback: - await progress_callback(progress_pct) - #await TaskManager.sleep(1) # test slowness - else: - print("chunk_data is None while there was no error so this was the last one.\n Finished downloading {url}") - if fd: - fd.close() - return True - elif chunk_callback: - return True - else: - return b''.join(chunks) - except Exception as e: - print(f"download_url got exception {e}") - return False if outfile else None - @staticmethod def badgehub_app_to_mpos_app(bhapp): #print(f"Converting {bhapp} to MPOS app object...") @@ -293,7 +197,7 @@ def badgehub_app_to_mpos_app(bhapp): async def fetch_badgehub_app_details(self, app_obj): details_url = self.app_detail_url_badgehub + "/" + app_obj.fullname - response = await self.download_url(details_url) + response = await DownloadManager.download_url(details_url) if not response: print(f"Could not download app details from from\n{details_url}") return @@ -578,7 +482,7 @@ async def download_and_install(self, app_obj, dest_folder): pass temp_zip_path = "tmp/temp.mpk" print(f"Downloading .mpk file from: {zip_url} to {temp_zip_path}") - result = await self.appstore.download_url(zip_url, outfile=temp_zip_path, total_size=download_url_size, progress_callback=self.pcb) + 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: diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 464207b..0746708 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -2,6 +2,7 @@ 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 @@ -13,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/net/__init__.py b/internal_filesystem/lib/mpos/net/__init__.py index 0cc7f35..1af8d8e 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 0000000..0f65e76 --- /dev/null +++ b/internal_filesystem/lib/mpos/net/download_manager.py @@ -0,0 +1,352 @@ +""" +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 +- 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 + async def progress(pct): + print(f"{pct}%") + + success = await DownloadManager.download_url( + "https://example.com/file.bin", + outfile="/sdcard/file.bin", + progress_callback=progress + ) + + # 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 + +# 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): + """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: int) + Called with progress 0-100. + 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-'}) + + 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 + async def on_progress(percent): + print(f"Progress: {percent}%") + + success = await DownloadManager.download_url( + "https://example.com/large.bin", + outfile="/sdcard/large.bin", + progress_callback=on_progress + ) + + # 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 + + 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) + + # Report progress + partial_size += len(chunk_data) + progress_pct = round((partial_size * 100) / int(total_size)) + print(f"DownloadManager: Progress: {partial_size} / {total_size} bytes = {progress_pct}%") + if progress_callback: + await progress_callback(progress_pct) + 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/tests/test_download_manager.py b/tests/test_download_manager.py new file mode 100644 index 0000000..0eee141 --- /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()) From 1038dd828cc01e0872dfb0d966d5ffce54874f62 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 13:18:10 +0100 Subject: [PATCH 175/192] Update CLAUDE.md --- CLAUDE.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 05137f0..b00e372 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. From 4b9a147deb04020d4297dfb5b74e8415f7531441 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 14:40:30 +0100 Subject: [PATCH 176/192] OSUpdate app: eliminate thread by using TaskManager and DownloadManager --- .../assets/osupdate.py | 381 ++++++++++-------- tests/network_test_helper.py | 214 ++++++++++ tests/test_osupdate.py | 217 +++++----- 3 files changed, 542 insertions(+), 270 deletions(-) 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 deceb59..82236fa 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 @@ -256,11 +255,9 @@ def install_button_click(self): 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 +272,36 @@ def check_again_click(self): self.set_state(UpdateState.CHECKING_UPDATE) self.schedule_show_update_info() - def progress_callback(self, percent): + async def async_progress_callback(self, percent): + """Async progress callback for DownloadManager.""" 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) + # 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) - # 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, 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 +314,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 +323,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 +343,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 +393,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 +416,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 +459,87 @@ 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, 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-100 should_continue_callback: Optional callback function() -> bool Returns False to cancel download @@ -460,9 +550,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 +559,99 @@ def download_and_install(self, url, progress_callback=None, should_continue_call 'paused': False } + # Store callbacks for use in _process_chunk + self._progress_callback = progress_callback + self._should_continue = True + self._total_bytes_received = 0 + try: - # Get OTA partition - next_partition = None - if not self.simulate: - current = self.partition_module(self.partition_module.RUNNING) - next_partition = current.get_next_update() - print(f"UpdateDownloader: Writing to partition: {next_partition}") + # Setup partition + self._setup_partition() + + # Initialize block index from resume position + self._block_index = self.bytes_written_so_far // self.CHUNK_SIZE - # Start download (or resume if we have bytes_written_so_far) - headers = {} + # Build headers for resume + headers = None if self.bytes_written_so_far > 0: - headers['Range'] = f'bytes={self.bytes_written_so_far}-' + headers = {'Range': f'bytes={self.bytes_written_so_far}-'} print(f"UpdateDownloader: Resuming from byte {self.bytes_written_so_far}") - response = self.requests.get(url, stream=True, headers=headers) + # Get the download manager (use injected one for testing, or global) + dm = self.download_manager if self.download_manager else DownloadManager - # For initial download, get total size + # Create wrapper for chunk callback that checks should_continue + async def chunk_handler(chunk): + if should_continue_callback and not should_continue_callback(): + self._should_continue = False + raise Exception("Download cancelled by user") + await self._process_chunk(chunk) + + # For initial download, we need to get total size first + # DownloadManager doesn't expose Content-Length directly, so we estimate if self.bytes_written_so_far == 0: - total_size = int(response.headers.get('Content-Length', 0)) - result['total_size'] = round_up_to_multiple(total_size, 4096) - self.total_size_expected = result['total_size'] - else: - # For resume, use the stored total size - # (Content-Length will be the remaining bytes, not total) - result['total_size'] = self.total_size_expected + # We'll update total_size_expected as we download + # For now, set a placeholder that will be updated + self.total_size_expected = 0 - print(f"UpdateDownloader: Download target {result['total_size']} bytes") + # Download with streaming chunk callback + # Progress is reported by DownloadManager via progress_callback + 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 + 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/tests/network_test_helper.py b/tests/network_test_helper.py index c811c1f..05349c5 100644 --- a/tests/network_test_helper.py +++ b/tests/network_test_helper.py @@ -680,3 +680,217 @@ def get_sleep_calls(self): def clear_sleep_calls(self): """Clear the sleep call history.""" self._sleep_calls = [] + + +class MockDownloadManager: + """ + Mock DownloadManager for testing async downloads. + + Simulates the mpos.DownloadManager module for testing without actual network I/O. + Supports chunk_callback mode for streaming downloads. + """ + + def __init__(self): + """Initialize mock download manager.""" + 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 # Default chunk size for streaming + + async def download_url(self, url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, headers=None): + """ + Mock async download with flexible output modes. + + Simulates the real DownloadManager behavior including: + - Streaming chunks via chunk_callback + - Progress reporting via progress_callback (based on total size) + - Network failure simulation + + Args: + url: URL to download + outfile: Path to write file (optional) + total_size: Expected size for progress tracking (optional) + progress_callback: Async callback for progress updates (optional) + chunk_callback: Async callback for streaming chunks (optional) + headers: HTTP headers dict (optional) + + Returns: + bytes: Downloaded content (if outfile and chunk_callback are None) + bool: True if successful (when using outfile or chunk_callback) + """ + self.url_received = url + self.headers_received = headers + + # Record call in history + 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 + }) + + if self.should_fail: + if outfile or chunk_callback: + return False + return None + + # Check for immediate failure (fail_after_bytes=0) + if self.fail_after_bytes is not None and self.fail_after_bytes == 0: + raise OSError(-113, "ECONNABORTED") + + # Stream data in chunks + bytes_sent = 0 + chunks = [] + total_data_size = len(self.download_data) + + # Use provided total_size or actual data size for progress calculation + effective_total_size = total_size if total_size else total_data_size + + while bytes_sent < total_data_size: + # Check if we should simulate network failure + 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: + # For file mode, we'd write to file (mock just tracks) + pass + else: + chunks.append(chunk) + + bytes_sent += len(chunk) + + # Report progress (like real DownloadManager does) + if progress_callback and effective_total_size > 0: + percent = round((bytes_sent * 100) / effective_total_size) + await progress_callback(percent) + + # Return based on mode + 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. + + Args: + data: Bytes to return from download + """ + self.download_data = data + + def set_should_fail(self, should_fail): + """ + Configure whether downloads should fail. + + Args: + should_fail: True to make downloads fail + """ + self.should_fail = should_fail + + def set_fail_after_bytes(self, bytes_count): + """ + Configure network failure after specified bytes. + + Args: + bytes_count: Number of bytes to send before failing + """ + self.fail_after_bytes = bytes_count + + def clear_history(self): + """Clear the call history.""" + self.call_history = [] + + +class MockTaskManager: + """ + Mock TaskManager for testing async operations. + + Provides mock implementations of TaskManager methods for testing. + """ + + def __init__(self): + """Initialize mock task manager.""" + self.tasks_created = [] + self.sleep_calls = [] + + @classmethod + def create_task(cls, coroutine): + """ + Mock create_task - just runs the coroutine synchronously for testing. + + Args: + coroutine: Coroutine to execute + + Returns: + The coroutine (for compatibility) + """ + # In tests, we typically run with asyncio.run() so just return the coroutine + return coroutine + + @staticmethod + async def sleep(seconds): + """ + Mock async sleep. + + Args: + seconds: Number of seconds to sleep (ignored in mock) + """ + pass # Don't actually sleep in tests + + @staticmethod + async def sleep_ms(milliseconds): + """ + Mock async sleep in milliseconds. + + Args: + milliseconds: Number of milliseconds to sleep (ignored in mock) + """ + pass # Don't actually sleep in tests + + @staticmethod + async def wait_for(awaitable, timeout): + """ + Mock wait_for with timeout. + + Args: + awaitable: Coroutine to await + timeout: Timeout in seconds (ignored in mock) + + Returns: + Result of the awaitable + """ + return await awaitable + + @staticmethod + def notify_event(): + """ + Create a mock async event. + + Returns: + A simple mock event object + """ + 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() diff --git a/tests/test_osupdate.py b/tests/test_osupdate.py index 16e52fd..88687ed 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']) From c944e6924e23ce6093a5ca9a1282c8f36e8e84ec Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 14:45:16 +0100 Subject: [PATCH 177/192] run_desktop: backup and restore config file --- patches/micropython-camera-API.patch | 167 +++++++++++++++++++++++++++ scripts/cleanup_pyc.sh | 1 + scripts/run_desktop.sh | 2 + 3 files changed, 170 insertions(+) create mode 100644 patches/micropython-camera-API.patch create mode 100755 scripts/cleanup_pyc.sh diff --git a/patches/micropython-camera-API.patch b/patches/micropython-camera-API.patch new file mode 100644 index 0000000..c56cc02 --- /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/cleanup_pyc.sh b/scripts/cleanup_pyc.sh new file mode 100755 index 0000000..55f63f4 --- /dev/null +++ b/scripts/cleanup_pyc.sh @@ -0,0 +1 @@ +find internal_filesystem -iname "*.pyc" -exec rm {} \; diff --git a/scripts/run_desktop.sh b/scripts/run_desktop.sh index 1284cf4..63becd2 100755 --- a/scripts/run_desktop.sh +++ b/scripts/run_desktop.sh @@ -62,9 +62,11 @@ if [ -f "$script" ]; then "$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 From 23a8f92ea9a0e915e3aeb688ef68e3d15c6365e9 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 15:02:31 +0100 Subject: [PATCH 178/192] OSUpdate app: show download speed DownloadManager: add support for download speed --- .../assets/osupdate.py | 44 +++++++++-- .../lib/mpos/net/download_manager.py | 77 +++++++++++++++---- tests/network_test_helper.py | 35 +++++++-- 3 files changed, 128 insertions(+), 28 deletions(-) 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 82236fa..20b0579 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -20,6 +20,7 @@ class OSUpdate(Activity): main_screen = None progress_label = None progress_bar = None + speed_label = None # State management current_state = None @@ -249,7 +250,12 @@ 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) @@ -273,14 +279,36 @@ def check_again_click(self): self.schedule_show_update_info() async def async_progress_callback(self, percent): - """Async progress callback for DownloadManager.""" - print(f"OTA Update: {percent:.1f}%") + """Async progress callback for DownloadManager. + + Args: + percent: Progress percentage with 2 decimal places (0.00 - 100.00) + """ + print(f"OTA Update: {percent:.2f}%") # UI updates are safe from async context in MicroPythonOS (runs on main thread) if self.has_foreground(): self.progress_bar.set_value(int(percent), True) self.progress_label.set_text(f"OTA Update: {percent:.2f}%") await TaskManager.sleep_ms(50) + async def async_speed_callback(self, bytes_per_second): + """Async speed callback for DownloadManager. + + Args: + bytes_per_second: Download speed in bytes per second + """ + # Convert to human-readable format + if bytes_per_second >= 1024 * 1024: + speed_str = f"{bytes_per_second / (1024 * 1024):.1f} MB/s" + elif bytes_per_second >= 1024: + speed_str = f"{bytes_per_second / 1024:.1f} KB/s" + else: + speed_str = f"{bytes_per_second:.0f} B/s" + + print(f"Download speed: {speed_str}") + if self.has_foreground() and self.speed_label: + self.speed_label.set_text(f"Speed: {speed_str}") + async def perform_update(self): """Download and install update using async patterns. @@ -295,6 +323,7 @@ async def perform_update(self): result = await self.update_downloader.download_and_install( url, progress_callback=self.async_progress_callback, + speed_callback=self.async_speed_callback, should_continue_callback=self.has_foreground ) @@ -531,7 +560,7 @@ async def _flush_buffer(self): 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, should_continue_callback=None): + 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. @@ -539,7 +568,9 @@ async def download_and_install(self, url, progress_callback=None, should_continu Args: url: URL to download firmware from progress_callback: Optional async callback function(percent: float) - Called by DownloadManager with progress 0-100 + 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 @@ -595,12 +626,13 @@ async def chunk_handler(chunk): self.total_size_expected = 0 # Download with streaming chunk callback - # Progress is reported by DownloadManager via progress_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 ) diff --git a/internal_filesystem/lib/mpos/net/download_manager.py b/internal_filesystem/lib/mpos/net/download_manager.py index 0f65e76..ed9db2a 100644 --- a/internal_filesystem/lib/mpos/net/download_manager.py +++ b/internal_filesystem/lib/mpos/net/download_manager.py @@ -11,7 +11,8 @@ - Automatic session lifecycle management - Thread-safe session access - Retry logic (3 attempts per chunk, 10s timeout) -- Progress tracking +- Progress tracking with 2-decimal precision +- Download speed reporting - Resume support via Range headers Example: @@ -20,14 +21,18 @@ # Download to memory data = await DownloadManager.download_url("https://api.example.com/data.json") - # Download to file with progress - async def progress(pct): - print(f"{pct}%") + # 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=progress + progress_callback=on_progress, + speed_callback=on_speed ) # Stream processing @@ -46,6 +51,7 @@ async def process_chunk(chunk): _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 @@ -169,7 +175,8 @@ async def close_session(): async def download_url(url, outfile=None, total_size=None, - progress_callback=None, chunk_callback=None, headers=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: @@ -182,11 +189,14 @@ async def download_url(url, outfile=None, total_size=None, 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: int) - Called with progress 0-100. + 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) @@ -199,14 +209,18 @@ async def download_url(url, outfile=None, total_size=None, # Download to memory data = await DownloadManager.download_url("https://example.com/file.json") - # Download to file with progress + # Download to file with progress and speed async def on_progress(percent): - print(f"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 + progress_callback=on_progress, + speed_callback=on_speed ) # Stream processing @@ -282,6 +296,18 @@ async def on_chunk(chunk): 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}") @@ -317,12 +343,31 @@ async def on_chunk(chunk): else: chunks.append(chunk_data) - # Report progress - partial_size += len(chunk_data) - progress_pct = round((partial_size * 100) / int(total_size)) - print(f"DownloadManager: Progress: {partial_size} / {total_size} bytes = {progress_pct}%") - if progress_callback: + # 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}") diff --git a/tests/network_test_helper.py b/tests/network_test_helper.py index 05349c5..9d5bebe 100644 --- a/tests/network_test_helper.py +++ b/tests/network_test_helper.py @@ -699,15 +699,18 @@ def __init__(self): self.url_received = None self.call_history = [] self.chunk_size = 1024 # Default chunk size for streaming + self.simulated_speed_bps = 100 * 1024 # 100 KB/s default simulated speed async def download_url(self, url, outfile=None, total_size=None, - progress_callback=None, chunk_callback=None, headers=None): + progress_callback=None, chunk_callback=None, headers=None, + speed_callback=None): """ Mock async download with flexible output modes. Simulates the real DownloadManager behavior including: - Streaming chunks via chunk_callback - - Progress reporting via progress_callback (based on total size) + - Progress reporting via progress_callback with 2-decimal precision + - Speed reporting via speed_callback - Network failure simulation Args: @@ -715,8 +718,11 @@ async def download_url(self, url, outfile=None, total_size=None, outfile: Path to write file (optional) total_size: Expected size for progress tracking (optional) progress_callback: Async callback for progress updates (optional) + Called with percent as float with 2 decimal places (0.00-100.00) chunk_callback: Async callback for streaming chunks (optional) headers: HTTP headers dict (optional) + speed_callback: Async callback for speed updates (optional) + Called with bytes_per_second as float Returns: bytes: Downloaded content (if outfile and chunk_callback are None) @@ -732,7 +738,8 @@ async def download_url(self, url, outfile=None, total_size=None, 'total_size': total_size, 'headers': headers, 'has_progress_callback': progress_callback is not None, - 'has_chunk_callback': chunk_callback is not None + 'has_chunk_callback': chunk_callback is not None, + 'has_speed_callback': speed_callback is not None }) if self.should_fail: @@ -751,6 +758,13 @@ async def download_url(self, url, outfile=None, total_size=None, # Use provided total_size or actual data size for progress calculation effective_total_size = total_size if total_size else total_data_size + + # Track progress to avoid duplicate callbacks + last_progress_pct = -1.0 + + # Track speed reporting (simulate every ~1000 bytes for testing) + bytes_since_speed_update = 0 + speed_update_threshold = 1000 while bytes_sent < total_data_size: # Check if we should simulate network failure @@ -768,11 +782,20 @@ async def download_url(self, url, outfile=None, total_size=None, chunks.append(chunk) bytes_sent += len(chunk) + bytes_since_speed_update += len(chunk) - # Report progress (like real DownloadManager does) + # Report progress with 2-decimal precision (like real DownloadManager) + # Only call callback if progress changed by at least 0.01% if progress_callback and effective_total_size > 0: - percent = round((bytes_sent * 100) / effective_total_size) - await progress_callback(percent) + percent = round((bytes_sent * 100) / effective_total_size, 2) + if percent != last_progress_pct: + await progress_callback(percent) + last_progress_pct = percent + + # Report speed periodically + if speed_callback and bytes_since_speed_update >= speed_update_threshold: + await speed_callback(self.simulated_speed_bps) + bytes_since_speed_update = 0 # Return based on mode if outfile or chunk_callback: From afe8434bc7d6e6b764f3178be8c395a4217e1c0c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 17:03:42 +0100 Subject: [PATCH 179/192] AudioFlinger: eliminate thread by using TaskManager (asyncio) Also simplify, and move all testing mocks to a dedicated file. --- .../lib/mpos/audio/__init__.py | 23 +- .../lib/mpos/audio/audioflinger.py | 161 +-- .../lib/mpos/audio/stream_rtttl.py | 14 +- .../lib/mpos/audio/stream_wav.py | 39 +- .../lib/mpos/board/fri3d_2024.py | 3 +- internal_filesystem/lib/mpos/board/linux.py | 6 +- .../board/waveshare_esp32_s3_touch_lcd_2.py | 4 +- .../lib/mpos/testing/__init__.py | 77 ++ internal_filesystem/lib/mpos/testing/mocks.py | 730 +++++++++++++ tests/network_test_helper.py | 965 ++---------------- tests/test_audioflinger.py | 194 +--- 11 files changed, 1004 insertions(+), 1212 deletions(-) create mode 100644 internal_filesystem/lib/mpos/testing/__init__.py create mode 100644 internal_filesystem/lib/mpos/testing/mocks.py diff --git a/internal_filesystem/lib/mpos/audio/__init__.py b/internal_filesystem/lib/mpos/audio/__init__.py index 86526aa..86689f8 100644 --- a/internal_filesystem/lib/mpos/audio/__init__.py +++ b/internal_filesystem/lib/mpos/audio/__init__.py @@ -1,17 +1,12 @@ # AudioFlinger - Centralized Audio Management Service for MicroPythonOS # Android-inspired audio routing with priority-based audio focus +# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer 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, @@ -25,17 +20,14 @@ resume, set_volume, get_volume, - get_device_type, is_playing, + + # Hardware availability checks + has_i2s, + has_buzzer, ) __all__ = [ - # Device types - 'DEVICE_NULL', - 'DEVICE_I2S', - 'DEVICE_BUZZER', - 'DEVICE_BOTH', - # Stream types 'STREAM_MUSIC', 'STREAM_NOTIFICATION', @@ -50,6 +42,7 @@ 'resume', 'set_volume', 'get_volume', - 'get_device_type', 'is_playing', + 'has_i2s', + 'has_buzzer', ] diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py index 167eea5..543aa4c 100644 --- a/internal_filesystem/lib/mpos/audio/audioflinger.py +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -1,12 +1,11 @@ # 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 +# Uses TaskManager (asyncio) for non-blocking background playback -# 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 +from mpos.task_manager import TaskManager # Stream type constants (priority order: higher number = higher priority) STREAM_MUSIC = 0 # Background music (lowest priority) @@ -14,45 +13,47 @@ 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 +_current_task = None # Currently running playback task _volume = 50 # System volume (0-100) -_stream_lock = None # Thread lock for stream management -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)" - } - print(f"AudioFlinger initialized: {device_names.get(device_type, 'Unknown')}") +def has_buzzer(): + """Check if buzzer is available for RTTTL playback.""" + return _buzzer_instance is not None def _check_audio_focus(stream_type): @@ -85,35 +86,27 @@ def _check_audio_focus(stream_type): return True -def _playback_thread(stream): +async def _playback_coroutine(stream): """ - Background thread function for audio playback. + Async coroutine for audio playback. Args: stream: Stream instance (WAVStream or RTTTLStream) """ - global _current_stream + global _current_stream, _current_task - # Acquire lock and set as current stream - if _stream_lock: - _stream_lock.acquire() _current_stream = stream - if _stream_lock: - _stream_lock.release() try: - # Run playback (blocks until complete or stopped) - stream.play() + # Run async playback + await stream.play_async() 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() + _current_task = None def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None): @@ -129,29 +122,19 @@ 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 + global _current_task 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 as async task try: from mpos.audio.stream_wav import WAVStream - import _thread - import mpos.apps stream = WAVStream( file_path=file_path, @@ -161,8 +144,7 @@ def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None) on_complete=on_complete ) - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(_playback_thread, (stream,)) + _current_task = TaskManager.create_task(_playback_coroutine(stream)) return True except Exception as e: @@ -183,29 +165,19 @@ 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 + global _current_task 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 as async task try: from mpos.audio.stream_rtttl import RTTTLStream - import _thread - import mpos.apps stream = RTTTLStream( rtttl_string=rtttl_string, @@ -215,8 +187,7 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co on_complete=on_complete ) - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(_playback_thread, (stream,)) + _current_task = TaskManager.create_task(_playback_coroutine(stream)) return True except Exception as e: @@ -226,10 +197,7 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co def stop(): """Stop current audio playback.""" - global _current_stream - - if _stream_lock: - _stream_lock.acquire() + global _current_stream, _current_task if _current_stream: _current_stream.stop() @@ -237,49 +205,30 @@ def stop(): else: print("AudioFlinger: No playback to stop") - if _stream_lock: - _stream_lock.release() - def pause(): """ Pause current audio playback (if supported by stream). Note: Most streams don't support pause, only stop. """ - global _current_stream - - if _stream_lock: - _stream_lock.acquire() - if _current_stream and hasattr(_current_stream, 'pause'): _current_stream.pause() print("AudioFlinger: Playback paused") else: print("AudioFlinger: Pause not supported or no playback active") - if _stream_lock: - _stream_lock.release() - def resume(): """ Resume paused audio playback (if supported by stream). Note: Most streams don't support resume, only play. """ - global _current_stream - - if _stream_lock: - _stream_lock.acquire() - if _current_stream and hasattr(_current_stream, 'resume'): _current_stream.resume() print("AudioFlinger: Playback resumed") else: print("AudioFlinger: Resume not supported or no playback active") - if _stream_lock: - _stream_lock.release() - def set_volume(volume): """ @@ -304,16 +253,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. @@ -321,12 +260,4 @@ def is_playing(): Returns: bool: True if playback active, False otherwise """ - if _stream_lock: - _stream_lock.acquire() - - result = _current_stream is not None and _current_stream.is_playing() - - if _stream_lock: - _stream_lock.release() - - return result + return _current_stream is not None and _current_stream.is_playing() diff --git a/internal_filesystem/lib/mpos/audio/stream_rtttl.py b/internal_filesystem/lib/mpos/audio/stream_rtttl.py index ea8d0a4..45ccf5c 100644 --- a/internal_filesystem/lib/mpos/audio/stream_rtttl.py +++ b/internal_filesystem/lib/mpos/audio/stream_rtttl.py @@ -1,9 +1,10 @@ # RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger # Ring Tone Text Transfer Language parser and player -# Ported from Fri3d Camp 2024 Badge firmware +# Uses async playback with TaskManager for non-blocking operation import math -import time + +from mpos.task_manager import TaskManager class RTTTLStream: @@ -179,8 +180,8 @@ def _notes(self): yield freq, msec - def play(self): - """Play RTTTL tune via buzzer (runs in background thread).""" + async def play_async(self): + """Play RTTTL tune via buzzer (runs as TaskManager task).""" self._is_playing = True # Calculate exponential duty cycle for perceptually linear volume @@ -212,9 +213,10 @@ def play(self): self.buzzer.duty_u16(duty) # Play for 90% of duration, silent for 10% (note separation) - time.sleep_ms(int(msec * 0.9)) + # Use async sleep to allow other tasks to run + await TaskManager.sleep_ms(int(msec * 0.9)) self.buzzer.duty_u16(0) - time.sleep_ms(int(msec * 0.1)) + await TaskManager.sleep_ms(int(msec * 0.1)) print(f"RTTTLStream: Finished playing '{self.name}'") if self.on_complete: diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index b5a7104..50191a1 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -1,13 +1,14 @@ # 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 async playback with TaskManager for non-blocking operation import machine import micropython import os -import time import sys +from mpos.task_manager import TaskManager + # 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. @@ -313,8 +314,8 @@ def _upsample_buffer(raw, factor): # ---------------------------------------------------------------------- # Main playback routine # ---------------------------------------------------------------------- - def play(self): - """Main playback routine (runs in background thread).""" + async def play_async(self): + """Main async playback routine (runs as TaskManager task).""" self._is_playing = True try: @@ -363,23 +364,12 @@ def play(self): print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)") f.seek(data_start) - # smaller chunk size means less jerks but buffer can run empty - # at 22050 Hz, 16-bit, 2-ch, 4096/4 = 1024 samples / 22050 = 46ms - # with rough volume scaling: - # 4096 => audio stutters during quasibird at ~20fps - # 8192 => no audio stutters and quasibird runs at ~16 fps => good compromise! - # 16384 => no audio stutters during quasibird but low framerate (~8fps) - # with optimized volume scaling: - # 6144 => audio stutters and quasibird at ~17fps - # 7168 => audio slightly stutters and quasibird at ~16fps - # 8192 => no audio stutters and quasibird runs at ~15-17fps => this is probably best - # with shift volume scaling: - # 6144 => audio slightly stutters and quasibird at ~16fps?! - # 8192 => no audio stutters, quasibird runs at ~13fps?! - # with power of 2 thing: - # 6144 => audio sutters and quasibird at ~18fps - # 8192 => no audio stutters, quasibird runs at ~14fps - chunk_size = 8192 + # Chunk size tuning notes: + # - Smaller chunks = more responsive to stop(), better async yielding + # - Larger chunks = less overhead, smoother audio + # - 4096 bytes with async yield works well for responsiveness + # - The 32KB I2S buffer handles timing smoothness + chunk_size = 4096 bytes_per_original_sample = (bits_per_sample // 8) * channels total_original = 0 @@ -412,8 +402,6 @@ def play(self): raw = self._upsample_buffer(raw, upsample_factor) # 3. Volume scaling - #shift = 16 - int(self.volume / 6.25) - #_scale_audio_powers_of_2(raw, len(raw), shift) scale = self.volume / 100.0 if scale < 1.0: scale_fixed = int(scale * 32768) @@ -425,9 +413,12 @@ def play(self): else: # Simulate playback timing if no I2S num_samples = len(raw) // (2 * channels) - time.sleep(num_samples / playback_rate) + await TaskManager.sleep(num_samples / playback_rate) total_original += to_read + + # Yield to other async tasks after each chunk + await TaskManager.sleep_ms(0) print(f"WAVStream: Finished playing {self.file_path}") if self.on_complete: diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 19cc307..8eeb104 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -304,9 +304,8 @@ def adc_to_voltage(adc_value): 'sd': 16, } -# 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 ) diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index a82a12c..0ca9ba5 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -100,11 +100,7 @@ def adc_to_voltage(adc_value): # 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 -) +AudioFlinger.init() # === LED HARDWARE === # Note: Desktop builds have no LED hardware 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 e2075c6..15642ee 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 @@ -113,8 +113,8 @@ def adc_to_voltage(adc_value): # === AUDIO HARDWARE === import mpos.audio.audioflinger as AudioFlinger -# Note: Waveshare board has no buzzer or I2S audio: -AudioFlinger.init(device_type=AudioFlinger.DEVICE_NULL) +# Note: Waveshare board has no buzzer or I2S audio +AudioFlinger.init() # === LED HARDWARE === # Note: Waveshare board has no NeoPixel LEDs diff --git a/internal_filesystem/lib/mpos/testing/__init__.py b/internal_filesystem/lib/mpos/testing/__init__.py new file mode 100644 index 0000000..437da22 --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/__init__.py @@ -0,0 +1,77 @@ +""" +MicroPythonOS Testing Module + +Provides mock implementations for testing without actual hardware. +These mocks work on both desktop (unit tests) and device (integration tests). + +Usage: + from mpos.testing import MockMachine, MockTaskManager, MockNetwork + + # Inject mocks before importing modules that use hardware + import sys + sys.modules['machine'] = MockMachine() + + # Or use the helper function + from mpos.testing import inject_mocks + inject_mocks(['machine', 'mpos.task_manager']) +""" + +from .mocks import ( + # Hardware mocks + MockMachine, + MockPin, + MockPWM, + MockI2S, + MockTimer, + MockSocket, + + # MPOS mocks + MockTaskManager, + MockTask, + MockDownloadManager, + + # Network mocks + MockNetwork, + MockRequests, + MockResponse, + MockRaw, + + # Utility mocks + MockTime, + MockJSON, + MockModule, + + # Helper functions + inject_mocks, + create_mock_module, +) + +__all__ = [ + # Hardware mocks + 'MockMachine', + 'MockPin', + 'MockPWM', + 'MockI2S', + 'MockTimer', + 'MockSocket', + + # MPOS mocks + 'MockTaskManager', + 'MockTask', + 'MockDownloadManager', + + # 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 0000000..f0dc6a1 --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/mocks.py @@ -0,0 +1,730 @@ +""" +Mock implementations for MicroPythonOS testing. + +This module provides mock implementations of hardware and system modules +for testing without actual hardware. Works on both desktop and device. +""" + +import sys + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +class MockModule: + """ + Simple class that acts as a module container. + MicroPython doesn't have types.ModuleType, so we use this instead. + """ + pass + + +def create_mock_module(name, **attrs): + """ + Create a mock module with the given attributes. + + Args: + name: Module name (for debugging) + **attrs: Attributes to set on the module + + Returns: + MockModule instance with attributes set + """ + module = MockModule() + module.__name__ = name + for key, value in attrs.items(): + setattr(module, key, value) + return module + + +def inject_mocks(mock_specs): + """ + Inject mock modules into sys.modules. + + Args: + mock_specs: Dict mapping module names to mock instances/classes + e.g., {'machine': MockMachine(), 'mpos.task_manager': mock_tm} + """ + for name, mock in mock_specs.items(): + sys.modules[name] = mock + + +# ============================================================================= +# Hardware Mocks - machine module +# ============================================================================= + +class MockPin: + """Mock machine.Pin for testing GPIO operations.""" + + IN = 0 + OUT = 1 + PULL_UP = 2 + PULL_DOWN = 3 + + def __init__(self, pin_number, mode=None, pull=None): + self.pin_number = pin_number + self.mode = mode + self.pull = pull + self._value = 0 + + def value(self, val=None): + """Get or set pin value.""" + if val is None: + return self._value + self._value = val + + def on(self): + """Set pin high.""" + self._value = 1 + + def off(self): + """Set pin low.""" + self._value = 0 + + +class MockPWM: + """Mock machine.PWM for testing PWM operations (buzzer, etc.).""" + + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + + def freq(self, value=None): + """Get or set frequency.""" + if value is not None: + self.last_freq = value + return self.last_freq + + def duty_u16(self, value=None): + """Get or set duty cycle (16-bit).""" + if value is not None: + self.last_duty = value + return self.last_duty + + def duty(self, value=None): + """Get or set duty cycle (10-bit).""" + if value is not None: + self.last_duty = value * 64 # Convert to 16-bit + return self.last_duty // 64 + + def deinit(self): + """Deinitialize PWM.""" + self.last_freq = 0 + self.last_duty = 0 + + +class MockI2S: + """Mock machine.I2S for testing audio I2S operations.""" + + TX = 0 + RX = 1 + MONO = 0 + STEREO = 1 + + def __init__(self, id, sck=None, ws=None, sd=None, mode=None, + bits=16, format=None, rate=44100, ibuf=None): + self.id = id + self.sck = sck + self.ws = ws + self.sd = sd + self.mode = mode + self.bits = bits + self.format = format + self.rate = rate + self.ibuf = ibuf + self._write_buffer = bytearray(1024) + self._bytes_written = 0 + + def write(self, buf): + """Write audio data (blocking).""" + self._bytes_written += len(buf) + return len(buf) + + def write_readinto(self, write_buf, read_buf): + """Non-blocking write with readback.""" + self._bytes_written += len(write_buf) + return len(write_buf) + + def deinit(self): + """Deinitialize I2S.""" + pass + + +class MockTimer: + """Mock machine.Timer for testing periodic callbacks.""" + + _all_timers = {} + + PERIODIC = 1 + ONE_SHOT = 0 + + def __init__(self, timer_id=-1): + self.timer_id = timer_id + self.callback = None + self.period = None + self.mode = None + self.active = False + if timer_id >= 0: + MockTimer._all_timers[timer_id] = self + + def init(self, period=None, mode=None, callback=None): + """Initialize/configure the timer.""" + self.period = period + self.mode = mode + self.callback = callback + self.active = True + + def deinit(self): + """Deinitialize the timer.""" + self.active = False + self.callback = None + + def trigger(self, *args, **kwargs): + """Manually trigger the timer callback (for testing).""" + if self.callback and self.active: + self.callback(*args, **kwargs) + + @classmethod + def get_timer(cls, timer_id): + """Get a timer by ID.""" + return cls._all_timers.get(timer_id) + + @classmethod + def trigger_all(cls): + """Trigger all active timers (for testing).""" + for timer in cls._all_timers.values(): + if timer.active: + timer.trigger() + + @classmethod + def reset_all(cls): + """Reset all timers (clear registry).""" + cls._all_timers.clear() + + +class MockMachine: + """ + Mock machine module containing all hardware mocks. + + Usage: + sys.modules['machine'] = MockMachine() + """ + + Pin = MockPin + PWM = MockPWM + I2S = MockI2S + Timer = MockTimer + + @staticmethod + def freq(freq=None): + """Get or set CPU frequency.""" + return 240000000 # 240 MHz + + @staticmethod + def reset(): + """Reset the device (no-op in mock).""" + pass + + @staticmethod + def soft_reset(): + """Soft reset the device (no-op in mock).""" + pass + + +# ============================================================================= +# MPOS Mocks - TaskManager +# ============================================================================= + +class MockTask: + """Mock asyncio Task for testing.""" + + def __init__(self): + self.ph_key = 0 + self._done = False + self.coro = None + self._result = None + self._exception = None + + def done(self): + """Check if task is done.""" + return self._done + + def cancel(self): + """Cancel the task.""" + self._done = True + + def result(self): + """Get task result.""" + if self._exception: + raise self._exception + return self._result + + +class MockTaskManager: + """ + Mock TaskManager for testing async operations. + + Usage: + mock_tm = create_mock_module('mpos.task_manager', TaskManager=MockTaskManager) + sys.modules['mpos.task_manager'] = mock_tm + """ + + task_list = [] + + @classmethod + def create_task(cls, coroutine): + """Create a mock task from a coroutine.""" + task = MockTask() + task.coro = coroutine + cls.task_list.append(task) + return task + + @staticmethod + async def sleep(seconds): + """Mock async sleep (no actual delay).""" + pass + + @staticmethod + async def sleep_ms(milliseconds): + """Mock async sleep in milliseconds (no actual delay).""" + pass + + @staticmethod + async def wait_for(awaitable, timeout): + """Mock wait_for with timeout.""" + return await awaitable + + @staticmethod + def notify_event(): + """Create a mock async event.""" + class MockEvent: + def __init__(self): + self._set = False + + async def wait(self): + pass + + def set(self): + self._set = True + + def is_set(self): + return self._set + + return MockEvent() + + @classmethod + def clear_tasks(cls): + """Clear all tracked tasks (for test cleanup).""" + cls.task_list = [] + + +# ============================================================================= +# Network Mocks +# ============================================================================= + +class MockNetwork: + """Mock network module for testing network connectivity.""" + + STA_IF = 0 + AP_IF = 1 + + class MockWLAN: + """Mock WLAN interface.""" + + def __init__(self, interface, connected=True): + self.interface = interface + self._connected = connected + self._active = True + self._config = {} + self._scan_results = [] + + def isconnected(self): + """Return whether the WLAN is connected.""" + return self._connected + + def active(self, is_active=None): + """Get/set whether the interface is active.""" + if is_active is None: + return self._active + self._active = is_active + + def connect(self, ssid, password): + """Simulate connecting to a network.""" + self._connected = True + self._config['ssid'] = ssid + + def disconnect(self): + """Simulate disconnecting from network.""" + self._connected = False + + def config(self, param): + """Get configuration parameter.""" + return self._config.get(param) + + def ifconfig(self): + """Get IP configuration.""" + if self._connected: + return ('192.168.1.100', '255.255.255.0', '192.168.1.1', '8.8.8.8') + return ('0.0.0.0', '0.0.0.0', '0.0.0.0', '0.0.0.0') + + def scan(self): + """Scan for available networks.""" + return self._scan_results + + def __init__(self, connected=True): + self._connected = connected + self._wlan_instances = {} + + def WLAN(self, interface): + """Create or return a WLAN interface.""" + if interface not in self._wlan_instances: + self._wlan_instances[interface] = self.MockWLAN(interface, self._connected) + return self._wlan_instances[interface] + + def set_connected(self, connected): + """Change the connection state of all WLAN interfaces.""" + self._connected = connected + for wlan in self._wlan_instances.values(): + wlan._connected = connected + + +class MockRaw: + """Mock raw HTTP response for streaming.""" + + def __init__(self, content, fail_after_bytes=None): + self.content = content + self.position = 0 + self.fail_after_bytes = fail_after_bytes + + def read(self, size): + """Read a chunk of data.""" + if self.fail_after_bytes is not None and self.position >= self.fail_after_bytes: + raise OSError(-113, "ECONNABORTED") + + chunk = self.content[self.position:self.position + size] + self.position += len(chunk) + return chunk + + +class MockResponse: + """Mock HTTP response.""" + + def __init__(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): + self.status_code = status_code + self.text = text + self.headers = headers or {} + self.content = content + self._closed = False + self.raw = MockRaw(content, fail_after_bytes=fail_after_bytes) + + def close(self): + """Close the response.""" + self._closed = True + + def json(self): + """Parse response as JSON.""" + import json + return json.loads(self.text) + + +class MockRequests: + """Mock requests module for testing HTTP operations.""" + + def __init__(self): + self.last_url = None + self.last_headers = None + self.last_timeout = None + self.last_stream = None + self.last_request = None + self.next_response = None + self.raise_exception = None + self.call_history = [] + + def get(self, url, stream=False, timeout=None, headers=None): + """Mock GET request.""" + self.last_url = url + self.last_headers = headers + self.last_timeout = timeout + self.last_stream = stream + + self.last_request = { + 'method': 'GET', + 'url': url, + 'stream': stream, + 'timeout': timeout, + 'headers': headers or {} + } + self.call_history.append(self.last_request.copy()) + + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + if self.next_response: + response = self.next_response + self.next_response = None + return response + + return MockResponse() + + def post(self, url, data=None, json=None, timeout=None, headers=None): + """Mock POST request.""" + self.last_url = url + self.last_headers = headers + self.last_timeout = timeout + + self.call_history.append({ + 'method': 'POST', + 'url': url, + 'data': data, + 'json': json, + 'timeout': timeout, + 'headers': headers + }) + + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + if self.next_response: + response = self.next_response + self.next_response = None + return response + + return MockResponse() + + def set_next_response(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): + """Configure the next response to return.""" + self.next_response = MockResponse(status_code, text, headers, content, fail_after_bytes=fail_after_bytes) + return self.next_response + + def set_exception(self, exception): + """Configure an exception to raise on the next request.""" + self.raise_exception = exception + + def clear_history(self): + """Clear the call history.""" + self.call_history = [] + + +class MockSocket: + """Mock socket for testing socket operations.""" + + AF_INET = 2 + SOCK_STREAM = 1 + + def __init__(self, af=None, sock_type=None): + self.af = af + self.sock_type = sock_type + self.connected = False + self.bound = False + self.listening = False + self.address = None + self._send_exception = None + self._recv_data = b'' + self._recv_position = 0 + + def connect(self, address): + """Simulate connecting to an address.""" + self.connected = True + self.address = address + + def bind(self, address): + """Simulate binding to an address.""" + self.bound = True + self.address = address + + def listen(self, backlog): + """Simulate listening for connections.""" + self.listening = True + + def send(self, data): + """Simulate sending data.""" + if self._send_exception: + exc = self._send_exception + self._send_exception = None + raise exc + return len(data) + + def recv(self, size): + """Simulate receiving data.""" + chunk = self._recv_data[self._recv_position:self._recv_position + size] + self._recv_position += len(chunk) + return chunk + + def close(self): + """Close the socket.""" + self.connected = False + + def set_send_exception(self, exception): + """Configure an exception to raise on next send().""" + self._send_exception = exception + + def set_recv_data(self, data): + """Configure data to return from recv().""" + self._recv_data = data + self._recv_position = 0 + + +# ============================================================================= +# Utility Mocks +# ============================================================================= + +class MockTime: + """Mock time module for testing time-dependent code.""" + + def __init__(self, start_time=0): + self._current_time_ms = start_time + self._sleep_calls = [] + + def ticks_ms(self): + """Get current time in milliseconds.""" + return self._current_time_ms + + def ticks_diff(self, ticks1, ticks2): + """Calculate difference between two tick values.""" + return ticks1 - ticks2 + + def sleep(self, seconds): + """Simulate sleep (doesn't actually sleep).""" + self._sleep_calls.append(seconds) + + def sleep_ms(self, milliseconds): + """Simulate sleep in milliseconds.""" + self._sleep_calls.append(milliseconds / 1000.0) + + def advance(self, milliseconds): + """Advance the mock time.""" + self._current_time_ms += milliseconds + + def get_sleep_calls(self): + """Get history of sleep calls.""" + return self._sleep_calls + + def clear_sleep_calls(self): + """Clear the sleep call history.""" + self._sleep_calls = [] + + +class MockJSON: + """Mock JSON module for testing JSON parsing.""" + + def __init__(self): + self.raise_exception = None + + def loads(self, text): + """Parse JSON string.""" + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + import json + return json.loads(text) + + def dumps(self, obj): + """Serialize object to JSON string.""" + import json + return json.dumps(obj) + + def set_exception(self, exception): + """Configure an exception to raise on the next loads() call.""" + self.raise_exception = exception + + +class MockDownloadManager: + """Mock DownloadManager for testing async downloads.""" + + def __init__(self): + self.download_data = b'' + self.should_fail = False + self.fail_after_bytes = None + self.headers_received = None + self.url_received = None + self.call_history = [] + self.chunk_size = 1024 + self.simulated_speed_bps = 100 * 1024 + + async def download_url(self, url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, headers=None, + speed_callback=None): + """Mock async download with flexible output modes.""" + self.url_received = url + self.headers_received = headers + + self.call_history.append({ + 'url': url, + 'outfile': outfile, + 'total_size': total_size, + 'headers': headers, + 'has_progress_callback': progress_callback is not None, + 'has_chunk_callback': chunk_callback is not None, + 'has_speed_callback': speed_callback is not None + }) + + if self.should_fail: + if outfile or chunk_callback: + return False + return None + + if self.fail_after_bytes is not None and self.fail_after_bytes == 0: + raise OSError(-113, "ECONNABORTED") + + bytes_sent = 0 + chunks = [] + total_data_size = len(self.download_data) + effective_total_size = total_size if total_size else total_data_size + last_progress_pct = -1.0 + bytes_since_speed_update = 0 + speed_update_threshold = 1000 + + while bytes_sent < total_data_size: + if self.fail_after_bytes is not None and bytes_sent >= self.fail_after_bytes: + raise OSError(-113, "ECONNABORTED") + + chunk = self.download_data[bytes_sent:bytes_sent + self.chunk_size] + + if chunk_callback: + await chunk_callback(chunk) + elif outfile: + pass + else: + chunks.append(chunk) + + bytes_sent += len(chunk) + bytes_since_speed_update += len(chunk) + + if progress_callback and effective_total_size > 0: + percent = round((bytes_sent * 100) / effective_total_size, 2) + if percent != last_progress_pct: + await progress_callback(percent) + last_progress_pct = percent + + if speed_callback and bytes_since_speed_update >= speed_update_threshold: + await speed_callback(self.simulated_speed_bps) + bytes_since_speed_update = 0 + + if outfile or chunk_callback: + return True + else: + return b''.join(chunks) + + def set_download_data(self, data): + """Configure the data to return from downloads.""" + self.download_data = data + + def set_should_fail(self, should_fail): + """Configure whether downloads should fail.""" + self.should_fail = should_fail + + def set_fail_after_bytes(self, bytes_count): + """Configure network failure after specified bytes.""" + self.fail_after_bytes = bytes_count + + def clear_history(self): + """Clear the call history.""" + self.call_history = [] \ No newline at end of file diff --git a/tests/network_test_helper.py b/tests/network_test_helper.py index 9d5bebe..1a6d235 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,318 +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 = [] - - -class MockDownloadManager: - """ - Mock DownloadManager for testing async downloads. - - Simulates the mpos.DownloadManager module for testing without actual network I/O. - Supports chunk_callback mode for streaming downloads. - """ - - def __init__(self): - """Initialize mock download manager.""" - 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 # Default chunk size for streaming - self.simulated_speed_bps = 100 * 1024 # 100 KB/s default simulated speed - - 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. - - Simulates the real DownloadManager behavior including: - - Streaming chunks via chunk_callback - - Progress reporting via progress_callback with 2-decimal precision - - Speed reporting via speed_callback - - Network failure simulation - - Args: - url: URL to download - outfile: Path to write file (optional) - total_size: Expected size for progress tracking (optional) - progress_callback: Async callback for progress updates (optional) - Called with percent as float with 2 decimal places (0.00-100.00) - chunk_callback: Async callback for streaming chunks (optional) - headers: HTTP headers dict (optional) - speed_callback: Async callback for speed updates (optional) - Called with bytes_per_second as float - - Returns: - bytes: Downloaded content (if outfile and chunk_callback are None) - bool: True if successful (when using outfile or chunk_callback) - """ - self.url_received = url - self.headers_received = headers - - # Record call in history - 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 - - # Check for immediate failure (fail_after_bytes=0) - if self.fail_after_bytes is not None and self.fail_after_bytes == 0: - raise OSError(-113, "ECONNABORTED") - - # Stream data in chunks - bytes_sent = 0 - chunks = [] - total_data_size = len(self.download_data) - - # Use provided total_size or actual data size for progress calculation - effective_total_size = total_size if total_size else total_data_size - - # Track progress to avoid duplicate callbacks - last_progress_pct = -1.0 - - # Track speed reporting (simulate every ~1000 bytes for testing) - bytes_since_speed_update = 0 - speed_update_threshold = 1000 - - while bytes_sent < total_data_size: - # Check if we should simulate network failure - 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: - # For file mode, we'd write to file (mock just tracks) - pass - else: - chunks.append(chunk) - - bytes_sent += len(chunk) - bytes_since_speed_update += len(chunk) - - # Report progress with 2-decimal precision (like real DownloadManager) - # Only call callback if progress changed by at least 0.01% - 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 - - # Report speed periodically - if speed_callback and bytes_since_speed_update >= speed_update_threshold: - await speed_callback(self.simulated_speed_bps) - bytes_since_speed_update = 0 - - # Return based on mode - 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. - - Args: - data: Bytes to return from download - """ - self.download_data = data - - def set_should_fail(self, should_fail): - """ - Configure whether downloads should fail. - - Args: - should_fail: True to make downloads fail - """ - self.should_fail = should_fail - - def set_fail_after_bytes(self, bytes_count): - """ - Configure network failure after specified bytes. - - Args: - bytes_count: Number of bytes to send before failing - """ - self.fail_after_bytes = bytes_count - - def clear_history(self): - """Clear the call history.""" - self.call_history = [] - - -class MockTaskManager: - """ - Mock TaskManager for testing async operations. - - Provides mock implementations of TaskManager methods for testing. - """ - - def __init__(self): - """Initialize mock task manager.""" - self.tasks_created = [] - self.sleep_calls = [] - - @classmethod - def create_task(cls, coroutine): - """ - Mock create_task - just runs the coroutine synchronously for testing. - - Args: - coroutine: Coroutine to execute - - Returns: - The coroutine (for compatibility) - """ - # In tests, we typically run with asyncio.run() so just return the coroutine - return coroutine - - @staticmethod - async def sleep(seconds): - """ - Mock async sleep. - - Args: - seconds: Number of seconds to sleep (ignored in mock) - """ - pass # Don't actually sleep in tests - - @staticmethod - async def sleep_ms(milliseconds): - """ - Mock async sleep in milliseconds. - - Args: - milliseconds: Number of milliseconds to sleep (ignored in mock) - """ - pass # Don't actually sleep in tests - - @staticmethod - async def wait_for(awaitable, timeout): - """ - Mock wait_for with timeout. - - Args: - awaitable: Coroutine to await - timeout: Timeout in seconds (ignored in mock) - - Returns: - Result of the awaitable - """ - return await awaitable - - @staticmethod - def notify_event(): - """ - Create a mock async event. - - Returns: - A simple mock event object - """ - 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() +__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 039d6b1..3a4e3b4 100644 --- a/tests/test_audioflinger.py +++ b/tests/test_audioflinger.py @@ -2,66 +2,21 @@ 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, + MockTaskManager, + create_mock_module, + inject_mocks, +) + +# Inject mocks before importing AudioFlinger +inject_mocks({ + 'machine': MockMachine(), + 'mpos.task_manager': create_mock_module('mpos.task_manager', TaskManager=MockTaskManager), +}) # Now import the module to test import mpos.audio.audioflinger as AudioFlinger @@ -79,7 +34,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 +44,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 +90,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 +131,13 @@ 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( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None - ) + AudioFlinger.init(i2s_pins=None, buzzer_instance=None) self.assertEqual(AudioFlinger.get_volume(), 70) From 740f239acca94fc7294c8d6e85640e570628c2b4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 19:09:40 +0100 Subject: [PATCH 180/192] fix(ui/testing): use send_event for reliable label clicks in tests click_label() now detects clickable parent containers and uses send_event(lv.EVENT.CLICKED) instead of simulate_click() for more reliable UI test interactions. This fixes sporadic failures in test_graphical_imu_calibration_ui_bug.py where clicking "Check IMU Calibration" would sometimes fail because simulate_click() wasn't reliably triggering the click event on the parent container. - Add use_send_event parameter to click_label() (default: True) - Detect clickable parent containers and send events directly to them - Verified with 15 consecutive test runs (100% pass rate) --- internal_filesystem/lib/mpos/ui/testing.py | 197 ++++++++++++++++-- .../test_graphical_imu_calibration_ui_bug.py | 5 + 2 files changed, 181 insertions(+), 21 deletions(-) diff --git a/internal_filesystem/lib/mpos/ui/testing.py b/internal_filesystem/lib/mpos/ui/testing.py index df061f7..1f660b2 100644 --- a/internal_filesystem/lib/mpos/ui/testing.py +++ b/internal_filesystem/lib/mpos/ui/testing.py @@ -518,7 +518,7 @@ def _ensure_touch_indev(): print("Created simulated touch input device") -def simulate_click(x, y, press_duration_ms=50): +def simulate_click(x, y, press_duration_ms=100): """ Simulate a touch/click at the specified coordinates. @@ -543,7 +543,7 @@ def simulate_click(x, y, press_duration_ms=50): Args: x: X coordinate to click (in pixels) y: Y coordinate to click (in pixels) - press_duration_ms: How long to hold the press (default: 50ms) + press_duration_ms: How long to hold the press (default: 100ms) Example: from mpos.ui.testing import simulate_click, wait_for_render @@ -568,21 +568,37 @@ def simulate_click(x, y, press_duration_ms=50): _touch_y = y _touch_pressed = True - # Process the press immediately + # Process the press event + lv.task_handler() + time.sleep(0.02) lv.task_handler() - def release_timer_cb(timer): - """Timer callback to release the touch press.""" - global _touch_pressed - _touch_pressed = False - lv.task_handler() # Process the release immediately + # Wait for press duration + time.sleep(press_duration_ms / 1000.0) - # Schedule the release - timer = lv.timer_create(release_timer_cb, press_duration_ms, None) - timer.set_repeat_count(1) + # Release the touch + _touch_pressed = False -def click_button(button_text, timeout=5): - """Find and click a button with given text.""" + # Process the release event - this triggers the CLICKED event + lv.task_handler() + time.sleep(0.02) + lv.task_handler() + time.sleep(0.02) + lv.task_handler() + +def click_button(button_text, timeout=5, use_send_event=True): + """Find and click a button with given text. + + Args: + button_text: Text to search for in button labels + timeout: Maximum time to wait for button to appear (default: 5s) + use_send_event: If True, use send_event() which is more reliable for + triggering button actions. If False, use simulate_click() + which simulates actual touch input. (default: True) + + Returns: + True if button was found and clicked, False otherwise + """ start = time.time() while time.time() - start < timeout: button = find_button_with_text(lv.screen_active(), button_text) @@ -590,28 +606,167 @@ def click_button(button_text, timeout=5): 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']) + 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): - """Find a label with given text and click on it (or its clickable parent).""" +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: - print("Scrolling label to view...") - label.scroll_to_view_recursive(True) - wait_for_render(iterations=50) # needs quite a bit of time + # 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: - print(f"Clicking label '{label_text}' at ({coords['center_x']}, {coords['center_y']})") - simulate_click(coords['center_x'], coords['center_y']) + # 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 diff --git a/tests/test_graphical_imu_calibration_ui_bug.py b/tests/test_graphical_imu_calibration_ui_bug.py index 1dcb66f..c44430e 100755 --- a/tests/test_graphical_imu_calibration_ui_bug.py +++ b/tests/test_graphical_imu_calibration_ui_bug.py @@ -50,6 +50,11 @@ def test_imu_calibration_bug_test(self): 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() From 736b146eda9f82653f5d71458a2fed890313699f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 19:14:29 +0100 Subject: [PATCH 181/192] Increment version number --- CHANGELOG.md | 6 +++++- internal_filesystem/lib/mpos/info.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d53af4..91013d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,13 @@ 0.5.2 ===== - AudioFlinger: optimize WAV volume scaling for speed and immediately set volume -- API: add TaskManager that wraps asyncio +- AudioFlinger: eliminate thread by using TaskManager (asyncio) - AppStore app: eliminate all thread by using TaskManager - AppStore app: add support for BadgeHub backend +- OSUpdate app: show download speed +- API: add TaskManager that wraps asyncio +- API: add DownloadManager that uses TaskManager +- API: use aiorepl to eliminate another thread 0.5.1 diff --git a/internal_filesystem/lib/mpos/info.py b/internal_filesystem/lib/mpos/info.py index 22bb09c..84f78e0 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" From 4836db557bdeaffdcd8460c83c75ed5a0b521a82 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 19:36:32 +0100 Subject: [PATCH 182/192] stream_wav.py: back to 8192 chunk size Still jitters during QuasiBird. --- internal_filesystem/lib/mpos/audio/stream_wav.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index 50191a1..f8ea0fb 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -369,7 +369,7 @@ async def play_async(self): # - Larger chunks = less overhead, smoother audio # - 4096 bytes with async yield works well for responsiveness # - The 32KB I2S buffer handles timing smoothness - chunk_size = 4096 + chunk_size = 8192 bytes_per_original_sample = (bits_per_sample // 8) * channels total_original = 0 From e64b475b103cb8dc422692803fc8b7d1c49de801 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 20:07:51 +0100 Subject: [PATCH 183/192] AudioFlinger: revert to threaded method The TaskManager (asyncio) was jittery when under heavy CPU load. --- .../lib/mpos/audio/audioflinger.py | 34 ++++++------ .../lib/mpos/audio/stream_rtttl.py | 15 +++-- .../lib/mpos/audio/stream_wav.py | 19 +++---- .../lib/mpos/testing/__init__.py | 8 +++ internal_filesystem/lib/mpos/testing/mocks.py | 55 ++++++++++++++++++- tests/test_audioflinger.py | 7 ++- 6 files changed, 96 insertions(+), 42 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py index 543aa4c..e634244 100644 --- a/internal_filesystem/lib/mpos/audio/audioflinger.py +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -3,9 +3,10 @@ # Supports I2S (digital audio) and PWM buzzer (tones/ringtones) # # Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer -# Uses TaskManager (asyncio) for non-blocking background playback +# Uses _thread for non-blocking background playback (separate thread from UI) -from mpos.task_manager import TaskManager +import _thread +import mpos.apps # Stream type constants (priority order: higher number = higher priority) STREAM_MUSIC = 0 # Background music (lowest priority) @@ -16,7 +17,6 @@ _i2s_pins = None # I2S pin configuration dict (created per-stream) _buzzer_instance = None # PWM buzzer instance _current_stream = None # Currently playing stream -_current_task = None # Currently running playback task _volume = 50 # System volume (0-100) @@ -86,27 +86,27 @@ def _check_audio_focus(stream_type): return True -async def _playback_coroutine(stream): +def _playback_thread(stream): """ - Async coroutine 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, _current_task + global _current_stream _current_stream = stream try: - # Run async playback - await stream.play_async() + # Run synchronous playback in this thread + stream.play() except Exception as e: print(f"AudioFlinger: Playback error: {e}") finally: # Clear current stream if _current_stream == stream: _current_stream = None - _current_task = None def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None): @@ -122,8 +122,6 @@ 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 """ - global _current_task - if not _i2s_pins: print("AudioFlinger: play_wav() failed - I2S not configured") return False @@ -132,7 +130,7 @@ def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None) if not _check_audio_focus(stream_type): return False - # Create stream and start playback as async task + # Create stream and start playback in separate thread try: from mpos.audio.stream_wav import WAVStream @@ -144,7 +142,8 @@ def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None) on_complete=on_complete ) - _current_task = TaskManager.create_task(_playback_coroutine(stream)) + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_playback_thread, (stream,)) return True except Exception as e: @@ -165,8 +164,6 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co Returns: bool: True if playback started, False if rejected or unavailable """ - global _current_task - if not _buzzer_instance: print("AudioFlinger: play_rtttl() failed - buzzer not configured") return False @@ -175,7 +172,7 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co if not _check_audio_focus(stream_type): return False - # Create stream and start playback as async task + # Create stream and start playback in separate thread try: from mpos.audio.stream_rtttl import RTTTLStream @@ -187,7 +184,8 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co on_complete=on_complete ) - _current_task = TaskManager.create_task(_playback_coroutine(stream)) + _thread.stack_size(mpos.apps.good_stack_size()) + _thread.start_new_thread(_playback_thread, (stream,)) return True except Exception as e: @@ -197,7 +195,7 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co def stop(): """Stop current audio playback.""" - global _current_stream, _current_task + global _current_stream if _current_stream: _current_stream.stop() diff --git a/internal_filesystem/lib/mpos/audio/stream_rtttl.py b/internal_filesystem/lib/mpos/audio/stream_rtttl.py index 45ccf5c..d02761f 100644 --- a/internal_filesystem/lib/mpos/audio/stream_rtttl.py +++ b/internal_filesystem/lib/mpos/audio/stream_rtttl.py @@ -1,10 +1,9 @@ # RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger # Ring Tone Text Transfer Language parser and player -# Uses async playback with TaskManager for non-blocking operation +# Uses synchronous playback in a separate thread for non-blocking operation import math - -from mpos.task_manager import TaskManager +import time class RTTTLStream: @@ -180,8 +179,8 @@ def _notes(self): yield freq, msec - async def play_async(self): - """Play RTTTL tune via buzzer (runs as TaskManager task).""" + def play(self): + """Play RTTTL tune via buzzer (runs in separate thread).""" self._is_playing = True # Calculate exponential duty cycle for perceptually linear volume @@ -213,10 +212,10 @@ async def play_async(self): self.buzzer.duty_u16(duty) # Play for 90% of duration, silent for 10% (note separation) - # Use async sleep to allow other tasks to run - await TaskManager.sleep_ms(int(msec * 0.9)) + # Blocking sleep is OK - we're in a separate thread + time.sleep_ms(int(msec * 0.9)) self.buzzer.duty_u16(0) - await TaskManager.sleep_ms(int(msec * 0.1)) + time.sleep_ms(int(msec * 0.1)) print(f"RTTTLStream: Finished playing '{self.name}'") if self.on_complete: diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index f8ea0fb..10e4801 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -1,13 +1,12 @@ # WAVStream - WAV File Playback Stream for AudioFlinger # Supports 8/16/24/32-bit PCM, mono+stereo, auto-upsampling, volume control -# Uses async playback with TaskManager for non-blocking operation +# Uses synchronous playback in a separate thread for non-blocking operation import machine import micropython import os import sys - -from mpos.task_manager import TaskManager +import time # Volume scaling function - Viper-optimized for ESP32 performance # NOTE: The line below is automatically commented out by build_mpos.sh during @@ -314,8 +313,8 @@ def _upsample_buffer(raw, factor): # ---------------------------------------------------------------------- # Main playback routine # ---------------------------------------------------------------------- - async def play_async(self): - """Main async playback routine (runs as TaskManager task).""" + def play(self): + """Main synchronous playback routine (runs in separate thread).""" self._is_playing = True try: @@ -365,9 +364,8 @@ async def play_async(self): f.seek(data_start) # Chunk size tuning notes: - # - Smaller chunks = more responsive to stop(), better async yielding + # - Smaller chunks = more responsive to stop() # - Larger chunks = less overhead, smoother audio - # - 4096 bytes with async yield works well for responsiveness # - The 32KB I2S buffer handles timing smoothness chunk_size = 8192 bytes_per_original_sample = (bits_per_sample // 8) * channels @@ -407,18 +405,15 @@ async def play_async(self): scale_fixed = int(scale * 32768) _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: # Simulate playback timing if no I2S num_samples = len(raw) // (2 * channels) - await TaskManager.sleep(num_samples / playback_rate) + time.sleep(num_samples / playback_rate) total_original += to_read - - # Yield to other async tasks after each chunk - await TaskManager.sleep_ms(0) print(f"WAVStream: Finished playing {self.file_path}") if self.on_complete: diff --git a/internal_filesystem/lib/mpos/testing/__init__.py b/internal_filesystem/lib/mpos/testing/__init__.py index 437da22..cb0d219 100644 --- a/internal_filesystem/lib/mpos/testing/__init__.py +++ b/internal_filesystem/lib/mpos/testing/__init__.py @@ -30,6 +30,10 @@ MockTask, MockDownloadManager, + # Threading mocks + MockThread, + MockApps, + # Network mocks MockNetwork, MockRequests, @@ -60,6 +64,10 @@ 'MockTask', 'MockDownloadManager', + # Threading mocks + 'MockThread', + 'MockApps', + # Network mocks 'MockNetwork', 'MockRequests', diff --git a/internal_filesystem/lib/mpos/testing/mocks.py b/internal_filesystem/lib/mpos/testing/mocks.py index f0dc6a1..df650a5 100644 --- a/internal_filesystem/lib/mpos/testing/mocks.py +++ b/internal_filesystem/lib/mpos/testing/mocks.py @@ -727,4 +727,57 @@ def set_fail_after_bytes(self, bytes_count): def clear_history(self): """Clear the call history.""" - self.call_history = [] \ No newline at end of file + 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/tests/test_audioflinger.py b/tests/test_audioflinger.py index 3a4e3b4..9211159 100644 --- a/tests/test_audioflinger.py +++ b/tests/test_audioflinger.py @@ -7,15 +7,16 @@ MockMachine, MockPWM, MockPin, - MockTaskManager, - create_mock_module, + MockThread, + MockApps, inject_mocks, ) # Inject mocks before importing AudioFlinger inject_mocks({ 'machine': MockMachine(), - 'mpos.task_manager': create_mock_module('mpos.task_manager', TaskManager=MockTaskManager), + '_thread': MockThread, + 'mpos.apps': MockApps, }) # Now import the module to test From da9f912ab717f9e78dddba4609b674db01dd0fa8 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 21:49:51 +0100 Subject: [PATCH 184/192] AudioFlinger: add support for I2S microphone recording to WAV --- .../META-INF/MANIFEST.JSON | 23 ++ .../assets/sound_recorder.py | 340 ++++++++++++++++++ .../lib/mpos/audio/__init__.py | 20 +- .../lib/mpos/audio/audioflinger.py | 121 ++++++- .../lib/mpos/audio/stream_record.py | 319 ++++++++++++++++ .../lib/mpos/board/fri3d_2024.py | 14 +- internal_filesystem/lib/mpos/board/linux.py | 14 +- tests/test_audioflinger.py | 62 ++++ 8 files changed, 896 insertions(+), 17 deletions(-) create mode 100644 internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py create mode 100644 internal_filesystem/lib/mpos/audio/stream_record.py 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 0000000..eef5faf --- /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 0000000..87baf32 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -0,0 +1,340 @@ +# 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 + MAX_DURATION_MS = 60000 # 60 seconds max recording + RECORDINGS_DIR = "data/com.micropythonos.soundrecorder/recordings" + + # 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() + + # 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("00:00 / 01:00") + 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() + + # Add to focus group + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(self._record_button) + focusgroup.add_obj(self._play_button) + focusgroup.add_obj(self._delete_button) + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + 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 _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}") + + # Start recording + print(f"SoundRecorder: Calling AudioFlinger.record_wav()") + print(f" file_path: {file_path}") + print(f" duration_ms: {self.MAX_DURATION_MS}") + print(f" sample_rate: 16000") + + success = AudioFlinger.record_wav( + file_path=file_path, + duration_ms=self.MAX_DURATION_MS, + on_complete=self._on_recording_complete, + sample_rate=16000 + ) + + 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 + + # Update UI + self._record_button_label.set_text(lv.SYMBOL.AUDIO + " Record") + self._record_button.set_style_bg_color(lv.theme_get_color_primary(None), 0) + self._update_status() + + # Stop timer update + self._stop_timer_update() + + 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 + + # Update UI + self._record_button_label.set_text(lv.SYMBOL.AUDIO + " Record") + self._record_button.set_style_bg_color(lv.theme_get_color_primary(None), 0) + self._update_status() + self._find_last_recording() + + # Stop timer update + self._stop_timer_update() + + 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("00:00 / 01:00") + + 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) + elapsed_sec = elapsed_ms // 1000 + max_sec = self.MAX_DURATION_MS // 1000 + + elapsed_min = elapsed_sec // 60 + elapsed_sec = elapsed_sec % 60 + max_min = max_sec // 60 + max_sec_display = max_sec % 60 + + self._timer_label.set_text( + f"{elapsed_min:02d}:{elapsed_sec:02d} / {max_min:02d}:{max_sec_display:02d}" + ) + + def _on_play_clicked(self, event): + """Handle play button click.""" + if self._last_recording and not self._is_recording: + # Stop any current playback + AudioFlinger.stop() + time.sleep_ms(100) + + # Play the recording + success = AudioFlinger.play_wav( + self._last_recording, + stream_type=AudioFlinger.STREAM_MUSIC, + on_complete=self._on_playback_complete + ) + + if success: + self._status_label.set_text("Playing...") + self._status_label.set_style_text_color(lv.color_hex(0x0000AA), 0) + else: + self._status_label.set_text("Playback failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) + + def _on_playback_complete(self, message): + """Callback when playback finishes.""" + self.update_ui_threadsafe_if_foreground(self._update_status) + + def _on_delete_clicked(self, event): + """Handle delete button click.""" + if self._last_recording and not self._is_recording: + try: + os.remove(self._last_recording) + print(f"SoundRecorder: Deleted {self._last_recording}") + self._find_last_recording() + self._status_label.set_text("Recording deleted") + except Exception as e: + print(f"SoundRecorder: Delete failed: {e}") + self._status_label.set_text("Delete failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), 0) \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/audio/__init__.py b/internal_filesystem/lib/mpos/audio/__init__.py index 86689f8..37be505 100644 --- a/internal_filesystem/lib/mpos/audio/__init__.py +++ b/internal_filesystem/lib/mpos/audio/__init__.py @@ -1,6 +1,6 @@ # AudioFlinger - Centralized Audio Management Service for MicroPythonOS # Android-inspired audio routing with priority-based audio focus -# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer +# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer, record_wav() -> I2S mic from . import audioflinger @@ -11,7 +11,7 @@ STREAM_NOTIFICATION, STREAM_ALARM, - # Core functions + # Core playback functions init, play_wav, play_rtttl, @@ -21,10 +21,15 @@ set_volume, get_volume, is_playing, - + + # Recording functions + record_wav, + is_recording, + # Hardware availability checks has_i2s, has_buzzer, + has_microphone, ) __all__ = [ @@ -33,7 +38,7 @@ 'STREAM_NOTIFICATION', 'STREAM_ALARM', - # Functions + # Playback functions 'init', 'play_wav', 'play_rtttl', @@ -43,6 +48,13 @@ 'set_volume', 'get_volume', 'is_playing', + + # Recording functions + 'record_wav', + 'is_recording', + + # Hardware checks 'has_i2s', 'has_buzzer', + 'has_microphone', ] diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py index e634244..031c395 100644 --- a/internal_filesystem/lib/mpos/audio/audioflinger.py +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -2,8 +2,8 @@ # 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 -# Uses _thread for non-blocking background playback (separate thread from UI) +# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer, record_wav() -> I2S mic +# Uses _thread for non-blocking background playback/recording (separate thread from UI) import _thread import mpos.apps @@ -17,6 +17,7 @@ _i2s_pins = None # I2S pin configuration dict (created per-stream) _buzzer_instance = None # PWM buzzer instance _current_stream = None # Currently playing stream +_current_recording = None # Currently recording stream _volume = 50 # System volume (0-100) @@ -56,6 +57,11 @@ def has_buzzer(): return _buzzer_instance is not None +def has_microphone(): + """Check if I2S microphone is available for recording.""" + return _i2s_pins is not None and 'sd_in' in _i2s_pins + + def _check_audio_focus(stream_type): """ Check if a stream with the given type can start playback. @@ -193,15 +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 + + stopped = False if _current_stream: _current_stream.stop() print("AudioFlinger: Playback stopped") - else: - print("AudioFlinger: No playback to stop") + stopped = True + + if _current_recording: + _current_recording.stop() + print("AudioFlinger: Recording stopped") + stopped = True + + if not stopped: + print("AudioFlinger: No playback or recording to stop") def pause(): @@ -259,3 +358,13 @@ def is_playing(): bool: True if playback active, False otherwise """ return _current_stream is not None and _current_stream.is_playing() + + +def is_recording(): + """ + Check if audio is currently being recorded. + + Returns: + bool: True if recording active, False otherwise + """ + return _current_recording is not None and _current_recording.is_recording() diff --git a/internal_filesystem/lib/mpos/audio/stream_record.py b/internal_filesystem/lib/mpos/audio/stream_record.py new file mode 100644 index 0000000..f284821 --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/stream_record.py @@ -0,0 +1,319 @@ +# 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 + + 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(f, 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 + + # 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')) + + # ---------------------------------------------------------------------- + # 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=0 + ) + 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 + + print(f"RecordStream: max_bytes={max_bytes}, chunk_size={chunk_size}") + + # Open file for appending audio data + with open(self.file_path, 'r+b') as f: + f.seek(44) # Skip header + + buf = bytearray(chunk_size) + + 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 + + # Update header with actual data size + print(f"RecordStream: Updating WAV header with data_size={self._bytes_recorded}") + self._update_wav_header(f, 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/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 8eeb104..3f397cc 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -296,12 +296,18 @@ 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 with I2S and buzzer diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 0ca9ba5..9522344 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -98,9 +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() +# 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 diff --git a/tests/test_audioflinger.py b/tests/test_audioflinger.py index 9211159..da9414e 100644 --- a/tests/test_audioflinger.py +++ b/tests/test_audioflinger.py @@ -142,3 +142,65 @@ def test_volume_default_value(self): # After init, volume should be at default (70) AudioFlinger.init(i2s_pins=None, buzzer_instance=None) self.assertEqual(AudioFlinger.get_volume(), 70) + + +class TestAudioFlingerRecording(unittest.TestCase): + """Test cases for AudioFlinger recording functionality.""" + + def setUp(self): + """Initialize AudioFlinger with microphone before each test.""" + self.buzzer = MockPWM(MockPin(46)) + # I2S pins with microphone input + self.i2s_pins_with_mic = {'sck': 2, 'ws': 47, 'sd': 16, 'sd_in': 15} + # I2S pins without microphone input + self.i2s_pins_no_mic = {'sck': 2, 'ws': 47, 'sd': 16} + + # Reset state + AudioFlinger._current_recording = None + AudioFlinger.set_volume(70) + + AudioFlinger.init( + i2s_pins=self.i2s_pins_with_mic, + buzzer_instance=self.buzzer + ) + + def tearDown(self): + """Clean up after each test.""" + AudioFlinger.stop() + + def test_has_microphone_with_sd_in(self): + """Test has_microphone() returns True when sd_in pin is configured.""" + AudioFlinger.init(i2s_pins=self.i2s_pins_with_mic, buzzer_instance=None) + self.assertTrue(AudioFlinger.has_microphone()) + + def test_has_microphone_without_sd_in(self): + """Test has_microphone() returns False when sd_in pin is not configured.""" + AudioFlinger.init(i2s_pins=self.i2s_pins_no_mic, buzzer_instance=None) + self.assertFalse(AudioFlinger.has_microphone()) + + def test_has_microphone_no_i2s(self): + """Test has_microphone() returns False when no I2S is configured.""" + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + self.assertFalse(AudioFlinger.has_microphone()) + + def test_is_recording_initially_false(self): + """Test that is_recording() returns False initially.""" + self.assertFalse(AudioFlinger.is_recording()) + + def test_record_wav_no_microphone(self): + """Test that record_wav() fails when no microphone is configured.""" + AudioFlinger.init(i2s_pins=self.i2s_pins_no_mic, buzzer_instance=None) + result = AudioFlinger.record_wav("test.wav") + self.assertFalse(result) + + def test_record_wav_no_i2s(self): + """Test that record_wav() fails when no I2S is configured.""" + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + result = AudioFlinger.record_wav("test.wav") + self.assertFalse(result) + + def test_stop_with_no_recording(self): + """Test that stop() can be called when nothing is recording.""" + # Should not raise exception + AudioFlinger.stop() + self.assertFalse(AudioFlinger.is_recording()) From 9286260453ff18a446d2878226368fd119a3cac3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 22:20:24 +0100 Subject: [PATCH 185/192] Fix delay when finalizing sound recording --- CHANGELOG.md | 2 +- .../assets/sound_recorder.py | 2 +- internal_filesystem/lib/mpos/audio/stream_record.py | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91013d7..05d59f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ 0.5.2 ===== - AudioFlinger: optimize WAV volume scaling for speed and immediately set volume -- AudioFlinger: eliminate thread by using TaskManager (asyncio) +- AudioFlinger: add support for I2S microphone recording to WAV - AppStore app: eliminate all thread by using TaskManager - AppStore app: add support for BadgeHub backend - OSUpdate app: show download speed diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py index 87baf32..d6fe4ba 100644 --- a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -36,7 +36,7 @@ class SoundRecorder(Activity): # Constants MAX_DURATION_MS = 60000 # 60 seconds max recording - RECORDINGS_DIR = "data/com.micropythonos.soundrecorder/recordings" + RECORDINGS_DIR = "data/recordings" # UI Widgets _status_label = None diff --git a/internal_filesystem/lib/mpos/audio/stream_record.py b/internal_filesystem/lib/mpos/audio/stream_record.py index f284821..beeeea8 100644 --- a/internal_filesystem/lib/mpos/audio/stream_record.py +++ b/internal_filesystem/lib/mpos/audio/stream_record.py @@ -261,10 +261,8 @@ def record(self): print(f"RecordStream: max_bytes={max_bytes}, chunk_size={chunk_size}") - # Open file for appending audio data - with open(self.file_path, 'r+b') as f: - f.seek(44) # Skip header - + # Open file for appending audio data (append mode to avoid seek issues) + with open(self.file_path, 'ab') as f: buf = bytearray(chunk_size) while self._keep_running and self._bytes_recorded < max_bytes: @@ -294,8 +292,11 @@ def record(self): f.write(buf[:num_read]) self._bytes_recorded += num_read - # Update header with actual data size - print(f"RecordStream: Updating WAV header with data_size={self._bytes_recorded}") + # Close the file first, then reopen to update header + # This avoids the massive delay caused by seeking backwards in a large file + # on ESP32 with SD card (FAT filesystem buffering issue) + print(f"RecordStream: Updating WAV header with data_size={self._bytes_recorded}") + with open(self.file_path, 'r+b') as f: self._update_wav_header(f, self._bytes_recorded) elapsed_ms = time.ticks_diff(time.ticks_ms(), start_time) From 4e83900702c2350949740d28429222d7205cb563 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 22:29:14 +0100 Subject: [PATCH 186/192] Sound Recorder app: max duration 60min (or as much as storage allows) --- .../assets/sound_recorder.py | 97 +++++++++++++++---- 1 file changed, 79 insertions(+), 18 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py index d6fe4ba..294e7eb 100644 --- a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -35,8 +35,13 @@ class SoundRecorder(Activity): """ # Constants - MAX_DURATION_MS = 60000 # 60 seconds max recording 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 @@ -57,6 +62,9 @@ class SoundRecorder(Activity): 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") @@ -69,7 +77,7 @@ def onCreate(self): # Timer display self._timer_label = lv.label(screen) - self._timer_label.set_text("00:00 / 01:00") + 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) @@ -123,6 +131,9 @@ def onCreate(self): 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() @@ -170,6 +181,57 @@ def _find_last_recording(self): 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 @@ -200,17 +262,26 @@ def _start_recording(self): 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.MAX_DURATION_MS}") - print(f" sample_rate: 16000") + 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.MAX_DURATION_MS, + duration_ms=self._current_max_duration_ms, on_complete=self._on_recording_complete, - sample_rate=16000 + sample_rate=self.SAMPLE_RATE ) print(f"SoundRecorder: record_wav returned: {success}") @@ -281,7 +352,7 @@ def _stop_timer_update(self): if self._timer_task: self._timer_task.delete() self._timer_task = None - self._timer_label.set_text("00:00 / 01:00") + self._timer_label.set_text(self._format_timer_text(0)) def _update_timer(self, timer): """Update timer display (called periodically).""" @@ -289,17 +360,7 @@ def _update_timer(self, timer): return elapsed_ms = time.ticks_diff(time.ticks_ms(), self._record_start_time) - elapsed_sec = elapsed_ms // 1000 - max_sec = self.MAX_DURATION_MS // 1000 - - elapsed_min = elapsed_sec // 60 - elapsed_sec = elapsed_sec % 60 - max_min = max_sec // 60 - max_sec_display = max_sec % 60 - - self._timer_label.set_text( - f"{elapsed_min:02d}:{elapsed_sec:02d} / {max_min:02d}:{max_sec_display:02d}" - ) + self._timer_label.set_text(self._format_timer_text(elapsed_ms)) def _on_play_clicked(self, event): """Handle play button click.""" From 5975f518305bb0bd915c091e587c4a8288c568d5 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 22:33:25 +0100 Subject: [PATCH 187/192] SoundRecorder: fix focus issue --- .../assets/sound_recorder.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py index 294e7eb..bc944ec 100644 --- a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -120,13 +120,6 @@ def onCreate(self): delete_label.set_text(lv.SYMBOL.TRASH + " Delete") delete_label.center() - # Add to focus group - focusgroup = lv.group_get_default() - if focusgroup: - focusgroup.add_obj(self._record_button) - focusgroup.add_obj(self._play_button) - focusgroup.add_obj(self._delete_button) - self.setContentView(screen) def onResume(self, screen): From cb05fc48db58ee0322cadce0498e1dd23952304c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 22:37:35 +0100 Subject: [PATCH 188/192] SoundRecorder: add icon --- .../generate_icon.py | 93 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 672 bytes 2 files changed, 93 insertions(+) create mode 100644 internal_filesystem/apps/com.micropythonos.soundrecorder/generate_icon.py create mode 100644 internal_filesystem/apps/com.micropythonos.soundrecorder/res/mipmap-mdpi/icon_64x64.png 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 0000000..f2cfa66 --- /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 0000000000000000000000000000000000000000..a301f72f7944e7a0133db47b14acfca8252546e1 GIT binary patch literal 672 zcmV;R0$=@!P)=B%5JkT;sW=2P8B?Uz6>Tr95*5jS}Q4B>Bi+|gyiWGkQ} zUa6nXL0W&HsRB4mh;G1MDO{{l18hnDp3gxs-xrIZ&--8>f@A=dJ=fVcDeIxgaupCAf)O}2;@sTt0Uai3Kywza z%Z<~70h^%sy+COH1NIqEpx*IOw}Q(AuXrGW0n!8P;wf+Z_q##hCeCKWkO@C|1BkJg zGcf}WTBB47rBe9b?Sf)Swo#M{kQ5L~l*=H;y?_*=2GABLu?=z|-U6Zh4@`UpJahj8 z6J3QlnY{s%y%*pj&w$hkq$V4XI)T*8-hgDc!=KA#=e4iXDS95WuYha-cfh_!+s_t1 zcm}N3>+6%C?RHxLb&?!Uh1;0oZQnZvu@>MyQ&N>BIs>?pmTaqF1I+R>%aT}WU5pjr z`Yc!Z0}=NC62kEtAx_v^z*Yq&zKR%9Eq(DHg~fn&8FDA-iW^$~0AmG6n;;<`U~U1M z386;VVsMEEgnlOH6HUq6j`6+MK86d?Y0KFL+`@?{mzxkHq=XYue=Gcm5z@km+yW9o zrS<@T--zg&;IqZg{}D=^Kx)_xke=Ro5n^Wcdq5^LbN&FR(Ff0ZzT-Ur0000 Date: Wed, 17 Dec 2025 22:52:57 +0100 Subject: [PATCH 189/192] Improve recording UX --- .../assets/sound_recorder.py | 25 +++++++++----- .../lib/mpos/audio/stream_record.py | 34 +++++++++++++++---- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py index bc944ec..b90a10f 100644 --- a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -307,13 +307,17 @@ def _stop_recording(self): AudioFlinger.stop() self._is_recording = False - # Update UI - self._record_button_label.set_text(lv.SYMBOL.AUDIO + " Record") - self._record_button.set_style_bg_color(lv.theme_get_color_primary(None), 0) - self._update_status() + # 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 - # Stop timer update - self._stop_timer_update() + # 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.""" @@ -326,14 +330,17 @@ def _recording_finished(self, message): """Update UI after recording finishes (called on main thread).""" self._is_recording = False - # Update UI + # 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() - # Stop timer update - self._stop_timer_update() + # Reset timer display + self._timer_label.set_text(self._format_timer_text(0)) def _start_timer_update(self): """Start updating the timer display.""" diff --git a/internal_filesystem/lib/mpos/audio/stream_record.py b/internal_filesystem/lib/mpos/audio/stream_record.py index beeeea8..7d08f99 100644 --- a/internal_filesystem/lib/mpos/audio/stream_record.py +++ b/internal_filesystem/lib/mpos/audio/stream_record.py @@ -262,9 +262,14 @@ def record(self): print(f"RecordStream: max_bytes={max_bytes}, chunk_size={chunk_size}") # Open file for appending audio data (append mode to avoid seek issues) - with open(self.file_path, 'ab') as f: - buf = bytearray(chunk_size) + 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) @@ -291,13 +296,30 @@ def record(self): if num_read > 0: f.write(buf[:num_read]) self._bytes_recorded += num_read - - # Close the file first, then reopen to update header + finally: + # Explicitly close the file and measure time + print(f"RecordStream: Closing audio data file...") + t0 = time.ticks_ms() + f.close() + print(f"RecordStream: File closed in {time.ticks_diff(time.ticks_ms(), t0)}ms") + + # Now reopen to update header # This avoids the massive delay caused by seeking backwards in a large file # on ESP32 with SD card (FAT filesystem buffering issue) + print(f"RecordStream: Reopening file to update WAV header...") + t0 = time.ticks_ms() + f = open(self.file_path, 'r+b') + print(f"RecordStream: File reopened in {time.ticks_diff(time.ticks_ms(), t0)}ms") + print(f"RecordStream: Updating WAV header with data_size={self._bytes_recorded}") - with open(self.file_path, 'r+b') as f: - self._update_wav_header(f, self._bytes_recorded) + t0 = time.ticks_ms() + self._update_wav_header(f, self._bytes_recorded) + print(f"RecordStream: Header updated in {time.ticks_diff(time.ticks_ms(), t0)}ms") + + print(f"RecordStream: Closing header file...") + t0 = time.ticks_ms() + f.close() + print(f"RecordStream: Header file closed in {time.ticks_diff(time.ticks_ms(), t0)}ms") elapsed_ms = time.ticks_diff(time.ticks_ms(), start_time) print(f"RecordStream: Finished recording {self._bytes_recorded} bytes ({elapsed_ms}ms)") From d2f80dbfc93940ac62bc7a82098fb1c45a1819f0 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 23:00:08 +0100 Subject: [PATCH 190/192] stream_record.py: add periodic flushing --- .../lib/mpos/audio/stream_record.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_record.py b/internal_filesystem/lib/mpos/audio/stream_record.py index 7d08f99..a03f412 100644 --- a/internal_filesystem/lib/mpos/audio/stream_record.py +++ b/internal_filesystem/lib/mpos/audio/stream_record.py @@ -259,7 +259,13 @@ def record(self): start_time = time.ticks_ms() sample_offset = 0 # For sine wave phase continuity - print(f"RecordStream: max_bytes={max_bytes}, chunk_size={chunk_size}") + # 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...") @@ -296,9 +302,19 @@ def record(self): 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...") + 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") From 29af03e6b30d66688242bcff201a596d16961195 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 23:34:27 +0100 Subject: [PATCH 191/192] stream_record.py: avoid seeking by writing large file size --- .../lib/mpos/audio/stream_record.py | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_record.py b/internal_filesystem/lib/mpos/audio/stream_record.py index a03f412..3a4990f 100644 --- a/internal_filesystem/lib/mpos/audio/stream_record.py +++ b/internal_filesystem/lib/mpos/audio/stream_record.py @@ -46,6 +46,7 @@ class RecordStream: # 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): """ @@ -128,7 +129,7 @@ def _create_wav_header(sample_rate, num_channels, bits_per_sample, data_size): return bytes(header) @staticmethod - def _update_wav_header(f, data_size): + def _update_wav_header(file_path, data_size): """ Update WAV header with final data size. @@ -138,6 +139,8 @@ def _update_wav_header(f, data_size): """ 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')) @@ -146,6 +149,9 @@ def _update_wav_header(f, data_size): f.seek(40) f.write(data_size.to_bytes(4, 'little')) + f.close() + + # ---------------------------------------------------------------------- # Desktop simulation - generate 440Hz sine wave # ---------------------------------------------------------------------- @@ -214,7 +220,7 @@ def record(self): self.sample_rate, num_channels=1, bits_per_sample=16, - data_size=0 + data_size=self.DEFAULT_FILESIZE ) f.write(header) print(f"RecordStream: Header written ({len(header)} bytes)") @@ -319,23 +325,8 @@ def record(self): f.close() print(f"RecordStream: File closed in {time.ticks_diff(time.ticks_ms(), t0)}ms") - # Now reopen to update header - # This avoids the massive delay caused by seeking backwards in a large file - # on ESP32 with SD card (FAT filesystem buffering issue) - print(f"RecordStream: Reopening file to update WAV header...") - t0 = time.ticks_ms() - f = open(self.file_path, 'r+b') - print(f"RecordStream: File reopened in {time.ticks_diff(time.ticks_ms(), t0)}ms") - - print(f"RecordStream: Updating WAV header with data_size={self._bytes_recorded}") - t0 = time.ticks_ms() - self._update_wav_header(f, self._bytes_recorded) - print(f"RecordStream: Header updated in {time.ticks_diff(time.ticks_ms(), t0)}ms") - - print(f"RecordStream: Closing header file...") - t0 = time.ticks_ms() - f.close() - print(f"RecordStream: Header 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)") From eaab2ce3c9fdf6c6a1d9819da03e95b2a71dda2c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 18 Dec 2025 07:30:35 +0100 Subject: [PATCH 192/192] SoundRecorder: update max duration after stopping recording --- .../assets/sound_recorder.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py index b90a10f..3fe5247 100644 --- a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -373,7 +373,8 @@ def _on_play_clicked(self, event): success = AudioFlinger.play_wav( self._last_recording, stream_type=AudioFlinger.STREAM_MUSIC, - on_complete=self._on_playback_complete + on_complete=self._on_playback_complete, + volume=100 ) if success: @@ -394,6 +395,11 @@ def _on_delete_clicked(self, event): 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}")