From 76434d5bcd6246def97f16f153a0ae159d7145e4 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 16 Oct 2026 23:27:30 +0200 Subject: [PATCH] App Hub, Setup, BT, tt, posix http, and more (#697) --- .claude/rules/lvgl.md | 2 + .claude/rules/memory.md | 23 + .github/workflows/build.yml | 15 + Buildscripts/CDN/tactility.py | 2 +- Buildscripts/TactilitySDK/CMakeLists.txt | 1 + .../TactilitySDK/TactilitySDK.esp32.cmake | 74 ++++ .../TactilitySDK/TactilitySDK.posix.cmake | 1 + Buildscripts/TactilitySDK/Tools/_bootstrap.py | 20 +- Buildscripts/TactilitySDK/Tools/app.py | 31 +- Buildscripts/TactilitySDK/Tools/screenshot.py | 41 ++ Buildscripts/TactilitySDK/Tools/shell.py | 15 + Buildscripts/TactilitySDK/Tools/update-sdk.py | 111 +++-- .../TactilitySDK/Tools/update-self.py | 11 +- Buildscripts/check-tt-version.py | 69 +++ Buildscripts/release-sdk-esp32.py | 3 + Buildscripts/release-sdk-posix.py | 3 + Buildscripts/release-tt.py | 4 +- Buildscripts/sdkconfig-default.properties | 2 + Data/system/certificates/GTS_Root_R4.pem | 13 + Data/system/certificates/WE1.pem | 17 - .../fonts/MaterialSymbolsRounded.codepoints | 1 + Data/system/fonts/MaterialSymbolsRounded.ttf | Bin 20484 -> 20700 bytes Devices/cyd-2432s028r/device.properties | 1 + Documentation/ideas.md | 10 +- .../source/app_esp32_loader_service.cpp | 13 +- Modules/app-hub-module/CMakeLists.txt | 5 + Modules/app-hub-module/README.md | 6 +- .../app-hub-module/include/app_hub/app_hub.h | 22 +- .../private/app_hub/app_hub_catalog.h | 38 +- .../private/app_hub/app_hub_service.h | 4 + .../app-hub-module/source/app_hub_catalog.cpp | 217 +++++++++- .../app-hub-module/source/app_hub_command.cpp | 296 +++++++++++++ .../app-hub-module/source/app_hub_service.cpp | 399 +++++++++++------ Modules/app-hub-module/source/module.cpp | 21 +- .../tests/source/app_hub_test.cpp | 22 + .../tests/source/catalog_test.cpp | 89 +++- Modules/binfont-module/source/generator.cpp | 3 +- Modules/coreutils-module/source/apps/rm.cpp | 2 +- .../source/command_support.cpp | 22 +- Modules/http-module/CMakeLists.txt | 3 +- .../private/http/private/http_parsing.h | 27 ++ Modules/http-module/source/download_mock.cpp | 9 - Modules/http-module/source/download_posix.cpp | 404 ++++++++++++++++++ Modules/http-module/source/http_parsing.cpp | 145 +++++++ Modules/http-module/tests/CMakeLists.txt | 2 +- .../tests/source/download_test.cpp | 10 +- .../tests/source/http_parsing_test.cpp | 74 ++++ Modules/location-module/CMakeLists.txt | 2 + .../include/location/location.h | 16 + .../private/location/ip_provider.h | 22 +- .../location-module/source/ip_provider.cpp | 333 +++++++-------- Modules/location-module/source/module.cpp | 1 + .../tests/source/ip_provider_test.cpp | 79 ++-- Modules/lvgl-module/CMakeLists.txt | 1 + Modules/lvgl-module/generate-icons.py | 3 +- .../lvgl-module/include/lvgl/icons/names.h | 2 +- .../lvgl-module/include/lvgl/icons/shared.h | 1 + .../include/lvgl/widgets/step_indicator.h | 36 ++ .../include/lvgl/widgets/text_button.h | 20 + .../lvgl-module/source/devices/devices.cpp | 18 +- Modules/lvgl-module/source/icon_names.c | 1 + Modules/lvgl-module/source/symbols.c | 8 + .../source/themes/lv_theme_material.c | 76 +++- .../source/themes/lv_theme_material_mono.c | 60 ++- .../lvgl-module/source/widgets/statusbar.cpp | 135 +++--- .../source/widgets/step_indicator.cpp | 82 ++++ .../source/widgets/text_button.cpp | 26 ++ Modules/lvgl-module/themes/material.lss | 70 ++- Modules/lvgl-module/themes/material_mono.lss | 61 ++- Modules/mesh-module/source/mesh_service.cpp | 111 ++--- .../drivers/bluetooth/esp32_ble_hid.cpp | 35 +- .../Tactility/settings/RadioSettings.h | 8 + Tactility/Private/Tactility/app/TileGrid.h | 3 +- .../Private/Tactility/app/btmanage/View.h | 2 + .../Tactility/app/timezone/TimeZoneView.h | 83 ++++ .../Tactility/service/screenshot/Screenshot.h | 55 --- .../service/screenshot/ScreenshotTask.h | 62 --- Tactility/Source/app/TileGrid.cpp | 46 +- Tactility/Source/app/apphub/AppHubApp.cpp | 99 ++++- .../app/apphubdetails/AppHubDetailsApp.cpp | 14 +- Tactility/Source/app/btmanage/BtManage.cpp | 7 +- Tactility/Source/app/btmanage/View.cpp | 21 +- Tactility/Source/app/launcher/AppsMode.cpp | 1 - .../Source/app/screenshot/Screenshot.cpp | 326 +++----------- Tactility/Source/app/setup/Setup.cpp | 319 +++++++++++--- Tactility/Source/app/timezone/TimeZone.cpp | 209 ++------- .../Source/app/timezone/TimeZoneView.cpp | 304 +++++++++++++ Tactility/Source/bluetooth/Bluetooth.cpp | 31 ++ Tactility/Source/lvgl/Lvgl.cpp | 9 - Tactility/Source/lvgl/SystemMenu.cpp | 4 +- .../Source/service/screenshot/Screenshot.cpp | 106 ----- .../service/screenshot/ScreenshotTask.cpp | 182 -------- Tactility/Source/settings/RadioSettings.cpp | 59 +++ Tactility/Source/settings/time.cpp | 12 + Tests/Python/tt_test.py | 74 +++- device.py | 24 ++ tt.cmd | 9 - tt.ps1 | 9 + tt.py | 61 ++- 99 files changed, 3985 insertions(+), 1656 deletions(-) create mode 100644 .claude/rules/memory.md create mode 100644 Buildscripts/TactilitySDK/Tools/screenshot.py create mode 100755 Buildscripts/check-tt-version.py create mode 100644 Data/system/certificates/GTS_Root_R4.pem delete mode 100644 Data/system/certificates/WE1.pem create mode 100644 Modules/app-hub-module/source/app_hub_command.cpp create mode 100644 Modules/http-module/private/http/private/http_parsing.h delete mode 100644 Modules/http-module/source/download_mock.cpp create mode 100644 Modules/http-module/source/download_posix.cpp create mode 100644 Modules/http-module/source/http_parsing.cpp create mode 100644 Modules/http-module/tests/source/http_parsing_test.cpp create mode 100644 Modules/lvgl-module/include/lvgl/widgets/step_indicator.h create mode 100644 Modules/lvgl-module/include/lvgl/widgets/text_button.h create mode 100644 Modules/lvgl-module/source/widgets/step_indicator.cpp create mode 100644 Modules/lvgl-module/source/widgets/text_button.cpp create mode 100644 Tactility/Private/Tactility/app/timezone/TimeZoneView.h delete mode 100644 Tactility/Private/Tactility/service/screenshot/Screenshot.h delete mode 100644 Tactility/Private/Tactility/service/screenshot/ScreenshotTask.h create mode 100644 Tactility/Source/app/timezone/TimeZoneView.cpp delete mode 100644 Tactility/Source/service/screenshot/Screenshot.cpp delete mode 100644 Tactility/Source/service/screenshot/ScreenshotTask.cpp delete mode 100755 tt.cmd create mode 100755 tt.ps1 diff --git a/.claude/rules/lvgl.md b/.claude/rules/lvgl.md index ab6370458..bc7bcc206 100644 --- a/.claude/rules/lvgl.md +++ b/.claude/rules/lvgl.md @@ -1,11 +1,13 @@ # Architecture: LVGL User interfaces should scale well for everything between very large (e.g. 1280x720) and small (e.g. 135x240) displays. Vertical and horizontal layouts are supported. +Margins and padding are set in density-independent pixels with `LV_DPX()`, not in raw pixels. Two kernel modules cover LVGL: - **`lvgl-module`** (`Modules/lvgl-module/`, ``) owns LVGL's lifecycle: init/deinit, the LVGL task loop, and `lvgl_lock()`/`lvgl_try_lock()`/`lvgl_unlock()` mutex-based locking that any task must hold before touching LVGL objects. It bridges Tactility's device model to LVGL indevs (`lvgl/devices/*.h`: `display`, `pointer`, `keyboard`, `trackball`), and provides shared fonts (`lvgl/fonts.h`: Montserrat text sizes, Material Symbols icon sets for statusbar/launcher/shared use) and a few shared widgets (`lvgl/widgets/*.h`: `toolbar`, `spinner`, `sliderbox`). - **`lvgl-window-manager-module`** (`Modules/lvgl-window-manager-module/`, ``) manages a single stacked window per app instance on top of `lvgl-module`. `window_manager_start()`/`window_manager_stop()` create/tear down the root widget (plus optional chrome from a configured `WindowManagerScreenInitFn`); `window_manager_create()`/`window_manager_remove()` push/pop an app's window and (re)populate it via a `WindowCreateWidgetsFn`. Only the topmost window ever has live widgets - burying and resurfacing a window deletes and rebuilds its widget tree rather than hiding/showing it. That rebuild-on-remove path can run `create_widgets` on a *different* app's thread (whichever app's `window_manager_remove()` call caused this window to resurface), so `create_widgets` must only rebuild already-committed state, never decide what happens next - state transitions belong in the app's own `main()` event loop, driven by real `APP_EVENT_RESULT`s. + Widget pointers an app keeps become dangling once its window is buried (e.g. by a dialog it started). Use `window_manager_create_ext()` with a `destroy_widgets` callback that clears them, and skip widget updates from the app's own thread while they're cleared. ## Keyboard navigation diff --git a/.claude/rules/memory.md b/.claude/rules/memory.md new file mode 100644 index 000000000..5fbdd3f19 --- /dev/null +++ b/.claude/rules/memory.md @@ -0,0 +1,23 @@ +# Memory: Keep State Out of Internal RAM + +Internal RAM is scarce, especially without PSRAM. Allocate state with external RAM as the desired capability. Without PSRAM it falls back to internal RAM automatically. + +- Don't use file-scope or function-local `static` variables for state of more than ~64 bytes, or that hold buffers or containers. +- Bundle the state in a `struct` and allocate it with `memory_alloc_with_policy()` and `MEMORY_CAPABILITY_EXTERNAL`. + In C++ use `tt::newObject()` and `tt::deleteObject()` (``). Check for `nullptr`. +- With a lifecycle (start and stop): allocate on start, free on stop. +- Without one: allocate once on first use and never free: + ```cpp + static State& state() { + static State* instance = tt::newObject(); + check(instance != nullptr); + return *instance; + } + ``` +- Containers that live long use the `tt::std` replacements from TactilityKernelCpp: `tt::std::vector`, `tt::std::deque`, `tt::std::map` and `tt::std::string` (``). + They prefer external RAM. For other types use `tt::OptExternalAllocator` (``). +- Large buffers in functions with small stacks go on the heap. Don't share a static buffer between callers that can run at the same time. + +Stays in internal RAM: `const` data (flash), DMA buffers, interrupt handler data, code that runs with the flash cache off, task stacks, and the mutex that guards lazy allocation. + +Find large globals with `nm -S --size-sort -C -l `. Only `.data` and `.bss` count, `const` tables are in flash. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3846a243..f330a3c00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,8 +46,23 @@ jobs: persist-credentials: false - name: "Build Tools" uses: ./.github/actions/build-tools + # Always runs, so the jobs that depend on it aren't skipped for pushes + CheckTtVersion: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + if: github.event_name == 'pull_request' + with: + persist-credentials: false + fetch-depth: 0 + - name: "Check tt version" + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: python3 Buildscripts/check-tt-version.py "origin/$BASE_REF" BuildTt: runs-on: ubuntu-latest + needs: [ CheckTtVersion ] steps: - uses: actions/checkout@v6 with: diff --git a/Buildscripts/CDN/tactility.py b/Buildscripts/CDN/tactility.py index 849591266..eaf485302 100644 --- a/Buildscripts/CDN/tactility.py +++ b/Buildscripts/CDN/tactility.py @@ -55,7 +55,7 @@ if __name__ == "__main__": if not install_tt(directory): sys.exit(1) print(f"Installed tt in {directory}") - command = "tt" if os.name == "nt" else "./tt" + command = "py tt.py" if os.name == "nt" else "./tt" print_warning(f"tactility.py was replaced by tt: use tt from now on, e.g. '{command} app build'") print_warning(f"Run '{command} help' for more info") sys.exit(1) diff --git a/Buildscripts/TactilitySDK/CMakeLists.txt b/Buildscripts/TactilitySDK/CMakeLists.txt index 9a8f9a518..0c46d1433 100644 --- a/Buildscripts/TactilitySDK/CMakeLists.txt +++ b/Buildscripts/TactilitySDK/CMakeLists.txt @@ -1,6 +1,7 @@ idf_component_register( INCLUDE_DIRS "Libraries/TactilityKernel/include" + "Libraries/TactilityKernelCpp/Include" "Libraries/TactilityFreeRtos/Include" "Libraries/lvgl/include" "Libraries/minmea/include" diff --git a/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake b/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake index a1d493035..8065d43c0 100644 --- a/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake +++ b/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake @@ -62,8 +62,82 @@ macro(tactility_project_post project_name) service-module ) + _tactility_enable_sdk_ccache() + endmacro() +# Compiles the ESP-IDF and SDK components through ccache, so apps built for the same SDK and +# platform reuse each other's objects: the SDK's fixed sdkconfig makes these objects identical +# across apps. The app's own components get no launcher, so their sources are never cached. +# Each platform uses its own cache directory: /. +# Disable with -D TACTILITY_CCACHE=OFF (the tool's --no-ccache). +function(_tactility_enable_sdk_ccache) + set(TACTILITY_CCACHE ON CACHE BOOL "Compile ESP-IDF and SDK components through ccache") + if (NOT TACTILITY_CCACHE) + return() + endif() + find_program(TACTILITY_CCACHE_PROGRAM ccache) + if (NOT TACTILITY_CCACHE_PROGRAM) + return() + endif() + + if (DEFINED ENV{TACTILITY_CCACHE_DIR}) + set(cache_root "$ENV{TACTILITY_CCACHE_DIR}") + elseif (CMAKE_HOST_WIN32) + set(cache_root "$ENV{USERPROFILE}/.tactility/ccache") + else() + set(cache_root "$ENV{HOME}/.tactility/ccache") + endif() + idf_build_get_property(target IDF_TARGET) + + # The settings are part of the launcher, so they also apply when idf.py runs without the tool. + # They remove the parts of an SDK compile command that differ per app: + # - CCACHE_BASEDIR: makes paths into the build directory (-I/config) relative + # - CCACHE_NOHASHDIR: ignores the working directory that -g would otherwise add to the hash + # - CCACHE_IGNOREOPTIONS: ignores -fmacro-prefix-map==., which can't affect + # sources outside the app directory + set(launcher + ${CMAKE_COMMAND} -E env + "CCACHE_DIR=${cache_root}/${target}" + "CCACHE_BASEDIR=${CMAKE_BINARY_DIR}" + "CCACHE_NOHASHDIR=1" + "CCACHE_IGNOREOPTIONS=-fmacro-prefix-map=*" + ${TACTILITY_CCACHE_PROGRAM} + ) + + # Selected by the directory that defines the target, not by component: some components define + # extra library targets (e.g. mbedtls's mbedcrypto), and the app's own targets (main, the ELF) + # are defined in the app directory. CMake has no launcher for assembly, so .S files aren't cached. + idf_build_get_property(idf_path IDF_PATH) + set(sdk_roots "${idf_path}" "${TACTILITY_SDK_PATH}") + set(directories "${CMAKE_SOURCE_DIR}") + while (directories) + list(POP_FRONT directories directory) + get_property(subdirectories DIRECTORY "${directory}" PROPERTY SUBDIRECTORIES) + list(APPEND directories ${subdirectories}) + set(is_sdk_directory FALSE) + foreach(sdk_root ${sdk_roots}) + cmake_path(IS_PREFIX sdk_root "${directory}" NORMALIZE is_prefix) + if (is_prefix) + set(is_sdk_directory TRUE) + endif() + endforeach() + if (NOT is_sdk_directory) + continue() + endif() + get_property(targets DIRECTORY "${directory}" PROPERTY BUILDSYSTEM_TARGETS) + foreach(target_name ${targets}) + get_target_property(target_type ${target_name} TYPE) + if (target_type MATCHES "^(STATIC_LIBRARY|SHARED_LIBRARY|MODULE_LIBRARY|OBJECT_LIBRARY|EXECUTABLE)$") + set_target_properties(${target_name} PROPERTIES + C_COMPILER_LAUNCHER "${launcher}" + CXX_COMPILER_LAUNCHER "${launcher}" + ) + endif() + endforeach() + endwhile() +endfunction() + macro(tactility_component_register) cmake_parse_arguments(TT_COMPONENT "" "" "SRCS;INCLUDE_DIRS;REQUIRES;PRIV_REQUIRES" ${ARGN}) idf_component_register( diff --git a/Buildscripts/TactilitySDK/TactilitySDK.posix.cmake b/Buildscripts/TactilitySDK/TactilitySDK.posix.cmake index ae4ebe95f..4945d4019 100644 --- a/Buildscripts/TactilitySDK/TactilitySDK.posix.cmake +++ b/Buildscripts/TactilitySDK/TactilitySDK.posix.cmake @@ -26,6 +26,7 @@ macro(tactility_project_post project_name) ${TACTILITY_SDK_PATH}/Modules/lvgl-window-manager-module/include ${TACTILITY_SDK_PATH}/Modules/service-module/include ${TACTILITY_SDK_PATH}/Libraries/TactilityKernel/include + ${TACTILITY_SDK_PATH}/Libraries/TactilityKernelCpp/Include ${TACTILITY_SDK_PATH}/Libraries/lvgl/include ${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/include ${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix diff --git a/Buildscripts/TactilitySDK/Tools/_bootstrap.py b/Buildscripts/TactilitySDK/Tools/_bootstrap.py index c0d19e5f7..5fa6f9919 100755 --- a/Buildscripts/TactilitySDK/Tools/_bootstrap.py +++ b/Buildscripts/TactilitySDK/Tools/_bootstrap.py @@ -56,6 +56,8 @@ def check_cdn(state, sdk_version): index = fetch_json(f"{CDN_URL}/sdk/{sdk_version}/index.json") if isinstance(index, dict): state.setdefault("sdk", {})[sdk_version] = index.get("gitCommit") + tools = index.get("tools") + state.setdefault("sdk_tools", {})[sdk_version] = tools.get("sha256") if isinstance(tools, dict) else None write_state(state) @@ -75,6 +77,14 @@ def installed_commits(version_directory): return commits +def installed_tools_hash(version_directory): + try: + with open(os.path.join(version_directory, "tools.sha256")) as file: + return file.read().strip() + except OSError: + return None + + def run(): tt_version = os.environ.get("TT_VERSION") if tt_version is None: @@ -91,8 +101,14 @@ def run(): latest = state.get("version") if isinstance(latest, str) and parse_version(latest) > parse_version(tt_version): print(f"tt: version {latest} is available (currently {tt_version}): run 'tt update-self'", file=sys.stderr) - latest_commit = state.get("sdk", {}).get(sdk_version) if sdk_version is not None else None - if latest_commit and sdk_path is not None and any(commit != latest_commit for commit in installed_commits(os.path.join(sdk_path, "sdk", sdk_version))): + if sdk_version is None or sdk_path is None: + return + version_directory = os.path.join(sdk_path, "sdk", sdk_version) + latest_commit = state.get("sdk", {}).get(sdk_version) + latest_tools = state.get("sdk_tools", {}).get(sdk_version) + has_newer_platforms = latest_commit and any(commit != latest_commit for commit in installed_commits(version_directory)) + has_newer_tools = latest_tools and installed_tools_hash(version_directory) != latest_tools + if has_newer_platforms or has_newer_tools: print(f"tt: a newer build of SDK {sdk_version} is available: run 'tt update-sdk'", file=sys.stderr) diff --git a/Buildscripts/TactilitySDK/Tools/app.py b/Buildscripts/TactilitySDK/Tools/app.py index e77ef9d24..6994433b8 100755 --- a/Buildscripts/TactilitySDK/Tools/app.py +++ b/Buildscripts/TactilitySDK/Tools/app.py @@ -12,6 +12,8 @@ import _device_host ttbuild_version = "8.0.0" verbose = False +# Compile the ESP-IDF and SDK components through ccache (see TactilitySDK.esp32.cmake) +use_ccache = True # The development server client (shell.py) is next to this script tool_directory = os.path.dirname(os.path.abspath(__file__)) # The SDKs are in TACTILITY_SDK_PATH/sdk/, which tt sets along with the version it selected. @@ -34,7 +36,7 @@ def print_help(): print("") print(" build Build the app. Optionally specify a platform.") print(" Supported platforms are lower case. Example: esp32s3") - print(" Supported platforms are read from manifest.properties") + print(" Supported platforms are read from manifest.properties (target.platforms=all for every installed SDK platform)") print(" Parameters:") print(" (optional) --architecture [input], -a [input]") print("") @@ -65,6 +67,7 @@ def print_help(): print("Options:") print(" -p, --path [input] Path to the app directory (defaults to current directory)") print(" --help Show this commandline info") + print(" --no-ccache Don't reuse ESP-IDF/SDK objects from other app builds via ccache") print(" --skip-build Run everything except the idf.py/CMake commands") print(" --verbose Show extra console output") print("") @@ -182,6 +185,7 @@ ARG_SPECS = { "platform": ("-a", "--architecture", True), "verbose": (None, "--verbose", False), "skip-build": (None, "--skip-build", False), + "no-ccache": (None, "--no-ccache", False), "help": (None, "--help", False), } @@ -344,9 +348,19 @@ def get_binary_dirs(manifest): else: return [(manifest["app.id"], ".")] +# "target.platforms=all" means every platform that an SDK is installed for +def get_manifest_platforms(manifest): + platforms = [platform.strip() for platform in manifest["target.platforms"].split(",")] + if platforms != ["all"]: + return platforms + version_directory = os.path.join(sdk_root_path, "sdk", manifest["target.sdk"]) + prefix = "platform-" + if not os.path.isdir(version_directory): + exit_with_error(f"No SDK {manifest['target.sdk']} installed, so 'all' platforms can't be determined") + return sorted(name[len(prefix):] for name in os.listdir(version_directory) if name.startswith(prefix)) + def is_valid_manifest_platform(manifest, platform): - manifest_platforms = manifest["target.platforms"].split(",") - return platform in manifest_platforms + return platform in get_manifest_platforms(manifest) def validate_manifest_platform(manifest, platform): if not is_valid_manifest_platform(manifest, platform): @@ -354,7 +368,7 @@ def validate_manifest_platform(manifest, platform): def get_manifest_target_platforms(manifest, requested_platform): if requested_platform == "" or requested_platform is None: - return manifest["target.platforms"].split(",") + return get_manifest_platforms(manifest) else: validate_manifest_platform(manifest, requested_platform) return [requested_platform] @@ -472,6 +486,10 @@ def build_all(version, platforms, skip_build): return False return True +# Always passed, because a -D value persists in CMakeCache.txt +def get_ccache_define(): + return f"TACTILITY_CCACHE={'ON' if use_ccache else 'OFF'}" + def wait_for_process(process): buffer = [] if sys.platform != "win32": @@ -514,7 +532,7 @@ def build_first(version, platform, skip_build): cmake_path = get_cmake_path(platform) print_status_busy(f"Building {platform} ELF") shell_needed = sys.platform == "win32" - build_command = ["idf.py", "-B", cmake_path, "build"] + build_command = ["idf.py", "-B", cmake_path, "-D", get_ccache_define(), "build"] if verbose: print(f"Running command: {' '.join(build_command)}") with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: @@ -544,7 +562,7 @@ def build_consecutively(version, platform, skip_build): cmake_path = get_cmake_path(platform) print_status_busy(f"Building {platform} ELF") shell_needed = sys.platform == "win32" - build_command = ["idf.py", "-B", cmake_path, "elf"] + build_command = ["idf.py", "-B", cmake_path, "-D", get_ccache_define(), "elf"] if verbose: print(f"Running command: {' '.join(build_command)}") with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: @@ -758,6 +776,7 @@ if __name__ == "__main__": sys.exit(1) verbose = parsed_command.arguments.has("verbose") + use_ccache = not parsed_command.arguments.has("no-ccache") app_path = parsed_command.arguments.get("path") if app_path is not None: diff --git a/Buildscripts/TactilitySDK/Tools/screenshot.py b/Buildscripts/TactilitySDK/Tools/screenshot.py new file mode 100644 index 000000000..9c03cd0d7 --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/screenshot.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Takes a screenshot of the device through its development server. Run it as "tt screenshot".""" + +import argparse +import datetime +import os +import sys + +import shell + +# Encoding the PNG can take a while on a large display +SCREENSHOT_TIMEOUT_SECONDS = 30 + + +def main(): + shell.PROGRAM_NAME = "tt screenshot" if "TACTILITY_SDK_VERSION" in os.environ else "screenshot.py" + parser = argparse.ArgumentParser(prog=shell.PROGRAM_NAME, description="take a screenshot of the device", add_help=False) + shell.add_connection_arguments(parser) + parser.add_argument("local_path", nargs="?", help="the .png file on this computer (default: screenshot-.png)") + args = shell.parse_arguments(parser) + if args.local_path is None: + args.local_path = f"screenshot-{datetime.date.today().isoformat()}.png" + + # The device saves the screenshot in its temp folder and prints the path + exit_code, output = shell.run_command(args.host, args.port, "screenshot", SCREENSHOT_TIMEOUT_SECONDS) + if exit_code != 0: + shell.fail(output.strip() or "the device failed to take a screenshot") + remote_path = output.strip().splitlines()[-1] + + try: + shell.run_pull(argparse.Namespace(host=args.host, port=args.port, remote_path=remote_path, local_path=args.local_path)) + finally: + shell.run_command(args.host, args.port, f"rm {remote_path}") + sys.exit(0) + + +if __name__ == "__main__": + import _bootstrap + _bootstrap.run() + main() diff --git a/Buildscripts/TactilitySDK/Tools/shell.py b/Buildscripts/TactilitySDK/Tools/shell.py index a53febc81..b66b92de6 100755 --- a/Buildscripts/TactilitySDK/Tools/shell.py +++ b/Buildscripts/TactilitySDK/Tools/shell.py @@ -495,6 +495,21 @@ def run_shell(args): return 1 return exit_code + +def run_command(host, port, command, timeout=REQUEST_TIMEOUT_SECONDS): + """Runs a command without a terminal. @return (exit code, output text)""" + connection = open_session(host, port, "shell", command=command) + output = bytearray() + try: + while True: + frame_type, payload = connection.read_frame(timeout) + if frame_type == FRAME_DATA: + output += payload + elif frame_type == FRAME_EXIT and len(payload) == 4: + return struct.unpack(">i", payload)[0], output.decode("utf-8", errors="replace").replace("\r", "") + finally: + connection.close() + # endregion diff --git a/Buildscripts/TactilitySDK/Tools/update-sdk.py b/Buildscripts/TactilitySDK/Tools/update-sdk.py index 693016210..b21830e94 100755 --- a/Buildscripts/TactilitySDK/Tools/update-sdk.py +++ b/Buildscripts/TactilitySDK/Tools/update-sdk.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 # License: Apache License v2 -"""Downloads the installed platforms of the selected SDK again when the CDN has a newer build of it. Run it as "tt update-sdk".""" +"""Downloads the tools and installed platforms of the selected SDK again when the CDN has a newer build of it. Run it as "tt update-sdk".""" +import hashlib import io import json import os @@ -43,27 +44,67 @@ def installed_platforms(version_directory): ) -def install_platform(platform_directory, archive_data, commit): - """Extracts next to the installed SDK and then replaces it, refusing entries outside of it""" - staging_directory = os.path.join(platform_directory, "TactilitySDK.new") - shutil.rmtree(staging_directory, ignore_errors=True) - os.makedirs(staging_directory) - root = os.path.realpath(staging_directory) +def read_installed_tools_hash(version_directory): try: - with zipfile.ZipFile(io.BytesIO(archive_data)) as archive: - for member in archive.infolist(): - destination = os.path.realpath(os.path.join(root, member.filename)) - if not destination.startswith(root + os.sep): - fail(f"invalid entry in the SDK archive: {member.filename}") - archive.extractall(root) - except zipfile.BadZipFile: + with open(os.path.join(version_directory, "tools.sha256")) as file: + return file.read().strip() + except OSError: + return None + + +class Update: + """Stages every component next to its installed folder, so a failed download leaves the installation untouched""" + + def __init__(self): + # (target directory, staging directory, metadata path, metadata) + self.staged = [] + + def stage(self, archive_data, target_directory, metadata_path, metadata): + """Extracts into a staging folder, refusing entries outside of it""" + staging_directory = f"{target_directory}.new" shutil.rmtree(staging_directory, ignore_errors=True) - fail("the downloaded SDK archive is invalid") - sdk_directory = os.path.join(platform_directory, "TactilitySDK") - shutil.rmtree(sdk_directory, ignore_errors=True) - os.replace(staging_directory, sdk_directory) - with open(os.path.join(platform_directory, "commit"), "w") as file: - file.write(commit) + os.makedirs(staging_directory) + self.staged.append((target_directory, staging_directory, metadata_path, metadata)) + root = os.path.realpath(staging_directory) + try: + with zipfile.ZipFile(io.BytesIO(archive_data)) as archive: + for member in archive.infolist(): + destination = os.path.realpath(os.path.join(root, member.filename)) + if not destination.startswith(root + os.sep): + fail(f"invalid entry in the archive: {member.filename}") + archive.extractall(root) + except zipfile.BadZipFile: + fail("the downloaded archive is invalid") + + def discard(self): + for _, staging_directory, _, _ in self.staged: + shutil.rmtree(staging_directory, ignore_errors=True) + self.staged = [] + + def commit(self): + """Replaces the installed folders, and puts the previous ones back when that fails""" + replaced = [] + try: + for target_directory, staging_directory, _, _ in self.staged: + backup_directory = f"{target_directory}.old" + shutil.rmtree(backup_directory, ignore_errors=True) + if os.path.isdir(target_directory): + os.replace(target_directory, backup_directory) + replaced.append((target_directory, backup_directory)) + os.replace(staging_directory, target_directory) + except OSError as error: + for target_directory, backup_directory in reversed(replaced): + shutil.rmtree(target_directory, ignore_errors=True) + if os.path.isdir(backup_directory): + os.replace(backup_directory, target_directory) + self.discard() + fail(f"failed to install the update: {error}") + for _, _, metadata_path, metadata in self.staged: + with open(metadata_path, "w") as file: + file.write(metadata) + for _, backup_directory in replaced: + shutil.rmtree(backup_directory, ignore_errors=True) + self.staged = [] def main(): @@ -81,14 +122,28 @@ def main(): fail("the downloaded index.json is invalid") commit = index.get("gitCommit", "") available = index.get("platforms", {}) - updated = False - for platform in installed_platforms(version_directory): - platform_directory = os.path.join(version_directory, f"platform-{platform}") - if read_commit(platform_directory) == commit or platform not in available: - continue - print(f"Updating SDK {version} for {platform}") - install_platform(platform_directory, fetch(f"{CDN_URL}/sdk/{version}/{available[platform]}"), commit) - updated = True + update = Update() + try: + tools = index.get("tools") + if tools is not None and read_installed_tools_hash(version_directory) != tools.get("sha256"): + print(f"Downloading the tools of SDK {version}", flush=True) + archive_data = fetch(f"{CDN_URL}/sdk/{version}/{tools['file']}") + if hashlib.sha256(archive_data).hexdigest() != tools["sha256"]: + fail("the downloaded tools don't match their checksum") + update.stage(archive_data, os.path.join(version_directory, "tools"), os.path.join(version_directory, "tools.sha256"), tools["sha256"]) + for platform in installed_platforms(version_directory): + platform_directory = os.path.join(version_directory, f"platform-{platform}") + if read_commit(platform_directory) == commit or platform not in available: + continue + print(f"Downloading SDK {version} for {platform}", flush=True) + archive_data = fetch(f"{CDN_URL}/sdk/{version}/{available[platform]}") + update.stage(archive_data, os.path.join(platform_directory, "TactilitySDK"), os.path.join(platform_directory, "commit"), commit) + except SystemExit: + update.discard() + print(f"SDK {version} was not changed", file=sys.stderr) + raise + updated = len(update.staged) > 0 + update.commit() with open(os.path.join(version_directory, "index.json"), "wb") as file: file.write(index_data) print(f"SDK {version} was updated" if updated else f"SDK {version} is up to date") diff --git a/Buildscripts/TactilitySDK/Tools/update-self.py b/Buildscripts/TactilitySDK/Tools/update-self.py index b9a6d2ad5..014e3173a 100755 --- a/Buildscripts/TactilitySDK/Tools/update-self.py +++ b/Buildscripts/TactilitySDK/Tools/update-self.py @@ -11,6 +11,8 @@ import zipfile CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") FILE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]*$") +# Files of older versions of tt: tt.cmd made cmd.exe parse the arguments a second time, tt.ps1 replaces it +OBSOLETE_FILE_NAMES = ["tt.cmd"] def fail(message): @@ -44,16 +46,15 @@ def main(): fail("the downloaded tt.py is invalid") for file_name, content in contents.items(): path = os.path.join(directory, file_name) - # A running tt.cmd is read while it executes, so an unchanged file is left alone - if os.path.isfile(path): - with open(path, "rb") as file: - if file.read() == content: - continue temporary_path = f"{path}.new" with open(temporary_path, "wb") as file: file.write(content) os.chmod(temporary_path, 0o755) os.replace(temporary_path, path) + for file_name in OBSOLETE_FILE_NAMES: + path = os.path.join(directory, file_name) + if file_name not in contents and os.path.isfile(path): + os.remove(path) print("tt was updated") diff --git a/Buildscripts/check-tt-version.py b/Buildscripts/check-tt-version.py new file mode 100755 index 000000000..b30e4cf05 --- /dev/null +++ b/Buildscripts/check-tt-version.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Fails when the files of tt or its packaging changed since a base revision, but TT_VERSION in tt.py wasn't increased. +Usage: check-tt-version.py , e.g. origin/main""" + +import importlib.util +import os +import re +import subprocess +import sys + +ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +VERSION_PATTERN = re.compile(r'^TT_VERSION = "([^"]+)"', re.MULTILINE) +# Changes to what tt.zip contains, e.g. an added or removed file +PACKAGING_FILE_NAME = "Buildscripts/release-tt.py" + + +def load_file_names(): + """The files that make up tt, as release-tt.py packages them""" + spec = importlib.util.spec_from_file_location("release_tt", os.path.join(ROOT_PATH, "Buildscripts", "release-tt.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.FILE_NAMES + + +def git(*arguments): + return subprocess.run(["git", *arguments], cwd=ROOT_PATH, capture_output=True, text=True) + + +def read_version(source): + match = VERSION_PATTERN.search(source) + return match.group(1) if match else None + + +def parse_version(version): + return tuple(int(part) for part in re.findall(r"\d+", version)[:3]) + + +def main(): + if len(sys.argv) != 2: + print("Usage: check-tt-version.py ", file=sys.stderr) + sys.exit(2) + base = sys.argv[1] + file_names = load_file_names() + diff = git("diff", "--name-only", f"{base}...HEAD", "--", *file_names, PACKAGING_FILE_NAME) + if diff.returncode != 0: + print(f"git diff failed: {diff.stderr.strip()}", file=sys.stderr) + sys.exit(2) + changed = diff.stdout.split() + if not changed: + print("tt is unchanged") + return + base_source = git("show", f"{base}:tt.py") + if base_source.returncode != 0: + print("tt is new") + return + with open(os.path.join(ROOT_PATH, "tt.py")) as file: + current_version = read_version(file.read()) + base_version = read_version(base_source.stdout) + if current_version is None or base_version is None: + print("TT_VERSION not found in tt.py", file=sys.stderr) + sys.exit(1) + if parse_version(current_version) <= parse_version(base_version): + print(f"{', '.join(changed)} changed, but TT_VERSION is still {current_version}: increase TT_VERSION in tt.py", file=sys.stderr) + sys.exit(1) + print(f"tt changed and its version increased from {base_version} to {current_version}") + + +if __name__ == "__main__": + main() diff --git a/Buildscripts/release-sdk-esp32.py b/Buildscripts/release-sdk-esp32.py index e35df036f..1d2cfb4df 100644 --- a/Buildscripts/release-sdk-esp32.py +++ b/Buildscripts/release-sdk-esp32.py @@ -101,6 +101,9 @@ def main(): {'src': 'TactilityKernel/include/**', 'dst': 'Libraries/TactilityKernel/include/'}, {'src': 'TactilityKernel/CMakeLists.txt', 'dst': 'Libraries/TactilityKernel/'}, {'src': 'TactilityKernel/*.md', 'dst': 'Libraries/TactilityKernel/'}, + # TactilityKernelCpp (header-only) + {'src': 'TactilityKernelCpp/Include/**', 'dst': 'Libraries/TactilityKernelCpp/Include/'}, + {'src': 'TactilityKernelCpp/*.md', 'dst': 'Libraries/TactilityKernelCpp/'}, # lvgl (basics) {'src': 'build/esp-idf/lvgl__lvgl/liblvgl__lvgl.a', 'dst': 'Libraries/lvgl/binary/liblvgl.a'}, {'src': 'Libraries/lvgl/lvgl.h', 'dst': 'Libraries/lvgl/include/'}, diff --git a/Buildscripts/release-sdk-posix.py b/Buildscripts/release-sdk-posix.py index 539e413c6..af30616c0 100755 --- a/Buildscripts/release-sdk-posix.py +++ b/Buildscripts/release-sdk-posix.py @@ -58,6 +58,9 @@ def main(): {'src': 'TactilityKernel/include/**', 'dst': 'Libraries/TactilityKernel/include/'}, {'src': 'TactilityKernel/CMakeLists.txt', 'dst': 'Libraries/TactilityKernel/'}, {'src': 'TactilityKernel/*.md', 'dst': 'Libraries/TactilityKernel/'}, + # TactilityKernelCpp (header-only) + {'src': 'TactilityKernelCpp/Include/**', 'dst': 'Libraries/TactilityKernelCpp/Include/'}, + {'src': 'TactilityKernelCpp/*.md', 'dst': 'Libraries/TactilityKernelCpp/'}, # FreeRTOS-Kernel - TactilityKernel's public headers (tactility/freertos/*.h) include the # real FreeRTOS.h/task.h/etc directly, unlike ESP32 where ESP-IDF's own "freertos" # component and Kconfig-generated FreeRTOSConfig.h are already part of every project. diff --git a/Buildscripts/release-tt.py b/Buildscripts/release-tt.py index 435f35fba..ccc3dc9a0 100755 --- a/Buildscripts/release-tt.py +++ b/Buildscripts/release-tt.py @@ -10,8 +10,8 @@ import zipfile ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) LAUNCHER_PATH = os.path.join(ROOT_PATH, "tt.py") -# tt.py and the wrappers that run it: tt (Linux, macOS) and tt.cmd (Windows) -FILE_NAMES = ["tt.py", "tt", "tt.cmd"] +# tt.py and the wrappers that run it: tt (Linux, macOS) and tt.ps1 (Windows PowerShell) +FILE_NAMES = ["tt.py", "tt", "tt.ps1"] CDN_OUTPUT_PATH = os.path.join(ROOT_PATH, "release", "tt") diff --git a/Buildscripts/sdkconfig-default.properties b/Buildscripts/sdkconfig-default.properties index be1f08403..a2b1041ce 100644 --- a/Buildscripts/sdkconfig-default.properties +++ b/Buildscripts/sdkconfig-default.properties @@ -48,6 +48,8 @@ CONFIG_LV_USE_BUILTIN_MALLOC=n # Routes lv_malloc/realloc/free through Modules/lvgl-module/source/lv_mem_custom.c, which prefers # PSRAM (falls back to internal RAM automatically) instead of a fixed-size pool or plain malloc. CONFIG_LV_USE_CUSTOM_MALLOC=y +# A failed lv_malloc() stops in the assert instead of writing to NULL. The task watchdog then reports where. +CONFIG_LV_USE_ASSERT_MALLOC=y CONFIG_LV_USE_MSGBOX=n CONFIG_LV_USE_SPINNER=n CONFIG_LV_USE_WIN=n diff --git a/Data/system/certificates/GTS_Root_R4.pem b/Data/system/certificates/GTS_Root_R4.pem new file mode 100644 index 000000000..fdb2d1bdf --- /dev/null +++ b/Data/system/certificates/GTS_Root_R4.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD +VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG +A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw +WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz +IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi +QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR +HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D +9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8 +p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD +-----END CERTIFICATE----- diff --git a/Data/system/certificates/WE1.pem b/Data/system/certificates/WE1.pem deleted file mode 100644 index 7d99e6ec1..000000000 --- a/Data/system/certificates/WE1.pem +++ /dev/null @@ -1,17 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICnzCCAiWgAwIBAgIQf/MZd5csIkp2FV0TttaF4zAKBggqhkjOPQQDAzBHMQsw -CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU -MBIGA1UEAxMLR1RTIFJvb3QgUjQwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIwMTQw -MDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZp -Y2VzMQwwCgYDVQQDEwNXRTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARvzTr+ -Z1dHTCEDhUDCR127WEcPQMFcF4XGGTfn1XzthkubgdnXGhOlCgP4mMTG6J7/EFmP -LCaY9eYmJbsPAvpWo4H+MIH7MA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr -BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU -kHeSNWfE/6jMqeZ72YB5e8yT+TgwHwYDVR0jBBgwFoAUgEzW63T/STaj1dj8tT7F -avCUHYwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzAChhhodHRwOi8vaS5wa2ku -Z29vZy9yNC5jcnQwKwYDVR0fBCQwIjAgoB6gHIYaaHR0cDovL2MucGtpLmdvb2cv -ci9yNC5jcmwwEwYDVR0gBAwwCjAIBgZngQwBAgEwCgYIKoZIzj0EAwMDaAAwZQIx -AOcCq1HW90OVznX+0RGU1cxAQXomvtgM8zItPZCuFQ8jSBJSjz5keROv9aYsAm5V -sQIwJonMaAFi54mrfhfoFNZEfuNMSQ6/bIBiNLiyoX46FohQvKeIoJ99cx7sUkFN -7uJW ------END CERTIFICATE----- diff --git a/Data/system/fonts/MaterialSymbolsRounded.codepoints b/Data/system/fonts/MaterialSymbolsRounded.codepoints index 757c28473..d05798477 100644 --- a/Data/system/fonts/MaterialSymbolsRounded.codepoints +++ b/Data/system/fonts/MaterialSymbolsRounded.codepoints @@ -42,6 +42,7 @@ forum e8af gamepad e30f hard_disk f3da help e8fd +hourglass ebff hub e9f4 image e3f4 info e88e diff --git a/Data/system/fonts/MaterialSymbolsRounded.ttf b/Data/system/fonts/MaterialSymbolsRounded.ttf index 6234dbbb842e827455a7ef64d3baed76e25223b7..78bd51939391543312a7fb4e8914766c22cccede 100644 GIT binary patch delta 1495 zcmX|>e@q)?7{|ZYmX_ab;d)wFyTd(oS7}(tooKsC0~uy zO<$vmQEUvSp@mI;TNP0)Y{)A0m?Goq!jWu8t;1no0qoz|C&|8Q@0EU%K9vqg?NYWR zNp=ag!#1z2-d1V-$U0~ZTaN+O7R$d@lQrFP!}6gyVZJTK#9=Wio)E{yy<(bJEc_yz z5`uy#XiNeA3jZdZ4SbXz+Kw`TkSWb96V zEv-GJLU(5K&1vs+yu++~QIpRNk*$*_G-+~NW3Q^ELL9_a=Yp$LU|0$brh zx-w5grP(|>fjbop?3OE&T$U;zzJ~!L>0SnE<)NgK=0oIB4Hakw#6MxsT8Q6bAV_+L zfnC5|@)h79eT#u=c`>OZ!w3AC$3UIz(Uvqn4cwz5DWD#heU70NboLttsK&F`8Q4zx zGXnwA83w#^Oe=Og3*2`xU?#0)U_WUS0|!W7V4#vT%s>_}x03mU#2Iln))Q}c4l)BA-!GNE1g@HzSRClC?a=Z{{ppEn%15}5F4F(E;MH>T8 z0)N*rP$oN*UEEH&G1)b;4_NA9P}Q+C%0M~kdkj29I>A6I@J|;54Wzdj%4d_HeI%5s z(HkV_xeC-rNKoz+sH2de+$m6RA<+T!Du&t&2}+LwH5?KL8Ps}6&;km`2bQT_6hL!U zt}b~qi&&Rx!To%6XTa&OWOlR*1Zt*WP~4Yl-1r9*$&oEo-|Bq4^N+NqAv zf1n=Kl*1OFC)Z!@&eNI#0lz=j#0%;?x4%5#^O31=yZwGwo}1jQVm0wz^E-f{9tJGdESI5Ky*NOX%ZK~UGS#Sx5pZcm%v`M=Zq?z#7T z^y1sf-owh!ZM7}00!Vcw6RG}v)^~ud6Ue{blju*;?{1)FkhHL;ch}=3o9a4%A{A(u zI?z$xKCD#&%Dc4uNpD|Q;-ATqD!|bs{bXWi3Qyn+t+%VVjjBoe*>gue)XM0Q8G+Zwgrw>DUD%V(LEWx?ERE;3h{ zb;eO6Y4jRhfYBJ7HZ-F+I2t?>$OJCvhxJ|hPW>tUklw5p>s9_A{LlGY{JLMAU8{Yp z4bpeD_NYId4!1ysn#u>lBpoMgd!CEnvrz>}fnY@_u zcHq}i26v0a`7!4`z$I#h1gk_UUr*D$E}vzvg!Fp`car|dP*%8nj-mW8;bQPGX&Hk6 z=?Vr9lCEd)5b0J1l=noEffx8~E#Q>~wdBwYOVB6+1xuZ+z?EAJ){D*py?hyPwVIJl zxq5=ZV$vxFtHr^B$7-mU*Txx8F|S=>K;wShWI$K_YbApkQD0c5tPvfBW$8FD+09TJ zF*(HG4${{d(7wqJ7~BVB=$|G*1L;qUoc}DfB}*|-MM@=+&|n}-C6UmGAiIH~VL_I1 zPD?{`gKQf^6ND_yfP`iVSvo+1a^ME_Ujhm;^%a94@OP8}#hCe$!8*A9+mQoUnv1m$ zC-A#ct$e0T+nQ~s?W%o56)!mIdj?$>-3#5%yU%*Uo<7glo`1dDynp(fz7F4~zKoX8 z_Gs^EW3$`*A^#TtsP52*_49#^fwzMDgBc@kJY$?PrbDHnw$RI=Z%wD!WS%iIR@yoj f-V#0?c`b4!S{Hq3j&IKH #endif +#include "tactility/memory.h" + + #include #include #include @@ -47,7 +50,8 @@ error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) { return ERROR_RESOURCE; } - auto* data = static_cast(malloc(static_cast(size))); + static constexpr MemoryPolicy policy = { .required = 0, .desired = 0, .alignment = 0}; + auto* data = static_cast(memory_alloc_with_policy(static_cast(size), &policy)); if (data == nullptr) { fclose(file); return ERROR_OUT_OF_MEMORY; @@ -57,7 +61,7 @@ error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) { fclose(file); if (read != static_cast(size)) { - free(data); + memory_free(data); return ERROR_RESOURCE; } @@ -141,7 +145,7 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) { } if (esp_elf_init(&runtime->elf) != ESP_OK) { - free(runtime->file_data); + memory_free(runtime->file_data); delete runtime; LOG_E(TAG, "Failed to init elf"); return ERROR_RESOURCE; @@ -149,14 +153,13 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) { if (esp_elf_relocate(&runtime->elf, runtime->file_data) != 0) { // esp_elf_relocate() already frees elf->pdata/ptext itself on a relocation failure - free(runtime->file_data); delete runtime; LOG_E(TAG, "Failed to map elf. Possibly out of memory."); return ERROR_RESOURCE; } // Relocation copied everything it needs (segments, symbol names) out of the file buffer - free(runtime->file_data); + memory_free(runtime->file_data); runtime->file_data = nullptr; *out_runtime = runtime; diff --git a/Modules/app-hub-module/CMakeLists.txt b/Modules/app-hub-module/CMakeLists.txt index 4db93f5ea..83851add6 100644 --- a/Modules/app-hub-module/CMakeLists.txt +++ b/Modules/app-hub-module/CMakeLists.txt @@ -10,3 +10,8 @@ tactility_add_module(app-hub-module INCLUDE_DIRS include/ REQUIRES TactilityKernel TactilityKernelCpp app-module service-module http-module cjson-module ) + +if (NOT DEFINED ENV{ESP_IDF_VERSION}) + # Matches the app binary folder name of app-posix-module, e.g. "posix-x86_64" + target_compile_definitions(app-hub-module PRIVATE "TACTILITY_POSIX_ARCH=\"${CMAKE_SYSTEM_PROCESSOR}\"") +endif () diff --git a/Modules/app-hub-module/README.md b/Modules/app-hub-module/README.md index a8e3aa92e..6621b66d1 100644 --- a/Modules/app-hub-module/README.md +++ b/Modules/app-hub-module/README.md @@ -4,10 +4,12 @@ App Hub service (`tactility.apphub`): finds, installs and removes apps from the The C API is in ``. - `app_hub_search()` lists the catalog apps that are compatible with the device, merged with all installed apps. -- `app_hub_refresh()`, `app_hub_install()` and `app_hub_remove()` queue an operation. Operations run one at a time +- `app_hub_refresh()`, `app_hub_install()`, `app_hub_update_all()` and `app_hub_remove()` queue an operation. Operations run one at a time on a background task, which stops after 5 seconds without operations and starts again for the next one. - Subscribe with `app_hub_event_subscribe()`, then wait with `app_hub_event_await()` and read with `app_hub_event_poll()`. - The events are `REFRESHING`/`REFRESHED`, `INSTALLING`/`INSTALLED` and `REMOVING`/`REMOVED`, with the app id and the result. + The events are `REFRESHING`/`REFRESHED`, `INSTALLING`/`INSTALLED`, `REMOVING`/`REMOVED` and `UPDATING`/`UPDATED`, with the app id and the result. + +The `apphub` shell command uses the same API: `refresh`, `search [query]`, `updates`, `info `, `install `, `update` and `remove `. The catalog is stored in the service's user data directory, so it's available offline after the first refresh. diff --git a/Modules/app-hub-module/include/app_hub/app_hub.h b/Modules/app-hub-module/include/app_hub/app_hub.h index 071628975..193568d8c 100644 --- a/Modules/app-hub-module/include/app_hub/app_hub.h +++ b/Modules/app-hub-module/include/app_hub/app_hub.h @@ -28,6 +28,8 @@ enum AppHubOperationType { APP_HUB_OPERATION_INSTALL, /** Uninstall an app */ APP_HUB_OPERATION_REMOVE, + /** Install the newer catalog version of every installed app that has one */ + APP_HUB_OPERATION_UPDATE_ALL, }; /** An app from the catalog, an installed app, or both */ @@ -89,6 +91,9 @@ error_t app_hub_get_description(const char* id, char* description, size_t descri // region Operations +/** @return true while an operation is queued or running */ +bool app_hub_is_busy(void); + /** * Queue a download of the app catalog. * Only one refresh is queued or running at a time: while one is pending, its id is returned instead. @@ -105,6 +110,17 @@ error_t app_hub_refresh(uint32_t* operation_id); */ error_t app_hub_install(const char* id, uint32_t* operation_id); +/** + * Queue the update of every installed app that has a newer version in the catalog. The apps are installed one at a time, + * with an INSTALLING and INSTALLED event each. A failed update doesn't stop the updates of the other apps. + * While the operation is pending, the apps with an update report it as their pending operation. + * The operation takes the place of an install or remove: only one of them is queued or running at a time. + * @param[out] operation_id the id of the queued operation (optional) + * @retval ERROR_NONE the operation was queued, or it was already pending and its id is returned + * @retval ERROR_RESOURCE_BUSY an install or remove is pending + */ +error_t app_hub_update_all(uint32_t* operation_id); + /** * Queue the removal of an installed app. * Only one install or remove is queued or running at a time. A refresh can be pending next to it. @@ -131,12 +147,16 @@ enum AppHubEventType { APP_HUB_EVENT_REMOVING, /** The removal of AppHubEvent.app_id finished, see AppHubEvent.result */ APP_HUB_EVENT_REMOVED, + /** Updating all apps started. INSTALLING and INSTALLED events with the same operation id follow for each app. */ + APP_HUB_EVENT_UPDATING, + /** Updating all apps finished. AppHubEvent.result is the error of the last failed app, ERROR_NONE when all succeeded. */ + APP_HUB_EVENT_UPDATED, }; struct AppHubEvent { enum AppHubEventType type; uint32_t operation_id; - /** Empty for the refresh events */ + /** Empty for the refresh, UPDATING and UPDATED events */ PackageId app_id; /** The outcome of the operation for the REFRESHED, INSTALLED and REMOVED events, ERROR_NONE otherwise */ error_t result; diff --git a/Modules/app-hub-module/private/app_hub/app_hub_catalog.h b/Modules/app-hub-module/private/app_hub/app_hub_catalog.h index be76ea66b..aa6d5d821 100644 --- a/Modules/app-hub-module/private/app_hub/app_hub_catalog.h +++ b/Modules/app-hub-module/private/app_hub/app_hub_catalog.h @@ -1,13 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once -#include - #include #include #include -constexpr auto* APP_HUB_CERTIFICATE_PATH = "/system/certificates/WE1.pem"; struct AppHubCatalogEntry { std::string id; @@ -25,14 +22,43 @@ struct AppHubCatalogEntry { std::string file; }; -using AppHubCatalog = std::vector>; +/** + * The catalog isn't kept in memory. A downloaded apps.json is converted once into a catalog file with one line per app, + * which is read one entry at a time. + */ -/** Parse an apps.json file */ -bool app_hub_catalog_parse(const char* path, AppHubCatalog& catalog); +/** + * Convert an apps.json file into a catalog file + * @param[in] json_path the apps.json file + * @param[in] catalog_path the catalog file to write, which is removed when the conversion fails + * @return false when the JSON is invalid or the catalog file can't be written + */ +bool app_hub_catalog_convert(const char* json_path, const char* catalog_path); + +/** @return true to continue, false to stop */ +using AppHubCatalogVisitor = bool (*)(const AppHubCatalogEntry& entry, void* context); + +/** + * Visit the entries of a catalog file in order + * @return false when the file can't be read or is malformed + */ +bool app_hub_catalog_for_each(const char* catalog_path, AppHubCatalogVisitor visitor, void* context); + +/** + * @param[out] entry the entry with the id + * @return false when the entry isn't in the catalog file + */ +bool app_hub_catalog_find(const char* catalog_path, const char* id, AppHubCatalogEntry& entry); + +/** @return the platform name of this device in the catalog's target platforms, e.g. "esp32s3" or "posix-x86_64" */ +const char* app_hub_catalog_get_platform(); /** @return true when this device can install and run the entry's app (target platform, device and RAM requirements) */ bool app_hub_catalog_is_compatible(const AppHubCatalogEntry& entry); +/** @return the path of the certificate that verifies the catalog server, in the "system" file system, or an empty string when it isn't available */ +std::string app_hub_catalog_get_certificate_path(); + std::string app_hub_catalog_get_url(); std::string app_hub_catalog_get_download_url(const std::string& file); diff --git a/Modules/app-hub-module/private/app_hub/app_hub_service.h b/Modules/app-hub-module/private/app_hub/app_hub_service.h index 3670c4d11..2d7c2f4b1 100644 --- a/Modules/app-hub-module/private/app_hub/app_hub_service.h +++ b/Modules/app-hub-module/private/app_hub/app_hub_service.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include #include /** The service id, which also determines where the catalog is stored */ @@ -8,5 +9,8 @@ extern const ServiceManifest app_hub_service_manifest; +/** The "apphub" command-line app */ +extern "C" const AppManifest app_hub_command_manifest; + /** @return true while the background task that runs the operations exists */ bool app_hub_is_worker_running(); diff --git a/Modules/app-hub-module/source/app_hub_catalog.cpp b/Modules/app-hub-module/source/app_hub_catalog.cpp index 2b0d9ab15..2dfb980b3 100644 --- a/Modules/app-hub-module/source/app_hub_catalog.cpp +++ b/Modules/app-hub-module/source/app_hub_catalog.cpp @@ -3,10 +3,14 @@ #include +#include #include +#include #include +#include + #ifdef ESP_PLATFORM #include #endif @@ -15,6 +19,7 @@ #include #include #include +#include #include #include @@ -27,6 +32,16 @@ static std::string get_version_without_postfix() { return (index == std::string::npos) ? version : version.substr(0, index); } +std::string app_hub_catalog_get_certificate_path() { + // The system file system is mounted at "/system" on ESP32, but it's a folder elsewhere on the simulator + FileSystem* system = file_system_find_by_name("system"); + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (system == nullptr || file_system_get_path(system, path, sizeof(path)) != ERROR_NONE) { + return ""; + } + return std::string(path) + "/certificates/GTS_Root_R4.pem"; +} + std::string app_hub_catalog_get_url() { return std::format("{}/{}/apps.json", BASE_URL, get_version_without_postfix()); } @@ -100,8 +115,9 @@ static std::unique_ptr read_file(const char* path) { if (fseek(file, 0, SEEK_END) == 0) { const long size = ftell(file); if (size >= 0 && fseek(file, 0, SEEK_SET) == 0) { - data = std::make_unique(size + 1); - if (fread(data.get(), 1, size, file) == static_cast(size)) { + // Exceptions are disabled, so a failed allocation must not abort + data.reset(new (std::nothrow) char[size + 1]); + if (data != nullptr && fread(data.get(), 1, size, file) == static_cast(size)) { data[size] = '\0'; } else { data = nullptr; @@ -112,19 +128,114 @@ static std::unique_ptr read_file(const char* path) { return data; } -bool app_hub_catalog_parse(const char* path, AppHubCatalog& catalog) { - auto data = read_file(path); +namespace { + +constexpr char FIELD_SEPARATOR = '\t'; +constexpr char LIST_SEPARATOR = ','; +constexpr size_t FIELD_COUNT = 10; +// Bounds the stack buffer of a reader. A longer entry makes the conversion fail. +constexpr size_t LINE_MAX_SIZE = 1024; + +/** Control characters, such as the field separator, can't be in a catalog line */ +void append_text(std::string& line, const std::string& text) { + for (const char character : text) { + line += (static_cast(character) < ' ') ? ' ' : character; + } + line += FIELD_SEPARATOR; +} + +bool append_list(std::string& line, const std::vector& items) { + for (size_t i = 0; i < items.size(); i++) { + if (items[i].find(LIST_SEPARATOR) != std::string::npos) { + return false; + } + if (i > 0) { + line += LIST_SEPARATOR; + } + append_text(line, items[i]); + line.pop_back(); + } + line += FIELD_SEPARATOR; + return true; +} + +bool format_entry(const AppHubCatalogEntry& entry, std::string& line) { + line.clear(); + append_text(line, entry.id); + append_text(line, entry.version_name); + line += std::to_string(entry.version_code) + FIELD_SEPARATOR; + append_text(line, entry.name); + append_text(line, entry.description); + append_text(line, entry.target_sdk); + if (!append_list(line, entry.target_platforms) || !append_list(line, entry.requires_device_id)) { + return false; + } + line += std::to_string(entry.requires_ram) + FIELD_SEPARATOR; + append_text(line, entry.file); + line.back() = '\n'; + return line.size() < LINE_MAX_SIZE; +} + +std::vector split(std::string_view text, char separator) { + std::vector parts; + size_t start = 0; + while (true) { + const size_t end = text.find(separator, start); + if (end == std::string_view::npos) { + parts.emplace_back(text.substr(start)); + return parts; + } + parts.emplace_back(text.substr(start, end - start)); + start = end + 1; + } +} + +std::vector split_list(const std::string& text) { + return text.empty() ? std::vector {} : split(text, LIST_SEPARATOR); +} + +bool parse_line(std::string_view line, AppHubCatalogEntry& entry) { + const auto fields = split(line, FIELD_SEPARATOR); + if (fields.size() != FIELD_COUNT) { + return false; + } + char* end = nullptr; + const long version_code = strtol(fields[2].c_str(), &end, 10); + if (fields[2].empty() || *end != '\0') { + return false; + } + const long requires_ram = strtol(fields[8].c_str(), &end, 10); + if (fields[8].empty() || *end != '\0') { + return false; + } + entry.id = fields[0]; + entry.version_name = fields[1]; + entry.version_code = static_cast(version_code); + entry.name = fields[3]; + entry.description = fields[4]; + entry.target_sdk = fields[5]; + entry.target_platforms = split_list(fields[6]); + entry.requires_device_id = split_list(fields[7]); + entry.requires_ram = static_cast(requires_ram); + entry.file = fields[9]; + return true; +} + +} // namespace + +bool app_hub_catalog_convert(const char* json_path, const char* catalog_path) { + auto data = read_file(json_path); if (data == nullptr) { - LOG_E(TAG, "Failed to read %s", path); + LOG_E(TAG, "Failed to read %s", json_path); return false; } cJSON* json = cJSON_Parse(data.get()); + data = nullptr; if (json == nullptr) { - LOG_E(TAG, "Failed to parse %s", path); + LOG_E(TAG, "Failed to parse %s", json_path); return false; } - const cJSON* apps = cJSON_GetObjectItemCaseSensitive(json, "apps"); if (!cJSON_IsArray(apps)) { LOG_E(TAG, "apps is not an array"); @@ -132,31 +243,97 @@ bool app_hub_catalog_parse(const char* path, AppHubCatalog& catalog) { return false; } - AppHubCatalog parsed; - parsed.resize(cJSON_GetArraySize(apps)); - size_t index = 0; + FILE* file = fopen(catalog_path, "wb"); + if (file == nullptr) { + LOG_E(TAG, "Failed to open %s", catalog_path); + cJSON_Delete(json); + return false; + } + bool success = true; + std::string line; const cJSON* item; cJSON_ArrayForEach(item, apps) { - if (!parse_entry(item, parsed[index++])) { + AppHubCatalogEntry entry; + if (!parse_entry(item, entry)) { LOG_E(TAG, "Failed to read entry"); - cJSON_Delete(json); - return false; + success = false; + break; + } + if (!format_entry(entry, line)) { + LOG_E(TAG, "%s can't be stored", entry.id.c_str()); + success = false; + break; + } + if (fwrite(line.data(), 1, line.size(), file) != line.size()) { + LOG_E(TAG, "Failed to write %s", catalog_path); + success = false; + break; } } - cJSON_Delete(json); - catalog = std::move(parsed); - return true; + success = (fclose(file) == 0) && success; + if (!success) { + remove(catalog_path); + } + return success; +} + +bool app_hub_catalog_for_each(const char* catalog_path, AppHubCatalogVisitor visitor, void* context) { + FILE* file = fopen(catalog_path, "rb"); + if (file == nullptr) { + return false; + } + bool success = true; + char line[LINE_MAX_SIZE]; + while (fgets(line, sizeof(line), file) != nullptr) { + const size_t length = strlen(line); + AppHubCatalogEntry entry; + if (length == 0 || line[length - 1] != '\n' || !parse_line(std::string_view(line, length - 1), entry)) { + LOG_E(TAG, "Malformed catalog %s", catalog_path); + success = false; + break; + } + if (!visitor(entry, context)) { + break; + } + } + fclose(file); + return success; +} + +bool app_hub_catalog_find(const char* catalog_path, const char* id, AppHubCatalogEntry& entry) { + struct Search { + const char* id; + AppHubCatalogEntry& entry; + bool found; + } search { id, entry, false }; + app_hub_catalog_for_each(catalog_path, [](const AppHubCatalogEntry& candidate, void* context) { + auto* search = static_cast(context); + if (candidate.id != search->id) { + return true; + } + search->entry = candidate; + search->found = true; + return false; + }, &search); + return search.found; +} + +const char* app_hub_catalog_get_platform() { +#ifdef ESP_PLATFORM + return CONFIG_IDF_TARGET; +#else + // Same name as the folder of the app binary that the POSIX app loader looks for + return "posix-" TACTILITY_POSIX_ARCH; +#endif } bool app_hub_catalog_is_compatible(const AppHubCatalogEntry& entry) { - // The simulator isn't a real MCU target, so it has no platform to match against -#ifdef ESP_PLATFORM + // An empty list means that the app supports every platform if (!entry.target_platforms.empty() && - std::ranges::find(entry.target_platforms, std::string_view(CONFIG_IDF_TARGET)) == entry.target_platforms.end()) { + std::ranges::find(entry.target_platforms, std::string_view(app_hub_catalog_get_platform())) == entry.target_platforms.end()) { return false; } -#endif // Same device and RAM rules as for installed packages PackageManifest package {}; diff --git a/Modules/app-hub-module/source/app_hub_command.cpp b/Modules/app-hub-module/source/app_hub_command.cpp new file mode 100644 index 000000000..41470eb1b --- /dev/null +++ b/Modules/app-hub-module/source/app_hub_command.cpp @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include + +constexpr TickType_t OPERATION_TIMEOUT = pdMS_TO_TICKS(180000); +constexpr size_t DESCRIPTION_MAX_SIZE = 2048; + +static void print_usage() { + printf( + "usage: apphub \n" + " refresh\n" + " search [query]\n" + " updates\n" + " info \n" + " install \n" + " update\n" + " remove \n" + ); +} + +static int report_error(const char* action, error_t error) { + if (error == ERROR_RESOURCE_BUSY) { + printf("apphub: %s failed: another app is being installed or removed\n", action); + } else { + printf("apphub: %s failed: %s\n", action, error_to_string(error)); + } + return 1; +} + +/** + * Queues an operation and waits for its result. + * The subscription is made before queueing, so the operation can't finish unnoticed. + * It times out after OPERATION_TIMEOUT without events of the operation, so a long update of many apps doesn't. + * @param[in] finished_type the event that ends the operation and carries its result + * @param[in] on_event called for the other events of the operation (optional) + */ +static error_t run_and_wait( + const char* id, + error_t (*operation)(const char* id, uint32_t* operation_id), + AppHubEventType finished_type, + void (*on_event)(const AppHubEvent& event) = nullptr +) { + TaskEventGroup event_group {}; + task_event_group_construct(&event_group); + AppHubEventSubscription subscription {}; + error_t error = app_hub_event_subscribe(&subscription, &event_group); + if (error != ERROR_NONE) { + task_event_group_destruct(&event_group); + return error; + } + + uint32_t operation_id = 0; + error = operation(id, &operation_id); + if (error == ERROR_NONE) { + error = ERROR_TIMEOUT; + TickType_t start = get_ticks(); + TickType_t elapsed = 0; + bool finished = false; + while (!finished && elapsed < OPERATION_TIMEOUT) { + if (app_hub_event_await(&subscription, OPERATION_TIMEOUT - elapsed) == ERROR_NONE) { + AppHubEvent event {}; + while (!finished && app_hub_event_poll(&subscription, &event) == ERROR_NONE) { + if (event.operation_id != operation_id) { + continue; + } + start = get_ticks(); + if (event.type == finished_type) { + error = event.result; + finished = true; + } else if (on_event != nullptr) { + on_event(event); + } + } + } + elapsed = get_ticks() - start; + } + } + + app_hub_event_unsubscribe(&subscription); + task_event_group_destruct(&event_group); + return error; +} + +static error_t refresh(const char* /*id*/, uint32_t* operation_id) { + return app_hub_refresh(operation_id); +} + +/** Collects the apps first, as printing can block while app_hub_search() holds the service's lock */ +static std::vector search(const char* query) { + std::vector apps; + app_hub_search(query, [](const AppHubApp* app, void* context) { + static_cast*>(context)->push_back(*app); + return true; + }, &apps); + return apps; +} + +static void print_app(const AppHubApp& app) { + const char marker = app.update_available ? '^' : (app.installed ? '*' : ' '); + const char* version = app.in_catalog ? app.version_name : app.installed_version_name; + printf("%c %-32s %-24s %s\n", marker, app.id, app.name, version); +} + +static int command_refresh() { + printf("Downloading the catalog...\n"); + const error_t error = run_and_wait(nullptr, refresh, APP_HUB_EVENT_REFRESHED); + if (error != ERROR_NONE) { + return report_error("refresh", error); + } + printf("Catalog updated\n"); + return 0; +} + +static int command_search(int argc, char* argv[], bool updates_only) { + if (argc > 3 || (updates_only && argc > 2)) { + print_usage(); + return 1; + } + + const auto apps = search(argc == 3 ? argv[2] : nullptr); + size_t count = 0; + for (const auto& app : apps) { + if (!updates_only || app.update_available) { + print_app(app); + count++; + } + } + if (count == 0) { + printf(updates_only ? "No updates available\n" : "No apps found. Run 'apphub refresh' to download the catalog.\n"); + } else if (!updates_only) { + printf("(* = installed, ^ = update available)\n"); + } + return 0; +} + +static int command_info(int argc, char* argv[]) { + if (argc != 3) { + printf("usage: apphub info \n"); + return 1; + } + + AppHubApp app {}; + if (app_hub_get_app(argv[2], &app) != ERROR_NONE) { + printf("apphub: unknown app: %s\n", argv[2]); + return 1; + } + + printf("id: %s\n", app.id); + printf("name: %s\n", app.name); + if (app.in_catalog) { + printf("version: %s\n", app.version_name); + } + if (app.installed) { + printf("installed: %s%s\n", app.installed_version_name, app.update_available ? " (update available)" : ""); + } else { + printf("installed: no\n"); + } + + auto description = std::make_unique(DESCRIPTION_MAX_SIZE); + if (app_hub_get_description(app.id, description.get(), DESCRIPTION_MAX_SIZE) == ERROR_NONE && description[0] != '\0') { + printf("\n%s\n", description.get()); + } + return 0; +} + +static int command_install(int argc, char* argv[]) { + if (argc != 3) { + printf("usage: apphub install \n"); + return 1; + } + + printf("Installing %s...\n", argv[2]); + const error_t error = run_and_wait(argv[2], app_hub_install, APP_HUB_EVENT_INSTALLED); + if (error == ERROR_NOT_FOUND) { + printf("apphub: %s is not in the catalog\n", argv[2]); + return 1; + } else if (error != ERROR_NONE) { + return report_error("install", error); + } + printf("Installed %s\n", argv[2]); + return 0; +} + +static error_t update_all(const char* /*id*/, uint32_t* operation_id) { + return app_hub_update_all(operation_id); +} + +static void print_update_progress(const AppHubEvent& event) { + if (event.type == APP_HUB_EVENT_INSTALLING) { + printf("Updating %s...\n", event.app_id); + } else if (event.type == APP_HUB_EVENT_INSTALLED) { + if (event.result == ERROR_NONE) { + printf("Updated %s\n", event.app_id); + } else { + printf("apphub: update of %s failed: %s\n", event.app_id, error_to_string(event.result)); + } + } +} + +static int command_update(int argc) { + if (argc != 2) { + printf("usage: apphub update\n"); + return 1; + } + + // Counted first, as the update reports nothing when there are no updates + size_t count = 0; + for (const auto& app : search(nullptr)) { + if (app.update_available) { + count++; + } + } + if (count == 0) { + printf("No updates available\n"); + return 0; + } + + const error_t error = run_and_wait(nullptr, update_all, APP_HUB_EVENT_UPDATED, print_update_progress); + if (error == ERROR_RESOURCE_BUSY || error == ERROR_TIMEOUT) { + return report_error("update", error); + } else if (error != ERROR_NONE) { + printf("apphub: some apps failed to update\n"); + return 1; + } + printf("All apps are up to date\n"); + return 0; +} + +static int command_remove(int argc, char* argv[]) { + if (argc != 3) { + printf("usage: apphub remove \n"); + return 1; + } + + printf("Removing %s...\n", argv[2]); + const error_t error = run_and_wait(argv[2], app_hub_remove, APP_HUB_EVENT_REMOVED); + if (error == ERROR_NOT_FOUND) { + printf("apphub: %s is not installed\n", argv[2]); + return 1; + } else if (error != ERROR_NONE) { + return report_error("remove", error); + } + printf("Removed %s\n", argv[2]); + return 0; +} + +static int32_t app_hub_command_main(int argc, char* argv[]) { + if (argc < 2 || strcmp(argv[1], "help") == 0) { + print_usage(); + return 0; + } + + const char* command = argv[1]; + if (strcmp(command, "refresh") == 0) { + return command_refresh(); + } else if (strcmp(command, "search") == 0) { + return command_search(argc, argv, false); + } else if (strcmp(command, "updates") == 0) { + return command_search(argc, argv, true); + } else if (strcmp(command, "info") == 0) { + return command_info(argc, argv); + } else if (strcmp(command, "install") == 0) { + return command_install(argc, argv); + } else if (strcmp(command, "update") == 0) { + return command_update(argc); + } else if (strcmp(command, "remove") == 0) { + return command_remove(argc, argv); + } + + printf("apphub: unknown command: %s\n", command); + print_usage(); + return 1; +} + +extern "C" { + +const AppManifest app_hub_command_manifest = { + .id = "apphub", + .name = "apphub", + .category = APP_CATEGORY_SYSTEM, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(app_hub_command_main) }, + .flags = APP_MANIFEST_FLAG_HEADLESS, + .stack = { .depth = 5120, .desired_memory_capability = 0 }, +}; + +} diff --git a/Modules/app-hub-module/source/app_hub_service.cpp b/Modules/app-hub-module/source/app_hub_service.cpp index 0056b7100..5f350f29c 100644 --- a/Modules/app-hub-module/source/app_hub_service.cpp +++ b/Modules/app-hub-module/source/app_hub_service.cpp @@ -15,10 +15,14 @@ #include #include #include +#include #include #include +#include + #include +#include #include #include #include @@ -30,8 +34,11 @@ namespace { constexpr auto* TAG = "app_hub"; -constexpr auto* CATALOG_FILE_NAME = "apps.json"; +constexpr auto* CATALOG_FILE_NAME = "catalog.txt"; +constexpr auto* CATALOG_TEMP_FILE_NAME = "catalog.txt.tmp"; constexpr auto* CATALOG_DOWNLOAD_FILE_NAME = "apps.json.tmp"; +// Older versions kept the downloaded apps.json as the catalog +constexpr auto* LEGACY_CATALOG_FILE_NAME = "apps.json"; constexpr size_t WORKER_STACK_SIZE = 8192; constexpr TickType_t WORKER_IDLE_TIMEOUT = pdMS_TO_TICKS(5000); @@ -61,21 +68,36 @@ void unlock() { mutex_unlock(&globalMutex.handle); } // region State, guarded by the global lock -AppHubCatalog catalog; -bool catalogLoaded = false; -std::deque queue; -Operation runningOperation {}; -bool operationRunning = false; -uint32_t nextOperationId = 1; -AppHubEventSubscription* subscriptions = nullptr; +struct State { + std::deque> queue; + Operation runningOperation {}; + bool operationRunning = false; + uint32_t nextOperationId = 1; + AppHubEventSubscription* subscriptions = nullptr; -bool serviceRunning = false; -bool stopping = false; -Thread* worker = nullptr; -bool workerRunning = false; -TaskEventGroup workerEvents {}; -uint32_t queueBit = 0; -uint32_t stopBit = 0; + bool serviceRunning = false; + bool stopping = false; + Thread* worker = nullptr; + bool workerRunning = false; + TaskEventGroup workerEvents {}; + uint32_t queueBit = 0; + uint32_t stopBit = 0; +}; + +constexpr MemoryPolicy STATE_MEMORY_POLICY = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = alignof(State) }; + +/** Exists while the service does */ +State* state = nullptr; + +/** Locks, and unlocks again when the service doesn't exist */ +bool lockState() { + lock(); + if (state != nullptr) { + return true; + } + unlock(); + return false; +} // endregion @@ -86,6 +108,7 @@ AppHubEventType getStartedEventType(AppHubOperationType operation) { case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHING; case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLING; case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVING; + case APP_HUB_OPERATION_UPDATE_ALL: return APP_HUB_EVENT_UPDATING; } return APP_HUB_EVENT_REFRESHING; } @@ -95,18 +118,22 @@ AppHubEventType getFinishedEventType(AppHubOperationType operation) { case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHED; case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLED; case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVED; + case APP_HUB_OPERATION_UPDATE_ALL: return APP_HUB_EVENT_UPDATED; } return APP_HUB_EVENT_REFRESHED; } -/** Requires the global lock */ -void emitEvent(AppHubEventType type, const Operation& operation, error_t result) { +/** + * Requires the global lock + * @param[in] appId the app the event is about, which an update of all apps reports per app + */ +void emitEvent(AppHubEventType type, const Operation& operation, const char* appId, error_t result) { AppHubEvent event {}; event.type = type; event.operation_id = operation.id; - strcpy(event.app_id, operation.app_id); + strcpy(event.app_id, appId); event.result = result; - for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) { + for (auto* sub = state->subscriptions; sub != nullptr; sub = sub->internal.next) { if (sub->internal.count >= APP_HUB_EVENT_QUEUE_CAPACITY) { LOG_W(TAG, "A subscriber's event queue is full: dropped an event of operation %lu", static_cast(event.operation_id)); continue; @@ -126,26 +153,32 @@ bool getCatalogPath(const char* fileName, char* path, size_t pathSize) { return service_paths_get_user_data_path(APP_HUB_SERVICE_ID, fileName, path, pathSize) == ERROR_NONE; } -/** Requires the global lock */ -void ensureCatalogLoaded() { - if (catalogLoaded) { - return; - } - char path[FILE_MAX_PATH_STRING_LENGTH]; - if (getCatalogPath(CATALOG_FILE_NAME, path, sizeof(path)) && path_exists(path)) { - catalogLoaded = app_hub_catalog_parse(path, catalog); +/** + * Converts the apps.json of an older version into a catalog file + * @return false when there is no catalog + */ +bool migrateLegacyCatalog(const char* catalogPath) { + char legacyPath[FILE_MAX_PATH_STRING_LENGTH]; + if (!getCatalogPath(LEGACY_CATALOG_FILE_NAME, legacyPath, sizeof(legacyPath)) || !path_exists(legacyPath)) { + return false; } + const bool converted = app_hub_catalog_convert(legacyPath, catalogPath); + file_remove(legacyPath); + return converted; +} + +/** + * @param[out] path the path of the catalog file + * @return false when there is no catalog + */ +bool getCatalogFile(char* path, size_t pathSize) { + return getCatalogPath(CATALOG_FILE_NAME, path, pathSize) && (path_exists(path) || migrateLegacyCatalog(path)); } /** Requires the global lock */ -const AppHubCatalogEntry* findCatalogEntry(const char* id) { - ensureCatalogLoaded(); - for (const auto& entry : catalog) { - if (entry.id == id) { - return &entry; - } - } - return nullptr; +bool findCatalogEntry(const char* id, AppHubCatalogEntry& entry) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + return getCatalogFile(path, sizeof(path)) && app_hub_catalog_find(path, id, entry); } // endregion @@ -174,10 +207,10 @@ std::vector getInstalledPackages() { /** Requires the global lock. Only one refresh and one install or remove can be queued or running at a time. */ const Operation* findPendingOperationOfKind(bool refresh) { - if (operationRunning && (runningOperation.type == APP_HUB_OPERATION_REFRESH) == refresh) { - return &runningOperation; + if (state->operationRunning && (state->runningOperation.type == APP_HUB_OPERATION_REFRESH) == refresh) { + return &state->runningOperation; } - for (const auto& operation : queue) { + for (const auto& operation : state->queue) { if ((operation.type == APP_HUB_OPERATION_REFRESH) == refresh) { return &operation; } @@ -187,10 +220,10 @@ const Operation* findPendingOperationOfKind(bool refresh) { /** Requires the global lock */ const Operation* findPendingOperation(const char* id) { - if (operationRunning && runningOperation.type != APP_HUB_OPERATION_REFRESH && strcmp(runningOperation.app_id, id) == 0) { - return &runningOperation; + if (state->operationRunning && state->runningOperation.type != APP_HUB_OPERATION_REFRESH && strcmp(state->runningOperation.app_id, id) == 0) { + return &state->runningOperation; } - for (const auto& operation : queue) { + for (const auto& operation : state->queue) { if (operation.type != APP_HUB_OPERATION_REFRESH && strcmp(operation.app_id, id) == 0) { return &operation; } @@ -200,19 +233,21 @@ const Operation* findPendingOperation(const char* id) { /** Requires the global lock */ AppList collectApps(const std::vector& installedPackages) { - ensureCatalogLoaded(); AppList apps; - for (const auto& entry : catalog) { - if (!app_hub_catalog_is_compatible(entry)) { - continue; - } - AppHubApp app {}; - copyString(app.id, sizeof(app.id), entry.id.c_str()); - copyString(app.name, sizeof(app.name), entry.name.c_str()); - copyString(app.version_name, sizeof(app.version_name), entry.version_name.c_str()); - app.version_code = static_cast(entry.version_code); - app.in_catalog = true; - apps.push_back(app); + char catalogPath[FILE_MAX_PATH_STRING_LENGTH]; + if (getCatalogFile(catalogPath, sizeof(catalogPath))) { + app_hub_catalog_for_each(catalogPath, [](const AppHubCatalogEntry& entry, void* context) { + if (app_hub_catalog_is_compatible(entry)) { + AppHubApp app {}; + copyString(app.id, sizeof(app.id), entry.id.c_str()); + copyString(app.name, sizeof(app.name), entry.name.c_str()); + copyString(app.version_name, sizeof(app.version_name), entry.version_name.c_str()); + app.version_code = static_cast(entry.version_code); + app.in_catalog = true; + static_cast(context)->push_back(app); + } + return true; + }, &apps); } for (const auto& installed : installedPackages) { @@ -235,8 +270,13 @@ AppList collectApps(const std::vector& installedPackages) { app->update_available = app->in_catalog && app->installed_version_code < app->version_code; } + const Operation* pendingAppOperation = findPendingOperationOfKind(false); + const bool updatingAll = pendingAppOperation != nullptr && pendingAppOperation->type == APP_HUB_OPERATION_UPDATE_ALL; for (auto& app : apps) { const Operation* pending = findPendingOperation(app.id); + if (pending == nullptr && updatingAll && app.update_available) { + pending = pendingAppOperation; + } app.operation_pending = pending != nullptr; if (pending != nullptr) { app.pending_operation = pending->type; @@ -265,18 +305,18 @@ bool lessIgnoringCase(const AppHubApp& left, const AppHubApp& right) { error_t download(const char* url, const char* targetPath) { HttpDownloadSubscription sub {}; - if (http_download_subscribe(&sub, &workerEvents) != ERROR_NONE) { + if (http_download_subscribe(&sub, &state->workerEvents) != ERROR_NONE) { return ERROR_RESOURCE; } - if (http_download_start(url, APP_HUB_CERTIFICATE_PATH, targetPath, &sub) != ERROR_NONE) { + if (http_download_start(url, app_hub_catalog_get_certificate_path().c_str(), targetPath, &sub) != ERROR_NONE) { http_download_unsubscribe(&sub); return ERROR_RESOURCE; } while (true) { uint32_t flags = 0; - task_event_group_wait(&workerEvents, sub.bit | stopBit, false, &flags, portMAX_DELAY); - if (flags & stopBit) { + task_event_group_wait(&state->workerEvents, sub.bit | state->stopBit, false, &flags, portMAX_DELAY); + if (flags & state->stopBit) { http_download_cancel(&sub); http_download_unsubscribe(&sub); return ERROR_INVALID_STATE; @@ -302,9 +342,11 @@ error_t executeRefresh() { char directory[FILE_MAX_PATH_STRING_LENGTH]; char downloadPath[FILE_MAX_PATH_STRING_LENGTH]; char catalogPath[FILE_MAX_PATH_STRING_LENGTH]; + char tempCatalogPath[FILE_MAX_PATH_STRING_LENGTH]; if (service_paths_get_user_data_directory(APP_HUB_SERVICE_ID, directory, sizeof(directory)) != ERROR_NONE || !getCatalogPath(CATALOG_DOWNLOAD_FILE_NAME, downloadPath, sizeof(downloadPath)) || - !getCatalogPath(CATALOG_FILE_NAME, catalogPath, sizeof(catalogPath))) { + !getCatalogPath(CATALOG_FILE_NAME, catalogPath, sizeof(catalogPath)) || + !getCatalogPath(CATALOG_TEMP_FILE_NAME, tempCatalogPath, sizeof(tempCatalogPath))) { return ERROR_NOT_FOUND; } error_t error = ensureDirectory(directory); @@ -318,33 +360,28 @@ error_t executeRefresh() { return error; } - AppHubCatalog downloaded; - if (!app_hub_catalog_parse(downloadPath, downloaded)) { - file_remove(downloadPath); + // The catalog file is only replaced by a complete conversion + const bool converted = app_hub_catalog_convert(downloadPath, tempCatalogPath); + file_remove(downloadPath); + if (!converted) { return ERROR_INVALID_ARGUMENT; } - error = file_move(downloadPath, catalogPath, true); - if (error != ERROR_NONE) { - return error; - } - lock(); - catalog = std::move(downloaded); - catalogLoaded = true; + error = file_move(tempCatalogPath, catalogPath, true); unlock(); - return ERROR_NONE; + return error; } error_t executeInstall(const char* id) { + AppHubCatalogEntry entry; lock(); - const AppHubCatalogEntry* entry = findCatalogEntry(id); - const bool compatible = entry != nullptr && app_hub_catalog_is_compatible(*entry); - const std::string file = (entry != nullptr) ? entry->file : ""; + const bool found = findCatalogEntry(id, entry); unlock(); - if (entry == nullptr) { + if (!found) { return ERROR_NOT_FOUND; } - if (!compatible) { + const std::string& file = entry.file; + if (!app_hub_catalog_is_compatible(entry)) { return ERROR_NOT_SUPPORTED; } @@ -374,6 +411,41 @@ error_t executeInstall(const char* id) { return error; } +error_t executeUpdateAll(const Operation& operation) { + const auto installed = getInstalledPackages(); + std::vector ids; + lock(); + for (const auto& app : collectApps(installed)) { + if (app.update_available) { + ids.emplace_back(app.id); + } + } + unlock(); + + error_t lastError = ERROR_NONE; + for (const auto& id : ids) { + lock(); + const bool stop = state->stopping; + if (!stop) { + emitEvent(APP_HUB_EVENT_INSTALLING, operation, id.c_str(), ERROR_NONE); + } + unlock(); + if (stop) { + return ERROR_INVALID_STATE; + } + + const error_t error = executeInstall(id.c_str()); + if (error != ERROR_NONE) { + lastError = error; + } + + lock(); + emitEvent(APP_HUB_EVENT_INSTALLED, operation, id.c_str(), error); + unlock(); + } + return lastError; +} + error_t execute(const Operation& operation) { switch (operation.type) { case APP_HUB_OPERATION_REFRESH: @@ -382,6 +454,8 @@ error_t execute(const Operation& operation) { return executeInstall(operation.app_id); case APP_HUB_OPERATION_REMOVE: return app_uninstall(operation.app_id); + case APP_HUB_OPERATION_UPDATE_ALL: + return executeUpdateAll(operation); } return ERROR_INVALID_ARGUMENT; } @@ -389,21 +463,21 @@ error_t execute(const Operation& operation) { int32_t workerMain(void* /*context*/) { while (true) { lock(); - if (stopping) { - workerRunning = false; + if (state->stopping) { + state->workerRunning = false; unlock(); return 0; } - if (queue.empty()) { + if (state->queue.empty()) { unlock(); uint32_t flags = 0; - const error_t waited = task_event_group_wait(&workerEvents, queueBit | stopBit, false, &flags, WORKER_IDLE_TIMEOUT); + const error_t waited = task_event_group_wait(&state->workerEvents, state->queueBit | state->stopBit, false, &flags, WORKER_IDLE_TIMEOUT); if (waited == ERROR_TIMEOUT) { // An operation can be queued between the wait and taking the lock lock(); - const bool idle = queue.empty(); + const bool idle = state->queue.empty(); if (idle) { - workerRunning = false; + state->workerRunning = false; } unlock(); if (idle) { @@ -413,42 +487,42 @@ int32_t workerMain(void* /*context*/) { continue; } - runningOperation = queue.front(); - queue.pop_front(); - operationRunning = true; - const Operation operation = runningOperation; - emitEvent(getStartedEventType(operation.type), operation, ERROR_NONE); + state->runningOperation = state->queue.front(); + state->queue.pop_front(); + state->operationRunning = true; + const Operation operation = state->runningOperation; + emitEvent(getStartedEventType(operation.type), operation, operation.app_id, ERROR_NONE); unlock(); const error_t result = execute(operation); lock(); - operationRunning = false; - emitEvent(getFinishedEventType(operation.type), operation, result); + state->operationRunning = false; + emitEvent(getFinishedEventType(operation.type), operation, operation.app_id, result); unlock(); } } /** Requires the global lock */ error_t ensureWorkerRunning() { - if (workerRunning) { + if (state->workerRunning) { return ERROR_NONE; } - if (worker != nullptr) { + if (state->worker != nullptr) { // The previous worker cleared workerRunning and exits without taking the lock - thread_join(worker, portMAX_DELAY, pdMS_TO_TICKS(10)); - thread_free(worker); + thread_join(state->worker, portMAX_DELAY, pdMS_TO_TICKS(10)); + thread_free(state->worker); } - worker = thread_alloc_full("apphub", WORKER_STACK_SIZE, workerMain, nullptr, -1); - if (worker == nullptr) { + state->worker = thread_alloc_full("apphub", WORKER_STACK_SIZE, workerMain, nullptr, -1); + if (state->worker == nullptr) { return ERROR_OUT_OF_MEMORY; } - if (thread_start(worker) != ERROR_NONE) { - thread_free(worker); - worker = nullptr; + if (thread_start(state->worker) != ERROR_NONE) { + thread_free(state->worker); + state->worker = nullptr; return ERROR_RESOURCE; } - workerRunning = true; + state->workerRunning = true; return ERROR_NONE; } @@ -457,8 +531,10 @@ error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId) return ERROR_INVALID_ARGUMENT; } - lock(); - if (!serviceRunning) { + if (!lockState()) { + return ERROR_INVALID_STATE; + } + if (!state->serviceRunning) { unlock(); return ERROR_INVALID_STATE; } @@ -474,7 +550,7 @@ error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId) } Operation operation {}; - operation.id = nextOperationId++; + operation.id = state->nextOperationId++; operation.type = type; if (id != nullptr) { strcpy(operation.app_id, id); @@ -482,8 +558,8 @@ error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId) const error_t error = ensureWorkerRunning(); if (error == ERROR_NONE) { - queue.push_back(operation); - task_event_group_signal(&workerEvents, queueBit); + state->queue.push_back(operation); + task_event_group_signal(&state->workerEvents, state->queueBit); if (operationId != nullptr) { *operationId = operation.id; } @@ -497,44 +573,65 @@ error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId) // region Service void* createService(const ServiceManifest* /*manifest*/) { - return nullptr; + void* memory = memory_alloc_with_policy(sizeof(State), &STATE_MEMORY_POLICY); + if (memory == nullptr) { + return nullptr; + } + auto* created = new (memory) State(); + lock(); + state = created; + unlock(); + return created; } -void destroyService(const ServiceManifest* /*manifest*/, void* /*data*/) {} +void destroyService(const ServiceManifest* /*manifest*/, void* data) { + if (data == nullptr) { + return; + } + auto* destroyed = static_cast(data); + lock(); + state = nullptr; + unlock(); + destroyed->~State(); + memory_free(destroyed); +} -error_t onStart(ServiceInstance* /*instance*/, void* /*data*/) { - task_event_group_construct(&workerEvents); - if (task_event_group_claim_bit(&workerEvents, &queueBit) != ERROR_NONE || - task_event_group_claim_bit(&workerEvents, &stopBit) != ERROR_NONE) { - task_event_group_destruct(&workerEvents); +error_t onStart(ServiceInstance* /*instance*/, void* data) { + if (data == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + task_event_group_construct(&state->workerEvents); + if (task_event_group_claim_bit(&state->workerEvents, &state->queueBit) != ERROR_NONE || + task_event_group_claim_bit(&state->workerEvents, &state->stopBit) != ERROR_NONE) { + task_event_group_destruct(&state->workerEvents); return ERROR_RESOURCE; } lock(); - serviceRunning = true; - stopping = false; + state->serviceRunning = true; + state->stopping = false; unlock(); return ERROR_NONE; } void onStop(ServiceInstance* /*instance*/, void* /*data*/) { lock(); - serviceRunning = false; - stopping = true; - queue.clear(); - Thread* stoppingWorker = worker; - worker = nullptr; + state->serviceRunning = false; + state->stopping = true; + state->queue.clear(); + Thread* stoppingWorker = state->worker; + state->worker = nullptr; unlock(); if (stoppingWorker != nullptr) { - task_event_group_signal(&workerEvents, stopBit); + task_event_group_signal(&state->workerEvents, state->stopBit); thread_join(stoppingWorker, portMAX_DELAY, pdMS_TO_TICKS(10)); thread_free(stoppingWorker); } lock(); - workerRunning = false; + state->workerRunning = false; unlock(); - task_event_group_destruct(&workerEvents); + task_event_group_destruct(&state->workerEvents); } // endregion @@ -550,8 +647,10 @@ const ServiceManifest app_hub_service_manifest = { }; bool app_hub_is_worker_running() { - lock(); - const bool running = workerRunning; + if (!lockState()) { + return false; + } + const bool running = state->workerRunning; unlock(); return running; } @@ -562,7 +661,9 @@ extern "C" { error_t app_hub_search(const char* query, AppHubAppVisitorFn visitor, void* context) { const auto installed = getInstalledPackages(); - lock(); + if (!lockState()) { + return ERROR_INVALID_STATE; + } AppList apps = collectApps(installed); std::ranges::sort(apps, lessIgnoringCase); for (const auto& app : apps) { @@ -579,7 +680,9 @@ error_t app_hub_search(const char* query, AppHubAppVisitorFn visitor, void* cont error_t app_hub_get_app(const char* id, AppHubApp* app) { const auto installed = getInstalledPackages(); - lock(); + if (!lockState()) { + return ERROR_INVALID_STATE; + } const AppList apps = collectApps(installed); unlock(); const auto found = std::ranges::find_if(apps, [id](const AppHubApp& candidate) { @@ -593,24 +696,33 @@ error_t app_hub_get_app(const char* id, AppHubApp* app) { } error_t app_hub_get_description(const char* id, char* description, size_t description_size) { + AppHubCatalogEntry entry; lock(); - const AppHubCatalogEntry* entry = findCatalogEntry(id); - error_t error = ERROR_NONE; - if (entry == nullptr) { - error = ERROR_NOT_FOUND; - } else if (entry->description.size() >= description_size) { - error = ERROR_BUFFER_OVERFLOW; - } else { - strcpy(description, entry->description.c_str()); - } + const bool found = findCatalogEntry(id, entry); unlock(); - return error; + if (!found) { + return ERROR_NOT_FOUND; + } + if (entry.description.size() >= description_size) { + return ERROR_BUFFER_OVERFLOW; + } + strcpy(description, entry.description.c_str()); + return ERROR_NONE; } // endregion // region Operations +bool app_hub_is_busy() { + if (!lockState()) { + return false; + } + const bool busy = state->operationRunning || !state->queue.empty(); + unlock(); + return busy; +} + error_t app_hub_refresh(uint32_t* operation_id) { return enqueue(APP_HUB_OPERATION_REFRESH, nullptr, operation_id); } @@ -619,6 +731,10 @@ error_t app_hub_install(const char* id, uint32_t* operation_id) { return enqueue(APP_HUB_OPERATION_INSTALL, id, operation_id); } +error_t app_hub_update_all(uint32_t* operation_id) { + return enqueue(APP_HUB_OPERATION_UPDATE_ALL, nullptr, operation_id); +} + error_t app_hub_remove(const char* id, uint32_t* operation_id) { return enqueue(APP_HUB_OPERATION_REMOVE, id, operation_id); } @@ -634,8 +750,11 @@ error_t app_hub_event_subscribe(AppHubEventSubscription* sub, TaskEventGroup* ev return claimed; } - lock(); - for (auto* existing = subscriptions; existing != nullptr; existing = existing->internal.next) { + if (!lockState()) { + task_event_group_release_bit(event_group, bit); + return ERROR_INVALID_STATE; + } + for (auto* existing = state->subscriptions; existing != nullptr; existing = existing->internal.next) { if (existing == sub) { unlock(); task_event_group_release_bit(event_group, bit); @@ -646,16 +765,18 @@ error_t app_hub_event_subscribe(AppHubEventSubscription* sub, TaskEventGroup* ev sub->internal.event_group = event_group; sub->internal.head = 0; sub->internal.count = 0; - sub->internal.next = subscriptions; - subscriptions = sub; + sub->internal.next = state->subscriptions; + state->subscriptions = sub; unlock(); return ERROR_NONE; } error_t app_hub_event_unsubscribe(AppHubEventSubscription* sub) { error_t result = ERROR_NOT_FOUND; - lock(); - for (auto** link = &subscriptions; *link != nullptr; link = &(*link)->internal.next) { + if (!lockState()) { + return result; + } + for (auto** link = &state->subscriptions; *link != nullptr; link = &(*link)->internal.next) { if (*link == sub) { *link = sub->internal.next; result = ERROR_NONE; diff --git a/Modules/app-hub-module/source/module.cpp b/Modules/app-hub-module/source/module.cpp index 0e26b81a1..487cca502 100644 --- a/Modules/app-hub-module/source/module.cpp +++ b/Modules/app-hub-module/source/module.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -11,23 +12,37 @@ extern "C" { static error_t start() { - return service_manager_add(&app_hub_service_manifest, /*auto_start=*/true); + error_t error = app_manager_add(&app_hub_command_manifest); + if (error != ERROR_NONE) { + return error; + } + error = service_manager_add(&app_hub_service_manifest, /*auto_start=*/true); + if (error != ERROR_NONE) { + app_manager_remove(app_hub_command_manifest.id); + } + return error; } static error_t stop() { - const error_t error = service_manager_stop(app_hub_service_manifest.id); + error_t error = service_manager_stop(app_hub_service_manifest.id); if (error != ERROR_NONE && error != ERROR_NOT_FOUND) { return error; } - return service_manager_remove(app_hub_service_manifest.id); + error = service_manager_remove(app_hub_service_manifest.id); + if (error != ERROR_NONE) { + return error; + } + return app_manager_remove(app_hub_command_manifest.id); } static const ModuleSymbol app_hub_module_symbols[] = { DEFINE_MODULE_SYMBOL(app_hub_search), DEFINE_MODULE_SYMBOL(app_hub_get_app), DEFINE_MODULE_SYMBOL(app_hub_get_description), + DEFINE_MODULE_SYMBOL(app_hub_is_busy), DEFINE_MODULE_SYMBOL(app_hub_refresh), DEFINE_MODULE_SYMBOL(app_hub_install), + DEFINE_MODULE_SYMBOL(app_hub_update_all), DEFINE_MODULE_SYMBOL(app_hub_remove), DEFINE_MODULE_SYMBOL(app_hub_event_subscribe), DEFINE_MODULE_SYMBOL(app_hub_event_unsubscribe), diff --git a/Modules/app-hub-module/tests/source/app_hub_test.cpp b/Modules/app-hub-module/tests/source/app_hub_test.cpp index a9c4cb702..258ed4967 100644 --- a/Modules/app-hub-module/tests/source/app_hub_test.cpp +++ b/Modules/app-hub-module/tests/source/app_hub_test.cpp @@ -249,6 +249,28 @@ TEST_CASE("app_hub drops newer events while a subscriber's queue is full") { task_event_group_destruct(&group); } +TEST_CASE("app_hub_update_all finishes without installs when nothing has an update") { + ensureFixture(); + TaskEventGroup group {}; + task_event_group_construct(&group); + AppHubEventSubscription sub {}; + REQUIRE_EQ(app_hub_event_subscribe(&sub, &group), ERROR_NONE); + + uint32_t id = 0; + REQUIRE_EQ(app_hub_update_all(&id), ERROR_NONE); + AppHubEvent event = waitForEvent(sub); + CHECK_EQ(event.type, APP_HUB_EVENT_UPDATING); + CHECK_EQ(event.operation_id, id); + CHECK_EQ(std::string(event.app_id), ""); + event = waitForEvent(sub); + CHECK_EQ(event.type, APP_HUB_EVENT_UPDATED); + CHECK_EQ(event.operation_id, id); + CHECK_EQ(event.result, ERROR_NONE); + + CHECK_EQ(app_hub_event_unsubscribe(&sub), ERROR_NONE); + task_event_group_destruct(&group); +} + TEST_CASE("app_hub rejects invalid ids") { ensureFixture(); CHECK_EQ(app_hub_install("", nullptr), ERROR_INVALID_ARGUMENT); diff --git a/Modules/app-hub-module/tests/source/catalog_test.cpp b/Modules/app-hub-module/tests/source/catalog_test.cpp index 7de8cda63..388c3d582 100644 --- a/Modules/app-hub-module/tests/source/catalog_test.cpp +++ b/Modules/app-hub-module/tests/source/catalog_test.cpp @@ -17,6 +17,17 @@ std::string writeAppsJson(const char* contents) { return path; } +const std::string CATALOG_PATH = "/tmp/tactility_app_hub_catalog_test.txt"; + +std::vector readCatalog() { + std::vector entries; + REQUIRE(app_hub_catalog_for_each(CATALOG_PATH.c_str(), [](const AppHubCatalogEntry& entry, void* context) { + static_cast*>(context)->push_back(entry); + return true; + }, &entries)); + return entries; +} + AppHubCatalogEntry entryWith(std::vector requiresDeviceId, int32_t requiresRam) { AppHubCatalogEntry entry {}; entry.requires_device_id = std::move(requiresDeviceId); @@ -26,47 +37,74 @@ AppHubCatalogEntry entryWith(std::vector requiresDeviceId, int32_t } // namespace -TEST_CASE("app hub: requiresDeviceId and requiresRam are parsed, and optional") { +TEST_CASE("app hub: converted entries keep all fields") { const auto path = writeAppsJson(R"({"apps": [ - {"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app", + {"appId": "a", "appVersionName": "1.2", "appVersionCode": 12, "appName": "A", "appDescription": "Tab\there", "targetSdk": "0.8.0", "targetPlatforms": ["esp32", "esp32s3"], "file": "a-1.app", "requiresDeviceId": ["m5stack-tab5", "lilygo-tdeck"], "requiresRam": 2}, {"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"} ]})"); - AppHubCatalog entries; - REQUIRE(app_hub_catalog_parse(path.c_str(), entries)); + REQUIRE(app_hub_catalog_convert(path.c_str(), CATALOG_PATH.c_str())); + const auto entries = readCatalog(); REQUIRE_EQ(entries.size(), 2u); + CHECK_EQ(entries[0].id, "a"); + CHECK_EQ(entries[0].version_name, "1.2"); + CHECK_EQ(entries[0].version_code, 12); + CHECK_EQ(entries[0].name, "A"); + CHECK_EQ(entries[0].description, "Tab here"); + CHECK_EQ(entries[0].target_sdk, "0.8.0"); + CHECK_EQ(entries[0].target_platforms, std::vector { "esp32", "esp32s3" }); + CHECK_EQ(entries[0].file, "a-1.app"); CHECK_EQ(entries[0].requires_device_id, std::vector { "m5stack-tab5", "lilygo-tdeck" }); CHECK_EQ(entries[0].requires_ram, 2); + CHECK(entries[1].target_platforms.empty()); CHECK(entries[1].requires_device_id.empty()); CHECK_EQ(entries[1].requires_ram, 0); remove(path.c_str()); + remove(CATALOG_PATH.c_str()); } -TEST_CASE("app hub: reparsing into the same list resets optional requirements") { - AppHubCatalog entries; - const auto restricted = writeAppsJson(R"({"apps": [ - {"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app", - "requiresDeviceId": ["m5stack-tab5"], "requiresRam": 2} - ]})"); - REQUIRE(app_hub_catalog_parse(restricted.c_str(), entries)); - const auto unrestricted = writeAppsJson(R"({"apps": [ +TEST_CASE("app hub: a catalog entry is found by id") { + const auto path = writeAppsJson(R"({"apps": [ + {"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app"}, {"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"} ]})"); - REQUIRE(app_hub_catalog_parse(unrestricted.c_str(), entries)); - REQUIRE_EQ(entries.size(), 1u); - CHECK(entries[0].requires_device_id.empty()); - CHECK_EQ(entries[0].requires_ram, 0); - remove(unrestricted.c_str()); + REQUIRE(app_hub_catalog_convert(path.c_str(), CATALOG_PATH.c_str())); + AppHubCatalogEntry entry; + REQUIRE(app_hub_catalog_find(CATALOG_PATH.c_str(), "b", entry)); + CHECK_EQ(entry.name, "B"); + CHECK_FALSE(app_hub_catalog_find(CATALOG_PATH.c_str(), "c", entry)); + remove(path.c_str()); + remove(CATALOG_PATH.c_str()); } -TEST_CASE("app hub: a fractional requiresRam rejects the entry") { +TEST_CASE("app hub: a visitor can stop the visit") { const auto path = writeAppsJson(R"({"apps": [ + {"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app"}, + {"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"} + ]})"); + REQUIRE(app_hub_catalog_convert(path.c_str(), CATALOG_PATH.c_str())); + int visited = 0; + CHECK(app_hub_catalog_for_each(CATALOG_PATH.c_str(), [](const AppHubCatalogEntry&, void* context) { + ++*static_cast(context); + return false; + }, &visited)); + CHECK_EQ(visited, 1); + remove(path.c_str()); + remove(CATALOG_PATH.c_str()); +} + +TEST_CASE("app hub: a failed conversion leaves no catalog file") { + const auto fractional = writeAppsJson(R"({"apps": [ {"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app", "requiresRam": 1.5} ]})"); - AppHubCatalog entries; - CHECK_FALSE(app_hub_catalog_parse(path.c_str(), entries)); - remove(path.c_str()); + CHECK_FALSE(app_hub_catalog_convert(fractional.c_str(), CATALOG_PATH.c_str())); + CHECK_EQ(fopen(CATALOG_PATH.c_str(), "rb"), nullptr); + const auto tooLong = writeAppsJson((R"({"apps": [{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": ")" + std::string(2000, 'x') + R"(", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app"}]})").c_str()); + CHECK_FALSE(app_hub_catalog_convert(tooLong.c_str(), CATALOG_PATH.c_str())); + CHECK_EQ(fopen(CATALOG_PATH.c_str(), "rb"), nullptr); + CHECK_FALSE(app_hub_catalog_for_each(CATALOG_PATH.c_str(), [](const AppHubCatalogEntry&, void*) { return true; }, nullptr)); + remove(fractional.c_str()); } TEST_CASE("app hub: isCompatible applies the device and RAM requirements") { @@ -75,3 +113,12 @@ TEST_CASE("app hub: isCompatible applies the device and RAM requirements") { CHECK_FALSE(app_hub_catalog_is_compatible(entryWith({ "other-device" }, 0))); CHECK_FALSE(app_hub_catalog_is_compatible(entryWith({}, 256))); } + +TEST_CASE("app hub: isCompatible requires this device's platform when the app lists platforms") { + auto entry = entryWith({}, 0); + CHECK(app_hub_catalog_is_compatible(entry)); + entry.target_platforms = { "some-other-platform" }; + CHECK_FALSE(app_hub_catalog_is_compatible(entry)); + entry.target_platforms = { "some-other-platform", app_hub_catalog_get_platform() }; + CHECK(app_hub_catalog_is_compatible(entry)); +} diff --git a/Modules/binfont-module/source/generator.cpp b/Modules/binfont-module/source/generator.cpp index 176326449..d2339e0dc 100644 --- a/Modules/binfont-module/source/generator.cpp +++ b/Modules/binfont-module/source/generator.cpp @@ -17,7 +17,8 @@ struct AllocationState { }; void* stbtt_allocate(size_t size, void* user) { - void* pointer = memory_alloc(size); + static constexpr MemoryPolicy policy = { .required = 0, .desired = 0, .alignment = 0}; + void* pointer = memory_alloc_with_policy(size, &policy); if (pointer == nullptr && user != nullptr) { static_cast(user)->failed = true; } diff --git a/Modules/coreutils-module/source/apps/rm.cpp b/Modules/coreutils-module/source/apps/rm.cpp index b37cf63a3..828773800 100644 --- a/Modules/coreutils-module/source/apps/rm.cpp +++ b/Modules/coreutils-module/source/apps/rm.cpp @@ -47,7 +47,7 @@ extern const ::AppManifest manifest = { .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, .flags = APP_MANIFEST_FLAG_HEADLESS, - .stack = { .depth = 3072, .desired_memory_capability = 0 }, + .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; } // namespace coreutils::rm diff --git a/Modules/coreutils-module/source/command_support.cpp b/Modules/coreutils-module/source/command_support.cpp index 2c4bd92cf..ee2e86c85 100644 --- a/Modules/coreutils-module/source/command_support.cpp +++ b/Modules/coreutils-module/source/command_support.cpp @@ -3,23 +3,34 @@ #include #include +#include #include #include #include #include +#include namespace { // Reads the calling app instance's own cwd (app/dir.h) - inherited from whichever instance // started this one (e.g. the interactive shell, after a `cd`), so this always agrees with `pwd`. // "/" if unset/unavailable (matches this module's old hardcoded default). -const char* currentDirectory() { - static char buffer[FILE_MAX_PATH_STRING_LENGTH]; - if (app_dir_get_cwd(buffer, sizeof(buffer)) != ERROR_NONE) { +// The buffer is allocated per call, in external memory when available. Apps have small stacks, +// and a shared static buffer would be overwritten by other apps that run at the same time. +struct MemoryDeleter { + void operator()(char* memory) const { memory_free(memory); } +}; + +using DirectoryBuffer = std::unique_ptr; + +const char* currentDirectory(DirectoryBuffer& buffer) { + constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + buffer.reset(static_cast(memory_alloc_with_policy(FILE_MAX_PATH_STRING_LENGTH, &policy))); + if (buffer == nullptr || app_dir_get_cwd(buffer.get(), FILE_MAX_PATH_STRING_LENGTH) != ERROR_NONE) { return "/"; } - return buffer; + return buffer.get(); } /** @@ -88,7 +99,8 @@ void normalizePath(char* path) { // Module-local equivalent of ShellFs::resolvePath(): resolves against this app instance's own // cwd (currentDirectory(), above) - inherited from whichever instance started it. bool resolvePath(const char* path, char* out, size_t outSize) { - const char* current = currentDirectory(); + DirectoryBuffer currentBuffer; + const char* current = currentDirectory(currentBuffer); if (path == nullptr || path[0] == '\0') { snprintf(out, outSize, "%s", current); diff --git a/Modules/http-module/CMakeLists.txt b/Modules/http-module/CMakeLists.txt index d671181ad..2d207f635 100644 --- a/Modules/http-module/CMakeLists.txt +++ b/Modules/http-module/CMakeLists.txt @@ -15,8 +15,9 @@ if (DEFINED ENV{ESP_IDF_VERSION}) lwip mbedtls ) - list(FILTER SOURCE_FILES EXCLUDE REGEX ".*download_mock\\.cpp$") + list(FILTER SOURCE_FILES EXCLUDE REGEX ".*download_posix\\.cpp$") else () + list(APPEND REQUIRES_LIST mbedtls) list(FILTER SOURCE_FILES EXCLUDE REGEX ".*download_esp\\.cpp$") endif () diff --git a/Modules/http-module/private/http/private/http_parsing.h b/Modules/http-module/private/http/private/http_parsing.h new file mode 100644 index 000000000..c0165ce21 --- /dev/null +++ b/Modules/http-module/private/http/private/http_parsing.h @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +struct HttpUrl { + std::string host; + std::string port; + /** The path and query, "/" when the URL has none */ + std::string path; +}; + +/** Parses an https:// URL. The port is 443 when the URL has none. */ +bool http_parse_https_url(const std::string& url, HttpUrl& out); + +/** Parses a status line such as "HTTP/1.1 200 OK" */ +bool http_parse_status_line(const std::string& line, int& statusCode); + +/** Parses a header line such as "Content-Length: 42". The name is returned in lower case, the value without surrounding whitespace. */ +bool http_parse_header_line(const std::string& line, std::string& name, std::string& value); + +/** Parses a Content-Length value: decimal digits only, without sign or overflow */ +bool http_parse_content_length(const std::string& value, long& length); + +/** Parses the size line of a chunk in a chunked body, e.g. "1a2b" or "1a2b;name=value" */ +bool http_parse_chunk_size(const std::string& line, size_t& size); diff --git a/Modules/http-module/source/download_mock.cpp b/Modules/http-module/source/download_mock.cpp deleted file mode 100644 index cf121efec..000000000 --- a/Modules/http-module/source/download_mock.cpp +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#include - -HttpDownloadEvent http_download_run(const std::string&, const std::string&, const std::string&, HttpDownloadLink* link) { - if (http_download_is_cancelled(link)) { - return http_download_make_cancelled_event(); - } - return http_download_make_error_event("HTTP downloads are not supported on this platform"); -} diff --git a/Modules/http-module/source/download_posix.cpp b/Modules/http-module/source/download_posix.cpp new file mode 100644 index 000000000..02f4116ce --- /dev/null +++ b/Modules/http-module/source/download_posix.cpp @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr auto* TAG = "http-download"; +constexpr int TIMEOUT_SECONDS = 5; +constexpr size_t BUFFER_SIZE = 512; + +// A write to a closed connection must fail instead of raising SIGPIPE. macOS has no MSG_NOSIGNAL, but SO_NOSIGPIPE on the socket. +#ifdef MSG_NOSIGNAL +constexpr int SEND_FLAGS = MSG_NOSIGNAL; +#else +constexpr int SEND_FLAGS = 0; +#endif + +int bio_send(void* context, const unsigned char* data, size_t size) { + const int fd = *static_cast(context); + const ssize_t sent = send(fd, data, size, SEND_FLAGS); + if (sent >= 0) { + return static_cast(sent); + } + if (errno == EINTR) { + return MBEDTLS_ERR_SSL_WANT_WRITE; + } + return (errno == EAGAIN || errno == EWOULDBLOCK) ? MBEDTLS_ERR_SSL_TIMEOUT : MBEDTLS_ERR_SSL_INTERNAL_ERROR; +} + +int bio_receive(void* context, unsigned char* data, size_t size) { + const int fd = *static_cast(context); + const ssize_t received = recv(fd, data, size, 0); + if (received >= 0) { + return static_cast(received); + } + if (errno == EINTR) { + return MBEDTLS_ERR_SSL_WANT_READ; + } + // The socket's receive timeout elapsed + return (errno == EAGAIN || errno == EWOULDBLOCK) ? MBEDTLS_ERR_SSL_TIMEOUT : MBEDTLS_ERR_SSL_INTERNAL_ERROR; +} + +// RAII: the TLS session, the socket and the certificate are released on every exit path +class TlsConnection { + int fd = -1; + bool handshakeDone = false; + mbedtls_x509_crt ca {}; + mbedtls_ssl_config config {}; + mbedtls_ssl_context ssl {}; + // Bytes that readLine() read beyond the line it returned + std::string pending; + +public: + TlsConnection() { + mbedtls_x509_crt_init(&ca); + mbedtls_ssl_config_init(&config); + mbedtls_ssl_init(&ssl); + } + + ~TlsConnection() { + if (handshakeDone) { + mbedtls_ssl_close_notify(&ssl); + } + if (fd >= 0) { + close(fd); + } + mbedtls_ssl_free(&ssl); + mbedtls_ssl_config_free(&config); + mbedtls_x509_crt_free(&ca); + } + + bool loadCertificate(const std::string& certPath) { + auto* file = fopen(certPath.c_str(), "rb"); + if (file == nullptr) { + return false; + } + fseek(file, 0, SEEK_END); + const long size = ftell(file); + fseek(file, 0, SEEK_SET); + if (size <= 0) { + fclose(file); + return false; + } + // A PEM certificate is parsed as a NUL-terminated string + std::string certificate(static_cast(size) + 1, '\0'); + const size_t readBytes = fread(certificate.data(), 1, static_cast(size), file); + fclose(file); + if (readBytes != static_cast(size)) { + return false; + } + return mbedtls_x509_crt_parse(&ca, reinterpret_cast(certificate.data()), certificate.size()) == 0; + } + + bool connectSocket(const HttpUrl& url) { + addrinfo hints {}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + addrinfo* addresses = nullptr; + if (getaddrinfo(url.host.c_str(), url.port.c_str(), &hints, &addresses) != 0) { + LOG_E(TAG, "Failed to resolve %s", url.host.c_str()); + return false; + } + + const timeval timeout { .tv_sec = TIMEOUT_SECONDS, .tv_usec = 0 }; + for (auto* address = addresses; address != nullptr; address = address->ai_next) { + fd = socket(address->ai_family, address->ai_socktype, address->ai_protocol); + if (fd < 0) { + continue; + } + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); +#ifdef SO_NOSIGPIPE + const int enabled = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); +#endif + int result; + do { + result = ::connect(fd, address->ai_addr, address->ai_addrlen); + } while (result != 0 && errno == EINTR); + if (result == 0) { + break; + } + close(fd); + fd = -1; + } + freeaddrinfo(addresses); + return fd >= 0; + } + + bool handshake(const HttpUrl& url, HttpDownloadLink* link) { + if (mbedtls_ssl_config_defaults(&config, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT) != 0) { + return false; + } + mbedtls_ssl_conf_authmode(&config, MBEDTLS_SSL_VERIFY_REQUIRED); + mbedtls_ssl_conf_ca_chain(&config, &ca, nullptr); + // Same as the ESP32 implementation + mbedtls_ssl_conf_max_tls_version(&config, MBEDTLS_SSL_VERSION_TLS1_2); + if (mbedtls_ssl_setup(&ssl, &config) != 0 || mbedtls_ssl_set_hostname(&ssl, url.host.c_str()) != 0) { + return false; + } + mbedtls_ssl_set_bio(&ssl, &fd, bio_send, bio_receive, nullptr); + + int result; + while ((result = mbedtls_ssl_handshake(&ssl)) != 0) { + if ((result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) || http_download_is_cancelled(link)) { + LOG_E(TAG, "TLS handshake with %s failed: -0x%04x", url.host.c_str(), static_cast(-result)); + return false; + } + } + handshakeDone = true; + return true; + } + + bool writeAll(const std::string& data) { + size_t offset = 0; + while (offset < data.size()) { + const int written = mbedtls_ssl_write(&ssl, reinterpret_cast(data.data()) + offset, data.size() - offset); + if (written > 0) { + offset += static_cast(written); + } else if (written != MBEDTLS_ERR_SSL_WANT_READ && written != MBEDTLS_ERR_SSL_WANT_WRITE) { + return false; + } + } + return true; + } + + /** @return the number of bytes read, 0 when the server closed the connection, or -1 on an error */ + int read(char* buffer, size_t size) { + if (!pending.empty()) { + const size_t count = std::min(size, pending.size()); + memcpy(buffer, pending.data(), count); + pending.erase(0, count); + return static_cast(count); + } + while (true) { + const int result = mbedtls_ssl_read(&ssl, reinterpret_cast(buffer), size); + if (result >= 0) { + return result; + } + if (result == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) { + return 0; + } + if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) { + return -1; + } + } + } + + /** Reads a line that ends with CRLF, without the CRLF */ + bool readLine(std::string& line) { + constexpr size_t MAX_LINE_LENGTH = 8192; + while (true) { + const auto end = pending.find("\r\n"); + if (end != std::string::npos) { + line = pending.substr(0, end); + pending.erase(0, end + 2); + return true; + } + if (pending.size() > MAX_LINE_LENGTH) { + return false; + } + char buffer[BUFFER_SIZE]; + const int result = mbedtls_ssl_read(&ssl, reinterpret_cast(buffer), sizeof(buffer)); + if (result > 0) { + pending.append(buffer, static_cast(result)); + } else if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) { + return false; + } + } + } +}; + +// RAII: a file that's removed unless commit() renamed it onto the target path +class TempFile { + std::string tempPath; + FILE* file = nullptr; + +public: + explicit TempFile(const std::string& targetPath) : tempPath(targetPath + ".tmp") { + file = fopen(tempPath.c_str(), "wb"); + } + + ~TempFile() { + if (file != nullptr) { + fclose(file); + remove(tempPath.c_str()); + } + } + + bool isOpen() const { return file != nullptr; } + + bool write(const char* data, size_t size) { return fwrite(data, 1, size, file) == size; } + + /** Closes the file and replaces the target path with it */ + bool commit(const std::string& targetPath) { + const bool closed = fclose(file) == 0; + file = nullptr; + if (!closed || rename(tempPath.c_str(), targetPath.c_str()) != 0) { + remove(tempPath.c_str()); + return false; + } + return true; + } +}; + +} + +HttpDownloadEvent http_download_run(const std::string& url, const std::string& certPath, const std::string& targetPath, HttpDownloadLink* link) { + if (http_download_is_cancelled(link)) { + return http_download_make_cancelled_event(); + } + + HttpUrl parsedUrl; + if (!http_parse_https_url(url, parsedUrl)) { + return http_download_make_error_event("Unsupported URL"); + } + if (psa_crypto_init() != PSA_SUCCESS) { + return http_download_make_error_event("Failed to initialize crypto"); + } + + TlsConnection connection; + if (!connection.loadCertificate(certPath)) { + return http_download_make_error_event("Failed to read certificate file"); + } + if (!connection.connectSocket(parsedUrl) || !connection.handshake(parsedUrl, link)) { + return http_download_make_error_event("Failed to open connection"); + } + + const std::string request = + "GET " + parsedUrl.path + " HTTP/1.1\r\n" + "Host: " + parsedUrl.host + "\r\n" + "User-Agent: Tactility\r\n" + "Accept-Encoding: identity\r\n" + "Connection: close\r\n" + "\r\n"; + if (!connection.writeAll(request)) { + return http_download_make_error_event("Failed to send request"); + } + + std::string line; + int statusCode = 0; + if (!connection.readLine(line) || !http_parse_status_line(line, statusCode)) { + return http_download_make_error_event("Failed to fetch response headers"); + } + + // -1 means the length is unknown: the body is chunked or ends when the server closes the connection + long contentLength = -1; + std::string contentLengthValue; + bool hasContentLength = false; + bool hasConflictingContentLengths = false; + bool chunked = false; + while (true) { + if (!connection.readLine(line)) { + return http_download_make_error_event("Failed to fetch response headers", statusCode); + } + if (line.empty()) { + break; + } + std::string name; + std::string value; + if (!http_parse_header_line(line, name, value)) { + continue; + } + if (name == "content-length") { + // Repeated headers must have the same value, or the body's end is ambiguous + if (hasContentLength && value != contentLengthValue) { + hasConflictingContentLengths = true; + } + contentLengthValue = value; + hasContentLength = true; + } else if (name == "transfer-encoding" && value.find("chunked") != std::string::npos) { + chunked = true; + } + } + + if (statusCode < 200 || statusCode >= 300) { + return http_download_make_error_event("Server response is not OK", statusCode); + } + // A chunked body ignores the Content-Length + if (hasContentLength && !chunked && (hasConflictingContentLengths || !http_parse_content_length(contentLengthValue, contentLength))) { + return http_download_make_error_event("Invalid Content-Length", statusCode); + } + if (http_download_is_cancelled(link)) { + return http_download_make_cancelled_event(statusCode); + } + + // Downloaded to a sibling ".tmp" file first and only renamed onto targetPath on success, so a + // failed/cancelled download never leaves a partial file at targetPath. + TempFile file(targetPath); + if (!file.isOpen()) { + return http_download_make_error_event("Failed to open target file", statusCode); + } + + char buffer[BUFFER_SIZE]; + bool cancelled = false; + // Copies up to `count` body bytes to the file. A count of -1 copies until the server closes the connection. + // @return an error message, or nullptr when the bytes were copied or the download was cancelled + auto copyBody = [&](long count) -> const char* { + while (count != 0) { + if (http_download_is_cancelled(link)) { + cancelled = true; + return nullptr; + } + const size_t wanted = (count < 0) ? sizeof(buffer) : std::min(sizeof(buffer), static_cast(count)); + const int received = connection.read(buffer, wanted); + if (received < 0) { + return "Failed to read response data"; + } + if (received == 0) { + return (count < 0) ? nullptr : "Response body was incomplete"; + } + if (!file.write(buffer, static_cast(received))) { + return "Failed to write downloaded data"; + } + if (count > 0) { + count -= received; + } + } + return nullptr; + }; + + const char* error = nullptr; + if (chunked) { + while (error == nullptr && !cancelled) { + size_t chunkSize = 0; + if (!connection.readLine(line) || !http_parse_chunk_size(line, chunkSize)) { + error = "Failed to read response data"; + } else if (chunkSize == 0) { + // The trailer ends with an empty line + while (connection.readLine(line) && !line.empty()) {} + break; + } else if ((error = copyBody(static_cast(chunkSize))) == nullptr && (!connection.readLine(line) || !line.empty())) { + error = "Failed to read response data"; + } + } + } else { + error = copyBody(contentLength); + } + + if (cancelled) { + return http_download_make_cancelled_event(statusCode); + } + if (error != nullptr) { + return http_download_make_error_event(error, statusCode); + } + if (!file.commit(targetPath)) { + return http_download_make_error_event("Failed to finalize downloaded file", statusCode); + } + return http_download_make_success_event(statusCode); +} diff --git a/Modules/http-module/source/http_parsing.cpp b/Modules/http-module/source/http_parsing.cpp new file mode 100644 index 000000000..f783b4bb1 --- /dev/null +++ b/Modules/http-module/source/http_parsing.cpp @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include +#include +#include + +namespace { + +std::string trim(const std::string& text) { + const auto start = text.find_first_not_of(" \t"); + if (start == std::string::npos) { + return ""; + } + const auto end = text.find_last_not_of(" \t"); + return text.substr(start, end - start + 1); +} + +bool is_digits(const std::string& text) { + if (text.empty()) { + return false; + } + for (const char c : text) { + if (!std::isdigit(static_cast(c))) { + return false; + } + } + return true; +} + +} + +bool http_parse_https_url(const std::string& url, HttpUrl& out) { + constexpr std::string_view scheme = "https://"; + if (url.compare(0, scheme.size(), scheme) != 0) { + return false; + } + + const auto authorityStart = scheme.size(); + const auto pathStart = url.find_first_of("/?#", authorityStart); + const std::string authority = url.substr(authorityStart, pathStart == std::string::npos ? std::string::npos : pathStart - authorityStart); + if (authority.empty() || authority.find('@') != std::string::npos) { + return false; + } + + // An IPv6 address is enclosed in brackets, as it contains colons itself + std::string host; + std::string rest; + if (authority[0] == '[') { + const auto close = authority.find(']'); + if (close == std::string::npos) { + return false; + } + host = authority.substr(1, close - 1); + rest = authority.substr(close + 1); + } else { + const auto colon = authority.find(':'); + host = authority.substr(0, colon); + rest = colon == std::string::npos ? "" : authority.substr(colon); + } + if (host.empty()) { + return false; + } + + std::string port = "443"; + if (!rest.empty()) { + if (rest[0] != ':' || !is_digits(rest.substr(1))) { + return false; + } + port = rest.substr(1); + } + + std::string path = pathStart == std::string::npos ? "/" : url.substr(pathStart); + // The fragment isn't sent to the server + path = path.substr(0, path.find('#')); + if (path.empty() || path[0] != '/') { + path.insert(0, "/"); + } + + out = { .host = host, .port = port, .path = path }; + return true; +} + +bool http_parse_status_line(const std::string& line, int& statusCode) { + if (line.compare(0, 5, "HTTP/") != 0) { + return false; + } + const auto space = line.find(' '); + if (space == std::string::npos) { + return false; + } + const std::string code = line.substr(space + 1, 3); + if (code.size() != 3 || !is_digits(code) || (line.size() > space + 4 && line[space + 4] != ' ')) { + return false; + } + statusCode = std::atoi(code.c_str()); + return true; +} + +bool http_parse_header_line(const std::string& line, std::string& name, std::string& value) { + const auto colon = line.find(':'); + if (colon == std::string::npos || colon == 0) { + return false; + } + name = trim(line.substr(0, colon)); + for (auto& c : name) { + c = static_cast(std::tolower(static_cast(c))); + } + value = trim(line.substr(colon + 1)); + return !name.empty(); +} + +bool http_parse_content_length(const std::string& value, long& length) { + if (value.empty() || value.find_first_not_of("0123456789") != std::string::npos) { + return false; + } + long result = 0; + for (const char c : value) { + const long digit = c - '0'; + if (result > (LONG_MAX - digit) / 10) { + return false; + } + result = result * 10 + digit; + } + length = result; + return true; +} + +bool http_parse_chunk_size(const std::string& line, size_t& size) { + const std::string digits = trim(line.substr(0, line.find(';'))); + if (digits.empty() || digits.size() > sizeof(size_t) * 2) { + return false; + } + size_t result = 0; + for (const char c : digits) { + if (!std::isxdigit(static_cast(c))) { + return false; + } + const char lower = static_cast(std::tolower(static_cast(c))); + result = result * 16 + static_cast(lower <= '9' ? lower - '0' : lower - 'a' + 10); + } + size = result; + return true; +} diff --git a/Modules/http-module/tests/CMakeLists.txt b/Modules/http-module/tests/CMakeLists.txt index 47c9046db..3ee776805 100644 --- a/Modules/http-module/tests/CMakeLists.txt +++ b/Modules/http-module/tests/CMakeLists.txt @@ -5,7 +5,7 @@ enable_language(C CXX ASM) file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp) add_executable(HttpModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES}) -target_include_directories(HttpModuleTests PRIVATE ${DOCTESTINC}) +target_include_directories(HttpModuleTests PRIVATE ${DOCTESTINC} ${PROJECT_SOURCE_DIR}/../private) add_test(NAME HttpModuleTests COMMAND HttpModuleTests) diff --git a/Modules/http-module/tests/source/download_test.cpp b/Modules/http-module/tests/source/download_test.cpp index b33806f41..c0fb311ec 100644 --- a/Modules/http-module/tests/source/download_test.cpp +++ b/Modules/http-module/tests/source/download_test.cpp @@ -4,7 +4,7 @@ TEST_CASE("http_download_start requires a subscribed subscription") { HttpDownloadSubscription sub {}; - CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_INVALID_ARGUMENT); + CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_INVALID_ARGUMENT); } TEST_CASE("http_download_poll times out before the download finishes") { @@ -21,13 +21,13 @@ TEST_CASE("http_download_poll times out before the download finishes") { task_event_group_destruct(&event_group); } -TEST_CASE("http_download_start on this (non-ESP-IDF) platform always fails the download") { +TEST_CASE("http_download_start fails the download when the certificate can't be read") { TaskEventGroup event_group {}; task_event_group_construct(&event_group); HttpDownloadSubscription sub {}; CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE); - CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_NONE); + CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_NONE); CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE); @@ -55,7 +55,7 @@ TEST_CASE("http_download_unsubscribe is safe to call right after start, without HttpDownloadSubscription sub {}; CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE); - CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_NONE); + CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_NONE); // No cancel, no poll for the terminal event - just unsubscribe and tear down immediately, // racing whatever the download task is doing. @@ -77,7 +77,7 @@ TEST_CASE("http_download_cancel before start makes the download finish as cancel HttpDownloadSubscription sub {}; CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE); CHECK_EQ(http_download_cancel(&sub), ERROR_NONE); - CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_NONE); + CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_NONE); CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE); diff --git a/Modules/http-module/tests/source/http_parsing_test.cpp b/Modules/http-module/tests/source/http_parsing_test.cpp new file mode 100644 index 000000000..b04b282a6 --- /dev/null +++ b/Modules/http-module/tests/source/http_parsing_test.cpp @@ -0,0 +1,74 @@ +#include "doctest.h" + +#include + +TEST_CASE("http_parse_https_url splits the host, port and path") { + HttpUrl url; + REQUIRE(http_parse_https_url("https://cdn.example.org/apps/1.0/apps.json?x=1#top", url)); + CHECK_EQ(url.host, "cdn.example.org"); + CHECK_EQ(url.port, "443"); + CHECK_EQ(url.path, "/apps/1.0/apps.json?x=1"); + + REQUIRE(http_parse_https_url("https://example.org:8443", url)); + CHECK_EQ(url.port, "8443"); + CHECK_EQ(url.path, "/"); + + REQUIRE(http_parse_https_url("https://[::1]:9000/file", url)); + CHECK_EQ(url.host, "::1"); + CHECK_EQ(url.port, "9000"); +} + +TEST_CASE("http_parse_https_url rejects other schemes and malformed URLs") { + HttpUrl url; + CHECK_FALSE(http_parse_https_url("http://example.org/", url)); + CHECK_FALSE(http_parse_https_url("https://", url)); + CHECK_FALSE(http_parse_https_url("https://example.org:abc/", url)); + CHECK_FALSE(http_parse_https_url("https://user@example.org/", url)); +} + +TEST_CASE("http_parse_status_line reads the status code") { + int status = 0; + REQUIRE(http_parse_status_line("HTTP/1.1 200 OK", status)); + CHECK_EQ(status, 200); + REQUIRE(http_parse_status_line("HTTP/1.1 404", status)); + CHECK_EQ(status, 404); + CHECK_FALSE(http_parse_status_line("HTTP/1.1 20 OK", status)); + CHECK_FALSE(http_parse_status_line("SSH-2.0 200", status)); +} + +TEST_CASE("http_parse_header_line lowercases the name and trims the value") { + std::string name; + std::string value; + REQUIRE(http_parse_header_line("Content-Length: 42 ", name, value)); + CHECK_EQ(name, "content-length"); + CHECK_EQ(value, "42"); + CHECK_FALSE(http_parse_header_line("no colon here", name, value)); + CHECK_FALSE(http_parse_header_line(": empty name", name, value)); +} + +TEST_CASE("http_parse_content_length accepts decimal digits only") { + long length = -1; + REQUIRE(http_parse_content_length("42", length)); + CHECK_EQ(length, 42); + REQUIRE(http_parse_content_length("0", length)); + CHECK_EQ(length, 0); + CHECK_FALSE(http_parse_content_length("", length)); + CHECK_FALSE(http_parse_content_length("-5", length)); + CHECK_FALSE(http_parse_content_length("+5", length)); + CHECK_FALSE(http_parse_content_length("12abc", length)); + CHECK_FALSE(http_parse_content_length("1 2", length)); + CHECK_FALSE(http_parse_content_length("99999999999999999999999", length)); + CHECK_EQ(length, 0); +} + +TEST_CASE("http_parse_chunk_size reads hexadecimal sizes and ignores extensions") { + size_t size = 0; + REQUIRE(http_parse_chunk_size("1a2B", size)); + CHECK_EQ(size, 0x1a2b); + REQUIRE(http_parse_chunk_size("0", size)); + CHECK_EQ(size, 0); + REQUIRE(http_parse_chunk_size("ff;name=value", size)); + CHECK_EQ(size, 0xff); + CHECK_FALSE(http_parse_chunk_size("", size)); + CHECK_FALSE(http_parse_chunk_size("xyz", size)); +} diff --git a/Modules/location-module/CMakeLists.txt b/Modules/location-module/CMakeLists.txt index 16b39e520..56dcf6874 100644 --- a/Modules/location-module/CMakeLists.txt +++ b/Modules/location-module/CMakeLists.txt @@ -9,6 +9,8 @@ list(APPEND REQUIRES_LIST TactilityKernelCpp service-module gps-module + http-module + cjson-module minmea ) diff --git a/Modules/location-module/include/location/location.h b/Modules/location-module/include/location/location.h index dd2fdfea0..4a18631d6 100644 --- a/Modules/location-module/include/location/location.h +++ b/Modules/location-module/include/location/location.h @@ -2,6 +2,7 @@ #pragma once #include +#include #include #include @@ -20,6 +21,9 @@ extern "C" { #define LOCATION_PROVIDER_ID_MAX_LENGTH 15 +/** Maximum length of an IANA time zone name from location_lookup_time_zone(), excluding the NUL terminator */ +#define LOCATION_TIME_ZONE_MAX_LENGTH 63 + /** Horizontal accuracy in meters of a fix with LOCATION_PRECISION_APPROXIMATE */ #define LOCATION_APPROXIMATE_ACCURACY_METERS 3000.0f @@ -115,6 +119,18 @@ error_t location_unsubscribe(struct LocationSubscription* sub); /** @return true when location services are enabled and there is at least one subscription */ bool location_is_in_use(void); +/** + * Look up the IANA time zone name (e.g. "Europe/Brussels") of the device's public IP address with json.geoiplookup.io. + * Blocks for up to about 15 seconds, so call it from an app's own task. + * @param[out] out_name the time zone name + * @param[in] out_size the size of @a out_name, at least LOCATION_TIME_ZONE_MAX_LENGTH + 1 for any name + * @retval ERROR_INVALID_STATE location services are disabled + * @retval ERROR_RESOURCE the request failed, e.g. without network + * @retval ERROR_NOT_FOUND json.geoiplookup.io returned no time zone + * @retval ERROR_BUFFER_OVERFLOW the name doesn't fit in @a out_name + */ +error_t location_lookup_time_zone(char* out_name, size_t out_size); + #ifdef __cplusplus } #endif diff --git a/Modules/location-module/private/location/ip_provider.h b/Modules/location-module/private/location/ip_provider.h index c2d67f2f6..af39b539a 100644 --- a/Modules/location-module/private/location/ip_provider.h +++ b/Modules/location-module/private/location/ip_provider.h @@ -6,7 +6,7 @@ #include /** - * Fallback provider that looks up the location of the public IP address with ip-api.com. + * Fallback provider that looks up the location of the public IP address with json.geoiplookup.io. * The result is reused while the network (local IP address and gateway) is unchanged. */ extern const LocationProviderApi ip_location_provider; @@ -17,25 +17,17 @@ error_t ip_provider_track_network(void); /** Stop tracking the network and release the cached result. The provider must not run. */ void ip_provider_untrack_network(void); -#define IP_API_MESSAGE_MAX_LENGTH 63 - -struct IpApiResponse { - /** HTTP status code */ - int status_code; - /** Requests left in the rate limit window, -1 when unknown */ - int rate_remaining; - /** Seconds until the rate limit window resets, -1 when unknown */ - int rate_reset_seconds; +struct IpLookupResponse { bool success; - /** Reason of failure, empty on success */ - char message[IP_API_MESSAGE_MAX_LENGTH + 1]; double latitude; double longitude; + /** IANA time zone name, empty when unknown */ + char time_zone[LOCATION_TIME_ZONE_MAX_LENGTH + 1]; }; /** - * Parse an HTTP response of the newline separated ip-api endpoint, requested with fields "status,message,lat,lon" - * @param[in] response the NUL-terminated response, including the status line and headers + * Parse the JSON response of json.geoiplookup.io + * @param[in] response the NUL-terminated response body * @return false when the response is malformed */ -bool ip_api_parse_response(const char* response, IpApiResponse* out); +bool ip_lookup_parse_response(const char* response, IpLookupResponse* out); diff --git a/Modules/location-module/source/ip_provider.cpp b/Modules/location-module/source/ip_provider.cpp index 4a685d8db..d6f48c8c7 100644 --- a/Modules/location-module/source/ip_provider.cpp +++ b/Modules/location-module/source/ip_provider.cpp @@ -1,45 +1,45 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include + #include #include +#include +#include #include +#include #include #include #include #include -#include -#include -#include -#include -#include +#include #include #include -#include #include #include -#include #include #include -#include #define TAG "location_ip" namespace { -// The free ip-api endpoint only supports plain HTTP -constexpr auto* API_HOST = "ip-api.com"; -constexpr auto* API_PATH = "/line/?fields=status,message,lat,lon"; +constexpr auto* API_URL = "https://json.geoiplookup.io/"; +// The root certificate of the API's certificate chain +constexpr auto* API_CERTIFICATE = "certificates/GTS_Root_R4.pem"; constexpr uint64_t RETRY_INTERVAL_MS = 60 * 1000; // Bounds the request rate when the network connects repeatedly constexpr uint64_t MIN_INTERVAL_MS = 10 * 1000; // Wake up interval to check whether the fallback became needed constexpr uint32_t CHECK_INTERVAL_MS = 10 * 1000; -constexpr int SOCKET_TIMEOUT_SECONDS = 5; -constexpr size_t RESPONSE_MAX_LENGTH = 1024; +constexpr uint64_t LOOKUP_TIMEOUT_MS = 15 * 1000; +// Bounds the wait for the download, which doesn't stop when the provider is stopped +constexpr uint32_t LOOKUP_POLL_INTERVAL_MS = 100; +constexpr size_t RESPONSE_MAX_LENGTH = 2048; // IP geolocation is accurate to roughly city level constexpr float ACCURACY_METERS = 25000.0f; @@ -81,96 +81,83 @@ uint64_t getUptimeMs() { return get_micros_since_boot() / 1000; } -// The FreeRTOS POSIX port's tick signal can interrupt blocking socket calls, so they retry on EINTR - -bool connectWithTimeout(int fd, const sockaddr* address, socklen_t addressLength) { - const int flags = fcntl(fd, F_GETFL, 0); - fcntl(fd, F_SETFL, flags | O_NONBLOCK); - int result = connect(fd, address, addressLength); - if (result < 0 && errno == EINPROGRESS) { - fd_set writeSet; - do { - FD_ZERO(&writeSet); - FD_SET(fd, &writeSet); - timeval timeout { .tv_sec = SOCKET_TIMEOUT_SECONDS, .tv_usec = 0 }; - result = select(fd + 1, nullptr, &writeSet, nullptr, &timeout); - } while (result < 0 && errno == EINTR); - int error = 0; - socklen_t errorLength = sizeof(error); - if (result == 1 && getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &errorLength) == 0 && error == 0) { - result = 0; - } else { - result = -1; - } +bool getCertificatePath(char* out_path, size_t out_size) { + // The system file system is mounted at "/system" on ESP32, but it's a folder elsewhere on the simulator + FileSystem* system = file_system_find_by_name("system"); + char system_path[FILE_MAX_PATH_STRING_LENGTH]; + if (system == nullptr || file_system_get_path(system, system_path, sizeof(system_path)) != ERROR_NONE) { + return false; } - fcntl(fd, F_SETFL, flags); - return result == 0; + const int written = snprintf(out_path, out_size, "%s/%s", system_path, API_CERTIFICATE); + return written > 0 && static_cast(written) < out_size; } -bool sendAll(int fd, const char* data, size_t length) { - size_t sent = 0; - while (sent < length) { - const ssize_t result = send(fd, data + sent, length - sent, 0); - if (result < 0) { - if (errno == EINTR) { - continue; - } - return false; - } - sent += static_cast(result); +/** + * Downloads the lookup result into a temp file named @a file_name and parses it + * @param[in] group the event group to wait on, with a free bit for the download + * @param[in] running the lookup is cancelled when this becomes false + * @return false when the lookup failed or was cancelled + */ +bool fetchLookup(const char* file_name, TaskEventGroup* group, const std::atomic& running, IpLookupResponse* out) { + char certificate_path[FILE_MAX_PATH_STRING_LENGTH]; + char temp_path[FILE_MAX_PATH_STRING_LENGTH]; + char target_path[FILE_MAX_PATH_STRING_LENGTH]; + if (!getCertificatePath(certificate_path, sizeof(certificate_path)) || paths_get_temp_path(temp_path, sizeof(temp_path)) != ERROR_NONE) { + return false; } - return true; -} - -/** Receive until the server closes the connection */ -bool receiveAll(int fd, char* buffer, size_t bufferSize) { - size_t received = 0; - while (received < bufferSize - 1) { - const ssize_t result = recv(fd, buffer + received, bufferSize - 1 - received, 0); - if (result < 0) { - if (errno == EINTR) { - continue; - } + if (!directory_exists(temp_path)) { + const error_t error = directory_make(temp_path, true); + if (error != ERROR_NONE && error != ERROR_ALREADY_EXISTS) { return false; } - if (result == 0) { + } + const int written = snprintf(target_path, sizeof(target_path), "%s/%s", temp_path, file_name); + if (written <= 0 || static_cast(written) >= sizeof(target_path)) { + return false; + } + + HttpDownloadSubscription subscription {}; + if (http_download_subscribe(&subscription, group) != ERROR_NONE) { + return false; + } + if (http_download_start(API_URL, certificate_path, target_path, &subscription) != ERROR_NONE) { + http_download_unsubscribe(&subscription); + return false; + } + const uint64_t deadline = getUptimeMs() + LOOKUP_TIMEOUT_MS; + HttpDownloadEvent event {}; + bool finished = false; + while (running && getUptimeMs() < deadline) { + if (http_download_poll(&subscription, &event) == ERROR_NONE) { + finished = true; break; } - received += static_cast(result); + task_event_group_wait_any(group, nullptr, millis_to_ticks(LOOKUP_POLL_INTERVAL_MS)); } - buffer[received] = '\0'; - return received > 0; -} - -bool httpGet(const char* host, const char* path, char* response, size_t responseSize) { - addrinfo hints {}; - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - addrinfo* addresses = nullptr; - if (getaddrinfo(host, "80", &hints, &addresses) != 0 || addresses == nullptr) { + if (!finished) { + http_download_cancel(&subscription); + } + http_download_unsubscribe(&subscription); + if (!finished || event.type != HTTP_DOWNLOAD_EVENT_SUCCESS) { + if (finished && event.type == HTTP_DOWNLOAD_EVENT_ERROR) { + LOG_W(TAG, "Request failed: %s (HTTP %d)", event.error.message, static_cast(event.status_code)); + } return false; } - const int fd = socket(addresses->ai_family, addresses->ai_socktype, addresses->ai_protocol); - if (fd < 0) { - freeaddrinfo(addresses); + tt::std::string response(RESPONSE_MAX_LENGTH, '\0'); + size_t size = response.size() - 1; + const error_t error = file_read_binary(target_path, reinterpret_cast(response.data()), &size); + remove(target_path); + if (error != ERROR_NONE) { return false; } - - timeval timeout { .tv_sec = SOCKET_TIMEOUT_SECONDS, .tv_usec = 0 }; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); - setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); - - char request[160]; - const int requestLength = snprintf(request, sizeof(request), "GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n", path, host); - - const bool success = connectWithTimeout(fd, addresses->ai_addr, addresses->ai_addrlen) && - sendAll(fd, request, static_cast(requestLength)) && - receiveAll(fd, response, responseSize); - - close(fd); - freeaddrinfo(addresses); - return success; + response[size] = '\0'; + if (!ip_lookup_parse_response(response.c_str(), out)) { + LOG_W(TAG, "Malformed response"); + return false; + } + return true; } void report(IpProvider& provider, double latitude, double longitude) { @@ -198,39 +185,23 @@ void report(IpProvider& provider, double latitude, double longitude) { /** @return the delay until the next lookup is allowed */ uint64_t lookup(IpProvider& provider, uint64_t network) { - tt::std::string response(RESPONSE_MAX_LENGTH, '\0'); - if (!httpGet(API_HOST, API_PATH, response.data(), response.size())) { - LOG_W(TAG, "Request failed"); + IpLookupResponse parsed; + if (!fetchLookup("location_ip.json", &provider.group, provider.running, &parsed)) { return RETRY_INTERVAL_MS; } - - IpApiResponse parsed; - if (!ip_api_parse_response(response.c_str(), &parsed)) { - LOG_W(TAG, "Malformed response"); + if (!parsed.success) { + LOG_W(TAG, "Lookup failed"); return RETRY_INTERVAL_MS; } - - uint64_t delay = RETRY_INTERVAL_MS; - if (parsed.status_code != 200) { - LOG_W(TAG, "HTTP status %d", parsed.status_code); - } else if (!parsed.success) { - LOG_W(TAG, "Lookup failed: %s", parsed.message); - } else { - if (cache == nullptr) { - cache = tt::newObject(); - } - if (cache != nullptr) { - *cache = { .network = network, .latitude = parsed.latitude, .longitude = parsed.longitude }; - } - report(provider, parsed.latitude, parsed.longitude); - LOG_D(TAG, "Lookup succeeded"); - delay = MIN_INTERVAL_MS; + if (cache == nullptr) { + cache = tt::newObject(); } - - if (parsed.rate_remaining == 0 && parsed.rate_reset_seconds > 0) { - delay = std::max(delay, static_cast(parsed.rate_reset_seconds) * 1000); + if (cache != nullptr) { + *cache = { .network = network, .latitude = parsed.latitude, .longitude = parsed.longitude }; } - return delay; + report(provider, parsed.latitude, parsed.longitude); + LOG_D(TAG, "Lookup succeeded"); + return MIN_INTERVAL_MS; } int32_t threadMain(void* context) { @@ -337,22 +308,10 @@ bool waitStopped(void* context, TickType_t timeout) { return true; } -std::string_view nextLine(std::string_view& text) { - const size_t end = text.find('\n'); - std::string_view line = text.substr(0, end); - text = end == std::string_view::npos ? std::string_view {} : text.substr(end + 1); - if (!line.empty() && line.back() == '\r') { - line.remove_suffix(1); - } - return line; -} - -int parseHeaderNumber(std::string_view line, const char* name) { - const size_t nameLength = strlen(name); - if (line.size() <= nameLength || line[nameLength] != ':' || strncasecmp(line.data(), name, nameLength) != 0) { - return -1; - } - return atoi(tt::std::string(line.substr(nameLength + 1)).c_str()); +/** The characters of IANA time zone names, e.g. "America/Port-au-Prince" or "Etc/GMT+5" */ +bool isTimeZoneCharacter(char character) { + return (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || + character == '_' || character == '+' || character == '-' || character == '/'; } } // namespace @@ -369,73 +328,67 @@ void ip_provider_untrack_network(void) { } } -bool ip_api_parse_response(const char* response, IpApiResponse* out) { - *out = IpApiResponse { - .status_code = 0, - .rate_remaining = -1, - .rate_reset_seconds = -1, +error_t location_lookup_time_zone(char* out_name, size_t out_size) { + if (!location_is_enabled()) { + return ERROR_INVALID_STATE; + } + TaskEventGroup group {}; + task_event_group_construct(&group); + const std::atomic running = true; + IpLookupResponse parsed; + const bool fetched = fetchLookup("location_time_zone.json", &group, running, &parsed); + task_event_group_destruct(&group); + if (!fetched || !parsed.success) { + return ERROR_RESOURCE; + } + const size_t length = strlen(parsed.time_zone); + if (length == 0) { + return ERROR_NOT_FOUND; + } + if (length >= out_size) { + return ERROR_BUFFER_OVERFLOW; + } + memcpy(out_name, parsed.time_zone, length + 1); + return ERROR_NONE; +} + +bool ip_lookup_parse_response(const char* response, IpLookupResponse* out) { + *out = IpLookupResponse { .success = false, - .message = {}, .latitude = NAN, .longitude = NAN, + .time_zone = {}, }; - std::string_view text = response; - const std::string_view statusLine = nextLine(text); - if (!statusLine.starts_with("HTTP/1.")) { + cJSON* json = cJSON_Parse(response); + if (!cJSON_IsObject(json)) { + cJSON_Delete(json); return false; } - const size_t space = statusLine.find(' '); - if (space == std::string_view::npos) { - return false; - } - out->status_code = atoi(tt::std::string(statusLine.substr(space + 1)).c_str()); - - while (true) { - if (text.empty()) { - return false; - } - const std::string_view header = nextLine(text); - if (header.empty()) { - break; - } - const int remaining = parseHeaderNumber(header, "X-Rl"); - if (remaining >= 0) { - out->rate_remaining = remaining; - } - const int resetSeconds = parseHeaderNumber(header, "X-Ttl"); - if (resetSeconds >= 0) { - out->rate_reset_seconds = resetSeconds; + bool valid = false; + const cJSON* success = cJSON_GetObjectItemCaseSensitive(json, "success"); + if (cJSON_IsFalse(success)) { + valid = true; + } else if (cJSON_IsTrue(success)) { + const cJSON* latitude = cJSON_GetObjectItemCaseSensitive(json, "latitude"); + const cJSON* longitude = cJSON_GetObjectItemCaseSensitive(json, "longitude"); + const cJSON* timeZone = cJSON_GetObjectItemCaseSensitive(json, "timezone_name"); + if (cJSON_IsNumber(latitude) && cJSON_IsNumber(longitude) && + std::isfinite(latitude->valuedouble) && std::isfinite(longitude->valuedouble) && + std::fabs(latitude->valuedouble) <= 90.0 && std::fabs(longitude->valuedouble) <= 180.0) { + const std::string_view name = cJSON_IsString(timeZone) ? timeZone->valuestring : ""; + if (name.size() <= LOCATION_TIME_ZONE_MAX_LENGTH && std::ranges::all_of(name, isTimeZoneCharacter)) { + out->latitude = latitude->valuedouble; + out->longitude = longitude->valuedouble; + memcpy(out->time_zone, name.data(), name.size()); + out->time_zone[name.size()] = '\0'; + out->success = true; + valid = true; + } } } - - if (out->status_code != 200) { - return true; - } - - // Fields are returned one per line, in the order of the API documentation - const std::string_view status = nextLine(text); - if (status == "success") { - const tt::std::string latitude(nextLine(text)); - const tt::std::string longitude(nextLine(text)); - char* latitudeEnd; - char* longitudeEnd; - out->latitude = strtod(latitude.c_str(), &latitudeEnd); - out->longitude = strtod(longitude.c_str(), &longitudeEnd); - if (latitude.empty() || *latitudeEnd != '\0' || longitude.empty() || *longitudeEnd != '\0' || - !std::isfinite(out->latitude) || !std::isfinite(out->longitude) || - std::fabs(out->latitude) > 90.0 || std::fabs(out->longitude) > 180.0) { - return false; - } - out->success = true; - } else if (status == "fail") { - const std::string_view message = nextLine(text).substr(0, IP_API_MESSAGE_MAX_LENGTH); - memcpy(out->message, message.data(), message.size()); - out->message[message.size()] = '\0'; - } else { - return false; - } - return true; + cJSON_Delete(json); + return valid; } const LocationProviderApi ip_location_provider = { diff --git a/Modules/location-module/source/module.cpp b/Modules/location-module/source/module.cpp index f66755808..62c956733 100644 --- a/Modules/location-module/source/module.cpp +++ b/Modules/location-module/source/module.cpp @@ -51,6 +51,7 @@ static const ModuleSymbol location_module_symbols[] = { DEFINE_MODULE_SYMBOL(location_subscribe), DEFINE_MODULE_SYMBOL(location_unsubscribe), DEFINE_MODULE_SYMBOL(location_is_in_use), + DEFINE_MODULE_SYMBOL(location_lookup_time_zone), MODULE_SYMBOL_TERMINATOR }; diff --git a/Modules/location-module/tests/source/ip_provider_test.cpp b/Modules/location-module/tests/source/ip_provider_test.cpp index 34c1c5862..c6a2b0ec7 100644 --- a/Modules/location-module/tests/source/ip_provider_test.cpp +++ b/Modules/location-module/tests/source/ip_provider_test.cpp @@ -3,55 +3,46 @@ #include #include +#include -TEST_CASE("ip_api_parse_response parses a successful lookup") { - const char* response = - "HTTP/1.1 200 OK\r\n" - "Content-Type: text/plain; charset=utf-8\r\n" - "X-Ttl: 60\r\n" - "X-Rl: 44\r\n" - "\r\n" - "success\n" - "39.03\n" - "-77.5\n"; - IpApiResponse parsed; - REQUIRE(ip_api_parse_response(response, &parsed)); - CHECK(parsed.status_code == 200); +TEST_CASE("ip_lookup_parse_response parses a successful lookup") { + const char* response = R"({"ip":"192.0.2.1","latitude":52.3676,"longitude":4.9041,"city":"Amsterdam","timezone_name":"Europe/Amsterdam","success":true,"premium":false})"; + IpLookupResponse parsed; + REQUIRE(ip_lookup_parse_response(response, &parsed)); CHECK(parsed.success); - CHECK(parsed.latitude == doctest::Approx(39.03)); - CHECK(parsed.longitude == doctest::Approx(-77.5)); - CHECK(parsed.rate_remaining == 44); - CHECK(parsed.rate_reset_seconds == 60); + CHECK(parsed.latitude == doctest::Approx(52.3676)); + CHECK(parsed.longitude == doctest::Approx(4.9041)); + CHECK(strcmp(parsed.time_zone, "Europe/Amsterdam") == 0); } -TEST_CASE("ip_api_parse_response parses a failed lookup") { - const char* response = "HTTP/1.1 200 OK\r\nx-rl: 0\r\nx-ttl: 12\r\n\r\nfail\nprivate range\n"; - IpApiResponse parsed; - REQUIRE(ip_api_parse_response(response, &parsed)); +TEST_CASE("ip_lookup_parse_response accepts a lookup without a time zone") { + IpLookupResponse parsed; + REQUIRE(ip_lookup_parse_response(R"({"latitude":-33.9,"longitude":151.2,"success":true})", &parsed)); + CHECK(parsed.success); + CHECK(parsed.time_zone[0] == '\0'); +} + +TEST_CASE("ip_lookup_parse_response rejects an invalid time zone") { + IpLookupResponse parsed; + CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":52.37,"longitude":4.89,"timezone_name":"../etc/passwd x","success":true})", &parsed)); + const std::string tooLong = R"({"latitude":52.37,"longitude":4.89,"timezone_name":")" + std::string(LOCATION_TIME_ZONE_MAX_LENGTH + 1, 'a') + R"(","success":true})"; + CHECK_FALSE(ip_lookup_parse_response(tooLong.c_str(), &parsed)); +} + +TEST_CASE("ip_lookup_parse_response parses a failed lookup") { + IpLookupResponse parsed; + REQUIRE(ip_lookup_parse_response(R"({"success":false})", &parsed)); CHECK_FALSE(parsed.success); - CHECK(strcmp(parsed.message, "private range") == 0); - CHECK(parsed.rate_remaining == 0); - CHECK(parsed.rate_reset_seconds == 12); } -TEST_CASE("ip_api_parse_response handles a throttled request") { - const char* response = "HTTP/1.1 429 Too Many Requests\r\nX-Rl: 0\r\nX-Ttl: 30\r\n\r\n"; - IpApiResponse parsed; - REQUIRE(ip_api_parse_response(response, &parsed)); - CHECK(parsed.status_code == 429); - CHECK_FALSE(parsed.success); - CHECK(parsed.rate_reset_seconds == 30); -} - -TEST_CASE("ip_api_parse_response rejects malformed responses") { - IpApiResponse parsed; - CHECK_FALSE(ip_api_parse_response("", &parsed)); - CHECK_FALSE(ip_api_parse_response("garbage", &parsed)); - CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\nX-Rl: 1\r\n", &parsed)); - CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\nabc\n1.0\n", &parsed)); - CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nunknown\n", &parsed)); - CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\nnan\n1.0\n", &parsed)); - CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\n1.0\ninf\n", &parsed)); - CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\n90.5\n1.0\n", &parsed)); - CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\n1.0\n-180.5\n", &parsed)); +TEST_CASE("ip_lookup_parse_response rejects malformed responses") { + IpLookupResponse parsed; + CHECK_FALSE(ip_lookup_parse_response("", &parsed)); + CHECK_FALSE(ip_lookup_parse_response("garbage", &parsed)); + CHECK_FALSE(ip_lookup_parse_response("[]", &parsed)); + CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":52.37,"longitude":4.89})", &parsed)); + CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":"52.37","longitude":4.89,"success":true})", &parsed)); + CHECK_FALSE(ip_lookup_parse_response(R"({"longitude":4.89,"success":true})", &parsed)); + CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":90.5,"longitude":1.0,"success":true})", &parsed)); + CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":1.0,"longitude":-180.5,"success":true})", &parsed)); } diff --git a/Modules/lvgl-module/CMakeLists.txt b/Modules/lvgl-module/CMakeLists.txt index de7d34e0e..9eefa04aa 100644 --- a/Modules/lvgl-module/CMakeLists.txt +++ b/Modules/lvgl-module/CMakeLists.txt @@ -48,6 +48,7 @@ file(GLOB_RECURSE SOURCE_FILES "source/*.c*") list(APPEND REQUIRES_LIST TactilityKernel + TactilityKernelCpp binfont-module lodepng-module lvgl diff --git a/Modules/lvgl-module/generate-icons.py b/Modules/lvgl-module/generate-icons.py index a4ff4b437..3c5c7d517 100755 --- a/Modules/lvgl-module/generate-icons.py +++ b/Modules/lvgl-module/generate-icons.py @@ -153,12 +153,13 @@ shared_symbol_code_point_names = [ "help", # Diceware help "hub", # App Hub "info", # System Info - "image", # Screenshot app + "image", # Files app "keyboard_arrow_up", "lightbulb", "language", # Globe "lists", # Chat app toolbar "list_alt", # Settings apps: opens a selection dialog + "hourglass", # App Hub: an operation for the app is pending "mail", "menu", "memory", # System Info diff --git a/Modules/lvgl-module/include/lvgl/icons/names.h b/Modules/lvgl-module/include/lvgl/icons/names.h index 1cb0d28df..ef5ce124e 100644 --- a/Modules/lvgl-module/include/lvgl/icons/names.h +++ b/Modules/lvgl-module/include/lvgl/icons/names.h @@ -9,7 +9,7 @@ extern "C" { #endif /** Changes whenever the icon TTF subset, its codepoints file or the icon name lists change */ -#define LVGL_ICON_FONT_VERSION 0xE8678DFBu +#define LVGL_ICON_FONT_VERSION 0x3F505982u // Icon names per icon font, resolved through the .codepoints file next to the TTF extern const char* const lvgl_icon_shared_names[]; diff --git a/Modules/lvgl-module/include/lvgl/icons/shared.h b/Modules/lvgl-module/include/lvgl/icons/shared.h index 1885ed0ac..5f16a9cae 100644 --- a/Modules/lvgl-module/include/lvgl/icons/shared.h +++ b/Modules/lvgl-module/include/lvgl/icons/shared.h @@ -32,6 +32,7 @@ #define LVGL_ICON_SHARED_LANGUAGE "\xEE\xA8\x87" #define LVGL_ICON_SHARED_LISTS "\xEE\xA6\xB9" #define LVGL_ICON_SHARED_LIST_ALT "\xEE\x83\xAE" +#define LVGL_ICON_SHARED_HOURGLASS "\xEE\xAF\xBF" #define LVGL_ICON_SHARED_MAIL "\xEE\x85\x99" #define LVGL_ICON_SHARED_MENU "\xEE\x97\x92" #define LVGL_ICON_SHARED_MEMORY "\xEE\x8C\xA2" diff --git a/Modules/lvgl-module/include/lvgl/widgets/step_indicator.h b/Modules/lvgl-module/include/lvgl/widgets/step_indicator.h new file mode 100644 index 000000000..d612032ef --- /dev/null +++ b/Modules/lvgl-module/include/lvgl/widgets/step_indicator.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Creates a step indicator: a horizontal row of line segments, one per step, that shows the progress through a number of steps. + * The segments share the indicator's width. The theme determines their thickness, the gap between them, and the colours of finished and unfinished steps. + * @param[in] parent the parent object + * @return the created step indicator + */ +lv_obj_t* lvgl_step_indicator_create(lv_obj_t* parent); + +/** + * @brief Sets the number of steps, which is the number of segments. + * @param[in] obj the step indicator + * @param[in] count the number of steps + */ +void lvgl_step_indicator_set_step_count(lv_obj_t* obj, uint32_t count); + +/** + * @brief Sets how many steps are finished: the segments of the first @p finished_count steps show as finished. + * @param[in] obj the step indicator + * @param[in] finished_count the number of finished steps + */ +void lvgl_step_indicator_set_finished_count(lv_obj_t* obj, uint32_t finished_count); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-module/include/lvgl/widgets/text_button.h b/Modules/lvgl-module/include/lvgl/widgets/text_button.h new file mode 100644 index 000000000..3d1da6562 --- /dev/null +++ b/Modules/lvgl-module/include/lvgl/widgets/text_button.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Creates a button without a container, for low-emphasis actions such as "Skip" or "Back". + * It behaves like a regular button, but the theme styles it as a text button. + * @param[in] parent the parent object for the new button + * @return the created button + */ +lv_obj_t* lvgl_text_button_create(lv_obj_t* parent); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-module/source/devices/devices.cpp b/Modules/lvgl-module/source/devices/devices.cpp index 27238f7f2..aa86529e2 100644 --- a/Modules/lvgl-module/source/devices/devices.cpp +++ b/Modules/lvgl-module/source/devices/devices.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -11,6 +12,8 @@ #include #include +#include + #include #include @@ -55,7 +58,16 @@ struct IdleRepaintContext { // One slot per display bound in lvgl_devices_attach()'s loop below that turns out to be // DISPLAY_CAPABILITY_SLOW_REFRESH; a board with multiple such displays needs independent idle // tracking for each; slots are 1:1 with idle_repaint_contexts' index, not a compacted list. -static IdleRepaintContext idle_repaint_contexts[LVGL_DEVICES_MAX_PER_TYPE] = {}; +struct IdleRepaintContexts { + IdleRepaintContext slots[LVGL_DEVICES_MAX_PER_TYPE] = {}; +}; + +// Allocated once in external memory when available +static IdleRepaintContext* idle_repaint_contexts() { + static IdleRepaintContexts* instance = tt::newObject(); + check(instance != nullptr); + return instance->slots; +} extern "C" { @@ -144,7 +156,7 @@ void lvgl_devices_attach() { // cases without extra plumbing at app-close time specifically. if (display_has_capability(kernel_display_device, DISPLAY_CAPABILITY_SLOW_REFRESH)) { display_clear(kernel_display_device); - IdleRepaintContext* ctx = &idle_repaint_contexts[i]; + IdleRepaintContext* ctx = &idle_repaint_contexts()[i]; if (ctx->timer == nullptr) { ctx->lvgl_display = added_display; ctx->kernel_display_device = kernel_display_device; @@ -257,7 +269,7 @@ void lvgl_devices_detach() { } for (size_t i = 0; i < LVGL_DEVICES_MAX_PER_TYPE; i++) { - IdleRepaintContext* ctx = &idle_repaint_contexts[i]; + IdleRepaintContext* ctx = &idle_repaint_contexts()[i]; if (ctx->timer != nullptr) { lv_timer_delete(ctx->timer); *ctx = {}; diff --git a/Modules/lvgl-module/source/icon_names.c b/Modules/lvgl-module/source/icon_names.c index c7a572138..76d76f6d7 100644 --- a/Modules/lvgl-module/source/icon_names.c +++ b/Modules/lvgl-module/source/icon_names.c @@ -34,6 +34,7 @@ const char* const lvgl_icon_shared_names[] = { "language", "lists", "list_alt", + "hourglass", "mail", "menu", "memory", diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index ed5e565ac..c7729380e 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -22,8 +22,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -129,6 +131,10 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lvgl_page_indicator_create), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page_count), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page), + // lvgl_step_indicator + DEFINE_MODULE_SYMBOL(lvgl_step_indicator_create), + DEFINE_MODULE_SYMBOL(lvgl_step_indicator_set_step_count), + DEFINE_MODULE_SYMBOL(lvgl_step_indicator_set_finished_count), // lvgl_card DEFINE_MODULE_SYMBOL(lvgl_card_create), // lvgl_chip @@ -136,6 +142,8 @@ const struct ModuleSymbol lvgl_module_symbols[] = { // lvgl_icon_button DEFINE_MODULE_SYMBOL(lvgl_icon_button_create), DEFINE_MODULE_SYMBOL(lvgl_icon_button_create_variant), + // lvgl_text_button + DEFINE_MODULE_SYMBOL(lvgl_text_button_create), // lvgl_list DEFINE_MODULE_SYMBOL(lvgl_list_create), DEFINE_MODULE_SYMBOL(lvgl_list_add_text), diff --git a/Modules/lvgl-module/source/themes/lv_theme_material.c b/Modules/lvgl-module/source/themes/lv_theme_material.c index 2df4a83b0..07893d0db 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material.c @@ -19,9 +19,12 @@ extern const lv_obj_class_t lvgl_chip_class; extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_page_indicator_class; extern const lv_obj_class_t lvgl_page_indicator_dot_class; +extern const lv_obj_class_t lvgl_step_indicator_class; +extern const lv_obj_class_t lvgl_step_indicator_segment_class; extern const lv_obj_class_t lvgl_list_class; extern const lv_obj_class_t lvgl_list_text_class; extern const lv_obj_class_t lvgl_list_button_class; +extern const lv_obj_class_t lvgl_text_button_class; #define NEUTRAL_4 lv_color_hex(0x0E0E0E) #define NEUTRAL_6 lv_color_hex(0x131313) @@ -149,6 +152,11 @@ extern const lv_obj_class_t lvgl_list_button_class; #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE_VARIANT #define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8)) #define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_FINISHED_COLOR COLOR_SECONDARY +#define STEP_INDICATOR_UNFINISHED_COLOR (lv_color_mix(lv_color_black(), COLOR_SECONDARY, LV_OPA_50)) +#define STEP_INDICATOR_THICKNESS (LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_MARGIN (LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_GAP STEP_INDICATOR_MARGIN #define BADGE_COLOR COLOR_SECONDARY #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define SCROLLBAR_COLOR COLOR_OUTLINE @@ -265,7 +273,7 @@ extern const lv_obj_class_t lvgl_list_button_class; #define KEY_CHECKED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER #define KEY_SELECTED_COLOR COLOR_PRIMARY #define KEY_SELECTED_TEXT_COLOR COLOR_ON_PRIMARY -#define LIST_COLOR COLOR_SURFACE +#define LIST_COLOR CARD_COLOR #define LIST_PAD DENSITY_LIST_PAD #define LIST_ITEM_COLOR COLOR_SURFACE #define LIST_ITEM_OPACITY LV_OPA_TRANSP @@ -275,7 +283,9 @@ extern const lv_obj_class_t lvgl_list_button_class; #define LIST_ITEM_GAP (theme->config.is_compact ? 3 : SPACE_4) #define LIST_ITEM_FOCUSED_COLOR COLOR_SECONDARY_CONTAINER #define LIST_ITEM_FOCUSED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER -#define LIST_DIVIDER_COLOR COLOR_OUTLINE_VARIANT +#define LIST_ITEM_SELECTED_COLOR COLOR_SECONDARY_CONTAINER +#define LIST_ITEM_SELECTED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER +#define LIST_DIVIDER_COLOR (lv_color_mix(COLOR_SECONDARY, LIST_COLOR, 89)) #define LIST_DIVIDER_WIDTH DENSITY_BORDER #define LIST_SUBHEADER_TEXT_COLOR COLOR_ON_SURFACE_VARIANT #define TABS_COLOR CARD_COLOR @@ -356,6 +366,9 @@ typedef struct { #endif lv_style_t page_indicator_dot; lv_style_t page_indicator_dot_current; + lv_style_t step_indicator; + lv_style_t step_indicator_segment; + lv_style_t step_indicator_segment_finished; lv_style_t badge; lv_style_t chip; lv_style_t chip_selected; @@ -372,12 +385,9 @@ typedef struct { lv_style_t button_checked_pressed; #endif lv_style_t button_disabled; -#if LV_USE_MSGBOX lv_style_t text_button; -#endif -#if LV_USE_MSGBOX lv_style_t text_button_pressed; -#endif + lv_style_t text_button_margin; lv_style_t icon_button; lv_style_t icon_button_selected; lv_style_t icon_button_filled; @@ -502,6 +512,7 @@ typedef struct { lv_style_t list; lv_style_t list_item; lv_style_t list_item_focused; + lv_style_t list_item_selected; lv_style_t list_subheader; #if LV_USE_TABVIEW lv_style_t tabs; @@ -800,6 +811,22 @@ static void style_init(my_theme_t * theme) style_init_reset(&theme->styles.page_indicator_dot_current); lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER); + style_init_reset(&theme->styles.step_indicator); + lv_style_set_bg_opa(&theme->styles.step_indicator, LV_OPA_TRANSP); + lv_style_set_border_width(&theme->styles.step_indicator, 0); + lv_style_set_pad_all(&theme->styles.step_indicator, STEP_INDICATOR_MARGIN); + lv_style_set_pad_gap(&theme->styles.step_indicator, STEP_INDICATOR_GAP); + + style_init_reset(&theme->styles.step_indicator_segment); + lv_style_set_height(&theme->styles.step_indicator_segment, STEP_INDICATOR_THICKNESS); + lv_style_set_radius(&theme->styles.step_indicator_segment, LV_RADIUS_CIRCLE); + lv_style_set_border_width(&theme->styles.step_indicator_segment, 0); + lv_style_set_bg_color(&theme->styles.step_indicator_segment, STEP_INDICATOR_UNFINISHED_COLOR); + lv_style_set_bg_opa(&theme->styles.step_indicator_segment, LV_OPA_COVER); + + style_init_reset(&theme->styles.step_indicator_segment_finished); + lv_style_set_bg_color(&theme->styles.step_indicator_segment_finished, STEP_INDICATOR_FINISHED_COLOR); + style_init_reset(&theme->styles.badge); lv_style_set_width(&theme->styles.badge, BADGE_SIZE); lv_style_set_height(&theme->styles.badge, BADGE_SIZE); @@ -857,7 +884,6 @@ static void style_init(my_theme_t * theme) lv_style_set_text_color(&theme->styles.button_disabled, BUTTON_DISABLED_LABEL_COLOR); lv_style_set_text_opa(&theme->styles.button_disabled, BUTTON_DISABLED_LABEL_OPACITY); -#if LV_USE_MSGBOX style_init_reset(&theme->styles.text_button); lv_style_set_bg_opa(&theme->styles.text_button, LV_OPA_TRANSP); lv_style_set_text_color(&theme->styles.text_button, TEXT_BUTTON_LABEL_COLOR); @@ -865,13 +891,13 @@ static void style_init(my_theme_t * theme) lv_style_set_pad_hor(&theme->styles.text_button, TEXT_BUTTON_PAD_HOR); lv_style_set_pad_ver(&theme->styles.text_button, BUTTON_PAD_VER); lv_style_set_pad_gap(&theme->styles.text_button, BUTTON_GAP); -#endif -#if LV_USE_MSGBOX style_init_reset(&theme->styles.text_button_pressed); lv_style_set_bg_color(&theme->styles.text_button_pressed, TEXT_BUTTON_PRESSED_CONTAINER_COLOR); lv_style_set_bg_opa(&theme->styles.text_button_pressed, OPACITY_STATE_PRESSED); -#endif + + style_init_reset(&theme->styles.text_button_margin); + lv_style_set_margin_all(&theme->styles.text_button_margin, FOCUS_WIDTH + FOCUS_OFFSET); style_init_reset(&theme->styles.icon_button); lv_style_set_bg_opa(&theme->styles.icon_button, LV_OPA_TRANSP); @@ -1213,7 +1239,7 @@ static void style_init(my_theme_t * theme) lv_style_set_bg_color(&theme->styles.list, LIST_COLOR); lv_style_set_bg_opa(&theme->styles.list, LV_OPA_COVER); lv_style_set_pad_hor(&theme->styles.list, 0); - lv_style_set_pad_ver(&theme->styles.list, LIST_PAD); + lv_style_set_pad_ver(&theme->styles.list, 0); lv_style_set_pad_gap(&theme->styles.list, 0); lv_style_set_clip_corner(&theme->styles.list, true); @@ -1233,6 +1259,11 @@ static void style_init(my_theme_t * theme) lv_style_set_bg_opa(&theme->styles.list_item_focused, LV_OPA_COVER); lv_style_set_text_color(&theme->styles.list_item_focused, LIST_ITEM_FOCUSED_TEXT_COLOR); + style_init_reset(&theme->styles.list_item_selected); + lv_style_set_bg_color(&theme->styles.list_item_selected, LIST_ITEM_SELECTED_COLOR); + lv_style_set_bg_opa(&theme->styles.list_item_selected, LV_OPA_COVER); + lv_style_set_text_color(&theme->styles.list_item_selected, LIST_ITEM_SELECTED_TEXT_COLOR); + style_init_reset(&theme->styles.list_subheader); lv_style_set_text_color(&theme->styles.list_subheader, LIST_SUBHEADER_TEXT_COLOR); lv_style_set_text_font(&theme->styles.list_subheader, theme->config.font_small); @@ -1843,6 +1874,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) return; } #endif + if(lv_obj_check_type(obj, &lvgl_text_button_class)) { + /* text_button */ + lv_obj_add_style(obj, &theme->styles.text_button, 0); + lv_obj_add_style(obj, &theme->styles.text_button_margin, 0); + lv_obj_add_style(obj, &theme->styles.transition_exit, 0); + lv_obj_add_style(obj, &theme->styles.text_button_pressed, LV_STATE_PRESSED); + lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED); + lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY); + lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); + return; + } #if LV_USE_MSGBOX if(lv_obj_check_type(obj, &lv_msgbox_footer_button_class)) { /* msgbox_footer_button */ @@ -1927,6 +1969,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED); return; } + if(lv_obj_check_type(obj, &lvgl_step_indicator_class)) { + /* step_indicator */ + lv_obj_add_style(obj, &theme->styles.step_indicator, 0); + return; + } + if(lv_obj_check_type(obj, &lvgl_step_indicator_segment_class)) { + /* step_indicator_segment */ + lv_obj_add_style(obj, &theme->styles.step_indicator_segment, 0); + lv_obj_add_style(obj, &theme->styles.step_indicator_segment_finished, LV_STATE_CHECKED); + return; + } if(lv_obj_check_type(obj, &lvgl_badge_class)) { /* badge */ lv_obj_add_style(obj, &theme->styles.badge, 0); @@ -2142,6 +2195,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) if(lv_obj_check_type(obj, &lvgl_list_button_class)) { /* flex_list_button */ lv_obj_add_style(obj, &theme->styles.list_item, 0); + lv_obj_add_style(obj, &theme->styles.list_item_selected, LV_STATE_CHECKED); lv_obj_add_style(obj, &theme->styles.transition_exit, 0); lv_obj_add_style(obj, &theme->styles.state_pressed, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED); diff --git a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c index c8e8458cb..b15c07806 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c @@ -19,9 +19,12 @@ extern const lv_obj_class_t lvgl_chip_class; extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_page_indicator_class; extern const lv_obj_class_t lvgl_page_indicator_dot_class; +extern const lv_obj_class_t lvgl_step_indicator_class; +extern const lv_obj_class_t lvgl_step_indicator_segment_class; extern const lv_obj_class_t lvgl_list_class; extern const lv_obj_class_t lvgl_list_text_class; extern const lv_obj_class_t lvgl_list_button_class; +extern const lv_obj_class_t lvgl_text_button_class; #define BLACK lv_color_black() #define WHITE lv_color_white() @@ -96,6 +99,10 @@ extern const lv_obj_class_t lvgl_list_button_class; #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE #define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8)) #define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_COLOR COLOR_ON_SURFACE +#define STEP_INDICATOR_THICKNESS (LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_MARGIN (LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_GAP STEP_INDICATOR_MARGIN #define BADGE_COLOR COLOR_ON_SURFACE #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define SCROLLBAR_COLOR COLOR_ON_SURFACE @@ -232,11 +239,15 @@ typedef struct { #endif lv_style_t page_indicator_dot; lv_style_t page_indicator_dot_current; + lv_style_t step_indicator; + lv_style_t step_indicator_segment; + lv_style_t step_indicator_segment_finished; lv_style_t badge; lv_style_t chip; #if LV_USE_BUTTON || LV_USE_MSGBOX lv_style_t button; #endif + lv_style_t text_button; lv_style_t icon_button; lv_style_t icon_button_filled; lv_style_t icon_button_filled_pressed; @@ -580,6 +591,23 @@ static void style_init(my_theme_t * theme) style_init_reset(&theme->styles.page_indicator_dot_current); lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER); + style_init_reset(&theme->styles.step_indicator); + lv_style_set_bg_opa(&theme->styles.step_indicator, LV_OPA_TRANSP); + lv_style_set_border_width(&theme->styles.step_indicator, 0); + lv_style_set_pad_all(&theme->styles.step_indicator, STEP_INDICATOR_MARGIN); + lv_style_set_pad_gap(&theme->styles.step_indicator, STEP_INDICATOR_GAP); + + style_init_reset(&theme->styles.step_indicator_segment); + lv_style_set_height(&theme->styles.step_indicator_segment, STEP_INDICATOR_THICKNESS); + lv_style_set_radius(&theme->styles.step_indicator_segment, LV_RADIUS_CIRCLE); + lv_style_set_bg_color(&theme->styles.step_indicator_segment, STEP_INDICATOR_COLOR); + lv_style_set_bg_opa(&theme->styles.step_indicator_segment, LV_OPA_TRANSP); + lv_style_set_border_color(&theme->styles.step_indicator_segment, STEP_INDICATOR_COLOR); + lv_style_set_border_width(&theme->styles.step_indicator_segment, 1); + + style_init_reset(&theme->styles.step_indicator_segment_finished); + lv_style_set_bg_opa(&theme->styles.step_indicator_segment_finished, LV_OPA_COVER); + style_init_reset(&theme->styles.badge); lv_style_set_width(&theme->styles.badge, BADGE_SIZE); lv_style_set_height(&theme->styles.badge, BADGE_SIZE); @@ -612,6 +640,16 @@ static void style_init(my_theme_t * theme) lv_style_set_margin_all(&theme->styles.button, FOCUS_WIDTH + FOCUS_OFFSET); #endif + style_init_reset(&theme->styles.text_button); + lv_style_set_bg_opa(&theme->styles.text_button, LV_OPA_TRANSP); + lv_style_set_text_color(&theme->styles.text_button, BUTTON_LABEL_COLOR); + lv_style_set_border_width(&theme->styles.text_button, 0); + lv_style_set_radius(&theme->styles.text_button, BUTTON_SHAPE); + lv_style_set_pad_hor(&theme->styles.text_button, BUTTON_PAD_HOR); + lv_style_set_pad_ver(&theme->styles.text_button, BUTTON_PAD_VER); + lv_style_set_pad_gap(&theme->styles.text_button, BUTTON_GAP); + lv_style_set_margin_all(&theme->styles.text_button, FOCUS_WIDTH + FOCUS_OFFSET); + style_init_reset(&theme->styles.icon_button); lv_style_set_bg_opa(&theme->styles.icon_button, LV_OPA_TRANSP); lv_style_set_text_color(&theme->styles.icon_button, COLOR_ON_SURFACE); @@ -877,7 +915,7 @@ static void style_init(my_theme_t * theme) lv_style_set_border_width(&theme->styles.list, BORDER); lv_style_set_radius(&theme->styles.list, SHAPE_CONTAINER); lv_style_set_pad_hor(&theme->styles.list, 0); - lv_style_set_pad_ver(&theme->styles.list, LIST_PAD); + lv_style_set_pad_ver(&theme->styles.list, 0); lv_style_set_pad_gap(&theme->styles.list, 0); lv_style_set_clip_corner(&theme->styles.list, true); @@ -1415,6 +1453,14 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) return; } #endif + if(lv_obj_check_type(obj, &lvgl_text_button_class)) { + /* text_button */ + lv_obj_add_style(obj, &theme->styles.text_button, 0); + lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED); + lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY); + lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); + return; + } #if LV_USE_MSGBOX if(lv_obj_check_type(obj, &lv_msgbox_footer_button_class)) { /* msgbox_footer_button */ @@ -1495,6 +1541,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED); return; } + if(lv_obj_check_type(obj, &lvgl_step_indicator_class)) { + /* step_indicator */ + lv_obj_add_style(obj, &theme->styles.step_indicator, 0); + return; + } + if(lv_obj_check_type(obj, &lvgl_step_indicator_segment_class)) { + /* step_indicator_segment */ + lv_obj_add_style(obj, &theme->styles.step_indicator_segment, 0); + lv_obj_add_style(obj, &theme->styles.step_indicator_segment_finished, LV_STATE_CHECKED); + return; + } if(lv_obj_check_type(obj, &lvgl_badge_class)) { /* badge */ lv_obj_add_style(obj, &theme->styles.badge, 0); @@ -1703,6 +1760,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) if(lv_obj_check_type(obj, &lvgl_list_button_class)) { /* flex_list_button */ lv_obj_add_style(obj, &theme->styles.list_item, 0); + lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_CHECKED); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_FOCUS_KEY); lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); diff --git a/Modules/lvgl-module/source/widgets/statusbar.cpp b/Modules/lvgl-module/source/widgets/statusbar.cpp index a41e39673..3346ab523 100644 --- a/Modules/lvgl-module/source/widgets/statusbar.cpp +++ b/Modules/lvgl-module/source/widgets/statusbar.cpp @@ -19,6 +19,8 @@ #include #include +#include + #include #include #include @@ -61,7 +63,12 @@ struct StatusbarData { } }; -static StatusbarData statusbar_data; +// Allocated once in external memory when available +static StatusbarData& statusbar_data() { + static StatusbarData* instance = tt::newObject(); + check(instance != nullptr); + return *instance; +} struct Statusbar { lv_obj_t obj; @@ -99,8 +106,8 @@ static TickType_t getNextUpdateTime() { } static void notify_instances() { - mutex_lock(&statusbar_data.instances_mutex); - for (auto* statusbar = statusbar_data.instances; statusbar != nullptr; statusbar = statusbar->next) { + mutex_lock(&statusbar_data().instances_mutex); + for (auto* statusbar = statusbar_data().instances; statusbar != nullptr; statusbar = statusbar->next) { LOG_D(TAG, "Update event"); if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) { update_main(statusbar); @@ -110,28 +117,28 @@ static void notify_instances() { LOG_W(TAG, "Mutex acquisition timeout (%s)", "Statusbar"); } } - mutex_unlock(&statusbar_data.instances_mutex); + mutex_unlock(&statusbar_data().instances_mutex); } static void on_update_time(void* /*context*/) { time_t now = ::time(nullptr); tm* tm_struct = localtime(&now); - if (recursive_mutex_try_lock(&statusbar_data.mutex, 100 / portTICK_PERIOD_MS)) { + if (recursive_mutex_try_lock(&statusbar_data().mutex, 100 / portTICK_PERIOD_MS)) { bool changed = false; if (tm_struct->tm_year >= (2025 - 1900)) { - statusbar_data.time_hours = tm_struct->tm_hour; - statusbar_data.time_minutes = tm_struct->tm_min; - statusbar_data.time_set = true; + statusbar_data().time_hours = tm_struct->tm_hour; + statusbar_data().time_minutes = tm_struct->tm_min; + statusbar_data().time_set = true; changed = true; // Reschedule - timer_reset_with_interval(statusbar_data.time_update_timer, getNextUpdateTime()); + timer_reset_with_interval(statusbar_data().time_update_timer, getNextUpdateTime()); } else { - timer_reset_with_interval(statusbar_data.time_update_timer, pdMS_TO_TICKS(60000U)); + timer_reset_with_interval(statusbar_data().time_update_timer, pdMS_TO_TICKS(60000U)); } - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_unlock(&statusbar_data().mutex); if (changed) { notify_instances(); } @@ -154,9 +161,9 @@ static lv_obj_class_t statusbar_class = { }; static void onTimeChanged(struct SystemEvent* /*event*/, void* /*context*/) { - recursive_mutex_lock(&statusbar_data.mutex); - timer_reset_with_interval(statusbar_data.time_update_timer, 5); - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_lock(&statusbar_data().mutex); + timer_reset_with_interval(statusbar_data().time_update_timer, 5); + recursive_mutex_unlock(&statusbar_data().mutex); } static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { @@ -170,26 +177,26 @@ static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) // would let a concurrent lvgl_statusbar_icon_add()/_remove()/_set_image()/_set_visibility() call // from another task run update_icon() on a still-null icons[] slot before this // instance's own creation loop below has had a chance to fill it in. - recursive_mutex_lock(&statusbar_data.mutex); - if (statusbar_data.time_update_timer == nullptr) { - statusbar_data.time_update_timer = timer_alloc(TIMER_TYPE_ONCE, 200 / portTICK_PERIOD_MS, on_update_time, nullptr); - check(statusbar_data.time_update_timer != nullptr); - timer_start(statusbar_data.time_update_timer); + recursive_mutex_lock(&statusbar_data().mutex); + if (statusbar_data().time_update_timer == nullptr) { + statusbar_data().time_update_timer = timer_alloc(TIMER_TYPE_ONCE, 200 / portTICK_PERIOD_MS, on_update_time, nullptr); + check(statusbar_data().time_update_timer != nullptr); + timer_start(statusbar_data().time_update_timer); system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, onTimeChanged, nullptr); } - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_unlock(&statusbar_data().mutex); } static void statusbar_destructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { auto* statusbar = (Statusbar*)obj; - mutex_lock(&statusbar_data.instances_mutex); - for (auto** link = &statusbar_data.instances; *link != nullptr; link = &(*link)->next) { + mutex_lock(&statusbar_data().instances_mutex); + for (auto** link = &statusbar_data().instances; *link != nullptr; link = &(*link)->next) { if (*link == statusbar) { *link = statusbar->next; break; } } - mutex_unlock(&statusbar_data.instances_mutex); + mutex_unlock(&statusbar_data().instances_mutex); } static void update_icon(lv_obj_t* image, const StatusbarIcon* icon) { @@ -202,9 +209,9 @@ static void update_icon(lv_obj_t* image, const StatusbarIcon* icon) { } static void show_menu() { - recursive_mutex_lock(&statusbar_data.mutex); - auto* callback = statusbar_data.menu_callback; - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_lock(&statusbar_data().mutex); + auto* callback = statusbar_data().menu_callback; + recursive_mutex_unlock(&statusbar_data().mutex); if (callback != nullptr) { callback(); } @@ -257,15 +264,15 @@ static void onStatusbarGesture(lv_event_t* /*event*/) { extern "C" { void lvgl_statusbar_set_menu_callback(void (*callback)()) { - recursive_mutex_lock(&statusbar_data.mutex); - statusbar_data.menu_callback = callback; - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_lock(&statusbar_data().mutex); + statusbar_data().menu_callback = callback; + recursive_mutex_unlock(&statusbar_data().mutex); } void lvgl_statusbar_set_time_format_24h(bool format_24h) { - recursive_mutex_lock(&statusbar_data.mutex); - statusbar_data.time_format_24h = format_24h; - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_lock(&statusbar_data().mutex); + statusbar_data().time_format_24h = format_24h; + recursive_mutex_unlock(&statusbar_data().mutex); notify_instances(); } @@ -362,7 +369,7 @@ lv_obj_t* lvgl_statusbar_create(lv_obj_t* parent) { } - recursive_mutex_lock(&statusbar_data.mutex); + recursive_mutex_lock(&statusbar_data().mutex); for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { auto* image = lv_image_create(icon_row); lv_obj_set_size(image, icon_size, icon_size); // regular padding doesn't work @@ -370,17 +377,17 @@ lv_obj_t* lvgl_statusbar_create(lv_obj_t* parent) { lv_obj_set_style_pad_all(image, 0, LV_STATE_DEFAULT); statusbar->icons[i] = image; - update_icon(image, &(statusbar_data.icons[i])); + update_icon(image, &(statusbar_data().icons[i])); } // Only now is statusbar->icons[] fully populated - see statusbar_constructor()'s comment for // why the instance can't be registered any earlier. Registering before unlocking (rather // than after) closes a narrow window where a concurrent icon change could be missed by // this instance after the icons are populated but before it's registered. - mutex_lock(&statusbar_data.instances_mutex); - statusbar->next = statusbar_data.instances; - statusbar_data.instances = statusbar; - mutex_unlock(&statusbar_data.instances_mutex); - recursive_mutex_unlock(&statusbar_data.mutex); + mutex_lock(&statusbar_data().instances_mutex); + statusbar->next = statusbar_data().instances; + statusbar_data().instances = statusbar; + mutex_unlock(&statusbar_data().instances_mutex); + recursive_mutex_unlock(&statusbar_data().mutex); // Only the icon row is focused, it joins the group by itself if (lv_obj_get_group(obj) != nullptr) { @@ -393,17 +400,17 @@ lv_obj_t* lvgl_statusbar_create(lv_obj_t* parent) { } static void update_time(Statusbar* statusbar) { - if (statusbar_data.time_set) { - bool format24 = statusbar_data.time_format_24h; - int hours = format24 ? statusbar_data.time_hours : statusbar_data.time_hours % 12; - lv_label_set_text_fmt(statusbar->time, "%d:%02d", hours, statusbar_data.time_minutes); + if (statusbar_data().time_set) { + bool format24 = statusbar_data().time_format_24h; + int hours = format24 ? statusbar_data().time_hours : statusbar_data().time_hours % 12; + lv_label_set_text_fmt(statusbar->time, "%d:%02d", hours, statusbar_data().time_minutes); } else { lv_label_set_text(statusbar->time, ""); } } // In the side strip, the time and the action buttons always show. The icons get the height that's left, and the newest ones are hidden when they don't fit. -// Call while holding statusbar_data.mutex, after update_icon() set the icons' visibility. +// Call while holding statusbar_data().mutex, after update_icon() set the icons' visibility. static void apply_side_icon_limit(Statusbar* statusbar) { if (!statusbar->side) { return; @@ -427,7 +434,7 @@ static void apply_side_icon_limit(Statusbar* statusbar) { } } std::sort(shown, shown + shown_count, [](int a, int b) { - return statusbar_data.icons[a].added_order < statusbar_data.icons[b].added_order; + return statusbar_data().icons[a].added_order < statusbar_data().icons[b].added_order; }); for (int i = static_cast(fitting); i < shown_count; ++i) { lv_obj_set_hidden(statusbar->icons[shown[i]], true); @@ -435,12 +442,12 @@ static void apply_side_icon_limit(Statusbar* statusbar) { } static void update_icons(Statusbar* statusbar) { - if (recursive_mutex_try_lock(&statusbar_data.mutex, 200 / portTICK_PERIOD_MS)) { + if (recursive_mutex_try_lock(&statusbar_data().mutex, 200 / portTICK_PERIOD_MS)) { for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { - update_icon(statusbar->icons[i], &(statusbar_data.icons[i])); + update_icon(statusbar->icons[i], &(statusbar_data().icons[i])); } apply_side_icon_limit(statusbar); - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_unlock(&statusbar_data().mutex); } } @@ -487,20 +494,20 @@ static void copy_image(char* target, const char* image) { extern "C" { int8_t lvgl_statusbar_icon_add(const char* image, bool visible) { - recursive_mutex_lock(&statusbar_data.mutex); + recursive_mutex_lock(&statusbar_data().mutex); int8_t result = -1; for (int8_t i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { - if (!statusbar_data.icons[i].claimed) { - statusbar_data.icons[i].claimed = true; - statusbar_data.icons[i].visible = visible; - copy_image(statusbar_data.icons[i].image, image); - statusbar_data.icons[i].added_order = statusbar_data.next_added_order++; + if (!statusbar_data().icons[i].claimed) { + statusbar_data().icons[i].claimed = true; + statusbar_data().icons[i].visible = visible; + copy_image(statusbar_data().icons[i].image, image); + statusbar_data().icons[i].added_order = statusbar_data().next_added_order++; result = i; LOG_D(TAG, "id %d: added", (int)i); break; } } - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_unlock(&statusbar_data().mutex); if (result < 0) { LOG_W(TAG, "No icon slot left, the icon isn't shown"); } @@ -514,12 +521,12 @@ void lvgl_statusbar_icon_remove(int8_t id) { return; } check(id >= 0 && id < STATUSBAR_ICON_LIMIT); - recursive_mutex_lock(&statusbar_data.mutex); - StatusbarIcon* icon = &statusbar_data.icons[id]; + recursive_mutex_lock(&statusbar_data().mutex); + StatusbarIcon* icon = &statusbar_data().icons[id]; icon->claimed = false; icon->visible = false; icon->image[0] = '\0'; - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_unlock(&statusbar_data().mutex); notify_instances(); } @@ -529,11 +536,11 @@ void lvgl_statusbar_icon_set_image(int8_t id, const char* image) { return; } check(id >= 0 && id < STATUSBAR_ICON_LIMIT); - recursive_mutex_lock(&statusbar_data.mutex); - StatusbarIcon* icon = &statusbar_data.icons[id]; + recursive_mutex_lock(&statusbar_data().mutex); + StatusbarIcon* icon = &statusbar_data().icons[id]; check(icon->claimed); copy_image(icon->image, image); - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_unlock(&statusbar_data().mutex); notify_instances(); } @@ -543,11 +550,11 @@ void lvgl_statusbar_icon_set_visibility(int8_t id, bool visible) { return; } check(id >= 0 && id < STATUSBAR_ICON_LIMIT); - recursive_mutex_lock(&statusbar_data.mutex); - StatusbarIcon* icon = &statusbar_data.icons[id]; + recursive_mutex_lock(&statusbar_data().mutex); + StatusbarIcon* icon = &statusbar_data().icons[id]; check(icon->claimed); icon->visible = visible; - recursive_mutex_unlock(&statusbar_data.mutex); + recursive_mutex_unlock(&statusbar_data().mutex); notify_instances(); } diff --git a/Modules/lvgl-module/source/widgets/step_indicator.cpp b/Modules/lvgl-module/source/widgets/step_indicator.cpp new file mode 100644 index 000000000..d15024eaa --- /dev/null +++ b/Modules/lvgl-module/source/widgets/step_indicator.cpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration + +#include + +static void step_indicator_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { + LV_UNUSED(class_p); + lv_obj_set_clickable(obj, false); + lv_obj_set_scrollable(obj, false); + lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); +} + +static void step_indicator_segment_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { + LV_UNUSED(class_p); + lv_obj_set_clickable(obj, false); + lv_obj_set_scrollable(obj, false); + // The segments share the indicator's width + lv_obj_set_flex_grow(obj, 1); +} + +// Not static: the theme styles step indicators and their segments by these classes +extern "C" const lv_obj_class_t lvgl_step_indicator_class = { + .base_class = &lv_obj_class, + .constructor_cb = step_indicator_constructor, + .destructor_cb = nullptr, + .event_cb = nullptr, + .user_data = nullptr, + .name = "step_indicator", + .width_def = LV_PCT(100), + .height_def = LV_SIZE_CONTENT, + .editable = LV_OBJ_CLASS_EDITABLE_INHERIT, + .group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE, + .instance_size = 0, + .theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE +}; + +extern "C" const lv_obj_class_t lvgl_step_indicator_segment_class = { + .base_class = &lv_obj_class, + .constructor_cb = step_indicator_segment_constructor, + .destructor_cb = nullptr, + .event_cb = nullptr, + .user_data = nullptr, + .name = "step_indicator_segment", + .width_def = 0, + .height_def = 0, + .editable = LV_OBJ_CLASS_EDITABLE_INHERIT, + .group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE, + .instance_size = 0, + .theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE +}; + +extern "C" { + +lv_obj_t* lvgl_step_indicator_create(lv_obj_t* parent) { + lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_step_indicator_class, parent); + lv_obj_class_init_obj(obj); + return obj; +} + +void lvgl_step_indicator_set_step_count(lv_obj_t* obj, uint32_t count) { + uint32_t current = lv_obj_get_child_count(obj); + while (current > count) { + lv_obj_delete(lv_obj_get_child(obj, -1)); + current--; + } + while (current < count) { + lv_obj_t* segment = lv_obj_class_create_obj(&lvgl_step_indicator_segment_class, obj); + lv_obj_class_init_obj(segment); + current++; + } +} + +void lvgl_step_indicator_set_finished_count(lv_obj_t* obj, uint32_t finished_count) { + const uint32_t count = lv_obj_get_child_count(obj); + for (uint32_t i = 0; i < count; i++) { + // The theme shows finished steps by the checked state + lv_obj_set_state(lv_obj_get_child(obj, static_cast(i)), LV_STATE_CHECKED, i < finished_count); + } +} + +} diff --git a/Modules/lvgl-module/source/widgets/text_button.cpp b/Modules/lvgl-module/source/widgets/text_button.cpp new file mode 100644 index 000000000..e9256ee5d --- /dev/null +++ b/Modules/lvgl-module/source/widgets/text_button.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration + +#include + +// Not static: the theme styles text buttons by this class +extern "C" const lv_obj_class_t lvgl_text_button_class = { + .base_class = &lv_button_class, + .constructor_cb = nullptr, + .destructor_cb = nullptr, + .event_cb = nullptr, + .user_data = nullptr, + .name = "text_button", + .width_def = 0, + .height_def = 0, + .editable = LV_OBJ_CLASS_EDITABLE_INHERIT, + .group_def = LV_OBJ_CLASS_GROUP_DEF_INHERIT, + .instance_size = 0, + .theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE +}; + +lv_obj_t* lvgl_text_button_create(lv_obj_t* parent) { + lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_text_button_class, parent); + lv_obj_class_init_obj(obj); + return obj; +} diff --git a/Modules/lvgl-module/themes/material.lss b/Modules/lvgl-module/themes/material.lss index 086b38601..a55faff42 100644 --- a/Modules/lvgl-module/themes/material.lss +++ b/Modules/lvgl-module/themes/material.lss @@ -35,9 +35,12 @@ @widget badge: lvgl_badge_class; @widget page_indicator: lvgl_page_indicator_class; @widget page_indicator_dot: lvgl_page_indicator_dot_class; +@widget step_indicator: lvgl_step_indicator_class; +@widget step_indicator_segment: lvgl_step_indicator_segment_class; @widget flex_list: lvgl_list_class; @widget flex_list_text: lvgl_list_text_class; @widget flex_list_button: lvgl_list_button_class; +@widget text_button: lvgl_text_button_class; // ============================================================ // 1. Base tokens @@ -218,6 +221,13 @@ @page-indicator-dot-size: if($isCompact, 6, 8dp); @page-indicator-gap: if($isCompact, 4, 6dp); +// Step indicator (lvgl_step_indicator_create): a line segment per step, finished steps in the secondary colour +@step-indicator-finished-color: @color-secondary; +@step-indicator-unfinished-color: mix(black, @color-secondary, 50%); +@step-indicator-thickness: 6dp; +@step-indicator-margin: 6dp; +@step-indicator-gap: @step-indicator-margin; + // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite @badge-color: @color-secondary; @badge-size: if($isCompact, 4, 6dp); @@ -372,7 +382,7 @@ @key-selected-text-color: @color-on-primary; // List -@list-color: @color-surface; +@list-color: @card-color; @list-pad: @density-list-pad; @list-item-color: @color-surface; @list-item-opacity: transp; @@ -382,7 +392,10 @@ @list-item-gap: if($isCompact, 3, @space-4); @list-item-focused-color: @color-secondary-container; @list-item-focused-text-color: @color-on-secondary-container; -@list-divider-color: @color-outline-variant; +@list-item-selected-color: @color-secondary-container; +@list-item-selected-text-color: @color-on-secondary-container; +// The secondary colour, muted towards the list background +@list-divider-color: mix(@color-secondary, @list-color, 35%); @list-divider-width: @density-border; @list-subheader-text-color: @color-on-surface-variant; @@ -613,6 +626,25 @@ page_indicator_dot_current { bgOpa: cover; } +step_indicator { + bgOpa: transp; + borderWidth: 0; + padAll: @step-indicator-margin; + padGap: @step-indicator-gap; +} + +step_indicator_segment { + height: @step-indicator-thickness; + radius: circle; + borderWidth: 0; + bgColor: @step-indicator-unfinished-color; + bgOpa: cover; +} + +step_indicator_segment_finished { + bgColor: @step-indicator-finished-color; +} + badge { width: @badge-size; height: @badge-size; @@ -686,6 +718,11 @@ text_button_pressed { bgOpa: @opacity-state-pressed; } +// Room for the focus ring of a text button, which the parent would clip +text_button_margin { + marginAll: @focus-width + @focus-offset; +} + icon_button { bgOpa: transp; textColor: @icon-button-color; @@ -1006,7 +1043,7 @@ list { bgColor: @list-color; bgOpa: cover; padHor: 0; - padVer: @list-pad; + padVer: 0; padGap: 0; clipCorner: true; } @@ -1029,6 +1066,13 @@ list_item_focused { textColor: @list-item-focused-text-color; } +// A list item that is checked to show it's the selected one +list_item_selected { + bgColor: @list-item-selected-color; + bgOpa: cover; + textColor: @list-item-selected-text-color; +} + list_subheader { textColor: @list-subheader-text-color; textFont: $fontSmall; @@ -1420,6 +1464,16 @@ apply(msgbox_header_button) { disabled: state.disabled; } +apply(text_button) { + text_button: default; + text_button_margin: default; + transition_exit: default; + text_button_pressed: state.pressed; + transition_enter: state.pressed; + focus_ring: state.focusKey; + disabled: state.disabled; +} + apply(msgbox_footer_button) { text_button: default; transition_exit: default; @@ -1485,6 +1539,15 @@ apply(page_indicator_dot) { page_indicator_dot_current: state.checked; } +apply(step_indicator) { + step_indicator: default; +} + +apply(step_indicator_segment) { + step_indicator_segment: default; + step_indicator_segment_finished: state.checked; +} + apply(badge) { badge: default; } @@ -1664,6 +1727,7 @@ apply(flex_list_text) { apply(flex_list_button) { list_item: default; + list_item_selected: state.checked; transition_exit: default; state_pressed: state.pressed; transition_enter: state.pressed; diff --git a/Modules/lvgl-module/themes/material_mono.lss b/Modules/lvgl-module/themes/material_mono.lss index ef01da7b1..acf10b63d 100644 --- a/Modules/lvgl-module/themes/material_mono.lss +++ b/Modules/lvgl-module/themes/material_mono.lss @@ -33,9 +33,12 @@ @widget badge: lvgl_badge_class; @widget page_indicator: lvgl_page_indicator_class; @widget page_indicator_dot: lvgl_page_indicator_dot_class; +@widget step_indicator: lvgl_step_indicator_class; +@widget step_indicator_segment: lvgl_step_indicator_segment_class; @widget flex_list: lvgl_list_class; @widget flex_list_text: lvgl_list_text_class; @widget flex_list_button: lvgl_list_button_class; +@widget text_button: lvgl_text_button_class; // ============================================================ // 1. Base tokens @@ -153,6 +156,12 @@ @page-indicator-dot-size: if($isCompact, 6, 8dp); @page-indicator-gap: if($isCompact, 4, 6dp); +// Step indicator (lvgl_step_indicator_create): a line segment per step, finished steps filled, unfinished steps outlined +@step-indicator-color: @color-on-surface; +@step-indicator-thickness: 6dp; +@step-indicator-margin: 6dp; +@step-indicator-gap: @step-indicator-margin; + // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite @badge-color: @color-on-surface; @badge-size: if($isCompact, 4, 6dp); @@ -430,6 +439,26 @@ page_indicator_dot_current { bgOpa: cover; } +step_indicator { + bgOpa: transp; + borderWidth: 0; + padAll: @step-indicator-margin; + padGap: @step-indicator-gap; +} + +step_indicator_segment { + height: @step-indicator-thickness; + radius: circle; + bgColor: @step-indicator-color; + bgOpa: transp; + borderColor: @step-indicator-color; + borderWidth: 1; +} + +step_indicator_segment_finished { + bgOpa: cover; +} + badge { width: @badge-size; height: @badge-size; @@ -465,6 +494,19 @@ button { marginAll: @focus-width + @focus-offset; } +// A button without a container, for low-emphasis actions +text_button { + bgOpa: transp; + textColor: @button-label-color; + borderWidth: 0; + radius: @button-shape; + padHor: @button-pad-hor; + padVer: @button-pad-ver; + padGap: @button-gap; + // Room for the focus ring, which the parent would clip + marginAll: @focus-width + @focus-offset; +} + icon_button { bgOpa: transp; textColor: @color-on-surface; @@ -719,7 +761,7 @@ list { borderWidth: @border; radius: @shape-container; padHor: 0; - padVer: @list-pad; + padVer: 0; padGap: 0; clipCorner: true; } @@ -1059,6 +1101,13 @@ apply(menu_sidebar_header_cont > button, menu_main_header_cont > button, win > o focus_ring: state.focusKey; } +apply(text_button) { + text_button: default; + inverted: state.pressed; + focus_ring: state.focusKey; + disabled: state.disabled; +} + apply(button, msgbox_footer_button) { button: default; inverted: state.pressed; @@ -1120,6 +1169,15 @@ apply(page_indicator_dot) { page_indicator_dot_current: state.checked; } +apply(step_indicator) { + step_indicator: default; +} + +apply(step_indicator_segment) { + step_indicator_segment: default; + step_indicator_segment_finished: state.checked; +} + apply(badge) { badge: default; } @@ -1270,6 +1328,7 @@ apply(flex_list_text) { apply(flex_list_button) { list_item: default; + inverted: state.checked; inverted: state.pressed; inverted: state.focusKey; disabled: state.disabled; diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 8ac829bc6..61dd96a47 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -63,22 +64,29 @@ void unlock() { mutex_unlock(&globalMutex.handle); } /** Serializes callOnServiceThread() callers. Taken before the global lock, never while holding it */ GlobalMutex callMutex; -const MeshBackendApi* backends[MAX_BACKENDS] = {}; -size_t backendCount = 0; +/** The registry, settings and subscriptions. Allocated once in external memory and guarded by the global lock. */ +struct Globals { + const MeshBackendApi* backends[MAX_BACKENDS] = {}; + size_t backendCount = 0; + MeshSettings settings {}; + bool settingsLoaded = false; + MeshEventSubscription* subscriptions = nullptr; +}; -MeshSettings settings {}; -bool settingsLoaded = false; - -MeshEventSubscription* subscriptions = nullptr; +Globals& globals() { + static Globals* instance = tt::newObject(); + check(instance != nullptr); + return *instance; +} uint64_t getMillis() { return get_micros_since_boot() / 1000; } const MeshBackendApi* findBackend(const char* id) { - for (size_t i = 0; i < backendCount; i++) { - if (strncmp(backends[i]->id, id, MESH_BACKEND_ID_MAX_LENGTH) == 0) { - return backends[i]; + for (size_t i = 0; i < globals().backendCount; i++) { + if (strncmp(globals().backends[i]->id, id, MESH_BACKEND_ID_MAX_LENGTH) == 0) { + return globals().backends[i]; } } return nullptr; @@ -102,10 +110,10 @@ Device* findRadio() { /** Requires the global lock and loaded settings */ const MeshBackendApi* getSelectedBackend() { - if (settings.backend[0] != '\0') { - return findBackend(settings.backend); + if (globals().settings.backend[0] != '\0') { + return findBackend(globals().settings.backend); } - return (backendCount > 0) ? backends[0] : nullptr; + return (globals().backendCount > 0) ? globals().backends[0] : nullptr; } size_t getPresetCount(const MeshBackendApi* api) { @@ -130,15 +138,15 @@ size_t findPreset(const MeshBackendApi* api, const char* name) { } void ensureSettingsLoaded() { - if (!settingsLoaded) { - mesh_settings_load(&settings); - settingsLoaded = true; + if (!globals().settingsLoaded) { + mesh_settings_load(&globals().settings); + globals().settingsLoaded = true; } } /** Requires the global lock */ void queueEvent(const MeshEvent& event) { - for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) { + for (auto* sub = globals().subscriptions; sub != nullptr; sub = sub->internal.next) { if (sub->internal.count >= MESH_EVENT_QUEUE_CAPACITY) { continue; } @@ -226,6 +234,7 @@ struct MeshHost { RadioTxId txId = 0; Frame txFrame; tt::std::deque airtime; + RadioRxEvent rxEvent {}; bool dutyCycleLogged = false; uint64_t lastTickMs = 0; @@ -377,7 +386,7 @@ void configure(MeshHost* host) { lock(); const MeshBackendApi* api = getSelectedBackend(); - const size_t preset = (api != nullptr) ? findPreset(api, settings.preset) : 0; + const size_t preset = (api != nullptr) ? findPreset(api, globals().settings.preset) : 0; unlock(); if (api == nullptr) { setStateLocked(host, MESH_STATE_NO_BACKEND); @@ -566,8 +575,8 @@ bool attachRadio(MeshHost* host) { void drainRadioEvents(MeshHost* host) { if (host->rxSubscribed) { - // Static: RadioRxEvent is large and only used on this thread - static RadioRxEvent rxEvent; + // A host member: RadioRxEvent is large and only used on this thread + auto& rxEvent = host->rxEvent; while (radio_rx_event_poll(&host->rxSub, &rxEvent) == ERROR_NONE) { if (host->api != nullptr) { if (!host->api->on_receive(host->backend, rxEvent.data, rxEvent.length, rxEvent.rssi, rxEvent.snr)) { @@ -767,7 +776,7 @@ const ServiceManifest mesh_service_manifest = { bool mesh_service_should_auto_start() { lock(); ensureSettingsLoaded(); - const bool enabled = settings.enabled; + const bool enabled = globals().settings.enabled; unlock(); return enabled; } @@ -781,10 +790,10 @@ error_t mesh_backend_register(const MeshBackendApi* api) { error_t result = ERROR_NONE; if (findBackend(api->id) != nullptr) { result = ERROR_ALREADY_EXISTS; - } else if (backendCount >= MAX_BACKENDS) { + } else if (globals().backendCount >= MAX_BACKENDS) { result = ERROR_RESOURCE; } else { - backends[backendCount++] = api; + globals().backends[globals().backendCount++] = api; if (service != nullptr && service->state == MESH_STATE_NO_BACKEND) { MeshHost* host = service; post(host, [host] { configure(host); }); @@ -797,10 +806,10 @@ error_t mesh_backend_register(const MeshBackendApi* api) { error_t mesh_backend_unregister(const MeshBackendApi* api) { lock(); error_t result = ERROR_NOT_FOUND; - for (size_t i = 0; i < backendCount; i++) { - if (backends[i] == api) { - backends[i] = backends[--backendCount]; - backends[backendCount] = nullptr; + for (size_t i = 0; i < globals().backendCount; i++) { + if (globals().backends[i] == api) { + globals().backends[i] = globals().backends[--globals().backendCount]; + globals().backends[globals().backendCount] = nullptr; result = ERROR_NONE; break; } @@ -947,7 +956,7 @@ error_t mesh_host_get_data_path(MeshHost* host, const char* child_path, char* ou void mesh_host_get_node_name(MeshHost*, char* name, size_t name_size) { lock(); ensureSettingsLoaded(); - strncpy(name, settings.node_name, name_size - 1); + strncpy(name, globals().settings.node_name, name_size - 1); name[name_size - 1] = '\0'; unlock(); } @@ -980,8 +989,8 @@ const char* mesh_state_to_string(MeshState state) { error_t mesh_set_enabled(bool enabled) { lock(); ensureSettingsLoaded(); - settings.enabled = enabled; - const MeshSettings copy = settings; + globals().settings.enabled = enabled; + const MeshSettings copy = globals().settings; unlock(); const error_t saved = mesh_settings_save(©); @@ -992,7 +1001,7 @@ error_t mesh_set_enabled(bool enabled) { bool mesh_is_enabled(void) { lock(); ensureSettingsLoaded(); - const bool enabled = settings.enabled; + const bool enabled = globals().settings.enabled; unlock(); return enabled; } @@ -1010,9 +1019,9 @@ error_t mesh_set_node_name(const char* name) { } lock(); ensureSettingsLoaded(); - strncpy(settings.node_name, name, MESH_NAME_MAX_LENGTH); - settings.node_name[MESH_NAME_MAX_LENGTH] = '\0'; - const MeshSettings copy = settings; + strncpy(globals().settings.node_name, name, MESH_NAME_MAX_LENGTH); + globals().settings.node_name[MESH_NAME_MAX_LENGTH] = '\0'; + const MeshSettings copy = globals().settings; unlock(); return mesh_settings_save(©); } @@ -1036,7 +1045,7 @@ bool mesh_is_available(void) { size_t mesh_get_backend_count(void) { lock(); - const size_t count = backendCount; + const size_t count = globals().backendCount; unlock(); return count; } @@ -1044,8 +1053,8 @@ size_t mesh_get_backend_count(void) { error_t mesh_get_backend_id(size_t index, char* id, size_t id_size) { lock(); error_t result = ERROR_NOT_FOUND; - if (index < backendCount && id_size > 0) { - strncpy(id, backends[index]->id, id_size - 1); + if (index < globals().backendCount && id_size > 0) { + strncpy(id, globals().backends[index]->id, id_size - 1); id[id_size - 1] = '\0'; result = ERROR_NONE; } @@ -1060,12 +1069,12 @@ error_t mesh_set_backend(const char* id) { return ERROR_NOT_FOUND; } ensureSettingsLoaded(); - if (strncmp(settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH) != 0) { - settings.preset[0] = '\0'; + if (strncmp(globals().settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH) != 0) { + globals().settings.preset[0] = '\0'; } - strncpy(settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH); - settings.backend[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; - const MeshSettings copy = settings; + strncpy(globals().settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH); + globals().settings.backend[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; + const MeshSettings copy = globals().settings; if (service != nullptr) { MeshHost* host = service; post(host, [host] { configure(host); }); @@ -1077,7 +1086,7 @@ error_t mesh_set_backend(const char* id) { error_t mesh_get_backend(char* id, size_t id_size) { lock(); ensureSettingsLoaded(); - const char* selected = settings.backend; + const char* selected = globals().settings.backend; if (service != nullptr && service->activeBackend[0] != '\0') { selected = service->activeBackend; } @@ -1123,9 +1132,9 @@ error_t mesh_set_preset(const char* name) { unlock(); return ERROR_NOT_FOUND; } - strncpy(settings.preset, name, MESH_NAME_MAX_LENGTH); - settings.preset[MESH_NAME_MAX_LENGTH] = '\0'; - const MeshSettings copy = settings; + strncpy(globals().settings.preset, name, MESH_NAME_MAX_LENGTH); + globals().settings.preset[MESH_NAME_MAX_LENGTH] = '\0'; + const MeshSettings copy = globals().settings; if (service != nullptr) { MeshHost* host = service; post(host, [host] { configure(host); }); @@ -1140,7 +1149,7 @@ error_t mesh_get_preset(char* name, size_t name_size) { const MeshBackendApi* api = getSelectedBackend(); error_t result = ERROR_NOT_FOUND; if (api != nullptr && getPresetCount(api) > 0 && name_size > 0) { - strncpy(name, api->presets[findPreset(api, settings.preset)], name_size - 1); + strncpy(name, api->presets[findPreset(api, globals().settings.preset)], name_size - 1); name[name_size - 1] = '\0'; result = ERROR_NONE; } @@ -1168,7 +1177,7 @@ error_t mesh_send_text(const MeshAddress* to, uint8_t channel, const char* text, message.peer = *to; } message.channel = channel; - strncpy(message.sender_name, settings.node_name, MESH_NAME_MAX_LENGTH); + strncpy(message.sender_name, globals().settings.node_name, MESH_NAME_MAX_LENGTH); const int64_t now = (int64_t)time(nullptr); message.wall_time = (now >= MIN_VALID_WALL_TIME) ? now : 0; message.time_source = (now >= MIN_VALID_WALL_TIME) ? MESH_TIME_SOURCE_SYSTEM : MESH_TIME_SOURCE_NONE; @@ -1295,7 +1304,7 @@ error_t mesh_event_subscribe(MeshEventSubscription* sub, TaskEventGroup* event_g } lock(); - for (auto* existing = subscriptions; existing != nullptr; existing = existing->internal.next) { + for (auto* existing = globals().subscriptions; existing != nullptr; existing = existing->internal.next) { if (existing == sub) { unlock(); task_event_group_release_bit(event_group, bit); @@ -1306,8 +1315,8 @@ error_t mesh_event_subscribe(MeshEventSubscription* sub, TaskEventGroup* event_g sub->internal.event_group = event_group; sub->internal.head = 0; sub->internal.count = 0; - sub->internal.next = subscriptions; - subscriptions = sub; + sub->internal.next = globals().subscriptions; + globals().subscriptions = sub; unlock(); return ERROR_NONE; } @@ -1315,7 +1324,7 @@ error_t mesh_event_subscribe(MeshEventSubscription* sub, TaskEventGroup* event_g error_t mesh_event_unsubscribe(MeshEventSubscription* sub) { error_t result = ERROR_NOT_FOUND; lock(); - for (auto** link = &subscriptions; *link != nullptr; link = &(*link)->internal.next) { + for (auto** link = &globals().subscriptions; *link != nullptr; link = &(*link)->internal.next) { if (*link == sub) { *link = sub->internal.next; result = ERROR_NONE; diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid.cpp index 89322ce06..47a999f42 100644 --- a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid.cpp +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid.cpp @@ -275,13 +275,41 @@ bool ble_hid_switch_profile(struct Device* device, BleHidProfile profile) { ble_gap_adv_stop(); + // NimBLE only allows GATT changes without discovery, which a repeating scan keeps starting + if (ble_gap_disc_active()) { + ble_gap_disc_cancel(); + if (ble_get_scan_active(device)) { + ble_set_scan_active(device, false); + struct BtEvent finished = {}; + finished.type = BT_EVENT_SCAN_FINISHED; + ble_publish_event(device, finished); + } + } + + // A connection that is being set up, such as an auto-connect to a keyboard, blocks it too + if (ble_gap_conn_active()) { + ble_gap_conn_cancel(); + } + BleHidDeviceCtx* hid_ctx = (BleHidDeviceCtx*)device_get_driver_data(device); if (hid_ctx && hid_ctx->hid_conn_handle.load() != BLE_HS_CONN_HANDLE_NONE) { ble_gap_terminate(hid_ctx->hid_conn_handle.load(), BLE_ERR_REM_USER_CONN_TERM); hid_ctx->hid_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); } - ble_gatts_reset(); + // ble_svc_gap_init() asserts when NimBLE refuses the change, so check first + if (ble_gap_adv_active() || ble_gap_disc_active() || ble_gap_conn_active()) { + LOG_W(TAG, "switchGattProfile: GAP procedure still active (adv=%d disc=%d conn=%d)", + ble_gap_adv_active(), ble_gap_disc_active(), ble_gap_conn_active()); + return false; + } + + // Refuses with BLE_HS_EBUSY while a connection exists, which ble_svc_gap_init() would assert on + const int reset_rc = ble_gatts_reset(); + if (reset_rc != 0) { + LOG_W(TAG, "switchGattProfile: ble_gatts_reset failed rc=%d", reset_rc); + return false; + } ble_svc_gap_init(); ble_svc_gatt_init(); @@ -357,6 +385,11 @@ void ble_hid_init_gatt() { // stored as its driver data. static error_t hid_device_start(struct Device* device, enum BtHidDeviceMode mode) { + // NimBLE can't change GATT services while connected, and the HID host holds a connection + if (ble_hid_get_host_active(device)) { + LOG_W(TAG, "Can't start the HID device while a HID host connection is active"); + return ERROR_INVALID_STATE; + } // Clean up any leftover context from a previous session (e.g. BLE was disabled // while connected: dispatch_disable() skips the normal hid_device_stop cleanup). BleHidDeviceCtx* old_ctx = (BleHidDeviceCtx*)device_get_driver_data(device); diff --git a/Tactility/Include/Tactility/settings/RadioSettings.h b/Tactility/Include/Tactility/settings/RadioSettings.h index a39621af0..d9f73432c 100644 --- a/Tactility/Include/Tactility/settings/RadioSettings.h +++ b/Tactility/Include/Tactility/settings/RadioSettings.h @@ -2,6 +2,8 @@ #include +#include + namespace tt::settings::radio { /** @return the persisted regulatory region, RADIO_REGION_UNSET when none was chosen */ @@ -19,4 +21,10 @@ const char* getRegionLabel(RadioRegion region); /** @return true when the devicetree has a radio, whether it's started or not */ bool hasRadio(); +/** + * @param[in] timeZoneName an IANA time zone name, e.g. "Europe/Amsterdam" + * @return the region of the most common radio band where that time zone is used, RADIO_REGION_UNSET when unknown + */ +RadioRegion findRegionForTimeZone(const std::string& timeZoneName); + } diff --git a/Tactility/Private/Tactility/app/TileGrid.h b/Tactility/Private/Tactility/app/TileGrid.h index 72ceb3f2f..05be0d8b6 100644 --- a/Tactility/Private/Tactility/app/TileGrid.h +++ b/Tactility/Private/Tactility/app/TileGrid.h @@ -62,7 +62,8 @@ public: void setSwipeNavigation(bool enabled) { swipeNavigation = enabled; } /** - * Creates the grid and populates it. Call setPageButtons() right after it to add page buttons. + * Creates the grid and populates it, with a page indicator below it that is hidden when there's only one page. + * Call setPageButtons() right after it to add page buttons. * @param[in] parent the container for the grid, with a flex column layout */ void createWidgets(lv_obj_t* parent); diff --git a/Tactility/Private/Tactility/app/btmanage/View.h b/Tactility/Private/Tactility/app/btmanage/View.h index 7a603bae7..9c945e4ae 100644 --- a/Tactility/Private/Tactility/app/btmanage/View.h +++ b/Tactility/Private/Tactility/app/btmanage/View.h @@ -33,6 +33,8 @@ public: View(Bindings* bindings, State* state) : bindings(bindings), state(state) {} void init(void* context, lv_obj_t* parent); + /** Forgets the widgets after their window was buried and deleted. Call with the LVGL lock held. */ + void destroyWidgets(); void update(); }; diff --git a/Tactility/Private/Tactility/app/timezone/TimeZoneView.h b/Tactility/Private/Tactility/app/timezone/TimeZoneView.h new file mode 100644 index 000000000..ff664a134 --- /dev/null +++ b/Tactility/Private/Tactility/app/timezone/TimeZoneView.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace tt::app::timezone { + +/** + * A searchable list of the time zones, with a button that detects the time zone from the location. + * Its state survives rebuilding its widgets, e.g. when a window is covered and shown again. + */ +class TimeZoneView final { + +public: + + /** Called with the LVGL lock held when the user picks a time zone, or when one was detected */ + using OnSelected = std::function; + + explicit TimeZoneView(OnSelected onSelected); + + /** Cancels a running detection and waits for its lookup to end, which can take a few seconds */ + ~TimeZoneView(); + + TimeZoneView(const TimeZoneView&) = delete; + TimeZoneView& operator=(const TimeZoneView&) = delete; + + /** Creates the widgets in @a parent, a flex column, with the LVGL lock held. The list grows to fill it. */ + void createWidgets(lv_obj_t* parent); + + /** Call with the LVGL lock held when the widgets were deleted */ + void destroyWidgets(); + + /** Shows this time zone as the selected one */ + void setSelectedName(const std::string& name); + +private: + + struct Entry { + std::string name; + std::string code; + }; + + OnSelected onSelected; + Mutex mutex; + // Guarded by mutex + std::vector entries; + std::string filter; + std::string selectedName; + std::string message; + std::unique_ptr filterTimer; + std::unique_ptr detectThread; + std::atomic detecting = false; + // Set when the view is destroyed, so a running detection ends without using it + std::atomic cancelled = false; + + // Valid while the widgets exist, guarded by the LVGL lock + lv_obj_t* textarea = nullptr; + lv_obj_t* detectButton = nullptr; + lv_obj_t* detectSpinner = nullptr; + lv_obj_t* messageLabel = nullptr; + lv_obj_t* list = nullptr; + + static void onFilterChanged(lv_event_t* event); + static void onDetectPressed(lv_event_t* event); + static void onEntryPressed(lv_event_t* event); + + void loadEntries(); + void rebuildList(); + void showState(); + void detect(); + void select(const std::string& name, const std::string& code); +}; + +} diff --git a/Tactility/Private/Tactility/service/screenshot/Screenshot.h b/Tactility/Private/Tactility/service/screenshot/Screenshot.h deleted file mode 100644 index 2fe89576a..000000000 --- a/Tactility/Private/Tactility/service/screenshot/Screenshot.h +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include "Tactility/TactilityConfig.h" - -#if TT_FEATURE_SCREENSHOT_ENABLED - -#include "ScreenshotTask.h" - -#include -#include - -namespace tt::service::screenshot { - -enum class Mode { - None, - Timed, - Apps -}; - -class ScreenshotService final : public Service { - - Mutex mutex; - std::unique_ptr task; - Mode mode = Mode::None; - -public: - - bool onStart(ServiceContext& serviceContext) override; - - bool isTaskStarted(); - - /** The state of the service. */ - Mode getMode() const; - - /** @brief Start taking screenshot whenever an app is started - * @param[in] path the path to store the screenshots at - */ - void startApps(const std::string& path); - - /** @brief Start taking screenshots after a certain delay - * @param[in] path the path to store the screenshots at - * @param[in] delayInSeconds the delay before starting (and between successive screenshots) - * @param[in] amount 0 = indefinite, >0 for a specific - */ - void startTimed(const std::string& path, uint8_t delayInSeconds, uint8_t amount); - - /** @brief Stop taking screenshots */ - void stop(); -}; - -std::shared_ptr optScreenshotService(); - -} // namespace - -#endif diff --git a/Tactility/Private/Tactility/service/screenshot/ScreenshotTask.h b/Tactility/Private/Tactility/service/screenshot/ScreenshotTask.h deleted file mode 100644 index 49a44e720..000000000 --- a/Tactility/Private/Tactility/service/screenshot/ScreenshotTask.h +++ /dev/null @@ -1,62 +0,0 @@ -#include - -#if TT_FEATURE_SCREENSHOT_ENABLED - -#pragma once - -#include -#include - -namespace tt::service::screenshot { - -#define TASK_WORK_TYPE_DELAY 1 -#define TASK_WORK_TYPE_APPS 2 - -class ScreenshotTask { - - struct ScreenshotTaskWork { - int type = TASK_WORK_TYPE_DELAY ; - uint8_t delay_in_seconds = 0; - uint8_t amount = 0; - std::string path; - }; - - Thread* thread = nullptr; - RecursiveMutex mutex; - bool interrupted = false; - bool finished = false; - ScreenshotTaskWork work; - -public: - ScreenshotTask() = default; - ~ScreenshotTask(); - - /** @brief Start taking screenshots after a certain delay - * @param[in] path the path to store the screenshots at - * @param[in] delayInSeconds the delay before starting (and between successive screenshots) - * @param[in] amount 0 = indefinite, >0 for a specific - */ - void startTimed(const std::string& path, uint8_t delayInSeconds, uint8_t amount); - - /** @brief Start taking screenshot whenever an app is started - * @param[in] path the path to store the screenshots at - */ - void startApps(const std::string& path); - - /** @brief Stop taking screenshots */ - void stop(); - - void taskMain(); - - bool isFinished(); - -private: - - bool isInterrupted(); - void setFinished(); - void taskStart(); -}; - -} - -#endif diff --git a/Tactility/Source/app/TileGrid.cpp b/Tactility/Source/app/TileGrid.cpp index 185716d1a..8ed110ddd 100644 --- a/Tactility/Source/app/TileGrid.cpp +++ b/Tactility/Source/app/TileGrid.cpp @@ -90,6 +90,40 @@ void updatePageIndicatorWidth(void* data) { } } +// A page indicator below the grid gets the width of its parent. +// It shows the page number when its dots are wider than its max width, which must be in pixels. +void updateCenteredPageIndicatorWidth(lv_obj_t* indicator) { + const int32_t margins = lv_obj_get_style_margin_left(indicator, LV_PART_MAIN) + lv_obj_get_style_margin_right(indicator, LV_PART_MAIN); + const int32_t max_width = std::max(0, lv_obj_get_content_width(lv_obj_get_parent(indicator)) - margins); + // Setting a style marks the layout as dirty, which would resize the parent and call this again + if (lv_obj_get_style_max_width(indicator, LV_PART_MAIN) != max_width) { + lv_obj_set_style_max_width(indicator, max_width, LV_STATE_DEFAULT); + } +} + +void onCenteredPageIndicatorParentSizeChanged(lv_event_t* event) { + updateCenteredPageIndicatorWidth(static_cast(lv_event_get_user_data(event))); +} + +void onCenteredPageIndicatorDeleted(lv_event_t* event) { + lv_obj_t* indicator = lv_event_get_target_obj(event); + lv_obj_remove_event_cb_with_user_data(lv_obj_get_parent(indicator), onCenteredPageIndicatorParentSizeChanged, indicator); +} + +/** Call after moving the page indicator below the grid */ +void centerPageIndicator(lv_obj_t* indicator) { + lv_obj_set_width(indicator, LV_PCT(100)); + lv_obj_add_event_cb(lv_obj_get_parent(indicator), onCenteredPageIndicatorParentSizeChanged, LV_EVENT_SIZE_CHANGED, indicator); + lv_obj_add_event_cb(indicator, onCenteredPageIndicatorDeleted, LV_EVENT_DELETE, nullptr); + updateCenteredPageIndicatorWidth(indicator); +} + +/** Call before moving the page indicator away from below the grid */ +void uncenterPageIndicator(lv_obj_t* indicator) { + lv_obj_remove_event_cb_with_user_data(lv_obj_get_parent(indicator), onCenteredPageIndicatorParentSizeChanged, indicator); + lv_obj_remove_event_cb(indicator, onCenteredPageIndicatorDeleted); +} + void onBottomBarSizeChanged(lv_event_t* event) { lv_async_call_cancel(updatePageIndicatorWidth, lv_event_get_target_obj(event)); lv_async_call(updatePageIndicatorWidth, lv_event_get_target_obj(event)); @@ -446,6 +480,11 @@ void TileGrid::createWidgets(lv_obj_t* parent) { barSideContainer = nullptr; barButtons.clear(); createGrid(parent); + // Centered below the grid + pageIndicator = lvgl_page_indicator_create(parent); + centerPageIndicator(pageIndicator); + // Keeps the dots off the screen's bottom edge + lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? LV_DPX(2) : LV_DPX(8), LV_STATE_DEFAULT); finishCreate(parent); } @@ -526,8 +565,7 @@ void TileGrid::setBarSideContainer(lv_obj_t* sideContainer) { // The page indicator is centered below the grid lv_obj_set_parent(pageIndicator, barParent); lv_obj_move_to_index(pageIndicator, lv_obj_get_index(grid) + 1); - lv_obj_set_width(pageIndicator, LV_PCT(100)); - lv_obj_remove_local_style_prop(pageIndicator, LV_STYLE_MAX_WIDTH, LV_PART_MAIN); + centerPageIndicator(pageIndicator); // Keeps the dots off the screen's bottom edge lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? 1 : 2, LV_STATE_DEFAULT); } else { @@ -538,9 +576,13 @@ void TileGrid::setBarSideContainer(lv_obj_t* sideContainer) { lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_BG_OPA, LV_PART_MAIN); lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_PAD_ROW, LV_PART_MAIN); lv_obj_set_hidden(barSpacer, false); + uncenterPageIndicator(pageIndicator); lv_obj_set_parent(pageIndicator, bottomBar); lv_obj_set_width(pageIndicator, LV_SIZE_CONTENT); lv_obj_remove_local_style_prop(pageIndicator, LV_STYLE_MARGIN_BOTTOM, LV_PART_MAIN); + // The bar's size can stay the same, which wouldn't update the max width + lv_async_call_cancel(updatePageIndicatorWidth, bottomBar); + lv_async_call(updatePageIndicatorWidth, bottomBar); } // The keys move through the bar where it's shown: below the statusbar's icons in the side strip, or below the grid setGroupOrder(grid, bottomBar, sideContainer != nullptr); diff --git a/Tactility/Source/app/apphub/AppHubApp.cpp b/Tactility/Source/app/apphub/AppHubApp.cpp index 33ce11876..2991aef89 100644 --- a/Tactility/Source/app/apphub/AppHubApp.cpp +++ b/Tactility/Source/app/apphub/AppHubApp.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -14,7 +15,9 @@ #include #include +#include #include +#include #include #include #include @@ -50,6 +53,8 @@ struct Context { lv_obj_t* contentWrapper = nullptr; lv_obj_t* refreshButton = nullptr; + lv_obj_t* updateAllButton = nullptr; + lv_obj_t* updateAllSpinner = nullptr; lv_obj_t* filterChips = nullptr; // Survives across a bury/resurface cycle Filter filter = Filter::All; @@ -74,6 +79,10 @@ struct Context { AppHubEventSubscription appHubSub {}; /** The id of the queued catalog refresh, 0 when none is pending */ std::atomic refreshOperationId {0}; + /** The id of the running update of all apps, 0 when none is running */ + uint32_t updateAllOperationId = 0; + /** The ids of the apps that failed to update during updateAllOperationId */ + std::vector failedUpdates; }; @@ -103,6 +112,30 @@ void updateFilterChips(Context* ctx) { } } +/** + * Shows the spinner while App Hub is busy, and otherwise the update all button when there are updates. + * Requires the LVGL lock. + */ +void updateToolbarActions(Context* ctx, bool hasUpdates) { + if (ctx->updateAllSpinner == nullptr) { + return; + } + const bool busy = app_hub_is_busy(); + lv_obj_set_hidden(ctx->updateAllSpinner, !busy); + lv_obj_set_hidden(ctx->updateAllButton, busy || !hasUpdates); +} + +void onUpdateAllPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + const error_t error = app_hub_update_all(nullptr); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to queue the update of all apps: %s", error_to_string(error)); + return; + } + // Shows the spinner + showApps(ctx); +} + void updateUpdatesChipText(Context* ctx, size_t updateCount) { auto* chip = lv_obj_get_child(ctx->filterChips, static_cast(Filter::Updates)); const auto text = std::format("{} {}", updateCount, updateCount == 1 ? "update" : "updates"); @@ -189,6 +222,7 @@ void showRefreshFailedError(Context* ctx, const char* message) { lv_label_set_text(label, message); lv_obj_set_hidden(ctx->refreshButton, false); + updateToolbarActions(ctx, false); } void showNoInternet(Context* ctx) { @@ -236,6 +270,7 @@ void showApps(Context* ctx) { }, &apps); const bool hasApps = !apps.empty(); updateUpdatesChipText(ctx, std::ranges::count_if(apps, [](const AppHubApp& app) { return app.update_available; })); + updateToolbarActions(ctx, std::ranges::any_of(apps, [](const AppHubApp& app) { return app.update_available; })); std::erase_if(apps, [ctx](const AppHubApp& app) { return !matchesFilter(app, ctx->filter); }); @@ -256,8 +291,11 @@ void showApps(Context* ctx) { lv_obj_t* focus_target = nullptr; for (int i = 0; i < apps.size(); i++) { const auto& app = apps[i]; - const char* icon = app.installed ? LV_SYMBOL_OK : nullptr; + const char* icon = app.operation_pending ? LVGL_ICON_SHARED_HOURGLASS : (app.installed ? LV_SYMBOL_OK : nullptr); auto* entry_button = lvgl_list_add_button(list, icon, app.name); + if (app.operation_pending) { + lv_obj_set_style_text_font(lv_obj_get_child(entry_button, 0), lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); + } auto int_as_voidptr = reinterpret_cast(i); lv_obj_set_user_data(entry_button, int_as_voidptr); lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, ctx); @@ -331,7 +369,17 @@ void refresh(Context* ctx) { lvgl_unlock(); } -void onOperationFinished(Context* ctx, const AppHubEvent& event) { +void onOperationEvent(Context* ctx, const AppHubEvent& event) { + if (event.type == APP_HUB_EVENT_REFRESHING) { + // The list doesn't change yet, only the toolbar shows that App Hub is busy + lvgl_lock(); + if (ctx->updateAllButton != nullptr) { + updateToolbarActions(ctx, !lv_obj_is_hidden(ctx->updateAllButton)); + } + lvgl_unlock(); + return; + } + const bool isRefresh = event.operation_id == ctx->refreshOperationId; if (isRefresh) { ctx->refreshOperationId = 0; @@ -341,13 +389,10 @@ void onOperationFinished(Context* ctx, const AppHubEvent& event) { } } - if (ctx->contentWrapper == nullptr) { - // Buried (e.g. AppHubDetailsApp is open): createWidgets() rebuilds the list on resurface - return; - } - lvgl_lock(); - if (isRefresh && event.result != ERROR_NONE) { + if (ctx->contentWrapper == nullptr) { + // Buried (e.g. AppHubDetailsApp or a dialog is open): createWidgets() rebuilds the list on resurface + } else if (isRefresh && event.result != ERROR_NONE) { showRefreshFailedError(ctx, "Cannot reach server"); } else { showApps(ctx); @@ -366,6 +411,10 @@ void createWidgets(lv_obj_t* parent, void* userData) { lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, ctx); lv_obj_set_hidden(ctx->refreshButton, true); + ctx->updateAllButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DOWNLOAD, onUpdateAllPressed, ctx); + lv_obj_set_hidden(ctx->updateAllButton, true); + ctx->updateAllSpinner = lvgl_toolbar_add_spinner_action(toolbar); + lv_obj_set_hidden(ctx->updateAllSpinner, true); ctx->filterChips = createFilterChips(parent, ctx); updateFilterChips(ctx); @@ -391,9 +440,36 @@ void destroyWidgets(void* userData) { ctx->scrollY = lv_obj_get_scroll_y(ctx->contentWrapper); ctx->contentWrapper = nullptr; ctx->refreshButton = nullptr; + ctx->updateAllButton = nullptr; + ctx->updateAllSpinner = nullptr; ctx->filterChips = nullptr; } +/** Tracks the update of all apps, and shows which apps failed when it's done */ +void onUpdateAllEvent(Context* ctx, const AppHubEvent& event) { + if (event.type == APP_HUB_EVENT_UPDATING) { + ctx->updateAllOperationId = event.operation_id; + ctx->failedUpdates.clear(); + } else if (event.type == APP_HUB_EVENT_INSTALLED && event.operation_id == ctx->updateAllOperationId && event.result != ERROR_NONE) { + ctx->failedUpdates.emplace_back(event.app_id); + } else if (event.type == APP_HUB_EVENT_UPDATED && event.operation_id == ctx->updateAllOperationId) { + ctx->updateAllOperationId = 0; + if (!ctx->failedUpdates.empty()) { + std::string names; + for (const auto& id : ctx->failedUpdates) { + AppHubApp app {}; + if (!names.empty()) { + names += ", "; + } + names += (app_hub_get_app(id.c_str(), &app) == ERROR_NONE) ? app.name : id.c_str(); + } + const auto count = ctx->failedUpdates.size(); + alertdialog::start(ctx->appInstanceId, "Update failed", std::format("{} {} failed to update: {}", count, count == 1 ? "app" : "apps", names)); + ctx->failedUpdates.clear(); + } + } +} + int32_t appMain(int argc, char* argv[]) { uint32_t appInstanceId = app_scheduler_current_app_id(); Context ctx; @@ -429,11 +505,8 @@ int32_t appMain(int argc, char* argv[]) { AppHubEvent app_hub_event {}; while (app_hub_event_poll(&ctx.appHubSub, &app_hub_event) == ERROR_NONE) { - if (app_hub_event.type == APP_HUB_EVENT_REFRESHED || - app_hub_event.type == APP_HUB_EVENT_INSTALLED || - app_hub_event.type == APP_HUB_EVENT_REMOVED) { - onOperationFinished(&ctx, app_hub_event); - } + onUpdateAllEvent(&ctx, app_hub_event); + onOperationEvent(&ctx, app_hub_event); } if (!shouldClose && ctx.refreshRequested.exchange(false)) { diff --git a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp index cf44d6d11..e2f6d71a4 100644 --- a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp +++ b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp @@ -136,6 +136,10 @@ void onOperationEvent(Context* ctx, const AppHubEvent& event) { } void updateViews(Context* ctx) { + // Covered by another window (e.g. a dialog): createWidgets() updates the views when it's shown again + if (ctx->toolbar == nullptr) { + return; + } lvgl_toolbar_clear_actions(ctx->toolbar); ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar); lv_obj_set_hidden(ctx->updateLabel, true); @@ -200,6 +204,14 @@ void createWidgets(lv_obj_t* parent, void* userData) { updateViews(ctx); } +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->toolbar = nullptr; + ctx->spinner = nullptr; + ctx->updateButton = nullptr; + ctx->updateLabel = nullptr; +} + int32_t appMain(int argc, char* argv[]) { uint32_t appInstanceId = app_scheduler_current_app_id(); // argv layout: [0]=appId @@ -218,7 +230,7 @@ int32_t appMain(int argc, char* argv[]) { check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); check(app_hub_event_subscribe(&ctx.appHubSub, &event_group) == ERROR_NONE); - WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); bool shouldClose = false; while (!shouldClose) { diff --git a/Tactility/Source/app/btmanage/BtManage.cpp b/Tactility/Source/app/btmanage/BtManage.cpp index d094ff99e..6eca2e9f8 100644 --- a/Tactility/Source/app/btmanage/BtManage.cpp +++ b/Tactility/Source/app/btmanage/BtManage.cpp @@ -151,6 +151,11 @@ void createWidgets(lv_obj_t* parent, void* userData) { ctx->unlock(); } +// Runs with the LVGL lock held and must not take other locks +void destroyWidgets(void* userData) { + static_cast(userData)->view.destroyWidgets(); +} + int32_t appMain(int argc, char* argv[]) { uint32_t appInstanceId = app_scheduler_current_app_id(); Context ctx; @@ -189,7 +194,7 @@ int32_t appMain(int argc, char* argv[]) { } } - WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); auto radio_state = bluetooth::getRadioState(); bool can_scan = radio_state == bluetooth::RadioState::On; diff --git a/Tactility/Source/app/btmanage/View.cpp b/Tactility/Source/app/btmanage/View.cpp index 431841901..06e9ed304 100644 --- a/Tactility/Source/app/btmanage/View.cpp +++ b/Tactility/Source/app/btmanage/View.cpp @@ -165,11 +165,15 @@ void View::updatePeerList() { // Scan results section auto scan_results = state->getScanResults(); auto* available_section = createSection(peers_list, "Available"); - if (!scan_results.empty()) { - for (size_t i = 0; i < scan_results.size(); ++i) { + // Devices without a name are skipped. The index stays that of the scan results, which the click handler uses. + bool hasNamedResults = false; + for (size_t i = 0; i < scan_results.size(); ++i) { + if (!scan_results[i].name.empty()) { createPeerListItem(available_section, scan_results[i], false, i); + hasNamedResults = true; } - } else if (!state->isScanning()) { + } + if (!hasNamedResults && !state->isScanning()) { lvgl_list_add_text(available_section, "No devices found."); } // Never hide peers_list: it always contains the "Enable on boot" row. @@ -212,7 +216,18 @@ void View::init(void* newContext, lv_obj_t* parent) { lv_obj_set_style_border_width(peers_list, 0, LV_STATE_DEFAULT); } +void View::destroyWidgets() { + root = nullptr; + enable_switch = nullptr; + scanning_spinner = nullptr; + peers_list = nullptr; +} + void View::update() { + // The widgets are gone while another window covers this one + if (root == nullptr) { + return; + } updateBtToggle(); updateScanning(); updatePeerList(); diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index 514d2bd6e..c38bba5cf 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -33,7 +33,6 @@ constexpr IconEntry ICONS[] = { {"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE}, {"tactility.meshinspect", LVGL_ICON_SHARED_LISTS}, {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, - {"tactility.screenshot", LVGL_ICON_SHARED_IMAGE}, {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, {"tactility.terminal", LVGL_ICON_SHARED_TERMINAL}, {"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD}, diff --git a/Tactility/Source/app/screenshot/Screenshot.cpp b/Tactility/Source/app/screenshot/Screenshot.cpp index 16d62b7f5..b809d0d60 100644 --- a/Tactility/Source/app/screenshot/Screenshot.cpp +++ b/Tactility/Source/app/screenshot/Screenshot.cpp @@ -1,312 +1,86 @@ -#include #include #if TT_FEATURE_SCREENSHOT_ENABLED #include -#include -#include -#include -#include #include -#include -#include - -#include #include -#include +#include #include -#include #include -#include -#include -#include -#include + +#include + +#include +#include namespace tt::app::screenshot { -constexpr auto* TAG = "Screenshot"; - -extern const ::AppManifest manifest; - namespace { -enum class CaptureMode { - Timer, - AppStart -}; +constexpr TickType_t LVGL_LOCK_TIMEOUT = pdMS_TO_TICKS(1000); -struct ModeOption { - CaptureMode mode; - const char* name; -}; - -constexpr ModeOption MODE_OPTIONS[] = { - { CaptureMode::Timer, "Timer" }, - { CaptureMode::AppStart, "App start" }, -}; - -struct Context { - uint32_t appInstanceId; - CaptureMode mode = CaptureMode::Timer; - lv_obj_t* modeChips = nullptr; - lv_obj_t* pathTextArea = nullptr; - lv_obj_t* startStopButtonLabel = nullptr; - // Shown in timer mode - lv_obj_t* delayRow = nullptr; - lv_obj_t* delayTextArea = nullptr; - std::unique_ptr updateTimer; -}; - - -void updateScreenshotMode(Context* ctx) { - auto service = service::screenshot::optScreenshotService(); - if (service == nullptr) { - LOG_E(TAG, "Service not found/running"); - return; +/** The default path is in the temp folder, so "tt screenshot" can pull and remove it */ +bool getDefaultPath(char* out_path, size_t out_size) { + char temp_path[FILE_MAX_PATH_STRING_LENGTH]; + if (paths_get_temp_path(temp_path, sizeof(temp_path)) != ERROR_NONE) { + return false; } - - lv_obj_t* label = ctx->startStopButtonLabel; - if (service->isTaskStarted()) { - lv_label_set_text(label, "Stop"); - } else { - lv_label_set_text(label, "Start"); - } - - const uint32_t chip_count = lv_obj_get_child_count(ctx->modeChips); - for (uint32_t i = 0; i < chip_count; i++) { - lv_obj_set_state(lv_obj_get_child(ctx->modeChips, static_cast(i)), LV_STATE_CHECKED, MODE_OPTIONS[i].mode == ctx->mode); - } - lv_obj_set_hidden(ctx->delayRow, ctx->mode != CaptureMode::Timer); -} - -void onBackPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - app_event_emit_close(ctx->appInstanceId); -} - -void onStartPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - - auto service = service::screenshot::optScreenshotService(); - if (service == nullptr) { - LOG_E(TAG, "Service not found/running"); - return; - } - - if (service->isTaskStarted()) { - LOG_I(TAG, "Stop screenshot"); - service->stop(); - updateScreenshotMode(ctx); - return; - } - - const char* path = lv_textarea_get_text(ctx->pathTextArea); - - error_t result = directory_make(path, true); - if (result != ERROR_NONE && result != ERROR_ALREADY_EXISTS) { - LOG_E(TAG, "Task failed to start: couldn't create directory at %s", path); - return; - } - - if (ctx->mode == CaptureMode::Timer) { - LOG_I(TAG, "Start timed screenshots"); - const char* delay_text = lv_textarea_get_text(ctx->delayTextArea); - int delay = atoi(delay_text); - if (delay > 0) { - service->startTimed(path, delay, 1); - } else { - LOG_W(TAG, "Ignored screenshot start because delay was 0"); - } - } else { - LOG_I(TAG, "Start app screenshots"); - service->startApps(path); - } - - updateScreenshotMode(ctx); -} - -void onModeChipPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - ctx->mode = MODE_OPTIONS[lv_obj_get_index(lv_event_get_target_obj(event))].mode; - updateScreenshotMode(ctx); -} - -/** A transparent row in a card: "Title [content]" */ -lv_obj_t* createRow(lv_obj_t* card, const char* title) { - auto* row = lv_obj_create(card); - lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_set_scrollable(row, false); - lv_label_set_text(lv_label_create(row), title); - return row; -} - -void createModeWidgets(Context* ctx, lv_obj_t* parent) { - auto* title = lv_label_create(parent); - lv_label_set_text(title, "Mode"); - lv_obj_set_width(title, LV_PCT(100)); - - // The chips are centered in the card while they fit, and scroll when they don't - auto* card = lvgl_card_create(parent); - lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - ctx->modeChips = lv_obj_create(card); - lv_obj_set_size(ctx->modeChips, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_max_width(ctx->modeChips, LV_PCT(100), LV_STATE_DEFAULT); - lv_obj_set_flex_flow(ctx->modeChips, LV_FLEX_FLOW_ROW); - lv_obj_set_scroll_dir(ctx->modeChips, LV_DIR_HOR); - lv_obj_set_style_bg_opa(ctx->modeChips, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ctx->modeChips, 0, LV_STATE_DEFAULT); - // The chips' margins leave room for their focus rings and space them apart - lv_obj_set_style_pad_all(ctx->modeChips, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_column(ctx->modeChips, 0, LV_STATE_DEFAULT); - for (const auto& option : MODE_OPTIONS) { - auto* chip = lvgl_chip_create(ctx->modeChips); - lv_label_set_text(lv_label_create(chip), option.name); - lv_obj_add_event_cb(chip, onModeChipPressed, LV_EVENT_SHORT_CLICKED, ctx); - } - lvgl_grid_navigation_add(ctx->modeChips); -} - -void createSettingsWidgets(Context* ctx, lv_obj_t* parent) { - auto* card = lvgl_card_create(parent); - lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); - - auto* path_row = createRow(card, "Path"); - ctx->pathTextArea = lv_textarea_create(path_row); - lv_textarea_set_one_line(ctx->pathTextArea, true); - lv_obj_set_flex_grow(ctx->pathTextArea, 1); - char data_path[FILE_MAX_PATH_STRING_LENGTH]; - if (paths_get_data_path(data_path, sizeof(data_path)) == ERROR_NONE) { - std::string lvgl_mount_path = std::string(data_path) + "/screenshots"; - lv_textarea_set_text(ctx->pathTextArea, lvgl_mount_path.c_str()); - } else { - lv_textarea_set_text(ctx->pathTextArea, "Error: no data path"); - } - - ctx->delayRow = createRow(card, "Delay"); - ctx->delayTextArea = lv_textarea_create(ctx->delayRow); - lv_textarea_set_one_line(ctx->delayTextArea, true); - lv_textarea_set_accepted_chars(ctx->delayTextArea, "0123456789"); - lv_textarea_set_text(ctx->delayTextArea, "10"); - lv_obj_set_flex_grow(ctx->delayTextArea, 1); - lv_label_set_text(lv_label_create(ctx->delayRow), "seconds"); -} - -void createStartStopButton(Context* ctx, lv_obj_t* parent) { - auto* button = lv_button_create(parent); - lv_obj_add_event_cb(button, onStartPressed, LV_EVENT_SHORT_CLICKED, ctx); - ctx->startStopButtonLabel = lv_label_create(button); -} - -void createWidgets(lv_obj_t* parent, void* userData) { - auto* ctx = static_cast(userData); - - if (ctx->updateTimer->isRunning()) { - ctx->updateTimer->stop(); - } - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - auto* toolbar = lvgl_toolbar_create(parent, "Screenshot"); - // The global toolbar nav callback only knows how to stop old-model apps. - lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - - auto* wrapper = lv_obj_create(parent); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_style_border_width(wrapper, 0, 0); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - // The cards stretch, the start/stop button is centered - lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); - - auto service = service::screenshot::optScreenshotService(); - if (service != nullptr && service->getMode() == service::screenshot::Mode::Apps) { - ctx->mode = CaptureMode::AppStart; - } - - createModeWidgets(ctx, wrapper); - createSettingsWidgets(ctx, wrapper); - createStartStopButton(ctx, wrapper); - - updateScreenshotMode(ctx); - - if (!ctx->updateTimer->isRunning()) { - ctx->updateTimer->start(); - } -} - -int32_t appMain(int argc, char* argv[]) { - uint32_t appInstanceId = app_scheduler_current_app_id(); - Context ctx {}; - ctx.appInstanceId = appInstanceId; - ctx.updateTimer = std::make_unique(Timer::Type::Periodic, 500 / portTICK_PERIOD_MS, [&ctx] { - if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) { - updateScreenshotMode(&ctx); - lvgl_unlock(); - } - }); - - TaskEventGroup event_group {}; - task_event_group_construct(&event_group); - - AppEventSubscription sub {}; - check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); - - WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - - bool shouldClose = false; - while (!shouldClose) { - task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); - - AppEvent event {}; - while (app_event_poll(&sub, &event) == ERROR_NONE) { - switch (event.type) { - case APP_EVENT_CLOSE: - shouldClose = true; - break; - default: - break; - } - if (shouldClose) break; + if (!directory_exists(temp_path)) { + const error_t error = directory_make(temp_path, true); + if (error != ERROR_NONE && error != ERROR_ALREADY_EXISTS) { + return false; } } + const int written = snprintf(out_path, out_size, "%s/screenshot.png", temp_path); + return written > 0 && static_cast(written) < out_size; +} - if (ctx.updateTimer->isRunning()) { - ctx.updateTimer->stop(); +int32_t main(int argc, char* argv[]) { + if (argc > 2 || (argc == 2 && argv[1][0] != '/')) { + printf("usage: screenshot [absolute path of the .png file]\n"); + return 1; } - window_manager_remove(window); - check(app_event_unsubscribe(&sub) == ERROR_NONE); - task_event_group_destruct(&event_group); + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (argc == 2) { + snprintf(path, sizeof(path), "%s", argv[1]); + } else if (!getDefaultPath(path, sizeof(path))) { + printf("screenshot: no storage available\n"); + return 1; + } + if (!lvgl_try_lock(LVGL_LOCK_TIMEOUT)) { + printf("screenshot: the display is busy\n"); + return 1; + } + // lv_screenshot writes through lv_fs, which requires the drive prefix + const std::string lvgl_path = std::string(lvgl::PATH_PREFIX) + path; + const bool success = lv_screenshot_create(lv_screen_active(), LV_100ASK_SCREENSHOT_SV_PNG, lvgl_path.c_str()); + lvgl_unlock(); + + if (!success) { + printf("screenshot: failed to save %s\n", path); + return 1; + } + printf("%s\n", path); return 0; } } // namespace extern const ::AppManifest manifest = { - .id = "tactility.screenshot", - .name = "Screenshot", + .id = "screenshot", + .name = "screenshot", .category = APP_CATEGORY_SYSTEM, - .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, - .flags = 0, - .stack = {} + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, + .flags = APP_MANIFEST_FLAG_HEADLESS, + .stack = { .depth = 8192, .desired_memory_capability = 0 }, }; -} // namespace +} // namespace tt::app::screenshot #endif diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index 158c18fbd..567d7b03b 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -3,11 +3,10 @@ #include #include -#include -#include +#include #include #include -#include +#include #include #include @@ -27,11 +26,17 @@ #include #include +#include #include +#include #include +#include +#include +#include #include #include +#include #include #ifdef ESP_PLATFORM @@ -92,8 +97,19 @@ struct StepConfiguration { std::function run; /** Optional: handles the result of the dialog that run() started */ std::function onResult = nullptr; + /** What the page shows below its title */ + enum class Content { + Description, + /** The description, with the location choices below it */ + LocationChoice, + /** The time zone list instead of the description */ + TimeZones + } content = Content::Description; }; +/** The location choices of the location page, in the order of their chips */ +constexpr const char* LOCATION_CHOICES[] = { "Precise", "Approximate", "Disabled" }; + struct Context { uint32_t appInstanceId; @@ -102,14 +118,134 @@ struct Context { std::vector steps; uint32_t pendingStepDialogId = 0; + // Valid while the window is shown lv_obj_t* titleLabel = nullptr; lv_obj_t* descriptionLabel = nullptr; + lv_obj_t* backButton = nullptr; lv_obj_t* skipButton = nullptr; lv_obj_t* continueButton = nullptr; + lv_obj_t* stepIndicator = nullptr; + lv_obj_t* bottom = nullptr; + lv_obj_t* timeZoneContainer = nullptr; + lv_obj_t* locationChips = nullptr; + // The index in LOCATION_CHOICES picked on the location page, -1 when none. Guarded by the LVGL lock. + int32_t locationChoice = -1; + + std::unique_ptr timeZoneView; + // The time zone picked on the time zone page, guarded by the LVGL lock + std::string timeZoneName; + std::string timeZoneCode; }; +/** The welcome page, a page per step, and the completion page */ +uint32_t getPageCount(const Context* ctx) { + return static_cast(ctx->steps.size()) + 2; +} + +uint32_t getPage(const Context* ctx) { + switch (ctx->phase) { + case Phase::Welcome: + return 0; + case Phase::StepIntro: + return static_cast(ctx->stepIndex) + 1; + case Phase::Done: + return getPageCount(ctx) - 1; + } + return 0; +} + + +/** The welcome page counts as the first step, followed by the configuration steps */ +uint32_t getStepCount(const Context* ctx) { + return static_cast(ctx->steps.size()) + 1; +} + +/** The steps before the current one, or all of them once setup is done */ +uint32_t getFinishedStepCount(const Context* ctx) { + switch (ctx->phase) { + case Phase::Welcome: + return 0; + case Phase::StepIntro: + return static_cast(ctx->stepIndex) + 1; + case Phase::Done: + return getStepCount(ctx); + } + return 0; +} + +StepConfiguration::Content getContent(const Context* ctx) { + return ctx->phase == Phase::StepIntro ? ctx->steps[ctx->stepIndex].content : StepConfiguration::Content::Description; +} + +/** Shows which location choice is picked */ +void renderLocationChips(Context* ctx) { + const uint32_t count = lv_obj_get_child_count(ctx->locationChips); + for (uint32_t i = 0; i < count; i++) { + lv_obj_set_state(lv_obj_get_child(ctx->locationChips, static_cast(i)), LV_STATE_CHECKED, static_cast(i) == ctx->locationChoice); + } +} + +/** The time zone page shows the title at the top and the list below it, the other pages center their text */ +void renderLayout(Context* ctx) { + const auto content = getContent(ctx); + const bool showsTimeZones = content == StepConfiguration::Content::TimeZones; + const bool showsLocationChoice = content == StepConfiguration::Content::LocationChoice; + const bool hasTimeZoneWidgets = lv_obj_get_child_count(ctx->timeZoneContainer) > 0; + lv_obj_set_hidden(ctx->descriptionLabel, showsTimeZones); + lv_obj_set_hidden(ctx->timeZoneContainer, !showsTimeZones); + lv_obj_set_hidden(ctx->locationChips, !showsLocationChoice); + if (!showsTimeZones) { + if (hasTimeZoneWidgets) { + lv_obj_clean(ctx->timeZoneContainer); + ctx->timeZoneView->destroyWidgets(); + } + const int titleMargin = lvgl_get_text_font_height(FONT_SIZE_LARGE); + if (showsLocationChoice) { + // Centered as a group in the space above the buttons + const int32_t gap = titleMargin / 2; + lv_obj_update_layout(lv_obj_get_parent(ctx->titleLabel)); + const int32_t height = lv_obj_get_height(ctx->titleLabel) + gap + lv_obj_get_height(ctx->descriptionLabel) + gap + lv_obj_get_height(ctx->locationChips); + lv_area_t parent; + lv_area_t bottom; + lv_obj_get_content_coords(lv_obj_get_parent(ctx->titleLabel), &parent); + lv_obj_get_coords(ctx->bottom, &bottom); + const int32_t top = LV_MAX((bottom.y1 - parent.y1 - height) / 2, 0); + lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, top); + lv_obj_align_to(ctx->descriptionLabel, ctx->titleLabel, LV_ALIGN_OUT_BOTTOM_MID, 0, gap); + lv_obj_align_to(ctx->locationChips, ctx->descriptionLabel, LV_ALIGN_OUT_BOTTOM_MID, 0, gap); + // Continue confirms the picked choice + renderLocationChips(ctx); + lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->locationChoice < 0); + } else { + lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0); + lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -titleMargin); + lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); + } + return; + } + + lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, LV_DPX(8)); + if (!hasTimeZoneWidgets) { + ctx->timeZoneView->createWidgets(ctx->timeZoneContainer); + } + // Continue confirms the picked time zone + lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->timeZoneName.empty()); + + // Fills the space between the title and the buttons + lv_obj_update_layout(lv_obj_get_parent(ctx->timeZoneContainer)); + lv_area_t title; + lv_area_t bottom; + lv_obj_get_coords(ctx->titleLabel, &title); + lv_obj_get_coords(ctx->bottom, &bottom); + lv_area_t parent; + lv_obj_get_content_coords(lv_obj_get_parent(ctx->timeZoneContainer), &parent); + lv_obj_set_pos(ctx->timeZoneContainer, 0, title.y2 + LV_DPX(4) - parent.y1); + lv_obj_set_size(ctx->timeZoneContainer, LV_PCT(100), bottom.y1 - title.y2 - LV_DPX(8)); +} void renderCurrent(Context* ctx) { + lvgl_step_indicator_set_finished_count(ctx->stepIndicator, getFinishedStepCount(ctx)); + lv_obj_set_hidden(ctx->backButton, ctx->phase == Phase::Welcome); switch (ctx->phase) { case Phase::Welcome: { lv_label_set_text(ctx->titleLabel, "Welcome"); @@ -135,6 +271,7 @@ void renderCurrent(Context* ctx) { lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Finish"); break; } + renderLayout(ctx); } void advanceTo(Context* ctx, size_t index) { @@ -146,10 +283,29 @@ void advanceTo(Context* ctx, size_t index) { } lvgl_lock(); - renderCurrent(ctx); + // Covered by a step's app: createWidgets() renders the current page when the window is shown again + if (ctx->titleLabel != nullptr) { + renderCurrent(ctx); + } lvgl_unlock(); } +void onBackClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // The previous page: a step's intro page, or the welcome page before the first step + const size_t previousPage = getPage(ctx) - 1; + if (ctx->phase == Phase::Welcome) { + return; + } else if (previousPage == 0) { + ctx->phase = Phase::Welcome; + lvgl_lock(); + renderCurrent(ctx); + lvgl_unlock(); + } else { + advanceTo(ctx, previousPage - 1); + } +} + void onSkipClicked(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); if (ctx->phase == Phase::StepIntro) { @@ -174,6 +330,13 @@ void onContinueClicked(lv_event_t* event) { } } +void onLocationChipPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->locationChoice = static_cast(reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event)))); + renderLocationChips(ctx); + lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); +} + void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); @@ -190,26 +353,90 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_WRAP); lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0); - int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE); - lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin); + // The location choices: one can be picked, and they wrap to more lines on narrow screens + ctx->locationChips = lv_obj_create(parent); + lv_obj_remove_style_all(ctx->locationChips); + lv_obj_set_size(ctx->locationChips, LV_PCT(100), LV_SIZE_CONTENT); + // The chip margins space the choices, so they fit on one line on small screens + lv_obj_set_flex_flow(ctx->locationChips, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(ctx->locationChips, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + for (size_t i = 0; i < std::size(LOCATION_CHOICES); i++) { + auto* chip = lvgl_chip_create(ctx->locationChips); + lv_label_set_text(lv_label_create(chip), LOCATION_CHOICES[i]); + lv_obj_set_user_data(chip, reinterpret_cast(static_cast(i))); + lv_obj_add_event_cb(chip, onLocationChipPressed, LV_EVENT_SHORT_CLICKED, ctx); + } + lvgl_grid_navigation_add(ctx->locationChips); + lv_obj_set_hidden(ctx->locationChips, true); - ctx->skipButton = lv_button_create(parent); - lv_obj_t* skip_label = lv_label_create(ctx->skipButton); - lv_label_set_text(skip_label, "Skip"); - lv_obj_center(skip_label); - lv_obj_align(ctx->skipButton, LV_ALIGN_BOTTOM_LEFT, 12, -12); + // Filled with the time zone list on the time zone page + ctx->timeZoneContainer = lv_obj_create(parent); + lv_obj_remove_style_all(ctx->timeZoneContainer); + lv_obj_set_flex_flow(ctx->timeZoneContainer, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_hor(ctx->timeZoneContainer, LV_DPX(8), LV_STATE_DEFAULT); + lv_obj_set_hidden(ctx->timeZoneContainer, true); + + // The buttons, with the progress through the pages below them at the bottom + auto* bottom = lv_obj_create(parent); + ctx->bottom = bottom; + lv_obj_remove_style_all(bottom); + lv_obj_set_size(bottom, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(bottom, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(bottom, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_align(bottom, LV_ALIGN_BOTTOM_MID, 0, 0); + + // Back on the left, and Skip next to Continue on the right + auto* buttonRow = lv_obj_create(bottom); + lv_obj_remove_style_all(buttonRow); + lv_obj_set_size(buttonRow, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_hor(buttonRow, LV_DPX(8), LV_STATE_DEFAULT); + lv_obj_set_flex_flow(buttonRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(buttonRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + ctx->backButton = lvgl_text_button_create(buttonRow); + lv_label_set_text(lv_label_create(ctx->backButton), "Back"); + lv_obj_add_event_cb(ctx->backButton, onBackClicked, LV_EVENT_SHORT_CLICKED, ctx); + + auto* spacer = lv_obj_create(buttonRow); + lv_obj_remove_style_all(spacer); + lv_obj_set_height(spacer, 1); + lv_obj_set_flex_grow(spacer, 1); + + ctx->skipButton = lvgl_text_button_create(buttonRow); + lv_label_set_text(lv_label_create(ctx->skipButton), "Skip"); lv_obj_add_event_cb(ctx->skipButton, onSkipClicked, LV_EVENT_SHORT_CLICKED, ctx); - ctx->continueButton = lv_button_create(parent); - lv_obj_t* continue_label = lv_label_create(ctx->continueButton); - lv_label_set_text(continue_label, "Continue"); - lv_obj_center(continue_label); - lv_obj_align(ctx->continueButton, LV_ALIGN_BOTTOM_RIGHT, -12, -12); + ctx->continueButton = lv_button_create(buttonRow); + lv_label_set_text(lv_label_create(ctx->continueButton), "Continue"); lv_obj_add_event_cb(ctx->continueButton, onContinueClicked, LV_EVENT_SHORT_CLICKED, ctx); + ctx->stepIndicator = lvgl_step_indicator_create(bottom); + lvgl_step_indicator_set_step_count(ctx->stepIndicator, getStepCount(ctx)); + + // The arrow keys move between the buttons, and Continue has the focus first + lvgl_grid_navigation_add(buttonRow); + if (!lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { + lv_group_focus_obj(buttonRow); + lv_gridnav_set_focused(buttonRow, ctx->continueButton, LV_ANIM_OFF); + } + renderCurrent(ctx); } +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->titleLabel = nullptr; + ctx->descriptionLabel = nullptr; + ctx->backButton = nullptr; + ctx->skipButton = nullptr; + ctx->continueButton = nullptr; + ctx->stepIndicator = nullptr; + ctx->bottom = nullptr; + ctx->timeZoneContainer = nullptr; + ctx->locationChips = nullptr; + ctx->timeZoneView->destroyWidgets(); +} + int32_t appMain(int argc, char* argv[]) { uint32_t appInstanceId = app_scheduler_current_app_id(); Context ctx {}; @@ -222,11 +449,6 @@ int32_t appMain(int argc, char* argv[]) { .run = [&ctx] { ctx.pendingStepDialogId = touchcalibration::start(ctx.appInstanceId); } }, #endif - { - .title = "Time Zone Setup", - .description = "Let's set the time zone.", - .run = [&ctx] { ctx.pendingStepDialogId = timezone::start(ctx.appInstanceId, true); } - }, { .title = "Wi-Fi Setup", .description = "Let's connect to a Wi-Fi access point.", @@ -245,40 +467,37 @@ int32_t appMain(int argc, char* argv[]) { } }; - // The radio may only transmit on the frequencies and power of the user's region - if (settings::radio::hasRadio()) { - ctx.steps.push_back({ - .title = "Radio Region", - .description = "Let's select your region, so the radio uses the frequencies that are allowed there.", - .run = [&ctx] { - std::vector items; - for (int region = RADIO_REGION_UNSET + 1; region < RADIO_REGION_COUNT; region++) { - items.emplace_back(settings::radio::getRegionLabel(static_cast(region))); - } - ctx.pendingStepDialogId = selectiondialog::start(ctx.appInstanceId, "Region", items); - }, - .onResult = [](int32_t selected) { - if (selected >= 0 && selected < RADIO_REGION_COUNT - 1) { - settings::radio::saveRegion(static_cast(selected + 1)); - } - } - }); - } - ctx.steps.push_back({ .title = "Location Services", - .description = "Location services let apps use your location. It comes from GPS, or from your IP address via ip-api.com when there is no GPS fix.", + .description = "Do you want to allow apps to retrieve your location? It uses GPS and/or contacts geoiplookup.io", .run = [&ctx] { - const std::vector items = { "Allow precise location", "Allow approximate location", "Don't allow" }; - ctx.pendingStepDialogId = selectiondialog::start(ctx.appInstanceId, "Location", items); - }, - .onResult = [](int32_t selected) { - if (selected == 0 || selected == 1) { - location_set_precision(selected == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE); + // The order of LOCATION_CHOICES: precise, approximate, disabled + if (ctx.locationChoice == 0 || ctx.locationChoice == 1) { + location_set_precision(ctx.locationChoice == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE); location_set_enabled(true); - } else if (selected == 2) { + } else { location_set_enabled(false); } + advanceTo(&ctx, ctx.stepIndex + 1); + }, + .content = StepConfiguration::Content::LocationChoice + }); + + // Setting the time zone also sets the radio region, as both depend on where the device is + ctx.steps.push_back({ + .title = "Time Zone", + .description = "", + .run = [&ctx] { + settings::setTimeZone(ctx.timeZoneName, ctx.timeZoneCode); + advanceTo(&ctx, ctx.stepIndex + 1); + }, + .content = StepConfiguration::Content::TimeZones + }); + ctx.timeZoneView = std::make_unique([&ctx](const std::string& name, const std::string& code) { + ctx.timeZoneName = name; + ctx.timeZoneCode = code; + if (ctx.continueButton != nullptr) { + lv_obj_remove_state(ctx.continueButton, LV_STATE_DISABLED); } }); @@ -288,7 +507,7 @@ int32_t appMain(int argc, char* argv[]) { AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); - WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); bool shouldClose = false; while (!shouldClose) { diff --git a/Tactility/Source/app/timezone/TimeZone.cpp b/Tactility/Source/app/timezone/TimeZone.cpp index f6f9a5b39..5df442d67 100644 --- a/Tactility/Source/app/timezone/TimeZone.cpp +++ b/Tactility/Source/app/timezone/TimeZone.cpp @@ -1,9 +1,5 @@ #include -#include -#include -#include -#include -#include +#include #include #include @@ -15,176 +11,34 @@ #include #include -#include -#include -#include -#include -#include #include #include namespace tt::app::timezone { -constexpr auto* TAG = "TimeZone"; - extern const ::AppManifest manifest; namespace { -struct TimeZoneEntry { - std::string name; - std::string code; -}; - struct Context { uint32_t appInstanceId; - Mutex mutex; - std::vector entries; - std::unique_ptr updateTimer; - lv_obj_t* listWidget = nullptr; - lv_obj_t* filterTextareaWidget = nullptr; bool saveTimeZone = false; + std::unique_ptr view; // The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this // is a plain (non-atomic) field safely shared between the LVGL thread (writer, before // emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it). int32_t result = 1; // Cancelled - safety-net default if closed without picking a time zone }; - // The last picked name/code. Static rather than per-instance: simple, and in practice only one -// TimeZone dialog is ever open at a time. Written on the LVGL thread (the item-selected -// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastName()/getLastCode() -// after receiving that event - safe without a lock for the same reason Context::result is (see -// AlertDialog.cpp). +// TimeZone dialog is ever open at a time. Written before emitting APP_EVENT_CLOSE; read by the +// parent via getLastName()/getLastCode() after receiving that event - safe without a lock for +// the same reason Context::result is (see AlertDialog.cpp). std::string lastName; std::string lastCode; -bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) { - std::string partial_strip = input.substr(1, input.size() - 3); - auto first_end_quote = partial_strip.find('"'); - if (first_end_quote == std::string::npos) { - return false; - } else { - outName = partial_strip.substr(0, first_end_quote); - outCode = partial_strip.substr(first_end_quote + 3); - return true; - } -} - -void onTextareaValueChanged(lv_event_t* e) { - auto* ctx = static_cast(lv_event_get_user_data(e)); - if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { - if (ctx->updateTimer->isRunning()) { - ctx->updateTimer->stop(); - } - ctx->updateTimer->start(); - ctx->mutex.unlock(); - } -} - -void createListItem(Context* ctx, lv_obj_t* list, const std::string& title, size_t index) { - auto* btn = lvgl_list_add_button(list, nullptr, title.c_str()); - struct ButtonContext { - Context* ctx; - size_t index; - }; - auto* buttonCtx = new ButtonContext { ctx, index }; - lv_obj_add_event_cb(btn, [](lv_event_t* e) { - auto* buttonCtx = static_cast(lv_event_get_user_data(e)); - delete buttonCtx; - }, LV_EVENT_DELETE, buttonCtx); - lv_obj_add_event_cb(btn, [](lv_event_t* e) { - auto* buttonCtx = static_cast(lv_event_get_user_data(e)); - auto* ctx = buttonCtx->ctx; - auto index = buttonCtx->index; - LOG_I(TAG, "Selected item at index %d", (int)index); - - auto& entry = ctx->entries[index]; - - if (ctx->saveTimeZone) { - settings::setTimeZone(entry.name, entry.code); - } - - lastName = entry.name; - lastCode = entry.code; - - ctx->result = 0; // Ok - app_event_emit_close(ctx->appInstanceId); - }, LV_EVENT_SHORT_CLICKED, buttonCtx); -} - -void readTimeZones(Context* ctx, std::string filter) { - auto path = std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv"; - auto* file = fopen(path.c_str(), "rb"); - if (file == nullptr) { - LOG_E(TAG, "Failed to open %s", path.c_str()); - return; - } - char line[96]; - std::string name; - std::string code; - uint32_t count = 0; - std::vector new_entries; - while (fgets(line, 96, file)) { - if (parseEntry(line, name, code)) { - if (string::lowercase(name).find(filter) != std::string::npos) { - count++; - new_entries.push_back({.name = name, .code = code}); - - // Safety guard - if (count > 50) { - // TODO: Show warning that we're not displaying a complete list - break; - } - } - } else { - LOG_E(TAG, "Parse error at line %llu", count); - } - } - - fclose(file); - - if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { - ctx->entries = std::move(new_entries); - ctx->mutex.unlock(); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - } - - LOG_I(TAG, "Processed %llu entries", count); -} - -void updateList(Context* ctx) { - if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) { - std::string filter = string::lowercase(std::string(lv_textarea_get_text(ctx->filterTextareaWidget))); - lvgl_unlock(); - readTimeZones(ctx, filter); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL"); - return; - } - - if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) { - if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { - lv_obj_clean(ctx->listWidget); - - uint32_t index = 0; - for (auto& entry : ctx->entries) { - createListItem(ctx, ctx->listWidget, entry.name, index); - index++; - } - - ctx->mutex.unlock(); - } - - lvgl_unlock(); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL"); - } -} - void onBackPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); app_event_emit_close(ctx->appInstanceId); @@ -199,32 +53,20 @@ void createWidgets(lv_obj_t* parent, void* userData) { auto* toolbar = lvgl_toolbar_create(parent, "Select Time zone"); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - auto* search_wrapper = lv_obj_create(parent); - lv_obj_set_size(search_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(search_wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(search_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); - lv_obj_set_style_pad_all(search_wrapper, 0, 0); - lv_obj_set_style_border_width(search_wrapper, 0, 0); + // Margins around the view's card + auto* wrapper = lv_obj_create(parent); + lv_obj_remove_style_all(wrapper); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(wrapper, LV_DPX(8), LV_STATE_DEFAULT); - auto* icon = lv_image_create(search_wrapper); - lv_obj_set_style_margin_left(icon, 8, 0); - lv_obj_set_style_image_recolor_opa(icon, 255, 0); - lv_obj_set_style_image_recolor(icon, lv_theme_get_color_primary(parent), 0); - lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); - lv_image_set_src(icon, LVGL_ICON_SHARED_SEARCH); + ctx->view->createWidgets(wrapper); +} - auto* textarea = lv_textarea_create(search_wrapper); - lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam"); - lv_textarea_set_one_line(textarea, true); - lv_obj_add_event_cb(textarea, onTextareaValueChanged, LV_EVENT_VALUE_CHANGED, ctx); - ctx->filterTextareaWidget = textarea; - lv_obj_set_flex_grow(textarea, 1); - - auto* list = lvgl_list_create(parent); - lv_obj_set_width(list, LV_PCT(100)); - lv_obj_set_flex_grow(list, 1); - lv_obj_set_style_border_width(list, 0, 0); - ctx->listWidget = list; +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->view->destroyWidgets(); } int32_t appMain(int argc, char* argv[]) { @@ -234,9 +76,15 @@ int32_t appMain(int argc, char* argv[]) { Context ctx; ctx.appInstanceId = appInstanceId; ctx.saveTimeZone = argc > 0 && argv[0][0] == '1'; - - ctx.updateTimer = std::make_unique(Timer::Type::Once, 500 / portTICK_PERIOD_MS, [&ctx] { - updateList(&ctx); + // Picking a time zone, or detecting one, closes the app with it + ctx.view = std::make_unique([&ctx](const std::string& name, const std::string& code) { + if (ctx.saveTimeZone) { + settings::setTimeZone(name, code); + } + lastName = name; + lastCode = code; + ctx.result = 0; // Ok + app_event_emit_close(ctx.appInstanceId); }); TaskEventGroup event_group {}; @@ -245,8 +93,7 @@ int32_t appMain(int argc, char* argv[]) { AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); - WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - ctx.updateTimer->start(); + WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); while (true) { task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); @@ -262,8 +109,8 @@ int32_t appMain(int argc, char* argv[]) { if (shouldClose) break; } - ctx.updateTimer->stop(); window_manager_remove(window); + ctx.view.reset(); check(app_event_unsubscribe(&sub) == ERROR_NONE); task_event_group_destruct(&event_group); diff --git a/Tactility/Source/app/timezone/TimeZoneView.cpp b/Tactility/Source/app/timezone/TimeZoneView.cpp new file mode 100644 index 000000000..f38acc61f --- /dev/null +++ b/Tactility/Source/app/timezone/TimeZoneView.cpp @@ -0,0 +1,304 @@ +#include + +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace tt::app::timezone { + +constexpr auto* TAG = "TimeZoneView"; + +namespace { + +// Bounds the list, so a short filter doesn't create hundreds of widgets +constexpr size_t MAX_ENTRIES = 50; +constexpr TickType_t FILTER_DELAY = pdMS_TO_TICKS(500); + +std::string getTimeZonesPath() { + return std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv"; +} + +/** Parses a line like "Europe/Amsterdam","CET-1CEST,M3.5.0,M10.5.0/3" */ +bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) { + if (input.size() < 3) { + return false; + } + std::string partial_strip = input.substr(1, input.size() - 3); + auto first_end_quote = partial_strip.find('"'); + if (first_end_quote == std::string::npos) { + return false; + } + outName = partial_strip.substr(0, first_end_quote); + outCode = partial_strip.substr(first_end_quote + 3); + return true; +} + +/** Calls @a visitor for each time zone until it returns false */ +void forEachTimeZone(const std::function& visitor) { + auto* file = fopen(getTimeZonesPath().c_str(), "rb"); + if (file == nullptr) { + LOG_E(TAG, "Failed to open %s", getTimeZonesPath().c_str()); + return; + } + char line[96]; + std::string name; + std::string code; + while (fgets(line, sizeof(line), file)) { + if (parseEntry(line, name, code) && !visitor(name, code)) { + break; + } + } + fclose(file); +} + +} // namespace + +TimeZoneView::TimeZoneView(OnSelected onSelected) : onSelected(std::move(onSelected)) { + filterTimer = std::make_unique(Timer::Type::Once, FILTER_DELAY, [this] { + loadEntries(); + lvgl_lock(); + rebuildList(); + lvgl_unlock(); + }); + loadEntries(); +} + +TimeZoneView::~TimeZoneView() { + filterTimer->stop(); + cancelled = true; + if (detectThread != nullptr) { + detectThread->join(); + } +} + +void TimeZoneView::createWidgets(lv_obj_t* parent) { + // The search row, the message and the list share a card. Only the list scrolls, so the card keeps its size. + auto* card = lvgl_card_create(parent); + lv_obj_set_width(card, LV_PCT(100)); + lv_obj_set_flex_grow(card, 1); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + + auto* searchRow = lv_obj_create(card); + lv_obj_remove_style_all(searchRow); + lv_obj_set_size(searchRow, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(searchRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(searchRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(searchRow, LV_DPX(4), LV_STATE_DEFAULT); + + auto* searchIcon = lv_image_create(searchRow); + lv_obj_set_style_image_recolor_opa(searchIcon, LV_OPA_COVER, LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor(searchIcon, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT); + lv_obj_set_style_text_font(searchIcon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); + lv_image_set_src(searchIcon, LVGL_ICON_SHARED_SEARCH); + + textarea = lv_textarea_create(searchRow); + lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam"); + lv_textarea_set_one_line(textarea, true); + lv_obj_set_flex_grow(textarea, 1); + + // Detects the time zone from the location, with a spinner in its place while it does + detectButton = lvgl_icon_button_create(searchRow); + auto* detectLabel = lv_label_create(detectButton); + lv_obj_set_style_text_font(detectLabel, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); + lv_label_set_text(detectLabel, LVGL_ICON_SHARED_LOCATION_ON); + lv_obj_add_event_cb(detectButton, onDetectPressed, LV_EVENT_SHORT_CLICKED, this); + detectSpinner = lvgl_spinner_create(searchRow); + + messageLabel = lv_label_create(card); + lv_obj_set_width(messageLabel, LV_PCT(100)); + lv_label_set_long_mode(messageLabel, LV_LABEL_LONG_WRAP); + + list = lvgl_list_create(card); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_flex_grow(list, 1); + lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT); + + mutex.lock(); + lv_textarea_set_text(textarea, filter.c_str()); + mutex.unlock(); + // Added after restoring the text, so restoring it doesn't filter the list again + lv_obj_add_event_cb(textarea, onFilterChanged, LV_EVENT_VALUE_CHANGED, this); + + showState(); + rebuildList(); +} + +void TimeZoneView::destroyWidgets() { + textarea = nullptr; + detectButton = nullptr; + detectSpinner = nullptr; + messageLabel = nullptr; + list = nullptr; +} + +void TimeZoneView::setSelectedName(const std::string& name) { + mutex.lock(); + selectedName = name; + mutex.unlock(); + if (list != nullptr) { + rebuildList(); + } +} + +void TimeZoneView::onFilterChanged(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + view->mutex.lock(); + view->filter = string::lowercase(std::string(lv_textarea_get_text(view->textarea))); + view->mutex.unlock(); + view->filterTimer->stop(); + view->filterTimer->start(); +} + +void TimeZoneView::onDetectPressed(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + if (view->detecting.exchange(true)) { + return; + } + view->mutex.lock(); + view->message.clear(); + view->mutex.unlock(); + view->showState(); + // The previous detection already ended, as detecting was false + if (view->detectThread != nullptr) { + view->detectThread->join(); + } + view->detectThread = std::make_unique("timezone_detect", 5120, [view] { + view->detect(); + return 0; + }); + view->detectThread->start(); +} + +void TimeZoneView::onEntryPressed(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + const auto index = reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event))); + view->mutex.lock(); + if (index >= view->entries.size()) { + view->mutex.unlock(); + return; + } + const Entry entry = view->entries[index]; + view->mutex.unlock(); + view->select(entry.name, entry.code); +} + +void TimeZoneView::loadEntries() { + mutex.lock(); + const std::string currentFilter = filter; + mutex.unlock(); + + std::vector matches; + forEachTimeZone([&matches, ¤tFilter](const std::string& name, const std::string& code) { + if (string::lowercase(name).find(currentFilter) != std::string::npos) { + matches.push_back({ name, code }); + } + return matches.size() < MAX_ENTRIES; + }); + + mutex.lock(); + entries = std::move(matches); + mutex.unlock(); +} + +// Called with the LVGL lock held +void TimeZoneView::rebuildList() { + if (list == nullptr) { + return; + } + lv_obj_clean(list); + mutex.lock(); + for (size_t index = 0; index < entries.size(); index++) { + auto* button = lvgl_list_add_button(list, nullptr, entries[index].name.c_str()); + lv_obj_set_user_data(button, reinterpret_cast(static_cast(index))); + lv_obj_add_event_cb(button, onEntryPressed, LV_EVENT_SHORT_CLICKED, this); + if (entries[index].name == selectedName) { + lv_obj_add_state(button, LV_STATE_CHECKED); + } + } + mutex.unlock(); +} + +// Called with the LVGL lock held +void TimeZoneView::showState() { + if (detectButton == nullptr) { + return; + } + const bool isDetecting = detecting; + lv_obj_set_hidden(detectButton, isDetecting); + lv_obj_set_hidden(detectSpinner, !isDetecting); + mutex.lock(); + lv_label_set_text(messageLabel, message.c_str()); + lv_obj_set_hidden(messageLabel, message.empty()); + mutex.unlock(); +} + +// Runs on the detection thread, as the lookup blocks +void TimeZoneView::detect() { + char name[LOCATION_TIME_ZONE_MAX_LENGTH + 1]; + const error_t error = location_lookup_time_zone(name, sizeof(name)); + std::string code; + std::string failure; + if (error == ERROR_NONE) { + forEachTimeZone([&name, &code](const std::string& entryName, const std::string& entryCode) { + if (entryName == name) { + code = entryCode; + return false; + } + return true; + }); + if (code.empty()) { + failure = std::string("The detected time zone ") + name + " isn't in the list."; + } + } else if (error == ERROR_INVALID_STATE) { + failure = "Detecting the time zone needs location services. You can turn them on in Settings."; + } else { + failure = "The time zone couldn't be detected. Check the internet connection and try again."; + } + + // Waits for the LVGL lock without blocking the destructor, which can be called with the lock held + while (!lvgl_try_lock(pdMS_TO_TICKS(50))) { + if (cancelled) { + return; + } + } + if (cancelled) { + lvgl_unlock(); + return; + } + detecting = false; + mutex.lock(); + message = failure; + mutex.unlock(); + showState(); + if (failure.empty()) { + LOG_I(TAG, "Detected %s", name); + // Shows the detected time zone in the list + if (textarea != nullptr) { + lv_textarea_set_text(textarea, name); + } + select(name, code); + } + lvgl_unlock(); +} + +// Called with the LVGL lock held +void TimeZoneView::select(const std::string& name, const std::string& code) { + setSelectedName(name); + onSelected(name, code); +} + +} diff --git a/Tactility/Source/bluetooth/Bluetooth.cpp b/Tactility/Source/bluetooth/Bluetooth.cpp index c791ff41e..d7c2f778c 100644 --- a/Tactility/Source/bluetooth/Bluetooth.cpp +++ b/Tactility/Source/bluetooth/Bluetooth.cpp @@ -115,9 +115,36 @@ BtEventSubscription btEventSub {}; Thread* btEventThread = nullptr; std::atomic btEventThreadRunning {false}; +// Scans repeat while the radio is on, so new devices show up without opening the app. +// Only used by the bridge thread. +constexpr TickType_t AUTO_SCAN_INTERVAL = pdMS_TO_TICKS(10000); +bool autoScanEnabled = false; +bool autoScanPending = false; +TickType_t nextAutoScanTick = 0; + +static void scheduleAutoScan(TickType_t delay) { + autoScanPending = autoScanEnabled; + nextAutoScanTick = xTaskGetTickCount() + delay; +} + +static void runAutoScan() { + autoScanPending = false; + Device* dev; + if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) != ERROR_NONE) { + scheduleAutoScan(AUTO_SCAN_INTERVAL); + return; + } + if (!bluetooth_is_scanning(dev) && bluetooth_scan_start(dev) != ERROR_NONE) { + scheduleAutoScan(AUTO_SCAN_INTERVAL); + } + device_put(dev); +} + static void bt_event_bridge(BtEvent event) { switch (event.type) { case BT_EVENT_RADIO_STATE_CHANGED: + autoScanEnabled = event.radio_state == BT_RADIO_STATE_ON; + scheduleAutoScan(0); switch (event.radio_state) { case BT_RADIO_STATE_ON: getMainDispatcher().dispatch([] { @@ -175,6 +202,7 @@ static void bt_event_bridge(BtEvent event) { break; case BT_EVENT_SCAN_FINISHED: + scheduleAutoScan(AUTO_SCAN_INTERVAL); getMainDispatcher().dispatch([] { autoConnectHidHost(); }); break; @@ -276,6 +304,9 @@ int32_t btEventThreadMain() { while (bluetooth_event_poll(&btEventSub, &event) == ERROR_NONE) { bt_event_bridge(event); } + if (autoScanPending && static_cast(xTaskGetTickCount() - nextAutoScanTick) >= 0) { + runAutoScan(); + } } return 0; } diff --git a/Tactility/Source/lvgl/Lvgl.cpp b/Tactility/Source/lvgl/Lvgl.cpp index 8533504c7..c0cb1a1d5 100644 --- a/Tactility/Source/lvgl/Lvgl.cpp +++ b/Tactility/Source/lvgl/Lvgl.cpp @@ -54,9 +54,6 @@ namespace service { namespace displayidle { extern const ServiceManifest manifest; } namespace keyboardidle { extern const ServiceManifest manifest; } #endif -#if TT_FEATURE_SCREENSHOT_ENABLED - namespace screenshot { extern const ServiceManifest manifest; } -#endif } namespace lvgl { @@ -185,9 +182,6 @@ static void onLvglStarted() { #if defined(CONFIG_TT_TDECK_WORKAROUND) addService(service::keyboardidle::manifest); #endif -#if TT_FEATURE_SCREENSHOT_ENABLED - addService(service::screenshot::manifest); -#endif lvgl::startUsbHidInput(); lvgl::startKeyboardDeviceListener(); @@ -207,9 +201,6 @@ static void onLvglStopped() { if (device_exists_of_type(&IMU_TYPE)) { check(service::removeService(service::autorotate::manifest.id)); } -#if TT_FEATURE_SCREENSHOT_ENABLED - check(service::removeService(service::screenshot::manifest.id)); -#endif #if defined(CONFIG_TT_TDECK_WORKAROUND) check(service::removeService(service::keyboardidle::manifest.id)); #endif diff --git a/Tactility/Source/lvgl/SystemMenu.cpp b/Tactility/Source/lvgl/SystemMenu.cpp index 010207c79..4136c2f6e 100644 --- a/Tactility/Source/lvgl/SystemMenu.cpp +++ b/Tactility/Source/lvgl/SystemMenu.cpp @@ -304,8 +304,8 @@ static void createWidgets(lv_obj_t* parent, void* /*userData*/) { // The arrow keys move between the tiles. Toggles that are focused directly would be switched by them. lvgl_grid_navigation_add(grid); - // Without a pointer device, tapping outside of the card or swiping up isn't possible - if (!lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { + // A keyboard or encoder can't tap outside of the card or swipe up, so it needs a button to close the menu + if (lvgl_indev_exists(LV_INDEV_TYPE_KEYPAD) || lvgl_indev_exists(LV_INDEV_TYPE_ENCODER)) { auto* close_button = createTile(card, LVGL_ICON_SHARED_CLOSE); lv_obj_add_event_cb(close_button, onCloseClicked, LV_EVENT_SHORT_CLICKED, nullptr); lv_group_focus_obj(close_button); diff --git a/Tactility/Source/service/screenshot/Screenshot.cpp b/Tactility/Source/service/screenshot/Screenshot.cpp deleted file mode 100644 index af79f8064..000000000 --- a/Tactility/Source/service/screenshot/Screenshot.cpp +++ /dev/null @@ -1,106 +0,0 @@ -#include - -#if TT_FEATURE_SCREENSHOT_ENABLED - -#include -#include -#include - -#include -#include - -#include - -namespace tt::service::screenshot { - -constexpr auto* TAG = "ScreenshotService"; - -extern const ServiceManifest manifest; - -std::shared_ptr optScreenshotService() { - return service::findServiceById(manifest.id); -} - -void ScreenshotService::startApps(const std::string& path) { - auto lock = mutex.asScopedLock(); - if (!lock.lock(50 / portTICK_PERIOD_MS)) { - LOG_W(TAG,LOG_MESSAGE_MUTEX_LOCK_FAILED); - return; - } - - if (task == nullptr || task->isFinished()) { - task = std::make_unique(); - mode = Mode::Apps; - task->startApps(path); - } else { - LOG_W(TAG,"Screenshot task already running"); - } -} - -void ScreenshotService::startTimed(const std::string& path, uint8_t delayInSeconds, uint8_t amount) { - auto lock = mutex.asScopedLock(); - if (!lock.lock(50 / portTICK_PERIOD_MS)) { - LOG_W(TAG,LOG_MESSAGE_MUTEX_LOCK_FAILED); - return; - } - - if (task == nullptr || task->isFinished()) { - task = std::make_unique(); - mode = Mode::Timed; - task->startTimed(path, delayInSeconds, amount); - } else { - LOG_W(TAG,"Screenshot task already running"); - } -} - -bool ScreenshotService::onStart(ServiceContext& serviceContext) { - if (lv_screen_active() == nullptr) { - LOG_E(TAG, "No display found"); - return false; - } - - return true; -} - -void ScreenshotService::stop() { - auto lock = mutex.asScopedLock(); - if (!lock.lock(50 / portTICK_PERIOD_MS)) { - LOG_W(TAG,LOG_MESSAGE_MUTEX_LOCK_FAILED); - return; - } - - if (task != nullptr) { - task = nullptr; - mode = Mode::None; - } else { - LOG_W(TAG,"Screenshot task not running"); - } -} - -Mode ScreenshotService::getMode() const { - auto lock = mutex.asScopedLock(); - if (!lock.lock(50 / portTICK_PERIOD_MS)) { - LOG_W(TAG,LOG_MESSAGE_MUTEX_LOCK_FAILED); - return Mode::None; - } - - return mode; -} - -bool ScreenshotService::isTaskStarted() { - auto* current_task = task.get(); - if (current_task == nullptr) { - return false; - } else { - return !current_task->isFinished(); - } -} - -extern const ServiceManifest manifest = { - .id = "tactility.screenshot", - .createService = create -}; - -} // namespace - -#endif \ No newline at end of file diff --git a/Tactility/Source/service/screenshot/ScreenshotTask.cpp b/Tactility/Source/service/screenshot/ScreenshotTask.cpp deleted file mode 100644 index ca3e8a494..000000000 --- a/Tactility/Source/service/screenshot/ScreenshotTask.cpp +++ /dev/null @@ -1,182 +0,0 @@ -#include - -#if TT_FEATURE_SCREENSHOT_ENABLED - -#include -#include -#include -#include - -#include - -#include -#include -#include - -#include - -#include - -#include - -namespace tt::service::screenshot { - -constexpr auto* TAG = "ScreenshotTask"; - -ScreenshotTask::~ScreenshotTask() { - if (thread) { - stop(); - } -} - -bool ScreenshotTask::isInterrupted() { - auto lock = mutex.asScopedLock(); - if (!lock.lock(50 / portTICK_PERIOD_MS)) { - LOG_W(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - return true; - } - return interrupted; -} - -bool ScreenshotTask::isFinished() { - auto lock = mutex.asScopedLock(); - if (!lock.lock(50 / portTICK_PERIOD_MS)) { - LOG_W(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - return false; - } - return finished; -} - -void ScreenshotTask::setFinished() { - auto lock = mutex.asScopedLock(); - lock.lock(); - finished = true; -} - -static void makeScreenshot(const std::string& path) { - if (lvgl_try_lock(50 / portTICK_PERIOD_MS)) { - std::string lvgl_path = std::string(lvgl::PATH_PREFIX) + path; - if (lv_screenshot_create(lv_scr_act(), LV_100ASK_SCREENSHOT_SV_PNG, lvgl_path.c_str())) { - LOG_I(TAG, "Screenshot saved to %s", path.c_str()); - } else { - LOG_E(TAG, "Screenshot not saved to %s", path.c_str()); - } - lvgl_unlock(); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "LVGL"); - } -} - -void ScreenshotTask::taskMain() { - uint8_t screenshots_taken = 0; - uint32_t last_app_instance_id = 0; - - while (!isInterrupted()) { - if (work.type == TASK_WORK_TYPE_DELAY) { - // Splitting up the delays makes it easier to stop the service - for (int i = 0; i < (work.delay_in_seconds * 10) && !isInterrupted(); ++i){ - delay_millis(100); - } - - if (!isInterrupted()) { - screenshots_taken++; - std::string filename = std::format("{}/screenshot-{}.png", work.path, screenshots_taken); - makeScreenshot(filename); - - if (work.amount > 0 && screenshots_taken >= work.amount) { - break; // Interrupted loop - } - } - } else if (work.type == TASK_WORK_TYPE_APPS) { - AppInstanceId app_instance_id = 0; - bool has_topmost = app_manager_get_topmost_instance_id(&app_instance_id) == ERROR_NONE; - if (has_topmost && app_instance_id != last_app_instance_id) { - delay_millis(100); - last_app_instance_id = app_instance_id; - auto filename = std::format("{}/screenshot-{}.png", work.path, app_instance_id); - makeScreenshot(filename); - } - // Ensure the LVGL widgets are rendered as the app just started - delay_millis(250); - } - } - - setFinished(); -} - -void ScreenshotTask::taskStart() { - auto lock = mutex.asScopedLock(); - if (!lock.lock(50 / portTICK_PERIOD_MS)) { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - return; - } - - check(thread == nullptr); - thread = new Thread( - "screenshot", - 8192, - [this] { - this->taskMain(); - return 0; - }, - getCpuAffinityConfiguration().graphics - ); - thread->start(); -} - -void ScreenshotTask::startApps(const std::string& path) { - auto lock = mutex.asScopedLock(); - if (!lock.lock(50 / portTICK_PERIOD_MS)) { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - return; - } - - if (thread == nullptr) { - interrupted = false; - work.type = TASK_WORK_TYPE_APPS; - work.path = path; - taskStart(); - } else { - LOG_E(TAG, "Task was already running"); - } -} - -void ScreenshotTask::startTimed(const std::string& path, uint8_t delay_in_seconds, uint8_t amount) { - auto lock = mutex.asScopedLock(); - if (!lock.lock(50 / portTICK_PERIOD_MS)) { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - return; - } - - if (thread == nullptr) { - interrupted = false; - work.type = TASK_WORK_TYPE_DELAY; - work.delay_in_seconds = delay_in_seconds; - work.amount = amount; - work.path = path; - taskStart(); - } else { - LOG_E(TAG, "Task was already running"); - } -} - -void ScreenshotTask::stop() { - if (thread != nullptr) { - if (mutex.lock(50 / portTICK_PERIOD_MS)) { - interrupted = true; - mutex.unlock(); - } - - thread->join(); - - if (mutex.lock(50 / portTICK_PERIOD_MS)) { - delete thread; - thread = nullptr; - mutex.unlock(); - } - } -} - -} // namespace - -#endif diff --git a/Tactility/Source/settings/RadioSettings.cpp b/Tactility/Source/settings/RadioSettings.cpp index 3bc99174b..d04827e0e 100644 --- a/Tactility/Source/settings/RadioSettings.cpp +++ b/Tactility/Source/settings/RadioSettings.cpp @@ -46,4 +46,63 @@ bool hasRadio() { return device_exists_of_type(&RADIO_TYPE); } +RadioRegion findRegionForTimeZone(const std::string& timeZoneName) { + struct ZoneRegion { + const char* zone; + RadioRegion region; + }; + // Exact names before the area prefixes below, e.g. the Russian zones in Europe and Asia + static constexpr ZoneRegion ZONES[] = { + { "Europe/Moscow", RADIO_REGION_RU_868 }, + { "Europe/Kaliningrad", RADIO_REGION_RU_868 }, + { "Europe/Samara", RADIO_REGION_RU_868 }, + { "Europe/Volgograd", RADIO_REGION_RU_868 }, + { "Europe/Kirov", RADIO_REGION_RU_868 }, + { "Europe/Astrakhan", RADIO_REGION_RU_868 }, + { "Europe/Saratov", RADIO_REGION_RU_868 }, + { "Europe/Ulyanovsk", RADIO_REGION_RU_868 }, + { "Asia/Yekaterinburg", RADIO_REGION_RU_868 }, + { "Asia/Omsk", RADIO_REGION_RU_868 }, + { "Asia/Novosibirsk", RADIO_REGION_RU_868 }, + { "Asia/Barnaul", RADIO_REGION_RU_868 }, + { "Asia/Tomsk", RADIO_REGION_RU_868 }, + { "Asia/Novokuznetsk", RADIO_REGION_RU_868 }, + { "Asia/Krasnoyarsk", RADIO_REGION_RU_868 }, + { "Asia/Irkutsk", RADIO_REGION_RU_868 }, + { "Asia/Chita", RADIO_REGION_RU_868 }, + { "Asia/Yakutsk", RADIO_REGION_RU_868 }, + { "Asia/Khandyga", RADIO_REGION_RU_868 }, + { "Asia/Vladivostok", RADIO_REGION_RU_868 }, + { "Asia/Ust-Nera", RADIO_REGION_RU_868 }, + { "Asia/Magadan", RADIO_REGION_RU_868 }, + { "Asia/Sakhalin", RADIO_REGION_RU_868 }, + { "Asia/Srednekolymsk", RADIO_REGION_RU_868 }, + { "Asia/Kamchatka", RADIO_REGION_RU_868 }, + { "Asia/Anadyr", RADIO_REGION_RU_868 }, + { "Asia/Shanghai", RADIO_REGION_CN_470 }, + { "Asia/Urumqi", RADIO_REGION_CN_470 }, + { "Asia/Kolkata", RADIO_REGION_IN_865 }, + { "Asia/Calcutta", RADIO_REGION_IN_865 }, + { "Asia/Tokyo", RADIO_REGION_JP_920 }, + { "Pacific/Auckland", RADIO_REGION_NZ_865 }, + { "Pacific/Chatham", RADIO_REGION_NZ_865 }, + }; + static constexpr ZoneRegion AREAS[] = { + { "Europe/", RADIO_REGION_EU_868 }, + { "America/", RADIO_REGION_US_915 }, + { "Australia/", RADIO_REGION_AU_915 }, + }; + for (const auto& zone : ZONES) { + if (timeZoneName == zone.zone) { + return zone.region; + } + } + for (const auto& area : AREAS) { + if (timeZoneName.starts_with(area.zone)) { + return area.region; + } + } + return RADIO_REGION_UNSET; +} + } diff --git a/Tactility/Source/settings/time.cpp b/Tactility/Source/settings/time.cpp index f99372a5e..1060444b8 100644 --- a/Tactility/Source/settings/time.cpp +++ b/Tactility/Source/settings/time.cpp @@ -1,10 +1,12 @@ #include +#include #include #include #include #include +#include #include #include @@ -15,6 +17,8 @@ namespace tt::settings { +constexpr auto* TAG = "Time"; + constexpr auto* TIMEZONE_PREFERENCES_KEY_NAME = "tz_name"; constexpr auto* TIMEZONE_PREFERENCES_KEY_CODE = "tz_code"; constexpr auto* TIMEZONE_PREFERENCES_KEY_TIME24 = "tz_time24"; @@ -61,6 +65,14 @@ void setTimeZone(const std::string& name, const std::string& code) { tzset(); #endif + // The time zone tells where the device is, and so which radio band it may use + if (radio::hasRadio()) { + const RadioRegion region = radio::findRegionForTimeZone(name); + if (region != RADIO_REGION_UNSET && !radio::saveRegion(region)) { + LOG_E(TAG, "Failed to save the radio region"); + } + } + system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); } diff --git a/Tests/Python/tt_test.py b/Tests/Python/tt_test.py index 22b84359e..9fc766fea 100644 --- a/Tests/Python/tt_test.py +++ b/Tests/Python/tt_test.py @@ -66,18 +66,19 @@ class TtTest(unittest.TestCase): def default_tools(self): return {name: read_tool(name) for name in ("update-self.py", "update-sdk.py", "_bootstrap.py")} | {"echo.py": ECHO_TOOL} - def publish_sdk(self, tools, commit="commit1", sdk_content="# sdk"): + def publish_sdk(self, tools, commit="commit1", sdk_content="# sdk", platforms=("posix",)): version_directory = os.path.join(self.cdn, "sdk", VERSION) os.makedirs(version_directory, exist_ok=True) tools_zip = make_zip(tools) with open(os.path.join(version_directory, "tools.zip"), "wb") as file: file.write(tools_zip) - with open(os.path.join(version_directory, "TactilitySDK-posix.zip"), "wb") as file: - file.write(make_zip({"TactilitySDK.cmake": sdk_content})) + for platform in platforms: + with open(os.path.join(version_directory, f"TactilitySDK-{platform}.zip"), "wb") as file: + file.write(make_zip({"TactilitySDK.cmake": sdk_content})) index = { "version": VERSION, "gitCommit": commit, - "platforms": {"posix": "TactilitySDK-posix.zip"}, + "platforms": {platform: f"TactilitySDK-{platform}.zip" for platform in platforms}, "tools": {"file": "tools.zip", "sha256": hashlib.sha256(tools_zip).hexdigest()}, } with open(os.path.join(version_directory, "index.json"), "w") as file: @@ -160,22 +161,68 @@ class TtTest(unittest.TestCase): self.assertIn("echo", result.stdout) self.assertIn(VERSION, result.stdout) - def test_changed_tools_are_downloaded_again(self): + def test_installed_tools_and_platforms_are_only_replaced_by_update_sdk(self): self.write_manifest(self.work) self.assertEqual(self.run_tt("echo").returncode, 3) - self.publish_sdk({"echo.py": "import sys\nsys.exit(4)\n"}) - self.assertEqual(self.run_tt("echo").returncode, 3) + tools = self.default_tools() | {"echo.py": "import sys\nimport _bootstrap\n_bootstrap.run()\nsys.exit(4)\n"} + self.publish_sdk(tools, commit="commit2", sdk_content="# sdk 2") self.expire_index() + self.expire_update_check() + result = self.run_tt("echo") + self.assertEqual(result.returncode, 3) + self.assertEqual(self.read_sdk_file(), "# sdk") + self.assertIn("run 'tt update-sdk'", result.stderr) + result = self.run_tt("update-sdk") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Downloading the tools", result.stdout) + self.assertIn("was updated", result.stdout) self.assertEqual(self.run_tt("echo").returncode, 4) + self.assertEqual(self.read_sdk_file(), "# sdk 2") - def test_failed_download_keeps_the_cache(self): + def test_all_platforms_installs_every_platform_of_the_sdk(self): + self.publish_sdk(self.default_tools(), platforms=("posix", "esp32")) + self.write_manifest(self.work, platforms="all") + self.assertEqual(self.run_tt("echo").returncode, 3) + for platform in ("posix", "esp32"): + self.assertTrue(os.path.isdir(self.sdk_path(f"platform-{platform}", "TactilitySDK")), platform) + + def test_missing_platform_of_a_republished_sdk_asks_for_update_sdk(self): + self.publish_sdk(self.default_tools(), platforms=("posix", "esp32")) + self.write_manifest(self.work) + self.assertEqual(self.run_tt("echo").returncode, 3) + self.publish_sdk(self.default_tools(), commit="commit2", sdk_content="# sdk 2", platforms=("posix", "esp32")) + self.expire_index() + self.write_manifest(self.work, platforms="posix,esp32") + result = self.run_tt("echo") + self.assertEqual(result.returncode, 1) + self.assertIn("run 'tt update-sdk' first", result.stderr) + self.assertFalse(os.path.exists(self.sdk_path("platform-esp32"))) + self.assertEqual(self.run_tt("update-sdk").returncode, 0) + self.assertEqual(self.run_tt("echo").returncode, 3) + for platform in ("posix", "esp32"): + with open(self.sdk_path(f"platform-{platform}", "commit")) as file: + self.assertEqual(file.read(), "commit2", platform) + + def test_failed_update_sdk_keeps_the_installed_build(self): + self.write_manifest(self.work) + self.assertEqual(self.run_tt("echo").returncode, 3) + tools = self.default_tools() | {"echo.py": "import sys\nsys.exit(4)\n"} + self.publish_sdk(tools, commit="commit2", sdk_content="# sdk 2") + os.remove(os.path.join(self.cdn, "sdk", VERSION, "TactilitySDK-posix.zip")) + result = self.run_tt("update-sdk") + self.assertEqual(result.returncode, 1) + self.assertIn("was not changed", result.stderr) + self.assertEqual(self.read_sdk_file(), "# sdk") + self.assertEqual(self.run_tt("echo").returncode, 3) + self.assertEqual(sorted(name for name in os.listdir(self.sdk_path()) if name.endswith((".new", ".old"))), []) + + def test_installed_sdk_works_offline(self): self.write_manifest(self.work) self.assertEqual(self.run_tt("echo").returncode, 3) shutil.rmtree(self.cdn) self.expire_index() result = self.run_tt("echo") self.assertEqual(result.returncode, 3, result.stderr) - self.assertIn("cached", result.stderr) def test_tools_with_a_wrong_checksum_are_refused(self): self.write_manifest(self.work) @@ -225,16 +272,19 @@ class TtTest(unittest.TestCase): def test_update_replaces_itself_and_installs_wrappers(self): tt_copy = os.path.join(self.root, "tt.py") shutil.copy(TT_PATH, tt_copy) - self.publish_tt({"tt.py": "# updated\n", "tt": "#!/bin/sh\n", "tt.cmd": "@echo off\r\n"}) + with open(os.path.join(self.root, "tt.cmd"), "w") as file: + file.write("@echo off\r\n") + self.publish_tt({"tt.py": "# updated\n", "tt": "#!/bin/sh\n", "tt.ps1": "exit 0\r\n"}) self.write_manifest(self.work) result = self.run_tt("update-self", tt_path=tt_copy) self.assertEqual(result.returncode, 0, result.stderr) with open(tt_copy) as file: self.assertEqual(file.read(), "# updated\n") self.assertTrue(os.access(os.path.join(self.root, "tt"), os.X_OK)) - self.assertTrue(os.access(os.path.join(self.root, "tt.cmd"), os.X_OK)) + self.assertTrue(os.access(os.path.join(self.root, "tt.ps1"), os.X_OK)) self.assertTrue(os.access(tt_copy, os.X_OK)) - self.assertEqual(sorted(os.listdir(self.root)), ["cdn", "home", "tt", "tt.cmd", "tt.py", "work"]) + # The obsolete tt.cmd is removed + self.assertEqual(sorted(os.listdir(self.root)), ["cdn", "home", "tt", "tt.ps1", "tt.py", "work"]) def test_update_rejects_invalid_python(self): tt_copy = os.path.join(self.root, "tt.py") diff --git a/device.py b/device.py index 001f14db4..9907fb994 100755 --- a/device.py +++ b/device.py @@ -267,6 +267,24 @@ def write_spiram_variables(output_file, device_properties: dict): output_file.write("CONFIG_SPIRAM_RODATA=y\n") output_file.write("CONFIG_SPIRAM_XIP_FROM_PSRAM=y\n") +def write_iram_reduction(output_file, device_properties: dict): + # Without PSRAM, internal RAM is scarce: keep these in flash, which costs some speed + has_spiram = get_property_or_exit(device_properties, "hardware.spiRam") + if has_spiram == "true": + return + output_file.write("# Reduce IRAM usage on devices without PSRAM\n") + for option in ( + "ESP_PHY_IRAM_OPT", + "LOG_IN_IRAM", + "ESP_TIMER_IN_IRAM", + "ESP_INTR_IN_IRAM", + "ESP_SYSTEM_IN_IRAM", + "LIBC_MISC_IN_IRAM", + "ESP_HW_SUPPORT_FUNC_IN_IRAM", + "SPI_FLASH_PLACE_FUNCTIONS_IN_IRAM", + ): + output_file.write(f"CONFIG_{option}=n\n") + def write_performance_improvements(output_file, device_properties: dict): idf_target = get_property_or_exit(device_properties, "hardware.target").lower() if idf_target == "esp32s3": @@ -305,6 +323,11 @@ def write_lvgl_variables(output_file, device_properties: dict): output_file.write("CONFIG_LV_USE_THEME_DEFAULT=n\n") output_file.write("CONFIG_LV_USE_THEME_SIMPLE=n\n") output_file.write("CONFIG_LV_USE_THEME_MONO=n\n") + # Devices without PSRAM can run out of the memory that LVGL's file cache allocates on the first read of a file + fs_cache_size_text = get_property_or_none(device_properties, "lvgl.fsCacheSize") + if fs_cache_size_text is not None: + fs_cache_size = safe_int(fs_cache_size_text, f"File cache size must be an integer, but was: '{fs_cache_size_text}'") + output_file.write(f"CONFIG_LV_FS_STDIO_CACHE_SIZE={fs_cache_size}\n") font_size_text = get_property_or_none(device_properties, "font.defaultSize") if font_size_text is None: font_size_text = get_property_or_default(device_properties, "lvgl.fontSize", "14") @@ -423,6 +446,7 @@ def write_properties(output_file, device_properties: dict, device_id: str, is_de write_flash_variables(output_file, device_properties) write_partition_table(output_file, device_properties, is_dev) write_spiram_variables(output_file, device_properties) + write_iram_reduction(output_file, device_properties) write_performance_improvements(output_file, device_properties) write_usb_variables(output_file, device_properties) write_bluetooth_variables(output_file, device_properties) diff --git a/tt.cmd b/tt.cmd deleted file mode 100755 index fc685aaf4..000000000 --- a/tt.cmd +++ /dev/null @@ -1,9 +0,0 @@ -@echo off -rem Runs tt.py from the directory of this script, on Windows -where py >nul 2>nul -if %ERRORLEVEL% equ 0 ( - py -3 "%~dp0tt.py" %* -) else ( - python "%~dp0tt.py" %* -) -exit /b %ERRORLEVEL% diff --git a/tt.ps1 b/tt.ps1 new file mode 100755 index 000000000..4cc5537ba --- /dev/null +++ b/tt.ps1 @@ -0,0 +1,9 @@ +# Runs tt.py from the folder of this script, on Windows. +# PowerShell passes the arguments to Python as they are, without parsing them again. +$script = Join-Path $PSScriptRoot "tt.py" +if (Get-Command py -ErrorAction SilentlyContinue) { + & py -3 $script @args +} else { + & python $script @args +} +exit $LASTEXITCODE diff --git a/tt.py b/tt.py index 667c46c0a..b15e27d45 100755 --- a/tt.py +++ b/tt.py @@ -5,7 +5,7 @@ "tt [arguments]" runs .py from the tools of that SDK version. When TACTILITY_SDK_PATH is set, its SDK and tools are used instead of downloading them. Both ~/.tactility and TACTILITY_SDK_PATH hold sdk//tools/ and sdk//platform-/TactilitySDK/. -The tt (Linux, macOS) and tt.cmd (Windows) wrappers next to this script run it. +The tt (Linux, macOS) and tt.ps1 (Windows PowerShell) wrappers next to this script run it. The SDK version is read from ./manifest.properties, or else from the first Apps/*/manifest.properties or apps/*/manifest.properties. """ @@ -20,7 +20,7 @@ import time import urllib.request import zipfile -TT_VERSION = "1.0.0" +TT_VERSION = "1.1.1" CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") HOME_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility") INDEX_VALIDITY_SECONDS = 3600 @@ -143,16 +143,9 @@ def install_tools(version, version_directory, index): fail(f"SDK {version} has no tools") tools_directory = os.path.join(version_directory, "tools") hash_path = os.path.join(version_directory, "tools.sha256") - if os.path.isdir(tools_directory) and os.path.isfile(hash_path): - with open(hash_path) as file: - if file.read().strip() == tools["sha256"]: - return print(f"Downloading the tools of SDK {version}", file=sys.stderr) zip_path = os.path.join(version_directory, "tools.zip") if not download(sdk_url(version, tools["file"]), zip_path): - if os.path.isdir(tools_directory): - warn(f"using the cached tools of SDK {version}") - return fail(f"failed to download the tools of SDK {version}") if file_sha256(zip_path) != tools["sha256"]: os.remove(zip_path) @@ -167,8 +160,6 @@ def install_platforms(version, version_directory, index, platforms): available = index.get("platforms", {}) for platform in platforms: platform_directory = os.path.join(version_directory, f"platform-{platform}") - if os.path.isdir(os.path.join(platform_directory, "TactilitySDK")): - continue if platform not in available: fail(f"platform {platform} is not available in SDK {version}") print(f"Downloading SDK {version} for {platform}", file=sys.stderr) @@ -182,12 +173,50 @@ def install_platforms(version, version_directory, index, platforms): file.write(index.get("gitCommit", "")) +def read_text(path): + try: + with open(path) as file: + return file.read().strip() + except OSError: + return None + + +def is_installed_build(version_directory, index): + """True when the installed tools and platforms are of the build that the index describes""" + installed_tools_hash = read_text(os.path.join(version_directory, "tools.sha256")) + if installed_tools_hash is not None and installed_tools_hash != index.get("tools", {}).get("sha256"): + return False + for name in os.listdir(version_directory): + commit = read_text(os.path.join(version_directory, name, "commit")) + if name.startswith("platform-") and commit is not None and commit != index.get("gitCommit", ""): + return False + return True + + def prepare_sdk(version, platforms): + """Installs what's missing of the SDK version. Installed tools and platforms are only replaced by "tt update-sdk", so they stay from the same build.""" version_directory = version_path(HOME_DIRECTORY, version) + index = None + # "target.platforms=all" means every platform of the SDK + if platforms == ["all"]: + os.makedirs(version_directory, exist_ok=True) + index = load_index(version, version_directory) + platforms = sorted(index.get("platforms", {})) + has_tools = os.path.isdir(os.path.join(version_directory, "tools")) + missing_platforms = [platform for platform in platforms if not os.path.isdir(os.path.join(version_directory, f"platform-{platform}", "TactilitySDK"))] + if has_tools and not missing_platforms: + return version_directory os.makedirs(version_directory, exist_ok=True) - index = load_index(version, version_directory) - install_tools(version, version_directory, index) - install_platforms(version, version_directory, index, platforms) + if index is None: + index = load_index(version, version_directory) + # The CDN only has the latest build of a version, so a newer build can't be mixed with the installed one + if not is_installed_build(version_directory, index): + if has_tools: + fail(f"SDK {version} was published again since it was installed: run 'tt update-sdk' first") + fail(f"SDK {version} was published again since it was installed: remove {version_directory} and try again") + if not has_tools: + install_tools(version, version_directory, index) + install_platforms(version, version_directory, index, missing_platforms) return version_directory @@ -258,7 +287,9 @@ def main(arguments): fail(f"tools not found: {tools_directory}") else: sdk_path = HOME_DIRECTORY - tools_directory = os.path.join(prepare_sdk(version, platforms), "tools") + # update-sdk replaces the installed build, so it only needs the tools + required_platforms = [] if command == "update-sdk" else platforms + tools_directory = os.path.join(prepare_sdk(version, required_platforms), "tools") environment = tool_environment(version, sdk_path, is_local) if is_help: print_help(version, tools_directory)