From 1d9ba8f814f8e39b0618371ae7075c97675eee1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Cebri=C3=A1n?= Date: Sat, 7 Feb 2026 17:28:56 +0100 Subject: [PATCH 001/317] Force M5Stack-Fire board detection for ESP32 M5Stack-Fire board detection should be: i2c0 = I2C(0, sda=Pin(21), scl=Pin(22)) if {0x68} <= set(i2c0.scan()): # IMU (MPU6886) return "m5stack-fire" But there are some pin incompatibilities between ESP32 and ESP32-S3 boards that don't allow I2C scan. --- internal_filesystem/lib/mpos/main.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 360a7423..fff0c65b 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -31,16 +31,7 @@ def detect_board(): if sys.platform == "linux" or sys.platform == "darwin": # linux and macOS return "linux" elif sys.platform == "esp32": - from machine import Pin, I2C - i2c0 = I2C(0, sda=Pin(48), scl=Pin(47)) - if {0x15, 0x6B} <= set(i2c0.scan()): # touch screen and IMU (at least, possibly more) - return "waveshare_esp32_s3_touch_lcd_2" - else: - i2c0 = I2C(0, sda=Pin(9), scl=Pin(18)) - if {0x6B} <= set(i2c0.scan()): # IMU (plus possibly the Communicator's LANA TNY at 0x38) - return "fri3d_2024" - else: # if {0x6A} <= set(i2c0.scan()): # IMU (plus a few others, to be added later, but this should work) - return "fri3d_2026" + return "m5stack_fire" board = detect_board() From 96fb2d19376c6e32bb6e81dd9f4d636618848289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Cebri=C3=A1n?= Date: Sat, 7 Feb 2026 17:32:31 +0100 Subject: [PATCH 002/317] Add support for M5Stack-Fire board M5Stack-Fire uses an ILI9342 instead of an ILI9341. Therefore, lvgl_micropython should include "_ili9341_init_type3.py". --- .../lib/mpos/board/m5stack_fire.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 internal_filesystem/lib/mpos/board/m5stack_fire.py diff --git a/internal_filesystem/lib/mpos/board/m5stack_fire.py b/internal_filesystem/lib/mpos/board/m5stack_fire.py new file mode 100644 index 00000000..2b60bed9 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/m5stack_fire.py @@ -0,0 +1,148 @@ +# Hardware initialization for ESP32 M5Stack-Fire board +# Manufacturer's website at https://https://docs.m5stack.com/en/core/fire_v2.7 +import ili9341 +import lcd_bus +import machine + +import lvgl as lv +import task_handler + +import mpos.ui +import mpos.ui.focus_direction +from mpos import InputManager + +# Pin configuration +SPI_BUS = 1 # SPI2 +SPI_FREQ = 40000000 +LCD_SCLK = 18 +LCD_MOSI = 23 +LCD_DC = 27 +LCD_CS = 14 +LCD_BL = 32 +LCD_RST = 33 +LCD_TYPE = 3 # ILI9341 type 3 (M5Stack-Fire ILI9342) + +TFT_HOR_RES=320 +TFT_VER_RES=240 + +spi_bus = machine.SPI.Bus( + host=SPI_BUS, + mosi=LCD_MOSI, + sck=LCD_SCLK +) +display_bus = lcd_bus.SPIBus( + spi_bus=spi_bus, + freq=SPI_FREQ, + dc=LCD_DC, + cs=LCD_CS +) + +mpos.ui.main_display = ili9341.ILI9341( + data_bus=display_bus, + display_width=TFT_HOR_RES, + display_height=TFT_VER_RES, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=ili9341.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + reset_pin=LCD_RST, + reset_state=ili9341.STATE_LOW, + backlight_pin=LCD_BL, + backlight_on_state=ili9341.STATE_PWM +) +mpos.ui.main_display.init(LCD_TYPE) +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_color_inversion(True) +mpos.ui.main_display.set_backlight(25) + +lv.init() + +# Button handling code: +from machine import Pin +import time + +btn_a = Pin(39, Pin.IN, Pin.PULL_UP) # A +btn_b = Pin(38, Pin.IN, Pin.PULL_UP) # B +btn_c = Pin(37, Pin.IN, Pin.PULL_UP) # C + +# Key repeat configuration +# This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where +# the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus. +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +last_key = None +last_state = lv.INDEV_STATE.RELEASED +key_press_start = 0 # Time when key was first pressed +last_repeat_time = 0 # Time of last repeat event + +# Read callback +# Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, +# that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. +def keypad_read_cb(indev, data): + global last_key, last_state, key_press_start, last_repeat_time + since_last_repeat = 0 + + # Check buttons + current_key = None + current_time = time.ticks_ms() + if btn_a.value() == 0: + current_key = lv.KEY.PREV + elif btn_b.value() == 0: + current_key = lv.KEY.ENTER + elif btn_c.value() == 0: + current_key = lv.KEY.NEXT + + if (btn_a.value() == 0) and (btn_c.value() == 0): + current_key = lv.KEY.ESC + + # Key repeat logic + if current_key: + if current_key != last_key: + # New key press + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + last_key = current_key + last_state = lv.INDEV_STATE.PRESSED + key_press_start = current_time + last_repeat_time = current_time + else: # same key + # Key held: Check for repeat + elapsed = time.ticks_diff(current_time, key_press_start) + since_last_repeat = time.ticks_diff(current_time, last_repeat_time) + if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: + # Send a new PRESSED/RELEASED pair for repeat + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED + last_state = data.state + last_repeat_time = current_time + else: + # No repeat yet, send RELEASED to avoid PRESSING + data.state = lv.INDEV_STATE.RELEASED + last_state = lv.INDEV_STATE.RELEASED + else: + # No key pressed + data.key = last_key if last_key else lv.KEY.ENTER + data.state = lv.INDEV_STATE.RELEASED + last_key = None + last_state = lv.INDEV_STATE.RELEASED + key_press_start = 0 + last_repeat_time = 0 + + # Handle ESC for back navigation (only on initial PRESSED) + if last_state == lv.INDEV_STATE.PRESSED: + if current_key == lv.KEY.ESC and since_last_repeat == 0: + mpos.ui.back_screen() + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(keypad_read_cb) +indev.set_group(group) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +InputManager.register_indev(indev) + +print("m5stack_fire.py finished") From 2e134312df7fe1d305f08eb967ea2f770a63b374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Cebri=C3=A1n?= Date: Sat, 7 Feb 2026 17:37:03 +0100 Subject: [PATCH 003/317] Update build script for ESP32 based M5Stack-Fire --- 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 797c592b..1780eb3d 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -97,7 +97,7 @@ if [ "$target" == "esp32" ]; then manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." - # Build for https://www.waveshare.com/wiki/ESP32-S3-Touch-LCD-2. + # Build for https://https://docs.m5stack.com/en/core/fire_v2.7. # See https://github.com/lvgl-micropython/lvgl_micropython # --ota: support Over-The-Air updates # --partition size: both OTA partitions are 4MB @@ -112,7 +112,7 @@ if [ "$target" == "esp32" ]; then # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y pushd "$codebasedir"/lvgl_micropython/ rm -rf lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/ - python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=ESP32_GENERIC_S3 BOARD_VARIANT=SPIRAM_OCT DISPLAY=st7789 INDEV=cst816s USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y "$frozenmanifest" + python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=ESP32_GENERIC BOARD_VARIANT=SPIRAM DISPLAY=ili9341 USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y "$frozenmanifest" popd echo "Grepping..." pwd From f84bc99790cb884840c23b2b43b4bf96a55749f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Cebri=C3=A1n?= Date: Sun, 8 Feb 2026 11:38:12 +0100 Subject: [PATCH 004/317] Simplify support for M5Stack-Fire board Avoid new "_ili9341_init_type3.py" file dependency in lvgl_micropython by following suggestion: https://github.com/lvgl-micropython/lvgl_micropython/issues/527 --- internal_filesystem/lib/mpos/board/m5stack_fire.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/m5stack_fire.py b/internal_filesystem/lib/mpos/board/m5stack_fire.py index 2b60bed9..2d3f0094 100644 --- a/internal_filesystem/lib/mpos/board/m5stack_fire.py +++ b/internal_filesystem/lib/mpos/board/m5stack_fire.py @@ -20,7 +20,7 @@ LCD_CS = 14 LCD_BL = 32 LCD_RST = 33 -LCD_TYPE = 3 # ILI9341 type 3 (M5Stack-Fire ILI9342) +LCD_TYPE = 2 # ILI9341 type 2 TFT_HOR_RES=320 TFT_VER_RES=240 @@ -37,7 +37,16 @@ cs=LCD_CS ) -mpos.ui.main_display = ili9341.ILI9341( +# M5Stack-Fire ILI9342 uses ILI9341 type 2 with a modified orientation table. +class ILI9341(ili9341.ILI9341): + _ORIENTATION_TABLE = ( + 0x00, + 0x40 | 0x20, # _MADCTL_MX | _MADCTL_MV + 0x80 | 0x40, # _MADCTL_MY | _MADCTL_MX + 0x80 | 0x20 # _MADCTL_MY | _MADCTL_MV + ) + +mpos.ui.main_display = ILI9341( data_bus=display_bus, display_width=TFT_HOR_RES, display_height=TFT_VER_RES, From f355ddfe7fb5ba1535e202fc161d2a27238fc8bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Cebri=C3=A1n?= Date: Sun, 8 Feb 2026 13:26:28 +0100 Subject: [PATCH 005/317] Add M5Stack-Fire board detection for ESP32 --- internal_filesystem/lib/mpos/main.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index fff0c65b..de964d59 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -31,7 +31,24 @@ def detect_board(): if sys.platform == "linux" or sys.platform == "darwin": # linux and macOS return "linux" elif sys.platform == "esp32": - return "m5stack_fire" + from machine import Pin, I2C + # Check for ESP32 boards first to avoid conflicts with SPI flash reserved pins (GPIO6 to GPIO11) + try: + i2c0 = I2C(0, sda=Pin(21), scl=Pin(22)) + if {0x68} <= set(i2c0.scan()): # IMU (MPU6886) + return "m5stack_fire" + except ValueError: # GPIO22 doesn't exist in ESP32-S3 + pass + # Check for ESP32-S3 boards + i2c0 = I2C(0, sda=Pin(48), scl=Pin(47)) + if {0x15, 0x6B} <= set(i2c0.scan()): # touch screen and IMU (at least, possibly more) + return "waveshare_esp32_s3_touch_lcd_2" + else: + i2c0 = I2C(0, sda=Pin(9), scl=Pin(18)) + if {0x6B} <= set(i2c0.scan()): # IMU (plus possibly the Communicator's LANA TNY at 0x38) + return "fri3d_2024" + else: # if {0x6A} <= set(i2c0.scan()): # IMU (plus a few others, to be added later, but this should work) + return "fri3d_2026" board = detect_board() From 95cb22800123f220e1e065b2fe93465fbb104e48 Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Wed, 11 Feb 2026 17:52:21 +0100 Subject: [PATCH 006/317] Use a dot-whitelist I use PyCharm. So there is the .idea directory to exclude. But exclude every variant of these are boring. So i use this idea: Exclude all dot stuff and include the needed one. --- .gitignore | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 43ff56bc..e9927143 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,11 @@ +.* +!.github +!.gitignore + trash/ conf.json* -# macOS file: -.DS_Store - # auto created when running on desktop: internal_filesystem/SDLPointer_2 internal_filesystem/SDLPointer_3 @@ -26,7 +27,6 @@ __pycache__/ *.py[cod] *$py.class *.so -.Python # these get created: c_mpos/c_mpos From 4ee02fbbc84a5ae73dfa105ee57470e72ab55519 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 12 Feb 2026 16:27:21 +0100 Subject: [PATCH 007/317] Add experimental adc_mic.c --- c_mpos/src/adc_mic.c | 83 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 c_mpos/src/adc_mic.c diff --git a/c_mpos/src/adc_mic.c b/c_mpos/src/adc_mic.c new file mode 100644 index 00000000..0eb46c02 --- /dev/null +++ b/c_mpos/src/adc_mic.c @@ -0,0 +1,83 @@ +#include "py/obj.h" +#include "py/runtime.h" +#include "py/mphal.h" +#include "esp_heap_caps.h" +#include "esp_codec_dev.h" +#include "adc_mic.h" // Include for audio_codec_adc_cfg_t, audio_codec_new_adc_data, etc. + +static mp_obj_t adc_mic_read(void) { + // Configure for mono ADC on GPIO1 (ADC1_CHANNEL_0) at 16kHz + audio_codec_adc_cfg_t cfg = DEFAULT_AUDIO_CODEC_ADC_MONO_CFG(ADC_CHANNEL_0, 16000); + const audio_codec_data_if_t *adc_if = audio_codec_new_adc_data(&cfg); + if (adc_if == NULL) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to initialize ADC data interface")); + } + + // Create codec device for input + esp_codec_dev_cfg_t codec_dev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_IN, + .data_if = adc_if, + }; + esp_codec_dev_handle_t dev = esp_codec_dev_new(&codec_dev_cfg); + if (dev == NULL) { + audio_codec_delete_adc_data(adc_if); + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to create codec device")); + } + + // Set sample info: 16kHz, mono, 16-bit + esp_codec_dev_sample_info_t fs = { + .sample_rate = 16000, + .channel = 1, + .bits_per_sample = 16, + }; + if (esp_codec_dev_open(dev, &fs) != ESP_OK) { + esp_codec_dev_del(dev); + audio_codec_delete_adc_data(adc_if); + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to open codec device")); + } + + // Allocate buffer for 16000 samples (16-bit, so 32000 bytes) + const size_t buf_size = 16000 * sizeof(uint16_t); + uint8_t *audio_buffer = (uint8_t *)heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (audio_buffer == NULL) { + esp_codec_dev_close(dev); + esp_codec_dev_del(dev); + audio_codec_delete_adc_data(adc_if); + mp_raise_OSError(MP_ENOMEM); + } + + // Read the data (blocking until buffer is filled) + int ret = esp_codec_dev_read(dev, audio_buffer, buf_size); + if (ret < 0) { + heap_caps_free(audio_buffer); + esp_codec_dev_close(dev); + esp_codec_dev_del(dev); + audio_codec_delete_adc_data(adc_if); + mp_raise_msg_varg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to read audio data: %d"), ret); + } + + // Create MicroPython bytes object from the buffer + mp_obj_t buf_obj = mp_obj_new_bytes(audio_buffer, ret); + + // Cleanup + heap_caps_free(audio_buffer); + esp_codec_dev_close(dev); + esp_codec_dev_del(dev); + audio_codec_delete_adc_data(adc_if); + + return buf_obj; +} +MP_DEFINE_CONST_FUN_OBJ_0(adc_mic_read_obj, adc_mic_read); + +static const mp_rom_map_elem_t adc_mic_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_adc_mic) }, + { MP_ROM_QSTR(MP_QSTR_read), MP_ROM_PTR(&adc_mic_read_obj) }, +}; +static MP_DEFINE_CONST_DICT(adc_mic_module_globals, adc_mic_module_globals_table); + +const mp_obj_module_t adc_mic_user_cmodule = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&adc_mic_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_adc_mic, adc_mic_user_cmodule); From 11d3ce1f10b6b8df0b9fe5e8d558f8d4d527856b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 12 Feb 2026 17:30:05 +0100 Subject: [PATCH 008/317] Fix compilation --- c_mpos/src/adc_mic.c | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/c_mpos/src/adc_mic.c b/c_mpos/src/adc_mic.c index 0eb46c02..a625e38c 100644 --- a/c_mpos/src/adc_mic.c +++ b/c_mpos/src/adc_mic.c @@ -2,8 +2,9 @@ #include "py/runtime.h" #include "py/mphal.h" #include "esp_heap_caps.h" -#include "esp_codec_dev.h" +#include "esp_codec_dev.h" // Include for esp_codec_dev_* #include "adc_mic.h" // Include for audio_codec_adc_cfg_t, audio_codec_new_adc_data, etc. +#include // For ENOMEM static mp_obj_t adc_mic_read(void) { // Configure for mono ADC on GPIO1 (ADC1_CHANNEL_0) at 16kHz @@ -20,7 +21,7 @@ static mp_obj_t adc_mic_read(void) { }; esp_codec_dev_handle_t dev = esp_codec_dev_new(&codec_dev_cfg); if (dev == NULL) { - audio_codec_delete_adc_data(adc_if); + audio_codec_delete_data_if(adc_if); mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to create codec device")); } @@ -31,19 +32,19 @@ static mp_obj_t adc_mic_read(void) { .bits_per_sample = 16, }; if (esp_codec_dev_open(dev, &fs) != ESP_OK) { - esp_codec_dev_del(dev); - audio_codec_delete_adc_data(adc_if); + esp_codec_dev_delete(dev); + audio_codec_delete_data_if(adc_if); mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to open codec device")); } // Allocate buffer for 16000 samples (16-bit, so 32000 bytes) - const size_t buf_size = 16000 * sizeof(uint16_t); - uint8_t *audio_buffer = (uint8_t *)heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + const size_t buf_size = 16000 * sizeof(int16_t); + int16_t *audio_buffer = (int16_t *)heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); if (audio_buffer == NULL) { esp_codec_dev_close(dev); - esp_codec_dev_del(dev); - audio_codec_delete_adc_data(adc_if); - mp_raise_OSError(MP_ENOMEM); + esp_codec_dev_delete(dev); + audio_codec_delete_data_if(adc_if); + mp_raise_OSError(ENOMEM); } // Read the data (blocking until buffer is filled) @@ -51,19 +52,19 @@ static mp_obj_t adc_mic_read(void) { if (ret < 0) { heap_caps_free(audio_buffer); esp_codec_dev_close(dev); - esp_codec_dev_del(dev); - audio_codec_delete_adc_data(adc_if); + esp_codec_dev_delete(dev); + audio_codec_delete_data_if(adc_if); mp_raise_msg_varg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to read audio data: %d"), ret); } // Create MicroPython bytes object from the buffer - mp_obj_t buf_obj = mp_obj_new_bytes(audio_buffer, ret); + mp_obj_t buf_obj = mp_obj_new_bytes((const byte *)audio_buffer, ret); // Cleanup heap_caps_free(audio_buffer); esp_codec_dev_close(dev); - esp_codec_dev_del(dev); - audio_codec_delete_adc_data(adc_if); + esp_codec_dev_delete(dev); + audio_codec_delete_data_if(adc_if); return buf_obj; } @@ -80,4 +81,4 @@ const mp_obj_module_t adc_mic_user_cmodule = { .globals = (mp_obj_dict_t *)&adc_mic_module_globals, }; -MP_REGISTER_MODULE(MP_QSTR_adc_mic, adc_mic_user_cmodule); +MP_REGISTER_MODULE(MP_QSTR_adc_mic, adc_mic_user_cmodule); \ No newline at end of file From e8a4682486681a0233cbcde0d675f57dfa1deb60 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 12 Feb 2026 18:39:52 +0100 Subject: [PATCH 009/317] Add debug logging --- c_mpos/src/adc_mic.c | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/c_mpos/src/adc_mic.c b/c_mpos/src/adc_mic.c index a625e38c..3ccf345f 100644 --- a/c_mpos/src/adc_mic.c +++ b/c_mpos/src/adc_mic.c @@ -6,24 +6,36 @@ #include "adc_mic.h" // Include for audio_codec_adc_cfg_t, audio_codec_new_adc_data, etc. #include // For ENOMEM +#define ADC_MIC_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) + static mp_obj_t adc_mic_read(void) { + ADC_MIC_DEBUG_PRINT("Starting adc_mic_read...\n"); + // Configure for mono ADC on GPIO1 (ADC1_CHANNEL_0) at 16kHz audio_codec_adc_cfg_t cfg = DEFAULT_AUDIO_CODEC_ADC_MONO_CFG(ADC_CHANNEL_0, 16000); + ADC_MIC_DEBUG_PRINT("Config created for channel %d, sample rate %d\n", ADC_CHANNEL_0, 16000); + + ADC_MIC_DEBUG_PRINT("Creating ADC data interface...\n"); const audio_codec_data_if_t *adc_if = audio_codec_new_adc_data(&cfg); if (adc_if == NULL) { + ADC_MIC_DEBUG_PRINT("Failed to initialize ADC data interface\n"); mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to initialize ADC data interface")); } + ADC_MIC_DEBUG_PRINT("ADC data interface created successfully\n"); // Create codec device for input esp_codec_dev_cfg_t codec_dev_cfg = { .dev_type = ESP_CODEC_DEV_TYPE_IN, .data_if = adc_if, }; + ADC_MIC_DEBUG_PRINT("Creating codec device...\n"); esp_codec_dev_handle_t dev = esp_codec_dev_new(&codec_dev_cfg); if (dev == NULL) { + ADC_MIC_DEBUG_PRINT("Failed to create codec device\n"); audio_codec_delete_data_if(adc_if); mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to create codec device")); } + ADC_MIC_DEBUG_PRINT("Codec device created successfully\n"); // Set sample info: 16kHz, mono, 16-bit esp_codec_dev_sample_info_t fs = { @@ -31,25 +43,35 @@ static mp_obj_t adc_mic_read(void) { .channel = 1, .bits_per_sample = 16, }; - if (esp_codec_dev_open(dev, &fs) != ESP_OK) { + ADC_MIC_DEBUG_PRINT("Opening codec device with sample rate %d, channels %d, bits %d...\n", fs.sample_rate, fs.channel, fs.bits_per_sample); + esp_err_t open_ret = esp_codec_dev_open(dev, &fs); + if (open_ret != ESP_OK) { + ADC_MIC_DEBUG_PRINT("Failed to open codec device: error %d\n", open_ret); esp_codec_dev_delete(dev); audio_codec_delete_data_if(adc_if); - mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to open codec device")); + mp_raise_msg_varg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to open codec device: %d"), open_ret); } + ADC_MIC_DEBUG_PRINT("Codec device opened successfully\n"); // Allocate buffer for 16000 samples (16-bit, so 32000 bytes) const size_t buf_size = 16000 * sizeof(int16_t); + ADC_MIC_DEBUG_PRINT("Allocating buffer of size %zu bytes...\n", buf_size); int16_t *audio_buffer = (int16_t *)heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); if (audio_buffer == NULL) { + ADC_MIC_DEBUG_PRINT("Failed to allocate buffer\n"); esp_codec_dev_close(dev); esp_codec_dev_delete(dev); audio_codec_delete_data_if(adc_if); mp_raise_OSError(ENOMEM); } + ADC_MIC_DEBUG_PRINT("Buffer allocated successfully\n"); // Read the data (blocking until buffer is filled) + ADC_MIC_DEBUG_PRINT("Starting esp_codec_dev_read for %zu bytes...\n", buf_size); int ret = esp_codec_dev_read(dev, audio_buffer, buf_size); + ADC_MIC_DEBUG_PRINT("esp_codec_dev_read completed, returned %d\n", ret); if (ret < 0) { + ADC_MIC_DEBUG_PRINT("Failed to read audio data: %d\n", ret); heap_caps_free(audio_buffer); esp_codec_dev_close(dev); esp_codec_dev_delete(dev); @@ -58,14 +80,17 @@ static mp_obj_t adc_mic_read(void) { } // Create MicroPython bytes object from the buffer + ADC_MIC_DEBUG_PRINT("Creating bytes object from buffer...\n"); mp_obj_t buf_obj = mp_obj_new_bytes((const byte *)audio_buffer, ret); // Cleanup + ADC_MIC_DEBUG_PRINT("Cleaning up...\n"); heap_caps_free(audio_buffer); esp_codec_dev_close(dev); esp_codec_dev_delete(dev); audio_codec_delete_data_if(adc_if); + ADC_MIC_DEBUG_PRINT("adc_mic_read completed\n"); return buf_obj; } MP_DEFINE_CONST_FUN_OBJ_0(adc_mic_read_obj, adc_mic_read); From 07f7c43fa4063e63490fb59dfbf08c38217279d3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 12 Feb 2026 21:23:15 +0100 Subject: [PATCH 010/317] Work on adc_mic.c I get values, but they are all 16380 (0x3FFC). --- c_mpos/src/adc_mic.c | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/c_mpos/src/adc_mic.c b/c_mpos/src/adc_mic.c index 3ccf345f..6aa4059e 100644 --- a/c_mpos/src/adc_mic.c +++ b/c_mpos/src/adc_mic.c @@ -4,15 +4,30 @@ #include "esp_heap_caps.h" #include "esp_codec_dev.h" // Include for esp_codec_dev_* #include "adc_mic.h" // Include for audio_codec_adc_cfg_t, audio_codec_new_adc_data, etc. +#include "sdkconfig.h" // for CONFIG_ADC_MIC_TASK_CORE #include // For ENOMEM #define ADC_MIC_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) static mp_obj_t adc_mic_read(void) { ADC_MIC_DEBUG_PRINT("Starting adc_mic_read...\n"); + ADC_MIC_DEBUG_PRINT("CONFIG_ADC_MIC_TASK_CORE: %d\n", CONFIG_ADC_MIC_TASK_CORE); // Configure for mono ADC on GPIO1 (ADC1_CHANNEL_0) at 16kHz - audio_codec_adc_cfg_t cfg = DEFAULT_AUDIO_CODEC_ADC_MONO_CFG(ADC_CHANNEL_0, 16000); + //audio_codec_adc_cfg_t cfg = DEFAULT_AUDIO_CODEC_ADC_MONO_CFG(ADC_CHANNEL_0, 16000); + audio_codec_adc_cfg_t cfg = { + .handle = NULL, + .max_store_buf_size = 1024 * 2, + .conv_frame_size = 1024, + .unit_id = ADC_UNIT_1, + .adc_channel_list = ((uint8_t[]){ADC_CHANNEL_0}), + .adc_channel_num = 1, + .sample_rate_hz = 16000, + //.atten = ADC_ATTEN_DB_0, // ← try 0 dB first (0–1.1 V range, higher gain) + .atten = ADC_ATTEN_DB_2_5, // ← try 0 dB first (0–1.1 V range, higher gain) + // or ADC_ATTEN_DB_2_5 for ~0–1.5 V + // keep other fields as default or explicit + }; ADC_MIC_DEBUG_PRINT("Config created for channel %d, sample rate %d\n", ADC_CHANNEL_0, 16000); ADC_MIC_DEBUG_PRINT("Creating ADC data interface...\n"); @@ -54,7 +69,8 @@ static mp_obj_t adc_mic_read(void) { ADC_MIC_DEBUG_PRINT("Codec device opened successfully\n"); // Allocate buffer for 16000 samples (16-bit, so 32000 bytes) - const size_t buf_size = 16000 * sizeof(int16_t); + //const size_t buf_size = 16000 * sizeof(int16_t); + const size_t buf_size = 64 * sizeof(int16_t); ADC_MIC_DEBUG_PRINT("Allocating buffer of size %zu bytes...\n", buf_size); int16_t *audio_buffer = (int16_t *)heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); if (audio_buffer == NULL) { @@ -81,7 +97,24 @@ static mp_obj_t adc_mic_read(void) { // Create MicroPython bytes object from the buffer ADC_MIC_DEBUG_PRINT("Creating bytes object from buffer...\n"); - mp_obj_t buf_obj = mp_obj_new_bytes((const byte *)audio_buffer, ret); + mp_obj_t buf_obj; + if (ret >= 0) { + ADC_MIC_DEBUG_PRINT("Creating full bytes object from buffer...\n"); + buf_obj = mp_obj_new_bytes((const byte *)audio_buffer, buf_size); + + ADC_MIC_DEBUG_PRINT("First 16 samples:\n"); + size_t samples_to_print = 16; + for (size_t i = 0; i < samples_to_print; i++) { + int16_t sample = audio_buffer[i]; + ADC_MIC_DEBUG_PRINT("%4d (0x%04X) ", sample, (uint16_t)sample); + if ((i + 1) % 4 == 0) ADC_MIC_DEBUG_PRINT("\n"); + } + ADC_MIC_DEBUG_PRINT("\n"); + + } else { + ADC_MIC_DEBUG_PRINT("Creating empty bytes object from buffer...\n"); + buf_obj = mp_obj_new_bytes((const byte *)audio_buffer, 0); + } // Cleanup ADC_MIC_DEBUG_PRINT("Cleaning up...\n"); From 887e24dac342b795275b9d3b910132d39e036afa Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 12 Feb 2026 22:01:40 +0100 Subject: [PATCH 011/317] samples are being read but board crashes --- c_mpos/src/adc_mic.c | 133 ++++++++++++++++++++++++------------------- 1 file changed, 74 insertions(+), 59 deletions(-) diff --git a/c_mpos/src/adc_mic.c b/c_mpos/src/adc_mic.c index 6aa4059e..e5beba92 100644 --- a/c_mpos/src/adc_mic.c +++ b/c_mpos/src/adc_mic.c @@ -6,6 +6,9 @@ #include "adc_mic.h" // Include for audio_codec_adc_cfg_t, audio_codec_new_adc_data, etc. #include "sdkconfig.h" // for CONFIG_ADC_MIC_TASK_CORE #include // For ENOMEM +#include "esp_task_wdt.h" // watchdog +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" // to add a delay #define ADC_MIC_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) @@ -13,8 +16,7 @@ static mp_obj_t adc_mic_read(void) { ADC_MIC_DEBUG_PRINT("Starting adc_mic_read...\n"); ADC_MIC_DEBUG_PRINT("CONFIG_ADC_MIC_TASK_CORE: %d\n", CONFIG_ADC_MIC_TASK_CORE); - // Configure for mono ADC on GPIO1 (ADC1_CHANNEL_0) at 16kHz - //audio_codec_adc_cfg_t cfg = DEFAULT_AUDIO_CODEC_ADC_MONO_CFG(ADC_CHANNEL_0, 16000); + // Configuration (your current manual setup with 2.5 dB atten) audio_codec_adc_cfg_t cfg = { .handle = NULL, .max_store_buf_size = 1024 * 2, @@ -23,108 +25,121 @@ static mp_obj_t adc_mic_read(void) { .adc_channel_list = ((uint8_t[]){ADC_CHANNEL_0}), .adc_channel_num = 1, .sample_rate_hz = 16000, - //.atten = ADC_ATTEN_DB_0, // ← try 0 dB first (0–1.1 V range, higher gain) - .atten = ADC_ATTEN_DB_2_5, // ← try 0 dB first (0–1.1 V range, higher gain) - // or ADC_ATTEN_DB_2_5 for ~0–1.5 V - // keep other fields as default or explicit + //.atten = ADC_ATTEN_DB_2_5, + .atten = ADC_ATTEN_DB_11, }; - ADC_MIC_DEBUG_PRINT("Config created for channel %d, sample rate %d\n", ADC_CHANNEL_0, 16000); + ADC_MIC_DEBUG_PRINT("Config created for channel %d, sample rate %d, atten %d\n", + ADC_CHANNEL_0, 16000, cfg.atten); - ADC_MIC_DEBUG_PRINT("Creating ADC data interface...\n"); + // ──────────────────────────────────────────────── + // Initialization (same as before) + // ──────────────────────────────────────────────── const audio_codec_data_if_t *adc_if = audio_codec_new_adc_data(&cfg); if (adc_if == NULL) { ADC_MIC_DEBUG_PRINT("Failed to initialize ADC data interface\n"); - mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to initialize ADC data interface")); + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to init ADC interface")); } - ADC_MIC_DEBUG_PRINT("ADC data interface created successfully\n"); - // Create codec device for input esp_codec_dev_cfg_t codec_dev_cfg = { .dev_type = ESP_CODEC_DEV_TYPE_IN, .data_if = adc_if, }; - ADC_MIC_DEBUG_PRINT("Creating codec device...\n"); esp_codec_dev_handle_t dev = esp_codec_dev_new(&codec_dev_cfg); if (dev == NULL) { - ADC_MIC_DEBUG_PRINT("Failed to create codec device\n"); audio_codec_delete_data_if(adc_if); - mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to create codec device")); + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to create codec dev")); } - ADC_MIC_DEBUG_PRINT("Codec device created successfully\n"); - // Set sample info: 16kHz, mono, 16-bit esp_codec_dev_sample_info_t fs = { .sample_rate = 16000, .channel = 1, .bits_per_sample = 16, }; - ADC_MIC_DEBUG_PRINT("Opening codec device with sample rate %d, channels %d, bits %d...\n", fs.sample_rate, fs.channel, fs.bits_per_sample); esp_err_t open_ret = esp_codec_dev_open(dev, &fs); if (open_ret != ESP_OK) { - ADC_MIC_DEBUG_PRINT("Failed to open codec device: error %d\n", open_ret); esp_codec_dev_delete(dev); audio_codec_delete_data_if(adc_if); - mp_raise_msg_varg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to open codec device: %d"), open_ret); + mp_raise_msg_varg(&mp_type_RuntimeError, MP_ERROR_TEXT("esp_codec_dev_open failed: %d"), open_ret); } - ADC_MIC_DEBUG_PRINT("Codec device opened successfully\n"); - // Allocate buffer for 16000 samples (16-bit, so 32000 bytes) - //const size_t buf_size = 16000 * sizeof(int16_t); - const size_t buf_size = 64 * sizeof(int16_t); - ADC_MIC_DEBUG_PRINT("Allocating buffer of size %zu bytes...\n", buf_size); - int16_t *audio_buffer = (int16_t *)heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + // ──────────────────────────────────────────────── + // Small reusable buffer + tracking variables + // ──────────────────────────────────────────────── + const size_t chunk_samples = 64; + const size_t buf_size = chunk_samples * sizeof(int16_t); + //int16_t *audio_buffer = heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + int16_t *audio_buffer = heap_caps_malloc_prefer(buf_size, MALLOC_CAP_DEFAULT | MALLOC_CAP_SPIRAM, MALLOC_CAP_DEFAULT); if (audio_buffer == NULL) { - ADC_MIC_DEBUG_PRINT("Failed to allocate buffer\n"); esp_codec_dev_close(dev); esp_codec_dev_delete(dev); audio_codec_delete_data_if(adc_if); mp_raise_OSError(ENOMEM); } - ADC_MIC_DEBUG_PRINT("Buffer allocated successfully\n"); - - // Read the data (blocking until buffer is filled) - ADC_MIC_DEBUG_PRINT("Starting esp_codec_dev_read for %zu bytes...\n", buf_size); - int ret = esp_codec_dev_read(dev, audio_buffer, buf_size); - ADC_MIC_DEBUG_PRINT("esp_codec_dev_read completed, returned %d\n", ret); - if (ret < 0) { - ADC_MIC_DEBUG_PRINT("Failed to read audio data: %d\n", ret); - heap_caps_free(audio_buffer); - esp_codec_dev_close(dev); - esp_codec_dev_delete(dev); - audio_codec_delete_data_if(adc_if); - mp_raise_msg_varg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to read audio data: %d"), ret); - } - // Create MicroPython bytes object from the buffer - ADC_MIC_DEBUG_PRINT("Creating bytes object from buffer...\n"); - mp_obj_t buf_obj; - if (ret >= 0) { - ADC_MIC_DEBUG_PRINT("Creating full bytes object from buffer...\n"); - buf_obj = mp_obj_new_bytes((const byte *)audio_buffer, buf_size); - - ADC_MIC_DEBUG_PRINT("First 16 samples:\n"); - size_t samples_to_print = 16; - for (size_t i = 0; i < samples_to_print; i++) { - int16_t sample = audio_buffer[i]; - ADC_MIC_DEBUG_PRINT("%4d (0x%04X) ", sample, (uint16_t)sample); - if ((i + 1) % 4 == 0) ADC_MIC_DEBUG_PRINT("\n"); + // How many chunks to read (adjust as needed) + const int N = 10; // e.g. 50 × 64 = 3200 samples (~0.2 seconds @ 16 kHz) + + int16_t global_min = 32767; + int16_t global_max = -32768; + + ADC_MIC_DEBUG_PRINT("Reading %d chunks of %zu samples each (total %d samples)...\n", + N, chunk_samples, N * chunk_samples); + + mp_obj_t last_buf_obj = mp_const_none; + + for (int chunk = 0; chunk < N; chunk++) { + esp_task_wdt_reset(); // "I'm alive" + int ret = esp_codec_dev_read(dev, audio_buffer, buf_size); + if (ret < 0) { + ADC_MIC_DEBUG_PRINT("Read failed at chunk %d: %d\n", chunk, ret); + break; + } + vTaskDelay(pdMS_TO_TICKS(1)); // 1 ms yield + //if (ret != (int)buf_size) { + // ADC_MIC_DEBUG_PRINT("Partial read at chunk %d: got %d bytes (expected %zu)\n", + // chunk, ret, buf_size); + //} + + // Update global min/max + for (size_t i = 0; i < chunk_samples; i++) { + int16_t s = audio_buffer[i]; + if (s < global_min) global_min = s; + if (s > global_max) global_max = s; + } + + // Optional: print first few chunks for debug (comment out after testing) + if (chunk < 3) { + ADC_MIC_DEBUG_PRINT("Chunk %d first 16 samples:\n", chunk); + for (size_t i = 0; i < 16; i++) { + ADC_MIC_DEBUG_PRINT("%6d ", audio_buffer[i]); + if ((i + 1) % 8 == 0) ADC_MIC_DEBUG_PRINT("\n"); + } + ADC_MIC_DEBUG_PRINT("\n"); } - ADC_MIC_DEBUG_PRINT("\n"); - } else { - ADC_MIC_DEBUG_PRINT("Creating empty bytes object from buffer...\n"); - buf_obj = mp_obj_new_bytes((const byte *)audio_buffer, 0); + // Keep only the last chunk to return + if (chunk == N - 1) { + last_buf_obj = mp_obj_new_bytes((const byte *)audio_buffer, buf_size); + } } + // ──────────────────────────────────────────────── + // Report results + // ──────────────────────────────────────────────── + ADC_MIC_DEBUG_PRINT("\nAfter %d chunks:\n", N); + ADC_MIC_DEBUG_PRINT("Global min: %d\n", global_min); + ADC_MIC_DEBUG_PRINT("Global max: %d\n", global_max); + ADC_MIC_DEBUG_PRINT("Range: %d\n", global_max - global_min); + // Cleanup - ADC_MIC_DEBUG_PRINT("Cleaning up...\n"); heap_caps_free(audio_buffer); esp_codec_dev_close(dev); esp_codec_dev_delete(dev); audio_codec_delete_data_if(adc_if); ADC_MIC_DEBUG_PRINT("adc_mic_read completed\n"); - return buf_obj; + + return last_buf_obj ? last_buf_obj : mp_obj_new_bytes(NULL, 0); } MP_DEFINE_CONST_FUN_OBJ_0(adc_mic_read_obj, adc_mic_read); From 982422137495d584303c98fec80e905d52cb6793 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 12 Feb 2026 22:49:19 +0100 Subject: [PATCH 012/317] adc_mic works but brittle --- c_mpos/src/adc_mic.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/c_mpos/src/adc_mic.c b/c_mpos/src/adc_mic.c index e5beba92..8a93ad60 100644 --- a/c_mpos/src/adc_mic.c +++ b/c_mpos/src/adc_mic.c @@ -65,7 +65,7 @@ static mp_obj_t adc_mic_read(void) { // ──────────────────────────────────────────────── // Small reusable buffer + tracking variables // ──────────────────────────────────────────────── - const size_t chunk_samples = 64; + const size_t chunk_samples = 512; const size_t buf_size = chunk_samples * sizeof(int16_t); //int16_t *audio_buffer = heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); int16_t *audio_buffer = heap_caps_malloc_prefer(buf_size, MALLOC_CAP_DEFAULT | MALLOC_CAP_SPIRAM, MALLOC_CAP_DEFAULT); @@ -77,7 +77,7 @@ static mp_obj_t adc_mic_read(void) { } // How many chunks to read (adjust as needed) - const int N = 10; // e.g. 50 × 64 = 3200 samples (~0.2 seconds @ 16 kHz) + const int N = 1; // e.g. 50 × 512 = ~1.5 seconds @ 16 kHz int16_t global_min = 32767; int16_t global_max = -32768; From b6d3f0c63d15c98636345680bb62ea609b29e71c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 10:58:16 +0100 Subject: [PATCH 013/317] Comments --- internal_filesystem/lib/mpos/board/fri3d_2024.py | 4 ++-- internal_filesystem/lib/mpos/board/fri3d_2026.py | 2 +- internal_filesystem/lib/mpos/ui/keyboard.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index ab40c9fd..4285d8fc 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -302,11 +302,11 @@ def adc_to_voltage(adc_value): # The DAC uses BCK (bit clock) on GPIO 2, while the microphone uses SCLK on GPIO 17 # See schematics: DAC has BCK=2, WS=47, SD=16; Microphone has SCLK=17, WS=47, DIN=15 i2s_pins = { - # Output (DAC/speaker) pins + # Output (DAC/speaker) config 'sck': 2, # SCLK or BCLK - Bit Clock for DAC output (mandatory) 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) 'sd': 16, # Serial Data OUT (speaker/DAC) - # Input (microphone) pins + # Input (microphone) config 'sck_in': 17, # SCLK - Serial Clock for microphone input 'sd_in': 15, # DIN - Serial Data IN (microphone) } diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py index ffaaae7f..3c10f77e 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2026.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -224,7 +224,7 @@ def adc_to_voltage(adc_value): i2s_pins = { # Output (DAC/speaker) pins 'mck': 2, # MCLK (mandatory) - #'sck': 17, # SCLK or BCLK (optional) + #'sck': 17, # SCLK aka BCLK (optional) 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) 'sd': 16, # Serial Data OUT (speaker/DAC) } diff --git a/internal_filesystem/lib/mpos/ui/keyboard.py b/internal_filesystem/lib/mpos/ui/keyboard.py index 55a2246b..198c1548 100644 --- a/internal_filesystem/lib/mpos/ui/keyboard.py +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -11,7 +11,7 @@ # Create keyboard keyboard = MposKeyboard(parent_obj) keyboard.set_textarea(my_textarea) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) # shows up when textarea is clicked """ From 78b819d396379f728e4155511e4564008dc6f03d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 10:58:58 +0100 Subject: [PATCH 014/317] CHANGELOG --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d9b6220..d1426dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,9 @@ Builtin Apps: - About: use logger framework +- AppStore: mark BadgeHub backend as 'beta' - Launcher: improve layout on different screen width sizes -- OSUpdate: remove 'force update' checkbox in favor of varying button labels +- OSUpdate: remove 'force update' checkbox not in favor of varying button labels Frameworks: - SDCard: add support for SDIO/SD/MMC mode @@ -13,7 +14,7 @@ Frameworks: OS: - Add board support: Makerfabs MaTouch ESP32-S3 SPI IPS 2.8' with Camera OV3660 - Scale MicroPythonOS boot logo down if necessary -- UI: Don't show battery icon if not present +- Don't show battery icon if battery is not supported - Move logging.py to subdirectory 0.7.1 From aa96ca9797c0677c1adb1c11a5ab04ba3dee5592 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 11:08:47 +0100 Subject: [PATCH 015/317] Comments and output --- internal_filesystem/lib/mpos/build_info.py | 2 +- internal_filesystem/lib/mpos/main.py | 9 ++++++--- internal_filesystem/main.py | 3 +-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal_filesystem/lib/mpos/build_info.py b/internal_filesystem/lib/mpos/build_info.py index d9d88edc..ad2bbc09 100644 --- a/internal_filesystem/lib/mpos/build_info.py +++ b/internal_filesystem/lib/mpos/build_info.py @@ -9,5 +9,5 @@ class BuildInfo: class version: """Version information.""" - release = "0.8.0" + release = "0.8.1" api_level = 0 # subject to change until API Level 1 diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 4763e533..af6f1c92 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -5,7 +5,7 @@ import mpos.ui import mpos.ui.topmenu -from mpos import AppearanceManager, DisplayMetrics, AppManager, SharedPreferences, TaskManager, DeviceInfo +from mpos import AppearanceManager, AppManager, BuildInfo, DeviceInfo, DisplayMetrics, SharedPreferences, TaskManager def init_rootscreen(): """Initialize the root screen and set display metrics.""" @@ -74,8 +74,11 @@ def detect_board(): # default: if single_address_i2c_scan(i2c0, 0x6A): # IMU but currently not installed return "fri3d_2026" +# EXECUTION STARTS HERE + +print(f"MicroPythonOS {BuildInfo.version.release} running lib/mpos/main.py") board = detect_board() -print(f"Initializing {board} hardware") +print(f"Detected {board} system, importing mpos.board.{board}") DeviceInfo.set_hardware_id(board) __import__(f"mpos.board.{board}") @@ -135,7 +138,7 @@ def custom_exception_handler(e): except Exception as e: print(f"Couldn't start WifiService.auto_connect thread because: {e}") -# Start launcher so it's always at bottom of stack +# Start launcher first so it's always at bottom of stack launcher_app = AppManager.get_launcher() started_launcher = AppManager.start_app(launcher_app.fullname) # Then start auto_start_app if configured diff --git a/internal_filesystem/main.py b/internal_filesystem/main.py index e768d64d..935d0476 100644 --- a/internal_filesystem/main.py +++ b/internal_filesystem/main.py @@ -5,6 +5,5 @@ import sys sys.path.insert(0, 'lib') -print("Passing execution over to mpos.main") +print(f"Minimal main.py importing mpos.main with sys.path: {sys.path}") import mpos.main - From 06d64b7ac490f972a89d152bb8fc01b87c7ff0af Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 11:09:03 +0100 Subject: [PATCH 016/317] WifiService: less debug output --- internal_filesystem/lib/mpos/net/wifi_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index ef8ec2a0..314f4357 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -23,7 +23,8 @@ import network HAS_NETWORK_MODULE = True except ImportError: - print("WifiService: network module not available (desktop mode)") + pass + #print("WifiService: network module not available (desktop mode)") class WifiService: From a6b010b3e07feb484f3f6b1fe1061c8127f64127 Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Thu, 12 Feb 2026 14:02:11 +0100 Subject: [PATCH 017/317] Support Hardkernel ODROID-Go Support for Hardkernel ESP32 device: ODROID-Go (The old one from 2018) * https://github.com/hardkernel/ODROID-GO/ * https://wiki.odroid.com/odroid_go/odroid_go What worked: * Display * Buttons * Crossbar * Wifi * Battery * blue LED TODO: * Speaker The blue LED is "coupled" with the button/crossbar press. --- .../lib/mpos/board/odroid_go.py | 253 ++++++++++++++++++ internal_filesystem/lib/mpos/main.py | 83 ++++-- internal_filesystem/main.py | 26 +- ruff.toml | 2 + scripts/build_mpos.sh | 18 ++ 5 files changed, 361 insertions(+), 21 deletions(-) create mode 100644 internal_filesystem/lib/mpos/board/odroid_go.py create mode 100644 ruff.toml diff --git a/internal_filesystem/lib/mpos/board/odroid_go.py b/internal_filesystem/lib/mpos/board/odroid_go.py new file mode 100644 index 00000000..81bf92a2 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/odroid_go.py @@ -0,0 +1,253 @@ +print("odroid_go.py initialization") + +# Hardware initialization for Hardkernel ODROID-Go +# https://github.com/hardkernel/ODROID-GO/ +# https://wiki.odroid.com/odroid_go/odroid_go + +import time + +import ili9341 +import lcd_bus +import lvgl as lv +import machine +import mpos.ui +from machine import ADC, Pin +from micropython import const +from mpos import InputManager + +# Display settings: +SPI_HOST = const(1) +SPI_FREQ = const(40000000) + +LCD_SCLK = const(18) +LCD_MOSI = const(23) +LCD_DC = const(21) +LCD_CS = const(5) +LCD_BL = const(32) +LCD_RST = const(33) +LCD_TYPE = const(2) # ILI9341 type 2 + +TFT_VER_RES = const(320) +TFT_HOR_RES = const(240) + + +# Button settings: +BUTTON_MENU = const(13) +BUTTON_VOLUME = const(0) +BUTTON_SELECT = const(27) +BUTTON_START = const(39) + +BUTTON_B = const(33) +BUTTON_A = const(32) + +# The crossbar pin numbers: +CROSSBAR_X = const(34) +CROSSBAR_Y = const(35) + + +# Misc settings: +LED_BLUE = const(2) +BATTERY_PIN = const(36) +BATTERY_RESISTANCE_NUM = const(2) +SPEAKER_ENABLE_PIN = const(25) +SPEAKER_PIN = const(26) + + +print("odroid_go.py turn on blue LED") +blue_led = machine.Pin(LED_BLUE, machine.Pin.OUT) +blue_led.on() + + +print("odroid_go.py machine.SPI.Bus() initialization") +try: + spi_bus = machine.SPI.Bus(host=SPI_HOST, mosi=LCD_MOSI, sck=LCD_SCLK) +except Exception as e: + print(f"Error initializing SPI bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + +print("odroid_go.py lcd_bus.SPIBus() initialization") +display_bus = lcd_bus.SPIBus(spi_bus=spi_bus, freq=SPI_FREQ, dc=LCD_DC, cs=LCD_CS) + +print("odroid_go.py ili9341.ILI9341() initialization") +try: + mpos.ui.main_display = ili9341.ILI9341( + data_bus=display_bus, + display_width=TFT_HOR_RES, + display_height=TFT_VER_RES, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=ili9341.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + reset_pin=LCD_RST, + reset_state=ili9341.STATE_LOW, + backlight_pin=LCD_BL, + backlight_on_state=ili9341.STATE_PWM, + ) +except Exception as e: + print(f"Error initializing ILI9341: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + +print("odroid_go.py display.init()") +mpos.ui.main_display.init(type=LCD_TYPE) +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._270) +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_color_inversion(False) +mpos.ui.main_display.set_backlight(25) + +print("odroid_go.py lv.init() initialization") +lv.init() + + +print("odroid_go.py Battery initialization...") +from mpos import BatteryManager + + +def adc_to_voltage(adc_value): + return adc_value * BATTERY_RESISTANCE_NUM + + +BatteryManager.init_adc(BATTERY_PIN, adc_to_voltage) + + +print("odroid_go.py button initialization...") + +button_menu = Pin(BUTTON_MENU, Pin.IN, Pin.PULL_UP) +button_volume = Pin(BUTTON_VOLUME, Pin.IN, Pin.PULL_UP) +button_select = Pin(BUTTON_SELECT, Pin.IN, Pin.PULL_UP) +button_start = Pin(BUTTON_START, Pin.IN, Pin.PULL_UP) # -> ENTER + +# PREV <- B | A -> NEXT +button_b = Pin(BUTTON_B, Pin.IN, Pin.PULL_UP) +button_a = Pin(BUTTON_A, Pin.IN, Pin.PULL_UP) + + +class CrossbarHandler: + # ADC values are around low: ~236 and high ~511 + # So the mid value is around (236+511)/2 = 373.5 + CROSSBAR_MIN_ADC_LOW = const(100) + CROSSBAR_MIN_ADC_MID = const(370) + + def __init__(self, pin, high_key, low_key): + self.adc = ADC(Pin(pin, mode=Pin.IN)) + self.adc.width(ADC.WIDTH_9BIT) + self.adc.atten(ADC.ATTN_11DB) + + self.high_key = high_key + self.low_key = low_key + + def poll(self): + value = self.adc.read() + if value > self.CROSSBAR_MIN_ADC_LOW: + if value > self.CROSSBAR_MIN_ADC_MID: + return self.high_key + elif value < self.CROSSBAR_MIN_ADC_MID: + return self.low_key + + +class Crossbar: + def __init__(self, *, up, down, left, right): + self.joy_x = CrossbarHandler(CROSSBAR_X, high_key=left, low_key=right) + self.joy_y = CrossbarHandler(CROSSBAR_Y, high_key=up, low_key=down) + + def poll(self): + crossbar_pressed = self.joy_x.poll() or self.joy_y.poll() + return crossbar_pressed + + +# see: internal_filesystem/lib/mpos/indev/mpos_sdl_keyboard.py +# lv.KEY.UP +# lv.KEY.LEFT - lv.KEY.RIGHT +# lv.KEY.DOWN +# +crossbar = Crossbar( + up=lv.KEY.UP, down=lv.KEY.DOWN, left=lv.KEY.LEFT, right=lv.KEY.RIGHT +) + +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +next_repeat = None # Used for auto-repeat key handling + + +def input_callback(indev, data): + global next_repeat + + current_key = None + + if crossbar_pressed := crossbar.poll(): + current_key = crossbar_pressed + + elif button_menu.value() == 0: + current_key = lv.KEY.ESC + elif button_volume.value() == 0: + print("Volume button pressed -> reset") + machine.reset() + elif button_select.value() == 0: + current_key = lv.KEY.BACKSPACE + elif button_start.value() == 0: + current_key = lv.KEY.ENTER + + elif button_b.value() == 0: + current_key = lv.KEY.PREV + elif button_a.value() == 0: + current_key = lv.KEY.NEXT + else: + # No crossbar/buttons pressed + if data.key: # A key was previously pressed and now released + # print(f"Key {data.key=} released") + data.key = 0 + data.state = lv.INDEV_STATE.RELEASED + next_repeat = None + blue_led.off() + return + + # A key is currently pressed + + blue_led.on() # Blink on key press and auto repeat for feedback + + current_time = time.ticks_ms() + repeat = current_time > next_repeat if next_repeat else False # Auto repeat? + if repeat or current_key != data.key: + print(f"Key {current_key} pressed {repeat=}") + + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + + if current_key == lv.KEY.ESC: # Handle ESC for back navigation + mpos.ui.back_screen() + elif current_key == lv.KEY.RIGHT: + mpos.ui.focus_direction.move_focus_direction(90) + elif current_key == lv.KEY.LEFT: + mpos.ui.focus_direction.move_focus_direction(270) + elif current_key == lv.KEY.UP: + mpos.ui.focus_direction.move_focus_direction(0) + elif current_key == lv.KEY.DOWN: + mpos.ui.focus_direction.move_focus_direction(180) + + if not repeat: + # Initial press: Delay before first repeat + next_repeat = current_time + REPEAT_INITIAL_DELAY_MS + else: + # Faster auto repeat after initial press + next_repeat = current_time + REPEAT_RATE_MS + blue_led.off() # Blink the LED, too + + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(input_callback) +indev.set_group( + group +) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +InputManager.register_indev(indev) + +print("odroid_go.py finished") diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index af6f1c92..a8bdeb1f 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -39,38 +39,85 @@ def single_address_i2c_scan(i2c_bus, address): Returns: True if a device responds at the specified address, False otherwise """ + print(f"Attempt to write a single byte to I2C bus address 0x{address:02x}...") try: # Attempt to write a single byte to the address # This will raise an exception if no device responds - i2c_bus.writeto(address, b'') + i2c_bus.writeto(address, b"") + print("Write test successful") return True - except OSError: - # No device at this address + except OSError as e: + print(f"No device at this address: {e}") return False except Exception as e: # Handle any other exceptions gracefully - print(f"single_address_i2c_scan: error scanning address 0x{address:02x}: {e}") + print(f"scan error: {e}") return False + +def fail_save_i2c(sda, scl): + from machine import I2C, Pin + + print(f"Try to I2C initialized on {sda=} {scl=}") + try: + i2c0 = I2C(0, sda=Pin(sda), scl=Pin(scl)) + except Exception as e: + print(f"Failed: {e}") + return None + else: + print("OK") + return i2c0 + + +def check_pins(*pins): + from machine import Pin + + print(f"Test {pins=}...") + for pin in pins: + try: + Pin(pin) + except Exception as e: + print(f"Failed to initialize {pin=}: {e}") + return True + print("All pins initialized successfully") + return True + + def detect_board(): import sys if sys.platform == "linux" or sys.platform == "darwin": # linux and macOS return "linux" elif sys.platform == "esp32": - from machine import Pin, I2C - - i2c0 = I2C(0, sda=Pin(39), scl=Pin(38)) - if single_address_i2c_scan(i2c0, 0x14) or single_address_i2c_scan(i2c0, 0x5D): # "ghost" or real GT911 touch screen - return "matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660" - - i2c0 = I2C(0, sda=Pin(48), scl=Pin(47)) # IO48 is floating on matouch and therefore, using that for I2C will find many devices, so do this after matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 - if single_address_i2c_scan(i2c0, 0x15) and single_address_i2c_scan(i2c0, 0x6B): # CST816S touch screen and IMU - return "waveshare_esp32_s3_touch_lcd_2" - - i2c0 = I2C(0, sda=Pin(9), scl=Pin(18)) - if single_address_i2c_scan(i2c0, 0x6B): # IMU (plus possibly the Communicator's LANA TNY at 0x38) - return "fri3d_2024" - + print("Detecting ESP32 board by scanning I2C addresses...") + + print("matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 ?") + if i2c0 := fail_save_i2c(sda=39, scl=38): + if single_address_i2c_scan(i2c0, 0x14) or single_address_i2c_scan( + i2c0, 0x5D + ): + # "ghost" or real GT911 touch screen + return "matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660" + + print("waveshare_esp32_s3_touch_lcd_2 ?") + if i2c0 := fail_save_i2c(sda=48, scl=47): + # IO48 is floating on matouch and therefore, using that for I2C will find many devices, so do this after matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 + if single_address_i2c_scan(i2c0, 0x15) and single_address_i2c_scan( + i2c0, 0x6B + ): + # CST816S touch screen and IMU + return "waveshare_esp32_s3_touch_lcd_2" + + print("odroid_go ?") + if check_pins(0, 13, 27, 39): + return "odroid_go" + + print("fri3d_2024 ?") + if i2c0 := fail_save_i2c(sda=9, scl=18): + # IMU (plus possibly the Communicator's LANA TNY at 0x38) + if single_address_i2c_scan(i2c0, 0x6B): + return "fri3d_2024" + + print("Fallback to fri3d_2026") # default: if single_address_i2c_scan(i2c0, 0x6A): # IMU but currently not installed return "fri3d_2026" diff --git a/internal_filesystem/main.py b/internal_filesystem/main.py index 935d0476..1dfb64bf 100644 --- a/internal_filesystem/main.py +++ b/internal_filesystem/main.py @@ -2,8 +2,28 @@ # Make sure the storage partition's lib/ is first in the path, so whatever is placed there overrides frozen libraries. # This allows any build to be used for development as well, just by overriding the libraries in lib/ +import gc +import os import sys -sys.path.insert(0, 'lib') -print(f"Minimal main.py importing mpos.main with sys.path: {sys.path}") -import mpos.main +sys.path.insert(0, "lib") + +print(f"{sys.version=}") +print(f"{sys.implementation=}") + + +print("Check free space on root filesystem:") +stat = os.statvfs("/") +total_space = stat[0] * stat[2] +free_space = stat[0] * stat[3] +used_space = total_space - free_space +print(f"{total_space=} / {used_space=} / {free_space=} bytes") + + +gc.collect() +print( + f"RAM: {gc.mem_free()} free, {gc.mem_alloc()} allocated, {gc.mem_alloc() + gc.mem_free()} total" +) + +print("Passing execution over to mpos.main") +import mpos.main # noqa: F401 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..f1c43c0a --- /dev/null +++ b/ruff.toml @@ -0,0 +1,2 @@ +[format] +quote-style = "double" diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 938878a7..b20ae9be 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -14,6 +14,7 @@ if [ -z "$target" ]; then echo "Example: $0 unix" echo "Example: $0 macOS" echo "Example: $0 esp32" + echo "Example: $0 odroid_go" exit 1 fi @@ -100,6 +101,23 @@ if [ "$target" == "esp32" ]; then rm -rf lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/ python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=ESP32_GENERIC_S3 BOARD_VARIANT=SPIRAM_OCT DISPLAY=st7789 INDEV=cst816s USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y "$frozenmanifest" popd +elif [ "$target" == "odroid_go" ]; then + manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) + frozenmanifest="FROZEN_MANIFEST=$manifest" + echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." + # Build for https://wiki.odroid.com/odroid_go/odroid_go + pushd "$codebasedir"/lvgl_micropython/ + rm -rf lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/ + python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 \ + BOARD=ESP32_GENERIC BOARD_VARIANT=SPIRAM DISPLAY=ili9341 \ + USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake \ + USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake \ + USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake \ + CONFIG_FREERTOS_USE_TRACE_FACILITY=y \ + CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y \ + CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y \ + "$frozenmanifest" + popd elif [ "$target" == "unix" -o "$target" == "macOS" ]; then manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" From 588d6edca9cbd8780da7b7dea50ae33b9b375d12 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 13:29:25 +0100 Subject: [PATCH 018/317] fix showbattery link --- .../apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON index 796e1082..f02b6283 100644 --- a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON @@ -3,8 +3,8 @@ "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.showbattery_0.1.1_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.helloworld/mpks/com.micropythonos.showbattery_0.1.1.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/icons/com.micropythonos.showbattery_0.1.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/mpks/com.micropythonos.showbattery_0.1.1.mpk", "fullname": "com.micropythonos.showbattery", "version": "0.1.1", "category": "development", From 82b116969f5ffa38f3120577c36d44fc9c4c781f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 15:29:25 +0100 Subject: [PATCH 019/317] [UNTESTED] rework odroid into generic esp32 target --- c_mpos/micropython.cmake | 6 +- .../display/ili9341/_ili9341_init_type1.py | 114 +++++++ .../display/ili9341/_ili9341_init_type2.py | 117 +++++++ .../lib/drivers/display/ili9341/ili9341.py | 15 + .../drivers/display/st7789/_st7789_init.py | 173 ++++++++++ .../lib/drivers/display/st7789/st7789.py | 80 +++++ .../drivers => drivers/imu_sensor}/qmi8658.py | 0 .../imu_sensor}/wsen_isds.py | 0 .../lib/drivers/indev/cst816s.py | 307 ++++++++++++++++++ .../lib/{mpos => drivers}/indev/gt911.py | 0 .../indev/sdl_keyboard.py} | 0 .../lib/mpos/board/fri3d_2024.py | 5 +- .../lib/mpos/board/fri3d_2026.py | 5 +- internal_filesystem/lib/mpos/board/linux.py | 5 +- ...esp32_s3_spi_ips_2_8_with_camera_ov3660.py | 2 +- .../lib/mpos/board/odroid_go.py | 4 +- .../board/waveshare_esp32_s3_touch_lcd_2.py | 5 +- .../lib/mpos/hardware/drivers/__init__.py | 1 - .../lib/mpos/sensor_manager.py | 8 +- .../lib/mpos/ui/camera_settings.py | 2 +- scripts/build_mpos.sh | 54 +-- 21 files changed, 848 insertions(+), 55 deletions(-) create mode 100644 internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type1.py create mode 100644 internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type2.py create mode 100644 internal_filesystem/lib/drivers/display/ili9341/ili9341.py create mode 100644 internal_filesystem/lib/drivers/display/st7789/_st7789_init.py create mode 100644 internal_filesystem/lib/drivers/display/st7789/st7789.py rename internal_filesystem/lib/{mpos/hardware/drivers => drivers/imu_sensor}/qmi8658.py (100%) rename internal_filesystem/lib/{mpos/hardware/drivers => drivers/imu_sensor}/wsen_isds.py (100%) create mode 100644 internal_filesystem/lib/drivers/indev/cst816s.py rename internal_filesystem/lib/{mpos => drivers}/indev/gt911.py (100%) rename internal_filesystem/lib/{mpos/indev/mpos_sdl_keyboard.py => drivers/indev/sdl_keyboard.py} (100%) delete mode 100644 internal_filesystem/lib/mpos/hardware/drivers/__init__.py diff --git a/c_mpos/micropython.cmake b/c_mpos/micropython.cmake index 900a9f78..9f517303 100644 --- a/c_mpos/micropython.cmake +++ b/c_mpos/micropython.cmake @@ -4,9 +4,13 @@ add_library(usermod_c_mpos INTERFACE) -set(MPOS_C_INCLUDES) +set(MPOS_C_INCLUDES + ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/include/ + ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/interface/ +) set(MPOS_C_SOURCES + ${CMAKE_CURRENT_LIST_DIR}/src/adc_mic.c ${CMAKE_CURRENT_LIST_DIR}/src/quirc_decode.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/identify.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/version_db.c diff --git a/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type1.py b/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type1.py new file mode 100644 index 00000000..7b973dbe --- /dev/null +++ b/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type1.py @@ -0,0 +1,114 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import time +from micropython import const # NOQA +import lvgl as lv # NOQA + + +_PWR1 = const(0xC0) +_PWR2 = const(0xC1) +_VCOMCTL1 = const(0xC5) +_VCOMCTL2 = const(0xC7) +_MADCTL = const(0x36) +_COLMOD = const(0x3A) +_FRMCTR1 = const(0xB1) +_DFUNCTRL = const(0xB6) +_GAMSET = const(0x26) +_PGC = const(0xE0) +_NGC = const(0xE1) +_SLPOUT = const(0x11) +_DISPON = const(0x29) +_RASET = const(0x2B) +_CASET = const(0x2A) +_PWRCTRLB = const(0xCF) +_PWRONSQCTRL = const(0xED) +_DRVTIMCTRLA1 = const(0xE8) +_PWRCTRLA = const(0xCB) +_PUMPRATIOCTRL = const(0xF7) +_DRVTIMCTRLB = const(0xEA) +_ENA3GAMMA = const(0xF2) + + +def init(self): + param_buf = bytearray(15) + param_mv = memoryview(param_buf) + + param_buf[:3] = bytearray([0x03, 0x80, 0x02]) + self.set_params(0xEF, param_mv[:3]) + + param_buf[:3] = bytearray([0x00, 0XC1, 0X30]) + self.set_params(_PWRCTRLB, param_mv[:3]) + + param_buf[:4] = bytearray([0x64, 0x03, 0X12, 0X81]) + self.set_params(_PWRONSQCTRL, param_mv[:4]) + + param_buf[:3] = bytearray([0x85, 0x00, 0x78]) + self.set_params(_DRVTIMCTRLA1, param_mv[:3]) + + param_buf[:5] = bytearray([0x39, 0x2C, 0x00, 0x34, 0x02]) + self.set_params(_PWRCTRLA, param_mv[:5]) + + param_buf[0] = 0x20 + self.set_params(_PUMPRATIOCTRL, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x00]) + self.set_params(_DRVTIMCTRLB, param_mv[:2]) + + param_buf[0] = 0x23 + self.set_params(_PWR1, param_mv[:1]) + + param_buf[0] = 0x10 + self.set_params(_PWR2, param_mv[:1]) + + param_buf[:2] = bytearray([0x3e, 0x28]) + self.set_params(_VCOMCTL1, param_mv[:2]) + + param_buf[0] = 0x86 + self.set_params(_VCOMCTL2, param_mv[:1]) + + param_buf[0] = ( + self._madctl( + self._color_byte_order, + self._ORIENTATION_TABLE # NOQA + ) + ) + self.set_params(_MADCTL, param_mv[:1]) + + color_size = lv.color_format_get_size(self._color_space) + if color_size == 2: # NOQA + pixel_format = 0x55 + else: + raise RuntimeError( + f'{self.__class__.__name__} IC only supports ' + 'lv.COLOR_FORMAT.RGB565' + ) + + param_buf[0] = pixel_format + self.set_params(_COLMOD, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x13]) # 0x18 ?? + self.set_params(_FRMCTR1, param_mv[:2]) + + param_buf[:3] = bytearray([0x08, 0x82, 0x27]) + self.set_params(_DFUNCTRL, param_mv[:3]) + + param_buf[0] = 0x00 + self.set_params(_ENA3GAMMA, param_mv[:1]) + + param_buf[0] = 0x01 + self.set_params(_GAMSET, param_mv[:1]) + + param_buf[:15] = bytearray([ + 0x0F, 0x31, 0x2B, 0x0C, 0x0E, 0x08, 0x4E, 0xF1, + 0x37, 0x07, 0x10, 0x03, 0x0E, 0x09, 0x00]) + self.set_params(_PGC, param_mv[:15]) + + param_buf[:15] = bytearray([ + 0x00, 0x0E, 0x14, 0x03, 0x11, 0x07, 0x31, 0xC1, + 0x48, 0x08, 0x0F, 0x0C, 0x31, 0x36, 0x0F]) + self.set_params(_NGC, param_mv[:15]) + + self.set_params(_SLPOUT) + time.sleep_ms(120) # NOQA + self.set_params(_DISPON) + time.sleep_ms(20) # NOQA diff --git a/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type2.py b/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type2.py new file mode 100644 index 00000000..769aea31 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/ili9341/_ili9341_init_type2.py @@ -0,0 +1,117 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import time +from micropython import const # NOQA +import lvgl as lv # NOQA + + +_PWR1 = const(0xC0) +_PWR2 = const(0xC1) +_VCOMCTL1 = const(0xC5) +_VCOMCTL2 = const(0xC7) +_MADCTL = const(0x36) +_COLMOD = const(0x3A) +_FRMCTR1 = const(0xB1) +_DFUNCTRL = const(0xB6) +_GAMSET = const(0x26) +_PGC = const(0xE0) +_NGC = const(0xE1) +_SLPOUT = const(0x11) +_DISPON = const(0x29) +_RASET = const(0x2B) +_CASET = const(0x2A) +_PWRCTRLB = const(0xCF) +_PWRONSQCTRL = const(0xED) +_DRVTIMCTRLA1 = const(0xE8) +_PWRCTRLA = const(0xCB) +_PUMPRATIOCTRL = const(0xF7) +_DRVTIMCTRLB = const(0xEA) +_ENA3GAMMA = const(0xF2) + + +def init(self): + param_buf = bytearray(15) + param_mv = memoryview(param_buf) + + param_buf[:3] = bytearray([0x00, 0XC1, 0X30]) + self.set_params(_PWRCTRLB, param_mv[:3]) + + param_buf[:4] = bytearray([0x64, 0x03, 0X12, 0X81]) + self.set_params(_PWRONSQCTRL, param_mv[:4]) + + param_buf[:3] = bytearray([0x85, 0x00, 0x78]) + self.set_params(_DRVTIMCTRLA1, param_mv[:3]) + + param_buf[:5] = bytearray([0x39, 0x2C, 0x00, 0x34, 0x02]) + self.set_params(_PWRCTRLA, param_mv[:5]) + + param_buf[0] = 0x20 + self.set_params(_PUMPRATIOCTRL, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x00]) + self.set_params(_DRVTIMCTRLB, param_mv[:2]) + + param_buf[0] = 0x10 + self.set_params(_PWR1, param_mv[:1]) + + param_buf[0] = 0x00 + self.set_params(_PWR2, param_mv[:1]) + + param_buf[:2] = bytearray([0x30, 0x30]) + self.set_params(_VCOMCTL1, param_mv[:2]) + + param_buf[0] = 0xB7 + self.set_params(_VCOMCTL2, param_mv[:1]) + + param_buf[0] = ( + self._madctl( + self._color_byte_order, + self._ORIENTATION_TABLE # NOQA + ) + ) + self.set_params(_MADCTL, param_mv[:1]) + + color_size = lv.color_format_get_size(self._color_space) + if color_size == 2: # NOQA + pixel_format = 0x55 + else: + raise RuntimeError( + f'{self.__class__.__name__} IC only supports ' + 'lv.COLOR_FORMAT.RGB565' + ) + + param_buf[0] = pixel_format + self.set_params(_COLMOD, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x1A]) + self.set_params(_FRMCTR1, param_mv[:2]) + + param_buf[:3] = bytearray([0x08, 0x82, 0x27]) + self.set_params(_DFUNCTRL, param_mv[:3]) + + param_buf[0] = 0x00 + self.set_params(_ENA3GAMMA, param_mv[:1]) + + param_buf[0] = 0x01 + self.set_params(_GAMSET, param_mv[:1]) + + param_buf[:15] = bytearray([ + 0x0F, 0x2A, 0x28, 0x08, 0x0E, 0x08, 0x54, 0xA9, + 0x43, 0x0A, 0x0F, 0x00, 0x00, 0x00, 0x00]) + self.set_params(_PGC, param_mv[:15]) + + param_buf[:15] = bytearray([ + 0x00, 0x15, 0x17, 0x07, 0x11, 0x06, 0x2B, 0x56, + 0x3C, 0x05, 0x10, 0x0F, 0x3F, 0x3F, 0x0F]) + self.set_params(_NGC, param_mv[:15]) + + param_buf[:4] = bytearray([0x00, 0x00, 0x01, 0x3f]) + self.set_params(_RASET, param_mv[:4]) + + param_buf[:4] = bytearray([0x00, 0x00, 0x00, 0xef]) + self.set_params(_CASET, param_mv[:4]) + + self.set_params(_SLPOUT) + time.sleep_ms(120) # NOQA + self.set_params(_DISPON) + time.sleep_ms(20) # NOQA diff --git a/internal_filesystem/lib/drivers/display/ili9341/ili9341.py b/internal_filesystem/lib/drivers/display/ili9341/ili9341.py new file mode 100644 index 00000000..e4ac2c32 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/ili9341/ili9341.py @@ -0,0 +1,15 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import display_driver_framework + + +STATE_HIGH = display_driver_framework.STATE_HIGH +STATE_LOW = display_driver_framework.STATE_LOW +STATE_PWM = display_driver_framework.STATE_PWM + +BYTE_ORDER_RGB = display_driver_framework.BYTE_ORDER_RGB +BYTE_ORDER_BGR = display_driver_framework.BYTE_ORDER_BGR + + +class ILI9341(display_driver_framework.DisplayDriver): + pass diff --git a/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py b/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py new file mode 100644 index 00000000..42a21135 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py @@ -0,0 +1,173 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import time +from micropython import const # NOQA + +import lvgl as lv # NOQA +import lcd_bus + + +_SWRESET = const(0x01) +_SLPOUT = const(0x11) +_MADCTL = const(0x36) +_COLMOD = const(0x3A) +_PORCTRL = const(0xB2) +_GCTRL = const(0xB7) +_VCOMS = const(0xBB) +_LCMCTRL = const(0xC0) +_VDVVRHEN = const(0xC2) +_VRHS = const(0xC3) +_VDVSET = const(0xC4) +_FRCTR2 = const(0xC6) +_PWCTRL1 = const(0xD0) +_INVON = const(0x21) +_CASET = const(0x2A) +_RASET = const(0x2B) +_PGC = const(0xE0) +_NGC = const(0xE1) +_DISPON = const(0x29) +_NORON = const(0x13) + +_RAMCTRL = const(0xB0) +_RGB565SWAP = const(0x08) + + +def init(self): + param_buf = bytearray(14) + param_mv = memoryview(param_buf) + + self.set_params(_SWRESET) + + time.sleep_ms(120) # NOQA + + self.set_params(_SLPOUT) + + time.sleep_ms(120) # NOQA + + self.set_params(_NORON) + + param_buf[0] = ( + self._madctl( + self._color_byte_order, + self._ORIENTATION_TABLE # NOQA + ) + ) + self.set_params(_MADCTL, param_mv[:1]) + + param_buf[0] = 0x0A + param_buf[1] = 0x82 + self.set_params(0xB6, param_mv[:2]) + + # sets swapping the bytes at the hardware level. + + if ( + self._rgb565_byte_swap and + isinstance(self._data_bus, lcd_bus.I80Bus) and + self._data_bus.get_lane_count() == 8 + ): + param_buf[0] = 0x00 + param_buf[1] = 0xF0 | _RGB565SWAP + self.set_params(_RAMCTRL, param_mv[:2]) + + color_size = lv.color_format_get_size(self._color_space) + if color_size == 2: # NOQA + pixel_format = 0x55 + elif color_size == 3: + pixel_format = 0x77 + else: + raise RuntimeError( + f'{self.__class__.__name__} IC only supports ' + 'lv.COLOR_FORMAT.RGB565 or lv.COLOR_FORMAT.RGB888' + ) + + param_buf[0] = pixel_format + self.set_params(_COLMOD, param_mv[:1]) + + time.sleep_ms(10) # NOQA + + param_buf[0] = 0x0C + param_buf[1] = 0x0C + param_buf[2] = 0x00 + param_buf[3] = 0x33 + param_buf[4] = 0x33 + self.set_params(_PORCTRL, param_mv[:5]) + + param_buf[0] = 0x35 + self.set_params(_GCTRL, param_mv[:1]) + + param_buf[0] = 0x28 + self.set_params(_VCOMS, param_mv[:1]) + + param_buf[0] = 0x0C + self.set_params(_LCMCTRL, param_mv[:1]) + + param_buf[0] = 0x01 + self.set_params(_VDVVRHEN, param_mv[:1]) + + param_buf[0] = 0x13 + self.set_params(_VRHS, param_mv[:1]) + + param_buf[0] = 0x20 + self.set_params(_VDVSET, param_mv[:1]) + + param_buf[0] = 0x0F + self.set_params(_FRCTR2, param_mv[:1]) + + param_buf[0] = 0xA4 + param_buf[1] = 0xA1 + self.set_params(_PWCTRL1, param_mv[:2]) + + param_buf[0] = 0xD0 + param_buf[1] = 0x00 + param_buf[2] = 0x02 + param_buf[3] = 0x07 + param_buf[4] = 0x0A + param_buf[5] = 0x28 + param_buf[6] = 0x32 + param_buf[7] = 0x44 + param_buf[8] = 0x42 + param_buf[9] = 0x06 + param_buf[10] = 0x0E + param_buf[11] = 0x12 + param_buf[12] = 0x14 + param_buf[13] = 0x17 + self.set_params(_PGC, param_mv[:14]) + + param_buf[0] = 0xD0 + param_buf[1] = 0x00 + param_buf[2] = 0x02 + param_buf[3] = 0x07 + param_buf[4] = 0x0A + param_buf[5] = 0x28 + param_buf[6] = 0x31 + param_buf[7] = 0x54 + param_buf[8] = 0x47 + param_buf[9] = 0x0E + param_buf[10] = 0x1C + param_buf[11] = 0x17 + param_buf[12] = 0x1B + param_buf[13] = 0x1E + self.set_params(_NGC, param_mv[:14]) + + self.set_params(_INVON) + + param_buf[0] = 0x00 + param_buf[1] = 0x00 + param_buf[2] = (self.display_width >> 8) & 0xFF + param_buf[3] = self.display_width & 0xFF + + self.set_params(_CASET, param_mv[:4]) + + # Page addresses + param_buf[0] = 0x00 + param_buf[1] = 0x00 + param_buf[2] = (self.display_height >> 8) & 0xFF + param_buf[3] = self.display_height & 0xFF + + self.set_params(_RASET, param_mv[:4]) + + self.set_params(_DISPON) + time.sleep_ms(120) # NOQA + + self.set_params(_SLPOUT) + time.sleep_ms(120) # NOQA diff --git a/internal_filesystem/lib/drivers/display/st7789/st7789.py b/internal_filesystem/lib/drivers/display/st7789/st7789.py new file mode 100644 index 00000000..26b9b3a5 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/st7789/st7789.py @@ -0,0 +1,80 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +from micropython import const # NOQA +import display_driver_framework +import lcd_bus +import lvgl as lv + + +STATE_HIGH = display_driver_framework.STATE_HIGH +STATE_LOW = display_driver_framework.STATE_LOW +STATE_PWM = display_driver_framework.STATE_PWM + +BYTE_ORDER_RGB = display_driver_framework.BYTE_ORDER_RGB +BYTE_ORDER_BGR = display_driver_framework.BYTE_ORDER_BGR + +_MADCTL_MV = const(0x20) # 0=Normal, 1=Row/column exchange +_MADCTL_MX = const(0x40) # 0=Left to Right, 1=Right to Left +_MADCTL_MY = const(0x80) # 0=Top to Bottom, 1=Bottom to Top + + +class ST7789(display_driver_framework.DisplayDriver): + _ORIENTATION_TABLE = ( + 0x0, + _MADCTL_MV | _MADCTL_MX, + _MADCTL_MY | _MADCTL_MX, + _MADCTL_MV | _MADCTL_MY + ) + + def __init__( + self, + data_bus, + display_width, + display_height, + frame_buffer1=None, + frame_buffer2=None, + reset_pin=None, + reset_state=STATE_HIGH, + power_pin=None, + power_on_state=STATE_HIGH, + backlight_pin=None, + backlight_on_state=STATE_HIGH, + offset_x=0, + offset_y=0, + color_byte_order=BYTE_ORDER_RGB, + color_space=lv.COLOR_FORMAT.RGB888, # NOQA + rgb565_byte_swap=False, + ): + + if color_space != lv.COLOR_FORMAT.RGB565: # NOQA + rgb565_byte_swap = False + + self._rgb565_byte_swap = rgb565_byte_swap + + if ( + isinstance(data_bus, lcd_bus.I80Bus) and + data_bus.get_lane_count() == 8 + ): + rgb565_byte_swap = False + + super().__init__( + data_bus=data_bus, + display_width=display_width, + display_height=display_height, + frame_buffer1=frame_buffer1, + frame_buffer2=frame_buffer2, + reset_pin=reset_pin, + reset_state=reset_state, + power_pin=power_pin, + power_on_state=power_on_state, + backlight_pin=backlight_pin, + backlight_on_state=backlight_on_state, + offset_x=offset_x, + offset_y=offset_y, + color_byte_order=color_byte_order, + color_space=color_space, # NOQA + rgb565_byte_swap=rgb565_byte_swap, + _cmd_bits=8, + _param_bits=8, + _init_bus=True + ) diff --git a/internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py b/internal_filesystem/lib/drivers/imu_sensor/qmi8658.py similarity index 100% rename from internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py rename to internal_filesystem/lib/drivers/imu_sensor/qmi8658.py diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/drivers/imu_sensor/wsen_isds.py similarity index 100% rename from internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py rename to internal_filesystem/lib/drivers/imu_sensor/wsen_isds.py diff --git a/internal_filesystem/lib/drivers/indev/cst816s.py b/internal_filesystem/lib/drivers/indev/cst816s.py new file mode 100644 index 00000000..64255ac8 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/cst816s.py @@ -0,0 +1,307 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +from micropython import const # NOQA +import pointer_framework +import time +import machine # NOQA + + +I2C_ADDR = 0x15 +BITS = 8 + +# 0x00: No gesture +# 0x01: Swipe up +# 0x02: Swipe down +# 0x03: Swipe left +# 0x04: Swipe right +# 0x05: Single click +# 0x0B: Double click +# 0x0C: Long press +_GestureID = const(0x01) + +# 0: No finger +# 1: 1 finger +_FingerNum = const(0x02) + +# & 0xF << 8 +_XposH = const(0x03) +_XposL = const(0x04) + +# & 0xF << 8 +_YposH = const(0x05) +_YposL = const(0x06) + +_RegisterVersion = const(0x15) + +_BPC0H = const(0xB0) +_BPC0L = const(0xB1) + +_BPC1H = const(0xB2) +_BPC1L = const(0xB3) + +_ChipID = const(0xA7) +_ChipIDValue = const(0xB5) +_ChipIDValue2 = const(0xB6) + +_ProjID = const(0xA8) +_FwVersion = const(0xA9) + + +# =============================== +_MotionMask = const(0xEC) + +# Enables continuous left and right sliding +_EnConLR = const(0x04) +# Enables continuous up and down sliding +_EnConUD = const(0x02) +# Enable double-click action +_EnDClick = const(0x01) +# =============================== + +# Interrupt low pulse output width. +# Unit 0.1ms, optional value: 1~200. The default value is 10. +_IrqPluseWidth = const(0xED) + +# Normal fast detection cycle. +# This value affects LpAutoWakeTime and AutoSleepTime. +# Unit 10ms, optional value: 1~30. The default value is 1. +_NorScanPer = const(0xEE) + +# Gesture detection sliding partition angle control. Angle=tan(c)*10 +# c is the angle based on the positive direction of the x-axis. +_MotionSlAngle = const(0xEF) + +_LpScanRaw1H = const(0xF0) +_LpScanRaw1L = const(0xF1) +_LpScanRaw2H = const(0xF2) +_LpScanRaw2L = const(0xF3) + +# Automatic recalibration period in low power consumption. +# Unit: 1 minute, optional value: 1 to 5. The default value is 5. +_LpAutoWakeTime = const(0xF4) + + +# Low power scan wake-up threshold. The smaller the value, +# the more sensitive it is. +# Optional values: 1 to 255. The default value is 48. +_LpScanTH = const(0xF5) + +# Low power scan range. The larger the value, the more sensitive it is, +# and the higher the power consumption is. +# Optional values: 0, 1, 2, 3. The default value is 3. +_LpScanWin = const(0xF6) + +# Low power scan frequency. The smaller the value, the more sensitive it is. +# Optional values: 1 to 255. The default value is 7. +_LpScanFreq = const(0xF7) + +# Low power scan current. The smaller the value, the more sensitive it is. +# Optional values: 1 to 255. +_LpScanIdac = const(0xF8) + + +# Automatically enters low power mode when there is no touch within x seconds. +# Unit: 1S, default value: 2S. +_AutoSleepTime = const(0xF9) + +# =============================== +_IrqCtl = const(0xFA) +# Interrupt pin test, automatically sends low pulses periodically after enabling +_EnTest = const(0x80) +# Sends low pulses periodically when touch is detected. +_EnTouch = const(0x40) +# Sends low pulses when touch state changes are detected. +_EnChange = const(0x20) +# Sends low pulses when gestures are detected. +_EnMotion = const(0x10) +# Long press gesture only sends one low pulse signal. +_OnceWLP = const(0x01) +# =============================== + + +# Automatically reset when there is touch but no valid gesture within x seconds. +# Unit: 1S. This function is not enabled when it is 0. The default value is 5. +_AutoReset = const(0xFB) + +# Automatically reset after long pressing for x seconds. +# Unit: 1S. This function is not enabled when it is 0. The default value is 10. +_LongPressTime = const(0xFC) + +# =============================== +_IOCtl = const(0xFD) + +# The master controller realizes the soft reset function +# of the touch screen by pulling down the IRQ pin. +# 0: Disable soft reset. +# 1: Enable soft reset. +_SOFT_RST = const(0x04) + +# IIC pin drive mode, the default is resistor pull-up. +# 0: Resistor pull-up +# 1: OD +_IIC_OD = const(0x02) + +# IIC and IRQ pin level selection, the default is VDD level. +# 0: VDD +# 1: 1.8V +_En1v8 = const(0x01) +# =============================== + +# The default value is 0, enabling automatic entry into low power mode. +# When the value is non-zero, automatic entry into low power mode is disabled. +# 0: enabled +# 1: disabled +_DisAutoSleep = const(0xFE) + + +class CST816S(pointer_framework.PointerDriver): + + def _read_reg(self, reg): + self._tx_buf[0] = reg + self._rx_buf[0] = 0x00 + + self._device.write_readinto(self._tx_mv[:1], self._rx_mv[:1]) + + def _write_reg(self, reg, value): + self._tx_buf[0] = reg + self._tx_buf[1] = value + self._device.write(self._tx_mv[:2]) + + def __init__( + self, + device, + reset_pin=None, + touch_cal=None, + startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._0, # NOQA + debug=False + ): + self._tx_buf = bytearray(2) + self._tx_mv = memoryview(self._tx_buf) + self._rx_buf = bytearray(1) + self._rx_mv = memoryview(self._rx_buf) + + self._device = device + + if not isinstance(reset_pin, int): + self._reset_pin = reset_pin + else: + self._reset_pin = machine.Pin(reset_pin, machine.Pin.OUT) + + if self._reset_pin: + self._reset_pin.value(1) + + self.hw_reset() + self.auto_sleep = False + + self._read_reg(_ChipID) + print('Chip ID:', hex(self._rx_buf[0])) + chip_id = self._rx_buf[0] + + self._read_reg(_RegisterVersion) + print('Touch version:', self._rx_buf[0]) + + self._read_reg(_ProjID) + print('Proj ID:', hex(self._rx_buf[0])) + + self._read_reg(_FwVersion) + print('FW Version:', hex(self._rx_buf[0])) + + if chip_id not in (_ChipIDValue, _ChipIDValue2): + raise RuntimeError(f'Incorrect chip id ({hex(_ChipIDValue)})') + + self._write_reg(_IrqCtl, _EnTouch | _EnChange) + + super().__init__( + touch_cal=touch_cal, startup_rotation=startup_rotation, debug=debug + ) + + @property + def wake_up_threshold(self): + self._read_reg(_LpScanTH) + return 256 - self._rx_buf[0] + + @wake_up_threshold.setter + def wake_up_threshold(self, value): + if value < 1: + value = 1 + elif value > 255: + value = 255 + + self._write_reg(_LpScanTH, 256 - value) + + @property + def wake_up_scan_frequency(self): + self._read_reg(_LpScanFreq) + return 256 - self._rx_buf[0] + + @wake_up_scan_frequency.setter + def wake_up_scan_frequency(self, value): + if value < 1: + value = 1 + elif value > 255: + value = 255 + + self._write_reg(_LpScanFreq, 256 - value) + + @property + def auto_sleep_timeout(self): + self._read_reg(_AutoSleepTime) + return self._rx_buf[0] + + @auto_sleep_timeout.setter + def auto_sleep_timeout(self, value): + if value < 1: + value = 1 + elif value > 255: + value = 255 + + self._write_reg(_AutoSleepTime, value) + + def wake_up(self): + auto_sleep = self.auto_sleep + + self._write_reg(_DisAutoSleep, 0x00) + time.sleep_ms(10) # NOQA + self._write_reg(_DisAutoSleep, 0xFE) + time.sleep_ms(50) # NOQA + self._write_reg(_DisAutoSleep, 0xFE) + time.sleep_ms(50) # NOQA + self._write_reg(_DisAutoSleep, int(not auto_sleep)) + + @property + def auto_sleep(self): + self._read_reg(_DisAutoSleep) + return self._rx_buf[0] == 0x00 + + @auto_sleep.setter + def auto_sleep(self, en): + if en: + self._write_reg(_DisAutoSleep, 0x00) + else: + self._write_reg(_DisAutoSleep, 0xFE) + + def hw_reset(self): + if self._reset_pin is None: + return + + self._reset_pin(0) + time.sleep_ms(1) # NOQA + self._reset_pin(1) + time.sleep_ms(50) # NOQA + + def _get_coords(self): + self._read_reg(_FingerNum) + if self._rx_buf[0] == 0: + return None + + self._read_reg(_XposH) + x = (self._rx_buf[0] & 0x0F) << 8 + self._read_reg(_XposL) + x |= self._rx_buf[0] + + self._read_reg(_YposH) + y = (self._rx_buf[0] & 0x0F) << 8 + self._read_reg(_YposL) + y |= self._rx_buf[0] + + return self.PRESSED, x, y diff --git a/internal_filesystem/lib/mpos/indev/gt911.py b/internal_filesystem/lib/drivers/indev/gt911.py similarity index 100% rename from internal_filesystem/lib/mpos/indev/gt911.py rename to internal_filesystem/lib/drivers/indev/gt911.py diff --git a/internal_filesystem/lib/mpos/indev/mpos_sdl_keyboard.py b/internal_filesystem/lib/drivers/indev/sdl_keyboard.py similarity index 100% rename from internal_filesystem/lib/mpos/indev/mpos_sdl_keyboard.py rename to internal_filesystem/lib/drivers/indev/sdl_keyboard.py diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 4285d8fc..38ededa0 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -1,9 +1,7 @@ # Hardware initialization for Fri3d Camp 2024 Badge from machine import Pin, SPI, SDCard -import st7789 import lcd_bus import machine -import cst816s import i2c import math @@ -13,6 +11,9 @@ import lvgl as lv import task_handler +import drivers.display.st7789 as st7789 +import drivers.indev.cst816s as cst816s + import mpos.ui import mpos.ui.focus_direction from mpos import InputManager diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py index 3c10f77e..e5c0935c 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2026.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -14,10 +14,8 @@ # - test it on the Waveshare to make sure no syntax / variable errors from machine import Pin, SPI, SDCard -import st7789 import lcd_bus import machine -import cst816s import i2c import math @@ -27,6 +25,9 @@ import lvgl as lv import task_handler +import drivers.display.st7789 as st7789 +import drivers.indev.cst816s as cst816s + import mpos.ui import mpos.ui.focus_direction from mpos import InputManager diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 2705ca8e..b8cf1495 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -3,8 +3,9 @@ import lvgl as lv import sdl_display +from drivers.indev.sdl_keyboard import MposSDLKeyboard + import mpos.clipboard -import mpos.indev.mpos_sdl_keyboard import mpos.ui import mpos.ui.focus_direction from mpos import InputManager @@ -76,7 +77,7 @@ def catch_escape_key(indev, indev_data): sdlkeyboard._read(indev, indev_data) -sdlkeyboard = mpos.indev.mpos_sdl_keyboard.MposSDLKeyboard() +sdlkeyboard = MposSDLKeyboard() sdlkeyboard._indev_drv.set_read_cb(catch_escape_key) # check for escape InputManager.register_indev(sdlkeyboard) try: diff --git a/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py b/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py index f88ed859..9e2a6eea 100644 --- a/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py +++ b/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py @@ -82,7 +82,7 @@ def init_touch(): try: import i2c i2c_bus = i2c.I2C.Bus(host=0, scl=38, sda=39, freq=I2C_FREQ, use_locks=False) - import mpos.indev.gt911 as gt911 + import drivers.indev.gt911 as gt911 touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=gt911.I2C_ADDR, reg_bits=gt911.BITS) indev = gt911.GT911(touch_dev, reset_pin=1, interrupt_pin=40, debug=False) # debug makes it slower from mpos import InputManager diff --git a/internal_filesystem/lib/mpos/board/odroid_go.py b/internal_filesystem/lib/mpos/board/odroid_go.py index 81bf92a2..0a08cc8b 100644 --- a/internal_filesystem/lib/mpos/board/odroid_go.py +++ b/internal_filesystem/lib/mpos/board/odroid_go.py @@ -6,7 +6,7 @@ import time -import ili9341 +import drivers.display.ili9341 as ili9341 import lcd_bus import lvgl as lv import machine @@ -157,7 +157,7 @@ def poll(self): return crossbar_pressed -# see: internal_filesystem/lib/mpos/indev/mpos_sdl_keyboard.py +# see: internal_filesystem/lib/drivers/indev/sdl_keyboard.py # lv.KEY.UP # lv.KEY.LEFT - lv.KEY.RIGHT # lv.KEY.DOWN 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 e75b97bc..15a10229 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 @@ -2,15 +2,16 @@ print("waveshare_esp32_s3_touch_lcd_2.py initialization") # Hardware initialization for ESP32-S3-Touch-LCD-2 # Manufacturer's website at https://www.waveshare.com/wiki/ESP32-S3-Touch-LCD-2 -import st7789 import lcd_bus import machine -import cst816s import i2c import lvgl as lv import task_handler +import drivers.display.st7789 as st7789 +import drivers.indev.cst816s as cst816s + import mpos.ui # Pin configuration diff --git a/internal_filesystem/lib/mpos/hardware/drivers/__init__.py b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py deleted file mode 100644 index 119fb43d..00000000 --- a/internal_filesystem/lib/mpos/hardware/drivers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# IMU and sensor drivers for MicroPythonOS diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 96f147bc..fa5eab57 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -162,7 +162,7 @@ def _ensure_imu_initialized(self): # Try QMI8658 first (Waveshare board) if self._i2c_bus: try: - from mpos.hardware.drivers.qmi8658 import QMI8658 + from drivers.imu_sensor.qmi8658 import QMI8658 chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x00, 1)[0] # PARTID register if chip_id == 0x05: # QMI8685_PARTID self._imu_driver = _QMI8658Driver(self._i2c_bus, self._i2c_address) @@ -174,7 +174,7 @@ def _ensure_imu_initialized(self): # Try WSEN_ISDS (fri3d_2024) or LSM6DSO (fri3d_2026) try: - from mpos.hardware.drivers.wsen_isds import Wsen_Isds + from drivers.imu_sensor.wsen_isds import Wsen_Isds chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x0F, 1)[0] # WHO_AM_I register - could also use Wsen_Isds.get_chip_id() if chip_id == 0x6A or chip_id == 0x6C: # WSEN_ISDS WHO_AM_I 0x6A (Fri3d 2024) or 0x6C (Fri3d 2026) self._imu_driver = _WsenISDSDriver(self._i2c_bus, self._i2c_address) @@ -721,7 +721,7 @@ class _QMI8658Driver(_IMUDriver): """Wrapper for QMI8658 IMU (Waveshare board).""" def __init__(self, i2c_bus, address): - from mpos.hardware.drivers.qmi8658 import QMI8658 + from drivers.imu_sensor.qmi8658 import QMI8658 # QMI8658 scale constants (can't import const() values) _ACCELSCALE_RANGE_8G = 0b10 _GYROSCALE_RANGE_256DPS = 0b100 @@ -817,7 +817,7 @@ 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 + from drivers.imu_sensor.wsen_isds import Wsen_Isds self.sensor = Wsen_Isds( i2c_bus, address=address, diff --git a/internal_filesystem/lib/mpos/ui/camera_settings.py b/internal_filesystem/lib/mpos/ui/camera_settings.py index 46cd8f34..6931d79b 100644 --- a/internal_filesystem/lib/mpos/ui/camera_settings.py +++ b/internal_filesystem/lib/mpos/ui/camera_settings.py @@ -224,7 +224,7 @@ 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 ..indev.mpos_sdl_keyboard import MposKeyboard + from mpos.ui.keyboard import MposKeyboard keyboard = MposKeyboard(parent) keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) keyboard.add_flag(lv.obj.FLAG.HIDDEN) diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index b20ae9be..8edd4902 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -6,7 +6,6 @@ codebasedir=$(readlink -f "$mydir"/..) # build process needs absolute paths target="$1" buildtype="$2" -subtarget="$3" if [ -z "$target" ]; then echo "Usage: $0 target" @@ -14,11 +13,10 @@ if [ -z "$target" ]; then echo "Example: $0 unix" echo "Example: $0 macOS" echo "Example: $0 esp32" - echo "Example: $0 odroid_go" + echo "Example: $0 esp32s3" exit 1 fi - # This assumes all the git submodules have been checked out recursively echo "Fetch tags for lib/SDL, otherwise lvgl_micropython's make.py script can't checkout a specific tag..." @@ -79,13 +77,21 @@ ln -sf ../../c_mpos "$codebasedir"/lvgl_micropython/ext_mod/c_mpos echo "Refreshing freezefs..." "$codebasedir"/scripts/freezefs_mount_builtin.sh -manifest="" -if [ "$target" == "esp32" ]; then +if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then + if [ "$target" == "esp32" ]; then + BOARD=ESP32_GENERIC + BOARD_VARIANT=SPIRAM + else # esp32s3 + BOARD=ESP32_GENERIC_S3 + BOARD_VARIANT=SPIRAM_OCT + fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." - # Build for https://www.waveshare.com/wiki/ESP32-S3-Touch-LCD-2. - # See https://github.com/lvgl-micropython/lvgl_micropython + pushd "$codebasedir"/lvgl_micropython/ + rm -rf lib/micropython/ports/esp32/build-$BOARD-$BOARD_VARIANT + + # For more info on the options, see https://github.com/lvgl-micropython/lvgl_micropython # --ota: support Over-The-Air updates # --partition size: both OTA partitions are 4MB # --flash-size: total flash size is 16MB @@ -97,19 +103,8 @@ if [ "$target" == "esp32" ]; then # CONFIG_FREERTOS_USE_TRACE_FACILITY=y # CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y - pushd "$codebasedir"/lvgl_micropython/ - rm -rf lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/ - python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=ESP32_GENERIC_S3 BOARD_VARIANT=SPIRAM_OCT DISPLAY=st7789 INDEV=cst816s USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y "$frozenmanifest" - popd -elif [ "$target" == "odroid_go" ]; then - manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) - frozenmanifest="FROZEN_MANIFEST=$manifest" - echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." - # Build for https://wiki.odroid.com/odroid_go/odroid_go - pushd "$codebasedir"/lvgl_micropython/ - rm -rf lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/ - python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 \ - BOARD=ESP32_GENERIC BOARD_VARIANT=SPIRAM DISPLAY=ili9341 \ + + python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake \ USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake \ USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake \ @@ -117,6 +112,7 @@ elif [ "$target" == "odroid_go" ]; then CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y \ CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y \ "$frozenmanifest" + popd elif [ "$target" == "unix" -o "$target" == "macOS" ]; then manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) @@ -128,31 +124,15 @@ elif [ "$target" == "unix" -o "$target" == "macOS" ]; then stream_wav_file="$codebasedir"/internal_filesystem/lib/mpos/audio/stream_wav.py sed -i.backup 's/^@micropython\.viper$/#@micropython.viper/' "$stream_wav_file" - #if [ "$target" == "unix" ]; then - if false; then - # only on unix, because on macos, homebrew install rlottie fails so the compilation runs into: fatal error: 'rlottie_capi.h' file not found on macos" - # and on esp32, rlottie_create_from_raw() crashes the system - sed -i.backup 's/#define MICROPY_RLOTTIE 0/#define MICROPY_RLOTTIE 1/' "$codebasedir"/lvgl_micropython/lib/lv_conf.h - echo "After enabling MICROPY_RLOTTIE:" - cat "$codebasedir"/lvgl_micropython/lib/lv_conf.h - fi - # If it's still running, kill it, otherwise "text file busy" pkill -9 -f /lvgl_micropy_unix # LV_CFLAGS are passed to USER_C_MODULES (compiler flags only, no linker flags) # STRIP= makes it so that debug symbols are kept pushd "$codebasedir"/lvgl_micropython/ # USER_C_MODULE doesn't seem to work properly so there are symlinks in lvgl_micropython/extmod/ - python3 make.py "$target" LV_CFLAGS="-g -O0 -ggdb" STRIP= DISPLAY=sdl_display INDEV=sdl_pointer INDEV=sdl_keyboard "$frozenmanifest" + python3 make.py "$target" LV_CFLAGS="-g -O0 -ggdb" STRIP= DISPLAY=sdl_display INDEV=sdl_pointer "$frozenmanifest" popd - # Restore RLOTTIE: - if [ "$target" == "unix" ]; then - sed -i.backup 's/#define MICROPY_RLOTTIE 1/#define MICROPY_RLOTTIE 0/' "$codebasedir"/lvgl_micropython/lib/lv_conf.h - #echo "After disabling MICROPY_RLOTTIE:" - #cat "$codebasedir"/lvgl_micropython/lib/lv_conf.h - fi - # Restore @micropython.viper decorator after build echo "Restoring @micropython.viper decorator..." sed -i.backup 's/^#@micropython\.viper$/@micropython.viper/' "$stream_wav_file" From 749bb78d7cbd1d47335b9cef0624c70b8705763e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 15:30:24 +0100 Subject: [PATCH 020/317] Don't compile adc_mic.c for now --- c_mpos/micropython.cmake | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/c_mpos/micropython.cmake b/c_mpos/micropython.cmake index 9f517303..b7ac436a 100644 --- a/c_mpos/micropython.cmake +++ b/c_mpos/micropython.cmake @@ -4,13 +4,13 @@ add_library(usermod_c_mpos INTERFACE) -set(MPOS_C_INCLUDES - ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/include/ - ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/interface/ -) +#set(MPOS_C_INCLUDES +# ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/include/ +# ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/interface/ +#) set(MPOS_C_SOURCES - ${CMAKE_CURRENT_LIST_DIR}/src/adc_mic.c +# ${CMAKE_CURRENT_LIST_DIR}/src/adc_mic.c ${CMAKE_CURRENT_LIST_DIR}/src/quirc_decode.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/identify.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/version_db.c From 920e51d7e1b8ece41fbe8574dabe6c6dff108bd4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 17:34:18 +0100 Subject: [PATCH 021/317] Fix build --- .github/workflows/linux.yml | 6 +++--- c_mpos/micropython.cmake | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index d5b6b734..d22bf2d5 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -92,8 +92,8 @@ jobs: - name: Build LVGL MicroPython esp32 run: | ./scripts/build_mpos.sh esp32 - mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin - mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC-SPIRAM-16.bin lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota - name: Upload built binary as artifact uses: actions/upload-artifact@v4 @@ -106,7 +106,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota - path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota retention-days: 7 diff --git a/c_mpos/micropython.cmake b/c_mpos/micropython.cmake index b7ac436a..d442f0c3 100644 --- a/c_mpos/micropython.cmake +++ b/c_mpos/micropython.cmake @@ -4,6 +4,8 @@ add_library(usermod_c_mpos INTERFACE) +set(MPOS_C_INCLUDES) + #set(MPOS_C_INCLUDES # ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/include/ # ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/interface/ From 180fd0e6675a43febfacf3df37212f905ed6b118 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 19:10:06 +0100 Subject: [PATCH 022/317] Fix unit tests --- tests/unittest.sh | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/unittest.sh b/tests/unittest.sh index baa1d1e2..602a2451 100755 --- a/tests/unittest.sh +++ b/tests/unittest.sh @@ -64,16 +64,16 @@ one_test() { # Desktop execution if [ $is_graphical -eq 1 ]; then echo "Graphical test: include main.py" - "$binary" -X heapsize=8M -c "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) ; sys.path.append(\"$tests_abs_path\") + "$binary" -X heapsize=8M -c "import sys ; sys.path.insert(0, 'lib') ; sys.path.append(\"$tests_abs_path\") ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(cat $file) result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " - result=$? + result=$? else echo "Regular test: no boot files" - "$binary" -X heapsize=8M -c "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) + "$binary" -X heapsize=8M -c "import sys ; sys.path.insert(0, 'lib') ; sys.path.append(\"$tests_abs_path\") ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(cat $file) result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " - result=$? + result=$? fi else if [ ! -z "$ondevice" ]; then @@ -90,23 +90,23 @@ result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " echo "$test logging to $testlog" if [ $is_graphical -eq 1 ]; then # Graphical test: system already initialized, just add test paths - "$mpremote" exec "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) ; sys.path.append('tests') + "$mpremote" exec "import sys ; sys.path.insert(0, 'lib') ; sys.path.append('tests') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(cat $file) result = unittest.main() if result.wasSuccessful(): - print('TEST WAS A SUCCESS') + print('TEST WAS A SUCCESS') else: - print('TEST WAS A FAILURE') + print('TEST WAS A FAILURE') " | tee "$testlog" else # Regular test: no boot files - "$mpremote" exec "import sys ; sys.path.insert(0, 'lib') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) + "$mpremote" exec "import sys ; sys.path.insert(0, 'lib') ; sys.path.append('tests') ; import mpos ; mpos.TaskManager.disable() ; $(cat main.py) $(cat $file) result = unittest.main() if result.wasSuccessful(): - print('TEST WAS A SUCCESS') + print('TEST WAS A SUCCESS') else: - print('TEST WAS A FAILURE') + print('TEST WAS A FAILURE') " | tee "$testlog" fi grep -q "TEST WAS A SUCCESS" "$testlog" From 1f72b5adf2b79634da732348d4b41f397c76b7bf Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 19:11:51 +0100 Subject: [PATCH 023/317] Linux builds: add esp32s3 --- .github/workflows/linux.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index d22bf2d5..e40d5f33 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -109,4 +109,24 @@ jobs: path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota retention-days: 7 + - name: Build LVGL MicroPython esp32s3 + run: | + ./scripts/build_mpos.sh esp32s3 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + retention-days: 7 + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + retention-days: 7 + From 7d832097302df5773f6c2c7ab87101c812aa978f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 19:22:32 +0100 Subject: [PATCH 024/317] Output both esp32 and esp32s3 builds --- .github/workflows/linux.yml | 6 +++--- .github/workflows/macos.yml | 30 +++++++++++++++++++++--------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index e40d5f33..7a9d1d47 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -58,7 +58,7 @@ jobs: - name: Install additional MicroPythonOS dependencies run: | sudo apt-get update - sudo apt-get install -y libv4l-dev librlottie-dev + sudo apt-get install -y libv4l-dev - name: Extract OS version id: version @@ -118,14 +118,14 @@ jobs: - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin path: lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin retention-days: 7 - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota retention-days: 7 diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 0dbdf7e0..2876bd67 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -56,34 +56,46 @@ jobs: with: name: MicroPythonOS_amd64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin path: lvgl_micropython/build/MicroPythonOS_amd64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin - compression-level: 0 # don't zip it retention-days: 7 + - name: Build LVGL MicroPython esp32 run: | ./scripts/build_mpos.sh esp32 - mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin - mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC-SPIRAM-16.bin lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin path: lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin - compression-level: 0 # don't zip it retention-days: 7 - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota - path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota - compression-level: 0 # don't zip it + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota retention-days: 7 - - name: Cleanup + + - name: Build LVGL MicroPython esp32s3 run: | - rm lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin - rm lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + ./scripts/build_mpos.sh esp32s3 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + retention-days: 7 + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + retention-days: 7 From 5f448f420bafed2dd814422a756f3bfdd9c35c63 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 20:33:19 +0100 Subject: [PATCH 025/317] Fix tests --- internal_filesystem/lib/mpos/audio/stream_record_adc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_record_adc.py b/internal_filesystem/lib/mpos/audio/stream_record_adc.py index 67c15914..cf237cc2 100644 --- a/internal_filesystem/lib/mpos/audio/stream_record_adc.py +++ b/internal_filesystem/lib/mpos/audio/stream_record_adc.py @@ -130,7 +130,7 @@ def __init__(self, file_path, duration_ms, sample_rate, adc_pin=None, self.max_pending_samples = adc_config.get('max_pending_samples', self.DEFAULT_MAX_PENDING_SAMPLES) # PI Controller state - self._current_freq = self.sample_rate + self.callback_overhead_offset + self._current_freq = self.sample_rate self._sample_counter = 0 self._last_adjustment_sample = 0 self._integral_error = 0.0 From 6a4cbcc452dcf2a7fc4eeba46083fcc5d4446324 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 20:33:58 +0100 Subject: [PATCH 026/317] Add test --- tests/test_adc_recording.py | 370 ++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 tests/test_adc_recording.py diff --git a/tests/test_adc_recording.py b/tests/test_adc_recording.py new file mode 100644 index 00000000..59525164 --- /dev/null +++ b/tests/test_adc_recording.py @@ -0,0 +1,370 @@ +# Test ADC Recording Integration +# Tests the new ADCRecordStream with adaptive frequency control +# Run with: ./MicroPythonOS/tests/unittest.sh MicroPythonOS/tests/test_adc_recording.py + +import unittest +import time +import os +import sys + +# Add lib path for imports +# In MicroPython, os.path doesn't exist, so we construct the path manually +test_dir = __file__.rsplit('/', 1)[0] if '/' in __file__ else '.' +lib_path = test_dir + '/../internal_filesystem/lib' +sys.path.insert(0, lib_path) + +from mpos.audio.stream_record_adc import ADCRecordStream + + +class TestADCRecordStream(unittest.TestCase): + """Test ADCRecordStream with adaptive frequency control.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_dir = "data/test_adc" + self.test_file = f"{self.test_dir}/test_recording.wav" + + # Create test directory + try: + os.makedirs(self.test_dir, exist_ok=True) + except: + pass + + def tearDown(self): + """Clean up test files.""" + try: + if os.path.exists(self.test_file): + os.remove(self.test_file) + except: + pass + + def test_adc_stream_initialization(self): + """Test ADCRecordStream initialization.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000, + adc_pin=2 + ) + + self.assertEqual(stream.file_path, self.test_file) + self.assertEqual(stream.duration_ms, 1000) + self.assertEqual(stream.sample_rate, 8000) + self.assertEqual(stream.adc_pin, 2) + self.assertFalse(stream.is_recording()) + + def test_adc_stream_defaults(self): + """Test ADCRecordStream default parameters.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=None, + sample_rate=None + ) + + self.assertEqual(stream.duration_ms, ADCRecordStream.DEFAULT_MAX_DURATION_MS) + self.assertEqual(stream.sample_rate, ADCRecordStream.DEFAULT_SAMPLE_RATE) + self.assertEqual(stream.adc_pin, ADCRecordStream.DEFAULT_ADC_PIN) + + def test_pi_controller_defaults(self): + """Test PI controller default parameters.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000 + ) + + self.assertEqual(stream.control_gain_p, ADCRecordStream.DEFAULT_CONTROL_GAIN_P) + self.assertEqual(stream.control_gain_i, ADCRecordStream.DEFAULT_CONTROL_GAIN_I) + self.assertEqual(stream.integral_windup_limit, ADCRecordStream.DEFAULT_INTEGRAL_WINDUP_LIMIT) + self.assertEqual(stream.adjustment_interval, ADCRecordStream.DEFAULT_ADJUSTMENT_INTERVAL) + self.assertEqual(stream.warmup_samples, ADCRecordStream.DEFAULT_WARMUP_SAMPLES) + + def test_custom_pi_parameters(self): + """Test custom PI controller parameters.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000, + control_gain_p=0.1, + control_gain_i=0.02, + integral_windup_limit=500, + adjustment_interval=500, + warmup_samples=1000 + ) + + self.assertEqual(stream.control_gain_p, 0.1) + self.assertEqual(stream.control_gain_i, 0.02) + self.assertEqual(stream.integral_windup_limit, 500) + self.assertEqual(stream.adjustment_interval, 500) + self.assertEqual(stream.warmup_samples, 1000) + + def test_wav_header_creation(self): + """Test WAV header generation.""" + header = ADCRecordStream._create_wav_header( + sample_rate=8000, + num_channels=1, + bits_per_sample=16, + data_size=16000 + ) + + # Check header size + self.assertEqual(len(header), 44) + + # Check RIFF signature + self.assertEqual(header[0:4], b'RIFF') + + # Check WAVE signature + self.assertEqual(header[8:12], b'WAVE') + + # Check fmt signature + self.assertEqual(header[12:16], b'fmt ') + + # Check data signature + self.assertEqual(header[36:40], b'data') + + def test_wav_header_sample_rate(self): + """Test WAV header contains correct sample rate.""" + sample_rate = 16000 + header = ADCRecordStream._create_wav_header( + sample_rate=sample_rate, + num_channels=1, + bits_per_sample=16, + data_size=32000 + ) + + # Sample rate is at offset 24-28 (little-endian) + header_sample_rate = int.from_bytes(header[24:28], 'little') + self.assertEqual(header_sample_rate, sample_rate) + + def test_wav_header_data_size(self): + """Test WAV header contains correct data size.""" + data_size = 32000 + header = ADCRecordStream._create_wav_header( + sample_rate=8000, + num_channels=1, + bits_per_sample=16, + data_size=data_size + ) + + # Data size is at offset 40-44 (little-endian) + header_data_size = int.from_bytes(header[40:44], 'little') + self.assertEqual(header_data_size, data_size) + + def test_sine_wave_generation(self): + """Test sine wave generation for desktop simulation.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000 + ) + + # Generate 1KB of sine wave + buf, num_samples = stream._generate_sine_wave_chunk(1024, 0) + + self.assertEqual(len(buf), 1024) + self.assertEqual(num_samples, 512) # 1024 bytes / 2 bytes per sample + + def test_sine_wave_phase_continuity(self): + """Test sine wave phase continuity across chunks.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000 + ) + + # Generate two chunks + buf1, num_samples1 = stream._generate_sine_wave_chunk(1024, 0) + buf2, num_samples2 = stream._generate_sine_wave_chunk(1024, num_samples1) + + # Both should have same number of samples + self.assertEqual(num_samples1, num_samples2) + + # Buffers should be different (different phase) + self.assertNotEqual(buf1, buf2) + + def test_stop_recording(self): + """Test stop() method.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=10000, + sample_rate=8000 + ) + + self.assertTrue(stream._keep_running) + stream.stop() + self.assertFalse(stream._keep_running) + + def test_elapsed_time_calculation(self): + """Test elapsed time calculation.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000 + ) + + # Simulate recording 1 second of audio + # 8000 samples * 2 bytes per sample = 16000 bytes + stream._bytes_recorded = 16000 + + elapsed_ms = stream.get_elapsed_ms() + self.assertEqual(elapsed_ms, 1000) + + def test_adaptive_control_disabled(self): + """Test creating stream with adaptive control disabled.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000, + adaptive_control=False + ) + + self.assertFalse(stream.adaptive_control) + + def test_gc_configuration(self): + """Test garbage collection configuration.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000, + gc_enabled=True, + gc_interval=3000 + ) + + self.assertTrue(stream.gc_enabled) + self.assertEqual(stream.gc_interval, 3000) + + def test_max_pending_samples(self): + """Test max pending samples buffer configuration.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000, + max_pending_samples=8192 + ) + + self.assertEqual(stream.max_pending_samples, 8192) + + def test_frequency_bounds(self): + """Test frequency bounds configuration.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000, + min_freq=5000, + max_freq=50000 + ) + + self.assertEqual(stream.min_freq, 5000) + self.assertEqual(stream.max_freq, 50000) + + def test_callback_overhead_offset(self): + """Test callback overhead offset configuration.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000, + callback_overhead_offset=3000 + ) + + self.assertEqual(stream.callback_overhead_offset, 3000) + # Initial frequency should be target sample rate (offset is only used if needed) + self.assertEqual(stream._current_freq, 8000) + + def test_on_complete_callback(self): + """Test on_complete callback is stored.""" + def callback(msg): + pass + + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000, + on_complete=callback + ) + + self.assertEqual(stream.on_complete, callback) + + def test_multiple_streams_independent(self): + """Test multiple ADCRecordStream instances are independent.""" + stream1 = ADCRecordStream( + file_path=f"{self.test_dir}/test1.wav", + duration_ms=1000, + sample_rate=8000 + ) + + stream2 = ADCRecordStream( + file_path=f"{self.test_dir}/test2.wav", + duration_ms=2000, + sample_rate=16000 + ) + + self.assertNotEqual(stream1.file_path, stream2.file_path) + self.assertNotEqual(stream1.duration_ms, stream2.duration_ms) + self.assertNotEqual(stream1.sample_rate, stream2.sample_rate) + + def test_pi_controller_state_initialization(self): + """Test PI controller state is properly initialized.""" + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000 + ) + + self.assertEqual(stream._sample_counter, 0) + self.assertEqual(stream._integral_error, 0.0) + self.assertFalse(stream._warmup_complete) + self.assertEqual(len(stream._adjustment_history), 0) + + def test_desktop_simulation_mode(self): + """Test desktop simulation mode (no machine module).""" + # This test verifies the stream can be created even without machine module + stream = ADCRecordStream( + file_path=self.test_file, + duration_ms=1000, + sample_rate=8000 + ) + + # Should not raise exception + self.assertIsNotNone(stream) + + +class TestADCIntegrationWithAudioManager(unittest.TestCase): + """Test ADC recording integration with AudioManager.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_dir = "data/test_adc_manager" + self.test_file = f"{self.test_dir}/test_recording.wav" + + try: + os.makedirs(self.test_dir, exist_ok=True) + except: + pass + + def tearDown(self): + """Clean up test files.""" + try: + if os.path.exists(self.test_file): + os.remove(self.test_file) + except: + pass + + def test_adc_stream_import(self): + """Test ADCRecordStream can be imported.""" + try: + from mpos.audio.stream_record_adc import ADCRecordStream + self.assertIsNotNone(ADCRecordStream) + except ImportError as e: + self.fail(f"Failed to import ADCRecordStream: {e}") + + def test_audio_manager_has_adc_method(self): + """Test AudioManager has record_wav_adc method.""" + try: + from mpos import AudioManager + self.assertTrue(hasattr(AudioManager, 'record_wav_adc')) + except ImportError: + self.skipTest("AudioManager not available in test environment") + + +if __name__ == '__main__': + unittest.main() From 72faccc1b408f1dbe19b6a598195b1136e02a8c7 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 21:13:43 +0100 Subject: [PATCH 027/317] Fix board --- .../board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py b/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py index 9e2a6eea..77ed36d8 100644 --- a/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py +++ b/internal_filesystem/lib/mpos/board/matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660.py @@ -11,7 +11,7 @@ # - No buzzer or I2S audio from micropython import const -import st7789 +import drivers.display.st7789 as st7789 import lcd_bus import machine From 2dfcd1ed841fb32f4bc31bd27d6fec09a408b369 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 21:44:06 +0100 Subject: [PATCH 028/317] Fix st7789 --- .../lib/drivers/display/st7789/__init__.py | 8 ++++++++ internal_filesystem/lib/drivers/display/st7789/st7789.py | 9 +++++++++ 2 files changed, 17 insertions(+) create mode 100644 internal_filesystem/lib/drivers/display/st7789/__init__.py diff --git a/internal_filesystem/lib/drivers/display/st7789/__init__.py b/internal_filesystem/lib/drivers/display/st7789/__init__.py new file mode 100644 index 00000000..7c336086 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/st7789/__init__.py @@ -0,0 +1,8 @@ +import sys +from .st7789 import * +from . import _st7789_init + +# Register _st7789_init in sys.modules so __import__('_st7789_init') can find it +# This is needed because display_driver_framework.py uses __import__('_st7789_init') +# expecting a top-level module, but _st7789_init is in the st7789 package subdirectory +sys.modules['_st7789_init'] = _st7789_init diff --git a/internal_filesystem/lib/drivers/display/st7789/st7789.py b/internal_filesystem/lib/drivers/display/st7789/st7789.py index 26b9b3a5..7287d238 100644 --- a/internal_filesystem/lib/drivers/display/st7789/st7789.py +++ b/internal_filesystem/lib/drivers/display/st7789/st7789.py @@ -13,6 +13,15 @@ BYTE_ORDER_RGB = display_driver_framework.BYTE_ORDER_RGB BYTE_ORDER_BGR = display_driver_framework.BYTE_ORDER_BGR +__all__ = [ + 'ST7789', + 'STATE_HIGH', + 'STATE_LOW', + 'STATE_PWM', + 'BYTE_ORDER_RGB', + 'BYTE_ORDER_BGR', +] + _MADCTL_MV = const(0x20) # 0=Normal, 1=Row/column exchange _MADCTL_MX = const(0x40) # 0=Left to Right, 1=Right to Left _MADCTL_MY = const(0x80) # 0=Top to Bottom, 1=Bottom to Top From 821073b5be801af1da283bca04b56e385157c688 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 21:54:59 +0100 Subject: [PATCH 029/317] Fix st7789 --- .../lib/drivers/display/st7789/__init__.py | 20 ++++++++++++++++++- .../lib/drivers/display/st7789/st7789.py | 9 --------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/internal_filesystem/lib/drivers/display/st7789/__init__.py b/internal_filesystem/lib/drivers/display/st7789/__init__.py index 7c336086..0b38c11b 100644 --- a/internal_filesystem/lib/drivers/display/st7789/__init__.py +++ b/internal_filesystem/lib/drivers/display/st7789/__init__.py @@ -1,8 +1,26 @@ import sys -from .st7789 import * +from . import st7789 from . import _st7789_init # Register _st7789_init in sys.modules so __import__('_st7789_init') can find it # This is needed because display_driver_framework.py uses __import__('_st7789_init') # expecting a top-level module, but _st7789_init is in the st7789 package subdirectory sys.modules['_st7789_init'] = _st7789_init + +# Explicitly define __all__ and re-export public symbols from st7789 module +__all__ = [ + 'ST7789', + 'STATE_HIGH', + 'STATE_LOW', + 'STATE_PWM', + 'BYTE_ORDER_RGB', + 'BYTE_ORDER_BGR', +] + +# Re-export the public symbols +ST7789 = st7789.ST7789 +STATE_HIGH = st7789.STATE_HIGH +STATE_LOW = st7789.STATE_LOW +STATE_PWM = st7789.STATE_PWM +BYTE_ORDER_RGB = st7789.BYTE_ORDER_RGB +BYTE_ORDER_BGR = st7789.BYTE_ORDER_BGR diff --git a/internal_filesystem/lib/drivers/display/st7789/st7789.py b/internal_filesystem/lib/drivers/display/st7789/st7789.py index 7287d238..26b9b3a5 100644 --- a/internal_filesystem/lib/drivers/display/st7789/st7789.py +++ b/internal_filesystem/lib/drivers/display/st7789/st7789.py @@ -13,15 +13,6 @@ BYTE_ORDER_RGB = display_driver_framework.BYTE_ORDER_RGB BYTE_ORDER_BGR = display_driver_framework.BYTE_ORDER_BGR -__all__ = [ - 'ST7789', - 'STATE_HIGH', - 'STATE_LOW', - 'STATE_PWM', - 'BYTE_ORDER_RGB', - 'BYTE_ORDER_BGR', -] - _MADCTL_MV = const(0x20) # 0=Normal, 1=Row/column exchange _MADCTL_MX = const(0x40) # 0=Left to Right, 1=Right to Left _MADCTL_MY = const(0x80) # 0=Top to Bottom, 1=Bottom to Top From d2d5d64b6f627881252bb47700f9b692815d71fd Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 21:59:24 +0100 Subject: [PATCH 030/317] Fix ili9341 --- .../lib/drivers/display/ili9341/__init__.py | 28 +++++++++++++++++++ .../lib/mpos/board/m5stack_fire.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 internal_filesystem/lib/drivers/display/ili9341/__init__.py diff --git a/internal_filesystem/lib/drivers/display/ili9341/__init__.py b/internal_filesystem/lib/drivers/display/ili9341/__init__.py new file mode 100644 index 00000000..5712c270 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/ili9341/__init__.py @@ -0,0 +1,28 @@ +import sys +from . import ili9341 +from . import _ili9341_init_type1 +from . import _ili9341_init_type2 + +# Register _ili9341_init_type1 and _ili9341_init_type2 in sys.modules so __import__() can find them +# This is needed because display_driver_framework.py uses __import__('_ili9341_init_type1') and __import__('_ili9341_init_type2') +# expecting top-level modules, but they are in the ili9341 package subdirectory +sys.modules['_ili9341_init_type1'] = _ili9341_init_type1 +sys.modules['_ili9341_init_type2'] = _ili9341_init_type2 + +# Explicitly define __all__ and re-export public symbols from ili9341 module +__all__ = [ + 'ILI9341', + 'STATE_HIGH', + 'STATE_LOW', + 'STATE_PWM', + 'BYTE_ORDER_RGB', + 'BYTE_ORDER_BGR', +] + +# Re-export the public symbols +ILI9341 = ili9341.ILI9341 +STATE_HIGH = ili9341.STATE_HIGH +STATE_LOW = ili9341.STATE_LOW +STATE_PWM = ili9341.STATE_PWM +BYTE_ORDER_RGB = ili9341.BYTE_ORDER_RGB +BYTE_ORDER_BGR = ili9341.BYTE_ORDER_BGR diff --git a/internal_filesystem/lib/mpos/board/m5stack_fire.py b/internal_filesystem/lib/mpos/board/m5stack_fire.py index 2d3f0094..d1380c41 100644 --- a/internal_filesystem/lib/mpos/board/m5stack_fire.py +++ b/internal_filesystem/lib/mpos/board/m5stack_fire.py @@ -1,6 +1,6 @@ # Hardware initialization for ESP32 M5Stack-Fire board # Manufacturer's website at https://https://docs.m5stack.com/en/core/fire_v2.7 -import ili9341 +import drivers.display.ili9341 as ili9341 import lcd_bus import machine From 74b1a427ced05280c507bbc0fc03056460eafb3c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 22:47:52 +0100 Subject: [PATCH 031/317] Move odroid go detection towards the bottom --- internal_filesystem/lib/mpos/board/m5stack_fire.py | 2 ++ internal_filesystem/lib/mpos/board/odroid_go.py | 1 + internal_filesystem/lib/mpos/main.py | 12 ++++++------ scripts/build_mpos.sh | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/m5stack_fire.py b/internal_filesystem/lib/mpos/board/m5stack_fire.py index d1380c41..7da98c44 100644 --- a/internal_filesystem/lib/mpos/board/m5stack_fire.py +++ b/internal_filesystem/lib/mpos/board/m5stack_fire.py @@ -1,5 +1,7 @@ # Hardware initialization for ESP32 M5Stack-Fire board # Manufacturer's website at https://https://docs.m5stack.com/en/core/fire_v2.7 +# Original author: https://github.com/ancebfer + import drivers.display.ili9341 as ili9341 import lcd_bus import machine diff --git a/internal_filesystem/lib/mpos/board/odroid_go.py b/internal_filesystem/lib/mpos/board/odroid_go.py index 0a08cc8b..256d7b52 100644 --- a/internal_filesystem/lib/mpos/board/odroid_go.py +++ b/internal_filesystem/lib/mpos/board/odroid_go.py @@ -3,6 +3,7 @@ # Hardware initialization for Hardkernel ODROID-Go # https://github.com/hardkernel/ODROID-GO/ # https://wiki.odroid.com/odroid_go/odroid_go +# Original author: https://github.com/jedie import time diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index c6fcf192..77401bcd 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -14,12 +14,12 @@ def init_rootscreen(): width = disp.get_horizontal_resolution() height = disp.get_vertical_resolution() dpi = disp.get_dpi() - + # Initialize DisplayMetrics with actual display values DisplayMetrics.set_resolution(width, height) DisplayMetrics.set_dpi(dpi) print(f"init_rootscreen set resolution to {width}x{height} at {dpi} DPI") - + # Show logo img = lv.image(screen) img.set_src("M:builtin/res/mipmap-mdpi/MicroPythonOS-logo-white-long-w296.png") # from the MPOS-logo repo @@ -105,15 +105,15 @@ def detect_board(): if single_address_i2c_scan(i2c0, 0x68): # IMU (MPU6886) return "m5stack_fire" - print("odroid_go ?") - if check_pins(0, 13, 27, 39): - return "odroid_go" - print("fri3d_2024 ?") if i2c0 := fail_save_i2c(sda=9, scl=18): if single_address_i2c_scan(i2c0, 0x6B): # IMU (plus possibly the Communicator's LANA TNY at 0x38) return "fri3d_2024" + print("odroid_go ?") + if check_pins(0, 13, 27, 39): # this matches too much (also fri3d_2024) so move towards the end + return "odroid_go" + print("Fallback to fri3d_2026") # default: if single_address_i2c_scan(i2c0, 0x6A): # IMU but currently not installed return "fri3d_2026" diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 8edd4902..31dd573b 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -86,7 +86,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then BOARD_VARIANT=SPIRAM_OCT fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) - frozenmanifest="FROZEN_MANIFEST=$manifest" + #frozenmanifest="FROZEN_MANIFEST=$manifest" echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." pushd "$codebasedir"/lvgl_micropython/ rm -rf lib/micropython/ports/esp32/build-$BOARD-$BOARD_VARIANT From 86b08b73782a2e3189a33dee6983713db0731dca Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 13 Feb 2026 23:17:53 +0100 Subject: [PATCH 032/317] Fix frozen files --- scripts/build_mpos.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 31dd573b..13b8a770 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -86,7 +86,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then BOARD_VARIANT=SPIRAM_OCT fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) - #frozenmanifest="FROZEN_MANIFEST=$manifest" + frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." pushd "$codebasedir"/lvgl_micropython/ rm -rf lib/micropython/ports/esp32/build-$BOARD-$BOARD_VARIANT From ac892376474a1114aec6e52ad2152e854f59e0eb Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Fri, 13 Feb 2026 13:45:55 +0100 Subject: [PATCH 033/317] Enhance ShowBattery and fixes for Odroid-GO battery Add a "Real-time values" checkbox to ShowBattery app. If checked, then the cache will be clear on every cycle. Bugfix: Use `mpos.time.localtime()` to get the "correct" local time with timezone offset. Changes for Odroid-GO: The seen "2400" values are at startup the Odroid-GO... After a while the values are somewhere between 270 and 310... The full range is unknown, yet. But with the new calculation it looks more realistic, then before ;) --- .../META-INF/MANIFEST.JSON | 2 +- .../assets/show_battery.py | 31 +++++++++++++------ .../lib/mpos/board/odroid_go.py | 20 ++++++++++-- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON index f02b6283..a08ab91d 100644 --- a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON @@ -6,7 +6,7 @@ "icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/icons/com.micropythonos.showbattery_0.1.1_64x64.png", "download_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/mpks/com.micropythonos.showbattery_0.1.1.mpk", "fullname": "com.micropythonos.showbattery", -"version": "0.1.1", +"version": "0.2.0", "category": "development", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py b/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py index 95a62c79..e2bff8d5 100644 --- a/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py +++ b/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py @@ -40,8 +40,7 @@ """ import lvgl as lv -import time - +import mpos.time from mpos import Activity, BatteryManager HISTORY_LEN = 60 @@ -56,11 +55,13 @@ class ShowBattery(Activity): # Widgets lbl_time = None lbl_sec = None - lbl_date = None + lbl_text = None bat_outline = None bat_fill = None + clear_cache_checkbox = None # Add reference to checkbox + history_v = [] history_p = [] @@ -69,16 +70,21 @@ def onCreate(self): # --- TIME --- self.lbl_time = lv.label(scr) - self.lbl_time.set_style_text_font(lv.font_montserrat_48, 0) + self.lbl_time.set_style_text_font(lv.font_montserrat_40, 0) self.lbl_time.align(lv.ALIGN.TOP_LEFT, 5, 5) self.lbl_sec = lv.label(scr) self.lbl_sec.set_style_text_font(lv.font_montserrat_24, 0) self.lbl_sec.align_to(self.lbl_time, lv.ALIGN.OUT_RIGHT_BOTTOM, 24, -4) - self.lbl_date = lv.label(scr) - self.lbl_date.set_style_text_font(lv.font_montserrat_24, 0) - self.lbl_date.align(lv.ALIGN.TOP_LEFT, 5, 60) + # --- CHECKBOX --- + self.clear_cache_checkbox = lv.checkbox(scr) + self.clear_cache_checkbox.set_text("Real-time values") + self.clear_cache_checkbox.align(lv.ALIGN.TOP_LEFT, 5, 50) + + self.lbl_text = lv.label(scr) + self.lbl_text.set_style_text_font(lv.font_montserrat_16, 0) + self.lbl_text.align(lv.ALIGN.TOP_LEFT, 5, 80) # --- BATTERY ICON --- self.bat_outline = lv.obj(scr) @@ -126,19 +132,26 @@ def onResume(self, screen): super().onResume(screen) def update(timer): - now = time.localtime() + now = mpos.time.localtime() hour, minute, second = now[3], now[4], now[5] date = f"{now[0]}-{now[1]:02}-{now[2]:02}" + if self.clear_cache_checkbox.get_state() & lv.STATE.CHECKED: + # Get "real-time" values by clearing the cache before reading + BatteryManager.clear_cache() + voltage = BatteryManager.read_battery_voltage() percent = BatteryManager.get_battery_percentage() # --- TIME --- self.lbl_time.set_text(f"{hour:02}:{minute:02}") self.lbl_sec.set_text(f":{second:02}") + + # --- BATTERY VALUES --- date += f"\n{voltage:.2f}V {percent:.0f}%" - self.lbl_date.set_text(date) + date += f"\nRaw ADC: {BatteryManager.read_raw_adc()}" + self.lbl_text.set_text(date) # --- BATTERY ICON --- fill_h = int((percent / 100) * (self.bat_size * 0.9)) diff --git a/internal_filesystem/lib/mpos/board/odroid_go.py b/internal_filesystem/lib/mpos/board/odroid_go.py index 81bf92a2..4c301c44 100644 --- a/internal_filesystem/lib/mpos/board/odroid_go.py +++ b/internal_filesystem/lib/mpos/board/odroid_go.py @@ -48,7 +48,6 @@ # Misc settings: LED_BLUE = const(2) BATTERY_PIN = const(36) -BATTERY_RESISTANCE_NUM = const(2) SPEAKER_ENABLE_PIN = const(25) SPEAKER_PIN = const(26) @@ -105,8 +104,22 @@ from mpos import BatteryManager -def adc_to_voltage(adc_value): - return adc_value * BATTERY_RESISTANCE_NUM +def adc_to_voltage(raw_adc_value): + """ + The percentage calculation uses MIN_VOLTAGE = 3.15 and MAX_VOLTAGE = 4.15 + 0% at 3.15V -> raw_adc_value = 270 + 100% at 4.15V -> raw_adc_value = 310 + + 4.15 - 3.15 = 1V + 310 - 270 = 40 raw ADC steps + + So each raw ADC step is 1V / 40 = 0.025V + Offset calculation: + 270 * 0.025 = 6.75V. but we want it to be 3.15V + So the offset is 3.15V - 6.75V = -3.6V + """ + voltage = raw_adc_value * 0.025 - 3.6 + return voltage BatteryManager.init_adc(BATTERY_PIN, adc_to_voltage) @@ -183,6 +196,7 @@ def input_callback(indev, data): current_key = lv.KEY.ESC elif button_volume.value() == 0: print("Volume button pressed -> reset") + blue_led.on() machine.reset() elif button_select.value() == 0: current_key = lv.KEY.BACKSPACE From 9db8287328b42812fc76ff63bb08f8b6cdb2121d Mon Sep 17 00:00:00 2001 From: Pavel Machek <8401486+pavelmachek@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:14:58 +0100 Subject: [PATCH 034/317] Add simple calendar application (#38) * cal: initial version * cal: hacks to get more functionality working * cal: Disable file output for now * cal: Got button mapping to work * cal: tweak power and size * cal: Tweak layouts * cal: got events to display * cal: single day addition now works * cal: got file i/o to work * cal: Layout tweaks * cal: Tweak add dialog * calendar/columns: start separate apps for them * calendar: open keyboard * calendar: make keyboard fit on small screen * calendar: better metadata for calendar * calendar: more metadata tweaks * calendar: revert hello tweaks * calendar: revert columns changes. * calendar: remove manifest from columns * calendar: attempt to fix MANIFEST --- .../META-INF/MANIFEST.JSON | 24 + .../apps/cz.ucw.pavel.calendar/assets/main.py | 560 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 7294 bytes 3 files changed, 584 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.calendar/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..b2f079cc --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Calendar", +"publisher": "micropythonos", +"short_description": "Calendar", +"long_description": "Simple calendar app.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/icons/cz.ucw.pavel.columns_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/mpks/cz.ucw.pavel.columns_0.0.1.mpk", +"fullname": "cz.ucw.pavel.columns", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "utilities" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py new file mode 100644 index 00000000..54708487 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py @@ -0,0 +1,560 @@ +from mpos import Activity + +""" + +Create simple calendar application. On main screen, it should have +current time, date, and month overview. Current date and dates with +events should be highlighted. There should be list of upcoming events. + +When date is clicked, dialog with adding event for that date should be +displayed. Multi-day events should be supported. + +Data should be read/written to emacs org compatible text file. + + +""" + +import time +import os + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard + + +ORG_FILE = f"data/calendar.org" # adjust for your device +MAX_UPCOMING = 8 + + +# ------------------------------------------------------------ +# Small date helpers (no datetime module assumed) +# ------------------------------------------------------------ + +def is_leap_year(y): + return (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0) + + +def days_in_month(y, m): + if m == 2: + return 29 if is_leap_year(y) else 28 + if m in (1, 3, 5, 7, 8, 10, 12): + return 31 + return 30 + + +def ymd_to_int(y, m, d): + return y * 10000 + m * 100 + d + + +def int_to_ymd(v): + y = v // 10000 + m = (v // 100) % 100 + d = v % 100 + return y, m, d + + +def weekday_name(idx): + # MicroPython localtime(): 0=Mon..6=Sun typically + names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + if 0 <= idx < 7: + return names[idx] + return "???" + + +def first_weekday_of_month(y, m): + # brute-force using time.mktime if available + # Some ports support it, some don't. + # If it fails, we fallback to "Monday". + try: + # localtime tuple: (y,m,d,h,mi,s,wd,yd) + t = time.mktime((y, m, 1, 0, 0, 0, 0, 0)) + wd = time.localtime(t)[6] + return wd + except Exception: + return 0 + + +# ------------------------------------------------------------ +# Org event model + parser/writer +# ------------------------------------------------------------ + +class Event: + def __init__(self, title, start_ymd, end_ymd, start_time=None, end_time=None): + self.title = title + self.start = start_ymd # int yyyymmdd + self.end = end_ymd # int yyyymmdd + self.start_time = start_time # "HH:MM" or None + self.end_time = end_time # "HH:MM" or None + + def is_multi_day(self): + return self.end != self.start + + def occurs_on(self, ymd): + return self.start <= ymd <= self.end + + def start_key(self): + return self.start + + +class OrgCalendarStore: + def __init__(self, path): + self.path = path + + def load(self): + if not self._exists(self.path): + return [] + + try: + with open(self.path, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + except Exception: + # fallback without encoding kw if unsupported + with open(self.path, "r") as f: + lines = f.read().splitlines() + + events = [] + current_title = None + # FIXME this likely does not work + + for line in lines: + line = line.strip() + + if line.startswith("** "): + current_title = line[3:].strip() + continue + + if not line.startswith("<"): + continue + + ev = self._parse_timestamp_line(current_title, line) + if ev: + events.append(ev) + + events.sort(key=lambda e: e.start_key()) + return events + + def save_append(self, event): + # Create file if missing + if not self._exists(self.path): + self._write_text("* Events\n") + + # Append event + out = [] + out.append("** " + event.title) + + if event.start == event.end: + y, m, d = int_to_ymd(event.start) + wd = weekday_name(self._weekday_for_ymd(y, m, d)) + if event.start_time and event.end_time: + out.append("<%04d-%02d-%02d %s %s-%s>" % ( + y, m, d, wd, event.start_time, event.end_time + )) + else: + out.append("<%04d-%02d-%02d %s>" % (y, m, d, wd)) + else: + y1, m1, d1 = int_to_ymd(event.start) + y2, m2, d2 = int_to_ymd(event.end) + wd1 = weekday_name(self._weekday_for_ymd(y1, m1, d1)) + wd2 = weekday_name(self._weekday_for_ymd(y2, m2, d2)) + out.append("<%04d-%02d-%02d %s>--<%04d-%02d-%02d %s>" % ( + y1, m1, d1, wd1, + y2, m2, d2, wd2 + )) + + out.append("") # blank line + self._append_text("\n".join(out) + "\n") + + # -------------------- + + def _parse_timestamp_line(self, title, line): + if not title: + return None + + # Single-day: <2026-02-05 Thu> + # With time: <2026-02-05 Thu 10:00-11:00> + # Range: <2026-02-10 Tue>--<2026-02-14 Sat> + + if "--<" in line: + a, b = line.split("--", 1) + s = self._parse_one_timestamp(a) + e = self._parse_one_timestamp(b) + if not s or not e: + return None + return Event(title, s["ymd"], e["ymd"], None, None) + + s = self._parse_one_timestamp(line) + if not s: + return None + + return Event(title, s["ymd"], s["ymd"], s.get("start_time"), s.get("end_time")) + + def _parse_one_timestamp(self, token): + token = token.strip() + if not (token.startswith("<") and token.endswith(">")): + return None + + inner = token[1:-1].strip() + parts = inner.split() + + # Expect YYYY-MM-DD ... + if len(parts) < 2: + return None + + date_s = parts[0] + try: + y = int(date_s[0:4]) + m = int(date_s[5:7]) + d = int(date_s[8:10]) + except Exception: + return None + + ymd = ymd_to_int(y, m, d) + + # Optional time part like 10:00-11:00 + start_time = None + end_time = None + if len(parts) >= 3 and "-" in parts[2]: + t = parts[2] + if len(t) == 11 and t[2] == ":" and t[5] == "-" and t[8] == ":": + start_time = t[0:5] + end_time = t[6:11] + + return { + "ymd": ymd, + "start_time": start_time, + "end_time": end_time + } + + def _exists(self, path): + try: + os.stat(path) + return True + except Exception: + return False + + def _append_text(self, s): + with open(self.path, "a") as f: + f.write(s) + + def _write_text(self, s): + with open(self.path, "w") as f: + f.write(s) + + def _weekday_for_ymd(self, y, m, d): + try: + t = time.mktime((y, m, d, 0, 0, 0, 0, 0)) + return time.localtime(t)[6] + except Exception: + return 0 + + +# ------------------------------------------------------------ +# Calendar Activity +# ------------------------------------------------------------ + +class Main(Activity): + + def __init__(self): + super().__init__() + + self.store = OrgCalendarStore(ORG_FILE) + self.events = [] + + self.timer = None + + # UI + self.screen = None + self.lbl_time = None + self.lbl_date = None + self.lbl_month = None + + self.grid = None + self.day_buttons = [] + + self.upcoming_list = None + + # Current month shown + self.cur_y = 0 + self.cur_m = 0 + self.today_ymd = 0 + + # -------------------- + + def onCreate(self): + self.screen = lv.obj() + #self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # Top labels + self.lbl_time = lv.label(self.screen) + self.lbl_time.set_style_text_font(lv.font_montserrat_20, 0) + self.lbl_time.align(lv.ALIGN.TOP_LEFT, 6, 4) + + self.lbl_date = lv.label(self.screen) + self.lbl_date.align(lv.ALIGN.TOP_LEFT, 6, 40) + + self.lbl_month = lv.label(self.screen) + self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 10) + + # Upcoming events list + self.upcoming_list = lv.list(self.screen) + self.upcoming_list.set_size(lv.pct(90), 60) + self.upcoming_list.align_to(self.lbl_date, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + + # Month grid container + self.grid = lv.obj(self.screen) + self.grid.set_size(lv.pct(90), 60) + self.grid.set_style_border_width(1, 0) + self.grid.set_style_pad_all(0, 0) + self.grid.set_style_radius(6, 0) + self.grid.align_to(self.upcoming_list, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + + self.setContentView(self.screen) + + self.reload_data() + print("My events == ", self.events) + self.build_month_view() + self.refresh_upcoming() + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 30000, None) + self.tick(0) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # -------------------- + + def reload_data(self): + print("Loading...") + self.events = self.store.load() + # FIXME + #self.events = [ Event("Test event", 20260207, 20260208) ] + + def tick(self, t): + now = time.localtime() + y, m, d = now[0], now[1], now[2] + hh, mm, ss = now[3], now[4], now[5] + wd = weekday_name(now[6]) + + self.today_ymd = ymd_to_int(y, m, d) + + self.lbl_time.set_text("%02d:%02d" % (hh, mm)) + self.lbl_date.set_text("%04d-%02d-%02d %s" % (y, m, d, wd)) + + # Month label + self.lbl_month.set_text("%04d-%02d" % (self.cur_y, self.cur_m)) + + # Re-highlight today (cheap) + self.update_day_highlights() + + # -------------------- + + def build_month_view(self): + now = time.localtime() + self.cur_y, self.cur_m = now[0], now[1] + + # Determine size + d = lv.display_get_default() + w = d.get_horizontal_resolution() + + cell = w // 8 + grid_w = cell * 7 + 8 + grid_h = cell * 6 + 8 + + self.grid.set_size(grid_w, grid_h) + + # Clear old buttons + for b in self.day_buttons: + b.delete() + self.day_buttons = [] + self.day_of_btn = {} + + first_wd = first_weekday_of_month(self.cur_y, self.cur_m) # 0=Mon + dim = days_in_month(self.cur_y, self.cur_m) + + # LVGL grid is easiest as absolute positioning here + for day in range(1, dim + 1): + idx = (first_wd + (day - 1)) + row = idx // 7 + col = idx % 7 + + btn = lv.button(self.grid) + btn.set_size(cell - 2, cell - 2) + btn.set_pos(4 + col * cell, 4 + row * cell) + btn.add_event_cb(lambda e, dd=day: self.on_day_clicked(dd), lv.EVENT.CLICKED, None) + + lbl = lv.label(btn) + lbl.set_text(str(day)) + lbl.center() + + self.day_buttons.append(btn) + self.day_of_btn[btn] = day + + self.update_day_highlights() + + def update_day_highlights(self): + for btn in self.day_buttons: + day = self.day_of_btn.get(btn, None) + if day is None: + continue + + ymd = ymd_to_int(self.cur_y, self.cur_m, day) + + has_event = self.day_has_event(ymd) + is_today = (ymd == self.today_ymd) + #print(ymd, has_event, is_today) + + if is_today: + btn.set_style_bg_color(lv.palette_main(lv.PALETTE.BLUE), 0) + elif has_event: + btn.set_style_bg_color(lv.palette_main(lv.PALETTE.GREEN), 0) + else: + btn.set_style_bg_color(lv.palette_main(lv.PALETTE.GREY), 0) + + def day_has_event(self, ymd): + for e in self.events: + if e.occurs_on(ymd): + return True + return False + + # -------------------- + + def refresh_upcoming(self): + self.upcoming_list.clean() + + now = time.localtime() + today = ymd_to_int(now[0], now[1], now[2]) + + upcoming = [] + for e in self.events: + if e.end >= today: + upcoming.append(e) + + upcoming.sort(key=lambda e: e.start) + + for e in upcoming[:MAX_UPCOMING]: + y1, m1, d1 = int_to_ymd(e.start) + y2, m2, d2 = int_to_ymd(e.end) + + if e.start == e.end: + date_s = "%04d-%02d-%02d" % (y1, m1, d1) + else: + date_s = "%04d-%02d-%02d..%04d-%02d-%02d" % (y1, m1, d1, y2, m2, d2) + + txt = date_s + " " + e.title + self.upcoming_list.add_text(txt) + + self.upcoming_list.add_text("that's all folks") + + # -------------------- + + def on_day_clicked(self, day): + print("Day clicked") + ymd = ymd_to_int(self.cur_y, self.cur_m, day) + self.open_add_dialog(ymd) + + def open_add_dialog(self, ymd): + y, m, d = int_to_ymd(ymd) + + dlg = lv.obj(self.screen) + dlg.set_size(lv.pct(100), 480) + dlg.center() + dlg.set_style_bg_color(lv.color_hex(0x8f8f8f), 0) + dlg.set_style_border_width(2, 0) + dlg.set_style_radius(10, 0) + + title = lv.label(dlg) + title.set_text("Add event") + title.align(lv.ALIGN.TOP_MID, 0, 8) + + date_lbl = lv.label(dlg) + date_lbl.set_text("%04d-%02d-%02d" % (y, m, d)) + date_lbl.align_to(title, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + # Title input + ti = lv.textarea(dlg) + ti.set_size(220, 32) + ti.align_to(date_lbl, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + ti.set_placeholder_text("Title") + keyboard = MposKeyboard(dlg) + keyboard.set_textarea(ti) + #keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # End date offset (days) + end_lbl = lv.label(dlg) + end_lbl.set_text("Duration days:") + end_lbl.align_to(ti, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + dd = lv.dropdown(dlg) + dd.set_options("1\n2\n3\n4\n5\n6\n7\n10\n14\n21\n30") + dd.set_selected(0) + dd.set_size(70, 32) + dd.align_to(end_lbl, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + # Buttons + btn_cancel = lv.button(dlg) + btn_cancel.set_size(90, 30) + btn_cancel.align(lv.ALIGN.TOP_LEFT, 12, 10) + btn_cancel.add_event_cb(lambda e: dlg.delete(), lv.EVENT.CLICKED, None) + lc = lv.label(btn_cancel) + lc.set_text("Cancel") + lc.center() + + btn_add = lv.button(dlg) + btn_add.set_size(90, 30) + btn_add.align(lv.ALIGN.TOP_RIGHT, -12, 10) + + def do_add(e): + title_s = ti.get_text() + if not title_s or title_s.strip() == "": + return + + dur_s = 1 # dd.get_selected_str() FIXME + try: + dur = int(dur_s) + except Exception: + dur = 1 + + end_ymd = self.add_days(ymd, dur - 1) + + ev = Event(title_s.strip(), ymd, end_ymd, None, None) + self.events.append(ev) + self.store.save_append(ev) # FIXME + + # Reload + refresh UI + # FIXME: common code? + #self.reload_data() + self.update_day_highlights() + self.refresh_upcoming() + + dlg.delete() + + btn_add.add_event_cb(do_add, lv.EVENT.CLICKED, None) + la = lv.label(btn_add) + la.set_text("Add") + la.center() + + # -------------------- + + def add_days(self, ymd, days): + # simple date add (forward only), no datetime dependency + y, m, d = int_to_ymd(ymd) + + while days > 0: + d += 1 + dim = days_in_month(y, m) + if d > dim: + d = 1 + m += 1 + if m > 12: + m = 1 + y += 1 + days -= 1 + + return ymd_to_int(y, m, d) + diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.calendar/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..ee72f838043fae284c9d0836dd11e5b23d7a1bc1 GIT binary patch literal 7294 zcmeHMc{tQ-`=7C|Wi3L@8Kh!XgE1NVzGo+$%<>&3W-$xHaY|*$7TPExDMclfts|wC zii9kONJXefg-YqX-=S0bz2|q{-?^^e`(LhW%=dYo&;7aY=kwh6^E}^cQas%q5VFNHgK&L?=;HDbgUm8ey7^w9_+N`&d28+k-8xU3CB*|wwo)wA~~0sc|qy+ zg&SGcJ(I6*>*Zlm`euhdgd1CI8)z7nU_~J|pjUx4|>oa-x2h%*DNQ)+%dOm%4hR z?jmAPDNdbx(EpmM^uR`FOyAUv9WmHt6>?AA41bsF7zuDo+%*uTuXg54S4-9Cjny0b z?Hg{t0FqR5g1L%nehn`wws-_Ko0+scEla`Is(S{B((E^FpBnK9MyY2MAz97KJT_V( zPq|BBR7~f@RUw$CliCFm=H;2IZgxVg3TV4M(JbKHnuw@JUKSkXFiGoMwd5AZKJ^A$ zeBMv#k(ME{x6Vn^l*4bT^t~uZ1Mr+Dn!%IXZKRZNAArawiMqQS_vx7aHmcpIplhGH z_8E1r4QEJY&#_#uJL<_a_Pw6NR$1J0H&r*+Cct$M_;3FH~ zkFK?>Y|6HNrr9WALKJf~s;V|XOePbS+TEDV`uS5*SXk2QBTiZrSXiT< zJ~`{KRMc-(x<~r5cHL;&^lG}Usd{|30@bnaEwujbV2{TuEPke=PHy+y6jF3Tk>4-P z-%It`s1V}3vV)owHy{J)h}i=XvpFe`J0lh;o@jRASX-qp^;41M%Dt;H%Tn(cr*RlV zgBHXV-FwHG)s;>PBAMGyi}8cF+~Xlvx>`=09{a69Yjyms{-nqaJ$Gx%@|e&4B>D8Q zwBqwY?u-oP?UUD$<1pupEG^Hzy{SQM^BtOR3ojmZ1c~9!v;(#i+#u}n}1ebhvYsbS{f5Uy{Zp%z<7M{XF-?vdiT%0v?oS4p zo~e!9xuab1D!fwm(DNy+kg@{7tr=bUGW8tA6Vjx@j$JX@8)w4}CzQ5M#T|t1j%Yig zq0_#;T|(96u~f;UB;=gA%VwLL%Q?r->JA;89wJ!CtXc4YalteL}Q)!HvG@i*9@8*h+7NRS7F;Wk{@OiqJyfI?{^uFFu%S*sn54o z;P-|o2pv1Bp=#?#@QnE|F{7baUo%&oS36)rbJ$qI7#qm#uK_YQ9h{kHrHt1+-IkLb zrHyF2VimKafKfD91A#yj=r%TPFPNm5Dq-qc!eerX`YI&#BwBo@a_GBZu^Xkn&e@TBUg-_5Yt6=V_NUtaF_E@K> zE#3Wba$?}=gcc09&ef=HHN^Kqx(^&lJAaBis48MT+K4A(4W~_=oUd`?`mZ;3*R5%N zWBzVLYWU4+(Kz^PgAVyL!A?PJN4x1O<>Wx8dy0k1nd&^NCl2NM$SaXZg27~SMYem3 za&pQ|vZTINvvZQOnVshC(iOw9>N(u}8vLpKE5iHtUP0$}`yIIG&Q+5(AWJLRt~pth zE@NOz?{vguADydB-q!TmWyq_^S3q4o{)jo z(Y*5w4T$@7&t|%M8^=VmO3L?`G+g}&9dzs^KQsCrZ)WDzLBDMl)%KigZ*En3Lm*4j z=-|a7z|ECFVKI;-DvJyt1q?QL@qj?gECg&4B^=M~f zFs7!aC^Qy@#Uelif*Zr+kpu`PS6fK2z+nq;DI7YRM`tl%LQWEy#pe;>aBv*ig^STmjSrop|sgo@cE{vBzWl`u0qHk-VvMN>9Muqu zFr}E#5L9EZ(}+r;;mO8}C_FfHuo6fSi=z@!Q9&vkhDI}@qG<@S3EB{WBT)bZiENBP zP|zkAL#h#Ail>m~sf6o5u<~>w!m&v7w;s<35|75>FoHsC{N0Ny;3P&AB* z2?mYE;Lt`!cr4Bs`wip=aJXQS3pp`pBz9qhN+H;Tj3hAG=nPUQfMPR4=S_smLI5WN z3QH2!2FN_$56*>P!vRP<7RQIhiXg&;vBHFu^Cbl{`%)|fR~BX7a2^a$h4uEO;;c!b zsQIQD>hHk+#^fEwie~=bc)mfuvRH9=(JW552gifF1)%W$p69Q?UzxnXy^hP{#5nzj zN&OF;*+RNHg0?J9%p(8ZK;%MeVMB_b&r1b^&2It(5@kVtE-4D2&MyI|B#~%T3<*FOk#SfA4o@~jkTGZ+0>B#M@nkXu99zh=uk2hFjTcSg z09K)34uIO@DB*_s=k&~kTaKF>VX**a z!YzpaUNS=gZ$6tHK?gYB3-f29{4cmg_Ft0nKbbFvEm+&I*fC(yhVeY2nSbm4FMtaS zu5=2(?`Sr(l;V9X1B;CTifkEpNb0GYuZV^~#5A02t$e_r1oGT= zY1*kJ6`qa0k5OkE%e^dLf)D33S=9^JOO;Xza0NGZF1M00mDwSws3Ip5JE;H{C{F9V z9>iQcq$#4PlpUsE7wNyNwuxL*A-&fPTbNwhc66*@qO`PNY@&bpp|bMwM>VG|7e3$8 zGn|!B85$oQFWS)BU=#w0fcE)ame(JUG(PiDFc5e|oaY(`4S+JCIeI@0+6+#M5NhbX zK@g_s9}p;eDOh@ovrTj+P&9QZddys2aZ+XSSHD}0kTYYdCGv!dC#+6EX-!M} zN|y#vqnzmE^HN!dlq(AC8oZncWepi3&-Gs= z=T)9lgtq#H$hohXvQhzB4h?qPPW77eB{xUxvkcTzVpuOf zxcB19AQBCcPcKjjHV_mvbWcJ?*=K1O{M3Q{oyMnY zC_2~@83fx+^CGJywa%S5?b|KevGb3;;p@%lwy1-jSBkGZUv^RUllUdcxcuW1Z`B`+ z&zvwkm!R3Cnk|bFS#mRY#x?Zh^U+$BL+hb%j{eqc95(NyBo5>0o(C;3Th=Qc$0vzeSoiMU&ncRk|RdOm#l_sHY< zclD~=)un40X(EFULeM>aogUab3_|7b>FSPzBbkUdlm=7fZGpb6BL>~kVW-CO;!DRN zzV%!B3kr{n@{^y%0>L8XhcB;p@u*1u2-R2u5XxOj)5RVh)ZwbwHJ(r`0dzv(>J_!M z@A<bcao$Hd~kC!Rk zpB$2@DRZ{$-m4!RX!PFuUS;e(h%Zqzj;^~&)IIi%C`Wtz`VEwhrh(?x?QdiiV^qRQ zpX)Zh>05gN)_t+@)+I2~InteV>-5EbZ7%4fzc@MjA^sUtFC?Z!?BsL%+frfRFGGha zT+Dsd#p-iAs~HS(dKAZAryu&cLM|V1_hE)XcMYp)Q=uf3UiL&{Y=jfKT`^_milcGL z#*$12wSpyi)QF4NwVlbX+M>@b8+@!MHGdhDO_EHHZVo!#zL_j~_Jz(fLFMTRt-1*l zlKxMu&?FQ~OHeB%5*NU{EB$!1Vrqx<`1OwZfZRm$Niox{`|>5B=JzicTie?bMa-52 l0|GpucW=lqJvPWsvPu0{{q0OrcM3NaCp&lBa_f*C{{q{ZX;T0I literal 0 HcmV?d00001 From ac46bdce3d1902706afff510896b49542d2d5374 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 14 Feb 2026 21:20:06 +0100 Subject: [PATCH 035/317] Tweak manifest --- .../apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON index b2f079cc..7067cde4 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON @@ -1,11 +1,11 @@ { "name": "Calendar", -"publisher": "micropythonos", +"publisher": "Pavel Machek", "short_description": "Calendar", "long_description": "Simple calendar app.", -"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/icons/cz.ucw.pavel.columns_0.0.1_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/mpks/cz.ucw.pavel.columns_0.0.1.mpk", -"fullname": "cz.ucw.pavel.columns", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.calendar/icons/cz.ucw.pavel.calendar_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.calendar/mpks/cz.ucw.pavel.calendar_0.0.1.mpk", +"fullname": "cz.ucw.pavel.calendar", "version": "0.0.1", "category": "utilities", "activities": [ @@ -15,7 +15,7 @@ "intent_filters": [ { "action": "main", - "category": "utilities" + "category": "launcher" } ] } From c8ffb9d752d9259a770b65525231306feb6fea30 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 14 Feb 2026 21:22:23 +0100 Subject: [PATCH 036/317] Exclude cz.ucw.pavel.calendar from bundling --- 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 3e28e0a0..3ac39b76 100755 --- a/scripts/bundle_apps.sh +++ b/scripts/bundle_apps.sh @@ -20,7 +20,8 @@ rm "$outputjson" # com.micropythonos.errortest is an intentional bad app for testing (caught by tests/test_graphical_launch_all_apps.py) # com.micropythonos.doom_launcher isn't ready because the firmware doesn't have doom built-in yet # com.micropythonos.nostr isn't ready for release yet -blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.errortest com.micropythonos.doom_launcher com.micropythonos.nostr" +# cz.ucw.pavel.calendar isn't ready for release yet +blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.errortest com.micropythonos.doom_launcher com.micropythonos.nostr cz.ucw.pavel.calendar" echo "[" | tee -a "$outputjson" From 4ab4e31de17483d11ef1bd7a42ed09001f9b4598 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 15 Feb 2026 14:37:24 +0100 Subject: [PATCH 037/317] Fix board detect --- internal_filesystem/lib/mpos/main.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 77401bcd..dc4270d0 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -110,13 +110,14 @@ def detect_board(): if single_address_i2c_scan(i2c0, 0x6B): # IMU (plus possibly the Communicator's LANA TNY at 0x38) return "fri3d_2024" - print("odroid_go ?") - if check_pins(0, 13, 27, 39): # this matches too much (also fri3d_2024) so move towards the end - return "odroid_go" + import machine + if machine.unique_id()[0] == 0xdc: # prototype board had: dc:b4:d9:0b:7d:80 + # or: if single_address_i2c_scan(i2c0, 0x6A): # IMU currently not installed on prototype board + return "fri3d_2026" - print("Fallback to fri3d_2026") - # default: if single_address_i2c_scan(i2c0, 0x6A): # IMU but currently not installed - return "fri3d_2026" + print("odroid_go ?") + #if check_pins(0, 13, 27, 39): # not good because it matches other boards (like fri3d_2024 and fri3d_2026) + return "odroid_go" # EXECUTION STARTS HERE From d256c543941df57287692554008f7c49568e207d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 15 Feb 2026 15:41:15 +0100 Subject: [PATCH 038/317] Improve adc_mic --- c_mpos/src/adc_mic.c | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/c_mpos/src/adc_mic.c b/c_mpos/src/adc_mic.c index 8a93ad60..580d8537 100644 --- a/c_mpos/src/adc_mic.c +++ b/c_mpos/src/adc_mic.c @@ -25,11 +25,12 @@ static mp_obj_t adc_mic_read(void) { .adc_channel_list = ((uint8_t[]){ADC_CHANNEL_0}), .adc_channel_num = 1, .sample_rate_hz = 16000, - //.atten = ADC_ATTEN_DB_2_5, - .atten = ADC_ATTEN_DB_11, + //.atten = ADC_ATTEN_DB_0, // values always 16380 + //.atten = ADC_ATTEN_DB_2_5, // values always 16380 + .atten = ADC_ATTEN_DB_6, // values around 12500 +/- 320 (silence) or 4000 (loud talk) + //.atten = ADC_ATTEN_DB_11, // values around -1130 +/- 160 (silence) }; - ADC_MIC_DEBUG_PRINT("Config created for channel %d, sample rate %d, atten %d\n", - ADC_CHANNEL_0, 16000, cfg.atten); + ADC_MIC_DEBUG_PRINT("Config created for channel %d, sample rate %d, atten %d\n", ADC_CHANNEL_0, 16000, cfg.atten); // ──────────────────────────────────────────────── // Initialization (same as before) @@ -65,10 +66,10 @@ static mp_obj_t adc_mic_read(void) { // ──────────────────────────────────────────────── // Small reusable buffer + tracking variables // ──────────────────────────────────────────────── - const size_t chunk_samples = 512; + const size_t chunk_samples = 10240; const size_t buf_size = chunk_samples * sizeof(int16_t); - //int16_t *audio_buffer = heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); - int16_t *audio_buffer = heap_caps_malloc_prefer(buf_size, MALLOC_CAP_DEFAULT | MALLOC_CAP_SPIRAM, MALLOC_CAP_DEFAULT); + int16_t *audio_buffer = heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + //int16_t *audio_buffer = heap_caps_thread.start_new_thread(testit, ())_malloc_prefer(buf_size, MALLOC_CAP_DEFAULT | MALLOC_CAP_SPIRAM, MALLOC_CAP_DEFAULT); if (audio_buffer == NULL) { esp_codec_dev_close(dev); esp_codec_dev_delete(dev); @@ -77,7 +78,7 @@ static mp_obj_t adc_mic_read(void) { } // How many chunks to read (adjust as needed) - const int N = 1; // e.g. 50 × 512 = ~1.5 seconds @ 16 kHz + const int N = 5; // e.g. 5 × 10240 = ~3.2 seconds @ 16 kHz int16_t global_min = 32767; int16_t global_max = -32768; @@ -111,7 +112,8 @@ static mp_obj_t adc_mic_read(void) { if (chunk < 3) { ADC_MIC_DEBUG_PRINT("Chunk %d first 16 samples:\n", chunk); for (size_t i = 0; i < 16; i++) { - ADC_MIC_DEBUG_PRINT("%6d ", audio_buffer[i]); + int16_t sample = audio_buffer[i]; + ADC_MIC_DEBUG_PRINT("%6d (0x%04X)", sample, (uint16_t)sample); if ((i + 1) % 8 == 0) ADC_MIC_DEBUG_PRINT("\n"); } ADC_MIC_DEBUG_PRINT("\n"); From 1b82fb2d09d6ab08b37ab4269b1e26f84395f516 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 16 Feb 2026 14:20:12 +0100 Subject: [PATCH 039/317] adc_mic: add nr of samples argument --- c_mpos/src/adc_mic.c | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/c_mpos/src/adc_mic.c b/c_mpos/src/adc_mic.c index 580d8537..fecf91c2 100644 --- a/c_mpos/src/adc_mic.c +++ b/c_mpos/src/adc_mic.c @@ -12,7 +12,10 @@ #define ADC_MIC_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) -static mp_obj_t adc_mic_read(void) { +static mp_obj_t adc_mic_read(mp_obj_t chunk_samples_obj) { + // Extract chunk_samples from argument + size_t chunk_samples = mp_obj_get_int(chunk_samples_obj); + ADC_MIC_DEBUG_PRINT("Starting adc_mic_read...\n"); ADC_MIC_DEBUG_PRINT("CONFIG_ADC_MIC_TASK_CORE: %d\n", CONFIG_ADC_MIC_TASK_CORE); @@ -66,7 +69,6 @@ static mp_obj_t adc_mic_read(void) { // ──────────────────────────────────────────────── // Small reusable buffer + tracking variables // ──────────────────────────────────────────────── - const size_t chunk_samples = 10240; const size_t buf_size = chunk_samples * sizeof(int16_t); int16_t *audio_buffer = heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); //int16_t *audio_buffer = heap_caps_thread.start_new_thread(testit, ())_malloc_prefer(buf_size, MALLOC_CAP_DEFAULT | MALLOC_CAP_SPIRAM, MALLOC_CAP_DEFAULT); @@ -78,7 +80,8 @@ static mp_obj_t adc_mic_read(void) { } // How many chunks to read (adjust as needed) - const int N = 5; // e.g. 5 × 10240 = ~3.2 seconds @ 16 kHz + const int N = 1; // e.g. 5 × 10240 = ~3.2 seconds @ 16 kHz + const int chunks_to_print = 0; int16_t global_min = 32767; int16_t global_max = -32768; @@ -109,7 +112,7 @@ static mp_obj_t adc_mic_read(void) { } // Optional: print first few chunks for debug (comment out after testing) - if (chunk < 3) { + if (chunk < chunks_to_print) { ADC_MIC_DEBUG_PRINT("Chunk %d first 16 samples:\n", chunk); for (size_t i = 0; i < 16; i++) { int16_t sample = audio_buffer[i]; @@ -143,7 +146,7 @@ static mp_obj_t adc_mic_read(void) { return last_buf_obj ? last_buf_obj : mp_obj_new_bytes(NULL, 0); } -MP_DEFINE_CONST_FUN_OBJ_0(adc_mic_read_obj, adc_mic_read); +MP_DEFINE_CONST_FUN_OBJ_1(adc_mic_read_obj, adc_mic_read); static const mp_rom_map_elem_t adc_mic_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_adc_mic) }, From cdb2651e9104cc81c5a9a546cb468060afdd6057 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 16 Feb 2026 16:12:06 +0100 Subject: [PATCH 040/317] Work on adc_mic --- c_mpos/micropython.cmake | 10 +++--- c_mpos/src/adc_mic.c | 66 ++++++++++++++++++++++++++-------------- scripts/build_mpos.sh | 13 ++++++-- 3 files changed, 60 insertions(+), 29 deletions(-) diff --git a/c_mpos/micropython.cmake b/c_mpos/micropython.cmake index d442f0c3..9669288a 100644 --- a/c_mpos/micropython.cmake +++ b/c_mpos/micropython.cmake @@ -6,13 +6,13 @@ add_library(usermod_c_mpos INTERFACE) set(MPOS_C_INCLUDES) -#set(MPOS_C_INCLUDES -# ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/include/ -# ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/interface/ -#) +set(MPOS_C_INCLUDES + ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/include/ + ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__esp_codec_dev/interface/ +) set(MPOS_C_SOURCES -# ${CMAKE_CURRENT_LIST_DIR}/src/adc_mic.c + ${CMAKE_CURRENT_LIST_DIR}/src/adc_mic.c ${CMAKE_CURRENT_LIST_DIR}/src/quirc_decode.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/identify.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/version_db.c diff --git a/c_mpos/src/adc_mic.c b/c_mpos/src/adc_mic.c index fecf91c2..22882efc 100644 --- a/c_mpos/src/adc_mic.c +++ b/c_mpos/src/adc_mic.c @@ -12,28 +12,54 @@ #define ADC_MIC_DEBUG_PRINT(...) mp_printf(&mp_plat_print, __VA_ARGS__) -static mp_obj_t adc_mic_read(mp_obj_t chunk_samples_obj) { - // Extract chunk_samples from argument - size_t chunk_samples = mp_obj_get_int(chunk_samples_obj); +static mp_obj_t adc_mic_read(size_t n_args, const mp_obj_t *args) { + // Extract arguments + // args[0]: chunk_samples + // args[1]: unit_id + // args[2]: adc_channel_list + // args[3]: adc_channel_num + // args[4]: sample_rate_hz + // args[5]: atten + + size_t chunk_samples = mp_obj_get_int(args[0]); + int unit_id = mp_obj_get_int(args[1]); + mp_obj_t channel_list_obj = args[2]; + size_t channel_list_len; + mp_obj_t *channel_list_items; + mp_obj_get_array(channel_list_obj, &channel_list_len, &channel_list_items); + + int adc_channel_num = mp_obj_get_int(args[3]); + int sample_rate_hz = mp_obj_get_int(args[4]); + int atten = mp_obj_get_int(args[5]); + ADC_MIC_DEBUG_PRINT("Starting adc_mic_read...\n"); ADC_MIC_DEBUG_PRINT("CONFIG_ADC_MIC_TASK_CORE: %d\n", CONFIG_ADC_MIC_TASK_CORE); - // Configuration (your current manual setup with 2.5 dB atten) + if (adc_channel_num > 10) { + mp_raise_ValueError("Too many channels (max 10)"); + } + if (channel_list_len < adc_channel_num) { + mp_raise_ValueError("adc_channel_list shorter than adc_channel_num"); + } + + uint8_t channels[10]; + for (size_t i = 0; i < adc_channel_num; i++) { + channels[i] = (uint8_t)mp_obj_get_int(channel_list_items[i]); + } + + // Configuration audio_codec_adc_cfg_t cfg = { .handle = NULL, .max_store_buf_size = 1024 * 2, .conv_frame_size = 1024, - .unit_id = ADC_UNIT_1, - .adc_channel_list = ((uint8_t[]){ADC_CHANNEL_0}), - .adc_channel_num = 1, - .sample_rate_hz = 16000, - //.atten = ADC_ATTEN_DB_0, // values always 16380 - //.atten = ADC_ATTEN_DB_2_5, // values always 16380 - .atten = ADC_ATTEN_DB_6, // values around 12500 +/- 320 (silence) or 4000 (loud talk) - //.atten = ADC_ATTEN_DB_11, // values around -1130 +/- 160 (silence) + .unit_id = (adc_unit_t)unit_id, + .adc_channel_list = channels, + .adc_channel_num = adc_channel_num, // can probably just count adc_channel_list + .sample_rate_hz = sample_rate_hz, + .atten = (adc_atten_t)atten, }; - ADC_MIC_DEBUG_PRINT("Config created for channel %d, sample rate %d, atten %d\n", ADC_CHANNEL_0, 16000, cfg.atten); + ADC_MIC_DEBUG_PRINT("Config created for unit %d, channels %d, sample rate %d, atten %d\n", unit_id, adc_channel_num, sample_rate_hz, atten); // ──────────────────────────────────────────────── // Initialization (same as before) @@ -55,8 +81,8 @@ static mp_obj_t adc_mic_read(mp_obj_t chunk_samples_obj) { } esp_codec_dev_sample_info_t fs = { - .sample_rate = 16000, - .channel = 1, + .sample_rate = sample_rate_hz, + .channel = adc_channel_num, .bits_per_sample = 16, }; esp_err_t open_ret = esp_codec_dev_open(dev, &fs); @@ -69,9 +95,9 @@ static mp_obj_t adc_mic_read(mp_obj_t chunk_samples_obj) { // ──────────────────────────────────────────────── // Small reusable buffer + tracking variables // ──────────────────────────────────────────────── - const size_t buf_size = chunk_samples * sizeof(int16_t); + const size_t buf_size = chunk_samples * sizeof(int16_t) * adc_channel_num; int16_t *audio_buffer = heap_caps_malloc(buf_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); - //int16_t *audio_buffer = heap_caps_thread.start_new_thread(testit, ())_malloc_prefer(buf_size, MALLOC_CAP_DEFAULT | MALLOC_CAP_SPIRAM, MALLOC_CAP_DEFAULT); + //int16_t *audio_buffer = heap_caps_malloc_prefer(buf_size, MALLOC_CAP_DEFAULT | MALLOC_CAP_SPIRAM, MALLOC_CAP_DEFAULT); if (audio_buffer == NULL) { esp_codec_dev_close(dev); esp_codec_dev_delete(dev); @@ -99,10 +125,6 @@ static mp_obj_t adc_mic_read(mp_obj_t chunk_samples_obj) { break; } vTaskDelay(pdMS_TO_TICKS(1)); // 1 ms yield - //if (ret != (int)buf_size) { - // ADC_MIC_DEBUG_PRINT("Partial read at chunk %d: got %d bytes (expected %zu)\n", - // chunk, ret, buf_size); - //} // Update global min/max for (size_t i = 0; i < chunk_samples; i++) { @@ -146,7 +168,7 @@ static mp_obj_t adc_mic_read(mp_obj_t chunk_samples_obj) { return last_buf_obj ? last_buf_obj : mp_obj_new_bytes(NULL, 0); } -MP_DEFINE_CONST_FUN_OBJ_1(adc_mic_read_obj, adc_mic_read); +MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(adc_mic_read_obj, 6, 6, adc_mic_read); static const mp_rom_map_elem_t adc_mic_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_adc_mic) }, diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 13b8a770..60aaba0c 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -29,7 +29,7 @@ popd idfile="$codebasedir"/lvgl_micropython/lib/micropython/ports/esp32/main/idf_component.yml echo "Patching $idfile"... -echo "Check need to add esp32-camera..." +echo "Check need to add esp32-camera to $idfile" if ! grep esp32-camera "$idfile"; then echo "Adding esp32-camera to $idfile" echo " mpos/esp32-camera: @@ -37,7 +37,16 @@ if ! grep esp32-camera "$idfile"; then else echo "No need to add esp32-camera to $idfile" fi -echo "Resulting file:" + +echo "Check need to add adc_mic to $idfile" +if ! grep esp32-camera "$idfile"; then + echo "Adding esp32-camera to $idfile" + echo ' espressif/adc_mic: "*"' >> "$idfile" +else + echo "No need to add adc_mic to $idfile" +fi + +echo "Resulting $idfile file:" cat "$idfile" echo "Check need to add lvgl_micropython manifest to micropython-camera-API's manifest..." From 7431537010f081947989c50b2ed39b01ffd6882c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 16 Feb 2026 16:39:52 +0100 Subject: [PATCH 041/317] AudioManager: add support for adc_mic This works for init: AudioManager(i2s_pins=i2s_pins, adc_mic_pin=1) And then this for recording: AudioManager.record_wav_adc(file_path="/recording.wav",duration_ms=5000,sample_rate=16000,on_complete=lambda msg: print(f"Finished: {msg}")) --- .../lib/mpos/audio/audiomanager.py | 45 +- .../lib/mpos/audio/stream_record_adc.py | 503 ++++-------------- .../lib/mpos/board/fri3d_2026.py | 3 +- 3 files changed, 127 insertions(+), 424 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/audiomanager.py b/internal_filesystem/lib/mpos/audio/audiomanager.py index 241ed141..35e0ce7c 100644 --- a/internal_filesystem/lib/mpos/audio/audiomanager.py +++ b/internal_filesystem/lib/mpos/audio/audiomanager.py @@ -32,13 +32,14 @@ class AudioManager: _instance = None # Singleton instance - def __init__(self, i2s_pins=None, buzzer_instance=None): + def __init__(self, i2s_pins=None, buzzer_instance=None, adc_mic_pin=None): """ Initialize AudioManager instance with optional hardware configuration. Args: i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S/WAV playback) buzzer_instance: PWM instance for buzzer (for RTTTL playback) + adc_mic_pin: GPIO pin number for ADC microphone (for ADC recording) """ if AudioManager._instance: return @@ -46,6 +47,7 @@ def __init__(self, i2s_pins=None, buzzer_instance=None): self._i2s_pins = i2s_pins # I2S pin configuration dict (created per-stream) self._buzzer_instance = buzzer_instance # PWM buzzer instance + self._adc_mic_pin = adc_mic_pin # ADC microphone pin self._current_stream = None # Currently playing stream self._current_recording = None # Currently recording stream self._volume = 50 # System volume (0-100) @@ -56,6 +58,8 @@ def __init__(self, i2s_pins=None, buzzer_instance=None): capabilities.append("I2S (WAV)") if buzzer_instance: capabilities.append("Buzzer (RTTTL)") + if adc_mic_pin: + capabilities.append(f"ADC Mic (Pin {adc_mic_pin})") if capabilities: print(f"AudioManager initialized: {', '.join(capabilities)}") @@ -78,8 +82,10 @@ def has_buzzer(self): return self._buzzer_instance is not None def has_microphone(self): - """Check if I2S microphone is available for recording.""" - return self._i2s_pins is not None and 'sd_in' in self._i2s_pins + """Check if microphone (I2S or ADC) is available for recording.""" + has_i2s_mic = self._i2s_pins is not None and 'sd_in' in self._i2s_pins + has_adc_mic = self._adc_mic_pin is not None + return has_i2s_mic or has_adc_mic def _check_audio_focus(self, stream_type): """ @@ -296,39 +302,35 @@ def record_wav(self, file_path, duration_ms=None, on_complete=None, sample_rate= sys.print_exception(e) return False - def record_wav_adc(self, file_path, duration_ms=None, adc_pin=2, sample_rate=8000, - adaptive_control=True, on_complete=None, **adc_config): + def record_wav_adc(self, file_path, duration_ms=None, adc_pin=None, sample_rate=16000, + on_complete=None, **adc_config): """ - Record audio from ADC with adaptive frequency control to WAV file. + Record audio from ADC using optimized C module 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) - adc_pin: GPIO pin for ADC input (default: 2 for ESP32) - sample_rate: Target sample rate in Hz (default 8000 for voice) - adaptive_control: Enable PI feedback control for stable sampling (default: True) + adc_pin: GPIO pin for ADC input (default: configured pin or 1) + sample_rate: Target sample rate in Hz (default 16000 for voice) on_complete: Callback function(message) when recording finishes - **adc_config: Additional ADC configuration: - - control_gain_p: Proportional gain (default: 0.05) - - control_gain_i: Integral gain (default: 0.01) - - integral_windup_limit: Integral term limit (default: 1000) - - adjustment_interval: Samples between adjustments (default: 1000) - - warmup_samples: Warm-up phase samples (default: 3000) - - callback_overhead_offset: Initial frequency offset (default: 2500) - - min_freq: Minimum timer frequency (default: 6000) - - max_freq: Maximum timer frequency (default: 40000) - - gc_enabled: Enable garbage collection (default: True) - - gc_interval: Samples between GC cycles (default: 5000) + **adc_config: Additional ADC configuration Returns: bool: True if recording started, False if rejected or unavailable """ + # Use configured pin if not specified + if adc_pin is None: + adc_pin = self._adc_mic_pin + + # Fallback to default if still None + if adc_pin is None: + adc_pin = 1 # Default to GPIO1 (Fri3d 2026) + print(f"AudioManager.record_wav_adc() called") print(f" file_path: {file_path}") print(f" duration_ms: {duration_ms}") print(f" adc_pin: {adc_pin}") print(f" sample_rate: {sample_rate}") - print(f" adaptive_control: {adaptive_control}") # Cannot record while playing (I2S can only be TX or RX, not both) if self.is_playing(): @@ -351,7 +353,6 @@ def record_wav_adc(self, file_path, duration_ms=None, adc_pin=2, sample_rate=800 duration_ms=duration_ms, sample_rate=sample_rate, adc_pin=adc_pin, - adaptive_control=adaptive_control, on_complete=on_complete, **adc_config ) diff --git a/internal_filesystem/lib/mpos/audio/stream_record_adc.py b/internal_filesystem/lib/mpos/audio/stream_record_adc.py index cf237cc2..1cdaf87d 100644 --- a/internal_filesystem/lib/mpos/audio/stream_record_adc.py +++ b/internal_filesystem/lib/mpos/audio/stream_record_adc.py @@ -1,21 +1,21 @@ -# ADCRecordStream - WAV File Recording Stream with Adaptive ADC Sampling -# Records 16-bit mono PCM audio from ADC with timer-based sampling -# Uses PI (Proportional-Integral) feedback control for stable sampling rate -# Includes warm-up phase and periodic garbage collection for long recordings +# ADCRecordStream - WAV File Recording Stream with C-based ADC Sampling +# Records 16-bit mono PCM audio from ADC using the optimized adc_mic C module +# Uses timer-based sampling with double buffering in C for high performance # Maintains compatibility with AudioManager and existing recording framework -import math import os import sys import time import gc +import array # Try to import machine module (not available on desktop) try: import machine - _HAS_MACHINE = True + import adc_mic + _HAS_HARDWARE = True except ImportError: - _HAS_MACHINE = False + _HAS_HARDWARE = False def _makedirs(path): @@ -41,114 +41,59 @@ def _makedirs(path): class ADCRecordStream: """ - WAV file recording stream with adaptive ADC timer-based sampling. - Records 16-bit mono PCM audio from ADC with PI feedback control. - Maintains target sample rate through dynamic timer frequency adjustment. + WAV file recording stream with C-optimized ADC sampling. + Records 16-bit mono PCM audio from ADC using the adc_mic module. """ # Default recording parameters - DEFAULT_SAMPLE_RATE = 8000 # 8kHz - good for voice/ADC + DEFAULT_SAMPLE_RATE = 16000 # 16kHz - good for voice/ADC DEFAULT_MAX_DURATION_MS = 60000 # 60 seconds max DEFAULT_FILESIZE = 1024 * 1024 * 1024 # 1GB data size - + # ADC configuration defaults - DEFAULT_ADC_PIN = 2 # GPIO2 on ESP32 - DEFAULT_ADC_ATTENUATION = None # Will be set based on machine module - DEFAULT_ADC_WIDTH = None # Will be set based on machine module - - # PI Controller configuration - DEFAULT_CONTROL_GAIN_P = 0.05 # Proportional gain (aggressive for fast response) - DEFAULT_CONTROL_GAIN_I = 0.01 # Integral gain (steady-state correction) - DEFAULT_INTEGRAL_WINDUP_LIMIT = 1000 # Prevent integral overflow - DEFAULT_ADJUSTMENT_INTERVAL = 1000 # Samples between frequency adjustments - DEFAULT_WARMUP_SAMPLES = 3000 # Samples before starting adjustments - DEFAULT_CALLBACK_OVERHEAD_OFFSET = 9000 # Hz offset for initial frequency (disabled by default) - DEFAULT_MAX_PENDING_SAMPLES = 4096 # Maximum pending samples buffer size - - # Frequency bounds - DEFAULT_MIN_FREQ = 6000 # Minimum timer frequency - DEFAULT_MAX_FREQ = 40000 # Maximum timer frequency - - # Garbage collection configuration - DEFAULT_GC_INTERVAL = 5000 # Perform GC every N samples - DEFAULT_GC_ENABLED = False # Enable explicit garbage collection + DEFAULT_ADC_PIN = 1 # GPIO1 on Fri3d 2026 + DEFAULT_ADC_UNIT = 0 # ADC_UNIT_1 = 0 + DEFAULT_ADC_CHANNEL = 0 # ADC_CHANNEL_0 = 0 (GPIO1) + DEFAULT_ATTEN = 2 # ADC_ATTEN_DB_6 = 2 def __init__(self, file_path, duration_ms, sample_rate, adc_pin=None, - adaptive_control=True, on_complete=None, **adc_config): + on_complete=None, **adc_config): """ - Initialize ADC recording stream with adaptive frequency control. + Initialize ADC recording stream. Args: file_path: Path to save WAV file duration_ms: Recording duration in milliseconds (None = until stop()) sample_rate: Target sample rate in Hz - adc_pin: GPIO pin for ADC input (default: GPIO2) - adaptive_control: Enable PI feedback control (default: True) + adc_pin: GPIO pin for ADC input (default: GPIO1) on_complete: Callback function(message) when recording finishes - **adc_config: Additional ADC configuration: - - control_gain_p: Proportional gain - - control_gain_i: Integral gain - - integral_windup_limit: Integral term limit - - adjustment_interval: Samples between adjustments - - warmup_samples: Warm-up phase samples - - callback_overhead_offset: Initial frequency offset (Hz, default 0) - - min_freq: Minimum timer frequency - - max_freq: Maximum timer frequency - - gc_enabled: Enable garbage collection (default: True) - - gc_interval: Samples between GC cycles - - max_pending_samples: Maximum pending samples buffer size (default: 4096) + **adc_config: Additional ADC configuration """ self.file_path = file_path self.duration_ms = duration_ms if duration_ms else self.DEFAULT_MAX_DURATION_MS self.sample_rate = sample_rate if sample_rate else self.DEFAULT_SAMPLE_RATE self.adc_pin = adc_pin if adc_pin is not None else self.DEFAULT_ADC_PIN - self.adaptive_control = adaptive_control self.on_complete = on_complete - - # ADC configuration - self._adc = None - self._timer = None + + # Determine ADC unit and channel from pin + # This is a simple mapping for ESP32-S3 + # TODO: Make this more robust or pass in unit/channel directly + self.adc_unit = self.DEFAULT_ADC_UNIT + self.adc_channel = self.DEFAULT_ADC_CHANNEL + + # Simple mapping for Fri3d 2026 (GPIO1 -> ADC1_CH0) + if self.adc_pin == 1: + self.adc_unit = 0 # ADC_UNIT_1 + self.adc_channel = 0 # ADC_CHANNEL_0 + elif self.adc_pin == 2: + self.adc_unit = 0 + self.adc_channel = 1 + # Add more mappings as needed + self._keep_running = True self._is_recording = False self._bytes_recorded = 0 - - # PI Controller configuration - self.control_gain_p = adc_config.get('control_gain_p', self.DEFAULT_CONTROL_GAIN_P) - self.control_gain_i = adc_config.get('control_gain_i', self.DEFAULT_CONTROL_GAIN_I) - self.integral_windup_limit = adc_config.get('integral_windup_limit', self.DEFAULT_INTEGRAL_WINDUP_LIMIT) - self.adjustment_interval = adc_config.get('adjustment_interval', self.DEFAULT_ADJUSTMENT_INTERVAL) - self.warmup_samples = adc_config.get('warmup_samples', self.DEFAULT_WARMUP_SAMPLES) - self.callback_overhead_offset = adc_config.get('callback_overhead_offset', self.DEFAULT_CALLBACK_OVERHEAD_OFFSET) - self.min_freq = adc_config.get('min_freq', self.DEFAULT_MIN_FREQ) - self.max_freq = adc_config.get('max_freq', self.DEFAULT_MAX_FREQ) - - # Garbage collection configuration - self.gc_enabled = adc_config.get('gc_enabled', self.DEFAULT_GC_ENABLED) - self.gc_interval = adc_config.get('gc_interval', self.DEFAULT_GC_INTERVAL) - - # Pending samples buffer configuration - self.max_pending_samples = adc_config.get('max_pending_samples', self.DEFAULT_MAX_PENDING_SAMPLES) - - # PI Controller state - self._current_freq = self.sample_rate - self._sample_counter = 0 - self._last_adjustment_sample = 0 - self._integral_error = 0.0 - self._warmup_complete = False - self._last_gc_sample = 0 self._start_time_ms = 0 - self._adjustment_history = [] - - # Logging and diagnostics for dropped samples - self._dropped_samples = 0 - self._drop_events = [] # List of (sample_number, pending_queue_size) tuples - self._max_pending_depth = 0 - self._pending_depth_history = [] # Track queue depth over time - self._last_pending_depth_log = 0 - self._samples_written = 0 - self._callback_count = 0 - self._last_callback_time_ms = 0 - self._max_callback_lag_ms = 0 def is_recording(self): """Check if stream is currently recording.""" @@ -165,7 +110,7 @@ def get_elapsed_ms(self): return 0 # ----------------------------------------------------------------------- - # WAV header generation (reused from RecordStream) + # WAV header generation # ----------------------------------------------------------------------- @staticmethod def _create_wav_header(sample_rate, num_channels, bits_per_sample, data_size): @@ -245,6 +190,7 @@ def _generate_sine_wave_chunk(self, chunk_size, sample_offset): Returns: tuple: (bytearray of samples, number of samples generated) """ + import math frequency = 440 # A4 note amplitude = 16000 # ~50% of max 16-bit amplitude @@ -268,146 +214,6 @@ def _generate_sine_wave_chunk(self, chunk_size, sample_offset): return buf, num_samples - # ----------------------------------------------------------------------- - # PI Controller for adaptive frequency control - # ----------------------------------------------------------------------- - def _adjust_frequency(self): - """ - PI (Proportional-Integral) feedback control to adjust timer frequency. - Compares actual sampling rate vs target rate and adjusts accordingly. - Only called after warm-up phase completes. - """ - elapsed_ms = time.ticks_diff(time.ticks_ms(), self._start_time_ms) - - if elapsed_ms <= 0: - return - - # Calculate actual sampling rate - actual_rate = self._sample_counter / (elapsed_ms / 1000.0) - - # Calculate error (positive means we're behind target) - rate_error = self.sample_rate - actual_rate - - # Update integral term (accumulated error) - self._integral_error += rate_error - - # Limit integral windup to prevent excessive accumulation - self._integral_error = max(-self.integral_windup_limit, - min(self.integral_windup_limit, self._integral_error)) - - # PI control: combine proportional and integral terms - freq_adjustment = (rate_error * self.control_gain_p) + (self._integral_error * self.control_gain_i) - - # Calculate new frequency - new_freq = self._current_freq + freq_adjustment - - # Clamp frequency to safe range - new_freq = max(self.min_freq, min(self.max_freq, new_freq)) - - # Only adjust if change is significant (at least 1 Hz) - if abs(new_freq - self._current_freq) >= 1: - old_freq = self._current_freq - self._current_freq = int(new_freq) - - # Calculate estimated callback overhead - estimated_overhead = self._current_freq - actual_rate - - # Reinitialize timer with new frequency - try: - self._timer.deinit() - self._timer.init(freq=self._current_freq, mode=machine.Timer.PERIODIC, - callback=self._record_sample_callback) - - adjustment_info = { - 'sample': self._sample_counter, - 'actual_rate': actual_rate, - 'target_rate': self.sample_rate, - 'error': rate_error, - 'integral_error': self._integral_error, - 'old_freq': old_freq, - 'new_freq': self._current_freq, - 'adjustment': freq_adjustment, - 'estimated_overhead': estimated_overhead - } - self._adjustment_history.append(adjustment_info) - - print(f" [ADJUST] Sample {self._sample_counter}: Rate {actual_rate:.1f} Hz " - f"(error: {rate_error:+.1f} Hz) → Freq {old_freq} → {self._current_freq} Hz") - - except Exception as e: - print(f"Error adjusting frequency: {e}") - self._current_freq = old_freq - - def _record_sample_callback(self, timer): - """ - Timer callback function to read ADC samples with adaptive frequency. - Called by hardware timer at precise intervals. - Includes periodic garbage collection and buffer overflow protection. - Tracks dropped samples and main thread lag. - """ - if not self._is_recording or not self._keep_running: - return - - try: - # Track callback timing for lag detection - current_time_ms = time.ticks_ms() - if self._last_callback_time_ms > 0: - callback_lag = time.ticks_diff(current_time_ms, self._last_callback_time_ms) - if callback_lag > self._max_callback_lag_ms: - self._max_callback_lag_ms = callback_lag - self._last_callback_time_ms = current_time_ms - self._callback_count += 1 - - # Read ADC value - adc_value = self._adc.read() - self._sample_counter += 1 - - # Convert 12-bit ADC value to 16-bit signed PCM - # ADC range: 0-4095 (12-bit), convert to -32768 to 32767 (16-bit signed) - sample_16bit = int((adc_value - 2048) * 16) - - # Clamp to 16-bit range - if sample_16bit > 32767: - sample_16bit = 32767 - elif sample_16bit < -32768: - sample_16bit = -32768 - - # Track pending queue depth - current_pending = len(self._pending_samples) - if current_pending > self._max_pending_depth: - self._max_pending_depth = current_pending - - # Store sample (unbounded buffer - will buffer everything) - self._pending_samples.append(sample_16bit) - - # Log pending queue depth periodically - if self._sample_counter - self._last_pending_depth_log >= 1000: - self._pending_depth_history.append((self._sample_counter, current_pending)) - if current_pending > self.max_pending_samples * 0.8: - print(f"[QUEUE] Sample {self._sample_counter}: Pending queue at {current_pending}/{self.max_pending_samples} " - f"({100*current_pending/self.max_pending_samples:.1f}%)") - self._last_pending_depth_log = self._sample_counter - - # Perform garbage collection at regular intervals - if self.gc_enabled and self._sample_counter - self._last_gc_sample >= self.gc_interval: - gc.collect() - self._last_gc_sample = self._sample_counter - - # Check if warm-up phase is complete - if not self._warmup_complete and self._sample_counter >= self.warmup_samples: - self._warmup_complete = True - print(f">>> WARM-UP PHASE COMPLETE at sample {self._sample_counter}") - print(f">>> Starting adaptive frequency control...\n") - - # Adjust frequency only after warm-up phase and at intervals - if self.adaptive_control and self._warmup_complete and \ - self._sample_counter - self._last_adjustment_sample >= self.adjustment_interval: - self._adjust_frequency() - self._last_adjustment_sample = self._sample_counter - - except Exception as e: - print(f"Error in ADC callback: {e}") - # ----------------------------------------------------------------------- # Main recording routine # ----------------------------------------------------------------------- @@ -417,23 +223,18 @@ def record(self): print(f" file_path: {self.file_path}") print(f" duration_ms: {self.duration_ms}") print(f" sample_rate: {self.sample_rate}") - print(f" adc_pin: {self.adc_pin}") - print(f" adaptive_control: {self.adaptive_control}") - print(f" _HAS_MACHINE: {_HAS_MACHINE}") + print(f" adc_pin: {self.adc_pin} (Unit {self.adc_unit}, Channel {self.adc_channel})") + print(f" _HAS_HARDWARE: {_HAS_HARDWARE}") self._is_recording = True self._bytes_recorded = 0 - self._sample_counter = 0 - self._pending_samples = [] self._start_time_ms = time.ticks_ms() try: # Ensure directory exists dir_path = '/'.join(self.file_path.split('/')[:-1]) - print(f"ADCRecordStream: Creating directory: {dir_path}") if dir_path: _makedirs(dir_path) - print(f"ADCRecordStream: Directory created/verified") # Create file with placeholder header print(f"ADCRecordStream: Creating WAV file with header") @@ -446,210 +247,110 @@ def record(self): data_size=self.DEFAULT_FILESIZE ) f.write(header) - print(f"ADCRecordStream: Header written ({len(header)} bytes)") print(f"ADCRecordStream: Recording to {self.file_path}") - print(f"ADCRecordStream: {self.sample_rate} Hz, 16-bit, mono") - print(f"ADCRecordStream: Max duration {self.duration_ms}ms") - - # Check if we have real ADC hardware or need to simulate - use_simulation = not _HAS_MACHINE + + # Check if we have real hardware or need to simulate + use_simulation = not _HAS_HARDWARE if not use_simulation: - # Initialize ADC - try: - print(f"ADCRecordStream: Initializing ADC on pin {self.adc_pin}") - self._adc = machine.ADC(machine.Pin(self.adc_pin)) - self._adc.atten(machine.ADC.ATTN_11DB) # Full range: 0-3.3V - self._adc.width(machine.ADC.WIDTH_12BIT) # 12-bit resolution - print(f"ADCRecordStream: ADC initialized successfully") - - # Initialize timer for sampling - print(f"ADCRecordStream: Initializing timer at {self._current_freq} Hz") - self._timer = machine.Timer(2) - self._timer.init(freq=self._current_freq, mode=machine.Timer.PERIODIC, - callback=self._record_sample_callback) - print(f"ADCRecordStream: Timer initialized successfully") - - except Exception as e: - print(f"ADCRecordStream: ADC/Timer init failed: {e}") - print(f"ADCRecordStream: Falling back to simulation mode") - use_simulation = True + print(f"ADCRecordStream: Using hardware ADC") + # No explicit init needed for adc_mic.read() as it handles it internally per call + # But we might want to do some setup if the C module required it. + # The current C module implementation does setup/teardown inside read() + # which is inefficient for streaming. + # However, the C module read() reads a LARGE chunk (e.g. 10000 samples). + pass if use_simulation: - print(f"ADCRecordStream: Using desktop simulation (440Hz sine wave)") + print(f"ADCRecordStream: Using desktop simulation (sine wave)") # Calculate recording parameters - chunk_size = 1024 # Read 1KB at a time max_bytes = int((self.duration_ms / 1000) * self.sample_rate * 2) - sample_offset = 0 # For sine wave phase continuity - - # Flush every ~2 seconds of audio (64KB at 8kHz 16-bit mono) - flush_interval_bytes = 64 * 1024 - bytes_since_flush = 0 - - print(f"ADCRecordStream: max_bytes={max_bytes}, chunk_size={chunk_size}, flush_interval={flush_interval_bytes}") - + # Open file for appending audio data - print(f"ADCRecordStream: Opening file for audio data...") - t0 = time.ticks_ms() f = open(self.file_path, 'ab') - print(f"ADCRecordStream: File opened in {time.ticks_diff(time.ticks_ms(), t0)}ms") + + # Chunk size for reading + # For ADC, we want a reasonable chunk size to minimize overhead + # 4096 samples = 8192 bytes = ~0.25s at 16kHz + chunk_samples = 4096 + + sample_offset = 0 try: while self._keep_running: - # Check elapsed time - strict duration limit + # Check elapsed time elapsed = time.ticks_diff(time.ticks_ms(), self._start_time_ms) if elapsed >= self.duration_ms: - print(f"ADCRecordStream: Duration limit reached ({elapsed}ms >= {self.duration_ms}ms)") - # Stop the timer immediately to prevent more samples - if self._timer: - self._timer.deinit() - self._timer = None + print(f"ADCRecordStream: Duration limit reached") break - # Also check byte limit + # Check byte limit if self._bytes_recorded >= max_bytes: - print(f"ADCRecordStream: Byte limit reached ({self._bytes_recorded} >= {max_bytes})") + print(f"ADCRecordStream: Byte limit reached") break if use_simulation: # Generate sine wave samples for desktop testing - buf, num_samples = self._generate_sine_wave_chunk(chunk_size, sample_offset) + buf, num_samples = self._generate_sine_wave_chunk(chunk_samples * 2, sample_offset) sample_offset += num_samples - num_read = chunk_size - + + f.write(buf) + self._bytes_recorded += len(buf) + # Simulate real-time recording speed - time.sleep_ms(int((chunk_size / 2) / self.sample_rate * 1000)) - - f.write(buf[:num_read]) - self._bytes_recorded += num_read - bytes_since_flush += num_read - + time.sleep_ms(int((chunk_samples) / self.sample_rate * 1000)) + else: - # Just collect samples in buffer during recording - # Don't write to file yet - that causes I/O delays - pass - - # Minimal sleep to keep up with callback - time.sleep_ms(1) + # Read from C module + # adc_mic.read(chunk_samples, unit_id, adc_channel_list, adc_channel_num, sample_rate_hz, atten) + # Returns bytes object + + # unit_id: 0 (ADC_UNIT_1) + # adc_channel_list: [self.adc_channel] + # adc_channel_num: 1 + # sample_rate_hz: self.sample_rate + # atten: 2 (ADC_ATTEN_DB_6) + + data = adc_mic.read( + chunk_samples, + self.adc_unit, + [self.adc_channel], + 1, + self.sample_rate, + self.DEFAULT_ATTEN + ) + + if data: + f.write(data) + self._bytes_recorded += len(data) + else: + # No data available yet, short sleep + time.sleep_ms(10) finally: - # Write all pending samples to file after recording stops - print(f"ADCRecordStream: Writing {len(self._pending_samples)} pending samples to file...") - t0 = time.ticks_ms() - for sample in self._pending_samples: - if sample < 0: - sample_bytes = (sample & 0xFFFF).to_bytes(2, 'little') - else: - sample_bytes = sample.to_bytes(2, 'little') - f.write(sample_bytes) - self._bytes_recorded += 2 - self._pending_samples.clear() - write_time = time.ticks_diff(time.ticks_ms(), t0) - print(f"ADCRecordStream: Wrote pending samples in {write_time}ms") - - # Explicitly close the file and measure time - print(f"ADCRecordStream: Closing audio data file...") - t0 = time.ticks_ms() f.close() - print(f"ADCRecordStream: File closed in {time.ticks_diff(time.ticks_ms(), t0)}ms") + + # Update WAV header with actual size + try: + # Only update if we actually recorded something + if self._bytes_recorded > 0: + self._update_wav_header(self.file_path, self._bytes_recorded) + except Exception as e: + print(f"ADCRecordStream: Error updating header: {e}") elapsed_ms = time.ticks_diff(time.ticks_ms(), self._start_time_ms) print(f"ADCRecordStream: Finished recording {self._bytes_recorded} bytes ({elapsed_ms}ms)") - # Verify file size with os.stat() - print(f"\n{'='*60}") - print(f"FILE SIZE VERIFICATION") - print(f"{'='*60}") - try: - file_stat = os.stat(self.file_path) - file_size = file_stat[6] # st_size is at index 6 - - # Calculate expected size - expected_samples = int((self.duration_ms / 1000.0) * self.sample_rate) - expected_bytes = expected_samples * 2 + 44 # 44 bytes for WAV header - - # Calculate actual samples from file size - actual_audio_bytes = file_size - 44 # Subtract WAV header - actual_samples = actual_audio_bytes // 2 - actual_duration_ms = int((actual_samples / self.sample_rate) * 1000) - - print(f"Expected duration: {self.duration_ms}ms") - print(f"Expected samples: {expected_samples}") - print(f"Expected audio bytes: {expected_samples * 2}") - print(f"Expected total file size: {expected_bytes} bytes (including 44-byte WAV header)") - print() - print(f"Actual file size: {file_size} bytes") - print(f"Actual audio bytes: {actual_audio_bytes}") - print(f"Actual samples: {actual_samples}") - print(f"Actual duration: {actual_duration_ms}ms") - print() - - # Calculate difference - size_diff = file_size - expected_bytes - sample_diff = actual_samples - expected_samples - duration_diff = actual_duration_ms - self.duration_ms - - if size_diff == 0: - print(f"✓ PERFECT: File size matches expected size exactly!") - elif size_diff > 0: - print(f"✓ GOOD: File size is {size_diff} bytes larger than expected") - print(f" ({sample_diff} extra samples, {duration_diff}ms extra)") - else: - print(f"✗ SHORT: File size is {abs(size_diff)} bytes smaller than expected") - print(f" ({abs(sample_diff)} missing samples, {abs(duration_diff)}ms short)") - print(f" Completion: {(file_size / expected_bytes) * 100:.1f}%") - - except Exception as e: - print(f"Error verifying file size: {e}") - print(f"{'='*60}\n") - - # Print dropped samples summary - print(f"\n{'='*60}") - print(f"DROPPED SAMPLES SUMMARY") - print(f"{'='*60}") - print(f"Total samples collected: {self._sample_counter}") - print(f"Total samples dropped: {self._dropped_samples}") - print(f"Samples written to file: {self._samples_written}") - if self._sample_counter > 0: - drop_rate = (self._dropped_samples / self._sample_counter) * 100 - print(f"Drop rate: {drop_rate:.2f}%") - if self._drop_events: - print(f"Number of drop events: {len(self._drop_events)}") - print(f"First drop at sample: {self._drop_events[0][0]}") - print(f"Last drop at sample: {self._drop_events[-1][0]}") - print(f"Max pending queue depth: {self._max_pending_depth}/{self.max_pending_samples}") - print(f"Max callback lag: {self._max_callback_lag_ms}ms") - print(f"Total callbacks: {self._callback_count}") - print(f"{'='*60}\n") - - # Print adaptive control statistics - if self.adaptive_control and self._adjustment_history: - print(f"\nADCRecordStream: Adaptive control statistics:") - print(f" Total adjustments: {len(self._adjustment_history)}") - if self._adjustment_history: - first_error = self._adjustment_history[0]['error'] - last_error = self._adjustment_history[-1]['error'] - print(f" First error: {first_error:+.1f} Hz") - print(f" Last error: {last_error:+.1f} Hz") - print(f" Error reduction: {abs(first_error) - abs(last_error):+.1f} Hz") - if self.on_complete: self.on_complete(f"Recorded: {self.file_path}") except Exception as e: - import sys - print(f"ADCRecordStream: Error: {e}") sys.print_exception(e) if self.on_complete: self.on_complete(f"Error: {e}") finally: self._is_recording = False - if self._timer: - self._timer.deinit() - self._timer = None - if self._adc: - self._adc = None print(f"ADCRecordStream: Recording thread finished") diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py index e5c0935c..76029fb0 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2026.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -231,7 +231,8 @@ def adc_to_voltage(adc_value): } # Initialize AudioManager with I2S (buzzer TODO) -AudioManager(i2s_pins=i2s_pins) +# ADC microphone is on GPIO 1 +AudioManager(i2s_pins=i2s_pins, adc_mic_pin=1) # === SENSOR HARDWARE === from mpos import SensorManager From d6d36693099032934c9ee3dcec9d946dc0cb0b58 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Mon, 16 Feb 2026 10:56:15 +0100 Subject: [PATCH 042/317] imu: Allow access to iio on Linux Linux phones have IMU units. Allow applications to use them same way they would access IMU on esp32 based devices. Lightly tested on PinePhone. Same applications useful on esp32 (compass, step counter) are likely to be useful on Linux phones, too. --- internal_filesystem/lib/mpos/board/linux.py | 9 +- .../lib/mpos/sensor_manager.py | 235 +++++++++++++++--- 2 files changed, 214 insertions(+), 30 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index b8cf1495..c5b28ca2 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -127,7 +127,14 @@ 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_iio() + +# In app: +if False and SensorManager.is_available(): + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + print(accel) + ax, ay, az = SensorManager.read_sensor_once(accel) # Returns m/s² + print(ax, ay, az) # === CAMERA HARDWARE === diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index fa5eab57..5ed04656 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -19,6 +19,7 @@ """ import time +import os try: import _thread _lock = _thread.allocate_lock() @@ -28,6 +29,7 @@ # Sensor type constants (matching Android SensorManager) TYPE_ACCELEROMETER = 1 # Units: m/s² (meters per second squared) +TYPE_MAGNETIC_FIELD = 2 # Units: μT (micro Teslas) TYPE_GYROSCOPE = 4 # Units: deg/s (degrees per second) TYPE_TEMPERATURE = 13 # Units: °C (generic, returns first available - deprecated) TYPE_IMU_TEMPERATURE = 14 # Units: °C (IMU chip temperature) @@ -101,6 +103,7 @@ class SensorManager: # Class-level constants TYPE_ACCELEROMETER = TYPE_ACCELEROMETER + TYPE_MAGNETIC_FIELD = TYPE_MAGNETIC_FIELD TYPE_GYROSCOPE = TYPE_GYROSCOPE TYPE_TEMPERATURE = TYPE_TEMPERATURE TYPE_IMU_TEMPERATURE = TYPE_IMU_TEMPERATURE @@ -120,6 +123,7 @@ def get(cls): if cls._instance is None: cls._instance = cls() return cls._instance + def init(self, i2c_bus, address=0x6B, mounted_position=FACING_SKY): """Initialize SensorManager. MCU temperature initializes immediately, IMU initializes on first use. @@ -146,6 +150,43 @@ def init(self, i2c_bus, address=0x6B, mounted_position=FACING_SKY): self._initialized = True return True + + def init_iio(self): + self._imu_driver = _IIODriver() + self._sensor_list = [ + Sensor( + name="Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="Linux IIO", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2 + ), + Sensor( + name="Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="Linux IIO", + version=1, + max_range="±256 deg/s", + resolution="0.002 deg/s", + power_ma=0.7 + ), + Sensor( + name="Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="Linux IIO", + version=1, + max_range="-40°C to +85°C", + resolution="0.004°C", + power_ma=0 + ) + ] + + self._load_calibration() + + self._initialized = True + return True def _ensure_imu_initialized(self): """Perform IMU initialization on first use (lazy initialization). @@ -227,7 +268,35 @@ def get_default_sensor(self, sensor_type): if sensor.type == sensor_type: return sensor return None - + + def read_sensor_once(self, sensor): + if sensor.type == TYPE_ACCELEROMETER: + if self._imu_driver: + ax, ay, az = self._imu_driver.read_acceleration() + if self._mounted_position == FACING_EARTH: + az *= -1 + return (ax, ay, az) + elif sensor.type == TYPE_GYROSCOPE: + if self._imu_driver: + return self._imu_driver.read_gyroscope() + elif sensor.type == TYPE_IMU_TEMPERATURE: + if self._imu_driver: + return self._imu_driver.read_temperature() + elif sensor.type == TYPE_SOC_TEMPERATURE: + if self._has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + elif sensor.type == TYPE_TEMPERATURE: + # Generic temperature - return first available (backward compatibility) + if self._imu_driver: + temp = self._imu_driver.read_temperature() + if temp is not None: + return temp + if self._has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + return None + def read_sensor(self, sensor): """Read sensor data synchronously. @@ -258,32 +327,7 @@ def read_sensor(self, sensor): for attempt in range(max_retries): try: - if sensor.type == TYPE_ACCELEROMETER: - if self._imu_driver: - ax, ay, az = self._imu_driver.read_acceleration() - if self._mounted_position == FACING_EARTH: - az *= -1 - return (ax, ay, az) - elif sensor.type == TYPE_GYROSCOPE: - if self._imu_driver: - return self._imu_driver.read_gyroscope() - elif sensor.type == TYPE_IMU_TEMPERATURE: - if self._imu_driver: - return self._imu_driver.read_temperature() - elif sensor.type == TYPE_SOC_TEMPERATURE: - if self._has_mcu_temperature: - import esp32 - return esp32.mcu_temperature() - elif sensor.type == TYPE_TEMPERATURE: - # Generic temperature - return first available (backward compatibility) - if self._imu_driver: - temp = self._imu_driver.read_temperature() - if temp is not None: - return temp - if self._has_mcu_temperature: - import esp32 - return esp32.mcu_temperature() - return None + return self.read_sensor_once(sensor) except Exception as e: error_msg = str(e) # Retry if sensor data not ready, otherwise fail immediately @@ -292,6 +336,7 @@ def read_sensor(self, sensor): time.sleep_ms(retry_delay_ms) continue else: + print("Exception reading sensor:", error_msg) return None return None @@ -717,6 +762,138 @@ def set_calibration(self, accel_offsets, gyro_offsets): raise NotImplementedError +class _IIODriver(_IMUDriver): + """ + Read sensor data via Linux IIO sysfs. + + Typical base path: + /sys/bus/iio/devices/iio:device0 + """ + accel_path: str + + def __init__(self): + self.accel_path = self.find_iio_device_with_file("in_accel_x_raw") + print("path:", self.accel_path) + + def _p(self, name: str): + return self.accel_path + "/" + name + + def _exists(self, name): + try: + os.stat(name) + return True + except OSError: + return False + + def _is_dir(self, path): + # MicroPython: stat tuple, mode is [0] + try: + st = os.stat(path) + mode = st[0] + # directory bit (POSIX): 0o040000 + return (mode & 0o170000) == 0o040000 + except OSError: + return False + + def find_iio_device_with_file(self, filename, base_dir="/sys/bus/iio/devices/"): + """ + Returns full path to iio:deviceX that contains given filename, + e.g. "/sys/bus/iio/devices/iio:device0" + + Returns None if not found. + """ + + print("Is dir? ", self._is_dir(base_dir), base_dir) + try: + entries = os.listdir(base_dir) + except OSError: + print("Error listing dir") + return None + + for e in entries: + print("Entry:", e) + if not e.startswith("iio:device"): + continue + + print("Entry:", e) + + dev_path = base_dir + "/" + e + if not self._is_dir(dev_path): + continue + + if self._exists(dev_path + "/" + filename): + return dev_path + + return None + + def _read_text(self, name: str) -> str: + p = name + print("Read: ", p) + f = open(p, "r") + try: + return f.readline().strip() + finally: + f.close() + + def _read_float(self, name: str) -> float: + return float(self._read_text(name)) + + def _read_int(self, name: str) -> int: + return int(self._read_text(name), 10) + + def _read_raw_scaled(self, raw_name: str, scale_name: str) -> float: + raw = self._read_int(raw_name) + scale = self._read_float(scale_name) + return raw * scale + + # ---------------------------- + # Public API (replacing I2C) + # ---------------------------- + + def read_temperature(self) -> float: + """ + Tries common IIO patterns: + - in_temp_input (already scaled, usually millidegree C) + - in_temp_raw + in_temp_scale + """ + if False: # os.path.exists(self._p("in_temp_input")): + v = self._read_float(self.accel_path + "/" + "in_temp_input") + # Many drivers expose millidegree Celsius here. + if abs(v) > 200: # heuristic: 25000 means 25°C + return v / 1000.0 + return v + + # Fallback: raw + scale + return self._read_raw_scaled(self.accel_path + "/" + "in_temp_raw", self.accel_path + "/" + "in_temp_scale") + + def read_acceleration(self) -> tuple[float, float, float]: + """ + Returns acceleration in m/s^2 if the kernel driver uses standard IIO scale. + Common names: + in_accel_{x,y,z}_raw + in_accel_scale + """ + scale_name = self.accel_path + "/" + "in_accel_scale" + + ax = self._read_raw_scaled(self.accel_path + "/" + "in_accel_x_raw", scale_name) + ay = self._read_raw_scaled(self.accel_path + "/" + "in_accel_y_raw", scale_name) + az = self._read_raw_scaled(self.accel_path + "/" + "in_accel_z_raw", scale_name) + + return (ax, ay, az) + + def read_gyroscope(self) -> tuple[float, float, float]: + """ + Returns angular velocity in rad/s if the kernel driver uses standard IIO scale. + Common names: + in_anglvel_{x,y,z}_raw + in_anglvel_scale + """ + scale_name = self.accel_path + "/" + "in_anglvel_scale" + + gx = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_x_raw", scale_name) + gy = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_y_raw", scale_name) + gz = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_z_raw", scale_name) + + return (gx, gy, gz) + class _QMI8658Driver(_IMUDriver): """Wrapper for QMI8658 IMU (Waveshare board).""" @@ -918,8 +1095,8 @@ def set_calibration(self, accel_offsets, gyro_offsets): _original_methods = {} _methods_to_delegate = [ - 'init', 'is_available', 'get_sensor_list', 'get_default_sensor', - 'read_sensor', 'calibrate_sensor', 'check_calibration_quality', + 'init', 'init_iio', 'is_available', 'get_sensor_list', 'get_default_sensor', + 'read_sensor', 'read_sensor_once', 'calibrate_sensor', 'check_calibration_quality', 'check_stationarity' ] From 4a9493ae7ed6fc47449209f3419fca3c6dd29478 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Mon, 16 Feb 2026 20:54:21 +0100 Subject: [PATCH 043/317] imu: cleanup stale comments, revert unneccessary changes --- internal_filesystem/lib/mpos/board/linux.py | 10 -------- .../lib/mpos/sensor_manager.py | 23 +++++++++---------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index c5b28ca2..a1f464b5 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -122,20 +122,10 @@ def adc_to_voltage(adc_value): # LightsManager will not be initialized (functions will return False) # === SENSOR HARDWARE === -# Note: Desktop builds have no sensor hardware from mpos import SensorManager -# Initialize with no I2C bus - will detect MCU temp if available -# (On Linux desktop, this will fail gracefully but set _initialized flag) SensorManager.init_iio() -# In app: -if False and SensorManager.is_available(): - accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) - print(accel) - ax, ay, az = SensorManager.read_sensor_once(accel) # Returns m/s² - print(ax, ay, az) - # === CAMERA HARDWARE === def init_cam(width, height, colormode): diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 5ed04656..d5d3d7e1 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -29,7 +29,7 @@ # Sensor type constants (matching Android SensorManager) TYPE_ACCELEROMETER = 1 # Units: m/s² (meters per second squared) -TYPE_MAGNETIC_FIELD = 2 # Units: μT (micro Teslas) +TYPE_MAGNETIC_FIELD = 2 # Units: μT (micro teslas) TYPE_GYROSCOPE = 4 # Units: deg/s (degrees per second) TYPE_TEMPERATURE = 13 # Units: °C (generic, returns first available - deprecated) TYPE_IMU_TEMPERATURE = 14 # Units: °C (IMU chip temperature) @@ -124,7 +124,6 @@ def get(cls): cls._instance = cls() return cls._instance - def init(self, i2c_bus, address=0x6B, mounted_position=FACING_SKY): """Initialize SensorManager. MCU temperature initializes immediately, IMU initializes on first use. @@ -159,30 +158,30 @@ def init_iio(self): sensor_type=TYPE_ACCELEROMETER, vendor="Linux IIO", version=1, - max_range="±8G (78.4 m/s²)", - resolution="0.0024 m/s²", - power_ma=0.2 + max_range="?", + resolution="?", + power_ma=10 ), Sensor( name="Gyroscope", sensor_type=TYPE_GYROSCOPE, vendor="Linux IIO", version=1, - max_range="±256 deg/s", - resolution="0.002 deg/s", - power_ma=0.7 + max_range="?", + resolution="?", + power_ma=10 ), Sensor( name="Temperature", sensor_type=TYPE_IMU_TEMPERATURE, vendor="Linux IIO", version=1, - max_range="-40°C to +85°C", - resolution="0.004°C", - power_ma=0 + max_range="?", + resolution="?", + power_ma=10 ) ] - + self._load_calibration() self._initialized = True From ed0b94a44a1d639607712cc7b36808de854be4ae Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Mon, 16 Feb 2026 21:28:30 +0100 Subject: [PATCH 044/317] Enhance ShowBattery App (#39) Make the Layout nicer. Make the graph bigger. Display battery icon. --- .../assets/show_battery.py | 185 +++++++++--------- 1 file changed, 90 insertions(+), 95 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py b/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py index e2bff8d5..08b525f9 100644 --- a/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py +++ b/internal_filesystem/apps/com.micropythonos.showbattery/assets/show_battery.py @@ -42,6 +42,7 @@ import lvgl as lv import mpos.time from mpos import Activity, BatteryManager +from mpos.battery_manager import MAX_VOLTAGE, MIN_VOLTAGE HISTORY_LEN = 60 @@ -52,71 +53,60 @@ class ShowBattery(Activity): refresh_timer = None - # Widgets - lbl_time = None - lbl_sec = None - lbl_text = None - - bat_outline = None - bat_fill = None - - clear_cache_checkbox = None # Add reference to checkbox - history_v = [] history_p = [] def onCreate(self): - scr = lv.obj() + main_content = lv.obj() + main_content.set_flex_flow(lv.FLEX_FLOW.COLUMN) + main_content.set_style_pad_all(0, 0) + main_content.set_size(lv.pct(100), lv.pct(100)) + + # --- TOP FLEX BOX: INFORMATION --- - # --- TIME --- - self.lbl_time = lv.label(scr) - self.lbl_time.set_style_text_font(lv.font_montserrat_40, 0) - self.lbl_time.align(lv.ALIGN.TOP_LEFT, 5, 5) + info_column = lv.obj(main_content) + info_column.set_flex_flow(lv.FLEX_FLOW.COLUMN) + info_column.set_style_pad_all(1, 1) + info_column.set_size(lv.pct(100), lv.SIZE_CONTENT) - self.lbl_sec = lv.label(scr) - self.lbl_sec.set_style_text_font(lv.font_montserrat_24, 0) - self.lbl_sec.align_to(self.lbl_time, lv.ALIGN.OUT_RIGHT_BOTTOM, 24, -4) + self.lbl_datetime = lv.label(info_column) + self.lbl_datetime.set_style_text_font(lv.font_montserrat_16, 0) - # --- CHECKBOX --- - self.clear_cache_checkbox = lv.checkbox(scr) + self.lbl_battery = lv.label(info_column) + self.lbl_battery.set_style_text_font(lv.font_montserrat_24, 0) + + self.lbl_battery_raw = lv.label(info_column) + self.lbl_battery_raw.set_style_text_font(lv.font_montserrat_14, 0) + + self.clear_cache_checkbox = lv.checkbox(info_column) self.clear_cache_checkbox.set_text("Real-time values") - self.clear_cache_checkbox.align(lv.ALIGN.TOP_LEFT, 5, 50) - - self.lbl_text = lv.label(scr) - self.lbl_text.set_style_text_font(lv.font_montserrat_16, 0) - self.lbl_text.align(lv.ALIGN.TOP_LEFT, 5, 80) - - # --- BATTERY ICON --- - self.bat_outline = lv.obj(scr) - self.bat_size = 225 - self.bat_outline.set_size(80, self.bat_size) - self.bat_outline.align(lv.ALIGN.TOP_RIGHT, -10, 10) - self.bat_outline.set_style_border_width(2, 0) - self.bat_outline.set_style_radius(4, 0) - - self.bat_fill = lv.obj(self.bat_outline) - self.bat_fill.align(lv.ALIGN.BOTTOM_MID, 0, -2) - self.bat_fill.set_width(52) - self.bat_fill.set_style_radius(2, 0) - - # --- CANVAS --- - self.canvas = lv.canvas(scr) - self.canvas.set_size(220, 100) - self.canvas.align(lv.ALIGN.BOTTOM_LEFT, 5, -5) - self.canvas.set_style_border_width(1, 0) - self.canvas.set_style_bg_color(lv.color_white(), lv.PART.MAIN) - buffer = bytearray(220 * 100 * 4) - self.canvas.set_buffer(buffer, 220, 100, lv.COLOR_FORMAT.NATIVE) + + # --- BOTTOM FLEX BOX: GRAPH --- + + self.canvas_width = main_content.get_width() + self.canvas_height = 100 + + canvas_column = lv.obj(main_content) + canvas_column.set_flex_flow(lv.FLEX_FLOW.COLUMN) + canvas_column.set_style_pad_all(0, 0) + canvas_column.set_size(self.canvas_width, self.canvas_height) + + self.canvas = lv.canvas(canvas_column) + self.canvas.set_size(self.canvas_width, self.canvas_height) + buffer = bytearray(self.canvas_width * self.canvas_height * 4) + self.canvas.set_buffer( + buffer, self.canvas_width, self.canvas_height, lv.COLOR_FORMAT.NATIVE + ) + self.layer = lv.layer_t() self.canvas.init_layer(self.layer) - - self.setContentView(scr) + self.setContentView(main_content) def draw_line(self, color, x1, y1, x2, y2): dsc = lv.draw_line_dsc_t() lv.draw_line_dsc_t.init(dsc) dsc.color = color - dsc.width = 4 + dsc.width = 2 dsc.round_end = 1 dsc.round_start = 1 dsc.p1 = lv.point_precise_t() @@ -125,17 +115,47 @@ def draw_line(self, color, x1, y1, x2, y2): dsc.p2 = lv.point_precise_t() dsc.p2.x = x2 dsc.p2.y = y2 - lv.draw_line(self.layer,dsc) + lv.draw_line(self.layer, dsc) self.canvas.finish_layer(self.layer) + def draw_graph(self): + self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) + self.canvas.clean() + + w = self.canvas_width + h = self.canvas_height + + if len(self.history_v) < 2: + return + + v_range = max(MAX_VOLTAGE - MIN_VOLTAGE, 0.01) + + for i in range(1, len(self.history_v)): + x1 = int((i - 1) * w / HISTORY_LEN) + x2 = int(i * w / HISTORY_LEN) + + yv1 = h - int((self.history_v[i - 1] - MIN_VOLTAGE) / v_range * h) + yv2 = h - int((self.history_v[i] - MIN_VOLTAGE) / v_range * h) + + yp1 = h - int(self.history_p[i - 1] / 100 * h) + yp2 = h - int(self.history_p[i] / 100 * h) + + self.draw_line(DARKPINK, x1, yv1, x2, yv2) + self.draw_line(BLACK, x1, yp1, x2, yp2) + def onResume(self, screen): super().onResume(screen) def update(timer): + # --- DATE+TIME --- now = mpos.time.localtime() - + year, month, day = now[0], now[1], now[2] hour, minute, second = now[3], now[4], now[5] - date = f"{now[0]}-{now[1]:02}-{now[2]:02}" + self.lbl_datetime.set_text( + f"{year}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}" + ) + + # --- BATTERY VALUES --- if self.clear_cache_checkbox.get_state() & lv.STATE.CHECKED: # Get "real-time" values by clearing the cache before reading @@ -144,25 +164,27 @@ def update(timer): voltage = BatteryManager.read_battery_voltage() percent = BatteryManager.get_battery_percentage() - # --- TIME --- - self.lbl_time.set_text(f"{hour:02}:{minute:02}") - self.lbl_sec.set_text(f":{second:02}") - - # --- BATTERY VALUES --- - date += f"\n{voltage:.2f}V {percent:.0f}%" - date += f"\nRaw ADC: {BatteryManager.read_raw_adc()}" - self.lbl_text.set_text(date) - - # --- BATTERY ICON --- - fill_h = int((percent / 100) * (self.bat_size * 0.9)) - self.bat_fill.set_height(fill_h) + if percent > 80: + symbol = lv.SYMBOL.BATTERY_FULL + elif percent > 60: + symbol = lv.SYMBOL.BATTERY_3 + elif percent > 40: + symbol = lv.SYMBOL.BATTERY_2 + elif percent > 20: + symbol = lv.SYMBOL.BATTERY_1 + else: + symbol = lv.SYMBOL.BATTERY_EMPTY + self.lbl_battery.set_text(f"{symbol} {voltage:.2f}V {percent:.0f}%") if percent >= 30: - self.bat_fill.set_style_bg_color(lv.palette_main(lv.PALETTE.GREEN), 0) + bg_color = lv.PALETTE.GREEN else: - self.bat_fill.set_style_bg_color(lv.palette_main(lv.PALETTE.RED), 0) + bg_color = lv.PALETTE.RED + self.lbl_battery.set_style_text_color(lv.palette_main(bg_color), 0) + + self.lbl_battery_raw.set_text(f"Raw ADC: {BatteryManager.read_raw_adc()}") - # --- HISTORY --- + # --- HISTORY GRAPH --- self.history_v.append(voltage) self.history_p.append(percent) @@ -174,33 +196,6 @@ def update(timer): self.refresh_timer = lv.timer_create(update, 1000, None) - def draw_graph(self): - self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) - self.canvas.clean() - - w = self.canvas.get_width() - h = self.canvas.get_height() - - if len(self.history_v) < 2: - return - - v_min = 3.3 - v_max = 4.2 - v_range = max(v_max - v_min, 0.01) - - for i in range(1, len(self.history_v)): - x1 = int((i - 1) * w / HISTORY_LEN) - x2 = int(i * w / HISTORY_LEN) - - yv1 = h - int((self.history_v[i - 1] - v_min) / v_range * h) - yv2 = h - int((self.history_v[i] - v_min) / v_range * h) - - yp1 = h - int(self.history_p[i - 1] / 100 * h) - yp2 = h - int(self.history_p[i] / 100 * h) - - self.draw_line(DARKPINK, x1, yv1, x2, yv2) - self.draw_line(BLACK, x1, yp1, x2, yp2) - def onPause(self, screen): super().onPause(screen) if self.refresh_timer: From 0423e09522c0bfdb8882f86c6b1b6bb048ea54e4 Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Mon, 16 Feb 2026 21:42:14 +0100 Subject: [PATCH 045/317] Updates for ODROID-GO (#40) Setup the "Buzzer" and play intro and outro ;) Don't know if "I2S audio" is possible. Battery "settings": I tested to run ODROID-GO as long as it's possible. The min. raw ADC value on ODROID-GO i have seen is 210. So update the calculation. Fix the boot by moving ODROID-GO below `fri3d_2024` because the device will hard crash on `fail_save_i2c(sda=9, scl=18)` like: ``` MicroPythonOS 0.8.1 running lib/mpos/main.py matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 ? Try to I2C initialized on sda=39 scl=38 OK Attempt to write a single byte to I2C bus address 0x14... No device at this address: [Errno 116] ETIMEDOUT Attempt to write a single byte to I2C bus address 0x5d... No device at this address: [Errno 116] ETIMEDOUT waveshare_esp32_s3_touch_lcd_2 ? Try to I2C initialized on sda=48 scl=47 Failed: invalid pin m5stack_fire ? Try to I2C initialized on sda=21 scl=22 OK Attempt to write a single byte to I2C bus address 0x68... No device at this address: [Errno 19] ENODEV fri3d_2024 ? Try to I2C initialized on sda=9 scl=18 OK A fatal error occurred. The crash dump printed below may be used to help determine what caused it. If you are not already running the most recent version of MicroPython, consider upgrading. New versions often fix bugs. To learn more about how to debug and/or report this crash visit the wiki page at: https://github.com/micropython/micropython/wiki/ESP32-debugging LVGL MicroPython IDF version : v5.4 Machine : Generic ESP32 module with SPIRAM with ESP32 Guru Meditation Error: Core 1 panic'ed (LoadProhibited). Exception was unhandled. Core 1 register dump: PC : 0x401b04dd PS : 0x00060830 A0 : 0x801b0944 A1 : 0x3ffdb390 A2 : 0x3f80d2b0 A3 : 0x00000054 A4 : 0x3f8105e8 A5 : 0x3f54b240 A6 : 0x00000001 A7 : 0xaaaaae2a A8 : 0x00000019 A9 : 0x3ffdb370 A10 : 0xaaaaae2a A11 : 0x00000063 A12 : 0x3ffc7ccc A13 : 0x00000000 A14 : 0x3f4464f4 A15 : 0x00000001 SAR : 0x00000020 EXCCAUSE: 0x0000001c EXCVADDR: 0xaaaaae37 LBEG : 0x401d2964 LEND : 0x401d296d LCOUNT : 0x00000000 Backtrace: 0x401b04da:0x3ffdb390 0x401b0941:0x3ffdb3b0 0x40086719:0x3ffdb3d0 0x401a90da:0x3ffdb460 0x401b07ba:0x3ffdb490 0x40085de9:0x3ffdb4b0 0x401a90da:0x3ffdb540 0x401b07ba:0x3ffdb5b0 0x40085de9:0x3ffdb5d0 0x401a90da:0x3ffdb660 0x401b07ba:0x3ffdb690 0x401b083a:0x3ffdb6b0 0x401d35c1:0x3ffdb6f0 0x401d3809:0x3ffdb730 0x401b0919:0x3ffdb830 0x40085b59:0x3ffdb870 0x401a90da:0x3ffdb900 0x401b07ba:0x3ffdb970 0x401b07e2:0x3ffdb990 0x401e8d02:0x3ffdb9b0 0x401e90c9:0x3ffdba40 0x401c5b2d:0x3ffdba70 ``` --- .../lib/mpos/board/odroid_go.py | 40 ++++++++++++++----- internal_filesystem/lib/mpos/main.py | 20 +++++++--- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/odroid_go.py b/internal_filesystem/lib/mpos/board/odroid_go.py index b32d0767..4aac438d 100644 --- a/internal_filesystem/lib/mpos/board/odroid_go.py +++ b/internal_filesystem/lib/mpos/board/odroid_go.py @@ -12,9 +12,9 @@ import lvgl as lv import machine import mpos.ui -from machine import ADC, Pin +from machine import ADC, PWM, Pin from micropython import const -from mpos import InputManager +from mpos import AudioManager, BatteryManager, InputManager # Display settings: SPI_HOST = const(1) @@ -49,14 +49,26 @@ # Misc settings: LED_BLUE = const(2) BATTERY_PIN = const(36) -SPEAKER_ENABLE_PIN = const(25) -SPEAKER_PIN = const(26) + +# Buzzer +BUZZER_PIN = const(26) +BUZZER_DAC_PIN = const(25) +BUZZER_TONE_CHANNEL = const(0) print("odroid_go.py turn on blue LED") blue_led = machine.Pin(LED_BLUE, machine.Pin.OUT) blue_led.on() +print("odroid_go.py init buzzer") +buzzer = PWM(Pin(BUZZER_PIN, Pin.OUT, value=1), duty=5) +dac_pin = Pin(BUZZER_DAC_PIN, Pin.OUT, value=1) +dac_pin.value(1) # Unmute +AudioManager(i2s_pins=None, buzzer_instance=buzzer) +AudioManager.set_volume(40) +AudioManager.play_rtttl("Star Trek:o=4,d=20,b=200:8f.,a#,4d#6.,8d6,a#.,g.,c6.,4f6") +while AudioManager.is_playing(): + time.sleep(0.1) print("odroid_go.py machine.SPI.Bus() initialization") try: @@ -102,24 +114,23 @@ print("odroid_go.py Battery initialization...") -from mpos import BatteryManager def adc_to_voltage(raw_adc_value): """ The percentage calculation uses MIN_VOLTAGE = 3.15 and MAX_VOLTAGE = 4.15 - 0% at 3.15V -> raw_adc_value = 270 + 0% at 3.15V -> raw_adc_value = 210 100% at 4.15V -> raw_adc_value = 310 4.15 - 3.15 = 1V - 310 - 270 = 40 raw ADC steps + 310 - 210 = 100 raw ADC steps - So each raw ADC step is 1V / 40 = 0.025V + So each raw ADC step is 1V / 100 = 0.01V Offset calculation: - 270 * 0.025 = 6.75V. but we want it to be 3.15V - So the offset is 3.15V - 6.75V = -3.6V + 210 * 0.01 = 2.1V. but we want it to be 3.15V + So the offset is 3.15V - 2.1V = 1.05V """ - voltage = raw_adc_value * 0.025 - 3.6 + voltage = raw_adc_value * 0.01 + 1.05 return voltage @@ -198,6 +209,13 @@ def input_callback(indev, data): elif button_volume.value() == 0: print("Volume button pressed -> reset") blue_led.on() + AudioManager.play_rtttl( + "Outro:o=5,d=32,b=160,b=160:c6,b,a,g,f,e,d,c", + stream_type=AudioManager.STREAM_ALARM, + volume=40, + ) + while AudioManager.is_playing(): + time.sleep(0.1) machine.reset() elif button_select.value() == 0: current_key = lv.KEY.BACKSPACE diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index dc4270d0..16a73420 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -104,20 +104,28 @@ def detect_board(): if i2c0 := fail_save_i2c(sda=21, scl=22): if single_address_i2c_scan(i2c0, 0x68): # IMU (MPU6886) return "m5stack_fire" - + + import machine + unique_id_prefix = machine.unique_id()[0] + + print("odroid_go ?") + if unique_id_prefix == 0x30: + return "odroid_go" + print("fri3d_2024 ?") if i2c0 := fail_save_i2c(sda=9, scl=18): if single_address_i2c_scan(i2c0, 0x6B): # IMU (plus possibly the Communicator's LANA TNY at 0x38) return "fri3d_2024" - import machine - if machine.unique_id()[0] == 0xdc: # prototype board had: dc:b4:d9:0b:7d:80 + print("fri3d_2026 ?") + if unique_id_prefix == 0xDC: # prototype board had: dc:b4:d9:0b:7d:80 # or: if single_address_i2c_scan(i2c0, 0x6A): # IMU currently not installed on prototype board return "fri3d_2026" - print("odroid_go ?") - #if check_pins(0, 13, 27, 39): # not good because it matches other boards (like fri3d_2024 and fri3d_2026) - return "odroid_go" + raise Exception( + "Unknown ESP32-S3 board: couldn't detect known I2C devices or unique_id prefix" + ) + # EXECUTION STARTS HERE From e35c47ccddb8d1a7c0a1a6faff6bd1b1bb1e3874 Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Mon, 16 Feb 2026 21:45:56 +0100 Subject: [PATCH 046/317] Update board/m5stack_fire.py (#43) Init Buzzer and play a intro on startup. * Cleanup imports. * Use const() * Hard reset if `machine.SPI.Bus()` init not possible --- .../lib/mpos/board/m5stack_fire.py | 100 +++++++++++------- 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/m5stack_fire.py b/internal_filesystem/lib/mpos/board/m5stack_fire.py index 7da98c44..3f41a0f8 100644 --- a/internal_filesystem/lib/mpos/board/m5stack_fire.py +++ b/internal_filesystem/lib/mpos/board/m5stack_fire.py @@ -2,42 +2,66 @@ # Manufacturer's website at https://https://docs.m5stack.com/en/core/fire_v2.7 # Original author: https://github.com/ancebfer +import time + import drivers.display.ili9341 as ili9341 import lcd_bus -import machine - import lvgl as lv -import task_handler - +import machine import mpos.ui import mpos.ui.focus_direction -from mpos import InputManager - -# Pin configuration -SPI_BUS = 1 # SPI2 -SPI_FREQ = 40000000 -LCD_SCLK = 18 -LCD_MOSI = 23 -LCD_DC = 27 -LCD_CS = 14 -LCD_BL = 32 -LCD_RST = 33 -LCD_TYPE = 2 # ILI9341 type 2 - -TFT_HOR_RES=320 -TFT_VER_RES=240 - -spi_bus = machine.SPI.Bus( - host=SPI_BUS, - mosi=LCD_MOSI, - sck=LCD_SCLK -) -display_bus = lcd_bus.SPIBus( - spi_bus=spi_bus, - freq=SPI_FREQ, - dc=LCD_DC, - cs=LCD_CS -) +from machine import PWM, Pin +from micropython import const +from mpos import AudioManager, InputManager + +# Display settings: +SPI_BUS = const(1) # SPI2 +SPI_FREQ = const(40000000) + +LCD_SCLK = const(18) +LCD_MOSI = const(23) +LCD_DC = const(27) +LCD_CS = const(14) +LCD_BL = const(32) +LCD_RST = const(33) +LCD_TYPE = const(2) # ILI9341 type 2 + +TFT_HOR_RES = const(320) +TFT_VER_RES = const(240) + +# Button settings: +BUTTON_A = const(39) # A +BUTTON_B = const(38) # B +BUTTON_C = const(37) # C + +# Misc settings: +BATTERY_PIN = const(35) + +# Buzzer +BUZZER_PIN = const(25) + + +print("m5stack_fire.py init buzzer") +buzzer = PWM(Pin(BUZZER_PIN, Pin.OUT, value=1), duty=5) +AudioManager(i2s_pins=None, buzzer_instance=buzzer) +AudioManager.set_volume(40) +AudioManager.play_rtttl("Star Trek:o=4,d=20,b=200:8f.,a#,4d#6.,8d6,a#.,g.,c6.,4f6") +while AudioManager.is_playing(): + time.sleep(0.1) + + +print("m5stack_fire.py machine.SPI.Bus() initialization") +try: + spi_bus = machine.SPI.Bus(host=SPI_BUS, mosi=LCD_MOSI, sck=LCD_SCLK) +except Exception as e: + print(f"Error initializing SPI bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + + +display_bus = lcd_bus.SPIBus(spi_bus=spi_bus, freq=SPI_FREQ, dc=LCD_DC, cs=LCD_CS) + # M5Stack-Fire ILI9342 uses ILI9341 type 2 with a modified orientation table. class ILI9341(ili9341.ILI9341): @@ -45,9 +69,10 @@ class ILI9341(ili9341.ILI9341): 0x00, 0x40 | 0x20, # _MADCTL_MX | _MADCTL_MV 0x80 | 0x40, # _MADCTL_MY | _MADCTL_MX - 0x80 | 0x20 # _MADCTL_MY | _MADCTL_MV + 0x80 | 0x20, # _MADCTL_MY | _MADCTL_MV ) + mpos.ui.main_display = ILI9341( data_bus=display_bus, display_width=TFT_HOR_RES, @@ -58,7 +83,7 @@ class ILI9341(ili9341.ILI9341): reset_pin=LCD_RST, reset_state=ili9341.STATE_LOW, backlight_pin=LCD_BL, - backlight_on_state=ili9341.STATE_PWM + backlight_on_state=ili9341.STATE_PWM, ) mpos.ui.main_display.init(LCD_TYPE) mpos.ui.main_display.set_power(True) @@ -68,12 +93,9 @@ class ILI9341(ili9341.ILI9341): lv.init() # Button handling code: -from machine import Pin -import time - -btn_a = Pin(39, Pin.IN, Pin.PULL_UP) # A -btn_b = Pin(38, Pin.IN, Pin.PULL_UP) # B -btn_c = Pin(37, Pin.IN, Pin.PULL_UP) # C +btn_a = Pin(BUTTON_A, Pin.IN, Pin.PULL_UP) # A +btn_b = Pin(BUTTON_B, Pin.IN, Pin.PULL_UP) # B +btn_c = Pin(BUTTON_C, Pin.IN, Pin.PULL_UP) # C # Key repeat configuration # This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where From 7b33096eeb7d9cbd12bdb9b3da331001df730d42 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 16 Feb 2026 21:52:38 +0100 Subject: [PATCH 047/317] Fix build --- 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 60aaba0c..59328529 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -39,8 +39,8 @@ else fi echo "Check need to add adc_mic to $idfile" -if ! grep esp32-camera "$idfile"; then - echo "Adding esp32-camera to $idfile" +if ! grep adc_mic "$idfile"; then + echo "Adding adc_mic to $idfile" echo ' espressif/adc_mic: "*"' >> "$idfile" else echo "No need to add adc_mic to $idfile" From 024bb713f56e9d38549288d2592e715ed418367c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 16 Feb 2026 21:53:25 +0100 Subject: [PATCH 048/317] Update version --- .../com.micropythonos.showbattery/META-INF/MANIFEST.JSON | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON index a08ab91d..ae63afbf 100644 --- a/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.showbattery/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Minimal app", "long_description": "Demonstrates the simplest app.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/icons/com.micropythonos.showbattery_0.1.1_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/mpks/com.micropythonos.showbattery_0.1.1.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/icons/com.micropythonos.showbattery_0.2.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.showbattery/mpks/com.micropythonos.showbattery_0.2.1.mpk", "fullname": "com.micropythonos.showbattery", -"version": "0.2.0", +"version": "0.2.1", "category": "development", "activities": [ { From d64afd2cc580f6b969d70b2012e8fa8202c02f2b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 17 Feb 2026 21:29:38 +0100 Subject: [PATCH 049/317] Fix unit test --- internal_filesystem/lib/mpos/audio/audiomanager.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal_filesystem/lib/mpos/audio/audiomanager.py b/internal_filesystem/lib/mpos/audio/audiomanager.py index 35e0ce7c..4f5eaf42 100644 --- a/internal_filesystem/lib/mpos/audio/audiomanager.py +++ b/internal_filesystem/lib/mpos/audio/audiomanager.py @@ -42,7 +42,15 @@ def __init__(self, i2s_pins=None, buzzer_instance=None, adc_mic_pin=None): adc_mic_pin: GPIO pin number for ADC microphone (for ADC recording) """ if AudioManager._instance: + # If instance exists, update configuration if provided + if i2s_pins: + AudioManager._instance._i2s_pins = i2s_pins + if buzzer_instance: + AudioManager._instance._buzzer_instance = buzzer_instance + if adc_mic_pin: + AudioManager._instance._adc_mic_pin = adc_mic_pin return + AudioManager._instance = self self._i2s_pins = i2s_pins # I2S pin configuration dict (created per-stream) From 987e8f6a5bcd63dde23e4ab5237ee3736cb03c6f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 17 Feb 2026 21:32:40 +0100 Subject: [PATCH 050/317] Fix test --- tests/test_adc_recording.py | 386 +++++------------------------------- 1 file changed, 52 insertions(+), 334 deletions(-) diff --git a/tests/test_adc_recording.py b/tests/test_adc_recording.py index 59525164..ea95823f 100644 --- a/tests/test_adc_recording.py +++ b/tests/test_adc_recording.py @@ -9,362 +9,80 @@ # Add lib path for imports # In MicroPython, os.path doesn't exist, so we construct the path manually -test_dir = __file__.rsplit('/', 1)[0] if '/' in __file__ else '.' -lib_path = test_dir + '/../internal_filesystem/lib' -sys.path.insert(0, lib_path) +# This assumes the test is run from the project root or via unittest.sh +sys.path.append('MicroPythonOS/internal_filesystem/lib') -from mpos.audio.stream_record_adc import ADCRecordStream +from mpos import AudioManager - -class TestADCRecordStream(unittest.TestCase): - """Test ADCRecordStream with adaptive frequency control.""" +class TestADCRecording(unittest.TestCase): + """Test ADC recording functionality.""" def setUp(self): """Set up test fixtures.""" - self.test_dir = "data/test_adc" - self.test_file = f"{self.test_dir}/test_recording.wav" + self.test_file = "test_recording.wav" - # Create test directory - try: - os.makedirs(self.test_dir, exist_ok=True) - except: - pass + # Ensure AudioManager is initialized (mocking pins if needed) + # On desktop, it will use simulation mode + if not AudioManager._instance: + # Initialize with dummy values if needed, but adc_mic_pin is supported + AudioManager(adc_mic_pin=1) def tearDown(self): """Clean up test files.""" try: - if os.path.exists(self.test_file): - os.remove(self.test_file) + os.remove(self.test_file) except: pass - def test_adc_stream_initialization(self): - """Test ADCRecordStream initialization.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000, - adc_pin=2 - ) - - self.assertEqual(stream.file_path, self.test_file) - self.assertEqual(stream.duration_ms, 1000) - self.assertEqual(stream.sample_rate, 8000) - self.assertEqual(stream.adc_pin, 2) - self.assertFalse(stream.is_recording()) - - def test_adc_stream_defaults(self): - """Test ADCRecordStream default parameters.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=None, - sample_rate=None - ) - - self.assertEqual(stream.duration_ms, ADCRecordStream.DEFAULT_MAX_DURATION_MS) - self.assertEqual(stream.sample_rate, ADCRecordStream.DEFAULT_SAMPLE_RATE) - self.assertEqual(stream.adc_pin, ADCRecordStream.DEFAULT_ADC_PIN) - - def test_pi_controller_defaults(self): - """Test PI controller default parameters.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000 - ) - - self.assertEqual(stream.control_gain_p, ADCRecordStream.DEFAULT_CONTROL_GAIN_P) - self.assertEqual(stream.control_gain_i, ADCRecordStream.DEFAULT_CONTROL_GAIN_I) - self.assertEqual(stream.integral_windup_limit, ADCRecordStream.DEFAULT_INTEGRAL_WINDUP_LIMIT) - self.assertEqual(stream.adjustment_interval, ADCRecordStream.DEFAULT_ADJUSTMENT_INTERVAL) - self.assertEqual(stream.warmup_samples, ADCRecordStream.DEFAULT_WARMUP_SAMPLES) - - def test_custom_pi_parameters(self): - """Test custom PI controller parameters.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000, - control_gain_p=0.1, - control_gain_i=0.02, - integral_windup_limit=500, - adjustment_interval=500, - warmup_samples=1000 - ) - - self.assertEqual(stream.control_gain_p, 0.1) - self.assertEqual(stream.control_gain_i, 0.02) - self.assertEqual(stream.integral_windup_limit, 500) - self.assertEqual(stream.adjustment_interval, 500) - self.assertEqual(stream.warmup_samples, 1000) - - def test_wav_header_creation(self): - """Test WAV header generation.""" - header = ADCRecordStream._create_wav_header( - sample_rate=8000, - num_channels=1, - bits_per_sample=16, - data_size=16000 - ) + def test_record_wav_adc(self): + """Test recording a short WAV file using ADC.""" - # Check header size - self.assertEqual(len(header), 44) - - # Check RIFF signature - self.assertEqual(header[0:4], b'RIFF') - - # Check WAVE signature - self.assertEqual(header[8:12], b'WAVE') - - # Check fmt signature - self.assertEqual(header[12:16], b'fmt ') - - # Check data signature - self.assertEqual(header[36:40], b'data') - - def test_wav_header_sample_rate(self): - """Test WAV header contains correct sample rate.""" + # Record for 200ms + duration_ms = 200 sample_rate = 16000 - header = ADCRecordStream._create_wav_header( - sample_rate=sample_rate, - num_channels=1, - bits_per_sample=16, - data_size=32000 - ) - - # Sample rate is at offset 24-28 (little-endian) - header_sample_rate = int.from_bytes(header[24:28], 'little') - self.assertEqual(header_sample_rate, sample_rate) - - def test_wav_header_data_size(self): - """Test WAV header contains correct data size.""" - data_size = 32000 - header = ADCRecordStream._create_wav_header( - sample_rate=8000, - num_channels=1, - bits_per_sample=16, - data_size=data_size - ) - - # Data size is at offset 40-44 (little-endian) - header_data_size = int.from_bytes(header[40:44], 'little') - self.assertEqual(header_data_size, data_size) - - def test_sine_wave_generation(self): - """Test sine wave generation for desktop simulation.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000 - ) - - # Generate 1KB of sine wave - buf, num_samples = stream._generate_sine_wave_chunk(1024, 0) - - self.assertEqual(len(buf), 1024) - self.assertEqual(num_samples, 512) # 1024 bytes / 2 bytes per sample - - def test_sine_wave_phase_continuity(self): - """Test sine wave phase continuity across chunks.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000 - ) - # Generate two chunks - buf1, num_samples1 = stream._generate_sine_wave_chunk(1024, 0) - buf2, num_samples2 = stream._generate_sine_wave_chunk(1024, num_samples1) + print(f"Starting recording for {duration_ms}ms...") - # Both should have same number of samples - self.assertEqual(num_samples1, num_samples2) - - # Buffers should be different (different phase) - self.assertNotEqual(buf1, buf2) - - def test_stop_recording(self): - """Test stop() method.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=10000, - sample_rate=8000 + # Start recording + # Note: On desktop this will use the simulation mode in ADCRecordStream + success = AudioManager.record_wav_adc( + self.test_file, + duration_ms=duration_ms, + sample_rate=sample_rate ) - self.assertTrue(stream._keep_running) - stream.stop() - self.assertFalse(stream._keep_running) - - def test_elapsed_time_calculation(self): - """Test elapsed time calculation.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000 - ) + self.assertTrue(success, "AudioManager.record_wav_adc returned False") - # Simulate recording 1 second of audio - # 8000 samples * 2 bytes per sample = 16000 bytes - stream._bytes_recorded = 16000 - - elapsed_ms = stream.get_elapsed_ms() - self.assertEqual(elapsed_ms, 1000) - - def test_adaptive_control_disabled(self): - """Test creating stream with adaptive control disabled.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000, - adaptive_control=False - ) - - self.assertFalse(stream.adaptive_control) - - def test_gc_configuration(self): - """Test garbage collection configuration.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000, - gc_enabled=True, - gc_interval=3000 - ) - - self.assertTrue(stream.gc_enabled) - self.assertEqual(stream.gc_interval, 3000) - - def test_max_pending_samples(self): - """Test max pending samples buffer configuration.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000, - max_pending_samples=8192 - ) - - self.assertEqual(stream.max_pending_samples, 8192) - - def test_frequency_bounds(self): - """Test frequency bounds configuration.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000, - min_freq=5000, - max_freq=50000 - ) - - self.assertEqual(stream.min_freq, 5000) - self.assertEqual(stream.max_freq, 50000) - - def test_callback_overhead_offset(self): - """Test callback overhead offset configuration.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000, - callback_overhead_offset=3000 - ) - - self.assertEqual(stream.callback_overhead_offset, 3000) - # Initial frequency should be target sample rate (offset is only used if needed) - self.assertEqual(stream._current_freq, 8000) - - def test_on_complete_callback(self): - """Test on_complete callback is stored.""" - def callback(msg): - pass - - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000, - on_complete=callback - ) - - self.assertEqual(stream.on_complete, callback) - - def test_multiple_streams_independent(self): - """Test multiple ADCRecordStream instances are independent.""" - stream1 = ADCRecordStream( - file_path=f"{self.test_dir}/test1.wav", - duration_ms=1000, - sample_rate=8000 - ) - - stream2 = ADCRecordStream( - file_path=f"{self.test_dir}/test2.wav", - duration_ms=2000, - sample_rate=16000 - ) - - self.assertNotEqual(stream1.file_path, stream2.file_path) - self.assertNotEqual(stream1.duration_ms, stream2.duration_ms) - self.assertNotEqual(stream1.sample_rate, stream2.sample_rate) - - def test_pi_controller_state_initialization(self): - """Test PI controller state is properly initialized.""" - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000 - ) - - self.assertEqual(stream._sample_counter, 0) - self.assertEqual(stream._integral_error, 0.0) - self.assertFalse(stream._warmup_complete) - self.assertEqual(len(stream._adjustment_history), 0) - - def test_desktop_simulation_mode(self): - """Test desktop simulation mode (no machine module).""" - # This test verifies the stream can be created even without machine module - stream = ADCRecordStream( - file_path=self.test_file, - duration_ms=1000, - sample_rate=8000 - ) - - # Should not raise exception - self.assertIsNotNone(stream) - - -class TestADCIntegrationWithAudioManager(unittest.TestCase): - """Test ADC recording integration with AudioManager.""" - - def setUp(self): - """Set up test fixtures.""" - self.test_dir = "data/test_adc_manager" - self.test_file = f"{self.test_dir}/test_recording.wav" + # Wait for recording to finish (plus a buffer for thread startup/shutdown) + # Simulation mode might be slower or faster depending on system load + time.sleep(duration_ms / 1000.0 + 1.0) + # Verify file exists try: - os.makedirs(self.test_dir, exist_ok=True) - except: - pass - - def tearDown(self): - """Clean up test files.""" - try: - if os.path.exists(self.test_file): - os.remove(self.test_file) - except: - pass - - def test_adc_stream_import(self): - """Test ADCRecordStream can be imported.""" - try: - from mpos.audio.stream_record_adc import ADCRecordStream - self.assertIsNotNone(ADCRecordStream) - except ImportError as e: - self.fail(f"Failed to import ADCRecordStream: {e}") - - def test_audio_manager_has_adc_method(self): - """Test AudioManager has record_wav_adc method.""" - try: - from mpos import AudioManager - self.assertTrue(hasattr(AudioManager, 'record_wav_adc')) - except ImportError: - self.skipTest("AudioManager not available in test environment") - + st = os.stat(self.test_file) + file_size = st[6] + file_exists = True + except OSError: + file_exists = False + file_size = 0 + + self.assertTrue(file_exists, f"Recording file {self.test_file} was not created") + + # Verify file size is reasonable + # Header is 44 bytes + # 200ms at 16000Hz, 16-bit mono = 0.2 * 16000 * 2 = 6400 bytes + # Total should be around 6444 bytes + + expected_data_size = int(duration_ms / 1000.0 * sample_rate * 2) + expected_total_size = 44 + expected_data_size + + print(f"Created WAV file size: {file_size} bytes (Expected approx: {expected_total_size})") + + self.assertTrue(file_size > 44, "File contains only header or is empty") + + # Allow some margin of error for timing differences in test environment + # But it should have recorded *something* significant + self.assertTrue(file_size > 1000, f"File size {file_size} seems too small (expected ~{expected_total_size})") if __name__ == '__main__': unittest.main() From 8a54db91ef334d4559251aaaf667bdb666c7436f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 18 Feb 2026 15:46:28 +0100 Subject: [PATCH 051/317] Add lilygo_t_watch_s3_plus (untested) --- .../lib/mpos/board/lilygo_t_watch_s3_plus.py | 59 +++++++++++++++++++ internal_filesystem/lib/mpos/main.py | 8 +++ 2 files changed, 67 insertions(+) create mode 100644 internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py new file mode 100644 index 00000000..a11db159 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py @@ -0,0 +1,59 @@ +print("lilygo_t_watch_s3_plus.py initialization") +# Manufacturer's website at https://lilygo.cc/products/t-watch-s3-plus +import lcd_bus +import machine +import i2c + +import lvgl as lv +import task_handler + +import drivers.display.st7789 as st7789 + +import mpos.ui + +spi_bus = machine.SPI.Bus( + host=2, + mosi=13, + sck=18 +) +display_bus = lcd_bus.SPIBus( + spi_bus=spi_bus, + freq=40000000, + dc=38, + cs=12, +) + +_BUFFER_SIZE = const(28800) +fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) +fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) + +mpos.ui.main_display = st7789.ST7789( + data_bus=display_bus, + frame_buffer1=fb1, + frame_buffer2=fb2, + display_width=240, + display_height=240, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=st7789.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + backlight_pin=45, + backlight_on_state=st7789.STATE_PWM, +) +mpos.ui.main_display.init() +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_backlight(100) + +# TODO: +# Touch handling: +#import drivers.indev.cst816s as cst816s +#i2c_bus = i2c.I2C.Bus(host=0, scl=40, sda=39, freq=400000, use_locks=False) +#touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=0x15, reg_bits=8) +#indev=cst816s.CST816S(touch_dev) + +lv.init() + +# TODO: +# - battery +# - IMU + +print("lilygo_t_watch_s3_plus.py finished") diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 16a73420..40778bc8 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -122,6 +122,14 @@ def detect_board(): # or: if single_address_i2c_scan(i2c0, 0x6A): # IMU currently not installed on prototype board return "fri3d_2026" + print("qemu ?") + if unique_id_prefix == 0x10: + return "qemu" + + if i2c0 := fail_save_i2c(sda=10, scl=11): + if single_address_i2c_scan(i2c0, 0x20): # IMU + return "lilygo_t_watch_s3_plus" + raise Exception( "Unknown ESP32-S3 board: couldn't detect known I2C devices or unique_id prefix" ) From 9faf5e47be9b9f89af88796767ceca63ec76ebc4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 18 Feb 2026 16:55:34 +0100 Subject: [PATCH 052/317] About app: add network info --- .../com.micropythonos.about/assets/about.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 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 765b7bbd..f8c1e6ef 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py @@ -104,6 +104,23 @@ def onCreate(self): except Exception as e: self.logger.warning(f"Could not get ESP32 hardware info: {e}") + # Machine info + try: + self.logger.info("Trying to find out additional board info, not available on every platform...") + self._add_label(screen, f"{lv.SYMBOL.POWER} Machine Info", is_header=True) + import machine + self._add_label(screen, f"machine.freq: {machine.freq()}") + # Format unique_id as MAC address (AA:BB:CC:DD:EE:FF) + unique_id = machine.unique_id() + mac_address = ':'.join(f'{b:02X}' for b in unique_id) + self._add_label(screen, f"machine.unique_id(): {mac_address}") + self._add_label(screen, f"machine.wake_reason(): {machine.wake_reason()}") + self._add_label(screen, f"machine.reset_cause(): {machine.reset_cause()}") + except Exception as e: + error = f"Could not find machine info because: {e}\nIt's normal to get this error on desktop." + self.logger.warning(error) + self._add_label(screen, error) + # Partition info (ESP32 only) try: self._add_label(screen, f"{lv.SYMBOL.SD_CARD} Partition Info", is_header=True) @@ -117,23 +134,18 @@ def onCreate(self): self.logger.warning(error) self._add_label(screen, error) - # Machine info + # Network info (ESP32 only) try: - self.logger.info("Trying to find out additional board info, not available on every platform...") - self._add_label(screen, f"{lv.SYMBOL.POWER} Machine Info", is_header=True) - import machine - self._add_label(screen, f"machine.freq: {machine.freq()}") - # Format unique_id as MAC address (AA:BB:CC:DD:EE:FF) - unique_id = machine.unique_id() - mac_address = ':'.join(f'{b:02X}' for b in unique_id) - self._add_label(screen, f"machine.unique_id(): {mac_address}") - self._add_label(screen, f"machine.wake_reason(): {machine.wake_reason()}") - self._add_label(screen, f"machine.reset_cause(): {machine.reset_cause()}") + self._add_label(screen, f"{lv.SYMBOL.WIFI} Network Info", is_header=True) + from mpos import WifiService + self._add_label(screen, f"IPv4 Address: {WifiService.get_ipv4_address()}") + self._add_label(screen, f"IPv4 Gateway: {WifiService.get_ipv4_gateway()}") except Exception as e: - error = f"Could not find machine info because: {e}\nIt's normal to get this error on desktop." + error = f"Could not find network info because: {e}" self.logger.warning(error) self._add_label(screen, error) + # Freezefs info (production builds only) try: self.logger.info("Trying to find out freezefs info") From eca3a08fd9a94469f6311f9ef2b3bcca70b45b77 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 18 Feb 2026 18:10:14 +0100 Subject: [PATCH 053/317] build_mpos.sh: add esp32s3_qemu target --- scripts/build_mpos.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 59328529..fa437692 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -14,6 +14,7 @@ if [ -z "$target" ]; then echo "Example: $0 macOS" echo "Example: $0 esp32" echo "Example: $0 esp32s3" + echo "Example: $0 esp32s3_qemu" exit 1 fi @@ -86,13 +87,19 @@ ln -sf ../../c_mpos "$codebasedir"/lvgl_micropython/ext_mod/c_mpos echo "Refreshing freezefs..." "$codebasedir"/scripts/freezefs_mount_builtin.sh -if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then +if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "esp32s3_qemu" ]; then + extra_configs="" if [ "$target" == "esp32" ]; then BOARD=ESP32_GENERIC BOARD_VARIANT=SPIRAM - else # esp32s3 + else # esp32s3 or esp32s3_qemu BOARD=ESP32_GENERIC_S3 BOARD_VARIANT=SPIRAM_OCT + if [ "$target" == "esp32s3_qemu" ]; then + # CONFIG_ESPTOOLPY_FLASHMODE_DIO because QIO has an "off by 2 bytes" bug in qemu + # CONFIG_MBEDTLS_HARDWARE_* because these have bugs in qemu due to warning: [AES] Error reading from GDMA buffer + extra_configs="CONFIG_ESPTOOLPY_FLASHMODE_DIO=y CONFIG_MBEDTLS_HARDWARE_AES=n CONFIG_MBEDTLS_HARDWARE_SHA=n CONFIG_MBEDTLS_HARDWARE_MPI=n" + fi fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage @@ -120,6 +127,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then CONFIG_FREERTOS_USE_TRACE_FACILITY=y \ CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y \ CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y \ + $extra_configs \ "$frozenmanifest" popd From 8c087a988f74f82a731773c4cfecf74a2daf0018 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 18 Feb 2026 18:11:23 +0100 Subject: [PATCH 054/317] DownloadManager: make certificates explicit --- internal_filesystem/lib/mpos/net/download_manager.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/net/download_manager.py b/internal_filesystem/lib/mpos/net/download_manager.py index ca5e3b8f..2bc30843 100644 --- a/internal_filesystem/lib/mpos/net/download_manager.py +++ b/internal_filesystem/lib/mpos/net/download_manager.py @@ -105,6 +105,9 @@ async def _download_url_async(cls, url, outfile=None, total_size=None, print("DownloadManager: aiohttp not available") raise ImportError("aiohttp module not available") + import ssl + sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + sslctx.verify_mode = ssl.CERT_OPTIONAL # CERT_REQUIRED might fail because MBEDTLS_ERR_SSL_CA_CHAIN_REQUIRED session = aiohttp.ClientSession() print("DownloadManager: Created new aiohttp session") print(f"DownloadManager: Downloading {url}") @@ -115,7 +118,7 @@ async def _download_url_async(cls, url, outfile=None, total_size=None, if headers is None: headers = {} - async with session.get(url, headers=headers) as response: + async with session.get(url, headers=headers, ssl=sslctx) as response: if response.status < 200 or response.status >= 400: print(f"DownloadManager: HTTP error {response.status}") raise RuntimeError(f"HTTP {response.status}") From be89ba0dced37845cdde6bc5d82280bdfb9217d3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 18 Feb 2026 18:11:45 +0100 Subject: [PATCH 055/317] mklittlefs: use internal_filesystem by default --- scripts/mklittlefs.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/mklittlefs.sh b/scripts/mklittlefs.sh index a9d0aa78..ead0f2c1 100755 --- a/scripts/mklittlefs.sh +++ b/scripts/mklittlefs.sh @@ -6,6 +6,8 @@ mydir=$(dirname "$mydir") #size=0x200000 # 2MB #~/sources/mklittlefs/mklittlefs -c "$mydir"/../internal_filesystem/ -s "$size" internal_filesystem.bin -size=0x520000 -~/sources/mklittlefs/mklittlefs -c "$mydir"/../../../internalsd_zips_removed_gb_romart -s "$size" internalsd_zips_removed_gb_romart.bin +#size=0x520000 +#~/sources/mklittlefs/mklittlefs -c "$mydir"/../../../internalsd_zips_removed_gb_romart -s "$size" internalsd_zips_removed_gb_romart.bin +size=0x520000 +~/sources/mklittlefs/mklittlefs -c "$mydir"/../internal_filesystem/ -s "$size" internal_filesystem.bin From 0b2368464bf48fc7ee4c766a8eff864b9e646ba1 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 18 Feb 2026 18:12:11 +0100 Subject: [PATCH 056/317] WifiService: add functions for IP address --- .../lib/mpos/net/wifi_service.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 314f4357..9e9468b6 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -308,6 +308,45 @@ def is_connected(network_module=None): print(f"WifiService: Error checking connection: {e}") return False + + @staticmethod + def get_ipv4_address(network_module=None): + # If WiFi operations are in progress, report not connected + if WifiService.wifi_busy: + return None + + # Desktop mode - always report connected + if not HAS_NETWORK_MODULE and network_module is None: + return "123.456.789.000" + + # Check actual connection status + try: + net = network_module if network_module else network + wlan = net.WLAN(net.STA_IF) + return wlan.ipconfig("addr4") + except Exception as e: + print(f"WifiService: Error retrieving ip4v address: {e}") + return None + + @staticmethod + def get_ipv4_gateway(network_module=None): + # If WiFi operations are in progress, report not connected + if WifiService.wifi_busy: + return None + + # Desktop mode - always report connected + if not HAS_NETWORK_MODULE and network_module is None: + return "000.123.456.789" + + # Check actual connection status + try: + net = network_module if network_module else network + wlan = net.WLAN(net.STA_IF) + return wlan.ipconfig("gw4") + except Exception as e: + print(f"WifiService: Error retrieving ip4v gateway: {e}") + return None + @staticmethod def disconnect(network_module=None): """ From 403cd8d4cd745f68d21364430d1b69f5ac2a51c9 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 18 Feb 2026 18:12:42 +0100 Subject: [PATCH 057/317] Add qemu board --- internal_filesystem/lib/mpos/board/qemu.py | 134 +++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 internal_filesystem/lib/mpos/board/qemu.py diff --git a/internal_filesystem/lib/mpos/board/qemu.py b/internal_filesystem/lib/mpos/board/qemu.py new file mode 100644 index 00000000..82424288 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/qemu.py @@ -0,0 +1,134 @@ +print("qemu.py running") + +import lcd_bus +import lvgl as lv +import machine +import time + +import mpos.ui + +print("qemu.py display bus initialization") +try: + display_bus = lcd_bus.I80Bus( + dc=7, + wr=8, + cs=6, + data0=39, + data1=40, + data2=41, + data3=42, + data4=45, + data5=46, + data6=47, + data7=48, + reverse_color_bits=False # doesnt seem to do anything? + ) +except Exception as e: + print(f"Error initializing display bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + +_BUFFER_SIZE = const(28800) +fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) +fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) + +import drivers.display.st7789 as st7789 +# 320x200 => make 320x240 screenshot => it's 240x200 (but the display shows more than 200) +mpos.ui.main_display = st7789.ST7789( + data_bus=display_bus, + frame_buffer1=fb1, + frame_buffer2=fb2, + display_width=170, # emulator st7789.c has 135 + display_height=320, # emulator st7789.c has 240 + color_space=lv.COLOR_FORMAT.RGB565, + #color_space=lv.COLOR_FORMAT.RGB888, + color_byte_order=st7789.BYTE_ORDER_RGB, + # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 + reset_pin=5, + backlight_pin=38, + backlight_on_state=st7789.STATE_PWM, +) +mpos.ui.main_display.init() +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_backlight(100) + +lv.init() +#mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling +mpos.ui.main_display.set_color_inversion(True) # doesnt seem to do anything? + +# Button handling code: +from machine import Pin +btn_a = Pin(0, Pin.IN, Pin.PULL_UP) # 1 +btn_b = Pin(14, Pin.IN, Pin.PULL_UP) # 2 +btn_c = Pin(3, Pin.IN, Pin.PULL_UP) # 3 + +# Key repeat configuration +# This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where +# the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus. +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +last_key = None +last_state = lv.INDEV_STATE.RELEASED +key_press_start = 0 # Time when key was first pressed +last_repeat_time = 0 # Time of last repeat event + +# Read callback +# Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, +# that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. +def keypad_read_cb(indev, data): + global last_key, last_state, key_press_start, last_repeat_time + since_last_repeat = 0 + + # Check buttons + current_key = None + current_time = time.ticks_ms() + if btn_a.value() == 0: + current_key = lv.KEY.PREV + elif btn_b.value() == 0: + current_key = lv.KEY.ENTER + elif btn_c.value() == 0: + current_key = lv.KEY.NEXT + + if (btn_a.value() == 0) and (btn_c.value() == 0): + current_key = lv.KEY.ESC + + if current_key: + if current_key != last_key: + # New key press + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + last_key = current_key + last_state = lv.INDEV_STATE.PRESSED + key_press_start = current_time + last_repeat_time = current_time + else: + # No key pressed + data.key = last_key if last_key else lv.KEY.ENTER + data.state = lv.INDEV_STATE.RELEASED + last_key = None + last_state = lv.INDEV_STATE.RELEASED + key_press_start = 0 + last_repeat_time = 0 + + # Handle ESC for back navigation (only on initial PRESSED) + if last_state == lv.INDEV_STATE.PRESSED: + if current_key == lv.KEY.ESC: + mpos.ui.back_screen() + + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(keypad_read_cb) +indev.set_group(group) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +from mpos import InputManager +InputManager.register_indev(indev) + +print("qemu.py finished") From e193f9c2ef376ccf029608bdf13ef8e96bb4c0ad Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 20 Feb 2026 15:36:54 +0100 Subject: [PATCH 058/317] Fix adc_mic hang issue --- scripts/build_mpos.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index fa437692..96220efc 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -119,6 +119,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "esp32s3_qem # CONFIG_FREERTOS_USE_TRACE_FACILITY=y # CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y + # CONFIG_ADC_MIC_TASK_CORE=1 because with the default (-1) it hangs the CPU python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake \ @@ -127,6 +128,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "esp32s3_qem CONFIG_FREERTOS_USE_TRACE_FACILITY=y \ CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y \ CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y \ + CONFIG_ADC_MIC_TASK_CORE=1 \ $extra_configs \ "$frozenmanifest" From 9e52f8649fc38626753845158d89c031276f0ea5 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 20 Feb 2026 15:37:20 +0100 Subject: [PATCH 059/317] fri3d_2024: adapt to new sdcard API --- internal_filesystem/lib/mpos/board/fri3d_2024.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 38ededa0..77dafb11 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -289,7 +289,7 @@ def adc_to_voltage(adc_value): BatteryManager.init_adc(13, adc_to_voltage) import mpos.sdcard -mpos.sdcard.init(spi_bus, cs_pin=14) +mpos.sdcard.init(spi_bus=spi_bus, cs_pin=14) # === AUDIO HARDWARE === from machine import PWM, Pin From 7a2cccb51ebadf966a55e161ac6904a666eaa7fd Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 20 Feb 2026 15:38:04 +0100 Subject: [PATCH 060/317] fri3d_2026: disable sound by default for debugging --- .../lib/mpos/board/fri3d_2026.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py index 76029fb0..12e688a5 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2026.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -15,7 +15,6 @@ from machine import Pin, SPI, SDCard import lcd_bus -import machine import i2c import math @@ -35,6 +34,7 @@ TFT_HOR_RES=320 TFT_VER_RES=240 +import machine spi_bus = machine.SPI.Bus( host=2, mosi=6, @@ -43,7 +43,7 @@ ) display_bus = lcd_bus.SPIBus( spi_bus=spi_bus, - freq=40000000, + freq=40000000, # 40 Mhz dc=4, cs=5 ) @@ -190,16 +190,11 @@ def keypad_read_cb(indev, data): # Battery voltage ADC measuring: sits on PC0 of CH32X035GxUx from mpos import BatteryManager def adc_to_voltage(adc_value): - """ - Convert raw ADC value to battery voltage using calibrated linear function. - Calibration data shows linear relationship: voltage = -0.0016237 * adc + 8.2035 - This is ~10x more accurate than simple scaling (error ~0.01V vs ~0.1V). - """ return (0.001651* adc_value + 0.08709) #BatteryManager.init_adc(13, adc_to_voltage) # TODO import mpos.sdcard -mpos.sdcard.init(spi_bus, cs_pin=14) +mpos.sdcard.init(spi_bus=spi_bus, cs_pin=14) # === AUDIO HARDWARE === from machine import PWM, Pin @@ -220,7 +215,7 @@ def adc_to_voltage(adc_value): 'sd': 16, # Serial Data OUT (speaker/DAC) 'sck_in': 17, # SCLK - Serial Clock for microphone input (optional for audio out) } - +''' # This is how it should be (untested) i2s_pins = { # Output (DAC/speaker) pins @@ -229,10 +224,10 @@ def adc_to_voltage(adc_value): 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) 'sd': 16, # Serial Data OUT (speaker/DAC) } - +''' # Initialize AudioManager with I2S (buzzer TODO) # ADC microphone is on GPIO 1 -AudioManager(i2s_pins=i2s_pins, adc_mic_pin=1) +#AudioManager(i2s_pins=i2s_pins, adc_mic_pin=1) # === SENSOR HARDWARE === from mpos import SensorManager @@ -252,10 +247,6 @@ def adc_to_voltage(adc_value): import _thread def startup_wow_effect(): - """ - Epic startup effect with rainbow LED chase and upbeat startup jingle. - Runs in background thread to avoid blocking boot. - """ try: # Startup jingle: Happy upbeat sequence (ascending scale with flourish) startup_jingle = "Startup:d=8,o=6,b=200:c,d,e,g,4c7,4e,4c7" From 13891fe36383247cdb8cf388e2bdfc859624a3cb Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 20 Feb 2026 15:39:09 +0100 Subject: [PATCH 061/317] Add lilygo_t_display_s3 (unfinished) --- .../lib/mpos/board/lilygo_t_display_s3.py | 134 ++++++++++++++++++ internal_filesystem/lib/mpos/main.py | 16 ++- 2 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py new file mode 100644 index 00000000..7ed84bf4 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -0,0 +1,134 @@ +print("lilygo_t_display_s3.py running again") + +import lcd_bus +import lvgl as lv +import machine +import time + +import mpos.ui + +print("lilygo_t_display_s3.py display bus initialization") +try: + display_bus = lcd_bus.I80Bus( + dc=7, + wr=8, + cs=6, + data0=39, + data1=40, + data2=41, + data3=42, + data4=45, + data5=46, + data6=47, + data7=48, + reverse_color_bits=False # doesnt seem to do anything? + ) +except Exception as e: + print(f"Error initializing display bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + +_BUFFER_SIZE = const(28800) +fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) +fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) + +import drivers.display.st7789 as st7789 +mpos.ui.main_display = st7789.ST7789( + data_bus=display_bus, + frame_buffer1=fb1, + frame_buffer2=fb2, + display_width=170, # emulator st7789.c has 135 + display_height=320, # emulator st7789.c has 240 + color_space=lv.COLOR_FORMAT.RGB565, + #color_space=lv.COLOR_FORMAT.RGB888, + color_byte_order=st7789.BYTE_ORDER_RGB, + # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 + power_pin=15, + reset_pin=5, + backlight_pin=38, + backlight_on_state=st7789.STATE_PWM, +) +mpos.ui.main_display.init() +mpos.ui.main_display.set_power(True) +mpos.ui.main_display.set_backlight(100) + +lv.init() +#mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling +mpos.ui.main_display.set_color_inversion(True) # doesnt seem to do anything? + +# Button handling code: +from machine import Pin +btn_a = Pin(0, Pin.IN, Pin.PULL_UP) # 1 +btn_b = Pin(14, Pin.IN, Pin.PULL_UP) # 2 +btn_c = Pin(3, Pin.IN, Pin.PULL_UP) # 3 + +# Key repeat configuration +# This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where +# the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus. +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +last_key = None +last_state = lv.INDEV_STATE.RELEASED +key_press_start = 0 # Time when key was first pressed +last_repeat_time = 0 # Time of last repeat event + +# Read callback +# Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, +# that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. +def keypad_read_cb(indev, data): + global last_key, last_state, key_press_start, last_repeat_time + since_last_repeat = 0 + + # Check buttons + current_key = None + current_time = time.ticks_ms() + if btn_a.value() == 0: + current_key = lv.KEY.PREV + elif btn_b.value() == 0: + current_key = lv.KEY.ENTER + elif btn_c.value() == 0: + current_key = lv.KEY.NEXT + + if (btn_a.value() == 0) and (btn_c.value() == 0): + current_key = lv.KEY.ESC + + if current_key: + if current_key != last_key: + # New key press + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + last_key = current_key + last_state = lv.INDEV_STATE.PRESSED + key_press_start = current_time + last_repeat_time = current_time + else: + # No key pressed + data.key = last_key if last_key else lv.KEY.ENTER + data.state = lv.INDEV_STATE.RELEASED + last_key = None + last_state = lv.INDEV_STATE.RELEASED + key_press_start = 0 + last_repeat_time = 0 + + # Handle ESC for back navigation (only on initial PRESSED) + #if last_state == lv.INDEV_STATE.PRESSED: + # if current_key == lv.KEY.ESC: + # mpos.ui.back_screen() + + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(keypad_read_cb) +indev.set_group(group) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +from mpos import InputManager +InputManager.register_indev(indev) + +print("lilygo_t_display_s3.py finished") diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 40778bc8..90273f9e 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -126,22 +126,25 @@ def detect_board(): if unique_id_prefix == 0x10: return "qemu" + print("lilygo_t_display_s3 ?") + if unique_id_prefix == 0xc0: + return "lilygo_t_display_s3" + if i2c0 := fail_save_i2c(sda=10, scl=11): if single_address_i2c_scan(i2c0, 0x20): # IMU return "lilygo_t_watch_s3_plus" - raise Exception( - "Unknown ESP32-S3 board: couldn't detect known I2C devices or unique_id prefix" - ) + print("Unknown board: couldn't detect known I2C devices or unique_id prefix") # EXECUTION STARTS HERE print(f"MicroPythonOS {BuildInfo.version.release} running lib/mpos/main.py") board = detect_board() -print(f"Detected {board} system, importing mpos.board.{board}") -DeviceInfo.set_hardware_id(board) -__import__(f"mpos.board.{board}") +if board: + print(f"Detected {board} system, importing mpos.board.{board}") + DeviceInfo.set_hardware_id(board) + __import__(f"mpos.board.{board}") # Allow LVGL M:/path/to/file or M:relative/path/to/file to work for image set_src etc import mpos.fs_driver @@ -223,6 +226,7 @@ async def 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: From d00a4ca46841569cf51d1702e868dd49fff8dbee Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 20 Feb 2026 15:42:59 +0100 Subject: [PATCH 062/317] qemu: simplify for now --- internal_filesystem/lib/mpos/board/qemu.py | 32 +++++++++++----------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/qemu.py b/internal_filesystem/lib/mpos/board/qemu.py index 82424288..f50e2afa 100644 --- a/internal_filesystem/lib/mpos/board/qemu.py +++ b/internal_filesystem/lib/mpos/board/qemu.py @@ -39,10 +39,9 @@ data_bus=display_bus, frame_buffer1=fb1, frame_buffer2=fb2, - display_width=170, # emulator st7789.c has 135 - display_height=320, # emulator st7789.c has 240 + display_width=170, + display_height=320, color_space=lv.COLOR_FORMAT.RGB565, - #color_space=lv.COLOR_FORMAT.RGB888, color_byte_order=st7789.BYTE_ORDER_RGB, # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 reset_pin=5, @@ -70,14 +69,14 @@ REPEAT_RATE_MS = 100 # Interval between repeats last_key = None last_state = lv.INDEV_STATE.RELEASED -key_press_start = 0 # Time when key was first pressed -last_repeat_time = 0 # Time of last repeat event +#key_press_start = 0 # Time when key was first pressed +#last_repeat_time = 0 # Time of last repeat event # Read callback # Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, # that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. def keypad_read_cb(indev, data): - global last_key, last_state, key_press_start, last_repeat_time + global last_key, last_state #, key_press_start, last_repeat_time since_last_repeat = 0 # Check buttons @@ -98,23 +97,24 @@ def keypad_read_cb(indev, data): # New key press data.key = current_key data.state = lv.INDEV_STATE.PRESSED - last_key = current_key - last_state = lv.INDEV_STATE.PRESSED - key_press_start = current_time - last_repeat_time = current_time + last_key = data.key + last_state = data.state + #key_press_start = current_time + #last_repeat_time = current_time + else: + print(f"should {current_key} be repeated?") else: # No key pressed data.key = last_key if last_key else lv.KEY.ENTER data.state = lv.INDEV_STATE.RELEASED last_key = None - last_state = lv.INDEV_STATE.RELEASED - key_press_start = 0 - last_repeat_time = 0 + last_state = data.state + #key_press_start = 0 + #last_repeat_time = 0 # Handle ESC for back navigation (only on initial PRESSED) - if last_state == lv.INDEV_STATE.PRESSED: - if current_key == lv.KEY.ESC: - mpos.ui.back_screen() + if data.state == lv.INDEV_STATE.PRESSED and data.key == lv.KEY.ESC: + mpos.ui.back_screen() group = lv.group_create() From 965df2545ce8d262785aa7288c34fad1bcc1ee28 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 20 Feb 2026 16:09:38 +0100 Subject: [PATCH 063/317] fri3d_2026: fix audio --- .../lib/mpos/audio/stream_wav.py | 35 ++++++++++++------- .../lib/mpos/board/fri3d_2026.py | 17 +++------ internal_filesystem/lib/mpos/main.py | 2 +- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index c2a672c4..e84f254e 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -329,7 +329,6 @@ def play(self): # Decide playback rate (force >=22050 Hz) - but why?! the DAC should support down to 8kHz! target_rate = 22050 # slower is faster (less data) - target_rate = 22050 * 2 # CJC only supports 30kHz and up? if original_rate >= target_rate: playback_rate = original_rate upsample_factor = 1 @@ -365,17 +364,29 @@ def play(self): print(f"MCLK PWM init failed: {e}") # fallback or error handling - self._i2s = machine.I2S( - 0, - sck=machine.Pin(self.i2s_pins['sck'], machine.Pin.OUT), - ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT), - sd=machine.Pin(self.i2s_pins['sd'], machine.Pin.OUT), - mode=machine.I2S.TX, - bits=16, - format=i2s_format, - rate=playback_rate, - ibuf=32000 - ) + if self.i2s_pins.get("sck"): + self._i2s = machine.I2S( + 0, + sck=machine.Pin(self.i2s_pins['sck'], machine.Pin.OUT), + ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT), + sd=machine.Pin(self.i2s_pins['sd'], machine.Pin.OUT), + mode=machine.I2S.TX, + bits=16, + format=i2s_format, + rate=playback_rate, + ibuf=32000 + ) + else: + self._i2s = machine.I2S( + 0, + ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT), + sd=machine.Pin(self.i2s_pins['sd'], machine.Pin.OUT), + mode=machine.I2S.TX, + bits=16, + format=i2s_format, + rate=playback_rate, + ibuf=32000 + ) except Exception as e: print(f"WAVStream: I2S init failed: {e}") return diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py index 12e688a5..15313b26 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2026.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -198,7 +198,6 @@ def adc_to_voltage(adc_value): # === AUDIO HARDWARE === from machine import PWM, Pin -from mpos import AudioManager # Initialize buzzer: now sits on PC14/CC1 of the CH32X035GxUx so needs custom code #buzzer = PWM(Pin(46), freq=550, duty=0) @@ -208,26 +207,18 @@ def adc_to_voltage(adc_value): # 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 -# This worked briefly, way too loud and with distortion: -i2s_pins = { - 'sck': 2, # SCLK or BCLK (optional) - 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) - 'sd': 16, # Serial Data OUT (speaker/DAC) - 'sck_in': 17, # SCLK - Serial Clock for microphone input (optional for audio out) -} -''' -# This is how it should be (untested) i2s_pins = { # Output (DAC/speaker) pins 'mck': 2, # MCLK (mandatory) - #'sck': 17, # SCLK aka BCLK (optional) + 'sck': 17, # SCLK aka BCLK (optional) 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) 'sd': 16, # Serial Data OUT (speaker/DAC) } -''' + # Initialize AudioManager with I2S (buzzer TODO) # ADC microphone is on GPIO 1 -#AudioManager(i2s_pins=i2s_pins, adc_mic_pin=1) +from mpos import AudioManager +AudioManager(i2s_pins=i2s_pins, adc_mic_pin=1) # === SENSOR HARDWARE === from mpos import SensorManager diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 90273f9e..d8eaaf36 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -132,7 +132,7 @@ def detect_board(): if i2c0 := fail_save_i2c(sda=10, scl=11): if single_address_i2c_scan(i2c0, 0x20): # IMU - return "lilygo_t_watch_s3_plus" + return "lilygo_t_watch_s3_plus" # example MAC address: D0:CF:13:33:36:306 print("Unknown board: couldn't detect known I2C devices or unique_id prefix") From 4826063f36b36c5f1e309b5417afc979420ccef5 Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Sat, 21 Feb 2026 07:38:00 +0100 Subject: [PATCH 064/317] WIP: New app: "Scan Bluetooth" (#58) Just display a table and list all nearby Bluetooth devices --- .../META-INF/MANIFEST.JSON | 24 +++ .../assets/scan_bluetooth.py | 142 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 5992 bytes 3 files changed, 166 insertions(+) create mode 100644 internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py create mode 100644 internal_filesystem/apps/com.micropythonos.scan_bluetooth/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..bc61ab72 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "ScanBluetooth", +"publisher": "MicroPythonOS", +"short_description": "Scan Bluetooth", +"long_description": "Lists all nearby Bluetooth devices with some information", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.scan_bluetooth/icons/com.micropythonos.scan_bluetooth_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.scan_bluetooth/mpks/com.micropythonos.scan_bluetooth_0.0.1.mpk", +"fullname": "com.micropythonos.scan_bluetooth", +"version": "0.0.1", +"category": "development", +"activities": [ + { + "entrypoint": "assets/scan_bluetooth.py", + "classname": "ScanBluetooth", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py new file mode 100644 index 00000000..6b894b46 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py @@ -0,0 +1,142 @@ +""" +Initial author: https://github.com/jedie +https://docs.micropython.org/en/latest/library/bluetooth.html +""" + +import time + +import bluetooth +import lvgl as lv +from micropython import const +from mpos import Activity + +SCAN_DURATION = const(1000) # Duration of each BLE scan in milliseconds +_IRQ_SCAN_RESULT = const(5) + + +# BLE Advertising Data Types (Standardized by Bluetooth SIG) +_ADV_TYPE_NAME = const(0x09) + + +def decode_field(payload: bytes, adv_type: int) -> list: + results = [] + i = 0 + payload_len = len(payload) + while i < payload_len: + length = payload[i] + if length == 0 or i + length >= payload_len: + break + field_type = payload[i + 1] + if field_type == adv_type: + results.append(payload[i + 2 : i + length + 1]) + i += length + 1 + return results + + +class BluetoothScanner: + def __init__(self, device_callback): + self.device_callback = device_callback + self.ble = bluetooth.BLE() + self.ble.irq(self.ble_irq_handler) + + def __enter__(self): + print("Activating BLE") + self.ble.active(True) + return self + + def ble_irq_handler(self, event: int, data: tuple) -> None: + if event == _IRQ_SCAN_RESULT: + addr_type, addr, adv_type, rssi, adv_data = data + addr = ":".join(f"{b:02x}" for b in addr) + names = decode_field(adv_data, _ADV_TYPE_NAME) + name = str(names[0], "utf-8") if names else "Unknown" + self.device_callback(addr, rssi, name) + + def scan(self, duration_ms: int): + print(f"BLE scanning for {duration_ms}ms...") + self.ble.gap_scan(duration_ms, 20000, 10000) + + def __exit__(self, exc_type, exc_val, exc_tb): + print("Deactivating BLE") + self.ble.active(False) + + +def set_dynamic_column_widths(table, font=None, padding=8): + font = font or lv.font_montserrat_14 + for col in range(table.get_column_count()): + max_width = 0 + for row in range(table.get_row_count()): + value = table.get_cell_value(row, col) + width = lv.text_get_width(value, len(value), font, lv.TEXT_FLAG.NONE) + if width > max_width: + max_width = width + table.set_column_width(col, max_width + padding) + + +def set_cell_value(table, *, row: int, values: tuple): + for col, value in enumerate(values): + table.set_cell_value(row, col, value) + + +class ScanBluetooth(Activity): + refresh_timer = None + + def onCreate(self): + screen = lv.obj() + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + screen.set_style_pad_all(0, 0) + screen.set_size(lv.pct(100), lv.pct(100)) + + self.table = lv.table(screen) + set_cell_value( + self.table, + row=0, + values=("pos", "MAC", "RSSI", "count", "Name"), + ) + set_dynamic_column_widths(self.table) + + self.mac2column = {} + self.mac2counts = {} + + self.scanner_cm = BluetoothScanner(device_callback=self.scan_callback) + self.scanner = self.scanner_cm.__enter__() # Activate BLE + + self.setContentView(screen) + + def scan_callback(self, addr, rssi, name): + if not (column_index := self.mac2column.get(addr)): + column_index = len(self.mac2column) + 1 + self.mac2column[addr] = column_index + self.mac2counts[addr] = 1 + else: + self.mac2counts[addr] += 1 + + set_cell_value( + self.table, + row=column_index, + values=( + str(column_index), + addr, + f"{rssi} dBm", + str(self.mac2counts[addr]), + name, + ), + ) + + def onResume(self, screen): + super().onResume(screen) + + def update(timer): + self.scanner.scan(SCAN_DURATION) + set_dynamic_column_widths(self.table) + time.sleep_ms(SCAN_DURATION + 100) # Wait ? + print(f"Scan complete: {len(self.mac2column)} unique devices") + + self.refresh_timer = lv.timer_create(update, SCAN_DURATION + 1000, None) + + def onPause(self, screen): + super().onPause(screen) + self.scanner.__exit__(None, None, None) # Deactivate BLE + if self.refresh_timer: + self.refresh_timer.delete() + self.refresh_timer = None diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..f9f8f4884390eacb8fa7362bc57dad726e194486 GIT binary patch literal 5992 zcmV-u7nkUXP)PTEn0E z-U6S61FS&!t4iddU_b$&(Fh<27%;|v9Iiu@x&e6je{BR@_Vw>H8&RM&?Hyg=y$5Tf zBJRf-(4r9|ARs8jfDwZ+24jSwRb}0|xv?HJ1Ok?N>KFIr{f~CQ%=wostZ!(Wg$QB^ zI+?s)#NwM*e2MIwY{yXJ1h}|?p8DB2c)_GX!F_J;?Bvm>UO^j!CF)^~jWL?Sg1l90 zUVr+D{}B$Df8kYgn_JsQ7_Am|bR}jPG_$9Vqr9{frGyF9W693Wa*Z`^0Ep`Z<3&M6 z7Pzl&@91FjmhITK;dp&LE8g3JC`B}EtqLl6q98wa&#N!|@~uBB1|)j*we6ku)wYg% z222<~n&HETrcV}pW`T%v$l}C5si(9T95-ZVXESrg6cE_E_aok3{Q*-ti*`m-F?j zE=4Itb}WV!un+{}S|CO+q>abDX_546GU8vvGhY06XB?*`wM}P77g}o`d;D2y4jm^i zH@365DCfx+pZ@ndKX!%sxNo@Rs=r#?)YSQvj*fWs!b`uv+&R-JEGQrviJ($MKls75(zV6*gytWma=8rMyI~gF?-fTuDJZ7j1V;nOgdFD7$?NVMSIt9<1^kEBV1W_RLANb|y$B6YEP$OGkaG81Uk<*N6OQ$-^HVZ)#)75C4v6B#dP#&)8G|r2lh^ zzOTN$_WE1AvvxDu7>qV3QD~#lT6-4=SPG?t=~Kq@#rgBnY2d>F{CHxFhc`xR5=onz zzV|N_W=B}G=$jL+y!iZ0f3yQGzxJjJ_tYHzYDZVk)t4-o$?VxP2nGWg$!)yKp82F; zHy9cknmBy;DBD{0@>*gidxFPl9c~Cz#!y8etl}V6Q5dTzOrS9R&nkBQ)*eZ+yWtSK zRXxFkjcV0sZOF=wrL)4MKijx~DrP~C!xgTz5~gNg%%0jzWK3Czi5UC}Xq@~darv|}gno&;JOFLn*7doUgVmy{G! zHJ}uQqN%lg!O*JGF`qaEX3f8%wZ5@6x4bl;fBfEGyEWZMPWq%|Pj7-7|L$%gH6geQ*oy4>y8M7>#$zw+|X7nhxL}oG#aUI*(+{}MI^#VtZHee@{|G0JSi$DBW zy?{auOePXmG#sL|sMu>#oSZW*0y~GWa^<^xxc4BtCv;(zWut9P$ErFkV^Q*9h>K?Y zC4oSIU?9NeO*_0&=J5yL@72}gSv2J(oo;b z+}Sf60Q6GPxO7A)DkvZv2+$qxL0Q4dkJSu~oqk?%B0+Igc@YB#l)EABoV4HR254+* z=C?=ILR1J3E5sxW;SQnY*UgL^T1jbH2^Hlf*tVgewS{MD*MTwUjLPBMxKfIN?E@(+ z$j4w9mRCmh`g*o~bdb>45_+>VdQgDPn~$;eXdUBQMp0N;;HIH-W5zuo5us{88SU+z z9I9(79x-u#@tzH@Hk?)pjGeyV`b0uKblF$VVb2_Ef3a+Xm_qzi?+uU}?Pibsy;Acy21>4a5v*Sq8U=(Pr(E?f%d~wfpJrNpj=2E@8)x zy{z7J1T`fajVAJci&_5WN@h%(L{==yiTG?B;MSTzF!YCs(5Q*?O3s>i_O-1Y-3#VU zAIr$$LntgLNMFxKd^$Ti`RQZ7CFlQ?VMIvQcEjtfTyXXr^7Hff`M*C)^ROi0Ir&&+ zF=$Alk90G+v4RPDAR|;6!=;pws*I6VIU}ty@)JQ0bv7Xg!NFOm!Vq;IHL&fC4_R>T zObQC~Sif=$W>6579mL8HvhimhFn9V{L?aPjr|Gan5lAGG1T4dWL&vEaJf=31TYjLW zet*|VBOqa$s>8Ld_XAW{k74N0!T8xKzHUuxjV&515H0BaakAJ1g+^if zuD#5jHI-OnkhXnstg@(kd`@rS-D4kM88T!LwzgTdb_ZSY#Dhl3dZ6K?4zR40eI~71 zlJ4N}<*c&AiC-5 z!nBE_x$3eDx$fFaICSU;cmLCq#C}kL8WklrJc@qPkXg&QXX-~*HM-@|Scdfj z_H*=CwS431i#Xg=$CJNYLH3p9$`P$??QCs6f|VOYMCjSrNsh{*ATOUa8}@K4)QJ(Hr>29f^}XD5{iW2_ z9%t3|_3S*mi|$ed2}o@3!rtDEGKzSqV#mx*-rly3+GEGL`G!l$-Iye?uN|X>+F%!} zH}0e$KaVU4)4Q<~lwf5A+1hf1_STM!`}Rk0hODObsU0wKP`^N`$v5=T@aQYuiOa$z z%?>@q*XhxdN4kh@Fbp3)nEJ*R{_EgI7#hUtwx~Rw#iW{YCVo^#ze8cHZVNNSV%d>R z9ItO=*s#ImZV8Y))Z-kctwAh|0oyi+r4a2Lkg_a{Hfepr*CD1OS<>p%sT}a({-a6p zNN;M6m#N#QIDzxYX?|eDV3fi5*_9G+_UCYa4NB-oeP>O%J?Ca!_?VZN#*)ym%R zP7gaIg~r-oYgsVA1DiK(WmDsB%mNTwp)Er|EUsC25w>lk zwf3$ViUb)kVhDHMw#Z|AA`l9NTzZ{O+jKg(Scduo4zRWRUY+qmIavdtjTcQ~oXTOD zw9qLU0{tU&PZsv>JxFPB5et~Ws+|Yfp5H@()RMI~8!@oos$*CGR;<=2G-v{q0R+g+ z$xdJ2EoC5J1&M|uZkW4jfbmUK+_pG;%iux2Uu zuU*Qj=zdIP5c^m!!JH7?v0jchG-4-{PX4=UvQPTRm@Rl!PtpMGjoQVe9Uy-I1p5`| zDKUNJj=n?)#*G`p#Z%|gviJbT5~A0X^F-G>-232@TygmYOg=goF@korQ`>EQv$A8% zm^_BWz0D-+dI()xM(DaSbVCnai;ogN-a+WvQr3<-#`k~pFdZG8&MBw(h-BoTXOQyE z7&AzVr0^=$xPcLU>J&I!-=ed#@<~_I6RBY8CtGVoERTj_r7D60hP{tA5I#2_(?3Ag z9ThzD?n=geIDomcrZH~pXlf206B~u5TLI3g!3JUTWGiV_7`)XJ=<1^K9Yn^?PIziK2<6{R^DXV;;F43!eX;Sixv2!;5tNz%gX zi5bH79lLpX`8t%c*u8$~1%BgCDTvs~B%)M$t}p&p*tvWx7UM7Go=yDU8VT6}jI9Yw z$l;CEA5h=e$ecOT7}Zfu&rgoidw&CG6%J!!^_a|(VtgH(`&dY_vZG`Yc))5wtOgD3Xc9>CXj*4KBOEKkhJnW}B%TOV#|w zTlS)N#<8=6=%O<2eefAVp&&D7PUZe3w{riFzsI0KRgjX_`i_fzvp-QnRpkIKJ%1L7 zms-GbMrcNCmwx+z*{AwsR6!3XT=pl5L6t=}oc3hsiwn*l8}IH}aOg;_RbOAv=re|& zG|VuDsguU=#;W&;Ov*({Ls(HoWO@#VpV>umQ6c#`Iquk8GN(v=3~<<_cz9D*TFmI; z0jzjoHHqyV{NU@?QCwI=AQ<2zYIx>6+Qq4tm%YLJ>vxco6YDK2E%?m`o7QYQEeEVy zy>d$=w|`}OXMCbIaQ2jH&-(}{AH9r5Bb4OD2=y9{H8mlX5lmF^(J%KgwR$u~1^Fik zpZ-JlhhTaQKnMl{ghC-odt!{stz!7FA%sG~jHX0v^9Hc z_R-ef2}YCXO+L5#{g)g5s2!mzFaF}*t1ti3!rJ;~?s@Pxw0Crq)Y|L5ym6T?#QnK! z#P1AqLSvQcIP}R@$x_XX_-2y+l?31ZpvKVA)yGOipTVYv1{) z?+L#A{BILY$38qZW9FQG+x8rs+}+WIBFTWt{&3=EpyS=tO)AOQ3>Q~25?@$IN?SZh zNfb_GeYkzVd#m4P&HC-^-gk^CXO4LE&6gg3@U1tNB|dcrq#kEYn(0Nb`{ZEdHsvzy1B zc#hZJUQc^hVrOA)){}(=*?+rb!|KGxvF8&nOP@L8{7GHC`h9T8kO+6){%uD;NJ&+m zeRAR_8s9>2-=RaF)j92=sMdz2#%8|%qldk)Q3z)YE}j0n-~7j#Kg;(8{^r)Zu6gD4 z_kLlcT|l%DV|d`++udUA&}EZBUea;icl2hwUcqIGZd?1ETkdkgUfU>3VT@r&W$AUx ze)rg;f7b5{jhPL*Ot2(-pGi_~yQoQ-*I~j8J`0=ACE-uX64kumO{2Lxk z%`I%&yv>=?8G~i1)Di)f6(B!1yecaeeWD;g=iNW{{y)|442_y{!EM^M2Zlq|IXww` zxVV%}3w-UudGsqU?bEsV*IJAR@AVB$JoW5yv=}V0Fj`YyoOigkvF#OQ1rF?7`}|#> z);<48zBDy)_QiwZJ&B@G!v@?O2nN0^h;{HtU97&TEj?{B>HgPScXb(NL0*;_Hl(r# z&_u8Ka~7TWF)bnpako zyMEQOrzU?E_Z9f89AGJPb<*z5Qp%CiElaT7?JgxrhfryE24L(?K7V}v`26u%JpLP9 W)a3I|P(m~S0000 Date: Sat, 21 Feb 2026 07:41:06 +0100 Subject: [PATCH 065/317] Add MPU6886 driver (tested with M5Stack FIRE) (#56) Will fix https://github.com/MicroPythonOS/MicroPythonOS/issues/44 --- .../lib/drivers/imu_sensor/mpu6886.py | 83 +++++++++ .../lib/mpos/board/m5stack_fire.py | 20 ++- .../lib/mpos/sensor_manager.py | 160 ++++++++++++++++-- 3 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 internal_filesystem/lib/drivers/imu_sensor/mpu6886.py diff --git a/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py b/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py new file mode 100644 index 00000000..0cf8d137 --- /dev/null +++ b/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py @@ -0,0 +1,83 @@ +""" +MicroPython driver for MPU6886 3-Axis Accelerometer + 3-Axis Gyroscope. +Tested with M5Stack FIRE +https://docs.m5stack.com/en/unit/imu +https://github.com/m5stack/M5Stack/blob/master/src/utility/MPU6886.h +""" + +import time + +from machine import I2C +from micropython import const + +_I2CADDR_DEFAULT = const(0x68) + + +# register addresses +_REG_PWR_MGMT_1 = const(0x6B) +_REG_ACCEL_XOUT_H = const(0x3B) +_REG_GYRO_XOUT_H = const(0x43) +_REG_ACCEL_CONFIG = const(0x1C) +_REG_GYRO_CONFIG = const(0x1B) +_REG_TEMPERATURE_OUT_H = const(0x41) + +# Scale factors for converting raw sensor data to physical units: +_ACCEL_SCALE_8G = 8.0 / 32768.0 # LSB/g for +-8g range +_GYRO_SCALE_2000DPS = 2000.0 / 32768.0 # LSB/°/s for +-2000dps range +_TEMPERATURE_SCALE = 326.8 # LSB/°C +_TEMPERATURE_OFFSET = const(25) # Offset (25°C at 0 LSB) + + +def twos_complement(val, bits): + if val & (1 << (bits - 1)): + val -= 1 << bits + return val + + +class MPU6886: + def __init__( + self, + i2c_bus: I2C, + address: int = _I2CADDR_DEFAULT, + ): + self.i2c = i2c_bus + self.address = address + + for data in (b"\x00", b"\x80", b"\x01"): # Reset, then wake up + self._write(_REG_PWR_MGMT_1, data) + time.sleep(0.01) + + self._write(_REG_ACCEL_CONFIG, b"\x10") # +-8g + time.sleep(0.001) + + self._write(_REG_GYRO_CONFIG, b"\x18") # +-2000dps + time.sleep(0.001) + + # Helper functions for register operations + def _write(self, reg: int, data: bytes): + self.i2c.writeto_mem(self.address, reg, data) + + def _read_xyz(self, reg: int, scale: float) -> tuple[int, int, int]: + data = self.i2c.readfrom_mem(self.address, reg, 6) + x = twos_complement(data[0] << 8 | data[1], 16) + y = twos_complement(data[2] << 8 | data[3], 16) + z = twos_complement(data[4] << 8 | data[5], 16) + return (x * scale, y * scale, z * scale) + + @property + def temperature(self) -> float: + buf = self.i2c.readfrom_mem(self.address, _REG_TEMPERATURE_OUT_H, 14) + temp_raw = (buf[6] << 8) | buf[7] + if temp_raw & 0x8000: # If MSB is 1, it's negative + temp_raw -= 0x10000 # Subtract 2^16 to get negative value + return temp_raw / _TEMPERATURE_SCALE + _TEMPERATURE_OFFSET + + @property + def acceleration(self) -> tuple[int, int, int]: + """Get current acceleration reading.""" + return self._read_xyz(_REG_ACCEL_XOUT_H, scale=_ACCEL_SCALE_8G) + + @property + def gyro(self) -> tuple[int, int, int]: + """Get current gyroscope reading.""" + return self._read_xyz(_REG_GYRO_XOUT_H, scale=_GYRO_SCALE_2000DPS) diff --git a/internal_filesystem/lib/mpos/board/m5stack_fire.py b/internal_filesystem/lib/mpos/board/m5stack_fire.py index 3f41a0f8..9aac9953 100644 --- a/internal_filesystem/lib/mpos/board/m5stack_fire.py +++ b/internal_filesystem/lib/mpos/board/m5stack_fire.py @@ -1,7 +1,6 @@ # Hardware initialization for ESP32 M5Stack-Fire board # Manufacturer's website at https://https://docs.m5stack.com/en/core/fire_v2.7 # Original author: https://github.com/ancebfer - import time import drivers.display.ili9341 as ili9341 @@ -10,9 +9,9 @@ import machine import mpos.ui import mpos.ui.focus_direction -from machine import PWM, Pin +from machine import I2C, PWM, Pin from micropython import const -from mpos import AudioManager, InputManager +from mpos import AudioManager, InputManager, SensorManager # Display settings: SPI_BUS = const(1) # SPI2 @@ -40,6 +39,12 @@ # Buzzer BUZZER_PIN = const(25) +# MPU6886 Sensor settings: +MPU6886_I2C_ADDR = const(0x68) +MPU6886_I2C_SCL = const(22) +MPU6886_I2C_SDA = const(21) +MPU6886_I2C_FREQ = const(400000) + print("m5stack_fire.py init buzzer") buzzer = PWM(Pin(BUZZER_PIN, Pin.OUT, value=1), duty=5) @@ -50,6 +55,15 @@ time.sleep(0.1) +print("m5stack_fire.py init IMU") +i2c_bus = I2C(0, scl=Pin(MPU6886_I2C_SCL), sda=Pin(MPU6886_I2C_SDA), freq=MPU6886_I2C_FREQ) +SensorManager.init( + i2c_bus=i2c_bus, + address=MPU6886_I2C_ADDR, + mounted_position=SensorManager.FACING_EARTH, +) + + print("m5stack_fire.py machine.SPI.Bus() initialization") try: spi_bus = machine.SPI.Bus(host=SPI_BUS, mosi=LCD_MOSI, sck=LCD_SCLK) diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index fa5eab57..c9bc86d3 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -159,30 +159,48 @@ def _ensure_imu_initialized(self): if not self._initialized or self._imu_driver is not None: return self._imu_driver is not None - # Try QMI8658 first (Waveshare board) if self._i2c_bus: try: - from drivers.imu_sensor.qmi8658 import QMI8658 - chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x00, 1)[0] # PARTID register + print("Try QMI8658 first (Waveshare board)") + # PARTID register: + chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x00, 1)[0] + print(f"{chip_id=:#04x}") if chip_id == 0x05: # QMI8685_PARTID self._imu_driver = _QMI8658Driver(self._i2c_bus, self._i2c_address) self._register_qmi8658_sensors() self._load_calibration() + print("Use QMI8658, ok") return True - except: - pass + except Exception as e: + print("No QMI8658:", e) - # Try WSEN_ISDS (fri3d_2024) or LSM6DSO (fri3d_2026) try: - from drivers.imu_sensor.wsen_isds import Wsen_Isds - chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x0F, 1)[0] # WHO_AM_I register - could also use Wsen_Isds.get_chip_id() - if chip_id == 0x6A or chip_id == 0x6C: # WSEN_ISDS WHO_AM_I 0x6A (Fri3d 2024) or 0x6C (Fri3d 2026) + print("Try WSEN_ISDS (fri3d_2024) or LSM6DSO (fri3d_2026)") + # WHO_AM_I register - could also use Wsen_Isds.get_chip_id(): + chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x0F, 1)[0] + print(f"{chip_id=:#04x}") + # WSEN_ISDS WHO_AM_I 0x6A (Fri3d 2024) or 0x6C (Fri3d 2026): + if chip_id == 0x6A or chip_id == 0x6C: self._imu_driver = _WsenISDSDriver(self._i2c_bus, self._i2c_address) self._register_wsen_isds_sensors() self._load_calibration() + print("Use WSEN_ISDS/LSM6DSO, ok") return True - except: - pass + except Exception as e: + print("No WSEN_ISDS or LSM6DSO:", e) + + try: + print("Try MPU6886 (M5Stack FIRE)") + chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x75, 1)[0] + print(f"{chip_id=:#04x}") + if chip_id == 0x19: + self._imu_driver = _MPU6886Driver(self._i2c_bus, self._i2c_address) + self._register_mpu6886_sensors() + self._load_calibration() + print("Use MPU6886, ok") + return True + except Exception as e: + print("No MPU6886:", e) return False @@ -575,7 +593,39 @@ def _register_qmi8658_sensors(self): power_ma=0 ) ] - + + def _register_mpu6886_sensors(self): + """Register MPU6886 sensors in sensor list.""" + self._sensor_list = [ + Sensor( + name="MPU6886 Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="InvenSense", + version=1, + max_range="±16g", + resolution="0.0024 m/s²", + power_ma=0.2, + ), + Sensor( + name="MPU6886 Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="InvenSense", + version=1, + max_range="±256 deg/s", + resolution="0.002 deg/s", + power_ma=0.7, + ), + Sensor( + name="MPU6886 Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="InvenSense", + version=1, + max_range="-40°C to +85°C", + resolution="0.05°C", + power_ma=0, + ), + ] + def _register_wsen_isds_sensors(self): """Register WSEN_ISDS sensors in sensor list.""" self._sensor_list = [ @@ -813,6 +863,92 @@ def set_calibration(self, accel_offsets, gyro_offsets): self.gyro_offset = list(gyro_offsets) +class _MPU6886Driver(_IMUDriver): + """Wrapper for MPU6886 IMU (Waveshare board).""" + + def __init__(self, i2c_bus, address): + from drivers.imu_sensor.mpu6886 import MPU6886 + + self.sensor = MPU6886(i2c_bus, address=address) + # Software calibration offsets (MPU6886 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_temperature(self): + """Read temperature in °C.""" + return self.sensor.temperature + + 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 calibrate_accelerometer(self, samples): + """Calibrate accelerometer (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + ax, ay, az = self.sensor.acceleration + sum_x += ax * _GRAVITY + sum_y += ay * _GRAVITY + sum_z += az * _GRAVITY + time.sleep_ms(10) + + if FACING_EARTH == FACING_EARTH: + sum_z *= -1 + + # Average offsets (assuming Z-axis should read +9.8 m/s²) + self.accel_offset[0] = sum_x / samples + self.accel_offset[1] = sum_y / samples + self.accel_offset[2] = (sum_z / samples) - _GRAVITY + + return tuple(self.accel_offset) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self.sensor.gyro + sum_x += gx + sum_y += gy + sum_z += gz + time.sleep_ms(10) + + # Average offsets (should be 0 when stationary) + self.gyro_offset[0] = sum_x / samples + self.gyro_offset[1] = sum_y / samples + self.gyro_offset[2] = sum_z / samples + + return tuple(self.gyro_offset) + + def get_calibration(self): + """Get current calibration.""" + return {"accel_offsets": self.accel_offset, "gyro_offsets": self.gyro_offset} + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration from saved values.""" + if accel_offsets: + self.accel_offset = list(accel_offsets) + if gyro_offsets: + self.gyro_offset = list(gyro_offsets) + + class _WsenISDSDriver(_IMUDriver): """Wrapper for WSEN_ISDS IMU (Fri3d badge).""" From a1478080b1d8046809744f776e77e775296fe0a5 Mon Sep 17 00:00:00 2001 From: Richard <115934595+bitcoin3us@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:42:04 +0000 Subject: [PATCH 066/317] Clean up existing install before extracting .mpk (#55) install_mpk() would fail with EEXIST if the destination folder already existed from a previous (possibly partial) install, or if a dangling symlink occupied the path (e.g. a dev symlink whose target was removed). Before extracting, now remove the destination if it is: - A real directory (via shutil.rmtree) - A regular file (via os.remove) - A symlink, including broken ones (via os.remove as a fallback) This enables clean reinstalls, updates, and recovery from failed installs. Co-authored-by: Richard Taylor Co-authored-by: Claude Opus 4.6 --- .../lib/mpos/content/app_manager.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal_filesystem/lib/mpos/content/app_manager.py b/internal_filesystem/lib/mpos/content/app_manager.py index b8385b04..07ef1b86 100644 --- a/internal_filesystem/lib/mpos/content/app_manager.py +++ b/internal_filesystem/lib/mpos/content/app_manager.py @@ -157,6 +157,25 @@ def uninstall_app(app_fullname): @staticmethod def install_mpk(temp_zip_path, dest_folder): try: + # Step 1: Remove any existing (possibly partial) install or symlink + try: + st = os.stat(dest_folder) + if st[0] & 0x4000: # It's a real directory + import shutil + shutil.rmtree(dest_folder) + print("Removed existing folder:", dest_folder) + else: + os.remove(dest_folder) + print("Removed existing file:", dest_folder) + except OSError: + pass # Doesn't exist, that's fine + # Also remove if it's a symlink (broken or otherwise) + try: + os.remove(dest_folder) + print("Removed symlink:", dest_folder) + except OSError: + pass # Not a symlink or already removed + # Step 2: Unzip the file print("Unzipping it to:", dest_folder) with zipfile.ZipFile(temp_zip_path, "r") as zip_ref: From d3e009966dc3689910aea9e67916cf45d3f5f2e0 Mon Sep 17 00:00:00 2001 From: Richard <115934595+bitcoin3us@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:43:39 +0000 Subject: [PATCH 067/317] Fix EEXIST errors in zipfile.py during extraction (#54) When extracting zip archives, os.mkdir() could raise [Errno 17] EEXIST in two places: 1. makedirs() - A directory might already exist between the path_exists check and the os.mkdir() call, or be created by a prior extraction step. 2. _extract_member() - makedirs() may have already created a directory when building parent paths for a file entry, and then a separate directory entry for the same path triggers os.mkdir() again. Wrap both os.mkdir() calls in try/except OSError to handle this gracefully. Co-authored-by: Richard Taylor Co-authored-by: Claude Opus 4.6 --- internal_filesystem/lib/zipfile.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/lib/zipfile.py b/internal_filesystem/lib/zipfile.py index 1f411716..fb867356 100644 --- a/internal_filesystem/lib/zipfile.py +++ b/internal_filesystem/lib/zipfile.py @@ -1940,7 +1940,10 @@ def makedirs(self, path): if parent: self.makedirs(parent) # Recursively create parent directories if not self.path_exists(path): - os.mkdir(path) + try: + os.mkdir(path) + except OSError: + pass # Directory may already exist def _extract_member(self, member, targetpath, pwd): """Extract the ZipInfo object 'member' to a physical @@ -1972,7 +1975,10 @@ def _extract_member(self, member, targetpath, pwd): # Handle directories if member.is_dir(): if not self.path_isdir(targetpath): - os.mkdir(targetpath) + try: + os.mkdir(targetpath) + except OSError: + pass # Directory may already exist from makedirs return targetpath # Extract file From 84d3f839668c011faa7da94305adaf73691e73c1 Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Sat, 21 Feb 2026 07:44:45 +0100 Subject: [PATCH 068/317] Odroid-Go: Unmute+Mute buzzer before/after Playback (#52) On ODROID-GO the buzzer makes noises when it is on and nothing is being played. Don't know if this is common on ESP32 devices... --- .../lib/mpos/audio/audiomanager.py | 41 +++++++++++++++---- .../lib/mpos/board/odroid_go.py | 29 +++++++++++-- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/audiomanager.py b/internal_filesystem/lib/mpos/audio/audiomanager.py index 4f5eaf42..f8823328 100644 --- a/internal_filesystem/lib/mpos/audio/audiomanager.py +++ b/internal_filesystem/lib/mpos/audio/audiomanager.py @@ -31,8 +31,15 @@ class AudioManager: STREAM_ALARM = 2 # Alarms/alerts (highest priority) _instance = None # Singleton instance - - def __init__(self, i2s_pins=None, buzzer_instance=None, adc_mic_pin=None): + + def __init__( + self, + i2s_pins=None, + buzzer_instance=None, + adc_mic_pin=None, + pre_playback=None, + post_playback=None, + ): """ Initialize AudioManager instance with optional hardware configuration. @@ -40,6 +47,8 @@ def __init__(self, i2s_pins=None, buzzer_instance=None, adc_mic_pin=None): i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S/WAV playback) buzzer_instance: PWM instance for buzzer (for RTTTL playback) adc_mic_pin: GPIO pin number for ADC microphone (for ADC recording) + pre_playback: Optional callback called before starting playback + post_playback: Optional callback called after stopping playback """ if AudioManager._instance: # If instance exists, update configuration if provided @@ -53,12 +62,15 @@ def __init__(self, i2s_pins=None, buzzer_instance=None, adc_mic_pin=None): AudioManager._instance = self - self._i2s_pins = i2s_pins # I2S pin configuration dict (created per-stream) - self._buzzer_instance = buzzer_instance # PWM buzzer instance - self._adc_mic_pin = adc_mic_pin # ADC microphone pin - self._current_stream = None # Currently playing stream - self._current_recording = None # Currently recording stream - self._volume = 50 # System volume (0-100) + self._i2s_pins = i2s_pins # I2S pin configuration dict (created per-stream) + self._buzzer_instance = buzzer_instance # PWM buzzer instance + self._adc_mic_pin = adc_mic_pin # ADC microphone pin + self.pre_playback = pre_playback + self.post_playback = post_playback + + self._current_stream = None # Currently playing stream + self._current_recording = None # Currently recording stream + self._volume = 50 # System volume (0-100) # Build status message capabilities = [] @@ -68,7 +80,7 @@ def __init__(self, i2s_pins=None, buzzer_instance=None, adc_mic_pin=None): capabilities.append("Buzzer (RTTTL)") if adc_mic_pin: capabilities.append(f"ADC Mic (Pin {adc_mic_pin})") - + if capabilities: print(f"AudioManager initialized: {', '.join(capabilities)}") else: @@ -131,6 +143,11 @@ def _playback_thread(self, stream): stream: Stream instance (WAVStream or RTTTLStream) """ self._current_stream = stream + if self.pre_playback: + try: + self.pre_playback() + except Exception as e: + print(f"AudioManager: pre_playback callback error: {e}") try: # Run synchronous playback in this thread @@ -142,6 +159,12 @@ def _playback_thread(self, stream): if self._current_stream == stream: self._current_stream = None + if self.post_playback: + try: + self.post_playback() + except Exception as e: + print(f"AudioManager: post_playback callback error: {e}") + def play_wav(self, file_path, stream_type=None, volume=None, on_complete=None): """ Play WAV file via I2S. diff --git a/internal_filesystem/lib/mpos/board/odroid_go.py b/internal_filesystem/lib/mpos/board/odroid_go.py index 4aac438d..e9e87dca 100644 --- a/internal_filesystem/lib/mpos/board/odroid_go.py +++ b/internal_filesystem/lib/mpos/board/odroid_go.py @@ -62,9 +62,32 @@ print("odroid_go.py init buzzer") buzzer = PWM(Pin(BUZZER_PIN, Pin.OUT, value=1), duty=5) -dac_pin = Pin(BUZZER_DAC_PIN, Pin.OUT, value=1) -dac_pin.value(1) # Unmute -AudioManager(i2s_pins=None, buzzer_instance=buzzer) + + +class BuzzerCallbacks: + __slots__ = ("dac_pin",) + + def __init__(self): + self.dac_pin = Pin(BUZZER_DAC_PIN, Pin.OUT, value=1) + + def unmute(self): + print("Unmute buzzer") + self.dac_pin.value(1) # Unmute + + def mute(self): + print("Mute buzzer") + self.dac_pin.value(0) # Mute + + +buzzer_callbacks = BuzzerCallbacks() +AudioManager( + i2s_pins=None, + buzzer_instance=buzzer, + # The buzzer makes noise when it's unmuted, to avoid this we + # mute it after playback and vice versa unmute it before playback: + pre_playback=buzzer_callbacks.unmute, + post_playback=buzzer_callbacks.mute, +) AudioManager.set_volume(40) AudioManager.play_rtttl("Star Trek:o=4,d=20,b=200:8f.,a#,4d#6.,8d6,a#.,g.,c6.,4f6") while AudioManager.is_playing(): From 3101594d036a6cfe2120c8f5a2b4f7fc24f114dc Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 21 Feb 2026 08:04:10 +0100 Subject: [PATCH 069/317] comments --- internal_filesystem/lib/mpos/board/fri3d_2024.py | 2 +- internal_filesystem/lib/mpos/board/fri3d_2026.py | 2 +- 2 files 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 77dafb11..fb0ed56f 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -303,9 +303,9 @@ def adc_to_voltage(adc_value): # 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 = { + 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) # Output (DAC/speaker) config 'sck': 2, # SCLK or BCLK - Bit Clock for DAC output (mandatory) - 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) 'sd': 16, # Serial Data OUT (speaker/DAC) # Input (microphone) config 'sck_in': 17, # SCLK - Serial Clock for microphone input diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py index 15313b26..9035345d 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2026.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -210,7 +210,7 @@ def adc_to_voltage(adc_value): i2s_pins = { # Output (DAC/speaker) pins 'mck': 2, # MCLK (mandatory) - 'sck': 17, # SCLK aka BCLK (optional) + 'sck': 17, # SCLK aka BCLK (unclear if optional or mandatory) 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) 'sd': 16, # Serial Data OUT (speaker/DAC) } From 2b5acc2a93c15a1f18becf315b7c67083bad50ad Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 21 Feb 2026 08:33:14 +0100 Subject: [PATCH 070/317] scan_bluetooth: graceful error if bluetooth not available --- .../assets/scan_bluetooth.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py index 6b894b46..9ac9a1ee 100644 --- a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py +++ b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py @@ -5,7 +5,11 @@ import time -import bluetooth +try: + import bluetooth +except ImportError: # Linux test runner may not provide bluetooth module + bluetooth = None + import lvgl as lv from micropython import const from mpos import Activity @@ -35,6 +39,8 @@ def decode_field(payload: bytes, adv_type: int) -> list: class BluetoothScanner: def __init__(self, device_callback): + if bluetooth is None: + raise RuntimeError("Bluetooth module not available") self.device_callback = device_callback self.ble = bluetooth.BLE() self.ble.irq(self.ble_irq_handler) @@ -87,6 +93,13 @@ def onCreate(self): screen.set_style_pad_all(0, 0) screen.set_size(lv.pct(100), lv.pct(100)) + if bluetooth is None: + label = lv.label(screen) + label.set_text("Bluetooth not available on this platform") + label.center() + self.setContentView(screen) + return + self.table = lv.table(screen) set_cell_value( self.table, @@ -125,6 +138,8 @@ def scan_callback(self, addr, rssi, name): def onResume(self, screen): super().onResume(screen) + if bluetooth is None: + return def update(timer): self.scanner.scan(SCAN_DURATION) @@ -136,6 +151,8 @@ def update(timer): def onPause(self, screen): super().onPause(screen) + if bluetooth is None: + return self.scanner.__exit__(None, None, None) # Deactivate BLE if self.refresh_timer: self.refresh_timer.delete() From 76e97ee6a558ee389a792ee75a4f6f5cebcb1224 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 21 Feb 2026 08:34:00 +0100 Subject: [PATCH 071/317] IIODriver: add calibration stubs --- .../lib/mpos/sensor_manager.py | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 9fed96c4..55078126 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -346,6 +346,8 @@ def read_sensor(self, sensor): try: return self.read_sensor_once(sensor) except Exception as e: + import sys + sys.print_exception(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: @@ -394,6 +396,8 @@ def calibrate_sensor(self, sensor, samples=100): return offsets except Exception as e: + import sys + sys.print_exception(e) print(f"[SensorManager] Calibration error: {e}") return None finally: @@ -812,6 +816,64 @@ def set_calibration(self, accel_offsets, gyro_offsets): class _IIODriver(_IMUDriver): + def __init__(self): + self.accel_path = self.find_iio_device_with_file("in_accel_x_raw") + print("path:", self.accel_path) + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] + + 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.read_acceleration() + sum_x += ax + sum_y += ay + sum_z += az + time.sleep_ms(10) + + if FACING_EARTH == FACING_EARTH: + sum_z *= -1 + + # Average offsets (assuming Z-axis should read +9.8 m/s²) + self.accel_offset[0] = sum_x / samples + self.accel_offset[1] = sum_y / samples + self.accel_offset[2] = (sum_z / samples) - _GRAVITY + + return tuple(self.accel_offset) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self.read_gyroscope() + 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) """ Read sensor data via Linux IIO sysfs. @@ -921,13 +983,19 @@ def read_acceleration(self) -> tuple[float, float, float]: Common names: in_accel_{x,y,z}_raw + in_accel_scale """ + if not self.accel_path: + return (0.0, 0.0, 0.0) scale_name = self.accel_path + "/" + "in_accel_scale" ax = self._read_raw_scaled(self.accel_path + "/" + "in_accel_x_raw", scale_name) ay = self._read_raw_scaled(self.accel_path + "/" + "in_accel_y_raw", scale_name) az = self._read_raw_scaled(self.accel_path + "/" + "in_accel_z_raw", scale_name) - return (ax, ay, az) + return ( + ax - self.accel_offset[0], + ay - self.accel_offset[1], + az - self.accel_offset[2] + ) def read_gyroscope(self) -> tuple[float, float, float]: """ @@ -935,13 +1003,19 @@ def read_gyroscope(self) -> tuple[float, float, float]: Common names: in_anglvel_{x,y,z}_raw + in_anglvel_scale """ + if not self.accel_path: + return (0.0, 0.0, 0.0) scale_name = self.accel_path + "/" + "in_anglvel_scale" gx = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_x_raw", scale_name) gy = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_y_raw", scale_name) gz = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_z_raw", scale_name) - return (gx, gy, gz) + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2] + ) class _QMI8658Driver(_IMUDriver): """Wrapper for QMI8658 IMU (Waveshare board).""" From 1ffd59155e69a4bacd2f9ca1354363935ad7ec20 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 21 Feb 2026 08:38:28 +0100 Subject: [PATCH 072/317] SensorManager: handle IIO if the sensor isn't present --- internal_filesystem/lib/mpos/sensor_manager.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 55078126..1e4b47e0 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -967,6 +967,9 @@ def read_temperature(self) -> float: - in_temp_input (already scaled, usually millidegree C) - in_temp_raw + in_temp_scale """ + if not self.accel_path: + return None + if False: # os.path.exists(self._p("in_temp_input")): v = self._read_float(self.accel_path + "/" + "in_temp_input") # Many drivers expose millidegree Celsius here. @@ -975,7 +978,11 @@ def read_temperature(self) -> float: return v # Fallback: raw + scale - return self._read_raw_scaled(self.accel_path + "/" + "in_temp_raw", self.accel_path + "/" + "in_temp_scale") + raw_path = self.accel_path + "/" + "in_temp_raw" + scale_path = self.accel_path + "/" + "in_temp_scale" + if not self._exists(raw_path) or not self._exists(scale_path): + return None + return self._read_raw_scaled(raw_path, scale_path) def read_acceleration(self) -> tuple[float, float, float]: """ From 0d4344f45b882d9c5f897bb69bf44fb83baa48fe Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 21 Feb 2026 09:05:29 +0100 Subject: [PATCH 073/317] Free space comment --- internal_filesystem/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/main.py b/internal_filesystem/main.py index 1dfb64bf..4e2829be 100644 --- a/internal_filesystem/main.py +++ b/internal_filesystem/main.py @@ -12,7 +12,7 @@ print(f"{sys.implementation=}") -print("Check free space on root filesystem:") +print("Free space on root filesystem:") stat = os.statvfs("/") total_space = stat[0] * stat[2] free_space = stat[0] * stat[3] From aea339a143a0d7c969154ff04d11ed06c8854bff Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 21 Feb 2026 11:39:25 +0100 Subject: [PATCH 074/317] Fix unit test --- internal_filesystem/lib/mpos/sensor_manager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 1e4b47e0..401198f7 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -885,6 +885,8 @@ def set_calibration(self, accel_offsets, gyro_offsets): def __init__(self): self.accel_path = self.find_iio_device_with_file("in_accel_x_raw") print("path:", self.accel_path) + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] def _p(self, name: str): return self.accel_path + "/" + name From 70915a78ca8ae3b48e1c70e3ff7a727acb3bf7e6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 21 Feb 2026 12:11:08 +0100 Subject: [PATCH 075/317] Rework IMU drivers --- internal_filesystem/lib/mpos/imu/__init__.py | 29 + internal_filesystem/lib/mpos/imu/constants.py | 15 + .../lib/mpos/imu/drivers/base.py | 82 ++ .../lib/mpos/imu/drivers/iio.py | 140 ++ .../lib/mpos/imu/drivers/mpu6886.py | 39 + .../lib/mpos/imu/drivers/qmi8658.py | 46 + .../lib/mpos/imu/drivers/wsen_isds.py | 46 + internal_filesystem/lib/mpos/imu/manager.py | 550 ++++++++ internal_filesystem/lib/mpos/imu/sensor.py | 25 + .../lib/mpos/sensor_manager.py | 1153 +---------------- 10 files changed, 1026 insertions(+), 1099 deletions(-) create mode 100644 internal_filesystem/lib/mpos/imu/__init__.py create mode 100644 internal_filesystem/lib/mpos/imu/constants.py create mode 100644 internal_filesystem/lib/mpos/imu/drivers/base.py create mode 100644 internal_filesystem/lib/mpos/imu/drivers/iio.py create mode 100644 internal_filesystem/lib/mpos/imu/drivers/mpu6886.py create mode 100644 internal_filesystem/lib/mpos/imu/drivers/qmi8658.py create mode 100644 internal_filesystem/lib/mpos/imu/drivers/wsen_isds.py create mode 100644 internal_filesystem/lib/mpos/imu/manager.py create mode 100644 internal_filesystem/lib/mpos/imu/sensor.py diff --git a/internal_filesystem/lib/mpos/imu/__init__.py b/internal_filesystem/lib/mpos/imu/__init__.py new file mode 100644 index 00000000..49ff04f9 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/__init__.py @@ -0,0 +1,29 @@ +from mpos.imu.constants import ( + TYPE_ACCELEROMETER, + TYPE_MAGNETIC_FIELD, + TYPE_GYROSCOPE, + TYPE_TEMPERATURE, + TYPE_IMU_TEMPERATURE, + TYPE_SOC_TEMPERATURE, + FACING_EARTH, + FACING_SKY, + GRAVITY, + IMU_CALIBRATION_FILENAME, +) +from mpos.imu.sensor import Sensor +from mpos.imu.manager import ImuManager + +__all__ = [ + "TYPE_ACCELEROMETER", + "TYPE_MAGNETIC_FIELD", + "TYPE_GYROSCOPE", + "TYPE_TEMPERATURE", + "TYPE_IMU_TEMPERATURE", + "TYPE_SOC_TEMPERATURE", + "FACING_EARTH", + "FACING_SKY", + "GRAVITY", + "IMU_CALIBRATION_FILENAME", + "Sensor", + "ImuManager", +] diff --git a/internal_filesystem/lib/mpos/imu/constants.py b/internal_filesystem/lib/mpos/imu/constants.py new file mode 100644 index 00000000..e4e145ea --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/constants.py @@ -0,0 +1,15 @@ +TYPE_ACCELEROMETER = 1 # Units: m/s² (meters per second squared) +TYPE_MAGNETIC_FIELD = 2 # Units: μT (micro teslas) +TYPE_GYROSCOPE = 4 # Units: deg/s (degrees per second) +TYPE_TEMPERATURE = 13 # Units: °C (generic, returns first available - deprecated) +TYPE_IMU_TEMPERATURE = 14 # Units: °C (IMU chip temperature) +TYPE_SOC_TEMPERATURE = 15 # Units: °C (MCU/SoC internal temperature) + +# mounted_position: +FACING_EARTH = 20 # underside of PCB, like fri3d_2024 +FACING_SKY = 21 # top of PCB, like waveshare_esp32_s3_lcd_touch_2 (default) + +# Gravity constant for unit conversions +GRAVITY = 9.80665 # m/s² + +IMU_CALIBRATION_FILENAME = "imu_calibration.json" diff --git a/internal_filesystem/lib/mpos/imu/drivers/base.py b/internal_filesystem/lib/mpos/imu/drivers/base.py new file mode 100644 index 00000000..10d55e92 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/base.py @@ -0,0 +1,82 @@ +import time + +from mpos.imu.constants import GRAVITY, FACING_EARTH + + +class IMUDriverBase: + """Base class for IMU drivers with shared calibration logic.""" + + def __init__(self): + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] + + def read_acceleration(self): + """Returns (x, y, z) in m/s²""" + raise NotImplementedError + + def read_gyroscope(self): + """Returns (x, y, z) in deg/s""" + raise NotImplementedError + + def read_temperature(self): + """Returns temperature in °C""" + raise NotImplementedError + + def _raw_acceleration_mps2(self): + """Returns raw (x, y, z) in m/s² for calibration sampling.""" + raise NotImplementedError + + def _raw_gyroscope_dps(self): + """Returns raw (x, y, z) in deg/s for calibration sampling.""" + raise NotImplementedError + + def calibrate_accelerometer(self, samples): + """Calibrate accel, return (x, y, z) offsets in m/s²""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + ax, ay, az = self._raw_acceleration_mps2() + sum_x += ax + sum_y += ay + sum_z += az + time.sleep_ms(10) + + if FACING_EARTH == FACING_EARTH: + sum_z *= -1 + + self.accel_offset[0] = sum_x / samples + self.accel_offset[1] = sum_y / samples + self.accel_offset[2] = (sum_z / samples) - GRAVITY + + return tuple(self.accel_offset) + + def calibrate_gyroscope(self, samples): + """Calibrate gyro, return (x, y, z) offsets in deg/s""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self._raw_gyroscope_dps() + sum_x += gx + sum_y += gy + sum_z += gz + time.sleep_ms(10) + + self.gyro_offset[0] = sum_x / samples + self.gyro_offset[1] = sum_y / samples + self.gyro_offset[2] = sum_z / samples + + return tuple(self.gyro_offset) + + def get_calibration(self): + """Return dict with 'accel_offsets' and 'gyro_offsets' keys""" + return { + "accel_offsets": self.accel_offset, + "gyro_offsets": self.gyro_offset, + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration offsets from saved values""" + if accel_offsets: + self.accel_offset = list(accel_offsets) + if gyro_offsets: + self.gyro_offset = list(gyro_offsets) diff --git a/internal_filesystem/lib/mpos/imu/drivers/iio.py b/internal_filesystem/lib/mpos/imu/drivers/iio.py new file mode 100644 index 00000000..444b4141 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/iio.py @@ -0,0 +1,140 @@ +import os + +from mpos.imu.drivers.base import IMUDriverBase + + +class IIODriver(IMUDriverBase): + """ + Read sensor data via Linux IIO sysfs. + + Typical base path: + /sys/bus/iio/devices/iio:device0 + """ + + accel_path: str + + def __init__(self): + super().__init__() + self.accel_path = self.find_iio_device_with_file("in_accel_x_raw") + print("path:", self.accel_path) + + def _p(self, name: str): + return self.accel_path + "/" + name + + def _exists(self, name): + try: + os.stat(name) + return True + except OSError: + return False + + def _is_dir(self, path): + # MicroPython: stat tuple, mode is [0] + try: + st = os.stat(path) + mode = st[0] + # directory bit (POSIX): 0o040000 + return (mode & 0o170000) == 0o040000 + except OSError: + return False + + def find_iio_device_with_file(self, filename, base_dir="/sys/bus/iio/devices/"): + """ + Returns full path to iio:deviceX that contains given filename, + e.g. "/sys/bus/iio/devices/iio:device0" + + Returns None if not found. + """ + + print("Is dir? ", self._is_dir(base_dir), base_dir) + try: + entries = os.listdir(base_dir) + except OSError: + print("Error listing dir") + return None + + for entry in entries: + print("Entry:", entry) + if not entry.startswith("iio:device"): + continue + + dev_path = base_dir + "/" + entry + if not self._is_dir(dev_path): + continue + + if self._exists(dev_path + "/" + filename): + return dev_path + + return None + + def _read_text(self, name: str) -> str: + print("Read: ", name) + f = open(name, "r") + try: + return f.readline().strip() + finally: + f.close() + + def _read_float(self, name: str) -> float: + return float(self._read_text(name)) + + def _read_int(self, name: str) -> int: + return int(self._read_text(name), 10) + + def _read_raw_scaled(self, raw_name: str, scale_name: str) -> float: + raw = self._read_int(raw_name) + scale = self._read_float(scale_name) + return raw * scale + + def read_temperature(self) -> float: + """ + Tries common IIO patterns: + - in_temp_input (already scaled, usually millidegree C) + - in_temp_raw + in_temp_scale + """ + if not self.accel_path: + return None + + raw_path = self.accel_path + "/" + "in_temp_raw" + scale_path = self.accel_path + "/" + "in_temp_scale" + if not self._exists(raw_path) or not self._exists(scale_path): + return None + return self._read_raw_scaled(raw_path, scale_path) + + def _raw_acceleration_mps2(self): + if not self.accel_path: + return (0.0, 0.0, 0.0) + scale_name = self.accel_path + "/" + "in_accel_scale" + + ax = self._read_raw_scaled(self.accel_path + "/" + "in_accel_x_raw", scale_name) + ay = self._read_raw_scaled(self.accel_path + "/" + "in_accel_y_raw", scale_name) + az = self._read_raw_scaled(self.accel_path + "/" + "in_accel_z_raw", scale_name) + + return (ax, ay, az) + + def _raw_gyroscope_dps(self): + if not self.accel_path: + return (0.0, 0.0, 0.0) + scale_name = self.accel_path + "/" + "in_anglvel_scale" + + gx = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_x_raw", scale_name) + gy = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_y_raw", scale_name) + gz = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_z_raw", scale_name) + + return (gx, gy, gz) + + def read_acceleration(self): + ax, ay, az = self._raw_acceleration_mps2() + return ( + ax - self.accel_offset[0], + ay - self.accel_offset[1], + az - self.accel_offset[2], + ) + + def read_gyroscope(self): + gx, gy, gz = self._raw_gyroscope_dps() + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2], + ) diff --git a/internal_filesystem/lib/mpos/imu/drivers/mpu6886.py b/internal_filesystem/lib/mpos/imu/drivers/mpu6886.py new file mode 100644 index 00000000..9a3ef588 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/mpu6886.py @@ -0,0 +1,39 @@ +from mpos.imu.constants import GRAVITY +from mpos.imu.drivers.base import IMUDriverBase + + +class MPU6886Driver(IMUDriverBase): + """Wrapper for MPU6886 IMU (Waveshare board).""" + + def __init__(self, i2c_bus, address): + super().__init__() + from drivers.imu_sensor.mpu6886 import MPU6886 + + self.sensor = MPU6886(i2c_bus, address=address) + + def _raw_acceleration_mps2(self): + ax, ay, az = self.sensor.acceleration + return (ax * GRAVITY, ay * GRAVITY, az * GRAVITY) + + def _raw_gyroscope_dps(self): + gx, gy, gz = self.sensor.gyro + return (gx, gy, gz) + + def read_temperature(self): + return self.sensor.temperature + + def read_acceleration(self): + ax, ay, az = self._raw_acceleration_mps2() + return ( + ax - self.accel_offset[0], + ay - self.accel_offset[1], + az - self.accel_offset[2], + ) + + def read_gyroscope(self): + gx, gy, gz = self._raw_gyroscope_dps() + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2], + ) diff --git a/internal_filesystem/lib/mpos/imu/drivers/qmi8658.py b/internal_filesystem/lib/mpos/imu/drivers/qmi8658.py new file mode 100644 index 00000000..cb061b23 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/qmi8658.py @@ -0,0 +1,46 @@ +from mpos.imu.constants import GRAVITY +from mpos.imu.drivers.base import IMUDriverBase + + +class QMI8658Driver(IMUDriverBase): + """Wrapper for QMI8658 IMU (Waveshare board).""" + + def __init__(self, i2c_bus, address): + super().__init__() + from drivers.imu_sensor.qmi8658 import QMI8658 + + _ACCELSCALE_RANGE_8G = 0b10 + _GYROSCALE_RANGE_256DPS = 0b100 + self.sensor = QMI8658( + i2c_bus, + address=address, + accel_scale=_ACCELSCALE_RANGE_8G, + gyro_scale=_GYROSCALE_RANGE_256DPS, + ) + + def _raw_acceleration_mps2(self): + ax, ay, az = self.sensor.acceleration + return (ax * GRAVITY, ay * GRAVITY, az * GRAVITY) + + def _raw_gyroscope_dps(self): + gx, gy, gz = self.sensor.gyro + return (gx, gy, gz) + + def read_acceleration(self): + ax, ay, az = self._raw_acceleration_mps2() + return ( + ax - self.accel_offset[0], + ay - self.accel_offset[1], + az - self.accel_offset[2], + ) + + def read_gyroscope(self): + gx, gy, gz = self._raw_gyroscope_dps() + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2], + ) + + def read_temperature(self): + return self.sensor.temperature diff --git a/internal_filesystem/lib/mpos/imu/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/imu/drivers/wsen_isds.py new file mode 100644 index 00000000..6e8c4262 --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/drivers/wsen_isds.py @@ -0,0 +1,46 @@ +from mpos.imu.constants import GRAVITY +from mpos.imu.drivers.base import IMUDriverBase + + +class WsenISDSDriver(IMUDriverBase): + """Wrapper for WSEN_ISDS IMU (Fri3d badge).""" + + def __init__(self, i2c_bus, address): + super().__init__() + from drivers.imu_sensor.wsen_isds import Wsen_Isds + + self.sensor = Wsen_Isds( + i2c_bus, + address=address, + acc_range="8g", + acc_data_rate="104Hz", + gyro_range="500dps", + gyro_data_rate="104Hz", + ) + + def _raw_acceleration_mps2(self): + ax, ay, az = self.sensor._read_raw_accelerations() + return ((ax / 1000) * GRAVITY, (ay / 1000) * GRAVITY, (az / 1000) * GRAVITY) + + def _raw_gyroscope_dps(self): + gx, gy, gz = self.sensor.read_angular_velocities() + return (gx / 1000.0, gy / 1000.0, gz / 1000.0) + + def read_acceleration(self): + ax, ay, az = self._raw_acceleration_mps2() + return ( + ax - self.accel_offset[0], + ay - self.accel_offset[1], + az - self.accel_offset[2], + ) + + def read_gyroscope(self): + gx, gy, gz = self._raw_gyroscope_dps() + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2], + ) + + def read_temperature(self): + return self.sensor.temperature diff --git a/internal_filesystem/lib/mpos/imu/manager.py b/internal_filesystem/lib/mpos/imu/manager.py new file mode 100644 index 00000000..7944ec9f --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/manager.py @@ -0,0 +1,550 @@ +import time + +from mpos.imu.constants import ( + TYPE_ACCELEROMETER, + TYPE_GYROSCOPE, + TYPE_IMU_TEMPERATURE, + TYPE_SOC_TEMPERATURE, + TYPE_TEMPERATURE, + FACING_EARTH, + FACING_SKY, + GRAVITY, + IMU_CALIBRATION_FILENAME, +) +from mpos.imu.sensor import Sensor +from mpos.imu.drivers.iio import IIODriver +from mpos.imu.drivers.qmi8658 import QMI8658Driver +from mpos.imu.drivers.wsen_isds import WsenISDSDriver +from mpos.imu.drivers.mpu6886 import MPU6886Driver + + +class ImuManager: + """Internal IMU manager (for SensorManager delegation).""" + + def __init__(self): + self._initialized = False + self._imu_driver = None + self._sensor_list = [] + self._i2c_bus = None + self._i2c_address = None + self._mounted_position = FACING_SKY + self._has_mcu_temperature = False + + def init(self, i2c_bus, address=0x6B, mounted_position=FACING_SKY): + self._i2c_bus = i2c_bus + self._i2c_address = address + self._mounted_position = mounted_position + + try: + import esp32 + + _ = esp32.mcu_temperature() + self._has_mcu_temperature = True + self._register_mcu_temperature_sensor() + except: + pass + + self._initialized = True + return True + + def init_iio(self): + self._imu_driver = IIODriver() + self._sensor_list = [ + Sensor( + name="Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="Linux IIO", + version=1, + max_range="?", + resolution="?", + power_ma=10, + ), + Sensor( + name="Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="Linux IIO", + version=1, + max_range="?", + resolution="?", + power_ma=10, + ), + Sensor( + name="Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="Linux IIO", + version=1, + max_range="?", + resolution="?", + power_ma=10, + ), + ] + + self._load_calibration() + + self._initialized = True + return True + + def _ensure_imu_initialized(self): + if not self._initialized or self._imu_driver is not None: + return self._imu_driver is not None + + if self._i2c_bus: + try: + print("Try QMI8658 first (Waveshare board)") + chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x00, 1)[0] + print(f"{chip_id=:#04x}") + if chip_id == 0x05: + self._imu_driver = QMI8658Driver(self._i2c_bus, self._i2c_address) + self._register_qmi8658_sensors() + self._load_calibration() + print("Use QMI8658, ok") + return True + except Exception as exc: + print("No QMI8658:", exc) + + try: + print("Try WSEN_ISDS (fri3d_2024) or LSM6DSO (fri3d_2026)") + chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x0F, 1)[0] + print(f"{chip_id=:#04x}") + if chip_id == 0x6A or chip_id == 0x6C: + self._imu_driver = WsenISDSDriver(self._i2c_bus, self._i2c_address) + self._register_wsen_isds_sensors() + self._load_calibration() + print("Use WSEN_ISDS/LSM6DSO, ok") + return True + except Exception as exc: + print("No WSEN_ISDS or LSM6DSO:", exc) + + try: + print("Try MPU6886 (M5Stack FIRE)") + chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x75, 1)[0] + print(f"{chip_id=:#04x}") + if chip_id == 0x19: + self._imu_driver = MPU6886Driver(self._i2c_bus, self._i2c_address) + self._register_mpu6886_sensors() + self._load_calibration() + print("Use MPU6886, ok") + return True + except Exception as exc: + print("No MPU6886:", exc) + + return False + + def is_available(self): + return self._initialized + + def get_sensor_list(self): + self._ensure_imu_initialized() + return self._sensor_list.copy() if self._sensor_list else [] + + def get_default_sensor(self, sensor_type): + if self._initialized and sensor_type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + self._ensure_imu_initialized() + + for sensor in self._sensor_list: + if sensor.type == sensor_type: + return sensor + return None + + def read_sensor_once(self, sensor): + if sensor.type == TYPE_ACCELEROMETER: + if self._imu_driver: + ax, ay, az = self._imu_driver.read_acceleration() + if self._mounted_position == FACING_EARTH: + az *= -1 + return (ax, ay, az) + elif sensor.type == TYPE_GYROSCOPE: + if self._imu_driver: + return self._imu_driver.read_gyroscope() + elif sensor.type == TYPE_IMU_TEMPERATURE: + if self._imu_driver: + return self._imu_driver.read_temperature() + elif sensor.type == TYPE_SOC_TEMPERATURE: + if self._has_mcu_temperature: + import esp32 + + return esp32.mcu_temperature() + elif sensor.type == TYPE_TEMPERATURE: + if self._imu_driver: + temp = self._imu_driver.read_temperature() + if temp is not None: + return temp + if self._has_mcu_temperature: + import esp32 + + return esp32.mcu_temperature() + return None + + def read_sensor(self, sensor): + if sensor is None: + return None + + if sensor.type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): + self._ensure_imu_initialized() + + max_retries = 3 + retry_delay_ms = 20 + + for attempt in range(max_retries): + try: + return self.read_sensor_once(sensor) + except Exception as exc: + import sys + + sys.print_exception(exc) + error_msg = str(exc) + if "data not ready" in error_msg and attempt < max_retries - 1: + time.sleep_ms(retry_delay_ms) + continue + print("Exception reading sensor:", error_msg) + return None + + return None + + def calibrate_sensor(self, sensor, samples=100): + self._ensure_imu_initialized() + if not self.is_available() or sensor is None: + return None + + if sensor.type == TYPE_ACCELEROMETER: + offsets = self._imu_driver.calibrate_accelerometer(samples) + elif sensor.type == TYPE_GYROSCOPE: + offsets = self._imu_driver.calibrate_gyroscope(samples) + else: + return None + + if offsets: + self._save_calibration() + + return offsets + + def check_calibration_quality(self, samples=50): + self._ensure_imu_initialized() + if not self.is_available(): + return None + + try: + accel = self.get_default_sensor(TYPE_ACCELEROMETER) + gyro = self.get_default_sensor(TYPE_GYROSCOPE) + + accel_samples = [[], [], []] + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = self.read_sensor(accel) + if data: + ax, ay, az = data + accel_samples[0].append(ax) + accel_samples[1].append(ay) + accel_samples[2].append(az) + if gyro: + data = self.read_sensor(gyro) + if data: + gx, gy, gz = data + gyro_samples[0].append(gx) + gyro_samples[1].append(gy) + gyro_samples[2].append(gz) + time.sleep_ms(10) + + accel_stats = [_calc_mean_variance(s) for s in accel_samples] + gyro_stats = [_calc_mean_variance(s) for s in gyro_samples] + + accel_mean = tuple(s[0] for s in accel_stats) + accel_variance = tuple(s[1] for s in accel_stats) + gyro_mean = tuple(s[0] for s in gyro_stats) + gyro_variance = tuple(s[1] for s in gyro_stats) + + issues = [] + scores = [] + + if accel: + accel_max_variance = max(accel_variance) + variance_score = max(0.0, 1.0 - (accel_max_variance / 1.0)) + scores.append(variance_score) + if accel_max_variance > 0.5: + issues.append( + f"High accelerometer variance: {accel_max_variance:.3f} m/s²" + ) + + ax, ay, az = accel_mean + xy_error = (abs(ax) + abs(ay)) / 2.0 + z_error = abs(az - GRAVITY) + expected_score = max(0.0, 1.0 - ((xy_error + z_error) / 5.0)) + scores.append(expected_score) + if xy_error > 1.0: + issues.append( + f"Accel X/Y not near zero: X={ax:.2f}, Y={ay:.2f} m/s²" + ) + if z_error > 1.0: + issues.append(f"Accel Z not near 9.8: Z={az:.2f} m/s²") + + if gyro: + gyro_max_variance = max(gyro_variance) + variance_score = max(0.0, 1.0 - (gyro_max_variance / 10.0)) + scores.append(variance_score) + if gyro_max_variance > 5.0: + issues.append( + f"High gyroscope variance: {gyro_max_variance:.3f} deg/s" + ) + + gx, gy, gz = gyro_mean + error = (abs(gx) + abs(gy) + abs(gz)) / 3.0 + expected_score = max(0.0, 1.0 - (error / 10.0)) + scores.append(expected_score) + if error > 2.0: + issues.append( + f"Gyro not near zero: X={gx:.2f}, Y={gy:.2f}, Z={gz:.2f} deg/s" + ) + + quality_score = sum(scores) / len(scores) if scores else 0.0 + + if quality_score >= 0.8: + quality_rating = "Good" + elif quality_score >= 0.5: + quality_rating = "Fair" + else: + quality_rating = "Poor" + + return { + "accel_mean": accel_mean, + "accel_variance": accel_variance, + "gyro_mean": gyro_mean, + "gyro_variance": gyro_variance, + "quality_score": quality_score, + "quality_rating": quality_rating, + "issues": issues, + } + + except Exception as exc: + print(f"[SensorManager] Error checking calibration quality: {exc}") + return None + + def check_stationarity( + self, + samples=30, + variance_threshold_accel=0.5, + variance_threshold_gyro=5.0, + ): + self._ensure_imu_initialized() + if not self.is_available(): + return None + + try: + accel = self.get_default_sensor(TYPE_ACCELEROMETER) + gyro = self.get_default_sensor(TYPE_GYROSCOPE) + + accel_samples = [[], [], []] + gyro_samples = [[], [], []] + + for _ in range(samples): + if accel: + data = self.read_sensor(accel) + if data: + ax, ay, az = data + accel_samples[0].append(ax) + accel_samples[1].append(ay) + accel_samples[2].append(az) + if gyro: + data = self.read_sensor(gyro) + if data: + gx, gy, gz = data + gyro_samples[0].append(gx) + gyro_samples[1].append(gy) + gyro_samples[2].append(gz) + time.sleep_ms(10) + + accel_var = [_calc_variance(s) for s in accel_samples] + gyro_var = [_calc_variance(s) for s in gyro_samples] + + max_accel_var = max(accel_var) if accel_var else 0.0 + max_gyro_var = max(gyro_var) if gyro_var else 0.0 + + accel_stationary = max_accel_var < variance_threshold_accel + gyro_stationary = max_gyro_var < variance_threshold_gyro + is_stationary = accel_stationary and gyro_stationary + + if is_stationary: + message = "Device is stationary - ready to calibrate" + else: + problems = [] + if not accel_stationary: + problems.append( + f"movement detected (accel variance: {max_accel_var:.3f})" + ) + if not gyro_stationary: + problems.append( + f"rotation detected (gyro variance: {max_gyro_var:.3f})" + ) + message = f"Device NOT stationary: {', '.join(problems)}" + + return { + "is_stationary": is_stationary, + "accel_variance": max_accel_var, + "gyro_variance": max_gyro_var, + "message": message, + } + + except Exception as exc: + print(f"[SensorManager] Error checking stationarity: {exc}") + return None + + def _register_qmi8658_sensors(self): + self._sensor_list = [ + Sensor( + name="QMI8658 Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="QST Corporation", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2, + ), + Sensor( + name="QMI8658 Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="QST Corporation", + version=1, + max_range="±256 deg/s", + resolution="0.002 deg/s", + power_ma=0.7, + ), + Sensor( + name="QMI8658 Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="QST Corporation", + version=1, + max_range="-40°C to +85°C", + resolution="0.004°C", + power_ma=0, + ), + ] + + def _register_mpu6886_sensors(self): + self._sensor_list = [ + Sensor( + name="MPU6886 Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="InvenSense", + version=1, + max_range="±16g", + resolution="0.0024 m/s²", + power_ma=0.2, + ), + Sensor( + name="MPU6886 Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="InvenSense", + version=1, + max_range="±256 deg/s", + resolution="0.002 deg/s", + power_ma=0.7, + ), + Sensor( + name="MPU6886 Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="InvenSense", + version=1, + max_range="-40°C to +85°C", + resolution="0.05°C", + power_ma=0, + ), + ] + + def _register_wsen_isds_sensors(self): + self._sensor_list = [ + Sensor( + name="WSEN_ISDS Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="Würth Elektronik", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2, + ), + Sensor( + name="WSEN_ISDS Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="Würth Elektronik", + version=1, + max_range="±500 deg/s", + resolution="0.0175 deg/s", + power_ma=0.65, + ), + Sensor( + name="WSEN_ISDS Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="Würth Elektronik", + version=1, + max_range="-40°C to +85°C", + resolution="0.004°C", + power_ma=0, + ), + ] + + def _register_mcu_temperature_sensor(self): + self._sensor_list.append( + Sensor( + name="ESP32 MCU Temperature", + sensor_type=TYPE_SOC_TEMPERATURE, + vendor="Espressif", + version=1, + max_range="-40°C to +125°C", + resolution="0.5°C", + power_ma=0, + ) + ) + + def _load_calibration(self): + if not self._imu_driver: + return + + try: + from mpos.config import SharedPreferences + + prefs_new = SharedPreferences( + "com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME + ) + accel_offsets = prefs_new.get_list("accel_offsets") + gyro_offsets = prefs_new.get_list("gyro_offsets") + + if accel_offsets or gyro_offsets: + self._imu_driver.set_calibration(accel_offsets, gyro_offsets) + except: + pass + + def _save_calibration(self): + if not self._imu_driver: + return + + try: + from mpos.config import SharedPreferences + + prefs = SharedPreferences( + "com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME + ) + editor = prefs.edit() + + cal = self._imu_driver.get_calibration() + editor.put_list("accel_offsets", list(cal["accel_offsets"])) + editor.put_list("gyro_offsets", list(cal["gyro_offsets"])) + editor.commit() + except: + pass + + +def _calc_mean_variance(samples_list): + if not samples_list: + return 0.0, 0.0 + n = len(samples_list) + mean = sum(samples_list) / n + variance = sum((x - mean) ** 2 for x in samples_list) / n + return mean, variance + + +def _calc_variance(samples_list): + if not samples_list: + return 0.0 + n = len(samples_list) + mean = sum(samples_list) / n + return sum((x - mean) ** 2 for x in samples_list) / n diff --git a/internal_filesystem/lib/mpos/imu/sensor.py b/internal_filesystem/lib/mpos/imu/sensor.py new file mode 100644 index 00000000..dc819f7d --- /dev/null +++ b/internal_filesystem/lib/mpos/imu/sensor.py @@ -0,0 +1,25 @@ +class Sensor: + """Sensor metadata (lightweight data class, Android-inspired).""" + + def __init__(self, name, sensor_type, vendor, version, max_range, resolution, power_ma): + """Initialize sensor metadata. + + Args: + name: Human-readable sensor name + sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + vendor: Sensor vendor/manufacturer + version: Driver version + max_range: Maximum measurement range (with units) + resolution: Measurement resolution (with units) + power_ma: Power consumption in mA (or 0 if unknown) + """ + self.name = name + self.type = sensor_type + self.vendor = vendor + self.version = version + self.max_range = max_range + self.resolution = resolution + self.power = power_ma + + def __repr__(self): + return f"Sensor({self.name}, type={self.type})" diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 401198f7..6b96bce7 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -18,58 +18,25 @@ Copyright (c) 2024 MicroPythonOS contributors """ -import time -import os 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_MAGNETIC_FIELD = 2 # Units: μT (micro teslas) -TYPE_GYROSCOPE = 4 # Units: deg/s (degrees per second) -TYPE_TEMPERATURE = 13 # Units: °C (generic, returns first available - deprecated) -TYPE_IMU_TEMPERATURE = 14 # Units: °C (IMU chip temperature) -TYPE_SOC_TEMPERATURE = 15 # Units: °C (MCU/SoC internal temperature) - -# mounted_position: -FACING_EARTH = 20 # underside of PCB, like fri3d_2024 -FACING_SKY = 21 # top of PCB, like waveshare_esp32_s3_lcd_touch_2 (default) - -# Gravity constant for unit conversions -_GRAVITY = 9.80665 # m/s² - -IMU_CALIBRATION_FILENAME = "imu_calibration.json" - - -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})" +from mpos.imu.constants import ( + TYPE_ACCELEROMETER, + TYPE_MAGNETIC_FIELD, + TYPE_GYROSCOPE, + TYPE_TEMPERATURE, + TYPE_IMU_TEMPERATURE, + TYPE_SOC_TEMPERATURE, + FACING_EARTH, + FACING_SKY, +) +from mpos.imu.manager import ImuManager +from mpos.imu.sensor import Sensor class SensorManager: @@ -91,15 +58,10 @@ class SensorManager: """ _instance = None - + # Class-level state variables (for testing and singleton pattern) _initialized = False - _imu_driver = None - _sensor_list = [] - _i2c_bus = None - _i2c_address = None - _mounted_position = FACING_SKY - _has_mcu_temperature = False + _imu_manager = None # Class-level constants TYPE_ACCELEROMETER = TYPE_ACCELEROMETER @@ -134,115 +96,22 @@ def init(self, i2c_bus, address=0x6B, mounted_position=FACING_SKY): Returns: bool: True if initialized successfully """ - self._i2c_bus = i2c_bus - self._i2c_address = address - self._mounted_position = mounted_position - - # Initialize MCU temperature sensor immediately (fast, no I2C needed) - try: - import esp32 - _ = esp32.mcu_temperature() - self._has_mcu_temperature = True - self._register_mcu_temperature_sensor() - except: - pass - - self._initialized = True - return True + self._ensure_imu_manager() + self._initialized = self._imu_manager.init( + i2c_bus, + address=address, + mounted_position=mounted_position, + ) + return self._initialized def init_iio(self): - self._imu_driver = _IIODriver() - self._sensor_list = [ - Sensor( - name="Accelerometer", - sensor_type=TYPE_ACCELEROMETER, - vendor="Linux IIO", - version=1, - max_range="?", - resolution="?", - power_ma=10 - ), - Sensor( - name="Gyroscope", - sensor_type=TYPE_GYROSCOPE, - vendor="Linux IIO", - version=1, - max_range="?", - resolution="?", - power_ma=10 - ), - Sensor( - name="Temperature", - sensor_type=TYPE_IMU_TEMPERATURE, - vendor="Linux IIO", - version=1, - max_range="?", - resolution="?", - power_ma=10 - ) - ] - - self._load_calibration() - - self._initialized = True - return True - - def _ensure_imu_initialized(self): - """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 - """ - if not self._initialized or self._imu_driver is not None: - return self._imu_driver is not None - - if self._i2c_bus: - try: - print("Try QMI8658 first (Waveshare board)") - # PARTID register: - chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x00, 1)[0] - print(f"{chip_id=:#04x}") - if chip_id == 0x05: # QMI8685_PARTID - self._imu_driver = _QMI8658Driver(self._i2c_bus, self._i2c_address) - self._register_qmi8658_sensors() - self._load_calibration() - print("Use QMI8658, ok") - return True - except Exception as e: - print("No QMI8658:", e) - - try: - print("Try WSEN_ISDS (fri3d_2024) or LSM6DSO (fri3d_2026)") - # WHO_AM_I register - could also use Wsen_Isds.get_chip_id(): - chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x0F, 1)[0] - print(f"{chip_id=:#04x}") - # WSEN_ISDS WHO_AM_I 0x6A (Fri3d 2024) or 0x6C (Fri3d 2026): - if chip_id == 0x6A or chip_id == 0x6C: - self._imu_driver = _WsenISDSDriver(self._i2c_bus, self._i2c_address) - self._register_wsen_isds_sensors() - self._load_calibration() - print("Use WSEN_ISDS/LSM6DSO, ok") - return True - except Exception as e: - print("No WSEN_ISDS or LSM6DSO:", e) - - try: - print("Try MPU6886 (M5Stack FIRE)") - chip_id = self._i2c_bus.readfrom_mem(self._i2c_address, 0x75, 1)[0] - print(f"{chip_id=:#04x}") - if chip_id == 0x19: - self._imu_driver = _MPU6886Driver(self._i2c_bus, self._i2c_address) - self._register_mpu6886_sensors() - self._load_calibration() - print("Use MPU6886, ok") - return True - except Exception as e: - print("No MPU6886:", e) + self._ensure_imu_manager() + self._initialized = self._imu_manager.init_iio() + return self._initialized - return False + def _ensure_imu_manager(self): + if self._imu_manager is None: + self._imu_manager = ImuManager() def is_available(self): """Check if sensors are available. @@ -254,7 +123,7 @@ def is_available(self): bool: True if SensorManager is initialized (may only have MCU temp, not IMU) """ return self._initialized - + def get_sensor_list(self): """Get list of all available sensors. @@ -263,9 +132,10 @@ def get_sensor_list(self): Returns: list: List of Sensor objects """ - self._ensure_imu_initialized() - return self._sensor_list.copy() if self._sensor_list else [] - + if not self._imu_manager: + return [] + return self._imu_manager.get_sensor_list() + def get_default_sensor(self, sensor_type): """Get default sensor of given type. @@ -277,42 +147,14 @@ def get_default_sensor(self, sensor_type): Returns: Sensor object or None if not available """ - # Only initialize IMU if SensorManager has been initialized and requesting IMU sensor types - if self._initialized and sensor_type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): - self._ensure_imu_initialized() - - for sensor in self._sensor_list: - if sensor.type == sensor_type: - return sensor - return None + if not self._imu_manager: + return None + return self._imu_manager.get_default_sensor(sensor_type) def read_sensor_once(self, sensor): - if sensor.type == TYPE_ACCELEROMETER: - if self._imu_driver: - ax, ay, az = self._imu_driver.read_acceleration() - if self._mounted_position == FACING_EARTH: - az *= -1 - return (ax, ay, az) - elif sensor.type == TYPE_GYROSCOPE: - if self._imu_driver: - return self._imu_driver.read_gyroscope() - elif sensor.type == TYPE_IMU_TEMPERATURE: - if self._imu_driver: - return self._imu_driver.read_temperature() - elif sensor.type == TYPE_SOC_TEMPERATURE: - if self._has_mcu_temperature: - import esp32 - return esp32.mcu_temperature() - elif sensor.type == TYPE_TEMPERATURE: - # Generic temperature - return first available (backward compatibility) - if self._imu_driver: - temp = self._imu_driver.read_temperature() - if temp is not None: - return temp - if self._has_mcu_temperature: - import esp32 - return esp32.mcu_temperature() - return None + if not self._imu_manager: + return None + return self._imu_manager.read_sensor_once(sensor) def read_sensor(self, sensor): """Read sensor data synchronously. @@ -330,35 +172,11 @@ def read_sensor(self, sensor): if sensor is None: return None - # Only initialize IMU if reading IMU sensor - if sensor.type in (TYPE_ACCELEROMETER, TYPE_GYROSCOPE): - self._ensure_imu_initialized() - if _lock: _lock.acquire() try: - # Retry logic for "sensor data not ready" (WSEN_ISDS needs time after init) - max_retries = 3 - retry_delay_ms = 20 # Wait 20ms between retries - - for attempt in range(max_retries): - try: - return self.read_sensor_once(sensor) - except Exception as e: - import sys - sys.print_exception(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: - print("Exception reading sensor:", error_msg) - return None - - return None + return self._imu_manager.read_sensor(sensor) if self._imu_manager else None finally: if _lock: _lock.release() @@ -376,34 +194,24 @@ def calibrate_sensor(self, sensor, samples=100): Returns: tuple: Calibration offsets (x, y, z) or None if failed """ - self._ensure_imu_initialized() - if not self.is_available() or sensor is None: + if not self._imu_manager: return None if _lock: _lock.acquire() try: - if sensor.type == TYPE_ACCELEROMETER: - offsets = self._imu_driver.calibrate_accelerometer(samples) - elif sensor.type == TYPE_GYROSCOPE: - offsets = self._imu_driver.calibrate_gyroscope(samples) - else: - return None - - if offsets: - self._save_calibration() - - return offsets + return self._imu_manager.calibrate_sensor(sensor, samples=samples) except Exception as e: import sys + sys.print_exception(e) print(f"[SensorManager] Calibration error: {e}") return None finally: if _lock: _lock.release() - + def check_calibration_quality(self, samples=50): """Check quality of current calibration. @@ -423,113 +231,13 @@ def check_calibration_quality(self, samples=50): - issues: list of strings describing problems None if IMU not available """ - self._ensure_imu_initialized() - if not self.is_available(): + if not self._imu_manager: return None + return self._imu_manager.check_calibration_quality(samples=samples) - # Don't acquire lock here - let read_sensor() handle it per-read - # (avoids deadlock since read_sensor also acquires the lock) - try: - accel = self.get_default_sensor(TYPE_ACCELEROMETER) - gyro = self.get_default_sensor(TYPE_GYROSCOPE) - - # Collect samples - accel_samples = [[], [], []] # x, y, z lists - gyro_samples = [[], [], []] - - for _ in range(samples): - if accel: - data = self.read_sensor(accel) - if data: - ax, ay, az = data - accel_samples[0].append(ax) - accel_samples[1].append(ay) - accel_samples[2].append(az) - if gyro: - data = self.read_sensor(gyro) - if data: - gx, gy, gz = data - gyro_samples[0].append(gx) - gyro_samples[1].append(gy) - gyro_samples[2].append(gz) - time.sleep_ms(10) - - # Calculate statistics using helper - accel_stats = [_calc_mean_variance(s) for s in accel_samples] - gyro_stats = [_calc_mean_variance(s) for s in gyro_samples] - - accel_mean = tuple(s[0] for s in accel_stats) - accel_variance = tuple(s[1] for s in accel_stats) - gyro_mean = tuple(s[0] for s in gyro_stats) - gyro_variance = tuple(s[1] for s in gyro_stats) - - # Calculate quality score (0.0 - 1.0) - issues = [] - scores = [] - - # Check accelerometer - if accel: - # Variance check (lower is better) - accel_max_variance = max(accel_variance) - variance_score = max(0.0, 1.0 - (accel_max_variance / 1.0)) # 1.0 m/s² variance threshold - scores.append(variance_score) - if accel_max_variance > 0.5: - issues.append(f"High accelerometer variance: {accel_max_variance:.3f} m/s²") - - # Expected values check (X≈0, Y≈0, Z≈9.8) - ax, ay, az = accel_mean - xy_error = (abs(ax) + abs(ay)) / 2.0 - z_error = abs(az - _GRAVITY) - expected_score = max(0.0, 1.0 - ((xy_error + z_error) / 5.0)) # 5.0 m/s² error threshold - scores.append(expected_score) - if xy_error > 1.0: - issues.append(f"Accel X/Y not near zero: X={ax:.2f}, Y={ay:.2f} m/s²") - if z_error > 1.0: - issues.append(f"Accel Z not near 9.8: Z={az:.2f} m/s²") - - # Check gyroscope - if gyro: - # Variance check - gyro_max_variance = max(gyro_variance) - variance_score = max(0.0, 1.0 - (gyro_max_variance / 10.0)) # 10 deg/s variance threshold - scores.append(variance_score) - if gyro_max_variance > 5.0: - issues.append(f"High gyroscope variance: {gyro_max_variance:.3f} deg/s") - - # Expected values check (all ≈0) - gx, gy, gz = gyro_mean - error = (abs(gx) + abs(gy) + abs(gz)) / 3.0 - expected_score = max(0.0, 1.0 - (error / 10.0)) # 10 deg/s error threshold - scores.append(expected_score) - if error > 2.0: - issues.append(f"Gyro not near zero: X={gx:.2f}, Y={gy:.2f}, Z={gz:.2f} deg/s") - - # Overall quality score - quality_score = sum(scores) / len(scores) if scores else 0.0 - - # Rating - if quality_score >= 0.8: - quality_rating = "Good" - elif quality_score >= 0.5: - quality_rating = "Fair" - else: - quality_rating = "Poor" - - return { - 'accel_mean': accel_mean, - 'accel_variance': accel_variance, - 'gyro_mean': gyro_mean, - 'gyro_variance': gyro_variance, - 'quality_score': quality_score, - 'quality_rating': quality_rating, - 'issues': issues - } - - except Exception as e: - print(f"[SensorManager] Error checking calibration quality: {e}") - return None - - def check_stationarity(self, samples=30, variance_threshold_accel=0.5, variance_threshold_gyro=5.0): + def check_stationarity( + self, samples=30, variance_threshold_accel=0.5, variance_threshold_gyro=5.0 + ): """Check if device is stationary (required for calibration). Args: @@ -545,766 +253,13 @@ def check_stationarity(self, samples=30, variance_threshold_accel=0.5, variance_ - message: string describing result None if IMU not available """ - self._ensure_imu_initialized() - if not self.is_available(): - return None - - # Don't acquire lock here - let read_sensor() handle it per-read - # (avoids deadlock since read_sensor also acquires the lock) - try: - accel = self.get_default_sensor(TYPE_ACCELEROMETER) - gyro = self.get_default_sensor(TYPE_GYROSCOPE) - - # Collect samples - accel_samples = [[], [], []] - gyro_samples = [[], [], []] - - for _ in range(samples): - if accel: - data = self.read_sensor(accel) - if data: - ax, ay, az = data - accel_samples[0].append(ax) - accel_samples[1].append(ay) - accel_samples[2].append(az) - if gyro: - data = self.read_sensor(gyro) - if data: - gx, gy, gz = data - gyro_samples[0].append(gx) - gyro_samples[1].append(gy) - gyro_samples[2].append(gz) - time.sleep_ms(10) - - # Calculate variance using 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 - - def _register_qmi8658_sensors(self): - """Register QMI8658 sensors in sensor list.""" - self._sensor_list = [ - Sensor( - name="QMI8658 Accelerometer", - sensor_type=TYPE_ACCELEROMETER, - vendor="QST Corporation", - version=1, - max_range="±8G (78.4 m/s²)", - resolution="0.0024 m/s²", - power_ma=0.2 - ), - Sensor( - name="QMI8658 Gyroscope", - sensor_type=TYPE_GYROSCOPE, - vendor="QST Corporation", - version=1, - max_range="±256 deg/s", - resolution="0.002 deg/s", - power_ma=0.7 - ), - Sensor( - name="QMI8658 Temperature", - sensor_type=TYPE_IMU_TEMPERATURE, - vendor="QST Corporation", - version=1, - max_range="-40°C to +85°C", - resolution="0.004°C", - power_ma=0 - ) - ] - - def _register_mpu6886_sensors(self): - """Register MPU6886 sensors in sensor list.""" - self._sensor_list = [ - Sensor( - name="MPU6886 Accelerometer", - sensor_type=TYPE_ACCELEROMETER, - vendor="InvenSense", - version=1, - max_range="±16g", - resolution="0.0024 m/s²", - power_ma=0.2, - ), - Sensor( - name="MPU6886 Gyroscope", - sensor_type=TYPE_GYROSCOPE, - vendor="InvenSense", - version=1, - max_range="±256 deg/s", - resolution="0.002 deg/s", - power_ma=0.7, - ), - Sensor( - name="MPU6886 Temperature", - sensor_type=TYPE_IMU_TEMPERATURE, - vendor="InvenSense", - version=1, - max_range="-40°C to +85°C", - resolution="0.05°C", - power_ma=0, - ), - ] - - def _register_wsen_isds_sensors(self): - """Register WSEN_ISDS sensors in sensor list.""" - self._sensor_list = [ - Sensor( - name="WSEN_ISDS Accelerometer", - sensor_type=TYPE_ACCELEROMETER, - vendor="Würth Elektronik", - version=1, - max_range="±8G (78.4 m/s²)", - resolution="0.0024 m/s²", - power_ma=0.2 - ), - Sensor( - name="WSEN_ISDS Gyroscope", - sensor_type=TYPE_GYROSCOPE, - vendor="Würth Elektronik", - version=1, - max_range="±500 deg/s", - resolution="0.0175 deg/s", - power_ma=0.65 - ), - Sensor( - name="WSEN_ISDS Temperature", - sensor_type=TYPE_IMU_TEMPERATURE, - vendor="Würth Elektronik", - version=1, - max_range="-40°C to +85°C", - resolution="0.004°C", - power_ma=0 - ) - ] - - def _register_mcu_temperature_sensor(self): - """Register MCU internal temperature sensor in sensor list.""" - self._sensor_list.append( - Sensor( - name="ESP32 MCU Temperature", - sensor_type=TYPE_SOC_TEMPERATURE, - vendor="Espressif", - version=1, - max_range="-40°C to +125°C", - resolution="0.5°C", - power_ma=0 - ) - ) - - def _load_calibration(self): - """Load calibration from SharedPreferences (with migration support).""" - if not self._imu_driver: - return - - try: - from mpos.config import SharedPreferences - - # Try NEW location first - prefs_new = SharedPreferences("com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME) - accel_offsets = prefs_new.get_list("accel_offsets") - gyro_offsets = prefs_new.get_list("gyro_offsets") - - if accel_offsets or gyro_offsets: - self._imu_driver.set_calibration(accel_offsets, gyro_offsets) - except: - pass - - def _save_calibration(self): - """Save calibration to SharedPreferences.""" - if not self._imu_driver: - return - - try: - from mpos.config import SharedPreferences - prefs = SharedPreferences("com.micropythonos.settings", filename=IMU_CALIBRATION_FILENAME) - editor = prefs.edit() - - cal = self._imu_driver.get_calibration() - editor.put_list("accel_offsets", list(cal['accel_offsets'])) - editor.put_list("gyro_offsets", list(cal['gyro_offsets'])) - editor.commit() - except: - pass - - -# ============================================================================ -# Helper functions for calibration quality checking -# ============================================================================ - -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 - - -# ============================================================================ -# 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 _IIODriver(_IMUDriver): - def __init__(self): - self.accel_path = self.find_iio_device_with_file("in_accel_x_raw") - print("path:", self.accel_path) - self.accel_offset = [0.0, 0.0, 0.0] - self.gyro_offset = [0.0, 0.0, 0.0] - - 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.read_acceleration() - sum_x += ax - sum_y += ay - sum_z += az - time.sleep_ms(10) - - if FACING_EARTH == FACING_EARTH: - sum_z *= -1 - - # Average offsets (assuming Z-axis should read +9.8 m/s²) - self.accel_offset[0] = sum_x / samples - self.accel_offset[1] = sum_y / samples - self.accel_offset[2] = (sum_z / samples) - _GRAVITY - - return tuple(self.accel_offset) - - def calibrate_gyroscope(self, samples): - """Calibrate gyroscope (device must be stationary).""" - sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - - for _ in range(samples): - gx, gy, gz = self.read_gyroscope() - 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) - """ - Read sensor data via Linux IIO sysfs. - - Typical base path: - /sys/bus/iio/devices/iio:device0 - """ - accel_path: str - - def __init__(self): - self.accel_path = self.find_iio_device_with_file("in_accel_x_raw") - print("path:", self.accel_path) - self.accel_offset = [0.0, 0.0, 0.0] - self.gyro_offset = [0.0, 0.0, 0.0] - - def _p(self, name: str): - return self.accel_path + "/" + name - - def _exists(self, name): - try: - os.stat(name) - return True - except OSError: - return False - - def _is_dir(self, path): - # MicroPython: stat tuple, mode is [0] - try: - st = os.stat(path) - mode = st[0] - # directory bit (POSIX): 0o040000 - return (mode & 0o170000) == 0o040000 - except OSError: - return False - - def find_iio_device_with_file(self, filename, base_dir="/sys/bus/iio/devices/"): - """ - Returns full path to iio:deviceX that contains given filename, - e.g. "/sys/bus/iio/devices/iio:device0" - - Returns None if not found. - """ - - print("Is dir? ", self._is_dir(base_dir), base_dir) - try: - entries = os.listdir(base_dir) - except OSError: - print("Error listing dir") - return None - - for e in entries: - print("Entry:", e) - if not e.startswith("iio:device"): - continue - - print("Entry:", e) - - dev_path = base_dir + "/" + e - if not self._is_dir(dev_path): - continue - - if self._exists(dev_path + "/" + filename): - return dev_path - - return None - - def _read_text(self, name: str) -> str: - p = name - print("Read: ", p) - f = open(p, "r") - try: - return f.readline().strip() - finally: - f.close() - - def _read_float(self, name: str) -> float: - return float(self._read_text(name)) - - def _read_int(self, name: str) -> int: - return int(self._read_text(name), 10) - - def _read_raw_scaled(self, raw_name: str, scale_name: str) -> float: - raw = self._read_int(raw_name) - scale = self._read_float(scale_name) - return raw * scale - - # ---------------------------- - # Public API (replacing I2C) - # ---------------------------- - - def read_temperature(self) -> float: - """ - Tries common IIO patterns: - - in_temp_input (already scaled, usually millidegree C) - - in_temp_raw + in_temp_scale - """ - if not self.accel_path: + if not self._imu_manager: return None - - if False: # os.path.exists(self._p("in_temp_input")): - v = self._read_float(self.accel_path + "/" + "in_temp_input") - # Many drivers expose millidegree Celsius here. - if abs(v) > 200: # heuristic: 25000 means 25°C - return v / 1000.0 - return v - - # Fallback: raw + scale - raw_path = self.accel_path + "/" + "in_temp_raw" - scale_path = self.accel_path + "/" + "in_temp_scale" - if not self._exists(raw_path) or not self._exists(scale_path): - return None - return self._read_raw_scaled(raw_path, scale_path) - - def read_acceleration(self) -> tuple[float, float, float]: - """ - Returns acceleration in m/s^2 if the kernel driver uses standard IIO scale. - Common names: - in_accel_{x,y,z}_raw + in_accel_scale - """ - if not self.accel_path: - return (0.0, 0.0, 0.0) - scale_name = self.accel_path + "/" + "in_accel_scale" - - ax = self._read_raw_scaled(self.accel_path + "/" + "in_accel_x_raw", scale_name) - ay = self._read_raw_scaled(self.accel_path + "/" + "in_accel_y_raw", scale_name) - az = self._read_raw_scaled(self.accel_path + "/" + "in_accel_z_raw", scale_name) - - return ( - ax - self.accel_offset[0], - ay - self.accel_offset[1], - az - self.accel_offset[2] - ) - - def read_gyroscope(self) -> tuple[float, float, float]: - """ - Returns angular velocity in rad/s if the kernel driver uses standard IIO scale. - Common names: - in_anglvel_{x,y,z}_raw + in_anglvel_scale - """ - if not self.accel_path: - return (0.0, 0.0, 0.0) - scale_name = self.accel_path + "/" + "in_anglvel_scale" - - gx = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_x_raw", scale_name) - gy = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_y_raw", scale_name) - gz = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_z_raw", scale_name) - - return ( - gx - self.gyro_offset[0], - gy - self.gyro_offset[1], - gz - self.gyro_offset[2] - ) - -class _QMI8658Driver(_IMUDriver): - """Wrapper for QMI8658 IMU (Waveshare board).""" - - def __init__(self, i2c_bus, address): - from drivers.imu_sensor.qmi8658 import QMI8658 - # QMI8658 scale constants (can't import const() values) - _ACCELSCALE_RANGE_8G = 0b10 - _GYROSCALE_RANGE_256DPS = 0b100 - self.sensor = QMI8658( - i2c_bus, - address=address, - accel_scale=_ACCELSCALE_RANGE_8G, - gyro_scale=_GYROSCALE_RANGE_256DPS + return self._imu_manager.check_stationarity( + samples=samples, + variance_threshold_accel=variance_threshold_accel, + variance_threshold_gyro=variance_threshold_gyro, ) - # Software calibration offsets (QMI8658 has no built-in calibration) - self.accel_offset = [0.0, 0.0, 0.0] - self.gyro_offset = [0.0, 0.0, 0.0] - - def read_acceleration(self): - """Read acceleration in m/s² (converts from G).""" - ax, ay, az = self.sensor.acceleration - # Convert G to m/s² and apply calibration - return ( - (ax * _GRAVITY) - self.accel_offset[0], - (ay * _GRAVITY) - self.accel_offset[1], - (az * _GRAVITY) - self.accel_offset[2] - ) - - def read_gyroscope(self): - """Read gyroscope in deg/s (already in correct units).""" - gx, gy, gz = self.sensor.gyro - # Apply calibration - return ( - gx - self.gyro_offset[0], - gy - self.gyro_offset[1], - gz - self.gyro_offset[2] - ) - - def read_temperature(self): - """Read temperature in °C.""" - return self.sensor.temperature - - def calibrate_accelerometer(self, samples): - """Calibrate accelerometer (device must be stationary).""" - sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - - for _ in range(samples): - ax, ay, az = self.sensor.acceleration - sum_x += ax * _GRAVITY - sum_y += ay * _GRAVITY - sum_z += az * _GRAVITY - time.sleep_ms(10) - - if FACING_EARTH == FACING_EARTH: - sum_z *= -1 - - # Average offsets (assuming Z-axis should read +9.8 m/s²) - self.accel_offset[0] = sum_x / samples - self.accel_offset[1] = sum_y / samples - self.accel_offset[2] = (sum_z / samples) - _GRAVITY - - return tuple(self.accel_offset) - - def calibrate_gyroscope(self, samples): - """Calibrate gyroscope (device must be stationary).""" - sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - - for _ in range(samples): - gx, gy, gz = self.sensor.gyro - sum_x += gx - sum_y += gy - sum_z += gz - time.sleep_ms(10) - - # Average offsets (should be 0 when stationary) - self.gyro_offset[0] = sum_x / samples - self.gyro_offset[1] = sum_y / samples - self.gyro_offset[2] = sum_z / samples - - return tuple(self.gyro_offset) - - def get_calibration(self): - """Get current calibration.""" - return { - 'accel_offsets': self.accel_offset, - 'gyro_offsets': self.gyro_offset - } - - def set_calibration(self, accel_offsets, gyro_offsets): - """Set calibration from saved values.""" - if accel_offsets: - self.accel_offset = list(accel_offsets) - if gyro_offsets: - self.gyro_offset = list(gyro_offsets) - - -class _MPU6886Driver(_IMUDriver): - """Wrapper for MPU6886 IMU (Waveshare board).""" - - def __init__(self, i2c_bus, address): - from drivers.imu_sensor.mpu6886 import MPU6886 - - self.sensor = MPU6886(i2c_bus, address=address) - # Software calibration offsets (MPU6886 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_temperature(self): - """Read temperature in °C.""" - return self.sensor.temperature - - 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 calibrate_accelerometer(self, samples): - """Calibrate accelerometer (device must be stationary).""" - sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - - for _ in range(samples): - ax, ay, az = self.sensor.acceleration - sum_x += ax * _GRAVITY - sum_y += ay * _GRAVITY - sum_z += az * _GRAVITY - time.sleep_ms(10) - - if FACING_EARTH == FACING_EARTH: - sum_z *= -1 - - # Average offsets (assuming Z-axis should read +9.8 m/s²) - self.accel_offset[0] = sum_x / samples - self.accel_offset[1] = sum_y / samples - self.accel_offset[2] = (sum_z / samples) - _GRAVITY - - return tuple(self.accel_offset) - - def calibrate_gyroscope(self, samples): - """Calibrate gyroscope (device must be stationary).""" - sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - - for _ in range(samples): - gx, gy, gz = self.sensor.gyro - sum_x += gx - sum_y += gy - sum_z += gz - time.sleep_ms(10) - - # Average offsets (should be 0 when stationary) - self.gyro_offset[0] = sum_x / samples - self.gyro_offset[1] = sum_y / samples - self.gyro_offset[2] = sum_z / samples - - return tuple(self.gyro_offset) - - def get_calibration(self): - """Get current calibration.""" - return {"accel_offsets": self.accel_offset, "gyro_offsets": self.gyro_offset} - - def set_calibration(self, accel_offsets, gyro_offsets): - """Set calibration from saved values.""" - if accel_offsets: - self.accel_offset = list(accel_offsets) - if gyro_offsets: - self.gyro_offset = list(gyro_offsets) - - -class _WsenISDSDriver(_IMUDriver): - """Wrapper for WSEN_ISDS IMU (Fri3d badge).""" - - def __init__(self, i2c_bus, address): - from drivers.imu_sensor.wsen_isds import Wsen_Isds - self.sensor = Wsen_Isds( - i2c_bus, - address=address, - acc_range="8g", - acc_data_rate="104Hz", - gyro_range="500dps", - gyro_data_rate="104Hz" - ) - # Software calibration offsets - self.accel_offset = [0.0, 0.0, 0.0] - self.gyro_offset = [0.0, 0.0, 0.0] - - def read_acceleration(self): - """Read acceleration in m/s² (converts from mg).""" - ax, ay, az = self.sensor._read_raw_accelerations() - - # Convert G to m/s² and apply calibration - return ( - ((ax / 1000) * _GRAVITY) - self.accel_offset[0], - ((ay / 1000) * _GRAVITY) - self.accel_offset[1], - ((az / 1000) * _GRAVITY) - self.accel_offset[2] - ) - - def read_gyroscope(self): - """Read gyroscope in deg/s (converts from mdps).""" - gx, gy, gz = self.sensor.read_angular_velocities() - # Convert mdps to deg/s and apply calibration - return ( - gx / 1000.0 - self.gyro_offset[0], - gy / 1000.0 - self.gyro_offset[1], - gz / 1000.0 - self.gyro_offset[2] - ) - - def read_temperature(self): - return self.sensor.temperature - - def calibrate_accelerometer(self, samples): - """Calibrate accelerometer (device must be stationary).""" - sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - - for _ in range(samples): - ax, ay, az = self.sensor._read_raw_accelerations() - sum_x += (ax / 1000.0) * _GRAVITY - sum_y += (ay / 1000.0) * _GRAVITY - sum_z += (az / 1000.0) * _GRAVITY - time.sleep_ms(10) - - print(f"sumz: {sum_z}") - z_offset = 0 - if FACING_EARTH == FACING_EARTH: - sum_z *= -1 - print(f"sumz: {sum_z}") - - # Average offsets (assuming Z-axis should read +9.8 m/s²) - self.accel_offset[0] = sum_x / samples - self.accel_offset[1] = sum_y / samples - self.accel_offset[2] = (sum_z / samples) - _GRAVITY - print(f"offsets: {self.accel_offset}") - - return tuple(self.accel_offset) - - def calibrate_gyroscope(self, samples): - """Calibrate gyroscope (device must be stationary).""" - sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 - - for _ in range(samples): - gx, gy, gz = self.sensor.read_angular_velocities() - sum_x += gx / 1000.0 - sum_y += gy / 1000.0 - sum_z += gz / 1000.0 - time.sleep_ms(10) - - # Average offsets (should be 0 when stationary) - self.gyro_offset[0] = sum_x / samples - self.gyro_offset[1] = sum_y / samples - self.gyro_offset[2] = sum_z / samples - - return tuple(self.gyro_offset) - - def get_calibration(self): - """Get current calibration.""" - return { - 'accel_offsets': self.accel_offset, - 'gyro_offsets': self.gyro_offset - } - - def set_calibration(self, accel_offsets, gyro_offsets): - """Set calibration from saved values.""" - if accel_offsets: - self.accel_offset = list(accel_offsets) - if gyro_offsets: - self.gyro_offset = list(gyro_offsets) # ============================================================================ From bc4611bf4b2f7c8c36d852355b51d62777a78362 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Sun, 22 Feb 2026 00:12:46 +0100 Subject: [PATCH 076/317] magn: Start magnetometer support Add magnetometer support for iio. With suitable application, this allows painting compass. --- internal_filesystem/lib/mpos/imu/drivers/base.py | 4 ++++ internal_filesystem/lib/mpos/imu/drivers/iio.py | 10 +++++++++- internal_filesystem/lib/mpos/imu/manager.py | 13 +++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/imu/drivers/base.py b/internal_filesystem/lib/mpos/imu/drivers/base.py index 10d55e92..fb375f21 100644 --- a/internal_filesystem/lib/mpos/imu/drivers/base.py +++ b/internal_filesystem/lib/mpos/imu/drivers/base.py @@ -18,6 +18,10 @@ def read_gyroscope(self): """Returns (x, y, z) in deg/s""" raise NotImplementedError + def read_magnetometer(self): + """Returns (x, y, z) in uT""" + raise NotImplementedError + def read_temperature(self): """Returns temperature in °C""" raise NotImplementedError diff --git a/internal_filesystem/lib/mpos/imu/drivers/iio.py b/internal_filesystem/lib/mpos/imu/drivers/iio.py index 444b4141..71d71830 100644 --- a/internal_filesystem/lib/mpos/imu/drivers/iio.py +++ b/internal_filesystem/lib/mpos/imu/drivers/iio.py @@ -12,11 +12,12 @@ class IIODriver(IMUDriverBase): """ accel_path: str + mag_path: str def __init__(self): super().__init__() self.accel_path = self.find_iio_device_with_file("in_accel_x_raw") - print("path:", self.accel_path) + self.mag_path = self.find_iio_device_with_file("in_magn_x_raw") def _p(self, name: str): return self.accel_path + "/" + name @@ -138,3 +139,10 @@ def read_gyroscope(self): gy - self.gyro_offset[1], gz - self.gyro_offset[2], ) + + def read_magnetometer(self) -> tuple[float, float, float]: + gx = self._read_raw_scaled(self.mag_path + "/" + "in_magn_x_raw", self.mag_path + "/" + "in_magn_x_scale") + gy = self._read_raw_scaled(self.mag_path + "/" + "in_magn_y_raw", self.mag_path + "/" + "in_magn_y_scale") + gz = self._read_raw_scaled(self.mag_path + "/" + "in_magn_z_raw", self.mag_path + "/" + "in_magn_z_scale") + + return (gx, gy, gz) diff --git a/internal_filesystem/lib/mpos/imu/manager.py b/internal_filesystem/lib/mpos/imu/manager.py index 7944ec9f..0c5f9b61 100644 --- a/internal_filesystem/lib/mpos/imu/manager.py +++ b/internal_filesystem/lib/mpos/imu/manager.py @@ -3,6 +3,7 @@ from mpos.imu.constants import ( TYPE_ACCELEROMETER, TYPE_GYROSCOPE, + TYPE_MAGNETIC_FIELD, TYPE_IMU_TEMPERATURE, TYPE_SOC_TEMPERATURE, TYPE_TEMPERATURE, @@ -50,6 +51,15 @@ def init(self, i2c_bus, address=0x6B, mounted_position=FACING_SKY): def init_iio(self): self._imu_driver = IIODriver() self._sensor_list = [ + Sensor( + name="Magnetometer", + sensor_type=TYPE_MAGNETIC_FIELD, + vendor="Linux IIO", + version=1, + max_range="?", + resolution="?", + power_ma=0.2 + ), Sensor( name="Accelerometer", sensor_type=TYPE_ACCELEROMETER, @@ -156,6 +166,9 @@ def read_sensor_once(self, sensor): elif sensor.type == TYPE_GYROSCOPE: if self._imu_driver: return self._imu_driver.read_gyroscope() + elif sensor.type == TYPE_MAGNETIC_FIELD: + if self._imu_driver: + return self._imu_driver.read_magnetometer() elif sensor.type == TYPE_IMU_TEMPERATURE: if self._imu_driver: return self._imu_driver.read_temperature() From 6a81e5481fb4c9b0f9422e3690858eeee8af59f6 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Tue, 17 Feb 2026 13:36:06 +0100 Subject: [PATCH 077/317] compass: Add an application for magnetometer / accelerometer debugging This is more of a debugging tool for now, but should point north when calibrated properly. For best results, place on flat surface and rotate device few times. --- .../META-INF/MANIFEST.JSON | 24 + .../apps/cz.ucw.pavel.compass/assets/main.py | 687 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 9440 bytes 3 files changed, 711 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.compass/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..dd52c192 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Compass", +"publisher": "Pavel Machek", +"short_description": "Application for testing accelerometer and magnetometer", +"long_description": "Simple compass application, allowing tests of accelerometer and magnetometer.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.compass/icons/cz.ucw.pavel.compass_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.compass/mpks/cz.ucw.pavel.compass_0.0.1.mpk", +"fullname": "cz.ucw.pavel.compass", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py new file mode 100644 index 00000000..383da538 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py @@ -0,0 +1,687 @@ +""" +Robot translated that from bwatch/magcali.js + +""" + +import time +import os +import math + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard, SensorManager + +# ----------------------------- +# Utilities +# ----------------------------- + +def clamp(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + +def to_rad(deg): + return deg * math.pi / 180.0 + +def to_deg(rad): + return rad * 180.0 / math.pi + +# ----------------------------- +# Calibration + heading +# ----------------------------- + +class Compass: + def __init__(self): + self.reset() + + def reset(self): + self.vmin = [10000.0, 10000.0, 10000.0] + self.vmax = [-10000.0, -10000.0, -10000.0] + + def step(self, v): + """ + Update min/max. Returns True if calibration box changed ("bad" in JS). + """ + bad = False + for i in range(3): + if v[i] < self.vmin[i]: + self.vmin[i] = v[i] + bad = True + if v[i] > self.vmax[i]: + self.vmax[i] = v[i] + bad = True + return bad + + def compensated(self, v): + """ + Returns: + vh = v - center + sc = scaled to [-1..+1] + """ + vh = [0.0, 0.0, 0.0] + sc = [0.0, 0.0, 0.0] + + for i in range(3): + center = (self.vmin[i] + self.vmax[i]) / 2.0 + vh[i] = v[i] - center + + denom = (self.vmax[i] - self.vmin[i]) + if denom == 0: + sc[i] = 0.0 + else: + sc[i] = (v[i] - self.vmin[i]) / denom * 2.0 - 1.0 + + return vh, sc + + def heading_flat(self): + """ + Equivalent of: + heading = atan2(sc[1], sc[0]) * 180/pi - 90 + + Compute heading based on last update(). This will only work well + on flat surface. + """ + vh, sc = self.compensated(self.val) + + h = to_deg(math.atan2(sc[1], sc[0])) - 90.0 + while h < 0: + h += 360.0 + while h >= 360.0: + h -= 360.0 + return h + + +class TiltCompass(Compass): + def __init__(self): + super().__init__() + + def tilt_calibrate(self): + """ + JS tiltCalibrate(min,max) + vmin/vmax are dicts with x,y,z + """ + vmin = self.vmin + vmax = self.vmax + + offset = ( (vmax[0] + vmin[0]) / 2.0, + (vmax[1] + vmin[1]) / 2.0, + (vmax[2] + vmin[2]) / 2.0 ) + delta = ( (vmax[0] - vmin[0]) / 2.0, + (vmax[1] - vmin[1]) / 2.0, + (vmax[2] - vmin[2]) / 2.0 ) + + avg = (delta[0] + delta[1] + delta[2]) / 3.0 + + # Avoid division by zero + scale = ( + avg / delta[0] if delta[0] else 1.0, + avg / delta[1] if delta[1] else 1.0, + avg / delta[2] if delta[2] else 1.0, + ) + + self.offset = offset + self.scale = scale + + def heading_tilted(self): + """ + Returns heading 0..360 + """ + mag_xyz = self.val + acc_xyz = self.acc + + if mag_xyz is None or acc_xyz is None: + return None + + self.tilt_calibrate() + + mx, my, mz = mag_xyz + ax, ay, az = acc_xyz + + dx = (mx - self.offset[0]) * self.scale[0] + dy = (my - self.offset[1]) * self.scale[1] + dz = (mz - self.offset[2]) * self.scale[2] + + # JS: + # phi = atan(-g.x/-g.z) + # theta = atan(-g.y/(-g.x*sinphi-g.z*cosphi)) + # ... + # psi = atan2(yh,xh) + # + # Keep the same structure. + + # Avoid pathological az=0 + if az == 0: + az = 1e-9 + + phi = math.atan((-ax) / (-az)) + cosphi = math.cos(phi) + sinphi = math.sin(phi) + + denom = (-ax * sinphi - az * cosphi) + if denom == 0: + denom = 1e-9 + + theta = math.atan((-ay) / denom) + costheta = math.cos(theta) + sintheta = math.sin(theta) + + xh = dy * costheta + dx * sinphi * sintheta + dz * cosphi * sintheta + yh = dz * sinphi - dx * cosphi + + psi = to_deg(math.atan2(yh, xh)) + if psi < 0: + psi += 360.0 + return psi + +# ----------------------------- +# Canvas (LVGL) +# ----------------------------- + +class Canvas: + """ + LVGL canvas + layer drawing Canvas. + + This matches ports where: + - lv.canvas has init_layer() / finish_layer() + - primitives are drawn via lv.draw_* into lv.layer_t + """ + + def __init__(self, scr, canvas): + self.scr = scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + self.canvas = canvas + + # Background: white (change if you want dark theme) + self.canvas.set_style_bg_color(lv.color_white(), lv.PART.MAIN) + + # Buffer: your working example uses 4 bytes/pixel + # Reality filter: this depends on LV_COLOR_DEPTH; but your example proves it works. + self.buf = bytearray(self.draw_w * self.draw_h * 4) + self.canvas.set_buffer(self.buf, self.draw_w, self.draw_h, lv.COLOR_FORMAT.NATIVE) + + # Layer used for draw engine + self.layer = lv.layer_t() + self.canvas.init_layer(self.layer) + + # Persistent draw descriptors (avoid allocations) + self._line_dsc = lv.draw_line_dsc_t() + lv.draw_line_dsc_t.init(self._line_dsc) + self._line_dsc.width = 1 + self._line_dsc.color = lv.color_black() + self._line_dsc.round_end = 1 + self._line_dsc.round_start = 1 + + self._label_dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(self._label_dsc) + self._label_dsc.color = lv.color_black() + self._label_dsc.font = lv.font_montserrat_24 + + self._rect_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._rect_dsc) + self._rect_dsc.bg_opa = lv.OPA.TRANSP + self._rect_dsc.border_opa = lv.OPA.COVER + self._rect_dsc.border_width = 1 + self._rect_dsc.border_color = lv.color_black() + + self._fill_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._fill_dsc) + self._fill_dsc.bg_opa = lv.OPA.COVER + self._fill_dsc.bg_color = lv.color_black() + self._fill_dsc.border_width = 1 + + # Clear once + self.clear() + + # ---------------------------- + # Layer lifecycle + # ---------------------------- + + def _begin(self): + # Start drawing into the layer + self.canvas.init_layer(self.layer) + + def _end(self): + # Commit drawing + self.canvas.finish_layer(self.layer) + + # ---------------------------- + # Public API: drawing + # ---------------------------- + + def clear(self): + # Clear the canvas background + self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) + + def text(self, x, y, s, fg = lv.color_black()): + self._begin() + + dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(dsc) + dsc.text = str(s) + dsc.font = lv.font_montserrat_24 + dsc.color = lv.color_black() + + area = lv.area_t() + area.x1 = x + area.y1 = y + area.x2 = x + self.W + area.y2 = y + self.H + + lv.draw_label(self.layer, dsc, area) + + self._end() + + def line(self, x1, y1, x2, y2, fg = lv.color_black()): + self._begin() + + dsc = self._line_dsc + dsc.p1 = lv.point_precise_t() + dsc.p2 = lv.point_precise_t() + dsc.p1.x = int(x1) + dsc.p1.y = int(y1) + dsc.p2.x = int(x2) + dsc.p2.y = int(y2) + + lv.draw_line(self.layer, dsc) + + self._end() + + def circle(self, x, y, r, fg = lv.color_black()): + # Rounded rectangle trick (works everywhere) + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_circle(self, x, y, r, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_rect(self, x, y, sx, sy, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = x + a.y1 = y + a.x2 = x+sx + a.y2 = y+sy + + dsc = self._fill_dsc + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def update(self): + # Nothing needed; drawing is committed per primitive. + # If you want, you can change the implementation so that: + # - draw ops happen between clear() and update() + # But then you must ensure the app calls update() once per frame. + pass + +# ---------------------------- +# App logic +# ---------------------------- + +class PagedCanvas(Activity): + def __init__(self): + super().__init__() + self.page = 0 + self.pages = 3 + + def onCreate(self): + self.scr = lv.obj() + scr = self.scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + # Canvas + self.canvas = lv.canvas(self.scr) + self.canvas.set_size(self.draw_w, self.draw_h) + self.canvas.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.canvas.set_style_border_width(0, 0) + + self.c = Canvas(self.scr, self.canvas) + + # Build buttons + self.build_buttons() + self.setContentView(self.c.scr) + + # ---------------------------- + # Button bar + # ---------------------------- + + def _make_btn(self, parent, x, y, w, h, label): + b = lv.button(parent) + b.set_pos(x, y) + b.set_size(w, h) + + l = lv.label(b) + l.set_text(label) + l.center() + + return b + + def _btn_cb(self, evt, tag): + self.page = tag + + def template_buttons(self, names): + margin = self.margin + y = self.H - self.bar_h - margin + + num = len(names) + if num == 0: + self.buttons = [] + return + + w = (self.W - margin * (num + 1)) // num + h = self.bar_h + x0 = margin + + self.buttons = [] + + for i, label in enumerate(names): + x = x0 + (w + margin) * i + btn = self._make_btn(self.scr, x, y, w, h, label) + + # capture index correctly + btn.add_event_cb( + lambda evt, idx=i: self._btn_cb(evt, idx), + lv.EVENT.CLICKED, + None + ) + + self.buttons.append(btn) + + def build_buttons(self): + self.template_buttons(["Pg0", "Pg1", "Pg2", "Pg3", "..."]) + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 1000, None) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + def tick(self, t): + self.update() + self.draw() + + def update(self): + pass + + def draw_page_example(self): + ui = self.c + ui.clear() + + st = 28 + y = 2*st + ui.text(0, y, "Hello world, page is %d" % self.page) + y += st + + def draw(self): + self.draw_page_example() + + def handle_buttons(self): + ui = self.c + +# ---------------------------- +# App logic +# ---------------------------- + +class UCompass(TiltCompass): + # val (+vfirst, vmin, vmax) -- vector from magnetometer + # acc -- vector from accelerometer + + # FIXME: we need to scale acc to similar values we used on watch; + # 90 degrees should correspond to outer circle + + def __init__(self): + super().__init__() + + self.accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.magn = SensorManager.get_default_sensor(SensorManager.TYPE_MAGNETIC_FIELD) + + self.val = None + self.vfirst = None + + def update(self): + v = SensorManager.read_sensor_once(self.magn) + sc = 1000 + v = [float(v[1]) * sc, -float(v[0]) * sc, float(v[2]) * sc] + self.val = v + + if self.vfirst is None: + self.vfirst = self.val[:] + + acc = SensorManager.read_sensor_once(self.accel) + acc = ( -acc[1], -acc[0], acc[2] ) + self.acc = acc + +class Main(PagedCanvas): + def __init__(self): + super().__init__() + + self.cal = UCompass() + + self.bad = False + + self.heading = 0.0 + self.heading2 = None + + self.Ypos = 40 + self.brg = None # bearing target, degrees or None + + def draw(self): + pass + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 50, None) + + def update(self): + self.c.clear() + st = 14 + y = 2*st + + self.cal.update() + if self.cal.val is None: + self.c.text(0, y, f"No compass data") + y += st + return + + self.bad = self.cal.step(self.cal.val) + self.heading = self.cal.heading_flat() + + acc = self.cal.acc + + #self.c.text(0, y, f"Compass, raw is {self.cal.val}, bad is {self.bad}, acc is {acc}") + y += st + + self.heading2 = self.cal.heading_tilted() + + if self.page == 0: + self.draw_top(acc) + elif self.page == 1: + self.draw_values() + elif self.page == 2: + self.c.text(0, y, f"Resetting calibration") + self.page = 0 + self.cal.reset() + + def build_buttons(self): + self.template_buttons(["Graph", "Values", "Reset"]) + + def draw_values(self): + self.c.text(0, 28, f""" +Acccelerometer +X {self.cal.acc[0]:.2f} Y {self.cal.acc[1]:.2f} Z {self.cal.acc[2]:.2f} +Magnetometer +X {self.cal.val[0]:.2f} Y {self.cal.val[1]:.2f} Z {self.cal.val[2]:.2f} +""") + + def _px_per_deg(self): + # JS used deg->px: (deg/90)*(width/2.1) + s = min(self.c.W, self.c.H) + return (s / 2.1) / 90.0 + + def _degrees_to_pixels(self, deg): + return deg * self._px_per_deg() + + # ---- TOP VIEW ---- + + def draw_top(self, acc): + heading=self.heading + heading2=self.heading2 + vmin=self.cal.vmin + vmax=self.cal.vmax + vfirst=self.cal.vfirst + v=self.cal.val + bad=self.bad + + cx = self.c.W // 2 + cy = self.c.H // 2 + + # Crosshair + self.c.line(0, cy, self.c.W, cy) + self.c.line(cx, 0, cx, self.c.H) + + # Circles (30/60/90 deg) + for rdeg in (30, 60, 90): + r = int(self._degrees_to_pixels(rdeg)) + self.c.circle(cx, cy, r) + + # Calibration box + current point + self._draw_calib_box(vmin, vmax, vfirst, v, bad) + + # Accel circle + if acc is not None: + self._draw_accel(acc) + + # Heading arrow(s) + self._draw_heading_arrow(heading, color=lv.color_make(255, 0, 0)) + self.c.text(265, 22, "%d°" % int(heading)) + if heading2 is not None: + self._draw_heading_arrow(heading2, color=lv.color_make(255, 255, 255), size = 100) + self.c.text(10, 22, "%d°" % int(heading2)) + + def _draw_heading_arrow(self, heading, color, size = 80): + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + rad = -to_rad(heading) + x2 = cx + math.sin(rad - 0.1) * size + y2 = cy - math.cos(rad - 0.1) * size + x3 = cx + math.sin(rad + 0.1) * size + y3 = cy - math.cos(rad + 0.1) * size + + poly = [ + int(cx), int(cy), + int(x2), int(y2), + int(x3), int(y3), + ] + + self.c.line(poly[0], poly[1], poly[2], poly[3]) + self.c.line(poly[2], poly[3], poly[4], poly[5]) + self.c.line(poly[4], poly[5], poly[0], poly[1]) + + def _draw_accel(self, acc): + ax, ay, az = acc + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + x2 = cx + ax * self.c.W + y2 = cy + ay * self.c.W + + self.c.circle(int(x2), int(y2), int(self.c.W / 8)) + + def _draw_calib_box(self, vmin, vmax, vfirst, v, bad): + if v is None or vfirst is None: + return + + scale = 0.15 + + boxW = (vmax[0] - vmin[0]) * scale + boxH = -(vmax[1] - vmin[1]) * scale + boxX = (vmin[0] - vfirst[0]) * scale + self.c.W / 2.0 + boxY = -(vmin[1] - vfirst[1]) * scale + self.c.H / 2.0 + + x = (v[0] - vfirst[0]) * scale + self.c.W / 2.0 + y = -(v[1] - vfirst[1]) * scale + self.c.H / 2.0 + + # box rect + if bad: + bg = lv.color_make(255, 0, 0) + else: + bg = lv.color_make(0, 150, 0) + + x1 = int(boxX) + y1 = int(boxY) + x2 = int(boxX + boxW) + y2 = int(boxY + boxH) + + # normalize coords + xa = min(x1, x2) + xb = max(x1, x2) + ya = min(y1, y2) + yb = max(y1, y2) + + self.c.fill_rect(xa, ya, xb - xa, yb - ya, bg = bg) + + # point + self.c.fill_circle(int(x), int(y), 3, bg = lv.color_make(255, 255, 0)) + diff --git a/internal_filesystem/apps/cz.ucw.pavel.compass/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.compass/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..2b1f919fbaed011489c8794dc854a97b36ecb4a0 GIT binary patch literal 9440 zcmeHLXH-+$whp2+MXD49K|p#4fdCn7t+5UmUXq6sh8N?GwFUs(2PWbzT=|vq zgGS44Td5NcmvKc*tsb9W-k}tE6Ad)`F#LV6sHKH;T!7~AA&UF)-!~R8dksb*^HOJY z4bNuXNi|k?j*an7PA#wgdSZQJkGACU?l;1)q9atoBVunizUS-Kj>`_mM=nvDCG5i? zyxhQSI3cli!iaKMaM>>EGkSZwd3&_TdhF2{tY=0#_0o@05cu zsY};iPxw~f-=wLIB7R7%6FSv77}{RC{ce+VIgelbEtP^p(c5;PxndO3sfSj3s(fX~ za((Rf?4DG;;B=FOP}QbdZ_+~0d*VAKhXSvW!t7aD>80n2Gva|3;iX;A{Ki(ks@jW; zKdoGTZPq`*8Wfin)q_vr<8>W&W-SR^NbOKLX=Zfzvn$a$V9m}Z#1%DA%TXbl0q zs05$m@{{+@+QjJPH=JOQZ;ZZu?}~0tEL@D=Y$RXN*q+!{(uveFuyo9Ncih`^x*%IZ zDbdQ4+i{b^C=7xgKd+VSdMJz2Itkm-`l7Yme_&ZGue3Wl*34p!NygsLyxQCMw2W6| z!eCT4^#qfQ)rm3&M66+YV^~Pp_0Uq=!+@7PaaTXw`WSZ05aoJ8+f*Ys_I#jpo>mym zE$r1R>r54q&6V|SL5nI4ZNvOF5Yl&z@vvL-iOoLYgNcvnpUQq)w$|3^F;4dM=#Hee z&X+I)+Nmffj8-jnF!VM~WCOcn2`|x2isrUmDtQlNDpHE{bg#T^3kdErH^^aruq}Yo zyj5obyRTfqkso65VtM+D%-1_u6TkT($B*#J25`g{o5gw>f5r+v?E9h3r zX0BLWeSwQ-`&LZv<@BY9>v%-QTAI@cTVe6BS{6mNwcWiU?Na*d#XPPzUU5!f!)UyY zIh@CEj&im2-0Xkd5(AYBx@cju8UL15#2rp~`c{OZ>GCn9p}ePK<}@$jN-Zr$HmZh? z%Hrk^Q5>JSXs73ry?V^va5Q#6qfx-SYzNc?? z>|CZnD~06AsMu={TAA<#b+`^<_U4Aw%fh37vWwu1?)+;q_rX3}fNCRu~5z z16}Ncy7*It{tmhIiG^Ib*{cb5dI`=R*Y6|atg!ZLXWp=DHn(`}7_Y>Qwr*$kw;fez zM?ofqIt6>hRDF3G^RxQV* zt#H+}HO3w>lDME>tHq&^QWyN56;or7@7&IuGj{jGa1WWS)>eM(x-EDgvG7ooCIA2`H-xbiW%dG z%h7EV8>-iIRgIY_)3*4S9t;Rw*Qz?NayNcJEW-X)b2^5jirLL_WHyA+UW6!WXKCCa zk}%XA$2Lt*-M#K&U~bFPEe4TnsT@W>RE*&pj<7BOMokL$Uk~h=yH?Yn{wZ3IA+2vj zlxsp=Im#qP79Mvs(C>_zVd%-r-!p?ABm^1oQ%0wl+|7UvICAeCpB}oD*^qm2Ld7u3 z2EzMcs#JoK=_|Vxh(&&=btL|Ha z^^wIJ*QxK9oMt`mt_o^7Ef}OT;7hTL&-Bia4OiwjIbx}v)}}6bKFy!)COgL*vl23y z!m5`gtz1OXQ7H_lgT1mmeusdexoV%3*c8I#g6L-^WcPCMx_iL(~&Hrq!40DZ|%1 zYKI^ziQa~37YNCH&^ssYdRehC^+;ANoG3 zf>J-L1%^_adP|C^S^%g+E8HPuU$U^o~tuaq}H zSyyQ(O!QF>9!KoyWq)Gh4^xLo)tr_U~Sp}AQZngoauftJndlHTI{IpBm)(fFus zj2m?O8LwRxa`mmVg+JUX141!v8jqtS*>a9@qw;iA0CZN@^9n^~y9sBKO{tc%b1t-{ zhjQDXKUp%(H3syMf>1h`EI9GA9&#Z0>00l%8=K0U&rRZNy;v2}O=457Qhv&y{DR{Q zd7`cxcWTR6&y{wgyo`d_A$7=DY%K8R40{>n@wD7~5Jtvfwyo`A-Q9P`da&5D;|(_l zUO3t}KBR=mAHLSIc7p3eK0`ppDn^;LUDx7eMFlOFKo+y$Q4DR%d-}j>%K9u8{ zP?uHzUQ6w{7c|dSttXvH@H5lPu1mLUH>xx3$3(!I1tEArsW`bi@Oi@f!_ z1|Qn&hfM{?;m+I(t-b@E8H?#UvN&!g6TgMRu34|maKJaes6Yy$M9Ol}h7^bUot~b( zxnPB_jXoWRY5TFmh8r*7HVbqN!~q`t-6&|ejHS(^$g3=q;EqH=-J}fz7V|w+hsM-- zlcHyX?yRSEJT0P4WrCJl1&_Xan9dr*sYTrvZ)Qu;(Rt$ha+5Hf>QkfeTOo?4+7{-_ z#sdmo9+5U$3QsyC)AN+Y!hd?)2F6(y@43+{#G})OXq4d6JF4G%)g$)BDG$0_lXboH zFS=)2>51Fz7FF#!*+&Rrbk4vLp5*(dzCeVjqI}FMm|RxdtQuqT)I!I2v)G3n!F4cT0!ZX+Xjk?05ZBIcvS#92+pL4t`AX)9SQrwpjkUFBU_SGPtK?sW85@z0k%tLFFe(URqoR&L>#F~l3o^tP<4kutO@ zCVlO5NP08VxL^}*R41+fBvj zW~A;v@p;BsGP5428pH!c#9va`v~=tF(xNGIj!WJir3@m|3L!rD6W2Ir?2 z^lKk(t=UnQJ@l!wYF{x9ukur7aCvnhwX)Fa6+xWN^3{f*g>tW-%u4@Ym}{k@!=XD( zjgxBy1)<;tb=#_XNQ!8!x2Q(2HT88;woZM26kq4SYi60C#GY5?g@a)gXW=P8kIXqa z63Vw;ZtFu>1yh2AoY?c)-CCN73LeVWyHsi$26KsiCKZZ@Fec{D$GnnqWmyyqgFhjj zLifgZJ(A)2-vsGY=<6)L`&C~0zP9<@llRGYH44f-YK`d!@o%Y{>Hz?Xo7i*bjN#|b z{r0Ften3j{iK^6lt;*4u`bbw(l#h+cx!E{bJmfai?!;~5Gre?AVR2Wsj~JYcW@Xo| zE&BMe3Hh$J!NQE%OpS3Du*AS9q{%L_RGHby*QgM&voy4$I8;eZvnJJ(7vZehgOiR8 zxf4H^%i(#`pJKHu>`klGq<{$R=#on$U~~bg__lh!qbp-loa(T~n1ywm>=M<|>LZ-f zh)~4y^gQNNmdJRL+L(5}*y(C#iBj>!){uC^HWuxW2K7^7xYpZ`v=lYDF3BV>-wbOK zodUL7mbD~ojDwC%t;S;VdroQ{x?_IhFcV#_aN4m`-~pEl;+fNm^PhPL-Li5H2n!8$7OEmTzV@nX?Rd}cBRk_JxaFt%77PXgwmr=bbT(;g*{=2KCo zQ*?)s32+z^g4Z4A;7EkIEAbuh!pPVA(;zfZ>HmKqMrAn(kOv zDL!R7UPS`h8g@ZL>n8Ztny!E7 z`==5_6Y?tr&;<+;??OOfG+i-{B>rEOIytxyf7R(i#OzNU*zJI}29b?A(EO{6Hr&AY zcbR=R+F)@`2NL_}Uy*3k?>HwHg2Mp@jRIjDFgUUyL^3n@Pk0j6`i}tpX+Qfb|6mAN z-S7N=LjUHk16vOK3e&)&T=td1HI(@F?S-N7C@dOwa0!(MOCjZCWr0#MPzVr$gjxd; z7_bZwEN?A?g34l`(n#{F4+C9q^3lsrUMQce~EltW5kfDlP228fiA zhXN6D($aD$NwhQ+BmE175dllC1cbw{R_#-v$y5-qwY3ad(i(`AlavNR5GV`~fs_RU zQIc|CX|xPR9*RO7Q0>P7re+LR;**k){A0%00YS3H6L3m=`dCL7_dga)usF;`5@O$J zU^zLk98?ktmX?#30YiS6cED$fArQ$)zRwAkl#r4-5J#h6T4Y89IoYr{gbfDdK}qo{{wx-lJ|1--cmT$r z_v`Iv#i=7~KnFub(7yuz1JgxYyqn|yj^_{P?<{Hrk{h02Z$vOc+F?+nf6epHz`rwH zAon^ViQobMH=FuzIK?07sza8=6Fh#=e-Y#SWAvjTIbaW5#mjrp1Yii%5BC!ht{C(| z2*`f?v4pZkIND&y-S%gx{Wg#NCuuB=Kv<)}2n=XI3EpE0La*l{ef?<-9d_ThbGu;1~ z{YZkPWY9o~RIT zoE)$i!k-iOw?h6OaKFg^Y#RSg{8!iy>2r7|4{}qrB^kLn{!8`$1o(qNAB(~`67m1a z^j{%A-15u0fNb-RIr4dfe0%}@et!8`w&alfCqF;i!GBT(FYn(<{*r(HcGutT`b!@8 zOW?n=>u-1cB@g^1@ZZ_>f95W_Ki+^~9LWc9H}d-pjXJqEb*FDf1v1!v7Q&J3s%ePTd&svi4 zzv0$i=6_c2zPLr_!06B>v;WZ=RnC=T z{=tA70}Bg5jCb%DyC~A#-K25Za4~~rp6KekifV;hxBwnrP9WvRkUz0bpyRz)uaJ(l zupY?j?vnd_EG|0x3sY6%_|=x)Whsww8f;07oa6ZVZl=Lw7fL@}J*>yTkib=mZfmdM zx%F@Hi(+pcdHOHpUVk;4wB0@a?g{=QPIkG5LMo~szvcY&lY&m8$mJ6J!p)Ko&7q;{ z_eb9|SH+L>&n(tnSANx|HBx=8*)tq%_LOU}h_q>YZ*HzNcwtVUq*%Aou%ow*!ViAa zNbpTpS6x?Xii83t!BPM_12Xy`E-!P5qso1I;l0a?YhKH&G_1j(B&??vM%?-OI9kB z?%8&m4?q;vJTz2#gWM|lQ~46_IGreJs4t}Z&9X@L&l;0Ww{^aWC1oekCwS#!Bb@dUWo;{=ZaFoa#5qIbKO}jVQbkc z04RWz!*vLrv8bn~XN$u%cpW_&*NT3gJcuo!q^E!P=zB!UV{w0F8Ozz#^>yYN$DO>V z;RTN#jUDNHVWagu_gofbWB6B`!+gLfb{^H_-Z9e}%yT;bQNDwz{NXEFTqVWDriW-Kf~^lHc^&`c*> zH#IZUTdx>)6qk@l&(7914U^KE6gkUzx6N7}3N1Z_4Sro9w0t^J+jShioX<&p=7b1y zD67HF-kz^k#`zD6lUxa=o`91yhg*Y$-pIcgIh<;;3_+n#)^#!Cv@f_mb{bmWSukos zdMxtu^M^Gcb3d}T=2Poh*S5CmpY~QfZFYp`><7DI%7miNk8hkjd2-<9!tCrtTBB%| zqFlOX?BAcG;={wk9q@P?0p|0xJc2WiG1x04k0c#|INdChI&B@-_pi6FtKJEUFE?dR zZ5oHx!j~_z)J&m4u0d$+&Bbl${$~Q*>6w5bibZOFo*>32g@Hn>m>|j;N&iSd5|vXS zpr0S-yoPUA7t2`l$+-I6VCFQB{ Date: Sun, 22 Feb 2026 10:42:21 +0100 Subject: [PATCH 078/317] weather: start simple weather application Simple weather application, using open-meteo.com data. Once an hour weather is fetched and temperature/wind/sky condition is displayed. --- .../META-INF/MANIFEST.JSON | 24 ++ .../apps/cz.ucw.pavel.weather/assets/main.py | 225 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 12342 bytes 3 files changed, 249 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.weather/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..53b80338 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Weather", +"publisher": "Pavel Machek", +"short_description": "Display weather information.", +"long_description": "This displays weather information from open-meteo.com.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.weather/icons/cz.ucw.pavel.weather_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.weather/mpks/cz.ucw.pavel.weather_0.0.1.mpk", +"fullname": "cz.ucw.pavel.weather", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py new file mode 100644 index 00000000..9258e47d --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py @@ -0,0 +1,225 @@ +from mpos import Activity + +""" +Look at https://open-meteo.com/en/docs , then design an application that would display current time and weather, and summary of forecast ("no change expected for 2 days" or maybe "rain in 5 hours"), with a way to access detailed forecast. +""" + +import time +import os + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard + +import ujson +import utime +import usocket as socket +import ujson + +# ----------------------------- +# WEATHER DATA MODEL +# ----------------------------- + +class WData: + WMO_CODES = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Rime fog", + 51: "Light drizzle", + 53: "Drizzle", + 55: "Heavy drizzle", + 56: "Freezing drizzle", + 57: "Freezing drizzle", + 61: "Light rain", + 63: "Rain", + 65: "Heavy rain", + 66: "Freezing rain", + 67: "Freezing rain", + 71: "Light snow", + 73: "Snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Rain showers", + 81: "Rain showers", + 82: "Heavy rain showers", + 85: "Snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm + hail", + 99: "Thunderstorm + hail", + } + + def code_to_text(self, code): + return self.WMO_CODES.get(int(code), "Unknown") + +class Hourly(WData): + def __init__(self, cw): + self.temp = cw["temperature_2m"] + self.wind = cw["windspeed"] + self.code = self.code_to_text(cw["weather_code"]) + + def summarize(self): + return f"{self.code}\nTemp {self.temp}\nWind {self.wind}" + +class Weather: + name = "Prague" + lat = 50.08 + lon = 14.44 + + def __init__(self): + self.now = None + self.hourly = [] + self.daily = [] + self.summary = "(no weather)" + + def fetch(self): + self.summary = "...fetching..." + + # See https://open-meteo.com/en/docs?forecast_days=1¤t=relative_humidity_2m + + host = "api.open-meteo.com" + port = 80 # HTTP only + path = ( + "/v1/forecast?" + "latitude={}&longitude={}" + "¤t=temperature_2m,dewpoint_2m,pressure_msl,precipitation,weather_code,windspeed" + "&timezone=auto" + ).format(self.lat, self.lon) + + print("Weather fetch: ", path) + + # Resolve DNS + addr = socket.getaddrinfo(host, port, socket.AF_INET)[0][-1] + print("DNS", addr) + + s = socket.socket() + s.connect(addr) + + # Send HTTP request + request = ( + "GET {} HTTP/1.1\r\n" + "Host: {}\r\n" + "Connection: close\r\n\r\n" + ).format(path, host) + + s.send(request.encode()) + + # ---- Read response ---- + # Skip HTTP headers + buffer = b"" + while True: + chunk = s.recv(256) + if not chunk: + raise Exception("No response") + buffer += chunk + header_end = buffer.find(b"\r\n\r\n") + if header_end != -1: + body = buffer[header_end + 4:] + break + + + # Read remaining body + while True: + chunk = s.recv(512) + if not chunk: + break + body += chunk + + s.close() + + # Strip non-json parts + body = body[5:] + body = body[:-7] + + print("Have result:", body.decode()) + + # Parse JSON + data = ujson.loads(body) + + # ---- Extract data ---- + cw = data["current"] + self.now = Hourly(cw) + self.summary = self.now.summarize() + +weather = Weather() + +# ------------------------------------------------------------ +# Main activity +# ------------------------------------------------------------ + +class Main(Activity): + def __init__(self): + self.last_hour = 0 + super().__init__() + + # -------------------- + + def onCreate(self): + self.screen = lv.obj() + #self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + scr_main = self.screen + + # ---- MAIN SCREEN ---- + + label_time = lv.label(scr_main) + label_time.set_text("(time)") + label_time.align(lv.ALIGN.TOP_LEFT, 10, 40) + label_time.set_style_text_font(lv.font_montserrat_24, 0) + self.label_time = label_time + + label_weather = lv.label(scr_main) + label_weather.set_text(f"Weather for {weather.name} ({weather.lat}, {weather.lon})") + label_weather.align_to(label_time, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + label_weather.set_style_text_font(lv.font_montserrat_14, 0) + self.label_weather = label_weather + + label_summary = lv.label(scr_main) + label_summary.set_text("(weather)") + #label_summary.set_long_mode(lv.label.LONG.WRAP) + label_summary.set_width(300) + label_summary.align_to(label_weather, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5) + label_summary.set_style_text_font(lv.font_montserrat_24, 0) + self.label_summary = label_summary + + btn_hourly = lv.button(scr_main) + btn_hourly.set_size(100, 40) + btn_hourly.align(lv.ALIGN.BOTTOM_LEFT, 10, -10) + lv.label(btn_hourly).set_text("Reload") + + btn_hourly.add_event_cb(lambda x: self.do_load(), lv.EVENT.CLICKED, None) + + self.setContentView(self.screen) + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 15000, None) + self.tick(0) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # -------------------- + + def tick(self, t): + now = time.localtime() + y, m, d = now[0], now[1], now[2] + hh, mm, ss = now[3], now[4], now[5] + + if hh != self.last_hour: + self.last_hour = hh + self.do_load() + + self.label_time.set_text("%02d:%02d" % (hh, mm)) + self.label_summary.set_text(weather.summary) + + def do_load(self): + self.label_summary.set_text("Requesting...") + weather.fetch() + diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.weather/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..87df6b6275aeea67edcfb9f9b101568b579dcf41 GIT binary patch literal 12342 zcmeHscTkkuvM)JG28oih3^PNHf@F{+86-^thBypqKtPcoS(1n(Ns^P~AP9$ecbMy}GefD<0d)~S4RNYhc{@7H_%(vG1^{;#N>h5n<6J@BcNkPU!hJ%Acp{=E6 zg#A^%_>d4`ze|yxvp6`H#R5z$yp14!oG7%11JV`2>Ftj~a3U~B2OJ#CItzD|t(M$V&8c6hve6G?rhcV5&5^(ARV6QCnwwdP8NZB*za6hwAT32|z^k{MQ8bk)}Uw;B4TiP4*4EkjMf`0a>i1n#& zc5#)!_7OpH`}A^P!b2M7+1(wDqvwf|Ct=q-4EO8e6O041Rj!=W+lgzRJV{cLs=WP4 z`sjE9qBo{UTmgRNe0y`uqL$eHi=AOZxwN`IDz^dIc5pf(mC4h|x$)gwAxwnoIZJ|?@hJ~>67f?9&I-tT$5&mPW%4OBMW)qW*g z;v)R*3Zt2>``8%Xi^MzG2cBmR!O7eKtg|bc^1?6l9NkcT!}@+UyThgg=TlxlPIY)^@QBY}+`9Ctw>G(G&X-qYzC@;>2@JKFRmJpl8QqKdOPYLp|98j-z+h*~SYkKgb7!JZFdpf)1uwt9(kN)~cQT zwLTce23UM}K&~Oo&miQCAZqtIlm&3Up0IVv%s&xpal2`FGoHUlBXa~VIRZ!^UY0G} zC&cXat#;e{nyI7Ds!u9A>zI{1`NQ_`$?}5|Mq>u)Cl&qCj+b(Wh?&NeySfK!EqOB3 z(T(L)9=6QA#j1oo_%NuE>Mj0}14Cvr1D`sX=Yj5f_U@kjvtN=Tq?d|=Ti#Q<4F?Ot z(0<|ed+FaY(}qmWy}*|iiZehi>~oo4`$>ZPEf>=RfwRWGXHMJNitk~h%?ol$kMkbp ze@wGou-<5WBYPz1FzHpMNVoOebYtnP)qLt1A(PB4muTYfp)pKgG4x00D+f~h2KG#w z_Y9xp`keyX16dz=oJ_aJMPKcIRX_etE7)m6jHv$Uk?q$_nR?9u$-7JqtO^f~FMmXb z4dcvfZgZ8N(0%ukz#CiOZr7JXu=C^dkCgM%2PGDFWF_}LF;^3V*Dr3#w5xsgQn;1} z9xo?m-)cY3r@rI{D%K_Qdm1Dl6;S)MHO)n!Ov4o*Uo~G6wm(bf{MVTj`PU5@TYuv??z;wyD zE1B++?K92tg0G?aZzX&64BWy<>&(A)#9|_)3GT5fv$L(t>0gz|=(jcscePpcve69{ z!j%GcL!p|k3rt63Iedm1!;o&D5>YQ9vYorn$IN}&{oj&a3w#@;sqvgwtvC4P1A^Ge z=ieY$IBb?N-f#8I-*a?;@ug+s83{}~dR)2E(C1dU2#Cu=) z#L~=y=pVTlmeozGCcQ*c6(@5)z$GMWZ;WfTuV001a8lq{7Ti7PE#}5uV>%>DeVwGi zwdK%vf6{kJBj}6RA^x^y2krWkXQZ#icURen=Bc!jGT#7{KNRynPs)N05UB)5k#cVd z@kYKXN$ zIz^Vv37i`o6A+@-I=B+BkYFO4AkY=AY9dQpX4T1cU->hax?%8ryU^Rh6dD+G9_Xfq zk&uxU37==v%a4~M1q6+hAo~$1dD;0pd@`N%fk9!M?4J)3(o1iYYbK`Wz0fsMbeM?d zOexunvz? zG2_CIjjAEid}XUpWdX$@y1OWx76;@SY5+MWmvnb7is6AHqCA3-%cPoF$sI(lCsR75 zBR9Ct3^j~xh9$w=oSXt8>bPL>d0C+qLgEry*xDgJh_ANrR^58RHAcJSGz)8M=a2DG zfm`(q42NWGX78>rtx*T!P^;tI4yAqU0HdO0qN}*~d1VFeJisSX`fhk#*cZ}M3ORck zfedWdz~nVoZ%Y9s%mn}f348+5LOrhpvyxM)GBaP(8VpL>lzeyMRbg=_ZRWNXxVywa z4Do6rBvV8yC59wQsGUMT5#FqwP36PD<;Ww#s=$Zgs?_^O1ZuQnqZ@dbkVLi23BmqLIu{+5kzBDOt2O^h( ztH_7R{g@NA0N@8W@|VI})$ZjJrHEaMqmQgiIxR>KZMB;6(Q->v^$c(2F#*aU9NJEy7(FfYDkP@&@`Zd?A)taXn@GUj{s&D!2|9;fb?`9;OJLg)3U ztDNc)ZP}->orP`6n}N!xb}lBehqQ)kY9FD7^5wA1jPx6_YR_h7l9VSnpQ?~YJT8J) za){5(>b#{QC_%6 zm4qvn>kc|5kQ>Al2|7XouV3N1{32O%Oa2nIN-R$! z%!GPC%kMsBTn3$80sek-J-NWlE8~roagDvcW~OiF4E=|RC{DwGV^{Ceg=*NNsI@ZA znXc8htLqgXowclcC#vP+zPYlqf@|N;uFo+|)LrH!%L)E;Y&m>BGhoGgn`fQE+409R zuJazl@G6C~N}aYxAbWLJmrLQ-Eju6o!z>2)Jdtud^PahzDyw7Pm)rm-k^#=#D z=Q*=-X9)XSJ$PuIX?)Fhl3a=B+6|AQd7v7A2x7JD+G;;*k!?VIBZ3ZE3DOM&o~#Ec z1zeYwH#B&+(aJ?%EpYlUk4KM=JXk{O4R4!#@dr$S*JJ|5*JO&%{?t%+m3qeb)rSuOf<{@o(lZeL}ep1LyAuQX@^+tl_O-}wBpW_D4Z3#*ue2d19y zQXf2i4`)mjd0EGvsFRg6eZUCdb{ZkLsK)^YQf0Uura zjE7+E&uS{A8rsPor{u=23aK|zn*HDmLu^Hn(`2DcVRQvOlzcWCH?7;hc8njkfcYj8 z@~^vEOUw^S204(5IWaW8yaOtX5pCl#7I;)M7C>s~U-d}vNt8KKK`A}Rozn$g*Yy)TXVuK{86Z5^f95(V0x5h$YciVDM;u625SQ72%_P?#%FQb)u{l32ZYJ z(orUUXmLo)ceKFqktRs2?_>PDRk1wd)VYOv*al=XaJy|de1kzKX&VE}IWk*jPYW8! zsNc`w$l1=*d@{1^2WVjt;QagmO22Yq88=&Y%lI-?LBXxoKFZ!OU}$;&d9#e?ed8gy znun|plZ(|lUm9k>JD=2i1806S4N~0PWoc|<7n?Wb)3R1f|GwIJ7}ZWf@a?%z$7jYL z`7y_N`|}~zOBZ$oZaYCWKNpFg9##XMxl5ze%G z_^HS%)gwjL((R!JBTnG#l9Nr>_lSVeIKG8BrqMnzOskk%hw9GZH|w1s{u$zc_{ir; zUH&>y)}?V)f3q*^mh-!!z4+YXT-^C&?9N;=7|$g}Q~L$xYTfTd>3LZ;`cDbHLnIhU zxhzBL+KXuYXgAnwnMKeG{qq6adFy^R5Rr516w_B~NbvRwqc>e?T2h`t>Z-ys1&mol zrEM<@mae!hS)h6xxpU9+8C;QVPspFIE);K=n@On$)ixv<8qz+fK|}A0RHI&9AL<}4 z3x2}_tAz6}J9hZRe7A~;!i@U50ay0*(n~1BhTFP$aAJA=t#gHUgHo@7tlmOl)3=-|_w0HOF(E*j zY`y-Ne%OnVq*ea1CbZ9+%-v>B_UVzG+NaVijU7QbBGmcG{L^vOuSOAV#Uhs7W}KtGXTB&FLo9e%*2)mp1%;snAr^X!=a0Lu+r{ z$o6>h{-r7{*ITl9fX;?-s!FYXYDx8%kAxRU?3~%Y(kmlr4+<(O06i+yZ`T3B=IR(` zo{Xo0);x6~y`$UJZ>_~Gb6YlE@zY$s<pHV>yb)xn$7B8}rfj&tjpf0$oCbTpq=;A% zK2I8)xUu(&*7Q>*A8>@;vqVHYeTm3!oI`Uxm`e(%OL|`$>$lgjPMOv#XHb10uzN&# zZS~XO66>K;{ffFil3~Y@H=tAqc$IWudihyu4zJw3)?@a8Z(5B+{*>`C%|ME6YvbXB z5ie?+Xfm>pDQC+Hfkk!UoK|zaH-M^iZknihyL|_D+WfDKlsoiEOSAos?2Ah7P9`4@ z9e64qRUl2P+!FH{D(goG#HI!GHAp7ul-619rc}R%3-#u~5Napep6_^Y_9os46eV|2BY@7OY2@t6t8ZY4+N%+y{|-Mh@gC(wLYlGu>}Hy*wh zclGO2N!m0O#g^~XAiOAT=HXgUN@lDXFf+I0(!TR}gT;}Je<)q$Egx3f5`p#>WzmjvT4Hu&!>xf_oyG+PwbV&8V z>aLHUc)PpB;d8s|K@-)WW2KJrobrHs6D8s_H4imhrc=t;-p3aa|Y(1}{TmJ0vIwzorh(jcx=y)A}Uf8*IHw5l& z+@c)T>KfGZ{Jxy3o|2&6Nwr*!ba~Kr{_#$Ca=m9J^?iCdfP#JiiDkoh-i`A|P=i20IbP+Bvzk8?3nomyF1dH*-XEygC%g{O=8=t)i4~QcF%|(_r7G- z%!OuF1YB(mAh`bJ4p}5^ZA0Ioh@f#GikAtOpuM~+7B2x&1@pn+s*DQH39wA;H|)bP z-{3p7(KENh2Aa;bATKWxR;o=y#@|~BXX@8oBKn5Z zKQNLv$2VxB?z+uqL8KJ@sjt6vC1zD_Dv9ND=#L4Md)pypLZh z)maT^d|%x$c5gPzWZE(5Q*6%oQtIct$!}9ec>SDeoHfkOu#_RHr=D--QN4i8Rrh1}$EMAWG3BT+c<6tc589Olb zn8JU7G5vA;`Ui(}(q3r!Ib+@3xX+1Jj@a%oZa7j^)lgei^&j11Y|l6|DDlOOSITU! z?TnvtGtiVp%D{{DFRQ70e-x(ARVik$@&PrUhw)QrN5|yPS)n$2pd+2HEzOC|uT!7m zE^u(_M~fOQ93-j4Fj2*yY_88Pu3zCC*3p%m>c%mxja3s7F8ojionyc|9tyqI-`hXD z#4AKOu;oL=VYk7p(04NZB>!r%B=a0yl;dM}i!RnKc>8_>> z`dGX*V8a>s5`k|>z(~W32X3vwc!y6yu2!r7$L_8ed??cc1QK|A;4_Zt=}q8^`rQo? zu#tjmZhxdl%g&v}whK}0&f;acxO>C}WnRP4UKI|;XJfwT=+yjab#kirc?S32_1tkm$0&_ulb3zf0NOwi< zjn)_3oJhDLx4EPqNDrloa6)PYpb^Fa`X;ad7nm%ZTj?5^0!9u?;D+#qaAMqC-M!>6 zirhbW<*?5e(?D*{pAc^sMQ#f{Lrzr>G=ft?R6-O4P{$yB#kjALaVntU4su3n8oyIu zM~d7|-rguVAkfdxPt;Fb)C27Z1k1|G0zqOxF);uZ0r2v7_l95q?p{0>6u)t(A-rH{ zB+47<;m&!%34wa}cq?*qW7j!&PA#>qIN2D9-r^E&N zS0o(v2M*i*#W3;G}a`f1Bg zzsjk3z#tT_P{FZO5?}`hNjS&>0F?%b10*0Y1ONh+0s~+mX|On45+N%C zgZ!krI0w1whT4kUVxpiwXAE5--VPpUH$`q;q`MF1&jk~t8^YKda^W+1`GyE ziHplhh)MqmGDD!fut9#o2?mLZiTzxG!{juuj1X+FA>AO32q4Pc@#n(DvB+UJgLN$A zA~&$iKj*P~kyAw@Al@Em6AuqpMeYk*IWH)GrWB{b@5v&k>jC>I_!EqPU*y~G8K(kq z1pXW<0RI*EzcCp*dHA{izw!JD{e$H?+S|_q?P7p7fI1^!-v65CpMn2iGQ!q6FK@KJ z_W!V{{{yG+TexarWj)aTzvwqcc>Xr}tsuD~f4Yj3^Jfu|gTQ`szZb+80snafSU>)@ z1apG8J0h^v_V-l#$2{_%gt0gT;s6Ij5CBQ2gcv|V1}YAKfT z7L$ZSAy5Ez6WINTNyq|Z9b~}(Ss7Uw5EzOr#0bP+E&Ts`KVsO@ASNy?jV%UZ7nMp1 z0)WEBr2sOL5D6#*0s@Ic#Q(SVqX4{k;r%206fRx~dU|rd2B^ZtD??8EqB0o!pir(z z1p2Rm`;SEazu1^fs*|^YpZmX5|4)FwG3X*;2zM`!e`Wfw zkl)<$%e??=^KWz5`v&&<0{r9t@_X7|RJQ-%-|yw%Kd1pq{kM{T%fElS>u-1cTORng zz<)>A-|qUiJn(OU|BkNzHFuHy*#SYgV=v-<*#1Vz!3$z+Z-fYXLsRW%mjvfXwe=46 zi4>(}>4k$sPIvLa#YxX(!cG!bHMn+FSq10q_Go@PX zdM8V8HPyWkRzP{loQh_hH8Nk7rPlMfdcenEvCVJE$GGWE)AVC;$FKc6N=yEV_#X-K zO(&f8Qin?W5n*M8p5=a@8_#P5I-Go}hE}^Xds;NlsccBE&QYWKolM^#kBB?)(Z?qb zl{*eP_Bui`wU&M22dqg66a>q1#06-xz2bXfsJpCABI@^@vd!c{R#}o(miZ$nE818y z^3TkpPKcOvZI?9~`4D;Z7G5y5vwd^FMe4&%>-Ux39Zq|$aeblNKg`|T2dQ43bzGL! z)4g|iDv(u}c4Q+UVAz6{-3`t_ z;3i$Qa{4}Q-x;S!S4>w>iD|sLlHDMan?@PXdVp)`X_{@0;v39U-mEjuX+&K*u1HMx zF6~+y&NT3HGp`iRGcG$kSdH*1=?|uAI+DzRH(@TyL(AGRx0;nf{qPm?KZ95s9OT}^lnLEVnW zMj$6f3;IkvZ^OKEmzKJaYpyiq<)hcj2nP1K_iQ!qI4c~ljts6STkDiDHPkGBz1D+1 z&;hCOz5_g^&mlh)DKg~7(dXsH9!T?{uSazGI+tv_yo>J5#R-3!gtz3GzTU-^Ct_Ch zh^SW99P!1oHKd1#c32~k1rM&?Ba^Teo}rN(j<9_6sn>e~HPdJK<05Jg-uCf!xb!!KcbL70z%Qs zxYSJw){1lURa)ET<&1MH;u_t#V~~`kuW2L2RPpuAQ4BO^yTgJ)C3$XgnuHqn0glVJ z@kn<8hw+D@n~NPm!PE{D;{ERUtNj6hrm3l^WL|ckOP>~lsVToSmG(uEf~?-bp;G~C z^x3%vzA>e#0Vz>#8wH8EM7D(P2cer&nc@2M^rqo%xb&N>C|z%~iR~_xEPF~Xhy~!B zKhoCWeDi^SqUcD+Q<3!@d3((|!E~Aup6d@XA$8m80=LwcJ2S&8Oljmewb?qz26m3m z8x6;)RFZl56V|G1?Yp!i3MsyY>bEgFtvXM#kK>gl z7#Yl4%k+9jAKp~hSsPJsZm52L$oV?=d}q;%AFDAGMfx*_Ot>0lm5~pDBRBJhy&uaUiEXJOIG>XM$-t75(yQqd}bjc>IHw6;_dCj!i;Dx z<+m`K#7LC!2Z=_?QF*m`aB~@358-it8h8TaGOFw1=0bzz;N5fEeRfa&TM@M7{Uh!# z&tzUI5ycvnGzo8#pvJ9ky^q6re8#8$@kSD@v<@n%9OsntH2qCm2zwEMox!1$y)*fd>aZuN?2oRIYwxI=h{9ZbRCQ^lI0$mL$ZrvvjGLX`iw64jK8>>@{k2 za%0*QG;+g-sY1oSBsuY|VcYGND|m9W@niC)y7m=rjizeh#4ZL;Ds8hK-6t!jGnb}R zV68QKWI@Pgx|~dAq8SPt6?pQ(Y3Ql>Ei30X&C(=`*F#S$f~ol{CS#*QZbY-&*K>ru z7~MU~E-DE|OW!%a0~EC)(9vTK?RrNzs;$8|_C>u-xm5T8M_3Opz$1*I17cR0lK5Wt zGoFDW;Zq|Y*DUn|N0y3nu+>HiBSHv+GNgIWvMj2*M z6`}m=QE{hs=DfJsOKlX>+#^r9_{qvL?oKHToMitXH?mW3tee!Q(~}rd`~Ay0^^j5b Xt^h&B!MW?jt4~{9U#(Kb{@#B9<^11~ literal 0 HcmV?d00001 From a996081ea51e9b4bec010b28486276333f05fcd8 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Tue, 3 Feb 2026 00:53:46 +0100 Subject: [PATCH 079/317] columns: simple falling-blocks game This is a start of falling-blocks game. More improvements are possible (and some are described in the sources), but this is already playable. You can try to beat score of 120 :-). --- .../META-INF/MANIFEST.JSON | 25 ++ .../apps/cz.ucw.pavel.columns/assets/main.py | 337 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 8820 bytes 3 files changed, 362 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.columns/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..51d15601 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON @@ -0,0 +1,25 @@ +{ +"name": "Columns", +"publisher": "Pavel Machek", +"short_description": "Falling columns game", +"long_description": "Blocks of 3 colors are falling. Align the colors to make blocks di\ +sappear.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/icons/cz.ucw.pavel.columns_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/mpks/cz.ucw.pavel.columns_0.0.1.mpk", +"fullname": "cz.ucw.pavel.columns", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py new file mode 100644 index 00000000..4bbc58b5 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py @@ -0,0 +1,337 @@ +import time +import random + +""" +Columns -- falling columns game + +Possible TODOs: + +should blink explosions +explodes while moving? +/ in bottom left part may not explode + +smooth moving? +music? +some kind of game over? + +more contrast colors? +different shapes? + +""" + +from mpos import Activity + +try: + import lvgl as lv +except ImportError: + pass + +class Main(Activity): + + COLS = 6 + ROWS = 12 + + COLORS = [ + 0xE74C3C, # red + 0xF1C40F, # yellow + 0x2ECC71, # green + 0x3498DB, # blue + 0x9B59B6, # purple + ] + + EMPTY = -1 + + FALL_INTERVAL = 1000 # ms + # I can do 120 in this config :-). + + def __init__(self): + super().__init__() + self.board = [[self.EMPTY for _ in range(self.COLS)] for _ in range(self.ROWS)] + self.cells = [] + + self.active_col = self.COLS // 2 + self.active_row = -3 + self.active_colors = [] + + self.timer = None + self.animating = False + + # --------------------------------------------------------------------- + + def onCreate(self): + self.screen = lv.obj() + self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + vert = 60 + horiz = 60 + font = lv.font_montserrat_20 + + score = lv.label(self.screen) + score.align(lv.ALIGN.TOP_LEFT, 5, 25) + score.set_text("Score") + score.set_style_text_font(font, 0) + self.lb_score = score + + btn_left = lv.button(self.screen) + btn_left.set_size(horiz, vert) + btn_left.align(lv.ALIGN.BOTTOM_LEFT, 5, -10-vert) + btn_left.add_event_cb(lambda e: self.move(-1), lv.EVENT.CLICKED, None) + lc = lv.label(btn_left) + lc.set_style_text_font(font, 0) + lc.set_text("<") + lc.center() + + btn_right = lv.button(self.screen) + btn_right.set_size(horiz, vert) + btn_right.align(lv.ALIGN.BOTTOM_RIGHT, -5, -10-vert) + btn_right.add_event_cb(lambda e: self.move(1), lv.EVENT.CLICKED, None) + lc = lv.label(btn_right) + lc.set_style_text_font(font, 0) + lc.set_text(">") + lc.center() + + btn_rotate = lv.button(self.screen) + btn_rotate.set_size(horiz, vert) + btn_rotate.align(lv.ALIGN.BOTTOM_RIGHT, -5, -15-vert-vert) + btn_rotate.add_event_cb(lambda e: self.rotate(), lv.EVENT.CLICKED, None) + lc = lv.label(btn_rotate) + lc.set_style_text_font(font, 0) + lc.set_text("R") + lc.center() + + btn_down = lv.button(self.screen) + btn_down.set_size(horiz, vert) + btn_down.align(lv.ALIGN.BOTTOM_LEFT, 5, -5) + btn_down.add_event_cb(lambda e: self.tick(0), lv.EVENT.CLICKED, None) + lc = lv.label(btn_down) + lc.set_style_text_font(font, 0) + lc.set_text("v") + lc.center() + + d = lv.display_get_default() + self.SCREEN_WIDTH = d.get_horizontal_resolution() + self.SCREEN_HEIGHT = d.get_vertical_resolution() + + self.CELL = min( + self.SCREEN_WIDTH // (self.COLS + 1), + self.SCREEN_HEIGHT // (self.ROWS + 1) + ) + + board_x = (self.SCREEN_WIDTH - self.CELL * self.COLS) // 2 + board_y = (self.SCREEN_HEIGHT - self.CELL * self.ROWS) // 2 + + for r in range(self.ROWS): + row = [] + for c in range(self.COLS): + o = lv.obj(self.screen) + o.set_size(self.CELL - 2, self.CELL - 2) + o.set_pos( + board_x + c * self.CELL + 1, + board_y + r * self.CELL + 1 + ) + o.set_style_radius(4, 0) + o.set_style_bg_color(lv.color_hex(0x1C2833), 0) + o.set_style_border_width(1, 0) + row.append(o) + self.cells.append(row) + + # Make screen focusable for keyboard input + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(self.screen) + + #self.screen.add_event_cb(self.on_touch, lv.EVENT.CLICKED, None) + self.screen.add_event_cb(self.on_key, lv.EVENT.KEY, None) + + self.setContentView(self.screen) + + self.new_game() + self.spawn_piece() + + + def new_game(self): + self.score = 0 + # --------------------------------------------------------------------- + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, self.FALL_INTERVAL, None) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # --------------------------------------------------------------------- + + def spawn_piece(self): + self.active_col = self.COLS // 2 + self.active_row = -3 + self.active_colors = [random.randrange(len(self.COLORS)) for _ in range(3)] + + def tick(self, t): + if self.can_fall(): + self.active_row += 1 + else: + self.lock_piece() + self.clear_matches() + self.spawn_piece() + + self.redraw() + + # --------------------------------------------------------------------- + + def can_fall(self): + for i in range(3): + r = self.active_row + i + 1 + c = self.active_col + if r >= self.ROWS: + return False + if r >= 0 and self.board[r][c] != self.EMPTY: + return False + return True + + def lock_piece(self): + for i in range(3): + r = self.active_row + i + if r >= 0: + self.board[r][self.active_col] = self.active_colors[i] + + # --------------------------------------------------------------------- + + def clear_matches(self): + to_clear = set() + score = 0 + + for r in range(self.ROWS): + for c in range(self.COLS): + color = self.board[r][c] + if color == self.EMPTY: + continue + + # horizontal + if c <= self.COLS - 3: + if all(self.board[r][c + i] == color for i in range(3)): + for i in range(3): + to_clear.add((r, c + i)) + score += 1 + + # vertical + if r <= self.ROWS - 3: + if all(self.board[r + i][c] == color for i in range(3)): + for i in range(3): + to_clear.add((r + i, c)) + score += 1 + + # diagonal \ + if r <= self.ROWS - 3 and c <= self.COLS - 3: + if all(self.board[r + i][c + i] == color for i in range(3)): + for i in range(3): + to_clear.add((r + i, c + i)) + score += 1 + + # diagonal / + if r <= self.ROWS - 3 and c > 2: + if all(self.board[r + i][c - i] == color for i in range(3)): + for i in range(3): + to_clear.add((r + i, c - i)) + score += 1 + + if not to_clear: + return + + print("Score: ", score) + self.score += score + self.lb_score.set_text("Score\n%d" % self.score) + for r, c in to_clear: + self.board[r][c] = self.EMPTY + + self.redraw() + time.sleep(.5) + self.apply_gravity() + self.redraw() + time.sleep(.5) + self.clear_matches() + self.redraw() + + def apply_gravity(self): + for c in range(self.COLS): + stack = [self.board[r][c] for r in range(self.ROWS) if self.board[r][c] != self.EMPTY] + for r in range(self.ROWS): + self.board[r][c] = self.EMPTY + for i, v in enumerate(reversed(stack)): + self.board[self.ROWS - 1 - i][c] = v + + # --------------------------------------------------------------------- + + def redraw(self): + # draw board + for r in range(self.ROWS): + for c in range(self.COLS): + v = self.board[r][c] + if v == self.EMPTY: + self.cells[r][c].set_style_bg_color(lv.color_hex(0x1C2833), 0) + else: + self.cells[r][c].set_style_bg_color( + lv.color_hex(self.COLORS[v]), 0 + ) + + # draw active piece + for i in range(3): + r = self.active_row + i + if r >= 0 and r < self.ROWS: + self.cells[r][self.active_col].set_style_bg_color( + lv.color_hex(self.COLORS[self.active_colors[i]]), 0 + ) + + # --------------------------------------------------------------------- + + def on_touch(self, e): + return + print("Touch event") + p = lv.indev_get_act().get_point() + x = p.x + + if x < self.SCREEN_WIDTH // 3: + self.move(-1) + elif x > self.SCREEN_WIDTH * 2 // 3: + self.move(1) + else: + self.rotate() + + def on_key(self, event): + """Handle keyboard input""" + print("Keyboard event") + key = event.get_key() + if key == ord("a"): + self.move(-1) + return + if key == ord("w"): + self.rotate() + return + if key == ord("d"): + self.move(1) + return + if key == ord("s"): + self.tick(0) + return + + #if key == lv.KEY.ENTER or key == lv.KEY.UP or key == ord("A") or key == ord("a"): + print(f"on_key: unhandled key {key}") + + def move(self, dx): + nc = self.active_col + dx + if not(0 <= nc < self.COLS): + return + + for i in range(3): + r = self.active_row + i + if self.board[r][nc] != self.EMPTY: + return + + self.active_col = nc + self.redraw() + + def rotate(self): + self.active_colors = self.active_colors[-1:] + self.active_colors[:-1] + self.redraw() + diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.columns/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..49812a67e41c1cc2a86d6e37a8d318be137fb5a2 GIT binary patch literal 8820 zcmeHMc{tST+n=#-C6Oi47?jGG#TYYV-y00Fi_&ZuW@E+@g(4LtA*mD%%cYmfIwoG!kyiDj-*fsCy4DsXV4(L-5eT(7Do30fx>#n z(>+7fwyd;RRIrd#F^=Aa)$blvn3;-5Xlg0l5prU(pz2Hhx>hyYRr=W%FRE6}W=65+ z;E`&V#>gGX0j+XICHK~@)qPzV)%*FQoBs3RjfTqa=c*#-!dJarz5V8*=4a%>(@AgX ze&S25np3$)8eCQ&;)f2)CNRz5#!LPT^j7xo~?E9=w zxM|dS%e<|>h+w#|Zq2)g=9goO?>x90n#5rnkENWw#(f-?q3~8c5Ppq#LH}61iDB8} zSs%BFi*7qZ7ZMgE?T+u6)58xaYS;GK&+q-x|7jxq)5B)e&Mmc*ncbhlGKPI8J^BaU z8SY3>NO{wE{+tyqRAMaMt2N-1&1bcVS+5iM*mk|C#DeadaYcrMY2z0&Z+#x`rXO6- zK*p^}#l%XznpyM6p&{wPP^RC>=O#JLsrw~`C)R{F1i%-rdbR~M$jP2Ncl@^YDP0Zh z{FjY|@QuBFjCa4d?5rSr-!^W-v~WK@)KGdcCv7z%|7rK$ugF?4W!uJ<1N;^F&z)vo zt?xl8-4DEmKs*{C4Fwx+s58HMw&#ePK8IE}Syqb{m{r!3{rWgPSK#pZmwcIy>0>B! zmF?)%T>;aHU$imVoX`djS;J(DK;ntGBIq5VNVF{CNpg0&V>7{tpi{1idy~rTV!Y~h zM*DYM&9lg_fU2iC2R(B1x{}lBAQ)t;<5rN<)w?zU1}YyER@GF$c{bja zSvVQ-(5OZ>@iHUpR`tF7ytYl%j|iyDp^{Yw%5Kx3=#_8E@;}MPXm_7#D_nahUZ#hJ z8n$ICH^c;fPO6CdJaKS%;Pb<~pR6Lq?b~qNXGTLf-@^HNuF?FsbP@3Z=H=>8eJaV% zvL<$lQnwkF>O&ZhfW5oH6@Qnyg82(e--q?MEjuEo^yJumlBY1K;b`QD9Vxq~$Y6t8 z+v7q7-iVT3AwFaW3es^0{^azep3R=3uDxTQm}fX(PjZa`_4<|5PVg5Yp5k*dcQ5sn z!@Wu^$Mvp)5t}#OOVI8aGJSputz9@RJo1wGZXo24pLImH<|l!&MvUt_K0n?t=2WWm zTqVXKo$hwOt^i|bw!`a=_|&LDr-8qF^oF}}?~dE3pKZ8m-fDRLPMci8{IlpSG{hal zhOzlxO;7L{VMd@g%)1ZdCilv_&x>;XNr@vg?+8q{{T$IUQ&oRz=0#0Wc3M-5{&IX;TR88{MZ|i~LZUK{2>E(=NZK0s2Py_SQ{< zFEQ~k!j~33U3WjP3|&VSzvp}ibR%%MeB*neTOuPh38UK~rA7XS(uc;-tMb!I3FoK6 zaDACdcBwey&>>EW)1-%6CTX_h-dmb6Q@}Z2^~sfWt1o(n_pk~o-$!|y9#J`%C3rxV z%ix9|h$&Kea7>WSL4?bD>`}M!^Tg`eVhj_ly2oq{ zqWxS%idk)2lqTg3+aiw~O+ZKwH@H$zOak3J`gGUZ<7l0#mbG{^so!}jk}R{+k5;hv z$ID(o@ezUs&W21RHZ8hKl0@ZCrw_(y;n!h#SA`Ur!PhnJbyH+ z_cAF@zhr7wv1mHuaj}tJ8}10QL1s?yt*moth*5`H;#u_!sm_yXGg69RrN+MYfE|5A zqk3-{WM;(#QCP(IRDnc!PT986>K-0Z+IEW8yW>OgpnAEVp-F@%PIE~kZT#%~7m@l$ z?ltZ;RC;*7@Lzigsh%Mgs~J##RJFl< zL)vvppQ~ml5^w!(yXgqXE9rcXR+@q8*RE;h$t=&KjNJ}WXSYdy$u7(tIn!}(uO~m@K{lw^ zGEc?kWkyC{y*gCN3#kQdy!-X!lAQy=ARXG0RQPk-M7g0anN& zdAgj2B#{<#@^mY!mDearRpv|NimpRmUY)%UG3+NUTgqJf#?9#R!**1Lww{sHKb$S?^b*lq07eS+YCjH#gUAnUJg(t-dpn!w|6H zf2~Tq)*0`2{-Bgf^T)mmZ{{n%5@gb+LoTV`?+Rc}?PYNr^iUA+}$D-2kjJ@JHfJ$gOG}{ z4z93+-nA4&V0ZMd*?n$9<1at3PoBk4~#gC+L@$R*8}&Kc~J)q*ys4#7vAZYjATE#o?7DWz3#q#nc&w8@3swQjBYfh zuDG0{b}X@?DIdP&>g6@BDoo?b9&esrD^s8ol8?uuv!2{;G2er}7~mJjOKu-}HN<#r zc|jyaPF1xrZeBRA{qaK7g~i+EyH-|4t1OCFmWZP*R%Q2qK;Q#(GcyN*nc1%=R^XhP zvG;hbO@p!0U2mrx=&W#$iMfhda4B^2i(D5C(*q7$u8r;r8iD(DBxT>giWLe9M`^mQ z^U4Sg=ByF8y&kdvev$nasvZf4T|4xC@P_2%gu%oB*tEO}ozGNi=k?j%xj^1rvsq}T zN|xL+`TF!0O^RLeizy}VxvBTFTJ>esZNK0upB%Xh%G|2|K;rR+0m!+5Yb#pk@2I4m zba}yju`NYw>({2>oK|)QS4&)9o4P@I`i=@2*{EHq8_iwM$W9Ab}(6yP_>uD-T@c1rR_|>-E?u`M;Wa{xx+2!HCwV8`e@HdU zSMkOTmVdJ{=O+J*kwWRFs^rW|Ne}rZeP@XDtV!9oUioufJ&hy$N!f)jbn9-bAd)YR z1s@9yz4r0r#iw`q^F7x7n>!oQp6tWEA1sFixi4G7mb`TAM)0HY5ERr<45A-I z^}#!uTYjeiMg~wn9*={E!$U(uwL-PE*g?Kd5r?&avlK>OI+L?3v4s8^ zNu~V2ae{*w%NQyJPGiuRfFWFf8TmUrkM8r^2K~;TrImj(1W@;b|99wLRb+!{{QhG%AWp{)xgq zhz?W&iSe^lOH@>V3XSyf(V-%IU}RkcKt-a^U?egI38Nr%k=j%p8V*Y#EmJM+1Kz}e zU;ssFA%2^2V32q|>>#EA)RxW)4*PAvna-p+@kmQSL+a`xQ93AXgpQ83Hdgy5X;)eh z7fA9YP9#DLg<2M;Qt*}lBMC@0I+Nr}gL7EE%L_}}f(M)d0!vz|4S;!h9`FTk7DOZQ z*g?*0Hp2kAWGiHea=D}+`rnHMZ_B1E3oe6c)TMg+UU8-*U-cC=!fJ#^?aOfsBEXbjd)w(k7u0SgI}#P5GDI|KIzg4_~_B z{hEIIOSc3f5&v_7>Mz|g@Pwt#;1tZ^FzB?P-zV;`h5X;(ev<#*H2#zL&#-UOW^B%G zpsD)t>_b_9s{Rjv-xzG^6dH@m{xj2mhI|XlPv-)_=5KSrc>_4Uz<-=yzL)J%XZssp z-`m08r~#nwiuD=w4zXbj#yZ+C@CHC7dAT$q%e;_v=AQUTeWgM z?D74P?I4iAL4vu7Gw|o^8Sg{RF7kCz8&5Ky)m#b4FK8hv60VrU`3YEPs?F>=U?OR= z+G0j{&(yX(tHdu{2u}(hxuTpLubGr(8{^*8eo$n;Y%}^<2czt;WoT#yvoIn&U9o@N zaI3-Mt*3|I#5_2sH`mXd_?eXT=qpFE9E64QMB5)s0)0oJ0)#B!N9Dm#MPP~likdZ3w~KQHJ@DTxnk51A~@)k z_tnYnSp*;dC&Tio*90}eU4eODt)yp<34p3Qcdag^I*J)n-Pvj$siUuXd3)|&crKpm za{odk>zNh_mBNH!7IhHgoXm|h4GZft~3r>3!IE2RY>}LA%%;!XvIAi{KQhv7>x;b zD*i>h2b&k&9sP?e|5q2Wy66Uj@PVhn^JUtCbF_-Un%5Eur|O^wBb=jk2JJB9qSL|7($HKZ%B3lSoW{QLF5Y5Lsp9fjZqwn{%DvMkN{_v>(5b_$>=~jPS_Kul^{%V4wg+WmTS5q8(GLs5GEB3in$l zUW|U7cUSbgt>wR1!7W~MmT9Bmwofw0i(-ZEIG)JMw)i0Fay4=%sm-MV=u#kpg`Ih! I>8}0%1%q%D-v9sr literal 0 HcmV?d00001 From 1d99047f3db5dc6f579d0c5ca55991b34dcfc6cd Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 22:11:09 +0100 Subject: [PATCH 080/317] Fix lilygo_t_display_s3.py --- .../lib/mpos/board/lilygo_t_display_s3.py | 74 +++++++++++++------ 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index 7ed84bf4..b20b2b0f 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -1,12 +1,10 @@ -print("lilygo_t_display_s3.py running again") +print("lilygo_t_display_s3.py running") import lcd_bus import lvgl as lv import machine import time -import mpos.ui - print("lilygo_t_display_s3.py display bus initialization") try: display_bus = lcd_bus.I80Bus( @@ -21,7 +19,7 @@ data5=46, data6=47, data7=48, - reverse_color_bits=False # doesnt seem to do anything? + #reverse_color_bits=False # doesnt seem to do anything? ) except Exception as e: print(f"Error initializing display bus: {e}") @@ -34,40 +32,45 @@ fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) import drivers.display.st7789 as st7789 +import mpos.ui mpos.ui.main_display = st7789.ST7789( data_bus=display_bus, frame_buffer1=fb1, frame_buffer2=fb2, display_width=170, # emulator st7789.c has 135 display_height=320, # emulator st7789.c has 240 - color_space=lv.COLOR_FORMAT.RGB565, - #color_space=lv.COLOR_FORMAT.RGB888, + #color_space=lv.COLOR_FORMAT.RGB565, + color_space=lv.COLOR_FORMAT.RGB888, color_byte_order=st7789.BYTE_ORDER_RGB, # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 - power_pin=15, + power_pin=9, # Must set RD pin to high, otherwise blank screen as soon as LVGL's task_handler starts reset_pin=5, - backlight_pin=38, + reset_state=st7789.STATE_LOW, # needs low: high will not enable the display + backlight_pin=38, # needed backlight_on_state=st7789.STATE_PWM, + offset_x=35, + offset_y=0 ) +mpos.ui.main_display.set_power(True) # set RD pin to high before the rest, otherwise garbled output mpos.ui.main_display.init() -mpos.ui.main_display.set_power(True) -mpos.ui.main_display.set_backlight(100) +mpos.ui.main_display.set_backlight(100) # works lv.init() #mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling -mpos.ui.main_display.set_color_inversion(True) # doesnt seem to do anything? +mpos.ui.main_display.set_color_inversion(True) + # Button handling code: from machine import Pin btn_a = Pin(0, Pin.IN, Pin.PULL_UP) # 1 btn_b = Pin(14, Pin.IN, Pin.PULL_UP) # 2 -btn_c = Pin(3, Pin.IN, Pin.PULL_UP) # 3 # Key repeat configuration # This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where # the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus. REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat REPEAT_RATE_MS = 100 # Interval between repeats +REPEAT_PREV_BECOMES_BACK = 700 # Long previous press becomes back button last_key = None last_state = lv.INDEV_STATE.RELEASED key_press_start = 0 # Time when key was first pressed @@ -86,25 +89,51 @@ def keypad_read_cb(indev, data): if btn_a.value() == 0: current_key = lv.KEY.PREV elif btn_b.value() == 0: - current_key = lv.KEY.ENTER - elif btn_c.value() == 0: current_key = lv.KEY.NEXT - if (btn_a.value() == 0) and (btn_c.value() == 0): - current_key = lv.KEY.ESC + if (btn_a.value() == 0) and (btn_b.value() == 0): + current_key = lv.KEY.ENTER - if current_key: - if current_key != last_key: - # New key press + if current_key is not None: + if last_key is None or current_key != last_key: + print(f"New key press: {current_key}") data.key = current_key data.state = lv.INDEV_STATE.PRESSED last_key = current_key last_state = lv.INDEV_STATE.PRESSED key_press_start = current_time last_repeat_time = current_time + else: + print(f"key repeat because current_key {current_key} == last_key {last_key}") + elapsed = time.ticks_diff(current_time, key_press_start) + since_last_repeat = time.ticks_diff(current_time, last_repeat_time) + if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: + if current_key == lv.KEY.PREV: + print("Repeated PREV does not do anything, instead it triggers ESC (back) if long enough") + if since_last_repeat > REPEAT_PREV_BECOMES_BACK: + print("back button trigger!") + data.key = lv.KEY.ESC + data.state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + print("repeat PREV ignored because not pressed long enough") + else: + print("Send a new PRESSED/RELEASED pair for repeat") + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + pass # not needed as it doesnt help navigating around in the keyboard: + #print("No repeat yet, send RELEASED to avoid PRESSING?") + #data.state = lv.INDEV_STATE.RELEASED + #last_state = lv.INDEV_STATE.RELEASED else: # No key pressed - data.key = last_key if last_key else lv.KEY.ENTER + data.key = last_key if last_key else -1 data.state = lv.INDEV_STATE.RELEASED last_key = None last_state = lv.INDEV_STATE.RELEASED @@ -112,9 +141,8 @@ def keypad_read_cb(indev, data): last_repeat_time = 0 # Handle ESC for back navigation (only on initial PRESSED) - #if last_state == lv.INDEV_STATE.PRESSED: - # if current_key == lv.KEY.ESC: - # mpos.ui.back_screen() + if data.state == lv.INDEV_STATE.PRESSED and data.key == lv.KEY.ESC: + mpos.ui.back_screen() group = lv.group_create() From 7acb881e8aace14619df36658238ae155bac3517 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 22:15:50 +0100 Subject: [PATCH 081/317] Simplify --- .../lib/mpos/board/lilygo_t_display_s3.py | 91 ++++++++++--------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index b20b2b0f..b8875132 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -81,57 +81,21 @@ # that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. def keypad_read_cb(indev, data): global last_key, last_state, key_press_start, last_repeat_time - since_last_repeat = 0 # Check buttons - current_key = None current_time = time.ticks_ms() - if btn_a.value() == 0: + btn_a_pressed = btn_a.value() == 0 + btn_b_pressed = btn_b.value() == 0 + if btn_a_pressed and btn_b_pressed: + current_key = lv.KEY.ENTER + elif btn_a_pressed: current_key = lv.KEY.PREV - elif btn_b.value() == 0: + elif btn_b_pressed: current_key = lv.KEY.NEXT - - if (btn_a.value() == 0) and (btn_b.value() == 0): - current_key = lv.KEY.ENTER - - if current_key is not None: - if last_key is None or current_key != last_key: - print(f"New key press: {current_key}") - data.key = current_key - data.state = lv.INDEV_STATE.PRESSED - last_key = current_key - last_state = lv.INDEV_STATE.PRESSED - key_press_start = current_time - last_repeat_time = current_time - else: - print(f"key repeat because current_key {current_key} == last_key {last_key}") - elapsed = time.ticks_diff(current_time, key_press_start) - since_last_repeat = time.ticks_diff(current_time, last_repeat_time) - if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: - if current_key == lv.KEY.PREV: - print("Repeated PREV does not do anything, instead it triggers ESC (back) if long enough") - if since_last_repeat > REPEAT_PREV_BECOMES_BACK: - print("back button trigger!") - data.key = lv.KEY.ESC - data.state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED - last_key = current_key - last_state = data.state - last_repeat_time = current_time - else: - print("repeat PREV ignored because not pressed long enough") - else: - print("Send a new PRESSED/RELEASED pair for repeat") - data.key = current_key - data.state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED - last_key = current_key - last_state = data.state - last_repeat_time = current_time - else: - pass # not needed as it doesnt help navigating around in the keyboard: - #print("No repeat yet, send RELEASED to avoid PRESSING?") - #data.state = lv.INDEV_STATE.RELEASED - #last_state = lv.INDEV_STATE.RELEASED else: + current_key = None + + if current_key is None: # No key pressed data.key = last_key if last_key else -1 data.state = lv.INDEV_STATE.RELEASED @@ -139,6 +103,43 @@ def keypad_read_cb(indev, data): last_state = lv.INDEV_STATE.RELEASED key_press_start = 0 last_repeat_time = 0 + elif last_key is None or current_key != last_key: + print(f"New key press: {current_key}") + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + last_key = current_key + last_state = lv.INDEV_STATE.PRESSED + key_press_start = current_time + last_repeat_time = current_time + else: + print(f"key repeat because current_key {current_key} == last_key {last_key}") + elapsed = time.ticks_diff(current_time, key_press_start) + since_last_repeat = time.ticks_diff(current_time, last_repeat_time) + if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: + next_state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED + if current_key == lv.KEY.PREV: + print("Repeated PREV does not do anything, instead it triggers ESC (back) if long enough") + if since_last_repeat > REPEAT_PREV_BECOMES_BACK: + print("back button trigger!") + data.key = lv.KEY.ESC + data.state = next_state + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + print("repeat PREV ignored because not pressed long enough") + else: + print("Send a new PRESSED/RELEASED pair for repeat") + data.key = current_key + data.state = next_state + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + pass # not needed as it doesnt help navigating around in the keyboard: + #print("No repeat yet, send RELEASED to avoid PRESSING?") + #data.state = lv.INDEV_STATE.RELEASED + #last_state = lv.INDEV_STATE.RELEASED # Handle ESC for back navigation (only on initial PRESSED) if data.state == lv.INDEV_STATE.PRESSED and data.key == lv.KEY.ESC: From 31c2eb168c95247bffe997e204bc56ac6dcf22de Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 22:29:25 +0100 Subject: [PATCH 082/317] lilygo_t_display_s3: add combo guard This avoids PREV or NEXT actions being triggered when the buttons aren't pressed exactly simultaneously. --- .../lib/mpos/board/lilygo_t_display_s3.py | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index b8875132..90a0e555 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -71,23 +71,58 @@ REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat REPEAT_RATE_MS = 100 # Interval between repeats REPEAT_PREV_BECOMES_BACK = 700 # Long previous press becomes back button +COMBO_GRACE_MS = 60 # Accept near-simultaneous A+B as ENTER last_key = None last_state = lv.INDEV_STATE.RELEASED key_press_start = 0 # Time when key was first pressed last_repeat_time = 0 # Time of last repeat event +last_a_down_time = 0 +last_b_down_time = 0 +last_a_pressed = False +last_b_pressed = False # Read callback # Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, # that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. def keypad_read_cb(indev, data): - global last_key, last_state, key_press_start, last_repeat_time + global last_key, last_state, key_press_start, last_repeat_time, last_a_down_time, last_b_down_time + global last_a_pressed, last_b_pressed # Check buttons current_time = time.ticks_ms() btn_a_pressed = btn_a.value() == 0 btn_b_pressed = btn_b.value() == 0 + if btn_a_pressed and not last_a_pressed: + last_a_down_time = current_time + if btn_b_pressed and not last_b_pressed: + last_b_down_time = current_time + last_a_pressed = btn_a_pressed + last_b_pressed = btn_b_pressed + + near_simul = False if btn_a_pressed and btn_b_pressed: + near_simul = True + elif btn_a_pressed and last_b_down_time and time.ticks_diff(current_time, last_b_down_time) <= COMBO_GRACE_MS: + near_simul = True + elif btn_b_pressed and last_a_down_time and time.ticks_diff(current_time, last_a_down_time) <= COMBO_GRACE_MS: + near_simul = True + + single_press_wait = False + if btn_a_pressed ^ btn_b_pressed: + if btn_a_pressed and time.ticks_diff(current_time, last_a_down_time) < COMBO_GRACE_MS: + single_press_wait = True + elif btn_b_pressed and time.ticks_diff(current_time, last_b_down_time) < COMBO_GRACE_MS: + single_press_wait = True + + if near_simul or single_press_wait: + dt_a = time.ticks_diff(current_time, last_a_down_time) if last_a_down_time else None + dt_b = time.ticks_diff(current_time, last_b_down_time) if last_b_down_time else None + print(f"combo guard: a={btn_a_pressed} b={btn_b_pressed} near={near_simul} wait={single_press_wait} dt_a={dt_a} dt_b={dt_b}") + + if near_simul: current_key = lv.KEY.ENTER + elif single_press_wait: + current_key = None elif btn_a_pressed: current_key = lv.KEY.PREV elif btn_b_pressed: From 1ab484577770f1b91f379cdbce94ae232275f141 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 22:45:13 +0100 Subject: [PATCH 083/317] lilygo_t_display_s3: rotate display for more natural experience --- .../lib/mpos/board/lilygo_t_display_s3.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index 90a0e555..490c9713 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -37,8 +37,8 @@ data_bus=display_bus, frame_buffer1=fb1, frame_buffer2=fb2, - display_width=170, # emulator st7789.c has 135 - display_height=320, # emulator st7789.c has 240 + display_width=170, + display_height=320, #color_space=lv.COLOR_FORMAT.RGB565, color_space=lv.COLOR_FORMAT.RGB888, color_byte_order=st7789.BYTE_ORDER_RGB, @@ -48,15 +48,15 @@ reset_state=st7789.STATE_LOW, # needs low: high will not enable the display backlight_pin=38, # needed backlight_on_state=st7789.STATE_PWM, - offset_x=35, - offset_y=0 + offset_x=0, + offset_y=35 ) mpos.ui.main_display.set_power(True) # set RD pin to high before the rest, otherwise garbled output mpos.ui.main_display.init() mpos.ui.main_display.set_backlight(100) # works lv.init() -#mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling mpos.ui.main_display.set_color_inversion(True) From d58f7c7bfbd29db2d3e93049efdfa64f9243baea Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 22:48:28 +0100 Subject: [PATCH 084/317] tweak qemu --- internal_filesystem/lib/mpos/board/qemu.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/qemu.py b/internal_filesystem/lib/mpos/board/qemu.py index f50e2afa..0c2f6f9d 100644 --- a/internal_filesystem/lib/mpos/board/qemu.py +++ b/internal_filesystem/lib/mpos/board/qemu.py @@ -5,8 +5,6 @@ import machine import time -import mpos.ui - print("qemu.py display bus initialization") try: display_bus = lcd_bus.I80Bus( @@ -33,6 +31,7 @@ fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) +import mpos.ui import drivers.display.st7789 as st7789 # 320x200 => make 320x240 screenshot => it's 240x200 (but the display shows more than 200) mpos.ui.main_display = st7789.ST7789( From b145b2f091ce6c952d2dde0717a857f9cc6733b3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 23:03:01 +0100 Subject: [PATCH 085/317] Comments --- internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index 490c9713..9e7eceec 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -1,3 +1,5 @@ +# LilyGo T-Display non-touch edition + print("lilygo_t_display_s3.py running") import lcd_bus @@ -39,7 +41,7 @@ frame_buffer2=fb2, display_width=170, display_height=320, - #color_space=lv.COLOR_FORMAT.RGB565, + #color_space=lv.COLOR_FORMAT.RGB565, # gives bad colors color_space=lv.COLOR_FORMAT.RGB888, color_byte_order=st7789.BYTE_ORDER_RGB, # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 From e8601c5b0542ede8f7832ab0efba3210fd0aff32 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 23:03:42 +0100 Subject: [PATCH 086/317] Board detect: use more digits Otherwise it will break if espressif wraps around and starts producing boards with the same first digit of the unique ID. --- internal_filesystem/lib/mpos/main.py | 46 +++++++++++++++------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index d8eaaf36..eee2a6a2 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -89,6 +89,29 @@ def detect_board(): return "linux" elif sys.platform == "esp32": + # First do unique_id-based board detections because they're fast and don't mess with actual hardware configurations + import machine + unique_id_prefixes = machine.unique_id()[0:3] + + print("qemu ?") + if unique_id_prefixes[0] == 0x10: + return "qemu" + + print("odroid_go ?") + if unique_id_prefixes[0] == 0x30: + return "odroid_go" + + print("lilygo_t_display_s3 ?") + if unique_id_prefixes == b'\xc0\x4e\x30': + return "lilygo_t_display_s3" # display gets confused by the i2c stuff below + + print("fri3d_2026 ?") + if unique_id_prefixes == b'\xdc\xb4\xd9': + # or: if single_address_i2c_scan(i2c0, 0x6A): # IMU currently not installed on prototype board + return "fri3d_2026" + + + # Then do I2C-based board detection print("matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 ?") if i2c0 := fail_save_i2c(sda=39, scl=38): if single_address_i2c_scan(i2c0, 0x14) or single_address_i2c_scan(i2c0, 0x5D): # "ghost" or real GT911 touch screen @@ -105,35 +128,16 @@ def detect_board(): if single_address_i2c_scan(i2c0, 0x68): # IMU (MPU6886) return "m5stack_fire" - import machine - unique_id_prefix = machine.unique_id()[0] - - print("odroid_go ?") - if unique_id_prefix == 0x30: - return "odroid_go" - print("fri3d_2024 ?") if i2c0 := fail_save_i2c(sda=9, scl=18): if single_address_i2c_scan(i2c0, 0x6B): # IMU (plus possibly the Communicator's LANA TNY at 0x38) return "fri3d_2024" - print("fri3d_2026 ?") - if unique_id_prefix == 0xDC: # prototype board had: dc:b4:d9:0b:7d:80 - # or: if single_address_i2c_scan(i2c0, 0x6A): # IMU currently not installed on prototype board - return "fri3d_2026" - - print("qemu ?") - if unique_id_prefix == 0x10: - return "qemu" - - print("lilygo_t_display_s3 ?") - if unique_id_prefix == 0xc0: - return "lilygo_t_display_s3" - + print("lilygo_t_watch_s3_plus ?") if i2c0 := fail_save_i2c(sda=10, scl=11): if single_address_i2c_scan(i2c0, 0x20): # IMU return "lilygo_t_watch_s3_plus" # example MAC address: D0:CF:13:33:36:306 - + print("Unknown board: couldn't detect known I2C devices or unique_id prefix") From e0727c2d227cf2e90196808d692320ba9b7ed4e6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 23:11:33 +0100 Subject: [PATCH 087/317] lilygo_t_display_s3: different rotation so buttons make sense Otherwise, with 90 degree rotation, the PREV and NEXT buttons should be swapped. With 270 degree rotation, they can remain. --- internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index 9e7eceec..5e3af8e7 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -58,14 +58,14 @@ mpos.ui.main_display.set_backlight(100) # works lv.init() -mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._270) # must be done after initializing display and creating the touch drivers, to ensure proper handling mpos.ui.main_display.set_color_inversion(True) # Button handling code: from machine import Pin -btn_a = Pin(0, Pin.IN, Pin.PULL_UP) # 1 -btn_b = Pin(14, Pin.IN, Pin.PULL_UP) # 2 +btn_a = Pin(0, Pin.IN, Pin.PULL_UP) +btn_b = Pin(14, Pin.IN, Pin.PULL_UP) # Key repeat configuration # This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where From 65d7f6e4b337018e1e9d172a435e29e8cffb5c4a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 23:12:30 +0100 Subject: [PATCH 088/317] Comments --- internal_filesystem/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal_filesystem/main.py b/internal_filesystem/main.py index 4e2829be..c44ee8a6 100644 --- a/internal_filesystem/main.py +++ b/internal_filesystem/main.py @@ -2,6 +2,8 @@ # Make sure the storage partition's lib/ is first in the path, so whatever is placed there overrides frozen libraries. # This allows any build to be used for development as well, just by overriding the libraries in lib/ + +# Copy this file to / on the device's internal storage to have it run automatically instead of relying on the frozen-in files. import gc import os import sys From 33862749b73d58a0fa33cd313b294c9f5343ea4b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 23:33:48 +0100 Subject: [PATCH 089/317] Comments --- .../lib/mpos/board/lilygo_t_display_s3.py | 8 ++++---- internal_filesystem/lib/mpos/ui/keyboard.py | 10 +++++++++- scripts/build_mpos.sh | 4 ++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index 5e3af8e7..1bd2e5b9 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -173,10 +173,10 @@ def keypad_read_cb(indev, data): last_state = data.state last_repeat_time = current_time else: - pass # not needed as it doesnt help navigating around in the keyboard: - #print("No repeat yet, send RELEASED to avoid PRESSING?") - #data.state = lv.INDEV_STATE.RELEASED - #last_state = lv.INDEV_STATE.RELEASED + # This doesn't seem to make the key navigation in on-screen keyboards work, unlike on the m5stack_fire...? + #print("No repeat yet, send RELEASED to avoid PRESSING, which breaks keyboard navigation...") + data.state = lv.INDEV_STATE.RELEASED + last_state = lv.INDEV_STATE.RELEASED # Handle ESC for back navigation (only on initial PRESSED) if data.state == lv.INDEV_STATE.PRESSED and data.key == lv.KEY.ESC: diff --git a/internal_filesystem/lib/mpos/ui/keyboard.py b/internal_filesystem/lib/mpos/ui/keyboard.py index 198c1548..625c3e6c 100644 --- a/internal_filesystem/lib/mpos/ui/keyboard.py +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -16,6 +16,7 @@ """ import lvgl as lv + from .appearance_manager import AppearanceManager from .widget_animator import WidgetAnimator @@ -129,7 +130,14 @@ def __init__(self, parent): def _handle_events(self, event): code = event.get_code() - #print(f"keyboard event code = {code}") + + ''' + # DEBUG: + from .event import get_event_name + name = get_event_name(code) + print(f"keyboard event code = {code} is {name}") + ''' + if code == lv.EVENT.READY or code == lv.EVENT.CANCEL: self.hide_keyboard() return diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 96220efc..e3e6d3c2 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -102,7 +102,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "esp32s3_qem fi fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) - frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage + #frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." pushd "$codebasedir"/lvgl_micropython/ rm -rf lib/micropython/ports/esp32/build-$BOARD-$BOARD_VARIANT @@ -111,7 +111,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "esp32s3_qem # --ota: support Over-The-Air updates # --partition size: both OTA partitions are 4MB # --flash-size: total flash size is 16MB - # --debug: enable debugging from ESP-IDF but makes copying files to it very slow + # --debug: enable debugging from ESP-IDF but makes copying files to it very slow so that's not added # --dual-core-threads: disabled GIL, run code on both CPUs # --task-stack-size={stack size in bytes} # CONFIG_* sets ESP-IDF options From 51056eaad97f5241db02995d9a7c093c33208518 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 23 Feb 2026 23:34:08 +0100 Subject: [PATCH 090/317] Fix typo --- scripts/build_mpos.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index e3e6d3c2..f0cb1dd2 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -102,7 +102,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "esp32s3_qem fi fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) - #frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage + frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." pushd "$codebasedir"/lvgl_micropython/ rm -rf lib/micropython/ports/esp32/build-$BOARD-$BOARD_VARIANT From 29a5465992675e4b91ffb3131b9f52523d372a01 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 00:10:09 +0100 Subject: [PATCH 091/317] lilygo_t_display_s3: fix keyboard handling --- .../lib/mpos/board/lilygo_t_display_s3.py | 19 +++++++++++++++++-- internal_filesystem/lib/mpos/main.py | 8 +++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index 1bd2e5b9..cd939eab 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -121,14 +121,29 @@ def keypad_read_cb(indev, data): dt_b = time.ticks_diff(current_time, last_b_down_time) if last_b_down_time else None print(f"combo guard: a={btn_a_pressed} b={btn_b_pressed} near={near_simul} wait={single_press_wait} dt_a={dt_a} dt_b={dt_b}") + # While in an on-screen keyboard, PREV button is LEFT and NEXT button is RIGHT + focus_group = lv.group_get_default() + focus_keyboard = False + if focus_group: + current_focused = focus_group.get_focused() + if isinstance(current_focused, lv.keyboard): + #print("focus is on a keyboard") + focus_keyboard = True + if near_simul: current_key = lv.KEY.ENTER elif single_press_wait: current_key = None elif btn_a_pressed: - current_key = lv.KEY.PREV + if focus_keyboard: + current_key = lv.KEY.LEFT + else: + current_key = lv.KEY.PREV elif btn_b_pressed: - current_key = lv.KEY.NEXT + if focus_keyboard: + current_key = lv.KEY.RIGHT + else: + current_key = lv.KEY.NEXT else: current_key = None diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index eee2a6a2..2a480e74 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -184,9 +184,11 @@ def custom_exception_handler(e): sys.print_exception(e) # NOQA # No need to deinit() and re-init LVGL: #mpos.ui.task_handler.deinit() # default task handler does this, but then things hang - # otherwise it does focus_next and then crashes while doing lv.deinit() - #focusgroup.remove_all_objs() - #focusgroup.delete() + #focusgroup = lv.group_get_default() + #if focusgroup: # on esp32 this may not be set + # otherwise it does focus_next and then crashes while doing lv.deinit() + #focusgroup.remove_all_objs() + #focusgroup.delete() #lv.deinit() import sys From c95a24bbfa692a7ffb7c8b2310fd522d51cdbc62 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 00:10:09 +0100 Subject: [PATCH 092/317] lilygo_t_display_s3: fix keyboard handling --- .../lib/mpos/board/lilygo_t_display_s3.py | 19 +++++++++++++++++-- internal_filesystem/lib/mpos/main.py | 8 +++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index 1bd2e5b9..cd939eab 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -121,14 +121,29 @@ def keypad_read_cb(indev, data): dt_b = time.ticks_diff(current_time, last_b_down_time) if last_b_down_time else None print(f"combo guard: a={btn_a_pressed} b={btn_b_pressed} near={near_simul} wait={single_press_wait} dt_a={dt_a} dt_b={dt_b}") + # While in an on-screen keyboard, PREV button is LEFT and NEXT button is RIGHT + focus_group = lv.group_get_default() + focus_keyboard = False + if focus_group: + current_focused = focus_group.get_focused() + if isinstance(current_focused, lv.keyboard): + #print("focus is on a keyboard") + focus_keyboard = True + if near_simul: current_key = lv.KEY.ENTER elif single_press_wait: current_key = None elif btn_a_pressed: - current_key = lv.KEY.PREV + if focus_keyboard: + current_key = lv.KEY.LEFT + else: + current_key = lv.KEY.PREV elif btn_b_pressed: - current_key = lv.KEY.NEXT + if focus_keyboard: + current_key = lv.KEY.RIGHT + else: + current_key = lv.KEY.NEXT else: current_key = None diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index eee2a6a2..2a480e74 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -184,9 +184,11 @@ def custom_exception_handler(e): sys.print_exception(e) # NOQA # No need to deinit() and re-init LVGL: #mpos.ui.task_handler.deinit() # default task handler does this, but then things hang - # otherwise it does focus_next and then crashes while doing lv.deinit() - #focusgroup.remove_all_objs() - #focusgroup.delete() + #focusgroup = lv.group_get_default() + #if focusgroup: # on esp32 this may not be set + # otherwise it does focus_next and then crashes while doing lv.deinit() + #focusgroup.remove_all_objs() + #focusgroup.delete() #lv.deinit() import sys From 2f1ee282c361200463b3065a9b27a97f6c32f674 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 08:28:04 +0100 Subject: [PATCH 093/317] Comments --- .../lib/mpos/board/fri3d_2026.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py index 9035345d..90950c1d 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2026.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -187,12 +187,6 @@ def keypad_read_cb(indev, data): indev.enable(True) # NOQA InputManager.register_indev(indev) -# Battery voltage ADC measuring: sits on PC0 of CH32X035GxUx -from mpos import BatteryManager -def adc_to_voltage(adc_value): - return (0.001651* adc_value + 0.08709) -#BatteryManager.init_adc(13, adc_to_voltage) # TODO - import mpos.sdcard mpos.sdcard.init(spi_bus=spi_bus, cs_pin=14) @@ -207,12 +201,24 @@ def adc_to_voltage(adc_value): # The DAC uses BCK (bit clock) on GPIO 2, while the microphone uses SCLK on GPIO 17 # See schematics: DAC has BCK=2, WS=47, SD=16; Microphone has SCLK=17, WS=47, DIN=15 +# recording and playback at the same time: +# - no issue for the headset +# - communicator: must be same sample rate because shared sck 17 BUT this will result in feedback, probably +# fix: playback headset speaker, record communicator microphone: should work +# fix: playback communicator speaker, record headset microphone: should work +# TODO: +# - revamp to multiple audio framework so all 4 items can be defined: 2 speakers (hss, cs) and 2 microphones (hsm, cm) +# - try each 4 of the items separately: hss, hsm, cs, cm +# - try trivial combinations: hss + hsm, cs + cm +# - try similar combinations: hss + cs, cm + hsm +# - try cross combinations: hss + cm, cs + hsm + i2s_pins = { - # Output (DAC/speaker) pins - 'mck': 2, # MCLK (mandatory) - 'sck': 17, # SCLK aka BCLK (unclear if optional or mandatory) 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) + # Output (DAC/speaker) pins 'sd': 16, # Serial Data OUT (speaker/DAC) + 'sck': 17, # SCLK aka BCLK (appears mandatory) BUT this pin is sck_in on the communicator + 'mck': 2, # MCLK (mandatory) BUT this pin is sck on the communicator } # Initialize AudioManager with I2S (buzzer TODO) From daa71250527dbf7898bc9925e60d05900e7d5da5 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 16:39:26 +0100 Subject: [PATCH 094/317] Rework AudioManager --- .../assets/music_player.py | 28 +- .../assets/sound_recorder.py | 69 +- .../lib/mpos/audio/__init__.py | 5 +- .../lib/mpos/audio/audiomanager.py | 1074 ++++++++++------- .../lib/mpos/audio/stream_record.py | 9 +- .../lib/mpos/audio/stream_record_adc.py | 5 + .../lib/mpos/audio/stream_wav.py | 89 +- .../lib/mpos/board/fri3d_2024.py | 45 +- .../lib/mpos/board/fri3d_2026.py | 24 +- internal_filesystem/lib/mpos/board/linux.py | 13 +- .../lib/mpos/board/m5stack_fire.py | 12 +- .../lib/mpos/board/odroid_go.py | 35 +- scripts/install.sh | 3 + tests/test_audiomanager.py | 160 +-- 14 files changed, 931 insertions(+), 640 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 a5a979b4..402b3b2b 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -103,19 +103,31 @@ def onResume(self, screen): AudioManager.stop() time.sleep(0.1) - success = AudioManager.play_wav( - self._filename, - stream_type=AudioManager.STREAM_MUSIC, - on_complete=self.player_finished - ) - - if not success: - error_msg = "Error: Audio device unavailable or busy" + output = AudioManager.get_default_output() + if output is None: + error_msg = "Error: No audio output available" print(error_msg) self.update_ui_threadsafe_if_foreground( self._filename_label.set_text, error_msg ) + return + + try: + player = AudioManager.player( + file_path=self._filename, + stream_type=AudioManager.STREAM_MUSIC, + on_complete=self.player_finished, + output=output, + ) + player.start() + except Exception as exc: + error_msg = "Error: Audio device unavailable or busy" + print(f"{error_msg}: {exc}") + self.update_ui_threadsafe_if_foreground( + self._filename_label.set_text, + error_msg + ) def focus_obj(self, obj): obj.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) 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 12aebc41..7981bf84 100644 --- a/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/assets/sound_recorder.py @@ -56,6 +56,8 @@ class SoundRecorder(Activity): _last_recording = None _timer_task = None _record_start_time = 0 + _recorder = None + _player = None def onCreate(self): screen = lv.obj() @@ -136,7 +138,8 @@ def onPause(self, screen): def _update_status(self): """Update status label based on microphone availability.""" - if AudioManager.has_microphone(): + default_input = AudioManager.get_default_input() + if default_input is not None: self._status_label.set_text("Microphone ready") self._status_label.set_style_text_color(lv.color_hex(0x00AA00), lv.PART.MAIN) self._record_button.remove_flag(lv.obj.FLAG.HIDDEN) @@ -243,9 +246,10 @@ def _on_record_clicked(self, event): def _start_recording(self): """Start recording audio.""" print("SoundRecorder: _start_recording called") - print(f"SoundRecorder: has_microphone() = {AudioManager.has_microphone()}") + default_input = AudioManager.get_default_input() + print(f"SoundRecorder: default input = {default_input}") - if not AudioManager.has_microphone(): + if default_input is None: print("SoundRecorder: No microphone available - aborting") return @@ -263,25 +267,32 @@ def _start_recording(self): return # Start recording - print(f"SoundRecorder: Calling AudioManager.record_wav()") + print(f"SoundRecorder: Calling AudioManager.recorder()") print(f" file_path: {file_path}") print(f" duration_ms: {self._current_max_duration_ms}") print(f" sample_rate: {self.SAMPLE_RATE}") - success = AudioManager.record_wav( - file_path=file_path, - duration_ms=self._current_max_duration_ms, - on_complete=self._on_recording_complete, - sample_rate=self.SAMPLE_RATE - ) + try: + self._recorder = AudioManager.recorder( + file_path=file_path, + duration_ms=self._current_max_duration_ms, + on_complete=self._on_recording_complete, + sample_rate=self.SAMPLE_RATE, + input=default_input, + ) + self._recorder.start() + success = True + except Exception as exc: + print(f"SoundRecorder: recorder start failed: {exc}") + success = False - print(f"SoundRecorder: record_wav returned: {success}") + print(f"SoundRecorder: recorder started: {success}") if success: self._is_recording = True self._record_start_time = time.ticks_ms() self._last_recording = file_path - print(f"SoundRecorder: Recording started successfully") + print("SoundRecorder: Recording started successfully") # Update UI self._record_button_label.set_text(lv.SYMBOL.STOP + " Stop") @@ -296,13 +307,15 @@ def _start_recording(self): # Start timer update self._start_timer_update() else: - print("SoundRecorder: record_wav failed!") + print("SoundRecorder: recorder failed!") self._status_label.set_text("Failed to start recording") self._status_label.set_style_text_color(lv.color_hex(0xAA0000), lv.PART.MAIN) def _stop_recording(self): """Stop recording audio.""" - AudioManager.stop() + if self._recorder: + self._recorder.stop() + self._recorder = None self._is_recording = False # Show "Saving..." status immediately (file finalization takes time on SD card) @@ -364,16 +377,30 @@ def _on_play_clicked(self, event): """Handle play button click.""" if self._last_recording and not self._is_recording: # Stop any current playback - AudioManager.stop() + if self._player: + self._player.stop() time.sleep_ms(100) + output = AudioManager.get_default_output() + if output is None: + self._status_label.set_text("Playback failed") + self._status_label.set_style_text_color(lv.color_hex(0xAA0000), lv.PART.MAIN) + return + # Play the recording - success = AudioManager.play_wav( - self._last_recording, - stream_type=AudioManager.STREAM_MUSIC, - on_complete=self._on_playback_complete, - volume=100 - ) + try: + self._player = AudioManager.player( + file_path=self._last_recording, + stream_type=AudioManager.STREAM_MUSIC, + on_complete=self._on_playback_complete, + volume=100, + output=output, + ) + self._player.start() + success = True + except Exception as exc: + print(f"SoundRecorder: playback failed: {exc}") + success = False if success: self._status_label.set_text("Playing...") diff --git a/internal_filesystem/lib/mpos/audio/__init__.py b/internal_filesystem/lib/mpos/audio/__init__.py index d009cb77..f3b85cc8 100644 --- a/internal_filesystem/lib/mpos/audio/__init__.py +++ b/internal_filesystem/lib/mpos/audio/__init__.py @@ -1,5 +1,4 @@ # AudioManager - Centralized Audio Management Service for MicroPythonOS -# Android-inspired audio routing with priority-based audio focus -# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer, record_wav() -> I2S mic +# Registry-based audio routing with device descriptors and session control -from .audiomanager import AudioManager +from .audiomanager import AudioManager, Player, Recorder, StereoNotSupported diff --git a/internal_filesystem/lib/mpos/audio/audiomanager.py b/internal_filesystem/lib/mpos/audio/audiomanager.py index f8823328..7a8f631b 100644 --- a/internal_filesystem/lib/mpos/audio/audiomanager.py +++ b/internal_filesystem/lib/mpos/audio/audiomanager.py @@ -1,519 +1,657 @@ # AudioManager - Core Audio Management Service -# Centralized audio routing with priority-based audio focus (Android-inspired) -# Supports I2S (digital audio) and PWM buzzer (tones/ringtones) -# -# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer, record_wav() -> I2S mic -# Uses _thread for non-blocking background playback/recording (separate thread from UI) +# Registry-based audio routing with device descriptors and session control import _thread + from ..task_manager import TaskManager +class StereoNotSupported(Exception): + pass + + class AudioManager: """ - Centralized audio management service with priority-based audio focus. - Implements singleton pattern for single audio service instance. - + Centralized audio management service with device registry and session control. + Usage: from mpos import AudioManager - - # Direct class method calls (no .get() needed) - AudioManager.init(i2s_pins=pins, buzzer_instance=buzzer) - AudioManager.play_wav("music.wav", stream_type=AudioManager.STREAM_MUSIC) - AudioManager.set_volume(80) - volume = AudioManager.get_volume() - AudioManager.stop() + + AudioManager.add(AudioManager.Output(...)) + AudioManager.add(AudioManager.Input(...)) + + player = AudioManager.player(file_path="music.wav") + player.start() """ - - # 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) - - _instance = None # Singleton instance - def __init__( - self, - i2s_pins=None, - buzzer_instance=None, - adc_mic_pin=None, - pre_playback=None, - post_playback=None, - ): - """ - Initialize AudioManager instance with optional hardware configuration. - - Args: - i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S/WAV playback) - buzzer_instance: PWM instance for buzzer (for RTTTL playback) - adc_mic_pin: GPIO pin number for ADC microphone (for ADC recording) - pre_playback: Optional callback called before starting playback - post_playback: Optional callback called after stopping playback - """ - if AudioManager._instance: - # If instance exists, update configuration if provided - if i2s_pins: - AudioManager._instance._i2s_pins = i2s_pins - if buzzer_instance: - AudioManager._instance._buzzer_instance = buzzer_instance - if adc_mic_pin: - AudioManager._instance._adc_mic_pin = adc_mic_pin + STREAM_MUSIC = 0 + STREAM_NOTIFICATION = 1 + STREAM_ALARM = 2 + + _instance = None + + class Output: + def __init__( + self, + name, + kind, + channels=1, + i2s_pins=None, + buzzer_pin=None, + preferred_sample_rate=None, + ): + if kind not in ("i2s", "buzzer"): + raise ValueError("Output.kind must be 'i2s' or 'buzzer'") + if channels not in (1, 2): + raise ValueError("Output.channels must be 1 or 2") + + self.name = name + self.kind = kind + self.channels = channels + self.preferred_sample_rate = preferred_sample_rate + + if kind == "i2s": + if not i2s_pins: + raise ValueError("Output.i2s_pins required for i2s output") + self._validate_i2s_pins(i2s_pins) + self.i2s_pins = dict(i2s_pins) + self.buzzer_pin = None + else: + if buzzer_pin is None: + raise ValueError("Output.buzzer_pin required for buzzer output") + self.buzzer_pin = buzzer_pin + self.i2s_pins = None + + @staticmethod + def _validate_i2s_pins(i2s_pins): + allowed = {"sck", "ws", "sd", "mck"} + for key in i2s_pins: + if key not in allowed: + raise ValueError("Invalid i2s_pins key for output: %s" % key) + for key in ("ws", "sd"): + if key not in i2s_pins: + raise ValueError("i2s_pins must include '%s'" % key) + + def __repr__(self): + return "" % (self.name, self.kind) + + class Input: + def __init__( + self, + name, + kind, + channels=1, + i2s_pins=None, + adc_mic_pin=None, + preferred_sample_rate=None, + ): + if kind not in ("i2s", "adc"): + raise ValueError("Input.kind must be 'i2s' or 'adc'") + if channels != 1: + raise StereoNotSupported("Input channels=2 not supported yet") + + self.name = name + self.kind = kind + self.channels = channels + self.preferred_sample_rate = preferred_sample_rate + + if kind == "i2s": + if not i2s_pins: + raise ValueError("Input.i2s_pins required for i2s input") + self._validate_i2s_pins(i2s_pins) + self.i2s_pins = dict(i2s_pins) + self.adc_mic_pin = None + else: + if adc_mic_pin is None: + raise ValueError("Input.adc_mic_pin required for adc input") + self.adc_mic_pin = adc_mic_pin + self.i2s_pins = None + + @staticmethod + def _validate_i2s_pins(i2s_pins): + allowed = {"sck_in", "sck", "ws", "sd_in"} + for key in i2s_pins: + if key not in allowed: + raise ValueError("Invalid i2s_pins key for input: %s" % key) + for key in ("ws", "sd_in"): + if key not in i2s_pins: + raise ValueError("i2s_pins must include '%s'" % key) + + def __repr__(self): + return "" % (self.name, self.kind) + + def __init__(self): + if getattr(self, "_initialized", False): return - - AudioManager._instance = self - self._i2s_pins = i2s_pins # I2S pin configuration dict (created per-stream) - self._buzzer_instance = buzzer_instance # PWM buzzer instance - self._adc_mic_pin = adc_mic_pin # ADC microphone pin - self.pre_playback = pre_playback - self.post_playback = post_playback - - self._current_stream = None # Currently playing stream - self._current_recording = None # Currently recording stream - self._volume = 50 # System volume (0-100) - - # Build status message - capabilities = [] - if i2s_pins: - capabilities.append("I2S (WAV)") - if buzzer_instance: - capabilities.append("Buzzer (RTTTL)") - if adc_mic_pin: - capabilities.append(f"ADC Mic (Pin {adc_mic_pin})") - - if capabilities: - print(f"AudioManager initialized: {', '.join(capabilities)}") - else: - print("AudioManager initialized: No audio hardware") + AudioManager._instance = self + self._outputs = [] + self._inputs = [] + self._default_output = None + self._default_input = None + self._active_sessions = [] + self._volume = 50 + self._initialized = True + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance @classmethod def get(cls): - """Get or create the singleton instance.""" if cls._instance is None: cls._instance = cls() return cls._instance - def has_i2s(self): - """Check if I2S audio is available for WAV playback.""" - return self._i2s_pins is not None - - def has_buzzer(self): - """Check if buzzer is available for RTTTL playback.""" - return self._buzzer_instance is not None - - def has_microphone(self): - """Check if microphone (I2S or ADC) is available for recording.""" - has_i2s_mic = self._i2s_pins is not None and 'sd_in' in self._i2s_pins - has_adc_mic = self._adc_mic_pin is not None - return has_i2s_mic or has_adc_mic - - def _check_audio_focus(self, 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 - """ - if not self._current_stream: - return True # No stream playing, OK to start - - if not self._current_stream.is_playing(): - return True # Current stream finished, OK to start - - # Check priority - if stream_type <= self._current_stream.stream_type: - print(f"AudioManager: Stream rejected (priority {stream_type} <= current {self._current_stream.stream_type})") - return False - - # Higher priority stream - interrupt current - print(f"AudioManager: Interrupting stream (priority {stream_type} > current {self._current_stream.stream_type})") - self._current_stream.stop() - return True + @classmethod + def add(cls, device): + return cls.get()._add_device(device) + + def _add_device(self, device): + if isinstance(device, AudioManager.Output): + self._outputs.append(device) + if self._default_output is None: + self._default_output = device + return device + if isinstance(device, AudioManager.Input): + self._inputs.append(device) + if self._default_input is None: + self._default_input = device + return device + raise ValueError("Unsupported device type") - def _playback_thread(self, stream): - """ - Thread function for audio playback. - Runs in a separate thread to avoid blocking the UI. + @classmethod + def get_outputs(cls): + return list(cls.get()._outputs) - Args: - stream: Stream instance (WAVStream or RTTTLStream) - """ - self._current_stream = stream - if self.pre_playback: - try: - self.pre_playback() - except Exception as e: - print(f"AudioManager: pre_playback callback error: {e}") + @classmethod + def get_inputs(cls): + return list(cls.get()._inputs) - try: - # Run synchronous playback in this thread - stream.play() - except Exception as e: - print(f"AudioManager: Playback error: {e}") - finally: - # Clear current stream - if self._current_stream == stream: - self._current_stream = None + @classmethod + def get_default_output(cls): + return cls.get()._default_output - if self.post_playback: - try: - self.post_playback() - except Exception as e: - print(f"AudioManager: post_playback callback error: {e}") - - def play_wav(self, file_path, stream_type=None, 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 stream_type is None: - stream_type = self.STREAM_MUSIC - - if not self._i2s_pins: - print("AudioManager: play_wav() failed - I2S not configured") - return False - - # Check audio focus - if not self._check_audio_focus(stream_type): - return False - - # Create stream and start playback in separate thread - try: - from mpos.audio.stream_wav import WAVStream - - stream = WAVStream( - file_path=file_path, - stream_type=stream_type, - volume=volume if volume is not None else self._volume, - i2s_pins=self._i2s_pins, - on_complete=on_complete - ) + @classmethod + def get_default_input(cls): + return cls.get()._default_input - _thread.stack_size(TaskManager.good_stack_size()) - _thread.start_new_thread(self._playback_thread, (stream,)) - return True + @classmethod + def set_default_output(cls, output): + cls.get()._default_output = output - except Exception as e: - print(f"AudioManager: play_wav() failed: {e}") - return False - - def play_rtttl(self, rtttl_string, stream_type=None, 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 stream_type is None: - stream_type = self.STREAM_NOTIFICATION - - if not self._buzzer_instance: - print("AudioManager: play_rtttl() failed - buzzer not configured") - return False - - # Check audio focus - if not self._check_audio_focus(stream_type): - return False - - # Create stream and start playback in separate thread - try: - from mpos.audio.stream_rtttl import RTTTLStream - - stream = RTTTLStream( - rtttl_string=rtttl_string, - stream_type=stream_type, - volume=volume if volume is not None else self._volume, - buzzer_instance=self._buzzer_instance, - on_complete=on_complete - ) + @classmethod + def set_default_input(cls, input_device): + cls.get()._default_input = input_device - _thread.stack_size(TaskManager.good_stack_size()) - _thread.start_new_thread(self._playback_thread, (stream,)) - return True + @classmethod + def set_volume(cls, volume): + cls.get()._volume = max(0, min(100, volume)) - except Exception as e: - print(f"AudioManager: play_rtttl() failed: {e}") - return False + @classmethod + def get_volume(cls): + return cls.get()._volume - def _recording_thread(self, stream): - """ - Thread function for audio recording. - Runs in a separate thread to avoid blocking the UI. + @classmethod + def player( + cls, + file_path=None, + rtttl=None, + stream_type=None, + on_complete=None, + output=None, + sample_rate=None, + volume=None, + ): + return Player( + manager=cls.get(), + file_path=file_path, + rtttl=rtttl, + stream_type=stream_type, + on_complete=on_complete, + output=output, + sample_rate=sample_rate, + volume=volume, + ) - Args: - stream: RecordStream instance - """ - self._current_recording = stream + @classmethod + def rtttl_player(cls, rtttl, **kwargs): + return cls.player(rtttl=rtttl, **kwargs) - try: - # Run synchronous recording in this thread - stream.record() - except Exception as e: - print(f"AudioManager: Recording error: {e}") - finally: - # Clear current recording - if self._current_recording == stream: - self._current_recording = None - - def record_wav(self, 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"AudioManager.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: {self._i2s_pins}") - print(f" has_microphone(): {self.has_microphone()}") - - if not self.has_microphone(): - print("AudioManager: record_wav() failed - microphone not configured") - return False - - # Cannot record while playing (I2S can only be TX or RX, not both) - if self.is_playing(): - print("AudioManager: Cannot record while playing") - return False - - # Cannot start new recording while already recording - if self.is_recording(): - print("AudioManager: Already recording") - return False - - # Create stream and start recording in separate thread - try: - print("AudioManager: Importing RecordStream...") - from mpos.audio.stream_record import RecordStream - - print("AudioManager: Creating RecordStream instance...") - stream = RecordStream( - file_path=file_path, - duration_ms=duration_ms, - sample_rate=sample_rate, - i2s_pins=self._i2s_pins, - on_complete=on_complete - ) + @classmethod + def recorder( + cls, + file_path, + input=None, + sample_rate=None, + on_complete=None, + duration_ms=None, + **adc_config + ): + return Recorder( + manager=cls.get(), + file_path=file_path, + input_device=input, + sample_rate=sample_rate, + on_complete=on_complete, + duration_ms=duration_ms, + adc_config=adc_config, + ) - print("AudioManager: Starting recording thread...") - _thread.stack_size(TaskManager.good_stack_size()) - _thread.start_new_thread(self._recording_thread, (stream,)) - print("AudioManager: Recording thread started successfully") - return True + @classmethod + def record_wav_adc( + cls, + file_path, + duration_ms=None, + sample_rate=None, + adc_pin=None, + on_complete=None, + **adc_config + ): + manager = cls.get() + from mpos.audio.stream_record_adc import ADCRecordStream + + stream = ADCRecordStream( + file_path=file_path, + duration_ms=duration_ms, + sample_rate=sample_rate, + adc_pin=adc_pin, + on_complete=on_complete, + **adc_config, + ) + session = _ADCRecorderSession(manager, stream) + manager._resolve_conflicts(session) + manager._register_session(session) + + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(session._record_thread, ()) + return True - except Exception as e: - import sys - print(f"AudioManager: record_wav() failed: {e}") - sys.print_exception(e) - return False - - def record_wav_adc(self, file_path, duration_ms=None, adc_pin=None, sample_rate=16000, - on_complete=None, **adc_config): - """ - Record audio from ADC using optimized C module 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) - adc_pin: GPIO pin for ADC input (default: configured pin or 1) - sample_rate: Target sample rate in Hz (default 16000 for voice) - on_complete: Callback function(message) when recording finishes - **adc_config: Additional ADC configuration - - Returns: - bool: True if recording started, False if rejected or unavailable - """ - # Use configured pin if not specified - if adc_pin is None: - adc_pin = self._adc_mic_pin - - # Fallback to default if still None - if adc_pin is None: - adc_pin = 1 # Default to GPIO1 (Fri3d 2026) - - print(f"AudioManager.record_wav_adc() called") - print(f" file_path: {file_path}") - print(f" duration_ms: {duration_ms}") - print(f" adc_pin: {adc_pin}") - print(f" sample_rate: {sample_rate}") - - # Cannot record while playing (I2S can only be TX or RX, not both) - if self.is_playing(): - print("AudioManager: Cannot record while playing") - return False - - # Cannot start new recording while already recording - if self.is_recording(): - print("AudioManager: Already recording") - return False - - # Create stream and start recording in separate thread - try: - print("AudioManager: Importing ADCRecordStream...") - from mpos.audio.stream_record_adc import ADCRecordStream - - print("AudioManager: Creating ADCRecordStream instance...") - stream = ADCRecordStream( - file_path=file_path, - duration_ms=duration_ms, - sample_rate=sample_rate, - adc_pin=adc_pin, - on_complete=on_complete, - **adc_config + @classmethod + def stop(cls): + return cls.get()._stop_all() + + def _stop_all(self): + for session in list(self._active_sessions): + session.stop() + self._active_sessions = [] + + def _register_session(self, session): + self._active_sessions.append(session) + + def _session_finished(self, session): + if session in self._active_sessions: + self._active_sessions.remove(session) + + def _cleanup_inactive(self): + active = [] + for session in self._active_sessions: + if session.is_active(): + active.append(session) + self._active_sessions = active + + def _resolve_conflicts(self, new_session): + self._cleanup_inactive() + to_stop = [] + for session in self._active_sessions: + if self._sessions_conflict(session, new_session): + to_stop.append(session) + for session in to_stop: + session.stop() + if session in self._active_sessions: + self._active_sessions.remove(session) + + @staticmethod + def _pins_compatible(existing_signal, new_signal): + if existing_signal == new_signal and existing_signal in ("ws", "sck"): + return True + return False + + def _sessions_conflict(self, existing, new_session): + existing_pins = existing.pin_usage() + new_pins = new_session.pin_usage() + shared_clock = False + + for pin, new_signal in new_pins.items(): + if pin in existing_pins: + existing_signal = existing_pins[pin] + if self._pins_compatible(existing_signal, new_signal): + shared_clock = True + continue + return True + + if shared_clock: + if existing.sample_rate is None or new_session.sample_rate is None: + return True + if existing.sample_rate != new_session.sample_rate: + return True + + return False + + def _start_player(self, player): + if player.output is None: + player.output = self._default_output + if player.output is None: + raise ValueError("No output device registered") + + if player.stream_type is None: + player.stream_type = ( + self.STREAM_NOTIFICATION if player.rtttl else self.STREAM_MUSIC ) - print("AudioManager: Starting ADC recording thread...") - _thread.stack_size(TaskManager.good_stack_size()) - _thread.start_new_thread(self._recording_thread, (stream,)) - print("AudioManager: ADC recording thread started successfully") - return True + if player.output.kind == "buzzer" and not player.rtttl: + raise ValueError("RTTTL string required for buzzer output") + if player.output.kind == "i2s" and not player.file_path: + raise ValueError("file_path required for i2s output") + + player.sample_rate = self._determine_player_rate(player) + + self._resolve_conflicts(player) + self._register_session(player) + + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(player._play_thread, ()) + + def _start_recorder(self, recorder): + if recorder.input_device is None: + recorder.input_device = self._default_input + if recorder.input_device is None: + raise ValueError("No input device registered") + + recorder.sample_rate = self._determine_recorder_rate(recorder) + + self._resolve_conflicts(recorder) + self._register_session(recorder) + + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(recorder._record_thread, ()) + + def _determine_player_rate(self, player): + if player.output.kind != "i2s": + return None + + preferred = player.sample_rate or player.output.preferred_sample_rate - except Exception as e: - import sys - print(f"AudioManager: record_wav_adc() failed: {e}") - sys.print_exception(e) - return False + from mpos.audio.stream_wav import WAVStream + + info = WAVStream.get_wav_info(player.file_path) + original_rate = info["sample_rate"] + playback_rate, _ = WAVStream.compute_playback_rate(original_rate, preferred) + return playback_rate + + def _determine_recorder_rate(self, recorder): + if recorder.sample_rate: + return recorder.sample_rate + if recorder.input_device and recorder.input_device.preferred_sample_rate: + return recorder.input_device.preferred_sample_rate + return 16000 + + +class _ADCRecorderSession: + def __init__(self, manager, stream): + self._manager = manager + self._stream = stream + self.sample_rate = stream.sample_rate + + def start(self): + self._manager._resolve_conflicts(self) + self._manager._register_session(self) + + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(self._record_thread, ()) def stop(self): - """Stop current audio playback or recording.""" - stopped = False + if self._stream: + self._stream.stop() + self._manager._session_finished(self) + + def is_active(self): + return self.is_recording() + + def is_recording(self): + return self._stream is not None and self._stream.is_recording() + + def pin_usage(self): + adc_pin = getattr(self._stream, "adc_pin", None) + if adc_pin is None: + return {} + return {adc_pin: "adc"} + + def _record_thread(self): + try: + self._stream.record() + finally: + self._manager._session_finished(self) - if self._current_stream: - self._current_stream.stop() - print("AudioManager: Playback stopped") - stopped = True - if self._current_recording: - self._current_recording.stop() - print("AudioManager: Recording stopped") - stopped = True +class Player: + def __init__( + self, + manager, + file_path=None, + rtttl=None, + stream_type=None, + on_complete=None, + output=None, + sample_rate=None, + volume=None, + ): + self._manager = manager + self.file_path = file_path + self.rtttl = rtttl + self.stream_type = stream_type + self.on_complete = on_complete + self.output = output + self.sample_rate = sample_rate + self.volume = volume + self._stream = None + self._buzzer = None + + def start(self): + self._manager._start_player(self) - if not stopped: - print("AudioManager: No playback or recording to stop") + def stop(self): + if self._stream: + self._stream.stop() + if self._buzzer: + try: + self._buzzer.deinit() + except Exception: + pass + self._manager._session_finished(self) def pause(self): - """ - Pause current audio playback (if supported by stream). - Note: Most streams don't support pause, only stop. - """ - if self._current_stream and hasattr(self._current_stream, 'pause'): - self._current_stream.pause() - print("AudioManager: Playback paused") - else: - print("AudioManager: Pause not supported or no playback active") + if self._stream and hasattr(self._stream, "pause"): + self._stream.pause() def resume(self): - """ - Resume paused audio playback (if supported by stream). - Note: Most streams don't support resume, only play. - """ - if self._current_stream and hasattr(self._current_stream, 'resume'): - self._current_stream.resume() - print("AudioManager: Playback resumed") - else: - print("AudioManager: Resume not supported or no playback active") - - def set_volume(self, volume): - """ - Set system volume (affects new streams, not current playback). - - Args: - volume: Volume level (0-100) - """ - self._volume = max(0, min(100, volume)) - if self._current_stream: - self._current_stream.set_volume(self._volume) - - def get_volume(self): - """ - Get system volume. - - Returns: - int: Current system volume (0-100) - """ - return self._volume + if self._stream and hasattr(self._stream, "resume"): + self._stream.resume() + + def is_active(self): + return self.is_playing() def is_playing(self): - """ - Check if audio is currently playing. + return self._stream is not None and self._stream.is_playing() + + def get_progress_percent(self): + if self._stream and hasattr(self._stream, "get_progress_percent"): + return self._stream.get_progress_percent() + return None + + def get_progress_ms(self): + if self._stream and hasattr(self._stream, "get_progress_ms"): + return self._stream.get_progress_ms() + return None + + def get_duration_ms(self): + if self._stream and hasattr(self._stream, "get_duration_ms"): + return self._stream.get_duration_ms() + return None + + def pin_usage(self): + if not self.output: + return {} + if self.output.kind == "buzzer": + return {self.output.buzzer_pin: "buzzer"} + if self.output.kind == "i2s": + return _pin_map_i2s_output(self.output.i2s_pins) + return {} + + def _play_thread(self): + try: + if self.output.kind == "buzzer": + self._play_rtttl() + else: + self._play_wav() + finally: + if self._buzzer: + try: + self._buzzer.deinit() + except Exception: + pass + self._manager._session_finished(self) + + def _play_rtttl(self): + from mpos.audio.stream_rtttl import RTTTLStream + from machine import Pin, PWM + + self._buzzer = PWM(Pin(self.output.buzzer_pin, Pin.OUT)) + self._buzzer.duty_u16(0) + + self._stream = RTTTLStream( + rtttl_string=self.rtttl, + stream_type=self.stream_type, + volume=self.volume if self.volume is not None else self._manager._volume, + buzzer_instance=self._buzzer, + on_complete=self.on_complete, + ) + self._stream.play() + + def _play_wav(self): + from mpos.audio.stream_wav import WAVStream + + self._stream = WAVStream( + file_path=self.file_path, + stream_type=self.stream_type, + volume=self.volume if self.volume is not None else self._manager._volume, + i2s_pins=self.output.i2s_pins, + on_complete=self.on_complete, + requested_sample_rate=self.sample_rate, + ) + self._stream.play() + + +class Recorder: + def __init__( + self, + manager, + file_path, + input_device=None, + sample_rate=None, + on_complete=None, + duration_ms=None, + adc_config=None, + ): + self._manager = manager + self.file_path = file_path + self.input_device = input_device + self.sample_rate = sample_rate + self.on_complete = on_complete + self.duration_ms = duration_ms + self.adc_config = adc_config or {} + self._stream = None + + def start(self): + self._manager._start_recorder(self) - Returns: - bool: True if playback active, False otherwise - """ - return self._current_stream is not None and self._current_stream.is_playing() + def stop(self): + if self._stream: + self._stream.stop() + self._manager._session_finished(self) - def is_recording(self): - """ - Check if audio is currently being recorded. - - Returns: - bool: True if recording active, False otherwise - """ - return self._current_recording is not None and self._current_recording.is_recording() - -# ============================================================================ -# Class method forwarding to singleton instance -# -# Instead of writing each function like this: -# @classmethod -# def has_microphone(cls): -# instance = cls.get() -# return instance._i2s_pins is not None and 'sd_in' in instance._i2s_pins -# -# They can be written like this: -# def has_microphone(self): -# return self._i2s_pins is not None and 'sd_in' in self._i2s_pins -# -# ============================================================================ -# Store original instance methods before replacing them -_original_methods = {} -_methods_to_delegate = [ - 'play_wav', 'play_rtttl', 'record_wav', 'record_wav_adc', 'stop', 'pause', 'resume', - 'set_volume', 'get_volume', 'is_playing', 'is_recording', - 'has_i2s', 'has_buzzer', 'has_microphone' -] - -for method_name in _methods_to_delegate: - _original_methods[method_name] = getattr(AudioManager, method_name) - -# Helper to create delegating class methods -def _make_class_method(method_name): - """Create a class method that delegates to the singleton instance.""" - original_method = _original_methods[method_name] + def pause(self): + if self._stream and hasattr(self._stream, "pause"): + self._stream.pause() - @classmethod - def class_method(cls, *args, **kwargs): - instance = cls.get() - return original_method(instance, *args, **kwargs) - - return class_method - -# Attach class methods to AudioManager -for method_name in _methods_to_delegate: - setattr(AudioManager, method_name, _make_class_method(method_name)) + def resume(self): + if self._stream and hasattr(self._stream, "resume"): + self._stream.resume() + + def is_active(self): + return self.is_recording() + + def is_recording(self): + return self._stream is not None and self._stream.is_recording() + + def get_duration_ms(self): + if self._stream and hasattr(self._stream, "get_duration_ms"): + return self._stream.get_duration_ms() + if self._stream and hasattr(self._stream, "get_elapsed_ms"): + return self._stream.get_elapsed_ms() + return None + + def pin_usage(self): + if not self.input_device: + return {} + if self.input_device.kind == "adc": + return {self.input_device.adc_mic_pin: "adc"} + if self.input_device.kind == "i2s": + return _pin_map_i2s_input(self.input_device.i2s_pins) + return {} + + def _record_thread(self): + try: + if self.input_device.kind == "adc": + self._record_adc() + else: + self._record_i2s() + finally: + self._manager._session_finished(self) + + def _record_i2s(self): + from mpos.audio.stream_record import RecordStream + + self._stream = RecordStream( + file_path=self.file_path, + duration_ms=self.duration_ms, + sample_rate=self.sample_rate, + i2s_pins=self.input_device.i2s_pins, + on_complete=self.on_complete, + ) + self._stream.record() + + def _record_adc(self): + from mpos.audio.stream_record_adc import ADCRecordStream + + self._stream = ADCRecordStream( + file_path=self.file_path, + duration_ms=self.duration_ms, + sample_rate=self.sample_rate, + adc_pin=self.input_device.adc_mic_pin, + on_complete=self.on_complete, + **self.adc_config, + ) + self._stream.record() + + +def _pin_map_i2s_output(i2s_pins): + pins = {} + if i2s_pins.get("sck") is not None: + pins[i2s_pins["sck"]] = "sck" + pins[i2s_pins["ws"]] = "ws" + pins[i2s_pins["sd"]] = "sd" + if i2s_pins.get("mck") is not None: + pins[i2s_pins["mck"]] = "mck" + return pins + + +def _pin_map_i2s_input(i2s_pins): + pins = {} + sck_pin = i2s_pins.get("sck_in", i2s_pins.get("sck")) + if sck_pin is not None: + pins[sck_pin] = "sck" + pins[i2s_pins["ws"]] = "ws" + pins[i2s_pins["sd_in"]] = "sd_in" + return pins diff --git a/internal_filesystem/lib/mpos/audio/stream_record.py b/internal_filesystem/lib/mpos/audio/stream_record.py index d12a580e..ff9f9034 100644 --- a/internal_filesystem/lib/mpos/audio/stream_record.py +++ b/internal_filesystem/lib/mpos/audio/stream_record.py @@ -68,6 +68,7 @@ def __init__(self, file_path, duration_ms, sample_rate, i2s_pins, on_complete): self._is_recording = False self._i2s = None self._bytes_recorded = 0 + self._start_time_ms = 0 def is_recording(self): """Check if stream is currently recording.""" @@ -203,6 +204,7 @@ def record(self): self._is_recording = True self._bytes_recorded = 0 + self._start_time_ms = time.ticks_ms() try: # Ensure directory exists @@ -346,4 +348,9 @@ def record(self): if self._i2s: self._i2s.deinit() self._i2s = None - print(f"RecordStream: Recording thread finished") \ No newline at end of file + print(f"RecordStream: Recording thread finished") + + def get_duration_ms(self): + if self._start_time_ms <= 0: + return 0 + return time.ticks_diff(time.ticks_ms(), self._start_time_ms) \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/audio/stream_record_adc.py b/internal_filesystem/lib/mpos/audio/stream_record_adc.py index 1cdaf87d..d876591d 100644 --- a/internal_filesystem/lib/mpos/audio/stream_record_adc.py +++ b/internal_filesystem/lib/mpos/audio/stream_record_adc.py @@ -354,3 +354,8 @@ def record(self): finally: self._is_recording = False print(f"ADCRecordStream: Recording thread finished") + + def get_duration_ms(self): + if self._start_time_ms <= 0: + return 0 + return time.ticks_diff(time.ticks_ms(), self._start_time_ms) diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index e84f254e..2aa2ce53 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -147,7 +147,15 @@ class WAVStream: 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): + def __init__( + self, + file_path, + stream_type, + volume, + i2s_pins, + on_complete, + requested_sample_rate=None, + ): """ Initialize WAV stream. @@ -157,15 +165,25 @@ def __init__(self, file_path, stream_type, volume, i2s_pins, on_complete): volume: Volume level (0-100) i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers on_complete: Callback function(message) when playback finishes + requested_sample_rate: Optional negotiated sample rate for shared clocks """ self.file_path = file_path self.stream_type = stream_type self.volume = volume self.i2s_pins = i2s_pins self.on_complete = on_complete + self.requested_sample_rate = requested_sample_rate self._keep_running = True self._is_playing = False self._i2s = None + self._progress_samples = 0 + self._total_samples = 0 + self._duration_ms = None + self._playback_rate = None + self._original_rate = None + self._channels = None + self._bits_per_sample = None + self._data_size = None def is_playing(self): """Check if stream is currently playing.""" @@ -175,6 +193,19 @@ def stop(self): """Stop playback.""" self._keep_running = False + def get_progress_percent(self): + if self._total_samples <= 0: + return None + return int((self._progress_samples / self._total_samples) * 100) + + def get_progress_ms(self): + if self._playback_rate: + return int((self._progress_samples / self._playback_rate) * 1000) + return None + + def get_duration_ms(self): + return self._duration_ms + # ---------------------------------------------------------------------- # WAV header parser - returns bit-depth and format info # ---------------------------------------------------------------------- @@ -235,6 +266,37 @@ def _find_data_chunk(f): raise ValueError("No 'data' chunk found") + # ---------------------------------------------------------------------- + # WAV info helpers + # ---------------------------------------------------------------------- + @staticmethod + def get_wav_info(file_path): + with open(file_path, 'rb') as f: + data_start, data_size, sample_rate, channels, bits_per_sample = ( + WAVStream._find_data_chunk(f) + ) + return { + "data_start": data_start, + "data_size": data_size, + "sample_rate": sample_rate, + "channels": channels, + "bits_per_sample": bits_per_sample, + } + + @staticmethod + def compute_playback_rate(original_rate, requested_rate=None): + if requested_rate: + if requested_rate <= original_rate: + return original_rate, 1 + upsample_factor = (requested_rate + original_rate - 1) // original_rate + return original_rate * upsample_factor, upsample_factor + + target_rate = 22050 + if original_rate >= target_rate: + return original_rate, 1 + upsample_factor = (target_rate + original_rate - 1) // original_rate + return original_rate * upsample_factor, upsample_factor + # ---------------------------------------------------------------------- # Bit depth conversion functions # ---------------------------------------------------------------------- @@ -327,14 +389,17 @@ def play(self): data_start, data_size, original_rate, channels, bits_per_sample = \ self._find_data_chunk(f) - # Decide playback rate (force >=22050 Hz) - but why?! the DAC should support down to 8kHz! - target_rate = 22050 # slower is faster (less data) - if original_rate >= target_rate: - playback_rate = original_rate - upsample_factor = 1 - else: - upsample_factor = (target_rate + original_rate - 1) // original_rate - playback_rate = original_rate * upsample_factor + self._original_rate = original_rate + self._channels = channels + self._bits_per_sample = bits_per_sample + self._data_size = data_size + + playback_rate, upsample_factor = self.compute_playback_rate( + original_rate, + self.requested_sample_rate, + ) + + self._playback_rate = playback_rate print(f"WAVStream: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch") print(f"WAVStream: Playback at {playback_rate} Hz (factor {upsample_factor})") @@ -342,6 +407,11 @@ def play(self): if data_size > file_size - data_start: data_size = file_size - data_start + bytes_per_sample = (bits_per_sample // 8) * channels + if bytes_per_sample > 0: + self._total_samples = data_size // bytes_per_sample + self._duration_ms = int((self._total_samples / original_rate) * 1000) + # Initialize I2S (always 16-bit output) try: i2s_format = machine.I2S.MONO if channels == 1 else machine.I2S.STEREO @@ -445,6 +515,7 @@ def play(self): time.sleep(num_samples / playback_rate) total_original += to_read + self._progress_samples = total_original // bytes_per_original_sample 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 fb0ed56f..71f9b4ff 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -292,28 +292,47 @@ def adc_to_voltage(adc_value): mpos.sdcard.init(spi_bus=spi_bus, cs_pin=14) # === AUDIO HARDWARE === -from machine import PWM, Pin from mpos import AudioManager -# Initialize buzzer (GPIO 46) -buzzer = PWM(Pin(46), freq=550, duty=0) - # I2S pin configuration for audio output (DAC) and input (microphone) # Note: I2S is created per-stream, not at boot (only one instance can exist) # The DAC uses BCK (bit clock) on GPIO 2, while the microphone uses SCLK on GPIO 17 # See schematics: DAC has BCK=2, WS=47, SD=16; Microphone has SCLK=17, WS=47, DIN=15 -i2s_pins = { +i2s_output_pins = { 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) - # Output (DAC/speaker) config 'sck': 2, # SCLK or BCLK - Bit Clock for DAC output (mandatory) 'sd': 16, # Serial Data OUT (speaker/DAC) - # Input (microphone) config +} + +i2s_input_pins = { + 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) 'sck_in': 17, # SCLK - Serial Clock for microphone input 'sd_in': 15, # DIN - Serial Data IN (microphone) } -# Initialize AudioManager with I2S and buzzer -AudioManager(i2s_pins=i2s_pins, buzzer_instance=buzzer) +speaker_output = AudioManager.add( + AudioManager.Output( + name="speaker", + kind="i2s", + i2s_pins=i2s_output_pins, + ) +) + +buzzer_output = AudioManager.add( + AudioManager.Output( + name="buzzer", + kind="buzzer", + buzzer_pin=46, + ) +) + +mic_input = AudioManager.add( + AudioManager.Input( + name="mic", + kind="i2s", + i2s_pins=i2s_input_pins, + ) +) # === SENSOR HARDWARE === from mpos import SensorManager @@ -344,7 +363,13 @@ def startup_wow_effect(): #startup_jingle = "ShortBeeps:d=32,o=5,b=320:c6,c7" # Start the jingle - AudioManager.play_rtttl(startup_jingle,stream_type=AudioManager.STREAM_NOTIFICATION,volume=60) + player = AudioManager.player( + rtttl=startup_jingle, + stream_type=AudioManager.STREAM_NOTIFICATION, + volume=60, + output=buzzer_output, + ) + player.start() # Rainbow colors for the 5 LEDs rainbow = [ diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py index 90950c1d..e722344a 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2026.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -191,7 +191,6 @@ def keypad_read_cb(indev, data): mpos.sdcard.init(spi_bus=spi_bus, cs_pin=14) # === AUDIO HARDWARE === -from machine import PWM, Pin # Initialize buzzer: now sits on PC14/CC1 of the CH32X035GxUx so needs custom code #buzzer = PWM(Pin(46), freq=550, duty=0) @@ -213,18 +212,31 @@ def keypad_read_cb(indev, data): # - try similar combinations: hss + cs, cm + hsm # - try cross combinations: hss + cm, cs + hsm -i2s_pins = { +i2s_output_pins = { 'ws': 47, # Word Select / LRCLK shared between DAC and mic (mandatory) - # Output (DAC/speaker) pins 'sd': 16, # Serial Data OUT (speaker/DAC) 'sck': 17, # SCLK aka BCLK (appears mandatory) BUT this pin is sck_in on the communicator 'mck': 2, # MCLK (mandatory) BUT this pin is sck on the communicator } -# Initialize AudioManager with I2S (buzzer TODO) -# ADC microphone is on GPIO 1 from mpos import AudioManager -AudioManager(i2s_pins=i2s_pins, adc_mic_pin=1) + +speaker_output = AudioManager.add( + AudioManager.Output( + name="speaker", + kind="i2s", + i2s_pins=i2s_output_pins, + ) +) + +# ADC microphone is on GPIO 1 +mic_input = AudioManager.add( + AudioManager.Input( + name="mic", + kind="adc", + adc_mic_pin=1, + ) +) # === SENSOR HARDWARE === from mpos import SensorManager diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index a1f464b5..43aab645 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -107,15 +107,22 @@ def adc_to_voltage(adc_value): # 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 = { +# The i2s_pins dict with 'sd_in' enables microphone simulation +AudioManager() + +output_i2s_pins = { 'sck': 0, # Simulated - not used on desktop 'ws': 0, # Simulated - not used on desktop 'sd': 0, # Simulated - not used on desktop +} +input_i2s_pins = { 'sck_in': 0, # Simulated - not used on desktop + 'ws': 0, # Simulated - not used on desktop 'sd_in': 0, # Simulated - enables microphone simulation } -AudioManager(i2s_pins=i2s_pins) + +AudioManager.add(AudioManager.Output("speaker", "i2s", i2s_pins=output_i2s_pins)) +AudioManager.add(AudioManager.Input("mic", "i2s", i2s_pins=input_i2s_pins)) # === LED HARDWARE === # Note: Desktop builds have no LED hardware diff --git a/internal_filesystem/lib/mpos/board/m5stack_fire.py b/internal_filesystem/lib/mpos/board/m5stack_fire.py index 9aac9953..1292b6e3 100644 --- a/internal_filesystem/lib/mpos/board/m5stack_fire.py +++ b/internal_filesystem/lib/mpos/board/m5stack_fire.py @@ -48,10 +48,16 @@ print("m5stack_fire.py init buzzer") buzzer = PWM(Pin(BUZZER_PIN, Pin.OUT, value=1), duty=5) -AudioManager(i2s_pins=None, buzzer_instance=buzzer) +AudioManager() +AudioManager.add(AudioManager.Output("buzzer", "buzzer", buzzer_pin=BUZZER_PIN)) AudioManager.set_volume(40) -AudioManager.play_rtttl("Star Trek:o=4,d=20,b=200:8f.,a#,4d#6.,8d6,a#.,g.,c6.,4f6") -while AudioManager.is_playing(): + +player = AudioManager.player( + rtttl="Star Trek:o=4,d=20,b=200:8f.,a#,4d#6.,8d6,a#.,g.,c6.,4f6", + stream_type=AudioManager.STREAM_NOTIFICATION, +) +player.start() +while player.is_playing(): time.sleep(0.1) diff --git a/internal_filesystem/lib/mpos/board/odroid_go.py b/internal_filesystem/lib/mpos/board/odroid_go.py index e9e87dca..4aa1d946 100644 --- a/internal_filesystem/lib/mpos/board/odroid_go.py +++ b/internal_filesystem/lib/mpos/board/odroid_go.py @@ -61,7 +61,6 @@ blue_led.on() print("odroid_go.py init buzzer") -buzzer = PWM(Pin(BUZZER_PIN, Pin.OUT, value=1), duty=5) class BuzzerCallbacks: @@ -80,17 +79,23 @@ def mute(self): buzzer_callbacks = BuzzerCallbacks() -AudioManager( - i2s_pins=None, - buzzer_instance=buzzer, - # The buzzer makes noise when it's unmuted, to avoid this we - # mute it after playback and vice versa unmute it before playback: - pre_playback=buzzer_callbacks.unmute, - post_playback=buzzer_callbacks.mute, + +buzzer_output = AudioManager.add( + AudioManager.Output( + name="buzzer", + kind="buzzer", + buzzer_pin=BUZZER_PIN, + ) ) AudioManager.set_volume(40) -AudioManager.play_rtttl("Star Trek:o=4,d=20,b=200:8f.,a#,4d#6.,8d6,a#.,g.,c6.,4f6") -while AudioManager.is_playing(): +player = AudioManager.player( + rtttl="Star Trek:o=4,d=20,b=200:8f.,a#,4d#6.,8d6,a#.,g.,c6.,4f6", + output=buzzer_output, + on_complete=buzzer_callbacks.mute, +) +buzzer_callbacks.unmute() +player.start() +while player.is_playing(): time.sleep(0.1) print("odroid_go.py machine.SPI.Bus() initialization") @@ -232,12 +237,16 @@ def input_callback(indev, data): elif button_volume.value() == 0: print("Volume button pressed -> reset") blue_led.on() - AudioManager.play_rtttl( - "Outro:o=5,d=32,b=160,b=160:c6,b,a,g,f,e,d,c", + player = AudioManager.player( + rtttl="Outro:o=5,d=32,b=160,b=160:c6,b,a,g,f,e,d,c", stream_type=AudioManager.STREAM_ALARM, volume=40, + output=buzzer_output, + on_complete=buzzer_callbacks.mute, ) - while AudioManager.is_playing(): + buzzer_callbacks.unmute() + player.start() + while player.is_playing(): time.sleep(0.1) machine.reset() elif button_select.value() == 0: diff --git a/scripts/install.sh b/scripts/install.sh index ff204723..4e2bb6ff 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -68,6 +68,9 @@ $mpremote fs mkdir :/data/com.micropythonos.system.wifiservice $mpremote fs cp ../internal_filesystem_excluded/data/com.micropythonos.system.wifiservice/config.json :/data/com.micropythonos.system.wifiservice/ $mpremote fs mkdir :/apps +$mpremote fs cp -r apps/com.micropythonos.musicplayer :/apps/ +$mpremote fs cp -r apps/com.micropythonos.soundrecorder :/apps/ +exit 1 $mpremote fs cp -r apps/com.micropythonos.* :/apps/ find apps/ -maxdepth 1 -type l | while read symlink; do if echo $symlink | grep quasiboats; then diff --git a/tests/test_audiomanager.py b/tests/test_audiomanager.py index 83c2c646..43c664fb 100644 --- a/tests/test_audiomanager.py +++ b/tests/test_audiomanager.py @@ -5,8 +5,6 @@ # Import centralized mocks from mpos.testing import ( MockMachine, - MockPWM, - MockPin, MockThread, inject_mocks, ) @@ -26,17 +24,16 @@ class TestAudioManager(unittest.TestCase): def setUp(self): """Initialize AudioManager before each test.""" - self.buzzer = MockPWM(MockPin(46)) + self.buzzer_pin = 46 self.i2s_pins = {'sck': 2, 'ws': 47, 'sd': 16} # Reset singleton instance for each test AudioManager._instance = None - AudioManager( - i2s_pins=self.i2s_pins, - buzzer_instance=self.buzzer - ) - + AudioManager() + AudioManager.add(AudioManager.Output("speaker", "i2s", i2s_pins=self.i2s_pins)) + AudioManager.add(AudioManager.Output("buzzer", "buzzer", buzzer_pin=self.buzzer_pin)) + # Reset volume to default after creating instance AudioManager.set_volume(70) @@ -47,32 +44,22 @@ def tearDown(self): def test_initialization(self): """Test that AudioManager initializes correctly.""" am = AudioManager.get() - self.assertEqual(am._i2s_pins, self.i2s_pins) - self.assertEqual(am._buzzer_instance, self.buzzer) - - def test_has_i2s(self): - """Test has_i2s() returns correct value.""" - # With I2S configured - AudioManager._instance = None - AudioManager(i2s_pins=self.i2s_pins, buzzer_instance=None) - self.assertTrue(AudioManager.has_i2s()) - - # Without I2S configured - AudioManager._instance = None - AudioManager(i2s_pins=None, buzzer_instance=self.buzzer) - self.assertFalse(AudioManager.has_i2s()) - - def test_has_buzzer(self): - """Test has_buzzer() returns correct value.""" - # With buzzer configured - AudioManager._instance = None - AudioManager(i2s_pins=None, buzzer_instance=self.buzzer) - self.assertTrue(AudioManager.has_buzzer()) - - # Without buzzer configured - AudioManager._instance = None - AudioManager(i2s_pins=self.i2s_pins, buzzer_instance=None) - self.assertFalse(AudioManager.has_buzzer()) + self.assertEqual(len(am._outputs), 2) + self.assertEqual(am._outputs[0].i2s_pins, self.i2s_pins) + self.assertEqual(am._outputs[1].buzzer_pin, self.buzzer_pin) + + def test_get_outputs(self): + """Test that get_outputs() returns configured outputs.""" + outputs = AudioManager.get_outputs() + self.assertEqual(len(outputs), 2) + self.assertEqual(outputs[0].kind, "i2s") + self.assertEqual(outputs[1].kind, "buzzer") + + def test_default_output(self): + """Test default output selection.""" + default_output = AudioManager.get_default_output() + self.assertIsNotNone(default_output) + self.assertEqual(default_output.kind, "i2s") def test_stream_types(self): """Test stream type constants and priority order.""" @@ -101,60 +88,53 @@ def test_no_hardware_rejects_playback(self): """Test that no hardware rejects all playback requests.""" # Re-initialize with no hardware AudioManager._instance = None - AudioManager(i2s_pins=None, buzzer_instance=None) + AudioManager() - # WAV should be rejected (no I2S) - result = AudioManager.play_wav("test.wav") - self.assertFalse(result) + with self.assertRaises(ValueError): + AudioManager.player(file_path="test.wav").start() - # RTTTL should be rejected (no buzzer) - result = AudioManager.play_rtttl("Test:d=4,o=5,b=120:c") - self.assertFalse(result) + with self.assertRaises(ValueError): + AudioManager.player(rtttl="Test:d=4,o=5,b=120:c").start() def test_i2s_only_rejects_rtttl(self): """Test that I2S-only config rejects buzzer playback.""" # Re-initialize with I2S only AudioManager._instance = None - AudioManager(i2s_pins=self.i2s_pins, buzzer_instance=None) + AudioManager() + AudioManager.add(AudioManager.Output("speaker", "i2s", i2s_pins=self.i2s_pins)) - # RTTTL should be rejected (no buzzer) - result = AudioManager.play_rtttl("Test:d=4,o=5,b=120:c") - self.assertFalse(result) + with self.assertRaises(ValueError): + AudioManager.player(rtttl="Test:d=4,o=5,b=120:c").start() def test_buzzer_only_rejects_wav(self): """Test that buzzer-only config rejects I2S playback.""" # Re-initialize with buzzer only AudioManager._instance = None - AudioManager(i2s_pins=None, buzzer_instance=self.buzzer) + AudioManager() + AudioManager.add(AudioManager.Output("buzzer", "buzzer", buzzer_pin=self.buzzer_pin)) - # WAV should be rejected (no I2S) - result = AudioManager.play_wav("test.wav") - self.assertFalse(result) + with self.assertRaises(ValueError): + AudioManager.player(file_path="test.wav").start() def test_is_playing_initially_false(self): """Test that is_playing() returns False initially.""" # Reset to ensure clean state AudioManager._instance = None - AudioManager(i2s_pins=self.i2s_pins, buzzer_instance=self.buzzer) - self.assertFalse(AudioManager.is_playing()) + AudioManager() + AudioManager.add(AudioManager.Output("speaker", "i2s", i2s_pins=self.i2s_pins)) + self.assertFalse(AudioManager.player(file_path="test.wav").is_playing()) def test_stop_with_no_playback(self): """Test that stop() can be called when nothing is playing.""" # Should not raise exception AudioManager.stop() - self.assertFalse(AudioManager.is_playing()) - - def test_audio_focus_check_no_current_stream(self): - """Test audio focus allows playback when no stream is active.""" - am = AudioManager.get() - result = am._check_audio_focus(AudioManager.STREAM_MUSIC) - self.assertTrue(result) def test_volume_default_value(self): """Test that default volume is reasonable.""" - # After init, volume should be at default (70) - AudioManager(i2s_pins=None, buzzer_instance=None) - self.assertEqual(AudioManager.get_volume(), 70) + # After init, volume should be at default (50) + AudioManager._instance = None + AudioManager() + self.assertEqual(AudioManager.get_volume(), 50) class TestAudioManagerRecording(unittest.TestCase): @@ -162,20 +142,15 @@ class TestAudioManagerRecording(unittest.TestCase): def setUp(self): """Initialize AudioManager 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} + self.i2s_pins_with_mic = {'sck': 2, 'ws': 47, 'sd_in': 15} # Reset singleton instance for each test AudioManager._instance = None - AudioManager( - i2s_pins=self.i2s_pins_with_mic, - buzzer_instance=self.buzzer - ) - + AudioManager() + AudioManager.add(AudioManager.Input("mic", "i2s", i2s_pins=self.i2s_pins_with_mic)) + # Reset volume to default after creating instance AudioManager.set_volume(70) @@ -183,43 +158,38 @@ def tearDown(self): """Clean up after each test.""" AudioManager.stop() - def test_has_microphone_with_sd_in(self): - """Test has_microphone() returns True when sd_in pin is configured.""" - AudioManager._instance = None - AudioManager(i2s_pins=self.i2s_pins_with_mic, buzzer_instance=None) - self.assertTrue(AudioManager.has_microphone()) - - def test_has_microphone_without_sd_in(self): - """Test has_microphone() returns False when sd_in pin is not configured.""" - AudioManager._instance = None - AudioManager(i2s_pins=self.i2s_pins_no_mic, buzzer_instance=None) - self.assertFalse(AudioManager.has_microphone()) + def test_get_inputs(self): + """Test get_inputs() returns configured inputs.""" + inputs = AudioManager.get_inputs() + self.assertEqual(len(inputs), 1) + self.assertEqual(inputs[0].kind, "i2s") - def test_has_microphone_no_i2s(self): - """Test has_microphone() returns False when no I2S is configured.""" - AudioManager._instance = None - AudioManager(i2s_pins=None, buzzer_instance=self.buzzer) - self.assertFalse(AudioManager.has_microphone()) + def test_default_input(self): + """Test default input selection.""" + default_input = AudioManager.get_default_input() + self.assertIsNotNone(default_input) + self.assertEqual(default_input.kind, "i2s") def test_is_recording_initially_false(self): """Test that is_recording() returns False initially.""" - self.assertFalse(AudioManager.is_recording()) + recorder = AudioManager.recorder(file_path="test.wav") + self.assertFalse(recorder.is_recording()) def test_record_wav_no_microphone(self): - """Test that record_wav() fails when no microphone is configured.""" + """Test that recorder() fails when no microphone is configured.""" AudioManager._instance = None - AudioManager(i2s_pins=self.i2s_pins_no_mic, buzzer_instance=None) - result = AudioManager.record_wav("test.wav") - self.assertFalse(result, "record_wav() fails when no microphone is configured") + AudioManager() + with self.assertRaises(ValueError): + AudioManager.recorder(file_path="test.wav").start() def test_record_wav_no_i2s(self): AudioManager._instance = None - AudioManager(i2s_pins=None, buzzer_instance=self.buzzer) - result = AudioManager.record_wav("test.wav") - self.assertFalse(result, "record_wav() should fail when no I2S is configured") + AudioManager() + AudioManager.add(AudioManager.Input("mic", "adc", adc_mic_pin=4)) + recorder = AudioManager.recorder(file_path="test.wav") + self.assertFalse(recorder.is_recording()) def test_stop_with_no_recording(self): """Test that stop() can be called when nothing is recording.""" # Should not raise exception AudioManager.stop() - self.assertFalse(AudioManager.is_recording()) From f358ed65ae3d422354b1f15da82580c97ae3ca1a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 16:47:02 +0100 Subject: [PATCH 095/317] Fix volume setting --- .gitignore | 3 +++ .../assets/music_player.py | 18 ++++++++++-------- .../lib/mpos/audio/audiomanager.py | 18 +++++++++++++++++- internal_filesystem/lib/mpos/board/linux.py | 1 - .../lib/mpos/board/m5stack_fire.py | 1 - 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index e9927143..f5e46736 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ __pycache__/ # these get created: c_mpos/c_mpos + +# build files +*.bin 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 402b3b2b..689d72d4 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -62,19 +62,21 @@ class FullscreenPlayer(Activity): def onCreate(self): self._filename = self.getIntent().extras.get("filename") qr_screen = lv.obj() - self._slider_label=lv.label(qr_screen) + self._slider_label = lv.label(qr_screen) self._slider_label.set_text(f"Volume: {AudioManager.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,16) - self._slider.set_value(int(AudioManager.get_volume()/6.25), False) + 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(int(AudioManager.get_volume()), False) self._slider.set_width(lv.pct(90)) - self._slider.align_to(self._slider_label,lv.ALIGN.OUT_BOTTOM_MID,0,10) + self._slider.align_to(self._slider_label, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + def volume_slider_changed(e): - volume_int = self._slider.get_value()*6.25 + volume_int = int(self._slider.get_value()) self._slider_label.set_text(f"Volume: {volume_int}%") AudioManager.set_volume(volume_int) - self._slider.add_event_cb(volume_slider_changed,lv.EVENT.VALUE_CHANGED,None) + + self._slider.add_event_cb(volume_slider_changed, lv.EVENT.VALUE_CHANGED, None) self._filename_label = lv.label(qr_screen) self._filename_label.align(lv.ALIGN.CENTER,0,0) self._filename_label.set_text(self._filename) diff --git a/internal_filesystem/lib/mpos/audio/audiomanager.py b/internal_filesystem/lib/mpos/audio/audiomanager.py index 7a8f631b..fac23dd6 100644 --- a/internal_filesystem/lib/mpos/audio/audiomanager.py +++ b/internal_filesystem/lib/mpos/audio/audiomanager.py @@ -187,7 +187,23 @@ def set_default_input(cls, input_device): @classmethod def set_volume(cls, volume): - cls.get()._volume = max(0, min(100, volume)) + manager = cls.get() + try: + volume_int = int(round(volume)) + except (TypeError, ValueError): + return manager._volume + volume_int = max(0, min(100, volume_int)) + manager._volume = volume_int + + for session in list(manager._active_sessions): + stream = getattr(session, "_stream", None) + if stream and hasattr(stream, "set_volume"): + try: + stream.set_volume(volume_int) + except Exception: + pass + + return volume_int @classmethod def get_volume(cls): diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 43aab645..1ef9556f 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -108,7 +108,6 @@ def adc_to_voltage(adc_value): # Desktop builds have no real audio hardware, but we simulate microphone # recording with a 440Hz sine wave for testing WAV file generation # The i2s_pins dict with 'sd_in' enables microphone simulation -AudioManager() output_i2s_pins = { 'sck': 0, # Simulated - not used on desktop diff --git a/internal_filesystem/lib/mpos/board/m5stack_fire.py b/internal_filesystem/lib/mpos/board/m5stack_fire.py index 1292b6e3..6c95bf09 100644 --- a/internal_filesystem/lib/mpos/board/m5stack_fire.py +++ b/internal_filesystem/lib/mpos/board/m5stack_fire.py @@ -48,7 +48,6 @@ print("m5stack_fire.py init buzzer") buzzer = PWM(Pin(BUZZER_PIN, Pin.OUT, value=1), duty=5) -AudioManager() AudioManager.add(AudioManager.Output("buzzer", "buzzer", buzzer_pin=BUZZER_PIN)) AudioManager.set_volume(40) From 93c2317a5d8ae20f6b3feb13ffc91c3fdc1f2318 Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Tue, 24 Feb 2026 19:06:21 +0100 Subject: [PATCH 096/317] Fix MPU6886 X-Axis MPU6886 is part of M5Stack FIRE. I compare the values from this device with Waveshare ESP32-S3-Touch-LCD-2: The X-axis values are inverted. Fix this here. --- internal_filesystem/lib/drivers/imu_sensor/mpu6886.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py b/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py index 0cf8d137..a7a570c5 100644 --- a/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py +++ b/internal_filesystem/lib/drivers/imu_sensor/mpu6886.py @@ -59,7 +59,7 @@ def _write(self, reg: int, data: bytes): def _read_xyz(self, reg: int, scale: float) -> tuple[int, int, int]: data = self.i2c.readfrom_mem(self.address, reg, 6) - x = twos_complement(data[0] << 8 | data[1], 16) + x = twos_complement(data[0] << 8 | data[1], 16) * -1 y = twos_complement(data[2] << 8 | data[3], 16) z = twos_complement(data[4] << 8 | data[5], 16) return (x * scale, y * scale, z * scale) From 210698bd0f3dc410caf87987007edfce60de67d7 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 19:26:24 +0100 Subject: [PATCH 097/317] MusicPlayer: fix volume --- .../assets/music_player.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 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 689d72d4..79ae88e9 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -4,6 +4,8 @@ from mpos import Activity, Intent, sdcard, get_event_name, AudioManager +slider_max = 16 + class MusicPlayer(Activity): # Widgets: @@ -62,17 +64,22 @@ class FullscreenPlayer(Activity): def onCreate(self): self._filename = self.getIntent().extras.get("filename") qr_screen = lv.obj() + + audio_volume = AudioManager.get_volume() + slider_volume = int(round(audio_volume * slider_max / 100)) + self._slider_label = lv.label(qr_screen) - self._slider_label.set_text(f"Volume: {AudioManager.get_volume()}%") + self._slider_label.set_text(f"Volume: {audio_volume}%") self._slider_label.align(lv.ALIGN.TOP_MID, 0, lv.pct(4)) self._slider = lv.slider(qr_screen) - self._slider.set_range(0, 100) - self._slider.set_value(int(AudioManager.get_volume()), False) + self._slider.set_range(0, slider_max) + self._slider.set_value(slider_volume, False) self._slider.set_width(lv.pct(90)) self._slider.align_to(self._slider_label, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) def volume_slider_changed(e): - volume_int = int(self._slider.get_value()) + slider_value = int(self._slider.get_value()) + volume_int = int(round(slider_value * 100 / slider_max)) self._slider_label.set_text(f"Volume: {volume_int}%") AudioManager.set_volume(volume_int) From 4989a961dc3cf759631f9a83103da7d701fe852f Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Tue, 24 Feb 2026 20:19:30 +0100 Subject: [PATCH 098/317] waveshare_esp32_s3_touch_lcd_2: Fix soft reset fix soft reset, if machine.SPI.Bus() can't be initialized: do a hrd reset --- .../board/waveshare_esp32_s3_touch_lcd_2.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 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 15a10229..5422523b 100644 --- a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py +++ b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py @@ -1,17 +1,15 @@ - print("waveshare_esp32_s3_touch_lcd_2.py initialization") # Hardware initialization for ESP32-S3-Touch-LCD-2 # Manufacturer's website at https://www.waveshare.com/wiki/ESP32-S3-Touch-LCD-2 -import lcd_bus -import machine -import i2c -import lvgl as lv -import task_handler +import time import drivers.display.st7789 as st7789 import drivers.indev.cst816s as cst816s - +import i2c +import lcd_bus +import lvgl as lv +import machine import mpos.ui # Pin configuration @@ -34,12 +32,17 @@ TFT_HOR_RES=320 TFT_VER_RES=240 -spi_bus = machine.SPI.Bus( - host=SPI_BUS, - mosi=LCD_MOSI, - miso=LCD_MISO, - sck=LCD_SCLK -) + +print("waveshare_esp32_s3_touch_lcd_2.py machine.SPI.Bus() initialization") +try: + spi_bus = machine.SPI.Bus(host=SPI_BUS, mosi=LCD_MOSI, miso=LCD_MISO, sck=LCD_SCLK) +except Exception as e: + print(f"Error initializing SPI bus: {e}") + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + + display_bus = lcd_bus.SPIBus( spi_bus=spi_bus, freq=SPI_FREQ, From cb7669f88a0cc48cc25fd9ade653686cdd20336a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 21:40:18 +0100 Subject: [PATCH 099/317] Logging --- internal_filesystem/lib/mpos/audio/stream_wav.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index 2aa2ce53..ec64606d 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -412,9 +412,22 @@ def play(self): self._total_samples = data_size // bytes_per_sample self._duration_ms = int((self._total_samples / original_rate) * 1000) + print( + "WAVStream: I2S init params: " + f"requested_rate={self.requested_sample_rate}, " + f"playback_rate={playback_rate}, original_rate={original_rate}, " + f"channels={channels}, bits=16, i2s_pins={self.i2s_pins}" + ) + # Initialize I2S (always 16-bit output) try: i2s_format = machine.I2S.MONO if channels == 1 else machine.I2S.STEREO + print( + "WAVStream: I2S config: " + f"format={'MONO' if channels == 1 else 'STEREO'}, " + f"ibuf=32000, has_sck={bool(self.i2s_pins.get('sck'))}, " + f"mck_pin={self.i2s_pins.get('mck')}" + ) # Configure MCLK pin if provided (must be done before I2S init) # On some MicroPython versions, machine.I2S() supports a mck argument From 722ab65ebdf738eb3d4bd3d005cd9b87524826be Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 21:44:36 +0100 Subject: [PATCH 100/317] MusicPlayer: show playing song --- .../assets/music_player.py | 72 +++++++++++-------- .../lib/mpos/audio/audiomanager.py | 21 ++++++ 2 files changed, 63 insertions(+), 30 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 79ae88e9..2a3760c0 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -15,6 +15,13 @@ def onCreate(self): screen = lv.obj() # the user might have recently plugged in the sd card so try to mount it sdcard.mount_with_optional_format('/sdcard') + + active_track = AudioManager.get_active_track(stream_type=AudioManager.STREAM_MUSIC) + if active_track: + self.startActivity(Intent(activity_class=FullscreenPlayer).putExtra("filename", active_track)) + self.finish() + return + self.file_explorer = lv.file_explorer(screen) self.file_explorer.explorer_open_dir('M:/') self.file_explorer.align(lv.ALIGN.CENTER, 0, 0) @@ -107,36 +114,41 @@ def onResume(self, screen): super().onResume(screen) if not self._filename: print("Not playing any file...") - else: - print(f"Playing file {self._filename}") - AudioManager.stop() - time.sleep(0.1) - - output = AudioManager.get_default_output() - if output is None: - error_msg = "Error: No audio output available" - print(error_msg) - self.update_ui_threadsafe_if_foreground( - self._filename_label.set_text, - error_msg - ) - return - - try: - player = AudioManager.player( - file_path=self._filename, - stream_type=AudioManager.STREAM_MUSIC, - on_complete=self.player_finished, - output=output, - ) - player.start() - except Exception as exc: - error_msg = "Error: Audio device unavailable or busy" - print(f"{error_msg}: {exc}") - self.update_ui_threadsafe_if_foreground( - self._filename_label.set_text, - error_msg - ) + return + + print(f"Playing file {self._filename}") + active_player = AudioManager.get_active_player(stream_type=AudioManager.STREAM_MUSIC) + if active_player and active_player.file_path == self._filename and active_player.is_playing(): + return + + AudioManager.stop() + time.sleep(0.1) + + output = AudioManager.get_default_output() + if output is None: + error_msg = "Error: No audio output available" + print(error_msg) + self.update_ui_threadsafe_if_foreground( + self._filename_label.set_text, + error_msg + ) + return + + try: + player = AudioManager.player( + file_path=self._filename, + stream_type=AudioManager.STREAM_MUSIC, + on_complete=self.player_finished, + output=output, + ) + player.start() + except Exception as exc: + error_msg = "Error: Audio device unavailable or busy" + print(f"{error_msg}: {exc}") + self.update_ui_threadsafe_if_foreground( + self._filename_label.set_text, + error_msg + ) def focus_obj(self, obj): obj.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) diff --git a/internal_filesystem/lib/mpos/audio/audiomanager.py b/internal_filesystem/lib/mpos/audio/audiomanager.py index fac23dd6..e0e3bf08 100644 --- a/internal_filesystem/lib/mpos/audio/audiomanager.py +++ b/internal_filesystem/lib/mpos/audio/audiomanager.py @@ -209,6 +209,27 @@ def set_volume(cls, volume): def get_volume(cls): return cls.get()._volume + @classmethod + def get_active_player(cls, stream_type=None, file_path=None): + manager = cls.get() + manager._cleanup_inactive() + for session in list(manager._active_sessions): + if isinstance(session, Player): + if stream_type is not None and session.stream_type != stream_type: + continue + if file_path is not None and session.file_path != file_path: + continue + if session.is_playing(): + return session + return None + + @classmethod + def get_active_track(cls, stream_type=None): + player = cls.get_active_player(stream_type=stream_type) + if player and player.file_path: + return player.file_path + return None + @classmethod def player( cls, From 817807ca3a81fc4194a275652c788c954ca07aed Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 23:15:42 +0100 Subject: [PATCH 101/317] MusicPlayer: fix blank screen when playing issue --- .../apps/com.micropythonos.musicplayer/assets/music_player.py | 1 - 1 file changed, 1 deletion(-) 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 2a3760c0..4d1130f1 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py @@ -19,7 +19,6 @@ def onCreate(self): active_track = AudioManager.get_active_track(stream_type=AudioManager.STREAM_MUSIC) if active_track: self.startActivity(Intent(activity_class=FullscreenPlayer).putExtra("filename", active_track)) - self.finish() return self.file_explorer = lv.file_explorer(screen) From 417b7254738cd0ebb2e9cf7dceb59b19319a7d7b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 23:20:02 +0100 Subject: [PATCH 102/317] stream_wav: reduce minimal sample rate --- .../lib/mpos/audio/stream_wav.py | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index ec64606d..c9749456 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -291,10 +291,10 @@ def compute_playback_rate(original_rate, requested_rate=None): upsample_factor = (requested_rate + original_rate - 1) // original_rate return original_rate * upsample_factor, upsample_factor - target_rate = 22050 - if original_rate >= target_rate: + minimal_rate = 8000 + if original_rate >= minimal_rate: return original_rate, 1 - upsample_factor = (target_rate + original_rate - 1) // original_rate + upsample_factor = (minimal_rate + original_rate - 1) // original_rate return original_rate * upsample_factor, upsample_factor # ---------------------------------------------------------------------- @@ -425,7 +425,7 @@ def play(self): print( "WAVStream: I2S config: " f"format={'MONO' if channels == 1 else 'STEREO'}, " - f"ibuf=32000, has_sck={bool(self.i2s_pins.get('sck'))}, " + f"ibuf={playback_rate}, has_sck={bool(self.i2s_pins.get('sck'))}, " f"mck_pin={self.i2s_pins.get('mck')}" ) @@ -457,7 +457,7 @@ def play(self): bits=16, format=i2s_format, rate=playback_rate, - ibuf=32000 + ibuf=playback_rate ) else: self._i2s = machine.I2S( @@ -468,7 +468,7 @@ def play(self): bits=16, format=i2s_format, rate=playback_rate, - ibuf=32000 + ibuf=playback_rate ) except Exception as e: print(f"WAVStream: I2S init failed: {e}") @@ -480,11 +480,16 @@ def play(self): # Chunk size tuning notes: # - Smaller chunks = more responsive to stop() # - Larger chunks = less overhead, smoother audio - # - The 32KB I2S buffer handles timing smoothness - chunk_size = 8192 - bytes_per_original_sample = (bits_per_sample // 8) * channels - total_original = 0 + # - The 0.5-second (stereo) or 1 second (mono) I2S buffer handles timing smoothness + bytes_per_second = original_rate * bytes_per_sample + chunk_size = int(bytes_per_second / 10) + # chunk_size of 8192 worked great with 22050hz stereo 16 bit so 88200 bytes per sample so fator 10.7 + #chunk_size = bytes_per_second >> 3 # 12-14 fps + #chunk_size = bytes_per_second >> 4 # 16-18 fps but stutters + #chunk_size = int(bytes_per_second / 12) # 18 fps for 8khz mono, 16 fps for 22khz mono, higher stutters + #chunk_size = int(bytes_per_second / 11) # still jitters at 22050hz stereo in quasibird + total_original = 0 while total_original < data_size: if not self._keep_running: print("WAVStream: Playback stopped by user") @@ -492,7 +497,7 @@ def play(self): # Read chunk of original data to_read = min(chunk_size, data_size - total_original) - to_read -= (to_read % bytes_per_original_sample) + to_read -= (to_read % bytes_per_sample) if to_read <= 0: break @@ -528,7 +533,7 @@ def play(self): time.sleep(num_samples / playback_rate) total_original += to_read - self._progress_samples = total_original // bytes_per_original_sample + self._progress_samples = total_original // bytes_per_sample print(f"WAVStream: Finished playing {self.file_path}") if self.on_complete: From 0268b20628d1675f4c02b04f48fc04ec626b0cd4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Feb 2026 23:27:53 +0100 Subject: [PATCH 103/317] stream_wav: restore tweaks QuasiBird still runs jittery at 22050 hz 16 bit stereo. Mono is fine, and lower sample rates are fine too. At max volume, so no volume scaling, the frame rate is quite high but it still jitters. The question is why... - does the buffer underrun? - or does it spend too much time reading from SD card? - or is the CPU busy? Solutions: - offload it to a different core - do the I2S playback asynchronous I guess it doesn't make sense tweaking this, as audio plays fine up to 48khz stereo if no game is being played, just normal GUI updates. --- internal_filesystem/lib/mpos/audio/stream_wav.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index c9749456..8d035e70 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -144,7 +144,7 @@ def _scale_audio_powers_of_2(buf: ptr8, num_bytes: int, shift: int): class WAVStream: """ WAV file playback stream with I2S output. - Supports 8/16/24/32-bit PCM, mono and stereo, auto-upsampling to >=22050 Hz. + Supports 8/16/24/32-bit PCM, mono and stereo, auto-upsampling to >=8000 Hz. """ def __init__( @@ -400,6 +400,8 @@ def play(self): ) self._playback_rate = playback_rate + #ibuf = playback_rate # doesnt account for stereo vs mono... + ibuf = 32000 print(f"WAVStream: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch") print(f"WAVStream: Playback at {playback_rate} Hz (factor {upsample_factor})") @@ -425,7 +427,7 @@ def play(self): print( "WAVStream: I2S config: " f"format={'MONO' if channels == 1 else 'STEREO'}, " - f"ibuf={playback_rate}, has_sck={bool(self.i2s_pins.get('sck'))}, " + f"ibuf={ibuf}, has_sck={bool(self.i2s_pins.get('sck'))}, " f"mck_pin={self.i2s_pins.get('mck')}" ) @@ -457,7 +459,7 @@ def play(self): bits=16, format=i2s_format, rate=playback_rate, - ibuf=playback_rate + ibuf=ibuf ) else: self._i2s = machine.I2S( @@ -468,7 +470,7 @@ def play(self): bits=16, format=i2s_format, rate=playback_rate, - ibuf=playback_rate + ibuf=ibuf ) except Exception as e: print(f"WAVStream: I2S init failed: {e}") @@ -482,8 +484,7 @@ def play(self): # - Larger chunks = less overhead, smoother audio # - The 0.5-second (stereo) or 1 second (mono) I2S buffer handles timing smoothness bytes_per_second = original_rate * bytes_per_sample - chunk_size = int(bytes_per_second / 10) - # chunk_size of 8192 worked great with 22050hz stereo 16 bit so 88200 bytes per sample so fator 10.7 + chunk_size = int(bytes_per_second / 10.7) # chunk_size of 8192 worked great with 22050hz stereo 16 bit so 88200 bytes per sample so fator 10.7 #chunk_size = bytes_per_second >> 3 # 12-14 fps #chunk_size = bytes_per_second >> 4 # 16-18 fps but stutters #chunk_size = int(bytes_per_second / 12) # 18 fps for 8khz mono, 16 fps for 22khz mono, higher stutters From e4c0ee257095ceb86ef9a983f164bb53f6abdb05 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 07:54:54 +0100 Subject: [PATCH 104/317] Add duplex test --- .../lib/mpos/audio/duplex_test.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 internal_filesystem/lib/mpos/audio/duplex_test.py diff --git a/internal_filesystem/lib/mpos/audio/duplex_test.py b/internal_filesystem/lib/mpos/audio/duplex_test.py new file mode 100644 index 00000000..601a68fa --- /dev/null +++ b/internal_filesystem/lib/mpos/audio/duplex_test.py @@ -0,0 +1,102 @@ +"""Minimal duplex I2S proof-of-concept for Fri3d 2024. + +Creates TX + RX I2S instances simultaneously using merged pin config +from the fri3d_2024 board setup. Intended for quick validation only. +""" + +import time + +try: + import machine + _HAS_MACHINE = True +except ImportError: + _HAS_MACHINE = False + + +# Merged pin map from internal_filesystem/lib/mpos/board/fri3d_2024.py +I2S_PINS = { + "ws": 47, # shared LRCLK + "sck": 2, # DAC bit clock + "sd": 16, # DAC data out + "sck_in": 17, # mic bit clock + "sd_in": 15, # mic data in +} + + +class DuplexI2STest: + """Minimal duplex setup: one TX I2S + one RX I2S running together.""" + + def __init__(self, sample_rate=16000, duration_ms=3000): + self.sample_rate = sample_rate + self.duration_ms = duration_ms + self._tx = None + self._rx = None + + def _init_i2s(self): + if not _HAS_MACHINE: + raise RuntimeError("machine.I2S not available") + + self._tx = machine.I2S( + 0, + sck=machine.Pin(I2S_PINS["sck"], machine.Pin.OUT), + ws=machine.Pin(I2S_PINS["ws"], machine.Pin.OUT), + sd=machine.Pin(I2S_PINS["sd"], machine.Pin.OUT), + mode=machine.I2S.TX, + bits=16, + format=machine.I2S.MONO, + rate=self.sample_rate, + ibuf=8000, + ) + + self._rx = machine.I2S( + 1, + sck=machine.Pin(I2S_PINS["sck_in"], machine.Pin.OUT), + ws=machine.Pin(I2S_PINS["ws"], machine.Pin.OUT), + sd=machine.Pin(I2S_PINS["sd_in"], machine.Pin.IN), + mode=machine.I2S.RX, + bits=16, + format=machine.I2S.MONO, + rate=self.sample_rate, + ibuf=8000, + ) + + def _deinit_i2s(self): + if self._tx: + self._tx.deinit() + self._tx = None + if self._rx: + self._rx.deinit() + self._rx = None + + def run(self): + """Run a short duplex session: play a tone while reading mic data.""" + self._init_i2s() + try: + tone = self._make_tone_buffer(freq_hz=440, ms=50) + read_buf = bytearray(1024) + t_end = time.ticks_add(time.ticks_ms(), self.duration_ms) + + while time.ticks_diff(t_end, time.ticks_ms()) > 0: + self._tx.write(tone) + self._rx.readinto(read_buf) + finally: + self._deinit_i2s() + + def _make_tone_buffer(self, freq_hz=440, ms=50): + samples = int(self.sample_rate * (ms / 1000)) + buf = bytearray(samples * 2) + for i in range(samples): + phase = 2 * 3.14159265 * freq_hz * (i / self.sample_rate) + sample = int(12000 * __import__("math").sin(phase)) + buf[i * 2] = sample & 0xFF + buf[i * 2 + 1] = (sample >> 8) & 0xFF + return buf + + +def run_duplex_test(sample_rate=16000, duration_ms=3000): + """Convenience entry point for quick manual tests.""" + DuplexI2STest(sample_rate=sample_rate, duration_ms=duration_ms).run() + + +if __name__ == "__main__": + run_duplex_test() From 391071aee47174678e4800b0d89b966aaa82d8d2 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 07:55:47 +0100 Subject: [PATCH 105/317] Move file --- .../audio/duplex_test.py => tests/manual_test_duplex_audio.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal_filesystem/lib/mpos/audio/duplex_test.py => tests/manual_test_duplex_audio.py (100%) diff --git a/internal_filesystem/lib/mpos/audio/duplex_test.py b/tests/manual_test_duplex_audio.py similarity index 100% rename from internal_filesystem/lib/mpos/audio/duplex_test.py rename to tests/manual_test_duplex_audio.py From 7628e9a6bc869078ee0d8b3ecd445bd03ecd1e5d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 08:01:10 +0100 Subject: [PATCH 106/317] playback after recording --- tests/manual_test_duplex_audio.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/manual_test_duplex_audio.py b/tests/manual_test_duplex_audio.py index 601a68fa..362e7f1e 100644 --- a/tests/manual_test_duplex_audio.py +++ b/tests/manual_test_duplex_audio.py @@ -74,11 +74,23 @@ def run(self): try: tone = self._make_tone_buffer(freq_hz=440, ms=50) read_buf = bytearray(1024) + recorded = bytearray() t_end = time.ticks_add(time.ticks_ms(), self.duration_ms) while time.ticks_diff(t_end, time.ticks_ms()) > 0: self._tx.write(tone) - self._rx.readinto(read_buf) + read_len = self._rx.readinto(read_buf) + if read_len: + recorded.extend(read_buf[:read_len]) + + print("waiting a bit") + time.sleep(1) + if recorded: + print("playing the recording") + playback = memoryview(recorded) + offset = 0 + while offset < len(playback): + offset += self._tx.write(playback[offset:]) finally: self._deinit_i2s() From 9f041f0108b714fac322bf202dafcb165dc28987 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 08:05:25 +0100 Subject: [PATCH 107/317] record and then playback works but the record during playback gives static --- tests/manual_test_duplex_audio.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/manual_test_duplex_audio.py b/tests/manual_test_duplex_audio.py index 362e7f1e..a7ac12cc 100644 --- a/tests/manual_test_duplex_audio.py +++ b/tests/manual_test_duplex_audio.py @@ -32,10 +32,7 @@ def __init__(self, sample_rate=16000, duration_ms=3000): self._tx = None self._rx = None - def _init_i2s(self): - if not _HAS_MACHINE: - raise RuntimeError("machine.I2S not available") - + def _init_write(self): self._tx = machine.I2S( 0, sck=machine.Pin(I2S_PINS["sck"], machine.Pin.OUT), @@ -48,6 +45,12 @@ def _init_i2s(self): ibuf=8000, ) + + def _init_i2s(self): + if not _HAS_MACHINE: + raise RuntimeError("machine.I2S not available") + + # self._init_write() self._rx = machine.I2S( 1, sck=machine.Pin(I2S_PINS["sck_in"], machine.Pin.OUT), @@ -78,7 +81,7 @@ def run(self): t_end = time.ticks_add(time.ticks_ms(), self.duration_ms) while time.ticks_diff(t_end, time.ticks_ms()) > 0: - self._tx.write(tone) + #self._tx.write(tone) read_len = self._rx.readinto(read_buf) if read_len: recorded.extend(read_buf[:read_len]) @@ -90,6 +93,8 @@ def run(self): playback = memoryview(recorded) offset = 0 while offset < len(playback): + if not self._tx: + self._init_write() offset += self._tx.write(playback[offset:]) finally: self._deinit_i2s() From 4bbe81f7867b861ba8902de794b74a189b17bc89 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 14:28:05 +0100 Subject: [PATCH 108/317] Synchronize qemu with t-display-s3 --- internal_filesystem/lib/mpos/board/qemu.py | 164 +++++++++++++++------ 1 file changed, 122 insertions(+), 42 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/qemu.py b/internal_filesystem/lib/mpos/board/qemu.py index 0c2f6f9d..5242ad3d 100644 --- a/internal_filesystem/lib/mpos/board/qemu.py +++ b/internal_filesystem/lib/mpos/board/qemu.py @@ -19,7 +19,7 @@ data5=46, data6=47, data7=48, - reverse_color_bits=False # doesnt seem to do anything? + #reverse_color_bits=False # doesnt seem to do anything? ) except Exception as e: print(f"Error initializing display bus: {e}") @@ -31,91 +31,171 @@ fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) -import mpos.ui import drivers.display.st7789 as st7789 -# 320x200 => make 320x240 screenshot => it's 240x200 (but the display shows more than 200) +import mpos.ui mpos.ui.main_display = st7789.ST7789( data_bus=display_bus, frame_buffer1=fb1, frame_buffer2=fb2, display_width=170, display_height=320, - color_space=lv.COLOR_FORMAT.RGB565, + color_space=lv.COLOR_FORMAT.RGB565, # gives bad colors + #color_space=lv.COLOR_FORMAT.RGB888, # not supported by qemu color_byte_order=st7789.BYTE_ORDER_RGB, # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 + power_pin=9, # Must set RD pin to high, otherwise blank screen as soon as LVGL's task_handler starts reset_pin=5, - backlight_pin=38, + reset_state=st7789.STATE_LOW, # needs low: high will not enable the display + backlight_pin=38, # needed backlight_on_state=st7789.STATE_PWM, + offset_x=0, + offset_y=35 ) +mpos.ui.main_display.set_power(True) # set RD pin to high before the rest, otherwise garbled output mpos.ui.main_display.init() -mpos.ui.main_display.set_power(True) -mpos.ui.main_display.set_backlight(100) +mpos.ui.main_display.set_backlight(100) # works lv.init() -#mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling -mpos.ui.main_display.set_color_inversion(True) # doesnt seem to do anything? +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._270) # must be done after initializing display and creating the touch drivers, to ensure proper handling +mpos.ui.main_display.set_color_inversion(True) + # Button handling code: from machine import Pin -btn_a = Pin(0, Pin.IN, Pin.PULL_UP) # 1 -btn_b = Pin(14, Pin.IN, Pin.PULL_UP) # 2 -btn_c = Pin(3, Pin.IN, Pin.PULL_UP) # 3 +btn_a = Pin(0, Pin.IN, Pin.PULL_UP) +btn_b = Pin(14, Pin.IN, Pin.PULL_UP) # Key repeat configuration # This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where # the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus. REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat REPEAT_RATE_MS = 100 # Interval between repeats +REPEAT_PREV_BECOMES_BACK = 700 # Long previous press becomes back button +COMBO_GRACE_MS = 60 # Accept near-simultaneous A+B as ENTER last_key = None last_state = lv.INDEV_STATE.RELEASED -#key_press_start = 0 # Time when key was first pressed -#last_repeat_time = 0 # Time of last repeat event +key_press_start = 0 # Time when key was first pressed +last_repeat_time = 0 # Time of last repeat event +last_a_down_time = 0 +last_b_down_time = 0 +last_a_pressed = False +last_b_pressed = False # Read callback # Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line, # that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash. def keypad_read_cb(indev, data): - global last_key, last_state #, key_press_start, last_repeat_time - since_last_repeat = 0 + global last_key, last_state, key_press_start, last_repeat_time, last_a_down_time, last_b_down_time + global last_a_pressed, last_b_pressed # Check buttons - current_key = None current_time = time.ticks_ms() - if btn_a.value() == 0: - current_key = lv.KEY.PREV - elif btn_b.value() == 0: + btn_a_pressed = btn_a.value() == 0 + btn_b_pressed = btn_b.value() == 0 + if btn_a_pressed and not last_a_pressed: + last_a_down_time = current_time + if btn_b_pressed and not last_b_pressed: + last_b_down_time = current_time + last_a_pressed = btn_a_pressed + last_b_pressed = btn_b_pressed + + near_simul = False + if btn_a_pressed and btn_b_pressed: + near_simul = True + elif btn_a_pressed and last_b_down_time and time.ticks_diff(current_time, last_b_down_time) <= COMBO_GRACE_MS: + near_simul = True + elif btn_b_pressed and last_a_down_time and time.ticks_diff(current_time, last_a_down_time) <= COMBO_GRACE_MS: + near_simul = True + + single_press_wait = False + if btn_a_pressed ^ btn_b_pressed: + if btn_a_pressed and time.ticks_diff(current_time, last_a_down_time) < COMBO_GRACE_MS: + single_press_wait = True + elif btn_b_pressed and time.ticks_diff(current_time, last_b_down_time) < COMBO_GRACE_MS: + single_press_wait = True + + if near_simul or single_press_wait: + dt_a = time.ticks_diff(current_time, last_a_down_time) if last_a_down_time else None + dt_b = time.ticks_diff(current_time, last_b_down_time) if last_b_down_time else None + print(f"combo guard: a={btn_a_pressed} b={btn_b_pressed} near={near_simul} wait={single_press_wait} dt_a={dt_a} dt_b={dt_b}") + + # While in an on-screen keyboard, PREV button is LEFT and NEXT button is RIGHT + focus_group = lv.group_get_default() + focus_keyboard = False + if focus_group: + current_focused = focus_group.get_focused() + if isinstance(current_focused, lv.keyboard): + #print("focus is on a keyboard") + focus_keyboard = True + + if near_simul: current_key = lv.KEY.ENTER - elif btn_c.value() == 0: - current_key = lv.KEY.NEXT - - if (btn_a.value() == 0) and (btn_c.value() == 0): - current_key = lv.KEY.ESC - - if current_key: - if current_key != last_key: - # New key press - data.key = current_key - data.state = lv.INDEV_STATE.PRESSED - last_key = data.key - last_state = data.state - #key_press_start = current_time - #last_repeat_time = current_time + elif single_press_wait: + current_key = None + elif btn_a_pressed: + if focus_keyboard: + current_key = lv.KEY.LEFT else: - print(f"should {current_key} be repeated?") + current_key = lv.KEY.PREV + elif btn_b_pressed: + if focus_keyboard: + current_key = lv.KEY.RIGHT + else: + current_key = lv.KEY.NEXT else: + current_key = None + + if current_key is None: # No key pressed - data.key = last_key if last_key else lv.KEY.ENTER + data.key = last_key if last_key else -1 data.state = lv.INDEV_STATE.RELEASED last_key = None - last_state = data.state - #key_press_start = 0 - #last_repeat_time = 0 + last_state = lv.INDEV_STATE.RELEASED + key_press_start = 0 + last_repeat_time = 0 + elif last_key is None or current_key != last_key: + print(f"New key press: {current_key}") + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + last_key = current_key + last_state = lv.INDEV_STATE.PRESSED + key_press_start = current_time + last_repeat_time = current_time + else: + print(f"key repeat because current_key {current_key} == last_key {last_key}") + elapsed = time.ticks_diff(current_time, key_press_start) + since_last_repeat = time.ticks_diff(current_time, last_repeat_time) + if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: + next_state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED + if current_key == lv.KEY.PREV: + print("Repeated PREV does not do anything, instead it triggers ESC (back) if long enough") + if since_last_repeat > REPEAT_PREV_BECOMES_BACK: + print("back button trigger!") + data.key = lv.KEY.ESC + data.state = next_state + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + print("repeat PREV ignored because not pressed long enough") + else: + print("Send a new PRESSED/RELEASED pair for repeat") + data.key = current_key + data.state = next_state + last_key = current_key + last_state = data.state + last_repeat_time = current_time + else: + # This doesn't seem to make the key navigation in on-screen keyboards work, unlike on the m5stack_fire...? + #print("No repeat yet, send RELEASED to avoid PRESSING, which breaks keyboard navigation...") + data.state = lv.INDEV_STATE.RELEASED + last_state = lv.INDEV_STATE.RELEASED # Handle ESC for back navigation (only on initial PRESSED) if data.state == lv.INDEV_STATE.PRESSED and data.key == lv.KEY.ESC: mpos.ui.back_screen() - +''' group = lv.group_create() group.set_default() @@ -129,5 +209,5 @@ def keypad_read_cb(indev, data): indev.enable(True) # NOQA from mpos import InputManager InputManager.register_indev(indev) - +''' print("qemu.py finished") From 0cc1ca0ff42c986444fb9176d9fd75c4e6109454 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 14:29:13 +0100 Subject: [PATCH 109/317] update audio --- tests/manual_test_duplex_audio.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/manual_test_duplex_audio.py b/tests/manual_test_duplex_audio.py index a7ac12cc..f1f2c707 100644 --- a/tests/manual_test_duplex_audio.py +++ b/tests/manual_test_duplex_audio.py @@ -1,7 +1,9 @@ -"""Minimal duplex I2S proof-of-concept for Fri3d 2024. +"""Minimal duplex I2S test for Fri3d 2024 with communicator. Creates TX + RX I2S instances simultaneously using merged pin config from the fri3d_2024 board setup. Intended for quick validation only. + +To get this working, the I2S needs to be changed, see plan at https://github.com/orgs/micropython/discussions/12473 """ import time @@ -42,15 +44,10 @@ def _init_write(self): bits=16, format=machine.I2S.MONO, rate=self.sample_rate, - ibuf=8000, + ibuf=16000, ) - - def _init_i2s(self): - if not _HAS_MACHINE: - raise RuntimeError("machine.I2S not available") - - # self._init_write() + def _init_read(self): self._rx = machine.I2S( 1, sck=machine.Pin(I2S_PINS["sck_in"], machine.Pin.OUT), @@ -60,9 +57,16 @@ def _init_i2s(self): bits=16, format=machine.I2S.MONO, rate=self.sample_rate, - ibuf=8000, + ibuf=16000, ) + def _init_i2s(self): + if not _HAS_MACHINE: + raise RuntimeError("machine.I2S not available") + + self._init_read() + self._init_write() + def _deinit_i2s(self): if self._tx: self._tx.deinit() @@ -81,7 +85,7 @@ def run(self): t_end = time.ticks_add(time.ticks_ms(), self.duration_ms) while time.ticks_diff(t_end, time.ticks_ms()) > 0: - #self._tx.write(tone) + #self._tx.write(tone) # works but saturates the microphone read_len = self._rx.readinto(read_buf) if read_len: recorded.extend(read_buf[:read_len]) From 71e1ea8bd24eb6db6dc3ee39c575df9a042fb7d8 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 14:31:57 +0100 Subject: [PATCH 110/317] stream_wav.py: add hardware volume control uses shift but doesn't seem to work --- .../lib/mpos/audio/stream_wav.py | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index 8d035e70..b688ae7e 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -8,6 +8,10 @@ import sys import time +# Toggle to enable I2S.shift-based volume scaling when available. +# Set to False to use legacy software scaling only. +USE_I2S_SHIFT_VOLUME = False + # Volume scaling function - Viper-optimized for ESP32 performance # NOTE: The line below is automatically commented out by build_mpos.sh during # Unix/macOS builds (cross-compiler doesn't support Viper), then uncommented after build. @@ -141,6 +145,21 @@ def _scale_audio_powers_of_2(buf: ptr8, num_bytes: int, shift: int): buf[i] = s & 0xFF buf[i+1] = (s >> 8) & 0xFF + +# Would be faster to use a lookup table here +def _volume_to_shift(scale_fixed): + """Convert fixed-point volume (0..32768) to a right-shift amount (0..16).""" + if scale_fixed >= 32768: + return 0 + if scale_fixed <= 0: + return 16 + shift = 0 + threshold = 32768 + while shift < 16 and scale_fixed < threshold: + shift += 1 + threshold >>= 1 + return shift + class WAVStream: """ WAV file playback stream with I2S output. @@ -400,8 +419,8 @@ def play(self): ) self._playback_rate = playback_rate - #ibuf = playback_rate # doesnt account for stereo vs mono... - ibuf = 32000 + # ibuf = playback_rate # doesnt account for stereo vs mono... + ibuf = 32000 print(f"WAVStream: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch") print(f"WAVStream: Playback at {playback_rate} Hz (factor {upsample_factor})") @@ -523,7 +542,24 @@ def play(self): scale = self.volume / 100.0 if scale < 1.0: scale_fixed = int(scale * 32768) - _scale_audio_optimized(raw, len(raw), scale_fixed) + if ( + USE_I2S_SHIFT_VOLUME + and self._i2s + and hasattr(self._i2s, "shift") + ): + shift = _volume_to_shift(scale_fixed) + if shift >= 16: + for i in range(len(raw)): + raw[i] = 0 + elif shift > 0: + try: + self._i2s.shift(raw, 16, shift) # triggers exception + except Exception as e: + print(f"_i2s.shift got exception, falling back to software scaling: {e}") + _scale_audio_optimized(raw, len(raw), scale_fixed) + else: + print("_i2s has no shift attribute, falling back to software scaling") + _scale_audio_optimized(raw, len(raw), scale_fixed) # 4. Output to I2S (blocking write is OK - we're in a separate thread) if self._i2s: From f33997944038f641ba586d1c2e6c3b1a1b10d4af Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 15:40:36 +0100 Subject: [PATCH 111/317] Reduce debug --- .../builtin/apps/com.micropythonos.launcher/assets/launcher.py | 2 +- internal_filesystem/lib/mpos/content/app_manager.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py b/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py index e88e1471..7459fc59 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py @@ -91,7 +91,7 @@ def onResume(self, screen): app_name = app.name app_dir_fullpath = app.installed_path - print(f"Adding app {app_name} from {app_dir_fullpath}") + #print(f"Adding app {app_name} from {app_dir_fullpath}") # ----- container ------------------------------------------------ app_cont = lv.obj(screen) diff --git a/internal_filesystem/lib/mpos/content/app_manager.py b/internal_filesystem/lib/mpos/content/app_manager.py index 07ef1b86..377f87d1 100644 --- a/internal_filesystem/lib/mpos/content/app_manager.py +++ b/internal_filesystem/lib/mpos/content/app_manager.py @@ -137,7 +137,7 @@ def refresh_apps(cls): # ---- store in both containers --------------------------- cls._app_list.append(app) cls._by_fullname[fullname] = app - print("added app {}".format(app)) + #print("added app {}".format(app)) except Exception as e: print("AppManager: handling {} got exception: {}".format(base, e)) From 85498e47a3bfecd0b28c988b7404c4f2a3870c5f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 16:02:40 +0100 Subject: [PATCH 112/317] fix(st7789): program RAMCTRL for I80 RGB565 byte order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure ST7789 RAMCTRL sets RGB565 swap on 8‑bit I80 buses to fix color endianness; SPI path unchanged. --- .../lib/drivers/display/st7789/_st7789_init.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py b/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py index 42a21135..aebf24df 100644 --- a/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py +++ b/internal_filesystem/lib/drivers/display/st7789/_st7789_init.py @@ -60,16 +60,14 @@ def init(self): # sets swapping the bytes at the hardware level. - if ( - self._rgb565_byte_swap and - isinstance(self._data_bus, lcd_bus.I80Bus) and - self._data_bus.get_lane_count() == 8 - ): + color_size = lv.color_format_get_size(self._color_space) + if isinstance(self._data_bus, lcd_bus.I80Bus) and color_size == 2: param_buf[0] = 0x00 - param_buf[1] = 0xF0 | _RGB565SWAP + param_buf[1] = 0xF0 + if self._data_bus.get_lane_count() == 8: + param_buf[1] |= _RGB565SWAP self.set_params(_RAMCTRL, param_mv[:2]) - color_size = lv.color_format_get_size(self._color_space) if color_size == 2: # NOQA pixel_format = 0x55 elif color_size == 3: From 5555a83d7c6b8e69860e28c5d19d3ec573732b26 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 18:09:43 +0100 Subject: [PATCH 113/317] Use lilygo_t_display_s3 for emulated t-display-s3 This means the qemu.py is deprecated. --- .../lib/mpos/board/lilygo_t_display_s3.py | 4 ++-- internal_filesystem/lib/mpos/board/qemu.py | 5 ++--- internal_filesystem/lib/mpos/main.py | 11 +++-------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index cd939eab..522f55d3 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -41,8 +41,8 @@ frame_buffer2=fb2, display_width=170, display_height=320, - #color_space=lv.COLOR_FORMAT.RGB565, # gives bad colors - color_space=lv.COLOR_FORMAT.RGB888, + color_space=lv.COLOR_FORMAT.RGB565, + # color_space=lv.COLOR_FORMAT.RGB888, # not supported on qemu color_byte_order=st7789.BYTE_ORDER_RGB, # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 power_pin=9, # Must set RD pin to high, otherwise blank screen as soon as LVGL's task_handler starts diff --git a/internal_filesystem/lib/mpos/board/qemu.py b/internal_filesystem/lib/mpos/board/qemu.py index 5242ad3d..9cd5412e 100644 --- a/internal_filesystem/lib/mpos/board/qemu.py +++ b/internal_filesystem/lib/mpos/board/qemu.py @@ -39,7 +39,7 @@ frame_buffer2=fb2, display_width=170, display_height=320, - color_space=lv.COLOR_FORMAT.RGB565, # gives bad colors + color_space=lv.COLOR_FORMAT.RGB565, #color_space=lv.COLOR_FORMAT.RGB888, # not supported by qemu color_byte_order=st7789.BYTE_ORDER_RGB, # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 @@ -195,7 +195,6 @@ def keypad_read_cb(indev, data): if data.state == lv.INDEV_STATE.PRESSED and data.key == lv.KEY.ESC: mpos.ui.back_screen() -''' group = lv.group_create() group.set_default() @@ -209,5 +208,5 @@ def keypad_read_cb(indev, data): indev.enable(True) # NOQA from mpos import InputManager InputManager.register_indev(indev) -''' + print("qemu.py finished") diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 2a480e74..40f3d779 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -93,24 +93,19 @@ def detect_board(): import machine unique_id_prefixes = machine.unique_id()[0:3] - print("qemu ?") - if unique_id_prefixes[0] == 0x10: - return "qemu" + print("(emulated) lilygo_t_display_s3 ?") + if unique_id_prefixes == b'\x10\x01\x00' or unique_id_prefixes == b'\xc0\x4e\x30': + return "lilygo_t_display_s3" # display gets confused by the i2c stuff below print("odroid_go ?") if unique_id_prefixes[0] == 0x30: return "odroid_go" - print("lilygo_t_display_s3 ?") - if unique_id_prefixes == b'\xc0\x4e\x30': - return "lilygo_t_display_s3" # display gets confused by the i2c stuff below - print("fri3d_2026 ?") if unique_id_prefixes == b'\xdc\xb4\xd9': # or: if single_address_i2c_scan(i2c0, 0x6A): # IMU currently not installed on prototype board return "fri3d_2026" - # Then do I2C-based board detection print("matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 ?") if i2c0 := fail_save_i2c(sda=39, scl=38): From 7016efffd69746ff849e996206112fbc9e515358 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 19:23:10 +0100 Subject: [PATCH 114/317] Rename websocket to uaiowebsocket to avoid conflict --- internal_filesystem/lib/{websocket.py => uaiowebsocket.py} | 0 micropython-nostr | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename internal_filesystem/lib/{websocket.py => uaiowebsocket.py} (100%) diff --git a/internal_filesystem/lib/websocket.py b/internal_filesystem/lib/uaiowebsocket.py similarity index 100% rename from internal_filesystem/lib/websocket.py rename to internal_filesystem/lib/uaiowebsocket.py diff --git a/micropython-nostr b/micropython-nostr index 25b47118..3da5987f 160000 --- a/micropython-nostr +++ b/micropython-nostr @@ -1 +1 @@ -Subproject commit 25b4711813da0a4fce4e587b2d687c6bd49dd83a +Subproject commit 3da5987fcc4a38c0467f00e03c1731075b51500c From 5740b29f11d1c6ede2932a1d56cc049983f4fa24 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 19:49:30 +0100 Subject: [PATCH 115/317] Add webrepl on esp32 --- internal_filesystem/lib/mpos/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 40f3d779..81a5cbf0 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -220,6 +220,12 @@ async def asyncio_repl(): await aiorepl.task() TaskManager.create_task(asyncio_repl()) # only gets started after TaskManager.start() +try: + import webrepl + webrepl.start(port=7890,password="MPOSweb26") # password max 9 characters +except Exception as e: + print(f"Could not start webrepl - this is normal on desktop systems: {e}") + async def ota_rollback_cancel(): try: from esp32 import Partition From d37db477c55d23305011eb3f305a1c404d3b00d2 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 19:50:02 +0100 Subject: [PATCH 116/317] CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1426dab..544ea7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Frameworks: - CameraManager and CameraActivity: work fully camera-agnostic OS: +- Add webrepl on ESP32 boards - Add board support: Makerfabs MaTouch ESP32-S3 SPI IPS 2.8' with Camera OV3660 - Scale MicroPythonOS boot logo down if necessary - Don't show battery icon if battery is not supported From c269ab5ae23a8f0ce36db992424ec8336d17132d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 20:53:25 +0100 Subject: [PATCH 117/317] Increment version number --- CHANGELOG.md | 14 +++++++++++++- internal_filesystem/lib/mpos/build_info.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 544ea7af..17e392a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +0.9.0 +===== + +Frameworks: +- AudioManager: add support for multiple speakers and microphones +- AudioManager: add support for ADC-based microphone (adc_mic) + +OS: +- ESP32 boards: add webrepl +- New board support: LilyGo T-Display-S3 +- New board support: M5Stack Fire +- New board support: ODroid Go + 0.8.0 ===== @@ -12,7 +25,6 @@ Frameworks: - CameraManager and CameraActivity: work fully camera-agnostic OS: -- Add webrepl on ESP32 boards - Add board support: Makerfabs MaTouch ESP32-S3 SPI IPS 2.8' with Camera OV3660 - Scale MicroPythonOS boot logo down if necessary - Don't show battery icon if battery is not supported diff --git a/internal_filesystem/lib/mpos/build_info.py b/internal_filesystem/lib/mpos/build_info.py index ad2bbc09..4a3cd0eb 100644 --- a/internal_filesystem/lib/mpos/build_info.py +++ b/internal_filesystem/lib/mpos/build_info.py @@ -9,5 +9,5 @@ class BuildInfo: class version: """Version information.""" - release = "0.8.1" + release = "0.9.0" api_level = 0 # subject to change until API Level 1 From 9625ab32183ba0f712b1e24b9c8f355e11477fbe Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 21:04:01 +0100 Subject: [PATCH 118/317] Fix tests --- tests/test_multi_connect.py | 2 +- tests/test_multi_websocket_with_bad_ones.py | 2 +- tests/test_websocket.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_multi_connect.py b/tests/test_multi_connect.py index e26bc6a8..2b836515 100644 --- a/tests/test_multi_connect.py +++ b/tests/test_multi_connect.py @@ -4,7 +4,7 @@ from mpos import App, AppManager, TaskManager -from websocket import WebSocketApp +from uaiowebsocket import WebSocketApp # demo_multiple_ws.py import asyncio diff --git a/tests/test_multi_websocket_with_bad_ones.py b/tests/test_multi_websocket_with_bad_ones.py index 5fbda5a7..d6811bd0 100644 --- a/tests/test_multi_websocket_with_bad_ones.py +++ b/tests/test_multi_websocket_with_bad_ones.py @@ -5,7 +5,7 @@ from mpos import App, AppManager from mpos import TaskManager -from websocket import WebSocketApp +from uaiowebsocket import WebSocketApp import asyncio import aiohttp diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 46ad55af..258cdff5 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -6,7 +6,7 @@ from mpos import App, AppManager from mpos import TaskManager -from websocket import WebSocketApp +from uaiowebsocket import WebSocketApp class TestMutlipleWebsocketsAsyncio(unittest.TestCase): From 370a20dcd2255da148254b47638c7568ba9cc069 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 21:16:24 +0100 Subject: [PATCH 119/317] Fix colors on lilygo_t_display_s3 --- internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index 522f55d3..0b6fd2c9 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -43,7 +43,7 @@ display_height=320, color_space=lv.COLOR_FORMAT.RGB565, # color_space=lv.COLOR_FORMAT.RGB888, # not supported on qemu - color_byte_order=st7789.BYTE_ORDER_RGB, + color_byte_order=st7789.BYTE_ORDER_BGR, # rgb565_byte_swap=False, # always False is data_bus.get_lane_count() == 8 power_pin=9, # Must set RD pin to high, otherwise blank screen as soon as LVGL's task_handler starts reset_pin=5, From 557520cafa9647d166c735d2d944096d70368d18 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Feb 2026 22:03:55 +0100 Subject: [PATCH 120/317] Comments --- internal_filesystem/lib/mpos/board/linux.py | 6 +++--- internal_filesystem/lib/mpos/main.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 1ef9556f..f6725998 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -22,9 +22,9 @@ #TFT_HOR_RES=240 #TFT_VER_RES=320 -# Bigger screen -#TFT_HOR_RES=640 -#TFT_VER_RES=480 +# LilyGo T-Display-S3 +#TFT_HOR_RES=320 +#TFT_VER_RES=170 # 4:3 DVD resolution: #TFT_HOR_RES=720 diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 81a5cbf0..9ba905ba 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -222,7 +222,7 @@ async def asyncio_repl(): try: import webrepl - webrepl.start(port=7890,password="MPOSweb26") # password max 9 characters + webrepl.start(port=7890,password="MPOSweb26") # password is max 9 characters except Exception as e: print(f"Could not start webrepl - this is normal on desktop systems: {e}") From cd95f55d0246ba620de3e1d29e15ff77281bc86a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Feb 2026 13:00:03 +0100 Subject: [PATCH 121/317] DownloadManager: fix http, double chunk size for speed --- .../assets/osupdate.py | 4 ++-- .../lib/mpos/net/download_manager.py | 23 ++++++++----------- 2 files changed, 12 insertions(+), 15 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 899b5977..11390c27 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -283,7 +283,7 @@ async def async_progress_callback(self, percent): Args: percent: Progress percentage with 2 decimal places (0.00 - 100.00) """ - print(f"OTA Update: {percent:.2f}%") + #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) @@ -304,7 +304,7 @@ async def async_speed_callback(self, bytes_per_second): else: speed_str = f"{bytes_per_second:.0f} B/s" - print(f"Download speed: {speed_str}") + #print(f"Download speed: {speed_str}") if self.has_foreground() and self.speed_label: self.speed_label.set_text(f"Speed: {speed_str}") diff --git a/internal_filesystem/lib/mpos/net/download_manager.py b/internal_filesystem/lib/mpos/net/download_manager.py index 2bc30843..b813ac28 100644 --- a/internal_filesystem/lib/mpos/net/download_manager.py +++ b/internal_filesystem/lib/mpos/net/download_manager.py @@ -15,7 +15,7 @@ """ # Constants -_DEFAULT_CHUNK_SIZE = 2048 # 2KB chunks +_DEFAULT_CHUNK_SIZE = 4 * 1024 _DEFAULT_TOTAL_SIZE = 100 * 1024 # 100KB default if Content-Length missing _MAX_RETRIES = 3 # Retry attempts per chunk _CHUNK_TIMEOUT_SECONDS = 10 # Timeout per chunk read @@ -97,19 +97,15 @@ async def _download_url_async(cls, url, outfile=None, total_size=None, "Cannot use both outfile and chunk_callback. " "Use outfile for saving to disk, or chunk_callback for streaming." ) - - # Create a new session for this request - try: - import aiohttp - except ImportError: - print("DownloadManager: aiohttp not available") - raise ImportError("aiohttp module not available") - - import ssl - sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - sslctx.verify_mode = ssl.CERT_OPTIONAL # CERT_REQUIRED might fail because MBEDTLS_ERR_SSL_CA_CHAIN_REQUIRED + + import aiohttp session = aiohttp.ClientSession() - print("DownloadManager: Created new aiohttp session") + sslctx = None # for http + if url.lower().startswith("https"): + import ssl + sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + sslctx.verify_mode = ssl.CERT_OPTIONAL # CERT_REQUIRED might fail because MBEDTLS_ERR_SSL_CA_CHAIN_REQUIRED + print(f"DownloadManager: Downloading {url}") fd = None @@ -238,6 +234,7 @@ async def _download_url_async(cls, url, outfile=None, total_size=None, if elapsed_ms >= _SPEED_UPDATE_INTERVAL_MS: # Calculate bytes per second bytes_per_second = (speed_bytes_since_last_update * 1000) / elapsed_ms + print(f"DownloadManager: Speed: {bytes_per_second} bytes / second") await speed_callback(bytes_per_second) # Reset for next interval speed_bytes_since_last_update = 0 From 0be8b5233bb705763adce17f63841eba04dbbd4c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Feb 2026 21:01:11 +0100 Subject: [PATCH 122/317] Unify esp32s3 and esp32s3_qemu builds --- scripts/build_mpos.sh | 18 +++++++++--------- scripts/mklittlefs.sh | 3 ++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index f0cb1dd2..a62f3914 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -14,7 +14,6 @@ if [ -z "$target" ]; then echo "Example: $0 macOS" echo "Example: $0 esp32" echo "Example: $0 esp32s3" - echo "Example: $0 esp32s3_qemu" exit 1 fi @@ -87,18 +86,17 @@ ln -sf ../../c_mpos "$codebasedir"/lvgl_micropython/ext_mod/c_mpos echo "Refreshing freezefs..." "$codebasedir"/scripts/freezefs_mount_builtin.sh -if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "esp32s3_qemu" ]; then +if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then extra_configs="" if [ "$target" == "esp32" ]; then BOARD=ESP32_GENERIC BOARD_VARIANT=SPIRAM - else # esp32s3 or esp32s3_qemu + else # esp32s3 BOARD=ESP32_GENERIC_S3 BOARD_VARIANT=SPIRAM_OCT - if [ "$target" == "esp32s3_qemu" ]; then - # CONFIG_ESPTOOLPY_FLASHMODE_DIO because QIO has an "off by 2 bytes" bug in qemu - # CONFIG_MBEDTLS_HARDWARE_* because these have bugs in qemu due to warning: [AES] Error reading from GDMA buffer - extra_configs="CONFIG_ESPTOOLPY_FLASHMODE_DIO=y CONFIG_MBEDTLS_HARDWARE_AES=n CONFIG_MBEDTLS_HARDWARE_SHA=n CONFIG_MBEDTLS_HARDWARE_MPI=n" + # These options disable hardware AES, SHA and MPI because they give warnings in QEMU: [AES] Error reading from GDMA buffer + # There's a 25% https download speed penalty for this, but that's usually not the bottleneck. + extra_configs="CONFIG_MBEDTLS_HARDWARE_AES=n CONFIG_MBEDTLS_HARDWARE_SHA=n CONFIG_MBEDTLS_HARDWARE_MPI=n" fi fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) @@ -114,14 +112,16 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "esp32s3_qem # --debug: enable debugging from ESP-IDF but makes copying files to it very slow so that's not added # --dual-core-threads: disabled GIL, run code on both CPUs # --task-stack-size={stack size in bytes} + # --py-freertos: add MicroPython FreeRTOS module to expose internals # CONFIG_* sets ESP-IDF options # listing processes on the esp32 still doesn't work because no esp32.vtask_list_threads() or something # CONFIG_FREERTOS_USE_TRACE_FACILITY=y # CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y - # CONFIG_ADC_MIC_TASK_CORE=1 because with the default (-1) it hangs the CPU - + # CONFIG_ADC_MIC_TASK_CORE=1 because with the default (-1) it hangs the CPU + # CONFIG_SPIRAM_XIP_FROM_PSRAM: load entire firmware into RAM to reduce SD vs PSRAM contention (recommended at https://github.com/MicroPythonOS/MicroPythonOS/issues/17) python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ + --py-freertos \ USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake \ USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake \ USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake \ diff --git a/scripts/mklittlefs.sh b/scripts/mklittlefs.sh index ead0f2c1..7f1bf890 100755 --- a/scripts/mklittlefs.sh +++ b/scripts/mklittlefs.sh @@ -9,5 +9,6 @@ mydir=$(dirname "$mydir") #size=0x520000 #~/sources/mklittlefs/mklittlefs -c "$mydir"/../../../internalsd_zips_removed_gb_romart -s "$size" internalsd_zips_removed_gb_romart.bin -size=0x520000 +size=0x520000 # 16MB filesystem +#size=0x460000 # 8MB filesystem ~/sources/mklittlefs/mklittlefs -c "$mydir"/../internal_filesystem/ -s "$size" internal_filesystem.bin From 505b8ac311bf14152fe82231daa89194a2786c29 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Feb 2026 21:39:45 +0100 Subject: [PATCH 123/317] Fix syntax --- scripts/build_mpos.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index a62f3914..01c701de 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -97,7 +97,6 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then # These options disable hardware AES, SHA and MPI because they give warnings in QEMU: [AES] Error reading from GDMA buffer # There's a 25% https download speed penalty for this, but that's usually not the bottleneck. extra_configs="CONFIG_MBEDTLS_HARDWARE_AES=n CONFIG_MBEDTLS_HARDWARE_SHA=n CONFIG_MBEDTLS_HARDWARE_MPI=n" - fi fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage @@ -129,7 +128,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y \ CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y \ CONFIG_ADC_MIC_TASK_CORE=1 \ - $extra_configs \ + $extra_configs \ "$frozenmanifest" popd From 5b8f612747e8737721c797c658707a0702eaee2c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Feb 2026 21:43:24 +0100 Subject: [PATCH 124/317] WifiService: print IP address on console after connecting --- internal_filesystem/lib/mpos/net/wifi_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 9e9468b6..0303c00b 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -137,7 +137,7 @@ def attempt_connecting(ssid, password, network_module=None, time_module=None): # Wait up to 10 seconds for connection for i in range(10): if wlan.isconnected(): - print(f"WifiService: Connected to '{ssid}' after {i+1} seconds") + print(f"WifiService: Connected to '{ssid}' after {i+1} seconds with IP: {wlan.ipconfig('addr4')}") # Sync time from NTP server if possible try: From 3be52f1cf7664fdafbf02423ea02f6286df8fdc4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Feb 2026 21:57:42 +0100 Subject: [PATCH 125/317] Fix build --- 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 01c701de..eae3e803 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -97,6 +97,8 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then # These options disable hardware AES, SHA and MPI because they give warnings in QEMU: [AES] Error reading from GDMA buffer # There's a 25% https download speed penalty for this, but that's usually not the bottleneck. extra_configs="CONFIG_MBEDTLS_HARDWARE_AES=n CONFIG_MBEDTLS_HARDWARE_SHA=n CONFIG_MBEDTLS_HARDWARE_MPI=n" + # --py-freertos: add MicroPython FreeRTOS module to expose internals + extra_configs="$extra_configs --py-freertos" fi manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage @@ -111,7 +113,6 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then # --debug: enable debugging from ESP-IDF but makes copying files to it very slow so that's not added # --dual-core-threads: disabled GIL, run code on both CPUs # --task-stack-size={stack size in bytes} - # --py-freertos: add MicroPython FreeRTOS module to expose internals # CONFIG_* sets ESP-IDF options # listing processes on the esp32 still doesn't work because no esp32.vtask_list_threads() or something # CONFIG_FREERTOS_USE_TRACE_FACILITY=y @@ -120,7 +121,6 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then # CONFIG_ADC_MIC_TASK_CORE=1 because with the default (-1) it hangs the CPU # CONFIG_SPIRAM_XIP_FROM_PSRAM: load entire firmware into RAM to reduce SD vs PSRAM contention (recommended at https://github.com/MicroPythonOS/MicroPythonOS/issues/17) python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ - --py-freertos \ USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake \ USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake \ USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake \ From f2c131342d13500ec952eea2dd095b964372098e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 27 Feb 2026 12:18:21 +0100 Subject: [PATCH 126/317] Add pathlib --- internal_filesystem/lib/README.md | 1 + internal_filesystem/lib/pathlib.py | 210 +++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 internal_filesystem/lib/pathlib.py diff --git a/internal_filesystem/lib/README.md b/internal_filesystem/lib/README.md index 25d05903..a5492472 100644 --- a/internal_filesystem/lib/README.md +++ b/internal_filesystem/lib/README.md @@ -10,3 +10,4 @@ This /lib folder contains: - https://github.com/micropython/micropython-lib/blob/master/python-stdlib/logging/logging.py version 0.6.2 # for About app - https://github.com/micropython/micropython-lib/blob/master/python-stdlib/shutil/shutil.py version 0.0.5 # for rmtree() - https://github.com/micropython/micropython-lib/blob/master/python-stdlib/unittest/unittest/__init__.py version 0.10.4 # for testing (also on-device) +- https://github.com/micropython/micropython-lib/blob/master/python-stdlib/pathlib/pathlib.py version 0.0.1 # for Path() diff --git a/internal_filesystem/lib/pathlib.py b/internal_filesystem/lib/pathlib.py new file mode 100644 index 00000000..e0f96137 --- /dev/null +++ b/internal_filesystem/lib/pathlib.py @@ -0,0 +1,210 @@ +import errno +import os + +from micropython import const + +_SEP = const("/") + + +def _mode_if_exists(path): + try: + return os.stat(path)[0] + except OSError as e: + if e.errno == errno.ENOENT: + return 0 + raise e + + +def _clean_segment(segment): + segment = str(segment) + if not segment: + return "." + segment = segment.rstrip(_SEP) + if not segment: + return _SEP + while True: + no_double = segment.replace(_SEP + _SEP, _SEP) + if no_double == segment: + break + segment = no_double + return segment + + +class Path: + def __init__(self, *segments): + segments_cleaned = [] + for segment in segments: + segment = _clean_segment(segment) + if segment[0] == _SEP: + segments_cleaned = [segment] + elif segment == ".": + continue + else: + segments_cleaned.append(segment) + + self._path = _clean_segment(_SEP.join(segments_cleaned)) + + def __truediv__(self, other): + return Path(self._path, str(other)) + + def __rtruediv__(self, other): + return Path(other, self._path) + + def __repr__(self): + return f'{type(self).__name__}("{self._path}")' + + def __str__(self): + return self._path + + def __eq__(self, other): + return self.absolute() == Path(other).absolute() + + def absolute(self): + path = self._path + cwd = os.getcwd() + if not path or path == ".": + return cwd + if path[0] == _SEP: + return path + return _SEP + path if cwd == _SEP else cwd + _SEP + path + + def resolve(self): + return self.absolute() + + def open(self, mode="r", encoding=None): + return open(self._path, mode, encoding=encoding) + + def exists(self): + return bool(_mode_if_exists(self._path)) + + def mkdir(self, parents=False, exist_ok=False): + try: + os.mkdir(self._path) + return + except OSError as e: + if e.errno == errno.EEXIST and exist_ok: + return + elif e.errno == errno.ENOENT and parents: + pass # handled below + else: + raise e + + segments = self._path.split(_SEP) + progressive_path = "" + if segments[0] == "": + segments = segments[1:] + progressive_path = _SEP + for segment in segments: + progressive_path += _SEP + segment + try: + os.mkdir(progressive_path) + except OSError as e: + if e.errno != errno.EEXIST: + raise e + + def is_dir(self): + return bool(_mode_if_exists(self._path) & 0x4000) + + def is_file(self): + return bool(_mode_if_exists(self._path) & 0x8000) + + def _glob(self, path, pattern, recursive): + # Currently only supports a single "*" pattern. + n_wildcards = pattern.count("*") + n_single_wildcards = pattern.count("?") + + if n_single_wildcards: + raise NotImplementedError("? single wildcards not implemented.") + + if n_wildcards == 0: + raise ValueError + elif n_wildcards > 1: + raise NotImplementedError("Multiple * wildcards not implemented.") + + prefix, suffix = pattern.split("*") + + for name, mode, *_ in os.ilistdir(path): + full_path = path + _SEP + name + if name.startswith(prefix) and name.endswith(suffix): + yield full_path + if recursive and mode & 0x4000: # is_dir + yield from self._glob(full_path, pattern, recursive=recursive) + + def glob(self, pattern): + """Iterate over this subtree and yield all existing files (of any + kind, including directories) matching the given relative pattern. + + Currently only supports a single "*" pattern. + """ + return self._glob(self._path, pattern, recursive=False) + + def rglob(self, pattern): + return self._glob(self._path, pattern, recursive=True) + + def stat(self): + return os.stat(self._path) + + def read_bytes(self): + with open(self._path, "rb") as f: + return f.read() + + def read_text(self, encoding=None): + with open(self._path, "r", encoding=encoding) as f: + return f.read() + + def rename(self, target): + os.rename(self._path, target) + + def rmdir(self): + os.rmdir(self._path) + + def touch(self, exist_ok=True): + if self.exists(): + if exist_ok: + return # TODO: should update timestamp + else: + # In lieue of FileExistsError + raise OSError(errno.EEXIST) + with open(self._path, "w"): + pass + + def unlink(self, missing_ok=False): + try: + os.unlink(self._path) + except OSError as e: + if not (missing_ok and e.errno == errno.ENOENT): + raise e + + def write_bytes(self, data): + with open(self._path, "wb") as f: + f.write(data) + + def write_text(self, data, encoding=None): + with open(self._path, "w", encoding=encoding) as f: + f.write(data) + + def with_suffix(self, suffix): + index = -len(self.suffix) or None + return Path(self._path[:index] + suffix) + + @property + def stem(self): + return self.name.rsplit(".", 1)[0] + + @property + def parent(self): + tokens = self._path.rsplit(_SEP, 1) + if len(tokens) == 2: + if not tokens[0]: + tokens[0] = _SEP + return Path(tokens[0]) + return Path(".") + + @property + def name(self): + return self._path.rsplit(_SEP, 1)[-1] + + @property + def suffix(self): + elems = self._path.rsplit(".", 1) + return "" if len(elems) == 1 else "." + elems[1] From 65f4a20c96f9f77ced5b2eda013ce9f0bfb35153 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 27 Feb 2026 12:40:29 +0100 Subject: [PATCH 127/317] test_wifi_service: fix and speedup --- .../lib/mpos/net/wifi_service.py | 22 +++++++++++++---- internal_filesystem/lib/mpos/testing/mocks.py | 13 ++++++++++ tests/test_wifi_service.py | 24 +++++++++++-------- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 0303c00b..07cb9cec 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -47,7 +47,7 @@ class WifiService: _desktop_connected_ssid = None @staticmethod - def connect(network_module=None): + def connect(network_module=None, time_module=None): """ Scan for available networks and connect to the first saved network found. Networks are tried in order of signal strength (strongest first). @@ -55,6 +55,7 @@ def connect(network_module=None): Args: network_module: Network module for dependency injection (testing) + time_module: Time module for dependency injection (testing) Returns: bool: True if successfully connected, False otherwise @@ -79,7 +80,12 @@ def connect(network_module=None): password = WifiService.access_points.get(ssid).get("password") print(f"WifiService: Attempting to connect to saved network '{ssid}'") - if WifiService.attempt_connecting(ssid, password, network_module=network_module): + if WifiService.attempt_connecting( + ssid, + password, + network_module=network_module, + time_module=time_module, + ): print(f"WifiService: Connected to '{ssid}'") return True else: @@ -93,7 +99,12 @@ def connect(network_module=None): password = config.get("password") print(f"WifiService: Attempting hidden network '{ssid}'") - if WifiService.attempt_connecting(ssid, password, network_module=network_module): + if WifiService.attempt_connecting( + ssid, + password, + network_module=network_module, + time_module=time_module, + ): print(f"WifiService: Connected to hidden network '{ssid}'") return True else: @@ -201,7 +212,10 @@ def auto_connect(network_module=None, time_module=None): print("WifiService: Simulated connection complete") else: # Attempt to connect to saved networks - if WifiService.connect(network_module=network_module): + if WifiService.connect( + network_module=network_module, + time_module=time_module, + ): print("WifiService: Auto-connect successful") else: print("WifiService: Auto-connect failed") diff --git a/internal_filesystem/lib/mpos/testing/mocks.py b/internal_filesystem/lib/mpos/testing/mocks.py index cd3a5a42..94863e62 100644 --- a/internal_filesystem/lib/mpos/testing/mocks.py +++ b/internal_filesystem/lib/mpos/testing/mocks.py @@ -412,6 +412,19 @@ def ifconfig(self): if self._connected: return ('192.168.1.100', '255.255.255.0', '192.168.1.1', '8.8.8.8') return ('0.0.0.0', '0.0.0.0', '0.0.0.0', '0.0.0.0') + + def ipconfig(self, key=None): + """Return IP configuration details, mirroring network.WLAN.ipconfig.""" + config = self.ifconfig() + mapping = { + 'addr4': config[0], + 'netmask4': config[1], + 'gateway4': config[2], + 'dns4': config[3], + } + if key is None: + return mapping + return mapping.get(key) def scan(self): """Scan for available networks.""" diff --git a/tests/test_wifi_service.py b/tests/test_wifi_service.py index 705b85d4..be4cf493 100644 --- a/tests/test_wifi_service.py +++ b/tests/test_wifi_service.py @@ -102,7 +102,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) @@ -114,7 +114,7 @@ def test_connect_with_no_saved_networks(self): mock_wlan = mock_network.WLAN(mock_network.STA_IF) mock_wlan._scan_results = [(b"UnsavedNetwork", -50, 1, 3, b"", 0)] - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertFalse(result) @@ -128,7 +128,7 @@ def test_connect_when_no_saved_networks_available(self): mock_wlan = mock_network.WLAN(mock_network.STA_IF) mock_wlan._scan_results = [(b"DifferentNetwork", -50, 1, 3, b"", 0)] - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertFalse(result) @@ -166,6 +166,8 @@ def mock_isconnected(): ) self.assertTrue(result) + # Should not sleep once connected immediately + self.assertEqual(len(mock_time.get_sleep_calls()), 0) def test_connection_timeout(self): """Test connection timeout after 10 attempts.""" @@ -227,6 +229,8 @@ def mock_active(state=None): self.assertFalse(result) # Should have checked less than 10 times (aborted early) self.assertTrue(check_count[0] < 10) + # Should have slept only until abort + self.assertEqual(len(mock_time.get_sleep_calls()), 2) def test_connection_error_handling(self): """Test handling of connection errors.""" @@ -501,7 +505,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) # Should try strongest first (-45 dBm) @@ -538,7 +542,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) # Verify order: strongest to weakest @@ -572,7 +576,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) # Should only try once (first is strongest and succeeds) @@ -618,7 +622,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertTrue(result) # Expected order: Channel 8 (-47), Baptistus (-48), telenet (-70), Galaxy (-83) @@ -654,7 +658,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertFalse(result) # No connection succeeded # Verify all 3 were attempted in RSSI order @@ -684,7 +688,7 @@ def mock_connect(ssid, password): mock_wlan.connect = mock_connect - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) self.assertFalse(result) # No attempts should be made @@ -706,7 +710,7 @@ def test_rssi_logging_shows_signal_strength(self): # The connect method now logs "Found network 'TestNet' (RSSI: -55 dBm)" # This test just verifies it doesn't crash - result = WifiService.connect(network_module=mock_network) + result = WifiService.connect(network_module=mock_network, time_module=MockTime()) # Since mock doesn't actually connect, this will likely be False # but the important part is the code runs without error From 19420163b745e3e724f54b41c2049e0b2b95a8db Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 27 Feb 2026 14:11:56 +0100 Subject: [PATCH 128/317] Move import closer to where it's needed --- internal_filesystem/lib/mpos/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 9ba905ba..66e20cd8 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -1,4 +1,3 @@ -import task_handler import _thread import lvgl as lv @@ -187,6 +186,7 @@ def custom_exception_handler(e): #lv.deinit() import sys +import task_handler if sys.platform == "esp32": mpos.ui.task_handler = task_handler.TaskHandler(duration=5, exception_hook=custom_exception_handler) # 1ms gives highest framerate on esp32-s3's but might have side effects? else: From d6d05bf0f5085171cf96124494ddce2be53b21fd Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 27 Feb 2026 15:55:26 +0100 Subject: [PATCH 129/317] Simplify --- .../lib/mpos/board/lilygo_t_display_s3.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py index 0b6fd2c9..7db41019 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_display_s3.py @@ -52,12 +52,11 @@ backlight_on_state=st7789.STATE_PWM, offset_x=0, offset_y=35 -) +) # this will trigger lv.init() mpos.ui.main_display.set_power(True) # set RD pin to high before the rest, otherwise garbled output mpos.ui.main_display.init() mpos.ui.main_display.set_backlight(100) # works -lv.init() mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._270) # must be done after initializing display and creating the touch drivers, to ensure proper handling mpos.ui.main_display.set_color_inversion(True) @@ -119,7 +118,7 @@ def keypad_read_cb(indev, data): if near_simul or single_press_wait: dt_a = time.ticks_diff(current_time, last_a_down_time) if last_a_down_time else None dt_b = time.ticks_diff(current_time, last_b_down_time) if last_b_down_time else None - print(f"combo guard: a={btn_a_pressed} b={btn_b_pressed} near={near_simul} wait={single_press_wait} dt_a={dt_a} dt_b={dt_b}") + #print(f"combo guard: a={btn_a_pressed} b={btn_b_pressed} near={near_simul} wait={single_press_wait} dt_a={dt_a} dt_b={dt_b}") # While in an on-screen keyboard, PREV button is LEFT and NEXT button is RIGHT focus_group = lv.group_get_default() @@ -156,7 +155,7 @@ def keypad_read_cb(indev, data): key_press_start = 0 last_repeat_time = 0 elif last_key is None or current_key != last_key: - print(f"New key press: {current_key}") + #print(f"New key press: {current_key}") data.key = current_key data.state = lv.INDEV_STATE.PRESSED last_key = current_key @@ -164,24 +163,25 @@ def keypad_read_cb(indev, data): key_press_start = current_time last_repeat_time = current_time else: - print(f"key repeat because current_key {current_key} == last_key {last_key}") + #print(f"key repeat because current_key {current_key} == last_key {last_key}") elapsed = time.ticks_diff(current_time, key_press_start) since_last_repeat = time.ticks_diff(current_time, last_repeat_time) if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS: next_state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED if current_key == lv.KEY.PREV: - print("Repeated PREV does not do anything, instead it triggers ESC (back) if long enough") + #print("Repeated PREV does not do anything, instead it triggers ESC (back) if long enough") if since_last_repeat > REPEAT_PREV_BECOMES_BACK: - print("back button trigger!") + print("Long press on PREV triggered back button") data.key = lv.KEY.ESC data.state = next_state last_key = current_key last_state = data.state last_repeat_time = current_time else: - print("repeat PREV ignored because not pressed long enough") + #print("repeat PREV ignored because not pressed long enough") + pass else: - print("Send a new PRESSED/RELEASED pair for repeat") + #print("Send a new PRESSED/RELEASED pair for repeat") data.key = current_key data.state = next_state last_key = current_key From 484695d87dd38f7a5ae942e4280984f1f13fba1c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 27 Feb 2026 15:56:34 +0100 Subject: [PATCH 130/317] Update lvgl_micropython --- lvgl_micropython | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lvgl_micropython b/lvgl_micropython index ec3ecd41..b7734f77 160000 --- a/lvgl_micropython +++ b/lvgl_micropython @@ -1 +1 @@ -Subproject commit ec3ecd4150e81f73c9eaa0575d62437318b4dee8 +Subproject commit b7734f77497d3b09d079627e465519af1129b328 From 3459440a59dcaea38706130dddb60f1cc17205d3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 27 Feb 2026 17:22:14 +0100 Subject: [PATCH 131/317] Simplify --- .../com.micropythonos.launcher/assets/launcher.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py b/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py index 7459fc59..440ee6b4 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py @@ -122,15 +122,9 @@ def onResume(self, screen): label.set_style_text_align(lv.TEXT_ALIGN.CENTER, lv.PART.MAIN) # ----- events -------------------------------------------------- - app_cont.add_event_cb( - lambda e, fullname=app.fullname: AppManager.start_app(fullname), - lv.EVENT.CLICKED, None) - app_cont.add_event_cb( - lambda e, cont=app_cont: self.focus_app_cont(cont), - lv.EVENT.FOCUSED, None) - app_cont.add_event_cb( - lambda e, cont=app_cont: self.defocus_app_cont(cont), - lv.EVENT.DEFOCUSED, None) + app_cont.add_event_cb(lambda e, fullname=app.fullname: AppManager.start_app(fullname),lv.EVENT.CLICKED, None) + app_cont.add_event_cb(lambda e, cont=app_cont: self.focus_app_cont(cont),lv.EVENT.FOCUSED, None) + app_cont.add_event_cb(lambda e, cont=app_cont: self.defocus_app_cont(cont),lv.EVENT.DEFOCUSED, None) if focusgroup: focusgroup.add_obj(app_cont) From eb1d7fbe240b196550d0092aa3123b0758c268ed Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 27 Feb 2026 17:22:19 +0100 Subject: [PATCH 132/317] About app: make labels focusable to allow scroll on devices without touch screen --- .../com.micropythonos.about/assets/about.py | 76 ++++++++++++------- 1 file changed, 47 insertions(+), 29 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 f8c1e6ef..66421d46 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py @@ -8,35 +8,6 @@ class About(Activity): logger = logging.getLogger(__file__) logger.setLevel(logging.INFO) - def _add_label(self, parent, text, is_header=False, margin_top=DisplayMetrics.pct_of_height(5)): - """Helper to create and add a label with text.""" - label = lv.label(parent) - label.set_text(text) - if is_header: - primary_color = lv.theme_get_color_primary(None) - label.set_style_text_color(primary_color, lv.PART.MAIN) - label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) - label.set_style_margin_top(margin_top, lv.PART.MAIN) - label.set_style_margin_bottom(DisplayMetrics.pct_of_height(2), lv.PART.MAIN) - else: - label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) - label.set_style_margin_bottom(2, lv.PART.MAIN) - return label - - def _add_disk_info(self, screen, path): - """Helper to add disk usage info for a given path.""" - import os - try: - stat = os.statvfs(path) - total_space = stat[0] * stat[2] - free_space = stat[0] * stat[3] - used_space = total_space - free_space - self._add_label(screen, f"Total space {path}: {total_space} bytes") - self._add_label(screen, f"Free space {path}: {free_space} bytes") - self._add_label(screen, f"Used space {path}: {used_space} bytes") - except Exception as e: - self.logger.warning(f"About app could not get info on {path} filesystem: {e}") - def onCreate(self): screen = lv.obj() screen.set_style_border_width(0, lv.PART.MAIN) @@ -183,3 +154,50 @@ def onCreate(self): self._add_disk_info(screen, '/sdcard') self.setContentView(screen) + + @staticmethod + def _focus_obj(event): + target = event.get_target_obj() + target.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN) + target.set_style_border_width(1, lv.PART.MAIN) + target.scroll_to_view(True) + + @staticmethod + def _defocus_obj(event): + target = event.get_target_obj() + target.set_style_border_width(0, lv.PART.MAIN) + + def _add_label(self, parent, text, is_header=False, margin_top=DisplayMetrics.pct_of_height(5)): + """Helper to create and add a label with text.""" + label = lv.label(parent) + label.set_text(text) + # Make labels focusable to allow scroll on devices without touch screen + label.add_event_cb(self._focus_obj, lv.EVENT.FOCUSED, None) + label.add_event_cb(self._defocus_obj, lv.EVENT.DEFOCUSED, None) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(label) + if is_header: + primary_color = lv.theme_get_color_primary(None) + label.set_style_text_color(primary_color, lv.PART.MAIN) + label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) + label.set_style_margin_top(margin_top, lv.PART.MAIN) + label.set_style_margin_bottom(DisplayMetrics.pct_of_height(2), lv.PART.MAIN) + else: + label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) + label.set_style_margin_bottom(2, lv.PART.MAIN) + return label + + def _add_disk_info(self, screen, path): + """Helper to add disk usage info for a given path.""" + import os + try: + stat = os.statvfs(path) + total_space = stat[0] * stat[2] + free_space = stat[0] * stat[3] + used_space = total_space - free_space + self._add_label(screen, f"Total space {path}: {total_space} bytes") + self._add_label(screen, f"Free space {path}: {free_space} bytes") + self._add_label(screen, f"Used space {path}: {used_space} bytes") + except Exception as e: + self.logger.warning(f"About app could not get info on {path} filesystem: {e}") From 8f082059c0f70057a90c5c36305301f17c288ab1 Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Sat, 28 Feb 2026 00:05:31 +0100 Subject: [PATCH 133/317] Enhance ScanBluetooth app Add a info column with init information and "unique devices" and "scan" count. Use `TaskManager` that makes the UI a lot more responsive. Use better scan settings to maximize detection rate. --- .../META-INF/MANIFEST.JSON | 2 +- .../assets/scan_bluetooth.py | 185 ++++++++++-------- 2 files changed, 103 insertions(+), 84 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON index bc61ab72..53754530 100644 --- a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/META-INF/MANIFEST.JSON @@ -6,7 +6,7 @@ "icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.scan_bluetooth/icons/com.micropythonos.scan_bluetooth_0.0.1_64x64.png", "download_url": "https://apps.micropythonos.com/apps/com.micropythonos.scan_bluetooth/mpks/com.micropythonos.scan_bluetooth_0.0.1.mpk", "fullname": "com.micropythonos.scan_bluetooth", -"version": "0.0.1", +"version": "0.1.0", "category": "development", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py index 9ac9a1ee..0eb67a2d 100644 --- a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py +++ b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py @@ -3,27 +3,32 @@ https://docs.micropython.org/en/latest/library/bluetooth.html """ -import time - try: import bluetooth except ImportError: # Linux test runner may not provide bluetooth module bluetooth = None +import sys + import lvgl as lv from micropython import const -from mpos import Activity +from mpos import Activity, TaskManager -SCAN_DURATION = const(1000) # Duration of each BLE scan in milliseconds -_IRQ_SCAN_RESULT = const(5) +# Scan for 5 seconds, +SCAN_DURATION_MS = const(5000) # Duration of each BLE scan in milliseconds +# with very low interval/window (to maximize detection rate): +INTERVAL_US = const(30000) +WINDOW_US = const(30000) +_IRQ_SCAN_RESULT = const(5) +_IRQ_SCAN_DONE = const(6) # BLE Advertising Data Types (Standardized by Bluetooth SIG) -_ADV_TYPE_NAME = const(0x09) +_ADV_TYPE_SHORT_NAME = const(8) +_ADV_TYPE_NAME = const(9) -def decode_field(payload: bytes, adv_type: int) -> list: - results = [] +def decode_name(payload: bytes) -> str: i = 0 payload_len = len(payload) while i < payload_len: @@ -31,40 +36,13 @@ def decode_field(payload: bytes, adv_type: int) -> list: if length == 0 or i + length >= payload_len: break field_type = payload[i + 1] - if field_type == adv_type: - results.append(payload[i + 2 : i + length + 1]) + if field_type in (_ADV_TYPE_SHORT_NAME, _ADV_TYPE_NAME): + if new_name := payload[i + 2 : i + length + 1]: + return str(new_name, "utf-8") + else: + print(f"Unsupported: {field_type=} with {length=}") i += length + 1 - return results - - -class BluetoothScanner: - def __init__(self, device_callback): - if bluetooth is None: - raise RuntimeError("Bluetooth module not available") - self.device_callback = device_callback - self.ble = bluetooth.BLE() - self.ble.irq(self.ble_irq_handler) - - def __enter__(self): - print("Activating BLE") - self.ble.active(True) - return self - - def ble_irq_handler(self, event: int, data: tuple) -> None: - if event == _IRQ_SCAN_RESULT: - addr_type, addr, adv_type, rssi, adv_data = data - addr = ":".join(f"{b:02x}" for b in addr) - names = decode_field(adv_data, _ADV_TYPE_NAME) - name = str(names[0], "utf-8") if names else "Unknown" - self.device_callback(addr, rssi, name) - - def scan(self, duration_ms: int): - print(f"BLE scanning for {duration_ms}ms...") - self.ble.gap_scan(duration_ms, 20000, 10000) - - def __exit__(self, exc_type, exc_val, exc_tb): - print("Deactivating BLE") - self.ble.active(False) + return "Unknown" def set_dynamic_column_widths(table, font=None, padding=8): @@ -85,22 +63,31 @@ def set_cell_value(table, *, row: int, values: tuple): class ScanBluetooth(Activity): - refresh_timer = None - def onCreate(self): - screen = lv.obj() - screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) - screen.set_style_pad_all(0, 0) - screen.set_size(lv.pct(100), lv.pct(100)) + main_content = lv.obj() + main_content.set_flex_flow(lv.FLEX_FLOW.COLUMN) + main_content.set_style_pad_all(0, 0) + main_content.set_size(lv.pct(100), lv.pct(100)) + + info_column = lv.obj(main_content) + info_column.set_flex_flow(lv.FLEX_FLOW.COLUMN) + info_column.set_style_pad_all(1, 1) + info_column.set_size(lv.pct(100), lv.SIZE_CONTENT) + + self.info_label = lv.label(info_column) + self.info_label.set_style_text_font(lv.font_montserrat_14, 0) if bluetooth is None: - label = lv.label(screen) - label.set_text("Bluetooth not available on this platform") - label.center() - self.setContentView(screen) + self.info("Bluetooth not available on this platform") + self.setContentView(main_content) return - self.table = lv.table(screen) + tabel_column = lv.obj(main_content) + tabel_column.set_flex_flow(lv.FLEX_FLOW.COLUMN) + tabel_column.set_style_pad_all(0, 0) + tabel_column.set_size(lv.pct(100), lv.SIZE_CONTENT) + + self.table = lv.table(tabel_column) set_cell_value( self.table, row=0, @@ -108,52 +95,84 @@ def onCreate(self): ) set_dynamic_column_widths(self.table) + self.scan_count = 0 self.mac2column = {} self.mac2counts = {} - self.scanner_cm = BluetoothScanner(device_callback=self.scan_callback) - self.scanner = self.scanner_cm.__enter__() # Activate BLE + self.ble = bluetooth.BLE() - self.setContentView(screen) + self.setContentView(main_content) - def scan_callback(self, addr, rssi, name): - if not (column_index := self.mac2column.get(addr)): - column_index = len(self.mac2column) + 1 - self.mac2column[addr] = column_index - self.mac2counts[addr] = 1 - else: - self.mac2counts[addr] += 1 + def info(self, text): + print(text) + self.info_label.set_text(text) - set_cell_value( - self.table, - row=column_index, - values=( - str(column_index), - addr, - f"{rssi} dBm", - str(self.mac2counts[addr]), - name, - ), - ) + async def ble_scan(self): + """Check sensor every second""" + while self.scanning: + print(f"async scan for {SCAN_DURATION_MS}ms...") + self.ble.gap_scan(SCAN_DURATION_MS, INTERVAL_US, WINDOW_US, True) + await TaskManager.sleep_ms(SCAN_DURATION_MS + 100) def onResume(self, screen): super().onResume(screen) if bluetooth is None: return - def update(timer): - self.scanner.scan(SCAN_DURATION) - set_dynamic_column_widths(self.table) - time.sleep_ms(SCAN_DURATION + 100) # Wait ? - print(f"Scan complete: {len(self.mac2column)} unique devices") + self.info("Activating Bluetooth...") + self.ble.irq(self.ble_irq_handler) + self.ble.active(True) - self.refresh_timer = lv.timer_create(update, SCAN_DURATION + 1000, None) + self.scanning = True + TaskManager.create_task(self.ble_scan()) def onPause(self, screen): super().onPause(screen) if bluetooth is None: return - self.scanner.__exit__(None, None, None) # Deactivate BLE - if self.refresh_timer: - self.refresh_timer.delete() - self.refresh_timer = None + + self.scanning = False + + self.info("Stop scanning...") + self.ble.gap_scan(None) + self.info("Deactivating BLE...") + self.ble.active(False) + self.info("BLE deactivated") + + def ble_irq_handler(self, event: int, data: tuple) -> None: + try: + if event == _IRQ_SCAN_RESULT: + addr_type, addr, adv_type, rssi, adv_data = data + addr = ":".join(f"{b:02x}" for b in addr) + print(f"{addr=} {rssi=} {len(adv_data)=}") + name = decode_name(adv_data) + + if not (column_index := self.mac2column.get(addr)): + column_index = len(self.mac2column) + 1 + self.mac2column[addr] = column_index + self.mac2counts[addr] = 1 + else: + self.mac2counts[addr] += 1 + + set_cell_value( + self.table, + row=column_index, + values=( + str(column_index), + addr, + f"{rssi} dBm", + str(self.mac2counts[addr]), + name, + ), + ) + elif event == _IRQ_SCAN_DONE: + set_dynamic_column_widths(self.table) + self.scan_count += 1 + self.info( + f"{len(self.mac2column)} unique devices (Scan {self.scan_count})" + ) + else: + print(f"Ignored BLE {event=}") + except Exception as e: + sys.print_exception(e) + print(f"Error in BLE IRQ handler {event=}: {e}") From 896f74d25ffffea2dd09accc6bb9927d7233a00a Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Sat, 28 Feb 2026 10:49:36 +0100 Subject: [PATCH 134/317] ScanBluetooth: Don't loose a BLE device name If we get a name of a device, don't lost it by overwrite it with "unknown" ;) --- .../assets/scan_bluetooth.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py index 0eb67a2d..efbf7c4d 100644 --- a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py +++ b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py @@ -28,7 +28,7 @@ _ADV_TYPE_NAME = const(9) -def decode_name(payload: bytes) -> str: +def decode_name(payload: bytes) -> str | None: i = 0 payload_len = len(payload) while i < payload_len: @@ -42,7 +42,6 @@ def decode_name(payload: bytes) -> str: else: print(f"Unsupported: {field_type=} with {length=}") i += length + 1 - return "Unknown" def set_dynamic_column_widths(table, font=None, padding=8): @@ -98,6 +97,7 @@ def onCreate(self): self.scan_count = 0 self.mac2column = {} self.mac2counts = {} + self.mac2name = {} self.ble = bluetooth.BLE() @@ -145,7 +145,10 @@ def ble_irq_handler(self, event: int, data: tuple) -> None: addr_type, addr, adv_type, rssi, adv_data = data addr = ":".join(f"{b:02x}" for b in addr) print(f"{addr=} {rssi=} {len(adv_data)=}") - name = decode_name(adv_data) + if name := decode_name(adv_data): + self.mac2name[addr] = name + else: + name = self.mac2name.get(addr, "Unknown") if not (column_index := self.mac2column.get(addr)): column_index = len(self.mac2column) + 1 From 4f82bb9b9f3428372c3b32d93677d54f84dbd620 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 28 Feb 2026 18:18:05 +0100 Subject: [PATCH 135/317] stream_record_adc.py: increase attenuation --- internal_filesystem/lib/mpos/audio/stream_record_adc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_record_adc.py b/internal_filesystem/lib/mpos/audio/stream_record_adc.py index d876591d..8bf1a114 100644 --- a/internal_filesystem/lib/mpos/audio/stream_record_adc.py +++ b/internal_filesystem/lib/mpos/audio/stream_record_adc.py @@ -54,7 +54,8 @@ class ADCRecordStream: DEFAULT_ADC_PIN = 1 # GPIO1 on Fri3d 2026 DEFAULT_ADC_UNIT = 0 # ADC_UNIT_1 = 0 DEFAULT_ADC_CHANNEL = 0 # ADC_CHANNEL_0 = 0 (GPIO1) - DEFAULT_ATTEN = 2 # ADC_ATTEN_DB_6 = 2 + #DEFAULT_ATTEN = 2 # ADC_ATTEN_DB_6 + DEFAULT_ATTEN = 3 # ADC_ATTEN_DB_12 == ADC_ATTEN_DB_11 def __init__(self, file_path, duration_ms, sample_rate, adc_pin=None, on_complete=None, **adc_config): From ce603eecd09860c7ff89d922840114883c7916a3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 28 Feb 2026 18:18:30 +0100 Subject: [PATCH 136/317] Audio: disable MCK after playback if enabled --- internal_filesystem/lib/mpos/audio/stream_wav.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index b688ae7e..255f81b8 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -195,6 +195,7 @@ def __init__( self._keep_running = True self._is_playing = False self._i2s = None + self._mck_pwm = None self._progress_samples = 0 self._total_samples = 0 self._duration_ms = None @@ -458,11 +459,11 @@ def play(self): from machine import Pin, PWM # Add MCLK generation on GPIO2 try: - mck_pwm = PWM(mck_pin) + self._mck_pwm = PWM(mck_pin) # Set frequency to sample_rate * 256 (common ratio for CJC4334H auto-detect) # Use duty_u16 for finer control (0–65535 range, 32768 = 50%) - mck_pwm.freq(playback_rate * 256) - mck_pwm.duty_u16(32768) # 50% duty cycle + self._mck_pwm.freq(playback_rate * 256) + self._mck_pwm.duty_u16(32768) # 50% duty cycle print(f"MCLK PWM started on GPIO2 at {playback_rate * 256} Hz") except Exception as e: print(f"MCLK PWM init failed: {e}") @@ -584,8 +585,15 @@ def play(self): finally: self._is_playing = False if self._i2s: - self._i2s.deinit() + print("Done playing, doing i2s deinit") + self._i2s.deinit() # disabling this does not fix the "play just once" issue self._i2s = None + if self._mck_pwm: + try: + print("Done playing, stopping MCLK PWM") + self._mck_pwm.deinit() + finally: + self._mck_pwm = None def set_volume(self, vol): self.volume = vol From ab29a39a80d178511bc0bb0194901ba30404716f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 28 Feb 2026 22:37:41 +0100 Subject: [PATCH 137/317] Add scripts --- partitions_with_retro-go.csv | 19 ++++++++++++++++++ scripts/make_image.sh | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 partitions_with_retro-go.csv create mode 100755 scripts/make_image.sh diff --git a/partitions_with_retro-go.csv b/partitions_with_retro-go.csv new file mode 100644 index 00000000..00c66f8a --- /dev/null +++ b/partitions_with_retro-go.csv @@ -0,0 +1,19 @@ +# Partition table for Fri3D Camp 2024 Badge with ESP-IDF OTA support using 16MB flash +# +# Also present in flash: +# 0x0 images/bootloader.bin +# 0x8000 images/partition-table.bin +# +# Notes: +# - app partitions should be aligned at 0x10000 (64k block) +# - otadata size should be 0x2000 +# +# Name, Type, SubType, Offset, Size, Flags +otadata, data, ota, 0x9000, 0x2000, +nvs, data, nvs, 0xb000, 0x5000, +ota_0,app,ota_0,0x20000,0x400000 +ota_1,app,ota_1,0x420000,0x400000 +launcher, app, ota_2, 0x820000, 0x100000, +retro-core, app, ota_3, 0x930000, 0xd0000 +prboom-go, app, ota_4, 0xa00000, 0xe0000, +vfs, data, fat, 0xae0000, 0x520000 diff --git a/scripts/make_image.sh b/scripts/make_image.sh new file mode 100755 index 00000000..2121aa87 --- /dev/null +++ b/scripts/make_image.sh @@ -0,0 +1,37 @@ +# Experimental quick and dirty script to assemble an ESP32 firmware image based on a partition table, internal_filesystem directory, bootloader, and ESP32 "app" binary +mydir=$(readlink -f "$0") +mydir=$(dirname "$mydir") +# This needs python and the esptool + +python3 lvgl_micropython/lib/esp-idf/components/partition_table/gen_esp32part.py --flash-size 16MB partitions_with_retro-go.csv > partitions_with_retro-go_16mb.bin +#python3 lvgl_micropython/lib/esp-idf/components/partition_table/gen_esp32part.py --flash-size 4MB partitions_4mb.csv > partitions_4mb.bin +#python3 lvgl_micropython/lib/esp-idf/components/partition_table/gen_esp32part.py --flash-size 8MB partitions_8mb.csv > partitions_8mb.bin + +if [ $? -ne 0 ]; then + echo "ERROR: Converting partition csv to bin failed!" + exit 1 +fi + +"$mydir"/../scripts/mklittlefs.sh + +prboom="~/projects/MicroPythonOS/claude/retro-go/prboom-go/build/prboom-go.bin" +launcher="~/projects/MicroPythonOS/claude/retro-go/launcher/build/launcher.bin" +core="~/projects/MicroPythonOS/claude/retro-go/retro-core/build/retro-core.bin" +#ls -al "$launcher" "$core" "$prboom" + + +#outdir=lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/ +#~/.espressif/python_env/*/bin/python -m esptool --chip esp32 merge_bin --fill-flash-size=16MB --output image_esp32.bin 0x1000 "$outdir"/bootloader/bootloader.bin 0x8000 partitions_with_retro-go.bin 0x20000 "$outdir"/micropython.bin 0x820000 "$launcher" 0x930000 "$core" 0xa00000 "$prboom" # 0xae0000 "$mydir"/../internalsd_zips_removed_gb_romart.bin $@ + +outdir=lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/ +rm image_esp32s3.bin +#~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 merge_bin --fill-flash-size=4MB --output image_esp32s3.bin 0x0 "$outdir"/bootloader/bootloader.bin 0x8000 partitions_for_qemu.bin 0x20000 "$outdir"/micropython.bin # 0x820000 "$launcher" 0x930000 "$core" 0xa00000 "$prboom" # 0xae0000 "$mydir"/../internalsd_zips_removed_gb_romart.bin $@ +#~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 merge_bin --fill-flash-size=8MB --output image_esp32s3.bin 0x0 "$outdir"/bootloader/bootloader.bin 0x8000 partitions_for_qemu.bin 0x20000 "$outdir"/micropython.bin 0x3A0000 "$mydir"/../internal_filesystem.bin $@ +~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 merge_bin --fill-flash-size=16MB --output image_esp32s3.bin 0x0 "$outdir"/bootloader/bootloader.bin 0x8000 partitions_with_retro-go_16mb.bin 0x20000 "$outdir"/micropython.bin 0x820000 "$launcher" 0x930000 "$core" 0xa00000 "$prboom" 0xae0000 "$mydir"/../internal_filesystem.bin $@ + +# Building an image based on an Arduino IDE build also works, although I only tried arduino-esp32 v2.x and not to v3.x +#outdir=~/.cache/arduino/sketches/4012C161135E5B60169BDFEA7F67E0C6 +#sketch=Test_Read_Flash.ino +#outdir=~/.cache/arduino/sketches/DF69B76A41013B091A4C9C10734C1710 +#sketch=Test_Download_File.ino +#~/.espressif/python_env/*/bin/python -m esptool --chip esp32s3 merge_bin --fill-flash-size=16MB --output image_esp32s3.bin 0x0 "$outdir"/"$sketch".bootloader.bin 0x8000 "$outdir"/"$sketch".partitions.bin 0x10000 "$outdir"/"$sketch".bin From ffd2a6863691a963e2b940018fd651d115a0857b Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 1 Mar 2026 22:45:08 +0100 Subject: [PATCH 138/317] Fix typo in cst816s.py --- internal_filesystem/lib/drivers/indev/cst816s.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal_filesystem/lib/drivers/indev/cst816s.py b/internal_filesystem/lib/drivers/indev/cst816s.py index 64255ac8..93306002 100644 --- a/internal_filesystem/lib/drivers/indev/cst816s.py +++ b/internal_filesystem/lib/drivers/indev/cst816s.py @@ -207,7 +207,7 @@ def __init__( print('FW Version:', hex(self._rx_buf[0])) if chip_id not in (_ChipIDValue, _ChipIDValue2): - raise RuntimeError(f'Incorrect chip id ({hex(_ChipIDValue)})') + raise RuntimeError(f'Incorrect chip id ({hex(chip_id)})') self._write_reg(_IrqCtl, _EnTouch | _EnChange) From f492278b992c918aba9ecfc263deeb0fab2c5bf7 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 1 Mar 2026 22:45:30 +0100 Subject: [PATCH 139/317] Improve lilygo_t_watch_s3_plus --- .../lib/drivers/indev/__init__.py | 10 ++ .../lib/drivers/indev/focaltech_touch.py | 124 ++++++++++++++++++ .../lib/drivers/indev/ft6x36.py | 37 ++++++ .../lib/mpos/board/lilygo_t_watch_s3_plus.py | 20 ++- .../board/waveshare_esp32_s3_touch_lcd_2.py | 3 +- internal_filesystem/lib/mpos/main.py | 9 +- 6 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 internal_filesystem/lib/drivers/indev/__init__.py create mode 100644 internal_filesystem/lib/drivers/indev/focaltech_touch.py create mode 100644 internal_filesystem/lib/drivers/indev/ft6x36.py diff --git a/internal_filesystem/lib/drivers/indev/__init__.py b/internal_filesystem/lib/drivers/indev/__init__.py new file mode 100644 index 00000000..b51d2f04 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/__init__.py @@ -0,0 +1,10 @@ +"""Input device drivers package helpers.""" + +try: + import sys + from . import focaltech_touch as _focaltech_touch + + if "focaltech_touch" not in sys.modules: + sys.modules["focaltech_touch"] = _focaltech_touch +except Exception: + pass diff --git a/internal_filesystem/lib/drivers/indev/focaltech_touch.py b/internal_filesystem/lib/drivers/indev/focaltech_touch.py new file mode 100644 index 00000000..5e0f6d1a --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/focaltech_touch.py @@ -0,0 +1,124 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +from micropython import const # NOQA +import pointer_framework +import machine # NOQA + + +# Register of the current mode +_DEV_MODE_REG = const(0x00) + +# ** Possible modes as of FT6X36_DEV_MODE_REG ** +_DEV_MODE_WORKING = const(0x00) +_CTRL = const(0x86) + + +# Status register: stores number of active touch points (0, 1, 2) +_TD_STAT_REG = const(0x02) +_P1_XH = const(0x03) +_P1_XL = const(0x04) + +_P1_YH = const(0x05) +_P1_YL = const(0x06) + +_MSB_MASK = const(0x0F) +_LSB_MASK = const(0xFF) + +# Report rate in Active mode +_PERIOD_ACTIVE_REG = const(0x88) + + +_VENDID = const(0x11) +_CHIPID_REG = const(0xA3) + +_FIRMWARE_ID_REG = const(0xA6) +_RELEASECODE_REG = const(0xAF) +_PANEL_ID_REG = const(0xA8) + +_G_MODE = const(0xA4) + + +class FocalTechTouch(pointer_framework.PointerDriver): + + def __init__( + self, + device, + touch_cal, + startup_rotation, # NOQA + debug, + factors, + *chip_ids + ): # NOQA + self._tx_buf = bytearray(5) + self._tx_mv = memoryview(self._tx_buf) + self._rx_buf = bytearray(5) + self._rx_mv = memoryview(self._rx_buf) + + self._device = device + self._factors = factors + + self._read_reg(_PANEL_ID_REG) + print("Touch Device ID: 0x%02x" % self._rx_buf[0]) + ven_id = self._rx_buf[0] # NOQA + + self._read_reg(_CHIPID_REG) + print("Touch Chip ID: 0x%02x" % self._rx_buf[0]) + chip_id = self._rx_buf[0] + + self._read_reg(_DEV_MODE_REG) + print("Touch Device mode: 0x%02x" % self._rx_buf[0]) + + self._read_reg(_FIRMWARE_ID_REG) + print("Touch Firmware ID: 0x%02x" % self._rx_buf[0]) + + self._read_reg(_RELEASECODE_REG) + print("Touch Release code: 0x%02x" % self._rx_buf[0]) + + if chip_id not in chip_ids: + raise RuntimeError( + f'IC is not compatable with the {self.__class__.__name__} driver' # NOQA + ) + + self._write_reg(_DEV_MODE_REG, _DEV_MODE_WORKING) + self._write_reg(_PERIOD_ACTIVE_REG, 0x0E) + self._write_reg(_G_MODE, 0x00) + + # This is needed so the TS doesn't go to sleep + self._write_reg(_CTRL, 0x00) + + super().__init__( + touch_cal=touch_cal, startup_rotation=startup_rotation, debug=debug + ) + + def _get_coords(self): + self._tx_buf[0] = _TD_STAT_REG + try: + self._device.write_readinto(self._tx_mv, self._rx_mv) + except OSError: + return None + + buf = self._rx_buf + + touch_pnt_cnt = buf[0] + if touch_pnt_cnt != 1: + return None + + x = ((buf[1] & _MSB_MASK) << 8) | buf[2] + y = ((buf[3] & _MSB_MASK) << 8) | buf[4] + + if self._factors is not None: + x = round(x / self._factors[0]) + y = round(y / self._factors[1]) + + return self.PRESSED, x, y + + def _read_reg(self, reg): + self._tx_buf[0] = reg + self._rx_buf[0] = 0x00 + + self._device.write_readinto(self._tx_mv[:1], self._rx_mv[:1]) + + def _write_reg(self, reg, value): + self._tx_buf[0] = reg + self._tx_buf[1] = value + self._device.write(self._tx_mv[:2]) diff --git a/internal_filesystem/lib/drivers/indev/ft6x36.py b/internal_filesystem/lib/drivers/indev/ft6x36.py new file mode 100644 index 00000000..b85c8ae2 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/ft6x36.py @@ -0,0 +1,37 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +# FT6236/FT6336/FT6436/FT6436L + +from micropython import const # NOQA +import focaltech_touch +import pointer_framework + + +I2C_ADDR = const(0x38) +BITS = 8 + +_FT6x36_CHIPID_1 = const(0x36) +_FT6x36_CHIPID_2 = const(0x64) +_FT6x36_CHIPID_3 = const(0xCD) + + +class FT6x36(focaltech_touch.FocalTechTouch): + + def __init__( + self, + device, + touch_cal=None, + startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._0, # NOQA + debug=False + ): # NOQA + + super().__init__( + device, + touch_cal, + startup_rotation, + debug, + None, + _FT6x36_CHIPID_1, + _FT6x36_CHIPID_2, + _FT6x36_CHIPID_3 + ) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py index a11db159..65cec32f 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py @@ -2,13 +2,10 @@ # Manufacturer's website at https://lilygo.cc/products/t-watch-s3-plus import lcd_bus import machine -import i2c import lvgl as lv import task_handler -import drivers.display.st7789 as st7789 - import mpos.ui spi_bus = machine.SPI.Bus( @@ -27,6 +24,7 @@ fb1 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) fb2 = display_bus.allocate_framebuffer(_BUFFER_SIZE, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA) +import drivers.display.st7789 as st7789 mpos.ui.main_display = st7789.ST7789( data_bus=display_bus, frame_buffer1=fb1, @@ -38,19 +36,17 @@ rgb565_byte_swap=True, backlight_pin=45, backlight_on_state=st7789.STATE_PWM, -) +) # triggers lv.init() mpos.ui.main_display.init() mpos.ui.main_display.set_power(True) mpos.ui.main_display.set_backlight(100) -# TODO: -# Touch handling: -#import drivers.indev.cst816s as cst816s -#i2c_bus = i2c.I2C.Bus(host=0, scl=40, sda=39, freq=400000, use_locks=False) -#touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=0x15, reg_bits=8) -#indev=cst816s.CST816S(touch_dev) - -lv.init() +import i2c +import drivers.indev.ft6x36 as ft6x36 +i2c_bus = i2c.I2C.Bus(host=0, sda=39, scl=40, freq=400000, use_locks=False) +touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=ft6x36.I2C_ADDR, reg_bits=ft6x36.BITS) +import pointer_framework +indev=ft6x36.FT6x36(touch_dev, startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._180) # TODO: # - battery 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 5422523b..32a6f26d 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 @@ -72,7 +72,7 @@ rgb565_byte_swap=True, backlight_pin=LCD_BL, backlight_on_state=st7789.STATE_PWM, -) +) # triggers lv.init() mpos.ui.main_display.init() mpos.ui.main_display.set_power(True) mpos.ui.main_display.set_backlight(100) @@ -82,7 +82,6 @@ touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=TP_ADDR, reg_bits=TP_REGBITS) indev=cst816s.CST816S(touch_dev,startup_rotation=lv.DISPLAY_ROTATION._180) # button in top left, good -lv.init() mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling # Battery voltage ADC measuring diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 66e20cd8..14a65a95 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -105,6 +105,11 @@ def detect_board(): # or: if single_address_i2c_scan(i2c0, 0x6A): # IMU currently not installed on prototype board return "fri3d_2026" + print("lilygo_t_watch_s3_plus ?") + if i2c0 := fail_save_i2c(sda=10, scl=11): + if single_address_i2c_scan(i2c0, 0x19): # IMU on 32? but scan shows: [25, 52, 81, 90] + return "lilygo_t_watch_s3_plus" # example MAC address: D0:CF:13:33:36:306 + # Then do I2C-based board detection print("matouch_esp32_s3_spi_ips_2_8_with_camera_ov3660 ?") if i2c0 := fail_save_i2c(sda=39, scl=38): @@ -127,10 +132,6 @@ def detect_board(): if single_address_i2c_scan(i2c0, 0x6B): # IMU (plus possibly the Communicator's LANA TNY at 0x38) return "fri3d_2024" - print("lilygo_t_watch_s3_plus ?") - if i2c0 := fail_save_i2c(sda=10, scl=11): - if single_address_i2c_scan(i2c0, 0x20): # IMU - return "lilygo_t_watch_s3_plus" # example MAC address: D0:CF:13:33:36:306 print("Unknown board: couldn't detect known I2C devices or unique_id prefix") From 0917e39b04d167b3e8298e1d519d34401300dfe5 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 1 Mar 2026 22:56:08 +0100 Subject: [PATCH 140/317] lilygo_t_watch_s3_plus: fix orientation --- internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py index 65cec32f..0ef7c13e 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py @@ -36,6 +36,7 @@ rgb565_byte_swap=True, backlight_pin=45, backlight_on_state=st7789.STATE_PWM, + offset_y=80 ) # triggers lv.init() mpos.ui.main_display.init() mpos.ui.main_display.set_power(True) @@ -48,6 +49,9 @@ import pointer_framework indev=ft6x36.FT6x36(touch_dev, startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._180) +mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._180) + + # TODO: # - battery # - IMU From 66e64e028a289fa785459c29fe6fe9e46bc8fc9a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 1 Mar 2026 23:29:03 +0100 Subject: [PATCH 141/317] lilygo_t_watch_s3_plus: vibrator works, sound does not --- .../lib/mpos/board/lilygo_t_watch_s3_plus.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py index 0ef7c13e..ec840850 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py @@ -51,9 +51,50 @@ mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._180) +# Audio: +from mpos import AudioManager +i2s_output_pins = { + 'ws': 15, # Word Select / LRCLK shared between DAC and mic (mandatory) + 'sck': 48, # SCLK or BCLK - Bit Clock for DAC output (mandatory) + 'sd': 46, # Serial Data OUT (speaker/DAC) +} +speaker_output = AudioManager.add( + AudioManager.Output( + name="speaker", + kind="i2s", + i2s_pins=i2s_output_pins, + ) +) + +i2s_input_pins = { + 'ws': 15, # Word Select / LRCLK shared between DAC and mic (mandatory) + 'sck_in': 44, # SCLK - Serial Clock for microphone input + 'sd_in': 47, # DIN - Serial Data IN (microphone) +} +mic_input = AudioManager.add( + AudioManager.Input( + name="mic", + kind="i2s", + i2s_pins=i2s_input_pins, + ) +) + +# Vibrator test + +# One extremely strong & fairly long buzz (repeat as needed) +write_reg(0x01, 0x00) # internal trigger +write_reg(0x03, 0) # Library A +write_reg(0x04, 47) # Strong Buzz 100% +write_reg(0x0C, 1) # GO +import time +time.sleep(1) # ~0.8s strong buzz +write_reg(0x0C, 0) # stop (optional) # TODO: # - battery # - IMU +# - vibrator +# - GPS +# - LoRa print("lilygo_t_watch_s3_plus.py finished") From 6ce0fcfc4eb20b5b5d77253322793a7aa73f95ef Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 2 Mar 2026 00:06:25 +0100 Subject: [PATCH 142/317] lilygo_t_watch_s3_plus: add bma423 IMU sensor --- .../lib/drivers/imu_sensor/bma423/bma423.py | 379 ++++++++++++++++++ .../lib/drivers/imu_sensor/bma423/git.version | 1 + .../lib/mpos/board/lilygo_t_watch_s3_plus.py | 11 +- internal_filesystem/lib/mpos/main.py | 2 +- 4 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py create mode 100644 internal_filesystem/lib/drivers/imu_sensor/bma423/git.version diff --git a/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py b/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py new file mode 100644 index 00000000..6dae8f08 --- /dev/null +++ b/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py @@ -0,0 +1,379 @@ +# BMA423 driver that aims to support features detection. +# +# Copyright (C) 2024 Salvatore Sanfilippo -- All Rights Reserved +# This code is released under the MIT license +# https://opensource.org/license/mit/ +# +# Written reading the specification at: +# https://www.mouser.com/datasheet/2/783/BST-BMA423-DS000-1509600.pdf + +from machine import Pin +import time + +# Registers +REG_CHIP_ID = const(0x00) # Chip identification number. +REG_INT_STATUS_0 = const(0x1C) # Interrupt status for features detection. +REG_INT_STATUS_1 = const(0x1D) # Interrupt status for data ready. +REG_STEP_COUNTER_0 = const(0x1E) # For bytes starting here. Number of steps. +REG_TEMPERATURE = const(0x22) # Temperature sensor reading: kelvin units. +REG_INTERNAL_STATUS = const(0x2A) # Error / status bits. +REG_ACC_CONF = const(0x40) # Out data rate, bandwidth, read mode. +REG_ACC_RANGE = const(0x41) # Acceleration range selection. +REG_PWR_CONF = const(0x7c) # Power mode configuration. +REG_PWR_CTL = const(0x7d) # Used to power-on the device parts +REG_CMD = const(0x7e) # Write there to send commands +REG_INT1_IO_CTRL = const(0x53) # Electrical config of interrupt 1 pin. +REG_INT2_IO_CTRL = const(0x54) # Electrical config of interrupt 2 pin. +REG_INT_LATCH = const(0x55) # Interrupt latch mode. +REG_INT1_MAP = const(0x56) # Interrput map for detected features and pin1. +REG_INT2_MAP = const(0x57) # Interrput map for detected features and pin2. +REG_INT_MAP_DATA = const(0x58) # Interrupt map for pin1/2 data events. +REG_INIT_CTRL = const(0x59) # Initialization register. +FEATURES_IN_SIZE = const(70) # Size of the features configuration area + +# Commands for the REG_CMD register +REG_CMD_SOFTRESET = const(0xB6) + +class BMA423: + # Acceleration range can be selected among the available settings of + # 2G, 4G, 8G and 16G. If we want to be able to measure higher + # max accelerations, the relative precision decreases as we have + # a fixed 12 bit reading. + def __init__(self,i2c,*,acc_range=2): + default_addr = [0x18,0x19] # Changes depending on SDO pin + # pulled to ground or V+ + self.i2c = i2c + self.myaddr = None + self.features_in = bytearray(FEATURES_IN_SIZE) + + found_devices = i2c.scan() + print("BMA423: scan i2c bus:", [hex(x) for x in found_devices]) + for addr in default_addr: + if addr in found_devices: + self.myaddr = addr + break + if self.myaddr == None: + raise Exception("BMA423 not found at i2c bus") + print("BMA423: device with matching address found at",hex(self.myaddr)) + + # Device initialization. + self.reset() + chip_id = self.get_reg(REG_CHIP_ID) + if chip_id != 0x13: + raise Exception("BMA423 chip ID is not 0x13 as expected. Different sensor connected?") + print("BMA423: chip correctly identified.") + + # Set default parameters. By default we enable the accelerometer + # so that the user can read the acceleration vector from the + # device without much setup work. + self.enable_accelerometer(acc=True,aux=False) + self.set_accelerometer_perf(True) + self.set_accelerometer_avg(2) + self.set_accelerometer_freq(100) + self.set_advanced_power_save(False,False) + self.set_range(acc_range) + + # Soft reset using the commands register. + def reset(self): + self.set_reg(REG_CMD,REG_CMD_SOFTRESET) # Reset the chip. + time.sleep(1) # Datasheet claims we need to wait that much. I know. + + # Enable or disable advanced power saving (ADP). + # + # When data is not being sampled, power saving mode slows down the + # clock and makes latency higher. + # Fifo self wakeup controls if the FIFO works when ADP is enabled. + # Step counting less reliable if APS is enabled (note of the implementator). + def set_advanced_power_save(self,adp=False,fifo_self_wakeup=False): + adp = int(adp) & 1 + fifo_self_wakeup = (int(fifo_self_wakeup) & 1) << 1 + self.set_reg(REG_PWR_CONF,adp|fifo_self_wakeup) + + # Enable/Disable accelerometer and aux sensor. + def enable_accelerometer(self,*,acc=True,aux=False): + val = 0 + if acc: val |= 0x4 # acc_en bit, enable accelerometer acquisition. + if aux: val |= 0x1 # aux_en bit, enable aux sensor. + self.set_reg(REG_PWR_CTL,val) + + # Enable/Disable performance mode. When performance mode is enabled + # the accelerometer performs continuous sampling at the specified + # sampling rate. + def set_accelerometer_perf(self,perf_mode): + val = self.get_reg(REG_ACC_CONF) + val = (val & 0b01111111) | (int(perf_mode) << 7) + self.set_reg(REG_ACC_CONF,val) + + # Set average mode. The mode selected depends on the fact performance + # mode is enabled/disabled. + # Valid values: + # perf mode on: 0 = osr4, 1 = osr2, 2 = normal, 3 = cic. + # perf mode off: 0 = avg1, 1 = avg2, 2 = avg4, 3 = avg8 + # 4 = avg16, 5 = avg32, 6 = avg64, 7 = avg128. + def set_accelerometer_avg(self,avg_mode): + val = self.get_reg(REG_ACC_CONF) + val = (val & 0b10001111) | avg_mode << 4 + self.set_reg(REG_ACC_CONF,val) + + # Set accelerometer sampling frequency, either as a frequency in + # hz that we convert using a table, or as immediate value if the + # user wants to select one of the low frequency modes (see datasheet). + def set_accelerometer_freq(self,freq): + table = {25:6, 50:7, 100:8, 200:9, 400:10, 800:11, 1600:12} + if freq in table: + freq = table[freq] + elif freq == 0 or freq >= 0x0d: + raise Exception("Invalid frequency or raw value") + val = self.get_reg(REG_ACC_CONF) + val = (val & 0b11110000) | freq + self.set_reg(REG_ACC_CONF,val) + + # Write in the FEATURES-IN configuration to enable specific + # features. + def enable_features_detection(self,*features): + self.read_features_in() + for f in features: + if f == "step-count": + self.features_in[0x3B] |= 0x10 # Enable step counter. + else: + raise Exception("Unrecognized feature name",f) + self.write_features_in() + + # Prepare the device to load the binary configuration in the + # bma423conf.bin file (data from Bosch). This step is required for + # features detection. + def load_features_config(self): + saved_pwr_conf = self.get_reg(REG_PWR_CONF) # To restore it later. + self.set_reg(REG_PWR_CONF,0x00) # Disable adv_power_save. + time.sleep_us(500) # Wait time synchronization. + self.set_reg(REG_INIT_CTRL,0x00) # Prepare for loading configuration. + self.transfer_config() # Load binary features config. + self.set_reg(REG_INIT_CTRL,0x01) # Enable features. + time.sleep_ms(140) # Wait ASIC initialization. + + # The chip is ready for further configuration when the + # status "message" turns 1. + wait_epoch = 0 + while True: + status = self.get_reg(REG_INTERNAL_STATUS) & 0b11111 + if status == 1: break # Initialization successful + time.sleep_ms(50) + wait_epoch += 1 + if wait_epoch == 20: + raise Exception("Timeout during init, internal_status: ", + status) + print("BMA423: features engine initialized successfully.") + self.set_reg(REG_PWR_CONF,saved_pwr_conf) + + # Write to the ASIC memory. This is useful to set the device + # features configuration. + # + # Writing / reading from ASIC works setting two registers that + # point to the memory area(0x5B/5C), and then reading/writing from/to + # the register 0x5E. Note that while normally writing / reading + # to a given register will write bytes to successive registers, in + # the case of 0x5E it works like a "port", so we keep reading + # or writing from successive parts of the ASIC memory. + def write_config_mem(self,idx,buf): + # The index of the half-word (so index/2) must + # be placed into this two undocumented registers + # 0x5B and 0x5C. Data goes in 0xE. + # Thanks for the mess, Bosch! + self.set_reg(0x5b,(idx//2)&0xf) # Set LSB (bits 3:0) + self.set_reg(0x5c,(idx//2)>>4) # Set MSB (bits 11:5) + self.set_reg(0x5e,buf) + + # see write_config_mem(). + def read_config_mem(self,idx,count): + self.set_reg(0x5b,(idx//2)&0xf) # Set LSB (bits 3:0) + self.set_reg(0x5c,(idx//2)>>4) # Set MSB (bits 11:5) + return self.get_reg(0x5e,count) + + # Read the steps counter. + def get_steps(self): + data = self.get_reg(REG_STEP_COUNTER_0,4) + return data[0] | data[1]<<8 | data[2]<<16 | data[3]<<24 + + # The BMA423 features detection requires that we transfer a binary + # blob via the features configuration register (and other two undocumented + # registers that set the internal target address at which the register + # points). If this are the weights of a small neural network, or just + # parameters, I'm not sure. More info (LOL, not really) here: + # + # https://github.com/boschsensortec/BMA423_SensorDriver + def transfer_config(self): + print("Uploading features configuration...") + f = open("bma423conf.bin","rb") + buf = bytearray(8) # Binary config is multiple of 8 in len. + idx = 0 + while f.readinto(buf,8) == 8: + self.write_config_mem(idx,buf) + idx += 8 + print("Done: total transfer: ", idx) + + # Verify the content. + print("BMA423: Verifying stored configuration...") + idx = 0 + f.seek(0) + while f.readinto(buf,8) == 8: + content = self.read_config_mem(idx,8) + idx += 8 + if content != buf: + raise Exception("Feature config data mismatch at",idx) + f.close() + + # Enable interrupt for the specified list of events. + # + # 'chip_pin' is 1 or 2 (the chip has two interrupt pins), you should + # select the one you want to use or the one you have an actual + # connection to with your host. + # 'pin' is your machine.Pin instance in your host. + # 'callback' is the function to call when the specified events will fire. + # 'events' is a list of strings specifying what events you want to + # listen for. Valid events are: + # "data": new acceleration reading available. + # "fifo-wm: fifo watermark reached. + # "fifo-full": fifo is full. + # "step": step feature. + # "activity": detect walking, running, ... + # "tilt": tilt on wrist. + # "double-tap": double tap. + # "single-tap": single tap. + # "any-none": any motion / no motion detected. + # Note: you can't subscribe to both double and single tap. + def enable_interrupt(self,chip_pin,pin,callback,events): + self.callback = callback + + # Features detection only work in latch mode. + self.set_reg(REG_INT_LATCH,0x01) + # feature name -> bit to set in INT1/2_MAP. + feature_bits = {"any-none":6,"tilt":3,"activity":2,"step":1} + # data source name -> bit to set for [pin1,pin2] in + # INT_MAP_DATA. + data_bits = {"data":[2,6],"fifo-wm":[1,5],"fifo-full":[0,4]} + + # Set features/data interrupt maps register values. + feature_map,data_map = 0,0 + for e in events: + if e in feature_bits: + feature_map |= (1 << feature_bits[e]) + elif e in data_bits: + data_map |= data_bits[e][chip_pin-1] + else: + raise Exception(f"Unknown event {e} when enabling interrupt.") + if feature_map != 0: + map_int_reg = REG_INT1_MAP if chip_pin == 1 else REG_INT2_MAP + self.set_reg(map_int_reg,feature_map) + if data_map != 0: self.set_reg(REG_INT_MAP_DATA,data_map) + + # XXX: set config registers according to single/double tap. + # XXX: set FEATURES_IN registers. + + # Configure the electrical interrput pin behavior. + ctrl_reg = REG_INT1_IO_CTRL if chip_pin == 1 else REG_INT2_IO_CTRL + # Output enabled 0x8, active high 0x2, all other bits zero, that + # is: input_enabled=no, edge_ctrl=level-trigger, od=push-pull. + self.set_reg(ctrl_reg, 0x8 | 0x2) + + # Finally enable the interrupt in the host pin. + pin.irq(handler=self.irq, trigger=Pin.IRQ_RISING) + + # Set range of 2, 4 or 8 or 16g + def set_range(self,acc_range): + range_to_regval = {2:0,4:1,8:2,16:3} + if not acc_range in range_to_regval: + raise Exception(f"Invalid range {acc_range}: use 2, 4, 8, 16", + acc_range) + self.range = acc_range + self.set_reg(REG_ACC_RANGE,range_to_regval[acc_range]) + + # Convert the raw 12 bit number in two's complement as a signed + # number. + def convert_to_int12(self,raw_value): + if not raw_value & 0x800: return raw_value + raw_value = ((~raw_value) & 0x7ff) + 1 + return -raw_value + + # Normalize the signed 12 bit acceleration value to + # acceleration value in "g" according to the currently + # selected range. + def normalize_reading(self,reading): + return self.range / 2047 * reading + + # Return x,y,z acceleration. + def get_xyz(self): + rawdata = self.get_reg(0x12,6) + acc_x = (rawdata[0] >> 4) | (rawdata[1] << 4) + acc_y = (rawdata[2] >> 4) | (rawdata[3] << 4) + acc_z = (rawdata[4] >> 4) | (rawdata[5] << 4) + acc_x = self.convert_to_int12(acc_x) + acc_y = self.convert_to_int12(acc_y) + acc_z = self.convert_to_int12(acc_z) + acc_x = self.normalize_reading(acc_x) + acc_y = self.normalize_reading(acc_y) + acc_z = self.normalize_reading(acc_z) + return (acc_x,acc_y,acc_z) + + # Return the chip tempereature in celsius. + # If the temperature is invalid, None is returned. + def get_temperature(self): + raw = self.get_reg(REG_TEMPERATURE) + if raw == 0x80: return None + if raw & 0x80: + raw = -((~raw)+1) # Conver 2 complement to signed integer. + return 23+raw + + def irq(self,pin): + if self.callback == None: + printf("BMA423: not handled IRQ. Please, set a callback.") + return + data = {} + + print("IRQ CALLED") + + if len(data) == None: return + self.callback(data) + + # Return the single byte at the specified register + def get_reg(self, register, count=1): + if count == 1: + return self.i2c.readfrom_mem(self.myaddr,register,1)[0] + else: + return self.i2c.readfrom_mem(self.myaddr,register,count) + + # Write 'value' to the specified register + def set_reg(self, register, value): + if isinstance(value,bytearray) or isinstance(value,bytes): + self.i2c.writeto_mem(self.myaddr,register,value) + else: + self.i2c.writeto_mem(self.myaddr,register,bytes([value])) + + def read_features_in(self): + self.i2c.readfrom_mem_into(self.myaddr,0x5E,self.features_in) + + def write_features_in(self): + self.i2c.writeto_mem(self.myaddr,0x5E,self.features_in) + +# Example usage and quick test to see if your device is working. +if __name__ == "__main__": + from machine import SoftI2C, Pin + import time + + # Called when a feature/data interrupt triggers. + def mycallback(data): + print(data) + + i2c = SoftI2C(scl=11,sda=10) + sensor = BMA423(i2c) + sensor.enable_interrupt(1,Pin(14,Pin.IN),mycallback,["data"]) + + # Enable steps counting + sensor.load_features_config() + sensor.enable_features_detection("step-count") + + while True: + print("(x,y,z),temp,steps", + sensor.get_xyz(), + sensor.get_temperature(), + sensor.get_steps()) + time.sleep(.1) diff --git a/internal_filesystem/lib/drivers/imu_sensor/bma423/git.version b/internal_filesystem/lib/drivers/imu_sensor/bma423/git.version new file mode 100644 index 00000000..bc0e9abd --- /dev/null +++ b/internal_filesystem/lib/drivers/imu_sensor/bma423/git.version @@ -0,0 +1 @@ +9ce483a0e067629a10486a305d9fb91ce5d2bad2 diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py index ec840850..7dfa6b94 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py @@ -81,7 +81,7 @@ # Vibrator test -# One extremely strong & fairly long buzz (repeat as needed) +# One strong & fairly long buzz (repeat as needed) write_reg(0x01, 0x00) # internal trigger write_reg(0x03, 0) # Library A write_reg(0x04, 47) # Strong Buzz 100% @@ -90,6 +90,15 @@ time.sleep(1) # ~0.8s strong buzz write_reg(0x0C, 0) # stop (optional) +# IMU: +import drivers.imu_sensor.bma423.bma423 as bma423 +from machine import SoftI2C, Pin +i2c = SoftI2C(scl=11,sda=10) +sensor = bma423.BMA423(i2c) +print("temperature: ", sensor.get_temperature()) +print("steps: ", sensor.get_steps()) +print("(x,y,z): ", sensor.get_xyz()) + # TODO: # - battery # - IMU diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 14a65a95..ad37f1f6 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -107,7 +107,7 @@ def detect_board(): print("lilygo_t_watch_s3_plus ?") if i2c0 := fail_save_i2c(sda=10, scl=11): - if single_address_i2c_scan(i2c0, 0x19): # IMU on 32? but scan shows: [25, 52, 81, 90] + if single_address_i2c_scan(i2c0, 0x19): # IMU on 0x19, scan shows: [25, 52, 81, 90] return "lilygo_t_watch_s3_plus" # example MAC address: D0:CF:13:33:36:306 # Then do I2C-based board detection From 7d707244dde37d807b6794e4694cd00e8ee0869a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Mon, 2 Mar 2026 00:21:52 +0100 Subject: [PATCH 143/317] bma423: avoid i2c scan if possible --- .../lib/drivers/imu_sensor/bma423/bma423.py | 23 +++++++++++-------- .../lib/mpos/board/lilygo_t_watch_s3_plus.py | 11 ++++++--- internal_filesystem/lib/mpos/main.py | 2 +- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py b/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py index 6dae8f08..60571625 100644 --- a/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py +++ b/internal_filesystem/lib/drivers/imu_sensor/bma423/bma423.py @@ -39,22 +39,25 @@ class BMA423: # 2G, 4G, 8G and 16G. If we want to be able to measure higher # max accelerations, the relative precision decreases as we have # a fixed 12 bit reading. - def __init__(self,i2c,*,acc_range=2): + def __init__(self,i2c,*,acc_range=2,address=None): default_addr = [0x18,0x19] # Changes depending on SDO pin # pulled to ground or V+ self.i2c = i2c self.myaddr = None self.features_in = bytearray(FEATURES_IN_SIZE) - found_devices = i2c.scan() - print("BMA423: scan i2c bus:", [hex(x) for x in found_devices]) - for addr in default_addr: - if addr in found_devices: - self.myaddr = addr - break - if self.myaddr == None: - raise Exception("BMA423 not found at i2c bus") - print("BMA423: device with matching address found at",hex(self.myaddr)) + if address is not None: + self.myaddr = address + else: + found_devices = i2c.scan() + print("BMA423: scan i2c bus:", [hex(x) for x in found_devices]) + for addr in default_addr: + if addr in found_devices: + self.myaddr = addr + break + if self.myaddr == None: + raise Exception("BMA423 not found at i2c bus") + print("BMA423: device with matching address found at",hex(self.myaddr)) # Device initialization. self.reset() diff --git a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py index 7dfa6b94..1b7092fd 100644 --- a/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py +++ b/internal_filesystem/lib/mpos/board/lilygo_t_watch_s3_plus.py @@ -82,6 +82,12 @@ # Vibrator test # One strong & fairly long buzz (repeat as needed) +from machine import I2C, Pin +i2c = I2C(1, sda=Pin(10), scl=Pin(11), freq=400000) + +def write_reg(reg, val): + i2c.writeto_mem(0x5A, reg, bytes([val])) + write_reg(0x01, 0x00) # internal trigger write_reg(0x03, 0) # Library A write_reg(0x04, 47) # Strong Buzz 100% @@ -92,9 +98,8 @@ # IMU: import drivers.imu_sensor.bma423.bma423 as bma423 -from machine import SoftI2C, Pin -i2c = SoftI2C(scl=11,sda=10) -sensor = bma423.BMA423(i2c) +sensor = bma423.BMA423(i2c, address=0x19) +time.sleep_ms(500) # some sleep is needed before reading values print("temperature: ", sensor.get_temperature()) print("steps: ", sensor.get_steps()) print("(x,y,z): ", sensor.get_xyz()) diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index ad37f1f6..000cca7e 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -107,7 +107,7 @@ def detect_board(): print("lilygo_t_watch_s3_plus ?") if i2c0 := fail_save_i2c(sda=10, scl=11): - if single_address_i2c_scan(i2c0, 0x19): # IMU on 0x19, scan shows: [25, 52, 81, 90] + if single_address_i2c_scan(i2c0, 0x19): # IMU on 0x19, vibrator on 0x5A and scan also shows: [52, 81] return "lilygo_t_watch_s3_plus" # example MAC address: D0:CF:13:33:36:306 # Then do I2C-based board detection From b83de2c28204bf2fa012b115cda46b2eade8561e Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Mon, 2 Mar 2026 16:55:08 +0100 Subject: [PATCH 144/317] scan_bluetooth: Display "last seen in sec." and fix timeout --- .../assets/scan_bluetooth.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py index efbf7c4d..4ee88b80 100644 --- a/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py +++ b/internal_filesystem/apps/com.micropythonos.scan_bluetooth/assets/scan_bluetooth.py @@ -3,6 +3,8 @@ https://docs.micropython.org/en/latest/library/bluetooth.html """ +import time + try: import bluetooth except ImportError: # Linux test runner may not provide bluetooth module @@ -90,7 +92,7 @@ def onCreate(self): set_cell_value( self.table, row=0, - values=("pos", "MAC", "RSSI", "count", "Name"), + values=("pos", "MAC", "RSSI", "last", "count", "Name"), ) set_dynamic_column_widths(self.table) @@ -98,6 +100,7 @@ def onCreate(self): self.mac2column = {} self.mac2counts = {} self.mac2name = {} + self.mac2last_seen = {} self.ble = bluetooth.BLE() @@ -112,7 +115,7 @@ async def ble_scan(self): while self.scanning: print(f"async scan for {SCAN_DURATION_MS}ms...") self.ble.gap_scan(SCAN_DURATION_MS, INTERVAL_US, WINDOW_US, True) - await TaskManager.sleep_ms(SCAN_DURATION_MS + 100) + await TaskManager.sleep_ms(SCAN_DURATION_MS + 500) def onResume(self, screen): super().onResume(screen) @@ -139,12 +142,20 @@ def onPause(self, screen): self.ble.active(False) self.info("BLE deactivated") + def update_last_seen(self): + current_time = int(time.time()) + for addr, last_seen in self.mac2last_seen.items(): + last_seen_sec = int(current_time - last_seen) + column_index = self.mac2column[addr] + self.table.set_cell_value(column_index, 3, f"{last_seen_sec}s") + def ble_irq_handler(self, event: int, data: tuple) -> None: try: if event == _IRQ_SCAN_RESULT: addr_type, addr, adv_type, rssi, adv_data = data addr = ":".join(f"{b:02x}" for b in addr) print(f"{addr=} {rssi=} {len(adv_data)=}") + self.mac2last_seen[addr] = int(time.time()) if name := decode_name(adv_data): self.mac2name[addr] = name else: @@ -164,11 +175,13 @@ def ble_irq_handler(self, event: int, data: tuple) -> None: str(column_index), addr, f"{rssi} dBm", + '0s', # Last seen since 0 sec ;) str(self.mac2counts[addr]), name, ), ) elif event == _IRQ_SCAN_DONE: + self.update_last_seen() set_dynamic_column_widths(self.table) self.scan_count += 1 self.info( From 519ceaae6fc0efefc1268aae43a01f34ec3267cb Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 5 Mar 2026 11:17:25 +0100 Subject: [PATCH 145/317] webrepl: support access from LAN, without internet Host everything on the device itself, rather than redirecting to https://micropython.org/webrepl/ because that doesn't work when there is not internet, including when the device is in Access Point mode. This was a bit slow, because of the many files and being pretty large, but the inline_minify_webrepl.py makes this much better and brings it down to around 1s to load the page, versus 20 seconds. The minification also reduces the size from around 160KB to 80KB. --- .gitignore | 5 +- internal_filesystem/builtin/html/README.md | 1 + internal_filesystem/lib/mpos/main.py | 3 +- .../lib/mpos/webserver/__init__.py | 5 + .../lib/mpos/webserver/webrepl_http.py | 136 + webrepl/FileSaver.js | 188 + webrepl/README.md | 3 + webrepl/inline_minify_webrepl.py | 157 + webrepl/term.js | 6010 +++++++++++++++++ webrepl/webrepl.css | 13 + webrepl/webrepl.html | 46 + webrepl/webrepl.js | 288 + 12 files changed, 6853 insertions(+), 2 deletions(-) create mode 100644 internal_filesystem/builtin/html/README.md create mode 100644 internal_filesystem/lib/mpos/webserver/__init__.py create mode 100644 internal_filesystem/lib/mpos/webserver/webrepl_http.py create mode 100644 webrepl/FileSaver.js create mode 100644 webrepl/README.md create mode 100755 webrepl/inline_minify_webrepl.py create mode 100644 webrepl/term.js create mode 100644 webrepl/webrepl.css create mode 100644 webrepl/webrepl.html create mode 100644 webrepl/webrepl.js diff --git a/.gitignore b/.gitignore index f5e46736..bcf674be 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,11 @@ __pycache__/ *$py.class *.so -# these get created: +# these get created by the build system, don't know why: c_mpos/c_mpos # build files *.bin + +# auto created by inline_minify_webrepl.py +internal_filesystem/builtin/html/webrepl_inlined_minified.html diff --git a/internal_filesystem/builtin/html/README.md b/internal_filesystem/builtin/html/README.md new file mode 100644 index 00000000..ddacaedb --- /dev/null +++ b/internal_filesystem/builtin/html/README.md @@ -0,0 +1 @@ +This folder will be filled by the inline_minify_webrepl.py script. diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 000cca7e..c6e065e7 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -223,7 +223,8 @@ async def asyncio_repl(): try: import webrepl - webrepl.start(port=7890,password="MPOSweb26") # password is max 9 characters + from mpos.webserver import accept_handler as webrepl_accept_handler + webrepl.start(port=7890, password="MPOSweb26", accept_handler=webrepl_accept_handler) # password is max 9 characters except Exception as e: print(f"Could not start webrepl - this is normal on desktop systems: {e}") diff --git a/internal_filesystem/lib/mpos/webserver/__init__.py b/internal_filesystem/lib/mpos/webserver/__init__.py new file mode 100644 index 00000000..473cfec3 --- /dev/null +++ b/internal_filesystem/lib/mpos/webserver/__init__.py @@ -0,0 +1,5 @@ +"""Web server helpers for MicroPythonOS.""" + +from .webrepl_http import accept_handler + +__all__ = ["accept_handler"] diff --git a/internal_filesystem/lib/mpos/webserver/webrepl_http.py b/internal_filesystem/lib/mpos/webserver/webrepl_http.py new file mode 100644 index 00000000..aac9eed5 --- /dev/null +++ b/internal_filesystem/lib/mpos/webserver/webrepl_http.py @@ -0,0 +1,136 @@ +import os +import socket +import uio + +import _webrepl +import webrepl +import websocket + +WEBREPL_HTML_PATH = "builtin/html/webrepl_inlined_minified.html" +''' +# Unused as these files are minified and inlined: +#WEBREPL_HTML_PATH = "/builtin/html/webrepl.html" +WEBREPL_CONTENT_PATH = "/builtin/html/webrepl.js" +WEBREPL_TERM_PATH = "/builtin/html/term.js" +WEBREPL_CSS_PATH = "/builtin/html/webrepl.css" +WEBREPL_FILE_SAVER_PATH = "/builtin/html/FileSaver.js" +''' + +WEBREPL_ASSETS = { + b"/": (WEBREPL_HTML_PATH, b"text/html"), + b"/index.html": (WEBREPL_HTML_PATH, b"text/html"), + #b"/webrepl.css": (WEBREPL_CSS_PATH, b"text/css"), + #b"/webrepl.js": (WEBREPL_CONTENT_PATH, b"application/javascript"), + #b"/term.js": (WEBREPL_TERM_PATH, b"application/javascript"), + #b"/FileSaver.js": (WEBREPL_FILE_SAVER_PATH, b"application/javascript"), +} + + +class _MakefileSocket: + def __init__(self, sock, raw_request): + self._sock = sock + self._raw_request = raw_request + + def makefile(self, *args, **kwargs): + return uio.BytesIO(self._raw_request) + + def __getattr__(self, name): + return getattr(self._sock, name) + + +def _read_http_request(cl): + req = cl.makefile("rwb", 0) + first_line = req.readline() + if not first_line: + return None, None, b"" + + raw_request = first_line + headers = {} + while True: + line = req.readline() + if not line: + break + raw_request += line + if line == b"\r\n": + break + if b":" in line: + key, value = line.split(b":", 1) + headers[key.strip().lower()] = value.strip().lower() + + parts = first_line.split() + path = parts[1] if len(parts) >= 2 else b"/" + if b"?" in path: + path = path.split(b"?", 1)[0] + + return path, headers, raw_request + + +def _is_websocket_request(headers): + connection = headers.get(b"connection", b"") + upgrade = headers.get(b"upgrade", b"") + return b"upgrade" in connection and upgrade == b"websocket" + + +def _send_response(cl, status, content_type, body): + cl.send(b"HTTP/1.0 " + status + b"\r\n") + cl.send(b"Server: MicroPythonOS\r\n") + cl.send(b"Content-Type: " + content_type + b"\r\n") + cl.send(b"Content-Length: %d\r\n\r\n" % len(body)) + cl.send(body) + cl.close() + + +def _send_file_response(cl, path, content_type): + try: + with open(path, "rb") as handle: + body = handle.read() + except OSError: + _send_response(cl, b"404 Not Found", b"text/plain", b"Not Found") + return False + + _send_response(cl, b"200 OK", content_type, body) + return False + + +def _start_webrepl_session(cl, remote_addr): + print("\nWebREPL connection from:", remote_addr) + webrepl.client_s = cl + + ws = websocket.websocket(cl, True) + ws = _webrepl._webrepl(ws) + cl.setblocking(False) + if hasattr(os, "dupterm_notify"): + cl.setsockopt(socket.SOL_SOCKET, 20, os.dupterm_notify) + os.dupterm(ws) + + return True + + +def accept_handler(listen_sock): + cl, remote_addr = listen_sock.accept() + print("\webrepl_http connection from:", remote_addr) + try: + path, headers, raw_request = _read_http_request(cl) + if not path: + cl.close() + return False + + if _is_websocket_request(headers): + if not webrepl.server_handshake(_MakefileSocket(cl, raw_request)): + cl.close() + return False + return _start_webrepl_session(cl, remote_addr) + + if path in WEBREPL_ASSETS: + asset_path, content_type = WEBREPL_ASSETS[path] + return _send_file_response(cl, asset_path, content_type) + + _send_response(cl, b"404 Not Found", b"text/plain", b"Not Found") + return False + except Exception as exc: + print("webrepl_http: error handling connection:", exc) + try: + cl.close() + except Exception: + pass + return False diff --git a/webrepl/FileSaver.js b/webrepl/FileSaver.js new file mode 100644 index 00000000..239db122 --- /dev/null +++ b/webrepl/FileSaver.js @@ -0,0 +1,188 @@ +/* FileSaver.js + * A saveAs() FileSaver implementation. + * 1.3.2 + * 2016-06-16 18:25:19 + * + * By Eli Grey, http://eligrey.com + * License: MIT + * See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md + */ + +/*global self */ +/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */ + +/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */ + +var saveAs = saveAs || (function(view) { + "use strict"; + // IE <10 is explicitly unsupported + if (typeof view === "undefined" || typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) { + return; + } + var + doc = view.document + // only get URL when necessary in case Blob.js hasn't overridden it yet + , get_URL = function() { + return view.URL || view.webkitURL || view; + } + , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a") + , can_use_save_link = "download" in save_link + , click = function(node) { + var event = new MouseEvent("click"); + node.dispatchEvent(event); + } + , is_safari = /constructor/i.test(view.HTMLElement) + , is_chrome_ios =/CriOS\/[\d]+/.test(navigator.userAgent) + , throw_outside = function(ex) { + (view.setImmediate || view.setTimeout)(function() { + throw ex; + }, 0); + } + , force_saveable_type = "application/octet-stream" + // the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to + , arbitrary_revoke_timeout = 1000 * 40 // in ms + , revoke = function(file) { + var revoker = function() { + if (typeof file === "string") { // file is an object URL + get_URL().revokeObjectURL(file); + } else { // file is a File + file.remove(); + } + }; + setTimeout(revoker, arbitrary_revoke_timeout); + } + , dispatch = function(filesaver, event_types, event) { + event_types = [].concat(event_types); + var i = event_types.length; + while (i--) { + var listener = filesaver["on" + event_types[i]]; + if (typeof listener === "function") { + try { + listener.call(filesaver, event || filesaver); + } catch (ex) { + throw_outside(ex); + } + } + } + } + , auto_bom = function(blob) { + // prepend BOM for UTF-8 XML and text/* types (including HTML) + // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF + if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { + return new Blob([String.fromCharCode(0xFEFF), blob], {type: blob.type}); + } + return blob; + } + , FileSaver = function(blob, name, no_auto_bom) { + if (!no_auto_bom) { + blob = auto_bom(blob); + } + // First try a.download, then web filesystem, then object URLs + var + filesaver = this + , type = blob.type + , force = type === force_saveable_type + , object_url + , dispatch_all = function() { + dispatch(filesaver, "writestart progress write writeend".split(" ")); + } + // on any filesys errors revert to saving with object URLs + , fs_error = function() { + if ((is_chrome_ios || (force && is_safari)) && view.FileReader) { + // Safari doesn't allow downloading of blob urls + var reader = new FileReader(); + reader.onloadend = function() { + var url = is_chrome_ios ? reader.result : reader.result.replace(/^data:[^;]*;/, 'data:attachment/file;'); + var popup = view.open(url, '_blank'); + if(!popup) view.location.href = url; + url=undefined; // release reference before dispatching + filesaver.readyState = filesaver.DONE; + dispatch_all(); + }; + reader.readAsDataURL(blob); + filesaver.readyState = filesaver.INIT; + return; + } + // don't create more object URLs than needed + if (!object_url) { + object_url = get_URL().createObjectURL(blob); + } + if (force) { + view.location.href = object_url; + } else { + var opened = view.open(object_url, "_blank"); + if (!opened) { + // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html + view.location.href = object_url; + } + } + filesaver.readyState = filesaver.DONE; + dispatch_all(); + revoke(object_url); + } + ; + filesaver.readyState = filesaver.INIT; + + if (can_use_save_link) { + object_url = get_URL().createObjectURL(blob); + setTimeout(function() { + save_link.href = object_url; + save_link.download = name; + click(save_link); + dispatch_all(); + revoke(object_url); + filesaver.readyState = filesaver.DONE; + }); + return; + } + + fs_error(); + } + , FS_proto = FileSaver.prototype + , saveAs = function(blob, name, no_auto_bom) { + return new FileSaver(blob, name || blob.name || "download", no_auto_bom); + } + ; + // IE 10+ (native saveAs) + if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) { + return function(blob, name, no_auto_bom) { + name = name || blob.name || "download"; + + if (!no_auto_bom) { + blob = auto_bom(blob); + } + return navigator.msSaveOrOpenBlob(blob, name); + }; + } + + FS_proto.abort = function(){}; + FS_proto.readyState = FS_proto.INIT = 0; + FS_proto.WRITING = 1; + FS_proto.DONE = 2; + + FS_proto.error = + FS_proto.onwritestart = + FS_proto.onprogress = + FS_proto.onwrite = + FS_proto.onabort = + FS_proto.onerror = + FS_proto.onwriteend = + null; + + return saveAs; +}( + typeof self !== "undefined" && self + || typeof window !== "undefined" && window + || this.content +)); +// `self` is undefined in Firefox for Android content script context +// while `this` is nsIContentFrameMessageManager +// with an attribute `content` that corresponds to the window + +if (typeof module !== "undefined" && module.exports) { + module.exports.saveAs = saveAs; +} else if ((typeof define !== "undefined" && define !== null) && (define.amd !== null)) { + define([], function() { + return saveAs; + }); +} diff --git a/webrepl/README.md b/webrepl/README.md new file mode 100644 index 00000000..4c691bbe --- /dev/null +++ b/webrepl/README.md @@ -0,0 +1,3 @@ +# WebREPL content + +These files were sourced from commit `fff7b87` of https://github.com/micropython/webrepl. diff --git a/webrepl/inline_minify_webrepl.py b/webrepl/inline_minify_webrepl.py new file mode 100755 index 00000000..1a14e697 --- /dev/null +++ b/webrepl/inline_minify_webrepl.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Minify and inline WebREPL assets into webrepl_inlined_minified.html.""" +from __future__ import annotations + +import re +from pathlib import Path + + +def _is_alphanum(ch: str) -> bool: + return ch.isalnum() or ch in "_$\\" + + +def jsmin(js: str) -> str: + """Minify JavaScript by stripping comments and collapsing whitespace safely.""" + out: list[str] = [] + i = 0 + length = len(js) + state = "code" + quote = "" + + def peek(offset: int = 1) -> str: + idx = i + offset + if idx >= length: + return "" + return js[idx] + + def push_char(ch: str) -> None: + out.append(ch) + + while i < length: + ch = js[i] + nxt = peek(1) + + if state == "code": + if ch == "/" and nxt == "/": + state = "line_comment" + i += 2 + continue + if ch == "/" and nxt == "*": + state = "block_comment" + i += 2 + continue + if ch in ("'", '"'): + state = "string" + quote = ch + push_char(ch) + i += 1 + continue + if ch == "`": + state = "template" + push_char(ch) + i += 1 + continue + if ch.isspace(): + if out: + last = out[-1] + if last in "{}[]();,": + i += 1 + continue + if last != " ": + push_char(" ") + i += 1 + continue + if ch in "{}[]();,": + if out and out[-1] == " ": + out.pop() + push_char(ch) + i += 1 + continue + push_char(ch) + i += 1 + continue + + if state == "line_comment": + if ch in ("\n", "\r"): + if out and out[-1] != " ": + out.append(" ") + state = "code" + i += 1 + continue + + if state == "block_comment": + if ch == "*" and nxt == "/": + state = "code" + i += 2 + else: + i += 1 + continue + + if state == "string": + push_char(ch) + if ch == "\\" and nxt: + push_char(nxt) + i += 2 + continue + if ch == quote: + state = "code" + i += 1 + continue + + if state == "template": + push_char(ch) + if ch == "\\" and nxt: + push_char(nxt) + i += 2 + continue + if ch == "`": + state = "code" + i += 1 + continue + + return "".join(out).strip() + + +def cssmin(css: str) -> str: + css = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL) + css = re.sub(r"\s+", " ", css) + css = re.sub(r"\s*([{}:;,>])\s*", r"\1", css) + return css.strip() + + +def inline_assets() -> None: + base_dir = Path(__file__).parent + html_path = base_dir / "webrepl.html" + out_path = base_dir / "webrepl_inlined_minified.html" + + html = html_path.read_text(encoding="utf-8") + css = cssmin((base_dir / "webrepl.css").read_text(encoding="utf-8")) + term_js = jsmin((base_dir / "term.js").read_text(encoding="utf-8")) + file_saver_js = jsmin((base_dir / "FileSaver.js").read_text(encoding="utf-8")) + webrepl_js = jsmin((base_dir / "webrepl.js").read_text(encoding="utf-8")) + + replacements = [ + (r"", f""), + (r"\s*", f""), + (r"\s*", f""), + (r"\s*", f""), + ] + + for pattern, replacement in replacements: + new_html, count = re.subn( + pattern, + lambda _match, rep=replacement: rep, + html, + flags=re.IGNORECASE, + ) + if count != 1: + raise RuntimeError( + f"Expected to replace exactly one tag for pattern: {pattern}; replaced {count}" + ) + html = new_html + + out_path.write_text(html, encoding="utf-8") + + +if __name__ == "__main__": + inline_assets() diff --git a/webrepl/term.js b/webrepl/term.js new file mode 100644 index 00000000..dc535ccd --- /dev/null +++ b/webrepl/term.js @@ -0,0 +1,6010 @@ +/** + * term.js - an xterm emulator + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +;(function() { + +/** + * Terminal Emulation References: + * http://vt100.net/ + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.txt + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + * http://invisible-island.net/vttest/ + * http://www.inwap.com/pdp10/ansicode.txt + * http://linux.die.net/man/4/console_codes + * http://linux.die.net/man/7/urxvt + */ + +'use strict'; + +/** + * Shared + */ + +var window = this + , document = this.document; + +/** + * EventEmitter + */ + +function EventEmitter() { + this._events = this._events || {}; +} + +EventEmitter.prototype.addListener = function(type, listener) { + this._events[type] = this._events[type] || []; + this._events[type].push(listener); +}; + +EventEmitter.prototype.on = EventEmitter.prototype.addListener; + +EventEmitter.prototype.removeListener = function(type, listener) { + if (!this._events[type]) return; + + var obj = this._events[type] + , i = obj.length; + + while (i--) { + if (obj[i] === listener || obj[i].listener === listener) { + obj.splice(i, 1); + return; + } + } +}; + +EventEmitter.prototype.off = EventEmitter.prototype.removeListener; + +EventEmitter.prototype.removeAllListeners = function(type) { + if (this._events[type]) delete this._events[type]; +}; + +EventEmitter.prototype.once = function(type, listener) { + function on() { + var args = Array.prototype.slice.call(arguments); + this.removeListener(type, on); + return listener.apply(this, args); + } + on.listener = listener; + return this.on(type, on); +}; + +EventEmitter.prototype.emit = function(type) { + if (!this._events[type]) return; + + var args = Array.prototype.slice.call(arguments, 1) + , obj = this._events[type] + , l = obj.length + , i = 0; + + for (; i < l; i++) { + obj[i].apply(this, args); + } +}; + +EventEmitter.prototype.listeners = function(type) { + return this._events[type] = this._events[type] || []; +}; + +/** + * Stream + */ + +function Stream() { + EventEmitter.call(this); +} + +inherits(Stream, EventEmitter); + +Stream.prototype.pipe = function(dest, options) { + var src = this + , ondata + , onerror + , onend; + + function unbind() { + src.removeListener('data', ondata); + src.removeListener('error', onerror); + src.removeListener('end', onend); + dest.removeListener('error', onerror); + dest.removeListener('close', unbind); + } + + src.on('data', ondata = function(data) { + dest.write(data); + }); + + src.on('error', onerror = function(err) { + unbind(); + if (!this.listeners('error').length) { + throw err; + } + }); + + src.on('end', onend = function() { + dest.end(); + unbind(); + }); + + dest.on('error', onerror); + dest.on('close', unbind); + + dest.emit('pipe', src); + + return dest; +}; + +/** + * States + */ + +var normal = 0 + , escaped = 1 + , csi = 2 + , osc = 3 + , charset = 4 + , dcs = 5 + , ignore = 6 + , UDK = { type: 'udk' }; + +/** + * Terminal + */ + +function Terminal(options) { + var self = this; + + if (!(this instanceof Terminal)) { + return new Terminal(arguments[0], arguments[1], arguments[2]); + } + + Stream.call(this); + + if (typeof options === 'number') { + options = { + cols: arguments[0], + rows: arguments[1], + handler: arguments[2] + }; + } + + options = options || {}; + + each(keys(Terminal.defaults), function(key) { + if (options[key] == null) { + options[key] = Terminal.options[key]; + // Legacy: + if (Terminal[key] !== Terminal.defaults[key]) { + options[key] = Terminal[key]; + } + } + self[key] = options[key]; + }); + + if (options.colors.length === 8) { + options.colors = options.colors.concat(Terminal._colors.slice(8)); + } else if (options.colors.length === 16) { + options.colors = options.colors.concat(Terminal._colors.slice(16)); + } else if (options.colors.length === 10) { + options.colors = options.colors.slice(0, -2).concat( + Terminal._colors.slice(8, -2), options.colors.slice(-2)); + } else if (options.colors.length === 18) { + options.colors = options.colors.slice(0, -2).concat( + Terminal._colors.slice(16, -2), options.colors.slice(-2)); + } + this.colors = options.colors; + + this.options = options; + + // this.context = options.context || window; + // this.document = options.document || document; + this.parent = options.body || options.parent + || (document ? document.getElementsByTagName('body')[0] : null); + + this.cols = options.cols || options.geometry[0]; + this.rows = options.rows || options.geometry[1]; + + // Act as though we are a node TTY stream: + this.setRawMode; + this.isTTY = true; + this.isRaw = true; + this.columns = this.cols; + this.rows = this.rows; + + if (options.handler) { + this.on('data', options.handler); + } + + this.ybase = 0; + this.ydisp = 0; + this.x = 0; + this.y = 0; + this.cursorState = 0; + this.cursorHidden = false; + this.convertEol; + this.state = 0; + this.queue = ''; + this.scrollTop = 0; + this.scrollBottom = this.rows - 1; + + // modes + this.applicationKeypad = false; + this.applicationCursor = false; + this.originMode = false; + this.insertMode = false; + this.wraparoundMode = false; + this.normal = null; + + // select modes + this.prefixMode = false; + this.selectMode = false; + this.visualMode = false; + this.searchMode = false; + this.searchDown; + this.entry = ''; + this.entryPrefix = 'Search: '; + this._real; + this._selected; + this._textarea; + + // charset + this.charset = null; + this.gcharset = null; + this.glevel = 0; + this.charsets = [null]; + + // mouse properties + this.decLocator; + this.x10Mouse; + this.vt200Mouse; + this.vt300Mouse; + this.normalMouse; + this.mouseEvents; + this.sendFocus; + this.utfMouse; + this.sgrMouse; + this.urxvtMouse; + + // misc + this.element; + this.children; + this.refreshStart; + this.refreshEnd; + this.savedX; + this.savedY; + this.savedCols; + + // stream + this.readable = true; + this.writable = true; + + this.defAttr = (0 << 18) | (257 << 9) | (256 << 0); + this.curAttr = this.defAttr; + + this.params = []; + this.currentParam = 0; + this.prefix = ''; + this.postfix = ''; + + this.lines = []; + var i = this.rows; + while (i--) { + this.lines.push(this.blankLine()); + } + + this.tabs; + this.setupStops(); +} + +inherits(Terminal, Stream); + +/** + * Colors + */ + +// Colors 0-15 +Terminal.tangoColors = [ + // dark: + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + // bright: + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +]; + +Terminal.xtermColors = [ + // dark: + '#000000', // black + '#cd0000', // red3 + '#00cd00', // green3 + '#cdcd00', // yellow3 + '#0000ee', // blue2 + '#cd00cd', // magenta3 + '#00cdcd', // cyan3 + '#e5e5e5', // gray90 + // bright: + '#7f7f7f', // gray50 + '#ff0000', // red + '#00ff00', // green + '#ffff00', // yellow + '#5c5cff', // rgb:5c/5c/ff + '#ff00ff', // magenta + '#00ffff', // cyan + '#ffffff' // white +]; + +// Colors 0-15 + 16-255 +// Much thanks to TooTallNate for writing this. +Terminal.colors = (function() { + var colors = Terminal.tangoColors.slice() + , r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] + , i; + + // 16-231 + i = 0; + for (; i < 216; i++) { + out(r[(i / 36) % 6 | 0], r[(i / 6) % 6 | 0], r[i % 6]); + } + + // 232-255 (grey) + i = 0; + for (; i < 24; i++) { + r = 8 + i * 10; + out(r, r, r); + } + + function out(r, g, b) { + colors.push('#' + hex(r) + hex(g) + hex(b)); + } + + function hex(c) { + c = c.toString(16); + return c.length < 2 ? '0' + c : c; + } + + return colors; +})(); + +// Default BG/FG +Terminal.colors[256] = '#000000'; +Terminal.colors[257] = '#f0f0f0'; + +Terminal._colors = Terminal.colors.slice(); + +Terminal.vcolors = (function() { + var out = [] + , colors = Terminal.colors + , i = 0 + , color; + + for (; i < 256; i++) { + color = parseInt(colors[i].substring(1), 16); + out.push([ + (color >> 16) & 0xff, + (color >> 8) & 0xff, + color & 0xff + ]); + } + + return out; +})(); + +/** + * Options + */ + +Terminal.defaults = { + colors: Terminal.colors, + convertEol: false, + termName: 'xterm', + geometry: [80, 24], + cursorBlink: true, + visualBell: false, + popOnBell: false, + scrollback: 1000, + screenKeys: false, + debug: false, + useStyle: false + // programFeatures: false, + // focusKeys: false, +}; + +Terminal.options = {}; + +each(keys(Terminal.defaults), function(key) { + Terminal[key] = Terminal.defaults[key]; + Terminal.options[key] = Terminal.defaults[key]; +}); + +/** + * Focused Terminal + */ + +Terminal.focus = null; + +Terminal.prototype.focus = function() { + if (Terminal.focus === this) return; + + if (Terminal.focus) { + Terminal.focus.blur(); + } + + if (this.sendFocus) this.send('\x1b[I'); + this.showCursor(); + + // try { + // this.element.focus(); + // } catch (e) { + // ; + // } + + // this.emit('focus'); + + Terminal.focus = this; +}; + +Terminal.prototype.blur = function() { + if (Terminal.focus !== this) return; + + this.cursorState = 0; + this.refresh(this.y, this.y); + if (this.sendFocus) this.send('\x1b[O'); + + // try { + // this.element.blur(); + // } catch (e) { + // ; + // } + + // this.emit('blur'); + + Terminal.focus = null; +}; + +/** + * Initialize global behavior + */ + +Terminal.prototype.initGlobal = function() { + var document = this.document; + + Terminal._boundDocs = Terminal._boundDocs || []; + if (~indexOf(Terminal._boundDocs, document)) { + return; + } + Terminal._boundDocs.push(document); + + Terminal.bindPaste(document); + + Terminal.bindKeys(document); + + Terminal.bindCopy(document); + + if (this.isMobile) { + this.fixMobile(document); + } + + if (this.useStyle) { + Terminal.insertStyle(document, this.colors[256], this.colors[257]); + } +}; + +/** + * Bind to paste event + */ + +Terminal.bindPaste = function(document) { + // This seems to work well for ctrl-V and middle-click, + // even without the contentEditable workaround. + var window = document.defaultView; + on(window, 'paste', function(ev) { + var term = Terminal.focus; + if (!term) return; + if (ev.clipboardData) { + term.send(ev.clipboardData.getData('text/plain')); + } else if (term.context.clipboardData) { + term.send(term.context.clipboardData.getData('Text')); + } + // Not necessary. Do it anyway for good measure. + term.element.contentEditable = 'inherit'; + return cancel(ev); + }); +}; + +/** + * Global Events for key handling + */ + +Terminal.bindKeys = function(document) { + // We should only need to check `target === body` below, + // but we can check everything for good measure. + on(document, 'keydown', function(ev) { + if (!Terminal.focus) return; + var target = ev.target || ev.srcElement; + if (!target) return; + if (target === Terminal.focus.element + || target === Terminal.focus.context + || target === Terminal.focus.document + || target === Terminal.focus.body + || target === Terminal._textarea + || target === Terminal.focus.parent) { + return Terminal.focus.keyDown(ev); + } + }, true); + + on(document, 'keypress', function(ev) { + if (!Terminal.focus) return; + var target = ev.target || ev.srcElement; + if (!target) return; + if (ev.ctrlKey && ev.key === 'v') { + // If we got here with Ctrl+V, then we know it's us who enabled it + // to bubble to be handled by browser as Paste, so let this happen. + return; + } + if (target === Terminal.focus.element + || target === Terminal.focus.context + || target === Terminal.focus.document + || target === Terminal.focus.body + || target === Terminal._textarea + || target === Terminal.focus.parent) { + // In case user popped up context menu, widget may be stuck in + // "contentEditable" state (as a workaround for Firefox braindeadness) + // with visual artifacts like browser's cursur. Disable it now. + Terminal.focus.element.contentEditable = 'inherit'; + return Terminal.focus.keyPress(ev); + } + }, true); + + // If we click somewhere other than a + // terminal, unfocus the terminal. + on(document, 'mousedown', function(ev) { + if (!Terminal.focus) return; + + var el = ev.target || ev.srcElement; + if (!el) return; + + do { + if (el === Terminal.focus.element) return; + } while (el = el.parentNode); + + Terminal.focus.blur(); + }); +}; + +/** + * Copy Selection w/ Ctrl-C (Select Mode) + */ + +Terminal.bindCopy = function(document) { + var window = document.defaultView; + + // if (!('onbeforecopy' in document)) { + // // Copies to *only* the clipboard. + // on(window, 'copy', function fn(ev) { + // var term = Terminal.focus; + // if (!term) return; + // if (!term._selected) return; + // var text = term.grabText( + // term._selected.x1, term._selected.x2, + // term._selected.y1, term._selected.y2); + // term.emit('copy', text); + // ev.clipboardData.setData('text/plain', text); + // }); + // return; + // } + + // Copies to primary selection *and* clipboard. + // NOTE: This may work better on capture phase, + // or using the `beforecopy` event. + on(window, 'copy', function(ev) { + var term = Terminal.focus; + if (!term) return; + if (!term._selected) return; + var textarea = term.getCopyTextarea(); + var text = term.grabText( + term._selected.x1, term._selected.x2, + term._selected.y1, term._selected.y2); + term.emit('copy', text); + textarea.focus(); + textarea.textContent = text; + textarea.value = text; + textarea.setSelectionRange(0, text.length); + setTimeout(function() { + term.element.focus(); + term.focus(); + }, 1); + }); +}; + +/** + * Fix Mobile + */ + +Terminal.prototype.fixMobile = function(document) { + var self = this; + + var textarea = document.createElement('textarea'); + textarea.style.position = 'absolute'; + textarea.style.left = '-32000px'; + textarea.style.top = '-32000px'; + textarea.style.width = '0px'; + textarea.style.height = '0px'; + textarea.style.opacity = '0'; + textarea.style.backgroundColor = 'transparent'; + textarea.style.borderStyle = 'none'; + textarea.style.outlineStyle = 'none'; + textarea.autocapitalize = 'none'; + textarea.autocorrect = 'off'; + + document.getElementsByTagName('body')[0].appendChild(textarea); + + Terminal._textarea = textarea; + + setTimeout(function() { + textarea.focus(); + }, 1000); + + if (this.isAndroid) { + on(textarea, 'change', function() { + var value = textarea.textContent || textarea.value; + textarea.value = ''; + textarea.textContent = ''; + self.send(value + '\r'); + }); + } +}; + +/** + * Insert a default style + */ + +Terminal.insertStyle = function(document, bg, fg) { + var style = document.getElementById('term-style'); + if (style) return; + + var head = document.getElementsByTagName('head')[0]; + if (!head) return; + + var style = document.createElement('style'); + style.id = 'term-style'; + + // textContent doesn't work well with IE for + + + + + +
+
+ + +
+
+
+
+
+ +
+ +
+ Send a file + +
+ +
+ +
+ Get a file + + +
+ +
(file operation status)
+ +
+ +
+Terminal widget should be focused (text cursor visible) to accept input. Click on it if not.
+To paste, press Ctrl+A, then Ctrl+V + + + + + + diff --git a/webrepl/inline_minify_webrepl.py b/webrepl/inline_minify_webrepl.py index 1a14e697..26e40c08 100755 --- a/webrepl/inline_minify_webrepl.py +++ b/webrepl/inline_minify_webrepl.py @@ -129,12 +129,14 @@ def inline_assets() -> None: term_js = jsmin((base_dir / "term.js").read_text(encoding="utf-8")) file_saver_js = jsmin((base_dir / "FileSaver.js").read_text(encoding="utf-8")) webrepl_js = jsmin((base_dir / "webrepl.js").read_text(encoding="utf-8")) + webrepl_tweaks_js = jsmin((base_dir / "webrepl_tweaks.js").read_text(encoding="utf-8")) replacements = [ (r"", f""), (r"\s*", f""), (r"\s*", f""), (r"\s*", f""), + (r"\s*", f""), ] for pattern, replacement in replacements: From f27fab8bf554231f7e46172718d69cf31272345a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 12:21:53 +0100 Subject: [PATCH 282/317] Add tests/test_webserver.py --- tests/test_webserver.py | 96 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_webserver.py diff --git a/tests/test_webserver.py b/tests/test_webserver.py new file mode 100644 index 00000000..c85856ea --- /dev/null +++ b/tests/test_webserver.py @@ -0,0 +1,96 @@ +""" +Unit tests for the MicroPythonOS webserver. +""" + +import _thread +import sys +import time +import unittest + +sys.path.insert(0, "../internal_filesystem/lib") + +from mpos import TaskManager +from mpos.net.download_manager import DownloadManager +from mpos.webserver.webserver import WebServer + + +class TestWebServer(unittest.TestCase): + """Test cases for WebServer.""" + + def tearDown(self): + """Ensure the webserver is stopped after tests.""" + if WebServer.is_started(): + WebServer.stop() + TaskManager.stop() + + def test_webserver_serves_webrepl_page(self): + """Webserver should serve the WebREPL HTML page on root.""" + + def start_task_manager(): + try: + TaskManager.enable() + TaskManager.start() + except KeyboardInterrupt: + print("TaskManager got KeyboardInterrupt, falling back to REPL shell...") + except Exception as exc: + print(f"TaskManager got exception: {exc}") + + TaskManager.enable() + _thread.stack_size(TaskManager.good_stack_size()) + _thread.start_new_thread(start_task_manager, ()) + + startup_timeout = 5.0 + start_time = time.time() + while TaskManager.keep_running is not True and (time.time() - start_time) < startup_timeout: + time.sleep(0.05) + + if TaskManager.keep_running is not True: + self.fail("TaskManager failed to start") + + started = WebServer.start() + if not started: + self.fail("WebServer failed to start") + + startup_wait = 1.0 + startup_wait_start = time.time() + while (time.time() - startup_wait_start) < startup_wait: + time.sleep(0.05) + + response_state = {"data": None, "error": None, "done": False} + + async def download_task(): + response_bytes = None + last_error = None + url_attempts = ["http://localhost:7890/", "http://127.0.0.1:7890/"] + for url in url_attempts: + for _ in range(15): + try: + response_bytes = await DownloadManager.download_url(url) + break + except Exception as exc: + last_error = exc + await TaskManager.sleep(0.5) + if response_bytes is not None: + break + + if response_bytes is None: + response_state["error"] = last_error + else: + response_state["data"] = response_bytes + response_state["done"] = True + + TaskManager.create_task(download_task()) + + timeout_seconds = 20.0 + start_wait = time.time() + while not response_state["done"] and (time.time() - start_wait) < timeout_seconds: + time.sleep(0.1) + + if response_state["data"] is None: + raise response_state["error"] + + response_text = response_state["data"].decode("utf-8", "replace") + self.assertIn("MicroPython WebREPL", response_text) + + WebServer.stop() + self.assertFalse(WebServer.is_started()) From acee8da47779fef08665d3464e8bced9eaef3bff Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 12:37:21 +0100 Subject: [PATCH 283/317] aiorepl.py: fix infinite prompt spam with /dev/null Previously, when running with "< /dev/null", the REPL was continously sending the prompt "-->" which was needlessly exploding the output. --- internal_filesystem/lib/aiorepl.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/aiorepl.py b/internal_filesystem/lib/aiorepl.py index 15026e43..9d431ef8 100644 --- a/internal_filesystem/lib/aiorepl.py +++ b/internal_filesystem/lib/aiorepl.py @@ -114,8 +114,9 @@ async def task(g=None, prompt="--> "): curs = 0 # cursor offset from end of cmd buffer while True: b = await s.read(1) + # MPOS: return on EOF to avoid infinite prompt spam with /dev/null (differs from upstream). if not b: # Handle EOF/empty read - break + return pc = c # save previous character c = ord(b) pt = t # save previous time From 66ef3c36c0db02759711e97061e0b19f9a1ac216 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 12:40:25 +0100 Subject: [PATCH 284/317] Add title to WebREPL HTML --- .../builtin/html/webrepl_inlined_minified.html | 4 ++-- webrepl/webrepl.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal_filesystem/builtin/html/webrepl_inlined_minified.html b/internal_filesystem/builtin/html/webrepl_inlined_minified.html index 6ef57225..fe7e27dd 100644 --- a/internal_filesystem/builtin/html/webrepl_inlined_minified.html +++ b/internal_filesystem/builtin/html/webrepl_inlined_minified.html @@ -1,13 +1,13 @@ -MicroPython WebREPL +MicroPythonOS WebREPL - +

MicroPythonOS WebREPL

diff --git a/webrepl/webrepl.html b/webrepl/webrepl.html index 6aa7e6a7..e87ac560 100644 --- a/webrepl/webrepl.html +++ b/webrepl/webrepl.html @@ -1,13 +1,13 @@ -MicroPython WebREPL +MicroPythonOS WebREPL - +

MicroPythonOS WebREPL

From c25e46c18bc44fb156f2136c6fb625042eb421d8 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 12:52:22 +0100 Subject: [PATCH 285/317] Tweak webserver_settings.py and wifi_service.py --- .../assets/webserver_settings.py | 4 ++-- internal_filesystem/lib/mpos/net/wifi_service.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py index 014f6ff4..07a35da8 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py @@ -66,9 +66,9 @@ def refresh_status(self): port = status.get("port") ip_address = WifiService.get_ipv4_address() if ip_address: - url_text = f"http://{ip_address}:{port}" + url_text = f"http://{ip_address}:{port}/" else: - url_text = f"http://:{port}" + url_text = f"http://:{port}/" self.detail_label.set_text(f"URL: {url_text}\nAutostart: {autostart_text}") button_text = "Stop" if status.get("started") else "Start" diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index b94a4c22..89708016 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -509,7 +509,7 @@ def get_ipv4_address(network_module=None): network_module=network_module, ap_index=0, sta_key="addr4", - desktop_value="123.456.789.000", + desktop_value="127.0.0.1", label="address", ) From fe9633b661e792a9af1a7e3e403f2b3b48217ca8 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 13:35:12 +0100 Subject: [PATCH 286/317] About app: add netmask separately --- .../com.micropythonos.about/assets/about.py | 24 +++++++++++-------- .../lib/mpos/net/wifi_service.py | 18 ++++++++++++-- 2 files changed, 30 insertions(+), 12 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 66421d46..e83ea73a 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/assets/about.py @@ -105,16 +105,20 @@ def onCreate(self): self.logger.warning(error) self._add_label(screen, error) - # Network info (ESP32 only) - try: - self._add_label(screen, f"{lv.SYMBOL.WIFI} Network Info", is_header=True) - from mpos import WifiService - self._add_label(screen, f"IPv4 Address: {WifiService.get_ipv4_address()}") - self._add_label(screen, f"IPv4 Gateway: {WifiService.get_ipv4_gateway()}") - except Exception as e: - error = f"Could not find network info because: {e}" - self.logger.warning(error) - self._add_label(screen, error) + # Network info + try: + self._add_label(screen, f"{lv.SYMBOL.WIFI} Network Info", is_header=True) + from mpos import WifiService + ipv4_address = WifiService.get_ipv4_address() or "127.0.0.1" + ipv4_netmask = WifiService.get_ipv4_netmask() or "255.255.255.0" + ipv4_gateway = WifiService.get_ipv4_gateway() or "" + self._add_label(screen, f"IPv4 Address: {ipv4_address}") + self._add_label(screen, f"IPv4 Netmask: {ipv4_netmask}") + self._add_label(screen, f"IPv4 Gateway: {ipv4_gateway}") + except Exception as e: + error = f"Could not find network info because: {e}" + self.logger.warning(error) + self._add_label(screen, error) # Freezefs info (production builds only) diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 89708016..21206e50 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -498,7 +498,10 @@ def _get_ipv4_value(network_module, ap_index, sta_key, desktop_value, label): ap = WifiService._get_ap_wlan(net) return ap.ifconfig()[ap_index] wlan = WifiService._get_sta_wlan(net) - return wlan.ipconfig(sta_key) + value = wlan.ipconfig(sta_key) + if isinstance(value, tuple): + return value[0] if value else None + return value except Exception as e: print(f"WifiService: Error retrieving ip4v {label}: {e}") return None @@ -513,13 +516,23 @@ def get_ipv4_address(network_module=None): label="address", ) + @staticmethod + def get_ipv4_netmask(network_module=None): + return WifiService._get_ipv4_value( + network_module=network_module, + ap_index=1, + sta_key="addr4", + desktop_value="255.255.255.0", + label="netmask", + ) + @staticmethod def get_ipv4_gateway(network_module=None): return WifiService._get_ipv4_value( network_module=network_module, ap_index=2, sta_key="gw4", - desktop_value="000.123.456.789", + desktop_value="", label="gateway", ) @@ -760,3 +773,4 @@ def forget_network(ssid): else: print(f"WifiService: Network '{ssid}' not found in saved networks") return False + From 477b6726b8779c6a50d2b9d8d5e28bb2edb462b3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 13:37:27 +0100 Subject: [PATCH 287/317] cz.ucw.pavel.navstar app: fix icon not showing There was a mismatch between the app's fullname and the app's folder name. --- .../META-INF/MANIFEST.JSON | 6 +++--- .../res/mipmap-mdpi/icon_64x64.png | Bin 6751 -> 806 bytes 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON index 58b4638a..f8d69b0d 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON @@ -3,9 +3,9 @@ "publisher": "Pavel Machek", "short_description": "Simple navigation app.", "long_description": "Simple navigation app using data from NAVSTAR GPS and other GNSS systems.", -"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.xxx/icons/cz.ucw.pavel.xxx_0.0.1_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.xxx/mpks/cz.ucw.pavel.xxx_0.0.1.mpk", -"fullname": "cz.ucw.pavel.xxx", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.navstar/icons/cz.ucw.pavel.navstar_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.navstar/mpks/cz.ucw.pavel.navstar_0.0.1.mpk", +"fullname": "cz.ucw.pavel.navstar", "version": "0.0.1", "category": "utilities", "activities": [ diff --git a/internal_filesystem/apps/cz.ucw.pavel.navstar/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.navstar/res/mipmap-mdpi/icon_64x64.png index dba74da3c82c466a45307972eb5a36c15a554859..88d6d0742996023075b3577aabc0a1cf46898113 100644 GIT binary patch delta 11 Scmca_vW#tl^5z(EW@Z2ztplF` literal 6751 zcmeHLc~n!^*1sW4f>K6BaEP%o2$>TyK$Lk90>}(v@g})}NG39nV4Ep(=xk$|Fd2I8r-MqHr zxa&!aH;AE;pRKE@dYg)#vAd8N-RX|%H@D9B7m)={x*POl-K%SWC%@{NyuKJxXIuU& z$!Y8KjAL)TjUJ!aTlu78*KgJfs!;AGSP-eH^#A zDnjhr&Y~xp_GNZGBl(c3rGlRJ`Gxl0gMjNZYd=5R2*<3Q#h!UKdgZbibMV2j9LtKOP$YD|Ukk>TE)$)faSu+emR&(t#ubZqr{_|kcJ{_OF zTzkgIKb(4YC`~;#H?|GT(erc|Ncr+$)?okZmTIE)_8W~R$kyw)Y^;1!`Aolb^F~w4 zoangRMq40$&5wHZrdg}J%tlJ(F;{$b7cyS)fuV+Z%20Q7n&;(s>lSv)vjw(yt@j*@ z9XgRysE1y&W6I44QRm^}$~oWsKw}@86li8E*eq)Qx?hM&_nl6Z-+n^wO(KQt zJ!BDnkhM>7(#N>uc>T8rF7N!?={VHQ)!(gIvu>^5<`>I0|LXG?*RN>mxe~Wv*PYcb zmQC4L*ghvI-=d{x)zNsvoK_xy*h5WRAMQvO_PQkcHC{BjbS=zSHXLBJ+c*4P-)itfV@7LE){RTog3tBe%-Z&iX-vnlpEAnM zxPAX{t%JjgqyBCHBk<96YeUua~V-e8ArFAL-~BQp3ffQ+<7OXm|4~zE!;ZxbAU&1GWZ6sBqJ?b z7?a(N3ei1^i!~)D17GM)`oVSow~U}wZGAO7pH+_#uN;rNht7Um%Cau2CJMk%N&p?wZb-?(*XMZPfsPSj+eF3Jvh`de=7KW>J)} ztv2wS`-}?-;Xd1bL-wJ%a!VMomR2b@=UbL|9k;%DeJug+RH^@s`f z%4tTG(dy1GOh zUocGxcBIP%d8NAPbdo1Qg2rD)HR$4b<#|RAJV$1t|qq+#Roq zR5weF%@<0SK7F-W)&Z?bT-O*O-AfB{?p_kZ2cz(mF2}Cy!;iM(N|{k~aZ1x7tEMXZ zm7v3szd6hHbend?oHzfn*WW|WXZy0bi;e_uT>E8zUHV8?MFKkCvjS~RpBlPOzvX$> z=-&rL}l7> zR_rc3W)MFBAVby(>MJZu9q&ukZ>DF2>-W^^oo=e`uz5qv0K#rYcIne-y1ql(&kQ^p zxYE?|mU*LR&|nK!dTi6mFf#x!bK=8&Jcbp-;E07-kSk_GSd~x$_jv$tTd0zNoMcFj zVnd015feT5b1fRh=Q7dJ6c(N(@q&{0{wt(V_=?~N&WdCXor_+$z{pL-fC+?<97L&v z0+EcNVxqOY4ESC%jYFff5P32a9m5Jmd5NVEii{;=@faT!Kb44HV1#m$a(Rp}Z{KkW zc*H~}$>kCT4yRNqu}TtFEKS4_=yW;`Ps9<47#M+(rHSOA3L}!u)liIactbLdlrNF< z#Uhl36J(1OawZxLucOAv;Z=^03Z5KSpc!lFWpXYq03MKGe_#NPh{w|~cp`>K$4!)n zS6Qt0(jwV-6ybQ{RGQu-e&6L(|0hfn^jnZnKDB~Bv3Vko531cE(DA7u2&=Zkd&U>6hAr)vbTn-Kr zKtk9M8O%)h2ruXJK5WoO{%BVI#SmECd;X8m@8YGkMH^R!x0s{QC=KvtqBZt1xMB{U z%h2A^=ma90>f(YSQfOohnN8ziK!`xW5a>J#hvov&NNmCcssNEp4vILCh6*Of@?jn- zor32mQa)S>pkSg^8Y(VKMJDig6fT~J zVN>xW3>oA=7?ABkz;N(X0*On3=rj(frPAyJ!y`0+i6&z4A7(-YpqwX`3Yq92zDS|^ zun@r)Lg8{y6Ep&qN+42*WEzz~puzVaKv9rX1}C|OlYqw(iP{w|hv5q|f^f3&gm9y&U4m|D5NK!0(yD z;9e(_OVa}WjZ^;z=QftE{;;fAnl?dyIFvFr8f!=bzBW`Sl(q>lK+ahBWne1A)ouYC z$FU_&5-3W9;BGr!YVYRxe~`u`5ae+QAcUc?$wUm9#wKCd1Uwl75nX6BHk$*ljb++< zcA1zbSAtT=BN5I4c%R|Y((W_LL7RP!AKRi#g5Vr+!V?&HJbG+<$G7{h@dpoQIhPHx zF|ZS`KSVMeL+8;67&?tk!xPwWBZi=lE&Sj1hrlI~=p-_Tq2Ot7Zy-}$Fd&)E!%*1} zNTpIhHih(G_s0#VdEmWEKR3-IfyH7>Oi(w?BZCp3=?vitiA2DMq#q~lyF&hda1-Rm zo5sHrp9mY1_7Y3d;HH`+4^fK#RQ(?S#~6b697rS+|C#9%A!A{ga4vvt9-D*D8}RW3 z_x}7cUbdRf_7{G}+reL`0jB<}S<|;mOFb|%YB=L`x0f4cE=8FIdi>%>E19g;YWho*aEXZx5~RwIqCXPQw>Y1bVFrpFs6Zhcs}@LPY8 z{@n#Jsi{1r-LqZP!82(H^8oKt$6Nc$gAZ~li_(wWx`XJX>~74ky0pGJ z*e7X6P!THNWbASP2!GJY04}B&bS@cnj0O@fWSq59ms!0`Fpx}BTP;1~9->;cJf=vu zGc&24IO`YW6O!R-6Pw&E{8~G=3wo#KTSu_G(OASn}8KwHa&Z*kDV@~ESk&7DrAlCg2!?6}AnWg3l4(@!;Jl~pR5AEOY zF%%@_0GVNB!zkbOM|2LVSMtYAXqT)-+pO>~Y*P08P6}q8Y5qMO; z!Tq+*%NN*3X(xqmmV9?zW%+B})XZshrYYEYtps2rFbkg*jYn0$&AmH!~+_Eo*~ zZX?u@IK4_+EM6!>%? z710LZ0~~)tEXc|4N}H`d$I(wUP%|T6XO_q2~IjOv(&c0%SdEp^vywm*)j%qZHw>fllQxCBZ^zEJ}^{w)#wr15clU@ZZrX= z#Q4rY+^-TNvRr`|Hp}V_EUp0Gw*g5dZ|5VdVbCdL?r7OeI~#R8@;0Kf3Yjb0*4LFu zd6af`Dbn0FHwhpC;()Y}d6{_|f5|ZEJf00GBW(WeZ4P2<5yAIbL+{k?u!L&?2=EE^ JuJBy8`kx&5{TBcL From 2fbbea959d6f5bc6522144f9af83f6c223aa104e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 14:03:28 +0100 Subject: [PATCH 288/317] Settings: improve UI --- .../assets/hotspot_settings.py | 2 +- .../assets/webserver_settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py index ec0ce526..2f31ec07 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py @@ -44,7 +44,7 @@ def onCreate(self): button_row.set_width(lv.pct(100)) button_row.set_height(lv.SIZE_CONTENT) button_row.set_style_border_width(0, lv.PART.MAIN) - button_row.set_style_pad_all(0, lv.PART.MAIN) + button_row.set_style_pad_all(10, lv.PART.MAIN) button_row.set_flex_flow(lv.FLEX_FLOW.ROW) button_row.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py index 07a35da8..86d6d721 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.webserver/assets/webserver_settings.py @@ -35,7 +35,7 @@ def onCreate(self): button_row.set_width(lv.pct(100)) button_row.set_height(lv.SIZE_CONTENT) button_row.set_style_border_width(0, lv.PART.MAIN) - button_row.set_style_pad_all(0, lv.PART.MAIN) + button_row.set_style_pad_all(10, lv.PART.MAIN) button_row.set_flex_flow(lv.FLEX_FLOW.ROW) button_row.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) From 072a0757bb426774a7c7be1fb369477b74630edc Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 15:09:40 +0100 Subject: [PATCH 289/317] Fix test --- tests/test_wifi_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_wifi_service.py b/tests/test_wifi_service.py index 4b5ffcbb..71b8028c 100644 --- a/tests/test_wifi_service.py +++ b/tests/test_wifi_service.py @@ -692,8 +692,8 @@ def test_get_ipv4_info_desktop_mode(self): address = WifiService.get_ipv4_address(network_module=None) gateway = WifiService.get_ipv4_gateway(network_module=None) - self.assertEqual(address, "123.456.789.000") - self.assertEqual(gateway, "000.123.456.789") + self.assertEqual(address, "127.0.0.1") + self.assertEqual(gateway, "") class TestWifiServiceDisconnect(unittest.TestCase): From ca52112330f377621952e0e101cb9cbb5559d9eb Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 15:19:25 +0100 Subject: [PATCH 290/317] Fix webserver test --- tests/test_webserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_webserver.py b/tests/test_webserver.py index c85856ea..fd5af438 100644 --- a/tests/test_webserver.py +++ b/tests/test_webserver.py @@ -90,7 +90,7 @@ async def download_task(): raise response_state["error"] response_text = response_state["data"].decode("utf-8", "replace") - self.assertIn("MicroPython WebREPL", response_text) + self.assertIn("MicroPythonOS WebREPL", response_text) WebServer.stop() self.assertFalse(WebServer.is_started()) From bf803b5020aafb749ae56c2698784138429854d1 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 15:20:52 +0100 Subject: [PATCH 291/317] HowTo app: make text focusable to improve navigation --- .../com.micropythonos.howto/assets/howto.py | 73 ++++++++++++------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.howto/assets/howto.py b/internal_filesystem/builtin/apps/com.micropythonos.howto/assets/howto.py index a656478d..75481b89 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.howto/assets/howto.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.howto/assets/howto.py @@ -11,40 +11,27 @@ class HowTo(Activity): def onCreate(self): screen = lv.obj() screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) - ''' - welcome_label = lv.label(screen) - welcome_label.set_width(lv.pct(100)) - welcome_label.set_text("Welcome!") - welcome_label.set_style_text_font(lv.font_montserrat_34, lv.PART.MAIN) - welcome_label.set_style_margin_bottom(2, lv.PART.MAIN) - ''' + # Make the screen focusable so it can be scrolled with the arrow keys + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(screen) preamble = "How to Navigate" - title_label = lv.label(screen) - title_label.set_width(lv.pct(100)) - title_label.set_text(preamble) - title_label.set_style_text_font(lv.font_montserrat_24, lv.PART.MAIN) - label = lv.label(screen) - label.set_width(lv.pct(100)) - label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) - buttonhelp = '''As you don't have a touch screen, you need to use the buttons to navigate: - -- If you have a joystick and at least 2 buttons, then use the joystick to move around. Use one of the buttons to ENTER and another to go BACK. - -- If you have 3 buttons, then one is PREVIOUS, one is ENTER and one is NEXT. To go back, press PREVIOUS and NEXT together. + self._add_label(screen, preamble, is_header=True) -- If you have just 2 buttons, then one is PREVIOUS, the other is NEXT. To ENTER, press both at the same time. To go back, long-press the PREVIOUS button. - ''' + buttonhelp_intro = "As you don't have a touch screen, you need to use the buttons to navigate:" + buttonhelp_items = [ + "If you have a joystick and at least 2 buttons, then use the joystick to move around. Use one of the buttons to ENTER and another to go BACK.", + "If you have 3 buttons, then one is PREVIOUS, one is ENTER and one is NEXT. To go back, press PREVIOUS and NEXT together.", + "If you have just 2 buttons, then one is PREVIOUS, the other is NEXT. To ENTER, press both at the same time. To go back, long-press the PREVIOUS button.", + ] touchhelp = "Swipe from the left edge to go back and from the top edge to open the menu." from mpos import InputManager if InputManager.has_pointer(): - label.set_text(f''' -{touchhelp} - ''') + self._add_label(screen, touchhelp) else: - label.set_text(f''' -{buttonhelp} - ''') - label.set_long_mode(lv.label.LONG_MODE.WRAP) + self._add_label(screen, buttonhelp_intro) + for item in buttonhelp_items: + self._add_label(screen, f"• {item}") self.dontshow_checkbox = lv.checkbox(screen) self.dontshow_checkbox.set_text("Don't show again") @@ -56,6 +43,36 @@ def onCreate(self): self.setContentView(screen) + @staticmethod + def _focus_obj(event): + target = event.get_target_obj() + target.set_style_border_color(lv.theme_get_color_primary(None), lv.PART.MAIN) + target.set_style_border_width(1, lv.PART.MAIN) + target.scroll_to_view(True) + + @staticmethod + def _defocus_obj(event): + target = event.get_target_obj() + target.set_style_border_width(0, lv.PART.MAIN) + + def _add_label(self, parent, text, is_header=False): + label = lv.label(parent) + label.set_width(lv.pct(100)) + label.set_text(text) + label.set_long_mode(lv.label.LONG_MODE.WRAP) + label.add_event_cb(self._focus_obj, lv.EVENT.FOCUSED, None) + label.add_event_cb(self._defocus_obj, lv.EVENT.DEFOCUSED, None) + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(label) + if is_header: + label.set_style_text_font(lv.font_montserrat_24, lv.PART.MAIN) + label.set_style_margin_bottom(4, lv.PART.MAIN) + else: + label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) + label.set_style_margin_bottom(2, lv.PART.MAIN) + return label + def onResume(self, screen): # Autostart can only be disabled if nothing was enabled or if this app was enabled self.prefs = SharedPreferences("com.micropythonos.settings") From d340dd11a5f407c76f4e12fd3aeb2434bc164f49 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 17:58:23 +0100 Subject: [PATCH 292/317] GitHub actions: add macos-intel, target macos-15 and rename amd64 to x64 --- .github/workflows/linux.yml | 6 +- .github/workflows/macos-intel.yml | 101 ++++++++++++++++++++++++++++++ .github/workflows/macos.yml | 6 +- 3 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/macos-intel.yml diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index add42ac0..c27e0082 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -70,13 +70,13 @@ jobs: - name: Build LVGL MicroPython for unix run: | ./scripts/build_mpos.sh unix - cp lvgl_micropython/build/lvgl_micropy_unix lvgl_micropython/build/MicroPythonOS_amd64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + cp lvgl_micropython/build/lvgl_micropy_unix lvgl_micropython/build/MicroPythonOS_x64_linux_${{ steps.version.outputs.OS_VERSION }}.elf - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_amd64_linux_${{ steps.version.outputs.OS_VERSION }}.elf - path: lvgl_micropython/build/MicroPythonOS_amd64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + name: MicroPythonOS_x64_linux_${{ steps.version.outputs.OS_VERSION }}.elf + path: lvgl_micropython/build/MicroPythonOS_x64_linux_${{ steps.version.outputs.OS_VERSION }}.elf retention-days: 7 - name: Run syntax tests on unix diff --git a/.github/workflows/macos-intel.yml b/.github/workflows/macos-intel.yml new file mode 100644 index 00000000..94f3bb6b --- /dev/null +++ b/.github/workflows/macos-intel.yml @@ -0,0 +1,101 @@ +name: Build LVGL MicroPython for MacOS + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: # allow manual workflow starts + +jobs: + build: + runs-on: macos-15-intel + + steps: + - name: Checkout repository with submodules + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies via Homebrew + run: | + xcode-select --install || true # already installed on github + brew install pkg-config libffi ninja make SDL2 + + - name: Show version numbers + run: | + xcodebuild -version + clang --version + + - name: Extract OS version + id: version + run: | + OS_VERSION=$(grep "release = " internal_filesystem/lib/mpos/build_info.py | cut -d "=" -f 2 | cut -d "#" -f 1 | tr -d " " | tr -d '"') + echo "OS_VERSION=$OS_VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $OS_VERSION" + + - name: Build LVGL MicroPython for macOS dev + run: | + ./scripts/build_mpos.sh macOS + + - name: Run syntax tests on macOS + run: | + ./tests/syntax.sh + continue-on-error: true + + - name: Run unit tests on macOS + run: | + ./tests/unittest.sh + mv lvgl_micropython/build/lvgl_micropy_macOS lvgl_micropython/build/MicroPythonOS_intel_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + continue-on-error: true + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_intel_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_intel_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + retention-days: 7 + + + - name: Build LVGL MicroPython esp32 + run: | + ./scripts/build_mpos.sh esp32 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC-SPIRAM-16.bin lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.bin + retention-days: 7 + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC-SPIRAM/MicroPythonOS_esp32_${{ steps.version.outputs.OS_VERSION }}.ota + retention-days: 7 + + + - name: Build LVGL MicroPython esp32s3 + run: | + ./scripts/build_mpos.sh esp32s3 + mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin + retention-days: 7 + + - name: Upload built binary as artifact + uses: actions/upload-artifact@v4 + with: + name: MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + path: lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota + retention-days: 7 diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 2876bd67..b718b4c4 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -48,14 +48,14 @@ jobs: - name: Run unit tests on macOS run: | ./tests/unittest.sh - mv lvgl_micropython/build/lvgl_micropy_macOS lvgl_micropython/build/MicroPythonOS_amd64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + mv lvgl_micropython/build/lvgl_micropy_macOS lvgl_micropython/build/MicroPythonOS_arm64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin continue-on-error: true - name: Upload built binary as artifact uses: actions/upload-artifact@v4 with: - name: MicroPythonOS_amd64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin - path: lvgl_micropython/build/MicroPythonOS_amd64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + name: MicroPythonOS_arm64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin + path: lvgl_micropython/build/MicroPythonOS_arm64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin retention-days: 7 From 1dc4d593d435da1086ea9abce43e2f54fbcb3c11 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 17:59:44 +0100 Subject: [PATCH 293/317] target macos-15 instead of macos-14 --- .github/workflows/macos.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index b718b4c4..6bb95577 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -11,7 +11,7 @@ on: jobs: build: - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout repository with submodules From 622c531d6652cfaa5a69a020bc8dcef538046398 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 19:18:31 +0100 Subject: [PATCH 294/317] Improve workflow names --- .github/workflows/linux-arm64.yml | 2 +- .github/workflows/linux.yml | 2 +- .github/workflows/macos-intel.yml | 2 +- .github/workflows/macos.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-arm64.yml b/.github/workflows/linux-arm64.yml index ec452d67..c5d461c6 100644 --- a/.github/workflows/linux-arm64.yml +++ b/.github/workflows/linux-arm64.yml @@ -1,4 +1,4 @@ -name: Build LVGL MicroPython on Linux for ARM64 +name: Build LVGL MicroPython on Linux for arm64 on: push: diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index c27e0082..f541afd3 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -1,4 +1,4 @@ -name: Build LVGL MicroPython on Linux +name: Build LVGL MicroPython for Linux x64, esp32 and esp32s3 on: push: diff --git a/.github/workflows/macos-intel.yml b/.github/workflows/macos-intel.yml index 94f3bb6b..fdef4483 100644 --- a/.github/workflows/macos-intel.yml +++ b/.github/workflows/macos-intel.yml @@ -1,4 +1,4 @@ -name: Build LVGL MicroPython for MacOS +name: Build LVGL MicroPython for MacOS Intel, esp32 and esp32s3 on: push: diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 6bb95577..2173be5e 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -1,4 +1,4 @@ -name: Build LVGL MicroPython for MacOS +name: Build LVGL MicroPython for MacOS arm64 (M1), esp32 and esp32s3 on: push: From 4c741a8b05786f726e95b8504508a74f09a3c04d Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 19:24:52 +0100 Subject: [PATCH 295/317] Copy artifact, even if unit tests fail --- .github/workflows/macos-intel.yml | 2 +- .github/workflows/macos.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/macos-intel.yml b/.github/workflows/macos-intel.yml index fdef4483..1d2b3c79 100644 --- a/.github/workflows/macos-intel.yml +++ b/.github/workflows/macos-intel.yml @@ -39,6 +39,7 @@ jobs: - name: Build LVGL MicroPython for macOS dev run: | ./scripts/build_mpos.sh macOS + cp lvgl_micropython/build/lvgl_micropy_macOS lvgl_micropython/build/MicroPythonOS_intel_macOS_${{ steps.version.outputs.OS_VERSION }}.bin - name: Run syntax tests on macOS run: | @@ -48,7 +49,6 @@ jobs: - name: Run unit tests on macOS run: | ./tests/unittest.sh - mv lvgl_micropython/build/lvgl_micropy_macOS lvgl_micropython/build/MicroPythonOS_intel_macOS_${{ steps.version.outputs.OS_VERSION }}.bin continue-on-error: true - name: Upload built binary as artifact diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 2173be5e..cae6ff92 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -39,6 +39,7 @@ jobs: - name: Build LVGL MicroPython for macOS dev run: | ./scripts/build_mpos.sh macOS + cp lvgl_micropython/build/lvgl_micropy_macOS lvgl_micropython/build/MicroPythonOS_arm64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin - name: Run syntax tests on macOS run: | @@ -48,7 +49,6 @@ jobs: - name: Run unit tests on macOS run: | ./tests/unittest.sh - mv lvgl_micropython/build/lvgl_micropy_macOS lvgl_micropython/build/MicroPythonOS_arm64_macOS_${{ steps.version.outputs.OS_VERSION }}.bin continue-on-error: true - name: Upload built binary as artifact From bdcf2897dd87312f07c53479ef3e75bb38e46b8f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 21:28:53 +0100 Subject: [PATCH 296/317] Attempt to fix tests/test_graphical_about_app.py on macOS intel --- tests/test_graphical_about_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_graphical_about_app.py b/tests/test_graphical_about_app.py index 9ee66722..34b91c45 100644 --- a/tests/test_graphical_about_app.py +++ b/tests/test_graphical_about_app.py @@ -150,7 +150,7 @@ def test_about_app_shows_os_version(self): self.assertTrue(result, "Failed to start About app") # Wait for UI to render - wait_for_render(iterations=15) + wait_for_render(iterations=150) # Get current screen screen = lv.screen_active() From 5a8949998040f930a59b7ff615bafbd5fab3ba5c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 21:31:25 +0100 Subject: [PATCH 297/317] Attempt to fix test_webserver on arm64 --- tests/test_webserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_webserver.py b/tests/test_webserver.py index fd5af438..d5849f67 100644 --- a/tests/test_webserver.py +++ b/tests/test_webserver.py @@ -81,7 +81,7 @@ async def download_task(): TaskManager.create_task(download_task()) - timeout_seconds = 20.0 + timeout_seconds = 30.0 start_wait = time.time() while not response_state["done"] and (time.time() - start_wait) < timeout_seconds: time.sleep(0.1) From 4b717421355dc86c2f4f622fe13556b4e0ac57ed Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 24 Mar 2026 22:38:59 +0100 Subject: [PATCH 298/317] Update CHANGELOG --- CHANGELOG.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30e9f561..6ffb9945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,19 +3,35 @@ Builtin Apps: - AppStore: update BadgeHub.eu URL -- Hotspot Settings -- WebServer Settings - +- About: show netmask separately, make labels focusable +- HowTo: new onboarding app with auto-start handling to explain controls +- Settings: add sub-groups of setings as separate apps, including WiFi app +- Settings: add Hotspot sub-group (SSID, password, security) +- Settings: add WebServer sub-group (autostart, port, password) +- Launcher: ignore launchers and MPOS settings (except WiFi) Frameworks: -- AudioManager: add support for multiple speakers and microphones -- AudioManager: add support for ADC-based microphone (adc_mic) +- Audio streams: WAV playback/recording improvements (duration/progress, hardware volume control) +- AudioManager: registry/session model, multi-speaker/mic routing, ADC-based mic (adc_mic) +- DownloadManager: explicit certificate handling +- InputManager: pointer detection helpers and board registrations +- SensorManager: refactor to IMU drivers with magnetometer support and desktop IIO fallback +- SharedPreferences: fix None handling +- WebServer: new framework with Linux/macOS fixes and no background thread +- WifiService: hotspot support, IP address helpers, simplified connect/auto-connect +- Websocket library: renamed to uaiowebsocket to avoid conflicts OS: -- ESP32 boards: add webrepl -- New board support: LilyGo T-Display-S3 +- ESP32 boards: bundle WebREPL (disabled by default, password protected, can be enabled in Settings) +- New board support: LilyGo T-Display-S3 (physical and emulated by QEMU) +- New board support: LilyGo T-Watch S3 Plus - New board support: M5Stack Fire - New board support: ODroid Go +- New board support: unPhone 9 +- Fri3d 2024/2026 updates: display reset support using CH32 microcontroller, communicator/expander drivers +- ADC microphone C module and tests +- Build system: switch to static builds for desktop systems to bundle LIBC and fix LIBC version issue +- Build system: add linux-arm64 and macos-intel GitHub workflows to support more precompiled binaries - Add FreeRTOS module for low-level ESP32 functions 0.8.0 From abcf4df5f00d8879f3b5968f708585e3d282240a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 13:08:20 +0100 Subject: [PATCH 299/317] Re-add Over-The-Air update support It got lost when build_mpos.sh was modified to support the unPhone, whose storage is too small to support 2 OTA partitions. --- scripts/build_mpos.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 7eb9750e..8bd97fde 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -109,6 +109,7 @@ echo "Refreshing freezefs..." if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "unphone" ]; then partition_size="4194304" flash_size="16" + otasupport="--ota" extra_configs="" if [ "$target" == "esp32" ]; then BOARD=ESP32_GENERIC @@ -117,6 +118,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "unphone" ]; if [ "$target" == "unphone" ]; then partition_size="3900000" flash_size="8" + otasupport="" # too small for 2 OTA partitions + internal storage fi BOARD=ESP32_GENERIC_S3 BOARD_VARIANT=SPIRAM_OCT @@ -146,8 +148,7 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "unphone" ]; # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y # CONFIG_ADC_MIC_TASK_CORE=1 because with the default (-1) it hangs the CPU # CONFIG_SPIRAM_XIP_FROM_PSRAM: load entire firmware into RAM to reduce SD vs PSRAM contention (recommended at https://github.com/MicroPythonOS/MicroPythonOS/issues/17) -# python3 make.py --ota --partition-size=$partition_size --flash-size=$flash_size esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ - python3 make.py --optimize-size --partition-size=$partition_size --flash-size=$flash_size esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ + python3 make.py "$otasupport" --optimize-size --partition-size=$partition_size --flash-size=$flash_size esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake \ USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake \ USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake \ From 73b90956319d2f2218f142d733ba16b448de7418 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 14:23:53 +0100 Subject: [PATCH 300/317] Add tests/test_graphical_hotspot_password.py --- internal_filesystem/lib/mpos/__init__.py | 8 +- internal_filesystem/lib/mpos/ui/testing.py | 192 +++++++++++++++++- tests/test_graphical_hotspot_password.py | 155 ++++++++++++++ .../test_graphical_imu_calibration_ui_bug.py | 0 4 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 tests/test_graphical_hotspot_password.py mode change 100755 => 100644 tests/test_graphical_imu_calibration_ui_bug.py diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 0f58334f..82ce41c0 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -34,7 +34,9 @@ wait_for_render, capture_screenshot, simulate_click, get_widget_coords, find_label_with_text, verify_text_present, print_screen_labels, find_text_on_screen, click_button, click_label, click_keyboard_button, find_button_with_text, - get_all_widgets_with_text + get_all_widgets_with_text, find_setting_value_label, get_setting_value_text, + verify_setting_value_text, find_dropdown_widget, get_dropdown_options, + find_dropdown_option_index, select_dropdown_option_by_text ) # UI utility functions @@ -92,7 +94,9 @@ "wait_for_render", "capture_screenshot", "simulate_click", "get_widget_coords", "find_label_with_text", "verify_text_present", "print_screen_labels", "find_text_on_screen", "click_button", "click_label", "click_keyboard_button", "find_button_with_text", - "get_all_widgets_with_text", + "get_all_widgets_with_text", "find_setting_value_label", "get_setting_value_text", + "verify_setting_value_text", "find_dropdown_widget", "get_dropdown_options", + "find_dropdown_option_index", "select_dropdown_option_by_text", # Submodules "ui", "config", "net", "content", "time", "sensor_manager", "camera_manager", "sdcard", "audio", "hardware", diff --git a/internal_filesystem/lib/mpos/ui/testing.py b/internal_filesystem/lib/mpos/ui/testing.py index 0c3b2a78..2cfc600b 100644 --- a/internal_filesystem/lib/mpos/ui/testing.py +++ b/internal_filesystem/lib/mpos/ui/testing.py @@ -259,7 +259,6 @@ def get_screen_text_content(obj): pass # Error getting text return texts - def verify_text_present(obj, expected_text): """ Verify that expected text is present somewhere on screen. @@ -281,6 +280,87 @@ def verify_text_present(obj, expected_text): return find_label_with_text(obj, expected_text) is not None +def find_setting_value_label(obj, setting_title_text): + """ + Find the value label associated with a SettingsActivity setting title. + + SettingsActivity renders each setting as a container with two labels: + a title label (large) and a value label (smaller) directly below it. + This helper finds the title label, then returns the sibling value label. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + setting_title_text: Text of the setting title (exact or substring) + + Returns: + LVGL label object for the value if found, None otherwise + + Example: + value_label = find_setting_value_label(lv.screen_active(), "Auth Mode") + if value_label: + assert value_label.get_text() == "(defaults to none)" + """ + title_label = find_label_with_text(obj, setting_title_text) + if not title_label: + return None + try: + parent = title_label.get_parent() + if not parent: + return None + child_count = parent.get_child_count() + for i in range(child_count): + child = parent.get_child(i) + if child is title_label: + continue + try: + if hasattr(child, "get_text"): + text = child.get_text() + if text: + return child + except: + pass + except: + pass + return None + + +def get_setting_value_text(obj, setting_title_text): + """ + Get the value text associated with a SettingsActivity setting title. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + setting_title_text: Text of the setting title (exact or substring) + + Returns: + str or None: The value label text if found + """ + value_label = find_setting_value_label(obj, setting_title_text) + if value_label: + try: + return value_label.get_text() + except: + return None + return None + + +def verify_setting_value_text(obj, setting_title_text, expected_text): + """ + Verify a SettingsActivity value label matches expected text. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + setting_title_text: Text of the setting title (exact or substring) + expected_text: Expected text for the value label (exact match) + + Returns: + bool: True if value label text matches expected, False otherwise + """ + value_text = get_setting_value_text(obj, setting_title_text) + return value_text == expected_text + + + def text_to_hex(text): """ Convert text to hex representation for debugging. @@ -414,6 +494,116 @@ def find_button_with_text(obj, search_text): return None +def find_dropdown_widget(obj): + """ + Find a dropdown widget in the object hierarchy. + + Args: + obj: LVGL object to search (typically lv.screen_active()) + + Returns: + LVGL dropdown object if found, None otherwise + """ + def find_dropdown_recursive(node): + try: + if node.__class__.__name__ == "dropdown" or hasattr(node, "get_selected"): + if hasattr(node, "get_options"): + return node + except: + pass + + try: + child_count = node.get_child_count() + except: + return None + + for i in range(child_count): + child = node.get_child(i) + result = find_dropdown_recursive(child) + if result: + return result + return None + + return find_dropdown_recursive(obj) + + +def get_dropdown_options(dropdown): + """ + Get dropdown options as a list of strings. + + Args: + dropdown: LVGL dropdown widget + + Returns: + list: List of option strings (order preserved) + """ + try: + options = dropdown.get_options() + if options: + lines = options.split("\n") + return [line for line in lines if line] + except: + pass + return [] + + +def find_dropdown_option_index(dropdown, option_text, allow_partial=True): + """ + Find the index of an option in a dropdown by text. + + Args: + dropdown: LVGL dropdown widget + option_text: Text to search for + allow_partial: If True, match substring (default: True) + + Returns: + int or None: Index of matching option + """ + options = get_dropdown_options(dropdown) + if options: + for idx, text in enumerate(options): + if (allow_partial and option_text in text) or (not allow_partial and option_text == text): + return idx + return None + + try: + option_count = dropdown.get_option_count() + except: + option_count = 0 + + for idx in range(option_count): + try: + text = dropdown.get_option_text(idx) + if (allow_partial and option_text in text) or (not allow_partial and option_text == text): + return idx + except: + pass + + return None + + +def select_dropdown_option_by_text(dropdown, option_text, allow_partial=True): + """ + Select a dropdown option by its text. + + Args: + dropdown: LVGL dropdown widget + option_text: Text to select + allow_partial: If True, match substring (default: True) + + Returns: + bool: True if option was found and selected + """ + idx = find_dropdown_option_index(dropdown, option_text, allow_partial=allow_partial) + if idx is None: + return False + try: + dropdown.set_selected(idx) + return True + except: + return False + + def get_keyboard_button_coords(keyboard, button_text): """ Get the coordinates of a specific button on an LVGL keyboard/buttonmatrix. diff --git a/tests/test_graphical_hotspot_password.py b/tests/test_graphical_hotspot_password.py new file mode 100644 index 00000000..28604986 --- /dev/null +++ b/tests/test_graphical_hotspot_password.py @@ -0,0 +1,155 @@ +""" +Graphical test for hotspot settings password defaults. + +This test verifies that the hotspot settings screen shows the +"(defaults to none)" value under the "Auth Mode" setting. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_hotspot_password.py + Device: ./tests/unittest.sh tests/test_graphical_hotspot_password.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui +from mpos import ( + AppManager, + wait_for_render, + print_screen_labels, + click_button, + verify_text_present, + find_setting_value_label, + get_setting_value_text, + click_label, + simulate_click, + get_widget_coords, + select_dropdown_option_by_text, + find_dropdown_widget, + SharedPreferences, +) + + +class TestGraphicalHotspotPassword(unittest.TestCase): + """Test suite for hotspot password defaults in settings UI.""" + + def _reset_hotspot_preferences(self): + """Clear hotspot preferences to ensure default values are shown.""" + prefs = SharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + editor.remove_all() + editor.commit() + + def _open_hotspot_settings_screen(self): + """Start hotspot app and open the Settings screen.""" + result = AppManager.start_app("com.micropythonos.settings.hotspot") + self.assertTrue(result, "Failed to start hotspot settings app") + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nInitial screen labels:") + print_screen_labels(screen) + + self.assertTrue( + click_button("Settings"), + "Could not find Settings button in hotspot app", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nSettings screen labels:") + print_screen_labels(screen) + return screen + + def tearDown(self): + """Clean up after each test method.""" + # Navigate back to launcher to close any opened apps + try: + mpos.ui.back_screen() + wait_for_render(5) + except: + pass + + def test_auth_mode_defaults_label(self): + """Verify Auth Mode shows defaults to none in hotspot settings.""" + print("\n=== Starting Hotspot Settings Auth Mode default test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_settings_screen() + + self.assertTrue( + verify_text_present(screen, "Auth Mode"), + "Auth Mode setting title not found on settings screen", + ) + + value_label = find_setting_value_label(screen, "Auth Mode") + self.assertIsNotNone( + value_label, + "Could not find value label for Auth Mode setting", + ) + + value_text = get_setting_value_text(screen, "Auth Mode") + print(f"Auth Mode value text: {value_text}") + self.assertEqual( + value_text, + "(defaults to none)", + "Auth Mode value text did not match expected default", + ) + + print("\n=== Hotspot settings Auth Mode default test completed ===") + + def test_auth_mode_dropdown_select_wpa2(self): + """Change Auth Mode via dropdown and verify stored value label.""" + print("\n=== Starting Hotspot Settings Auth Mode dropdown test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_settings_screen() + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nAuth Mode edit screen labels:") + print_screen_labels(screen) + + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "WPA2", allow_partial=True), + "Could not select WPA2 option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nSettings screen labels after save:") + print_screen_labels(screen) + + value_text = get_setting_value_text(screen, "Auth Mode") + print(f"Auth Mode value text after save: {value_text}") + self.assertEqual( + value_text, + "wpa2", + "Auth Mode value did not update to wpa2", + ) + + print("\n=== Hotspot settings Auth Mode dropdown test completed ===") + + +if __name__ == "__main__": + pass diff --git a/tests/test_graphical_imu_calibration_ui_bug.py b/tests/test_graphical_imu_calibration_ui_bug.py old mode 100755 new mode 100644 From ed9abf7e3d45f51b8ef668bac6fd35e9fdaeba20 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 14:30:16 +0100 Subject: [PATCH 301/317] Hotspot test and fix --- .../assets/hotspot_settings.py | 2 +- tests/test_graphical_hotspot_password.py | 139 ++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py index 2f31ec07..1bd58307 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py @@ -127,7 +127,7 @@ def toggle_hotspot(self, new_value): self.refresh_status() def should_show_password(self, setting): - authmode = self.prefs.get_string("authmode", None) + authmode = self.ui_prefs.get_string("authmode", None) if authmode is None: authmode = self.DEFAULTS["authmode"] return authmode != "none" diff --git a/tests/test_graphical_hotspot_password.py b/tests/test_graphical_hotspot_password.py index 28604986..0dded516 100644 --- a/tests/test_graphical_hotspot_password.py +++ b/tests/test_graphical_hotspot_password.py @@ -97,6 +97,145 @@ def test_auth_mode_defaults_label(self): print("\n=== Hotspot settings Auth Mode default test completed ===") + def test_auth_mode_change_hides_password_setting(self): + """Verify Password setting disappears after switching Auth Mode to None.""" + print("\n=== Starting Hotspot Settings Password hide test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_settings_screen() + + self.assertFalse( + verify_text_present(screen, "Password"), + "Password setting should not be visible with Auth Mode None", + ) + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "WPA2", allow_partial=True), + "Could not select WPA2 option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + self.assertTrue( + verify_text_present(screen, "Password"), + "Password setting did not appear after selecting WPA2", + ) + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting to revert", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found on revert") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates on revert") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "None", allow_partial=True), + "Could not select None option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings (revert)", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nSettings screen labels after Auth Mode revert:") + print_screen_labels(screen) + + self.assertFalse( + verify_text_present(screen, "Password"), + "Password setting did not disappear after selecting None", + ) + + print("\n=== Hotspot settings Password hide test completed ===") + + def test_auth_mode_change_shows_password_setting(self): + """Verify Password setting appears after switching Auth Mode to WPA2.""" + print("\n=== Starting Hotspot Settings Password visibility test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_settings_screen() + + self.assertFalse( + verify_text_present(screen, "Password"), + "Password setting should not be visible with Auth Mode None", + ) + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "WPA2", allow_partial=True), + "Could not select WPA2 option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nSettings screen labels after Auth Mode change:") + print_screen_labels(screen) + + self.assertTrue( + verify_text_present(screen, "Password"), + "Password setting did not appear after selecting WPA2", + ) + + print("\n=== Hotspot settings Password visibility test completed ===") + def test_auth_mode_dropdown_select_wpa2(self): """Change Auth Mode via dropdown and verify stored value label.""" print("\n=== Starting Hotspot Settings Auth Mode dropdown test ===") From 583e368a435eee5c511d7066ae4533aa06749299 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 14:38:23 +0100 Subject: [PATCH 302/317] util.py: add mkdir_parents() like mkdir -p --- internal_filesystem/lib/mpos/util.py | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal_filesystem/lib/mpos/util.py b/internal_filesystem/lib/mpos/util.py index 054f5784..eb79d521 100644 --- a/internal_filesystem/lib/mpos/util.py +++ b/internal_filesystem/lib/mpos/util.py @@ -1,4 +1,5 @@ import lvgl as lv +import os def urldecode(s): result = "" @@ -31,3 +32,40 @@ def print_lvgl_widget(obj, depth=0): print_lvgl_widget(obj.get_child(childnr), depth+1) else: print("print_lvgl_widget called on 'None'") + + +def mkdir_parents(path): + """ + Create directory and all parent directories like `mkdir -p`. + + Creates intermediate directories as needed, does nothing if the path + already exists, and raises if any component exists as a non-directory. + """ + if not path: + return + + def _is_dir(stat_result): + return (stat_result[0] & 0x4000) != 0 + + parts = path.split("/") + current = "/" if path.startswith("/") else "" + + for part in parts: + if not part: + continue + if current in ("", "/"): + current = f"{current}{part}" + else: + current = f"{current}/{part}" + try: + stat_result = os.stat(current) + except OSError: + try: + os.mkdir(current) + except OSError: + stat_result = os.stat(current) + if not _is_dir(stat_result): + raise + else: + if not _is_dir(stat_result): + raise OSError("Path component exists and is not a directory") From 0912a5d536ee5a67ed0f1bc478e76a44d747fe47 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 14:49:47 +0100 Subject: [PATCH 303/317] Remove screenshot from tests/test_graphical_about_app.py --- tests/test_graphical_about_app.py | 42 ++----------------------------- 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/tests/test_graphical_about_app.py b/tests/test_graphical_about_app.py index 34b91c45..165affc7 100644 --- a/tests/test_graphical_about_app.py +++ b/tests/test_graphical_about_app.py @@ -7,8 +7,7 @@ This is a proof of concept for graphical testing that: 1. Starts an app programmatically 2. Verifies UI content via direct widget inspection -3. Captures screenshots for visual regression testing -4. Works on both desktop and device +3. Works on both desktop and device Usage: Desktop: ./tests/unittest.sh tests/test_graphical_about_app.py @@ -18,16 +17,14 @@ import unittest import lvgl as lv import mpos.ui -import os from mpos import ( wait_for_render, - capture_screenshot, find_label_with_text, verify_text_present, print_screen_labels, DeviceInfo, BuildInfo, - AppManager + AppManager, ) @@ -36,21 +33,6 @@ class TestGraphicalAboutApp(unittest.TestCase): def setUp(self): """Set up test fixtures before each test method.""" - # Get absolute path to screenshots directory - # When running tests, we're in internal_filesystem/, so go up one level - import sys - if sys.platform == "esp32": - self.screenshot_dir = "tests/screenshots" - else: - # On desktop, tests directory is in parent - self.screenshot_dir = "../tests/screenshots" - - # Ensure screenshots directory exists - try: - os.mkdir(self.screenshot_dir) - except OSError: - pass # Directory already exists - # Store hardware ID for verification self.hardware_id = DeviceInfo.hardware_id print(f"Testing with hardware ID: {self.hardware_id}") @@ -73,7 +55,6 @@ def test_about_app_shows_correct_hardware_id(self): 2. Wait for UI to render 3. Find the "Hardware ID:" label 4. Verify it contains the actual hardware ID - 5. Capture screenshot for visual verification """ print("\n=== Starting About app test ===") @@ -116,25 +97,6 @@ def test_about_app_shows_correct_hardware_id(self): f"Hardware ID '{self.hardware_id}' not found on screen" ) - # Capture screenshot for visual regression testing - screenshot_path = f"{self.screenshot_dir}/about_app_{self.hardware_id}.raw" - print(f"\nCapturing screenshot to: {screenshot_path}") - - try: - buffer = capture_screenshot(screenshot_path, width=320, height=240) - print(f"Screenshot captured: {len(buffer)} bytes") - - # Verify screenshot file was created - stat = os.stat(screenshot_path) - self.assertTrue( - stat[6] > 0, # stat[6] is file size - "Screenshot file is empty" - ) - print(f"Screenshot file size: {stat[6]} bytes") - - except Exception as e: - self.fail(f"Failed to capture screenshot: {e}") - print("\n=== About app test completed successfully ===") def test_about_app_shows_os_version(self): From 42c92f3fc950f0251df66b7949f388c6c53ab506 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 14:51:29 +0100 Subject: [PATCH 304/317] Create new dedicated tests/test_screenshot.py --- tests/test_screenshot.py | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_screenshot.py diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py new file mode 100644 index 00000000..bcff9538 --- /dev/null +++ b/tests/test_screenshot.py @@ -0,0 +1,69 @@ +""" +Graphical test for screenshot capture. + +This test focuses on screenshot capture for visual regression testing. + +Usage: + Desktop: ./tests/unittest.sh tests/test_screenshot.py + Device: ./tests/unittest.sh tests/test_screenshot.py --ondevice +""" + +import os +import sys +import unittest +import mpos.ui +from mpos import AppManager, DeviceInfo, capture_screenshot, wait_for_render + + +class TestScreenshotCapture(unittest.TestCase): + """Test suite for screenshot capture.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "../tests/screenshots" + + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass + + self.hardware_id = DeviceInfo.hardware_id + print(f"Testing with hardware ID: {self.hardware_id}") + + def tearDown(self): + """Clean up after each test method.""" + try: + mpos.ui.back_screen() + wait_for_render(5) + except Exception: + pass + + def test_capture_about_app_screenshot(self): + """Capture screenshot of the About app for regression testing.""" + print("\n=== Starting About app screenshot test ===") + + result = AppManager.start_app("com.micropythonos.about") + self.assertTrue(result, "Failed to start About app") + + wait_for_render(iterations=15) + + screenshot_path = f"{self.screenshot_dir}/about_app_{self.hardware_id}.raw" + print(f"\nCapturing screenshot to: {screenshot_path}") + + try: + buffer = capture_screenshot(screenshot_path, width=320, height=240) + print(f"Screenshot captured: {len(buffer)} bytes") + + stat = os.stat(screenshot_path) + self.assertTrue( + stat[6] > 0, + "Screenshot file is empty", + ) + print(f"Screenshot file size: {stat[6]} bytes") + except Exception as exc: + self.fail(f"Failed to capture screenshot: {exc}") + + print("\n=== About app screenshot test completed successfully ===") From d24a52c54cdb3b7a49c5b4d339bb86059d6ef735 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 15:16:17 +0100 Subject: [PATCH 305/317] Simplify tests --- tests/base/__init__.py | 1 - tests/base/graphical_test_base.py | 48 ------------- tests/test_graphical_camera_settings.py | 42 +---------- tests/test_graphical_custom_keyboard.py | 71 +------------------ tests/test_graphical_imu_calibration.py | 34 --------- .../test_graphical_imu_calibration_ui_bug.py | 7 -- tests/test_graphical_keyboard_styling.py | 29 +------- tests/test_graphical_osupdate.py | 68 +----------------- 8 files changed, 10 insertions(+), 290 deletions(-) diff --git a/tests/base/__init__.py b/tests/base/__init__.py index f83aed8e..0d7c5b19 100644 --- a/tests/base/__init__.py +++ b/tests/base/__init__.py @@ -11,7 +11,6 @@ class TestMyApp(GraphicalTestBase): def test_something(self): # self.screen is already set up - # self.screenshot_dir is configured pass """ diff --git a/tests/base/graphical_test_base.py b/tests/base/graphical_test_base.py index cac2993a..b2497060 100644 --- a/tests/base/graphical_test_base.py +++ b/tests/base/graphical_test_base.py @@ -4,7 +4,6 @@ This class provides common setup/teardown patterns for tests that require LVGL/UI initialization. It handles: - Screen creation and cleanup -- Screenshot directory configuration - Common UI testing utilities Usage: @@ -13,17 +12,13 @@ class TestMyApp(GraphicalTestBase): def test_something(self): # self.screen is already set up (320x240) - # self.screenshot_dir is configured label = lv.label(self.screen) label.set_text("Hello") self.wait_for_render() - self.capture_screenshot("my_test") """ import unittest import lvgl as lv -import sys -import os class GraphicalTestBase(unittest.TestCase): @@ -42,33 +37,12 @@ class GraphicalTestBase(unittest.TestCase): Instance Attributes: screen: The LVGL screen object for the test - screenshot_dir: Path to the screenshots directory """ SCREEN_WIDTH = 320 SCREEN_HEIGHT = 240 DEFAULT_RENDER_ITERATIONS = 5 - @classmethod - def setUpClass(cls): - """ - Set up class-level fixtures. - - Configures the screenshot directory based on platform. - """ - # Determine screenshot directory based on platform - if sys.platform == "esp32": - cls.screenshot_dir = "tests/screenshots" - else: - # On desktop, tests directory is in parent - cls.screenshot_dir = "../tests/screenshots" - - # Ensure screenshots directory exists - try: - os.mkdir(cls.screenshot_dir) - except OSError: - pass # Directory already exists - def setUp(self): """ Set up test fixtures before each test method. @@ -103,28 +77,6 @@ def wait_for_render(self, iterations=None): iterations = self.DEFAULT_RENDER_ITERATIONS wait_for_render(iterations) - def capture_screenshot(self, name, width=None, height=None): - """ - Capture a screenshot with standardized naming. - - Args: - name: Name for the screenshot (without extension) - width: Screenshot width (default: SCREEN_WIDTH) - height: Screenshot height (default: SCREEN_HEIGHT) - - Returns: - bytes: The screenshot buffer - """ - from mpos import capture_screenshot - - if width is None: - width = self.SCREEN_WIDTH - if height is None: - height = self.SCREEN_HEIGHT - - path = f"{self.screenshot_dir}/{name}.raw" - return capture_screenshot(path, width=width, height=height) - def find_label_with_text(self, text, parent=None): """ Find a label containing the specified text. diff --git a/tests/test_graphical_camera_settings.py b/tests/test_graphical_camera_settings.py index 08c404f5..3f44e373 100644 --- a/tests/test_graphical_camera_settings.py +++ b/tests/test_graphical_camera_settings.py @@ -20,11 +20,9 @@ import unittest import lvgl as lv import mpos.ui -import os import sys from mpos import ( wait_for_render, - capture_screenshot, find_label_with_text, find_button_with_text, verify_text_present, @@ -51,19 +49,6 @@ def setUp(self): except: self.skipTest("No camera module available (webcam or internal)") - # Get absolute path to screenshots directory - import sys - if sys.platform == "esp32": - self.screenshot_dir = "tests/screenshots" - else: - self.screenshot_dir = "../tests/screenshots" - - # Ensure screenshots directory exists - try: - os.mkdir(self.screenshot_dir) - except OSError: - pass # Directory already exists - def tearDown(self): """Clean up after each test method.""" # Navigate back to launcher (closes the camera app) @@ -109,10 +94,9 @@ def test_settings_button_click_no_crash(self): Steps: 1. Start camera app 2. Wait for camera to initialize - 3. Capture initial screenshot - 4. Click settings button (found dynamically by lv.SYMBOL.SETTINGS) - 5. Verify settings dialog opened - 6. If we get here without crash, test passes + 3. Click settings button (found dynamically by lv.SYMBOL.SETTINGS) + 4. Verify settings dialog opened + 5. If we get here without crash, test passes """ print("\n=== Testing settings button click (no crash) ===") @@ -130,11 +114,6 @@ def test_settings_button_click_no_crash(self): print("\nInitial screen labels:") print_screen_labels(screen) - # Capture screenshot before clicking settings - screenshot_path = f"{self.screenshot_dir}/camera_before_settings.raw" - print(f"\nCapturing initial screenshot: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - # Find and click settings button dynamically found = self._find_and_click_settings_button(screen) self.assertTrue(found, "Settings button with lv.SYMBOL.SETTINGS not found on screen") @@ -171,11 +150,6 @@ def test_settings_button_click_no_crash(self): "Settings screen did not open (no Save/Cancel buttons or expected UI elements found)" ) - # Capture screenshot of settings dialog - screenshot_path = f"{self.screenshot_dir}/camera_settings_dialog.raw" - print(f"\nCapturing settings dialog screenshot: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - # If we got here without segfault, the test passes! print("\n✓ Settings button clicked successfully without crash!") @@ -296,11 +270,6 @@ def test_resolution_change_no_crash(self): wait_for_render(iterations=15) - # Capture screenshot - screenshot_path = f"{self.screenshot_dir}/camera_dropdown_open.raw" - print(f"Capturing dropdown screenshot: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - screen = lv.screen_active() print("\nScreen after dropdown interaction:") print_screen_labels(screen) @@ -319,11 +288,6 @@ def test_resolution_change_no_crash(self): 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!") diff --git a/tests/test_graphical_custom_keyboard.py b/tests/test_graphical_custom_keyboard.py index 872d2439..3ccf329d 100644 --- a/tests/test_graphical_custom_keyboard.py +++ b/tests/test_graphical_custom_keyboard.py @@ -2,7 +2,7 @@ Graphical tests for MposKeyboard. Tests keyboard visual appearance, text input via simulated button presses, -and mode switching. Captures screenshots for regression testing. +and mode switching. Usage: Desktop: ./tests/unittest.sh tests/test_graphical_custom_keyboard.py @@ -11,9 +11,7 @@ import unittest import lvgl as lv -import sys -import os -from mpos import MposKeyboard, wait_for_render, capture_screenshot, AppearanceManager +from mpos import MposKeyboard, wait_for_render, AppearanceManager class TestGraphicalMposKeyboard(unittest.TestCase): @@ -21,20 +19,7 @@ class TestGraphicalMposKeyboard(unittest.TestCase): def setUp(self): """Set up test fixtures before each test method.""" - # Determine screenshot directory - if sys.platform == "esp32": - self.screenshot_dir = "tests/screenshots" - else: - self.screenshot_dir = "../tests/screenshots" - - # Ensure screenshots directory exists - try: - os.mkdir(self.screenshot_dir) - except OSError: - pass # Directory already exists - print(f"\n=== Graphical Keyboard Test Setup ===") - print(f"Platform: {sys.platform}") def tearDown(self): """Clean up after each test method.""" @@ -102,7 +87,7 @@ def test_keyboard_lowercase_appearance(self): """ Test keyboard appearance in lowercase mode. - Verifies that the keyboard renders correctly and captures screenshot. + Verifies that the keyboard renders correctly. """ print("\n=== Testing lowercase keyboard appearance ===") @@ -112,16 +97,6 @@ def test_keyboard_lowercase_appearance(self): keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) wait_for_render(10) - # Capture screenshot - screenshot_path = f"{self.screenshot_dir}/custom_keyboard_lowercase.raw" - print(f"Capturing screenshot: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - - # Verify screenshot was created - stat = os.stat(screenshot_path) - self.assertTrue(stat[6] > 0, "Screenshot file is empty") - print(f"Screenshot captured: {stat[6]} bytes") - print("=== Lowercase appearance test PASSED ===") def test_keyboard_uppercase_appearance(self): @@ -134,16 +109,6 @@ def test_keyboard_uppercase_appearance(self): keyboard.set_mode(MposKeyboard.MODE_UPPERCASE) wait_for_render(10) - # Capture screenshot - screenshot_path = f"{self.screenshot_dir}/custom_keyboard_uppercase.raw" - print(f"Capturing screenshot: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - - # Verify screenshot was created - stat = os.stat(screenshot_path) - self.assertTrue(stat[6] > 0, "Screenshot file is empty") - print(f"Screenshot captured: {stat[6]} bytes") - print("=== Uppercase appearance test PASSED ===") def test_keyboard_numbers_appearance(self): @@ -156,16 +121,6 @@ def test_keyboard_numbers_appearance(self): keyboard.set_mode(MposKeyboard.MODE_NUMBERS) wait_for_render(10) - # Capture screenshot - screenshot_path = f"{self.screenshot_dir}/custom_keyboard_numbers.raw" - print(f"Capturing screenshot: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - - # Verify screenshot was created - stat = os.stat(screenshot_path) - self.assertTrue(stat[6] > 0, "Screenshot file is empty") - print(f"Screenshot captured: {stat[6]} bytes") - print("=== Numbers appearance test PASSED ===") def test_keyboard_specials_appearance(self): @@ -178,16 +133,6 @@ def test_keyboard_specials_appearance(self): keyboard.set_mode(MposKeyboard.MODE_SPECIALS) wait_for_render(10) - # Capture screenshot - screenshot_path = f"{self.screenshot_dir}/custom_keyboard_specials.raw" - print(f"Capturing screenshot: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - - # Verify screenshot was created - stat = os.stat(screenshot_path) - self.assertTrue(stat[6] > 0, "Screenshot file is empty") - print(f"Screenshot captured: {stat[6]} bytes") - print("=== Specials appearance test PASSED ===") def test_keyboard_visibility_light_mode(self): @@ -275,11 +220,6 @@ def test_keyboard_with_standard_comparison(self): lv.screen_load(screen) wait_for_render(20) - # Capture standard keyboard - screenshot_path = f"{self.screenshot_dir}/keyboard_standard_comparison.raw" - print(f"Capturing standard keyboard: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - # Clean up lv.screen_load(lv.obj()) wait_for_render(5) @@ -301,11 +241,6 @@ def test_keyboard_with_standard_comparison(self): lv.screen_load(screen2) wait_for_render(20) - # Capture custom keyboard - screenshot_path = f"{self.screenshot_dir}/keyboard_custom_comparison.raw" - print(f"Capturing custom keyboard: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - print("=== Comparison test PASSED ===") diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py index 4c20b1ae..204ddf63 100644 --- a/tests/test_graphical_imu_calibration.py +++ b/tests/test_graphical_imu_calibration.py @@ -12,12 +12,9 @@ import unittest import lvgl as lv import mpos.ui -import os -import sys import time from mpos import ( wait_for_render, - capture_screenshot, find_label_with_text, verify_text_present, print_screen_labels, @@ -34,20 +31,6 @@ class TestIMUCalibration(unittest.TestCase): """Test suite for IMU calibration activities.""" - def setUp(self): - """Set up test fixtures.""" - # Get screenshot directory - if sys.platform == "esp32": - self.screenshot_dir = "tests/screenshots" - else: - self.screenshot_dir = "../tests/screenshots" # it runs from internal_filesystem/ - - # Ensure directory exists - try: - os.mkdir(self.screenshot_dir) - except OSError: - pass - def tearDown(self): """Clean up after test.""" # Navigate back to launcher @@ -82,15 +65,6 @@ def test_check_calibration_activity_loads(self): self.assertTrue(verify_text_present(screen, "Accel."), "Accel. label not found") self.assertTrue(verify_text_present(screen, "Gyro"), "Gyro label not found") - # Capture screenshot - screenshot_path = f"{self.screenshot_dir}/check_imu_calibration.raw" - print(f"Capturing screenshot: {screenshot_path}") - capture_screenshot(screenshot_path) - - # Verify screenshot saved - stat = os.stat(screenshot_path) - self.assertTrue(stat[6] > 0, "Screenshot file is empty") - print("=== CheckIMUCalibrationActivity test complete ===") def test_calibrate_activity_flow(self): @@ -118,10 +92,6 @@ def test_calibrate_activity_flow(self): self.assertTrue(verify_text_present(screen, "Place device on flat"), "Instructions not shown") - # Capture initial state - screenshot_path = f"{self.screenshot_dir}/calibrate_imu_01_initial.raw" - capture_screenshot(screenshot_path) - # Click "Calibrate Now" button to start calibration calibrate_btn = find_button_with_text(screen, "Calibrate Now") self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate Now' button") @@ -144,10 +114,6 @@ def test_calibrate_activity_flow(self): verify_text_present(screen, "offsets"), "Calibration offsets not shown") - # Capture completion state - screenshot_path = f"{self.screenshot_dir}/calibrate_imu_02_complete.raw" - capture_screenshot(screenshot_path) - print("=== CalibrateIMUActivity flow test complete ===") def test_navigation_from_check_to_calibrate(self): diff --git a/tests/test_graphical_imu_calibration_ui_bug.py b/tests/test_graphical_imu_calibration_ui_bug.py index 88a90e42..ec44511f 100644 --- a/tests/test_graphical_imu_calibration_ui_bug.py +++ b/tests/test_graphical_imu_calibration_ui_bug.py @@ -23,7 +23,6 @@ find_label_with_text, get_widget_coords, print_screen_labels, - capture_screenshot, click_label, click_button, find_text_on_screen, @@ -85,9 +84,6 @@ def test_imu_calibration_bug_test(self): 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 = [] @@ -154,9 +150,6 @@ def test_imu_calibration_bug_test(self): 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()): diff --git a/tests/test_graphical_keyboard_styling.py b/tests/test_graphical_keyboard_styling.py index 54d8f0ed..a895eb1e 100644 --- a/tests/test_graphical_keyboard_styling.py +++ b/tests/test_graphical_keyboard_styling.py @@ -5,9 +5,8 @@ in both light and dark modes. It checks for the bug where keyboard buttons appear white-on-white in light mode on ESP32. -The test uses two approaches: -1. Programmatic: Query LVGL style properties to verify button background colors -2. Visual: Capture screenshots for manual verification and regression testing +The test uses a programmatic approach: Query LVGL style properties to verify +button background colors. This test should INITIALLY FAIL, demonstrating the bug before the fix is applied. @@ -21,10 +20,8 @@ import mpos.ui import mpos.config import sys -import os from mpos import ( wait_for_render, - capture_screenshot, AppearanceManager, ) @@ -34,18 +31,6 @@ class TestKeyboardStyling(unittest.TestCase): def setUp(self): """Set up test fixtures before each test method.""" - # Determine screenshot directory - if sys.platform == "esp32": - self.screenshot_dir = "tests/screenshots" - else: - self.screenshot_dir = "../tests/screenshots" - - # Ensure screenshots directory exists - try: - os.mkdir(self.screenshot_dir) - except OSError: - pass # Directory already exists - # Save current theme setting prefs = mpos.config.SharedPreferences("theme_settings") self.original_theme = prefs.get_string("theme_light_dark", "light") @@ -243,11 +228,6 @@ def test_keyboard_buttons_visible_in_light_mode(self): print(f" Screen background: {screen_bg}") print(f" Button background: {button_bg}") - # Capture screenshot - screenshot_path = f"{self.screenshot_dir}/keyboard_light_mode.raw" - print(f"\nCapturing screenshot: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - # Verify contrast print("\nChecking button/screen contrast...") has_contrast = self._color_contrast_sufficient(button_bg, screen_bg, min_difference=20) @@ -297,11 +277,6 @@ def test_keyboard_buttons_visible_in_dark_mode(self): print(f" Screen background: {screen_bg}") print(f" Button background: {button_bg}") - # Capture screenshot - screenshot_path = f"{self.screenshot_dir}/keyboard_dark_mode.raw" - print(f"\nCapturing screenshot: {screenshot_path}") - capture_screenshot(screenshot_path, width=320, height=240) - # Verify contrast print("\nChecking button/screen contrast...") has_contrast = self._color_contrast_sufficient(button_bg, screen_bg, min_difference=20) diff --git a/tests/test_graphical_osupdate.py b/tests/test_graphical_osupdate.py index 7011fd71..f489e1f1 100644 --- a/tests/test_graphical_osupdate.py +++ b/tests/test_graphical_osupdate.py @@ -1,18 +1,13 @@ import unittest import lvgl as lv import mpos -import time -import sys -import os # Import graphical test helper from mpos import ( wait_for_render, - capture_screenshot, find_label_with_text, verify_text_present, print_screen_labels, - DeviceInfo, BuildInfo, AppManager ) @@ -107,8 +102,8 @@ def test_initial_status_message_without_wifi(self): verify_text_present(screen, "WiFi") self.assertTrue(checking_found, "Should show some status message") - def test_screenshot_initial_state(self): - """Capture screenshot of initial app state.""" + def test_initial_state_labels(self): + """Print initial app labels for debugging.""" result = AppManager.start_app("com.micropythonos.osupdate") self.assertTrue(result) wait_for_render(20) @@ -122,19 +117,6 @@ def test_screenshot_initial_state(self): class TestOSUpdateGraphicalStatusMessages(unittest.TestCase): """Graphical tests for OSUpdate status messages.""" - def setUp(self): - """Set up test fixtures.""" - self.hardware_id = DeviceInfo.hardware_id - self.screenshot_dir = "tests/screenshots" - - try: - os.stat(self.screenshot_dir) - except OSError: - try: - os.mkdir(self.screenshot_dir) - except OSError: - pass - def tearDown(self): """Clean up after test.""" mpos.ui.back_screen() @@ -176,49 +158,3 @@ def test_all_labels_readable(self): self.assertTrue(version_found, "Version label should be present and readable") -class TestOSUpdateGraphicalScreenshots(unittest.TestCase): - """Screenshot tests for visual regression testing.""" - - def setUp(self): - """Set up test fixtures.""" - self.hardware_id = DeviceInfo.hardware_id - self.screenshot_dir = "tests/screenshots" - - try: - os.stat(self.screenshot_dir) - except OSError: - try: - os.mkdir(self.screenshot_dir) - except OSError: - pass - - def tearDown(self): - """Clean up after test.""" - mpos.ui.back_screen() - wait_for_render(5) - - def test_capture_main_screen(self): - """Capture screenshot of main OSUpdate screen.""" - result = AppManager.start_app("com.micropythonos.osupdate") - self.assertTrue(result) - wait_for_render(20) - - - def test_capture_with_labels_visible(self): - """Capture screenshot ensuring all text is visible.""" - result = AppManager.start_app("com.micropythonos.osupdate") - self.assertTrue(result) - wait_for_render(20) - - screen = lv.screen_active() - - # Verify key elements are visible before screenshot (case insensitive) - has_version = verify_text_present(screen, "Installed") or verify_text_present(screen, "version") - # Button text can be "Update OS", "Reinstall\nsame version", or "Install\nolder version" - has_button = verify_text_present(screen, "Update") or verify_text_present(screen, "update") or \ - verify_text_present(screen, "Reinstall") or verify_text_present(screen, "Install") - - self.assertTrue(has_version, "Version label should be visible") - self.assertTrue(has_button, "Update button should be visible") - - From 02a897dd06bfdafe95c6057007c83235b36eceb1 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 15:24:34 +0100 Subject: [PATCH 306/317] Rename test --- ...enshot.py => test_graphical_screenshot.py} | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) rename tests/{test_screenshot.py => test_graphical_screenshot.py} (72%) diff --git a/tests/test_screenshot.py b/tests/test_graphical_screenshot.py similarity index 72% rename from tests/test_screenshot.py rename to tests/test_graphical_screenshot.py index bcff9538..b96565a4 100644 --- a/tests/test_screenshot.py +++ b/tests/test_graphical_screenshot.py @@ -12,7 +12,7 @@ import sys import unittest import mpos.ui -from mpos import AppManager, DeviceInfo, capture_screenshot, wait_for_render +from mpos import AppManager, DeviceInfo, DisplayMetrics, capture_screenshot, wait_for_render class TestScreenshotCapture(unittest.TestCase): @@ -21,7 +21,7 @@ class TestScreenshotCapture(unittest.TestCase): def setUp(self): """Set up test fixtures before each test method.""" if sys.platform == "esp32": - self.screenshot_dir = "tests/screenshots" + self.screenshot_dir = "screenshots" else: self.screenshot_dir = "../tests/screenshots" @@ -54,7 +54,9 @@ def test_capture_about_app_screenshot(self): print(f"\nCapturing screenshot to: {screenshot_path}") try: - buffer = capture_screenshot(screenshot_path, width=320, height=240) + width = DisplayMetrics.width() + height = DisplayMetrics.height() + buffer = capture_screenshot(screenshot_path, width=width, height=height) print(f"Screenshot captured: {len(buffer)} bytes") stat = os.stat(screenshot_path) @@ -62,8 +64,20 @@ def test_capture_about_app_screenshot(self): stat[6] > 0, "Screenshot file is empty", ) + expected_size = width * height * 2 + self.assertEqual( + stat[6], + expected_size, + f"Screenshot file size {stat[6]} does not match expected {expected_size}", + ) print(f"Screenshot file size: {stat[6]} bytes") except Exception as exc: self.fail(f"Failed to capture screenshot: {exc}") + finally: + try: + print(f"Removing screenshot {screenshot_path}") + os.remove(screenshot_path) + except OSError: + pass print("\n=== About app screenshot test completed successfully ===") From e65c48cbf69079cb32bf0a3846d335d838eca495 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 15:32:28 +0100 Subject: [PATCH 307/317] Add tests/test_graphical_hotspot_then_station.py --- tests/test_graphical_hotspot_then_station.py | 71 ++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/test_graphical_hotspot_then_station.py diff --git a/tests/test_graphical_hotspot_then_station.py b/tests/test_graphical_hotspot_then_station.py new file mode 100644 index 00000000..4fa421c4 --- /dev/null +++ b/tests/test_graphical_hotspot_then_station.py @@ -0,0 +1,71 @@ +""" +Graphical test for enabling hotspot from the Hotspot Settings app. + +This test launches the hotspot settings app, verifies the hotspot is initially +stopped, clicks the "Start" button, then verifies the hotspot is running. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_hotspot_then_station.py + Device: ./tests/unittest.sh tests/test_graphical_hotspot_then_station.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui +from mpos import AppManager, WifiService, wait_for_render, click_button, print_screen_labels + + +class TestGraphicalHotspotThenStation(unittest.TestCase): + """Test hotspot start flow via the hotspot settings app.""" + + def tearDown(self): + """Clean up after each test method.""" + try: + WifiService.disable_hotspot() + except Exception: + pass + + try: + mpos.ui.back_screen() + wait_for_render(5) + except Exception: + pass + + def test_hotspot_start_button_enables_hotspot(self): + """Start the hotspot app and verify hotspot toggles on.""" + print("\n=== Starting hotspot start-flow test ===") + + WifiService.disable_hotspot() + wait_for_render(5) + + result = AppManager.start_app("com.micropythonos.settings.hotspot") + self.assertTrue(result, "Failed to start hotspot settings app") + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nHotspot screen labels:") + print_screen_labels(screen) + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should be disabled before pressing Start", + ) + + WifiService.wifi_busy = False + + self.assertTrue( + click_button("Start"), + "Could not find Start button in hotspot app", + ) + wait_for_render(iterations=20) + + self.assertTrue( + WifiService.is_hotspot_enabled(), + "Hotspot should be enabled after pressing Start", + ) + + print("\n=== Hotspot start-flow test completed ===") + + +if __name__ == "__main__": + pass From b36ff9dcb97f1086966093cf51f779a309e56959 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 15:45:48 +0100 Subject: [PATCH 308/317] WifiService: disable hotspot when connecting to access point --- CHANGELOG.md | 2 +- .../lib/mpos/net/wifi_service.py | 8 +- tests/test_graphical_hotspot_then_station.py | 82 ++++++++++++++++++- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffb9945..5bcea4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ Frameworks: - Websocket library: renamed to uaiowebsocket to avoid conflicts OS: -- ESP32 boards: bundle WebREPL (disabled by default, password protected, can be enabled in Settings) +- ESP32 boards: bundle WebREPL (not started by default) to offer remote MicroPython shell over the network, accessible through webbrowser - New board support: LilyGo T-Display-S3 (physical and emulated by QEMU) - New board support: LilyGo T-Watch S3 Plus - New board support: M5Stack Fire diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 21206e50..8cbf1fa0 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -257,6 +257,10 @@ def attempt_connecting(ssid, password, network_module=None, time_module=None): time_mod = time_module if time_module else time + if WifiService.is_hotspot_enabled(network_module=network_module): + WifiService._needs_hotspot_restore = False + WifiService.disable_hotspot(network_module=network_module) + # Desktop mode - simulate successful connection if WifiService._is_desktop_mode(network_module): print("WifiService: Desktop mode, simulating connection...") @@ -268,10 +272,6 @@ def attempt_connecting(ssid, password, network_module=None, time_module=None): net = WifiService._get_network_module(network_module) try: - if WifiService.is_hotspot_enabled(network_module=network_module): - WifiService._needs_hotspot_restore = True - WifiService.disable_hotspot(network_module=network_module) - wlan = WifiService._get_sta_wlan(net) wlan.connect(ssid, password) diff --git a/tests/test_graphical_hotspot_then_station.py b/tests/test_graphical_hotspot_then_station.py index 4fa421c4..c5360512 100644 --- a/tests/test_graphical_hotspot_then_station.py +++ b/tests/test_graphical_hotspot_then_station.py @@ -10,14 +10,56 @@ """ import unittest +import time import lvgl as lv import mpos.ui -from mpos import AppManager, WifiService, wait_for_render, click_button, print_screen_labels +from mpos import ( + AppManager, + WifiService, + wait_for_render, + click_button, + print_screen_labels, + get_widget_coords, + simulate_click, +) class TestGraphicalHotspotThenStation(unittest.TestCase): """Test hotspot start flow via the hotspot settings app.""" + def _find_first_list_item(self, screen): + def find_list(node): + try: + if node.__class__.__name__ == "list": + return node + except Exception: + pass + try: + if hasattr(node, "add_button") and hasattr(node, "get_child_count"): + return node + except Exception: + pass + try: + child_count = node.get_child_count() + except Exception: + child_count = 0 + for i in range(child_count): + child = node.get_child(i) + found = find_list(child) + if found: + return found + return None + + wifi_list = find_list(screen) + if wifi_list is None: + return None + try: + if wifi_list.get_child_count() < 1: + return None + return wifi_list.get_child(0) + except Exception: + return None + def tearDown(self): """Clean up after each test method.""" try: @@ -64,6 +106,44 @@ def test_hotspot_start_button_enables_hotspot(self): "Hotspot should be enabled after pressing Start", ) + result = AppManager.start_app("com.micropythonos.settings.wifi") + self.assertTrue(result, "Failed to start WiFi settings app") + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nWiFi screen labels (before scan wait):") + print_screen_labels(screen) + + print("\nWaiting 10 seconds for WiFi scan to finish...") + time.sleep(10) + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nWiFi screen labels (after scan wait):") + print_screen_labels(screen) + + first_item = self._find_first_list_item(screen) + self.assertIsNotNone(first_item, "Could not find first WiFi access point") + + coords = get_widget_coords(first_item) + if coords: + print(f"Clicking first WiFi access point at ({coords['center_x']}, {coords['center_y']})") + first_item.send_event(lv.EVENT.CLICKED, None) + else: + first_item.send_event(lv.EVENT.CLICKED, None) + wait_for_render(iterations=40) + + self.assertTrue( + click_button("Connect"), + "Could not find Connect button in WiFi edit screen", + ) + wait_for_render(iterations=40) + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should be disabled after connecting to a WiFi access point", + ) + print("\n=== Hotspot start-flow test completed ===") From 5cd3d404400776ee4bbe63c0e5e0212adabf30a3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 16:51:10 +0100 Subject: [PATCH 309/317] Remove tests/base/* stuff to fix --ondevice tests --- .../assets/osupdate.py | 15 +- .../lib/mpos/net/wifi_service.py | 2 +- internal_filesystem/lib/mpos/ui/testing.py | 226 ++++++++++++++++++ tests/base/__init__.py | 23 -- tests/base/graphical_test_base.py | 189 --------------- tests/base/keyboard_test_base.py | 223 ----------------- tests/test_graphical_keyboard_animation.py | 4 +- ...st_graphical_keyboard_default_vs_custom.py | 29 +-- tests/test_graphical_keyboard_q_button_bug.py | 4 +- tests/test_osupdate.py | 2 +- 10 files changed, 246 insertions(+), 471 deletions(-) delete mode 100644 tests/base/__init__.py delete mode 100644 tests/base/graphical_test_base.py delete mode 100644 tests/base/keyboard_test_base.py 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 11390c27..2da08e13 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -708,20 +708,7 @@ def __init__(self, download_manager=None, json_module=None): self.json = json_module if json_module else ujson def get_update_url(self, hardware_id): - """Determine the update JSON URL based on hardware ID. - - Args: - hardware_id: Hardware identifier string - - Returns: - str: Full URL to update JSON file - """ - if hardware_id == "waveshare_esp32_s3_touch_lcd_2": - # First supported device - no hardware ID in URL - infofile = "osupdate.json" - else: - infofile = f"osupdate_{hardware_id}.json" - return f"https://updates.micropythonos.com/{infofile}" + return f"https://updates.micropythonos.com/osupdate_{hardware_id}.json" async def fetch_update_info(self, hardware_id): """Fetch and parse update information from server. diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 8cbf1fa0..9d83dc19 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -258,7 +258,7 @@ def attempt_connecting(ssid, password, network_module=None, time_module=None): time_mod = time_module if time_module else time if WifiService.is_hotspot_enabled(network_module=network_module): - WifiService._needs_hotspot_restore = False + WifiService._needs_hotspot_restore = True WifiService.disable_hotspot(network_module=network_module) # Desktop mode - simulate successful connection diff --git a/internal_filesystem/lib/mpos/ui/testing.py b/internal_filesystem/lib/mpos/ui/testing.py index 2cfc600b..fbc2163b 100644 --- a/internal_filesystem/lib/mpos/ui/testing.py +++ b/internal_filesystem/lib/mpos/ui/testing.py @@ -44,6 +44,11 @@ import lvgl as lv import time +try: + import unittest +except ImportError: # pragma: no cover - fallback for device builds without unittest + unittest = None + # Simulation globals for touch input _touch_x = 0 _touch_y = 0 @@ -51,6 +56,227 @@ _touch_indev = None +class GraphicalTestCase(unittest.TestCase if unittest else object): + """ + Base class for graphical tests. + + Provides: + - Automatic screen creation and cleanup + - Common UI testing utilities + + Class Attributes: + SCREEN_WIDTH: Default screen width (320) + SCREEN_HEIGHT: Default screen height (240) + DEFAULT_RENDER_ITERATIONS: Default iterations for wait_for_render (5) + + Instance Attributes: + screen: The LVGL screen object for the test + """ + + SCREEN_WIDTH = 320 + SCREEN_HEIGHT = 240 + DEFAULT_RENDER_ITERATIONS = 5 + + def setUp(self): + """Set up test fixtures before each test method.""" + self.screen = lv.obj() + self.screen.set_size(self.SCREEN_WIDTH, self.SCREEN_HEIGHT) + lv.screen_load(self.screen) + self.wait_for_render() + + def tearDown(self): + """Clean up after each test method.""" + lv.screen_load(lv.obj()) + self.wait_for_render() + + def wait_for_render(self, iterations=None): + """Wait for LVGL to render.""" + if iterations is None: + iterations = self.DEFAULT_RENDER_ITERATIONS + wait_for_render(iterations) + + def find_label_with_text(self, text, parent=None): + """Find a label containing the specified text.""" + if parent is None: + parent = lv.screen_active() + return find_label_with_text(parent, text) + + def verify_text_present(self, text, parent=None): + """Verify that text is present on screen.""" + if parent is None: + parent = lv.screen_active() + return verify_text_present(parent, text) + + def print_screen_labels(self, parent=None): + """Print all labels on screen (for debugging).""" + if parent is None: + parent = lv.screen_active() + print_screen_labels(parent) + + def click_button(self, text, use_send_event=True): + """Click a button by its text.""" + return click_button(text, use_send_event=use_send_event) + + def click_label(self, text, use_send_event=True): + """Click a label by its text.""" + return click_label(text, use_send_event=use_send_event) + + def simulate_click(self, x, y): + """Simulate a click at specific coordinates.""" + simulate_click(x, y) + self.wait_for_render() + + def assertTextPresent(self, text, msg=None): + """Assert that text is present on screen.""" + if msg is None: + msg = f"Text '{text}' not found on screen" + self.assertTrue(self.verify_text_present(text), msg) + + def assertTextNotPresent(self, text, msg=None): + """Assert that text is NOT present on screen.""" + if msg is None: + msg = f"Text '{text}' should not be on screen" + self.assertFalse(self.verify_text_present(text), msg) + + +class KeyboardTestCase(GraphicalTestCase): + """ + Base class for keyboard tests. + + Extends GraphicalTestCase with keyboard-specific functionality. + + Instance Attributes: + keyboard: The MposKeyboard instance (after create_keyboard_scene) + textarea: The textarea widget (after create_keyboard_scene) + """ + + DEFAULT_RENDER_ITERATIONS = 10 + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.keyboard = None + self.textarea = None + + def create_keyboard_scene(self, initial_text="", textarea_width=200, textarea_height=30): + """ + Create a standard keyboard test scene with textarea and keyboard. + + Args: + initial_text: Initial text in the textarea + textarea_width: Width of the textarea + textarea_height: Height of the textarea + + Returns: + tuple: (keyboard, textarea) + """ + from mpos import MposKeyboard + + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(textarea_width, textarea_height) + self.textarea.set_one_line(True) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_text(initial_text) + self.wait_for_render() + + self.keyboard = MposKeyboard(self.screen) + self.keyboard.set_textarea(self.textarea) + self.keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + self.wait_for_render() + + return self.keyboard, self.textarea + + def click_keyboard_button(self, button_text): + """ + Click a keyboard button by its text. + + Args: + button_text: The text of the button to click (e.g., "q", "a", "Enter") + + Returns: + bool: True if button was clicked successfully + """ + if self.keyboard is None: + raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") + + return click_keyboard_button(self.keyboard, button_text) + + def get_textarea_text(self): + """Get the current text in the textarea.""" + if self.textarea is None: + raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") + return self.textarea.get_text() + + def set_textarea_text(self, text): + """Set the textarea text.""" + if self.textarea is None: + raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") + self.textarea.set_text(text) + self.wait_for_render() + + def clear_textarea(self): + """Clear the textarea.""" + self.set_textarea_text("") + + def type_text(self, text): + """Type a string by clicking each character on the keyboard.""" + for char in text: + if not self.click_keyboard_button(char): + return False + return True + + def assertTextareaText(self, expected, msg=None): + """Assert that the textarea contains the expected text.""" + actual = self.get_textarea_text() + if msg is None: + msg = f"Textarea text mismatch. Expected '{expected}', got '{actual}'" + self.assertEqual(actual, expected, msg) + + def assertTextareaEmpty(self, msg=None): + """Assert that the textarea is empty.""" + if msg is None: + msg = f"Textarea should be empty, but contains '{self.get_textarea_text()}'" + self.assertEqual(self.get_textarea_text(), "", msg) + + def assertTextareaContains(self, substring, msg=None): + """Assert that the textarea contains a substring.""" + actual = self.get_textarea_text() + if msg is None: + msg = f"Textarea should contain '{substring}', but has '{actual}'" + self.assertIn(substring, actual, msg) + + def get_keyboard_button_text(self, index): + """Get the text of a keyboard button by index.""" + if self.keyboard is None: + raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") + + try: + return self.keyboard.get_button_text(index) + except: + return None + + def find_keyboard_button_index(self, button_text): + """Find the index of a keyboard button by its text.""" + for i in range(100): + text = self.get_keyboard_button_text(i) + if text is None: + break + if text == button_text: + return i + return None + + def get_all_keyboard_buttons(self): + """Get all keyboard buttons as a list of (index, text) tuples.""" + buttons = [] + for i in range(100): + text = self.get_keyboard_button_text(i) + if text is None: + break + if text: + buttons.append((i, text)) + return buttons + + def wait_for_render(iterations=10): """ Wait for LVGL to process UI events and render. diff --git a/tests/base/__init__.py b/tests/base/__init__.py deleted file mode 100644 index 0d7c5b19..00000000 --- a/tests/base/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Base test classes for MicroPythonOS testing. - -This module provides base classes that encapsulate common test patterns: -- GraphicalTestBase: For tests that require LVGL/UI -- KeyboardTestBase: For tests that involve keyboard interaction - -Usage: - from base import GraphicalTestBase, KeyboardTestBase - - class TestMyApp(GraphicalTestBase): - def test_something(self): - # self.screen is already set up - pass -""" - -from .graphical_test_base import GraphicalTestBase -from .keyboard_test_base import KeyboardTestBase - -__all__ = [ - 'GraphicalTestBase', - 'KeyboardTestBase', -] diff --git a/tests/base/graphical_test_base.py b/tests/base/graphical_test_base.py deleted file mode 100644 index b2497060..00000000 --- a/tests/base/graphical_test_base.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -Base class for graphical tests in MicroPythonOS. - -This class provides common setup/teardown patterns for tests that require -LVGL/UI initialization. It handles: -- Screen creation and cleanup -- Common UI testing utilities - -Usage: - from base import GraphicalTestBase - - class TestMyApp(GraphicalTestBase): - def test_something(self): - # self.screen is already set up (320x240) - label = lv.label(self.screen) - label.set_text("Hello") - self.wait_for_render() -""" - -import unittest -import lvgl as lv - - -class GraphicalTestBase(unittest.TestCase): - """ - Base class for all graphical tests. - - Provides: - - Automatic screen creation and cleanup - - Screenshot directory configuration - - Common UI testing utilities - - Class Attributes: - SCREEN_WIDTH: Default screen width (320) - SCREEN_HEIGHT: Default screen height (240) - DEFAULT_RENDER_ITERATIONS: Default iterations for wait_for_render (5) - - Instance Attributes: - screen: The LVGL screen object for the test - """ - - SCREEN_WIDTH = 320 - SCREEN_HEIGHT = 240 - DEFAULT_RENDER_ITERATIONS = 5 - - def setUp(self): - """ - Set up test fixtures before each test method. - - Creates a new screen and loads it. - """ - # Create and load a new screen - self.screen = lv.obj() - self.screen.set_size(self.SCREEN_WIDTH, self.SCREEN_HEIGHT) - lv.screen_load(self.screen) - self.wait_for_render() - - def tearDown(self): - """ - Clean up after each test method. - - Loads an empty screen to clean up. - """ - # Load an empty screen to clean up - lv.screen_load(lv.obj()) - self.wait_for_render() - - def wait_for_render(self, iterations=None): - """ - Wait for LVGL to render. - - Args: - iterations: Number of render iterations (default: DEFAULT_RENDER_ITERATIONS) - """ - from mpos import wait_for_render - if iterations is None: - iterations = self.DEFAULT_RENDER_ITERATIONS - wait_for_render(iterations) - - def find_label_with_text(self, text, parent=None): - """ - Find a label containing the specified text. - - Args: - text: Text to search for - parent: Parent widget to search in (default: current screen) - - Returns: - The label widget if found, None otherwise - """ - from mpos import find_label_with_text - if parent is None: - parent = lv.screen_active() - return find_label_with_text(parent, text) - - def verify_text_present(self, text, parent=None): - """ - Verify that text is present on screen. - - Args: - text: Text to search for - parent: Parent widget to search in (default: current screen) - - Returns: - bool: True if text is found - """ - from mpos import verify_text_present - if parent is None: - parent = lv.screen_active() - return verify_text_present(parent, text) - - def print_screen_labels(self, parent=None): - """ - Print all labels on screen (for debugging). - - Args: - parent: Parent widget to search in (default: current screen) - """ - from mpos import print_screen_labels - if parent is None: - parent = lv.screen_active() - print_screen_labels(parent) - - def click_button(self, text, use_send_event=True): - """ - Click a button by its text. - - Args: - text: Button text to find and click - use_send_event: If True, use send_event (more reliable) - - Returns: - bool: True if button was found and clicked - """ - from mpos import click_button - return click_button(text, use_send_event=use_send_event) - - def click_label(self, text, use_send_event=True): - """ - Click a label by its text. - - Args: - text: Label text to find and click - use_send_event: If True, use send_event (more reliable) - - Returns: - bool: True if label was found and clicked - """ - from mpos import click_label - return click_label(text, use_send_event=use_send_event) - - def simulate_click(self, x, y): - """ - Simulate a click at specific coordinates. - - Note: For most UI testing, prefer click_button() or click_label() - which are more reliable. Use this only when testing touch behavior. - - Args: - x: X coordinate - y: Y coordinate - """ - from mpos import simulate_click - simulate_click(x, y) - self.wait_for_render() - - def assertTextPresent(self, text, msg=None): - """ - Assert that text is present on screen. - - Args: - text: Text to search for - msg: Optional failure message - """ - if msg is None: - msg = f"Text '{text}' not found on screen" - self.assertTrue(self.verify_text_present(text), msg) - - def assertTextNotPresent(self, text, msg=None): - """ - Assert that text is NOT present on screen. - - Args: - text: Text to search for - msg: Optional failure message - """ - if msg is None: - msg = f"Text '{text}' should not be on screen" - self.assertFalse(self.verify_text_present(text), msg) diff --git a/tests/base/keyboard_test_base.py b/tests/base/keyboard_test_base.py deleted file mode 100644 index 86670ec8..00000000 --- a/tests/base/keyboard_test_base.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -Base class for keyboard tests in MicroPythonOS. - -This class extends GraphicalTestBase with keyboard-specific functionality: -- Keyboard and textarea creation -- Keyboard button clicking -- Textarea text assertions - -Usage: - from base import KeyboardTestBase - - class TestMyKeyboard(KeyboardTestBase): - def test_typing(self): - keyboard, textarea = self.create_keyboard_scene() - self.click_keyboard_button("h") - self.click_keyboard_button("i") - self.assertTextareaText("hi") -""" - -import lvgl as lv -from .graphical_test_base import GraphicalTestBase - - -class KeyboardTestBase(GraphicalTestBase): - """ - Base class for keyboard tests. - - Extends GraphicalTestBase with keyboard-specific functionality. - - Instance Attributes: - keyboard: The MposKeyboard instance (after create_keyboard_scene) - textarea: The textarea widget (after create_keyboard_scene) - """ - - # Increase render iterations for keyboard tests - DEFAULT_RENDER_ITERATIONS = 10 - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.keyboard = None - self.textarea = None - - def create_keyboard_scene(self, initial_text="", textarea_width=200, textarea_height=30): - """ - Create a standard keyboard test scene with textarea and keyboard. - - Args: - initial_text: Initial text in the textarea - textarea_width: Width of the textarea - textarea_height: Height of the textarea - - Returns: - tuple: (keyboard, textarea) - """ - from mpos import MposKeyboard - - # Create textarea - self.textarea = lv.textarea(self.screen) - self.textarea.set_size(textarea_width, textarea_height) - self.textarea.set_one_line(True) - self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) - self.textarea.set_text(initial_text) - self.wait_for_render() - - # Create keyboard and connect to textarea - self.keyboard = MposKeyboard(self.screen) - self.keyboard.set_textarea(self.textarea) - self.keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - self.wait_for_render() - - return self.keyboard, self.textarea - - def click_keyboard_button(self, button_text): - """ - Click a keyboard button by its text. - - This uses the reliable click_keyboard_button helper which - directly manipulates the textarea for MposKeyboard instances. - - Args: - button_text: The text of the button to click (e.g., "q", "a", "Enter") - - Returns: - bool: True if button was clicked successfully - """ - from mpos import click_keyboard_button - - if self.keyboard is None: - raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") - - return click_keyboard_button(self.keyboard, button_text) - - def get_textarea_text(self): - """ - Get the current text in the textarea. - - Returns: - str: The textarea text - """ - if self.textarea is None: - raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") - return self.textarea.get_text() - - def set_textarea_text(self, text): - """ - Set the textarea text. - - Args: - text: The text to set - """ - if self.textarea is None: - raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") - self.textarea.set_text(text) - self.wait_for_render() - - def clear_textarea(self): - """Clear the textarea.""" - self.set_textarea_text("") - - def type_text(self, text): - """ - Type a string by clicking each character on the keyboard. - - Args: - text: The text to type - - Returns: - bool: True if all characters were typed successfully - """ - for char in text: - if not self.click_keyboard_button(char): - return False - return True - - def assertTextareaText(self, expected, msg=None): - """ - Assert that the textarea contains the expected text. - - Args: - expected: Expected text - msg: Optional failure message - """ - actual = self.get_textarea_text() - if msg is None: - msg = f"Textarea text mismatch. Expected '{expected}', got '{actual}'" - self.assertEqual(actual, expected, msg) - - def assertTextareaEmpty(self, msg=None): - """ - Assert that the textarea is empty. - - Args: - msg: Optional failure message - """ - if msg is None: - msg = f"Textarea should be empty, but contains '{self.get_textarea_text()}'" - self.assertEqual(self.get_textarea_text(), "", msg) - - def assertTextareaContains(self, substring, msg=None): - """ - Assert that the textarea contains a substring. - - Args: - substring: Substring to search for - msg: Optional failure message - """ - actual = self.get_textarea_text() - if msg is None: - msg = f"Textarea should contain '{substring}', but has '{actual}'" - self.assertIn(substring, actual, msg) - - def get_keyboard_button_text(self, index): - """ - Get the text of a keyboard button by index. - - Args: - index: Button index - - Returns: - str: Button text, or None if not found - """ - if self.keyboard is None: - raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") - - try: - return self.keyboard.get_button_text(index) - except: - return None - - def find_keyboard_button_index(self, button_text): - """ - Find the index of a keyboard button by its text. - - Args: - button_text: Text to search for - - Returns: - int: Button index, or None if not found - """ - for i in range(100): # Check first 100 indices - text = self.get_keyboard_button_text(i) - if text is None: - break - if text == button_text: - return i - return None - - def get_all_keyboard_buttons(self): - """ - Get all keyboard buttons as a list of (index, text) tuples. - - Returns: - list: List of (index, text) tuples - """ - buttons = [] - for i in range(100): - text = self.get_keyboard_button_text(i) - if text is None: - break - if text: # Skip empty strings - buttons.append((i, text)) - return buttons diff --git a/tests/test_graphical_keyboard_animation.py b/tests/test_graphical_keyboard_animation.py index 569049fe..31e415d6 100644 --- a/tests/test_graphical_keyboard_animation.py +++ b/tests/test_graphical_keyboard_animation.py @@ -13,10 +13,10 @@ import lvgl as lv import time from mpos.ui.widget_animator import WidgetAnimator -from base import KeyboardTestBase +from mpos.ui.testing import KeyboardTestCase -class TestKeyboardAnimation(KeyboardTestBase): +class TestKeyboardAnimation(KeyboardTestCase): """Test MposKeyboard compatibility with animation system.""" def test_keyboard_has_set_style_opa(self): diff --git a/tests/test_graphical_keyboard_default_vs_custom.py b/tests/test_graphical_keyboard_default_vs_custom.py index ffc1976c..4751ae0d 100644 --- a/tests/test_graphical_keyboard_default_vs_custom.py +++ b/tests/test_graphical_keyboard_default_vs_custom.py @@ -10,23 +10,20 @@ import unittest import lvgl as lv -from mpos import MposKeyboard, wait_for_render +from mpos import MposKeyboard +from mpos.ui.testing import GraphicalTestCase -class TestDefaultVsCustomKeyboard(unittest.TestCase): +class TestDefaultVsCustomKeyboard(GraphicalTestCase): """Compare default LVGL keyboard with custom MposKeyboard.""" def setUp(self): """Set up test fixtures.""" - self.screen = lv.obj() - self.screen.set_size(320, 240) - lv.screen_load(self.screen) - wait_for_render(5) + super().setUp() def tearDown(self): """Clean up.""" - lv.screen_load(lv.obj()) - wait_for_render(5) + super().tearDown() def test_default_lvgl_keyboard_layout(self): """ @@ -41,13 +38,13 @@ def test_default_lvgl_keyboard_layout(self): textarea.set_size(280, 40) textarea.align(lv.ALIGN.TOP_MID, 0, 10) textarea.set_one_line(True) - wait_for_render(5) + self.wait_for_render(5) # Create DEFAULT LVGL keyboard keyboard = lv.keyboard(self.screen) keyboard.set_textarea(textarea) keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - wait_for_render(10) + self.wait_for_render(10) print("\nDefault LVGL keyboard buttons (first 40):") found_special_labels = {} @@ -87,13 +84,13 @@ def test_custom_mpos_keyboard_layout(self): textarea.set_size(280, 40) textarea.align(lv.ALIGN.TOP_MID, 0, 10) textarea.set_one_line(True) - wait_for_render(5) + self.wait_for_render(5) # Create CUSTOM MposKeyboard keyboard = MposKeyboard(self.screen) keyboard.set_textarea(textarea) keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - wait_for_render(10) + self.wait_for_render(10) print("\nCustom MposKeyboard buttons (first 40):") found_special_labels = {} @@ -130,12 +127,12 @@ def test_mode_switching_bug_reproduction(self): textarea.set_size(280, 40) textarea.align(lv.ALIGN.TOP_MID, 0, 10) textarea.set_one_line(True) - wait_for_render(5) + self.wait_for_render(5) keyboard = MposKeyboard(self.screen) keyboard.set_textarea(textarea) keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - wait_for_render(10) + self.wait_for_render(10) # Step 1: Start in lowercase print("\nStep 1: Initial lowercase mode") @@ -146,7 +143,7 @@ def test_mode_switching_bug_reproduction(self): # Step 2: Switch to numbers print("\nStep 2: Switch to numbers mode") keyboard.set_mode(MposKeyboard.MODE_NUMBERS) - wait_for_render(5) + self.wait_for_render(5) labels_step2 = self._get_special_labels(keyboard) print(f" Labels: {list(labels_step2.keys())}") self.assertIn("Abc", labels_step2, "Should have 'Abc' in numbers mode") @@ -154,7 +151,7 @@ def test_mode_switching_bug_reproduction(self): # Step 3: Switch back to lowercase (this is where bug might happen) print("\nStep 3: Switch back to lowercase via set_mode()") keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) - wait_for_render(5) + self.wait_for_render(5) labels_step3 = self._get_special_labels(keyboard) print(f" Labels: {list(labels_step3.keys())}") diff --git a/tests/test_graphical_keyboard_q_button_bug.py b/tests/test_graphical_keyboard_q_button_bug.py index f9de244f..18620d30 100644 --- a/tests/test_graphical_keyboard_q_button_bug.py +++ b/tests/test_graphical_keyboard_q_button_bug.py @@ -14,10 +14,10 @@ """ import unittest -from base import KeyboardTestBase +from mpos.ui.testing import KeyboardTestCase -class TestKeyboardQButton(KeyboardTestBase): +class TestKeyboardQButton(KeyboardTestCase): """Test keyboard button functionality (especially 'q' which was at index 0).""" def test_q_button_works(self): diff --git a/tests/test_osupdate.py b/tests/test_osupdate.py index f618ecdf..2dc2b2dd 100644 --- a/tests/test_osupdate.py +++ b/tests/test_osupdate.py @@ -66,7 +66,7 @@ def test_get_update_url_waveshare(self): """Test URL generation for waveshare hardware.""" url = self.checker.get_update_url("waveshare_esp32_s3_touch_lcd_2") - self.assertEqual(url, "https://updates.micropythonos.com/osupdate.json") + self.assertEqual(url, "https://updates.micropythonos.com/osupdate_waveshare_esp32_s3_touch_lcd_2.json") def test_get_update_url_other_hardware(self): """Test URL generation for other hardware.""" From ad1ff2168a1a98a6732c2bf1e8b77f4e9a35bfeb Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 17:05:42 +0100 Subject: [PATCH 310/317] Add longer sleep for ondevice reset --- tests/unittest.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittest.sh b/tests/unittest.sh index 602a2451..e796ddb0 100755 --- a/tests/unittest.sh +++ b/tests/unittest.sh @@ -79,7 +79,7 @@ result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " if [ ! -z "$ondevice" ]; then echo "Hack: reset the device to make sure no previous UnitTest classes have been registered..." "$mpremote" reset - sleep 15 + sleep 20 fi echo "Device execution" From a180adfe90ffe527276547a59d4f9b74c8c119cc Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 20:56:10 +0100 Subject: [PATCH 311/317] Improve hotspot state handling --- .../lib/mpos/net/wifi_service.py | 11 +- tests/test_graphical_hotspot_security_none.py | 150 ++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 tests/test_graphical_hotspot_security_none.py diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 9d83dc19..e1905339 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -83,7 +83,7 @@ def _get_hotspot_config(): "enabled": prefs.get_bool("enabled", False), "ssid": prefs.get_string("ssid", "MicroPythonOS"), "password": prefs.get_string("password", ""), - "authmode": prefs.get_string("authmode", "wpa2"), + "authmode": prefs.get_string("authmode", None), } @staticmethod @@ -142,6 +142,12 @@ def enable_hotspot(network_module=None): print("WifiService: Hotspot enabled") return True except Exception as e: + try: + ap = WifiService._get_ap_wlan(net) + ap.active(False) + except Exception: + pass + WifiService.hotspot_enabled = False print(f"WifiService: Failed to enable hotspot: {e}") return False @@ -168,7 +174,8 @@ def is_hotspot_enabled(network_module=None): try: net = WifiService._get_network_module(network_module) ap = WifiService._get_ap_wlan(net) - return ap.active() + WifiService.hotspot_enabled = ap.active() + return WifiService.hotspot_enabled except Exception: return WifiService.hotspot_enabled diff --git a/tests/test_graphical_hotspot_security_none.py b/tests/test_graphical_hotspot_security_none.py new file mode 100644 index 00000000..a3367054 --- /dev/null +++ b/tests/test_graphical_hotspot_security_none.py @@ -0,0 +1,150 @@ +""" +Graphical test for hotspot start flow with security none and invalid password handling. + +This test verifies: +1) Starting hotspot with default settings and Security: None succeeds. +2) Starting hotspot with an invalid WPA2 password fails and leaves hotspot disabled. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_hotspot_security_none.py + Device: ./tests/unittest.sh tests/test_graphical_hotspot_security_none.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui +from mpos import ( + AppManager, + WifiService, + SharedPreferences, + wait_for_render, + click_button, + print_screen_labels, + verify_text_present, +) + + +class TestGraphicalHotspotSecurityNone(unittest.TestCase): + """Graphical tests for hotspot security handling.""" + + def _reset_hotspot_preferences(self): + prefs = SharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + editor.remove_all() + editor.commit() + + def _set_hotspot_preferences(self, ssid=None, password=None, authmode=None): + prefs = SharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + if ssid is not None: + editor.put_string("ssid", ssid) + if password is not None: + editor.put_string("password", password) + if authmode is not None: + editor.put_string("authmode", authmode) + editor.commit() + + def _open_hotspot_screen(self): + result = AppManager.start_app("com.micropythonos.settings.hotspot") + self.assertTrue(result, "Failed to start hotspot settings app") + wait_for_render(iterations=20) + screen = lv.screen_active() + print("\nHotspot screen labels:") + print_screen_labels(screen) + return screen + + def tearDown(self): + try: + WifiService.disable_hotspot() + except Exception: + pass + + try: + mpos.ui.back_screen() + wait_for_render(5) + except Exception: + pass + + def test_security_none_allows_open_hotspot(self): + """Ensure Security: None starts an open hotspot successfully.""" + print("\n=== Starting hotspot security none test ===") + + self._reset_hotspot_preferences() + screen = self._open_hotspot_screen() + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should be disabled before pressing Start", + ) + + WifiService.wifi_busy = False + + self.assertTrue( + click_button("Start"), + "Could not find Start button in hotspot app", + ) + wait_for_render(iterations=40) + + self.assertTrue( + WifiService.is_hotspot_enabled(), + "Hotspot should be enabled with Security: None", + ) + + screen = lv.screen_active() + print("\nHotspot screen labels after Start:") + print_screen_labels(screen) + self.assertTrue( + verify_text_present(screen, "Security: None"), + "Hotspot should display Security: None after start", + ) + self.assertTrue( + verify_text_present(screen, "Status: Running"), + "Hotspot should display Status: Running after start", + ) + + print("\n=== Hotspot security none test completed ===") + + @unittest.skipIf( + WifiService._is_desktop_mode(None), + "Invalid password handling requires device network stack", + ) + def test_invalid_password_fails_and_reports_disabled(self): + """Ensure invalid WPA2 password fails and hotspot remains disabled.""" + print("\n=== Starting hotspot invalid password test ===") + + self._reset_hotspot_preferences() + self._set_hotspot_preferences(password="123", authmode="wpa2") + + screen = self._open_hotspot_screen() + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should be disabled before pressing Start", + ) + + WifiService.wifi_busy = False + + self.assertTrue( + click_button("Start"), + "Could not find Start button in hotspot app", + ) + wait_for_render(iterations=40) + + self.assertFalse( + WifiService.is_hotspot_enabled(), + "Hotspot should remain disabled when password is invalid", + ) + + screen = lv.screen_active() + print("\nHotspot screen labels after invalid password attempt:") + print_screen_labels(screen) + self.assertTrue( + verify_text_present(screen, "Status: Stopped"), + "Hotspot should display Status: Stopped after failed start", + ) + + print("\n=== Hotspot invalid password test completed ===") + + +if __name__ == "__main__": + pass From 7499ba87dc4858650247ac784e0904805b3777a2 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 25 Mar 2026 21:12:56 +0100 Subject: [PATCH 312/317] Hotspot settings: refresh UI after changes --- .../assets/hotspot_settings.py | 6 +- tests/test_graphical_hotspot_settings.py | 185 ++++++++++++++++++ 2 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 tests/test_graphical_hotspot_settings.py diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py index 1bd58307..84b6f9d4 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings.hotspot/assets/hotspot_settings.py @@ -70,8 +70,10 @@ def onResume(self, screen): def refresh_status(self): is_running = WifiService.is_hotspot_enabled() state_text = "Running" if is_running else "Stopped" - ssid = self.prefs.get_string("ssid", self.DEFAULTS["ssid"]) - authmode = self.prefs.get_string("authmode", None) + self.prefs.load() + self.ui_prefs.load() + ssid = self.ui_prefs.get_string("ssid", self.DEFAULTS["ssid"]) + authmode = self.ui_prefs.get_string("authmode", self.DEFAULTS["authmode"]) security_text = self._format_security_label(authmode) self.status_label.set_text( f"Status: {state_text}\nHotspot name: {ssid}\nSecurity: {security_text}" diff --git a/tests/test_graphical_hotspot_settings.py b/tests/test_graphical_hotspot_settings.py new file mode 100644 index 00000000..4a7b12e9 --- /dev/null +++ b/tests/test_graphical_hotspot_settings.py @@ -0,0 +1,185 @@ +""" +Graphical test for hotspot settings refreshing overview values. + +This test verifies: +1) Auth Mode changes in settings are reflected on the hotspot overview. +2) SSID changes in settings are reflected on the hotspot overview. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_hotspot_settings.py + Device: ./tests/unittest.sh tests/test_graphical_hotspot_settings.py --ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui +from mpos import ( + AppManager, + SharedPreferences, + WifiService, + wait_for_render, + click_button, + click_label, + print_screen_labels, + verify_text_present, + find_dropdown_widget, + select_dropdown_option_by_text, + get_widget_coords, + simulate_click, +) + + +class TestGraphicalHotspotSettings(unittest.TestCase): + """Graphical tests for hotspot settings refresh.""" + + def _reset_hotspot_preferences(self): + prefs = SharedPreferences("com.micropythonos.settings.hotspot") + editor = prefs.edit() + editor.remove_all() + editor.commit() + + def _open_hotspot_settings_screen(self): + result = AppManager.start_app("com.micropythonos.settings.hotspot") + self.assertTrue(result, "Failed to start hotspot settings app") + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nHotspot overview labels:") + print_screen_labels(screen) + + self.assertTrue( + click_button("Settings"), + "Could not find Settings button in hotspot app", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + print("\nHotspot settings labels:") + print_screen_labels(screen) + return screen + + def _find_textarea(self, node): + try: + if node.__class__.__name__ == "textarea": + return node + if hasattr(node, "set_one_line") and hasattr(node, "set_text") and hasattr(node, "get_text"): + return node + except Exception: + pass + + try: + child_count = node.get_child_count() + except Exception: + return None + + for i in range(child_count): + child = node.get_child(i) + result = self._find_textarea(child) + if result: + return result + return None + + def tearDown(self): + try: + WifiService.disable_hotspot() + except Exception: + pass + + try: + mpos.ui.back_screen() + wait_for_render(5) + except Exception: + pass + + def test_auth_mode_change_updates_overview_security(self): + """Verify Auth Mode change is reflected on the hotspot overview.""" + print("\n=== Starting hotspot Auth Mode overview refresh test ===") + + self._reset_hotspot_preferences() + self._open_hotspot_settings_screen() + + self.assertTrue( + click_label("Auth Mode"), + "Could not click Auth Mode setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + dropdown = find_dropdown_widget(screen) + self.assertIsNotNone(dropdown, "Auth Mode dropdown not found") + + coords = get_widget_coords(dropdown) + self.assertIsNotNone(coords, "Could not get dropdown coordinates") + + print(f"Clicking dropdown at ({coords['center_x']}, {coords['center_y']})") + simulate_click(coords["center_x"], coords["center_y"], press_duration_ms=100) + wait_for_render(iterations=20) + + self.assertTrue( + select_dropdown_option_by_text(dropdown, "WPA2", allow_partial=True), + "Could not select WPA2 option in dropdown", + ) + wait_for_render(iterations=20) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in Auth Mode settings", + ) + wait_for_render(iterations=40) + + mpos.ui.back_screen() + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nHotspot overview labels after Auth Mode change:") + print_screen_labels(screen) + self.assertTrue( + verify_text_present(screen, "Security: WPA2"), + "Hotspot overview did not update Security after Auth Mode change", + ) + + print("\n=== Hotspot Auth Mode overview refresh test completed ===") + + def test_ssid_change_updates_overview_name(self): + """Verify SSID change is reflected on the hotspot overview.""" + print("\n=== Starting hotspot SSID overview refresh test ===") + + new_ssid = "MPOS-Test-SSID" + + self._reset_hotspot_preferences() + self._open_hotspot_settings_screen() + + self.assertTrue( + click_label("Network Name (SSID)"), + "Could not click Network Name (SSID) setting", + ) + wait_for_render(iterations=40) + + screen = lv.screen_active() + textarea = self._find_textarea(screen) + self.assertIsNotNone(textarea, "SSID textarea not found") + textarea.set_text(new_ssid) + wait_for_render(iterations=10) + + self.assertTrue( + click_button("Save"), + "Could not click Save button in SSID settings", + ) + wait_for_render(iterations=40) + + mpos.ui.back_screen() + wait_for_render(iterations=20) + + screen = lv.screen_active() + print("\nHotspot overview labels after SSID change:") + print_screen_labels(screen) + self.assertTrue( + verify_text_present(screen, f"Hotspot name: {new_ssid}"), + "Hotspot overview did not update SSID after settings change", + ) + + print("\n=== Hotspot SSID overview refresh test completed ===") + + +if __name__ == "__main__": + pass From 5ae6d01374aaa958ab17b636ef451fad4ee1b40e Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Mar 2026 09:13:58 +0100 Subject: [PATCH 313/317] ondevice unit test: wait longer --- tests/unittest.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittest.sh b/tests/unittest.sh index e796ddb0..952b45da 100755 --- a/tests/unittest.sh +++ b/tests/unittest.sh @@ -79,7 +79,7 @@ result = unittest.main() ; sys.exit(0 if result.wasSuccessful() else 1) " if [ ! -z "$ondevice" ]; then echo "Hack: reset the device to make sure no previous UnitTest classes have been registered..." "$mpremote" reset - sleep 20 + sleep 30 fi echo "Device execution" From c69efa4b86b3a445aa168cd1c530e4ec24dbaba4 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Mar 2026 11:08:35 +0100 Subject: [PATCH 314/317] Fix typo in cz.ucw.pavel.columns/META-INF/MANIFEST.JSON --- .../apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON index 51d15601..2fdd8acc 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON @@ -2,8 +2,7 @@ "name": "Columns", "publisher": "Pavel Machek", "short_description": "Falling columns game", -"long_description": "Blocks of 3 colors are falling. Align the colors to make blocks di\ -sappear.", +"long_description": "Blocks of 3 colors are falling. Align the colors to make blocks disappear.", "icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/icons/cz.ucw.pavel.columns_0.0.1_64x64.png", "download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/mpks/cz.ucw.pavel.columns_0.0.1.mpk", "fullname": "cz.ucw.pavel.columns", From 25bc6e4ca2d24883e7e6c765595b894544e8e450 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Mar 2026 11:09:03 +0100 Subject: [PATCH 315/317] install.sh: dont install all apps by default --- scripts/install.sh | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 9efce0b6..0818d9e5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,3 +1,6 @@ +#!/bin/bash +# Bash is used for pushd and popd + mydir=$(readlink -f "$0") mydir=$(dirname "$mydir") @@ -73,7 +76,14 @@ $mpremote fs mkdir :/apps #$mpremote fs cp -r apps/com.micropythonos.musicplayer :/apps/ #$mpremote fs cp -r apps/com.micropythonos.soundrecorder :/apps/ #$mpremote fs cp -r apps/com.micropythonos.breakout :/apps/ -#exit 1 + +if [ ! -z "$appname" ]; then + echo "Not resetting so the installed app can be used immediately." + $mpremote reset +fi + +# Uncomment this line if you really want all apps the be installed: +echo "Not installing all apps by default because it takes a long time, uses lots of storage and makes the boot slower..." ; popd ; exit 1 $mpremote fs cp -r apps/com.micropythonos.* :/apps/ find apps/ -maxdepth 1 -type l | while read symlink; do @@ -89,12 +99,3 @@ done popd -# Install test infrastructure (for running ondevice tests) -echo "Installing test infrastructure..." -$mpremote fs mkdir :/tests -$mpremote fs mkdir :/tests/screenshots - -if [ ! -z "$appname" ]; then - echo "Not resetting so the installed app can be used immediately." - $mpremote reset -fi From a698c72805b1ccc20602fc4c1451a4d14e026392 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Mar 2026 11:39:50 +0100 Subject: [PATCH 316/317] Increment version numbers --- .../apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON | 6 +++--- .../com.micropythonos.musicplayer/META-INF/MANIFEST.JSON | 6 +++--- .../com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON | 6 +++--- scripts/bundle_apps.sh | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) 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 ad93b291..36846295 100644 --- a/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.imageview/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Image Viewer", "long_description": "Opens and shows images on the display.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/icons/com.micropythonos.imageview_0.1.0_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/mpks/com.micropythonos.imageview_0.1.0.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/icons/com.micropythonos.imageview_0.1.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.imageview/mpks/com.micropythonos.imageview_0.1.1.mpk", "fullname": "com.micropythonos.imageview", -"version": "0.1.0", +"version": "0.1.1", "category": "graphics", "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 62ee11f2..6c0ae6de 100644 --- a/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.musicplayer/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Player audio files", "long_description": "Traverse around the filesystem and play audio files that you select.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/icons/com.micropythonos.musicplayer_0.1.0_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/mpks/com.micropythonos.musicplayer_0.1.0.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/icons/com.micropythonos.musicplayer_0.1.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.musicplayer/mpks/com.micropythonos.musicplayer_0.1.1.mpk", "fullname": "com.micropythonos.musicplayer", -"version": "0.1.0", +"version": "0.1.1", "category": "development", "activities": [ { diff --git a/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON index b5470ef9..af84efc0 100644 --- a/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/com.micropythonos.soundrecorder/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Record audio from microphone", "long_description": "Record audio from the I2S microphone and save as WAV files. Recordings can be played back with the Music Player app.", - "icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/icons/com.micropythonos.soundrecorder_0.1.0_64x64.png", - "download_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/mpks/com.micropythonos.soundrecorder_0.1.0.mpk", + "icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/icons/com.micropythonos.soundrecorder_0.1.1_64x64.png", + "download_url": "https://apps.micropythonos.com/apps/com.micropythonos.soundrecorder/mpks/com.micropythonos.soundrecorder_0.1.1.mpk", "fullname": "com.micropythonos.soundrecorder", - "version": "0.1.0", + "version": "0.1.1", "category": "utilities", "activities": [ { diff --git a/scripts/bundle_apps.sh b/scripts/bundle_apps.sh index e403eb29..f64e58a9 100755 --- a/scripts/bundle_apps.sh +++ b/scripts/bundle_apps.sh @@ -20,8 +20,8 @@ rm "$outputjson" # com.micropythonos.errortest is an intentional bad app for testing (caught by tests/test_graphical_launch_all_apps.py) # com.micropythonos.nostr isn't ready for release yet blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.errortest com.micropythonos.nostr" -blacklist="$blacklist com.micropythonos.doom_launcher com.micropythonos.doom" # not ready yet -blacklist="$blacklist cz.ucw.pavel.calendar cz.ucw.pavel.cellular cz.ucw.pavel.compass cz.ucw.pavel.navstar" # not ready yet +blacklist="$blacklist com.micropythonos.doom_launcher com.micropythonos.doom com.micropythonos.breakout" # not ready yet +blacklist="$blacklist cz.ucw.pavel.calendar cz.ucw.pavel.cellular cz.ucw.pavel.compass cz.ucw.pavel.navstar cz.ucw.pavel.weather" # not ready yet echo "[" | tee -a "$outputjson" From 02cf86551cbea5196bbc96f73820ec7d9e9b979f Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Thu, 26 Mar 2026 11:43:14 +0100 Subject: [PATCH 317/317] Increment version numbers --- .../apps/com.micropythonos.about/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.launcher/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON | 6 +++--- .../apps/com.micropythonos.settings/META-INF/MANIFEST.JSON | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) 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 5532c1bc..cd8a111f 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.about/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Info about MicroPythonOS", "long_description": "Shows current MicroPythonOS version, MicroPython version, build date and other useful info..", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/icons/com.micropythonos.about_0.1.1_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/mpks/com.micropythonos.about_0.1.1.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/icons/com.micropythonos.about_0.1.2_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.about/mpks/com.micropythonos.about_0.1.2.mpk", "fullname": "com.micropythonos.about", -"version": "0.1.1", +"version": "0.1.2", "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 3da1a130..768e4782 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Store for App(lication)s", "long_description": "This is the place to discover, find, install, uninstall and upgrade all the apps that make your device useless.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/icons/com.micropythonos.appstore_0.1.2_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/mpks/com.micropythonos.appstore_0.1.2.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/icons/com.micropythonos.appstore_0.1.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.appstore/mpks/com.micropythonos.appstore_0.1.3.mpk", "fullname": "com.micropythonos.appstore", -"version": "0.1.2", +"version": "0.1.3", "category": "appstore", "activities": [ { diff --git a/internal_filesystem/builtin/apps/com.micropythonos.launcher/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.launcher/META-INF/MANIFEST.JSON index 74e0cb60..c22a3ab1 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.launcher/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.launcher/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Simple launcher to start apps.", "long_description": "", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.launcher/icons/com.micropythonos.launcher_0.1.2_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.launcher/mpks/com.micropythonos.launcher_0.1.2.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.launcher/icons/com.micropythonos.launcher_0.1.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.launcher/mpks/com.micropythonos.launcher_0.1.3.mpk", "fullname": "com.micropythonos.launcher", -"version": "0.1.2", +"version": "0.1.3", "category": "launcher", "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 b03e3af7..ca4eecdc 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/META-INF/MANIFEST.JSON @@ -3,10 +3,10 @@ "publisher": "MicroPythonOS", "short_description": "Operating System Updater", "long_description": "Updates the operating system in a safe way, to a secondary partition. After the update, the device is restarted. If the system starts up successfully, it is marked as valid and kept. Otherwise, a rollback to the old, primary partition is performed.", -"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/icons/com.micropythonos.osupdate_0.1.2_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/mpks/com.micropythonos.osupdate_0.1.2.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/icons/com.micropythonos.osupdate_0.1.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.osupdate/mpks/com.micropythonos.osupdate_0.1.3.mpk", "fullname": "com.micropythonos.osupdate", -"version": "0.1.2", +"version": "0.1.3", "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 61eb5827..719e8a9d 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/META-INF/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/META-INF/MANIFEST.JSON @@ -3,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.1.2_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/mpks/com.micropythonos.settings_0.1.2.mpk", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/icons/com.micropythonos.settings_0.1.3_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.settings/mpks/com.micropythonos.settings_0.1.3.mpk", "fullname": "com.micropythonos.settings", -"version": "0.1.2", +"version": "0.1.3", "category": "development", "activities": [ {