From 7faf65f21028e1c841ce9741b342c99713961c14 Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Sat, 17 Oct 2026 14:49:47 +0100 Subject: [PATCH 1/8] Refactor: Split Tab5 tools into registered standalone apps Preserve the existing modernization as the stabilization baseline. - Add the app catalog, shared shell, standalone hardware/settings/media screens and PPS module support. - Include the existing camera, GPIO, PPS and power workflow safeguards, desktop simulation and regression tests. - Preserve VS Code/CMake setup and the OTA partition layout. The reviewed baseline builds for desktop and Tab5 and passes 55 desktop tests. Device acceptance and known lifecycle, hardware and OTA defects remain outstanding. --- .clangd | 13 + .gitignore | 1 - .vscode/extensions.json | 12 + .vscode/launch.json | 14 + .vscode/settings.json | 66 + .vscode/tasks.json | 139 ++ CMakeLists.txt | 385 +++++ CMakePresets.json | 36 + README.md | 89 ++ app/app.cpp | 1277 ++++++++++++++++- app/apps/app_catalog.cpp | 298 ++++ app/apps/app_catalog.h | 14 + app/apps/app_diagnostics/app_diagnostics.cpp | 141 ++ app/apps/app_diagnostics/app_diagnostics.h | 15 + app/apps/app_diagnostics/diagnostics_layout.h | 48 + app/apps/app_generator.py | 317 ++-- .../app_com_monitor.cpp | 143 ++ .../app_com_monitor.h | 15 + .../com_monitor_controller.cpp | 36 + .../com_monitor_controller.h | 35 + .../com_monitor_layout.h | 41 + app/apps/app_hardware_gpio/app_gpio.cpp | 193 +++ app/apps/app_hardware_gpio/app_gpio.h | 15 + .../app_hardware_gpio/gpio_controller.cpp | 112 ++ app/apps/app_hardware_gpio/gpio_controller.h | 54 + app/apps/app_hardware_gpio/gpio_layout.h | 55 + app/apps/app_hardware_i2c/app_i2c_scan.cpp | 147 ++ app/apps/app_hardware_i2c/app_i2c_scan.h | 15 + .../app_hardware_i2c/i2c_scan_controller.cpp | 104 ++ .../app_hardware_i2c/i2c_scan_controller.h | 44 + app/apps/app_hardware_i2c/i2c_scan_layout.h | 49 + app/apps/app_hardware_imu/app_imu.cpp | 133 ++ app/apps/app_hardware_imu/app_imu.h | 15 + app/apps/app_hardware_imu/imu_controller.cpp | 38 + app/apps/app_hardware_imu/imu_controller.h | 33 + app/apps/app_hardware_imu/imu_layout.h | 49 + .../app_power_monitor.cpp | 141 ++ .../app_power_monitor.h | 15 + .../power_monitor_controller.cpp | 66 + .../power_monitor_controller.h | 45 + .../power_monitor_layout.h | 48 + app/apps/app_hardware_rtc/app_rtc.cpp | 125 ++ app/apps/app_hardware_rtc/app_rtc.h | 15 + app/apps/app_hardware_rtc/rtc_controller.cpp | 44 + app/apps/app_hardware_rtc/rtc_controller.h | 34 + app/apps/app_hardware_rtc/rtc_layout.h | 41 + app/apps/app_hardware_sd/app_sd_card.cpp | 125 ++ app/apps/app_hardware_sd/app_sd_card.h | 15 + .../app_hardware_sd/sd_card_controller.cpp | 105 ++ app/apps/app_hardware_sd/sd_card_controller.h | 46 + app/apps/app_hardware_sd/sd_card_layout.h | 39 + .../app_hardware_switches/app_switches.cpp | 195 +++ app/apps/app_hardware_switches/app_switches.h | 15 + .../switches_controller.cpp | 40 + .../switches_controller.h | 55 + .../app_hardware_switches/switches_layout.h | 68 + app/apps/app_installer.h | 26 +- app/apps/app_launcher/view/launcher_layout.h | 123 ++ app/apps/app_launcher/view/panel_camera.cpp | 139 -- .../app_launcher/view/panel_com_monitor.cpp | 125 -- app/apps/app_launcher/view/panel_dual_mic.cpp | 167 --- app/apps/app_launcher/view/panel_gpio.cpp | 248 ---- .../app_launcher/view/panel_headphone.cpp | 178 --- app/apps/app_launcher/view/panel_i2c_scan.cpp | 194 --- app/apps/app_launcher/view/panel_imu.cpp | 120 -- .../app_launcher/view/panel_lcd_backlight.cpp | 96 -- app/apps/app_launcher/view/panel_music.cpp | 180 --- app/apps/app_launcher/view/panel_power.cpp | 274 ---- .../app_launcher/view/panel_power_monitor.cpp | 80 -- app/apps/app_launcher/view/panel_rtc.cpp | 205 --- app/apps/app_launcher/view/panel_sd_card.cpp | 137 -- .../app_launcher/view/panel_spk_volume.cpp | 95 -- app/apps/app_launcher/view/panel_switches.cpp | 350 ----- app/apps/app_launcher/view/view.cpp | 381 ++++- app/apps/app_launcher/view/view.h | 333 +---- app/apps/app_media_audio/app_audio_tool.cpp | 213 +++ app/apps/app_media_audio/app_audio_tool.h | 17 + .../app_media_audio/audio_tool_controller.cpp | 68 + .../app_media_audio/audio_tool_controller.h | 50 + app/apps/app_media_audio/audio_tool_layout.h | 49 + app/apps/app_media_camera/app_camera.cpp | 208 +++ app/apps/app_media_camera/app_camera.h | 15 + .../app_media_camera/camera_controller.cpp | 83 ++ app/apps/app_media_camera/camera_controller.h | 40 + app/apps/app_media_camera/camera_layout.h | 49 + app/apps/app_pps/app_pps.cpp | 44 + app/apps/app_pps/app_pps.h | 24 + app/apps/app_pps/pps_controller.cpp | 459 ++++++ app/apps/app_pps/pps_controller.h | 71 + app/apps/app_pps/pps_layout.h | 66 + app/apps/app_pps/pps_view.cpp | 340 +++++ app/apps/app_pps/pps_view.h | 69 + app/apps/app_registry.cpp | 137 ++ app/apps/app_registry.h | 99 ++ app/apps/app_settings/about_layout.h | 39 + app/apps/app_settings/app_settings.cpp | 97 ++ app/apps/app_settings/app_settings.h | 16 + .../app_display_sound.cpp | 180 +++ .../app_display_sound.h | 15 + .../display_sound_controller.cpp | 52 + .../display_sound_controller.h | 45 + .../display_sound_layout.h | 60 + app/apps/app_settings_network/app_network.cpp | 487 +++++++ app/apps/app_settings_network/app_network.h | 15 + .../network_controller.cpp | 209 +++ .../app_settings_network/network_controller.h | 68 + .../app_settings_network/network_layout.h | 61 + .../app_settings_power/app_power_settings.cpp | 156 ++ .../app_settings_power/app_power_settings.h | 15 + .../power_settings_controller.cpp | 219 +++ .../power_settings_controller.h | 51 + .../power_settings_layout.h | 30 + app/apps/app_settings_updates/app_updates.cpp | 232 +++ app/apps/app_settings_updates/app_updates.h | 15 + .../updates_controller.cpp | 87 ++ .../app_settings_updates/updates_controller.h | 47 + .../app_settings_updates/updates_layout.h | 41 + .../app_startup_anim/app_startup_anim.cpp | 133 -- app/apps/app_startup_anim/app_startup_anim.h | 45 - app/apps/app_template/app_template.cpp | 46 +- app/apps/app_template/app_template.h | 23 +- app/apps/runtime/async_job_runner.cpp | 100 ++ app/apps/runtime/async_job_runner.h | 31 + app/apps/runtime/performance_telemetry.cpp | 90 ++ app/apps/runtime/performance_telemetry.h | 51 + app/apps/runtime/update_scheduler.h | 37 + app/apps/ui/app_shell.cpp | 223 +++ app/apps/ui/app_shell.h | 53 + app/apps/ui/app_shell_layout.h | 60 + app/apps/ui/ui_components.cpp | 299 ++++ app/apps/ui/ui_components.h | 45 + app/apps/ui/ui_layout.h | 96 ++ app/apps/ui/ui_theme.h | 29 + app/assets/assets.h | 17 - app/hal/camera_preview.h | 41 + app/hal/display_sleep_gate.h | 33 + app/hal/gpio_diagnostics.h | 22 + app/hal/hal.h | 183 ++- app/hal/power_control.h | 13 + app/hal/profiles/tab5_gpio.h | 34 + app/modules/i2c_device.cpp | 30 + app/modules/i2c_device.h | 36 + app/modules/pps/pps_device.cpp | 195 +++ app/modules/pps/pps_device.h | 93 ++ app/modules/rtc/rx8130_wake_timer.cpp | 68 + app/modules/rtc/rx8130_wake_timer.h | 19 + lv_conf.h | 6 +- platforms/desktop/CMakeLists.txt | 15 +- .../desktop/hal/components/hal_camera.cpp | 148 ++ platforms/desktop/hal/components/hal_lvgl.cpp | 3 +- platforms/desktop/hal/hal_desktop.cpp | 355 ++++- platforms/desktop/hal/hal_desktop.h | 51 + platforms/tab5/.gitignore | 1 - platforms/tab5/.vscode/settings.json | 53 + .../tab5/components/esp_video/src/esp_video.c | 4 +- .../components/esp_video/src/esp_video_init.c | 6 +- .../sensor_bmi270/include/accel_gyro_bmi270.h | 1 + .../sensor_bmi270/src/accel_gyro_bmi270.c | 23 +- platforms/tab5/main/CMakeLists.txt | 3 + .../tab5/main/hal/components/hal_camera.cpp | 723 +++++----- .../tab5/main/hal/components/hal_imu.cpp | 19 - .../tab5/main/hal/components/hal_power.cpp | 188 +-- .../tab5/main/hal/components/hal_wifi.cpp | 426 ++++-- platforms/tab5/main/hal/hal_esp32.cpp | 151 +- platforms/tab5/main/hal/hal_esp32.h | 47 +- .../tab5/main/hal/utils/rx8130/rx8130.cpp | 8 +- platforms/tab5/partitions.csv | 7 +- tests/test_about_layout.cpp | 38 + tests/test_app_catalog_metadata.cpp | 134 ++ tests/test_app_registry.cpp | 137 ++ tests/test_app_shell_layout.cpp | 51 + tests/test_async_job_runner.cpp | 61 + tests/test_audio_tool_controller.cpp | 54 + tests/test_audio_tool_layout.cpp | 61 + tests/test_camera_controller.cpp | 100 ++ tests/test_camera_layout.cpp | 76 + tests/test_com_monitor_controller.cpp | 41 + tests/test_com_monitor_layout.cpp | 41 + tests/test_diagnostics_layout.cpp | 54 + tests/test_display_sleep_gate.cpp | 26 + tests/test_display_sound_controller.cpp | 83 ++ tests/test_display_sound_layout.cpp | 53 + tests/test_gpio_controller.cpp | 153 ++ tests/test_gpio_layout.cpp | 41 + tests/test_i2c_scan_controller.cpp | 23 + tests/test_i2c_scan_layout.cpp | 52 + tests/test_imu_controller.cpp | 27 + tests/test_imu_layout.cpp | 47 + tests/test_launcher_layout.cpp | 61 + tests/test_network_layout.cpp | 56 + tests/test_network_settings_controller.cpp | 125 ++ tests/test_performance_telemetry.cpp | 57 + tests/test_power_monitor_controller.cpp | 69 + tests/test_power_monitor_layout.cpp | 54 + tests/test_power_settings_controller.cpp | 182 +++ tests/test_power_settings_layout.cpp | 25 + tests/test_pps_controller.cpp | 367 +++++ tests/test_pps_device.cpp | 181 +++ tests/test_pps_layout.cpp | 66 + tests/test_rtc_controller.cpp | 27 + tests/test_rtc_layout.cpp | 41 + tests/test_rtc_wake_timer.cpp | 71 + tests/test_sd_card_controller.cpp | 34 + tests/test_sd_card_layout.cpp | 39 + tests/test_switches_controller.cpp | 74 + tests/test_switches_layout.cpp | 60 + tests/test_ui_architecture_guard.cpp | 226 +++ tests/test_ui_layout.cpp | 86 ++ tests/test_update_scheduler.cpp | 47 + tests/test_updates_controller.cpp | 68 + tests/test_updates_layout.cpp | 43 + 211 files changed, 17142 insertions(+), 3910 deletions(-) create mode 100644 .clangd create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 CMakePresets.json create mode 100644 app/apps/app_catalog.cpp create mode 100644 app/apps/app_catalog.h create mode 100644 app/apps/app_diagnostics/app_diagnostics.cpp create mode 100644 app/apps/app_diagnostics/app_diagnostics.h create mode 100644 app/apps/app_diagnostics/diagnostics_layout.h create mode 100644 app/apps/app_hardware_com_monitor/app_com_monitor.cpp create mode 100644 app/apps/app_hardware_com_monitor/app_com_monitor.h create mode 100644 app/apps/app_hardware_com_monitor/com_monitor_controller.cpp create mode 100644 app/apps/app_hardware_com_monitor/com_monitor_controller.h create mode 100644 app/apps/app_hardware_com_monitor/com_monitor_layout.h create mode 100644 app/apps/app_hardware_gpio/app_gpio.cpp create mode 100644 app/apps/app_hardware_gpio/app_gpio.h create mode 100644 app/apps/app_hardware_gpio/gpio_controller.cpp create mode 100644 app/apps/app_hardware_gpio/gpio_controller.h create mode 100644 app/apps/app_hardware_gpio/gpio_layout.h create mode 100644 app/apps/app_hardware_i2c/app_i2c_scan.cpp create mode 100644 app/apps/app_hardware_i2c/app_i2c_scan.h create mode 100644 app/apps/app_hardware_i2c/i2c_scan_controller.cpp create mode 100644 app/apps/app_hardware_i2c/i2c_scan_controller.h create mode 100644 app/apps/app_hardware_i2c/i2c_scan_layout.h create mode 100644 app/apps/app_hardware_imu/app_imu.cpp create mode 100644 app/apps/app_hardware_imu/app_imu.h create mode 100644 app/apps/app_hardware_imu/imu_controller.cpp create mode 100644 app/apps/app_hardware_imu/imu_controller.h create mode 100644 app/apps/app_hardware_imu/imu_layout.h create mode 100644 app/apps/app_hardware_power_monitor/app_power_monitor.cpp create mode 100644 app/apps/app_hardware_power_monitor/app_power_monitor.h create mode 100644 app/apps/app_hardware_power_monitor/power_monitor_controller.cpp create mode 100644 app/apps/app_hardware_power_monitor/power_monitor_controller.h create mode 100644 app/apps/app_hardware_power_monitor/power_monitor_layout.h create mode 100644 app/apps/app_hardware_rtc/app_rtc.cpp create mode 100644 app/apps/app_hardware_rtc/app_rtc.h create mode 100644 app/apps/app_hardware_rtc/rtc_controller.cpp create mode 100644 app/apps/app_hardware_rtc/rtc_controller.h create mode 100644 app/apps/app_hardware_rtc/rtc_layout.h create mode 100644 app/apps/app_hardware_sd/app_sd_card.cpp create mode 100644 app/apps/app_hardware_sd/app_sd_card.h create mode 100644 app/apps/app_hardware_sd/sd_card_controller.cpp create mode 100644 app/apps/app_hardware_sd/sd_card_controller.h create mode 100644 app/apps/app_hardware_sd/sd_card_layout.h create mode 100644 app/apps/app_hardware_switches/app_switches.cpp create mode 100644 app/apps/app_hardware_switches/app_switches.h create mode 100644 app/apps/app_hardware_switches/switches_controller.cpp create mode 100644 app/apps/app_hardware_switches/switches_controller.h create mode 100644 app/apps/app_hardware_switches/switches_layout.h create mode 100644 app/apps/app_launcher/view/launcher_layout.h delete mode 100644 app/apps/app_launcher/view/panel_camera.cpp delete mode 100644 app/apps/app_launcher/view/panel_com_monitor.cpp delete mode 100644 app/apps/app_launcher/view/panel_dual_mic.cpp delete mode 100644 app/apps/app_launcher/view/panel_gpio.cpp delete mode 100644 app/apps/app_launcher/view/panel_headphone.cpp delete mode 100644 app/apps/app_launcher/view/panel_i2c_scan.cpp delete mode 100644 app/apps/app_launcher/view/panel_imu.cpp delete mode 100644 app/apps/app_launcher/view/panel_lcd_backlight.cpp delete mode 100644 app/apps/app_launcher/view/panel_music.cpp delete mode 100644 app/apps/app_launcher/view/panel_power.cpp delete mode 100644 app/apps/app_launcher/view/panel_power_monitor.cpp delete mode 100644 app/apps/app_launcher/view/panel_rtc.cpp delete mode 100644 app/apps/app_launcher/view/panel_sd_card.cpp delete mode 100644 app/apps/app_launcher/view/panel_spk_volume.cpp delete mode 100644 app/apps/app_launcher/view/panel_switches.cpp create mode 100644 app/apps/app_media_audio/app_audio_tool.cpp create mode 100644 app/apps/app_media_audio/app_audio_tool.h create mode 100644 app/apps/app_media_audio/audio_tool_controller.cpp create mode 100644 app/apps/app_media_audio/audio_tool_controller.h create mode 100644 app/apps/app_media_audio/audio_tool_layout.h create mode 100644 app/apps/app_media_camera/app_camera.cpp create mode 100644 app/apps/app_media_camera/app_camera.h create mode 100644 app/apps/app_media_camera/camera_controller.cpp create mode 100644 app/apps/app_media_camera/camera_controller.h create mode 100644 app/apps/app_media_camera/camera_layout.h create mode 100644 app/apps/app_pps/app_pps.cpp create mode 100644 app/apps/app_pps/app_pps.h create mode 100644 app/apps/app_pps/pps_controller.cpp create mode 100644 app/apps/app_pps/pps_controller.h create mode 100644 app/apps/app_pps/pps_layout.h create mode 100644 app/apps/app_pps/pps_view.cpp create mode 100644 app/apps/app_pps/pps_view.h create mode 100644 app/apps/app_registry.cpp create mode 100644 app/apps/app_registry.h create mode 100644 app/apps/app_settings/about_layout.h create mode 100644 app/apps/app_settings/app_settings.cpp create mode 100644 app/apps/app_settings/app_settings.h create mode 100644 app/apps/app_settings_display_sound/app_display_sound.cpp create mode 100644 app/apps/app_settings_display_sound/app_display_sound.h create mode 100644 app/apps/app_settings_display_sound/display_sound_controller.cpp create mode 100644 app/apps/app_settings_display_sound/display_sound_controller.h create mode 100644 app/apps/app_settings_display_sound/display_sound_layout.h create mode 100644 app/apps/app_settings_network/app_network.cpp create mode 100644 app/apps/app_settings_network/app_network.h create mode 100644 app/apps/app_settings_network/network_controller.cpp create mode 100644 app/apps/app_settings_network/network_controller.h create mode 100644 app/apps/app_settings_network/network_layout.h create mode 100644 app/apps/app_settings_power/app_power_settings.cpp create mode 100644 app/apps/app_settings_power/app_power_settings.h create mode 100644 app/apps/app_settings_power/power_settings_controller.cpp create mode 100644 app/apps/app_settings_power/power_settings_controller.h create mode 100644 app/apps/app_settings_power/power_settings_layout.h create mode 100644 app/apps/app_settings_updates/app_updates.cpp create mode 100644 app/apps/app_settings_updates/app_updates.h create mode 100644 app/apps/app_settings_updates/updates_controller.cpp create mode 100644 app/apps/app_settings_updates/updates_controller.h create mode 100644 app/apps/app_settings_updates/updates_layout.h delete mode 100644 app/apps/app_startup_anim/app_startup_anim.cpp delete mode 100644 app/apps/app_startup_anim/app_startup_anim.h create mode 100644 app/apps/runtime/async_job_runner.cpp create mode 100644 app/apps/runtime/async_job_runner.h create mode 100644 app/apps/runtime/performance_telemetry.cpp create mode 100644 app/apps/runtime/performance_telemetry.h create mode 100644 app/apps/runtime/update_scheduler.h create mode 100644 app/apps/ui/app_shell.cpp create mode 100644 app/apps/ui/app_shell.h create mode 100644 app/apps/ui/app_shell_layout.h create mode 100644 app/apps/ui/ui_components.cpp create mode 100644 app/apps/ui/ui_components.h create mode 100644 app/apps/ui/ui_layout.h create mode 100644 app/apps/ui/ui_theme.h create mode 100644 app/hal/camera_preview.h create mode 100644 app/hal/display_sleep_gate.h create mode 100644 app/hal/gpio_diagnostics.h create mode 100644 app/hal/power_control.h create mode 100644 app/hal/profiles/tab5_gpio.h create mode 100644 app/modules/i2c_device.cpp create mode 100644 app/modules/i2c_device.h create mode 100644 app/modules/pps/pps_device.cpp create mode 100644 app/modules/pps/pps_device.h create mode 100644 app/modules/rtc/rx8130_wake_timer.cpp create mode 100644 app/modules/rtc/rx8130_wake_timer.h create mode 100644 platforms/desktop/hal/components/hal_camera.cpp create mode 100644 platforms/tab5/.vscode/settings.json create mode 100644 tests/test_about_layout.cpp create mode 100644 tests/test_app_catalog_metadata.cpp create mode 100644 tests/test_app_registry.cpp create mode 100644 tests/test_app_shell_layout.cpp create mode 100644 tests/test_async_job_runner.cpp create mode 100644 tests/test_audio_tool_controller.cpp create mode 100644 tests/test_audio_tool_layout.cpp create mode 100644 tests/test_camera_controller.cpp create mode 100644 tests/test_camera_layout.cpp create mode 100644 tests/test_com_monitor_controller.cpp create mode 100644 tests/test_com_monitor_layout.cpp create mode 100644 tests/test_diagnostics_layout.cpp create mode 100644 tests/test_display_sleep_gate.cpp create mode 100644 tests/test_display_sound_controller.cpp create mode 100644 tests/test_display_sound_layout.cpp create mode 100644 tests/test_gpio_controller.cpp create mode 100644 tests/test_gpio_layout.cpp create mode 100644 tests/test_i2c_scan_controller.cpp create mode 100644 tests/test_i2c_scan_layout.cpp create mode 100644 tests/test_imu_controller.cpp create mode 100644 tests/test_imu_layout.cpp create mode 100644 tests/test_launcher_layout.cpp create mode 100644 tests/test_network_layout.cpp create mode 100644 tests/test_network_settings_controller.cpp create mode 100644 tests/test_performance_telemetry.cpp create mode 100644 tests/test_power_monitor_controller.cpp create mode 100644 tests/test_power_monitor_layout.cpp create mode 100644 tests/test_power_settings_controller.cpp create mode 100644 tests/test_power_settings_layout.cpp create mode 100644 tests/test_pps_controller.cpp create mode 100644 tests/test_pps_device.cpp create mode 100644 tests/test_pps_layout.cpp create mode 100644 tests/test_rtc_controller.cpp create mode 100644 tests/test_rtc_layout.cpp create mode 100644 tests/test_rtc_wake_timer.cpp create mode 100644 tests/test_sd_card_controller.cpp create mode 100644 tests/test_sd_card_layout.cpp create mode 100644 tests/test_switches_controller.cpp create mode 100644 tests/test_switches_layout.cpp create mode 100644 tests/test_ui_architecture_guard.cpp create mode 100644 tests/test_ui_layout.cpp create mode 100644 tests/test_update_scheduler.cpp create mode 100644 tests/test_updates_controller.cpp create mode 100644 tests/test_updates_layout.cpp diff --git a/.clangd b/.clangd new file mode 100644 index 00000000..0036118c --- /dev/null +++ b/.clangd @@ -0,0 +1,13 @@ +CompileFlags: + CompilationDatabase: build/vscode-desktop + +Index: + Background: Build + +Diagnostics: + UnusedIncludes: Strict + +If: + PathMatch: platforms/tab5/.* +CompileFlags: + CompilationDatabase: platforms/tab5/build diff --git a/.gitignore b/.gitignore index 511fa899..57207187 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .cache -.vscode build dependencies .DS_Store diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..b3cb14ee --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "espressif.esp-idf-extension", + "llvm-vs-code-extensions.vscode-clangd", + "ms-vscode.cmake-tools", + "ms-python.python", + "vadimcn.vscode-lldb" + ], + "unwantedRecommendations": [ + "platformio.platformio-ide" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..ca838145 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug desktop demo", + "type": "lldb", + "request": "launch", + "program": "${workspaceFolder}/build/desktop/app_desktop_build", + "cwd": "${workspaceFolder}", + "preLaunchTask": "Desktop: build", + "args": [] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..465d4a5d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,66 @@ +{ + "cmake.useCMakePresets": "always", + "cmake.configureOnOpen": false, + "cmake.buildDirectory": "${workspaceFolder}/build/vscode-desktop", + "cmake.configureArgs": [ + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON" + ], + + "C_Cpp.intelliSenseEngine": "disabled", + + "clangd.arguments": [ + "--background-index", + "--completion-style=detailed", + "--header-insertion=never", + "--query-driver=${env:HOME}/.espressif/tools/**/bin/*gcc,${env:HOME}/.espressif/tools/**/bin/*g++,${env:HOME}/esp/**/bin/*gcc,${env:HOME}/esp/**/bin/*g++" + ], + + "idf.espIdfPath": "${env:IDF_PATH}", + "idf.buildPath": "${workspaceFolder}/platforms/tab5/build", + "idf.sdkconfigFilePath": "${workspaceFolder}/platforms/tab5/sdkconfig", + "idf.flashType": "UART", + "idf.flashBaudRate": "921600", + "idf.monitorEnableTimestamps": true, + "idf.saveBeforeBuild": true, + "idf.enableCCache": true, + "idf.enableSizeTaskAfterBuildTask": true, + "idf.notificationMode": "Notifications", + "idf.telemetry": false, + + "files.associations": { + "*.h": "cpp", + "*.hpp": "cpp", + "*.hh": "cpp", + "*.c": "c", + "*.cpp": "cpp", + "CMakeLists.txt": "cmake", + "Kconfig*": "kconfig", + "sdkconfig*": "kconfig" + }, + + "files.exclude": { + "**/.DS_Store": true, + "**/build/**": true, + "**/platforms/tab5/build/**": true, + "**/platforms/tab5/managed_components/**": true + }, + + "search.exclude": { + "**/build/**": true, + "**/platforms/tab5/build/**": true, + "**/platforms/tab5/managed_components/**": true, + "**/dependencies/**": true + }, + + "files.watcherExclude": { + "**/build/**": true, + "**/platforms/tab5/build/**": true, + "**/platforms/tab5/managed_components/**": true, + "**/dependencies/**": true + }, + + "editor.formatOnSave": false, + "editor.rulers": [100], + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..7cefa249 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,139 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Tab5: fetch dependencies", + "type": "shell", + "command": "python3 ./fetch_repos.py", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "problemMatcher": [] + }, + { + "label": "Tab5: ESP-IDF reconfigure", + "type": "shell", + "command": "set -e; if [ -f ../esp-idf/export.sh ]; then . ../esp-idf/export.sh; elif [ -f \"$HOME/esp/esp-idf/export.sh\" ]; then . \"$HOME/esp/esp-idf/export.sh\"; elif [ -n \"$IDF_PATH\" ] && [ -f \"$IDF_PATH/export.sh\" ]; then . \"$IDF_PATH/export.sh\"; else echo 'ESP-IDF export.sh not found. Put esp-idf next to this repo or set IDF_PATH.'; exit 1; fi; cd platforms/tab5; idf.py reconfigure", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "problemMatcher": [] + }, + { + "label": "Tab5: ESP-IDF build", + "type": "shell", + "command": "set -e; if [ -f ../esp-idf/export.sh ]; then . ../esp-idf/export.sh; elif [ -f \"$HOME/esp/esp-idf/export.sh\" ]; then . \"$HOME/esp/esp-idf/export.sh\"; elif [ -n \"$IDF_PATH\" ] && [ -f \"$IDF_PATH/export.sh\" ]; then . \"$IDF_PATH/export.sh\"; else echo 'ESP-IDF export.sh not found. Put esp-idf next to this repo or set IDF_PATH.'; exit 1; fi; cd platforms/tab5; idf.py build", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "group": "build", + "problemMatcher": ["$gcc"] + }, + { + "label": "Tab5: ESP-IDF flash", + "type": "shell", + "command": "set -e; if [ -f ../esp-idf/export.sh ]; then . ../esp-idf/export.sh; elif [ -f \"$HOME/esp/esp-idf/export.sh\" ]; then . \"$HOME/esp/esp-idf/export.sh\"; elif [ -n \"$IDF_PATH\" ] && [ -f \"$IDF_PATH/export.sh\" ]; then . \"$IDF_PATH/export.sh\"; else echo 'ESP-IDF export.sh not found. Put esp-idf next to this repo or set IDF_PATH.'; exit 1; fi; cd platforms/tab5; idf.py flash", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "problemMatcher": [] + }, + { + "label": "Tab5: ESP-IDF monitor", + "type": "shell", + "command": "set -e; if [ -f ../esp-idf/export.sh ]; then . ../esp-idf/export.sh; elif [ -f \"$HOME/esp/esp-idf/export.sh\" ]; then . \"$HOME/esp/esp-idf/export.sh\"; elif [ -n \"$IDF_PATH\" ] && [ -f \"$IDF_PATH/export.sh\" ]; then . \"$IDF_PATH/export.sh\"; else echo 'ESP-IDF export.sh not found. Put esp-idf next to this repo or set IDF_PATH.'; exit 1; fi; cd platforms/tab5; idf.py monitor", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "problemMatcher": [] + }, + { + "label": "Tab5: ESP-IDF menuconfig", + "type": "shell", + "command": "set -e; if [ -f ../esp-idf/export.sh ]; then . ../esp-idf/export.sh; elif [ -f \"$HOME/esp/esp-idf/export.sh\" ]; then . \"$HOME/esp/esp-idf/export.sh\"; elif [ -n \"$IDF_PATH\" ] && [ -f \"$IDF_PATH/export.sh\" ]; then . \"$IDF_PATH/export.sh\"; else echo 'ESP-IDF export.sh not found. Put esp-idf next to this repo or set IDF_PATH.'; exit 1; fi; cd platforms/tab5; idf.py menuconfig", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "problemMatcher": [] + }, + { + "label": "Desktop: configure", + "type": "shell", + "command": "cmake --preset desktop", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "problemMatcher": [] + }, + { + "label": "Desktop: build", + "type": "shell", + "command": "cmake --build --preset desktop", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "dependsOn": ["Desktop: configure"], + "problemMatcher": ["$gcc"] + }, + { + "label": "Desktop: test", + "type": "shell", + "command": "ctest --preset desktop", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "dependsOn": ["Desktop: build"], + "problemMatcher": ["$gcc"] + }, + { + "label": "Desktop: run", + "type": "shell", + "command": "./build/desktop/app_desktop_build", + "options": { + "cwd": "${workspaceFolder}", + "shell": { + "executable": "/bin/zsh", + "args": ["-lc"] + } + }, + "dependsOn": ["Desktop: build"], + "problemMatcher": [] + } + ] +} diff --git a/CMakeLists.txt b/CMakeLists.txt index 39b4be0f..5638f761 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,3 +2,388 @@ cmake_minimum_required(VERSION 3.10) project(MC_APP_BOILERPLATE VERSION 0.1.0 LANGUAGES C CXX) include(platforms/desktop/CMakeLists.txt) + +enable_testing() + +add_test(NAME desktop_runtime_smoke_tests + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy M5TAB5_DESKTOP_MAX_FRAMES=8 M5TAB5_DESKTOP_ASSERT_LAUNCHER_UI=1 M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI=1 $ +) +set_tests_properties(desktop_runtime_smoke_tests PROPERTIES TIMEOUT 20) + +add_test(NAME desktop_network_empty_ui_tests + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy M5TAB5_DESKTOP_MAX_FRAMES=8 M5TAB5_DESKTOP_ASSERT_LAUNCHER_UI=1 M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI=1 M5TAB5_DESKTOP_WIFI_NO_NETWORKS=1 $ +) +add_test(NAME desktop_network_failure_ui_tests + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy M5TAB5_DESKTOP_MAX_FRAMES=8 M5TAB5_DESKTOP_ASSERT_LAUNCHER_UI=1 M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI=1 M5TAB5_DESKTOP_WIFI_CONNECT_FAIL=1 $ +) +set_tests_properties(desktop_network_empty_ui_tests desktop_network_failure_ui_tests PROPERTIES TIMEOUT 20) + +foreach(camera_fault start frame stall) + add_test(NAME desktop_camera_${camera_fault}_ui_tests + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy M5TAB5_DESKTOP_MAX_FRAMES=8 M5TAB5_DESKTOP_ASSERT_LAUNCHER_UI=1 M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI=1 M5TAB5_DESKTOP_CAMERA_FAIL=${camera_fault} $ + ) + set_tests_properties(desktop_camera_${camera_fault}_ui_tests PROPERTIES TIMEOUT 20) +endforeach() + +add_executable(pps_device_tests + tests/test_pps_device.cpp + app/modules/pps/pps_device.cpp +) +target_include_directories(pps_device_tests PUBLIC app) +target_include_directories(pps_device_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(pps_device_tests PUBLIC cxx_std_17) +add_test(NAME pps_device_tests COMMAND pps_device_tests) + +add_executable(pps_controller_tests + tests/test_pps_controller.cpp + app/apps/app_pps/pps_controller.cpp + app/apps/runtime/async_job_runner.cpp + app/modules/pps/pps_device.cpp + app/modules/i2c_device.cpp +) +target_include_directories(pps_controller_tests PUBLIC app) +target_include_directories(pps_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(pps_controller_tests PUBLIC cxx_std_17) +target_link_libraries(pps_controller_tests PUBLIC mooncake_log) +add_test(NAME pps_controller_tests COMMAND pps_controller_tests) +set_tests_properties(pps_controller_tests PROPERTIES TIMEOUT 20) + +add_test(NAME desktop_pps_absent_ui_tests + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy M5TAB5_DESKTOP_MAX_FRAMES=8 M5TAB5_DESKTOP_ASSERT_LAUNCHER_UI=1 M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI=1 M5TAB5_DESKTOP_PPS_ABSENT=1 $ +) +set_tests_properties(desktop_pps_absent_ui_tests PROPERTIES TIMEOUT 20) + +foreach(gpio_fault init set release) + add_test(NAME desktop_gpio_${gpio_fault}_ui_tests + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy M5TAB5_DESKTOP_MAX_FRAMES=8 M5TAB5_DESKTOP_ASSERT_LAUNCHER_UI=1 M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI=1 M5TAB5_DESKTOP_GPIO_FAIL=${gpio_fault} $ + ) + set_tests_properties(desktop_gpio_${gpio_fault}_ui_tests PROPERTIES TIMEOUT 20) +endforeach() + +add_test(NAME desktop_power_failure_ui_tests + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy M5TAB5_DESKTOP_MAX_FRAMES=8 M5TAB5_DESKTOP_ASSERT_LAUNCHER_UI=1 M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI=1 M5TAB5_DESKTOP_POWER_FAIL=1 $ +) +set_tests_properties(desktop_power_failure_ui_tests PROPERTIES TIMEOUT 20) + +add_executable(app_registry_tests + tests/test_app_registry.cpp + app/apps/app_registry.cpp +) +target_include_directories(app_registry_tests PUBLIC app dependencies/mooncake/src) +target_include_directories(app_registry_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(app_registry_tests PUBLIC cxx_std_17) +target_link_libraries(app_registry_tests PUBLIC mooncake mooncake_log) +add_test(NAME app_registry_tests COMMAND app_registry_tests) + +add_executable(app_catalog_metadata_tests + tests/test_app_catalog_metadata.cpp +) +target_compile_features(app_catalog_metadata_tests PUBLIC cxx_std_17) +add_test(NAME app_catalog_metadata_tests COMMAND app_catalog_metadata_tests) + +add_executable(ui_layout_tests + tests/test_ui_layout.cpp +) +target_include_directories(ui_layout_tests PUBLIC app) +target_compile_features(ui_layout_tests PUBLIC cxx_std_17) +add_test(NAME ui_layout_tests COMMAND ui_layout_tests) + +add_executable(app_shell_layout_tests + tests/test_app_shell_layout.cpp +) +target_include_directories(app_shell_layout_tests PUBLIC app) +target_compile_features(app_shell_layout_tests PUBLIC cxx_std_17) +add_test(NAME app_shell_layout_tests COMMAND app_shell_layout_tests) + +add_executable(async_job_runner_tests + tests/test_async_job_runner.cpp + app/apps/runtime/async_job_runner.cpp +) +target_include_directories(async_job_runner_tests PUBLIC app) +target_compile_features(async_job_runner_tests PUBLIC cxx_std_17) +target_link_libraries(async_job_runner_tests PUBLIC pthread) +add_test(NAME async_job_runner_tests COMMAND async_job_runner_tests) + +add_executable(performance_telemetry_tests + tests/test_performance_telemetry.cpp + app/apps/runtime/performance_telemetry.cpp +) +target_include_directories(performance_telemetry_tests PUBLIC app) +target_compile_features(performance_telemetry_tests PUBLIC cxx_std_17) +add_test(NAME performance_telemetry_tests COMMAND performance_telemetry_tests) + +add_executable(update_scheduler_tests + tests/test_update_scheduler.cpp +) +target_include_directories(update_scheduler_tests PUBLIC app) +target_compile_features(update_scheduler_tests PUBLIC cxx_std_17) +add_test(NAME update_scheduler_tests COMMAND update_scheduler_tests) + +add_executable(ui_architecture_guard_tests + tests/test_ui_architecture_guard.cpp +) +target_compile_features(ui_architecture_guard_tests PUBLIC cxx_std_17) +add_test(NAME ui_architecture_guard_tests COMMAND ui_architecture_guard_tests) + +add_executable(i2c_scan_controller_tests + tests/test_i2c_scan_controller.cpp + app/apps/app_hardware_i2c/i2c_scan_controller.cpp + app/apps/runtime/async_job_runner.cpp +) +target_include_directories(i2c_scan_controller_tests PUBLIC app) +target_compile_features(i2c_scan_controller_tests PUBLIC cxx_std_17) +add_test(NAME i2c_scan_controller_tests COMMAND i2c_scan_controller_tests) + +add_executable(sd_card_controller_tests + tests/test_sd_card_controller.cpp + app/apps/app_hardware_sd/sd_card_controller.cpp + app/apps/runtime/async_job_runner.cpp +) +target_include_directories(sd_card_controller_tests PUBLIC app) +target_include_directories(sd_card_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(sd_card_controller_tests PUBLIC cxx_std_17) +add_test(NAME sd_card_controller_tests COMMAND sd_card_controller_tests) + +add_executable(gpio_controller_tests + tests/test_gpio_controller.cpp + app/apps/app_hardware_gpio/gpio_controller.cpp +) +target_include_directories(gpio_controller_tests PUBLIC app) +target_compile_features(gpio_controller_tests PUBLIC cxx_std_17) +add_test(NAME gpio_controller_tests COMMAND gpio_controller_tests) + +add_executable(power_monitor_controller_tests + tests/test_power_monitor_controller.cpp + app/apps/app_hardware_power_monitor/power_monitor_controller.cpp +) +target_include_directories(power_monitor_controller_tests PUBLIC app) +target_include_directories(power_monitor_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(power_monitor_controller_tests PUBLIC cxx_std_17) +add_test(NAME power_monitor_controller_tests COMMAND power_monitor_controller_tests) + +add_executable(switches_controller_tests + tests/test_switches_controller.cpp + app/apps/app_hardware_switches/switches_controller.cpp +) +target_include_directories(switches_controller_tests PUBLIC app) +target_compile_features(switches_controller_tests PUBLIC cxx_std_17) +add_test(NAME switches_controller_tests COMMAND switches_controller_tests) + +add_executable(display_sound_controller_tests + tests/test_display_sound_controller.cpp + app/apps/app_settings_display_sound/display_sound_controller.cpp +) +target_include_directories(display_sound_controller_tests PUBLIC app) +target_compile_features(display_sound_controller_tests PUBLIC cxx_std_17) +add_test(NAME display_sound_controller_tests COMMAND display_sound_controller_tests) + +add_executable(power_settings_controller_tests + tests/test_power_settings_controller.cpp + app/apps/app_settings_power/power_settings_controller.cpp + app/apps/runtime/async_job_runner.cpp +) +target_include_directories(power_settings_controller_tests PUBLIC app) +target_include_directories(power_settings_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(power_settings_controller_tests PUBLIC cxx_std_17) +add_test(NAME power_settings_controller_tests COMMAND power_settings_controller_tests) +set_tests_properties(power_settings_controller_tests PROPERTIES TIMEOUT 10) + +add_executable(rtc_wake_timer_tests tests/test_rtc_wake_timer.cpp app/modules/rtc/rx8130_wake_timer.cpp) +target_include_directories(rtc_wake_timer_tests PUBLIC app) +target_include_directories(rtc_wake_timer_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(rtc_wake_timer_tests PUBLIC cxx_std_17) +add_test(NAME rtc_wake_timer_tests COMMAND rtc_wake_timer_tests) + +add_executable(display_sleep_gate_tests tests/test_display_sleep_gate.cpp) +target_include_directories(display_sleep_gate_tests PUBLIC app) +target_compile_features(display_sleep_gate_tests PUBLIC cxx_std_17) +add_test(NAME display_sleep_gate_tests COMMAND display_sleep_gate_tests) + +add_executable(rtc_controller_tests + tests/test_rtc_controller.cpp + app/apps/app_hardware_rtc/rtc_controller.cpp +) +target_include_directories(rtc_controller_tests PUBLIC app) +target_include_directories(rtc_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(rtc_controller_tests PUBLIC cxx_std_17) +add_test(NAME rtc_controller_tests COMMAND rtc_controller_tests) + +add_executable(imu_controller_tests + tests/test_imu_controller.cpp + app/apps/app_hardware_imu/imu_controller.cpp +) +target_include_directories(imu_controller_tests PUBLIC app) +target_include_directories(imu_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(imu_controller_tests PUBLIC cxx_std_17) +add_test(NAME imu_controller_tests COMMAND imu_controller_tests) + +add_executable(com_monitor_controller_tests + tests/test_com_monitor_controller.cpp + app/apps/app_hardware_com_monitor/com_monitor_controller.cpp +) +target_include_directories(com_monitor_controller_tests PUBLIC app) +target_include_directories(com_monitor_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(com_monitor_controller_tests PUBLIC cxx_std_17) +add_test(NAME com_monitor_controller_tests COMMAND com_monitor_controller_tests) + +add_executable(audio_tool_controller_tests + tests/test_audio_tool_controller.cpp + app/apps/app_media_audio/audio_tool_controller.cpp +) +target_include_directories(audio_tool_controller_tests PUBLIC app) +target_include_directories(audio_tool_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(audio_tool_controller_tests PUBLIC cxx_std_17) +add_test(NAME audio_tool_controller_tests COMMAND audio_tool_controller_tests) + +add_executable(camera_controller_tests + tests/test_camera_controller.cpp + app/apps/app_media_camera/camera_controller.cpp +) +target_include_directories(camera_controller_tests PUBLIC app) +target_include_directories(camera_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(camera_controller_tests PUBLIC cxx_std_17) +add_test(NAME camera_controller_tests COMMAND camera_controller_tests) + +add_executable(network_settings_controller_tests + tests/test_network_settings_controller.cpp + app/apps/app_settings_network/network_controller.cpp +) +target_include_directories(network_settings_controller_tests PUBLIC app) +target_include_directories(network_settings_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(network_settings_controller_tests PUBLIC cxx_std_17) +add_test(NAME network_settings_controller_tests COMMAND network_settings_controller_tests) + +add_executable(updates_controller_tests + tests/test_updates_controller.cpp + app/apps/app_settings_updates/updates_controller.cpp +) +target_include_directories(updates_controller_tests PUBLIC app) +target_include_directories(updates_controller_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(updates_controller_tests PUBLIC cxx_std_17) +add_test(NAME updates_controller_tests COMMAND updates_controller_tests) + +add_executable(pps_layout_tests + tests/test_pps_layout.cpp +) +target_include_directories(pps_layout_tests PUBLIC app) +target_compile_features(pps_layout_tests PUBLIC cxx_std_17) +add_test(NAME pps_layout_tests COMMAND pps_layout_tests) + +add_executable(launcher_layout_tests + tests/test_launcher_layout.cpp +) +target_include_directories(launcher_layout_tests PUBLIC app) +target_compile_features(launcher_layout_tests PUBLIC cxx_std_17) +add_test(NAME launcher_layout_tests COMMAND launcher_layout_tests) + +add_executable(gpio_layout_tests + tests/test_gpio_layout.cpp +) +target_include_directories(gpio_layout_tests PUBLIC app) +target_compile_features(gpio_layout_tests PUBLIC cxx_std_17) +add_test(NAME gpio_layout_tests COMMAND gpio_layout_tests) + +add_executable(i2c_scan_layout_tests + tests/test_i2c_scan_layout.cpp +) +target_include_directories(i2c_scan_layout_tests PUBLIC app) +target_compile_features(i2c_scan_layout_tests PUBLIC cxx_std_17) +add_test(NAME i2c_scan_layout_tests COMMAND i2c_scan_layout_tests) + +add_executable(sd_card_layout_tests + tests/test_sd_card_layout.cpp +) +target_include_directories(sd_card_layout_tests PUBLIC app) +target_compile_features(sd_card_layout_tests PUBLIC cxx_std_17) +add_test(NAME sd_card_layout_tests COMMAND sd_card_layout_tests) + +add_executable(com_monitor_layout_tests + tests/test_com_monitor_layout.cpp +) +target_include_directories(com_monitor_layout_tests PUBLIC app) +target_compile_features(com_monitor_layout_tests PUBLIC cxx_std_17) +add_test(NAME com_monitor_layout_tests COMMAND com_monitor_layout_tests) + +add_executable(audio_tool_layout_tests + tests/test_audio_tool_layout.cpp +) +target_include_directories(audio_tool_layout_tests PUBLIC app) +target_compile_features(audio_tool_layout_tests PUBLIC cxx_std_17) +add_test(NAME audio_tool_layout_tests COMMAND audio_tool_layout_tests) + +add_executable(display_sound_layout_tests + tests/test_display_sound_layout.cpp +) +target_include_directories(display_sound_layout_tests PUBLIC app) +target_compile_features(display_sound_layout_tests PUBLIC cxx_std_17) +add_test(NAME display_sound_layout_tests COMMAND display_sound_layout_tests) + +add_executable(power_settings_layout_tests + tests/test_power_settings_layout.cpp +) +target_include_directories(power_settings_layout_tests PUBLIC app) +target_compile_features(power_settings_layout_tests PUBLIC cxx_std_17) +add_test(NAME power_settings_layout_tests COMMAND power_settings_layout_tests) + +add_executable(rtc_layout_tests + tests/test_rtc_layout.cpp +) +target_include_directories(rtc_layout_tests PUBLIC app) +target_compile_features(rtc_layout_tests PUBLIC cxx_std_17) +add_test(NAME rtc_layout_tests COMMAND rtc_layout_tests) + +add_executable(imu_layout_tests + tests/test_imu_layout.cpp +) +target_include_directories(imu_layout_tests PUBLIC app) +target_compile_features(imu_layout_tests PUBLIC cxx_std_17) +add_test(NAME imu_layout_tests COMMAND imu_layout_tests) + +add_executable(power_monitor_layout_tests + tests/test_power_monitor_layout.cpp +) +target_include_directories(power_monitor_layout_tests PUBLIC app) +target_compile_features(power_monitor_layout_tests PUBLIC cxx_std_17) +add_test(NAME power_monitor_layout_tests COMMAND power_monitor_layout_tests) + +add_executable(about_layout_tests + tests/test_about_layout.cpp +) +target_include_directories(about_layout_tests PUBLIC app) +target_compile_features(about_layout_tests PUBLIC cxx_std_17) +add_test(NAME about_layout_tests COMMAND about_layout_tests) + +add_executable(diagnostics_layout_tests + tests/test_diagnostics_layout.cpp +) +target_include_directories(diagnostics_layout_tests PUBLIC app) +target_compile_features(diagnostics_layout_tests PUBLIC cxx_std_17) +add_test(NAME diagnostics_layout_tests COMMAND diagnostics_layout_tests) + +add_executable(switches_layout_tests + tests/test_switches_layout.cpp +) +target_include_directories(switches_layout_tests PUBLIC app) +target_compile_features(switches_layout_tests PUBLIC cxx_std_17) +add_test(NAME switches_layout_tests COMMAND switches_layout_tests) + +add_executable(camera_layout_tests + tests/test_camera_layout.cpp +) +target_include_directories(camera_layout_tests PUBLIC app) +target_compile_features(camera_layout_tests PUBLIC cxx_std_17) +add_test(NAME camera_layout_tests COMMAND camera_layout_tests) + +add_executable(network_layout_tests + tests/test_network_layout.cpp +) +target_include_directories(network_layout_tests PUBLIC app) +target_compile_features(network_layout_tests PUBLIC cxx_std_17) +add_test(NAME network_layout_tests COMMAND network_layout_tests) + +add_executable(updates_layout_tests + tests/test_updates_layout.cpp +) +target_include_directories(updates_layout_tests PUBLIC app) +target_compile_features(updates_layout_tests PUBLIC cxx_std_17) +add_test(NAME updates_layout_tests COMMAND updates_layout_tests) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 00000000..2ab246a7 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,36 @@ +{ + "version": 6, + "configurePresets": [ + { + "name": "desktop", + "displayName": "Desktop", + "description": "Configure the SDL desktop build with compile_commands.json for clangd.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/vscode-desktop", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + } + ], + "buildPresets": [ + { + "name": "desktop", + "configurePreset": "desktop" + } + ], + "testPresets": [ + { + "name": "desktop", + "configurePreset": "desktop", + "filter": { + "include": { + "name": ".*_tests$" + } + }, + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/README.md b/README.md index fe137e5e..2acc4738 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,95 @@ idf.py build idf.py flash ``` +## App Architecture + +The UI is registry-driven. New features should be added as standalone Mooncake apps and registered in +`app/apps/app_catalog.cpp` with an `AppKey`, category, label, icon, summary priority, and status provider. + +App screens should use the shared shell in `app/apps/ui/`: + +- Create the full-screen root through `app_ui::AppShell`. +- Put all controls under `AppShell::content()`, which is the safe 1232x600 content area. +- Use the shared UI primitives for panels, buttons, metric cards, chips, and empty states. +- Do not create app controls directly on `lv_screen_active()`; use the shared shell. Global toasts are separate overlays. +- Bind single-line text fields with `AppShell::bindTextInput()` for the shared touchscreen editor with save/cancel. +- Keep hardware calls behind app-owned views/controllers and use the HAL rather than embedding launcher-specific logic. + +### Add A New App + +```bash +python app/apps/app_generator.py "Fan Control" +``` + +The generator creates an `AppShell`-based app folder and prints the catalog changes to make. Add the new `AppKey` in +`app/apps/app_registry.h`, include the new app header in `app/apps/app_catalog.cpp`, and add one `apps.add(...)` entry in +`app_catalog::registerDefaultApps()`. + +After adding the catalog entry, re-run CMake so new source files are picked up: + +```bash +cmake --preset desktop +cmake --build --preset desktop --target app_desktop_build +ctest --preset desktop --output-on-failure +``` + +The desktop UI smoke tests exercise network selection, connection, rescanning, disconnect, and manual AP control. +Set `M5TAB5_DESKTOP_WIFI_NO_NETWORKS=1` for empty scan results or `M5TAB5_DESKTOP_WIFI_CONNECT_FAIL=1` for connection +failure. These simulator flags do not affect Tab5 builds. + +Camera capture reports Starting/Running/Stopping/Failed states from the HAL rather than assuming startup succeeded. +`stopCameraCapture()` detaches the LVGL target before returning; app views can then be destroyed while hardware +cleanup finishes. A new capture cannot start until that cleanup completes. Tab5 previews use separate render/display +buffers, preserve the source aspect ratio, and fail visibly if no frame arrives within two seconds. + +Desktop camera smoke tests check animated preview pixels, repeated start/stop, closing during capture and before the +first frame, resource cleanup, and recovery from faults. Set `M5TAB5_DESKTOP_CAMERA_FAIL` to `start`, `frame`, or `stall` +to exercise those failure states. These checks do not replace repeated capture/close and missing-camera tests on Tab5. + +PPS uses a serialized background worker and synchronized UI snapshots. First connection and reconnection verify +module identity and request output-off before controls unlock. Unknown/stale output state is never displayed as off. +Apply requires confirmed output-off and verifies both setpoint registers; Enable remains locked after partial or missed +writes until a successful reapply. Emergency Off takes priority over queued commands and interrupts polling between +register transactions. Back cancels queued Apply/Enable and requests output-off asynchronously for an identified PPS; communication failure +can still leave the physical output live, so this is not a substitute for a hardware disconnect. + +GPIO diagnostics are opt-in: opening the app never configures or drives a pin. Drive Low claims one permitted +expansion pin before its level toggle unlocks. Release, Release All, and Back return only this app's claimed pins +to input mode with no pull resistors. Port A I2C pins are protected, internal peripheral pins are absent from the +inventory, and Tab5 UART console pins are reserved when that console is configured. Legacy pins 0/1 remain +protected until their expansion role is verified for this board. The HAL rejects unknown, +protected, or already claimed pins before changing hardware. + +Failed initialization, writes, and releases show Unknown rather than claiming a known output level. Release remains +available for partially configured pins; Back stays open when cleanup fails so it can be retried. The GPIO grid +scrolls independently of Back and Release All. Desktop UI tests exercise explicit claims, protection, an independently +claimed output, lower-row scrolling, cleanup, and failure recovery. Set `M5TAB5_DESKTOP_GPIO_FAIL` to `init`, `set`, or +`release` for faults. Hardware acceptance still requires checking pin levels/impedance and unaffected I2C operation +on Tab5. A displayed output level is the last successful command, not an electrical measurement. + +Power Settings requires confirmation before screen-off, motion sleep, timed sleep, or shutdown. Cancel and Back +dismiss an unconfirmed request without calling hardware. Preparation runs in a serialized background worker; +reopening the app reflects the current operation rather than starting another one. Power telemetry uses synchronized +cached snapshots so a UI read does not wait for an in-flight I2C transaction. + +Screen Off turns off the backlight only; apps and networking remain active. Tab5 filters its existing touch input +stream to wake the display after a fresh press and consumes that gesture until release, preventing click-through. +There is no second touch-reader task or indefinite LVGL lock. Timed Sleep accepts 1-60 minutes and configures the +RX8130CE relative wake timer with readback verification; it never changes clock/calendar registers. Motion wake +disable is also verified before shutdown preparation. + +Audio has a two-second stop deadline. If the power-off signal returns and the device is still running after 1.5 seconds, +the controller reports failure, disarms wake sources where possible, and restores the display. External PPS/module +outputs can remain live independently; confirmation warns about that and does not silently change those outputs. +Desktop simulates these actions without powering off the host. Set `M5TAB5_DESKTOP_POWER_FAIL=1` for a recoverable +action failure. Tests cover confirmation/cancel, background dispatch, app close/reopen, screen-off/wake, failure recovery, +timer register faults, and control overlap. Physical touch wake, motion wake, timed wake, and failed-shutdown cleanup +still require acceptance on Tab5. + +Desktop PPS tests cover startup, communication loss, reconnection, partial writes, readback mismatch, concurrent edits, +Emergency Off during a blocked poll, and closing during a queued command. `M5TAB5_DESKTOP_PPS_ABSENT=1` exercises +the missing-device UI. Actual off latency, load behaviour, and failed-communication safety still require Tab5/PPS tests. + ## Acknowledgments This project references the following open-source libraries and resources: diff --git a/app/app.cpp b/app/app.cpp index c1eb98bf..ffe673db 100644 --- a/app/app.cpp +++ b/app/app.cpp @@ -6,18 +6,1268 @@ #include "app.h" #include "hal/hal.h" #include "apps/app_installer.h" +#include "apps/runtime/async_job_runner.h" +#include "apps/runtime/performance_telemetry.h" +#include "apps/runtime/update_scheduler.h" +#include "apps/app_pps/pps_controller.h" +#include "apps/app_settings_power/power_settings_controller.h" +#if defined(PLATFORM_BUILD_DESKTOP) +#include "apps/app_registry.h" +#include "apps/ui/ui_layout.h" +#endif #include #include #include #include +#if defined(PLATFORM_BUILD_DESKTOP) +#include +#include +#include +#include +#include +#include +#endif using namespace mooncake; static const std::string _tag = "app"; +static app_runtime::UpdateScheduler _appUpdateScheduler(16); +#if defined(PLATFORM_BUILD_DESKTOP) +static uint32_t _desktopUpdateCount = 0; +static uint32_t _desktopMaxUpdates = 0; +static bool _desktopAssertLauncherUi = false; +static bool _desktopLauncherUiAsserted = false; +static bool _desktopAssertAllAppsUi = false; +static bool _desktopAllAppsUiAsserted = false; + +static uint32_t desktopMaxUpdatesFromEnv() +{ + const char* raw = std::getenv("M5TAB5_DESKTOP_MAX_FRAMES"); + if (raw == nullptr || raw[0] == '\0') { + return 0; + } + + char* end = nullptr; + const auto parsed = std::strtoul(raw, &end, 10); + if (end == raw || parsed == 0 || parsed > UINT32_MAX) { + return 0; + } + return static_cast(parsed); +} + +static bool desktopEnvEnabled(const char* name) +{ + const char* raw = std::getenv(name); + return raw != nullptr && raw[0] != '\0' && raw[0] != '0'; +} + +struct DesktopUiSnapshot { + uint32_t objectCount = 0; + uint32_t fullScreenChildCount = 0; +}; + +static lv_obj_tree_walk_res_t desktopCountUiObjects(lv_obj_t* obj, void* userData) +{ + auto* snapshot = static_cast(userData); + ++snapshot->objectCount; + + const auto displayWidth = GetHAL()->getDisplayWidth(); + const auto displayHeight = GetHAL()->getDisplayHeight(); + if (lv_obj_get_parent(obj) == lv_screen_active() && lv_obj_get_width(obj) >= displayWidth && + lv_obj_get_height(obj) >= displayHeight) { + ++snapshot->fullScreenChildCount; + } + return LV_OBJ_TREE_WALK_NEXT; +} + +static bool desktopLauncherUiLooksReady() +{ + LvglLockGuard lock; + auto* screen = lv_screen_active(); + if (screen == nullptr || lv_obj_get_child_count(screen) == 0) { + return false; + } + lv_obj_update_layout(screen); + + lv_obj_t* topRoot = nullptr; + const auto displayWidth = GetHAL()->getDisplayWidth(); + const auto displayHeight = GetHAL()->getDisplayHeight(); + for (int32_t i = static_cast(lv_obj_get_child_count(screen)) - 1; i >= 0; --i) { + auto* child = lv_obj_get_child(screen, static_cast(i)); + if (child != nullptr && lv_obj_get_width(child) >= displayWidth && lv_obj_get_height(child) >= displayHeight) { + topRoot = child; + break; + } + } + if (topRoot == nullptr || lv_obj_get_child_count(topRoot) < 4) { + return false; + } + + DesktopUiSnapshot snapshot; + lv_obj_tree_walk(screen, desktopCountUiObjects, &snapshot); + return snapshot.fullScreenChildCount > 0 && snapshot.objectCount >= 40; +} + +static DesktopUiSnapshot desktopCaptureUiSnapshot() +{ + LvglLockGuard lock; + DesktopUiSnapshot snapshot; + auto* screen = lv_screen_active(); + if (screen != nullptr) { + lv_obj_update_layout(screen); + lv_obj_tree_walk(screen, desktopCountUiObjects, &snapshot); + } + return snapshot; +} + +static lv_obj_t* desktopTopFullscreenRoot() +{ + auto* screen = lv_screen_active(); + if (screen == nullptr) { + return nullptr; + } + lv_obj_update_layout(screen); + + const auto displayWidth = GetHAL()->getDisplayWidth(); + const auto displayHeight = GetHAL()->getDisplayHeight(); + for (int32_t i = static_cast(lv_obj_get_child_count(screen)) - 1; i >= 0; --i) { + auto* child = lv_obj_get_child(screen, static_cast(i)); + if (child != nullptr && lv_obj_get_width(child) >= displayWidth && lv_obj_get_height(child) >= displayHeight) { + return child; + } + } + return nullptr; +} + +struct DesktopBoundsValidation { + const char* context = ""; + bool valid = true; +}; + +static const char* desktopObjectKind(lv_obj_t* obj) +{ + if (obj == nullptr) { + return "null"; + } + if (lv_obj_check_type(obj, &lv_label_class)) { + return "label"; + } + if (lv_obj_check_type(obj, &lv_button_class)) { + return "button"; + } + if (lv_obj_check_type(obj, &lv_slider_class)) { + return "slider"; + } + if (lv_obj_check_type(obj, &lv_switch_class)) { + return "switch"; + } + if (lv_obj_check_type(obj, &lv_bar_class)) { + return "bar"; + } + if (lv_obj_check_type(obj, &lv_textarea_class)) { + return "textarea"; + } + return "object"; +} + +static void desktopValidateBoundsRecursive(lv_obj_t* parent, DesktopBoundsValidation& validation) +{ + if (parent == nullptr || !validation.valid) { + return; + } + + const bool parentScrollable = lv_obj_has_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + const int32_t parentWidth = lv_obj_get_width(parent); + const int32_t parentHeight = lv_obj_get_height(parent); + const uint32_t childCount = lv_obj_get_child_count(parent); + lv_area_t parentArea; + lv_obj_get_coords(parent, &parentArea); + + for (uint32_t i = 0; i < childCount; ++i) { + auto* child = lv_obj_get_child(parent, i); + if (child == nullptr || lv_obj_has_flag(child, LV_OBJ_FLAG_HIDDEN)) { + continue; + } + + if (!parentScrollable) { + lv_area_t childArea; + lv_obj_get_coords(child, &childArea); + const int32_t x = childArea.x1; + const int32_t y = childArea.y1; + const int32_t w = lv_obj_get_width(child); + const int32_t h = lv_obj_get_height(child); + if (x < parentArea.x1 || y < parentArea.y1 || w <= 0 || h <= 0 || + childArea.x2 > parentArea.x2 || childArea.y2 > parentArea.y2) { + mclog::tagError(_tag, + "desktop UI bounds failed in {}: {} child {} at {},{} {}x{} outside {} parent at {},{} " + "{}x{}", + validation.context, desktopObjectKind(child), static_cast(i), + static_cast(x), static_cast(y), static_cast(w), static_cast(h), + desktopObjectKind(parent), static_cast(parentArea.x1), + static_cast(parentArea.y1), static_cast(parentWidth), + static_cast(parentHeight)); + validation.valid = false; + return; + } + } + + desktopValidateBoundsRecursive(child, validation); + if (!validation.valid) { + return; + } + } +} + +static bool desktopValidateNonScrollableBounds(const char* context) +{ + LvglLockGuard lock; + auto* topRoot = desktopTopFullscreenRoot(); + if (topRoot == nullptr) { + return false; + } + + lv_obj_update_layout(topRoot); + lv_area_t rootArea; + lv_obj_get_coords(topRoot, &rootArea); + if (rootArea.x1 != 0 || rootArea.y1 != 0 || rootArea.x2 != GetHAL()->getDisplayWidth() - 1 || + rootArea.y2 != GetHAL()->getDisplayHeight() - 1) { + return false; + } + DesktopBoundsValidation validation; + validation.context = context; + desktopValidateBoundsRecursive(topRoot, validation); + return validation.valid; +} + +static bool desktopTopFullscreenRootHasContent() +{ + LvglLockGuard lock; + auto* topRoot = desktopTopFullscreenRoot(); + if (topRoot == nullptr || lv_obj_get_child_count(topRoot) != 2) { + return false; + } + + auto* content = lv_obj_get_child(topRoot, 1); + if (content == nullptr || lv_obj_get_child_count(content) == 0) { + return false; + } + + DesktopUiSnapshot rootSnapshot; + lv_obj_tree_walk(topRoot, desktopCountUiObjects, &rootSnapshot); + return rootSnapshot.objectCount >= 8; +} + +static void desktopAssertLauncherUiReady() +{ + if (!_desktopAssertLauncherUi || _desktopLauncherUiAsserted) { + return; + } + + if (!desktopLauncherUiLooksReady() || !desktopValidateNonScrollableBounds("Home")) { + mclog::tagError(_tag, "desktop launcher UI smoke assertion failed"); + std::abort(); + } + + _desktopLauncherUiAsserted = true; + mclog::tagInfo(_tag, "desktop launcher UI smoke assertion passed"); +} + +static void desktopCollectTextFields(lv_obj_t* object, std::vector& fields) +{ + if (lv_obj_has_flag(object, LV_OBJ_FLAG_HIDDEN)) { + return; + } + if (lv_obj_check_type(object, &lv_textarea_class)) { + fields.push_back(object); + return; + } + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) { + desktopCollectTextFields(lv_obj_get_child(object, i), fields); + } +} + +static lv_obj_t* desktopFindKeyboard(lv_obj_t* object) +{ + if (lv_obj_has_flag(object, LV_OBJ_FLAG_HIDDEN)) { + return nullptr; + } + if (lv_obj_check_type(object, &lv_keyboard_class)) { + return object; + } + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) { + if (auto* keyboard = desktopFindKeyboard(lv_obj_get_child(object, i))) { + return keyboard; + } + } + return nullptr; +} + +static bool desktopHasLabel(lv_obj_t* object, const char* text) +{ + if (lv_obj_check_type(object, &lv_label_class) && std::string(lv_label_get_text(object)) == text) { + return true; + } + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) { + if (desktopHasLabel(lv_obj_get_child(object, i), text)) { + return true; + } + } + return false; +} + +static void desktopPressKeyboardKey(lv_obj_t* keyboard, const char* key) +{ + // Exercise LVGL's own keyboard handler, including its nested Ready/Cancel events. + for (uint32_t i = 0; i < 100; ++i) { + const char* text = lv_buttonmatrix_get_button_text(keyboard, i); + if (text == nullptr) { + break; + } + if (std::string(text) == key) { + lv_buttonmatrix_set_selected_button(keyboard, i); + lv_obj_send_event(keyboard, LV_EVENT_VALUE_CHANGED, nullptr); + return; + } + } + mclog::tagError(_tag, "desktop touch editor is missing a required keyboard key"); + std::abort(); +} + +static void desktopSaveUiCapture(const std::string& name) +{ + const char* directory = std::getenv("M5TAB5_DESKTOP_UI_CAPTURE_DIR"); + if (directory == nullptr || directory[0] == '\0') { + return; + } + auto* display = lv_display_get_default(); + // Capture settled visual states rather than the first frame of theme transitions. + lv_timer_handler(); + SDL_Delay(LV_THEME_DEFAULT_TRANSITION_TIME + 80); + lv_timer_handler(); + lv_refr_now(display); + // Read a complete LVGL render, not SDL's undefined back buffer after RenderPresent. + auto* surface = SDL_CreateRGBSurfaceWithFormat(0, GetHAL()->getDisplayWidth(), GetHAL()->getDisplayHeight(), + 32, SDL_PIXELFORMAT_ARGB8888); + // Keep a full-screen capture outside LVGL's intentionally small embedded allocator. + lv_draw_buf_t snapshot{}; + const bool rendered = surface && + lv_draw_buf_init(&snapshot, surface->w, surface->h, LV_COLOR_FORMAT_ARGB8888, surface->pitch, + surface->pixels, surface->pitch * surface->h) == LV_RESULT_OK && + lv_snapshot_take_to_draw_buf(lv_screen_active(), LV_COLOR_FORMAT_ARGB8888, &snapshot) == LV_RESULT_OK; + const auto path = (std::filesystem::path(directory) / (name + ".bmp")).string(); + const bool saved = rendered && SDL_SaveBMP(surface, path.c_str()) == 0; + SDL_FreeSurface(surface); + if (!saved) { + mclog::tagError(_tag, "desktop UI capture failed: {}", SDL_GetError()); + std::abort(); + } +} + +static void desktopAssertTouchTextEditing(const char* context) +{ + LvglLockGuard lock; + std::vector fields; + desktopCollectTextFields(desktopTopFullscreenRoot(), fields); + for (auto* field : fields) { + const std::string original = lv_textarea_get_text(field); + lv_textarea_set_text(field, "seed"); + lv_obj_send_event(field, LV_EVENT_CLICKED, nullptr); + auto* keyboard = desktopFindKeyboard(desktopTopFullscreenRoot()); + if (keyboard == nullptr) { + mclog::tagError(_tag, "desktop touch editor missing in {}", context); + std::abort(); + } + auto* draft = lv_keyboard_get_textarea(keyboard); + auto* root = desktopTopFullscreenRoot(); + lv_obj_update_layout(root); + DesktopBoundsValidation editorBounds; + editorBounds.context = context; + desktopValidateBoundsRecursive(root, editorBounds); + if (draft == nullptr || draft == field || std::string(lv_textarea_get_text(draft)) != "seed" || + lv_textarea_get_password_mode(draft) != lv_textarea_get_password_mode(field) || + !editorBounds.valid) { + mclog::tagError(_tag, "desktop touch editor draft/bounds failed in {}", context); + std::abort(); + } + desktopPressKeyboardKey(keyboard, "a"); + desktopPressKeyboardKey(keyboard, LV_SYMBOL_OK); + if (std::string(lv_textarea_get_text(field)) != "seeda" || + desktopFindKeyboard(desktopTopFullscreenRoot()) != nullptr) { + mclog::tagError(_tag, "desktop touch editor save failed in {}", context); + std::abort(); + } + lv_obj_send_event(field, LV_EVENT_CLICKED, nullptr); + keyboard = desktopFindKeyboard(desktopTopFullscreenRoot()); + if (keyboard == nullptr) { + std::abort(); + } + desktopPressKeyboardKey(keyboard, "a"); + desktopPressKeyboardKey(keyboard, LV_SYMBOL_KEYBOARD); + if (std::string(lv_textarea_get_text(field)) != "seeda" || + desktopFindKeyboard(desktopTopFullscreenRoot()) != nullptr) { + mclog::tagError(_tag, "desktop touch editor cancel failed in {}", context); + std::abort(); + } + lv_obj_send_event(field, LV_EVENT_CLICKED, nullptr); + keyboard = desktopFindKeyboard(desktopTopFullscreenRoot()); + desktopPressKeyboardKey(keyboard, LV_SYMBOL_NEW_LINE); + if (desktopFindKeyboard(desktopTopFullscreenRoot()) != nullptr) { + mclog::tagError(_tag, "desktop touch editor Enter failed in {}", context); + std::abort(); + } + lv_textarea_set_text(field, original.c_str()); + } + // Leave the editor open to test app-close cleanup as well. + if (!fields.empty()) { + lv_obj_send_event(fields.back(), LV_EVENT_CLICKED, nullptr); + desktopSaveUiCapture(std::string(context) + "-editor"); + } +} + +static lv_obj_t* desktopFindButton(lv_obj_t* object, const char* label) +{ + if (lv_obj_has_flag(object, LV_OBJ_FLAG_HIDDEN)) { + return nullptr; + } + if (lv_obj_check_type(object, &lv_button_class) && desktopHasLabel(object, label)) { + return object; + } + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) { + if (auto* button = desktopFindButton(lv_obj_get_child(object, i), label)) { + return button; + } + } + return nullptr; +} + +static void desktopWaitForWifiState(hal::HalBase::WifiConnectionState state, const char* label) +{ + const uint32_t started = GetHAL()->millis(); + while (GetHAL()->millis() - started < 2000) { + GetMooncake().update(); + if (GetHAL()->wifiGetStatus().state == state && !app_runtime::globalAsyncJobRunner().busy()) { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + std::vector fields; + desktopCollectTextFields(root, fields); + if (desktopHasLabel(root, label) && fields.size() == 2 && + !lv_obj_has_state(fields[0], LV_STATE_DISABLED)) { + return; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + mclog::tagError(_tag, "desktop Wi-Fi workflow did not reach {}", label); + std::abort(); +} + +static void desktopClickNetworkAction(const char* label) +{ + LvglLockGuard lock; + auto* button = desktopFindButton(desktopTopFullscreenRoot(), label); + if (button == nullptr || lv_obj_has_state(button, LV_STATE_DISABLED)) { + mclog::tagError(_tag, "desktop network action {} must be reachable", label); + std::abort(); + } + lv_obj_send_event(button, LV_EVENT_CLICKED, nullptr); +} + +static void desktopAssertNetworkSelection() +{ + { + LvglLockGuard lock; + auto* scan = desktopFindButton(desktopTopFullscreenRoot(), "Scan"); + if (scan == nullptr) { + std::abort(); + } + lv_obj_send_event(scan, LV_EVENT_CLICKED, nullptr); + if (!lv_obj_has_state(scan, LV_STATE_DISABLED)) { + mclog::tagError(_tag, "desktop Scan must be disabled while a scan is running"); + std::abort(); + } + } + const uint32_t started = GetHAL()->millis(); + while (GetHAL()->millis() - started < 2000) { + GetMooncake().update(); + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + if (desktopEnvEnabled("M5TAB5_DESKTOP_WIFI_NO_NETWORKS") && desktopHasLabel(root, "No networks found")) { + auto* scan = desktopFindButton(root, "Scan"); + if (lv_obj_has_state(scan, LV_STATE_DISABLED)) { + std::abort(); + } + desktopSaveUiCapture("Network-empty"); + return; + } + if (auto* workshop = desktopFindButton(root, "Workshop")) { + std::vector fields; + desktopCollectTextFields(root, fields); + if (fields.size() != 2) { + std::abort(); + } + lv_textarea_set_text(fields[1], "previous-password"); + lv_obj_send_event(workshop, LV_EVENT_CLICKED, nullptr); + if (std::string(lv_textarea_get_text(fields[0])) != "Workshop" || + lv_textarea_get_text(fields[1])[0] != '\0') { + mclog::tagError(_tag, "desktop network selection must fill SSID and clear the old password"); + std::abort(); + } + desktopSaveUiCapture("Network-selected"); + break; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + { + LvglLockGuard lock; + std::vector fields; + desktopCollectTextFields(desktopTopFullscreenRoot(), fields); + if (fields.size() != 2 || std::string(lv_textarea_get_text(fields[0])) != "Workshop") { + mclog::tagError(_tag, "desktop Wi-Fi scan must render selectable network rows"); + std::abort(); + } + lv_textarea_set_text(fields[1], "simulated-password"); + lv_obj_send_event(desktopFindButton(desktopTopFullscreenRoot(), "Workshop"), LV_EVENT_CLICKED, nullptr); + if (std::string(lv_textarea_get_text(fields[1])) != "simulated-password") { + mclog::tagError(_tag, "desktop selecting the same network must preserve its password"); + std::abort(); + } + } + desktopClickNetworkAction("Connect"); + if (desktopEnvEnabled("M5TAB5_DESKTOP_WIFI_CONNECT_FAIL")) { + desktopWaitForWifiState(hal::HalBase::WifiConnectionState::Failed, "Failed"); + { + LvglLockGuard lock; + if (!desktopHasLabel(desktopTopFullscreenRoot(), "Simulated connection failure")) { + std::abort(); + } + desktopSaveUiCapture("Network-failure"); + } + desktopClickNetworkAction("Forget"); + } else { + desktopWaitForWifiState(hal::HalBase::WifiConnectionState::Connected, "Connected"); + if (GetHAL()->wifiGetStatus().ssid != "Workshop" || GetHAL()->wifiGetStatus().ipAddress.empty()) { + std::abort(); + } + desktopClickNetworkAction("Scan"); + desktopWaitForWifiState(hal::HalBase::WifiConnectionState::Connected, "Connected"); + if (GetHAL()->wifiGetStatus().ssid != "Workshop") { + std::abort(); + } + desktopClickNetworkAction("Disconnect"); + } + desktopWaitForWifiState(hal::HalBase::WifiConnectionState::Idle, "Idle"); + desktopClickNetworkAction("Start AP"); + desktopWaitForWifiState(hal::HalBase::WifiConnectionState::ApRunning, "AP running"); + { + LvglLockGuard lock; + auto* scan = desktopFindButton(desktopTopFullscreenRoot(), "Scan"); + if (!lv_obj_has_state(scan, LV_STATE_DISABLED)) { + std::abort(); + } + } + desktopClickNetworkAction("Stop AP"); + desktopWaitForWifiState(hal::HalBase::WifiConnectionState::Idle, "Idle"); +} + +static size_t desktopTimerCount() +{ + size_t count = 0; + for (auto* timer = lv_timer_get_next(nullptr); timer != nullptr; timer = lv_timer_get_next(timer)) { + ++count; + } + return count; +} + +static lv_obj_t* desktopFindCanvas(lv_obj_t* object) +{ + if (lv_obj_check_type(object, &lv_canvas_class)) { + return object; + } + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) { + if (auto* canvas = desktopFindCanvas(lv_obj_get_child(object, i))) { + return canvas; + } + } + return nullptr; +} + +static void desktopClickCameraAction(const char* text) +{ + LvglLockGuard lock; + auto* button = desktopFindButton(desktopTopFullscreenRoot(), text); + if (button == nullptr || lv_obj_has_state(button, LV_STATE_DISABLED)) { + mclog::tagError(_tag, "desktop camera action unavailable: {}", text); + std::abort(); + } + lv_obj_send_event(button, LV_EVENT_CLICKED, nullptr); +} + +static void desktopWaitForCameraState(hal::HalBase::CameraState state, const char* label) +{ + const uint32_t started = GetHAL()->millis(); + while (GetHAL()->millis() - started < 3000) { + GetMooncake().update(); + { + LvglLockGuard lock; + lv_timer_handler(); + const auto snapshot = GetHAL()->cameraGetStatus(); + if (snapshot.state == state && desktopHasLabel(desktopTopFullscreenRoot(), label) && + (state != hal::HalBase::CameraState::Running || snapshot.frames >= 3)) { + return; + } + } + SDL_Delay(5); + } + mclog::tagError(_tag, "desktop camera state timed out: {}", label); + std::abort(); +} + +static void desktopAssertCameraWorkflow() +{ + using State = hal::HalBase::CameraState; + size_t baselineTimers; + { + LvglLockGuard lock; + baselineTimers = desktopTimerCount(); + auto* stop = desktopFindButton(desktopTopFullscreenRoot(), "Stop"); + if (stop == nullptr || !lv_obj_has_state(stop, LV_STATE_DISABLED)) { + std::abort(); + } + } + const char* fault = std::getenv("M5TAB5_DESKTOP_CAMERA_FAIL"); + const bool expectFailure = fault != nullptr && fault[0] != '\0'; + desktopClickCameraAction("Start"); + if (expectFailure) { + desktopWaitForCameraState(State::Failed, "Camera failed"); + { + LvglLockGuard lock; + const auto status = GetHAL()->cameraGetStatus(); + if (status.lastError.empty() || !desktopHasLabel(desktopTopFullscreenRoot(), status.lastError.c_str()) || + desktopTimerCount() != baselineTimers) { + std::abort(); + } + desktopSaveUiCapture("Camera-failure"); + } + unsetenv("M5TAB5_DESKTOP_CAMERA_FAIL"); + desktopClickCameraAction("Start"); + } + for (int cycle = 0; cycle < 5; ++cycle) { + desktopWaitForCameraState(State::Running, "Camera running"); + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + auto* canvas = desktopFindCanvas(root); + auto* buffer = canvas == nullptr ? nullptr : lv_canvas_get_draw_buf(canvas); + const auto status = GetHAL()->cameraGetStatus(); + auto* start = desktopFindButton(root, "Start"); + if (buffer == nullptr || buffer->header.w != status.width || buffer->header.h != status.height || + status.width != lv_obj_get_width(canvas) || status.height != lv_obj_get_height(canvas) || + !lv_obj_has_state(start, LV_STATE_DISABLED) || desktopTimerCount() != baselineTimers + 1) { + std::abort(); + } + auto* pixels = reinterpret_cast(buffer->data); + bool varied = false; + for (uint32_t x = 1; x < buffer->header.w; ++x) { + varied = varied || pixels[x] != pixels[0]; + } + if (!varied) { + mclog::tagError(_tag, "desktop camera preview is blank"); + std::abort(); + } + if (cycle == 0) { + desktopSaveUiCapture("Camera-running"); + } + } + if (!desktopValidateNonScrollableBounds("Camera running")) { + std::abort(); + } + desktopClickCameraAction("Stop"); + desktopWaitForCameraState(State::Stopped, "Camera stopped"); + { + LvglLockGuard lock; + auto* canvas = desktopFindCanvas(desktopTopFullscreenRoot()); + if (lv_image_get_src(canvas) != nullptr || desktopTimerCount() != baselineTimers) { + std::abort(); + } + } + desktopClickCameraAction("Start"); + } + // Leave capture active so the normal Back/cleanup assertions exercise close during capture. + desktopWaitForCameraState(State::Running, "Camera running"); +} + +static lv_obj_t* desktopFindSwitch(lv_obj_t* object) +{ + if (lv_obj_check_type(object, &lv_switch_class)) { + return object; + } + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) { + if (auto* toggle = desktopFindSwitch(lv_obj_get_child(object, i))) { + return toggle; + } + } + return nullptr; +} + +static void desktopWaitForPpsLabel(const char* label) +{ + const auto started = GetHAL()->millis(); + while (GetHAL()->millis() - started < 3000) { + GetMooncake().update(); + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + if (desktopHasLabel(root, label)) { + // Output-off readback can arrive before the rest of the telemetry or command completion. + const bool readyOff = std::string(label) != "Output Off confirmed" || + (!lv_obj_has_state(desktopFindButton(root, "Apply"), LV_STATE_DISABLED) && + desktopHasLabel(root, "0.000 A")); + const bool readyOn = std::string(label) != "Output On" || + desktopHasLabel(root, "Mode: CV") || desktopHasLabel(root, "Mode: CC"); + if (readyOff && readyOn) { + return; + } + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + mclog::tagError(_tag, "desktop PPS did not reach {}", label); + std::abort(); +} + +static void desktopSetPpsOutput(bool enabled) +{ + LvglLockGuard lock; + auto* toggle = desktopFindSwitch(desktopTopFullscreenRoot()); + if (toggle == nullptr || lv_obj_has_state(toggle, LV_STATE_DISABLED)) { + mclog::tagError(_tag, "desktop PPS output toggle unavailable"); + std::abort(); + } + if (enabled) { + lv_obj_add_state(toggle, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(toggle, LV_STATE_CHECKED); + } + lv_obj_send_event(toggle, LV_EVENT_VALUE_CHANGED, nullptr); +} + +static void desktopAssertPpsWorkflow() +{ + if (desktopEnvEnabled("M5TAB5_DESKTOP_PPS_ABSENT")) { + desktopWaitForPpsLabel("No PPS detected / check 9-36V input power"); + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + if (!desktopHasLabel(root, "Output unknown - may be live") || + !desktopHasLabel(root, "-- A") || + !lv_obj_has_state(desktopFindButton(root, "Apply"), LV_STATE_DISABLED) || + !lv_obj_has_state(desktopFindSwitch(root), LV_STATE_DISABLED) || + lv_obj_has_state(desktopFindButton(root, "Emergency Off"), LV_STATE_DISABLED)) { + std::abort(); + } + desktopSaveUiCapture("PPS-absent"); + return; + } + desktopWaitForPpsLabel("Output Off confirmed"); + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + if (!desktopHasLabel(root, "0.000 A") || !desktopHasLabel(root, "Set V: 5.0 V")) { + mclog::tagError(_tag, "desktop PPS initial telemetry not ready"); + std::abort(); + } + lv_obj_send_event(desktopFindButton(root, "12V"), LV_EVENT_CLICKED, nullptr); + if (!lv_obj_has_state(desktopFindSwitch(root), LV_STATE_DISABLED)) { + mclog::tagError(_tag, "desktop PPS preset must lock Enable until Apply"); + std::abort(); + } + lv_obj_send_event(desktopFindButton(root, "Apply"), LV_EVENT_CLICKED, nullptr); + } + const auto started = GetHAL()->millis(); + bool applied = false; + while (GetHAL()->millis() - started < 3000 && !applied) { + GetMooncake().update(); + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + applied = !lv_obj_has_state(desktopFindSwitch(root), LV_STATE_DISABLED) && + desktopHasLabel(root, "Set V: 12.0 V"); + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + if (!applied) { + mclog::tagError(_tag, "desktop PPS Apply did not finish"); + std::abort(); + } + uint8_t voltageBytes[4]; + float voltage = 0; + if (!GetHAL()->i2cReadRegister(hal::ModuleBus::MBus, 0x35, 0x18, voltageBytes, sizeof(voltageBytes))) { + std::abort(); + } + std::memcpy(&voltage, voltageBytes, sizeof(voltage)); + if (voltage != 12.0f) { + mclog::tagError(_tag, "desktop PPS setpoint readback mismatch"); + std::abort(); + } + desktopSetPpsOutput(true); + desktopWaitForPpsLabel("Output On"); + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + if (!lv_obj_has_state(desktopFindButton(root, "Apply"), LV_STATE_DISABLED)) { + mclog::tagError(_tag, "desktop PPS Apply must be locked while output is on"); + std::abort(); + } + desktopSaveUiCapture("PPS-running"); + lv_obj_send_event(desktopFindButton(root, "Emergency Off"), LV_EVENT_CLICKED, nullptr); + } + desktopWaitForPpsLabel("Output Off confirmed"); + if (!desktopValidateNonScrollableBounds("PPS workflow")) { + std::abort(); + } + // Leave output on so Back exercises asynchronous output-off, not merely object deletion. + desktopSetPpsOutput(true); + desktopWaitForPpsLabel("Output On"); +} + +static void desktopWaitPowerLabel(const char* text) +{ + const auto started = GetHAL()->millis(); + while (GetHAL()->millis() - started < 3000) { + GetMooncake().update(); + { + LvglLockGuard lock; + lv_timer_handler(); + if (desktopHasLabel(desktopTopFullscreenRoot(), text)) return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + mclog::tagError(_tag, "desktop power state did not arrive: {}", text); + std::abort(); +} + +static void desktopPowerClick(const char* text) +{ + LvglLockGuard lock; + auto* button = desktopFindButton(desktopTopFullscreenRoot(), text); + if (!button || lv_obj_has_state(button, LV_STATE_DISABLED)) { + mclog::tagError(_tag, "desktop power action unavailable: {}", text); + std::abort(); + } + const auto started = GetHAL()->millis(); + lv_obj_send_event(button, LV_EVENT_CLICKED, nullptr); + if (GetHAL()->millis() - started > 100) { + mclog::tagError(_tag, "desktop power action blocked UI: {}", text); + std::abort(); + } +} + +static void desktopAssertPowerWorkflow(int appId) +{ + const auto brightness = GetHAL()->getDisplayBrightness(); + for (const char* action : {"Screen Off", "Motion Sleep", "Timed Sleep", "Power Off"}) { + desktopPowerClick(action); + if (!desktopValidateNonScrollableBounds("Power confirmation")) std::abort(); + { + LvglLockGuard lock; + if (!desktopHasLabel(desktopTopFullscreenRoot(), (std::string(action) + "?").c_str()) || + GetHAL()->isDisplayAsleep() || GetHAL()->getDisplayBrightness() != brightness) { + mclog::tagError(_tag, "desktop power action ran before confirmation"); + std::abort(); + } + } + desktopPowerClick("Cancel"); + } + desktopPowerClick("Power Off"); + desktopPowerClick("Back"); + GetMooncake().update(); + if (GetMooncake().getAppCurrentState(appId) != AppAbility::StateRunning) { + mclog::tagError(_tag, "Back must dismiss power confirmation before leaving app"); + std::abort(); + } + desktopPowerClick("Screen Off"); + desktopPowerClick("Confirm"); + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + if (!desktopHasLabel(root, "Preparing") || + !lv_obj_has_state(desktopFindButton(root, "Power Off"), LV_STATE_DISABLED)) std::abort(); + } + if (desktopEnvEnabled("M5TAB5_DESKTOP_POWER_FAIL")) { + desktopWaitPowerLabel("Simulated power action failure"); + { + LvglLockGuard lock; + if (GetHAL()->isDisplayAsleep() || GetHAL()->getDisplayBrightness() != brightness) std::abort(); + desktopSaveUiCapture("Power-error"); + } + unsetenv("M5TAB5_DESKTOP_POWER_FAIL"); + desktopPowerClick("Screen Off"); + desktopPowerClick("Confirm"); + } + desktopWaitPowerLabel("Display off (simulated)"); + if (!GetHAL()->isDisplayAsleep() || GetHAL()->getDisplayBrightness() != 0) std::abort(); + desktopPowerClick("Back"); + GetMooncake().update(); + if (GetMooncake().getAppCurrentState(appId) != AppAbility::StateSleeping || !GetHAL()->isDisplayAsleep()) std::abort(); + if (!app_registry::registry().open(app_registry::AppKey::SettingsPower)) std::abort(); + GetMooncake().update(); + desktopWaitPowerLabel("Display off (simulated)"); + desktopPowerClick("Wake Display"); + desktopWaitPowerLabel("Display awake"); + if (GetHAL()->isDisplayAsleep() || GetHAL()->getDisplayBrightness() != brightness) std::abort(); + for (const char* action : {"Motion Sleep", "Timed Sleep", "Power Off"}) { + desktopPowerClick(action); + { + LvglLockGuard lock; + desktopSaveUiCapture("Power-confirmation"); + } + desktopPowerClick("Confirm"); + desktopWaitPowerLabel(std::strcmp(action, "Motion Sleep") == 0 ? "Motion sleep simulated" : + std::strcmp(action, "Timed Sleep") == 0 ? "Timed sleep simulated: 5 minutes" : "Power off simulated"); + } + if (!desktopValidateNonScrollableBounds("Power workflow")) std::abort(); + { + LvglLockGuard lock; + desktopSaveUiCapture("Power-running"); + } +} + +static void desktopCheckGpio(bool condition, const char* message) +{ + if (!condition) { + mclog::tagError(_tag, "desktop GPIO workflow: {}", message); + std::abort(); + } +} + +static lv_obj_t* desktopGpioCard(lv_obj_t* object, uint8_t pin) +{ + const auto title = "GPIO " + std::to_string(pin); + if (lv_obj_check_type(object, &lv_label_class) && title == lv_label_get_text(object)) { + return lv_obj_get_parent(object); + } + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) { + if (auto* card = desktopGpioCard(lv_obj_get_child(object, i), pin)) return card; + } + return nullptr; +} + +static void desktopSeedIndependentGpio() +{ + const char* fault = std::getenv("M5TAB5_DESKTOP_GPIO_FAIL"); + const std::string saved = fault ? fault : ""; + unsetenv("M5TAB5_DESKTOP_GPIO_FAIL"); + desktopCheckGpio(GetHAL()->gpioInitOutput(50) == hal::GpioClaimResult::Claimed && + GetHAL()->gpioSetLevel(50, true), "cannot seed independent output"); + if (!saved.empty()) setenv("M5TAB5_DESKTOP_GPIO_FAIL", saved.c_str(), 1); +} + +static void desktopAssertGpioWorkflow(int appId) +{ + const char* faultEnv = std::getenv("M5TAB5_DESKTOP_GPIO_FAIL"); + const std::string fault = faultEnv ? faultEnv : ""; + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + for (const auto& info : GetHAL()->gpioGetPins()) { + const auto state = GetHAL()->gpioGetOutputState(info.pin); + desktopCheckGpio(info.pin == 50 ? state.claimed && state.levelKnown && state.high : !state.claimed, + "opening changed a pin"); + } + for (uint8_t pin : {0, 1, 53, 54}) { + auto* card = desktopGpioCard(root, pin); + auto* action = desktopFindButton(card, "Reserved"); + auto* toggle = desktopFindSwitch(card); + desktopCheckGpio(card && action && toggle && lv_obj_has_state(action, LV_STATE_DISABLED) && + lv_obj_has_flag(toggle, LV_OBJ_FLAG_HIDDEN), "protected controls are active"); + desktopCheckGpio(GetHAL()->gpioInitOutput(pin) == hal::GpioClaimResult::Rejected && + !GetHAL()->gpioSetLevel(pin, true) && !GetHAL()->gpioReset(pin), + "HAL permits protected pin output"); + } + auto* card = desktopGpioCard(root, 49); + auto* toggle = desktopFindSwitch(card); + desktopCheckGpio(desktopHasLabel(card, "Unclaimed") && lv_obj_has_state(toggle, LV_STATE_DISABLED), + "unclaimed pin must not have an active toggle"); + lv_obj_send_event(desktopFindButton(card, "Drive Low"), LV_EVENT_CLICKED, nullptr); + if (fault == "init") { + desktopCheckGpio(desktopHasLabel(card, "Unknown") && lv_obj_has_state(toggle, LV_STATE_DISABLED) && + GetHAL()->gpioGetOutputState(49).claimed, "partial initialization not retained"); + desktopSaveUiCapture("GPIO-init-error"); + unsetenv("M5TAB5_DESKTOP_GPIO_FAIL"); + lv_obj_send_event(desktopFindButton(card, "Release"), LV_EVENT_CLICKED, nullptr); + lv_obj_send_event(desktopFindButton(card, "Drive Low"), LV_EVENT_CLICKED, nullptr); + } + desktopCheckGpio(desktopHasLabel(card, "Low") && !lv_obj_has_state(toggle, LV_STATE_DISABLED), + "explicit claim did not establish low output"); + lv_obj_add_state(toggle, LV_STATE_CHECKED); + lv_obj_send_event(toggle, LV_EVENT_VALUE_CHANGED, nullptr); + if (fault == "set") { + desktopCheckGpio(desktopHasLabel(card, "Unknown") && lv_obj_has_state(toggle, LV_STATE_DISABLED), + "failed write must invalidate level"); + desktopSaveUiCapture("GPIO-write-error"); + unsetenv("M5TAB5_DESKTOP_GPIO_FAIL"); + lv_obj_send_event(desktopFindButton(card, "Release"), LV_EVENT_CLICKED, nullptr); + lv_obj_send_event(desktopFindButton(card, "Drive Low"), LV_EVENT_CLICKED, nullptr); + lv_obj_add_state(toggle, LV_STATE_CHECKED); + lv_obj_send_event(toggle, LV_EVENT_VALUE_CHANGED, nullptr); + } + desktopCheckGpio(GetHAL()->gpioGetOutputState(49).high && desktopHasLabel(card, "High"), + "toggle did not establish high output"); + desktopSaveUiCapture("GPIO-active"); + if (fault == "release") { + lv_obj_send_event(desktopFindButton(root, "Back"), LV_EVENT_CLICKED, nullptr); + desktopCheckGpio(desktopHasLabel(card, "Unknown") && + desktopHasLabel(root, "GPIO release failed; a pin may still be driven"), + "release failure hidden on Back"); + desktopSaveUiCapture("GPIO-release-error"); + } + } + if (fault == "release") { + GetMooncake().update(); + desktopCheckGpio(GetMooncake().getAppCurrentState(appId) == AppAbility::StateRunning, + "Back closed with a failed release"); + unsetenv("M5TAB5_DESKTOP_GPIO_FAIL"); + } + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + auto* card = desktopGpioCard(root, 49); + lv_obj_send_event(desktopFindButton(card, "Release"), LV_EVENT_CLICKED, nullptr); + desktopCheckGpio(!GetHAL()->gpioGetOutputState(49).claimed && desktopHasLabel(card, "Unclaimed"), + "release did not relinquish output"); + // A rejected claim of another owner's pin must not be released by Release All or Back. + auto* independent = desktopGpioCard(root, 50); + lv_obj_send_event(desktopFindButton(independent, "Drive Low"), LV_EVENT_CLICKED, nullptr); + desktopCheckGpio(desktopHasLabel(independent, "Unclaimed") && GetHAL()->gpioGetOutputState(50).high, + "rejected claim altered independent output"); + auto* last = desktopGpioCard(root, 51); + auto* grid = lv_obj_get_parent(last); + auto* releaseAll = desktopFindButton(root, "Release All"); + auto* back = desktopFindButton(root, "Back"); + lv_area_t releaseBefore, backBefore; + lv_obj_get_coords(releaseAll, &releaseBefore); + lv_obj_get_coords(back, &backBefore); + lv_obj_scroll_to_view(last, LV_ANIM_OFF); + lv_obj_update_layout(root); + lv_area_t cardArea, gridArea, releaseAfter, backAfter; + lv_obj_get_coords(last, &cardArea); + lv_obj_get_coords(grid, &gridArea); + lv_obj_get_coords(releaseAll, &releaseAfter); + lv_obj_get_coords(back, &backAfter); + desktopCheckGpio(lv_obj_get_scroll_y(grid) > 0 && cardArea.y1 >= gridArea.y1 && cardArea.y2 <= gridArea.y2 && + releaseAfter.y1 == releaseBefore.y1 && backAfter.y1 == backBefore.y1, + "scroll hides controls or moves fixed actions"); + lv_obj_send_event(desktopFindButton(last, "Drive Low"), LV_EVENT_CLICKED, nullptr); + auto* toggle = desktopFindSwitch(last); + lv_obj_add_state(toggle, LV_STATE_CHECKED); + lv_obj_send_event(toggle, LV_EVENT_VALUE_CHANGED, nullptr); + desktopCheckGpio(GetHAL()->gpioGetOutputState(51).high, "last row controls inaccessible"); + desktopSaveUiCapture("GPIO-scrolled"); + lv_obj_send_event(releaseAll, LV_EVENT_CLICKED, nullptr); + desktopCheckGpio(!GetHAL()->gpioGetOutputState(51).claimed && GetHAL()->gpioGetOutputState(50).high && + lv_obj_has_state(releaseAll, LV_STATE_DISABLED), "Release All changed unowned output"); + lv_obj_scroll_to_view(card, LV_ANIM_OFF); + lv_obj_send_event(desktopFindButton(card, "Drive Low"), LV_EVENT_CLICKED, nullptr); + toggle = desktopFindSwitch(card); + lv_obj_add_state(toggle, LV_STATE_CHECKED); + lv_obj_send_event(toggle, LV_EVENT_VALUE_CHANGED, nullptr); + } +} + +static void desktopAssertAllRegisteredAppsUi() +{ + if (!_desktopAssertAllAppsUi || _desktopAllAppsUiAsserted) { + return; + } + if (!desktopLauncherUiLooksReady()) { + return; + } + + auto& registry = app_registry::registry(); + { + LvglLockGuard lock; + desktopSaveUiCapture("Home"); + } + for (const auto& registeredApp : registry.all()) { + if (!registeredApp.visible || registeredApp.appId < 0) { + continue; + } + + const auto beforeOpen = desktopCaptureUiSnapshot(); + size_t timersBeforeOpen; + { + LvglLockGuard lock; + timersBeforeOpen = desktopTimerCount(); + } + if (registeredApp.key == app_registry::AppKey::Gpio) { + desktopSeedIndependentGpio(); + } + if (!registry.open(registeredApp.key)) { + mclog::tagError(_tag, "desktop app UI smoke failed to open {}", registeredApp.label); + std::abort(); + } + GetMooncake().update(); + + const auto openSnapshot = desktopCaptureUiSnapshot(); + if (GetMooncake().getAppCurrentState(registeredApp.appId) != AppAbility::StateRunning || + openSnapshot.fullScreenChildCount < 1 || !desktopTopFullscreenRootHasContent()) { + mclog::tagError(_tag, "desktop app UI smoke assertion failed for {} (fullscreen roots: {}, objects: {})", + registeredApp.label, static_cast(openSnapshot.fullScreenChildCount), + static_cast(openSnapshot.objectCount)); + std::abort(); + } + if (!desktopValidateNonScrollableBounds(registeredApp.label.c_str())) { + mclog::tagError(_tag, "desktop app UI smoke found off-bounds controls in {}", registeredApp.label); + std::abort(); + } + + { + LvglLockGuard lock; + desktopSaveUiCapture(registeredApp.label); + } + if (registeredApp.key == app_registry::AppKey::Network) { + desktopAssertNetworkSelection(); + if (!desktopValidateNonScrollableBounds("Network workflow")) { + std::abort(); + } + } + if (registeredApp.key == app_registry::AppKey::Camera) { + desktopAssertCameraWorkflow(); + } + if (registeredApp.key == app_registry::AppKey::Pps) { + desktopAssertPpsWorkflow(); + } + if (registeredApp.key == app_registry::AppKey::Gpio) { + desktopAssertGpioWorkflow(registeredApp.appId); + } + if (registeredApp.key == app_registry::AppKey::SettingsPower) { + desktopAssertPowerWorkflow(registeredApp.appId); + } + desktopAssertTouchTextEditing(registeredApp.label.c_str()); + + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + auto* header = lv_obj_get_child(root, 0); + auto* back = lv_obj_get_child(header, 0); + lv_area_t headerArea; + lv_obj_get_coords(header, &headerArea); + if (headerArea.y2 >= app_ui::safeContentRect().y || + lv_obj_has_flag(header, LV_OBJ_FLAG_HIDDEN) || !lv_obj_check_type(back, &lv_button_class)) { + mclog::tagError(_tag, "desktop Back control is hidden in {}", registeredApp.label); + std::abort(); + } + lv_obj_send_event(back, LV_EVENT_CLICKED, nullptr); + } + GetMooncake().update(); + + if (GetMooncake().getAppCurrentState(registeredApp.appId) != AppAbility::StateSleeping) { + mclog::tagError(_tag, "desktop app UI smoke failed to close {}", registeredApp.label); + std::abort(); + } + if (!desktopLauncherUiLooksReady()) { + mclog::tagError(_tag, "desktop app UI smoke did not return to launcher after {}", registeredApp.label); + std::abort(); + } + const auto afterClose = desktopCaptureUiSnapshot(); + if (registeredApp.key == app_registry::AppKey::Gpio) { + desktopCheckGpio(!GetHAL()->gpioGetOutputState(49).claimed && GetHAL()->gpioGetOutputState(50).high, + "Back cleanup released unowned output or left app output driven"); + desktopCheckGpio(GetHAL()->gpioReset(50), "independent output cleanup failed"); + } + if (registeredApp.key == app_registry::AppKey::Pps && !desktopEnvEnabled("M5TAB5_DESKTOP_PPS_ABSENT")) { + const auto started = GetHAL()->millis(); + uint8_t enabled = 1; + while (GetHAL()->millis() - started < 2000) { + if (GetHAL()->i2cReadRegister(hal::ModuleBus::MBus, 0x35, 0x04, &enabled, 1) && enabled == 0) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + if (enabled != 0) { + mclog::tagError(_tag, "desktop PPS output remained on after Back"); + std::abort(); + } + if (!registry.open(registeredApp.key)) { + std::abort(); + } + GetMooncake().update(); + desktopWaitForPpsLabel("Output Off confirmed"); + { + LvglLockGuard lock; + auto* root = desktopTopFullscreenRoot(); + if (!desktopHasLabel(root, "Set V: 12.0 V")) { + mclog::tagError(_tag, "desktop PPS reopening must reflect verified hardware setpoints"); + std::abort(); + } + lv_obj_send_event(desktopFindButton(root, "Back"), LV_EVENT_CLICKED, nullptr); + } + GetMooncake().update(); + const auto reopenedClose = desktopCaptureUiSnapshot(); + if (reopenedClose.objectCount != beforeOpen.objectCount || + reopenedClose.fullScreenChildCount != beforeOpen.fullScreenChildCount) { + std::abort(); + } + } + if (afterClose.objectCount != beforeOpen.objectCount || + afterClose.fullScreenChildCount != beforeOpen.fullScreenChildCount) { + mclog::tagError(_tag, "desktop app UI objects leaked after closing {}", registeredApp.label); + std::abort(); + } + if (registeredApp.key == app_registry::AppKey::Camera) { + { + LvglLockGuard lock; + if (GetHAL()->cameraGetStatus().state != hal::HalBase::CameraState::Stopped || + desktopTimerCount() != timersBeforeOpen) { + mclog::tagError(_tag, "desktop camera leaked capture resources on close"); + std::abort(); + } + } + // Also close before the first frame arrives, without pumping LVGL timers. + if (!registry.open(registeredApp.key)) { + std::abort(); + } + GetMooncake().update(); + desktopClickCameraAction("Start"); + { + LvglLockGuard lock; + if (GetHAL()->cameraGetStatus().state != hal::HalBase::CameraState::Starting) { + std::abort(); + } + auto* back = desktopFindButton(desktopTopFullscreenRoot(), "Back"); + lv_obj_send_event(back, LV_EVENT_CLICKED, nullptr); + } + GetMooncake().update(); + const auto closedBeforeFrame = desktopCaptureUiSnapshot(); + { + LvglLockGuard lock; + if (GetHAL()->cameraGetStatus().state != hal::HalBase::CameraState::Stopped || + desktopTimerCount() != timersBeforeOpen || closedBeforeFrame.objectCount != beforeOpen.objectCount || + closedBeforeFrame.fullScreenChildCount != beforeOpen.fullScreenChildCount) { + mclog::tagError(_tag, "desktop camera cleanup failed before first frame"); + std::abort(); + } + } + } + } + + _desktopAllAppsUiAsserted = true; + mclog::tagInfo(_tag, "desktop registered app UI smoke assertion passed"); +} +#endif void app::Init(InitCallback_t callback) { mclog::tagInfo(_tag, "init"); +#if defined(PLATFORM_BUILD_DESKTOP) + _desktopUpdateCount = 0; + _desktopMaxUpdates = desktopMaxUpdatesFromEnv(); + _desktopAssertLauncherUi = desktopEnvEnabled("M5TAB5_DESKTOP_ASSERT_LAUNCHER_UI"); + _desktopLauncherUiAsserted = false; + _desktopAssertAllAppsUi = desktopEnvEnabled("M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI"); + _desktopAllAppsUiAsserted = false; +#endif mclog::tagInfo(_tag, "hal injection"); if (callback.onHalInjection) { @@ -26,28 +1276,51 @@ void app::Init(InitCallback_t callback) GetMooncake(); - on_startup_anim(); on_install_apps(); } void app::Update() { - GetMooncake().update(); + const uint32_t nowMs = GetHAL()->millis(); + auto& telemetry = app_runtime::globalPerformanceTelemetry(); + if (_appUpdateScheduler.shouldRun(nowMs)) { + const uint32_t appStartMs = GetHAL()->millis(); + GetMooncake().update(); + const uint32_t appEndMs = GetHAL()->millis(); + telemetry.recordAppUpdate(appStartMs, appEndMs); + } + telemetry.recordBackgroundJob(app_runtime::globalAsyncJobRunner().busy(), app_runtime::globalAsyncJobRunner().currentJob()); #if defined(__APPLE__) && defined(__MACH__) // 'nextEventMatchingMask should only be called from the Main Thread!' + const uint32_t lvglStartMs = GetHAL()->millis(); auto time_till_next = lv_timer_handler(); + telemetry.recordLvglHandler(lvglStartMs, GetHAL()->millis()); std::this_thread::sleep_for(std::chrono::milliseconds(time_till_next)); #endif + +#if defined(PLATFORM_BUILD_DESKTOP) + ++_desktopUpdateCount; + if (_desktopMaxUpdates == 0 || _desktopUpdateCount + 1 >= _desktopMaxUpdates) { + desktopAssertLauncherUiReady(); + desktopAssertAllRegisteredAppsUi(); + } +#endif } bool app::IsDone() { +#if defined(PLATFORM_BUILD_DESKTOP) + return _desktopMaxUpdates > 0 && _desktopUpdateCount >= _desktopMaxUpdates; +#else return false; +#endif } void app::Destroy() { DestroyMooncake(); + app_pps::PpsController::shutdown(); + app_settings_power::PowerSettingsController::shutdown(); hal::Destroy(); } diff --git a/app/apps/app_catalog.cpp b/app/apps/app_catalog.cpp new file mode 100644 index 00000000..7408970b --- /dev/null +++ b/app/apps/app_catalog.cpp @@ -0,0 +1,298 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_catalog.h" + +#include +#include +#include +#include +#include "app_diagnostics/app_diagnostics.h" +#include "app_hardware_com_monitor/app_com_monitor.h" +#include "app_hardware_gpio/app_gpio.h" +#include "app_hardware_i2c/app_i2c_scan.h" +#include "app_hardware_imu/app_imu.h" +#include "app_hardware_power_monitor/app_power_monitor.h" +#include "app_hardware_rtc/app_rtc.h" +#include "app_hardware_sd/app_sd_card.h" +#include "app_hardware_switches/app_switches.h" +#include "app_media_audio/app_audio_tool.h" +#include "app_media_camera/app_camera.h" +#include "app_pps/app_pps.h" +#include "app_settings/app_settings.h" +#include "app_settings_network/app_network.h" +#include "app_settings_updates/app_updates.h" + +using namespace app_registry; + +void app_catalog::registerDefaultApps(AppRegistry& apps) +{ + if (!apps.all().empty()) { + return; + } + + apps.add({AppKey::Network, + AppCategory::Settings, + "Network", + "Wi-Fi station, AP and antenna", + "Wi-Fi", + LV_SYMBOL_WIFI, + 10, + 115, + false, + true, + [] { return app_settings_network::makeNetworkApp(); }, + [] { + auto status = GetHAL()->wifiGetStatus(); + if (status.state == hal::HalBase::WifiConnectionState::Connected) { + return AppStatus{status.ssid.empty() ? "Connected" : status.ssid, StatusLevel::Ok}; + } + if (status.state == hal::HalBase::WifiConnectionState::ApRunning) { + return AppStatus{"AP mode", StatusLevel::Warning}; + } + if (status.state == hal::HalBase::WifiConnectionState::Failed) { + return AppStatus{"Failed", StatusLevel::Warning}; + } + return AppStatus{"Offline", StatusLevel::Neutral}; + }}); + apps.add({AppKey::Updates, + AppCategory::Settings, + "Updates", + "Manual URL firmware update", + "OTA", + LV_SYMBOL_DOWNLOAD, + 20, + 0, + true, + false, + [] { return app_settings_updates::makeUpdatesApp(); }, + StatusProvider{}}); + apps.add({AppKey::DisplaySound, + AppCategory::Settings, + "Display & Sound", + "Brightness and volume", + "", + LV_SYMBOL_VOLUME_MAX, + 30, + 80, + false, + true, + [] { return app_settings::makeDisplaySoundApp(); }, + [] { + char text[24]; + std::snprintf(text, sizeof(text), "%u%% / %u%%", static_cast(GetHAL()->getDisplayBrightness()), + static_cast(GetHAL()->getSpeakerVolume())); + return AppStatus{text, StatusLevel::Neutral}; + }}); + apps.add({AppKey::SettingsPower, + AppCategory::Settings, + "Power", + "Sleep and shutdown", + "Sleep", + LV_SYMBOL_POWER, + 40, + 0, + true, + false, + [] { return app_settings::makePowerSettingsApp(); }, + StatusProvider{}}); + apps.add({AppKey::About, + AppCategory::Settings, + "About", + "Device and build information", + GetHAL()->type(), + LV_SYMBOL_SETTINGS, + 50, + 0, + false, + false, + [] { return app_settings::makeAboutApp(); }, + StatusProvider{}}); + apps.add({AppKey::Diagnostics, + AppCategory::Settings, + "Diagnostics", + "UI performance and background tasks", + "Perf", + LV_SYMBOL_LIST, + 60, + 0, + false, + false, + [] { return app_diagnostics::makeDiagnosticsApp(); }, + StatusProvider{}}); + apps.add({AppKey::Rtc, + AppCategory::Hardware, + "RTC", + "Clock, date and RTC setting", + "", + LV_SYMBOL_REFRESH, + 10, + 100, + false, + true, + [] { return app_hardware_rtc::makeRtcApp(); }, + [] { + tm now = {}; + GetHAL()->getRtcTime(&now); + char text[16]; + std::snprintf(text, sizeof(text), "%02d:%02d", now.tm_hour, now.tm_min); + return AppStatus{text, StatusLevel::Neutral}; + }}); + apps.add({AppKey::Imu, + AppCategory::Hardware, + "IMU", + "Motion sensor readout", + "Accel", + LV_SYMBOL_GPS, + 40, + 40, + false, + false, + [] { return app_hardware_imu::makeImuApp(); }, + StatusProvider{}}); + apps.add({AppKey::PowerMonitor, + AppCategory::Hardware, + "Power Monitor", + "Battery, bus and CPU telemetry", + "", + LV_SYMBOL_BATTERY_3, + 10, + 120, + false, + true, + [] { return app_hardware_power_monitor::makePowerMonitorApp(); }, + [] { + char text[24]; + const auto data = GetHAL()->getPowerMonitorData(); + std::snprintf(text, sizeof(text), "%.2fV %.2fW", data.busVoltage, data.busPower); + return AppStatus{text, StatusLevel::Ok}; + }}); + apps.add({AppKey::Switches, + AppCategory::Hardware, + "Switches", + "Power rails and interface toggles", + "", + LV_SYMBOL_USB, + 20, + 90, + false, + true, + [] { return app_hardware_switches::makeSwitchesApp(); }, + [] { return AppStatus{GetHAL()->usbCDetect() ? "USB-C" : "Ports", StatusLevel::Neutral}; }}); + apps.add({AppKey::I2cScan, + AppCategory::Hardware, + "I2C Scan", + "Scan internal and external buses", + "Scan", + LV_SYMBOL_LIST, + 40, + 30, + false, + false, + [] { return app_hardware_i2c::makeI2cScanApp(); }, + StatusProvider{}}); + apps.add({AppKey::Gpio, + AppCategory::Hardware, + "GPIO", + "M-Bus and Port A GPIO test", + "Test", + LV_SYMBOL_KEYBOARD, + 50, + 20, + true, + false, + [] { return app_hardware_gpio::makeGpioApp(); }, + StatusProvider{}}); + apps.add({AppKey::Camera, + AppCategory::Media, + "Camera", + "Camera preview test", + "Preview", + LV_SYMBOL_IMAGE, + 10, + 20, + false, + false, + [] { return app_media_camera::makeCameraApp(); }, + StatusProvider{}}); + apps.add({AppKey::DualMic, + AppCategory::Media, + "Dual Mic", + "Record and play dual mic test", + "Record", + LV_SYMBOL_AUDIO, + 20, + 20, + false, + false, + [] { return app_media_audio::makeDualMicApp(); }, + StatusProvider{}}); + apps.add({AppKey::Headphone, + AppCategory::Media, + "Headphone", + "Headphone mic and output test", + "", + LV_SYMBOL_AUDIO, + 30, + 60, + false, + true, + [] { return app_media_audio::makeHeadphoneApp(); }, + [] { + return AppStatus{GetHAL()->headPhoneDetect() ? "Connected" : "Not detected", + GetHAL()->headPhoneDetect() ? StatusLevel::Ok : StatusLevel::Neutral}; + }}); + apps.add({AppKey::Music, + AppCategory::Media, + "Music", + "Speaker playback test", + "Play", + LV_SYMBOL_PLAY, + 40, + 10, + false, + false, + [] { return app_media_audio::makeMusicApp(); }, + StatusProvider{}}); + apps.add({AppKey::SdCard, + AppCategory::Hardware, + "SD Card", + "Browse mounted SD card", + "", + LV_SYMBOL_SD_CARD, + 10, + 50, + false, + true, + [] { return app_hardware_sd::makeSdCardApp(); }, + [] { + return AppStatus{GetHAL()->isSdCardMounted() ? "Mounted" : "No card", + GetHAL()->isSdCardMounted() ? StatusLevel::Ok : StatusLevel::Warning}; + }}); + apps.add({AppKey::ComMonitor, + AppCategory::Hardware, + "COM Monitor", + "RS485 serial monitor", + "UART", + LV_SYMBOL_KEYBOARD, + 20, + 20, + false, + false, + [] { return app_hardware_com_monitor::makeComMonitorApp(); }, + StatusProvider{}}); + apps.add({AppKey::Pps, + AppCategory::Modules, + "PPS", + "Module13.2 programmable supply", + "0x35", + LV_SYMBOL_CHARGE, + 10, + 110, + false, + true, + [] { return std::make_unique(); }, + StatusProvider{}}); +} diff --git a/app/apps/app_catalog.h b/app/apps/app_catalog.h new file mode 100644 index 00000000..0b0401e6 --- /dev/null +++ b/app/apps/app_catalog.h @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include "app_registry.h" + +namespace app_catalog { + +void registerDefaultApps(app_registry::AppRegistry& apps); + +} // namespace app_catalog diff --git a/app/apps/app_diagnostics/app_diagnostics.cpp b/app/apps/app_diagnostics/app_diagnostics.cpp new file mode 100644 index 00000000..664bb795 --- /dev/null +++ b/app/apps/app_diagnostics/app_diagnostics.cpp @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_diagnostics.h" +#include "diagnostics_layout.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace app_diagnostics; + +namespace { + +class AppDiagnostics : public mooncake::AppAbility { +public: + AppDiagnostics() + { + setAppInfo().name = "Diagnostics"; + } + + void onOpen() override + { + _shell = std::make_unique("Diagnostics", [&]() { close(); }); + _shell->init(); + build(); + } + + void onRunning() override + { + const uint32_t now = GetHAL()->millis(); + if (now - _lastUpdateMs < 250) { + return; + } + LvglLockGuard lock; + refresh(); + _lastUpdateMs = now; + } + + void onClose() override + { + _shell.reset(); + } + +private: + std::unique_ptr _shell; + lv_obj_t* _loop = nullptr; + lv_obj_t* _slow = nullptr; + lv_obj_t* _lvgl = nullptr; + lv_obj_t* _task = nullptr; + lv_obj_t* _detail = nullptr; + uint32_t _lastUpdateMs = 0; + + lv_obj_t* content() const + { + return _shell->content(); + } + + void build() + { + LvglLockGuard lock; + const auto layout = makeDiagnosticsLayout(); + auto* description = app_ui::makeLabel(content(), "Runtime counters for UI responsiveness and background jobs.", + layout.description.x, layout.description.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(description, layout.description.w); + _loop = makeMetric(layout, 0, "App Loop"); + _slow = makeMetric(layout, 1, "Slow Updates"); + _lvgl = makeMetric(layout, 2, "LVGL"); + _task = makeMetric(layout, 3, "Background Task"); + auto* panel = app_ui::makePanel(content(), layout.detailPanel.x, layout.detailPanel.y, layout.detailPanel.w, + layout.detailPanel.h); + auto* hint = app_ui::makeLabel( + panel, + "Use this screen when the UI feels sluggish. Slow updates count app-loop samples over 50 ms; the average is a rolling window.", + layout.detailHint.x, layout.detailHint.y, &lv_font_montserrat_18, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(hint, layout.detailHint.w); + lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); + _detail = app_ui::makeLabel(panel, "", layout.detailText.x, layout.detailText.y, &lv_font_montserrat_18, + app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(_detail, layout.detailText.w); + lv_label_set_long_mode(_detail, LV_LABEL_LONG_WRAP); + refresh(); + } + + lv_obj_t* makeMetric(const DiagnosticsLayout& layout, int index, const char* title) + { + const auto rect = diagnosticsMetricRect(layout, index); + return app_ui::makeMetricCard(content(), title, "--", rect.x, rect.y, rect.w, rect.h); + } + + void refresh() + { + auto perf = app_runtime::globalPerformanceTelemetry().snapshot(); + setMetric(_loop, "App Loop", "%ums avg %ums", static_cast(perf.lastAppUpdateMs), + static_cast(perf.avgAppUpdateMs)); + setMetric(_slow, "Slow Updates", "%u", static_cast(perf.slowAppUpdates)); + setMetric(_lvgl, "LVGL", "%ums max %ums", static_cast(perf.lastLvglMs), + static_cast(perf.maxLvglMs)); + setMetric(_task, "Background Task", "%s", + perf.backgroundBusy ? (perf.backgroundJob.empty() ? "Busy" : perf.backgroundJob.c_str()) : "Idle"); + if (_detail != nullptr) { + lv_label_set_text_fmt(_detail, "Updates: %u\nMax app loop: %ums\nCurrent task: %s", + static_cast(perf.totalAppUpdates), static_cast(perf.maxAppUpdateMs), + perf.backgroundBusy ? (perf.backgroundJob.empty() ? "busy" : perf.backgroundJob.c_str()) : "none"); + } + } + + template + void setMetric(lv_obj_t* card, const char* title, const char* format, Args... args) + { + if (card == nullptr) { + return; + } + char value[96]; + std::snprintf(value, sizeof(value), format, args...); + auto* titleLabel = lv_obj_get_child(card, 0); + if (titleLabel != nullptr) { + lv_label_set_text(titleLabel, title); + } + auto* valueLabel = lv_obj_get_child(card, 1); + if (valueLabel != nullptr) { + lv_label_set_text(valueLabel, value); + } + } +}; + +} // namespace + +std::unique_ptr app_diagnostics::makeDiagnosticsApp() +{ + return std::make_unique(); +} diff --git a/app/apps/app_diagnostics/app_diagnostics.h b/app/apps/app_diagnostics/app_diagnostics.h new file mode 100644 index 00000000..16d7770d --- /dev/null +++ b/app/apps/app_diagnostics/app_diagnostics.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_diagnostics { + +std::unique_ptr makeDiagnosticsApp(); + +} // namespace app_diagnostics diff --git a/app/apps/app_diagnostics/diagnostics_layout.h b/app/apps/app_diagnostics/diagnostics_layout.h new file mode 100644 index 00000000..08f41a38 --- /dev/null +++ b/app/apps/app_diagnostics/diagnostics_layout.h @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace app_diagnostics { + +inline constexpr int kDiagnosticsMetricCount = 4; + +struct DiagnosticsLayout { + app_ui::Rect content; + app_ui::Rect description; + int cardW = 280; + int cardH = 132; + int colGap = 24; + int startY = 72; + app_ui::Rect detailPanel; + app_ui::Rect detailHint; + app_ui::Rect detailText; +}; + +inline app_ui::Rect diagnosticsContentRect() +{ + return {0, 0, app_ui::safeContentRect().w, app_ui::safeContentRect().h}; +} + +inline app_ui::Rect diagnosticsMetricRect(const DiagnosticsLayout& layout, int index) +{ + return {index * (layout.cardW + layout.colGap), layout.startY, layout.cardW, layout.cardH}; +} + +inline DiagnosticsLayout makeDiagnosticsLayout() +{ + DiagnosticsLayout layout; + layout.content = diagnosticsContentRect(); + layout.description = {0, 0, layout.content.w, 32}; + const auto firstMetric = diagnosticsMetricRect(layout, 0); + layout.detailPanel = {0, firstMetric.y + firstMetric.h + 32, 1192, 220}; + layout.detailHint = {0, 0, layout.detailPanel.w - 32, 56}; + layout.detailText = {0, 72, layout.detailPanel.w - 32, layout.detailPanel.h - 96}; + return layout; +} + +} // namespace app_diagnostics diff --git a/app/apps/app_generator.py b/app/apps/app_generator.py index a7b8ffdb..f9a173b5 100644 --- a/app/apps/app_generator.py +++ b/app/apps/app_generator.py @@ -1,176 +1,177 @@ -import os as os -from shutil import copytree +#!/usr/bin/env python3 +import argparse import re -import sys -from re import sub from datetime import datetime +from pathlib import Path -script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) + "/" +SCRIPT_DIRECTORY = Path(__file__).resolve().parent -def toSnakeCase(sentence): - # 用空格替换连字符,然后应用正则表达式替换以实现标题大小写转换, - # 并在单词之间添加下划线,最后将结果转换为小写 - return '_'.join( - sub('([A-Z][a-z]+)', r' \1', - sub('([A-Z]+)', r' \1', - sentence.replace('-', ' '))).split()).lower() +def to_snake_case(value: str) -> str: + words = re.sub(r"[^A-Za-z0-9]+", " ", value).strip().split() + return "_".join(word.lower() for word in words) -def toCamelCase(sentence): - # 使用正则表达式替换下划线和连字符为空格, - # 然后将字符串转换为标题格式(每个单词的第一个字母大写),并移除空格 - sentence = sub(r"(_|-)+", " ", sentence).title().replace(" ", "") - # 确保第一个字母是小写 - return ''.join([sentence[0].lower(), sentence[1:]]) +def to_pascal_case(value: str) -> str: + return "".join(word.capitalize() for word in to_snake_case(value).split("_")) -def toCamelCase(sentence): - words = sentence.replace('_', ' ').split() - camel_case = ''.join(word.capitalize() for word in words) - return camel_case +def check_input_valid(value: str) -> bool: + snake = to_snake_case(value) + return bool(snake) and snake != "template" -def checkInputValid(input): - # 检查字符是否有效 - if input == "": - return False - if not re.match("^[a-zA-Z_\s]+$", input): - return False - if input == "template": - return False +def header_template(namespace: str, factory_name: str) -> str: + return f"""/* + * SPDX-FileCopyrightText: {datetime.now().year} M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once - # 检查格式是否有效 - if len(input.replace(' ', '').replace('_', '')) == 0: - return False +#include +#include - return True +namespace {namespace} {{ +std::unique_ptr {factory_name}(); -class PrintColor: - HEADER = '\033[95m' - OKBLUE = '\033[94m' - OKGREEN = '\033[92m' - WARNING = '\033[93m' - FAIL = '\033[91m' - ENDC = '\033[0m' +}} // namespace {namespace} +""" -def getAppName(): - # 读取输入 - while True: - user_input = input("input new app's name: ") - if checkInputValid(user_input): - break - else: - print("bad name, try again") - - print("get input: {}\n".format(user_input)) - - app_name = "App" + toCamelCase(user_input) - print("> new app's name: " + PrintColor.OKGREEN + app_name + PrintColor.ENDC) - return app_name - - -class AppFilePaths: - def __init__(self, folder, source_file, header_file): - self.folder = folder - self.source_file = source_file - self.header_file = header_file - - -def getAppFilePaths(appName): - file_paths = AppFilePaths("", "", "") - - # 获取文件路径 - file_paths.folder = script_directory + toSnakeCase(appName) - - file_paths.source_file = os.path.join( - file_paths.folder, toSnakeCase(appName) + ".cpp") - - file_paths.header_file = os.path.join( - file_paths.folder, toSnakeCase(appName) + ".h") - - print("> new app's file paths:\n - {}\n - {}\n - {}\n".format( - PrintColor.OKGREEN + file_paths.folder, - PrintColor.OKGREEN + file_paths.source_file, - PrintColor.OKGREEN + file_paths.header_file) - + PrintColor.ENDC) - return file_paths - - -def createAppFiles(appName, appFilePaths): - # 创建文件夹 - os.mkdir(appFilePaths.folder) - - # 创建文件 - source_file = open(appFilePaths.source_file, mode='x') - header_file = open(appFilePaths.header_file, mode='x') - - # 获取模板内容 - content_source_file = open( - script_directory + "app_template/app_template.cpp", mode='r').read() - content_header_file = open( - script_directory + "app_template/app_template.h", mode='r').read() - - # 替换类名 - content_source_file = content_source_file.replace( - "AppTemplate", appName) - content_header_file = content_header_file.replace( - "AppTemplate", appName) - - # 替换文件名 - content_source_file = content_source_file.replace( - "app_template", toSnakeCase(appName)) - content_header_file = content_header_file.replace( - "app_template", toSnakeCase(appName)) - - # 替换日期 - content_source_file = content_source_file.replace( - "", datetime.now().strftime("%Y-%m-%d")) - content_header_file = content_header_file.replace( - "", datetime.now().strftime("%Y-%m-%d")) - - # 写入内容 - source_file.write(content_source_file) - header_file.write(content_header_file) - - # 关闭文件 - source_file.close() - header_file.close() - - -def installApp(appName): - print("> install app {} into mooncake".format(appName)) - - # 读取应用安装回调 - app_install_cb_file = open(script_directory + "app_installer.h", mode='r') - content_app_install_cb_file = app_install_cb_file.read() - app_install_cb_file.close() - - # 添加头文件包含 - header_include_tag = "/* Header files locator (Don't remove) */" - content_app_install_cb_file = content_app_install_cb_file.replace( - header_include_tag, "#include \"{}/{}.h\"\n{}".format(toSnakeCase(appName), toSnakeCase(appName), header_include_tag)) - - # 添加应用安装语句 - app_install_tag = "/* Install app locator (Don't remove) */" - content_app_install_cb_file = content_app_install_cb_file.replace( - app_install_tag, "mooncake::GetMooncake().installApp(std::make_unique<{}>());\n {}".format(appName, app_install_tag)) - - # 写入应用安装回调 - app_install_cb_file = open(script_directory + "app_installer.h", mode='w+') - app_install_cb_file.write(content_app_install_cb_file) - app_install_cb_file.close() - - -if __name__ == "__main__": - print("app generator") - - name = getAppName() # 获取应用名称 - file_paths = getAppFilePaths(name) # 获取文件路径 - createAppFiles(name, file_paths) # 创建应用文件 - installApp(name) # 安装应用 - - print("\ndone") # 完成 +def source_template(class_name: str, namespace: str, header_name: str, title: str, factory_name: str) -> str: + return f"""/* + * SPDX-FileCopyrightText: {datetime.now().year} M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "{header_name}" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace {namespace}; + +namespace {{ + +class {class_name} : public mooncake::AppAbility {{ +public: + {class_name}() + {{ + setAppInfo().name = "{title}"; + }} + + void onOpen() override + {{ + _shell = std::make_unique("{title}", [&]() {{ close(); }}); + _shell->init(); + build(); + }} + + void onClose() override + {{ + _shell.reset(); + }} + +private: + std::unique_ptr _shell; + + lv_obj_t* content() const + {{ + return _shell->content(); + }} + + void build() + {{ + LvglLockGuard lock; + auto* title = app_ui::makeLabel(content(), "{title}", 0, 0, &lv_font_montserrat_24, + app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(title, 640); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + + auto* detail = app_ui::makeLabel(content(), "Add controls, controller state, and layout helpers here.", 0, 56, + &lv_font_montserrat_18, app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(detail, 900); + lv_label_set_long_mode(detail, LV_LABEL_LONG_WRAP); + }} +}}; + +}} // namespace + +std::unique_ptr {namespace}::{factory_name}() +{{ + return std::make_unique<{class_name}>(); +}} +""" + + +def catalog_snippet(app_key: str, namespace: str, header_path: str, factory_name: str, title: str, icon: str) -> str: + return f"""Add the app key in app/apps/app_registry.h: + {app_key}, + +Add the include in app/apps/app_catalog.cpp: + #include "{header_path}" + +Add the catalog entry in app_catalog::registerDefaultApps(): + apps.add({{AppKey::{app_key}, + AppCategory::Settings, + "{title}", + "Describe this app", + "", + "{icon}", + 100, + 0, + false, + false, + [] {{ return {namespace}::{factory_name}(); }}, + StatusProvider{{}}}}); +""" + + +def create_app_files(raw_name: str) -> None: + if not check_input_valid(raw_name): + raise ValueError("Use a non-empty app name containing letters, numbers, spaces, underscores, or dashes.") + + feature = to_snake_case(raw_name) + pascal = to_pascal_case(raw_name) + class_name = f"App{pascal}" + namespace = f"app_{feature}" + folder_name = f"app_{feature}" + folder = SCRIPT_DIRECTORY / folder_name + header_name = f"{folder_name}.h" + source_name = f"{folder_name}.cpp" + header_path = folder / header_name + source_path = folder / source_name + factory_name = f"make{pascal}App" + title = raw_name.strip().replace("_", " ").replace("-", " ").title() + icon = "".join(part[0].upper() for part in title.split()[:2]) or "AP" + + folder.mkdir(exist_ok=False) + header_path.write_text(header_template(namespace, factory_name), encoding="utf-8") + source_path.write_text(source_template(class_name, namespace, header_name, title, factory_name), encoding="utf-8") + + print(f"Created {header_path}") + print(f"Created {source_path}") + print() + print(catalog_snippet(pascal, namespace, f"{folder_name}/{header_name}", factory_name, title, icon[:2])) + print("Re-run CMake after adding the catalog entry so new source files are picked up.") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Create a modular Mooncake app shell.") + parser.add_argument("name", nargs="?", help="New app name, for example 'Fan Control'") + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + name = args.name or input("input new app's name: ") + create_app_files(name) diff --git a/app/apps/app_hardware_com_monitor/app_com_monitor.cpp b/app/apps/app_hardware_com_monitor/app_com_monitor.cpp new file mode 100644 index 00000000..a6bbe7a1 --- /dev/null +++ b/app/apps/app_hardware_com_monitor/app_com_monitor.cpp @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_com_monitor.h" +#include "com_monitor_controller.h" +#include "com_monitor_layout.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace app_hardware_com_monitor; + +namespace { + +class AppComMonitor : public mooncake::AppAbility { +public: + AppComMonitor() + : _controller([]() -> hal::HalBase::UartMonitorData_t& { return GetHAL()->uartMonitorData; }, + [](const std::string& msg, bool newLine) { GetHAL()->uartMonitorSend(msg, newLine); }) + { + setAppInfo().name = "COM Monitor"; + } + + void onOpen() override + { + _shell = std::make_unique("COM Monitor", [&]() { close(); }); + _shell->init(); + build(); + } + + void onRunning() override + { + const uint32_t now = GetHAL()->millis(); + if (now - _lastUpdateMs < 250) { + return; + } + LvglLockGuard lock; + refresh(); + _lastUpdateMs = now; + } + + void onClose() override + { + _shell.reset(); + } + +private: + ComMonitorController _controller; + std::unique_ptr _shell; + lv_obj_t* _log = nullptr; + lv_obj_t* _status = nullptr; + std::string _logText; + uint32_t _lastUpdateMs = 0; + + lv_obj_t* content() const + { + return _shell->content(); + } + + void build() + { + LvglLockGuard lock; + const auto layout = makeComMonitorLayout(); + auto* description = app_ui::makeLabel(content(), "RS485 serial monitor.", layout.description.x, + layout.description.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(description, layout.description.w); + lv_label_set_long_mode(description, LV_LABEL_LONG_DOT); + + auto* panel = app_ui::makePanel(content(), layout.logPanel.x, layout.logPanel.y, layout.logPanel.w, + layout.logPanel.h); + auto* title = app_ui::makeLabel(panel, "RX Log", layout.logTitle.x, layout.logTitle.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(title, layout.logTitle.w); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + _log = app_ui::makeLabel(panel, "No data received.", layout.logText.x, layout.logText.y, &lv_font_montserrat_18, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(_log, layout.logText.w); + lv_label_set_long_mode(_log, LV_LABEL_LONG_WRAP); + + auto* send = app_ui::makeButton(content(), "Send Test Message", layout.sendButton.x, layout.sendButton.y, + layout.sendButton.w, layout.sendButton.h, app_ui::ButtonKind::Primary); + lv_obj_add_event_cb(send, AppComMonitor::onSend, LV_EVENT_CLICKED, this); + _status = app_ui::makeLabel(content(), "Polling at 4 Hz while this app is open.", layout.statusText.x, + layout.statusText.y, &lv_font_montserrat_16, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(_status, layout.statusText.w); + lv_label_set_long_mode(_status, LV_LABEL_LONG_DOT); + } + + void refresh() + { + auto sample = _controller.drainRx(); + if (sample.text.empty()) { + return; + } + _logText += sample.text; + if (_logText.size() > 2000) { + _logText.erase(0, _logText.size() - 2000); + } + if (_log != nullptr) { + lv_label_set_text(_log, _logText.c_str()); + } + if (_status != nullptr) { + lv_label_set_text_fmt(_status, "Received %u bytes this poll. Showing last 2000 bytes.", + static_cast(sample.bytesRead)); + } + } + + void sendTestMessage() + { + audio::play_next_tone_progression(); + _controller.sendTestMessage(); + if (_status != nullptr) { + lv_label_set_text(_status, "Sent test message with newline."); + } + } + + static void onSend(lv_event_t* event) + { + auto* app = static_cast(lv_event_get_user_data(event)); + if (app != nullptr) { + app->sendTestMessage(); + } + } +}; + +} // namespace + +std::unique_ptr app_hardware_com_monitor::makeComMonitorApp() +{ + return std::make_unique(); +} diff --git a/app/apps/app_hardware_com_monitor/app_com_monitor.h b/app/apps/app_hardware_com_monitor/app_com_monitor.h new file mode 100644 index 00000000..24b93d6f --- /dev/null +++ b/app/apps/app_hardware_com_monitor/app_com_monitor.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_hardware_com_monitor { + +std::unique_ptr makeComMonitorApp(); + +} // namespace app_hardware_com_monitor diff --git a/app/apps/app_hardware_com_monitor/com_monitor_controller.cpp b/app/apps/app_hardware_com_monitor/com_monitor_controller.cpp new file mode 100644 index 00000000..b8a31b05 --- /dev/null +++ b/app/apps/app_hardware_com_monitor/com_monitor_controller.cpp @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "com_monitor_controller.h" + +#include +#include + +using namespace app_hardware_com_monitor; + +ComMonitorController::ComMonitorController(DataFn dataFn, SendFn sendFn) : _dataFn(std::move(dataFn)), _sendFn(std::move(sendFn)) {} + +ComMonitorSample ComMonitorController::drainRx(size_t maxBytes) +{ + ComMonitorSample sample; + if (!_dataFn || maxBytes == 0) { + return sample; + } + auto& data = _dataFn(); + std::lock_guard lock(data.mutex); + while (!data.rxQueue.empty() && sample.text.size() < maxBytes) { + sample.text.push_back(static_cast(data.rxQueue.front())); + data.rxQueue.pop(); + } + sample.bytesRead = sample.text.size(); + return sample; +} + +void ComMonitorController::sendTestMessage() +{ + if (_sendFn) { + _sendFn("hello from Tab5", true); + } +} diff --git a/app/apps/app_hardware_com_monitor/com_monitor_controller.h b/app/apps/app_hardware_com_monitor/com_monitor_controller.h new file mode 100644 index 00000000..b3f0cc06 --- /dev/null +++ b/app/apps/app_hardware_com_monitor/com_monitor_controller.h @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include +#include + +namespace app_hardware_com_monitor { + +struct ComMonitorSample { + std::string text; + size_t bytesRead = 0; +}; + +class ComMonitorController { +public: + using DataFn = std::function; + using SendFn = std::function; + + ComMonitorController(DataFn dataFn, SendFn sendFn); + + ComMonitorSample drainRx(size_t maxBytes = 800); + void sendTestMessage(); + +private: + DataFn _dataFn; + SendFn _sendFn; +}; + +} // namespace app_hardware_com_monitor diff --git a/app/apps/app_hardware_com_monitor/com_monitor_layout.h b/app/apps/app_hardware_com_monitor/com_monitor_layout.h new file mode 100644 index 00000000..0cc6310b --- /dev/null +++ b/app/apps/app_hardware_com_monitor/com_monitor_layout.h @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace app_hardware_com_monitor { + +struct ComMonitorLayout { + app_ui::Rect content; + app_ui::Rect description; + app_ui::Rect logPanel; + app_ui::Rect logTitle; + app_ui::Rect logText; + int sectionGap = 28; + app_ui::Rect sendButton; + app_ui::Rect statusText; +}; + +inline app_ui::Rect comMonitorContentRect() +{ + return {0, 0, app_ui::safeContentRect().w, app_ui::safeContentRect().h}; +} + +inline ComMonitorLayout makeComMonitorLayout() +{ + ComMonitorLayout layout; + layout.content = comMonitorContentRect(); + layout.description = {0, 0, layout.content.w, 32}; + layout.logPanel = {0, 72, 980, 420}; + layout.logTitle = {0, 0, layout.logPanel.w - 32, 30}; + layout.logText = {0, 48, layout.logPanel.w - 32, layout.logPanel.h - 76}; + layout.sendButton = {0, layout.logPanel.y + layout.logPanel.h + layout.sectionGap, 260, 58}; + layout.statusText = {layout.sendButton.x + layout.sendButton.w + 24, layout.sendButton.y + 12, 800, 32}; + return layout; +} + +} // namespace app_hardware_com_monitor diff --git a/app/apps/app_hardware_gpio/app_gpio.cpp b/app/apps/app_hardware_gpio/app_gpio.cpp new file mode 100644 index 00000000..708d1b42 --- /dev/null +++ b/app/apps/app_hardware_gpio/app_gpio.cpp @@ -0,0 +1,193 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_gpio.h" +#include "gpio_controller.h" +#include "gpio_layout.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace app_hardware_gpio; + +namespace { + +struct PinBinding { + class AppGpio* app = nullptr; + uint8_t pin = 0; + lv_obj_t* state = nullptr; + lv_obj_t* toggle = nullptr; + lv_obj_t* action = nullptr; +}; + +class AppGpio : public mooncake::AppAbility { +public: + AppGpio() : _controller(GetHAL()->gpioGetPins(), + [](uint8_t pin) { return GetHAL()->gpioInitOutput(pin); }, + [](uint8_t pin, bool level) { return GetHAL()->gpioSetLevel(pin, level); }, + [](uint8_t pin) { return GetHAL()->gpioReset(pin); }) + { + setAppInfo().name = "GPIO Test"; + } + + void onOpen() override + { + _shell = std::make_unique("GPIO Test", [&] { + if (_controller.resetAll()) { + close(); + } else { + refresh(); + } + }); + _shell->init(); + build(); + } + + void onClose() override + { + _controller.resetAll(); + _shell.reset(); + _bindings.clear(); + } + +private: + GpioController _controller; + std::unique_ptr _shell; + std::vector> _bindings; + lv_obj_t* _status = nullptr; + lv_obj_t* _error = nullptr; + lv_obj_t* _releaseAll = nullptr; + bool _syncing = false; + + static lv_obj_t* fittedText(lv_obj_t* parent, const char* text, const app_ui::Rect& rect, const lv_font_t* font) + { + return app_ui::makeFittedLabel(parent, text, rect.x, rect.y, rect.w, rect.h, font, + app_ui::theme::color(app_ui::theme::text), LV_LABEL_LONG_DOT, LV_TEXT_ALIGN_LEFT); + } + + static void setText(lv_obj_t* object, const char* text) + { + if (std::strcmp(lv_label_get_text(object), text) != 0) { + lv_label_set_text(object, text); + } + } + + void build() + { + LvglLockGuard lock; + const auto layout = makeGpioLayout(static_cast(_controller.pins().size())); + _status = fittedText(_shell->content(), "No outputs claimed", layout.statusText, &lv_font_montserrat_20); + _error = fittedText(_shell->content(), "", layout.errorText, &lv_font_montserrat_18); + _releaseAll = app_ui::makeButton(_shell->content(), "Release All", layout.releaseAll.x, layout.releaseAll.y, + layout.releaseAll.w, layout.releaseAll.h, app_ui::ButtonKind::Danger); + lv_obj_add_event_cb(_releaseAll, [](lv_event_t* event) { + auto* app = static_cast(lv_event_get_user_data(event)); + app->_controller.resetAll(); + app->refresh(); + }, LV_EVENT_CLICKED, this); + auto* grid = app_ui::makeScrollableContent(_shell->content(), layout.grid.x, layout.grid.y, layout.grid.w, layout.grid.h); + if (_controller.pins().empty()) { + fittedText(grid, "GPIO unavailable", {0, 0, layout.grid.w, 28}, &lv_font_montserrat_20); + } + for (size_t index = 0; index < _controller.pins().size(); ++index) { + const auto& pin = _controller.pins()[index].info; + const auto rect = gpioCardRect(layout, static_cast(index)); + auto* panel = app_ui::makePanel(grid, rect.x, rect.y, rect.w, rect.h); + char title[24]; + std::snprintf(title, sizeof(title), "GPIO %u", static_cast(pin.pin)); + fittedText(panel, title, layout.title, &lv_font_montserrat_18); + fittedText(panel, pin.outputAllowed ? pin.role.c_str() : pin.reservation.c_str(), layout.role, &lv_font_montserrat_14); + auto binding = std::make_unique(); + binding->app = this; + binding->pin = pin.pin; + binding->state = fittedText(panel, "Unclaimed", layout.state, &lv_font_montserrat_16); + binding->toggle = app_ui::makeSwitch(panel, layout.toggle.x, layout.toggle.y, layout.toggle.w, layout.toggle.h); + binding->action = app_ui::makeButton(panel, "Drive Low", layout.action.x, layout.action.y, layout.action.w, + layout.action.h, app_ui::ButtonKind::Secondary); + lv_obj_add_event_cb(binding->action, onAction, LV_EVENT_CLICKED, binding.get()); + lv_obj_add_event_cb(binding->toggle, onToggle, LV_EVENT_VALUE_CHANGED, binding.get()); + _bindings.push_back(std::move(binding)); + } + refresh(); + } + + void refresh() + { + LvglLockGuard lock; + _syncing = true; + for (const auto& binding : _bindings) { + const auto* state = _controller.state(binding->pin); + const auto* text = !state->info.outputAllowed ? "Protected" : !state->claimed ? "Unclaimed" : + !state->levelKnown ? "Unknown" : state->high ? "High" : "Low"; + setText(binding->state, text); + setText(lv_obj_get_child(binding->action, 0), !state->info.outputAllowed ? "Reserved" : + state->claimed ? "Release" : "Drive Low"); + if (!state->info.outputAllowed) { + lv_obj_add_state(binding->action, LV_STATE_DISABLED); + lv_obj_add_flag(binding->toggle, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_remove_flag(binding->toggle, LV_OBJ_FLAG_HIDDEN); + if (state->claimed && state->levelKnown) { + lv_obj_remove_state(binding->toggle, LV_STATE_DISABLED); + } else { + lv_obj_add_state(binding->toggle, LV_STATE_DISABLED); + } + } + if (state->claimed && state->high) { + lv_obj_add_state(binding->toggle, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(binding->toggle, LV_STATE_CHECKED); + } + } + const auto count = _controller.claimedCount(); + char status[80]; + if (count == 0) { + std::snprintf(status, sizeof(status), "No outputs claimed"); + lv_obj_add_state(_releaseAll, LV_STATE_DISABLED); + } else { + std::snprintf(status, sizeof(status), "%u output%s claimed", static_cast(count), count == 1 ? "" : "s"); + lv_obj_remove_state(_releaseAll, LV_STATE_DISABLED); + } + setText(_status, status); + setText(_error, _controller.lastError().empty() ? "Outputs may energize connected hardware" : _controller.lastError().c_str()); + lv_obj_set_style_text_color(_error, app_ui::theme::color(_controller.lastError().empty() ? app_ui::theme::amber : app_ui::theme::red), 0); + _syncing = false; + } + + static void onAction(lv_event_t* event) + { + auto* binding = static_cast(lv_event_get_user_data(event)); + auto& controller = binding->app->_controller; + if (controller.state(binding->pin)->claimed) { + controller.release(binding->pin); + } else { + controller.claim(binding->pin); + } + binding->app->refresh(); + } + + static void onToggle(lv_event_t* event) + { + auto* binding = static_cast(lv_event_get_user_data(event)); + if (!binding->app->_syncing) { + binding->app->_controller.setLevel(binding->pin, lv_obj_has_state(binding->toggle, LV_STATE_CHECKED)); + binding->app->refresh(); + } + } +}; + +} // namespace + +std::unique_ptr app_hardware_gpio::makeGpioApp() +{ + return std::make_unique(); +} diff --git a/app/apps/app_hardware_gpio/app_gpio.h b/app/apps/app_hardware_gpio/app_gpio.h new file mode 100644 index 00000000..d7a837a1 --- /dev/null +++ b/app/apps/app_hardware_gpio/app_gpio.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_hardware_gpio { + +std::unique_ptr makeGpioApp(); + +} // namespace app_hardware_gpio diff --git a/app/apps/app_hardware_gpio/gpio_controller.cpp b/app/apps/app_hardware_gpio/gpio_controller.cpp new file mode 100644 index 00000000..9deabda9 --- /dev/null +++ b/app/apps/app_hardware_gpio/gpio_controller.cpp @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "gpio_controller.h" +#include + +using namespace app_hardware_gpio; + +GpioController::GpioController(std::vector pins, InitFn initFn, SetFn setFn, ResetFn resetFn) + : _initFn(std::move(initFn)), _setFn(std::move(setFn)), _resetFn(std::move(resetFn)) +{ + for (auto& pin : pins) { + if (!find(pin.pin)) { + _pins.push_back({std::move(pin)}); + } + } +} + +GpioController::~GpioController() +{ + resetAll(); +} + +GpioPinState* GpioController::find(uint8_t pin) +{ + auto found = std::find_if(_pins.begin(), _pins.end(), [pin](const auto& state) { return state.info.pin == pin; }); + return found == _pins.end() ? nullptr : &*found; +} + +const GpioPinState* GpioController::state(uint8_t pin) const +{ + const auto found = std::find_if(_pins.begin(), _pins.end(), [pin](const auto& state) { return state.info.pin == pin; }); + return found == _pins.end() ? nullptr : &*found; +} + +bool GpioController::claim(uint8_t pin) +{ + auto* state = find(pin); + if (!state || !state->info.outputAllowed || state->claimed || !_initFn) { + _lastError = "GPIO unavailable or already claimed"; + return false; + } + const auto result = _initFn(pin); + // Rejection changes nothing; partial configuration retains ownership for cleanup/retry. + state->claimed = result != hal::GpioClaimResult::Rejected; + state->levelKnown = result == hal::GpioClaimResult::Claimed; + state->high = false; + _lastError = state->levelKnown ? "" : state->claimed ? "GPIO initialization failed; state unknown" : "GPIO reserved or busy"; + return state->levelKnown; +} + +bool GpioController::setLevel(uint8_t pin, bool level) +{ + auto* state = find(pin); + if (!state || !state->info.outputAllowed || !state->claimed || !state->levelKnown || !_setFn) { + _lastError = "GPIO is not a ready claimed output"; + return false; + } + if (state->high == level) { + return true; + } + if (!_setFn(pin, level)) { + state->levelKnown = false; + _lastError = "GPIO write failed; state unknown"; + return false; + } + state->high = level; + _lastError.clear(); + return true; +} + +bool GpioController::release(uint8_t pin) +{ + auto* state = find(pin); + if (!state || !state->info.outputAllowed || !state->claimed || !_resetFn) { + return false; + } + if (!_resetFn(pin)) { + state->levelKnown = false; + _lastError = "GPIO release failed; pin may still be driven"; + return false; + } + state->claimed = false; + state->levelKnown = false; + state->high = false; + _lastError.clear(); + return true; +} + +bool GpioController::resetAll() +{ + bool ok = true; + for (auto& state : _pins) { + if (state.claimed && !release(state.info.pin)) { + ok = false; + } + } + if (!ok) { + _lastError = "GPIO release failed; a pin may still be driven"; + } + return ok; +} + +bool GpioController::isKnownPin(uint8_t pin) const { return state(pin) != nullptr; } +const std::vector& GpioController::pins() const { return _pins; } +const std::string& GpioController::lastError() const { return _lastError; } +size_t GpioController::claimedCount() const +{ + return std::count_if(_pins.begin(), _pins.end(), [](const auto& state) { return state.claimed; }); +} diff --git a/app/apps/app_hardware_gpio/gpio_controller.h b/app/apps/app_hardware_gpio/gpio_controller.h new file mode 100644 index 00000000..3f119ab8 --- /dev/null +++ b/app/apps/app_hardware_gpio/gpio_controller.h @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include +#include +#include + +namespace app_hardware_gpio { + +struct GpioPinState { + hal::GpioPinInfo info; + bool claimed = false; + bool levelKnown = false; + bool high = false; +}; + +class GpioController { +public: + using InitFn = std::function; + using SetFn = std::function; + using ResetFn = std::function; + + GpioController(std::vector pins, InitFn initFn, SetFn setFn, ResetFn resetFn); + ~GpioController(); + GpioController(const GpioController&) = delete; + GpioController& operator=(const GpioController&) = delete; + + bool claim(uint8_t pin); + bool setLevel(uint8_t pin, bool level); + bool release(uint8_t pin); + bool resetAll(); + bool isKnownPin(uint8_t pin) const; + + const std::vector& pins() const; + const GpioPinState* state(uint8_t pin) const; + size_t claimedCount() const; + const std::string& lastError() const; + +private: + InitFn _initFn; + SetFn _setFn; + ResetFn _resetFn; + std::vector _pins; + std::string _lastError; + GpioPinState* find(uint8_t pin); +}; + +} // namespace app_hardware_gpio diff --git a/app/apps/app_hardware_gpio/gpio_layout.h b/app/apps/app_hardware_gpio/gpio_layout.h new file mode 100644 index 00000000..37ab6cab --- /dev/null +++ b/app/apps/app_hardware_gpio/gpio_layout.h @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once +#include + +namespace app_hardware_gpio { + +struct GpioLayout { + app_ui::Rect content; + app_ui::Rect statusText; + app_ui::Rect errorText; + app_ui::Rect releaseAll; + app_ui::Rect grid; + int cols = 4; + int cardW = 296; + int cardH = 180; + int gap = 16; + int scrollContentHeight = 0; + app_ui::Rect title; + app_ui::Rect role; + app_ui::Rect state; + app_ui::Rect toggle; + app_ui::Rect action; +}; + +inline GpioLayout makeGpioLayout(int pinCount) +{ + GpioLayout layout; + layout.content = {0, 0, app_ui::safeContentRect().w, app_ui::safeContentRect().h}; + layout.statusText = {0, 0, 980, 28}; + layout.errorText = {0, 34, 980, 28}; + layout.releaseAll = {layout.content.w - 200, 0, 200, 48}; + layout.grid = {0, 80, layout.content.w, layout.content.h - 80}; + layout.cardW = (layout.grid.w - (layout.cols - 1) * layout.gap) / layout.cols; + const int innerW = layout.cardW - 36; + layout.title = {0, 0, innerW, 24}; + layout.role = {0, 28, innerW, 20}; + layout.state = {0, 56, innerW - 96, 28}; + layout.toggle = {innerW - 76, 52, 72, 38}; + layout.action = {0, 96, innerW, 44}; + const int rows = pinCount > 0 ? (pinCount + layout.cols - 1) / layout.cols : 0; + layout.scrollContentHeight = rows > 0 ? rows * (layout.cardH + layout.gap) - layout.gap + 24 : 0; + return layout; +} + +inline app_ui::Rect gpioCardRect(const GpioLayout& layout, int index) +{ + return {(index % layout.cols) * (layout.cardW + layout.gap), + (index / layout.cols) * (layout.cardH + layout.gap), layout.cardW, layout.cardH}; +} + +} // namespace app_hardware_gpio diff --git a/app/apps/app_hardware_i2c/app_i2c_scan.cpp b/app/apps/app_hardware_i2c/app_i2c_scan.cpp new file mode 100644 index 00000000..1eabf90b --- /dev/null +++ b/app/apps/app_hardware_i2c/app_i2c_scan.cpp @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_i2c_scan.h" +#include "i2c_scan_controller.h" +#include "i2c_scan_layout.h" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace app_hardware_i2c; + +namespace { + +enum class ActionId { + ScanPortA, + ScanInternal, +}; + +class AppI2cScan; + +struct ActionBinding { + AppI2cScan* app = nullptr; + ActionId id = ActionId::ScanPortA; +}; + +class AppI2cScan : public mooncake::AppAbility { +public: + AppI2cScan() + : _controller([](bool internalBus) { return GetHAL()->i2cScan(internalBus); }) + { + setAppInfo().name = "I2C Scan"; + } + + void onOpen() override + { + _shell = std::make_unique("I2C Scan", [&]() { close(); }); + _shell->init(); + build(); + } + + void onRunning() override + { + const uint32_t now = GetHAL()->millis(); + if (now - _lastUpdateMs < 100) { + return; + } + _controller.refresh(); + LvglLockGuard lock; + refreshView(); + _lastUpdateMs = now; + } + + void onClose() override + { + _actions.clear(); + _shell.reset(); + } + +private: + I2cScanController _controller; + std::unique_ptr _shell; + std::vector> _actions; + lv_obj_t* _status = nullptr; + uint32_t _lastUpdateMs = 0; + + lv_obj_t* content() const + { + return _shell->content(); + } + + void build() + { + LvglLockGuard lock; + const auto layout = makeI2cScanLayout(); + auto* description = app_ui::makeLabel(content(), "Scan M-Bus, Port A, and onboard I2C devices without blocking the UI.", + layout.description.x, layout.description.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(description, layout.description.w); + lv_label_set_long_mode(description, LV_LABEL_LONG_DOT); + addButton(layout, 0, "Scan Port A", app_ui::ButtonKind::Primary, ActionId::ScanPortA); + addButton(layout, 1, "Scan Internal", app_ui::ButtonKind::Secondary, ActionId::ScanInternal); + + auto* panel = app_ui::makePanel(content(), layout.resultsPanel.x, layout.resultsPanel.y, + layout.resultsPanel.w, layout.resultsPanel.h); + auto* title = app_ui::makeLabel(panel, "Results", layout.resultsTitle.x, layout.resultsTitle.y, + &lv_font_montserrat_20, app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(title, layout.resultsTitle.w); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + _status = app_ui::makeLabel(panel, _controller.statusText().c_str(), layout.resultsText.x, + layout.resultsText.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(_status, layout.resultsText.w); + lv_label_set_long_mode(_status, LV_LABEL_LONG_WRAP); + } + + void addButton(const I2cScanLayout& layout, int index, const char* text, app_ui::ButtonKind kind, ActionId id) + { + auto binding = std::make_unique(); + binding->app = this; + binding->id = id; + const auto rect = i2cScanActionRect(layout, index); + auto* button = app_ui::makeButton(content(), text, rect.x, rect.y, rect.w, rect.h, kind); + lv_obj_add_event_cb(button, AppI2cScan::onAction, LV_EVENT_CLICKED, binding.get()); + _actions.push_back(std::move(binding)); + } + + void handle(ActionId id) + { + audio::play_next_tone_progression(); + _controller.startScan(id == ActionId::ScanInternal); + refreshView(); + } + + void refreshView() + { + if (_status != nullptr) { + lv_label_set_text(_status, _controller.statusText().c_str()); + lv_obj_set_style_text_color(_status, + _controller.busy() ? app_ui::theme::color(app_ui::theme::amber) + : app_ui::theme::color(app_ui::theme::text), + 0); + } + } + + static void onAction(lv_event_t* event) + { + auto* binding = static_cast(lv_event_get_user_data(event)); + if (binding != nullptr && binding->app != nullptr) { + binding->app->handle(binding->id); + } + } +}; + +} // namespace + +std::unique_ptr app_hardware_i2c::makeI2cScanApp() +{ + return std::make_unique(); +} diff --git a/app/apps/app_hardware_i2c/app_i2c_scan.h b/app/apps/app_hardware_i2c/app_i2c_scan.h new file mode 100644 index 00000000..100c16e6 --- /dev/null +++ b/app/apps/app_hardware_i2c/app_i2c_scan.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_hardware_i2c { + +std::unique_ptr makeI2cScanApp(); + +} // namespace app_hardware_i2c diff --git a/app/apps/app_hardware_i2c/i2c_scan_controller.cpp b/app/apps/app_hardware_i2c/i2c_scan_controller.cpp new file mode 100644 index 00000000..012b37cf --- /dev/null +++ b/app/apps/app_hardware_i2c/i2c_scan_controller.cpp @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "i2c_scan_controller.h" +#include +#include +#include + +using namespace app_hardware_i2c; + +I2cScanController::I2cScanController(ScanFn scanFn) : _scanFn(std::move(scanFn)) +{ +} + +bool I2cScanController::startScan(bool internalBus) +{ + if (!_scanFn) { + std::lock_guard lock(_mutex); + _statusText = "I2C scan is unavailable."; + _busy = false; + return false; + } + + auto state = std::make_shared(); + auto scanFn = _scanFn; + if (!app_runtime::globalAsyncJobRunner().start(internalBus ? "internal I2C scan" : "port A I2C scan", + [state, scanFn, internalBus]() { + auto devices = scanFn(internalBus); + auto text = I2cScanController::formatResult(internalBus, devices); + std::lock_guard lock(state->mutex); + state->text = std::move(text); + state->complete = true; + })) { + std::lock_guard lock(_mutex); + _statusText = "Another background task is already running. Try again in a moment."; + _busy = false; + return false; + } + + { + std::lock_guard lock(_mutex); + _scanState = state; + _statusText = internalBus ? "Scanning internal I2C bus..." : "Scanning Port A I2C bus..."; + _busy = true; + } + return true; +} + +void I2cScanController::refresh() +{ + std::shared_ptr state; + { + std::lock_guard lock(_mutex); + state = _scanState; + } + if (!state) { + return; + } + + std::string text; + { + std::lock_guard lock(state->mutex); + if (!state->complete) { + return; + } + text = state->text; + } + + std::lock_guard lock(_mutex); + _statusText = std::move(text); + _busy = false; + _scanState.reset(); +} + +bool I2cScanController::busy() const +{ + std::lock_guard lock(_mutex); + return _busy; +} + +std::string I2cScanController::statusText() const +{ + std::lock_guard lock(_mutex); + return _statusText; +} + +std::string I2cScanController::formatResult(bool internalBus, const std::vector& devices) +{ + std::ostringstream out; + out << (internalBus ? "Internal bus" : "Port A") << " devices:"; + if (devices.empty()) { + out << "\nNo devices found."; + return out.str(); + } + + for (auto address : devices) { + char line[16]; + std::snprintf(line, sizeof(line), "\n0x%02X", address); + out << line; + } + return out.str(); +} diff --git a/app/apps/app_hardware_i2c/i2c_scan_controller.h b/app/apps/app_hardware_i2c/i2c_scan_controller.h new file mode 100644 index 00000000..4ac16354 --- /dev/null +++ b/app/apps/app_hardware_i2c/i2c_scan_controller.h @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace app_hardware_i2c { + +class I2cScanController { +public: + using ScanFn = std::function(bool internalBus)>; + + explicit I2cScanController(ScanFn scanFn); + + bool startScan(bool internalBus); + void refresh(); + bool busy() const; + std::string statusText() const; + + static std::string formatResult(bool internalBus, const std::vector& devices); + +private: + struct ScanState { + std::mutex mutex; + bool complete = false; + std::string text; + }; + + ScanFn _scanFn; + std::shared_ptr _scanState; + mutable std::mutex _mutex; + std::string _statusText = "No scan run yet."; + bool _busy = false; +}; + +} // namespace app_hardware_i2c diff --git a/app/apps/app_hardware_i2c/i2c_scan_layout.h b/app/apps/app_hardware_i2c/i2c_scan_layout.h new file mode 100644 index 00000000..e5df5358 --- /dev/null +++ b/app/apps/app_hardware_i2c/i2c_scan_layout.h @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace app_hardware_i2c { + +inline constexpr int kI2cScanActionCount = 2; + +struct I2cScanLayout { + app_ui::Rect content; + app_ui::Rect description; + int actionY = 72; + int actionW = 220; + int actionH = 60; + int actionGap = 24; + int sectionGap = 32; + app_ui::Rect resultsPanel; + app_ui::Rect resultsTitle; + app_ui::Rect resultsText; +}; + +inline app_ui::Rect i2cScanContentRect() +{ + return {0, 0, app_ui::safeContentRect().w, app_ui::safeContentRect().h}; +} + +inline app_ui::Rect i2cScanActionRect(const I2cScanLayout& layout, int index) +{ + return {index * (layout.actionW + layout.actionGap), layout.actionY, layout.actionW, layout.actionH}; +} + +inline I2cScanLayout makeI2cScanLayout() +{ + I2cScanLayout layout; + layout.content = i2cScanContentRect(); + layout.description = {0, 0, layout.content.w, 32}; + const auto firstAction = i2cScanActionRect(layout, 0); + layout.resultsPanel = {0, firstAction.y + firstAction.h + layout.sectionGap, 900, 392}; + layout.resultsTitle = {0, 0, layout.resultsPanel.w - 32, 30}; + layout.resultsText = {0, 48, layout.resultsPanel.w - 32, layout.resultsPanel.h - 76}; + return layout; +} + +} // namespace app_hardware_i2c diff --git a/app/apps/app_hardware_imu/app_imu.cpp b/app/apps/app_hardware_imu/app_imu.cpp new file mode 100644 index 00000000..28892b2d --- /dev/null +++ b/app/apps/app_hardware_imu/app_imu.cpp @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_imu.h" +#include "imu_controller.h" +#include "imu_layout.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace app_hardware_imu; + +namespace { + +class AppImu : public mooncake::AppAbility { +public: + AppImu() + : _controller([] { GetHAL()->updateImuData(); }, + [] { return GetHAL()->imuData; }) + { + setAppInfo().name = "IMU"; + } + + void onOpen() override + { + _shell = std::make_unique("IMU", [&]() { close(); }); + _shell->init(); + build(); + } + + void onRunning() override + { + const uint32_t now = GetHAL()->millis(); + if (now - _lastUpdateMs < 250) { + return; + } + LvglLockGuard lock; + refresh(); + _lastUpdateMs = now; + } + + void onClose() override + { + _shell.reset(); + } + +private: + ImuController _controller; + std::unique_ptr _shell; + lv_obj_t* _accelX = nullptr; + lv_obj_t* _accelY = nullptr; + lv_obj_t* _accelZ = nullptr; + lv_obj_t* _gyroX = nullptr; + lv_obj_t* _gyroY = nullptr; + lv_obj_t* _gyroZ = nullptr; + lv_obj_t* _detail = nullptr; + uint32_t _lastUpdateMs = 0; + + lv_obj_t* content() const + { + return _shell->content(); + } + + void build() + { + LvglLockGuard lock; + const auto layout = makeImuLayout(); + auto* description = app_ui::makeLabel(content(), "Live accelerometer and gyro readout.", layout.description.x, + layout.description.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(description, layout.description.w); + lv_label_set_long_mode(description, LV_LABEL_LONG_DOT); + _accelX = makeMetric(layout, 0, "Accel X"); + _accelY = makeMetric(layout, 1, "Accel Y"); + _accelZ = makeMetric(layout, 2, "Accel Z"); + _gyroX = makeMetric(layout, 3, "Gyro X"); + _gyroY = makeMetric(layout, 4, "Gyro Y"); + _gyroZ = makeMetric(layout, 5, "Gyro Z"); + _detail = app_ui::makeLabel(content(), "Sampling: 4 Hz while this app is open", layout.detailText.x, + layout.detailText.y, &lv_font_montserrat_18, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(_detail, layout.detailText.w); + lv_label_set_long_mode(_detail, LV_LABEL_LONG_DOT); + refresh(); + } + + lv_obj_t* makeMetric(const ImuLayout& layout, int index, const char* title) + { + const auto rect = imuMetricRect(layout, index); + return app_ui::makeMetricCard(content(), title, "--", rect.x, rect.y, rect.w, rect.h); + } + + void refresh() + { + const auto sample = _controller.sample(); + setMetric(_accelX, "Accel X", ImuController::formatAccel(sample.accelX).c_str()); + setMetric(_accelY, "Accel Y", ImuController::formatAccel(sample.accelY).c_str()); + setMetric(_accelZ, "Accel Z", ImuController::formatAccel(sample.accelZ).c_str()); + setMetric(_gyroX, "Gyro X", ImuController::formatGyro(sample.gyroX).c_str()); + setMetric(_gyroY, "Gyro Y", ImuController::formatGyro(sample.gyroY).c_str()); + setMetric(_gyroZ, "Gyro Z", ImuController::formatGyro(sample.gyroZ).c_str()); + } + + void setMetric(lv_obj_t* card, const char* title, const char* value) + { + if (card == nullptr) { + return; + } + auto* titleLabel = lv_obj_get_child(card, 0); + if (titleLabel != nullptr) { + lv_label_set_text(titleLabel, title); + } + auto* valueLabel = lv_obj_get_child(card, 1); + if (valueLabel != nullptr) { + lv_label_set_text(valueLabel, value); + } + } +}; + +} // namespace + +std::unique_ptr app_hardware_imu::makeImuApp() +{ + return std::make_unique(); +} diff --git a/app/apps/app_hardware_imu/app_imu.h b/app/apps/app_hardware_imu/app_imu.h new file mode 100644 index 00000000..5fe64634 --- /dev/null +++ b/app/apps/app_hardware_imu/app_imu.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_hardware_imu { + +std::unique_ptr makeImuApp(); + +} // namespace app_hardware_imu diff --git a/app/apps/app_hardware_imu/imu_controller.cpp b/app/apps/app_hardware_imu/imu_controller.cpp new file mode 100644 index 00000000..7e076d5e --- /dev/null +++ b/app/apps/app_hardware_imu/imu_controller.cpp @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "imu_controller.h" + +#include +#include + +using namespace app_hardware_imu; + +ImuController::ImuController(UpdateFn updateFn, DataFn dataFn) : _updateFn(std::move(updateFn)), _dataFn(std::move(dataFn)) {} + +ImuSample ImuController::sample() const +{ + if (_updateFn) { + _updateFn(); + } + if (_dataFn) { + return _dataFn(); + } + return {}; +} + +std::string ImuController::formatAccel(float value) +{ + char buffer[24]; + std::snprintf(buffer, sizeof(buffer), "%.3f g", value); + return buffer; +} + +std::string ImuController::formatGyro(float value) +{ + char buffer[24]; + std::snprintf(buffer, sizeof(buffer), "%.2f dps", value); + return buffer; +} diff --git a/app/apps/app_hardware_imu/imu_controller.h b/app/apps/app_hardware_imu/imu_controller.h new file mode 100644 index 00000000..e9a361e1 --- /dev/null +++ b/app/apps/app_hardware_imu/imu_controller.h @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include + +namespace app_hardware_imu { + +using ImuSample = hal::HalBase::IMUData_t; + +class ImuController { +public: + using UpdateFn = std::function; + using DataFn = std::function; + + ImuController(UpdateFn updateFn, DataFn dataFn); + + ImuSample sample() const; + + static std::string formatAccel(float value); + static std::string formatGyro(float value); + +private: + UpdateFn _updateFn; + DataFn _dataFn; +}; + +} // namespace app_hardware_imu diff --git a/app/apps/app_hardware_imu/imu_layout.h b/app/apps/app_hardware_imu/imu_layout.h new file mode 100644 index 00000000..c2dfb6dc --- /dev/null +++ b/app/apps/app_hardware_imu/imu_layout.h @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace app_hardware_imu { + +inline constexpr int kImuMetricCount = 6; + +struct ImuLayout { + app_ui::Rect content; + app_ui::Rect description; + int cols = 3; + int cardW = 280; + int cardH = 132; + int colGap = 24; + int rowGap = 24; + int startY = 72; + app_ui::Rect detailText; +}; + +inline app_ui::Rect imuContentRect() +{ + return {0, 0, app_ui::safeContentRect().w, app_ui::safeContentRect().h}; +} + +inline app_ui::Rect imuMetricRect(const ImuLayout& layout, int index) +{ + const int col = index % layout.cols; + const int row = index / layout.cols; + return {col * (layout.cardW + layout.colGap), layout.startY + row * (layout.cardH + layout.rowGap), + layout.cardW, layout.cardH}; +} + +inline ImuLayout makeImuLayout() +{ + ImuLayout layout; + layout.content = imuContentRect(); + layout.description = {0, 0, layout.content.w, 32}; + const auto lastMetric = imuMetricRect(layout, kImuMetricCount - 1); + layout.detailText = {0, lastMetric.y + lastMetric.h + 32, 900, 32}; + return layout; +} + +} // namespace app_hardware_imu diff --git a/app/apps/app_hardware_power_monitor/app_power_monitor.cpp b/app/apps/app_hardware_power_monitor/app_power_monitor.cpp new file mode 100644 index 00000000..f1bf1c7e --- /dev/null +++ b/app/apps/app_hardware_power_monitor/app_power_monitor.cpp @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_power_monitor.h" +#include "power_monitor_controller.h" +#include "power_monitor_layout.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace app_hardware_power_monitor; + +namespace { + +class AppPowerMonitor : public mooncake::AppAbility { +public: + AppPowerMonitor() + : _controller([] { GetHAL()->updatePowerMonitorData(); }, + [] { return GetHAL()->getPowerMonitorData(); }, + [] { return GetHAL()->getCpuTemp(); }) + { + setAppInfo().name = "Power Monitor"; + } + + void onOpen() override + { + _shell = std::make_unique("Power Monitor", [&]() { close(); }); + _shell->init(); + build(); + } + + void onRunning() override + { + const uint32_t now = GetHAL()->millis(); + if (now - _lastUpdateMs < 250) { + return; + } + LvglLockGuard lock; + refresh(); + _lastUpdateMs = now; + } + + void onClose() override + { + _shell.reset(); + } + +private: + PowerMonitorController _controller; + std::unique_ptr _shell; + lv_obj_t* _voltage = nullptr; + lv_obj_t* _current = nullptr; + lv_obj_t* _power = nullptr; + lv_obj_t* _cpu = nullptr; + lv_obj_t* _detail = nullptr; + uint32_t _lastUpdateMs = 0; + + lv_obj_t* content() const + { + return _shell->content(); + } + + void build() + { + LvglLockGuard lock; + const auto layout = makePowerMonitorLayout(); + auto* description = + app_ui::makeLabel(content(), "Live input, rail and CPU telemetry from the power monitor.", + layout.description.x, layout.description.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(description, layout.description.w); + lv_label_set_long_mode(description, LV_LABEL_LONG_DOT); + _voltage = makeMetric(layout, 0, "Bus Voltage"); + _current = makeMetric(layout, 1, "Current"); + _power = makeMetric(layout, 2, "Power"); + _cpu = makeMetric(layout, 3, "CPU Temp"); + + auto* panel = app_ui::makePanel(content(), layout.readoutPanel.x, layout.readoutPanel.y, + layout.readoutPanel.w, layout.readoutPanel.h); + auto* title = app_ui::makeLabel(panel, "Readout", layout.readoutTitle.x, layout.readoutTitle.y, + &lv_font_montserrat_20, app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(title, layout.readoutTitle.w); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + _detail = app_ui::makeLabel(panel, "", layout.readoutDetail.x, layout.readoutDetail.y, + &lv_font_montserrat_18, app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(_detail, layout.readoutDetail.w); + lv_label_set_long_mode(_detail, LV_LABEL_LONG_WRAP); + refresh(); + } + + lv_obj_t* makeMetric(const PowerMonitorLayout& layout, int index, const char* title) + { + const auto rect = powerMonitorMetricRect(layout, index); + return app_ui::makeMetricCard(content(), title, "--", rect.x, rect.y, rect.w, rect.h); + } + + void refresh() + { + const auto sample = _controller.sample(); + setMetric(_voltage, "Bus Voltage", PowerMonitorController::formatVoltage(sample).c_str()); + setMetric(_current, "Current", PowerMonitorController::formatCurrent(sample).c_str()); + setMetric(_power, "Power", PowerMonitorController::formatPower(sample).c_str()); + setMetric(_cpu, "CPU Temp", PowerMonitorController::formatCpuTemp(sample).c_str()); + if (_detail != nullptr) { + char detail[96]; + std::snprintf(detail, sizeof(detail), "Shunt: %.3f V\nSampling: 4 Hz while this app is open", sample.shuntVoltage); + lv_label_set_text(_detail, detail); + } + } + + void setMetric(lv_obj_t* card, const char* title, const char* value) + { + if (card == nullptr) { + return; + } + auto* titleLabel = lv_obj_get_child(card, 0); + if (titleLabel != nullptr) { + lv_label_set_text(titleLabel, title); + } + auto* valueLabel = lv_obj_get_child(card, 1); + if (valueLabel != nullptr) { + lv_label_set_text(valueLabel, value); + } + } +}; + +} // namespace + +std::unique_ptr app_hardware_power_monitor::makePowerMonitorApp() +{ + return std::make_unique(); +} diff --git a/app/apps/app_hardware_power_monitor/app_power_monitor.h b/app/apps/app_hardware_power_monitor/app_power_monitor.h new file mode 100644 index 00000000..101a9853 --- /dev/null +++ b/app/apps/app_hardware_power_monitor/app_power_monitor.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_hardware_power_monitor { + +std::unique_ptr makePowerMonitorApp(); + +} // namespace app_hardware_power_monitor diff --git a/app/apps/app_hardware_power_monitor/power_monitor_controller.cpp b/app/apps/app_hardware_power_monitor/power_monitor_controller.cpp new file mode 100644 index 00000000..348e4aec --- /dev/null +++ b/app/apps/app_hardware_power_monitor/power_monitor_controller.cpp @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "power_monitor_controller.h" + +#include +#include + +using namespace app_hardware_power_monitor; + +namespace { + +std::string format(const char* fmt, float value) +{ + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), fmt, value); + return buffer; +} + +} // namespace + +PowerMonitorController::PowerMonitorController(UpdateFn updateFn, DataFn dataFn, CpuTempFn cpuTempFn) + : _updateFn(std::move(updateFn)), _dataFn(std::move(dataFn)), _cpuTempFn(std::move(cpuTempFn)) +{ +} + +PowerSample PowerMonitorController::sample() +{ + _updateFn(); + const auto data = _dataFn(); + _cached.busVoltage = data.busVoltage; + _cached.current = data.shuntCurrent; + _cached.shuntVoltage = data.shuntVoltage; + _cached.outputPower = std::fabs(data.busPower) > 0.001f ? data.busPower : data.busVoltage * std::fabs(data.shuntCurrent); + _cached.cpuTempC = _cpuTempFn(); + return _cached; +} + +PowerSample PowerMonitorController::cached() const +{ + return _cached; +} + +std::string PowerMonitorController::formatVoltage(const PowerSample& sample) +{ + return format("%.2f V", sample.busVoltage); +} + +std::string PowerMonitorController::formatCurrent(const PowerSample& sample) +{ + return format("%.3f A", sample.current); +} + +std::string PowerMonitorController::formatPower(const PowerSample& sample) +{ + return format("%.2f W", sample.outputPower); +} + +std::string PowerMonitorController::formatCpuTemp(const PowerSample& sample) +{ + char buffer[24]; + std::snprintf(buffer, sizeof(buffer), "%d C", sample.cpuTempC); + return buffer; +} diff --git a/app/apps/app_hardware_power_monitor/power_monitor_controller.h b/app/apps/app_hardware_power_monitor/power_monitor_controller.h new file mode 100644 index 00000000..eeee49cf --- /dev/null +++ b/app/apps/app_hardware_power_monitor/power_monitor_controller.h @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include + +namespace app_hardware_power_monitor { + +struct PowerSample { + float busVoltage = 0.0f; + float current = 0.0f; + float outputPower = 0.0f; + float shuntVoltage = 0.0f; + int cpuTempC = 0; +}; + +class PowerMonitorController { +public: + using UpdateFn = std::function; + using DataFn = std::function; + using CpuTempFn = std::function; + + PowerMonitorController(UpdateFn updateFn, DataFn dataFn, CpuTempFn cpuTempFn); + + PowerSample sample(); + PowerSample cached() const; + + static std::string formatVoltage(const PowerSample& sample); + static std::string formatCurrent(const PowerSample& sample); + static std::string formatPower(const PowerSample& sample); + static std::string formatCpuTemp(const PowerSample& sample); + +private: + UpdateFn _updateFn; + DataFn _dataFn; + CpuTempFn _cpuTempFn; + PowerSample _cached; +}; + +} // namespace app_hardware_power_monitor diff --git a/app/apps/app_hardware_power_monitor/power_monitor_layout.h b/app/apps/app_hardware_power_monitor/power_monitor_layout.h new file mode 100644 index 00000000..cd428c94 --- /dev/null +++ b/app/apps/app_hardware_power_monitor/power_monitor_layout.h @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace app_hardware_power_monitor { + +inline constexpr int kPowerMonitorMetricCount = 4; + +struct PowerMonitorLayout { + app_ui::Rect content; + app_ui::Rect description; + int cardW = 280; + int cardH = 132; + int colGap = 24; + int startY = 72; + app_ui::Rect readoutPanel; + app_ui::Rect readoutTitle; + app_ui::Rect readoutDetail; +}; + +inline app_ui::Rect powerMonitorContentRect() +{ + return {0, 0, app_ui::safeContentRect().w, app_ui::safeContentRect().h}; +} + +inline app_ui::Rect powerMonitorMetricRect(const PowerMonitorLayout& layout, int index) +{ + return {index * (layout.cardW + layout.colGap), layout.startY, layout.cardW, layout.cardH}; +} + +inline PowerMonitorLayout makePowerMonitorLayout() +{ + PowerMonitorLayout layout; + layout.content = powerMonitorContentRect(); + layout.description = {0, 0, layout.content.w, 32}; + const auto firstMetric = powerMonitorMetricRect(layout, 0); + layout.readoutPanel = {0, firstMetric.y + firstMetric.h + 32, 1192, 180}; + layout.readoutTitle = {0, 0, layout.readoutPanel.w - 32, 30}; + layout.readoutDetail = {0, 48, layout.readoutPanel.w - 32, layout.readoutPanel.h - 76}; + return layout; +} + +} // namespace app_hardware_power_monitor diff --git a/app/apps/app_hardware_rtc/app_rtc.cpp b/app/apps/app_hardware_rtc/app_rtc.cpp new file mode 100644 index 00000000..04c79cde --- /dev/null +++ b/app/apps/app_hardware_rtc/app_rtc.cpp @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_rtc.h" +#include "rtc_controller.h" +#include "rtc_layout.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace app_hardware_rtc; + +namespace { + +class AppRtc : public mooncake::AppAbility { +public: + AppRtc() : _controller([](tm* time) { GetHAL()->getRtcTime(time); }) + { + setAppInfo().name = "RTC"; + } + + void onOpen() override + { + _shell = std::make_unique("RTC", [&]() { close(); }); + _shell->init(); + build(); + } + + void onRunning() override + { + const uint32_t now = GetHAL()->millis(); + if (now - _lastUpdateMs < 250) { + return; + } + LvglLockGuard lock; + refresh(); + _lastUpdateMs = now; + } + + void onClose() override + { + _shell.reset(); + } + +private: + RtcController _controller; + std::unique_ptr _shell; + lv_obj_t* _time = nullptr; + lv_obj_t* _date = nullptr; + lv_obj_t* _detail = nullptr; + uint32_t _lastUpdateMs = 0; + + lv_obj_t* content() const + { + return _shell->content(); + } + + void build() + { + LvglLockGuard lock; + const auto layout = makeRtcLayout(); + auto* description = app_ui::makeLabel(content(), "Live hardware clock readout.", layout.description.x, + layout.description.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(description, layout.description.w); + lv_label_set_long_mode(description, LV_LABEL_LONG_DOT); + _time = app_ui::makeMetricCard(content(), "Current Time", "--:--:--", layout.timeCard.x, layout.timeCard.y, + layout.timeCard.w, layout.timeCard.h); + _date = app_ui::makeMetricCard(content(), "Date", "---- -- --", layout.dateCard.x, layout.dateCard.y, + layout.dateCard.w, layout.dateCard.h); + + auto* panel = app_ui::makePanel(content(), layout.sourcePanel.x, layout.sourcePanel.y, layout.sourcePanel.w, + layout.sourcePanel.h); + auto* title = app_ui::makeLabel(panel, "RTC Source", layout.sourceTitle.x, layout.sourceTitle.y, + &lv_font_montserrat_20, app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(title, layout.sourceTitle.w); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + _detail = app_ui::makeLabel(panel, "", layout.sourceDetail.x, layout.sourceDetail.y, &lv_font_montserrat_18, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(_detail, layout.sourceDetail.w); + lv_label_set_long_mode(_detail, LV_LABEL_LONG_WRAP); + refresh(); + } + + void refresh() + { + const auto sample = _controller.sample(); + setMetric(_time, "Current Time", RtcController::formatTime(sample).c_str()); + setMetric(_date, "Date", RtcController::formatDate(sample).c_str()); + if (_detail != nullptr) { + lv_label_set_text_fmt(_detail, "%s\nSampling: 4 Hz while this app is open", + RtcController::formatWeekday(sample).c_str()); + } + } + + void setMetric(lv_obj_t* card, const char* title, const char* value) + { + if (card == nullptr) { + return; + } + auto* titleLabel = lv_obj_get_child(card, 0); + if (titleLabel != nullptr) { + lv_label_set_text(titleLabel, title); + } + auto* valueLabel = lv_obj_get_child(card, 1); + if (valueLabel != nullptr) { + lv_label_set_text(valueLabel, value); + } + } +}; + +} // namespace + +std::unique_ptr app_hardware_rtc::makeRtcApp() +{ + return std::make_unique(); +} diff --git a/app/apps/app_hardware_rtc/app_rtc.h b/app/apps/app_hardware_rtc/app_rtc.h new file mode 100644 index 00000000..2badfb36 --- /dev/null +++ b/app/apps/app_hardware_rtc/app_rtc.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_hardware_rtc { + +std::unique_ptr makeRtcApp(); + +} // namespace app_hardware_rtc diff --git a/app/apps/app_hardware_rtc/rtc_controller.cpp b/app/apps/app_hardware_rtc/rtc_controller.cpp new file mode 100644 index 00000000..3f367348 --- /dev/null +++ b/app/apps/app_hardware_rtc/rtc_controller.cpp @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "rtc_controller.h" + +#include +#include + +using namespace app_hardware_rtc; + +RtcController::RtcController(ReadRtcFn readRtcFn) : _readRtcFn(std::move(readRtcFn)) {} + +RtcSample RtcController::sample() const +{ + RtcSample result; + if (_readRtcFn) { + _readRtcFn(&result.time); + } + return result; +} + +std::string RtcController::formatTime(const RtcSample& sample) +{ + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), "%02d:%02d:%02d", sample.time.tm_hour, sample.time.tm_min, sample.time.tm_sec); + return buffer; +} + +std::string RtcController::formatDate(const RtcSample& sample) +{ + char buffer[48]; + std::snprintf(buffer, sizeof(buffer), "%04d-%02d-%02d", sample.time.tm_year + 1900, sample.time.tm_mon + 1, + sample.time.tm_mday); + return buffer; +} + +std::string RtcController::formatWeekday(const RtcSample& sample) +{ + char buffer[48]; + std::snprintf(buffer, sizeof(buffer), "Weekday: %d", sample.time.tm_wday); + return buffer; +} diff --git a/app/apps/app_hardware_rtc/rtc_controller.h b/app/apps/app_hardware_rtc/rtc_controller.h new file mode 100644 index 00000000..0e8345fb --- /dev/null +++ b/app/apps/app_hardware_rtc/rtc_controller.h @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include + +namespace app_hardware_rtc { + +struct RtcSample { + tm time = {}; +}; + +class RtcController { +public: + using ReadRtcFn = std::function; + + explicit RtcController(ReadRtcFn readRtcFn); + + RtcSample sample() const; + + static std::string formatTime(const RtcSample& sample); + static std::string formatDate(const RtcSample& sample); + static std::string formatWeekday(const RtcSample& sample); + +private: + ReadRtcFn _readRtcFn; +}; + +} // namespace app_hardware_rtc diff --git a/app/apps/app_hardware_rtc/rtc_layout.h b/app/apps/app_hardware_rtc/rtc_layout.h new file mode 100644 index 00000000..0fbe4892 --- /dev/null +++ b/app/apps/app_hardware_rtc/rtc_layout.h @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace app_hardware_rtc { + +struct RtcLayout { + app_ui::Rect content; + app_ui::Rect description; + app_ui::Rect timeCard; + app_ui::Rect dateCard; + int cardGap = 24; + app_ui::Rect sourcePanel; + app_ui::Rect sourceTitle; + app_ui::Rect sourceDetail; +}; + +inline app_ui::Rect rtcContentRect() +{ + return {0, 0, app_ui::safeContentRect().w, app_ui::safeContentRect().h}; +} + +inline RtcLayout makeRtcLayout() +{ + RtcLayout layout; + layout.content = rtcContentRect(); + layout.description = {0, 0, layout.content.w, 32}; + layout.timeCard = {0, 72, 360, 132}; + layout.dateCard = {layout.timeCard.x + layout.timeCard.w + layout.cardGap, 72, 360, 132}; + layout.sourcePanel = {0, 236, 744, 160}; + layout.sourceTitle = {0, 0, layout.sourcePanel.w - 32, 30}; + layout.sourceDetail = {0, 48, layout.sourcePanel.w - 32, layout.sourcePanel.h - 76}; + return layout; +} + +} // namespace app_hardware_rtc diff --git a/app/apps/app_hardware_sd/app_sd_card.cpp b/app/apps/app_hardware_sd/app_sd_card.cpp new file mode 100644 index 00000000..de9decf8 --- /dev/null +++ b/app/apps/app_hardware_sd/app_sd_card.cpp @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_sd_card.h" +#include "sd_card_controller.h" +#include "sd_card_layout.h" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace app_hardware_sd; + +namespace { + +class AppSdCard : public mooncake::AppAbility { +public: + AppSdCard() + : _controller([] { return GetHAL()->isSdCardMounted(); }, + [](const std::string& path) { return GetHAL()->scanSdCard(path); }) + { + setAppInfo().name = "SD Card"; + } + + void onOpen() override + { + _shell = std::make_unique("SD Card", [&]() { close(); }); + _shell->init(); + build(); + } + + void onRunning() override + { + const uint32_t now = GetHAL()->millis(); + if (now - _lastUpdateMs < 100) { + return; + } + _controller.refresh(); + LvglLockGuard lock; + refreshView(); + _lastUpdateMs = now; + } + + void onClose() override + { + _shell.reset(); + } + +private: + SdCardController _controller; + std::unique_ptr _shell; + lv_obj_t* _status = nullptr; + uint32_t _lastUpdateMs = 0; + + lv_obj_t* content() const + { + return _shell->content(); + } + + void build() + { + LvglLockGuard lock; + const auto layout = makeSdCardLayout(); + + auto* description = app_ui::makeLabel(content(), "Scan the mounted SD card root without blocking navigation.", + layout.description.x, layout.description.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(description, layout.description.w); + lv_label_set_long_mode(description, LV_LABEL_LONG_DOT); + auto* button = app_ui::makeButton(content(), "Scan SD Card", layout.scanButton.x, layout.scanButton.y, + layout.scanButton.w, layout.scanButton.h, app_ui::ButtonKind::Primary); + lv_obj_add_event_cb(button, AppSdCard::onScan, LV_EVENT_CLICKED, this); + + auto* panel = app_ui::makePanel(content(), layout.resultsPanel.x, layout.resultsPanel.y, + layout.resultsPanel.w, layout.resultsPanel.h); + auto* title = app_ui::makeLabel(panel, "Root", layout.resultsTitle.x, layout.resultsTitle.y, + &lv_font_montserrat_20, app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(title, layout.resultsTitle.w); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + _status = app_ui::makeLabel(panel, _controller.statusText().c_str(), layout.resultsText.x, + layout.resultsText.y, &lv_font_montserrat_18, + app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(_status, layout.resultsText.w); + lv_label_set_long_mode(_status, LV_LABEL_LONG_WRAP); + } + + void startScan() + { + audio::play_next_tone_progression(); + _controller.startScan(); + refreshView(); + } + + void refreshView() + { + if (_status != nullptr) { + lv_label_set_text(_status, _controller.statusText().c_str()); + lv_obj_set_style_text_color(_status, + _controller.busy() ? app_ui::theme::color(app_ui::theme::amber) + : app_ui::theme::color(app_ui::theme::text), + 0); + } + } + + static void onScan(lv_event_t* event) + { + auto* app = static_cast(lv_event_get_user_data(event)); + if (app != nullptr) { + app->startScan(); + } + } +}; + +} // namespace + +std::unique_ptr app_hardware_sd::makeSdCardApp() +{ + return std::make_unique(); +} diff --git a/app/apps/app_hardware_sd/app_sd_card.h b/app/apps/app_hardware_sd/app_sd_card.h new file mode 100644 index 00000000..221a703b --- /dev/null +++ b/app/apps/app_hardware_sd/app_sd_card.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_hardware_sd { + +std::unique_ptr makeSdCardApp(); + +} // namespace app_hardware_sd diff --git a/app/apps/app_hardware_sd/sd_card_controller.cpp b/app/apps/app_hardware_sd/sd_card_controller.cpp new file mode 100644 index 00000000..408ac10d --- /dev/null +++ b/app/apps/app_hardware_sd/sd_card_controller.cpp @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "sd_card_controller.h" +#include +#include + +using namespace app_hardware_sd; + +SdCardController::SdCardController(MountedFn mountedFn, ScanFn scanFn) + : _mountedFn(std::move(mountedFn)), _scanFn(std::move(scanFn)) +{ +} + +bool SdCardController::startScan() +{ + if (!_mountedFn || !_scanFn) { + std::lock_guard lock(_mutex); + _statusText = "SD card scan is unavailable."; + _busy = false; + return false; + } + + auto state = std::make_shared(); + auto mountedFn = _mountedFn; + auto scanFn = _scanFn; + if (!app_runtime::globalAsyncJobRunner().start("SD card scan", [state, mountedFn, scanFn]() { + const bool mounted = mountedFn(); + auto files = mounted ? scanFn("/") : std::vector {}; + auto text = SdCardController::formatResult(mounted, files); + std::lock_guard lock(state->mutex); + state->text = std::move(text); + state->complete = true; + })) { + std::lock_guard lock(_mutex); + _statusText = "Another background task is already running. Try again in a moment."; + _busy = false; + return false; + } + + { + std::lock_guard lock(_mutex); + _scanState = state; + _statusText = "Scanning SD card..."; + _busy = true; + } + return true; +} + +void SdCardController::refresh() +{ + std::shared_ptr state; + { + std::lock_guard lock(_mutex); + state = _scanState; + } + if (!state) { + return; + } + + std::string text; + { + std::lock_guard lock(state->mutex); + if (!state->complete) { + return; + } + text = state->text; + } + + std::lock_guard lock(_mutex); + _statusText = std::move(text); + _busy = false; + _scanState.reset(); +} + +bool SdCardController::busy() const +{ + std::lock_guard lock(_mutex); + return _busy; +} + +std::string SdCardController::statusText() const +{ + std::lock_guard lock(_mutex); + return _statusText; +} + +std::string SdCardController::formatResult(bool mounted, const std::vector& files) +{ + if (!mounted) { + return "No SD card mounted."; + } + if (files.empty()) { + return "SD card is mounted but no files were found."; + } + + std::ostringstream out; + out << "SD card root:"; + for (const auto& file : files) { + out << "\n" << (file.isDir ? "[DIR] " : " ") << file.name; + } + return out.str(); +} diff --git a/app/apps/app_hardware_sd/sd_card_controller.h b/app/apps/app_hardware_sd/sd_card_controller.h new file mode 100644 index 00000000..a6640ef8 --- /dev/null +++ b/app/apps/app_hardware_sd/sd_card_controller.h @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace app_hardware_sd { + +class SdCardController { +public: + using MountedFn = std::function; + using ScanFn = std::function(const std::string& path)>; + + SdCardController(MountedFn mountedFn, ScanFn scanFn); + + bool startScan(); + void refresh(); + bool busy() const; + std::string statusText() const; + + static std::string formatResult(bool mounted, const std::vector& files); + +private: + struct ScanState { + std::mutex mutex; + bool complete = false; + std::string text; + }; + + MountedFn _mountedFn; + ScanFn _scanFn; + std::shared_ptr _scanState; + mutable std::mutex _mutex; + std::string _statusText = "No scan run yet."; + bool _busy = false; +}; + +} // namespace app_hardware_sd diff --git a/app/apps/app_hardware_sd/sd_card_layout.h b/app/apps/app_hardware_sd/sd_card_layout.h new file mode 100644 index 00000000..95bc197b --- /dev/null +++ b/app/apps/app_hardware_sd/sd_card_layout.h @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace app_hardware_sd { + +struct SdCardLayout { + app_ui::Rect content; + app_ui::Rect description; + app_ui::Rect scanButton; + int sectionGap = 32; + app_ui::Rect resultsPanel; + app_ui::Rect resultsTitle; + app_ui::Rect resultsText; +}; + +inline app_ui::Rect sdCardContentRect() +{ + return {0, 0, app_ui::safeContentRect().w, app_ui::safeContentRect().h}; +} + +inline SdCardLayout makeSdCardLayout() +{ + SdCardLayout layout; + layout.content = sdCardContentRect(); + layout.description = {0, 0, layout.content.w, 32}; + layout.scanButton = {0, 72, 220, 60}; + layout.resultsPanel = {0, layout.scanButton.y + layout.scanButton.h + layout.sectionGap, 900, 392}; + layout.resultsTitle = {0, 0, layout.resultsPanel.w - 32, 30}; + layout.resultsText = {0, 48, layout.resultsPanel.w - 32, layout.resultsPanel.h - 76}; + return layout; +} + +} // namespace app_hardware_sd diff --git a/app/apps/app_hardware_switches/app_switches.cpp b/app/apps/app_hardware_switches/app_switches.cpp new file mode 100644 index 00000000..45f4651d --- /dev/null +++ b/app/apps/app_hardware_switches/app_switches.cpp @@ -0,0 +1,195 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_switches.h" +#include "switches_controller.h" +#include "switches_layout.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace app_hardware_switches; + +namespace { + +struct ToggleBinding { + class AppSwitches* app = nullptr; + SwitchId id = SwitchId::Charge; +}; + +class AppSwitches : public mooncake::AppAbility { +public: + AppSwitches() + : _controller( + [] { + SwitchSnapshot snapshot; + snapshot.charge = GetHAL()->getChargeEnable(); + snapshot.chargeQc = GetHAL()->getChargeQcEnable(); + snapshot.ext5v = GetHAL()->getExt5vEnable(); + snapshot.usbA5v = GetHAL()->getUsb5vEnable(); + snapshot.extAntenna = GetHAL()->getExtAntennaEnable(); + return snapshot; + }, + [](SwitchId id, bool enabled) { + switch (id) { + case SwitchId::Charge: + GetHAL()->setChargeEnable(enabled); + break; + case SwitchId::ChargeQc: + GetHAL()->setChargeQcEnable(enabled); + break; + case SwitchId::Ext5v: + GetHAL()->setExt5vEnable(enabled); + break; + case SwitchId::UsbA5v: + GetHAL()->setUsb5vEnable(enabled); + break; + case SwitchId::ExtAntenna: + GetHAL()->setExtAntennaEnable(enabled); + break; + } + }, + [] { + InterfaceSnapshot snapshot; + snapshot.usbC = GetHAL()->usbCDetect(); + snapshot.usbA = GetHAL()->usbADetect(); + snapshot.headphone = GetHAL()->headPhoneDetect(); + return snapshot; + }) + { + setAppInfo().name = "Switches"; + } + + void onOpen() override + { + _shell = std::make_unique("Switches", [&]() { close(); }); + _shell->init(); + build(); + } + + void onRunning() override + { + const uint32_t now = GetHAL()->millis(); + if (now - _lastUpdateMs < 250) { + return; + } + LvglLockGuard lock; + refreshInterface(); + _lastUpdateMs = now; + } + + void onClose() override + { + _bindings.clear(); + _shell.reset(); + } + +private: + SwitchesController _controller; + std::unique_ptr _shell; + std::vector> _bindings; + lv_obj_t* _status = nullptr; + uint32_t _lastUpdateMs = 0; + + lv_obj_t* content() const + { + return _shell->content(); + } + + void build() + { + LvglLockGuard lock; + const auto layout = makeSwitchesLayout(); + + auto* description = app_ui::makeLabel(content(), "Control power rails and interface routing from one safe panel.", + layout.description.x, layout.description.y, &lv_font_montserrat_20, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(description, layout.description.w); + lv_label_set_long_mode(description, LV_LABEL_LONG_DOT); + + const auto switches = _controller.readSwitches(); + addToggle(layout, 0, "Charge Enable", "Allow battery charging", SwitchId::Charge, switches.charge); + addToggle(layout, 1, "Charge QC", "Enable quick-charge negotiation", SwitchId::ChargeQc, switches.chargeQc); + addToggle(layout, 2, "External 5V", "Drive the external 5V rail", SwitchId::Ext5v, switches.ext5v); + addToggle(layout, 3, "USB-A 5V", "Power the USB-A host port", SwitchId::UsbA5v, switches.usbA5v); + addToggle(layout, 4, "External Antenna", "Route wireless through the external antenna", SwitchId::ExtAntenna, + switches.extAntenna); + + auto* panel = app_ui::makePanel(content(), layout.interfaceStatus.x, layout.interfaceStatus.y, + layout.interfaceStatus.w, layout.interfaceStatus.h); + auto* title = app_ui::makeLabel(panel, "Interface Status", layout.statusTitle.x, layout.statusTitle.y, + &lv_font_montserrat_20, app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(title, layout.statusTitle.w); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + _status = app_ui::makeLabel(panel, "", layout.statusDetail.x, layout.statusDetail.y, &lv_font_montserrat_18, + app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(_status, layout.statusDetail.w); + lv_label_set_long_mode(_status, LV_LABEL_LONG_DOT); + refreshInterface(); + } + + void addToggle(const SwitchesLayout& layout, int index, const char* title, const char* detail, SwitchId id, + bool initial) + { + const auto card = switchCardRect(layout, index); + auto* panel = app_ui::makePanel(content(), card.x, card.y, card.w, card.h); + auto* titleLabel = app_ui::makeLabel(panel, title, layout.cardTitle.x, layout.cardTitle.y, + &lv_font_montserrat_18, app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(titleLabel, layout.cardTitle.w); + lv_label_set_long_mode(titleLabel, LV_LABEL_LONG_DOT); + auto* detailLabel = app_ui::makeLabel(panel, detail, layout.cardDetail.x, layout.cardDetail.y, + &lv_font_montserrat_14, app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(detailLabel, layout.cardDetail.w); + lv_label_set_long_mode(detailLabel, LV_LABEL_LONG_DOT); + auto* sw = app_ui::makeSwitch(panel, layout.switchControl.x, layout.switchControl.y, layout.switchControl.w, layout.switchControl.h, initial); + + auto binding = std::make_unique(); + binding->app = this; + binding->id = id; + lv_obj_add_event_cb(sw, AppSwitches::onToggle, LV_EVENT_VALUE_CHANGED, binding.get()); + _bindings.push_back(std::move(binding)); + } + + void setSwitch(SwitchId id, bool enabled) + { + audio::play_next_tone_progression(); + _controller.setSwitch(id, enabled); + refreshInterface(); + } + + void refreshInterface() + { + if (_status == nullptr) { + return; + } + const auto text = SwitchesController::formatInterfaceStatus(_controller.readInterface()); + lv_label_set_text(_status, text.c_str()); + } + + static void onToggle(lv_event_t* event) + { + auto* binding = static_cast(lv_event_get_user_data(event)); + if (binding == nullptr || binding->app == nullptr) { + return; + } + auto* target = static_cast(lv_event_get_target(event)); + binding->app->setSwitch(binding->id, lv_obj_has_state(target, LV_STATE_CHECKED)); + } +}; + +} // namespace + +std::unique_ptr app_hardware_switches::makeSwitchesApp() +{ + return std::make_unique(); +} diff --git a/app/apps/app_hardware_switches/app_switches.h b/app/apps/app_hardware_switches/app_switches.h new file mode 100644 index 00000000..3d945aa8 --- /dev/null +++ b/app/apps/app_hardware_switches/app_switches.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_hardware_switches { + +std::unique_ptr makeSwitchesApp(); + +} // namespace app_hardware_switches diff --git a/app/apps/app_hardware_switches/switches_controller.cpp b/app/apps/app_hardware_switches/switches_controller.cpp new file mode 100644 index 00000000..0f65be6b --- /dev/null +++ b/app/apps/app_hardware_switches/switches_controller.cpp @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "switches_controller.h" + +#include + +using namespace app_hardware_switches; + +SwitchesController::SwitchesController(ReadSwitchesFn readSwitchesFn, SetSwitchFn setSwitchFn, ReadInterfaceFn readInterfaceFn) + : _readSwitchesFn(std::move(readSwitchesFn)), _setSwitchFn(std::move(setSwitchFn)), + _readInterfaceFn(std::move(readInterfaceFn)) +{ +} + +SwitchSnapshot SwitchesController::readSwitches() const +{ + return _readSwitchesFn(); +} + +void SwitchesController::setSwitch(SwitchId id, bool enabled) +{ + _setSwitchFn(id, enabled); +} + +InterfaceSnapshot SwitchesController::readInterface() const +{ + return _readInterfaceFn(); +} + +std::string SwitchesController::formatInterfaceStatus(const InterfaceSnapshot& snapshot) +{ + char buffer[96]; + std::snprintf(buffer, sizeof(buffer), "USB-C: %s USB-A: %s Headphone: %s", + snapshot.usbC ? "connected" : "off", snapshot.usbA ? "connected" : "off", + snapshot.headphone ? "connected" : "off"); + return buffer; +} diff --git a/app/apps/app_hardware_switches/switches_controller.h b/app/apps/app_hardware_switches/switches_controller.h new file mode 100644 index 00000000..bacfc8d7 --- /dev/null +++ b/app/apps/app_hardware_switches/switches_controller.h @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include + +namespace app_hardware_switches { + +enum class SwitchId { + Charge, + ChargeQc, + Ext5v, + UsbA5v, + ExtAntenna, +}; + +struct SwitchSnapshot { + bool charge = false; + bool chargeQc = false; + bool ext5v = false; + bool usbA5v = false; + bool extAntenna = false; +}; + +struct InterfaceSnapshot { + bool usbC = false; + bool usbA = false; + bool headphone = false; +}; + +class SwitchesController { +public: + using ReadSwitchesFn = std::function; + using SetSwitchFn = std::function; + using ReadInterfaceFn = std::function; + + SwitchesController(ReadSwitchesFn readSwitchesFn, SetSwitchFn setSwitchFn, ReadInterfaceFn readInterfaceFn); + + SwitchSnapshot readSwitches() const; + void setSwitch(SwitchId id, bool enabled); + InterfaceSnapshot readInterface() const; + + static std::string formatInterfaceStatus(const InterfaceSnapshot& snapshot); + +private: + ReadSwitchesFn _readSwitchesFn; + SetSwitchFn _setSwitchFn; + ReadInterfaceFn _readInterfaceFn; +}; + +} // namespace app_hardware_switches diff --git a/app/apps/app_hardware_switches/switches_layout.h b/app/apps/app_hardware_switches/switches_layout.h new file mode 100644 index 00000000..476da6bb --- /dev/null +++ b/app/apps/app_hardware_switches/switches_layout.h @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace app_hardware_switches { + +inline constexpr int kSwitchControlCount = 5; + +struct SwitchesLayout { + app_ui::Rect content; + app_ui::Rect description; + int cols = 2; + int cardW = 0; + int cardH = 88; + int gridGap = 24; + int startY = 72; + int sectionGap = 24; + app_ui::Rect switchControl; + app_ui::Rect cardTitle; + app_ui::Rect cardDetail; + app_ui::Rect interfaceStatus; + app_ui::Rect statusTitle; + app_ui::Rect statusDetail; + int bottomPadding = 24; + int scrollContentHeight = 0; +}; + +inline app_ui::Rect switchesContentRect() +{ + return {0, 0, app_ui::safeContentRect().w, app_ui::safeContentRect().h}; +} + +inline app_ui::Rect switchCardRect(const SwitchesLayout& layout, int index) +{ + const int col = index % layout.cols; + const int row = index / layout.cols; + return { + col * (layout.cardW + layout.gridGap), + layout.startY + row * (layout.cardH + layout.gridGap), + layout.cardW, + layout.cardH, + }; +} + +inline SwitchesLayout makeSwitchesLayout() +{ + SwitchesLayout layout; + layout.content = switchesContentRect(); + layout.cardW = (layout.content.w - layout.gridGap) / layout.cols; + layout.description = {0, 0, layout.content.w, 32}; + layout.switchControl = {layout.cardW - 112, 9, 72, 38}; + layout.cardTitle = {0, 0, layout.switchControl.x - 24, 26}; + layout.cardDetail = {0, 34, layout.switchControl.x - 24, 22}; + + const auto lastCard = switchCardRect(layout, kSwitchControlCount - 1); + layout.interfaceStatus = {0, lastCard.y + lastCard.h + layout.sectionGap, layout.content.w, 112}; + layout.statusTitle = {0, 0, layout.interfaceStatus.w - 32, 28}; + layout.statusDetail = {0, 46, layout.interfaceStatus.w - 32, 28}; + layout.scrollContentHeight = layout.interfaceStatus.y + layout.interfaceStatus.h + layout.bottomPadding; + return layout; +} + +} // namespace app_hardware_switches diff --git a/app/apps/app_installer.h b/app/apps/app_installer.h index 50cf05df..4ce231e6 100644 --- a/app/apps/app_installer.h +++ b/app/apps/app_installer.h @@ -7,25 +7,9 @@ #include #include #include -#include "app_template/app_template.h" +#include "app_catalog.h" #include "app_launcher/app_launcher.h" -#include "app_startup_anim/app_startup_anim.h" -/* Header files locator (Don't remove) */ - -// Start boot anim app and wait for it to finish -inline void on_startup_anim() -{ - auto app_id = mooncake::GetMooncake().installApp(std::make_unique()); - mooncake::GetMooncake().openApp(app_id); - while (1) { - mooncake::GetMooncake().update(); - if (mooncake::GetMooncake().getAppCurrentState(app_id) == mooncake::AppAbility::StateSleeping) { - break; - } - GetHAL()->delay(1); - } - mooncake::GetMooncake().uninstallApp(app_id); -} +#include "app_registry.h" /** * @brief App 安装回调 @@ -34,8 +18,8 @@ inline void on_startup_anim() */ inline void on_install_apps() { - // 安装 App - // mooncake::GetMooncake().installApp(std::make_unique()); mooncake::GetMooncake().installApp(std::make_unique()); - /* Install app locator (Don't remove) */ + auto& apps = app_registry::registry(); + app_catalog::registerDefaultApps(apps); + apps.installAll(); } diff --git a/app/apps/app_launcher/view/launcher_layout.h b/app/apps/app_launcher/view/launcher_layout.h new file mode 100644 index 00000000..329ad499 --- /dev/null +++ b/app/apps/app_launcher/view/launcher_layout.h @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +namespace launcher_view { + +struct LauncherLayout { + app_ui::Rect screen; + app_ui::Rect safe; + app_ui::Rect topBar; + app_ui::Rect categoryRail; + app_ui::Rect tileArea; + app_ui::Rect summaryArea; + app_ui::Rect tileIcon; + app_ui::Rect tileTitle; + app_ui::Rect tileDescription; + app_ui::Rect tileStatus; + int columnGap = 20; + int categoryHeadingX = 18; + int categoryHeadingY = 16; + int categoryButtonX = 14; + int categoryButtonY = 62; + int categoryButtonW = 182; + int categoryButtonH = 52; + int categoryButtonGap = 12; + int tileHeadingX = 20; + int tileHeadingY = 16; + int tileGridX = 20; + int tileGridY = 62; + int tileW = 214; + int tileH = 142; + int tileGap = 18; + int tileColumns = 3; + int summaryHeadingX = 18; + int summaryHeadingY = 16; + int summaryRowY = 64; + int summaryRowH = 38; + int summaryLabelX = 18; + int summaryLabelW = 126; + int summaryValueW = 150; + int summaryRightPad = 18; +}; + +inline LauncherLayout makeLauncherLayout() +{ + auto screen = app_ui::screenRect(); + auto safe = app_ui::safeContentRect(); + constexpr int railW = 210; + constexpr int summaryW = 318; + constexpr int gap = 20; + const auto columns = app_ui::makeColumns(safe, {railW, 0, summaryW}, gap); + + LauncherLayout layout; + layout.screen = screen; + layout.safe = safe; + layout.topBar = {0, 0, screen.w, 72}; + layout.categoryRail = columns.size() == 3 ? columns[0] : app_ui::Rect{safe.x, safe.y, railW, safe.h}; + layout.tileArea = columns.size() == 3 ? columns[1] : app_ui::Rect{safe.x + railW + gap, safe.y, 0, safe.h}; + layout.summaryArea = columns.size() == 3 ? columns[2] : app_ui::Rect{safe.x + railW + gap, safe.y, summaryW, safe.h}; + layout.columnGap = gap; + layout.tileColumns = 3; + layout.tileGap = 18; + layout.tileGridX = 20; + layout.tileGridY = 62; + constexpr int tileRightMargin = 16; + layout.tileW = (layout.tileArea.w - layout.tileGridX - tileRightMargin - (layout.tileGap * (layout.tileColumns - 1))) / + layout.tileColumns; + layout.tileH = 142; + layout.tileIcon = {14, 14, 42, 42}; + layout.tileTitle = {66, 17, layout.tileW - 82, 24}; + layout.tileDescription = {16, 70, layout.tileW - 32, 44}; + layout.tileStatus = {16, layout.tileH - 36, layout.tileW - 32, 24}; + return layout; +} + +inline app_ui::Rect launcherCategoryButtonRect(const LauncherLayout& layout, int index) +{ + return { + layout.categoryButtonX, + layout.categoryButtonY + index * (layout.categoryButtonH + layout.categoryButtonGap), + layout.categoryButtonW, + layout.categoryButtonH, + }; +} + +inline app_ui::Rect launcherTileRect(const LauncherLayout& layout, int index) +{ + const int col = index % layout.tileColumns; + const int row = index / layout.tileColumns; + return { + layout.tileGridX + col * (layout.tileW + layout.tileGap), + layout.tileGridY + row * (layout.tileH + layout.tileGap), + layout.tileW, + layout.tileH, + }; +} + +inline app_ui::Rect launcherSummaryLabelRect(const LauncherLayout& layout, int index) +{ + return { + layout.summaryLabelX, + layout.summaryRowY + index * layout.summaryRowH, + layout.summaryLabelW, + 24, + }; +} + +inline app_ui::Rect launcherSummaryValueRect(const LauncherLayout& layout, int index) +{ + return { + layout.summaryArea.w - layout.summaryRightPad - layout.summaryValueW, + layout.summaryRowY + index * layout.summaryRowH, + layout.summaryValueW, + 24, + }; +} + +} // namespace launcher_view diff --git a/app/apps/app_launcher/view/panel_camera.cpp b/app/apps/app_launcher/view/panel_camera.cpp deleted file mode 100644 index 7ff06f14..00000000 --- a/app/apps/app_launcher/view/panel_camera.cpp +++ /dev/null @@ -1,139 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD - * - * SPDX-License-Identifier: MIT - */ -#include "view.h" -#include -#include -#include -#include -#include -#include - -using namespace launcher_view; -using namespace smooth_ui_toolkit; -using namespace smooth_ui_toolkit::lvgl_cpp; - -static const std::string _tag = "panel-camera"; - -static const ui::Window::KeyFrame_t _kf_camera_close = {598, 0, 74, 74, 0}; -static const ui::Window::KeyFrame_t _kf_camera_open = {141, 0, 800, 480, 255}; - -class CameraWindow : public ui::Window { -public: - CameraWindow() - { - config.kfClosed = _kf_camera_close; - config.kfOpened = _kf_camera_open; - } - - void close(bool teleport = false, bool triggerCallback = true) override - { - if (!_is_camera_opened) { - ui::Window::close(teleport, triggerCallback); - return; - } - - if (!_is_camera_closing) { - _is_camera_closing = true; - _camera_canvas->setOpa(0); - _label_msg->setText("Closing Camera ..."); - GetHAL()->stopCameraCapture(); - } - } - - void onOpen() override - { - _window->setScrollbarMode(LV_SCROLLBAR_MODE_OFF); - - _label_msg = std::make_unique