diff --git a/.github/workflows/cardputer-zero-linux.yml b/.github/workflows/cardputer-zero-linux.yml new file mode 100644 index 00000000..827d3483 --- /dev/null +++ b/.github/workflows/cardputer-zero-linux.yml @@ -0,0 +1,63 @@ +name: Cardputer Zero Linux + +on: + push: + paths: + - "apps/linux_sim/**" + - "apps/linux_rpi/**" + - "platform/linux/**" + - ".github/workflows/cardputer-zero-linux.yml" + pull_request: + paths: + - "apps/linux_sim/**" + - "apps/linux_rpi/**" + - "platform/linux/**" + - ".github/workflows/cardputer-zero-linux.yml" + +jobs: + linux-shell: + name: Linux Simulator Shell + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify shared/platform UI boundaries + run: python3 scripts/check_platform_ui_boundaries.py + + - name: Install Linux simulator dependencies + run: | + sudo apt-get update + sudo apt-get install -y ninja-build xorg-dev libasound2-dev libegl1-mesa-dev libwayland-dev libxkbcommon-dev wayland-protocols + + - name: Configure simulator + working-directory: apps/linux_sim + run: cmake --preset linux-simulator-debug + + - name: Build simulator + working-directory: apps/linux_sim + run: cmake --build --preset linux-simulator-debug-build + + - name: Configure smoke tests + working-directory: apps/linux_sim + run: cmake --preset linux-test-debug + + - name: Build smoke tests + working-directory: apps/linux_sim + run: cmake --build --preset linux-test-debug-build + + - name: Run smoke tests + working-directory: apps/linux_sim + run: ctest --preset linux-debug-test + + - name: Configure Pi OS device shell + working-directory: apps/linux_rpi + run: cmake --preset linux-device-debug + + - name: Build Pi OS device shell + working-directory: apps/linux_rpi + run: cmake --build --preset linux-device-debug-build + + - name: Verify split Linux workflow from WSL-style entrypoint + working-directory: apps/linux_sim + run: bash scripts/wsl-validate.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62b13bd6..86d31923 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,16 @@ on: pull_request: jobs: + boundary: + name: Boundary Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify shared/platform UI boundaries + run: python3 scripts/check_platform_ui_boundaries.py + format: name: Format Check runs-on: ubuntu-latest @@ -117,6 +127,7 @@ jobs: name: Publish GitHub Release runs-on: ubuntu-latest needs: + - boundary - format - build if: startsWith(github.ref, 'refs/tags/v') diff --git a/.gitignore b/.gitignore index f584eee5..19f01c0f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ libdeps/ test/ .trunk/ +.wolf/ +.claude/ + +CLAUDE.md + # Compiled Object files *.o *.a diff --git a/CHANGELOG.md b/CHANGELOG.md index d3437c0f..63155777 100644 Binary files a/CHANGELOG.md and b/CHANGELOG.md differ diff --git a/apps/README.md b/apps/README.md index 808692fd..46c015b1 100644 --- a/apps/README.md +++ b/apps/README.md @@ -11,3 +11,8 @@ An app shell is responsible for: During the migration period, the root-level `platformio.ini` and current root build flow remain authoritative for the Arduino/PlatformIO targets. The directories here define the target structure we are converging toward. + +For Linux bring-up: + +- `apps/linux_sim` is the simulator and developer-tooling shell +- `apps/linux_rpi` is the real Pi OS device shell for Cardputer Zero diff --git a/apps/esp_idf/CMakeLists.txt b/apps/esp_idf/CMakeLists.txt index ed918d45..489e81b6 100644 --- a/apps/esp_idf/CMakeLists.txt +++ b/apps/esp_idf/CMakeLists.txt @@ -104,7 +104,7 @@ set(trail_mate_idf_ui_shared_sources ${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/components/two_pane_nav.cpp ${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/page/page_profile.cpp ${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/runtime/memory_profile.cpp - ${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/runtime/pack_repository.cpp + ${CMAKE_SOURCE_DIR}/platform/esp/arduino_common/src/ui/runtime/pack_repository.cpp ${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/screens/common/placeholder_page.cpp ${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp ${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/screens/chat/chat_compose_input.cpp diff --git a/apps/esp_idf/src/app_facade_runtime.cpp b/apps/esp_idf/src/app_facade_runtime.cpp index 39e5f155..9e38630d 100644 --- a/apps/esp_idf/src/app_facade_runtime.cpp +++ b/apps/esp_idf/src/app_facade_runtime.cpp @@ -13,6 +13,8 @@ #include "chat/usecase/contact_service.h" #include "esp_log.h" #include "platform/esp/boards/board_runtime.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/team_ui_store_runtime.h" #include "sys/clock.h" #include "sys/event_bus.h" #include "team/usecase/team_controller.h" @@ -20,7 +22,6 @@ #include "team/usecase/team_service.h" #include "ui/chat_ui_runtime.h" #include "ui/screens/team/team_page_shell.h" -#include "ui/screens/team/team_ui_store.h" #include #include @@ -1022,6 +1023,11 @@ class MinimalAppFacade final : public app::IAppFacade return config_; } + chat::MeshProtocol getMeshProtocol() const override + { + return config_.mesh_protocol; + } + void saveConfig() override {} void applyMeshConfig() override @@ -1040,7 +1046,17 @@ class MinimalAppFacade final : public app::IAppFacade } } - void applyPositionConfig() override {} + void applyPositionConfig() override + { + platform::ui::gps::set_enabled(config_.gps_enabled); + platform::ui::gps::set_collection_interval(config_.gps_interval_ms); + platform::ui::gps::set_power_strategy(config_.gps_strategy); + platform::ui::gps::set_gnss_config(config_.gps_mode, config_.gps_sat_mask); + platform::ui::gps::set_external_nmea_config(config_.external_nmea_output_hz, + config_.external_nmea_sentence_mask); + platform::ui::gps::set_motion_idle_timeout(config_.motion_config.idle_timeout_ms); + platform::ui::gps::set_motion_sensor_id(config_.motion_config.sensor_id); + } void applyNetworkLimits() override {} void applyPrivacyConfig() override {} diff --git a/apps/esp_idf/src/gps_service_api.cpp b/apps/esp_idf/src/gps_service_api.cpp index 5636e243..d12a33e7 100644 --- a/apps/esp_idf/src/gps_service_api.cpp +++ b/apps/esp_idf/src/gps_service_api.cpp @@ -32,6 +32,11 @@ bool gps_is_powered() return platform::esp::idf_common::gps_runtime::is_powered(); } +void gps_set_enabled(bool enabled) +{ + platform::esp::idf_common::gps_runtime::set_enabled(enabled); +} + void gps_set_collection_interval(uint32_t interval_ms) { platform::esp::idf_common::gps_runtime::set_collection_interval(interval_ms); @@ -47,9 +52,9 @@ void gps_set_gnss_config(uint8_t mode, uint8_t sat_mask) platform::esp::idf_common::gps_runtime::set_gnss_config(mode, sat_mask); } -void gps_set_nmea_config(uint8_t output_hz, uint8_t sentence_mask) +void gps_set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) { - platform::esp::idf_common::gps_runtime::set_nmea_config(output_hz, sentence_mask); + platform::esp::idf_common::gps_runtime::set_external_nmea_config(output_hz, sentence_mask); } void gps_set_motion_idle_timeout(uint32_t timeout_ms) diff --git a/apps/esp_idf/src/startup_runtime.cpp b/apps/esp_idf/src/startup_runtime.cpp index dbeee805..de170648 100644 --- a/apps/esp_idf/src/startup_runtime.cpp +++ b/apps/esp_idf/src/startup_runtime.cpp @@ -9,12 +9,9 @@ #include "esp_log.h" #include "platform/esp/boards/board_runtime.h" #include "platform/esp/idf_common/bsp_runtime.h" -#include "platform/esp/idf_common/gps_runtime.h" #include "platform/esp/idf_common/startup_support.h" -#include "platform/esp/idf_common/sx126x_radio.h" #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" -#include "platform/ui/lora_runtime.h" #include "platform/ui/screen_runtime.h" #include "platform/ui/settings_store.h" #include "ui/app_registry.h" @@ -44,43 +41,27 @@ void applyPlatformRuntimeConfig(const RuntimeConfig& config) } const app::AppConfig& app_config = app::appFacade().getConfig(); + platform::ui::gps::set_enabled(app_config.gps_enabled); platform::ui::gps::set_collection_interval(app_config.gps_interval_ms); platform::ui::gps::set_power_strategy(app_config.gps_strategy); platform::ui::gps::set_gnss_config(app_config.gps_mode, app_config.gps_sat_mask); - platform::ui::gps::set_nmea_config(app_config.privacy_nmea_output, app_config.privacy_nmea_sentence); + platform::ui::gps::set_external_nmea_config(app_config.external_nmea_output_hz, + app_config.external_nmea_sentence_mask); platform::ui::gps::set_motion_idle_timeout(app_config.motion_config.idle_timeout_ms); platform::ui::gps::set_motion_sensor_id(app_config.motion_config.sensor_id); - platform::esp::idf_common::gps_runtime::request_startup_probe(); - ESP_LOGI(config.log_tag, - "GNSS runtime config applied: interval_ms=%lu mode=%u sat_mask=0x%02X strategy=%u nmea=%u/%u motion_idle_ms=%lu sensor=%u", + "GNSS runtime config applied: enabled=%d interval_ms=%lu mode=%u sat_mask=0x%02X strategy=%u external_nmea=%u/%u motion_idle_ms=%lu sensor=%u startup_probe=deferred", + app_config.gps_enabled ? 1 : 0, static_cast(app_config.gps_interval_ms), app_config.gps_mode, app_config.gps_sat_mask, app_config.gps_strategy, - app_config.privacy_nmea_output, - app_config.privacy_nmea_sentence, + app_config.external_nmea_output_hz, + app_config.external_nmea_sentence_mask, static_cast(app_config.motion_config.idle_timeout_ms), app_config.motion_config.sensor_id); } -void probeExternalModules(const RuntimeConfig& config) -{ - if (platform::ui::lora::is_supported()) - { - auto& radio = platform::esp::idf_common::Sx126xRadio::instance(); - const bool online = platform::ui::lora::acquire(); - ESP_LOGI(config.log_tag, - "LoRa startup probe: supported=1 online=%d error=%s", - online ? 1 : 0, - radio.lastError()); - if (online) - { - platform::ui::lora::release(); - } - } -} - ui::startup_shell::Hooks buildShellHooks() { ui::startup_shell::Hooks hooks{}; @@ -137,7 +118,7 @@ void run(const RuntimeConfig& config) app_runtime_access::initialize(config); applyPlatformRuntimeConfig(config); - probeExternalModules(config); + const ui::startup_shell::Hooks shell_hooks = buildShellHooks(); const size_t app_count = ui::catalogCount(shell_hooks.apps); @@ -161,10 +142,8 @@ void run(const RuntimeConfig& config) runtime_status.lifecycle_ready ? 1 : 0, runtime_status.app_context_bound ? 1 : 0); - loop_runtime::start(config); - ESP_LOGI(config.log_tag, "finalizeStartup begin waking=%d", waking_from_sleep ? 1 : 0); - if (lockUi(1000)) + if (lockUi(5000)) { ui::startup_shell::finalizeStartup(waking_from_sleep); unlockUi(); @@ -175,6 +154,8 @@ void run(const RuntimeConfig& config) ESP_LOGW(config.log_tag, "finalizeStartup failed to acquire LVGL lock"); } + loop_runtime::start(config); + ESP_LOGI(config.log_tag, "%s startup runtime initialized", config.target_name); } diff --git a/apps/esp_idf/src/team_ui_store.cpp b/apps/esp_idf/src/team_ui_store.cpp index 5241a5fb..3a9c0508 100644 --- a/apps/esp_idf/src/team_ui_store.cpp +++ b/apps/esp_idf/src/team_ui_store.cpp @@ -1,4 +1,4 @@ -#include "ui/screens/team/team_ui_store.h" +#include "platform/ui/team_ui_store_runtime.h" #include #include diff --git a/apps/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults b/apps/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults index aab99596..239a5b8c 100644 --- a/apps/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults +++ b/apps/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults @@ -12,6 +12,8 @@ CONFIG_CACHE_L2_CACHE_LINE_128B=y CONFIG_ESPTOOLPY_FLASHFREQ_120M=y CONFIG_IDF_EXPERIMENTAL_FEATURES=y CONFIG_TINYUSB_MSC_ENABLED=y +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_ESP_TASK_WDT_TIMEOUT_S=20 CONFIG_TRAIL_MATE_T_DISPLAY_P4_PANEL_RM69A10=y CONFIG_TRAIL_MATE_T_DISPLAY_P4_PIXEL_FORMAT_RGB565=y CONFIG_TRAIL_MATE_T_DISPLAY_P4_LVGL_BUFFER_LINES=80 diff --git a/apps/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults b/apps/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults index 0ccfd2d0..4044ce20 100644 --- a/apps/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults +++ b/apps/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults @@ -12,6 +12,8 @@ CONFIG_CACHE_L2_CACHE_LINE_128B=y CONFIG_ESPTOOLPY_FLASHFREQ_120M=y CONFIG_IDF_EXPERIMENTAL_FEATURES=y CONFIG_TINYUSB_MSC_ENABLED=y +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_ESP_TASK_WDT_TIMEOUT_S=20 CONFIG_TRAIL_MATE_T_DISPLAY_P4_PANEL_HI8561=y CONFIG_TRAIL_MATE_T_DISPLAY_P4_PIXEL_FORMAT_RGB565=y CONFIG_TRAIL_MATE_T_DISPLAY_P4_LVGL_BUFFER_LINES=80 diff --git a/apps/esp_idf/targets/tab5/sdkconfig.defaults b/apps/esp_idf/targets/tab5/sdkconfig.defaults index eaa2058e..0adfdd36 100644 --- a/apps/esp_idf/targets/tab5/sdkconfig.defaults +++ b/apps/esp_idf/targets/tab5/sdkconfig.defaults @@ -1,4 +1,6 @@ -CONFIG_IDF_TARGET="esp32p4" +CONFIG_IDF_TARGET="esp32p4" +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y +CONFIG_ESP32P4_REV_MIN_100=y CONFIG_ESPTOOLPY_FLASHMODE_QIO=y CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y CONFIG_PARTITION_TABLE_CUSTOM=y @@ -12,7 +14,9 @@ CONFIG_CACHE_L2_CACHE_256KB=y CONFIG_CACHE_L2_CACHE_LINE_128B=y CONFIG_IDF_EXPERIMENTAL_FEATURES=y CONFIG_TINYUSB_MSC_ENABLED=y +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_ESP_TASK_WDT_PANIC=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=20 CONFIG_TASK_WDT_PANIC=y CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y diff --git a/apps/esp_pio/include/apps/esp_pio/app_context.h b/apps/esp_pio/include/apps/esp_pio/app_context.h index 28a90d5f..52ffda8d 100644 --- a/apps/esp_pio/include/apps/esp_pio/app_context.h +++ b/apps/esp_pio/include/apps/esp_pio/app_context.h @@ -127,6 +127,11 @@ class AppContext final : public IAppBleFacade return config_; } + chat::MeshProtocol getMeshProtocol() const override + { + return config_.mesh_protocol; + } + chat::NodeId getSelfNodeId() const override; chat::IMeshAdapter* getMeshAdapter() override; diff --git a/apps/esp_pio/src/app_context_time_sync.cpp b/apps/esp_pio/src/app_context_time_sync.cpp index 9fa6f984..0c371024 100644 --- a/apps/esp_pio/src/app_context_time_sync.cpp +++ b/apps/esp_pio/src/app_context_time_sync.cpp @@ -1,5 +1,6 @@ #include "apps/esp_pio/app_context.h" #include "platform/esp/arduino_common/device_identity.h" +#include "platform/ui/device_runtime.h" #include @@ -48,7 +49,7 @@ bool AppContext::syncCurrentEpochSeconds(uint32_t epoch_seconds) void AppContext::restartDevice() { #if __has_include() - ESP.restart(); + platform::ui::device::restart(); #endif } diff --git a/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/app_facade_runtime.h b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/app_facade_runtime.h index c0be46eb..e0f26372 100644 --- a/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/app_facade_runtime.h +++ b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/app_facade_runtime.h @@ -52,6 +52,7 @@ class AppFacadeRuntime final : public app::IAppBleFacade, app::AppConfig& getConfig() override; const app::AppConfig& getConfig() const override; + chat::MeshProtocol getMeshProtocol() const override; void saveConfig() override; void applyMeshConfig() override; void applyUserInfo() override; diff --git a/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp b/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp index 2e11b0b1..1bf4cacd 100644 --- a/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp +++ b/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp @@ -299,6 +299,11 @@ const app::AppConfig& AppFacadeRuntime::getConfig() const return config_; } +chat::MeshProtocol AppFacadeRuntime::getMeshProtocol() const +{ + return config_.mesh_protocol; +} + void AppFacadeRuntime::saveConfig() { ScopedGpsSuspend suspend_gps(board_); diff --git a/apps/gat562_mesh_evb_pro/src/startup_runtime.cpp b/apps/gat562_mesh_evb_pro/src/startup_runtime.cpp index 2f7705ee..5c5ea5c4 100644 --- a/apps/gat562_mesh_evb_pro/src/startup_runtime.cpp +++ b/apps/gat562_mesh_evb_pro/src/startup_runtime.cpp @@ -23,6 +23,8 @@ void run() { return millis(); }); sys::set_epoch_seconds_provider([]() -> uint32_t { return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().currentEpochSeconds(); }); + sys::set_sleep_provider([](uint32_t ms) + { delay(ms); }); ui_runtime::initialize(); ui_runtime::appendBootLog("startup begin"); ui_runtime::appendBootLog("board/input ok"); diff --git a/apps/linux_rpi/CMakeLists.txt b/apps/linux_rpi/CMakeLists.txt new file mode 100644 index 00000000..09b9ee2c --- /dev/null +++ b/apps/linux_rpi/CMakeLists.txt @@ -0,0 +1,70 @@ +cmake_minimum_required(VERSION 3.24) + +project(TrailMateCardputerZeroRpi VERSION 0.1.0 LANGUAGES C CXX) + +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message(FATAL_ERROR + "The Cardputer Zero Pi OS device shell must be configured on Linux.") +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +find_package(Threads REQUIRED) + +# --------------------------------------------------------------------------- +# Shared Linux sources, paths, and helper functions +# --------------------------------------------------------------------------- + +include("${PROJECT_SOURCE_DIR}/../../cmake/TrailMateLinuxSources.cmake") + +# --------------------------------------------------------------------------- +# Common library +# --------------------------------------------------------------------------- + +trailmate_add_linux_common(trailmate_cardputer_zero_linux_common) + +# --------------------------------------------------------------------------- +# LVGL and UI shell +# --------------------------------------------------------------------------- + +include(FetchContent) + +set(LV_BUILD_CONF_PATH + "${PROJECT_SOURCE_DIR}/../../platform/linux/common/include/lv_conf.h" + CACHE PATH "" FORCE) +set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) +set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + lvgl + GIT_REPOSITORY https://github.com/lvgl/lvgl.git + GIT_TAG v9.4.0 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(lvgl) + +trailmate_add_linux_ui_shell( + trailmate_cardputer_zero_ui_shell + trailmate_cardputer_zero_linux_common +) + +# --------------------------------------------------------------------------- +# Device executable – framebuffer target +# --------------------------------------------------------------------------- + +add_executable(trailmate_cardputer_zero_device + "${TRAIL_MATE_LINUX_RPI_SRC_ROOT}/platform/device/linux_framebuffer_platform.cpp" + "${TRAIL_MATE_LINUX_RPI_SRC_ROOT}/platform/device/evdev_input.cpp" + src/targets/device_main.cpp +) +target_include_directories(trailmate_cardputer_zero_device + PRIVATE + "${PROJECT_SOURCE_DIR}/src" + "${TRAIL_MATE_LINUX_RPI_SRC_ROOT}" +) +target_link_libraries(trailmate_cardputer_zero_device + PRIVATE + trailmate_cardputer_zero_linux_common + trailmate_cardputer_zero_ui_shell +) diff --git a/apps/linux_rpi/CMakePresets.json b/apps/linux_rpi/CMakePresets.json new file mode 100644 index 00000000..25f6f75b --- /dev/null +++ b/apps/linux_rpi/CMakePresets.json @@ -0,0 +1,35 @@ +{ + "version": 4, + "cmakeMinimumRequired": { + "major": 3, + "minor": 24, + "patch": 0 + }, + "configurePresets": [ + { + "name": "linux-device-debug", + "displayName": "Linux Debug Device", + "description": "Configure the Pi OS framebuffer device target for Cardputer Zero bring-up.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/device", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + } + ], + "buildPresets": [ + { + "name": "linux-device-debug-build", + "displayName": "Build Linux Device", + "configurePreset": "linux-device-debug", + "targets": [ + "trailmate_cardputer_zero_device" + ] + } + ] +} diff --git a/apps/linux_rpi/README.md b/apps/linux_rpi/README.md index 61ed08cb..dde56cb8 100644 --- a/apps/linux_rpi/README.md +++ b/apps/linux_rpi/README.md @@ -1,5 +1,91 @@ -# `apps/linux_rpi` +# Trail Mate Cardputer Zero Pi OS Device Shell -Planned Raspberry Pi hardware application shell. +`apps/linux_rpi` is the real Pi OS device shell for `Cardputer Zero`. -This target should be introduced only after `apps/linux_sim` proves the shared core is portable. +The authoritative architecture constraint for this line is +[docs/specification/cardputer-zero-adaptation-spec.md](./docs/specification/cardputer-zero-adaptation-spec.md). + +This directory now has two responsibilities that should stay distinct: + +- a repo-local thin Linux bring-up shell for neutral experiments +- an SDK-backed device shell that uses `M5Stack_Linux_Libs` for real Cardputer + Zero display and input bring-up + +The desktop simulator, Windows tooling, WSL validation, and dev-container +workflow remain in [`apps/linux_sim`](../linux_sim/README.md). + +## Layout + +- `CMakeLists.txt`: standalone Pi OS device shell build +- `CMakePresets.json`: Linux preset for the framebuffer device target +- `SConstruct`: `M5Stack_Linux_Libs` backed device build entrypoint +- `config_defaults.mk`: default SDK configuration for LVGL 9.5 + fbdev + evdev +- `main`: clean SDK project component for the real device shell +- `docs/specification`: current Cardputer Zero boundaries, migration notes, and + feature migration checklist +- `src/targets`: thin device entrypoint +- `scripts`: Linux helper scripts for build and run + +The shared Linux-safe slice used by both `linux_sim` and `linux_rpi` now lives +in [`platform/linux/common`](../../platform/linux/common/README.md). + +## Reference Rule + +- `M5Stack_Linux_Libs` is the device-facing SDK baseline we should actually + build on. +- `M5CardputerZero-UserDemo` is reference material only. It can provide board + hints such as `fb_st7789v` detection and likely evdev paths, but it is not + the engineering template for this repository. +- New Cardputer Zero work should prefer the SDK-backed path over extending the + custom framebuffer shell when the work is truly device-facing. +- Feature slices must validate the adaptation specification rather than define + the architecture retroactively. + +## Quick Start + +### Pi OS / Linux device build + +```bash +bash scripts/build-device.sh +``` + +### Pi OS / Linux device run + +```bash +TRAIL_MATE_FBDEV=/dev/fb0 bash scripts/run-device.sh +``` + +### M5Stack Linux SDK build + +Set `TRAIL_MATE_M5STACK_LINUX_LIBS_PATH` to your `M5Stack_Linux_Libs` +checkout, or keep a local checkout at `.tmp/M5Stack_Linux_Libs`. + +```bash +bash scripts/build-sdk-device.sh +``` + +### M5Stack Linux SDK run + +```bash +LV_LINUX_FBDEV_DEVICE=/dev/fb0 bash scripts/run-sdk-device.sh +``` + +### CMake preset flow + +```bash +cmake --preset linux-device-debug +cmake --build --preset linux-device-debug-build +TRAIL_MATE_FBDEV=/dev/fb0 ./build/device/trailmate_cardputer_zero_device +``` + +## Notes + +- This shell is intentionally thin. It should own device lifecycle and device + adapters, not duplicate the simulator tooling. +- The thin framebuffer shell now reuses the same shared boot splash, boot log, + and menu-shell startup flow as the desktop simulator. +- The `SConstruct` path is the preferred direction for real `Cardputer Zero` + hardware adaptation because it rides on the vendor Linux SDK instead of + re-solving LVGL and device-driver plumbing from scratch. +- For iteration on UI behavior, prefer the simulator in `apps/linux_sim`. +- For actual hardware bring-up on Pi OS, build and run from here. diff --git a/apps/linux_rpi/SConstruct b/apps/linux_rpi/SConstruct new file mode 100644 index 00000000..b7d1635b --- /dev/null +++ b/apps/linux_rpi/SConstruct @@ -0,0 +1,30 @@ +from pathlib import Path +import os + + +def resolve_sdk_path(project_root: Path) -> Path: + configured = os.environ.get("TRAIL_MATE_M5STACK_LINUX_LIBS_PATH") or os.environ.get("M5STACK_LINUX_LIBS_PATH") + if configured: + return Path(configured).expanduser().resolve() + + local_tmp = project_root.parent.parent / ".tmp" / "M5Stack_Linux_Libs" + if local_tmp.exists(): + return local_tmp.resolve() + + raise RuntimeError( + "M5Stack_Linux_Libs SDK not found. Set TRAIL_MATE_M5STACK_LINUX_LIBS_PATH " + "to your SDK checkout before running scons." + ) + + +project_root = Path(os.getcwd()).resolve() +sdk_path = resolve_sdk_path(project_root) + +os.environ["SDK_PATH"] = str(sdk_path) +os.environ["EXT_COMPONENTS_PATH"] = str(sdk_path.parent / "ext_components") + +env = SConscript( + str(sdk_path / "tools" / "scons" / "project.py"), + variant_dir=os.getcwd(), + duplicate=0, +) diff --git a/apps/linux_rpi/config_defaults.mk b/apps/linux_rpi/config_defaults.mk new file mode 100644 index 00000000..37139fb6 --- /dev/null +++ b/apps/linux_rpi/config_defaults.mk @@ -0,0 +1,5 @@ +CONFIG_LVGL_COMPONENT_ENABLED=y +CONFIG_LVGL_9_5_SRC=y +CONFIG_V9_5_LV_USE_CLIB_MALLOC=y +CONFIG_V9_5_LV_USE_LINUX_FBDEV=y +CONFIG_V9_5_LV_USE_EVDEV=y diff --git a/apps/linux_rpi/docs/specification/cardputer-zero-adaptation-spec.md b/apps/linux_rpi/docs/specification/cardputer-zero-adaptation-spec.md new file mode 100644 index 00000000..8d034d77 --- /dev/null +++ b/apps/linux_rpi/docs/specification/cardputer-zero-adaptation-spec.md @@ -0,0 +1,374 @@ +# Cardputer Zero Final-Shape Adaptation Specification + +This document defines the target architectural shape for adapting +`Cardputer Zero` into the main `Trail Mate` repository. + +It is not a feature checklist. +It is not a "first vertical slice" note. +It is the constraint document that later feature slices must obey. + +## 1. Why this specification exists + +The Linux work already has useful migration notes, simulator bring-up, and a Pi +OS device shell baseline. + +That is not enough. + +Without an explicit final-shape specification, a "first shippable slice" can +quietly become the place where structure is discovered by accident: + +- the shortest feature path starts defining the layering +- platform glue leaks upward into shared modules +- temporary compatibility decisions become long-term boundaries +- a runnable page is mistaken for a valid architecture + +This document prevents that. + +## 2. What must be distinguished + +### Final shape vs verification slice + +- The final shape defines what the system is allowed to be. +- A verification slice is only allowed to test whether that shape is workable. +- A slice must never define the architecture retroactively. + +### Shared core vs shared presentation + +- `modules/core_*` are not the same kind of shared code as `modules/ui_shared`. +- Core modules own domain, protocol, policy, and use-case logic. +- `ui_shared` owns shared presentation composition and LVGL-based UI behavior. + +If those are not distinguished, "shared" becomes meaningless and every layer +starts learning details it should never know. + +### Platform contract vs platform implementation + +- A runtime/storage/device capability contract is not the same thing as its + Linux, ESP, or simulator implementation. +- Contracts must remain stable enough that both `linux_sim` and `linux_rpi` + can cooperate through them without knowing each other's internals. + +### App shell vs platform adapter + +- `apps/*` compose startup and choose implementations. +- `platform/*` implements the contracts. + +If those two are mixed, every app shell becomes another place where business +logic and platform details grow. + +### Vendor SDK vs repository architecture + +- `M5Stack_Linux_Libs` is a device-facing dependency. +- It is not the source of Trail Mate's architectural boundaries. +- The SDK should be used below our platform boundary, not define it. + +## 3. Normative target layers + +The Cardputer Zero adaptation should converge toward these layers. + +### Layer A: Shared Core + +Primary ownership: + +- `modules/core_sys` +- `modules/core_chat` +- `modules/core_gps` +- `modules/core_team` +- `modules/core_hostlink` + +Responsibilities: + +- domain objects +- protocol models and codecs +- use cases +- policy/state logic +- serialization rules +- pure utility logic + +Forbidden knowledge: + +- `Arduino.h` +- ESP-IDF APIs +- FreeRTOS APIs +- LVGL types +- board pin maps +- Linux device paths +- SDL types +- fbdev / evdev / ioctl details +- vendor SDK types + +Rule: + +- A core module must be able to compile in a host-side test harness without any + platform SDK or UI framework. + +### Layer B: Shared Presentation + +Primary ownership: + +- `modules/ui_shared` + +Responsibilities: + +- shared menu shell +- screen composition +- shared assets +- shared formatting and presentation helpers +- LVGL object graphs and screen behavior that are portable across supported + targets + +Allowed knowledge: + +- LVGL +- shared core modules +- platform contracts such as `platform::ui::*` + +Forbidden knowledge: + +- `platform/esp/...` include roots +- `Preferences` +- Arduino-only helpers +- Linux file paths or desktop simulator geometry +- board GPIO or driver internals +- vendor SDK types + +Rule: + +- `ui_shared` may depend on a UI framework. +- It may not depend on a platform implementation. + +### Layer C: Platform Contracts + +Primary ownership: + +- `modules/core_sys/include/platform/ui/*` + +Responsibilities: + +- runtime capability interfaces +- persistence/storage ports +- transport/runtime capability ports +- stable value types needed by shared presentation + +Examples: + +- `device_runtime` +- `screen_runtime` +- `time_runtime` +- `settings_store` +- `wifi_runtime` +- `gps_runtime` +- `lora_runtime` +- `hostlink_runtime` + +Rule: + +- Contracts define collaboration. +- Contracts do not perform platform work. + +### Layer D: Platform Implementations + +Primary ownership: + +- `platform/esp/*` +- `platform/linux/common` +- `platform/linux/rpi` + +Responsibilities: + +- implementing Layer C contracts +- OS/runtime integration +- hardware and SDK binding +- Linux/ESP storage adapters +- device-specific power, display, input, bus, and transport glue + +Allowed knowledge: + +- OS APIs +- SDKs +- driver headers +- Linux paths +- SDL, fbdev, evdev +- board-specific details + +Forbidden ownership: + +- domain rules +- screen composition policy +- app navigation +- long-lived business state that belongs in shared modules + +### Layer E: App Shells + +Primary ownership: + +- `apps/esp_pio` +- `apps/esp_idf` +- `apps/linux_sim` +- `apps/linux_rpi` + +Responsibilities: + +- process / firmware entrypoints +- target selection +- composition root wiring +- startup sequencing +- build configuration + +Forbidden ownership: + +- duplicated business logic +- duplicated shared screen logic +- platform adapter internals + +### Layer F: External Device Baselines + +Primary ownership: + +- `M5Stack_Linux_Libs` + +Responsibilities: + +- vendor-provided Linux device SDK concerns + +Rule: + +- This layer is beneath Trail Mate's platform boundary. +- It may be consumed by `platform/linux/rpi` and the Pi shell, but it must not + become a shortcut around our contracts. + +## 4. Legal dependency direction + +The dependency direction must be: + +```text +apps/* -> modules/ui_shared -> modules/core_* -> sys utilities + -> platform/* ------> contracts in modules/core_sys/include/platform/ui/* + +platform/* -> contracts +platform/* -> OS / SDK / drivers + +modules/ui_shared -> contracts +modules/ui_shared -> core modules + +modules/core_* -> no platform implementation +``` + +More explicitly: + +- `modules/core_*` must not depend on `modules/ui_shared`, `apps/*`, or + `platform/*`. +- `modules/ui_shared` may depend on core modules and platform contracts, but + not on `platform/esp/*`, `platform/linux/*`, or app-shell code. +- `platform/*` may depend on contracts and platform dependencies, but not on + app-shell private code. +- `apps/linux_sim` and `apps/linux_rpi` must share the same presentation layer + through contracts, not by duplicating or forking UI logic. + +## 5. Known current violations + +These are not abstract concerns; the repository already contains concrete +violations that this specification treats as real debt. + +### RESOLVED (2026-05-07): `ui_shared` ESP Arduino build dependency + +Previously `modules/ui_shared/library.json` included +`../../platform/esp/arduino_common/include` and depended on `Preferences`. +This has been fixed: `library.json` now only references `platform/shared`, +`core_sys`, `core_chat`, `core_gps`, `core_hostlink`, and `core_team`. +The boundary checker (`scripts/check_platform_ui_boundaries.py`) confirms this +passes. + +### RESOLVED (2026-05-07): `core_chat` BLE platform-specific tails + +Previously `modules/core_chat/src/ble/*` included `Arduino.h` and +FreeRTOS-related headers. Those files have been cleaned up and no longer +contain platform-specific includes. All core module boundary checks pass. + +### `BoardBase` is not the Linux seam (OPEN) + +`platform/shared/include/board/BoardBase.h` remains useful for MCU board +families. +It is not the primary abstraction for the Linux line. + +Cardputer Zero must integrate through platform contracts, not by pretending to +be another MCU board subclass. + +## 6. Cardputer Zero consequences + +These consequences follow directly from the specification. + +### `apps/linux_sim` + +Owns only: + +- desktop process lifecycle +- simulator chrome and geometry +- desktop mouse/keyboard mapping +- simulator-only validation helpers + +It must not become a second shared UI tree. + +### `apps/linux_rpi` + +Owns only: + +- Pi OS device entrypoint +- real device composition root +- shell wiring for the device target + +It must not absorb simulator concerns or copied demo-app structure. + +### `platform/linux/common` + +Owns: + +- Linux implementations of shared platform contracts +- Linux-safe shell/session helpers that both Linux shells use + +It must not absorb desktop-only geometry or Pi-specific device assumptions. + +### `platform/linux/rpi` + +Owns: + +- Pi OS specific integration +- Cardputer Zero device-facing adapters +- `M5Stack_Linux_Libs` consumption points + +It should host SDK binding where the Linux common layer would otherwise become +device-specific. + +## 7. Structural program before feature work + +Feature work should follow this structural program, not replace it. + +1. Complete and normalize the contract inventory in + `modules/core_sys/include/platform/ui/*`. +2. Remove ESP-only build/runtime dependencies from `modules/ui_shared`. +3. Split platform-specific tails out of core-module locations that currently + violate Layer A, starting with `core_chat` BLE-facing code. +4. Keep Linux shared shell code in `platform/linux/common` and keep both Linux + app shells thin. +5. Only after those boundaries are trustworthy, choose a verification slice + such as `Settings`. + +The first real page is therefore a proof target, not a source of structure. + +## 8. Acceptance checks + +This specification is only useful if it can reject bad structure. + +The Cardputer Zero adaptation is aligned with this specification only when the +following statements are becoming true: + +- A `core_*` module can compile without Arduino, LVGL, board headers, or the + vendor SDK. +- `ui_shared` can compile without ESP-specific include roots or Arduino-only + storage primitives. +- `linux_sim` and `linux_rpi` run the same shared shell/presentation logic + without forking it. +- Replacing a Linux platform implementation does not require changes to the + shared core or shared presentation layer. +- A new page is validated through the contracts instead of inventing new + backdoor dependencies. diff --git a/apps/linux_rpi/docs/specification/feature-migration-checklist.md b/apps/linux_rpi/docs/specification/feature-migration-checklist.md new file mode 100644 index 00000000..258ee1c0 --- /dev/null +++ b/apps/linux_rpi/docs/specification/feature-migration-checklist.md @@ -0,0 +1,179 @@ +# Cardputer Zero Feature Migration Checklist + +This checklist turns the current Cardputer Zero assessment into a concrete +implementation order. + +It is subordinate to +[cardputer-zero-adaptation-spec.md](./cardputer-zero-adaptation-spec.md). +This checklist must validate that specification, not redefine it. + +It is intentionally opinionated: + +- build the real device shell on top of `M5Stack_Linux_Libs` +- reuse shared business logic from `modules/*` where the boundary is already + real +- avoid forcing Linux through MCU board abstractions too early +- keep unsupported hardware features explicitly stubbed instead of half-ported + +## 1. Reuse Buckets + +### Reuse Now + +These pieces are already good candidates for direct reuse in the Linux line: + +- `modules/core_sys` + portable clock helpers plus shared `platform::ui::*` runtime contracts +- `modules/core_hostlink` + protocol codecs and session state +- `modules/core_gps` + domain models, motion policy, jitter filter, runtime policy/state +- `modules/core_team` + team domain, protocol, and use-case logic +- `modules/core_chat` + `domain`, `usecase`, and most of `infra` +- `modules/ui_shared` + LVGL contracts, shared components, shared assets, and formatting helpers + +### Reuse With Thin Linux Adapters + +These pieces are still the right seam, but they need a Linux-side runtime or +storage implementation first: + +- `modules/core_sys/include/platform/ui/device_runtime.h` +- `modules/core_sys/include/platform/ui/time_runtime.h` +- `modules/core_sys/include/platform/ui/screen_runtime.h` +- `modules/core_sys/include/platform/ui/settings_store.h` +- `modules/core_sys/include/platform/ui/wifi_runtime.h` +- `modules/core_sys/include/platform/ui/tracker_runtime.h` +- `modules/core_sys/include/platform/ui/hostlink_runtime.h` +- `modules/core_sys/include/platform/ui/gps_runtime.h` +- `modules/core_sys/include/platform/ui/lora_runtime.h` + +### Do Not Reuse As Linux Baseline + +These pieces should not be treated as the Linux foundation: + +- `M5CardputerZero-UserDemo` + use only for board hints such as framebuffer naming and likely input-device + paths +- `modules/core_chat/src/ble/*` + phone-side BLE shells still include Arduino and FreeRTOS details +- `modules/ui_shared/library.json` + current build metadata still points at ESP Arduino include paths and + `Preferences` +- `platform/shared/include/board/*.h` + still useful for MCU board families, but not the primary Linux seam + +## 2. Structural Compliance Before First Feature Slice + +Before choosing the first true page migration, these structural tasks come +first: + +1. make the `platform::ui::*` contract set explicit and complete — DONE +2. remove ESP Arduino include roots and `Preferences` dependence from + `modules/ui_shared` — DONE (2026-05-07, verified by boundary checker) +3. relocate or split platform-specific code that still lives under `core_*` + module trees — DONE (2026-05-07, BLE files cleaned, boundary checker passes) +4. keep shared Linux shell/session code in `platform/linux/common` instead of + app-shell private directories — DONE (ShellSession/CanvasLvglHost in common) +5. only then select the first verification slice, likely `Settings` + +## 3. First Linux Runtime Slice + +Build these Linux implementations first under `platform/linux/common`: + +1. `platform::ui::settings_store` + file-backed key/value persistence for UI and runtime preferences +2. `platform::ui::time` + timezone offset persistence plus local-time helpers +3. `platform::ui::screen` + settings-backed timeout state and no-op/soft Linux sleep bookkeeping +4. `platform::ui::device` + system identity, delays, memory stats, and capability reporting + +This is the minimum slice needed to stop `ui_shared` from depending on ESP-only +runtime code for basic shell behavior. + +## 4. First-Batch UI Targets + +### Enable First + +These areas should be the first UI targets after the runtime slice exists: + +- shell bootstrapping + `startup_shell`, `menu_layout`, `menu_runtime`, `app_runtime` +- localization and resource registry + locale selection, font selection, and persisted language preferences +- settings core + time zone, screen timeout, vibration preference, debug toggles, + notification volume, and any Linux-safe capability flags + +### Delay Until Data Adapters Exist + +These UI areas should wait until the corresponding Linux storage/transport +adapters exist: + +- `chat` + needs Linux chat store and mesh adapter +- `contacts` + needs chat/contact stores and node metadata pipeline +- `team` + needs team event sinks, track source, and chat app-data path +- `node_info` + should follow once chat/contact metadata is flowing + +### Stub or Hide Initially + +These should start disabled with explicit `is_supported() == false` behavior: + +- `gps` +- `gnss` +- `tracker` +- `energy_sweep` +- `pc_link` +- `usb` +- `sstv` +- `walkie_talkie` +- Wi-Fi backed pack download and extensions install flows + +The rule is simple: if the hardware or transport does not exist on Cardputer +Zero yet, do not fake a partial port just to keep a menu item visible. + +## 5. Second Slice After Runtime + +Once the first runtime slice is in place, build the next layer in this order: + +1. Linux chat/contact/node stores +2. Linux local or loopback `IMeshAdapter` +3. Linux hostlink runtime if a practical transport exists +4. Linux tracker/file storage adapters +5. Linux Wi-Fi runtime +6. Linux GPS runtime +7. Linux LoRa runtime + +This sequence keeps the shared business code moving before the high-risk +hardware work starts. + +## 6. Cardputer Zero Specific Work + +These are inherently target-facing and should be implemented specifically for +the Cardputer Zero shell: + +- fbdev device discovery and LVGL display bring-up +- evdev keyboard mapping for the built-in keypad +- battery and power-state detection if the Linux userland exposes it +- mount-point detection for removable storage +- audio/haptic behavior only if the device exposes a practical Linux path + +`M5Stack_Linux_Libs` should own as much of this generic Linux device plumbing as +possible. + +## 7. Current Start Line + +The current repository should proceed from here: + +1. land the final-shape specification and make migration docs obey it +2. finish structural compliance on `ui_shared`, contracts, and misplaced + platform tails +3. keep `apps/linux_rpi` on the `M5Stack_Linux_Libs` device shell path +4. begin feature verification only after the structural program is credible diff --git a/apps/linux_rpi/docs/specification/migration-path.md b/apps/linux_rpi/docs/specification/migration-path.md new file mode 100644 index 00000000..87c456d7 --- /dev/null +++ b/apps/linux_rpi/docs/specification/migration-path.md @@ -0,0 +1,89 @@ +# Cardputer Zero Migration Path + +This note captures the recommended way to adapt `Cardputer Zero` into the +current `Trail Mate` repository without damaging the existing ESP/PlatformIO +mainline. + +This document is subordinate to +[cardputer-zero-adaptation-spec.md](./cardputer-zero-adaptation-spec.md). +It describes migration sequencing, not the architectural source of truth. + +## Why not bolt Linux onto the current root build + +The current root build is still split between: + +- root `CMakeLists.txt` / `src/main.cpp` for ESP-IDF +- root `platformio.ini` for Arduino/PlatformIO targets +- `modules/*` that are only partially platform-neutral today + +Some modules that should eventually become shared still hard-reference ESP or +Arduino pieces. For example, [modules/ui_shared/library.json](../../../../modules/ui_shared/library.json) +still includes `../../platform/esp/arduino_common/include` and depends on +`Preferences`. + +That means treating `Cardputer Zero` as "just another board target" right now +would create the wrong dependency direction: Linux code would end up tunneling +through ESP-specific layers. + +## Why `M5Stack_Linux_Libs` changes the device plan + +For the real Cardputer Zero hardware shell, the most valuable external input is +`M5Stack_Linux_Libs`, not `M5CardputerZero-UserDemo`. + +- `M5Stack_Linux_Libs` gives us the reusable device-facing SDK: + LVGL integration, fbdev, evdev, and Linux bus helpers. +- `M5CardputerZero-UserDemo` is useful only as a source of concrete board + hints such as framebuffer naming and likely input-device paths. +- Therefore the correct move is to build our device shell on top of the SDK, + while keeping our app architecture and migration discipline independent from + the demo app. + +## Best adaptation strategy + +The recommended sequence is: + +1. Keep `apps/linux_sim` as the simulator and developer-tooling shell. +2. Keep `apps/linux_rpi` as the real Pi OS device shell, and let its preferred + device-facing path use `M5Stack_Linux_Libs`. +3. Establish and obey the final-shape architectural boundaries before feature + migration starts defining them by accident. +4. Extract shared logic from the current repository into `platform/linux/common` + and purified `modules/*` only after the responsible layer is explicit. + +## Structural order before feature order + +Before choosing the first real feature page, the migration should first do: + +1. normalize platform contracts +2. remove ESP-only dependencies from `ui_shared` +3. split platform-specific tails out of `core_*` module locations +4. keep Linux shared shell code in `platform/linux/common` + +Only then should feature verification slices begin. + +## Feature verification order + +Use this order when porting real Trail Mate features into the Linux shell after +the structural program above: + +1. runtime and input contracts +2. presentation-independent app state +3. render/layout helpers +4. storage abstractions and file-backed settings +5. Linux-safe chat, GPS, and team slices +6. hardware integrations for Pi OS on the real device + +## Working rule + +Do not start by compiling the whole current app stack under Linux. Start by +building two thin Linux shells and one neutral shared slice: + +- `apps/linux_sim` owns desktop process lifecycle and simulator presentation +- `apps/linux_rpi` owns Pi OS device lifecycle and the SDK-backed hardware path +- `platform/linux/common` owns only the Linux-safe code both shells need +- Linux file/storage adapters should enter the shared slice only when they are + no longer device-specific + +Then pull shared logic into it deliberately. + +That keeps the Linux adaptation incremental, testable, and reversible. diff --git a/apps/linux_rpi/docs/specification/project-baseline.md b/apps/linux_rpi/docs/specification/project-baseline.md new file mode 100644 index 00000000..a7ad4ec3 --- /dev/null +++ b/apps/linux_rpi/docs/specification/project-baseline.md @@ -0,0 +1,117 @@ +# Trail Mate Cardputer Zero Linux Baseline + +This document describes the Linux baseline and development stance. +The authoritative final-shape constraint document is +[cardputer-zero-adaptation-spec.md](./cardputer-zero-adaptation-spec.md). + +## 1. Current Confusions + +- `Cardputer Zero` is a Linux handheld running Pi OS, so it should not be modeled + as another MCU firmware board bring-up. +- `M5Stack_Linux_Libs` and `M5CardputerZero-UserDemo` do not play the same role: + the former is a reusable device SDK, while the latter is only a loose demo. +- The current Trail Mate repository already has the target directory names for + Linux, but the existing shared modules are not yet portable enough to be + reused directly. +- The repository root `CMakeLists.txt` is still owned by the ESP-IDF path, so a + Linux baseline should not try to hijack the root build. +- A simulator is required for fast iteration on Windows and WSL, but the + simulator must not become the device runtime. +- A Linux dev container is useful for workflow parity with Pi-OS-like userland, + but it is not a substitute for real framebuffer validation on the device. + +## 2. Candidate Distinctions + +- `apps/linux_sim` + The simulator and developer-tooling shell. +- `apps/linux_rpi` + The real Pi OS hardware shell. +- `M5Stack_Linux_Libs` + The device-facing SDK baseline for Cardputer Zero display, input, LVGL, and + low-level Linux bus access. +- `M5CardputerZero-UserDemo` + Reference material only for board-specific hints, not a project template. +- `platform/linux/common` + The Linux-safe shared slice used by both shells. +- `platform/linux/rpi` + Pi OS specific platform adapters that remain valuable only where the vendor + SDK does not already provide the right primitive. + +## 3. Invalid Distinctions + +- Do not force current Arduino or ESP-IDF runtime glue into this shell just to + make it compile. +- Do not treat `UserDemo` as a rigorous Linux engineering baseline just because + it runs on the target hardware. +- Do not treat SDL types, framebuffer ioctls, or Linux device paths as shared + app concepts. +- Do not pretend the current `modules/ui_shared` tree is already Linux-safe when + it still depends on ESP/Arduino pieces. +- Do not use this baseline as an excuse to create a second long-term copy of the + full Trail Mate business logic. +- Do not let `apps/linux_rpi` depend on simulator-only code living inside + `apps/linux_sim`. + +## 4. Engineer Decisions Already Made + +- Language: `C++20` +- Desktop simulator build system: standalone subproject `CMake` +- Device-facing SDK build system: `M5Stack_Linux_Libs` `SCons` + `Kconfig` +- Desktop simulator backend: `SDL3` +- Device SDK baseline: `M5Stack_Linux_Libs` +- Device reference only: `M5CardputerZero-UserDemo` +- Device baseline: Linux framebuffer output through the SDK-backed LVGL path +- Linux dev container baseline: Debian `Trixie` +- Root build isolation: the Linux shells stay under `apps/linux_sim` and + `apps/linux_rpi` and do not replace the repository-root ESP-IDF build +- Shared Linux slice: `platform/linux/common` + +## 5. Current Baseline (updated 2026-05-08) + +- The simulator must build on both Windows and Linux. — TARGET (structural work + in progress; build gate needs re-verification after P0-P6 changes) +- The framebuffer device target must build on Linux and be usable on Pi OS. — + NOT YET VALIDATED (CMake restructured, evdev adapter wired, but not yet + built/run on real Pi OS) +- The real device adaptation should prefer the vendor Linux SDK over custom + framebuffer code whenever the SDK already owns the problem well. +- The baseline app must stay tiny and intentionally generic. +- The Linux-safe shared slice belongs in `platform/linux/common`. — DONE +- Platform boundary violations in `ui_shared/library.json` and `core_chat/src/ble/` — + RESOLVED (2026-05-07, verified by boundary checker) +- LVGL ownership split (ShellSession / CanvasLvglHost / NativeLvglHost) — + PARTIAL (structure in place, need build verification and input callback fix) +- evdev input adapter — PARTIAL (code written, need rpi CMake wiring and real + device key sampling) +- Runtime path/env/capability centralization — PARTIAL (helpers created, + route/tracker/dev/settings migrated, remaining runtimes not yet unified) +- Demo world extraction — PARTIAL (DemoWorld class created, facade still + contains legacy loopback mesh and demo seeding; runtime_mode not yet driving + composition) +- Final Cardputer Zero feature work should migrate reusable logic out of this + shell split into the main repository structure instead of letting either shell + become a permanent fork. +- Feature-first execution is not allowed to redefine the architecture; feature + slices must validate the final-shape specification instead. + +## 6. Minimal Externalization + +- Use the simulator shell to prove iteration speed, and use the Pi shell to + prove real-device viability. +- Use `M5Stack_Linux_Libs` for LVGL, fbdev, evdev, and low-level device-facing + bring-up instead of promoting those responsibilities into Trail Mate too + early. +- Promote code into `platform/linux/common` only after a responsibility is + clearly proven to be shared. +- Keep device-specific Linux details in `platform/linux/rpi`. +- Keep simulator geometry and desktop input mapping in `apps/linux_sim`. + +## 7. Recommended Next Migration Slices + +1. Define Linux-safe input/runtime contracts that can outlive this shell. +2. Extract presentation-independent view state and formatting helpers. +3. Extract storage and filesystem abstractions. +4. Move Linux-safe pieces of chat/GPS/team logic only after their side effects + are clearly separated from ESP runtime services. +5. Grow `apps/linux_rpi` only with real device concerns, not with duplicated + desktop tooling or copied demo-app structure. diff --git a/apps/linux_rpi/main/SConstruct b/apps/linux_rpi/main/SConstruct new file mode 100644 index 00000000..9b773d18 --- /dev/null +++ b/apps/linux_rpi/main/SConstruct @@ -0,0 +1,45 @@ +import os + +Import("env") +with open(env["PROJECT_TOOL_S"]) as f: + exec(f.read()) + + +SRCS = Glob("src/*.c*") +INCLUDE = [ADir("."), ADir("include")] +PRIVATE_INCLUDE = [] +REQUIREMENTS = ["lvgl_component", "pthread"] +STATIC_LIB = [] +DYNAMIC_LIB = [] +DEFINITIONS = [] +DEFINITIONS_PRIVATE = [] +LDFLAGS = [] +LINK_SEARCH_PATH = [] +STATIC_FILES = [] + +lvgl_component = list( + filter(lambda component: component["target"] == "lvgl_component", env["COMPONENTS"]) +)[0] +if "CONFIG_V9_5_LV_USE_SDL" in os.environ: + lvgl_component["REQUIREMENTS"] += ["SDL2"] + lvgl_component["INCLUDE"] += ["/usr/include/SDL2"] + lvgl_component["DEFINITIONS"] += ["-D_REENTRANT"] + + +env["COMPONENTS"].append( + { + "target": "trailmate_cardputer_zero_m5sdk", + "SRCS": SRCS, + "INCLUDE": INCLUDE, + "PRIVATE_INCLUDE": PRIVATE_INCLUDE, + "REQUIREMENTS": REQUIREMENTS, + "STATIC_LIB": STATIC_LIB, + "DYNAMIC_LIB": DYNAMIC_LIB, + "DEFINITIONS": DEFINITIONS, + "DEFINITIONS_PRIVATE": DEFINITIONS_PRIVATE, + "LDFLAGS": LDFLAGS, + "LINK_SEARCH_PATH": LINK_SEARCH_PATH, + "STATIC_FILES": STATIC_FILES, + "REGISTER": "project", + } +) diff --git a/apps/linux_rpi/main/include/main.h b/apps/linux_rpi/main/include/main.h new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/apps/linux_rpi/main/include/main.h @@ -0,0 +1 @@ +#pragma once diff --git a/apps/linux_rpi/main/src/main.cpp b/apps/linux_rpi/main/src/main.cpp new file mode 100644 index 00000000..8695b703 --- /dev/null +++ b/apps/linux_rpi/main/src/main.cpp @@ -0,0 +1,195 @@ +#include "lvgl/lvgl.h" + +#include +#include +#include +#include + +// Trail Mate shared shell session — available when the SDK project is +// configured to pull in platform/linux/common and modules/*. +// #include "ui/shell_ui_runner.h" + +namespace +{ + +const char* getenvDefault(const char* name, const char* fallback) +{ + const char* value = std::getenv(name); + return (value != nullptr && value[0] != '\0') ? value : fallback; +} + +int detectSt7789vFramebuffer(char* dev_path, std::size_t buf_size) +{ + if (dev_path == nullptr || buf_size == 0U) return -1; + + FILE* fp = std::fopen("/proc/fb", "r"); + if (fp == nullptr) return -1; + + char line[256]; + int fb_num = -1; + while (std::fgets(line, sizeof(line), fp) != nullptr) + { + if (std::strstr(line, "fb_st7789v") != nullptr && + std::sscanf(line, "%d", &fb_num) == 1) + { + break; + } + } + std::fclose(fp); + if (fb_num < 0) return -1; + + std::snprintf(dev_path, buf_size, "/dev/fb%d", fb_num); + return 0; +} + +void logStartupInfo(const char* fbdev, const char* input_dev, + const char* settings_root) +{ + std::printf("=== Trail Mate Cardputer Zero ===\n"); + std::printf("Display: %s\n", fbdev ? fbdev : "(none)"); + std::printf("Input: %s\n", input_dev ? input_dev : "(none)"); + std::printf("Settings root: %s\n", + settings_root ? settings_root : "(default)"); + std::printf("Runtime mode: %s\n", + getenvDefault("TRAIL_MATE_RUNTIME_MODE", "local")); +} + +#if LV_USE_EVDEV +void initLinuxInput(lv_display_t* display) +{ + const char* pointer = std::getenv("LV_LINUX_MOUSE_DEVICE"); + if (pointer != nullptr && pointer[0] != '\0') + { + lv_indev_t* dev = lv_evdev_create(LV_INDEV_TYPE_POINTER, pointer); + lv_indev_set_display(dev, display); + } + + const char* keyboard = getenvDefault( + "LV_LINUX_KEYBOARD_DEVICE", + "/dev/input/by-path/platform-3f804000.i2c-event"); + if (keyboard != nullptr && keyboard[0] != '\0') + { + lv_indev_t* dev = lv_evdev_create(LV_INDEV_TYPE_KEYPAD, keyboard); + lv_indev_set_display(dev, display); + std::printf("Keyboard: %s\n", keyboard); + } + else + { + std::fprintf(stderr, + "[device] No keyboard device found. " + "Set LV_LINUX_KEYBOARD_DEVICE to override.\n"); + } +} +#endif + +#if LV_USE_LINUX_FBDEV +void initLinuxDisplay() +{ + char detected_path[64] = {0}; + const char* device = std::getenv("LV_LINUX_FBDEV_DEVICE"); + if ((device == nullptr || device[0] == '\0') && + detectSt7789vFramebuffer(detected_path, sizeof(detected_path)) == 0) + { + device = detected_path; + } + if (device == nullptr || device[0] == '\0') + { + device = "/dev/fb0"; + } + + std::printf("Framebuffer: %s\n", device); + lv_display_t* display = lv_linux_fbdev_create(); + if (display == nullptr) + { + std::fprintf(stderr, "Failed to create Linux fbdev display\n"); + std::exit(1); + } + lv_linux_fbdev_set_file(display, device); + +#if LV_USE_EVDEV + initLinuxInput(display); +#endif +} +#elif LV_USE_SDL +void initLinuxDisplay() +{ + const int width = std::atoi(getenvDefault("LV_SDL_VIDEO_WIDTH", "320")); + const int height = std::atoi(getenvDefault("LV_SDL_VIDEO_HEIGHT", "170")); + + lv_display_t* display = lv_sdl_window_create(width, height); + if (display == nullptr) + { + std::fprintf(stderr, "Failed to create SDL display\n"); + std::exit(1); + } + lv_sdl_mouse_create(); + lv_sdl_keyboard_create(); +} +#else +#error Unsupported LVGL backend configuration +#endif + +} // namespace + +int main() +{ + lv_init(); + initLinuxDisplay(); + + logStartupInfo( + std::getenv("LV_LINUX_FBDEV_DEVICE"), + std::getenv("LV_LINUX_KEYBOARD_DEVICE"), + std::getenv("TRAIL_MATE_SETTINGS_ROOT")); + + // ------------------------------------------------------------------ + // When the SDK build links against platform/linux/common, replace the + // bring-up placeholder below with the shared shell session: + // + // #include "ui/shell_ui_runner.h" + // + // trailmate::cardputer_zero::linux_ui::ShellSession shell; + // if (!shell.begin()) { std::fprintf(stderr, "Shell session failed\n"); return 1; } + // + // trailmate::cardputer_zero::linux_ui::NativeLvglHost host{shell}; + // while (true) { host.tick(); lv_timer_handler(); usleep(1000); } + // + // Until then, keep the bring-up UI as a visual confirmation that LVGL, + // fbdev, and evdev are working correctly on the target hardware. + // ------------------------------------------------------------------ + + // Bring-up UI: confirms display and input plumbing work. + lv_obj_t* screen = lv_screen_active(); + lv_obj_set_style_bg_color(screen, lv_color_hex(0x0F172A), 0); + lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0); + + lv_obj_t* title = lv_label_create(screen); + lv_label_set_text(title, "Trail Mate"); + lv_obj_set_style_text_color(title, lv_color_hex(0xE2E8F0), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_24, 0); + lv_obj_align(title, LV_ALIGN_TOP_LEFT, 14, 12); + + lv_obj_t* subtitle = lv_label_create(screen); + lv_label_set_text(subtitle, "Cardputer Zero — SDK bring-up"); + lv_obj_set_style_text_color(subtitle, lv_color_hex(0x93C5FD), 0); + lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_16, 0); + lv_obj_align_to(subtitle, title, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6); + + lv_obj_t* body = lv_label_create(screen); + lv_label_set_long_mode(body, LV_LABEL_LONG_WRAP); + lv_obj_set_width(body, 292); + lv_label_set_text(body, + "LVGL fbdev + evdev + M5Stack_Linux_Libs plumbing verified.\n\n" + "Next: link platform/linux/common and switch this shell to\n" + "Trail Mate shared ShellSession + NativeLvglHost."); + lv_obj_set_style_text_color(body, lv_color_hex(0xCBD5E1), 0); + lv_obj_set_style_text_font(body, &lv_font_montserrat_14, 0); + lv_obj_align(body, LV_ALIGN_TOP_LEFT, 14, 66); + + while (true) + { + lv_timer_handler(); + usleep(1000); + } + + return 0; +} diff --git a/apps/linux_rpi/scripts/build-device.sh b/apps/linux_rpi/scripts/build-device.sh new file mode 100644 index 00000000..4c6e8659 --- /dev/null +++ b/apps/linux_rpi/scripts/build-device.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="${1:-$ROOT_DIR/build/device}" +BUILD_TYPE="${BUILD_TYPE:-Debug}" + +GENERATOR_ARGS=() +if command -v ninja >/dev/null 2>&1; then + GENERATOR_ARGS=(-G Ninja) +fi + +cmake -S "$ROOT_DIR" -B "$BUILD_DIR" "${GENERATOR_ARGS[@]}" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" + +cmake --build "$BUILD_DIR" --target trailmate_cardputer_zero_device --config "$BUILD_TYPE" diff --git a/apps/linux_rpi/scripts/build-sdk-device.sh b/apps/linux_rpi/scripts/build-sdk-device.sh new file mode 100644 index 00000000..d6590634 --- /dev/null +++ b/apps/linux_rpi/scripts/build-sdk-device.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +project_dir="$(cd -- "${script_dir}/.." && pwd)" +repo_root="$(cd -- "${project_dir}/../.." && pwd)" +lvgl_commit="85aa60d18b3d5e5588d7b247abf90198f07c8a63" + +if command -v getent >/dev/null 2>&1 && command -v id >/dev/null 2>&1; then + detected_home="$(getent passwd "$(id -un)" | cut -d: -f6 || true)" + if [[ -n "${detected_home}" && -d "${detected_home}" ]]; then + export HOME="${detected_home}" + fi +fi + +if [[ -n "${HOME:-}" && -d "${HOME}/.local/bin" ]]; then + export PATH="${HOME}/.local/bin:${PATH}" +fi + +if [[ -z "${TRAIL_MATE_M5STACK_LINUX_LIBS_PATH:-}" && -d "${repo_root}/.tmp/M5Stack_Linux_Libs" ]]; then + export TRAIL_MATE_M5STACK_LINUX_LIBS_PATH="${repo_root}/.tmp/M5Stack_Linux_Libs" +fi + +if ! command -v scons >/dev/null 2>&1; then + echo "scons not found in PATH. Install it first, for example with pip3 install --user scons." >&2 + exit 1 +fi + +prepare_lvgl_sdk_layout() { + if [[ -z "${TRAIL_MATE_M5STACK_LINUX_LIBS_PATH:-}" ]]; then + return 0 + fi + + local sdk_root="${TRAIL_MATE_M5STACK_LINUX_LIBS_PATH}" + local repo_cache="${sdk_root}/github_source" + local archive_path="${repo_cache}/lvgl-${lvgl_commit}.zip" + local extracted_path="${repo_cache}/lvgl-${lvgl_commit}" + local sdk_lvgl_root="${repo_cache}/lvgl/lvgl_9_5" + local sdk_lvgl_path="${sdk_lvgl_root}/lvgl" + + if [[ -d "${sdk_lvgl_path}" ]]; then + return 0 + fi + + mkdir -p "${sdk_lvgl_root}" + + if [[ ! -d "${extracted_path}" && -f "${archive_path}" ]]; then + python3 - <<'PY' "${archive_path}" "${repo_cache}" +import sys +import zipfile + +archive_path = sys.argv[1] +destination = sys.argv[2] +with zipfile.ZipFile(archive_path, "r") as archive: + archive.extractall(destination) +PY + fi + + if [[ -d "${extracted_path}" ]]; then + mv "${extracted_path}" "${sdk_lvgl_path}" + return 0 + fi + + if [[ -d "${sdk_lvgl_root}" && ! -d "${sdk_lvgl_path}" ]]; then + shopt -s nullglob dotglob + local existing=("${sdk_lvgl_root}"/*) + shopt -u nullglob dotglob + if (( ${#existing[@]} > 0 )); then + mkdir -p "${sdk_lvgl_path}" + for entry in "${existing[@]}"; do + if [[ "${entry}" != "${sdk_lvgl_path}" ]]; then + mv "${entry}" "${sdk_lvgl_path}/" + fi + done + fi + fi +} + +prepare_lvgl_sdk_layout + +cd "${project_dir}" +scons "$@" diff --git a/apps/linux_rpi/scripts/run-device.sh b/apps/linux_rpi/scripts/run-device.sh new file mode 100644 index 00000000..faa24e2a --- /dev/null +++ b/apps/linux_rpi/scripts/run-device.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="${1:-$ROOT_DIR/build/device}" +BUILD_TYPE="${BUILD_TYPE:-Debug}" +FBDEV="${TRAIL_MATE_FBDEV:-/dev/fb0}" + +bash "$ROOT_DIR/scripts/build-device.sh" "$BUILD_DIR" + +DEVICE_BIN="$BUILD_DIR/trailmate_cardputer_zero_device" +if [[ ! -x "$DEVICE_BIN" ]]; then + DEVICE_BIN="$BUILD_DIR/$BUILD_TYPE/trailmate_cardputer_zero_device" +fi + +"$DEVICE_BIN" --fbdev "$FBDEV" diff --git a/apps/linux_rpi/scripts/run-sdk-device.sh b/apps/linux_rpi/scripts/run-sdk-device.sh new file mode 100644 index 00000000..88295707 --- /dev/null +++ b/apps/linux_rpi/scripts/run-sdk-device.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +project_dir="$(cd -- "${script_dir}/.." && pwd)" +binary="${project_dir}/dist/trailmate_cardputer_zero_m5sdk" + +if [[ ! -x "${binary}" ]]; then + echo "SDK device binary not found at ${binary}. Build it first with scripts/build-sdk-device.sh." >&2 + exit 1 +fi + +if [[ -z "${LV_LINUX_FBDEV_DEVICE:-}" ]]; then + export LV_LINUX_FBDEV_DEVICE=/dev/fb0 +fi + +exec "${binary}" "$@" diff --git a/apps/linux_rpi/src/targets/device_main.cpp b/apps/linux_rpi/src/targets/device_main.cpp new file mode 100644 index 00000000..be352e38 --- /dev/null +++ b/apps/linux_rpi/src/targets/device_main.cpp @@ -0,0 +1,53 @@ +#include +#include +#include +#include +#include + +#include "platform/device/linux_framebuffer_platform.h" +#include "ui/shell_ui_runner.h" + +namespace +{ + +std::string parseFramebufferPath(int argc, char** argv) +{ + std::string path = "/dev/fb0"; + + if (const char* env = std::getenv("TRAIL_MATE_FBDEV")) + { + if (*env != '\0') + { + path = env; + } + } + + for (int index = 1; index < argc; ++index) + { + const std::string_view current{argv[index]}; + if (current == "--fbdev" && (index + 1) < argc) + { + path = argv[index + 1]; + ++index; + } + } + + return path; +} + +} // namespace + +int main(int argc, char** argv) +{ + try + { + trailmate::cardputer_zero::platform::device::LinuxFramebufferPlatform device{parseFramebufferPath(argc, argv)}; + trailmate::cardputer_zero::linux_ui::runShellUi(device); + return 0; + } + catch (const std::exception& ex) + { + std::cerr << "Device startup failed: " << ex.what() << '\n'; + return 1; + } +} diff --git a/apps/linux_sim/.dockerignore b/apps/linux_sim/.dockerignore new file mode 100644 index 00000000..20cd66d8 --- /dev/null +++ b/apps/linux_sim/.dockerignore @@ -0,0 +1,2 @@ +build/ +.git/ diff --git a/apps/linux_sim/CMakeLists.txt b/apps/linux_sim/CMakeLists.txt new file mode 100644 index 00000000..8fc167f7 --- /dev/null +++ b/apps/linux_sim/CMakeLists.txt @@ -0,0 +1,108 @@ +cmake_minimum_required(VERSION 3.24) + +project(TrailMateCardputerZeroSimulator VERSION 0.1.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(CTest) +find_package(Threads REQUIRED) + +# --------------------------------------------------------------------------- +# Shared Linux sources, paths, and helper functions +# --------------------------------------------------------------------------- + +include("${PROJECT_SOURCE_DIR}/../../cmake/TrailMateLinuxSources.cmake") + +# --------------------------------------------------------------------------- +# Common library +# --------------------------------------------------------------------------- + +trailmate_add_linux_common(trailmate_cardputer_zero_linux_common) + +# --------------------------------------------------------------------------- +# SDL3 – simulator-only dependency +# --------------------------------------------------------------------------- + +include(FetchContent) + +set(SDL_SHARED OFF CACHE BOOL "" FORCE) +set(SDL_STATIC ON CACHE BOOL "" FORCE) +set(SDL_TESTS OFF CACHE BOOL "" FORCE) +set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE) +set(SDL_DISABLE_INSTALL ON CACHE BOOL "" FORCE) + +FetchContent_Declare( + SDL3 + GIT_REPOSITORY https://github.com/libsdl-org/SDL.git + GIT_TAG release-3.4.4 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(SDL3) + +add_executable(trailmate_cardputer_zero_simulator + src/platform/simulator/sdl_simulator.cpp + src/targets/simulator_main.cpp +) +target_include_directories(trailmate_cardputer_zero_simulator PRIVATE "${PROJECT_SOURCE_DIR}/src") +target_link_libraries(trailmate_cardputer_zero_simulator + PRIVATE trailmate_cardputer_zero_linux_common SDL3::SDL3 +) + +# --------------------------------------------------------------------------- +# LVGL and UI shell +# --------------------------------------------------------------------------- + +set(LV_BUILD_CONF_PATH + "${PROJECT_SOURCE_DIR}/../../platform/linux/common/include/lv_conf.h" + CACHE PATH "" FORCE) +set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) +set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + lvgl + GIT_REPOSITORY https://github.com/lvgl/lvgl.git + GIT_TAG v9.4.0 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(lvgl) + +trailmate_add_linux_ui_shell( + trailmate_cardputer_zero_ui_shell + trailmate_cardputer_zero_linux_common +) +# Simulator-specific include path for shell_ui_runner internals +target_include_directories(trailmate_cardputer_zero_ui_shell + PRIVATE "${PROJECT_SOURCE_DIR}/src" +) + +target_link_libraries(trailmate_cardputer_zero_simulator + PRIVATE trailmate_cardputer_zero_ui_shell +) + +# --------------------------------------------------------------------------- +# Smoke tests (simulator-only) +# --------------------------------------------------------------------------- + +if(BUILD_TESTING) + add_executable(trailmate_cardputer_zero_smoke tests/demo_app_smoke.cpp) + target_link_libraries(trailmate_cardputer_zero_smoke + PRIVATE trailmate_cardputer_zero_linux_common) + add_test(NAME trailmate_cardputer_zero_smoke + COMMAND trailmate_cardputer_zero_smoke) + + add_executable(trailmate_cardputer_zero_runtime_smoke + tests/linux_runtime_smoke.cpp) + target_link_libraries(trailmate_cardputer_zero_runtime_smoke + PRIVATE trailmate_cardputer_zero_linux_common) + add_test(NAME trailmate_cardputer_zero_runtime_smoke + COMMAND trailmate_cardputer_zero_runtime_smoke) + + add_executable(trailmate_cardputer_zero_path_safety_smoke + tests/path_safety_smoke.cpp) + target_link_libraries(trailmate_cardputer_zero_path_safety_smoke + PRIVATE trailmate_cardputer_zero_linux_common) + add_test(NAME trailmate_cardputer_zero_path_safety_smoke + COMMAND trailmate_cardputer_zero_path_safety_smoke) +endif() diff --git a/apps/linux_sim/CMakePresets.json b/apps/linux_sim/CMakePresets.json new file mode 100644 index 00000000..de1f2fb3 --- /dev/null +++ b/apps/linux_sim/CMakePresets.json @@ -0,0 +1,137 @@ +{ + "version": 4, + "cmakeMinimumRequired": { + "major": 3, + "minor": 24, + "patch": 0 + }, + "configurePresets": [ + { + "name": "windows-vs2022-debug", + "displayName": "Windows VS2022 Debug Simulator", + "description": "Generate a Visual Studio 2022 solution for the Cardputer Zero simulator.", + "generator": "Visual Studio 17 2022", + "binaryDir": "${sourceDir}/build/simulator", + "architecture": { + "value": "x64" + }, + "cacheVariables": { + "BUILD_TESTING": "ON" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "windows-vs2022-test-debug", + "displayName": "Windows VS2022 Debug Tests", + "description": "Generate a Visual Studio 2022 solution for Linux-shell smoke tests.", + "generator": "Visual Studio 17 2022", + "binaryDir": "${sourceDir}/build/test", + "architecture": { + "value": "x64" + }, + "cacheVariables": { + "BUILD_TESTING": "ON" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "linux-simulator-debug", + "displayName": "Linux Debug Simulator", + "description": "Configure the Cardputer Zero desktop simulator on Linux.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/simulator", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "BUILD_TESTING": "ON" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "linux-test-debug", + "displayName": "Linux Debug Tests", + "description": "Configure Linux-shell smoke tests on Linux.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/test", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "BUILD_TESTING": "ON" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + } + ], + "buildPresets": [ + { + "name": "windows-vs2022-debug-build-simulator", + "displayName": "Build Windows Simulator", + "configurePreset": "windows-vs2022-debug", + "configuration": "Debug", + "targets": [ + "trailmate_cardputer_zero_simulator" + ] + }, + { + "name": "windows-vs2022-debug-build-tests", + "displayName": "Build Windows Tests", + "configurePreset": "windows-vs2022-test-debug", + "configuration": "Debug", + "targets": [ + "trailmate_cardputer_zero_smoke", + "trailmate_cardputer_zero_runtime_smoke", + "trailmate_cardputer_zero_path_safety_smoke" + ] + }, + { + "name": "linux-simulator-debug-build", + "displayName": "Build Linux Simulator", + "configurePreset": "linux-simulator-debug", + "targets": [ + "trailmate_cardputer_zero_simulator" + ] + }, + { + "name": "linux-test-debug-build", + "displayName": "Build Linux Tests", + "configurePreset": "linux-test-debug", + "targets": [ + "trailmate_cardputer_zero_smoke", + "trailmate_cardputer_zero_runtime_smoke", + "trailmate_cardputer_zero_path_safety_smoke" + ] + } + ], + "testPresets": [ + { + "name": "windows-vs2022-debug-test", + "displayName": "Run Windows Tests", + "configurePreset": "windows-vs2022-test-debug", + "configuration": "Debug", + "output": { + "outputOnFailure": true + } + }, + { + "name": "linux-debug-test", + "displayName": "Run Linux Tests", + "configurePreset": "linux-test-debug", + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/apps/linux_sim/README.md b/apps/linux_sim/README.md index 9a6db613..f662d379 100644 --- a/apps/linux_sim/README.md +++ b/apps/linux_sim/README.md @@ -1,5 +1,103 @@ -# `apps/linux_sim` +# Trail Mate Cardputer Zero Simulator -First Linux validation target. +`apps/linux_sim` is now the simulator and developer-tooling shell for the future +`Cardputer Zero` adaptation. -This shell will be used to prove that shared core modules can run outside ESP environments before Raspberry Pi and UNO Q hardware targets are introduced. +This directory intentionally preserves the `LoFiBox-Zero` workflow model for +desktop development: + +- a self-contained `CMake` workspace +- a Windows/Linux `SDL3` front-view device simulator that now boots the real shared-ui shell +- scripted build/run flows for Windows, Linux, WSL, and a Linux GUI dev container +- smoke tests for the extracted Linux-safe shared slice + +The real Pi OS device shell now lives in +[`apps/linux_rpi`](../linux_rpi/README.md). + +## What stays here + +- simulator entrypoints and shell rendering +- Windows build ergonomics +- Linux desktop build ergonomics +- WSL validation for the Linux shell +- Docker-based Linux GUI development +- smoke tests for the Linux-safe shared slice + +## What moved out + +- Pi OS framebuffer device target +- Cardputer Zero device-specific documentation +- device build instructions and device entrypoint + +Those now belong to [`apps/linux_rpi`](../linux_rpi/README.md), while the shared +Linux-safe code they both use now lives under +[`platform/linux/common`](../../platform/linux/common/README.md). + +## Layout + +- `CMakeLists.txt`: standalone simulator shell build +- `CMakePresets.json`: Windows/Linux presets for simulator and tests +- `src/platform/simulator`: SDL front-view device simulator +- `src/targets`: simulator entrypoints +- `tests`: smoke tests for the Linux-safe shared slice +- `scripts`: helper scripts for build/run/dev-container flows +- `docker`: Linux GUI dev-container assets + +## Quick Start + +### Windows simulator + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\build-simulator.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\run-simulator.ps1 +``` + +### Windows Visual Studio solution + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\generate-windows-solution.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\open-windows-solution.ps1 +``` + +### Linux simulator + +```bash +bash scripts/build-simulator.sh +bash scripts/run-simulator.sh +``` + +The main simulator target is now the shared-ui milestone: it keeps the original +device shell and injects the real `ui_shared` menu shell into the simulated +screen, shows the shared boot splash and boot log, and still uses placeholder +pages for individual apps. + +Virtual keyboard note: +- left-click on `Fn` / `Shift` / `Ctrl` / `Alt` arms a one-shot modifier for the next key +- right-click on those modifier buttons toggles a sticky hold state so combos can be clicked with the mouse + +### WSL validation + +```bash +bash scripts/wsl-validate.sh +``` + +### Linux GUI dev container + +From WSL: + +```bash +bash scripts/run-dev-container.sh +``` + +From Windows PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\run-dev-container.ps1 +``` + +## Boundary note + +This shell is intentionally simulator-first. It should not become the place +where real Pi OS hardware concerns accumulate. Device-facing code belongs in +`apps/linux_rpi` and `platform/linux/rpi`, while only the truly shared Linux-safe +slice should flow into `platform/linux/common`. diff --git a/apps/linux_sim/docker/dev-simulator.Dockerfile b/apps/linux_sim/docker/dev-simulator.Dockerfile new file mode 100644 index 00000000..16d7eac2 --- /dev/null +++ b/apps/linux_sim/docker/dev-simulator.Dockerfile @@ -0,0 +1,32 @@ +FROM debian:trixie-slim + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + build-essential \ + ca-certificates \ + cmake \ + git \ + libasound2-dev \ + libegl-dev \ + libgl1-mesa-dev \ + libwayland-dev \ + libx11-dev \ + libxcursor-dev \ + libxext-dev \ + libxfixes-dev \ + libxi-dev \ + libxkbcommon-dev \ + libxrandr-dev \ + libxrender-dev \ + libxss-dev \ + libxtst-dev \ + ninja-build \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace/trail-mate/apps/linux_sim + +CMD ["/bin/bash"] diff --git a/apps/linux_sim/scripts/build-dev-container.sh b/apps/linux_sim/scripts/build-dev-container.sh new file mode 100644 index 00000000..07733690 --- /dev/null +++ b/apps/linux_sim/scripts/build-dev-container.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_TAG="${DEV_CONTAINER_IMAGE:-trailmate-cardputer-zero/dev-simulator:trixie}" +DOCKERFILE_PATH="${DEV_CONTAINER_DOCKERFILE:-$ROOT_DIR/docker/dev-simulator.Dockerfile}" +PLATFORM="${DEV_CONTAINER_PLATFORM:-}" + +require_command() { + local command_name="$1" + if command -v "$command_name" >/dev/null 2>&1; then + return 0 + fi + + printf 'Missing required command: %s\n' "$command_name" >&2 + if [[ "$command_name" == "docker" ]]; then + printf 'Install Docker Desktop and enable Linux containers before rerunning this script.\n' >&2 + fi + exit 1 +} + +ensure_docker_available() { + if docker version >/dev/null 2>&1; then + return 0 + fi + + printf 'Docker is installed but not usable from the current environment.\n' >&2 + printf 'On Windows + WSL, enable Docker Desktop WSL integration for your distro first.\n' >&2 + exit 1 +} + +print_step() { + local message="$1" + printf '\n==> %s\n' "$message" +} + +require_command docker +ensure_docker_available + +BUILD_ARGS=( + build + -f "$DOCKERFILE_PATH" + -t "$IMAGE_TAG" +) + +if [[ -n "$PLATFORM" ]]; then + BUILD_ARGS+=(--platform "$PLATFORM") +fi + +BUILD_ARGS+=("$ROOT_DIR") + +print_step "Building development container image $IMAGE_TAG" +docker "${BUILD_ARGS[@]}" + +printf '\nContainer image build completed successfully.\n' +printf ' Image tag: %s\n' "$IMAGE_TAG" +if [[ -n "$PLATFORM" ]]; then + printf ' Requested platform: %s\n' "$PLATFORM" +fi diff --git a/apps/linux_sim/scripts/build-simulator.ps1 b/apps/linux_sim/scripts/build-simulator.ps1 new file mode 100644 index 00000000..76656f1c --- /dev/null +++ b/apps/linux_sim/scripts/build-simulator.ps1 @@ -0,0 +1,26 @@ +param( + [string]$BuildDir = "build/simulator", + [string]$BuildType = "Debug" +) + +$ErrorActionPreference = "Stop" +$root = Resolve-Path (Join-Path $PSScriptRoot "..") +$resolvedBuildDir = Join-Path $root $BuildDir +$generatorArgs = @() + +if ($env:OS -eq "Windows_NT") { + $generatorArgs = @("-G", "Visual Studio 17 2022", "-A", "x64") +} elseif (Get-Command ninja -ErrorAction SilentlyContinue) { + $generatorArgs = @("-G", "Ninja") +} + +cmake -S $root -B $resolvedBuildDir @generatorArgs ` + "-DCMAKE_BUILD_TYPE=$BuildType" ` + "-DBUILD_TESTING=ON" + +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +cmake --build $resolvedBuildDir --target trailmate_cardputer_zero_simulator --config $BuildType +exit $LASTEXITCODE diff --git a/apps/linux_sim/scripts/build-simulator.sh b/apps/linux_sim/scripts/build-simulator.sh new file mode 100644 index 00000000..717c3b65 --- /dev/null +++ b/apps/linux_sim/scripts/build-simulator.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="${1:-$ROOT_DIR/build/simulator}" +BUILD_TYPE="${BUILD_TYPE:-Debug}" + +GENERATOR_ARGS=() +if command -v ninja >/dev/null 2>&1; then + GENERATOR_ARGS=(-G Ninja) +fi + +cmake -S "$ROOT_DIR" -B "$BUILD_DIR" "${GENERATOR_ARGS[@]}" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + -DBUILD_TESTING=ON + +cmake --build "$BUILD_DIR" --target trailmate_cardputer_zero_simulator --config "$BUILD_TYPE" diff --git a/apps/linux_sim/scripts/generate-windows-solution.ps1 b/apps/linux_sim/scripts/generate-windows-solution.ps1 new file mode 100644 index 00000000..b85b23c9 --- /dev/null +++ b/apps/linux_sim/scripts/generate-windows-solution.ps1 @@ -0,0 +1,27 @@ +param( + [string]$Preset = "windows-vs2022-debug" +) + +$ErrorActionPreference = "Stop" +$root = Resolve-Path (Join-Path $PSScriptRoot "..") + +if ($env:OS -ne "Windows_NT") { + throw "This script is intended for Windows only." +} + +cmake --preset $Preset +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +$solutionPath = Join-Path $root "build\\simulator\\TrailMateCardputerZero.sln" +if ($Preset -eq "windows-vs2022-test-debug") { + $solutionPath = Join-Path $root "build\\test\\TrailMateCardputerZero.sln" +} + +if (-not (Test-Path $solutionPath)) { + throw "Expected Visual Studio solution was not generated: $solutionPath" +} + +Write-Output "Generated Visual Studio solution:" +Write-Output $solutionPath diff --git a/apps/linux_sim/scripts/open-windows-solution.ps1 b/apps/linux_sim/scripts/open-windows-solution.ps1 new file mode 100644 index 00000000..04b9caa7 --- /dev/null +++ b/apps/linux_sim/scripts/open-windows-solution.ps1 @@ -0,0 +1,19 @@ +param( + [string]$Preset = "windows-vs2022-debug" +) + +$ErrorActionPreference = "Stop" +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +& (Join-Path $scriptDir "generate-windows-solution.ps1") -Preset $Preset +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +$root = Resolve-Path (Join-Path $scriptDir "..") +$solutionPath = Join-Path $root "build\\simulator\\TrailMateCardputerZero.sln" +if ($Preset -eq "windows-vs2022-test-debug") { + $solutionPath = Join-Path $root "build\\test\\TrailMateCardputerZero.sln" +} + +Start-Process $solutionPath diff --git a/apps/linux_sim/scripts/run-dev-container.ps1 b/apps/linux_sim/scripts/run-dev-container.ps1 new file mode 100644 index 00000000..d77331e8 --- /dev/null +++ b/apps/linux_sim/scripts/run-dev-container.ps1 @@ -0,0 +1,135 @@ +[CmdletBinding()] +param( + [string]$Distro = "Ubuntu-24.04", + [ValidateSet("simulator", "shell")] + [string]$Mode = "simulator", + [string]$BuildType = "Debug", + [string]$Platform, + [string]$ImageTag, + [switch]$SkipImageBuild, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$SimulatorArgs +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Convert-ToWslPath { + param( + [Parameter(Mandatory = $true)] + [string]$WindowsPath + ) + + $resolvedPath = (Resolve-Path -LiteralPath $WindowsPath).Path + $driveName = $resolvedPath.Substring(0, 1).ToLowerInvariant() + $restPath = $resolvedPath.Substring(2).Replace('\', '/') + return "/mnt/$driveName$restPath" +} + +function Ensure-WslDriveMounted { + param( + [Parameter(Mandatory = $true)] + [string]$WindowsPath + ) + + $resolvedPath = (Resolve-Path -LiteralPath $WindowsPath).Path + $driveName = $resolvedPath.Substring(0, 1).ToLowerInvariant() + if ($driveName -eq 'c') { + return + } + + $mountPoint = "/mnt/$driveName" + & wsl.exe -d $Distro --cd /mnt/c bash -lc "test -d '$mountPoint'" + if ($LASTEXITCODE -eq 0) { + return + } + + $driveSpec = "$($driveName.ToUpperInvariant()):" + & wsl.exe -d $Distro -u root --cd /mnt/c bash -lc "mkdir -p '$mountPoint' && mount -t drvfs '$driveSpec' '$mountPoint'" + if ($LASTEXITCODE -ne 0) { + throw "Failed to mount Windows drive '$($driveName.ToUpperInvariant()):' into WSL at $mountPoint" + } +} + +function Add-EnvIfPresent { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [string]$TargetName = $Name, + [switch]$ConvertPath + ) + + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { + return + } + + if ($ConvertPath.IsPresent) { + if (Test-Path -LiteralPath $value) { + Ensure-WslDriveMounted -WindowsPath $value + $value = Convert-ToWslPath -WindowsPath $value + } elseif ($value -match '^[A-Za-z]:[\\/]' ) { + throw "Environment variable '$Name' points to a Windows path that does not exist: $value" + } + } + + $script:wslArguments += "$TargetName=$value" +} + +$repositoryRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") +$repositoryRootWsl = Convert-ToWslPath -WindowsPath $repositoryRoot + +$wslArguments = @( + "-d", $Distro, + "--cd", $repositoryRootWsl, + "env", + "DEV_CONTAINER_MODE=$Mode", + "DEV_CONTAINER_BUILD_TYPE=$BuildType" +) + +if ($Platform) { + $wslArguments += "DEV_CONTAINER_PLATFORM=$Platform" +} + +if ($ImageTag) { + $wslArguments += "DEV_CONTAINER_IMAGE=$ImageTag" +} + +if ($SkipImageBuild.IsPresent) { + $wslArguments += "DEV_CONTAINER_SKIP_BUILD=1" +} + +$gpsScalarEnvNames = @( + "TRAIL_MATE_GPS_VALID", + "TRAIL_MATE_GPS_ENABLED", + "TRAIL_MATE_GPS_POWERED", + "TRAIL_MATE_GPS_LAT", + "TRAIL_MATE_GPS_LNG", + "TRAIL_MATE_GPS_ALT_M", + "TRAIL_MATE_GPS_SPEED_MPS", + "TRAIL_MATE_GPS_COURSE_DEG", + "TRAIL_MATE_GPS_SATS", + "TRAIL_MATE_GPS_HDOP", + "TRAIL_MATE_GPS_FIX", + "TRAIL_MATE_GPS_BAUD", + "TRAIL_MATE_GPS_DEVICE" +) + +foreach ($envName in $gpsScalarEnvNames) { + Add-EnvIfPresent -Name $envName +} + +Add-EnvIfPresent -Name "TRAIL_MATE_SD_ROOT" -TargetName "DEV_CONTAINER_TRAIL_MATE_SD_ROOT_HOST" -ConvertPath +Add-EnvIfPresent -Name "TRAIL_MATE_SETTINGS_ROOT" -TargetName "DEV_CONTAINER_TRAIL_MATE_SETTINGS_ROOT_HOST" -ConvertPath +Add-EnvIfPresent -Name "TRAIL_MATE_GPS_NMEA_FILE" -TargetName "DEV_CONTAINER_TRAIL_MATE_GPS_NMEA_FILE_HOST" -ConvertPath + +$wslArguments += "bash" +$wslArguments += "scripts/run-dev-container.sh" + +if ($SimulatorArgs -and $SimulatorArgs.Count -gt 0) { + $wslArguments += "--" + $wslArguments += $SimulatorArgs +} + +& wsl.exe @wslArguments +exit $LASTEXITCODE diff --git a/apps/linux_sim/scripts/run-dev-container.sh b/apps/linux_sim/scripts/run-dev-container.sh new file mode 100644 index 00000000..5ed9b2f4 --- /dev/null +++ b/apps/linux_sim/scripts/run-dev-container.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$ROOT_DIR/../.." && pwd)" +IMAGE_TAG="${DEV_CONTAINER_IMAGE:-trailmate-cardputer-zero/dev-simulator:trixie}" +BUILD_TYPE="${DEV_CONTAINER_BUILD_TYPE:-Debug}" +BUILD_VOLUME_NAME="${DEV_CONTAINER_BUILD_VOLUME:-trailmate-cardputer-zero-dev-build}" +CONTAINER_BUILD_ROOT="${DEV_CONTAINER_BUILD_ROOT:-/container-build}" +CONTAINER_BUILD_DIR="${DEV_CONTAINER_BUILD_DIR:-$CONTAINER_BUILD_ROOT/simulator}" +MODE="${DEV_CONTAINER_MODE:-simulator}" +SKIP_IMAGE_BUILD="${DEV_CONTAINER_SKIP_BUILD:-0}" +PLATFORM="${DEV_CONTAINER_PLATFORM:-}" +CONTAINER_SD_ROOT="${DEV_CONTAINER_CONTAINER_SD_ROOT:-/trailmate-sdroot}" +CONTAINER_SETTINGS_ROOT="${DEV_CONTAINER_CONTAINER_SETTINGS_ROOT:-/trailmate-settings-root}" +CONTAINER_GPS_NMEA_FILE="${DEV_CONTAINER_CONTAINER_GPS_NMEA_FILE:-/trailmate-gps-input.nmea}" + +require_command() { + local command_name="$1" + if command -v "$command_name" >/dev/null 2>&1; then + return 0 + fi + + printf 'Missing required command: %s\n' "$command_name" >&2 + if [[ "$command_name" == "docker" ]]; then + printf 'This workflow expects Docker to be available inside WSL.\n' >&2 + printf 'Enable Docker Desktop WSL integration for your distro, then rerun the script.\n' >&2 + fi + exit 1 +} + +ensure_docker_available() { + if docker version >/dev/null 2>&1; then + return 0 + fi + + printf 'Docker is installed but not usable from the current environment.\n' >&2 + printf 'On Windows + WSL, enable Docker Desktop WSL integration for your distro first.\n' >&2 + exit 1 +} + +print_step() { + local message="$1" + printf '\n==> %s\n' "$message" +} + +resolve_host_path_env() { + local converted_name="$1" + local direct_name="$2" + local value="${!converted_name:-}" + if [[ -n "$value" ]]; then + printf '%s' "$value" + return 0 + fi + + value="${!direct_name:-}" + if [[ "$value" == /* ]]; then + printf '%s' "$value" + return 0 + fi + + return 1 +} + +append_env_if_present() { + local name="$1" + if [[ -n "${!name:-}" ]]; then + RUN_ARGS+=(-e "${name}=${!name}") + fi +} + +simulator_args=() +while (( $# > 0 )); do + case "$1" in + --shell) + MODE="shell" + shift + ;; + --skip-image-build) + SKIP_IMAGE_BUILD=1 + shift + ;; + --) + shift + simulator_args=("$@") + break + ;; + *) + simulator_args+=("$1") + shift + ;; + esac +done + +require_command docker +ensure_docker_available + +if (( ! SKIP_IMAGE_BUILD )); then + "$ROOT_DIR/scripts/build-dev-container.sh" +fi + +GUI_ARGS=() + +if [[ -d /mnt/wslg ]]; then + GUI_ARGS+=(-v /mnt/wslg:/mnt/wslg) +fi + +if [[ -d /tmp/.X11-unix ]]; then + GUI_ARGS+=(-v /tmp/.X11-unix:/tmp/.X11-unix) +fi + +for environment_name in DISPLAY WAYLAND_DISPLAY XDG_RUNTIME_DIR PULSE_SERVER SDL_AUDIODRIVER SDL_VIDEODRIVER; do + if [[ -n "${!environment_name:-}" ]]; then + GUI_ARGS+=(-e "${environment_name}=${!environment_name}") + fi +done + +if [[ -d /mnt/wslg && -z "${XDG_RUNTIME_DIR:-}" ]]; then + GUI_ARGS+=(-e XDG_RUNTIME_DIR=/mnt/wslg/runtime-dir) +fi + +RUN_ARGS=( + run + --rm + -v "$REPO_ROOT:/workspace/trail-mate" + -v "$BUILD_VOLUME_NAME:$CONTAINER_BUILD_ROOT" + -w /workspace/trail-mate/apps/linux_sim + -e "TRAIL_MATE_CONTAINER_BUILD_TYPE=$BUILD_TYPE" + -e "TRAIL_MATE_CONTAINER_BUILD_DIR=$CONTAINER_BUILD_DIR" +) + +for env_name in \ + TRAIL_MATE_GPS_VALID \ + TRAIL_MATE_GPS_ENABLED \ + TRAIL_MATE_GPS_POWERED \ + TRAIL_MATE_GPS_LAT \ + TRAIL_MATE_GPS_LNG \ + TRAIL_MATE_GPS_ALT_M \ + TRAIL_MATE_GPS_SPEED_MPS \ + TRAIL_MATE_GPS_COURSE_DEG \ + TRAIL_MATE_GPS_SATS \ + TRAIL_MATE_GPS_HDOP \ + TRAIL_MATE_GPS_FIX \ + TRAIL_MATE_GPS_BAUD; do + append_env_if_present "$env_name" +done + +if host_sd_root="$(resolve_host_path_env DEV_CONTAINER_TRAIL_MATE_SD_ROOT_HOST TRAIL_MATE_SD_ROOT)"; then + if [[ ! -d "$host_sd_root" ]]; then + printf 'Configured host SD root does not exist: %s\n' "$host_sd_root" >&2 + exit 1 + fi + RUN_ARGS+=(-v "$host_sd_root:$CONTAINER_SD_ROOT:ro") + RUN_ARGS+=(-e "TRAIL_MATE_SD_ROOT=$CONTAINER_SD_ROOT") +fi + +if host_settings_root="$(resolve_host_path_env DEV_CONTAINER_TRAIL_MATE_SETTINGS_ROOT_HOST TRAIL_MATE_SETTINGS_ROOT)"; then + mkdir -p "$host_settings_root" + RUN_ARGS+=(-v "$host_settings_root:$CONTAINER_SETTINGS_ROOT") + RUN_ARGS+=(-e "TRAIL_MATE_SETTINGS_ROOT=$CONTAINER_SETTINGS_ROOT") +fi + +if host_nmea_file="$(resolve_host_path_env DEV_CONTAINER_TRAIL_MATE_GPS_NMEA_FILE_HOST TRAIL_MATE_GPS_NMEA_FILE)"; then + if [[ ! -f "$host_nmea_file" ]]; then + printf 'Configured host GPS NMEA file does not exist: %s\n' "$host_nmea_file" >&2 + exit 1 + fi + RUN_ARGS+=(-v "$host_nmea_file:$CONTAINER_GPS_NMEA_FILE:ro") + RUN_ARGS+=(-e "TRAIL_MATE_GPS_NMEA_FILE=$CONTAINER_GPS_NMEA_FILE") +fi + +if [[ -n "${TRAIL_MATE_GPS_DEVICE:-}" ]]; then + RUN_ARGS+=(-e "TRAIL_MATE_GPS_DEVICE=${TRAIL_MATE_GPS_DEVICE}") + if [[ "${TRAIL_MATE_GPS_DEVICE}" == /dev/* && -e "${TRAIL_MATE_GPS_DEVICE}" ]]; then + RUN_ARGS+=(--device "${TRAIL_MATE_GPS_DEVICE}:${TRAIL_MATE_GPS_DEVICE}") + fi +fi + +if [[ "$MODE" == "shell" ]]; then + RUN_ARGS+=(-it) +elif [[ -t 0 && -t 1 ]]; then + RUN_ARGS+=(-it) +fi + +if command -v id >/dev/null 2>&1 && [[ "$REPO_ROOT" != /mnt/* ]]; then + RUN_ARGS+=(--user "$(id -u):$(id -g)") +fi + +if [[ -n "$PLATFORM" ]]; then + RUN_ARGS+=(--platform "$PLATFORM") +fi + +RUN_ARGS+=("${GUI_ARGS[@]}") + +print_step "Launching development container in $MODE mode" + +if [[ "$MODE" == "shell" ]]; then + docker "${RUN_ARGS[@]}" "$IMAGE_TAG" bash + exit 0 +fi + +docker "${RUN_ARGS[@]}" "$IMAGE_TAG" bash -lc ' + set -euo pipefail + cmake -S . -B "$TRAIL_MATE_CONTAINER_BUILD_DIR" -G Ninja \ + -DCMAKE_BUILD_TYPE="$TRAIL_MATE_CONTAINER_BUILD_TYPE" \ + -DBUILD_TESTING=ON + cmake --build "$TRAIL_MATE_CONTAINER_BUILD_DIR" --target trailmate_cardputer_zero_simulator --config "$TRAIL_MATE_CONTAINER_BUILD_TYPE" + exec "$TRAIL_MATE_CONTAINER_BUILD_DIR/trailmate_cardputer_zero_simulator" "$@" +' bash "${simulator_args[@]}" diff --git a/apps/linux_sim/scripts/run-simulator.ps1 b/apps/linux_sim/scripts/run-simulator.ps1 new file mode 100644 index 00000000..827c2b2d --- /dev/null +++ b/apps/linux_sim/scripts/run-simulator.ps1 @@ -0,0 +1,29 @@ +param( + [string]$BuildDir = "build/simulator", + [string]$BuildType = "Debug", + [int]$Scale = 1, + [int]$AutoExitMs = 0 +) + +$ErrorActionPreference = "Stop" +$root = Resolve-Path (Join-Path $PSScriptRoot "..") +$resolvedBuildDir = Join-Path $root $BuildDir + +& (Join-Path $PSScriptRoot "build-simulator.ps1") -BuildDir $BuildDir -BuildType $BuildType +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +$candidate = Join-Path $resolvedBuildDir "trailmate_cardputer_zero_simulator.exe" +if (-not (Test-Path $candidate)) { + $candidate = Join-Path $resolvedBuildDir $BuildType + $candidate = Join-Path $candidate "trailmate_cardputer_zero_simulator.exe" +} + +$arguments = @("--scale", $Scale) +if ($AutoExitMs -gt 0) { + $arguments += @("--auto-exit-ms", $AutoExitMs) +} + +& $candidate @arguments +exit $LASTEXITCODE diff --git a/apps/linux_sim/scripts/run-simulator.sh b/apps/linux_sim/scripts/run-simulator.sh new file mode 100644 index 00000000..a201125e --- /dev/null +++ b/apps/linux_sim/scripts/run-simulator.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="${1:-$ROOT_DIR/build/simulator}" +BUILD_TYPE="${BUILD_TYPE:-Debug}" +SCALE="${SIM_SCALE:-1}" +AUTO_EXIT_MS="${SIM_AUTO_EXIT_MS:-0}" + +bash "$ROOT_DIR/scripts/build-simulator.sh" "$BUILD_DIR" + +SIM_BIN="$BUILD_DIR/trailmate_cardputer_zero_simulator" +if [[ ! -x "$SIM_BIN" ]]; then + SIM_BIN="$BUILD_DIR/$BUILD_TYPE/trailmate_cardputer_zero_simulator" +fi + +if [[ "$AUTO_EXIT_MS" -gt 0 ]]; then + "$SIM_BIN" --scale "$SCALE" --auto-exit-ms "$AUTO_EXIT_MS" +else + "$SIM_BIN" --scale "$SCALE" +fi diff --git a/apps/linux_sim/scripts/wsl-validate.sh b/apps/linux_sim/scripts/wsl-validate.sh new file mode 100644 index 00000000..b4714708 --- /dev/null +++ b/apps/linux_sim/scripts/wsl-validate.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$ROOT_DIR/../.." && pwd)" +BUILD_TYPE="${BUILD_TYPE:-Debug}" +TEST_BUILD_DIR="${WSL_TEST_BUILD_DIR:-$ROOT_DIR/build/wsl/test}" +SIM_BUILD_DIR="${WSL_SIM_BUILD_DIR:-$ROOT_DIR/build/wsl/simulator}" +RPI_ROOT_DIR="$(cd "$ROOT_DIR/../linux_rpi" && pwd)" +DEVICE_BUILD_DIR="${WSL_DEVICE_BUILD_DIR:-$RPI_ROOT_DIR/build/wsl/device}" + +require_command() { + local command_name="$1" + if command -v "$command_name" >/dev/null 2>&1; then + return 0 + fi + + printf 'Missing required command: %s\n' "$command_name" >&2 + printf 'Install the Linux build toolchain first, for example:\n' >&2 + printf ' sudo apt update && sudo apt install -y build-essential cmake ninja-build pkg-config git\n' >&2 + exit 1 +} + +print_step() { + local message="$1" + printf '\n==> %s\n' "$message" +} + +GENERATOR_ARGS=() +if command -v ninja >/dev/null 2>&1; then + GENERATOR_ARGS=(-G Ninja) +fi + +require_command cmake +require_command ctest +require_command git +require_command python3 + +if command -v pkg-config >/dev/null 2>&1; then + REQUIRED_PKGCONFIG_MODULES=( + alsa + wayland-client + xkbcommon + x11 + xrandr + xrender + xi + xext + xcursor + xfixes + xscrnsaver + xtst + egl + gl + ) + + MISSING_MODULES=() + for module_name in "${REQUIRED_PKGCONFIG_MODULES[@]}"; do + if ! pkg-config --exists "$module_name"; then + MISSING_MODULES+=("$module_name") + fi + done + + if (( ${#MISSING_MODULES[@]} > 0 )); then + printf 'Missing required Linux development packages for the SDL simulator.\n' >&2 + printf 'Missing pkg-config modules: %s\n' "${MISSING_MODULES[*]}" >&2 + printf 'On Ubuntu/WSL, install the packages documented in README.md before rerunning this script.\n' >&2 + exit 1 + fi +fi + +print_step "Verifying shared/platform UI boundaries" +python3 "$REPO_ROOT/scripts/check_platform_ui_boundaries.py" + +print_step "Configuring Linux-shell tests into $TEST_BUILD_DIR" +cmake -S "$ROOT_DIR" -B "$TEST_BUILD_DIR" "${GENERATOR_ARGS[@]}" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + -DBUILD_TESTING=ON + +print_step "Building Linux-shell tests" +cmake --build "$TEST_BUILD_DIR" --target trailmate_cardputer_zero_smoke --config "$BUILD_TYPE" +cmake --build "$TEST_BUILD_DIR" --target trailmate_cardputer_zero_runtime_smoke --config "$BUILD_TYPE" +cmake --build "$TEST_BUILD_DIR" --target trailmate_cardputer_zero_path_safety_smoke --config "$BUILD_TYPE" + +print_step "Running Linux-shell tests" +ctest --test-dir "$TEST_BUILD_DIR" --build-config "$BUILD_TYPE" --output-on-failure + +print_step "Configuring Linux simulator into $SIM_BUILD_DIR" +cmake -S "$ROOT_DIR" -B "$SIM_BUILD_DIR" "${GENERATOR_ARGS[@]}" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + -DBUILD_TESTING=ON + +print_step "Building Linux simulator" +cmake --build "$SIM_BUILD_DIR" --target trailmate_cardputer_zero_simulator --config "$BUILD_TYPE" + +print_step "Configuring Linux framebuffer device shell into $DEVICE_BUILD_DIR" +cmake -S "$RPI_ROOT_DIR" -B "$DEVICE_BUILD_DIR" "${GENERATOR_ARGS[@]}" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" + +print_step "Building Linux framebuffer device shell" +cmake --build "$DEVICE_BUILD_DIR" --target trailmate_cardputer_zero_device --config "$BUILD_TYPE" + +printf '\nWSL validation completed successfully.\n' +printf ' Tests build dir: %s\n' "$TEST_BUILD_DIR" +printf ' Simulator build dir: %s\n' "$SIM_BUILD_DIR" +printf ' Device build dir: %s\n' "$DEVICE_BUILD_DIR" +printf ' To launch the Linux simulator from WSL later:\n' +printf ' %s\n' "\"$SIM_BUILD_DIR/trailmate_cardputer_zero_simulator\" --scale 1" diff --git a/apps/linux_sim/src/platform/simulator/sdl_simulator.cpp b/apps/linux_sim/src/platform/simulator/sdl_simulator.cpp new file mode 100644 index 00000000..bee22d1a --- /dev/null +++ b/apps/linux_sim/src/platform/simulator/sdl_simulator.cpp @@ -0,0 +1,1135 @@ +#include "platform/simulator/sdl_simulator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "app/input_event.h" +#include "core/bitmap_font.h" +#include "core/canvas.h" +#include "core/display_profile.h" + +namespace trailmate::cardputer_zero::platform::simulator +{ +namespace +{ + +using core::rgba; + +constexpr int kDeviceWidth = 800; +constexpr int kDeviceHeight = 520; +constexpr int kScreenWidth = core::kDisplayWidth; +constexpr int kScreenHeight = core::kDisplayHeight; +constexpr int kScreenFrameRectX = 180; +constexpr int kScreenFrameRectY = 26; +constexpr int kScreenFrameRectWidth = 374; +constexpr int kScreenFrameRectHeight = 202; +constexpr int kScreenLipInsetX = 9; +constexpr int kScreenLipInsetY = 11; +constexpr int kScreenLipInsetBottom = 9; +constexpr int kScreenLipWidth = kScreenFrameRectWidth - (kScreenLipInsetX * 2); +constexpr int kScreenLipHeight = kScreenFrameRectHeight - kScreenLipInsetY - kScreenLipInsetBottom; +constexpr int kScreenOpeningInset = 6; +constexpr int kScreenOpeningWidth = kScreenWidth + (kScreenOpeningInset * 2); +constexpr int kScreenOpeningHeight = kScreenHeight + (kScreenOpeningInset * 2); +constexpr int kScreenOpeningX = kScreenFrameRectX + kScreenLipInsetX + ((kScreenLipWidth - kScreenOpeningWidth) / 2); +constexpr int kScreenOpeningY = kScreenFrameRectY + kScreenLipInsetY + ((kScreenLipHeight - kScreenOpeningHeight) / 2); +constexpr int kScreenX = kScreenOpeningX + kScreenOpeningInset; +constexpr int kScreenY = kScreenOpeningY + kScreenOpeningInset; +constexpr int kRightPanelX = 592; +constexpr int kRightPanelY = 24; +constexpr int kRightPanelWidth = 166; +constexpr int kRightPanelHeight = 226; +constexpr int kPowerSwitchWidth = 58; +constexpr int kPowerSwitchHeight = 34; +constexpr int kPowerSwitchX = kRightPanelX + ((kRightPanelWidth - kPowerSwitchWidth) / 2); +constexpr int kPowerSwitchY = 54; +constexpr int kPanelButtonWidth = 62; +constexpr int kPanelButtonHeight = 22; +constexpr int kPanelButtonGap = 8; +constexpr int kPanelButtonsTotalWidth = (kPanelButtonWidth * 2) + kPanelButtonGap; +constexpr int kHomeButtonX = kRightPanelX + ((kRightPanelWidth - kPanelButtonsTotalWidth) / 2); +constexpr int kNextButtonX = kHomeButtonX + kPanelButtonWidth + kPanelButtonGap; +constexpr int kPanelButtonsY = 185; +constexpr int kKeyboardDeckX = 18; +constexpr int kKeyboardDeckY = 240; +constexpr int kKeyboardDeckWidth = 740; +constexpr int kKeyboardDeckHeight = 242; +constexpr int kKeyboardDeckSeparatorInset = 18; +constexpr int kKeyboardDeckSeparatorX = kKeyboardDeckX + kKeyboardDeckSeparatorInset; +constexpr int kKeyboardDeckSeparatorWidth = kKeyboardDeckWidth - (kKeyboardDeckSeparatorInset * 2); +constexpr int kKeyUnitWidth = 54; +constexpr int kKeyGap = 8; +constexpr int kKeyAreaHeight = 42; +constexpr int kKeyboardRowStartX = 36; +constexpr int kKeyboardRow1Y = 254; +constexpr int kKeyboardRowGapY = 54; +constexpr auto kPressFlash = std::chrono::milliseconds(140); + +constexpr auto kBackdrop = rgba(6, 34, 58); +constexpr auto kShellShadow = rgba(80, 88, 96); +constexpr auto kShell = rgba(223, 225, 227); +constexpr auto kShellBorder = rgba(188, 191, 194); +constexpr auto kInset = rgba(210, 212, 214); +constexpr auto kScreenFrame = rgba(18, 20, 24); +constexpr auto kScreenLip = rgba(32, 35, 40); +constexpr auto kLabelDark = rgba(40, 43, 47); +constexpr auto kKeyBody = rgba(26, 29, 33); +constexpr auto kKeyPressed = rgba(81, 101, 122); +constexpr auto kAccentPink = rgba(220, 91, 142); +constexpr auto kAccentBlue = rgba(74, 155, 240); +constexpr auto kAccentOrange = rgba(243, 150, 63); +constexpr auto kAccentYellow = rgba(245, 217, 84); +constexpr auto kStickerWhite = rgba(248, 248, 248); +constexpr auto kPowerRed = rgba(221, 69, 63); +constexpr auto kPowerRedPressed = rgba(245, 96, 88); +constexpr auto kPanelDark = rgba(70, 74, 79); + +enum class ButtonStyle +{ + Keyboard, + Panel, + Switch, +}; + +enum class LatchMode +{ + None, + OneShot, + Sticky, +}; + +enum class LegendSymbol +{ + None, + ArrowLeft, + ArrowRight, + ArrowUp, + ArrowDown, + BackArrow, + SpaceBar, +}; + +struct Rect +{ + int x{}; + int y{}; + int width{}; + int height{}; + + [[nodiscard]] bool contains(int px, int py) const noexcept + { + return px >= x && py >= y && px < (x + width) && py < (y + height); + } +}; + +struct KeyboardLegend +{ + std::string text{}; + core::Color color{rgba(40, 43, 47)}; + LegendSymbol symbol{LegendSymbol::None}; + bool badge{false}; + core::Color badge_fill{rgba(0, 0, 0, 0)}; +}; + +struct VirtualButton +{ + Rect bounds{}; + app::InputEvent event{}; + KeyboardLegend primary_legend{}; + KeyboardLegend secondary_legend{}; + std::vector highlight_aliases{}; + ButtonStyle style{ButtonStyle::Keyboard}; + std::chrono::steady_clock::time_point pressed_until{}; + LatchMode latch_mode{LatchMode::None}; +}; + +[[nodiscard]] std::string upperCopy(std::string_view value) +{ + std::string result{}; + result.reserve(value.size()); + + for (const char ch : value) + { + result.push_back(static_cast(std::toupper(static_cast(ch)))); + } + + return result; +} + +void requireSdl(bool condition, std::string_view step) +{ + if (!condition) + { + throw std::runtime_error(std::string(step) + ": " + SDL_GetError()); + } +} + +template +T* requireSdl(T* pointer, std::string_view step) +{ + if (!pointer) + { + throw std::runtime_error(std::string(step) + ": " + SDL_GetError()); + } + + return pointer; +} + +} // namespace + +struct SdlSimulator::Impl +{ + SDL_Window* window{}; + SDL_Renderer* renderer{}; + SDL_Texture* texture{}; + bool running{true}; + int scale{1}; + int window_width{}; + int window_height{}; + core::Canvas device_canvas{kDeviceWidth, kDeviceHeight}; + std::vector staging = std::vector(static_cast(kDeviceWidth * kDeviceHeight)); + std::vector input_queue{}; + std::vector buttons{}; +}; + +[[nodiscard]] bool isModifierKey(app::InputKey key) noexcept +{ + switch (key) + { + case app::InputKey::Fn: + case app::InputKey::Ctrl: + case app::InputKey::Alt: + case app::InputKey::Shift: + return true; + default: + return false; + } +} + +[[nodiscard]] bool isModifierButton(const VirtualButton& button) noexcept +{ + return isModifierKey(button.event.key); +} + +[[nodiscard]] KeyboardLegend textLegend(std::string text, core::Color color = kLabelDark) +{ + return KeyboardLegend{std::move(text), color, LegendSymbol::None, false, rgba(0, 0, 0, 0)}; +} + +[[nodiscard]] KeyboardLegend badgeLegend(std::string text, core::Color text_color, core::Color badge_fill) +{ + return KeyboardLegend{std::move(text), text_color, LegendSymbol::None, true, badge_fill}; +} + +[[nodiscard]] KeyboardLegend symbolLegend(LegendSymbol symbol, core::Color color = kLabelDark) +{ + return KeyboardLegend{{}, color, symbol, false, rgba(0, 0, 0, 0)}; +} + +void addKeyboardButton( + std::vector& buttons, + int x, + int y, + KeyboardLegend primary_legend, + app::InputEvent event, + KeyboardLegend secondary_legend = {}, + std::initializer_list aliases = {}) +{ + VirtualButton button{}; + button.bounds = Rect{x, y, kKeyUnitWidth, kKeyAreaHeight}; + button.primary_legend = std::move(primary_legend); + button.secondary_legend = std::move(secondary_legend); + button.style = ButtonStyle::Keyboard; + button.event = std::move(event); + if (!button.event.label.empty()) + { + button.highlight_aliases.push_back(button.event.label); + } + for (const auto alias : aliases) + { + button.highlight_aliases.emplace_back(alias); + } + buttons.push_back(std::move(button)); +} + +void addPanelButton( + std::vector& buttons, + Rect bounds, + std::string primary, + app::InputEvent event, + ButtonStyle style = ButtonStyle::Panel, + std::initializer_list aliases = {}) +{ + VirtualButton button{}; + button.bounds = bounds; + button.primary_legend = textLegend(std::move(primary), rgba(247, 247, 247)); + button.style = style; + button.event = std::move(event); + for (const auto alias : aliases) + { + button.highlight_aliases.emplace_back(alias); + } + buttons.push_back(std::move(button)); +} + +void buildKeyboardButtons(std::vector& buttons) +{ + const auto key_x = [](int column) noexcept + { + return kKeyboardRowStartX + (column * (kKeyUnitWidth + kKeyGap)); + }; + + addKeyboardButton(buttons, key_x(0), kKeyboardRow1Y, textLegend("1"), app::makeCharacterInput('1', "1"), textLegend("!", kAccentBlue), {"!"}); + addKeyboardButton(buttons, key_x(1), kKeyboardRow1Y, textLegend("2"), app::makeCharacterInput('2', "2"), textLegend("@", kAccentBlue), {"@"}); + addKeyboardButton(buttons, key_x(2), kKeyboardRow1Y, textLegend("3"), app::makeCharacterInput('3', "3"), textLegend("#", kAccentBlue), {"#"}); + addKeyboardButton(buttons, key_x(3), kKeyboardRow1Y, textLegend("4"), app::makeCharacterInput('4', "4"), textLegend("$", kAccentBlue), {"$"}); + addKeyboardButton(buttons, key_x(4), kKeyboardRow1Y, textLegend("5"), app::makeCharacterInput('5', "5"), textLegend("%", kAccentBlue), {"%"}); + addKeyboardButton(buttons, key_x(5), kKeyboardRow1Y, textLegend("6"), app::makeCharacterInput('6', "6"), textLegend("^", kAccentBlue), {"^"}); + addKeyboardButton(buttons, key_x(6), kKeyboardRow1Y, textLegend("7"), app::makeCharacterInput('7', "7"), textLegend("&", kAccentBlue), {"&"}); + addKeyboardButton(buttons, key_x(7), kKeyboardRow1Y, textLegend("8"), app::makeCharacterInput('8', "8"), textLegend("*", kAccentBlue), {"*"}); + addKeyboardButton(buttons, key_x(8), kKeyboardRow1Y, textLegend("9"), app::makeCharacterInput('9', "9"), textLegend("(", kAccentBlue), {"("}); + addKeyboardButton(buttons, key_x(9), kKeyboardRow1Y, textLegend("0"), app::makeCharacterInput('0', "0"), textLegend(")", kAccentBlue), {")"}); + addKeyboardButton( + buttons, + key_x(10), + kKeyboardRow1Y, + symbolLegend(LegendSymbol::BackArrow), + app::InputEvent{app::InputKey::Backspace, "DEL", '\0'}, + textLegend("del", kAccentOrange), + {"BACKSPACE"}); + + const int row2Y = kKeyboardRow1Y + kKeyboardRowGapY; + addKeyboardButton( + buttons, + key_x(0), + row2Y, + textLegend("tab"), + app::InputEvent{app::InputKey::Tab, "TAB", '\0'}, + textLegend("alt", kAccentOrange), + {"ALT"}); + addKeyboardButton(buttons, key_x(1), row2Y, textLegend("Q"), app::makeCharacterInput('q', "Q"), textLegend("~", kAccentBlue), {"~"}); + addKeyboardButton(buttons, key_x(2), row2Y, textLegend("W"), app::makeCharacterInput('w', "W"), textLegend("`", kAccentBlue), {"`"}); + addKeyboardButton(buttons, key_x(3), row2Y, textLegend("E"), app::makeCharacterInput('e', "E"), textLegend("+", kAccentBlue), {"+"}); + addKeyboardButton(buttons, key_x(4), row2Y, textLegend("R"), app::makeCharacterInput('r', "R"), textLegend("-", kAccentBlue)); + addKeyboardButton(buttons, key_x(5), row2Y, textLegend("T"), app::makeCharacterInput('t', "T"), textLegend("/", kAccentBlue), {"/"}); + addKeyboardButton(buttons, key_x(6), row2Y, textLegend("Y"), app::makeCharacterInput('y', "Y"), textLegend("\\", kAccentBlue), {"\\"}); + addKeyboardButton(buttons, key_x(7), row2Y, textLegend("U"), app::makeCharacterInput('u', "U"), textLegend("{", kAccentBlue), {"{"}); + addKeyboardButton(buttons, key_x(8), row2Y, textLegend("I"), app::makeCharacterInput('i', "I"), textLegend("}", kAccentBlue), {"}"}); + addKeyboardButton(buttons, key_x(9), row2Y, textLegend("O"), app::makeCharacterInput('o', "O"), textLegend("[", kAccentBlue), {"["}); + addKeyboardButton(buttons, key_x(10), row2Y, textLegend("P"), app::makeCharacterInput('p', "P"), textLegend("]", kAccentBlue), {"]"}); + + const int row3Y = row2Y + kKeyboardRowGapY; + addKeyboardButton( + buttons, + key_x(0), + row3Y, + badgeLegend("Aa", rgba(255, 255, 255), kAccentBlue), + app::InputEvent{app::InputKey::Shift, "AA", '\0'}, + {}, + {"SHIFT"}); + addKeyboardButton(buttons, key_x(1), row3Y, textLegend("A"), app::makeCharacterInput('a', "A")); + addKeyboardButton(buttons, key_x(2), row3Y, textLegend("S"), app::makeCharacterInput('s', "S")); + addKeyboardButton( + buttons, + key_x(3), + row3Y, + textLegend("D"), + app::makeCharacterInput('d', "D"), + symbolLegend(LegendSymbol::ArrowUp, kAccentOrange), + {"UP"}); + addKeyboardButton(buttons, key_x(4), row3Y, textLegend("F"), app::makeCharacterInput('f', "F"), textLegend("|", kAccentBlue), {"|"}); + addKeyboardButton(buttons, key_x(5), row3Y, textLegend("G"), app::makeCharacterInput('g', "G"), textLegend("=", kAccentBlue), {"="}); + addKeyboardButton(buttons, key_x(6), row3Y, textLegend("H"), app::makeCharacterInput('h', "H"), textLegend(":", kAccentBlue), {":"}); + addKeyboardButton(buttons, key_x(7), row3Y, textLegend("J"), app::makeCharacterInput('j', "J"), textLegend(";", kAccentBlue), {";"}); + addKeyboardButton(buttons, key_x(8), row3Y, textLegend("K"), app::makeCharacterInput('k', "K"), textLegend("_", kAccentBlue), {"_"}); + addKeyboardButton(buttons, key_x(9), row3Y, textLegend("L"), app::makeCharacterInput('l', "L"), textLegend("?", kAccentBlue), {"?"}); + addKeyboardButton( + buttons, + key_x(10), + row3Y, + symbolLegend(LegendSymbol::BackArrow), + app::InputEvent{app::InputKey::Enter, "OK", '\0'}, + textLegend("ok"), + {"ENTER"}); + + const int row4Y = row3Y + kKeyboardRowGapY; + addKeyboardButton( + buttons, + key_x(0), + row4Y, + badgeLegend("fn", rgba(255, 255, 255), kAccentOrange), + app::InputEvent{app::InputKey::Fn, "FN", '\0'}); + addKeyboardButton( + buttons, + key_x(1), + row4Y, + badgeLegend("ctrl", kLabelDark, kAccentYellow), + app::InputEvent{app::InputKey::Ctrl, "CTRL", '\0'}); + addKeyboardButton( + buttons, + key_x(2), + row4Y, + textLegend("Z"), + app::makeCharacterInput('z', "Z"), + symbolLegend(LegendSymbol::ArrowLeft, kAccentOrange), + {"LEFT"}); + addKeyboardButton( + buttons, + key_x(3), + row4Y, + textLegend("X"), + app::makeCharacterInput('x', "X"), + symbolLegend(LegendSymbol::ArrowDown, kAccentOrange), + {"DOWN"}); + addKeyboardButton( + buttons, + key_x(4), + row4Y, + textLegend("C"), + app::makeCharacterInput('c', "C"), + symbolLegend(LegendSymbol::ArrowRight, kAccentOrange), + {"RIGHT"}); + addKeyboardButton(buttons, key_x(5), row4Y, textLegend("V"), app::makeCharacterInput('v', "V"), textLegend("<", kAccentBlue), {"<"}); + addKeyboardButton(buttons, key_x(6), row4Y, textLegend("B"), app::makeCharacterInput('b', "B"), textLegend(">", kAccentBlue), {">"}); + addKeyboardButton(buttons, key_x(7), row4Y, textLegend("N"), app::makeCharacterInput('n', "N"), textLegend("'", kAccentBlue), {"'"}); + addKeyboardButton(buttons, key_x(8), row4Y, textLegend("M"), app::makeCharacterInput('m', "M"), textLegend("\"", kAccentBlue), {"\""}); + addKeyboardButton(buttons, key_x(9), row4Y, textLegend(","), app::makeCharacterInput(',', ","), textLegend(".", kAccentBlue), {"."}); + addKeyboardButton( + buttons, + key_x(10), + row4Y, + symbolLegend(LegendSymbol::SpaceBar), + app::makeCharacterInput(' ', "SPACE")); +} + +void buildPanelButtons(std::vector& buttons) +{ + addPanelButton( + buttons, + Rect{kPowerSwitchX, kPowerSwitchY, kPowerSwitchWidth, kPowerSwitchHeight}, + "ON", + app::InputEvent{app::InputKey::Power, "POWER", '\0'}, + ButtonStyle::Switch, + {"POWER"}); + addPanelButton( + buttons, + Rect{kHomeButtonX, kPanelButtonsY, kPanelButtonWidth, kPanelButtonHeight}, + "HOME", + app::InputEvent{app::InputKey::Home, "HOME", '\0'}, + ButtonStyle::Panel, + {"HOME"}); + addPanelButton( + buttons, + Rect{kNextButtonX, kPanelButtonsY, kPanelButtonWidth, kPanelButtonHeight}, + "NEXT", + app::InputEvent{app::InputKey::Next, "NEXT", '\0'}, + ButtonStyle::Panel, + {"NEXT"}); +} + +[[nodiscard]] std::vector buildButtons() +{ + std::vector buttons{}; + buttons.reserve(49); + buildKeyboardButtons(buttons); + buildPanelButtons(buttons); + return buttons; +} + +void markPressed(VirtualButton& button) +{ + button.pressed_until = std::chrono::steady_clock::now() + kPressFlash; +} + +void clearLatch(VirtualButton& button) noexcept +{ + button.latch_mode = LatchMode::None; +} + +void setOneShotLatch(VirtualButton& button) noexcept +{ + button.latch_mode = LatchMode::OneShot; +} + +void toggleStickyLatch(VirtualButton& button) noexcept +{ + button.latch_mode = button.latch_mode == LatchMode::Sticky ? LatchMode::None : LatchMode::Sticky; +} + +[[nodiscard]] bool isLatched(const VirtualButton& button) noexcept +{ + return button.latch_mode != LatchMode::None; +} + +[[nodiscard]] bool isPressed(const VirtualButton& button) noexcept +{ + return isLatched(button) || std::chrono::steady_clock::now() < button.pressed_until; +} + +[[nodiscard]] int centeredTextX(std::string_view text, int scale, int left, int width) noexcept +{ + return left + ((width - core::bitmap_font::measureText(text, scale)) / 2); +} + +void drawLabelBlock(core::Canvas& canvas, int x, int y, std::string_view text, core::Color color, int scale = 1) +{ + core::bitmap_font::drawText(canvas, text, x, y, color, scale); +} + +[[nodiscard]] bool hasLegend(const KeyboardLegend& legend) noexcept +{ + return legend.symbol != LegendSymbol::None || !legend.text.empty(); +} + +[[nodiscard]] int symbolWidth(LegendSymbol symbol) noexcept +{ + switch (symbol) + { + case LegendSymbol::ArrowLeft: + case LegendSymbol::ArrowRight: + case LegendSymbol::ArrowUp: + case LegendSymbol::ArrowDown: + return 11; + case LegendSymbol::BackArrow: + return 14; + case LegendSymbol::SpaceBar: + return 16; + case LegendSymbol::None: + return 0; + } + + return 0; +} + +[[nodiscard]] int legendWidth(const KeyboardLegend& legend) noexcept +{ + int width = 0; + if (!legend.text.empty()) + { + width = core::bitmap_font::measureText(legend.text, 1); + } + else if (legend.symbol != LegendSymbol::None) + { + width = symbolWidth(legend.symbol); + } + + if (legend.badge) + { + width += 8; + } + + return width; +} + +void drawLegendSymbol(core::Canvas& canvas, int x, int y, LegendSymbol symbol, core::Color color) +{ + switch (symbol) + { + case LegendSymbol::ArrowLeft: + canvas.fillRect(x + 3, y + 3, 8, 1, color); + canvas.fillRect(x + 2, y + 2, 1, 3, color); + canvas.fillRect(x + 1, y + 1, 1, 5, color); + canvas.fillRect(x + 0, y + 0, 1, 7, color); + break; + case LegendSymbol::ArrowRight: + canvas.fillRect(x, y + 3, 8, 1, color); + canvas.fillRect(x + 8, y + 2, 1, 3, color); + canvas.fillRect(x + 9, y + 1, 1, 5, color); + canvas.fillRect(x + 10, y + 0, 1, 7, color); + break; + case LegendSymbol::ArrowUp: + canvas.fillRect(x + 5, y, 1, 7, color); + canvas.fillRect(x + 4, y + 1, 3, 1, color); + canvas.fillRect(x + 3, y + 2, 5, 1, color); + canvas.fillRect(x + 2, y + 3, 7, 1, color); + break; + case LegendSymbol::ArrowDown: + canvas.fillRect(x + 5, y, 1, 7, color); + canvas.fillRect(x + 2, y + 3, 7, 1, color); + canvas.fillRect(x + 3, y + 4, 5, 1, color); + canvas.fillRect(x + 4, y + 5, 3, 1, color); + break; + case LegendSymbol::BackArrow: + canvas.fillRect(x + 5, y + 3, 8, 1, color); + canvas.fillRect(x + 5, y + 4, 8, 1, color); + canvas.fillRect(x + 4, y + 2, 1, 3, color); + canvas.fillRect(x + 3, y + 1, 1, 5, color); + canvas.fillRect(x + 2, y + 0, 1, 7, color); + break; + case LegendSymbol::SpaceBar: + canvas.fillRect(x + 2, y + 6, 12, 1, color); + canvas.fillRect(x + 2, y + 2, 1, 5, color); + canvas.fillRect(x + 13, y + 2, 1, 5, color); + break; + case LegendSymbol::None: + break; + } +} + +void drawKeyboardLegend(core::Canvas& canvas, int x, int y, const KeyboardLegend& legend) +{ + if (!hasLegend(legend)) + { + return; + } + + int content_x = x; + if (legend.badge) + { + const int badge_width = legendWidth(legend); + canvas.fillRect(x, y - 2, badge_width, 15, legend.badge_fill); + content_x += 4; + } + + if (legend.symbol != LegendSymbol::None) + { + drawLegendSymbol(canvas, content_x, y + 1, legend.symbol, legend.color); + return; + } + + drawLabelBlock(canvas, content_x, y, legend.text, legend.color, 1); +} + +void drawKeyboardButton(core::Canvas& canvas, const VirtualButton& button) +{ + const bool pressed = isPressed(button); + const auto pill_color = pressed ? kKeyPressed : kKeyBody; + const int pill_y = button.bounds.y + 18; + const int pill_height = button.bounds.height - 18; + + const bool has_secondary = hasLegend(button.secondary_legend); + const int primary_width = legendWidth(button.primary_legend); + const int secondary_width = has_secondary ? legendWidth(button.secondary_legend) : 0; + const int legend_gap = has_secondary ? 2 : 0; + const int legend_total_width = primary_width + secondary_width + legend_gap; + int legend_x = button.bounds.x + ((button.bounds.width - legend_total_width) / 2); + + drawKeyboardLegend(canvas, legend_x, button.bounds.y, button.primary_legend); + if (has_secondary) + { + legend_x += primary_width + legend_gap; + drawKeyboardLegend(canvas, legend_x, button.bounds.y, button.secondary_legend); + } + + canvas.fillRoundedRect(button.bounds.x + 2, pill_y + 2, button.bounds.width - 4, pill_height - 2, 10, rgba(9, 11, 14)); + canvas.fillRoundedRect(button.bounds.x, pill_y, button.bounds.width - 2, pill_height - 4, 10, pill_color); +} + +void drawPanelButton(core::Canvas& canvas, const VirtualButton& button) +{ + const bool pressed = isPressed(button); + auto fill = kPanelDark; + auto text = rgba(247, 247, 247); + + if (button.style == ButtonStyle::Switch) + { + fill = pressed ? kPowerRedPressed : kPowerRed; + text = rgba(255, 255, 255); + } + else if (pressed) + { + fill = rgba(90, 98, 110); + } + + canvas.fillRoundedRect(button.bounds.x, button.bounds.y, button.bounds.width, button.bounds.height, 10, fill); + drawLabelBlock( + canvas, + centeredTextX(button.primary_legend.text, 1, button.bounds.x, button.bounds.width), + button.bounds.y + ((button.bounds.height - core::bitmap_font::lineHeight(1)) / 2) + 1, + button.primary_legend.text, + text, + 1); +} + +void drawButtons(core::Canvas& canvas, const std::vector& buttons) +{ + for (const auto& button : buttons) + { + if (button.style == ButtonStyle::Keyboard) + { + drawKeyboardButton(canvas, button); + } + else + { + drawPanelButton(canvas, button); + } + } +} + +void drawLeftPanel(core::Canvas& canvas) +{ + canvas.fillRoundedRect(24, 24, 144, 226, 20, kInset); + drawLabelBlock(canvas, 38, 40, "TM", rgba(198, 201, 204), 6); + + canvas.fillRoundedRect(24, 110, 132, 88, 10, kStickerWhite); + drawLabelBlock(canvas, 34, 120, "CARDPUTER", rgba(74, 74, 76), 1); + drawLabelBlock(canvas, 37, 142, "ZERO", rgba(34, 34, 36), 3); + drawLabelBlock(canvas, 34, 174, "PI OS LINUX", rgba(78, 78, 82), 1); + + canvas.fillRoundedRect(40, 202, 38, 16, 8, rgba(38, 40, 43)); + canvas.fillRoundedRect(106, 202, 16, 16, 8, rgba(202, 204, 206)); +} + +void drawRightPanel(core::Canvas& canvas) +{ + canvas.fillRoundedRect(kRightPanelX, kRightPanelY, kRightPanelWidth, kRightPanelHeight, 18, kInset); + drawLabelBlock(canvas, centeredTextX("POWER", 2, kRightPanelX, kRightPanelWidth), 100, "POWER", kLabelDark, 2); + drawLabelBlock(canvas, centeredTextX("SWITCH", 1, kRightPanelX, kRightPanelWidth), 120, "SWITCH", kLabelDark, 1); + drawLabelBlock(canvas, centeredTextX("USB-C", 1, kRightPanelX, kRightPanelWidth), 130, "USB-C", kAccentOrange, 1); + drawLabelBlock(canvas, centeredTextX("LINUX", 1, kRightPanelX, kRightPanelWidth), 144, "LINUX", kAccentBlue, 1); + drawLabelBlock(canvas, centeredTextX("CARDPUTER ZERO", 1, kRightPanelX, kRightPanelWidth), 159, "CARDPUTER ZERO", kAccentPink, 1); + drawLabelBlock(canvas, centeredTextX("HOME", 1, kHomeButtonX, kPanelButtonWidth), 214, "HOME", kLabelDark, 1); + drawLabelBlock(canvas, centeredTextX("NEXT", 1, kNextButtonX, kPanelButtonWidth), 214, "NEXT", kLabelDark, 1); +} + +void drawScreenFrame(core::Canvas& canvas) +{ + canvas.fillRoundedRect(kScreenFrameRectX, kScreenFrameRectY, kScreenFrameRectWidth, kScreenFrameRectHeight, 18, kScreenFrame); + canvas.fillRoundedRect( + kScreenFrameRectX + kScreenLipInsetX, + kScreenFrameRectY + kScreenLipInsetY, + kScreenLipWidth, + kScreenLipHeight, + 12, + kScreenLip); + canvas.fillRect(kScreenOpeningX, kScreenOpeningY, kScreenOpeningWidth, kScreenOpeningHeight, rgba(0, 0, 0)); +} + +void drawKeyboardDeck(core::Canvas& canvas) +{ + canvas.fillRoundedRect(kKeyboardDeckX, kKeyboardDeckY, kKeyboardDeckWidth, kKeyboardDeckHeight, 18, rgba(230, 232, 234)); + canvas.fillRect(kKeyboardDeckSeparatorX, 306, kKeyboardDeckSeparatorWidth, 2, rgba(205, 207, 209)); + canvas.fillRect(kKeyboardDeckSeparatorX, 360, kKeyboardDeckSeparatorWidth, 2, rgba(205, 207, 209)); + canvas.fillRect(kKeyboardDeckSeparatorX, 414, kKeyboardDeckSeparatorWidth, 2, rgba(205, 207, 209)); +} + +void drawShell(core::Canvas& canvas, const core::Canvas& screen_canvas, const std::vector& buttons) +{ + canvas.clear(kBackdrop); + canvas.fillRoundedRect(12, 16, kDeviceWidth - 20, kDeviceHeight - 28, 34, kShellShadow); + canvas.fillRoundedRect(0, 0, kDeviceWidth - 20, kDeviceHeight - 28, 34, kShell); + canvas.fillRoundedRect(6, 6, kDeviceWidth - 32, 14, 7, rgba(255, 255, 255)); + canvas.fillRoundedRect(8, 8, kDeviceWidth - 36, kDeviceHeight - 44, 30, kShell); + canvas.strokeRect(12, 12, kDeviceWidth - 44, kDeviceHeight - 52, kShellBorder, 1); + + drawLeftPanel(canvas); + drawRightPanel(canvas); + drawScreenFrame(canvas); + drawKeyboardDeck(canvas); + drawButtons(canvas, buttons); + canvas.blit(screen_canvas, kScreenX, kScreenY); +} + +void highlightByLabel(std::vector& buttons, std::string_view label) +{ + const auto normalized = upperCopy(label); + for (auto& button : buttons) + { + for (const auto& alias : button.highlight_aliases) + { + if (upperCopy(alias) == normalized) + { + markPressed(button); + return; + } + } + } +} + +[[nodiscard]] int toDeviceCoordinate(float coordinate, int scale) noexcept +{ + if (scale <= 1) + { + return static_cast(coordinate); + } + + return static_cast(coordinate / static_cast(scale)); +} + +[[nodiscard]] std::uint32_t packPixel(core::Color color) noexcept +{ + return (static_cast(color.a) << 24U) | (static_cast(color.b) << 16U) | (static_cast(color.g) << 8U) | static_cast(color.r); +} + +[[nodiscard]] std::string characterLabel(char ch) +{ + if (ch == ' ') + { + return "SPACE"; + } + + std::string label{}; + label.push_back(static_cast(std::toupper(static_cast(ch)))); + return label; +} + +[[nodiscard]] std::optional eventFromLegend(const KeyboardLegend& legend) +{ + switch (legend.symbol) + { + case LegendSymbol::ArrowLeft: + return app::InputEvent{app::InputKey::Left, "LEFT", '\0'}; + case LegendSymbol::ArrowRight: + return app::InputEvent{app::InputKey::Right, "RIGHT", '\0'}; + case LegendSymbol::ArrowUp: + return app::InputEvent{app::InputKey::Up, "UP", '\0'}; + case LegendSymbol::ArrowDown: + return app::InputEvent{app::InputKey::Down, "DOWN", '\0'}; + case LegendSymbol::None: + case LegendSymbol::BackArrow: + case LegendSymbol::SpaceBar: + break; + } + + if (legend.text.empty()) + { + return std::nullopt; + } + + const auto normalized = upperCopy(legend.text); + if (normalized == "ALT") + { + return app::InputEvent{app::InputKey::Alt, "ALT", '\0'}; + } + if (normalized == "DEL") + { + return app::InputEvent{app::InputKey::Backspace, "DEL", '\0'}; + } + if (normalized == "OK") + { + return app::InputEvent{app::InputKey::Enter, "OK", '\0'}; + } + if (legend.text.size() == 1U) + { + return app::makeCharacterInput(legend.text.front(), characterLabel(legend.text.front())); + } + + return std::nullopt; +} + +[[nodiscard]] app::InputEvent resolveButtonEvent(const VirtualButton& button, const std::vector& buttons) +{ + bool shift_active = false; + bool fn_active = false; + + for (const auto& candidate : buttons) + { + if (!isLatched(candidate)) + { + continue; + } + + if (candidate.event.key == app::InputKey::Shift) + { + shift_active = true; + } + else if (candidate.event.key == app::InputKey::Fn) + { + fn_active = true; + } + } + + if (fn_active) + { + if (const auto alternate = eventFromLegend(button.secondary_legend)) + { + return *alternate; + } + } + + if (shift_active && button.event.key == app::InputKey::Character && button.event.text != '\0') + { + const unsigned char value = static_cast(button.event.text); + if (std::isalpha(value)) + { + const char shifted = static_cast(std::toupper(value)); + return app::makeCharacterInput(shifted, characterLabel(shifted)); + } + + if (const auto alternate = eventFromLegend(button.secondary_legend)) + { + return *alternate; + } + } + + return button.event; +} + +void consumeOneShotModifiers(std::vector& buttons) noexcept +{ + for (auto& button : buttons) + { + if (button.latch_mode == LatchMode::OneShot) + { + clearLatch(button); + } + } +} + +void enqueueSpecial( + std::vector& queue, + std::vector& buttons, + app::InputKey key, + std::string label) +{ + queue.push_back(app::InputEvent{key, label, '\0'}); + highlightByLabel(buttons, label); +} + +void handleKeyboardEvent( + std::vector& queue, + std::vector& buttons, + const SDL_KeyboardEvent& event) +{ + if (event.repeat) + { + return; + } + + switch (event.key) + { + case SDLK_BACKSPACE: + enqueueSpecial(queue, buttons, app::InputKey::Backspace, "DEL"); + break; + case SDLK_RETURN: + enqueueSpecial(queue, buttons, app::InputKey::Enter, "OK"); + break; + case SDLK_TAB: + enqueueSpecial(queue, buttons, app::InputKey::Tab, "TAB"); + break; + case SDLK_HOME: + enqueueSpecial(queue, buttons, app::InputKey::Home, "HOME"); + break; + case SDLK_END: + enqueueSpecial(queue, buttons, app::InputKey::Next, "NEXT"); + break; + case SDLK_ESCAPE: + enqueueSpecial(queue, buttons, app::InputKey::Power, "POWER"); + break; + case SDLK_LSHIFT: + case SDLK_RSHIFT: + enqueueSpecial(queue, buttons, app::InputKey::Shift, "AA"); + break; + case SDLK_LCTRL: + case SDLK_RCTRL: + enqueueSpecial(queue, buttons, app::InputKey::Ctrl, "CTRL"); + break; + case SDLK_LALT: + case SDLK_RALT: + enqueueSpecial(queue, buttons, app::InputKey::Alt, "ALT"); + break; + case SDLK_LEFT: + enqueueSpecial(queue, buttons, app::InputKey::Left, "LEFT"); + break; + case SDLK_RIGHT: + enqueueSpecial(queue, buttons, app::InputKey::Right, "RIGHT"); + break; + case SDLK_UP: + enqueueSpecial(queue, buttons, app::InputKey::Up, "UP"); + break; + case SDLK_DOWN: + enqueueSpecial(queue, buttons, app::InputKey::Down, "DOWN"); + break; + default: + break; + } +} + +void handleTextInput( + std::vector& queue, + std::vector& buttons, + const SDL_TextInputEvent& event) +{ + if (!event.text) + { + return; + } + + for (const char* current = event.text; *current != '\0'; ++current) + { + const unsigned char value = static_cast(*current); + if (value > 127U || !std::isprint(value)) + { + continue; + } + + const char ch = static_cast(value); + std::string label{}; + if (ch == ' ') + { + label = "SPACE"; + } + else + { + label.push_back(static_cast(std::toupper(value))); + } + + queue.push_back(app::makeCharacterInput(ch, label)); + highlightByLabel(buttons, label); + } +} + +void handleMouseClick( + std::vector& queue, + std::vector& buttons, + const SDL_MouseButtonEvent& event, + int scale) +{ + if (event.button != SDL_BUTTON_LEFT && event.button != SDL_BUTTON_RIGHT) + { + return; + } + + const int device_x = toDeviceCoordinate(event.x, scale); + const int device_y = toDeviceCoordinate(event.y, scale); + + for (auto& button : buttons) + { + if (!button.bounds.contains(device_x, device_y)) + { + continue; + } + + markPressed(button); + + if (isModifierButton(button)) + { + if (event.button == SDL_BUTTON_RIGHT) + { + toggleStickyLatch(button); + if (button.latch_mode == LatchMode::Sticky) + { + markPressed(button); + } + } + else + { + if (button.latch_mode != LatchMode::Sticky) + { + setOneShotLatch(button); + } + } + break; + } + + queue.push_back(resolveButtonEvent(button, buttons)); + consumeOneShotModifiers(buttons); + break; + } +} + +SdlSimulator::SdlSimulator(int scale) + : impl_(std::make_unique()) +{ + const int clamped_scale = std::max(scale, 1); + impl_->scale = clamped_scale; + impl_->window_width = kDeviceWidth * clamped_scale; + impl_->window_height = kDeviceHeight * clamped_scale; + impl_->buttons = buildButtons(); + + requireSdl(SDL_Init(SDL_INIT_VIDEO), "SDL_Init"); + + impl_->window = requireSdl( + SDL_CreateWindow("Trail Mate Cardputer Zero Front Simulator", impl_->window_width, impl_->window_height, 0), + "SDL_CreateWindow"); + impl_->renderer = requireSdl(SDL_CreateRenderer(impl_->window, nullptr), "SDL_CreateRenderer"); + impl_->texture = requireSdl( + SDL_CreateTexture(impl_->renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STREAMING, kDeviceWidth, kDeviceHeight), + "SDL_CreateTexture"); + + requireSdl(SDL_SetTextureScaleMode(impl_->texture, SDL_SCALEMODE_NEAREST), "SDL_SetTextureScaleMode"); + requireSdl(SDL_StartTextInput(impl_->window), "SDL_StartTextInput"); +} + +SdlSimulator::~SdlSimulator() +{ + if (impl_->texture) + { + SDL_DestroyTexture(impl_->texture); + } + if (impl_->renderer) + { + SDL_DestroyRenderer(impl_->renderer); + } + if (impl_->window) + { + SDL_DestroyWindow(impl_->window); + } + + SDL_Quit(); +} + +bool SdlSimulator::pump() +{ + SDL_Event event{}; + while (SDL_PollEvent(&event)) + { + if (event.type == SDL_EVENT_QUIT) + { + impl_->running = false; + continue; + } + if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN) + { + handleMouseClick(impl_->input_queue, impl_->buttons, event.button, impl_->scale); + continue; + } + if (event.type == SDL_EVENT_KEY_DOWN) + { + handleKeyboardEvent(impl_->input_queue, impl_->buttons, event.key); + continue; + } + if (event.type == SDL_EVENT_TEXT_INPUT) + { + handleTextInput(impl_->input_queue, impl_->buttons, event.text); + } + } + + return impl_->running; +} + +std::vector SdlSimulator::drainInput() +{ + auto drained = std::move(impl_->input_queue); + impl_->input_queue.clear(); + return drained; +} + +void SdlSimulator::present(const core::Canvas& canvas) +{ + drawShell(impl_->device_canvas, canvas, impl_->buttons); + + const auto& pixels = impl_->device_canvas.pixels(); + for (std::size_t i = 0; i < pixels.size(); ++i) + { + impl_->staging[i] = packPixel(pixels[i]); + } + + requireSdl( + SDL_UpdateTexture( + impl_->texture, + nullptr, + impl_->staging.data(), + impl_->device_canvas.width() * static_cast(sizeof(std::uint32_t))), + "SDL_UpdateTexture"); + requireSdl(SDL_SetRenderDrawColor(impl_->renderer, 0, 0, 0, 255), "SDL_SetRenderDrawColor"); + requireSdl(SDL_RenderClear(impl_->renderer), "SDL_RenderClear"); + + const SDL_FRect destination{0.0F, 0.0F, static_cast(impl_->window_width), static_cast(impl_->window_height)}; + requireSdl(SDL_RenderTexture(impl_->renderer, impl_->texture, nullptr, &destination), "SDL_RenderTexture"); + requireSdl(SDL_RenderPresent(impl_->renderer), "SDL_RenderPresent"); +} + +} // namespace trailmate::cardputer_zero::platform::simulator diff --git a/apps/linux_sim/src/platform/simulator/sdl_simulator.h b/apps/linux_sim/src/platform/simulator/sdl_simulator.h new file mode 100644 index 00000000..24c40fb8 --- /dev/null +++ b/apps/linux_sim/src/platform/simulator/sdl_simulator.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include "platform/surface_presenter.h" + +namespace trailmate::cardputer_zero::platform::simulator +{ + +class SdlSimulator : public SurfacePresenter +{ + public: + explicit SdlSimulator(int scale = 1); + ~SdlSimulator() override; + + [[nodiscard]] bool pump() override; + [[nodiscard]] std::vector drainInput() override; + void present(const core::Canvas& canvas) override; + + private: + struct Impl; + + std::unique_ptr impl_{}; +}; + +} // namespace trailmate::cardputer_zero::platform::simulator diff --git a/apps/linux_sim/src/targets/simulator_main.cpp b/apps/linux_sim/src/targets/simulator_main.cpp new file mode 100644 index 00000000..071083b6 --- /dev/null +++ b/apps/linux_sim/src/targets/simulator_main.cpp @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "platform/simulator/sdl_simulator.h" +#include "ui/shell_ui_runner.h" + +namespace +{ + +struct SimulatorOptions +{ + int scale{1}; + int auto_exit_ms{0}; +}; + +SimulatorOptions parseOptions(int argc, char** argv) +{ + SimulatorOptions options{}; + + if (const char* env = std::getenv("TRAIL_MATE_SIM_SCALE")) + { + options.scale = std::max(1, std::atoi(env)); + } + if (const char* env = std::getenv("TRAIL_MATE_SIM_AUTO_EXIT_MS")) + { + options.auto_exit_ms = std::max(0, std::atoi(env)); + } + + for (int index = 1; index < argc; ++index) + { + const std::string_view current{argv[index]}; + if (current == "--scale" && (index + 1) < argc) + { + options.scale = std::max(1, std::atoi(argv[index + 1])); + ++index; + continue; + } + + if (current == "--auto-exit-ms" && (index + 1) < argc) + { + options.auto_exit_ms = std::max(0, std::atoi(argv[index + 1])); + ++index; + } + } + + return options; +} + +} // namespace + +int main(int argc, char** argv) +{ + try + { + const auto options = parseOptions(argc, argv); + + // Simulator defaults to demo mode (synthetic peers, loopback mesh). + // Only set when the user has not already specified a mode. + if (!std::getenv("TRAIL_MATE_RUNTIME_MODE")) + { +#if defined(_WIN32) + _putenv_s("TRAIL_MATE_RUNTIME_MODE", "demo"); +#else + setenv("TRAIL_MATE_RUNTIME_MODE", "demo", 1); +#endif + } + + trailmate::cardputer_zero::platform::simulator::SdlSimulator simulator{options.scale}; + trailmate::cardputer_zero::linux_ui::runShellUi( + simulator, + std::chrono::milliseconds{options.auto_exit_ms}); + return 0; + } + catch (const std::exception& ex) + { + std::cerr << "Simulator startup failed: " << ex.what() << '\n'; + return 1; + } +} diff --git a/apps/linux_sim/tests/demo_app_smoke.cpp b/apps/linux_sim/tests/demo_app_smoke.cpp new file mode 100644 index 00000000..55c4c4b1 --- /dev/null +++ b/apps/linux_sim/tests/demo_app_smoke.cpp @@ -0,0 +1,72 @@ +#include +#include +#include +#include + +#include "app/demo_app.h" +#include "app/input_event.h" +#include "core/canvas.h" +#include "core/color.h" +#include "core/display_profile.h" + +int main() +{ + trailmate::cardputer_zero::app::DemoApp app{}; + trailmate::cardputer_zero::core::Canvas canvas{ + trailmate::cardputer_zero::core::kDisplayWidth, + trailmate::cardputer_zero::core::kDisplayHeight}; + app.render(canvas); + const auto baseline_pixels = canvas.pixels(); + + app.handleInput(trailmate::cardputer_zero::app::makeCharacterInput('A', "A")); + app.handleInput(trailmate::cardputer_zero::app::InputEvent{ + trailmate::cardputer_zero::app::InputKey::Next, + "NEXT", + '\0'}); + app.render(canvas); + + const auto background = canvas.pixel(0, 0); + std::size_t changed_pixels = 0; + std::size_t frame_delta = 0; + std::unordered_set palette{}; + + for (std::size_t index = 0; index < canvas.pixels().size(); ++index) + { + const auto& pixel = canvas.pixels()[index]; + if (pixel != background) + { + ++changed_pixels; + } + palette.insert(trailmate::cardputer_zero::core::pack_key(pixel)); + if (pixel != baseline_pixels[index]) + { + ++frame_delta; + } + } + + if (changed_pixels < 1000) + { + std::cerr << "Expected the demo screen to render more than a trivial number of pixels.\n"; + return 1; + } + + if (palette.size() < 4) + { + std::cerr << "Expected multiple colors in the rendered demo screen.\n"; + return 1; + } + + if (canvas.pixel(12, 12) == background) + { + std::cerr << "Expected the framed application panel to differ from the outer background.\n"; + return 1; + } + + if (frame_delta == 0) + { + std::cerr << "Expected the rendered frame to change after simulated input.\n"; + return 1; + } + + return 0; +} diff --git a/apps/linux_sim/tests/linux_runtime_smoke.cpp b/apps/linux_sim/tests/linux_runtime_smoke.cpp new file mode 100644 index 00000000..4bc8dc5a --- /dev/null +++ b/apps/linux_sim/tests/linux_runtime_smoke.cpp @@ -0,0 +1,345 @@ +#include "app/linux_app_facade.h" +#include "chat/usecase/chat_service.h" +#include "chat/usecase/contact_service.h" +#include "platform/ui/device_runtime.h" +#include "platform/ui/firmware_update_runtime.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/hostlink_runtime.h" +#include "platform/ui/lora_runtime.h" +#include "platform/ui/orientation_runtime.h" +#include "platform/ui/route_storage.h" +#include "platform/ui/screen_runtime.h" +#include "platform/ui/settings_store.h" +#include "platform/ui/sstv_runtime.h" +#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/time_runtime.h" +#include "platform/ui/tracker_runtime.h" +#include "platform/ui/usb_support_runtime.h" +#include "platform/ui/walkie_runtime.h" +#include "platform/ui/wifi_runtime.h" +#include "team/protocol/team_track.h" +#include "team/usecase/team_pairing_service.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +void set_env_var(const char* name, const std::string& value) +{ +#if defined(_WIN32) + _putenv_s(name, value.c_str()); +#else + setenv(name, value.c_str(), 1); +#endif +} + +} // namespace + +int main() +{ + using namespace platform::ui; + namespace ui_gps = platform::ui::gps; + namespace ui_hostlink = platform::ui::hostlink; + using trailmate::cardputer_zero::linux_ui::MinimalLinuxAppFacade; + + const std::filesystem::path runtime_root = + std::filesystem::temp_directory_path() / "trailmate-linux-runtime-smoke"; + std::error_code ec; + std::filesystem::remove_all(runtime_root, ec); + std::filesystem::create_directories(runtime_root, ec); + assert(!ec); + + set_env_var("TRAIL_MATE_SETTINGS_ROOT", runtime_root.string()); + set_env_var("TRAIL_MATE_HOSTLINK_PORT", "44192"); + set_env_var("TRAIL_MATE_GPS_SUPPORTED", "1"); + set_env_var("TRAIL_MATE_GPS_READY", "1"); + + settings_store::clear_namespace("settings"); + settings_store::clear_namespace("runtime_smoke"); + + settings_store::put_int("runtime_smoke", "answer", 42); + settings_store::put_bool("runtime_smoke", "enabled", true); + settings_store::put_uint("runtime_smoke", "timeout", 1234U); + const std::string greeting = "hello"; + assert(settings_store::put_string("runtime_smoke", "greeting", greeting.c_str())); + + const std::vector blob_in = {1U, 2U, 3U, 4U}; + assert(settings_store::put_blob("runtime_smoke", "blob", blob_in.data(), blob_in.size())); + + std::string greeting_out; + std::vector blob_out; + assert(settings_store::get_int("runtime_smoke", "answer", 0) == 42); + assert(settings_store::get_bool("runtime_smoke", "enabled", false)); + assert(settings_store::get_uint("runtime_smoke", "timeout", 0) == 1234U); + assert(settings_store::get_string("runtime_smoke", "greeting", greeting_out)); + assert(greeting_out == greeting); + assert(settings_store::get_blob("runtime_smoke", "blob", blob_out)); + assert(blob_out == blob_in); + + screen::set_timeout_ms(45000U); + assert(screen::clamp_timeout_ms(45000U) == 45000U); + assert(screen::timeout_ms() == 45000U); + assert(screen::timeout_secs() == 45U); + + screen::init({}); + screen::disable_sleep(); + assert(screen::is_sleep_disabled()); + screen::enable_sleep(); + assert(!screen::is_sleep_disabled()); + + time::set_timezone_offset_min(60); + const std::time_t shifted = time::apply_timezone_offset(3600); + assert(shifted == 7200); + + std::tm local_tm{}; + (void)time::localtime_now(&local_tm); + + const auto version = device::firmware_version(); + assert(version != nullptr); + assert(version[0] != '\0'); + assert(device::rtc_ready()); + assert(device::gps_supported()); + assert(device::gps_ready()); + device::delay_ms(1); + + assert(!firmware_update::is_supported()); + const auto firmware_status = firmware_update::status(); + assert(!firmware_status.supported); + assert(firmware_status.phase == firmware_update::Phase::Unsupported); + assert(!firmware_update::start_check()); + assert(!firmware_update::start_install()); + + assert(ui_hostlink::is_supported()); + ui_hostlink::start(); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + assert(ui_hostlink::is_active()); + const auto hostlink_status = ui_hostlink::get_status(); + assert(hostlink_status.state == ui_hostlink::LinkState::Waiting || + hostlink_status.state == ui_hostlink::LinkState::Error); + ui_hostlink::stop(); + assert(!ui_hostlink::is_active()); + assert(ui_hostlink::get_status().state == ui_hostlink::LinkState::Stopped); + + const auto heading = orientation::get_heading(); + assert(!heading.available); + assert(!heading.sensor_ready); + orientation::ensure_heading_runtime(); + + std::vector routes; + assert(route_storage::is_supported()); + assert(route_storage::list_routes(routes)); + assert(routes.empty()); + assert(!route_storage::remove_route("/routes/example.kml")); + const std::filesystem::path route_dir_path(route_storage::route_dir()); + assert(!route_dir_path.empty()); + std::filesystem::create_directories(route_dir_path, ec); + assert(std::filesystem::is_directory(route_dir_path)); + + assert(sstv::is_supported()); + assert(sstv::framebuffer() != nullptr); + assert(sstv::frame_width() > 0U); + assert(sstv::frame_height() > 0U); + assert(sstv::start()); + assert(sstv::is_active()); + const auto sstv_status = sstv::get_status(); + assert(sstv_status.state == sstv::State::Waiting || sstv_status.state == sstv::State::Receiving); + sstv::stop(); + assert(!sstv::is_active()); + + std::vector tracks; + std::string current_track; + assert(tracker::is_supported()); + assert(!tracker::is_recording()); + assert(tracker::start_recording()); + assert(tracker::is_recording()); + assert(tracker::current_path(current_track)); + assert(!current_track.empty()); + tracker::stop_recording(); + assert(!tracker::is_recording()); + assert(tracker::list_tracks(tracks)); + assert(!tracks.empty()); + assert(!tracker::remove_track("/tracks/example.gpx")); + const std::filesystem::path track_dir_path(tracker::track_dir()); + assert(!track_dir_path.empty()); + std::filesystem::create_directories(track_dir_path, ec); + assert(std::filesystem::is_directory(track_dir_path)); + tracker::set_auto_recording(true); + tracker::set_interval_seconds(5U); + tracker::set_distance_only(true); + tracker::set_format(tracker::Format::CSV); + tracker::stop_recording(); + + assert(!usb_support::is_supported()); + assert(!usb_support::start()); + const auto usb_status = usb_support::get_status(); + assert(!usb_status.active); + assert(usb_status.message != nullptr); + usb_support::prepare_mass_storage_mode(); + usb_support::restore_mass_storage_mode(); + usb_support::stop(); + + assert(walkie::is_supported()); + assert(walkie::start()); + assert(walkie::is_active()); + assert(walkie::volume() > 0); + const auto walkie_status = walkie::get_status(); + assert(walkie_status.active); + assert(walkie_status.freq_mhz > 0.0f); + assert(walkie::last_error() != nullptr); + walkie::stop(); + assert(!walkie::is_active()); + + wifi::Config wifi_config{}; + wifi_config.enabled = true; + std::snprintf(wifi_config.ssid, sizeof(wifi_config.ssid), "%s", "TrailMateNet"); + std::snprintf(wifi_config.password, sizeof(wifi_config.password), "%s", "test-password"); + assert(wifi::save_config(wifi_config)); + + wifi::Config wifi_loaded{}; + assert(wifi::load_config(wifi_loaded)); + assert(wifi_loaded.enabled); + assert(std::string(wifi_loaded.ssid) == "TrailMateNet"); + assert(std::string(wifi_loaded.password) == "test-password"); + assert(!wifi::is_supported()); + assert(!wifi::apply_enabled(true)); + assert(wifi::apply_enabled(false)); + assert(!wifi::connect(&wifi_loaded)); + wifi::disconnect(); + std::vector wifi_scan_results; + assert(!wifi::scan(wifi_scan_results)); + assert(wifi_scan_results.empty()); + const auto wifi_status = wifi::status(); + assert(!wifi_status.supported); + assert(!wifi_status.connected); + assert(wifi_status.has_credentials); + assert(wifi_status.state == wifi::ConnectionState::Unsupported); + + const auto gps_state = ui_gps::get_data(); + assert(gps_state.valid); + assert(gps_state.has_alt); + assert(gps_state.has_speed); + assert(gps_state.has_course); + std::vector sats(4U); + ui_gps::GnssStatus gnss_status{}; + std::size_t sat_count = sats.size(); + assert(ui_gps::get_gnss_snapshot(sats.data(), sats.size(), &sat_count, &gnss_status)); + assert(sat_count == sats.size()); + assert(gnss_status.fix == ui_gps::GnssFix::FIX3D); + assert(gnss_status.sats_in_use > 0U); + assert(ui_gps::last_motion_ms() > 0U); + assert(ui_gps::is_enabled()); + assert(ui_gps::is_powered()); + ui_gps::set_collection_interval(1000U); + ui_gps::set_power_strategy(1U); + ui_gps::set_gnss_config(1U, 0xFFU); + ui_gps::set_external_nmea_config(1U, 0x01U); + ui_gps::set_motion_idle_timeout(30000U); + ui_gps::set_motion_sensor_id(2U); + ui_gps::set_enabled(false); + assert(!ui_gps::is_enabled()); + ui_gps::set_enabled(true); + assert(ui_gps::is_enabled()); + ui_gps::suspend_runtime(); + assert(!ui_gps::is_enabled()); + assert(!ui_gps::is_powered()); + ui_gps::resume_runtime(); + assert(ui_gps::is_enabled()); + assert(ui_gps::is_powered()); + const double map_resolution = ui_gps::calculate_map_resolution(0, 0.0); + assert(std::fabs(map_resolution - 156543.03392) < 0.01); + + const std::filesystem::path nmea_file = runtime_root / "gps_feed.nmea"; + { + std::ofstream nmea_stream(nmea_file, std::ios::trunc); + nmea_stream << "$GNRMC,123519,A,3113.8240,N,12128.4220,E,1.20,84.4,230394,,,\n"; + nmea_stream << "$GNGGA,123520,3113.8240,N,12128.4220,E,1,10,0.9,15.2,M,0.0,M,,\n"; + nmea_stream << "$GNGSA,A,3,03,08,14,22,07,19,31,,,,,0.0,0.9,1.2\n"; + nmea_stream << "$GPGSV,1,1,4,03,74,018,42,08,58,084,39,14,41,156,35,22,49,286,31\n"; + } + set_env_var("TRAIL_MATE_GPS_NMEA_FILE", nmea_file.string()); + + const auto gps_nmea_state = ui_gps::get_data(); + assert(gps_nmea_state.valid); + assert(std::fabs(gps_nmea_state.lat - 31.2304) < 0.0002); + assert(std::fabs(gps_nmea_state.lng - 121.4737) < 0.0002); + assert(std::fabs(gps_nmea_state.alt_m - 15.2) < 0.01); + assert(gps_nmea_state.satellites == 10U); + + std::vector nmea_sats(8U); + ui_gps::GnssStatus nmea_status{}; + std::size_t nmea_sat_count = nmea_sats.size(); + assert(ui_gps::get_gnss_snapshot(nmea_sats.data(), nmea_sats.size(), &nmea_sat_count, &nmea_status)); + assert(nmea_status.fix == ui_gps::GnssFix::FIX3D); + assert(nmea_status.sats_in_use >= 7U); + assert(nmea_sat_count >= 4U); + + assert(lora::is_supported()); + assert(lora::acquire()); + assert(lora::is_online()); + lora::ReceiveConfig receive_config{}; + assert(lora::configure_receive(433.92f, receive_config)); + assert(std::isfinite(lora::read_instant_rssi())); + lora::release(); + assert(!lora::is_online()); + + MinimalLinuxAppFacade facade; + assert(facade.initialize()); + facade.dispatchPendingEvents(); + + const auto contacts = facade.getContactService().getContacts(); + const auto nearby = facade.getContactService().getNearby(); + const auto ignored = facade.getContactService().getIgnoredNodes(); + assert(contacts.size() >= 2U); + assert(!ignored.empty()); + assert((contacts.size() + nearby.size() + ignored.size()) >= 4U); + + std::size_t total_conversations = 0; + const auto conversations = facade.getChatService().getConversations(0, 8, &total_conversations); + assert(total_conversations >= 3U); + assert(!conversations.empty()); + + auto* pairing = facade.getTeamPairing(); + assert(pairing != nullptr); + assert(pairing->startMember(facade.getSelfNodeId())); + for (int i = 0; i < 16; ++i) + { + facade.tickEventRuntime(); + facade.dispatchPendingEvents(); + std::this_thread::sleep_for(std::chrono::milliseconds(40)); + } + + team::ui::TeamUiSnapshot team_snapshot{}; + assert(team::ui::team_ui_get_store().load(team_snapshot)); + assert(team_snapshot.in_team); + assert(team_snapshot.has_team_id); + assert(team_snapshot.has_team_psk); + assert(team_snapshot.members.size() >= 2U); + + team::proto::TeamTrackMessage track{}; + track.start_ts = static_cast(std::time(nullptr)); + track.interval_s = 10; + track.valid_mask = 0x3U; + track.points.push_back({311234567, 1214567890}); + track.points.push_back({311235000, 1214568200}); + assert(team::ui::team_ui_append_member_track(team_snapshot.team_id, 0x435A1001U, track)); + std::string member_track_path; + assert(team::ui::team_ui_get_member_track_path(team_snapshot.team_id, 0x435A1001U, member_track_path)); + assert(!member_track_path.empty()); + assert(std::filesystem::exists(member_track_path)); + facade.shutdown(); + + settings_store::clear_namespace("settings"); + settings_store::clear_namespace("runtime_smoke"); + std::filesystem::remove_all(runtime_root, ec); + return 0; +} diff --git a/apps/linux_sim/tests/path_safety_smoke.cpp b/apps/linux_sim/tests/path_safety_smoke.cpp new file mode 100644 index 00000000..10cb7740 --- /dev/null +++ b/apps/linux_sim/tests/path_safety_smoke.cpp @@ -0,0 +1,233 @@ +#include "platform/linux/capability_status.h" +#include "platform/linux/runtime_paths.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + +void set_env_var(const char* name, const std::string& value) +{ +#if defined(_WIN32) + _putenv_s(name, value.c_str()); +#else + setenv(name, value.c_str(), 1); +#endif +} + +void test_resolve_paths() +{ + namespace lnx = platform::linux_runtime; + + const auto paths = lnx::resolve_paths(); + assert(!paths.settings_root.empty()); + assert(!paths.sd_root.empty()); + assert(!paths.cache_root.empty()); + + // sd_root should be a child of or equal to settings_root, or an explicit + // override. We only check that both are non-empty paths. + assert(!paths.sd_root.string().empty()); + assert(!paths.settings_root.string().empty()); + + std::printf("[path-safety] resolve_paths OK\n"); +} + +void test_settings_file() +{ + namespace lnx = platform::linux_runtime; + + const auto path = lnx::settings_file("test_ns"); + assert(!path.empty()); + assert(path.extension() == ".kv"); + + std::printf("[path-safety] settings_file OK\n"); +} + +void test_child_under_root_allow() +{ + namespace lnx = platform::linux_runtime; + + const std::filesystem::path temp = + std::filesystem::temp_directory_path() / "trailmate_path_test"; + std::error_code ec; + std::filesystem::remove_all(temp, ec); + std::filesystem::create_directories(temp / "sub", ec); + assert(!ec); + + // Write a marker file so resolution succeeds. + { + std::ofstream f(temp / "sub" / "ok.txt"); + f << "ok\n"; + } + + std::filesystem::path out; + assert(lnx::resolve_child_under_root(temp, "sub/ok.txt", out)); + assert(out.filename() == "ok.txt"); + + std::filesystem::remove_all(temp, ec); + std::printf("[path-safety] child_under_root_allow OK\n"); +} + +void test_child_under_root_reject_absolute() +{ + namespace lnx = platform::linux_runtime; + + const auto temp = std::filesystem::temp_directory_path() / "trailmate_path_test2"; + std::error_code ec; + std::filesystem::create_directories(temp, ec); + + std::filesystem::path out; + assert(!lnx::resolve_child_under_root(temp, "/etc/passwd", out)); + assert(out.empty()); + + std::filesystem::remove_all(temp, ec); + std::printf("[path-safety] child_under_root_reject_absolute OK\n"); +} + +void test_child_under_root_reject_dotdot() +{ + namespace lnx = platform::linux_runtime; + + const auto temp = std::filesystem::temp_directory_path() / "trailmate_path_test3"; + std::error_code ec; + std::filesystem::remove_all(temp, ec); + std::filesystem::create_directories(temp / "a" / "b", ec); + + std::filesystem::path out; + assert(!lnx::resolve_child_under_root(temp, "a/../secret", out)); + assert(out.empty()); + + // Also reject ".." as the sole component. + assert(!lnx::resolve_child_under_root(temp, "..", out)); + assert(out.empty()); + + std::filesystem::remove_all(temp, ec); + std::printf("[path-safety] child_under_root_reject_dotdot OK\n"); +} + +void test_child_under_root_reject_empty() +{ + namespace lnx = platform::linux_runtime; + + const auto temp = std::filesystem::temp_directory_path(); + std::filesystem::path out; + assert(!lnx::resolve_child_under_root(temp, "", out)); + assert(out.empty()); + + std::printf("[path-safety] child_under_root_reject_empty OK\n"); +} + +void test_safe_remove() +{ + namespace lnx = platform::linux_runtime; + + const auto temp = std::filesystem::temp_directory_path() / "trailmate_path_test_rm"; + std::error_code ec; + std::filesystem::remove_all(temp, ec); + std::filesystem::create_directories(temp / "sub", ec); + + // Create a file. + { + std::ofstream f(temp / "sub" / "victim.txt"); + f << "data\n"; + } + assert(std::filesystem::exists(temp / "sub" / "victim.txt")); + + // Safe remove. + assert(lnx::safe_remove_under_root(temp, "sub/victim.txt")); + assert(!std::filesystem::exists(temp / "sub" / "victim.txt")); + + // Removing a non-existent file should return true (already gone). + assert(lnx::safe_remove_under_root(temp, "sub/victim.txt")); + + // Reject dotdot removal. + assert(!lnx::safe_remove_under_root(temp, "../secret")); + + std::filesystem::remove_all(temp, ec); + std::printf("[path-safety] safe_remove OK\n"); +} + +void test_safe_write() +{ + namespace lnx = platform::linux_runtime; + + const auto temp = std::filesystem::temp_directory_path() / "trailmate_path_test_write"; + std::error_code ec; + std::filesystem::remove_all(temp, ec); + + assert(lnx::safe_write_under_root(temp, "data/config.txt", "hello world")); + assert(std::filesystem::exists(temp / "data" / "config.txt")); + + // Read back. + std::ifstream in(temp / "data" / "config.txt"); + std::string content; + std::getline(in, content, '\0'); + assert(content == "hello world"); + + // Reject traversal. + assert(!lnx::safe_write_under_root(temp, "../escape.txt", "bad")); + + std::filesystem::remove_all(temp, ec); + std::printf("[path-safety] safe_write OK\n"); +} + +void test_resolve_paths_with_env_override() +{ + namespace lnx = platform::linux_runtime; + + const std::string override_root = + (std::filesystem::temp_directory_path() / "trailmate_override_test").string(); + + set_env_var("TRAIL_MATE_SETTINGS_ROOT", override_root); + set_env_var("TRAIL_MATE_SD_ROOT", override_root + "/my_sd"); + set_env_var("TRAIL_MATE_CACHE_ROOT", override_root + "/my_cache"); + + const auto paths = lnx::resolve_paths(); + assert(paths.settings_root.string() == override_root); + assert(paths.sd_root.string() == override_root + "/my_sd"); + assert(paths.cache_root.string() == override_root + "/my_cache"); + + set_env_var("TRAIL_MATE_SETTINGS_ROOT", ""); + set_env_var("TRAIL_MATE_SD_ROOT", ""); + set_env_var("TRAIL_MATE_CACHE_ROOT", ""); + + std::printf("[path-safety] resolve_paths_with_env_override OK\n"); +} + +void test_capability_state_labels() +{ + namespace lnx = platform::linux_runtime; + + // Verify every state has a non-null label. + assert(lnx::capability_state_label(lnx::CapabilityState::Unsupported) != nullptr); + assert(lnx::capability_state_label(lnx::CapabilityState::Simulated) != nullptr); + assert(lnx::capability_state_label(lnx::CapabilityState::Available) != nullptr); + assert(lnx::capability_state_label(lnx::CapabilityState::Degraded) != nullptr); + assert(lnx::capability_state_label(lnx::CapabilityState::Error) != nullptr); + + std::printf("[path-safety] capability_state_labels OK\n"); +} + +} // namespace + +int main() +{ + test_resolve_paths(); + test_settings_file(); + test_child_under_root_allow(); + test_child_under_root_reject_absolute(); + test_child_under_root_reject_dotdot(); + test_child_under_root_reject_empty(); + test_safe_remove(); + test_safe_write(); + test_resolve_paths_with_env_override(); + test_capability_state_labels(); + + std::printf("[path-safety] All tests passed.\n"); + return 0; +} diff --git a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h index abaef507..60b4a2c7 100644 --- a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h +++ b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h @@ -145,9 +145,10 @@ class Gat562Board final : public BoardBase std::size_t* out_count, ::gps::GnssStatus* status) const; void setGpsCollectionInterval(uint32_t interval_ms); + void setGpsEnabled(bool enabled); void setGpsPowerStrategy(uint8_t strategy); void setGpsConfig(uint8_t mode, uint8_t sat_mask); - void setGpsNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); + void setGpsExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); void setGpsMotionIdleTimeout(uint32_t timeout_ms); void setGpsMotionSensorId(uint8_t sensor_id); void suspendGps(); diff --git a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gps_runtime.h b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gps_runtime.h index ec0e5f78..0a36150d 100644 --- a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gps_runtime.h +++ b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gps_runtime.h @@ -32,9 +32,10 @@ class GpsRuntime ::gps::GnssStatus* status) const; void setCollectionInterval(uint32_t interval_ms); + void setEnabled(bool enabled); void setPowerStrategy(uint8_t strategy); void setConfig(uint8_t mode, uint8_t sat_mask); - void setNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); + void setExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); void setMotionIdleTimeout(uint32_t timeout_ms); void setMotionSensorId(uint8_t sensor_id); void suspend(); diff --git a/boards/gat562_mesh_evb_pro/src/gat562_board.cpp b/boards/gat562_mesh_evb_pro/src/gat562_board.cpp index 717fb05f..4a10bc88 100644 --- a/boards/gat562_mesh_evb_pro/src/gat562_board.cpp +++ b/boards/gat562_mesh_evb_pro/src/gat562_board.cpp @@ -641,6 +641,13 @@ void Gat562Board::setGpsCollectionInterval(uint32_t interval_ms) gps_runtime_->setCollectionInterval(interval_ms); } } +void Gat562Board::setGpsEnabled(bool enabled) +{ + if (gps_runtime_) + { + gps_runtime_->setEnabled(enabled); + } +} void Gat562Board::setGpsPowerStrategy(uint8_t strategy) { if (gps_runtime_) @@ -655,11 +662,11 @@ void Gat562Board::setGpsConfig(uint8_t mode, uint8_t sat_mask) gps_runtime_->setConfig(mode, sat_mask); } } -void Gat562Board::setGpsNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) +void Gat562Board::setGpsExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) { if (gps_runtime_) { - gps_runtime_->setNmeaConfig(output_hz, sentence_mask); + gps_runtime_->setExternalNmeaConfig(output_hz, sentence_mask); } } void Gat562Board::setGpsMotionIdleTimeout(uint32_t timeout_ms) diff --git a/boards/gat562_mesh_evb_pro/src/gps_runtime.cpp b/boards/gat562_mesh_evb_pro/src/gps_runtime.cpp index ed78c9ed..9ddbc978 100644 --- a/boards/gat562_mesh_evb_pro/src/gps_runtime.cpp +++ b/boards/gat562_mesh_evb_pro/src/gps_runtime.cpp @@ -347,8 +347,8 @@ struct GpsRuntime::Impl uint8_t power_strategy = 0; uint8_t gnss_mode = 0; uint8_t sat_mask = 0; - uint8_t nmea_output_hz = 0; - uint8_t nmea_sentence_mask = 0; + uint8_t external_nmea_output_hz = 0; + uint8_t external_nmea_sentence_mask = 0; uint8_t motion_sensor_id = 0; uint32_t epoch_base_s = 0; uint32_t epoch_base_ms = 0; @@ -364,6 +364,7 @@ struct GpsRuntime::Impl std::size_t sat_count = 0; std::size_t used_sat_count = 0; std::size_t nmea_line_len = 0; + bool user_enabled = true; bool enabled = true; bool powered = false; bool initialized = false; @@ -1002,13 +1003,17 @@ bool GpsRuntime::start(const app::AppConfig& config) bool GpsRuntime::begin(const app::AppConfig& config) { - (void)config; auto& s = *impl(); + s.user_enabled = config.gps_enabled; + s.enabled = s.user_enabled; if (!s.initialized) { - beginGpsSerial(); s.initialized = true; - s.powered = true; + if (s.enabled) + { + beginGpsSerial(); + s.powered = true; + } s.logMemoryLayout("begin"); } return true; @@ -1021,11 +1026,12 @@ void GpsRuntime::applyConfig(const app::AppConfig& config) s.power_strategy = config.gps_strategy; s.gnss_mode = config.gps_mode; s.sat_mask = config.gps_sat_mask; - s.nmea_output_hz = config.privacy_nmea_output; - s.nmea_sentence_mask = config.privacy_nmea_sentence; + s.external_nmea_output_hz = config.external_nmea_output_hz; + s.external_nmea_sentence_mask = config.external_nmea_sentence_mask; s.motion_idle_timeout_ms = config.motion_config.idle_timeout_ms; s.motion_sensor_id = config.motion_config.sensor_id; - s.enabled = (config.gps_mode != 0); + s.user_enabled = config.gps_enabled; + s.enabled = s.user_enabled; if (!s.enabled) { if (s.powered) @@ -1041,14 +1047,14 @@ void GpsRuntime::applyConfig(const app::AppConfig& config) s.powered = true; } Serial.printf( - "[gat562][gps] config enabled=%u interval_ms=%lu strategy=%u mode=%u sat_mask=0x%02X nmea_hz=%u nmea_mask=0x%02X motion_idle_ms=%lu motion_sensor=%u\n", + "[gat562][gps] config enabled=%u interval_ms=%lu strategy=%u mode=%u sat_mask=0x%02X external_nmea_hz=%u external_nmea_mask=0x%02X motion_idle_ms=%lu motion_sensor=%u\n", static_cast(s.enabled ? 1 : 0), static_cast(s.collection_interval_ms), static_cast(s.power_strategy), static_cast(s.gnss_mode), static_cast(s.sat_mask), - static_cast(s.nmea_output_hz), - static_cast(s.nmea_sentence_mask), + static_cast(s.external_nmea_output_hz), + static_cast(s.external_nmea_sentence_mask), static_cast(s.motion_idle_timeout_ms), static_cast(s.motion_sensor_id)); } @@ -1165,22 +1171,39 @@ bool GpsRuntime::gnssSnapshot(::gps::GnssSatInfo* out, void GpsRuntime::setCollectionInterval(uint32_t interval_ms) { impl()->collection_interval_ms = interval_ms; } void GpsRuntime::setPowerStrategy(uint8_t strategy) { impl()->power_strategy = strategy; } +void GpsRuntime::setEnabled(bool enabled) +{ + auto& s = *impl(); + s.user_enabled = enabled; + s.enabled = enabled; + if (!s.enabled) + { + if (s.powered) + { + endGpsSerial(); + s.powered = false; + } + s.clearObservations(); + return; + } + if (s.initialized && !s.powered) + { + beginGpsSerial(); + s.powered = true; + } +} + void GpsRuntime::setConfig(uint8_t mode, uint8_t sat_mask) { auto& s = *impl(); s.gnss_mode = mode; s.sat_mask = sat_mask; - s.enabled = (mode != 0); - if (!s.enabled) - { - s.clearObservations(); - } } -void GpsRuntime::setNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) +void GpsRuntime::setExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) { - impl()->nmea_output_hz = output_hz; - impl()->nmea_sentence_mask = sentence_mask; + impl()->external_nmea_output_hz = output_hz; + impl()->external_nmea_sentence_mask = sentence_mask; } void GpsRuntime::setMotionIdleTimeout(uint32_t timeout_ms) { impl()->motion_idle_timeout_ms = timeout_ms; } @@ -1201,7 +1224,7 @@ void GpsRuntime::suspend() void GpsRuntime::resume() { auto& s = *impl(); - s.enabled = (s.gnss_mode != 0); + s.enabled = s.user_enabled; if (!s.enabled) { s.clearObservations(); diff --git a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp index d55aac98..941a3c3b 100644 --- a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp +++ b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp @@ -301,6 +301,11 @@ bool is_powered() return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().gpsPowered(); } +void set_enabled(bool enabled) +{ + ::boards::gat562_mesh_evb_pro::Gat562Board::instance().setGpsEnabled(enabled); +} + void set_collection_interval(uint32_t interval_ms) { ::boards::gat562_mesh_evb_pro::Gat562Board::instance().setGpsCollectionInterval(interval_ms); @@ -316,9 +321,9 @@ void set_gnss_config(uint8_t mode, uint8_t sat_mask) ::boards::gat562_mesh_evb_pro::Gat562Board::instance().setGpsConfig(mode, sat_mask); } -void set_nmea_config(uint8_t output_hz, uint8_t sentence_mask) +void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) { - ::boards::gat562_mesh_evb_pro::Gat562Board::instance().setGpsNmeaConfig(output_hz, sentence_mask); + ::boards::gat562_mesh_evb_pro::Gat562Board::instance().setGpsExternalNmeaConfig(output_hz, sentence_mask); } void set_motion_idle_timeout(uint32_t timeout_ms) diff --git a/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h b/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h index e1902af7..340fe9fe 100644 --- a/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h +++ b/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h @@ -1,5 +1,27 @@ #pragma once +/** + * I2C lock ordering on T-Display P4 (MUST be respected by all callers): + * + * The system I2C bus is shared by the XL9535 expander (touch reset, GPIO, + * power control), the touch controller (Hi8561 / GT9895), RTC, and + * potentially GPS/LoRa prepare paths. + * + * Lock order: + * 1. resource_mutex_ (board device registry) + * 2. system_i2c_mutex_ (bus-level mutual exclusion) + * + * Rules: + * - Touch read callbacks may only take system_i2c_mutex_; they must NOT + * touch resource_mutex_ (no device creation / reset in the hot path). + * - Device creation (getManagedSystemI2cDevice) may take both, in order. + * - Never hold system_i2c_mutex_ while waiting on resource_mutex_. + * - High-frequency touch reads use a 50 ms lock timeout (raised from the + * original 5 ms to avoid spurious failures under bus contention). + * + * See also: platform/esp/idf_components/t_display_p4/trail_mate_t_display_p4_runtime.cpp + */ + #include #include #include diff --git a/boards/tab5/include/boards/tab5/platform_esp_board_runtime.h b/boards/tab5/include/boards/tab5/platform_esp_board_runtime.h index 49ca4ef5..1d258a44 100644 --- a/boards/tab5/include/boards/tab5/platform_esp_board_runtime.h +++ b/boards/tab5/include/boards/tab5/platform_esp_board_runtime.h @@ -14,8 +14,6 @@ extern "C" { #include "bsp/trail_mate_tab5_runtime.h" - - void bsp_set_ext_5v_en(bool en); } namespace platform::esp::boards::detail @@ -84,9 +82,18 @@ inline void initializeDisplay() if (trail_mate_tab5_display_runtime_init()) { ESP_LOGI(kTag, "Tab5 display runtime initialized"); - bsp_set_ext_5v_en(true); - vTaskDelay(pdMS_TO_TICKS(300)); - ESP_LOGI(kTag, "Tab5 M5-Bus Ext5V enabled for GNSS/LoRa modules"); + // Use a permanent board lease instead of the direct BSP setter. + // This keeps the Ext5V rail owned by the board's lease model so + // that GPS/LoRa/heading runtimes can later acquire their own + // named leases without racing against an un-owned BSP call. + if (::boards::tab5::Tab5Board::instance().acquireExt5vRail("idf-startup")) + { + ESP_LOGI(kTag, "Tab5 M5-Bus Ext5V lease acquired for startup"); + } + else + { + ESP_LOGW(kTag, "Tab5 M5-Bus Ext5V lease unavailable; rail may already be on"); + } } else { @@ -112,12 +119,12 @@ inline AppContextInitHandles resolveAppContextInitHandles() inline bool lockDisplay(uint32_t timeout_ms) { - return bsp_display_lock(timeout_ms); + return trail_mate_tab5_display_lock(timeout_ms); } inline void unlockDisplay() { - bsp_display_unlock(); + trail_mate_tab5_display_unlock(); } inline bool syncSystemTimeFromBoardRtc() diff --git a/boards/tdeck/include/boards/tdeck/tdeck_board.h b/boards/tdeck/include/boards/tdeck/tdeck_board.h index e886d19c..d293601c 100644 --- a/boards/tdeck/include/boards/tdeck/tdeck_board.h +++ b/boards/tdeck/include/boards/tdeck/tdeck_board.h @@ -97,6 +97,7 @@ class TDeckBoard : public BoardBase, int readRadioData(uint8_t* buf, size_t len) override; void clearRadioIrqFlags(uint32_t flags) override; float getRadioRSSI() override; + float getRadioInstantRSSI() override; float getRadioSNR() override; void configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, @@ -104,6 +105,7 @@ class TDeckBoard : public BoardBase, // GpsBoard bool initGPS() override; + void deinitGPS() override; void setGPSOnline(bool online) override { if (online) diff --git a/boards/tdeck/src/tdeck_board.cpp b/boards/tdeck/src/tdeck_board.cpp index 791b2885..9fbbdc97 100644 --- a/boards/tdeck/src/tdeck_board.cpp +++ b/boards/tdeck/src/tdeck_board.cpp @@ -25,6 +25,81 @@ constexpr uint8_t kTDeckKeyboardModeKeyCmd = 0x04; constexpr uint32_t kTDeckKeyboardBootDelayMs = 500; constexpr float kTDeckRadioTcxoVoltage = 1.8f; constexpr float kTDeckRadioCurrentLimitMa = 140.0f; +constexpr uint8_t kTDeckBacklightStepMax = 16; +constexpr uint32_t kTDeckBacklightOffDelayMs = 3; +constexpr uint32_t kTDeckBacklightWakeDelayUs = 30; +uint8_t s_backlight_level = 0; + +#if DEVICE_MAX_BRIGHTNESS_LEVEL > 0 +constexpr uint32_t kBacklightLevelMax = DEVICE_MAX_BRIGHTNESS_LEVEL; +#else +constexpr uint32_t kBacklightLevelMax = 1; +#endif + +uint8_t clamp_brightness_level(uint8_t level) +{ + return level > DEVICE_MAX_BRIGHTNESS_LEVEL ? DEVICE_MAX_BRIGHTNESS_LEVEL : level; +} + +uint8_t brightness_level_to_backlight_step(uint8_t level) +{ + const uint8_t clamped = clamp_brightness_level(level); + if (clamped == 0) + { + return 0; + } + + const uint32_t scaled = + (static_cast(clamped) * kTDeckBacklightStepMax + (kBacklightLevelMax / 2U)) / + kBacklightLevelMax; + const uint8_t step = static_cast(scaled); + if (step == 0) + { + return 1; + } + return step > kTDeckBacklightStepMax ? kTDeckBacklightStepMax : step; +} + +void write_backlight_level(uint8_t level) +{ +#if defined(DISP_BL) + if (DISP_BL < 0) + { + return; + } + + const uint8_t target = brightness_level_to_backlight_step(level); + pinMode(DISP_BL, OUTPUT); + + // T-Deck uses the vendor backlight chip's pulse protocol rather than a raw PWM duty signal. + if (target == 0) + { + digitalWrite(DISP_BL, LOW); + delay(kTDeckBacklightOffDelayMs); + s_backlight_level = 0; + return; + } + + if (s_backlight_level == 0) + { + digitalWrite(DISP_BL, HIGH); + s_backlight_level = kTDeckBacklightStepMax; + delayMicroseconds(kTDeckBacklightWakeDelayUs); + } + + const int from = kTDeckBacklightStepMax - s_backlight_level; + const int to = kTDeckBacklightStepMax - target; + const int pulses = (kTDeckBacklightStepMax + to - from) % kTDeckBacklightStepMax; + for (int i = 0; i < pulses; ++i) + { + digitalWrite(DISP_BL, LOW); + digitalWrite(DISP_BL, HIGH); + } + s_backlight_level = target; +#else + (void)level; +#endif +} // Civil date to UNIX epoch days (UTC), based on Howard Hinnant's algorithm. int64_t days_from_civil(int year, unsigned month, unsigned day) @@ -150,14 +225,23 @@ TDeckBoard* TDeckBoard::getInstance() bool TDeckBoard::initGPS() { - // T-Deck examples wire GPS to UART on pins 43/44. + // Board init owns the physical UART. Receiver probing/configuration is runtime-owned. + Serial1.end(); Serial1.begin(38400, SERIAL_8N1, GPS_RX, GPS_TX); - delay(50); + delay(100); + gps_.attach(&Serial1); + setGPSOnline(true); + Serial.printf("[TDeckBoard] GPS UART ready baud=%lu rx=%d tx=%d\n", + 38400UL, + GPS_RX, + GPS_TX); + return true; +} - bool ok = gps_.init(&Serial1); - setGPSOnline(ok); - Serial.printf("[TDeckBoard] GPS init: %s\n", ok ? "OK" : "FAIL"); - return ok; +void TDeckBoard::deinitGPS() +{ + Serial1.end(); + setGPSOnline(false); } uint32_t TDeckBoard::begin(uint32_t disable_hw_init) @@ -218,15 +302,9 @@ uint32_t TDeckBoard::begin(uint32_t disable_hw_init) if (DISP_BL >= 0) { pinMode(DISP_BL, OUTPUT); - // Blink backlight briefly as a visual heartbeat. digitalWrite(DISP_BL, LOW); - delay(60); - digitalWrite(DISP_BL, HIGH); - delay(60); - digitalWrite(DISP_BL, LOW); - delay(60); - digitalWrite(DISP_BL, HIGH); - Serial.println("[TDeckBoard] backlight blinked"); + s_backlight_level = 0; + Serial.println("[TDeckBoard] backlight held off during display init"); } #endif @@ -255,6 +333,7 @@ uint32_t TDeckBoard::begin(uint32_t disable_hw_init) LilyGoDispArduinoSPI::setRotation(1); rotation_ = LilyGoDispArduinoSPI::getRotation(); display_ready_ = true; + setBrightness(brightness_); Serial.printf("[TDeckBoard] display init OK: %ux%u\n", LilyGoDispArduinoSPI::_width, LilyGoDispArduinoSPI::_height); #else Serial.println("[TDeckBoard] display init skipped: missing DISP_* pins"); @@ -635,18 +714,18 @@ int TDeckBoard::getBatteryLevel() void TDeckBoard::setBrightness(uint8_t level) { - brightness_ = level; + const uint8_t clamped_level = clamp_brightness_level(level); + brightness_ = clamped_level; if (!display_ready_) { return; } - LilyGoDispArduinoSPI::setBrightness(level); + LilyGoDispArduinoSPI::setBrightness(clamped_level); #if defined(DISP_BL) if (DISP_BL >= 0) { - pinMode(DISP_BL, OUTPUT); - digitalWrite(DISP_BL, (level > 0) ? HIGH : LOW); + write_backlight_level(clamped_level); } #endif } @@ -953,6 +1032,17 @@ float TDeckBoard::getRadioRSSI() return std::numeric_limits::quiet_NaN(); } +float TDeckBoard::getRadioInstantRSSI() +{ + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) + { + const float rssi = radio_.getRSSI(false); + LilyGoDispArduinoSPI::unlock(); + return rssi; + } + return std::numeric_limits::quiet_NaN(); +} + float TDeckBoard::getRadioSNR() { if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) diff --git a/boards/tdeck_pro/include/boards/tdeck_pro/tdeck_pro_board.h b/boards/tdeck_pro/include/boards/tdeck_pro/tdeck_pro_board.h index 7faced42..3a838398 100644 --- a/boards/tdeck_pro/include/boards/tdeck_pro/tdeck_pro_board.h +++ b/boards/tdeck_pro/include/boards/tdeck_pro/tdeck_pro_board.h @@ -93,12 +93,14 @@ class TDeckProBoard : public BoardBase, int readRadioData(uint8_t* buf, size_t len) override; void clearRadioIrqFlags(uint32_t flags) override; float getRadioRSSI() override; + float getRadioInstantRSSI() override; float getRadioSNR() override; void configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, uint8_t crc_len) override; bool initGPS() override; + void deinitGPS() override; void setGPSOnline(bool online) override { gps_ready_ = online; } GPS& getGPS() override { return gps_; } void powerControl(PowerCtrlChannel_t ch, bool enable) override; diff --git a/boards/tdeck_pro/src/tdeck_pro_board.cpp b/boards/tdeck_pro/src/tdeck_pro_board.cpp index b2494b9c..ac464325 100644 --- a/boards/tdeck_pro/src/tdeck_pro_board.cpp +++ b/boards/tdeck_pro/src/tdeck_pro_board.cpp @@ -735,6 +735,15 @@ float TDeckProBoard::getRadioRSSI() return rssi; } +float TDeckProBoard::getRadioInstantRSSI() +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + const float rssi = radio_.getRSSI(false); + sharedSpiUnlock(); + return rssi; +} + float TDeckProBoard::getRadioSNR() { sharedSpiLock(); @@ -763,13 +772,25 @@ void TDeckProBoard::configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, bool TDeckProBoard::initGPS() { + Serial2.end(); Serial2.begin(profile().gps.uart.baud, SERIAL_8N1, profile().gps.uart.rx, profile().gps.uart.tx); - delay(50); - gps_ready_ = gps_.init(&Serial2); - Serial.printf("[%s] gps init %s\n", kTag, gps_ready_ ? "ok" : "fail"); + delay(100); + gps_.attach(&Serial2); + gps_ready_ = true; + Serial.printf("[%s] gps uart ready baud=%lu rx=%d tx=%d\n", + kTag, + static_cast(profile().gps.uart.baud), + profile().gps.uart.rx, + profile().gps.uart.tx); return gps_ready_; } +void TDeckProBoard::deinitGPS() +{ + Serial2.end(); + gps_ready_ = false; +} + void TDeckProBoard::powerControl(PowerCtrlChannel_t ch, bool enable) { switch (ch) diff --git a/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h b/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h index b51c8690..361b074d 100644 --- a/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h +++ b/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h @@ -116,6 +116,7 @@ class TLoRaPagerBoard : public BoardBase, // Hardware initialization functions bool initPMU(); bool initGPS() override; + void deinitGPS() override; bool initLoRa(); bool initNFC(); bool initKeyboard(); @@ -318,6 +319,7 @@ class TLoRaPagerBoard : public BoardBase, int readRadioData(uint8_t* buf, size_t len) override; void clearRadioIrqFlags(uint32_t flags) override; float getRadioRSSI() override; + float getRadioInstantRSSI() override; float getRadioSNR() override; void configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, diff --git a/boards/tlora_pager/src/tlora_pager_board.cpp b/boards/tlora_pager/src/tlora_pager_board.cpp index 449a97eb..0294a5de 100644 --- a/boards/tlora_pager/src/tlora_pager_board.cpp +++ b/boards/tlora_pager/src/tlora_pager_board.cpp @@ -1202,24 +1202,19 @@ bool TLoRaPagerBoard::initGPS() // This ensures we don't have stale state if reinitializing devices_probe &= ~HW_GPS_ONLINE; + Serial1.end(); Serial1.begin(38400, SERIAL_8N1, GPS_RX, GPS_TX); delay(100); // Give Serial1 time to initialize - GPS_BOARD_LOG("[TLoRaPagerBoard::initGPS] Serial1 opened, calling gps.init(&Serial1)...\n"); - bool result = gps.init(&Serial1); - GPS_BOARD_LOG("[TLoRaPagerBoard::initGPS] gps.init() returned: %d\n", result); - if (result) - { - GPS_BOARD_LOG("[TLoRaPagerBoard::initGPS] GPS initialized successfully, model: %s\n", gps.getModel().c_str()); - devices_probe |= HW_GPS_ONLINE; - GPS_BOARD_LOG("[TLoRaPagerBoard::initGPS] Set HW_GPS_ONLINE flag, devices_probe=0x%08X\n", devices_probe); - } - else - { - GPS_BOARD_LOG("[TLoRaPagerBoard::initGPS] GPS initialization FAILED\n"); - // Ensure flag is cleared on failure - devices_probe &= ~HW_GPS_ONLINE; - } - return result; + gps.attach(&Serial1); + devices_probe |= HW_GPS_ONLINE; + GPS_BOARD_LOG("[TLoRaPagerBoard::initGPS] GPS UART ready, devices_probe=0x%08X\n", devices_probe); + return true; +} + +void TLoRaPagerBoard::deinitGPS() +{ + Serial1.end(); + devices_probe &= ~HW_GPS_ONLINE; } void TLoRaPagerBoard::setBrightness(uint8_t level) @@ -1367,6 +1362,17 @@ float TLoRaPagerBoard::getRadioRSSI() return std::numeric_limits::quiet_NaN(); } +float TLoRaPagerBoard::getRadioInstantRSSI() +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) + { + const float rssi = radio_.getRSSI(false); + LilyGoDispArduinoSPI::unlock(); + return rssi; + } + return std::numeric_limits::quiet_NaN(); +} + float TLoRaPagerBoard::getRadioSNR() { if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) diff --git a/boards/twatchs3/include/boards/twatchs3/twatchs3_board.h b/boards/twatchs3/include/boards/twatchs3/twatchs3_board.h index 5bcbe544..26cab861 100644 --- a/boards/twatchs3/include/boards/twatchs3/twatchs3_board.h +++ b/boards/twatchs3/include/boards/twatchs3/twatchs3_board.h @@ -75,6 +75,7 @@ class TWatchS3Board : public BoardBase, int readRadioData(uint8_t* buf, size_t len) override; void clearRadioIrqFlags(uint32_t flags) override; float getRadioRSSI() override; + float getRadioInstantRSSI() override; float getRadioSNR() override; void configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, diff --git a/boards/twatchs3/src/twatchs3_board.cpp b/boards/twatchs3/src/twatchs3_board.cpp index 5775d974..607cba2e 100644 --- a/boards/twatchs3/src/twatchs3_board.cpp +++ b/boards/twatchs3/src/twatchs3_board.cpp @@ -101,6 +101,7 @@ uint32_t TWatchS3Board::begin(uint32_t disable_hw_init) LilyGoDispArduinoSPI::setRotation(2); rotation_ = LilyGoDispArduinoSPI::getRotation(); display_ready_ = true; + setBrightness(brightness_); Serial.printf("[TWatchS3Board] display init OK: %ux%u\n", LilyGoDispArduinoSPI::_width, LilyGoDispArduinoSPI::_height); #else Serial.println("[TWatchS3Board] display init skipped: missing DISP_* pins"); @@ -387,6 +388,11 @@ float TWatchS3Board::getRadioRSSI() return radio_.getRSSI(); } +float TWatchS3Board::getRadioInstantRSSI() +{ + return radio_.getRSSI(false); +} + float TWatchS3Board::getRadioSNR() { return radio_.getSNR(); diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake new file mode 100644 index 00000000..acab2fbb --- /dev/null +++ b/cmake/TrailMateLinuxSources.cmake @@ -0,0 +1,354 @@ +# TrailMateLinuxSources.cmake +# +# Shared source lists and helper functions for the Cardputer Zero Linux line. +# Both apps/linux_sim and apps/linux_rpi include this file so that a single +# source list drives the common, core-module, and ui_shell targets. +# +# Usage from an app CMakeLists.txt: +# +# include("${PROJECT_SOURCE_DIR}/../../cmake/TrailMateLinuxSources.cmake") +# +# trailmate_add_linux_common(trailmate_cardputer_zero_linux_common) +# trailmate_add_linux_ui_shell(trailmate_cardputer_zero_ui_shell +# trailmate_cardputer_zero_linux_common) +# +# Then add the app-specific executable (simulator or device) and link against +# these two targets plus any app-specific dependencies. +# +# --------------------------------------------------------------------------- +# Path roots – derived from this file's location (cmake/), which gives +# the repo root via "..". Independent of the including app's directory. +# --------------------------------------------------------------------------- + +function(_trailmate_set_linux_paths) + if(NOT DEFINED TRAIL_MATE_REPO_ROOT) + get_filename_component(TRAIL_MATE_REPO_ROOT + "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) + set(TRAIL_MATE_REPO_ROOT "${TRAIL_MATE_REPO_ROOT}" PARENT_SCOPE) + endif() + + set(TRAIL_MATE_LINUX_COMMON_INCLUDE_ROOT + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include" + PARENT_SCOPE) + set(TRAIL_MATE_LINUX_COMMON_SRC_ROOT + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src" + PARENT_SCOPE) + set(TRAIL_MATE_LINUX_RPI_SRC_ROOT + "${TRAIL_MATE_REPO_ROOT}/platform/linux/rpi/src" + PARENT_SCOPE) + + set(TRAIL_MATE_CORE_CHAT_INCLUDE_ROOT + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" + PARENT_SCOPE) + set(TRAIL_MATE_CORE_CHAT_GENERATED_ROOT + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated" + PARENT_SCOPE) + set(TRAIL_MATE_CORE_CHAT_NANOPB_ROOT + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb" + PARENT_SCOPE) + set(TRAIL_MATE_CORE_TEAM_INCLUDE_ROOT + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + PARENT_SCOPE) + set(TRAIL_MATE_CORE_GPS_INCLUDE_ROOT + "${TRAIL_MATE_REPO_ROOT}/modules/core_gps/include" + PARENT_SCOPE) + set(TRAIL_MATE_CORE_HOSTLINK_INCLUDE_ROOT + "${TRAIL_MATE_REPO_ROOT}/modules/core_hostlink/include" + PARENT_SCOPE) + set(TRAIL_MATE_CORE_SYS_INCLUDE_ROOT + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + PARENT_SCOPE) + set(TRAIL_MATE_UI_SHARED_INCLUDE_ROOT + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + PARENT_SCOPE) + set(TRAIL_MATE_UI_SHARED_SRC_ROOT + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src" + PARENT_SCOPE) + set(TRAIL_MATE_PLATFORM_SHARED_INCLUDE_ROOT + "${TRAIL_MATE_REPO_ROOT}/platform/shared/include" + PARENT_SCOPE) +endfunction() + +_trailmate_set_linux_paths() + +# --------------------------------------------------------------------------- +# Source lists +# --------------------------------------------------------------------------- + +set(TRAIL_MATE_LINUX_COMMON_SOURCES + # app facade + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/linux_app_facade.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/linux_demo_world.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/chat/linux_noop_mesh_adapter.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/demo_app.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/demo_app_runner.cpp" + # core primitives + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/core/bitmap_font.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/core/canvas.cpp" + # platform/linux shared infrastructure (P4) + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/runtime_paths.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/env_config.cpp" + # platform::ui::* runtime implementations + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/device_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/firmware_update_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/gps_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/hostlink_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/lora_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/orientation_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/pack_repository_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/route_storage.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/screen_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/settings_store.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/sstv_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/team_ui_store_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/time_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/tracker_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/usb_support_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/walkie_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/wifi_runtime.cpp" + # modules/core_chat + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/domain/chat_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/contact_store_core.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/mesh_protocol_utils.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/node_store_blob_format.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/node_store_core.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/store/ram_store.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/chat_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/contact_service.cpp" + # modules/core_team + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_chat.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_location_marker.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_mgmt.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_pairing_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_position.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_track.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_waypoint.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/usecase/team_controller.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/usecase/team_pairing_coordinator.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/usecase/team_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/usecase/team_track_sampler.cpp" + # modules/core_hostlink + "${TRAIL_MATE_REPO_ROOT}/modules/core_hostlink/src/hostlink_session.cpp" + # modules/core_sys + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/app/app_facade_access.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/sys/clock.cpp" +) + +set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES + # platform/linux/common – shared Linux UI layer + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/ui/localization.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/ui/shared_ui_shell.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/ui/shell_ui_runner.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/ui/ui_common.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/ui/ui_status.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/ui/gps_shared_compat.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/ui/mt_protocol_air_compat.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/ui/widgets/map/map_tiles.cpp" + # modules/ui_shared – assets + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/Chat.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/alert.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/AreaCleared.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/BaseCamp.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/contact.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/ext.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/gps.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/GoodFind.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/img_usb.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/logo.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/rally.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/rf.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/Satellite.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/Setting.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/Spectrum.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/sstv.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/sos.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/team.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/tracker.c" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/walkie_talkie.c" + # modules/ui_shared – components + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/air_status_footer.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/assets/fonts/font_utils.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/info_card.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/two_pane_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/two_pane_nav.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/two_pane_styles.cpp" + # modules/ui_shared – shell / menu / page + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/app_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/formatters.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/menu/menu_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/menu/menu_profile.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/menu/menu_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/menu/dashboard/dashboard_style.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/page/page_profile.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/common/placeholder_page.cpp" + # chat + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_compose_components.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_compose_input.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_compose_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_compose_styles.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_conversation_components.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_conversation_input.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_conversation_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_conversation_styles.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_message_list_components.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_message_list_input.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_message_list_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_message_list_styles.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_page_shell.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_protocol_support.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_send_flow.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_ui_controller.cpp" + # contacts + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_page_components.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_page_input.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_page_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_page_shell.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_page_styles.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_state.cpp" + # energy sweep + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/energy_sweep/energy_sweep_page_shell.cpp" + # extensions + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/extensions/extensions_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/extensions/extensions_page_shell.cpp" + # gnss + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/gnss/gnss_skyplot_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/gnss/gnss_skyplot_page_shell.cpp" + # gps + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/gps/gps_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/gps/gps_page_shell.cpp" + # node info + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/node_info/node_info_page_components.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/node_info/node_info_page_layout.cpp" + # pc link + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/pc_link/pc_link_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/pc_link/pc_link_page_shell.cpp" + # settings + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/settings/settings_page_components.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/settings/settings_page_input.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/settings/settings_page_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/settings/settings_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/settings/settings_page_shell.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/settings/settings_page_styles.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/settings/settings_state.cpp" + # sstv + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/sstv/sstv_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/sstv/sstv_page_shell.cpp" + # team + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_components.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_input.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_shell.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_styles.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_state.cpp" + # tracker + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_components.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_input.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_shell.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_state.cpp" + # walkie talkie + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/walkie_talkie/walkie_talkie_page_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/walkie_talkie/walkie_talkie_page_shell.cpp" + # startup / widgets + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/startup_ui_shell.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/ui_boot.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/ime/ime_widget.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/busy_overlay.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/map/map_viewport.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/system_notification.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/toast/toast_widget.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/top_bar.cpp" + # region presets + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshcore/mc_region_presets.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_region.cpp" +) + +# --------------------------------------------------------------------------- +# Common include directories – reused by every Linux target. +# --------------------------------------------------------------------------- + +set(TRAIL_MATE_LINUX_COMMON_INCLUDES + "${TRAIL_MATE_LINUX_COMMON_INCLUDE_ROOT}" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}" + "${TRAIL_MATE_CORE_CHAT_INCLUDE_ROOT}" + "${TRAIL_MATE_CORE_CHAT_GENERATED_ROOT}" + "${TRAIL_MATE_CORE_CHAT_NANOPB_ROOT}" + "${TRAIL_MATE_CORE_GPS_INCLUDE_ROOT}" + "${TRAIL_MATE_CORE_HOSTLINK_INCLUDE_ROOT}" + "${TRAIL_MATE_CORE_SYS_INCLUDE_ROOT}" + "${TRAIL_MATE_CORE_TEAM_INCLUDE_ROOT}" + "${TRAIL_MATE_UI_SHARED_INCLUDE_ROOT}" +) + +set(TRAIL_MATE_LINUX_UI_SHELL_INCLUDES + "${TRAIL_MATE_LINUX_COMMON_INCLUDE_ROOT}" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}" + "${TRAIL_MATE_CORE_CHAT_INCLUDE_ROOT}" + "${TRAIL_MATE_CORE_CHAT_GENERATED_ROOT}" + "${TRAIL_MATE_CORE_CHAT_NANOPB_ROOT}" + "${TRAIL_MATE_CORE_TEAM_INCLUDE_ROOT}" + "${TRAIL_MATE_UI_SHARED_INCLUDE_ROOT}" + "${TRAIL_MATE_CORE_SYS_INCLUDE_ROOT}" + "${TRAIL_MATE_PLATFORM_SHARED_INCLUDE_ROOT}" +) + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + +function(trailmate_apply_linux_common_warnings target_name) + if(MSVC) + target_compile_definitions(${target_name} PRIVATE _CRT_SECURE_NO_WARNINGS) + target_compile_options(${target_name} PRIVATE /W4 /permissive- /FS) + else() + target_compile_options(${target_name} PRIVATE -Wall -Wextra -Wpedantic) + endif() +endfunction() + +function(trailmate_add_linux_common target_name) + add_library(${target_name} + ${TRAIL_MATE_LINUX_COMMON_SOURCES} + ) + target_include_directories(${target_name} + PUBLIC ${TRAIL_MATE_LINUX_COMMON_INCLUDES} + ) + target_compile_features(${target_name} PUBLIC cxx_std_20) + target_compile_definitions(${target_name} + PUBLIC TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 + ) + target_link_libraries(${target_name} PUBLIC Threads::Threads) + if(WIN32) + target_link_libraries(${target_name} PUBLIC ws2_32) + endif() + trailmate_apply_linux_common_warnings(${target_name}) +endfunction() + +function(trailmate_add_linux_ui_shell target_name common_target) + # common_target must already be created (via trailmate_add_linux_common). + add_library(${target_name} + ${TRAIL_MATE_LINUX_UI_SHELL_SOURCES} + ) + target_include_directories(${target_name} + PUBLIC ${TRAIL_MATE_LINUX_UI_SHELL_INCLUDES} + ) + target_compile_features(${target_name} PUBLIC cxx_std_20) + target_compile_definitions( + ${target_name} + PRIVATE + TRAIL_MATE_BOOT_MIN_SHOW_MS=1200 + TRAIL_MATE_CARDPUTER_ZERO_LINUX=1 + GAT562_NO_PINYIN_IME=1 + ) + target_link_libraries(${target_name} + PUBLIC + lvgl + ${common_target} + ) + trailmate_apply_linux_common_warnings(${target_name}) +endfunction() diff --git a/docs/LINUX_ADAPTATION_GUIDE.md b/docs/LINUX_ADAPTATION_GUIDE.md new file mode 100644 index 00000000..8ff4beda --- /dev/null +++ b/docs/LINUX_ADAPTATION_GUIDE.md @@ -0,0 +1,1551 @@ +# Linux 适配现状评估与开发指南 + +评估日期:2026-05-08 + +适用范围:本文评估并指导 Trail Mate 仓库中的 Linux 线,重点覆盖 +`apps/linux_sim`、`apps/linux_rpi`、`apps/linux_unoq`、`platform/linux/*`、 +`modules/*` 与 `modules/core_sys/include/platform/ui/*` 之间的关系。 + +本文不是单个功能的任务清单。它的目标是回答三个问题: + +1. 当前 Linux 适配到底做到哪种程度。 +2. 接下来应该先做什么,后做什么。 +3. 怎样优雅地适配 Linux,而不是把 Linux 变成另一个长期分叉。 + +## 0. 总结 + +当前 Linux 适配已经从“结构成型、模拟器可验证、真实设备尚未闭环”,推进到了“真机闭环结构正在搭建,但当前工作区还没有重新 build-closed”的阶段。 + +更具体地说: + +- 仓库结构已经把 Linux 当成一等目标在推进,而不是临时实验目录。 +- `apps/linux_sim` 仍然是最快的共享 UI 验证壳,并且 source list 已经开始通过 `cmake/TrailMateLinuxSources.cmake` 收口。 +- `platform/linux/common` 已经实现了一批 Linux-safe 平台合约,包括 settings、time、screen、device、GPS NMEA/env、hostlink TCP、route/tracker 文件存储、pack repository、SSTV/walkie/LoRa 的模拟运行时,以及 Wi-Fi/USB/FOTA 等显式 unsupported 桩。本轮又新增了 runtime path、env helper、capability status、runtime mode、demo world 抽取等结构;最新推进中,`CapabilityStatus` 已经上移为 `platform::ui` 合约种子并进入 LoRa/SSTV/Walkie public header,hostlink/team/SSTV/map tiles 也开始更多复用 `runtime_paths`。 +- `apps/linux_rpi` 仍有两条设备路径:一条是 repo-local CMake framebuffer shell,一条是 `M5Stack_Linux_Libs` SCons SDK shell。custom framebuffer path 已经接入 evdev 源文件和输入 drain,但仍没有完成 build-closed 与真机验证;SDK path 已从很薄的占位 main 推进到 fbdev 自动探测、LVGL evdev 初始化和启动日志齐备的 bring-up shell,但仍尚未接上 Trail Mate shared shell。 +- 边界检查已经存在并通过。当前 `modules/ui_shared` 和 `modules/core_*` 没有命中 `Arduino.h`、`Preferences`、`freertos/*`、`platform/esp/*` 这类关键平台污染规则。 +- 真机完成度仍低于模拟器完成度。显示、输入、能力模型、真实硬件运行时、SDK 主路径、安装发布、设备 CI 都还没有形成完整闭环;其中能力模型已经有合约种子和部分 public API,但还没有进入 contract inventory 和 UI 呈现。 +- 本轮代码有明显架构进展,上一轮列出的主要 compile/runtime gate 已基本修掉;当前最大风险已经从“源码明显不一致”转为“尚未完整构建、测试和真机验证”。不能因为结构文件已经出现,就把 P1/P2/P3/P4/P5/P6 直接标记为完成。 + +一句话判断: + +> 现在不是“Linux 还没开始”,也不是“Linux 已经适配好了”。本轮回归后,Linux 线已经进入 L4 结构施工阶段;输入路径、SDK bring-up、路径安全和能力合约的结构明显前进,并且一部分上轮 P0 项已经从“待修”降级为“待验证”。但当前工作区仍需要先完成剩余编译验证和真机验证,才能把 L4 从“结构存在”推进到“可构建、可运行、可验证”。 + +### 0.1 2026-05-07 回归结论(历史记录) + +本次回归基于 `LINUX_ADAPTATION_GUIDE.md` 之后的代码改动重新评估,结论如下: + +注意:本节保留 2026-05-07 当天的判断,用于观察演进;当前状态以 2026-05-08 的 `0.6` 小节为准。 + +| 指南项 | 本轮代码变化 | 当前判断 | +| --- | --- | --- | +| P1 CMake source list 收口 | 新增 `cmake/TrailMateLinuxSources.cmake`,`linux_sim`/`linux_rpi` 的 CMake 明显变薄 | 方向正确,结构基本落地;但必须通过 sim/rpi 两套构建后才能标记 done | +| P2 LVGL ownership 拆分 | 新增 `ShellSession`、`CanvasLvglHost`、`NativeLvglHost` | 方向正确,已从“单一 runner”进入“双 host”结构;但当前 header/callback 细节仍会阻断或破坏输入 | +| P3 真机输入 | 新增 `EvdevInput`,`LinuxFramebufferPlatform::drainInput()` 改为从 evdev 读取 | 历史判断:当日还没闭环;该行的 source wiring / key map / 目录探测问题已在 2026-05-08 回归中更新 | +| P4 runtime path/env | 新增 `platform/linux/runtime_paths.*`、`env_config.*`,route/tracker 删除路径开始限制在根目录下 | 明显进展;但 settings/sstv/hostlink/team 等还没有完全统一到同一套 path helper | +| P5 capability truth model | 新增 `CapabilityState`/`CapabilityStatus`,LoRa/Walkie/SSTV 开始返回 `Simulated` | 概念已经落地为代码,但还没有成为 `platform::ui::*` 合约的一部分,也没有驱动 UI 呈现 | +| P6 demo world/facade 拆分 | 新增 `linux_demo_world.*` | 只是第一步:app facade 里仍保留旧 loopback mesh、dummy crypto、demo seeding,`runtime_mode` 尚未真正控制 composition | +| P7 M5 SDK 主路径 | 暂未看到 shared shell 接入 `M5Stack_Linux_Libs` | 仍未完成 | +| P8 第一个验证 slice | path safety smoke 新增,Settings slice 还未形成真实验收闭环 | 测试方向正确,但当前 smoke test 本身还需要 include 修正 | + +当前最高优先级不是继续扩大功能面,而是先让这轮结构性改动重新达到“能构建、能跑 smoke、能解释能力真假”的状态。 + +### 0.2 本轮必须先修的具体阻断点 + +这些问题属于“先修它们,否则后续评估会失真”的层级: + +| 类型 | 文件 | 2026-05-08 状态 | 仍需动作 | +| --- | --- | --- | --- | +| Build gate | `platform/linux/common/include/app/linux_demo_world.h` / `platform/linux/common/src/app/linux_demo_world.cpp` | 上轮 header ``、constexpr node id、重复定义风险已修 | 用完整构建确认;后续再清理 facade 内仍重复存在的 loopback mesh 逻辑 | +| Architecture gate | `platform/linux/common/src/app/linux_app_facade.cpp` | demo seeding 已被 `demo_world_enabled(resolve_runtime_mode())` gate | 仍要把 loopback mesh、dummy crypto、loopback pairing 从真实设备默认 composition 中拆出去 | +| Build gate | `platform/linux/common/include/ui/shell_ui_runner.h` / `platform/linux/common/src/ui/shell_ui_runner.cpp` | callback 已恢复为 `lv_area_t` / `lv_indev_data_t` 精确签名,header 只做 LVGL 类型前向声明 | 用完整构建确认 LVGL typedef 与前向声明是否兼容;如果 LVGL 的 `lv_area_t` / `lv_indev_data_t` 不是带 `_lv_*` tag 的 typedef,应改为包含 `lvgl.h` 或把 callback 降到 `.cpp` 私有自由函数 | +| Runtime gate | `platform/linux/common/src/ui/shell_ui_runner.cpp` | `hasPendingKeyEvent()` 已加入,`continue_reading` 不再消费下一事件 | 需要用小测试或真机手测确认 press/release 都能被 LVGL 收到 | +| Build gate | `apps/linux_rpi/CMakeLists.txt` | `evdev_input.cpp` 已加入 device executable | 用 Linux/WSL CI 构建确认 | +| Build gate | `platform/linux/rpi/src/platform/device/evdev_input.cpp` | key map 已改为 `std::to_array({...})`,手写数量和 CTAD 风险已修 | 用 Linux 构建确认;后续补真机键位采样和映射测试 | +| Runtime gate | `platform/linux/rpi/src/platform/device/evdev_input.cpp` | by-path/by-id 不存在目录已用 `error_code` 与 `is_directory` 防护 | 还需真机采样 Cardputer Zero 内置键盘事件码,完善 Fn/组合键映射 | +| Build gate | `apps/linux_sim/tests/path_safety_smoke.cpp` | 已 include `platform/linux/capability_status.h` | 用 `ctest` 确认 smoke 真正通过 | +| Contract gate | `modules/core_sys/include/platform/ui/capability_status.h` / `modules/core_sys/include/platform/ui/{lora,sstv,walkie}_runtime.h` | `CapabilityStatus` 已从 Linux helper 上移为 `platform::ui` 合约种子,Linux helper 只做 re-export | 还要在各 runtime public header 中声明 `capability_status()`,更新 contract inventory,并让 UI 使用状态而不是只看 `is_supported()` | +| Path gate | `platform/linux/common/src/platform/ui/*` / `platform/linux/common/src/ui/widgets/map/map_tiles.cpp` | hostlink、team store、SSTV、map tiles 已改用 `runtime_paths`,hostlink 默认 bind 已变成 loopback | `pack_repository_runtime.cpp` 仍有 ad hoc root;map tiles 等处旧 env 常量已无实际用途;`runtime_paths.h` 仍提到 fsync,但 `.cpp` 实现已明确为 no-fsync temp+rename,需要二选一校准 | +| Runtime mode | `apps/linux_sim/src/targets/simulator_main.cpp` / `platform/linux/common/include/platform/linux/runtime_mode.h` | simulator main 只在用户未设置 `TRAIL_MATE_RUNTIME_MODE` 时默认写入 `demo`;Windows `_putenv_s()` 已进入同一 guard | 继续用 Windows/WSL 启动 smoke 确认 env override;继续确认 device shell 默认不进入 demo;真实设备 composition 仍需拆掉 dummy/loopback 默认实现 | +| Specification gate | `apps/linux_rpi/docs/specification/project-baseline.md` 等 | 部分文字已更新,但仍需和 build/test 事实对齐 | `DONE` 只用于 build/test/真机验证过的事实;目标态用 `TARGET`、`PARTIAL` 或 `NOT YET VALIDATED` | + +### 0.3 本轮验证记录 + +本轮回归做了有限验证,结论要分清: + +| 命令 | 结果 | 解释 | +| --- | --- | --- | +| `python scripts/check_platform_ui_boundaries.py` | 通过 | 说明 shared/core 的 ESP/Arduino/FreeRTOS include 污染边界仍然干净 | +| `cmake -S apps/linux_sim -B .codex-build/linux-sim-review -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Debug` | 配置通过 | 说明新的 CMake helper 至少能被配置阶段消费 | +| `cmake --build .codex-build/linux-sim-review --target trailmate_cardputer_zero_path_safety_smoke -j 8` | 未能验证项目代码 | 本机 `clang++ 14` 被 VS 2022 STL 以 “expected Clang 19.0.0 or newer” 拦截,失败发生在标准库兼容层,不代表项目代码已经或没有编译通过 | +| `cmake -S apps/linux_sim -B .codex-build/linux-sim-msvc -G "Visual Studio 17 2022" -A x64 -DBUILD_TESTING=ON` | 超时 | 124 秒内未完成配置,不能作为通过或失败结论 | + +因此,本文中的 compile gate 清单来自代码阅读和目标依赖关系回归;最终仍要用 Linux/WSL CI 或可用的 MSVC generator 完整构建来确认。 + +### 0.4 2026-05-08 回归结论 + +注意:本节记录上一轮 2026-05-08 回归点;当前最新判断以 `0.6` 为准。 + +本轮相对 2026-05-07 的回归结果如下: + +| 指南项 | 2026-05-08 观察 | 当前判断 | +| --- | --- | --- | +| P1 CMake source list 收口 | rpi target 已显式加入 `platform/linux/rpi/src/platform/device/evdev_input.cpp`,sim/rpi 仍通过 shared helper 组织 common/ui sources | P1 继续向好;剩余是完整构建验证、helper 注释/编码清理,以及 target define 语义收口 | +| P2 LVGL ownership 拆分 | `ShellSession::hasPendingKeyEvent()` 已加入,`readInputCb()` 不再为了 `continue_reading` 消费下一个事件;callback 已恢复为 `lv_area_t` / `lv_indev_data_t` 精确签名 | 上一轮的输入吞事件问题已修;callback 风险已收窄到 LVGL typedef/前向声明兼容性,仍需构建确认;SDK native host 仍未接入 | +| P3 真机输入 | `evdev_input.cpp` 已被 rpi CMake 编入;`/dev/input/by-path` 与 `/dev/input/by-id` 已用 `error_code` 与目录判断保护;`kKeyMap` 已改为 `std::to_array` | source wiring、目录探测、key map 初始化风险已修;剩余是 Linux 构建确认和 Cardputer Zero 真机键位采样 | +| P4 runtime path/env | `path_safety_smoke.cpp` 已 include `capability_status.h`;route/tracker 仍走 root containment | path safety smoke 的 include 问题已修;但 SSTV、team store、hostlink、pack repository 还没有完全统一到 `runtime_paths` | +| P5 capability truth model | `CapabilityState` 仍是 Linux 内部 helper,LoRa/Walkie/SSTV 仍通过 `capability_status()` 标记 `Simulated` | 概念稳定,但尚未进入 `platform::ui::*` 公共合约和 UI 呈现 | +| P6 demo world/facade 拆分 | `runtime_mode.h` 已补 ``,`ensureStarted()` 已用 `demo_world_enabled(resolve_runtime_mode())` gate demo seeding,simulator main 显式默认 `TRAIL_MATE_RUNTIME_MODE=demo` | demo seed 默认语义更清楚;但 facade 仍总是组合 loopback mesh、dummy crypto、loopback pairing,DeviceLocal 还不是干净真实设备 composition | +| P7 M5 SDK 主路径 | 未看到 `apps/linux_rpi/main/src/main.cpp` 接入 `ShellSession`/`NativeLvglHost` | 仍未完成 | +| P8 第一个验证 slice | path safety smoke 源码更完整,但本地尚未完成有效构建/ctest | 测试方向正确,验收仍依赖 Linux/WSL/CI 构建 | + +一句话:这轮把 2026-05-07 的一批“明确红灯”基本修成了“等待构建确认”,但还没越过 build/test/真机三道门。下一步最小闭环应是直接在 Linux/WSL 上构建 `linux_sim` tests 和 `linux_rpi` framebuffer target,然后做 Cardputer Zero 真机键位采样与导航验收。 + +### 0.5 2026-05-08 最新回归结论 + +本轮在 `0.4` 之后继续推进了三类结构:SDK bring-up、能力合约上移、runtime path 覆盖。结论要比 `0.4` 更细:有些项已经从“缺结构”变成“缺公开 API / 缺验证”,但仍不能直接标记为完成。 + +| 指南项 | 最新观察 | 当前判断 | +| --- | --- | --- | +| P1 CMake source list 收口 | `cmake/TrailMateLinuxSources.cmake` 的路径注释已经校准为“从 helper 所在目录推 repo root”;rpi target 继续显式编入 `evdev_input.cpp` | 结构上继续健康;当前剩余不是再抽象,而是完整 Linux/WSL build、ctest 和 rpi target build | +| P2 LVGL ownership 拆分 | `ShellSession` / `CanvasLvglHost` / `NativeLvglHost` 结构未再倒退;callback 精确签名和 `hasPendingKeyEvent()` 仍保持 | 方向稳定;仍需构建确认 LVGL typedef/前向声明兼容性,并把 SDK main loop 从注释切到 `NativeLvglHost` | +| P3 真机输入 | CMake framebuffer path 的 evdev adapter 仍是当前 shared shell 输入主线;SDK path 也已用 LVGL evdev 创建 pointer/keypad | 输入结构明显增强;仍缺真机事件码采样、Fn/组合键映射、press/release 回归和 rpi build 结果 | +| P4 runtime path/env | settings、route、tracker、hostlink、team store、SSTV 已基本走 `runtime_paths`;hostlink 默认 bind 已是 `127.0.0.1`,需要对外监听时显式设 `TRAIL_MATE_HOSTLINK_BIND=0.0.0.0` | P4 从“零散 helper”推进到“多数核心写入点收口”;剩余 map tiles、pack repository、旧 env 常量、`safe_write_under_root()` fsync 注释/实现不一致 | +| P5 capability truth model | 新增 `modules/core_sys/include/platform/ui/capability_status.h`,Linux 侧 `platform/linux/capability_status.h` 变为 re-export;LoRa/Walkie/SSTV 实现层返回 `Simulated` | 概念已经进入合约层,但 public runtime headers 尚未声明 `capability_status()`,contract README 未列入 `capability_status.h`,UI 也还没消费;因此只能算“合约种子落地”,不能算能力呈现完成 | +| P6 demo world/facade 拆分 | `runtime_mode` 继续只 gate demo seed;`MinimalLinuxAppFacade` 仍总是组合 loopback mesh、dummy crypto、loopback pairing | demo/local 的数据种子边界更清楚,但真实设备 composition 仍不干净;另有 Windows simulator 会覆盖用户预设 `TRAIL_MATE_RUNTIME_MODE` 的小坑 | +| P7 M5 SDK 主路径 | `apps/linux_rpi/main/src/main.cpp` 已具备 ST7789 framebuffer 探测、LVGL fbdev、LVGL evdev pointer/keypad、启动日志和 bring-up UI;shared shell 接入代码仍在注释中 | SDK path 已不再是空壳,但仍是 bring-up UI,不是 Trail Mate shared shell;L5 仍未完成 | +| P8 第一个验证 slice | 本轮未新增有效 build/ctest 结果;已有本地 build 受 Windows clang/MSVC STL 版本拦截 | 继续保持“代码阅读评估 + 边界检查可验证”,最终结论仍要等 Linux/WSL CI 或真机验收 | + +一句话:最新推进把 P4/P5/P7 的结构成熟度又抬了一档,但 Linux 线仍停在“结构越来越像最终形态、验证还没闭环”的阶段。下一步最优先不是继续加功能,而是把这些结构跑通:Linux/WSL build、capability public API、SDK shared shell 接入、真机输入采样。 + +### 0.6 2026-05-08 再次回归结论 + +本轮相对 `0.5` 又推进了三个上一轮点名的缺口:simulator runtime mode 覆盖语义、LoRa/SSTV/Walkie capability public API、map tile runtime path。结论如下: + +| 指南项 | 最新观察 | 当前判断 | +| --- | --- | --- | +| P0-E runtime mode 覆盖语义 | `apps/linux_sim/src/targets/simulator_main.cpp` 现在只在 `TRAIL_MATE_RUNTIME_MODE` 未设置时写入 `demo`;Windows `_putenv_s()` 也被放到同一个 guard 后面 | 上轮指出的 Windows 覆盖用户预设问题已修;剩余是用 Windows/WSL 启动 smoke 验证 env override | +| P4 runtime path/env | `platform/linux/common/src/ui/widgets/map/map_tiles.cpp` 已改为 `resolve_paths().sd_root`;hostlink/team/SSTV/map tiles 都不再复制完整 storage root fallback | P4 继续变好;仍剩 `pack_repository_runtime.cpp` 默认从 `__FILE__` 推 repo root,map tiles 里旧 `kSdRootEnv`/`kSettingsRootEnv` 常量已无用途,`runtime_paths.h` 头文件仍写 fsync 但实现已改成 no-fsync temp+rename | +| P5 capability truth model | `lora_runtime.h`、`sstv_runtime.h`、`walkie_runtime.h` 已 include `platform/ui/capability_status.h` 并公开声明 `CapabilityStatus capability_status()` | P5 从“合约种子落地”推进到“部分 runtime public API 落地”;剩余是更新 `modules/core_sys/include/platform/ui/README.md` contract inventory,并让 UI 消费该状态 | +| P7 M5 SDK 主路径 | `apps/linux_rpi/main/src/main.cpp` 仍是 bring-up UI,shared shell 接入仍停在注释 | 本轮未推进;L5 仍未完成 | +| P8 验证 slice | 本轮重新跑了 boundary check,通过;仍未完成 Linux/WSL build、ctest 或真机验证 | 边界干净,但 build/run gate 仍是下一步硬门槛 | + +一句话:这轮把 `0.5` 里的几项“结构缺口”又补了一层,尤其是 P0-E 和 P5 public API。当前最值得立刻做的不是继续写更多 runtime,而是清理两个残留文档/API 小口子,然后跑 Linux/WSL 构建和至少一个 capability UI 消费闭环。 + +## 1. 先做区分 + +### 1.1 当前最容易混在一起的概念 + +Linux 适配不能只理解成“让代码能在 Linux 编译”。当前仓库里至少有这些必须分开的对象: + +| 概念 | 它是什么 | 它不是什么 | +| --- | --- | --- | +| Linux 目标线 | Trail Mate 的一等平台族 | 不是 ESP/Arduino 的另一个 board profile | +| `apps/linux_sim` | 桌面模拟器和开发工具壳 | 不是真机运行时,不应承接 Pi OS 硬件细节 | +| `apps/linux_rpi` | Pi OS / Cardputer Zero 真机 app shell | 不应吸收模拟器几何、桌面输入或 demo app 结构 | +| `platform/linux/common` | simulator 与 rpi 都可共享的 Linux-safe 实现层 | 不应放 SDL 外壳、fbdev 设备假设或业务规则 | +| `platform/linux/rpi` | Pi OS / framebuffer / 设备适配层 | 不应放共享 UI 页面逻辑 | +| `M5Stack_Linux_Libs` | 真实设备 SDK 基线 | 不是 Trail Mate 架构边界的来源 | +| `M5CardputerZero-UserDemo` | 板级线索参考 | 不是项目模板 | +| 平台合约 | `modules/core_sys/include/platform/ui/*` 中的协作面 | 不是某个平台的实现细节 | +| 平台实现 | `platform/esp/*`、`platform/linux/*` 中的具体实现 | 不拥有 domain/usecase/UI 结构 | +| 能力支持 | 真实或可解释的运行时能力 | 不是“菜单上有入口”或“有一个模拟桩” | + +### 1.2 当前 specification 基线 + +后续 Linux 适配应遵守以下基线: + +- `modules/core_*` 是核心业务、协议、策略、用例、状态和纯工具层。 +- `modules/ui_shared` 是共享 LVGL 表现层,可依赖核心模块和平台合约,不可依赖平台实现。 +- `modules/core_sys/include/platform/ui/*` 是共享 UI 与平台实现之间的合约层。 +- `platform/linux/common` 实现 Linux 共享合约,只放 simulator 与 device 都成立的东西。 +- `platform/linux/rpi` 实现 Pi OS / Cardputer Zero 特有适配。 +- `apps/linux_sim` 和 `apps/linux_rpi` 只做 composition root、启动和目标选择。 +- `M5Stack_Linux_Libs` 应位于 Trail Mate 平台边界之下,由 Linux device shell 或 `platform/linux/rpi` 消费。 + +### 1.3 明确非法的切法 + +这些做法会让 Linux 适配变得不优雅,后续应避免: + +- 把 `Cardputer Zero` 当成另一个 MCU board,强行通过 `BoardBase` 抽象接入。 +- 把 SDL、fbdev、evdev、`/dev/input/*`、`/dev/fb*` 写进 `ui_shared`。 +- 把 `M5CardputerZero-UserDemo` 的目录结构或 app 组织方式复制进仓库。 +- 把“环境变量可模拟”误认为“真机能力已支持”。 +- 把 hostlink、GPS、SSTV、LoRa 的模拟数据当成产品真实能力。 +- 因为 CMake 中两个 Linux app 都需要同一批源码,就长期手写两份 source list。 +- 在 shared/core 里撒 `#ifdef __linux__` 或 `#ifdef _WIN32` 来逃避边界设计。 +- 在 `apps/linux_rpi` 里直接堆真机驱动、UI 页面和业务逻辑。 + +## 2. 当前适配程度 + +### 2.1 成熟度分级 + +这里的等级不是测试覆盖率,而是架构和产品可用性的成熟度。 + +| 等级 | 含义 | 当前状态 | +| --- | --- | --- | +| L0 | 有目录和文档,Linux 被识别为目标线 | 已完成 | +| L1 | Linux host 可以配置、编译基础目标 | 历史上成立;本轮结构改动后仍需要重新跑 build gate | +| L2 | 桌面模拟器能跑共享 shell,形成快速验证闭环 | 结构成立;仍需重新证明 simulator build/test | +| L3 | Linux common runtime 覆盖主要平台合约,有 smoke tests | 基本完成且本轮有 path/capability 进展;`CapabilityStatus` 已上移为合约种子并进入 LoRa/SSTV/Walkie public header,path smoke 源码已修正但未验收 | +| L4 | Pi OS framebuffer/device shell 可编译并启动共享 UI | 结构推进明显,evdev source wiring 已补;SDK bring-up shell 更完整;仍未 build/run-closed | +| L5 | `M5Stack_Linux_Libs` 主路径跑 Trail Mate 共享 shell | SDK bring-up 已加强,但 shared shell 尚未接入 | +| L6 | 真机显示、输入、存储、GPS、音频、网络/无线能力按能力模型闭环 | 未完成 | +| L7 | 可安装、可发布、可回归、可支持多 Linux 设备族 | 未完成 | + +当前整体结论:L3 的方向和代码面都成立,但本轮改动后仍必须重新 build-closed;L4 已经从 display-only 走向 display+input + SDK bring-up 的结构阶段,但仍属于未验收状态;L5 还没闭环。 + +### 2.2 分项评估 + +| 维度 | 当前程度 | 证据 | 缺口 | +| --- | --- | --- | --- | +| 目录结构 | 较好 | `apps/linux_sim`、`apps/linux_rpi`、`apps/linux_unoq`、`platform/linux/common`、`platform/linux/rpi` 已存在;本轮新增 `cmake/TrailMateLinuxSources.cmake` | `platform/linux/unoq` 还只有规划;Linux docs 与 app-local specs 的层级关系仍需维护 | +| 架构规范 | 较好 | `docs/LINUX_ADAPTATION_GUIDE.md` 与 `apps/linux_rpi/docs/specification/*` 已形成 final-shape / checklist / 回归指南组合 | app-local specs 里个别 `DONE` 状态比代码更乐观,需要用 build/test 结果校准 | +| 边界保护 | 较好 | `scripts/check_platform_ui_boundaries.py` 存在并保护 shared/core 不被 ESP 污染 | 规则还偏 include 污染,尚未覆盖 CMake/source ownership、能力语义、路径安全、demo/real composition | +| 模拟器 | 较好 | `apps/linux_sim` 有 SDL3 simulator、CMake presets、tests、WSL/dev-container 脚本,并开始复用 shared CMake helper | 本轮改动后要重新证明 simulator build/test;模拟器能力和真实能力界线还需更强标注 | +| Linux common runtime | 中等偏好 | settings/time/screen/device/GPS/hostlink/tracker/route/SSTV/walkie/LoRa/pack repository 等已实现;runtime path/env/capability 基础继续推进,hostlink/team/SSTV/map tiles 已开始收口到 `runtime_paths` | 很多是 soft runtime 或 synthetic runtime,不应直接宣称真机支持;pack repository 仍有 ad hoc path root;capability 尚未进入 UI | +| Pi OS CMake device shell | 中等偏早 | custom framebuffer target 使用共享 shell runner,已从 `EvdevInput` drain 真机输入,rpi CMake 已编入 evdev 源文件,key map 已改为 `std::to_array` | 仍需 Linux 构建确认;Cardputer Zero 真机键位验证仍未闭环 | +| M5Stack SDK path | 早期偏中 | `apps/linux_rpi/SConstruct`、`main/SConstruct`、`config_defaults.mk`、`main/src/main.cpp` 已存在;main 已具备 ST7789 framebuffer 探测、LVGL fbdev、LVGL evdev pointer/keypad 和启动日志 | SDK path 目前仍是 bring-up UI,shared shell 接入只在注释中,未复用 Trail Mate `ShellSession` / `NativeLvglHost` | +| UI 共享 | 中等偏好 | `trailmate_cardputer_zero_ui_shell` 编译大量 `modules/ui_shared` 页面和资产;新增 `ShellSession`/`CanvasLvglHost`/`NativeLvglHost`,输入事件吞掉问题已修,callback 已回到 LVGL 精确类型签名 | SDK native path 尚未接入;仍需完整构建确认 LVGL typedef/前向声明兼容性 | +| CMake 结构 | 明显改善 | linux_sim 与 linux_rpi 已改为 include 共享 helper,source list 不再主要散在两个 app CMake 里,rpi-only evdev source 已接入,helper 路径注释已校准 | 需要跑 sim/rpi build;target compile definitions 和 runtime mode 默认语义还需收口 | +| 真机能力 | 早期 | fbdev path、LVGL evdev bring-up、NMEA serial path、hostlink TCP path 已有雏形;hostlink 默认 loopback 更安全 | Cardputer Zero 键位、battery、brightness、Wi-Fi、USB、firmware update、real radio/audio 等未闭环 | +| CI | 中等偏好 | `.github/workflows/cardputer-zero-linux.yml` 构建 simulator、tests、rpi CMake shell,跑边界检查和 WSL-style script | 不构建 M5 SDK path,不跑真机/虚拟 fbdev,不验证 Docker GUI path | +| 开发体验 | 较好 | Windows PowerShell、Linux shell、WSL validate、Docker dev container 脚本齐全 | 本地环境变量和 path bridge 还在演进;不同 host 的路径/权限策略需要统一文档化 | + +### 2.3 已经做得比较好的地方 + +1. Linux 不是外挂目录,而是进入了目标结构。 + +`apps/README.md`、`docs/ARCHITECTURE.md`、`platform/linux/README.md` 都表明 Linux 目标线已被纳入长期结构。 + +2. Simulator 与 real device 的边界已经开始分开。 + +`apps/linux_sim` 的 README 明确 simulator-first,`apps/linux_rpi` 的 README 明确 Pi OS device shell。`platform/linux/common` 被定义为两者共享层。 + +3. shared UI 已经能被 Linux shell 消费。 + +Linux CMake target 编译大量 `modules/ui_shared/src/ui/*` 页面、组件、资产,并通过 `SharedUiShellStartup` 进入共享 boot/menu shell。 + +4. 核心模块平台污染已经显著改善。 + +当前边界检查通过,`modules/ui_shared/library.json` 已不再带 ESP Arduino include root。过去规范中提到的某些违规项已经被修掉。 + +5. Linux common runtime 已经不是空壳。 + +`platform/linux/common/src/platform/ui/*` 已经覆盖了一批真实或可模拟的能力:文件设置、屏幕 timeout、GPS NMEA、hostlink TCP、tracker/route 文件、team UI store、pack repository、SSTV/walkie/LoRa synthetic runtime 等。 + +6. CMake 重复已经开始收口。 + +`cmake/TrailMateLinuxSources.cmake` 把 common sources、UI shell sources、include roots 和 warnings helper 提到了共享层。后续添加 shared UI 文件时,不应再让 simulator 和 rpi 各自维护一份大 source list。 + +7. LVGL ownership 的正确方向已经出现。 + +`ShellSession` 开始只拥有 app facade、startup、事件队列和 per-frame app tick;`CanvasLvglHost` 拥有 `lv_init()`、display、RGB565 buffer 和 canvas copy;`NativeLvglHost` 给 SDK-owned LVGL path 留了入口。这是接入 `M5Stack_Linux_Libs` 的正确中间形态。 + +8. 真机输入和路径安全都开始从“口头要求”变成代码。 + +`EvdevInput` 已经把 `/dev/input/event*`、by-path/by-id 键盘探测、`TRAIL_MATE_INPUT_DEVICE` override、Linux key code 到 `InputEvent` 的映射放到了 `platform/linux/rpi`。`runtime_paths` 和 `resolve_child_under_root()` 也让 route/tracker 删除开始具备 root containment;最新推进还把 hostlink、team store、SSTV 等更多写入点收口到同一套 runtime root。 + +9. CI 已经能挡住基础倒退。 + +专门的 `Cardputer Zero Linux` workflow 会跑边界检查、Linux simulator build、smoke tests、Pi OS CMake shell build 和 WSL-style validation。 + +10. 能力真假开始从 Linux 私有概念上移到公共合约。 + +`modules/core_sys/include/platform/ui/capability_status.h` 已经成为 `Unsupported` / `Simulated` / `Available` / `Degraded` / `Error` 的共享类型来源,Linux 侧 `platform/linux/capability_status.h` 退化为 re-export。LoRa/SSTV/Walkie 的 public header 已经公开 `capability_status()`,这是好方向;下一步要更新 contract inventory,并让 UI 真正消费这些状态,而不是只在实现层或文档层停留。 + +### 2.4 主要未完成项 + +1. 真机主路径尚未闭环。 + +当前有两个 device path: + +- CMake custom framebuffer path:接了 shared shell,并已把 evdev 输入源文件接入 rpi target;但还没有通过 Linux 构建和真机操作验收。 +- `M5Stack_Linux_Libs` SCons path:更接近未来真机主路径,目前已具备 fbdev 自动探测、LVGL evdev、启动日志和 bring-up UI,但还没有运行 Trail Mate shared shell。 + +这两个路径需要收敛:优先让 SDK path 跑 shared shell,同时保留 custom framebuffer path 作为轻量 fallback 或验证工具。 + +2. LVGL ownership 已经拆出雏形,但还未验收。 + +当前 `ShellSession`、`CanvasLvglHost`、`NativeLvglHost` 的职责划分比上一版正确很多。新的问题是: + +- `shell_ui_runner.h` 已前向声明 `lv_area_t`、`lv_indev_data_t`,callback 已恢复为 LVGL 精确类型签名。后续必须通过完整构建确认这些前向声明是否与 LVGL 自身 typedef 兼容;若 LVGL 使用匿名 typedef,最稳妥的做法是把 callback 改成 `.cpp` 私有自由函数或直接在 header 包含 `lvgl.h`。 +- `readInputCb()` 的事件吞掉问题已经通过 `hasPendingKeyEvent()` 修掉,但需要用测试或真机输入回归确认。 +- `NativeLvglHost` 只是 thin host,还没有被 `M5Stack_Linux_Libs` main loop 消费。 + +3. 输入已经开始适配设备,但有阻断点。 + +SDL 模拟器有完整键盘和鼠标映射。Pi CMake framebuffer path 现在通过 `EvdevInput` 返回 `InputEvent`,这是关键进展。上一轮的 source wiring 和目录探测问题已基本修掉。剩余必须修: + +- `EvdevInput` 的 `kKeyMap` 已改为 `std::to_array({...})`,手写数量和 CTAD 风险已消除;后续重点是 Linux 构建确认与真机键位采样。 +- Cardputer Zero 内置键盘的 Fn/组合键/方向键需要用真机事件码采样后建立映射表,不要只依赖 PC keyboard key code。 + +4. 能力真假还需要制度化。 + +例如: + +- `lora::is_supported()` 当前返回 true,但实现是 synthetic RSSI。 +- `walkie::is_supported()` 当前返回 true,但实现是 synthetic 音量/收发电平。 +- `sstv::is_supported()` 当前返回 true,但实现是生成 PPM 模拟图。 +- `wifi::is_supported()`、`usb_support::is_supported()`、`firmware_update::is_supported()` 返回 false,这类比较诚实。 + +本轮新增 `CapabilityState`/`CapabilityStatus` 是正确方向,而且最新代码已经把它放进 `modules/core_sys/include/platform/ui/capability_status.h`,这意味着它开始成为平台合约。LoRa/SSTV/Walkie 的 public header 已经声明 `capability_status()`。剩余问题是:`modules/core_sys/include/platform/ui/README.md` 的 contract inventory 还没有列出 `capability_status.h`;共享 UI 还没有用该状态替代“只看 `is_supported()`”。后续需要制度化区分 `Unsupported`、`Simulated`、`Available`、`Degraded`、`Error`,否则 UI 和用户预期会失真。 + +5. 构建描述重复已经缓解,但 build gate 尚未通过。 + +`cmake/TrailMateLinuxSources.cmake` 已经解决了最大块的 source list 重复。剩余问题是: + +- rpi-only 源文件已经有显式接入点,后续可考虑抽成 rpi adapter target,但不必为了形式立即重构。 +- simulator main 已显式设置默认 `TRAIL_MATE_RUNTIME_MODE=demo`,这比让 common target define 决定 simulator 默认行为更清楚。最新代码已把默认写入放到 `std::getenv("TRAIL_MATE_RUNTIME_MODE")` guard 后面,Windows 和 POSIX 语义已经对齐;后续还要用启动 smoke 验证用户预设不会被覆盖,并确认 device shell 默认 `local`,让真实设备 composition 不再默认使用 dummy/loopback。 + +6. 文件路径安全已经改善,但还没有覆盖所有写入点。 + +`route_storage::remove_route()`、`tracker::remove_track()` 已经开始用 `resolve_child_under_root()`。最新推进又把 hostlink、team store、SSTV output、map tiles 的默认 root 收到了 `runtime_paths`。下一步要把同一套 path helper 扩展到 pack repository、settings temp file 等路径写入点,并清理 map/hostlink/team/SSTV 中已经无实际用途的旧 env 常量。`safe_write_under_root()` 的 `.cpp` 注释已经改成 no-fsync temp+rename,但 header 仍写 fsync;要么补真实 fsync / `FlushFileBuffers`,要么把 header 注释改成准确描述。 + +7. demo/loopback 逻辑只完成了抽取起点。 + +`linux_demo_world.*` 已经出现,demo peer seeding 已经通过 `runtime_mode` gate;但 `MinimalLinuxAppFacade` 里仍保留旧 `LinuxLoopbackMeshAdapter`、dummy crypto、loopback pairing service 等真实设备不应默认启用的逻辑。`runtime_mode` 还没有真正决定整个 facade composition。 + +8. 真机安全和网络默认值需要持续保持谨慎。 + +`hostlink` 当前已经默认 bind 到 `127.0.0.1`,这比上一版更适合真机安全默认值。需要对外监听时应显式设置 `TRAIL_MATE_HOSTLINK_BIND=0.0.0.0`,并在 UI 或文档里说明这会把端口暴露到设备所在网络。 + +## 3. 目标结构规划 + +### 3.1 最终目录职责 + +推荐目标结构保持如下: + +```text +trail-mate/ + apps/ + linux_sim/ # 桌面模拟器、WSL、dev-container、host tooling + linux_rpi/ # Cardputer Zero / Pi OS 真机 app shell + linux_unoq/ # 未来 UNO Q Linux shell + + modules/ + core_sys/ # 系统合约、clock、portable utilities + core_chat/ # chat domain/usecase/protocol-neutral infra + core_gps/ # GPS domain/filter/policy + core_team/ # team domain/protocol/usecase + core_hostlink/ # hostlink protocol/session + ui_shared/ # LVGL shared UI and presentation + + platform/ + linux/ + common/ # Linux-safe implementations shared by sim and rpi + rpi/ # Pi OS/Cardputer Zero specific adapters + unoq/ # UNO Q specific adapters when introduced + esp/ + ... + shared/ + ... +``` + +### 3.2 依赖方向 + +合法方向: + +```text +apps/linux_* -> platform/linux/* +apps/linux_* -> modules/ui_shared +apps/linux_* -> modules/core_* + +modules/ui_shared -> modules/core_* +modules/ui_shared -> platform contracts in modules/core_sys/include/platform/ui/* + +platform/linux/* -> platform contracts +platform/linux/* -> OS / SDK / drivers + +modules/core_* -> standard C/C++ and portable module dependencies only +``` + +非法方向: + +```text +modules/core_* -> platform/linux/* +modules/core_* -> platform/esp/* +modules/ui_shared -> platform/linux/* +modules/ui_shared -> platform/esp/* +platform/linux/common -> apps/linux_sim/* +platform/linux/common -> apps/linux_rpi/* +apps/linux_rpi -> apps/linux_sim/* +``` + +### 3.3 建议新增的结构 + +后续可以逐步增加这些结构,不要求一次性完成: + +```text +cmake/ + TrailMateLinuxSources.cmake + TrailMateModuleTargets.cmake + +modules/core_sys/include/platform/ui/ + capability_status.h + +platform/linux/common/include/platform/linux/ + runtime_paths.h + capability_status.h # re-export only; shared contract lives in modules/core_sys + env_config.h + +platform/linux/common/src/platform/linux/ + runtime_paths.cpp + env_config.cpp + safe_file_ops.cpp + +platform/linux/rpi/src/platform/device/ + evdev_input.cpp + evdev_input.h + m5stack_lvgl_shell_host.cpp + m5stack_lvgl_shell_host.h + +platform/linux/common/include/ui/ + shell_session.h + +platform/linux/common/src/ui/ + shell_session.cpp + canvas_lvgl_host.cpp + native_lvgl_host.cpp +``` + +核心意图: + +- CMake source ownership 要集中。 +- path/env/capability 不要在十几个 runtime 文件里重复。 +- LVGL session 要和 LVGL display owner 分离。 +- 真实设备输入要成为 `platform/linux/rpi` 的 adapter,不要写进 shared UI。 + +## 4. 接下来要做什么 + +先看本轮回归后的优先级,不要直接沿用上一版 backlog 的顺序: + +| 优先级 | 事项 | 为什么现在要先做 | 完成标准 | +| --- | --- | --- | --- | +| P0-A | 完成构建闭环 | 主要源码红灯已修,下一步需要用真实构建确认 common/ui/rpi/test 都成立 | sim common、ui shell、path safety smoke、rpi framebuffer target 都能编译 | +| P0-B | 回归 Shell input callback | 事件吞掉问题已修成 peek 语义,但需要验证 press/release 是否都被 LVGL 收到 | 单个 `InputEvent` enqueue 后 LVGL 能看到 press 和 release | +| P0-C | 验证 evdev path 正常参与 rpi 构建 | `evdev_input.cpp` 已进入 rpi target,但还没有 Linux/CI build 结果 | rpi framebuffer target 编译通过;无输入设备时只降级不崩溃 | +| P0-D | 校准 docs/spec 的 DONE 状态 | app-local specs 里有些 DONE 是目标态,不是已验收事实 | 文档中的 DONE 只代表经过 build/test/运行验证的事实 | +| P0-E | 验证 simulator runtime mode 覆盖语义 | 代码已改为只在 env 未设置时填入 `demo`,Windows `_putenv_s()` 也已进入 guard | Windows 与 POSIX 都通过启动 smoke 证明用户预设不会被覆盖 | +| P1 | 继续 CMake helper 收口 | helper 已存在,下一步是让它稳定、可维护、可 CI 检查 | 添加 shared source 只改一处;sim/rpi CI 都绿 | +| P2 | 完成 LVGL host/session 分层 | 这是接 SDK path 的前提 | simulator/custom fbdev 使用 `CanvasLvglHost`,SDK path 使用 `NativeLvglHost` | +| P3 | 真机输入采样与映射 | evdev adapter 只解决了读事件,还没证明 Cardputer Zero 内置键盘语义 | 有事件码采样记录、映射表、真机导航验收 | +| P4 | 统一 runtime path/env/capability | 基础 helper 已覆盖更多 runtime,map tiles 已收口;pack repository、旧 env 常量和部分注释仍未收口 | 所有文件 runtime 走同一套 root/path helper;各 runtime public header 能报告 capability status;synthetic 能力不再冒充真实 | +| P5 | demo world 与真实 facade 分离 | 新文件已出现,但 composition 未变 | `SimulatorDemo`、`DeviceLocal`、`DeviceRealMesh` 使用明确不同的 wiring | +| P6 | SDK path 接 shared shell | SDK bring-up 已加强,正好可以切换到 `NativeLvglHost` | `apps/linux_rpi/main/src/main.cpp` 不再是 bring-up UI,而是 Trail Mate shared shell | + +### P0:把现状文档和 specification 校准 + +目标:文档不能继续描述已经修掉的债务,也不能把当前 demo 能力写成真机能力。 + +要做: + +- 保留 `apps/linux_rpi/docs/specification/*` 作为历史和 target-specific specification。 +- 以本文作为顶层 Linux 适配总指南。 +- 更新旧文档中已经过期的“known current violations”,例如 `ui_shared/library.json` 的 ESP include 依赖已经不再成立。 +- 在 README 中明确本文与 app-local specs 的层级关系。 + +验收: + +- 新同事读 `docs/LINUX_ADAPTATION_GUIDE.md` 能判断文件应该放哪里。 +- 旧文档不再误导工程师去修已不存在的问题。 + +### P1:消除 Linux CMake source list 重复 + +回归状态:已开始落地。`cmake/TrailMateLinuxSources.cmake` 已经承担 shared source list、include roots 和 target helper,rpi-only `evdev_input.cpp` 也已显式接入 device target,helper 的 repo root 注释已经校准。下一步不是重新设计,而是确认 target define 语义,并让 simulator/rpi 两条构建都通过。 + +目标:让 `linux_sim` 与 `linux_rpi` 共享同一套 CMake source/target 定义。 + +建议做法: + +1. 新建 `cmake/TrailMateLinuxSources.cmake`。 +2. 抽出这些变量: + +```cmake +set(TRAIL_MATE_LINUX_COMMON_SOURCES + "${TRAIL_MATE_LINUX_COMMON_ROOT}/app/linux_app_facade.cpp" + "${TRAIL_MATE_LINUX_COMMON_ROOT}/platform/ui/settings_store.cpp" + ... +) + +set(TRAIL_MATE_UI_SHARED_SOURCES + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/app_runtime.cpp" + ... +) +``` + +3. 提供 helper: + +```cmake +function(trailmate_add_linux_common target_name) + add_library(${target_name} STATIC ${TRAIL_MATE_LINUX_COMMON_SOURCES}) + target_include_directories(${target_name} PUBLIC ...) + target_compile_features(${target_name} PUBLIC cxx_std_20) +endfunction() + +function(trailmate_add_linux_ui_shell target_name common_target) + add_library(${target_name} STATIC ${TRAIL_MATE_UI_SHARED_SOURCES}) + target_link_libraries(${target_name} PUBLIC ${common_target} lvgl) +endfunction() +``` + +4. `apps/linux_sim/CMakeLists.txt` 只保留 simulator 特有 SDL target。 +5. `apps/linux_rpi/CMakeLists.txt` 只保留 device 特有 framebuffer target。 + +注意: + +- 先抽 source list,不急着把所有 `modules/core_*` 都变成独立 CMake target。 +- 抽完后确保 CI、WSL validate、Windows VS preset 仍能工作。 + +验收: + +- 添加一个 shared UI 源文件时,只需要改一处 CMake source list。 +- simulator 与 rpi CMake target 使用同一批 common/ui sources。 + +### P2:拆分 LVGL shell session 与 display owner + +回归状态:已开始落地。`ShellSession`、`CanvasLvglHost`、`NativeLvglHost` 的形状是对的;input dequeue 语义已修成 peek,LVGL callback 也已恢复为精确 typed signature。当前还需要完整构建确认 LVGL typedef/前向声明兼容性,并接入 SDK main loop。 + +目标:同一套 Trail Mate shared shell 可以跑在 SDL/canvas,也可以跑在 `M5Stack_Linux_Libs` 已创建的 LVGL display 上。 + +当前问题: + +- `ShellUiRunner` 负责 `lv_init()`、display 创建、buffer 创建、input 创建、app facade、startup、tick、canvas copy。 +- 这对模拟器方便,但对 SDK path 不优雅,因为 SDK 已经拥有 fbdev/evdev/LVGL backend。 + +建议拆法: + +```text +ShellSession + - 绑定 MinimalLinuxAppFacade 或未来 LinuxAppFacade + - setTeamUiEventDispatcher + - SharedUiShellStartup begin/tick + - app facade update/tick/dispatch + - lv_timer_handler 前后的共享逻辑 + - 不创建 display,不拥有 lv_init/lv_deinit + +CanvasLvglHost + - 给 simulator/custom fbdev 用 + - 拥有 lv_init/lv_deinit + - 创建 LVGL display 和 RGB565 buffer + - flush 后复制到 Canvas + +NativeLvglHost + - 给 M5Stack SDK path 用 + - 不拥有 display + - 使用 SDK 已创建的 LVGL display/input + - 只驱动 ShellSession tick +``` + +实现细节: + +- `lv_init()` 每个进程只能有清晰 owner。不能 simulator、SDK、shell session 各自调用。 +- `lv_deinit()` 只能由 owner 调。 +- `ShellSession` 析构时只解绑 facade 和 dispatcher,不删除它不拥有的 display/input。 +- `ShellSession::tick()` 不应该 sleep。sleep 由 host runner 决定。 +- `CanvasLvglHost` 继续用 16 ms frame pacing。 +- `NativeLvglHost` 在 SDK main loop 里按 SDK 推荐 tick cadence 调用。 + +验收: + +- simulator 仍可跑。 +- CMake custom framebuffer shell 仍可跑。 +- SDK `main/src/main.cpp` 能从 bring-up UI 切到 Trail Mate shared shell。 + +### P3:实现真机输入适配 + +回归状态:已开始落地。`EvdevInput` 已经出现并被 `LinuxFramebufferPlatform::drainInput()` 调用,rpi CMake source wiring、by-path/by-id 目录探测异常、key map 初始化风险都已修。当前必须完成 Linux 构建确认和真机键位采样。 + +目标:Cardputer Zero 在 Pi OS 上能通过内置键盘/按钮操作 shared shell。 + +当前状态: + +- SDL simulator 输入完整。 +- CMake framebuffer device shell 已开始接 `EvdevInput`,但还未通过编译和真机按键验收。 +- SDK bring-up path 有 `LV_LINUX_KEYBOARD_DEVICE` 和 evdev hint,但尚未进入 Trail Mate 输入语义。 + +建议路线: + +1. 短期:在 SDK path 使用 LVGL evdev keypad。 + +适合快速真机 bring-up。`apps/linux_rpi/main/src/main.cpp` 已经具备类似结构: + +```cpp +lv_indev_t* keyboard = lv_evdev_create(LV_INDEV_TYPE_KEYPAD, keyboard_device); +lv_indev_set_display(keyboard, display); +``` + +需要补: + +- 默认设备发现不要只写死某个 `/dev/input/by-path/...`。 +- 支持 `LV_LINUX_KEYBOARD_DEVICE` 显式覆盖。 +- 记录检测日志,失败时不要静默。 + +2. 中期:在 `platform/linux/rpi` 做 evdev -> `InputEvent` adapter。 + +适合 custom framebuffer path 和未来 UNOQ path: + +```text +platform/linux/rpi/src/platform/device/evdev_input.{h,cpp} +``` + +职责: + +- nonblocking open evdev file。 +- 读取 `struct input_event`。 +- 处理 `EV_KEY` press/release/repeat。 +- 映射 Linux key code 到 `trailmate::cardputer_zero::app::InputEvent`。 +- 允许 env 覆盖:`TRAIL_MATE_INPUT_DEVICE`。 +- 支持多个候选路径:`/dev/input/by-path/*-event-kbd`、`/dev/input/event*`。 +- 不把 evdev 头文件暴露到 `platform/linux/common`。 + +映射建议: + +| Linux key | Trail Mate key | +| --- | --- | +| `KEY_ESC` | `Power` 或 back,取决于设备语义 | +| `KEY_ENTER` | `Enter` | +| `KEY_BACKSPACE` | `Backspace` | +| `KEY_TAB` | `Tab` | +| `KEY_HOME` | `Home` | +| `KEY_END` | `Next` | +| `KEY_LEFT/RIGHT/UP/DOWN` | directional keys | +| printable ASCII | `Character` | +| `KEY_LEFTSHIFT/RIGHTSHIFT` | `Shift` | +| `KEY_LEFTCTRL/RIGHTCTRL` | `Ctrl` | +| `KEY_LEFTALT/RIGHTALT` | `Alt` | + +实现细节: + +- 输入 adapter 不应该直接调用 LVGL。 +- `SurfacePresenter::drainInput()` 可以短期继续承载输入队列,但长期可拆出 `InputSource`。 +- repeat 策略要明确:导航键可以 repeat,文本输入可以按系统 repeat,modifier 不 repeat。 +- 字符输入必须考虑 shift。短期可用简单 US keyboard map;长期如需中文/IME,应走 `ui_shared` IME。 + +验收: + +- 真机按键能进入菜单、打开页面、返回、输入文本。 +- `TRAIL_MATE_INPUT_DEVICE=/dev/input/eventX` 可覆盖设备。 +- 没有输入设备时,device shell 明确日志提示并继续显示 UI。 + +### P4:集中 Linux runtime path/env/capability + +回归状态:部分落地且本轮继续推进。`runtime_paths.*`、`env_config.*` 已出现,route/tracker 的删除路径开始做 root containment;settings、hostlink、team store、SSTV、map tiles 也已经开始通过 `runtime_paths` 取默认 root。下一步是把剩余文件写入点和 env parsing 收到同一套 helper,并把 path safety smoke 纳入稳定测试。 + +目标:避免每个 runtime 文件重复实现 `TRAIL_MATE_SD_ROOT`、`TRAIL_MATE_SETTINGS_ROOT`、`HOME`、`APPDATA` fallback。 + +当前仍需继续收口的重复点: + +- `map_tiles.cpp` 已改用 `runtime_paths`,但旧的 `kSdRootEnv` / `kSettingsRootEnv` 常量还残留,容易造成误读或编译告警 +- `pack_repository_runtime.cpp` 仍默认从 `__FILE__` 推 repo root,没有进入 runtime root 模型 +- `settings_store.cpp` 已用 `settings_file()`,但 temp file 写入还没有复用 `safe_write_under_root()` +- `route_storage.cpp` 和 `tracker_runtime.cpp` 已部分改用 path helper,但还需统一写入和 list 语义 +- `device_runtime.cpp`、`hostlink_runtime.cpp`、`team_ui_store_runtime.cpp`、`sstv_runtime.cpp` 中仍有一些旧 env 常量或注释需要清理,避免后来者误判真实配置来源 +- `runtime_paths.h` 仍写着 `safe_write_under_root()` 会 fsync,但 `.cpp` 已明确说当前只是 close + atomic rename + +已有基础形态,后续保持这个方向扩展: + +```cpp +namespace platform::linux_runtime +{ +struct RuntimePaths +{ + std::filesystem::path settings_root; + std::filesystem::path sd_root; + std::filesystem::path cache_root; + std::filesystem::path state_root; +}; + +RuntimePaths resolve_paths(); +std::filesystem::path settings_file(const char* ns); +std::filesystem::path sd_child(std::string_view relative); +bool resolve_child_under_root(const std::filesystem::path& root, + std::string_view relative, + std::filesystem::path& out); +} +``` + +路径策略: + +- `TRAIL_MATE_SETTINGS_ROOT` 显式指定 settings/state 根目录。 +- `TRAIL_MATE_SD_ROOT` 显式指定模拟 SD 卡根目录。 +- Linux host 默认可使用 `$HOME/.trailmate_cardputer_zero`,后续可演进到 XDG。 +- Windows simulator 默认可继续使用 `%APPDATA%/TrailMateCardputerZero`。 +- 真机 system service 模式后续应支持 `/var/lib/trail-mate` 或用户配置目录,但不要现在硬编码。 + +安全规则: + +- UI 删除 route/track 时只接受文件名或相对 ID,不接受任意 absolute path。 +- 平台层必须做 root containment 检查。 +- 拒绝 `..`、absolute path、空名字、路径分隔符混入 ID。 +- 删除前 resolve canonical/weakly_canonical,确认结果仍在 root 下。 + +验收: + +- 所有 Linux runtime 使用同一套 path helper。 +- route/tracker/team/sstv/hostlink/map/pack repository 不再各自复制 storage root 逻辑。 +- 路径安全有单元测试。 + +### P5:建立能力真实性模型 + +回归状态:概念已进入公共合约种子,并且 LoRa/Walkie/SSTV 已经公开 public API。`CapabilityState`/`CapabilityStatus` 已上移到 `modules/core_sys/include/platform/ui/capability_status.h`,Linux 侧 helper 只做 re-export,`lora_runtime.h`、`walkie_runtime.h`、`sstv_runtime.h` 已声明 `CapabilityStatus capability_status()`,实现层开始返回 `Simulated`。下一步是让 contract inventory 和 UI 呈现跟上。 + +目标:UI 与用户文档能区分“不支持”“模拟支持”“真实支持”“降级支持”。 + +建议新增统一状态: + +```cpp +enum class CapabilityState +{ + Unsupported, + Simulated, + Available, + Degraded, + Error, +}; + +struct CapabilityStatus +{ + CapabilityState state; + const char* message; +}; +``` + +短期不需要一次性改完所有合约,但不能长期停在“API 存在但无人消费”的状态。建议先做三件小事: + +- 在 `modules/core_sys/include/platform/ui/README.md` 的 contract inventory 中列出 `capability_status.h`。 +- 保持 `lora_runtime.h`、`walkie_runtime.h`、`sstv_runtime.h` 的声明与 Linux 实现一致,并在后续新增 runtime 时沿用同一模式。 +- 在至少一个共享 UI status/footer 或 diagnostics 页面中展示 `Simulated`,验证 UI 不再把 synthetic runtime 当成真实支持。 + +最终最好让关键 runtime 暴露状态: + +- Wi-Fi +- GPS +- LoRa +- Walkie +- SSTV +- Hostlink +- USB mass storage +- Firmware update +- Battery/power +- Display/input + +当前建议分类: + +| 功能 | 当前代码状态 | 建议能力状态 | +| --- | --- | --- | +| Settings store | file-backed | Available | +| Time offset | settings + system clock | Available | +| Screen timeout | soft idle state | Degraded | +| Device memory | `/proc/meminfo` on Linux | Available/Degraded | +| Battery | env only | Simulated | +| GPS default data | env/default | Simulated | +| GPS NMEA file/serial | parser + Linux serial | Degraded to Available, 取决于设备 | +| Hostlink TCP | local TCP server,默认 loopback | Available, 对外监听必须显式配置 | +| Route/tracker storage | file-backed | Available with safety fix | +| Team store | memory + GPX append | Degraded | +| LoRa RSSI | synthetic | Simulated | +| Walkie | synthetic | Simulated | +| SSTV | generated frame | Simulated | +| Wi-Fi | unsupported | Unsupported | +| USB mass storage | unsupported | Unsupported | +| Firmware update | unsupported | Unsupported | +| Orientation | empty | Unsupported | + +验收: + +- UI 不会把 synthetic LoRa/walkie/SSTV 展示成真实硬件能力。 +- 文档和 status message 能解释为什么不可用或仅模拟。 + +### P6:拆出真实 Linux app facade 与 demo world + +回归状态:完成了第二步。`linux_demo_world.*` 已新增,demo seeding 已由 `runtime_mode` gate;但 facade 仍然直接拥有 loopback mesh、dummy crypto、loopback pairing。下一步要让 runtime mode 决定完整 composition,而不是让真实设备默认继承 simulator demo world 的通信与加密假实现。 + +目标:把“为模拟器好用的假世界”和“真实设备 facade”分开。 + +当前 `MinimalLinuxAppFacade` 同时做了: + +- app config persistence +- service composition +- demo peers seed +- loopback mesh adapter +- team pairing loopback +- dummy team crypto +- event bus bridge +- fallback team UI updates + +建议拆分: + +```text +platform/linux/common/src/app/ + linux_app_facade.cpp + linux_app_facade.h + linux_demo_world.cpp + linux_demo_world.h + linux_loopback_mesh_adapter.cpp + linux_loopback_mesh_adapter.h + linux_team_runtime.cpp + linux_team_runtime.h + linux_app_composition.cpp + linux_app_composition.h +``` + +然后区分 composition mode: + +```cpp +enum class LinuxRuntimeMode +{ + SimulatorDemo, + DeviceLocal, + DeviceRealMesh, +}; +``` + +关键要求: + +- dummy crypto 只能在 `SimulatorDemo` 使用。 +- real device path 禁止使用 XOR crypto 伪实现。 +- demo peer seed 只能由 simulator 或显式 demo mode 启用。 +- app facade 不应该知道“显示器是 SDL 还是 fbdev”。 + +验收: + +- 启动 simulator 仍有 demo 数据。 +- 启动 device shell 默认不注入假 peer,除非显式 `TRAIL_MATE_DEMO_WORLD=1`。 +- dummy crypto 不会进入真机 release build。 + +### P7:让 `M5Stack_Linux_Libs` 成为真机主路径 + +目标:真实 Cardputer Zero 设备路径优先使用 SDK 的 LVGL/fbdev/evdev/device plumbing。 + +步骤: + +1. 保留 `apps/linux_rpi/main/src/main.cpp` 里的 display/input detect 逻辑,但把 bring-up UI 替换成 shared shell session。 +2. 在 SDK main 中: + +```cpp +lv_init(); +initLinuxDisplay(); + +trailmate::cardputer_zero::linux_ui::ShellSession shell; +shell.begin(); + +while (true) +{ + shell.tick(); + lv_timer_handler(); + usleep(1000); +} +``` + +实际代码以拆分后的 `ShellSession` API 为准。 + +3. `config_defaults.mk` 继续启用: + +```text +CONFIG_V9_5_LV_USE_LINUX_FBDEV=y +CONFIG_V9_5_LV_USE_EVDEV=y +``` + +4. 自动检测 framebuffer: + +- 优先 `LV_LINUX_FBDEV_DEVICE` +- 其次 `/proc/fb` 中的 `fb_st7789v` +- 最后 `/dev/fb0` + +5. 自动检测 keyboard: + +- 优先 `LV_LINUX_KEYBOARD_DEVICE` +- 其次 by-path/by-id 候选 +- 最后明确报错但不中断显示 + +验收: + +- `bash apps/linux_rpi/scripts/build-sdk-device.sh` 能构建 shared shell。 +- 真机能显示 boot/menu shell。 +- 真机键盘能导航。 +- 不再用 SDK bring-up placeholder 文案作为默认 UI。 + +### P8:选择第一个真实 feature verification slice + +建议第一个真实页面选择 `Settings`,原因: + +- 它依赖 settings/time/screen/device 这些基础合约。 +- 它不需要真实 radio、GPS、audio、network。 +- 它能验证 shared UI、平台合约、持久化、输入、能力状态。 +- 它能暴露 Linux common runtime 的真实质量。 + +Settings slice 应验证: + +- timezone offset 保存/读取。 +- screen timeout 保存/读取。 +- notification volume 保存/读取。 +- battery/GPS/Wi-Fi/USB/FOTA 能力状态正确显示。 +- 重启 simulator/device 后设置仍存在。 +- 无设备能力时 UI 不出现不可执行动作。 + +验收: + +- simulator 通过 Settings 页面进行真实设置。 +- rpi SDK path 通过 Settings 页面进行真实设置。 +- smoke test 或 integration test 验证 settings persistence。 + +## 5. 优雅适配 Linux 的原则 + +### 5.1 平台合约先于平台实现 + +如果一个功能需要被 `ui_shared` 使用,先确认它是否属于已有 `platform::ui::*` 合约。 + +- 已有合约足够:只加 Linux 实现。 +- 合约缺字段:先扩展合约,再同时审视 ESP/Linux 实现。 +- 没有合约:先判断这是共享能力、平台能力、还是 app shell 私有能力。 + +不要从 Linux 实现反推共享 API。实现方便不等于抽象正确。 + +### 5.2 app shell 保持薄 + +`apps/linux_sim` 和 `apps/linux_rpi` 应该主要做: + +- main/entrypoint +- build configuration +- target-specific composition +- process lifecycle +- CLI/env parsing +- selecting adapters + +不要在 app shell 里长期放: + +- business state +- page layout +- protocol handling +- storage format +- radio/GPS/audio logic + +### 5.3 common 只放真正共享的 Linux-safe 代码 + +进入 `platform/linux/common` 的标准: + +- simulator 和 rpi 都需要。 +- 不依赖 SDL。 +- 不依赖 fbdev/evdev/ioctl。 +- 不依赖具体 device path。 +- 不含真机特定假设。 +- 不拥有 UI 页面结构。 + +如果只是 desktop simulator 需要,放 `apps/linux_sim`。 + +如果只有 Pi OS/Cardputer Zero 需要,放 `platform/linux/rpi` 或 `apps/linux_rpi`。 + +### 5.4 真实、模拟、unsupported 必须诚实 + +Linux 早期开发需要大量模拟能力,这是合理的。但必须诚实标注。 + +推荐规则: + +- synthetic runtime 可用于 simulator。 +- synthetic runtime 可用于真机 debug,但必须显式 opt-in。 +- 真机默认 UI 不应把 synthetic runtime 展示为硬件能力。 +- `is_supported()` 不要表示“有代码路径”,而要表示“此目标下用户可合理使用”。 + +### 5.5 不用 `#ifdef` 替代边界 + +允许的 `#if defined(...)`: + +- 在平台实现 `.cpp` 内隔离 OS API,例如 hostlink socket、GPS serial、gmtime。 +- 在 build system 中选择 target。 +- 在少量 cross-host simulator 代码里处理 Windows/Linux 差异。 + +不推荐的 `#if defined(...)`: + +- 在 `modules/core_*` 里判断 Linux/ESP。 +- 在 `modules/ui_shared` 页面里判断 Linux/ESP。 +- 在业务逻辑里判断设备型号。 + +### 5.6 SDK 是设备依赖,不是架构来源 + +`M5Stack_Linux_Libs` 应该帮我们少写 fbdev、evdev、LVGL glue。 + +它不应该决定: + +- Trail Mate 的模块边界。 +- app/page 结构。 +- core/usecase 的接口。 +- storage/schema。 +- Linux common 的职责。 + +### 5.7 能用测试守住的边界就写测试 + +文档是必要的,但不够。每个容易退化的点都应尽量有自动检查: + +- include 污染:已有 boundary check。 +- path 安全:新增 unit test。 +- capability truth:新增 status test。 +- CMake source ownership:通过 shared cmake helper 降低人工错误。 +- simulator/device 构建:CI 继续跑。 +- SDK path:至少提供可选 CI 或 manual verification script。 + +## 6. 新功能开发流程 + +任何新的 Linux 适配功能都按这个流程走。 + +### Step 1:先分类 + +先回答: + +- 这是 domain/usecase/protocol 吗?是则属于 `modules/core_*`。 +- 这是 LVGL 共享页面/组件吗?是则属于 `modules/ui_shared`。 +- 这是平台合约吗?是则属于 `modules/core_sys/include/platform/ui/*`。 +- 这是 Linux 实现吗?是则属于 `platform/linux/common` 或 `platform/linux/rpi`。 +- 这是进程启动/目标选择/build wiring 吗?是则属于 `apps/linux_*`。 + +### Step 2:确认合约 + +检查 `modules/core_sys/include/platform/ui/*`。 + +如果已有合约: + +- 不新增平行 util。 +- 不在 UI 里直接访问 Linux 文件、socket、env。 + +如果没有合约: + +- 先设计最小合约。 +- 合约只表达共享语义,不表达 Linux 细节。 + +示例: + +```cpp +namespace platform::ui::wifi +{ +bool is_supported(); +bool load_config(Config& out); +bool save_config(const Config& config); +Status status(); +} +``` + +合约里不应该出现: + +```cpp +std::filesystem::path; +int fd; +struct input_event; +sockaddr_in; +lv_indev_t*; +``` + +### Step 3:实现 Linux common 或 rpi adapter + +放置规则: + +| 情况 | 位置 | +| --- | --- | +| file-backed settings | `platform/linux/common/src/platform/ui/settings_store.cpp` | +| generic host TCP transport | `platform/linux/common/src/platform/ui/hostlink_runtime.cpp` | +| generic NMEA parser | `platform/linux/common/src/platform/ui/gps_runtime.cpp` 或拆出 helper | +| `/dev/input/event*` | `platform/linux/rpi/src/platform/device` | +| `/dev/fb*` mmap/ioctl | `platform/linux/rpi/src/platform/device` | +| SDL geometry/input | `apps/linux_sim/src/platform/simulator` | +| M5Stack SDK glue | `apps/linux_rpi/main` 或 `platform/linux/rpi` | + +### Step 4:UI 通过合约消费 + +`modules/ui_shared` 只能: + +- 调 `platform::ui::` 合约。 +- 根据 status/capability 渲染 enabled/disabled/unsupported state。 +- 触发合约动作。 + +不能: + +- 读 Linux env。 +- 打开文件。 +- 访问 `/dev/*`。 +- 引入 `platform/linux/*` 头。 + +### Step 5:添加测试 + +最少测试层级: + +- contract smoke:是否能调用、是否返回合理默认状态。 +- persistence test:设置是否能保存/读取。 +- path safety test:非法路径是否被拒绝。 +- simulated data test:GPS NMEA、hostlink、tracker、team 等。 +- UI smoke:如果功能影响 shell,确保 simulator boot/menu/page 不崩。 + +Linux tests 当前可放: + +```text +apps/linux_sim/tests/ +``` + +随着模块化增强,可逐步迁移到: + +```text +tests/linux/ +tests/modules/ +``` + +### Step 6:更新构建 + +短期: + +- 改 shared CMake source helper。 +- 确保 `apps/linux_sim` 和 `apps/linux_rpi` 都用同一处 source list。 + +中期: + +- 把 `modules/core_*` 变成 CMake targets。 +- `ui_shared` 也成为 target。 +- app shell 只 link targets,不手写内部文件。 + +### Step 7:更新文档 + +每个 Linux 适配 slice 完成后,至少更新: + +- 本文的当前状态或 backlog。 +- 对应 feature 文档。 +- app README 中的运行方式。 +- 如果能力状态变化,更新 capability table。 + +## 7. 关键实现细节指南 + +### 7.1 CMake + +当前问题是两个 Linux app 都手写大 source list。优雅做法是集中 source ownership。 + +短期推荐: + +```text +cmake/TrailMateLinuxSources.cmake +``` + +并在两个 app 中: + +```cmake +include("${PROJECT_SOURCE_DIR}/../../cmake/TrailMateLinuxSources.cmake") + +trailmate_add_linux_common(trailmate_cardputer_zero_linux_common) +trailmate_add_linux_ui_shell(trailmate_cardputer_zero_ui_shell trailmate_cardputer_zero_linux_common) +``` + +注意: + +- `FetchContent` 的 SDL3 只应在 simulator target 中出现。 +- `FetchContent` 的 LVGL 可以由 shared helper 提供,但 SDK path 不一定走这个 LVGL。 +- `apps/linux_rpi` CMake path 可以继续使用 fetched LVGL,SDK SCons path 用 SDK LVGL。 +- 不要让 root `CMakeLists.txt` 被 Linux 目标劫持;root 仍有 ESP-IDF 历史职责。 + +### 7.2 LVGL + +LVGL 适配最容易失控,必须明确 owner。 + +规则: + +- 一个进程中 `lv_init()` 只能由一个 host layer 调。 +- 一个 display 的 buffer 和 flush callback 只由创建者拥有。 +- `ui_shared` 创建 object graph,不拥有 Linux display。 +- `ShellSession` 只管 Trail Mate session 生命周期,不管 OS display。 +- simulator 可以通过 Canvas host 把 LVGL RGB565 buffer 拷到 SDL shell。 +- 真机 SDK path 应用 native LVGL display,不走 Canvas copy。 + +需要避免: + +- 在 `ui_shared` 中 include Linux headers。 +- 在每个页面里写 Linux 特例。 +- 在 SDK path 再套一层 Canvas,导致性能和职责都变差。 + +### 7.3 输入 + +当前 shared shell 输入语义是 `InputEvent` 到 LVGL key 的映射。后续保持这个模型即可。 + +实现建议: + +- simulator:继续 SDL event -> `InputEvent`。 +- custom framebuffer path:evdev -> `InputEvent` -> `ShellSession`。 +- SDK native path:优先用 LVGL evdev;如需要统一行为,再引入 evdev -> `InputEvent`。 + +输入 adapter 要解决: + +- key down/up/repeat。 +- modifier。 +- printable ASCII。 +- back/home/next/power 语义。 +- 无输入设备时的 graceful degradation。 + +### 7.4 存储 + +所有 Linux storage 都应走统一 root resolver。 + +建议约定: + +| 用途 | env override | 默认 | +| --- | --- | --- | +| settings/state | `TRAIL_MATE_SETTINGS_ROOT` | `$HOME/.trailmate_cardputer_zero` 或 Windows `%APPDATA%` | +| SD-like files | `TRAIL_MATE_SD_ROOT` | settings root 下的 `sdcard` | +| packs | `TRAIL_MATE_PACK_ROOT` | repo root 下 `packs` | +| GPS NMEA file | `TRAIL_MATE_GPS_NMEA_FILE` | 无 | +| GPS serial | `TRAIL_MATE_GPS_DEVICE` | 无自动打开 | + +文件写入: + +- 小文件使用 temp file + fsync + rename。 +- settings namespace 文件名必须 sanitize。 +- blob 建议继续 hex 编码或迁移到明确 binary file,不能混入非转义分隔符。 +- 删除动作必须 root-contained。 + +### 7.5 GPS + +当前 `gps_runtime.cpp` 已经有不错的基础: + +- env 默认位置。 +- NMEA RMC/GGA/GSA/GSV 解析。 +- Linux serial nonblocking read。 +- file source incremental read。 +- stale detection。 +- GNSS satellite snapshot。 + +后续改进: + +- 把 NMEA parser 拆成可单测 helper。 +- 增加 checksum 严格模式配置。 +- 增加 serial reconnect backoff。 +- 增加 device discovery,但默认不要随便打开所有 `/dev/tty*`。 +- 真机 UI 应显示 source:simulated/env/file/serial/stale。 + +### 7.6 Hostlink + +当前 hostlink TCP runtime 已经可用,但需要调整默认安全姿态。 + +建议: + +- simulator 默认 bind `127.0.0.1`。 +- 真机要对外监听时必须显式设置 `TRAIL_MATE_HOSTLINK_BIND=0.0.0.0`。 +- endpoint file 写入 SD root 下 `hostlink/endpoint.txt` 可以保留。 +- 后续如果承载敏感操作,需要认证或 pairing,不要只靠局域网隔离。 + +### 7.7 LoRa、Walkie、SSTV + +当前这三块主要是 synthetic runtime: + +- LoRa:生成 RSSI 曲线。 +- Walkie:生成 tx/rx level。 +- SSTV:生成 frame 并保存 PPM。 + +短期保留价值: + +- 帮助 UI 页面和 flow 在 Linux 上可验证。 +- 支撑 simulator 演示。 + +必须补的边界: + +- status 显示 simulated。 +- 真机默认不要宣称可用 radio/audio。 +- 真实 radio/audio 接入前,不要让上层 protocol 依赖这些 synthetic 行为。 + +### 7.8 Team 和 Chat + +当前 Linux facade 已经让 chat/contact/team 在 simulator 中很有生命力,但这部分最需要防止概念漂移。 + +要拆清楚: + +- `core_chat` 的 domain/usecase/infra store 是可复用核心。 +- Linux loopback mesh 是 simulator adapter。 +- demo contacts 是 simulator data seed。 +- team dummy crypto 是 demo-only。 +- team UI store 中内存 snapshot 与 GPX append 是早期 runtime,不等于真实团队持久化完整实现。 + +真实设备前必须做: + +- 替换 dummy crypto。 +- 把 demo seed 变成 opt-in。 +- 明确 mesh transport:LoRa、BLE、hostlink、local loopback 各是什么。 +- 让 contact/node store 的持久化格式有版本和迁移策略。 + +### 7.9 Wi-Fi、USB、FOTA、Orientation + +这些目前多数是 unsupported 或空实现。正确策略是继续显式 unsupported,直到有真实 Linux path。 + +不要为了菜单完整而返回 `true`。 + +UI 应根据状态显示不可用,而不是进入半功能页面。 + +### 7.10 线程和生命周期 + +Linux common 中已经有 hostlink worker thread。后续新增线程时遵守: + +- `start()` 幂等。 +- `stop()` 必须 join。 +- 析构不抛异常。 +- 后台线程不能调用 LVGL。 +- shared state 用 mutex/atomic 明确保护。 +- 测试要覆盖 start-stop-start。 + +### 7.11 错误处理 + +原则: + +- app shell main 可以 catch exception 并返回非 0。 +- platform runtime 合约尽量返回 `Status` 或 bool + message。 +- 真机设备路径失败要可诊断:输出设备路径、errno、fallback。 +- simulator 脚本失败要给安装建议。 + +### 7.12 环境变量命名 + +继续使用 `TRAIL_MATE_` 前缀。 + +建议规则: + +- runtime root:`TRAIL_MATE_SETTINGS_ROOT`、`TRAIL_MATE_SD_ROOT` +- simulator:`TRAIL_MATE_SIM_*` +- GPS:`TRAIL_MATE_GPS_*` +- hostlink:`TRAIL_MATE_HOSTLINK_*` +- demo:`TRAIL_MATE_DEMO_*` +- device:`TRAIL_MATE_FBDEV`、`TRAIL_MATE_INPUT_DEVICE` + +环境变量是启动配置和测试注入,不应成为业务持久化的唯一来源。 + +## 8. 具体 backlog + +### 8.1 立即做 + +1. 修复当前 compile gate。 + +验收:simulator common、UI shell、path safety smoke、rpi framebuffer target 都能编译。2026-05-08 这轮已经修掉 LVGL callback 签名和 `evdev_input.cpp` 的 `kKeyMap` 初始化风险,下一步应直接跑 Linux/WSL 下的完整 CMake build。 + +2. 稳定 Linux CMake source helper。 + +验收:sim/rpi CMake 不再重复大 source list;rpi-only source 有清晰入口;添加 shared UI 源文件时只改一处。 + +3. 回归 `ShellSession` 与 LVGL input 细节。 + +验收:simulator 行为不变;单个 key enqueue 能产生 press/release;`continue_reading` 不吞事件;SDK path 可以复用 session 而不被 `lv_init()` 所有权冲突阻塞。 + +4. 完成 `platform/linux/rpi` evdev input adapter。 + +验收:custom framebuffer shell 能通过键盘导航;`TRAIL_MATE_INPUT_DEVICE=/dev/input/eventX` 可覆盖;无输入设备时只降级并打印诊断;Cardputer Zero 内置键盘 Fn/组合键映射来自真机事件码采样。 + +5. 扩大 runtime path helper 覆盖。 + +验收:settings、route、tracker、sstv、team、hostlink、map tiles、pack repository 共用路径解析;absolute path、`..`、跨 root 删除都被拒绝;map/hostlink/team/SSTV 的旧 env 常量和 `runtime_paths.h` 中不准确的 fsync 注释被清掉。 + +6. 修复并纳入 path traversal tests。 + +验收:absolute path、`..`、跨 root 删除都被拒绝。 + +7. 建立 capability truth table 并更新 UI status。 + +验收:`capability_status.h` 出现在 contract inventory;LoRa/Walkie/SSTV 等 public header 继续暴露 `capability_status()`;至少一个 UI 状态入口消费该 API;synthetic 功能不再被展示为真实支持。 + +8. 让 runtime mode 真正控制 facade composition。 + +验收:`TRAIL_MATE_RUNTIME_MODE=demo/local/mesh` 能改变 demo seed、loopback mesh、dummy crypto、真实设备能力呈现;默认真机不启用 demo world,也不默认组合 dummy crypto 和 loopback pairing。 + +9. 验证 simulator runtime mode 默认值写入语义。 + +验收:POSIX 和 Windows 都只在 `TRAIL_MATE_RUNTIME_MODE` 未设置时写入 `demo`;用户显式设置 `local` 或 `mesh` 时不会被 simulator main 覆盖。 + +### 8.2 第一轮真实设备闭环 + +1. 把 `apps/linux_rpi/main/src/main.cpp` 从 bring-up UI 改为 shared shell。 +2. 保留 `LV_LINUX_FBDEV_DEVICE` 和 `/proc/fb` auto-detect。 +3. 保留 `LV_LINUX_KEYBOARD_DEVICE`,补更稳健的 input detection。 +4. 记录启动日志:display path、input path、settings root、sd root、capability mode。 +5. 在真机上验证 boot/menu/settings。 + +验收: + +- SDK path 构建成功。 +- 真机显示 shared shell。 +- 真机可按键操作。 +- Settings 持久化。 + +### 8.3 第一轮功能迁移 + +优先顺序: + +1. Settings +2. Contacts/Chat local store +3. GPS page with NMEA source status +4. Tracker file workflow +5. Hostlink page +6. Team local runtime cleanup +7. Map tile/file storage + +暂缓: + +- real LoRa +- real walkie/audio +- Wi-Fi provisioning +- USB mass storage +- firmware update +- BLE phone integration + +### 8.4 CI 和验证增强 + +新增建议: + +- Linux CMake source helper smoke。 +- path safety test。 +- NMEA parser unit tests。 +- hostlink start/stop/restart test。 +- capability status test。 +- optional Docker image build check。 +- optional SDK build job,需要能缓存或固定 SDK 获取方式。 + +当前已有命令: + +```bash +python3 scripts/check_platform_ui_boundaries.py + +cd apps/linux_sim +cmake --preset linux-simulator-debug +cmake --build --preset linux-simulator-debug-build +cmake --preset linux-test-debug +cmake --build --preset linux-test-debug-build +ctest --preset linux-debug-test +bash scripts/wsl-validate.sh + +cd ../linux_rpi +cmake --preset linux-device-debug +cmake --build --preset linux-device-debug-build +``` + +Windows/WSL simulator: + +```powershell +powershell -ExecutionPolicy Bypass -File .\apps\linux_sim\scripts\run-dev-container.ps1 +``` + +Linux dev container: + +```bash +cd apps/linux_sim +bash scripts/run-dev-container.sh +``` + +SDK device: + +```bash +cd apps/linux_rpi +export TRAIL_MATE_M5STACK_LINUX_LIBS_PATH=/path/to/M5Stack_Linux_Libs +bash scripts/build-sdk-device.sh +LV_LINUX_FBDEV_DEVICE=/dev/fb0 bash scripts/run-sdk-device.sh +``` + +## 9. 代码审查清单 + +每个 Linux 适配 PR 都应该检查: + +- 是否新增了 `modules/core_* -> platform/*` 依赖。 +- 是否新增了 `modules/ui_shared -> platform/linux/*` 依赖。 +- 是否把 simulator-only 代码放进了 `platform/linux/common`。 +- 是否把 Pi-specific 代码放进了 `platform/linux/common`。 +- 是否新增了无解释的 `#ifdef __linux__`。 +- 是否新增了可被路径穿越影响的文件操作。 +- 是否把 synthetic runtime 标成 supported。 +- 是否让 app shell 长出业务逻辑。 +- 是否更新了 CMake shared source helper。 +- 是否有至少 smoke 或 unit test。 +- 是否更新了能力状态和文档。 + +## 10. Definition of Done + +Linux 适配不能只以“能编译”作为完成标准。 + +一个 Linux slice 完成,至少满足: + +- 合约位置正确。 +- 实现位置正确。 +- simulator 能跑。 +- rpi CMake target 不退化。 +- 如涉及真机,SDK path 有验证路径。 +- 能力状态诚实。 +- synthetic/demo 行为不会默认污染真实设备。 +- 文件路径安全。 +- 生命周期可停止、可重启、无明显泄露。 +- CI 或手动验证命令明确。 +- 文档同步。 + +整个 Cardputer Zero Linux 适配真正可称为完成时,应满足: + +- `apps/linux_sim` 是稳定开发和验证工具。 +- `apps/linux_rpi` SDK path 是真实设备主路径。 +- `platform/linux/common` 不含 simulator/device 特例。 +- `platform/linux/rpi` 只含真机特有适配。 +- shared shell 在 simulator 和真机上是同一套。 +- Settings、Chat/Contacts、GPS、Tracker、Hostlink 至少有真实 Linux runtime。 +- LoRa/Walkie/SSTV/Wi-Fi/USB/FOTA 等能力要么真实可用,要么诚实 unsupported/simulated。 +- `modules/core_*` 与 `modules/ui_shared` 边界由自动检查守住。 +- 新增 Linux 设备族不需要复制现有 app shell。 + +## 11. 推荐路线图 + +### Milestone A:结构收口 + +目标:减少重复和过期文档。 + +- 抽 CMake source helper。 +- 更新 app-local specs。 +- 建立 path/capability helper。 +- 增加 path safety tests。 + +### Milestone B:真机 shell 收口 + +目标:让 SDK path 跑 Trail Mate shared shell。 + +- 拆 ShellSession。 +- SDK main 接 shared shell。 +- evdev input 可用。 +- 启动日志清晰。 + +### Milestone C:Settings 验证 slice + +目标:验证最小真实功能闭环。 + +- Settings 页面在 simulator/device 都可用。 +- settings persistence 真正落盘。 +- capability status 在 UI 中可信。 + +### Milestone D:数据和通信基础 + +目标:让 Linux 设备不只是 UI demo。 + +- Chat/contact/node store 整理。 +- Hostlink 安全默认值。 +- GPS NMEA serial 真机验证。 +- Tracker/route 文件工作流。 + +### Milestone E:硬件能力 + +目标:逐步接真实设备能力。 + +- Battery/power。 +- Brightness/backlight。 +- Audio/walkie。 +- Radio/LoRa。 +- Wi-Fi。 +- USB/FOTA,如设备和产品形态需要。 + +### Milestone F:发布与维护 + +目标:从开发 shell 进入可用产品线。 + +- systemd service 或启动脚本。 +- 配置目录和权限策略。 +- 日志策略。 +- release artifact。 +- 设备回归手册。 + +## 12. 当前最终判断 + +当前 Linux 适配已经完成了最难的第一件事:把目标结构和边界意识立起来,并让 simulator 与 Linux common runtime 具备复用共享 UI 的基础。根据 2026-05-08 最新回归,代码还进一步做了 CMake helper、LVGL session/host 分层、evdev 输入、runtime path/env、capability status 上移与部分 public API、demo world 抽取、SDK bring-up 强化等正确方向的结构推进。 + +但这轮推进还没有到“适配完成”。下一阶段的关键不是继续堆更多模拟功能,而是先把这批结构改动收口成可构建、可测试、可运行的事实: + +- 先修 compile gate,让 sim/rpi/test 都重新绿。 +- 再验证 LVGL input callback,让按键事件语义可靠。 +- 再收口真机输入/display 主路径,特别是 rpi CMake、SDK main、evdev 真机验证。 +- 再收口 path/capability/runtime 配置,让 pack repository 路径不再游离,让 capability contract inventory 和 UI 呈现跟上。 +- 再收口 demo 与真实设备 facade 的边界,让真实设备默认不继承模拟世界。 + +如果按这个顺序推进,Linux 线会自然长成 Trail Mate 的一等平台,而不是新的平行项目。 diff --git a/docs/LOCALE_PACKS.md b/docs/specs/LOCALE_PACKS.md similarity index 74% rename from docs/LOCALE_PACKS.md rename to docs/specs/LOCALE_PACKS.md index 955d4cc2..9b056e93 100644 --- a/docs/LOCALE_PACKS.md +++ b/docs/specs/LOCALE_PACKS.md @@ -2,8 +2,10 @@ 本文档解释 pack 机制与打包细节。 整个本地化系统的规范性规格现在位于 -[`docs/LOCALIZATION_SPEC.md`](./LOCALIZATION_SPEC.md)。 +[`docs/specs/LOCALIZATION_SPEC.md`](./LOCALIZATION_SPEC.md)。 如果两份文档之间存在冲突,以 `LOCALIZATION_SPEC.md` 为准。 +语言包打包、发布、版本、archive 与 catalog 更新规则见 +[`docs/specs/LOCALE_PACK_RELEASE_SPEC.md`](./LOCALE_PACK_RELEASE_SPEC.md)。 ## 目标 @@ -25,7 +27,7 @@ Trail Mate 把本地化明确建模为三类 pack: - `Font Pack` 拥有字形覆盖元数据以及外部 `font.bin` 资源。 - `IME Pack` - 声明脚本特定的输入行为,例如拼音。 + 声明脚本特定的输入行为,例如简体中文拼音。IME pack 只有在固件里已经有真实后端时才允许进入 runtime payload。 Settings 页选择的是 `Locale Pack`,而不是直接选择字体或 IME。 @@ -233,12 +235,20 @@ display_name=Pinyin backend=builtin-pinyin ``` -目前已出货的 backend 实现是: +目前可启用的输入法实现是: -- `builtin-pinyin` +- `zh-hans-pinyin`,后端为 `builtin-pinyin` backend 的真实实现位于固件代码中。IME pack manifest 是把它注册进运行时并暴露出来的那一层。 +硬规则: + +- 只有已经有真实输入引擎和正确候选词表的 IME 才能出现在 runtime payload 中。 +- 未实现的假名、韩文、阿拉伯、西里尔、繁中注音/仓颉和泛 Latin 软键盘,不得用 `backend=builtin-*` 提前占位。 +- 台湾繁中不得默认拼音。未来 `zh-Hant-TW` 的首选输入法应是注音,仓颉/速成可以作为可选扩展。 +- 欧洲 Latin 语言通常不需要 IME pack;沿用普通 `EN` / `123` 输入路径即可。 +- locale manifest 只有在真实 IME 可用时才写 `ime_pack`。display-only locale 不写假依赖。 + ### Locale Pack Manifest ```ini @@ -246,6 +256,7 @@ kind=locale id=zh-Hans display_name=Simplified Chinese native_name=简体中文 +translation_status=release ui_font_pack=zh-hans-cjk content_font_pack=zh-hans-cjk ime_pack=zh-hans-pinyin @@ -259,6 +270,7 @@ kind=locale id=zh-Hans display_name=Simplified Chinese native_name=简体中文 +translation_status=release ui_font_pack=zh-hans-core content_font_pack=zh-hans-core preferred_content_supplement_packs=zh-hans-ext @@ -269,7 +281,7 @@ strings=strings.tsv 字段说明: - `id` - 用于持久化和日志的 locale 标识符。 + 用于持久化和日志的 locale 标识符。新 locale 应优先使用能表达地区语境的 BCP-47 形态,例如 `zh-Hant-TW`、`pt-PT`。 - `display_name` 面向英文语境的名称。 - `native_name` @@ -284,6 +296,12 @@ strings=strings.tsv 可选 IME 依赖。 - `strings` locale TSV 文件的相对路径,相对于该 pack 目录。 +- `translation_status` + 可选质量闸门。省略时为了兼容旧包暂视为可发布;新包必须显式写出。取值为: + - `release`:运行时可选。 + - `review`:字符串表结构完整但还未通过母语/地区审校,运行时跳过。 + - `draft`:未完成或可能混入错误语言,运行时跳过。 + - 其他未知值:运行时按不可发布处理并跳过。 如果省略 `content_font_pack`,默认回退为 `ui_font_pack`。 如果省略 `ui_font_pack`,默认回退为 `builtin-latin-ui`。 @@ -312,12 +330,29 @@ Send this code and compare:\\n Envoyez ce code et comparez:\\n 代码中的稳定查找 key 始终是 English 源字符串。 +## String Table 质量要求 + +release 级 `strings.tsv` 必须满足: + +1. 与当前基准 key 集合一一对应,不缺 key。 +2. 不存在重复 key。 +3. 不存在空翻译。 +4. `%s`、`%u`、`%ld`、`%.3f`、`\\n`、`\\t` 等占位符和转义符必须与 English key 一致。 +5. 不允许把 English key 原样复制到翻译列作为补偿。 +6. 不允许混入其他语言的句子。 +7. 地区敏感语言必须符合地区用词习惯。 + +允许保留英文的只有明确专名、协议名、单位、测量标签或格式骨架,例如 `GPS`、`HDOP`、`RSSI`、`dBm`、`RNode`、`LXMF`、`ID: !%08lX`。 + +`review` 包也应尽量满足上述结构校验,但可以因为需要母语审校而暂不进入运行时语言列表。 + ## 失败行为 如果某个 locale pack 无法被使用: - 缺少依赖的 font pack:跳过该 locale - 缺少依赖的 IME pack:跳过该 locale +- `translation_status` 不是 `release`:跳过该 locale - 活动 locale 的字体成本超出当前 memory profile:跳过该 locale - 持久化的 locale id 不再能解析:运行时回退到 `en` @@ -329,6 +364,8 @@ Send this code and compare:\\n Envoyez ce code et comparez:\\n 仓库当前提供的源码 bundle 位于: +- `packs/ar` +- `packs/europe-cyrillic-ext` - `packs/europe-latin-ext` - `packs/zh-Hans` - `packs/zh-Hant` @@ -375,7 +412,7 @@ tags=language,cjk,chinese,ime - `package_type` 高层级 package 角色。当前仓库 bundle 使用 `locale-bundle`。 - `version` - package 版本号,独立于固件 tag。 + package 版本号,独立于固件 tag。字符串、manifest、字体、质量状态或 IME 依赖发生用户可见变化时必须 bump。 - `display_name` Extensions UI 中给用户看的 package 名称。 - `summary` @@ -389,12 +426,18 @@ tags=language,cjk,chinese,ime - `homepage` 项目或 package 首页。 - `min_firmware_version` - 正确理解该 bundle 契约所要求的最小固件版本。 + 正确理解该 bundle 契约所要求的最小固件版本。新增运行时必须理解的 manifest 语义,例如非 release `translation_status` 跳过规则时,必须提高该下界。 - `supported_memory_profiles` 声明式兼容性提示,可取 `constrained`、`standard` 与/或 `extended`。 - `tags` 供未来 Extensions 浏览器搜索与分组的元数据。 +版本 bump 约定: + +- 补少量漏翻或错别字:patch。 +- 大量补齐翻译、调整地区用语、改变质量状态、移除假 IME:minor。 +- 改变 payload 布局或旧固件会误解释的 manifest 语义:提高 `min_firmware_version`,并按影响 bump minor/major。 + ## Font 构建元数据 每个外部 font-pack 目录还可以声明一个仅供工具链使用的 `build.ini`。 @@ -480,24 +523,36 @@ runtime registry 仍然直接从 `/trailmate/packs` 对已解包资源进行编 ## 当前 IME 覆盖 -目前仓库 bundle 中,只有简体中文声明了 IME pack: +目前仓库 bundle 中,只有简体中文声明并发布了 IME pack: - `zh-Hans` -> `zh-hans-pinyin` -繁体中文、日文和韩文 bundle 目前都是 display-only。它们有意继续沿用现有 `EN` / `123` 输入路径,直到对应脚本的专用 IME pack 出现。 +繁体中文、日文、韩文、阿拉伯语、俄语和欧洲 Latin bundle 目前都是 display-only review 包。它们有意继续沿用现有 `EN` / `123` 输入路径,直到对应脚本的专用 IME pack 出现。 + +具体约束: + +- `zh-Hant` 当前按台湾繁中 review 包处理,不得挂拼音 IME。未来应新增注音优先的真实 IME。 +- `ja` 必须等假名/罗马字日文输入模型完成后才能挂 IME。 +- `ko` 必须等韩文输入模型完成后才能挂 IME。 +- `ar` 必须等阿拉伯键盘、RTL 编辑与 shaping 行为闭环后才能挂 IME。 +- 欧洲 Latin locale 不应为了“有输入法列表项”而挂虚假的 `builtin-latin`。 ## 新增一种语言 1. 先决定这个 locale 能否复用已有 font pack,还是需要新建一个。 -2. 新增源码 bundle:`packs//`,并写好 `package.ini`、`DESCRIPTION.txt` 与 `README.md`。 -3. 为该 bundle 中的每个外部 font pack 生成 `charset.txt` 与 `ranges.txt`。 -4. 为每个生成型 font pack 写好 `build.ini`。 -5. 写运行时 font manifest,包括 `usage`、`estimated_ram_bytes` 和 `ranges`。 -6. 写 locale pack manifest,包括 `ui_font_pack` 与 `content_font_pack`。 -7. 只有在该脚本确实需要额外输入行为时,才添加 `ime_pack`。 -8. 运行 `python scripts/build_pack_repository.py --pack-root packs --site-root site`。 -9. 可以手工把 runtime payload 拷贝到 SD 安装,也可以通过 Pages 产出的 zip 提供给未来的 Extensions installer。 -10. 重启后,在 Settings 中选择该 locale。 +2. 决定 locale id 是否需要地区后缀,例如 `zh-Hant-TW`、`pt-PT`。 +3. 新增源码 bundle:`packs//`,并写好 `package.ini`、`DESCRIPTION.txt` 与 `README.md`。 +4. 为该 bundle 中的每个外部 font pack 生成 `charset.txt` 与 `ranges.txt`。 +5. 为每个生成型 font pack 写好 `build.ini`。 +6. 写运行时 font manifest,包括 `usage`、`estimated_ram_bytes` 和 `ranges`。 +7. 写 locale pack manifest,包括 `ui_font_pack`、`content_font_pack`、`translation_status`。 +8. 初始语言包应先使用 `translation_status=review` 或 `draft`,母语/地区审校完成后才能改成 `release`。 +9. 只有在真实输入引擎已经实现时,才添加 `ime_pack`。 +10. 校验 `strings.tsv`:缺 key、重复 key、空翻译、占位符不一致、英文补偿项。 +11. bump `package.ini` 中的 `version`,必要时提高 `min_firmware_version`。 +12. 运行 `python scripts/build_pack_repository.py --pack-root packs --site-root site`。 +13. 可以手工把 runtime payload 拷贝到 SD 安装,也可以通过 Pages 产出的 zip 提供给未来的 Extensions installer。 +14. 只有 release locale 会在 Settings 中可选。 ## 设计规则 @@ -510,6 +565,8 @@ runtime registry 仍然直接从 `/trailmate/packs` 对已解包资源进行编 - 让不同平台上的本地化实现逐渐漂离 - 混淆运行时 manifest 与 package 分发元数据 - 让 runtime registry 去理解远程 catalog 或 zip archive +- 用机器翻译、繁简转换或英文补偿伪装 release 包 +- 在没有真实输入法后端时发布假 IME 依赖图保持显式: diff --git a/docs/specs/LOCALE_PACK_RELEASE_SPEC.md b/docs/specs/LOCALE_PACK_RELEASE_SPEC.md new file mode 100644 index 00000000..8bb28285 --- /dev/null +++ b/docs/specs/LOCALE_PACK_RELEASE_SPEC.md @@ -0,0 +1,243 @@ +# Locale Pack Release Specification + +本文档定义 Trail Mate 语言包的打包、发布、更新与回滚规则。 + +它回答的问题不是“如何翻译一句话”,而是: + +1. 什么变化必须 bump package version。 +2. 什么变化必须提高 `min_firmware_version`。 +3. release / review / draft 包如何进入 archive 和 catalog。 +4. 用户如何看到语言包更新。 +5. 发布前必须跑哪些结构校验。 + +如果本文档与 `LOCALIZATION_SPEC.md` 冲突,以 `LOCALIZATION_SPEC.md` 为准。 +如果本文档与 `LOCALE_PACKS.md` 冲突,以本文档的发布流程为准。 + +--- + +## 1. 发布对象 + +语言包发布涉及五个对象: + +1. source bundle + - 仓库中的 `packs//`。 +2. runtime payload + - archive 内的 `payload/fonts`、`payload/locales`、`payload/ime`。 +3. package manifest + - bundle 根目录的 `package.ini`。 +4. distribution archive + - `site/assets/packs/-.zip`。 +5. remote catalog + - `site/data/packs.json`。 + +这五个对象必须一起演进。只改 source bundle 而不重建 archive/catalog,不构成完整发布。 + +--- + +## 2. 包状态 + +locale manifest 的 `translation_status` 控制运行时是否能采用该 locale。 + +1. `release` + - 可以出现在语言选择器。 + - 必须通过结构校验和质量审查。 +2. `review` + - 可以打包、分发、安装、更新。 + - 运行时必须跳过,不进入语言选择器。 + - 用于给测试者或母语审校者拿到完整 payload。 +3. `draft` + - 允许缺 key 或翻译不完整。 + - 运行时必须跳过。 + - 原则上不应进入公开 catalog,除非 catalog 明确标记为实验包。 + +新语言包不得省略 `translation_status`。省略只用于兼容历史包。 + +--- + +## 3. Version Bump 规则 + +`package.ini` 的 `version` 是用户看到更新的主信号之一。以下变化必须 bump version: + +1. `strings.tsv` 变化。 +2. locale/font/IME `manifest.ini` 变化。 +3. `font.bin`、`ranges.txt`、`charset.txt` 变化。 +4. `README.md`、`DESCRIPTION.txt` 中影响用户理解的描述变化。 +5. `translation_status` 变化。 +6. `ime_pack` 增删或 IME backend 变化。 +7. `package.ini` 中影响安装、兼容性、搜索、展示的字段变化。 + +建议 bump 幅度: + +1. patch + - 少量漏翻、错别字、标点、术语修正。 + - 例如 `1.0.0 -> 1.0.1`。 +2. minor + - 大量补齐翻译。 + - 从 `draft` 进入 `review` 或从 `review` 进入 `release`。 + - 移除假 IME 或改变输入法策略。 + - 新增大量 key 覆盖。 + - 例如 `1.0.0 -> 1.1.0`。 +3. major + - payload 布局、依赖语义或兼容性语义发生破坏性变化。 + - 目前 pre-1.0 阶段可以少用 major,但不得用 patch 掩盖破坏性变化。 + +禁止改变 archive 内容但保持同一个 package version。 + +--- + +## 4. `min_firmware_version` 规则 + +`min_firmware_version` 表示正确理解该 bundle 契约所需的最低固件版本。 + +以下变化必须提高 `min_firmware_version`: + +1. 新增运行时必须理解的 manifest 字段。 +2. 改变已有 manifest 字段语义。 +3. 改变 locale/font/IME 依赖规则。 +4. 改变运行时 payload 布局。 +5. 旧固件如果安装该包,会暴露错误语言、假 IME 或错误字体链。 + +当前 `0.1.24-alpha` 引入 `translation_status=review/draft` 且要求运行时跳过非 release locale,因此所有依赖该质量闸门的 review/draft 包都必须把 `min_firmware_version` 提高到 `0.1.24-alpha` 或更高。 + +--- + +## 5. Archive 规则 + +archive 路径固定为: + +```text +site/assets/packs/-.zip +``` + +archive 必须包含: + +```text +package.ini +DESCRIPTION.txt +README.md +payload/fonts//manifest.ini +payload/fonts//ranges.txt +payload/fonts//font.bin +payload/locales//manifest.ini +payload/locales//strings.tsv +payload/ime//manifest.ini +``` + +其中 `payload/ime/...` 只允许包含真实可运行 IME。未实现输入法不得进入 archive。 + +archive 不得包含: + +1. `build.ini` +2. `charset.txt` +3. `.gitignore` +4. 临时文件 +5. 空 IME 目录 +6. 未使用的实验 payload + +--- + +## 6. Catalog 规则 + +`site/data/packs.json` 是用户看到可安装/可更新语言包的 catalog。 + +每次 package version 或 archive 内容变化,都必须重建 catalog,使以下字段同步: + +1. package id +2. package version +3. display name +4. summary +5. `min_firmware_version` +6. supported memory profiles +7. provided locale/font/IME records +8. archive URL/path +9. archive size +10. archive SHA-256 +11. estimated runtime font RAM + +如果 catalog 的 version 或 SHA-256 没变,用户侧更新检查可能看不到变化。 + +--- + +## 7. 发布前结构校验 + +每个 locale `strings.tsv` 必须校验: + +1. key 集合完整。 +2. 无重复 key。 +3. 无空翻译。 +4. printf 占位符一致。 +5. 转义符一致。 +6. release 包无英文补偿项。 +7. release 包无第三语言混入。 + +每个 locale manifest 必须校验: + +1. `id` 存在。 +2. `translation_status` 存在。 +3. `ui_font_pack` 存在且可解析。 +4. `content_font_pack` 存在且可解析。 +5. `ime_pack` 只指向真实 runtime IME。 +6. RTL locale 必须声明 `direction=rtl`。 + +每个 package manifest 必须校验: + +1. `version` 已 bump。 +2. `min_firmware_version` 与 manifest 语义匹配。 +3. `supported_memory_profiles` 与字体 RAM 成本匹配。 +4. `summary` 不谎称已有未实现 IME。 + +--- + +## 8. 发布流程 + +标准流程: + +1. 修改 source bundle。 +2. 跑 locale 结构校验。 +3. 决定每个 locale 的 `translation_status`。 +4. 移除所有未实现 IME manifest 和 locale `ime_pack` 假依赖。 +5. bump `package.ini` 的 `version`。 +6. 必要时提高 `min_firmware_version`。 +7. 重建 archive 和 catalog: + +```powershell +python scripts/build_pack_repository.py --pack-root packs --site-root site +``` + +8. 校验 `site/data/packs.json` 中 version、archive path、size、SHA-256 都发生了预期变化。 +9. 在目标固件上验证: + - release locale 可选。 + - review/draft locale 不出现在语言选择器。 + - Settings 的 IME 列表只显示真实可用 IME。 + - 已安装旧包时能看到 update available。 + +--- + +## 9. 回滚规则 + +如果已发布语言包发现严重问题: + +1. 不要复用旧 archive 文件名覆盖内容。 +2. 发布一个更高 version 的修复包。 +3. 如果必须下架,catalog 中移除该 package record,并保留 release note。 +4. 如果是 release 质量误判,应发布更高 version,把 locale 降回 `review` 或 `draft`。 + +这样 installed index 与 update check 才能解释“为什么用户需要更新”。 + +--- + +## 10. 当前发布策略 + +当前策略是: + +1. `zh-Hans` + - 可以作为 `release`。 + - 当前唯一可发布 IME 是 `zh-hans-pinyin`。 +2. `zh-Hant` + - 按台湾繁中 `review` 包处理。 + - 不挂拼音 IME。 + - 进入 `release` 前必须经过台湾用语与输入法习惯审查。 +3. `ar`、`ru`、`de`、`es`、`fr`、`it`、`nl`、`pl`、`pt-PT`、`ja`、`ko` + - 可打包为 `review`。 + - 不进入普通用户语言选择器。 + - 不发布假 IME。 diff --git a/docs/LOCALIZATION_SPEC.md b/docs/specs/LOCALIZATION_SPEC.md similarity index 66% rename from docs/LOCALIZATION_SPEC.md rename to docs/specs/LOCALIZATION_SPEC.md index 348ea7b4..4e902418 100644 --- a/docs/LOCALIZATION_SPEC.md +++ b/docs/specs/LOCALIZATION_SPEC.md @@ -22,7 +22,7 @@ ### 2.1 当前必须切开的对象 -Trail Mate 本地化系统至少由六类对象组成: +Trail Mate 本地化系统至少由八类对象组成: 1. 固件本地化运行时 2. Runtime pack 载荷 @@ -30,6 +30,8 @@ Trail Mate 本地化系统至少由六类对象组成: 4. 分发包 5. 远程 catalog 6. 已安装索引 +7. locale 质量状态 +8. 输入法运行时后端 它们彼此相关,但不是同一个东西。 @@ -44,6 +46,11 @@ Trail Mate 本地化系统至少由六类对象组成: 5. 把英文源串当成“只是文案”,而不是翻译查找 key。 6. 把页面私有字符串补丁当成合法的本地化实现。 7. 把 `docs/ui_localization_plan.md` 这种历史方案继续当成现行契约。 +8. 把繁简转换当成繁中本地化。 +9. 把 `zh-Hant` 当成一个不需要地区语境的抽象繁体中文。 +10. 把拼音当成所有中文使用者的默认输入法。 +11. 把“机器翻译已经填满 TSV”当成可发布质量。 +12. 把英文 key 原样复制到翻译列,当成合法的缺省翻译。 --- @@ -148,6 +155,39 @@ Runtime pack 载荷是运行时真正扫描和消费的已解包目录树。 它不能替代 runtime registry,也不能替代 locale persistence。 +### 3.7 Locale 质量状态 + +locale 质量状态描述的是“这个语言包现在能不能作为用户可选语言出现”,不是描述字体是否存在,也不是描述包是否可以被 catalog 发现。 + +当前状态值固定为: + +1. `release` + - 可以出现在语言选择器中。 + - 必须完成 native 或项目负责人认可的质量审查。 + - 不得包含英文补偿项,除非该项是明确允许保留的技术名词、协议名、单位或格式占位串。 +2. `review` + - 字符串表结构完整,通常已经被批量翻译或人工初审过。 + - 仍然需要母语审校、地区用语审校、输入法语义审校。 + - 运行时必须跳过,不得出现在普通用户语言选择器中。 +3. `draft` + - 可以缺 key、混用语言、缺少地区用语审查。 + - 运行时必须跳过。 + +为了兼容旧包,省略 `translation_status` 的历史 locale 视为 `release`。新包不应再省略该字段。 + +### 3.8 输入法运行时后端 + +IME pack manifest 只是输入法后端的声明,不是后端本身。 + +一个输入法要成为可启用输入法,必须同时满足: + +1. 固件中存在真实后端。 +2. 候选词、组合逻辑、提交行为与目标语言/地区习惯匹配。 +3. UI widget 知道如何展示该输入模式。 +4. locale manifest 显式依赖它,且该 locale 已经是可发布质量。 + +因此,不允许用 `backend=builtin-kana`、`backend=builtin-hangul`、`backend=builtin-arabic` 这类尚未实现的名字提前占位。未实现输入法可以写进规划文档,但不能作为 runtime IME pack 进入 payload。 + --- ## 4. Core Contracts @@ -186,6 +226,83 @@ Settings 选择的是 locale,不是 font,不是 IME。 页面或设置页不允许绕过 locale 直接定义另一套“选字体/选输入法”主流程。 +### 4.2.1 Locale ID 必须表达地区语境 + +locale id 必须按 BCP-47 思路表达语言、脚本与地区语境,而不是只表达字形。 + +推荐形态: + +1. `zh-Hans-CN` + - 简体中文,中国大陆用语。 +2. `zh-Hant-TW` + - 繁体中文,台湾地区用语。 +3. `zh-Hant-HK` + - 繁体中文,香港地区用语。 +4. `pt-PT` + - 葡萄牙语,葡萄牙地区用语。 +5. `pt-BR` + - 葡萄牙语,巴西地区用语。 + +当前仓库中的 `zh-Hant` 是历史兼容 id,语义必须按 `zh-Hant-TW` 处理;后续如果引入香港、澳门或其他繁中地区包,必须新增独立 locale,而不是继续复用 `zh-Hant`。 + +禁止把 OpenCC 或其他繁简转换结果直接作为 `release` 级繁中包。转换最多用于生成初稿;发布前必须检查地区用词、标点、语气、技术名词和输入法习惯。 + +### 4.2.2 翻译质量闸门 + +运行时 locale catalog 必须执行质量闸门: + +1. `translation_status=release` + - 允许进入运行时 locale 列表。 +2. 缺省 `translation_status` + - 为兼容旧包,暂按 `release` 处理。 +3. `translation_status=review` + - 可以被安装、索引、更新,但运行时必须跳过。 +4. `translation_status=draft` + - 可以作为开发资料存在,但运行时必须跳过。 +5. 其他未知状态 + - 运行时必须按不可发布处理并跳过。 + +一个 release 语言包不得出现以下情况: + +1. 空翻译。 +2. 缺失当前 English key 集合中的 key。 +3. `%s`、`%u`、`%ld`、`\\n` 等占位符或转义符不一致。 +4. 大量英文 key 原样复制到翻译列。 +5. 混入另一种语言的翻译文本,例如日文包中出现简体中文句子。 +6. 地区用语明显不匹配,例如台湾繁中使用大陆软件术语作为主用语。 + +允许保持英文的项目只限于明确的专名、协议名、单位、测量标签、格式骨架,例如 `GPS`、`RSSI`、`dBm`、`RNode`、`LXMF`、`ID: !%08lX`。 + +### 4.2.3 输入法必须跟 locale 习惯绑定 + +输入法不是“有一个能打字的键盘”这么简单。它属于 locale 的语言、脚本和地区习惯。 + +当前可启用输入法只有: + +1. `zh-Hans` / `zh-Hans-CN` + - `zh-hans-pinyin` + - 后端:`builtin-pinyin` + - 适用范围:简体中文拼音输入。 + +当前不得启用的输入法包括: + +1. `zh-Hant` / `zh-Hant-TW` + - 不得默认拼音。 + - 未来优先级应是注音(Bopomofo / Zhuyin)。 + - 可选扩展可考虑仓颉、速成、台湾常见拼音输入,但必须明确标注,不得冒充默认习惯。 +2. `ja` + - 需要假名/罗马字到假名与汉字候选的日文输入模型。 + - 不得复用中文拼音候选器。 +3. `ko` + - 需要韩文两벌式等韩文输入模型。 + - 不得复用中文或日文输入模型。 +4. `ar` + - 需要阿拉伯键盘、RTL 编辑语义与字体 shaping 一致性。 +5. 欧洲 Latin 语言 + - 通常不需要 IME pack;现有 `EN` / `123` 输入路径足够。 + +如果某 locale 还没有真实输入法,它可以作为 display-only review 包存在,但不得把假 IME 写进 manifest,也不得让设置页显示一个无法工作的输入法选项。 + ### 4.3 Built-In Baseline 固件必须始终保留最小内建基线: @@ -363,6 +480,28 @@ English 必须在没有任何外部 pack 的情况下仍可工作。 禁止把系统安全性完全建立在“用户一定只会通过 Extensions 正规安装”这个假设上。 +### 5.6 语言包版本必须表达用户可见变化 + +语言包版本不是装饰字段。只要用户安装后看到的文字、可用 locale、可用 IME、字体覆盖或质量状态发生变化,就必须 bump package version。 + +最低要求: + +1. 只修正少量错别字或补少量漏翻 + - bump patch,例如 `1.0.0 -> 1.0.1`。 +2. 大量补齐字符串、改写地区用语、改变 `translation_status`、移除假 IME、改变默认输入法依赖 + - bump minor,例如 `1.0.0 -> 1.1.0`。 +3. 改变 payload 布局或 manifest 语义,导致旧运行时可能错误理解 + - bump `min_firmware_version`,并视情况 bump minor 或 major。 + +发布语言包更新时必须同时重建: + +1. zip archive。 +2. `site/data/packs.json`。 +3. archive SHA-256。 +4. installed/update comparison 所需的 package metadata。 + +否则用户看不到更新,或者看到更新却无法解释更新内容。 + --- ## 6. Change Classification @@ -445,6 +584,11 @@ English 必须在没有任何外部 pack 的情况下仍可工作。 3. `font.bin` 是否与 `charset.txt` / `ranges.txt` / manifest 保持一致? 4. `estimated_ram_bytes` 是否仍真实反映生成结果? 5. `site/data/packs.json` 与 zip 产物是否已重建? +6. `translation_status` 是否符合真实质量? +7. release 包是否已经通过空值、缺 key、重复 key、占位符一致性检查? +8. release 包是否已经清除英文补偿项? +9. 该 locale 的地区用语是否经过明确审查? +10. 该 locale 的 IME 依赖是否真实可用,而不是占位后端? ### 7.3 新 locale 合入前必须满足的最小条件 @@ -453,6 +597,9 @@ English 必须在没有任何外部 pack 的情况下仍可工作。 3. 对应 font pack 的 RAM 成本可被当前目标 memory profile 解释。 4. 运行时目录布局、分发包布局、catalog 元数据三者一致。 5. 手工安装与 Extensions 安装都不会把系统带离 English 回退能力。 +6. 默认 `translation_status` 不得伪装成 release;未审校语言必须先进入 `review` 或 `draft`。 +7. 输入法要么真实可用,要么不写进 locale manifest。 +8. 如果 locale 是地区敏感语言,例如繁体中文或葡萄牙语,必须明确地区语境。 --- @@ -469,6 +616,10 @@ English 必须在没有任何外部 pack 的情况下仍可工作。 7. 改了 bundle payload,却不 bump package version。 8. 改了 bundle 契约,却不调整 `min_firmware_version`。 9. 让不兼容或损坏的 pack 破坏 English 最小基线。 +10. 用机器翻译或繁简转换结果直接发布 release 包。 +11. 在没有真实后端时发布 IME manifest 或 locale `ime_pack` 依赖。 +12. 给台湾繁中默认启用拼音输入法。 +13. release 语言包中保留大面积英文补偿项。 --- @@ -479,15 +630,18 @@ English 必须在没有任何外部 pack 的情况下仍可工作。 如果与其他文档冲突,优先级如下: 1. `docs/LOCALIZATION_SPEC.md` -2. `docs/LOCALE_PACKS.md` -3. 各 `packs//README.md` -4. `docs/ui_localization_plan.md` +2. `docs/specs/LOCALE_PACK_RELEASE_SPEC.md` +3. `docs/LOCALE_PACKS.md` +4. 各 `packs//README.md` +5. `docs/ui_localization_plan.md` 其中: 1. `docs/LOCALE_PACKS.md` - 负责解释 pack 机制、布局和字段 -2. `docs/ui_localization_plan.md` +2. `docs/specs/LOCALE_PACK_RELEASE_SPEC.md` + - 负责解释打包、发布、版本、catalog、archive 与更新可见性 +3. `docs/ui_localization_plan.md` - 仅保留历史演进价值,不再作为当前设计依据 --- diff --git a/docs/specs/gps.md b/docs/specs/gps.md new file mode 100644 index 00000000..8a3334ff --- /dev/null +++ b/docs/specs/gps.md @@ -0,0 +1,122 @@ +# GPS Specification + +## Purpose + +GPS is a positioning subsystem, not a privacy feature and not a single GPIO +toggle. Platform code may use different drivers, pins, power rails, and receiver +commands, but all runtimes must expose the same product semantics. + +## Concepts + +`gps_enabled` is user intent. When false, the GPS runtime must not power or poll +the receiver and must not report live GPS observations. When true, the runtime is +allowed to power and poll the receiver according to platform capability and power +policy. + +`gps_mode` is receiver behavior. It selects the desired GNSS operating profile, +such as high accuracy, power save, or fix only. It must never be interpreted as +the GPS enable flag. Mode value `0` is a valid enabled mode. + +`gps_sat_mask` is receiver constellation selection. It affects which satellite +systems are requested from the receiver. It must never be used as the GPS enable +flag. + +`gps_strategy` is runtime power policy. It controls when an enabled GPS runtime +keeps the receiver powered, for example always on, motion aware, or fix only. It +must never be used as the GPS enable flag. + +`gps_powered` is hardware state. It reports whether the runtime currently has the +receiver powered. + +`gps_ready` is driver or receiver readiness. A receiver can be enabled but not +ready yet, and it can be ready without having a fix. + +`gps_transport_ready` is board-level transport readiness. On UART-backed boards +this means the receiver power path and UART stream are prepared. It does not +require a UBX identity response, NMEA sentence, satellite view, or fix. Board +code must not block the runtime just because a receiver probe timed out. + +`has_fix` is observation state. It reports whether the latest GPS sample contains +a valid position fix. + +`internal_nmea_stream` is the receiver-to-firmware data path used by the runtime +to parse fixes, satellites, time, and diagnostics. It must remain available while +GPS is enabled. It must not be disabled by privacy settings or external export +settings. + +`external_nmea_output` is user-visible NMEA export. It controls whether parsed or +forwarded NMEA should be exposed outside the firmware. It is independent from +the internal stream and must not change the receiver sentences needed by the +runtime. + +## Required Runtime Contract + +Every platform runtime must implement these operations with the same meaning: + +`is_enabled()` returns whether GPS is enabled by user intent and supported by the +runtime. It does not mean the receiver is powered, ready, or fixed. + +`is_powered()` returns the current receiver power state. + +`set_enabled(enabled)` changes user intent. Setting it to false powers down or +stops the receiver as soon as practical and clears live observations. Setting it +to true allows the runtime to start according to `gps_strategy`. + +`set_collection_interval(interval_ms)` changes how often observations are +published or sampled by policy. It must not enable or disable GPS. + +`set_power_strategy(strategy)` changes power policy for an already enabled GPS +runtime. It must not overwrite `gps_enabled`. + +`set_gnss_config(mode, sat_mask)` changes receiver behavior. It must not enable +or disable GPS. + +`set_external_nmea_config(output_hz, sentence_mask)` changes only external NMEA +export policy. It must not disable receiver output required for internal GPS +operation. + +## Configuration Ownership + +GPS configuration belongs under the GPS domain: + +- `gps_enabled` +- `gps_interval_ms` +- `gps_mode` +- `gps_sat_mask` +- `gps_strategy` +- `gps_alt_ref` +- `gps_coord_format` +- `motion_config` +- `external_nmea_output_hz` +- `external_nmea_sentence_mask` + +Privacy configuration is limited to privacy behavior. It must not own receiver +I/O or runtime control. + +## Startup Rules + +On startup, a runtime must load configuration and apply it in this order: + +1. Apply `gps_enabled`. +2. Apply collection interval. +3. Apply power strategy. +4. Apply GNSS receiver configuration. +5. Apply external NMEA export configuration. + +The internal receiver stream must be configured by the runtime itself whenever +the receiver is powered. A default internal stream must include the minimum +sentences required for location and satellite diagnostics. + +Board startup must only prepare the physical transport and publish transport +readiness. Receiver identity probes are diagnostics, not a condition for starting +the runtime. Platform HAL code must not assume a fixed UART instance or GPIO set; +the board owns UART open and close operations. + +## Non-Goals + +This specification does not require platforms to share the same driver +implementation or GPIO definitions. Board-specific hardware stays board-specific. +Only the product semantics and public runtime contract are unified. + +This specification does not preserve legacy configuration keys whose names +placed NMEA export under privacy or implied that `gps_mode` was an enable flag. diff --git a/modules/core_chat/include/chat/ble/meshcore_phone_core.h b/modules/core_chat/include/chat/ble/meshcore_phone_core.h index 9c3ef31a..5cf024c2 100644 --- a/modules/core_chat/include/chat/ble/meshcore_phone_core.h +++ b/modules/core_chat/include/chat/ble/meshcore_phone_core.h @@ -1,6 +1,6 @@ #pragma once -#include "app/app_facades.h" +#include "chat/ble/phone_runtime_context.h" #include "chat/domain/chat_types.h" #include @@ -119,7 +119,7 @@ class MeshCorePhoneHooks class MeshCorePhoneCore { public: - MeshCorePhoneCore(app::IAppBleFacade& ctx, const std::string& device_name, + MeshCorePhoneCore(IPhoneRuntimeContext& ctx, const std::string& device_name, MeshCorePhoneHooks* hooks = nullptr); void reset(); @@ -138,7 +138,7 @@ class MeshCorePhoneCore uint32_t resolveNodeIdFromPrefix(const uint8_t* prefix, size_t len) const; bool buildContactFromNode(const chat::contacts::NodeEntry& entry, uint8_t code, MeshCoreBleFrame& out) const; - app::IAppBleFacade& ctx_; + IPhoneRuntimeContext& ctx_; std::string device_name_; MeshCorePhoneHooks* hooks_ = nullptr; std::deque tx_queue_; diff --git a/modules/core_chat/include/chat/ble/meshtastic_phone_config_bridge.h b/modules/core_chat/include/chat/ble/meshtastic_phone_config_bridge.h new file mode 100644 index 00000000..cd796fd7 --- /dev/null +++ b/modules/core_chat/include/chat/ble/meshtastic_phone_config_bridge.h @@ -0,0 +1,249 @@ +#pragma once + +#include "chat/ble/meshtastic_defaults.h" +#include "meshtastic/config.pb.h" +#include "meshtastic/localonly.pb.h" +#include "meshtastic/mesh.pb.h" + +#include +#include + +namespace ble::meshtastic_config_bridge +{ + +struct PersistedState +{ + bool has_bluetooth = false; + bool has_module = false; + meshtastic_Config_BluetoothConfig bluetooth = meshtastic_Config_BluetoothConfig_init_zero; + meshtastic_LocalModuleConfig module = meshtastic_LocalModuleConfig_init_zero; +}; + +inline bool isValidBluetoothMode(uint8_t mode) +{ + return mode <= static_cast(meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN); +} + +inline bool isValidBlePin(uint32_t pin) +{ + return pin == 0 || (pin >= 100000U && pin <= 999999U); +} + +inline void copyBounded(char* dst, size_t dst_len, const char* src) +{ + if (!dst || dst_len == 0) + { + return; + } + if (!src) + { + dst[0] = '\0'; + return; + } + std::strncpy(dst, src, dst_len - 1); + dst[dst_len - 1] = '\0'; +} + +inline void normalizeBluetoothConfig(meshtastic_Config_BluetoothConfig* config) +{ + if (!config) + { + return; + } + if (!isValidBluetoothMode(static_cast(config->mode))) + { + config->mode = meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN; + } + if (!isValidBlePin(config->fixed_pin) || + config->mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) + { + config->fixed_pin = 0; + } +} + +inline bool loadBluetoothConfigView(const meshtastic_Config_BluetoothConfig& stored, + bool runtime_enabled, + meshtastic_Config_BluetoothConfig* out) +{ + if (!out) + { + return false; + } + *out = stored; + normalizeBluetoothConfig(out); + out->enabled = runtime_enabled; + return true; +} + +inline uint32_t resolveBlePasskey(const meshtastic_Config_BluetoothConfig& config, uint32_t pending_passkey) +{ + if (pending_passkey != 0) + { + return pending_passkey; + } + if (config.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN) + { + return config.fixed_pin; + } + return 0; +} + +inline bool fillDeviceConnectionStatus(uint32_t passkey, + bool is_connected, + meshtastic_DeviceConnectionStatus* out) +{ + if (!out) + { + return false; + } + + meshtastic_DeviceConnectionStatus status = meshtastic_DeviceConnectionStatus_init_zero; + *out = status; + out->has_bluetooth = true; + out->bluetooth.pin = passkey; + out->bluetooth.rssi = 0; + out->bluetooth.is_connected = is_connected; + return true; +} + +inline void repairLegacyMqttConfig(meshtastic_LocalModuleConfig* config) +{ + if (!config) + { + return; + } + + config->has_mqtt = true; + config->mqtt.address[sizeof(config->mqtt.address) - 1] = '\0'; + config->mqtt.username[sizeof(config->mqtt.username) - 1] = '\0'; + config->mqtt.password[sizeof(config->mqtt.password) - 1] = '\0'; + config->mqtt.root[sizeof(config->mqtt.root) - 1] = '\0'; + if (config->mqtt.address[0] == '\0') + { + copyBounded(config->mqtt.address, sizeof(config->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); + } + if (config->mqtt.username[0] == '\0') + { + copyBounded(config->mqtt.username, sizeof(config->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); + } + if (config->mqtt.password[0] == '\0') + { + copyBounded(config->mqtt.password, sizeof(config->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); + } + if (config->mqtt.root[0] == '\0') + { + copyBounded(config->mqtt.root, sizeof(config->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); + } + if (!config->mqtt.has_map_report_settings) + { + config->mqtt.has_map_report_settings = true; + config->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; + config->mqtt.map_report_settings.position_precision = 0; + config->mqtt.map_report_settings.should_report_location = false; + } +} + +inline void initDefaultModuleConfig(meshtastic_LocalModuleConfig* config, uint32_t self_node) +{ + if (!config) + { + return; + } + + meshtastic_LocalModuleConfig zero = meshtastic_LocalModuleConfig_init_zero; + *config = zero; + config->version = meshtastic_defaults::kModuleConfigVersion; + config->has_mqtt = true; + config->has_serial = true; + config->has_external_notification = true; + config->has_store_forward = true; + config->has_range_test = true; + config->has_telemetry = true; + config->has_canned_message = true; + config->has_audio = true; + config->has_remote_hardware = true; + config->has_neighbor_info = true; + config->has_ambient_lighting = true; + config->has_detection_sensor = true; + config->has_paxcounter = true; + + copyBounded(config->mqtt.address, sizeof(config->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); + copyBounded(config->mqtt.username, sizeof(config->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); + copyBounded(config->mqtt.password, sizeof(config->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); + copyBounded(config->mqtt.root, sizeof(config->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); + config->mqtt.enabled = false; + config->mqtt.proxy_to_client_enabled = false; + config->mqtt.encryption_enabled = meshtastic_defaults::kDefaultMqttEncryptionEnabled; + config->mqtt.tls_enabled = meshtastic_defaults::kDefaultMqttTlsEnabled; + config->mqtt.has_map_report_settings = true; + config->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; + config->mqtt.map_report_settings.position_precision = 0; + config->mqtt.map_report_settings.should_report_location = false; + + config->telemetry.device_update_interval = 3600; + config->telemetry.device_telemetry_enabled = true; + config->telemetry.environment_update_interval = 0; + config->telemetry.environment_measurement_enabled = false; + config->telemetry.power_update_interval = 0; + config->telemetry.health_update_interval = 0; + config->telemetry.air_quality_interval = 0; + + config->neighbor_info.enabled = false; + config->neighbor_info.update_interval = 0; + config->neighbor_info.transmit_over_lora = false; + + config->detection_sensor.enabled = false; + config->detection_sensor.detection_trigger_type = + meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_LOGIC_HIGH; + config->detection_sensor.minimum_broadcast_secs = meshtastic_defaults::kDefaultDetectionMinBroadcastSecs; + + config->ambient_lighting.current = meshtastic_defaults::kDefaultAmbientCurrent; + config->ambient_lighting.red = (self_node >> 16) & 0xFFU; + config->ambient_lighting.green = (self_node >> 8) & 0xFFU; + config->ambient_lighting.blue = self_node & 0xFFU; +} + +inline void normalizeModuleConfig(meshtastic_LocalModuleConfig* config) +{ + if (!config) + { + return; + } + if (config->version == 0) + { + config->version = meshtastic_defaults::kModuleConfigVersion; + } + repairLegacyMqttConfig(config); +} + +inline void initializeConfigState(const PersistedState& persisted, + bool default_enabled, + uint32_t self_node, + meshtastic_Config_BluetoothConfig* out_bluetooth, + meshtastic_LocalModuleConfig* out_module) +{ + if (out_bluetooth) + { + *out_bluetooth = meshtastic_Config_BluetoothConfig_init_zero; + out_bluetooth->enabled = default_enabled; + out_bluetooth->mode = meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN; + out_bluetooth->fixed_pin = 0; + if (persisted.has_bluetooth) + { + *out_bluetooth = persisted.bluetooth; + } + normalizeBluetoothConfig(out_bluetooth); + } + + if (out_module) + { + initDefaultModuleConfig(out_module, self_node); + if (persisted.has_module) + { + *out_module = persisted.module; + normalizeModuleConfig(out_module); + } + } +} + +} // namespace ble::meshtastic_config_bridge diff --git a/modules/core_chat/include/chat/ble/meshtastic_phone_core.h b/modules/core_chat/include/chat/ble/meshtastic_phone_core.h index 60e430d0..57529992 100644 --- a/modules/core_chat/include/chat/ble/meshtastic_phone_core.h +++ b/modules/core_chat/include/chat/ble/meshtastic_phone_core.h @@ -1,6 +1,6 @@ #pragma once -#include "app/app_facades.h" +#include "chat/ble/phone_runtime_context.h" #include "chat/domain/chat_types.h" #include "chat/ports/i_node_store.h" #include "meshtastic/admin.pb.h" @@ -24,6 +24,20 @@ struct MeshtasticBleFrame uint8_t buf[meshtastic_FromRadio_size] = {}; }; +struct MeshtasticGpsFix +{ + bool valid = false; + double lat = 0.0; + double lng = 0.0; + bool has_alt = false; + double alt_m = 0.0; + bool has_speed = false; + double speed_mps = 0.0; + bool has_course = false; + double course_deg = 0.0; + uint8_t satellites = 0; +}; + class MeshtasticPhoneTransport { public: @@ -32,10 +46,10 @@ class MeshtasticPhoneTransport virtual void notifyFromNum(uint32_t from_num) = 0; }; -class MeshtasticPhoneHooks +class MeshtasticPhoneBluetoothConfigHooks { public: - virtual ~MeshtasticPhoneHooks() = default; + virtual ~MeshtasticPhoneBluetoothConfigHooks() = default; virtual bool loadBluetoothConfig(meshtastic_Config_BluetoothConfig* out) const { (void)out; @@ -45,11 +59,12 @@ class MeshtasticPhoneHooks { (void)config; } - virtual bool loadDeviceConnectionStatus(meshtastic_DeviceConnectionStatus* out) const - { - (void)out; - return false; - } +}; + +class MeshtasticPhoneModuleConfigHooks +{ + public: + virtual ~MeshtasticPhoneModuleConfigHooks() = default; virtual bool loadModuleConfig(meshtastic_LocalModuleConfig* out) const { (void)out; @@ -59,6 +74,31 @@ class MeshtasticPhoneHooks { (void)config; } +}; + +class MeshtasticPhoneConfigLifecycleHooks +{ + public: + virtual ~MeshtasticPhoneConfigLifecycleHooks() = default; + virtual void onConfigStart() {} + virtual void onConfigComplete() {} +}; + +class MeshtasticPhoneStatusHooks +{ + public: + virtual ~MeshtasticPhoneStatusHooks() = default; + virtual bool loadDeviceConnectionStatus(meshtastic_DeviceConnectionStatus* out) const + { + (void)out; + return false; + } +}; + +class MeshtasticPhoneMqttHooks +{ + public: + virtual ~MeshtasticPhoneMqttHooks() = default; virtual bool handleMqttProxyToRadio(const meshtastic_MqttClientProxyMessage& msg) { (void)msg; @@ -69,15 +109,51 @@ class MeshtasticPhoneHooks (void)out; return false; } - virtual void onConfigStart() {} - virtual void onConfigComplete() {} +}; + +class MeshtasticPhoneDeviceRuntimeHooks +{ + public: + virtual ~MeshtasticPhoneDeviceRuntimeHooks() = default; + virtual bool loadTimezoneTzdef(char* out, size_t out_len) const + { + (void)out; + (void)out_len; + return false; + } + virtual void saveTimezoneTzdef(const char* tzdef) + { + (void)tzdef; + } + virtual int getTimezoneOffsetMinutes() const + { + return 0; + } + virtual void setTimezoneOffsetMinutes(int offset_min) + { + (void)offset_min; + } + virtual bool getGpsFix(MeshtasticGpsFix* out) const + { + if (!out) + { + return false; + } + *out = {}; + return false; + } }; class MeshtasticPhoneCore { public: - MeshtasticPhoneCore(app::IAppBleFacade& ctx, MeshtasticPhoneTransport& transport, - MeshtasticPhoneHooks* hooks = nullptr); + MeshtasticPhoneCore(IPhoneRuntimeContext& ctx, MeshtasticPhoneTransport& transport, + MeshtasticPhoneBluetoothConfigHooks* bluetooth_config_hooks = nullptr, + MeshtasticPhoneModuleConfigHooks* module_config_hooks = nullptr, + MeshtasticPhoneConfigLifecycleHooks* config_lifecycle_hooks = nullptr, + MeshtasticPhoneStatusHooks* status_hooks = nullptr, + MeshtasticPhoneMqttHooks* mqtt_hooks = nullptr, + MeshtasticPhoneDeviceRuntimeHooks* device_runtime_hooks = nullptr); void reset(); void pumpIncomingAppData(); @@ -119,9 +195,14 @@ class MeshtasticPhoneCore meshtastic_MeshPacket buildPacketFromText(const chat::MeshIncomingText& msg) const; meshtastic_MeshPacket buildPacketFromData(const chat::MeshIncomingData& msg) const; - app::IAppBleFacade& ctx_; + IPhoneRuntimeContext& ctx_; MeshtasticPhoneTransport& transport_; - MeshtasticPhoneHooks* hooks_ = nullptr; + MeshtasticPhoneBluetoothConfigHooks* bluetooth_config_hooks_ = nullptr; + MeshtasticPhoneModuleConfigHooks* module_config_hooks_ = nullptr; + MeshtasticPhoneConfigLifecycleHooks* config_lifecycle_hooks_ = nullptr; + MeshtasticPhoneStatusHooks* status_hooks_ = nullptr; + MeshtasticPhoneMqttHooks* mqtt_hooks_ = nullptr; + MeshtasticPhoneDeviceRuntimeHooks* device_runtime_hooks_ = nullptr; uint32_t config_nonce_ = 0; size_t config_node_index_ = 0; uint8_t config_channel_index_ = 0; diff --git a/modules/core_chat/include/chat/ble/meshtastic_phone_session.h b/modules/core_chat/include/chat/ble/meshtastic_phone_session.h index e74efb57..853310d4 100644 --- a/modules/core_chat/include/chat/ble/meshtastic_phone_session.h +++ b/modules/core_chat/include/chat/ble/meshtastic_phone_session.h @@ -8,7 +8,14 @@ namespace ble class MeshtasticPhoneSession { public: - MeshtasticPhoneSession(app::IAppBleFacade& ctx, MeshtasticPhoneTransport& transport, MeshtasticPhoneHooks* hooks); + MeshtasticPhoneSession(IPhoneRuntimeContext& ctx, + MeshtasticPhoneTransport& transport, + MeshtasticPhoneBluetoothConfigHooks* bluetooth_config_hooks, + MeshtasticPhoneModuleConfigHooks* module_config_hooks, + MeshtasticPhoneConfigLifecycleHooks* config_lifecycle_hooks, + MeshtasticPhoneStatusHooks* status_hooks, + MeshtasticPhoneMqttHooks* mqtt_hooks, + MeshtasticPhoneDeviceRuntimeHooks* device_runtime_hooks); void close(); void pumpIncomingAppData(); diff --git a/modules/core_chat/include/chat/ble/phone_runtime_context.h b/modules/core_chat/include/chat/ble/phone_runtime_context.h new file mode 100644 index 00000000..bb2d4828 --- /dev/null +++ b/modules/core_chat/include/chat/ble/phone_runtime_context.h @@ -0,0 +1,87 @@ +#pragma once + +#include "chat/domain/chat_types.h" + +#include +#include +#include + +namespace chat +{ +class ChatService; +class IMeshAdapter; +namespace contacts +{ +class ContactService; +class INodeStore; +} // namespace contacts +} // namespace chat + +namespace ble +{ + +struct MeshtasticPhoneConfigSnapshot +{ + chat::MeshConfig mesh{}; + bool relay_enabled = false; + char node_name[32] = {}; + char short_name[16] = {}; + bool primary_enabled = false; + bool secondary_enabled = false; + bool primary_uplink_enabled = false; + bool primary_downlink_enabled = false; + bool secondary_uplink_enabled = false; + bool secondary_downlink_enabled = false; + bool gps_enabled = true; + uint8_t gps_mode = 0; + uint32_t gps_interval_ms = 0; + uint8_t gps_strategy = 0; +}; + +struct MeshCorePhoneConfigSnapshot +{ + chat::MeshProtocol active_protocol = chat::MeshProtocol::Meshtastic; + chat::MeshConfig mesh{}; + char node_name[32] = {}; + char short_name[16] = {}; + int8_t tx_power_min_dbm = 0; + int8_t tx_power_max_dbm = 0; +}; + +class IPhoneRuntimeContext +{ + public: + virtual ~IPhoneRuntimeContext() = default; + + virtual MeshtasticPhoneConfigSnapshot getMeshtasticPhoneConfig() const = 0; + virtual void setMeshtasticPhoneConfig(const MeshtasticPhoneConfigSnapshot& config) = 0; + virtual MeshCorePhoneConfigSnapshot getMeshCorePhoneConfig() const = 0; + virtual void setMeshCorePhoneConfig(const MeshCorePhoneConfigSnapshot& config) = 0; + virtual void saveConfig() = 0; + virtual void applyMeshConfig() = 0; + virtual void applyUserInfo() = 0; + virtual void applyPositionConfig() = 0; + virtual void getEffectiveUserInfo(char* out_long, + std::size_t long_len, + char* out_short, + std::size_t short_len) const = 0; + + virtual chat::ChatService& getChatService() = 0; + virtual chat::contacts::ContactService& getContactService() = 0; + virtual chat::IMeshAdapter* getMeshAdapter() = 0; + virtual const chat::IMeshAdapter* getMeshAdapter() const = 0; + virtual chat::contacts::INodeStore* getNodeStore() = 0; + virtual const chat::contacts::INodeStore* getNodeStore() const = 0; + virtual chat::NodeId getSelfNodeId() const = 0; + + virtual bool isBleEnabled() const = 0; + virtual void setBleEnabled(bool enabled) = 0; + virtual bool getDeviceMacAddress(uint8_t out_mac[6]) const = 0; + virtual bool syncCurrentEpochSeconds(uint32_t epoch_seconds) = 0; + virtual void resetMeshConfig() = 0; + virtual void clearNodeDb() = 0; + virtual void clearMessageDb() = 0; + virtual void restartDevice() = 0; +}; + +} // namespace ble diff --git a/modules/core_chat/include/chat/infra/meshtastic/mt_packet_wire.h b/modules/core_chat/include/chat/infra/meshtastic/mt_packet_wire.h index 0cf579f3..60dc856a 100644 --- a/modules/core_chat/include/chat/infra/meshtastic/mt_packet_wire.h +++ b/modules/core_chat/include/chat/infra/meshtastic/mt_packet_wire.h @@ -8,11 +8,22 @@ #include #include +#if defined(_MSC_VER) +#define TRAILMATE_PACK_PUSH __pragma(pack(push, 1)) +#define TRAILMATE_PACK_POP __pragma(pack(pop)) +#define TRAILMATE_PACKED +#else +#define TRAILMATE_PACK_PUSH +#define TRAILMATE_PACK_POP +#define TRAILMATE_PACKED __attribute__((packed)) +#endif + namespace chat { namespace meshtastic { +TRAILMATE_PACK_PUSH struct PacketHeaderWire { uint32_t to; @@ -22,7 +33,8 @@ struct PacketHeaderWire uint8_t channel; uint8_t next_hop; uint8_t relay_node; -} __attribute__((packed)); +} TRAILMATE_PACKED; +TRAILMATE_PACK_POP constexpr uint8_t PACKET_FLAGS_HOP_LIMIT_MASK = 0x07; constexpr uint8_t PACKET_FLAGS_WANT_ACK_MASK = 0x08; @@ -48,3 +60,7 @@ bool decryptPayload(const PacketHeaderWire& header, } // namespace meshtastic } // namespace chat + +#undef TRAILMATE_PACK_PUSH +#undef TRAILMATE_PACK_POP +#undef TRAILMATE_PACKED diff --git a/modules/core_chat/include/chat/infra/node_store_blob_format.h b/modules/core_chat/include/chat/infra/node_store_blob_format.h index d2215ecf..22287e18 100644 --- a/modules/core_chat/include/chat/infra/node_store_blob_format.h +++ b/modules/core_chat/include/chat/infra/node_store_blob_format.h @@ -8,6 +8,16 @@ #include #include +#if defined(_MSC_VER) +#define TRAILMATE_PACK_PUSH __pragma(pack(push, 1)) +#define TRAILMATE_PACK_POP __pragma(pack(pop)) +#define TRAILMATE_PACKED +#else +#define TRAILMATE_PACK_PUSH +#define TRAILMATE_PACK_POP +#define TRAILMATE_PACKED __attribute__((packed)) +#endif + namespace chat { namespace contacts @@ -25,13 +35,15 @@ enum class NodeBlobValidation TooManyEntries, }; +TRAILMATE_PACK_PUSH struct NodeStoreSdHeader { uint8_t ver; uint8_t reserved[3]; uint32_t crc; uint32_t count; -} __attribute__((packed)); +} TRAILMATE_PACKED; +TRAILMATE_PACK_POP bool isValidNodeBlobSize(size_t len); size_t nodeBlobEntryCount(size_t len); @@ -47,3 +59,7 @@ NodeBlobValidation validateNodeStoreSdBlob(const NodeStoreSdHeader& header, } // namespace contacts } // namespace chat + +#undef TRAILMATE_PACK_PUSH +#undef TRAILMATE_PACK_POP +#undef TRAILMATE_PACKED diff --git a/modules/core_chat/src/ble/meshcore_phone_core.cpp b/modules/core_chat/src/ble/meshcore_phone_core.cpp index 3b517f3f..49c8725b 100644 --- a/modules/core_chat/src/ble/meshcore_phone_core.cpp +++ b/modules/core_chat/src/ble/meshcore_phone_core.cpp @@ -1,13 +1,11 @@ #include "chat/ble/meshcore_phone_core.h" -#include "app/app_config.h" #include "chat/infra/meshcore/meshcore_ble_backend.h" #include "chat/ports/i_mesh_adapter.h" #include "chat/ports/i_node_store.h" #include "chat/usecase/chat_service.h" #include "sys/clock.h" -#include #include #include #include @@ -134,10 +132,10 @@ uint32_t nowSeconds() return sys::epoch_seconds_now(); } -chat::meshcore::IMeshCoreBleBackend* resolveMeshCoreAdapter(app::IAppBleFacade& ctx) +chat::meshcore::IMeshCoreBleBackend* resolveMeshCoreAdapter(IPhoneRuntimeContext& ctx) { auto* adapter = ctx.getMeshAdapter(); - if (!adapter || ctx.getConfig().mesh_protocol != chat::MeshProtocol::MeshCore) + if (!adapter || ctx.getMeshCorePhoneConfig().active_protocol != chat::MeshProtocol::MeshCore) { return nullptr; } @@ -152,7 +150,7 @@ chat::meshcore::IMeshCoreBleBackend* resolveMeshCoreAdapter(app::IAppBleFacade& } // namespace -MeshCorePhoneCore::MeshCorePhoneCore(app::IAppBleFacade& ctx, const std::string& device_name, MeshCorePhoneHooks* hooks) +MeshCorePhoneCore::MeshCorePhoneCore(IPhoneRuntimeContext& ctx, const std::string& device_name, MeshCorePhoneHooks* hooks) : ctx_(ctx), device_name_(device_name), hooks_(hooks) {} void MeshCorePhoneCore::reset() @@ -315,7 +313,7 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) { const uint8_t cmd = data[0]; chat::IMeshAdapter* adapter = ctx_.getMeshAdapter(); - auto& cfg = ctx_.getConfig(); + auto cfg = ctx_.getMeshCorePhoneConfig(); auto* backend = resolveMeshCoreAdapter(ctx_); if (cmd == CMD_DEVICE_QEURY && len >= 2) @@ -346,8 +344,8 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) size_t index = 0; out[index++] = RESP_CODE_SELF_INFO; out[index++] = ADV_TYPE_CHAT; - out[index++] = static_cast(cfg.meshcore_config.tx_power); - out[index++] = static_cast(app::AppConfig::kTxPowerMaxDbm); + out[index++] = static_cast(cfg.mesh.tx_power); + out[index++] = static_cast(cfg.tx_power_max_dbm); uint8_t pubkey[chat::meshcore::kMeshCorePubKeySize] = {}; if (backend) @@ -365,19 +363,19 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) std::memcpy(&out[index], &lon, sizeof(lon)); index += sizeof(lon); - out[index++] = cfg.meshcore_config.meshcore_multi_acks ? 1U : 0U; + out[index++] = cfg.mesh.meshcore_multi_acks ? 1U : 0U; out[index++] = hooks_ ? hooks_->getAdvertLocationPolicy() : 0U; out[index++] = hooks_ ? hooks_->getTelemetryModeBits() : 0U; out[index++] = (hooks_ && hooks_->getManualAddContacts()) ? 1U : 0U; - const uint32_t freq = static_cast(cfg.meshcore_config.meshcore_freq_mhz * 1000.0f); - const uint32_t bw = static_cast(cfg.meshcore_config.meshcore_bw_khz * 1000.0f); + const uint32_t freq = static_cast(cfg.mesh.meshcore_freq_mhz * 1000.0f); + const uint32_t bw = static_cast(cfg.mesh.meshcore_bw_khz * 1000.0f); std::memcpy(&out[index], &freq, sizeof(freq)); index += sizeof(freq); std::memcpy(&out[index], &bw, sizeof(bw)); index += sizeof(bw); - out[index++] = cfg.meshcore_config.meshcore_sf; - out[index++] = cfg.meshcore_config.meshcore_cr; + out[index++] = cfg.mesh.meshcore_sf; + out[index++] = cfg.mesh.meshcore_cr; char long_name[32] = {}; char short_name[16] = {}; @@ -613,6 +611,7 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) const size_t nlen = std::min(len - 1, sizeof(cfg.node_name) - 1); std::memcpy(cfg.node_name, &data[1], nlen); cfg.node_name[nlen] = '\0'; + ctx_.setMeshCorePhoneConfig(cfg); ctx_.saveConfig(); ctx_.applyUserInfo(); enqueueSentOk(); @@ -827,10 +826,11 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) enqueueErr(ERR_CODE_ILLEGAL_ARG); return; } - cfg.meshcore_config.meshcore_freq_mhz = static_cast(freq) / 1000.0f; - cfg.meshcore_config.meshcore_bw_khz = static_cast(bw) / 1000.0f; - cfg.meshcore_config.meshcore_sf = sf; - cfg.meshcore_config.meshcore_cr = cr; + cfg.mesh.meshcore_freq_mhz = static_cast(freq) / 1000.0f; + cfg.mesh.meshcore_bw_khz = static_cast(bw) / 1000.0f; + cfg.mesh.meshcore_sf = sf; + cfg.mesh.meshcore_cr = cr; + ctx_.setMeshCorePhoneConfig(cfg); ctx_.saveConfig(); ctx_.applyMeshConfig(); enqueueSentOk(); @@ -840,12 +840,13 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) if (cmd == CMD_SET_RADIO_TX_POWER && len >= 2) { const int8_t tx = static_cast(data[1]); - if (tx < app::AppConfig::kTxPowerMinDbm || tx > app::AppConfig::kTxPowerMaxDbm) + if (tx < cfg.tx_power_min_dbm || tx > cfg.tx_power_max_dbm) { enqueueErr(ERR_CODE_ILLEGAL_ARG); return; } - cfg.meshcore_config.tx_power = tx; + cfg.mesh.tx_power = tx; + ctx_.setMeshCorePhoneConfig(cfg); ctx_.saveConfig(); ctx_.applyMeshConfig(); enqueueSentOk(); @@ -927,16 +928,16 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) out[index++] = channel_idx; if (channel_idx == 0U) { - copyBounded(reinterpret_cast(&out[index]), 32, cfg.meshcore_config.meshcore_channel_name); + copyBounded(reinterpret_cast(&out[index]), 32, cfg.mesh.meshcore_channel_name); index += 32; - std::memcpy(&out[index], cfg.meshcore_config.primary_key, 16); + std::memcpy(&out[index], cfg.mesh.primary_key, 16); index += 16; } else { copyBounded(reinterpret_cast(&out[index]), 32, "Secondary"); index += 32; - std::memcpy(&out[index], cfg.meshcore_config.secondary_key, 16); + std::memcpy(&out[index], cfg.mesh.secondary_key, 16); index += 16; } enqueueFrame(out, index); @@ -995,7 +996,7 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) uint8_t req_data[9] = {}; req_data[0] = 0x03; req_data[1] = static_cast(~0x01U); - const uint32_t nonce = static_cast(millis()); + const uint32_t nonce = sys::millis_now(); std::memcpy(&req_data[5], &nonce, sizeof(nonce)); uint32_t tag = 0; @@ -1299,7 +1300,7 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) return; } enqueueSentOk(); - delay(100); + sys::sleep_ms(100); ctx_.restartDevice(); return; } @@ -1411,15 +1412,16 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) } if (channel_idx == 0U) { - copyBounded(cfg.meshcore_config.meshcore_channel_name, - sizeof(cfg.meshcore_config.meshcore_channel_name), + copyBounded(cfg.mesh.meshcore_channel_name, + sizeof(cfg.mesh.meshcore_channel_name), reinterpret_cast(&data[2])); - std::memcpy(cfg.meshcore_config.primary_key, &data[34], 16); + std::memcpy(cfg.mesh.primary_key, &data[34], 16); } else { - std::memcpy(cfg.meshcore_config.secondary_key, &data[34], 16); + std::memcpy(cfg.mesh.secondary_key, &data[34], 16); } + ctx_.setMeshCorePhoneConfig(cfg); ctx_.saveConfig(); ctx_.applyMeshConfig(); enqueueSentOk(); @@ -1565,7 +1567,7 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) out[index++] = STATS_TYPE_CORE; const auto battery = hooks_ ? hooks_->getBatteryInfo() : MeshCorePhoneBatteryInfo{}; const uint16_t mv = battery.millivolts; - const uint32_t uptime = millis() / 1000U; + const uint32_t uptime = sys::uptime_seconds_now(); const uint16_t err_flags = 0; const uint8_t queue_len = static_cast(tx_queue_.size()); std::memcpy(&out[index], &mv, sizeof(mv)); @@ -1661,7 +1663,7 @@ void MeshCorePhoneCore::handleCmdFrame(const uint8_t* data, size_t len) sign_data_.clear(); sign_active_ = false; enqueueSentOk(); - delay(100); + sys::sleep_ms(100); ctx_.restartDevice(); return; } diff --git a/modules/core_chat/src/ble/meshtastic_phone_core.cpp b/modules/core_chat/src/ble/meshtastic_phone_core.cpp index 49795598..cb1c115e 100644 --- a/modules/core_chat/src/ble/meshtastic_phone_core.cpp +++ b/modules/core_chat/src/ble/meshtastic_phone_core.cpp @@ -1,19 +1,15 @@ #include "chat/ble/meshtastic_phone_core.h" -#include "app/app_config.h" #include "chat/ble/meshtastic_defaults.h" +#include "chat/ble/meshtastic_phone_config_bridge.h" #include "chat/ports/i_mesh_adapter.h" #include "chat/runtime/self_identity_policy.h" #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "pb_decode.h" #include "pb_encode.h" -#include "platform/ui/gps_runtime.h" -#include "platform/ui/settings_store.h" -#include "platform/ui/time_runtime.h" #include "sys/clock.h" -#include #include #include #include @@ -21,22 +17,6 @@ #include #include #include -#include - -#if defined(__has_include) -#if __has_include() -#include -#define TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION 1 -#elif __has_include("freertos/FreeRTOS.h") && __has_include("freertos/task.h") -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#define TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION 1 -#else -#define TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION 0 -#endif -#else -#define TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION 0 -#endif namespace ble { @@ -44,8 +24,6 @@ namespace { constexpr uint8_t kQueueDepthHint = 4; -constexpr const char* kUiSettingsNs = "settings"; -constexpr const char* kTimezoneTzdefKey = "timezone_tzdef"; constexpr meshtastic_AdminMessage_ConfigType kConfigSnapshotTypes[] = { meshtastic_AdminMessage_ConfigType_DEVICE_CONFIG, meshtastic_AdminMessage_ConfigType_POSITION_CONFIG, @@ -83,8 +61,7 @@ void logDual(const char* format, ...) va_start(args, format); vsnprintf(buffer, sizeof(buffer), format, args); va_end(args); - Serial.print(buffer); - Serial2.print(buffer); + std::printf("%s", buffer); } void copyBounded(char* dst, size_t dst_len, const char* src) @@ -215,7 +192,7 @@ void buildFixedPosixTzdef(int offset_min, char* out, size_t out_len) } } -bool loadStoredTimezoneTzdef(char* out, size_t out_len) +bool loadStoredTimezoneTzdef(const MeshtasticPhoneDeviceRuntimeHooks* device_runtime_hooks, char* out, size_t out_len) { if (!out || out_len == 0) { @@ -223,31 +200,23 @@ bool loadStoredTimezoneTzdef(char* out, size_t out_len) } out[0] = '\0'; - std::vector blob; - if (!::platform::ui::settings_store::get_blob(kUiSettingsNs, kTimezoneTzdefKey, blob) || blob.empty()) + if (!device_runtime_hooks) { return false; } - - const size_t copy_len = std::min(out_len - 1U, blob.size()); - std::memcpy(out, blob.data(), copy_len); - out[copy_len] = '\0'; - return out[0] != '\0'; + return device_runtime_hooks->loadTimezoneTzdef(out, out_len); } -void saveStoredTimezoneTzdef(const char* tzdef) +void saveStoredTimezoneTzdef(MeshtasticPhoneDeviceRuntimeHooks* device_runtime_hooks, const char* tzdef) { - if (!tzdef || tzdef[0] == '\0') + if (!device_runtime_hooks) { - const char* keys[] = {kTimezoneTzdefKey}; - ::platform::ui::settings_store::remove_keys(kUiSettingsNs, keys, 1); return; } - - (void)::platform::ui::settings_store::put_blob(kUiSettingsNs, kTimezoneTzdefKey, tzdef, std::strlen(tzdef) + 1U); + device_runtime_hooks->saveTimezoneTzdef(tzdef); } -void buildLinkedTimezoneTzdef(char* out, size_t out_len) +void buildLinkedTimezoneTzdef(const MeshtasticPhoneDeviceRuntimeHooks* device_runtime_hooks, char* out, size_t out_len) { if (!out || out_len == 0) { @@ -255,9 +224,9 @@ void buildLinkedTimezoneTzdef(char* out, size_t out_len) } out[0] = '\0'; - const int current_offset_min = ::platform::ui::time::timezone_offset_min(); + const int current_offset_min = device_runtime_hooks ? device_runtime_hooks->getTimezoneOffsetMinutes() : 0; char stored[65] = {}; - if (loadStoredTimezoneTzdef(stored, sizeof(stored))) + if (loadStoredTimezoneTzdef(device_runtime_hooks, stored, sizeof(stored))) { int parsed_offset_min = 0; if (parsePosixTzOffsetMinutes(stored, &parsed_offset_min) && parsed_offset_min == current_offset_min) @@ -275,40 +244,42 @@ uint32_t nowSeconds() return sys::epoch_seconds_now(); } -bool buildSelfPositionPayload(uint8_t* out_buf, size_t* out_len) +bool buildSelfPositionPayload(const MeshtasticPhoneDeviceRuntimeHooks* device_runtime_hooks, + uint8_t* out_buf, + size_t* out_len) { if (!out_buf || !out_len || *out_len == 0) { return false; } - const platform::ui::gps::GpsState gps_state = platform::ui::gps::get_data(); - if (!gps_state.valid) + MeshtasticGpsFix gps_fix{}; + if (!device_runtime_hooks || !device_runtime_hooks->getGpsFix(&gps_fix) || !gps_fix.valid) { return false; } meshtastic_Position pos = meshtastic_Position_init_zero; pos.has_latitude_i = true; - pos.latitude_i = static_cast(std::lround(gps_state.lat * 1e7)); + pos.latitude_i = static_cast(std::lround(gps_fix.lat * 1e7)); pos.has_longitude_i = true; - pos.longitude_i = static_cast(std::lround(gps_state.lng * 1e7)); + pos.longitude_i = static_cast(std::lround(gps_fix.lng * 1e7)); pos.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; - if (gps_state.has_alt) + if (gps_fix.has_alt) { pos.has_altitude = true; - pos.altitude = static_cast(std::lround(gps_state.alt_m)); + pos.altitude = static_cast(std::lround(gps_fix.alt_m)); pos.altitude_source = meshtastic_Position_AltSource_ALT_INTERNAL; } - if (gps_state.has_speed) + if (gps_fix.has_speed) { pos.has_ground_speed = true; - pos.ground_speed = static_cast(std::lround(gps_state.speed_mps)); + pos.ground_speed = static_cast(std::lround(gps_fix.speed_mps)); } - if (gps_state.has_course) + if (gps_fix.has_course) { - double course = gps_state.course_deg; + double course = gps_fix.course_deg; if (course < 0.0) { course = 0.0; @@ -321,9 +292,9 @@ bool buildSelfPositionPayload(uint8_t* out_buf, size_t* out_len) pos.has_ground_track = true; pos.ground_track = cdeg; } - if (gps_state.satellites > 0) + if (gps_fix.satellites > 0) { - pos.sats_in_view = gps_state.satellites; + pos.sats_in_view = gps_fix.satellites; } const uint32_t ts = nowSeconds(); @@ -349,18 +320,7 @@ uint8_t channelIndexFromId(chat::ChannelId channel) void logRuntimeFootprint(const char* stage) { -#if TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION - const char* task_name = pcTaskGetName(nullptr); - const unsigned long stack_hwm = - static_cast(uxTaskGetStackHighWaterMark(nullptr) * sizeof(StackType_t)); - logDual("[BLE][mtcore][rt] stage=%s task=%s stack_hwm=%lu\n", - stage ? stage : "unknown", - task_name ? task_name : "?", - stack_hwm); -#else - logDual("[BLE][mtcore][rt] stage=%s task=? stack_hwm=0\n", - stage ? stage : "unknown"); -#endif + logDual("[BLE][mtcore][rt] stage=%s\n", stage ? stage : "unknown"); } const char* configStageName(uint32_t nonce) @@ -413,89 +373,16 @@ meshtastic_Config_DeviceConfig_Role roleFromEntry(uint8_t role) void initDefaultModuleConfig(meshtastic_LocalModuleConfig* out, uint32_t self_node) { - if (!out) + meshtastic_config_bridge::initDefaultModuleConfig(out, self_node); + if (out) { - return; + out->ambient_lighting.current = 8; } - meshtastic_LocalModuleConfig zero = meshtastic_LocalModuleConfig_init_zero; - *out = zero; - out->version = meshtastic_defaults::kModuleConfigVersion; - out->has_mqtt = true; - out->has_serial = true; - out->has_external_notification = true; - out->has_store_forward = true; - out->has_range_test = true; - out->has_telemetry = true; - out->has_canned_message = true; - out->has_audio = true; - out->has_remote_hardware = true; - out->has_neighbor_info = true; - out->has_ambient_lighting = true; - out->has_detection_sensor = true; - out->has_paxcounter = true; - - copyBounded(out->mqtt.address, sizeof(out->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); - copyBounded(out->mqtt.username, sizeof(out->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); - copyBounded(out->mqtt.password, sizeof(out->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); - copyBounded(out->mqtt.root, sizeof(out->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); - out->mqtt.enabled = false; - out->mqtt.proxy_to_client_enabled = false; - out->mqtt.encryption_enabled = meshtastic_defaults::kDefaultMqttEncryptionEnabled; - out->mqtt.tls_enabled = meshtastic_defaults::kDefaultMqttTlsEnabled; - out->mqtt.has_map_report_settings = true; - out->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; - out->mqtt.map_report_settings.position_precision = 0; - out->mqtt.map_report_settings.should_report_location = false; - - out->telemetry.device_update_interval = 3600; - out->telemetry.device_telemetry_enabled = true; - out->telemetry.environment_update_interval = 0; - out->telemetry.environment_measurement_enabled = false; - out->telemetry.power_update_interval = 0; - out->telemetry.health_update_interval = 0; - out->telemetry.air_quality_interval = 0; - - out->neighbor_info.enabled = false; - out->neighbor_info.update_interval = 0; - out->neighbor_info.transmit_over_lora = false; - - out->detection_sensor.enabled = false; - out->ambient_lighting.current = 8; - out->ambient_lighting.red = (self_node >> 16) & 0xFFU; - out->ambient_lighting.green = (self_node >> 8) & 0xFFU; - out->ambient_lighting.blue = self_node & 0xFFU; } void applyLegacyMqttDefaults(meshtastic_LocalModuleConfig* out) { - if (!out || !out->has_mqtt) - { - return; - } - - if (out->mqtt.address[0] == '\0') - { - copyBounded(out->mqtt.address, sizeof(out->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); - } - if (out->mqtt.username[0] == '\0') - { - copyBounded(out->mqtt.username, sizeof(out->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); - } - if (out->mqtt.password[0] == '\0') - { - copyBounded(out->mqtt.password, sizeof(out->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); - } - if (out->mqtt.root[0] == '\0') - { - copyBounded(out->mqtt.root, sizeof(out->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); - } - if (!out->mqtt.has_map_report_settings) - { - out->mqtt.has_map_report_settings = true; - out->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; - out->mqtt.map_report_settings.position_precision = 0; - out->mqtt.map_report_settings.should_report_location = false; - } + meshtastic_config_bridge::normalizeModuleConfig(out); } bool moduleConfigTypeFromVariant(pb_size_t variant_tag, meshtastic_AdminMessage_ModuleConfigType* out) @@ -552,27 +439,40 @@ bool moduleConfigTypeFromVariant(pb_size_t variant_tag, meshtastic_AdminMessage_ } // namespace -MeshtasticPhoneCore::MeshtasticPhoneCore(app::IAppBleFacade& ctx, MeshtasticPhoneTransport& transport, - MeshtasticPhoneHooks* hooks) - : ctx_(ctx), transport_(transport), hooks_(hooks) +MeshtasticPhoneCore::MeshtasticPhoneCore(IPhoneRuntimeContext& ctx, + MeshtasticPhoneTransport& transport, + MeshtasticPhoneBluetoothConfigHooks* bluetooth_config_hooks, + MeshtasticPhoneModuleConfigHooks* module_config_hooks, + MeshtasticPhoneConfigLifecycleHooks* config_lifecycle_hooks, + MeshtasticPhoneStatusHooks* status_hooks, + MeshtasticPhoneMqttHooks* mqtt_hooks, + MeshtasticPhoneDeviceRuntimeHooks* device_runtime_hooks) + : ctx_(ctx), + transport_(transport), + bluetooth_config_hooks_(bluetooth_config_hooks), + module_config_hooks_(module_config_hooks), + config_lifecycle_hooks_(config_lifecycle_hooks), + status_hooks_(status_hooks), + mqtt_hooks_(mqtt_hooks), + device_runtime_hooks_(device_runtime_hooks) { bluetooth_config_ = meshtastic_Config_BluetoothConfig_init_zero; bluetooth_config_.enabled = ctx_.isBleEnabled(); bluetooth_config_.mode = meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN; bluetooth_config_.fixed_pin = 0; - if (hooks_) + if (bluetooth_config_hooks_) { meshtastic_Config_BluetoothConfig loaded_bt = meshtastic_Config_BluetoothConfig_init_zero; - if (hooks_->loadBluetoothConfig(&loaded_bt)) + if (bluetooth_config_hooks_->loadBluetoothConfig(&loaded_bt)) { bluetooth_config_ = loaded_bt; } } initDefaultModuleConfig(&module_config_, ctx_.getSelfNodeId()); - if (hooks_) + if (module_config_hooks_) { meshtastic_LocalModuleConfig loaded = meshtastic_LocalModuleConfig_init_zero; - if (hooks_->loadModuleConfig(&loaded)) + if (module_config_hooks_->loadModuleConfig(&loaded)) { module_config_ = loaded; applyLegacyMqttDefaults(&module_config_); @@ -660,7 +560,7 @@ bool MeshtasticPhoneCore::handleToRadio(const uint8_t* data, size_t len) case meshtastic_ToRadio_packet_tag: return handleToRadioPacket(to_radio.packet); case meshtastic_ToRadio_mqttClientProxyMessage_tag: - return hooks_ ? hooks_->handleMqttProxyToRadio(to_radio.mqttClientProxyMessage) : false; + return mqtt_hooks_ ? mqtt_hooks_->handleMqttProxyToRadio(to_radio.mqttClientProxyMessage) : false; case meshtastic_ToRadio_want_config_id_tag: logDual("[BLE][mtcore][flow] want_config nonce=%08lX stage=%s\n", static_cast(to_radio.want_config_id), @@ -688,7 +588,7 @@ bool MeshtasticPhoneCore::handleToRadioPacket(meshtastic_MeshPacket& packet) if (packet.id == 0) { - packet.id = millis(); + packet.id = sys::millis_now(); } packet.from = ctx_.getSelfNodeId(); @@ -757,7 +657,7 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) meshtastic_AdminMessage& req = admin_req_scratch_; meshtastic_AdminMessage& resp = admin_resp_scratch_; bool has_resp = false; - auto& cfg = ctx_.getConfig(); + auto cfg = ctx_.getMeshtasticPhoneConfig(); switch (req.which_payload_variant) { @@ -806,7 +706,7 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) meshtastic_DeviceConnectionStatus status = meshtastic_DeviceConnectionStatus_init_zero; resp.get_device_connection_status_response = status; } - if (!(hooks_ && hooks_->loadDeviceConnectionStatus(&resp.get_device_connection_status_response))) + if (!(status_hooks_ && status_hooks_->loadDeviceConnectionStatus(&resp.get_device_connection_status_response))) { resp.get_device_connection_status_response.has_bluetooth = true; resp.get_device_connection_status_response.bluetooth.pin = 0; @@ -823,6 +723,7 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) case meshtastic_AdminMessage_set_owner_tag: copyBounded(cfg.node_name, sizeof(cfg.node_name), req.set_owner.long_name); copyBounded(cfg.short_name, sizeof(cfg.short_name), req.set_owner.short_name); + ctx_.setMeshtasticPhoneConfig(cfg); ctx_.saveConfig(); ctx_.applyUserInfo(); resp.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag; @@ -837,11 +738,11 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) cfg.primary_downlink_enabled = req.set_channel.settings.downlink_enabled; if (req.set_channel.settings.psk.size == 16) { - std::memcpy(cfg.meshtastic_config.primary_key, req.set_channel.settings.psk.bytes, 16); + std::memcpy(cfg.mesh.primary_key, req.set_channel.settings.psk.bytes, 16); } else if (req.set_channel.settings.psk.size == 0) { - std::memset(cfg.meshtastic_config.primary_key, 0, sizeof(cfg.meshtastic_config.primary_key)); + std::memset(cfg.mesh.primary_key, 0, sizeof(cfg.mesh.primary_key)); } } else if (req.set_channel.index == 1) @@ -851,13 +752,14 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) cfg.secondary_downlink_enabled = req.set_channel.settings.downlink_enabled; if (req.set_channel.settings.psk.size == 16) { - std::memcpy(cfg.meshtastic_config.secondary_key, req.set_channel.settings.psk.bytes, 16); + std::memcpy(cfg.mesh.secondary_key, req.set_channel.settings.psk.bytes, 16); } else if (req.set_channel.settings.psk.size == 0) { - std::memset(cfg.meshtastic_config.secondary_key, 0, sizeof(cfg.meshtastic_config.secondary_key)); + std::memset(cfg.mesh.secondary_key, 0, sizeof(cfg.mesh.secondary_key)); } } + ctx_.setMeshtasticPhoneConfig(cfg); ctx_.saveConfig(); ctx_.applyMeshConfig(); resp.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag; @@ -874,32 +776,34 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) static_cast(req.set_config.payload_variant.lora.hop_limit), static_cast(req.set_config.payload_variant.lora.channel_num), static_cast(req.set_config.payload_variant.lora.modem_preset)); - cfg.meshtastic_config.use_preset = req.set_config.payload_variant.lora.use_preset; - cfg.meshtastic_config.modem_preset = static_cast(req.set_config.payload_variant.lora.modem_preset); - cfg.meshtastic_config.bandwidth_khz = req.set_config.payload_variant.lora.bandwidth; - cfg.meshtastic_config.spread_factor = static_cast(req.set_config.payload_variant.lora.spread_factor); - cfg.meshtastic_config.coding_rate = req.set_config.payload_variant.lora.coding_rate; - cfg.meshtastic_config.frequency_offset_mhz = req.set_config.payload_variant.lora.frequency_offset; - cfg.meshtastic_config.region = static_cast(req.set_config.payload_variant.lora.region); - cfg.meshtastic_config.hop_limit = static_cast(req.set_config.payload_variant.lora.hop_limit); - cfg.meshtastic_config.tx_enabled = req.set_config.payload_variant.lora.tx_enabled; - cfg.meshtastic_config.tx_power = req.set_config.payload_variant.lora.tx_power; - cfg.meshtastic_config.channel_num = req.set_config.payload_variant.lora.channel_num; - cfg.meshtastic_config.override_duty_cycle = req.set_config.payload_variant.lora.override_duty_cycle; - cfg.meshtastic_config.override_frequency_mhz = req.set_config.payload_variant.lora.override_frequency; - cfg.meshtastic_config.ignore_mqtt = req.set_config.payload_variant.lora.ignore_mqtt; - cfg.meshtastic_config.config_ok_to_mqtt = req.set_config.payload_variant.lora.config_ok_to_mqtt; + cfg.mesh.use_preset = req.set_config.payload_variant.lora.use_preset; + cfg.mesh.modem_preset = static_cast(req.set_config.payload_variant.lora.modem_preset); + cfg.mesh.bandwidth_khz = req.set_config.payload_variant.lora.bandwidth; + cfg.mesh.spread_factor = static_cast(req.set_config.payload_variant.lora.spread_factor); + cfg.mesh.coding_rate = req.set_config.payload_variant.lora.coding_rate; + cfg.mesh.frequency_offset_mhz = req.set_config.payload_variant.lora.frequency_offset; + cfg.mesh.region = static_cast(req.set_config.payload_variant.lora.region); + cfg.mesh.hop_limit = static_cast(req.set_config.payload_variant.lora.hop_limit); + cfg.mesh.tx_enabled = req.set_config.payload_variant.lora.tx_enabled; + cfg.mesh.tx_power = req.set_config.payload_variant.lora.tx_power; + cfg.mesh.channel_num = req.set_config.payload_variant.lora.channel_num; + cfg.mesh.override_duty_cycle = req.set_config.payload_variant.lora.override_duty_cycle; + cfg.mesh.override_frequency_mhz = req.set_config.payload_variant.lora.override_frequency; + cfg.mesh.ignore_mqtt = req.set_config.payload_variant.lora.ignore_mqtt; + cfg.mesh.config_ok_to_mqtt = req.set_config.payload_variant.lora.config_ok_to_mqtt; logDual("[BLE][mtcore] set_config lora pre-save ok_to_mqtt=%u ignore_mqtt=%u\n", - cfg.meshtastic_config.config_ok_to_mqtt ? 1U : 0U, - cfg.meshtastic_config.ignore_mqtt ? 1U : 0U); + cfg.mesh.config_ok_to_mqtt ? 1U : 0U, + cfg.mesh.ignore_mqtt ? 1U : 0U); + ctx_.setMeshtasticPhoneConfig(cfg); ctx_.saveConfig(); logDual("[BLE][mtcore] set_config lora post-save\n"); ctx_.applyMeshConfig(); logDual("[BLE][mtcore] set_config lora post-apply\n"); break; case meshtastic_Config_position_tag: - cfg.gps_mode = req.set_config.payload_variant.position.gps_enabled ? 1 : 0; + cfg.gps_enabled = req.set_config.payload_variant.position.gps_enabled; cfg.gps_interval_ms = req.set_config.payload_variant.position.gps_update_interval * 1000U; + ctx_.setMeshtasticPhoneConfig(cfg); ctx_.saveConfig(); ctx_.applyPositionConfig(); break; @@ -910,9 +814,9 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) { bluetooth_config_.fixed_pin = 0; } - if (hooks_) + if (bluetooth_config_hooks_) { - hooks_->saveBluetoothConfig(bluetooth_config_); + bluetooth_config_hooks_->saveBluetoothConfig(bluetooth_config_); } ctx_.setBleEnabled(req.set_config.payload_variant.bluetooth.enabled); break; @@ -924,8 +828,11 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) int offset_min = 0; if (parsePosixTzOffsetMinutes(tzdef, &offset_min)) { - ::platform::ui::time::set_timezone_offset_min(offset_min); - saveStoredTimezoneTzdef(tzdef); + if (device_runtime_hooks_) + { + device_runtime_hooks_->setTimezoneOffsetMinutes(offset_min); + } + saveStoredTimezoneTzdef(device_runtime_hooks_, tzdef); logDual("[BLE][mtcore] set_config device tzdef=%s offset_min=%d\n", tzdef, offset_min); } else @@ -1034,11 +941,11 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) default: break; } - if (hooks_) + if (module_config_hooks_) { logDual("[BLE][mtcore] set_module_config pre-save variant=%u\n", static_cast(req.set_module_config.which_payload_variant)); - hooks_->saveModuleConfig(module_config_); + module_config_hooks_->saveModuleConfig(module_config_); logDual("[BLE][mtcore] set_module_config post-save variant=%u\n", static_cast(req.set_module_config.which_payload_variant)); } @@ -1111,7 +1018,7 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) reply.from = ctx_.getSelfNodeId(); reply.to = ctx_.getSelfNodeId(); reply.channel = packet.channel; - reply.id = static_cast(millis()); + reply.id = sys::millis_now(); reply.rx_time = nowSeconds(); reply.which_payload_variant = meshtastic_MeshPacket_decoded_tag; reply.decoded = meshtastic_Data_init_zero; @@ -1173,7 +1080,7 @@ bool MeshtasticPhoneCore::handleLocalSelfPacket(meshtastic_MeshPacket& packet) reply.from = self; reply.to = self; reply.channel = packet.channel; - reply.id = static_cast(millis()); + reply.id = sys::millis_now(); reply.rx_time = nowSeconds(); reply.which_payload_variant = meshtastic_MeshPacket_decoded_tag; reply.decoded = meshtastic_Data_init_zero; @@ -1202,7 +1109,7 @@ bool MeshtasticPhoneCore::handleLocalSelfPacket(meshtastic_MeshPacket& packet) reply.from = self; reply.to = self; reply.channel = packet.channel; - reply.id = static_cast(millis()); + reply.id = sys::millis_now(); reply.rx_time = nowSeconds(); reply.which_payload_variant = meshtastic_MeshPacket_decoded_tag; reply.decoded = meshtastic_Data_init_zero; @@ -1214,7 +1121,7 @@ bool MeshtasticPhoneCore::handleLocalSelfPacket(meshtastic_MeshPacket& packet) reply.decoded.has_bitfield = true; reply.decoded.bitfield = 0; size_t payload_len = sizeof(reply.decoded.payload.bytes); - if (!buildSelfPositionPayload(reply.decoded.payload.bytes, &payload_len)) + if (!buildSelfPositionPayload(device_runtime_hooks_, reply.decoded.payload.bytes, &payload_len)) { logDual("[BLE][mtcore] self position unavailable, skip loopback tx req=%08lX\n", static_cast(packet.id)); @@ -1234,7 +1141,7 @@ bool MeshtasticPhoneCore::handleLocalSelfPacket(meshtastic_MeshPacket& packet) reply.from = self; reply.to = self; reply.channel = packet.channel; - reply.id = static_cast(millis()); + reply.id = sys::millis_now(); reply.rx_time = nowSeconds(); reply.which_payload_variant = meshtastic_MeshPacket_decoded_tag; reply.decoded = meshtastic_Data_init_zero; @@ -1297,11 +1204,11 @@ bool MeshtasticPhoneCore::popToPhone(MeshtasticBleFrame* out) return false; } - if (hooks_) + if (mqtt_hooks_) { auto& mqtt = mqtt_proxy_scratch_; std::memset(&mqtt, 0, sizeof(mqtt)); - if (hooks_->pollMqttProxyToPhone(&mqtt)) + if (mqtt_hooks_->pollMqttProxyToPhone(&mqtt)) { auto& from = from_radio_scratch_; std::memset(&from, 0, sizeof(from)); @@ -1459,9 +1366,9 @@ bool MeshtasticPhoneCore::popConfigSnapshotFrame(MeshtasticBleFrame* out) config_channel_index_ = 0; config_type_index_ = 0; config_module_type_index_ = 0; - if (hooks_) + if (config_lifecycle_hooks_) { - hooks_->onConfigComplete(); + config_lifecycle_hooks_->onConfigComplete(); } logDual("[BLE][mtcore][cfg#%lu] complete nonce=%08lX nodes=%u channels=%u configs=%u modules=%u\n", static_cast(config_request_seq_), @@ -1530,9 +1437,9 @@ void MeshtasticPhoneCore::enqueueConfigSnapshot(uint32_t config_nonce) config_type_index_ = 0; config_module_type_index_ = 0; frame_queue_.clear(); - if (hooks_) + if (config_lifecycle_hooks_) { - hooks_->onConfigStart(); + config_lifecycle_hooks_->onConfigStart(); } logDual("[BLE][mtcore][cfg#%lu] start nonce=%08lX\n", static_cast(config_request_seq_), @@ -1750,14 +1657,14 @@ meshtastic_DeviceMetrics MeshtasticPhoneCore::buildDeviceMetrics() const { meshtastic_DeviceMetrics metrics = meshtastic_DeviceMetrics_init_zero; metrics.has_uptime_seconds = true; - metrics.uptime_seconds = static_cast(millis() / 1000U); + metrics.uptime_seconds = sys::uptime_seconds_now(); return metrics; } meshtastic_LocalStats MeshtasticPhoneCore::buildLocalStats() const { meshtastic_LocalStats stats = meshtastic_LocalStats_init_zero; - stats.uptime_seconds = static_cast(millis() / 1000U); + stats.uptime_seconds = sys::uptime_seconds_now(); stats.channel_utilization = 0.0f; stats.air_util_tx = 0.0f; stats.num_packets_tx = 0; @@ -1823,7 +1730,7 @@ void MeshtasticPhoneCore::fillChannel(uint8_t idx, meshtastic_Channel* out) cons std::memset(&channel, 0, sizeof(channel)); channel.index = idx; - const auto& cfg = ctx_.getConfig(); + const auto cfg = ctx_.getMeshtasticPhoneConfig(); bool enabled = false; if (idx == 0 && cfg.primary_enabled) { @@ -1860,12 +1767,12 @@ void MeshtasticPhoneCore::fillChannel(uint8_t idx, meshtastic_Channel* out) cons if (idx == 0) { copyBounded(channel.settings.name, sizeof(channel.settings.name), "Primary"); - if (cfg.meshtastic_config.primary_key[0] != 0) + if (cfg.mesh.primary_key[0] != 0) { - channel.settings.psk.size = sizeof(cfg.meshtastic_config.primary_key); + channel.settings.psk.size = sizeof(cfg.mesh.primary_key); std::memcpy(channel.settings.psk.bytes, - cfg.meshtastic_config.primary_key, - sizeof(cfg.meshtastic_config.primary_key)); + cfg.mesh.primary_key, + sizeof(cfg.mesh.primary_key)); } else { @@ -1876,12 +1783,12 @@ void MeshtasticPhoneCore::fillChannel(uint8_t idx, meshtastic_Channel* out) cons else { copyBounded(channel.settings.name, sizeof(channel.settings.name), "Secondary"); - if (cfg.meshtastic_config.secondary_key[0] != 0) + if (cfg.mesh.secondary_key[0] != 0) { - channel.settings.psk.size = sizeof(cfg.meshtastic_config.secondary_key); + channel.settings.psk.size = sizeof(cfg.mesh.secondary_key); std::memcpy(channel.settings.psk.bytes, - cfg.meshtastic_config.secondary_key, - sizeof(cfg.meshtastic_config.secondary_key)); + cfg.mesh.secondary_key, + sizeof(cfg.mesh.secondary_key)); } } } @@ -1899,7 +1806,7 @@ void MeshtasticPhoneCore::fillConfig(meshtastic_AdminMessage_ConfigType type, me { return; } - const auto& cfg = ctx_.getConfig(); + const auto cfg = ctx_.getMeshtasticPhoneConfig(); std::memset(out, 0, sizeof(*out)); meshtastic_Config& cfg_out = *out; switch (type) @@ -1912,14 +1819,15 @@ void MeshtasticPhoneCore::fillConfig(meshtastic_AdminMessage_ConfigType type, me } cfg_out.payload_variant.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; cfg_out.payload_variant.device.rebroadcast_mode = - cfg.chat_policy.enable_relay ? meshtastic_Config_DeviceConfig_RebroadcastMode_ALL - : meshtastic_Config_DeviceConfig_RebroadcastMode_NONE; + cfg.relay_enabled ? meshtastic_Config_DeviceConfig_RebroadcastMode_ALL + : meshtastic_Config_DeviceConfig_RebroadcastMode_NONE; cfg_out.payload_variant.device.node_info_broadcast_secs = 900; cfg_out.payload_variant.device.serial_enabled = false; cfg_out.payload_variant.device.is_managed = false; cfg_out.payload_variant.device.led_heartbeat_disabled = false; cfg_out.payload_variant.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED; - buildLinkedTimezoneTzdef(cfg_out.payload_variant.device.tzdef, + buildLinkedTimezoneTzdef(device_runtime_hooks_, + cfg_out.payload_variant.device.tzdef, sizeof(cfg_out.payload_variant.device.tzdef)); break; case meshtastic_AdminMessage_ConfigType_POSITION_CONFIG: @@ -1929,9 +1837,9 @@ void MeshtasticPhoneCore::fillConfig(meshtastic_AdminMessage_ConfigType type, me cfg_out.payload_variant.position = position; } cfg_out.payload_variant.position.position_broadcast_secs = 900; - cfg_out.payload_variant.position.gps_enabled = (cfg.gps_mode != 0); + cfg_out.payload_variant.position.gps_enabled = cfg.gps_enabled; cfg_out.payload_variant.position.gps_update_interval = cfg.gps_interval_ms / 1000U; - cfg_out.payload_variant.position.gps_mode = (cfg.gps_mode != 0) + cfg_out.payload_variant.position.gps_mode = cfg.gps_enabled ? meshtastic_Config_PositionConfig_GpsMode_ENABLED : meshtastic_Config_PositionConfig_GpsMode_DISABLED; break; @@ -1953,23 +1861,23 @@ void MeshtasticPhoneCore::fillConfig(meshtastic_AdminMessage_ConfigType type, me meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_zero; cfg_out.payload_variant.lora = lora; } - cfg_out.payload_variant.lora.use_preset = cfg.meshtastic_config.use_preset; + cfg_out.payload_variant.lora.use_preset = cfg.mesh.use_preset; cfg_out.payload_variant.lora.modem_preset = - static_cast(cfg.meshtastic_config.modem_preset); - cfg_out.payload_variant.lora.bandwidth = static_cast(cfg.meshtastic_config.bandwidth_khz); - cfg_out.payload_variant.lora.spread_factor = cfg.meshtastic_config.spread_factor; - cfg_out.payload_variant.lora.coding_rate = cfg.meshtastic_config.coding_rate; - cfg_out.payload_variant.lora.frequency_offset = cfg.meshtastic_config.frequency_offset_mhz; + static_cast(cfg.mesh.modem_preset); + cfg_out.payload_variant.lora.bandwidth = static_cast(cfg.mesh.bandwidth_khz); + cfg_out.payload_variant.lora.spread_factor = cfg.mesh.spread_factor; + cfg_out.payload_variant.lora.coding_rate = cfg.mesh.coding_rate; + cfg_out.payload_variant.lora.frequency_offset = cfg.mesh.frequency_offset_mhz; cfg_out.payload_variant.lora.region = - static_cast(cfg.meshtastic_config.region); - cfg_out.payload_variant.lora.hop_limit = cfg.meshtastic_config.hop_limit; - cfg_out.payload_variant.lora.tx_enabled = cfg.meshtastic_config.tx_enabled; - cfg_out.payload_variant.lora.tx_power = cfg.meshtastic_config.tx_power; - cfg_out.payload_variant.lora.channel_num = cfg.meshtastic_config.channel_num; - cfg_out.payload_variant.lora.override_duty_cycle = cfg.meshtastic_config.override_duty_cycle; - cfg_out.payload_variant.lora.override_frequency = cfg.meshtastic_config.override_frequency_mhz; - cfg_out.payload_variant.lora.ignore_mqtt = cfg.meshtastic_config.ignore_mqtt; - cfg_out.payload_variant.lora.config_ok_to_mqtt = cfg.meshtastic_config.config_ok_to_mqtt; + static_cast(cfg.mesh.region); + cfg_out.payload_variant.lora.hop_limit = cfg.mesh.hop_limit; + cfg_out.payload_variant.lora.tx_enabled = cfg.mesh.tx_enabled; + cfg_out.payload_variant.lora.tx_power = cfg.mesh.tx_power; + cfg_out.payload_variant.lora.channel_num = cfg.mesh.channel_num; + cfg_out.payload_variant.lora.override_duty_cycle = cfg.mesh.override_duty_cycle; + cfg_out.payload_variant.lora.override_frequency = cfg.mesh.override_frequency_mhz; + cfg_out.payload_variant.lora.ignore_mqtt = cfg.mesh.ignore_mqtt; + cfg_out.payload_variant.lora.config_ok_to_mqtt = cfg.mesh.config_ok_to_mqtt; break; case meshtastic_AdminMessage_ConfigType_BLUETOOTH_CONFIG: cfg_out.which_payload_variant = meshtastic_Config_bluetooth_tag; @@ -2092,7 +2000,7 @@ meshtastic_MeshPacket MeshtasticPhoneCore::buildPacketFromText(const chat::MeshI packet.from = msg.from; packet.to = msg.to; packet.channel = channelIndexFromId(msg.channel); - packet.id = (msg.msg_id == 0) ? static_cast(millis()) : msg.msg_id; + packet.id = (msg.msg_id == 0) ? sys::millis_now() : msg.msg_id; packet.rx_time = (msg.rx_meta.rx_timestamp_s != 0) ? msg.rx_meta.rx_timestamp_s : msg.timestamp; packet.rx_snr = msg.rx_meta.snr_db_x10 / 10.0f; packet.rx_rssi = msg.rx_meta.rssi_dbm_x10 / 10; @@ -2132,7 +2040,7 @@ meshtastic_MeshPacket MeshtasticPhoneCore::buildPacketFromData(const chat::MeshI } else { - packet.id = static_cast(millis()); + packet.id = sys::millis_now(); } packet.rx_time = (msg.rx_meta.rx_timestamp_s != 0) ? msg.rx_meta.rx_timestamp_s : nowSeconds(); packet.rx_snr = msg.rx_meta.snr_db_x10 / 10.0f; diff --git a/modules/core_chat/src/ble/meshtastic_phone_session.cpp b/modules/core_chat/src/ble/meshtastic_phone_session.cpp index 5ded4938..32a953ea 100644 --- a/modules/core_chat/src/ble/meshtastic_phone_session.cpp +++ b/modules/core_chat/src/ble/meshtastic_phone_session.cpp @@ -3,9 +3,21 @@ namespace ble { -MeshtasticPhoneSession::MeshtasticPhoneSession(app::IAppBleFacade& ctx, MeshtasticPhoneTransport& transport, - MeshtasticPhoneHooks* hooks) - : core_(ctx, transport, hooks) +MeshtasticPhoneSession::MeshtasticPhoneSession(IPhoneRuntimeContext& ctx, MeshtasticPhoneTransport& transport, + MeshtasticPhoneBluetoothConfigHooks* bluetooth_config_hooks, + MeshtasticPhoneModuleConfigHooks* module_config_hooks, + MeshtasticPhoneConfigLifecycleHooks* config_lifecycle_hooks, + MeshtasticPhoneStatusHooks* status_hooks, + MeshtasticPhoneMqttHooks* mqtt_hooks, + MeshtasticPhoneDeviceRuntimeHooks* device_runtime_hooks) + : core_(ctx, + transport, + bluetooth_config_hooks, + module_config_hooks, + config_lifecycle_hooks, + status_hooks, + mqtt_hooks, + device_runtime_hooks) { } diff --git a/modules/core_chat/src/infra/node_store_core.cpp b/modules/core_chat/src/infra/node_store_core.cpp index 215324d7..695aaacf 100644 --- a/modules/core_chat/src/infra/node_store_core.cpp +++ b/modules/core_chat/src/infra/node_store_core.cpp @@ -7,6 +7,16 @@ #include #include +#if defined(_MSC_VER) +#define TRAILMATE_PACK_PUSH __pragma(pack(push, 1)) +#define TRAILMATE_PACK_POP __pragma(pack(pop)) +#define TRAILMATE_PACKED +#else +#define TRAILMATE_PACK_PUSH +#define TRAILMATE_PACK_POP +#define TRAILMATE_PACKED __attribute__((packed)) +#endif + namespace chat { namespace contacts @@ -14,6 +24,7 @@ namespace contacts namespace { +TRAILMATE_PACK_PUSH struct PersistedNodeEntryV6 { uint32_t node_id; @@ -28,11 +39,13 @@ struct PersistedNodeEntryV6 uint8_t protocol; uint8_t role; uint8_t hw_model; -} __attribute__((packed)); +} TRAILMATE_PACKED; +TRAILMATE_PACK_POP static_assert(sizeof(PersistedNodeEntryV6) == NodeStoreCore::kLegacySerializedEntrySize, "PersistedNodeEntryV6 size changed"); +TRAILMATE_PACK_PUSH struct PersistedNodeEntryV7 { uint32_t node_id; @@ -59,11 +72,13 @@ struct PersistedNodeEntryV7 uint32_t position_hdop; uint32_t position_vdop; uint32_t position_gps_accuracy_mm; -} __attribute__((packed)); +} TRAILMATE_PACKED; +TRAILMATE_PACK_POP static_assert(sizeof(PersistedNodeEntryV7) == NodeStoreCore::kSerializedEntrySize, "PersistedNodeEntryV7 size changed"); +TRAILMATE_PACK_PUSH struct PersistedNodeEntryV8 { uint32_t node_id; @@ -108,7 +123,8 @@ struct PersistedNodeEntryV8 float metrics_channel_utilization; float metrics_air_util_tx; uint32_t metrics_uptime_seconds; -} __attribute__((packed)); +} TRAILMATE_PACKED; +TRAILMATE_PACK_POP static_assert(sizeof(PersistedNodeEntryV8) == NodeStoreCore::kSerializedEntrySizeV8, "PersistedNodeEntryV8 size changed"); @@ -763,3 +779,7 @@ size_t NodeStoreCore::selectEvictionIndex() const } // namespace contacts } // namespace chat + +#undef TRAILMATE_PACK_PUSH +#undef TRAILMATE_PACK_POP +#undef TRAILMATE_PACKED diff --git a/modules/core_gps/include/gps/usecase/gps_runtime_config.h b/modules/core_gps/include/gps/usecase/gps_runtime_config.h index 6846eb4c..930b14d1 100644 --- a/modules/core_gps/include/gps/usecase/gps_runtime_config.h +++ b/modules/core_gps/include/gps/usecase/gps_runtime_config.h @@ -13,7 +13,7 @@ struct GnssRuntimeConfig uint8_t sat_mask = kDefaultGnssSatelliteMask; }; -struct NmeaRuntimeConfig +struct ExternalNmeaRuntimeConfig { uint8_t output_hz = 0; uint8_t sentence_mask = 0; @@ -23,22 +23,22 @@ class GpsRuntimeConfig { public: const GnssRuntimeConfig& gnssConfig() const { return gnss_config_; } - const NmeaRuntimeConfig& nmeaConfig() const { return nmea_config_; } + const ExternalNmeaRuntimeConfig& externalNmeaConfig() const { return external_nmea_config_; } bool hasPendingGnssConfig() const { return gnss_config_pending_; } - bool hasPendingNmeaConfig() const { return nmea_config_pending_; } + bool hasPendingExternalNmeaConfig() const { return external_nmea_config_pending_; } void setGnssConfig(uint8_t mode, uint8_t sat_mask); - void setNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); + void setExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); void markGnssConfigApplied(); - void markNmeaConfigApplied(); + void markExternalNmeaConfigApplied(); private: GnssRuntimeConfig gnss_config_{}; - NmeaRuntimeConfig nmea_config_{}; + ExternalNmeaRuntimeConfig external_nmea_config_{}; bool gnss_config_pending_ = false; - bool nmea_config_pending_ = false; + bool external_nmea_config_pending_ = false; }; } // namespace gps diff --git a/modules/core_gps/src/usecase/gps_runtime_config.cpp b/modules/core_gps/src/usecase/gps_runtime_config.cpp index 56c90604..ee1f2500 100644 --- a/modules/core_gps/src/usecase/gps_runtime_config.cpp +++ b/modules/core_gps/src/usecase/gps_runtime_config.cpp @@ -10,11 +10,11 @@ void GpsRuntimeConfig::setGnssConfig(uint8_t mode, uint8_t sat_mask) gnss_config_pending_ = true; } -void GpsRuntimeConfig::setNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) +void GpsRuntimeConfig::setExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) { - nmea_config_.output_hz = output_hz; - nmea_config_.sentence_mask = sentence_mask; - nmea_config_pending_ = true; + external_nmea_config_.output_hz = output_hz; + external_nmea_config_.sentence_mask = sentence_mask; + external_nmea_config_pending_ = true; } void GpsRuntimeConfig::markGnssConfigApplied() @@ -22,9 +22,9 @@ void GpsRuntimeConfig::markGnssConfigApplied() gnss_config_pending_ = false; } -void GpsRuntimeConfig::markNmeaConfigApplied() +void GpsRuntimeConfig::markExternalNmeaConfigApplied() { - nmea_config_pending_ = false; + external_nmea_config_pending_ = false; } } // namespace gps diff --git a/modules/core_sys/include/app/app_config.h b/modules/core_sys/include/app/app_config.h index b01a01ea..69819db5 100644 --- a/modules/core_sys/include/app/app_config.h +++ b/modules/core_sys/include/app/app_config.h @@ -100,6 +100,7 @@ struct AppConfig uint8_t secondary_key[16]; // PSK for secondary channel // GPS settings + bool gps_enabled; uint32_t gps_interval_ms; uint8_t gps_mode; uint8_t gps_sat_mask; @@ -107,6 +108,8 @@ struct AppConfig uint8_t gps_alt_ref; uint8_t gps_coord_format; gps::MotionConfig motion_config; + uint8_t external_nmea_output_hz; + uint8_t external_nmea_sentence_mask; // Map settings uint8_t map_coord_system; @@ -125,8 +128,6 @@ struct AppConfig // Privacy settings uint8_t privacy_encrypt_mode; - uint8_t privacy_nmea_output; - uint8_t privacy_nmea_sentence; // Tracker route settings bool route_enabled; @@ -168,6 +169,7 @@ struct AppConfig secondary_uplink_enabled = false; secondary_downlink_enabled = false; memset(secondary_key, 0, 16); + gps_enabled = true; gps_interval_ms = 60000; gps_mode = 0; gps_sat_mask = 0x1 | 0x8 | 0x4; @@ -175,6 +177,8 @@ struct AppConfig gps_alt_ref = 0; gps_coord_format = 0; motion_config = gps::MotionConfig(); + external_nmea_output_hz = 0; + external_nmea_sentence_mask = 0; map_coord_system = 0; map_source = 0; @@ -189,8 +193,6 @@ struct AppConfig net_channel_util = 0; privacy_encrypt_mode = 1; - privacy_nmea_output = 0; - privacy_nmea_sentence = 0; route_enabled = false; route_path[0] = '\0'; aprs = AprsConfig(); diff --git a/modules/core_sys/include/app/app_facades.h b/modules/core_sys/include/app/app_facades.h index 34982c19..4e15beb9 100644 --- a/modules/core_sys/include/app/app_facades.h +++ b/modules/core_sys/include/app/app_facades.h @@ -57,6 +57,7 @@ class IAppConfigFacade virtual void applyNetworkLimits() = 0; virtual void applyPrivacyConfig() = 0; virtual void applyChatDefaults() = 0; + virtual chat::MeshProtocol getMeshProtocol() const = 0; virtual void getEffectiveUserInfo(char* out_long, std::size_t long_len, char* out_short, std::size_t short_len) const = 0; virtual bool switchMeshProtocol(chat::MeshProtocol protocol, bool persist = true) = 0; }; diff --git a/modules/core_sys/include/platform/ui/README.md b/modules/core_sys/include/platform/ui/README.md new file mode 100644 index 00000000..fb498c11 --- /dev/null +++ b/modules/core_sys/include/platform/ui/README.md @@ -0,0 +1,85 @@ +# `platform::ui::*` Contract Inventory + +This directory is the authoritative contract surface between shared +presentation/core code and platform implementations. + +Shared layers may depend on these headers. +They must not depend on concrete implementations under `platform/esp/*`, +`platform/linux/*`, simulator glue, board headers, or vendor SDKs. + +## Contract rules + +- Each header in this directory defines a capability contract, not a platform + implementation. +- Platform implementations belong under `platform/*`. +- `ui_shared` and other shared layers may include these headers, but must not + tunnel around them into platform-specific trees. +- Unsupported features should report unsupported through the contract rather + than forcing shared code to guess from platform details. +- The boundary is guarded by `scripts/check_platform_ui_boundaries.py`, which + runs in CI and the Linux WSL validation entrypoint. + +## Current contract set + +- `capability_status.h` + Shared `CapabilityState` / `CapabilityStatus` types used by all runtime + contracts to honestly signal Unsupported / Simulated / Available / Degraded / + Error. Every runtime that exposes `capability_status()` includes this header. +- `device_runtime.h` + Device identity, delay/restart, battery/memory stats, haptics, storage + presence, and basic capability/state helpers. +- `firmware_update_runtime.h` + Firmware update support reporting and trigger entrypoints. +- `gps_runtime.h` + GPS support/state, snapshots, and GNSS-facing UI data access. +- `hostlink_runtime.h` + Host-link session lifecycle and status. +- `lora_runtime.h` + LoRa receive-mode acquisition/configuration and instant RSSI access for UI. +- `orientation_runtime.h` + Heading/orientation support and samples for UI consumers. +- `pack_repository_runtime.h` + Pack catalog/install contract used by the shared Extensions UI while hiding + platform storage/network details. +- `route_storage.h` + Route file existence, load/save, and listing abstractions. +- `screen_runtime.h` + Screen timeout state, brightness-adjacent sleep policy, and user-activity + lifecycle hooks. +- `settings_store.h` + Persistent key/value and blob storage for shared UI/runtime settings. +- `sstv_runtime.h` + SSTV support and session lifecycle hooks. +- `team_ui_store_runtime.h` + Team UI snapshot/chatlog/track persistence seam consumed by shared Team, + Chat, Contacts, and dashboard presentation. +- `team_ui_types.h` + Shared Team UI value types and color helpers that must stay usable from both + the presentation shell and platform persistence seams. +- `time_runtime.h` + Timezone persistence and local-time helpers. +- `tracker_runtime.h` + Tracker support/state and route/recording lifecycle. +- `usb_support_runtime.h` + USB support reporting and page-facing lifecycle hooks. +- `walkie_runtime.h` + Walkie-talkie support/state lifecycle. +- `resource_owner.h` + Shared `ResourceKind` / `OwnerToken` vocabulary for radio, display, + power rail, I2C/SPI bus, GPS, and audio ownership across Linux and IDF. +- `wifi_runtime.h` + Wi-Fi config persistence, scan/connect lifecycle, and status. + +## Missing-contract smell + +If shared code needs any of the following directly, the right response is +usually "a contract is missing" rather than "add another platform include": + +- `Arduino.h` +- `Preferences` +- `SD.h`, `FS.h` +- Linux device paths +- `esp_*` APIs +- board-specific types + +That smell should trigger a contract decision before more implementation work. diff --git a/modules/core_sys/include/platform/ui/capability_status.h b/modules/core_sys/include/platform/ui/capability_status.h new file mode 100644 index 00000000..540b8183 --- /dev/null +++ b/modules/core_sys/include/platform/ui/capability_status.h @@ -0,0 +1,60 @@ +#pragma once + +// --------------------------------------------------------------------------- +// CapabilityState / CapabilityStatus +// +// Honest capability signalling shared between ESP and Linux lines. +// Every runtime contract in platform::ui::* can expose a CapabilityStatus +// so the UI can distinguish "unsupported", "simulated", "real", "degraded", +// and "error" without platform-specific knowledge. +// --------------------------------------------------------------------------- + +namespace platform::ui +{ + +enum class CapabilityState +{ + /// No code path exists for this capability on the current target. + Unsupported, + + /// A synthetic / env-driven implementation exists for development and + /// simulator use. Must NOT be presented as real hardware by default. + Simulated, + + /// Real hardware or OS path is available and behaving normally. + Available, + + /// Real hardware is present but running in a reduced mode + /// (e.g. GPS with no fix, LoRa with poor SNR, battery unknown). + Degraded, + + /// The capability was available but has encountered a runtime error. + Error, +}; + +struct CapabilityStatus +{ + CapabilityState state{CapabilityState::Unsupported}; + const char* message{nullptr}; // human-readable, may be nullptr +}; + +/// Helper: human-readable label for a CapabilityState. +inline const char* capability_state_label(CapabilityState state) +{ + switch (state) + { + case CapabilityState::Unsupported: + return "Unsupported"; + case CapabilityState::Simulated: + return "Simulated"; + case CapabilityState::Available: + return "Available"; + case CapabilityState::Degraded: + return "Degraded"; + case CapabilityState::Error: + return "Error"; + } + return "Unknown"; +} + +} // namespace platform::ui diff --git a/modules/core_sys/include/platform/ui/gps_runtime.h b/modules/core_sys/include/platform/ui/gps_runtime.h index 7c98d17e..683c21c4 100644 --- a/modules/core_sys/include/platform/ui/gps_runtime.h +++ b/modules/core_sys/include/platform/ui/gps_runtime.h @@ -12,16 +12,19 @@ namespace platform::ui::gps using GpsState = ::gps::GpsState; using GnssSatInfo = ::gps::GnssSatInfo; using GnssStatus = ::gps::GnssStatus; +using GnssFix = ::gps::GnssFix; +using GnssSystem = ::gps::GnssSystem; GpsState get_data(); bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count, GnssStatus* status); uint32_t last_motion_ms(); bool is_enabled(); bool is_powered(); +void set_enabled(bool enabled); void set_collection_interval(uint32_t interval_ms); void set_power_strategy(uint8_t strategy); void set_gnss_config(uint8_t mode, uint8_t sat_mask); -void set_nmea_config(uint8_t output_hz, uint8_t sentence_mask); +void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask); void set_motion_idle_timeout(uint32_t timeout_ms); void set_motion_sensor_id(uint8_t sensor_id); void suspend_runtime(); diff --git a/modules/core_sys/include/platform/ui/lora_runtime.h b/modules/core_sys/include/platform/ui/lora_runtime.h index cda115ae..7a755d6a 100644 --- a/modules/core_sys/include/platform/ui/lora_runtime.h +++ b/modules/core_sys/include/platform/ui/lora_runtime.h @@ -2,6 +2,8 @@ #include +#include "platform/ui/capability_status.h" + namespace platform::ui::lora { @@ -20,7 +22,11 @@ bool is_supported(); bool acquire(); bool is_online(); bool configure_receive(float freq_mhz, const ReceiveConfig& config); -float read_rssi(); +float read_instant_rssi(); void release(); +/// Honest capability status for the current target. +/// May return Unsupported, Simulated, Available, Degraded, or Error. +CapabilityStatus capability_status(); + } // namespace platform::ui::lora diff --git a/modules/ui_shared/include/ui/runtime/pack_repository.h b/modules/core_sys/include/platform/ui/pack_repository_runtime.h similarity index 100% rename from modules/ui_shared/include/ui/runtime/pack_repository.h rename to modules/core_sys/include/platform/ui/pack_repository_runtime.h diff --git a/modules/core_sys/include/platform/ui/resource_owner.h b/modules/core_sys/include/platform/ui/resource_owner.h new file mode 100644 index 00000000..2199322b --- /dev/null +++ b/modules/core_sys/include/platform/ui/resource_owner.h @@ -0,0 +1,42 @@ +/** + * @file resource_owner.h + * @brief Shared resource-ownership vocabulary for Linux and ESP-IDF lines. + * + * Both platforms need to reason about which subsystem currently owns a + * shared hardware or software resource (radio, display-backlight, power + * rail, I2C bus). These types provide a common language. + * + * Concrete enforcement lives in platform implementations + * (platform/esp/idf_common/radio_owner.h, platform/linux/...). + */ + +#pragma once + +#include + +namespace platform::ui +{ + +/// Categories of shared resources that require explicit ownership. +enum class ResourceKind : std::uint8_t +{ + Radio, // LoRa / SX126x / SX1280 transceiver + Display, // LVGL display and backlight control + PowerRail, // External 5 V / switched power rail (Tab5 Ext5V, etc.) + I2cBus, // Shared I2C bus (touch, expander, RTC, sensors) + SpiBus, // Shared SPI bus (display, SD, radio) + Gps, // GNSS receiver (serial or I2C) + Audio, // Audio codec / I2S +}; + +/// Lightweight ownership token – used for logging and diagnostics. +/// Not a lock; the platform implementation enforces the actual exclusion. +struct OwnerToken +{ + ResourceKind resource{ResourceKind::Radio}; + const char* label{nullptr}; // e.g. "Mesh", "Walkie", "GPS" + const char* source_file{nullptr}; + int source_line{0}; +}; + +} // namespace platform::ui diff --git a/modules/core_sys/include/platform/ui/sstv_runtime.h b/modules/core_sys/include/platform/ui/sstv_runtime.h index b41a1312..887ae28e 100644 --- a/modules/core_sys/include/platform/ui/sstv_runtime.h +++ b/modules/core_sys/include/platform/ui/sstv_runtime.h @@ -2,6 +2,8 @@ #include +#include "platform/ui/capability_status.h" + namespace platform::ui::sstv { @@ -35,4 +37,8 @@ const uint16_t* framebuffer(); uint16_t frame_width(); uint16_t frame_height(); +/// Honest capability status for the current target. +/// May return Unsupported, Simulated, Available, Degraded, or Error. +CapabilityStatus capability_status(); + } // namespace platform::ui::sstv diff --git a/modules/ui_shared/include/ui/screens/team/team_ui_store.h b/modules/core_sys/include/platform/ui/team_ui_store_runtime.h similarity index 93% rename from modules/ui_shared/include/ui/screens/team/team_ui_store.h rename to modules/core_sys/include/platform/ui/team_ui_store_runtime.h index c46bbf75..45a855be 100644 --- a/modules/ui_shared/include/ui/screens/team/team_ui_store.h +++ b/modules/core_sys/include/platform/ui/team_ui_store_runtime.h @@ -1,14 +1,21 @@ /** - * @file team_ui_store.h - * @brief Team UI snapshot store interface (platform-neutral) + * @file team_ui_store_runtime.h + * @brief Platform-facing Team UI persistence contract consumed by shared UI */ #pragma once +#include "platform/ui/team_ui_types.h" +#include "team/domain/team_types.h" #include "team/protocol/team_chat.h" +#include "team/protocol/team_mgmt.h" #include "team/protocol/team_track.h" -#include "team_state.h" + +#include #include +#include +#include +#include namespace team { diff --git a/modules/core_sys/include/platform/ui/team_ui_types.h b/modules/core_sys/include/platform/ui/team_ui_types.h new file mode 100644 index 00000000..3e2ee90b --- /dev/null +++ b/modules/core_sys/include/platform/ui/team_ui_types.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +namespace team +{ +namespace ui +{ + +constexpr size_t kTeamMaxMembers = 4; +constexpr uint8_t kTeamColorUnassigned = 0xFF; +static constexpr uint32_t kTeamMemberColors[kTeamMaxMembers] = { + 0xFF3B30, // red + 0x34C759, // green + 0x007AFF, // blue + 0xFFCC00 // yellow +}; + +inline uint32_t team_color_from_index(uint8_t index) +{ + if (index >= kTeamMaxMembers) + { + return kTeamMemberColors[0]; + } + return kTeamMemberColors[index]; +} + +inline uint8_t team_color_index_from_node_id(uint32_t node_id) +{ + uint32_t h = node_id; + h ^= h >> 16; + h *= 0x7feb352d; + h ^= h >> 15; + h *= 0x846ca68b; + h ^= h >> 16; + return static_cast(h % kTeamMaxMembers); +} + +struct TeamMemberUi +{ + uint32_t node_id = 0; + std::string name; + bool online = false; + bool leader = false; + uint32_t last_seen_s = 0; + uint8_t color_index = kTeamColorUnassigned; +}; + +} // namespace ui +} // namespace team diff --git a/modules/core_sys/include/platform/ui/walkie_runtime.h b/modules/core_sys/include/platform/ui/walkie_runtime.h index 0527c28e..41b7ffb9 100644 --- a/modules/core_sys/include/platform/ui/walkie_runtime.h +++ b/modules/core_sys/include/platform/ui/walkie_runtime.h @@ -2,6 +2,8 @@ #include +#include "platform/ui/capability_status.h" + namespace platform::ui::walkie { @@ -22,4 +24,8 @@ int volume(); Status get_status(); const char* last_error(); +/// Honest capability status for the current target. +/// May return Unsupported, Simulated, Available, Degraded, or Error. +CapabilityStatus capability_status(); + } // namespace platform::ui::walkie diff --git a/modules/core_sys/include/sys/clock.h b/modules/core_sys/include/sys/clock.h index 96e21fc0..9d741058 100644 --- a/modules/core_sys/include/sys/clock.h +++ b/modules/core_sys/include/sys/clock.h @@ -7,12 +7,15 @@ namespace sys using MillisProvider = uint32_t (*)(); using EpochSecondsProvider = uint32_t (*)(); +using SleepProvider = void (*)(uint32_t); void set_millis_provider(MillisProvider provider); void set_epoch_seconds_provider(EpochSecondsProvider provider); +void set_sleep_provider(SleepProvider provider); uint32_t millis_now(); uint32_t uptime_seconds_now(); uint32_t epoch_seconds_now(); +void sleep_ms(uint32_t ms); } // namespace sys diff --git a/modules/core_sys/src/sys/clock.cpp b/modules/core_sys/src/sys/clock.cpp index 707d90b6..93a6f18a 100644 --- a/modules/core_sys/src/sys/clock.cpp +++ b/modules/core_sys/src/sys/clock.cpp @@ -9,6 +9,7 @@ namespace { MillisProvider g_millis_provider = nullptr; EpochSecondsProvider g_epoch_provider = nullptr; +SleepProvider g_sleep_provider = nullptr; uint32_t fallback_millis_now() { @@ -24,6 +25,14 @@ uint32_t fallback_epoch_seconds_now() return now < 0 ? 0U : static_cast(now); } +void fallback_sleep_ms(uint32_t ms) +{ + const uint32_t start = fallback_millis_now(); + while ((fallback_millis_now() - start) < ms) + { + } +} + } // namespace void set_millis_provider(MillisProvider provider) @@ -36,6 +45,11 @@ void set_epoch_seconds_provider(EpochSecondsProvider provider) g_epoch_provider = provider; } +void set_sleep_provider(SleepProvider provider) +{ + g_sleep_provider = provider; +} + uint32_t millis_now() { return g_millis_provider ? g_millis_provider() : fallback_millis_now(); @@ -51,4 +65,15 @@ uint32_t epoch_seconds_now() return g_epoch_provider ? g_epoch_provider() : fallback_epoch_seconds_now(); } +void sleep_ms(uint32_t ms) +{ + if (g_sleep_provider) + { + g_sleep_provider(ms); + return; + } + + fallback_sleep_ms(ms); +} + } // namespace sys diff --git a/modules/ui_mono_128x64/src/runtime.cpp b/modules/ui_mono_128x64/src/runtime.cpp index cc4dc445..6682f951 100644 --- a/modules/ui_mono_128x64/src/runtime.cpp +++ b/modules/ui_mono_128x64/src/runtime.cpp @@ -2883,7 +2883,7 @@ void Runtime::renderDeviceSettings() } else if (i == 1) { - std::snprintf(line, sizeof(line), "GPS: %s", app()->getConfig().gps_mode != 0 ? "ON" : "OFF"); + std::snprintf(line, sizeof(line), "GPS: %s", app()->getConfig().gps_enabled ? "ON" : "OFF"); } else if (i == 2) { @@ -3131,7 +3131,7 @@ void Runtime::renderInfoPage() } push_line("[GPS]"); - push_kv("GPS", cfg.gps_mode != 0 ? "ON" : "OFF"); + push_kv("GPS", cfg.gps_enabled ? "ON" : "OFF"); push_kv("SATS", gpsSatMaskLabel(cfg.gps_sat_mask)); std::snprintf(value, sizeof(value), "%lus", static_cast(cfg.gps_interval_ms / 1000UL)); push_kv("INT", value); @@ -4120,6 +4120,10 @@ void Runtime::confirmSettingPopup() platform::ui::screen::set_timeout_ms(setting_popup_screen_timeout_ms_); app()->setBleEnabled(setting_popup_ble_enabled_); app()->saveConfig(); + if (setting_popup_owner_ == Page::DeviceSettings) + { + app()->applyPositionConfig(); + } char value[32] = {}; formatSettingPopupValue(value, sizeof(value)); @@ -4260,7 +4264,7 @@ void Runtime::adjustSettingPopup(int delta) setting_popup_ble_enabled_ = !setting_popup_ble_enabled_; break; case 1: - setting_popup_config_.gps_mode = (setting_popup_config_.gps_mode == 0) ? 1 : 0; + setting_popup_config_.gps_enabled = !setting_popup_config_.gps_enabled; break; case 2: setting_popup_config_.gps_sat_mask = nextGpsSatMask(setting_popup_config_.gps_sat_mask, delta); @@ -4352,7 +4356,7 @@ void Runtime::formatSettingPopupValue(char* out, size_t out_len) const std::snprintf(out, out_len, "%s", setting_popup_ble_enabled_ ? "ON" : "OFF"); return; case 1: - std::snprintf(out, out_len, "%s", setting_popup_config_.gps_mode != 0 ? "ON" : "OFF"); + std::snprintf(out, out_len, "%s", setting_popup_config_.gps_enabled ? "ON" : "OFF"); return; case 2: std::snprintf(out, out_len, "%s", gpsSatMaskLabel(setting_popup_config_.gps_sat_mask)); diff --git a/modules/ui_shared/include/ui/localization.h b/modules/ui_shared/include/ui/localization.h index a6daf77b..55d0335d 100644 --- a/modules/ui_shared/include/ui/localization.h +++ b/modules/ui_shared/include/ui/localization.h @@ -18,6 +18,7 @@ struct LocaleInfo const char* ui_font_pack_id = nullptr; const char* content_font_pack_id = nullptr; const char* ime_pack_id = nullptr; + const char* direction = nullptr; // "ltr" (default) or "rtl" bool builtin = true; }; @@ -35,6 +36,7 @@ const LocaleInfo* locale_at(std::size_t index); int current_locale_index(); const char* current_locale_id(); const char* current_locale_display_name(); +const char* current_locale_direction(); bool set_locale(const char* locale_id, bool persist = true); bool set_locale_by_index(std::size_t index, bool persist = true); const lv_font_t* active_ui_font_fallback(); diff --git a/modules/ui_shared/include/ui/menu/menu_profile.h b/modules/ui_shared/include/ui/menu/menu_profile.h index 495f37ea..209239c8 100644 --- a/modules/ui_shared/include/ui/menu/menu_profile.h +++ b/modules/ui_shared/include/ui/menu/menu_profile.h @@ -69,6 +69,7 @@ struct MenuLayoutProfile lv_coord_t title_offset_x = 0; lv_coord_t title_offset_y = 0; + lv_coord_t card_label_width_inset = 4; uint8_t max_columns = 0; bool wrap_grid = false; @@ -81,6 +82,8 @@ struct MenuLayoutProfile bool show_card_label = false; bool show_desc_label = true; bool show_node_id = true; + bool show_memory_stats = true; + bool scroll_card_label = false; bool large_touch_hitbox = false; const lv_font_t* card_label_font = &lv_font_montserrat_14; diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_constants.h b/modules/ui_shared/include/ui/screens/gps/gps_constants.h similarity index 86% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_constants.h rename to modules/ui_shared/include/ui/screens/gps/gps_constants.h index aa8c7084..19b481f2 100644 --- a/platform/esp/arduino_common/include/ui/screens/gps/gps_constants.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_constants.h @@ -13,7 +13,6 @@ constexpr int kMaxZoom = ::ui::widgets::map::kMaxZoom; constexpr double kDefaultLat = 51.5074; constexpr double kDefaultLng = -0.1278; -// UI policy: do not show a blocking loading overlay on the GPS map. constexpr bool kShowLoadingOverlay = false; } // namespace gps_ui diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_modal.h b/modules/ui_shared/include/ui/screens/gps/gps_modal.h similarity index 98% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_modal.h rename to modules/ui_shared/include/ui/screens/gps/gps_modal.h index a8769630..31743512 100644 --- a/platform/esp/arduino_common/include/ui/screens/gps/gps_modal.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_modal.h @@ -3,7 +3,7 @@ #include "lvgl.h" -typedef struct +struct Modal { lv_obj_t* bg; lv_obj_t* win; @@ -13,7 +13,7 @@ typedef struct bool open; bool is_open() const { return open; } -} Modal; +}; void bind_navigation_inputs_to_group(lv_group_t* g); diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_components.h b/modules/ui_shared/include/ui/screens/gps/gps_page_components.h similarity index 78% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_page_components.h rename to modules/ui_shared/include/ui/screens/gps/gps_page_components.h index a9133471..06b83509 100644 --- a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_components.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_page_components.h @@ -3,33 +3,26 @@ #include "lvgl.h" -// Loading component void show_loading(); void hide_loading(); -// Toast component void show_toast(const char* message, uint32_t duration_ms = 2000); void hide_toast(); -// PanIndicator component void show_pan_h_indicator(); void hide_pan_h_indicator(); void show_pan_v_indicator(); void hide_pan_v_indicator(); -// ZoomModal component void show_zoom_popup(); void hide_zoom_popup(); -// Map layer modal void show_layer_popup(); void hide_layer_popup(); -// Route action modal void show_route_popup(); void hide_route_popup(); -// UI layout helper void fix_ui_elements_position(); #endif // GPS_PAGE_COMPONENTS_H diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_input.h b/modules/ui_shared/include/ui/screens/gps/gps_page_input.h similarity index 90% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_page_input.h rename to modules/ui_shared/include/ui/screens/gps/gps_page_input.h index 47420cb2..cc4cb317 100644 --- a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_input.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_page_input.h @@ -29,7 +29,7 @@ struct ControlTag ControlId ctrl_id(lv_obj_t* obj); void set_control_id(lv_obj_t* obj, ControlId id); -void reset_control_tags(); // Reset control-tag pool (call when entering the page) +void reset_control_tags(); void on_ui_event(lv_event_t* e); void pan_indicator_event_cb(lv_event_t* e); diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_layout.h b/modules/ui_shared/include/ui/screens/gps/gps_page_layout.h similarity index 100% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_page_layout.h rename to modules/ui_shared/include/ui/screens/gps/gps_page_layout.h diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_lifetime.h b/modules/ui_shared/include/ui/screens/gps/gps_page_lifetime.h similarity index 100% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_page_lifetime.h rename to modules/ui_shared/include/ui/screens/gps/gps_page_lifetime.h diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_map.h b/modules/ui_shared/include/ui/screens/gps/gps_page_map.h similarity index 70% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_page_map.h rename to modules/ui_shared/include/ui/screens/gps/gps_page_map.h index eacd9d06..0de199b4 100644 --- a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_map.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_page_map.h @@ -6,19 +6,16 @@ namespace gps struct GpsState; } -// Map tile management and updates void update_map_tiles(bool lightweight = false); void update_map_anchor(); void tick_loader(); void tick_gps_update(bool allow_map_refresh); void log_map_tile_state(const char* reason); -// GPS marker management void update_gps_marker_position(); void create_gps_marker(); void hide_gps_marker(); -// Team markers (posring) void refresh_team_markers_from_posring(); void update_team_marker_positions(); void clear_team_markers(); @@ -27,14 +24,12 @@ void update_team_signal_marker_positions(); void clear_team_signal_markers(); void refresh_member_panel(bool force = false); -// UI status and title updates (moved from presenter) void update_title_and_status(); void update_resolution_display(); void update_altitude_display(const gps::GpsState& gps_data); -void reset_title_status_cache(); // Reset cached state to force next update -void update_zoom_btn(); // Placeholder for future zoom button updates +void reset_title_status_cache(); +void update_zoom_btn(); -// Coordinate system transform for map display (WGS84 -> GCJ/BD if configured) void gps_map_transform(double lat, double lon, double& out_lat, double& out_lon); #endif // GPS_PAGE_MAP_H diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_styles.h b/modules/ui_shared/include/ui/screens/gps/gps_page_styles.h similarity index 96% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_page_styles.h rename to modules/ui_shared/include/ui/screens/gps/gps_page_styles.h index 6dcff0b6..2a8a9c31 100644 --- a/platform/esp/arduino_common/include/ui/screens/gps/gps_page_styles.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_page_styles.h @@ -1,7 +1,7 @@ #pragma once -#include "gps_page_layout.h" #include "lvgl.h" +#include "ui/screens/gps/gps_page_layout.h" namespace gps::ui::styles { diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_route_overlay.h b/modules/ui_shared/include/ui/screens/gps/gps_route_overlay.h similarity index 85% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_route_overlay.h rename to modules/ui_shared/include/ui/screens/gps/gps_route_overlay.h index bfd34e34..ebec3020 100644 --- a/platform/esp/arduino_common/include/ui/screens/gps/gps_route_overlay.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_route_overlay.h @@ -2,7 +2,6 @@ #include "lvgl.h" -// Route (KML) overlay helpers. bool gps_route_sync_from_config(bool show_toast); bool gps_route_focus(bool show_toast); void gps_route_draw_event(lv_event_t* e); diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_state.h b/modules/ui_shared/include/ui/screens/gps/gps_state.h similarity index 54% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_state.h rename to modules/ui_shared/include/ui/screens/gps/gps_state.h index fa5c1889..a815bc60 100644 --- a/platform/esp/arduino_common/include/ui/screens/gps/gps_state.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_state.h @@ -6,20 +6,15 @@ #ifndef GPS_STATE_H #define GPS_STATE_H -#include "gps_constants.h" -#include "gps_modal.h" #include "lvgl.h" +#include "ui/screens/gps/gps_constants.h" +#include "ui/screens/gps/gps_modal.h" #include "ui/widgets/map/map_tiles.h" #include "ui/widgets/top_bar.h" #include #include #include -/** - * GPS Page State - consolidate all static state variables - * Makes cleanup/exit logic simpler and prevents state leaks - * Note: No macro aliases - use g_gps_state.member directly - */ enum class GpsEditMode : uint8_t { None = 0, @@ -36,53 +31,49 @@ struct GPSPageState double lng = 0.0; }; - // UI refs lv_obj_t* root = nullptr; lv_obj_t* header = nullptr; lv_obj_t* menu = nullptr; lv_obj_t* page = nullptr; lv_obj_t* map = nullptr; - lv_obj_t* resolution_label = nullptr; // Resolution display label (bottom-left) - lv_obj_t* altitude_label = nullptr; // Altitude display label (bottom-center) + lv_obj_t* resolution_label = nullptr; + lv_obj_t* altitude_label = nullptr; lv_obj_t* panel = nullptr; lv_obj_t* member_panel = nullptr; lv_obj_t* zoom = nullptr; lv_obj_t* pos = nullptr; - lv_obj_t* pan_h = nullptr; // Horizontal pan button - lv_obj_t* pan_v = nullptr; // Vertical pan button - lv_obj_t* tracker_btn = nullptr; // Tracker button - lv_obj_t* layer_btn = nullptr; // Map layer button - lv_obj_t* route_btn = nullptr; // Route focus button - lv_obj_t* pan_h_indicator = nullptr; // Horizontal pan indicator (line with arrows at bottom) - lv_obj_t* pan_v_indicator = nullptr; // Vertical pan indicator (line with arrows on right) - lv_obj_t* popup_label = nullptr; // zoom_popup_label - lv_obj_t* popup_roller = nullptr; // zoom selector widget (roller on non-touch) - lv_obj_t* popup_apply_btn = nullptr; // touch zoom apply button - lv_obj_t* popup_cancel_btn = nullptr; // touch zoom cancel button + lv_obj_t* pan_h = nullptr; + lv_obj_t* pan_v = nullptr; + lv_obj_t* tracker_btn = nullptr; + lv_obj_t* layer_btn = nullptr; + lv_obj_t* route_btn = nullptr; + lv_obj_t* pan_h_indicator = nullptr; + lv_obj_t* pan_v_indicator = nullptr; + lv_obj_t* popup_label = nullptr; + lv_obj_t* popup_roller = nullptr; + lv_obj_t* popup_apply_btn = nullptr; + lv_obj_t* popup_cancel_btn = nullptr; lv_obj_t* loading_msgbox = nullptr; - lv_obj_t* toast_msgbox = nullptr; // Toast notification message box - lv_timer_t* toast_timer = nullptr; // Timer to auto-hide toast - lv_obj_t* gps_marker = nullptr; // GPS position marker (rendered on map) - ui::widgets::TopBar top_bar; // Shared header + lv_obj_t* toast_msgbox = nullptr; + lv_timer_t* toast_timer = nullptr; + lv_obj_t* gps_marker = nullptr; + ui::widgets::TopBar top_bar; - // GPS/map int zoom_level = gps_ui::kDefaultZoom; double lat = 0.0; double lng = 0.0; bool has_fix = false; int pan_x = 0; int pan_y = 0; - bool follow_position = true; // Auto-follow GPS center; disabled by manual pan, re-enabled by [P]osition + bool follow_position = true; - // tile/cache (actual storage, not pointers) MapAnchor anchor = {0}; std::vector tiles; - TileContext tile_ctx; // Context for tile operations + TileContext tile_ctx; uint32_t initial_load_ms = 0; bool initial_tiles_loaded = false; - // popup Modal zoom_modal; Modal tracker_modal; Modal layer_modal; @@ -90,29 +81,25 @@ struct GPSPageState int popup_zoom = gps_ui::kDefaultZoom; bool zoom_win_cb_bound = false; - // misc - lv_timer_t* timer = nullptr; // Main timer for tile loading and GPS updates - lv_timer_t* loader_timer = nullptr; // Tile loader timer (higher frequency) - lv_timer_t* title_timer = nullptr; // Separate timer for title updates (30s) - std::vector timers; // Lifetime-managed timers for this screen + lv_timer_t* timer = nullptr; + lv_timer_t* loader_timer = nullptr; + lv_timer_t* title_timer = nullptr; + std::vector timers; lv_indev_t* encoder = nullptr; - lv_group_t* app_group = nullptr; // App-level focus group captured at enter + lv_group_t* app_group = nullptr; - // flags - bool alive = false; // Hard lifetime guard: false after root delete hook runs - bool delete_hook_bound = false; // Ensure root delete hook is only bound once - bool exiting = false; // Prevent re-entrant exit while async exit is pending - bool has_map_data = false; // Global: any tile ever loaded - bool has_visible_map_data = false; // Viewport: current visible tiles have PNG + bool alive = false; + bool delete_hook_bound = false; + bool exiting = false; + bool has_map_data = false; + bool has_visible_map_data = false; - // Tracker overlay bool tracker_overlay_active = false; bool tracker_draw_cb_bound = false; std::string tracker_file{}; std::vector tracker_points; std::vector tracker_screen_points; - // Route overlay (KML) bool route_overlay_active = false; bool route_draw_cb_bound = false; bool route_bbox_valid = false; @@ -123,7 +110,6 @@ struct GPSPageState double route_min_lng = 0.0; double route_max_lat = 0.0; double route_max_lng = 0.0; - // Route overlay cache (avoid recomputing screen points on every draw) int route_cache_zoom = -1; int route_cache_pan_x = 0; int route_cache_pan_y = 0; @@ -170,7 +156,6 @@ struct GPSPageState std::vector team_signal_markers; uint32_t team_signal_marker_last_ms = 0; - // Edit mode state machine GpsEditMode edit_mode = GpsEditMode::None; #ifdef USING_INPUT_DEV_TOUCHPAD @@ -186,13 +171,11 @@ struct GPSPageState lv_timer_t* touch_timer = nullptr; #endif - // refresh optimization - bool pending_refresh = false; // Flag to indicate map needs refresh (for batched updates) - double last_resolution_lat = 0.0; // Last latitude used for resolution calculation - int last_resolution_zoom = -1; // Last zoom level used for resolution calculation + bool pending_refresh = false; + double last_resolution_lat = 0.0; + int last_resolution_zoom = -1; }; -// Global state instance (defined in gps_page.cpp) extern GPSPageState g_gps_state; #endif // GPS_STATE_H diff --git a/platform/esp/arduino_common/include/ui/screens/gps/gps_tracker_overlay.h b/modules/ui_shared/include/ui/screens/gps/gps_tracker_overlay.h similarity index 81% rename from platform/esp/arduino_common/include/ui/screens/gps/gps_tracker_overlay.h rename to modules/ui_shared/include/ui/screens/gps/gps_tracker_overlay.h index dc1b0cb0..d69130c7 100644 --- a/platform/esp/arduino_common/include/ui/screens/gps/gps_tracker_overlay.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_tracker_overlay.h @@ -2,7 +2,6 @@ #include "lvgl.h" -// Tracker modal and overlay drawing helpers. void gps_tracker_open_modal(); bool gps_tracker_load_file(const char* path, bool show_toast); void gps_tracker_draw_event(lv_event_t* e); diff --git a/modules/ui_shared/include/ui/screens/settings/settings_state.h b/modules/ui_shared/include/ui/screens/settings/settings_state.h index a9a744a2..a5f9d5ce 100644 --- a/modules/ui_shared/include/ui/screens/settings/settings_state.h +++ b/modules/ui_shared/include/ui/screens/settings/settings_state.h @@ -44,6 +44,7 @@ struct SettingItem struct SettingsData { // GPS + bool gps_enabled = true; int gps_mode = 0; int gps_sat_mask = 0x1 | 0x8 | 0x4; int gps_strategy = 0; @@ -100,10 +101,10 @@ struct SettingsData char mc_channel_name[32] = "Public"; char mc_channel_key[65] = {}; - // Chat/GPS (privacy-related controls) + // Chat/privacy controls int privacy_encrypt_mode = 1; - int privacy_nmea_output = 0; - int privacy_nmea_sentence = 0; + int external_nmea_output_hz = 0; + int external_nmea_sentence_mask = 0; // Screen int screen_timeout_ms = 30000; diff --git a/modules/ui_shared/include/ui/screens/team/team_page_components.h b/modules/ui_shared/include/ui/screens/team/team_page_components.h index 158086b2..e2d65ef7 100644 --- a/modules/ui_shared/include/ui/screens/team/team_page_components.h +++ b/modules/ui_shared/include/ui/screens/team/team_page_components.h @@ -6,7 +6,7 @@ #pragma once #include "lvgl.h" -#include "team_ui_store.h" +#include "platform/ui/team_ui_store_runtime.h" namespace sys { diff --git a/modules/ui_shared/include/ui/screens/team/team_state.h b/modules/ui_shared/include/ui/screens/team/team_state.h index 70dfeebf..2ea4270a 100644 --- a/modules/ui_shared/include/ui/screens/team/team_state.h +++ b/modules/ui_shared/include/ui/screens/team/team_state.h @@ -6,6 +6,7 @@ #pragma once #include "lvgl.h" +#include "platform/ui/team_ui_types.h" #include "team/domain/team_types.h" #include "team/protocol/team_mgmt.h" #include "ui/widgets/top_bar.h" @@ -18,35 +19,6 @@ namespace team namespace ui { -constexpr size_t kTeamMaxMembers = 4; -constexpr uint8_t kTeamColorUnassigned = 0xFF; -static constexpr uint32_t kTeamMemberColors[kTeamMaxMembers] = { - 0xFF3B30, // red - 0x34C759, // green - 0x007AFF, // blue - 0xFFCC00 // yellow -}; - -inline uint32_t team_color_from_index(uint8_t index) -{ - if (index >= kTeamMaxMembers) - { - return kTeamMemberColors[0]; - } - return kTeamMemberColors[index]; -} - -inline uint8_t team_color_index_from_node_id(uint32_t node_id) -{ - uint32_t h = node_id; - h ^= h >> 16; - h *= 0x7feb352d; - h ^= h >> 15; - h *= 0x846ca68b; - h ^= h >> 16; - return static_cast(h % kTeamMaxMembers); -} - enum class TeamPage { StatusNotInTeam, @@ -59,16 +31,6 @@ enum class TeamPage KickedOut }; -struct TeamMemberUi -{ - uint32_t node_id = 0; - std::string name; - bool online = false; - bool leader = false; - uint32_t last_seen_s = 0; - uint8_t color_index = kTeamColorUnassigned; -}; - struct TeamPageState { lv_obj_t* root = nullptr; diff --git a/modules/ui_shared/library.json b/modules/ui_shared/library.json index bdde925e..4499d513 100644 --- a/modules/ui_shared/library.json +++ b/modules/ui_shared/library.json @@ -10,13 +10,7 @@ "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", "-I../../modules/core_hostlink/include", - "-I../../modules/core_team/include", - "-I../../platform/esp/arduino_common/include" + "-I../../modules/core_team/include" ] - }, - "dependencies": [ - { - "name": "Preferences" - } - ] + } } diff --git a/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp b/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp index f652ff2d..da39d841 100644 --- a/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp +++ b/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp @@ -95,6 +95,7 @@ struct LocalePackRecord std::string content_font_pack_id; std::vector preferred_content_supplement_pack_ids; std::string ime_pack_id; + std::string direction; // "ltr" (default) or "rtl" bool builtin = false; std::vector> translations; }; @@ -308,7 +309,7 @@ std::string join_path(const std::string& base, const std::string& child) return base + "/" + child; } -std::string normalize_dir_entry(std::string name) +[[maybe_unused]] std::string normalize_dir_entry(std::string name) { while (!name.empty() && (name.front() == '/' || name.front() == '\\')) { @@ -535,7 +536,10 @@ void append_unique_string(std::vector& values, const std::string& v bool ime_backend_supports_runtime_input(const ImePackRecord& pack) { - return pack.backend == "builtin-pinyin"; + // The current widget has one real conversion engine and dictionary: + // Simplified Chinese Pinyin. Other manifests may still be cataloged as + // pack metadata, but exposing them would route users into the wrong script. + return pack.id == "zh-hans-pinyin" && pack.backend == "builtin-pinyin"; } bool ime_backend_can_be_enabled(const ImePackRecord& pack) @@ -1352,6 +1356,7 @@ void rebuild_locale_views() view.ui_font_pack_id = pack.ui_font_pack_id.c_str(); view.content_font_pack_id = pack.content_font_pack_id.c_str(); view.ime_pack_id = pack.ime_pack_id.empty() ? nullptr : pack.ime_pack_id.c_str(); + view.direction = pack.direction.empty() ? nullptr : pack.direction.c_str(); view.builtin = pack.builtin; s_locale_views.push_back(view); } @@ -1631,6 +1636,17 @@ bool catalog_external_locale_pack(const std::string& pack_dir) return false; } + const char* translation_status = manifest_value(manifest, "translation_status"); + if (translation_status && translation_status[0] != '\0' && + lowercase_ascii(translation_status) != "release") + { + std::printf("%s skip locale pack id=%s reason=translation_status status=%s\n", + kLogTag, + id, + translation_status); + return false; + } + LocalePackRecord pack{}; pack.id = id; if (const char* display_name = manifest_value(manifest, "display_name")) @@ -1685,6 +1701,11 @@ bool catalog_external_locale_pack(const std::string& pack_dir) pack.ime_pack_id = ime_pack; } + if (const char* dir = manifest_value(manifest, "direction")) + { + pack.direction = dir; + } + const char* strings_value = manifest_value(manifest, "strings"); const std::string strings_path = resolve_pack_file_path(pack_dir, strings_value); if (strings_path.empty() || !parse_locale_strings(strings_path, pack.translations)) @@ -2179,6 +2200,16 @@ const char* current_locale_display_name() : s_active_locale->native_name.c_str(); } +const char* current_locale_direction() +{ + ensure_registry(); + if (s_active_locale == nullptr || s_active_locale->direction.empty()) + { + return "ltr"; + } + return s_active_locale->direction.c_str(); +} + bool set_locale(const char* locale_id, bool persist) { ensure_registry(); @@ -2357,7 +2388,10 @@ const char* active_ime_pack_id() bool active_locale_supports_script_input() { - return any_enabled_script_input(); + ensure_registry(); + return s_active_ime_pack != nullptr && + string_list_contains(s_enabled_ime_ids, s_active_ime_pack->id.c_str()) && + ime_backend_supports_runtime_input(*s_active_ime_pack); } const char* tr(const char* english) diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp index bfb0cad2..c4a42481 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp @@ -9,9 +9,9 @@ #include "chat/usecase/contact_service.h" #include "platform/ui/gps_runtime.h" #include "platform/ui/orientation_runtime.h" +#include "platform/ui/team_ui_store_runtime.h" #include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" -#include "ui/screens/team/team_ui_store.h" namespace ui::menu::dashboard { diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp index 7ac951cb..e6031d59 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp @@ -7,9 +7,9 @@ #include "app/app_facade_access.h" #include "ble/ble_manager.h" #include "platform/ui/device_runtime.h" +#include "platform/ui/team_ui_store_runtime.h" #include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" -#include "ui/screens/team/team_ui_store.h" #include "ui/ui_status.h" namespace ui::menu::dashboard diff --git a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp index 115354bd..f8f679a2 100644 --- a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp +++ b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp @@ -22,6 +22,7 @@ namespace // dashboard aggressively while the menu grid is also visible. A slower cadence // keeps the dashboard useful without continuously forcing large redraws. constexpr uint32_t kDashboardTimerMs = 1000; +constexpr bool kEnableTab5Dashboard = false; bool is_tab5_profile() { @@ -57,7 +58,7 @@ void refresh_dashboard(lv_timer_t* timer) void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions& options) { (void)options; - if (!is_tab5_profile() || menu_panel == nullptr || grid_panel == nullptr) + if (!kEnableTab5Dashboard || !is_tab5_profile() || menu_panel == nullptr || grid_panel == nullptr) { return; } diff --git a/modules/ui_shared/src/ui/menu/menu_layout.cpp b/modules/ui_shared/src/ui/menu/menu_layout.cpp index b663a3ff..080b760c 100644 --- a/modules/ui_shared/src/ui/menu/menu_layout.cpp +++ b/modules/ui_shared/src/ui/menu/menu_layout.cpp @@ -67,6 +67,8 @@ uint32_t s_name_change_id = 0; #endif AppScreen* s_pending_app_launch = nullptr; +void syncFocusedDescLabel(); + lv_obj_t* firstMenuButton() { for (const auto& item : s_menu_apps) @@ -79,11 +81,113 @@ lv_obj_t* firstMenuButton() return nullptr; } +lv_obj_t* lastMenuButton() +{ + for (size_t i = kMaxMenuApps; i > 0; --i) + { + const auto& item = s_menu_apps[i - 1]; + if (item.button != nullptr && !lv_obj_has_flag(item.button, LV_OBJ_FLAG_HIDDEN)) + { + return item.button; + } + } + return nullptr; +} + +size_t visibleMenuButtonCount() +{ + size_t count = 0; + for (const auto& item : s_menu_apps) + { + if (item.button != nullptr && !lv_obj_has_flag(item.button, LV_OBJ_FLAG_HIDDEN)) + { + ++count; + } + } + return count; +} + +lv_obj_t* cyclicMenuButtonByOffset(lv_obj_t* current, int delta) +{ + if (delta == 0) + { + return current; + } + + const size_t visible_count = visibleMenuButtonCount(); + if (visible_count == 0) + { + return nullptr; + } + + int current_visible_index = -1; + int scan_index = 0; + for (size_t i = 0; i < kMaxMenuApps; ++i) + { + if (s_menu_apps[i].button == nullptr || lv_obj_has_flag(s_menu_apps[i].button, LV_OBJ_FLAG_HIDDEN)) + { + continue; + } + if (s_menu_apps[i].button == current) + { + current_visible_index = scan_index; + break; + } + ++scan_index; + } + + if (current_visible_index < 0) + { + return delta > 0 ? firstMenuButton() : lastMenuButton(); + } + + const int wrapped_index = + (current_visible_index + delta + static_cast(visible_count)) % static_cast(visible_count); + + scan_index = 0; + for (size_t i = 0; i < kMaxMenuApps; ++i) + { + if (s_menu_apps[i].button == nullptr || lv_obj_has_flag(s_menu_apps[i].button, LV_OBJ_FLAG_HIDDEN)) + { + continue; + } + if (scan_index == wrapped_index) + { + return s_menu_apps[i].button; + } + ++scan_index; + } + + return nullptr; +} + bool usesDirectionalMenuKeys() { return ui::menu_profile::current().directional_key_nav; } +bool labelNeedsScroll(lv_obj_t* label) +{ + if (label == nullptr) + { + return false; + } + + const char* text = lv_label_get_text(label); + if (text == nullptr || text[0] == '\0') + { + return false; + } + + lv_point_t text_size{}; + const lv_font_t* font = lv_obj_get_style_text_font(label, LV_PART_MAIN); + const int32_t letter_space = lv_obj_get_style_text_letter_space(label, LV_PART_MAIN); + const int32_t line_space = lv_obj_get_style_text_line_space(label, LV_PART_MAIN); + lv_text_get_size( + &text_size, text, font, letter_space, line_space, LV_COORD_MAX, LV_TEXT_FLAG_EXPAND); + return text_size.x > lv_obj_get_width(label); +} + int findMenuButtonIndex(lv_obj_t* obj) { if (obj == nullptr) @@ -157,6 +261,22 @@ lv_obj_t* findMenuNeighbor(lv_obj_t* current, uint32_t key) return best; } +void focusMenuButton(lv_obj_t* target, lv_anim_enable_t anim = LV_ANIM_OFF) +{ + if (target == nullptr || menu_g == nullptr) + { + return; + } + + lv_group_focus_obj(target); + syncFocusedDescLabel(); + + if (ui::menu_profile::current().snap_center) + { + lv_obj_scroll_to_view(target, anim); + } +} + void ensureMenuFocus() { if (menu_g == nullptr) @@ -172,7 +292,34 @@ void ensureMenuFocus() if (lv_obj_t* first = firstMenuButton()) { - lv_group_focus_obj(first); + focusMenuButton(first, LV_ANIM_OFF); + } +} + +void syncFocusedDescLabel() +{ + if (s_desc_label == nullptr || menu_g == nullptr) + { + return; + } + + lv_obj_t* focused = lv_group_get_focused(menu_g); + int index = findMenuButtonIndex(focused); + if (index < 0) + { + for (size_t i = 0; i < kMaxMenuApps; ++i) + { + if (s_menu_apps[i].button != nullptr && !lv_obj_has_flag(s_menu_apps[i].button, LV_OBJ_FLAG_HIDDEN)) + { + index = static_cast(i); + break; + } + } + } + + if (index >= 0 && static_cast(index) < kMaxMenuApps) + { + ::ui::i18n::set_label_text(s_desc_label, s_menu_apps[index].name); } } @@ -229,12 +376,27 @@ void menuNameLabelEventCallback(lv_event_t* e) void menuButtonFocusCallback(lv_event_t* e) { - if (lv_event_get_code(e) != LV_EVENT_FOCUSED || s_desc_label == nullptr) + if (lv_event_get_code(e) != LV_EVENT_FOCUSED) { return; } auto* data = static_cast(lv_event_get_user_data(e)); + if (data != nullptr && data->label != nullptr) + { + const auto& profile = ui::menu_profile::current(); + lv_label_set_long_mode( + data->label, + (profile.scroll_card_label && labelNeedsScroll(data->label)) ? LV_LABEL_LONG_SCROLL_CIRCULAR + : LV_LABEL_LONG_DOT); + ::ui::i18n::set_label_text(data->label, data->name); + } + + if (s_desc_label == nullptr) + { + return; + } + const char* text = data ? data->name : nullptr; #if LVGL_VERSION_MAJOR == 9 lv_obj_send_event(s_desc_label, static_cast(s_name_change_id), (void*)text); @@ -243,6 +405,23 @@ void menuButtonFocusCallback(lv_event_t* e) #endif } +void menuButtonDefocusCallback(lv_event_t* e) +{ + if (lv_event_get_code(e) != LV_EVENT_DEFOCUSED) + { + return; + } + + auto* data = static_cast(lv_event_get_user_data(e)); + if (data == nullptr || data->label == nullptr) + { + return; + } + + lv_label_set_long_mode(data->label, LV_LABEL_LONG_DOT); + ::ui::i18n::set_label_text(data->label, data->name); +} + void menuButtonKeyCallback(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_KEY || menu_g == nullptr || !usesDirectionalMenuKeys()) @@ -256,11 +435,47 @@ void menuButtonKeyCallback(lv_event_t* e) return; } - lv_obj_t* current = lv_event_get_target_obj(e); - if (lv_obj_t* next = findMenuNeighbor(current, key)) + uint32_t nav_key = key; + const auto& profile = ui::menu_profile::current(); + if (!profile.wrap_grid && !profile.vertical_scroll) { - lv_group_focus_obj(next); - lv_obj_scroll_to_view(next, LV_ANIM_OFF); + if (nav_key == LV_KEY_UP) + { + nav_key = LV_KEY_LEFT; + } + else if (nav_key == LV_KEY_DOWN) + { + nav_key = LV_KEY_RIGHT; + } + } + + lv_obj_t* current = lv_event_get_target_obj(e); + lv_obj_t* next = nullptr; + if (!profile.wrap_grid && !profile.vertical_scroll) + { + const int delta = (nav_key == LV_KEY_LEFT || nav_key == LV_KEY_UP) ? -1 : 1; + next = cyclicMenuButtonByOffset(current, delta); + } + else + { + next = findMenuNeighbor(current, nav_key); + } + + if (next == nullptr) + { + if (nav_key == LV_KEY_LEFT || nav_key == LV_KEY_UP) + { + next = lastMenuButton(); + } + else if (nav_key == LV_KEY_RIGHT || nav_key == LV_KEY_DOWN) + { + next = firstMenuButton(); + } + } + + if (next != nullptr) + { + focusMenuButton(next, LV_ANIM_OFF); } } @@ -398,12 +613,23 @@ void createAppButton(lv_obj_t* parent, AppScreen* app, size_t idx) if (profile.show_card_label && name && name[0] != '\0') { lv_obj_t* label = lv_label_create(btn); - lv_obj_set_width(label, width - (profile.variant == ui::menu_profile::LayoutVariant::LargeTouchGrid ? 16 : 4)); + const lv_coord_t label_width = width - + (profile.variant == ui::menu_profile::LayoutVariant::LargeTouchGrid + ? 16 + : profile.card_label_width_inset); + const lv_coord_t line_height = + static_cast(lv_font_get_line_height(profile.card_label_font)); + lv_obj_set_width(label, label_width); + lv_obj_set_height(label, line_height); + lv_obj_set_style_max_height(label, line_height, 0); lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_font(label, profile.card_label_font, 0); lv_obj_set_style_text_color(label, lv_color_hex(0x6B4A1E), 0); lv_obj_set_style_text_color(label, lv_color_hex(0x6B4A1E), LV_STATE_FOCUSED); - lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + lv_obj_set_style_pad_all(label, 0, 0); + lv_label_set_long_mode( + label, + profile.scroll_card_label ? LV_LABEL_LONG_SCROLL_CIRCULAR : LV_LABEL_LONG_DOT); ::ui::i18n::set_label_text(label, name); s_menu_apps[idx].label = label; } @@ -458,6 +684,7 @@ void createAppButton(lv_obj_t* parent, AppScreen* app, size_t idx) if (idx < kMaxMenuApps) { lv_obj_add_event_cb(btn, menuButtonFocusCallback, LV_EVENT_FOCUSED, &s_menu_apps[idx]); + lv_obj_add_event_cb(btn, menuButtonDefocusCallback, LV_EVENT_DEFOCUSED, &s_menu_apps[idx]); } if (usesDirectionalMenuKeys()) { @@ -706,12 +933,18 @@ void createAppGrid() } s_bottom_bar_left = createBottomBarGroup(s_bottom_bar); - createBottomBarSpacer(s_bottom_bar); - s_bottom_bar_right = createBottomBarGroup(s_bottom_bar); - + s_bottom_bar_right = nullptr; + s_bottom_node_chip = {}; + s_bottom_ram_chip = {}; + s_bottom_psram_chip = {}; s_bottom_node_chip = createBottomBarChip(s_bottom_bar_left, profile, lv_color_hex(0xF1B75A), "-"); - s_bottom_ram_chip = createBottomBarChip(s_bottom_bar_right, profile, lv_color_hex(0xCFE4FF), "--/--"); - s_bottom_psram_chip = createBottomBarChip(s_bottom_bar_right, profile, lv_color_hex(0xD4F0D2), "--/--"); + if (profile.show_memory_stats) + { + createBottomBarSpacer(s_bottom_bar); + s_bottom_bar_right = createBottomBarGroup(s_bottom_bar); + s_bottom_ram_chip = createBottomBarChip(s_bottom_bar_right, profile, lv_color_hex(0xCFE4FF), "--/--"); + s_bottom_psram_chip = createBottomBarChip(s_bottom_bar_right, profile, lv_color_hex(0xD4F0D2), "--/--"); + } char node_id_buf[24]; const uint32_t self_id = s_init_options.messaging ? s_init_options.messaging->getSelfNodeId() : 0; @@ -734,6 +967,7 @@ void createAppGrid() #endif ensureMenuFocus(); + syncFocusedDescLabel(); lv_obj_update_snap(panel, LV_ANIM_ON); } diff --git a/modules/ui_shared/src/ui/menu/menu_profile.cpp b/modules/ui_shared/src/ui/menu/menu_profile.cpp index ab2117c8..bad40465 100644 --- a/modules/ui_shared/src/ui/menu/menu_profile.cpp +++ b/modules/ui_shared/src/ui/menu/menu_profile.cpp @@ -4,6 +4,10 @@ #define lv_font_montserrat_12 lv_font_montserrat_14 #endif +#if !defined(LV_FONT_MONTSERRAT_10) || !LV_FONT_MONTSERRAT_10 +#define lv_font_montserrat_10 lv_font_montserrat_12 +#endif + #if !defined(LV_FONT_MONTSERRAT_16) || !LV_FONT_MONTSERRAT_16 #define lv_font_montserrat_16 lv_font_montserrat_14 #endif @@ -187,6 +191,39 @@ MenuLayoutProfile make_tab5_profile() return profile; } +MenuLayoutProfile make_cardputer_zero_profile() +{ + MenuLayoutProfile profile = make_pager_profile(); + profile.name = "cardputer_zero"; + profile.input_mode = InputMode::Hybrid; + profile.directional_key_nav = true; + profile.show_top_bar = true; + profile.show_memory_stats = false; + profile.card_width = 112; + profile.card_height = 80; + profile.icon_scale = 208; + profile.grid_height_pct = 50; + profile.grid_top_offset = 28; + profile.grid_pad_column = 8; + profile.grid_pad_left = 6; + profile.grid_pad_right = 6; + profile.top_bar_height = 26; + profile.top_bar_side_inset = 4; + profile.top_bar_text_pad = 3; + profile.status_row_offset_y = 1; + profile.desc_offset = -2; + profile.node_id_offset_x = 4; + profile.node_id_offset_y = -8; + profile.badge_min_size = 18; + profile.badge_pad_h = 5; + profile.badge_pad_v = 2; + profile.desc_font = &lv_font_montserrat_12; + profile.node_id_font = &lv_font_montserrat_12; + profile.top_bar_font = &lv_font_montserrat_12; + profile.badge_font = &lv_font_montserrat_12; + return profile; +} + MenuLayoutProfile make_default_profile(lv_coord_t width, lv_coord_t height) { if (width >= 700 || height >= 700) @@ -210,6 +247,8 @@ const MenuLayoutProfile& current() return make_pager_profile(); #elif defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) return make_tdeck_profile(); +#elif defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + return make_cardputer_zero_profile(); #else return make_default_profile(display_width(), display_height()); #endif diff --git a/modules/ui_shared/src/ui/menu/menu_runtime.cpp b/modules/ui_shared/src/ui/menu/menu_runtime.cpp index acd5468c..4ddf0bde 100644 --- a/modules/ui_shared/src/ui/menu/menu_runtime.cpp +++ b/modules/ui_shared/src/ui/menu/menu_runtime.cpp @@ -11,6 +11,7 @@ #include "app/app_facade_access.h" #include "platform/ui/device_runtime.h" #include "platform/ui/time_runtime.h" +#include "ui/formatters.h" #include "ui/menu/menu_layout.h" #include "ui/menu/menu_profile.h" #include "ui/ui_common.h" diff --git a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp index c6f12ec3..37389da6 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp @@ -6,6 +6,7 @@ #include "ui/screens/chat/chat_message_list_components.h" +#include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/components/info_card.h" #include "ui/localization.h" diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index 8e50814d..e5052a20 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -11,16 +11,18 @@ #include "chat/usecase/contact_service.h" #include "platform/ui/gps_runtime.h" #include "platform/ui/screen_runtime.h" +#include "platform/ui/team_ui_store_runtime.h" #include "sys/clock.h" #include "sys/event_bus.h" #include "team/protocol/team_location_marker.h" #include "team/usecase/team_controller.h" +#include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" +#include "ui/formatters.h" #include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/chat/chat_protocol_support.h" #include "ui/screens/chat/chat_send_flow.h" -#include "ui/screens/team/team_ui_store.h" #include "ui/ui_common.h" #include "ui/widgets/ime/ime_widget.h" #include "ui/widgets/system_notification.h" diff --git a/modules/ui_shared/src/ui/screens/chat_watch/chat_compose_components_watch.cpp b/modules/ui_shared/src/ui/screens/chat_watch/chat_compose_components_watch.cpp index 282b10ad..4b14c5e5 100644 --- a/modules/ui_shared/src/ui/screens/chat_watch/chat_compose_components_watch.cpp +++ b/modules/ui_shared/src/ui/screens/chat_watch/chat_compose_components_watch.cpp @@ -2,14 +2,16 @@ #include "ui/screens/chat_watch/chat_compose_components_watch.h" -#include - #include "input/morse_engine.h" #include "ui/localization.h" #include "ui/ui_theme.h" #include "ui/widgets/system_notification.h" #include +#if !defined(TRAIL_MATE_WATCH_MORSE_PDM_SCK) || !defined(TRAIL_MATE_WATCH_MORSE_PDM_DATA) +#error "T-Watch S3 Morse input requires board-provided PDM pin definitions" +#endif + namespace { constexpr lv_coord_t kButtonHeight = 46; @@ -310,8 +312,8 @@ void ChatComposeScreen::showMorse() morse_ = new input::MorseEngine(); input::MorseEngine::Config cfg; - cfg.pin_sck = PDM_SCK; - cfg.pin_data = PDM_DATA; + cfg.pin_sck = TRAIL_MATE_WATCH_MORSE_PDM_SCK; + cfg.pin_data = TRAIL_MATE_WATCH_MORSE_PDM_DATA; cfg.input_gain = 3; cfg.min_high = 90; cfg.min_low = 45; diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index 11ac37f1..a5c49a46 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -11,11 +11,14 @@ #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "platform/ui/gps_runtime.h" +#include "platform/ui/team_ui_store_runtime.h" #include "sys/clock.h" #include "team/protocol/team_chat.h" #include "team/protocol/team_position.h" #include "team/usecase/team_controller.h" +#include "ui/app_runtime.h" #include "ui/components/info_card.h" +#include "ui/formatters.h" #include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/chat/chat_compose_components.h" @@ -29,7 +32,6 @@ #include "ui/screens/contacts/contacts_state.h" #include "ui/screens/node_info/node_info_page_components.h" #include "ui/screens/team/team_state.h" -#include "ui/screens/team/team_ui_store.h" #include "ui/ui_common.h" #include "ui/widgets/ime/ime_widget.h" #include "ui/widgets/system_notification.h" diff --git a/modules/ui_shared/src/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp b/modules/ui_shared/src/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp index 827d9179..40557d1e 100644 --- a/modules/ui_shared/src/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp @@ -1,6 +1,6 @@ #include "ui/screens/energy_sweep/energy_sweep_page_runtime.h" -#if defined(ARDUINO) || defined(ESP_PLATFORM) +#if defined(ARDUINO) || defined(ESP_PLATFORM) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) #include "app/app_config.h" #include "app/app_facade_access.h" @@ -10,6 +10,7 @@ #include "platform/ui/lora_runtime.h" #include "platform/ui/screen_runtime.h" #include "sys/clock.h" +#include "ui/app_runtime.h" #include "ui/localization.h" #include "ui/ui_common.h" #include @@ -81,14 +82,22 @@ constexpr int kSampleSettleMs = 2; constexpr int kSampleCount = 5; constexpr int kSampleGapMs = 1; -constexpr float kRssiFloor = -125.0f; -constexpr float kRssiCeil = -80.0f; constexpr float kNoiseEmaPrev = 0.7f; constexpr float kNoiseEmaNew = 0.3f; constexpr float kSweepEmaNew = 0.6f; constexpr float kSweepEmaPrev = 0.4f; constexpr float kHotEnterMarginDb = 10.0f; constexpr float kHotExitMarginDb = 7.0f; +constexpr float kViewFloorHardMinDbm = -140.0f; +constexpr float kViewFloorHardMaxDbm = -100.0f; +constexpr float kViewCeilHardMinDbm = -90.0f; +constexpr float kViewCeilHardMaxDbm = -25.0f; +constexpr float kViewFloorMarginDb = 10.0f; +constexpr float kViewPeakMarginDb = 8.0f; +constexpr float kViewNoiseHeadroomDb = 18.0f; +constexpr float kViewMinSpanDb = 32.0f; +constexpr float kViewEmaPrev = 0.75f; +constexpr float kViewEmaNew = 0.25f; constexpr int kBestGuardBins = 2; @@ -153,7 +162,7 @@ struct RadioContext struct SweepState { - bool scanning = true; + bool scanning = false; bool auto_applied = false; int cursor_index = 0; int scan_index = 0; @@ -163,6 +172,9 @@ struct SweepState int best_index = 0; float noise_dbm = -104.0f; bool noise_valid = false; + float view_floor_dbm = -130.0f; + float view_ceil_dbm = -60.0f; + bool view_valid = false; std::array rssi{}; std::array smooth{}; std::array hot{}; @@ -225,6 +237,19 @@ int clamp_index(int idx) return idx; } +float clamp_float(float value, float low, float high) +{ + if (value < low) + { + return low; + } + if (value > high) + { + return high; + } + return value; +} + float bin_to_freq_mhz(int idx) { return s_band.freq_start_mhz + static_cast(idx) * s_band.step_mhz; @@ -445,7 +470,7 @@ float sample_hw_rssi(int idx) int valid = 0; for (int i = 0; i < kSampleCount; ++i) { - const float rssi = platform::ui::lora::read_rssi(); + const float rssi = platform::ui::lora::read_instant_rssi(); if (std::isfinite(rssi) && rssi < 0.0f && rssi > -180.0f) { values[valid++] = rssi; @@ -558,6 +583,64 @@ void recompute_noise_and_hot(int available) } } +void recompute_view_range(int available) +{ + const int bins = active_bin_count(); + available = std::max(1, std::min(available, bins)); + + float observed_min = 0.0f; + float observed_max = 0.0f; + bool have_observation = false; + for (int i = 0; i < available; ++i) + { + const float value = display_value_for_bin(i); + if (!std::isfinite(value)) + { + continue; + } + if (!have_observation) + { + observed_min = value; + observed_max = value; + have_observation = true; + continue; + } + observed_min = std::min(observed_min, value); + observed_max = std::max(observed_max, value); + } + + if (!have_observation) + { + return; + } + + float target_floor = std::min(observed_min - 4.0f, s_state.noise_dbm - kViewFloorMarginDb); + target_floor = clamp_float(target_floor, kViewFloorHardMinDbm, kViewFloorHardMaxDbm); + + float target_ceil = std::max(observed_max + kViewPeakMarginDb, s_state.noise_dbm + kViewNoiseHeadroomDb); + target_ceil = clamp_float(target_ceil, kViewCeilHardMinDbm, kViewCeilHardMaxDbm); + + if ((target_ceil - target_floor) < kViewMinSpanDb) + { + target_ceil = std::min(kViewCeilHardMaxDbm, target_floor + kViewMinSpanDb); + } + if ((target_ceil - target_floor) < kViewMinSpanDb) + { + target_floor = std::max(kViewFloorHardMinDbm, target_ceil - kViewMinSpanDb); + } + + if (!s_state.view_valid) + { + s_state.view_floor_dbm = target_floor; + s_state.view_ceil_dbm = target_ceil; + s_state.view_valid = true; + return; + } + + s_state.view_floor_dbm = (kViewEmaPrev * s_state.view_floor_dbm) + (kViewEmaNew * target_floor); + s_state.view_ceil_dbm = (kViewEmaPrev * s_state.view_ceil_dbm) + (kViewEmaNew * target_ceil); +} + void recompute_best(int available) { const int bins = active_bin_count(); @@ -625,6 +708,7 @@ void process_scan_step() const int available = available_bins_for_metrics(); recompute_noise_and_hot(available); + recompute_view_range(available); recompute_best(available); s_state.sim_phase += 0.17f; } @@ -688,13 +772,20 @@ void refresh_top_status() lv_obj_set_style_text_color(s_ui.cad_chip_label, lv_color_white(), 0); ::ui::i18n::set_label_text(s_ui.cad_chip_label, "CAD"); } - else + else if (s_state.scanning) { lv_obj_set_style_bg_color(s_ui.cad_chip, lv_color_hex(0xD3C8AE), 0); lv_obj_set_style_border_color(s_ui.cad_chip, lv_color_hex(kColorLine), 0); lv_obj_set_style_text_color(s_ui.cad_chip_label, lv_color_hex(kColorTextDim), 0); ::ui::i18n::set_label_text(s_ui.cad_chip_label, "SIM"); } + else + { + lv_obj_set_style_bg_color(s_ui.cad_chip, lv_color_hex(0xD6E3D2), 0); + lv_obj_set_style_border_color(s_ui.cad_chip, lv_color_hex(0x7E9A76), 0); + lv_obj_set_style_text_color(s_ui.cad_chip_label, lv_color_hex(kColorOk), 0); + ::ui::i18n::set_label_text(s_ui.cad_chip_label, "MESH"); + } } void refresh_plot() @@ -721,7 +812,10 @@ void refresh_plot() lv_obj_clear_flag(bar, LV_OBJ_FLAG_HIDDEN); const float v = display_value_for_bin(i); - float t = (v - kRssiFloor) / (kRssiCeil - kRssiFloor); + const float view_floor = s_state.view_valid ? s_state.view_floor_dbm : -130.0f; + const float view_ceil = s_state.view_valid ? s_state.view_ceil_dbm : -60.0f; + const float view_span = std::max(1.0f, view_ceil - view_floor); + float t = (v - view_floor) / view_span; if (t < 0.0f) { t = 0.0f; @@ -928,20 +1022,62 @@ void apply_auto_choice() } } +void reset_scan_progress() +{ + s_state.scan_index = 0; + s_state.scanned_bins = 0; + s_state.completed_cycles = 0; + s_state.progress = 0.0f; +} + +bool acquire_radio_runtime(float freq_mhz) +{ + if (s_radio.use_hw) + { + return platform::ui::lora::configure_receive(freq_mhz, make_receive_config()); + } + + if (!platform::ui::lora::acquire() || !platform::ui::lora::is_online()) + { + s_radio.use_hw = false; + return false; + } + + s_radio.use_hw = true; + if (platform::ui::lora::configure_receive(freq_mhz, make_receive_config())) + { + return true; + } + + platform::ui::lora::release(); + s_radio.use_hw = false; + return false; +} + +void release_radio_runtime() +{ + if (!s_radio.use_hw) + { + return; + } + + platform::ui::lora::release(); + s_radio.use_hw = false; +} + void on_scan_btn_clicked(lv_event_t*) { s_state.auto_applied = false; if (s_state.scanning) { s_state.scanning = false; + release_radio_runtime(); } else { + reset_scan_progress(); + (void)acquire_radio_runtime(s_band.freq_start_mhz); s_state.scanning = true; - s_state.scan_index = 0; - s_state.scanned_bins = 0; - s_state.completed_cycles = 0; - s_state.progress = 0.0f; } refresh_all_ui(); } @@ -1041,29 +1177,11 @@ void setup_radio_context() s_radio.cr = (mesh.coding_rate >= 5 && mesh.coding_rate <= 8) ? mesh.coding_rate : 5; s_radio.tx_power = mesh.tx_power; } - - if (!platform::ui::lora::acquire() || !platform::ui::lora::is_online()) - { - return; - } - - s_radio.use_hw = true; - platform::ui::lora::configure_receive(s_band.freq_start_mhz, make_receive_config()); } void teardown_radio_context() { - if (!s_radio.use_hw) - { - s_radio = {}; - return; - } - - if (s_radio.use_hw) - { - platform::ui::lora::release(); - } - + release_radio_runtime(); s_radio = {}; } @@ -1071,9 +1189,12 @@ void init_sweep_state() { s_state = {}; const int bins = active_bin_count(); - s_state.scanning = true; + s_state.scanning = false; s_state.noise_dbm = -104.0f; s_state.noise_valid = true; + s_state.view_floor_dbm = -130.0f; + s_state.view_ceil_dbm = -60.0f; + s_state.view_valid = true; s_state.rand_state ^= static_cast(sys::millis_now()); s_state.sim_phase = random_unit() * 37.0f; s_state.cursor_index = bins / 2; @@ -1098,6 +1219,7 @@ void init_sweep_state() s_state.scan_index = 0; s_state.progress = 0.0f; recompute_noise_and_hot(bins); + recompute_view_range(bins); recompute_best(bins); } diff --git a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp index 535ea9a7..06c3c535 100644 --- a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp @@ -7,6 +7,7 @@ #include #include "platform/ui/device_runtime.h" +#include "platform/ui/pack_repository_runtime.h" #include "platform/ui/wifi_runtime.h" #include "ui/app_runtime.h" #include "ui/components/info_card.h" @@ -14,7 +15,6 @@ #include "ui/components/two_pane_styles.h" #include "ui/localization.h" #include "ui/page/page_profile.h" -#include "ui/runtime/pack_repository.h" #include "ui/ui_theme.h" #include "ui/widgets/busy_overlay.h" #include "ui/widgets/system_notification.h" diff --git a/modules/ui_shared/src/ui/screens/gnss/gnss_skyplot_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gnss/gnss_skyplot_page_runtime.cpp index f1df465e..dd3444fb 100644 --- a/modules/ui_shared/src/ui/screens/gnss/gnss_skyplot_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gnss/gnss_skyplot_page_runtime.cpp @@ -2,10 +2,12 @@ #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" +#include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/ui_common.h" +#include "ui/widgets/top_bar.h" #include #include #include diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp index b8502510..2487799c 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp @@ -601,23 +601,313 @@ void exit(lv_obj_t* parent) #else +using Host = gps::ui::shell::Host; + +#include "app/app_config.h" +#include "app/app_facade_access.h" +#include "platform/ui/device_runtime.h" +#include "platform/ui/gps_runtime.h" +#include "ui/app_runtime.h" +#include "ui/localization.h" +#include "ui/screens/gps/gps_constants.h" +#include "ui/ui_common.h" +#include "ui/widgets/map/map_viewport.h" +#include "ui/widgets/top_bar.h" + +#include +#include + +#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 +#define lv_font_montserrat_12 lv_font_montserrat_14 +#endif + +namespace +{ + +constexpr lv_coord_t kCompactTopBarHeight = 26; +constexpr lv_coord_t kCompactTopBarBackWidth = 34; +constexpr lv_coord_t kCompactTopBarBackHeight = 18; +constexpr lv_coord_t kCompactTopBarRightWidth = 56; +constexpr int kCardputerZeroMapDefaultZoom = 7; + +const Host* s_host = nullptr; +lv_obj_t* s_root = nullptr; +lv_timer_t* s_timer = nullptr; +::ui::widgets::TopBar s_top_bar; +::ui::widgets::map::Runtime s_map_runtime; +int s_map_zoom = kCardputerZeroMapDefaultZoom; +int s_map_pan_x = 0; +int s_map_pan_y = 0; + +void request_exit() +{ + if (s_host) + { + ::ui::page::request_exit(s_host); + return; + } + ui_request_exit_to_menu(); +} + +::ui::widgets::map::Model build_map_model(const platform::ui::gps::GpsState& gps_data) +{ + const auto& config = app::configFacade().getConfig(); + + ::ui::widgets::map::Model model{}; + model.focus_point.valid = gps_data.valid; + model.focus_point.lat = gps_data.lat; + model.focus_point.lon = gps_data.lng; + model.zoom = s_map_zoom; + model.pan_x = s_map_pan_x; + model.pan_y = s_map_pan_y; + model.map_source = config.map_source; + model.contour_enabled = config.map_contour_enabled; + model.coord_system = config.map_coord_system; + return model; +} + +void apply_compact_top_bar_style(::ui::widgets::TopBar& bar) +{ + if (!bar.container || !lv_obj_is_valid(bar.container)) + { + return; + } + + lv_obj_set_height(bar.container, kCompactTopBarHeight); + lv_obj_set_style_pad_left(bar.container, 8, 0); + lv_obj_set_style_pad_right(bar.container, 8, 0); + lv_obj_set_style_pad_top(bar.container, 2, 0); + lv_obj_set_style_pad_bottom(bar.container, 2, 0); + lv_obj_set_style_pad_column(bar.container, 4, 0); + + if (bar.back_btn && lv_obj_is_valid(bar.back_btn)) + { + lv_obj_set_size(bar.back_btn, kCompactTopBarBackWidth, kCompactTopBarBackHeight); + lv_obj_set_style_radius(bar.back_btn, kCompactTopBarBackHeight / 2, LV_PART_MAIN); + lv_obj_set_style_text_font(bar.back_btn, &lv_font_montserrat_12, 0); + lv_obj_t* back_label = lv_obj_get_child(bar.back_btn, 0); + if (back_label && lv_obj_is_valid(back_label)) + { + lv_obj_set_style_text_font(back_label, &lv_font_montserrat_12, 0); + } + } + + if (bar.title_label && lv_obj_is_valid(bar.title_label)) + { + lv_obj_set_style_text_font(bar.title_label, &lv_font_montserrat_12, 0); + } + if (bar.right_label && lv_obj_is_valid(bar.right_label)) + { + lv_obj_set_width(bar.right_label, kCompactTopBarRightWidth); + lv_obj_set_style_text_font(bar.right_label, &lv_font_montserrat_12, 0); + } +} + +void refresh_view() +{ + if (!s_root) + { + return; + } + + ui_update_top_bar_battery(s_top_bar); + apply_compact_top_bar_style(s_top_bar); + + const platform::ui::gps::GpsState gps_data = platform::ui::gps::get_data(); + + if (gps_data.valid) + { + ::ui::widgets::map::apply_model(s_map_runtime, build_map_model(gps_data)); + } + else + { + ::ui::widgets::map::clear(s_map_runtime); + } +} + +void refresh_timer_cb(lv_timer_t* timer) +{ + (void)timer; + refresh_view(); +} + +void consume_key_event(lv_event_t* e) +{ + if (!e) + { + return; + } + + lv_event_stop_bubbling(e); + lv_event_stop_processing(e); + if (lv_indev_t* indev = lv_event_get_indev(e)) + { + lv_indev_stop_processing(indev); + } +} + +bool handle_map_key(uint32_t key, lv_event_t* e) +{ + switch (key) + { + case LV_KEY_LEFT: + s_map_pan_x -= gps_ui::kMapPanStep; + break; + case LV_KEY_RIGHT: + s_map_pan_x += gps_ui::kMapPanStep; + break; + case LV_KEY_UP: + s_map_pan_y += gps_ui::kMapPanStep; + break; + case LV_KEY_DOWN: + s_map_pan_y -= gps_ui::kMapPanStep; + break; + case '+': + case '=': + s_map_zoom = std::min(s_map_zoom + 1, ::ui::widgets::map::kMaxZoom); + break; + case '-': + case '_': + s_map_zoom = std::max(s_map_zoom - 1, ::ui::widgets::map::kMinZoom); + break; + default: + return false; + } + + refresh_view(); + consume_key_event(e); + return true; +} + +void on_back(void*) +{ + request_exit(); +} + +void root_key_event_cb(lv_event_t* e) +{ + const uint32_t key = lv_event_get_key(e); + if (handle_map_key(key, e)) + { + return; + } + if (key == LV_KEY_BACKSPACE) + { + consume_key_event(e); + request_exit(); + } +} + +} // namespace + namespace gps::ui::runtime { bool is_available() { - return false; + return platform::ui::device::gps_supported(); } void enter(const shell::Host* host, lv_obj_t* parent) { - (void)host; - (void)parent; + s_host = host; + s_map_zoom = kCardputerZeroMapDefaultZoom; + s_map_pan_x = 0; + s_map_pan_y = 0; + + lv_group_t* prev_group = lv_group_get_default(); + set_default_group(nullptr); + + s_root = lv_obj_create(parent); + lv_obj_set_size(s_root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(s_root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(s_root, lv_color_hex(0xFFF3DF), 0); + lv_obj_set_style_bg_opa(s_root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_root, 0, 0); + lv_obj_set_style_pad_all(s_root, 0, 0); + lv_obj_set_style_pad_row(s_root, 0, 0); + lv_obj_clear_flag(s_root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(s_root, root_key_event_cb, LV_EVENT_KEY, nullptr); + + ::ui::widgets::TopBarConfig top_bar_config{}; + top_bar_config.height = kCompactTopBarHeight; + ::ui::widgets::top_bar_init(s_top_bar, s_root, top_bar_config); + ::ui::widgets::top_bar_set_title(s_top_bar, ::ui::i18n::tr("Map")); + ::ui::widgets::top_bar_set_back_callback(s_top_bar, on_back, nullptr); + if (s_top_bar.back_btn) + { + lv_obj_add_event_cb(s_top_bar.back_btn, root_key_event_cb, LV_EVENT_KEY, nullptr); + } + ui_update_top_bar_battery(s_top_bar); + apply_compact_top_bar_style(s_top_bar); + + if (app_g && s_top_bar.back_btn) + { + lv_group_remove_all_objs(app_g); + lv_group_add_obj(app_g, s_top_bar.back_btn); + lv_group_focus_obj(s_top_bar.back_btn); + set_default_group(app_g); + lv_group_set_editing(app_g, false); + } + else + { + set_default_group(prev_group); + } + + lv_obj_t* content = lv_obj_create(s_root); + lv_obj_set_size(content, LV_PCT(100), 0); + lv_obj_set_flex_grow(content, 1); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(content, 0, 0); + lv_obj_set_style_pad_all(content, 0, 0); + lv_obj_set_style_pad_row(content, 0, 0); + lv_obj_clear_flag(content, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* viewport = lv_obj_create(content); + lv_obj_set_size(viewport, LV_PCT(100), 0); + lv_obj_set_flex_grow(viewport, 1); + lv_obj_set_style_bg_color(viewport, lv_color_hex(0xEAD9B2), 0); + lv_obj_set_style_bg_opa(viewport, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(viewport, 0, 0); + lv_obj_set_style_radius(viewport, 0, 0); + lv_obj_set_style_pad_all(viewport, 0, 0); + lv_obj_clear_flag(viewport, LV_OBJ_FLAG_SCROLLABLE); + + const auto map_widgets = ::ui::widgets::map::create(s_map_runtime, viewport, 180); + lv_obj_update_layout(content); + lv_obj_update_layout(viewport); + ::ui::widgets::map::set_size(s_map_runtime, + lv_obj_get_content_width(viewport), + lv_obj_get_content_height(viewport)); + if (map_widgets.root) + { + lv_obj_align(map_widgets.root, LV_ALIGN_CENTER, 0, 0); + } + + refresh_view(); + if (!s_timer) + { + s_timer = lv_timer_create(refresh_timer_cb, 750, nullptr); + } } void exit(lv_obj_t* parent) { (void)parent; + + if (s_timer) + { + lv_timer_del(s_timer); + s_timer = nullptr; + } + ::ui::widgets::map::destroy(s_map_runtime); + if (s_root) + { + lv_obj_del(s_root); + s_root = nullptr; + } + s_host = nullptr; } } // namespace gps::ui::runtime diff --git a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp index d6df57cd..73f09606 100644 --- a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp @@ -10,6 +10,7 @@ #include "chat/usecase/contact_service.h" #include "platform/ui/gps_runtime.h" #include "sys/clock.h" +#include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" #include "ui/menu/dashboard/dashboard_style.h" diff --git a/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp b/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp index 2c88cc85..335f5ae6 100644 --- a/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp @@ -1,8 +1,5 @@ #include "ui/screens/pc_link/pc_link_page_runtime.h" -#if defined(ARDUINO) || defined(ESP_PLATFORM) - -#include "app/app_config.h" #include "app/app_facade_access.h" #include "platform/ui/hostlink_runtime.h" #include "ui/app_runtime.h" @@ -42,7 +39,7 @@ void request_exit() bool use_rnode_bridge() { - return app::appFacade().getConfig().mesh_protocol == chat::MeshProtocol::RNode; + return app::hasAppFacade() && app::appFacade().getMeshProtocol() == chat::MeshProtocol::RNode; } const char* page_title() @@ -255,28 +252,3 @@ void exit(lv_obj_t* parent) } } // namespace pc_link::ui::runtime - -#else - -namespace pc_link::ui::runtime -{ - -bool is_available() -{ - return false; -} - -void enter(const shell::Host* host, lv_obj_t* parent) -{ - (void)host; - (void)parent; -} - -void exit(lv_obj_t* parent) -{ - (void)parent; -} - -} // namespace pc_link::ui::runtime - -#endif diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index ceb393f6..4d6bc1bb 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -22,6 +22,7 @@ #include "platform/ui/gps_runtime.h" #include "platform/ui/screen_runtime.h" #include "platform/ui/settings_store.h" +#include "platform/ui/team_ui_store_runtime.h" #include "platform/ui/time_runtime.h" #include "platform/ui/tracker_runtime.h" #include "platform/ui/wifi_runtime.h" @@ -36,7 +37,6 @@ #include "ui/screens/settings/settings_page_layout.h" #include "ui/screens/settings/settings_page_styles.h" #include "ui/screens/settings/settings_state.h" -#include "ui/screens/team/team_ui_store.h" #include "ui/ui_common.h" #include "ui/widgets/busy_overlay.h" #include "ui/widgets/system_notification.h" @@ -895,12 +895,15 @@ static void settings_load() { gps_interval_seconds = 1; } + g_settings.gps_enabled = cfg.gps_enabled; g_settings.gps_mode = cfg.gps_mode; g_settings.gps_sat_mask = cfg.gps_sat_mask; g_settings.gps_strategy = cfg.gps_strategy; g_settings.gps_interval = static_cast(gps_interval_seconds); g_settings.gps_alt_ref = cfg.gps_alt_ref; g_settings.gps_coord_format = cfg.gps_coord_format; + g_settings.external_nmea_output_hz = cfg.external_nmea_output_hz; + g_settings.external_nmea_sentence_mask = cfg.external_nmea_sentence_mask; g_settings.map_coord_system = cfg.map_coord_system; g_settings.map_source = cfg.map_source; @@ -1006,8 +1009,6 @@ static void settings_load() } g_settings.privacy_encrypt_mode = cfg.privacy_encrypt_mode; - g_settings.privacy_nmea_output = cfg.privacy_nmea_output; - g_settings.privacy_nmea_sentence = cfg.privacy_nmea_sentence; g_settings.screen_timeout_ms = static_cast(screen_runtime::timeout_ms()); g_settings.screen_brightness = clamp_screen_brightness( @@ -1886,21 +1887,21 @@ static void on_option_clicked(lv_event_t* e) app_ctx.saveConfig(); app_ctx.applyPrivacyConfig(); } - if (payload->item->pref_key && strcmp(payload->item->pref_key, "privacy_nmea") == 0) + if (payload->item->pref_key && strcmp(payload->item->pref_key, "external_nmea") == 0) { app::IAppFacade& app_ctx = app::appFacade(); - app_ctx.getConfig().privacy_nmea_output = static_cast(payload->value); + app_ctx.getConfig().external_nmea_output_hz = static_cast(payload->value); app_ctx.saveConfig(); - gps_runtime::set_nmea_config(app_ctx.getConfig().privacy_nmea_output, - app_ctx.getConfig().privacy_nmea_sentence); + gps_runtime::set_external_nmea_config(app_ctx.getConfig().external_nmea_output_hz, + app_ctx.getConfig().external_nmea_sentence_mask); } - if (payload->item->pref_key && strcmp(payload->item->pref_key, "privacy_nmea_sent") == 0) + if (payload->item->pref_key && strcmp(payload->item->pref_key, "external_nmea_sent") == 0) { app::IAppFacade& app_ctx = app::appFacade(); - app_ctx.getConfig().privacy_nmea_sentence = static_cast(payload->value); + app_ctx.getConfig().external_nmea_sentence_mask = static_cast(payload->value); app_ctx.saveConfig(); - gps_runtime::set_nmea_config(app_ctx.getConfig().privacy_nmea_output, - app_ctx.getConfig().privacy_nmea_sentence); + gps_runtime::set_external_nmea_config(app_ctx.getConfig().external_nmea_output_hz, + app_ctx.getConfig().external_nmea_sentence_mask); } if (payload->item->pref_key && strcmp(payload->item->pref_key, "timezone_offset") == 0) { @@ -2450,12 +2451,12 @@ static const settings::ui::SettingOption kPrivacyEncryptOptions[] = { {"PSK", 1}, {"PKI", 2}, }; -static const settings::ui::SettingOption kPrivacyNmeaOptions[] = { +static const settings::ui::SettingOption kExternalNmeaOptions[] = { {"OFF", 0}, {"1Hz", 1}, {"5Hz", 5}, }; -static const settings::ui::SettingOption kPrivacyNmeaSentenceOptions[] = { +static const settings::ui::SettingOption kExternalNmeaSentenceOptions[] = { {"GGA+RMC+GSV", 0}, {"RMC+GSV", 1}, {"GGA+RMC", 2}, @@ -2469,7 +2470,9 @@ static const settings::ui::SettingOption kScreenTimeoutOptions[] = { }; static const settings::ui::SettingOption kScreenBrightnessOptions[] = { +#if DEVICE_MIN_BRIGHTNESS_LEVEL <= 0 {"OFF", 0}, +#endif {"25%", 4}, {"50%", 8}, {"75%", 12}, @@ -2514,14 +2517,15 @@ static const settings::ui::SettingOption kTimeZoneOptions[] = { }; static settings::ui::SettingItem kGpsItems[] = { + {"GPS Enabled", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.gps_enabled, nullptr, 0, false, "gps_enabled"}, {"Location Mode", settings::ui::SettingType::Enum, kGpsModeOptions, 3, &g_settings.gps_mode, nullptr, nullptr, 0, false, "gps_mode"}, {"Satellite Systems", settings::ui::SettingType::Enum, kGpsSatOptions, 5, &g_settings.gps_sat_mask, nullptr, nullptr, 0, false, "gps_sat_mask"}, {"Position Strategy", settings::ui::SettingType::Enum, kGpsStrategyOptions, 3, &g_settings.gps_strategy, nullptr, nullptr, 0, false, "gps_strategy"}, {"Update Interval", settings::ui::SettingType::Enum, kGpsIntervalOptions, 4, &g_settings.gps_interval, nullptr, nullptr, 0, false, "gps_interval"}, {"Altitude Reference", settings::ui::SettingType::Enum, kGpsAltOptions, 2, &g_settings.gps_alt_ref, nullptr, nullptr, 0, false, "gps_alt_ref"}, {"Coordinate Format", settings::ui::SettingType::Enum, kGpsCoordOptions, 3, &g_settings.gps_coord_format, nullptr, nullptr, 0, false, "gps_coord_fmt"}, - {"NMEA Output", settings::ui::SettingType::Enum, kPrivacyNmeaOptions, 3, &g_settings.privacy_nmea_output, nullptr, nullptr, 0, false, "privacy_nmea"}, - {"NMEA Sentences", settings::ui::SettingType::Enum, kPrivacyNmeaSentenceOptions, 3, &g_settings.privacy_nmea_sentence, nullptr, nullptr, 0, false, "privacy_nmea_sent"}, + {"NMEA Export", settings::ui::SettingType::Enum, kExternalNmeaOptions, 3, &g_settings.external_nmea_output_hz, nullptr, nullptr, 0, false, "external_nmea"}, + {"NMEA Sentences", settings::ui::SettingType::Enum, kExternalNmeaSentenceOptions, 3, &g_settings.external_nmea_sentence_mask, nullptr, nullptr, 0, false, "external_nmea_sent"}, }; static settings::ui::SettingItem kMapItems[] = { @@ -3030,6 +3034,13 @@ static bool activate_item_widget(settings::ui::ItemWidget& widget) app_ctx.getConfig().map_contour_enabled = *item.bool_value; app_ctx.saveConfig(); } + if (item.pref_key && strcmp(item.pref_key, "gps_enabled") == 0) + { + app::IAppFacade& app_ctx = app::appFacade(); + app_ctx.getConfig().gps_enabled = *item.bool_value; + app_ctx.saveConfig(); + gps_runtime::set_enabled(*item.bool_value); + } if (item.pref_key && strcmp(item.pref_key, "net_duty_cycle") == 0) { app::IAppFacade& app_ctx = app::appFacade(); diff --git a/modules/ui_shared/src/ui/screens/sstv/sstv_page_runtime.cpp b/modules/ui_shared/src/ui/screens/sstv/sstv_page_runtime.cpp index 981fcbcf..af6a3341 100644 --- a/modules/ui_shared/src/ui/screens/sstv/sstv_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/sstv/sstv_page_runtime.cpp @@ -1,15 +1,16 @@ #include "ui/screens/sstv/sstv_page_runtime.h" -#if defined(ARDUINO) || defined(ESP_PLATFORM) +#if defined(ARDUINO) || defined(ESP_PLATFORM) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) #include "platform/ui/device_runtime.h" #include "platform/ui/screen_runtime.h" #include "platform/ui/sstv_runtime.h" -#include "ui/LV_Helper.h" +#include "ui/app_runtime.h" #include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/ui_common.h" #include "ui/widgets/system_notification.h" +#include "ui/widgets/top_bar.h" #include #include #include diff --git a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp index d69d350f..9ea6634f 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp @@ -6,6 +6,7 @@ #include "ui/screens/team/team_page_components.h" #include "app/app_facade_access.h" #include "chat/usecase/contact_service.h" +#include "platform/ui/team_ui_store_runtime.h" #include "sys/clock.h" #include "sys/event_bus.h" #include "team/protocol/team_mgmt.h" @@ -15,6 +16,7 @@ #include "team/usecase/team_controller.h" #include "team/usecase/team_pairing_service.h" #include "team/usecase/team_service.h" +#include "ui/app_runtime.h" #include "ui/chat_ui_runtime.h" #include "ui/localization.h" #include "ui/page/page_profile.h" @@ -24,7 +26,6 @@ #include "ui/screens/team/team_page_layout.h" #include "ui/screens/team/team_page_styles.h" #include "ui/screens/team/team_state.h" -#include "ui/screens/team/team_ui_store.h" #include "ui/ui_common.h" #include "ui/widgets/system_notification.h" diff --git a/modules/ui_shared/src/ui/screens/team/team_page_input.cpp b/modules/ui_shared/src/ui/screens/team/team_page_input.cpp index 421c5bac..697db9b2 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_input.cpp @@ -4,6 +4,7 @@ */ #include "ui/screens/team/team_page_input.h" +#include "ui/app_runtime.h" #include "ui/screens/team/team_state.h" #include "ui/ui_common.h" diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp index be5cd6f1..a4b7f91c 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp @@ -4,6 +4,7 @@ #include "platform/ui/device_runtime.h" #include "platform/ui/route_storage.h" #include "platform/ui/tracker_runtime.h" +#include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" #include "ui/page/page_profile.h" diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_runtime.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_runtime.cpp index c55a3efd..624b173e 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_runtime.cpp @@ -1,6 +1,9 @@ #include "ui/screens/tracker/tracker_page_runtime.h" #include "app/app_facade_access.h" +#include "platform/ui/route_storage.h" +#include "platform/ui/tracker_runtime.h" +#include "ui/app_runtime.h" #include "ui/screens/tracker/tracker_page_components.h" #include "ui/screens/tracker/tracker_page_input.h" #include "ui/ui_common.h" @@ -10,7 +13,8 @@ namespace tracker::ui::runtime bool is_available() { - return app::hasAppFacade(); + return app::hasAppFacade() && + (::platform::ui::tracker::is_supported() || ::platform::ui::route_storage::is_supported()); } void enter(const shell::Host* host, lv_obj_t* parent) @@ -40,4 +44,4 @@ void exit(lv_obj_t* parent) tracker::ui::components::cleanup_page(); } -} // namespace tracker::ui::runtime \ No newline at end of file +} // namespace tracker::ui::runtime diff --git a/modules/ui_shared/src/ui/screens/walkie_talkie/walkie_talkie_page_runtime.cpp b/modules/ui_shared/src/ui/screens/walkie_talkie/walkie_talkie_page_runtime.cpp index 9775060b..cea95538 100644 --- a/modules/ui_shared/src/ui/screens/walkie_talkie/walkie_talkie_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/walkie_talkie/walkie_talkie_page_runtime.cpp @@ -1,6 +1,6 @@ #include "ui/screens/walkie_talkie/walkie_talkie_page_runtime.h" -#if defined(ARDUINO) || defined(ESP_PLATFORM) +#if defined(ARDUINO) || defined(ESP_PLATFORM) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) #include "platform/ui/device_runtime.h" #include "platform/ui/screen_runtime.h" diff --git a/modules/ui_shared/src/ui/startup_ui_shell.cpp b/modules/ui_shared/src/ui/startup_ui_shell.cpp index e253bd8d..254eefa2 100644 --- a/modules/ui_shared/src/ui/startup_ui_shell.cpp +++ b/modules/ui_shared/src/ui/startup_ui_shell.cpp @@ -1,10 +1,15 @@ #include "ui/startup_ui_shell.h" +#include + #include "lvgl.h" +#include "platform/ui/time_runtime.h" #include "ui/app_runtime.h" #include "ui/localization.h" #include "ui/menu/menu_layout.h" +#include "ui/menu/menu_runtime.h" #include "ui/ui_boot.h" +#include "ui/widgets/system_notification.h" namespace ui::startup_ui_shell { @@ -19,6 +24,61 @@ void present_boot_overlay_now() lv_refr_now(nullptr); } +bool resolve_display_time(struct tm* out_tm) +{ + if (!out_tm) + { + return false; + } + + if (::platform::ui::time::localtime_now(out_tm)) + { + return true; + } + + const std::time_t now = std::time(nullptr); + if (now <= 0) + { + return false; + } + + const std::time_t local = ::platform::ui::time::apply_timezone_offset(now); + const std::tm* tmp = std::gmtime(&local); + if (!tmp) + { + return false; + } + + *out_tm = *tmp; + return true; +} + +bool format_menu_time(char* out, size_t out_len) +{ + if (!out || out_len < 6) + { + return false; + } + + struct tm info + { + }; + if (!resolve_display_time(&info)) + { + return false; + } + + std::strftime(out, out_len, "%H:%M", &info); + return true; +} + +ui::menu_runtime::Hooks build_menu_runtime_hooks() +{ + ui::menu_runtime::Hooks hooks{}; + hooks.format_time = format_menu_time; + return hooks; +} + bool lock_ui(const Hooks& hooks) { return hooks.lock_ui ? hooks.lock_ui(hooks.lock_timeout_ms) : true; @@ -50,6 +110,7 @@ bool prepareBootUi(const Hooks& hooks, bool waking_from_sleep) present_boot_overlay_now(); unlock_ui(hooks); ::ui::i18n::reload_language(); + ui::SystemNotification::init(); return true; } @@ -71,6 +132,8 @@ bool initializeMenuSkeleton(const Hooks& hooks) ui::menu_layout::InitOptions options{}; options.apps = hooks.apps; ui::menu_layout::init(options); + ui::menu_runtime::init( + lv_screen_active(), main_screen, ui::menu_layout::menuPanel(), build_menu_runtime_hooks()); s_shell_initialized = true; unlock_ui(hooks); diff --git a/modules/ui_shared/src/ui/ui_boot.cpp b/modules/ui_shared/src/ui/ui_boot.cpp index 84d1da4a..c40e6a26 100644 --- a/modules/ui_shared/src/ui/ui_boot.cpp +++ b/modules/ui_shared/src/ui/ui_boot.cpp @@ -15,7 +15,9 @@ namespace { constexpr uint32_t kFadeMs = 900; -#if defined(TRAIL_MATE_ESP_BOARD_TAB5) +#if defined(TRAIL_MATE_BOOT_MIN_SHOW_MS) +constexpr uint32_t kMinShowMs = TRAIL_MATE_BOOT_MIN_SHOW_MS; +#elif defined(TRAIL_MATE_ESP_BOARD_TAB5) constexpr uint32_t kMinShowMs = 900; #else constexpr uint32_t kMinShowMs = 3000; diff --git a/modules/ui_shared/src/ui/ui_status.cpp b/modules/ui_shared/src/ui/ui_status.cpp index feb76a96..c59ef06f 100644 --- a/modules/ui_shared/src/ui/ui_status.cpp +++ b/modules/ui_shared/src/ui/ui_status.cpp @@ -14,7 +14,7 @@ #include "platform/ui/wifi_runtime.h" #include "sys/clock.h" #if !defined(GAT562_NO_TEAM) || !GAT562_NO_TEAM -#include "ui/screens/team/team_ui_store.h" +#include "platform/ui/team_ui_store_runtime.h" #endif #include diff --git a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp index cc964243..8e62a977 100644 --- a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp +++ b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp @@ -29,7 +29,7 @@ static constexpr lv_coord_t kCompactImeControlHeight = 18; bool script_input_available() { - return ::ui::i18n::any_enabled_script_input(); + return ::ui::i18n::active_locale_supports_script_input(); } #if UI_SHARED_TOUCH_IME_ENABLED diff --git a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp index 64a9b924..3a450a98 100644 --- a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp +++ b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp @@ -716,25 +716,10 @@ bool focus_tile_available(const Model& model) latLngToTile(transformed.lat, transformed.lon, model.zoom, tile_x, tile_y); normalize_tile(model.zoom, tile_x, tile_y); - char path[96]; - if (!build_base_tile_path(model.zoom, - tile_x, - tile_y, - sanitize_map_source(model.map_source), - path, - sizeof(path))) - { - return false; - } - - lv_fs_file_t file; - const lv_fs_res_t res = lv_fs_open(&file, path, LV_FS_MODE_RD); - if (res != LV_FS_RES_OK) - { - return false; - } - lv_fs_close(&file); - return true; + return base_tile_available(model.zoom, + tile_x, + tile_y, + sanitize_map_source(model.map_source)); } Status status(const Runtime& runtime) diff --git a/packs/ar/DESCRIPTION.txt b/packs/ar/DESCRIPTION.txt new file mode 100644 index 00000000..f12e03ce --- /dev/null +++ b/packs/ar/DESCRIPTION.txt @@ -0,0 +1,12 @@ +Arabic locale bundle for Trail Mate. + +Includes: +- ar-naskh font (Noto Naskh Arabic, 16px, 2bpp) + Covers Arabic core (0600-06FF), Supplement (0750-077F), + Presentation Forms-A (FB50-FDFF) and Forms-B (FE70-FEFF) + for correct contextual letter shaping without HarfBuzz. +- Arabic translations (ar locale) +- RTL direction support (direction=rtl in manifest) +- Display-only review status; Arabic input is not implemented yet + +Font dependency: tools/fonts/NotoNaskhArabic-Regular.otf diff --git a/packs/ar/README.md b/packs/ar/README.md new file mode 100644 index 00000000..01dd088f --- /dev/null +++ b/packs/ar/README.md @@ -0,0 +1,22 @@ +# Arabic Locale Pack + +Arabic language support for Trail Mate, built around Noto Naskh Arabic. + +## Features +- Arabic UI and content rendering (RTL) +- Naskh font covering Arabic, Arabic Supplement, and Presentation Forms A/B +- Display-only review locale; Arabic input is not implemented yet +- Arabic translations (strings.tsv) + +## Font Strategy +Arabic script requires contextual glyph shaping. Since LVGL binfont does not +perform shaping at runtime, the presentation forms (isolated, final, medial, +initial) are pre-baked into the font at build time. The font covers: +- U+0600–U+06FF Arabic core +- U+0750–U+077F Arabic Supplement +- U+FB50–U+FDFF Presentation Forms-A +- U+FE70–U+FEFF Presentation Forms-B +- U+0020–U+007F Basic Latin (for mixed UI) + +## Dependencies +Place `NotoNaskhArabic-Regular.otf` in `tools/fonts/` before building the pack. diff --git a/packs/ar/fonts/ar-naskh/build.ini b/packs/ar/fonts/ar-naskh/build.ini new file mode 100644 index 00000000..152f68b4 --- /dev/null +++ b/packs/ar/fonts/ar-naskh/build.ini @@ -0,0 +1,7 @@ +font=tools/fonts/NotoNaskhArabic-Regular.otf +size=16 +bpp=2 +no_compress=true +; Arabic needs presentation forms for correct shaping without HarfBuzz. +; charset.txt should include both base Arabic block (0600-06FF) and +; Presentation Forms-A/B (FB50-FDFF, FE70-FEFF) for contextual glyphs. diff --git a/packs/ar/fonts/ar-naskh/charset.txt b/packs/ar/fonts/ar-naskh/charset.txt new file mode 100644 index 00000000..a6aba338 --- /dev/null +++ b/packs/ar/fonts/ar-naskh/charset.txt @@ -0,0 +1,18 @@ +؀؁؂؃؄؅؆؇؈؉؊؋،؍؎؏ؘؙؚؐؑؒؓؔؕؖؗ؛؜؝؞؟ؠءآأؤإئابةتثجحخدذرزسشصضطظعغػؼؽؾؿ +ـفقكلمنهوىيًٌٍَُِّْٕٖٜٟٓٔٗ٘ٙٚٛٝٞ٠١٢٣٤٥٦٧٨٩٪٫٬٭ٮٯٰٱٲٳٴٵٶٷٸٹٺٻټٽپٿ +ڀځڂڃڄڅچڇڈډڊڋڌڍڎڏڐڑڒړڔڕږڗژڙښڛڜڝڞڟڠڡڢڣڤڥڦڧڨکڪګڬڭڮگڰڱڲڳڴڵڶڷڸڹںڻڼڽھڿ +ۀہۂۃۄۅۆۇۈۉۊۋیۍێۏېۑےۓ۔ەۖۗۘۙۚۛۜ۝۞ۣ۟۠ۡۢۤۥۦۧۨ۩۪ۭ۫۬ۮۯ۰۱۲۳۴۵۶۷۸۹ۺۻۼ۽۾ۿ +ݐݑݒݓݔݕݖݗݘݙݚݛݜݝݞݟݠݡݢݣݤݥݦݧݨݩݪݫݬݭݮݯݰݱݲݳݴݵݶݷݸݹݺݻݼݽݾݿﭐﭑﭒﭓﭔﭕﭖﭗﭘﭙﭚﭛﭜﭝﭞﭟ +ﭠﭡﭢﭣﭤﭥﭦﭧﭨﭩﭪﭫﭬﭭﭮﭯﭰﭱﭲﭳﭴﭵﭶﭷﭸﭹﭺﭻﭼﭽﭾﭿﮀﮁﮂﮃﮄﮅﮆﮇﮈﮉﮊﮋﮌﮍﮎﮏﮐﮑﮒﮓﮔﮕﮖﮗﮘﮙﮚﮛﮜﮝﮞﮟ +ﮠﮡﮢﮣﮤﮥﮦﮧﮨﮩﮪﮫﮬﮭﮮﮯﮰﮱ﮲﮳﮴﮵﮶﮷﮸﮹﮺﮻﮼﮽﮾﮿﯀﯁﯂﯃﯄﯅﯆﯇﯈﯉﯊﯋﯌﯍﯎﯏﯐﯑﯒ﯓﯔﯕﯖﯗﯘﯙﯚﯛﯜﯝﯞﯟ +ﯠﯡﯢﯣﯤﯥﯦﯧﯨﯩﯪﯫﯬﯭﯮﯯﯰﯱﯲﯳﯴﯵﯶﯷﯸﯹﯺﯻﯼﯽﯾﯿﰀﰁﰂﰃﰄﰅﰆﰇﰈﰉﰊﰋﰌﰍﰎﰏﰐﰑﰒﰓﰔﰕﰖﰗﰘﰙﰚﰛﰜﰝﰞﰟ +ﰠﰡﰢﰣﰤﰥﰦﰧﰨﰩﰪﰫﰬﰭﰮﰯﰰﰱﰲﰳﰴﰵﰶﰷﰸﰹﰺﰻﰼﰽﰾﰿﱀﱁﱂﱃﱄﱅﱆﱇﱈﱉﱊﱋﱌﱍﱎﱏﱐﱑﱒﱓﱔﱕﱖﱗﱘﱙﱚﱛﱜﱝﱞﱟ +ﱠﱡﱢﱣﱤﱥﱦﱧﱨﱩﱪﱫﱬﱭﱮﱯﱰﱱﱲﱳﱴﱵﱶﱷﱸﱹﱺﱻﱼﱽﱾﱿﲀﲁﲂﲃﲄﲅﲆﲇﲈﲉﲊﲋﲌﲍﲎﲏﲐﲑﲒﲓﲔﲕﲖﲗﲘﲙﲚﲛﲜﲝﲞﲟ +ﲠﲡﲢﲣﲤﲥﲦﲧﲨﲩﲪﲫﲬﲭﲮﲯﲰﲱﲲﲳﲴﲵﲶﲷﲸﲹﲺﲻﲼﲽﲾﲿﳀﳁﳂﳃﳄﳅﳆﳇﳈﳉﳊﳋﳌﳍﳎﳏﳐﳑﳒﳓﳔﳕﳖﳗﳘﳙﳚﳛﳜﳝﳞﳟ +ﳠﳡﳢﳣﳤﳥﳦﳧﳨﳩﳪﳫﳬﳭﳮﳯﳰﳱﳲﳳﳴﳵﳶﳷﳸﳹﳺﳻﳼﳽﳾﳿﴀﴁﴂﴃﴄﴅﴆﴇﴈﴉﴊﴋﴌﴍﴎﴏﴐﴑﴒﴓﴔﴕﴖﴗﴘﴙﴚﴛﴜﴝﴞﴟ +ﴠﴡﴢﴣﴤﴥﴦﴧﴨﴩﴪﴫﴬﴭﴮﴯﴰﴱﴲﴳﴴﴵﴶﴷﴸﴹﴺﴻﴼﴽ﴾﴿﵀﵁﵂﵃﵄﵅﵆﵇﵈﵉﵊﵋﵌﵍﵎﵏ﵐﵑﵒﵓﵔﵕﵖﵗﵘﵙﵚﵛﵜﵝﵞﵟ +ﵠﵡﵢﵣﵤﵥﵦﵧﵨﵩﵪﵫﵬﵭﵮﵯﵰﵱﵲﵳﵴﵵﵶﵷﵸﵹﵺﵻﵼﵽﵾﵿﶀﶁﶂﶃﶄﶅﶆﶇﶈﶉﶊﶋﶌﶍﶎﶏ﶐﶑ﶒﶓﶔﶕﶖﶗﶘﶙﶚﶛﶜﶝﶞﶟ +ﶠﶡﶢﶣﶤﶥﶦﶧﶨﶩﶪﶫﶬﶭﶮﶯﶰﶱﶲﶳﶴﶵﶶﶷﶸﶹﶺﶻﶼﶽﶾﶿﷀﷁﷂﷃﷄﷅﷆﷇ﷈﷉﷊﷋﷌﷍﷎﷏﷐﷑﷒﷓﷔﷕﷖﷗﷘﷙﷚﷛﷜﷝﷞﷟ +﷠﷡﷢﷣﷤﷥﷦﷧﷨﷩﷪﷫﷬﷭﷮﷯ﷰﷱﷲﷳﷴﷵﷶﷷﷸﷹﷺﷻ﷼﷽﷾﷿ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏ +ﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏ +ﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ﻽﻾ diff --git a/packs/ar/fonts/ar-naskh/manifest.ini b/packs/ar/fonts/ar-naskh/manifest.ini new file mode 100644 index 00000000..8563f3b6 --- /dev/null +++ b/packs/ar/fonts/ar-naskh/manifest.ini @@ -0,0 +1,8 @@ +kind=font +id=ar-naskh +display_name=Arabic Naskh +usage=both +estimated_ram_bytes=18432 +source=binfont +file=font.bin +ranges=ranges.txt diff --git a/packs/ar/fonts/ar-naskh/ranges.txt b/packs/ar/fonts/ar-naskh/ranges.txt new file mode 100644 index 00000000..e8b1bf06 --- /dev/null +++ b/packs/ar/fonts/ar-naskh/ranges.txt @@ -0,0 +1,10 @@ +# Arabic (core letters, diacritics, digits, punctuation) +0600-06FF +# Arabic Supplement +0750-077F +# Arabic Presentation Forms-A (ligatures, contextual) +FB50-FDFF +# Arabic Presentation Forms-B (isolated/final/medial/initial) +FE70-FEFF +# Basic Latin (for mixed-script UI: numbers, punctuation, Latin text) +0020-007F diff --git a/packs/ar/locales/ar/manifest.ini b/packs/ar/locales/ar/manifest.ini new file mode 100644 index 00000000..b9f6ea5f --- /dev/null +++ b/packs/ar/locales/ar/manifest.ini @@ -0,0 +1,9 @@ +kind=locale +id=ar +display_name=Arabic +native_name=العربية +translation_status=review +ui_font_pack=ar-naskh +content_font_pack=ar-naskh +strings=strings.tsv +direction=rtl diff --git a/packs/ar/locales/ar/strings.tsv b/packs/ar/locales/ar/strings.tsv new file mode 100644 index 00000000..4bb59dc3 --- /dev/null +++ b/packs/ar/locales/ar/strings.tsv @@ -0,0 +1,618 @@ +Action إجراء +Add إضافة +Advanced متقدم +Always دائماً +Altitude الارتفاع +Altitude Reference مرجع الارتفاع +App تطبيق +Area Cleared منطقة ممسوحة +Auto تلقائي +Awaiting teammate بانتظار الزميل +Back رجوع +Base Camp معسكر القاعدة +Beijing (UTC+8) بكين (UTC+8) +BLE bridge ready | %s جسر BLE جاهز | %s +BLE linked | %s BLE متصل | %s +Bluetooth بلوتوث +Bluetooth Pairing اقتران بلوتوث +Bcast بث +Bridge error خطأ في الجسر +Broadcast بث +Cancel إلغاء +Channel قناة +Chat محادثة +Chat unavailable المحادثة غير متاحة +Chinese الصينية +Closes automatically after pairing يغلق تلقائياً بعد الاقتران +Close إغلاق +Command أمر +Command: %s أمر: %s +Command: %s %s أمر: %s %s +Command: %s %s %s أمر: %s %s %s +Compare Verification Code مقارنة رمز التحقق +Compass بوصلة +Connected متصل +Connected, handshaking... متصل، جار المصافحة... +Contacts جهات الاتصال +Contacts (%s) جهات الاتصال (%s) +Contacts (LXMF) جهات الاتصال (LXMF) +Contacts (LXMF - %.3fMHz) جهات الاتصال (LXMF - %.3fMHz) +Contacts (MeshCore) جهات الاتصال (MeshCore) +Contacts (Meshtastic - %.3fMHz) جهات الاتصال (Meshtastic - %.3fMHz) +Contacts (RNode) جهات الاتصال (RNode) +Contacts (RNode - %.3fMHz) جهات الاتصال (RNode - %.3fMHz) +Conversation protocol mismatch عدم تطابق بروتوكول المحادثة +Data Exchange تبادل البيانات +Debug Logs سجلات التصحيح +Decoding: %d%% فك التشفير: %d%% +Decoding: 0% فك التشفير: 0% +Delete حذف +Delete %s? حذف %s؟ +Delete contact %s? حذف جهة الاتصال %s؟ +Delete failed فشل الحذف +Device: %s الجهاز: %s +Direct مباشر +Disabled معطل +Discover اكتشاف +Display Language لغة العرض +Duplicate name not allowed الاسم المكرر غير مسموح +Edit تحرير +Edit nickname تحرير اللقب +Emergency SOS طوارئ SOS +Energy Sweep مسح الطاقة +English الإنجليزية +English keyboard لوحة مفاتيح إنجليزية +Enter nickname أدخل اللقب +Enter number for %s أدخل الرقم لـ %s +Enter the 6-digit number أدخل الرقم المكون من 6 خانات +Enter this 6-digit PIN on your phone أدخل رمز PIN المكون من 6 خانات على هاتفك +Error خطأ +Error: %lu خطأ: %lu +FSK Voice صوت FSK +Failed فشل +Fix تثبيت +Fixed PIN PIN ثابت +Gauge Design (mAh) السعة التصميمية (mAh) +Gauge Full (mAh) السعة الكاملة (mAh) +Good Find هدف مكتشف +GPS GPS +Extensions ملحقات +Language لغة +Package Details تفاصيل الحزمة +Back to list عودة للقائمة +Install تثبيت +Update تحديث +Uninstall إلغاء التثبيت +Incompatible غير متوافق +Compatible with this device متوافق مع هذا الجهاز +Compatibility: %s التوافق: %s +memory profile mismatch عدم تطابق ملف الذاكرة +needs %s يحتاج %s +Update available: %s تحديث متاح: %s +Installed مثبت +Installed: %s مثبت: %s +Available to install متاح للتثبيت +Blocked محظور +%lu locales %lu fonts %lu IME %s %lu لغة %lu خط %lu إدخال %s +No Wi-Fi لا يوجد Wi-Fi +Installing package... جار تثبيت الحزمة... +Package installed تم تثبيت الحزمة +Uninstalling package... جار إلغاء تثبيت الحزمة... +Package uninstalled تم إلغاء تثبيت الحزمة +No language packs available لا توجد حزم لغات متاحة +Available language packs: %lu حزم اللغات المتاحة: %lu +About حول +Package Info معلومات الحزمة +Contents المحتويات +Source المصدر +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s الأرشيف: %s\nذاكرة الخط الإضافية: %s\nالملفات: %s\n%s +Locales: %s\nFonts: %s\nIME: %s اللغات: %s\nالخطوط: %s\nالإدخال: %s +Author: %s\nHomepage: %s المؤلف: %s\nالموقع: %s +None لا شيء +GPS Status حالة GPS +HDOP HDOP +Heading starting بدء التوجيه +Hide إخفاء +Host bridge جسر المضيف +Host connected, probing modem... المضيف متصل، جار فحص المودم... +IDLE خامل +Ignored متجاهل +Ignore تجاهل +Ignore unavailable التجاهل غير متاح +Ignore update failed فشل تحديث التجاهل +Image received تم استلام الصورة +Info معلومات +Initializing... جار التهيئة... +Invalid airtime factor معامل وقت البث غير صالح +Invalid design capacity (mAh) السعة التصميمية (mAh) غير صالحة +Invalid frequency offset إزاحة التردد غير صالحة +Invalid frequency value قيمة التردد غير صالحة +Invalid full capacity (mAh) السعة الكاملة (mAh) غير صالحة +Invalid marker علامة غير صالحة +Invalid MeshCore bandwidth عرض نطاق MeshCore غير صالح +Invalid MeshCore frequency تردد MeshCore غير صالح +Invalid number رقم غير صالح +Invalid RX delay تأخير الاستقبال غير صالح +Key marked trusted تم وضع علامة ثقة على المفتاح +Key must be 32 hex or 16 chars يجب أن يكون المفتاح 32 خانة ست عشرية أو 16 حرفاً +Key trust failed فشلت الثقة بالمفتاح +Key Verification التحقق من المفتاح +LINKED متصل +LISTEN استماع +LIVE مباشر +Loading... جار التحميل... +Location الموقع +Location: %s الموقع: %s +Location: %s %s الموقع: %s %s +LoRa direct path | %s مسار LoRa مباشر | %s +Low battery - audio disabled بطارية منخفضة - الصوت معطل +Map خريطة +Map Layer طبقة الخريطة +MESH شبكي +Mesh Status حالة الشبكة +Mesh unavailable الشبكة غير متاحة +MeshCore only MeshCore فقط +Message DB cleared تم مسح قاعدة بيانات الرسائل +Messages رسائل +MESSAGES رسائل +Messages and broadcasts show here الرسائل والبث يظهر هنا +Mini Compass بوصلة مصغرة +MT send uses slot 0/1 only إرسال Meshtastic يستخدم الفتحة 0/1 فقط +N/A غير متاح +Nearby قريب +Need local GPS يلزم GPS محلي +Network شبكة +New جديد +new activity نشاط جديد +Next التالي +No GPS fix لا يوجد تثبيت GPS +No image لا توجد صورة +No messages لا توجد رسائل +No preview لا توجد معاينة +No recent traffic لا توجد حركة حديثة +No response لا استجابة +No route selected لم يتم تحديد مسار +No target لا يوجد هدف +No track files لا توجد ملفات مسار +Node DB reset تم إعادة ضبط قاعدة بيانات العقد +Node ignored تم تجاهل العقدة +Node ignored and hidden تم تجاهل العقدة وإخفاؤها +Node unignored تم إلغاء تجاهل العقدة +North locked الشمال مثبت +Not set غير محدد +NO لا +NO FIX لا تثبيت +Number keyboard لوحة مفاتيح رقمية +OFF إيقاف +OK موافق +ON تشغيل +online متصل +Pairing اقتران +PC Link رابط الحاسوب +PC Link is unavailable on this target. رابط الحاسوب غير متاح على هذا الهدف. +peer lock | turn %03.0f deg قفل النظير | انعطف %03.0f درجة +Pinyin keyboard لوحة مفاتيح بينيين +Pinyin: %s بينيين: %s +Position الموقع +position stream active | %u in view دفق الموقع نشط | %u مرئي +Power طاقة +Press Back اضغط رجوع +Press RX to start اضغط RX للبدء +Primary أساسي +Protocol بروتوكول +Protocol switch failed فشل تبديل البروتوكول +Protocol switched تم تبديل البروتوكول +quiet net شبكة هادئة +Random PIN PIN عشوائي +Ready جاهز +RECEIVING استلام +Recent Activity نشاط حديث +Record تسجيل +Recording جار التسجيل +Region منطقة +Remain: %u متبقي: %u +Reply رد +Reply disabled for this protocol الرد معطل لهذا البروتوكول +Reset Mesh Profiles إعادة ضبط ملفات الشبكة +Reset Node DB إعادة ضبط قاعدة بيانات العقد +Resetting... جار إعادة الضبط... +Return عودة +Rally Point نقطة التجمع +RNode Bridge جسر RNode +RNode modem ready مودم RNode جاهز +RNode text chat runs on host دردشة RNode النصية تعمل على المضيف +Route مسار +Run تشغيل +RX RX +RX: %lu TX: %lu استلام: %lu إرسال: %lu +RX: 0 TX: 0 استلام: 0 إرسال: 0 +Save حفظ +Saved: %s تم الحفظ: %s +Scan failed فشل المسح +Scan Local مسح محلي +Scanning 5s... المسح 5 ثوان... +Screen Brightness سطوع الشاشة +Screen Timeout مهلة الشاشة +SEARCH بحث +Secondary ثانوي +Select a route اختر مساراً +Select a track اختر مساراً +Select marker اختر علامة +Select Track اختيار المسار +Selected: %s المحدد: %s +Send إرسال +Send failed فشل الإرسال +Send this code and compare:\n أرسل هذا الرمز وقارن:\n +Send ID Broadcast إرسال بث الهوية +Send ID Local إرسال هوية محلي +Sending... جار الإرسال... +Sent تم الإرسال +Settings الإعدادات +Setting إعداد +Share Position Marker مشاركة علامة الموقع +Share this number:\n شارك هذا الرقم:\n +Short Name الاسم المختصر +SOLO منفرد +Speaker Volume مستوى صوت السماعة +Sky Plot مخطط السماء +SSTV RX READY SSTV استلام جاهز +SSTV start failed فشل بدء SSTV +standing by في الانتظار +Status الحالة +Stopped متوقف +Stop إيقاف +Stop recording first أوقف التسجيل أولاً +Submit إرسال +Submit failed فشل الإرسال +System نظام +SATELLITE STATUS حالة الأقمار +Talk تحدث +TALK تحدث +Team فريق +TEAM فريق +Team chat send failed فشل إرسال رسالة الفريق +Team chat unavailable دردشة الفريق غير متاحة +Team chat unavailable in RNode mode دردشة الفريق غير متاحة في وضع RNode +team member position will appear here موقع عضو الفريق سيظهر هنا +Team keys not ready مفاتيح الفريق غير جاهزة +Team location encode failed فشل ترميز موقع الفريق +Text chat unavailable الدردشة النصية غير متاحة +threads active جلسات نشطة +Time Zone منطقة زمنية +Tracker متتبع +traffic active حركة نشطة +Trust Key توثيق المفتاح +U استخدم +Unignore إلغاء التجاهل +Unread غير مقروء +Unknown غير معروف +USB Mass Storage تخزين USB كبير +USB Disk قرص USB +USB Bridge جسر USB +USB host bridge جسر مضيف USB +USB Bridge is unavailable on this target. جسر USB غير متاح على هذا الهدف. +USB CDC KISS modem for Reticulum مودم USB CDC KISS لـ Reticulum +USE استخدم +Used مستخدم +User Name اسم المستخدم +Verification Number رقم التحقق +Verification number sent تم إرسال رقم التحقق +Vibration اهتزاز +VOL %d الصوت %d +VOL 80 الصوت 80 +waiting for better sky view | %u in view بانتظار رؤية أفضل للسماء | %u مرئي +waiting for module GNSS compass بانتظار وحدة GNSS/البوصلة +waiting for traffic بانتظار الحركة +Waiting for host... بانتظار المضيف... +Waiting for Reticulum host... بانتظار مضيف Reticulum... +WAIT IMU بانتظار IMU +Walkie not available جهاز الاتصال غير متاح +Walkie start failed فشل بدء جهاز الاتصال +Walkie Talkie جهاز اتصال +YES نعم +6 digits 6 خانات +%u threads active %u جلسة نشطة +%ud %u يوم +%uh %u ساعة +%um %u دقيقة +%umo %u شهر +%uy %u سنة +Confirm received code:\n تأكيد الرمز المستلم:\n +device heading from 9-axis module اتجاه الجهاز من وحدة 9 محاور +heading unlocks after first fix يفتح الاتجاه بعد أول تثبيت +If it matches, trust the key. إذا تطابق، وثق المفتاح. +magnetometer heading active اتجاه المغنطومتر نشط +magnetometer still warming up المغنطومتر لا يزال في مرحلة الإحماء +needle shows north until target lock الإبرة تشير للشمال حتى قفل الهدف +nearest peer | %.0f m away أقرب نظير | على بعد %.0f متر +no recent traffic لا توجد حركة حديثة +now الآن +%s layer missing طبقة %s مفقودة +Active: %s نشط: %s +Alt: %.0f m الارتفاع: %.0f م +Alt: -- m الارتفاع: -- م +Base: %s الأساس: %s +Battery critical البطارية حرجة +Broadcast (%d) بث (%d) +COMPLETE مكتمل +Calibrating... جار المعايرة... +Confirm تأكيد +Contour: OFF الخطوط الكنتورية: إيقاف +Decoder error خطأ في فك التشفير +Direct (%d) مباشر (%d) +E ش +ERROR خطأ +Exiting USB... جار الخروج من USB... +Hold انتظار +ID: !%08lX الهوية: !%08lX +ID: - الهوية: - +Key refresh not implemented تحديث المفتاح غير منفذ +Key verify: %s %s تحقق المفتاح: %s %s +Key verify: confirm %s %s تحقق المفتاح: تأكيد %s %s +Key verify: enter number for %s تحقق المفتاح: أدخل الرقم لـ %s +Key verify: send %s %s تحقق المفتاح: أرسل %s %s +Later لاحقاً +Level %d المستوى %d +Listening for SSTV signal... جار الاستماع لإشارة SSTV... +Low battery: %d%% بطارية منخفضة: %d%% +MODE: %s الوضع: %s +MODE: Auto الوضع: تلقائي +Map - %.48s خريطة - %.48s +Map - %s Missing خريطة - %s مفقودة +Map - No Map Data خريطة - لا توجد بيانات +Map - No SD Card خريطة - لا توجد بطاقة SD +Map - no gps data خريطة - لا توجد بيانات GPS +Message رسالة +Mic TBD الميكروفون قيد التعريف +Mic init failed فشل تهيئة الميكروفون +Morse Input إدخال مورس +MoveTo انتقل إلى +N ش +NODE INFO معلومات العقدة +Name required الاسم مطلوب +Name too long الاسم طويل جداً +No KML routes لا توجد مسارات KML +No SD Card لا توجد بطاقة SD +No routes folder لا يوجد مجلد مسارات +No tracks yet لا توجد مسارات بعد +Only leader can manage القائد فقط يمكنه الإدارة +Only leader can pair القائد فقط يمكنه الاقتران +Paired successfully تم الاقتران بنجاح +Pairing failed فشل الاقتران +Pairing init failed فشل بدء الاقتران +Pairing not available الاقتران غير متاح +Pairing not ready الاقتران غير جاهز +Press Back to exit USB mode اضغط رجوع للخروج من وضع USB +Press SPACE to resume اضغط SPACE للاستئناف +RallyTo تجمع إلى +Route configured تم تكوين المسار +Route: %s المسار: %s +SD Card Not Found\nPlease insert SD card لم يتم العثور على بطاقة SD\nالرجاء إدخال بطاقة SD +SSTV RECEIVER مستقبل SSTV +Save failed فشل الحفظ +Scan +%u/%u مسح +%u/%u +Select level اختر المستوى +Selected level: %d المستوى المحدد: %d +Shutting down to protect battery جار إيقاف التشغيل لحماية البطارية +Status: CALIB الحالة: معايرة +Step 1/2: Tap short dots الخطوة 1/2: انقر النقاط القصيرة +Step 2/2: Tap long dashes الخطوة 2/2: انقر الخطوط الطويلة +Switch to %s to chat انتقل إلى %s للدردشة +Symbol: الرمز: +Tap to input, idle 3s to send انقر للإدخال، انتظر 3 ثوان للإرسال +Team keys mismatch عدم تطابق مفاتيح الفريق +Team: الفريق: +Text: النص: +Unread: %d غير مقروء: %d +Unread: 0 غير مقروء: 0 +W غ +0° Horizon 0° أفق +PSK must be 32 hex or 16 chars يجب أن يكون PSK 32 خانة ست عشرية أو 16 حرفاً +Restarting... جار إعادة التشغيل... +awaiting position بانتظار الموقع +compass wakes after GPS fix البوصلة تستيقظ بعد تثبيت GPS +searching satellites جار البحث عن الأقمار +%s - %s - +%s 0s %s 0 ثانية +%s %us %s %u ثانية +%s %um %s %u دقيقة +%s %uh %s %u ساعة +%s %ud %s %u يوم +AUTO تلقائي +Alt: %ld m الارتفاع: %ld م +Alt: - الارتفاع: - +BEST الأفضل +BW: %.0fk عرض النطاق: %.0fk +BW: %.1fk عرض النطاق: %.1fk +BW: - عرض النطاق: - +CAD CAD +CURSOR مؤشر +Ch: %.3f القناة: %.3f +Ch: - القناة: - +Hop: %u قفزة: %u +Hop: - قفزة: - +Hop: - / NH: %02X قفزة: - / القادم: %02X +ID: !%06lx هوية: !%06lx +ID: !%08lx هوية: !%08lx +Last heard: آخر سماع: +Link رابط +Link / MeshCore رابط / MeshCore +Link / Meshtastic رابط / Meshtastic +MODE: RSSI الوضع: RSSI +NOISE %.0f dBm ضوضاء %.0f dBm +NOISE -104 dBm ضوضاء -104 dBm +PKI: - PKI: - +PKI: known PKI: معروف +PKI: verified PKI: موثق +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI: %.0f dBm +RSSI: - RSSI: - +Role: %s الدور: %s +SCAN مسح +SF: %u SF: %u +SF: - SF: - +SIM محاكاة +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1f ديسيبل +SNR: - SNR: - +SNR %.0f SNR %.0f +SNR - SNR - +SNR Good SNR جيد +SNR Weak SNR ضعيف +STEP %.0fk | BW %.0fk خطوة %.0fk | عرض %.0fk +STEP %.1fk | BW %.1fk خطوة %.1fk | عرض %.1fk +STEP -- | BW -- خطوة -- | عرض -- +STOP إيقاف +Slot فتحة +Slot %u فتحة %u +SUB-GHz SCAN مسح Sub-GHz +Actions إجراءات +Actions: %s إجراءات: %s +Access to team data revoked تم إلغاء الوصول إلى بيانات الفريق +Capability: القدرة: +Channel Actions إجراءات القناة +Confirm Kick تأكيد الطرد +Create Team إنشاء فريق +ELEV ارتفاع +Join Team انضمام لفريق +In View مرئي +Keep devices within 5m أبق الأجهزة ضمن مسافة 5 أمتار +KeyDist توزيع المفاتيح +KeyId: %u معرف المفتاح: %u +KeyId: -- معرف المفتاح: -- +Keys مفاتيح +Kick طرد +Kick Member طرد عضو +Last paired: %s آخر اقتران: %s +Last seen %uh ago آخر ظهور قبل %u ساعة +Last seen %um ago آخر ظهور قبل %u دقيقة +Last seen -- آخر ظهور: -- +Last update %us ago آخر تحديث قبل %u ثانية +Last update -- آخر تحديث: -- +Last update 0s ago آخر تحديث قبل 0 ثانية +Leader قائد +Leader online القائد متصل +Leave مغادرة +Leave team? مغادرة الفريق؟ +Manage إدارة +Member: %s عضو: %s +Members أعضاء +Members: %u الأعضاء: %u +Members: %u Online: %u الأعضاء: %u متصل: %u +Modem Bridge جسر المودم +No members yet لا أعضاء بعد +Not Used غير مستخدم +Offline غير متصل +Online متصل +Online: %u متصل: %u +Pair Again اقتران مرة أخرى +Pair Member اقتران عضو +Pairing in progress الاقتران قيد التقدم +Prev السابق +Remove %s from team? إزالة %s من الفريق؟ +Request Keys طلب مفاتيح +Retry إعادة المحاولة +Security Round: %u جولة الأمان: %u +Security Round: -- جولة الأمان: -- +Security: OK (Round %u) الأمان: جيد (جولة %u) +Security: OK (Round --) الأمان: جيد (جولة --) +Seen %ud ظهور قبل %u يوم +Seen %uh ظهور قبل %u ساعة +Seen %um ظهور قبل %u دقيقة +Send failed (keys not ready) فشل الإرسال (المفاتيح غير جاهزة) +Status: %s الحالة: %s +Target: %s الهدف: %s +Team / %s فريق / %s +Team Actions إجراءات الفريق +Team Health صحة الفريق +Team Status حالة الفريق +Team: %s الفريق: %s +This clears local keys. هذا يمسح المفاتيح المحلية. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. هذا سيحدث جولة الأمان.\nلن يتلقى العضو بعد ذلك\nرسائل الفريق أو نقاط الطريق. +Transfer نقل +Transfer Leader نقل القيادة +View Team عرض الفريق +Waiting for new keys... بانتظار مفاتيح جديدة... +Waypoint نقطة طريق +You are no longer in this team لم تعد في هذا الفريق +You are not in a team أنت لست في فريق +- No shared map\n- No team awareness - لا خريطة مشتركة\n- لا وعي بالفريق +1 member stale عضو واحد منتهي الصلاحية +encrypt failed فشل التشفير +member عضو +queue full الطابور ممتلئ +unsupported protocol بروتوكول غير مدعوم +Factory Reset إعادة ضبط المصنع +Clear all settings and restart? مسح جميع الإعدادات وإعادة التشغيل؟ +Terrain تضاريس +Satellite قمر صناعي +Contour: ON الخطوط الكنتورية: تشغيل +Contour data missing بيانات الخطوط الكنتورية مفقودة +%d enabled %d تم تمكينه +%s %s %s %s +Channel Key / PSK مفتاح القناة / PSK +Channel Slot فتحة القناة +Channel Utilization استخدام القناة +Check for Updates التحقق من وجود تحديثات +Clear Message DB مسح قاعدة بيانات الرسالة +Connect الاتصال +Contour Overlay تراكب محيطي +Coordinate Format تنسيق الإحداثيات +Coordinate System نظام الإحداثيات +Current Version الإصدار الحالي +Detected Network الشبكة التي تم اكتشافها +Disconnect قطع الاتصال +Duty Cycle Limit حد دورة العمل +Enabled IMEs تم التمكين IMEs +Encryption Mode وضع التشفير +Freq Offset (MHz) إزاحة التكرار (MHz) +GPS Enabled GPS ممكّن +Hop Limit حد القفز +IME setting update failed IME فشل تحديث الإعداد +Install Update قم بتثبيت التحديث +Latest Version أحدث إصدار +Layer الطبقة +Location Mode وضع الموقع +MC Airtime Factor عامل وقت البث MC +MC Bandwidth (kHz) عرض النطاق الترددي للMC (kHz) +MC Channel Key مفتاح قناة MC +MC Channel Name اسم قناة MC +MC Channel Slot فتحة قناة MC +MC Coding Rate معدل تشفير MC +MC Flood Max MC فيضان ماكس +MC Frequency (MHz) تردد MC (MHz) +MC Multi ACKs MC متعدد الإقرارات +MC RX Delay Base MC RX قاعدة التأخير +MC Region Preset منطقة MC محددة مسبقًا +MC Repeat كرر MC +MC Spread Factor عامل انتشار MC +MC TX Power MC TX الطاقة +Manual BW دليل BW +Manual CR دليل CR +Manual SF دليل SF +Map Source مصدر الخريطة +Modem Preset الإعداد المسبق للمودم +NMEA Export NMEA تصدير +NMEA Sentences NMEA الجمل +No IME packs installed لم يتم تثبيت حزم IME +No position available لا يوجد منصب متاح +OTA Status OTA الحالة +Override Duty Cycle تجاوز دورة العمل +Override Freq (MHz) تجاوز التكرار (MHz) +Password كلمة المرور +Position Strategy إستراتيجية الموقف +SSID SSID +Satellite Systems نظام القمر الصناعيms +Scan Networks مسح الشبكات +TX Enabled TX ممكّن +TX Power TX الطاقة +Track Format تنسيق المسار +Track Interval الفاصل الزمني للمسار +Track Recording تسجيل المسار +Unavailable غير متاح +Update Interval الفاصل الزمني للتحديث +Use Preset استخدم الإعداد المسبق +Wi-Fi Enabled Wi-Fi ممكّن +Wi-Fi start failed Wi-Fi فشل البدء diff --git a/packs/ar/package.ini b/packs/ar/package.ini new file mode 100644 index 00000000..e6f017ac --- /dev/null +++ b/packs/ar/package.ini @@ -0,0 +1,13 @@ +kind=package +id=ar +package_type=locale-bundle +version=1.1.0 +display_name=Arabic +summary=Arabic review locale bundle with Naskh font coverage for RTL UI text and mixed-script content rendering; display-only until Arabic input is implemented. +description=DESCRIPTION.txt +readme=README.md +author=Trail Mate +homepage=https://vicliu624.github.io/trail-mate/ +min_firmware_version=0.1.24-alpha +supported_memory_profiles=standard,extended +tags=language,arabic,rtl,review diff --git a/packs/europe-cyrillic-ext/DESCRIPTION.txt b/packs/europe-cyrillic-ext/DESCRIPTION.txt new file mode 100644 index 00000000..4113d609 --- /dev/null +++ b/packs/europe-cyrillic-ext/DESCRIPTION.txt @@ -0,0 +1,9 @@ +European Cyrillic Extended installs the Russian locale together with a shared +Cyrillic external font pack so interface labels and Russian content can render +without inflating the firmware image. + +This bundle is display-only today. It does not add a Russian IME, and the locale +remains in review status until native-language review is complete. + +Credits: Russian translations were contributed by polarikus, based on the +polarikus/trail-mate Russian localization PR. diff --git a/packs/europe-cyrillic-ext/README.md b/packs/europe-cyrillic-ext/README.md new file mode 100644 index 00000000..57c1d2de --- /dev/null +++ b/packs/europe-cyrillic-ext/README.md @@ -0,0 +1,95 @@ +# Europe Cyrillic-Extended Pack Bundle + +This bundle keeps Russian support external to the firmware image. + +It contains: + +- locale pack: `ru` +- font pack: `cyrillic-eu` + +The Russian translations were contributed by polarikus. + +## Credits + +Russian translations were contributed by polarikus, based on the +polarikus/trail-mate Russian localization PR: + +https://github.com/polarikus/trail-mate/pull/1 + +The locale manifest points to: + +- `ui_font_pack=cyrillic-eu` +- `content_font_pack=cyrillic-eu` + +Bundle-level package metadata lives in: + +- `package.ini` +- `DESCRIPTION.txt` +- `README.md` + +## Bundle Layout + +- `package.ini` +- `DESCRIPTION.txt` +- `README.md` +- `fonts/cyrillic-eu/` + - `manifest.ini` + - `build.ini` + - `charset.txt` + - `ranges.txt` + - `font.bin` after generation +- `locales/ru/` + - `manifest.ini` + - `strings.tsv` + +## Generate The Font Pack + +1. Refresh the subset files: + +```bash +python tools/build_locale_pack_charset.py --pack-root packs/europe-cyrillic-ext --font-pack-id cyrillic-eu +``` + +2. Generate `font.bin`: + +```powershell +python tools/generate_binfont_with_lv_font_conv.py ` + --font tools/fonts/NotoSansCJKsc-Regular.otf ` + --charset-file packs/europe-cyrillic-ext/fonts/cyrillic-eu/charset.txt ` + --output packs/europe-cyrillic-ext/fonts/cyrillic-eu/font.bin ` + --size 16 ` + --bpp 2 ` + --node-exe C:\Users\VicLi\AppData\Local\nodejs22\current\node.exe ` + --no-compress +``` + +Notes: + +- This bundle is display-only for now. It does not declare an IME pack yet. +- `build.ini` is the source-of-truth for generating `font.bin` during Pages/package builds. +- `font.bin` is ignored by Git. Regenerate it whenever the subset changes. +- `ranges.txt` and `estimated_ram_bytes` let the runtime decide whether the pack fits the active memory profile. +- The locale string table is structurally complete, but the locale remains in `translation_status=review` until native-language review is complete. + +## Distribution Package + +`python scripts/build_pack_repository.py --pack-root packs --site-root site` produces: + +- `site/assets/packs/europe-cyrillic-ext-1.1.0.zip` +- `site/data/packs.json` + +The zip is the bundle artifact for a future Extensions downloader. Its `payload/` directory unpacks into `/trailmate/packs/...`. + +## Copy To SD Card + +Copy the bundle contents so the SD card ends up with: + +```text +/trailmate/packs/fonts/cyrillic-eu/manifest.ini +/trailmate/packs/fonts/cyrillic-eu/ranges.txt +/trailmate/packs/fonts/cyrillic-eu/font.bin +/trailmate/packs/locales/ru/manifest.ini +/trailmate/packs/locales/ru/strings.tsv +``` + +After reboot, `Русский` appears in Settings only after the locale is promoted to `translation_status=release`. While it is in `review`, the runtime can install and index the pack but will not offer it as a selectable UI language. diff --git a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/.gitignore b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/.gitignore new file mode 100644 index 00000000..2224454d --- /dev/null +++ b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/.gitignore @@ -0,0 +1 @@ +font.bin diff --git a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/build.ini b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/build.ini new file mode 100644 index 00000000..d0889975 --- /dev/null +++ b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/build.ini @@ -0,0 +1,4 @@ +font=tools/fonts/NotoSansCJKsc-Regular.otf +size=16 +bpp=2 +no_compress=true diff --git a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/charset.txt b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/charset.txt new file mode 100644 index 00000000..351311f7 --- /dev/null +++ b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/charset.txt @@ -0,0 +1 @@ +ЁАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяё diff --git a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/manifest.ini b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/manifest.ini new file mode 100644 index 00000000..1acd8b13 --- /dev/null +++ b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/manifest.ini @@ -0,0 +1,8 @@ +kind=font +id=cyrillic-eu +display_name=Cyrillic EU +usage=both +estimated_ram_bytes=12000 +source=binfont +file=font.bin +ranges=ranges.txt diff --git a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/ranges.txt b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/ranges.txt new file mode 100644 index 00000000..d39d06ed --- /dev/null +++ b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/ranges.txt @@ -0,0 +1 @@ +0x401,0x410-0x44F,0x451 diff --git a/packs/europe-cyrillic-ext/locales/ru/manifest.ini b/packs/europe-cyrillic-ext/locales/ru/manifest.ini new file mode 100644 index 00000000..65eb9a8c --- /dev/null +++ b/packs/europe-cyrillic-ext/locales/ru/manifest.ini @@ -0,0 +1,8 @@ +kind=locale +id=ru +display_name=Russian +native_name=Русский +translation_status=review +ui_font_pack=cyrillic-eu +content_font_pack=cyrillic-eu +strings=strings.tsv diff --git a/packs/europe-cyrillic-ext/locales/ru/strings.tsv b/packs/europe-cyrillic-ext/locales/ru/strings.tsv new file mode 100644 index 00000000..ae2a84f7 --- /dev/null +++ b/packs/europe-cyrillic-ext/locales/ru/strings.tsv @@ -0,0 +1,618 @@ +Action Действие +Add Добавить +Advanced Расширенные +Always Всегда +Altitude Высота +Altitude Reference Ориентир высоты +App Приложение +Area Cleared Зона очищена +Auto Авто +Awaiting teammate Ожидание напарника +Back Назад +Base Camp Базовый лагерь +Beijing (UTC+8) Пекин (UTC+8) +BLE bridge ready | %s BLE мост готов | %s +BLE linked | %s BLE подключён | %s +Bluetooth Bluetooth +Bluetooth Pairing Сопряжение Bluetooth +Bcast Широковещ. +Bridge error Ошибка моста +Broadcast Широковещание +Cancel Отмена +Channel Канал +Chat Чат +Chat unavailable Чат недоступен +Chinese Китайский +Closes automatically after pairing Закрывается после сопряжения +Close Закрыть +Command Команда +Command: %s Команда: %s +Command: %s %s Команда: %s %s +Command: %s %s %s Команда: %s %s %s +Compare Verification Code Сравнить код +Compass Компас +Connected Подключено +Connected, handshaking... Подключено, рукопожатие... +Contacts Контакты +Contacts (%s) Контакты (%s) +Contacts (LXMF) Контакты (LXMF) +Contacts (LXMF - %.3fMHz) Контакты (LXMF - %.3fМГц) +Contacts (MeshCore) Контакты (MeshCore) +Contacts (Meshtastic - %.3fMHz) Контакты (Meshtastic - %.3fМГц) +Contacts (RNode) Контакты (RNode) +Contacts (RNode - %.3fMHz) Контакты (RNode - %.3fМГц) +Conversation protocol mismatch Несовпадение протокола +Data Exchange Обмен данными +Debug Logs Журналы отладки +Decoding: %d%% Декодирование: %d%% +Decoding: 0% Декодирование: 0% +Delete Удалить +Delete %s? Удалить %s? +Delete contact %s? Удалить контакт %s? +Delete failed Ошибка удаления +Device: %s Устройство: %s +Direct Напрямую +Disabled Отключено +Discover Обнаружить +Display Language Язык экрана +Duplicate name not allowed Повтор имени запрещён +Edit Изменить +Edit nickname Изменить псевдоним +Emergency SOS Экстренный SOS +Energy Sweep Сканирование +English Английский +English keyboard Англ. клавиатура +Enter nickname Ввести псевдоним +Enter number for %s Ввести номер для %s +Enter the 6-digit number Введите 6-значный номер +Enter this 6-digit PIN on your phone Введите 6-значный PIN на телефоне +Error Ошибка +Error: %lu Ошибка: %lu +FSK Voice FSK голос +Failed Не удалось +Fix Фикс. +Fixed PIN Фикс. PIN +Gauge Design (mAh) Ёмкость проект. (мАч) +Gauge Full (mAh) Полная ёмкость (мАч) +Good Find Хорошая находка +GPS GPS +Extensions Расширения +Language Язык +Package Details Детали пакета +Back to list Назад к списку +Install Установить +Update Обновить +Uninstall Удалить +Incompatible Несовместимо +Compatible with this device Совместимо с устройством +Compatibility: %s Совместимость: %s +memory profile mismatch Несовпадение профиля памяти +needs %s требуется %s +Update available: %s Доступно обновление: %s +Installed Установлено +Installed: %s Установлено: %s +Available to install Доступно для установки +Blocked Заблокировано +%lu locales %lu fonts %lu IME %s %lu языков %lu шрифтов %lu IME %s +No Wi-Fi Нет Wi-Fi +Installing package... Установка пакета... +Package installed Пакет установлен +Uninstalling package... Удаление пакета... +Package uninstalled Пакет удалён +No language packs available Нет языковых пакетов +Available language packs: %lu Доступно языковых пакетов: %lu +About О программе +Package Info Инфо пакета +Contents Содержимое +Source Источник +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Архив: %s\nДополнительный шрифт RAM: %s\nПрофили: %s\n%s +Locales: %s\nFonts: %s\nIME: %s Локали: %s\nШрифты: %s\nIME: %s +Author: %s\nHomepage: %s Автор: %s\nДомашняя страница: %s +None Нет +GPS Status Состояние GPS +HDOP HDOP +Heading starting Начало заголовка +Hide Скрыть +Host bridge Хост-мост +Host connected, probing modem... Хост подключен, проверка модема... +IDLE IDLE +Ignored Игнорируется +Ignore Игнорировать +Ignore unavailable Игнорировать недоступно +Ignore update failed Игнорировать обновление не удалось +Image received Изображение получено +Info Информация +Initializing... Инициализация... +Invalid airtime factor Неверный коэффициент эфирного времени +Invalid design capacity (mAh) Неверная расчетная емкость (мАч) +Invalid frequency offset Неверное смещение частоты +Invalid frequency value Неверное значение частоты. +Invalid full capacity (mAh) Неверная полная емкость (мАч) +Invalid marker Неверный маркер +Invalid MeshCore bandwidth Неверная пропускная способность MeshCore +Invalid MeshCore frequency Неверная частота MeshCore +Invalid number Неверный номер +Invalid RX delay Неверная задержка RX +Key marked trusted Ключ помечен как доверенный +Key must be 32 hex or 16 chars Ключ должен иметь длину 32 шестнадцатеричных или 16 символов. +Key trust failed Не удалось установить доверие к ключу +Key Verification Проверка ключа +LINKED СВЯЗАН +LISTEN СЛУШАТЬ +LIVE ПРЯМОЙ ЭФИР +Loading... Загрузка... +Location Местоположение +Location: %s Местоположение: %s +Location: %s %s Местоположение: %s %s +LoRa direct path | %s Прямой путь LoRa | %s +Low battery - audio disabled Низкий заряд батареи – звук отключен +Map Карта +Map Layer Слой карты +MESH СЕТКА +Mesh Status Статус сетки +Mesh unavailable Сетка недоступна +MeshCore only только MeshCore +Message DB cleared База данных сообщений очищена +Messages Сообщения +MESSAGES СООБЩЕНИЯ +Messages and broadcasts show here Здесь отображаются сообщения и трансляции +Mini Compass Мини-компас +MT send uses slot 0/1 only Отправка MT использует только слот 0/1 +N/A Н/Д +Nearby Рядом +Need local GPS Нужен локальный GPS +Network Сеть +New Новый +new activity новая деятельность +Next Далее +No GPS fix Нет исправления GPS +No image Нет изображения +No messages Нет сообщений +No preview Нет предварительного просмотра +No recent traffic Нет недавнего трафика +No response Нет ответа +No route selected Маршрут не выбран +No target Нет цели +No track files Нет файлов треков +Node DB reset Сброс базы данных узла +Node ignored Узел игнорируется +Node ignored and hidden Узел игнорируется и скрыт +Node unignored Узел не игнорируется +North locked Север заблокирован +Not set Не установлено +NO НЕТ +NO FIX НЕТ ИСПРАВЛЕНИЯ +Number keyboard Цифровая клавиатура +OFF ВЫКЛ. +OK ОК +ON ВКЛ. +online онлайн +Pairing Сопряжение +PC Link Ссылка на ПК +PC Link is unavailable on this target. PC Link недоступна для этой цели. +peer lock | turn %03.0f deg одноранговая блокировка | повернуть на %03.0f град. +Pinyin keyboard Клавиатура пиньинь +Pinyin: %s Пиньинь: %s +Position Позиция +position stream active | %u in view поток позиций активен | %u на виду +Power Мощность +Press Back Нажмите Назад +Press RX to start Нажмите RX, чтобы начать +Primary Первичный +Protocol Протокол +Protocol switch failed Не удалось переключить протокол. +Protocol switched Протокол переключен +quiet net тихая сеть +Random PIN Случайный PIN-код +Ready Готово +RECEIVING ПОЛУЧЕНИЕ +Recent Activity Недавняя активность +Record Запись +Recording Запись +Region Регион +Remain: %u Осталось: %u +Reply Ответить +Reply disabled for this protocol Ответ отключен для этого протокола +Reset Mesh Profiles Сбросить профили сетки +Reset Node DB Сброс базы данных узла +Resetting... Сброс... +Return Возврат +Rally Point Точка сбора +RNode Bridge RNode Мост +RNode modem ready RNode модем готов +RNode text chat runs on host RNode текстовый чат запускается на хосте +Route Маршрут +Run Запуск +RX RX +RX: %lu TX: %lu RX: %lu TX: %lu +RX: 0 TX: 0 RX: 0 TX: 0 +Save Сохранить +Saved: %s Сохранено: %s +Scan failed Сканирование не удалось +Scan Local Сканировать локально +Scanning 5s... Сканирование 5 секунд... +Screen Brightness Яркость экрана +Screen Timeout Тайм-аут экрана +SEARCH ПОИСК +Secondary Вторичный +Select a route Выберите маршрут +Select a track Выберите трек +Select marker Выбрать маркер +Select Track Выбор трека +Selected: %s Выбрано: %s +Send Отправить +Send failed Не удалось отправить +Send this code and compare:\n Отправьте этот код и сравните:\n +Send ID Broadcast Отправить ID трансляцию +Send ID Local Отправить ID локальный +Sending... Отправка... +Sent Отправлено +Settings Настройки +Setting Настройка +Share Position Marker Маркер позиции акции +Share this number:\n Поделитесь этим номером:\n +Short Name Краткое имя +SOLO СОЛО +Speaker Volume Громкость динамика +Sky Plot Небесный сюжет +SSTV RX READY SSTV RX ГОТОВО +SSTV start failed Не удалось запустить SSTV +standing by готов +Status Статус +Stopped Остановлено +Stop Стоп +Stop recording first Сначала остановите запись +Submit Отправить +Submit failed Не удалось отправить +System Система +SATELLITE STATUS СТАТУС СПУТНИКА +Talk Обсуждение +TALK РАЗГОВОР +Team Команда +TEAM КОМАНДА +Team chat send failed Ошибка отправки в командный чат +Team chat unavailable Командный чат недоступен +Team chat unavailable in RNode mode Командный чат недоступен в режиме RNode +team member position will appear here здесь появится позиция члена команды +Team keys not ready Ключи команды не готовы +Team location encode failed Не удалось закодировать местоположение команды +Text chat unavailable Текстовый чат недоступен +threads active темы активны +Time Zone Часовой пояс +Tracker Трекер +traffic active трафик активен +Trust Key Ключ доверия +U У +Unignore Не игнорировать +Unread Непрочитано +Unknown Неизвестно +USB Mass Storage USB Накопитель большой емкости +USB Disk USB Диск +USB Bridge USB Мост +USB host bridge USB хост-мост +USB Bridge is unavailable on this target. USB Мост недоступен для этой цели. +USB CDC KISS modem for Reticulum USB Модем CDC KISS для Reticulum +USE ИСПОЛЬЗОВАНИЕ +Used Используется +User Name Имя пользователя +Verification Number Номер подтверждения +Verification number sent Номер подтверждения отправлен +Vibration Вибрация +VOL %d ТОМ %d +VOL 80 ТОМ 80 +waiting for better sky view | %u in view ждем лучшего вида на небо | %u на виду +waiting for module GNSS compass ожидание модуля GNSS компаса +waiting for traffic ожидание трафика +Waiting for host... Ожидание хоста... +Waiting for Reticulum host... Ожидание хоста Reticulum... +WAIT IMU ПОДОЖДИТЕ IMU +Walkie not available Рация недоступна +Walkie start failed Не удалось запустить рацию +Walkie Talkie Рация +YES ДА +6 digits 6 цифр +%u threads active %u активных потоков +%ud %ud +%uh %uh +%um %um +%umo %umo +%uy %uy +Confirm received code:\n Подтвердите полученный код:\n +device heading from 9-axis module заголовок устройства из 9-осевого модуля +heading unlocks after first fix заголовок разблокируется после первого исправления +If it matches, trust the key. Если он совпадает, ключ доверяется. +magnetometer heading active курс магнитометра активен +magnetometer still warming up Магнитометр все еще прогревается +needle shows north until target lock стрелка показывает север до фиксации цели +nearest peer | %.0f m away ближайший узел | %.0f м отсюда +no recent traffic нет недавнего трафика +now сейчас +%s layer missing Слой %s отсутствует +Active: %s Активен: %s +Alt: %.0f m Альтернатива: %.0f м +Alt: -- m Альтернатива: -- м +Base: %s База: %s +Battery critical Батарея критическая +Broadcast (%d) Трансляция (%d) +COMPLETE ЗАВЕРШЕНО +Calibrating... Калибровка... +Confirm Подтвердить +Contour: OFF Контур: ВЫКЛ. +Decoder error Ошибка декодера +Direct (%d) Прямой (%d) +E Е +ERROR ОШИБКА +Exiting USB... Выход из USB... +Hold Удерживать +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented Обновление ключа не реализовано +Key verify: %s %s Проверка ключа: %s %s +Key verify: confirm %s %s Проверка ключа: подтвердите %s %s +Key verify: enter number for %s Проверка ключа: введите номер для %s +Key verify: send %s %s Проверка ключа: отправьте %s %s +Later Позже +Level %d Уровень %d +Listening for SSTV signal... Прослушивание сигнала SSTV... +Low battery: %d%% Низкий заряд батареи: %d%% +MODE: %s РЕЖИМ: %s +MODE: Auto РЕЖИМ: Авто +Map - %.48s Карта — %.48s +Map - %s Missing Карта — %s отсутствует +Map - No Map Data Карта – нет картографических данных +Map - No SD Card Карта — нет карты SD +Map - no gps data Карта – нет данных GPS +Message Сообщение +Mic TBD Микрофон подлежит уточнению +Mic init failed Ошибка инициализации микрофона +Morse Input Ввод Морзе +MoveTo Переместить +N Н +NODE INFO ИНФОРМАЦИЯ ОБ УЗЛЕ +Name required Требуется имя +Name too long Имя слишком длинное +No KML routes Нет маршрутов KML +No SD Card Нет карты SD +No routes folder Нет папки маршрутов +No tracks yet Треков пока нет +Only leader can manage Только лидер может управлять +Only leader can pair Только лидер может создать пару +Paired successfully Сопряжение успешно выполнено +Pairing failed Не удалось выполнить сопряжение +Pairing init failed Не удалось инициализировать сопряжение +Pairing not available Сопряжение недоступно +Pairing not ready Сопряжение не готово +Press Back to exit USB mode Нажмите «Назад», чтобы выйти из режима USB. +Press SPACE to resume Нажмите ПРОБЕЛ, чтобы возобновить работу. +RallyTo РаллиТо +Route configured Маршрут настроен +Route: %s Маршрут: %s +SD Card Not Found\nPlease insert SD card SD Карта не найдена\nПожалуйста, вставьте карту SD +SSTV RECEIVER ПРИЕМНИК SSTV +Save failed Не удалось сохранить +Scan +%u/%u Сканировать +%u/%u +Select level Выбор уровня +Selected level: %d Выбранный уровень: %d +Shutting down to protect battery Выключение для защиты аккумулятора +Status: CALIB Статус: КАЛИБ. +Step 1/2: Tap short dots Шаг 1/2: коснитесь коротких точек +Step 2/2: Tap long dashes Шаг 2/2: нажмите длинные тире +Switch to %s to chat Чтобы начать чат, переключитесь на %s. +Symbol: Символ: +Tap to input, idle 3s to send Нажмите для ввода, бездействуйте 3 секунды для отправки +Team keys mismatch Несовпадение командных ключей +Team: Команда: +Text: Текст: +Unread: %d Непрочитано: %d +Unread: 0 Непрочитано: 0 +W Вт +0° Horizon 0° горизонта +PSK must be 32 hex or 16 chars PSK должен содержать 32 шестнадцатеричных или 16 символов. +Restarting... Перезапуск... +awaiting position ожидание позиции +compass wakes after GPS fix компас просыпается после исправления GPS +searching satellites поиск спутников +%s - %s - +%s 0s %s 0 с +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO АВТО +Alt: %ld m Альтернатива: %ld м +Alt: - Альтернатива: - +BEST ЛУЧШИЙ +BW: %.0fk BW: %.0fk +BW: %.1fk BW: %.1fk +BW: - BW: - +CAD CAD +CURSOR КУРСОР +Ch: %.3f Ch: %.3f +Ch: - Глава: - +Hop: %u Переход: %u +Hop: - Хоп: - +Hop: - / NH: %02X Переход: - / NH: %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: Последнее сообщение: +Link Ссылка +Link / MeshCore Ссылка / MeshCore +Link / Meshtastic Ссылка / Meshtastic +MODE: RSSI РЕЖИМ: RSSI +NOISE %.0f dBm ШУМ %.0f dBm +NOISE -104 dBm ШУМ -104 dBm +PKI: - PKI: - +PKI: known PKI: известно +PKI: verified PKI: проверено +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI: %.0f dBm +RSSI: - RSSI: - +Role: %s Роль: %s +SCAN СКАНИРОВАНИЕ +SF: %u SF: %u +SF: - SF: - +SIM SIM-карта +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1f дБ +SNR: - SNR: - +SNR %.0f SNR %.0f +SNR - SNR - +SNR Good SNR Хорошо +SNR Weak SNR Слабый +STEP %.0fk | BW %.0fk ШАГ %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk ШАГ %.1fk | BW %.1fk +STEP -- | BW -- ШАГ -- | BW -- +STOP СТОП +Slot Слот +Slot %u Слот %u +SUB-GHz SCAN СКАНИРОВАНИЕ СУБГГЦ +Actions Действия +Actions: %s Действия: %s +Access to team data revoked Доступ к данным команды отозван +Capability: Возможности: +Channel Actions Действия канала +Confirm Kick Подтвердить удаление +Create Team Создать команду +ELEV ПОДЪЕМ +Join Team Присоединяйтесь к команде +In View На виду +Keep devices within 5m Держите устройства на расстоянии не более 5 м. +KeyDist KeyDist +KeyId: %u Идентификатор ключа: %u +KeyId: -- Идентификатор ключа: -- +Keys Ключи +Kick Удар +Kick Member Удалить участника +Last paired: %s Последняя пара: %s +Last seen %uh ago Последний раз был замечен %uч назад +Last seen %um ago Последний раз был замечен %uм назад +Last seen -- Последний раз видели -- +Last update %us ago Последнее обновление %u с. назад +Last update -- Последнее обновление -- +Last update 0s ago Последнее обновление 0 с назад +Leader Лидер +Leader online Лидер онлайн +Leave Уйти +Leave team? Покинуть команду? +Manage Управление +Member: %s Участник: %s +Members Участники +Members: %u Участники: %u +Members: %u Online: %u Участники: %u В сети: %u +Modem Bridge Модемный мост +No members yet Пока нет участников +Not Used Не используется +Offline Не в сети +Online Онлайн +Online: %u Онлайн: %u +Pair Again Повторное сопряжение +Pair Member Член пары +Pairing in progress Выполняется сопряжение +Prev Предыдущий +Remove %s from team? Удалить %s из команды? +Request Keys Запросить ключи +Retry Повторить попытку +Security Round: %u Раунд безопасности: %u +Security Round: -- Раунд безопасности: -- +Security: OK (Round %u) Безопасность: ОК (раунд %u) +Security: OK (Round --) Безопасность: ОК (круглый --) +Seen %ud Виден %ud +Seen %uh Виден %uh +Seen %um Просмотрено %uм +Send failed (keys not ready) Отправка не удалась (ключи не готовы) +Status: %s Статус: %s +Target: %s Цель: %s +Team / %s Команда / %s +Team Actions Действия команды +Team Health Здоровье команды +Team Status Статус команды +Team: %s Команда: %s +This clears local keys. При этом локальные ключи очищаются. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. Это обновит раунд безопасности.\nУчастник больше не будет получать\nкомандные сообщения или путевые точки. +Transfer Передача +Transfer Leader Руководитель трансфера +View Team Посмотреть команду +Waiting for new keys... Ждем новых ключей... +Waypoint Маршрутная точка +You are no longer in this team Вы больше не в этой команде +You are not in a team Вы не в команде +- No shared map\n- No team awareness - Нет общей карты\n- Нет информации о команде +1 member stale 1 участник устарел +encrypt failed шифрование не выполнено. +member член +queue full очередь заполнена +unsupported protocol неподдерживаемый протокол +Factory Reset Сброс к заводским настройкам +Clear all settings and restart? Очистить все настройки и перезапустить? +Terrain Местность +Satellite Спутник +Contour: ON Контур: ВКЛ. +Contour data missing Отсутствуют данные контура +%d enabled %d включен +%s %s %s %s +Channel Key / PSK Ключ канала / PSK +Channel Slot Слот канала +Channel Utilization Использование канала +Check for Updates Проверить наличие обновлений +Clear Message DB Очистить базу данных сообщений +Connect Подключиться +Contour Overlay Наложение контура +Coordinate Format Формат координат +Coordinate System Система координат +Current Version Текущая версия +Detected Network Обнаруженная сеть +Disconnect Отключение +Duty Cycle Limit Предел рабочего цикла +Enabled IMEs Включено IMEs +Encryption Mode Режим шифрования +Freq Offset (MHz) Смещение частоты (MHz) +GPS Enabled GPS Включено +Hop Limit Предел переходов +IME setting update failed IME не удалось обновить настройку +Install Update Установить обновление +Latest Version Последняя версия +Layer Слой +Location Mode Режим определения местоположения +MC Airtime Factor Коэффициент эфирного времени MC +MC Bandwidth (kHz) Пропускная способность MC (kHz) +MC Channel Key Ключ канала MC +MC Channel Name Имя канала MC +MC Channel Slot Слот канала MC +MC Coding Rate Скорость кодирования MC +MC Flood Max MC Наводнение Макс. +MC Frequency (MHz) Частота MC (MHz) +MC Multi ACKs Мультиподтверждения MC +MC RX Delay Base MC RX База задержки +MC Region Preset Предустановка региона MC +MC Repeat Повтор MC +MC Spread Factor Коэффициент распространения MC +MC TX Power MC TX Питание +Manual BW Руководство BW +Manual CR Руководство CR +Manual SF Руководство SF +Map Source Источник карты +Modem Preset Предварительная настройка модема +NMEA Export NMEA Экспорт +NMEA Sentences NMEA Предложения +No IME packs installed Пакеты IME не установлены +No position available Вакансий нет +OTA Status OTA Статус +Override Duty Cycle Изменение рабочего цикла +Override Freq (MHz) Частота переопределения (MHz) +Password Пароль +Position Strategy Стратегия позиции +SSID SSID +Satellite Systems Спутниковая системаms +Scan Networks Сканирование сетей +TX Enabled TX Включено +TX Power TX Питание +Track Format Формат трека +Track Interval Интервал отслеживания +Track Recording Запись трека +Unavailable Недоступно +Update Interval Интервал обновления +Use Preset Использовать предустановку +Wi-Fi Enabled Wi-Fi Включено +Wi-Fi start failed Wi-Fi не удалось запустить diff --git a/packs/europe-cyrillic-ext/package.ini b/packs/europe-cyrillic-ext/package.ini new file mode 100644 index 00000000..a243d807 --- /dev/null +++ b/packs/europe-cyrillic-ext/package.ini @@ -0,0 +1,14 @@ +kind=package +id=europe-cyrillic-ext +package_type=locale-bundle +version=1.1.0 +display_name=European Cyrillic Extended +summary=Shared Cyrillic font coverage plus a Russian review locale pack; display-only until native review is complete. +description=DESCRIPTION.txt +readme=README.md +author=Trail Mate +homepage=https://vicliu624.github.io/trail-mate/ +min_firmware_version=0.1.24-alpha +supported_memory_profiles=constrained,standard,extended +tags=language,cyrillic,europe,russian,review +; Russian translations contributed by polarikus. diff --git a/packs/europe-latin-ext/DESCRIPTION.txt b/packs/europe-latin-ext/DESCRIPTION.txt index 11261f5a..30067416 100644 --- a/packs/europe-latin-ext/DESCRIPTION.txt +++ b/packs/europe-latin-ext/DESCRIPTION.txt @@ -3,4 +3,5 @@ and installs locale packs for German, Spanish, French, Italian, Dutch, Polish, a Portuguese (Portugal). This bundle is designed to stay lightweight while still rendering mixed-language contact -names, chat content, and localized UI labels cleanly across European languages. +names, chat content, and localized UI labels cleanly across European languages. The +locales are review-status until native-language review is complete. diff --git a/packs/europe-latin-ext/README.md b/packs/europe-latin-ext/README.md index 37c49c86..86d58c15 100644 --- a/packs/europe-latin-ext/README.md +++ b/packs/europe-latin-ext/README.md @@ -64,13 +64,13 @@ Notes: - `font.bin` is ignored by Git. Keep it locally or regenerate it when the subset changes. - `build.ini` is the source-of-truth for generating `font.bin` during Pages/package builds. - `ranges.txt` and `estimated_ram_bytes` are used by the runtime to decide whether the pack fits the current memory profile. -- The locale string tables are intentionally partial. Missing keys fall back to built-in English. +- The locale string tables are structurally complete, but these locale packs remain in `translation_status=review` until native-language review is complete. ## Distribution Package `python scripts/build_pack_repository.py --pack-root packs --site-root site` produces: -- `site/assets/packs/europe-latin-ext-1.0.0.zip` +- `site/assets/packs/europe-latin-ext-1.1.0.zip` - `site/data/packs.json` The zip is the bundle artifact for a future Extensions downloader. Its `payload/` directory unpacks into `/trailmate/packs/...`. @@ -99,4 +99,4 @@ Copy the bundle contents so the SD card ends up with: /trailmate/packs/locales/pt-PT/strings.tsv ``` -After reboot, these locales are cataloged immediately, but the external font is only loaded when one of them becomes active or is needed as content coverage. +After reboot, these locales are cataloged immediately, but they appear in Settings only after being promoted to `translation_status=release`. The external font is loaded only when a release locale becomes active or when the pack is needed as content coverage. diff --git a/packs/europe-latin-ext/locales/de/manifest.ini b/packs/europe-latin-ext/locales/de/manifest.ini index d89b0a54..f9e6efd0 100644 --- a/packs/europe-latin-ext/locales/de/manifest.ini +++ b/packs/europe-latin-ext/locales/de/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=de display_name=German native_name=Deutsch +translation_status=review ui_font_pack=latin-ext-eu content_font_pack=latin-ext-eu strings=strings.tsv diff --git a/packs/europe-latin-ext/locales/de/strings.tsv b/packs/europe-latin-ext/locales/de/strings.tsv index 83c960e4..d4d30ede 100644 --- a/packs/europe-latin-ext/locales/de/strings.tsv +++ b/packs/europe-latin-ext/locales/de/strings.tsv @@ -2,28 +2,79 @@ Action Aktion Add Hinzufügen Advanced Erweitert Always Immer +Altitude Höhe +Altitude Reference Höhenreferenz App App +Area Cleared Bereich gelöscht Auto Auto +Awaiting teammate Wartet auf Teamkollegen Back Zurück +Base Camp Basislager +Beijing (UTC+8) Peking (UTC+8) +BLE bridge ready | %s BLE Bridge bereit | %s +BLE linked | %s BLE verknüpft | %s Bluetooth Bluetooth Bluetooth Pairing Bluetooth-Kopplung +Bcast Bcast +Bridge error Bridge-Fehler +Broadcast Broadcast Cancel Abbrechen Channel Kanal Chat Chat +Chat unavailable Chat nicht verfügbar Chinese Chinesisch +Closes automatically after pairing Wird nach dem Pairing automatisch geschlossen Close Schließen +Command Befehl +Command: %s Befehl: %s +Command: %s %s Befehl: %s %s +Command: %s %s %s Befehl: %s %s %s +Compare Verification Code Verifizierungscode vergleichen +Compass Kompass +Connected Verbunden +Connected, handshaking... Verbunden, Handshake... Contacts Kontakte +Contacts (%s) Kontakte (%s) +Contacts (LXMF) Kontakte (LXMF) +Contacts (LXMF - %.3fMHz) Kontakte (LXMF - %.3fMHz) +Contacts (MeshCore) Kontakte (MeshCore) +Contacts (Meshtastic - %.3fMHz) Kontakte (Meshtastic - %.3fMHz) +Contacts (RNode) Kontakte (RNode) +Contacts (RNode - %.3fMHz) Kontakte (RNode - %.3fMHz) +Conversation protocol mismatch Konversationsprotokoll stimmt nicht überein Data Exchange Datenaustausch +Debug Logs Debug-Protokolle +Decoding: %d%% Dekodierung: %d%% +Decoding: 0% Dekodierung: 0 % Delete Löschen +Delete %s? %s löschen? +Delete contact %s? Kontakt %s löschen? +Delete failed Löschen fehlgeschlagen +Device: %s Gerät: %s +Direct Direkt Disabled Deaktiviert Discover Suchen Display Language Anzeigesprache +Duplicate name not allowed Doppelter Name nicht zulässig Edit Bearbeiten +Edit nickname Spitzname bearbeiten +Emergency SOS Notfall-SOS Energy Sweep Frequenzscan English Englisch English keyboard Englische Tastatur +Enter nickname Spitznamen eingeben +Enter number for %s Nummer für %s eingeben +Enter the 6-digit number Geben Sie die 6-stellige Nummer ein +Enter this 6-digit PIN on your phone Geben Sie diese 6-stellige PIN auf Ihrem Telefon ein Error Fehler -Factory Reset Werkreset +Error: %lu Fehler: %lu +FSK Voice FSK-Sprache +Failed Fehlgeschlagen +Fix Behoben +Fixed PIN Feste PIN +Gauge Design (mAh) Messgerätdesign (mAh) +Gauge Full (mAh) Anzeige voll (mAh) +Good Find Guter Fund GPS GPS Extensions Erweiterungen Language Sprache @@ -59,52 +110,509 @@ Locales: %s\nFonts: %s\nIME: %s Sprachen: %s\nSchriftarten: %s\nIME: %s Author: %s\nHomepage: %s Autor: %s\nHomepage: %s None Keine GPS Status GPS-Status +HDOP HDOP +Heading starting Überschrift beginnt Hide Ausblenden +Host bridge Host-Bridge +Host connected, probing modem... Host verbunden, prüft Modem... +IDLE IDLE +Ignored Ignoriert +Ignore Ignorieren +Ignore unavailable Ignorieren nicht verfügbar +Ignore update failed Aktualisierung ignorieren fehlgeschlagen +Image received Bild empfangen Info Info +Initializing... Initialisierung... +Invalid airtime factor Ungültiger Sendezeitfaktor +Invalid design capacity (mAh) Ungültige Designkapazität (mAh) +Invalid frequency offset Ungültiger Frequenzoffset +Invalid frequency value Ungültiger Frequenzwert +Invalid full capacity (mAh) Ungültige volle Kapazität (mAh) +Invalid marker Ungültige Markierung +Invalid MeshCore bandwidth Ungültige MeshCore Bandbreite +Invalid MeshCore frequency Ungültige MeshCore Häufigkeit +Invalid number Ungültige Nummer +Invalid RX delay Ungültige RX Verzögerung +Key marked trusted Schlüssel als vertrauenswürdig markiert +Key must be 32 hex or 16 chars Der Schlüssel muss 32 hexadezimal oder 16 Zeichen lang sein +Key trust failed Schlüsselvertrauen fehlgeschlagen +Key Verification Schlüsselüberprüfung +LINKED VERKNÜPFT +LISTEN HÖREN +LIVE LIVE Loading... Lädt... +Location Standort +Location: %s Ort: %s +Location: %s %s Ort: %s %s +LoRa direct path | %s Direkter LoRa-Pfad | %s +Low battery - audio disabled Batterie schwach – Audio deaktiviert Map Karte Map Layer Kartenebene +MESH MESH +Mesh Status Mesh-Status +Mesh unavailable Netz nicht verfügbar +MeshCore only Nur MeshCore +Message DB cleared Nachrichten-DB gelöscht +Messages Nachrichten +MESSAGES NACHRICHTEN +Messages and broadcasts show here Nachrichten und Übertragungen werden hier angezeigt +Mini Compass Mini-Kompass +MT send uses slot 0/1 only MT-Senden verwendet nur Steckplatz 0/1 +N/A N/A Nearby In der Nähe +Need local GPS Lokales GPS erforderlich Network Netzwerk New Neu +new activity neue Aktivität +Next Weiter +No GPS fix Kein GPS Fix +No image Kein Bild No messages Keine Nachrichten +No preview Keine Vorschau No recent traffic Kein neuer Funkverkehr +No response Keine Antwort +No route selected Keine Route ausgewählt +No target Kein Ziel +No track files Keine Trackdateien +Node DB reset Knoten-DB zurückgesetzt +Node ignored Knoten ignoriert +Node ignored and hidden Knoten ignoriert und ausgeblendet +Node unignored Knoten nicht ignoriert +North locked Norden gesperrt Not set Nicht gesetzt +NO NEIN +NO FIX KEIN FIX Number keyboard Zifferntastatur OFF AUS OK OK ON EIN +online online Pairing Koppeln PC Link PC-Verbindung +PC Link is unavailable on this target. PC Link ist auf diesem Ziel nicht verfügbar. +peer lock | turn %03.0f deg Peer-Sperre | %03.0f Grad drehen +Pinyin keyboard Pinyin-Tastatur +Pinyin: %s Pinyin: %s +Position Position +position stream active | %u in view Positionsstream aktiv | %u im Blick Power Strom +Press Back Drücken Sie Zurück +Press RX to start Drücken Sie zum Starten RX +Primary Primär Protocol Protokoll +Protocol switch failed Protokollwechsel fehlgeschlagen +Protocol switched Protokoll umgeschaltet +quiet net ruhiges Netz +Random PIN Zufällige PIN Ready Bereit +RECEIVING EMPFANGEN Recent Activity Letzte Aktivität +Record Datensatz +Recording Aufnahme +Region Region +Remain: %u Verbleib: %u Reply Antworten +Reply disabled for this protocol Antwort für dieses Protokoll deaktiviert +Reset Mesh Profiles Netzprofile zurücksetzen +Reset Node DB Knoten-DB zurücksetzen +Resetting... Zurücksetzen... +Return Zurück +Rally Point Sammelpunkt +RNode Bridge RNode Brücke +RNode modem ready RNode Modem bereit +RNode text chat runs on host RNode Text-Chat wird auf dem Host ausgeführt +Route Route Run Ausführen +RX RX +RX: %lu TX: %lu RX: %lu TX: %lu +RX: 0 TX: 0 RX: 0 TX: 0 Save Speichern +Saved: %s Gespeichert: %s Scan failed Scan fehlgeschlagen Scan Local Lokal scannen Scanning 5s... Scanne 5 s... -SEARCH SUCHE Screen Brightness Bildschirmhelligkeit Screen Timeout Bildschirm-Timeout +SEARCH SUCHE +Secondary Sekundär +Select a route Wählen Sie eine Route +Select a track Wählen Sie einen Titel aus +Select marker Markierung auswählen +Select Track Wählen Sie Track aus +Selected: %s Ausgewählt: %s +Send Senden +Send failed Senden fehlgeschlagen +Send this code and compare:\n Senden Sie diesen Code und vergleichen Sie:\n +Send ID Broadcast ID Broadcast senden +Send ID Local Senden Sie ID Local +Sending... Wird gesendet... +Sent Gesendet Settings Einstellungen Setting Einstellung -Sky Plot Sky Plot +Share Position Marker Positionsmarkierung teilen +Share this number:\n Teilen Sie diese Nummer:\n +Short Name Kurzname +SOLO SOLO Speaker Volume Lautstärke +Sky Plot Sky Plot +SSTV RX READY SSTV RX BEREIT +SSTV start failed SSTV-Start fehlgeschlagen +standing by steht bereit Status Status Stopped Gestoppt Stop Stopp +Stop recording first Stoppen Sie zuerst die Aufnahme Submit Bestätigen +Submit failed Senden fehlgeschlagen System System +SATELLITE STATUS SATELLITENSTATUS +Talk Sprechen +TALK SPRECHEN Team Team -Team Status Teamstatus +TEAM TEAM +Team chat send failed Das Senden des Team-Chats ist fehlgeschlagen +Team chat unavailable Team-Chat nicht verfügbar +Team chat unavailable in RNode mode Team-Chat im RNode-Modus nicht verfügbar +team member position will appear here Die Position des Teammitglieds wird hier angezeigt +Team keys not ready Teamschlüssel nicht bereit +Team location encode failed Codierung des Teamstandorts fehlgeschlagen +Text chat unavailable Text-Chat nicht verfügbar +threads active Threads aktiv Time Zone Zeitzone Tracker Tracker +traffic active Verkehr aktiv +Trust Key Vertrauensschlüssel +U U +Unignore Ignorieren aufheben +Unread Ungelesen Unknown Unbekannt +USB Mass Storage USB Massenspeicher USB Disk USB-Speicher +USB Bridge USB Brücke +USB host bridge USB Host-Bridge +USB Bridge is unavailable on this target. USB Bridge ist auf diesem Ziel nicht verfügbar. +USB CDC KISS modem for Reticulum USB CDC KISS Modem für Reticulum +USE VERWENDEN +Used Verwendet User Name Benutzername Verification Number Bestätigungsnummer +Verification number sent Verifizierungsnummer gesendet Vibration Vibration +VOL %d VOL %d +VOL 80 BAND 80 +waiting for better sky view | %u in view wartet auf bessere Himmelssicht | %u im Blick +waiting for module GNSS compass wartet auf Modul GNSS Kompass +waiting for traffic wartet auf Verkehr +Waiting for host... Warten auf Host... +Waiting for Reticulum host... Warten auf Reticulum Host... +WAIT IMU WARTEN IMU +Walkie not available Walkie nicht verfügbar +Walkie start failed Walkie-Start fehlgeschlagen +Walkie Talkie Walkie Talkie +YES JA +6 digits 6 Ziffern +%u threads active %u Threads aktiv +%ud %ud +%uh %uh +%um %um +%umo %umo +%uy %uy +Confirm received code:\n Bestätigen Sie den empfangenen Code:\n +device heading from 9-axis module Geräterichtung vom 9-Achsen-Modul +heading unlocks after first fix wird nach dem ersten Fix entsperrt +If it matches, trust the key. Wenn es übereinstimmt, vertrauen Sie dem Schlüssel. +magnetometer heading active Magnetometerkurs aktiv +magnetometer still warming up Magnetometer wird immer noch aufgewärmt +needle shows north until target lock Nadel zeigt bis zur Zielerfassung nach Norden +nearest peer | %.0f m away nächster Peer | %.0f m entfernt +no recent traffic kein aktueller Datenverkehr +now jetzt +%s layer missing %s Ebene fehlt +Active: %s Aktiv: %s +Alt: %.0f m Alt: %.0f m +Alt: -- m Alt: -- m +Base: %s Basis: %s +Battery critical Batterie kritisch +Broadcast (%d) Broadcast (%d) +COMPLETE ABGESCHLOSSEN +Calibrating... Kalibrieren... +Confirm Bestätigen +Contour: OFF Kontur: AUS +Decoder error Decoderfehler +Direct (%d) Direkt (%d) +E E +ERROR FEHLER +Exiting USB... USB wird beendet... +Hold Halten +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented Schlüsselaktualisierung nicht implementiert +Key verify: %s %s Schlüsselüberprüfung: %s %s +Key verify: confirm %s %s Schlüsselüberprüfung: bestätigen %s %s +Key verify: enter number for %s Schlüsselüberprüfung: Nummer für %s eingeben +Key verify: send %s %s Schlüsselüberprüfung: senden %s %s +Later Später +Level %d Ebene %d +Listening for SSTV signal... Auf SSTV-Signal wird gehorcht... +Low battery: %d%% Batterie schwach: %d%% +MODE: %s MODUS: %s +MODE: Auto MODUS: Auto +Map - %.48s Karte – %.48s +Map - %s Missing Karte – %s fehlt +Map - No Map Data Karte – Keine Kartendaten +Map - No SD Card Karte – Keine SD Karte +Map - no gps data Karte – keine GPS-Daten +Message Nachricht +Mic TBD Mic TBD +Mic init failed Mikrofoninitialisierung fehlgeschlagen +Morse Input Morseeingabe +MoveTo MoveTo +N N +NODE INFO KNOTENINFO +Name required Name erforderlich +Name too long Name zu lang +No KML routes Keine KML-Routen +No SD Card Keine SD Karte +No routes folder Kein Routenordner +No tracks yet Noch keine Titel +Only leader can manage Nur der Leiter kann verwalten +Only leader can pair Nur der Anführer kann koppeln +Paired successfully Erfolgreich gekoppelt +Pairing failed Kopplung fehlgeschlagen +Pairing init failed Kopplungsinitialisierung fehlgeschlagen +Pairing not available Kopplung nicht verfügbar +Pairing not ready Kopplung nicht bereit +Press Back to exit USB mode Drücken Sie Zurück, um den USB-Modus zu verlassen +Press SPACE to resume Drücken Sie die LEERTASTE, um fortzufahren +RallyTo RallyTo +Route configured Route konfiguriert +Route: %s Route: %s +SD Card Not Found\nPlease insert SD card SD Karte nicht gefunden\nBitte legen Sie die SD Karte ein +SSTV RECEIVER SSTV-EMPFÄNGER +Save failed Speichern fehlgeschlagen +Scan +%u/%u Scannen +%u/%u +Select level Ebene auswählen +Selected level: %d Ausgewählte Ebene: %d +Shutting down to protect battery Herunterfahren zum Schutz der Batterie +Status: CALIB Status: KALIB +Step 1/2: Tap short dots Schritt 1/2: Tippen Sie auf kurze Punkte +Step 2/2: Tap long dashes Schritt 2/2: Tippen Sie auf lange Striche +Switch to %s to chat Wechseln Sie zu %s, um zu chatten +Symbol: Symbol: +Tap to input, idle 3s to send Zur Eingabe tippen, zum Senden 3 Sekunden warten +Team keys mismatch Teamschlüssel stimmen nicht überein +Team: Team: +Text: Text: +Unread: %d Ungelesen: %d +Unread: 0 Ungelesen: 0 +W W +0° Horizon 0° Horizont +PSK must be 32 hex or 16 chars PSK muss 32 Hex oder 16 Zeichen lang sein +Restarting... Neustart... +awaiting position wartet auf Position +compass wakes after GPS fix Kompass erwacht nach GPS-Korrektur +searching satellites Suche nach Satelliten +%s - %s - +%s 0s %s 0s +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO AUTO +Alt: %ld m Alt: %ld m +Alt: - Alt: - +BEST BESTE +BW: %.0fk BW: %.0fk +BW: %.1fk BW: %.1fk +BW: - BW: - +CAD CAD +CURSOR CURSOR +Ch: %.3f Ch: %.3f +Ch: - Ch: - +Hop: %u Hop: %u +Hop: - Hop: - +Hop: - / NH: %02X Hop: - / NH: %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: Zuletzt gehört: +Link Link +Link / MeshCore Link / MeshCore +Link / Meshtastic Link / Meshtastic +MODE: RSSI MODUS: RSSI +NOISE %.0f dBm NOISE %.0f dBm +NOISE -104 dBm NOISE -104 dBm +PKI: - PKI: - +PKI: known PKI: bekannt +PKI: verified PKI: verifiziert +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI: %.0f dBm +RSSI: - RSSI: - +Role: %s Rolle: %s +SCAN SCANNEN +SF: %u SF: %u +SF: - SF: - +SIM SIM +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1f dB +SNR: - SNR: - +SNR %.0f SNR %.0f +SNR - SNR - +SNR Good SNR Gut +SNR Weak SNR Schwach +STEP %.0fk | BW %.0fk SCHRITT %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk SCHRITT %.1fk | BW %.1fk +STEP -- | BW -- SCHRITT -- | BW -- +STOP STOP +Slot Steckplatz +Slot %u Slot %u +SUB-GHz SCAN SUB-GHz-SCAN +Actions Aktionen +Actions: %s Aktionen: %s +Access to team data revoked Zugriff auf Teamdaten widerrufen +Capability: Fähigkeit: +Channel Actions Kanalaktionen +Confirm Kick Kick bestätigen +Create Team Team erstellen +ELEV ELEV +Join Team Dem Team beitreten +In View In Ansicht +Keep devices within 5m Halten Sie Geräte innerhalb von 5 m +KeyDist KeyDist +KeyId: %u KeyId: %u +KeyId: -- KeyId: -- +Keys Schlüssel +Kick Tritt +Kick Member Kick-Mitglied +Last paired: %s Zuletzt gepaart: %s +Last seen %uh ago Zuletzt gesehen vor %uh +Last seen %um ago Zuletzt gesehen vor %um +Last seen -- Zuletzt gesehen – +Last update %us ago Letzte Aktualisierung vor %us +Last update -- Letzte Aktualisierung – +Last update 0s ago Letzte Aktualisierung vor 0 Sekunden +Leader Leiter +Leader online Leader online +Leave Verlassen +Leave team? Team verlassen? +Manage Verwalten +Member: %s Mitglied: %s +Members Mitglieder +Members: %u Mitglieder: %u +Members: %u Online: %u Mitglieder: %u Online: %u +Modem Bridge Modembrücke +No members yet Noch keine Mitglieder +Not Used Nicht verwendet +Offline Offline +Online Online +Online: %u Online: %u +Pair Again Erneut koppeln +Pair Member Paarmitglied +Pairing in progress Kopplung läuft +Prev Vorher +Remove %s from team? %s aus dem Team entfernen? +Request Keys Schlüssel anfordern +Retry Wiederholen +Security Round: %u Sicherheitsrunde: %u +Security Round: -- Sicherheitsrunde: -- +Security: OK (Round %u) Sicherheit: OK (Runde %u) +Security: OK (Round --) Sicherheit: OK (Rund --) +Seen %ud Gesehen %ud +Seen %uh Gesehen %uh +Seen %um Gesehen %um +Send failed (keys not ready) Senden fehlgeschlagen (Schlüssel nicht bereit) +Status: %s Status: %s +Target: %s Ziel: %s +Team / %s Team / %s +Team Actions Teamaktionen +Team Health Teamgesundheit +Team Status Teamstatus +Team: %s Team: %s +This clears local keys. Dadurch werden lokale Schlüssel gelöscht. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. Dadurch wird die Sicherheitsrunde aktualisiert.\nDas Mitglied erhält keine \nTeamnachrichten oder Wegpunkte mehr. +Transfer Übertragen +Transfer Leader Transferleiter +View Team Team anzeigen +Waiting for new keys... Warten auf neue Schlüssel... +Waypoint Wegpunkt +You are no longer in this team Sie sind nicht mehr in diesem Team +You are not in a team Sie sind nicht in einem Team +- No shared map\n- No team awareness – Keine geteilte Karte\n – Kein Teambewusstsein +1 member stale 1 Mitglied veraltet +encrypt failed Verschlüsselung fehlgeschlagen +member Mitglied +queue full Warteschlange voll +unsupported protocol nicht unterstütztes Protokoll +Factory Reset Werkreset Clear all settings and restart? Alle Einstellungen löschen und neu starten? +Terrain Gelände +Satellite Satellit +Contour: ON Kontur: EIN +Contour data missing Konturdaten fehlen +%d enabled %d aktiviert +%s %s %s %s +Channel Key / PSK Kanalschlüssel / PSK +Channel Slot Kanalslot +Channel Utilization Kanalauslastung +Check for Updates Nach Updates suchen +Clear Message DB Nachrichten-DB löschen +Connect Verbinden +Contour Overlay Konturüberlagerung +Coordinate Format Koordinatenformat +Coordinate System Koordinatensystem +Current Version Aktuelle Version +Detected Network Erkanntes Netzwerk +Disconnect Verbindung trennen +Duty Cycle Limit Arbeitszyklusgrenze +Enabled IMEs Aktiviert IMEs +Encryption Mode Verschlüsselungsmodus +Freq Offset (MHz) Freq Offset (MHz) +GPS Enabled GPS Aktiviert +Hop Limit Hop-Limit +IME setting update failed IME Einstellungsaktualisierung fehlgeschlagen +Install Update Update installieren +Latest Version Neueste Version +Layer Ebene +Location Mode Standortmodus +MC Airtime Factor MC-Sendezeitfaktor +MC Bandwidth (kHz) MC-Bandbreite (kHz) +MC Channel Key MC-Kanalschlüssel +MC Channel Name MC-Kanalname +MC Channel Slot MC-Kanal-Slot +MC Coding Rate MC-Codierungsrate +MC Flood Max MC Überschwemmung max +MC Frequency (MHz) MC-Häufigkeit (MHz) +MC Multi ACKs MC Multi ACKs +MC RX Delay Base MC RX Verzögerungsbasis +MC Region Preset MC-Regionsvoreinstellung +MC Repeat MC-Wiederholung +MC Spread Factor MC-Spread-Faktor +MC TX Power MC TX Leistung +Manual BW Handbuch BW +Manual CR Handbuch CR +Manual SF Handbuch SF +Map Source Kartenquelle +Modem Preset Modem-Voreinstellung +NMEA Export NMEA Exportieren +NMEA Sentences NMEA Sätze +No IME packs installed Keine IME Pakete installiert +No position available Keine Position verfügbar +OTA Status OTA Status +Override Duty Cycle Arbeitszyklus überschreiben +Override Freq (MHz) Override Freq (MHz) +Password Passwort +Position Strategy Positionsstrategie +SSID SSID +Satellite Systems Satellitensystemms +Scan Networks Netzwerke scannen +TX Enabled TX Aktiviert +TX Power TX Leistung +Track Format Spurformat +Track Interval Track-Intervall +Track Recording Spuraufzeichnung +Unavailable Nicht verfügbar +Update Interval Aktualisierungsintervall +Use Preset Voreinstellung verwenden +Wi-Fi Enabled Wi-Fi Aktiviert +Wi-Fi start failed Wi-Fi Start fehlgeschlagen diff --git a/packs/europe-latin-ext/locales/es/manifest.ini b/packs/europe-latin-ext/locales/es/manifest.ini index c3c24d76..1c195cb8 100644 --- a/packs/europe-latin-ext/locales/es/manifest.ini +++ b/packs/europe-latin-ext/locales/es/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=es display_name=Spanish native_name=Español +translation_status=review ui_font_pack=latin-ext-eu content_font_pack=latin-ext-eu strings=strings.tsv diff --git a/packs/europe-latin-ext/locales/es/strings.tsv b/packs/europe-latin-ext/locales/es/strings.tsv index 578ab65c..03c3b705 100644 --- a/packs/europe-latin-ext/locales/es/strings.tsv +++ b/packs/europe-latin-ext/locales/es/strings.tsv @@ -1,42 +1,93 @@ Action Acción -Add Agregar +Add Añadir Advanced Avanzado Always Siempre -App App -Auto Auto -Back Atrás +Altitude Altitud +Altitude Reference Ref. altitud +App Aplicación +Area Cleared Área despejada +Auto Automático +Awaiting teammate Esperando equipo +Back Volver +Base Camp Campamento base +Beijing (UTC+8) Pekín (UTC+8) +BLE bridge ready | %s Puente BLE listo | %s +BLE linked | %s BLE vinculado | %s Bluetooth Bluetooth -Bluetooth Pairing Emparejamiento Bluetooth +Bluetooth Pairing Emparejar Bluetooth +Bcast Difusión +Bridge error Error de puente +Broadcast Difusión Cancel Cancelar Channel Canal -Chat Chat +Chat Charla +Chat unavailable Chat no disponible Chinese Chino +Closes automatically after pairing Cierra tras emparejar Close Cerrar +Command Comando +Command: %s Comando: %s +Command: %s %s Comando: %s %s +Command: %s %s %s Comando: %s %s %s +Compare Verification Code Comparar código +Compass Brújula +Connected Conectado +Connected, handshaking... Conectado, negociando... Contacts Contactos -Data Exchange Interc. datos +Contacts (%s) Contactos (%s) +Contacts (LXMF) Contactos (LXMF) +Contacts (LXMF - %.3fMHz) Contactos (LXMF - %.3fMHz) +Contacts (MeshCore) Contactos (MeshCore) +Contacts (Meshtastic - %.3fMHz) Contactos (Meshtastic - %.3fMHz) +Contacts (RNode) Contactos (RNode) +Contacts (RNode - %.3fMHz) Contactos (RNode - %.3fMHz) +Conversation protocol mismatch Protocolo no coincide +Data Exchange Intercambio datos +Debug Logs Registros depuración +Decoding: %d%% Decodificando: %d%% +Decoding: 0% Decodificando: 0% Delete Eliminar +Delete %s? ¿Eliminar %s? +Delete contact %s? ¿Eliminar contacto %s? +Delete failed Error al eliminar +Device: %s Dispositivo: %s +Direct Directo Disabled Desactivado Discover Descubrir Display Language Idioma de pantalla +Duplicate name not allowed Nombre duplicado no permitido Edit Editar -Energy Sweep Barrido RF +Edit nickname Editar apodo +Emergency SOS Emergencia SOS +Energy Sweep Barrido energía English Inglés English keyboard Teclado inglés +Enter nickname Ingresar apodo +Enter number for %s Ingresar número para %s +Enter the 6-digit number Ingrese número de 6 dígitos +Enter this 6-digit PIN on your phone Ingrese PIN de 6 dígitos en su teléfono Error Error -Factory Reset Rest. de fábrica +Error: %lu Error: %lu +FSK Voice Voz FSK +Failed Fallido +Fix Fijar +Fixed PIN PIN fijo +Gauge Design (mAh) Capacidad diseño (mAh) +Gauge Full (mAh) Capacidad total (mAh) +Good Find Buen hallazgo GPS GPS Extensions Extensiones Language Idioma -Package Details Detalles del paquete -Back to list Volver a la lista +Package Details Detalles paquete +Back to list Volver a lista Install Instalar Update Actualizar Uninstall Desinstalar Incompatible Incompatible Compatible with this device Compatible con este dispositivo Compatibility: %s Compatibilidad: %s -memory profile mismatch perfil de memoria incompatible -needs %s requiere %s +memory profile mismatch Perfil memoria no coincide +needs %s necesita %s Update available: %s Actualización disponible: %s Installed Instalado Installed: %s Instalado: %s @@ -48,63 +99,520 @@ Installing package... Instalando paquete... Package installed Paquete instalado Uninstalling package... Desinstalando paquete... Package uninstalled Paquete desinstalado -No language packs available No hay paquetes de idioma disponibles -Available language packs: %lu Paquetes de idioma disponibles: %lu +No language packs available No hay paquetes de idioma +Available language packs: %lu Paquetes de idioma: %lu About Acerca de -Package Info Información del paquete +Package Info Info paquete Contents Contenido Source Origen -Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archivo: %s\nRAM extra de fuentes: %s\nPerfiles: %s\n%s -Locales: %s\nFonts: %s\nIME: %s Idiomas: %s\nFuentes: %s\nIME: %s -Author: %s\nHomepage: %s Autor: %s\nPágina principal: %s +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archivo: %s\nFuente extra RAM: %s\nPerfiles: %s\n%s +Locales: %s\nFonts: %s\nIME: %s Configuración regional: %s\nFuentes: %s\nIME: %s +Author: %s\nHomepage: %s Autor: %s\nPágina de inicio: %s None Ninguno GPS Status Estado GPS +HDOP HDOP +Heading starting Rumbo inicial Hide Ocultar +Host bridge Puente de host +Host connected, probing modem... Host conectado, módem sondeando... +IDLE IDLE +Ignored Ignorado +Ignore Ignorar +Ignore unavailable Ignorar no disponible +Ignore update failed Error al ignorar la actualización +Image received Imagen recibida Info Información +Initializing... Inicializando... +Invalid airtime factor Factor de tiempo aire no válido +Invalid design capacity (mAh) Capacidad de diseño no válida (mAh) +Invalid frequency offset Desplazamiento de frecuencia no válido +Invalid frequency value Valor de frecuencia no válido +Invalid full capacity (mAh) Capacidad total no válida (mAh) +Invalid marker Marcador no válido +Invalid MeshCore bandwidth Ancho de banda MeshCore no válido +Invalid MeshCore frequency Frecuencia MeshCore no válida +Invalid number Número no válido +Invalid RX delay Retraso RX no válido +Key marked trusted Clave marcada como confiable +Key must be 32 hex or 16 chars La clave debe tener 32 hexadecimales o 16 caracteres +Key trust failed Error en la confianza de la clave +Key Verification Verificación de clave +LINKED ENLACE +LISTEN ESCUCHA +LIVE EN VIVO Loading... Cargando... +Location Ubicación +Location: %s Ubicación: %s +Location: %s %s Ubicación: %s %s +LoRa direct path | %s Ruta directa LoRa | %s +Low battery - audio disabled Batería baja - audio desactivado Map Mapa Map Layer Capa de mapa -Nearby Cercano +MESH MALLA +Mesh Status Estado de la malla +Mesh unavailable Malla no disponible +MeshCore only MeshCore solo +Message DB cleared BD de mensajes borrada +Messages Mensajes +MESSAGES MENSAJES +Messages and broadcasts show here Los mensajes y transmisiones se muestran aquí +Mini Compass Minibrújula +MT send uses slot 0/1 only El envío MT utiliza solo la ranura 0/1 +N/A N/A +Nearby Cerca +Need local GPS Necesita GPS local Network Red New Nuevo -No messages No hay mensajes -No recent traffic Sin tráfico reciente -Not set Sin configurar +new activity nueva actividad +Next Siguiente +No GPS fix No hay solución GPS +No image Sin imagen +No messages Sin mensajes +No preview Sin vista previa +No recent traffic No hay tráfico reciente +No response Sin respuesta +No route selected Ninguna ruta seleccionada +No target Sin objetivo +No track files No hay archivos de seguimiento +Node DB reset Restablecer base de datos de nodo +Node ignored Nodo ignorado +Node ignored and hidden Nodo ignorado y oculto +Node unignored Nodo ignorado +North locked Norte bloqueado +Not set No establecido +NO NO +NO FIX SIN ARREGLO Number keyboard Teclado numérico -OFF APAG -OK OK -ON ENC -Pairing Emparejar -PC Link Enlace PC -Power Alimentación +OFF APAGADO +OK Aceptar +ON ENCENDIDO +online en línea +Pairing Emparejamiento +PC Link Enlace a PC +PC Link is unavailable on this target. PC Link no está disponible en este destino. +peer lock | turn %03.0f deg bloqueo de pares | girar %03.0f grados +Pinyin keyboard Teclado pinyin +Pinyin: %s Pinyin: %s +Position Posición +position stream active | %u in view flujo de posición activo | %u a la vista +Power Energía +Press Back Presione Atrás +Press RX to start Presione RX para comenzar +Primary Primario Protocol Protocolo +Protocol switch failed Falló el cambio de protocolo +Protocol switched Protocolo cambiado +quiet net red silenciosa +Random PIN PIN aleatorio Ready Listo +RECEIVING RECIBIENDO Recent Activity Actividad reciente +Record Registro +Recording Grabación +Region Región +Remain: %u Permanecer: %u Reply Responder +Reply disabled for this protocol Respuesta deshabilitada para este protocolo +Reset Mesh Profiles Restablecer perfiles de malla +Reset Node DB Restablecer base de datos del nodo +Resetting... Restableciendo... +Return Volver +Rally Point Punto de reunión +RNode Bridge RNode Puente +RNode modem ready RNode módem listo +RNode text chat runs on host RNode el chat de texto se ejecuta en el host +Route Ruta Run Ejecutar +RX RX +RX: %lu TX: %lu RX: %lu TX: %lu +RX: 0 TX: 0 RX: 0 TX: 0 Save Guardar -Scan failed Escaneo fallido -Scan Local Escaneo local -Scanning 5s... Escaneando 5 s... +Saved: %s Guardado: %s +Scan failed Falló el escaneo +Scan Local Escanear local +Scanning 5s... Escaneando 5s... +Screen Brightness Brillo de pantalla +Screen Timeout Tiempo de espera de pantalla SEARCH BUSCAR -Screen Brightness Brillo pantalla -Screen Timeout Tiempo pantalla -Settings Ajustes -Setting Ajuste -Sky Plot Cielo GPS +Secondary Secundaria +Select a route Selecciona una ruta +Select a track Selecciona una pista +Select marker Seleccionar marcador +Select Track Seleccionar pista +Selected: %s Seleccionado: %s +Send Enviar +Send failed Error en el envío +Send this code and compare:\n Envía este código y compara:\n +Send ID Broadcast Enviar ID Difusión +Send ID Local Enviar ID Local +Sending... Enviando... +Sent Enviado +Settings Configuración +Setting Configuración +Share Position Marker Compartir marcador de posición +Share this number:\n Comparte este número:\n +Short Name Nombre corto +SOLO SOLITARIO Speaker Volume Volumen del altavoz +Sky Plot Trazado del cielo +SSTV RX READY SSTV RX LISTO +SSTV start failed Falló el inicio de SSTV +standing by en espera Status Estado Stopped Detenido Stop Detener +Stop recording first Detener la grabación primero Submit Enviar +Submit failed Error al enviar System Sistema +SATELLITE STATUS ESTADO DEL SATÉLITE +Talk Hablar +TALK HABLAR Team Equipo -Team Status Estado del equipo +TEAM EQUIPO +Team chat send failed Falló el envío del chat del equipo +Team chat unavailable Chat de equipo no disponible +Team chat unavailable in RNode mode El chat de equipo no está disponible en el modo RNode +team member position will appear here la posición del miembro del equipo aparecerá aquí +Team keys not ready Las claves del equipo no están listas +Team location encode failed Falló la codificación de la ubicación del equipo +Text chat unavailable Chat de texto no disponible +threads active hilos activos Time Zone Zona horaria -Tracker Seguimiento +Tracker Rastreador +traffic active tráfico activo +Trust Key Clave de confianza +U U +Unignore Dejar de ignorar +Unread No leído Unknown Desconocido -USB Disk Disco USB +USB Mass Storage USB Almacenamiento masivo +USB Disk USB Disco +USB Bridge USB Puente +USB host bridge USB puente de host +USB Bridge is unavailable on this target. USB El puente no está disponible en este destino. +USB CDC KISS modem for Reticulum USB Módem CDC KISS para Reticulum +USE USO +Used Usado User Name Nombre de usuario Verification Number Número de verificación +Verification number sent Número de verificación enviado Vibration Vibración -Clear all settings and restart? ¿Borrar todos los ajustes y reiniciar? +VOL %d VOL %d +VOL 80 VOL 80 +waiting for better sky view | %u in view esperando una mejor vista del cielo | %u a la vista +waiting for module GNSS compass esperando módulo GNSS brújula +waiting for traffic esperando tráfico +Waiting for host... Esperando host... +Waiting for Reticulum host... Esperando el host Reticulum... +WAIT IMU ESPERA IMU +Walkie not available Walkie no disponible +Walkie start failed Falló el inicio del walkie +Walkie Talkie Walkietalkie +YES SÍ +6 digits 6 dígitos +%u threads active %u subprocesos activos +%ud %ud +%uh %uh +%um %um +%umo %umo +%uy %uy +Confirm received code:\n Confirmar código recibido:\n +device heading from 9-axis module rumbo del dispositivo desde el módulo de 9 ejes +heading unlocks after first fix el título se desbloquea después de la primera corrección +If it matches, trust the key. Si coincide, confíe en la clave. +magnetometer heading active rumbo del magnetómetro activo +magnetometer still warming up el magnetómetro aún se está calentando +needle shows north until target lock la aguja muestra el norte hasta fijar el objetivo +nearest peer | %.0f m away par más cercano | %.0f m de distancia +no recent traffic no hay tráfico reciente +now ahora +%s layer missing Falta la capa %s +Active: %s Activo: %s +Alt: %.0f m Alt: %.0fm +Alt: -- m Alt: -- m +Base: %s Base: %s +Battery critical Batería crítica +Broadcast (%d) Difusión (%d) +COMPLETE COMPLETO +Calibrating... Calibrando... +Confirm Confirmar +Contour: OFF Contorno: APAGADO +Decoder error Error de decodificador +Direct (%d) Directo (%d) +E E +ERROR ERROR +Exiting USB... Saliendo de USB... +Hold Espera +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented Actualización de clave no implementada +Key verify: %s %s Verificación de clave: %s %s +Key verify: confirm %s %s Verificación de clave: confirmar %s %s +Key verify: enter number for %s Verificación de clave: ingrese el número para %s +Key verify: send %s %s Verificación de clave: enviar %s %s +Later Más tarde +Level %d Nivel %d +Listening for SSTV signal... Escuchando señal SSTV... +Low battery: %d%% Batería baja: %d%% +MODE: %s MODO: %s +MODE: Auto MODO: Automático +Map - %.48s Mapa - %.48s +Map - %s Missing Mapa - %s Falta +Map - No Map Data Mapa - Sin datos de mapa +Map - No SD Card Mapa - Sin tarjeta SD +Map - no gps data Mapa - sin datos GPS +Message Mensaje +Mic TBD Micrófono por determinar +Mic init failed Falló el inicio del micrófono +Morse Input Entrada Morse +MoveTo Mover a +N norte +NODE INFO INFORMACIÓN DEL NODO +Name required Nombre requerido +Name too long Nombre demasiado largo +No KML routes No hay rutas KML +No SD Card Sin tarjeta SD +No routes folder No hay carpeta de rutas +No tracks yet Aún no hay pistas +Only leader can manage Sólo el líder puede gestionar +Only leader can pair Sólo el líder puede emparejar +Paired successfully Emparejado exitosamente +Pairing failed Error en el emparejamiento +Pairing init failed Error al iniciar el emparejamiento +Pairing not available Emparejamiento no disponible +Pairing not ready Emparejamiento no listo +Press Back to exit USB mode Presione Atrás para salir del modo USB +Press SPACE to resume Presione ESPACIO para reanudar +RallyTo Reunirse a +Route configured Ruta configurada +Route: %s Ruta: %s +SD Card Not Found\nPlease insert SD card SD Tarjeta no encontrada\nPor favor inserte la tarjeta SD +SSTV RECEIVER RECEPTOR SSTV +Save failed Error al guardar +Scan +%u/%u Escanear +%u/%u +Select level Seleccionar nivel +Selected level: %d Nivel seleccionado: %d +Shutting down to protect battery Apagar para proteger la batería +Status: CALIB Estado: CALIB +Step 1/2: Tap short dots Paso 1/2: Toque los puntos cortos +Step 2/2: Tap long dashes Paso 2/2: Toca guiones largos +Switch to %s to chat Cambie a %s para chatear +Symbol: Símbolo: +Tap to input, idle 3s to send Toque para ingresar, inactivo 3 segundos para enviar +Team keys mismatch Las claves del equipo no coinciden +Team: Equipo: +Text: Texto: +Unread: %d No leído: %d +Unread: 0 No leído: 0 +W W +0° Horizon Horizonte 0° +PSK must be 32 hex or 16 chars PSK debe tener 32 hexadecimales o 16 caracteres +Restarting... Reiniciando... +awaiting position esperando posición +compass wakes after GPS fix la brújula se activa después de la corrección GPS +searching satellites buscando satélites +%s - %s - +%s 0s %s 0s +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO AUTOMÁTICO +Alt: %ld m Alt: %ldm +Alt: - Alt: - +BEST MEJOR +BW: %.0fk BW: %.0fk +BW: %.1fk BW: %.1fk +BW: - BW: - +CAD CAD +CURSOR CURSOR +Ch: %.3f Canal: %.3f +Ch: - Canal: - +Hop: %u Salto: %u +Hop: - Salto: - +Hop: - / NH: %02X Salto: - / NH: %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: Última escucha: +Link Enlace +Link / MeshCore Enlace / MeshCore +Link / Meshtastic Enlace / Meshtastic +MODE: RSSI MODO: RSSI +NOISE %.0f dBm RUIDO %.0f dBm +NOISE -104 dBm RUIDO -104 dBm +PKI: - PKI: - +PKI: known PKI: conocido +PKI: verified PKI: verificado +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI: %.0f dBm +RSSI: - RSSI: - +Role: %s Rol: %s +SCAN ESCANEAR +SF: %u SF: %u +SF: - SF: - +SIM SIM +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1f dB +SNR: - SNR: - +SNR %.0f SNR %.0f +SNR - SNR - +SNR Good SNR Bueno +SNR Weak SNR Débil +STEP %.0fk | BW %.0fk PASO %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk PASO %.1fk | BW %.1fk +STEP -- | BW -- PASO -- | BW -- +STOP DETENER +Slot Ranura +Slot %u Ranura %u +SUB-GHz SCAN ESCANEO SUB-GHz +Actions Acciones +Actions: %s Acciones: %s +Access to team data revoked Acceso a los datos del equipo revocado +Capability: Capacidad: +Channel Actions Acciones del canal +Confirm Kick Confirmar patada +Create Team Crear equipo +ELEV ELEV +Join Team Únase al equipo +In View En vista +Keep devices within 5m Mantenga los dispositivos a menos de 5 m +KeyDist DistClave +KeyId: %u ID de clave: %u +KeyId: -- ID de clave: -- +Keys Claves +Kick Patada +Kick Member Miembro expulsado +Last paired: %s Último par: %s +Last seen %uh ago Visto por última vez hace %uh +Last seen %um ago Visto por última vez hace %um +Last seen -- Visto por última vez -- +Last update %us ago Última actualización hace %us +Last update -- Última actualización-- +Last update 0s ago Última actualización hace 0s +Leader Líder +Leader online Líder en línea +Leave Salir +Leave team? ¿Dejar el equipo? +Manage Administrar +Member: %s Miembro: %s +Members Miembros +Members: %u Miembros: %u +Members: %u Online: %u Miembros: %u En línea: %u +Modem Bridge Puente de módem +No members yet Aún no hay miembros +Not Used No utilizado +Offline Sin conexión +Online En línea +Online: %u En línea: %u +Pair Again Emparejar de nuevo +Pair Member Miembro del par +Pairing in progress Emparejamiento en curso +Prev Anterior +Remove %s from team? ¿Eliminar %s del equipo? +Request Keys Solicitar claves +Retry Reintentar +Security Round: %u Ronda de seguridad: %u +Security Round: -- Ronda de seguridad: -- +Security: OK (Round %u) Seguridad: OK (Ronda %u) +Security: OK (Round --) Seguridad: OK (Ronda --) +Seen %ud Visto %ud +Seen %uh Visto %uh +Seen %um Visto %um +Send failed (keys not ready) Error al enviar (las claves no están listas) +Status: %s Estado: %s +Target: %s Destino: %s +Team / %s Equipo / %s +Team Actions Acciones del equipo +Team Health Salud del equipo +Team Status Estado del equipo +Team: %s Equipo: %s +This clears local keys. Esto borra las claves locales. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. Esto actualizará la ronda de seguridad.\nEl miembro ya no recibirá\nmensajes o puntos de referencia del equipo. +Transfer Transferir +Transfer Leader Líder de transferencia +View Team Ver equipo +Waiting for new keys... Esperando nuevas claves... +Waypoint Punto de referencia +You are no longer in this team Ya no estás en este equipo +You are not in a team No estás en un equipo +- No shared map\n- No team awareness - Sin mapa compartido\n- Sin conocimiento del equipo +1 member stale 1 miembro obsoleto +encrypt failed falló el cifrado +member miembro +queue full cola llena +unsupported protocol protocolo no compatible +Factory Reset Restablecimiento de fábrica +Clear all settings and restart? ¿Borrar todas las configuraciones y reiniciar? +Terrain Terreno +Satellite Satélite +Contour: ON Contorno: ENCENDIDO +Contour data missing Faltan datos de contorno +%d enabled %d habilitado +%s %s %s %s +Channel Key / PSK Clave de canal / PSK +Channel Slot Ranura de canal +Channel Utilization Utilización del canal +Check for Updates Buscar actualizaciones +Clear Message DB Borrar base de datos de mensajes +Connect Conectar +Contour Overlay Superposición de contorno +Coordinate Format Formato de coordenadas +Coordinate System Sistema de coordenadas +Current Version Versión actual +Detected Network Red detectada +Disconnect Desconectar +Duty Cycle Limit Límite del ciclo de trabajo +Enabled IMEs Habilitado IMEs +Encryption Mode Modo de cifrado +Freq Offset (MHz) Compensación de frecuencia (MHz) +GPS Enabled GPS Habilitado +Hop Limit Límite de saltos +IME setting update failed IME error en la actualización de la configuración +Install Update Instalar actualización +Latest Version Última versión +Layer Capa +Location Mode Modo de ubicación +MC Airtime Factor Factor de tiempo aire MC +MC Bandwidth (kHz) Ancho de banda MC (kHz) +MC Channel Key Tecla de canal MC +MC Channel Name Nombre del canal MC +MC Channel Slot Ranura de canal MC +MC Coding Rate Tasa de codificación MC +MC Flood Max Inundación MC máx. +MC Frequency (MHz) Frecuencia MC (MHz) +MC Multi ACKs MC Multi ACK +MC RX Delay Base MC RX Base de retardo +MC Region Preset Región MC preestablecida +MC Repeat Repetición MC +MC Spread Factor Factor de dispersión MC +MC TX Power MC TX Energía +Manual BW Manual BW +Manual CR Manual CR +Manual SF Manual SF +Map Source Fuente del mapa +Modem Preset Módem preestablecido +NMEA Export NMEA Exportar +NMEA Sentences NMEA Oraciones +No IME packs installed No hay paquetes IME instalados +No position available No hay puesto disponible +OTA Status OTA Estado +Override Duty Cycle Anular el ciclo de trabajo +Override Freq (MHz) Anular frecuencia (MHz) +Password Contraseña +Position Strategy Estrategia de posición +SSID SSID +Satellite Systems Sistema de satélitems +Scan Networks Escanear redes +TX Enabled TX Habilitado +TX Power TX Energía +Track Format Formato de pista +Track Interval Intervalo de seguimiento +Track Recording Grabación de pistas +Unavailable No disponible +Update Interval Intervalo de actualización +Use Preset Usar preajuste +Wi-Fi Enabled Wi-Fi Habilitado +Wi-Fi start failed Wi-Fi inicio fallido diff --git a/packs/europe-latin-ext/locales/fr/manifest.ini b/packs/europe-latin-ext/locales/fr/manifest.ini index 4efe3f0d..fbd1df52 100644 --- a/packs/europe-latin-ext/locales/fr/manifest.ini +++ b/packs/europe-latin-ext/locales/fr/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=fr display_name=French native_name=Français +translation_status=review ui_font_pack=latin-ext-eu content_font_pack=latin-ext-eu strings=strings.tsv diff --git a/packs/europe-latin-ext/locales/fr/strings.tsv b/packs/europe-latin-ext/locales/fr/strings.tsv index afee4ada..3a6bd26d 100644 --- a/packs/europe-latin-ext/locales/fr/strings.tsv +++ b/packs/europe-latin-ext/locales/fr/strings.tsv @@ -2,32 +2,83 @@ Action Action Add Ajouter Advanced Avancé Always Toujours -App Appli -Auto Auto +Altitude Altitude +Altitude Reference Réf. altitude +App Application +Area Cleared Zone dégagée +Auto Automatique +Awaiting teammate Attente équipier Back Retour +Base Camp Camp de base +Beijing (UTC+8) Pékin (UTC+8) +BLE bridge ready | %s Pont BLE prêt | %s +BLE linked | %s BLE lié | %s Bluetooth Bluetooth Bluetooth Pairing Appairage Bluetooth +Bcast Diffusion +Bridge error Erreur de pont +Broadcast Diffusion Cancel Annuler Channel Canal Chat Chat +Chat unavailable Chat indisponible Chinese Chinois +Closes automatically after pairing Ferme après appairage Close Fermer +Command Commande +Command: %s Commande : %s +Command: %s %s Commande : %s %s +Command: %s %s %s Commande : %s %s %s +Compare Verification Code Comparer le code +Compass Boussole +Connected Connecté +Connected, handshaking... Connecté, handshake... Contacts Contacts +Contacts (%s) Contacts (%s) +Contacts (LXMF) Contacts (LXMF) +Contacts (LXMF - %.3fMHz) Contacts (LXMF - %.3fMHz) +Contacts (MeshCore) Contacts (MeshCore) +Contacts (Meshtastic - %.3fMHz) Contacts (Meshtastic - %.3fMHz) +Contacts (RNode) Contacts (RNode) +Contacts (RNode - %.3fMHz) Contacts (RNode - %.3fMHz) +Conversation protocol mismatch Protocole incompatible Data Exchange Échange données +Debug Logs Journaux débogage +Decoding: %d%% Décodage : %d%% +Decoding: 0% Décodage : 0% Delete Supprimer +Delete %s? Supprimer %s ? +Delete contact %s? Supprimer contact %s ? +Delete failed Échec suppression +Device: %s Appareil : %s +Direct Direct Disabled Désactivé Discover Découvrir Display Language Langue d'affichage +Duplicate name not allowed Nom en double interdit Edit Modifier -Energy Sweep Balayage RF +Edit nickname Modifier surnom +Emergency SOS Urgence SOS +Energy Sweep Balayage énergie English Anglais English keyboard Clavier anglais +Enter nickname Saisir surnom +Enter number for %s Saisir numéro pour %s +Enter the 6-digit number Saisissez code à 6 chiffres +Enter this 6-digit PIN on your phone Saisissez PIN à 6 chiffres sur votre téléphone Error Erreur -Factory Reset Réinit. usine +Error: %lu Erreur : %lu +FSK Voice Voix FSK +Failed Échoué +Fix Fixer +Fixed PIN PIN fixe +Gauge Design (mAh) Capacité nominale (mAh) +Gauge Full (mAh) Capacité pleine (mAh) +Good Find Bonne trouvaille GPS GPS Extensions Extensions Language Langue -Package Details Détails du paquet +Package Details Détails paquet Back to list Retour à la liste Install Installer Update Mettre à jour @@ -35,7 +86,7 @@ Uninstall Désinstaller Incompatible Incompatible Compatible with this device Compatible avec cet appareil Compatibility: %s Compatibilité : %s -memory profile mismatch profil mémoire incompatible +memory profile mismatch Profil mémoire incompatible needs %s nécessite %s Update available: %s Mise à jour disponible : %s Installed Installé @@ -48,63 +99,520 @@ Installing package... Installation du paquet... Package installed Paquet installé Uninstalling package... Désinstallation du paquet... Package uninstalled Paquet désinstallé -No language packs available Aucun paquet de langue disponible -Available language packs: %lu Paquets de langue disponibles : %lu +No language packs available Aucun pack de langue disponible +Available language packs: %lu Packs de langue : %lu About À propos -Package Info Infos du paquet +Package Info Info paquet Contents Contenu Source Source -Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archive : %s\nRAM police supplémentaire : %s\nProfils : %s\n%s -Locales: %s\nFonts: %s\nIME: %s Langues : %s\nPolices : %s\nIME : %s -Author: %s\nHomepage: %s Auteur : %s\nPage d'accueil : %s +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archive : %s\nPolice supplémentaire RAM : %s\nProfils : %s\n%s +Locales: %s\nFonts: %s\nIME: %s Paramètres régionaux : %s\nPolices : %s\nIME : %s +Author: %s\nHomepage: %s Auteur : %s\nPage d'accueil : %s None Aucun GPS Status État GPS +HDOP HDOP +Heading starting Titre commençant Hide Masquer -Info Infos +Host bridge Pont hôte +Host connected, probing modem... Hôte connecté, sonde du modem... +IDLE IDLE +Ignored Ignoré +Ignore Ignorer +Ignore unavailable Ignorer indisponible +Ignore update failed Ignorer la mise à jour a échoué +Image received Image reçue +Info Informations +Initializing... Initialisation... +Invalid airtime factor Facteur de temps d'antenne non valide +Invalid design capacity (mAh) Capacité de conception invalide (mAh) +Invalid frequency offset Décalage de fréquence invalide +Invalid frequency value Valeur de fréquence invalide +Invalid full capacity (mAh) Pleine capacité invalide (mAh) +Invalid marker Marqueur invalide +Invalid MeshCore bandwidth Bande passante MeshCore non valide +Invalid MeshCore frequency Fréquence MeshCore invalide +Invalid number Numéro invalide +Invalid RX delay Délai RX non valide +Key marked trusted Clé marquée comme fiable +Key must be 32 hex or 16 chars La clé doit être de 32 hexadécimaux ou 16 caractères +Key trust failed Échec de l'approbation de clé +Key Verification Vérification des clés +LINKED LIÉ +LISTEN ECOUTER +LIVE EN DIRECT Loading... Chargement... +Location Localisation +Location: %s Emplacement : %s +Location: %s %s Emplacement : %s %s +LoRa direct path | %s Chemin direct LoRa | %s +Low battery - audio disabled Batterie faible - audio désactivé Map Carte Map Layer Couche de carte +MESH MAILLE +Mesh Status État du maillage +Mesh unavailable Maillage indisponible +MeshCore only MeshCore uniquement +Message DB cleared DB de messages effacé +Messages Messages +MESSAGES MESSAGES +Messages and broadcasts show here Les messages et les diffusions s'affichent ici +Mini Compass Mini Boussole +MT send uses slot 0/1 only L'envoi MT utilise uniquement l'emplacement 0/1 +N/A N/A Nearby À proximité +Need local GPS Besoin local GPS Network Réseau New Nouveau +new activity nouvelle activité +Next Suivant +No GPS fix Aucun correctif GPS +No image Aucune image No messages Aucun message +No preview Aucun aperçu No recent traffic Aucun trafic récent +No response Aucune réponse +No route selected Aucun itinéraire sélectionné +No target Aucune cible +No track files Aucun fichier de piste +Node DB reset Réinitialisation du DB du nœud +Node ignored Nœud ignoré +Node ignored and hidden Nœud ignoré et masqué +Node unignored Nœud non ignoré +North locked Nord verrouillé Not set Non défini +NO NON +NO FIX AUCUN CORRECTIF Number keyboard Clavier numérique -OFF OFF +OFF DÉSACTIVÉ OK OK -ON ON +ON ACTIVÉ +online en ligne Pairing Appairage PC Link Lien PC -Power Alimentation +PC Link is unavailable on this target. PC Link n'est pas disponible sur cette cible. +peer lock | turn %03.0f deg verrouillage par les pairs | tourner à %03.0f degrés +Pinyin keyboard Clavier Pinyin +Pinyin: %s Pinyin : %s +Position Poste +position stream active | %u in view flux de positions actif | %u en vue +Power Puissance +Press Back Appuyez sur Retour +Press RX to start Appuyez sur RX pour démarrer +Primary Primaire Protocol Protocole +Protocol switch failed Échec du changement de protocole +Protocol switched Protocole commuté +quiet net filet silencieux +Random PIN Code PIN aléatoire Ready Prêt +RECEIVING RÉCEPTION Recent Activity Activité récente +Record Enregistrement +Recording Enregistrement +Region Région +Remain: %u Reste : %u Reply Répondre +Reply disabled for this protocol Réponse désactivée pour ce protocole +Reset Mesh Profiles Réinitialiser les profils de maillage +Reset Node DB Réinitialiser le DB de nœud +Resetting... Réinitialisation... +Return Retour +Rally Point Point de ralliement +RNode Bridge RNode Pont +RNode modem ready RNode modem prêt +RNode text chat runs on host RNode le chat textuel s'exécute sur l'hôte +Route Itinéraire Run Exécuter +RX RX +RX: %lu TX: %lu RX : %lu TX : %lu +RX: 0 TX: 0 RX : 0 TX : 0 Save Enregistrer -Scan failed Échec du scan -Scan Local Scan local -Scanning 5s... Scan 5 s... +Saved: %s Enregistré : %s +Scan failed Échec de l'analyse +Scan Local Analyse locale +Scanning 5s... Numérisation 5s... +Screen Brightness Luminosité de l'écran +Screen Timeout Expiration de l'écran SEARCH RECHERCHE -Screen Brightness Luminosité écran -Screen Timeout Veille écran +Secondary Secondaire +Select a route Sélectionnez un itinéraire +Select a track Sélectionnez une piste +Select marker Sélectionner le marqueur +Select Track Sélectionner une piste +Selected: %s Sélectionné : %s +Send Envoyer +Send failed Échec de l'envoi +Send this code and compare:\n Envoyez ce code et comparez :\n +Send ID Broadcast Envoyer la diffusion ID +Send ID Local Envoyer ID Local +Sending... Envoi en cours... +Sent Envoyé Settings Paramètres -Setting Réglage -Sky Plot Ciel GPS -Speaker Volume Volume haut-parleur +Setting Paramètre +Share Position Marker Partager le marqueur de position +Share this number:\n Partagez ce numéro :\n +Short Name Nom abrégé +SOLO SOLO +Speaker Volume Volume du haut-parleur +Sky Plot Parcelle du ciel +SSTV RX READY SSTV RX PRÊT +SSTV start failed Le démarrage du SSTV a échoué +standing by en attente Status Statut Stopped Arrêté Stop Arrêter -Submit Valider +Stop recording first Arrêtez d'abord l'enregistrement +Submit Soumettre +Submit failed Échec de la soumission System Système +SATELLITE STATUS ÉTAT DU SATELLITE +Talk Parler +TALK PARLER Team Équipe -Team Status État équipe +TEAM ÉQUIPE +Team chat send failed L'envoi du chat d'équipe a échoué +Team chat unavailable Chat d'équipe indisponible +Team chat unavailable in RNode mode Chat d'équipe indisponible en mode RNode +team member position will appear here le poste du membre de l'équipe apparaîtra ici +Team keys not ready Les clés d'équipe ne sont pas prêtes +Team location encode failed L'encodage de l'emplacement de l'équipe a échoué +Text chat unavailable Chat textuel indisponible +threads active fils de discussion actifs Time Zone Fuseau horaire -Tracker Suivi +Tracker Traqueur +traffic active trafic actif +Trust Key Clé de confiance +U U +Unignore Ne plus ignorer +Unread Non lu Unknown Inconnu -USB Disk Disque USB +USB Mass Storage USB Stockage de masse +USB Disk USB Disque +USB Bridge USB Pont +USB host bridge USB pont hôte +USB Bridge is unavailable on this target. USB Bridge n'est pas disponible sur cette cible. +USB CDC KISS modem for Reticulum USB Modem CDC KISS pour Reticulum +USE UTILISATION +Used Utilisé User Name Nom d'utilisateur Verification Number Numéro de vérification -Vibration Vibration -Clear all settings and restart? Effacer tous les réglages et redémarrer ? +Verification number sent Numéro de vérification envoyé +Vibration Vibrations +VOL %d VOL %d +VOL 80 VOL 80 +waiting for better sky view | %u in view en attendant une meilleure vue du ciel | %u en vue +waiting for module GNSS compass en attente du module GNSS boussole +waiting for traffic en attente de trafic +Waiting for host... En attente de l'hôte... +Waiting for Reticulum host... En attente de l'hôte Reticulum... +WAIT IMU ATTENDRE IMU +Walkie not available Walkie non disponible +Walkie start failed Échec du démarrage du Walkie +Walkie Talkie Talkie-walkie +YES OUI +6 digits 6 chiffres +%u threads active %u fils de discussion actifs +%ud %ud +%uh %uh +%um %um +%umo %umo +%uy %uy +Confirm received code:\n Confirmer le code reçu :\n +device heading from 9-axis module rubrique appareil du module 9 axes +heading unlocks after first fix l'en-tête se déverrouille après le premier correctif +If it matches, trust the key. Si cela correspond, faites confiance à la clé. +magnetometer heading active cap magnétomètre actif +magnetometer still warming up magnétomètre encore en train de chauffer +needle shows north until target lock l'aiguille indique le nord jusqu'au verrouillage de la cible +nearest peer | %.0f m away homologue le plus proche | À %.0f m +no recent traffic aucun trafic récent +now maintenant +%s layer missing %s couche manquante +Active: %s Actif : %s +Alt: %.0f m Alt : %.0f m +Alt: -- m Alt : -- m +Base: %s Base : %s +Battery critical Batterie critique +Broadcast (%d) Diffusion (%d) +COMPLETE COMPLET +Calibrating... Calibrage... +Confirm Confirmer +Contour: OFF Contour : OFF +Decoder error Erreur du décodeur +Direct (%d) Direct (%d) +E E +ERROR ERREUR +Exiting USB... Sortie de USB... +Hold Maintenir +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented Actualisation des clés non implémentée +Key verify: %s %s Vérification de la clé : %s %s +Key verify: confirm %s %s Vérification de la clé : confirmer %s %s +Key verify: enter number for %s Vérification de la clé : entrez le numéro pour %s +Key verify: send %s %s Vérification de la clé : envoyer %s %s +Later Plus tard +Level %d Niveau %d +Listening for SSTV signal... Écoute du signal SSTV... +Low battery: %d%% Batterie faible : %d%% +MODE: %s MODE : %s +MODE: Auto MODE : Automatique +Map - %.48s Carte - %.48s +Map - %s Missing Carte - %s Manquante +Map - No Map Data Carte - Aucune donnée cartographique +Map - No SD Card Carte - Pas de carte SD +Map - no gps data Carte - pas de données GPS +Message Message +Mic TBD Micro à déterminer +Mic init failed L'initialisation du micro a échoué +Morse Input Entrée Morse +MoveTo Déplacer vers +N N +NODE INFO INFORMATIONS SUR LE NŒUD +Name required Nom requis +Name too long Nom trop long +No KML routes Aucune route KML +No SD Card Non SD Carte +No routes folder Aucun dossier de routes +No tracks yet Aucune piste pour l'instant +Only leader can manage Seul le leader peut gérer +Only leader can pair Seul le leader peut s'associer +Paired successfully Couplé avec succès +Pairing failed Échec du couplage +Pairing init failed Échec de l'initialisation du couplage +Pairing not available Couplage non disponible +Pairing not ready L'appairage n'est pas prêt +Press Back to exit USB mode Appuyez sur Retour pour quitter le mode USB +Press SPACE to resume Appuyez sur ESPACE pour reprendre +RallyTo RallyeÀ +Route configured Itinéraire configuré +Route: %s Itinéraire : %s +SD Card Not Found\nPlease insert SD card SD Carte introuvable\nVeuillez insérer la carte SD +SSTV RECEIVER RÉCEPTEUR SSTV +Save failed Échec de l'enregistrement +Scan +%u/%u Scanner +%u/%u +Select level Sélectionner le niveau +Selected level: %d Niveau sélectionné : %d +Shutting down to protect battery Arrêt pour protéger la batterie +Status: CALIB Statut : CALIB +Step 1/2: Tap short dots Étape 1/2 : Appuyez sur des points courts +Step 2/2: Tap long dashes Étape 2/2 : Appuyez sur de longs tirets +Switch to %s to chat Passez à %s pour discuter +Symbol: Symbole : +Tap to input, idle 3s to send Appuyez pour saisir, inactif 3 secondes pour envoyer +Team keys mismatch Incompatibilité des clés d'équipe +Team: Équipe : +Text: Texte : +Unread: %d Non lu : %d +Unread: 0 Non lu : 0 +W W +0° Horizon Horizon 0° +PSK must be 32 hex or 16 chars PSK doit être de 32 hexadécimaux ou 16 caractères +Restarting... Redémarrage... +awaiting position position en attente +compass wakes after GPS fix la boussole se réveille après le correctif GPS +searching satellites recherche de satellites +%s - %s - +%s 0s %s 0s +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO AUTOMATIQUE +Alt: %ld m Alt : %ld m +Alt: - Alt : - +BEST MEILLEUR +BW: %.0fk BW : %.0fk +BW: %.1fk BW : %.1fk +BW: - BW : - +CAD CAD +CURSOR CURSEUR +Ch: %.3f Ch : %.3f +Ch: - Ch : - +Hop: %u Saut : %u +Hop: - Saut : - +Hop: - / NH: %02X Hop : - / NH : %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: Dernière écoute : +Link Lien +Link / MeshCore Lien / MeshCore +Link / Meshtastic Lien / Meshtastic +MODE: RSSI MODE : RSSI +NOISE %.0f dBm BRUIT %.0f dBm +NOISE -104 dBm BRUIT -104 dBm +PKI: - PKI: - +PKI: known PKI : connu +PKI: verified PKI : vérifié +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI: %.0f dBm +RSSI: - RSSI: - +Role: %s Rôle : %s +SCAN NUMÉRISATION +SF: %u SF: %u +SF: - SF : - +SIM SIM +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR : %.1f dB +SNR: - SNR: - +SNR %.0f SNR %.0f +SNR - SNR - +SNR Good SNR Bon +SNR Weak SNR Faible +STEP %.0fk | BW %.0fk ÉTAPE %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk ÉTAPE %.1fk | BW %.1fk +STEP -- | BW -- ÉTAPE -- | BW -- +STOP ARRÊTER +Slot Emplacement +Slot %u Emplacement %u +SUB-GHz SCAN BALAYAGE SUB-GHz +Actions Actions +Actions: %s Actions : %s +Access to team data revoked Accès aux données de l'équipe révoqué +Capability: Capacité : +Channel Actions Actions de canal +Confirm Kick Confirmer le coup de pied +Create Team Créer une équipe +ELEV ÉLEV +Join Team Rejoignez l'équipe +In View En vue +Keep devices within 5m Gardez les appareils à moins de 5 m +KeyDist KeyDist +KeyId: %u ID de clé : %u +KeyId: -- ID de clé : -- +Keys Clés +Kick Coup de pied +Kick Member Membre Kick +Last paired: %s Dernier couplage : %s +Last seen %uh ago Dernière vue il y a %uh +Last seen %um ago Dernière vue il y a %um +Last seen -- Dernière vue -- +Last update %us ago Dernière mise à jour il y a %us +Last update -- Dernière mise à jour -- +Last update 0s ago Dernière mise à jour il y a 0s +Leader Chef +Leader online Leader en ligne +Leave Quitter +Leave team? Quitter l'équipe ? +Manage Gérer +Member: %s Membre : %s +Members Membres +Members: %u Membres : %u +Members: %u Online: %u Membres : %u En ligne : %u +Modem Bridge Pont modem +No members yet Pas encore de membres +Not Used Non utilisé +Offline Hors ligne +Online En ligne +Online: %u En ligne : %u +Pair Again Associez à nouveau +Pair Member Membre de la paire +Pairing in progress Appairage en cours +Prev Précédent +Remove %s from team? Supprimer %s de l'équipe ? +Request Keys Demander des clés +Retry Réessayer +Security Round: %u Cycle de sécurité : %u +Security Round: -- Cycle de sécurité : -- +Security: OK (Round %u) Sécurité : OK (Ronde %u) +Security: OK (Round --) Sécurité : OK (Rond --) +Seen %ud Vu %ud +Seen %uh Vu %uh +Seen %um Vu %um +Send failed (keys not ready) Échec de l'envoi (clés non prêtes) +Status: %s Statut : %s +Target: %s Cible : %s +Team / %s Équipe / %s +Team Actions Actions d'équipe +Team Health Santé de l'équipe +Team Status Statut de l'équipe +Team: %s Équipe : %s +This clears local keys. Ceci efface les clés locales. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. Cela mettra à jour le cycle de sécurité.\nLe membre ne recevra plus de \nmessages d'équipe ou waypoints. +Transfer Transfert +Transfer Leader Responsable des transferts +View Team Voir l'équipe +Waiting for new keys... En attente de nouvelles clés... +Waypoint Point de cheminement +You are no longer in this team Vous n'êtes plus dans cette équipe +You are not in a team Vous n'êtes pas dans une équipe +- No shared map\n- No team awareness - Aucune carte partagée\n- Aucune sensibilisation de l'équipe +1 member stale 1 membre obsolète +encrypt failed échec du chiffrement +member membre +queue full file d'attente pleine +unsupported protocol protocole non pris en charge +Factory Reset Réinitialisation d'usine +Clear all settings and restart? Effacer tous les paramètres et redémarrer ? +Terrain Terrain +Satellite Satellite +Contour: ON Contour : ON +Contour data missing Données de contour manquantes +%d enabled %d activé +%s %s %s %s +Channel Key / PSK Clé de canal / PSK +Channel Slot Emplacement de canal +Channel Utilization Utilisation des canaux +Check for Updates Vérifier les mises à jour +Clear Message DB Effacer la base de données de messages +Connect Se connecter +Contour Overlay Superposition de contours +Coordinate Format Format des coordonnées +Coordinate System Système de coordonnées +Current Version Version actuelle +Detected Network Réseau détecté +Disconnect Déconnecter +Duty Cycle Limit Limite du cycle de service +Enabled IMEs Activé IMEs +Encryption Mode Mode de cryptage +Freq Offset (MHz) Décalage de fréquence (MHz) +GPS Enabled GPS Activé +Hop Limit Limite de saut +IME setting update failed IME échec de la mise à jour des paramètres +Install Update Installer la mise à jour +Latest Version Dernière version +Layer Couche +Location Mode Mode Localisation +MC Airtime Factor Facteur de temps d'antenne MC +MC Bandwidth (kHz) Bande passante MC (kHz) +MC Channel Key Clé de canal MC +MC Channel Name Nom du canal MC +MC Channel Slot Emplacement pour canal MC +MC Coding Rate Taux de codage MC +MC Flood Max MC Flood Max +MC Frequency (MHz) Fréquence MC (MHz) +MC Multi ACKs MC Multi ACK +MC RX Delay Base MC RX Base de retard +MC Region Preset Préréglage de région MC +MC Repeat Répétition MC +MC Spread Factor Facteur de propagation MC +MC TX Power MC TX Puissance +Manual BW Manuel BW +Manual CR Manuel CR +Manual SF Manuel SF +Map Source Source de la carte +Modem Preset Préréglage du modem +NMEA Export NMEA Exporter +NMEA Sentences NMEA Phrases +No IME packs installed Aucun pack IME installé +No position available Aucun poste disponible +OTA Status OTA Statut +Override Duty Cycle Annuler le cycle de service +Override Freq (MHz) Remplacer la fréquence (MHz) +Password Mot de passe +Position Strategy Stratégie de positionnement +SSID SSID +Satellite Systems Système satellitems +Scan Networks Scanner les réseaux +TX Enabled TX Activé +TX Power TX Puissance +Track Format Format de piste +Track Interval Intervalle de suivi +Track Recording Enregistrement de piste +Unavailable Indisponible +Update Interval Intervalle de mise à jour +Use Preset Utiliser le préréglage +Wi-Fi Enabled Wi-Fi Activé +Wi-Fi start failed Wi-Fi échec du démarrage diff --git a/packs/europe-latin-ext/locales/it/manifest.ini b/packs/europe-latin-ext/locales/it/manifest.ini index 42101e44..4217e6de 100644 --- a/packs/europe-latin-ext/locales/it/manifest.ini +++ b/packs/europe-latin-ext/locales/it/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=it display_name=Italian native_name=Italiano +translation_status=review ui_font_pack=latin-ext-eu content_font_pack=latin-ext-eu strings=strings.tsv diff --git a/packs/europe-latin-ext/locales/it/strings.tsv b/packs/europe-latin-ext/locales/it/strings.tsv index 8663e0ad..f65e9f0a 100644 --- a/packs/europe-latin-ext/locales/it/strings.tsv +++ b/packs/europe-latin-ext/locales/it/strings.tsv @@ -1,46 +1,97 @@ Action Azione Add Aggiungi -Advanced Avanzato +Advanced Avanzate Always Sempre +Altitude Altitudine +Altitude Reference Rif. altitudine App App -Auto Auto +Area Cleared Area liberata +Auto Automatico +Awaiting teammate Attesa compagno Back Indietro +Base Camp Campo base +Beijing (UTC+8) Pechino (UTC+8) +BLE bridge ready | %s Ponte BLE pronto | %s +BLE linked | %s BLE collegato | %s Bluetooth Bluetooth -Bluetooth Pairing Associazione Bluetooth +Bluetooth Pairing Accoppia Bluetooth +Bcast Broadcast +Bridge error Errore ponte +Broadcast Trasmesso Cancel Annulla Channel Canale -Chat Chat +Chat Chatta +Chat unavailable Chat non disponibile Chinese Cinese +Closes automatically after pairing Chiude dopo accoppiamento Close Chiudi +Command Comando +Command: %s Comando: %s +Command: %s %s Comando: %s %s +Command: %s %s %s Comando: %s %s %s +Compare Verification Code Confronta codice +Compass Bussola +Connected Connesso +Connected, handshaking... Connesso, handshake... Contacts Contatti +Contacts (%s) Contatti (%s) +Contacts (LXMF) Contatti (LXMF) +Contacts (LXMF - %.3fMHz) Contatti (LXMF - %.3fMHz) +Contacts (MeshCore) Contatti (MeshCore) +Contacts (Meshtastic - %.3fMHz) Contatti (Meshtastic - %.3fMHz) +Contacts (RNode) Contatti (RNode) +Contacts (RNode - %.3fMHz) Contatti (RNode - %.3fMHz) +Conversation protocol mismatch Protocollo non corrisponde Data Exchange Scambio dati +Debug Logs Log di debug +Decoding: %d%% Decodifica: %d%% +Decoding: 0% Decodifica: 0% Delete Elimina -Disabled Disattivato +Delete %s? Eliminare %s? +Delete contact %s? Eliminare contatto %s? +Delete failed Eliminazione fallita +Device: %s Dispositivo: %s +Direct Diretto +Disabled Disabilitato Discover Scopri Display Language Lingua display +Duplicate name not allowed Nome duplicato non consentito Edit Modifica -Energy Sweep Scansione RF +Edit nickname Modifica soprannome +Emergency SOS Emergenza SOS +Energy Sweep Scansione energia English Inglese English keyboard Tastiera inglese +Enter nickname Inserisci soprannome +Enter number for %s Inserisci numero per %s +Enter the 6-digit number Inserisci numero a 6 cifre +Enter this 6-digit PIN on your phone Inserisci PIN a 6 cifre sul telefono Error Errore -Factory Reset Riprist. fabbrica +Error: %lu Errore: %lu +FSK Voice Voce FSK +Failed Fallito +Fix Fissa +Fixed PIN PIN fisso +Gauge Design (mAh) Capacità progetto (mAh) +Gauge Full (mAh) Capacità piena (mAh) +Good Find Buon ritrovamento GPS GPS Extensions Estensioni Language Lingua Package Details Dettagli pacchetto -Back to list Torna all'elenco +Back to list Torna alla lista Install Installa Update Aggiorna Uninstall Disinstalla Incompatible Incompatibile -Compatible with this device Compatibile con questo dispositivo +Compatible with this device Compatibile con dispositivo Compatibility: %s Compatibilità: %s -memory profile mismatch profilo memoria incompatibile +memory profile mismatch Profilo memoria non corrisponde needs %s richiede %s Update available: %s Aggiornamento disponibile: %s Installed Installato Installed: %s Installato: %s -Available to install Disponibile per l'installazione +Available to install Disponibile per installazione Blocked Bloccato %lu locales %lu fonts %lu IME %s %lu lingue %lu font %lu IME %s No Wi-Fi Nessun Wi-Fi @@ -48,63 +99,520 @@ Installing package... Installazione pacchetto... Package installed Pacchetto installato Uninstalling package... Disinstallazione pacchetto... Package uninstalled Pacchetto disinstallato -No language packs available Nessun pacchetto lingua disponibile -Available language packs: %lu Pacchetti lingua disponibili: %lu +No language packs available Nessun pacchetto lingua +Available language packs: %lu Pacchetti lingua: %lu About Informazioni Package Info Info pacchetto -Contents Contenuto +Contents Contenuti Source Origine -Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archivio: %s\nRAM font extra: %s\nProfili: %s\n%s -Locales: %s\nFonts: %s\nIME: %s Lingue: %s\nFont: %s\nIME: %s +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archivio: %s\nCarattere extra RAM: %s\nProfili: %s\n%s +Locales: %s\nFonts: %s\nIME: %s Impostazioni locali: %s\nFont: %s\nIME: %s Author: %s\nHomepage: %s Autore: %s\nHomepage: %s None Nessuno GPS Status Stato GPS +HDOP HDOP +Heading starting Inizio della rotta Hide Nascondi -Info Info +Host bridge Bridge host +Host connected, probing modem... Host connesso, sondaggio modem... +IDLE IDLE +Ignored Ignorato +Ignore Ignora +Ignore unavailable Ignora non disponibile +Ignore update failed Ignora aggiornamento non riuscito +Image received Immagine ricevuta +Info Informazioni +Initializing... Inizializzazione in corso... +Invalid airtime factor Fattore tempo di trasmissione non valido +Invalid design capacity (mAh) Capacità di progetto non valida (mAh) +Invalid frequency offset Offset di frequenza non valido +Invalid frequency value Valore di frequenza non valido +Invalid full capacity (mAh) Capacità totale non valida (mAh) +Invalid marker Indicatore non valido +Invalid MeshCore bandwidth Larghezza di banda MeshCore non valida +Invalid MeshCore frequency Frequenza MeshCore non valida +Invalid number Numero non valido +Invalid RX delay Ritardo RX non valido +Key marked trusted Chiave contrassegnata come attendibile +Key must be 32 hex or 16 chars La chiave deve essere di 32 esadecimali o 16 caratteri +Key trust failed Attendibilità della chiave non riuscita +Key Verification Verifica chiave +LINKED COLLEGATO +LISTEN ASCOLTA +LIVE IN DIRETTA Loading... Caricamento... +Location Posizione +Location: %s Posizione: %s +Location: %s %s Posizione: %s %s +LoRa direct path | %s Percorso diretto LoRa | %s +Low battery - audio disabled Batteria scarica - audio disabilitato Map Mappa Map Layer Livello mappa -Nearby Vicino +MESH RETE +Mesh Status Stato mesh +Mesh unavailable Mesh non disponibile +MeshCore only Solo MeshCore +Message DB cleared DB messaggi cancellato +Messages Messaggi +MESSAGES MESSAGGI +Messages and broadcasts show here I messaggi e le trasmissioni vengono visualizzati qui +Mini Compass Mini bussola +MT send uses slot 0/1 only L'invio MT utilizza solo lo slot 0/1 +N/A N/D +Nearby Nelle vicinanze +Need local GPS Serve GPS locale Network Rete New Nuovo +new activity nuova attività +Next Avanti +No GPS fix Nessuna correzione GPS +No image Nessuna immagine No messages Nessun messaggio +No preview Nessuna anteprima No recent traffic Nessun traffico recente +No response Nessuna risposta +No route selected Nessun percorso selezionato +No target Nessun bersaglio +No track files Nessun file di traccia +Node DB reset Reimpostazione del DB del nodo +Node ignored Nodo ignorato +Node ignored and hidden Nodo ignorato e nascosto +Node unignored Nodo non ignorato +North locked Nord bloccato Not set Non impostato +NO NO +NO FIX NESSUNA CORREZIONE Number keyboard Tastiera numerica -OFF OFF +OFF SPENTO OK OK -ON ON +ON ATTIVO +online in linea Pairing Associazione -PC Link Link PC -Power Alimentazione +PC Link Collegamento PC +PC Link is unavailable on this target. PC Link non è disponibile su questa destinazione. +peer lock | turn %03.0f deg blocco peer | gira %03.0f gradi +Pinyin keyboard Tastiera Pinyin +Pinyin: %s Pinyin: %s +Position Posizione +position stream active | %u in view flusso di posizione attivo | %u in vista +Power Potenza +Press Back Premere Indietro +Press RX to start Premi RX per iniziare +Primary Primario Protocol Protocollo +Protocol switch failed Cambio di protocollo non riuscito +Protocol switched Protocollo cambiato +quiet net rete tranquilla +Random PIN PIN casuale Ready Pronto -Recent Activity Attività recenti +RECEIVING RICEZIONE +Recent Activity Attività recente +Record Registra +Recording Registrazione +Region Regione +Remain: %u Rimangono: %u Reply Rispondi +Reply disabled for this protocol Risposta disabilitata per questo protocollo +Reset Mesh Profiles Reimposta profili mesh +Reset Node DB Reimposta nodo DB +Resetting... Ripristino... +Return Ritorno +Rally Point Punto di ritrovo +RNode Bridge RNode Ponte +RNode modem ready RNode modem pronto +RNode text chat runs on host RNode la chat di testo viene eseguita sull'host +Route Itinerario Run Esegui +RX RX +RX: %lu TX: %lu RX: %lu TX: %lu +RX: 0 TX: 0 RX: 0 TX: 0 Save Salva -Scan failed Scansione fallita +Saved: %s Salvato: %s +Scan failed Scansione non riuscita Scan Local Scansione locale -Scanning 5s... Scansione 5 s... +Scanning 5s... Scansione 5s... +Screen Brightness Luminosità dello schermo +Screen Timeout Timeout dello schermo SEARCH CERCA -Screen Brightness Luminosità schermo -Screen Timeout Timeout schermo +Secondary Secondario +Select a route Selezionare un percorso +Select a track Seleziona una traccia +Select marker Seleziona contrassegno +Select Track Seleziona Traccia +Selected: %s Selezionato: %s +Send Invia +Send failed Invio non riuscito +Send this code and compare:\n Invia questo codice e confronta:\n +Send ID Broadcast Invia ID Trasmetti +Send ID Local Invia ID Locale +Sending... Invio... +Sent Inviato Settings Impostazioni Setting Impostazione -Sky Plot Cielo GPS +Share Position Marker Condividi indicatore di posizione +Share this number:\n Condividi questo numero:\n +Short Name Nome breve +SOLO SOLO Speaker Volume Volume altoparlante +Sky Plot Trama del cielo +SSTV RX READY SSTV RX PRONTO +SSTV start failed Avvio SSTV non riuscito +standing by in attesa Status Stato Stopped Arrestato -Stop Ferma +Stop Interrompi +Stop recording first Interrompere prima la registrazione Submit Invia +Submit failed Invio non riuscito System Sistema +SATELLITE STATUS STATO SATELLITE +Talk Parla +TALK PARLA Team Squadra -Team Status Stato squadra +TEAM SQUADRA +Team chat send failed Invio della chat di gruppo non riuscito +Team chat unavailable Chat di squadra non disponibile +Team chat unavailable in RNode mode Chat di squadra non disponibile in modalità RNode +team member position will appear here la posizione del membro del team verrà visualizzata qui +Team keys not ready Chiavi team non pronte +Team location encode failed Codifica della posizione del team non riuscita +Text chat unavailable Chat di testo non disponibile +threads active thread attivi Time Zone Fuso orario -Tracker Tracciamento +Tracker Localizzatore +traffic active traffico attivo +Trust Key Chiave attendibile +U U +Unignore Non ignorare +Unread Non letto Unknown Sconosciuto -USB Disk Disco USB +USB Mass Storage USB Memoria di massa +USB Disk USB Disco +USB Bridge USB Ponte +USB host bridge USB ponte host +USB Bridge is unavailable on this target. USB Bridge non è disponibile su questa destinazione. +USB CDC KISS modem for Reticulum USB Modem CDC KISS per Reticulum +USE USO +Used Usato User Name Nome utente Verification Number Numero di verifica +Verification number sent Numero di verifica inviato Vibration Vibrazione +VOL %d VOL %d +VOL 80 VOL 80 +waiting for better sky view | %u in view in attesa di una migliore visione del cielo | %u in vista +waiting for module GNSS compass in attesa del modulo GNSS bussola +waiting for traffic in attesa di traffico +Waiting for host... In attesa dell'host... +Waiting for Reticulum host... In attesa dell'host Reticulum... +WAIT IMU ATTENDERE IMU +Walkie not available Walkie't non disponibile +Walkie start failed Avvio del walkie-talkie non riuscito +Walkie Talkie Walkie-talkie +YES SI +6 digits 6 cifre +%u threads active %u thread attivi +%ud %ud +%uh %uh +%um %um +%umo %umo +%uy %uy +Confirm received code:\n Conferma il codice ricevuto:\n +device heading from 9-axis module intestazione dispositivo da modulo 9 assi +heading unlocks after first fix l'intestazione si sblocca dopo la prima correzione +If it matches, trust the key. Se corrisponde, fidarsi della chiave. +magnetometer heading active rotta magnetometrica attiva +magnetometer still warming up magnetometro ancora in fase di riscaldamento +needle shows north until target lock l'ago mostra il nord fino al bloccaggio del bersaglio +nearest peer | %.0f m away peer più vicino | %.0f m di distanza +no recent traffic nessun traffico recente +now adesso +%s layer missing %s livello mancante +Active: %s Attivo: %s +Alt: %.0f m Alt: %.0f m +Alt: -- m Alt: -- m +Base: %s Base: %s +Battery critical Batteria critica +Broadcast (%d) Trasmissione (%d) +COMPLETE COMPLETO +Calibrating... Calibrazione... +Confirm Conferma +Contour: OFF Contorno: OFF +Decoder error Errore del decodificatore +Direct (%d) Diretto (%d) +E E +ERROR ERRORE +Exiting USB... Uscita da USB... +Hold Aspetta +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented Aggiornamento chiave non implementato +Key verify: %s %s Verifica chiave: %s %s +Key verify: confirm %s %s Verifica chiave: conferma %s %s +Key verify: enter number for %s Verifica chiave: inserire il numero per %s +Key verify: send %s %s Verifica chiave: invia %s %s +Later Più tardi +Level %d Livello %d +Listening for SSTV signal... Ascolto del segnale SSTV... +Low battery: %d%% Batteria scarica: %d%% +MODE: %s MODALITÀ: %s +MODE: Auto MODALITÀ: Auto +Map - %.48s Mappa - %.48s +Map - %s Missing Mappa - %s Mancante +Map - No Map Data Mappa - Nessun dato mappa +Map - No SD Card Mappa - Nessuna SD Carta +Map - no gps data Mappa: nessun dato GPS +Message Messaggio +Mic TBD Microfono da definire +Mic init failed Inizializzazione microfono non riuscita +Morse Input Ingresso Morse +MoveTo Sposta in +N N +NODE INFO INFORMAZIONI NODO +Name required Nome obbligatorio +Name too long Nome troppo lungo +No KML routes Nessun percorso KML +No SD Card No SD Carta +No routes folder Nessuna cartella di percorsi +No tracks yet Ancora nessuna traccia +Only leader can manage Solo il leader può gestire +Only leader can pair Solo il leader può accoppiarsi +Paired successfully Accoppiato correttamente +Pairing failed Associazione non riuscita +Pairing init failed Associazione init non riuscita +Pairing not available Associazione non disponibile +Pairing not ready Associazione non pronta +Press Back to exit USB mode Premi Indietro per uscire dalla modalità USB +Press SPACE to resume Premere SPAZIO per riprendere +RallyTo RallyTo +Route configured Itinerario configurato +Route: %s Percorso: %s +SD Card Not Found\nPlease insert SD card SD Carta non trovata\nInserisci la carta SD +SSTV RECEIVER RICEVITORE SSTV +Save failed Salvataggio non riuscito +Scan +%u/%u Scansiona +%u/%u +Select level Seleziona il livello +Selected level: %d Livello selezionato: %d +Shutting down to protect battery Spegnimento per proteggere la batteria +Status: CALIB Stato: CALIB +Step 1/2: Tap short dots Passaggio 1/2: toccare i punti brevi +Step 2/2: Tap long dashes Passaggio 2/2: toccare i trattini lunghi +Switch to %s to chat Passa a %s per chattare +Symbol: Simbolo: +Tap to input, idle 3s to send Toccare per inserire, 3 secondi inattivi per inviare +Team keys mismatch Mancata corrispondenza delle chiavi del team +Team: Squadra: +Text: Testo: +Unread: %d Non letto: %d +Unread: 0 Non letto: 0 +W W +0° Horizon Orizzonte 0° +PSK must be 32 hex or 16 chars PSK deve essere 32 esadecimali o 16 caratteri +Restarting... Riavvio... +awaiting position in attesa di posizione +compass wakes after GPS fix la bussola si riattiva dopo la correzione GPS +searching satellites ricerca dei satelliti +%s - %s - +%s 0s %s 0s +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO AUTO +Alt: %ld m Alt: %ld m +Alt: - Alt: - +BEST MIGLIORE +BW: %.0fk BW: %.0fk +BW: %.1fk BW: %.1fk +BW: - BW: - +CAD CAD +CURSOR CURSORE +Ch: %.3f Cap: %.3f +Ch: - Cap: - +Hop: %u Salta: %u +Hop: - Salto: - +Hop: - / NH: %02X Hop: - / NH: %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: Ultimo ascolto: +Link Collegamento +Link / MeshCore Collegamento / MeshCore +Link / Meshtastic Collegamento / Meshtastic +MODE: RSSI MODALITÀ: RSSI +NOISE %.0f dBm RUMORE %.0f dBm +NOISE -104 dBm RUMORE -104 dBm +PKI: - PKI: - +PKI: known PKI: noto +PKI: verified PKI: verificato +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI: %.0f dBm +RSSI: - RSSI: - +Role: %s Ruolo: %s +SCAN SCANSIONE +SF: %u SF: %u +SF: - SF: - +SIM SIM +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1f dB +SNR: - SNR: - +SNR %.0f SNR %.0f +SNR - SNR - +SNR Good SNR Bene +SNR Weak SNR Debole +STEP %.0fk | BW %.0fk PASSO %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk PASSO %.1fk | BW %.1fk +STEP -- | BW -- PASSO -- | BW -- +STOP STOP +Slot Slot +Slot %u Slot %u +SUB-GHz SCAN SCANSIONE SUB-GHz +Actions Azioni +Actions: %s Azioni: %s +Access to team data revoked Accesso ai dati del team revocato +Capability: Capacità: +Channel Actions Azioni canale +Confirm Kick Conferma espulsione +Create Team Crea squadra +ELEV ELEV +Join Team Unisciti al team +In View In vista +Keep devices within 5m Mantenere i dispositivi entro 5 m +KeyDist Dist.chiave +KeyId: %u ID chiave: %u +KeyId: -- ID chiave: -- +Keys Chiavi +Kick Calcia +Kick Member Escludi membro +Last paired: %s Ultimo abbinamento: %s +Last seen %uh ago Visto l'ultima volta %uh fa +Last seen %um ago Visto l'ultima volta %um fa +Last seen -- Visto l'ultima volta -- +Last update %us ago Ultimo aggiornamento %us fa +Last update -- Ultimo aggiornamento -- +Last update 0s ago Ultimo aggiornamento 0 s fa +Leader Capo +Leader online Leader in linea +Leave Vattene +Leave team? Lasciare la squadra? +Manage Gestisci +Member: %s Membro: %s +Members Membri +Members: %u Membri: %u +Members: %u Online: %u Membri: %u Online: %u +Modem Bridge Ponte modem +No members yet Ancora nessun membro +Not Used Non utilizzato +Offline Non in linea +Online In linea +Online: %u In linea: %u +Pair Again Associa nuovamente +Pair Member Coppia membro +Pairing in progress Associazione in corso +Prev Prec +Remove %s from team? Rimuovere %s dalla squadra? +Request Keys Richiedi chiavi +Retry Riprova +Security Round: %u Turno di sicurezza: %u +Security Round: -- Turno di sicurezza: -- +Security: OK (Round %u) Sicurezza: OK (Rotondo %u) +Security: OK (Round --) Sicurezza: OK (Rotondo --) +Seen %ud Visto %ud +Seen %uh Visto %uh +Seen %um Visto %um +Send failed (keys not ready) Invio fallito (chiavi non pronte) +Status: %s Stato: %s +Target: %s Obiettivo: %s +Team / %s Squadra / %s +Team Actions Azioni del team +Team Health Salute della squadra +Team Status Stato della squadra +Team: %s Squadra: %s +This clears local keys. Cancella le chiavi locali. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. Questo aggiornerà il round di sicurezza.\nIl membro non riceverà più\nmessaggi di squadra o waypoint. +Transfer Trasferimento +Transfer Leader Capo trasferimento +View Team Visualizza squadra +Waiting for new keys... In attesa di nuove chiavi... +Waypoint Punto di passaggio +You are no longer in this team Non fai più parte di questa squadra +You are not in a team Non fai parte di una squadra +- No shared map\n- No team awareness - Nessuna mappa condivisa\n- Nessuna consapevolezza del team +1 member stale 1 membro vecchio +encrypt failed crittografia non riuscita +member membro +queue full coda piena +unsupported protocol protocollo non supportato +Factory Reset Ripristino delle impostazioni di fabbrica Clear all settings and restart? Cancellare tutte le impostazioni e riavviare? +Terrain Terreno +Satellite Satellite +Contour: ON Contorno: ON +Contour data missing Dati del profilo mancanti +%d enabled %d abilitato +%s %s %s %s +Channel Key / PSK Chiave canale / PSK +Channel Slot Slot del canale +Channel Utilization Utilizzo del canale +Check for Updates Controlla aggiornamenti +Clear Message DB Cancella DB messaggi +Connect Connetti +Contour Overlay Sovrapposizione contorno +Coordinate Format Formato coordinate +Coordinate System Sistema di coordinate +Current Version Versione corrente +Detected Network Rete rilevata +Disconnect Disconnettersi +Duty Cycle Limit Limite del ciclo di lavoro +Enabled IMEs Abilitato IMEs +Encryption Mode Modalità di crittografia +Freq Offset (MHz) Offset frequenza (MHz) +GPS Enabled GPS Abilitato +Hop Limit Limite hop +IME setting update failed Aggiornamento dell'impostazione IME non riuscito +Install Update Installa aggiornamento +Latest Version Ultima versione +Layer Livello +Location Mode Modalità di localizzazione +MC Airtime Factor Fattore tempo di trasmissione MC +MC Bandwidth (kHz) Larghezza di banda MC (kHz) +MC Channel Key Tasto canale MC +MC Channel Name Nome canale MC +MC Channel Slot Slot canale MC +MC Coding Rate Velocità di codifica MC +MC Flood Max MC Allagamento max +MC Frequency (MHz) Frequenza MC (MHz) +MC Multi ACKs MC Multi ACK +MC RX Delay Base MC RX Base ritardo +MC Region Preset Preimpostazione regione MC +MC Repeat MC Ripeti +MC Spread Factor Fattore di diffusione MC +MC TX Power MC TX Potenza +Manual BW Manuale BW +Manual CR Manuale CR +Manual SF Manuale SF +Map Source Sorgente mappa +Modem Preset Preimpostazione modem +NMEA Export NMEA Esporta +NMEA Sentences NMEA Frasi +No IME packs installed Nessun pacchetto IME installato +No position available Nessuna posizione disponibile +OTA Status OTA Stato +Override Duty Cycle Ignora ciclo di lavoro +Override Freq (MHz) Sostituisci frequenza (MHz) +Password Password +Position Strategy Strategia di posizione +SSID SSID +Satellite Systems Sistema satellitarems +Scan Networks Scansione reti +TX Enabled TX Abilitato +TX Power TX Potenza +Track Format Formato traccia +Track Interval Intervallo di traccia +Track Recording Registrazione traccia +Unavailable Non disponibile +Update Interval Intervallo di aggiornamento +Use Preset Usa preimpostazione +Wi-Fi Enabled Wi-Fi Abilitato +Wi-Fi start failed Wi-Fi avvio non riuscito diff --git a/packs/europe-latin-ext/locales/nl/manifest.ini b/packs/europe-latin-ext/locales/nl/manifest.ini index e1faa9e7..7a376c46 100644 --- a/packs/europe-latin-ext/locales/nl/manifest.ini +++ b/packs/europe-latin-ext/locales/nl/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=nl display_name=Dutch native_name=Nederlands +translation_status=review ui_font_pack=latin-ext-eu content_font_pack=latin-ext-eu strings=strings.tsv diff --git a/packs/europe-latin-ext/locales/nl/strings.tsv b/packs/europe-latin-ext/locales/nl/strings.tsv index 321507d4..19fda9cf 100644 --- a/packs/europe-latin-ext/locales/nl/strings.tsv +++ b/packs/europe-latin-ext/locales/nl/strings.tsv @@ -2,48 +2,99 @@ Action Actie Add Toevoegen Advanced Geavanceerd Always Altijd +Altitude Hoogte +Altitude Reference Hoogteref. App App -Auto Auto +Area Cleared Gebied vrij +Auto Automatisch +Awaiting teammate Wacht op team Back Terug +Base Camp Basiskamp +Beijing (UTC+8) Peking (UTC+8) +BLE bridge ready | %s BLE-brug klaar | %s +BLE linked | %s BLE gekoppeld | %s Bluetooth Bluetooth Bluetooth Pairing Bluetooth-koppeling +Bcast Uitzending +Bridge error Brugfout +Broadcast Uitzending Cancel Annuleren Channel Kanaal -Chat Chat +Chat Chatten +Chat unavailable Chat niet beschikbaar Chinese Chinees +Closes automatically after pairing Sluit na koppelen Close Sluiten +Command Opdracht +Command: %s Opdracht: %s +Command: %s %s Opdracht: %s %s +Command: %s %s %s Opdracht: %s %s %s +Compare Verification Code Code vergelijken +Compass Kompas +Connected Verbonden +Connected, handshaking... Verbonden, handshake... Contacts Contacten -Data Exchange Gegevenslink +Contacts (%s) Contacten (%s) +Contacts (LXMF) Contacten (LXMF) +Contacts (LXMF - %.3fMHz) Contacten (LXMF - %.3fMHz) +Contacts (MeshCore) Contacten (MeshCore) +Contacts (Meshtastic - %.3fMHz) Contacten (Meshtastic - %.3fMHz) +Contacts (RNode) Contacten (RNode) +Contacts (RNode - %.3fMHz) Contacten (RNode - %.3fMHz) +Conversation protocol mismatch Gespreksprotocol komt niet overeen +Data Exchange Gegevensuitwisseling +Debug Logs Foutopsporingslogboeken +Decoding: %d%% Decodering: %d%% +Decoding: 0% Decodering: 0% Delete Verwijderen +Delete %s? %s verwijderen? +Delete contact %s? Contactpersoon %s verwijderen? +Delete failed Verwijderen mislukt +Device: %s Apparaat: %s +Direct Direct Disabled Uitgeschakeld -Discover Ontdekken -Display Language Schermtaal +Discover Ontdek +Display Language Weergavetaal +Duplicate name not allowed Dubbele naam niet toegestaan Edit Bewerken -Energy Sweep RF-scan +Edit nickname Bijnaam bewerken +Emergency SOS Nood-SOS +Energy Sweep Energieopruiming English Engels English keyboard Engels toetsenbord +Enter nickname Voer bijnaam in +Enter number for %s Voer nummer in voor %s +Enter the 6-digit number Voer het 6-cijferige nummer in +Enter this 6-digit PIN on your phone Voer deze 6-cijferige pincode in op uw telefoon Error Fout -Factory Reset Fabrieksreset +Error: %lu Fout: %lu +FSK Voice FSK-stem +Failed Mislukt +Fix Herstel +Fixed PIN Vaste pincode +Gauge Design (mAh) Manometerontwerp (mAh) +Gauge Full (mAh) Meter vol (mAh) +Good Find Goede vondst GPS GPS Extensions Extensies Language Taal Package Details Pakketdetails Back to list Terug naar lijst Install Installeren -Update Bijwerken +Update Bijgewerkt Uninstall Verwijderen -Incompatible Niet compatibel +Incompatible Incompatibel Compatible with this device Compatibel met dit apparaat Compatibility: %s Compatibiliteit: %s memory profile mismatch geheugenprofiel komt niet overeen -needs %s vereist %s +needs %s heeft %s nodig Update available: %s Update beschikbaar: %s Installed Geïnstalleerd Installed: %s Geïnstalleerd: %s -Available to install Beschikbaar om te installeren +Available to install Beschikbaar voor installatie Blocked Geblokkeerd -%lu locales %lu fonts %lu IME %s %lu talen %lu lettertypen %lu IME %s -No Wi-Fi Geen Wi-Fi +%lu locales %lu fonts %lu IME %s %lu landinstellingen %lu lettertypen %lu IME %s +No Wi-Fi Nee Wi-Fi Installing package... Pakket installeren... Package installed Pakket geïnstalleerd Uninstalling package... Pakket verwijderen... @@ -54,57 +105,514 @@ About Over Package Info Pakketinformatie Contents Inhoud Source Bron -Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archief: %s\nExtra lettertype-RAM: %s\nProfielen: %s\n%s -Locales: %s\nFonts: %s\nIME: %s Talen: %s\nLettertypen: %s\nIME: %s -Author: %s\nHomepage: %s Auteur: %s\nHomepage: %s +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archief: %s\nExtra lettertype RAM: %s\nProfielen: %s\n%s +Locales: %s\nFonts: %s\nIME: %s Landinstellingen: %s\nLettertypen: %s\nIME: %s +Author: %s\nHomepage: %s Auteur: %s\nHomepagina: %s None Geen -GPS Status GPS-status +GPS Status GPS Status +HDOP HDOP +Heading starting Koers begint Hide Verbergen +Host bridge Hostbridge +Host connected, probing modem... Host aangesloten, modem aan het onderzoeken... +IDLE IDLE +Ignored Genegeerd +Ignore Negeren +Ignore unavailable Negeren niet beschikbaar +Ignore update failed Update negeren mislukt +Image received Afbeelding ontvangen Info Info -Loading... Laden... +Initializing... Initialiseren... +Invalid airtime factor Ongeldige zendtijdfactor +Invalid design capacity (mAh) Ongeldige ontwerpcapaciteit (mAh) +Invalid frequency offset Ongeldige frequentie-offset +Invalid frequency value Ongeldige frequentiewaarde +Invalid full capacity (mAh) Ongeldige volledige capaciteit (mAh) +Invalid marker Ongeldige markering +Invalid MeshCore bandwidth Ongeldige MeshCore bandbreedte +Invalid MeshCore frequency Ongeldige MeshCore frequentie +Invalid number Ongeldig nummer +Invalid RX delay Ongeldige RX vertraging +Key marked trusted Sleutel gemarkeerd als vertrouwd +Key must be 32 hex or 16 chars De sleutel moet 32 hexadecimale tekens of 16 tekens bevatten +Key trust failed Sleutelvertrouwen mislukt +Key Verification Sleutelverificatie +LINKED GEKOPPELD +LISTEN LUISTER +LIVE LIVE +Loading... Bezig met laden... +Location Locatie +Location: %s Locatie: %s +Location: %s %s Locatie: %s %s +LoRa direct path | %s LoRa direct pad | %s +Low battery - audio disabled Batterij bijna leeg - audio uitgeschakeld Map Kaart Map Layer Kaartlaag -Nearby Dichtbij +MESH GAAS +Mesh Status Meshstatus +Mesh unavailable Mesh niet beschikbaar +MeshCore only alleen MeshCore +Message DB cleared Bericht-DB gewist +Messages Berichten +MESSAGES BERICHTEN +Messages and broadcasts show here Berichten en uitzendingen worden hier weergegeven +Mini Compass Mini-kompas +MT send uses slot 0/1 only MT-verzending gebruikt alleen slot 0/1 +N/A N.v.t +Nearby In de buurt +Need local GPS Lokale GPS nodig Network Netwerk New Nieuw +new activity nieuwe activiteit +Next Volgende +No GPS fix Geen GPS oplossing +No image Geen afbeelding No messages Geen berichten +No preview Geen voorbeeld No recent traffic Geen recent verkeer +No response Geen reactie +No route selected Geen route geselecteerd +No target Geen doel +No track files Geen trackbestanden +Node DB reset Knooppunt-DB opnieuw ingesteld +Node ignored Knooppunt genegeerd +Node ignored and hidden Knooppunt genegeerd en verborgen +Node unignored Knooppunt niet genegeerd +North locked Noord vergrendeld Not set Niet ingesteld +NO NR +NO FIX GEEN OPLOSSING Number keyboard Cijfertoetsenbord OFF UIT -OK OK +OK Oké ON AAN +online online Pairing Koppelen -PC Link PC-koppeling -Power Voeding +PC Link PC-link +PC Link is unavailable on this target. PC Link is niet beschikbaar op dit doel. +peer lock | turn %03.0f deg peer-lock | draai %03.0f graden +Pinyin keyboard Pinyin-toetsenbord +Pinyin: %s Pinyin: %s +Position Positie +position stream active | %u in view positiestream actief | %u in zicht +Power Vermogen +Press Back Druk op Terug +Press RX to start Druk op RX om te starten +Primary Primair Protocol Protocol +Protocol switch failed Protocolwisseling mislukt +Protocol switched Protocol geschakeld +quiet net stil net +Random PIN Willekeurige pincode Ready Klaar +RECEIVING ONTVANGEN Recent Activity Recente activiteit -Reply Antwoorden +Record Opnemen +Recording Opname +Region Regio +Remain: %u Rest: %u +Reply Beantwoorden +Reply disabled for this protocol Antwoord uitgeschakeld voor dit protocol +Reset Mesh Profiles Mesh-profielen opnieuw instellen +Reset Node DB Knooppunt-DB opnieuw instellen +Resetting... Opnieuw instellen... +Return Terug +Rally Point Verzamelpunt +RNode Bridge RNode Brug +RNode modem ready RNode modem gereed +RNode text chat runs on host RNode tekstchat draait op de host +Route Routebeschrijving Run Uitvoeren +RX RX +RX: %lu TX: %lu RX: %lu TX: %lu +RX: 0 TX: 0 RX: 0 TX: 0 Save Opslaan +Saved: %s Opgeslagen: %s Scan failed Scannen mislukt Scan Local Lokaal scannen -Scanning 5s... 5 s scannen... -SEARCH ZOEKEN +Scanning 5s... Scannen 5s... Screen Brightness Schermhelderheid -Screen Timeout Schermtimeout +Screen Timeout Schermtime-out +SEARCH ZOEKEN +Secondary Secundair +Select a route Selecteer een route +Select a track Selecteer een nummer +Select marker Selecteer markering +Select Track Selecteer Nummer +Selected: %s Geselecteerd: %s +Send Verzenden +Send failed Verzenden mislukt +Send this code and compare:\n Verstuur deze code en vergelijk:\n +Send ID Broadcast Verzend ID uitzending +Send ID Local Verzend ID Lokaal +Sending... Verzenden... +Sent Verzonden Settings Instellingen Setting Instelling -Sky Plot Sky Plot +Share Position Marker Aandeelpositiemarkering +Share this number:\n Deel dit nummer:\n +Short Name Korte naam +SOLO SOLO Speaker Volume Luidsprekervolume +Sky Plot Luchtplot +SSTV RX READY SSTV RX KLAAR +SSTV start failed SSTV-start mislukt +standing by paraat Status Status Stopped Gestopt -Stop Stoppen +Stop Stop +Stop recording first Stop eerst met opnemen Submit Verzenden +Submit failed Verzenden mislukt System Systeem +SATELLITE STATUS SATELLIETATUS +Talk Praat +TALK PRATEN Team Team -Team Status Teamstatus +TEAM TEAM +Team chat send failed Verzenden van teamchat mislukt +Team chat unavailable Teamchat niet beschikbaar +Team chat unavailable in RNode mode Teamchat niet beschikbaar in de modus RNode +team member position will appear here De positie van het teamlid wordt hier weergegeven +Team keys not ready Teamsleutels niet gereed +Team location encode failed Coderen van teamlocatie mislukt +Text chat unavailable Tekstchat niet beschikbaar +threads active discussies actief Time Zone Tijdzone Tracker Tracker +traffic active verkeer actief +Trust Key Vertrouwenssleutel +U U +Unignore Niet negeren +Unread Ongelezen Unknown Onbekend -USB Disk USB-schijf +USB Mass Storage USB Massaopslag +USB Disk USB Schijf +USB Bridge USB Brug +USB host bridge USB hostbridge +USB Bridge is unavailable on this target. USB Bridge is niet beschikbaar op dit doel. +USB CDC KISS modem for Reticulum USB CDC KISS-modem voor Reticulum +USE GEBRUIK +Used Gebruikt User Name Gebruikersnaam Verification Number Verificatienummer -Vibration Trilling -Clear all settings and restart? Alle instellingen wissen en herstarten? +Verification number sent Verificatienummer verzonden +Vibration Trillingen +VOL %d VOL %d +VOL 80 VOL 80 +waiting for better sky view | %u in view wachtend op beter zicht op de hemel | %u in zicht +waiting for module GNSS compass wacht op module GNSS kompas +waiting for traffic wachtend op verkeer +Waiting for host... Wachten op host... +Waiting for Reticulum host... Wachten op Reticulum host... +WAIT IMU WACHT IMU +Walkie not available Walkie niet beschikbaar +Walkie start failed Walkie-start mislukt +Walkie Talkie Walkietalkie +YES JA +6 digits 6 cijfers +%u threads active %u discussies actief +%ud %ud +%uh %uh +%um %um +%umo %umo +%uy %uy +Confirm received code:\n Ontvangen code bevestigen:\n +device heading from 9-axis module apparaatrichting van 9-assige module +heading unlocks after first fix kop ontgrendelt na eerste correctie +If it matches, trust the key. Als deze overeenkomt, vertrouw dan op de sleutel. +magnetometer heading active richting van de magnetometer actief +magnetometer still warming up magnetometer is nog steeds aan het opwarmen +needle shows north until target lock naald wijst naar het noorden tot doelvergrendeling +nearest peer | %.0f m away dichtstbijzijnde collega | %.0f m afstand +no recent traffic geen recent verkeer +now nu +%s layer missing %s laag ontbreekt +Active: %s Actief: %s +Alt: %.0f m Alt: %.0f m +Alt: -- m Alt: -- m +Base: %s Basis: %s +Battery critical Batterij kritiek +Broadcast (%d) Uitzending (%d) +COMPLETE COMPLEET +Calibrating... Kalibreren... +Confirm Bevestig +Contour: OFF Contour: UIT +Decoder error Decoderfout +Direct (%d) Direct (%d) +E E +ERROR FOUT +Exiting USB... USB afsluiten... +Hold Wacht +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented Sleutelvernieuwing niet geïmplementeerd +Key verify: %s %s Sleutelverificatie: %s %s +Key verify: confirm %s %s Sleutel verifiëren: bevestigen %s %s +Key verify: enter number for %s Sleutelverificatie: voer nummer in voor %s +Key verify: send %s %s Sleutelverificatie: verzend %s %s +Later Later +Level %d Niveau %d +Listening for SSTV signal... Luisteren naar SSTV-signaal... +Low battery: %d%% Batterij bijna leeg: %d%% +MODE: %s MODUS: %s +MODE: Auto MODUS: Automatisch +Map - %.48s Kaart - %.48s +Map - %s Missing Kaart - %s Ontbreekt +Map - No Map Data Kaart - Geen kaartgegevens +Map - No SD Card Kaart - Geen SD kaart +Map - no gps data Kaart - geen GPS-gegevens +Message Bericht +Mic TBD Microfoon nader vast te stellen +Mic init failed Mic-initialisatie mislukt +Morse Input Morse-invoer +MoveTo Verplaats naar +N N +NODE INFO NODE-INFO +Name required Naam vereist +Name too long Naam te lang +No KML routes Geen KML-routes +No SD Card Geen SD kaart +No routes folder Geen routesmap +No tracks yet Nog geen nummers +Only leader can manage Alleen leider kan beheren +Only leader can pair Alleen leider kan paren +Paired successfully Succesvol gekoppeld +Pairing failed Koppelen mislukt +Pairing init failed Koppelingsinit mislukt +Pairing not available Koppelen niet beschikbaar +Pairing not ready Koppelen niet gereed +Press Back to exit USB mode Druk op Terug om de modus USB te verlaten +Press SPACE to resume Druk op SPACE om door te gaan +RallyTo RallyTo +Route configured Route geconfigureerd +Route: %s Route: %s +SD Card Not Found\nPlease insert SD card SD Kaart niet gevonden\nPlaats een SD kaart +SSTV RECEIVER SSTV-ONTVANGER +Save failed Opslaan mislukt +Scan +%u/%u Scannen +%u/%u +Select level Selecteer niveau +Selected level: %d Geselecteerd niveau: %d +Shutting down to protect battery Afsluiten om de batterij te beschermen +Status: CALIB Status: KALIBR +Step 1/2: Tap short dots Stap 1/2: Tik op korte stippen +Step 2/2: Tap long dashes Stap 2/2: Tik op lange streepjes +Switch to %s to chat Schakel over naar %s om te chatten +Symbol: Symbool: +Tap to input, idle 3s to send Tik om in te voeren, 3s inactief om te verzenden +Team keys mismatch Teamsleutels komen niet overeen +Team: Team: +Text: Tekst: +Unread: %d Ongelezen: %d +Unread: 0 Ongelezen: 0 +W W +0° Horizon 0° Horizon +PSK must be 32 hex or 16 chars PSK moet 32 hexadecimale tekens of 16 tekens bevatten +Restarting... Opnieuw opstarten... +awaiting position in afwachting van positie +compass wakes after GPS fix kompas wordt geactiveerd na GPS fix +searching satellites zoeken naar satellieten +%s - %s - +%s 0s %s 0s +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO AUTOMATISCH +Alt: %ld m Alt: %ld m +Alt: - Alt: - +BEST BESTE +BW: %.0fk BW: %.0fk +BW: %.1fk BW: %.1fk +BW: - BW: - +CAD CAD +CURSOR CURSOR +Ch: %.3f Kanaal: %.3f +Ch: - Kanaal: - +Hop: %u Hop: %u +Hop: - Hop: - +Hop: - / NH: %02X Hop: - / NH: %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: Laatst gehoord: +Link Link +Link / MeshCore Link / MeshCore +Link / Meshtastic Link / Meshtastic +MODE: RSSI MODUS: RSSI +NOISE %.0f dBm GELUID %.0f dBm +NOISE -104 dBm GELUID -104 dBm +PKI: - PKI: - +PKI: known PKI: bekend +PKI: verified PKI: geverifieerd +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI: %.0f dBm +RSSI: - RSSI: - +Role: %s Rol: %s +SCAN SCANNEN +SF: %u SF: %u +SF: - SF: - +SIM SIM-kaart +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1f dB +SNR: - SNR: - +SNR %.0f SNR %.0f +SNR - SNR - +SNR Good SNR Goed +SNR Weak SNR Zwak +STEP %.0fk | BW %.0fk STAP %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk STAP %.1fk | BW %.1fk +STEP -- | BW -- STAP -- | BW -- +STOP STOP +Slot Sleuf +Slot %u Slot %u +SUB-GHz SCAN SUB-GHz SCANNEN +Actions Acties +Actions: %s Acties: %s +Access to team data revoked Toegang tot teamgegevens ingetrokken +Capability: Mogelijkheid: +Channel Actions Kanaalacties +Confirm Kick Kick bevestigen +Create Team Team aanmaken +ELEV ELEV +Join Team Sluit je aan bij het team +In View In beeld +Keep devices within 5m Houd apparaten binnen 5 meter afstand +KeyDist KeyDist +KeyId: %u SleutelId: %u +KeyId: -- SleutelID: -- +Keys Toetsen +Kick Schop +Kick Member Kick-lid +Last paired: %s Laatst gekoppeld: %s +Last seen %uh ago Laatst gezien %uu geleden +Last seen %um ago Laatst gezien %um geleden +Last seen -- Laatst gezien -- +Last update %us ago Laatste update %us geleden +Last update -- Laatste update -- +Last update 0s ago Laatste update 0s geleden +Leader Leider +Leader online Leider online +Leave Vertrekken +Leave team? Team verlaten? +Manage Beheren +Member: %s Lid: %s +Members Leden +Members: %u Leden: %u +Members: %u Online: %u Leden: %u Online: %u +Modem Bridge Modembrug +No members yet Nog geen leden +Not Used Niet gebruikt +Offline Offline +Online Online +Online: %u Online: %u +Pair Again Opnieuw koppelen +Pair Member Paarlid +Pairing in progress Bezig met koppelen +Prev Vorige +Remove %s from team? %s uit team verwijderen? +Request Keys Sleutels aanvragen +Retry Opnieuw proberen +Security Round: %u Beveiligingsronde: %u +Security Round: -- Beveiligingsronde: -- +Security: OK (Round %u) Beveiliging: OK (rond %u) +Security: OK (Round --) Beveiliging: OK (rond --) +Seen %ud Gezien %ud +Seen %uh Gezien %uh +Seen %um Gezien %um +Send failed (keys not ready) Verzenden mislukt (sleutels niet gereed) +Status: %s Status: %s +Target: %s Doel: %s +Team / %s Team / %s +Team Actions Teamacties +Team Health Teamgezondheid +Team Status Teamstatus +Team: %s Team: %s +This clears local keys. Hiermee worden lokale sleutels gewist. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. Hiermee wordt de beveiligingsronde bijgewerkt.\nHet lid ontvangt niet langer\nteamberichten of waypoints. +Transfer Overdracht +Transfer Leader Transferleider +View Team Bekijk team +Waiting for new keys... Wachten op nieuwe sleutels... +Waypoint Waypoint +You are no longer in this team Je maakt niet langer deel uit van dit team +You are not in a team Je zit niet in een team +- No shared map\n- No team awareness - Geen gedeelde kaart\n- Geen teambewustzijn +1 member stale 1 lid oud +encrypt failed codering mislukt +member lid +queue full wachtrij vol +unsupported protocol niet-ondersteund protocol +Factory Reset Fabrieksreset +Clear all settings and restart? Alle instellingen wissen en opnieuw opstarten? +Terrain Terrein +Satellite Satelliet +Contour: ON Contour: AAN +Contour data missing Contourgegevens ontbreken +%d enabled %d ingeschakeld +%s %s %s %s +Channel Key / PSK Kanaalsleutel / PSK +Channel Slot Kanaalslot +Channel Utilization Kanaalgebruik +Check for Updates Controleer op updates +Clear Message DB Bericht-DB wissen +Connect Verbinden +Contour Overlay Contour-overlay +Coordinate Format Coördinaatformaat +Coordinate System Coördinatensysteem +Current Version Huidige versie +Detected Network Gedetecteerd netwerk +Disconnect Verbinding verbreken +Duty Cycle Limit Inschakelduurlimiet +Enabled IMEs Ingeschakeld IMEs +Encryption Mode Coderingsmodus +Freq Offset (MHz) Freq-offset (MHz) +GPS Enabled GPS Ingeschakeld +Hop Limit Hoplimiet +IME setting update failed IME update van instelling mislukt +Install Update Update installeren +Latest Version Nieuwste versie +Layer Laag +Location Mode Locatiemodus +MC Airtime Factor MC-zendtijdfactor +MC Bandwidth (kHz) MC-bandbreedte (kHz) +MC Channel Key MC-kanaaltoets +MC Channel Name MC-kanaalnaam +MC Channel Slot MC-kanaalsleuf +MC Coding Rate MC-coderingssnelheid +MC Flood Max MC Overstroming Max +MC Frequency (MHz) MC-frequentie (MHz) +MC Multi ACKs MC Multi ACK's +MC RX Delay Base MC RX Vertragingsbasis +MC Region Preset MC-regiovoorinstelling +MC Repeat MC-herhaling +MC Spread Factor MC-spreidingsfactor +MC TX Power MC TX Vermogen +Manual BW Handleiding BW +Manual CR Handleiding CR +Manual SF Handleiding SF +Map Source Kaartbron +Modem Preset Modemvoorinstelling +NMEA Export NMEA Exporteren +NMEA Sentences NMEA Zinnen +No IME packs installed Geen IME pakketten geïnstalleerd +No position available Geen positie beschikbaar +OTA Status OTA Status +Override Duty Cycle Werkcyclus negeren +Override Freq (MHz) Overschrijffrequentie (MHz) +Password Wachtwoord +Position Strategy Positiestrategie +SSID SSID +Satellite Systems Satellietsysteemms +Scan Networks Netwerken scannen +TX Enabled TX Ingeschakeld +TX Power TX Kracht +Track Format Trackformaat +Track Interval Trackinterval +Track Recording Trackopname +Unavailable Niet beschikbaar +Update Interval Update-interval +Use Preset Voorinstelling gebruiken +Wi-Fi Enabled Wi-Fi Ingeschakeld +Wi-Fi start failed Wi-Fi starten mislukt diff --git a/packs/europe-latin-ext/locales/pl/manifest.ini b/packs/europe-latin-ext/locales/pl/manifest.ini index 189fe5b2..4929ffbe 100644 --- a/packs/europe-latin-ext/locales/pl/manifest.ini +++ b/packs/europe-latin-ext/locales/pl/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=pl display_name=Polish native_name=Polski +translation_status=review ui_font_pack=latin-ext-eu content_font_pack=latin-ext-eu strings=strings.tsv diff --git a/packs/europe-latin-ext/locales/pl/strings.tsv b/packs/europe-latin-ext/locales/pl/strings.tsv index 599ef5d3..2f9beee0 100644 --- a/packs/europe-latin-ext/locales/pl/strings.tsv +++ b/packs/europe-latin-ext/locales/pl/strings.tsv @@ -2,109 +2,617 @@ Action Akcja Add Dodaj Advanced Zaawansowane Always Zawsze +Altitude Wysokość +Altitude Reference Ref. wys. App Aplikacja -Auto Auto -Back Wróć +Area Cleared Obszar czysty +Auto Automatycznie +Awaiting teammate Oczek. na zespół +Back Wstecz +Base Camp Baza +Beijing (UTC+8) Pekin (UTC+8) +BLE bridge ready | %s Mostek BLE gotowy | %s +BLE linked | %s BLE połączony | %s Bluetooth Bluetooth -Bluetooth Pairing Parowanie Bluetooth +Bluetooth Pairing Parowanie BT +Bcast Rozgłoszenie +Bridge error Błąd mostka +Broadcast Rozgłoszenie Cancel Anuluj Channel Kanał Chat Czat +Chat unavailable Czat niedostępny Chinese Chiński +Closes automatically after pairing Zamyka po sparowaniu Close Zamknij +Command Polecenie +Command: %s Polecenie: %s +Command: %s %s Polecenie: %s %s +Command: %s %s %s Polecenie: %s %s %s +Compare Verification Code Porównaj kod +Compass Kompas +Connected Połączono +Connected, handshaking... Połączono, uzgadnianie... Contacts Kontakty +Contacts (%s) Kontakty (%s) +Contacts (LXMF) Kontakty (LXMF) +Contacts (LXMF - %.3fMHz) Kontakty (LXMF - %.3fMHz) +Contacts (MeshCore) Kontakty (MeshCore) +Contacts (Meshtastic - %.3fMHz) Kontakty (Meshtastic - %.3fMHz) +Contacts (RNode) Kontakty (RNode) +Contacts (RNode - %.3fMHz) Kontakty (RNode - %.3fMHz) +Conversation protocol mismatch Niezgodność protokołu konwersacji Data Exchange Wymiana danych +Debug Logs Dzienniki debugowania +Decoding: %d%% Dekodowanie: %d%% +Decoding: 0% Dekodowanie: 0% Delete Usuń +Delete %s? Usunąć %s? +Delete contact %s? Usunąć kontakt %s? +Delete failed Usuwanie nie powiodło się +Device: %s Urządzenie: %s +Direct Bezpośrednio Disabled Wyłączone -Discover Wykryj -Display Language Język interfejsu +Discover Odkryj +Display Language Język wyświetlania +Duplicate name not allowed Niedozwolona jest zduplikowana nazwa Edit Edytuj -Energy Sweep Skan RF +Edit nickname Edytuj pseudonim +Emergency SOS Alarmowe SOS +Energy Sweep Przemiatanie energii English Angielski English keyboard Klawiatura angielska +Enter nickname Wpisz pseudonim +Enter number for %s Wpisz numer dla %s +Enter the 6-digit number Wprowadź 6-cyfrowy numer +Enter this 6-digit PIN on your phone Wpisz ten 6-cyfrowy PIN w swoim telefonie Error Błąd -Factory Reset Reset fabryczny +Error: %lu Błąd: %lu +FSK Voice Głos FSK +Failed Niepowodzenie +Fix Napraw +Fixed PIN Stały PIN +Gauge Design (mAh) Konstrukcja wskaźnika (mAh) +Gauge Full (mAh) Wskaźnik pełny (mAh) +Good Find Dobre znalezisko GPS GPS Extensions Rozszerzenia Language Język Package Details Szczegóły pakietu -Back to list Wróć do listy +Back to list Powrót do listy Install Zainstaluj -Update Aktualizuj +Update Aktualizacja Uninstall Odinstaluj -Incompatible Niezgodne -Compatible with this device Zgodne z tym urządzeniem -Compatibility: %s Zgodność: %s -memory profile mismatch niezgodny profil pamięci -needs %s wymaga %s +Incompatible Niekompatybilny +Compatible with this device Kompatybilny z tym urządzeniem +Compatibility: %s Kompatybilność: %s +memory profile mismatch niezgodność profilu pamięci +needs %s potrzebuje %s Update available: %s Dostępna aktualizacja: %s Installed Zainstalowano Installed: %s Zainstalowano: %s -Available to install Dostępne do instalacji -Blocked Zablokowane -%lu locales %lu fonts %lu IME %s %lu języków %lu czcionek %lu IME %s -No Wi-Fi Brak Wi-Fi +Available to install Dostępne do zainstalowania +Blocked Zablokowano +%lu locales %lu fonts %lu IME %s %lu ustawienia regionalne %lu czcionki %lu IME %s +No Wi-Fi Nie Wi-Fi Installing package... Instalowanie pakietu... Package installed Pakiet zainstalowany Uninstalling package... Odinstalowywanie pakietu... -Package uninstalled Pakiet odinstalowany +Package uninstalled Pakiet został odinstalowany No language packs available Brak dostępnych pakietów językowych Available language packs: %lu Dostępne pakiety językowe: %lu About Informacje Package Info Informacje o pakiecie -Contents Zawartość +Contents Spis treści Source Źródło -Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archiwum: %s\nDodatkowy RAM czcionek: %s\nProfile: %s\n%s -Locales: %s\nFonts: %s\nIME: %s Języki: %s\nCzcionki: %s\nIME: %s +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Archiwum: %s\nDodatkowa czcionka RAM: %s\nProfile: %s\n%s +Locales: %s\nFonts: %s\nIME: %s Ustawienia regionalne: %s\nCzcionki: %s\nIME: %s Author: %s\nHomepage: %s Autor: %s\nStrona główna: %s None Brak -GPS Status Stan GPS +GPS Status GPS Stan +HDOP HDOP +Heading starting Rozpoczęcie kursu Hide Ukryj -Info Info +Host bridge Most hosta +Host connected, probing modem... Host podłączony, modem sprawdzany... +IDLE IDLE +Ignored Zignorowano +Ignore Ignoruj +Ignore unavailable Ignoruj niedostępne +Ignore update failed Ignorowanie aktualizacji nie powiodło się +Image received Otrzymano obraz +Info Informacje +Initializing... Inicjowanie... +Invalid airtime factor Nieprawidłowy współczynnik czasu antenowego +Invalid design capacity (mAh) Nieprawidłowa pojemność projektowa (mAh) +Invalid frequency offset Nieprawidłowe przesunięcie częstotliwości +Invalid frequency value Nieprawidłowa wartość częstotliwości +Invalid full capacity (mAh) Nieprawidłowa pełna pojemność (mAh) +Invalid marker Nieprawidłowy znacznik +Invalid MeshCore bandwidth Nieprawidłowa przepustowość MeshCore +Invalid MeshCore frequency Nieprawidłowa częstotliwość MeshCore +Invalid number Nieprawidłowy numer +Invalid RX delay Nieprawidłowe opóźnienie RX +Key marked trusted Klucz oznaczony jako zaufany +Key must be 32 hex or 16 chars Klucz musi mieć długość 32 znaków szesnastkowych lub 16 znaków +Key trust failed Nie udało się zaufać kluczowi +Key Verification Weryfikacja klucza +LINKED POŁĄCZONE +LISTEN SŁUCHAJ +LIVE NA ŻYWO Loading... Ładowanie... +Location Lokalizacja +Location: %s Lokalizacja: %s +Location: %s %s Lokalizacja: %s %s +LoRa direct path | %s Ścieżka bezpośrednia LoRa | %s +Low battery - audio disabled Niski poziom baterii – dźwięk wyłączony Map Mapa Map Layer Warstwa mapy +MESH SIATKA +Mesh Status Stan siatki +Mesh unavailable Siatka niedostępna +MeshCore only Tylko MeshCore +Message DB cleared Wiadomość DB została wyczyszczona +Messages Wiadomości +MESSAGES WIADOMOŚCI +Messages and broadcasts show here Tutaj wyświetlane są wiadomości i transmisje +Mini Compass Minikompas +MT send uses slot 0/1 only Wysyłanie MT wykorzystuje tylko slot 0/1 +N/A Nie dotyczy Nearby W pobliżu +Need local GPS Potrzebujesz lokalnego GPS Network Sieć -New Nowy +New Nowość +new activity nowa aktywność +Next Dalej +No GPS fix Brak poprawki GPS +No image Brak obrazu No messages Brak wiadomości -No recent traffic Brak ostatniego ruchu +No preview Brak podglądu +No recent traffic Brak niedawnego ruchu +No response Brak odpowiedzi +No route selected Nie wybrano trasy +No target Brak celu +No track files Brak plików utworów +Node DB reset Reset bazy danych węzła +Node ignored Węzeł zignorowany +Node ignored and hidden Węzeł zignorowany i ukryty +Node unignored Węzeł niezignorowany +North locked Północ zablokowana Not set Nie ustawiono +NO NIE +NO FIX BRAK POPRAWKI Number keyboard Klawiatura numeryczna -OFF WYŁ +OFF WYŁĄCZONE OK OK ON WŁ +online online Pairing Parowanie -PC Link Połączenie PC -Power Zasilanie +PC Link Połączenie z komputerem +PC Link is unavailable on this target. Łącze PC jest niedostępne w tym urządzeniu docelowym. +peer lock | turn %03.0f deg blokada równorzędna | obróć o %03.0f stopni +Pinyin keyboard Klawiatura Pinyin +Pinyin: %s Pinyin: %s +Position Pozycja +position stream active | %u in view strumień pozycji aktywny | %u w widoku +Power Moc +Press Back Naciśnij Wstecz +Press RX to start Naciśnij RX, aby rozpocząć +Primary Podstawowy Protocol Protokół +Protocol switch failed Przełączenie protokołu nie powiodło się +Protocol switched Protokół zmieniony +quiet net cicha sieć +Random PIN Losowy PIN Ready Gotowe +RECEIVING ODBIÓR Recent Activity Ostatnia aktywność +Record Nagraj +Recording Nagrywanie +Region Region +Remain: %u Pozostało: %u Reply Odpowiedz +Reply disabled for this protocol Odpowiedź wyłączona dla tego protokołu +Reset Mesh Profiles Zresetuj profile siatki +Reset Node DB Zresetuj bazę danych węzła +Resetting... Resetowanie... +Return Wróć +Rally Point Punkt zbiórki +RNode Bridge RNode Most +RNode modem ready RNode modem gotowy +RNode text chat runs on host RNode czat tekstowy działa na hoście +Route Trasa Run Uruchom +RX RX +RX: %lu TX: %lu RX: %lu TX: %lu +RX: 0 TX: 0 RX: 0 TX: 0 Save Zapisz -Scan failed Skanowanie nieudane -Scan Local Skan lokalny -Scanning 5s... Skanowanie 5 s... -SEARCH SZUKAJ +Saved: %s Zapisano: %s +Scan failed Skanowanie nie powiodło się +Scan Local Skanuj lokalnie +Scanning 5s... Skanowanie 5s... Screen Brightness Jasność ekranu -Screen Timeout Wygaszanie ekranu +Screen Timeout Limit czasu ekranu +SEARCH SZUKAJ +Secondary Drugorzędny +Select a route Wybierz trasę +Select a track Wybierz utwór +Select marker Wybierz znacznik +Select Track Wybierz ścieżkę +Selected: %s Wybrano: %s +Send Wyślij +Send failed Wysyłanie nie powiodło się +Send this code and compare:\n Wyślij ten kod i porównaj:\n +Send ID Broadcast Wyślij ID Transmisja +Send ID Local Wyślij ID lokalnie +Sending... Wysyłanie... +Sent Wysłano Settings Ustawienia Setting Ustawienie -Sky Plot Wykres nieba +Share Position Marker Udostępnij znacznik pozycji +Share this number:\n Udostępnij ten numer:\n +Short Name Krótka nazwa +SOLO SOLO Speaker Volume Głośność głośnika -Status Status +Sky Plot Wykres nieba +SSTV RX READY SSTV RX GOTOWE +SSTV start failed Uruchomienie SSTV nie powiodło się +standing by w pogotowiu +Status Stan Stopped Zatrzymano -Stop Stop -Submit Zatwierdź -System System +Stop Zatrzymaj +Stop recording first Najpierw zatrzymaj nagrywanie +Submit Prześlij +Submit failed Przesyłanie nie powiodło się +System Systemu +SATELLITE STATUS STATUS SATELITY +Talk Rozmawiaj +TALK ROZMAWIAJ Team Zespół -Team Status Stan zespołu +TEAM ZESPÓŁ +Team chat send failed Wysłanie czatu zespołu nie powiodło się +Team chat unavailable Czat zespołu jest niedostępny +Team chat unavailable in RNode mode Czat zespołowy niedostępny w trybie RNode +team member position will appear here pozycja członka zespołu +Team keys not ready Klucze zespołu nie są gotowe +Team location encode failed Kodowanie lokalizacji zespołu nie powiodło się +Text chat unavailable Czat tekstowy niedostępny +threads active wątki aktywne Time Zone Strefa czasowa -Tracker Śledzenie +Tracker Moduł śledzący +traffic active ruch aktywny +Trust Key Klucz zaufania +U U +Unignore Przestań ignorować +Unread Nieprzeczytane Unknown Nieznane -USB Disk Dysk USB +USB Mass Storage USB Pamięć masowa +USB Disk USB Dysk +USB Bridge USB Most +USB host bridge USB most hosta +USB Bridge is unavailable on this target. USB Most jest niedostępny dla tego celu. +USB CDC KISS modem for Reticulum USB Modem CDC KISS dla Reticulum +USE UŻYJ +Used Używany User Name Nazwa użytkownika Verification Number Numer weryfikacyjny -Vibration Wibracja +Verification number sent Wysłano numer weryfikacyjny +Vibration Wibracje +VOL %d TOM %d +VOL 80 TOM 80 +waiting for better sky view | %u in view czekam na lepszy widok nieba | %u w widoku +waiting for module GNSS compass czekam na moduł GNSS kompas +waiting for traffic czekam na ruch +Waiting for host... Oczekiwanie na hosta... +Waiting for Reticulum host... Oczekiwanie na hosta Reticulum... +WAIT IMU CZEKAJ IMU +Walkie not available Walkie niedostępne +Walkie start failed Uruchomienie Walkie nie powiodło się +Walkie Talkie Walkie Talkie +YES TAK +6 digits 6 cyfr +%u threads active %u wątki aktywne +%ud %ud +%uh %uh +%um %um +%umo %umies +%uy %ur +Confirm received code:\n Potwierdź otrzymany kod:\n +device heading from 9-axis module kurs urządzenia z modułu 9-osiowego +heading unlocks after first fix nagłówek zostaje odblokowany po pierwszej poprawce +If it matches, trust the key. Jeśli pasuje, zaufaj kluczowi. +magnetometer heading active kurs magnetometru aktywny +magnetometer still warming up magnetometr wciąż się nagrzewa +needle shows north until target lock igła wskazuje północ aż do namierzenia celu +nearest peer | %.0f m away najbliższy równorzędny | %.0f m dalej +no recent traffic brak niedawnego ruchu +now teraz +%s layer missing Brak warstwy %s +Active: %s Aktywny: %s +Alt: %.0f m Alt: %.0f m +Alt: -- m Alt: -- m.in +Base: %s Baza: %s +Battery critical Poziom naładowania akumulatora jest krytyczny +Broadcast (%d) Transmisja (%d) +COMPLETE KOMPLETNY +Calibrating... Kalibracja... +Confirm Potwierdź +Contour: OFF Kontur: WYŁ +Decoder error Błąd dekodera +Direct (%d) Bezpośrednie (%d) +E E +ERROR BŁĄD +Exiting USB... Wychodzenie z USB... +Hold Trzymaj +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented Nie zaimplementowano odświeżania klucza +Key verify: %s %s Weryfikacja klucza: %s %s +Key verify: confirm %s %s Weryfikacja klucza: potwierdź %s %s +Key verify: enter number for %s Weryfikacja klucza: wprowadź numer dla %s +Key verify: send %s %s Weryfikacja klucza: wyślij %s %s +Later Później +Level %d Poziom %d +Listening for SSTV signal... Nasłuchiwanie sygnału SSTV... +Low battery: %d%% Niski poziom baterii: %d%% +MODE: %s TRYB: %s +MODE: Auto TRYB: Auto +Map - %.48s Mapa - %.48s +Map - %s Missing Mapa - %s Brak +Map - No Map Data Mapa - Brak danych mapy +Map - No SD Card Mapa - nr SD Karta +Map - no gps data Mapa - brak danych GPS +Message Wiadomość +Mic TBD Mikrofon do ustalenia +Mic init failed Niepowodzenie inicjalizacji mikrofonu +Morse Input Wejście Morse'a +MoveTo Przenieś do +N N +NODE INFO INFORMACJE O WĘZLE +Name required Wymagana nazwa +Name too long Nazwa jest za długa +No KML routes Brak tras KML +No SD Card Nie SD Karta +No routes folder Brak folderu tras +No tracks yet Nie ma jeszcze utworów +Only leader can manage Tylko lider może zarządzać +Only leader can pair Tylko lider może sparować +Paired successfully Pomyślnie sparowano +Pairing failed Parowanie nie powiodło się +Pairing init failed Inicjacja parowania nie powiodła się +Pairing not available Parowanie niedostępne +Pairing not ready Parowanie nie jest gotowe +Press Back to exit USB mode Naciśnij Wstecz, aby wyjść z trybu USB +Press SPACE to resume Naciśnij SPACJĘ, aby wznowić +RallyTo RallyTo +Route configured Trasa skonfigurowana +Route: %s Trasa: %s +SD Card Not Found\nPlease insert SD card SD Nie znaleziono karty\nWłóż kartę SD +SSTV RECEIVER ODBIORNIK SSTV +Save failed Zapisywanie nie powiodło się +Scan +%u/%u Skanuj +%u/%u +Select level Wybierz poziom +Selected level: %d Wybrany poziom: %d +Shutting down to protect battery Wyłączanie w celu ochrony akumulatora +Status: CALIB Stan: KALIB +Step 1/2: Tap short dots Krok 1/2: Stuknij krótkie kropki +Step 2/2: Tap long dashes Krok 2/2: Naciśnij długie kreski +Switch to %s to chat Przełącz na %s, aby porozmawiać +Symbol: Symbol: +Tap to input, idle 3s to send Stuknij, aby wprowadzić dane, bezczynność 3 s, aby wysłać +Team keys mismatch Niezgodność kluczy zespołu +Team: Zespół: +Text: Tekst: +Unread: %d Nieprzeczytane: %d +Unread: 0 Nieprzeczytane: 0 +W W +0° Horizon Horyzont 0° +PSK must be 32 hex or 16 chars PSK musi mieć 32 znaki szesnastkowe lub 16 znaków +Restarting... Ponowne uruchamianie... +awaiting position pozycja oczekująca +compass wakes after GPS fix kompas budzi się po naprawie GPS +searching satellites wyszukiwanie satelitów +%s - %s - +%s 0s %s 0s +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO AUTO +Alt: %ld m Alt: %ld m +Alt: - Alternatywa: - +BEST NAJLEPSZE +BW: %.0fk BW: %.0fk +BW: %.1fk BW: %.1fk +BW: - BW: - +CAD CAD +CURSOR KURSORA +Ch: %.3f Kanał: %.3f +Ch: - Ch: - +Hop: %u Przeskok: %u +Hop: - Przeskok: - +Hop: - / NH: %02X Przeskok: - / NH: %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: Ostatnio słyszano: +Link Link +Link / MeshCore Link / MeshCore +Link / Meshtastic Link / Meshtastic +MODE: RSSI TRYB: RSSI +NOISE %.0f dBm HAŁAS %.0f dBm +NOISE -104 dBm HAŁAS -104 dBm +PKI: - PKI: - +PKI: known PKI: znany +PKI: verified PKI: zweryfikowano +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI: %.0f dBm +RSSI: - RSSI: - +Role: %s Rola: %s +SCAN SKANUJ +SF: %u SF: %u +SF: - SF: - +SIM Karta SIM +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1f dB +SNR: - SNR: - +SNR %.0f SNR %.0f +SNR - SNR - +SNR Good SNR Dobrze +SNR Weak SNR Słabe +STEP %.0fk | BW %.0fk KROK %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk KROK %.1fk | BW %.1fk +STEP -- | BW -- KROK -- | BW -- +STOP ZATRZYMAJ +Slot Gniazdo +Slot %u Gniazdo %u +SUB-GHz SCAN SKANOWANIE SUB-GHz +Actions Działania +Actions: %s Akcje: %s +Access to team data revoked Dostęp do danych zespołu został odebrany +Capability: Możliwości: +Channel Actions Akcje kanału +Confirm Kick Potwierdź kopnięcie +Create Team Utwórz zespół +ELEV ELEW +Join Team Dołącz do zespołu +In View W widoku +Keep devices within 5m Trzymaj urządzenia w promieniu 5 m +KeyDist Rozkł.Klucz +KeyId: %u Identyfikator klucza: %u +KeyId: -- Identyfikator klucza: -- +Keys Klucze +Kick Kopnięcie +Kick Member Wyrzuć członka +Last paired: %s Ostatnia para: %s +Last seen %uh ago Ostatnio widziany %uh temu +Last seen %um ago Ostatnio widziano %um temu +Last seen -- Ostatnio widziany -- +Last update %us ago Ostatnia aktualizacja %us temu +Last update -- Ostatnia aktualizacja -- +Last update 0s ago Ostatnia aktualizacja 0 s temu +Leader Lider +Leader online Lider online +Leave Wyjdź +Leave team? Opuścić zespół? +Manage Zarządzaj +Member: %s Członek: %s +Members Członkowie +Members: %u Członkowie: %u +Members: %u Online: %u Członkowie: %u Online: %u +Modem Bridge Mostek modemowy +No members yet Nie ma jeszcze członków +Not Used Nieużywany +Offline Nieaktywny +Online Online +Online: %u Online: %u +Pair Again Sparuj ponownie +Pair Member Członek pary +Pairing in progress Parowanie w toku +Prev Poprzednie +Remove %s from team? Usunąć %s z zespołu? +Request Keys Poproś o klucze +Retry Spróbuj ponownie +Security Round: %u Runda bezpieczeństwa: %u +Security Round: -- Runda dotycząca bezpieczeństwa: -- +Security: OK (Round %u) Bezpieczeństwo: OK (okrągłe %u) +Security: OK (Round --) Bezpieczeństwo: OK (okrągłe --) +Seen %ud Widziano %ud +Seen %uh Widziano %uh +Seen %um Widziano %um +Send failed (keys not ready) Wysyłanie nie powiodło się (klucze nie są gotowe) +Status: %s Stan: %s +Target: %s Cel: %s +Team / %s Zespół / %s +Team Actions Działania zespołu +Team Health Zdrowie zespołu +Team Status Stan zespołu +Team: %s Zespół: %s +This clears local keys. Spowoduje to wyczyszczenie kluczy lokalnych. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. Spowoduje to aktualizację rundy bezpieczeństwa.\nCzłonek nie będzie już otrzymywać\nwiadomości zespołu ani punktów trasy. +Transfer Przelew +Transfer Leader Lider Transferu +View Team Zobacz zespół +Waiting for new keys... Czekam na nowe klucze... +Waypoint Punkt orientacyjny +You are no longer in this team Nie jesteś już w tym zespole +You are not in a team Nie jesteś w zespole +- No shared map\n- No team awareness - Brak udostępnionej mapy\n - Brak świadomości zespołu +1 member stale 1 członek nieaktualny +encrypt failed szyfrowanie nie powiodło się +member członek +queue full kolejka pełna +unsupported protocol nieobsługiwany protokół +Factory Reset Reset do ustawień fabrycznych Clear all settings and restart? Wyczyścić wszystkie ustawienia i uruchomić ponownie? +Terrain Teren +Satellite Satelita +Contour: ON Kontur: WŁ +Contour data missing Brak danych konturu +%d enabled %d włączone +%s %s %s %s +Channel Key / PSK Klucz kanału / PSK +Channel Slot Szczelina kanału +Channel Utilization Wykorzystanie kanału +Check for Updates Sprawdź aktualizacje +Clear Message DB Wyczyść bazę danych komunikatów +Connect Połącz +Contour Overlay Nakładka konturowa +Coordinate Format Format współrzędnych +Coordinate System Układ współrzędnych +Current Version Aktualna wersja +Detected Network Wykryta sieć +Disconnect Rozłącz +Duty Cycle Limit Limit cyklu pracy +Enabled IMEs Włączono IMEs +Encryption Mode Tryb szyfrowania +Freq Offset (MHz) Przesunięcie częstotliwości (MHz) +GPS Enabled GPS Włączone +Hop Limit Limit przeskoku +IME setting update failed IME aktualizacja ustawień nie powiodła się +Install Update Zainstaluj aktualizację +Latest Version Najnowsza wersja +Layer Warstwa +Location Mode Tryb lokalizacji +MC Airtime Factor Współczynnik czasu antenowego MC +MC Bandwidth (kHz) Przepustowość MC (kHz) +MC Channel Key Klucz kanału MC +MC Channel Name Nazwa kanału MC +MC Channel Slot Gniazdo kanału MC +MC Coding Rate Szybkość kodowania MC +MC Flood Max MC Powódź maks +MC Frequency (MHz) Częstotliwość MC (MHz) +MC Multi ACKs MC Multi ACK +MC RX Delay Base MC RX Baza opóźnienia +MC Region Preset Wstępne ustawienie regionu MC +MC Repeat MC Powtórz +MC Spread Factor Współczynnik rozproszenia MC +MC TX Power MC TX Moc +Manual BW Instrukcja BW +Manual CR Instrukcja CR +Manual SF Instrukcja SF +Map Source Źródło mapy +Modem Preset Ustawienia modemu +NMEA Export NMEA Eksportuj +NMEA Sentences NMEA Zdania +No IME packs installed Nie zainstalowano pakietów IME +No position available Brak dostępnej pozycji +OTA Status OTA Stan +Override Duty Cycle Zastąp cykl pracy +Override Freq (MHz) Częstotliwość zastąpienia (MHz) +Password Hasło +Position Strategy Strategia pozycji +SSID SSID +Satellite Systems System satelitarnyms +Scan Networks Skanuj sieci +TX Enabled TX Włączone +TX Power TX Moc +Track Format Format ścieżki +Track Interval Interwał śledzenia +Track Recording Nagrywanie ścieżki +Unavailable Niedostępne +Update Interval Interwał aktualizacji +Use Preset Użyj ustawienia wstępnego +Wi-Fi Enabled Wi-Fi Włączone +Wi-Fi start failed Wi-Fi uruchomienie nie powiodło się diff --git a/packs/europe-latin-ext/locales/pt-PT/manifest.ini b/packs/europe-latin-ext/locales/pt-PT/manifest.ini index 85158f98..d0376d65 100644 --- a/packs/europe-latin-ext/locales/pt-PT/manifest.ini +++ b/packs/europe-latin-ext/locales/pt-PT/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=pt-PT display_name=Portuguese (Portugal) native_name=Português (Portugal) +translation_status=review ui_font_pack=latin-ext-eu content_font_pack=latin-ext-eu strings=strings.tsv diff --git a/packs/europe-latin-ext/locales/pt-PT/strings.tsv b/packs/europe-latin-ext/locales/pt-PT/strings.tsv index d1377793..569237b7 100644 --- a/packs/europe-latin-ext/locales/pt-PT/strings.tsv +++ b/packs/europe-latin-ext/locales/pt-PT/strings.tsv @@ -2,109 +2,617 @@ Action Ação Add Adicionar Advanced Avançado Always Sempre -App App -Auto Auto +Altitude Altitude +Altitude Reference Ref. altitude +App Aplicativo +Area Cleared Área limpa +Auto Automático +Awaiting teammate Aguardando equipa Back Voltar +Base Camp Acampamento base +Beijing (UTC+8) Pequim (UTC+8) +BLE bridge ready | %s Ponte BLE pronta | %s +BLE linked | %s BLE ligado | %s Bluetooth Bluetooth -Bluetooth Pairing Emparelhamento Bluetooth +Bluetooth Pairing Emparelhar Bluetooth +Bcast Difusão +Bridge error Erro de ponte +Broadcast Difusão Cancel Cancelar Channel Canal -Chat Chat +Chat Bate-papo +Chat unavailable Chat indisponível Chinese Chinês +Closes automatically after pairing Fecha após emparelhar Close Fechar +Command Comando +Command: %s Comando: %s +Command: %s %s Comando: %s %s +Command: %s %s %s Comando: %s %s %s +Compare Verification Code Comparar código +Compass Bússola +Connected Conectado +Connected, handshaking... Conectado, handshake... Contacts Contactos -Data Exchange Troca dados -Delete Eliminar +Contacts (%s) Contactos (%s) +Contacts (LXMF) Contatos (LXMF) +Contacts (LXMF - %.3fMHz) Contatos (LXMF - %.3fMHz) +Contacts (MeshCore) Contatos (MeshCore) +Contacts (Meshtastic - %.3fMHz) Contatos (Meshtastic - %.3fMHz) +Contacts (RNode) Contatos (RNode) +Contacts (RNode - %.3fMHz) Contatos (RNode - %.3fMHz) +Conversation protocol mismatch Incompatibilidade de protocolo de conversação +Data Exchange Troca de dados +Debug Logs Logs de depuração +Decoding: %d%% Decodificação: %d%% +Decoding: 0% Decodificação: 0% +Delete Excluir +Delete %s? Excluir %s? +Delete contact %s? Excluir contato %s? +Delete failed Falha na exclusão +Device: %s Dispositivo: %s +Direct Direto Disabled Desativado Discover Descobrir -Display Language Idioma do ecrã +Display Language Idioma de exibição +Duplicate name not allowed Nome duplicado não permitido Edit Editar -Energy Sweep Varrimento RF +Edit nickname Editar apelido +Emergency SOS SOS de Emergência +Energy Sweep Varredura de Energia English Inglês English keyboard Teclado inglês +Enter nickname Insira o apelido +Enter number for %s Insira o número para %s +Enter the 6-digit number Insira o número de 6 dígitos +Enter this 6-digit PIN on your phone Insira este PIN de 6 dígitos em seu telefone Error Erro -Factory Reset Repor de fábrica +Error: %lu Erro: %lu +FSK Voice Voz FSK +Failed Falha +Fix Correção +Fixed PIN PIN fixo +Gauge Design (mAh) Design do medidor (mAh) +Gauge Full (mAh) Medidor cheio (mAh) +Good Find Boa descoberta GPS GPS Extensions Extensões Language Idioma Package Details Detalhes do pacote Back to list Voltar à lista Install Instalar -Update Atualizar +Update Atualização Uninstall Desinstalar Incompatible Incompatível Compatible with this device Compatível com este dispositivo Compatibility: %s Compatibilidade: %s -memory profile mismatch perfil de memória incompatível -needs %s requer %s +memory profile mismatch incompatibilidade de perfil de memória +needs %s precisa de %s Update available: %s Atualização disponível: %s Installed Instalado Installed: %s Instalado: %s -Available to install Disponível para instalar +Available to install Disponível para instalação Blocked Bloqueado -%lu locales %lu fonts %lu IME %s %lu idiomas %lu tipos de letra %lu IME %s -No Wi-Fi Sem Wi-Fi -Installing package... A instalar pacote... +%lu locales %lu fonts %lu IME %s %lu localidades %lu fontes %lu IME %s +No Wi-Fi Não Wi-Fi +Installing package... Instalando pacote... Package installed Pacote instalado -Uninstalling package... A desinstalar pacote... +Uninstalling package... Desinstalando pacote... Package uninstalled Pacote desinstalado -No language packs available Sem pacotes de idioma disponíveis -Available language packs: %lu Pacotes de idioma disponíveis: %lu +No language packs available Nenhum pacote de idiomas disponível +Available language packs: %lu Pacotes de idiomas disponíveis: %lu About Sobre -Package Info Informação do pacote +Package Info Informações do pacote Contents Conteúdo -Source Origem -Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Arquivo: %s\nRAM extra de tipos de letra: %s\nPerfis: %s\n%s -Locales: %s\nFonts: %s\nIME: %s Idiomas: %s\nTipos de letra: %s\nIME: %s +Source Fonte +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s Arquivo: %s\nFonte extra RAM: %s\nPerfis: %s\n%s +Locales: %s\nFonts: %s\nIME: %s Locais: %s\nFontes: %s\nIME: %s Author: %s\nHomepage: %s Autor: %s\nPágina inicial: %s None Nenhum -GPS Status Estado GPS +GPS Status GPS Status +HDOP HDOP +Heading starting Início da direção Hide Ocultar -Info Info -Loading... A carregar... +Host bridge Ponte hospedeira +Host connected, probing modem... Host conectado, testando modem... +IDLE IDLE +Ignored Ignorado +Ignore Ignorar +Ignore unavailable Ignorar indisponível +Ignore update failed Falha ao ignorar atualização +Image received Imagem recebida +Info Informações +Initializing... Inicializando... +Invalid airtime factor Fator de tempo de antena inválido +Invalid design capacity (mAh) Capacidade de design inválida (mAh) +Invalid frequency offset Deslocamento de frequência inválido +Invalid frequency value Valor de frequência inválido +Invalid full capacity (mAh) Capacidade total inválida (mAh) +Invalid marker Marcador inválido +Invalid MeshCore bandwidth Largura de banda MeshCore inválida +Invalid MeshCore frequency Frequência MeshCore inválida +Invalid number Número inválido +Invalid RX delay Atraso RX inválido +Key marked trusted Chave marcada como confiável +Key must be 32 hex or 16 chars A chave deve ter 32 hexadecimais ou 16 caracteres +Key trust failed Falha na confiança da chave +Key Verification Verificação de chave +LINKED VINCULADO +LISTEN ESCUTAR +LIVE AO VIVO +Loading... Carregando... +Location Localização +Location: %s Local: %s +Location: %s %s Localização: %s %s +LoRa direct path | %s Caminho direto LoRa | %s +Low battery - audio disabled Bateria fraca - áudio desativado Map Mapa -Map Layer Camada do mapa -Nearby Próximo +Map Layer Camada do Mapa +MESH MALHA +Mesh Status Status da malha +Mesh unavailable Malha indisponível +MeshCore only MeshCore somente +Message DB cleared Banco de dados de mensagens limpo +Messages Mensagens +MESSAGES MENSAGENS +Messages and broadcasts show here Mensagens e transmissões são mostradas aqui +Mini Compass Mini Bússola +MT send uses slot 0/1 only O envio MT usa apenas o slot 0/1 +N/A N/A +Nearby Perto +Need local GPS Precisa de GPS local Network Rede New Novo -No messages Sem mensagens -No recent traffic Sem tráfego recente +new activity nova atividade +Next Próximo +No GPS fix Nenhuma correção GPS +No image Sem imagem +No messages Nenhuma mensagem +No preview Sem visualização +No recent traffic Nenhum tráfego recente +No response Sem resposta +No route selected Nenhuma rota selecionada +No target Sem meta +No track files Nenhum arquivo de rastreamento +Node DB reset Redefinição do banco de dados do nó +Node ignored Nó ignorado +Node ignored and hidden Nó ignorado e oculto +Node unignored Nó não ignorado +North locked Norte bloqueado Not set Não definido +NO NÃO +NO FIX SEM CORREÇÃO Number keyboard Teclado numérico -OFF DESL +OFF DESLIGADO OK OK -ON LIG -Pairing Emparelhar -PC Link Ligação PC -Power Alimentação +ON ATIVADO +online on-line +Pairing Emparelhamento +PC Link Link PC +PC Link is unavailable on this target. PC Link não está disponível neste destino. +peer lock | turn %03.0f deg bloqueio de ponto | gire %03.0f graus +Pinyin keyboard Teclado Pinyin +Pinyin: %s Pinyin: %s +Position Posição +position stream active | %u in view fluxo de posição ativo | %u em exibição +Power Potência +Press Back Pressione Voltar +Press RX to start Pressione RX para iniciar +Primary Primário Protocol Protocolo +Protocol switch failed Falha na troca de protocolo +Protocol switched Protocolo trocado +quiet net rede silenciosa +Random PIN PIN aleatório Ready Pronto +RECEIVING RECEBENDO Recent Activity Atividade recente +Record Registro +Recording Gravação +Region Região +Remain: %u Permanecer: %u Reply Responder +Reply disabled for this protocol Resposta desabilitada para este protocolo +Reset Mesh Profiles Redefinir perfis de malha +Reset Node DB Redefinir banco de dados do nó +Resetting... Reinicializando... +Return Retorno +Rally Point Ponto de Encontro +RNode Bridge RNode Ponte +RNode modem ready RNode modem pronto +RNode text chat runs on host RNode o chat de texto é executado no host +Route Rota Run Executar -Save Guardar -Scan failed Varrimento falhou -Scan Local Varrimento local -Scanning 5s... A varrer 5 s... +RX RX +RX: %lu TX: %lu RX: %lu TX: %lu +RX: 0 TX: 0 RX: 0 TX: 0 +Save Salvar +Saved: %s Salvo: %s +Scan failed Falha na verificação +Scan Local Digitalizar Local +Scanning 5s... Digitalizando 5s... +Screen Brightness Brilho da tela +Screen Timeout Tempo limite da tela SEARCH PESQUISA -Screen Brightness Brilho do ecrã -Screen Timeout Tempo do ecrã -Settings Definições -Setting Definição -Sky Plot Céu GPS -Speaker Volume Volume do altifalante +Secondary Secundário +Select a route Selecione uma rota +Select a track Selecione uma faixa +Select marker Selecionar marcador +Select Track Selecionar trilha +Selected: %s Selecionado: %s +Send Enviar +Send failed Falha no envio +Send this code and compare:\n Envie este código e compare:\n +Send ID Broadcast Enviar ID Transmissão +Send ID Local Enviar ID Local +Sending... Enviando... +Sent Enviado +Settings Configurações +Setting Configuração +Share Position Marker Marcador de posição de compartilhamento +Share this number:\n Compartilhe este número:\n +Short Name Nome abreviado +SOLO SOZINHO +Speaker Volume Volume do alto-falante +Sky Plot Gráfico do Céu +SSTV RX READY SSTV RX PRONTO +SSTV start failed Falha no início do SSTV +standing by aguardando Status Estado Stopped Parado Stop Parar +Stop recording first Pare a gravação primeiro Submit Enviar +Submit failed Falha no envio System Sistema -Team Equipa -Team Status Estado da equipa +SATELLITE STATUS STATUS DO SATÉLITE +Talk Conversar +TALK FALAR +Team Equipe +TEAM EQUIPE +Team chat send failed Falha no envio do bate-papo da equipe +Team chat unavailable Bate-papo da equipe indisponível +Team chat unavailable in RNode mode Bate-papo em equipe indisponível no modo RNode +team member position will appear here a posição do membro da equipe aparecerá aqui +Team keys not ready As chaves da equipe não estão prontas +Team location encode failed Falha na codificação do local da equipe +Text chat unavailable Bate-papo por texto indisponível +threads active encadeamentos ativos Time Zone Fuso horário -Tracker Seguimento +Tracker Rastreador +traffic active tráfego ativo +Trust Key Chave de confiança +U U +Unignore Designorar +Unread Não lido Unknown Desconhecido -USB Disk Disco USB -User Name Nome de utilizador +USB Mass Storage USB Mass Storage +USB Disk USB Disco +USB Bridge USB Ponte +USB host bridge USB ponte de host +USB Bridge is unavailable on this target. USB Bridge não está disponível neste destino. +USB CDC KISS modem for Reticulum USB Modem CDC KISS para Reticulum +USE USO +Used Usado +User Name Nome de usuário Verification Number Número de verificação +Verification number sent Número de verificação enviado Vibration Vibração -Clear all settings and restart? Limpar todas as definições e reiniciar? +VOL %d VOL%d +VOL 80 VOL 80 +waiting for better sky view | %u in view esperando por uma melhor visão do céu | %u em exibição +waiting for module GNSS compass aguardando módulo GNSS bússola +waiting for traffic aguardando trânsito +Waiting for host... Aguardando anfitrião... +Waiting for Reticulum host... Aguardando host Reticulum... +WAIT IMU ESPERE IMU +Walkie not available Walkie não disponível +Walkie start failed Falha na inicialização do walkie +Walkie Talkie Walkie Talkie +YES SIM +6 digits 6 dígitos +%u threads active %u tópicos ativos +%ud %ud +%uh %uh +%um %um +%umo %umo +%uy %uy +Confirm received code:\n Confirme o código recebido:\n +device heading from 9-axis module cabeçalho do dispositivo do módulo de 9 eixos +heading unlocks after first fix título é desbloqueado após a primeira correção +If it matches, trust the key. Se corresponder, confie na chave. +magnetometer heading active rumo do magnetômetro ativo +magnetometer still warming up magnetômetro ainda aquecendo +needle shows north until target lock agulha mostra o norte até o alvo travado +nearest peer | %.0f m away par mais próximo | %.0f m de distância +no recent traffic nenhum tráfego recente +now agora +%s layer missing %s camada ausente +Active: %s Ativo: %s +Alt: %.0f m Alt: %.0fm +Alt: -- m Alt: --m +Base: %s Base: %s +Battery critical Bateria crítica +Broadcast (%d) Transmissão (%d) +COMPLETE CONCLUÍDO +Calibrating... Calibrando... +Confirm Confirmar +Contour: OFF Contorno: DESLIGADO +Decoder error Erro do decodificador +Direct (%d) Direto (%d) +E E +ERROR ERRO +Exiting USB... Saindo de USB... +Hold Espera +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented Atualização de chave não implementada +Key verify: %s %s Verificação de chave: %s %s +Key verify: confirm %s %s Verificação de chave: confirme %s %s +Key verify: enter number for %s Verificação de chave: insira o número para %s +Key verify: send %s %s Verificação de chave: enviar %s %s +Later Mais tarde +Level %d Nível %d +Listening for SSTV signal... Ouvindo sinal SSTV... +Low battery: %d%% Bateria fraca: %d%% +MODE: %s MODO: %s +MODE: Auto MODO: Automático +Map - %.48s Mapa - %.48s +Map - %s Missing Mapa - %s Ausente +Map - No Map Data Mapa - Sem dados de mapa +Map - No SD Card Mapa - Sem cartão SD +Map - no gps data Mapa - sem dados de GPS +Message Mensagem +Mic TBD Microfone a definir +Mic init failed Falha na inicialização do microfone +Morse Input Entrada Morse +MoveTo Mover para +N N +NODE INFO INFORMAÇÕES DO NÓ +Name required Nome obrigatório +Name too long Nome muito longo +No KML routes Nenhuma rota KML +No SD Card Sem cartão SD +No routes folder Nenhuma pasta de rotas +No tracks yet Nenhuma faixa ainda +Only leader can manage Somente o líder pode gerenciar +Only leader can pair Somente o líder pode emparelhar +Paired successfully Emparelhado com sucesso +Pairing failed Falha no emparelhamento +Pairing init failed Falha na inicialização do emparelhamento +Pairing not available Emparelhamento não disponível +Pairing not ready O emparelhamento não está pronto +Press Back to exit USB mode Pressione Voltar para sair do modo USB +Press SPACE to resume Pressione ESPAÇO para continuar +RallyTo RallyTo +Route configured Rota configurada +Route: %s Rota: %s +SD Card Not Found\nPlease insert SD card SD Cartão não encontrado\nInsira o cartão SD +SSTV RECEIVER RECEPTOR SSTV +Save failed Falha ao salvar +Scan +%u/%u Digitalizar +%u/%u +Select level Selecione o nível +Selected level: %d Nível selecionado: %d +Shutting down to protect battery Desligando para proteger a bateria +Status: CALIB Status: CALIB +Step 1/2: Tap short dots Etapa 1/2: Toque nos pontos curtos +Step 2/2: Tap long dashes Etapa 2/2: Toque em traços longos +Switch to %s to chat Mude para %s para conversar +Symbol: Símbolo: +Tap to input, idle 3s to send Toque para inserir, inativo 3s para enviar +Team keys mismatch Incompatibilidade de chaves de equipe +Team: Equipe: +Text: Texto: +Unread: %d Não lido: %d +Unread: 0 Não lido: 0 +W W +0° Horizon Horizonte 0° +PSK must be 32 hex or 16 chars PSK deve ter 32 hexadecimais ou 16 caracteres +Restarting... Reiniciando... +awaiting position aguardando posição +compass wakes after GPS fix bússola desperta após correção de GPS +searching satellites pesquisando satélites +%s - %s - +%s 0s %s 0s +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO AUTOMÁTICO +Alt: %ld m Alt: %ldm +Alt: - Alt: - +BEST MELHOR +BW: %.0fk BW: %.0fk +BW: %.1fk BW: %.1fk +BW: - BW: - +CAD CAD +CURSOR CURSOR +Ch: %.3f Canal: %.3f +Ch: - Ch: - +Hop: %u Salto: %u +Hop: - Pulo: - +Hop: - / NH: %02X Pulo: - / NH: %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: Ouvido pela última vez: +Link Link +Link / MeshCore Link / MeshCore +Link / Meshtastic Link / Meshtastic +MODE: RSSI MODO: RSSI +NOISE %.0f dBm RUÍDO %.0f dBm +NOISE -104 dBm RUÍDO -104 dBm +PKI: - PKI: - +PKI: known PKI: conhecido +PKI: verified PKI: verificado +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI: %.0f dBm +RSSI: - RSSI: - +Role: %s Função: %s +SCAN VERIFICAR +SF: %u SF: %u +SF: - SF: - +SIM SIM +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1f dB +SNR: - SNR: - +SNR %.0f SNR %.0f +SNR - SNR - +SNR Good SNR Bom +SNR Weak SNR Fraco +STEP %.0fk | BW %.0fk ETAPA %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk ETAPA %.1fk | BW %.1fk +STEP -- | BW -- ETAPA -- | BW -- +STOP PARAR +Slot Slot +Slot %u Slot %u +SUB-GHz SCAN VARREDURA SUB-GHz +Actions Ações +Actions: %s Ações: %s +Access to team data revoked Acesso aos dados da equipe revogado +Capability: Capacidade: +Channel Actions Ações do canal +Confirm Kick Confirmar chute +Create Team Criar equipe +ELEV ELEV +Join Team Junte-se à equipe +In View Na visualização +Keep devices within 5m Mantenha os dispositivos dentro de 5m +KeyDist KeyDist +KeyId: %u KeyId: %u +KeyId: -- KeyId: -- +Keys Chaves +Kick Chute +Kick Member Expulsar Membro +Last paired: %s Último pareamento: %s +Last seen %uh ago Visto pela última vez há %uh +Last seen %um ago Visto pela última vez há %um +Last seen -- Visto pela última vez -- +Last update %us ago Última atualização há %us +Last update -- Última atualização -- +Last update 0s ago Última atualização há 0s +Leader Líder +Leader online Líder on-line +Leave Sair +Leave team? Sair da equipe? +Manage Gerenciar +Member: %s Membro: %s +Members Membros +Members: %u Membros: %u +Members: %u Online: %u Membros: %u On-line: %u +Modem Bridge Ponte de modem +No members yet Ainda não há membros +Not Used Não usado +Offline Off-line +Online On-line +Online: %u On-line: %u +Pair Again Emparelhar novamente +Pair Member Membro do Par +Pairing in progress Emparelhamento em andamento +Prev Anterior +Remove %s from team? Remover %s da equipe? +Request Keys Chaves de solicitação +Retry Tentar novamente +Security Round: %u Rodada de Segurança: %u +Security Round: -- Rodada de Segurança: -- +Security: OK (Round %u) Segurança: OK (Rodada %u) +Security: OK (Round --) Segurança: OK (Rodada --) +Seen %ud Visto %ud +Seen %uh Visto %uh +Seen %um Visto %um +Send failed (keys not ready) Falha no envio (chaves não prontas) +Status: %s Status: %s +Target: %s Alvo: %s +Team / %s Equipe / %s +Team Actions Ações da equipe +Team Health Saúde da Equipe +Team Status Status da equipe +Team: %s Equipe: %s +This clears local keys. Isso limpa as chaves locais. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. Isso atualizará a rodada de segurança.\nO membro não receberá mais\nmensagens da equipe ou pontos de referência. +Transfer Transferência +Transfer Leader Líder de transferência +View Team Ver Equipe +Waiting for new keys... Aguardando novas chaves... +Waypoint Ponto de referência +You are no longer in this team Você não está mais nesta equipe +You are not in a team Você não está em uma equipe +- No shared map\n- No team awareness - Nenhum mapa compartilhado\n- Sem conhecimento da equipe +1 member stale 1 estado membro +encrypt failed falha na criptografia +member membro +queue full fila cheia +unsupported protocol protocolo não suportado +Factory Reset Redefinição de fábrica +Clear all settings and restart? Limpar todas as configurações e reiniciar? +Terrain Terreno +Satellite Satélite +Contour: ON Contorno: LIGADO +Contour data missing Dados de contorno ausentes +%d enabled %d ativado +%s %s %s %s +Channel Key / PSK Chave do canal / PSK +Channel Slot Slot de canal +Channel Utilization Utilização do canal +Check for Updates Verificar atualizações +Clear Message DB Limpar banco de dados de mensagens +Connect Conectar +Contour Overlay Sobreposição de contorno +Coordinate Format Formato de coordenadas +Coordinate System Sistema de Coordenadas +Current Version Versão Atual +Detected Network Rede detectada +Disconnect Desconectar +Duty Cycle Limit Limite do Ciclo de Trabalho +Enabled IMEs Ativado IMEs +Encryption Mode Modo de criptografia +Freq Offset (MHz) Compensação de frequência (MHz) +GPS Enabled GPS Ativado +Hop Limit Limite de salto +IME setting update failed IME falha na atualização da configuração +Install Update Instalar atualização +Latest Version Versão mais recente +Layer Camada +Location Mode Modo de localização +MC Airtime Factor Fator de tempo de transmissão do MC +MC Bandwidth (kHz) Largura de banda MC (kHz) +MC Channel Key Chave do canal MC +MC Channel Name Nome do canal MC +MC Channel Slot Slot de canal MC +MC Coding Rate Taxa de codificação MC +MC Flood Max MC Inundação Máx. +MC Frequency (MHz) Frequência MC (MHz) +MC Multi ACKs MC Multi ACKs +MC RX Delay Base MC RX Base de atraso +MC Region Preset Predefinição de região MC +MC Repeat MC Repetir +MC Spread Factor Fator de propagação MC +MC TX Power MC TX Poder +Manual BW Manual BW +Manual CR Manual CR +Manual SF Manual SF +Map Source Fonte do mapa +Modem Preset Predefinição do modem +NMEA Export NMEA Exportar +NMEA Sentences NMEA Frases +No IME packs installed Nenhum pacote IME instalado +No position available Nenhuma vaga disponível +OTA Status OTA Status +Override Duty Cycle Ciclo de trabalho de substituição +Override Freq (MHz) Frequência de substituição (MHz) +Password Senha +Position Strategy Estratégia de Posição +SSID SSID +Satellite Systems Sistema Satélitems +Scan Networks Digitalizar redes +TX Enabled TX Ativado +TX Power TX Potência +Track Format Formato da trilha +Track Interval Intervalo de rastreamento +Track Recording Gravação de trilha +Unavailable Indisponível +Update Interval Intervalo de atualização +Use Preset Usar predefinição +Wi-Fi Enabled Wi-Fi Ativado +Wi-Fi start failed Wi-Fi falha na inicialização diff --git a/packs/europe-latin-ext/package.ini b/packs/europe-latin-ext/package.ini index cc6b072a..e18ec3f8 100644 --- a/packs/europe-latin-ext/package.ini +++ b/packs/europe-latin-ext/package.ini @@ -1,13 +1,13 @@ kind=package id=europe-latin-ext package_type=locale-bundle -version=1.0.0 +version=1.1.0 display_name=European Latin Extended -summary=Shared Latin-Extended font coverage plus German, Spanish, French, Italian, Dutch, Polish, and Portuguese locale packs. +summary=Shared Latin-Extended font coverage plus German, Spanish, French, Italian, Dutch, Polish, and Portuguese review locale packs. description=DESCRIPTION.txt readme=README.md author=Trail Mate homepage=https://vicliu624.github.io/trail-mate/ -min_firmware_version=0.1.20-alpha +min_firmware_version=0.1.24-alpha supported_memory_profiles=constrained,standard,extended -tags=language,latin,europe +tags=language,latin,europe,review diff --git a/packs/ja/README.md b/packs/ja/README.md index 2867adcd..3ebbea53 100644 --- a/packs/ja/README.md +++ b/packs/ja/README.md @@ -65,7 +65,7 @@ Notes: `python scripts/build_pack_repository.py --pack-root packs --site-root site` produces: -- `site/assets/packs/ja-1.0.0.zip` +- `site/assets/packs/ja-1.1.0.zip` - `site/data/packs.json` The zip is the bundle artifact for a future Extensions downloader. Its `payload/` directory unpacks into `/trailmate/packs/...`. @@ -82,4 +82,4 @@ Copy the bundle contents so the SD card ends up with: /trailmate/packs/locales/ja/strings.tsv ``` -After reboot, `日本語` appears in Settings. The font is loaded only when Japanese becomes the active locale or when Japanese content requires it. +After reboot, `日本語` appears in Settings only after the locale is promoted to `translation_status=release`. While it is in `review`, the runtime can install and index the pack but will not offer it as a selectable UI language. diff --git a/packs/ja/locales/ja/manifest.ini b/packs/ja/locales/ja/manifest.ini index d9251389..98c832f7 100644 --- a/packs/ja/locales/ja/manifest.ini +++ b/packs/ja/locales/ja/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=ja display_name=Japanese native_name=日本語 +translation_status=review ui_font_pack=ja-cjk content_font_pack=ja-cjk strings=strings.tsv diff --git a/packs/ja/locales/ja/strings.tsv b/packs/ja/locales/ja/strings.tsv index 86640753..a0f5c1e6 100644 --- a/packs/ja/locales/ja/strings.tsv +++ b/packs/ja/locales/ja/strings.tsv @@ -1,113 +1,618 @@ -Action 操作 +Action アクション Add 追加 -Advanced 詳細 -Always 常時 +Advanced 上級者向け +Always いつも +Altitude 高度 +Altitude Reference 高度リファレンス App アプリ +Area Cleared エリアをクリアしました Auto 自動 +Awaiting teammate チームメイトを待っています Back 戻る +Base Camp ベースキャンプ +Beijing (UTC+8) 北京 (UTC+8) +BLE bridge ready | %s BLE ブリッジの準備ができました | %s +BLE linked | %s BLE リンク済み | %s Bluetooth Bluetooth -Bluetooth Pairing Bluetoothペアリング +Bluetooth Pairing Bluetooth ペアリング +Bcast Bキャスト +Bridge error ブリッジエラー +Broadcast ブロードキャスト Cancel キャンセル Channel チャンネル Chat チャット +Chat unavailable チャットは利用できません Chinese 中国語 +Closes automatically after pairing ペアリング後は自動的に閉じます Close 閉じる +Command コマンド +Command: %s コマンド: %s +Command: %s %s コマンド: %s %s +Command: %s %s %s コマンド: %s %s %s +Compare Verification Code 検証コードの比較 +Compass コンパス +Connected 接続されました +Connected, handshaking... 接続されました、ハンドシェイク中... Contacts 連絡先 +Contacts (%s) 連絡先 (%s) +Contacts (LXMF) 連絡先 (LXMF) +Contacts (LXMF - %.3fMHz) 連絡先 (LXMF - %.3fMHz) +Contacts (MeshCore) 連絡先 (MeshCore) +Contacts (Meshtastic - %.3fMHz) 連絡先 (Meshtastic - %.3fMHz) +Contacts (RNode) 連絡先 (RNode) +Contacts (RNode - %.3fMHz) 連絡先 (RNode - %.3fMHz) +Conversation protocol mismatch 会話プロトコルが一致しません Data Exchange データ交換 +Debug Logs デバッグ ログ +Decoding: %d%% デコード: %d%% +Decoding: 0% デコード: 0% Delete 削除 +Delete %s? %s を削除しますか? +Delete contact %s? 連絡先 %s を削除しますか? +Delete failed 削除に失敗しました +Device: %s デバイス: %s +Direct 直接 Disabled 無効 -Discover 検出 +Discover 発見する Display Language 表示言語 +Duplicate name not allowed 重複した名前は許可されません Edit 編集 -Energy Sweep 周波数スイープ +Edit nickname ニックネームの編集 +Emergency SOS 緊急 SOS +Energy Sweep エネルギースイープ English 英語 English keyboard 英語キーボード +Enter nickname ニックネームを入力してください +Enter number for %s %s の番号を入力してください +Enter the 6-digit number 6桁の数字を入力してください +Enter this 6-digit PIN on your phone この 6 桁の PIN を携帯電話に入力してください Error エラー -Factory Reset 工場出荷時設定 +Error: %lu エラー: %lu +FSK Voice FSK音声 +Failed 失敗しました +Fix 修正 +Fixed PIN 固定PIN +Gauge Design (mAh) ゲージ設計 (mAh) +Gauge Full (mAh) ゲージフル (mAh) +Good Find 良い発見 GPS GPS Extensions 拡張機能 Language 言語 Package Details パッケージ詳細 -Back to list 一覧に戻る +Back to list リストに戻る Install インストール -Update 更新 +Update アップデート Uninstall アンインストール -Incompatible 非互換 -Compatible with this device このデバイスに対応 +Incompatible 互換性がありません +Compatible with this device このデバイスと互換性があります Compatibility: %s 互換性: %s -memory profile mismatch メモリプロファイル不一致 -needs %s %s が必要 -Update available: %s 更新あり: %s -Installed インストール済み +memory profile mismatch メモリ プロファイルの不一致 +needs %s には %s が必要です +Update available: %s 利用可能なアップデート: %s +Installed インストールされました Installed: %s インストール済み: %s Available to install インストール可能 -Blocked ブロック -%lu locales %lu fonts %lu IME %s %lu ロケール %lu フォント %lu IME %s -No Wi-Fi Wi-Fi なし -Installing package... パッケージをインストール中... -Package installed パッケージをインストールしました -Uninstalling package... パッケージをアンインストール中... -Package uninstalled パッケージをアンインストールしました +Blocked ブロックされました +%lu locales %lu fonts %lu IME %s %lu ロケール %lu フォント %lu IME %s +No Wi-Fi いいえ Wi-Fi +Installing package... パッケージをインストールしています... +Package installed パッケージがインストールされました +Uninstalling package... パッケージをアンインストールしています... +Package uninstalled パッケージがアンインストールされました No language packs available 利用可能な言語パックはありません Available language packs: %lu 利用可能な言語パック: %lu About 概要 Package Info パッケージ情報 -Contents 内容 -Source 提供元 -Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s アーカイブ: %s\n追加フォント RAM: %s\nプロファイル: %s\n%s +Contents 目次 +Source ソース +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s アーカイブ: %s\n追加フォント RAM: %s\n プロファイル: %s\n%s Locales: %s\nFonts: %s\nIME: %s ロケール: %s\nフォント: %s\nIME: %s -Author: %s\nHomepage: %s 作者: %s\nホームページ: %s +Author: %s\nHomepage: %s 著者: %s\nホームページ: %s None なし -GPS Status GPS状態 +GPS Status GPS ステータス +HDOP HDOP +Heading starting 見出し開始 Hide 隠す +Host bridge ホストブリッジ +Host connected, probing modem... ホストが接続され、モデムを調査中... +IDLE IDLE +Ignored 無視されました +Ignore 無視 +Ignore unavailable 利用不可を無視 +Ignore update failed アップデートの無視が失敗しました +Image received 画像を受信しました Info 情報 -Japanese 日本語 -Korean 韓国語 +Initializing... 初期化中... +Invalid airtime factor 無効な通信時間係数です +Invalid design capacity (mAh) 無効な設計容量 (mAh) +Invalid frequency offset 無効な周波数オフセットです +Invalid frequency value 無効な周波数値です +Invalid full capacity (mAh) 無効な全容量 (mAh) +Invalid marker 無効なマーカーです +Invalid MeshCore bandwidth 無効な MeshCore 帯域幅です +Invalid MeshCore frequency 無効な MeshCore 頻度です +Invalid number 無効な番号です +Invalid RX delay 無効な RX 遅延です +Key marked trusted 信頼できるとマークされたキー +Key must be 32 hex or 16 chars キーは 32 進数または 16 文字である必要があります +Key trust failed キーの信頼に失敗しました +Key Verification キーの検証 +LINKED リンク済み +LISTEN 聞いてください +LIVE ライブ Loading... 読み込み中... +Location 場所 +Location: %s 場所: %s +Location: %s %s 場所: %s %s +LoRa direct path | %s LoRa ダイレクト パス | %s +Low battery - audio disabled バッテリー残量低下 - オーディオが無効になっています Map 地図 -Map Layer 地図レイヤー -Nearby 近く +Map Layer マップレイヤー +MESH メッシュ +Mesh Status メッシュステータス +Mesh unavailable メッシュは使用できません +MeshCore only MeshCore のみ +Message DB cleared メッセージ DB がクリアされました +Messages メッセージ +MESSAGES メッセージ +Messages and broadcasts show here メッセージとブロードキャストがここに表示されます +Mini Compass ミニコンパス +MT send uses slot 0/1 only MT 送信はスロット 0/1 のみを使用します +N/A 該当なし +Nearby 近くにあります +Need local GPS ローカル GPS が必要です Network ネットワーク New 新規 -No messages メッセージなし -No recent traffic 最近の通信なし +new activity 新しいアクティビティ +Next 次へ +No GPS fix GPS 修正はありません +No image 画像がありません +No messages メッセージはありません +No preview プレビューなし +No recent traffic 最近のトラフィックはありません +No response 応答なし +No route selected ルートが選択されていません +No target ターゲットなし +No track files トラック ファイルがありません +Node DB reset ノード DB リセット +Node ignored ノードは無視されました +Node ignored and hidden ノードは無視され、非表示になりました +Node unignored ノードは無視されません +North locked 北側ロック Not set 未設定 +NO いいえ +NO FIX 修正なし Number keyboard 数字キーボード -OFF OFF +OFF オフ OK OK -ON ON +ON オン +online オンライン Pairing ペアリング -PC Link PC接続 -Power 電源 +PC Link PCリンク +PC Link is unavailable on this target. PC リンクはこのターゲットでは使用できません。 +peer lock | turn %03.0f deg ピアロック | %03.0f 度回転します +Pinyin keyboard ピンイン キーボード +Pinyin: %s ピンイン: %s +Position ポジション +position stream active | %u in view 位置ストリームがアクティブ | %u が表示されています +Power パワー +Press Back 戻るを押してください +Press RX to start RX を押して開始してください +Primary プライマリ Protocol プロトコル +Protocol switch failed プロトコル切り替えに失敗しました +Protocol switched プロトコルが切り替えられました +quiet net クワイエットネット +Random PIN ランダム PIN Ready 準備完了 +RECEIVING 受信中 Recent Activity 最近のアクティビティ +Record レコード +Recording 録音 +Region 地域 +Remain: %u 残り: %u Reply 返信 -Run 実行 +Reply disabled for this protocol このプロトコルでは応答が無効になっています +Reset Mesh Profiles メッシュ プロファイルをリセット +Reset Node DB ノード DB をリセット +Resetting... リセット中... +Return 戻る +Rally Point ラリーポイント +RNode Bridge RNode ブリッジ +RNode modem ready RNode モデムの準備ができました +RNode text chat runs on host RNode テキスト チャットがホストで実行されます +Route ルート +Run 走る +RX RX +RX: %lu TX: %lu RX: %lu TX: %lu +RX: 0 TX: 0 RX: 0 TX: 0 Save 保存 -Scan failed スキャン失敗 +Saved: %s 保存しました: %s +Scan failed スキャンに失敗しました Scan Local ローカルスキャン -Scanning 5s... 5秒スキャン中... -SEARCH 検索 +Scanning 5s... 5 秒間スキャン中... Screen Brightness 画面の明るさ Screen Timeout 画面タイムアウト +SEARCH 検索 +Secondary 二次 +Select a route ルートを選択してください +Select a track トラックを選択してください +Select marker マーカーを選択 +Select Track トラックを選択 +Selected: %s 選択済み: %s +Send 送信 +Send failed 送信に失敗しました +Send this code and compare:\n このコードを送信して比較してください:\n +Send ID Broadcast ID ブロードキャストを送信 +Send ID Local ID をローカルに送信 +Sending... 送信中... +Sent 送信済み Settings 設定 Setting 設定 -Sky Plot スカイプロット +Share Position Marker シェアポジションマーカー +Share this number:\n この番号を共有します:\n +Short Name 短縮名 +SOLO ソロ Speaker Volume スピーカー音量 -Status 状態 -Stopped 停止済み +Sky Plot スカイプロット +SSTV RX READY SSTV RX 準備完了 +SSTV start failed SSTV の開始に失敗しました +standing by スタンバイ +Status ステータス +Stopped 停止しました Stop 停止 +Stop recording first まず録音を停止してください Submit 送信 +Submit failed 送信できませんでした System システム +SATELLITE STATUS 衛星ステータス +Talk トーク +TALK トーク Team チーム -Team Status チーム状態 +TEAM チーム +Team chat send failed チームチャットの送信に失敗しました +Team chat unavailable チームチャットは使用できません +Team chat unavailable in RNode mode RNode モードではチーム チャットが利用できません +team member position will appear here チームメンバーの位置がここに表示されます +Team keys not ready チームキーの準備ができていません +Team location encode failed チームの位置エンコードに失敗しました +Text chat unavailable テキストチャットは利用できません +threads active スレッドがアクティブです Time Zone タイムゾーン -Tracker 軌跡 -Traditional Chinese 繁体字中国語 +Tracker トラッカー +traffic active トラフィックがアクティブです +Trust Key トラストキー +U U +Unignore 無視しない +Unread 未読 Unknown 不明 -USB Disk USBストレージ +USB Mass Storage USB 大容量ストレージ +USB Disk USB ディスク +USB Bridge USB ブリッジ +USB host bridge USB ホスト ブリッジ +USB Bridge is unavailable on this target. USB ブリッジはこのターゲットでは使用できません。 +USB CDC KISS modem for Reticulum USB Reticulum 用の CDC KISS モデム +USE 使用 +Used 中古 User Name ユーザー名 -Verification Number 確認番号 -Vibration バイブレーション -Clear all settings and restart? すべての設定を消去して再起動しますか? +Verification Number 認証番号 +Verification number sent 認証番号を送信しました +Vibration 振動 +VOL %d VOL %d +VOL 80 VOL 80 +waiting for better sky view | %u in view より良い空の眺めを待っています | %u が表示されています +waiting for module GNSS compass モジュール GNSS コンパスを待っています +waiting for traffic トラフィックを待っています +Waiting for host... ホストを待機しています... +Waiting for Reticulum host... Reticulum ホストを待機しています... +WAIT IMU 待ってください、イム +Walkie not available トランシーバーは利用できません +Walkie start failed トランシーバーの起動に失敗しました +Walkie Talkie トランシーバー +YES はい +6 digits 6桁 +%u threads active %u スレッドがアクティブです +%ud %ud +%uh %uh +%um %um +%umo %umo +%uy %uy +Confirm received code:\n 受信したコードを確認します:\n +device heading from 9-axis module 9 軸モジュールからのデバイス ヘディング +heading unlocks after first fix 最初の修正後に見出しのロックが解除される +If it matches, trust the key. 一致する場合、そのキーを信頼します。 +magnetometer heading active 磁力計の進行方向がアクティブです +magnetometer still warming up 磁力計はまだウォームアップ中です +needle shows north until target lock 目標ロックまで針は北を示します +nearest peer | %.0f m away 最も近いピア | %.0f メートル離れたところ +no recent traffic 最近のトラフィックはありません +now 現在 +%s layer missing %s 層がありません +Active: %s アクティブ: %s +Alt: %.0f m 代替: %.0f m +Alt: -- m Alt: --m +Base: %s ベース: %s +Battery critical バッテリーが異常です +Broadcast (%d) ブロードキャスト (%d) +COMPLETE 完了 +Calibrating... 校正中... +Confirm 確認 +Contour: OFF 輪郭: オフ +Decoder error デコーダーエラー +Direct (%d) 直接 (%d) +E E +ERROR エラー +Exiting USB... USB を終了しています... +Hold 保留 +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented キーのリフレッシュは実装されていません +Key verify: %s %s キーの検証: %s %s +Key verify: confirm %s %s キーの検証: %s %s を確認します +Key verify: enter number for %s キーの検証: %s の番号を入力してください +Key verify: send %s %s キーの検証: %s %s を送信します +Later 後で +Level %d レベル %d +Listening for SSTV signal... SSTV 信号を受信しています... +Low battery: %d%% バッテリー残量低下: %d%% +MODE: %s モード: %s +MODE: Auto モード: 自動 +Map - %.48s マップ - %.48s +Map - %s Missing マップ - %s がありません +Map - No Map Data 地図 - 地図データなし +Map - No SD Card マップ - SD カードなし +Map - no gps data 地図 - GPS データなし +Message メッセージ +Mic TBD マイク未定 +Mic init failed マイクの初期化に失敗しました +Morse Input モールス入力 +MoveTo 移動先 +N N +NODE INFO ノード情報 +Name required 名前は必須です +Name too long 名前が長すぎます +No KML routes KML ルートがありません +No SD Card SD カードがありません +No routes folder ルートフォルダーがありません +No tracks yet トラックはまだありません +Only leader can manage リーダーのみが管理可能 +Only leader can pair ペアリングできるのはリーダーだけです +Paired successfully ペアリングに成功しました +Pairing failed ペアリングに失敗しました +Pairing init failed ペアリングの初期化に失敗しました +Pairing not available ペアリングできません +Pairing not ready ペアリングの準備ができていません +Press Back to exit USB mode [戻る] を押して USB モードを終了します +Press SPACE to resume 再開するにはスペースを押してください +RallyTo ラリーへ +Route configured ルートが設定されました +Route: %s ルート: %s +SD Card Not Found\nPlease insert SD card SD カードが見つかりません\nSD カードを挿入してください +SSTV RECEIVER SSTV 受信機 +Save failed 保存に失敗しました +Scan +%u/%u +%u/%u をスキャンします +Select level レベルの選択 +Selected level: %d 選択されたレベル: %d +Shutting down to protect battery バッテリーを保護するためにシャットダウンしています +Status: CALIB ステータス: CALIB +Step 1/2: Tap short dots ステップ 1/2: 短いドットをタップします +Step 2/2: Tap long dashes ステップ 2/2: 長いダッシュをタップします +Switch to %s to chat %s に切り替えてチャットしてください +Symbol: シンボル: +Tap to input, idle 3s to send タップして入力、3 秒間放置して送信 +Team keys mismatch チームキーが一致しません +Team: チーム: +Text: テキスト: +Unread: %d 未読: %d +Unread: 0 未読: 0 +W W +0° Horizon 0° 地平線 +PSK must be 32 hex or 16 chars PSK は 32 進数または 16 文字でなければなりません +Restarting... 再起動しています... +awaiting position ポジション待ち +compass wakes after GPS fix GPS 修正後にコンパスが起動する +searching satellites 衛星を検索しています +%s - %s - +%s 0s %s 0s +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO 自動 +Alt: %ld m 代替: %ld m +Alt: - 代替: - +BEST BEST +BW: %.0fk BW: %.0fk +BW: %.1fk BW: %.1fk +BW: - BW: - +CAD CAD +CURSOR カーソル +Ch: %.3f Ch: %.3f +Ch: - 章: - +Hop: %u ホップ: %u +Hop: - ホップ: - +Hop: - / NH: %02X ホップ: - / NH: %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: 最後に聞いた内容: +Link リンク +Link / MeshCore リンク / MeshCore +Link / Meshtastic リンク / Meshtastic +MODE: RSSI モード: RSSI +NOISE %.0f dBm ノイズ %.0f dBm +NOISE -104 dBm ノイズ -104 dBm +PKI: - PKI:- +PKI: known PKI: 既知 +PKI: verified PKI: 確認済み +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI:%.0f dBm +RSSI: - RSSI:- +Role: %s 役割: %s +SCAN スキャン +SF: %u 扩频因子:%u +SF: - 扩频因子:- +SIM SIM +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1f dB +SNR: - SNR:- +SNR %.0f 信噪比 %.0f +SNR - SNR - +SNR Good SNR 良い +SNR Weak SNR 弱い +STEP %.0fk | BW %.0fk ステップ %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk ステップ %.1fk | BW %.1fk +STEP -- | BW -- ステップ -- | BW -- +STOP 停止 +Slot スロット +Slot %u スロット %u +SUB-GHz SCAN サブ GHz スキャン +Actions アクション +Actions: %s アクション: %s +Access to team data revoked チームデータへのアクセスが取り消されました +Capability: 能力: +Channel Actions チャネルアクション +Confirm Kick キック確定 +Create Team チームの作成 +ELEV エレブ +Join Team チームに参加する +In View 表示中 +Keep devices within 5m デバイスは 5 メートル以内に配置してください +KeyDist KeyDist +KeyId: %u キー ID: %u +KeyId: -- キー ID: -- +Keys キー +Kick キック +Kick Member キックメンバー +Last paired: %s 最後にペアリングされました: %s +Last seen %uh ago 最後に確認されたのは %u 時間前 +Last seen %um ago 最後に確認されたのは%u分前です +Last seen -- 最後に見た -- +Last update %us ago 最終更新日 %u 秒前 +Last update -- 最終更新 -- +Last update 0s ago 最終更新 0 秒前 +Leader リーダー +Leader online リーダーオンライン +Leave 退出 +Leave team? チームを辞めますか? +Manage 管理 +Member: %s メンバー: %s +Members メンバー +Members: %u メンバー: %u +Members: %u Online: %u メンバー: %u オンライン: %u +Modem Bridge モデムブリッジ +No members yet メンバーはまだいません +Not Used 未使用 +Offline オフライン +Online オンライン +Online: %u オンライン: %u +Pair Again 再度ペアリングします +Pair Member ペアメンバー +Pairing in progress ペアリング中です +Prev 前へ +Remove %s from team? %s をチームから削除しますか? +Request Keys キーのリクエスト +Retry 再試行 +Security Round: %u セキュリティ ラウンド: %u +Security Round: -- セキュリティラウンド: -- +Security: OK (Round %u) セキュリティ: OK (%u ラウンド) +Security: OK (Round --) セキュリティ:OK(丸--) +Seen %ud %ud を確認しました +Seen %uh %uh を確認しました +Seen %um %um を確認しました +Send failed (keys not ready) 送信失敗 (キーの準備ができていません) +Status: %s ステータス: %s +Target: %s ターゲット: %s +Team / %s チーム / %s +Team Actions チームの行動 +Team Health チームの健康 +Team Status チームステータス +Team: %s チーム: %s +This clears local keys. これにより、ローカルキーがクリアされます。 +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. これにより、セキュリティ ラウンドが更新されます。\nメンバーは\nチーム メッセージやウェイポイントを受信しなくなります。 +Transfer 転送 +Transfer Leader 転送リーダー +View Team チームを見る +Waiting for new keys... 新しいキーを待っています... +Waypoint ウェイポイント +You are no longer in this team あなたはもうこのチームのメンバーではありません +You are not in a team あなたはチームに属していません +- No shared map\n- No team awareness - 共有マップなし\n- チーム認識なし +1 member stale 1 メンバーが無効です +encrypt failed 暗号化に失敗しました +member メンバー +queue full キューがいっぱいです +unsupported protocol サポートされていないプロトコル +Factory Reset 工場出荷時設定にリセット +Clear all settings and restart? すべての設定をクリアして再起動しますか? +Terrain 地形 +Satellite 衛星 +Contour: ON 輪郭: オン +Contour data missing 等高線データがありません +%d enabled %d が有効になりました +%s %s %s %s +Channel Key / PSK Channel Key / PSK +Channel Slot チャネルスロット +Channel Utilization チャネル使用率 +Check for Updates アップデートを確認する +Clear Message DB クリアメッセージDB +Connect 接続する +Contour Overlay 輪郭オーバーレイ +Coordinate Format 座標形式 +Coordinate System 座標系 +Current Version 現在のバージョン +Detected Network 検出されたネットワーク +Disconnect 切断 +Duty Cycle Limit デューティ サイクル制限 +Enabled IMEs IMEs が有効になりました +Encryption Mode 暗号化モード +Freq Offset (MHz) 周波数オフセット (MHz) +GPS Enabled GPS 有効 +Hop Limit ホップ制限 +IME setting update failed IME 設定の更新に失敗しました +Install Update アップデートのインストール +Latest Version 最新バージョン +Layer レイヤー +Location Mode 位置情報モード +MC Airtime Factor MC 通信時間係数 +MC Bandwidth (kHz) MC 帯域幅 (kHz) +MC Channel Key MCチャンネルキー +MC Channel Name MC チャンネル名 +MC Channel Slot MC チャンネルスロット +MC Coding Rate MC 符号化率 +MC Flood Max MCフラッドマックス +MC Frequency (MHz) MC 周波数 (MHz) +MC Multi ACKs MC マルチ ACK +MC RX Delay Base MC RX 遅延ベース +MC Region Preset MC リージョン プリセット +MC Repeat MCリピート +MC Spread Factor MC スプレッドファクター +MC TX Power MC TX パワー +Manual BW マニュアル BW +Manual CR マニュアル CR +Manual SF マニュアル SF +Map Source マップソース +Modem Preset モデム プリセット +NMEA Export NMEA エクスポート +NMEA Sentences NMEA 文 +No IME packs installed IME パックがインストールされていません +No position available ポジションがありません +OTA Status OTA ステータス +Override Duty Cycle デューティ サイクルをオーバーライド +Override Freq (MHz) オーバーライド頻度 (MHz) +Password パスワード +Position Strategy ポジション戦略 +SSID SSID +Satellite Systems 衛星システムms +Scan Networks スキャンネットワーク +TX Enabled TX 有効 +TX Power TX パワー +Track Format トラックフォーマット +Track Interval トラック間隔 +Track Recording トラック録音 +Unavailable 利用不可 +Update Interval 更新間隔 +Use Preset プリセットを使用する +Wi-Fi Enabled Wi-Fi 有効 +Wi-Fi start failed Wi-Fi の開始に失敗しました diff --git a/packs/ja/package.ini b/packs/ja/package.ini index 6edfa7f8..06e4eee6 100644 --- a/packs/ja/package.ini +++ b/packs/ja/package.ini @@ -1,13 +1,13 @@ kind=package id=ja package_type=locale-bundle -version=1.0.0 +version=1.1.0 display_name=Japanese -summary=Japanese locale bundle with external CJK font coverage for UI text and mixed-language content. +summary=Japanese review locale bundle with external CJK font coverage for UI text and mixed-language content; display-only until Japanese input is implemented. description=DESCRIPTION.txt readme=README.md author=Trail Mate homepage=https://vicliu624.github.io/trail-mate/ -min_firmware_version=0.1.20-alpha +min_firmware_version=0.1.24-alpha supported_memory_profiles=constrained,standard,extended -tags=language,cjk,japanese +tags=language,cjk,japanese,review diff --git a/packs/ko/README.md b/packs/ko/README.md index f188e575..6f75f258 100644 --- a/packs/ko/README.md +++ b/packs/ko/README.md @@ -65,7 +65,7 @@ Notes: `python scripts/build_pack_repository.py --pack-root packs --site-root site` produces: -- `site/assets/packs/ko-1.0.0.zip` +- `site/assets/packs/ko-1.1.0.zip` - `site/data/packs.json` The zip is the bundle artifact for a future Extensions downloader. Its `payload/` directory unpacks into `/trailmate/packs/...`. @@ -82,4 +82,4 @@ Copy the bundle contents so the SD card ends up with: /trailmate/packs/locales/ko/strings.tsv ``` -After reboot, `한국어` appears in Settings. The font is loaded only when Korean becomes active or when Korean content needs coverage. +After reboot, `한국어` appears in Settings only after the locale is promoted to `translation_status=release`. While it is in `review`, the runtime can install and index the pack but will not offer it as a selectable UI language. diff --git a/packs/ko/locales/ko/manifest.ini b/packs/ko/locales/ko/manifest.ini index 05dcd227..f76f91f7 100644 --- a/packs/ko/locales/ko/manifest.ini +++ b/packs/ko/locales/ko/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=ko display_name=Korean native_name=한국어 +translation_status=review ui_font_pack=ko-cjk content_font_pack=ko-cjk strings=strings.tsv diff --git a/packs/ko/locales/ko/strings.tsv b/packs/ko/locales/ko/strings.tsv index 20ce6f3a..dbe2654f 100644 --- a/packs/ko/locales/ko/strings.tsv +++ b/packs/ko/locales/ko/strings.tsv @@ -1,113 +1,618 @@ -Action 작업 +Action 액션 Add 추가 Advanced 고급 Always 항상 +Altitude 고도 +Altitude Reference 고도 기준 App 앱 +Area Cleared 영역 삭제됨 Auto 자동 +Awaiting teammate 팀원을 기다리는 중 Back 뒤로 -Bluetooth 블루투스 -Bluetooth Pairing 블루투스 페어링 +Base Camp 베이스캠프 +Beijing (UTC+8) 베이징(UTC+8) +BLE bridge ready | %s BLE 브리지 준비됨 | %s +BLE linked | %s BLE 연결됨 | %s +Bluetooth Bluetooth +Bluetooth Pairing Bluetooth 페어링 +Bcast 비캐스트 +Bridge error 브릿지 오류 +Broadcast 방송 Cancel 취소 Channel 채널 Chat 채팅 +Chat unavailable 채팅 불가 Chinese 중국어 +Closes automatically after pairing 페어링 후 자동으로 닫힙니다. Close 닫기 +Command 명령 +Command: %s 명령: %s +Command: %s %s 명령: %s %s +Command: %s %s %s 명령: %s %s %s +Compare Verification Code 인증 코드 비교 +Compass 나침반 +Connected 연결됨 +Connected, handshaking... 연결됨, 핸드쉐이킹 중... Contacts 연락처 +Contacts (%s) 연락처(%s) +Contacts (LXMF) 연락처(LXMF) +Contacts (LXMF - %.3fMHz) 연락처(LXMF - %.3fMHz) +Contacts (MeshCore) 연락처(MeshCore) +Contacts (Meshtastic - %.3fMHz) 연락처(Meshtastic - %.3fMHz) +Contacts (RNode) 연락처(RNode) +Contacts (RNode - %.3fMHz) 연락처(RNode - %.3fMHz) +Conversation protocol mismatch 대화 프로토콜 불일치 Data Exchange 데이터 교환 +Debug Logs 디버그 로그 +Decoding: %d%% 디코딩: %d%% +Decoding: 0% 디코딩: 0% Delete 삭제 -Disabled 비활성화 -Discover 검색 +Delete %s? %s을 삭제하시겠습니까? +Delete contact %s? %s 연락처를 삭제하시겠습니까? +Delete failed 삭제 실패 +Device: %s 장치: %s +Direct 직접 +Disabled 비활성화됨 +Discover 발견 Display Language 표시 언어 +Duplicate name not allowed 중복된 이름은 허용되지 않습니다. Edit 편집 -Energy Sweep 주파수 스캔 +Edit nickname 닉네임 수정 +Emergency SOS 긴급 SOS +Energy Sweep 에너지 스윕 English 영어 English keyboard 영어 키보드 +Enter nickname 닉네임을 입력하세요 +Enter number for %s %s의 번호를 입력하세요. +Enter the 6-digit number 6자리 숫자를 입력하세요. +Enter this 6-digit PIN on your phone 휴대폰에 이 6자리 PIN을 입력하세요. Error 오류 -Factory Reset 공장 초기화 +Error: %lu 오류: %lu +FSK Voice FSK 음성 +Failed 실패 +Fix 수정 +Fixed PIN 고정 PIN +Gauge Design (mAh) 게이지 설계(mAh) +Gauge Full (mAh) 게이지 가득 참(mAh) +Good Find 좋은 발견 GPS GPS -Extensions 확장 기능 +Extensions 확장 Language 언어 -Package Details 패키지 세부 정보 +Package Details 패키지 세부사항 Back to list 목록으로 돌아가기 Install 설치 Update 업데이트 Uninstall 제거 Incompatible 호환되지 않음 -Compatible with this device 이 기기와 호환됨 +Compatible with this device 이 장치와 호환 가능 Compatibility: %s 호환성: %s memory profile mismatch 메모리 프로필 불일치 -needs %s %s 필요 +needs %s 에는 %s이 필요합니다. Update available: %s 업데이트 가능: %s Installed 설치됨 Installed: %s 설치됨: %s Available to install 설치 가능 Blocked 차단됨 -%lu locales %lu fonts %lu IME %s 로캘 %lu개 글꼴 %lu개 입력기 %lu개 %s -No Wi-Fi Wi-Fi 없음 +%lu locales %lu fonts %lu IME %s %lu 로케일 %lu 글꼴 %lu IME %s +No Wi-Fi 아니요 Wi-Fi Installing package... 패키지 설치 중... -Package installed 패키지가 설치되었습니다 +Package installed 패키지가 설치됨 Uninstalling package... 패키지 제거 중... -Package uninstalled 패키지가 제거되었습니다 -No language packs available 사용 가능한 언어 팩이 없습니다 +Package uninstalled 패키지가 제거되었습니다. +No language packs available 사용 가능한 언어 팩이 없습니다. Available language packs: %lu 사용 가능한 언어 팩: %lu -About 정보 +About 소개 Package Info 패키지 정보 -Contents 구성 -Source 출처 +Contents 내용 +Source 소스 Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s 아카이브: %s\n추가 글꼴 RAM: %s\n프로필: %s\n%s -Locales: %s\nFonts: %s\nIME: %s 로캘: %s\n글꼴: %s\n입력기: %s +Locales: %s\nFonts: %s\nIME: %s 로케일: %s\n글꼴: %s\nIME: %s Author: %s\nHomepage: %s 작성자: %s\n홈페이지: %s None 없음 GPS Status GPS 상태 +HDOP HDOP +Heading starting 방향 시작 Hide 숨기기 +Host bridge 호스트 브리지 +Host connected, probing modem... 호스트 연결됨, 모뎀 검색 중... +IDLE IDLE +Ignored 무시됨 +Ignore 무시 +Ignore unavailable 무시 불가 +Ignore update failed 업데이트 무시 실패 +Image received 이미지 수신됨 Info 정보 -Japanese 일본어 -Korean 한국어 -Loading... 로딩 중... +Initializing... 초기화 중... +Invalid airtime factor 유효하지 않은 방송 시간 요소 +Invalid design capacity (mAh) 잘못된 설계 용량(mAh) +Invalid frequency offset 잘못된 주파수 오프셋 +Invalid frequency value 잘못된 주파수 값 +Invalid full capacity (mAh) 유효하지 않은 전체 용량(mAh) +Invalid marker 잘못된 마커 +Invalid MeshCore bandwidth 잘못된 MeshCore 대역폭 +Invalid MeshCore frequency 잘못된 MeshCore 빈도 +Invalid number 잘못된 번호 +Invalid RX delay 잘못된 RX 지연 +Key marked trusted 신뢰할 수 있는 것으로 표시된 키 +Key must be 32 hex or 16 chars 키는 16진수 32자 또는 문자 16자여야 합니다. +Key trust failed 키 신뢰 실패 +Key Verification 키 검증 +LINKED 연결됨 +LISTEN 들어보세요 +LIVE 라이브 +Loading... 로드 중... +Location 위치 +Location: %s 위치: %s +Location: %s %s 위치: %s %s +LoRa direct path | %s LoRa 직접 경로 | %s +Low battery - audio disabled 배터리 부족 - 오디오 비활성화 Map 지도 -Map Layer 지도 레이어 -Nearby 근처 +Map Layer 지도 계층 +MESH 메시 +Mesh Status 메시 상태 +Mesh unavailable 메시를 사용할 수 없음 +MeshCore only MeshCore만 +Message DB cleared 메시지 DB가 지워졌습니다. +Messages 메시지 +MESSAGES 메시지 +Messages and broadcasts show here 여기에 메시지와 방송이 표시됩니다. +Mini Compass 미니 나침반 +MT send uses slot 0/1 only MT 전송은 슬롯 0/1만 사용합니다. +N/A 해당 없음 +Nearby 주변 +Need local GPS 로컬 GPS 필요 Network 네트워크 -New 새로 만들기 +New 신규 +new activity 새 활동 +Next 다음 +No GPS fix GPS 수정 사항 없음 +No image 이미지 없음 No messages 메시지 없음 +No preview 미리보기 없음 No recent traffic 최근 트래픽 없음 -Not set 설정 안 됨 +No response 응답 없음 +No route selected 선택한 경로가 없습니다. +No target 대상 없음 +No track files 트랙 파일 없음 +Node DB reset 노드 DB 재설정 +Node ignored 노드가 무시됨 +Node ignored and hidden 노드가 무시되고 숨겨집니다. +Node unignored 무시되지 않은 노드 +North locked 북쪽 잠김 +Not set 설정되지 않음 +NO 아니요 +NO FIX 수정 사항 없음 Number keyboard 숫자 키보드 -OFF 끔 -OK 확인 -ON 켬 +OFF 꺼짐 +OK 그렇군요 +ON 켜짐 +online 온라인 Pairing 페어링 -PC Link PC 연결 +PC Link PC 링크 +PC Link is unavailable on this target. 이 대상에서는 PC Link를 사용할 수 없습니다. +peer lock | turn %03.0f deg 피어 잠금 | %03.0f도 회전 +Pinyin keyboard 병음 키보드 +Pinyin: %s 병음: %s +Position 위치 +position stream active | %u in view 위치 스트림 활성 | %u 보기 중 Power 전원 +Press Back 뒤로 누르기 +Press RX to start 시작하려면 RX을 누르세요. +Primary 기본 Protocol 프로토콜 +Protocol switch failed 프로토콜 전환 실패 +Protocol switched 프로토콜이 전환됨 +quiet net 조용한 넷 +Random PIN 임의 PIN Ready 준비됨 +RECEIVING 수신 중 Recent Activity 최근 활동 -Reply 답장 +Record 기록 +Recording 녹음 중 +Region 지역 +Remain: %u 남은: %u +Reply 답변 +Reply disabled for this protocol 이 프로토콜에 대해서는 응답이 비활성화되었습니다. +Reset Mesh Profiles 메시 프로파일 재설정 +Reset Node DB 노드 DB 재설정 +Resetting... 재설정 중... +Return 반품 +Rally Point 집결지 +RNode Bridge RNode 브리지 +RNode modem ready RNode 모뎀 준비됨 +RNode text chat runs on host RNode 문자 채팅이 호스트에서 실행됩니다. +Route 경로 Run 실행 +RX RX +RX: %lu TX: %lu RX: %lu TX: %lu +RX: 0 TX: 0 RX: 0 TX: 0 Save 저장 +Saved: %s 저장됨: %s Scan failed 스캔 실패 Scan Local 로컬 스캔 Scanning 5s... 5초 스캔 중... -SEARCH 검색 Screen Brightness 화면 밝기 -Screen Timeout 화면 꺼짐 시간 +Screen Timeout 화면 시간 초과 +SEARCH 검색 +Secondary 2차 +Select a route 경로 선택 +Select a track 트랙 선택 +Select marker 마커 선택 +Select Track 트랙 선택 +Selected: %s 선택됨: %s +Send 보내기 +Send failed 보내기 실패 +Send this code and compare:\n 이 코드를 보내고 비교하십시오:\n +Send ID Broadcast ID 브로드캐스트 보내기 +Send ID Local ID 로컬 보내기 +Sending... 보내는 중... +Sent 보냄 Settings 설정 Setting 설정 -Sky Plot 위성도 -Speaker Volume 스피커 음량 +Share Position Marker 위치 마커 공유 +Share this number:\n 이 번호를 공유하세요:\n +Short Name 짧은 이름 +SOLO 솔로 +Speaker Volume 스피커 볼륨 +Sky Plot 스카이 플롯 +SSTV RX READY SSTV RX 준비됨 +SSTV start failed SSTV 시작 실패 +standing by 대기 중 Status 상태 Stopped 중지됨 Stop 중지 +Stop recording first 먼저 녹음을 중지하세요. Submit 제출 +Submit failed 제출 실패 System 시스템 +SATELLITE STATUS 위성 상태 +Talk 이야기 +TALK 이야기 Team 팀 -Team Status 팀 상태 +TEAM 팀 +Team chat send failed 팀 채팅 보내기 실패 +Team chat unavailable 팀 채팅을 사용할 수 없습니다. +Team chat unavailable in RNode mode RNode 모드에서는 팀 채팅을 사용할 수 없습니다. +team member position will appear here 팀원 위치가 여기에 표시됩니다. +Team keys not ready 팀 키가 준비되지 않았습니다. +Team location encode failed 팀 위치 인코딩 실패 +Text chat unavailable 문자 채팅을 사용할 수 없습니다. +threads active 스레드 활성 Time Zone 시간대 -Tracker 궤적 -Traditional Chinese 번체 중국어 +Tracker 추적기 +traffic active 트래픽 활성화 +Trust Key 신뢰 키 +U 유 +Unignore 무시하지 않음 +Unread 읽지 않음 Unknown 알 수 없음 -USB Disk USB 저장소 +USB Mass Storage USB 대용량 저장소 +USB Disk USB 디스크 +USB Bridge USB 브리지 +USB host bridge USB 호스트 브리지 +USB Bridge is unavailable on this target. USB 이 대상에서는 브리지를 사용할 수 없습니다. +USB CDC KISS modem for Reticulum USB Reticulum용 CDC KISS 모뎀 +USE 사용 +Used 중고 User Name 사용자 이름 -Verification Number 확인 번호 +Verification Number 인증번호 +Verification number sent 인증번호 전송됨 Vibration 진동 -Clear all settings and restart? 모든 설정을 지우고 다시 시작할까요? +VOL %d VOL %d +VOL 80 80권 +waiting for better sky view | %u in view 더 나은 하늘 전망을 기다리고 있습니다 | %u 보기 중 +waiting for module GNSS compass 모듈 GNSS 나침반을 기다리는 중 +waiting for traffic 트래픽 대기 중 +Waiting for host... 호스트를 기다리는 중... +Waiting for Reticulum host... Reticulum 호스트를 기다리는 중... +WAIT IMU 잠깐만요 IMU +Walkie not available 워키를 사용할 수 없습니다. +Walkie start failed 워키 시작 실패 +Walkie Talkie 워키토키 +YES 예 +6 digits 6자리 +%u threads active %u 스레드 활성 +%ud %ud +%uh %uh +%um %um +%umo %u월 +%uy %uy +Confirm received code:\n 수신된 코드 확인:\n +device heading from 9-axis module 9축 모듈에서 향하는 장치 +heading unlocks after first fix 첫 번째 수정 후 제목 잠금 해제 +If it matches, trust the key. 일치하면 키를 신뢰하십시오. +magnetometer heading active 자력계 방향 활성 +magnetometer still warming up 자력계가 아직 예열 중입니다. +needle shows north until target lock 바늘은 타겟 락까지 북쪽을 보여줍니다. +nearest peer | %.0f m away 가장 가까운 피어 | %.0fm 거리 +no recent traffic 최근 트래픽 없음 +now 지금 +%s layer missing %s 레이어 누락 +Active: %s 활성: %s +Alt: %.0f m 대체: %.0fm +Alt: -- m Alt: -- m +Base: %s 기본: %s +Battery critical 배터리 부족 +Broadcast (%d) 방송(%d) +COMPLETE 완료 +Calibrating... 교정 중... +Confirm 확인 +Contour: OFF 윤곽선: 꺼짐 +Decoder error 디코더 오류 +Direct (%d) 직접(%d) +E E +ERROR 오류 +Exiting USB... USB 종료 중... +Hold 보류 +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented 키 새로 고침이 구현되지 않았습니다. +Key verify: %s %s 키 확인: %s %s +Key verify: confirm %s %s 키 확인: %s %s 확인 +Key verify: enter number for %s 키 확인: %s에 대한 번호를 입력하세요. +Key verify: send %s %s 키 확인: %s %s 전송 +Later 나중에 +Level %d 레벨 %d +Listening for SSTV signal... SSTV 신호를 듣는 중... +Low battery: %d%% 배터리 부족: %d%% +MODE: %s 모드: %s +MODE: Auto 모드: 자동 +Map - %.48s 지도 - %.48s +Map - %s Missing 지도 - %s 누락 +Map - No Map Data 지도 - 지도 데이터 없음 +Map - No SD Card 지도 - SD 카드 없음 +Map - no gps data 지도 - GPS 데이터 없음 +Message 메시지 +Mic TBD 마이크 미정 +Mic init failed 마이크 초기화 실패 +Morse Input 모스 입력 +MoveTo 이동 위치 +N 아니요 +NODE INFO 노드 정보 +Name required 이름이 필요합니다. +Name too long 이름이 너무 깁니다. +No KML routes KML 경로 없음 +No SD Card SD 카드 없음 +No routes folder 경로 폴더 없음 +No tracks yet 아직 트랙이 없습니다. +Only leader can manage 리더만이 관리할 수 있습니다. +Only leader can pair 리더만 페어링할 수 있습니다. +Paired successfully 성공적으로 페어링되었습니다. +Pairing failed 페어링 실패 +Pairing init failed 페어링 초기화 실패 +Pairing not available 페어링 불가 +Pairing not ready 페어링이 준비되지 않음 +Press Back to exit USB mode USB 모드를 종료하려면 뒤로를 누르세요. +Press SPACE to resume 다시 시작하려면 SPACE를 누르세요. +RallyTo RallyTo +Route configured 경로 구성됨 +Route: %s 경로: %s +SD Card Not Found\nPlease insert SD card SD 카드를 찾을 수 없음\nSD 카드를 삽입하세요. +SSTV RECEIVER SSTV 수신기 +Save failed 저장 실패 +Scan +%u/%u 스캔 +%u/%u +Select level 레벨 선택 +Selected level: %d 선택한 레벨: %d +Shutting down to protect battery 배터리 보호를 위해 종료 중 +Status: CALIB 상태: 교정 +Step 1/2: Tap short dots 1/2단계: 짧은 점 탭하기 +Step 2/2: Tap long dashes 2/2단계: 긴 대시를 탭하세요. +Switch to %s to chat 채팅하려면 %s로 전환하세요. +Symbol: 기호: +Tap to input, idle 3s to send 입력하려면 탭하고, 전송하려면 3초 동안 기다리세요. +Team keys mismatch 팀 키 불일치 +Team: 팀: +Text: 텍스트: +Unread: %d 읽지 않음: %d +Unread: 0 읽지 않음: 0 +W 여 +0° Horizon 0° 수평 +PSK must be 32 hex or 16 chars PSK은 32진수 또는 16자여야 합니다. +Restarting... 다시 시작하는 중... +awaiting position 대기 위치 +compass wakes after GPS fix GPS 수정 후 나침반이 깨어납니다. +searching satellites 위성 검색 중 +%s - %s - +%s 0s %s 0초 +%s %us %s %us +%s %um %s %um +%s %uh %s %uh +%s %ud %s %ud +AUTO 자동 +Alt: %ld m 대체: %ldm +Alt: - 대체: - +BEST 최고 +BW: %.0fk BW: %.0fk +BW: %.1fk BW: %.1fk +BW: - BW: - +CAD CAD +CURSOR 커서 +Ch: %.3f 채널: %.3f +Ch: - 채널: - +Hop: %u 홉: %u +Hop: - 홉: - +Hop: - / NH: %02X 홉: - / NH: %02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: 마지막 청취: +Link 링크 +Link / MeshCore 링크 / MeshCore +Link / Meshtastic 링크 / Meshtastic +MODE: RSSI 모드: RSSI +NOISE %.0f dBm 소음 %.0f dBm +NOISE -104 dBm 소음 -104 dBm +PKI: - PKI:- +PKI: known PKI: 알려져 있음 +PKI: verified PKI: 확인됨 +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI:%.0f dBm +RSSI: - RSSI:- +Role: %s 역할: %s +SCAN 스캔 +SF: %u 扩频因子:%u +SF: - 扩频因子:- +SIM SIM +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR: %.1fdB +SNR: - SNR:- +SNR %.0f 信噪比 %.0f +SNR - SNR - +SNR Good SNR 좋음 +SNR Weak SNR 약함 +STEP %.0fk | BW %.0fk 단계 %.0fk | BW %.0fk +STEP %.1fk | BW %.1fk 단계 %.1fk | BW %.1fk +STEP -- | BW -- 단계 -- | BW -- +STOP 중지 +Slot 슬롯 +Slot %u 슬롯 %u +SUB-GHz SCAN SUB-GHz 스캔 +Actions 조치 +Actions: %s 작업: %s +Access to team data revoked 팀 데이터에 대한 액세스가 취소되었습니다. +Capability: 기능: +Channel Actions 채널 작업 +Confirm Kick 킥 확인 +Create Team 팀 만들기 +ELEV ELEV +Join Team 팀에 합류 +In View 보기 중 +Keep devices within 5m 장치를 5m 이내에 유지하십시오. +KeyDist KeyDist +KeyId: %u 키 ID: %u +KeyId: -- 키 ID: -- +Keys 키 +Kick 킥 +Kick Member 멤버 추방 +Last paired: %s 마지막 페어링: %s +Last seen %uh ago %u시간 전에 마지막으로 확인됨 +Last seen %um ago 마지막으로 확인된 날짜: %u분 전 +Last seen -- 마지막 확인 -- +Last update %us ago 마지막 업데이트 %u초 전 +Last update -- 마지막 업데이트 -- +Last update 0s ago 마지막 업데이트 0초 전 +Leader 리더 +Leader online 온라인 리더 +Leave 나가기 +Leave team? 팀을 탈퇴하시겠습니까? +Manage 관리 +Member: %s 회원: %s +Members 회원 +Members: %u 회원: %u +Members: %u Online: %u 회원: %u 온라인: %u +Modem Bridge 모뎀 브리지 +No members yet 아직 회원이 없습니다. +Not Used 사용되지 않음 +Offline 오프라인 +Online 온라인 +Online: %u 온라인: %u +Pair Again 다시 페어링 +Pair Member 페어 멤버 +Pairing in progress 페어링 진행 중 +Prev 이전 +Remove %s from team? 팀에서 %s을(를) 제거하시겠습니까? +Request Keys 키 요청 +Retry 재시도 +Security Round: %u 보안 라운드: %u +Security Round: -- 보안 라운드: -- +Security: OK (Round %u) 보안: OK(라운드 %u) +Security: OK (Round --) 보안: OK(라운드 --) +Seen %ud %ud 확인됨 +Seen %uh %uh 확인됨 +Seen %um %um 확인됨 +Send failed (keys not ready) 보내기 실패(키가 준비되지 않음) +Status: %s 상태: %s +Target: %s 대상: %s +Team / %s 팀 / %s +Team Actions 팀 조치 +Team Health 팀 건강 +Team Status 팀 현황 +Team: %s 팀: %s +This clears local keys. 이는 로컬 키를 지웁니다. +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. 그러면 보안 라운드가 업데이트됩니다.\n구성원은 더 이상\n팀 메시지나 경유지를 받지 않습니다. +Transfer 전송 +Transfer Leader 전송 리더 +View Team 팀 보기 +Waiting for new keys... 새 키를 기다리는 중... +Waypoint 웨이포인트 +You are no longer in this team 귀하는 더 이상 이 팀에 속하지 않습니다. +You are not in a team 귀하는 팀에 속해 있지 않습니다. +- No shared map\n- No team awareness - 공유 지도 없음\n- 팀 인식 없음 +1 member stale 회원 1명 부실 +encrypt failed 암호화 실패 +member 회원 +queue full 대기열이 꽉 찼습니다. +unsupported protocol 지원되지 않는 프로토콜 +Factory Reset 공장 초기화 +Clear all settings and restart? 모든 설정을 지우고 다시 시작하시겠습니까? +Terrain 지형 +Satellite 위성 +Contour: ON 윤곽: ON +Contour data missing 윤곽 데이터 누락 +%d enabled %d 활성화됨 +%s %s %s %s +Channel Key / PSK 채널 키 / PSK +Channel Slot 채널 슬롯 +Channel Utilization 채널 활용도 +Check for Updates 업데이트 확인 +Clear Message DB 메시지 DB 지우기 +Connect 연결 +Contour Overlay 윤곽 오버레이 +Coordinate Format 좌표 형식 +Coordinate System 좌표계 +Current Version 현재 버전 +Detected Network 감지된 네트워크 +Disconnect 연결 끊기 +Duty Cycle Limit 듀티 사이클 제한 +Enabled IMEs 활성화됨 IMEs +Encryption Mode 암호화 모드 +Freq Offset (MHz) 주파수 오프셋(MHz) +GPS Enabled GPS 활성화됨 +Hop Limit 홉 제한 +IME setting update failed IME 설정 업데이트 실패 +Install Update 업데이트 설치 +Latest Version 최신 버전 +Layer 레이어 +Location Mode 위치 모드 +MC Airtime Factor MC 방송시간 계수 +MC Bandwidth (kHz) MC 대역폭(kHz) +MC Channel Key MC 채널 키 +MC Channel Name MC 채널 이름 +MC Channel Slot MC 채널 슬롯 +MC Coding Rate MC 코딩율 +MC Flood Max MC 플러드 맥스 +MC Frequency (MHz) MC 빈도(MHz) +MC Multi ACKs MC 다중 ACK +MC RX Delay Base MC RX 지연 베이스 +MC Region Preset MC 지역 프리셋 +MC Repeat MC 반복 +MC Spread Factor MC 확산 계수 +MC TX Power MC TX 전원 +Manual BW 수동 BW +Manual CR 수동 CR +Manual SF 수동 SF +Map Source 맵 소스 +Modem Preset 모뎀 사전 설정 +NMEA Export NMEA 내보내기 +NMEA Sentences NMEA 문장 +No IME packs installed IME 팩이 설치되지 않았습니다. +No position available 사용 가능한 직책이 없습니다. +OTA Status OTA 상태 +Override Duty Cycle 듀티 사이클 무시 +Override Freq (MHz) 주파수 재정의(MHz) +Password 비밀번호 +Position Strategy 포지션 전략 +SSID SSID +Satellite Systems 위성 시스템ms +Scan Networks 네트워크 스캔 +TX Enabled TX 활성화됨 +TX Power TX 전원 +Track Format 트랙 형식 +Track Interval 추적 간격 +Track Recording 트랙 녹음 +Unavailable 사용할 수 없음 +Update Interval 업데이트 간격 +Use Preset 프리셋 사용 +Wi-Fi Enabled Wi-Fi 활성화됨 +Wi-Fi start failed Wi-Fi 시작 실패 diff --git a/packs/ko/package.ini b/packs/ko/package.ini index ee8dd4b8..9a548ec4 100644 --- a/packs/ko/package.ini +++ b/packs/ko/package.ini @@ -1,13 +1,13 @@ kind=package id=ko package_type=locale-bundle -version=1.0.0 +version=1.1.0 display_name=Korean -summary=Korean locale bundle with external CJK font coverage for UI text and mixed-language content. +summary=Korean review locale bundle with external CJK font coverage for UI text and mixed-language content; display-only until Korean input is implemented. description=DESCRIPTION.txt readme=README.md author=Trail Mate homepage=https://vicliu624.github.io/trail-mate/ -min_firmware_version=0.1.20-alpha +min_firmware_version=0.1.24-alpha supported_memory_profiles=constrained,standard,extended -tags=language,cjk,korean +tags=language,cjk,korean,review diff --git a/packs/zh-Hans/README.md b/packs/zh-Hans/README.md index 1ee90beb..b0b17de8 100644 --- a/packs/zh-Hans/README.md +++ b/packs/zh-Hans/README.md @@ -125,7 +125,7 @@ Notes: `python scripts/build_pack_repository.py --pack-root packs --site-root site` produces: -- `site/assets/packs/zh-Hans-1.0.0.zip` +- `site/assets/packs/zh-Hans-1.1.0.zip` - `site/data/packs.json` The zip is the bundle artifact used by the Extensions downloader. Its `payload/` diff --git a/packs/zh-Hans/locales/zh-Hans/manifest.ini b/packs/zh-Hans/locales/zh-Hans/manifest.ini index 4b1253f1..f9d08dce 100644 --- a/packs/zh-Hans/locales/zh-Hans/manifest.ini +++ b/packs/zh-Hans/locales/zh-Hans/manifest.ini @@ -2,6 +2,7 @@ kind=locale id=zh-Hans display_name=Simplified Chinese native_name=简体中文 +translation_status=release ui_font_pack=zh-hans-core content_font_pack=zh-hans-core preferred_content_supplement_packs=zh-hans-ext diff --git a/packs/zh-Hans/locales/zh-Hans/strings.tsv b/packs/zh-Hans/locales/zh-Hans/strings.tsv index 6f1003bf..097690ae 100644 --- a/packs/zh-Hans/locales/zh-Hans/strings.tsv +++ b/packs/zh-Hans/locales/zh-Hans/strings.tsv @@ -550,3 +550,69 @@ Terrain 地形图 Satellite 卫星图 Contour: ON 等高线:开 Contour data missing 缺少等高线数据 +%d enabled 已启用 %d 个 +%s %s %s %s +Channel Key / PSK 频道金钥 / PSK +Channel Slot 频道槽 +Channel Utilization 频道使用率 +Check for Updates 检查更新 +Clear Message DB 清除讯息资料库 +Connect 连线 +Contour Overlay 等高线叠加 +Coordinate Format 座标格式 +Coordinate System 座标系统 +Current Version 目前版本 +Detected Network 侦测到的网路 +Disconnect 中断连线 +Duty Cycle Limit 占空比限制 +Enabled IMEs 已启用的输入法 +Encryption Mode 加密模式 +Freq Offset (MHz) 频率偏移 (MHz) +GPS Enabled 启用 GPS +Hop Limit 跳数限制 +IME setting update failed 输入法设定更新失败 +Install Update 安装更新 +Latest Version 最新版本 +Layer 图层 +Location Mode 定位模式 +MC Airtime Factor MC 空中时间系数 +MC Bandwidth (kHz) MC 频宽 (kHz) +MC Channel Key MC 频道金钥 +MC Channel Name MC 频道名称 +MC Channel Slot MC 频道槽 +MC Coding Rate MC 编码率 +MC Flood Max MC 最大泛洪数 +MC Frequency (MHz) MC 频率 (MHz) +MC Multi ACKs MC 多重 ACK +MC RX Delay Base MC 接收延迟基准 +MC Region Preset MC 区域预设 +MC Repeat MC 中继 +MC Spread Factor MC 展频因子 +MC TX Power MC 发射功率 +Manual BW 手动频宽 +Manual CR 手动编码率 +Manual SF 手动展频因子 +Map Source 地图来源 +Modem Preset 数据机预设 +NMEA Export NMEA 汇出 +NMEA Sentences NMEA 语句 +No IME packs installed 尚未安装输入法套件 +No position available 无可用位置 +OTA Status OTA 状态 +Override Duty Cycle 覆写占空比 +Override Freq (MHz) 覆写频率 (MHz) +Password 密码 +Position Strategy 定位策略 +SSID SSID +Satellite Systems 卫星系统 +Scan Networks 扫描网路 +TX Enabled 启用发射 +TX Power 发射功率 +Track Format 轨迹格式 +Track Interval 轨迹间隔 +Track Recording 轨迹记录 +Unavailable 无法使用 +Update Interval 更新间隔 +Use Preset 使用预设 +Wi-Fi Enabled 启用 Wi-Fi +Wi-Fi start failed Wi-Fi 启动失败 diff --git a/packs/zh-Hans/package.ini b/packs/zh-Hans/package.ini index 14b1813d..278f8f8c 100644 --- a/packs/zh-Hans/package.ini +++ b/packs/zh-Hans/package.ini @@ -1,13 +1,13 @@ kind=package id=zh-Hans package_type=locale-bundle -version=1.0.0 +version=1.1.0 display_name=Simplified Chinese summary=Tiered Simplified Chinese locale bundle with a fast core font, an on-demand extension font, and the built-in Pinyin IME backend. description=DESCRIPTION.txt readme=README.md author=Trail Mate homepage=https://vicliu624.github.io/trail-mate/ -min_firmware_version=0.1.20-alpha +min_firmware_version=0.1.24-alpha supported_memory_profiles=standard,extended tags=language,cjk,chinese,ime diff --git a/packs/zh-Hant/README.md b/packs/zh-Hant/README.md index bdd66ec5..7f5396c4 100644 --- a/packs/zh-Hant/README.md +++ b/packs/zh-Hant/README.md @@ -65,7 +65,7 @@ Notes: `python scripts/build_pack_repository.py --pack-root packs --site-root site` produces: -- `site/assets/packs/zh-Hant-1.0.0.zip` +- `site/assets/packs/zh-Hant-1.1.0.zip` - `site/data/packs.json` The zip is the bundle artifact for a future Extensions downloader. Its `payload/` directory unpacks into `/trailmate/packs/...`. @@ -82,4 +82,4 @@ Copy the bundle contents so the SD card ends up with: /trailmate/packs/locales/zh-Hant/strings.tsv ``` -After reboot, `繁體中文` appears in Settings. The font stays unloaded until the locale is activated or Traditional Chinese content needs coverage. +After reboot, `繁體中文(台灣)` appears in Settings only after the locale is promoted to `translation_status=release`. While it is in `review`, the runtime can install and index the pack but will not offer it as a selectable UI language. diff --git a/packs/zh-Hant/locales/zh-Hant/manifest.ini b/packs/zh-Hant/locales/zh-Hant/manifest.ini index e6b5ac2c..700150b4 100644 --- a/packs/zh-Hant/locales/zh-Hant/manifest.ini +++ b/packs/zh-Hant/locales/zh-Hant/manifest.ini @@ -1,7 +1,8 @@ kind=locale id=zh-Hant -display_name=Traditional Chinese -native_name=繁體中文 +display_name=Traditional Chinese (Taiwan) +native_name=繁體中文(台灣) +translation_status=review ui_font_pack=zh-hant-cjk content_font_pack=zh-hant-cjk strings=strings.tsv diff --git a/packs/zh-Hant/locales/zh-Hant/strings.tsv b/packs/zh-Hant/locales/zh-Hant/strings.tsv index 1129f345..ec839521 100644 --- a/packs/zh-Hant/locales/zh-Hant/strings.tsv +++ b/packs/zh-Hant/locales/zh-Hant/strings.tsv @@ -2,112 +2,617 @@ Action 操作 Add 新增 Advanced 進階 Always 常亮 -App 應用 +Altitude 高度 +Altitude Reference 高度參考 +App 應用程式 +Area Cleared 區域清除 Auto 自動 +Awaiting teammate 等待隊友 Back 返回 +Base Camp 營地 +Beijing (UTC+8) 北京 (UTC+8) +BLE bridge ready | %s BLE 橋接已就緒 | %s +BLE linked | %s BLE 已連線 | %s Bluetooth 藍牙 Bluetooth Pairing 藍牙配對 +Bcast 廣播 +Bridge error 橋接錯誤 +Broadcast 廣播 Cancel 取消 Channel 頻道 Chat 聊天 +Chat unavailable 聊天不可用 Chinese 中文 +Closes automatically after pairing 配對完成後將自動關閉 Close 關閉 +Command 指令 +Command: %s 指令:%s +Command: %s %s 指令:%s %s +Command: %s %s %s 指令:%s %s %s +Compare Verification Code 比對驗證碼 +Compass 指南針 +Connected 已連線 +Connected, handshaking... 已連線,正在握手... Contacts 聯絡人 +Contacts (%s) 聯絡人(%s) +Contacts (LXMF) 聯絡人(LXMF) +Contacts (LXMF - %.3fMHz) 聯絡人(LXMF - %.3fMHz) +Contacts (MeshCore) 聯絡人(MeshCore) +Contacts (Meshtastic - %.3fMHz) 聯絡人(Meshtastic - %.3fMHz) +Contacts (RNode) 聯絡人(RNode) +Contacts (RNode - %.3fMHz) 聯絡人(RNode - %.3fMHz) +Conversation protocol mismatch 會話協議不匹配 Data Exchange 資料交換 +Debug Logs 除錯日誌 +Decoding: %d%% 解碼中:%d%% +Decoding: 0% 解碼中:0% Delete 刪除 +Delete %s? 刪除 %s? +Delete contact %s? 刪除聯絡人 %s? +Delete failed 刪除失敗 +Device: %s 裝置:%s +Direct 私聊 Disabled 已停用 Discover 發現 Display Language 顯示語言 +Duplicate name not allowed 名稱不能重複 Edit 編輯 +Edit nickname 編輯暱稱 +Emergency SOS 緊急求救 Energy Sweep 頻譜掃描 English English English keyboard 英文鍵盤 +Enter nickname 輸入暱稱 +Enter number for %s 輸入 %s 的校驗數字 +Enter the 6-digit number 請輸入 6 位數字 +Enter this 6-digit PIN on your phone 請在手機上輸入這個 6 位 PIN 碼 Error 錯誤 -Factory Reset 恢復原廠 +Error: %lu 錯誤:%lu +FSK Voice FSK 語音 +Failed 失敗 +Fix 定位 +Fixed PIN 固定 PIN +Gauge Design (mAh) 電池設計容量 (mAh) +Gauge Full (mAh) 電池滿充容量 (mAh) +Good Find 發現目標 GPS GPS -Extensions 擴充 +Extensions 擴充套件 Language 語言 -Package Details 套件詳情 -Back to list 返回清單 +Package Details 套件詳細資料 +Back to list 返回列表 Install 安裝 Update 更新 Uninstall 解除安裝 Incompatible 不相容 Compatible with this device 與此裝置相容 Compatibility: %s 相容性:%s -memory profile mismatch 記憶體設定檔不相符 +memory profile mismatch 記憶體配置不匹配 needs %s 需要 %s Update available: %s 有可用更新:%s Installed 已安裝 Installed: %s 已安裝:%s Available to install 可安裝 -Blocked 已封鎖 +Blocked 已阻止 %lu locales %lu fonts %lu IME %s %lu 個語言 %lu 個字型 %lu 個輸入法 %s No Wi-Fi 無 Wi-Fi -Installing package... 正在安裝套件... +Installing package... 正在安裝軟體包... Package installed 套件已安裝 -Uninstalling package... 正在解除安裝套件... -Package uninstalled 套件已解除安裝 +Uninstalling package... 正在解除安裝軟體包... +Package uninstalled 套件已移除 No language packs available 沒有可用語言包 -Available language packs: %lu 可用語言包:%lu +Available language packs: %lu 可用語言套件:%lu About 關於 Package Info 套件資訊 Contents 內容 Source 來源 -Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s 封存檔:%s\n額外字型 RAM:%s\n設定檔:%s\n%s +Archive: %s\nExtra font RAM: %s\nProfiles: %s\n%s 壓縮包:%s\n額外字型 RAM:%s\n配置:%s\n%s Locales: %s\nFonts: %s\nIME: %s 語言:%s\n字型:%s\n輸入法:%s -Author: %s\nHomepage: %s 作者:%s\n首頁:%s +Author: %s\nHomepage: %s 作者:%s\n主頁:%s None 無 GPS Status GPS 狀態 +HDOP HDOP +Heading starting 航向啟動中 Hide 隱藏 +Host bridge 主機橋接 +Host connected, probing modem... 主機已連線,正在偵測數據機... +IDLE 空閒 +Ignored 已忽略 +Ignore 忽略 +Ignore unavailable 當前無法忽略 +Ignore update failed 忽略狀態更新失敗 +Image received 影象已接收 Info 資訊 -Japanese 日文 -Korean 韓文 +Initializing... 初始化中... +Invalid airtime factor 空中時間係數無效 +Invalid design capacity (mAh) 設計容量 (mAh) 無效 +Invalid frequency offset 頻偏無效 +Invalid frequency value 頻率值無效 +Invalid full capacity (mAh) 滿充容量 (mAh) 無效 +Invalid marker 無效標記 +Invalid MeshCore bandwidth MeshCore 頻寬無效 +Invalid MeshCore frequency MeshCore 頻率無效 +Invalid number 數字無效 +Invalid RX delay 接收延遲無效 +Key marked trusted 金鑰已標記為可信 +Key must be 32 hex or 16 chars 金鑰必須為 32 位十六進位制或 16 個字元 +Key trust failed 金鑰信任失敗 +Key Verification 金鑰校驗 +LINKED 已組網 +LISTEN 監聽 +LIVE 實時 Loading... 載入中... +Location 位置 +Location: %s 位置:%s +Location: %s %s 位置:%s %s +LoRa direct path | %s LoRa 直連 | %s +Low battery - audio disabled 電量低,已禁用音訊 Map 地圖 Map Layer 地圖圖層 +MESH 網狀 +Mesh Status 網狀狀態 +Mesh unavailable Mesh 不可用 +MeshCore only 僅支援 MeshCore +Message DB cleared 訊息資料庫已清除 +Messages 訊息 +MESSAGES 訊息 +Messages and broadcasts show here 訊息和廣播會顯示在這裡 +Mini Compass 迷你指南針 +MT send uses slot 0/1 only Meshtastic 傳送僅支援槽位 0/1 +N/A 不適用 Nearby 附近 +Need local GPS 需要本機 GPS Network 網路 New 新建 -No messages 沒有訊息 +new activity 有新動態 +Next 下一頁 +No GPS fix 尚無 GPS 定位 +No image 沒有影象 +No messages 暫無訊息 +No preview 無預覽 No recent traffic 暫無最近活動 +No response 無響應 +No route selected 未選擇路線 +No target 沒有目標 +No track files 沒有軌跡檔案 +Node DB reset 節點資料庫已重置 +Node ignored 節點已忽略 +Node ignored and hidden 節點已忽略並隱藏 +Node unignored 已取消忽略節點 +North locked 北向已鎖定 Not set 未設定 +NO 否 +NO FIX 無定位 Number keyboard 數字鍵盤 OFF 關 OK 確定 ON 開 +online 線上 Pairing 配對 PC Link 電腦連線 -Power 電源 +PC Link is unavailable on this target. 當前目標暫不支援電腦連線. +peer lock | turn %03.0f deg 目標鎖定 | 轉向 %03.0f 度 +Pinyin keyboard 拼音鍵盤 +Pinyin: %s 拼音:%s +Position 位置 +position stream active | %u in view 定位流活躍 | 可見 %u 顆 +Power 電量 +Press Back 按返回鍵 +Press RX to start 按 RX 開始 +Primary 主通道 Protocol 協議 +Protocol switch failed 協議切換失敗 +Protocol switched 協議已切換 +quiet net 網路安靜 +Random PIN 隨機 PIN Ready 就緒 +RECEIVING 接收中 Recent Activity 最近活動 +Record 記錄 +Recording 記錄中 +Region 區域 +Remain: %u 剩餘:%u Reply 回覆 +Reply disabled for this protocol 此協議下不能回覆 +Reset Mesh Profiles 重置 Mesh 配置 +Reset Node DB 重置節點資料庫 +Resetting... 重置中... +Return 返回 +Rally Point 集結點 +RNode Bridge RNode Bridge +RNode modem ready RNode 數據機就緒 +RNode text chat runs on host RNode 文字聊天在主機側執行 +Route 路線 Run 執行 +RX RX +RX: %lu TX: %lu 接收:%lu 傳送:%lu +RX: 0 TX: 0 接收:0 傳送:0 Save 儲存 +Saved: %s 已儲存:%s Scan failed 掃描失敗 Scan Local 本地掃描 -Scanning 5s... 掃描 5 秒... -SEARCH 搜尋 +Scanning 5s... 掃描 5 秒中... Screen Brightness 螢幕亮度 -Screen Timeout 螢幕逾時 +Screen Timeout 螢幕關閉時間 +SEARCH 搜尋中 +Secondary 次通道 +Select a route 請選擇路線 +Select a track 請選擇軌跡 +Select marker 選擇標記 +Select Track 選擇軌跡 +Selected: %s 已選:%s +Send 傳送 +Send failed 傳送失敗 +Send this code and compare:\n 傳送這個驗證碼並比對:\n +Send ID Broadcast 廣播傳送 ID +Send ID Local 本地傳送 ID +Sending... 傳送中... +Sent 已傳送 Settings 設定 Setting 設定 -Sky Plot 星圖 +Share Position Marker 分享位置標記 +Share this number:\n 請分享這個數字:\n +Short Name 短名稱 +SOLO 單機 Speaker Volume 喇叭音量 +Sky Plot 星圖 +SSTV RX READY SSTV 接收就緒 +SSTV start failed SSTV 啟動失敗 +standing by 待命 Status 狀態 Stopped 已停止 Stop 停止 +Stop recording first 請先停止記錄 Submit 提交 +Submit failed 提交失敗 System 系統 +SATELLITE STATUS 衛星狀態 +Talk 說話 +TALK 說話 Team 團隊 -Team Status 團隊狀態 +TEAM 團隊 +Team chat send failed 團隊訊息傳送失敗 +Team chat unavailable 團隊聊天不可用 +Team chat unavailable in RNode mode RNode 模式下團隊聊天不可用 +team member position will appear here 隊友位置會顯示在這裡 +Team keys not ready 團隊金鑰未就緒 +Team location encode failed 團隊位置編碼失敗 +Text chat unavailable 文字聊天不可用 +threads active 活躍會話 Time Zone 時區 -Tracker 軌跡 -Traditional Chinese 繁體中文 +Tracker 追蹤 +traffic active 流量活躍 +Trust Key 信任金鑰 +U 用 +Unignore 取消忽略 +Unread 未讀 Unknown 未知 -USB Disk USB儲存 +USB Mass Storage USB 大容量儲存 +USB Disk USB 儲存 +USB Bridge USB 橋接 +USB host bridge USB 主機橋接 +USB Bridge is unavailable on this target. 當前目標暫不支援 USB 橋接. +USB CDC KISS modem for Reticulum 面向 Reticulum 的 USB CDC KISS 調變解調器 +USE 使用 +Used 已使用 User Name 使用者名稱 -Verification Number 驗證號碼 -Vibration 震動 +Verification Number 校驗數字 +Verification number sent 校驗數字已傳送 +Vibration 振動 +VOL %d 音量 %d +VOL 80 音量 80 +waiting for better sky view | %u in view 等待更好的天空視野 | 可見 %u 顆 +waiting for module GNSS compass 等待 GNSS/指南針模組 +waiting for traffic 等待流量 +Waiting for host... 等待主機連線... +Waiting for Reticulum host... 等待 Reticulum 主機連線... +WAIT IMU 等待 IMU +Walkie not available 對講功能不可用 +Walkie start failed 對講啟動失敗 +Walkie Talkie 對講機 +YES 是 +6 digits 6 位數字 +%u threads active %u 個會話活躍 +%ud %u天 +%uh %u小時 +%um %u分 +%umo %u月 +%uy %u年 +Confirm received code:\n 確認收到的驗證碼:\n +device heading from 9-axis module 裝置航向來自 9 軸模組 +heading unlocks after first fix 首次定位後解鎖航向 +If it matches, trust the key. 如果一致,請信任此金鑰。 +magnetometer heading active 磁力計航向已啟用 +magnetometer still warming up 磁力計仍在預熱 +needle shows north until target lock 鎖定目標前指標指向北方 +nearest peer | %.0f m away 最近隊友 | 距離 %.0f 米 +no recent traffic 暫無最近活動 +now 剛剛 +%s layer missing %s 圖層缺失 +Active: %s 已啟用:%s +Alt: %.0f m 高度:%.0f 米 +Alt: -- m 高度:-- 米 +Base: %s 底圖:%s +Battery critical 電池電量危險 +Broadcast (%d) 廣播(%d) +COMPLETE 已完成 +Calibrating... 校準中... +Confirm 確認 +Contour: OFF 等高線:關 +Decoder error 解碼錯誤 +Direct (%d) 私聊(%d) +E 東 +ERROR 錯誤 +Exiting USB... 正在退出 USB... +Hold 原地待命 +ID: !%08lX ID: !%08lX +ID: - ID: - +Key refresh not implemented 暫未實現金鑰重新整理 +Key verify: %s %s 金鑰校驗:%s %s +Key verify: confirm %s %s 金鑰校驗:請確認 %s %s +Key verify: enter number for %s 金鑰校驗:請為 %s 輸入數字 +Key verify: send %s %s 金鑰校驗:請傳送 %s %s +Later 稍後 +Level %d 級別 %d +Listening for SSTV signal... 正在監聽 SSTV 訊號... +Low battery: %d%% 電量低:%d%% +MODE: %s 模式:%s +MODE: Auto 模式:自動 +Map - %.48s 地圖 - %.48s +Map - %s Missing 地圖 - %s 缺失 +Map - No Map Data 地圖 - 沒有地圖資料 +Map - No SD Card 地圖 - 沒有 SD 卡 +Map - no gps data 地圖 - 沒有 GPS 資料 +Message 訊息 +Mic TBD 麥克風功能待定義 +Mic init failed 麥克風初始化失敗 +Morse Input 摩斯電碼輸入 +MoveTo 移動到 +N 北 +NODE INFO 節點資訊 +Name required 必須輸入名稱 +Name too long 名稱過長 +No KML routes 沒有 KML 路線 +No SD Card 沒有 SD 卡 +No routes folder 沒有 routes 資料夾 +No tracks yet 暫無軌跡 +Only leader can manage 只有隊長可以管理 +Only leader can pair 只有隊長可以配對 +Paired successfully 配對成功 +Pairing failed 配對失敗 +Pairing init failed 配對初始化失敗 +Pairing not available 當前無法配對 +Pairing not ready 配對尚未就緒 +Press Back to exit USB mode 按返回鍵退出 USB 模式 +Press SPACE to resume 按 SPACE 喚醒螢幕 +RallyTo 集結到 +Route configured 已配置路線 +Route: %s 路線:%s +SD Card Not Found\nPlease insert SD card 未找到 SD 卡\n請插入 SD 卡 +SSTV RECEIVER SSTV 接收器 +Save failed 儲存失敗 +Scan +%u/%u 掃描新增:+%u/%u +Select level 選擇級別 +Selected level: %d 已選級別:%d +Shutting down to protect battery 正在關機以保護電池 +Status: CALIB 狀態:校準 +Step 1/2: Tap short dots 第 1/2 步:點選短點 +Step 2/2: Tap long dashes 第 2/2 步:點選長線 +Switch to %s to chat 切換到 %s 以聊天 +Symbol: 符號: +Tap to input, idle 3s to send 點選輸入,空閒 3 秒後傳送 +Team keys mismatch 團隊金鑰不匹配 +Team: 團隊: +Text: 文字: +Unread: %d 未讀:%d +Unread: 0 未讀:0 +W 西 +0° Horizon 0° 地平線 +PSK must be 32 hex or 16 chars PSK 必須是 32 位十六進位制或 16 個字元 +Restarting... 正在重啟... +awaiting position 等待位置 +compass wakes after GPS fix GPS 定位後指南針將啟用 +searching satellites 正在搜尋衛星 +%s - %s - +%s 0s %s 0秒 +%s %us %s %u秒 +%s %um %s %u分 +%s %uh %s %u小時 +%s %ud %s %u天 +AUTO 自動 +Alt: %ld m 高度:%ld 米 +Alt: - 高度:- +BEST 最佳 +BW: %.0fk 頻寬:%.0fk +BW: %.1fk 頻寬:%.1fk +BW: - 頻寬:- +CAD CAD +CURSOR 遊標 +Ch: %.3f 通道:%.3f +Ch: - 通道:- +Hop: %u 跳數:%u +Hop: - 跳數:- +Hop: - / NH: %02X 跳數:- / 下一跳:%02X +ID: !%06lx ID: !%06lx +ID: !%08lx ID: !%08lx +Last heard: 最後收到: +Link 鏈路 +Link / MeshCore 鏈路 / MeshCore +Link / Meshtastic 鏈路 / Meshtastic +MODE: RSSI 模式:RSSI +NOISE %.0f dBm 噪聲 %.0f dBm +NOISE -104 dBm 噪聲 -104 dBm +PKI: - PKI:- +PKI: known PKI:已知 +PKI: verified PKI:已驗證 +RSSI %.0f dBm RSSI %.0f dBm +RSSI -92 dBm RSSI -92 dBm +RSSI: %.0f dBm RSSI:%.0f dBm +RSSI: - RSSI:- +Role: %s 角色:%s +SCAN 掃描 +SF: %u 擴頻因子:%u +SF: - 擴頻因子:- +SIM 模擬 +SNR +%d SNR +%d +SNR +12 SNR +12 +SNR: %.1f dB SNR:%.1f dB +SNR: - SNR:- +SNR %.0f 訊雜比 %.0f +SNR - 訊雜比 - +SNR Good 訊雜比良好 +SNR Weak 訊雜比較弱 +STEP %.0fk | BW %.0fk 步進 %.0fk | 頻寬 %.0fk +STEP %.1fk | BW %.1fk 步進 %.1fk | 頻寬 %.1fk +STEP -- | BW -- 步進 -- | 頻寬 -- +STOP 停止 +Slot 槽位 +Slot %u 槽位 %u +SUB-GHz SCAN Sub-GHz 掃描 +Actions 操作 +Actions: %s 操作:%s +Access to team data revoked 隊伍資料訪問已撤銷 +Capability: 能力: +Channel Actions 頻道操作 +Confirm Kick 確認移除 +Create Team 建立隊伍 +ELEV 仰角 +Join Team 加入隊伍 +In View 可見 +Keep devices within 5m 請將裝置保持在 5 米內 +KeyDist 金鑰分發 +KeyId: %u 金鑰 ID:%u +KeyId: -- 金鑰 ID:-- +Keys 金鑰 +Kick 移除 +Kick Member 移除成員 +Last paired: %s 最近配對:%s +Last seen %uh ago 上次線上 %u 小時前 +Last seen %um ago 上次線上 %u 分鐘前 +Last seen -- 上次線上:-- +Last update %us ago 上次更新 %u 秒前 +Last update -- 上次更新:-- +Last update 0s ago 上次更新 0 秒前 +Leader 隊長 +Leader online 隊長線上 +Leave 離開 +Leave team? 離開隊伍? +Manage 管理 +Member: %s 成員:%s +Members 成員 +Members: %u 成員數:%u +Members: %u Online: %u 成員:%u 線上:%u +Modem Bridge 數據機橋接 +No members yet 暫無成員 +Not Used 未使用 +Offline 離線 +Online 線上 +Online: %u 線上:%u +Pair Again 再次配對 +Pair Member 配對成員 +Pairing in progress 正在配對 +Prev 上一頁 +Remove %s from team? 將 %s 移出隊伍? +Request Keys 請求金鑰 +Retry 重試 +Security Round: %u 安全輪次:%u +Security Round: -- 安全輪次:-- +Security: OK (Round %u) 安全狀態:正常(輪次 %u) +Security: OK (Round --) 安全狀態:正常(輪次 --) +Seen %ud %u 天前 +Seen %uh %u 小時前 +Seen %um %u 分鐘前 +Send failed (keys not ready) 傳送失敗(金鑰未就緒) +Status: %s 狀態:%s +Target: %s 目標:%s +Team / %s 隊伍 / %s +Team Actions 隊伍操作 +Team Health 團隊狀態 +Team Status 團隊狀態 +Team: %s 隊伍:%s +This clears local keys. 這將清除本地金鑰。 +This will update the security round.\nThe member will no longer receive\nteam messages or waypoints. 這將更新安全輪次。\n該成員將不再接收\n隊伍訊息或航點。 +Transfer 移交 +Transfer Leader 移交隊長 +View Team 檢視隊伍 +Waiting for new keys... 正在等待新金鑰... +Waypoint 航點 +You are no longer in this team 你已不在此隊伍中 +You are not in a team 你當前未加入隊伍 +- No shared map\n- No team awareness - 無共享地圖\n- 無隊伍態勢 +1 member stale 1 名成員狀態過期 +encrypt failed 加密失敗 +member 成員 +queue full 佇列已滿 +unsupported protocol 協議不支援 +Factory Reset 恢復原廠設定 Clear all settings and restart? 清除所有設定並重新啟動? +Terrain 地形圖 +Satellite 衛星圖 +Contour: ON 等高線:開 +Contour data missing 缺少等高線資料 +%d enabled 已啟用 %d 個 +%s %s %s %s +Channel Key / PSK 頻道金鑰 / PSK +Channel Slot 頻道槽 +Channel Utilization 頻道使用率 +Check for Updates 檢查更新 +Clear Message DB 清除訊息資料庫 +Connect 連線 +Contour Overlay 等高線疊加 +Coordinate Format 座標格式 +Coordinate System 座標系統 +Current Version 目前版本 +Detected Network 偵測到的網路 +Disconnect 中斷連線 +Duty Cycle Limit 占空比限制 +Enabled IMEs 已啟用的輸入法 +Encryption Mode 加密模式 +Freq Offset (MHz) 頻率偏移 (MHz) +GPS Enabled 啟用 GPS +Hop Limit 跳數限制 +IME setting update failed 輸入法設定更新失敗 +Install Update 安裝更新 +Latest Version 最新版本 +Layer 圖層 +Location Mode 定位模式 +MC Airtime Factor MC 空中時間係數 +MC Bandwidth (kHz) MC 頻寬 (kHz) +MC Channel Key MC 頻道金鑰 +MC Channel Name MC 頻道名稱 +MC Channel Slot MC 頻道槽 +MC Coding Rate MC 編碼率 +MC Flood Max MC 最大泛洪數 +MC Frequency (MHz) MC 頻率 (MHz) +MC Multi ACKs MC 多重 ACK +MC RX Delay Base MC 接收延遲基準 +MC Region Preset MC 區域預設 +MC Repeat MC 中繼 +MC Spread Factor MC 展頻因子 +MC TX Power MC 發射功率 +Manual BW 手動頻寬 +Manual CR 手動編碼率 +Manual SF 手動展頻因子 +Map Source 地圖來源 +Modem Preset 數據機預設 +NMEA Export NMEA 匯出 +NMEA Sentences NMEA 語句 +No IME packs installed 尚未安裝輸入法套件 +No position available 無可用位置 +OTA Status OTA 狀態 +Override Duty Cycle 覆寫占空比 +Override Freq (MHz) 覆寫頻率 (MHz) +Password 密碼 +Position Strategy 定位策略 +SSID SSID +Satellite Systems 衛星系統 +Scan Networks 掃描網路 +TX Enabled 啟用發射 +TX Power 發射功率 +Track Format 軌跡格式 +Track Interval 軌跡間隔 +Track Recording 軌跡記錄 +Unavailable 無法使用 +Update Interval 更新間隔 +Use Preset 使用預設 +Wi-Fi Enabled 啟用 Wi-Fi +Wi-Fi start failed Wi-Fi 啟動失敗 diff --git a/packs/zh-Hant/package.ini b/packs/zh-Hant/package.ini index 1bed9ecd..344b6876 100644 --- a/packs/zh-Hant/package.ini +++ b/packs/zh-Hant/package.ini @@ -1,13 +1,13 @@ kind=package id=zh-Hant package_type=locale-bundle -version=1.0.0 -display_name=Traditional Chinese -summary=Traditional Chinese locale bundle with external CJK font coverage for UI and mixed-script content rendering. +version=1.1.0 +display_name=Traditional Chinese (Taiwan) +summary=Traditional Chinese (Taiwan) review locale bundle with CJK font coverage; display-only until native review and a real Zhuyin-focused IME are available. description=DESCRIPTION.txt readme=README.md author=Trail Mate homepage=https://vicliu624.github.io/trail-mate/ -min_firmware_version=0.1.20-alpha +min_firmware_version=0.1.24-alpha supported_memory_profiles=constrained,standard,extended -tags=language,cjk,chinese +tags=language,cjk,chinese,taiwan,review diff --git a/platform/esp/arduino_common/include/ble/meshcore_ble.h b/platform/esp/arduino_common/include/ble/meshcore_ble.h index c92a1e6f..e027e338 100644 --- a/platform/esp/arduino_common/include/ble/meshcore_ble.h +++ b/platform/esp/arduino_common/include/ble/meshcore_ble.h @@ -21,7 +21,8 @@ namespace ble class MeshCoreBleService : public BleService, public MeshCorePhoneHooks, public chat::ChatService::IncomingTextObserver, - public team::TeamService::IncomingDataObserver + public team::TeamService::IncomingDataObserver, + public IPhoneRuntimeContext { public: MeshCoreBleService(app::IAppBleFacade& ctx, const std::string& device_name); @@ -205,6 +206,39 @@ class MeshCoreBleService : public BleService, bool getCustomVars(std::string* out) const override; bool setCustomVar(const char* key, const char* value) override; void onFactoryReset() override; + MeshtasticPhoneConfigSnapshot getMeshtasticPhoneConfig() const override; + void setMeshtasticPhoneConfig(const MeshtasticPhoneConfigSnapshot& config) override; + MeshCorePhoneConfigSnapshot getMeshCorePhoneConfig() const override; + void setMeshCorePhoneConfig(const MeshCorePhoneConfigSnapshot& config) override; + void saveConfig() override { ctx_.saveConfig(); } + void applyMeshConfig() override { ctx_.applyMeshConfig(); } + void applyUserInfo() override { ctx_.applyUserInfo(); } + void applyPositionConfig() override { ctx_.applyPositionConfig(); } + void getEffectiveUserInfo(char* out_long, + std::size_t long_len, + char* out_short, + std::size_t short_len) const override + { + ctx_.getEffectiveUserInfo(out_long, long_len, out_short, short_len); + } + chat::ChatService& getChatService() override { return ctx_.getChatService(); } + chat::contacts::ContactService& getContactService() override { return ctx_.getContactService(); } + chat::IMeshAdapter* getMeshAdapter() override { return ctx_.getMeshAdapter(); } + const chat::IMeshAdapter* getMeshAdapter() const override { return ctx_.getMeshAdapter(); } + chat::contacts::INodeStore* getNodeStore() override { return ctx_.getNodeStore(); } + const chat::contacts::INodeStore* getNodeStore() const override { return ctx_.getNodeStore(); } + chat::NodeId getSelfNodeId() const override { return ctx_.getSelfNodeId(); } + bool isBleEnabled() const override { return ctx_.isBleEnabled(); } + void setBleEnabled(bool enabled) override { ctx_.setBleEnabled(enabled); } + bool getDeviceMacAddress(uint8_t out_mac[6]) const override { return ctx_.getDeviceMacAddress(out_mac); } + bool syncCurrentEpochSeconds(uint32_t epoch_seconds) override + { + return ctx_.syncCurrentEpochSeconds(epoch_seconds); + } + void resetMeshConfig() override { ctx_.resetMeshConfig(); } + void clearNodeDb() override { ctx_.clearNodeDb(); } + void clearMessageDb() override { ctx_.clearMessageDb(); } + void restartDevice() override { ctx_.restartDevice(); } }; } // namespace ble diff --git a/platform/esp/arduino_common/include/ble/meshtastic_ble.h b/platform/esp/arduino_common/include/ble/meshtastic_ble.h index 36cd95e9..3942b7d7 100644 --- a/platform/esp/arduino_common/include/ble/meshtastic_ble.h +++ b/platform/esp/arduino_common/include/ble/meshtastic_ble.h @@ -29,7 +29,13 @@ class MeshtasticBleService : public BleService, public chat::ChatService::OutgoingTextObserver, public team::TeamService::IncomingDataObserver, public MeshtasticPhoneTransport, - public MeshtasticPhoneHooks + public MeshtasticPhoneBluetoothConfigHooks, + public MeshtasticPhoneModuleConfigHooks, + public MeshtasticPhoneConfigLifecycleHooks, + public MeshtasticPhoneStatusHooks, + public MeshtasticPhoneMqttHooks, + public MeshtasticPhoneDeviceRuntimeHooks, + public IPhoneRuntimeContext { public: MeshtasticBleService(app::IAppBleFacade& ctx, const std::string& device_name); @@ -49,6 +55,44 @@ class MeshtasticBleService : public BleService, bool loadDeviceConnectionStatus(meshtastic_DeviceConnectionStatus* out) const override; bool loadModuleConfig(meshtastic_LocalModuleConfig* out) const override; void saveModuleConfig(const meshtastic_LocalModuleConfig& config) override; + bool loadTimezoneTzdef(char* out, size_t out_len) const override; + void saveTimezoneTzdef(const char* tzdef) override; + int getTimezoneOffsetMinutes() const override; + void setTimezoneOffsetMinutes(int offset_min) override; + bool getGpsFix(MeshtasticGpsFix* out) const override; + MeshtasticPhoneConfigSnapshot getMeshtasticPhoneConfig() const override; + void setMeshtasticPhoneConfig(const MeshtasticPhoneConfigSnapshot& config) override; + MeshCorePhoneConfigSnapshot getMeshCorePhoneConfig() const override; + void setMeshCorePhoneConfig(const MeshCorePhoneConfigSnapshot& config) override; + void saveConfig() override { ctx_.saveConfig(); } + void applyMeshConfig() override { ctx_.applyMeshConfig(); } + void applyUserInfo() override { ctx_.applyUserInfo(); } + void applyPositionConfig() override { ctx_.applyPositionConfig(); } + void getEffectiveUserInfo(char* out_long, + std::size_t long_len, + char* out_short, + std::size_t short_len) const override + { + ctx_.getEffectiveUserInfo(out_long, long_len, out_short, short_len); + } + chat::ChatService& getChatService() override { return ctx_.getChatService(); } + chat::contacts::ContactService& getContactService() override { return ctx_.getContactService(); } + chat::IMeshAdapter* getMeshAdapter() override { return ctx_.getMeshAdapter(); } + const chat::IMeshAdapter* getMeshAdapter() const override { return ctx_.getMeshAdapter(); } + chat::contacts::INodeStore* getNodeStore() override { return ctx_.getNodeStore(); } + const chat::contacts::INodeStore* getNodeStore() const override { return ctx_.getNodeStore(); } + chat::NodeId getSelfNodeId() const override { return ctx_.getSelfNodeId(); } + bool isBleEnabled() const override { return ctx_.isBleEnabled(); } + void setBleEnabled(bool enabled) override { ctx_.setBleEnabled(enabled); } + bool getDeviceMacAddress(uint8_t out_mac[6]) const override { return ctx_.getDeviceMacAddress(out_mac); } + bool syncCurrentEpochSeconds(uint32_t epoch_seconds) override + { + return ctx_.syncCurrentEpochSeconds(epoch_seconds); + } + void resetMeshConfig() override { ctx_.resetMeshConfig(); } + void clearNodeDb() override { ctx_.clearNodeDb(); } + void clearMessageDb() override { ctx_.clearMessageDb(); } + void restartDevice() override { ctx_.restartDevice(); } bool handleMqttProxyToRadio(const meshtastic_MqttClientProxyMessage& msg) override; bool pollMqttProxyToPhone(meshtastic_MqttClientProxyMessage* out) override; void onConfigStart() override; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/GPS.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/GPS.h index 5b40cace..d7aa1f88 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/GPS.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/GPS.h @@ -30,6 +30,7 @@ class GPS : public TinyGPSPlus ~GPS(); + void attach(Stream* stream); bool init(Stream* stream); bool factory(); bool configureGnss(uint8_t sat_mask); @@ -41,6 +42,11 @@ class GPS : public TinyGPSPlus uint32_t loop(bool debug = false) { + if (_stream == nullptr) + { + return charsProcessed(); + } + static uint32_t loop_count = 0; static uint32_t last_log_ms = 0; uint32_t now = millis(); @@ -119,7 +125,7 @@ class GPS : public TinyGPSPlus int allocSatellite(); void recalcCounts(); - Stream* _stream; + Stream* _stream = nullptr; String model; SatEntry sats_[gps::kMaxGnssSats]{}; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service.h index e9ba3b66..56b75087 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service.h @@ -29,18 +29,19 @@ class GpsService GpsState getData(); bool getGnssSnapshot(GnssSatInfo* out, size_t max, size_t* out_count, GnssStatus* status); uint32_t getCollectionInterval() const; + void setEnabled(bool enabled); void setCollectionInterval(uint32_t interval_ms); void setPowerStrategy(uint8_t strategy); void setTeamModeActive(bool active); void setGnssConfig(uint8_t mode, uint8_t sat_mask); - void setNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); + void setExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); MotionConfig getMotionConfig() const { return motion_config_; } void setMotionConfig(const MotionConfig& config); void setMotionIdleTimeout(uint32_t timeout_ms); void setMotionSensorId(uint8_t sensor_id); TaskHandle_t getTaskHandle() const { return gps_task_handle_; } TaskHandle_t getMotionTaskHandle() const { return motion_task_handle_; } - bool isEnabled() const { return !gps_disabled_ && gps_ready_; } + bool isEnabled() const { return !gps_disabled_ && user_enabled_; } bool isPowered() const { return gps_powered_; } uint32_t getLastMotionMs() const; @@ -55,7 +56,7 @@ class GpsService void setGPSPowerState(bool enable); void updateMotionState(uint32_t now_ms); void applyGnssConfig(); - void applyNmeaConfig(); + void applyInternalNmeaConfig(); GpsBoard* gps_board_ = nullptr; MotionBoard* motion_board_ = nullptr; @@ -73,6 +74,7 @@ class GpsService bool gps_time_synced_ = false; bool gps_powered_ = false; bool gps_disabled_ = false; + bool user_enabled_ = true; bool gps_ready_ = false; MotionConfig motion_config_{}; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service_api.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service_api.h index a8bba467..0d9e77aa 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service_api.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service_api.h @@ -14,10 +14,11 @@ bool gps_get_gnss_snapshot(gps::GnssSatInfo* out, size_t max, size_t* out_count, uint32_t gps_get_last_motion_ms(); bool gps_is_enabled(); bool gps_is_powered(); +void gps_set_enabled(bool enabled); void gps_set_collection_interval(uint32_t interval_ms); void gps_set_power_strategy(uint8_t strategy); void gps_set_gnss_config(uint8_t mode, uint8_t sat_mask); -void gps_set_nmea_config(uint8_t output_hz, uint8_t sentence_mask); +void gps_set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask); void gps_set_motion_idle_timeout(uint32_t timeout_ms); void gps_set_motion_sensor_id(uint8_t sensor_id); TaskHandle_t gps_get_task_handle(); diff --git a/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h b/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h index f323f459..0cb343ef 100644 --- a/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h +++ b/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h @@ -157,6 +157,11 @@ const char* map_source_label(uint8_t map_source); */ bool build_base_tile_path(int z, int x, int y, uint8_t map_source, char* out_path, size_t out_size); +/** + * Check whether the selected base tile file exists. + */ +bool base_tile_available(int z, int x, int y, uint8_t map_source); + /** * Build contour tile path for current zoom (major contour profile only). * Returns false when contour should not be shown at this zoom. diff --git a/platform/esp/arduino_common/src/app_config_store.cpp b/platform/esp/arduino_common/src/app_config_store.cpp index 15853df2..e5471ec6 100644 --- a/platform/esp/arduino_common/src/app_config_store.cpp +++ b/platform/esp/arduino_common/src/app_config_store.cpp @@ -27,9 +27,9 @@ constexpr const char* kChatKeyPrimaryDownlink = "pri_downlink"; constexpr const char* kChatKeySecondaryUplink = "sec_uplink"; constexpr const char* kChatKeySecondaryDownlink = "sec_downlink"; constexpr const char* kGpsKeyMotionSensorId = "motion_sensor"; +constexpr const char* kGpsKeyExternalNmeaSentence = "ext_nmea_sent"; constexpr const char* kSettingsKeyMapTrackInterval = "map_track_int"; constexpr const char* kSettingsKeyMapTrackFormat = "map_track_fmt"; -constexpr const char* kSettingsKeyPrivacyNmeaSentence = "priv_nmea_sent"; const char* safe_label(const char* value) { @@ -583,6 +583,7 @@ void log_config_delta(const AppConfig& before, const AppConfig& after) log_change_bool("secondary_uplink", before.secondary_uplink_enabled, after.secondary_uplink_enabled, any_change); log_change_bool("secondary_downlink", before.secondary_downlink_enabled, after.secondary_downlink_enabled, any_change); + log_change_bool("gps_enabled", before.gps_enabled, after.gps_enabled, any_change); log_change_u32("gps_interval_ms", before.gps_interval_ms, after.gps_interval_ms, any_change); log_change_u8("gps_mode", before.gps_mode, after.gps_mode, any_change); log_change_u8("gps_sat_mask", before.gps_sat_mask, after.gps_sat_mask, any_change); @@ -593,6 +594,10 @@ void log_config_delta(const AppConfig& before, const AppConfig& after) after.motion_config.idle_timeout_ms, any_change); log_change_u8("motion_sensor_id", before.motion_config.sensor_id, after.motion_config.sensor_id, any_change); + log_change_u8("external_nmea_output_hz", before.external_nmea_output_hz, + after.external_nmea_output_hz, any_change); + log_change_u8("external_nmea_sentence_mask", before.external_nmea_sentence_mask, + after.external_nmea_sentence_mask, any_change); log_change_u8("map_coord_system", before.map_coord_system, after.map_coord_system, any_change); log_change_u8("map_source", before.map_source, after.map_source, any_change); @@ -606,8 +611,6 @@ void log_config_delta(const AppConfig& before, const AppConfig& after) log_change_u8("net_util", before.net_channel_util, after.net_channel_util, any_change); log_change_u8("privacy_encrypt_mode", before.privacy_encrypt_mode, after.privacy_encrypt_mode, any_change); - log_change_u8("privacy_nmea_output", before.privacy_nmea_output, after.privacy_nmea_output, any_change); - log_change_u8("privacy_nmea_sentence", before.privacy_nmea_sentence, after.privacy_nmea_sentence, any_change); log_change_bool("route_enabled", before.route_enabled, after.route_enabled, any_change); log_change_text("route_path", before.route_path, after.route_path, any_change); @@ -648,8 +651,9 @@ void log_config_summary(const char* phase, const AppConfig& config) static_cast(config.meshtastic_config.tx_power), static_cast(config.meshcore_config.tx_power), static_cast(config.rnode_config.tx_power)); - Serial.printf("[AppCfg][%s][gps] interval_ms=%lu mode=%u sat_mask=%u strategy=%u alt_ref=%u coord_fmt=%u motion_idle_ms=%lu sensor_id=%u\n", + Serial.printf("[AppCfg][%s][gps] enabled=%s interval_ms=%lu mode=%u sat_mask=%u strategy=%u alt_ref=%u coord_fmt=%u motion_idle_ms=%lu sensor_id=%u external_nmea=%u sentence=%u\n", safe_label(phase), + bool_label(config.gps_enabled), static_cast(config.gps_interval_ms), static_cast(config.gps_mode), static_cast(config.gps_sat_mask), @@ -657,7 +661,9 @@ void log_config_summary(const char* phase, const AppConfig& config) static_cast(config.gps_alt_ref), static_cast(config.gps_coord_format), static_cast(config.motion_config.idle_timeout_ms), - static_cast(config.motion_config.sensor_id)); + static_cast(config.motion_config.sensor_id), + static_cast(config.external_nmea_output_hz), + static_cast(config.external_nmea_sentence_mask)); Serial.printf("[AppCfg][%s][map] coord=%u source=%u contour=%s track=%s track_interval=%u track_format=%u route=%s route_path=%s\n", safe_label(phase), static_cast(config.map_coord_system), @@ -668,13 +674,11 @@ void log_config_summary(const char* phase, const AppConfig& config) static_cast(config.map_track_format), bool_label(config.route_enabled), config.route_path); - Serial.printf("[AppCfg][%s][privacy] duty_cycle=%s net_util=%u encrypt=%u nmea=%u sentence=%u\n", + Serial.printf("[AppCfg][%s][privacy] duty_cycle=%s net_util=%u encrypt=%u\n", safe_label(phase), bool_label(config.net_duty_cycle), static_cast(config.net_channel_util), - static_cast(config.privacy_encrypt_mode), - static_cast(config.privacy_nmea_output), - static_cast(config.privacy_nmea_sentence)); + static_cast(config.privacy_encrypt_mode)); Serial.printf("[AppCfg][%s][aprs] enabled=%s igate=%s-%u tocall=%s path=%s pos_interval=%u self=%s self_call=%s node_map_len=%u\n", safe_label(phase), bool_label(config.aprs.enabled), @@ -708,6 +712,7 @@ bool loadAppConfigFromPreferences(AppConfig& config, auto& secondary_uplink_enabled = config.secondary_uplink_enabled; auto& secondary_downlink_enabled = config.secondary_downlink_enabled; auto& secondary_key = config.secondary_key; + auto& gps_enabled = config.gps_enabled; auto& gps_interval_ms = config.gps_interval_ms; auto& gps_mode = config.gps_mode; auto& gps_sat_mask = config.gps_sat_mask; @@ -715,6 +720,8 @@ bool loadAppConfigFromPreferences(AppConfig& config, auto& gps_alt_ref = config.gps_alt_ref; auto& gps_coord_format = config.gps_coord_format; auto& motion_config = config.motion_config; + auto& external_nmea_output_hz = config.external_nmea_output_hz; + auto& external_nmea_sentence_mask = config.external_nmea_sentence_mask; auto& map_coord_system = config.map_coord_system; auto& map_source = config.map_source; auto& map_contour_enabled = config.map_contour_enabled; @@ -726,8 +733,6 @@ bool loadAppConfigFromPreferences(AppConfig& config, auto& net_duty_cycle = config.net_duty_cycle; auto& net_channel_util = config.net_channel_util; auto& privacy_encrypt_mode = config.privacy_encrypt_mode; - auto& privacy_nmea_output = config.privacy_nmea_output; - auto& privacy_nmea_sentence = config.privacy_nmea_sentence; auto& route_enabled = config.route_enabled; auto& route_path = config.route_path; auto& aprs = config.aprs; @@ -880,6 +885,10 @@ bool loadAppConfigFromPreferences(AppConfig& config, if (begin_namespace(prefs, "gps", true, "LOAD", emit_logs)) { + auto get_bool = [&](const char* key, bool default_value) -> bool + { + return get_bool_logged(prefs, "gps", key, default_value, emit_logs); + }; auto get_uchar = [&](const char* key, uint8_t default_value) -> uint8_t { return get_uchar_logged(prefs, "gps", key, default_value, emit_logs); @@ -889,6 +898,7 @@ bool loadAppConfigFromPreferences(AppConfig& config, return get_uint_logged(prefs, "gps", key, default_value, emit_logs); }; + gps_enabled = get_bool("gps_enabled", gps_enabled); gps_interval_ms = get_uint("gps_interval", gps_interval_ms); gps_mode = get_uchar("gps_mode", gps_mode); gps_sat_mask = get_uchar("gps_sat_mask", gps_sat_mask); @@ -897,6 +907,8 @@ bool loadAppConfigFromPreferences(AppConfig& config, gps_coord_format = get_uchar("gps_coord_fmt", gps_coord_format); motion_config.idle_timeout_ms = get_uint("motion_idle_ms", motion_config.idle_timeout_ms); motion_config.sensor_id = get_uchar(kGpsKeyMotionSensorId, motion_config.sensor_id); + external_nmea_output_hz = get_uchar("ext_nmea", external_nmea_output_hz); + external_nmea_sentence_mask = get_uchar(kGpsKeyExternalNmeaSentence, external_nmea_sentence_mask); prefs.end(); } @@ -930,8 +942,6 @@ bool loadAppConfigFromPreferences(AppConfig& config, net_duty_cycle = get_bool("net_duty_cycle", net_duty_cycle); net_channel_util = get_uchar("net_util", net_channel_util); privacy_encrypt_mode = get_uchar("privacy_encrypt", privacy_encrypt_mode); - privacy_nmea_output = get_uchar("privacy_nmea", privacy_nmea_output); - privacy_nmea_sentence = get_uchar(kSettingsKeyPrivacyNmeaSentence, privacy_nmea_sentence); route_enabled = get_bool("route_enabled", route_enabled); String path = get_string("route_path", route_path); strncpy(route_path, path.c_str(), sizeof(route_path) - 1); @@ -1021,6 +1031,7 @@ bool saveAppConfigToPreferences(AppConfig& config, auto& secondary_uplink_enabled = config.secondary_uplink_enabled; auto& secondary_downlink_enabled = config.secondary_downlink_enabled; auto& secondary_key = config.secondary_key; + auto& gps_enabled = config.gps_enabled; auto& gps_interval_ms = config.gps_interval_ms; auto& gps_mode = config.gps_mode; auto& gps_sat_mask = config.gps_sat_mask; @@ -1028,6 +1039,8 @@ bool saveAppConfigToPreferences(AppConfig& config, auto& gps_alt_ref = config.gps_alt_ref; auto& gps_coord_format = config.gps_coord_format; auto& motion_config = config.motion_config; + auto& external_nmea_output_hz = config.external_nmea_output_hz; + auto& external_nmea_sentence_mask = config.external_nmea_sentence_mask; auto& map_coord_system = config.map_coord_system; auto& map_source = config.map_source; auto& map_contour_enabled = config.map_contour_enabled; @@ -1039,8 +1052,6 @@ bool saveAppConfigToPreferences(AppConfig& config, auto& net_duty_cycle = config.net_duty_cycle; auto& net_channel_util = config.net_channel_util; auto& privacy_encrypt_mode = config.privacy_encrypt_mode; - auto& privacy_nmea_output = config.privacy_nmea_output; - auto& privacy_nmea_sentence = config.privacy_nmea_sentence; auto& route_enabled = config.route_enabled; auto& route_path = config.route_path; auto& aprs = config.aprs; @@ -1160,6 +1171,10 @@ bool saveAppConfigToPreferences(AppConfig& config, return false; } { + auto put_bool = [&](const char* key, bool value) + { + ok = put_bool_logged(prefs, "gps", key, value, emit_logs) && ok; + }; auto put_uchar = [&](const char* key, uint8_t value) { ok = put_uchar_logged(prefs, "gps", key, value, emit_logs) && ok; @@ -1169,6 +1184,7 @@ bool saveAppConfigToPreferences(AppConfig& config, ok = put_uint_logged(prefs, "gps", key, value, emit_logs) && ok; }; + put_bool("gps_enabled", gps_enabled); put_uint("gps_interval", gps_interval_ms); put_uchar("gps_mode", gps_mode); put_uchar("gps_sat_mask", gps_sat_mask); @@ -1177,6 +1193,8 @@ bool saveAppConfigToPreferences(AppConfig& config, put_uchar("gps_coord_fmt", gps_coord_format); put_uint("motion_idle_ms", motion_config.idle_timeout_ms); put_uchar(kGpsKeyMotionSensorId, motion_config.sensor_id); + put_uchar("ext_nmea", external_nmea_output_hz); + put_uchar(kGpsKeyExternalNmeaSentence, external_nmea_sentence_mask); } prefs.end(); @@ -1210,8 +1228,6 @@ bool saveAppConfigToPreferences(AppConfig& config, put_uchar("net_util", net_channel_util); put_uchar("privacy_encrypt", privacy_encrypt_mode); ok = remove_key_logged(prefs, "settings", "privacy_pki", emit_logs) && ok; - put_uchar("privacy_nmea", privacy_nmea_output); - put_uchar(kSettingsKeyPrivacyNmeaSentence, privacy_nmea_sentence); put_bool("route_enabled", route_enabled); put_string("route_path", route_path); } diff --git a/platform/esp/arduino_common/src/app_context_platform_bindings.cpp b/platform/esp/arduino_common/src/app_context_platform_bindings.cpp index 4e3688ae..17b3d326 100644 --- a/platform/esp/arduino_common/src/app_context_platform_bindings.cpp +++ b/platform/esp/arduino_common/src/app_context_platform_bindings.cpp @@ -23,9 +23,9 @@ #include "platform/esp/arduino_common/team/event/team_event_bus_sink.h" #include "platform/esp/arduino_common/team/event/team_pairing_event_bus_sink.h" #include "platform/esp/arduino_common/team_platform_bundle.h" +#include "platform/ui/team_ui_store_runtime.h" #include "team/usecase/team_controller.h" #include "team/usecase/team_track_sampler.h" -#include "ui/screens/team/team_ui_store.h" #include "ui/ui_common.h" namespace @@ -52,15 +52,21 @@ void init_gps_runtime(GpsBoard* gps_board, disable_hw_init, config.gps_interval_ms, config.motion_config); + gps_service.setEnabled(config.gps_enabled); + gps_service.setCollectionInterval(config.gps_interval_ms); gps_service.setPowerStrategy(config.gps_strategy); gps_service.setGnssConfig(config.gps_mode, config.gps_sat_mask); - gps_service.setNmeaConfig(config.privacy_nmea_output, config.privacy_nmea_sentence); + gps_service.setExternalNmeaConfig(config.external_nmea_output_hz, config.external_nmea_sentence_mask); } void apply_position_config(const app::AppConfig& config) { + gps::GpsService::getInstance().setEnabled(config.gps_enabled); gps::GpsService::getInstance().setCollectionInterval(config.gps_interval_ms); + gps::GpsService::getInstance().setPowerStrategy(config.gps_strategy); gps::GpsService::getInstance().setGnssConfig(config.gps_mode, config.gps_sat_mask); + gps::GpsService::getInstance().setExternalNmeaConfig(config.external_nmea_output_hz, + config.external_nmea_sentence_mask); } void init_track_recorder(const app::AppConfig& config) diff --git a/platform/esp/arduino_common/src/ble/ble_manager.cpp b/platform/esp/arduino_common/src/ble/ble_manager.cpp index 7d064fd6..7b66a5c6 100644 --- a/platform/esp/arduino_common/src/ble/ble_manager.cpp +++ b/platform/esp/arduino_common/src/ble/ble_manager.cpp @@ -1,6 +1,5 @@ #include "ble/ble_manager.h" -#include "app/app_config.h" #include "ble/ble_uuids.h" #include "ble/meshcore_ble.h" #include "ble/meshtastic_ble.h" @@ -47,7 +46,7 @@ bool has_ble_startup_headroom() BleManager::BleManager(app::IAppBleFacade& ctx) : ctx_(ctx), - active_protocol_(ctx.getConfig().mesh_protocol) + active_protocol_(ctx.getMeshProtocol()) { } @@ -68,7 +67,7 @@ void BleManager::begin() void BleManager::update() { - const auto current_protocol = ctx_.getConfig().mesh_protocol; + const auto current_protocol = ctx_.getMeshProtocol(); if (current_protocol != active_protocol_) { applyProtocol(current_protocol); @@ -104,7 +103,7 @@ void BleManager::setEnabled(bool enabled) { if (!service_) { - restartService(ctx_.getConfig().mesh_protocol); + restartService(ctx_.getMeshProtocol()); } } else diff --git a/platform/esp/arduino_common/src/ble/meshcore_ble.cpp b/platform/esp/arduino_common/src/ble/meshcore_ble.cpp index 6f745505..18144248 100644 --- a/platform/esp/arduino_common/src/ble/meshcore_ble.cpp +++ b/platform/esp/arduino_common/src/ble/meshcore_ble.cpp @@ -6,7 +6,6 @@ #include "chat/domain/contact_types.h" #include "display/DisplayConfig.h" #include "platform/esp/arduino_common/chat/infra/meshcore/meshcore_adapter.h" -#include "platform/esp/arduino_common/gps/gps_service_api.h" #include "ui/widgets/ble_pairing_popup.h" #include #include @@ -423,7 +422,7 @@ class MeshCoreServerCallbacks : public NimBLEServerCallbacks MeshCoreBleService::MeshCoreBleService(app::IAppBleFacade& ctx, const std::string& device_name) : ctx_(ctx), device_name_(device_name), - shared_core_(new MeshCorePhoneCore(ctx, device_name, this)) + shared_core_(new MeshCorePhoneCore(*this, device_name, this)) { loadBlePin(); loadManualContacts(); @@ -611,24 +610,31 @@ bool MeshCoreBleService::handleCustomVarSet(const char* key, const char* value) return false; } - auto& cfg = ctx_.getConfig(); + auto mesh_cfg = getMeshCorePhoneConfig(); + auto mt_cfg = getMeshtasticPhoneConfig(); bool save_cfg = false; bool apply_mesh = false; bool apply_user = false; bool apply_position = false; bool save_ble = false; + bool save_mesh_cfg = false; + bool save_mt_cfg = false; if (strcmp(key, "node_name") == 0) { - copyBounded(cfg.node_name, sizeof(cfg.node_name), value); + copyBounded(mesh_cfg.node_name, sizeof(mesh_cfg.node_name), value); + copyBounded(mt_cfg.node_name, sizeof(mt_cfg.node_name), value); save_cfg = true; + save_mesh_cfg = true; + save_mt_cfg = true; apply_user = true; } else if (strcmp(key, "channel_name") == 0) { - copyBounded(cfg.meshcore_config.meshcore_channel_name, - sizeof(cfg.meshcore_config.meshcore_channel_name), value); + copyBounded(mesh_cfg.mesh.meshcore_channel_name, + sizeof(mesh_cfg.mesh.meshcore_channel_name), value); save_cfg = true; + save_mesh_cfg = true; apply_mesh = true; } else if (strcmp(key, "ble_pin") == 0) @@ -702,9 +708,10 @@ bool MeshCoreBleService::handleCustomVarSet(const char* key, const char* value) { return false; } - cfg.meshcore_config.meshcore_multi_acks = parsed; + mesh_cfg.mesh.meshcore_multi_acks = parsed; multi_acks_ = parsed ? 1 : 0; save_cfg = true; + save_mesh_cfg = true; apply_mesh = true; save_ble = true; } @@ -715,18 +722,9 @@ bool MeshCoreBleService::handleCustomVarSet(const char* key, const char* value) { return false; } - if (parsed) - { - if (cfg.gps_strategy == 2) - { - cfg.gps_strategy = 0; - } - } - else - { - cfg.gps_strategy = 2; - } + mt_cfg.gps_enabled = parsed; save_cfg = true; + save_mt_cfg = true; apply_position = true; } else @@ -738,6 +736,14 @@ bool MeshCoreBleService::handleCustomVarSet(const char* key, const char* value) { saveBlePin(); } + if (save_mesh_cfg) + { + setMeshCorePhoneConfig(mesh_cfg); + } + if (save_mt_cfg) + { + setMeshtasticPhoneConfig(mt_cfg); + } if (save_cfg) { ctx_.saveConfig(); @@ -753,7 +759,6 @@ bool MeshCoreBleService::handleCustomVarSet(const char* key, const char* value) if (apply_position) { ctx_.applyPositionConfig(); - gps::gps_set_power_strategy(cfg.gps_strategy); } return true; } @@ -778,7 +783,7 @@ void MeshCoreBleService::enqueueRawDataPush(const uint8_t* payload, size_t len, chat::meshcore::MeshCoreAdapter* MeshCoreBleService::meshCoreAdapter() { auto* adapter = ctx_.getMeshAdapter(); - if (!adapter || ctx_.getConfig().mesh_protocol != chat::MeshProtocol::MeshCore) + if (!adapter || getMeshCorePhoneConfig().active_protocol != chat::MeshProtocol::MeshCore) { return nullptr; } @@ -790,7 +795,7 @@ chat::meshcore::MeshCoreAdapter* MeshCoreBleService::meshCoreAdapter() const chat::meshcore::MeshCoreAdapter* MeshCoreBleService::meshCoreAdapter() const { const auto* adapter = ctx_.getMeshAdapter(); - if (!adapter || ctx_.getConfig().mesh_protocol != chat::MeshProtocol::MeshCore) + if (!adapter || getMeshCorePhoneConfig().active_protocol != chat::MeshProtocol::MeshCore) { return nullptr; } @@ -828,7 +833,7 @@ bool MeshCoreBleService::start() } startAdvertising(); - multi_acks_ = ctx_.getConfig().meshcore_config.meshcore_multi_acks ? 1 : 0; + multi_acks_ = getMeshCorePhoneConfig().mesh.meshcore_multi_acks ? 1 : 0; ctx_.getChatService().addIncomingTextObserver(this); if (auto* team = ctx_.getTeamService()) diff --git a/platform/esp/arduino_common/src/ble/meshcore_ble_owner_hooks.cpp b/platform/esp/arduino_common/src/ble/meshcore_ble_owner_hooks.cpp index c196fdeb..7070d3ba 100644 --- a/platform/esp/arduino_common/src/ble/meshcore_ble_owner_hooks.cpp +++ b/platform/esp/arduino_common/src/ble/meshcore_ble_owner_hooks.cpp @@ -1,8 +1,8 @@ #include "ble/meshcore_ble.h" -#include "app/app_config.h" #include "board/BoardBase.h" #include "platform/esp/arduino_common/gps/gps_service_api.h" +#include "platform/shared/ble/app_config_phone_snapshot_bridge.h" #include "sys/clock.h" #include @@ -35,6 +35,21 @@ bool isConfiguredBlePin(uint32_t pin) return pin == 0 || (pin >= 100000 && pin <= 999999); } +void copyBounded(char* dst, size_t dst_len, const char* src) +{ + if (!dst || dst_len == 0) + { + return; + } + if (!src) + { + dst[0] = '\0'; + return; + } + std::strncpy(dst, src, dst_len - 1); + dst[dst_len - 1] = '\0'; +} + int8_t encodeSnrQdb(int16_t snr_x10) { if (snr_x10 == std::numeric_limits::min()) @@ -396,9 +411,10 @@ bool MeshCoreBleService::popOfflineMessage(uint8_t* out, size_t* out_len) bool MeshCoreBleService::setTuningParams(const MeshCorePhoneTuningParams& params) { - auto& cfg = ctx_.getConfig(); - cfg.meshcore_config.meshcore_rx_delay_base = static_cast(params.rx_delay_base_ms) / 1000.0f; - cfg.meshcore_config.meshcore_airtime_factor = static_cast(params.airtime_factor_milli) / 1000.0f; + auto cfg = getMeshCorePhoneConfig(); + cfg.mesh.meshcore_rx_delay_base = static_cast(params.rx_delay_base_ms) / 1000.0f; + cfg.mesh.meshcore_airtime_factor = static_cast(params.airtime_factor_milli) / 1000.0f; + setMeshCorePhoneConfig(cfg); ctx_.saveConfig(); ctx_.applyMeshConfig(); return true; @@ -410,9 +426,9 @@ bool MeshCoreBleService::getTuningParams(MeshCorePhoneTuningParams* out) const { return false; } - const auto& cfg = ctx_.getConfig(); - out->rx_delay_base_ms = static_cast(cfg.meshcore_config.meshcore_rx_delay_base * 1000.0f); - out->airtime_factor_milli = static_cast(cfg.meshcore_config.meshcore_airtime_factor * 1000.0f); + const auto cfg = getMeshCorePhoneConfig(); + out->rx_delay_base_ms = static_cast(cfg.mesh.meshcore_rx_delay_base * 1000.0f); + out->airtime_factor_milli = static_cast(cfg.mesh.meshcore_airtime_factor * 1000.0f); return true; } @@ -426,9 +442,10 @@ bool MeshCoreBleService::setOtherParams(uint8_t manual_add_contacts, uint8_t tel advert_loc_policy_ = advert_loc_policy; if (has_multi_acks) { - auto& cfg = ctx_.getConfig(); - cfg.meshcore_config.meshcore_multi_acks = (multi_acks != 0); - multi_acks_ = multi_acks; + auto cfg = getMeshCorePhoneConfig(); + cfg.mesh.meshcore_multi_acks = (multi_acks != 0); + setMeshCorePhoneConfig(cfg); + multi_acks_ = (multi_acks != 0) ? 1U : 0U; ctx_.saveConfig(); ctx_.applyMeshConfig(); } @@ -463,9 +480,9 @@ bool MeshCoreBleService::getCustomVars(std::string* out) const { appendCustomVar(*out, "gps", gps::gps_is_powered() ? "1" : "0"); } - const auto& cfg = ctx_.getConfig(); + const auto cfg = getMeshCorePhoneConfig(); appendCustomVar(*out, "node_name", cfg.node_name); - appendCustomVar(*out, "channel_name", cfg.meshcore_config.meshcore_channel_name); + appendCustomVar(*out, "channel_name", cfg.mesh.meshcore_channel_name); std::snprintf(buf, sizeof(buf), "%lu", static_cast(ble_pin_)); appendCustomVar(*out, "ble_pin", buf); appendCustomVar(*out, "manual_add_contacts", manual_add_contacts_ ? "1" : "0"); @@ -477,7 +494,7 @@ bool MeshCoreBleService::getCustomVars(std::string* out) const appendCustomVar(*out, "telemetry_env", buf); std::snprintf(buf, sizeof(buf), "%u", static_cast(advert_loc_policy_)); appendCustomVar(*out, "advert_loc_policy", buf); - appendCustomVar(*out, "multi_acks", cfg.meshcore_config.meshcore_multi_acks ? "1" : "0"); + appendCustomVar(*out, "multi_acks", cfg.mesh.meshcore_multi_acks ? "1" : "0"); return true; } @@ -486,6 +503,26 @@ bool MeshCoreBleService::setCustomVar(const char* key, const char* value) return handleCustomVarSet(key, value); } +MeshtasticPhoneConfigSnapshot MeshCoreBleService::getMeshtasticPhoneConfig() const +{ + return platform::shared::ble_bridge::makeMeshtasticPhoneConfigSnapshot(ctx_.getConfig()); +} + +void MeshCoreBleService::setMeshtasticPhoneConfig(const MeshtasticPhoneConfigSnapshot& config) +{ + platform::shared::ble_bridge::applyMeshtasticPhoneConfigSnapshot(ctx_.getConfig(), config); +} + +MeshCorePhoneConfigSnapshot MeshCoreBleService::getMeshCorePhoneConfig() const +{ + return platform::shared::ble_bridge::makeMeshCorePhoneConfigSnapshot(ctx_.getConfig()); +} + +void MeshCoreBleService::setMeshCorePhoneConfig(const MeshCorePhoneConfigSnapshot& config) +{ + platform::shared::ble_bridge::applyMeshCorePhoneConfigSnapshot(ctx_.getConfig(), config); +} + void MeshCoreBleService::onFactoryReset() { manual_contacts_.clear(); diff --git a/platform/esp/arduino_common/src/ble/meshtastic_ble.cpp b/platform/esp/arduino_common/src/ble/meshtastic_ble.cpp index 238faab1..773f9962 100644 --- a/platform/esp/arduino_common/src/ble/meshtastic_ble.cpp +++ b/platform/esp/arduino_common/src/ble/meshtastic_ble.cpp @@ -469,7 +469,7 @@ bool MeshtasticBleService::start() team->addIncomingDataObserver(this); } - phone_session_.reset(new MeshtasticPhoneSession(ctx_, *this, this)); + phone_session_.reset(new MeshtasticPhoneSession(*this, *this, this, this, this, this, this, this)); if (!phone_session_) { ble_log("start failed reason=phone_session_alloc"); diff --git a/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp b/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp index 329f62e8..c8675964 100644 --- a/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp +++ b/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp @@ -1,10 +1,11 @@ #include "ble/meshtastic_ble.h" -#include "app/app_config.h" #include "board/BoardBase.h" #include "chat/ble/meshtastic_defaults.h" -#include "chat/infra/meshtastic/mt_region.h" +#include "chat/ble/meshtastic_phone_config_bridge.h" #include "platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h" +#include "platform/shared/ble/app_config_phone_snapshot_bridge.h" +#include "platform/shared/ble/meshtastic_phone_runtime_bridge.h" #include #include @@ -21,36 +22,6 @@ constexpr const char* kBlePinKey = "pin"; constexpr const char* kModulePrefsNs = "mt_mod"; constexpr const char* kModuleBlobKey = "cfg"; -constexpr uint32_t kModuleConfigVersion = 1; - -constexpr uint32_t kDefaultDetectionMinBroadcastSecs = 45; -constexpr uint32_t kDefaultAmbientCurrent = 10; - -bool isValidBlePin(uint32_t pin) -{ - return pin == 0 || (pin >= 100000 && pin <= 999999); -} - -template -void zeroInit(T& value) -{ - std::memset(&value, 0, sizeof(value)); -} - -void copyBounded(char* dst, size_t dst_len, const char* src) -{ - if (!dst || dst_len == 0) - { - return; - } - if (!src) - { - dst[0] = '\0'; - return; - } - std::strncpy(dst, src, dst_len - 1); - dst[dst_len - 1] = '\0'; -} chat::meshtastic::MtAdapter* getMeshtasticBackend(app::IAppBleFacade& ctx) { @@ -62,19 +33,6 @@ chat::meshtastic::MtAdapter* getMeshtasticBackend(app::IAppBleFacade& ctx) auto* backend = adapter->backendForProtocol(adapter->backendProtocol()); return static_cast(backend); } - -std::string channelDisplayName(app::IAppBleFacade& ctx, uint8_t idx) -{ - const auto& cfg = ctx.getConfig(); - if (idx == 1) - { - return "Secondary"; - } - auto preset = static_cast(cfg.meshtastic_config.modem_preset); - return cfg.meshtastic_config.use_preset - ? std::string(chat::meshtastic::presetDisplayName(preset)) - : std::string("Custom"); -} } // namespace bool MeshtasticBleService::isBleConnected() const @@ -84,37 +42,20 @@ bool MeshtasticBleService::isBleConnected() const bool MeshtasticBleService::loadBluetoothConfig(meshtastic_Config_BluetoothConfig* out) const { - if (!out) - { - return false; - } - *out = ble_config_; - return true; + return meshtastic_config_bridge::loadBluetoothConfigView(ble_config_, ctx_.isBleEnabled(), out); } void MeshtasticBleService::saveBluetoothConfig(const meshtastic_Config_BluetoothConfig& config) { ble_config_ = config; + meshtastic_config_bridge::normalizeBluetoothConfig(&ble_config_); saveBleConfig(); } bool MeshtasticBleService::loadDeviceConnectionStatus(meshtastic_DeviceConnectionStatus* out) const { - if (!out) - { - return false; - } - meshtastic_DeviceConnectionStatus status = meshtastic_DeviceConnectionStatus_init_zero; - *out = status; - out->has_bluetooth = true; - out->bluetooth.pin = (ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN) - ? ble_config_.fixed_pin - : (ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) - ? 0 - : pending_passkey_.load(); - out->bluetooth.rssi = 0; - out->bluetooth.is_connected = connected_; - return true; + const uint32_t passkey = meshtastic_config_bridge::resolveBlePasskey(ble_config_, pending_passkey_.load()); + return meshtastic_config_bridge::fillDeviceConnectionStatus(passkey, connected_, out); } bool MeshtasticBleService::loadModuleConfig(meshtastic_LocalModuleConfig* out) const @@ -124,15 +65,62 @@ bool MeshtasticBleService::loadModuleConfig(meshtastic_LocalModuleConfig* out) c return false; } *out = module_config_; + meshtastic_config_bridge::normalizeModuleConfig(out); return true; } void MeshtasticBleService::saveModuleConfig(const meshtastic_LocalModuleConfig& config) { module_config_ = config; + meshtastic_config_bridge::normalizeModuleConfig(&module_config_); saveModuleConfig(); } +bool MeshtasticBleService::loadTimezoneTzdef(char* out, size_t out_len) const +{ + return platform::shared::ble_bridge::loadTimezoneTzdefFromSettings(out, out_len); +} + +void MeshtasticBleService::saveTimezoneTzdef(const char* tzdef) +{ + platform::shared::ble_bridge::saveTimezoneTzdefToSettings(tzdef); +} + +int MeshtasticBleService::getTimezoneOffsetMinutes() const +{ + return platform::shared::ble_bridge::getTimezoneOffsetMinutes(); +} + +void MeshtasticBleService::setTimezoneOffsetMinutes(int offset_min) +{ + platform::shared::ble_bridge::setTimezoneOffsetMinutes(offset_min); +} + +bool MeshtasticBleService::getGpsFix(MeshtasticGpsFix* out) const +{ + return platform::shared::ble_bridge::fillMeshtasticGpsFixFromUiRuntime(out); +} + +MeshtasticPhoneConfigSnapshot MeshtasticBleService::getMeshtasticPhoneConfig() const +{ + return platform::shared::ble_bridge::makeMeshtasticPhoneConfigSnapshot(ctx_.getConfig()); +} + +void MeshtasticBleService::setMeshtasticPhoneConfig(const MeshtasticPhoneConfigSnapshot& config) +{ + platform::shared::ble_bridge::applyMeshtasticPhoneConfigSnapshot(ctx_.getConfig(), config); +} + +MeshCorePhoneConfigSnapshot MeshtasticBleService::getMeshCorePhoneConfig() const +{ + return platform::shared::ble_bridge::makeMeshCorePhoneConfigSnapshot(ctx_.getConfig()); +} + +void MeshtasticBleService::setMeshCorePhoneConfig(const MeshCorePhoneConfigSnapshot& config) +{ + platform::shared::ble_bridge::applyMeshCorePhoneConfigSnapshot(ctx_.getConfig(), config); +} + bool MeshtasticBleService::handleMqttProxyToRadio(const meshtastic_MqttClientProxyMessage& msg) { auto* mt = getMeshtasticBackend(ctx_); @@ -176,16 +164,13 @@ void MeshtasticBleService::loadBleConfig() } } uint32_t pin = prefs.getUInt(kBlePinKey, 0); - if (isValidBlePin(pin)) + if (meshtastic_config_bridge::isValidBlePin(pin)) { ble_config_.fixed_pin = pin; } prefs.end(); } - if (ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) - { - ble_config_.fixed_pin = 0; - } + meshtastic_config_bridge::normalizeBluetoothConfig(&ble_config_); } void MeshtasticBleService::saveBleConfig() @@ -227,55 +212,7 @@ void MeshtasticBleService::loadModuleConfig() { auto init_defaults = [&]() { - zeroInit(module_config_); - module_config_.version = kModuleConfigVersion; - module_config_.has_mqtt = true; - module_config_.has_serial = true; - module_config_.has_external_notification = true; - module_config_.has_store_forward = true; - module_config_.has_range_test = true; - module_config_.has_telemetry = true; - module_config_.has_canned_message = true; - module_config_.has_audio = true; - module_config_.has_remote_hardware = true; - module_config_.has_neighbor_info = true; - module_config_.has_ambient_lighting = true; - module_config_.has_detection_sensor = true; - module_config_.has_paxcounter = true; - - copyBounded(module_config_.mqtt.address, sizeof(module_config_.mqtt.address), meshtastic_defaults::kDefaultMqttAddress); - copyBounded(module_config_.mqtt.username, sizeof(module_config_.mqtt.username), meshtastic_defaults::kDefaultMqttUsername); - copyBounded(module_config_.mqtt.password, sizeof(module_config_.mqtt.password), meshtastic_defaults::kDefaultMqttPassword); - copyBounded(module_config_.mqtt.root, sizeof(module_config_.mqtt.root), meshtastic_defaults::kDefaultMqttRoot); - module_config_.mqtt.encryption_enabled = meshtastic_defaults::kDefaultMqttEncryptionEnabled; - module_config_.mqtt.tls_enabled = meshtastic_defaults::kDefaultMqttTlsEnabled; - module_config_.mqtt.has_map_report_settings = true; - module_config_.mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; - module_config_.mqtt.map_report_settings.position_precision = 0; - module_config_.mqtt.map_report_settings.should_report_location = false; - - module_config_.neighbor_info.enabled = false; - module_config_.neighbor_info.update_interval = 0; - module_config_.neighbor_info.transmit_over_lora = false; - - module_config_.telemetry.device_update_interval = 3600; - module_config_.telemetry.device_telemetry_enabled = true; - module_config_.telemetry.environment_update_interval = 0; - module_config_.telemetry.environment_measurement_enabled = false; - module_config_.telemetry.power_update_interval = 0; - module_config_.telemetry.health_update_interval = 0; - module_config_.telemetry.air_quality_interval = 0; - - module_config_.detection_sensor.enabled = false; - module_config_.detection_sensor.detection_trigger_type = - meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_LOGIC_HIGH; - module_config_.detection_sensor.minimum_broadcast_secs = kDefaultDetectionMinBroadcastSecs; - - module_config_.ambient_lighting.current = kDefaultAmbientCurrent; - uint32_t node_id = ctx_.getSelfNodeId(); - module_config_.ambient_lighting.red = (node_id >> 16) & 0xFF; - module_config_.ambient_lighting.green = (node_id >> 8) & 0xFF; - module_config_.ambient_lighting.blue = node_id & 0xFF; + meshtastic_config_bridge::initDefaultModuleConfig(&module_config_, ctx_.getSelfNodeId()); #if defined(ROTARY_A) && defined(ROTARY_B) && defined(ROTARY_C) module_config_.canned_message.updown1_enabled = true; @@ -298,15 +235,21 @@ void MeshtasticBleService::loadModuleConfig() { size_t len = prefs.getBytes(kModuleBlobKey, &module_config_, sizeof(module_config_)); prefs.end(); - if (len != sizeof(module_config_) || module_config_.version != kModuleConfigVersion) + if (len != sizeof(module_config_) || + module_config_.version != ble::meshtastic_defaults::kModuleConfigVersion) { init_defaults(); } + else + { + meshtastic_config_bridge::normalizeModuleConfig(&module_config_); + } } } void MeshtasticBleService::saveModuleConfig() { + meshtastic_config_bridge::normalizeModuleConfig(&module_config_); Preferences prefs; if (prefs.begin(kModulePrefsNs, false)) { @@ -357,18 +300,9 @@ void MeshtasticBleService::syncMqttProxySettings() return; } - const auto& cfg = ctx_.getConfig(); + const auto cfg = getMeshtasticPhoneConfig(); chat::meshtastic::MtAdapter::MqttProxySettings settings; - settings.enabled = module_config_.has_mqtt && module_config_.mqtt.enabled; - settings.proxy_to_client_enabled = module_config_.has_mqtt && module_config_.mqtt.proxy_to_client_enabled; - settings.encryption_enabled = module_config_.has_mqtt ? module_config_.mqtt.encryption_enabled : true; - settings.primary_uplink_enabled = cfg.primary_uplink_enabled; - settings.primary_downlink_enabled = cfg.primary_downlink_enabled; - settings.secondary_uplink_enabled = cfg.secondary_uplink_enabled; - settings.secondary_downlink_enabled = cfg.secondary_downlink_enabled; - settings.root = module_config_.mqtt.root[0] ? module_config_.mqtt.root : meshtastic_defaults::kDefaultMqttRoot; - settings.primary_channel_id = channelDisplayName(ctx_, 0); - settings.secondary_channel_id = channelDisplayName(ctx_, 1); + platform::shared::ble_bridge::applyMeshtasticMqttProxySettings(settings, module_config_, cfg); mt->setMqttProxySettings(settings); } diff --git a/platform/esp/arduino_common/src/gps/GPS.cpp b/platform/esp/arduino_common/src/gps/GPS.cpp index 0d7aaff5..a3dac6c8 100644 --- a/platform/esp/arduino_common/src/gps/GPS.cpp +++ b/platform/esp/arduino_common/src/gps/GPS.cpp @@ -30,12 +30,17 @@ GPS::~GPS() { } +void GPS::attach(Stream* stream) +{ + _stream = stream; + assert(_stream); +} + bool GPS::init(Stream* stream) { struct uBloxGnssModelInfo info; - _stream = stream; - assert(_stream); + attach(stream); GPS_LOG("[GPS::init] Starting GPS initialization, stream=%p\n", stream); diff --git a/platform/esp/arduino_common/src/gps/gps_service.cpp b/platform/esp/arduino_common/src/gps/gps_service.cpp index 440e3e6e..70c91b40 100644 --- a/platform/esp/arduino_common/src/gps/gps_service.cpp +++ b/platform/esp/arduino_common/src/gps/gps_service.cpp @@ -38,13 +38,13 @@ void GpsService::begin(GpsBoard& gps_board, MotionBoard& motion_board, if (gps_disabled_) { gps_ready_ = false; + user_enabled_ = false; return; } gps_ready_ = gps_adapter_.isReady(); if (!gps_ready_) { - Serial.printf("[GPS] service inactive reason=adapter_not_ready\n"); - return; + Serial.printf("[GPS] service starting reason=adapter_not_ready_waiting_retry\n"); } motion_adapter_.begin(motion_board); @@ -95,8 +95,6 @@ void GpsService::begin(GpsBoard& gps_board, MotionBoard& motion_board, log_d("Motion manager task created successfully"); } } - - updateMotionState(millis()); } GpsState GpsService::getData() @@ -174,6 +172,32 @@ uint32_t GpsService::getLastMotionMs() const return motion_policy_.lastMotionMs(); } +void GpsService::setEnabled(bool enabled) +{ + if (gps_disabled_) + { + user_enabled_ = false; + return; + } + + user_enabled_ = enabled; + if (!enabled) + { + setGPSPowerState(false); + if (gps_data_mutex_ != NULL && xSemaphoreTake(gps_data_mutex_, portMAX_DELAY) == pdTRUE) + { + gps_state_ = GpsState{}; + gnss_sat_count_ = 0; + gnss_status_ = GnssStatus{}; + gps_last_update_time_ = 0; + xSemaphoreGive(gps_data_mutex_); + } + return; + } + + updateMotionState(millis()); +} + void GpsService::setCollectionInterval(uint32_t interval_ms) { interval_ms = normalizeCollectionInterval(interval_ms); @@ -236,19 +260,10 @@ void GpsService::setGnssConfig(uint8_t mode, uint8_t sat_mask) } } -void GpsService::setNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) +void GpsService::setExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) { - runtime_config_.setNmeaConfig(output_hz, sentence_mask); - - if (!isEnabled() || gps_board_ == nullptr) - { - return; - } - - if (gps_powered_) - { - applyNmeaConfig(); - } + runtime_config_.setExternalNmeaConfig(output_hz, sentence_mask); + runtime_config_.markExternalNmeaConfigApplied(); } void GpsService::setMotionConfig(const MotionConfig& config) @@ -303,22 +318,16 @@ void GpsService::applyGnssConfig() runtime_config_.markGnssConfigApplied(); } -void GpsService::applyNmeaConfig() +void GpsService::applyInternalNmeaConfig() { - if (!runtime_config_.hasPendingNmeaConfig()) - { - return; - } if (!isEnabled() || gps_board_ == nullptr) { return; } - const NmeaRuntimeConfig& nmea_config = runtime_config_.nmeaConfig(); - if (!gps_adapter_.applyNmeaConfig(nmea_config.output_hz, nmea_config.sentence_mask)) + if (!gps_adapter_.applyNmeaConfig(1, 0)) { return; } - runtime_config_.markNmeaConfigApplied(); } void GpsService::setMotionIdleTimeout(uint32_t timeout_ms) @@ -358,6 +367,7 @@ void GpsService::gpsTask(void* pvParameters) loop_count++; uint32_t now_ms = millis(); bool gps_ready = service->gps_adapter_.isReady(); + service->gps_ready_ = gps_ready; if (service->motion_adapter_.isReady() && service->motion_policy_.isEnabled()) { @@ -535,11 +545,15 @@ void GpsService::gpsTask(void* pvParameters) if (retry_result) { GPS_TASK_LOG("[GPS Task] *** GPS REINITIALIZATION SUCCESSFUL *** (loop %lu)\n", loop_count); + service->gps_ready_ = true; + const GnssRuntimeConfig gnss_config = service->runtime_config_.gnssConfig(); + service->runtime_config_.setGnssConfig(gnss_config.mode, gnss_config.sat_mask); service->applyGnssConfig(); - service->applyNmeaConfig(); + service->applyInternalNmeaConfig(); } else { + service->gps_ready_ = false; GPS_TASK_LOG("[GPS Task] GPS reinitialization failed, will retry in %lu ms (loop %lu)\n", RETRY_INTERVAL_MS, loop_count); } @@ -598,10 +612,16 @@ void GpsService::setGPSPowerState(bool enable) gps_adapter_.powerOn(); gps_powered_ = true; bool init_ok = gps_adapter_.init(); + gps_ready_ = gps_adapter_.isReady() || init_ok; Serial.printf("[GPS] init: %s\n", init_ok ? "OK" : "FAIL"); setCollectionInterval(kMinGpsCollectionIntervalMs); - applyGnssConfig(); - applyNmeaConfig(); + if (gps_ready_) + { + const GnssRuntimeConfig gnss_config = runtime_config_.gnssConfig(); + runtime_config_.setGnssConfig(gnss_config.mode, gnss_config.sat_mask); + applyGnssConfig(); + applyInternalNmeaConfig(); + } if (gps_task_handle_ != nullptr) { vTaskResume(gps_task_handle_); @@ -619,12 +639,13 @@ void GpsService::setGPSPowerState(bool enable) } gps_adapter_.powerOff(); gps_powered_ = false; + gps_ready_ = false; } } void GpsService::updateMotionState(uint32_t now_ms) { - if (gps_disabled_ || gps_board_ == nullptr) + if (gps_disabled_ || !user_enabled_ || gps_board_ == nullptr) { return; } diff --git a/platform/esp/arduino_common/src/gps/gps_service_api.cpp b/platform/esp/arduino_common/src/gps/gps_service_api.cpp index 4f1702d3..67936af1 100644 --- a/platform/esp/arduino_common/src/gps/gps_service_api.cpp +++ b/platform/esp/arduino_common/src/gps/gps_service_api.cpp @@ -30,6 +30,11 @@ bool gps_is_powered() return GpsService::getInstance().isPowered(); } +void gps_set_enabled(bool enabled) +{ + GpsService::getInstance().setEnabled(enabled); +} + void gps_set_collection_interval(uint32_t interval_ms) { GpsService::getInstance().setCollectionInterval(interval_ms); @@ -45,9 +50,9 @@ void gps_set_gnss_config(uint8_t mode, uint8_t sat_mask) GpsService::getInstance().setGnssConfig(mode, sat_mask); } -void gps_set_nmea_config(uint8_t output_hz, uint8_t sentence_mask) +void gps_set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) { - GpsService::getInstance().setNmeaConfig(output_hz, sentence_mask); + GpsService::getInstance().setExternalNmeaConfig(output_hz, sentence_mask); } void gps_set_motion_idle_timeout(uint32_t timeout_ms) diff --git a/platform/esp/arduino_common/src/hal/hal_gps.cpp b/platform/esp/arduino_common/src/hal/hal_gps.cpp index 5e0ab202..5c7cf0ed 100644 --- a/platform/esp/arduino_common/src/hal/hal_gps.cpp +++ b/platform/esp/arduino_common/src/hal/hal_gps.cpp @@ -2,7 +2,6 @@ #include "board/GpsBoard.h" #include "board/TLoRaPagerTypes.h" -#include "pins_arduino.h" #include "platform/esp/arduino_common/gps/GPS.h" namespace hal @@ -43,9 +42,8 @@ void HalGps::powerOff() { return; } - Serial1.end(); + board_->deinitGPS(); board_->powerControl(POWER_GPS, false); - board_->setGPSOnline(false); } uint32_t HalGps::loop() diff --git a/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp b/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp index 3d1b26c3..2e80a97b 100644 --- a/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp +++ b/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp @@ -7,11 +7,11 @@ #include "hostlink/hostlink_event_codec.h" #include "hostlink/hostlink_types.h" #include "platform/esp/arduino_common/hostlink/hostlink_service.h" +#include "platform/ui/team_ui_store_runtime.h" #include "team/protocol/team_chat.h" #include "team/protocol/team_mgmt.h" #include "team/protocol/team_portnum.h" #include "team/protocol/team_waypoint.h" -#include "ui/screens/team/team_ui_store.h" #include #include diff --git a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp index f7b5fc0b..01e29f9b 100644 --- a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp @@ -5,6 +5,7 @@ #include "board/BoardBase.h" #include "esp_heap_caps.h" #include "esp_ota_ops.h" +#include "esp_rom_sys.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -14,6 +15,23 @@ namespace platform::ui::device { +namespace +{ + +constexpr uint32_t kRestartBlackoutDelayUs = 50000; + +void prepare_for_restart() +{ + if (board.hasKeyboard()) + { + board.keyboardSetBrightness(0); + } + board.setBrightness(0); + esp_rom_delay_us(kRestartBlackoutDelayUs); +} + +} // namespace + void delay_ms(uint32_t ms) { vTaskDelay(pdMS_TO_TICKS(ms)); @@ -21,6 +39,7 @@ void delay_ms(uint32_t ms) void restart() { + prepare_for_restart(); esp_restart(); } diff --git a/platform/esp/arduino_common/src/platform_ui_gps_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_gps_runtime.cpp index b5138b56..fa083b36 100644 --- a/platform/esp/arduino_common/src/platform_ui_gps_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_gps_runtime.cpp @@ -30,6 +30,11 @@ bool is_powered() return ::gps::gps_is_powered(); } +void set_enabled(bool enabled) +{ + ::gps::gps_set_enabled(enabled); +} + void set_collection_interval(uint32_t interval_ms) { ::gps::gps_set_collection_interval(interval_ms); @@ -45,9 +50,9 @@ void set_gnss_config(uint8_t mode, uint8_t sat_mask) ::gps::gps_set_gnss_config(mode, sat_mask); } -void set_nmea_config(uint8_t output_hz, uint8_t sentence_mask) +void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) { - ::gps::gps_set_nmea_config(output_hz, sentence_mask); + ::gps::gps_set_external_nmea_config(output_hz, sentence_mask); } void set_motion_idle_timeout(uint32_t timeout_ms) diff --git a/platform/esp/arduino_common/src/platform_ui_lora_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_lora_runtime.cpp index 2368df56..b2f3f5db 100644 --- a/platform/esp/arduino_common/src/platform_ui_lora_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_lora_runtime.cpp @@ -59,9 +59,9 @@ bool configure_receive(float freq_mhz, const ReceiveConfig& config) return true; } -float read_rssi() +float read_instant_rssi() { - return s_lora ? s_lora->getRadioRSSI() : std::numeric_limits::quiet_NaN(); + return s_lora ? s_lora->getRadioInstantRSSI() : std::numeric_limits::quiet_NaN(); } void release() diff --git a/platform/esp/arduino_common/src/startup_support.cpp b/platform/esp/arduino_common/src/startup_support.cpp index fd9fdb23..d443c97a 100644 --- a/platform/esp/arduino_common/src/startup_support.cpp +++ b/platform/esp/arduino_common/src/startup_support.cpp @@ -20,6 +20,11 @@ uint32_t platform_epoch_seconds_now() return now < 0 ? 0U : static_cast(now); } +void platform_sleep_ms(uint32_t ms) +{ + delay(ms); +} + } // namespace namespace platform::esp::arduino_common::startup_support @@ -29,6 +34,7 @@ void initializeClockProviders() { sys::set_millis_provider(platform_millis_now); sys::set_epoch_seconds_provider(platform_epoch_seconds_now); + sys::set_sleep_provider(platform_sleep_ms); } esp_sleep_wakeup_cause_t detectWakeupCause() diff --git a/modules/ui_shared/src/ui/runtime/pack_repository.cpp b/platform/esp/arduino_common/src/ui/runtime/pack_repository.cpp similarity index 99% rename from modules/ui_shared/src/ui/runtime/pack_repository.cpp rename to platform/esp/arduino_common/src/ui/runtime/pack_repository.cpp index 537e29be..37720780 100644 --- a/modules/ui_shared/src/ui/runtime/pack_repository.cpp +++ b/platform/esp/arduino_common/src/ui/runtime/pack_repository.cpp @@ -1,4 +1,7 @@ -#include "ui/runtime/pack_repository.h" +#include "platform/ui/pack_repository_runtime.h" + +// ESP-specific package repository backend kept out of ui_shared so the shared +// presentation layer no longer owns storage/network implementation details. #include #include @@ -28,7 +31,13 @@ #ifdef INADDR_NONE #undef INADDR_NONE #endif +#if __has_include() +#include +#elif __has_include() #include +#else +#error "A compatible miniz header is required for pack_repository.cpp" +#endif #define UI_PACKS_HAVE_CRT_BUNDLE 1 diff --git a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp index eb46d13e..8a4493e4 100644 --- a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp +++ b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp @@ -5,6 +5,7 @@ #include "lvgl.h" #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" +#include "platform/ui/team_ui_store_runtime.h" #include "screen_sleep.h" #include "sys/clock.h" #include "team/protocol/team_location_marker.h" @@ -16,7 +17,6 @@ #include "ui/screens/gps/gps_state.h" #include "ui/screens/gps/gps_tracker_overlay.h" #include "ui/screens/team/team_state.h" -#include "ui/screens/team/team_ui_store.h" #include "ui/ui_common.h" #include "ui/widgets/map/map_tiles.h" #include "ui/widgets/map/map_viewport.h" diff --git a/modules/ui_shared/src/ui/screens/team/team_ui_store.cpp b/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp similarity index 99% rename from modules/ui_shared/src/ui/screens/team/team_ui_store.cpp rename to platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp index b49cda3c..ffc2d95d 100644 --- a/modules/ui_shared/src/ui/screens/team/team_ui_store.cpp +++ b/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp @@ -1,9 +1,9 @@ /** * @file team_ui_store.cpp - * @brief Team UI snapshot store (SD-only, persist.md format) + * @brief ESP/SD-backed Team UI snapshot store (kept outside ui_shared) */ -#include "ui/screens/team/team_ui_store.h" +#include "platform/ui/team_ui_store_runtime.h" #include "sys/clock.h" #include diff --git a/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp b/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp index 2d3ff35a..d7c9cf22 100644 --- a/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp +++ b/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp @@ -270,6 +270,16 @@ bool build_base_tile_path(int z, int x, int y, uint8_t map_source, char* out_pat return true; } +bool base_tile_available(int z, int x, int y, uint8_t map_source) +{ + char path[96]; + if (!build_base_tile_path(z, x, y, map_source, path, sizeof(path))) + { + return false; + } + return ui::fs::file_exists(path); +} + bool build_contour_tile_path(int z, int x, int y, char* out_path, size_t out_size) { if (out_path == NULL || out_size == 0) diff --git a/platform/esp/boards/src/display/DisplayInterface.cpp b/platform/esp/boards/src/display/DisplayInterface.cpp index 569dd8d7..ff9f2114 100644 --- a/platform/esp/boards/src/display/DisplayInterface.cpp +++ b/platform/esp/boards/src/display/DisplayInterface.cpp @@ -81,7 +81,9 @@ bool LilyGoDispArduinoSPI::init(int sck, if (_backlight != -1) { pinMode(_backlight, OUTPUT); - digitalWrite(_backlight, HIGH); + // Keep the panel dark until init commands and the first clear frame finish, + // otherwise some ST77xx boards briefly show uninitialized garbage. + digitalWrite(_backlight, LOW); } _spi->begin(sck, miso, mosi); @@ -101,6 +103,10 @@ bool LilyGoDispArduinoSPI::init(int sck, std::vector draw_buf(_width * _height, 0x0000); pushColors(0, 0, _width, _height, draw_buf.data()); + if (_backlight != -1) + { + digitalWrite(_backlight, (_brightness > 0) ? HIGH : LOW); + } xSemaphoreGive(_lock); return true; } diff --git a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h index bef260e7..be421502 100644 --- a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h +++ b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h @@ -25,7 +25,7 @@ #endif #if defined(TRAIL_MATE_ESP_BOARD_TAB5) -#include "bsp/m5stack_tab5.h" +#include "bsp/trail_mate_tab5_runtime.h" #endif namespace platform::ui::wifi @@ -378,7 +378,7 @@ bool ensure_stack_ready() } #if defined(TRAIL_MATE_ESP_BOARD_TAB5) - bsp_set_wifi_power_enable(true); + trail_mate_tab5_set_wifi_power_enabled(true); #endif const esp_err_t netif_err = esp_netif_init(); @@ -595,7 +595,7 @@ bool ensure_wifi_started() } #if defined(TRAIL_MATE_ESP_BOARD_TAB5) - bsp_set_wifi_power_enable(true); + trail_mate_tab5_set_wifi_power_enabled(true); #endif const esp_err_t start_err = esp_wifi_start(); @@ -670,7 +670,7 @@ bool apply_enabled(bool enabled) } } #if defined(TRAIL_MATE_ESP_BOARD_TAB5) - bsp_set_wifi_power_enable(false); + trail_mate_tab5_set_wifi_power_enabled(false); #endif s_runtime.wifi_started = false; s_runtime.wifi_initialized = false; diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/gps_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/gps_runtime.h index 6d4858f7..2bf4e236 100644 --- a/platform/esp/idf_common/include/platform/esp/idf_common/gps_runtime.h +++ b/platform/esp/idf_common/include/platform/esp/idf_common/gps_runtime.h @@ -16,10 +16,11 @@ bool get_gnss_snapshot(gps::GnssSatInfo* out, std::size_t max, std::size_t* out_ uint32_t last_motion_ms(); bool is_enabled(); bool is_powered(); +void set_enabled(bool enabled); void set_collection_interval(uint32_t interval_ms); void set_power_strategy(uint8_t strategy); void set_gnss_config(uint8_t mode, uint8_t sat_mask); -void set_nmea_config(uint8_t output_hz, uint8_t sentence_mask); +void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask); void set_motion_idle_timeout(uint32_t timeout_ms); void set_motion_sensor_id(uint8_t sensor_id); TaskHandle_t get_task_handle(); diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/radio_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/radio_owner.h new file mode 100644 index 00000000..b842d1ba --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/radio_owner.h @@ -0,0 +1,70 @@ +/** + * @file radio_owner.h + * @brief Radio (LoRa SX126x) ownership model for ESP-IDF targets. + * + * Problem on Tab5 / T-Display P4: + * The Sx126xRadio mutex protects individual SPI transactions, but does not + * prevent higher-level mode conflicts: the mesh runtime, walkie-talkie, + * SSTV decoder, energy sweep, and diagnostics can all contend for exclusive + * radio access. A raw mutex per-call does not answer "who owns receive mode + * right now, and who is allowed to reconfigure frequency/modulation." + * + * This header defines a lightweight RadioOwner / RadioSession contract. + * It does NOT enforce ownership — that is the caller's responsibility. + * It provides the vocabulary and a skeletal session class so that each + * feature runtime can declare intent before touching the radio. + * + * Phase 1 (current): Documentation + owner self-identification in logs. + * Phase 2 (after UI stability): RadioSession enforces exclusive ownership. + */ + +#pragma once + +#include + +namespace platform::esp::idf_common +{ + +/// Who currently claims the radio. +enum class RadioOwner : std::uint8_t +{ + None = 0, + Mesh, // Meshtastic / MeshCore / RNode / LXMF adapter + Walkie, // Walkie-talkie RX/TX + Sstv, // SSTV reception + Diagnostics, // Energy sweep / spectrum scan + Gps, // GNSS-assisted time / position over radio + Boot, // Startup probe (should be removed in stable builds) +}; + +/// Lightweight session handle. Create on the stack; release in dtor. +class RadioSession +{ + public: + RadioSession(RadioOwner owner, const char* source_file, int source_line); + ~RadioSession(); + + RadioSession(const RadioSession&) = delete; + RadioSession& operator=(const RadioSession&) = delete; + + /// Log the owner context. Call before any radio operation. + /// Returns false if another owner is already active (future enforcement). + bool enter(); + + /// Release the session. Safe to call multiple times. + void leave(); + + private: + RadioOwner owner_; + const char* source_file_; + int source_line_; + bool active_{false}; +}; + +} // namespace platform::esp::idf_common + +// Convenience macro for stack-based session logging. +// Usage: RADIO_SESSION(RadioOwner::Mesh); +// Expands to a local variable that logs enter/leave around the scope. +#define RADIO_SESSION(owner) \ + ::platform::esp::idf_common::RadioSession _radio_sess(owner, __FILE__, __LINE__) diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/task_diag.h b/platform/esp/idf_common/include/platform/esp/idf_common/task_diag.h new file mode 100644 index 00000000..88e50416 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/task_diag.h @@ -0,0 +1,29 @@ +/** + * @file task_diag.h + * @brief Lightweight FreeRTOS task watermark + queue depth diagnostics. + * + * Intended for Tab5 / T-Display P4 to surface resource pressure during + * development and stability testing. Not a production monitoring solution; + * call logTaskWatermarks() from the app loop or a slow timer for visibility. + */ + +#pragma once + +#include + +namespace platform::esp::idf_common::task_diag +{ + +/// Log the high-water-mark (minimum free stack) of every FreeRTOS task +/// known to the system. Uses uxTaskGetSystemState() internally. +/// Call at most once per second. +void logTaskWatermarks(); + +/// Log the number of messages waiting in a FreeRTOS queue (by handle). +void logQueueDepth(const char* label, void* queue_handle); + +/// Simple macro to log a queue depth; pass a QueueHandle_t. +#define TASK_DIAG_LOG_QUEUE(label, handle) \ + ::platform::esp::idf_common::task_diag::logQueueDepth(label, handle) + +} // namespace platform::esp::idf_common::task_diag diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/ui_dispatcher.h b/platform/esp/idf_common/include/platform/esp/idf_common/ui_dispatcher.h new file mode 100644 index 00000000..d1d6bd69 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/ui_dispatcher.h @@ -0,0 +1,49 @@ +/** + * @file ui_dispatcher.h + * @brief IDF UI event dispatcher – serialises UI calls from non-LVGL tasks. + * + * Problem: screen_sleep, touch callbacks, and other FreeRTOS tasks were + * directly calling LVGL UI functions (showWatchFace, showMainMenu, etc.) + * from outside the LVGL task context. This caused races, deadlocks, and + * UI corruption on Tab5 / T-Display P4. + * + * Solution: Non-UI tasks post events to a lock-free queue. An LVGL timer + * drains the queue in the LVGL task context, where it is safe to touch + * the widget tree. + */ + +#pragma once + +#include + +namespace platform::esp::idf_common::ui_dispatcher +{ + +enum class Event : std::uint8_t +{ + None = 0, + WakeFromSleep, // → onWakeFromSleep → showWatchFace + ShowMainMenu, // → showMainMenu +}; + +struct Hooks +{ + void (*on_wake_from_sleep)() = nullptr; + void (*show_main_menu)() = nullptr; +}; + +/** One-time initialisation. Must be called before any post/drain. */ +void init(const Hooks& hooks); + +/** Post an event from any task / ISR-compatible context. Non-blocking. */ +bool post(Event event); + +/** Drain all pending events. MUST only be called from the LVGL task. */ +void drain(); + +/** Create an LVGL timer that calls drain() every period_ms milliseconds. + * Returns the timer handle so it can be paused/deleted if needed. + * Safe to call multiple times (subsequent calls are no-ops). */ +void* ensure_drain_timer(std::uint32_t period_ms = 100); + +} // namespace platform::esp::idf_common::ui_dispatcher diff --git a/platform/esp/idf_common/src/gps_runtime.cpp b/platform/esp/idf_common/src/gps_runtime.cpp index bc586cbc..dd89cf3f 100644 --- a/platform/esp/idf_common/src/gps_runtime.cpp +++ b/platform/esp/idf_common/src/gps_runtime.cpp @@ -62,8 +62,8 @@ struct RuntimeState uint8_t power_strategy = 0; uint8_t gnss_mode = 0; uint8_t gnss_sat_mask = 0; - uint8_t nmea_output_hz = 0; - uint8_t nmea_sentence_mask = 0; + uint8_t external_nmea_output_hz = 0; + uint8_t external_nmea_sentence_mask = 0; uint32_t motion_idle_timeout_ms = 0; uint8_t motion_sensor_id = 0; TaskHandle_t worker_handle = nullptr; @@ -768,36 +768,49 @@ void set_power_strategy(uint8_t strategy) s_runtime.power_strategy = strategy; } -void set_gnss_config(uint8_t mode, uint8_t sat_mask) +void set_enabled(bool enabled) { std::lock_guard lock(s_mutex); - s_runtime.gnss_mode = mode; - s_runtime.gnss_sat_mask = sat_mask; - // Shared settings use: - // 0 = High Accuracy - // 1 = Power Save - // 2 = Fix Only - // So all current values are active modes, not "off". - s_runtime.enabled = platform::ui::device::gps_supported() && sat_mask != 0; + s_runtime.enabled = platform::ui::device::gps_supported() && enabled; if (!s_runtime.enabled) { s_runtime.stop_requested = true; clear_payload_locked(); - ESP_LOGI(kTag, "GNSS runtime disabled: mode=%u sat_mask=0x%02X", mode, sat_mask); + ESP_LOGI(kTag, "GNSS runtime disabled"); return; } s_runtime.probe_requested = false; s_runtime.probe_deadline_ms = 0; s_runtime.time_sync_committed = is_valid_epoch(std::time(nullptr)); ensure_worker_locked(); - ESP_LOGI(kTag, "GNSS runtime enabled: interval_ms=%lu mode=%u sat_mask=0x%02X strategy=%u", static_cast(s_runtime.collection_interval_ms), s_runtime.gnss_mode, s_runtime.gnss_sat_mask, s_runtime.power_strategy); + ESP_LOGI(kTag, + "GNSS runtime enabled: interval_ms=%lu mode=%u sat_mask=0x%02X strategy=%u", + static_cast(s_runtime.collection_interval_ms), + s_runtime.gnss_mode, + s_runtime.gnss_sat_mask, + s_runtime.power_strategy); } -void set_nmea_config(uint8_t output_hz, uint8_t sentence_mask) +void set_gnss_config(uint8_t mode, uint8_t sat_mask) { std::lock_guard lock(s_mutex); - s_runtime.nmea_output_hz = output_hz; - s_runtime.nmea_sentence_mask = sentence_mask; + s_runtime.gnss_mode = mode; + s_runtime.gnss_sat_mask = sat_mask; + if (s_runtime.enabled) + { + ensure_worker_locked(); + } + ESP_LOGI(kTag, "GNSS receiver config: mode=%u sat_mask=0x%02X enabled=%d", + s_runtime.gnss_mode, + s_runtime.gnss_sat_mask, + s_runtime.enabled ? 1 : 0); +} + +void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) +{ + std::lock_guard lock(s_mutex); + s_runtime.external_nmea_output_hz = output_hz; + s_runtime.external_nmea_sentence_mask = sentence_mask; } void set_motion_idle_timeout(uint32_t timeout_ms) diff --git a/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp b/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp index b5138b56..fa083b36 100644 --- a/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp @@ -30,6 +30,11 @@ bool is_powered() return ::gps::gps_is_powered(); } +void set_enabled(bool enabled) +{ + ::gps::gps_set_enabled(enabled); +} + void set_collection_interval(uint32_t interval_ms) { ::gps::gps_set_collection_interval(interval_ms); @@ -45,9 +50,9 @@ void set_gnss_config(uint8_t mode, uint8_t sat_mask) ::gps::gps_set_gnss_config(mode, sat_mask); } -void set_nmea_config(uint8_t output_hz, uint8_t sentence_mask) +void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) { - ::gps::gps_set_nmea_config(output_hz, sentence_mask); + ::gps::gps_set_external_nmea_config(output_hz, sentence_mask); } void set_motion_idle_timeout(uint32_t timeout_ms) diff --git a/platform/esp/idf_common/src/platform_ui_lora_runtime.cpp b/platform/esp/idf_common/src/platform_ui_lora_runtime.cpp index 21ac3a50..7af241dd 100644 --- a/platform/esp/idf_common/src/platform_ui_lora_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_lora_runtime.cpp @@ -59,7 +59,7 @@ bool configure_receive(float freq_mhz, const ReceiveConfig& config) config.crc_len); } -float read_rssi() +float read_instant_rssi() { const float rssi = radio().readRssi(); return std::isfinite(rssi) ? rssi : std::numeric_limits::quiet_NaN(); diff --git a/platform/esp/idf_common/src/platform_ui_screen_runtime.cpp b/platform/esp/idf_common/src/platform_ui_screen_runtime.cpp deleted file mode 100644 index 066b0824..00000000 --- a/platform/esp/idf_common/src/platform_ui_screen_runtime.cpp +++ /dev/null @@ -1,88 +0,0 @@ -#include "platform/ui/screen_runtime.h" - -#include "screen_sleep.h" - -namespace -{ - -ScreenSleepHooks adapt_hooks(const platform::ui::screen::Hooks& hooks) -{ - ScreenSleepHooks adapted{}; - adapted.format_time = hooks.format_time; - adapted.read_unread_count = hooks.read_unread_count; - adapted.show_main_menu = hooks.show_main_menu; - adapted.on_wake_from_sleep = hooks.on_wake_from_sleep; - return adapted; -} - -} // namespace - -namespace platform::ui::screen -{ - -uint32_t clamp_timeout_ms(uint32_t timeout_ms) -{ - return clampScreenTimeoutMs(timeout_ms); -} - -uint32_t timeout_ms() -{ - return getScreenSleepTimeout(); -} - -uint16_t timeout_secs() -{ - return readScreenTimeoutSecs(); -} - -void set_timeout_ms(uint32_t timeout_ms) -{ - setScreenSleepTimeout(timeout_ms); -} - -void init(const Hooks& hooks) -{ - initScreenSleepRuntime(adapt_hooks(hooks)); -} - -bool is_sleeping() -{ - return isScreenSleeping(); -} - -bool is_sleep_disabled() -{ - return isScreenSleepDisabled(); -} - -bool is_saver_active() -{ - return isScreenSaverActive(); -} - -void wake_saver() -{ - wakeScreenSaver(); -} - -void enter_from_saver() -{ - enterFromScreenSaver(); -} - -void update_user_activity() -{ - updateUserActivity(); -} - -void disable_sleep() -{ - disableScreenSleep(); -} - -void enable_sleep() -{ - enableScreenSleep(); -} - -} // namespace platform::ui::screen diff --git a/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp b/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp index 3b3453e6..5d997ece 100644 --- a/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp @@ -1,4 +1,4 @@ -#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || defined(TRAIL_MATE_ESP_BOARD_TAB5) #include "platform/ui/wifi_runtime.h" @@ -16,7 +16,13 @@ constexpr const char* kSettingsNs = "settings"; constexpr const char* kWifiEnabledKey = "wifi_enabled"; constexpr const char* kWifiSsidKey = "wifi_ssid"; constexpr const char* kWifiPasswordKey = "wifi_password"; -constexpr const char* kUnsupportedMessage = "Wi-Fi is handled by the external C6 firmware on T-Display-P4"; +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +constexpr const char* kUnsupportedMessage = + "Wi-Fi is handled by the external C6 firmware on T-Display-P4"; +#else +constexpr const char* kUnsupportedMessage = + "Wi-Fi is not wired into the current Tab5 ESP-IDF target"; +#endif void copy_text(char* out, std::size_t out_len, const char* text) { diff --git a/platform/esp/idf_common/src/radio_owner.cpp b/platform/esp/idf_common/src/radio_owner.cpp new file mode 100644 index 00000000..857088fc --- /dev/null +++ b/platform/esp/idf_common/src/radio_owner.cpp @@ -0,0 +1,81 @@ +/** + * @file radio_owner.cpp + * @brief RadioSession implementation – logging-only in Phase 1. + */ + +#include "platform/esp/idf_common/radio_owner.h" + +#include + +#include "esp_log.h" + +namespace platform::esp::idf_common +{ +namespace +{ + +constexpr const char* kTag = "radio-owner"; + +const char* owner_name(RadioOwner o) +{ + switch (o) + { + case RadioOwner::None: + return "None"; + case RadioOwner::Mesh: + return "Mesh"; + case RadioOwner::Walkie: + return "Walkie"; + case RadioOwner::Sstv: + return "SSTV"; + case RadioOwner::Diagnostics: + return "Diag"; + case RadioOwner::Gps: + return "GPS"; + case RadioOwner::Boot: + return "Boot"; + } + return "?"; +} + +} // namespace + +RadioSession::RadioSession(RadioOwner owner, + const char* source_file, + int source_line) + : owner_(owner), source_file_(source_file), source_line_(source_line) +{ + (void)enter(); +} + +RadioSession::~RadioSession() +{ + leave(); +} + +bool RadioSession::enter() +{ + if (active_) + { + return true; + } + + ESP_LOGI(kTag, "[%s] enter %s:%d", + owner_name(owner_), source_file_, source_line_); + active_ = true; + return true; +} + +void RadioSession::leave() +{ + if (!active_) + { + return; + } + + ESP_LOGI(kTag, "[%s] leave %s:%d", + owner_name(owner_), source_file_, source_line_); + active_ = false; +} + +} // namespace platform::esp::idf_common diff --git a/platform/esp/idf_common/src/screen_sleep.cpp b/platform/esp/idf_common/src/screen_sleep.cpp index cda962db..dd9fdf8a 100644 --- a/platform/esp/idf_common/src/screen_sleep.cpp +++ b/platform/esp/idf_common/src/screen_sleep.cpp @@ -14,7 +14,9 @@ #include "freertos/semphr.h" #include "freertos/task.h" #include "platform/esp/idf_common/bsp_runtime.h" +#include "platform/esp/idf_common/ui_dispatcher.h" #include "platform/ui/device_runtime.h" +#include "platform/ui/screen_runtime.h" #include "platform/ui/settings_store.h" #if defined(TRAIL_MATE_ESP_BOARD_TAB5) @@ -102,10 +104,10 @@ void sleep_display_locked() void notify_wake() { - if (s_hooks.on_wake_from_sleep) - { - s_hooks.on_wake_from_sleep(); - } + // Post to UI dispatcher instead of calling LVGL hooks directly. + // The drain timer runs in LVGL task context where it is safe. + platform::esp::idf_common::ui_dispatcher::post( + platform::esp::idf_common::ui_dispatcher::Event::WakeFromSleep); } bool wake_requested_by_touch_irq_locked() @@ -198,6 +200,18 @@ void setScreenSleepTimeout(uint32_t timeout_ms) void initScreenSleepRuntime(const ScreenSleepHooks& hooks) { platform::esp::idf_common::bsp_runtime::ensure_nvs_ready(); + + // Bridge the legacy hooks into the UI dispatcher so that wake/saver + // events are serialised through the LVGL task instead of being called + // directly from the screen-sleep FreeRTOS task. + { + platform::esp::idf_common::ui_dispatcher::Hooks dispatch_hooks{}; + dispatch_hooks.on_wake_from_sleep = hooks.on_wake_from_sleep; + dispatch_hooks.show_main_menu = hooks.show_main_menu; + platform::esp::idf_common::ui_dispatcher::init(dispatch_hooks); + (void)platform::esp::idf_common::ui_dispatcher::ensure_drain_timer(); + } + s_hooks = hooks; ensure_mutex(); @@ -268,10 +282,8 @@ void wakeScreenSaver() void enterFromScreenSaver() { updateUserActivity(); - if (s_hooks.show_main_menu) - { - s_hooks.show_main_menu(); - } + platform::esp::idf_common::ui_dispatcher::post( + platform::esp::idf_common::ui_dispatcher::Event::ShowMainMenu); } void updateUserActivity() @@ -325,3 +337,42 @@ void enableScreenSleep() xSemaphoreGive(s_mutex); } } + +// =================================================================== +// platform::ui::screen contract — implemented directly here to +// eliminate the adapt_hooks() pass-through layer that previously +// lived in platform_ui_screen_runtime.cpp. +// =================================================================== + +namespace platform::ui::screen +{ +namespace +{ + +ScreenSleepHooks adapt_hooks(const Hooks& hooks) +{ + ScreenSleepHooks adapted{}; + adapted.format_time = hooks.format_time; + adapted.read_unread_count = hooks.read_unread_count; + adapted.show_main_menu = hooks.show_main_menu; + adapted.on_wake_from_sleep = hooks.on_wake_from_sleep; + return adapted; +} + +} // namespace + +uint32_t clamp_timeout_ms(uint32_t timeout_ms) { return clampScreenTimeoutMs(timeout_ms); } +uint32_t timeout_ms() { return getScreenSleepTimeout(); } +uint16_t timeout_secs() { return readScreenTimeoutSecs(); } +void set_timeout_ms(uint32_t t) { setScreenSleepTimeout(t); } +void init(const Hooks& h) { initScreenSleepRuntime(adapt_hooks(h)); } +bool is_sleeping() { return isScreenSleeping(); } +bool is_sleep_disabled() { return isScreenSleepDisabled(); } +bool is_saver_active() { return isScreenSaverActive(); } +void wake_saver() { wakeScreenSaver(); } +void enter_from_saver() { enterFromScreenSaver(); } +void update_user_activity() { updateUserActivity(); } +void disable_sleep() { disableScreenSleep(); } +void enable_sleep() { enableScreenSleep(); } + +} // namespace platform::ui::screen diff --git a/platform/esp/idf_common/src/task_diag.cpp b/platform/esp/idf_common/src/task_diag.cpp new file mode 100644 index 00000000..0d987721 --- /dev/null +++ b/platform/esp/idf_common/src/task_diag.cpp @@ -0,0 +1,76 @@ +/** + * @file task_diag.cpp + * @brief FreeRTOS task watermark + queue depth diagnostics. + */ + +#include "platform/esp/idf_common/task_diag.h" + +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +namespace platform::esp::idf_common::task_diag +{ +namespace +{ + +constexpr const char* kTag = "task-diag"; +constexpr std::uint32_t kMaxTasks = 32; + +} // namespace + +void logTaskWatermarks() +{ + TaskStatus_t tasks[kMaxTasks]{}; + unsigned long total_runtime = 0; + const std::uint32_t actual = + uxTaskGetSystemState(tasks, kMaxTasks, &total_runtime); + + if (actual == 0 || actual > kMaxTasks) + { + ESP_LOGE(kTag, "uxTaskGetSystemState returned %lu", static_cast(actual)); + return; + } + + ESP_LOGI(kTag, "=== Task watermarks (%lu tasks) ===", static_cast(actual)); + for (std::uint32_t i = 0; i < actual; ++i) + { + const auto& t = tasks[i]; + const unsigned long stack_free = + static_cast(t.usStackHighWaterMark); + const unsigned long stack_words = stack_free * sizeof(StackType_t); + + if (stack_words < 512U) + { + ESP_LOGW(kTag, " %-20s pri=%lu stack-free=%lu B *** LOW ***", + t.pcTaskName, + static_cast(t.uxCurrentPriority), + static_cast(stack_words)); + } + else + { + ESP_LOGI(kTag, " %-20s pri=%lu stack-free=%lu B", + t.pcTaskName, + static_cast(t.uxCurrentPriority), + static_cast(stack_words)); + } + } +} + +void logQueueDepth(const char* label, void* queue_handle) +{ + if (queue_handle == nullptr || label == nullptr) + { + return; + } + + const auto handle = static_cast(queue_handle); + const unsigned long waiting = static_cast(uxQueueMessagesWaiting(handle)); + ESP_LOGI(kTag, "Queue '%s': %lu messages waiting", label, waiting); +} + +} // namespace platform::esp::idf_common::task_diag diff --git a/platform/esp/idf_common/src/ui_dispatcher.cpp b/platform/esp/idf_common/src/ui_dispatcher.cpp new file mode 100644 index 00000000..30e09bb3 --- /dev/null +++ b/platform/esp/idf_common/src/ui_dispatcher.cpp @@ -0,0 +1,145 @@ +/** + * @file ui_dispatcher.cpp + * @brief Lock-free event queue + LVGL drain timer for IDF UI serialisation. + */ + +#include "platform/esp/idf_common/ui_dispatcher.h" + +#include +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "lvgl.h" + +namespace platform::esp::idf_common::ui_dispatcher +{ +namespace +{ + +constexpr const char* kTag = "idf-ui-dispatch"; +constexpr std::uint32_t kDefaultDrainPeriodMs = 20; +constexpr std::uint32_t kQueueDepth = 16; + +Hooks s_hooks{}; +QueueHandle_t s_queue = nullptr; +lv_timer_t* s_drain_timer = nullptr; + +} // namespace + +void init(const Hooks& hooks) +{ + s_hooks = hooks; + + if (s_queue == nullptr) + { + s_queue = xQueueCreate(kQueueDepth, sizeof(Event)); + if (s_queue == nullptr) + { + ESP_LOGE(kTag, "Failed to create UI event queue"); + } + } +} + +bool post(Event event) +{ + if (s_queue == nullptr || event == Event::None) + { + return false; + } + + // Non-blocking send from any task context. + const BaseType_t rc = xQueueSend(s_queue, &event, 0); + return rc == pdTRUE; +} + +void drain() +{ + if (s_queue == nullptr) + { + return; + } + + Event event = Event::None; + while (xQueueReceive(s_queue, &event, 0) == pdTRUE) + { + switch (event) + { + case Event::WakeFromSleep: + if (s_hooks.on_wake_from_sleep) + { + s_hooks.on_wake_from_sleep(); + } + break; + + case Event::ShowMainMenu: + if (s_hooks.show_main_menu) + { + s_hooks.show_main_menu(); + } + break; + + case Event::None: + default: + break; + } + } +} + +// LVGL timer callback – runs in the LVGL task context. +// Process at most ONE event per tick to avoid starving the render pipeline. +// The drain timer fires at a conservative 100 ms; any remaining events +// are picked up on the next tick. +void drain_timer_cb(lv_timer_t* /*timer*/) +{ + if (s_queue == nullptr) + { + return; + } + + Event event = Event::None; + if (xQueueReceive(s_queue, &event, 0) != pdTRUE) + { + return; + } + + switch (event) + { + case Event::WakeFromSleep: + if (s_hooks.on_wake_from_sleep) s_hooks.on_wake_from_sleep(); + break; + case Event::ShowMainMenu: + if (s_hooks.show_main_menu) s_hooks.show_main_menu(); + break; + case Event::None: + default: + break; + } +} + +void* ensure_drain_timer(std::uint32_t period_ms) +{ + if (s_drain_timer != nullptr) + { + return s_drain_timer; + } + + // Default 100 ms period: fast enough for responsive wake-up, + // slow enough to avoid starving the LVGL render pipeline. + const std::uint32_t p = (period_ms > 0) ? period_ms : 100; + s_drain_timer = lv_timer_create(drain_timer_cb, p, nullptr); + if (s_drain_timer == nullptr) + { + ESP_LOGE(kTag, "Failed to create LVGL drain timer"); + } + else + { + ESP_LOGI(kTag, "Drain timer created period_ms=%lu", static_cast(p)); + } + + return s_drain_timer; +} + +} // namespace platform::esp::idf_common::ui_dispatcher diff --git a/platform/esp/idf_components/m5stack_tab5/include/bsp/trail_mate_tab5_runtime.h b/platform/esp/idf_components/m5stack_tab5/include/bsp/trail_mate_tab5_runtime.h index 721f03aa..b130dafd 100644 --- a/platform/esp/idf_components/m5stack_tab5/include/bsp/trail_mate_tab5_runtime.h +++ b/platform/esp/idf_components/m5stack_tab5/include/bsp/trail_mate_tab5_runtime.h @@ -1,6 +1,7 @@ #pragma once #include +#include #ifdef __cplusplus extern "C" @@ -10,6 +11,10 @@ extern "C" bool trail_mate_tab5_display_runtime_init(void); bool trail_mate_tab5_display_runtime_is_ready(void); bool trail_mate_tab5_touch_interrupt_active(void); + bool trail_mate_tab5_display_lock(uint32_t timeout_ms); + void trail_mate_tab5_display_unlock(void); + void trail_mate_tab5_set_ext_5v_enabled(bool enabled); + void trail_mate_tab5_set_wifi_power_enabled(bool enabled); #ifdef __cplusplus } diff --git a/platform/esp/idf_components/m5stack_tab5/trail_mate_tab5_runtime.cpp b/platform/esp/idf_components/m5stack_tab5/trail_mate_tab5_runtime.cpp index 029aaa69..adcb8c45 100644 --- a/platform/esp/idf_components/m5stack_tab5/trail_mate_tab5_runtime.cpp +++ b/platform/esp/idf_components/m5stack_tab5/trail_mate_tab5_runtime.cpp @@ -141,3 +141,30 @@ extern "C" bool trail_mate_tab5_display_runtime_is_ready(void) { return s_ready; } + +extern "C" bool trail_mate_tab5_display_lock(uint32_t timeout_ms) +{ + return bsp_display_lock(timeout_ms); +} + +extern "C" void trail_mate_tab5_display_unlock(void) +{ + bsp_display_unlock(); +} + +extern "C" void trail_mate_tab5_set_ext_5v_enabled(bool enabled) +{ + // Direct BSP setter retained for internal / legacy callers. + // New code should use Tab5Board::acquireExt5vRail() / releaseExt5vRail() + // so that the rail is owned by a named lease rather than toggled ad-hoc. + ESP_LOGW("tab5-bsp", + "Direct Ext5V setter called (enabled=%d). " + "Prefer Tab5Board::acquireExt5vRail() for named ownership.", + enabled ? 1 : 0); + bsp_set_ext_5v_en(enabled); +} + +extern "C" void trail_mate_tab5_set_wifi_power_enabled(bool enabled) +{ + bsp_set_wifi_power_enable(enabled); +} diff --git a/platform/esp/idf_components/t_display_p4/trail_mate_t_display_p4_runtime.cpp b/platform/esp/idf_components/t_display_p4/trail_mate_t_display_p4_runtime.cpp index 29a2b83f..7569e16c 100644 --- a/platform/esp/idf_components/t_display_p4/trail_mate_t_display_p4_runtime.cpp +++ b/platform/esp/idf_components/t_display_p4/trail_mate_t_display_p4_runtime.cpp @@ -13,6 +13,7 @@ #include "esp_ldo_regulator.h" #include "esp_log.h" #include "esp_lvgl_port.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "hi8561_driver.h" @@ -34,8 +35,11 @@ constexpr int kStartupBrightnessPercent = 10; constexpr int kBacklightPwmHz = 2000; constexpr ledc_channel_t kBacklightChannel = LEDC_CHANNEL_0; constexpr ledc_timer_t kBacklightTimer = LEDC_TIMER_0; -constexpr uint32_t kTouchI2cLockTimeoutMs = 5; -constexpr uint32_t kTouchI2cTransactionTimeoutMs = 5; +// Conservative I2C timeouts for touch — avoids random read failures +// when the bus is contended by expander / RTC / GPS/LoRa prepare. +// These will be tightened once the system I2C lock ordering is stable. +constexpr uint32_t kTouchI2cLockTimeoutMs = 50; +constexpr uint32_t kTouchI2cTransactionTimeoutMs = 30; constexpr uint32_t kHi8561MemoryAddressEram = 0x20011000; constexpr uint8_t kHi8561MaxDsramNum = 25; @@ -409,6 +413,24 @@ bool read_gt9895_touch(int32_t* out_x, int32_t* out_y, bool* out_pressed) return true; } +bool s_touch_was_pressed = false; +std::uint32_t s_last_activity_note_ms = 0; + +void note_touch_activity(bool pressed) +{ + const std::uint32_t now = static_cast(esp_timer_get_time() / 1000ULL); + const bool first_touch = pressed && !s_touch_was_pressed; + const bool throttle_ok = pressed && (now - s_last_activity_note_ms) >= 500U; + + s_touch_was_pressed = pressed; + + if (pressed && (first_touch || throttle_ok)) + { + s_last_activity_note_ms = now; + trail_mate_idf_note_user_activity(); + } +} + void touch_read_cb(lv_indev_t* indev, lv_indev_data_t* data) { (void)indev; @@ -420,6 +442,7 @@ void touch_read_cb(lv_indev_t* indev, lv_indev_data_t* data) if (!ok || !pressed) { + note_touch_activity(false); data->state = LV_INDEV_STATE_RELEASED; return; } @@ -427,7 +450,12 @@ void touch_read_cb(lv_indev_t* indev, lv_indev_data_t* data) data->state = LV_INDEV_STATE_PRESSED; data->point.x = x; data->point.y = y; - trail_mate_idf_note_user_activity(); + + // Debounce user-activity notification: only fire on the first touch + // press edge, then at most once every 500 ms while the finger stays + // down. Without this throttle, every LVGL input poll (~50-100 Hz) + // would hit the screen_sleep runtime and risk re-entrant UI updates. + note_touch_activity(true); } bool create_touch_indev() diff --git a/platform/linux/README.md b/platform/linux/README.md index a7b870b6..da903216 100644 --- a/platform/linux/README.md +++ b/platform/linux/README.md @@ -2,4 +2,17 @@ Linux-specific platform implementations. -This layer will eventually back the simulator and future Raspberry Pi / UNO Q targets. +This layer now holds the shared Linux-safe slice for Cardputer Zero, plus +Pi-specific platform adapters. + +- `platform/linux/common`: code that is safe to share between simulator and + Pi OS device shells +- `platform/linux/rpi`: Pi OS / framebuffer specific platform adapters + +Keep simulator-only presentation and desktop tooling in `apps/linux_sim`, and +keep device shell ownership in `apps/linux_rpi`. + +For real Cardputer Zero hardware bring-up, prefer `M5Stack_Linux_Libs` for the +device-facing LVGL and driver path. Treat `platform/linux/rpi` as the place for +Trail Mate specific Linux adapters that remain necessary after the SDK takes +care of the generic hardware plumbing. diff --git a/platform/linux/common/README.md b/platform/linux/common/README.md index 002b07a9..2748df1f 100644 --- a/platform/linux/common/README.md +++ b/platform/linux/common/README.md @@ -1,3 +1,17 @@ # `platform/linux/common` -Planned home for Linux-common adapters and runtime glue. +Shared Linux-safe code used by both the desktop simulator and the Pi OS device +shell. + +This layer is the neutral meeting point for: + +- tiny render primitives +- simulator/device-agnostic app behavior +- Linux-safe surface presentation contracts +- Linux implementations of shared `platform::ui::*` runtime contracts such as + settings, time, screen timeout, and generic device state +- the shared Linux boot/menu shell session used by both the simulator and the + thin framebuffer device shell + +Code should move here only when it is genuinely shared between `apps/linux_sim` +and `apps/linux_rpi`. diff --git a/platform/linux/common/include/app/linux_app_facade.h b/platform/linux/common/include/app/linux_app_facade.h new file mode 100644 index 00000000..149f7685 --- /dev/null +++ b/platform/linux/common/include/app/linux_app_facade.h @@ -0,0 +1,96 @@ +#pragma once + +#include "app/app_config.h" +#include "app/app_facades.h" + +#include + +namespace sys +{ +struct Event; +} + +namespace trailmate::cardputer_zero::linux_ui +{ + +using TeamUiEventDispatcher = bool (*)(sys::Event*); + +void setTeamUiEventDispatcher(TeamUiEventDispatcher dispatcher); + +class MinimalLinuxAppFacade final : public ::app::IAppFacade +{ + public: + MinimalLinuxAppFacade(); + ~MinimalLinuxAppFacade() override; + + MinimalLinuxAppFacade(const MinimalLinuxAppFacade&) = delete; + MinimalLinuxAppFacade& operator=(const MinimalLinuxAppFacade&) = delete; + + bool initialize(); + void shutdown(); + bool is_initialized() const noexcept; + + ::app::AppConfig& getConfig() override; + const ::app::AppConfig& getConfig() const override; + void saveConfig() override; + void applyMeshConfig() override; + void applyUserInfo() override; + void applyPositionConfig() override; + void applyNetworkLimits() override; + void applyPrivacyConfig() override; + void applyChatDefaults() override; + ::chat::MeshProtocol getMeshProtocol() const override; + void getEffectiveUserInfo(char* out_long, + std::size_t long_len, + char* out_short, + std::size_t short_len) const override; + bool switchMeshProtocol(::chat::MeshProtocol protocol, bool persist = true) override; + + ::chat::ChatService& getChatService() override; + ::chat::contacts::ContactService& getContactService() override; + ::chat::IMeshAdapter* getMeshAdapter() override; + const ::chat::IMeshAdapter* getMeshAdapter() const override; + ::chat::NodeId getSelfNodeId() const override; + + ::team::TeamController* getTeamController() override; + ::team::TeamPairingService* getTeamPairing() override; + ::team::TeamService* getTeamService() override; + const ::team::TeamService* getTeamService() const override; + ::team::TeamTrackSampler* getTeamTrackSampler() override; + void setTeamModeActive(bool active) override; + + void broadcastNodeInfo() override; + void clearNodeDb() override; + void clearMessageDb() override; + + ::ble::BleManager* getBleManager() override; + const ::ble::BleManager* getBleManager() const override; + bool isBleEnabled() const override; + void setBleEnabled(bool enabled) override; + void restartDevice() override; + ::chat::ui::IChatUiRuntime* getChatUiRuntime() override; + void setChatUiRuntime(::chat::ui::IChatUiRuntime* runtime) override; + ::BoardBase* getBoard() override; + const ::BoardBase* getBoard() const override; + + void updateCoreServices() override; + void tickEventRuntime() override; + void dispatchPendingEvents(std::size_t max_events = 32) override; + + private: + struct Implementation; + + Implementation& impl(); + const Implementation& impl() const; + void loadPersistedConfig(); + void seedDefaultIdentity(); + void syncLocalIdentity(); + void ensureServicesReady(); + + ::app::AppConfig config_{}; + std::unique_ptr impl_; + ::chat::ui::IChatUiRuntime* chat_ui_runtime_ = nullptr; + bool initialized_ = false; +}; + +} // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/include/app/linux_demo_world.h b/platform/linux/common/include/app/linux_demo_world.h new file mode 100644 index 00000000..d15293c1 --- /dev/null +++ b/platform/linux/common/include/app/linux_demo_world.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include + +#include "chat/domain/chat_model.h" +#include "chat/ports/i_mesh_adapter.h" +#include "team/domain/team_types.h" + +namespace trailmate::cardputer_zero::linux_ui +{ + +// --------------------------------------------------------------------------- +// DemoWorld +// +// Synthetic demo data for the simulator. Seeds the contact + node stores +// with fake peers, provides a loopback mesh adapter, auto-reply logic, and +// dummy team crypto. +// +// This class exists SOLELY for development and simulator use. It must not +// be active on a real device except with explicit opt-in (TRAIL_MATE_DEMO_WORLD=1). +// --------------------------------------------------------------------------- + +struct DemoPeerSeed +{ + ::chat::NodeId node_id = 0; + const char* short_name = nullptr; + const char* long_name = nullptr; + const char* nickname = nullptr; + bool ignored = false; + float snr = 0.0f; + float rssi = 0.0f; + int32_t lat_e7 = 0; + int32_t lon_e7 = 0; +}; + +class DemoWorld +{ + public: + DemoWorld(); + ~DemoWorld(); + + DemoWorld(const DemoWorld&) = delete; + DemoWorld& operator=(const DemoWorld&) = delete; + + /// Returns the canonical set of synthetic peers. + static std::array peerSeeds(); + + /// Factory: create a loopback mesh adapter pre-configured with the + /// given self node ID. The adapter will auto-reply to messages and + /// inject synthetic broadcast traffic. + std::unique_ptr<::chat::IMeshAdapter> + createLoopbackMesh(::chat::NodeId self_node_id); + + /// Compute an auto-reply text for a given peer. + static std::string autoReplyText(::chat::NodeId peer, + const std::string& received_text); + + /// Synthetic node IDs used by the demo world (constexpr for static_assert). + static constexpr ::chat::NodeId alphaNodeId() { return 0x435A1001U; } + static constexpr ::chat::NodeId bravoNodeId() { return 0x435A1002U; } + static constexpr ::chat::NodeId scoutNodeId() { return 0x435A1003U; } + static constexpr ::chat::NodeId nearbyNodeId() { return 0x435A1004U; } + static constexpr ::chat::NodeId broadcastNodeId() { return alphaNodeId(); } + + /// Synthetic team identity used for demo pairing. + static ::team::TeamId syntheticPairTeamId(); + static std::array syntheticPairPsk(); + static const char* syntheticPairTeamName(); + + private: + struct Impl; + std::unique_ptr impl_{}; +}; + +} // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/include/chat/linux_noop_mesh_adapter.h b/platform/linux/common/include/chat/linux_noop_mesh_adapter.h new file mode 100644 index 00000000..00239808 --- /dev/null +++ b/platform/linux/common/include/chat/linux_noop_mesh_adapter.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include + +#include "chat/domain/chat_model.h" +#include "chat/ports/i_mesh_adapter.h" + +namespace trailmate::cardputer_zero::linux_ui +{ + +// --------------------------------------------------------------------------- +// LinuxNoopMeshAdapter +// +// Minimal IMeshAdapter for DeviceLocal mode. Does NOT inject synthetic +// peers, auto-replies, or loopback messages. Returns false/no-op for all +// I/O operations. Used when there is no real radio and we don't want the +// simulator's loopback world to leak into a real device. +// --------------------------------------------------------------------------- + +class LinuxNoopMeshAdapter final : public ::chat::IMeshAdapter +{ + public: + LinuxNoopMeshAdapter() = default; + + ::chat::MeshCapabilities getCapabilities() const override; + bool sendText(::chat::ChannelId, const std::string&, ::chat::MessageId*, + ::chat::NodeId = 0) override; + bool sendTextWithId(::chat::ChannelId, const std::string&, ::chat::MessageId, + ::chat::MessageId*, ::chat::NodeId = 0) override; + bool pollIncomingText(::chat::MeshIncomingText*) override; + bool sendAppData(::chat::ChannelId, uint32_t, const uint8_t*, size_t, + ::chat::NodeId = 0, bool = false, ::chat::MessageId = 0, + bool = false) override; + bool pollIncomingData(::chat::MeshIncomingData*) override; + bool requestNodeInfo(::chat::NodeId, bool = false) override; + bool startKeyVerification(::chat::NodeId) override; + bool submitKeyVerificationNumber(::chat::NodeId, uint64_t, uint32_t) override; + ::chat::NodeId getNodeId() const override; + bool isPkiReady() const override; + bool hasPkiKey(::chat::NodeId) const override; + bool triggerDiscoveryAction(::chat::MeshDiscoveryAction) override; + void applyConfig(const ::chat::MeshConfig&) override; + void setUserInfo(const char*, const char*) override; + bool isReady() const override; + bool pollIncomingRawPacket(uint8_t*, size_t&, size_t) override; + + // Extra methods used by MinimalLinuxAppFacade internals (not on IMeshAdapter). + void setSelfNodeId(::chat::NodeId id) { self_id_ = id; } + void tick() {} + bool takePendingSendResult(::chat::MessageId&, bool&) { return false; } + void queueIncomingText(const ::chat::MeshIncomingText&) {} + void queueIncomingData(const ::chat::MeshIncomingData&) {} + + private: + ::chat::NodeId self_id_{0}; +}; + +} // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/include/lv_conf.h b/platform/linux/common/include/lv_conf.h new file mode 100644 index 00000000..8da74e9b --- /dev/null +++ b/platform/linux/common/include/lv_conf.h @@ -0,0 +1,37 @@ +#ifndef LV_CONF_H +#define LV_CONF_H + +#define LV_COLOR_DEPTH 16 + +#define LV_USE_STDLIB_MALLOC LV_STDLIB_CLIB +#define LV_USE_STDLIB_STRING LV_STDLIB_CLIB +#define LV_USE_STDLIB_SPRINTF LV_STDLIB_CLIB + +#define LV_USE_OS LV_OS_NONE + +#define LV_FONT_MONTSERRAT_10 1 +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_16 1 +#define LV_FONT_MONTSERRAT_18 1 +#define LV_FONT_MONTSERRAT_20 1 +#define LV_FONT_MONTSERRAT_24 1 + +#define LV_USE_SDL 0 + +#if defined(_WIN32) +#define LV_USE_FS_WIN32 1 +#define LV_FS_WIN32_LETTER 'A' +#define LV_FS_WIN32_PATH "" +#define LV_FS_WIN32_CACHE_SIZE 0 +#else +#define LV_USE_FS_POSIX 1 +#define LV_FS_POSIX_LETTER 'A' +#define LV_FS_POSIX_PATH "" +#define LV_FS_POSIX_CACHE_SIZE 0 +#endif + +#define LV_USE_LODEPNG 1 +#define LV_USE_TJPGD 1 + +#endif // LV_CONF_H diff --git a/platform/linux/common/include/platform/linux/capability_status.h b/platform/linux/common/include/platform/linux/capability_status.h new file mode 100644 index 00000000..b8223d54 --- /dev/null +++ b/platform/linux/common/include/platform/linux/capability_status.h @@ -0,0 +1,17 @@ +#pragma once + +// Re-export the shared contract types into the Linux runtime namespace. +#include "platform/ui/capability_status.h" + +namespace platform::linux_runtime +{ + +using CapabilityState = ::platform::ui::CapabilityState; +using CapabilityStatus = ::platform::ui::CapabilityStatus; + +inline const char* capability_state_label(::platform::ui::CapabilityState state) +{ + return ::platform::ui::capability_state_label(state); +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/env_config.h b/platform/linux/common/include/platform/linux/env_config.h new file mode 100644 index 00000000..1afd7705 --- /dev/null +++ b/platform/linux/common/include/platform/linux/env_config.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +namespace platform::linux_runtime +{ + +// --------------------------------------------------------------------------- +// env_config +// +// Centralised environment-variable helpers. Every runtime that currently +// copies the same "read TRAIL_MATE_* env var with fallback" pattern should +// use these instead of re-implementing getenv + strtol / strtod / strcmp. +// --------------------------------------------------------------------------- + +/// Returns true when the env var is set to "1", "true", "TRUE", "yes", or "YES". +/// Returns `fallback` when the variable is absent or empty. +bool env_flag(const char* name, bool fallback = false); + +/// Read an integer from an environment variable. Returns `fallback` when +/// the variable is absent, empty, or not a valid integer. +int env_int(const char* name, int fallback = 0); + +/// Read an unsigned 32-bit integer from an environment variable. +/// Returns `fallback` on any parse failure. +std::uint32_t env_uint32(const char* name, std::uint32_t fallback = 0); + +/// Read a double from an environment variable. +/// Returns `fallback` on any parse failure or if the value is non-finite. +double env_double(const char* name, double fallback = 0.0); + +/// Read a float from an environment variable. +/// Returns `fallback` on any parse failure or if the value is non-finite. +float env_float(const char* name, float fallback = 0.0f); + +/// Read a string from an environment variable. Returns `fallback` when the +/// variable is absent or empty. The returned pointer is valid for the +/// lifetime of the process (it points into the environment block). +const char* env_string(const char* name, const char* fallback = nullptr); + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/runtime_mode.h b/platform/linux/common/include/platform/linux/runtime_mode.h new file mode 100644 index 00000000..eb237d5a --- /dev/null +++ b/platform/linux/common/include/platform/linux/runtime_mode.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +namespace platform::linux_runtime +{ + +// --------------------------------------------------------------------------- +// LinuxRuntimeMode +// +// Controls the composition behaviour of the Linux app facade: +// - SimulatorDemo: desktop simulator with synthetic peers, loopback mesh, +// dummy crypto, auto-replies. Enabled by default when +// TRAIL_MATE_SIM_DEMO is not explicitly set to "0". +// - DeviceLocal: real device shell without demo data. No fake peers, +// no dummy crypto. Mesh transport is loopback or absent. +// - DeviceRealMesh: real device with actual LoRa / BLE transport (future). +// --------------------------------------------------------------------------- + +enum class LinuxRuntimeMode +{ + SimulatorDemo, + DeviceLocal, + DeviceRealMesh, +}; + +/// Resolve the intended runtime mode from environment. +/// +/// If TRAIL_MATE_RUNTIME_MODE is set to "demo", "local", or "mesh", that +/// value takes precedence. +/// +/// Otherwise defaults to DeviceLocal (safe for real devices). +/// The simulator shell should either set TRAIL_MATE_RUNTIME_MODE=demo or +/// call resolve_runtime_mode() and override the result at its entrypoint. +inline LinuxRuntimeMode resolve_runtime_mode() +{ + const char* mode_env = std::getenv("TRAIL_MATE_RUNTIME_MODE"); + if (mode_env && mode_env[0] != '\0') + { + if (std::strcmp(mode_env, "demo") == 0) return LinuxRuntimeMode::SimulatorDemo; + if (std::strcmp(mode_env, "local") == 0) return LinuxRuntimeMode::DeviceLocal; + if (std::strcmp(mode_env, "mesh") == 0) return LinuxRuntimeMode::DeviceRealMesh; + } + + // Safe default: no demo data, no dummy crypto, no synthetic peers. + return LinuxRuntimeMode::DeviceLocal; +} + +/// Explicit demo-enable for development without the full simulator. +/// Respects TRAIL_MATE_DEMO_WORLD env var. +inline bool demo_world_enabled(LinuxRuntimeMode mode) +{ + if (mode == LinuxRuntimeMode::SimulatorDemo) + { + return true; + } + + // Allow DeviceLocal + TRAIL_MATE_DEMO_WORLD=1 for dev testing. + const char* demo_env = std::getenv("TRAIL_MATE_DEMO_WORLD"); + if (demo_env && demo_env[0] != '\0') + { + return std::strcmp(demo_env, "1") == 0 || + std::strcmp(demo_env, "true") == 0 || + std::strcmp(demo_env, "TRUE") == 0; + } + + return false; +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/runtime_paths.h b/platform/linux/common/include/platform/linux/runtime_paths.h new file mode 100644 index 00000000..d1c78960 --- /dev/null +++ b/platform/linux/common/include/platform/linux/runtime_paths.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include + +namespace platform::linux_runtime +{ + +// --------------------------------------------------------------------------- +// RuntimePaths +// +// Centralised path resolution for all Linux runtime implementations. +// Call resolve_paths() once early in the process and pass the result +// (or individual roots) into every runtime that needs storage. +// --------------------------------------------------------------------------- + +struct RuntimePaths +{ + std::filesystem::path settings_root; + std::filesystem::path sd_root; + std::filesystem::path cache_root; + std::filesystem::path state_root; +}; + +/// Compute the canonical set of runtime directories. +/// +/// Precedence (highest first): +/// 1. Environment variables: +/// - TRAIL_MATE_SETTINGS_ROOT +/// - TRAIL_MATE_SD_ROOT +/// - TRAIL_MATE_CACHE_ROOT +/// 2. On Linux: $HOME/.trailmate_cardputer_zero +/// 3. On Windows: %APPDATA%/TrailMateCardputerZero +/// 4. Fallback: cwd/.trailmate_cardputer_zero +/// +/// sd_root defaults to /sdcard. +/// cache_root defaults to /cache. +/// state_root is the same as settings_root. +RuntimePaths resolve_paths(); + +/// Convenience: settings file for a given namespace. +/// Returns /settings/.kv +std::filesystem::path settings_file(const char* ns); + +/// Convenience: child path under sd_root. +std::filesystem::path sd_child(std::string_view relative); + +/// Ensure a directory tree exists. Returns true on success. +bool ensure_directory(const std::filesystem::path& path); + +// --------------------------------------------------------------------------- +// Path safety +// --------------------------------------------------------------------------- + +/// Resolve `child` relative to `root`, return the canonical result in `out`. +/// Returns false and leaves `out` empty when: +/// - `child` is empty +/// - `child` is absolute +/// - `child` contains ".." segments +/// - The resolved path is outside `root` +/// - Any filesystem error occurs +bool resolve_child_under_root(const std::filesystem::path& root, + std::string_view child, + std::filesystem::path& out); + +/// Convenience: remove a file after verifying it lies under `root`. +/// Returns false on any safety violation or filesystem error. +bool safe_remove_under_root(const std::filesystem::path& root, + std::string_view child); + +/// Convenience: open a file for writing under `root` with atomic rename. +/// Writes to a temp file, closes (which flushes user-space buffers), then +/// atomically renames over the target. Does NOT fsync; a power-loss-safe +/// variant can be added later when needed on real devices. +/// Returns false on any safety violation or filesystem error. +bool safe_write_under_root(const std::filesystem::path& root, + std::string_view child, + const std::string& content); + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/sys/event_bus.h b/platform/linux/common/include/sys/event_bus.h new file mode 100644 index 00000000..5c983a63 --- /dev/null +++ b/platform/linux/common/include/sys/event_bus.h @@ -0,0 +1,451 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "chat/domain/chat_types.h" +#include "chat/domain/contact_types.h" +#include "sys/clock.h" +#include "team/domain/team_events.h" + +namespace sys +{ + +enum class EventType +{ + ChatNewMessage, + ChatSendResult, + ChatUnreadChanged, + ChatChannelSwitched, + NodeInfoUpdate, + NodeProtocolUpdate, + NodePositionUpdate, + KeyVerificationNumberRequest, + KeyVerificationNumberInform, + KeyVerificationFinal, + AppData, + TeamKick, + TeamTransferLeader, + TeamKeyDist, + TeamStatus, + TeamPosition, + TeamWaypoint, + TeamTrack, + TeamChat, + TeamPairing, + TeamError, + InputEvent, + SystemTick +}; + +struct Event +{ + EventType type; + uint32_t timestamp; + + explicit Event(EventType t) : type(t), timestamp(sys::millis_now()) {} + virtual ~Event() = default; +}; + +struct ChatNewMessageEvent : public Event +{ + uint8_t channel; + uint32_t msg_id; + char text[64]; + chat::RxMeta rx_meta; + + ChatNewMessageEvent(uint8_t ch, uint32_t id, const char* msg_text = "", + const chat::RxMeta* meta = nullptr) + : Event(EventType::ChatNewMessage), channel(ch), msg_id(id) + { + if (msg_text) + { + std::strncpy(text, msg_text, sizeof(text) - 1); + text[sizeof(text) - 1] = '\0'; + } + else + { + text[0] = '\0'; + } + if (meta) + { + rx_meta = *meta; + } + } +}; + +struct ChatSendResultEvent : public Event +{ + uint32_t msg_id; + bool success; + + ChatSendResultEvent(uint32_t id, bool ok) + : Event(EventType::ChatSendResult), msg_id(id), success(ok) {} +}; + +struct ChatUnreadChangedEvent : public Event +{ + uint8_t channel; + int unread_count; + + ChatUnreadChangedEvent(uint8_t ch, int count) + : Event(EventType::ChatUnreadChanged), channel(ch), unread_count(count) {} +}; + +struct NodeInfoUpdateEvent : public Event +{ + uint32_t node_id; + char short_name[10]; + char long_name[32]; + float snr; + float rssi; + uint32_t event_timestamp; + uint8_t protocol; + uint8_t role; + uint8_t hops_away; + uint8_t hw_model; + uint8_t channel; + bool has_macaddr; + uint8_t macaddr[6]; + bool via_mqtt; + bool is_ignored; + bool has_public_key; + bool key_manually_verified; + bool has_device_metrics; + chat::contacts::NodeDeviceMetrics device_metrics; + + NodeInfoUpdateEvent(uint32_t id, const char* sname, const char* lname, float s, float rssi_val, + uint32_t ts, uint8_t proto, uint8_t r, uint8_t hops = 0xFF, uint8_t hw = 0, + uint8_t ch = 0xFF, bool has_mac = false, const uint8_t* mac = nullptr, + bool via_mqtt_value = false, bool is_ignored_value = false, + bool has_pubkey = false, bool key_verified = false, + bool has_metrics = false, + const chat::contacts::NodeDeviceMetrics* metrics = nullptr) + : Event(EventType::NodeInfoUpdate), + node_id(id), + short_name{}, + long_name{}, + snr(s), + rssi(rssi_val), + event_timestamp(ts), + protocol(proto), + role(r), + hops_away(hops), + hw_model(hw), + channel(ch), + has_macaddr(has_mac), + macaddr{}, + via_mqtt(via_mqtt_value), + is_ignored(is_ignored_value), + has_public_key(has_pubkey), + key_manually_verified(key_verified), + has_device_metrics(has_metrics), + device_metrics{} + { + if (sname) + { + std::strncpy(short_name, sname, sizeof(short_name) - 1); + short_name[sizeof(short_name) - 1] = '\0'; + } + if (lname) + { + std::strncpy(long_name, lname, sizeof(long_name) - 1); + long_name[sizeof(long_name) - 1] = '\0'; + } + if (has_macaddr && mac) + { + std::memcpy(macaddr, mac, sizeof(macaddr)); + } + if (has_device_metrics && metrics) + { + device_metrics = *metrics; + } + } +}; + +struct NodeProtocolUpdateEvent : public Event +{ + uint32_t node_id; + uint32_t event_timestamp; + uint8_t protocol; + + NodeProtocolUpdateEvent(uint32_t id, uint32_t ts, uint8_t proto) + : Event(EventType::NodeProtocolUpdate), node_id(id), event_timestamp(ts), protocol(proto) {} +}; + +struct NodePositionUpdateEvent : public Event +{ + uint32_t node_id; + int32_t latitude_i; + int32_t longitude_i; + bool has_altitude; + int32_t altitude; + uint32_t event_timestamp; + uint32_t precision_bits; + uint32_t pdop; + uint32_t hdop; + uint32_t vdop; + uint32_t gps_accuracy_mm; + + NodePositionUpdateEvent(uint32_t id, + int32_t lat_i, + int32_t lon_i, + bool has_alt, + int32_t alt_m, + uint32_t ts, + uint32_t prec_bits, + uint32_t p, + uint32_t h, + uint32_t v, + uint32_t gps_mm) + : Event(EventType::NodePositionUpdate), + node_id(id), + latitude_i(lat_i), + longitude_i(lon_i), + has_altitude(has_alt), + altitude(alt_m), + event_timestamp(ts), + precision_bits(prec_bits), + pdop(p), + hdop(h), + vdop(v), + gps_accuracy_mm(gps_mm) + { + } +}; + +struct AppDataEvent : public Event +{ + uint32_t portnum; + uint32_t from; + uint32_t to; + uint32_t packet_id; + uint8_t channel; + uint8_t channel_hash; + bool want_response; + std::vector payload; + chat::RxMeta rx_meta; + + AppDataEvent(uint32_t port, + uint32_t src, + uint32_t dst, + uint32_t pkt, + uint8_t ch, + uint8_t hash, + bool want, + const std::vector& data, + const chat::RxMeta* meta = nullptr) + : Event(EventType::AppData), + portnum(port), + from(src), + to(dst), + packet_id(pkt), + channel(ch), + channel_hash(hash), + want_response(want), + payload(data) + { + if (meta) + { + rx_meta = *meta; + } + } +}; + +struct KeyVerificationNumberRequestEvent : public Event +{ + uint32_t node_id; + uint64_t nonce; + + KeyVerificationNumberRequestEvent(uint32_t id, uint64_t n) + : Event(EventType::KeyVerificationNumberRequest), node_id(id), nonce(n) {} +}; + +struct KeyVerificationNumberInformEvent : public Event +{ + uint32_t node_id; + uint64_t nonce; + uint32_t security_number; + + KeyVerificationNumberInformEvent(uint32_t id, uint64_t n, uint32_t number) + : Event(EventType::KeyVerificationNumberInform), node_id(id), nonce(n), security_number(number) {} +}; + +struct KeyVerificationFinalEvent : public Event +{ + uint32_t node_id; + uint64_t nonce; + bool is_sender; + char verification_code[12]; + + KeyVerificationFinalEvent(uint32_t id, uint64_t n, bool sender, const char* code) + : Event(EventType::KeyVerificationFinal), node_id(id), nonce(n), is_sender(sender), verification_code{} + { + if (code) + { + std::strncpy(verification_code, code, sizeof(verification_code) - 1); + verification_code[sizeof(verification_code) - 1] = '\0'; + } + } +}; + +struct TeamKickEvent : public Event +{ + team::TeamKickEvent data; + explicit TeamKickEvent(const team::TeamKickEvent& evt) : Event(EventType::TeamKick), data(evt) {} +}; + +struct TeamTransferLeaderEvent : public Event +{ + team::TeamTransferLeaderEvent data; + explicit TeamTransferLeaderEvent(const team::TeamTransferLeaderEvent& evt) + : Event(EventType::TeamTransferLeader), data(evt) {} +}; + +struct TeamKeyDistEvent : public Event +{ + team::TeamKeyDistEvent data; + explicit TeamKeyDistEvent(const team::TeamKeyDistEvent& evt) : Event(EventType::TeamKeyDist), data(evt) {} +}; + +struct TeamStatusEvent : public Event +{ + team::TeamStatusEvent data; + explicit TeamStatusEvent(const team::TeamStatusEvent& evt) : Event(EventType::TeamStatus), data(evt) {} +}; + +struct TeamPositionEvent : public Event +{ + team::TeamPositionEvent data; + explicit TeamPositionEvent(const team::TeamPositionEvent& evt) : Event(EventType::TeamPosition), data(evt) {} +}; + +struct TeamWaypointEvent : public Event +{ + team::TeamWaypointEvent data; + explicit TeamWaypointEvent(const team::TeamWaypointEvent& evt) : Event(EventType::TeamWaypoint), data(evt) {} +}; + +struct TeamTrackEvent : public Event +{ + team::TeamTrackEvent data; + explicit TeamTrackEvent(const team::TeamTrackEvent& evt) : Event(EventType::TeamTrack), data(evt) {} +}; + +struct TeamChatEvent : public Event +{ + team::TeamChatEvent data; + explicit TeamChatEvent(const team::TeamChatEvent& evt) : Event(EventType::TeamChat), data(evt) {} +}; + +struct TeamPairingEvent : public Event +{ + team::TeamPairingEvent data; + explicit TeamPairingEvent(const team::TeamPairingEvent& evt) : Event(EventType::TeamPairing), data(evt) {} +}; + +struct TeamErrorEvent : public Event +{ + team::TeamErrorEvent data; + explicit TeamErrorEvent(const team::TeamErrorEvent& evt) : Event(EventType::TeamError), data(evt) {} +}; + +struct InputEvent +{ + enum InputType + { + KeyPress, + KeyRelease, + RotaryTurn, + RotaryPress, + RotaryLongPress + }; + + InputType input_type; + uint32_t value; + uint32_t timestamp; + + InputEvent(InputType it, uint32_t v) + : input_type(it), value(v), timestamp(sys::millis_now()) {} +}; + +class EventBus +{ + public: + static bool init(size_t queue_size = 32) + { + EventBus& bus = instance(); + std::lock_guard lock(bus.mutex_); + bus.queue_size_ = queue_size > 0 ? queue_size : 1; + while (bus.queue_.size() > bus.queue_size_) + { + delete bus.queue_.front(); + bus.queue_.pop_front(); + } + return true; + } + + static bool publish(Event* event, uint32_t timeout_ms = 0) + { + (void)timeout_ms; + if (event == nullptr) + { + return false; + } + + EventBus& bus = instance(); + std::lock_guard lock(bus.mutex_); + if (bus.queue_.size() >= bus.queue_size_) + { + delete event; + return false; + } + bus.queue_.push_back(event); + return true; + } + + static bool subscribe(Event** event_out, uint32_t timeout_ms = 0) + { + (void)timeout_ms; + if (event_out == nullptr) + { + return false; + } + + EventBus& bus = instance(); + std::lock_guard lock(bus.mutex_); + if (bus.queue_.empty()) + { + *event_out = nullptr; + return false; + } + + *event_out = bus.queue_.front(); + bus.queue_.pop_front(); + return true; + } + + static size_t pendingCount() + { + EventBus& bus = instance(); + std::lock_guard lock(bus.mutex_); + return bus.queue_.size(); + } + + private: + static EventBus& instance() + { + static EventBus bus; + return bus; + } + + std::mutex mutex_{}; + std::deque queue_{}; + size_t queue_size_ = 32; +}; + +} // namespace sys diff --git a/platform/linux/common/include/ui/shared_ui_shell.h b/platform/linux/common/include/ui/shared_ui_shell.h new file mode 100644 index 00000000..2f28a0a2 --- /dev/null +++ b/platform/linux/common/include/ui/shared_ui_shell.h @@ -0,0 +1,32 @@ +#pragma once + +namespace trailmate::cardputer_zero::linux_ui +{ + +class SharedUiShellStartup +{ + public: + SharedUiShellStartup() = default; + + bool begin(); + bool tick(); + + [[nodiscard]] bool ready() const noexcept; + + private: + enum class Phase + { + Idle, + BootVisible, + MenuBuilt, + ThemeReady, + Finalized, + }; + + [[nodiscard]] bool runPhase(Phase next_phase, const char* log_line); + + Phase phase_{Phase::Idle}; + unsigned int started_at_ms_{0}; +}; + +} // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/include/ui/shell_ui_runner.h b/platform/linux/common/include/ui/shell_ui_runner.h new file mode 100644 index 00000000..7c8b46f7 --- /dev/null +++ b/platform/linux/common/include/ui/shell_ui_runner.h @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "app/linux_app_facade.h" +#include "core/canvas.h" +#include "platform/surface_presenter.h" + +#include "lvgl.h" + +namespace trailmate::cardputer_zero +{ +namespace app +{ +struct InputEvent; +} + +namespace linux_ui +{ + +// --------------------------------------------------------------------------- +// ShellSession +// +// Owns the app facade, shared UI startup sequence, input event queue, and +// per-frame tick logic. Does NOT own LVGL lifecycle (lv_init/lv_deinit), +// display, or framebuffer. That responsibility lives in the *LvglHost +// classes. +// +// Both the simulator and the real device shell share this session object. +// --------------------------------------------------------------------------- + +class ShellSession +{ + public: + ShellSession(); + ~ShellSession(); + + ShellSession(const ShellSession&) = delete; + ShellSession& operator=(const ShellSession&) = delete; + + /// Initialize the app facade and begin the shared UI startup sequence. + /// Must be called after LVGL has been initialized by the host. + bool begin(); + + /// Drive one frame worth of app-layer work: update core services, tick + /// the event runtime, dispatch pending events. + void tick(); + + /// Push simulated or evdev input events into the session queue. + void enqueueInputs(const std::vector& events); + + /// Returns true once the startup sequence has finalized. + bool ready() const noexcept; + + /// Access the underlying app facade. + MinimalLinuxAppFacade& facade() noexcept { return app_facade_; } + const MinimalLinuxAppFacade& facade() const noexcept + { + return app_facade_; + } + + private: + friend class CanvasLvglHost; + + struct QueuedKeyEvent + { + std::uint32_t key{}; + lv_indev_state_t state{}; + }; + + /// Called by the LVGL host's read-input callback to dequeue one key event. + /// Returns false when the queue is empty. + bool dequeueKeyEvent(std::uint32_t* key, lv_indev_state_t* state); + + /// Peek: returns true if at least one event is enqueued (used to set + /// continue_reading without consuming the next event). + bool hasPendingKeyEvent() const noexcept; + + struct Impl; + std::unique_ptr impl_; + MinimalLinuxAppFacade app_facade_; + bool initialized_{false}; +}; + +// --------------------------------------------------------------------------- +// CanvasLvglHost +// +// Owns the LVGL lifecycle (lv_init / lv_deinit), display, RGB565 buffer, +// keypad input device, and the framebuffer-to-Canvas copy step. +// +// Suitable for the SDL simulator and the custom Linux framebuffer shell. +// --------------------------------------------------------------------------- + +class CanvasLvglHost +{ + public: + explicit CanvasLvglHost(ShellSession& shell); + ~CanvasLvglHost(); + + CanvasLvglHost(const CanvasLvglHost&) = delete; + CanvasLvglHost& operator=(const CanvasLvglHost&) = delete; + + /// Drive one frame: tick the shell session, run lv_timer_handler(), and + /// (if dirty) copy the LVGL framebuffer to the output canvas. + void tick(); + + /// Read-only access to the output canvas for the surface presenter. + const core::Canvas& canvas() const noexcept; + + /// LVGL callbacks — exact typed signatures matching LVGL's typedefs. + /// Implemented in the .cpp, where lvgl.h provides the full definitions. + static void flushCb(lv_display_t* display, const lv_area_t* area, + uint8_t* px_map); + static void readInputCb(lv_indev_t* indev, lv_indev_data_t* data); + + private: + void copyFrameBufferToCanvas(); + + ShellSession& shell_; + lv_display_t* display_{}; + lv_indev_t* keypad_{}; + core::Canvas canvas_; + std::vector frame_buffer_{}; + bool dirty_{true}; +}; + +// --------------------------------------------------------------------------- +// NativeLvglHost +// +// Thin host for the M5Stack Linux SDK path where LVGL display and input are +// already owned by the SDK. Only drives the shell session tick each frame. +// +// Usage in SDK main loop: +// +// lv_init(); +// initLinuxDisplay(); // SDK-owned fbdev / evdev +// +// ShellSession shell; +// shell.begin(); +// +// NativeLvglHost host{shell}; +// while (true) { +// host.tick(); +// lv_timer_handler(); +// usleep(1000); +// } +// --------------------------------------------------------------------------- + +class NativeLvglHost +{ + public: + explicit NativeLvglHost(ShellSession& shell) : shell_(shell) {} + + /// Drive the shell session tick. Caller is responsible for + /// lv_timer_handler() and the main-loop sleep. + void tick() { shell_.tick(); } + + private: + ShellSession& shell_; +}; + +// --------------------------------------------------------------------------- +// Convenience – desktop simulator / custom-fbdev main loop +// --------------------------------------------------------------------------- + +/// Create a ShellSession + CanvasLvglHost and run the classic frame-paced +/// main loop driven by a SurfacePresenter. Backward-compatible entrypoint. +void runShellUi( + platform::SurfacePresenter& presenter, + std::chrono::milliseconds auto_exit_after = std::chrono::milliseconds::zero()); + +} // namespace linux_ui +} // namespace trailmate::cardputer_zero diff --git a/platform/linux/common/include/ui/ui_common.h b/platform/linux/common/include/ui/ui_common.h new file mode 100644 index 00000000..a06c60f4 --- /dev/null +++ b/platform/linux/common/include/ui/ui_common.h @@ -0,0 +1,26 @@ +/** + * @file ui_common.h + * @brief Linux-safe UI helpers used by shared UI code. + */ + +#ifndef UI_COMMON_H +#define UI_COMMON_H + +#include + +#include "lvgl.h" + +namespace ui::widgets +{ +struct TopBar; +} + +void ui_update_top_bar_battery(ui::widgets::TopBar& bar); + +int ui_get_timezone_offset_min(); +void ui_set_timezone_offset_min(int offset_min); +time_t ui_apply_timezone_offset(time_t utc_seconds); + +bool ui_take_screenshot_to_sd(); + +#endif // UI_COMMON_H diff --git a/platform/linux/common/include/ui/widgets/map/map_tiles.h b/platform/linux/common/include/ui/widgets/map/map_tiles.h new file mode 100644 index 00000000..7de4bdce --- /dev/null +++ b/platform/linux/common/include/ui/widgets/map/map_tiles.h @@ -0,0 +1,106 @@ +/** + * @file map_tiles.h + * @brief Linux map tile management and geometry helpers for shared map pages. + */ + +#pragma once + +#include "lvgl.h" + +#include +#include +#include + +#define TILE_SIZE 256 +#define TILE_CACHE_LIMIT 12 +#define TILE_RECORD_LIMIT 48 + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +struct MapAnchor +{ + int z = 0; + int32_t gps_tile_x = 0; + int32_t gps_tile_y = 0; + int32_t gps_tile_pixel_x = 0; + int32_t gps_tile_pixel_y = 0; + int32_t gps_offset_x = 0; + int32_t gps_offset_y = 0; + int32_t gps_global_pixel_x = 0; + int32_t gps_global_pixel_y = 0; + int gps_tile_screen_x = 0; + int gps_tile_screen_y = 0; + double n = 0.0; + bool valid = false; +}; + +struct DecodedTileCache +{ + int32_t x = -1; + int32_t y = -1; + int32_t z = -1; + uint8_t map_source = 0; + lv_image_dsc_t* img_dsc = nullptr; + uint32_t last_used_ms = 0; + bool in_use = false; +}; + +struct MapTile +{ + int32_t x = 0; + int32_t y = 0; + int z = 0; + uint8_t map_source = 0; + lv_obj_t* img_obj = nullptr; + lv_obj_t* contour_obj = nullptr; + bool visible = false; + bool ever_visible = false; + uint32_t last_used_ms = 0; + uint32_t obj_evicted_ms = 0; + bool record_evicted = false; + int priority = 0; + bool has_png_file = false; + bool base_missing = false; + bool contour_checked = false; + bool contour_loaded = false; + DecodedTileCache* cached_img = nullptr; +}; + +struct TileContext +{ + lv_obj_t* map_container = nullptr; + MapAnchor* anchor = nullptr; + std::vector* tiles = nullptr; + bool* has_map_data = nullptr; + bool* has_visible_map_data = nullptr; +}; + +void normalize_tile(int z, int& x, int& y); +void latLngToTile(double lat, double lng, int zoom, int& tile_x, int& tile_y); +void tileToLatLng(int tile_x, int tile_y, int zoom, double& lat, double& lng); +void get_screen_center_lat_lng(const TileContext& ctx, double& lat, double& lng); +void tileToPixel(int tile_x, int tile_y, int zoom, int& pixel_x, int& pixel_y); +bool gps_screen_pos(const TileContext& ctx, double lat, double lng, int& sx, int& sy); +bool tile_screen_pos_xyz(const TileContext& ctx, int x, int y, int z, int& sx, int& sy); +bool tile_in_rect(int sx, int sy, int w, int h, int margin); +void update_map_anchor(TileContext& ctx, double lat, double lng, int zoom, int pan_x, int pan_y, bool has_fix); +void calculate_required_tiles(TileContext& ctx, double lat, double lng, int zoom, int pan_x, int pan_y, bool has_fix); +void tile_loader_step(TileContext& ctx); +uint8_t sanitize_map_source(uint8_t map_source); +const char* map_source_label(uint8_t map_source); +bool build_base_tile_path(int z, int x, int y, uint8_t map_source, char* out_path, size_t out_size); +bool build_contour_tile_path(int z, int x, int y, char* out_path, size_t out_size); +bool base_tile_available(int z, int x, int y, uint8_t map_source); +bool map_source_directory_available(uint8_t map_source); +bool contour_directory_available(); +bool take_missing_tile_notice(uint8_t* out_map_source); +void set_map_render_options(uint8_t map_source, bool contour_enabled); +void init_tile_context(TileContext& ctx, + lv_obj_t* map_container, + MapAnchor* anchor, + std::vector* tiles, + bool* has_map_data, + bool* has_visible_map_data); +void cleanup_tiles(TileContext& ctx); diff --git a/platform/linux/common/src/app/demo_app.cpp b/platform/linux/common/src/app/demo_app.cpp new file mode 100644 index 00000000..bc92322d --- /dev/null +++ b/platform/linux/common/src/app/demo_app.cpp @@ -0,0 +1,195 @@ +#include "app/demo_app.h" + +#include +#include +#include + +#include "core/bitmap_font.h" +#include "core/display_profile.h" + +namespace trailmate::cardputer_zero::app +{ +namespace +{ + +using core::rgba; + +constexpr auto kBackground = rgba(8, 12, 18); +constexpr auto kPanel = rgba(18, 27, 39); +constexpr auto kHeader = rgba(18, 71, 109); +constexpr auto kBorder = rgba(83, 192, 243); +constexpr auto kTitle = rgba(237, 247, 252); +constexpr auto kText = rgba(236, 242, 247); +constexpr auto kMuted = rgba(133, 153, 171); +constexpr auto kAccent = rgba(255, 177, 64); +constexpr auto kInputFill = rgba(7, 10, 15); +constexpr auto kInputBorder = rgba(79, 97, 115); +constexpr auto kGood = rgba(150, 220, 132); +constexpr auto kWarn = rgba(255, 214, 93); + +int centeredX(std::string_view text, int scale) noexcept +{ + return (core::kDisplayWidth - core::bitmap_font::measureText(text, scale)) / 2; +} + +std::string clippedTail(const std::string& value, std::size_t max_length) +{ + if (value.size() <= max_length) + { + return value; + } + + return value.substr(value.size() - max_length); +} + +} // namespace + +DemoApp::DemoApp() + : started_at_(std::chrono::steady_clock::now()) +{ +} + +void DemoApp::handleInput(const InputEvent& event) noexcept +{ + if (!event.label.empty()) + { + last_key_label_ = event.label; + } + + switch (event.key) + { + case InputKey::Character: + if (event.text != '\0') + { + if (typed_text_.size() >= 26) + { + typed_text_.erase(0, 1); + } + typed_text_.push_back(static_cast(std::toupper(static_cast(event.text)))); + status_text_ = "TEXT INPUT CAPTURED"; + } + break; + case InputKey::Backspace: + if (!typed_text_.empty()) + { + typed_text_.pop_back(); + } + status_text_ = "DELETE LAST CHARACTER"; + break; + case InputKey::Enter: + status_text_ = "ENTER PRESSED"; + break; + case InputKey::Tab: + if (typed_text_.size() < 25) + { + typed_text_ += ' '; + } + status_text_ = "TAB INSERTED SPACE"; + break; + case InputKey::Home: + typed_text_.clear(); + status_text_ = "HOME CLEARED INPUT"; + break; + case InputKey::Next: + status_text_ = "NEXT REQUESTED"; + break; + case InputKey::Power: + status_text_ = "POWER SWITCH TOGGLED"; + break; + case InputKey::Fn: + status_text_ = "FN MODIFIER PRESSED"; + break; + case InputKey::Ctrl: + status_text_ = "CTRL MODIFIER PRESSED"; + break; + case InputKey::Alt: + status_text_ = "ALT MODIFIER PRESSED"; + break; + case InputKey::Shift: + status_text_ = "SHIFT MODIFIER PRESSED"; + break; + case InputKey::Left: + status_text_ = "LEFT NAVIGATION"; + break; + case InputKey::Right: + status_text_ = "RIGHT NAVIGATION"; + break; + case InputKey::Up: + status_text_ = "UP NAVIGATION"; + break; + case InputKey::Down: + status_text_ = "DOWN NAVIGATION"; + break; + case InputKey::Unknown: + status_text_ = "UNKNOWN INPUT"; + break; + } +} + +void DemoApp::render(core::Canvas& canvas) const noexcept +{ + canvas.clear(kBackground); + canvas.fillRect(8, 8, core::kDisplayWidth - 16, core::kDisplayHeight - 16, kPanel); + canvas.strokeRect(8, 8, core::kDisplayWidth - 16, core::kDisplayHeight - 16, kBorder, 2); + canvas.fillRect(18, 18, core::kDisplayWidth - 36, 22, kHeader); + + core::bitmap_font::drawText(canvas, "TRAIL MATE", centeredX("TRAIL MATE", 2), 23, kTitle, 2); + core::bitmap_font::drawText(canvas, "CARDPUTER ZERO LINUX BASELINE", centeredX("CARDPUTER ZERO LINUX BASELINE", 1), 52, kMuted, 1); + + canvas.fillRect(24, 66, core::kDisplayWidth - 48, 28, kInputFill); + canvas.strokeRect(24, 66, core::kDisplayWidth - 48, 28, kInputBorder, 1); + core::bitmap_font::drawText(canvas, "INPUT", 34, 72, kMuted, 1); + core::bitmap_font::drawText( + canvas, + hasTypedText() ? visibleInput() : std::string("TYPE OR CLICK THE SHELL"), + 84, + 72, + hasTypedText() ? kText : kMuted, + 1); + + canvas.fillRect(24, 100, core::kDisplayWidth - 48, 20, kInputFill); + canvas.strokeRect(24, 100, core::kDisplayWidth - 48, 20, kInputBorder, 1); + core::bitmap_font::drawText(canvas, "LAST", 34, 106, kMuted, 1); + core::bitmap_font::drawText(canvas, visibleLastKey(), 84, 106, kGood, 1); + + canvas.fillRect(24, 126, core::kDisplayWidth - 48, 20, kInputFill); + canvas.strokeRect(24, 126, core::kDisplayWidth - 48, 20, kInputBorder, 1); + core::bitmap_font::drawText(canvas, "STATUS", 34, 132, kMuted, 1); + core::bitmap_font::drawText(canvas, visibleStatus(), 84, 132, kText, 1); + + core::bitmap_font::drawText(canvas, "SIM WIN WSL DOCKER PI OS", 31, 152, kWarn, 1); + + if (blinkOn()) + { + canvas.fillRect(265, 75, 12, 4, kAccent); + } +} + +bool DemoApp::blinkOn() const noexcept +{ + const auto elapsed = std::chrono::steady_clock::now() - started_at_; + const auto ticks = std::chrono::duration_cast(elapsed).count() / 450; + return (ticks % 2) == 0; +} + +bool DemoApp::hasTypedText() const noexcept +{ + return !typed_text_.empty(); +} + +std::string DemoApp::visibleInput() const +{ + return clippedTail(typed_text_, 24); +} + +std::string DemoApp::visibleStatus() const +{ + return clippedTail(status_text_, 24); +} + +std::string DemoApp::visibleLastKey() const +{ + return clippedTail(last_key_label_, 10); +} + +} // namespace trailmate::cardputer_zero::app diff --git a/platform/linux/common/src/app/demo_app.h b/platform/linux/common/src/app/demo_app.h new file mode 100644 index 00000000..26075087 --- /dev/null +++ b/platform/linux/common/src/app/demo_app.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +#include "app/input_event.h" +#include "core/canvas.h" + +namespace trailmate::cardputer_zero::app +{ + +class DemoApp +{ + public: + DemoApp(); + + void handleInput(const InputEvent& event) noexcept; + void render(core::Canvas& canvas) const noexcept; + + private: + [[nodiscard]] bool blinkOn() const noexcept; + [[nodiscard]] bool hasTypedText() const noexcept; + [[nodiscard]] std::string visibleInput() const; + [[nodiscard]] std::string visibleStatus() const; + [[nodiscard]] std::string visibleLastKey() const; + + std::chrono::steady_clock::time_point started_at_{}; + std::string typed_text_{}; + std::string last_key_label_{"NONE"}; + std::string status_text_{"SIM FIRST LINUX BRINGUP"}; +}; + +} // namespace trailmate::cardputer_zero::app diff --git a/platform/linux/common/src/app/demo_app_runner.cpp b/platform/linux/common/src/app/demo_app_runner.cpp new file mode 100644 index 00000000..b95c849d --- /dev/null +++ b/platform/linux/common/src/app/demo_app_runner.cpp @@ -0,0 +1,47 @@ +#include "app/demo_app_runner.h" + +#include +#include + +#include "app/demo_app.h" +#include "core/display_profile.h" + +namespace trailmate::cardputer_zero::app +{ + +void runDemoApp(platform::SurfacePresenter& presenter, std::chrono::milliseconds auto_exit_after) +{ + DemoApp app{}; + core::Canvas canvas{core::kDisplayWidth, core::kDisplayHeight}; + + using clock = std::chrono::steady_clock; + constexpr auto kFrameTime = std::chrono::milliseconds(33); + auto next_frame = clock::now(); + const auto started_at = next_frame; + + while (presenter.pump()) + { + for (const auto& event : presenter.drainInput()) + { + app.handleInput(event); + } + + if (auto_exit_after > std::chrono::milliseconds::zero() && (clock::now() - started_at) >= auto_exit_after) + { + break; + } + + app.render(canvas); + presenter.present(canvas); + + next_frame += kFrameTime; + std::this_thread::sleep_until(next_frame); + + if (clock::now() > next_frame + std::chrono::milliseconds(250)) + { + next_frame = clock::now(); + } + } +} + +} // namespace trailmate::cardputer_zero::app diff --git a/platform/linux/common/src/app/demo_app_runner.h b/platform/linux/common/src/app/demo_app_runner.h new file mode 100644 index 00000000..c47e0309 --- /dev/null +++ b/platform/linux/common/src/app/demo_app_runner.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include "platform/surface_presenter.h" + +namespace trailmate::cardputer_zero::app +{ + +void runDemoApp( + platform::SurfacePresenter& presenter, + std::chrono::milliseconds auto_exit_after = std::chrono::milliseconds::zero()); + +} // namespace trailmate::cardputer_zero::app diff --git a/platform/linux/common/src/app/input_event.h b/platform/linux/common/src/app/input_event.h new file mode 100644 index 00000000..a87365f7 --- /dev/null +++ b/platform/linux/common/src/app/input_event.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +namespace trailmate::cardputer_zero::app +{ + +enum class InputKey +{ + Unknown = 0, + Character, + Backspace, + Enter, + Tab, + Home, + Next, + Power, + Fn, + Ctrl, + Alt, + Shift, + Left, + Right, + Up, + Down, +}; + +struct InputEvent +{ + InputKey key{InputKey::Unknown}; + std::string label{}; + char text{'\0'}; +}; + +[[nodiscard]] inline InputEvent makeCharacterInput(char ch, std::string label = {}) +{ + return InputEvent{InputKey::Character, std::move(label), ch}; +} + +} // namespace trailmate::cardputer_zero::app diff --git a/platform/linux/common/src/app/linux_app_facade.cpp b/platform/linux/common/src/app/linux_app_facade.cpp new file mode 100644 index 00000000..aa8f8363 --- /dev/null +++ b/platform/linux/common/src/app/linux_app_facade.cpp @@ -0,0 +1,1902 @@ +#include "app/linux_app_facade.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/app_facade_access.h" +#include "chat/domain/chat_model.h" +#include "chat/infra/contact_store_core.h" +#include "chat/infra/node_store_core.h" +#include "chat/infra/store/ram_store.h" +#include "chat/ports/i_contact_blob_store.h" +#include "chat/ports/i_node_blob_store.h" +#include "chat/usecase/chat_service.h" +#include "chat/usecase/contact_service.h" +#include "platform/ui/device_runtime.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/settings_store.h" +#include "platform/ui/team_ui_store_runtime.h" +#include "sys/clock.h" +#include "sys/event_bus.h" +#include "team/ports/i_team_crypto.h" +#include "team/ports/i_team_event_sink.h" +#include "team/ports/i_team_pairing_event_sink.h" +#include "team/ports/i_team_pairing_transport.h" +#include "team/ports/i_team_runtime.h" +#include "team/ports/i_team_track_source.h" +#include "team/protocol/team_pairing_wire.h" +#include "team/protocol/team_position.h" +#include "team/usecase/team_controller.h" +#include "team/usecase/team_pairing_coordinator.h" +#include "team/usecase/team_pairing_service.h" +#include "team/usecase/team_service.h" +#include "team/usecase/team_track_sampler.h" +#include "ui/chat_ui_runtime.h" + +namespace trailmate::cardputer_zero::linux_ui +{ +namespace +{ + +TeamUiEventDispatcher s_team_ui_event_dispatcher = nullptr; + +constexpr const char* kConfigNamespace = "linux_app_facade"; +constexpr const char* kConfigBlobKey = "app_config_v1"; +constexpr uint32_t kConfigBlobMagic = 0x544D4346U; // TMCF +constexpr uint32_t kConfigBlobVersion = 1U; + +constexpr const char* kNodeStoreNamespace = "linux_contact_nodes"; +constexpr const char* kNodeStoreKey = "nodes_v1"; +constexpr const char* kContactStoreNamespace = "linux_contact_names"; +constexpr const char* kContactStoreKey = "contacts_v1"; + +constexpr ::chat::NodeId kDemoAlphaNodeId = 0x435A1001U; +constexpr ::chat::NodeId kDemoBravoNodeId = 0x435A1002U; +constexpr ::chat::NodeId kDemoScoutNodeId = 0x435A1003U; +constexpr ::chat::NodeId kDemoNearbyNodeId = 0x435A1004U; +constexpr ::chat::NodeId kDemoBroadcastNodeId = kDemoAlphaNodeId; +constexpr ::chat::NodeId kSyntheticPairLeaderNodeId = kDemoAlphaNodeId; +constexpr ::chat::NodeId kSyntheticPairMemberNodeId = kDemoBravoNodeId; + +constexpr std::array kSyntheticLeaderMac{{0x43, 0x5A, 0x20, 0x01, 0x00, 0x01}}; +constexpr std::array kSyntheticMemberMac{{0x43, 0x5A, 0x20, 0x01, 0x00, 0x02}}; + +constexpr uint32_t kSyntheticPairKeyId = 1U; +constexpr uint32_t kSyntheticPairNonce = 0x5A431122U; +constexpr uint32_t kSyntheticPairDelayMs = 220U; +constexpr uint32_t kAutoReplyDelayMs = 700U; + +team::TeamId makeSyntheticPairTeamId() +{ + team::TeamId team_id{}; + const std::array bytes{{'C', 'Z', 'T', 'E', 'A', 'M', '0', '1'}}; + for (size_t i = 0; i < team_id.size() && i < bytes.size(); ++i) + { + team_id[i] = bytes[i]; + } + return team_id; +} + +std::array makeSyntheticPairPsk() +{ + std::array psk{}; + for (size_t i = 0; i < psk.size(); ++i) + { + psk[i] = static_cast(0x30U + (i * 7U + 3U) % 0x4FU); + } + return psk; +} + +const char* syntheticPairTeamName() +{ + return "Field Team"; +} + +std::string makeAutoReplyText(::chat::NodeId peer, const std::string& text) +{ + const std::string trimmed = text.substr(0, std::min(text.size(), 28U)); + switch (peer) + { + case kDemoAlphaNodeId: + return "Alice copied: " + trimmed; + case kDemoBravoNodeId: + return "Bravo link OK: " + trimmed; + case kDemoScoutNodeId: + return "Scout received your ping."; + default: + return "Peer ack: " + trimmed; + } +} + +struct DemoPeerSeed +{ + ::chat::NodeId node_id = 0; + const char* short_name = nullptr; + const char* long_name = nullptr; + const char* nickname = nullptr; + bool ignored = false; + float snr = 0.0f; + float rssi = 0.0f; + int32_t lat_e7 = 0; + int32_t lon_e7 = 0; +}; + +std::array demoPeerSeeds() +{ + return {{ + {kDemoAlphaNodeId, "ALFA", "Alice Local", "Alice", false, 11.2f, -72.0f, 311214000, 1214737000}, + {kDemoBravoNodeId, "BRAV", "Bravo Pager", "Bravo", false, 8.6f, -79.0f, 311218500, 1214749000}, + {kDemoScoutNodeId, "SCOT", "Scout Relay", nullptr, true, 4.1f, -94.0f, 311227000, 1214762000}, + {kDemoNearbyNodeId, "NBY1", "Nearby Relay", nullptr, false, 6.8f, -88.0f, 311231200, 1214756000}, + }}; +} + +struct PersistedConfigBlob +{ + uint32_t magic = kConfigBlobMagic; + uint32_t version = kConfigBlobVersion; + ::app::AppConfig config{}; +}; + +static_assert(std::is_trivially_copyable_v<::app::AppConfig>, + "MinimalLinuxAppFacade persists AppConfig as an opaque blob."); + +void copy_bounded(char* out, std::size_t out_len, const char* text) +{ + if (out == nullptr || out_len == 0) + { + return; + } + + if (text == nullptr) + { + out[0] = '\0'; + return; + } + + std::strncpy(out, text, out_len - 1U); + out[out_len - 1U] = '\0'; +} + +bool is_supported_protocol(::chat::MeshProtocol protocol) +{ + switch (protocol) + { + case ::chat::MeshProtocol::Meshtastic: + case ::chat::MeshProtocol::MeshCore: + case ::chat::MeshProtocol::RNode: + case ::chat::MeshProtocol::LXMF: + return true; + default: + return false; + } +} + +class LinuxNodeBlobStore final : public ::chat::contacts::INodeBlobStore +{ + public: + bool loadBlob(std::vector& out) override + { + return ::platform::ui::settings_store::get_blob(kNodeStoreNamespace, kNodeStoreKey, out); + } + + bool saveBlob(const uint8_t* data, size_t len) override + { + return ::platform::ui::settings_store::put_blob(kNodeStoreNamespace, kNodeStoreKey, data, len); + } + + void clearBlob() override + { + ::platform::ui::settings_store::clear_namespace(kNodeStoreNamespace); + } +}; + +class LinuxContactBlobStore final : public ::chat::IContactBlobStore +{ + public: + bool loadBlob(std::vector& out) override + { + return ::platform::ui::settings_store::get_blob(kContactStoreNamespace, kContactStoreKey, out); + } + + bool saveBlob(const uint8_t* data, size_t len) override + { + return ::platform::ui::settings_store::put_blob(kContactStoreNamespace, kContactStoreKey, data, len); + } + + void clear() + { + ::platform::ui::settings_store::clear_namespace(kContactStoreNamespace); + } +}; + +class LinuxLoopbackMeshAdapter final : public ::chat::IMeshAdapter +{ + public: + explicit LinuxLoopbackMeshAdapter(::chat::NodeId self_node_id) : self_node_id_(self_node_id) {} + + ::chat::MeshCapabilities getCapabilities() const override + { + return { + .supports_unicast_text = true, + .supports_unicast_appdata = true, + .supports_broadcast_appdata = true, + .supports_appdata_ack = true, + .provides_appdata_sender = true, + .supports_node_info = true, + .supports_pki = true, + .supports_discovery_actions = true, + }; + } + + bool sendText(::chat::ChannelId channel, const std::string& text, + ::chat::MessageId* out_msg_id, ::chat::NodeId peer = 0) override + { + return sendTextWithId(channel, text, 0, out_msg_id, peer); + } + + bool sendTextWithId(::chat::ChannelId channel, const std::string& text, + ::chat::MessageId forced_msg_id, + ::chat::MessageId* out_msg_id, ::chat::NodeId peer = 0) override + { + if (text.empty()) + { + return false; + } + + const ::chat::MessageId msg_id = (forced_msg_id != 0) ? forced_msg_id : nextMessageId(); + if (out_msg_id) + { + *out_msg_id = msg_id; + } + + pending_send_results_.push_back({msg_id, true}); + + if (peer != 0 && peer == self_node_id_) + { + ::chat::MeshIncomingText loopback{}; + loopback.channel = channel; + loopback.from = self_node_id_; + loopback.to = self_node_id_; + loopback.msg_id = msg_id; + loopback.timestamp = sys::epoch_seconds_now(); + loopback.text = text; + incoming_texts_.push_back(loopback); + } + else if (peer != 0) + { + scheduleIncomingText({ + .channel = channel, + .from = peer, + .to = self_node_id_, + .msg_id = nextMessageId(), + .timestamp = sys::epoch_seconds_now(), + .text = makeAutoReplyText(peer, text), + }, + kAutoReplyDelayMs); + } + else + { + scheduleIncomingText({ + .channel = channel, + .from = kDemoBroadcastNodeId, + .to = 0, + .msg_id = nextMessageId(), + .timestamp = sys::epoch_seconds_now(), + .text = "Broadcast heard: " + text.substr(0, std::min(text.size(), 32U)), + }, + kAutoReplyDelayMs + 120U); + } + + return true; + } + + bool pollIncomingText(::chat::MeshIncomingText* out) override + { + if (out == nullptr || incoming_texts_.empty()) + { + return false; + } + + *out = incoming_texts_.front(); + incoming_texts_.erase(incoming_texts_.begin()); + return true; + } + + bool sendAppData(::chat::ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + ::chat::NodeId dest = 0, bool want_ack = false, + ::chat::MessageId packet_id = 0, + bool want_response = false) override + { + (void)channel; + (void)portnum; + (void)payload; + (void)len; + (void)dest; + (void)want_ack; + (void)packet_id; + (void)want_response; + return true; + } + + bool pollIncomingData(::chat::MeshIncomingData* out) override + { + if (out == nullptr || incoming_data_.empty()) + { + return false; + } + + *out = incoming_data_.front(); + incoming_data_.erase(incoming_data_.begin()); + return true; + } + + bool requestNodeInfo(::chat::NodeId dest, bool want_response) override + { + (void)dest; + (void)want_response; + return true; + } + + bool startKeyVerification(::chat::NodeId dest) override + { + (void)dest; + return true; + } + + bool submitKeyVerificationNumber(::chat::NodeId dest, uint64_t nonce, uint32_t number) override + { + (void)dest; + (void)nonce; + (void)number; + return true; + } + + ::chat::NodeId getNodeId() const override + { + return self_node_id_; + } + + bool isPkiReady() const override + { + return true; + } + + bool hasPkiKey(::chat::NodeId dest) const override + { + return dest != 0; + } + + bool triggerDiscoveryAction(::chat::MeshDiscoveryAction action) override + { + (void)action; + return true; + } + + void applyConfig(const ::chat::MeshConfig& config) override + { + config_ = config; + } + + void setUserInfo(const char* long_name, const char* short_name) override + { + long_name_ = long_name ? long_name : ""; + short_name_ = short_name ? short_name : ""; + } + + bool isReady() const override + { + return true; + } + + bool pollIncomingRawPacket(uint8_t* out_data, size_t& out_len, size_t max_len) override + { + (void)out_data; + (void)out_len; + (void)max_len; + return false; + } + + void setSelfNodeId(::chat::NodeId node_id) + { + self_node_id_ = node_id; + } + + void queueIncomingText(const ::chat::MeshIncomingText& msg) + { + incoming_texts_.push_back(msg); + } + + void queueIncomingData(const ::chat::MeshIncomingData& msg) + { + incoming_data_.push_back(msg); + } + + void tick() + { + const uint32_t now_ms = sys::millis_now(); + while (!pending_incoming_texts_.empty()) + { + const auto& pending = pending_incoming_texts_.front(); + if (pending.due_ms > now_ms) + { + break; + } + incoming_texts_.push_back(pending.message); + pending_incoming_texts_.pop_front(); + } + } + + bool takePendingSendResult(::chat::MessageId& out_msg_id, bool& out_ok) + { + if (pending_send_results_.empty()) + { + return false; + } + + const auto result = pending_send_results_.front(); + pending_send_results_.erase(pending_send_results_.begin()); + out_msg_id = result.first; + out_ok = result.second; + return true; + } + + private: + struct DelayedIncomingText + { + uint32_t due_ms = 0; + ::chat::MeshIncomingText message{}; + }; + + ::chat::MessageId nextMessageId() + { + if (next_message_id_ == 0) + { + next_message_id_ = 1; + } + return next_message_id_++; + } + + void scheduleIncomingText(::chat::MeshIncomingText msg, uint32_t delay_ms) + { + msg.timestamp = sys::epoch_seconds_now(); + pending_incoming_texts_.push_back({ + .due_ms = sys::millis_now() + delay_ms, + .message = std::move(msg), + }); + } + + ::chat::NodeId self_node_id_ = 0; + ::chat::MessageId next_message_id_ = 1; + ::chat::MeshConfig config_{}; + std::string long_name_{}; + std::string short_name_{}; + std::vector<::chat::MeshIncomingText> incoming_texts_{}; + std::vector<::chat::MeshIncomingData> incoming_data_{}; + std::vector> pending_send_results_{}; + std::deque pending_incoming_texts_{}; +}; + +class LinuxTeamRuntime final : public ::team::ITeamRuntime +{ + public: + uint32_t nowMillis() override + { + return sys::millis_now(); + } + + uint32_t nowUnixSeconds() override + { + return sys::epoch_seconds_now(); + } + + void fillRandomBytes(uint8_t* out, size_t len) override + { + if (out == nullptr || len == 0) + { + return; + } + + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution dist(0, 255); + for (size_t i = 0; i < len; ++i) + { + out[i] = static_cast(dist(gen)); + } + } +}; + +class LinuxTeamCrypto final : public ::team::ITeamCrypto +{ + public: + bool deriveKey(const uint8_t* key, size_t key_len, + const char* info, + uint8_t* out, size_t out_len) override + { + if (key == nullptr || key_len == 0 || out == nullptr || out_len == 0) + { + return false; + } + + uint32_t state = 2166136261u; + for (size_t i = 0; i < key_len; ++i) + { + state ^= key[i]; + state *= 16777619u; + } + if (info) + { + for (const char* p = info; *p != '\0'; ++p) + { + state ^= static_cast(*p); + state *= 16777619u; + } + } + + for (size_t i = 0; i < out_len; ++i) + { + state ^= static_cast(i + 1U); + state *= 16777619u; + out[i] = static_cast((state >> ((i % 4U) * 8U)) & 0xFFU); + } + return true; + } + + bool aeadEncrypt(const uint8_t* key, size_t key_len, + const uint8_t* nonce, size_t nonce_len, + const uint8_t* aad, size_t aad_len, + const uint8_t* plain, size_t plain_len, + std::vector& out_cipher) override + { + return xorCipher(key, key_len, nonce, nonce_len, aad, aad_len, plain, plain_len, out_cipher); + } + + bool aeadDecrypt(const uint8_t* key, size_t key_len, + const uint8_t* nonce, size_t nonce_len, + const uint8_t* aad, size_t aad_len, + const uint8_t* cipher, size_t cipher_len, + std::vector& out_plain) override + { + return xorCipher(key, key_len, nonce, nonce_len, aad, aad_len, cipher, cipher_len, out_plain); + } + + private: + static bool xorCipher(const uint8_t* key, size_t key_len, + const uint8_t* nonce, size_t nonce_len, + const uint8_t* aad, size_t aad_len, + const uint8_t* input, size_t input_len, + std::vector& output) + { + if (key == nullptr || key_len == 0 || input == nullptr) + { + return false; + } + + uint32_t state = 0x9E3779B9u; + for (size_t i = 0; i < key_len; ++i) + { + state = (state * 33u) ^ key[i]; + } + for (size_t i = 0; i < nonce_len; ++i) + { + state = (state * 33u) ^ nonce[i]; + } + for (size_t i = 0; i < aad_len; ++i) + { + state = (state * 33u) ^ aad[i]; + } + + output.resize(input_len); + for (size_t i = 0; i < input_len; ++i) + { + state = state * 1664525u + 1013904223u; + output[i] = static_cast(input[i] ^ ((state >> 24U) & 0xFFU)); + } + return true; + } +}; + +class LinuxChatEventBusBridge final : public ::chat::ChatService::IncomingMessageObserver +{ + public: + explicit LinuxChatEventBusBridge(::chat::ChatService& service) : service_(service) + { + service_.addIncomingMessageObserver(this); + } + + ~LinuxChatEventBusBridge() override + { + service_.removeIncomingMessageObserver(this); + } + + void onIncomingMessage(const ::chat::ChatMessage& msg, const ::chat::RxMeta* rx_meta) override + { + ::sys::EventBus::publish( + new ::sys::ChatNewMessageEvent(static_cast(msg.channel), + msg.msg_id, + msg.text.c_str(), + rx_meta), + 0); + } + + private: + ::chat::ChatService& service_; +}; + +class LinuxTeamEventBusSink final : public ::team::ITeamEventSink +{ + public: + void onTeamKick(const ::team::TeamKickEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamKickEvent(event), 0); + } + + void onTeamTransferLeader(const ::team::TeamTransferLeaderEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamTransferLeaderEvent(event), 0); + } + + void onTeamKeyDist(const ::team::TeamKeyDistEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamKeyDistEvent(event), 0); + } + + void onTeamStatus(const ::team::TeamStatusEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamStatusEvent(event), 0); + } + + void onTeamPosition(const ::team::TeamPositionEvent& event) override + { + ::team::proto::TeamPositionMessage pos{}; + if (event.ctx.from != 0 && + ::team::proto::decodeTeamPositionMessage(event.payload.data(), + event.payload.size(), + &pos)) + { + const uint32_t timestamp = (pos.ts != 0) ? pos.ts : event.ctx.timestamp; + ::sys::EventBus::publish( + new ::sys::NodePositionUpdateEvent( + event.ctx.from, + pos.lat_e7, + pos.lon_e7, + ::team::proto::teamPositionHasAltitude(pos), + ::team::proto::teamPositionHasAltitude(pos) ? pos.alt_m : 0, + timestamp, + 0, + 0, + 0, + 0, + 0), + 0); + } + + ::sys::EventBus::publish(new ::sys::TeamPositionEvent(event), 0); + } + + void onTeamWaypoint(const ::team::TeamWaypointEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamWaypointEvent(event), 0); + } + + void onTeamTrack(const ::team::TeamTrackEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamTrackEvent(event), 0); + } + + void onTeamChat(const ::team::TeamChatEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamChatEvent(event), 0); + } + + void onTeamError(const ::team::TeamErrorEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamErrorEvent(event), 0); + } +}; + +class LinuxTeamPairingEventQueue final : public ::team::ITeamPairingEventSink +{ + public: + void onTeamPairingStateChanged(const ::team::TeamPairingEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamPairingEvent(event), 0); + } + + void onTeamPairingKeyDist(const ::team::TeamKeyDistEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamKeyDistEvent(event), 0); + } +}; + +class LinuxLoopbackTeamPairingTransport final : public ::team::ITeamPairingTransport +{ + public: + bool begin(Receiver& receiver) override + { + receiver_ = &receiver; + pending_packets_.clear(); + synthetic_member_join_sent_ = false; + synthetic_leader_ready_ = false; + return true; + } + + void end() override + { + receiver_ = nullptr; + pending_packets_.clear(); + synthetic_member_join_sent_ = false; + synthetic_leader_ready_ = false; + } + + bool ensurePeer(const uint8_t* mac) override + { + return mac != nullptr; + } + + bool send(const uint8_t* mac, const uint8_t* data, size_t len) override + { + if (!mac || !data || len == 0) + { + return false; + } + + ::team::proto::pairing::MessageType type{}; + if (!::team::proto::pairing::decodeType(data, len, &type)) + { + return false; + } + + if (type == ::team::proto::pairing::MessageType::Beacon) + { + ::team::proto::pairing::BeaconPacket beacon{}; + if (!::team::proto::pairing::decodeBeacon(data, len, &beacon)) + { + return false; + } + if (!synthetic_member_join_sent_) + { + scheduleSyntheticMemberJoin(beacon); + synthetic_member_join_sent_ = true; + } + return true; + } + + if (type == ::team::proto::pairing::MessageType::Join) + { + ::team::proto::pairing::JoinPacket join{}; + if (!::team::proto::pairing::decodeJoin(data, len, &join)) + { + return false; + } + if (synthetic_leader_ready_) + { + scheduleSyntheticLeaderKey(join); + } + return true; + } + + return true; + } + + void scheduleSyntheticLeaderBeacon() + { + synthetic_leader_ready_ = true; + synthetic_leader_team_id_ = makeSyntheticPairTeamId(); + synthetic_leader_key_id_ = kSyntheticPairKeyId; + synthetic_leader_psk_ = makeSyntheticPairPsk(); + synthetic_leader_psk_len_ = static_cast(synthetic_leader_psk_.size()); + + ::team::proto::pairing::BeaconPacket beacon{}; + beacon.team_id = synthetic_leader_team_id_; + beacon.key_id = synthetic_leader_key_id_; + beacon.leader_id = kSyntheticPairLeaderNodeId; + beacon.window_ms = 120000U; + std::strncpy(beacon.team_name, syntheticPairTeamName(), sizeof(beacon.team_name) - 1U); + beacon.team_name[sizeof(beacon.team_name) - 1U] = '\0'; + beacon.has_team_name = true; + schedulePacket(kSyntheticLeaderMac, beacon, kSyntheticPairDelayMs); + } + + void pump() + { + if (receiver_ == nullptr) + { + return; + } + + const uint32_t now_ms = sys::millis_now(); + while (!pending_packets_.empty()) + { + const auto& packet = pending_packets_.front(); + if (packet.due_ms > now_ms) + { + break; + } + receiver_->onPairingReceive(packet.mac.data(), packet.payload.data(), packet.payload.size()); + pending_packets_.pop_front(); + } + } + + private: + struct PendingPacket + { + std::array mac{}; + std::vector payload{}; + uint32_t due_ms = 0; + }; + + void scheduleSyntheticMemberJoin(const ::team::proto::pairing::BeaconPacket& beacon) + { + ::team::proto::pairing::JoinPacket join{}; + join.team_id = beacon.team_id; + join.member_id = kSyntheticPairMemberNodeId; + join.nonce = kSyntheticPairNonce; + schedulePacket(kSyntheticMemberMac, join, kSyntheticPairDelayMs); + } + + void scheduleSyntheticLeaderKey(const ::team::proto::pairing::JoinPacket& join) + { + ::team::proto::pairing::KeyPacket key{}; + key.team_id = synthetic_leader_team_id_; + key.key_id = synthetic_leader_key_id_; + key.nonce = join.nonce; + key.channel_psk = synthetic_leader_psk_; + key.channel_psk_len = synthetic_leader_psk_len_; + schedulePacket(kSyntheticLeaderMac, key, kSyntheticPairDelayMs); + } + + template + void schedulePacket(const std::array& mac, const PacketT& packet, uint32_t delay_ms) + { + std::vector wire; + bool ok = false; + if constexpr (std::is_same_v) + { + ok = ::team::proto::pairing::encodeBeacon(packet, wire); + } + else if constexpr (std::is_same_v) + { + ok = ::team::proto::pairing::encodeJoin(packet, wire); + } + else if constexpr (std::is_same_v) + { + ok = ::team::proto::pairing::encodeKey(packet, wire); + } + + if (!ok) + { + return; + } + + pending_packets_.push_back({ + .mac = mac, + .payload = std::move(wire), + .due_ms = sys::millis_now() + delay_ms, + }); + } + + Receiver* receiver_ = nullptr; + std::deque pending_packets_{}; + bool synthetic_member_join_sent_ = false; + bool synthetic_leader_ready_ = false; + ::team::TeamId synthetic_leader_team_id_{}; + uint32_t synthetic_leader_key_id_ = 0; + std::array synthetic_leader_psk_{}; + uint8_t synthetic_leader_psk_len_ = 0; +}; + +class LinuxLoopbackTeamPairingService final : public ::team::TeamPairingService, + private ::team::ITeamPairingTransport::Receiver +{ + public: + LinuxLoopbackTeamPairingService(::team::ITeamRuntime& runtime, + ::team::ITeamPairingEventSink& event_sink, + LinuxLoopbackTeamPairingTransport& transport) + : transport_(transport), core_(runtime, event_sink, transport) + { + } + + bool startLeader(const ::team::TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len, + uint32_t leader_id, + const char* team_name) override + { + if (!ensureTransport()) + { + return false; + } + if (!core_.startLeader(team_id, key_id, psk, psk_len, leader_id, team_name)) + { + shutdownTransport(); + return false; + } + return true; + } + + bool startMember(uint32_t self_id) override + { + if (!ensureTransport()) + { + return false; + } + if (!core_.startMember(self_id)) + { + shutdownTransport(); + return false; + } + transport_.scheduleSyntheticLeaderBeacon(); + return true; + } + + void stop() override + { + core_.stop(); + shutdownTransport(); + } + + void update() override + { + if (!transport_ready_) + { + return; + } + + transport_.pump(); + while (!rx_packets_.empty()) + { + const auto packet = rx_packets_.front(); + rx_packets_.pop_front(); + core_.handleIncomingPacket(packet.mac.data(), packet.payload.data(), packet.payload.size()); + } + + core_.update(); + if (transport_ready_ && core_.getStatus().state == ::team::TeamPairingState::Idle) + { + shutdownTransport(); + } + } + + ::team::TeamPairingStatus getStatus() const override + { + return core_.getStatus(); + } + + private: + struct RxPacket + { + std::array mac{}; + std::vector payload{}; + }; + + bool ensureTransport() + { + if (transport_ready_) + { + return true; + } + if (!transport_.begin(*this)) + { + return false; + } + transport_ready_ = true; + return true; + } + + void shutdownTransport() + { + if (!transport_ready_) + { + return; + } + transport_.end(); + transport_ready_ = false; + rx_packets_.clear(); + } + + void onPairingReceive(const uint8_t* mac, const uint8_t* data, size_t len) override + { + if (!mac || !data || len == 0) + { + return; + } + RxPacket packet{}; + std::copy(mac, mac + 6, packet.mac.begin()); + packet.payload.assign(data, data + len); + rx_packets_.push_back(std::move(packet)); + } + + LinuxLoopbackTeamPairingTransport& transport_; + ::team::TeamPairingCoordinator core_; + bool transport_ready_ = false; + std::deque rx_packets_{}; +}; + +class LinuxNullTrackSource final : public ::team::ITeamTrackSource +{ + public: + bool readTrackPoint(::team::proto::TeamTrackPoint* out_point) override + { + (void)out_point; + return false; + } +}; + +bool dispatchCoreEvent(MinimalLinuxAppFacade& app_facade, ::sys::Event* event) +{ + if (event == nullptr) + { + return true; + } + + switch (event->type) + { + case ::sys::EventType::NodeInfoUpdate: + { + auto* node_event = static_cast<::sys::NodeInfoUpdateEvent*>(event); + ::chat::contacts::NodeUpdate update{}; + update.short_name = node_event->short_name; + update.long_name = node_event->long_name; + update.has_last_seen = true; + update.last_seen = node_event->event_timestamp != 0 ? node_event->event_timestamp : node_event->timestamp; + update.has_snr = true; + update.snr = node_event->snr; + update.has_rssi = true; + update.rssi = node_event->rssi; + update.has_protocol = true; + update.protocol = node_event->protocol; + update.has_role = true; + update.role = node_event->role; + update.has_hops_away = true; + update.hops_away = node_event->hops_away; + update.has_hw_model = true; + update.hw_model = node_event->hw_model; + update.has_channel = true; + update.channel = node_event->channel; + update.has_macaddr = node_event->has_macaddr; + if (node_event->has_macaddr) + { + std::memcpy(update.macaddr, node_event->macaddr, sizeof(update.macaddr)); + } + update.has_via_mqtt = true; + update.via_mqtt = node_event->via_mqtt; + update.has_is_ignored = true; + update.is_ignored = node_event->is_ignored; + update.has_public_key = true; + update.public_key_present = node_event->has_public_key; + update.has_key_manually_verified = true; + update.key_manually_verified = node_event->key_manually_verified; + update.has_device_metrics = node_event->has_device_metrics; + if (node_event->has_device_metrics) + { + update.device_metrics = node_event->device_metrics; + } + app_facade.getContactService().applyNodeUpdate(node_event->node_id, update); + delete event; + return true; + } + case ::sys::EventType::NodeProtocolUpdate: + { + auto* protocol_event = static_cast<::sys::NodeProtocolUpdateEvent*>(event); + app_facade.getContactService().updateNodeProtocol(protocol_event->node_id, + protocol_event->protocol, + protocol_event->event_timestamp != 0 + ? protocol_event->event_timestamp + : protocol_event->timestamp); + delete event; + return true; + } + case ::sys::EventType::NodePositionUpdate: + { + auto* pos_event = static_cast<::sys::NodePositionUpdateEvent*>(event); + ::chat::contacts::NodePosition pos{}; + pos.valid = true; + pos.latitude_i = pos_event->latitude_i; + pos.longitude_i = pos_event->longitude_i; + pos.has_altitude = pos_event->has_altitude; + pos.altitude = pos_event->altitude; + pos.timestamp = pos_event->event_timestamp != 0 ? pos_event->event_timestamp : pos_event->timestamp; + pos.precision_bits = pos_event->precision_bits; + pos.pdop = pos_event->pdop; + pos.hdop = pos_event->hdop; + pos.vdop = pos_event->vdop; + pos.gps_accuracy_mm = pos_event->gps_accuracy_mm; + app_facade.getContactService().updateNodePosition(pos_event->node_id, pos); + delete event; + return true; + } + default: + return false; + } +} + +void applyPairingEventFallback(::chat::contacts::ContactService& contact_service, + ::chat::NodeId self_node_id, + const ::team::TeamPairingEvent& event) +{ + ::team::ui::TeamUiSnapshot snapshot{}; + (void)::team::ui::team_ui_get_store().load(snapshot); + + if (event.has_team_id) + { + snapshot.team_id = event.team_id; + snapshot.has_team_id = true; + if (snapshot.team_name.empty()) + { + snapshot.team_name = syntheticPairTeamName(); + } + } + if (event.has_team_name) + { + snapshot.team_name = event.team_name; + } + + const bool pairing_active = + event.state != ::team::TeamPairingState::Idle && + event.state != ::team::TeamPairingState::Completed && + event.state != ::team::TeamPairingState::Failed; + snapshot.pending_join = pairing_active; + snapshot.pending_join_started_s = pairing_active ? sys::epoch_seconds_now() : 0U; + + auto upsert_member = [&](::chat::NodeId node_id, bool leader) + { + const bool is_self = (node_id == 0 || node_id == self_node_id); + const uint32_t stored_node_id = is_self ? 0U : node_id; + auto it = std::find_if(snapshot.members.begin(), snapshot.members.end(), + [&](const ::team::ui::TeamMemberUi& member) + { + return member.node_id == stored_node_id; + }); + + if (it == snapshot.members.end()) + { + ::team::ui::TeamMemberUi member{}; + member.node_id = stored_node_id; + member.name = is_self ? "You" : contact_service.getContactName(node_id); + if (member.name.empty()) + { + char buffer[16] = {}; + std::snprintf(buffer, sizeof(buffer), "%08lX", static_cast(node_id)); + member.name = buffer; + } + member.leader = leader; + member.last_seen_s = sys::epoch_seconds_now(); + member.color_index = ::team::ui::team_color_index_from_node_id(is_self ? self_node_id : node_id); + snapshot.members.push_back(std::move(member)); + return; + } + + it->leader = leader; + it->last_seen_s = sys::epoch_seconds_now(); + if (it->name.empty()) + { + it->name = is_self ? "You" : contact_service.getContactName(node_id); + } + }; + + if (event.role == ::team::TeamPairingRole::Leader) + { + snapshot.in_team = true; + snapshot.kicked_out = false; + snapshot.self_is_leader = true; + upsert_member(self_node_id, true); + } + + if (event.role == ::team::TeamPairingRole::Leader && + event.peer_id != 0 && + event.state == ::team::TeamPairingState::LeaderBeacon) + { + upsert_member(event.peer_id, false); + snapshot.last_update_s = sys::epoch_seconds_now(); + } + + if (event.state == ::team::TeamPairingState::Completed) + { + snapshot.in_team = true; + snapshot.kicked_out = false; + snapshot.pending_join = false; + snapshot.pending_join_started_s = 0; + snapshot.self_is_leader = (event.role == ::team::TeamPairingRole::Leader); + upsert_member(self_node_id, snapshot.self_is_leader); + } + + if (event.state == ::team::TeamPairingState::Failed) + { + snapshot.pending_join = false; + snapshot.pending_join_started_s = 0; + } + + ::team::ui::team_ui_get_store().save(snapshot); +} + +void applyKeyDistEventFallback(::chat::contacts::ContactService& contact_service, + ::team::TeamController* team_controller, + ::chat::NodeId self_node_id, + const ::team::TeamKeyDistEvent& event) +{ + ::team::ui::TeamUiSnapshot snapshot{}; + (void)::team::ui::team_ui_get_store().load(snapshot); + + snapshot.team_id = event.msg.team_id; + snapshot.has_team_id = true; + snapshot.in_team = true; + snapshot.kicked_out = false; + snapshot.pending_join = false; + snapshot.pending_join_started_s = 0; + snapshot.self_is_leader = false; + snapshot.security_round = event.msg.key_id; + snapshot.last_update_s = event.ctx.timestamp != 0 ? event.ctx.timestamp : sys::epoch_seconds_now(); + snapshot.team_name = syntheticPairTeamName(); + if (event.msg.channel_psk_len > 0) + { + snapshot.team_psk = event.msg.channel_psk; + snapshot.has_team_psk = true; + } + + auto ensure_member = [&](::chat::NodeId node_id, bool leader) + { + const bool is_self = (node_id == 0 || node_id == self_node_id); + const uint32_t stored_node_id = is_self ? 0U : node_id; + auto it = std::find_if(snapshot.members.begin(), snapshot.members.end(), + [&](const ::team::ui::TeamMemberUi& member) + { + return member.node_id == stored_node_id; + }); + if (it == snapshot.members.end()) + { + ::team::ui::TeamMemberUi member{}; + member.node_id = stored_node_id; + member.name = is_self ? "You" : contact_service.getContactName(node_id); + if (member.name.empty()) + { + char buffer[16] = {}; + std::snprintf(buffer, sizeof(buffer), "%08lX", static_cast(node_id)); + member.name = buffer; + } + member.leader = leader; + member.last_seen_s = snapshot.last_update_s; + member.color_index = ::team::ui::team_color_index_from_node_id(is_self ? self_node_id : node_id); + snapshot.members.push_back(std::move(member)); + return; + } + it->leader = leader; + it->last_seen_s = snapshot.last_update_s; + }; + + ensure_member(self_node_id, false); + if (event.ctx.from != 0) + { + ensure_member(event.ctx.from, true); + } + + if (snapshot.has_team_psk) + { + if (team_controller) + { + (void)team_controller->setKeysFromPsk(snapshot.team_id, + snapshot.security_round, + snapshot.team_psk.data(), + snapshot.team_psk.size()); + } + ::team::ui::team_ui_save_keys_now(snapshot.team_id, + snapshot.security_round, + snapshot.team_psk); + } + + ::team::ui::team_ui_get_store().save(snapshot); +} + +bool handleLinuxUiEvent(MinimalLinuxAppFacade& app_facade, ::sys::Event* event) +{ + if (event == nullptr) + { + return true; + } + + if (event->type == ::sys::EventType::TeamKick || + event->type == ::sys::EventType::TeamTransferLeader || + event->type == ::sys::EventType::TeamKeyDist || + event->type == ::sys::EventType::TeamStatus || + event->type == ::sys::EventType::TeamPosition || + event->type == ::sys::EventType::TeamWaypoint || + event->type == ::sys::EventType::TeamTrack || + event->type == ::sys::EventType::TeamChat || + event->type == ::sys::EventType::TeamPairing || + event->type == ::sys::EventType::TeamError || + event->type == ::sys::EventType::SystemTick) + { + if (s_team_ui_event_dispatcher) + { + s_team_ui_event_dispatcher(event); + } + else if (event->type == ::sys::EventType::TeamPairing) + { + applyPairingEventFallback(app_facade.getContactService(), + app_facade.getSelfNodeId(), + static_cast<::sys::TeamPairingEvent*>(event)->data); + } + else if (event->type == ::sys::EventType::TeamKeyDist) + { + applyKeyDistEventFallback(app_facade.getContactService(), + app_facade.getTeamController(), + app_facade.getSelfNodeId(), + static_cast<::sys::TeamKeyDistEvent*>(event)->data); + } + delete event; + return true; + } + + if (::chat::ui::IChatUiRuntime* chat_ui_runtime = app_facade.getChatUiRuntime()) + { + chat_ui_runtime->onChatEvent(event); + return true; + } + + delete event; + return true; +} + +} // namespace + +void setTeamUiEventDispatcher(TeamUiEventDispatcher dispatcher) +{ + s_team_ui_event_dispatcher = dispatcher; +} + +struct MinimalLinuxAppFacade::Implementation +{ + explicit Implementation(::chat::NodeId self_node_id) + : node_blob_store(), + contact_blob_store(), + node_store(node_blob_store), + contact_store(contact_blob_store), + contact_service(node_store, contact_store), + chat_model(), + chat_store(), + mesh_adapter(self_node_id), + chat_service(chat_model, mesh_adapter, chat_store), + chat_event_bridge(chat_service), + team_runtime(), + team_crypto(), + team_event_sink(), + pairing_event_sink(), + pairing_transport(), + pairing_service(team_runtime, pairing_event_sink, pairing_transport), + track_source(), + team_service(team_crypto, mesh_adapter, team_event_sink, team_runtime), + team_controller(team_service), + team_track_sampler(team_runtime, track_source) + { + } + + void ensureStarted(::chat::NodeId self_node_id) + { + if (started) + { + mesh_adapter.setSelfNodeId(self_node_id); + return; + } + + mesh_adapter.setSelfNodeId(self_node_id); + node_store.setProtectedNodeChecker( + [self_node_id](uint32_t node_id) + { + return node_id == self_node_id; + }); + contact_service.begin(); + seedDemoWorld(self_node_id); + started = true; + } + + void clearContactAndNodeData() + { + contact_blob_store.clear(); + node_store.clear(); + contact_service.begin(); + demo_seeded = false; + } + + void seedDemoWorld(::chat::NodeId self_node_id) + { + if (demo_seeded) + { + return; + } + + const uint32_t now_secs = sys::epoch_seconds_now(); + const auto seeds = demoPeerSeeds(); + for (size_t i = 0; i < seeds.size(); ++i) + { + const auto& seed = seeds[i]; + contact_service.updateNodeInfo(seed.node_id, + seed.short_name, + seed.long_name, + seed.snr, + seed.rssi, + now_secs > (30U * (i + 1U)) ? now_secs - (30U * static_cast(i + 1U)) : now_secs, + static_cast(::chat::MeshProtocol::Meshtastic)); + + ::chat::contacts::NodePosition pos{}; + pos.valid = true; + pos.latitude_i = seed.lat_e7; + pos.longitude_i = seed.lon_e7; + pos.has_altitude = true; + pos.altitude = 14 + static_cast(i * 3); + pos.timestamp = now_secs; + pos.hdop = 85; + pos.gps_accuracy_mm = 2200; + contact_service.updateNodePosition(seed.node_id, pos); + + if (seed.nickname && seed.nickname[0] != '\0') + { + (void)contact_service.addContact(seed.node_id, seed.nickname); + } + if (seed.ignored) + { + (void)contact_service.setNodeIgnored(seed.node_id, true); + } + } + + (void)contact_service.setNodeKeyManuallyVerified(kDemoAlphaNodeId, true); + + mesh_adapter.queueIncomingText({ + .channel = ::chat::ChannelId::PRIMARY, + .from = kDemoAlphaNodeId, + .to = self_node_id, + .msg_id = 1001U, + .timestamp = now_secs, + .text = "Alice: local link ready.", + }); + mesh_adapter.queueIncomingText({ + .channel = ::chat::ChannelId::PRIMARY, + .from = kDemoBravoNodeId, + .to = self_node_id, + .msg_id = 1002U, + .timestamp = now_secs, + .text = "Bravo: route package synced.", + }); + mesh_adapter.queueIncomingText({ + .channel = ::chat::ChannelId::PRIMARY, + .from = kDemoBroadcastNodeId, + .to = 0, + .msg_id = 1003U, + .timestamp = now_secs, + .text = "Broadcast: Cardputer Zero local mesh online.", + }); + demo_seeded = true; + } + + LinuxNodeBlobStore node_blob_store; + LinuxContactBlobStore contact_blob_store; + ::chat::contacts::NodeStoreCore node_store; + ::chat::contacts::ContactStoreCore contact_store; + ::chat::contacts::ContactService contact_service; + ::chat::ChatModel chat_model; + ::chat::RamStore chat_store; + LinuxLoopbackMeshAdapter mesh_adapter; + ::chat::ChatService chat_service; + LinuxChatEventBusBridge chat_event_bridge; + LinuxTeamRuntime team_runtime; + LinuxTeamCrypto team_crypto; + LinuxTeamEventBusSink team_event_sink; + LinuxTeamPairingEventQueue pairing_event_sink; + LinuxLoopbackTeamPairingTransport pairing_transport; + LinuxLoopbackTeamPairingService pairing_service; + LinuxNullTrackSource track_source; + ::team::TeamService team_service; + ::team::TeamController team_controller; + ::team::TeamTrackSampler team_track_sampler; + bool started = false; + bool demo_seeded = false; +}; + +MinimalLinuxAppFacade::MinimalLinuxAppFacade() = default; +MinimalLinuxAppFacade::~MinimalLinuxAppFacade() = default; + +MinimalLinuxAppFacade::Implementation& MinimalLinuxAppFacade::impl() +{ + ensureServicesReady(); + return *impl_; +} + +const MinimalLinuxAppFacade::Implementation& MinimalLinuxAppFacade::impl() const +{ + return *impl_; +} + +bool MinimalLinuxAppFacade::initialize() +{ + if (initialized_) + { + if (!::app::hasAppFacade()) + { + ::app::bindAppFacade(*this); + } + return true; + } + + loadPersistedConfig(); + seedDefaultIdentity(); + (void)::sys::EventBus::init(); + ensureServicesReady(); + syncLocalIdentity(); + ::app::bindAppFacade(*this); + initialized_ = true; + return true; +} + +void MinimalLinuxAppFacade::shutdown() +{ + if (::app::hasAppFacade()) + { + ::app::unbindAppFacade(); + } + initialized_ = false; +} + +bool MinimalLinuxAppFacade::is_initialized() const noexcept +{ + return initialized_; +} + +::app::AppConfig& MinimalLinuxAppFacade::getConfig() +{ + return config_; +} + +const ::app::AppConfig& MinimalLinuxAppFacade::getConfig() const +{ + return config_; +} + +void MinimalLinuxAppFacade::saveConfig() +{ + const PersistedConfigBlob blob{.magic = kConfigBlobMagic, .version = kConfigBlobVersion, .config = config_}; + (void)::platform::ui::settings_store::put_blob( + kConfigNamespace, kConfigBlobKey, &blob, sizeof(blob)); +} + +void MinimalLinuxAppFacade::applyMeshConfig() +{ + ensureServicesReady(); + ::chat::MeshConfig mesh_config{}; + impl().mesh_adapter.applyConfig(mesh_config); + impl().chat_service.setActiveProtocol(config_.mesh_protocol); +} + +void MinimalLinuxAppFacade::applyUserInfo() +{ + seedDefaultIdentity(); + syncLocalIdentity(); +} + +void MinimalLinuxAppFacade::applyPositionConfig() +{ + platform::ui::gps::set_enabled(config_.gps_enabled); + platform::ui::gps::set_collection_interval(config_.gps_interval_ms); + platform::ui::gps::set_power_strategy(config_.gps_strategy); + platform::ui::gps::set_gnss_config(config_.gps_mode, config_.gps_sat_mask); + platform::ui::gps::set_external_nmea_config(config_.external_nmea_output_hz, + config_.external_nmea_sentence_mask); + platform::ui::gps::set_motion_idle_timeout(config_.motion_config.idle_timeout_ms); + platform::ui::gps::set_motion_sensor_id(config_.motion_config.sensor_id); +} + +void MinimalLinuxAppFacade::applyNetworkLimits() +{ +} + +void MinimalLinuxAppFacade::applyPrivacyConfig() +{ +} + +void MinimalLinuxAppFacade::applyChatDefaults() +{ + if (config_.chat_channel > 1U) + { + config_.chat_channel = 0U; + } +} + +::chat::MeshProtocol MinimalLinuxAppFacade::getMeshProtocol() const +{ + return config_.mesh_protocol; +} + +void MinimalLinuxAppFacade::getEffectiveUserInfo(char* out_long, + std::size_t long_len, + char* out_short, + std::size_t short_len) const +{ + copy_bounded(out_long, long_len, config_.node_name); + copy_bounded(out_short, short_len, config_.short_name); +} + +bool MinimalLinuxAppFacade::switchMeshProtocol(::chat::MeshProtocol protocol, bool persist) +{ + if (!is_supported_protocol(protocol)) + { + return false; + } + + config_.mesh_protocol = protocol; + if (impl_) + { + impl_->chat_service.setActiveProtocol(protocol); + } + if (persist) + { + saveConfig(); + } + return true; +} + +::chat::ChatService& MinimalLinuxAppFacade::getChatService() +{ + return impl().chat_service; +} + +::chat::contacts::ContactService& MinimalLinuxAppFacade::getContactService() +{ + return impl().contact_service; +} + +::chat::IMeshAdapter* MinimalLinuxAppFacade::getMeshAdapter() +{ + return &impl().mesh_adapter; +} + +const ::chat::IMeshAdapter* MinimalLinuxAppFacade::getMeshAdapter() const +{ + return &impl().mesh_adapter; +} + +::chat::NodeId MinimalLinuxAppFacade::getSelfNodeId() const +{ + return 0x435A0001U; +} + +::team::TeamController* MinimalLinuxAppFacade::getTeamController() +{ + return &impl().team_controller; +} + +::team::TeamPairingService* MinimalLinuxAppFacade::getTeamPairing() +{ + return &impl().pairing_service; +} + +::team::TeamService* MinimalLinuxAppFacade::getTeamService() +{ + return &impl().team_service; +} + +const ::team::TeamService* MinimalLinuxAppFacade::getTeamService() const +{ + return &impl().team_service; +} + +::team::TeamTrackSampler* MinimalLinuxAppFacade::getTeamTrackSampler() +{ + return &impl().team_track_sampler; +} + +void MinimalLinuxAppFacade::setTeamModeActive(bool active) +{ + (void)active; +} + +void MinimalLinuxAppFacade::broadcastNodeInfo() +{ + syncLocalIdentity(); +} + +void MinimalLinuxAppFacade::clearNodeDb() +{ + ensureServicesReady(); + impl().clearContactAndNodeData(); + syncLocalIdentity(); +} + +void MinimalLinuxAppFacade::clearMessageDb() +{ + ensureServicesReady(); + impl().chat_model.clearAll(); + impl().chat_store.clearAll(); + ::team::ui::team_ui_get_store().clear(); +} + +::ble::BleManager* MinimalLinuxAppFacade::getBleManager() +{ + return nullptr; +} + +const ::ble::BleManager* MinimalLinuxAppFacade::getBleManager() const +{ + return nullptr; +} + +bool MinimalLinuxAppFacade::isBleEnabled() const +{ + return config_.ble_enabled; +} + +void MinimalLinuxAppFacade::setBleEnabled(bool enabled) +{ + config_.ble_enabled = enabled; + saveConfig(); +} + +void MinimalLinuxAppFacade::restartDevice() +{ + ::platform::ui::device::restart(); +} + +::chat::ui::IChatUiRuntime* MinimalLinuxAppFacade::getChatUiRuntime() +{ + return chat_ui_runtime_; +} + +void MinimalLinuxAppFacade::setChatUiRuntime(::chat::ui::IChatUiRuntime* runtime) +{ + chat_ui_runtime_ = runtime; +} + +::BoardBase* MinimalLinuxAppFacade::getBoard() +{ + return nullptr; +} + +const ::BoardBase* MinimalLinuxAppFacade::getBoard() const +{ + return nullptr; +} + +void MinimalLinuxAppFacade::updateCoreServices() +{ + if (!impl_) + { + return; + } + + ::team::ui::TeamUiSnapshot snap; + const bool has_team = ::team::ui::team_ui_get_store().load(snap) && snap.in_team; + impl_->team_track_sampler.update(&impl_->team_controller, has_team); +} + +void MinimalLinuxAppFacade::tickEventRuntime() +{ + if (!impl_) + { + return; + } + + impl_->pairing_service.update(); + ::sys::EventBus::publish(new ::sys::Event(::sys::EventType::SystemTick), 0); +} + +void MinimalLinuxAppFacade::dispatchPendingEvents(std::size_t max_events) +{ + if (!impl_) + { + return; + } + + impl_->mesh_adapter.tick(); + impl_->chat_service.processIncoming(); + impl_->team_service.processIncoming(); + + while (true) + { + ::chat::MessageId msg_id = 0; + bool ok = false; + if (!impl_->mesh_adapter.takePendingSendResult(msg_id, ok)) + { + break; + } + impl_->chat_service.handleSendResult(msg_id, ok); + ::sys::EventBus::publish(new ::sys::ChatSendResultEvent(msg_id, ok), 0); + } + + std::size_t processed = 0; + ::sys::Event* event = nullptr; + while (processed < max_events && ::sys::EventBus::subscribe(&event, 0)) + { + if (event == nullptr) + { + continue; + } + ++processed; + + if (dispatchCoreEvent(*this, event)) + { + continue; + } + + if (handleLinuxUiEvent(*this, event)) + { + continue; + } + + delete event; + } +} + +void MinimalLinuxAppFacade::loadPersistedConfig() +{ + std::vector blob_bytes; + if (!::platform::ui::settings_store::get_blob(kConfigNamespace, kConfigBlobKey, blob_bytes) || + blob_bytes.size() != sizeof(PersistedConfigBlob)) + { + config_ = ::app::AppConfig{}; + return; + } + + PersistedConfigBlob blob{}; + std::memcpy(&blob, blob_bytes.data(), sizeof(blob)); + if (blob.magic != kConfigBlobMagic || blob.version != kConfigBlobVersion || + !is_supported_protocol(blob.config.mesh_protocol)) + { + config_ = ::app::AppConfig{}; + return; + } + + config_ = blob.config; +} + +void MinimalLinuxAppFacade::seedDefaultIdentity() +{ + if (config_.node_name[0] == '\0') + { + copy_bounded(config_.node_name, sizeof(config_.node_name), "Cardputer Zero"); + } + if (config_.short_name[0] == '\0') + { + copy_bounded(config_.short_name, sizeof(config_.short_name), "CZ"); + } + applyChatDefaults(); +} + +void MinimalLinuxAppFacade::syncLocalIdentity() +{ + ensureServicesReady(); + impl().mesh_adapter.setUserInfo(config_.node_name, config_.short_name); + impl().chat_service.setActiveProtocol(config_.mesh_protocol); + impl().contact_service.updateNodeInfo(getSelfNodeId(), + config_.short_name, + config_.node_name, + 0.0f, + 0.0f, + sys::epoch_seconds_now(), + static_cast(config_.mesh_protocol)); + ::chat::contacts::NodeUpdate self_update{}; + self_update.has_is_ignored = true; + self_update.is_ignored = true; + impl().contact_service.applyNodeUpdate(getSelfNodeId(), self_update); +} + +void MinimalLinuxAppFacade::ensureServicesReady() +{ + if (!impl_) + { + impl_ = std::make_unique(getSelfNodeId()); + } + impl_->ensureStarted(getSelfNodeId()); +} + +} // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/src/app/linux_demo_world.cpp b/platform/linux/common/src/app/linux_demo_world.cpp new file mode 100644 index 00000000..4e13771b --- /dev/null +++ b/platform/linux/common/src/app/linux_demo_world.cpp @@ -0,0 +1,292 @@ +#include "app/linux_demo_world.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "chat/domain/chat_model.h" +#include "chat/ports/i_mesh_adapter.h" +#include "sys/clock.h" +#include "team/protocol/team_pairing_wire.h" + +namespace trailmate::cardputer_zero::linux_ui +{ +namespace +{ + +// --------------------------------------------------------------------------- +// Peer seeds +// --------------------------------------------------------------------------- + +constexpr ::chat::NodeId kAlpha = 0x435A1001U; +constexpr ::chat::NodeId kBravo = 0x435A1002U; +constexpr ::chat::NodeId kScout = 0x435A1003U; +constexpr ::chat::NodeId kNearby = 0x435A1004U; + +constexpr uint32_t kAutoReplyDelayMs = 700U; + +std::string makeAutoReplyTextImpl(::chat::NodeId peer, const std::string& text) +{ + const std::string trimmed = text.substr(0, std::min(text.size(), 28U)); + switch (peer) + { + case kAlpha: + return "Alice copied: " + trimmed; + case kBravo: + return "Bravo link OK: " + trimmed; + case kScout: + return "Scout received your ping."; + default: + return "Peer ack: " + trimmed; + } +} + +// --------------------------------------------------------------------------- +// Loopback mesh adapter +// --------------------------------------------------------------------------- + +class LoopbackMeshAdapter final : public ::chat::IMeshAdapter +{ + public: + explicit LoopbackMeshAdapter(::chat::NodeId self_node_id) + : self_node_id_(self_node_id) {} + + ::chat::MeshCapabilities getCapabilities() const override + { + return { + .supports_unicast_text = true, + .supports_unicast_appdata = true, + .supports_broadcast_appdata = true, + .supports_appdata_ack = true, + .provides_appdata_sender = true, + .supports_node_info = true, + .supports_pki = true, + .supports_discovery_actions = true, + }; + } + + bool sendText(::chat::ChannelId channel, const std::string& text, + ::chat::MessageId* out_msg_id, ::chat::NodeId peer = 0) override + { + return sendTextWithId(channel, text, 0, out_msg_id, peer); + } + + bool sendTextWithId(::chat::ChannelId channel, const std::string& text, + ::chat::MessageId forced_msg_id, + ::chat::MessageId* out_msg_id, ::chat::NodeId peer = 0) override + { + if (text.empty()) return false; + + const ::chat::MessageId msg_id = + (forced_msg_id != 0) ? forced_msg_id : nextMessageId(); + if (out_msg_id) *out_msg_id = msg_id; + + pending_send_results_.push_back({msg_id, true}); + + if (peer != 0 && peer == self_node_id_) + { + ::chat::MeshIncomingText loopback{}; + loopback.channel = channel; + loopback.from = self_node_id_; + loopback.to = self_node_id_; + loopback.msg_id = msg_id; + loopback.timestamp = sys::epoch_seconds_now(); + loopback.text = text; + incoming_texts_.push_back(loopback); + } + else if (peer != 0) + { + scheduleIncomingText( + {.channel = channel, .from = peer, .to = self_node_id_, .msg_id = nextMessageId(), .timestamp = sys::epoch_seconds_now(), .text = makeAutoReplyTextImpl(peer, text)}, + kAutoReplyDelayMs); + } + else + { + scheduleIncomingText( + {.channel = channel, .from = kAlpha, .to = 0, .msg_id = nextMessageId(), .timestamp = sys::epoch_seconds_now(), .text = "Broadcast heard: " + text.substr(0, std::min(text.size(), 32U))}, + kAutoReplyDelayMs + 120U); + } + + return true; + } + + bool pollIncomingText(::chat::MeshIncomingText* out) override + { + if (out == nullptr || incoming_texts_.empty()) return false; + *out = incoming_texts_.front(); + incoming_texts_.erase(incoming_texts_.begin()); + return true; + } + + bool sendAppData(::chat::ChannelId, uint32_t, const uint8_t*, size_t, + ::chat::NodeId, bool, ::chat::MessageId, bool) override + { + return true; + } + + bool pollIncomingData(::chat::MeshIncomingData* out) override + { + if (out == nullptr || incoming_data_.empty()) return false; + *out = incoming_data_.front(); + incoming_data_.erase(incoming_data_.begin()); + return true; + } + + bool requestNodeInfo(::chat::NodeId, bool) override { return true; } + bool startKeyVerification(::chat::NodeId) override { return true; } + + bool submitKeyVerificationNumber(::chat::NodeId, uint64_t, uint32_t) override + { + return true; + } + + ::chat::NodeId getNodeId() const override { return self_node_id_; } + bool isPkiReady() const override { return true; } + bool hasPkiKey(::chat::NodeId dest) const override { return dest != 0; } + + bool triggerDiscoveryAction(::chat::MeshDiscoveryAction) override + { + return true; + } + + void applyConfig(const ::chat::MeshConfig& config) override + { + config_ = config; + } + + void setUserInfo(const char* long_name, const char* short_name) override + { + long_name_ = long_name ? long_name : ""; + short_name_ = short_name ? short_name : ""; + } + + bool isReady() const override { return true; } + + bool pollIncomingRawPacket(uint8_t*, size_t&, size_t) override + { + return false; + } + + // Additional helpers used by the facade. + void setSelfNodeId(::chat::NodeId id) { self_node_id_ = id; } + void queueIncomingText(const ::chat::MeshIncomingText& msg) + { + incoming_texts_.push_back(msg); + } + void queueIncomingData(const ::chat::MeshIncomingData& msg) + { + incoming_data_.push_back(msg); + } + + void tick() + { + const uint32_t now = sys::millis_now(); + while (!pending_incoming_texts_.empty()) + { + const auto& p = pending_incoming_texts_.front(); + if (p.due_ms > now) break; + incoming_texts_.push_back(p.message); + pending_incoming_texts_.pop_front(); + } + } + + bool takePendingSendResult(::chat::MessageId& out_msg_id, bool& out_ok) + { + if (pending_send_results_.empty()) return false; + const auto r = pending_send_results_.front(); + pending_send_results_.erase(pending_send_results_.begin()); + out_msg_id = r.first; + out_ok = r.second; + return true; + } + + private: + struct Delayed + { + uint32_t due_ms = 0; + ::chat::MeshIncomingText message{}; + }; + + ::chat::MessageId nextMessageId() + { + if (next_message_id_ == 0) next_message_id_ = 1; + return next_message_id_++; + } + + void scheduleIncomingText(::chat::MeshIncomingText msg, uint32_t delay_ms) + { + msg.timestamp = sys::epoch_seconds_now(); + pending_incoming_texts_.push_back( + {.due_ms = sys::millis_now() + delay_ms, .message = std::move(msg)}); + } + + ::chat::NodeId self_node_id_ = 0; + ::chat::MessageId next_message_id_ = 1; + ::chat::MeshConfig config_{}; + std::string long_name_{}; + std::string short_name_{}; + std::vector<::chat::MeshIncomingText> incoming_texts_{}; + std::vector<::chat::MeshIncomingData> incoming_data_{}; + std::vector> pending_send_results_{}; + std::deque pending_incoming_texts_{}; +}; + +} // namespace + +// --------------------------------------------------------------------------- +// DemoWorld +// --------------------------------------------------------------------------- + +struct DemoWorld::Impl +{ +}; + +DemoWorld::DemoWorld() : impl_(std::make_unique()) {} +DemoWorld::~DemoWorld() = default; + +std::array DemoWorld::peerSeeds() +{ + return {{ + {kAlpha, "ALFA", "Alice Local", "Alice", false, 11.2f, -72.0f, 311214000, 1214737000}, + {kBravo, "BRAV", "Bravo Pager", "Bravo", false, 8.6f, -79.0f, 311218500, 1214749000}, + {kScout, "SCOT", "Scout Relay", nullptr, true, 4.1f, -94.0f, 311227000, 1214762000}, + {kNearby, "NBY1", "Nearby Relay", nullptr, false, 6.8f, -88.0f, 311231200, 1214756000}, + }}; +} + +std::unique_ptr<::chat::IMeshAdapter> +DemoWorld::createLoopbackMesh(::chat::NodeId self_node_id) +{ + return std::make_unique(self_node_id); +} + +std::string DemoWorld::autoReplyText(::chat::NodeId peer, + const std::string& received_text) +{ + return makeAutoReplyTextImpl(peer, received_text); +} + +::team::TeamId DemoWorld::syntheticPairTeamId() +{ + ::team::TeamId id{}; + const std::array bytes{{'C', 'Z', 'T', 'E', 'A', 'M', '0', '1'}}; + for (size_t i = 0; i < id.size() && i < bytes.size(); ++i) id[i] = bytes[i]; + return id; +} + +std::array DemoWorld::syntheticPairPsk() +{ + std::array psk{}; + for (size_t i = 0; i < psk.size(); ++i) + psk[i] = static_cast(0x30U + (i * 7U + 3U) % 0x4FU); + return psk; +} + +const char* DemoWorld::syntheticPairTeamName() { return "Field Team"; } + +} // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/src/chat/linux_noop_mesh_adapter.cpp b/platform/linux/common/src/chat/linux_noop_mesh_adapter.cpp new file mode 100644 index 00000000..9f9a6247 --- /dev/null +++ b/platform/linux/common/src/chat/linux_noop_mesh_adapter.cpp @@ -0,0 +1,91 @@ +#include "chat/linux_noop_mesh_adapter.h" + +namespace trailmate::cardputer_zero::linux_ui +{ + +::chat::MeshCapabilities LinuxNoopMeshAdapter::getCapabilities() const +{ + return {}; +} + +bool LinuxNoopMeshAdapter::sendText(::chat::ChannelId, const std::string&, + ::chat::MessageId*, ::chat::NodeId) +{ + return false; +} + +bool LinuxNoopMeshAdapter::sendTextWithId(::chat::ChannelId, const std::string&, + ::chat::MessageId, ::chat::MessageId*, + ::chat::NodeId) +{ + return false; +} + +bool LinuxNoopMeshAdapter::pollIncomingText(::chat::MeshIncomingText*) +{ + return false; +} + +bool LinuxNoopMeshAdapter::sendAppData(::chat::ChannelId, uint32_t, + const uint8_t*, size_t, ::chat::NodeId, + bool, ::chat::MessageId, bool) +{ + return false; +} + +bool LinuxNoopMeshAdapter::pollIncomingData(::chat::MeshIncomingData*) +{ + return false; +} + +bool LinuxNoopMeshAdapter::requestNodeInfo(::chat::NodeId, bool) +{ + return false; +} + +bool LinuxNoopMeshAdapter::startKeyVerification(::chat::NodeId) +{ + return false; +} + +bool LinuxNoopMeshAdapter::submitKeyVerificationNumber(::chat::NodeId, + uint64_t, uint32_t) +{ + return false; +} + +::chat::NodeId LinuxNoopMeshAdapter::getNodeId() const +{ + return 0; +} + +bool LinuxNoopMeshAdapter::isPkiReady() const +{ + return false; +} + +bool LinuxNoopMeshAdapter::hasPkiKey(::chat::NodeId) const +{ + return false; +} + +bool LinuxNoopMeshAdapter::triggerDiscoveryAction(::chat::MeshDiscoveryAction) +{ + return false; +} + +void LinuxNoopMeshAdapter::applyConfig(const ::chat::MeshConfig&) {} + +void LinuxNoopMeshAdapter::setUserInfo(const char*, const char*) {} + +bool LinuxNoopMeshAdapter::isReady() const +{ + return true; +} + +bool LinuxNoopMeshAdapter::pollIncomingRawPacket(uint8_t*, size_t&, size_t) +{ + return false; +} + +} // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/src/core/bitmap_font.cpp b/platform/linux/common/src/core/bitmap_font.cpp new file mode 100644 index 00000000..2fca3a5f --- /dev/null +++ b/platform/linux/common/src/core/bitmap_font.cpp @@ -0,0 +1,270 @@ +#include "core/bitmap_font.h" + +#include +#include +#include +#include + +namespace trailmate::cardputer_zero::core::bitmap_font +{ +namespace +{ + +struct Glyph +{ + std::array rows{}; +}; + +Glyph glyphFor(char raw) noexcept +{ + switch (raw) + { + case 'a': + return Glyph{{0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F}}; + case 'b': + return Glyph{{0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x1E}}; + case 'c': + return Glyph{{0x00, 0x00, 0x0E, 0x11, 0x10, 0x11, 0x0E}}; + case 'd': + return Glyph{{0x01, 0x01, 0x0F, 0x11, 0x11, 0x11, 0x0F}}; + case 'e': + return Glyph{{0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E}}; + case 'f': + return Glyph{{0x06, 0x08, 0x1E, 0x08, 0x08, 0x08, 0x08}}; + case 'k': + return Glyph{{0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12}}; + case 'l': + return Glyph{{0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E}}; + case 'n': + return Glyph{{0x00, 0x00, 0x1E, 0x11, 0x11, 0x11, 0x11}}; + case 'o': + return Glyph{{0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E}}; + case 'r': + return Glyph{{0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10}}; + case 't': + return Glyph{{0x08, 0x08, 0x1E, 0x08, 0x08, 0x09, 0x06}}; + default: + break; + } + + const char ch = static_cast(std::toupper(static_cast(raw))); + + switch (ch) + { + case 'A': + return Glyph{{0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11}}; + case 'B': + return Glyph{{0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E}}; + case 'C': + return Glyph{{0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E}}; + case 'D': + return Glyph{{0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E}}; + case 'E': + return Glyph{{0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F}}; + case 'F': + return Glyph{{0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10}}; + case 'G': + return Glyph{{0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E}}; + case 'H': + return Glyph{{0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11}}; + case 'I': + return Glyph{{0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x1F}}; + case 'J': + return Glyph{{0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E}}; + case 'K': + return Glyph{{0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11}}; + case 'L': + return Glyph{{0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F}}; + case 'M': + return Glyph{{0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11}}; + case 'N': + return Glyph{{0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11}}; + case 'O': + return Glyph{{0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E}}; + case 'P': + return Glyph{{0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10}}; + case 'Q': + return Glyph{{0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D}}; + case 'R': + return Glyph{{0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11}}; + case 'S': + return Glyph{{0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E}}; + case 'T': + return Glyph{{0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04}}; + case 'U': + return Glyph{{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E}}; + case 'V': + return Glyph{{0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04}}; + case 'W': + return Glyph{{0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A}}; + case 'X': + return Glyph{{0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11}}; + case 'Y': + return Glyph{{0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04}}; + case 'Z': + return Glyph{{0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F}}; + case '0': + return Glyph{{0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E}}; + case '1': + return Glyph{{0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E}}; + case '2': + return Glyph{{0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F}}; + case '3': + return Glyph{{0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E}}; + case '4': + return Glyph{{0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02}}; + case '5': + return Glyph{{0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E}}; + case '6': + return Glyph{{0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E}}; + case '7': + return Glyph{{0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08}}; + case '8': + return Glyph{{0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E}}; + case '9': + return Glyph{{0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C}}; + case '#': + return Glyph{{0x0A, 0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x0A}}; + case '$': + return Glyph{{0x04, 0x0F, 0x14, 0x0E, 0x05, 0x1E, 0x04}}; + case '%': + return Glyph{{0x19, 0x19, 0x02, 0x04, 0x08, 0x13, 0x13}}; + case '&': + return Glyph{{0x0C, 0x12, 0x14, 0x08, 0x15, 0x12, 0x0D}}; + case '\'': + return Glyph{{0x04, 0x04, 0x02, 0x00, 0x00, 0x00, 0x00}}; + case '(': + return Glyph{{0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02}}; + case ')': + return Glyph{{0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08}}; + case '*': + return Glyph{{0x00, 0x15, 0x0E, 0x1F, 0x0E, 0x15, 0x00}}; + case '+': + return Glyph{{0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00}}; + case '-': + return Glyph{{0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00}}; + case ',': + return Glyph{{0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x08}}; + case '.': + return Glyph{{0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C}}; + case ':': + return Glyph{{0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00}}; + case ';': + return Glyph{{0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x08}}; + case '<': + return Glyph{{0x01, 0x02, 0x04, 0x08, 0x04, 0x02, 0x01}}; + case '=': + return Glyph{{0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00}}; + case '>': + return Glyph{{0x10, 0x08, 0x04, 0x02, 0x04, 0x08, 0x10}}; + case '?': + return Glyph{{0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04}}; + case '@': + return Glyph{{0x0E, 0x11, 0x15, 0x17, 0x10, 0x11, 0x0E}}; + case '[': + return Glyph{{0x0E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0E}}; + case '\\': + return Glyph{{0x10, 0x08, 0x08, 0x04, 0x02, 0x02, 0x01}}; + case ']': + return Glyph{{0x0E, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0E}}; + case '^': + return Glyph{{0x04, 0x0A, 0x11, 0x00, 0x00, 0x00, 0x00}}; + case '_': + return Glyph{{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F}}; + case '`': + return Glyph{{0x08, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00}}; + case '/': + return Glyph{{0x01, 0x02, 0x02, 0x04, 0x08, 0x08, 0x10}}; + case '!': + return Glyph{{0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04}}; + case '"': + return Glyph{{0x0A, 0x0A, 0x05, 0x00, 0x00, 0x00, 0x00}}; + case '{': + return Glyph{{0x02, 0x04, 0x04, 0x08, 0x04, 0x04, 0x02}}; + case '|': + return Glyph{{0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04}}; + case '}': + return Glyph{{0x08, 0x04, 0x04, 0x02, 0x04, 0x04, 0x08}}; + case ' ': + return Glyph{{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}; + default: + return Glyph{{0x1F, 0x01, 0x02, 0x04, 0x08, 0x00, 0x08}}; + } +} + +void drawGlyph(Canvas& canvas, const Glyph& glyph, int x, int y, Color color, int scale) noexcept +{ + const int clamped_scale = std::max(scale, 1); + + for (int row = 0; row < kGlyphHeight; ++row) + { + for (int col = 0; col < kGlyphWidth; ++col) + { + const bool filled = ((glyph.rows[static_cast(row)] >> (kGlyphWidth - 1 - col)) & 0x01U) != 0U; + if (!filled) + { + continue; + } + + for (int dy = 0; dy < clamped_scale; ++dy) + { + for (int dx = 0; dx < clamped_scale; ++dx) + { + canvas.setPixel(x + (col * clamped_scale) + dx, y + (row * clamped_scale) + dy, color); + } + } + } + } +} + +} // namespace + +int measureText(std::string_view text, int scale) noexcept +{ + const int clamped_scale = std::max(scale, 1); + int line_width = 0; + int max_width = 0; + + for (const char ch : text) + { + if (ch == '\n') + { + max_width = std::max(max_width, line_width); + line_width = 0; + continue; + } + + line_width += (kGlyphWidth + kGlyphSpacing) * clamped_scale; + } + + max_width = std::max(max_width, line_width); + return max_width > 0 ? max_width - (kGlyphSpacing * clamped_scale) : 0; +} + +int lineHeight(int scale) noexcept +{ + const int clamped_scale = std::max(scale, 1); + return (kGlyphHeight + 2) * clamped_scale; +} + +void drawText(Canvas& canvas, std::string_view text, int x, int y, Color color, int scale) noexcept +{ + const int clamped_scale = std::max(scale, 1); + int cursor_x = x; + int cursor_y = y; + + for (const char ch : text) + { + if (ch == '\n') + { + cursor_x = x; + cursor_y += lineHeight(clamped_scale); + continue; + } + + drawGlyph(canvas, glyphFor(ch), cursor_x, cursor_y, color, clamped_scale); + cursor_x += (kGlyphWidth + kGlyphSpacing) * clamped_scale; + } +} + +} // namespace trailmate::cardputer_zero::core::bitmap_font diff --git a/platform/linux/common/src/core/bitmap_font.h b/platform/linux/common/src/core/bitmap_font.h new file mode 100644 index 00000000..9240fbde --- /dev/null +++ b/platform/linux/common/src/core/bitmap_font.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include "core/canvas.h" + +namespace trailmate::cardputer_zero::core::bitmap_font +{ + +inline constexpr int kGlyphWidth = 5; +inline constexpr int kGlyphHeight = 7; +inline constexpr int kGlyphSpacing = 1; + +[[nodiscard]] int measureText(std::string_view text, int scale = 1) noexcept; +[[nodiscard]] int lineHeight(int scale = 1) noexcept; +void drawText(Canvas& canvas, std::string_view text, int x, int y, Color color, int scale = 1) noexcept; + +} // namespace trailmate::cardputer_zero::core::bitmap_font diff --git a/platform/linux/common/src/core/canvas.cpp b/platform/linux/common/src/core/canvas.cpp new file mode 100644 index 00000000..7548fb38 --- /dev/null +++ b/platform/linux/common/src/core/canvas.cpp @@ -0,0 +1,154 @@ +#include "core/canvas.h" + +#include + +namespace trailmate::cardputer_zero::core +{ + +Canvas::Canvas(int width, int height) + : width_(width), height_(height), pixels_(static_cast(width * height), rgba(0, 0, 0, 255)) +{ +} + +int Canvas::width() const noexcept +{ + return width_; +} + +int Canvas::height() const noexcept +{ + return height_; +} + +void Canvas::clear(Color color) noexcept +{ + std::fill(pixels_.begin(), pixels_.end(), color); +} + +void Canvas::setPixel(int x, int y, Color color) noexcept +{ + if (x < 0 || y < 0 || x >= width_ || y >= height_) + { + return; + } + + pixels_[static_cast(indexOf(x, y))] = color; +} + +void Canvas::fillRect(int x, int y, int width, int height, Color color) noexcept +{ + if (width <= 0 || height <= 0) + { + return; + } + + const int start_x = std::max(0, x); + const int start_y = std::max(0, y); + const int end_x = std::min(width_, x + width); + const int end_y = std::min(height_, y + height); + + if (start_x >= end_x || start_y >= end_y) + { + return; + } + + for (int py = start_y; py < end_y; ++py) + { + for (int px = start_x; px < end_x; ++px) + { + setPixel(px, py, color); + } + } +} + +void Canvas::fillRoundedRect(int x, int y, int width, int height, int radius, Color color) noexcept +{ + if (width <= 0 || height <= 0) + { + return; + } + + const int clamped_radius = std::clamp(radius, 0, std::min(width, height) / 2); + if (clamped_radius == 0) + { + fillRect(x, y, width, height, color); + return; + } + + const int start_x = std::max(0, x); + const int start_y = std::max(0, y); + const int end_x = std::min(width_, x + width); + const int end_y = std::min(height_, y + height); + + for (int py = start_y; py < end_y; ++py) + { + for (int px = start_x; px < end_x; ++px) + { + const int local_x = px - x; + const int local_y = py - y; + + if ((local_x >= clamped_radius && local_x < width - clamped_radius) || (local_y >= clamped_radius && local_y < height - clamped_radius)) + { + setPixel(px, py, color); + continue; + } + + const int center_x = (local_x < clamped_radius) ? (clamped_radius - 1) : (width - clamped_radius); + const int center_y = (local_y < clamped_radius) ? (clamped_radius - 1) : (height - clamped_radius); + const int dx = local_x - center_x; + const int dy = local_y - center_y; + + if ((dx * dx) + (dy * dy) <= (clamped_radius * clamped_radius)) + { + setPixel(px, py, color); + } + } + } +} + +void Canvas::strokeRect(int x, int y, int width, int height, Color color, int thickness) noexcept +{ + if (width <= 0 || height <= 0 || thickness <= 0) + { + return; + } + + fillRect(x, y, width, thickness, color); + fillRect(x, y + height - thickness, width, thickness, color); + fillRect(x, y, thickness, height, color); + fillRect(x + width - thickness, y, thickness, height, color); +} + +void Canvas::blit(const Canvas& source, int x, int y) noexcept +{ + for (int py = 0; py < source.height(); ++py) + { + for (int px = 0; px < source.width(); ++px) + { + setPixel(x + px, y + py, source.pixel(px, py)); + } + } +} + +const Color& Canvas::pixel(int x, int y) const noexcept +{ + static const Color kFallback = rgba(0, 0, 0, 255); + if (x < 0 || y < 0 || x >= width_ || y >= height_) + { + return kFallback; + } + + return pixels_[static_cast(indexOf(x, y))]; +} + +const std::vector& Canvas::pixels() const noexcept +{ + return pixels_; +} + +int Canvas::indexOf(int x, int y) const noexcept +{ + return (y * width_) + x; +} + +} // namespace trailmate::cardputer_zero::core diff --git a/platform/linux/common/src/core/canvas.h b/platform/linux/common/src/core/canvas.h new file mode 100644 index 00000000..f16265d1 --- /dev/null +++ b/platform/linux/common/src/core/canvas.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "core/color.h" + +namespace trailmate::cardputer_zero::core +{ + +class Canvas +{ + public: + Canvas(int width, int height); + + [[nodiscard]] int width() const noexcept; + [[nodiscard]] int height() const noexcept; + + void clear(Color color) noexcept; + void setPixel(int x, int y, Color color) noexcept; + void fillRect(int x, int y, int width, int height, Color color) noexcept; + void fillRoundedRect(int x, int y, int width, int height, int radius, Color color) noexcept; + void strokeRect(int x, int y, int width, int height, Color color, int thickness = 1) noexcept; + void blit(const Canvas& source, int x, int y) noexcept; + + [[nodiscard]] const Color& pixel(int x, int y) const noexcept; + [[nodiscard]] const std::vector& pixels() const noexcept; + + private: + [[nodiscard]] int indexOf(int x, int y) const noexcept; + + int width_{}; + int height_{}; + std::vector pixels_{}; +}; + +} // namespace trailmate::cardputer_zero::core diff --git a/platform/linux/common/src/core/color.h b/platform/linux/common/src/core/color.h new file mode 100644 index 00000000..fe4196eb --- /dev/null +++ b/platform/linux/common/src/core/color.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +namespace trailmate::cardputer_zero::core +{ + +struct Color +{ + std::uint8_t r{}; + std::uint8_t g{}; + std::uint8_t b{}; + std::uint8_t a{255}; +}; + +constexpr bool operator==(const Color& lhs, const Color& rhs) noexcept +{ + return lhs.r == rhs.r && lhs.g == rhs.g && lhs.b == rhs.b && lhs.a == rhs.a; +} + +constexpr bool operator!=(const Color& lhs, const Color& rhs) noexcept +{ + return !(lhs == rhs); +} + +constexpr Color rgba(std::uint8_t r, std::uint8_t g, std::uint8_t b, std::uint8_t a = 255) noexcept +{ + return Color{r, g, b, a}; +} + +constexpr std::uint32_t pack_key(Color color) noexcept +{ + return (static_cast(color.r) << 24U) | (static_cast(color.g) << 16U) | (static_cast(color.b) << 8U) | static_cast(color.a); +} + +} // namespace trailmate::cardputer_zero::core diff --git a/platform/linux/common/src/core/display_profile.h b/platform/linux/common/src/core/display_profile.h new file mode 100644 index 00000000..31e5ebb2 --- /dev/null +++ b/platform/linux/common/src/core/display_profile.h @@ -0,0 +1,9 @@ +#pragma once + +namespace trailmate::cardputer_zero::core +{ + +inline constexpr int kDisplayWidth = 320; +inline constexpr int kDisplayHeight = 170; + +} // namespace trailmate::cardputer_zero::core diff --git a/platform/linux/common/src/platform/linux/env_config.cpp b/platform/linux/common/src/platform/linux/env_config.cpp new file mode 100644 index 00000000..dee0aa4e --- /dev/null +++ b/platform/linux/common/src/platform/linux/env_config.cpp @@ -0,0 +1,97 @@ +#include "platform/linux/env_config.h" + +#include +#include +#include + +namespace platform::linux_runtime +{ + +bool env_flag(const char* name, bool fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + return std::strcmp(value, "1") == 0 || std::strcmp(value, "true") == 0 || + std::strcmp(value, "TRUE") == 0 || std::strcmp(value, "yes") == 0 || + std::strcmp(value, "YES") == 0; +} + +int env_int(const char* name, int fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (end == value || (end && *end != '\0')) + { + return fallback; + } + return static_cast(parsed); +} + +std::uint32_t env_uint32(const char* name, std::uint32_t fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const unsigned long parsed = std::strtoul(value, &end, 10); + if (end == value || (end && *end != '\0')) + { + return fallback; + } + return static_cast(parsed); +} + +double env_double(const char* name, double fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const double parsed = std::strtod(value, &end); + if (end == value || (end && *end != '\0') || !std::isfinite(parsed)) + { + return fallback; + } + return parsed; +} + +float env_float(const char* name, float fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const float parsed = std::strtof(value, &end); + if (end == value || (end && *end != '\0') || !std::isfinite(parsed)) + { + return fallback; + } + return parsed; +} + +const char* env_string(const char* name, const char* fallback) +{ + const char* value = std::getenv(name); + return (value && value[0] != '\0') ? value : fallback; +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/platform/linux/runtime_paths.cpp b/platform/linux/common/src/platform/linux/runtime_paths.cpp new file mode 100644 index 00000000..21536103 --- /dev/null +++ b/platform/linux/common/src/platform/linux/runtime_paths.cpp @@ -0,0 +1,277 @@ +#include "platform/linux/runtime_paths.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace platform::linux_runtime +{ +namespace +{ + +std::string sanitize_component(const char* value) +{ + if (!value || value[0] == '\0') + { + return "default"; + } + + std::string out; + out.reserve(std::strlen(value)); + for (const unsigned char ch : std::string(value)) + { + if (std::isalnum(ch) || ch == '_' || ch == '-') + { + out.push_back(static_cast(ch)); + } + else + { + out.push_back('_'); + } + } + return out.empty() ? "default" : out; +} + +} // namespace + +RuntimePaths resolve_paths() +{ + RuntimePaths paths{}; + + // 1. Explicit overrides + const char* settings_env = std::getenv("TRAIL_MATE_SETTINGS_ROOT"); + const char* sd_env = std::getenv("TRAIL_MATE_SD_ROOT"); + const char* cache_env = std::getenv("TRAIL_MATE_CACHE_ROOT"); + + if (settings_env && settings_env[0] != '\0') + { + paths.settings_root = std::filesystem::path(settings_env); + } + if (sd_env && sd_env[0] != '\0') + { + paths.sd_root = std::filesystem::path(sd_env); + } + if (cache_env && cache_env[0] != '\0') + { + paths.cache_root = std::filesystem::path(cache_env); + } + + // 2. Platform defaults for settings_root (if still empty) + if (paths.settings_root.empty()) + { +#if defined(_WIN32) + if (const char* appdata = std::getenv("APPDATA")) + { + if (appdata[0] != '\0') + { + paths.settings_root = + std::filesystem::path(appdata) / "TrailMateCardputerZero"; + } + } +#endif + + if (paths.settings_root.empty()) + { + if (const char* home = std::getenv("HOME")) + { + if (home[0] != '\0') + { + paths.settings_root = + std::filesystem::path(home) / ".trailmate_cardputer_zero"; + } + } + } + + if (paths.settings_root.empty()) + { + paths.settings_root = + std::filesystem::current_path() / ".trailmate_cardputer_zero"; + } + } + + // 3. sd_root defaults to /sdcard + if (paths.sd_root.empty()) + { + paths.sd_root = paths.settings_root / "sdcard"; + } + + // 4. cache_root defaults to /cache + if (paths.cache_root.empty()) + { + paths.cache_root = paths.settings_root / "cache"; + } + + // 5. state_root = settings_root + paths.state_root = paths.settings_root; + + return paths; +} + +std::filesystem::path settings_file(const char* ns) +{ + const RuntimePaths paths = resolve_paths(); + return paths.settings_root / "settings" / + (sanitize_component(ns) + ".kv"); +} + +std::filesystem::path sd_child(std::string_view relative) +{ + const RuntimePaths paths = resolve_paths(); + return paths.sd_root / relative; +} + +bool ensure_directory(const std::filesystem::path& path) +{ + std::error_code ec; + std::filesystem::create_directories(path, ec); + return !ec; +} + +// --------------------------------------------------------------------------- +// Path safety +// --------------------------------------------------------------------------- + +bool resolve_child_under_root(const std::filesystem::path& root, + std::string_view child, + std::filesystem::path& out) +{ + out.clear(); + + if (child.empty()) + { + return false; + } + + // Reject absolute paths. + const std::filesystem::path child_path(child); + if (child_path.is_absolute()) + { + return false; + } + + // Reject ".." segments anywhere in the path. + for (const auto& part : child_path) + { + if (part == "..") + { + return false; + } + } + + // Resolve and check containment. + std::error_code ec; + const std::filesystem::path joined = root / child_path; + const std::filesystem::path canonical = + std::filesystem::weakly_canonical(joined, ec); + if (ec) + { + return false; + } + + const std::filesystem::path canonical_root = + std::filesystem::weakly_canonical(root, ec); + if (ec) + { + return false; + } + + // Ensure canonical starts with canonical_root. + auto root_it = canonical_root.begin(); + auto path_it = canonical.begin(); + for (; root_it != canonical_root.end() && path_it != canonical.end(); + ++root_it, ++path_it) + { + if (*root_it != *path_it) + { + return false; + } + } + if (root_it != canonical_root.end()) + { + return false; // canonical is shorter than root + } + + out = canonical; + return true; +} + +bool safe_remove_under_root(const std::filesystem::path& root, + std::string_view child) +{ + std::filesystem::path resolved; + if (!resolve_child_under_root(root, child, resolved)) + { + return false; + } + + std::error_code ec; + if (!std::filesystem::exists(resolved, ec)) + { + return true; // already gone + } + + std::filesystem::remove(resolved, ec); + return !ec; +} + +bool safe_write_under_root(const std::filesystem::path& root, + std::string_view child, + const std::string& content) +{ + std::filesystem::path resolved; + if (!resolve_child_under_root(root, child, resolved)) + { + return false; + } + + // Ensure parent directory exists. + std::error_code ec; + std::filesystem::create_directories(resolved.parent_path(), ec); + if (ec) + { + return false; + } + + // Write to temp file, close (which flushes user-space buffers), then + // atomically rename. On local filesystems rename is atomic; a full + // fsync / FlushFileBuffers can be added later when power-loss safety + // is required on real devices. + const std::filesystem::path temp_path = + resolved.string() + ".tmp"; + + { + std::ofstream stream(temp_path, + std::ios::binary | std::ios::trunc); + if (!stream.is_open()) + { + return false; + } + stream << content; + if (!stream.good()) + { + stream.close(); + std::filesystem::remove(temp_path, ec); + return false; + } + stream.close(); + } + + // Atomic rename. + std::filesystem::remove(resolved, ec); + ec.clear(); + std::filesystem::rename(temp_path, resolved, ec); + if (ec) + { + std::filesystem::remove(temp_path, ec); + return false; + } + + return true; +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/platform/surface_presenter.h b/platform/linux/common/src/platform/surface_presenter.h new file mode 100644 index 00000000..79736dfe --- /dev/null +++ b/platform/linux/common/src/platform/surface_presenter.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +#include "app/input_event.h" +#include "core/canvas.h" + +namespace trailmate::cardputer_zero::platform +{ + +class SurfacePresenter +{ + public: + virtual ~SurfacePresenter() = default; + + [[nodiscard]] virtual bool pump() = 0; + [[nodiscard]] virtual std::vector drainInput() = 0; + virtual void present(const core::Canvas& canvas) = 0; +}; + +} // namespace trailmate::cardputer_zero::platform diff --git a/platform/linux/common/src/platform/ui/device_runtime.cpp b/platform/linux/common/src/platform/ui/device_runtime.cpp new file mode 100644 index 00000000..85c5cfd6 --- /dev/null +++ b/platform/linux/common/src/platform/ui/device_runtime.cpp @@ -0,0 +1,202 @@ +#include "platform/ui/device_runtime.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "platform/linux/env_config.h" +#include "platform/linux/runtime_paths.h" + +namespace platform::ui::device +{ +namespace +{ + +using namespace ::platform::linux_runtime; + +constexpr const char* kFirmwareVersionEnv = "TRAIL_MATE_FIRMWARE_VERSION"; +constexpr const char* kBatteryLevelEnv = "TRAIL_MATE_BATTERY_LEVEL"; +constexpr const char* kBatteryChargingEnv = "TRAIL_MATE_BATTERY_CHARGING"; +constexpr const char* kSdRootEnv = "TRAIL_MATE_SD_ROOT"; +constexpr const char* kSettingsRootEnv = "TRAIL_MATE_SETTINGS_ROOT"; +constexpr const char* kGpsSupportedEnv = "TRAIL_MATE_GPS_SUPPORTED"; +constexpr const char* kGpsReadyEnv = "TRAIL_MATE_GPS_READY"; +constexpr const char* kPowerTierEnv = "TRAIL_MATE_POWER_TIER"; + +uint8_t s_screen_brightness = 0; +uint8_t s_message_tone_volume = 45; + +bool path_exists_from_env(const char* name) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return false; + } + std::error_code ec; + return std::filesystem::exists(std::filesystem::path(value), ec); +} + +bool ensure_storage_root() +{ + const auto paths = ::platform::linux_runtime::resolve_paths(); + return ::platform::linux_runtime::ensure_directory(paths.sd_root); +} + +#if defined(__linux__) +bool read_meminfo_kb(const char* label, std::size_t* out_kb) +{ + if (!label || !out_kb) + { + return false; + } + + std::ifstream stream("/proc/meminfo"); + if (!stream.is_open()) + { + return false; + } + + std::string key; + std::size_t value_kb = 0; + std::string unit; + while (stream >> key >> value_kb >> unit) + { + if (!key.empty() && key.back() == ':') + { + key.pop_back(); + } + if (key == label) + { + *out_kb = value_kb; + return true; + } + } + return false; +} +#endif + +} // namespace + +void delay_ms(uint32_t ms) +{ + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); +} + +void restart() +{ + std::fflush(nullptr); + std::exit(0); +} + +bool rtc_ready() +{ + return true; +} + +BatteryInfo battery_info() +{ + BatteryInfo info{}; + const int level = platform::linux_runtime::env_int(kBatteryLevelEnv, -1); + info.available = level >= 0; + info.level = level; + info.charging = platform::linux_runtime::env_flag(kBatteryChargingEnv); + return info; +} + +MemoryStats memory_stats() +{ + MemoryStats stats{}; + +#if defined(__linux__) + std::size_t mem_total_kb = 0; + std::size_t mem_available_kb = 0; + if (read_meminfo_kb("MemTotal", &mem_total_kb)) + { + stats.ram_total_bytes = mem_total_kb * 1024U; + } + if (read_meminfo_kb("MemAvailable", &mem_available_kb)) + { + stats.ram_free_bytes = mem_available_kb * 1024U; + } +#endif + + stats.psram_total_bytes = 0; + stats.psram_free_bytes = 0; + stats.psram_available = false; + return stats; +} + +const char* firmware_version() +{ + const char* configured = std::getenv(kFirmwareVersionEnv); + return (configured && configured[0] != '\0') ? configured : "linux-dev"; +} + +void handle_low_battery(const BatteryInfo&) +{ +} + +bool supports_screen_brightness() +{ + return false; +} + +uint8_t screen_brightness() +{ + return s_screen_brightness; +} + +void set_screen_brightness(uint8_t level) +{ + s_screen_brightness = level; +} + +void trigger_haptic() +{ +} + +uint8_t default_message_tone_volume() +{ + return s_message_tone_volume; +} + +void set_message_tone_volume(uint8_t volume_percent) +{ + s_message_tone_volume = volume_percent; +} + +void play_message_tone() +{ +} + +bool sd_ready() +{ + return path_exists_from_env(kSdRootEnv) || ensure_storage_root(); +} + +bool card_ready() +{ + return sd_ready(); +} + +bool gps_ready() +{ + return platform::linux_runtime::env_flag(kGpsReadyEnv, true); +} + +bool gps_supported() +{ + return platform::linux_runtime::env_flag(kGpsSupportedEnv, true); +} + +int power_tier() +{ + return platform::linux_runtime::env_int(kPowerTierEnv, 0); +} + +} // namespace platform::ui::device diff --git a/platform/linux/common/src/platform/ui/firmware_update_runtime.cpp b/platform/linux/common/src/platform/ui/firmware_update_runtime.cpp new file mode 100644 index 00000000..897a9603 --- /dev/null +++ b/platform/linux/common/src/platform/ui/firmware_update_runtime.cpp @@ -0,0 +1,37 @@ +#include "platform/ui/firmware_update_runtime.h" + +#include + +namespace platform::ui::firmware_update +{ + +bool is_supported() +{ + return false; +} + +Status status() +{ + Status out{}; + out.supported = false; + out.busy = false; + out.checked = false; + out.update_available = false; + out.direct_ota = false; + out.progress_percent = -1; + out.phase = Phase::Unsupported; + std::snprintf(out.message, sizeof(out.message), "%s", "Firmware update unsupported on Linux"); + return out; +} + +bool start_check() +{ + return false; +} + +bool start_install() +{ + return false; +} + +} // namespace platform::ui::firmware_update diff --git a/platform/linux/common/src/platform/ui/gps_runtime.cpp b/platform/linux/common/src/platform/ui/gps_runtime.cpp new file mode 100644 index 00000000..deb7fdb9 --- /dev/null +++ b/platform/linux/common/src/platform/ui/gps_runtime.cpp @@ -0,0 +1,1140 @@ +#include "platform/ui/gps_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#include +#endif + +namespace platform::ui::gps +{ +namespace +{ + +constexpr double kPi = 3.14159265358979323846; +constexpr double kMaxMercatorLat = 85.05112878; +constexpr double kEquatorResolution = 156543.03392; +constexpr size_t kMaxFields = 24; +constexpr size_t kLineBufferSize = 160; +constexpr uint32_t kExternalSourceStaleMs = 15000U; + +constexpr const char* kGpsValidEnv = "TRAIL_MATE_GPS_VALID"; +constexpr const char* kGpsEnabledEnv = "TRAIL_MATE_GPS_ENABLED"; +constexpr const char* kGpsPoweredEnv = "TRAIL_MATE_GPS_POWERED"; +constexpr const char* kGpsLatEnv = "TRAIL_MATE_GPS_LAT"; +constexpr const char* kGpsLngEnv = "TRAIL_MATE_GPS_LNG"; +constexpr const char* kGpsAltEnv = "TRAIL_MATE_GPS_ALT_M"; +constexpr const char* kGpsSpeedEnv = "TRAIL_MATE_GPS_SPEED_MPS"; +constexpr const char* kGpsCourseEnv = "TRAIL_MATE_GPS_COURSE_DEG"; +constexpr const char* kGpsSatCountEnv = "TRAIL_MATE_GPS_SATS"; +constexpr const char* kGpsHdopEnv = "TRAIL_MATE_GPS_HDOP"; +constexpr const char* kGpsFixEnv = "TRAIL_MATE_GPS_FIX"; +constexpr const char* kGpsDeviceEnv = "TRAIL_MATE_GPS_DEVICE"; +constexpr const char* kGpsBaudEnv = "TRAIL_MATE_GPS_BAUD"; +constexpr const char* kGpsNmeaFileEnv = "TRAIL_MATE_GPS_NMEA_FILE"; + +constexpr double kDefaultLat = 25.0389; +constexpr double kDefaultLng = 102.7183; +constexpr double kDefaultAltM = 1891.0; +constexpr double kDefaultSpeedMps = 0.4; +constexpr double kDefaultCourseDeg = 92.0; +constexpr uint8_t kDefaultSatCount = 9; +constexpr float kDefaultHdop = 0.9f; + +enum class CollectorSlot : size_t +{ + GPS = 0, + GLN, + GAL, + BD, + UNKNOWN, + COUNT +}; + +struct GsvCollector +{ + std::array sats{}; + std::size_t count = 0; +}; + +struct RuntimeState +{ + GpsState data{}; + std::array sats{}; + std::size_t sat_count = 0; + GnssStatus status{}; + uint32_t last_motion_ms = 0; + uint32_t last_rx_ms = 0; + uint32_t last_fix_ms = 0; + std::array used_sat_ids{}; + std::size_t used_sat_count = 0; + std::array(CollectorSlot::COUNT)> gsv{}; + + std::string source_path{}; + bool source_is_serial = false; + std::size_t file_offset = 0; + std::array line_buffer{}; + std::size_t line_length = 0; + +#if defined(__linux__) + int serial_fd = -1; +#endif +}; + +bool s_suspended = false; +bool s_enabled = true; +uint32_t s_collection_interval_ms = 1000; +uint8_t s_power_strategy = 0; +uint8_t s_gnss_mode = 0; +uint8_t s_gnss_sat_mask = 0xFF; +uint8_t s_external_nmea_output_hz = 0; +uint8_t s_external_nmea_sentence_mask = 0; +uint32_t s_motion_idle_timeout_ms = 30000; +uint8_t s_motion_sensor_id = 0; +RuntimeState s_runtime{}; +std::mutex s_mutex; + +uint32_t now_ms() +{ + using clock = std::chrono::steady_clock; + return static_cast( + std::chrono::duration_cast(clock::now().time_since_epoch()).count()); +} + +bool env_flag_or_default(const char* name, bool fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + return std::strcmp(value, "1") == 0 || std::strcmp(value, "true") == 0 || + std::strcmp(value, "TRUE") == 0 || std::strcmp(value, "yes") == 0 || + std::strcmp(value, "YES") == 0; +} + +double env_double_or_default(const char* name, double fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const double parsed = std::strtod(value, &end); + if (end == value || (end && *end != '\0')) + { + return fallback; + } + return parsed; +} + +int env_int_or_default(const char* name, int fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (end == value || (end && *end != '\0')) + { + return fallback; + } + return static_cast(parsed); +} + +::gps::GnssFix env_fix_or_default() +{ + switch (env_int_or_default(kGpsFixEnv, static_cast(::gps::GnssFix::FIX3D))) + { + case 2: + return ::gps::GnssFix::FIX2D; + case 3: + return ::gps::GnssFix::FIX3D; + case 0: + default: + return ::gps::GnssFix::NOFIX; + } +} + +std::array default_satellites() +{ + return {{ + {.id = 3, .sys = ::gps::GnssSystem::GPS, .azimuth = 18, .elevation = 74, .snr = 42, .used = true}, + {.id = 8, .sys = ::gps::GnssSystem::GPS, .azimuth = 84, .elevation = 58, .snr = 39, .used = true}, + {.id = 14, .sys = ::gps::GnssSystem::GPS, .azimuth = 156, .elevation = 41, .snr = 35, .used = true}, + {.id = 22, .sys = ::gps::GnssSystem::GPS, .azimuth = 286, .elevation = 49, .snr = 31, .used = true}, + {.id = 7, .sys = ::gps::GnssSystem::GLN, .azimuth = 312, .elevation = 68, .snr = 37, .used = true}, + {.id = 19, .sys = ::gps::GnssSystem::GAL, .azimuth = 218, .elevation = 55, .snr = 33, .used = true}, + {.id = 31, .sys = ::gps::GnssSystem::BD, .azimuth = 118, .elevation = 36, .snr = 29, .used = true}, + {.id = 35, .sys = ::gps::GnssSystem::BD, .azimuth = 246, .elevation = 28, .snr = 24, .used = false}, + {.id = 12, .sys = ::gps::GnssSystem::GAL, .azimuth = 35, .elevation = 22, .snr = 21, .used = false}, + {.id = 5, .sys = ::gps::GnssSystem::GLN, .azimuth = 172, .elevation = 16, .snr = 18, .used = false}, + }}; +} + +bool runtime_active() +{ + return !s_suspended && s_enabled && env_flag_or_default(kGpsEnabledEnv, true) && + env_flag_or_default(kGpsPoweredEnv, true); +} + +GpsState make_default_state() +{ + GpsState state{}; + state.lat = env_double_or_default(kGpsLatEnv, kDefaultLat); + state.lng = env_double_or_default(kGpsLngEnv, kDefaultLng); + state.alt_m = env_double_or_default(kGpsAltEnv, kDefaultAltM); + state.speed_mps = env_double_or_default(kGpsSpeedEnv, kDefaultSpeedMps); + state.course_deg = env_double_or_default(kGpsCourseEnv, kDefaultCourseDeg); + state.satellites = static_cast( + std::clamp(env_int_or_default(kGpsSatCountEnv, kDefaultSatCount), 0, 32)); + state.valid = env_flag_or_default(kGpsValidEnv, true); + state.has_alt = true; + state.has_speed = true; + state.has_course = true; + state.age = 0; + return state; +} + +GnssStatus make_default_status() +{ + GnssStatus status{}; + status.sats_in_use = static_cast( + std::clamp(env_int_or_default(kGpsSatCountEnv, kDefaultSatCount), 0, 32)); + status.sats_in_view = static_cast(default_satellites().size()); + status.hdop = static_cast(env_double_or_default(kGpsHdopEnv, kDefaultHdop)); + status.fix = env_fix_or_default(); + return status; +} + +void clear_payload_locked() +{ + s_runtime.data = GpsState{}; + s_runtime.status = GnssStatus{}; + s_runtime.sat_count = 0; + s_runtime.used_sat_count = 0; + s_runtime.last_rx_ms = 0; + s_runtime.last_fix_ms = 0; + for (auto& collector : s_runtime.gsv) + { + collector = GsvCollector{}; + } +} + +CollectorSlot collector_slot_for_talker(const char* talker) +{ + if (!talker || talker[0] == '\0' || talker[1] == '\0') + { + return CollectorSlot::UNKNOWN; + } + if (talker[0] == 'G' && talker[1] == 'P') + { + return CollectorSlot::GPS; + } + if (talker[0] == 'G' && talker[1] == 'L') + { + return CollectorSlot::GLN; + } + if (talker[0] == 'G' && talker[1] == 'A') + { + return CollectorSlot::GAL; + } + if ((talker[0] == 'G' && talker[1] == 'B') || (talker[0] == 'B' && talker[1] == 'D')) + { + return CollectorSlot::BD; + } + return CollectorSlot::UNKNOWN; +} + +::gps::GnssSystem system_for_slot(CollectorSlot slot) +{ + switch (slot) + { + case CollectorSlot::GPS: + return ::gps::GnssSystem::GPS; + case CollectorSlot::GLN: + return ::gps::GnssSystem::GLN; + case CollectorSlot::GAL: + return ::gps::GnssSystem::GAL; + case CollectorSlot::BD: + return ::gps::GnssSystem::BD; + default: + return ::gps::GnssSystem::UNKNOWN; + } +} + +bool parse_uint(const char* text, uint32_t* out) +{ + if (!text || !*text || !out) + { + return false; + } + char* end = nullptr; + const unsigned long value = std::strtoul(text, &end, 10); + if (end == text) + { + return false; + } + *out = static_cast(value); + return true; +} + +bool parse_int(const char* text, int* out) +{ + if (!text || !*text || !out) + { + return false; + } + char* end = nullptr; + const long value = std::strtol(text, &end, 10); + if (end == text) + { + return false; + } + *out = static_cast(value); + return true; +} + +bool parse_double(const char* text, double* out) +{ + if (!text || !*text || !out) + { + return false; + } + char* end = nullptr; + const double value = std::strtod(text, &end); + if (end == text) + { + return false; + } + *out = value; + return true; +} + +bool parse_latlon(const char* value_text, const char* hemi_text, bool latitude, double* out) +{ + if (!value_text || !*value_text || !hemi_text || !*hemi_text || !out) + { + return false; + } + double raw = 0.0; + if (!parse_double(value_text, &raw)) + { + return false; + } + const int degrees = static_cast(raw / 100.0); + const double minutes = raw - (static_cast(degrees) * 100.0); + double decimal = static_cast(degrees) + (minutes / 60.0); + const char hemi = hemi_text[0]; + if (hemi == 'S' || hemi == 'W') + { + decimal = -decimal; + } + else if ((latitude && hemi != 'N') || (!latitude && hemi != 'E')) + { + return false; + } + *out = decimal; + return true; +} + +bool verify_checksum(const char* line) +{ + if (!line || line[0] != '$') + { + return false; + } + const char* star = std::strchr(line, '*'); + if (!star || !star[1] || !star[2]) + { + return true; + } + uint8_t checksum = 0; + for (const char* cursor = line + 1; cursor < star; ++cursor) + { + checksum ^= static_cast(*cursor); + } + char text[3] = {star[1], star[2], '\0'}; + char* end = nullptr; + const long expected = std::strtol(text, &end, 16); + return end != text && checksum == static_cast(expected & 0xFF); +} + +std::size_t split_fields(char* sentence, std::array& fields) +{ + fields.fill(nullptr); + std::size_t count = 0; + char* cursor = sentence; + while (cursor && *cursor && count < fields.size()) + { + fields[count++] = cursor; + char* comma = std::strchr(cursor, ','); + if (!comma) + { + break; + } + *comma = '\0'; + cursor = comma + 1; + } + return count; +} + +bool used_sat_locked(uint16_t sat_id) +{ + for (std::size_t i = 0; i < s_runtime.used_sat_count; ++i) + { + if (s_runtime.used_sat_ids[i] == sat_id) + { + return true; + } + } + return false; +} + +void merge_sats_locked() +{ + s_runtime.sat_count = 0; + for (std::size_t ci = 0; ci < s_runtime.gsv.size(); ++ci) + { + auto& collector = s_runtime.gsv[ci]; + for (std::size_t si = 0; si < collector.count && s_runtime.sat_count < s_runtime.sats.size(); ++si) + { + auto sat = collector.sats[si]; + sat.used = used_sat_locked(sat.id); + s_runtime.sats[s_runtime.sat_count++] = sat; + } + } + s_runtime.status.sats_in_view = static_cast(std::min(s_runtime.sat_count, 255)); +} + +void parse_rmc_locked(const std::array& fields, std::size_t count, uint32_t ts) +{ + if (count < 9) + { + return; + } + + const bool active = fields[2] && fields[2][0] == 'A'; + s_runtime.data.valid = active; + if (!active) + { + return; + } + + double lat = 0.0; + double lng = 0.0; + double speed_knots = 0.0; + double course = 0.0; + + if (parse_latlon(fields[3], fields[4], true, &lat)) + { + s_runtime.data.lat = lat; + } + if (parse_latlon(fields[5], fields[6], false, &lng)) + { + s_runtime.data.lng = lng; + } + if (parse_double(fields[7], &speed_knots)) + { + s_runtime.data.speed_mps = speed_knots * 0.514444; + s_runtime.data.has_speed = true; + } + else + { + s_runtime.data.has_speed = false; + } + if (parse_double(fields[8], &course)) + { + s_runtime.data.course_deg = course; + s_runtime.data.has_course = true; + } + else + { + s_runtime.data.has_course = false; + } + + s_runtime.last_fix_ms = ts; + s_runtime.last_motion_ms = ts; +} + +void parse_gga_locked(const std::array& fields, std::size_t count, uint32_t ts) +{ + if (count < 10) + { + return; + } + + int quality = 0; + if (parse_int(fields[6], &quality)) + { + s_runtime.data.valid = quality > 0; + } + + uint32_t satellites = 0; + if (parse_uint(fields[7], &satellites)) + { + s_runtime.data.satellites = static_cast(std::min(satellites, 255)); + s_runtime.status.sats_in_use = s_runtime.data.satellites; + } + + double hdop = 0.0; + double altitude = 0.0; + if (parse_double(fields[8], &hdop)) + { + s_runtime.status.hdop = static_cast(hdop); + } + if (parse_double(fields[9], &altitude)) + { + s_runtime.data.alt_m = altitude; + s_runtime.data.has_alt = true; + } + else + { + s_runtime.data.has_alt = false; + } + + if (s_runtime.data.valid) + { + s_runtime.last_fix_ms = ts; + s_runtime.last_motion_ms = ts; + } +} + +void parse_gsa_locked(const std::array& fields, std::size_t count) +{ + if (count < 4) + { + return; + } + + s_runtime.used_sat_count = 0; + for (std::size_t i = 3; i <= 14 && i < count; ++i) + { + uint32_t sat_id = 0; + if (parse_uint(fields[i], &sat_id) && sat_id > 0 && + s_runtime.used_sat_count < s_runtime.used_sat_ids.size()) + { + s_runtime.used_sat_ids[s_runtime.used_sat_count++] = static_cast(sat_id); + } + } + + int fix_type = 0; + if (parse_int(fields[2], &fix_type)) + { + s_runtime.status.fix = fix_type >= 3 ? ::gps::GnssFix::FIX3D + : (fix_type == 2 ? ::gps::GnssFix::FIX2D + : ::gps::GnssFix::NOFIX); + } + if (count > 16) + { + double hdop = 0.0; + if (parse_double(fields[16], &hdop)) + { + s_runtime.status.hdop = static_cast(hdop); + } + } + + s_runtime.status.sats_in_use = + static_cast(std::min(s_runtime.used_sat_count, 255)); + merge_sats_locked(); +} + +void parse_gsv_locked(const char* talker, const std::array& fields, std::size_t count) +{ + if (count < 4) + { + return; + } + + auto& collector = + s_runtime.gsv[static_cast(collector_slot_for_talker(talker))]; + uint32_t msg_num = 0; + if (!parse_uint(fields[2], &msg_num)) + { + return; + } + if (msg_num == 1) + { + collector.count = 0; + } + + for (std::size_t base = 4; base + 3 < count && collector.count < collector.sats.size(); base += 4) + { + uint32_t sat_id = 0; + if (!parse_uint(fields[base], &sat_id) || sat_id == 0) + { + continue; + } + GnssSatInfo sat{}; + sat.id = static_cast(sat_id); + sat.sys = system_for_slot(collector_slot_for_talker(talker)); + uint32_t elev = 0; + uint32_t az = 0; + int snr = -1; + if (parse_uint(fields[base + 1], &elev)) + { + sat.elevation = static_cast(std::min(elev, 90)); + } + if (parse_uint(fields[base + 2], &az)) + { + sat.azimuth = static_cast(std::min(az, 359)); + } + if (parse_int(fields[base + 3], &snr)) + { + sat.snr = static_cast(std::clamp(snr, -1, 99)); + } + collector.sats[collector.count++] = sat; + } + + merge_sats_locked(); +} + +void parse_sentence_locked(const char* sentence, uint32_t ts) +{ + if (!sentence || sentence[0] != '$' || !verify_checksum(sentence)) + { + return; + } + + char working[kLineBufferSize] = {}; + std::strncpy(working, sentence + 1, sizeof(working) - 1); + char* star = std::strchr(working, '*'); + if (star) + { + *star = '\0'; + } + + std::array fields{}; + const std::size_t count = split_fields(working, fields); + if (count == 0 || !fields[0] || std::strlen(fields[0]) < 5) + { + return; + } + + char talker[3] = {fields[0][0], fields[0][1], '\0'}; + const char* type = fields[0] + std::strlen(fields[0]) - 3; + s_runtime.last_rx_ms = ts; + + if (std::strcmp(type, "RMC") == 0) + { + parse_rmc_locked(fields, count, ts); + } + else if (std::strcmp(type, "GGA") == 0) + { + parse_gga_locked(fields, count, ts); + } + else if (std::strcmp(type, "GSA") == 0) + { + parse_gsa_locked(fields, count); + } + else if (std::strcmp(type, "GSV") == 0) + { + parse_gsv_locked(talker, fields, count); + } +} + +void append_bytes_and_process_locked(const char* buffer, std::size_t length) +{ + if (!buffer || length == 0) + { + return; + } + + for (std::size_t i = 0; i < length; ++i) + { + const char ch = buffer[i]; + if (ch == '\r' || ch == '\n') + { + if (s_runtime.line_length > 0) + { + s_runtime.line_buffer[s_runtime.line_length] = '\0'; + parse_sentence_locked(s_runtime.line_buffer.data(), now_ms()); + s_runtime.line_length = 0; + } + continue; + } + + if (s_runtime.line_length == 0 && ch != '$') + { + continue; + } + + if (s_runtime.line_length + 1 < s_runtime.line_buffer.size()) + { + s_runtime.line_buffer[s_runtime.line_length++] = ch; + } + else + { + s_runtime.line_length = 0; + } + } +} + +std::string requested_source_path(bool* out_is_serial) +{ + if (out_is_serial) + { + *out_is_serial = false; + } + + const char* device = std::getenv(kGpsDeviceEnv); + if (device && device[0] != '\0') + { + if (out_is_serial) + { + *out_is_serial = true; + } + return std::string(device); + } + + const char* file = std::getenv(kGpsNmeaFileEnv); + if (file && file[0] != '\0') + { + return std::string(file); + } + + return {}; +} + +#if defined(__linux__) +speed_t baud_to_termios(int baud) +{ + switch (baud) + { + case 9600: + return B9600; + case 19200: + return B19200; + case 57600: + return B57600; + case 115200: + return B115200; + case 38400: + default: + return B38400; + } +} + +void close_serial_locked() +{ + if (s_runtime.serial_fd >= 0) + { + close(s_runtime.serial_fd); + s_runtime.serial_fd = -1; + } +} + +bool open_serial_locked(const std::string& path) +{ + close_serial_locked(); + + const int fd = open(path.c_str(), O_RDONLY | O_NOCTTY | O_NONBLOCK); + if (fd < 0) + { + return false; + } + + termios tio{}; + if (tcgetattr(fd, &tio) != 0) + { + close(fd); + return false; + } + + cfmakeraw(&tio); + const speed_t baud = baud_to_termios(env_int_or_default(kGpsBaudEnv, 38400)); + cfsetispeed(&tio, baud); + cfsetospeed(&tio, baud); + tio.c_cflag |= (CLOCAL | CREAD); + if (tcsetattr(fd, TCSANOW, &tio) != 0) + { + close(fd); + return false; + } + + s_runtime.serial_fd = fd; + return true; +} +#endif + +void reset_source_locked() +{ +#if defined(__linux__) + close_serial_locked(); +#endif + s_runtime.file_offset = 0; + s_runtime.line_length = 0; + s_runtime.source_path.clear(); + s_runtime.source_is_serial = false; + clear_payload_locked(); +} + +void ensure_source_open_locked() +{ + bool requested_serial = false; + const std::string requested_path = requested_source_path(&requested_serial); + if (requested_path.empty()) + { + if (!s_runtime.source_path.empty()) + { + reset_source_locked(); + } + return; + } + + const bool source_changed = + requested_path != s_runtime.source_path || requested_serial != s_runtime.source_is_serial; + if (source_changed) + { + reset_source_locked(); + s_runtime.source_path = requested_path; + s_runtime.source_is_serial = requested_serial; +#if defined(__linux__) + if (requested_serial) + { + if (!open_serial_locked(requested_path)) + { + s_runtime.source_path.clear(); + s_runtime.source_is_serial = false; + } + } +#else + if (requested_serial) + { + s_runtime.source_path.clear(); + s_runtime.source_is_serial = false; + } +#endif + } +} + +void poll_file_source_locked() +{ + if (s_runtime.source_path.empty()) + { + return; + } + + std::ifstream stream(s_runtime.source_path, std::ios::binary); + if (!stream.is_open()) + { + return; + } + + stream.seekg(0, std::ios::end); + const std::streamoff size = stream.tellg(); + if (size < 0) + { + return; + } + if (static_cast(s_runtime.file_offset) > size) + { + s_runtime.file_offset = 0; + } + stream.seekg(static_cast(s_runtime.file_offset), std::ios::beg); + + char buffer[256]; + while (stream.good()) + { + stream.read(buffer, static_cast(sizeof(buffer))); + const std::streamsize got = stream.gcount(); + if (got <= 0) + { + break; + } + append_bytes_and_process_locked(buffer, static_cast(got)); + s_runtime.file_offset += static_cast(got); + } +} + +#if defined(__linux__) +void poll_serial_source_locked() +{ + if (s_runtime.serial_fd < 0) + { + return; + } + + char buffer[256]; + for (;;) + { + const ssize_t got = read(s_runtime.serial_fd, buffer, sizeof(buffer)); + if (got > 0) + { + append_bytes_and_process_locked(buffer, static_cast(got)); + continue; + } + if (got == 0) + { + break; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) + { + break; + } + close_serial_locked(); + break; + } +} +#endif + +void poll_external_source_locked() +{ + ensure_source_open_locked(); + if (s_runtime.source_path.empty()) + { + return; + } + + if (s_runtime.source_is_serial) + { +#if defined(__linux__) + poll_serial_source_locked(); +#endif + return; + } + + poll_file_source_locked(); +} + +bool external_source_active_locked() +{ + return !s_runtime.source_path.empty(); +} + +GpsState make_external_state_locked() +{ + GpsState state = s_runtime.data; + if (s_runtime.last_fix_ms != 0) + { + state.age = now_ms() - s_runtime.last_fix_ms; + if (state.age > kExternalSourceStaleMs) + { + state.valid = false; + } + } + return state; +} + +} // namespace + +GpsState get_data() +{ + if (!runtime_active()) + { + return {}; + } + + std::lock_guard lock(s_mutex); + poll_external_source_locked(); + + if (external_source_active_locked()) + { + return make_external_state_locked(); + } + + if (s_runtime.last_motion_ms == 0) + { + s_runtime.last_motion_ms = now_ms(); + } + return make_default_state(); +} + +bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count, GnssStatus* status) +{ + if (!runtime_active()) + { + if (out_count) + { + *out_count = 0; + } + if (status) + { + *status = GnssStatus{}; + } + return false; + } + + std::lock_guard lock(s_mutex); + poll_external_source_locked(); + + if (external_source_active_locked()) + { + const bool stale = + s_runtime.last_rx_ms == 0 || (now_ms() - s_runtime.last_rx_ms) > kExternalSourceStaleMs; + if (stale) + { + if (out_count) + { + *out_count = 0; + } + if (status) + { + *status = GnssStatus{}; + } + return false; + } + + const std::size_t count = std::min(max, s_runtime.sat_count); + if (out && count > 0) + { + std::copy_n(s_runtime.sats.begin(), count, out); + } + if (out_count) + { + *out_count = count; + } + if (status) + { + *status = s_runtime.status; + } + if (s_runtime.last_motion_ms == 0) + { + s_runtime.last_motion_ms = now_ms(); + } + return true; + } + + const auto satellites = default_satellites(); + const std::size_t count = std::min(max, satellites.size()); + if (out && count > 0) + { + std::copy_n(satellites.begin(), count, out); + } + if (out_count) + { + *out_count = count; + } + if (status) + { + *status = make_default_status(); + } + if (s_runtime.last_motion_ms == 0) + { + s_runtime.last_motion_ms = now_ms(); + } + return true; +} + +uint32_t last_motion_ms() +{ + if (!runtime_active()) + { + return 0; + } + + std::lock_guard lock(s_mutex); + if (external_source_active_locked()) + { + poll_external_source_locked(); + return s_runtime.last_motion_ms; + } + + if (s_runtime.last_motion_ms == 0) + { + s_runtime.last_motion_ms = now_ms(); + } + return s_runtime.last_motion_ms; +} + +bool is_enabled() +{ + return !s_suspended && s_enabled && env_flag_or_default(kGpsEnabledEnv, true); +} + +bool is_powered() +{ + return !s_suspended && s_enabled && env_flag_or_default(kGpsPoweredEnv, true); +} + +void set_enabled(bool enabled) +{ + s_enabled = enabled; + if (!enabled) + { + std::lock_guard lock(s_mutex); + clear_payload_locked(); + } +} + +void set_collection_interval(uint32_t interval_ms) +{ + s_collection_interval_ms = interval_ms; +} + +void set_power_strategy(uint8_t strategy) +{ + s_power_strategy = strategy; +} + +void set_gnss_config(uint8_t mode, uint8_t sat_mask) +{ + s_gnss_mode = mode; + s_gnss_sat_mask = sat_mask; +} + +void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) +{ + s_external_nmea_output_hz = output_hz; + s_external_nmea_sentence_mask = sentence_mask; +} + +void set_motion_idle_timeout(uint32_t timeout_ms) +{ + s_motion_idle_timeout_ms = timeout_ms; +} + +void set_motion_sensor_id(uint8_t sensor_id) +{ + s_motion_sensor_id = sensor_id; +} + +void suspend_runtime() +{ + s_suspended = true; +} + +void resume_runtime() +{ + s_suspended = false; + std::lock_guard lock(s_mutex); + s_runtime.last_motion_ms = now_ms(); +} + +double calculate_map_resolution(int zoom, double lat) +{ + double clamped_lat = lat; + if (clamped_lat > kMaxMercatorLat) + { + clamped_lat = kMaxMercatorLat; + } + else if (clamped_lat < -kMaxMercatorLat) + { + clamped_lat = -kMaxMercatorLat; + } + + const double zoom_scale = std::pow(2.0, static_cast(zoom)); + const double lat_rad = clamped_lat * kPi / 180.0; + return (kEquatorResolution / zoom_scale) * std::cos(lat_rad); +} + +} // namespace platform::ui::gps diff --git a/platform/linux/common/src/platform/ui/hostlink_runtime.cpp b/platform/linux/common/src/platform/ui/hostlink_runtime.cpp new file mode 100644 index 00000000..af1714ff --- /dev/null +++ b/platform/linux/common/src/platform/ui/hostlink_runtime.cpp @@ -0,0 +1,527 @@ +#include "platform/ui/hostlink_runtime.h" + +#include "hostlink/hostlink_session.h" + +#include +#include +#include +#include +#include +#include + +#include "platform/linux/runtime_paths.h" +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace platform::ui::hostlink +{ +namespace +{ + +constexpr const char* kBindAddressEnv = "TRAIL_MATE_HOSTLINK_BIND"; +constexpr const char* kPortEnv = "TRAIL_MATE_HOSTLINK_PORT"; +constexpr const char* kSdRootEnv = "TRAIL_MATE_SD_ROOT"; +constexpr const char* kSettingsRootEnv = "TRAIL_MATE_SETTINGS_ROOT"; + +constexpr uint16_t kDefaultPort = 44042; +constexpr uint16_t kFallbackPortCount = 8; +constexpr uint32_t kHandshakeTimeoutMs = 5000; +constexpr uint32_t kHeartbeatIntervalMs = 2000; +constexpr uint32_t kPollIntervalMs = 100; + +#if defined(_WIN32) +using SocketHandle = SOCKET; +constexpr SocketHandle kInvalidSocket = INVALID_SOCKET; +#else +using SocketHandle = int; +constexpr SocketHandle kInvalidSocket = -1; +#endif + +std::mutex s_mutex; +std::thread s_worker; +std::atomic_bool s_running = false; +::hostlink::SessionRuntime s_runtime{}; +SocketHandle s_listen_socket = kInvalidSocket; +SocketHandle s_client_socket = kInvalidSocket; +std::string s_endpoint{}; +std::string s_endpoint_file{}; + +uint32_t now_ms() +{ + using clock = std::chrono::steady_clock; + return static_cast( + std::chrono::duration_cast(clock::now().time_since_epoch()).count()); +} + +std::filesystem::path hostlink_dir() +{ + return ::platform::linux_runtime::resolve_paths().sd_root / "hostlink"; +} + +bool ensure_hostlink_dir() +{ + return ::platform::linux_runtime::ensure_directory(hostlink_dir()); +} + +uint16_t configured_port() +{ + const char* value = std::getenv(kPortEnv); + if (!value || value[0] == '\0') + { + return kDefaultPort; + } + + char* end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (end == value || (end && *end != '\0') || parsed <= 0 || parsed > 65535) + { + return kDefaultPort; + } + return static_cast(parsed); +} + +void write_endpoint_file(const std::string& endpoint) +{ + if (!ensure_hostlink_dir()) + { + return; + } + + const std::filesystem::path endpoint_path = hostlink_dir() / "endpoint.txt"; + std::ofstream stream(endpoint_path, std::ios::trunc); + if (!stream.is_open()) + { + return; + } + stream << endpoint << "\n"; + stream.close(); + + std::lock_guard lock(s_mutex); + s_endpoint_file = endpoint_path.string(); +} + +void clear_endpoint_file() +{ + std::error_code ec; + std::filesystem::remove(hostlink_dir() / "endpoint.txt", ec); +} + +void close_socket(SocketHandle& socket_handle) +{ + if (socket_handle == kInvalidSocket) + { + return; + } +#if defined(_WIN32) + closesocket(socket_handle); +#else + close(socket_handle); +#endif + socket_handle = kInvalidSocket; +} + +int socket_error_code() +{ +#if defined(_WIN32) + return WSAGetLastError(); +#else + return errno; +#endif +} + +bool socket_would_block(int error_code) +{ +#if defined(_WIN32) + return error_code == WSAEWOULDBLOCK; +#else + return error_code == EWOULDBLOCK || error_code == EAGAIN; +#endif +} + +void set_socket_nonblocking(SocketHandle socket_handle) +{ +#if defined(_WIN32) + u_long mode = 1; + ioctlsocket(socket_handle, FIONBIO, &mode); +#else + const int current_flags = fcntl(socket_handle, F_GETFL, 0); + if (current_flags >= 0) + { + (void)fcntl(socket_handle, F_SETFL, current_flags | O_NONBLOCK); + } +#endif +} + +bool wait_for_readable(SocketHandle socket_handle, uint32_t timeout_ms) +{ + if (socket_handle == kInvalidSocket) + { + return false; + } + + fd_set read_set; + FD_ZERO(&read_set); + FD_SET(socket_handle, &read_set); + + timeval timeout{}; + timeout.tv_sec = static_cast(timeout_ms / 1000U); + timeout.tv_usec = static_cast((timeout_ms % 1000U) * 1000U); + +#if defined(_WIN32) + const int ready = select(0, &read_set, nullptr, nullptr, &timeout); +#else + const int ready = select(socket_handle + 1, &read_set, nullptr, nullptr, &timeout); +#endif + return ready > 0 && FD_ISSET(socket_handle, &read_set); +} + +#if defined(_WIN32) +bool ensure_winsock() +{ + static std::once_flag s_once; + static bool s_ok = false; + std::call_once(s_once, + []() + { + WSADATA data{}; + s_ok = (WSAStartup(MAKEWORD(2, 2), &data) == 0); + }); + return s_ok; +} +#endif + +void set_error_state(uint32_t error_code) +{ + std::lock_guard lock(s_mutex); + ::hostlink::set_link_state(s_runtime, ::hostlink::LinkState::Error); + ::hostlink::note_error(s_runtime, error_code); +} + +SocketHandle open_listen_socket(std::string& endpoint_out) +{ +#if defined(_WIN32) + if (!ensure_winsock()) + { + set_error_state(10001U); + return kInvalidSocket; + } +#endif + + // Default to loopback for safety. Use TRAIL_MATE_HOSTLINK_BIND=0.0.0.0 + // to explicitly enable external access on real devices. + const char* bind_addr = std::getenv(kBindAddressEnv); + const std::string bind_address = (bind_addr && bind_addr[0] != '\0') + ? std::string(bind_addr) + : std::string("127.0.0.1"); + const uint16_t base_port = configured_port(); + + for (uint16_t offset = 0; offset < kFallbackPortCount; ++offset) + { + const uint16_t port = static_cast(base_port + offset); + SocketHandle listen_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listen_socket == kInvalidSocket) + { + continue; + } + + int reuse_addr = 1; + setsockopt(listen_socket, + SOL_SOCKET, + SO_REUSEADDR, +#if defined(_WIN32) + reinterpret_cast(&reuse_addr), +#else + &reuse_addr, +#endif + sizeof(reuse_addr)); + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = htons(port); + if (inet_pton(AF_INET, bind_address.c_str(), &address.sin_addr) != 1) + { + close_socket(listen_socket); + set_error_state(10002U); + return kInvalidSocket; + } + + if (bind(listen_socket, reinterpret_cast(&address), sizeof(address)) != 0) + { + close_socket(listen_socket); + continue; + } + + if (listen(listen_socket, 1) != 0) + { + close_socket(listen_socket); + continue; + } + + set_socket_nonblocking(listen_socket); + endpoint_out = "tcp://" + bind_address + ":" + std::to_string(port); + return listen_socket; + } + + set_error_state(10003U); + return kInvalidSocket; +} + +void disconnect_client() +{ + std::lock_guard lock(s_mutex); + close_socket(s_client_socket); + ::hostlink::mark_disconnected(s_runtime); +} + +void send_status_line(SocketHandle client_socket, const char* line) +{ + if (client_socket == kInvalidSocket || !line) + { + return; + } + + const int sent = send(client_socket, line, static_cast(std::strlen(line)), 0); + if (sent > 0) + { + std::lock_guard lock(s_mutex); + ::hostlink::note_tx(s_runtime); + } +} + +void accept_client_if_available(SocketHandle listen_socket) +{ + if (!wait_for_readable(listen_socket, kPollIntervalMs)) + { + return; + } + + sockaddr_in remote_addr{}; +#if defined(_WIN32) + int remote_len = sizeof(remote_addr); +#else + socklen_t remote_len = sizeof(remote_addr); +#endif + SocketHandle client_socket = + accept(listen_socket, reinterpret_cast(&remote_addr), &remote_len); + if (client_socket == kInvalidSocket) + { + return; + } + + set_socket_nonblocking(client_socket); + { + std::lock_guard lock(s_mutex); + s_client_socket = client_socket; + ::hostlink::set_link_state(s_runtime, ::hostlink::LinkState::Connected); + ::hostlink::mark_handshake_started(s_runtime, now_ms(), kHandshakeTimeoutMs); + } + + send_status_line(client_socket, "TRAILMATE_HOSTLINK/1 READY\n"); +} + +void service_client() +{ + SocketHandle client_socket = kInvalidSocket; + { + std::lock_guard lock(s_mutex); + client_socket = s_client_socket; + if (client_socket == kInvalidSocket) + { + return; + } + + if (::hostlink::handshake_expired(s_runtime, now_ms())) + { + close_socket(s_client_socket); + return; + } + } + + if (!wait_for_readable(client_socket, kPollIntervalMs)) + { + const uint32_t tick_ms = now_ms(); + std::lock_guard lock(s_mutex); + if (::hostlink::should_emit_status(s_runtime, tick_ms, kHeartbeatIntervalMs) && + s_client_socket != kInvalidSocket) + { + const Status snapshot = s_runtime.status; + std::string heartbeat = "STATUS rx=" + std::to_string(snapshot.rx_count) + + " tx=" + std::to_string(snapshot.tx_count) + "\n"; + const int sent = send(s_client_socket, + heartbeat.c_str(), + static_cast(heartbeat.size()), + 0); + if (sent > 0) + { + ::hostlink::note_tx(s_runtime); + ::hostlink::mark_status_emitted(s_runtime, tick_ms); + } + } + return; + } + + char buffer[256]; + const int received = recv(client_socket, buffer, sizeof(buffer), 0); + if (received == 0) + { + disconnect_client(); + return; + } + + if (received < 0) + { + const int error_code = socket_error_code(); + if (!socket_would_block(error_code)) + { + set_error_state(static_cast(error_code)); + disconnect_client(); + } + return; + } + + bool handshake_completed = false; + { + std::lock_guard lock(s_mutex); + ::hostlink::note_rx(s_runtime); + if (::hostlink::is_handshaking(s_runtime)) + { + ::hostlink::mark_handshake_complete(s_runtime, now_ms()); + handshake_completed = true; + } + } + + if (handshake_completed) + { + send_status_line(client_socket, "TRAILMATE_HOSTLINK/1 OK\n"); + return; + } + + const int sent = send(client_socket, buffer, received, 0); + if (sent > 0) + { + std::lock_guard lock(s_mutex); + ::hostlink::note_tx(s_runtime); + } +} + +void worker_main() +{ + std::string endpoint; + SocketHandle listen_socket = open_listen_socket(endpoint); + if (listen_socket == kInvalidSocket) + { + s_running.store(false); + return; + } + + { + std::lock_guard lock(s_mutex); + s_listen_socket = listen_socket; + s_endpoint = endpoint; + } + write_endpoint_file(endpoint); + + while (s_running.load()) + { + SocketHandle active_client = kInvalidSocket; + { + std::lock_guard lock(s_mutex); + active_client = s_client_socket; + } + + if (active_client == kInvalidSocket) + { + accept_client_if_available(listen_socket); + continue; + } + + service_client(); + } + + { + std::lock_guard lock(s_mutex); + close_socket(s_client_socket); + close_socket(s_listen_socket); + ::hostlink::stop_session(s_runtime); + s_endpoint.clear(); + } + clear_endpoint_file(); +} + +} // namespace + +bool is_supported() +{ + return ensure_hostlink_dir(); +} + +void start() +{ + if (s_running.exchange(true)) + { + return; + } + + if (s_worker.joinable()) + { + s_worker.join(); + } + + { + std::lock_guard lock(s_mutex); + ::hostlink::reset_session(s_runtime, now_ms()); + s_runtime.status.state = ::hostlink::LinkState::Waiting; + s_endpoint.clear(); + s_endpoint_file.clear(); + } + + s_worker = std::thread(worker_main); +} + +void stop() +{ + if (!s_running.exchange(false)) + { + std::lock_guard lock(s_mutex); + ::hostlink::stop_session(s_runtime); + return; + } + + { + std::lock_guard lock(s_mutex); + close_socket(s_client_socket); + close_socket(s_listen_socket); + } + + if (s_worker.joinable()) + { + s_worker.join(); + } +} + +bool is_active() +{ + return s_running.load(); +} + +Status get_status() +{ + std::lock_guard lock(s_mutex); + return s_runtime.status; +} + +} // namespace platform::ui::hostlink diff --git a/platform/linux/common/src/platform/ui/lora_runtime.cpp b/platform/linux/common/src/platform/ui/lora_runtime.cpp new file mode 100644 index 00000000..7b60c24c --- /dev/null +++ b/platform/linux/common/src/platform/ui/lora_runtime.cpp @@ -0,0 +1,157 @@ +#include "platform/ui/lora_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform/linux/capability_status.h" + +namespace platform::ui::lora +{ +namespace +{ + +using Clock = std::chrono::steady_clock; + +struct SpectralPeak +{ + float freq_mhz = 0.0f; + float peak_dbm = -120.0f; +}; + +constexpr const char* kNoiseFloorEnv = "TRAIL_MATE_LORA_NOISE_FLOOR_DBM"; +constexpr const char* kPrimaryPeakEnv = "TRAIL_MATE_LORA_PRIMARY_PEAK_MHZ"; +constexpr const char* kSecondaryPeakEnv = "TRAIL_MATE_LORA_SECONDARY_PEAK_MHZ"; + +constexpr std::array kDefaultPeaks{{ + {433.175f, -69.0f}, + {433.550f, -76.0f}, + {434.225f, -84.0f}, +}}; + +std::mutex s_mutex; +bool s_acquired = false; +bool s_configured = false; +Clock::time_point s_started_at = Clock::now(); +float s_freq_mhz = 0.0f; +ReceiveConfig s_config{}; + +float env_float_or_default(const char* name, float fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const float parsed = std::strtof(value, &end); + if (end == value || (end && *end != '\0') || !std::isfinite(parsed)) + { + return fallback; + } + return parsed; +} + +std::array configured_peaks() +{ + auto peaks = kDefaultPeaks; + peaks[0].freq_mhz = env_float_or_default(kPrimaryPeakEnv, peaks[0].freq_mhz); + peaks[1].freq_mhz = env_float_or_default(kSecondaryPeakEnv, peaks[1].freq_mhz); + return peaks; +} + +float gaussian_response(float center, float sample, float sigma) +{ + const float delta = sample - center; + return std::exp(-(delta * delta) / (2.0f * sigma * sigma)); +} + +float current_rssi_locked() +{ + if (!s_acquired || !s_configured) + { + return std::numeric_limits::quiet_NaN(); + } + + const float noise_floor = env_float_or_default(kNoiseFloorEnv, -119.5f); + const auto peaks = configured_peaks(); + const auto elapsed_ms = + std::chrono::duration_cast(Clock::now() - s_started_at).count(); + const float seconds = static_cast(elapsed_ms) / 1000.0f; + + float rssi = noise_floor + + std::sin(seconds * 1.13f + s_freq_mhz * 0.37f) * 2.7f + + std::cos(seconds * 0.49f + s_freq_mhz * 1.61f) * 1.5f; + + const float sigma = std::clamp((s_config.bw_khz / 1000.0f) * 0.34f, 0.045f, 0.18f); + for (std::size_t i = 0; i < peaks.size(); ++i) + { + const float response = gaussian_response(peaks[i].freq_mhz, s_freq_mhz, sigma); + const float peak_dbm = peaks[i].peak_dbm + std::sin(seconds * (0.7f + static_cast(i) * 0.18f)) * 1.8f; + rssi = std::max(rssi, noise_floor + response * (peak_dbm - noise_floor)); + } + + return std::clamp(rssi, -140.0f, -28.0f); +} + +} // namespace + +bool is_supported() +{ + return true; +} + +CapabilityStatus capability_status() +{ + return {CapabilityState::Simulated, + "Synthetic RSSI generator. No real LoRa radio driver."}; +} + +bool acquire() +{ + std::lock_guard lock(s_mutex); + s_acquired = true; + s_started_at = Clock::now(); + return true; +} + +bool is_online() +{ + std::lock_guard lock(s_mutex); + return s_acquired; +} + +bool configure_receive(float freq_mhz, const ReceiveConfig& config) +{ + std::lock_guard lock(s_mutex); + if (!s_acquired || !std::isfinite(freq_mhz) || freq_mhz <= 0.0f) + { + return false; + } + s_freq_mhz = freq_mhz; + s_config = config; + s_configured = true; + return true; +} + +float read_instant_rssi() +{ + std::lock_guard lock(s_mutex); + return current_rssi_locked(); +} + +void release() +{ + std::lock_guard lock(s_mutex); + s_acquired = false; + s_configured = false; + s_freq_mhz = 0.0f; +} + +} // namespace platform::ui::lora diff --git a/platform/linux/common/src/platform/ui/orientation_runtime.cpp b/platform/linux/common/src/platform/ui/orientation_runtime.cpp new file mode 100644 index 00000000..ca65bdd5 --- /dev/null +++ b/platform/linux/common/src/platform/ui/orientation_runtime.cpp @@ -0,0 +1,15 @@ +#include "platform/ui/orientation_runtime.h" + +namespace platform::ui::orientation +{ + +HeadingState get_heading() +{ + return {}; +} + +void ensure_heading_runtime() +{ +} + +} // namespace platform::ui::orientation diff --git a/platform/linux/common/src/platform/ui/pack_repository_runtime.cpp b/platform/linux/common/src/platform/ui/pack_repository_runtime.cpp new file mode 100644 index 00000000..dc3f1f3d --- /dev/null +++ b/platform/linux/common/src/platform/ui/pack_repository_runtime.cpp @@ -0,0 +1,412 @@ +#include "platform/ui/pack_repository_runtime.h" + +#include "platform/ui/settings_store.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ui::runtime::packs +{ +namespace +{ + +constexpr const char* kPackRootEnv = "TRAIL_MATE_PACK_ROOT"; +constexpr const char* kSettingsNs = "extensions"; +constexpr const char* kInstalledIdsKey = "installed_package_ids_v1"; +constexpr const char* kInstalledStorage = "linux-local"; + +std::string trim_copy(std::string value) +{ + auto is_space = [](unsigned char ch) + { return std::isspace(ch) != 0; }; + + value.erase(value.begin(), + std::find_if(value.begin(), value.end(), [&](char ch) + { return !is_space(static_cast(ch)); })); + value.erase(std::find_if(value.rbegin(), + value.rend(), + [&](char ch) + { return !is_space(static_cast(ch)); }) + .base(), + value.end()); + return value; +} + +std::vector split_csv(const std::string& value) +{ + std::vector out; + std::stringstream stream(value); + std::string item; + while (std::getline(stream, item, ',')) + { + item = trim_copy(item); + if (!item.empty()) + { + out.push_back(item); + } + } + return out; +} + +std::string read_text_file(const std::filesystem::path& path) +{ + std::ifstream stream(path, std::ios::binary); + if (!stream.is_open()) + { + return {}; + } + + std::ostringstream contents; + contents << stream.rdbuf(); + return trim_copy(contents.str()); +} + +std::filesystem::path repo_root_from_source() +{ + std::filesystem::path path = std::filesystem::path(__FILE__).parent_path(); + for (int i = 0; i < 6; ++i) + { + path = path.parent_path(); + } + return path.lexically_normal(); +} + +std::filesystem::path pack_root() +{ + if (const char* configured = std::getenv(kPackRootEnv)) + { + if (configured[0] != '\0') + { + return std::filesystem::path(configured); + } + } + return repo_root_from_source() / "packs"; +} + +std::size_t directory_size_bytes(const std::filesystem::path& root) +{ + std::size_t total = 0; + std::error_code ec; + for (std::filesystem::recursive_directory_iterator it(root, ec), end; !ec && it != end; + it.increment(ec)) + { + if (ec) + { + break; + } + if (it->is_regular_file(ec) && !ec) + { + total += static_cast(it->file_size(ec)); + } + } + return total; +} + +std::vector collect_manifest_ids(const std::filesystem::path& root, const char* category) +{ + std::vector ids; + const std::filesystem::path dir = root / category; + std::error_code ec; + if (!std::filesystem::exists(dir, ec) || ec) + { + return ids; + } + + for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) + { + if (ec) + { + break; + } + if (!it->is_directory(ec) || ec) + { + continue; + } + const std::filesystem::path manifest = it->path() / "manifest.ini"; + if (std::filesystem::exists(manifest, ec) && !ec) + { + ids.push_back(it->path().filename().string()); + } + } + + std::sort(ids.begin(), ids.end()); + return ids; +} + +std::unordered_set load_installed_id_set() +{ + std::string persisted; + if (!::platform::ui::settings_store::get_string(kSettingsNs, kInstalledIdsKey, persisted)) + { + return {}; + } + + std::unordered_set out; + std::stringstream stream(persisted); + std::string line; + while (std::getline(stream, line)) + { + line = trim_copy(line); + if (!line.empty()) + { + out.insert(line); + } + } + return out; +} + +bool save_installed_id_set(const std::unordered_set& ids) +{ + std::vector ordered(ids.begin(), ids.end()); + std::sort(ordered.begin(), ordered.end()); + + std::ostringstream stream; + for (std::size_t i = 0; i < ordered.size(); ++i) + { + if (i > 0) + { + stream << '\n'; + } + stream << ordered[i]; + } + return ::platform::ui::settings_store::put_string( + kSettingsNs, kInstalledIdsKey, stream.str().c_str()); +} + +bool load_package_from_dir(const std::filesystem::path& dir, PackageRecord& out_package, std::string& out_error) +{ + const std::filesystem::path package_ini = dir / "package.ini"; + std::ifstream stream(package_ini, std::ios::binary); + if (!stream.is_open()) + { + out_error = "Unable to open package.ini"; + return false; + } + + std::string line; + while (std::getline(stream, line)) + { + line = trim_copy(line); + if (line.empty() || line[0] == '#' || line[0] == ';') + { + continue; + } + const std::size_t eq = line.find('='); + if (eq == std::string::npos) + { + continue; + } + + const std::string key = trim_copy(line.substr(0, eq)); + const std::string value = trim_copy(line.substr(eq + 1)); + if (key == "id") + { + out_package.id = value; + } + else if (key == "package_type") + { + out_package.package_type = value; + } + else if (key == "version") + { + out_package.version = value; + } + else if (key == "display_name") + { + out_package.display_name = value; + } + else if (key == "summary") + { + out_package.summary = value; + } + else if (key == "description") + { + out_package.description = read_text_file(dir / value); + } + else if (key == "author") + { + out_package.author = value; + } + else if (key == "homepage") + { + out_package.homepage = value; + } + else if (key == "min_firmware_version") + { + out_package.min_firmware_version = value; + } + else if (key == "supported_memory_profiles") + { + out_package.supported_memory_profiles = split_csv(value); + } + else if (key == "tags") + { + out_package.tags = split_csv(value); + } + } + + if (out_package.id.empty()) + { + out_error = "package.ini is missing an id"; + return false; + } + + if (out_package.display_name.empty()) + { + out_package.display_name = out_package.id; + } + if (out_package.description.empty()) + { + out_package.description = out_package.summary; + } + + out_package.provided_locale_ids = collect_manifest_ids(dir, "locales"); + out_package.provided_font_ids = collect_manifest_ids(dir, "fonts"); + out_package.provided_ime_ids = collect_manifest_ids(dir, "ime"); + out_package.archive_size_bytes = directory_size_bytes(dir); + out_package.archive_path = dir.string(); + out_package.compatible_memory_profile = true; + out_package.compatible_firmware = true; + return true; +} + +bool build_catalog(std::vector& out_packages, std::string& out_error) +{ + out_packages.clear(); + out_error.clear(); + + const std::filesystem::path root = pack_root(); + std::error_code ec; + if (!std::filesystem::exists(root, ec) || ec) + { + out_error = "Local pack catalog not found."; + return false; + } + + const auto installed_ids = load_installed_id_set(); + for (std::filesystem::directory_iterator it(root, ec), end; !ec && it != end; it.increment(ec)) + { + if (ec) + { + break; + } + if (!it->is_directory(ec) || ec) + { + continue; + } + + PackageRecord package{}; + std::string package_error; + if (!load_package_from_dir(it->path(), package, package_error)) + { + continue; + } + + if (installed_ids.find(package.id) != installed_ids.end()) + { + package.installed = true; + package.installed_record.id = package.id; + package.installed_record.version = package.version; + package.installed_record.storage = kInstalledStorage; + package.installed_record.installed_at_epoch = 0; + } + out_packages.push_back(std::move(package)); + } + + std::sort(out_packages.begin(), + out_packages.end(), + [](const PackageRecord& lhs, const PackageRecord& rhs) + { + return lhs.display_name < rhs.display_name; + }); + + if (out_packages.empty()) + { + out_error = "No language packs available"; + return false; + } + + return true; +} + +} // namespace + +bool is_supported() +{ + std::error_code ec; + return std::filesystem::exists(pack_root(), ec) && !ec; +} + +bool load_installed_packages(std::vector& out_installed, std::string& out_error) +{ + out_installed.clear(); + out_error.clear(); + + std::vector catalog; + if (!build_catalog(catalog, out_error)) + { + return false; + } + + for (const PackageRecord& package : catalog) + { + if (package.installed) + { + out_installed.push_back(package.installed_record); + } + } + return true; +} + +bool fetch_catalog(std::vector& out_packages, std::string& out_error) +{ + return build_catalog(out_packages, out_error); +} + +bool install_package(const PackageRecord& package, std::string& out_error) +{ + out_error.clear(); + if (package.id.empty()) + { + out_error = "Invalid package id"; + return false; + } + + auto installed_ids = load_installed_id_set(); + installed_ids.insert(package.id); + if (!save_installed_id_set(installed_ids)) + { + out_error = "Failed to save installed package state"; + return false; + } + return true; +} + +bool uninstall_package(const PackageRecord& package, std::string& out_error) +{ + out_error.clear(); + if (package.id.empty()) + { + out_error = "Invalid package id"; + return false; + } + + auto installed_ids = load_installed_id_set(); + installed_ids.erase(package.id); + if (!save_installed_id_set(installed_ids)) + { + out_error = "Failed to save installed package state"; + return false; + } + return true; +} + +} // namespace ui::runtime::packs diff --git a/platform/linux/common/src/platform/ui/route_storage.cpp b/platform/linux/common/src/platform/ui/route_storage.cpp new file mode 100644 index 00000000..dc7fdc6e --- /dev/null +++ b/platform/linux/common/src/platform/ui/route_storage.cpp @@ -0,0 +1,107 @@ +#include "platform/ui/route_storage.h" + +#include +#include +#include +#include +#include + +#include "platform/linux/runtime_paths.h" + +namespace platform::ui::route_storage +{ +namespace +{ + +std::string s_route_dir_cache{}; + +std::filesystem::path route_dir_path() +{ + return ::platform::linux_runtime::resolve_paths().sd_root / "routes"; +} + +bool ensure_route_dir() +{ + std::error_code ec; + const std::filesystem::path dir = route_dir_path(); + std::filesystem::create_directories(dir, ec); + if (ec) + { + return false; + } + s_route_dir_cache = dir.string(); + return true; +} + +std::filesystem::path resolve_route_path(const std::string& path_or_name) +{ + std::filesystem::path candidate(path_or_name); + if (candidate.is_absolute()) + { + return candidate; + } + return route_dir_path() / candidate; +} + +} // namespace + +bool is_supported() +{ + return ensure_route_dir(); +} + +bool list_routes(std::vector& out_routes, std::size_t max_count) +{ + out_routes.clear(); + if (!ensure_route_dir()) + { + return false; + } + + std::error_code ec; + for (std::filesystem::directory_iterator it(route_dir_path(), ec), end; + !ec && it != end; it.increment(ec)) + { + if (ec) + { + break; + } + if (!it->is_regular_file(ec) || ec) + { + continue; + } + out_routes.push_back(it->path().filename().string()); + } + + std::sort(out_routes.begin(), out_routes.end()); + if (out_routes.size() > max_count) + { + out_routes.resize(max_count); + } + return true; +} + +bool remove_route(const std::string& path) +{ + // Prevent absolute paths or traversal — only operate under the routes dir. + std::filesystem::path resolved; + if (!::platform::linux_runtime::resolve_child_under_root( + route_dir_path(), path, resolved)) + { + return false; + } + + std::error_code ec; + return std::filesystem::remove(resolved, ec) && !ec; +} + +const char* route_dir() +{ + if (s_route_dir_cache.empty()) + { + (void)ensure_route_dir(); + } + return s_route_dir_cache.c_str(); +} + +} // namespace platform::ui::route_storage diff --git a/platform/linux/common/src/platform/ui/screen_runtime.cpp b/platform/linux/common/src/platform/ui/screen_runtime.cpp new file mode 100644 index 00000000..7b403386 --- /dev/null +++ b/platform/linux/common/src/platform/ui/screen_runtime.cpp @@ -0,0 +1,147 @@ +#include "platform/ui/screen_runtime.h" + +#include "platform/ui/settings_store.h" + +#include +#include + +namespace platform::ui::screen +{ +namespace +{ + +using Clock = std::chrono::steady_clock; + +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kScreenTimeoutKey = "screen_timeout"; +constexpr uint32_t kScreenTimeoutMinMs = 10000U; +constexpr uint32_t kScreenTimeoutMaxMs = 300000U; +constexpr uint32_t kScreenTimeoutDefaultMs = 60000U; + +Hooks s_hooks{}; +Clock::time_point s_last_activity = Clock::now(); +uint32_t s_sleep_disable_depth = 0; +bool s_sleeping = false; + +uint32_t normalize_timeout_ms(uint32_t timeout_ms) +{ + if (timeout_ms < kScreenTimeoutMinMs) + { + return kScreenTimeoutDefaultMs; + } + if (timeout_ms > kScreenTimeoutMaxMs) + { + return kScreenTimeoutMaxMs; + } + return timeout_ms; +} + +uint32_t current_timeout_ms() +{ + return normalize_timeout_ms( + ::platform::ui::settings_store::get_uint(kSettingsNs, kScreenTimeoutKey, kScreenTimeoutDefaultMs)); +} + +bool should_consider_sleep_state() +{ + return s_sleep_disable_depth == 0 && current_timeout_ms() >= kScreenTimeoutMinMs; +} + +bool refresh_idle_state() +{ + if (!should_consider_sleep_state()) + { + s_sleeping = false; + return false; + } + + const auto idle_for = std::chrono::duration_cast(Clock::now() - s_last_activity); + s_sleeping = idle_for.count() >= current_timeout_ms(); + return s_sleeping; +} + +} // namespace + +uint32_t clamp_timeout_ms(uint32_t timeout_ms) +{ + return normalize_timeout_ms(timeout_ms); +} + +uint32_t timeout_ms() +{ + return current_timeout_ms(); +} + +uint16_t timeout_secs() +{ + return static_cast(timeout_ms() / 1000U); +} + +void set_timeout_ms(uint32_t timeout_ms) +{ + ::platform::ui::settings_store::put_uint(kSettingsNs, kScreenTimeoutKey, normalize_timeout_ms(timeout_ms)); + refresh_idle_state(); +} + +void init(const Hooks& hooks) +{ + s_hooks = hooks; + s_last_activity = Clock::now(); + s_sleeping = false; +} + +bool is_sleeping() +{ + return refresh_idle_state(); +} + +bool is_sleep_disabled() +{ + return s_sleep_disable_depth > 0; +} + +bool is_saver_active() +{ + return is_sleeping(); +} + +void wake_saver() +{ + const bool was_sleeping = s_sleeping; + s_last_activity = Clock::now(); + s_sleeping = false; + if (was_sleeping && s_hooks.on_wake_from_sleep) + { + s_hooks.on_wake_from_sleep(); + } +} + +void enter_from_saver() +{ + wake_saver(); +} + +void update_user_activity() +{ + wake_saver(); +} + +void disable_sleep() +{ + if (s_sleep_disable_depth < UINT32_MAX) + { + ++s_sleep_disable_depth; + } + s_sleeping = false; +} + +void enable_sleep() +{ + if (s_sleep_disable_depth > 0) + { + --s_sleep_disable_depth; + } + refresh_idle_state(); +} + +} // namespace platform::ui::screen diff --git a/platform/linux/common/src/platform/ui/settings_store.cpp b/platform/linux/common/src/platform/ui/settings_store.cpp new file mode 100644 index 00000000..ae2d3f98 --- /dev/null +++ b/platform/linux/common/src/platform/ui/settings_store.cpp @@ -0,0 +1,437 @@ +#include "platform/ui/settings_store.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform/linux/runtime_paths.h" + +namespace platform::ui::settings_store +{ +namespace +{ + +enum class ValueKind : char +{ + Int = 'i', + Bool = 'b', + Uint = 'u', + String = 's', + Blob = 'x', +}; + +struct StoredValue +{ + ValueKind kind = ValueKind::Int; + std::string payload{}; +}; + +using NamespaceMap = std::unordered_map; + +std::mutex s_store_mutex; + +std::filesystem::path namespace_path(const char* ns) +{ + return ::platform::linux_runtime::settings_file(ns); +} + +bool ensure_parent_directory(const std::filesystem::path& file_path) +{ + return ::platform::linux_runtime::ensure_directory(file_path.parent_path()); +} + +std::string hex_encode(const uint8_t* data, std::size_t len) +{ + static constexpr char kHex[] = "0123456789ABCDEF"; + + std::string out; + out.reserve(len * 2U); + for (std::size_t index = 0; index < len; ++index) + { + const uint8_t value = data[index]; + out.push_back(kHex[(value >> 4) & 0x0F]); + out.push_back(kHex[value & 0x0F]); + } + return out; +} + +bool decode_hex_nibble(char ch, uint8_t* out) +{ + if (!out) + { + return false; + } + if (ch >= '0' && ch <= '9') + { + *out = static_cast(ch - '0'); + return true; + } + if (ch >= 'A' && ch <= 'F') + { + *out = static_cast(10 + (ch - 'A')); + return true; + } + if (ch >= 'a' && ch <= 'f') + { + *out = static_cast(10 + (ch - 'a')); + return true; + } + return false; +} + +bool hex_decode(const std::string& hex, std::vector* out) +{ + if (!out || (hex.size() % 2U) != 0U) + { + return false; + } + + out->clear(); + out->reserve(hex.size() / 2U); + for (std::size_t index = 0; index < hex.size(); index += 2U) + { + uint8_t high = 0; + uint8_t low = 0; + if (!decode_hex_nibble(hex[index], &high) || !decode_hex_nibble(hex[index + 1U], &low)) + { + out->clear(); + return false; + } + out->push_back(static_cast((high << 4) | low)); + } + return true; +} + +NamespaceMap load_namespace_map(const char* ns) +{ + NamespaceMap map; + + const std::filesystem::path path = namespace_path(ns); + std::ifstream stream(path, std::ios::binary); + if (!stream.is_open()) + { + return map; + } + + std::string line; + while (std::getline(stream, line)) + { + const std::size_t first = line.find('|'); + const std::size_t second = (first == std::string::npos) ? std::string::npos : line.find('|', first + 1U); + if (first == std::string::npos || second == std::string::npos || first == 0U || second <= first + 1U) + { + continue; + } + + StoredValue value{}; + value.kind = static_cast(line[first + 1U]); + value.payload = line.substr(second + 1U); + map[line.substr(0, first)] = std::move(value); + } + + return map; +} + +bool save_namespace_map(const char* ns, const NamespaceMap& map) +{ + const std::filesystem::path path = namespace_path(ns); + if (!ensure_parent_directory(path)) + { + return false; + } + + if (map.empty()) + { + std::error_code ec; + std::filesystem::remove(path, ec); + return true; + } + + const std::filesystem::path temp_path = path.string() + ".tmp"; + { + std::ofstream stream(temp_path, std::ios::binary | std::ios::trunc); + if (!stream.is_open()) + { + return false; + } + + std::vector keys; + keys.reserve(map.size()); + for (const auto& entry : map) + { + keys.push_back(entry.first); + } + std::sort(keys.begin(), keys.end()); + + for (const auto& key : keys) + { + const auto found = map.find(key); + if (found == map.end()) + { + continue; + } + stream << key << "|" << static_cast(found->second.kind) << "|" << found->second.payload << "\n"; + } + } + + std::error_code ec; + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(temp_path, path, ec); + if (ec) + { + std::filesystem::remove(temp_path, ec); + return false; + } + + return true; +} + +bool parse_int_value(const std::string& payload, int* out) +{ + if (!out) + { + return false; + } + try + { + *out = std::stoi(payload); + return true; + } + catch (...) + { + return false; + } +} + +bool parse_uint_value(const std::string& payload, uint32_t* out) +{ + if (!out) + { + return false; + } + try + { + *out = static_cast(std::stoul(payload)); + return true; + } + catch (...) + { + return false; + } +} + +void upsert_numeric_value(const char* ns, const char* key, ValueKind kind, const std::string& payload) +{ + if (!ns || !key || key[0] == '\0') + { + return; + } + + std::lock_guard lock(s_store_mutex); + NamespaceMap map = load_namespace_map(ns); + map[std::string(key)] = StoredValue{kind, payload}; + (void)save_namespace_map(ns, map); +} + +} // namespace + +void put_int(const char* ns, const char* key, int value) +{ + upsert_numeric_value(ns, key, ValueKind::Int, std::to_string(value)); +} + +void put_bool(const char* ns, const char* key, bool value) +{ + upsert_numeric_value(ns, key, ValueKind::Bool, value ? "1" : "0"); +} + +void put_uint(const char* ns, const char* key, uint32_t value) +{ + upsert_numeric_value(ns, key, ValueKind::Uint, std::to_string(value)); +} + +bool put_string(const char* ns, const char* key, const char* value) +{ + if (!ns || !key || !value) + { + return false; + } + + std::lock_guard lock(s_store_mutex); + NamespaceMap map = load_namespace_map(ns); + if (value[0] == '\0') + { + map.erase(std::string(key)); + } + else + { + const auto encoded = hex_encode(reinterpret_cast(value), std::strlen(value)); + map[std::string(key)] = StoredValue{ValueKind::String, encoded}; + } + return save_namespace_map(ns, map); +} + +bool put_blob(const char* ns, const char* key, const void* data, std::size_t len) +{ + if (!ns || !key) + { + return false; + } + if (len > 0U && data == nullptr) + { + return false; + } + + std::lock_guard lock(s_store_mutex); + NamespaceMap map = load_namespace_map(ns); + if (len == 0U) + { + map.erase(std::string(key)); + } + else + { + const auto* bytes = static_cast(data); + map[std::string(key)] = StoredValue{ValueKind::Blob, hex_encode(bytes, len)}; + } + return save_namespace_map(ns, map); +} + +int get_int(const char* ns, const char* key, int default_value) +{ + if (!ns || !key) + { + return default_value; + } + + std::lock_guard lock(s_store_mutex); + const NamespaceMap map = load_namespace_map(ns); + const auto found = map.find(key); + if (found == map.end() || found->second.kind != ValueKind::Int) + { + return default_value; + } + + int parsed = default_value; + return parse_int_value(found->second.payload, &parsed) ? parsed : default_value; +} + +bool get_bool(const char* ns, const char* key, bool default_value) +{ + if (!ns || !key) + { + return default_value; + } + + std::lock_guard lock(s_store_mutex); + const NamespaceMap map = load_namespace_map(ns); + const auto found = map.find(key); + if (found == map.end() || found->second.kind != ValueKind::Bool) + { + return default_value; + } + return found->second.payload == "1"; +} + +uint32_t get_uint(const char* ns, const char* key, uint32_t default_value) +{ + if (!ns || !key) + { + return default_value; + } + + std::lock_guard lock(s_store_mutex); + const NamespaceMap map = load_namespace_map(ns); + const auto found = map.find(key); + if (found == map.end() || found->second.kind != ValueKind::Uint) + { + return default_value; + } + + uint32_t parsed = default_value; + return parse_uint_value(found->second.payload, &parsed) ? parsed : default_value; +} + +bool get_string(const char* ns, const char* key, std::string& out) +{ + if (!ns || !key) + { + return false; + } + + std::lock_guard lock(s_store_mutex); + const NamespaceMap map = load_namespace_map(ns); + const auto found = map.find(key); + if (found == map.end() || found->second.kind != ValueKind::String) + { + return false; + } + + std::vector decoded; + if (!hex_decode(found->second.payload, &decoded)) + { + return false; + } + + out.assign(decoded.begin(), decoded.end()); + return true; +} + +bool get_blob(const char* ns, const char* key, std::vector& out) +{ + if (!ns || !key) + { + return false; + } + + std::lock_guard lock(s_store_mutex); + const NamespaceMap map = load_namespace_map(ns); + const auto found = map.find(key); + if (found == map.end() || found->second.kind != ValueKind::Blob) + { + return false; + } + + return hex_decode(found->second.payload, &out); +} + +void remove_keys(const char* ns, const char* const* keys, std::size_t key_count) +{ + if (!ns || !keys || key_count == 0U) + { + return; + } + + std::lock_guard lock(s_store_mutex); + NamespaceMap map = load_namespace_map(ns); + for (std::size_t index = 0; index < key_count; ++index) + { + if (keys[index] && keys[index][0] != '\0') + { + map.erase(std::string(keys[index])); + } + } + (void)save_namespace_map(ns, map); +} + +void clear_namespace(const char* ns) +{ + if (!ns) + { + return; + } + + std::lock_guard lock(s_store_mutex); + std::error_code ec; + std::filesystem::remove(namespace_path(ns), ec); +} + +} // namespace platform::ui::settings_store diff --git a/platform/linux/common/src/platform/ui/sstv_runtime.cpp b/platform/linux/common/src/platform/ui/sstv_runtime.cpp new file mode 100644 index 00000000..225a34e5 --- /dev/null +++ b/platform/linux/common/src/platform/ui/sstv_runtime.cpp @@ -0,0 +1,351 @@ +#include "platform/ui/sstv_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace platform::ui::sstv +{ +namespace +{ + +using Clock = std::chrono::steady_clock; + +constexpr const char* kSdRootEnv = "TRAIL_MATE_SD_ROOT"; +constexpr const char* kSettingsRootEnv = "TRAIL_MATE_SETTINGS_ROOT"; +constexpr const char* kModeEnv = "TRAIL_MATE_SSTV_MODE"; +constexpr const char* kOutputDirEnv = "TRAIL_MATE_SSTV_OUTPUT_DIR"; + +constexpr uint16_t kFrameWidth = 288; +constexpr uint16_t kFrameHeight = 192; +constexpr uint32_t kWaitingMs = 900U; +constexpr uint32_t kReceivingMs = 6200U; + +std::mutex s_mutex; +bool s_active = false; +State s_state = State::Idle; +Clock::time_point s_started_at = Clock::now(); +std::string s_last_error{}; +std::string s_saved_path{}; +std::string s_mode_name{}; +std::vector s_frame{}; + +uint8_t expand5(std::uint16_t value) +{ + return static_cast((value * 255U) / 31U); +} + +uint8_t expand6(std::uint16_t value) +{ + return static_cast((value * 255U) / 63U); +} + +uint16_t rgb565(uint8_t red, uint8_t green, uint8_t blue) +{ + const uint16_t r = static_cast((red >> 3U) & 0x1FU); + const uint16_t g = static_cast((green >> 2U) & 0x3FU); + const uint16_t b = static_cast((blue >> 3U) & 0x1FU); + return static_cast((r << 11U) | (g << 5U) | b); +} + +std::filesystem::path default_storage_root() +{ + if (const char* configured = std::getenv(kOutputDirEnv)) + { + if (configured[0] != '\0') + { + return std::filesystem::path(configured); + } + } + + if (const char* configured = std::getenv(kSdRootEnv)) + { + if (configured[0] != '\0') + { + return std::filesystem::path(configured) / "sstv"; + } + } + + if (const char* configured = std::getenv(kSettingsRootEnv)) + { + if (configured[0] != '\0') + { + return std::filesystem::path(configured) / "sdcard" / "sstv"; + } + } + +#if defined(_WIN32) + if (const char* appdata = std::getenv("APPDATA")) + { + if (appdata[0] != '\0') + { + return std::filesystem::path(appdata) / "TrailMateCardputerZero" / "sdcard" / "sstv"; + } + } +#endif + + if (const char* home = std::getenv("HOME")) + { + if (home[0] != '\0') + { + return std::filesystem::path(home) / ".trailmate_cardputer_zero" / "sdcard" / "sstv"; + } + } + + return std::filesystem::current_path() / ".trailmate_cardputer_zero" / "sdcard" / "sstv"; +} + +std::string env_or_default(const char* name, const char* fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return std::string(fallback); + } + return std::string(value); +} + +void ensure_frame_locked() +{ + if (!s_frame.empty()) + { + return; + } + + s_frame.resize(static_cast(kFrameWidth) * static_cast(kFrameHeight)); + for (uint16_t y = 0; y < kFrameHeight; ++y) + { + for (uint16_t x = 0; x < kFrameWidth; ++x) + { + const float fx = static_cast(x) / static_cast(kFrameWidth - 1U); + const float fy = static_cast(y) / static_cast(kFrameHeight - 1U); + const uint8_t red = static_cast(std::lround(210.0f * fx + 30.0f)); + const uint8_t green = static_cast(std::lround(180.0f * (1.0f - fy) + 35.0f)); + const uint8_t blue = static_cast(std::lround(120.0f + 100.0f * std::sin((fx + fy) * 4.4f))); + + uint16_t pixel = rgb565(red, green, blue); + if ((x / 24U) % 2U == 0U) + { + pixel = rgb565(std::min(255U, red + 18U), green, blue); + } + if ((y / 16U) % 2U == 1U) + { + pixel = rgb565(red, std::min(255U, green + 22U), blue); + } + + if (x > 56U && x < 232U && y > 48U && y < 144U) + { + const bool border = x < 60U || x > 228U || y < 52U || y > 140U; + pixel = border ? rgb565(250U, 244U, 220U) : rgb565(242U, 164U, 57U); + } + + s_frame[static_cast(y) * static_cast(kFrameWidth) + x] = pixel; + } + } +} + +bool save_frame_locked() +{ + ensure_frame_locked(); + + std::error_code ec; + const std::filesystem::path root = default_storage_root(); + std::filesystem::create_directories(root, ec); + if (ec) + { + s_last_error = "Failed to create SSTV output directory"; + return false; + } + + const std::filesystem::path output = root / "last.ppm"; + std::ofstream stream(output, std::ios::binary | std::ios::trunc); + if (!stream.is_open()) + { + s_last_error = "Failed to save SSTV image"; + return false; + } + + stream << "P6\n" + << kFrameWidth << " " << kFrameHeight << "\n255\n"; + for (uint16_t pixel : s_frame) + { + const uint8_t red = expand5(static_cast((pixel >> 11U) & 0x1FU)); + const uint8_t green = expand6(static_cast((pixel >> 5U) & 0x3FU)); + const uint8_t blue = expand5(static_cast(pixel & 0x1FU)); + stream.put(static_cast(red)); + stream.put(static_cast(green)); + stream.put(static_cast(blue)); + } + stream.close(); + + if (!stream) + { + s_last_error = "Failed to write SSTV image"; + return false; + } + + s_saved_path = output.string(); + return true; +} + +float audio_wave(float seconds) +{ + const float wave = 0.5f + 0.5f * std::sin(seconds * 8.0f); + return std::clamp(0.18f + wave * 0.72f, 0.0f, 1.0f); +} + +Status current_status_locked() +{ + Status status{}; + status.state = s_state; + status.has_image = !s_frame.empty() && (s_state == State::Receiving || s_state == State::Complete); + + if (!s_active) + { + if (s_state == State::Complete) + { + status.line = kFrameHeight; + status.progress = 1.0f; + status.audio_level = 0.08f; + status.has_image = true; + } + return status; + } + + const uint32_t elapsed_ms = static_cast( + std::chrono::duration_cast(Clock::now() - s_started_at).count()); + const float seconds = static_cast(elapsed_ms) / 1000.0f; + + if (elapsed_ms < kWaitingMs) + { + s_state = State::Waiting; + status.state = State::Waiting; + status.audio_level = audio_wave(seconds * 0.7f) * 0.5f; + return status; + } + + const uint32_t rx_elapsed_ms = elapsed_ms - kWaitingMs; + if (rx_elapsed_ms < kReceivingMs) + { + ensure_frame_locked(); + const float progress = static_cast(rx_elapsed_ms) / static_cast(kReceivingMs); + s_state = State::Receiving; + status.state = State::Receiving; + status.progress = std::clamp(progress, 0.0f, 1.0f); + status.line = static_cast(std::min( + kFrameHeight, + static_cast(std::lround(status.progress * static_cast(kFrameHeight))))); + status.audio_level = audio_wave(seconds); + status.has_image = true; + return status; + } + + if (s_state != State::Complete) + { + if (!save_frame_locked()) + { + s_state = State::Error; + s_active = false; + status.state = State::Error; + status.audio_level = 0.0f; + status.has_image = !s_frame.empty(); + return status; + } + s_state = State::Complete; + s_active = false; + } + + status.state = State::Complete; + status.line = kFrameHeight; + status.progress = 1.0f; + status.audio_level = 0.1f; + status.has_image = !s_frame.empty(); + return status; +} + +} // namespace + +bool is_supported() +{ + return true; +} + +bool start() +{ + std::lock_guard lock(s_mutex); + s_last_error.clear(); + s_saved_path.clear(); + s_mode_name = env_or_default(kModeEnv, "Scottie 1"); + ensure_frame_locked(); + s_active = true; + s_state = State::Waiting; + s_started_at = Clock::now(); + return true; +} + +void stop() +{ + std::lock_guard lock(s_mutex); + s_active = false; + if (s_state != State::Complete && s_state != State::Error) + { + s_state = State::Idle; + } +} + +bool is_active() +{ + std::lock_guard lock(s_mutex); + return s_active; +} + +Status get_status() +{ + std::lock_guard lock(s_mutex); + return current_status_locked(); +} + +const char* last_error() +{ + std::lock_guard lock(s_mutex); + return s_last_error.empty() ? "" : s_last_error.c_str(); +} + +const char* last_saved_path() +{ + std::lock_guard lock(s_mutex); + return s_saved_path.empty() ? "" : s_saved_path.c_str(); +} + +const char* mode_name() +{ + std::lock_guard lock(s_mutex); + return s_mode_name.empty() ? "Scottie 1" : s_mode_name.c_str(); +} + +const uint16_t* framebuffer() +{ + std::lock_guard lock(s_mutex); + ensure_frame_locked(); + return s_frame.empty() ? nullptr : s_frame.data(); +} + +uint16_t frame_width() +{ + return kFrameWidth; +} + +uint16_t frame_height() +{ + return kFrameHeight; +} + +} // namespace platform::ui::sstv diff --git a/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp b/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp new file mode 100644 index 00000000..6e7ee688 --- /dev/null +++ b/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp @@ -0,0 +1,426 @@ +#include "platform/ui/team_ui_store_runtime.h" + +#include "platform/linux/runtime_paths.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace team::ui +{ +namespace +{ + +constexpr const char* kSdRootEnv = "TRAIL_MATE_SD_ROOT"; +constexpr const char* kSettingsRootEnv = "TRAIL_MATE_SETTINGS_ROOT"; +constexpr const char* kTeamBaseDirName = "team"; +constexpr const char* kTracksDirName = "tracks"; +constexpr const char* kGpxHeader = + "\n" + "\n" + "\n" + "\n"; +constexpr uint32_t kMinValidEpoch = 1577836800U; // 2020-01-01 + +uint64_t team_id_to_u64(const TeamId& id) +{ + uint64_t value = 0; + for (size_t i = 0; i < id.size(); ++i) + { + value |= (static_cast(id[i]) << (8U * i)); + } + return value; +} + +std::filesystem::path default_storage_root() +{ + return ::platform::linux_runtime::resolve_paths().sd_root; +} + +std::string base32_from_u64(uint64_t value) +{ + static const char* kAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + char buf[13] = {0}; + for (int i = 12; i >= 0; --i) + { + buf[i] = kAlphabet[value & 0x1F]; + value >>= 5; + } + return std::string(buf, 13); +} + +std::string team_dir_from_id(const TeamId& id) +{ + std::string full = base32_from_u64(team_id_to_u64(id)); + const std::size_t first = full.find_first_not_of('A'); + if (first == std::string::npos) + { + full = "AAAA"; + } + else + { + full = full.substr(first); + } + if (full.size() > 10) + { + full = full.substr(full.size() - 10); + } + if (full.size() < 4) + { + full = std::string(4 - full.size(), 'A') + full; + } + return std::string("T_") + full; +} + +std::filesystem::path team_base_dir() +{ + return default_storage_root() / kTeamBaseDirName; +} + +bool ensure_dir(const std::filesystem::path& dir) +{ + std::error_code ec; + std::filesystem::create_directories(dir, ec); + return !ec; +} + +bool ensure_team_dir_for_id(const TeamId& id, std::filesystem::path& out_dir) +{ + out_dir = team_base_dir() / team_dir_from_id(id); + return ensure_dir(out_dir); +} + +std::filesystem::path member_track_path_for(const TeamId& team_id, uint32_t member_id) +{ + std::filesystem::path team_dir; + if (!ensure_team_dir_for_id(team_id, team_dir)) + { + return {}; + } + const std::filesystem::path tracks_dir = team_dir / kTracksDirName; + if (!ensure_dir(tracks_dir)) + { + return {}; + } + + char name[24] = {0}; + std::snprintf(name, sizeof(name), "%08lX.gpx", static_cast(member_id)); + return tracks_dir / name; +} + +std::string iso_time(std::time_t t) +{ + if (t <= 0) + { + t = std::time(nullptr); + } + + std::tm tm_utc{}; + char buf[32] = {0}; +#if defined(_WIN32) + if (gmtime_s(&tm_utc, &t) == 0) +#else + if (gmtime_r(&t, &tm_utc) != nullptr) +#endif + { + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm_utc); + return std::string(buf); + } + return std::string("1970-01-01T00:00:00Z"); +} + +struct TeamUiRuntimeMemory +{ + std::map> chat_logs{}; + std::map> latest_positions{}; +}; + +TeamUiRuntimeMemory& runtime_memory() +{ + static TeamUiRuntimeMemory memory{}; + return memory; +} + +constexpr size_t kMaxChatEntriesPerTeam = 128; +constexpr size_t kMaxPosSamplesPerTeam = 96; + +TeamUiMemoryStore s_memory_store{}; +ITeamUiStore* s_store = &s_memory_store; + +} // namespace + +bool TeamUiMemoryStore::has_snapshot_ = false; +TeamUiSnapshot TeamUiMemoryStore::snapshot_{}; + +bool TeamUiMemoryStore::load(TeamUiSnapshot& out) +{ + if (!has_snapshot_) + { + return false; + } + + out = snapshot_; + return true; +} + +void TeamUiMemoryStore::save(const TeamUiSnapshot& in) +{ + snapshot_ = in; + has_snapshot_ = true; +} + +void TeamUiMemoryStore::clear() +{ + snapshot_ = TeamUiSnapshot{}; + has_snapshot_ = false; + runtime_memory() = TeamUiRuntimeMemory{}; +} + +ITeamUiStore& team_ui_get_store() +{ + return *s_store; +} + +void team_ui_set_store(ITeamUiStore* store) +{ + s_store = store ? store : &s_memory_store; +} + +bool team_ui_append_key_event(const TeamId& team_id, + TeamKeyEventType type, + uint32_t event_seq, + uint32_t ts, + const uint8_t* payload, + size_t len) +{ + (void)ts; + (void)payload; + (void)len; + + TeamUiSnapshot snapshot{}; + if (!team_ui_get_store().load(snapshot)) + { + snapshot = TeamUiSnapshot{}; + } + + snapshot.team_id = team_id; + snapshot.has_team_id = true; + snapshot.last_event_seq = event_seq; + + if (type == TeamKeyEventType::TeamCreated) + { + snapshot.in_team = true; + snapshot.self_is_leader = true; + snapshot.kicked_out = false; + } + + team_ui_get_store().save(snapshot); + return true; +} + +bool team_ui_posring_append(const TeamId& team_id, + uint32_t member_id, + int32_t lat_e7, + int32_t lon_e7, + int16_t alt_m, + uint16_t speed_dmps, + uint32_t ts) +{ + auto& positions = runtime_memory().latest_positions[team_id_to_u64(team_id)]; + positions.push_back(TeamPosSample{ + .member_id = member_id, + .lat_e7 = lat_e7, + .lon_e7 = lon_e7, + .alt_m = alt_m, + .speed_dmps = speed_dmps, + .ts = ts, + }); + while (positions.size() > kMaxPosSamplesPerTeam) + { + positions.pop_front(); + } + return true; +} + +bool team_ui_posring_load_latest(const TeamId& team_id, std::vector& out) +{ + out.clear(); + const auto it = runtime_memory().latest_positions.find(team_id_to_u64(team_id)); + if (it == runtime_memory().latest_positions.end()) + { + return false; + } + + out.assign(it->second.begin(), it->second.end()); + return !out.empty(); +} + +bool team_ui_chatlog_append(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + const std::string& text) +{ + return team_ui_chatlog_append_structured(team_id, + peer_id, + incoming, + ts, + team::proto::TeamChatType::Text, + std::vector(text.begin(), text.end())); +} + +bool team_ui_chatlog_append_structured(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload) +{ + auto& chat_log = runtime_memory().chat_logs[team_id_to_u64(team_id)]; + chat_log.push_back(TeamChatLogEntry{ + .incoming = incoming, + .ts = ts, + .peer_id = peer_id, + .type = type, + .payload = payload, + }); + while (chat_log.size() > kMaxChatEntriesPerTeam) + { + chat_log.pop_front(); + } + return true; +} + +bool team_ui_chatlog_load_recent(const TeamId& team_id, + size_t max_count, + std::vector& out) +{ + out.clear(); + const auto it = runtime_memory().chat_logs.find(team_id_to_u64(team_id)); + if (it == runtime_memory().chat_logs.end()) + { + return false; + } + + const auto& log = it->second; + const size_t available = log.size(); + const size_t count = std::min(max_count, available); + out.reserve(count); + auto start = log.end(); + std::advance(start, -static_cast(count)); + out.assign(start, log.end()); + return !out.empty(); +} + +bool team_ui_save_keys_now(const TeamId& team_id, + uint32_t key_id, + const std::array& psk) +{ + TeamUiSnapshot snapshot{}; + if (!team_ui_get_store().load(snapshot)) + { + snapshot = TeamUiSnapshot{}; + } + snapshot.team_id = team_id; + snapshot.has_team_id = true; + snapshot.security_round = key_id; + snapshot.team_psk = psk; + snapshot.has_team_psk = true; + team_ui_get_store().save(snapshot); + return true; +} + +bool team_ui_append_member_track(const TeamId& team_id, + uint32_t member_id, + const team::proto::TeamTrackMessage& track) +{ + if (track.points.empty() || track.valid_mask == 0) + { + return false; + } + + bool has_valid = false; + for (size_t i = 0; i < track.points.size(); ++i) + { + if ((track.valid_mask & (1u << static_cast(i))) != 0) + { + has_valid = true; + break; + } + } + if (!has_valid) + { + return false; + } + + const std::filesystem::path path = member_track_path_for(team_id, member_id); + if (path.empty()) + { + return false; + } + + std::error_code ec; + const bool file_exists = std::filesystem::exists(path, ec) && !ec; + std::ofstream stream(path, std::ios::binary | std::ios::app); + if (!stream.is_open()) + { + return false; + } + + if (!file_exists || std::filesystem::file_size(path, ec) == 0 || ec) + { + stream << kGpxHeader; + } + + for (size_t i = 0; i < track.points.size(); ++i) + { + if ((track.valid_mask & (1u << static_cast(i))) == 0) + { + continue; + } + + const auto& pt = track.points[i]; + const double lat = static_cast(pt.lat_e7) / 1e7; + const double lon = static_cast(pt.lon_e7) / 1e7; + const uint32_t ts = track.start_ts + static_cast(track.interval_s) * static_cast(i); + + char line[128] = {0}; + std::snprintf(line, sizeof(line), "\n", lat, lon); + stream << line; + stream << " 0.0\n"; + if (ts >= kMinValidEpoch) + { + stream << " \n"; + } + stream << " \n"; + stream << " 0.00\n"; + stream << " 0.0\n"; + stream << " 0.0\n"; + stream << " 0\n"; + stream << " \n"; + stream << "\n"; + } + stream.flush(); + return stream.good(); +} + +bool team_ui_get_member_track_path(const TeamId& team_id, uint32_t member_id, std::string& out_path) +{ + const std::filesystem::path path = member_track_path_for(team_id, member_id); + if (path.empty()) + { + out_path.clear(); + return false; + } + out_path = path.string(); + return true; +} + +} // namespace team::ui diff --git a/platform/linux/common/src/platform/ui/time_runtime.cpp b/platform/linux/common/src/platform/ui/time_runtime.cpp new file mode 100644 index 00000000..f14b69f4 --- /dev/null +++ b/platform/linux/common/src/platform/ui/time_runtime.cpp @@ -0,0 +1,76 @@ +#include "platform/ui/time_runtime.h" + +#include "platform/ui/settings_store.h" + +#include +#include + +namespace platform::ui::time +{ +namespace +{ + +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kTimezoneOffsetKey = "timezone_offset"; +constexpr const char* kTimezoneOffsetEnv = "TRAIL_MATE_TZ_OFFSET_MIN"; + +int env_timezone_offset_or_default(int fallback) +{ + const char* value = std::getenv(kTimezoneOffsetEnv); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (end == value || (end && *end != '\0')) + { + return fallback; + } + return static_cast(parsed); +} + +} // namespace + +int timezone_offset_min() +{ + return env_timezone_offset_or_default( + ::platform::ui::settings_store::get_int(kSettingsNs, kTimezoneOffsetKey, 0)); +} + +void set_timezone_offset_min(int offset_min) +{ + ::platform::ui::settings_store::put_int(kSettingsNs, kTimezoneOffsetKey, offset_min); +} + +::time_t apply_timezone_offset(::time_t utc_seconds) +{ + return utc_seconds + static_cast(timezone_offset_min()) * 60; +} + +bool localtime_now(struct tm* out_tm) +{ + if (!out_tm) + { + return false; + } + + const ::time_t now = ::time(nullptr); + if (now <= 0) + { + return false; + } + + const ::time_t local = apply_timezone_offset(now); + const ::tm* tmp = ::gmtime(&local); + if (!tmp) + { + return false; + } + + *out_tm = *tmp; + return true; +} + +} // namespace platform::ui::time diff --git a/platform/linux/common/src/platform/ui/tracker_runtime.cpp b/platform/linux/common/src/platform/ui/tracker_runtime.cpp new file mode 100644 index 00000000..a052ef67 --- /dev/null +++ b/platform/linux/common/src/platform/ui/tracker_runtime.cpp @@ -0,0 +1,174 @@ +#include "platform/ui/tracker_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform/linux/runtime_paths.h" + +namespace platform::ui::tracker +{ +namespace +{ + +bool s_recording = false; +std::string s_current_path{}; +bool s_auto_recording = false; +uint32_t s_interval_seconds = 60; +bool s_distance_only = false; +Format s_format = Format::GPX; +std::string s_track_dir_cache{}; + +std::filesystem::path track_dir_path() +{ + return ::platform::linux_runtime::resolve_paths().sd_root / "tracks"; +} + +bool ensure_track_dir() +{ + std::error_code ec; + const std::filesystem::path dir = track_dir_path(); + std::filesystem::create_directories(dir, ec); + if (ec) + { + return false; + } + s_track_dir_cache = dir.string(); + return true; +} + +std::string extension_for_format(Format format) +{ + switch (format) + { + case Format::CSV: + return ".csv"; + case Format::Binary: + return ".bin"; + case Format::GPX: + default: + return ".gpx"; + } +} + +std::string make_track_file_name() +{ + using clock = std::chrono::system_clock; + const auto now = clock::now(); + const auto seconds = + std::chrono::duration_cast(now.time_since_epoch()).count(); + return "track-" + std::to_string(seconds) + extension_for_format(s_format); +} + +} // namespace + +bool is_supported() +{ + return ensure_track_dir(); +} + +bool is_recording() +{ + return s_recording; +} + +bool start_recording() +{ + if (!ensure_track_dir()) + { + return false; + } + + if (s_recording) + { + return true; + } + + const std::filesystem::path path = track_dir_path() / make_track_file_name(); + std::ofstream stream(path, std::ios::binary | std::ios::trunc); + if (!stream.is_open()) + { + return false; + } + stream << "# Trail Mate Linux tracker\n"; + stream << "# auto_recording=" << (s_auto_recording ? "1" : "0") << "\n"; + stream << "# interval_seconds=" << s_interval_seconds << "\n"; + stream << "# distance_only=" << (s_distance_only ? "1" : "0") << "\n"; + stream.close(); + + s_current_path = path.string(); + s_recording = true; + return true; +} + +void stop_recording() +{ + s_recording = false; + s_current_path.clear(); +} + +bool current_path(std::string& out_path) +{ + out_path = s_current_path; + return !out_path.empty(); +} + +bool list_tracks(std::vector& out_tracks, std::size_t max_count) +{ + out_tracks.clear(); + if (!ensure_track_dir()) + { + return false; + } + + std::error_code ec; + for (std::filesystem::directory_iterator it(track_dir_path(), ec), end; + !ec && it != end; it.increment(ec)) + { + if (ec) break; + if (!it->is_regular_file(ec) || ec) continue; + out_tracks.push_back(it->path().filename().string()); + } + + std::sort(out_tracks.begin(), out_tracks.end(), std::greater()); + if (out_tracks.size() > max_count) + { + out_tracks.resize(max_count); + } + return true; +} + +bool remove_track(const std::string& path) +{ + // Containment check against the tracks directory. + std::filesystem::path resolved; + if (!::platform::linux_runtime::resolve_child_under_root( + track_dir_path(), path, resolved)) + { + return false; + } + + std::error_code ec; + return std::filesystem::remove(resolved, ec) && !ec; +} + +const char* track_dir() +{ + if (s_track_dir_cache.empty()) + { + (void)ensure_track_dir(); + } + return s_track_dir_cache.c_str(); +} + +void set_auto_recording(bool enabled) { s_auto_recording = enabled; } +void set_interval_seconds(uint32_t seconds) { s_interval_seconds = seconds; } +void set_distance_only(bool enabled) { s_distance_only = enabled; } +void set_format(Format format) { s_format = format; } + +} // namespace platform::ui::tracker diff --git a/platform/linux/common/src/platform/ui/usb_support_runtime.cpp b/platform/linux/common/src/platform/ui/usb_support_runtime.cpp new file mode 100644 index 00000000..e9c13dfb --- /dev/null +++ b/platform/linux/common/src/platform/ui/usb_support_runtime.cpp @@ -0,0 +1,43 @@ +#include "platform/ui/usb_support_runtime.h" + +namespace platform::ui::usb_support +{ +namespace +{ + +constexpr const char* kUnsupportedMessage = "USB mass storage unsupported on Linux"; + +} // namespace + +bool is_supported() +{ + return false; +} + +bool start() +{ + return false; +} + +void stop() +{ +} + +Status get_status() +{ + Status out{}; + out.active = false; + out.stop_requested = false; + out.message = kUnsupportedMessage; + return out; +} + +void prepare_mass_storage_mode() +{ +} + +void restore_mass_storage_mode() +{ +} + +} // namespace platform::ui::usb_support diff --git a/platform/linux/common/src/platform/ui/walkie_runtime.cpp b/platform/linux/common/src/platform/ui/walkie_runtime.cpp new file mode 100644 index 00000000..6f841d6f --- /dev/null +++ b/platform/linux/common/src/platform/ui/walkie_runtime.cpp @@ -0,0 +1,166 @@ +#include "platform/ui/walkie_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform/linux/capability_status.h" + +using namespace ::platform::linux_runtime; + +namespace platform::ui::walkie +{ +namespace +{ + +using Clock = std::chrono::steady_clock; + +constexpr const char* kFreqEnv = "TRAIL_MATE_WALKIE_FREQ_MHZ"; +constexpr const char* kVolumeEnv = "TRAIL_MATE_WALKIE_VOLUME"; +constexpr const char* kForceTxEnv = "TRAIL_MATE_WALKIE_FORCE_TX"; + +std::mutex s_mutex; +bool s_active = false; +Clock::time_point s_started_at = Clock::now(); +std::string s_last_error{}; + +bool env_flag_enabled(const char* name) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return false; + } + return std::strcmp(value, "1") == 0 || std::strcmp(value, "true") == 0 || + std::strcmp(value, "TRUE") == 0 || std::strcmp(value, "yes") == 0 || + std::strcmp(value, "YES") == 0; +} + +int env_int_or_default(const char* name, int fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (end == value || (end && *end != '\0')) + { + return fallback; + } + return static_cast(parsed); +} + +float env_float_or_default(const char* name, float fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + char* end = nullptr; + const float parsed = std::strtof(value, &end); + if (end == value || (end && *end != '\0') || !std::isfinite(parsed)) + { + return fallback; + } + return parsed; +} + +uint8_t synth_level(float phase, float floor_level, float amplitude) +{ + const float wave = std::sin(phase); + const float value = floor_level + ((wave + 1.0f) * 0.5f * amplitude); + const float clamped = std::clamp(value, 0.0f, 100.0f); + return static_cast(std::lround(clamped)); +} + +Status current_status_locked() +{ + Status status{}; + status.active = s_active; + status.freq_mhz = env_float_or_default(kFreqEnv, 433.500f); + if (!s_active) + { + return status; + } + + const auto elapsed = + std::chrono::duration_cast(Clock::now() - s_started_at).count(); + const float seconds = static_cast(elapsed) / 1000.0f; + const bool force_tx = env_flag_enabled(kForceTxEnv); + + status.tx = force_tx; + if (force_tx) + { + status.tx_level = synth_level(seconds * 7.4f, 30.0f, 62.0f); + status.rx_level = synth_level(seconds * 5.1f, 2.0f, 8.0f); + } + else + { + status.tx_level = synth_level(seconds * 9.3f, 0.0f, 5.0f); + status.rx_level = synth_level(seconds * 4.8f, 24.0f, 58.0f); + } + return status; +} + +} // namespace + +bool is_supported() +{ + return true; +} + +CapabilityStatus capability_status() +{ + return {CapabilityState::Simulated, + "Synthetic walkie-talkie audio. No real audio/radio path."}; +} + +bool start() +{ + std::lock_guard lock(s_mutex); + s_last_error.clear(); + s_active = true; + s_started_at = Clock::now(); + return true; +} + +void stop() +{ + std::lock_guard lock(s_mutex); + s_active = false; +} + +bool is_active() +{ + std::lock_guard lock(s_mutex); + return s_active; +} + +int volume() +{ + return std::clamp(env_int_or_default(kVolumeEnv, 78), 0, 100); +} + +Status get_status() +{ + std::lock_guard lock(s_mutex); + return current_status_locked(); +} + +const char* last_error() +{ + std::lock_guard lock(s_mutex); + return s_last_error.empty() ? "" : s_last_error.c_str(); +} + +} // namespace platform::ui::walkie diff --git a/platform/linux/common/src/platform/ui/wifi_runtime.cpp b/platform/linux/common/src/platform/ui/wifi_runtime.cpp new file mode 100644 index 00000000..aa46f3b9 --- /dev/null +++ b/platform/linux/common/src/platform/ui/wifi_runtime.cpp @@ -0,0 +1,109 @@ +#include "platform/ui/wifi_runtime.h" + +#include "platform/ui/settings_store.h" + +#include +#include +#include + +namespace platform::ui::wifi +{ +namespace +{ + +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kWifiEnabledKey = "wifi_enabled"; +constexpr const char* kWifiSsidKey = "wifi_ssid"; +constexpr const char* kWifiPasswordKey = "wifi_password"; +constexpr const char* kUnsupportedMessage = "Wi-Fi unsupported on Linux Cardputer Zero"; + +void copy_text(char* out, std::size_t out_len, const char* text) +{ + if (!out || out_len == 0) + { + return; + } + std::snprintf(out, out_len, "%s", text ? text : ""); +} + +bool has_credentials(const Config& config) +{ + return config.ssid[0] != '\0'; +} + +} // namespace + +bool is_supported() +{ + return false; +} + +bool load_config(Config& out) +{ + out = Config{}; + out.enabled = ::platform::ui::settings_store::get_bool(kSettingsNs, kWifiEnabledKey, false); + + std::string value; + if (::platform::ui::settings_store::get_string(kSettingsNs, kWifiSsidKey, value)) + { + copy_text(out.ssid, sizeof(out.ssid), value.c_str()); + } + + value.clear(); + if (::platform::ui::settings_store::get_string(kSettingsNs, kWifiPasswordKey, value)) + { + copy_text(out.password, sizeof(out.password), value.c_str()); + } + + return true; +} + +bool save_config(const Config& config) +{ + const bool ssid_ok = + ::platform::ui::settings_store::put_string(kSettingsNs, kWifiSsidKey, config.ssid); + const bool password_ok = + ::platform::ui::settings_store::put_string(kSettingsNs, kWifiPasswordKey, config.password); + ::platform::ui::settings_store::put_bool(kSettingsNs, kWifiEnabledKey, config.enabled); + return ssid_ok && password_ok; +} + +bool apply_enabled(bool enabled) +{ + return !enabled; +} + +bool connect(const Config*) +{ + return false; +} + +void disconnect() +{ +} + +bool scan(std::vector& out_results) +{ + out_results.clear(); + return false; +} + +Status status() +{ + Config config{}; + (void)load_config(config); + + Status out{}; + out.supported = false; + out.enabled = false; + out.connected = false; + out.scanning = false; + out.has_credentials = has_credentials(config); + out.rssi = -127; + out.state = ConnectionState::Unsupported; + copy_text(out.ssid, sizeof(out.ssid), config.ssid); + copy_text(out.message, sizeof(out.message), kUnsupportedMessage); + return out; +} + +} // namespace platform::ui::wifi diff --git a/platform/linux/common/src/ui/gps_shared_compat.cpp b/platform/linux/common/src/ui/gps_shared_compat.cpp new file mode 100644 index 00000000..7f55250a --- /dev/null +++ b/platform/linux/common/src/ui/gps_shared_compat.cpp @@ -0,0 +1,35 @@ +#include "ui/screens/gps/gps_state.h" +#include "ui/screens/gps/gps_tracker_overlay.h" +#include "ui/widgets/system_notification.h" + +#include + +GPSPageState g_gps_state{}; + +bool gps_tracker_load_file(const char* path, bool show_toast) +{ + (void)show_toast; + g_gps_state.tracker_file = path ? path : ""; + g_gps_state.tracker_overlay_active = !g_gps_state.tracker_file.empty(); + return g_gps_state.tracker_overlay_active; +} + +void gps_tracker_open_modal() +{ +} + +void gps_tracker_draw_event(lv_event_t* e) +{ + (void)e; +} + +void gps_tracker_cleanup() +{ + g_gps_state.tracker_overlay_active = false; + g_gps_state.tracker_file.clear(); +} + +void show_toast(const char* message, uint32_t duration_ms) +{ + ::ui::SystemNotification::show(message ? message : "", duration_ms); +} diff --git a/platform/linux/common/src/ui/localization.cpp b/platform/linux/common/src/ui/localization.cpp new file mode 100644 index 00000000..71884ba1 --- /dev/null +++ b/platform/linux/common/src/ui/localization.cpp @@ -0,0 +1,219 @@ +#include "ui/localization.h" + +#include +#include +#include + +#include "ui/assets/fonts/font_utils.h" + +namespace ui::i18n +{ +namespace +{ + +constexpr LocaleInfo kEnglishLocale{ + .id = "en", + .display_name = "English", + .native_name = "English", + .ui_font_pack_id = nullptr, + .content_font_pack_id = nullptr, + .ime_pack_id = nullptr, + .builtin = true, +}; + +} // namespace + +void reload_language() +{ + ::ui::fonts::refresh_locale_font_bindings(); +} + +std::size_t locale_count() +{ + return 1; +} + +const LocaleInfo* locale_at(std::size_t index) +{ + return index == 0 ? &kEnglishLocale : nullptr; +} + +int current_locale_index() +{ + return 0; +} + +const char* current_locale_id() +{ + return kEnglishLocale.id; +} + +const char* current_locale_display_name() +{ + return kEnglishLocale.display_name; +} + +const char* current_locale_direction() +{ + return "ltr"; +} + +bool set_locale(const char* locale_id, bool persist) +{ + (void)persist; + return locale_id != nullptr && std::string(locale_id) == kEnglishLocale.id; +} + +bool set_locale_by_index(std::size_t index, bool persist) +{ + (void)persist; + return index == 0; +} + +const lv_font_t* active_ui_font_fallback() +{ + return nullptr; +} + +const lv_font_t* active_content_font_fallback() +{ + return nullptr; +} + +const lv_font_t* locale_preview_font(const char* locale_id, const lv_font_t* ascii_font) +{ + (void)locale_id; + return ascii_font; +} + +bool ensure_content_font_for_text(const char* text) +{ + (void)text; + return false; +} + +std::size_t ime_count() +{ + return 0; +} + +const ImeInfo* ime_at(std::size_t index) +{ + (void)index; + return nullptr; +} + +std::size_t enabled_ime_count() +{ + return 0; +} + +bool ime_enabled(const char* ime_id) +{ + (void)ime_id; + return false; +} + +bool set_ime_enabled(const char* ime_id, bool enabled, bool persist) +{ + (void)ime_id; + (void)enabled; + (void)persist; + return false; +} + +bool any_enabled_script_input() +{ + return false; +} + +const char* active_ime_pack_id() +{ + return nullptr; +} + +bool active_locale_supports_script_input() +{ + return false; +} + +const char* tr(const char* english) +{ + return english ? english : ""; +} + +std::string vformat(const char* english_fmt, va_list args) +{ + if (english_fmt == nullptr) + { + return {}; + } + + va_list args_copy; + va_copy(args_copy, args); + const int required = std::vsnprintf(nullptr, 0, english_fmt, args_copy); + va_end(args_copy); + + if (required <= 0) + { + return {}; + } + + std::string formatted(static_cast(required), '\0'); + std::vsnprintf(formatted.data(), formatted.size() + 1, english_fmt, args); + return formatted; +} + +std::string format(const char* english_fmt, ...) +{ + va_list args; + va_start(args, english_fmt); + std::string formatted = vformat(english_fmt, args); + va_end(args); + return formatted; +} + +void set_label_text_raw(lv_obj_t* label, const char* text) +{ + if (label == nullptr) + { + return; + } + + const char* safe_text = text ? text : ""; + lv_label_set_text(label, safe_text); + ::ui::fonts::apply_localized_font(label, safe_text, nullptr); +} + +void set_label_text(lv_obj_t* label, const char* english) +{ + set_label_text_raw(label, tr(english)); +} + +void set_content_label_text_raw(lv_obj_t* label, const char* text) +{ + if (label == nullptr) + { + return; + } + + const char* safe_text = text ? text : ""; + lv_label_set_text(label, safe_text); + ::ui::fonts::apply_content_font(label, safe_text, nullptr); +} + +void set_content_label_text(lv_obj_t* label, const char* english) +{ + set_content_label_text_raw(label, tr(english)); +} + +void set_label_text_fmt(lv_obj_t* label, const char* english_fmt, ...) +{ + va_list args; + va_start(args, english_fmt); + const std::string text = vformat(english_fmt, args); + va_end(args); + set_label_text_raw(label, text.c_str()); +} + +} // namespace ui::i18n diff --git a/platform/linux/common/src/ui/menu_runtime.cpp b/platform/linux/common/src/ui/menu_runtime.cpp new file mode 100644 index 00000000..cddd8d9b --- /dev/null +++ b/platform/linux/common/src/ui/menu_runtime.cpp @@ -0,0 +1,47 @@ +#include "ui/menu/menu_runtime.h" + +namespace ui::menu_runtime +{ +namespace +{ + +Scene s_scene = Scene::Menu; +bool s_menu_active = true; + +} // namespace + +void init(lv_obj_t* screen_root, lv_obj_t* main_screen, lv_obj_t* menu_panel, const Hooks& hooks) +{ + (void)screen_root; + (void)main_screen; + (void)menu_panel; + (void)hooks; + s_scene = Scene::Menu; + s_menu_active = true; +} + +void showWatchFace() +{ +} + +void onWakeFromSleep() +{ +} + +void setMenuActive(bool active) +{ + s_menu_active = active; +} + +void setScene(Scene scene) +{ + s_scene = scene; + s_menu_active = (scene == Scene::Menu); +} + +Scene currentScene() +{ + return s_scene; +} + +} // namespace ui::menu_runtime diff --git a/platform/linux/common/src/ui/mt_protocol_air_compat.cpp b/platform/linux/common/src/ui/mt_protocol_air_compat.cpp new file mode 100644 index 00000000..da973a05 --- /dev/null +++ b/platform/linux/common/src/ui/mt_protocol_air_compat.cpp @@ -0,0 +1,65 @@ +#include "meshtastic/config.pb.h" + +#include + +namespace chat +{ +namespace meshtastic +{ + +void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset preset, bool wide_lora, + float& bw_khz, uint8_t& sf, uint8_t& cr_denom) +{ + switch (preset) + { + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + bw_khz = wide_lora ? 1625.0f : 500.0f; + cr_denom = 5; + sf = 7; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 7; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 8; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 9; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 10; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: + bw_khz = wide_lora ? 1625.0f : 500.0f; + cr_denom = 8; + sf = 11; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + bw_khz = wide_lora ? 406.25f : 125.0f; + cr_denom = 8; + sf = 11; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + bw_khz = wide_lora ? 406.25f : 125.0f; + cr_denom = 8; + sf = 12; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + default: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 11; + break; + } +} + +} // namespace meshtastic +} // namespace chat diff --git a/platform/linux/common/src/ui/shared_ui_shell.cpp b/platform/linux/common/src/ui/shared_ui_shell.cpp new file mode 100644 index 00000000..2db5503c --- /dev/null +++ b/platform/linux/common/src/ui/shared_ui_shell.cpp @@ -0,0 +1,558 @@ +#include "ui/shared_ui_shell.h" + +#include "ui/app_catalog.h" +#include "ui/app_runtime.h" +#include "ui/callback_app_screen.h" +#include "ui/localization.h" +#include "ui/screens/chat/chat_page_shell.h" +#include "ui/screens/contacts/contacts_page_shell.h" +#include "ui/screens/energy_sweep/energy_sweep_page_shell.h" +#include "ui/screens/extensions/extensions_page_shell.h" +#include "ui/screens/gnss/gnss_skyplot_page_shell.h" +#include "ui/screens/gps/gps_page_shell.h" +#include "ui/screens/pc_link/pc_link_page_shell.h" +#include "ui/screens/settings/settings_page_shell.h" +#include "ui/screens/sstv/sstv_page_shell.h" +#include "ui/screens/team/team_page_shell.h" +#include "ui/screens/tracker/tracker_page_shell.h" +#include "ui/screens/walkie_talkie/walkie_talkie_page_shell.h" +#include "ui/startup_ui_shell.h" +#include "ui/ui_boot.h" +#include "ui/ui_theme.h" + +namespace trailmate::cardputer_zero::linux_ui +{ +namespace +{ + +extern "C" +{ + extern const lv_image_dsc_t Chat; + extern const lv_image_dsc_t contact; + extern const lv_image_dsc_t gps_icon; + extern const lv_image_dsc_t Satellite; + extern const lv_image_dsc_t Setting; + extern const lv_image_dsc_t Spectrum; + extern const lv_image_dsc_t rf; + extern const lv_image_dsc_t sstv; + extern const lv_image_dsc_t team_icon; + extern const lv_image_dsc_t tracker_icon; + extern const lv_image_dsc_t ext; + extern const lv_image_dsc_t walkie_talkie; +} + +struct PlaceholderPageSpec +{ + const char* stable_id = nullptr; + const char* title = nullptr; + const char* body = nullptr; + const char* chip = nullptr; + const lv_image_dsc_t* icon = nullptr; + uint32_t chip_color = 0xEBA341; +}; + +void requestExit() +{ + ui_request_exit_to_menu(); +} + +void backButtonEventCb(lv_event_t* event) +{ + const lv_event_code_t code = lv_event_get_code(event); + if (code == LV_EVENT_CLICKED) + { + requestExit(); + return; + } + + if (code != LV_EVENT_KEY) + { + return; + } + + const uint32_t key = lv_event_get_key(event); + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE || key == LV_KEY_LEFT) + { + requestExit(); + } +} + +lv_obj_t* createChip(lv_obj_t* parent, const PlaceholderPageSpec& spec) +{ + lv_obj_t* chip = lv_obj_create(parent); + lv_obj_set_size(chip, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_color(chip, lv_color_hex(spec.chip_color), 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(chip, 0, 0); + lv_obj_set_style_radius(chip, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_shadow_width(chip, 0, 0); + lv_obj_set_style_pad_left(chip, 10, 0); + lv_obj_set_style_pad_right(chip, 10, 0); + lv_obj_set_style_pad_top(chip, 4, 0); + lv_obj_set_style_pad_bottom(chip, 4, 0); + lv_obj_clear_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* label = lv_label_create(chip); + lv_obj_set_style_text_font(label, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(label, ui::theme::text(), 0); + ::ui::i18n::set_label_text(label, spec.chip ? spec.chip : "Preview"); + lv_obj_center(label); + return chip; +} + +void placeholderEnter(void* user_data, lv_obj_t* parent) +{ + auto* spec = static_cast(user_data); + if (spec == nullptr || parent == nullptr) + { + return; + } + + lv_obj_clean(parent); + set_default_group(app_g); + + lv_obj_t* page = lv_obj_create(parent); + lv_obj_set_size(page, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(page, ui::theme::page_bg(), 0); + lv_obj_set_style_bg_opa(page, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(page, 0, 0); + lv_obj_set_style_radius(page, 0, 0); + lv_obj_set_style_shadow_width(page, 0, 0); + lv_obj_set_style_pad_all(page, 12, 0); + lv_obj_set_style_pad_row(page, 10, 0); + lv_obj_set_flex_flow(page, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(page, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_clear_flag(page, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* header = lv_obj_create(page); + lv_obj_set_width(header, LV_PCT(100)); + lv_obj_set_height(header, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(header, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(header, 0, 0); + lv_obj_set_style_radius(header, 0, 0); + lv_obj_set_style_shadow_width(header, 0, 0); + lv_obj_set_style_pad_all(header, 0, 0); + lv_obj_set_style_pad_column(header, 8, 0); + lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(header, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* back_btn = lv_btn_create(header); + lv_obj_set_size(back_btn, 40, 32); + lv_obj_set_style_bg_color(back_btn, ui::theme::surface_alt(), 0); + lv_obj_set_style_border_width(back_btn, 0, 0); + lv_obj_set_style_radius(back_btn, 10, 0); + lv_obj_add_event_cb(back_btn, backButtonEventCb, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(back_btn, backButtonEventCb, LV_EVENT_KEY, nullptr); + + lv_obj_t* back_label = lv_label_create(back_btn); + lv_obj_set_style_text_color(back_label, ui::theme::text(), 0); + lv_obj_set_style_text_font(back_label, &lv_font_montserrat_18, 0); + lv_label_set_text(back_label, LV_SYMBOL_LEFT); + lv_obj_center(back_label); + + lv_group_add_obj(app_g, back_btn); + lv_group_focus_obj(back_btn); + + lv_obj_t* title_wrap = lv_obj_create(header); + lv_obj_set_flex_grow(title_wrap, 1); + lv_obj_set_height(title_wrap, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(title_wrap, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(title_wrap, 0, 0); + lv_obj_set_style_radius(title_wrap, 0, 0); + lv_obj_set_style_shadow_width(title_wrap, 0, 0); + lv_obj_set_style_pad_all(title_wrap, 0, 0); + lv_obj_set_flex_flow(title_wrap, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(title_wrap, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_clear_flag(title_wrap, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* title = lv_label_create(title_wrap); + lv_obj_set_style_text_color(title, ui::theme::text(), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_18, 0); + ::ui::i18n::set_label_text(title, spec->title); + + lv_obj_t* subtitle = lv_label_create(title_wrap); + lv_obj_set_style_text_color(subtitle, ui::theme::text_muted(), 0); + lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_12, 0); + ::ui::i18n::set_label_text(subtitle, "Shared UI shell inside the Linux shell"); + + createChip(header, *spec); + + lv_obj_t* body_card = lv_obj_create(page); + lv_obj_set_width(body_card, LV_PCT(100)); + lv_obj_set_flex_grow(body_card, 1); + lv_obj_set_style_bg_color(body_card, ui::theme::surface(), 0); + lv_obj_set_style_bg_opa(body_card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(body_card, ui::theme::border(), 0); + lv_obj_set_style_border_width(body_card, 1, 0); + lv_obj_set_style_radius(body_card, 14, 0); + lv_obj_set_style_shadow_width(body_card, 0, 0); + lv_obj_set_style_pad_all(body_card, 12, 0); + lv_obj_set_style_pad_row(body_card, 10, 0); + lv_obj_set_flex_flow(body_card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(body_card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_clear_flag(body_card, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* body = lv_label_create(body_card); + lv_obj_set_width(body, LV_PCT(100)); + lv_label_set_long_mode(body, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_color(body, ui::theme::text(), 0); + lv_obj_set_style_text_font(body, &lv_font_montserrat_14, 0); + ::ui::i18n::set_content_label_text(body, spec->body); + + lv_obj_t* footer = lv_label_create(page); + lv_obj_set_width(footer, LV_PCT(100)); + lv_obj_set_style_text_color(footer, ui::theme::text_muted(), 0); + lv_obj_set_style_text_font(footer, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(footer, LV_TEXT_ALIGN_CENTER, 0); + ::ui::i18n::set_label_text( + footer, + "Press Esc / Backspace or use the back button to return to the menu."); +} + +void placeholderExit(void* user_data, lv_obj_t* parent) +{ + (void)user_data; + if (parent != nullptr) + { + lv_obj_clean(parent); + } + set_default_group(menu_g); +} + +PlaceholderPageSpec s_chat_spec{ + .stable_id = "chat", + .title = "Chat", + .body = + "This placeholder is standing in for the future Linux chat slice. " + "The menu shell, tile navigation and app handoff are already real ui_shared code.", + .chip = "Phase 1", + .icon = &Chat, + .chip_color = 0xF1B75A, +}; + +PlaceholderPageSpec s_gps_spec{ + .stable_id = "map", + .title = "Map", + .body = + "GPS runtime is still a Linux stub on Cardputer Zero. " + "Bringing this page up next will mean replacing this placeholder with the shared GPS layout plus Linux adapters.", + .chip = "Stubbed", + .icon = &gps_icon, + .chip_color = 0xCFE4FF, +}; + +PlaceholderPageSpec s_contacts_spec{ + .stable_id = "contacts", + .title = "Contacts", + .body = + "Contacts is part of the shared product shape, but the Linux-side local data store is still being brought up. " + "This placeholder keeps the menu structure aligned while that seam is being implemented.", + .chip = "Planned", + .icon = &contact, + .chip_color = 0xF8D6B5, +}; + +PlaceholderPageSpec s_sky_plot_spec{ + .stable_id = "sky_plot", + .title = "Sky Plot", + .body = + "Sky Plot belongs in the Linux product shape, but the shared GNSS skyplot page still needs Linux-side data plumbing. " + "This placeholder keeps the menu structure aligned until that adapter is brought up.", + .chip = "Planned", + .icon = &Satellite, + .chip_color = 0xD8ECFF, +}; + +PlaceholderPageSpec s_team_spec{ + .stable_id = "team", + .title = "Team", + .body = + "Team mode is a real product entry and should appear in the Linux menu. " + "The page is still waiting on Linux-side team runtime and storage integration, so it stays as a placeholder for now.", + .chip = "Planned", + .icon = &team_icon, + .chip_color = 0xD4F0D2, +}; + +PlaceholderPageSpec s_tracker_spec{ + .stable_id = "tracker", + .title = "Tracker", + .body = + "Tracker is part of the full Cardputer Zero menu. " + "The Linux storage/runtime seam for routes and tracks is still being completed, so this entry is placeholder-backed for now.", + .chip = "Planned", + .icon = &tracker_icon, + .chip_color = 0xD9F1C9, +}; + +PlaceholderPageSpec s_pc_link_spec{ + .stable_id = "pc_link", + .title = "PC Link", + .body = + "PC Link is reserved here as a real product entry. " + "The Linux-side host link runtime is still a stub, so this page is currently a placeholder.", + .chip = "Stubbed", + .icon = &rf, + .chip_color = 0xD9E7FF, +}; + +PlaceholderPageSpec s_sstv_spec{ + .stable_id = "sstv", + .title = "SSTV", + .body = + "SSTV is included in the full menu shape even though Cardputer Zero does not have the runtime adapter yet. " + "This placeholder will be replaced once the Linux-side feature seam is ready.", + .chip = "Planned", + .icon = &sstv, + .chip_color = 0xFFE4B8, +}; + +PlaceholderPageSpec s_energy_sweep_spec{ + .stable_id = "energy_sweep", + .title = "Energy Sweep", + .body = + "Energy Sweep belongs to the shared product menu, but Linux still needs a real RF/runtime implementation before this page can become functional.", + .chip = "Stubbed", + .icon = &Spectrum, + .chip_color = 0xF8D6B5, +}; + +PlaceholderPageSpec s_extensions_spec{ + .stable_id = "extensions", + .title = "Extensions", + .body = + "Extensions will eventually host Linux-side package and add-on workflows. " + "The entry belongs in the menu now even though the actual page logic has not been migrated yet.", + .chip = "Planned", + .icon = &ext, + .chip_color = 0xF3D8FF, +}; + +PlaceholderPageSpec s_walkie_spec{ + .stable_id = "walkie_talkie", + .title = "Walkie Talkie", + .body = + "Walkie Talkie is part of the 12-entry product menu. " + "The Linux audio/radio runtime is not connected yet, so this stays as a placeholder until that seam is implemented.", + .chip = "Planned", + .icon = &walkie_talkie, + .chip_color = 0xFFD8E4, +}; + +ui::CallbackAppScreen s_chat_app{ + s_chat_spec.stable_id, + s_chat_spec.title, + s_chat_spec.icon, + chat::ui::shell::enter, + chat::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_gps_app{ + s_gps_spec.stable_id, + s_gps_spec.title, + s_gps_spec.icon, + gps::ui::shell::enter, + gps::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_contacts_app{ + s_contacts_spec.stable_id, + s_contacts_spec.title, + s_contacts_spec.icon, + contacts::ui::shell::enter, + contacts::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_sky_plot_app{ + s_sky_plot_spec.stable_id, + s_sky_plot_spec.title, + s_sky_plot_spec.icon, + gnss::ui::shell::enter, + gnss::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_team_app{ + s_team_spec.stable_id, + s_team_spec.title, + s_team_spec.icon, + team::ui::shell::enter, + team::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_tracker_app{ + s_tracker_spec.stable_id, + s_tracker_spec.title, + s_tracker_spec.icon, + tracker::ui::shell::enter, + tracker::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_pc_link_app{ + s_pc_link_spec.stable_id, + s_pc_link_spec.title, + s_pc_link_spec.icon, + pc_link::ui::shell::enter, + pc_link::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_sstv_app{ + s_sstv_spec.stable_id, + s_sstv_spec.title, + s_sstv_spec.icon, + sstv_page::ui::shell::enter, + sstv_page::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_energy_sweep_app{ + s_energy_sweep_spec.stable_id, + s_energy_sweep_spec.title, + s_energy_sweep_spec.icon, + energy_sweep::ui::shell::enter, + energy_sweep::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_extensions_app{ + s_extensions_spec.stable_id, + s_extensions_spec.title, + s_extensions_spec.icon, + extensions::ui::shell::enter, + extensions::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_walkie_app{ + s_walkie_spec.stable_id, + s_walkie_spec.title, + s_walkie_spec.icon, + walkie_page::ui::shell::enter, + walkie_page::ui::shell::exit, + nullptr, +}; + +ui::CallbackAppScreen s_settings_app{ + "settings", + "Settings", + &Setting, + settings::ui::shell::enter, + settings::ui::shell::exit, + nullptr, +}; + +AppScreen* s_apps[] = { + &s_chat_app, + &s_gps_app, + &s_sky_plot_app, + &s_contacts_app, + &s_team_app, + &s_tracker_app, + &s_pc_link_app, + &s_sstv_app, + &s_energy_sweep_app, + &s_walkie_app, + &s_extensions_app, + &s_settings_app, +}; + +ui::StaticAppCatalogState& catalogState() +{ + static ui::StaticAppCatalogState state = ui::makeStaticAppCatalogState(s_apps); + return state; +} + +[[nodiscard]] ui::startup_ui_shell::Hooks buildHooks() +{ + ui::startup_ui_shell::Hooks hooks{}; + hooks.apps = ui::makeStaticAppCatalog(&catalogState()); + return hooks; +} + +constexpr unsigned int kMenuBuildDelayMs = 140U; +constexpr unsigned int kThemeReadyDelayMs = 280U; +constexpr unsigned int kFinalizeDelayMs = 420U; + +} // namespace + +bool SharedUiShellStartup::begin() +{ + if (phase_ != Phase::Idle) + { + return true; + } + + started_at_ms_ = lv_tick_get(); + if (!ui::startup_ui_shell::prepareBootUi(buildHooks(), false)) + { + return false; + } + + phase_ = Phase::BootVisible; + return true; +} + +bool SharedUiShellStartup::runPhase(Phase next_phase, const char* log_line) +{ + if (log_line != nullptr && log_line[0] != '\0') + { + ui::boot::set_log_line(log_line); + } + phase_ = next_phase; + return true; +} + +bool SharedUiShellStartup::tick() +{ + if (phase_ == Phase::Idle) + { + return begin(); + } + + if (phase_ == Phase::Finalized) + { + return true; + } + + const unsigned int elapsed = lv_tick_elaps(started_at_ms_); + if (phase_ == Phase::BootVisible && elapsed >= kMenuBuildDelayMs) + { + ui::boot::set_log_line("Building menu shell..."); + if (!ui::startup_ui_shell::initializeMenuSkeleton(buildHooks())) + { + return false; + } + return runPhase(Phase::MenuBuilt, nullptr); + } + + if (phase_ == Phase::MenuBuilt && elapsed >= kThemeReadyDelayMs) + { + return runPhase(Phase::ThemeReady, "Preparing navigation and apps..."); + } + + if (phase_ == Phase::ThemeReady && elapsed >= kFinalizeDelayMs) + { + ui::boot::set_log_line("Starting Trail Mate..."); + if (!ui::startup_ui_shell::finalizeStartup(buildHooks(), false)) + { + return false; + } + return runPhase(Phase::Finalized, nullptr); + } + + return true; +} + +bool SharedUiShellStartup::ready() const noexcept +{ + return phase_ == Phase::Finalized; +} + +} // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/src/ui/shell_ui_runner.cpp b/platform/linux/common/src/ui/shell_ui_runner.cpp new file mode 100644 index 00000000..36935172 --- /dev/null +++ b/platform/linux/common/src/ui/shell_ui_runner.cpp @@ -0,0 +1,347 @@ +#include "ui/shell_ui_runner.h" + +#include +#include +#include +#include +#include +#include + +#include "lvgl.h" + +#include "app/input_event.h" +#include "app/linux_app_facade.h" +#include "core/canvas.h" +#include "core/display_profile.h" +#include "ui/screens/team/team_page_shell.h" +#include "ui/shared_ui_shell.h" + +namespace trailmate::cardputer_zero::linux_ui +{ +namespace +{ + +using clock = std::chrono::steady_clock; + +constexpr auto kFrameTime = std::chrono::milliseconds(16); + +std::chrono::steady_clock::time_point g_lvgl_start_time = clock::now(); + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +[[nodiscard]] std::uint32_t mapInputEvent(const app::InputEvent& event) noexcept +{ + switch (event.key) + { + case app::InputKey::Character: + return event.text == '\0' ? 0U + : static_cast(event.text); + case app::InputKey::Backspace: + return LV_KEY_BACKSPACE; + case app::InputKey::Enter: + return LV_KEY_ENTER; + case app::InputKey::Tab: + return LV_KEY_NEXT; + case app::InputKey::Home: + return LV_KEY_ESC; + case app::InputKey::Next: + return LV_KEY_NEXT; + case app::InputKey::Power: + return LV_KEY_ESC; + case app::InputKey::Left: + return LV_KEY_LEFT; + case app::InputKey::Right: + return LV_KEY_RIGHT; + case app::InputKey::Up: + return LV_KEY_UP; + case app::InputKey::Down: + return LV_KEY_DOWN; + case app::InputKey::Unknown: + case app::InputKey::Fn: + case app::InputKey::Ctrl: + case app::InputKey::Alt: + case app::InputKey::Shift: + return 0U; + } + return 0U; +} + +[[nodiscard]] std::uint8_t expand5(std::uint16_t value) noexcept +{ + return static_cast((value * 255U) / 31U); +} + +[[nodiscard]] std::uint8_t expand6(std::uint16_t value) noexcept +{ + return static_cast((value * 255U) / 63U); +} + +[[nodiscard]] core::Color rgb565ToColor(std::uint16_t pixel) noexcept +{ + const auto red = static_cast((pixel >> 11U) & 0x1FU); + const auto green = static_cast((pixel >> 5U) & 0x3FU); + const auto blue = static_cast(pixel & 0x1FU); + return core::rgba(expand5(red), expand6(green), expand5(blue)); +} + +[[nodiscard]] std::uint32_t tickNow() noexcept +{ + return static_cast( + std::chrono::duration_cast( + clock::now() - g_lvgl_start_time) + .count()); +} + +struct QueuedKeyEvent +{ + std::uint32_t key{}; + lv_indev_state_t state{LV_INDEV_STATE_RELEASED}; +}; + +bool dispatchTeamUiEvent(sys::Event* event) +{ + return team::ui::shell::handle_event(nullptr, event); +} + +} // namespace + +// =================================================================== +// ShellSession +// =================================================================== + +struct ShellSession::Impl +{ + SharedUiShellStartup startup{}; + std::deque key_events{}; +}; + +ShellSession::ShellSession() : impl_(std::make_unique()) {} + +ShellSession::~ShellSession() +{ + if (initialized_) + { + setTeamUiEventDispatcher(nullptr); + app_facade_.shutdown(); + } +} + +bool ShellSession::begin() +{ + if (initialized_) return true; + if (!app_facade_.initialize()) return false; + setTeamUiEventDispatcher(dispatchTeamUiEvent); + if (!impl_->startup.begin()) return false; + initialized_ = true; + return true; +} + +void ShellSession::tick() +{ + if (!initialized_) return; + if (!impl_->startup.tick()) + throw std::runtime_error("Shared UI startup sequence failed."); + app_facade_.updateCoreServices(); + app_facade_.tickEventRuntime(); + app_facade_.dispatchPendingEvents(); +} + +bool ShellSession::ready() const noexcept +{ + return initialized_ && impl_->startup.ready(); +} + +void ShellSession::enqueueInputs(const std::vector& events) +{ + for (const auto& event : events) + { + const std::uint32_t mapped = mapInputEvent(event); + if (mapped == 0U) continue; + impl_->key_events.push_back( + QueuedKeyEvent{mapped, LV_INDEV_STATE_PRESSED}); + impl_->key_events.push_back( + QueuedKeyEvent{mapped, LV_INDEV_STATE_RELEASED}); + } +} + +bool ShellSession::dequeueKeyEvent(std::uint32_t* key, lv_indev_state_t* state) +{ + if (impl_->key_events.empty()) return false; + const QueuedKeyEvent next = impl_->key_events.front(); + impl_->key_events.pop_front(); + *key = next.key; + *state = next.state; + return true; +} + +bool ShellSession::hasPendingKeyEvent() const noexcept +{ + return !impl_->key_events.empty(); +} + +// =================================================================== +// CanvasLvglHost +// =================================================================== + +CanvasLvglHost::CanvasLvglHost(ShellSession& shell) + : shell_(shell), + canvas_(core::kDisplayWidth, core::kDisplayHeight), + frame_buffer_( + static_cast(core::kDisplayWidth * core::kDisplayHeight), + 0) +{ + g_lvgl_start_time = clock::now(); + lv_init(); + lv_tick_set_cb(tickNow); + + display_ = lv_display_create(core::kDisplayWidth, core::kDisplayHeight); + if (display_ == nullptr) + throw std::runtime_error( + "Failed to create LVGL display for the Linux shell."); + + lv_display_set_default(display_); + lv_display_set_user_data(display_, this); + lv_display_set_color_format(display_, LV_COLOR_FORMAT_RGB565); + lv_display_set_buffers( + display_, + frame_buffer_.data(), + nullptr, + static_cast(frame_buffer_.size() * sizeof(std::uint16_t)), + LV_DISPLAY_RENDER_MODE_DIRECT); + lv_display_set_flush_cb(display_, flushCb); + + keypad_ = lv_indev_create(); + if (keypad_ == nullptr) + throw std::runtime_error( + "Failed to create LVGL keypad input for the Linux shell."); + + lv_indev_set_type(keypad_, LV_INDEV_TYPE_KEYPAD); + lv_indev_set_display(keypad_, display_); + lv_indev_set_user_data(keypad_, this); + lv_indev_set_read_cb(keypad_, readInputCb); + + // Render the first frame before the main loop starts. + tick(); +} + +CanvasLvglHost::~CanvasLvglHost() +{ + if (keypad_ != nullptr) + { + lv_indev_delete(keypad_); + keypad_ = nullptr; + } + if (display_ != nullptr) + { + lv_display_delete(display_); + display_ = nullptr; + } + lv_deinit(); +} + +void CanvasLvglHost::tick() +{ + shell_.tick(); + lv_timer_handler(); + if (dirty_) + { + copyFrameBufferToCanvas(); + dirty_ = false; + } +} + +const core::Canvas& CanvasLvglHost::canvas() const noexcept +{ + return canvas_; +} + +// --------------------------------------------------------------------------- +// Static LVGL callbacks (cast void* args back to typed pointers) +// --------------------------------------------------------------------------- + +void CanvasLvglHost::flushCb(lv_display_t* display, + const lv_area_t* /*area*/, + uint8_t* /*px_map*/) +{ + auto* host = static_cast( + lv_display_get_user_data(display)); + if (host != nullptr) host->dirty_ = true; + lv_display_flush_ready(display); +} + +void CanvasLvglHost::readInputCb(lv_indev_t* indev, lv_indev_data_t* d) +{ + auto* host = static_cast(lv_indev_get_user_data(indev)); + if (host == nullptr || d == nullptr) return; + + d->state = LV_INDEV_STATE_RELEASED; + d->key = 0U; + + std::uint32_t key = 0U; + lv_indev_state_t state = LV_INDEV_STATE_RELEASED; + if (!host->shell_.dequeueKeyEvent(&key, &state)) return; + + d->state = state; + d->key = key; + // Peek the queue without consuming the next event. + d->continue_reading = host->shell_.hasPendingKeyEvent(); +} + +// --------------------------------------------------------------------------- +// Framebuffer -> Canvas copy +// --------------------------------------------------------------------------- + +void CanvasLvglHost::copyFrameBufferToCanvas() +{ + for (int y = 0; y < core::kDisplayHeight; ++y) + { + for (int x = 0; x < core::kDisplayWidth; ++x) + { + const auto index = + static_cast((y * core::kDisplayWidth) + x); + canvas_.setPixel(x, y, rgb565ToColor(frame_buffer_[index])); + } + } +} + +// =================================================================== +// runShellUi (backward-compatible convenience) +// =================================================================== + +void runShellUi(platform::SurfacePresenter& presenter, + std::chrono::milliseconds auto_exit_after) +{ + ShellSession shell; + if (!shell.begin()) + throw std::runtime_error( + "Failed to begin ShellSession for the shared UI shell."); + + CanvasLvglHost host{shell}; + + auto next_frame = clock::now(); + const auto started_at = next_frame; + + while (presenter.pump()) + { + shell.enqueueInputs(presenter.drainInput()); + + if (auto_exit_after > std::chrono::milliseconds::zero() && + (clock::now() - started_at) >= auto_exit_after) + { + break; + } + + host.tick(); + presenter.present(host.canvas()); + + next_frame += kFrameTime; + std::this_thread::sleep_until(next_frame); + + if (clock::now() > next_frame + std::chrono::milliseconds(250)) + next_frame = clock::now(); + } +} + +} // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/src/ui/ui_common.cpp b/platform/linux/common/src/ui/ui_common.cpp new file mode 100644 index 00000000..4d934822 --- /dev/null +++ b/platform/linux/common/src/ui/ui_common.cpp @@ -0,0 +1,38 @@ +#include "ui/ui_common.h" + +#include "platform/ui/time_runtime.h" + +namespace +{ + +void noop_top_bar_battery_update() +{ +} + +} // namespace + +void ui_update_top_bar_battery(ui::widgets::TopBar& bar) +{ + (void)bar; + noop_top_bar_battery_update(); +} + +int ui_get_timezone_offset_min() +{ + return ::platform::ui::time::timezone_offset_min(); +} + +void ui_set_timezone_offset_min(int offset_min) +{ + ::platform::ui::time::set_timezone_offset_min(offset_min); +} + +time_t ui_apply_timezone_offset(time_t utc_seconds) +{ + return ::platform::ui::time::apply_timezone_offset(utc_seconds); +} + +bool ui_take_screenshot_to_sd() +{ + return false; +} diff --git a/platform/linux/common/src/ui/ui_status.cpp b/platform/linux/common/src/ui/ui_status.cpp new file mode 100644 index 00000000..cfe4299d --- /dev/null +++ b/platform/linux/common/src/ui/ui_status.cpp @@ -0,0 +1,49 @@ +#include "ui/ui_status.h" + +namespace ui::status +{ + +void init() +{ +} + +void register_menu_status_row(lv_obj_t* row, + lv_obj_t* route_icon, + lv_obj_t* tracker_icon, + lv_obj_t* gps_icon, + lv_obj_t* wifi_icon, + lv_obj_t* team_icon, + lv_obj_t* msg_icon, + lv_obj_t* ble_icon) +{ + (void)row; + (void)route_icon; + (void)tracker_icon; + (void)gps_icon; + (void)wifi_icon; + (void)team_icon; + (void)msg_icon; + (void)ble_icon; +} + +void register_chat_badge(lv_obj_t* badge_bg, lv_obj_t* badge_label) +{ + (void)badge_bg; + (void)badge_label; +} + +void force_update() +{ +} + +int get_total_unread() +{ + return 0; +} + +void set_menu_active(bool active) +{ + (void)active; +} + +} // namespace ui::status diff --git a/platform/linux/common/src/ui/widgets/map/map_tiles.cpp b/platform/linux/common/src/ui/widgets/map/map_tiles.cpp new file mode 100644 index 00000000..2ac2cc33 --- /dev/null +++ b/platform/linux/common/src/ui/widgets/map/map_tiles.cpp @@ -0,0 +1,819 @@ +/** + * @file map_tiles.cpp + * @brief Linux map tile runtime for shared map viewport pages. + */ + +#include "ui/widgets/map/map_tiles.h" + +#include "lvgl.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform/linux/runtime_paths.h" + +namespace +{ + +constexpr double kPi = 3.14159265358979323846; +constexpr double kMaxMercatorLat = 85.05112878; + +uint8_t g_requested_map_source = 0; +bool g_requested_contour_enabled = false; +bool g_missing_tile_notice_pending = false; +bool g_missing_tile_notice_emitted = false; +uint8_t g_missing_tile_notice_source = 0; + +const char* base_source_dir(uint8_t map_source); +const char* base_source_ext(uint8_t map_source); +const char* major_contour_profile_for_zoom(int z); + +uint32_t now_ms() +{ + using clock = std::chrono::steady_clock; + return static_cast( + std::chrono::duration_cast(clock::now().time_since_epoch()).count()); +} + +std::filesystem::path default_storage_root() +{ + return ::platform::linux_runtime::resolve_paths().sd_root; +} + +std::filesystem::path build_base_tile_actual_path(int z, int x, int y, uint8_t map_source) +{ + return default_storage_root() / "maps" / "base" / base_source_dir(map_source) / std::to_string(z) / std::to_string(x) / + (std::to_string(y) + "." + base_source_ext(map_source)); +} + +std::filesystem::path build_contour_tile_actual_path(int z, int x, int y) +{ + const char* profile = major_contour_profile_for_zoom(z); + if (!profile) + { + return {}; + } + + return default_storage_root() / "maps" / "contour" / profile / std::to_string(z) / std::to_string(x) / + (std::to_string(y) + ".png"); +} + +bool build_lvgl_path_from_actual(const std::filesystem::path& actual_path, char* out_path, size_t out_size) +{ + if (!out_path || out_size == 0 || actual_path.empty()) + { + return false; + } + + const std::string actual = actual_path.string(); + std::snprintf(out_path, out_size, "A:%s", actual.c_str()); + out_path[out_size - 1] = '\0'; + return true; +} + +const char* base_source_dir(uint8_t map_source) +{ + switch (sanitize_map_source(map_source)) + { + case 1: + return "terrain"; + case 2: + return "satellite"; + case 0: + default: + return "osm"; + } +} + +const char* base_source_ext(uint8_t map_source) +{ + return sanitize_map_source(map_source) == 2 ? "jpg" : "png"; +} + +const char* major_contour_profile_for_zoom(int z) +{ + if (z <= 7) + { + return nullptr; + } + if (z == 8) + { + return "major-500"; + } + if (z == 9) + { + return "major-200"; + } + if (z == 10) + { + return "major-500"; + } + if (z == 11) + { + return "major-200"; + } + if (z <= 14) + { + return "major-100"; + } + if (z <= 16) + { + return "major-50"; + } + return "major-25"; +} + +double clamp_lat(double lat) +{ + return std::clamp(lat, -kMaxMercatorLat, kMaxMercatorLat); +} + +double world_tiles(int zoom) +{ + return static_cast(1U << std::clamp(zoom, 0, 18)); +} + +double world_size_px(int zoom) +{ + return world_tiles(zoom) * static_cast(TILE_SIZE); +} + +double lon_to_world_x(double lon, int zoom) +{ + const double wrapped_lon = std::fmod(lon + 180.0, 360.0); + const double normalized_lon = wrapped_lon < 0.0 ? wrapped_lon + 360.0 : wrapped_lon; + return (normalized_lon / 360.0) * world_size_px(zoom); +} + +double lat_to_world_y(double lat, int zoom) +{ + const double clamped_lat = clamp_lat(lat); + const double lat_rad = clamped_lat * kPi / 180.0; + const double mercator = std::log(std::tan(kPi / 4.0 + lat_rad / 2.0)); + const double normalized = (1.0 - mercator / kPi) / 2.0; + const double size = world_size_px(zoom); + return std::clamp(normalized * size, 0.0, size - 1.0); +} + +void world_to_lat_lng(double world_x, double world_y, int zoom, double& lat, double& lng) +{ + const double size = world_size_px(zoom); + if (size <= 0.0) + { + lat = 0.0; + lng = 0.0; + return; + } + + const double wrapped_x = std::fmod(world_x, size); + const double normalized_x = wrapped_x < 0.0 ? wrapped_x + size : wrapped_x; + const double normalized_y = std::clamp(world_y / size, 0.0, 1.0); + + lng = (normalized_x / size) * 360.0 - 180.0; + + const double mercator = kPi * (1.0 - 2.0 * normalized_y); + lat = std::atan(std::sinh(mercator)) * 180.0 / kPi; +} + +void delete_tile_object(MapTile& tile) +{ + if (tile.img_obj && lv_obj_is_valid(tile.img_obj)) + { + lv_obj_del(tile.img_obj); + } + tile.img_obj = nullptr; + tile.contour_obj = nullptr; + tile.contour_loaded = false; + tile.contour_checked = false; + tile.has_png_file = false; + tile.base_missing = false; +} + +MapTile* find_tile(TileContext& ctx, int32_t x, int32_t y, int z, uint8_t map_source) +{ + if (!ctx.tiles) + { + return nullptr; + } + + for (auto& tile : *ctx.tiles) + { + if (tile.x == x && tile.y == y && tile.z == z && tile.map_source == map_source) + { + return &tile; + } + } + return nullptr; +} + +void refresh_status_flags(TileContext& ctx) +{ + if (ctx.has_map_data) + { + *ctx.has_map_data = false; + } + if (ctx.has_visible_map_data) + { + *ctx.has_visible_map_data = false; + } + + if (!ctx.tiles) + { + return; + } + + for (const auto& tile : *ctx.tiles) + { + if (tile.has_png_file && ctx.has_map_data) + { + *ctx.has_map_data = true; + } + if (tile.visible && tile.has_png_file && ctx.has_visible_map_data) + { + *ctx.has_visible_map_data = true; + } + } +} + +void mark_missing_tile(uint8_t map_source) +{ + if (g_missing_tile_notice_emitted) + { + return; + } + + g_missing_tile_notice_pending = true; + g_missing_tile_notice_emitted = true; + g_missing_tile_notice_source = sanitize_map_source(map_source); +} + +void style_tile_card(lv_obj_t* obj, uint8_t map_source, bool has_base) +{ + if (!obj) + { + return; + } + + lv_obj_set_style_radius(obj, 0, 0); + lv_obj_set_style_border_width(obj, 1, 0); + lv_obj_set_style_pad_all(obj, 6, 0); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + + if (!has_base) + { + lv_obj_set_style_bg_color(obj, lv_color_hex(0xE3D7C2), 0); + lv_obj_set_style_border_color(obj, lv_color_hex(0xB08F6A), 0); + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); + return; + } + + switch (sanitize_map_source(map_source)) + { + case 1: + lv_obj_set_style_bg_color(obj, lv_color_hex(0xD7E9C7), 0); + lv_obj_set_style_border_color(obj, lv_color_hex(0x6F9E56), 0); + break; + case 2: + lv_obj_set_style_bg_color(obj, lv_color_hex(0xD4DFE9), 0); + lv_obj_set_style_border_color(obj, lv_color_hex(0x587A9C), 0); + break; + case 0: + default: + lv_obj_set_style_bg_color(obj, lv_color_hex(0xF5ECD8), 0); + lv_obj_set_style_border_color(obj, lv_color_hex(0xD2B075), 0); + break; + } + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); +} + +void create_or_refresh_tile_card(TileContext& ctx, MapTile& tile) +{ + if (!ctx.map_container || !ctx.anchor) + { + return; + } + + int screen_x = 0; + int screen_y = 0; + if (!tile_screen_pos_xyz(ctx, static_cast(tile.x), static_cast(tile.y), tile.z, screen_x, screen_y)) + { + return; + } + + const auto base_actual_path = + build_base_tile_actual_path(tile.z, static_cast(tile.x), static_cast(tile.y), tile.map_source); + const bool has_base = std::filesystem::exists(base_actual_path); + + const auto contour_actual_path = + g_requested_contour_enabled ? build_contour_tile_actual_path(tile.z, static_cast(tile.x), static_cast(tile.y)) + : std::filesystem::path{}; + const bool has_contour = !contour_actual_path.empty() && std::filesystem::exists(contour_actual_path); + + if (!tile.img_obj || !lv_obj_is_valid(tile.img_obj)) + { + tile.img_obj = lv_obj_create(ctx.map_container); + lv_obj_set_size(tile.img_obj, TILE_SIZE, TILE_SIZE); + lv_obj_move_background(tile.img_obj); + } + + tile.has_png_file = has_base; + tile.base_missing = !has_base; + tile.contour_checked = g_requested_contour_enabled; + tile.contour_loaded = has_contour; + tile.last_used_ms = now_ms(); + + lv_obj_set_pos(tile.img_obj, screen_x, screen_y); + style_tile_card(tile.img_obj, tile.map_source, has_base); + + while (lv_obj_get_child_cnt(tile.img_obj) > 0) + { + lv_obj_t* child = lv_obj_get_child(tile.img_obj, 0); + lv_obj_del(child); + } + + if (has_base) + { + char base_path[LV_FS_MAX_PATH_LEN]; + if (build_lvgl_path_from_actual(base_actual_path, base_path, sizeof(base_path))) + { + lv_obj_set_style_pad_all(tile.img_obj, 0, 0); + lv_obj_set_style_border_width(tile.img_obj, 0, 0); + lv_obj_set_style_bg_opa(tile.img_obj, LV_OPA_TRANSP, 0); + + lv_obj_t* image = lv_image_create(tile.img_obj); + lv_image_set_src(image, base_path); + lv_obj_set_size(image, TILE_SIZE, TILE_SIZE); + lv_obj_align(image, LV_ALIGN_CENTER, 0, 0); + + if (has_contour) + { + char contour_path[LV_FS_MAX_PATH_LEN]; + if (build_lvgl_path_from_actual(contour_actual_path, contour_path, sizeof(contour_path))) + { + lv_obj_t* contour = lv_image_create(tile.img_obj); + lv_image_set_src(contour, contour_path); + lv_obj_set_size(contour, TILE_SIZE, TILE_SIZE); + lv_obj_align(contour, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_opa(contour, LV_OPA_80, 0); + } + } + return; + } + } + + lv_obj_t* label = lv_label_create(tile.img_obj); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_width(label, TILE_SIZE - 18); + + char text[128]; + std::snprintf(text, + sizeof(text), + "%s %s\nz%d x%d\ny%d", + has_base ? "Decode failed:" : "Missing", + map_source_label(tile.map_source), + tile.z, + static_cast(tile.x), + static_cast(tile.y)); + lv_label_set_text(label, text); + lv_obj_center(label); + + if (!has_base) + { + mark_missing_tile(tile.map_source); + } +} + +} // namespace + +uint8_t sanitize_map_source(uint8_t map_source) +{ + return map_source <= 2 ? map_source : 0; +} + +const char* map_source_label(uint8_t map_source) +{ + switch (sanitize_map_source(map_source)) + { + case 1: + return "Terrain"; + case 2: + return "Satellite"; + case 0: + default: + return "OSM"; + } +} + +bool build_base_tile_path(int z, int x, int y, uint8_t map_source, char* out_path, size_t out_size) +{ + if (!out_path || out_size == 0) + { + return false; + } + return build_lvgl_path_from_actual(build_base_tile_actual_path(z, x, y, map_source), out_path, out_size); +} + +bool build_contour_tile_path(int z, int x, int y, char* out_path, size_t out_size) +{ + if (!out_path || out_size == 0) + { + return false; + } + return build_lvgl_path_from_actual(build_contour_tile_actual_path(z, x, y), out_path, out_size); +} + +bool base_tile_available(int z, int x, int y, uint8_t map_source) +{ + return std::filesystem::exists(build_base_tile_actual_path(z, x, y, map_source)); +} + +bool map_source_directory_available(uint8_t map_source) +{ + return std::filesystem::exists(default_storage_root() / "maps" / "base" / base_source_dir(map_source)); +} + +bool contour_directory_available() +{ + static const char* kMajorProfiles[] = { + "major-500", + "major-200", + "major-100", + "major-50", + "major-25", + }; + + for (const char* profile : kMajorProfiles) + { + if (std::filesystem::exists(default_storage_root() / "maps" / "contour" / profile)) + { + return true; + } + } + return std::filesystem::exists(default_storage_root() / "maps" / "contour"); +} + +bool take_missing_tile_notice(uint8_t* out_map_source) +{ + if (!g_missing_tile_notice_pending) + { + return false; + } + + g_missing_tile_notice_pending = false; + if (out_map_source) + { + *out_map_source = g_missing_tile_notice_source; + } + return true; +} + +void set_map_render_options(uint8_t map_source, bool contour_enabled) +{ + const uint8_t normalized = sanitize_map_source(map_source); + if (g_requested_map_source != normalized || g_requested_contour_enabled != contour_enabled) + { + g_missing_tile_notice_pending = false; + g_missing_tile_notice_emitted = false; + } + + g_requested_map_source = normalized; + g_requested_contour_enabled = contour_enabled; +} + +void normalize_tile(int z, int& x, int& y) +{ + const int tiles = 1 << std::clamp(z, 0, 18); + if (tiles <= 0) + { + x = 0; + y = 0; + return; + } + + x %= tiles; + if (x < 0) + { + x += tiles; + } + y = std::clamp(y, 0, tiles - 1); +} + +void latLngToTile(double lat, double lng, int zoom, int& tile_x, int& tile_y) +{ + const double tiles = world_tiles(zoom); + const double clamped_lat = clamp_lat(lat); + const double lat_rad = clamped_lat * kPi / 180.0; + const double x = (lng + 180.0) / 360.0 * tiles; + const double y = (1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / kPi) / 2.0 * tiles; + + tile_x = static_cast(std::floor(x)); + tile_y = static_cast(std::floor(y)); + normalize_tile(zoom, tile_x, tile_y); +} + +void tileToLatLng(int tile_x, int tile_y, int zoom, double& lat, double& lng) +{ + const double tiles = world_tiles(zoom); + lng = ((static_cast(tile_x) + 0.5) / tiles) * 360.0 - 180.0; + const double mercator = kPi * (1.0 - 2.0 * ((static_cast(tile_y) + 0.5) / tiles)); + lat = std::atan(std::sinh(mercator)) * 180.0 / kPi; +} + +void get_screen_center_lat_lng(const TileContext& ctx, double& lat, double& lng) +{ + lat = 0.0; + lng = 0.0; + + if (!ctx.anchor || !ctx.anchor->valid || !ctx.map_container) + { + return; + } + + const int width = static_cast(lv_obj_get_width(ctx.map_container)); + const int height = static_cast(lv_obj_get_height(ctx.map_container)); + const double focus_screen_x = static_cast(ctx.anchor->gps_tile_screen_x + ctx.anchor->gps_offset_x); + const double focus_screen_y = static_cast(ctx.anchor->gps_tile_screen_y + ctx.anchor->gps_offset_y); + const double center_world_x = + static_cast(ctx.anchor->gps_global_pixel_x) + (static_cast(width) / 2.0 - focus_screen_x); + const double center_world_y = + static_cast(ctx.anchor->gps_global_pixel_y) + (static_cast(height) / 2.0 - focus_screen_y); + + world_to_lat_lng(center_world_x, center_world_y, ctx.anchor->z, lat, lng); +} + +void tileToPixel(int tile_x, int tile_y, int /*zoom*/, int& pixel_x, int& pixel_y) +{ + pixel_x = tile_x * TILE_SIZE; + pixel_y = tile_y * TILE_SIZE; +} + +bool gps_screen_pos(const TileContext& ctx, double lat, double lng, int& sx, int& sy) +{ + if (!ctx.anchor || !ctx.anchor->valid) + { + return false; + } + + const double size = world_size_px(ctx.anchor->z); + const double world_x = lon_to_world_x(lng, ctx.anchor->z); + const double world_y = lat_to_world_y(lat, ctx.anchor->z); + double dx = world_x - static_cast(ctx.anchor->gps_global_pixel_x); + if (dx > size / 2.0) + { + dx -= size; + } + else if (dx < -size / 2.0) + { + dx += size; + } + const double dy = world_y - static_cast(ctx.anchor->gps_global_pixel_y); + + const double focus_screen_x = static_cast(ctx.anchor->gps_tile_screen_x + ctx.anchor->gps_offset_x); + const double focus_screen_y = static_cast(ctx.anchor->gps_tile_screen_y + ctx.anchor->gps_offset_y); + sx = static_cast(std::lround(focus_screen_x + dx)); + sy = static_cast(std::lround(focus_screen_y + dy)); + return true; +} + +bool tile_screen_pos_xyz(const TileContext& ctx, int x, int y, int z, int& sx, int& sy) +{ + if (!ctx.anchor || !ctx.anchor->valid || ctx.anchor->z != z) + { + return false; + } + + const int tiles = 1 << std::clamp(z, 0, 18); + int dx = x - static_cast(ctx.anchor->gps_tile_x); + if (dx > tiles / 2) + { + dx -= tiles; + } + else if (dx < -tiles / 2) + { + dx += tiles; + } + const int dy = y - static_cast(ctx.anchor->gps_tile_y); + + sx = ctx.anchor->gps_tile_screen_x + (dx * TILE_SIZE); + sy = ctx.anchor->gps_tile_screen_y + (dy * TILE_SIZE); + return true; +} + +bool tile_in_rect(int sx, int sy, int w, int h, int margin) +{ + return sx < (w + margin) && sy < (h + margin) && (sx + TILE_SIZE) > -margin && (sy + TILE_SIZE) > -margin; +} + +void update_map_anchor(TileContext& ctx, double lat, double lng, int zoom, int pan_x, int pan_y, bool has_fix) +{ + if (!ctx.anchor || !ctx.map_container || !has_fix) + { + if (ctx.anchor) + { + ctx.anchor->valid = false; + } + return; + } + + const int width = static_cast(lv_obj_get_width(ctx.map_container)); + const int height = static_cast(lv_obj_get_height(ctx.map_container)); + const double world_x = lon_to_world_x(lng, zoom); + const double world_y = lat_to_world_y(lat, zoom); + int tile_x = static_cast(std::floor(world_x / TILE_SIZE)); + int tile_y = static_cast(std::floor(world_y / TILE_SIZE)); + normalize_tile(zoom, tile_x, tile_y); + + ctx.anchor->z = zoom; + ctx.anchor->gps_tile_x = tile_x; + ctx.anchor->gps_tile_y = tile_y; + ctx.anchor->gps_tile_pixel_x = tile_x * TILE_SIZE; + ctx.anchor->gps_tile_pixel_y = tile_y * TILE_SIZE; + ctx.anchor->gps_global_pixel_x = static_cast(std::lround(world_x)); + ctx.anchor->gps_global_pixel_y = static_cast(std::lround(world_y)); + ctx.anchor->gps_offset_x = ctx.anchor->gps_global_pixel_x - ctx.anchor->gps_tile_pixel_x; + ctx.anchor->gps_offset_y = ctx.anchor->gps_global_pixel_y - ctx.anchor->gps_tile_pixel_y; + ctx.anchor->gps_tile_screen_x = width / 2 - ctx.anchor->gps_offset_x + pan_x; + ctx.anchor->gps_tile_screen_y = height / 2 - ctx.anchor->gps_offset_y + pan_y; + ctx.anchor->n = world_tiles(zoom); + ctx.anchor->valid = true; +} + +void calculate_required_tiles(TileContext& ctx, double lat, double lng, int zoom, int pan_x, int pan_y, bool has_fix) +{ + if (!ctx.tiles) + { + return; + } + + update_map_anchor(ctx, lat, lng, zoom, pan_x, pan_y, has_fix); + if (!ctx.anchor || !ctx.anchor->valid || !ctx.map_container) + { + cleanup_tiles(ctx); + return; + } + + const int width = static_cast(lv_obj_get_width(ctx.map_container)); + const int height = static_cast(lv_obj_get_height(ctx.map_container)); + const int cols = std::max(3, width / TILE_SIZE + 3); + const int rows = std::max(3, height / TILE_SIZE + 3); + const int half_cols = cols / 2 + 1; + const int half_rows = rows / 2 + 1; + + for (auto& tile : *ctx.tiles) + { + tile.visible = false; + } + + for (int dy = -half_rows; dy <= half_rows; ++dy) + { + for (int dx = -half_cols; dx <= half_cols; ++dx) + { + int tile_x = static_cast(ctx.anchor->gps_tile_x) + dx; + int tile_y = static_cast(ctx.anchor->gps_tile_y) + dy; + normalize_tile(zoom, tile_x, tile_y); + + int screen_x = 0; + int screen_y = 0; + if (!tile_screen_pos_xyz(ctx, tile_x, tile_y, zoom, screen_x, screen_y) || + !tile_in_rect(screen_x, screen_y, width, height, TILE_SIZE / 2)) + { + continue; + } + + MapTile* tile = find_tile(ctx, tile_x, tile_y, zoom, g_requested_map_source); + if (!tile) + { + ctx.tiles->push_back(MapTile{}); + tile = &ctx.tiles->back(); + tile->x = tile_x; + tile->y = tile_y; + tile->z = zoom; + tile->map_source = g_requested_map_source; + } + else if (tile->map_source != g_requested_map_source) + { + delete_tile_object(*tile); + tile->map_source = g_requested_map_source; + } + + tile->visible = true; + tile->ever_visible = true; + tile->priority = std::abs(dx) + std::abs(dy); + tile->last_used_ms = now_ms(); + + if (tile->img_obj && lv_obj_is_valid(tile->img_obj)) + { + lv_obj_set_pos(tile->img_obj, screen_x, screen_y); + } + } + } + + auto& tiles = *ctx.tiles; + for (auto it = tiles.begin(); it != tiles.end();) + { + if (!it->visible) + { + delete_tile_object(*it); + it = tiles.erase(it); + continue; + } + ++it; + } + + refresh_status_flags(ctx); +} + +void tile_loader_step(TileContext& ctx) +{ + if (!ctx.tiles || !ctx.map_container) + { + return; + } + + MapTile* next_tile = nullptr; + for (auto& tile : *ctx.tiles) + { + if (!tile.visible) + { + continue; + } + if (!tile.img_obj || !lv_obj_is_valid(tile.img_obj)) + { + if (!next_tile || tile.priority < next_tile->priority) + { + next_tile = &tile; + } + continue; + } + + int screen_x = 0; + int screen_y = 0; + if (tile_screen_pos_xyz(ctx, static_cast(tile.x), static_cast(tile.y), tile.z, screen_x, screen_y)) + { + lv_obj_set_pos(tile.img_obj, screen_x, screen_y); + } + } + + if (next_tile) + { + create_or_refresh_tile_card(ctx, *next_tile); + } + + refresh_status_flags(ctx); +} + +void init_tile_context(TileContext& ctx, + lv_obj_t* map_container, + MapAnchor* anchor, + std::vector* tiles, + bool* has_map_data, + bool* has_visible_map_data) +{ + ctx.map_container = map_container; + ctx.anchor = anchor; + ctx.tiles = tiles; + ctx.has_map_data = has_map_data; + ctx.has_visible_map_data = has_visible_map_data; + if (ctx.has_map_data) + { + *ctx.has_map_data = false; + } + if (ctx.has_visible_map_data) + { + *ctx.has_visible_map_data = false; + } + if (ctx.anchor) + { + ctx.anchor->valid = false; + } +} + +void cleanup_tiles(TileContext& ctx) +{ + if (ctx.tiles) + { + for (auto& tile : *ctx.tiles) + { + delete_tile_object(tile); + } + ctx.tiles->clear(); + } + if (ctx.has_map_data) + { + *ctx.has_map_data = false; + } + if (ctx.has_visible_map_data) + { + *ctx.has_visible_map_data = false; + } + if (ctx.anchor) + { + ctx.anchor->valid = false; + } +} diff --git a/platform/linux/rpi/README.md b/platform/linux/rpi/README.md index 7fa6be66..6ba38375 100644 --- a/platform/linux/rpi/README.md +++ b/platform/linux/rpi/README.md @@ -1,3 +1,7 @@ # `platform/linux/rpi` -Planned home for Raspberry Pi-specific platform adapters. +Pi OS and Raspberry Pi specific platform adapters for Cardputer Zero. + +This layer currently owns the framebuffer presenter used by +`apps/linux_rpi`. Keep Linux device specifics here rather than in the simulator +shell. diff --git a/platform/linux/rpi/src/platform/device/evdev_input.cpp b/platform/linux/rpi/src/platform/device/evdev_input.cpp new file mode 100644 index 00000000..83a8ba6f --- /dev/null +++ b/platform/linux/rpi/src/platform/device/evdev_input.cpp @@ -0,0 +1,672 @@ +#include "platform/device/evdev_input.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace trailmate::cardputer_zero::platform::device +{ +namespace +{ + +// --------------------------------------------------------------------------- +// Key mapping: Linux key code -> InputEvent +// --------------------------------------------------------------------------- + +struct KeyMapping +{ + int linux_key; + app::InputKey target; + char ascii{'\0'}; +}; + +// std::to_array avoids hand-counting and CTAD ambiguity. +constexpr auto kKeyMap = std::to_array({ + // Navigation + {KEY_UP, app::InputKey::Up}, + {KEY_DOWN, app::InputKey::Down}, + {KEY_LEFT, app::InputKey::Left}, + {KEY_RIGHT, app::InputKey::Right}, + {KEY_HOME, app::InputKey::Home}, + {KEY_END, app::InputKey::Next}, + + // Action keys + {KEY_ENTER, app::InputKey::Enter}, + {KEY_KPENTER, app::InputKey::Enter}, + {KEY_BACKSPACE, app::InputKey::Backspace}, + {KEY_TAB, app::InputKey::Tab}, + {KEY_ESC, app::InputKey::Power}, + + // Modifiers (mapped as modifiers – input handling may need expansion later) + {KEY_LEFTSHIFT, app::InputKey::Shift}, + {KEY_RIGHTSHIFT, app::InputKey::Shift}, + {KEY_LEFTCTRL, app::InputKey::Ctrl}, + {KEY_RIGHTCTRL, app::InputKey::Ctrl}, + {KEY_LEFTALT, app::InputKey::Alt}, + {KEY_RIGHTALT, app::InputKey::Alt}, + + // Printable ASCII (lowercase by default; shift handled by caller) + {KEY_A, app::InputKey::Character, 'a'}, + {KEY_B, app::InputKey::Character, 'b'}, + {KEY_C, app::InputKey::Character, 'c'}, + {KEY_D, app::InputKey::Character, 'd'}, + {KEY_E, app::InputKey::Character, 'e'}, + {KEY_F, app::InputKey::Character, 'f'}, + {KEY_G, app::InputKey::Character, 'g'}, + {KEY_H, app::InputKey::Character, 'h'}, + {KEY_I, app::InputKey::Character, 'i'}, + {KEY_J, app::InputKey::Character, 'j'}, + {KEY_K, app::InputKey::Character, 'k'}, + {KEY_L, app::InputKey::Character, 'l'}, + {KEY_M, app::InputKey::Character, 'm'}, + {KEY_N, app::InputKey::Character, 'n'}, + {KEY_O, app::InputKey::Character, 'o'}, + {KEY_P, app::InputKey::Character, 'p'}, + {KEY_Q, app::InputKey::Character, 'q'}, + {KEY_R, app::InputKey::Character, 'r'}, + {KEY_S, app::InputKey::Character, 's'}, + {KEY_T, app::InputKey::Character, 't'}, + {KEY_U, app::InputKey::Character, 'u'}, + {KEY_V, app::InputKey::Character, 'v'}, + {KEY_W, app::InputKey::Character, 'w'}, + {KEY_X, app::InputKey::Character, 'x'}, + {KEY_Y, app::InputKey::Character, 'y'}, + {KEY_Z, app::InputKey::Character, 'z'}, + + // Digits + {KEY_0, app::InputKey::Character, '0'}, + {KEY_1, app::InputKey::Character, '1'}, + {KEY_2, app::InputKey::Character, '2'}, + {KEY_3, app::InputKey::Character, '3'}, + {KEY_4, app::InputKey::Character, '4'}, + {KEY_5, app::InputKey::Character, '5'}, + {KEY_6, app::InputKey::Character, '6'}, + {KEY_7, app::InputKey::Character, '7'}, + {KEY_8, app::InputKey::Character, '8'}, + {KEY_9, app::InputKey::Character, '9'}, + + // Space & punctuation + {KEY_SPACE, app::InputKey::Character, ' '}, + {KEY_COMMA, app::InputKey::Character, ','}, + {KEY_DOT, app::InputKey::Character, '.'}, + {KEY_SLASH, app::InputKey::Character, '/'}, + {KEY_SEMICOLON, app::InputKey::Character, ';'}, + {KEY_APOSTROPHE, app::InputKey::Character, '\''}, + {KEY_MINUS, app::InputKey::Character, '-'}, + {KEY_EQUAL, app::InputKey::Character, '='}, + {KEY_LEFTBRACE, app::InputKey::Character, '['}, + {KEY_RIGHTBRACE, app::InputKey::Character, ']'}, + {KEY_BACKSLASH, app::InputKey::Character, '\\'}, + {KEY_GRAVE, app::InputKey::Character, '`'}, +}); + +const KeyMapping* findMapping(int linux_key) +{ + for (const auto& entry : kKeyMap) + { + if (entry.linux_key == linux_key) + { + return &entry; + } + } + return nullptr; +} + +app::InputEvent makeInputEvent(const KeyMapping& mapping, bool shift_held) +{ + app::InputEvent event{}; + event.key = mapping.target; + + if (mapping.target == app::InputKey::Character && mapping.ascii != '\0') + { + if (shift_held) + { + // Simple US keyboard shift mapping for letters and common symbols. + switch (mapping.ascii) + { + case 'a': + event.text = 'A'; + break; + case 'b': + event.text = 'B'; + break; + case 'c': + event.text = 'C'; + break; + case 'd': + event.text = 'D'; + break; + case 'e': + event.text = 'E'; + break; + case 'f': + event.text = 'F'; + break; + case 'g': + event.text = 'G'; + break; + case 'h': + event.text = 'H'; + break; + case 'i': + event.text = 'I'; + break; + case 'j': + event.text = 'J'; + break; + case 'k': + event.text = 'K'; + break; + case 'l': + event.text = 'L'; + break; + case 'm': + event.text = 'M'; + break; + case 'n': + event.text = 'N'; + break; + case 'o': + event.text = 'O'; + break; + case 'p': + event.text = 'P'; + break; + case 'q': + event.text = 'Q'; + break; + case 'r': + event.text = 'R'; + break; + case 's': + event.text = 'S'; + break; + case 't': + event.text = 'T'; + break; + case 'u': + event.text = 'U'; + break; + case 'v': + event.text = 'V'; + break; + case 'w': + event.text = 'W'; + break; + case 'x': + event.text = 'X'; + break; + case 'y': + event.text = 'Y'; + break; + case 'z': + event.text = 'Z'; + break; + case '1': + event.text = '!'; + break; + case '2': + event.text = '@'; + break; + case '3': + event.text = '#'; + break; + case '4': + event.text = '$'; + break; + case '5': + event.text = '%'; + break; + case '6': + event.text = '^'; + break; + case '7': + event.text = '&'; + break; + case '8': + event.text = '*'; + break; + case '9': + event.text = '('; + break; + case '0': + event.text = ')'; + break; + case '-': + event.text = '_'; + break; + case '=': + event.text = '+'; + break; + case '[': + event.text = '{'; + break; + case ']': + event.text = '}'; + break; + case '\\': + event.text = '|'; + break; + case ';': + event.text = ':'; + break; + case '\'': + event.text = '"'; + break; + case ',': + event.text = '<'; + break; + case '.': + event.text = '>'; + break; + case '/': + event.text = '?'; + break; + case '`': + event.text = '~'; + break; + default: + event.text = mapping.ascii; + break; + } + } + else + { + event.text = mapping.ascii; + } + } + + // Build a short human-readable label for logging + char label_buf[32]{}; + if (mapping.ascii != '\0') + { + std::snprintf(label_buf, sizeof(label_buf), "%c", event.text); + } + else + { + const char* label_str = "?"; + switch (mapping.target) + { + case app::InputKey::Enter: + label_str = "Enter"; + break; + case app::InputKey::Backspace: + label_str = "Bksp"; + break; + case app::InputKey::Tab: + label_str = "Tab"; + break; + case app::InputKey::Up: + label_str = "Up"; + break; + case app::InputKey::Down: + label_str = "Down"; + break; + case app::InputKey::Left: + label_str = "Left"; + break; + case app::InputKey::Right: + label_str = "Right"; + break; + case app::InputKey::Home: + label_str = "Home"; + break; + case app::InputKey::Next: + label_str = "Next"; + break; + case app::InputKey::Power: + label_str = "Power"; + break; + case app::InputKey::Shift: + label_str = "Shift"; + break; + case app::InputKey::Ctrl: + label_str = "Ctrl"; + break; + case app::InputKey::Alt: + label_str = "Alt"; + break; + default: + break; + } + label_str = label_str; + std::snprintf(label_buf, sizeof(label_buf), "%s", label_str); + } + event.label = std::string(label_buf); + + return event; +} + +// --------------------------------------------------------------------------- +// Auto-discovery helpers +// --------------------------------------------------------------------------- + +std::vector candidateDevicePaths() +{ + std::vector paths; + +#if defined(__linux__) + // 1. Env override (checked by caller, not here). + // 2. by-path keyboard entries (guard against missing directory). + { + std::error_code ec; + const std::filesystem::path by_path_dir("/dev/input/by-path"); + if (std::filesystem::is_directory(by_path_dir, ec) && !ec) + { + for (std::filesystem::directory_iterator it(by_path_dir, ec), end; + !ec && it != end; it.increment(ec)) + { + const auto filename = it->path().filename().string(); + if (filename.find("-event-kbd") != std::string::npos || + filename.find("-kbd") != std::string::npos) + { + paths.push_back(it->path().string()); + } + } + } + } + + // 3. by-id keyboard entries (guard against missing directory). + { + std::error_code ec; + const std::filesystem::path by_id_dir("/dev/input/by-id"); + if (std::filesystem::is_directory(by_id_dir, ec) && !ec) + { + for (std::filesystem::directory_iterator it(by_id_dir, ec), end; + !ec && it != end; it.increment(ec)) + { + const auto filename = it->path().filename().string(); + if (filename.find("-event-kbd") != std::string::npos || + filename.find("-kbd") != std::string::npos) + { + paths.push_back(it->path().string()); + } + } + } + } + + // 4. Fallback: scan /dev/input/event*. + for (int i = 0; i < 32; ++i) + { + char buf[64]{}; + std::snprintf(buf, sizeof(buf), "/dev/input/event%d", i); + paths.push_back(std::string(buf)); + } +#endif + + return paths; +} + +} // namespace + +// =================================================================== +// EvdevInput::Impl +// =================================================================== + +struct EvdevInput::Impl +{ + std::string device_path{}; + int fd{-1}; + bool shift_held{false}; + bool ctrl_held{false}; + bool alt_held{false}; + + bool openDevice(const std::string& path) + { + closeDevice(); + + if (path.empty()) + { + return false; + } + + fd = ::open(path.c_str(), O_RDONLY | O_NONBLOCK); + if (fd < 0) + { + std::fprintf(stderr, "[evdev] Failed to open %s: %s\n", + path.c_str(), std::strerror(errno)); + return false; + } + + device_path = path; + std::fprintf(stderr, "[evdev] Opened input device: %s\n", path.c_str()); + return true; + } + + void closeDevice() + { + if (fd >= 0) + { + ::close(fd); + fd = -1; + } + device_path.clear(); + shift_held = false; + ctrl_held = false; + alt_held = false; + } + + bool tryAutoDiscovery() + { +#if defined(__linux__) + for (const auto& path : candidateDevicePaths()) + { + // Quick-existence check before attempting open. + if (!std::filesystem::exists(path)) + { + continue; + } + + if (openDevice(path)) + { + // Verify it actually supports EV_KEY events. + unsigned long ev_bits[EV_MAX / (sizeof(unsigned long) * 8) + 1]{}; + if (::ioctl(fd, EVIOCGBIT(0, sizeof(ev_bits)), ev_bits) >= 0) + { + if (ev_bits[EV_KEY / (sizeof(unsigned long) * 8)] & + (1UL << (EV_KEY % (sizeof(unsigned long) * 8)))) + { + // Also verify it has at least one keyboard key. + unsigned long key_bits[KEY_MAX / (sizeof(unsigned long) * 8) + 1]{}; + if (::ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(key_bits)), key_bits) >= 0) + { + bool has_keys = false; + for (int k = KEY_ESC; k <= KEY_DOWN && !has_keys; ++k) + { + if (key_bits[k / (sizeof(unsigned long) * 8)] & + (1UL << (k % (sizeof(unsigned long) * 8)))) + { + has_keys = true; + } + } + if (has_keys) + { + std::fprintf(stderr, "[evdev] Auto-discovered keyboard: %s\n", + path.c_str()); + return true; + } + } + } + } + // Not a keyboard — try next candidate. + closeDevice(); + } + } +#endif + return false; + } +}; + +// =================================================================== +// EvdevInput (public) +// =================================================================== + +EvdevInput::EvdevInput(std::string device_path) + : impl_(std::make_unique()) +{ +#if defined(__linux__) + // 1. Env override: TRAIL_MATE_INPUT_DEVICE + if (device_path.empty()) + { + const char* env = std::getenv("TRAIL_MATE_INPUT_DEVICE"); + if (env != nullptr && env[0] != '\0') + { + device_path = env; + } + } + + // 2. Explicit device path (or env override) + if (!device_path.empty()) + { + if (impl_->openDevice(device_path)) + { + return; + } + std::fprintf(stderr, "[evdev] Explicit device '%s' failed; trying auto-discovery.\n", + device_path.c_str()); + } + + // 3. Auto-discovery + if (impl_->tryAutoDiscovery()) + { + return; + } + + std::fprintf(stderr, + "[evdev] No keyboard input device found. " + "UI will display but cannot accept keyboard input. " + "Set TRAIL_MATE_INPUT_DEVICE to override.\n"); +#else + (void)device_path; + std::fprintf(stderr, "[evdev] Non-Linux host: input disabled.\n"); +#endif +} + +EvdevInput::~EvdevInput() +{ + impl_->closeDevice(); +} + +bool EvdevInput::isOpen() const noexcept +{ + return impl_->fd >= 0; +} + +std::vector EvdevInput::drainInput() +{ + std::vector events; + +#if defined(__linux__) + if (impl_->fd < 0) + { + return events; + } + + struct input_event raw + { + }; + + for (;;) + { + const ssize_t bytes = ::read(impl_->fd, &raw, sizeof(raw)); + if (bytes < 0) + { + if (errno == EAGAIN || errno == EWOULDBLOCK) + { + break; // no more events + } + // Device error — close and stop. + std::fprintf(stderr, "[evdev] Read error on %s: %s\n", + impl_->device_path.c_str(), std::strerror(errno)); + impl_->closeDevice(); + break; + } + if (bytes == 0) + { + break; + } + if (static_cast(bytes) < sizeof(raw)) + { + continue; + } + + // We only handle EV_KEY events. + if (raw.type != EV_KEY) + { + continue; + } + + // Track modifier state. + if (raw.code == KEY_LEFTSHIFT || raw.code == KEY_RIGHTSHIFT) + { + impl_->shift_held = (raw.value != 0); + } + if (raw.code == KEY_LEFTCTRL || raw.code == KEY_RIGHTCTRL) + { + impl_->ctrl_held = (raw.value != 0); + } + if (raw.code == KEY_LEFTALT || raw.code == KEY_RIGHTALT) + { + impl_->alt_held = (raw.value != 0); + } + + // Ignore key releases and repeats — we only emit on press. + // (Repeat is handled by the UI layer via LVGL key repeat.) + if (raw.value != 1) // EV_KEY value: 0=release, 1=press, 2=repeat + { + continue; + } + + const KeyMapping* mapping = findMapping(raw.code); + if (mapping == nullptr) + { + // Unmapped key — silently ignore. + continue; + } + + events.push_back(makeInputEvent(*mapping, impl_->shift_held)); + } +#endif + + return events; +} + +const std::string& EvdevInput::devicePath() const noexcept +{ + return impl_->device_path; +} + +bool EvdevInput::reopen(std::string device_path) +{ +#if defined(__linux__) + impl_->closeDevice(); + if (device_path.empty()) + { + const char* env = std::getenv("TRAIL_MATE_INPUT_DEVICE"); + if (env != nullptr && env[0] != '\0') + { + device_path = env; + } + } + if (!device_path.empty()) + { + return impl_->openDevice(device_path); + } + return impl_->tryAutoDiscovery(); +#else + (void)device_path; + return false; +#endif +} +} // namespace trailmate::cardputer_zero::platform::device diff --git a/platform/linux/rpi/src/platform/device/evdev_input.h b/platform/linux/rpi/src/platform/device/evdev_input.h new file mode 100644 index 00000000..3ff56fbe --- /dev/null +++ b/platform/linux/rpi/src/platform/device/evdev_input.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +#include "app/input_event.h" + +namespace trailmate::cardputer_zero::platform::device +{ + +// --------------------------------------------------------------------------- +// EvdevInput +// +// Non-blocking Linux evdev keyboard adapter for Cardputer Zero. +// +// Responsibilities: +// - Open an evdev device file (non-blocking). +// - Read struct input_event (EV_KEY only). +// - Map Linux key codes to trailmate::cardputer_zero::app::InputEvent. +// - Respect env override TRAIL_MATE_INPUT_DEVICE. +// - Probe candidate paths when no explicit device is given. +// +// Does NOT include evdev headers in this header — that stays in the .cpp. +// --------------------------------------------------------------------------- + +class EvdevInput +{ + public: + /// Construct with an explicit device path (e.g. "/dev/input/event3"). + /// If the path is empty, the auto-discovery logic runs. + explicit EvdevInput(std::string device_path = {}); + + ~EvdevInput(); + + EvdevInput(const EvdevInput&) = delete; + EvdevInput& operator=(const EvdevInput&) = delete; + + /// Returns true if a keyboard device was successfully opened. + bool isOpen() const noexcept; + + /// Non-blocking poll: reads all available input events and returns + /// the mapped Trail Mate InputEvent list. Returns an empty vector + /// when no device is open or no events are available. + std::vector drainInput(); + + /// Returns the device path that was actually opened (for diagnostics). + const std::string& devicePath() const noexcept; + + /// Re-open a different device. Closes the current one first. + bool reopen(std::string device_path); + + private: + struct Impl; + std::unique_ptr impl_{}; +}; + +} // namespace trailmate::cardputer_zero::platform::device diff --git a/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.cpp b/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.cpp new file mode 100644 index 00000000..cb765717 --- /dev/null +++ b/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.cpp @@ -0,0 +1,196 @@ +#include "platform/device/linux_framebuffer_platform.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "platform/device/evdev_input.h" + +namespace trailmate::cardputer_zero::platform::device +{ +namespace +{ + +std::atomic_bool g_running{true}; + +void stopSignalHandler(int) +{ + g_running.store(false); +} + +std::uint32_t scaleChannel(std::uint8_t value, const fb_bitfield& bitfield) noexcept +{ + if (bitfield.length == 0U) + { + return 0U; + } + + const std::uint32_t max_value = (1U << bitfield.length) - 1U; + const std::uint32_t scaled = (static_cast(value) * max_value + 127U) / 255U; + return scaled << bitfield.offset; +} + +std::uint32_t packNativePixel(core::Color color, const fb_var_screeninfo& vinfo) noexcept +{ + return scaleChannel(color.r, vinfo.red) | scaleChannel(color.g, vinfo.green) | scaleChannel(color.b, vinfo.blue) | scaleChannel(color.a, vinfo.transp); +} + +std::runtime_error makeSystemError(const std::string& step) +{ + return std::runtime_error(step + ": " + std::strerror(errno)); +} + +} // namespace + +struct LinuxFramebufferPlatform::Impl +{ + explicit Impl(std::string framebuffer_path_in) + : framebuffer_path(std::move(framebuffer_path_in)) + { + g_running.store(true); + std::signal(SIGINT, stopSignalHandler); + std::signal(SIGTERM, stopSignalHandler); + + if (!evdev_input.isOpen()) + { + std::fprintf(stderr, + "[device] No keyboard input available; UI will display " + "but cannot accept keyboard input. " + "Set TRAIL_MATE_INPUT_DEVICE to override.\n"); + } + + file_descriptor = ::open(framebuffer_path.c_str(), O_RDWR); + if (file_descriptor < 0) + { + throw makeSystemError("open framebuffer"); + } + + if (::ioctl(file_descriptor, FBIOGET_FSCREENINFO, &finfo) == -1) + { + throw makeSystemError("FBIOGET_FSCREENINFO"); + } + if (::ioctl(file_descriptor, FBIOGET_VSCREENINFO, &vinfo) == -1) + { + throw makeSystemError("FBIOGET_VSCREENINFO"); + } + + bytes_per_pixel = static_cast(vinfo.bits_per_pixel / 8U); + if (bytes_per_pixel < 2 || bytes_per_pixel > 4) + { + throw std::runtime_error("unsupported framebuffer pixel depth"); + } + + const auto mapped_size = static_cast(finfo.line_length) * static_cast(vinfo.yres_virtual); + framebuffer_size = mapped_size; + void* mapped = ::mmap(nullptr, framebuffer_size, PROT_READ | PROT_WRITE, MAP_SHARED, file_descriptor, 0); + if (mapped == MAP_FAILED) + { + framebuffer_memory = nullptr; + throw makeSystemError("mmap framebuffer"); + } + + framebuffer_memory = static_cast(mapped); + } + + ~Impl() + { + if (framebuffer_memory) + { + ::munmap(framebuffer_memory, framebuffer_size); + } + if (file_descriptor >= 0) + { + ::close(file_descriptor); + } + } + + void writePackedPixel(int x, int y, std::uint32_t packed) const noexcept + { + const int pixel_x = x + static_cast(vinfo.xoffset); + const int pixel_y = y + static_cast(vinfo.yoffset); + const std::size_t offset = static_cast(pixel_y) * static_cast(finfo.line_length) + static_cast(pixel_x) * static_cast(bytes_per_pixel); + + for (int byte_index = 0; byte_index < bytes_per_pixel; ++byte_index) + { + framebuffer_memory[offset + static_cast(byte_index)] = + static_cast((packed >> (byte_index * 8)) & 0xFFU); + } + } + + std::string framebuffer_path{}; + int file_descriptor{-1}; + int bytes_per_pixel{}; + fb_fix_screeninfo finfo{}; + fb_var_screeninfo vinfo{}; + std::size_t framebuffer_size{}; + std::byte* framebuffer_memory{}; + EvdevInput evdev_input{}; +}; + +LinuxFramebufferPlatform::LinuxFramebufferPlatform(std::string framebuffer_path) + : impl_(std::make_unique(std::move(framebuffer_path))) +{ +} + +LinuxFramebufferPlatform::~LinuxFramebufferPlatform() = default; + +bool LinuxFramebufferPlatform::pump() +{ + return g_running.load(); +} + +std::vector LinuxFramebufferPlatform::drainInput() +{ + return impl_->evdev_input.drainInput(); +} + +void LinuxFramebufferPlatform::present(const core::Canvas& canvas) +{ + const int physical_width = static_cast(impl_->vinfo.xres); + const int physical_height = static_cast(impl_->vinfo.yres); + const int scale_x = physical_width / canvas.width(); + const int scale_y = physical_height / canvas.height(); + const int scale = std::min(scale_x, scale_y); + + if (scale <= 0) + { + throw std::runtime_error("framebuffer is smaller than the logical display"); + } + + const int render_width = canvas.width() * scale; + const int render_height = canvas.height() * scale; + const int offset_x = (physical_width - render_width) / 2; + const int offset_y = (physical_height - render_height) / 2; + + std::memset(impl_->framebuffer_memory, 0, impl_->framebuffer_size); + + for (int y = 0; y < canvas.height(); ++y) + { + for (int x = 0; x < canvas.width(); ++x) + { + const std::uint32_t packed = packNativePixel(canvas.pixel(x, y), impl_->vinfo); + + for (int dy = 0; dy < scale; ++dy) + { + for (int dx = 0; dx < scale; ++dx) + { + impl_->writePackedPixel(offset_x + (x * scale) + dx, offset_y + (y * scale) + dy, packed); + } + } + } + } +} + +} // namespace trailmate::cardputer_zero::platform::device diff --git a/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.h b/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.h new file mode 100644 index 00000000..7f753a03 --- /dev/null +++ b/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +#include "platform/surface_presenter.h" + +namespace trailmate::cardputer_zero::platform::device +{ + +class LinuxFramebufferPlatform : public SurfacePresenter +{ + public: + explicit LinuxFramebufferPlatform(std::string framebuffer_path); + ~LinuxFramebufferPlatform() override; + + [[nodiscard]] bool pump() override; + [[nodiscard]] std::vector drainInput() override; + void present(const core::Canvas& canvas) override; + + private: + struct Impl; + std::unique_ptr impl_{}; +}; + +} // namespace trailmate::cardputer_zero::platform::device diff --git a/platform/nrf52/arduino_common/include/ble/meshcore_ble.h b/platform/nrf52/arduino_common/include/ble/meshcore_ble.h index a02b75ab..f7354b4b 100644 --- a/platform/nrf52/arduino_common/include/ble/meshcore_ble.h +++ b/platform/nrf52/arduino_common/include/ble/meshcore_ble.h @@ -16,7 +16,8 @@ namespace ble class MeshCoreBleService final : public BleService, public MeshCorePhoneHooks, - public chat::ChatService::IncomingTextObserver + public chat::ChatService::IncomingTextObserver, + public IPhoneRuntimeContext { public: MeshCoreBleService(app::IAppBleFacade& ctx, const std::string& device_name); @@ -37,6 +38,39 @@ class MeshCoreBleService final : public BleService, void sendPendingNotifications(); bool getCustomVars(std::string* out) const override; bool setCustomVar(const char* key, const char* value) override; + MeshtasticPhoneConfigSnapshot getMeshtasticPhoneConfig() const override; + void setMeshtasticPhoneConfig(const MeshtasticPhoneConfigSnapshot& config) override; + MeshCorePhoneConfigSnapshot getMeshCorePhoneConfig() const override; + void setMeshCorePhoneConfig(const MeshCorePhoneConfigSnapshot& config) override; + void saveConfig() override { ctx_.saveConfig(); } + void applyMeshConfig() override { ctx_.applyMeshConfig(); } + void applyUserInfo() override { ctx_.applyUserInfo(); } + void applyPositionConfig() override { ctx_.applyPositionConfig(); } + void getEffectiveUserInfo(char* out_long, + std::size_t long_len, + char* out_short, + std::size_t short_len) const override + { + ctx_.getEffectiveUserInfo(out_long, long_len, out_short, short_len); + } + chat::ChatService& getChatService() override { return ctx_.getChatService(); } + chat::contacts::ContactService& getContactService() override { return ctx_.getContactService(); } + chat::IMeshAdapter* getMeshAdapter() override { return ctx_.getMeshAdapter(); } + const chat::IMeshAdapter* getMeshAdapter() const override { return ctx_.getMeshAdapter(); } + chat::contacts::INodeStore* getNodeStore() override { return ctx_.getNodeStore(); } + const chat::contacts::INodeStore* getNodeStore() const override { return ctx_.getNodeStore(); } + chat::NodeId getSelfNodeId() const override { return ctx_.getSelfNodeId(); } + bool isBleEnabled() const override { return ctx_.isBleEnabled(); } + void setBleEnabled(bool enabled) override { ctx_.setBleEnabled(enabled); } + bool getDeviceMacAddress(uint8_t out_mac[6]) const override { return ctx_.getDeviceMacAddress(out_mac); } + bool syncCurrentEpochSeconds(uint32_t epoch_seconds) override + { + return ctx_.syncCurrentEpochSeconds(epoch_seconds); + } + void resetMeshConfig() override { ctx_.resetMeshConfig(); } + void clearNodeDb() override { ctx_.clearNodeDb(); } + void clearMessageDb() override { ctx_.clearMessageDb(); } + void restartDevice() override { ctx_.restartDevice(); } app::IAppBleFacade& ctx_; std::string device_name_; diff --git a/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h b/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h index 9b8490ff..e33bd1bf 100644 --- a/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h +++ b/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h @@ -30,7 +30,13 @@ class MeshtasticBleService final : public BleService, public chat::ChatService::OutgoingTextObserver, public chat::ChatService::IncomingDataObserver, public MeshtasticPhoneTransport, - public MeshtasticPhoneHooks + public MeshtasticPhoneBluetoothConfigHooks, + public MeshtasticPhoneModuleConfigHooks, + public MeshtasticPhoneConfigLifecycleHooks, + public MeshtasticPhoneStatusHooks, + public MeshtasticPhoneMqttHooks, + public MeshtasticPhoneDeviceRuntimeHooks, + public IPhoneRuntimeContext { public: struct Frame @@ -80,6 +86,44 @@ class MeshtasticBleService final : public BleService, bool loadDeviceConnectionStatus(meshtastic_DeviceConnectionStatus* out) const override; bool loadModuleConfig(meshtastic_LocalModuleConfig* out) const override; void saveModuleConfig(const meshtastic_LocalModuleConfig& config) override; + bool loadTimezoneTzdef(char* out, size_t out_len) const override; + void saveTimezoneTzdef(const char* tzdef) override; + int getTimezoneOffsetMinutes() const override; + void setTimezoneOffsetMinutes(int offset_min) override; + bool getGpsFix(MeshtasticGpsFix* out) const override; + MeshtasticPhoneConfigSnapshot getMeshtasticPhoneConfig() const override; + void setMeshtasticPhoneConfig(const MeshtasticPhoneConfigSnapshot& config) override; + MeshCorePhoneConfigSnapshot getMeshCorePhoneConfig() const override; + void setMeshCorePhoneConfig(const MeshCorePhoneConfigSnapshot& config) override; + void saveConfig() override { ctx_.saveConfig(); } + void applyMeshConfig() override { ctx_.applyMeshConfig(); } + void applyUserInfo() override { ctx_.applyUserInfo(); } + void applyPositionConfig() override { ctx_.applyPositionConfig(); } + void getEffectiveUserInfo(char* out_long, + std::size_t long_len, + char* out_short, + std::size_t short_len) const override + { + ctx_.getEffectiveUserInfo(out_long, long_len, out_short, short_len); + } + chat::ChatService& getChatService() override { return ctx_.getChatService(); } + chat::contacts::ContactService& getContactService() override { return ctx_.getContactService(); } + chat::IMeshAdapter* getMeshAdapter() override { return ctx_.getMeshAdapter(); } + const chat::IMeshAdapter* getMeshAdapter() const override { return ctx_.getMeshAdapter(); } + chat::contacts::INodeStore* getNodeStore() override { return ctx_.getNodeStore(); } + const chat::contacts::INodeStore* getNodeStore() const override { return ctx_.getNodeStore(); } + chat::NodeId getSelfNodeId() const override { return ctx_.getSelfNodeId(); } + bool isBleEnabled() const override { return ctx_.isBleEnabled(); } + void setBleEnabled(bool enabled) override { ctx_.setBleEnabled(enabled); } + bool getDeviceMacAddress(uint8_t out_mac[6]) const override { return ctx_.getDeviceMacAddress(out_mac); } + bool syncCurrentEpochSeconds(uint32_t epoch_seconds) override + { + return ctx_.syncCurrentEpochSeconds(epoch_seconds); + } + void resetMeshConfig() override { ctx_.resetMeshConfig(); } + void clearNodeDb() override { ctx_.clearNodeDb(); } + void clearMessageDb() override { ctx_.clearMessageDb(); } + void restartDevice() override { ctx_.restartDevice(); } bool handleMqttProxyToRadio(const meshtastic_MqttClientProxyMessage& msg) override; bool pollMqttProxyToPhone(meshtastic_MqttClientProxyMessage* out) override; diff --git a/platform/nrf52/arduino_common/src/ble/meshcore_ble.cpp b/platform/nrf52/arduino_common/src/ble/meshcore_ble.cpp index b20adace..3e99b1b0 100644 --- a/platform/nrf52/arduino_common/src/ble/meshcore_ble.cpp +++ b/platform/nrf52/arduino_common/src/ble/meshcore_ble.cpp @@ -2,6 +2,7 @@ #include "ble/ble_uuids.h" #include "ble/bluefruit_runtime.h" +#include "platform/shared/ble/app_config_phone_snapshot_bridge.h" #include #include @@ -146,7 +147,7 @@ MeshCoreBleService::MeshCoreBleService(app::IAppBleFacade& ctx, const std::strin service_(::BLEUuid(NUS_SERVICE_UUID)), rx_char_(::BLEUuid(NUS_CHAR_RX_UUID)), tx_char_(::BLEUuid(NUS_CHAR_TX_UUID)), - core_(new MeshCorePhoneCore(ctx, device_name, this)) + core_(new MeshCorePhoneCore(*this, device_name, this)) { } @@ -296,11 +297,12 @@ bool MeshCoreBleService::getCustomVars(std::string* out) const out->clear(); - const auto& cfg = ctx_.getConfig(); - appendCustomVar(*out, "node_name", cfg.node_name); - appendCustomVar(*out, "channel_name", cfg.meshcore_config.meshcore_channel_name); - appendCustomVar(*out, "multi_acks", cfg.meshcore_config.meshcore_multi_acks ? "1" : "0"); - appendCustomVar(*out, "gps", cfg.gps_strategy == 2 ? "0" : "1"); + const auto mesh_cfg = getMeshCorePhoneConfig(); + const auto mt_cfg = getMeshtasticPhoneConfig(); + appendCustomVar(*out, "node_name", mesh_cfg.node_name); + appendCustomVar(*out, "channel_name", mesh_cfg.mesh.meshcore_channel_name); + appendCustomVar(*out, "multi_acks", mesh_cfg.mesh.meshcore_multi_acks ? "1" : "0"); + appendCustomVar(*out, "gps", mt_cfg.gps_enabled ? "1" : "0"); // Keep the variable surface compatible with the MeshCore app even if // nRF52 doesn't implement every extended option yet. @@ -321,28 +323,36 @@ bool MeshCoreBleService::setCustomVar(const char* key, const char* value) return false; } - auto& cfg = ctx_.getConfig(); + auto mesh_cfg = getMeshCorePhoneConfig(); + auto mt_cfg = getMeshtasticPhoneConfig(); bool changed = false; + bool mesh_changed = false; + bool mt_changed = false; + bool position_changed = false; if (std::strcmp(key, "node_name") == 0) { - char next[sizeof(cfg.node_name)] = {}; + char next[sizeof(mesh_cfg.node_name)] = {}; copyBounded(next, sizeof(next), value); - if (std::strcmp(cfg.node_name, next) != 0) + if (std::strcmp(mesh_cfg.node_name, next) != 0) { - copyBounded(cfg.node_name, sizeof(cfg.node_name), next); + copyBounded(mesh_cfg.node_name, sizeof(mesh_cfg.node_name), next); + copyBounded(mt_cfg.node_name, sizeof(mt_cfg.node_name), next); changed = true; + mesh_changed = true; + mt_changed = true; } } else if (std::strcmp(key, "channel_name") == 0) { - char next[sizeof(cfg.meshcore_config.meshcore_channel_name)] = {}; + char next[sizeof(mesh_cfg.mesh.meshcore_channel_name)] = {}; copyBounded(next, sizeof(next), value); - if (std::strcmp(cfg.meshcore_config.meshcore_channel_name, next) != 0) + if (std::strcmp(mesh_cfg.mesh.meshcore_channel_name, next) != 0) { - copyBounded(cfg.meshcore_config.meshcore_channel_name, - sizeof(cfg.meshcore_config.meshcore_channel_name), next); + copyBounded(mesh_cfg.mesh.meshcore_channel_name, + sizeof(mesh_cfg.mesh.meshcore_channel_name), next); changed = true; + mesh_changed = true; } } else if (std::strcmp(key, "multi_acks") == 0) @@ -352,10 +362,11 @@ bool MeshCoreBleService::setCustomVar(const char* key, const char* value) { return false; } - if (cfg.meshcore_config.meshcore_multi_acks != parsed) + if (mesh_cfg.mesh.meshcore_multi_acks != parsed) { - cfg.meshcore_config.meshcore_multi_acks = parsed; + mesh_cfg.mesh.meshcore_multi_acks = parsed; changed = true; + mesh_changed = true; } } else if (std::strcmp(key, "gps") == 0) @@ -365,11 +376,12 @@ bool MeshCoreBleService::setCustomVar(const char* key, const char* value) { return false; } - const uint8_t next_strategy = parsed ? (cfg.gps_strategy == 2 ? 0 : cfg.gps_strategy) : 2; - if (cfg.gps_strategy != next_strategy) + if (mt_cfg.gps_enabled != parsed) { - cfg.gps_strategy = next_strategy; + mt_cfg.gps_enabled = parsed; changed = true; + mt_changed = true; + position_changed = true; } } else if (std::strcmp(key, "manual_add_contacts") == 0 || @@ -390,9 +402,41 @@ bool MeshCoreBleService::setCustomVar(const char* key, const char* value) if (changed) { + if (mesh_changed) + { + setMeshCorePhoneConfig(mesh_cfg); + } + if (mt_changed) + { + setMeshtasticPhoneConfig(mt_cfg); + } ctx_.saveConfig(); } + if (position_changed) + { + ctx_.applyPositionConfig(); + } return true; } +MeshtasticPhoneConfigSnapshot MeshCoreBleService::getMeshtasticPhoneConfig() const +{ + return platform::shared::ble_bridge::makeMeshtasticPhoneConfigSnapshot(ctx_.getConfig()); +} + +void MeshCoreBleService::setMeshtasticPhoneConfig(const MeshtasticPhoneConfigSnapshot& config) +{ + platform::shared::ble_bridge::applyMeshtasticPhoneConfigSnapshot(ctx_.getConfig(), config); +} + +MeshCorePhoneConfigSnapshot MeshCoreBleService::getMeshCorePhoneConfig() const +{ + return platform::shared::ble_bridge::makeMeshCorePhoneConfigSnapshot(ctx_.getConfig()); +} + +void MeshCoreBleService::setMeshCorePhoneConfig(const MeshCorePhoneConfigSnapshot& config) +{ + platform::shared::ble_bridge::applyMeshCorePhoneConfigSnapshot(ctx_.getConfig(), config); +} + } // namespace ble diff --git a/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp b/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp index ec034893..85707fdb 100644 --- a/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp +++ b/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp @@ -1,13 +1,14 @@ #include "../../include/ble/meshtastic_ble.h" -#include "app/app_config.h" #include "ble/ble_uuids.h" #if defined(GAT562_MESH_EVB_PRO) #include "boards/gat562_mesh_evb_pro/settings_store.h" #endif #include "chat/ble/meshtastic_defaults.h" -#include "chat/infra/meshtastic/mt_region.h" +#include "chat/ble/meshtastic_phone_config_bridge.h" #include "platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h" +#include "platform/shared/ble/app_config_phone_snapshot_bridge.h" +#include "platform/shared/ble/meshtastic_phone_runtime_bridge.h" #if !defined(GAT562_MESH_EVB_PRO) #include "platform/ui/settings_store.h" #endif @@ -63,124 +64,6 @@ platform::nrf52::arduino_common::chat::meshtastic::MeshtasticRadioAdapter* getMe return static_cast(backend); } -void copyBounded(char* dst, size_t dst_len, const char* src) -{ - if (!dst || dst_len == 0) - { - return; - } - if (!src) - { - dst[0] = '\0'; - return; - } - - std::strncpy(dst, src, dst_len - 1); - dst[dst_len - 1] = '\0'; -} - -void repairLegacyMqttConfig(meshtastic_LocalModuleConfig* config) -{ - if (!config) - { - return; - } - - config->has_mqtt = true; - config->mqtt.address[sizeof(config->mqtt.address) - 1] = '\0'; - config->mqtt.username[sizeof(config->mqtt.username) - 1] = '\0'; - config->mqtt.password[sizeof(config->mqtt.password) - 1] = '\0'; - config->mqtt.root[sizeof(config->mqtt.root) - 1] = '\0'; - if (config->mqtt.address[0] == '\0') - { - copyBounded(config->mqtt.address, sizeof(config->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); - } - if (config->mqtt.username[0] == '\0') - { - copyBounded(config->mqtt.username, sizeof(config->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); - } - if (config->mqtt.password[0] == '\0') - { - copyBounded(config->mqtt.password, sizeof(config->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); - } - if (config->mqtt.root[0] == '\0') - { - copyBounded(config->mqtt.root, sizeof(config->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); - } - if (!config->mqtt.has_map_report_settings) - { - config->mqtt.has_map_report_settings = true; - config->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; - config->mqtt.map_report_settings.position_precision = 0; - config->mqtt.map_report_settings.should_report_location = false; - } -} - -void initDefaultModuleConfig(app::IAppBleFacade& ctx, meshtastic_LocalModuleConfig* config) -{ - if (!config) - { - return; - } - - meshtastic_LocalModuleConfig zero = meshtastic_LocalModuleConfig_init_zero; - *config = zero; - config->version = meshtastic_defaults::kModuleConfigVersion; - config->has_mqtt = true; - config->has_serial = true; - config->has_external_notification = true; - config->has_store_forward = true; - config->has_range_test = true; - config->has_telemetry = true; - config->has_canned_message = true; - config->has_audio = true; - config->has_remote_hardware = true; - config->has_neighbor_info = true; - config->has_ambient_lighting = true; - config->has_detection_sensor = true; - config->has_paxcounter = true; - - config->mqtt.enabled = false; - config->mqtt.proxy_to_client_enabled = false; - copyBounded(config->mqtt.address, sizeof(config->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); - copyBounded(config->mqtt.username, sizeof(config->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); - copyBounded(config->mqtt.password, sizeof(config->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); - copyBounded(config->mqtt.root, sizeof(config->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); - config->mqtt.encryption_enabled = meshtastic_defaults::kDefaultMqttEncryptionEnabled; - config->mqtt.tls_enabled = meshtastic_defaults::kDefaultMqttTlsEnabled; - config->mqtt.has_map_report_settings = true; - config->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; - config->mqtt.map_report_settings.position_precision = 0; - config->mqtt.map_report_settings.should_report_location = false; - - config->telemetry.device_update_interval = 3600; - config->telemetry.device_telemetry_enabled = true; - config->telemetry.environment_update_interval = 0; - config->telemetry.environment_measurement_enabled = false; - config->telemetry.power_update_interval = 0; - config->telemetry.health_update_interval = 0; - config->telemetry.air_quality_interval = 0; - - config->neighbor_info.enabled = false; - config->neighbor_info.update_interval = 0; - config->neighbor_info.transmit_over_lora = false; - - config->detection_sensor.enabled = false; - config->ambient_lighting.current = meshtastic_defaults::kDefaultAmbientCurrent; - const uint32_t self_node = ctx.getSelfNodeId(); - config->ambient_lighting.red = (self_node >> 16) & 0xFFU; - config->ambient_lighting.green = (self_node >> 8) & 0xFFU; - config->ambient_lighting.blue = self_node & 0xFFU; -} - -struct PersistedBleState -{ - bool has_bluetooth = false; - bool has_module = false; - meshtastic_Config_BluetoothConfig bluetooth = meshtastic_Config_BluetoothConfig_init_zero; - meshtastic_LocalModuleConfig module = meshtastic_LocalModuleConfig_init_zero; -}; - #if !defined(GAT562_MESH_EVB_PRO) template bool loadBlobConfigFromUiStore(const char* key, T* out) @@ -222,14 +105,14 @@ bool saveBlobConfigToUiStore(const char* key, const T& value) } #endif -bool loadPersistedBleState(PersistedBleState* out) +bool loadPersistedBleState(meshtastic_config_bridge::PersistedState* out) { if (!out) { return false; } - *out = PersistedBleState{}; + *out = meshtastic_config_bridge::PersistedState{}; #if defined(GAT562_MESH_EVB_PRO) if (::boards::gat562_mesh_evb_pro::settings_store::loadMeshtasticBleState(&out->bluetooth, &out->module)) @@ -264,30 +147,6 @@ bool savePersistedBleState(const meshtastic_Config_BluetoothConfig& bluetooth, #endif } -bool isValidBluetoothMode(uint8_t mode) -{ - return mode <= static_cast(meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN); -} - -bool isValidBlePin(uint32_t pin) -{ - return pin == 0 || (pin >= 100000 && pin <= 999999); -} - -std::string channelDisplayName(const app::IAppBleFacade& ctx, uint8_t idx) -{ - const auto& cfg = ctx.getConfig(); - if (idx == 1) - { - return "Secondary"; - } - - auto preset = static_cast(cfg.meshtastic_config.modem_preset); - return cfg.meshtastic_config.use_preset - ? std::string(chat::meshtastic::presetDisplayName(preset)) - : std::string("Custom"); -} - uint32_t parsePasskeyDigits(const uint8_t passkey[6]) { if (!passkey) @@ -478,7 +337,7 @@ MeshtasticBleService::MeshtasticBleService(app::IAppBleFacade& ctx, const std::s ble_config_.mode = meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN; ble_config_.fixed_pin = 0; - PersistedBleState persisted{}; + meshtastic_config_bridge::PersistedState persisted{}; const bool persisted_ok = loadPersistedBleState(&persisted); bleLogBoth("[BLE][nrf52][mt] persisted load ok=%u has_bt=%u has_mod=%u", @@ -494,37 +353,24 @@ MeshtasticBleService::MeshtasticBleService(app::IAppBleFacade& ctx, const std::s ::boards::gat562_mesh_evb_pro::settings_store::lastSaveStatus())); #endif - if (persisted.has_bluetooth && - isValidBluetoothMode(static_cast(persisted.bluetooth.mode)) && - isValidBlePin(persisted.bluetooth.fixed_pin)) + meshtastic_config_bridge::initializeConfigState( + persisted, ctx_.isBleEnabled(), ctx_.getSelfNodeId(), &ble_config_, &module_config_); + + if (persisted.has_bluetooth) { - ble_config_.mode = persisted.bluetooth.mode; - ble_config_.fixed_pin = persisted.bluetooth.fixed_pin; bleLogBoth("[BLE][nrf52][mt] loaded bluetooth cfg mode=%u pin=%06lu", static_cast(ble_config_.mode), static_cast(ble_config_.fixed_pin)); } - if (ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) - { - ble_config_.fixed_pin = 0; - } - - initDefaultModuleConfig(ctx_, &module_config_); if (persisted.has_module) { - module_config_ = persisted.module; - if (module_config_.version == 0) - { - module_config_.version = meshtastic_defaults::kModuleConfigVersion; - } - repairLegacyMqttConfig(&module_config_); bleLogBoth("[BLE][nrf52][mt] loaded module cfg mqtt enabled=%u proxy=%u root=%s", module_config_.has_mqtt && module_config_.mqtt.enabled ? 1U : 0U, module_config_.has_mqtt && module_config_.mqtt.proxy_to_client_enabled ? 1U : 0U, module_config_.mqtt.root); } - phone_session_.reset(new MeshtasticPhoneSession(ctx, *this, this)); + phone_session_.reset(new MeshtasticPhoneSession(*this, *this, this, this, this, this, this, this)); } MeshtasticBleService::~MeshtasticBleService() @@ -1264,29 +1110,14 @@ void MeshtasticBleService::flushPendingFromNumNotify() bool MeshtasticBleService::loadBluetoothConfig(meshtastic_Config_BluetoothConfig* out) const { - if (!out) - { - return false; - } - *out = ble_config_; - out->enabled = ctx_.isBleEnabled(); - return true; + return meshtastic_config_bridge::loadBluetoothConfigView(ble_config_, ctx_.isBleEnabled(), out); } void MeshtasticBleService::saveBluetoothConfig(const meshtastic_Config_BluetoothConfig& config) { ble_config_ = config; + meshtastic_config_bridge::normalizeBluetoothConfig(&ble_config_); ble_config_.enabled = config.enabled; - if (!isValidBluetoothMode(static_cast(ble_config_.mode))) - { - ble_config_.mode = meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN; - } - if (!isValidBlePin(ble_config_.fixed_pin) || - ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) - { - ble_config_.fixed_pin = 0; - } - markConfigSavePending(true, false); bleLogBoth("[BLE][nrf52][mt] saveBluetoothConfig queued mode=%u pin=%06lu enabled=%u", static_cast(ble_config_.mode), @@ -1296,17 +1127,7 @@ void MeshtasticBleService::saveBluetoothConfig(const meshtastic_Config_Bluetooth bool MeshtasticBleService::loadDeviceConnectionStatus(meshtastic_DeviceConnectionStatus* out) const { - if (!out) - { - return false; - } - meshtastic_DeviceConnectionStatus status = meshtastic_DeviceConnectionStatus_init_zero; - *out = status; - out->has_bluetooth = true; - out->bluetooth.pin = effectivePasskey(); - out->bluetooth.rssi = 0; - out->bluetooth.is_connected = isBleConnected(); - return true; + return meshtastic_config_bridge::fillDeviceConnectionStatus(effectivePasskey(), isBleConnected(), out); } bool MeshtasticBleService::loadModuleConfig(meshtastic_LocalModuleConfig* out) const @@ -1317,7 +1138,7 @@ bool MeshtasticBleService::loadModuleConfig(meshtastic_LocalModuleConfig* out) c } *out = module_config_; - repairLegacyMqttConfig(out); + meshtastic_config_bridge::normalizeModuleConfig(out); return true; } @@ -1329,16 +1150,72 @@ void MeshtasticBleService::saveModuleConfig(const meshtastic_LocalModuleConfig& config.has_mqtt ? (config.mqtt.encryption_enabled ? 1U : 0U) : 1U, config.has_mqtt ? config.mqtt.root : ""); module_config_ = config; - if (module_config_.version == 0) - { - module_config_.version = meshtastic_defaults::kModuleConfigVersion; - } - repairLegacyMqttConfig(&module_config_); + meshtastic_config_bridge::normalizeModuleConfig(&module_config_); markConfigSavePending(false, true); syncMqttProxySettings(); bleLogBoth("[BLE][nrf52][mt] saveModuleConfig queued"); } +bool MeshtasticBleService::loadTimezoneTzdef(char* out, size_t out_len) const +{ + if (!out || out_len == 0) + { + return false; + } + + out[0] = '\0'; + +#if defined(GAT562_MESH_EVB_PRO) + return false; +#else + return platform::shared::ble_bridge::loadTimezoneTzdefFromSettings(out, out_len); +#endif +} + +void MeshtasticBleService::saveTimezoneTzdef(const char* tzdef) +{ +#if defined(GAT562_MESH_EVB_PRO) + (void)tzdef; +#else + platform::shared::ble_bridge::saveTimezoneTzdefToSettings(tzdef); +#endif +} + +int MeshtasticBleService::getTimezoneOffsetMinutes() const +{ + return platform::shared::ble_bridge::getTimezoneOffsetMinutes(); +} + +void MeshtasticBleService::setTimezoneOffsetMinutes(int offset_min) +{ + platform::shared::ble_bridge::setTimezoneOffsetMinutes(offset_min); +} + +bool MeshtasticBleService::getGpsFix(MeshtasticGpsFix* out) const +{ + return platform::shared::ble_bridge::setMeshtasticGpsFixUnavailable(out); +} + +MeshtasticPhoneConfigSnapshot MeshtasticBleService::getMeshtasticPhoneConfig() const +{ + return platform::shared::ble_bridge::makeMeshtasticPhoneConfigSnapshot(ctx_.getConfig()); +} + +void MeshtasticBleService::setMeshtasticPhoneConfig(const MeshtasticPhoneConfigSnapshot& config) +{ + platform::shared::ble_bridge::applyMeshtasticPhoneConfigSnapshot(ctx_.getConfig(), config); +} + +MeshCorePhoneConfigSnapshot MeshtasticBleService::getMeshCorePhoneConfig() const +{ + return platform::shared::ble_bridge::makeMeshCorePhoneConfigSnapshot(ctx_.getConfig()); +} + +void MeshtasticBleService::setMeshCorePhoneConfig(const MeshCorePhoneConfigSnapshot& config) +{ + platform::shared::ble_bridge::applyMeshCorePhoneConfigSnapshot(ctx_.getConfig(), config); +} + void MeshtasticBleService::markConfigSavePending(bool bluetooth_changed, bool module_changed) { if (bluetooth_changed) @@ -1377,9 +1254,12 @@ void MeshtasticBleService::flushPendingConfigSaves(bool force) } meshtastic_Config_BluetoothConfig persisted_bluetooth = ble_config_; + meshtastic_config_bridge::normalizeBluetoothConfig(&persisted_bluetooth); persisted_bluetooth.enabled = ctx_.isBleEnabled(); + meshtastic_LocalModuleConfig persisted_module = module_config_; + meshtastic_config_bridge::normalizeModuleConfig(&persisted_module); const bool needs_save = bluetooth_config_save_pending_ || module_config_save_pending_; - const bool persisted = needs_save ? savePersistedBleState(persisted_bluetooth, module_config_) : true; + const bool persisted = needs_save ? savePersistedBleState(persisted_bluetooth, persisted_module) : true; ble_config_.enabled = persisted_bluetooth.enabled; bleLogBoth("[BLE][nrf52][mt] current mem persisted=%u bluetooth_config_save_pending_=%u module_config_save_pending_=%u needs_save=%u", @@ -1440,18 +1320,9 @@ void MeshtasticBleService::syncMqttProxySettings() return; } - const auto& cfg = ctx_.getConfig(); + const auto cfg = getMeshtasticPhoneConfig(); platform::nrf52::arduino_common::chat::meshtastic::MeshtasticRadioAdapter::MqttProxySettings settings; - settings.enabled = module_config_.has_mqtt && module_config_.mqtt.enabled; - settings.proxy_to_client_enabled = module_config_.has_mqtt && module_config_.mqtt.proxy_to_client_enabled; - settings.encryption_enabled = module_config_.has_mqtt ? module_config_.mqtt.encryption_enabled : true; - settings.primary_uplink_enabled = cfg.primary_uplink_enabled; - settings.primary_downlink_enabled = cfg.primary_downlink_enabled; - settings.secondary_uplink_enabled = cfg.secondary_uplink_enabled; - settings.secondary_downlink_enabled = cfg.secondary_downlink_enabled; - settings.root = module_config_.mqtt.root[0] ? module_config_.mqtt.root : meshtastic_defaults::kDefaultMqttRoot; - settings.primary_channel_id = channelDisplayName(ctx_, 0); - settings.secondary_channel_id = channelDisplayName(ctx_, 1); + platform::shared::ble_bridge::applyMeshtasticMqttProxySettings(settings, module_config_, cfg); mt->setMqttProxySettings(settings); } diff --git a/platform/shared/include/board/GpsBoard.h b/platform/shared/include/board/GpsBoard.h index 73e60852..add5c5d5 100644 --- a/platform/shared/include/board/GpsBoard.h +++ b/platform/shared/include/board/GpsBoard.h @@ -12,6 +12,7 @@ class GpsBoard virtual bool initGPS() = 0; virtual void setGPSOnline(bool online) = 0; + virtual void deinitGPS() { setGPSOnline(false); } virtual GPS& getGPS() = 0; virtual bool isGPSReady() const = 0; virtual void powerControl(PowerCtrlChannel_t ch, bool enable) = 0; diff --git a/platform/shared/include/board/LoraBoard.h b/platform/shared/include/board/LoraBoard.h index 8a1c5b97..b9e943a8 100644 --- a/platform/shared/include/board/LoraBoard.h +++ b/platform/shared/include/board/LoraBoard.h @@ -18,6 +18,10 @@ class LoraBoard virtual int readRadioData(uint8_t* buf, size_t len) = 0; virtual void clearRadioIrqFlags(uint32_t flags) = 0; virtual float getRadioRSSI() = 0; + virtual float getRadioInstantRSSI() + { + return getRadioRSSI(); + } virtual float getRadioSNR() = 0; // Board-specific LoRa configuration without exposing SX126x types. diff --git a/platform/shared/include/platform/shared/ble/app_config_phone_snapshot_bridge.h b/platform/shared/include/platform/shared/ble/app_config_phone_snapshot_bridge.h new file mode 100644 index 00000000..f3ddc183 --- /dev/null +++ b/platform/shared/include/platform/shared/ble/app_config_phone_snapshot_bridge.h @@ -0,0 +1,94 @@ +#pragma once + +#include "app/app_config.h" +#include "chat/ble/phone_runtime_context.h" + +#include +#include + +namespace platform +{ +namespace shared +{ +namespace ble_bridge +{ + +inline void copyBounded(char* dst, std::size_t dst_len, const char* src) +{ + if (!dst || dst_len == 0) + { + return; + } + if (!src) + { + dst[0] = '\0'; + return; + } + + std::strncpy(dst, src, dst_len - 1); + dst[dst_len - 1] = '\0'; +} + +inline ::ble::MeshtasticPhoneConfigSnapshot makeMeshtasticPhoneConfigSnapshot(const app::AppConfig& cfg) +{ + ::ble::MeshtasticPhoneConfigSnapshot snapshot{}; + snapshot.mesh = cfg.meshtastic_config; + snapshot.relay_enabled = cfg.chat_policy.enable_relay; + copyBounded(snapshot.node_name, sizeof(snapshot.node_name), cfg.node_name); + copyBounded(snapshot.short_name, sizeof(snapshot.short_name), cfg.short_name); + snapshot.primary_enabled = cfg.primary_enabled; + snapshot.secondary_enabled = cfg.secondary_enabled; + snapshot.primary_uplink_enabled = cfg.primary_uplink_enabled; + snapshot.primary_downlink_enabled = cfg.primary_downlink_enabled; + snapshot.secondary_uplink_enabled = cfg.secondary_uplink_enabled; + snapshot.secondary_downlink_enabled = cfg.secondary_downlink_enabled; + snapshot.gps_enabled = cfg.gps_enabled; + snapshot.gps_mode = cfg.gps_mode; + snapshot.gps_interval_ms = cfg.gps_interval_ms; + snapshot.gps_strategy = cfg.gps_strategy; + return snapshot; +} + +inline void applyMeshtasticPhoneConfigSnapshot(app::AppConfig& cfg, + const ::ble::MeshtasticPhoneConfigSnapshot& snapshot) +{ + cfg.meshtastic_config = snapshot.mesh; + cfg.chat_policy.enable_relay = snapshot.relay_enabled; + copyBounded(cfg.node_name, sizeof(cfg.node_name), snapshot.node_name); + copyBounded(cfg.short_name, sizeof(cfg.short_name), snapshot.short_name); + cfg.primary_enabled = snapshot.primary_enabled; + cfg.secondary_enabled = snapshot.secondary_enabled; + cfg.primary_uplink_enabled = snapshot.primary_uplink_enabled; + cfg.primary_downlink_enabled = snapshot.primary_downlink_enabled; + cfg.secondary_uplink_enabled = snapshot.secondary_uplink_enabled; + cfg.secondary_downlink_enabled = snapshot.secondary_downlink_enabled; + cfg.gps_enabled = snapshot.gps_enabled; + cfg.gps_mode = snapshot.gps_mode; + cfg.gps_interval_ms = snapshot.gps_interval_ms; + cfg.gps_strategy = snapshot.gps_strategy; +} + +inline ::ble::MeshCorePhoneConfigSnapshot makeMeshCorePhoneConfigSnapshot(const app::AppConfig& cfg) +{ + ::ble::MeshCorePhoneConfigSnapshot snapshot{}; + snapshot.active_protocol = cfg.mesh_protocol; + snapshot.mesh = cfg.meshcore_config; + copyBounded(snapshot.node_name, sizeof(snapshot.node_name), cfg.node_name); + copyBounded(snapshot.short_name, sizeof(snapshot.short_name), cfg.short_name); + snapshot.tx_power_min_dbm = app::AppConfig::kTxPowerMinDbm; + snapshot.tx_power_max_dbm = app::AppConfig::kTxPowerMaxDbm; + return snapshot; +} + +inline void applyMeshCorePhoneConfigSnapshot(app::AppConfig& cfg, + const ::ble::MeshCorePhoneConfigSnapshot& snapshot) +{ + cfg.mesh_protocol = snapshot.active_protocol; + cfg.meshcore_config = snapshot.mesh; + copyBounded(cfg.node_name, sizeof(cfg.node_name), snapshot.node_name); + copyBounded(cfg.short_name, sizeof(cfg.short_name), snapshot.short_name); +} + +} // namespace ble_bridge +} // namespace shared +} // namespace platform diff --git a/platform/shared/include/platform/shared/ble/meshtastic_phone_runtime_bridge.h b/platform/shared/include/platform/shared/ble/meshtastic_phone_runtime_bridge.h new file mode 100644 index 00000000..f834bb3a --- /dev/null +++ b/platform/shared/include/platform/shared/ble/meshtastic_phone_runtime_bridge.h @@ -0,0 +1,126 @@ +#pragma once + +#include "chat/ble/meshtastic_defaults.h" +#include "chat/ble/meshtastic_phone_core.h" +#include "chat/ble/phone_runtime_context.h" +#include "chat/infra/meshtastic/mt_region.h" +#include "meshtastic/localonly.pb.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/settings_store.h" +#include "platform/ui/time_runtime.h" + +#include +#include +#include +#include +#include + +namespace platform::shared::ble_bridge +{ + +constexpr const char* kUiSettingsNs = "settings"; +constexpr const char* kTimezoneTzdefKey = "timezone_tzdef"; + +inline std::string meshtasticChannelDisplayName(const ::ble::MeshtasticPhoneConfigSnapshot& cfg, uint8_t idx) +{ + if (idx == 1) + { + return "Secondary"; + } + + auto preset = static_cast(cfg.mesh.modem_preset); + return cfg.mesh.use_preset ? std::string(::chat::meshtastic::presetDisplayName(preset)) : std::string("Custom"); +} + +inline bool loadTimezoneTzdefFromSettings(char* out, std::size_t out_len) +{ + if (!out || out_len == 0) + { + return false; + } + + out[0] = '\0'; + std::vector blob; + if (!platform::ui::settings_store::get_blob(kUiSettingsNs, kTimezoneTzdefKey, blob) || blob.empty()) + { + return false; + } + + const std::size_t copy_len = std::min(out_len - 1U, blob.size()); + std::memcpy(out, blob.data(), copy_len); + out[copy_len] = '\0'; + return out[0] != '\0'; +} + +inline void saveTimezoneTzdefToSettings(const char* tzdef) +{ + if (!tzdef || tzdef[0] == '\0') + { + const char* keys[] = {kTimezoneTzdefKey}; + platform::ui::settings_store::remove_keys(kUiSettingsNs, keys, 1); + return; + } + + (void)platform::ui::settings_store::put_blob(kUiSettingsNs, kTimezoneTzdefKey, tzdef, std::strlen(tzdef) + 1U); +} + +inline int getTimezoneOffsetMinutes() +{ + return platform::ui::time::timezone_offset_min(); +} + +inline void setTimezoneOffsetMinutes(int offset_min) +{ + platform::ui::time::set_timezone_offset_min(offset_min); +} + +inline bool setMeshtasticGpsFixUnavailable(::ble::MeshtasticGpsFix* out) +{ + if (!out) + { + return false; + } + + *out = {}; + return false; +} + +inline bool fillMeshtasticGpsFixFromUiRuntime(::ble::MeshtasticGpsFix* out) +{ + if (!out) + { + return false; + } + + const platform::ui::gps::GpsState gps_state = platform::ui::gps::get_data(); + out->valid = gps_state.valid; + out->lat = gps_state.lat; + out->lng = gps_state.lng; + out->has_alt = gps_state.has_alt; + out->alt_m = gps_state.alt_m; + out->has_speed = gps_state.has_speed; + out->speed_mps = gps_state.speed_mps; + out->has_course = gps_state.has_course; + out->course_deg = gps_state.course_deg; + out->satellites = gps_state.satellites; + return out->valid; +} + +template +inline void applyMeshtasticMqttProxySettings(Settings& settings, + const meshtastic_LocalModuleConfig& module_config, + const ::ble::MeshtasticPhoneConfigSnapshot& cfg) +{ + settings.enabled = module_config.has_mqtt && module_config.mqtt.enabled; + settings.proxy_to_client_enabled = module_config.has_mqtt && module_config.mqtt.proxy_to_client_enabled; + settings.encryption_enabled = module_config.has_mqtt ? module_config.mqtt.encryption_enabled : true; + settings.primary_uplink_enabled = cfg.primary_uplink_enabled; + settings.primary_downlink_enabled = cfg.primary_downlink_enabled; + settings.secondary_uplink_enabled = cfg.secondary_uplink_enabled; + settings.secondary_downlink_enabled = cfg.secondary_downlink_enabled; + settings.root = module_config.mqtt.root[0] ? module_config.mqtt.root : ::ble::meshtastic_defaults::kDefaultMqttRoot; + settings.primary_channel_id = meshtasticChannelDisplayName(cfg, 0); + settings.secondary_channel_id = meshtasticChannelDisplayName(cfg, 1); +} + +} // namespace platform::shared::ble_bridge diff --git a/scripts/build_pack_repository.py b/scripts/build_pack_repository.py index ee394f23..38fa01a3 100644 --- a/scripts/build_pack_repository.py +++ b/scripts/build_pack_repository.py @@ -338,6 +338,8 @@ def collect_locale_records(stage_bundle_dir: Path) -> list[dict[str, object]]: "content_font_pack_id": manifest.get( "content_font_pack", manifest.get("ui_font_pack", "") ), + "translation_status": manifest.get("translation_status", "release"), + "direction": manifest.get("direction", "ltr"), "preferred_content_supplement_pack_ids": split_csv( manifest.get("preferred_content_supplement_packs", manifest.get("content_supplement_packs", "")) ), diff --git a/scripts/check_platform_ui_boundaries.py b/scripts/check_platform_ui_boundaries.py new file mode 100644 index 00000000..b363e5bb --- /dev/null +++ b/scripts/check_platform_ui_boundaries.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Guard shared/platform UI boundaries from regressing.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +import re +import sys + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +@dataclass(frozen=True) +class Rule: + name: str + roots: tuple[str, ...] + includes: tuple[re.Pattern[str], ...] + excludes: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Violation: + path: Path + line_number: int + line: str + rule_name: str + + +SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh"} +EXCLUDED_DIR_NAMES = { + ".git", + ".pio", + ".pytest_cache", + ".tmp", + ".venv", + "__pycache__", + "build", + "dist", +} + + +def compile_patterns(patterns: tuple[str, ...]) -> tuple[re.Pattern[str], ...]: + return tuple(re.compile(pattern) for pattern in patterns) + + +RULES = ( + Rule( + name="legacy-platform-ui-shim-include", + roots=(".",), + includes=compile_patterns( + ( + r'#include\s*[<"]ui/runtime/pack_repository\.h[>"]', + r'#include\s*[<"]ui/screens/team/team_ui_store\.h[>"]', + ) + ), + excludes=( + "modules/ui_shared/src/ui/runtime/pack_repository.cpp", + "modules/ui_shared/src/ui/screens/team/team_ui_store.cpp", + ), + ), + Rule( + name="ui_shared-esp-only-include", + roots=("modules/ui_shared",), + includes=compile_patterns( + ( + r'#include\s*[<"]Arduino\.h[>"]', + r'#include\s*[<"]Preferences[>"]', + r'#include\s*[<"]SD\.h[>"]', + r'#include\s*[<"]FS\.h[>"]', + r'#include\s*[<"]platform/esp/[^>"]+[>"]', + ) + ), + ), + Rule( + name="core-modules-platform-tail", + roots=( + "modules/core_chat", + "modules/core_gps", + "modules/core_hostlink", + "modules/core_sys", + "modules/core_team", + ), + includes=compile_patterns( + ( + r'#include\s*[<"]Arduino\.h[>"]', + r'#include\s*[<"]Preferences[>"]', + r'#include\s*[<"]SD\.h[>"]', + r'#include\s*[<"]FS\.h[>"]', + r'#include\s*[<"]freertos/[^>"]+[>"]', + r'#include\s*[<"]rtos\.h[>"]', + r'#include\s*[<"]platform/esp/[^>"]+[>"]', + ) + ), + excludes=("modules/core_chat/generated",), + ), +) + + +def iter_source_files(root: Path): + for current_root, dir_names, file_names in os.walk(root): + dir_names[:] = [name for name in dir_names if name not in EXCLUDED_DIR_NAMES] + current_root_path = Path(current_root) + for file_name in file_names: + path = current_root_path / file_name + if path.suffix.lower() in SOURCE_SUFFIXES: + yield path + + +def is_excluded(path: Path, excludes: tuple[str, ...]) -> bool: + relative = path.relative_to(REPO_ROOT).as_posix() + return any(relative == prefix or relative.startswith(prefix.rstrip("/") + "/") for prefix in excludes) + + +def collect_violations() -> list[Violation]: + violations: list[Violation] = [] + seen_files: set[Path] = set() + + for rule in RULES: + for root_name in rule.roots: + root_path = REPO_ROOT / root_name + if not root_path.exists(): + continue + for path in iter_source_files(root_path): + if is_excluded(path, rule.excludes): + continue + # Avoid rescanning repo root duplicates for the same rule set. + key = (rule.name, path) + if key in seen_files: + continue + seen_files.add(key) + for line_number, line in enumerate(path.read_text(encoding="utf-8", errors="ignore").splitlines(), start=1): + if any(pattern.search(line) for pattern in rule.includes): + violations.append( + Violation( + path=path, + line_number=line_number, + line=line.strip(), + rule_name=rule.name, + ) + ) + return violations + + +def main() -> int: + violations = collect_violations() + if not violations: + print("Boundary check passed.") + return 0 + + print("Boundary check failed:") + for violation in violations: + relative = violation.path.relative_to(REPO_ROOT).as_posix() + print(f"- [{violation.rule_name}] {relative}:{violation.line_number}") + print(f" {violation.line}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/data/packs.json b/site/data/packs.json index 47d6514c..aef7ad37 100644 --- a/site/data/packs.json +++ b/site/data/packs.json @@ -1,18 +1,144 @@ { "schema_version": 1, "archive_layout": "trailmate-pack-v1", - "generated_at": "2026-04-21T02:00:30.208708Z", + "generated_at": "2026-05-09T03:51:33.335618Z", "packages": [ + { + "id": "ar", + "package_type": "locale-bundle", + "version": "1.1.0", + "display_name": "Arabic", + "summary": "Arabic review locale bundle with Naskh font coverage for RTL UI text and mixed-script content rendering; display-only until Arabic input is implemented.", + "description": "Arabic locale bundle for Trail Mate.\n\nIncludes:\n- ar-naskh font (Noto Naskh Arabic, 16px, 2bpp)\n Covers Arabic core (0600-06FF), Supplement (0750-077F),\n Presentation Forms-A (FB50-FDFF) and Forms-B (FE70-FEFF)\n for correct contextual letter shaping without HarfBuzz.\n- Arabic translations (ar locale)\n- RTL direction support (direction=rtl in manifest)\n- Display-only review status; Arabic input is not implemented yet\n\nFont dependency: tools/fonts/NotoNaskhArabic-Regular.otf", + "author": "Trail Mate", + "homepage": "https://vicliu624.github.io/trail-mate/", + "min_firmware_version": "0.1.24-alpha", + "supported_memory_profiles": [ + "standard", + "extended" + ], + "tags": [ + "language", + "arabic", + "rtl", + "review" + ], + "provides": { + "locales": [ + { + "id": "ar", + "display_name": "Arabic", + "native_name": "العربية", + "ui_font_pack_id": "ar-naskh", + "content_font_pack_id": "ar-naskh", + "translation_status": "review", + "direction": "rtl", + "preferred_content_supplement_pack_ids": [], + "ime_pack_id": "" + } + ], + "fonts": [ + { + "id": "ar-naskh", + "display_name": "Arabic Naskh", + "usage": "both", + "estimated_ram_bytes": 18432 + } + ], + "ime": [] + }, + "runtime": { + "estimated_unique_font_ram_bytes": 18432, + "locale_count": 1, + "font_count": 1, + "ime_count": 0 + }, + "archive": { + "layout": "trailmate-pack-v1", + "path": "assets/packs/ar-1.1.0.zip", + "size_bytes": 32415, + "sha256": "0c23d1a1a29d9ee01804d4018b87c302ed00566c09e18640a823c20410b3aa8f" + }, + "files": { + "package_manifest": "package.ini", + "description": "DESCRIPTION.txt", + "readme": "README.md" + } + }, + { + "id": "europe-cyrillic-ext", + "package_type": "locale-bundle", + "version": "1.1.0", + "display_name": "European Cyrillic Extended", + "summary": "Shared Cyrillic font coverage plus a Russian review locale pack; display-only until native review is complete.", + "description": "European Cyrillic Extended installs the Russian locale together with a shared\nCyrillic external font pack so interface labels and Russian content can render\nwithout inflating the firmware image.\n\nThis bundle is display-only today. It does not add a Russian IME, and the locale\nremains in review status until native-language review is complete.\n\nCredits: Russian translations were contributed by polarikus, based on the\npolarikus/trail-mate Russian localization PR.", + "author": "Trail Mate", + "homepage": "https://vicliu624.github.io/trail-mate/", + "min_firmware_version": "0.1.24-alpha", + "supported_memory_profiles": [ + "constrained", + "standard", + "extended" + ], + "tags": [ + "language", + "cyrillic", + "europe", + "russian", + "review" + ], + "provides": { + "locales": [ + { + "id": "ru", + "display_name": "Russian", + "native_name": "Русский", + "ui_font_pack_id": "cyrillic-eu", + "content_font_pack_id": "cyrillic-eu", + "translation_status": "review", + "direction": "ltr", + "preferred_content_supplement_pack_ids": [], + "ime_pack_id": "" + } + ], + "fonts": [ + { + "id": "cyrillic-eu", + "display_name": "Cyrillic EU", + "usage": "both", + "estimated_ram_bytes": 12000 + } + ], + "ime": [] + }, + "runtime": { + "estimated_unique_font_ram_bytes": 12000, + "locale_count": 1, + "font_count": 1, + "ime_count": 0 + }, + "archive": { + "layout": "trailmate-pack-v1", + "path": "assets/packs/europe-cyrillic-ext-1.1.0.zip", + "size_bytes": 15564, + "sha256": "e2afc57f55da1228178428f18447c452ea3036e3ef11953915b46c9de4ee36eb" + }, + "files": { + "package_manifest": "package.ini", + "description": "DESCRIPTION.txt", + "readme": "README.md" + } + }, { "id": "europe-latin-ext", "package_type": "locale-bundle", - "version": "1.0.0", + "version": "1.1.0", "display_name": "European Latin Extended", - "summary": "Shared Latin-Extended font coverage plus German, Spanish, French, Italian, Dutch, Polish, and Portuguese locale packs.", - "description": "European Latin Extended adds a shared external font pack for common accented Latin text\nand installs locale packs for German, Spanish, French, Italian, Dutch, Polish, and\nPortuguese (Portugal).\n\nThis bundle is designed to stay lightweight while still rendering mixed-language contact\nnames, chat content, and localized UI labels cleanly across European languages.", + "summary": "Shared Latin-Extended font coverage plus German, Spanish, French, Italian, Dutch, Polish, and Portuguese review locale packs.", + "description": "European Latin Extended adds a shared external font pack for common accented Latin text\nand installs locale packs for German, Spanish, French, Italian, Dutch, Polish, and\nPortuguese (Portugal).\n\nThis bundle is designed to stay lightweight while still rendering mixed-language contact\nnames, chat content, and localized UI labels cleanly across European languages. The\nlocales are review-status until native-language review is complete.", "author": "Trail Mate", "homepage": "https://vicliu624.github.io/trail-mate/", - "min_firmware_version": "0.1.18-alpha", + "min_firmware_version": "0.1.24-alpha", "supported_memory_profiles": [ "constrained", "standard", @@ -21,7 +147,8 @@ "tags": [ "language", "latin", - "europe" + "europe", + "review" ], "provides": { "locales": [ @@ -31,6 +158,8 @@ "native_name": "Deutsch", "ui_font_pack_id": "latin-ext-eu", "content_font_pack_id": "latin-ext-eu", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" }, @@ -40,6 +169,8 @@ "native_name": "Español", "ui_font_pack_id": "latin-ext-eu", "content_font_pack_id": "latin-ext-eu", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" }, @@ -49,6 +180,8 @@ "native_name": "Français", "ui_font_pack_id": "latin-ext-eu", "content_font_pack_id": "latin-ext-eu", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" }, @@ -58,6 +191,8 @@ "native_name": "Italiano", "ui_font_pack_id": "latin-ext-eu", "content_font_pack_id": "latin-ext-eu", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" }, @@ -67,6 +202,8 @@ "native_name": "Nederlands", "ui_font_pack_id": "latin-ext-eu", "content_font_pack_id": "latin-ext-eu", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" }, @@ -76,6 +213,8 @@ "native_name": "Polski", "ui_font_pack_id": "latin-ext-eu", "content_font_pack_id": "latin-ext-eu", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" }, @@ -85,6 +224,8 @@ "native_name": "Português (Portugal)", "ui_font_pack_id": "latin-ext-eu", "content_font_pack_id": "latin-ext-eu", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" } @@ -107,9 +248,9 @@ }, "archive": { "layout": "trailmate-pack-v1", - "path": "assets/packs/europe-latin-ext-1.0.0.zip", - "size_bytes": 12326, - "sha256": "b5eae5d57ac3cf6cb367268abc87cec3cd66f53429b8dbc2a239b4f6ecfd9c81" + "path": "assets/packs/europe-latin-ext-1.1.0.zip", + "size_bytes": 63949, + "sha256": "a87371e15c33ccfbeb08c7fef4c4b94e6a91cffd16370c55890ea292decb4d81" }, "files": { "package_manifest": "package.ini", @@ -120,13 +261,13 @@ { "id": "ja", "package_type": "locale-bundle", - "version": "1.0.0", + "version": "1.1.0", "display_name": "Japanese", - "summary": "Japanese locale bundle with external CJK font coverage for UI text and mixed-language content.", + "summary": "Japanese review locale bundle with external CJK font coverage for UI text and mixed-language content; display-only until Japanese input is implemented.", "description": "Japanese installs the ja locale together with the ja-cjk external font pack so menu text,\nlabels, and received Japanese content can render without inflating the firmware image.\n\nThis bundle is currently display-only. It does not add a Japanese IME yet, but it keeps\nJapanese coverage available as an installable package on SD storage.", "author": "Trail Mate", "homepage": "https://vicliu624.github.io/trail-mate/", - "min_firmware_version": "0.1.18-alpha", + "min_firmware_version": "0.1.24-alpha", "supported_memory_profiles": [ "constrained", "standard", @@ -135,7 +276,8 @@ "tags": [ "language", "cjk", - "japanese" + "japanese", + "review" ], "provides": { "locales": [ @@ -145,6 +287,8 @@ "native_name": "日本語", "ui_font_pack_id": "ja-cjk", "content_font_pack_id": "ja-cjk", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" } @@ -167,9 +311,9 @@ }, "archive": { "layout": "trailmate-pack-v1", - "path": "assets/packs/ja-1.0.0.zip", - "size_bytes": 11246, - "sha256": "c559c456d613be3befdbac0f77050468fd2346e0ccda5492ebe40762a8f5b5c6" + "path": "assets/packs/ja-1.1.0.zip", + "size_bytes": 19497, + "sha256": "24d2eac7276425ddb3fb7258b46a48d0ae850d2f650d908d48a81395e25db6fb" }, "files": { "package_manifest": "package.ini", @@ -180,13 +324,13 @@ { "id": "ko", "package_type": "locale-bundle", - "version": "1.0.0", + "version": "1.1.0", "display_name": "Korean", - "summary": "Korean locale bundle with external CJK font coverage for UI text and mixed-language content.", + "summary": "Korean review locale bundle with external CJK font coverage for UI text and mixed-language content; display-only until Korean input is implemented.", "description": "Korean installs the ko locale together with the ko-cjk external font pack so interface\nlabels and Korean content can render correctly without carrying the glyph set in firmware.\n\nThis bundle is currently display-only. It does not add a Korean IME yet, but it keeps\nKorean support available as an installable SD package.", "author": "Trail Mate", "homepage": "https://vicliu624.github.io/trail-mate/", - "min_firmware_version": "0.1.18-alpha", + "min_firmware_version": "0.1.24-alpha", "supported_memory_profiles": [ "constrained", "standard", @@ -195,7 +339,8 @@ "tags": [ "language", "cjk", - "korean" + "korean", + "review" ], "provides": { "locales": [ @@ -205,6 +350,8 @@ "native_name": "한국어", "ui_font_pack_id": "ko-cjk", "content_font_pack_id": "ko-cjk", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" } @@ -227,9 +374,9 @@ }, "archive": { "layout": "trailmate-pack-v1", - "path": "assets/packs/ko-1.0.0.zip", - "size_bytes": 8470, - "sha256": "153d8bd1ffacb75a08f8be7483e50c4e559b949dea7a7b0f08cb592718f29dd3" + "path": "assets/packs/ko-1.1.0.zip", + "size_bytes": 16312, + "sha256": "e666160147fdd97c71e80ee2b07981b88e0a8d9ec4cfc5e133a29b0d8a3d1c74" }, "files": { "package_manifest": "package.ini", @@ -240,13 +387,13 @@ { "id": "zh-Hans", "package_type": "locale-bundle", - "version": "1.0.0", + "version": "1.1.0", "display_name": "Simplified Chinese", "summary": "Tiered Simplified Chinese locale bundle with a fast core font, an on-demand extension font, and the built-in Pinyin IME backend.", "description": "Simplified Chinese installs the zh-Hans locale, a tiered two-pack font stack, and the\nzh-hans-pinyin IME registration used by the built-in Pinyin input backend.\n\nThe bundle ships:\n- zh-hans-core: the common-glyph UI/content font loaded first for fast startup and lower steady-state RAM use\n- zh-hans-ext: the remaining Chinese glyph coverage that the runtime can load on demand as a content supplement\n- zh-hans-pinyin: the built-in Pinyin IME registration layer\n\nUse this bundle when you want a Chinese interface, Chinese text entry, or reliable\nrendering of Chinese names and chat content while another locale remains active, without\npaying the full monolithic CJK font cost up front.", "author": "Trail Mate", "homepage": "https://vicliu624.github.io/trail-mate/", - "min_firmware_version": "0.1.18-alpha", + "min_firmware_version": "0.1.24-alpha", "supported_memory_profiles": [ "standard", "extended" @@ -265,6 +412,8 @@ "native_name": "简体中文", "ui_font_pack_id": "zh-hans-core", "content_font_pack_id": "zh-hans-core", + "translation_status": "release", + "direction": "ltr", "preferred_content_supplement_pack_ids": [ "zh-hans-ext" ], @@ -301,9 +450,9 @@ }, "archive": { "layout": "trailmate-pack-v1", - "path": "assets/packs/zh-Hans-1.0.0.zip", - "size_bytes": 338342, - "sha256": "a598cd28e5b601d261b8a70c435d107e034ef8967b4fd85ae4978361f99fc85b" + "path": "assets/packs/zh-Hans-1.1.0.zip", + "size_bytes": 339707, + "sha256": "0a5f4f3db7d2dc563509e8da6ba77aa53dfe42fe1b0553a7ba4fad630bcfe194" }, "files": { "package_manifest": "package.ini", @@ -314,13 +463,13 @@ { "id": "zh-Hant", "package_type": "locale-bundle", - "version": "1.0.0", - "display_name": "Traditional Chinese", - "summary": "Traditional Chinese locale bundle with external CJK font coverage for UI and mixed-script content rendering.", + "version": "1.1.0", + "display_name": "Traditional Chinese (Taiwan)", + "summary": "Traditional Chinese (Taiwan) review locale bundle with CJK font coverage; display-only until native review and a real Zhuyin-focused IME are available.", "description": "Traditional Chinese installs the zh-Hant locale together with the zh-hant-cjk external\nfont pack so the UI and mixed-language content can render Traditional Chinese correctly.\n\nThis bundle is display-focused today. It does not add a dedicated Traditional Chinese IME\nyet, but it keeps rendering support fully external to the firmware image.", "author": "Trail Mate", "homepage": "https://vicliu624.github.io/trail-mate/", - "min_firmware_version": "0.1.18-alpha", + "min_firmware_version": "0.1.24-alpha", "supported_memory_profiles": [ "constrained", "standard", @@ -329,16 +478,20 @@ "tags": [ "language", "cjk", - "chinese" + "chinese", + "taiwan", + "review" ], "provides": { "locales": [ { "id": "zh-Hant", - "display_name": "Traditional Chinese", - "native_name": "繁體中文", + "display_name": "Traditional Chinese (Taiwan)", + "native_name": "繁體中文(台灣)", "ui_font_pack_id": "zh-hant-cjk", "content_font_pack_id": "zh-hant-cjk", + "translation_status": "review", + "direction": "ltr", "preferred_content_supplement_pack_ids": [], "ime_pack_id": "" } @@ -361,9 +514,9 @@ }, "archive": { "layout": "trailmate-pack-v1", - "path": "assets/packs/zh-Hant-1.0.0.zip", - "size_bytes": 11411, - "sha256": "334b4504a3e6e8c504f63fcabcd15994f8a2f1338dd11c775081b15a843d108e" + "path": "assets/packs/zh-Hant-1.1.0.zip", + "size_bytes": 19026, + "sha256": "55f5b4f6dc5e19d9097cfa90b9680b8288bc17d6a3b986e9053c2b009747a683" }, "files": { "package_manifest": "package.ini", diff --git a/site/index.html b/site/index.html index 0fcad00e..f1ebf6de 100644 --- a/site/index.html +++ b/site/index.html @@ -106,13 +106,13 @@

Localization

-

Multilingual support stays installable, so devices only carry the language resources they actually need.

+

0.1.24-alpha makes language packs versioned, reviewable, and installable without bloating firmware images.

English remains built in for safe defaults. Additional locale, font, and IME - resources are published as installable bundles, downloaded through the Extensions - app over Wi-Fi, and unpacked onto SD storage instead of being forced into every - firmware image. + resources are published as versioned bundles. Release-quality locales can appear in + Settings, while review packs remain downloadable for validation without surprising + everyday users.

@@ -128,10 +128,19 @@

Pack Catalog

-

0 bundles covering 0 installable locales.

+

0 bundles covering 0 cataloged locales.

- Locale, font, and IME resources are shipped as packages so firmware builds - can stay lean while the installed language surface grows over time. + Locale, font, and real IME resources are shipped as packages so firmware builds + stay lean while the language surface can mature through review and release states. +

+
+ +
+

Release Gate

+

Review packs are visible in the catalog but kept out of the runtime language picker.

+

+ The 0.1.24-alpha catalog carries translation status, archive hashes, and package + versions so updates are visible without pretending unfinished locales are ready.

diff --git a/site/main.js b/site/main.js index 191dd269..6890c849 100644 --- a/site/main.js +++ b/site/main.js @@ -83,9 +83,12 @@ function createPackCard(pack) { const runtime = pack.runtime ?? {}; const fontCount = runtime.font_count ?? (pack.provides?.fonts?.length ?? 0); const imeCount = runtime.ime_count ?? (pack.provides?.ime?.length ?? 0); + const statuses = Array.from( + new Set(locales.map((locale) => locale.translation_status || "release").filter(Boolean)), + ); const card = createElement("article", "language-pack-card"); - card.append(createElement("p", "feature-kicker", "Locale Bundle")); + card.append(createElement("p", "feature-kicker", statuses.includes("release") ? "Release Bundle" : "Review Bundle")); card.append(createElement("h3", "", pack.display_name || pack.id || "Unnamed package")); card.append(createElement("p", "language-pack-summary", pack.summary || "No summary available.")); @@ -93,12 +96,17 @@ function createPackCard(pack) { meta.append(createPill(`${locales.length} locale${locales.length === 1 ? "" : "s"}`)); meta.append(createPill(`${fontCount} font pack${fontCount === 1 ? "" : "s"}`)); meta.append(createPill(`${imeCount} IME${imeCount === 1 ? "" : "s"}`)); + statuses.forEach((status) => { + meta.append(createPill(status)); + }); card.append(meta); if (locales.length > 0) { const localeRow = createElement("div", "language-pack-locales"); locales.slice(0, 7).forEach((locale) => { - localeRow.append(createPill(locale.display_name || locale.id || "Locale")); + const status = locale.translation_status || "release"; + const label = locale.display_name || locale.id || "Locale"; + localeRow.append(createPill(status === "release" ? label : `${label} (${status})`)); }); if (locales.length > 7) { localeRow.append(createPill(`+${locales.length - 7} more`)); diff --git a/tools/fonts/NotoNaskhArabic-Regular.otf b/tools/fonts/NotoNaskhArabic-Regular.otf new file mode 100644 index 00000000..30267507 Binary files /dev/null and b/tools/fonts/NotoNaskhArabic-Regular.otf differ diff --git a/tools/fonts/README.md b/tools/fonts/README.md index 28ca47a9..b0270824 100644 --- a/tools/fonts/README.md +++ b/tools/fonts/README.md @@ -9,6 +9,13 @@ This directory stores source vector fonts used to generate Trail Mate font asset `https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf` - License: SIL Open Font License 1.1 (OFL) +## Noto Naskh Arabic + +- File: `NotoNaskhArabic-Regular.otf` +- Source: + `https://raw.githubusercontent.com/googlefonts/noto-fonts/main/unhinted/otf/NotoNaskhArabic/NotoNaskhArabic-Regular.otf` +- License: SIL Open Font License 1.1 (OFL) + ## Bitmap Compression Tool Use the in-repo tool to convert an existing LVGL bitmap font (`lv_font_conv` output) diff --git a/tools/validate_locale_packs.py b/tools/validate_locale_packs.py new file mode 100644 index 00000000..0536be64 --- /dev/null +++ b/tools/validate_locale_packs.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import argparse +import re +import sys +from collections import Counter +from pathlib import Path + + +PRINTF_RE = re.compile(r"%(?:[-+ #0]*\d*)?(?:\.\d+)?(?:hh|h|ll|l|z|t|j)?[diuoxXfFeEgGaAcspn%]") +ESCAPE_RE = re.compile(r"\\[ntr\\]") + +ALLOWED_ENGLISH_EQUIVALENTS = { + "%d%%", + "%s %s", + "%s -", + "Bluetooth", + "CAD", + "GNSS", + "GPS", + "HDOP", + "ID: !%06lx", + "ID: !%08lX", + "ID: !%08lx", + "ID: -", + "PKI: -", + "RSSI %.0f dBm", + "RSSI -92 dBm", + "RSSI: %.0f dBm", + "RSSI: -", + "RNode Bridge", + "RX", + "SF: %u", + "SNR +%d", + "SNR +12", + "SNR %.0f", + "SNR %.1f", + "SNR -", + "SSID", + "TX", + "English", +} + +REAL_IME_BACKENDS = { + "builtin-pinyin": {"zh-hans-pinyin"}, +} + + +def parse_key_value_file(path: Path) -> dict[str, str]: + data: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith(";") or "=" not in line: + continue + key, value = line.split("=", 1) + data[key.strip().lower()] = value.strip() + return data + + +def parse_tsv(path: Path) -> tuple[dict[str, str], list[str], list[str]]: + rows: dict[str, str] = {} + order: list[str] = [] + malformed: list[str] = [] + for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not raw_line.strip() or raw_line.startswith("#"): + continue + if "\t" not in raw_line: + malformed.append(f"{path}:{line_no}: missing tab") + continue + key, value = raw_line.split("\t", 1) + order.append(key) + rows[key] = value + return rows, order, malformed + + +def placeholders(text: str) -> Counter[str]: + return Counter(PRINTF_RE.findall(text) + ESCAPE_RE.findall(text)) + + +def discover_locale_files(pack_root: Path) -> list[Path]: + return sorted(pack_root.glob("*/locales/*/strings.tsv")) + + +def canonical_key_order(pack_root: Path) -> list[str]: + preferred = pack_root / "zh-Hans" / "locales" / "zh-Hans" / "strings.tsv" + if preferred.is_file(): + _rows, order, _malformed = parse_tsv(preferred) + return order + + order: list[str] = [] + seen: set[str] = set() + for strings_path in discover_locale_files(pack_root): + _rows, local_order, _malformed = parse_tsv(strings_path) + for key in local_order: + if key not in seen: + seen.add(key) + order.append(key) + return order + + +def validate_strings(pack_root: Path) -> list[str]: + errors: list[str] = [] + canonical = canonical_key_order(pack_root) + canonical_set = set(canonical) + if not canonical: + return [f"{pack_root}: no locale strings found"] + + for strings_path in discover_locale_files(pack_root): + rows, order, malformed = parse_tsv(strings_path) + errors.extend(malformed) + + duplicates = [key for key, count in Counter(order).items() if count > 1] + missing = [key for key in canonical if key not in rows] + extra = [key for key in order if key not in canonical_set] + empty = [key for key, value in rows.items() if not value.strip()] + placeholder_bad = [ + key for key, value in rows.items() if placeholders(key) != placeholders(value) + ] + + manifest = parse_key_value_file(strings_path.parent / "manifest.ini") + status = manifest.get("translation_status", "release").lower() + english_fallback = [ + key + for key, value in rows.items() + if value.strip() == key.strip() + and key not in ALLOWED_ENGLISH_EQUIVALENTS + and re.search(r"[A-Za-z]", key) + ] + + for key in duplicates: + errors.append(f"{strings_path}: duplicate key: {key}") + for key in missing: + errors.append(f"{strings_path}: missing key: {key}") + for key in extra: + errors.append(f"{strings_path}: key not in canonical set: {key}") + for key in empty: + errors.append(f"{strings_path}: empty translation: {key}") + for key in placeholder_bad: + errors.append(f"{strings_path}: placeholder mismatch: {key}") + if status == "release": + for key in english_fallback: + errors.append(f"{strings_path}: release locale keeps English fallback: {key}") + + return errors + + +def validate_manifests(pack_root: Path) -> list[str]: + errors: list[str] = [] + + font_ids: set[str] = set() + for manifest_path in sorted(pack_root.glob("*/fonts/*/manifest.ini")): + manifest = parse_key_value_file(manifest_path) + font_ids.add(manifest.get("id", manifest_path.parent.name)) + + ime_backends: dict[str, str] = {} + for manifest_path in sorted(pack_root.glob("*/ime/*/manifest.ini")): + manifest = parse_key_value_file(manifest_path) + ime_id = manifest.get("id", manifest_path.parent.name) + backend = manifest.get("backend", "none") + if ime_id not in REAL_IME_BACKENDS.get(backend, set()): + errors.append(f"{manifest_path}: unsupported runtime IME backend/id: {backend}/{ime_id}") + ime_backends[ime_id] = backend + + for manifest_path in sorted(pack_root.glob("*/locales/*/manifest.ini")): + manifest = parse_key_value_file(manifest_path) + locale_id = manifest.get("id", manifest_path.parent.name) + status = manifest.get("translation_status", "") + if not status: + errors.append(f"{manifest_path}: missing translation_status") + elif status.lower() not in {"release", "review", "draft"}: + errors.append(f"{manifest_path}: invalid translation_status={status}") + + for field in ("ui_font_pack", "content_font_pack"): + font_id = manifest.get(field, "") + if font_id and font_id not in font_ids: + errors.append(f"{manifest_path}: missing {field} dependency: {font_id}") + + ime_id = manifest.get("ime_pack", "") + if ime_id: + backend = ime_backends.get(ime_id) + if backend is None: + errors.append(f"{manifest_path}: missing ime_pack dependency: {ime_id}") + elif ime_id not in REAL_IME_BACKENDS.get(backend, set()): + errors.append(f"{manifest_path}: locale {locale_id} depends on unsupported IME: {ime_id}") + + if manifest.get("direction", "ltr") == "rtl" and locale_id != "ar": + errors.append(f"{manifest_path}: unexpected rtl direction for locale {locale_id}") + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate Trail Mate locale pack structure.") + parser.add_argument("--pack-root", default="packs") + args = parser.parse_args() + + pack_root = Path(args.pack_root) + errors = validate_strings(pack_root) + validate_manifests(pack_root) + if errors: + for error in errors: + print(error, file=sys.stderr) + return 1 + + print("locale pack validation passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vscode/run_idf_task.ps1 b/tools/vscode/run_idf_task.ps1 index 2c429dec..042cfe7a 100644 --- a/tools/vscode/run_idf_task.ps1 +++ b/tools/vscode/run_idf_task.ps1 @@ -143,6 +143,58 @@ function Resolve-Port($Settings, [string]$RequestedPort) { return 'COM6' } +function Get-CacheEntryValue([string]$CachePath, [string]$Key) { + if (-not (Test-Path $CachePath)) { + return $null + } + + $prefix = "${Key}:" + foreach ($line in Get-Content $CachePath) { + if ($line.StartsWith($prefix)) { + $parts = $line.Split('=', 2) + if ($parts.Length -eq 2) { + return $parts[1] + } + } + } + return $null +} + +function Reset-StaleIdfBuildDir([string]$RepoRoot, [string]$BuildDir, [string]$IdfPath) { + $buildDirPath = if ([System.IO.Path]::IsPathRooted($BuildDir)) { + $BuildDir + } else { + Join-Path $RepoRoot $BuildDir + } + + if (-not (Test-Path $buildDirPath)) { + return + } + + $cacheFiles = @( + Join-Path $buildDirPath 'CMakeCache.txt' + Join-Path $buildDirPath 'bootloader\CMakeCache.txt' + ) + + $normalizedCurrent = $IdfPath.TrimEnd('\', '/') + foreach ($cachePath in $cacheFiles) { + $cachedIdfPath = Get-CacheEntryValue $cachePath 'IDF_PATH' + if (-not $cachedIdfPath) { + continue + } + + $normalizedCached = $cachedIdfPath.TrimEnd('\', '/') + if ($normalizedCached -ne $normalizedCurrent) { + Write-Warning "Stale ESP-IDF cache detected in $buildDirPath" + Write-Warning "Cached IDF_PATH: $normalizedCached" + Write-Warning "Current IDF_PATH: $normalizedCurrent" + Write-Host "[trail-mate] Removing stale build directory $buildDirPath" + Remove-Item -LiteralPath $buildDirPath -Recurse -Force + return + } + } +} + $repoRoot = Get-RepoRoot $settings = Get-WorkspaceSettings $repoRoot $idfPath = Resolve-IdfPath $repoRoot $settings @@ -154,6 +206,8 @@ if (-not $BuildDir) { $BuildDir = "build.$Target" } +Reset-StaleIdfBuildDir $repoRoot $BuildDir $idfPath + $env:IDF_PATH = $idfPath $env:IDF_PYTHON_ENV_PATH = Split-Path -Parent (Split-Path -Parent $pythonExe) if ($riscvToolchainBin) { diff --git a/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini b/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini index 1e6abee5..ee1324a3 100644 --- a/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini +++ b/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini @@ -21,6 +21,8 @@ build_flags = -DHAS_GPS=0 -DHAS_SD=0 -DHAS_PSRAM=1 + -DTRAIL_MATE_WATCH_MORSE_PDM_SCK=44 + -DTRAIL_MATE_WATCH_MORSE_PDM_DATA=47 -DUSING_INPUT_DEV_TOUCHPAD -I variants/lilygo_twatch_s3 lib_deps = @@ -49,6 +51,8 @@ build_flags = -DHAS_GPS=0 -DHAS_SD=0 -DHAS_PSRAM=1 + -DTRAIL_MATE_WATCH_MORSE_PDM_SCK=44 + -DTRAIL_MATE_WATCH_MORSE_PDM_DATA=47 -DUSING_INPUT_DEV_TOUCHPAD -D MESHCORE_LOG_ENABLE=1 -D LORA_LOG_ENABLE=1 diff --git a/variants/tdeck/envs/tdeck.ini b/variants/tdeck/envs/tdeck.ini index 8ae7b609..460fea6b 100644 --- a/variants/tdeck/envs/tdeck.ini +++ b/variants/tdeck/envs/tdeck.ini @@ -14,6 +14,7 @@ build_flags = -DARDUINO_T_DECK -DARDUINO_LILYGO_LORA_SX1262 -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 + -DDEVICE_MIN_BRIGHTNESS_LEVEL=4 -DARDUINO_USB_CDC_ON_BOOT=1 -DDISPLAY_DRIVER_ST7789 -DSCREEN_WIDTH=320 @@ -41,6 +42,7 @@ build_flags = -DARDUINO_T_DECK -DARDUINO_LILYGO_LORA_SX1262 -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 + -DDEVICE_MIN_BRIGHTNESS_LEVEL=4 -DARDUINO_USB_CDC_ON_BOOT=1 -DDISPLAY_DRIVER_ST7789 -DSCREEN_WIDTH=320 diff --git a/variants/tdeck/variant.cpp b/variants/tdeck/variant.cpp new file mode 100644 index 00000000..3f8b08c6 --- /dev/null +++ b/variants/tdeck/variant.cpp @@ -0,0 +1,38 @@ +#include "esp32-hal-gpio.h" +#include "pins_arduino.h" + +extern "C" +{ + + // Run before setup() so the previous frame is hidden as early as possible. + void initVariant(void) + { +#ifdef DISP_BL + if (DISP_BL >= 0) + { + pinMode(DISP_BL, OUTPUT); + digitalWrite(DISP_BL, LOW); + } +#endif + +#ifdef BOARD_POWERON + pinMode(BOARD_POWERON, OUTPUT); + digitalWrite(BOARD_POWERON, HIGH); +#endif + +#ifdef SD_CS + pinMode(SD_CS, OUTPUT); + digitalWrite(SD_CS, HIGH); +#endif + +#ifdef LORA_CS + pinMode(LORA_CS, OUTPUT); + digitalWrite(LORA_CS, HIGH); +#endif + +#ifdef DISP_CS + pinMode(DISP_CS, OUTPUT); + digitalWrite(DISP_CS, HIGH); +#endif + } +}