From 9f5d6e5815de1f7e14f02d29c70c8198025695d1 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Mon, 19 Oct 2026 22:47:39 +0200 Subject: [PATCH] Simulator profiles and orientation, ESP-NOW move, tt project generation, App Hub crash fix (#703) --- .claude/rules/lvgl.md | 5 + .github/actions/build-sdk-posix/action.yml | 13 + .github/actions/build-sdk/action.yml | 1 + Buildscripts/CDN/generate-sdk-files.py | 6 +- Buildscripts/CDN/new_project.py | 19 + .../NewProject/gui/main/CMakeLists.txt | 4 + .../NewProject/gui/main/Source/main.c | 51 ++ .../NewProject/gui/manifest.properties | 9 + .../NewProject/terminal/main/CMakeLists.txt | 4 + .../NewProject/terminal/main/Source/main.c | 6 + .../NewProject/terminal/manifest.properties | 10 + Buildscripts/TactilitySDK/Tools/app.py | 43 +- Buildscripts/release-tools-current.py | 8 +- Devices/cl32-v02-v03/cl32-v02-v03.dts | 27 +- Devices/cl32-v02-v03/module.yaml | 1 + Devices/simulator/CMakeLists.txt | 3 + Devices/simulator/Source/Simulator.h | 4 +- Devices/simulator/Source/SimulatorProfile.cpp | 228 +++++++++ Devices/simulator/Source/SimulatorProfile.h | 64 +++ .../simulator/Source/SimulatorProfileApp.cpp | 292 +++++++++++ .../simulator/Source/drivers/sdl_display.cpp | 175 ++++++- .../simulator/Source/drivers/sdl_display.h | 18 + .../simulator/Source/drivers/sdl_input.cpp | 39 +- Devices/simulator/Source/module.cpp | 32 +- Devices/simulator/simulator.dts | 5 + Documentation/ideas.md | 27 +- .../bindings/mock,bluetooth.yaml | 8 + .../include/bindings/mock_bluetooth.h | 7 + .../include/mock/mock_bluetooth.h | 14 + .../source/mock_bluetooth.cpp | 481 ++++++++++++++++++ Drivers/mock-drivers-module/source/module.cpp | 2 + .../source/app_esp32_loader_service.cpp | 2 +- Modules/lvgl-module/include/lvgl/theme.h | 7 + .../lvgl-module/include/lvgl/widgets/list.h | 8 + Modules/lvgl-module/source/symbols.c | 2 + Modules/lvgl-module/source/theme.cpp | 7 +- Modules/lvgl-module/source/widgets/list.cpp | 7 + Platforms/platform-esp32/CMakeLists.txt | 2 +- .../espnow/esp_hosted_espnow_bridge_proto.h | 2 +- .../private/espnow/espnow_backend.h | 34 +- .../private/espnow/espnow_hosted_transport.h | 4 +- .../espnow/esp32_espnow_backend_hosted.cpp | 12 +- .../espnow/esp32_espnow_backend_native.cpp | 4 +- .../espnow/esp32_espnow_hosted_transport.cpp | 4 +- .../drivers/espnow/esp32_espnow_radio.cpp | 92 ++-- Platforms/platform-esp32/source/module.cpp | 21 +- Tactility/CMakeLists.txt | 5 - .../Tactility/app/meshchat/MeshChatCommon.h | 21 +- .../Include/Tactility/service/espnow/EspNow.h | 69 --- .../Tactility/service/espnow/EspNowRadio.h | 19 - .../Tactility/service/espnow/EspNowService.h | 78 --- Tactility/Source/InitApps.cpp | 12 +- Tactility/Source/InitServices.cpp | 9 - Tactility/Source/app/apphub/AppHubApp.cpp | 82 ++- .../app/apphubdetails/AppHubDetailsApp.cpp | 123 +++-- Tactility/Source/app/btmanage/BtManage.cpp | 11 +- .../Source/app/meshchat/MeshChannels.cpp | 22 +- .../Source/app/meshchat/MeshChatCommon.cpp | 77 ++- .../Source/app/meshchat/MeshContacts.cpp | 22 +- Tactility/Source/service/espnow/EspNow.cpp | 100 ---- .../Source/service/espnow/EspNowService.cpp | 202 -------- Tests/Python/tt_test.py | 92 +++- tt.py | 119 ++++- 63 files changed, 2157 insertions(+), 720 deletions(-) create mode 100644 Buildscripts/CDN/new_project.py create mode 100644 Buildscripts/TactilitySDK/NewProject/gui/main/CMakeLists.txt create mode 100644 Buildscripts/TactilitySDK/NewProject/gui/main/Source/main.c create mode 100644 Buildscripts/TactilitySDK/NewProject/gui/manifest.properties create mode 100644 Buildscripts/TactilitySDK/NewProject/terminal/main/CMakeLists.txt create mode 100644 Buildscripts/TactilitySDK/NewProject/terminal/main/Source/main.c create mode 100644 Buildscripts/TactilitySDK/NewProject/terminal/manifest.properties create mode 100644 Devices/simulator/Source/SimulatorProfile.cpp create mode 100644 Devices/simulator/Source/SimulatorProfile.h create mode 100644 Devices/simulator/Source/SimulatorProfileApp.cpp create mode 100644 Drivers/mock-drivers-module/bindings/mock,bluetooth.yaml create mode 100644 Drivers/mock-drivers-module/include/bindings/mock_bluetooth.h create mode 100644 Drivers/mock-drivers-module/include/mock/mock_bluetooth.h create mode 100644 Drivers/mock-drivers-module/source/mock_bluetooth.cpp rename {Tactility/Private/Tactility/service => Platforms/platform-esp32/private}/espnow/esp_hosted_espnow_bridge_proto.h (98%) rename Tactility/Private/Tactility/service/espnow/EspNowBackend.h => Platforms/platform-esp32/private/espnow/espnow_backend.h (56%) rename Tactility/Private/Tactility/service/espnow/EspNowHostedTransport.h => Platforms/platform-esp32/private/espnow/espnow_hosted_transport.h (89%) rename Tactility/Source/service/espnow/EspNowBackendHosted.cpp => Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_backend_hosted.cpp (97%) rename Tactility/Source/service/espnow/EspNowBackendNative.cpp => Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_backend_native.cpp (98%) rename Tactility/Source/service/espnow/EspNowHostedTransport.cpp => Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_hosted_transport.cpp (91%) rename Tactility/Source/service/espnow/EspNowRadio.cpp => Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_radio.cpp (81%) delete mode 100644 Tactility/Include/Tactility/service/espnow/EspNow.h delete mode 100644 Tactility/Private/Tactility/service/espnow/EspNowRadio.h delete mode 100644 Tactility/Private/Tactility/service/espnow/EspNowService.h delete mode 100644 Tactility/Source/service/espnow/EspNow.cpp delete mode 100644 Tactility/Source/service/espnow/EspNowService.cpp diff --git a/.claude/rules/lvgl.md b/.claude/rules/lvgl.md index 7a4b407a5..1ef7d12f4 100644 --- a/.claude/rules/lvgl.md +++ b/.claude/rules/lvgl.md @@ -9,6 +9,11 @@ Two kernel modules cover LVGL: - **`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. +## Lists in cards + +Every list item has a divider below it, so the last one lies on the border of the card that holds the list. +Call `lvgl_list_hide_last_divider()` (`lvgl/widgets/list.h`) on the list, or on the card when the items are its direct children, after the last item was added. + ## No file operations on the LVGL thread Code that runs on the LVGL thread must not do file operations (including reading or writing settings): `create_widgets`, LVGL event callbacks, timers, and anything under the LVGL lock. diff --git a/.github/actions/build-sdk-posix/action.yml b/.github/actions/build-sdk-posix/action.yml index b8b0a9fc5..f03008a29 100644 --- a/.github/actions/build-sdk-posix/action.yml +++ b/.github/actions/build-sdk-posix/action.yml @@ -30,11 +30,24 @@ runs: cp -r release/TactilitySDK $TACTILITY_PLATFORM_PATH/TactilitySDK mkdir -p test_sdk/sdk/$(cat version.txt)/tools cp Buildscripts/TactilitySDK/Tools/*.py test_sdk/sdk/$(cat version.txt)/tools/ + python -c "import sys; sys.path.insert(0, 'Buildscripts/CDN'); from new_project import create_new_project_zip; create_new_project_zip('test_sdk/sdk/$(cat version.txt)')" - name: 'Test Integration' shell: bash env: TACTILITY_ARCH: ${{ steps.arch.outputs.value }} run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python ../../tt.py app build -a posix-$TACTILITY_ARCH + - name: 'Test New Project' + shell: bash + env: + TACTILITY_ARCH: ${{ steps.arch.outputs.value }} + run: | + SDK_VERSION=$(cat version.txt) + # The answers are: generate, SDK version, package id, app id, app name and terminal app. An empty line takes the default. + for terminal in "" "y"; do + project_path="$RUNNER_TEMP/new-project-${terminal:-gui}" + mkdir -p "$project_path" + (cd "$project_path" && printf 'y\n%s\n\n\n\n%s\n' "$SDK_VERSION" "$terminal" | TACTILITY_SDK_PATH="$GITHUB_WORKSPACE/test_sdk" python "$GITHUB_WORKSPACE/tt.py" app build -a posix-$TACTILITY_ARCH) + done - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index d42c1a679..0f929de52 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -40,6 +40,7 @@ runs: cp -r release/TactilitySDK $TACTILITY_PLATFORM_PATH/TactilitySDK mkdir -p test_sdk/sdk/$(cat version.txt)/tools cp Buildscripts/TactilitySDK/Tools/*.py test_sdk/sdk/$(cat version.txt)/tools/ + python -c "import sys; sys.path.insert(0, 'Buildscripts/CDN'); from new_project import create_new_project_zip; create_new_project_zip('test_sdk/sdk/$(cat version.txt)')" - name: 'Test Integration' uses: espressif/esp-idf-ci-action@v1 with: diff --git a/Buildscripts/CDN/generate-sdk-files.py b/Buildscripts/CDN/generate-sdk-files.py index 93868e101..ef5334443 100644 --- a/Buildscripts/CDN/generate-sdk-files.py +++ b/Buildscripts/CDN/generate-sdk-files.py @@ -8,6 +8,8 @@ import json import shutil import zipfile +from new_project import create_new_project_zip + VERBOSE = False TOOLS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "TactilitySDK", "Tools") @@ -18,6 +20,7 @@ class SdkIndex: gitCommit: str platforms: dict tools: dict + newProject: dict shell_color_red = "\033[91m" shell_color_orange = "\033[93m" @@ -69,7 +72,8 @@ def main(in_path: str, out_path: str, version: str): created=datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%S'), gitCommit=get_git_commit_hash(), platforms={}, - tools=create_tools_bundle(out_path) + tools=create_tools_bundle(out_path), + newProject=create_new_project_zip(out_path) ) for artifact_directory in artifact_directories: if VERBOSE: diff --git a/Buildscripts/CDN/new_project.py b/Buildscripts/CDN/new_project.py new file mode 100644 index 000000000..a7d7204bc --- /dev/null +++ b/Buildscripts/CDN/new_project.py @@ -0,0 +1,19 @@ +import hashlib +import os +import zipfile + +NEW_PROJECT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "TactilitySDK", "NewProject") +ARCHIVE_NAME = "new-project.zip" + +def create_new_project_zip(out_path: str): + """Zips the project templates that tt.py generates a new app from. Returns the entry of index.json.""" + archive_path = os.path.join(out_path, ARCHIVE_NAME) + with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive: + for directory, directory_names, file_names in os.walk(NEW_PROJECT_PATH): + directory_names.sort() + for file_name in sorted(file_names): + file_path = os.path.join(directory, file_name) + archive.write(file_path, os.path.relpath(file_path, NEW_PROJECT_PATH)) + with open(archive_path, "rb") as archive: + sha256 = hashlib.sha256(archive.read()).hexdigest() + return {"file": ARCHIVE_NAME, "sha256": sha256} diff --git a/Buildscripts/TactilitySDK/NewProject/gui/main/CMakeLists.txt b/Buildscripts/TactilitySDK/NewProject/gui/main/CMakeLists.txt new file mode 100644 index 000000000..545b36c36 --- /dev/null +++ b/Buildscripts/TactilitySDK/NewProject/gui/main/CMakeLists.txt @@ -0,0 +1,4 @@ +include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +file(GLOB_RECURSE SOURCE_FILES Source/*.c) +tactility_component_register(SRCS ${SOURCE_FILES}) diff --git a/Buildscripts/TactilitySDK/NewProject/gui/main/Source/main.c b/Buildscripts/TactilitySDK/NewProject/gui/main/Source/main.c new file mode 100644 index 000000000..3eca0eb1e --- /dev/null +++ b/Buildscripts/TactilitySDK/NewProject/gui/main/Source/main.c @@ -0,0 +1,51 @@ +#include +#include + +#include + +#include + +#include +#include + +#include + +static void create_widgets(lv_obj_t* parent, void* user_data) { + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Hello World"); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + + lv_obj_t* label = lv_label_create(parent); + lv_label_set_text(label, "Hello, world!"); + lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); +} + +int main(int argc, char* argv[]) { + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + struct TaskEventGroup event_group = {0}; + task_event_group_construct(&event_group); + + struct AppEventSubscription sub = {0}; + check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); + + WindowId window = window_manager_create(app_instance_id, create_widgets, NULL); + + bool should_close = false; + while (!should_close) { + task_event_group_wait_any(&event_group, NULL, portMAX_DELAY); + + struct AppEvent event; + while (app_event_poll(&sub, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + should_close = true; + break; + } + } + } + + window_manager_remove(window); + check(app_event_unsubscribe(&sub) == ERROR_NONE); + task_event_group_destruct(&event_group); + + return 0; +} diff --git a/Buildscripts/TactilitySDK/NewProject/gui/manifest.properties b/Buildscripts/TactilitySDK/NewProject/gui/manifest.properties new file mode 100644 index 000000000..b252997e7 --- /dev/null +++ b/Buildscripts/TactilitySDK/NewProject/gui/manifest.properties @@ -0,0 +1,9 @@ +manifest.version=0.3 +target.sdk=@SDK_VERSION@ +target.platforms=all +id=@PACKAGE_ID@ +version.name=0.1.0 +version.code=1 +app.0.id=@APP_ID@ +app.0.name=@APP_NAME@ +app.0.binary=main diff --git a/Buildscripts/TactilitySDK/NewProject/terminal/main/CMakeLists.txt b/Buildscripts/TactilitySDK/NewProject/terminal/main/CMakeLists.txt new file mode 100644 index 000000000..545b36c36 --- /dev/null +++ b/Buildscripts/TactilitySDK/NewProject/terminal/main/CMakeLists.txt @@ -0,0 +1,4 @@ +include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +file(GLOB_RECURSE SOURCE_FILES Source/*.c) +tactility_component_register(SRCS ${SOURCE_FILES}) diff --git a/Buildscripts/TactilitySDK/NewProject/terminal/main/Source/main.c b/Buildscripts/TactilitySDK/NewProject/terminal/main/Source/main.c new file mode 100644 index 000000000..79bffb872 --- /dev/null +++ b/Buildscripts/TactilitySDK/NewProject/terminal/main/Source/main.c @@ -0,0 +1,6 @@ +#include + +int main(int argc, char* argv[]) { + printf("Hello, world!\n"); + return 0; +} diff --git a/Buildscripts/TactilitySDK/NewProject/terminal/manifest.properties b/Buildscripts/TactilitySDK/NewProject/terminal/manifest.properties new file mode 100644 index 000000000..7636ece53 --- /dev/null +++ b/Buildscripts/TactilitySDK/NewProject/terminal/manifest.properties @@ -0,0 +1,10 @@ +manifest.version=0.3 +target.sdk=@SDK_VERSION@ +target.platforms=all +id=@PACKAGE_ID@ +version.name=0.1.0 +version.code=1 +app.0.id=@APP_ID@ +app.0.name=@APP_NAME@ +app.0.binary=main +app.0.headless=true diff --git a/Buildscripts/TactilitySDK/Tools/app.py b/Buildscripts/TactilitySDK/Tools/app.py index 6994433b8..68aa58999 100755 --- a/Buildscripts/TactilitySDK/Tools/app.py +++ b/Buildscripts/TactilitySDK/Tools/app.py @@ -46,7 +46,7 @@ def print_help(): print(" Parameters:") print(" (optional) --host, -h [input] Defaults to the host of 'tt host'") print("") - print(" install Install the application") + print(" install [app file] Install the application, or the given .app file") print(" Parameters:") print(" (optional) --host, -h [input] Defaults to the host of 'tt host'") print("") @@ -227,6 +227,8 @@ def parse_command_line(argv): args[canonical] = "true" elif action is None: action = token + elif action == "install" and "app-file" not in args: + args["app-file"] = token else: exit_with_error(f"Unexpected argument: {token}") index += 1 @@ -731,18 +733,41 @@ def run_action(manifest, arguments): device.run(host, device.DEFAULT_PORT, app_id) print_status_success("Running") -def install_action(manifest, arguments): +def install_package(host, package_path): + print_status_busy("Installing") + device = load_device_client() + device.install(host, device.DEFAULT_PORT, package_path) + print_status_success("Installing") + +def install_with_manifest_action(manifest, arguments): host = get_host(arguments) package_path = package_name(manifest) if not os.path.isfile(package_path): print_status_error(f"Package not found: {package_path} (run 'build' first)") return False - print_status_busy("Installing") - device = load_device_client() - device.install(host, device.DEFAULT_PORT, package_path) - print_status_success("Installing") + install_package(host, package_path) return True +def install_with_app_file_action(app_file, arguments): + host = get_host(arguments) + if not os.path.isfile(app_file): + print_status_error(f"App file not found: {app_file}") + return False + install_package(host, app_file) + return True + +def is_app_file(path): + return path.endswith(".app") + +def install_action(manifest, arguments): + app_file = arguments.get("app-file") + if app_file is None: + return install_with_manifest_action(manifest, arguments) + if not is_app_file(app_file): + print_status_error(f"Not an app file: {app_file}") + return False + return install_with_app_file_action(app_file, arguments) + def uninstall_action(manifest, arguments): host = get_host(arguments) app_id = get_package_id(manifest) @@ -778,6 +803,12 @@ if __name__ == "__main__": verbose = parsed_command.arguments.has("verbose") use_ccache = not parsed_command.arguments.has("no-ccache") + # Installing an app file doesn't need an app project + if parsed_command.arguments.has("app-file"): + if not install_action(None, parsed_command.arguments): + sys.exit(1) + sys.exit() + app_path = parsed_command.arguments.get("path") if app_path is not None: if not os.path.isdir(app_path) or not os.path.isfile(os.path.join(app_path, "manifest.properties")): diff --git a/Buildscripts/release-tools-current.py b/Buildscripts/release-tools-current.py index 9d772e6d8..f0bc5ce9a 100755 --- a/Buildscripts/release-tools-current.py +++ b/Buildscripts/release-tools-current.py @@ -1,10 +1,14 @@ #!/usr/bin/env python3 -"""Exports the tools that tt runs to release/sdk//tools/, for tt with TACTILITY_SDK_PATH=release.""" +"""Exports the tools that tt runs to release/sdk//tools/ and the new project to release/sdk//new-project.zip, for tt with TACTILITY_SDK_PATH=release.""" import os import shutil +import sys ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +sys.path.insert(0, os.path.join(ROOT_PATH, "Buildscripts", "CDN")) + +from new_project import create_new_project_zip TOOLS_PATH = os.path.join(ROOT_PATH, "Buildscripts", "TactilitySDK", "Tools") @@ -17,4 +21,6 @@ if __name__ == "__main__": for file_name in sorted(os.listdir(TOOLS_PATH)): if file_name.endswith(".py"): shutil.copy2(os.path.join(TOOLS_PATH, file_name), os.path.join(output_path, file_name)) + # Next to the tools, where tt looks for the new project of a local SDK + create_new_project_zip(os.path.dirname(output_path)) print(f"Exported the tools to {output_path}") diff --git a/Devices/cl32-v02-v03/cl32-v02-v03.dts b/Devices/cl32-v02-v03/cl32-v02-v03.dts index e507812c3..d39cb4d98 100644 --- a/Devices/cl32-v02-v03/cl32-v02-v03.dts +++ b/Devices/cl32-v02-v03/cl32-v02-v03.dts @@ -12,6 +12,7 @@ #include #include +#include / { compatible = "root"; @@ -79,7 +80,7 @@ frequency-khz = <20000>; }; - epd@1 { + display@1 { compatible = "solomon-systech,ssd16xx"; pin-dc = <&gpio0 13 GPIO_FLAG_NONE>; pin-reset = <&gpio0 12 GPIO_FLAG_NONE>; @@ -90,4 +91,28 @@ rotation = <1>; }; }; + + // Optional Mesh Card with a Seeed Wio-SX1262 module, detected at runtime. + // Schematic: https://github.com/CL-32/CL-32/tree/main/Add-on/CL-32-Mesh + spi2 { + compatible = "espressif,esp32-spi"; + host = ; + cs-gpios = <&gpio0 38 GPIO_FLAG_NONE>; + pin-mosi = <&gpio0 17 GPIO_FLAG_NONE>; + pin-miso = <&gpio0 16 GPIO_FLAG_NONE>; + pin-sclk = <&gpio0 18 GPIO_FLAG_NONE>; + + // The module is powered by the 3.3 V rail. DIO3 powers the TCXO (1.7 V to 3.3 V, at least 200 mV + // below VCC). DIO2 enables the TX path of the RF switch and RF_SW (GPIO 15) the RX path. + radio@0 { + compatible = "semtech,sx1262"; + reg = <0>; + pin-reset = <&gpio0 21 GPIO_FLAG_NONE>; + pin-dio1 = <&gpio0 39 GPIO_FLAG_NONE>; + pin-busy = <&gpio0 40 GPIO_FLAG_NONE>; + pin-rx-enable = <&gpio0 15 GPIO_FLAG_NONE>; + tcxo-millivolts = <1800>; + dio2-as-rf-switch; + }; + }; }; diff --git a/Devices/cl32-v02-v03/module.yaml b/Devices/cl32-v02-v03/module.yaml index f5d6e26ac..de558796d 100644 --- a/Devices/cl32-v02-v03/module.yaml +++ b/Devices/cl32-v02-v03/module.yaml @@ -3,4 +3,5 @@ dependencies: - Drivers/tca8418-module - Drivers/ssd16xx-module - Drivers/bm8563-module + - Drivers/sx126x-module dts: cl32-v02-v03.dts diff --git a/Devices/simulator/CMakeLists.txt b/Devices/simulator/CMakeLists.txt index f51dbb597..252d50438 100644 --- a/Devices/simulator/CMakeLists.txt +++ b/Devices/simulator/CMakeLists.txt @@ -16,6 +16,9 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION}) PRIVATE TactilityKernel PRIVATE TactilityFreeRtos PRIVATE lvgl + PRIVATE app-module + PRIVATE lvgl-module + PRIVATE lvgl-window-manager-module PRIVATE SDL2-static ) diff --git a/Devices/simulator/Source/Simulator.h b/Devices/simulator/Source/Simulator.h index 901f32cea..bf37f6277 100644 --- a/Devices/simulator/Source/Simulator.h +++ b/Devices/simulator/Source/Simulator.h @@ -1,6 +1,7 @@ #pragma once #include "Main.h" +#include "SimulatorProfile.h" #include "drivers/sdl_bridge.h" #include @@ -19,7 +20,8 @@ extern "C" { void app_main(); // ESP-IDF's main function, implemented in the application } -int main() { +int main(int, char** argv) { + simulator::setProcessArguments(argv); // The folder for the "data" file system is not in the repository mkdir("data", 0755); diff --git a/Devices/simulator/Source/SimulatorProfile.cpp b/Devices/simulator/Source/SimulatorProfile.cpp new file mode 100644 index 000000000..8ae7943ed --- /dev/null +++ b/Devices/simulator/Source/SimulatorProfile.cpp @@ -0,0 +1,228 @@ +#include "SimulatorProfile.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __APPLE__ +#include +#endif + +namespace simulator { + +constexpr auto* TAG = "SimulatorProfile"; +constexpr auto* PROFILE_DIRECTORY = "data/tactility"; +constexpr auto* PROFILE_PATH = "data/tactility/simulator_profile.txt"; +constexpr auto* DISPLAY_SETTINGS_DIRECTORY = "data/tactility/user/app/tactility.display"; +constexpr auto* DISPLAY_SETTINGS_PATH = "data/tactility/user/app/tactility.display/display.properties"; +constexpr auto* ORIENTATION_KEY = "orientation="; + +static const Profile PROFILES[PROFILE_COUNT] = { + { "Default", 640, 320, false, false, false, true, true }, + { "CL-32", 384, 168, false, true, true, true, false }, + { "LilyGO T-Deck", 320, 240, false, false, false, true, true }, + { "LilyGO T-Lora Pager", 480, 222, false, false, false, true, false }, + { "M5Stack Cardputer", 240, 135, false, true, false, true, false }, + // Half the res on each axis to compensate for DPI differences + { "M5Stack Tab5", 640, 360, true, false, false, true, true }, +}; + +static size_t activeProfileIndex = 0; +static OrientationOverride activeOrientation = OrientationOverride::Default; +static char** processArguments = nullptr; +/** Absolute, because argv[0] can be a bare name that was found through PATH. Empty when it can't be resolved. */ +static std::string executablePath; + +const Profile& getProfile(size_t index) { + return PROFILES[index < PROFILE_COUNT ? index : 0]; +} + +static const char* toString(OrientationOverride orientation) { + switch (orientation) { + case OrientationOverride::Landscape: + return "landscape"; + case OrientationOverride::Portrait: + return "portrait"; + default: + return "default"; + } +} + +/** The first line has the profile name, the optional second line the orientation */ +static void loadSelection(size_t& profileIndex, OrientationOverride& orientation) { + profileIndex = 0; + orientation = OrientationOverride::Default; + FILE* file = fopen(PROFILE_PATH, "r"); + if (file == nullptr) { + return; + } + char name[64] = {}; + char orientationText[16] = {}; + const bool read = fgets(name, sizeof(name), file) != nullptr; + if (read) { + fgets(orientationText, sizeof(orientationText), file); + } + fclose(file); + if (!read) { + return; + } + name[strcspn(name, "\r\n")] = '\0'; + orientationText[strcspn(orientationText, "\r\n")] = '\0'; + for (size_t i = 0; i < PROFILE_COUNT; i++) { + if (strcmp(PROFILES[i].name, name) == 0) { + profileIndex = i; + } + } + for (auto candidate : { OrientationOverride::Landscape, OrientationOverride::Portrait }) { + if (strcmp(toString(candidate), orientationText) == 0) { + orientation = candidate; + } + } +} + +size_t loadSelectedProfileIndex() { + size_t profileIndex; + OrientationOverride orientation; + loadSelection(profileIndex, orientation); + return profileIndex; +} + +OrientationOverride loadSelectedOrientation() { + size_t profileIndex; + OrientationOverride orientation; + loadSelection(profileIndex, orientation); + return orientation; +} + +static const char* toDisplaySettingsValue(OrientationOverride orientation) { + return orientation == OrientationOverride::Portrait ? "Portrait" : "Landscape"; +} + +/** Creates the folders of a path, like "mkdir -p" */ +static void makeDirectories(const std::string& path) { + for (size_t position = path.find('/'); position != std::string::npos; position = path.find('/', position + 1)) { + mkdir(path.substr(0, position).c_str(), 0755); + } + mkdir(path.c_str(), 0755); +} + +/** + * The Display settings apply their saved orientation to the display. Writes the override there, + * or removes the saved orientation so that the display's own orientation applies. + */ +static void applyOrientationToDisplaySettings(OrientationOverride orientation) { + std::string kept; + if (FILE* file = fopen(DISPLAY_SETTINGS_PATH, "r"); file != nullptr) { + char line[256]; + while (fgets(line, sizeof(line), file) != nullptr) { + if (strncmp(line, ORIENTATION_KEY, strlen(ORIENTATION_KEY)) != 0) { + kept += line; + } + } + fclose(file); + } + if (orientation != OrientationOverride::Default) { + kept += std::string(ORIENTATION_KEY) + toDisplaySettingsValue(orientation) + "\n"; + } else if (kept.empty() && access(DISPLAY_SETTINGS_PATH, F_OK) != 0) { + return; + } + + makeDirectories(DISPLAY_SETTINGS_DIRECTORY); + FILE* file = fopen(DISPLAY_SETTINGS_PATH, "w"); + if (file == nullptr) { + LOG_E(TAG, "Failed to write %s", DISPLAY_SETTINGS_PATH); + return; + } + fputs(kept.c_str(), file); + fclose(file); +} + +bool saveSelection(size_t profileIndex, OrientationOverride orientation) { + size_t savedProfileIndex; + OrientationOverride savedOrientation; + loadSelection(savedProfileIndex, savedOrientation); + const bool changed = profileIndex != savedProfileIndex || orientation != savedOrientation; + mkdir(PROFILE_DIRECTORY, 0755); + FILE* file = fopen(PROFILE_PATH, "w"); + if (file == nullptr) { + LOG_E(TAG, "Failed to write %s", PROFILE_PATH); + return false; + } + const bool written = fprintf(file, "%s\n%s\n", getProfile(profileIndex).name, toString(orientation)) > 0; + if (fclose(file) != 0 || !written) { + return false; + } + if (changed) { + applyOrientationToDisplaySettings(orientation); + } + return true; +} + +void initActiveProfile() { + loadSelection(activeProfileIndex, activeOrientation); + LOG_I(TAG, "Profile: %s, orientation: %s", getProfile(activeProfileIndex).name, toString(activeOrientation)); +} + +size_t getActiveProfileIndex() { + return activeProfileIndex; +} + +OrientationOverride getActiveOrientation() { + return activeOrientation; +} + +static std::string resolveExecutablePath(const char* argv0) { + char path[PATH_MAX]; +#ifdef __APPLE__ + uint32_t size = sizeof(path); + char resolved[PATH_MAX]; + if (_NSGetExecutablePath(path, &size) == 0 && realpath(path, resolved) != nullptr) { + return resolved; + } +#else + const ssize_t length = readlink("/proc/self/exe", path, sizeof(path) - 1); + if (length > 0) { + path[length] = '\0'; + return path; + } +#endif + // Only a path with a slash can be resolved without searching PATH + if (strchr(argv0, '/') != nullptr && realpath(argv0, path) != nullptr) { + return path; + } + return ""; +} + +void setProcessArguments(char** argv) { + processArguments = argv; + executablePath = resolveExecutablePath(argv[0]); +} + +void reboot() { + if (processArguments == nullptr) { + LOG_E(TAG, "Cannot reboot without process arguments"); + return; + } + // The FreeRTOS tick timer would outlive exec() and kill the new image with SIGALRM + itimerval previousTimer = {}; + itimerval stopTimer = {}; + setitimer(ITIMER_REAL, &stopTimer, &previousTimer); + if (!executablePath.empty()) { + execv(executablePath.c_str(), processArguments); + } else { + execvp(processArguments[0], processArguments); + } + LOG_E(TAG, "Failed to reboot: %s", strerror(errno)); + // This process keeps running, so it needs its ticks again + setitimer(ITIMER_REAL, &previousTimer, nullptr); +} + +} diff --git a/Devices/simulator/Source/SimulatorProfile.h b/Devices/simulator/Source/SimulatorProfile.h new file mode 100644 index 000000000..7000392dc --- /dev/null +++ b/Devices/simulator/Source/SimulatorProfile.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +#include +#include + +namespace simulator { + +extern const ::AppManifest profileAppManifest; + +struct Profile { + const char* name; + uint16_t width; + uint16_t height; + /** The default rotation: the display is taller than wide. The width and height values may be given in either order. */ + bool portrait; + /** Compact UI density */ + bool compact; + /** Greyscale display, which makes the monochrome theme apply */ + bool mono; + bool keyboard; + bool pointer; +}; + +constexpr size_t PROFILE_COUNT = 6; + +/** Overrides the default orientation of the selected profile */ +enum class OrientationOverride { + /** The orientation of the profile */ + Default, + Landscape, + Portrait +}; + +const Profile& getProfile(size_t index); + +/** Reads the selected profile and makes it the active one. Call once at startup. */ +void initActiveProfile(); + +/** The profile that this run uses */ +size_t getActiveProfileIndex(); + +/** The profile that the next run uses */ +size_t loadSelectedProfileIndex(); + +/** The orientation override that this run uses */ +OrientationOverride getActiveOrientation(); + +/** The orientation override that the next run uses */ +OrientationOverride loadSelectedOrientation(); + +/** + * Saves the profile and orientation for the next run. A change also sets the saved orientation of the Display settings: + * the override, or none for the default so that the profile's own orientation applies. + */ +bool saveSelection(size_t profileIndex, OrientationOverride orientation); + +void setProcessArguments(char** argv); + +/** Restarts the process. Returns only when that failed. */ +void reboot(); + +} diff --git a/Devices/simulator/Source/SimulatorProfileApp.cpp b/Devices/simulator/Source/SimulatorProfileApp.cpp new file mode 100644 index 000000000..7da2629f7 --- /dev/null +++ b/Devices/simulator/Source/SimulatorProfileApp.cpp @@ -0,0 +1,292 @@ +#include "SimulatorProfile.h" + +#include +#include +#include +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace simulator { + +namespace { + +constexpr auto* SELECTION_DIALOG_ID = "tactility.selectiondialog"; +constexpr const char* ORIENTATION_LABELS[] = { "Default", "Landscape", "Portrait" }; +constexpr int ORIENTATION_COUNT = 3; + +struct Context { + uint32_t appInstanceId = 0; + TaskEventGroup eventGroup {}; + uint32_t selectProfileBit = 0; + uint32_t rebootBit = 0; + uint32_t orientationBit = 0; + /** Set by the pills on the LVGL task, taken by the app's task. -1 when there is none. */ + std::atomic requestedOrientation { -1 }; + AppInstanceId dialogId = 0; + /** Last read from the profile file, which is not on the LVGL thread */ + size_t selectedIndex = 0; + OrientationOverride selectedOrientation = OrientationOverride::Default; + bool saveFailed = false; + + lv_obj_t* profileLabel = nullptr; + lv_obj_t* rebootButton = nullptr; + lv_obj_t* orientationChips = nullptr; + lv_obj_t* errorLabel = nullptr; +}; + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +void onSelectProfilePressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(&ctx->eventGroup, ctx->selectProfileBit); +} + +void onOrientationPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->requestedOrientation.store(static_cast(lv_obj_get_index(lv_event_get_target_obj(event)))); + task_event_group_signal(&ctx->eventGroup, ctx->orientationBit); +} + +void onRebootPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(&ctx->eventGroup, ctx->rebootBit); +} + +lv_obj_t* createRow(lv_obj_t* parent) { + auto* row = lv_obj_create(parent); + lv_obj_remove_style_all(row); + 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_scrollable(row, false); + return row; +} + +void updateWidgets(Context* ctx) { + if (ctx->profileLabel == nullptr) { + return; + } + lv_label_set_text(ctx->profileLabel, getProfile(ctx->selectedIndex).name); + const bool unchanged = ctx->selectedIndex == getActiveProfileIndex() && ctx->selectedOrientation == getActiveOrientation(); + lv_obj_set_state(ctx->rebootButton, LV_STATE_DISABLED, unchanged); + for (int i = 0; i < ORIENTATION_COUNT; i++) { + lv_obj_set_state(lv_obj_get_child(ctx->orientationChips, i), LV_STATE_CHECKED, i == static_cast(ctx->selectedOrientation)); + } + lv_obj_set_hidden(ctx->errorLabel, !ctx->saveFailed); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + 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, "Simulator"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + + auto* card = lvgl_card_create(wrapper); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + + auto* profile_row = createRow(card); + auto* profile_column = lv_obj_create(profile_row); + lv_obj_remove_style_all(profile_column); + lv_obj_set_height(profile_column, LV_SIZE_CONTENT); + lv_obj_set_flex_grow(profile_column, 1); + lv_obj_set_flex_flow(profile_column, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scrollable(profile_column, false); + + auto* title_label = lv_label_create(profile_column); + lv_label_set_text(title_label, "Profile"); + + ctx->profileLabel = lv_label_create(profile_column); + lv_obj_set_style_text_font(ctx->profileLabel, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); + + auto* list_button = lvgl_icon_button_create(profile_row); + auto* list_icon = lv_label_create(list_button); + lv_label_set_text(list_icon, LV_SYMBOL_LIST); + lv_obj_add_event_cb(list_button, onSelectProfilePressed, LV_EVENT_SHORT_CLICKED, ctx); + + auto* reboot_row = createRow(card); + auto* reboot_label = lv_label_create(reboot_row); + lv_label_set_text(reboot_label, "Reboot to apply"); + lv_obj_set_flex_grow(reboot_label, 1); + + ctx->rebootButton = lvgl_text_button_create(reboot_row); + auto* reboot_button_label = lv_label_create(ctx->rebootButton); + lv_label_set_text(reboot_button_label, "Reboot"); + lv_obj_add_event_cb(ctx->rebootButton, onRebootPressed, LV_EVENT_SHORT_CLICKED, ctx); + + ctx->errorLabel = lv_label_create(card); + lv_label_set_text(ctx->errorLabel, "Failed to save the profile"); + lv_obj_set_style_text_color(ctx->errorLabel, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT); + lv_obj_set_style_text_font(ctx->errorLabel, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); + + auto* orientation_title = lv_label_create(wrapper); + lv_label_set_text(orientation_title, "Orientation"); + + auto* orientation_card = lvgl_card_create(wrapper); + lv_obj_set_size(orientation_card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(orientation_card, LV_FLEX_FLOW_COLUMN); + + // The pills wrap onto the next row when they don't fit + ctx->orientationChips = lv_obj_create(orientation_card); + lv_obj_set_size(ctx->orientationChips, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->orientationChips, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_style_bg_opa(ctx->orientationChips, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->orientationChips, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(ctx->orientationChips, 0, LV_STATE_DEFAULT); + lv_obj_set_scrollable(ctx->orientationChips, false); + for (const auto* label : ORIENTATION_LABELS) { + auto* chip = lvgl_chip_create(ctx->orientationChips); + lv_obj_add_event_cb(chip, onOrientationPressed, LV_EVENT_SHORT_CLICKED, ctx); + lv_label_set_text(lv_label_create(chip), label); + } + // Only the container is in the input group: left and right move between the pills + lvgl_grid_navigation_add(ctx->orientationChips); + + updateWidgets(ctx); +} + +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->profileLabel = nullptr; + ctx->rebootButton = nullptr; + ctx->orientationChips = nullptr; + ctx->errorLabel = nullptr; +} + +void startProfileDialog(Context* ctx) { + if (ctx->dialogId != 0) { + return; + } + const char* argv[PROFILE_COUNT + 1] = { "Profile" }; + for (size_t i = 0; i < PROFILE_COUNT; i++) { + argv[i + 1] = getProfile(i).name; + } + AppStartContext start_context; + if (app_start_context_from_id(SELECTION_DIALOG_ID, &start_context) != ERROR_NONE) { + return; + } + app_start_context_set_arguments_ext(&start_context, PROFILE_COUNT + 1, argv); + app_start_context_set_parent(&start_context, ctx->appInstanceId); + app_start_with_context(&start_context, &ctx->dialogId); +} + +/** A failed save keeps the selection that the next start loads */ +void saveAndShow(Context* ctx, size_t profileIndex, OrientationOverride orientation) { + ctx->saveFailed = !saveSelection(profileIndex, orientation); + if (!ctx->saveFailed) { + ctx->selectedIndex = profileIndex; + ctx->selectedOrientation = orientation; + } + lvgl_lock(); + updateWidgets(ctx); + lvgl_unlock(); +} + +// Other results than an index mean that the dialog was dismissed +void onDialogResult(Context* ctx, int32_t selected) { + ctx->dialogId = 0; + if (selected < 0 || selected >= static_cast(PROFILE_COUNT)) { + return; + } + // A new profile starts with its own orientation + saveAndShow(ctx, selected, OrientationOverride::Default); +} + +void onOrientationRequest(Context* ctx, int requested) { + if (requested < 0 || requested >= ORIENTATION_COUNT) { + return; + } + saveAndShow(ctx, ctx->selectedIndex, static_cast(requested)); +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx; + ctx.appInstanceId = app_scheduler_current_app_id(); + ctx.selectedIndex = loadSelectedProfileIndex(); + ctx.selectedOrientation = loadSelectedOrientation(); + + task_event_group_construct(&ctx.eventGroup); + check(task_event_group_claim_bit(&ctx.eventGroup, &ctx.selectProfileBit) == ERROR_NONE); + check(task_event_group_claim_bit(&ctx.eventGroup, &ctx.rebootBit) == ERROR_NONE); + check(task_event_group_claim_bit(&ctx.eventGroup, &ctx.orientationBit) == ERROR_NONE); + + AppEventSubscription sub {}; + check(app_event_subscribe(&sub, &ctx.eventGroup) == ERROR_NONE); + + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + uint32_t flags = 0; + task_event_group_wait_any(&ctx.eventGroup, &flags, portMAX_DELAY); + + if (flags & ctx.selectProfileBit) { + startProfileDialog(&ctx); + } + if (flags & ctx.orientationBit) { + onOrientationRequest(&ctx, ctx.requestedOrientation.exchange(-1)); + } + if ((flags & ctx.rebootBit) && (ctx.selectedIndex != getActiveProfileIndex() || ctx.selectedOrientation != getActiveOrientation())) { + reboot(); + } + + AppEvent event {}; + while (app_event_poll(&sub, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + if (event.type == APP_EVENT_RESULT) { + if (event.result.launch_id == ctx.dialogId) { + onDialogResult(&ctx, event.result.result); + } + app_manager_stop(event.result.launch_id); + } + } + } + + window_manager_remove(window); + check(app_event_unsubscribe(&sub) == ERROR_NONE); + task_event_group_destruct(&ctx.eventGroup); + return 0; +} + +} // namespace + +extern const ::AppManifest profileAppManifest = { + .id = "tactility.simulator", + .name = "Simulator", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = 0, + .stack = {} +}; + +} diff --git a/Devices/simulator/Source/drivers/sdl_display.cpp b/Devices/simulator/Source/drivers/sdl_display.cpp index 1c1fa2da0..dd6ff7a7e 100644 --- a/Devices/simulator/Source/drivers/sdl_display.cpp +++ b/Devices/simulator/Source/drivers/sdl_display.cpp @@ -10,27 +10,82 @@ #include +#include #include +#include +#include constexpr auto* TAG = "SdlDisplay"; #define GET_CONFIG(device) (static_cast((device)->config)) struct SdlDisplayInternal { - bool initialized; - bool init_failed; - SDL_Window* window; - SDL_Renderer* renderer; - SDL_Texture* texture; + bool initialized = false; + bool init_failed = false; + SDL_Window* window = nullptr; + SDL_Renderer* renderer = nullptr; + SDL_Texture* texture = nullptr; + // The window shows the oriented view: with swap_xy its width and height are swapped. + // The requested state is set by the LVGL task when it rotates the display. The shown state is + // only touched by the real main thread, which applies the request before it draws. + std::atomic requested_swap_xy { false }; + std::atomic requested_mirror_x { false }; + std::atomic requested_mirror_y { false }; + bool shown_swap_xy = false; + const SdlDisplayConfig* config = nullptr; }; // Only one sdl-display device exists in the simulator; sdl_input.cpp uses this to map window // coordinates back to the fixed logical resolution SDL_RenderSetLogicalSize() scales to the window. static SdlDisplayInternal* g_display_internal = nullptr; +// The size of the oriented view that the window shows +static void sdl_display_compute_view_size(const SdlDisplayConfig* config, bool swap_xy, int* width, int* height) { + *width = swap_xy ? config->vertical_resolution : config->horizontal_resolution; + *height = swap_xy ? config->horizontal_resolution : config->vertical_resolution; +} + SDL_Renderer* sdl_display_get_renderer(void) { return g_display_internal != nullptr ? g_display_internal->renderer : nullptr; } +bool sdl_display_get_view_size(int* width, int* height) { + SdlDisplayInternal* internal = g_display_internal; + if (internal == nullptr || internal->config == nullptr) { + return false; + } + sdl_display_compute_view_size(internal->config, internal->requested_swap_xy.load(), width, height); + return true; +} + +// LVGL turns a native position (px, py) into a position in the rotated view (lx, ly), with W and H +// the native size: 90 degrees is (H - 1 - py, px), 180 is (W - 1 - px, H - 1 - py) and 270 is +// (py, W - 1 - px). This is the inverse. The rotation follows from the axes that LVGL asked the +// display to swap and mirror for it (see lvgl_display_apply_rotation()). +void sdl_display_view_to_native(int32_t* x, int32_t* y) { + SdlDisplayInternal* internal = g_display_internal; + if (internal == nullptr || internal->config == nullptr) { + return; + } + const int32_t width = internal->config->horizontal_resolution; + const int32_t height = internal->config->vertical_resolution; + const int32_t view_x = *x; + const int32_t view_y = *y; + if (internal->requested_swap_xy.load()) { + if (internal->requested_mirror_x.load()) { + // 270 degrees + *x = width - 1 - view_y; + *y = view_x; + } else { + // 90 degrees + *x = view_y; + *y = height - 1 - view_x; + } + } else if (internal->requested_mirror_x.load() && internal->requested_mirror_y.load()) { + *x = width - 1 - view_x; + *y = height - 1 - view_y; + } +} + // Re-blits the already-drawn texture at the renderer's current (possibly just-resized) scale. // No new pixel data needed: the window resizing doesn't change what LVGL last rendered, only how // large it should appear, and SDL only applies that until the next SDL_RenderPresent() call. @@ -46,17 +101,12 @@ void sdl_display_present_now(void) { // region Driver lifecycle static error_t start(Device* device) { - auto* internal = static_cast(malloc(sizeof(SdlDisplayInternal))); + auto* internal = new(std::nothrow) SdlDisplayInternal(); if (internal == nullptr) { return ERROR_OUT_OF_MEMORY; } - internal->initialized = false; - internal->init_failed = false; - internal->window = nullptr; - internal->renderer = nullptr; - internal->texture = nullptr; - + internal->config = GET_CONFIG(device); device_set_driver_data(device, internal); return ERROR_NONE; } @@ -75,7 +125,7 @@ static error_t stop(Device* device) { g_display_internal = nullptr; } - free(internal); + delete internal; device_set_driver_data(device, nullptr); return ERROR_NONE; } @@ -107,8 +157,11 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal) // (config->horizontal_resolution/vertical_resolution, used below for the texture and // SDL_RenderSetLogicalSize()) is unaffected, same as any other resize the user does by hand. const float dpi_scale = sdl_display_get_dpi_scale(); - const int initial_width = static_cast(config->horizontal_resolution * dpi_scale); - const int initial_height = static_cast(config->vertical_resolution * dpi_scale); + internal->shown_swap_xy = internal->requested_swap_xy.load(); + int view_width, view_height; + sdl_display_compute_view_size(config, internal->shown_swap_xy, &view_width, &view_height); + const int initial_width = static_cast(view_width * dpi_scale); + const int initial_height = static_cast(view_height * dpi_scale); internal->window = SDL_CreateWindow( "Tactility", @@ -122,11 +175,10 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal) // Lets the window be resized freely while the renderer scales/letterboxes the fixed-resolution // texture below to fit - LVGL keeps rendering at horizontal_resolution x vertical_resolution. if (internal->renderer != nullptr) { - SDL_RenderSetLogicalSize(internal->renderer, config->horizontal_resolution, config->vertical_resolution); + SDL_RenderSetLogicalSize(internal->renderer, view_width, view_height); } internal->texture = internal->renderer != nullptr - ? SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING, - config->horizontal_resolution, config->vertical_resolution) + ? SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING, view_width, view_height) : nullptr; if (internal->window == nullptr || internal->renderer == nullptr || internal->texture == nullptr) { @@ -142,6 +194,34 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal) return true; } +// Swaps the width and height of the view when swap_xy changed: the window turns from landscape to +// portrait or back, and keeps its zoom. Only called on the real main thread. Mirroring needs no +// pixel work: the window shows the view as the user sees it, which is already upright. +static bool sdl_display_apply_orientation(const SdlDisplayConfig* config, SdlDisplayInternal* internal) { + const bool swap_xy = internal->requested_swap_xy.load(); + if (swap_xy == internal->shown_swap_xy) { + return true; + } + + int view_width, view_height; + sdl_display_compute_view_size(config, swap_xy, &view_width, &view_height); + SDL_Texture* texture = SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING, view_width, view_height); + if (texture == nullptr) { + LOG_E(TAG, "Failed to create the rotated texture: %s", SDL_GetError()); + return false; + } + SDL_DestroyTexture(internal->texture); + internal->texture = texture; + internal->shown_swap_xy = swap_xy; + + int window_width, window_height; + SDL_GetWindowSize(internal->window, &window_width, &window_height); + SDL_RenderSetLogicalSize(internal->renderer, view_width, view_height); + // Triggers SDL_WINDOWEVENT_SIZE_CHANGED, which presents the (empty) view until LVGL redraws it + SDL_SetWindowSize(internal->window, window_height, window_width); + return true; +} + // Only ever called from sdl_bridge_run_main_loop() on the real main thread - required for // SDL/Cocoa window creation and rendering on macOS. error_t sdl_display_execute_draw_bitmap(Device* device, void* internal_ptr, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) { @@ -159,9 +239,23 @@ error_t sdl_display_execute_draw_bitmap(Device* device, void* internal_ptr, int3 internal->initialized = true; } + if (!sdl_display_apply_orientation(GET_CONFIG(device), internal)) { + return ERROR_RESOURCE; + } + SDL_Rect rect = { x_start, y_start, x_end - x_start, y_end - y_start }; - // RGB565 = 2 bytes/pixel. - if (SDL_UpdateTexture(internal->texture, &rect, color_data, (x_end - x_start) * 2) != 0) { + // The texture is always RGB565 = 2 bytes/pixel. + std::vector converted; + if (GET_CONFIG(device)->grayscale) { + const auto* luminance = static_cast(color_data); + converted.resize(static_cast(rect.w) * rect.h); + for (size_t i = 0; i < converted.size(); i++) { + const uint16_t value = luminance[i]; + converted[i] = ((value >> 3) << 11) | ((value >> 2) << 5) | (value >> 3); + } + color_data = converted.data(); + } + if (SDL_UpdateTexture(internal->texture, &rect, color_data, rect.w * 2) != 0) { return ERROR_RESOURCE; } @@ -179,8 +273,33 @@ static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t return sdl_bridge_present(device, internal, x_start, y_start, x_end, y_end, color_data); } -static enum DisplayColorFormat sdl_display_get_color_format(Device*) { - return DISPLAY_COLOR_FORMAT_RGB565; +static error_t sdl_display_mirror(Device* device, bool x_axis, bool y_axis) { + auto* internal = static_cast(device_get_driver_data(device)); + internal->requested_mirror_x.store(x_axis); + internal->requested_mirror_y.store(y_axis); + return ERROR_NONE; +} + +static error_t sdl_display_swap_xy(Device* device, bool swap_axes) { + auto* internal = static_cast(device_get_driver_data(device)); + internal->requested_swap_xy.store(swap_axes); + return ERROR_NONE; +} + +static bool sdl_display_get_swap_xy(Device* device) { + return static_cast(device_get_driver_data(device))->requested_swap_xy.load(); +} + +static bool sdl_display_get_mirror_x(Device* device) { + return static_cast(device_get_driver_data(device))->requested_mirror_x.load(); +} + +static bool sdl_display_get_mirror_y(Device* device) { + return static_cast(device_get_driver_data(device))->requested_mirror_y.load(); +} + +static enum DisplayColorFormat sdl_display_get_color_format(Device* device) { + return GET_CONFIG(device)->grayscale ? DISPLAY_COLOR_FORMAT_GRAYSCALE8 : DISPLAY_COLOR_FORMAT_RGB565; } static uint16_t sdl_display_get_resolution_x(Device* device) { @@ -194,15 +313,15 @@ static uint16_t sdl_display_get_resolution_y(Device* device) { // endregion static const DisplayApi sdl_display_api = { - .capabilities = 0, + .capabilities = DISPLAY_CAPABILITY_CAP_MIRROR | DISPLAY_CAPABILITY_CAP_SWAP_XY, .reset = sdl_display_reset, .init = sdl_display_init, .draw_bitmap = sdl_display_draw_bitmap, - .mirror = nullptr, - .swap_xy = nullptr, - .get_swap_xy = nullptr, - .get_mirror_x = nullptr, - .get_mirror_y = nullptr, + .mirror = sdl_display_mirror, + .swap_xy = sdl_display_swap_xy, + .get_swap_xy = sdl_display_get_swap_xy, + .get_mirror_x = sdl_display_get_mirror_x, + .get_mirror_y = sdl_display_get_mirror_y, .set_gap = nullptr, .get_gap_x = nullptr, .get_gap_y = nullptr, diff --git a/Devices/simulator/Source/drivers/sdl_display.h b/Devices/simulator/Source/drivers/sdl_display.h index cb5a0c7b0..8f1aab76e 100644 --- a/Devices/simulator/Source/drivers/sdl_display.h +++ b/Devices/simulator/Source/drivers/sdl_display.h @@ -12,6 +12,8 @@ extern "C" { struct SdlDisplayConfig { uint16_t horizontal_resolution; uint16_t vertical_resolution; + /** Frames are 8 bit luminance instead of RGB565 */ + bool grayscale; }; /** @@ -21,6 +23,22 @@ struct SdlDisplayConfig { */ SDL_Renderer* sdl_display_get_renderer(void); +/** + * @brief Gets the size of the oriented view that the window shows: the display's resolution, with the width and + * height swapped while swap_xy is on. It follows a rotation right away, before the window is redrawn. + * @retval false when there is no display + */ +bool sdl_display_get_view_size(int* width, int* height); + +/** + * @brief Converts a position in the oriented view that the window shows to the display's native position, + * the position with the display's rotation undone. LVGL rotates pointer positions from native to the + * rotated view itself, so pointers must report native positions. + * @param[in,out] x the horizontal position + * @param[in,out] y the vertical position + */ +void sdl_display_view_to_native(int32_t* x, int32_t* y); + /** * @brief Re-presents the already-drawn frame at the renderer's current scale. Call this when the * window is resized: the window size change alone doesn't make SDL re-blit the last frame at diff --git a/Devices/simulator/Source/drivers/sdl_input.cpp b/Devices/simulator/Source/drivers/sdl_input.cpp index 0b056d70c..4dba360fe 100644 --- a/Devices/simulator/Source/drivers/sdl_input.cpp +++ b/Devices/simulator/Source/drivers/sdl_input.cpp @@ -6,6 +6,7 @@ #include +#include #include #include @@ -17,7 +18,10 @@ constexpr size_t KEY_QUEUE_CAPACITY = 32; // sdl_input_pop_key()/sdl_input_has_queued_key() on the lvgl task. std::mutex state_mutex; +// The position in window coordinates, and the size of the window then SdlPointerState pointer_state = { 0, 0, false }; +int window_width = 0; +int window_height = 0; uint32_t key_queue[KEY_QUEUE_CAPACITY]; size_t key_queue_head = 0; @@ -42,19 +46,33 @@ void push_key(uint32_t key) { // arrive separately via SDL_TEXTINPUT. // The window can be freely resized (see sdl_display.cpp's SDL_WINDOW_RESIZABLE/ // SDL_RenderSetLogicalSize()), so raw SDL mouse coordinates are in window-pixel space, not the -// fixed logical resolution LVGL renders at. SDL_RenderWindowToLogical() is the renderer's own -// inverse of that scaling, accounting for both the scale factor and any letterbox offset. +// resolution LVGL renders at. The view also changes shape when the display rotates. The raw +// position is kept with the window size, and sdl_input_get_pointer_state() maps it onto the +// current view, so a rotation takes effect right away even when the mouse doesn't move. +// LVGL rotates pointer positions from the display's native orientation to the rotated view itself, +// so the position in the view is turned back to a native one at the end. +// The view is scaled to fit the window and centered, like SDL_RenderSetLogicalSize() does. void set_pointer_position(int32_t window_x, int32_t window_y) { + pointer_state.x = window_x; + pointer_state.y = window_y; SDL_Renderer* renderer = sdl_display_get_renderer(); - if (renderer == nullptr) { - pointer_state.x = window_x; - pointer_state.y = window_y; + SDL_Window* window = renderer != nullptr ? SDL_RenderGetWindow(renderer) : nullptr; + if (window != nullptr) { + SDL_GetWindowSize(window, &window_width, &window_height); + } +} + +void map_to_view(int32_t* x, int32_t* y) { + int view_width, view_height; + if (window_width <= 0 || window_height <= 0 || !sdl_display_get_view_size(&view_width, &view_height) || view_width <= 0 || view_height <= 0) { return; } - float logical_x, logical_y; - SDL_RenderWindowToLogical(renderer, window_x, window_y, &logical_x, &logical_y); - pointer_state.x = static_cast(logical_x); - pointer_state.y = static_cast(logical_y); + const float scale = std::min(static_cast(window_width) / view_width, static_cast(window_height) / view_height); + const float offset_x = (window_width - view_width * scale) / 2.0f; + const float offset_y = (window_height - view_height * scale) / 2.0f; + *x = std::clamp(static_cast((*x - offset_x) / scale), 0, view_width - 1); + *y = std::clamp(static_cast((*y - offset_y) / scale), 0, view_height - 1); + sdl_display_view_to_native(x, y); } uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) { @@ -126,6 +144,8 @@ void sdl_input_pump() { // appear - re-present the existing frame at the new scale immediately, rather // than leaving stale-looking content on screen until the next LVGL-driven flush. if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) { + window_width = event.window.data1; + window_height = event.window.data2; sdl_display_present_now(); } break; @@ -146,6 +166,7 @@ void sdl_input_pump() { void sdl_input_get_pointer_state(SdlPointerState* out_state) { std::lock_guard lock(state_mutex); *out_state = pointer_state; + map_to_view(&out_state->x, &out_state->y); } bool sdl_input_pop_key(uint32_t* out_key) { diff --git a/Devices/simulator/Source/module.cpp b/Devices/simulator/Source/module.cpp index 3ca419ccf..cea09c7e2 100644 --- a/Devices/simulator/Source/module.cpp +++ b/Devices/simulator/Source/module.cpp @@ -1,5 +1,10 @@ +#include "SimulatorProfile.h" #include "drivers/sdl_display.h" +#include + +#include + #include #include #include @@ -7,6 +12,7 @@ #include #include +#include #include constexpr auto* TAG = "Simulator"; @@ -29,7 +35,7 @@ static Driver* const simulator_drivers[] = { // These devices have no real bus to attach to (SDL has no notion of one), but every non-root // device is still expected to have a parent (see Device::parent) - they're parented to root once // it's available below. -static const SdlDisplayConfig sdl_display_config = { 640, 320 }; +static SdlDisplayConfig sdl_display_config = {}; static Device sdl_display_device {}; static Device sdl_pointer_device {}; static Device sdl_keyboard_device {}; @@ -81,20 +87,40 @@ static void on_root_started(Device* device, DeviceEvent event, void* context) { return; } + const auto& profile = simulator::getProfile(simulator::getActiveProfileIndex()); + const uint16_t short_side = std::min(profile.width, profile.height); + const uint16_t long_side = std::max(profile.width, profile.height); + sdl_display_config = { + profile.portrait ? short_side : long_side, + profile.portrait ? long_side : short_side, + profile.mono + }; + construct_add_start(&sdl_display_device, device, "display0", &sdl_display_config, "tactility,sdl-display"); - construct_add_start(&sdl_pointer_device, device, "pointer0", nullptr, "tactility,sdl-pointer"); - construct_add_start(&sdl_keyboard_device, device, "keyboard0", nullptr, "tactility,sdl-keyboard"); + if (profile.pointer) { + construct_add_start(&sdl_pointer_device, device, "pointer0", nullptr, "tactility,sdl-pointer"); + } + if (profile.keyboard) { + construct_add_start(&sdl_keyboard_device, device, "keyboard0", nullptr, "tactility,sdl-keyboard"); + } } extern "C" { static error_t start() { + simulator::initActiveProfile(); + lvgl_theme_set_default_compact(simulator::getProfile(simulator::getActiveProfileIndex()).compact); + error_t error = app_manager_add(&simulator::profileAppManifest); + if (error != ERROR_NONE) { + return error; + } device_listener_add(on_root_started, nullptr); return ERROR_NONE; } static error_t stop() { device_listener_remove(on_root_started, nullptr); + app_manager_remove(simulator::profileAppManifest.id); return ERROR_NONE; } diff --git a/Devices/simulator/simulator.dts b/Devices/simulator/simulator.dts index 44677e32c..c02d535a6 100644 --- a/Devices/simulator/simulator.dts +++ b/Devices/simulator/simulator.dts @@ -3,6 +3,7 @@ #include #include #include +#include / { model = "Simulator"; @@ -15,4 +16,8 @@ radio0 { compatible = "mock,radio"; }; + + bluetooth0 { + compatible = "mock,bluetooth"; + }; }; diff --git a/Documentation/ideas.md b/Documentation/ideas.md index cb8309756..106bc5603 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,19 +7,11 @@ ## Higher Priority -- Move EspNowRadio etc to Platforms/platform-esp32/ +- Move TactilityKernelCpp project as C++ include folder into TactilityKernel. This way, the kernel itself can use the C++ templates internally. And it's a library less. - GPIO app: fix for monochrome display by using filled vs outline box in secondary colour - BlueTooth as radio backend for MeshCore/Meshtastic? - Find out why the first build of an app with a clean build cache folder always fails -- Fix BT: an esp32 device connecting to a peripheral isn't working -- "update all" functionality for app hub module, including commandline app. add button to app hub toolbar if there are 1 or more apps to update (does "update all"). -- Improve Wi-Fi mock so it actually works with connect/disconnect/scan list. It connects to literally anything from the scan list. -- Support http/https on posix - Use GPS time to set/update the current time: when enabled, at boot, when wifi is not enabled, start the gps device and wait until a lock is acquired, then get the time and sync it. Then stop the GPS device. -- lvgl-module's spinner relies on hard-coded spinner asset from Tactility main project, should be sized based on DPI -- Cursor is hard-coded, needs size based on DPI -- Move Bluetooth code from kernel to Modules/bluetooth-module, and have includes in path bluetooth/ then make a bluetooth_command.cpp like wifi_command.cpp -- Inspect code for memory improvements: replace `std::*` with `tt::std::*` - Improve posix support: use user home path and system /tmp folder. - NimBLE looses pairing key after reboot - Pick different font to improve monochrome screen readability (or change the weight). @@ -27,18 +19,22 @@ - Improve SPI kernel driver (implement read, write, transactions) - External app loading: Check the version of Tactility and check ESP target hardware to check for compatibility Check during installation process, but also when starting (SD card might have old app install from before Tactility OS update) -- Support direct installation of an `.app` file with `tt app install --app helloworld.app` - minitar/untarFile(): "entry->metadata.path" can escape its confined path (e.g. "../something") - xl9555 and tca9534 drivers: support `GPIO_FLAG_ACTIVE_LOW` on outputs in software, like tca95xx does. Afterwards the raw level reset handling in cst66xx and tca8418 can use ACTIVE_LOW too. ## Medium Priority +- Try https://github.com/bootdsc/Fastwave-Eink-Driver with CL-32 e-paper. +- Power Off with e-paper should trigger full refresh before power off. +- Support hot-plugging SD card (note: might be problematic with shared SPI bus) +- Inspect code for memory improvements: replace `std::*` with `tt::std::*` +- Tactility/Source/bluetooth move into bluetooth module, make it work generically for all bluetooth apis +- DisplayApi: resolution x/y shouldn't be functions but struct values - Tactility/Source/app/imageviewer/ImageViewer.cpp: Limit memory used to open an image. loadFile() now resizes fileData to the entire file size before displaying the image. start accepts a file path without a size limit, so opening a large image can exhaust memory on an embedded device before LVGL decodes it. Reject files above a supported limit or retain a bounded file-backed decoding path. - A device profile selection screen for Simulator: select device profiles such as T-Lora Pager, T-Deck Plus, CL-32, Cardputer and T-Embed which changes the resolution and theming defaults after restarting. - A fullscreen tiny keyboard that works with single stroke gestures (similar to Palm OS Graffiti) -- CL-32 display driver performance - Move more window management code from Tactility/ to window-manager - Implement signal/raise for terminal apps (for CTRL+C and window resize signals) - Build the simulator with `-fno-exceptions` to match ESP32. First make app exit() work without C++ unwinding there: @@ -52,8 +48,6 @@ - `struct Driver` has an `.owner`, but it's not always set. Either validate on Module construct that it matches, or otherwise set it during module start. The problem: NULL parent currently means that driver is not removable. This clashes with setting it dynamically. Consider some kind of flag to determine removability. - Consider moving certain drivers into separate modules: audio, bt, wifi, etc - Consider using https://github.com/Graphify-Labs/graphify -- Consider implementing LVGL gridnav in apps https://lvgl.io/docs/open/9.3/details/auxiliary-modules/gridnav.html -- Refactor HttpServer into C code and move implementation to http-module - Make a URL handler. Use it for handling local files. Match file types with apps. Create some kind of "intent" handler like on Android. The intent can have an action (e.g. view), a URL and an optional bundle. @@ -72,25 +66,20 @@ Make a separate group that is the default group. The keyboard can then use it (or use its own). The basic idea is to invert the ownership: now the keyboard group is made the default group, but it's probably more logical to have the default group used by the keyboard. - Localize all apps -- Support hot-plugging SD card (note: this is not possible if they require the CS pin hack) - Explore LVGL9's FreeRTOS functionality -- Consider using non_null (either via MS GSL, or custom) -- Fix system time to not be 1980 (use build year as a minimum). Consider keeping track of the last known time. +- Fix system time to not be 1980 (use build year january 1st as a minimum). - Use std::span or string_view in StringUtils https://youtu.be/FRkJCvHWdwQ?t=2754 - Mutex: Implement give/take from ISR support (works only for non-recursive ones) - Show a warning screen if firmware encryption or secure boot are off when saving WiFi credentials. - Calculator app should show regular text input field on non-touch devices that have a keyboard (Cardputer, T-Lora Pager) -- Allow for WSAD keys to navigate LVGL (this is extra nice for cardputer, but just handy in general) - Make WiFi setup app that starts an access point and hosts a webpage to set up the device. This will be useful for devices without a screen, a small screen or a non-touch screen. # Nice-to-haves -- Considering the lack of callstack debugging for external apps: allow for some debugging to be exposed during a device crash. Apps could report their state (e.g. an integer value) which can be stored during app operation and retrieve after crash. The same can be done for various OS apps and states. We can keep an array of these numbers to keep track of the last X states, to get an idea of what's going on. - Audio player app - Audio recording app - OTA updates -- If present, use LED to show boot/wifi status # App Ideas diff --git a/Drivers/mock-drivers-module/bindings/mock,bluetooth.yaml b/Drivers/mock-drivers-module/bindings/mock,bluetooth.yaml new file mode 100644 index 000000000..5f0df012e --- /dev/null +++ b/Drivers/mock-drivers-module/bindings/mock,bluetooth.yaml @@ -0,0 +1,8 @@ +description: Mock Bluetooth driver with a fixed list of nearby peers + +compatible: "mock,bluetooth" + +properties: + _unused: + type: int + default: 0 diff --git a/Drivers/mock-drivers-module/include/bindings/mock_bluetooth.h b/Drivers/mock-drivers-module/include/bindings/mock_bluetooth.h new file mode 100644 index 000000000..fb1558871 --- /dev/null +++ b/Drivers/mock-drivers-module/include/bindings/mock_bluetooth.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +DEFINE_DEVICETREE(bluetooth, struct MockBluetoothConfig) diff --git a/Drivers/mock-drivers-module/include/mock/mock_bluetooth.h b/Drivers/mock-drivers-module/include/mock/mock_bluetooth.h new file mode 100644 index 000000000..9a4d24a12 --- /dev/null +++ b/Drivers/mock-drivers-module/include/mock/mock_bluetooth.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +struct MockBluetoothConfig { + int _unused; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/mock-drivers-module/source/mock_bluetooth.cpp b/Drivers/mock-drivers-module/source/mock_bluetooth.cpp new file mode 100644 index 000000000..703ae8c08 --- /dev/null +++ b/Drivers/mock-drivers-module/source/mock_bluetooth.cpp @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Mock Bluetooth driver for the simulator: fakes a radio with a fixed list of nearby peers +// instead of talking to real hardware. Actions succeed instantly, there's no real scan, pairing +// or connection delay. + +#include +#include +#include +#include +#include + +#include +#include + +#define TAG "mock_bluetooth" + +namespace { + +struct MockPeerFixture { + BtAddr addr; + const char* name; + int8_t rssi; +}; + +constexpr MockPeerFixture MOCK_PEERS[] = { + { { 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 }, "Mock Keyboard", -45 }, + { { 0x02, 0x00, 0x00, 0x00, 0x00, 0x02 }, "Mock Gamepad", -60 }, + { { 0x02, 0x00, 0x00, 0x00, 0x00, 0x03 }, "Mock Speaker", -75 }, +}; +constexpr size_t MOCK_PEER_COUNT = sizeof(MOCK_PEERS) / sizeof(MOCK_PEERS[0]); +constexpr const char* MOCK_DEFAULT_NAME = "Tactility"; + +struct MockPeerState { + bool paired = false; + /** Bit per BtProfileId */ + uint8_t connectedProfiles = 0; +}; + +struct MockBluetoothCtx { + Device* device = nullptr; + + Mutex mutex {}; + BtRadioState radioState = BT_RADIO_STATE_OFF; + bool scanning = false; + bool hidHostActive = false; + char deviceName[BLE_DEVICE_NAME_MAX + 1] = {}; + MockPeerState peers[MOCK_PEER_COUNT]; + + Mutex subscriptionsMutex {}; + BtEventSubscription* subscriptions = nullptr; +}; + +#define GET_CTX(device) (static_cast(device_get_driver_data(device))) + +void fireEvent(MockBluetoothCtx* ctx, const BtEvent& event) { + mutex_lock(&ctx->subscriptionsMutex); + for (BtEventSubscription* sub = ctx->subscriptions; sub != nullptr; sub = sub->internal.next) { + mutex_lock(&sub->internal.ring_mutex); + if (sub->internal.count < BT_EVENT_QUEUE_CAPACITY) { + uint8_t tail = (sub->internal.head + sub->internal.count) % BT_EVENT_QUEUE_CAPACITY; + sub->internal.queue[tail] = event; + sub->internal.count++; + } + mutex_unlock(&sub->internal.ring_mutex); + task_event_group_signal(sub->internal.event_group, sub->bit); + } + mutex_unlock(&ctx->subscriptionsMutex); +} + +// Returns the index of the peer, or MOCK_PEER_COUNT when the address isn't a mock peer +size_t findPeer(const BtAddr addr) { + for (size_t i = 0; i < MOCK_PEER_COUNT; i++) { + if (memcmp(MOCK_PEERS[i].addr, addr, BT_ADDR_LEN) == 0) { + return i; + } + } + return MOCK_PEER_COUNT; +} + +// Caller holds ctx->mutex +void fillPeerRecord(const MockBluetoothCtx* ctx, size_t index, BtPeerRecord* record) { + memset(record, 0, sizeof(*record)); + memcpy(record->addr, MOCK_PEERS[index].addr, BT_ADDR_LEN); + strncpy(record->name, MOCK_PEERS[index].name, BT_NAME_MAX); + record->rssi = MOCK_PEERS[index].rssi; + record->paired = ctx->peers[index].paired; + record->connected = ctx->peers[index].connectedProfiles != 0; +} + +void fireRadioState(MockBluetoothCtx* ctx, BtRadioState state) { + BtEvent event = {}; + event.type = BT_EVENT_RADIO_STATE_CHANGED; + event.radio_state = state; + fireEvent(ctx, event); +} + +void fireProfileState(MockBluetoothCtx* ctx, size_t index, BtProfileId profile, BtProfileState state) { + BtEvent event = {}; + event.type = BT_EVENT_PROFILE_STATE_CHANGED; + memcpy(event.profile_state.addr, MOCK_PEERS[index].addr, BT_ADDR_LEN); + event.profile_state.profile = profile; + event.profile_state.state = state; + fireEvent(ctx, event); +} + +void fireConnectState(MockBluetoothCtx* ctx, size_t index) { + BtEvent event = {}; + event.type = BT_EVENT_CONNECT_STATE_CHANGED; + mutex_lock(&ctx->mutex); + fillPeerRecord(ctx, index, &event.peer); + mutex_unlock(&ctx->mutex); + fireEvent(ctx, event); +} + +// ---- BluetoothApi ---- + +error_t apiGetRadioState(Device* device, BtRadioState* state) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || state == nullptr) return ERROR_INVALID_ARGUMENT; + mutex_lock(&ctx->mutex); + *state = ctx->radioState; + mutex_unlock(&ctx->mutex); + return ERROR_NONE; +} + +error_t apiSetRadioEnabled(Device* device, bool enabled) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return ERROR_INVALID_STATE; + + mutex_lock(&ctx->mutex); + const bool changed = enabled != (ctx->radioState == BT_RADIO_STATE_ON); + ctx->radioState = enabled ? BT_RADIO_STATE_ON : BT_RADIO_STATE_OFF; + bool disconnected[MOCK_PEER_COUNT] = {}; + if (!enabled) { + ctx->scanning = false; + for (size_t i = 0; i < MOCK_PEER_COUNT; i++) { + disconnected[i] = ctx->peers[i].connectedProfiles != 0; + ctx->peers[i].connectedProfiles = 0; + } + } + mutex_unlock(&ctx->mutex); + if (!changed) return ERROR_NONE; + + for (size_t i = 0; i < MOCK_PEER_COUNT; i++) { + if (disconnected[i]) fireConnectState(ctx, i); + } + fireRadioState(ctx, enabled ? BT_RADIO_STATE_ON : BT_RADIO_STATE_OFF); + + LOG_I(TAG, "Bluetooth radio %s (mock)", enabled ? "on" : "off"); + return ERROR_NONE; +} + +error_t apiScanStart(Device* device) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return ERROR_INVALID_STATE; + + mutex_lock(&ctx->mutex); + if (ctx->radioState != BT_RADIO_STATE_ON || ctx->scanning) { + mutex_unlock(&ctx->mutex); + return ERROR_INVALID_STATE; + } + ctx->scanning = true; + mutex_unlock(&ctx->mutex); + + BtEvent started_event = {}; + started_event.type = BT_EVENT_SCAN_STARTED; + fireEvent(ctx, started_event); + + for (size_t i = 0; i < MOCK_PEER_COUNT; i++) { + BtEvent found_event = {}; + found_event.type = BT_EVENT_PEER_FOUND; + mutex_lock(&ctx->mutex); + fillPeerRecord(ctx, i, &found_event.peer); + mutex_unlock(&ctx->mutex); + fireEvent(ctx, found_event); + } + + // No real radio, so the "scan" completes instantly. + mutex_lock(&ctx->mutex); + ctx->scanning = false; + mutex_unlock(&ctx->mutex); + + BtEvent finished_event = {}; + finished_event.type = BT_EVENT_SCAN_FINISHED; + fireEvent(ctx, finished_event); + + return ERROR_NONE; +} + +error_t apiScanStop(Device* device) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return ERROR_INVALID_STATE; + mutex_lock(&ctx->mutex); + ctx->scanning = false; + mutex_unlock(&ctx->mutex); + return ERROR_NONE; +} + +bool apiIsScanning(Device* device) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return false; + mutex_lock(&ctx->mutex); + const bool scanning = ctx->scanning; + mutex_unlock(&ctx->mutex); + return scanning; +} + +error_t apiPair(Device* device, const BtAddr addr) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT; + + const size_t index = findPeer(addr); + mutex_lock(&ctx->mutex); + if (ctx->radioState != BT_RADIO_STATE_ON) { + mutex_unlock(&ctx->mutex); + return ERROR_INVALID_STATE; + } + if (index == MOCK_PEER_COUNT) { + mutex_unlock(&ctx->mutex); + return ERROR_NOT_FOUND; + } + ctx->peers[index].paired = true; + mutex_unlock(&ctx->mutex); + + BtEvent event = {}; + event.type = BT_EVENT_PAIR_RESULT; + memcpy(event.pair_result.addr, addr, BT_ADDR_LEN); + event.pair_result.result = BT_PAIR_RESULT_SUCCESS; + event.pair_result.profile = BT_PROFILE_HID_HOST; + fireEvent(ctx, event); + return ERROR_NONE; +} + +error_t apiUnpair(Device* device, const BtAddr addr) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT; + + const size_t index = findPeer(addr); + if (index == MOCK_PEER_COUNT) return ERROR_NOT_FOUND; + + mutex_lock(&ctx->mutex); + const bool was_connected = ctx->peers[index].connectedProfiles != 0; + ctx->peers[index] = {}; + mutex_unlock(&ctx->mutex); + + if (was_connected) fireConnectState(ctx, index); + return ERROR_NONE; +} + +error_t apiGetPairedPeers(Device* device, BtPeerRecord* out, size_t* count) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || count == nullptr) return ERROR_INVALID_ARGUMENT; + + mutex_lock(&ctx->mutex); + size_t paired_count = 0; + for (size_t i = 0; i < MOCK_PEER_COUNT; i++) { + if (!ctx->peers[i].paired) continue; + if (out != nullptr && paired_count < *count) { + fillPeerRecord(ctx, i, &out[paired_count]); + } + paired_count++; + } + mutex_unlock(&ctx->mutex); + + // With a buffer, the count is the number of records written + *count = out != nullptr && paired_count > *count ? *count : paired_count; + return ERROR_NONE; +} + +error_t apiConnect(Device* device, const BtAddr addr, BtProfileId profile) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT; + + const size_t index = findPeer(addr); + mutex_lock(&ctx->mutex); + if (ctx->radioState != BT_RADIO_STATE_ON) { + mutex_unlock(&ctx->mutex); + return ERROR_INVALID_STATE; + } + if (index == MOCK_PEER_COUNT) { + mutex_unlock(&ctx->mutex); + return ERROR_NOT_FOUND; + } + ctx->peers[index].paired = true; + ctx->peers[index].connectedProfiles |= 1 << profile; + mutex_unlock(&ctx->mutex); + + // No real peer to negotiate with, so the mock always succeeds instantly. + fireProfileState(ctx, index, profile, BT_PROFILE_STATE_CONNECTING); + fireProfileState(ctx, index, profile, BT_PROFILE_STATE_CONNECTED); + fireConnectState(ctx, index); + return ERROR_NONE; +} + +error_t apiDisconnect(Device* device, const BtAddr addr, BtProfileId profile) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT; + + const size_t index = findPeer(addr); + if (index == MOCK_PEER_COUNT) return ERROR_NOT_FOUND; + + mutex_lock(&ctx->mutex); + const bool was_connected = (ctx->peers[index].connectedProfiles & (1 << profile)) != 0; + ctx->peers[index].connectedProfiles &= ~(1 << profile); + mutex_unlock(&ctx->mutex); + if (!was_connected) return ERROR_NONE; + + fireProfileState(ctx, index, profile, BT_PROFILE_STATE_DISCONNECTING); + fireProfileState(ctx, index, profile, BT_PROFILE_STATE_IDLE); + fireConnectState(ctx, index); + return ERROR_NONE; +} + +error_t apiEventSubscribe(Device* device, BtEventSubscription* sub, TaskEventGroup* event_group) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || sub == nullptr || event_group == nullptr) return ERROR_INVALID_ARGUMENT; + + uint32_t bit; + error_t claim_result = task_event_group_claim_bit(event_group, &bit); + if (claim_result != ERROR_NONE) { + return claim_result; + } + + mutex_lock(&ctx->subscriptionsMutex); + + // Avoid cyclic subscription list that would loop forever + for (BtEventSubscription* existing = ctx->subscriptions; existing != nullptr; existing = existing->internal.next) { + if (existing == sub) { + mutex_unlock(&ctx->subscriptionsMutex); + task_event_group_release_bit(event_group, bit); + return ERROR_INVALID_STATE; + } + } + + sub->internal.event_group = event_group; + sub->bit = bit; + sub->internal.next = ctx->subscriptions; + ctx->subscriptions = sub; + mutex_unlock(&ctx->subscriptionsMutex); + return ERROR_NONE; +} + +error_t apiEventUnsubscribe(Device* device, BtEventSubscription* sub) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || sub == nullptr) return ERROR_INVALID_ARGUMENT; + + error_t result = ERROR_NOT_FOUND; + mutex_lock(&ctx->subscriptionsMutex); + for (BtEventSubscription** link = &ctx->subscriptions; *link != nullptr; link = &(*link)->internal.next) { + if (*link == sub) { + *link = sub->internal.next; + result = ERROR_NONE; + break; + } + } + mutex_unlock(&ctx->subscriptionsMutex); + + if (result == ERROR_NONE) { + task_event_group_release_bit(sub->internal.event_group, sub->bit); + } + return result; +} + +error_t apiSetDeviceName(Device* device, const char* name) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || name == nullptr || strlen(name) > BLE_DEVICE_NAME_MAX) return ERROR_INVALID_ARGUMENT; + mutex_lock(&ctx->mutex); + strncpy(ctx->deviceName, name, BLE_DEVICE_NAME_MAX); + ctx->deviceName[BLE_DEVICE_NAME_MAX] = '\0'; + mutex_unlock(&ctx->mutex); + return ERROR_NONE; +} + +error_t apiGetDeviceName(Device* device, char* buf, size_t buf_len) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || buf == nullptr || buf_len == 0) return ERROR_INVALID_ARGUMENT; + mutex_lock(&ctx->mutex); + strncpy(buf, ctx->deviceName, buf_len - 1); + buf[buf_len - 1] = '\0'; + mutex_unlock(&ctx->mutex); + return ERROR_NONE; +} + +void apiSetHidHostActive(Device* device, bool active) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return; + mutex_lock(&ctx->mutex); + ctx->hidHostActive = active; + mutex_unlock(&ctx->mutex); +} + +void apiFireEvent(Device* device, BtEvent event) { + auto* ctx = GET_CTX(device); + if (ctx != nullptr) fireEvent(ctx, event); +} + +const BluetoothApi mock_bluetooth_api = { + .get_radio_state = apiGetRadioState, + .set_radio_enabled = apiSetRadioEnabled, + .scan_start = apiScanStart, + .scan_stop = apiScanStop, + .is_scanning = apiIsScanning, + .pair = apiPair, + .unpair = apiUnpair, + .get_paired_peers = apiGetPairedPeers, + .connect = apiConnect, + .disconnect = apiDisconnect, + .event_subscribe = apiEventSubscribe, + .event_unsubscribe = apiEventUnsubscribe, + .set_device_name = apiSetDeviceName, + .get_device_name = apiGetDeviceName, + .set_hid_host_active = apiSetHidHostActive, + .fire_event = apiFireEvent, +}; + +// ---- Driver lifecycle ---- +// startDevice()/stopDevice() only allocate/free this driver's bookkeeping (subscriber list +// included), so event subscribers can stay subscribed across radio on/off toggles. + +error_t startDevice(Device* device) { + auto* ctx = new(std::nothrow) MockBluetoothCtx(); + if (ctx == nullptr) return ERROR_OUT_OF_MEMORY; + + ctx->device = device; + strncpy(ctx->deviceName, MOCK_DEFAULT_NAME, BLE_DEVICE_NAME_MAX); + mutex_construct(&ctx->mutex); + mutex_construct(&ctx->subscriptionsMutex); + + device_set_driver_data(device, ctx); + return ERROR_NONE; +} + +error_t stopDevice(Device* device) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return ERROR_NONE; + + if (ctx->radioState == BT_RADIO_STATE_ON) { + apiSetRadioEnabled(device, false); + } + + // Release any subscribers that never unsubscribed: device_stop() doesn't wait for apps still + // using this device, so a later bluetooth_event_unsubscribe() would find no ctx and skip + // releasing the bit. + mutex_lock(&ctx->subscriptionsMutex); + for (BtEventSubscription* sub = ctx->subscriptions; sub != nullptr;) { + BtEventSubscription* next = sub->internal.next; + // Force-close, don't destruct: a concurrent bluetooth_event_poll() may hold/await this + // same lock (see BtEventSubscription::internal::closed). + mutex_lock(&sub->internal.ring_mutex); + sub->internal.closed = true; + mutex_unlock(&sub->internal.ring_mutex); + task_event_group_release_bit(sub->internal.event_group, sub->bit); + sub = next; + } + ctx->subscriptions = nullptr; + mutex_unlock(&ctx->subscriptionsMutex); + + device_set_driver_data(device, nullptr); + mutex_destruct(&ctx->subscriptionsMutex); + mutex_destruct(&ctx->mutex); + delete ctx; + + return ERROR_NONE; +} + +} // namespace + +extern "C" { + +Driver mock_bluetooth_driver = { + .name = "mock_bluetooth", + .compatible = (const char*[]) { "mock,bluetooth", nullptr }, + .start_device = startDevice, + .stop_device = stopDevice, + .api = (const void*)&mock_bluetooth_api, + .device_type = &BLUETOOTH_TYPE, + .internal = nullptr +}; + +} // extern "C" diff --git a/Drivers/mock-drivers-module/source/module.cpp b/Drivers/mock-drivers-module/source/module.cpp index 28fe01b1f..a0a5dd43f 100644 --- a/Drivers/mock-drivers-module/source/module.cpp +++ b/Drivers/mock-drivers-module/source/module.cpp @@ -6,10 +6,12 @@ extern "C" { extern Driver mock_wifi_driver; extern Driver mock_radio_driver; +extern Driver mock_bluetooth_driver; static Driver* const mock_drivers[] = { &mock_wifi_driver, &mock_radio_driver, + &mock_bluetooth_driver, nullptr }; diff --git a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp index 6a70868ca..de823a26f 100644 --- a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp +++ b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp @@ -154,7 +154,7 @@ 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 delete runtime; - LOG_E(TAG, "Failed to map elf. Possibly out of memory."); + LOG_E(TAG, "Failed to map elf. Common causes: missing symbol or out of memory."); return ERROR_RESOURCE; } diff --git a/Modules/lvgl-module/include/lvgl/theme.h b/Modules/lvgl-module/include/lvgl/theme.h index 01e1b8a4b..2234d2ebc 100644 --- a/Modules/lvgl-module/include/lvgl/theme.h +++ b/Modules/lvgl-module/include/lvgl/theme.h @@ -36,6 +36,13 @@ void lvgl_theme_get_settings(LvglThemeSettings* settings); */ void lvgl_theme_get_default_settings(LvglThemeSettings* settings); +/** + * @brief Overrides the compile-time default UI density, for devices that only know it at runtime. + * Call before the theme settings are read. + * @param[in] is_compact true for the compact density + */ +void lvgl_theme_set_default_compact(bool is_compact); + /** * @brief Checks whether the active theme is the monochrome theme: on monochrome and greyscale displays, or when selected in the settings. * @return true when the monochrome theme is active diff --git a/Modules/lvgl-module/include/lvgl/widgets/list.h b/Modules/lvgl-module/include/lvgl/widgets/list.h index 9e37bdb10..9ba9d9f15 100644 --- a/Modules/lvgl-module/include/lvgl/widgets/list.h +++ b/Modules/lvgl-module/include/lvgl/widgets/list.h @@ -31,6 +31,14 @@ lv_obj_t* lvgl_list_add_text(lv_obj_t* list, const char* text); */ lv_obj_t* lvgl_list_add_button(lv_obj_t* list, const void* icon, const char* text); +/** + * @brief Removes the divider below the last item of a list. + * Every item has a divider below it. The last one lies on the border of the card or the container that holds the list. + * Call this after the last item was added. + * @param[in] list the list, or the container that holds the list items + */ +void lvgl_list_hide_last_divider(lv_obj_t* list); + #ifdef __cplusplus } #endif diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index 5b11f2d8a..d7f906da4 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -151,6 +151,7 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lvgl_list_create), DEFINE_MODULE_SYMBOL(lvgl_list_add_text), DEFINE_MODULE_SYMBOL(lvgl_list_add_button), + DEFINE_MODULE_SYMBOL(lvgl_list_hide_last_divider), // lvgl_qr DEFINE_MODULE_SYMBOL(lvgl_qr_create), DEFINE_MODULE_SYMBOL(lvgl_qr_set_data), @@ -272,6 +273,7 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lv_obj_set_layout), DEFINE_MODULE_SYMBOL(lv_obj_set_grid_dsc_array), DEFINE_MODULE_SYMBOL(lv_obj_set_grid_cell), + DEFINE_MODULE_SYMBOL(lv_obj_set_press_lock), DEFINE_MODULE_SYMBOL(lv_obj_is_layout_positioned), DEFINE_MODULE_SYMBOL(lv_obj_mark_layout_as_dirty), DEFINE_MODULE_SYMBOL(lv_obj_get_style_layout), diff --git a/Modules/lvgl-module/source/theme.cpp b/Modules/lvgl-module/source/theme.cpp index e5fc4dc74..521a929f8 100644 --- a/Modules/lvgl-module/source/theme.cpp +++ b/Modules/lvgl-module/source/theme.cpp @@ -18,6 +18,7 @@ enum class ActiveTheme { static LvglThemeSettings theme_settings; static bool theme_settings_set = false; +static bool default_compact = TT_LVGL_UI_DENSITY == LVGL_UI_DENSITY_COMPACT; static ActiveTheme active_theme = ActiveTheme::None; void lvgl_theme_get_default_settings(LvglThemeSettings* settings) { @@ -29,11 +30,15 @@ void lvgl_theme_get_default_settings(LvglThemeSettings* settings) { settings->color_primary = material_config.color_primary; settings->color_secondary = material_config.color_secondary; settings->color_error = material_config.color_error; - settings->is_compact = TT_LVGL_UI_DENSITY == LVGL_UI_DENSITY_COMPACT; + settings->is_compact = default_compact; settings->surface_override = material_config.surface_override; settings->color_surface = material_config.color_surface; } +void lvgl_theme_set_default_compact(bool is_compact) { + default_compact = is_compact; +} + void lvgl_theme_get_settings(LvglThemeSettings* settings) { if (theme_settings_set) { *settings = theme_settings; diff --git a/Modules/lvgl-module/source/widgets/list.cpp b/Modules/lvgl-module/source/widgets/list.cpp index c98d8cb80..6b2547d1f 100644 --- a/Modules/lvgl-module/source/widgets/list.cpp +++ b/Modules/lvgl-module/source/widgets/list.cpp @@ -82,3 +82,10 @@ lv_obj_t* lvgl_list_add_button(lv_obj_t* list, const void* icon, const char* tex return obj; } + +void lvgl_list_hide_last_divider(lv_obj_t* list) { + const uint32_t count = lv_obj_get_child_count(list); + if (count > 0) { + lv_obj_set_style_border_width(lv_obj_get_child(list, static_cast(count) - 1), 0, LV_STATE_DEFAULT); + } +} diff --git a/Platforms/platform-esp32/CMakeLists.txt b/Platforms/platform-esp32/CMakeLists.txt index 0ec0c84e8..16f367329 100644 --- a/Platforms/platform-esp32/CMakeLists.txt +++ b/Platforms/platform-esp32/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20) file(GLOB_RECURSE SOURCES "source/*.c**") -set(PRIV_REQUIRES_LIST "") +set(PRIV_REQUIRES_LIST TactilityFreeRtos) if ("${IDF_TARGET}" STREQUAL "esp32s3" OR "${IDF_TARGET}" STREQUAL "esp32p4") list(APPEND PRIV_REQUIRES_LIST esp_tinyusb) endif () diff --git a/Tactility/Private/Tactility/service/espnow/esp_hosted_espnow_bridge_proto.h b/Platforms/platform-esp32/private/espnow/esp_hosted_espnow_bridge_proto.h similarity index 98% rename from Tactility/Private/Tactility/service/espnow/esp_hosted_espnow_bridge_proto.h rename to Platforms/platform-esp32/private/espnow/esp_hosted_espnow_bridge_proto.h index 45cd0ef07..3ba3249fe 100644 --- a/Tactility/Private/Tactility/service/espnow/esp_hosted_espnow_bridge_proto.h +++ b/Platforms/platform-esp32/private/espnow/esp_hosted_espnow_bridge_proto.h @@ -43,7 +43,7 @@ extern "C" { * enforce that themselves based on the reported version, same as the non-bridged backend). */ #define ESPNOW_BRIDGE_MAX_DATA_LEN 1470 -/* req_init mode: matches tt::service::espnow::Mode / WIFI_MODE_STA vs WIFI_MODE_AP on the slave */ +/* req_init mode: matches espnow::Mode / WIFI_MODE_STA vs WIFI_MODE_AP on the slave */ #define ESPNOW_BRIDGE_MODE_STATION 0U #define ESPNOW_BRIDGE_MODE_ACCESS_POINT 1U diff --git a/Tactility/Private/Tactility/service/espnow/EspNowBackend.h b/Platforms/platform-esp32/private/espnow/espnow_backend.h similarity index 56% rename from Tactility/Private/Tactility/service/espnow/EspNowBackend.h rename to Platforms/platform-esp32/private/espnow/espnow_backend.h index 76c1be363..30b50a5fc 100644 --- a/Tactility/Private/Tactility/service/espnow/EspNowBackend.h +++ b/Platforms/platform-esp32/private/espnow/espnow_backend.h @@ -6,9 +6,39 @@ #if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) -#include +#include +#include +#include +#include -namespace tt::service::espnow::backend { +namespace espnow { + +enum class Mode { + Station, + AccessPoint +}; + +struct EspNowConfig { + uint8_t masterKey[ESP_NOW_KEY_LEN]; + Mode mode; + uint8_t channel; + bool longRange; + bool encrypt; + + EspNowConfig( + const uint8_t masterKey[ESP_NOW_KEY_LEN], + Mode mode, + uint8_t channel, + bool longRange, + bool encrypt + ) : mode(mode), channel(channel), longRange(longRange), encrypt(encrypt) { + memcpy(this->masterKey, masterKey, ESP_NOW_KEY_LEN); + } +}; + +} + +namespace espnow::backend { /** * Bring up the backend (WiFi if needed + ESP-NOW init + recv/send callbacks + PMK). diff --git a/Tactility/Private/Tactility/service/espnow/EspNowHostedTransport.h b/Platforms/platform-esp32/private/espnow/espnow_hosted_transport.h similarity index 89% rename from Tactility/Private/Tactility/service/espnow/EspNowHostedTransport.h rename to Platforms/platform-esp32/private/espnow/espnow_hosted_transport.h index 91f90feac..e1c993ee1 100644 --- a/Tactility/Private/Tactility/service/espnow/EspNowHostedTransport.h +++ b/Platforms/platform-esp32/private/espnow/espnow_hosted_transport.h @@ -8,14 +8,14 @@ #include -namespace tt::service::espnow::backend { +namespace espnow::backend { /** * The esp_hosted SDIO transport to the co-processor is brought up by esp_hosted_init() * (auto-called at process startup via a constructor attribute - always runs regardless of * WiFi/BT state), but the actual slave handshake only happens via esp_hosted_connect_to_slave(), * which today is only called as a side effect of the WiFi or BT stack starting - * (esp32_wifi.cpp / vhci_drv.c). Nothing in Tactility's boot sequence calls it on its own, so + * (esp32_wifi.cpp / vhci_drv.c). Nothing in the boot sequence calls it on its own, so * without this, ESP-NOW-over-bridge would require WiFi or BT to already be enabled - unlike * native ESP32 chips where ESP-NOW works standalone. * diff --git a/Tactility/Source/service/espnow/EspNowBackendHosted.cpp b/Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_backend_hosted.cpp similarity index 97% rename from Tactility/Source/service/espnow/EspNowBackendHosted.cpp rename to Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_backend_hosted.cpp index 86aab170d..481dd07a5 100644 --- a/Tactility/Source/service/espnow/EspNowBackendHosted.cpp +++ b/Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_backend_hosted.cpp @@ -4,9 +4,9 @@ #if defined(CONFIG_ESP_HOSTED_ENABLED) -#include -#include -#include +#include +#include +#include // esp_hosted_misc.h lacks its own extern "C" guard, so its declarations get C++ name-mangled // when included from a .cpp file, causing "undefined reference" at link time against the @@ -30,13 +30,13 @@ extern "C" { #include #include -namespace tt::service::espnow::backend { +namespace espnow::backend { constexpr auto* TAG = "EspNowBackendHosted"; constexpr TickType_t REQUEST_TIMEOUT = 2000U / portTICK_PERIOD_MS; -static RecursiveMutex requestMutex; -static Semaphore responseSemaphore(1, 0); +static tt::RecursiveMutex requestMutex; +static tt::Semaphore responseSemaphore(1, 0); // Callbacks (onRespGeneric/onRespInit) run from esp_hosted's own dispatch task, concurrently // with doRequest() running on the caller's task - std::atomic instead of requestMutex here // because taking requestMutex from the callback would deadlock: doRequest() holds it for its diff --git a/Tactility/Source/service/espnow/EspNowBackendNative.cpp b/Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_backend_native.cpp similarity index 98% rename from Tactility/Source/service/espnow/EspNowBackendNative.cpp rename to Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_backend_native.cpp index 55ad4c411..4413a4ea5 100644 --- a/Tactility/Source/service/espnow/EspNowBackendNative.cpp +++ b/Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_backend_native.cpp @@ -4,7 +4,7 @@ #if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_ESP_HOSTED_ENABLED) -#include +#include #include #include @@ -14,7 +14,7 @@ #include #include -namespace tt::service::espnow::backend { +namespace espnow::backend { constexpr auto* TAG = "EspNowBackendNative"; static bool wifiAcquired = false; diff --git a/Tactility/Source/service/espnow/EspNowHostedTransport.cpp b/Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_hosted_transport.cpp similarity index 91% rename from Tactility/Source/service/espnow/EspNowHostedTransport.cpp rename to Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_hosted_transport.cpp index 718e53da6..b3cac4e9c 100644 --- a/Tactility/Source/service/espnow/EspNowHostedTransport.cpp +++ b/Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_hosted_transport.cpp @@ -4,12 +4,12 @@ #if defined(CONFIG_ESP_HOSTED_ENABLED) -#include +#include #include #include -namespace tt::service::espnow::backend { +namespace espnow::backend { // Thin wrapper: the actual CP_INIT wait/generation-tracking logic lives in the kernel driver // (Platforms/platform-esp32/source/drivers/esp32_esp_hosted_ota.cpp, reached via the generic diff --git a/Tactility/Source/service/espnow/EspNowRadio.cpp b/Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_radio.cpp similarity index 81% rename from Tactility/Source/service/espnow/EspNowRadio.cpp rename to Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_radio.cpp index c824bed10..b48109d98 100644 --- a/Tactility/Source/service/espnow/EspNowRadio.cpp +++ b/Platforms/platform-esp32/source/drivers/espnow/esp32_espnow_radio.cpp @@ -4,8 +4,7 @@ #if (defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)) -#include -#include +#include #include #include @@ -21,12 +20,13 @@ #include #include +#include #include #include #include -namespace tt::service::espnow { +namespace espnow { constexpr auto* TAG = "EspNowRadio"; @@ -38,8 +38,10 @@ constexpr int32_t CHANNEL_MIN = 1; constexpr int32_t CHANNEL_MAX = 14; constexpr int32_t POWER_MIN = 2; constexpr int32_t POWER_MAX = 20; -constexpr uint32_t ENABLE_TIMEOUT_MS = 5000; constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; +// ESP-NOW protocol version 1 and 2 payload limits +constexpr size_t MAX_DATA_LENGTH_V1 = 250; +constexpr size_t MAX_DATA_LENGTH_V2 = 1470; struct Frame { RadioTxId id; @@ -58,10 +60,14 @@ struct Radio { int32_t power = POWER_MAX; RadioTxId nextTxId = 1; tt::std::deque queue; - ReceiverSubscription subscription = NO_SUBSCRIPTION; + /** Written by the radio thread while ESP-NOW is up */ + uint32_t version = 0; bool stopRequested = false; }; +/** ESP-NOW delivers received frames to a plain callback without a context */ +std::atomic activeRadio { nullptr }; + Radio* getRadio(Device* device) { return static_cast(device_get_driver_data(device)); } @@ -80,22 +86,9 @@ bool isStopRequested(Radio* radio) { return stop; } -/** Waits until the ESP-NOW service has brought ESP-NOW up. The service does that on another task. */ -bool waitUntilEnabled(Radio* radio) { - for (uint32_t waited = 0; waited < ENABLE_TIMEOUT_MS; waited += 20) { - if (isEnabled()) { - return true; - } - if (isStopRequested(radio)) { - return false; - } - delay_millis(20); - } - return isEnabled(); -} - -void onReceive(Radio* radio, const esp_now_recv_info_t* info, const uint8_t* data, int length) { - if (length <= 0) { +void onReceive(const esp_now_recv_info_t* info, const uint8_t* data, int length) { + auto* radio = activeRadio.load(); + if (radio == nullptr || length <= 0) { return; } const float rssi = (info != nullptr && info->rx_ctrl != nullptr) ? (float)info->rx_ctrl->rssi : 0.0f; @@ -115,11 +108,14 @@ void transmitQueued(Radio* radio) { mutex_unlock(&radio->mutex); radio_tx_event_emit(radio->device, frame.id, RADIO_TRANSMISSION_STATE_TRANSMIT_PENDING); - const bool fits = frame.data.size() <= getMaxDataLength(); + const size_t protocolMaxLength = radio->version >= 2 ? MAX_DATA_LENGTH_V2 : MAX_DATA_LENGTH_V1; + // Receivers hand out at most RADIO_RX_MAX_PACKET_LENGTH bytes, so a longer frame would arrive truncated + const size_t maxLength = ::std::min(protocolMaxLength, RADIO_RX_MAX_PACKET_LENGTH); + const bool fits = frame.data.size() <= maxLength; // The frame is handed to the Wi-Fi stack, which doesn't report when it's out - const bool sent = fits && send(BROADCAST_ADDRESS, frame.data.data(), frame.data.size()); + const bool sent = fits && backend::send(BROADCAST_ADDRESS, frame.data.data(), frame.data.size()); if (!sent) { - LOG_W(TAG, "Failed to send %u bytes (the maximum is %u)", (unsigned)frame.data.size(), (unsigned)getMaxDataLength()); + LOG_W(TAG, "Failed to send %u bytes (the maximum is %u)", (unsigned)frame.data.size(), (unsigned)maxLength); } radio_tx_event_emit(radio->device, frame.id, sent ? RADIO_TRANSMISSION_STATE_TRANSMITTED : RADIO_TRANSMISSION_STATE_ERROR); } @@ -135,26 +131,23 @@ int32_t radioThread(void* context) { // The same settings as MeshCore's ESP-NOW transport: a station with the long range protocol, unencrypted broadcasts uint8_t key[ESP_NOW_KEY_LEN] = {}; - enable(EspNowConfig(key, Mode::Station, (uint8_t)channel, true, false)); + if (!backend::init(EspNowConfig(key, Mode::Station, (uint8_t)channel, true, false), onReceive)) { + LOG_E(TAG, "ESP-NOW didn't start"); + setState(radio, RADIO_STATE_ERROR); + return 1; + } + radio->version = backend::getVersion(); - if (!waitUntilEnabled(radio)) { - // ESP-NOW may still finish starting on its own task, so it's turned off in both cases - disable(); - if (!isStopRequested(radio)) { - LOG_E(TAG, "ESP-NOW didn't start"); - setState(radio, RADIO_STATE_ERROR); - return 1; - } - setState(radio, RADIO_STATE_OFF); - return 0; + esp_now_peer_info_t broadcastPeer = {}; + memcpy(broadcastPeer.peer_addr, BROADCAST_ADDRESS, sizeof(BROADCAST_ADDRESS)); + if (!backend::addPeer(broadcastPeer)) { + LOG_E(TAG, "Failed to add the broadcast peer"); } #ifdef CONFIG_SOC_WIFI_SUPPORTED // In units of 0.25 dBm esp_wifi_set_max_tx_power((int8_t)(power * 4)); #endif - radio->subscription = subscribeReceiver([radio](const esp_now_recv_info_t* info, const uint8_t* data, int length) { - onReceive(radio, info, data, length); - }); + activeRadio.store(radio); setState(radio, RADIO_STATE_ON); while (!isStopRequested(radio)) { @@ -164,9 +157,10 @@ int32_t radioThread(void* context) { } setState(radio, RADIO_STATE_OFF_PENDING); - unsubscribeReceiver(radio->subscription); - radio->subscription = NO_SUBSCRIPTION; - disable(); + activeRadio.store(nullptr); + if (!backend::deinit()) { + LOG_E(TAG, "Failed to stop ESP-NOW"); + } setState(radio, RADIO_STATE_OFF); return 0; } @@ -214,7 +208,7 @@ error_t apiSetEnabled(Device* device, bool enabled) { mutex_unlock(&radio->mutex); event_group_clear(radio->events, TX_BIT | STOP_BIT); setState(radio, RADIO_STATE_ON_PENDING); - radio->thread = thread_alloc_full("espnow_radio", 4096, radioThread, radio, -1); + radio->thread = thread_alloc_full("espnow_radio", 8192, radioThread, radio, -1); if (radio->thread == nullptr || thread_start(radio->thread) != ERROR_NONE) { if (radio->thread != nullptr) { thread_free(radio->thread); @@ -371,14 +365,16 @@ Device device { } // namespace -void registerRadio() { - if (driver_construct_add(&driver) != ERROR_NONE || - device_construct_add(&device, "tactility,espnow-radio") != ERROR_NONE || - device_start(&device) != ERROR_NONE) { - LOG_E(TAG, "Failed to add the ESP-NOW radio"); - } } +error_t esp32_espnow_radio_start() { + if (driver_construct_add(&espnow::driver) != ERROR_NONE || + device_construct_add(&espnow::device, "tactility,espnow-radio") != ERROR_NONE || + device_start(&espnow::device) != ERROR_NONE) { + LOG_E(espnow::TAG, "Failed to add the ESP-NOW radio"); + return ERROR_RESOURCE; + } + return ERROR_NONE; } #endif diff --git a/Platforms/platform-esp32/source/module.cpp b/Platforms/platform-esp32/source/module.cpp index 6d5d1b579..26b0dcbab 100644 --- a/Platforms/platform-esp32/source/module.cpp +++ b/Platforms/platform-esp32/source/module.cpp @@ -1,7 +1,4 @@ -#ifdef ESP_PLATFORM #include -#endif - #include #include #include @@ -126,6 +123,22 @@ extern "C" { } error_t platform_esp32_start_partitions(); +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) +error_t esp32_espnow_radio_start(); +#endif + +static error_t platform_esp32_start() { + error_t error = platform_esp32_start_partitions(); + if (error != ERROR_NONE) { + return error; + } +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) + // Lets mesh networking run over ESP-NOW + return esp32_espnow_radio_start(); +#else + return ERROR_NONE; +#endif +} extern "C" { @@ -436,7 +449,7 @@ static Driver* const platform_esp32_drivers[] = { Module platform_esp32_module = { .name = "platform-esp32", - .start = platform_esp32_start_partitions, + .start = platform_esp32_start, .stop = nullptr, .drivers = platform_esp32_drivers, .symbols = platform_esp32_symbols, diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index 5481cc4a9..e32c01b66 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -92,11 +92,6 @@ if (DEFINED ENV{ESP_IDF_VERSION}) list(APPEND REQUIRES_LIST esp_tinyusb) endif () - if ("${IDF_TARGET}" STREQUAL "esp32p4") - # EspNowBackendHosted.cpp needs esp_hosted.h / esp_hosted_misc.h. - list(APPEND REQUIRES_LIST espressif__esp_hosted) - endif () - else () list(APPEND REQUIRES_LIST diff --git a/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h b/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h index ee1f2a886..b31be1f20 100644 --- a/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h +++ b/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h @@ -26,10 +26,29 @@ inline ::std::string getNotReadyText() { return ::std::string("Mesh isn't running: ") + mesh_state_to_string(mesh_get_state()) + ". Check the Mesh settings."; } +/** Whether mesh is running, and the network and the kind of radio that it uses. Those are empty while mesh isn't running. */ +struct NetworkInfo { + bool running = false; + char network[MESH_BACKEND_ID_MAX_LENGTH + 1] = {}; + char modulation[16] = {}; +}; + +/** Queries the service thread, so it's called without the LVGL lock */ +NetworkInfo loadNetworkInfo(); + +/** + * A text line under the toolbar with the values in the secondary theme colour: + * "Using [network] via [modulation]" while mesh is running, and "Mesh is OFF" when it isn't + */ +lv_obj_t* createNetworkHeader(lv_obj_t* parent); + +/** Requires the LVGL lock. Hides the header while mesh is running without a known network. */ +void updateNetworkHeader(lv_obj_t* header, const NetworkInfo& info); + /** Starts an app by id with the given arguments */ void startApp(const char* id, int argc, const char* const argv[]); -/** A scrollable, borderless column below the toolbar and status label that holds the lists and buttons of a screen */ +/** A scrollable, borderless column below the toolbar and network header that holds the lists and buttons of a screen */ lv_obj_t* createContent(lv_obj_t* parent); /** diff --git a/Tactility/Include/Tactility/service/espnow/EspNow.h b/Tactility/Include/Tactility/service/espnow/EspNow.h deleted file mode 100644 index c79c86d01..000000000 --- a/Tactility/Include/Tactility/service/espnow/EspNow.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#ifdef ESP_PLATFORM -#include -#endif - -#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) - -#include -#include -#include -#include - -namespace tt::service::espnow { - -typedef int ReceiverSubscription; -constexpr ReceiverSubscription NO_SUBSCRIPTION = -1; - -// ESP-NOW version payload limits -constexpr size_t MAX_DATA_LEN_V1 = 250; // ESP-NOW v1.0 max payload -constexpr size_t MAX_DATA_LEN_V2 = 1470; // ESP-NOW v2.0 max payload (requires ESP-IDF v5.4+) - -enum class Mode { - Station, - AccessPoint -}; - -struct EspNowConfig { - uint8_t masterKey[ESP_NOW_KEY_LEN]; - uint8_t address[ESP_NOW_ETH_ALEN]; - Mode mode; - uint8_t channel; - bool longRange; - bool encrypt; - - EspNowConfig( - uint8_t masterKey[16], - Mode mode, - uint8_t channel, - bool longRange, - bool encrypt - ) : mode(mode), channel(channel), longRange(longRange), encrypt(encrypt) { - memcpy(this->masterKey, masterKey, 16); - } -}; - -void enable(const EspNowConfig& config); - -void disable(); - -bool isEnabled(); - -bool addPeer(const esp_now_peer_info_t& peer); - -bool send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength); - -ReceiverSubscription subscribeReceiver(std::function onReceive); - -void unsubscribeReceiver(ReceiverSubscription subscription); - -/** Get the ESP-NOW protocol version (1 for v1.0, 2 for v2.0). Returns 0 if service not running. */ -uint32_t getVersion(); - -/** Get the maximum data length for current ESP-NOW version (250 for v1.0, 1470 for v2.0). Returns 0 if service not running. */ -size_t getMaxDataLength(); - -} - -#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_ESP_HOSTED_ENABLED \ No newline at end of file diff --git a/Tactility/Private/Tactility/service/espnow/EspNowRadio.h b/Tactility/Private/Tactility/service/espnow/EspNowRadio.h deleted file mode 100644 index a1aa3d05e..000000000 --- a/Tactility/Private/Tactility/service/espnow/EspNowRadio.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#ifdef ESP_PLATFORM -#include -#endif - -#if (defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)) - -namespace tt::service::espnow { - -/** - * Adds a radio device (RADIO_TYPE) that sends and receives broadcast frames with ESP-NOW, so that the mesh service - * can use it like a LoRa radio. It uses the ESP-NOW service, so it must be registered after that service. - */ -void registerRadio(); - -} - -#endif diff --git a/Tactility/Private/Tactility/service/espnow/EspNowService.h b/Tactility/Private/Tactility/service/espnow/EspNowService.h deleted file mode 100644 index 2f69c242e..000000000 --- a/Tactility/Private/Tactility/service/espnow/EspNowService.h +++ /dev/null @@ -1,78 +0,0 @@ -#pragma once - -#ifdef ESP_PLATFORM -#include -#endif - -#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) - -#include -#include - -#include - -#include - -namespace tt::service::espnow { - -class EspNowService final : public Service { - - struct ReceiverSubscriptionData { - ReceiverSubscription id; - std::function onReceive; - }; - - struct SendCallback { - uint8_t macAddress[ESP_NOW_ETH_ALEN]; - bool success; - }; - - RecursiveMutex mutex; - std::vector subscriptions; - ReceiverSubscription lastSubscriptionId = 0; - bool enabled = false; - uint32_t espnowVersion = 0; - - // Dispatcher calls this and forwards to non-static function - void enableFromDispatcher(const EspNowConfig& config); - - void disableFromDispatcher(); - - static void receiveCallback(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length); - void onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length); - -public: - - // region Overrides - - bool onStart(ServiceContext& service) override; - void onStop(ServiceContext& service) override; - - // endregion Overrides - - // region Internal API - - void enable(const EspNowConfig& config); - - void disable(); - - bool isEnabled() const; - - bool addPeer(const esp_now_peer_info_t& peer); - - bool send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength); - - ReceiverSubscription subscribeReceiver(std::function onReceive); - - void unsubscribeReceiver(ReceiverSubscription subscription); - - uint32_t getVersion() const; - - // region Internal API -}; - -std::shared_ptr findService(); - -} - -#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_ESP_HOSTED_ENABLED diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp index ce1c3ca78..f373522e7 100644 --- a/Tactility/Source/InitApps.cpp +++ b/Tactility/Source/InitApps.cpp @@ -1,10 +1,12 @@ +#include "tactility/drivers/bluetooth.h" + + #include #ifdef ESP_PLATFORM #include #endif -#include #include #include #include @@ -198,10 +200,10 @@ static void registerInternalApps() { app_manager_add(&app::power::manifest); } -#if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED - app_manager_add(&app::btmanage::manifest); - app_manager_add(&app::btpeersettings::manifest); -#endif + if (device_exists_of_type(&BLUETOOTH_TYPE)) { + app_manager_add(&app::btmanage::manifest); + app_manager_add(&app::btpeersettings::manifest); + } } // Registers every mounted filesystem's app install directory with app-module (see diff --git a/Tactility/Source/InitServices.cpp b/Tactility/Source/InitServices.cpp index 74ea3d309..8d0d2f824 100644 --- a/Tactility/Source/InitServices.cpp +++ b/Tactility/Source/InitServices.cpp @@ -6,7 +6,6 @@ #include #include -#include #include #include @@ -34,9 +33,6 @@ namespace tt { namespace service { // Primary namespace audio { extern const ServiceManifest manifest; } -#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) - namespace espnow { extern const ServiceManifest manifest; } -#endif #ifdef ESP_PLATFORM namespace rtctime { extern const ServiceManifest manifest; } #endif @@ -66,11 +62,6 @@ void registerAndStartServices() { check(posix_services_start() == ERROR_NONE); #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) - addService(service::espnow::manifest); - // Lets mesh networking run over ESP-NOW - service::espnow::registerRadio(); -#endif #if defined(ESP_PLATFORM) if (device_exists_of_type(&RTC_TYPE)) { addService(service::rtctime::manifest); diff --git a/Tactility/Source/app/apphub/AppHubApp.cpp b/Tactility/Source/app/apphub/AppHubApp.cpp index 2991aef89..10a3443c2 100644 --- a/Tactility/Source/app/apphub/AppHubApp.cpp +++ b/Tactility/Source/app/apphub/AppHubApp.cpp @@ -32,6 +32,9 @@ #include #include +#include +#include + namespace tt::app::apphub { constexpr auto* TAG = "AppHub"; @@ -46,7 +49,7 @@ enum class Filter { Updates }; -constexpr auto FILTER_LABELS = std::to_array({ "All", "Installed", "Updates" }); +constexpr auto FILTER_LABELS = ::std::to_array({ "All", "Installed", "Updates" }); struct Context { uint32_t appInstanceId; @@ -59,7 +62,9 @@ struct Context { // Survives across a bury/resurface cycle Filter filter = Filter::All; /** The app ids of the list's buttons, by index */ - std::vector appIds; + tt::std::vector appIds; + /** Read from the catalog file on this app's own thread (see loadApps()), as the widget code can't do file operations */ + tt::std::vector apps; Mutex mutex; // Survives across a bury/resurface cycle (e.g. opening AppHubDetailsApp and returning), @@ -74,21 +79,35 @@ struct Context { TaskEventGroup* eventGroup = nullptr; uint32_t refreshRequestedBit = 0; - std::atomic refreshRequested {false}; + ::std::atomic refreshRequested {false}; + /** Set when the window resurfaces: the installed apps can change without an App Hub event, e.g. through the Files app */ + ::std::atomic reloadRequested {false}; AppHubEventSubscription appHubSub {}; /** The id of the queued catalog refresh, 0 when none is pending */ - std::atomic refreshOperationId {0}; + ::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; + tt::std::vector failedUpdates; }; void showApps(Context* ctx); void refresh(Context* ctx); +/** Reads the apps from App Hub for showApps(). Doesn't touch widgets, so it runs without the LVGL lock. */ +void loadApps(Context* ctx) { + tt::std::vector apps; + app_hub_search(nullptr, [](const AppHubApp* app, void* context) { + static_cast*>(context)->push_back(*app); + return true; + }, &apps); + ctx->mutex.lock(); + ctx->apps = ::std::move(apps); + ctx->mutex.unlock(); +} + bool matchesFilter(const AppHubApp& app, Filter filter) { switch (filter) { case Filter::All: @@ -138,7 +157,7 @@ void onUpdateAllPressed(lv_event_t* event) { 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"); + const auto text = ::std::format("{} {}", updateCount, updateCount == 1 ? "update" : "updates"); lv_label_set_text(lv_obj_get_child(chip, 0), text.c_str()); } @@ -202,6 +221,11 @@ void requestRefresh(Context* ctx) { task_event_group_signal(ctx->eventGroup, ctx->refreshRequestedBit); } +void requestReload(Context* ctx) { + ctx->reloadRequested = true; + task_event_group_signal(ctx->eventGroup, ctx->refreshRequestedBit); +} + void onRefreshPressed(lv_event_t* e) { auto* ctx = static_cast(lv_event_get_user_data(e)); requestRefresh(ctx); @@ -250,7 +274,7 @@ void showApps(Context* ctx) { } ctx->mutex.lock(); // Rebuilding deletes the focused entry button, so the same app is focused again afterwards - std::string focused_app_id; + tt::std::string focused_app_id; bool focused_by_key = false; auto* group = lv_group_get_default(); auto* focused = group != nullptr ? lv_group_get_focused(group) : nullptr; @@ -263,15 +287,11 @@ void showApps(Context* ctx) { } lv_obj_clean(ctx->contentWrapper); - std::vector apps; - app_hub_search(nullptr, [](const AppHubApp* app, void* context) { - static_cast*>(context)->push_back(*app); - return true; - }, &apps); + tt::std::vector apps = 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) { + 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); }); ctx->appIds.clear(); @@ -331,11 +351,15 @@ void refresh(Context* ctx) { // Buried (e.g. AppHubDetailsApp is open): destroyWidgets() already released these. A refresh // request queued just before burying could still land here, so re-check rather than assume // requestRefresh() and refresh() always run against a live window. - if (ctx->refreshOperationId != 0 || ctx->contentWrapper == nullptr) { + if (ctx->refreshOperationId != 0) { return; } lvgl_lock(); + if (ctx->contentWrapper == nullptr) { + lvgl_unlock(); + return; + } lv_obj_set_hidden(ctx->refreshButton, true); lvgl_unlock(); @@ -364,8 +388,21 @@ void refresh(Context* ctx) { ctx->refreshOperationId = operation_id; // Shows the cached catalog while the new one downloads + loadApps(ctx); lvgl_lock(); - showApps(ctx); + if (ctx->contentWrapper != nullptr) { + showApps(ctx); + } + lvgl_unlock(); +} + +// Runs on appMain()'s own task, after the window resurfaced +void reload(Context* ctx) { + loadApps(ctx); + lvgl_lock(); + if (ctx->contentWrapper != nullptr) { + showApps(ctx); + } lvgl_unlock(); } @@ -389,6 +426,7 @@ void onOperationEvent(Context* ctx, const AppHubEvent& event) { } } + loadApps(ctx); lvgl_lock(); if (ctx->contentWrapper == nullptr) { // Buried (e.g. AppHubDetailsApp or a dialog is open): createWidgets() rebuilds the list on resurface @@ -432,6 +470,8 @@ void createWidgets(lv_obj_t* parent, void* userData) { // instead of hitting the network again. window_manager calls createWidgets() with the // LVGL lock already held, so this can touch widgets directly. showApps(ctx); + // The cache can be stale, so the app's task reloads it and shows the list again + requestReload(ctx); } } @@ -455,7 +495,7 @@ void onUpdateAllEvent(Context* ctx, const AppHubEvent& event) { } else if (event.type == APP_HUB_EVENT_UPDATED && event.operation_id == ctx->updateAllOperationId) { ctx->updateAllOperationId = 0; if (!ctx->failedUpdates.empty()) { - std::string names; + tt::std::string names; for (const auto& id : ctx->failedUpdates) { AppHubApp app {}; if (!names.empty()) { @@ -464,7 +504,7 @@ void onUpdateAllEvent(Context* ctx, const AppHubEvent& event) { 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)); + alertdialog::start(ctx->appInstanceId, "Update failed", ::std::format("{} {} failed to update: {}", count, count == 1 ? "app" : "apps", names)); ctx->failedUpdates.clear(); } } @@ -486,6 +526,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); + loadApps(&ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); bool shouldClose = false; @@ -509,6 +550,9 @@ int32_t appMain(int argc, char* argv[]) { onOperationEvent(&ctx, app_hub_event); } + if (!shouldClose && ctx.reloadRequested.exchange(false)) { + reload(&ctx); + } if (!shouldClose && ctx.refreshRequested.exchange(false)) { refresh(&ctx); } diff --git a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp index e2f6d71a4..3698fe50c 100644 --- a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp +++ b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp @@ -23,6 +23,9 @@ #include #include +#include +#include + namespace tt::app::apphubdetails { constexpr auto* TAG = "AppHubDetails"; @@ -38,24 +41,37 @@ constexpr size_t DESCRIPTION_MAX_SIZE = 2048; struct Context { uint32_t appInstanceId; - std::string appId; + tt::std::string appId; lv_obj_t* toolbar = nullptr; lv_obj_t* spinner = nullptr; lv_obj_t* updateButton = nullptr; lv_obj_t* updateLabel = nullptr; + lv_obj_t* descriptionLabel = nullptr; + lv_obj_t* versionLabel = nullptr; + /** Whether createWidgets() ran before, so the next call is a resurface */ + bool created = false; + + // Read from the catalog file on this app's own thread (see loadState()), as the widget code + // can't do file operations. Written with the LVGL lock held, read by the widget code. + AppHubApp app {}; + bool anotherAppBusy = false; + tt::std::string description; // Set from the LVGL task (button press), read from this app's own thread (event loop) - // both directions cross threads, hence atomic. - std::atomic installDialogId = 0; - std::atomic uninstallDialogId = 0; - std::atomic updateDialogId = 0; + ::std::atomic installDialogId = 0; + ::std::atomic uninstallDialogId = 0; + ::std::atomic updateDialogId = 0; TaskEventGroup* eventGroup = nullptr; + uint32_t reloadBit = 0; + /** Set when the window resurfaces: the catalog can change while a dialog covers it */ + ::std::atomic reloadRequested { false }; AppHubEventSubscription appHubSub {}; }; -AppHubApp getApp(Context* ctx) { +AppHubApp readApp(Context* ctx) { AppHubApp app {}; if (app_hub_get_app(ctx->appId.c_str(), &app) != ERROR_NONE) { strncpy(app.id, ctx->appId.c_str(), sizeof(app.id) - 1); @@ -81,11 +97,48 @@ bool isAnotherAppBusy(Context* ctx) { return search.busy; } +tt::std::string readDescription(Context* ctx) { + tt::std::vector buffer(DESCRIPTION_MAX_SIZE); + if (app_hub_get_description(ctx->appId.c_str(), buffer.data(), buffer.size()) != ERROR_NONE || buffer[0] == '\0') { + return "This app has no description yet."; + } + tt::std::string description = buffer.data(); + for (size_t pos = 0; (pos = description.find("\\n", pos)) != tt::std::string::npos;) { + description.replace(pos, 2, "\n"); + } + return description; +} + +/** Reads the state from App Hub. Doesn't touch widgets, so it runs without the LVGL lock. */ +void loadState(Context* ctx, bool includeDescription) { + const auto app = readApp(ctx); + const bool anotherAppBusy = isAnotherAppBusy(ctx); + tt::std::string description; + if (includeDescription) { + description = readDescription(ctx); + } + lvgl_lock(); + ctx->app = app; + ctx->anotherAppBusy = anotherAppBusy; + if (includeDescription) { + ctx->description = ::std::move(description); + } + lvgl_unlock(); +} + void updateViews(Context* ctx); +/** Loads the state and shows it. Runs on this app's own thread. */ +void refreshViews(Context* ctx, bool includeDescription = false) { + loadState(ctx, includeDescription); + lvgl_lock(); + updateViews(ctx); + lvgl_unlock(); +} + uint32_t showConfirmDialog(Context* ctx, const char* action) { - const auto message = std::format("{} {}?", action, getApp(ctx).name); - return alertdialog::start(ctx->appInstanceId, CONFIRM_TEXT, message, std::vector { CONFIRM_TEXT, CANCEL_TEXT }); + const auto message = ::std::format("{} {}?", action, ctx->app.name); + return alertdialog::start(ctx->appInstanceId, CONFIRM_TEXT, message, ::std::vector<::std::string> { CONFIRM_TEXT, CANCEL_TEXT }); } void onBackPressed(lv_event_t* e) { @@ -117,9 +170,7 @@ void queueOperation(Context* ctx, error_t (*operation)(const char* id, uint32_t* alertdialog::start(ctx->appInstanceId, "Error", failure); return; } - lvgl_lock(); - updateViews(ctx); - lvgl_unlock(); + refreshViews(ctx); } void onOperationEvent(Context* ctx, const AppHubEvent& event) { @@ -130,9 +181,7 @@ void onOperationEvent(Context* ctx, const AppHubEvent& event) { const char* message = (event.type == APP_HUB_EVENT_REMOVED) ? "Failed to uninstall app" : "Failed to install app"; alertdialog::start(ctx->appInstanceId, "Error", message); } - lvgl_lock(); - updateViews(ctx); - lvgl_unlock(); + refreshViews(ctx); } void updateViews(Context* ctx) { @@ -140,14 +189,16 @@ void updateViews(Context* ctx) { if (ctx->toolbar == nullptr) { return; } + lv_label_set_text(ctx->descriptionLabel, ctx->description.c_str()); + lv_label_set_text_fmt(ctx->versionLabel, "Version %s", ctx->app.in_catalog ? ctx->app.version_name : ctx->app.installed_version_name); lvgl_toolbar_clear_actions(ctx->toolbar); ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar); lv_obj_set_hidden(ctx->updateLabel, true); // This app's install or removal continues after the window closes, so it can already be running when the window opens. // Another app's install or removal blocks this app's buttons until it's done. - const auto app = getApp(ctx); - const bool busy = app.operation_pending || ((app.installed || app.in_catalog) && isAnotherAppBusy(ctx)); + const auto& app = ctx->app; + const bool busy = app.operation_pending || ((app.installed || app.in_catalog) && ctx->anotherAppBusy); lv_obj_set_hidden(ctx->spinner, !busy); if (busy) { return; @@ -170,7 +221,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - const auto app = getApp(ctx); + const auto& app = ctx->app; ctx->toolbar = lvgl_toolbar_create(parent, app.name); // The global toolbar nav callback only knows how to stop old-model apps. lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); @@ -184,28 +235,27 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_label_set_text(ctx->updateLabel, "Update available!"); lv_obj_set_style_text_color(ctx->updateLabel, lv_color_make(0xff, 0xff, 00), LV_STATE_DEFAULT); - auto* description_label = lv_label_create(wrapper); - lv_obj_set_width(description_label, LV_PCT(100)); - lv_label_set_long_mode(description_label, LV_LABEL_LONG_MODE_WRAP); - std::vector description_buffer(DESCRIPTION_MAX_SIZE); - if (app_hub_get_description(ctx->appId.c_str(), description_buffer.data(), description_buffer.size()) == ERROR_NONE && description_buffer[0] != '\0') { - std::string description = description_buffer.data(); - for (size_t pos = 0; (pos = description.find("\\n", pos)) != std::string::npos;) { - description.replace(pos, 2, "\n"); - } - lv_label_set_text(description_label, description.c_str()); - } else { - lv_label_set_text(description_label, "This app has no description yet."); - } + ctx->descriptionLabel = lv_label_create(wrapper); + lv_obj_set_width(ctx->descriptionLabel, LV_PCT(100)); + lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_MODE_WRAP); - auto* version_label = lv_label_create(wrapper); - lv_label_set_text_fmt(version_label, "Version %s", app.in_catalog ? app.version_name : app.installed_version_name); + ctx->versionLabel = lv_label_create(wrapper); updateViews(ctx); + + // The cached state shows right away. The app's task then reloads it, as the catalog can have + // changed while a dialog covered this window. + if (ctx->created) { + ctx->reloadRequested = true; + task_event_group_signal(ctx->eventGroup, ctx->reloadBit); + } + ctx->created = true; } void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); + ctx->descriptionLabel = nullptr; + ctx->versionLabel = nullptr; ctx->toolbar = nullptr; ctx->spinner = nullptr; ctx->updateButton = nullptr; @@ -228,8 +278,10 @@ int32_t appMain(int argc, char* argv[]) { AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); + check(task_event_group_claim_bit(&event_group, &ctx.reloadBit) == ERROR_NONE); check(app_hub_event_subscribe(&ctx.appHubSub, &event_group) == ERROR_NONE); + loadState(&ctx, true); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); bool shouldClose = false; @@ -262,10 +314,17 @@ int32_t appMain(int argc, char* argv[]) { AppHubEvent app_hub_event {}; while (!shouldClose && app_hub_event_poll(&ctx.appHubSub, &app_hub_event) == ERROR_NONE) { - if (app_hub_event.type != APP_HUB_EVENT_REFRESHING && app_hub_event.type != APP_HUB_EVENT_REFRESHED) { + if (app_hub_event.type == APP_HUB_EVENT_REFRESHED) { + // A new catalog can change the version, description and actions + ctx.reloadRequested = true; + } else if (app_hub_event.type != APP_HUB_EVENT_REFRESHING) { onOperationEvent(&ctx, app_hub_event); } } + + if (!shouldClose && ctx.reloadRequested.exchange(false)) { + refreshViews(&ctx, true); + } } // Queued operations continue in the background after the window closes diff --git a/Tactility/Source/app/btmanage/BtManage.cpp b/Tactility/Source/app/btmanage/BtManage.cpp index 6eca2e9f8..54e52ece3 100644 --- a/Tactility/Source/app/btmanage/BtManage.cpp +++ b/Tactility/Source/app/btmanage/BtManage.cpp @@ -3,10 +3,11 @@ #include #include #include -#include +#include +#include #include -#include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include +#include #include #include @@ -23,7 +25,6 @@ constexpr auto* TAG = "BtManage"; extern const ::AppManifest manifest; - static void onBtToggled(void* /*context*/, bool requestOn) { #if defined(CONFIG_BT_NIMBLE_ENABLED) // Dispatched, so file IO doesn't block the UI @@ -257,9 +258,9 @@ extern const ::AppManifest manifest = { .id = "tactility.btmanage", .name = "Bluetooth", .category = APP_CATEGORY_SETTINGS, - .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = 0, - .stack = {} + .stack = {}, }; } // namespace tt::app::btmanage diff --git a/Tactility/Source/app/meshchat/MeshChannels.cpp b/Tactility/Source/app/meshchat/MeshChannels.cpp index ea2580915..143b2c50d 100644 --- a/Tactility/Source/app/meshchat/MeshChannels.cpp +++ b/Tactility/Source/app/meshchat/MeshChannels.cpp @@ -63,20 +63,14 @@ struct Context { ::std::string pendingChannelName; tt::std::vector channels; // Valid while the window is shown - lv_obj_t* statusLabel = nullptr; + lv_obj_t* networkHeader = nullptr; lv_obj_t* content = nullptr; + NetworkInfo network; }; -bool isMeshOn() { - return mesh_get_state() == MESH_STATE_ON; -} - -::std::string getStatusText() { - return ::std::string("Mesh: ") + mesh_state_to_string(mesh_get_state()); -} - /** Queries the service thread, so it's called without the LVGL lock */ void loadData(Context* ctx) { + ctx->network = loadNetworkInfo(); ctx->channels.assign(CHANNEL_LIMIT, MeshChannel {}); size_t count = 0; if (mesh_get_channels(ctx->channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) { @@ -118,14 +112,14 @@ void updateWidgets(Context* ctx) { if (ctx->content == nullptr) { return; } - lv_label_set_text(ctx->statusLabel, getStatusText().c_str()); - lv_obj_set_hidden(ctx->statusLabel, isMeshOn()); + updateNetworkHeader(ctx->networkHeader, ctx->network); lv_obj_clean(ctx->content); auto* channelList = createCard(ctx->content); for (size_t i = 0; i < ctx->channels.size(); i++) { addListButton(channelList, ctx->channels[i].name, onChannelPressed, ctx, (intptr_t)i); } + lvgl_list_hide_last_divider(channelList); } void createWidgets(lv_obj_t* parent, void* userData) { @@ -139,9 +133,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx); lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx); - ctx->statusLabel = lv_label_create(parent); - lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); - lv_obj_set_style_pad_hor(ctx->statusLabel, LV_DPX(8), LV_STATE_DEFAULT); + ctx->networkHeader = createNetworkHeader(parent); ctx->content = createContent(parent); @@ -150,7 +142,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); - ctx->statusLabel = nullptr; + ctx->networkHeader = nullptr; ctx->content = nullptr; } diff --git a/Tactility/Source/app/meshchat/MeshChatCommon.cpp b/Tactility/Source/app/meshchat/MeshChatCommon.cpp index 34c623a01..8e11f0d2a 100644 --- a/Tactility/Source/app/meshchat/MeshChatCommon.cpp +++ b/Tactility/Source/app/meshchat/MeshChatCommon.cpp @@ -10,6 +10,9 @@ namespace tt::app::meshchat { constexpr auto* TAG = "MeshChat"; +/** The padding of the content area that holds the cards */ +#define CONTENT_PADDING LV_DPX(4) + void startApp(const char* id, int argc, const char* const argv[]) { AppStartContext context; if (app_start_context_from_id(id, &context) != ERROR_NONE) { @@ -23,6 +26,73 @@ void startApp(const char* id, int argc, const char* const argv[]) { } } +static const char* getNetworkLabel(const char* id) { + if (strcmp(id, "meshcore") == 0) return "MeshCore"; + if (strcmp(id, "meshtastic") == 0) return "Meshtastic"; + return id; +} + +NetworkInfo loadNetworkInfo() { + NetworkInfo info; + // A backend and a radio stay selected while mesh is off + if (mesh_get_state() != MESH_STATE_ON) { + return info; + } + info.running = true; + char id[MESH_BACKEND_ID_MAX_LENGTH + 1]; + if (mesh_get_backend(id, sizeof(id)) != ERROR_NONE) { + return info; + } + char radio[MESH_NAME_MAX_LENGTH + 1]; + if (mesh_get_radio(radio, sizeof(radio)) != ERROR_NONE) { + return info; + } + const size_t count = mesh_get_radio_count(); + for (size_t i = 0; i < count; i++) { + char name[MESH_NAME_MAX_LENGTH + 1]; + MeshRadioKind kind; + if (mesh_get_radio_name(i, name, sizeof(name)) == ERROR_NONE && strcmp(name, radio) == 0 && mesh_get_radio_kind(i, &kind) == ERROR_NONE) { + snprintf(info.network, sizeof(info.network), "%s", getNetworkLabel(id)); + snprintf(info.modulation, sizeof(info.modulation), "%s", kind == MESH_RADIO_LORA ? "LoRa" : "ESP-NOW"); + break; + } + } + return info; +} + +// The spans: "Using ", the network, " via ", the modulation. While mesh is off: "Mesh is ", "OFF", "", "" +constexpr int32_t NETWORK_SPAN = 1; +constexpr int32_t MODULATION_SPAN = 3; + +lv_obj_t* createNetworkHeader(lv_obj_t* parent) { + auto* header = lv_spangroup_create(parent); + lv_obj_set_width(header, LV_PCT(100)); + lv_obj_set_height(header, LV_SIZE_CONTENT); + // Lines up with the cards: the content's padding plus the card's margin, which is the theme's container padding + auto* probe = lv_obj_create(parent); + const int32_t cardMargin = lv_obj_get_style_pad_left(probe, LV_PART_MAIN); + lv_obj_delete(probe); + lv_obj_set_style_pad_hor(header, CONTENT_PADDING + cardMargin, LV_STATE_DEFAULT); + + const auto secondary = lv_theme_get_color_secondary(header); + for (const char* text : { "Using ", "", " via ", "" }) { + auto* span = lv_spangroup_add_span(header); + lv_span_set_text_static(span, text); + } + lv_style_set_text_color(lv_span_get_style(lv_spangroup_get_child(header, NETWORK_SPAN)), secondary); + lv_style_set_text_color(lv_span_get_style(lv_spangroup_get_child(header, MODULATION_SPAN)), secondary); + return header; +} + +void updateNetworkHeader(lv_obj_t* header, const NetworkInfo& info) { + lv_obj_set_hidden(header, info.running && info.network[0] == '\0'); + lv_span_set_text(lv_spangroup_get_child(header, 0), info.running ? "Using " : "Mesh is "); + lv_span_set_text(lv_spangroup_get_child(header, NETWORK_SPAN), info.running ? info.network : "OFF"); + lv_span_set_text(lv_spangroup_get_child(header, 2), info.running ? " via " : ""); + lv_span_set_text(lv_spangroup_get_child(header, MODULATION_SPAN), info.modulation); + lv_spangroup_refresh(header); +} + lv_obj_t* createContent(lv_obj_t* parent) { auto* content = lv_obj_create(parent); // The theme's container padding is the margin of the cards, read before it is overridden below @@ -30,8 +100,8 @@ lv_obj_t* createContent(lv_obj_t* parent) { lv_obj_set_width(content, LV_PCT(100)); lv_obj_set_flex_grow(content, 1); lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(content, LV_DPX(4), LV_STATE_DEFAULT); - lv_obj_set_style_pad_row(content, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(content, CONTENT_PADDING, LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(content, CONTENT_PADDING, LV_STATE_DEFAULT); lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT); return content; } @@ -46,7 +116,10 @@ lv_obj_t* createCard(lv_obj_t* parent) { lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(card, 0, LV_STATE_DEFAULT); + // The items' backgrounds and dividers follow the card's rounded corners + lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT); return card; } + } diff --git a/Tactility/Source/app/meshchat/MeshContacts.cpp b/Tactility/Source/app/meshchat/MeshContacts.cpp index fd33f2a54..2ac333564 100644 --- a/Tactility/Source/app/meshchat/MeshContacts.cpp +++ b/Tactility/Source/app/meshchat/MeshContacts.cpp @@ -58,20 +58,14 @@ struct Context { ::std::string pendingContactName; tt::std::vector contacts; // Valid while the window is shown - lv_obj_t* statusLabel = nullptr; + lv_obj_t* networkHeader = nullptr; lv_obj_t* content = nullptr; + NetworkInfo network; }; -bool isMeshOn() { - return mesh_get_state() == MESH_STATE_ON; -} - -::std::string getStatusText() { - return ::std::string("Mesh: ") + mesh_state_to_string(mesh_get_state()); -} - /** Queries the service thread, so it's called without the LVGL lock */ void loadData(Context* ctx) { + ctx->network = loadNetworkInfo(); ctx->contacts.assign(CONTACT_LIMIT, MeshContact {}); size_t count = 0; if (mesh_get_contacts(ctx->contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) { @@ -109,8 +103,7 @@ void updateWidgets(Context* ctx) { if (ctx->content == nullptr) { return; } - lv_label_set_text(ctx->statusLabel, getStatusText().c_str()); - lv_obj_set_hidden(ctx->statusLabel, isMeshOn()); + updateNetworkHeader(ctx->networkHeader, ctx->network); lv_obj_clean(ctx->content); auto* contactList = createCard(ctx->content); @@ -120,6 +113,7 @@ void updateWidgets(Context* ctx) { if (ctx->contacts.empty()) { lvgl_list_add_text(contactList, "No contacts yet. Add a node that you've heard, or wait for a direct message."); } + lvgl_list_hide_last_divider(contactList); } void createWidgets(lv_obj_t* parent, void* userData) { @@ -133,9 +127,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { auto* menuButton = lvgl_toolbar_add_text_button_action(toolbar, LVGL_ICON_SHARED_MORE_VERT, onMenuPressed, ctx); lv_obj_set_style_text_font(menuButton, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); - ctx->statusLabel = lv_label_create(parent); - lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); - lv_obj_set_style_pad_hor(ctx->statusLabel, LV_DPX(8), LV_STATE_DEFAULT); + ctx->networkHeader = createNetworkHeader(parent); ctx->content = createContent(parent); @@ -144,7 +136,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); - ctx->statusLabel = nullptr; + ctx->networkHeader = nullptr; ctx->content = nullptr; } diff --git a/Tactility/Source/service/espnow/EspNow.cpp b/Tactility/Source/service/espnow/EspNow.cpp deleted file mode 100644 index 61156b83e..000000000 --- a/Tactility/Source/service/espnow/EspNow.cpp +++ /dev/null @@ -1,100 +0,0 @@ -#ifdef ESP_PLATFORM -#include -#endif - -#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) - -#include -#include - -#include - -namespace tt::service::espnow { - -constexpr auto* TAG = "EspNow"; - -void enable(const EspNowConfig& config) { - auto service = findService(); - if (service != nullptr) { - service->enable(config); - } else { - LOG_E(TAG, "Service not found"); - } -} - -void disable() { - auto service = findService(); - if (service != nullptr) { - service->disable(); - } else { - LOG_E(TAG, "Service not found"); - } -} - -bool isEnabled() { - auto service = findService(); - if (service != nullptr) { - return service->isEnabled(); - } else { - LOG_E(TAG, "Service not found"); - return false; - } -} - -bool addPeer(const esp_now_peer_info_t& peer) { - auto service = findService(); - if (service != nullptr) { - return service->addPeer(peer); - } else { - LOG_E(TAG, "Service not found"); - return false; - } -} - -bool send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength) { - auto service = findService(); - if (service != nullptr) { - return service->send(address, buffer, bufferLength); - } else { - LOG_E(TAG, "Service not found"); - return false; - } -} - -ReceiverSubscription subscribeReceiver(std::function onReceive) { - auto service = findService(); - if (service != nullptr) { - return service->subscribeReceiver(onReceive); - } else { - LOG_E(TAG, "Service not found"); - return -1; - } -} - -void unsubscribeReceiver(ReceiverSubscription subscription) { - auto service = findService(); - if (service != nullptr) { - service->unsubscribeReceiver(subscription); - } else { - LOG_E(TAG, "Service not found"); - } -} - -uint32_t getVersion() { - auto service = findService(); - if (service != nullptr) { - return service->getVersion(); - } - LOG_E(TAG, "Service not found"); - return 0; -} - -size_t getMaxDataLength() { - auto v = getVersion(); - if (v == 0) return 0; - return v >= 2 ? MAX_DATA_LEN_V2 : MAX_DATA_LEN_V1; -} - -} - -#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_ESP_HOSTED_ENABLED diff --git a/Tactility/Source/service/espnow/EspNowService.cpp b/Tactility/Source/service/espnow/EspNowService.cpp deleted file mode 100644 index eadf2a22e..000000000 --- a/Tactility/Source/service/espnow/EspNowService.cpp +++ /dev/null @@ -1,202 +0,0 @@ -#ifdef ESP_PLATFORM -#include -#endif - -#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) - -#include -#include -#include -#include -#include - -#include -#include -#include - -#include - -namespace tt::service::espnow { - -extern const ServiceManifest manifest; - -constexpr auto* TAG = "EspNowService"; -static uint8_t BROADCAST_MAC[ESP_NOW_ETH_ALEN]; - -constexpr TickType_t MAX_DELAY = 1000U / portTICK_PERIOD_MS; -constexpr bool isBroadcastAddress(uint8_t address[ESP_NOW_ETH_ALEN]) { return memcmp(address, BROADCAST_MAC, ESP_NOW_ETH_ALEN) == 0; } - -bool EspNowService::onStart(ServiceContext& service) { - auto lock = mutex.asScopedLock(); - lock.lock(); - - memset(BROADCAST_MAC, 0xFF, sizeof(BROADCAST_MAC)); - - return true; -} - -void EspNowService::onStop(ServiceContext& service) { - auto lock = mutex.asScopedLock(); - lock.lock(); - - if (isEnabled()) { - disable(); - } -} - -// region Enable - -void EspNowService::enable(const EspNowConfig& config) { - getMainDispatcher().dispatch([this, config] { - enableFromDispatcher(config); - }); -} - -void EspNowService::enableFromDispatcher(const EspNowConfig& config) { - auto lock = mutex.asScopedLock(); - lock.lock(); - - if (enabled) { - return; - } - - if (!backend::init(config, receiveCallback)) { - LOG_E(TAG, "backend::init() failed"); - return; - } - - espnowVersion = backend::getVersion(); - if (espnowVersion != 0) { - LOG_I(TAG, "ESP-NOW version: %u.0", (unsigned)espnowVersion); - } else { - LOG_W(TAG, "Failed to get ESP-NOW version"); - } - - // Add default unencrypted broadcast peer - esp_now_peer_info_t broadcast_peer; - memset(&broadcast_peer, 0, sizeof(esp_now_peer_info_t)); - memcpy(broadcast_peer.peer_addr, BROADCAST_MAC, sizeof(BROADCAST_MAC)); - service::espnow::addPeer(broadcast_peer); - - enabled = true; -} - -// endregion Enable - -// region Disable - -void EspNowService::disable() { - getMainDispatcher().dispatch([this]() { - disableFromDispatcher(); - }); -} - -void EspNowService::disableFromDispatcher() { - auto lock = mutex.asScopedLock(); - lock.lock(); - - if (!enabled) { - return; - } - - if (!backend::deinit()) { - LOG_E(TAG, "backend::deinit() failed"); - } - - espnowVersion = 0; - enabled = false; -} - -// region Disable - -// region Callbacks - -void EspNowService::receiveCallback(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { - auto service = findService(); - if (service == nullptr) { - LOG_E(TAG,"Service not running"); - return; - } - service->onReceive(receiveInfo, data, length); -} - -void EspNowService::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { - auto lock = mutex.asScopedLock(); - lock.lock(); - - LOG_D(TAG, "Received %d bytes", length); - - for (const auto& item: subscriptions) { - item.onReceive(receiveInfo, data, length); - } -} - -// endregion Callbacks - -bool EspNowService::isEnabled() const { - auto lock = mutex.asScopedLock(); - lock.lock(); - return enabled; -} - -bool EspNowService::addPeer(const esp_now_peer_info_t& peer) { - if (!backend::addPeer(peer)) { - LOG_E(TAG,"Failed to add peer"); - return false; - } else { - LOG_I(TAG, "Peer added"); - return true; - } -} - -bool EspNowService::send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength) { - auto lock = mutex.asScopedLock(); - lock.lock(); - - if (!isEnabled()) { - return false; - } else { - return backend::send(address, buffer, bufferLength); - } -} - -ReceiverSubscription EspNowService::subscribeReceiver(std::function onReceive) { - auto lock = mutex.asScopedLock(); - lock.lock(); - - auto id = lastSubscriptionId++; - - subscriptions.push_back(ReceiverSubscriptionData { - .id = id, - .onReceive = onReceive - }); - - return id; -} - -void EspNowService::unsubscribeReceiver(ReceiverSubscription subscriptionId) { - auto lock = mutex.asScopedLock(); - lock.lock(); - std::erase_if(subscriptions, [subscriptionId](auto& subscription) { return subscription.id == subscriptionId; }); -} - -uint32_t EspNowService::getVersion() const { - auto lock = mutex.asScopedLock(); - lock.lock(); - return espnowVersion; -} - -std::shared_ptr findService() { - return std::static_pointer_cast( - findServiceById(manifest.id) - ); -} - -extern const ServiceManifest manifest = { - .id = "tactility.espnow", - .createService = create -}; - -} - -#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_ESP_HOSTED_ENABLED diff --git a/Tests/Python/tt_test.py b/Tests/Python/tt_test.py index 9fc766fea..4dea42208 100644 --- a/Tests/Python/tt_test.py +++ b/Tests/Python/tt_test.py @@ -89,7 +89,7 @@ class TtTest(unittest.TestCase): with open(os.path.join(directory, "manifest.properties"), "w") as file: file.write(f"[target]\ntarget.sdk={version}\ntarget.platforms={platforms}\n") - def run_tt(self, *arguments, tt_path=TT_PATH, environment=None): + def run_tt(self, *arguments, tt_path=TT_PATH, environment=None, input_text=""): extra_environment = environment or {} environment = dict(os.environ) environment.pop("TACTILITY_SDK_PATH", None) @@ -97,7 +97,7 @@ class TtTest(unittest.TestCase): environment["HOME"] = self.home environment["TT_CDN"] = f"http://127.0.0.1:{self.server.server_address[1]}" environment.pop("TACTILITY_SDK_VERSION", None) - return subprocess.run([sys.executable, tt_path, *arguments], cwd=self.work, env=environment, capture_output=True, text=True, timeout=60) + return subprocess.run([sys.executable, tt_path, *arguments], cwd=self.work, env=environment, capture_output=True, text=True, timeout=60, input=input_text) def sdk_path(self, *parts): return os.path.join(self.home, ".tactility", "sdk", VERSION, *parts) @@ -147,6 +147,94 @@ class TtTest(unittest.TestCase): self.assertEqual(result.returncode, 1) self.assertIn("no manifest.properties found", result.stderr) + def publish_new_project(self, files=None, checksum=None): + files = files or { + "gui/manifest.properties": "target.sdk=@SDK_VERSION@\nid=@PACKAGE_ID@\napp.0.id=@APP_ID@\napp.0.name=@APP_NAME@\n", + "gui/main/Source/main.c": "// gui\n", + "terminal/manifest.properties": "target.sdk=@SDK_VERSION@\nid=@PACKAGE_ID@\napp.0.id=@APP_ID@\napp.0.name=@APP_NAME@\napp.0.headless=true\n", + "terminal/main/Source/main.c": "// terminal\n", + } + version_directory = os.path.join(self.cdn, "sdk", VERSION) + archive = make_zip(files) + with open(os.path.join(version_directory, "new-project.zip"), "wb") as file: + file.write(archive) + index_path = os.path.join(version_directory, "index.json") + with open(index_path) as file: + index = json.load(file) + index["newProject"] = {"file": "new-project.zip", "sha256": checksum or hashlib.sha256(archive).hexdigest()} + with open(index_path, "w") as file: + json.dump(index, file) + + def read_work_file(self, *parts): + with open(os.path.join(self.work, *parts)) as file: + return file.read() + + def test_new_project_uses_defaults_and_the_gui_variant(self): + self.publish_new_project() + result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n") + self.assertEqual(result.returncode, 3, result.stderr) + self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\nid=example.helloworld\napp.0.id=helloworld\napp.0.name=Hello World\n") + self.assertEqual(self.read_work_file("main", "Source", "main.c"), "// gui\n") + self.assertEqual(json.loads(result.stdout)["version"], VERSION) + + def test_new_project_with_answers_and_the_terminal_variant(self): + self.publish_new_project() + result = self.run_tt("echo", input_text=f"yes\n{VERSION}\nmy.package\nmyapp\nMy App\ny\n") + self.assertEqual(result.returncode, 3, result.stderr) + self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\nid=my.package\napp.0.id=myapp\napp.0.name=My App\napp.0.headless=true\n") + self.assertEqual(self.read_work_file("main", "Source", "main.c"), "// terminal\n") + + def test_new_project_asks_again_for_invalid_answers(self): + self.publish_new_project() + result = self.run_tt("echo", input_text=f"y\n..\n{VERSION}\nab\nmy.package\n\nM\nMy App\n\n") + self.assertEqual(result.returncode, 3, result.stderr) + self.assertIn("invalid SDK version", result.stderr) + self.assertIn("use 5 to 32 letters, digits and dots", result.stderr) + self.assertIn("use 2 to 32 letters, digits, spaces and dashes", result.stderr) + self.assertIn("id=my.package", self.read_work_file("manifest.properties")) + + def test_new_project_rejects_a_wrong_checksum(self): + self.publish_new_project(checksum="0" * 64) + result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n") + self.assertEqual(result.returncode, 1) + self.assertIn("doesn't match its checksum", result.stderr) + self.assertFalse(os.path.exists(os.path.join(self.work, "manifest.properties"))) + + def test_new_project_needs_the_sdk_to_have_one(self): + result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n") + self.assertEqual(result.returncode, 1) + self.assertIn("has no new project", result.stderr) + + def test_new_project_does_not_overwrite(self): + self.publish_new_project() + os.makedirs(os.path.join(self.work, "main")) + result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n") + self.assertEqual(result.returncode, 1) + self.assertIn("already has", result.stderr) + + def test_new_project_from_the_local_sdk(self): + local = os.path.join(self.root, "local") + os.makedirs(os.path.join(local, "sdk", VERSION, "tools")) + with open(os.path.join(local, "sdk", VERSION, "tools", "echo.py"), "w") as file: + file.write(ECHO_TOOL) + shutil.copy(os.path.join(TOOLS_PATH, "_bootstrap.py"), os.path.join(local, "sdk", VERSION, "tools")) + with open(os.path.join(local, "sdk", VERSION, "new-project.zip"), "wb") as file: + file.write(make_zip({"gui/manifest.properties": "target.sdk=@SDK_VERSION@\n"})) + result = self.run_tt("echo", environment={"TACTILITY_SDK_PATH": local}, input_text=f"y\n{VERSION}\n\n\n\n\n") + self.assertEqual(result.returncode, 3, result.stderr) + self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\n") + + def test_without_a_project_the_sdk_version_can_be_given(self): + result = self.run_tt("echo", "a", input_text=f"n\n{VERSION}\n") + self.assertEqual(result.returncode, 3, result.stderr) + self.assertEqual(json.loads(result.stdout)["version"], VERSION) + self.assertFalse(os.path.exists(os.path.join(self.work, "manifest.properties"))) + + def test_without_a_project_and_version_it_fails(self): + result = self.run_tt("echo", input_text="n\n\n") + self.assertEqual(result.returncode, 1) + self.assertIn("no manifest.properties found", result.stderr) + def test_unknown_and_invalid_commands_are_not_found(self): self.write_manifest(self.work) for command in ("nope", "../echo", "Echo"): diff --git a/tt.py b/tt.py index b15e27d45..cebdaf674 100755 --- a/tt.py +++ b/tt.py @@ -10,6 +10,7 @@ The SDK version is read from ./manifest.properties, or else from the first Apps/ """ import hashlib +import io import json import os import re @@ -20,11 +21,15 @@ import time import urllib.request import zipfile -TT_VERSION = "1.1.1" +TT_VERSION = "1.2.0" CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") HOME_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility") INDEX_VALIDITY_SECONDS = 3600 COMMAND_PATTERN = re.compile(r"^[a-z0-9-]+$") +VERSION_PATTERN = re.compile(r"^[0-9A-Za-z.+-]+$") +ID_PATTERN = re.compile(r"^[0-9A-Za-z.]{5,32}$") +NAME_PATTERN = re.compile(r"^[0-9A-Za-z -]{2,32}$") +NEW_PROJECT_ARCHIVE = "new-project.zip" def fail(message): @@ -78,16 +83,124 @@ def find_manifest(): return None +def is_valid_version(version): + return VERSION_PATTERN.match(version) is not None and ".." not in version + + +def ask(question, default=None, is_valid=None, description="invalid value"): + """Asks until the answer is valid. An empty answer or the end of the input gives the default. Returns None when there is none.""" + suffix = f" [{default}]" if default is not None else "" + while True: + try: + # On stderr, so the prompts don't end up in the output of the command + print(f"{question}{suffix}: ", end="", file=sys.stderr, flush=True) + answer = input().strip() + except EOFError: + answer = "" + if not answer: + return default + if is_valid is None or is_valid(answer): + return answer + print(f"tt: {description}", file=sys.stderr) + + +def ask_yes_no(question, default): + answer = ask(f"{question} [{'Y/n' if default else 'y/N'}]") + if answer is None: + return default + return answer.lower() in ("y", "yes") + + +def read_new_project_archive(version): + """Returns the content of the new project archive of the SDK version, from the local SDK or else the CDN""" + local_path = os.environ.get("TACTILITY_SDK_PATH") or None + if local_path is not None: + archive_path = os.path.join(version_path(os.path.abspath(local_path), version), NEW_PROJECT_ARCHIVE) + if not os.path.isfile(archive_path): + fail(f"new project not found: {archive_path}") + with open(archive_path, "rb") as file: + return file.read() + version_directory = version_path(HOME_DIRECTORY, version) + os.makedirs(version_directory, exist_ok=True) + entry = load_index(version, version_directory).get("newProject") + if entry is None: + fail(f"SDK {version} has no new project") + archive_path = os.path.join(version_directory, NEW_PROJECT_ARCHIVE) + if not download(sdk_url(version, entry["file"]), archive_path): + fail(f"failed to download the new project of SDK {version}") + try: + with open(archive_path, "rb") as file: + content = file.read() + finally: + os.remove(archive_path) + if hashlib.sha256(content).hexdigest() != entry["sha256"]: + fail(f"the new project of SDK {version} doesn't match its checksum") + return content + + +def generate_project(version, package_id, app_id, app_name, is_terminal): + """Extracts the new project of the SDK version into . and fills in the answers""" + variant = "terminal" if is_terminal else "gui" + root = os.path.realpath(".") + created = [] + with zipfile.ZipFile(io.BytesIO(read_new_project_archive(version))) as archive: + for member in archive.infolist(): + if member.is_dir() or not member.filename.startswith(f"{variant}/"): + continue + relative_path = member.filename[len(variant) + 1:] + destination = os.path.realpath(os.path.join(root, relative_path)) + if not destination.startswith(root + os.sep): + fail(f"invalid entry in the new project: {member.filename}") + content = archive.read(member) + if relative_path == "manifest.properties": + text = content.decode("utf-8") + for placeholder, value in (("@SDK_VERSION@", version), ("@PACKAGE_ID@", package_id), ("@APP_ID@", app_id), ("@APP_NAME@", app_name)): + text = text.replace(placeholder, value) + content = text.encode("utf-8") + os.makedirs(os.path.dirname(destination), exist_ok=True) + with open(destination, "wb") as file: + file.write(content) + created.append(relative_path) + if "manifest.properties" not in created: + fail(f"the new project of SDK {version} has no {variant} variant") + return created + + +def offer_new_project(): + """Asks to generate a new app project. Returns the manifest path when it did, else None""" + if not ask_yes_no("Generate a new app project in this folder?", False): + return None + if os.path.exists("manifest.properties") or os.path.exists("main"): + fail("this folder already has a manifest.properties or main/") + version = ask("SDK version to use", None, is_valid_version, "invalid SDK version") + if version is None: + fail("no SDK version given") + package_id = ask("Package id", "example.helloworld", ID_PATTERN.match, "use 5 to 32 letters, digits and dots") + app_id = ask("App id", "helloworld", ID_PATTERN.match, "use 5 to 32 letters, digits and dots") + app_name = ask("App name", "Hello World", NAME_PATTERN.match, "use 2 to 32 letters, digits, spaces and dashes") + is_terminal = ask_yes_no("Terminal app?", False) + for path in generate_project(version, package_id, app_id, app_name, is_terminal): + print(f"Created {path}", file=sys.stderr) + return "manifest.properties" + + def resolve_target(): """Returns the SDK version and platforms of the app manifest""" manifest_path = find_manifest() if manifest_path is None: - fail("no manifest.properties found in . or Apps/*/ or apps/*/") + print("tt: no manifest.properties found in . or Apps/*/ or apps/*/", file=sys.stderr) + manifest_path = offer_new_project() + if manifest_path is None: + # Commands like "tt shell" don't need an app, only the tools of an SDK version + version = ask("SDK version to use without a project (leave empty to abort)", None, is_valid_version, "invalid SDK version") + if version is None: + fail("no manifest.properties found in . or Apps/*/ or apps/*/") + return version, [] manifest = read_properties(manifest_path) version = manifest.get("target.sdk") if not version: fail(f"target.sdk not found in {manifest_path}") - if not re.match(r"^[0-9A-Za-z.+-]+$", version) or ".." in version: + if not is_valid_version(version): fail(f"invalid target.sdk in {manifest_path}: {version}") platforms = [platform.strip() for platform in manifest.get("target.platforms", "").split(",") if platform.strip()] return version, platforms