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/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..a0edf53e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,70 @@ +name: Build and test + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + desktop: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Install desktop build tools + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build libsdl2-dev python3 git + - name: Test dependency fetching without network fixtures + run: python3 tests/test_fetch_repos.py + - name: Fetch and verify pinned dependencies + run: | + python3 fetch_repos.py + python3 fetch_repos.py --verify + - name: Configure and build + run: | + cmake --preset desktop + cmake --build --preset desktop --parallel 2 + - name: Check immediate incremental rebuild + run: | + cmake --build --preset desktop --parallel 2 2>&1 | tee /tmp/desktop-rebuild.log + if grep -E 'Building .* object|Linking |premature end' /tmp/desktop-rebuild.log; then + echo 'Unchanged build unexpectedly rebuilt targets or recovered a damaged Ninja log.' + exit 1 + fi + - name: Run project tests and headless UI journeys + env: + SDL_VIDEODRIVER: dummy + run: ctest --preset desktop + + firmware: + runs-on: ubuntu-24.04 + timeout-minutes: 40 + container: + # Official espressif/idf:v5.4.2 multi-platform image, pinned to its registry digest. + image: espressif/idf@sha256:4317595a911886e04cbc6d731315d5fc2918e510b6733fc7d03381ab2d270082 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Trust the mounted checkout in the build container + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Fetch and verify pinned dependencies + run: | + python3 fetch_repos.py + python3 fetch_repos.py --verify + - name: Build ESP32-P4 firmware and bootloader with ESP-IDF 5.4.2 + run: | + . /opt/esp/idf/export.sh + test "$(git -C "$IDF_PATH" rev-parse HEAD)" = f5c3654a1c2d2a01f7f67def7a0dc48e691f63c0 + idf.py -C platforms/tab5 build + git diff --exit-code -- platforms/tab5/dependencies.lock diff --git a/.gitignore b/.gitignore index 511fa899..ea75a946 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .cache -.vscode build dependencies .DS_Store +__pycache__/ +*.pyc 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..4cc94e13 --- /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/vscode-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..1f7fc3b2 --- /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/vscode-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..2456c6e2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,521 @@ -cmake_minimum_required(VERSION 3.10) +cmake_minimum_required(VERSION 3.25) 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 + app/apps/runtime/async_job_runner.cpp + 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) +target_link_libraries(power_monitor_controller_tests PUBLIC pthread) +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_include_directories(switches_controller_tests SYSTEM PRIVATE dependencies/lvgl) +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 + app/apps/runtime/async_job_runner.cpp + 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) +target_link_libraries(rtc_controller_tests PRIVATE pthread) +add_test(NAME rtc_controller_tests COMMAND rtc_controller_tests) + +add_executable(imu_controller_tests + app/apps/runtime/async_job_runner.cpp + 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) +target_link_libraries(imu_controller_tests PUBLIC pthread) +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(pps_input_tests tests/test_pps_input.cpp) +target_include_directories(pps_input_tests PRIVATE app) +target_compile_features(pps_input_tests PRIVATE cxx_std_17) +add_test(NAME pps_input_tests COMMAND pps_input_tests) + +add_executable(uart_monitor_tests tests/test_uart_monitor.cpp) +target_include_directories(uart_monitor_tests PRIVATE app) +target_compile_features(uart_monitor_tests PRIVATE cxx_std_17) +target_link_libraries(uart_monitor_tests PRIVATE pthread) +add_test(NAME uart_monitor_tests COMMAND uart_monitor_tests) + +find_package(Python3 REQUIRED COMPONENTS Interpreter) +add_test(NAME desktop_populated_ui_tests COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/run_populated_ui.py $) +set_tests_properties(desktop_populated_ui_tests PROPERTIES TIMEOUT 70) + +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) + +add_executable(network_state_tests tests/test_network_state.cpp) +target_include_directories(network_state_tests PUBLIC app) +target_include_directories(network_state_tests SYSTEM PUBLIC dependencies/lvgl) +target_compile_features(network_state_tests PUBLIC cxx_std_17) +target_link_libraries(network_state_tests PUBLIC pthread) +add_test(NAME network_state_tests COMMAND network_state_tests) + +add_executable(camera_cleanup_tests tests/test_camera_cleanup.cpp) +target_include_directories(camera_cleanup_tests PUBLIC app) +target_compile_features(camera_cleanup_tests PUBLIC cxx_std_17) +add_test(NAME camera_cleanup_tests COMMAND camera_cleanup_tests) + +add_executable(power_recovery_tests tests/test_power_recovery.cpp) +target_include_directories(power_recovery_tests PUBLIC app) +target_compile_features(power_recovery_tests PUBLIC cxx_std_17) +add_test(NAME power_recovery_tests COMMAND power_recovery_tests) + +add_executable(bmi270_driver_tests tests/test_bmi270_driver.c + platforms/tab5/components/sensor_bmi270/src/accel_gyro_bmi270.c) +target_include_directories(bmi270_driver_tests PRIVATE tests/bmi270_stubs + platforms/tab5/components/sensor_bmi270/include) +set_target_properties(bmi270_driver_tests PROPERTIES C_STANDARD 11 C_EXTENSIONS ON) +target_link_libraries(bmi270_driver_tests PRIVATE pthread) +add_test(NAME bmi270_driver_tests COMMAND bmi270_driver_tests) + +add_executable(audio_session_tests tests/test_audio_session.cpp app/apps/runtime/async_job_runner.cpp) +target_include_directories(audio_session_tests PRIVATE app) +target_include_directories(audio_session_tests SYSTEM PRIVATE dependencies/lvgl) +target_compile_features(audio_session_tests PRIVATE cxx_std_17) +target_link_libraries(audio_session_tests PRIVATE pthread) +add_test(NAME audio_session_tests COMMAND audio_session_tests) + +add_executable(audio_codec_transition_tests tests/test_audio_codec_transition.cpp) +target_include_directories(audio_codec_transition_tests PRIVATE platforms/tab5/components/m5stack_tab5/include) +target_compile_features(audio_codec_transition_tests PRIVATE cxx_std_17) +add_test(NAME audio_codec_transition_tests COMMAND audio_codec_transition_tests) + +add_executable(home_status_tests tests/test_home_status.cpp) +target_include_directories(home_status_tests PRIVATE app dependencies/mooncake/src) +target_include_directories(home_status_tests SYSTEM PRIVATE dependencies/lvgl) +target_link_libraries(home_status_tests PRIVATE mooncake mooncake_log) +target_compile_features(home_status_tests PRIVATE cxx_std_17) +add_test(NAME home_status_tests COMMAND home_status_tests) + +add_executable(rtc_calendar_tests tests/test_rtc_calendar.cpp app/modules/rtc/rx8130_calendar.cpp) +target_include_directories(rtc_calendar_tests PRIVATE app) +target_include_directories(rtc_calendar_tests SYSTEM PRIVATE dependencies/lvgl) +target_compile_features(rtc_calendar_tests PRIVATE cxx_std_17) +add_test(NAME rtc_calendar_tests COMMAND rtc_calendar_tests) + +add_executable(ina226_read_tests tests/test_ina226_reads.cpp + platforms/tab5/components/power_monitor_ina226/src/ina226.cpp) +target_include_directories(ina226_read_tests PRIVATE tests/ina226_stubs + platforms/tab5/components/power_monitor_ina226/include) +target_compile_features(ina226_read_tests PRIVATE cxx_std_17) +add_test(NAME ina226_read_tests COMMAND ina226_read_tests) + +add_executable(interface_data_tests tests/test_interface_data.cpp) +target_include_directories(interface_data_tests PRIVATE app) +target_include_directories(interface_data_tests SYSTEM PRIVATE dependencies/lvgl) +target_compile_features(interface_data_tests PRIVATE cxx_std_17) +add_test(NAME interface_data_tests COMMAND interface_data_tests) + +add_executable(boot_health_tests tests/test_boot_health.cpp) +target_include_directories(boot_health_tests PRIVATE app) +target_compile_features(boot_health_tests PRIVATE cxx_std_17) +add_test(NAME boot_health_tests COMMAND boot_health_tests) + +add_executable(ota_transfer_tests tests/test_ota_transfer.cpp) +target_include_directories(ota_transfer_tests PRIVATE app) +target_include_directories(ota_transfer_tests SYSTEM PRIVATE dependencies/lvgl) +target_compile_features(ota_transfer_tests PRIVATE cxx_std_17) +target_link_libraries(ota_transfer_tests PRIVATE pthread) +add_test(NAME ota_transfer_tests COMMAND ota_transfer_tests) + +add_executable(wifi_policy_tests tests/test_wifi_policy.cpp) +target_include_directories(wifi_policy_tests PRIVATE app) +target_include_directories(wifi_policy_tests SYSTEM PRIVATE dependencies/lvgl) +target_compile_features(wifi_policy_tests PRIVATE cxx_std_17) +target_link_libraries(wifi_policy_tests PRIVATE pthread) +add_test(NAME wifi_policy_tests COMMAND wifi_policy_tests) + +foreach(fault RTC_INVALID OTA_FAIL OTA_UNKNOWN_LENGTH) + string(TOLOWER ${fault} fault_name) + add_test(NAME desktop_${fault_name}_ui_tests + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy M5TAB5_DESKTOP_MAX_FRAMES=8 + M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI=1 M5TAB5_DESKTOP_${fault}=1 $) + set_tests_properties(desktop_${fault_name}_ui_tests PROPERTIES TIMEOUT 30) +endforeach() +add_test(NAME desktop_hardware_read_failure_ui_tests + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy M5TAB5_DESKTOP_MAX_FRAMES=8 + M5TAB5_DESKTOP_ASSERT_ALL_APPS_UI=1 M5TAB5_DESKTOP_RTC_FAIL=1 M5TAB5_DESKTOP_INTERFACE_FAIL=1 + $) +set_tests_properties(desktop_hardware_read_failure_ui_tests PROPERTIES TIMEOUT 30) + +# Dependency-fetch regressions use only temporary local Git repositories. +find_package(Python3 REQUIRED COMPONENTS Interpreter) +add_test(NAME fetch_repos_tests COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/test_fetch_repos.py) +set_tests_properties(fetch_repos_tests PROPERTIES TIMEOUT 30) + +# UI journeys assert short callback latency; concurrent full SDL simulations +# measure host CPU contention instead. Keep those journeys mutually exclusive. +get_property(tab5_project_tests DIRECTORY PROPERTY TESTS) +foreach(tab5_test IN LISTS tab5_project_tests) + if(tab5_test MATCHES "^desktop_.*_tests$") + set_tests_properties(${tab5_test} PROPERTIES RESOURCE_LOCK desktop_ui) + endif() +endforeach() 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..997bad25 100644 --- a/README.md +++ b/README.md @@ -2,60 +2,196 @@ User demo source code of [M5Tab5](https://docs.m5stack.com/en/products/sku/k145). -## Build +## Build and verify -### Fetch Dependencies +Use Python 3, Git, CMake 3.25 or newer, Ninja, a C++17 compiler and SDL2 development files. +On Ubuntu, the desktop packages are `build-essential cmake ninja-build libsdl2-dev python3`. +On macOS, install CMake, Ninja and SDL2 with your preferred package manager. -```bash -python ./fetch_repos.py +From the repository root: + +```sh +python3 fetch_repos.py +python3 fetch_repos.py --verify +cmake --preset desktop +cmake --build --preset desktop -j 8 +ctest --preset desktop --output-on-failure +./build/vscode-desktop/app_desktop_build ``` -## Desktop Build +Dependencies are pinned to exact commits in `repos.json`. Fetching reuses matching clean checkouts +and refuses to overwrite local edits or unexpected revisions. `--verify` checks without network access. +Build outputs stay in the selected CMake build directory; old `build/desktop` binaries are not updated. +The VS Code desktop tasks and debugger use the preset output shown above. -#### Tool Chains +For firmware, use [ESP-IDF v5.4.2](https://docs.espressif.com/projects/esp-idf/en/v5.4.2/esp32p4/index.html) +and source that installation's `export.sh`, then run: -```bash -sudo apt install build-essential cmake libsdl2-dev +```sh +idf.py -C platforms/tab5 build ``` -#### Build +The build does not flash hardware. Initial provisioning, partition/bootloader compatibility and device +acceptance are covered in [the release checklist](docs/RELEASE_CHECKLIST.md). The current stabilization +has software evidence only; physical verification is still pending. See [milestones and evidence](docs/STABILIZATION.md). -```bash -mkdir build && cd build -``` +The hosted workflow builds/tests desktop and builds firmware. It does not deploy. Desktop UI journeys +use the dummy SDL driver and simulated peripherals; enable captures with an existing directory in +`M5TAB5_DESKTOP_UI_CAPTURE_DIR`. For the populated SD/network/COM journey: -```bash -cmake .. && make -j8 +```sh +mkdir -p /tmp/tab5-captures +M5TAB5_DESKTOP_UI_CAPTURE_DIR=/tmp/tab5-captures \ + python3 tests/run_populated_ui.py build/vscode-desktop/app_desktop_build ``` -#### Run +## App Architecture -```bash -./desktop/app_desktop_build -``` +The execution, resource ownership and validation contract is documented in [EXTENDING.md](docs/EXTENDING.md). -## IDF Build +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. -#### Tool Chains +App screens should use the shared shell in `app/apps/ui/`: -[ESP-IDF v5.4.2](https://docs.espressif.com/projects/esp-idf/en/v5.4.2/esp32s3/index.html) +- 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. -#### Build +### Add A New App ```bash -cd platforms/tab5 +python app/apps/app_generator.py "Fan Control" ``` -```bash -idf.py build -``` +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()`. -#### Flash +After adding the catalog entry, re-run CMake so new source files are picked up: ```bash -idf.py flash +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. + +Wi-Fi restores validated saved credentials at startup, supports a full 32-byte SSID, and preserves an established +connection while scanning. Failed transitions and connection/DHCP timeouts are visible. Network changes run outside +the UI and are unavailable while an update owns the connection. + +Updates accepts HTTPS URLs only, verifies the server certificate/hostname using the bundled trust store, rejects +redirects, and checks the image's chip, project, size and SDK image validation before selecting it for boot. A valid +RTC/system time is needed for certificate checks. Install and reboot each require confirmation. Progress uses actual +bytes; unknown-length transfers show no invented percentage. Cancel works until final boot selection; Back leaves +the transfer running and reopening Updates restores its status. Desktop uses a simulated image and never flashes or +reboots the host; `M5TAB5_DESKTOP_OTA_FAIL=1` and `M5TAB5_DESKTOP_OTA_UNKNOWN_LENGTH=1` exercise these cases. + +Rollback is enabled in the build. A pending image must demonstrate core UI/input setup and sustained lifecycle/render +progress before it is marked valid. An independent 15-second startup watchdog covers initialization stalls. This is a +startup check, not peripheral acceptance. **Rollback requires the matching rebuilt bootloader to be flashed.** Updating +an app image alone cannot install that bootloader or migrate a partition table. Follow the +[device release gate](docs/STABILIZATION.md) before deploying to existing hardware. + +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. +If capture start/stop cannot prove DMA has stopped, its driver resources are retained and camera restart is disabled +until reboot. Failed scaler release similarly retains its preview buffers. Process teardown waits outside the display +lock for capture cleanup; a timeout prevents HAL destruction. + +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. +Home retains pending/off-unconfirmed warnings after Back. Reopening PPS permits an off retry; only successful +hardware readback clears the off failure. Successful old readback still becomes Unknown when telemetry is stale. + +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. +Ordinary brightness adjustment is limited to 5-100%; zero is reserved for Screen Off. BMI270 initialization reuses +one device handle and serializes driver access. Returned power failures attempt RTC cancellation and restore normal +IMU sampling, reporting rollback failures alongside the original error. + +Tones, music and microphone tests share one codec owner. Stop/Back requests cancellation without waiting on the UI; +errors are visible and task-start failures release the reservation. Audio transfers check driver results in chunks; +the installed Tab5 codec driver has a one-second IO timeout. Power preparation stops all audio with a two-second +deadline; a timeout retains ownership until real completion. The MP3 player remains allocated for the process lifetime +because its vendor delete path does not guarantee its task has stopped before freeing memory. +Playback and capture reconfigure both codec directions together for the bundled 44.1 kHz music and 48 kHz +four-channel recording. Zero volume remains muted across those transitions. The installed codec library discards +some low-level mute/volume write errors, so successful API returns alone do not verify those physical register writes. +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. Smoke journeys also inject a failed off write and verify the warning survives on Home. +Actual off latency, load behaviour, and failed-communication safety still require Tab5/PPS tests. + +RTC, power, IMU, and interface status use cached snapshots with validity and sample age. Failed reads retain the +last successful values and identify them as unavailable/stale; a checked zero remains a valid measurement. Desktop +samples are labelled simulated. Home refreshes these snapshots in a worker and never performs I2C reads while drawing. +RTC offers a confirmed date/time editor for 2000-2099 and verifies the written calendar. Normal setting preserves +wake configuration. An invalid oscillator/stopped clock requires explicit initialization, which clears wake settings; +startup never silently clears the invalid-clock flag. Desktop RTC setting does not change the host clock. + +SD scanning reports mount/read/cleanup errors separately from an empty directory, unmounts after each scan, and +limits results to 256 entries with a visible truncation notice. These software checks do not establish physical card, +RTC backup, sensor accuracy, or board-connection behavior; see [stabilization and acceptance](docs/STABILIZATION.md). + +PPS accepts decimal voltage/current entry up to three places (0.500-30.000 V and 0.000-5.000 A). +This is input precision, not a claim of physical output accuracy. Editing stages values; Apply writes and verifies +setpoints while output is off, and Enable is a separate action. Invalid input blocks Apply/Enable. Emergency Off +remains in the header during keyboard editing and discards the draft before requesting shutdown. + +Home presents six shortcuts without a duplicate summary sidebar. SD results, network scans and COM logs scroll +inside their own panels while Back and primary actions stay fixed. COM keeps the last 2,000 displayed bytes and +follows new data only while viewing the end. UART receive/transmit queues are bounded; rejected messages are +reported, and serial workers finish driver cleanup before HAL destruction. Desktop serial data is simulated echo. + + ## Acknowledgments This project references the following open-source libraries and resources: diff --git a/app/app.cpp b/app/app.cpp index c1eb98bf..1eb3bebe 100644 --- a/app/app.cpp +++ b/app/app.cpp @@ -6,48 +6,150 @@ #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/app_pps/pps_controller.h" +#include "apps/app_settings_power/power_settings_controller.h" +#if defined(PLATFORM_BUILD_DESKTOP) +#include +#endif #include #include #include -#include +#include +#include +#include +#if defined(ESP_PLATFORM) +#include +#include +#include +#endif using namespace mooncake; static const std::string _tag = "app"; +static lv_timer_t* _lifecycleTimer = nullptr; +static uint64_t _renderStartedUs = 0; + +static void updateLifecycle(lv_timer_t*) +{ + const uint32_t started = GetHAL()->millis(); + GetMooncake().update(); + auto& perf = app_runtime::globalPerformanceTelemetry(); + perf.recordAppUpdate(started, GetHAL()->millis()); + perf.recordBackgroundJob(app_runtime::AsyncJobRunner::activeJobs() != 0, + app_runtime::globalAsyncJobRunner().currentJob()); +#if defined(ESP_PLATFORM) + static uint32_t lastResourcesMs = 0; + if (started - lastResourcesMs >= 1000) { + perf.recordResources(heap_caps_get_free_size(MALLOC_CAP_8BIT), + heap_caps_get_minimum_free_size(MALLOC_CAP_8BIT), + uxTaskGetStackHighWaterMark(nullptr), uxTaskGetNumberOfTasks()); + lastResourcesMs = started; + } +#endif +} + +static void displayEvent(lv_event_t* event) +{ + const auto now = app_runtime::monotonicMicros(); + auto& perf = app_runtime::globalPerformanceTelemetry(); + switch (lv_event_get_code(event)) { + case LV_EVENT_RENDER_START: _renderStartedUs = now; break; + case LV_EVENT_RENDER_READY: perf.recordRender(_renderStartedUs, now); break; + case LV_EVENT_FLUSH_FINISH: perf.recordFlush(now); break; + default: break; + } +} void app::Init(InitCallback_t callback) { mclog::tagInfo(_tag, "init"); +#if defined(PLATFORM_BUILD_DESKTOP) + desktop_smoke::configure([] { + LvglLockGuard lock; + lv_timer_ready(_lifecycleTimer); + lv_timer_handler(); + }); +#endif mclog::tagInfo(_tag, "hal injection"); if (callback.onHalInjection) { callback.onHalInjection(); } + app_runtime::AsyncJobRunner::resumeAll(); + GetHAL()->resumeNetwork(); + GetHAL()->wifiRestoreSavedConnection(); + // Installation and timer registration are atomic with respect to the display task. + // Thereafter all normal lifecycle transitions execute from this LVGL timer. + LvglLockGuard lock; GetMooncake(); - - on_startup_anim(); on_install_apps(); + _lifecycleTimer = lv_timer_create(updateLifecycle, 16, nullptr); + lv_timer_ready(_lifecycleTimer); + lv_display_add_event_cb(lv_display_get_default(), displayEvent, LV_EVENT_ALL, nullptr); + for (auto* input = lv_indev_get_next(nullptr); input; input = lv_indev_get_next(input)) { + lv_indev_add_event_cb(input, [](lv_event_t*) { + app_runtime::globalPerformanceTelemetry().recordInput(app_runtime::monotonicMicros()); + }, LV_EVENT_PRESSED, nullptr); + } } void app::Update() { - GetMooncake().update(); +#if defined(PLATFORM_BUILD_DESKTOP) + // SDL and lifecycle callbacks share the main thread on every desktop platform. + uint32_t next; + { + LvglLockGuard lock; + const auto started = GetHAL()->millis(); + next = lv_timer_handler(); + app_runtime::globalPerformanceTelemetry().recordLvglHandler(started, GetHAL()->millis()); + } + GetHAL()->delay(std::min(next, 16)); +#endif -#if defined(__APPLE__) && defined(__MACH__) - // 'nextEventMatchingMask should only be called from the Main Thread!' - auto time_till_next = lv_timer_handler(); - std::this_thread::sleep_for(std::chrono::milliseconds(time_till_next)); +#if defined(PLATFORM_BUILD_DESKTOP) + desktop_smoke::afterUpdate(); #endif } bool app::IsDone() { +#if defined(PLATFORM_BUILD_DESKTOP) + return desktop_smoke::isDone(); +#else return false; +#endif } void app::Destroy() { - DestroyMooncake(); + { + LvglLockGuard lock; + if (_lifecycleTimer) lv_timer_delete(_lifecycleTimer); + _lifecycleTimer = nullptr; + DestroyMooncake(); + } + GetHAL()->audioCancel(); + if (!GetHAL()->shutdownCamera(5000)) { + mclog::tagError(_tag, "Camera shutdown timed out: refusing to destroy HAL while capture is active"); + std::abort(); + } + app_pps::PpsController::shutdown(); + app_settings_power::PowerSettingsController::shutdown(); + if (!GetHAL()->stopNetworkAndWait(15000)) { + mclog::tagError(_tag, "Network shutdown timed out: refusing to destroy active update resources"); + std::abort(); + } + if (!GetHAL()->stopUartAndWait(2000)) { + mclog::tagError(_tag, "UART shutdown timed out: refusing to destroy active serial resources"); + std::abort(); + } + if (!app_runtime::AsyncJobRunner::shutdownAll(30000)) { + mclog::tagError(_tag, "Shutdown timed out: refusing to destroy HAL while jobs still use it"); + std::abort(); + } hal::Destroy(); } diff --git a/app/apps/app_catalog.cpp b/app/apps/app_catalog.cpp new file mode 100644 index 00000000..0517f4a3 --- /dev/null +++ b/app/apps/app_catalog.cpp @@ -0,0 +1,306 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_catalog.h" +#include "app_launcher/home_status.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_pps/pps_controller.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(); }, + [] { + return launcher_view::clockStatus(GetHAL()->getRtcData(), GetHAL()->millis()); + }}); + 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(); }, + [] { + return launcher_view::powerStatus(GetHAL()->getPowerMonitorData(), GetHAL()->millis()); + }}); + apps.add({AppKey::Switches, + AppCategory::Hardware, + "Switches", + "Power rails and interface toggles", + "", + LV_SYMBOL_USB, + 20, + 90, + false, + true, + [] { return app_hardware_switches::makeSwitchesApp(); }, + [] { + const auto data = GetHAL()->getInterfaceData(); + if (!data.valid || !data.hasSample || !data.usbCKnown || GetHAL()->millis() - data.sampledAtMs > 2000) + return AppStatus{"Ports unknown", StatusLevel::Warning}; + return AppStatus{std::string(data.simulated ? "Sim " : "") + + (data.usbC ? "USB-C connected" : "USB-C absent"), 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(); }, + [] { + const auto data = GetHAL()->getInterfaceData(); + if (!data.valid || !data.hasSample || !data.headphoneKnown || GetHAL()->millis() - data.sampledAtMs > 2000) + return AppStatus{"Detection unknown", StatusLevel::Warning}; + return AppStatus{std::string(data.simulated ? "Sim " : "") + + (data.headphone ? "Detected" : "Not detected"), 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", + "Scan SD card files", + "Open to scan", + LV_SYMBOL_SD_CARD, + 10, + 50, + false, + true, + [] { return app_hardware_sd::makeSdCardApp(); }, + StatusProvider{}}); + 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(); }, + [] { + auto state = app_pps::PpsController::backgroundState(); + if (!state) return AppStatus{"Not checked", StatusLevel::Neutral}; + const auto level = state->off == app_pps::OffState::Unconfirmed ? StatusLevel::Error : + state->off == app_pps::OffState::Pending || state->output == app_pps::OutputState::Unknown + ? StatusLevel::Warning : StatusLevel::Neutral; + return AppStatus{app_pps::PpsController::outputSummary(*state), level}; + }}); +} 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..0879fdea --- /dev/null +++ b/app/apps/app_diagnostics/app_diagnostics.cpp @@ -0,0 +1,151 @@ +/* + * 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, "Render"); + _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, + "Input timing ends at the flush callback, not physical panel presentation. Lock counters cover HAL callers. Heap and stack are device-only.", + 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, "Render", "%ums max %ums", static_cast(perf.lastRenderUs / 1000), + static_cast(perf.maxRenderUs / 1000)); + 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, + "UI updates: %u Max: %ums Renders: %u\n" + "Input-to-flush samples: %u p95: %ums Max: %ums\n" + "HAL lock wait/hold max: %u / %u us\n" + "Device heap free/min: %u / %u bytes\n" + "Device UI stack headroom: %u bytes Tasks: %u", + static_cast(perf.totalAppUpdates), static_cast(perf.maxAppUpdateMs), + static_cast(perf.renderedFrames), static_cast(perf.inputSamples), + static_cast(perf.p95InputToFlushUs / 1000), + static_cast(perf.maxInputToFlushUs / 1000), + static_cast(perf.maxLockWaitUs), static_cast(perf.maxLockHoldUs), + static_cast(perf.freeHeap), static_cast(perf.minimumHeap), + static_cast(perf.uiStackHeadroom), static_cast(perf.taskCount)); + } + } + + 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..e83aca1e --- /dev/null +++ b/app/apps/app_hardware_com_monitor/app_com_monitor.cpp @@ -0,0 +1,150 @@ +/* + * 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) { return 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(); + _log = _status = _logViewport = nullptr; + } + +private: + ComMonitorController _controller; + std::unique_ptr _shell; + lv_obj_t* _log = nullptr; + lv_obj_t* _status = nullptr; + lv_obj_t* _logViewport = nullptr; + 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); + _logViewport = app_ui::makeScrollableContent(panel, layout.logText.x, layout.logText.y, + layout.logText.w, layout.logText.h); + const auto& retained = _controller.logText(); + _log = app_ui::makeLabel(_logViewport, retained.empty() ? "No data received." : retained.c_str(), + 0, 0, &lv_font_montserrat_18, app_ui::theme::color(app_ui::theme::muted)); + lv_obj_set_width(_log, layout.logText.w - 20); + lv_label_set_long_mode(_log, LV_LABEL_LONG_WRAP); + lv_obj_update_layout(_logViewport); + lv_obj_scroll_to_y(_logViewport, lv_obj_get_scroll_bottom(_logViewport), LV_ANIM_OFF); + + 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() && _log) { + lv_obj_update_layout(_logViewport); + const bool followTail = lv_obj_get_scroll_bottom(_logViewport) <= 4; + lv_label_set_text(_log, _controller.logText().c_str()); + lv_obj_update_layout(_logViewport); + if (followTail) lv_obj_scroll_to_y(_logViewport, + lv_obj_get_scroll_y(_logViewport) + lv_obj_get_scroll_bottom(_logViewport), LV_ANIM_OFF); + } + if (_status) { + if (!sample.error.empty()) lv_label_set_text_fmt(_status, "UART unavailable: %s", sample.error.c_str()); + else if (!sample.text.empty()) lv_label_set_text_fmt(_status, "%sReceived %u bytes. Last %u bytes; RX dropped %u.", + sample.simulated ? "Simulation: " : "", static_cast(sample.bytesRead), + static_cast(ComMonitorController::kLogBytes), static_cast(sample.dropped)); + else if (sample.simulated) lv_label_set_text(_status, "Simulation: showing retained RX log."); + } + } + + void sendTestMessage() + { + audio::play_next_tone_progression(); + const bool accepted = _controller.sendTestMessage(); + if (_status != nullptr) { + lv_label_set_text(_status, accepted ? "Queued test message with newline." : "UART unavailable or transmit queue full."); + } + } + + 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..18e479ac --- /dev/null +++ b/app/apps/app_hardware_com_monitor/com_monitor_controller.cpp @@ -0,0 +1,41 @@ +/* + * 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); + sample.simulated = data.simulated; + sample.error = data.error; + sample.dropped = data.rxDropped; + while (!data.rxQueue.empty() && sample.text.size() < std::min(maxBytes, hal::UartMonitorState::kCapacity)) { + sample.text.push_back(static_cast(data.rxQueue.front())); + data.rxQueue.pop(); + } + } + sample.bytesRead = sample.text.size(); + _logText += sample.text; + if (_logText.size() > kLogBytes) _logText.erase(0, _logText.size() - kLogBytes); + return sample; +} + +bool ComMonitorController::sendTestMessage() +{ + return _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..8602b44c --- /dev/null +++ b/app/apps/app_hardware_com_monitor/com_monitor_controller.h @@ -0,0 +1,41 @@ +/* + * 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; + size_t dropped = 0; + bool simulated = false; + std::string error; +}; + +class ComMonitorController { +public: + using DataFn = std::function; + using SendFn = std::function; + + ComMonitorController(DataFn dataFn, SendFn sendFn); + + ComMonitorSample drainRx(size_t maxBytes = 800); + bool sendTestMessage(); + const std::string& logText() const { return _logText; } + static constexpr size_t kLogBytes = 2000; + +private: + DataFn _dataFn; + SendFn _sendFn; + std::string _logText; +}; + +} // 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..c1ff3ef1 --- /dev/null +++ b/app/apps/app_hardware_imu/app_imu.cpp @@ -0,0 +1,134 @@ +/* + * 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()->getImuData(); }) + { + 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(); + lv_label_set_text(_detail, ImuController::statusText(sample, GetHAL()->millis()).c_str()); + setMetric(_accelX, "Accel X", sample.hasSample ? ImuController::formatAccel(sample.accelX).c_str() : "--"); + setMetric(_accelY, "Accel Y", sample.hasSample ? ImuController::formatAccel(sample.accelY).c_str() : "--"); + setMetric(_accelZ, "Accel Z", sample.hasSample ? ImuController::formatAccel(sample.accelZ).c_str() : "--"); + setMetric(_gyroX, "Gyro X", sample.hasSample ? ImuController::formatGyro(sample.gyroX).c_str() : "--"); + setMetric(_gyroY, "Gyro Y", sample.hasSample ? ImuController::formatGyro(sample.gyroY).c_str() : "--"); + setMetric(_gyroZ, "Gyro Z", sample.hasSample ? 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..33e2a639 --- /dev/null +++ b/app/apps/app_hardware_imu/imu_controller.cpp @@ -0,0 +1,56 @@ +/* + * 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 +{ + const auto update = _updateFn; + const auto dataFn = _dataFn; + if (!update || !dataFn) { + return cached(); + } + return _samples.request("IMU sample", [update, dataFn] { + update(); + ImuSample value; + static_cast(value) = dataFn(); + return value; + }); +} + +ImuSample ImuController::cached() const +{ + return _samples.cached(); +} + +std::string ImuController::statusText(const ImuSample& sample, uint32_t nowMs) +{ + const auto source = sample.simulated ? "Simulated" : "Hardware"; + if (!sample.hasSample) return sample.error.empty() ? "Waiting for first IMU sample..." : "Unavailable: " + sample.error; + if (!sample.valid || nowMs - sample.sampledAtMs > 2000) + return std::string(source) + " - stale" + (sample.error.empty() ? "" : ": " + sample.error); + return std::string(source) + " - up to 4 Hz while open"; +} + +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..f0fc8a97 --- /dev/null +++ b/app/apps/app_hardware_imu/imu_controller.h @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include +#include + +namespace app_hardware_imu { + +struct ImuSample : hal::HalBase::IMUData_t { + bool pending = false; +}; + +class ImuController { +public: + using UpdateFn = std::function; + using DataFn = std::function; + + ImuController(UpdateFn updateFn, DataFn dataFn); + + ImuSample sample() const; + ImuSample cached() const; + + static std::string statusText(const ImuSample& sample, uint32_t nowMs); + static std::string formatAccel(float value); + static std::string formatGyro(float value); + +private: + UpdateFn _updateFn; + DataFn _dataFn; + app_runtime::AsyncSample _samples; +}; + +} // 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..d56b503e --- /dev/null +++ b/app/apps/app_hardware_power_monitor/app_power_monitor.cpp @@ -0,0 +1,143 @@ +/* + * 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(); }, + [](int& value) { return GetHAL()->readCpuTemp(value); }) + { + 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) { + const auto status = PowerMonitorController::statusText(sample, GetHAL()->millis()); + char shunt[48] = {}; + if (sample.hasSample) std::snprintf(shunt, sizeof(shunt), "Shunt: %.3f V\n", sample.shuntVoltage); + const auto detail = std::string(shunt) + status + (sample.cpuTempValid ? "" : "\nCPU temperature unavailable"); + lv_label_set_text(_detail, detail.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_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..e07ecb98 --- /dev/null +++ b/app/apps/app_hardware_power_monitor/power_monitor_controller.cpp @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "power_monitor_controller.h" + +#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() +{ + const auto update = _updateFn; + const auto dataFn = _dataFn; + const auto cpuTemp = _cpuTempFn; + if (!update || !dataFn || !cpuTemp) { + return cached(); + } + return _samples.request("power monitor sample", [update, dataFn, cpuTemp] { + update(); + const auto data = dataFn(); + PowerSample value; + value.valid = data.valid; + value.hasSample = data.hasSample; + value.simulated = data.simulated; + value.sampledAtMs = data.sampledAtMs; + value.error = data.error; + value.busVoltage = data.busVoltage; + value.current = data.shuntCurrent; + value.shuntVoltage = data.shuntVoltage; + value.outputPower = data.busPower; + value.cpuTempValid = cpuTemp(value.cpuTempC); + return value; + }); +} + +PowerSample PowerMonitorController::cached() const +{ + return _samples.cached(); +} + +std::string PowerMonitorController::statusText(const PowerSample& sample, uint32_t nowMs) +{ + const auto source = sample.simulated ? "Simulated" : "Hardware"; + if (!sample.hasSample) return sample.error.empty() ? "Waiting for first power sample..." : "Unavailable: " + sample.error; + if (!sample.valid || nowMs - sample.sampledAtMs > 2000) + return std::string(source) + " - stale" + (sample.error.empty() ? "" : ": " + sample.error); + return std::string(source) + " - up to 4 Hz while open"; +} + +std::string PowerMonitorController::formatVoltage(const PowerSample& sample) +{ + if (!sample.hasSample) { + return "--"; + } + return format("%.2f V", sample.busVoltage); +} + +std::string PowerMonitorController::formatCurrent(const PowerSample& sample) +{ + if (!sample.hasSample) { + return "--"; + } + return format("%.3f A", sample.current); +} + +std::string PowerMonitorController::formatPower(const PowerSample& sample) +{ + if (!sample.hasSample) { + return "--"; + } + return format("%.2f W", sample.outputPower); +} + +std::string PowerMonitorController::formatCpuTemp(const PowerSample& sample) +{ + if (!sample.cpuTempValid) { + return "--"; + } + 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..bc79f682 --- /dev/null +++ b/app/apps/app_hardware_power_monitor/power_monitor_controller.h @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include +#include + +namespace app_hardware_power_monitor { + +struct PowerSample { + bool hasSample = false; + bool valid = false; + bool simulated = false; + uint32_t sampledAtMs = 0; + std::string error; + bool pending = false; + float busVoltage = 0.0f; + float current = 0.0f; + float outputPower = 0.0f; + float shuntVoltage = 0.0f; + int cpuTempC = 0; + bool cpuTempValid = false; +}; + +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 statusText(const PowerSample& sample, uint32_t nowMs); + 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; + app_runtime::AsyncSample _samples; +}; + +} // 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..c6b3414e --- /dev/null +++ b/app/apps/app_hardware_rtc/app_rtc.cpp @@ -0,0 +1,177 @@ +/* + * 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([] { GetHAL()->updateRtcData(); }, [] { return GetHAL()->getRtcData(); }, + [](const tm& time, bool initialize) { return GetHAL()->setRtcTimeChecked(time, initialize); }) + { + setAppInfo().name = "RTC"; + } + + void onOpen() override + { + _seeded = false; + _editorError.clear(); + _shell = std::make_unique("RTC", [&]() { close(); }); + _shell->init(); + build(); + _controller.sample(); + } + + void onRunning() override + { + const uint32_t now = GetHAL()->millis(); + if (now - _lastUpdateMs < 100) return; + if (now - _lastPollMs >= 500) { _controller.sample(); _lastPollMs = now; } + 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, _lastPollMs = 0; + lv_obj_t* _dateInput = nullptr; + lv_obj_t* _timeInput = nullptr; + lv_obj_t* _setButton = nullptr; + bool _seeded = false; + std::string _editorError; + + lv_obj_t* content() const + { + return _shell->content(); + } + + void build() + { + LvglLockGuard lock; + const auto layout = makeRtcLayout(); + auto* description = app_ui::makeLabel(content(), "Read and set the clock. Date: YYYY-MM-DD; time: HH:MM:SS (2000-2099).", 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); + + _dateInput = app_ui::makeTextArea(content(), "YYYY-MM-DD", layout.dateInput.x, layout.dateInput.y, + layout.dateInput.w, layout.dateInput.h); + _timeInput = app_ui::makeTextArea(content(), "HH:MM:SS", layout.timeInput.x, layout.timeInput.y, + layout.timeInput.w, layout.timeInput.h); + _shell->bindTextInput(_dateInput); + _shell->bindTextInput(_timeInput); + _setButton = app_ui::makeButton(content(), "Set clock", layout.setButton.x, layout.setButton.y, + layout.setButton.w, layout.setButton.h, app_ui::ButtonKind::Primary); + lv_obj_add_event_cb(_setButton, [](lv_event_t* event) { + static_cast(lv_event_get_user_data(event))->requestSet(); + }, LV_EVENT_CLICKED, this); + + 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 Status", 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 requestSet() + { + tm desired{}; + if (!RtcController::parseEditor(lv_textarea_get_text(_dateInput), lv_textarea_get_text(_timeInput), desired)) { + _editorError = "Invalid date/time. Use YYYY-MM-DD and HH:MM:SS, years 2000-2099."; + refresh(); return; + } + _editorError.clear(); + const bool initialize = _controller.cached().needsInitialization; + const std::string title = initialize ? "Initialize RTC and set time?" : "Set clock?"; + std::string message = initialize ? "Initialize RTC and set time (clears wake configuration). Alarm and timed wake settings will be cleared." : + "Set the hardware clock. Wake settings are preserved; the seconds divider phase resets."; + message += "\n\n" + std::string(lv_textarea_get_text(_dateInput)) + " " + lv_textarea_get_text(_timeInput); + if (_controller.cached().simulated) message += "\nThis desktop clock is simulated; the host clock stays unchanged."; + _shell->confirm(title, message, [this, desired, initialize] { + if (!_controller.setClock(desired, initialize)) _editorError = "RTC busy or unavailable; retry after the current operation."; + refresh(); + }); + } + + void refresh() + { + const auto sample = _controller.cached(); + setMetric(_time, "Current Time", RtcController::formatTime(sample).c_str()); + setMetric(_date, "Date", RtcController::formatDate(sample).c_str()); + if (!_seeded && sample.hasSample && lv_textarea_get_text(_dateInput)[0] == '\0' && + lv_textarea_get_text(_timeInput)[0] == '\0') { + lv_textarea_set_text(_dateInput, RtcController::formatDate(sample).c_str()); + lv_textarea_set_text(_timeInput, RtcController::formatTime(sample).c_str()); + _seeded = true; + } + if (sample.pending) lv_obj_add_state(_setButton, LV_STATE_DISABLED); + else lv_obj_remove_state(_setButton, LV_STATE_DISABLED); + std::string detail = RtcController::statusText(sample, GetHAL()->millis()); + if (sample.hasSample) detail += " | " + RtcController::formatWeekday(sample); + if (sample.pending) detail += " | Working..."; + if (!sample.commandMessage.empty()) detail += "\n" + sample.commandMessage; + if (!_editorError.empty()) detail += "\n" + _editorError; + if (sample.hasSample && !sample.valid) detail += "\nDisplayed values are the last successful sample."; + if (sample.needsInitialization) detail += "\nSetting the clock requires confirmed initialization and clears wake configuration."; + lv_label_set_text(_detail, detail.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..0390361f --- /dev/null +++ b/app/apps/app_hardware_rtc/rtc_controller.cpp @@ -0,0 +1,103 @@ +#include "rtc_controller.h" +#include +#include +#include +#include + +using namespace app_hardware_rtc; +RtcController::RtcController(UpdateFn update, DataFn data, SetFn set) + : _update(std::move(update)), _data(std::move(data)), _set(std::move(set)) {} + +RtcSample RtcController::cached() const +{ + std::lock_guard lock(_state->mutex); + return _state->sample; +} +RtcSample RtcController::sample() const +{ + const auto state = _state; + { + std::lock_guard lock(state->mutex); + if (state->sample.pending || !_update || !_data) return state->sample; + state->sample.pending = true; + } + auto update = _update; auto data = _data; + app_runtime::AsyncJobRunner runner; + if (!runner.start("RTC read", [state, update, data] { + update(); + auto result = data(); + std::lock_guard lock(state->mutex); + static_cast(state->sample) = std::move(result); + state->sample.pending = false; + })) { + std::lock_guard lock(state->mutex); + state->sample.pending = false; + } + return cached(); +} +bool RtcController::setClock(const tm& time, bool initialize) +{ + if (!rtc::validCalendar(time) || !_set || !_data) return false; + const auto state = _state; + { + std::lock_guard lock(state->mutex); + if (state->sample.pending) return false; + state->sample.pending = true; + state->sample.commandMessage = "Setting clock..."; + } + auto set = _set; auto data = _data; + app_runtime::AsyncJobRunner runner; + if (!runner.start("RTC set", [state, set, data, time, initialize] { + const bool ok = set(time, initialize); + auto result = data(); + std::lock_guard lock(state->mutex); + static_cast(state->sample) = std::move(result); + state->sample.pending = false; + state->sample.commandMessage = ok ? "Clock set and readback verified" : "Clock set failed"; + })) { + std::lock_guard lock(state->mutex); + state->sample.pending = false; + state->sample.commandMessage = "RTC worker unavailable"; + return false; + } + return true; +} +bool RtcController::parseEditor(const std::string& date, const std::string& time, tm& result) +{ + if (date.size() != 10 || time.size() != 8 || date[4] != '-' || date[7] != '-' || time[2] != ':' || time[5] != ':') return false; + for (size_t i = 0; i < date.size(); ++i) + if (i != 4 && i != 7 && !std::isdigit(static_cast(date[i]))) return false; + for (size_t i = 0; i < time.size(); ++i) + if (i != 2 && i != 5 && !std::isdigit(static_cast(time[i]))) return false; + tm parsed{}; int year = 0, month = 0; + if (std::sscanf(date.c_str(), "%d-%d-%d", &year, &month, &parsed.tm_mday) != 3 || + std::sscanf(time.c_str(), "%d:%d:%d", &parsed.tm_hour, &parsed.tm_min, &parsed.tm_sec) != 3) return false; + parsed.tm_year = year - 1900; parsed.tm_mon = month - 1; parsed.tm_isdst = -1; + if (!rtc::validCalendar(parsed)) return false; + parsed.tm_wday = rtc::calendarWeekday(parsed); + result = parsed; + return true; +} +std::string RtcController::statusText(const RtcSample& sample, uint32_t nowMs) +{ + if (!sample.valid) return sample.error.empty() ? "RTC unavailable" : sample.error; + if (!sample.hasSample || nowMs - sample.sampledAtMs > 2000) return "RTC sample stale"; + return sample.simulated ? "Simulated clock" : "Hardware RTC"; +} +std::string RtcController::formatTime(const RtcSample& sample) +{ + if (!sample.hasSample) return "--:--:--"; + char buffer[24]; 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) +{ + if (!sample.hasSample) return "---- -- --"; + char buffer[32]; 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) +{ + static const char* names[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; + return sample.hasSample && sample.time.tm_wday >= 0 && sample.time.tm_wday < 7 ? names[sample.time.tm_wday] : "Weekday unavailable"; +} 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..bb8fa5d5 --- /dev/null +++ b/app/apps/app_hardware_rtc/rtc_controller.h @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace app_hardware_rtc { +struct RtcSample : hal::HalBase::RtcData { + bool pending = false; + std::string commandMessage; +}; +class RtcController { +public: + using UpdateFn = std::function; + using DataFn = std::function; + using SetFn = std::function; + RtcController(UpdateFn update, DataFn data, SetFn set); + RtcSample sample() const; // Dispatch a read; never wait on I/O. + RtcSample cached() const; + bool setClock(const tm& time, bool initialize); + static bool parseEditor(const std::string& date, const std::string& time, tm& result); + static std::string statusText(const RtcSample& sample, uint32_t nowMs); + static std::string formatTime(const RtcSample& sample); + static std::string formatDate(const RtcSample& sample); + static std::string formatWeekday(const RtcSample& sample); +private: + struct State { std::mutex mutex; RtcSample sample; }; + UpdateFn _update; + DataFn _data; + SetFn _set; + std::shared_ptr _state = std::make_shared(); +}; +} // 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..28b20004 --- /dev/null +++ b/app/apps/app_hardware_rtc/rtc_layout.h @@ -0,0 +1,25 @@ +#pragma once +#include +namespace app_hardware_rtc { +struct RtcLayout { + app_ui::Rect content, description, timeCard, dateCard; + int cardGap = 24; + app_ui::Rect dateInput, timeInput, setButton, sourcePanel, sourceTitle, 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, 52, 360, 124}; + layout.dateCard = {384, 52, 360, 124}; + layout.dateInput = {0, 208, 360, 64}; + layout.timeInput = {384, 208, 360, 64}; + layout.setButton = {776, 208, 336, 64}; + layout.sourcePanel = {0, 304, 1112, 208}; + layout.sourceTitle = {0, 0, 1080, 30}; + layout.sourceDetail = {0, 44, 1080, 132}; + 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..1d56eea0 --- /dev/null +++ b/app/apps/app_hardware_sd/app_sd_card.cpp @@ -0,0 +1,139 @@ +/* + * 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([](const std::string& path) { return GetHAL()->scanSdCardResult(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(); + _status = _scanButton = _results = nullptr; + } + +private: + SdCardController _controller; + std::unique_ptr _shell; + lv_obj_t* _status = nullptr; + lv_obj_t* _scanButton = nullptr; + lv_obj_t* _results = nullptr; + std::string _renderedText; + 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(), "Mount and scan the 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); + _scanButton = 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(_scanButton, 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); + _results = app_ui::makeScrollableContent(panel, layout.resultsText.x, layout.resultsText.y, + layout.resultsText.w, layout.resultsText.h); + _renderedText = _controller.statusText(); + _status = app_ui::makeLabel(_results, _renderedText.c_str(), 0, 0, &lv_font_montserrat_18, + app_ui::theme::color(app_ui::theme::text)); + lv_obj_set_width(_status, layout.resultsText.w - 20); + lv_label_set_long_mode(_status, LV_LABEL_LONG_WRAP); + refreshView(); + } + + void startScan() + { + audio::play_next_tone_progression(); + _controller.startScan(); + refreshView(); + } + + void refreshView() + { + const bool busy = _controller.busy(); + if (_scanButton) { + if (busy) lv_obj_add_state(_scanButton, LV_STATE_DISABLED); + else lv_obj_remove_state(_scanButton, LV_STATE_DISABLED); + } + if (_status) { + const auto text = _controller.statusText(); + if (text != _renderedText) { + _renderedText = text; + lv_label_set_text(_status, text.c_str()); + lv_obj_scroll_to_y(_results, 0, LV_ANIM_OFF); + } + lv_obj_set_style_text_color(_status, 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..ed67841e --- /dev/null +++ b/app/apps/app_hardware_sd/sd_card_controller.cpp @@ -0,0 +1,108 @@ +/* + * 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(ScanFn scanFn) + : _scanFn(std::move(scanFn)) +{ +} + +bool SdCardController::startScan() +{ + { + std::lock_guard lock(_mutex); + if (_busy) return false; + if (!_scanFn) { + _statusText = "SD card scan is unavailable."; + return false; + } + _busy = true; // Reserve locally before another caller can submit a scan. + } + + auto state = std::make_shared(); + auto scanFn = _scanFn; + if (!app_runtime::globalAsyncJobRunner().start("SD card scan", [state, scanFn]() { + auto text = SdCardController::formatResult(scanFn("/")); + 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); + if (_scanState == state) { + _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(const hal::HalBase::SdScanResult& result) +{ + using Status = hal::HalBase::SdScanStatus; + const std::string source = result.simulated ? "Desktop simulation: " : ""; + switch (result.status) { + case Status::MountFailed: return source + "SD card mount failed. " + result.error; + case Status::ReadFailed: return source + "SD card scan failed. " + result.error; + case Status::Unavailable: return source + "SD card scan unavailable. " + result.error; + case Status::Success: break; + } + if (result.entries.empty()) return source + "SD card root is empty."; + std::ostringstream out; + out << source << "SD card root:"; + for (const auto& file : result.entries) out << "\n" << (file.isDir ? "[DIR] " : " ") << file.name; + if (result.truncated) out << "\nShowing first " << hal::HalBase::kMaxSdEntries << " entries."; + 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..e3f86fc6 --- /dev/null +++ b/app/apps/app_hardware_sd/sd_card_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_sd { + +class SdCardController { +public: + using ScanFn = std::function; + + explicit SdCardController(ScanFn scanFn); + + bool startScan(); + void refresh(); + bool busy() const; + std::string statusText() const; + + static std::string formatResult(const hal::HalBase::SdScanResult& result); + +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_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..7f372838 --- /dev/null +++ b/app/apps/app_hardware_switches/app_switches.cpp @@ -0,0 +1,191 @@ +/* + * 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; + } + }, + [] { + return GetHAL()->getInterfaceData(); + }) + { + 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..f9cd4946 --- /dev/null +++ b/app/apps/app_hardware_switches/switches_controller.cpp @@ -0,0 +1,46 @@ +/* + * 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) +{ + const bool available = snapshot.valid && snapshot.hasSample; + const auto label = [available](bool known, bool connected) { + return !available || !known ? "unknown" : connected ? "connected" : "off"; + }; + char buffer[160]; + std::snprintf(buffer, sizeof(buffer), "%sUSB-C: %s USB-A: %s Headphone: %s%s", + snapshot.simulated ? "Simulated: " : "", + label(snapshot.usbCKnown, snapshot.usbC), label(snapshot.usbAKnown, snapshot.usbA), + label(snapshot.headphoneKnown, snapshot.headphone), + !snapshot.valid && snapshot.hasSample ? " (stale)" : ""); + 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..2127b077 --- /dev/null +++ b/app/apps/app_hardware_switches/switches_controller.h @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#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; +}; + +using InterfaceSnapshot = hal::HalBase::InterfaceData; + +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/home_status.h b/app/apps/app_launcher/home_status.h new file mode 100644 index 00000000..291a2131 --- /dev/null +++ b/app/apps/app_launcher/home_status.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include + +namespace launcher_view { +inline app_registry::AppStatus powerStatus(const hal::HalBase::PMData_t& data, uint32_t nowMs) +{ + using app_registry::StatusLevel; + if (!data.hasSample) return {data.error.empty() ? "Not sampled" : "Read failed", StatusLevel::Warning}; + if (!data.valid) return {"Read failed; last good", StatusLevel::Warning}; + if (nowMs - data.sampledAtMs > 2000) return {"Stale reading", StatusLevel::Warning}; + char value[48]; + std::snprintf(value, sizeof(value), "%s%.2fV %.2fW", data.simulated ? "Sim " : "", data.busVoltage, data.busPower); + return {value, data.simulated ? StatusLevel::Neutral : StatusLevel::Ok}; +} + +inline app_registry::AppStatus clockStatus(const hal::HalBase::RtcData& data, uint32_t nowMs) +{ + using app_registry::StatusLevel; + if (data.needsInitialization) return {"Clock invalid", StatusLevel::Warning}; + if (!data.hasSample || !data.valid) return {"Clock unavailable", StatusLevel::Warning}; + if (nowMs - data.sampledAtMs > 2000) return {"Clock stale", StatusLevel::Warning}; + char value[24]; + std::snprintf(value, sizeof(value), "%s%02d:%02d", data.simulated ? "Sim " : "", data.time.tm_hour, data.time.tm_min); + return {value, StatusLevel::Neutral}; +} +} // namespace launcher_view 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..5289aead --- /dev/null +++ b/app/apps/app_launcher/view/launcher_layout.h @@ -0,0 +1,98 @@ +/* + * 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 headerTitle; + app_ui::Rect headerStatus; + app_ui::Rect headerTime; + 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 = 308; + int tileH = 184; + int tileGap = 18; + int tileColumns = 3; +}; + +inline LauncherLayout makeLauncherLayout() +{ + auto screen = app_ui::screenRect(); + auto safe = app_ui::safeContentRect(); + constexpr int railW = 210; + constexpr int gap = 20; + const auto columns = app_ui::makeColumns(safe, {railW, 0}, gap); + + LauncherLayout layout; + layout.screen = screen; + layout.safe = safe; + layout.topBar = {0, 0, screen.w, 72}; + layout.headerTitle = {24, 20, 224, 32}; + layout.headerStatus = {280, 25, 650, 24}; + layout.headerTime = {962, 25, 294, 24}; + layout.categoryRail = columns.size() == 2 ? columns[0] : app_ui::Rect{safe.x, safe.y, railW, safe.h}; + layout.tileArea = columns.size() == 2 ? columns[1] : app_ui::Rect{safe.x + railW + gap, safe.y, 0, safe.h}; + layout.columnGap = gap; + layout.tileColumns = 3; + layout.tileGap = 18; + layout.tileGridX = 20; + layout.tileGridY = 62; + constexpr int tileRightMargin = 20; + layout.tileW = (layout.tileArea.w - layout.tileGridX - tileRightMargin - (layout.tileGap * (layout.tileColumns - 1))) / + layout.tileColumns; + layout.tileH = 184; + layout.tileIcon = {16, 16, 48, 48}; + layout.tileTitle = {78, 25, layout.tileW - 94, 28}; + layout.tileDescription = {16, 82, layout.tileW - 32, 48}; + layout.tileStatus = {16, 148, 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, + }; +} + +} // 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