diff --git a/.gitattributes b/.gitattributes index e3223f2e..06e471b2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -40,6 +40,8 @@ platformio.ini text eol=lf # Scripts *.sh text eol=lf apps/linux_cardputer_zero/packaging/trailmate-cardputer-zero-applaunch text eol=lf +apps/linux_cardputer_zero/packaging/postinst text eol=lf +apps/linux_cardputer_zero/packaging/*.rules text eol=lf boards/t_display_cm0/packaging/postinst text eol=lf boards/t_display_cm0/packaging/prerm text eol=lf boards/t_display_cm0/packaging/postrm text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e12b7ac1..ad7f68b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -369,7 +369,7 @@ jobs: format: name: Format Check - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v4 @@ -973,6 +973,12 @@ jobs: name: trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }} path: dist/* + linux_packages: + name: Linux device release packages + needs: firmware_scope + if: needs.firmware_scope.outputs.build == 'true' + uses: ./.github/workflows/linux-device-packages.yml + release: name: Publish GitHub Release runs-on: ubuntu-latest @@ -989,6 +995,7 @@ jobs: - build_c6 - pages_contract - build_idf + - linux_packages if: startsWith(github.ref, 'refs/tags/v') permissions: actions: write @@ -1005,6 +1012,16 @@ jobs: - name: Verify collected firmware files run: | find dist -type f \( -name '*.bin' -o -name '*.hex' -o -name '*.uf2' -o -name '*.zip' \) -print | sort + version=$(python3 scripts/project_version.py --debian) + python3 scripts/verify_linux_release_packages.py --directory dist --version "$version" --device cardputer-zero + python3 scripts/verify_linux_release_packages.py --directory dist --version "$version" --device t-display-cm0 + python3 - <<'PY' + import hashlib + from pathlib import Path + files = sorted(p for p in Path('dist').rglob('*') if p.suffix in ('.bin', '.hex', '.uf2', '.zip', '.deb')) + assert len({p.name for p in files}) == len(files), 'Duplicate release asset names' + Path('dist/SHA256SUMS').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest() + ' ' + p.name + '\n' for p in files)) + PY - name: Extract changelog entry run: | @@ -1038,6 +1055,8 @@ jobs: dist/**/*.hex dist/**/*.uf2 dist/**/*.zip + dist/**/*.deb + dist/SHA256SUMS - name: Refresh Pages metadata env: diff --git a/.github/workflows/linux-device-packages.yml b/.github/workflows/linux-device-packages.yml new file mode 100644 index 00000000..e60de8fc --- /dev/null +++ b/.github/workflows/linux-device-packages.yml @@ -0,0 +1,90 @@ +name: Linux Device Packages + +on: + workflow_call: + workflow_dispatch: + +jobs: + producers: + name: Pinned self-contained TMAP producers + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: vicliu624/trail-mate-center + ref: efa12462a526aabad7212cbadfa537ace0b7dbd6 + path: .codex-build/tmap-center-source + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Publish licensed producer bundles + shell: pwsh + run: | + foreach ($runtime in @('linux-arm', 'linux-arm64')) { + ./tools/publish_tmap_producer.ps1 -CenterRoot .codex-build/tmap-center-source -OutputDirectory "build/tmap-producer/$runtime" -Runtime $runtime + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - name: Archive producer bundles + run: | + mkdir -p build/producer-archives + for runtime in linux-arm linux-arm64; do + tar -C "build/tmap-producer/$runtime" -czf "build/producer-archives/tmap-$runtime.tar.gz" . + done + - uses: actions/upload-artifact@v4 + with: + name: linux-tmap-producers + path: build/producer-archives/*.tar.gz + if-no-files-found: error + + packages: + name: ${{ matrix.device }} Debian package + needs: producers + runs-on: ubuntu-24.04-arm + strategy: + fail-fast: false + matrix: + device: [cardputer-zero, t-display-cm0] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: linux-tmap-producers + path: build/producer-archives + - name: Prepare target producer + env: + DEVICE: ${{ matrix.device }} + run: | + runtime=linux-arm64 + if [ "$DEVICE" = t-display-cm0 ]; then runtime=linux-arm; fi + mkdir -p "$RUNNER_TEMP/tmap-producer" + tar -C "$RUNNER_TEMP/tmap-producer" -xzf "build/producer-archives/tmap-$runtime.tar.gz" + echo "TRAIL_MATE_TMAP_PRODUCER_DIR=$RUNNER_TEMP/tmap-producer" >> "$GITHUB_ENV" + - name: Build Cardputer Zero ARM64 package + if: matrix.device == 'cardputer-zero' + env: + TRAIL_MATE_CARDPUTER_ZERO_PACKAGE_JOBS: '4' + run: bash apps/linux_cardputer_zero/tools/build_cardputer_zero_deb.sh + - name: Enable ARMHF native chroot execution + if: matrix.device == 't-display-cm0' + uses: docker/setup-qemu-action@v3 + with: + platforms: arm + - name: Install Raspbian bootstrap tools + if: matrix.device == 't-display-cm0' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends debootstrap gnupg ca-certificates + - name: Build CM0 Raspbian ARMHF app and 1seg backend + if: matrix.device == 't-display-cm0' + run: | + sudo env TRAIL_MATE_CM0_RELEASE_WORK="$RUNNER_TEMP/cm0-release" TRAIL_MATE_TMAP_PRODUCER_DIR="$TRAIL_MATE_TMAP_PRODUCER_DIR" TRAIL_MATE_PACKAGE_JOBS=4 bash apps/linux_lvgl/tools/build_cm0_native_release.sh + - name: Verify release package payloads + run: python3 scripts/verify_linux_release_packages.py --directory build --version "$(python3 scripts/project_version.py --debian)" --device '${{ matrix.device }}' + - uses: actions/upload-artifact@v4 + with: + name: linux-device-${{ matrix.device }} + path: | + build/cardputer-zero-deb/*.deb + build/cm0-deb/*.deb + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 2780ca80..c1d45b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,69 @@ stable firmware release. Hardware support and validation remain target-specific. ## [Unreleased] +## [1.1.0] - 2026-11-08 + +This minor release adds installable Cardputer Zero and T-Display-CM0 Linux +packages and expands their device workflows without changing existing storage +formats or requiring new radio identities. Hardware qualification remains +target-specific; see the linked acceptance records below. + +### Added + +- Cardputer Zero ARM64 Debian package with APPLaunch integration, framebuffer + rendering, physical keyboard navigation, hardware permissions, Cap LoRa/GNSS + detection, heading integration and the shared launcher/widget screens. +- T-Display-CM0 Raspbian ARMHF application and matching native 1seg backend + packages, preserving the target's GCC 14 C++ ABI and ARMv7 receive contract. + CM0 also gains the LR2021 FM/1seg views, OFDM learning workbench and receiver + diagnostics developed since 1.0.1. +- Online TMAP creation with global administrative-area search, optional manual + bounds, source/layer choices, review, progress, cancellation and a direct + transition to the generated map. Linux release packages include the pinned + self-contained Center producer and its source/license/hash manifest. +- Linux LoRa Reticulum discovery through signed local Announce and path replies, + and sleep-only private-message automatic replies through a shared observer. +- Release-time builds and validation of both Linux device packages, their + architectures, bundled producer files and accompanying 1seg backend. + +### Fixed + +- SX1262 transmit paths restore receive interrupts and support complete + 255-byte LoRa frames. LR2021 rearms continuous receive through standby. + ESP-IDF receive paths preserve progress interrupts, read valid payloads before + clearing their IRQs and retain a valid frame if the next receive restart fails. +- Meshtastic official-firmware interoperability: addressed ACK/NACK tracking, + public-key refresh after matched key errors, duplicate-error suppression and + consistent response-request bits in shared protobuf encoding. +- MeshCore discovery/key propagation and Reticulum full-destination contact + actions. Paired Zero/CM0 discovery, bidirectional private text and remote + delivery proofs were physically verified for all three protocols. +- Incoming messages refresh the active conversation/inbox, including broadcast + previews and unread state. Zero uses a compact single-line topbar and chat + actions, with Tab then Enter to send from the keyboard composer. +- Framebuffer rendering avoids repeated full-screen writes and flicker; keyboard + exit, native backlight sleep, wake-input handling and shared widget focus are + integrated with the existing screen lifecycle. +- Linux identity-file permission checks preserve SQLite POSIX locks instead of + closing an additional ordinary database descriptor. Lock, concurrent-write + and repeated-startup regressions verify message/configuration/identity + preservation. This removes a reproduced unsafe locking path; it does not + promise recovery of previously corrupted records. +- Online map download/conversion progress, proxy use, cancellation and temporary + workspace cleanup, plus the shared map-overlay and preview integration fixes + made since 1.0.1. +- Catalog image declarations use global C linkage, fixing newer host compiler + linkage failures in Cardputer and Agenda CI builds. Formatting retains + clang-format 14 on an explicitly supported runner image. + +### Validation + +- [Three-protocol and official Meshtastic acceptance](docs/engineering/linux-paired-mesh-validation-20261108.md). +- [Cardputer Zero device acceptance](docs/engineering/cardputer-zero-device-validation-20261108.md). +- [Online TMAP creation and packaging](docs/engineering/linux-tmap-creation.md). +- Shared ESP receive regressions and stack hygiene checks passed. Linux device + radio tests do not substitute for physical ESP32 qualification. + ## [1.0.1] - 2026-10-17 This patch enables the browser-management USB path in Pager and T-Deck release diff --git a/apps/linux_cardputer_zero/APP_SHELL_MANIFEST.md b/apps/linux_cardputer_zero/APP_SHELL_MANIFEST.md index e758a277..d2d76d2a 100644 --- a/apps/linux_cardputer_zero/APP_SHELL_MANIFEST.md +++ b/apps/linux_cardputer_zero/APP_SHELL_MANIFEST.md @@ -15,8 +15,10 @@ inside the Cardputer Zero user session. The framebuffer/evdev executable exists only as an explicit device-debug fallback, not as the APPLaunch product path. The shared LVGL main-menu profile is not simulator-owned. Cardputer Zero selects -`make_cardputer_zero_profile()`, an independent focus carousel constrained for -the 320 x 170 keyboard device. Its page descriptors come from the production +`make_cardputer_zero_profile()`, the Pager-style adaptive grid constrained to +four columns and two visible rows for the 320 x 170 keyboard device. The shared +widget feed is available through `V`, with keyboard paging and controls. Its +page descriptors come from the production catalog; the compact page set also includes Protocol Probe, Calculator, Calendar and Geocaching. Calendar uses the native worker and shared page/reminder/Map flow, including durable waypoint saving and selection through its native worker. diff --git a/apps/linux_cardputer_zero/CMakeLists.txt b/apps/linux_cardputer_zero/CMakeLists.txt index 3dd92323..6fd2558e 100644 --- a/apps/linux_cardputer_zero/CMakeLists.txt +++ b/apps/linux_cardputer_zero/CMakeLists.txt @@ -103,6 +103,11 @@ endif() if(TRAIL_MATE_CARDPUTER_ZERO_BUILD_DEVICE AND CMAKE_SYSTEM_NAME STREQUAL "Linux") include(GNUInstallDirs) + configure_file(packaging/70-trailmate-cardputer-zero.rules + "${CMAKE_CURRENT_BINARY_DIR}/70-trailmate-cardputer-zero.rules" @ONLY NEWLINE_STYLE UNIX) + install(FILES "${CMAKE_CURRENT_BINARY_DIR}/70-trailmate-cardputer-zero.rules" + DESTINATION "lib/udev/rules.d" + PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ) set(TRAIL_MATE_CARDPUTER_ZERO_PRIVATE_LIBDIR "lib/trailmate-cardputer-zero") set(CMAKE_INSTALL_DEFAULT_DIRECTORY_PERMISSIONS @@ -285,6 +290,9 @@ if(TRAIL_MATE_CARDPUTER_ZERO_BUILD_DEVICE AND CMAKE_SYSTEM_NAME STREQUAL "Linux" if(TRAIL_MATE_CARDPUTER_ZERO_ENABLE_DEB_PACKAGE) set(CPACK_GENERATOR "DEB") + configure_file(packaging/postinst "${CMAKE_CURRENT_BINARY_DIR}/postinst" @ONLY NEWLINE_STYLE UNIX) + set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA "${CMAKE_CURRENT_BINARY_DIR}/postinst") + set(CPACK_DEBIAN_PACKAGE_CONTROL_STRICT_PERMISSION TRUE) set(CPACK_PACKAGE_NAME "trailmate-cardputer-zero") set(CPACK_PACKAGE_VENDOR "Trail Mate") set(CPACK_PACKAGE_CONTACT "Trail Mate Maintainers") @@ -298,10 +306,10 @@ if(TRAIL_MATE_CARDPUTER_ZERO_BUILD_DEVICE AND CMAKE_SYSTEM_NAME STREQUAL "Linux" set(CPACK_DEBIAN_PACKAGE_SECTION "utils") set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional") set(CPACK_DEBIAN_PACKAGE_DEPENDS - "libc6, libstdc++6, libgcc-s1, libsqlite3-0, libcurl4, libssl3, libarchive13, libjpeg62-turbo, libpng16-16, libsdl2-2.0-0, libwayland-client0, libxkbcommon0, libnotify-bin, gdal-bin, unzip, ca-certificates, network-manager") + "libc6, libstdc++6, libgcc-s1, libsqlite3-0, libcurl4, libssl3, libarchive13, libjpeg62-turbo, libpng16-16, libsdl2-2.0-0, libwayland-client0, libxkbcommon0, libnotify-bin, gdal-bin, unzip, ca-certificates, network-manager, osmium-tool") set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS OFF) if(TRAIL_MATE_TMAP_PRODUCER_DIR) - string(APPEND CPACK_DEBIAN_PACKAGE_DEPENDS ", libicu72") + string(APPEND CPACK_DEBIAN_PACKAGE_DEPENDS ", libicu72 | libicu74 | libicu76") endif() set(CPACK_DEBIAN_PACKAGE_DESCRIPTION "Trail Mate Cardputer Zero Wayland shell for the portable Linux device.") @@ -321,12 +329,84 @@ if(TRAIL_MATE_CARDPUTER_ZERO_BUILD_SCREENSHOT_CAPTURE) endif() if(BUILD_TESTING) + add_executable(trailmate_cardputer_identity_storage_stress_test "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/linux_identity_storage_stress_test.cpp") + target_link_libraries(trailmate_cardputer_identity_storage_stress_test PRIVATE trailmate_cardputer_zero_linux_common) + target_compile_options(trailmate_cardputer_identity_storage_stress_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_cardputer_identity_storage_stress COMMAND trailmate_cardputer_identity_storage_stress_test) + foreach(identity_case IN ITEMS reticulum_identity identity_replacement) + add_executable(trailmate_cardputer_${identity_case}_test "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/linux_${identity_case}_test.cpp") + target_link_libraries(trailmate_cardputer_${identity_case}_test PRIVATE trailmate_cardputer_zero_linux_common) + target_compile_options(trailmate_cardputer_${identity_case}_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_cardputer_${identity_case} COMMAND trailmate_cardputer_${identity_case}_test) + endforeach() + add_executable(trailmate_linux_identity_database_lock_test "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/linux_identity_database_lock_test.cpp") + target_link_libraries(trailmate_linux_identity_database_lock_test PRIVATE trailmate_cardputer_zero_linux_common) + target_compile_options(trailmate_linux_identity_database_lock_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_linux_identity_database_lock COMMAND trailmate_linux_identity_database_lock_test) + add_executable(trailmate_linux_config_chat_roundtrip_test tests/linux_config_chat_roundtrip_test.cpp) + target_link_libraries(trailmate_linux_config_chat_roundtrip_test PRIVATE trailmate_cardputer_zero_ui_shell) + target_compile_options(trailmate_linux_config_chat_roundtrip_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_linux_config_chat_roundtrip COMMAND trailmate_linux_config_chat_roundtrip_test) + add_executable(trailmate_linux_backlight_screen_test tests/linux_backlight_screen_test.cpp) + target_link_libraries(trailmate_linux_backlight_screen_test PRIVATE trailmate_cardputer_zero_linux_common) + target_compile_options(trailmate_linux_backlight_screen_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_linux_backlight_screen COMMAND trailmate_linux_backlight_screen_test) + add_executable(trailmate_linux_reticulum_radio_announce_test "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/linux_reticulum_radio_announce_test.cpp") + target_link_libraries(trailmate_linux_reticulum_radio_announce_test PRIVATE trailmate_cardputer_zero_linux_common) + target_compile_options(trailmate_linux_reticulum_radio_announce_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_linux_reticulum_radio_announce COMMAND trailmate_linux_reticulum_radio_announce_test) + add_executable(trailmate_cardputer_zero_header_inbox_test tests/linux_cardputer_zero_header_inbox_test.cpp) + target_link_libraries(trailmate_cardputer_zero_header_inbox_test PRIVATE trailmate_cardputer_zero_ui_shell) + target_compile_options(trailmate_cardputer_zero_header_inbox_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_cardputer_zero_header_inbox COMMAND trailmate_cardputer_zero_header_inbox_test) + set_tests_properties(trailmate_cardputer_zero_header_inbox PROPERTIES TIMEOUT 30) + add_executable(trailmate_cardputer_zero_tmap_keyboard_test tests/linux_tmap_keyboard_test.cpp) + target_link_libraries(trailmate_cardputer_zero_tmap_keyboard_test PRIVATE trailmate_cardputer_zero_ui_shell) + target_compile_options(trailmate_cardputer_zero_tmap_keyboard_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_cardputer_zero_tmap_keyboard COMMAND trailmate_cardputer_zero_tmap_keyboard_test) + set_tests_properties(trailmate_cardputer_zero_tmap_keyboard PROPERTIES TIMEOUT 60) + add_executable(trailmate_cardputer_zero_tmap_creation_test "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/linux_tmap_creation_test.cpp") + target_link_libraries(trailmate_cardputer_zero_tmap_creation_test PRIVATE trailmate_cardputer_zero_linux_common) + target_compile_options(trailmate_cardputer_zero_tmap_creation_test PRIVATE -UNDEBUG) + find_package(Python3 COMPONENTS Interpreter REQUIRED) + set(sx126x_tx_source "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/linux/sx126x_radio.cpp") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${sx126x_tx_source}" tests/extract_linux_sx126x_tx.py) + execute_process(COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/extract_linux_sx126x_tx.py" + "${sx126x_tx_source}" "${CMAKE_CURRENT_BINARY_DIR}/linux_sx126x_tx.inc" COMMAND_ERROR_IS_FATAL ANY) + add_executable(trailmate_linux_sx126x_tx_receive_test tests/linux_sx126x_tx_receive_test.cpp) + target_include_directories(trailmate_linux_sx126x_tx_receive_test PRIVATE + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include" "${CMAKE_CURRENT_BINARY_DIR}") + target_compile_features(trailmate_linux_sx126x_tx_receive_test PRIVATE cxx_std_20) + target_compile_options(trailmate_linux_sx126x_tx_receive_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_linux_sx126x_tx_receive COMMAND trailmate_linux_sx126x_tx_receive_test) + set_tests_properties(trailmate_linux_sx126x_tx_receive PROPERTIES TIMEOUT 20) + set(tmap_creation_pbf "${CMAKE_CURRENT_BINARY_DIR}/tmap-creation-test.osm.pbf") + execute_process(COMMAND "${Python3_EXECUTABLE}" "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/make_tmap_creation_pbf.py" "${tmap_creation_pbf}" COMMAND_ERROR_IS_FATAL ANY) + set(tmap_contract "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/tmap_creation_contract.py") + file(CHMOD "${tmap_contract}" PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE) + foreach(scenario success failure cancel existing) + add_test(NAME trailmate_cardputer_zero_tmap_creation_${scenario} COMMAND trailmate_cardputer_zero_tmap_creation_test "${tmap_contract}" "${tmap_creation_pbf}" ${scenario}) + endforeach() + if(TRAIL_MATE_TMAP_TEST_PRODUCER AND NOT CMAKE_CROSSCOMPILING) + add_executable(trailmate_cardputer_zero_tmap_online_test tests/linux_tmap_online_test.cpp) + target_link_libraries(trailmate_cardputer_zero_tmap_online_test PRIVATE trailmate_cardputer_zero_linux_common) + target_compile_options(trailmate_cardputer_zero_tmap_online_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_cardputer_zero_tmap_online COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tmap_online_fixture.py" "$" "${TRAIL_MATE_TMAP_TEST_PRODUCER}") + set_tests_properties(trailmate_cardputer_zero_tmap_online PROPERTIES TIMEOUT 180) + add_test(NAME trailmate_cardputer_zero_tmap_creation_real COMMAND trailmate_cardputer_zero_tmap_creation_test "${TRAIL_MATE_TMAP_TEST_PRODUCER}" "${tmap_creation_pbf}" real) + endif() + add_executable(trailmate_cardputer_zero_meshtastic_ack_test tests/linux_meshtastic_ack_test.cpp) if(TARGET OpenSSL::Crypto) add_executable(trailmate_cardputer_zero_node_discovery_test "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/linux_node_discovery_test.cpp") target_link_libraries(trailmate_cardputer_zero_node_discovery_test PRIVATE trailmate_cardputer_zero_linux_common) target_compile_options(trailmate_cardputer_zero_node_discovery_test PRIVATE -UNDEBUG) add_test(NAME trailmate_cardputer_zero_node_discovery COMMAND trailmate_cardputer_zero_node_discovery_test) endif() + target_link_libraries(trailmate_cardputer_zero_meshtastic_ack_test PRIVATE trailmate_cardputer_zero_linux_common) + target_compile_options(trailmate_cardputer_zero_meshtastic_ack_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_cardputer_zero_meshtastic_ack COMMAND trailmate_cardputer_zero_meshtastic_ack_test) + set_tests_properties(trailmate_cardputer_zero_meshtastic_ack PROPERTIES TIMEOUT 30) add_executable(trailmate_cardputer_zero_wifi_profiles_test "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/linux_wifi_profiles_test.cpp") target_link_libraries(trailmate_cardputer_zero_wifi_profiles_test PRIVATE trailmate_cardputer_zero_linux_common) @@ -425,6 +505,61 @@ if(BUILD_TESTING) PRIVATE cxx_std_17) add_test(NAME trailmate_linux_cardputer_zero_menu_profile_smoke COMMAND trailmate_linux_cardputer_zero_menu_profile_smoke) + add_executable(trailmate_linux_cardputer_cap_test + tests/linux_cardputer_cap_test.cpp + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/linux/cardputer_cap.cpp") + target_include_directories(trailmate_linux_cardputer_cap_test PRIVATE + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include") + target_compile_features(trailmate_linux_cardputer_cap_test PRIVATE cxx_std_17) + target_compile_options(trailmate_linux_cardputer_cap_test PRIVATE -UNDEBUG) + target_link_libraries(trailmate_linux_cardputer_cap_test PRIVATE Threads::Threads) + add_test(NAME trailmate_linux_cardputer_cap COMMAND trailmate_linux_cardputer_cap_test) + add_executable(trailmate_linux_cardputer_zero_widgets_test tests/linux_cardputer_zero_widgets_test.cpp) + target_link_libraries(trailmate_linux_cardputer_zero_widgets_test PRIVATE trailmate_cardputer_zero_ui_shell) + target_compile_definitions(trailmate_linux_cardputer_zero_widgets_test PRIVATE TRAIL_MATE_CARDPUTER_ZERO_LINUX=1) + target_compile_options(trailmate_linux_cardputer_zero_widgets_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_linux_cardputer_zero_widgets COMMAND trailmate_linux_cardputer_zero_widgets_test) + add_executable(trailmate_linux_cardputer_zero_chat_keyboard_test tests/linux_cardputer_zero_chat_keyboard_test.cpp) + target_link_libraries(trailmate_linux_cardputer_zero_chat_keyboard_test PRIVATE trailmate_cardputer_zero_ui_shell) + target_compile_definitions(trailmate_linux_cardputer_zero_chat_keyboard_test PRIVATE TRAIL_MATE_CARDPUTER_ZERO_LINUX=1) + target_compile_options(trailmate_linux_cardputer_zero_chat_keyboard_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_linux_cardputer_zero_chat_keyboard COMMAND trailmate_linux_cardputer_zero_chat_keyboard_test) + add_executable(trailmate_linux_evdev_exit_test tests/linux_evdev_exit_test.cpp + "${TRAIL_MATE_REPO_ROOT}/platform/linux/rpi/src/platform/device/evdev_input.cpp") + target_include_directories(trailmate_linux_evdev_exit_test PRIVATE + "${TRAIL_MATE_REPO_ROOT}/platform/linux/rpi/src" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src") + target_compile_features(trailmate_linux_evdev_exit_test PRIVATE cxx_std_20) + target_compile_options(trailmate_linux_evdev_exit_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_linux_evdev_exit COMMAND trailmate_linux_evdev_exit_test) + foreach(sensor_test IN ITEMS heading iio) + if(sensor_test STREQUAL "heading") + set(sensor_test_source tests/linux_cardputer_heading_test.cpp) + else() + set(sensor_test_source "${TRAIL_MATE_REPO_ROOT}/apps/linux_lvgl/tests/linux_iio_sensors_test.cpp") + endif() + add_executable(trailmate_linux_cardputer_${sensor_test}_test "${sensor_test_source}" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/linux/iio_sensors.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/ui/orientation_runtime.cpp") + target_include_directories(trailmate_linux_cardputer_${sensor_test}_test PRIVATE + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_linux_cardputer_${sensor_test}_test PRIVATE cxx_std_17) + target_compile_options(trailmate_linux_cardputer_${sensor_test}_test PRIVATE -UNDEBUG) + target_link_libraries(trailmate_linux_cardputer_${sensor_test}_test PRIVATE Threads::Threads) + add_test(NAME trailmate_linux_cardputer_${sensor_test} COMMAND trailmate_linux_cardputer_${sensor_test}_test) + endforeach() + add_executable(trailmate_linux_framebuffer_present_test tests/linux_framebuffer_present_test.cpp + "${TRAIL_MATE_REPO_ROOT}/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/rpi/src/platform/device/evdev_input.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/core/canvas.cpp") + target_include_directories(trailmate_linux_framebuffer_present_test PRIVATE + "${TRAIL_MATE_REPO_ROOT}/platform/linux/rpi/src" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src") + target_compile_features(trailmate_linux_framebuffer_present_test PRIVATE cxx_std_20) + target_compile_options(trailmate_linux_framebuffer_present_test PRIVATE -UNDEBUG) + target_link_options(trailmate_linux_framebuffer_present_test PRIVATE -Wl,--wrap=ioctl) + add_test(NAME trailmate_linux_framebuffer_present COMMAND trailmate_linux_framebuffer_present_test) add_executable(trailmate_linux_cardputer_zero_page_profile_smoke "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_cardputer_zero_page_profile.cpp" diff --git a/apps/linux_cardputer_zero/README.md b/apps/linux_cardputer_zero/README.md index 70424be0..fd464015 100644 --- a/apps/linux_cardputer_zero/README.md +++ b/apps/linux_cardputer_zero/README.md @@ -16,10 +16,30 @@ entry and a separate explicit fbdev/evdev debug fallback; hardware validation still has to happen on the real device session. The shared LVGL main-menu profile for this target is Cardputer Zero specific: -`make_cardputer_zero_profile()` owns a compact focus carousel for the 320 x 170 -display. It remains independent of the Pager's newer grid layout. Business +`make_cardputer_zero_profile()` uses the Pager's adaptive icon grid and focus +styling, with four columns and two visible rows on the 320 x 170 display. +Keyboard direction keys navigate the grid and scroll to the remaining apps. Business pages continue using shared page geometry and runtime ports. +The Pager-style widget feed is also available: press `V` to switch between the +grid and widgets, use Up/Down or W/S to change widgets, and press `V` or Escape +to return to the grid. Music and Quick Controls support Left/Right (A/D) to +select a control and Enter to activate it. The feed reuses Mesh, GPS, Recent +Messages, Agenda, Music, and Quick Controls data ports; Compass appears only when +the runtime reports heading support. Each complete widget is scaled to fit the +short display without removing its controls or data fields. + +The framebuffer presenter caches identical canvas content. It converts changed +frames in private memory and publishes the completed native frame with one copy; +it never clears the live scanout buffer before drawing. This avoids black-frame +flicker and repeated pixel conversion while the UI is idle. + +The device session owns a background IIO sensor worker. Standard `in_magn_*` +channels provide the onboard BMM150 compass independently of GPS fixes and screen +rotation. Magnetic heading is shown as uncalibrated: keep the device level and +do not treat it as a calibrated true-north bearing. Missing or unreadable samples +remain unavailable rather than reusing a stale heading. + Known hardware facts in this repo: - display: 320 x 170 logical pixels @@ -178,6 +198,42 @@ Chat, Contacts, Map, or Sky Plot page is not currently open. Pages consume runtime state and send commands; they do not own radio polling or GPS serial reads. +On the framebuffer keyboard interface, press **S** in a Chat conversation to +open Compose. Type the message, press **Tab** once to select **Send**, then +press **Enter**. Enter inside the text field inserts a newline. A visible +Compose hint describes this sequence, and the compact action bars preserve +message space on the 320 × 170 panel. Further Tab presses reach the IME, +symbols, emoji, voice and cancel controls. Press Esc briefly to go back, or +hold it for approximately 1.5 seconds to exit cleanly to APPLaunch. + +Extensions → Make TMAP opens the five-step online creator. Search for a global +city or district and select the result with the correct country/context; the +boundary extent is filled automatically. Coordinates remains an optional +alternative. Select map layers and a detail preset, accept or edit the suggested +name, then review and create. Tab selects controls and Enter activates them; +Enter in the place field also starts its search. Advanced holds output metadata, +budgets and optional local PBF input. The normal path downloads map data itself. +Closing leaves a job running; reopening shows its progress. Cancel stops the job. +Completed maps are published directly to Map layers; View map opens the generated +area and Back returns to the result. See +[the creator guide](../../docs/engineering/linux-tmap-creation.md) for source and +device limits, endpoint configuration, and verification. + +The device shell enables the OS-owned `ext_5v_out` LED-class supply before +starting the radio and GPS services. The package grants the `gpio` group write +access to that specific brightness attribute through udev. Trail Mate restores +the previous supply state after its workers stop; an already enabled supply +remains enabled. The app does not need to run as root. + +Startup presence detection uses the radio's actual chip response or GPS UART +traffic, rather than treating `/dev/spidev*` and `/dev/serial0` as proof of a Cap. +If neither service responds after eight seconds, the shell shows one brief +notice and leaves local applications usable. Permission/power errors have a +separate notice, and intentionally disabled services do not trigger a missing +module notice. A GPS fix is not required to detect a receiver. Power off the +device before inserting or removing the Cap; this is a startup check, not a +request to hot-swap modules. + Cardputer Zero Settings follows the same ownership boundary. Trail Mate exposes application settings that its runtime can apply. Linux session brightness and screen timeout belong to the operating system or desktop environment and are diff --git a/apps/linux_cardputer_zero/packaging/70-trailmate-cardputer-zero.rules b/apps/linux_cardputer_zero/packaging/70-trailmate-cardputer-zero.rules new file mode 100644 index 00000000..85707176 --- /dev/null +++ b/apps/linux_cardputer_zero/packaging/70-trailmate-cardputer-zero.rules @@ -0,0 +1,3 @@ +# Let the gpio group manage only the Cap's shared EXT5V supply. The application +# stays unprivileged; the kernel continues to own the underlying GPIO line. +ACTION=="add", SUBSYSTEM=="leds", KERNEL=="ext_5v_out", RUN+="/bin/chgrp gpio /sys%p/brightness", RUN+="/bin/chmod g+w /sys%p/brightness" diff --git a/apps/linux_cardputer_zero/packaging/postinst b/apps/linux_cardputer_zero/packaging/postinst new file mode 100644 index 00000000..2247c48d --- /dev/null +++ b/apps/linux_cardputer_zero/packaging/postinst @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +if [ "${1:-}" = configure ] && command -v udevadm >/dev/null 2>&1; then + udevadm control --reload-rules || true + udevadm trigger --action=add --subsystem-match=leds --sysname-match=ext_5v_out || true + udevadm settle --timeout=5 || true +fi diff --git a/apps/linux_cardputer_zero/tests/extract_linux_sx126x_tx.py b/apps/linux_cardputer_zero/tests/extract_linux_sx126x_tx.py new file mode 100644 index 00000000..f6403e66 --- /dev/null +++ b/apps/linux_cardputer_zero/tests/extract_linux_sx126x_tx.py @@ -0,0 +1,16 @@ +"""Compile the production transmit method against a command-state radio fake.""" +from pathlib import Path +import sys + +source = Path(sys.argv[1]).read_text(encoding="utf-8") +needle = "bool Sx126xRadio::transmit(const std::uint8_t* data, std::size_t size)" +assert source.count(needle) == 1 +start = source.index(needle) +brace = source.index("{", start) +depth, end = 1, brace + 1 +while depth: + assert end < len(source) + depth += (source[end] == "{") - (source[end] == "}") + end += 1 +method = source[start:end].replace("Sx126xRadio::", "", 1) +Path(sys.argv[2]).write_text(method + "\n", encoding="utf-8") diff --git a/apps/linux_cardputer_zero/tests/linux_agenda_page_test.cpp b/apps/linux_cardputer_zero/tests/linux_agenda_page_test.cpp index 51d6107e..07e1b1c1 100644 --- a/apps/linux_cardputer_zero/tests/linux_agenda_page_test.cpp +++ b/apps/linux_cardputer_zero/tests/linux_agenda_page_test.cpp @@ -102,7 +102,7 @@ int main(int argc, char** argv) key(Key::Enter); }; tick(3300ms); - for (unsigned i = 0; i < 12; ++i) key(Key::Right); + for (unsigned i = 0; i < 12; ++i) key(Key::Next); key(Key::Enter); until([&] { return page::state() && page::state()->snapshot.storage_ready; }); diff --git a/apps/linux_cardputer_zero/tests/linux_backlight_screen_test.cpp b/apps/linux_cardputer_zero/tests/linux_backlight_screen_test.cpp new file mode 100644 index 00000000..b9ff4808 --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_backlight_screen_test.cpp @@ -0,0 +1,66 @@ +#include "platform/linux/backlight_screen.h" +#include "platform/ui/settings_store.h" +#include +#include +#include +#include +#include + +namespace +{ +template +void await(Predicate predicate) +{ + const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!predicate()) + { + assert(std::chrono::steady_clock::now() < end); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } +} +} // namespace + +int main() +{ + namespace runtime = platform::linux_runtime; + namespace screen = platform::ui::screen; + const auto root = std::filesystem::temp_directory_path() / ("trailmate-backlight-screen-" + std::to_string(getpid())); + std::filesystem::create_directories(root / "panel"); + std::ofstream(root / "panel/max_brightness") << 100; + std::ofstream(root / "panel/brightness") << 37; + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + platform::ui::settings_store::put_uint("settings", "screen_timeout", 10000); + uint32_t now = 0; + runtime::BacklightOptions options; + options.root = root; + { + runtime::BacklightScreenBinding binding(options, [&] + { return now; }); + await([] + { return runtime::backlight_snapshot().available; }); + screen::init({}); + assert(screen::supports_app_timeout_setting()); + runtime::service_screen_runtime(); + now = 10000; + runtime::service_screen_runtime(); + assert(screen::is_sleeping()); + await([] + { return runtime::backlight_snapshot().level == 0; }); + assert(runtime::BacklightScreenBinding::consumeWakeInput(false)); + assert(!screen::is_sleeping() && screen::is_saver_active()); + await([] + { return runtime::backlight_snapshot().level == 94; }); + assert(runtime::BacklightScreenBinding::consumeWakeInput(true)); + assert(!screen::is_saver_active()); + assert(!runtime::BacklightScreenBinding::consumeWakeInput(false)); + now = 20000; + runtime::service_screen_runtime(); + assert(screen::is_sleeping()); + await([] + { return runtime::backlight_snapshot().level == 0; }); + } + unsigned restored = 0; + std::ifstream(root / "panel/brightness") >> restored; + assert(restored == 37); // Exiting while asleep restores the prior brightness. + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_cap_test.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_cap_test.cpp new file mode 100644 index 00000000..4d8d8a80 --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_cap_test.cpp @@ -0,0 +1,101 @@ +#include "platform/linux/cardputer_cap.h" + +#include +#include +#include +#include +#include + +using platform::linux_runtime::CardputerCap; +using platform::linux_runtime::CardputerCapObservation; + +int readPower(const std::filesystem::path& path) +{ + int value = -1; + std::ifstream(path) >> value; + return value; +} + +int main() +{ + const auto root = std::filesystem::temp_directory_path() / + ("trailmate-cap-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + const auto path = root / "brightness"; + const CardputerCapObservation missing{true, false, true, 0, + "SX1262 probe failed: OCP readback mismatch; status=00"}; + std::ofstream(path) << 0; + { + CardputerCap cap(path.string(), 0); + cap.begin(); + cap.begin(); + assert(readPower(path) == 1); + assert(cap.takeNotice(7999, missing) == nullptr); + assert(std::strstr(cap.takeNotice(8000, missing), "not detected")); + assert(cap.takeNotice(16000, missing) == nullptr); + assert(!cap.pending()); + } + assert(readPower(path) == 0); + std::ofstream(path) << 1; + { + CardputerCap cap(path.string(), 0); + cap.begin(); + auto present = missing; + present.radio_online = true; + assert(cap.takeNotice(100, present) == nullptr); + assert(!cap.pending()); + assert(cap.takeNotice(9000, missing) == nullptr); + } + assert(readPower(path) == 1); // Preserve an already enabled OS-owned supply. + { + CardputerCap cap(path.string(), 0); + cap.begin(); + auto present = missing; + present.gps_chars = 128; // No satellite fix is required to detect a receiver. + assert(cap.takeNotice(1000, present) == nullptr); + assert(!cap.pending()); + } + { + CardputerCap cap(path.string(), 0); + cap.begin(); + auto disabled = missing; + disabled.radio_enabled = false; + assert(cap.takeNotice(8000, disabled) == nullptr); + assert(!cap.pending()); + } + { + CardputerCap cap(path.string(), 0); + cap.begin(); + auto denied = missing; + denied.radio_error = "open spidev: Permission denied"; + assert(std::strstr(cap.takeNotice(8000, denied), "permissions")); + } + { + CardputerCap cap(path.string(), 0); + cap.begin(); + auto no_endpoint = missing; + no_endpoint.radio_error.clear(); + no_endpoint.radio_endpoint_present = false; + assert(std::strstr(cap.takeNotice(8000, no_endpoint), "not detected")); + } + { + CardputerCap cap(path.string(), 0); + cap.begin(); + auto failed = missing; + failed.radio_error = "SX1262 configure failed"; + assert(std::strstr(cap.takeNotice(8000, failed), "not responding")); + } + { + CardputerCap cap(root.string(), 0); // Existing but unreadable power interface. + cap.begin(); + assert(std::strstr(cap.takeNotice(0, missing), "power unavailable")); + assert(cap.takeNotice(1, missing) == nullptr); + } + { + CardputerCap cap((root / "absent").string(), 0); + cap.begin(); + assert(!cap.pending()); // Desktop/non-Cardputer hosts remain unaffected. + assert(cap.takeNotice(8000, missing) == nullptr); + } + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_heading_test.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_heading_test.cpp new file mode 100644 index 00000000..977fc0e6 --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_heading_test.cpp @@ -0,0 +1,78 @@ +#include "platform/linux/iio_sensors.h" +#include "platform/ui/orientation_runtime.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +void write(const std::filesystem::path& path, const char* value) +{ + std::ofstream output(path.string() + ".tmp"); + output << value; + output.close(); + std::filesystem::rename(path.string() + ".tmp", path); +} +bool waitFor(const std::function& condition) +{ + const auto until = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (std::chrono::steady_clock::now() < until) + { + if (condition()) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + return false; +} +} // namespace + +int main() +{ + namespace sensors = platform::linux_runtime; + namespace orientation = platform::ui::orientation; + const auto root = std::filesystem::temp_directory_path() / ("trailmate-heading-" + std::to_string(getpid())); + const auto device = root / "iio:device0"; + std::filesystem::create_directories(device); + write(device / "in_magn_x_raw", "1600"); + write(device / "in_magn_y_raw", "0"); + write(device / "in_magn_z_raw", "1000"); + write(device / "in_magn_scale", "0.000625"); + sensors::MagnetometerSnapshot value; + assert(sensors::read_iio_magnetometer(device, value)); + assert(value.raw_x == 1600 && std::abs(value.x - 1.0f) < .001f && std::abs(value.heading_deg) < .01f); + assert(sensors::read_iio_magnetometer(device, value, 90) && std::abs(value.heading_deg - 90) < .01f); + write(device / "in_magn_x_raw", "0"); + write(device / "in_magn_y_raw", "-1600"); + assert(sensors::read_iio_magnetometer(device, value) && std::abs(value.heading_deg - 90) < .01f); + write(device / "in_magn_y_raw", "1600"); + assert(sensors::read_iio_magnetometer(device, value) && std::abs(value.heading_deg - 270) < .01f); + write(device / "in_magn_y_raw", "0"); + assert(!sensors::read_iio_magnetometer(device, value) && !value.ready); + write(device / "in_magn_x_raw", "NaN"); + assert(!sensors::read_iio_magnetometer(device, value)); + write(device / "in_magn_x_raw", "1600"); + sensors::IioSensorOptions options; + options.root = root; + options.expected = true; + assert(sensors::configure_iio_sensors(options)); + assert(waitFor([] + { return orientation::get_heading().available; })); + assert(orientation::heading_supported() && orientation::acquire_heading()); + assert(!sensors::linux_imu_snapshot().ready); // Heading does not depend on screen rotation or an accelerometer. + assert(orientation::get_heading().raw_x == 1600); + std::filesystem::remove(device / "in_magn_z_raw"); + assert(waitFor([] + { return !orientation::get_heading().available; })); + assert(orientation::heading_supported()); // Stable hardware capability survives a transient read failure. + std::filesystem::remove_all(device); + assert(waitFor([] + { return !orientation::heading_supported(); })); + orientation::release_heading(); + sensors::shutdown_iio_sensors(); + assert(!orientation::get_heading().available); + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_calculator_test.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_calculator_test.cpp index 771a3022..0865e9c3 100644 --- a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_calculator_test.cpp +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_calculator_test.cpp @@ -67,7 +67,7 @@ int main() shell.enqueueInputs({{code, value ? std::string(1, value) : std::string{}, value}}); tick(100ms); }; - for (int i = 0; i < 6; ++i) key(input::InputKey::Right); + for (int i = 0; i < 6; ++i) key(input::InputKey::Next); key(input::InputKey::Enter); tick(650ms); assert(hasLabel(lv_screen_active(), "CALCULATOR")); diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_chat_keyboard_test.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_chat_keyboard_test.cpp new file mode 100644 index 00000000..462a9ee2 --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_chat_keyboard_test.cpp @@ -0,0 +1,129 @@ +#include "app/input_event.h" +#include "ui/menu/menu_runtime.h" +#include "ui/screens/chat/chat_compose_components.h" +#include "ui/screens/chat/chat_conversation_layout.h" +#include "ui/shell_ui_runner.h" +#include "ui/widgets/ime/ime_widget.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; +using Key = trailmate::cardputer_zero::app::InputKey; + +static lv_obj_t* label(lv_obj_t* object, const char* text) +{ + if (lv_obj_check_type(object, &lv_label_class) && !std::strcmp(lv_label_get_text(object), text)) return object; + for (uint32_t index = 0; index < lv_obj_get_child_count(object); ++index) + if (auto* found = label(lv_obj_get_child(object, index), text)) return found; + return nullptr; +} + +int main() +{ + const auto root = std::filesystem::temp_directory_path() / ("trailmate-chat-keyboard-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_CACHE_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_SD_ROOT", (root / "sd").c_str(), 1); + setenv("TRAIL_MATE_RUNTIME_MODE", "local", 1); + setenv("TRAIL_MATE_LORA_DISABLE", "1", 1); + setenv("TRAIL_MATE_GPS_DEVICE", "/dev/trailmate-test-missing", 1); + setenv("SDL_AUDIODRIVER", "dummy", 1); + { + trailmate::cardputer_zero::linux_ui::ShellSession shell; + trailmate::cardputer_zero::linux_ui::CanvasLvglHost host(shell); + assert(shell.begin()); + const auto tick = [&](auto duration) + { + const auto end = std::chrono::steady_clock::now() + duration; + do + { + host.tick(); + std::this_thread::sleep_for(5ms); + } while (std::chrono::steady_clock::now() < end); + }; + const auto key = [&](Key code, char value = 0) + { + shell.enqueueInputs({{code, value ? std::string(1, value) : std::string{}, value}}); + tick(120ms); + }; + tick(3300ms); + auto* keypad = lv_indev_get_next(nullptr); + while (keypad && lv_indev_get_type(keypad) != LV_INDEV_TYPE_KEYPAD) keypad = lv_indev_get_next(keypad); + assert(keypad); + auto* previous = lv_group_get_default(); + auto* previous_input = lv_indev_get_group(keypad); + auto* group = lv_group_create(); + lv_group_set_default(group); + lv_indev_set_group(keypad, group); + ui::menu_runtime::setScene(ui::menu_runtime::Scene::App); + { + chat::ui::ChatComposeScreen compose(lv_screen_active(), {chat::ChannelId::PRIMARY, 123, chat::MeshProtocol::Meshtastic}); + ui::widgets::ImeWidget ime; + ime.init(compose.getContent(), compose.getTextarea()); + compose.attachImeWidget(&ime); + compose.setVoiceButton("Voice", true); + unsigned sends = 0; + compose.setActionCallback([](chat::ui::ChatComposeScreen::ActionIntent intent, void* context) + { + assert(intent == chat::ui::ChatComposeScreen::ActionIntent::Send); + ++*static_cast(context); }, + &sends); + tick(150ms); + assert(lv_group_get_focused(group) == compose.getTextarea()); + for (char value : std::string("hwsv")) key(Key::Character, value); + assert(compose.getText() == "hwsv"); + key(Key::Enter); + assert(compose.getText() == "hwsv\n" && sends == 0); + key(Key::Character, 'x'); + auto* send = lv_obj_get_parent(label(compose.getActionBar(), "Send")); + assert(send); + key(Key::Tab); + assert(lv_group_get_focused(group) == send); + assert(compose.getText() == "hwsv\nx" && sends == 0); + key(Key::Enter); + if (sends != 1) std::fprintf(stderr, "Send actions for one key press: %u\n", sends); + assert(sends == 1); // Key press/release must not duplicate the send. + assert(label(compose.getObj(), "Tab to Send, then Enter")); + lv_obj_update_layout(compose.getObj()); + assert(lv_obj_get_height(send) == 20); + assert(lv_obj_get_height(compose.getActionBar()) == 26); + for (uint32_t index = 0; index < lv_obj_get_child_count(compose.getActionBar()); ++index) + { + auto* child = lv_obj_get_child(compose.getActionBar(), index); + if (lv_obj_has_flag(child, LV_OBJ_FLAG_HIDDEN)) continue; + lv_area_t area; + lv_obj_get_coords(child, &area); + assert(area.x1 >= 0 && area.x2 < 320 && area.y1 >= 0 && area.y2 < 170); + } + std::set visited{lv_group_get_focused(group)}; + for (uint32_t index = 0; index < lv_group_get_obj_count(group); ++index) + { + key(Key::Tab); + visited.insert(lv_group_get_focused(group)); + } + assert(visited.count(ime.toggle_btn()) && visited.count(compose.getTextarea())); + ime.detach(); + } + { + auto conversation = chat::ui::layout::create_conversation_base(lv_screen_active()); + lv_obj_update_layout(conversation.root); + assert(lv_obj_get_height(conversation.action_bar) == 24); + assert(lv_obj_get_height(conversation.reply_btn) == 20); + assert(lv_obj_get_width(conversation.reply_btn) == 54); + lv_obj_delete(conversation.root); + } + ui::menu_runtime::setScene(ui::menu_runtime::Scene::Menu); + lv_group_set_default(previous); + lv_indev_set_group(keypad, previous_input); + lv_group_delete(group); + } + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_header_inbox_test.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_header_inbox_test.cpp new file mode 100644 index 00000000..0a3441a6 --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_header_inbox_test.cpp @@ -0,0 +1,116 @@ +#include "ui/screens/chat/chat_message_list_components.h" +#include "ui/shell_ui_runner.h" +#include "ui/widgets/top_bar.h" +#include +#include +#include +#include +#include +#include +#include +#include + +static lv_obj_t* find_label(lv_obj_t* object, const char* text) +{ + if (lv_obj_check_type(object, &lv_label_class) && !std::strcmp(lv_label_get_text(object), text)) return object; + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) + if (auto* found = find_label(lv_obj_get_child(object, i), text)) return found; + return nullptr; +} + +int main() +{ + const auto root = std::filesystem::temp_directory_path() / ("trailmate-header-inbox-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_CACHE_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_SD_ROOT", (root / "sd").c_str(), 1); + setenv("TRAIL_MATE_RUNTIME_MODE", "local", 1); + setenv("TRAIL_MATE_LORA_DISABLE", "1", 1); + setenv("TRAIL_MATE_GPS_DEVICE", "/dev/trailmate-test-missing", 1); + setenv("SDL_AUDIODRIVER", "dummy", 1); + { + trailmate::cardputer_zero::linux_ui::ShellSession shell; + trailmate::cardputer_zero::linux_ui::CanvasLvglHost host(shell); + assert(shell.begin()); + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(3300); + while (std::chrono::steady_clock::now() < end) + { + host.tick(); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + { + auto* page = lv_obj_create(lv_screen_active()); + lv_obj_set_size(page, 320, 170); + lv_obj_set_style_pad_all(page, 0, 0); + lv_obj_set_style_border_width(page, 0, 0); + ui::widgets::TopBar bar; + ui::widgets::TopBarConfig config; + config.power_indicator = false; + ui::widgets::top_bar_init(bar, page, config); + for (const char* title : {"[MT] Trail Mate T-Display-CM0 with a long name", + "[MC] Cardputer Zero with a long peer name", + "[RT] Long destination name", "[MT] 中文节点的较长名称"}) + { + ui::widgets::top_bar_set_title(bar, title); + for (const char* status : {LV_SYMBOL_BATTERY_FULL " 100%", LV_SYMBOL_CHARGE " 100%", "USB", "18:56"}) + { + ui::widgets::top_bar_set_right_text_ascii(bar, status); + lv_obj_update_layout(page); + assert(lv_label_get_long_mode(bar.title_label) == LV_LABEL_LONG_DOT); + const auto* font = lv_obj_get_style_text_font(bar.title_label, LV_PART_MAIN); + assert(lv_obj_get_height(bar.title_label) <= lv_font_get_line_height(font)); + lv_point_t required; + lv_text_get_size(&required, status, lv_obj_get_style_text_font(bar.right_label, LV_PART_MAIN), + 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + assert(required.x <= lv_obj_get_content_width(bar.right_label)); + lv_area_t container, title_area, right, back; + lv_obj_get_coords(bar.container, &container); + lv_obj_get_coords(bar.title_label, &title_area); + lv_obj_get_coords(bar.right_label, &right); + lv_obj_get_coords(bar.back_btn, &back); + assert(title_area.x1 > back.x2 && title_area.x2 < right.x1); + assert(title_area.y1 >= container.y1 && title_area.y2 <= container.y2); + assert(right.x2 < 320 && right.y1 >= container.y1 && right.y2 <= container.y2); + } + } + lv_obj_delete(page); + } + { + chat::ui::ChatMessageListScreen inbox(lv_screen_active()); + chat::ConversationMeta direct; + direct.id = {chat::ChannelId::PRIMARY, 123, chat::MeshProtocol::Meshtastic}; + direct.name = "Private peer"; + direct.preview = "old direct"; + direct.last_timestamp = 100; + inbox.setConversations({direct}); + assert(inbox.getItemCount() == 1); + assert(find_label(inbox.getDirectButton(), "All")); + inbox.setSelectedConversation(direct.id); + auto* same_screen = inbox.getObj(); + chat::ConversationMeta broadcast; + broadcast.id = {chat::ChannelId::PRIMARY, 0, chat::MeshProtocol::Meshtastic}; + broadcast.name = "Broadcast"; + broadcast.preview = "official live hi"; + broadcast.last_timestamp = 200; + broadcast.unread = 1; + // Simulate the normal refresh while the existing inbox stays open. + inbox.setConversations({broadcast, direct}); + lv_obj_update_layout(inbox.getObj()); + assert(inbox.getObj() == same_screen && inbox.getItemCount() == 2); + assert(find_label(inbox.getObj(), "official live hi")); + assert(find_label(inbox.getObj(), "1")); + broadcast.preview = "new broadcast while open"; + broadcast.unread = 2; + inbox.setConversations({broadcast, direct}); + assert(find_label(inbox.getObj(), "new broadcast while open")); + assert(find_label(inbox.getObj(), "2")); + lv_obj_send_event(inbox.getBroadcastButton(), LV_EVENT_CLICKED, nullptr); + assert(inbox.getItemCount() == 1); + lv_obj_send_event(inbox.getDirectButton(), LV_EVENT_CLICKED, nullptr); + assert(inbox.getItemCount() == 2); + } + } + std::filesystem::remove_all(root); + std::puts("Zero header and inbox: single-line peer names, complete status, live broadcast insertion/preview/unread and All filter passed"); +} diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_page_help_test.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_page_help_test.cpp index ec3ae244..373bb338 100644 --- a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_page_help_test.cpp +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_page_help_test.cpp @@ -84,7 +84,7 @@ int main(int argc, char** argv) tick(150ms); }; tick(3300ms); - for (int i = 0; i < index; ++i) key(cardputer_app::InputKey::Right); + for (int i = 0; i < index; ++i) key(cardputer_app::InputKey::Next); key(cardputer_app::InputKey::Enter); tick(650ms); assert(ui_get_active_app()); diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_widgets_test.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_widgets_test.cpp new file mode 100644 index 00000000..48eb3b03 --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_widgets_test.cpp @@ -0,0 +1,89 @@ +#include "app/input_event.h" +#include "platform/ui/orientation_runtime.h" +#include "ui/app_runtime.h" +#include "ui/menu/dashboard/dashboard_state.h" +#include "ui/menu/menu_dashboard.h" +#include "ui/shell_ui_runner.h" + +#include +#include +#include +#include +#include +#include + +using namespace trailmate::cardputer_zero; +using namespace std::chrono_literals; +using InputKey = trailmate::cardputer_zero::app::InputKey; + +int main() +{ + const auto root = std::filesystem::temp_directory_path() / + ("trailmate-compact-widgets-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_CACHE_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_SD_ROOT", (root / "sd").c_str(), 1); + setenv("TRAIL_MATE_RUNTIME_MODE", "offline", 1); + setenv("TRAIL_MATE_LORA_DISABLE", "1", 1); + setenv("TRAIL_MATE_GPS_DEVICE", "/dev/trailmate-test-missing", 1); + { + linux_ui::ShellSession shell; + linux_ui::CanvasLvglHost host(shell); + assert(shell.begin()); + const auto tick = [&](auto duration) + { + const auto end = std::chrono::steady_clock::now() + duration; + do + { + host.tick(); + std::this_thread::sleep_for(5ms); + } while (std::chrono::steady_clock::now() < end); + }; + const auto key = [&](InputKey code, char value = 0) + { + shell.enqueueInputs({{code, value ? std::string(1, value) : std::string{}, value}}); + tick(250ms); + }; + tick(3300ms); + assert(shell.ready()); + auto* initial = lv_group_get_focused(menu_g); + assert(initial); + key(InputKey::Right); + auto* right = lv_group_get_focused(menu_g); + assert(right && right != initial && lv_obj_get_y(right) == lv_obj_get_y(initial)); + key(InputKey::Down); + auto* down = lv_group_get_focused(menu_g); + assert(down && down != right && lv_obj_get_y(down) > lv_obj_get_y(right)); + key(InputKey::Character, 'v'); + assert(ui::menu::dashboard::isFeedOpen()); + auto* state = ui::menu::dashboard::dashboard_state(); + assert(state && state->widgets[0].root); + const auto count = static_cast(ui::menu::dashboard::WidgetKind::Count) - + (::platform::ui::orientation::heading_supported() ? 0U : 1U); + for (unsigned page = 0; page < count; ++page) + { + assert(state->page == page); + auto* widget = state->widgets[0].root; + assert(widget && lv_obj_is_valid(widget)); + assert(lv_obj_get_style_transform_scale_x(widget, LV_PART_MAIN) == 170); + assert(lv_obj_get_width(widget) * 170 / 256 <= 320); + assert(lv_obj_get_height(widget) * 170 / 256 <= lv_obj_get_height(state->grid)); + if (page + 1 < count) key(InputKey::Down); + } + // Widget controls take Left/Right without closing the feed. + key(InputKey::Right); + assert(ui::menu::dashboard::isFeedOpen()); + key(InputKey::Character, 'v'); + assert(!ui::menu::dashboard::isFeedOpen()); + assert(lv_group_get_focused(menu_g) == down); + key(InputKey::Enter); + assert(!ui::menu::dashboard::isFeedOpen()); + key(InputKey::Home); + key(InputKey::Character, 'v'); + assert(ui::menu::dashboard::isFeedOpen()); + key(InputKey::Home); + assert(!ui::menu::dashboard::isFeedOpen()); + } + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/linux_config_chat_roundtrip_test.cpp b/apps/linux_cardputer_zero/tests/linux_config_chat_roundtrip_test.cpp new file mode 100644 index 00000000..55c39e9d --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_config_chat_roundtrip_test.cpp @@ -0,0 +1,64 @@ +#include "app/app_facade_access.h" +#include "app/linux_app_config_blob.h" +#include "platform/ui/settings_store.h" +#include "ui/screens/chat/chat_compose_components.h" +#include "ui/screens/settings/settings_page_shell.h" +#include "ui/shell_ui_runner.h" +#include +#include +#include +#include +#include +#include + +int main() +{ + const auto root = std::filesystem::temp_directory_path() / ("trailmate-config-chat-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_CACHE_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_SD_ROOT", (root / "sd").c_str(), 1); + setenv("TRAIL_MATE_RUNTIME_MODE", "local", 1); + setenv("TRAIL_MATE_LORA_DISABLE", "1", 1); + setenv("TRAIL_MATE_GPS_DEVICE", "/dev/trailmate-test-missing", 1); + setenv("SDL_AUDIODRIVER", "dummy", 1); + { + trailmate::cardputer_zero::linux_ui::ShellSession shell; + trailmate::cardputer_zero::linux_ui::CanvasLvglHost host(shell); + assert(shell.begin()); + auto tick = [&](unsigned milliseconds) + { + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(milliseconds); + do + { + host.tick(); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } while (std::chrono::steady_clock::now() < end); + }; + tick(3300); + auto& facade = app::appFacade(); + for (auto protocol : {chat::MeshProtocol::MeshCore, chat::MeshProtocol::Reticulum, chat::MeshProtocol::Meshtastic}) + { + assert(facade.switchMeshProtocol(protocol, true)); + tick(600); + chat::ui::ChatComposeScreen compose(lv_screen_active(), {chat::ChannelId::PRIMARY, 123, protocol}); + std::string text = "rt zero cm0 tm3 "; + text.append(156, 'x'); + lv_textarea_set_text(compose.getTextarea(), text.c_str()); + tick(100); + assert(facade.readConfig().mesh_protocol == protocol); + settings::ui::shell::enter(nullptr, lv_screen_active()); + tick(100); + assert(facade.readConfig().mesh_protocol == protocol); + settings::ui::shell::exit(nullptr, lv_screen_active()); + facade.saveConfig(); + tick(700); + trailmate::linux_app::LinuxAppConfigBlob blob; + size_t length = 0; + assert(platform::ui::settings_store::get_blob_into("linux_app_facade", "app_config_v1", &blob, sizeof(blob), &length)); + assert(length == sizeof(blob) && blob.config.mesh_protocol == protocol); + assert(std::memcmp(&blob.config, &facade.readConfig(), sizeof(app::AppConfig)) == 0); + } + } + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/linux_evdev_exit_test.cpp b/apps/linux_cardputer_zero/tests/linux_evdev_exit_test.cpp new file mode 100644 index 00000000..55367be0 --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_evdev_exit_test.cpp @@ -0,0 +1,55 @@ +#include "platform/device/evdev_input.h" +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +int main() +{ + char path[] = "/tmp/trailmate-evdev-exit-XXXXXX"; + const int file = mkstemp(path); + assert(file >= 0); + const auto event = [&](unsigned short type, unsigned short code, int value) + { + input_event raw{}; + raw.type = type; + raw.code = code; + raw.value = value; + assert(write(file, &raw, sizeof(raw)) == sizeof(raw)); + }; + { + trailmate::cardputer_zero::platform::device::EvdevInput input(path); + assert(input.isOpen() && !input.quitRequested()); + event(EV_KEY, KEY_ESC, 1); + auto keys = input.drainInput(); + assert(keys.size() == 1 && keys[0].key == trailmate::cardputer_zero::app::InputKey::Power); + event(EV_KEY, KEY_ESC, 0); + assert(input.drainInput().empty()); + std::this_thread::sleep_for(1600ms); + input.drainInput(); + assert(!input.quitRequested()); + + event(EV_KEY, KEY_ESC, 1); + input.drainInput(); + event(EV_SYN, SYN_DROPPED, 0); + input.drainInput(); + std::this_thread::sleep_for(1600ms); + input.drainInput(); + assert(!input.quitRequested()); + + event(EV_KEY, KEY_ESC, 1); + input.drainInput(); + std::this_thread::sleep_for(1600ms); + event(EV_KEY, KEY_ESC, 2); // Kernel repeats do not restart the hold timer. + assert(input.drainInput().empty()); + assert(input.quitRequested()); + assert(input.reopen(path) && !input.quitRequested()); + } + close(file); + unlink(path); +} diff --git a/apps/linux_cardputer_zero/tests/linux_framebuffer_present_test.cpp b/apps/linux_cardputer_zero/tests/linux_framebuffer_present_test.cpp new file mode 100644 index 00000000..c48aea5a --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_framebuffer_present_test.cpp @@ -0,0 +1,112 @@ +#include "platform/device/linux_framebuffer_platform.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr std::size_t kBytes = 320 * 170 * 2; +} + +extern "C" int __wrap_ioctl(int, unsigned long request, ...) +{ + va_list arguments; + va_start(arguments, request); + void* output = va_arg(arguments, void*); + va_end(arguments); + if (request == FBIOGET_FSCREENINFO) + { + auto& info = *static_cast(output); + info = {}; + info.line_length = 320 * 2; + info.smem_len = kBytes; + return 0; + } + if (request == FBIOGET_VSCREENINFO) + { + auto& info = *static_cast(output); + info = {}; + info.xres = info.xres_virtual = 320; + info.yres = info.yres_virtual = 170; + info.bits_per_pixel = 16; + info.red = {11, 5, 0}; + info.green = {5, 6, 0}; + info.blue = {0, 5, 0}; + return 0; + } + return -1; +} + +int main() +{ + using trailmate::cardputer_zero::core::Canvas; + using trailmate::cardputer_zero::core::rgba; + using trailmate::cardputer_zero::platform::device::LinuxFramebufferPlatform; + char path[] = "/tmp/trailmate-framebuffer-XXXXXX"; + const int fd = mkstemp(path); + assert(fd >= 0 && ftruncate(fd, kBytes) == 0); + setenv("TRAIL_MATE_INPUT_DEVICE", "/dev/trailmate-test-missing-keyboard", 1); + { + LinuxFramebufferPlatform presenter(path); + Canvas canvas(320, 170); + canvas.clear(rgba(255, 255, 255)); + presenter.present(canvas); + for (int n = 0; n < 1000; ++n) presenter.present(canvas); + assert(presenter.statistics().requests == 1001); + assert(presenter.statistics().commits == 1); + assert(presenter.statistics().bytes_written == kBytes); + std::vector frame(kBytes); + assert(pread(fd, frame.data(), frame.size(), 0) == static_cast(frame.size())); + for (const auto byte : frame) assert(byte == 0xff); + + // A separate scanout process must never observe a black clearing phase. + // Both alternating frames contain only nonzero RGB565 bytes. This fails + // the former clear-live-mmap/slow-per-pixel publishing implementation. + int ready[2], done[2]; + assert(pipe(ready) == 0 && pipe(done) == 0); + const pid_t scanner = fork(); + assert(scanner >= 0); + if (scanner == 0) + { + close(ready[0]); + close(done[1]); + const char started = 1; + if (write(ready[1], &started, 1) != 1) _exit(2); + pollfd finished{done[0], POLLIN, 0}; + for (;;) + { + if (pread(fd, frame.data(), frame.size(), 0) != static_cast(frame.size())) _exit(3); + for (const auto byte : frame) + if (byte == 0) _exit(4); + if (poll(&finished, 1, 0) > 0) _exit(0); + } + } + close(ready[1]); + close(done[0]); + char started = 0; + assert(read(ready[0], &started, 1) == 1); + for (int n = 0; n < 100; ++n) + { + canvas.clear(n % 2 ? rgba(255, 255, 255) : rgba(255, 255, 0)); + presenter.present(canvas); + } + const char finished = 1; + assert(write(done[1], &finished, 1) == 1); + int status = 0; + assert(waitpid(scanner, &status, 0) == scanner); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + close(ready[0]); + close(done[1]); + assert(presenter.statistics().commits == 101); + assert(presenter.statistics().bytes_written == kBytes * 101); + } + close(fd); + std::filesystem::remove(path); +} diff --git a/apps/linux_cardputer_zero/tests/linux_meshtastic_ack_test.cpp b/apps/linux_cardputer_zero/tests/linux_meshtastic_ack_test.cpp new file mode 100644 index 00000000..69a97a5e --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_meshtastic_ack_test.cpp @@ -0,0 +1,318 @@ +#include "chat/infra/meshtastic/mt_codec_pb.h" +#include "chat/infra/meshtastic/mt_pki_crypto.h" +#include "chat/infra/meshtastic/mt_protocol_helpers.h" +#include "chat/linux_raw_lora_mesh_adapter.h" +#include "platform/linux/mesh/linux_sqlite_mesh_identity_store.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mt = chat::meshtastic; +namespace runtime = platform::linux_runtime; +using Adapter = trailmate::linux_app::LinuxRawLoraMeshAdapter; +using Failure = chat::delivery::SendFailureKind; +constexpr uint32_t self = 0x12345678, peer = 0xA1B3B57C; +uint32_t now_ms = 100; +uint32_t clock_ms() { return now_ms; } + +struct Radio final : runtime::IPacketRadio +{ + bool online = false, tx_ok = true; + size_t transmissions = 0; + runtime::RadioSettings settings{}; + std::deque incoming; + const char* name() const override { return "ACK test radio"; } + bool hardwareCandidatePresent() const override { return true; } + bool acquire() override + { + online = true; + return true; + } + void release() override { online = false; } + bool configureLoRa(const runtime::RadioSettings& value) override + { + settings = value; + return true; + } + bool startReceive() override { return online; } + bool transmit(const uint8_t*, size_t) override + { + ++transmissions; + return tx_ok; + } + bool pollReceive(runtime::RadioPacket* out) override + { + if (incoming.empty()) return false; + *out = incoming.front(); + incoming.pop_front(); + return true; + } + float readRssi() override { return -90; } + bool isOnline() const override { return online; } + const char* lastError() const override { return tx_ok ? "" : "test TX failed"; } + runtime::RadioSettings appliedLoRaConfig() const override { return settings; } + runtime::RadioStatistics stats() const override { return {}; } +}; +struct Result +{ + uint32_t id; + chat::MessageStatus status; + Failure failure; +}; +void result_sink(void* context, uint32_t id, chat::MeshProtocol protocol, chat::MessageStatus status, Failure failure) +{ + assert(protocol == chat::MeshProtocol::Meshtastic); + static_cast*>(context)->push_back({id, status, failure}); +} +mesh::LocalIdentity identity(uint8_t seed) +{ + mesh::LocalIdentity value{}; + for (size_t i = 0; i < 32; ++i) value.private_key[i] = uint8_t(seed + i); + EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, nullptr, value.private_key, 32); + assert(key); + size_t length = 32; + assert(EVP_PKEY_get_raw_public_key(key, value.public_key, &length) == 1 && length == 32); + EVP_PKEY_free(key); + value.valid = true; + return value; +} +std::array shared_key(const mesh::LocalIdentity& remote, const mesh::LocalIdentity& local) +{ + EVP_PKEY* private_key = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, nullptr, remote.private_key, 32); + EVP_PKEY* public_key = EVP_PKEY_new_raw_public_key(EVP_PKEY_X25519, nullptr, local.public_key, 32); + assert(private_key && public_key); + EVP_PKEY_CTX* context = EVP_PKEY_CTX_new(private_key, nullptr); + std::array shared{}; + size_t size = shared.size(); + assert(context && EVP_PKEY_derive_init(context) == 1 && EVP_PKEY_derive_set_peer(context, public_key) == 1 && + EVP_PKEY_derive(context, shared.data(), &size) == 1 && size == 32); + EVP_PKEY_CTX_free(context); + EVP_PKEY_free(private_key); + EVP_PKEY_free(public_key); + mt::hashSharedKey(shared.data(), shared.size()); + return shared; +} +runtime::RadioPacket routing_packet(const std::array& shared, uint32_t request, + meshtastic_Routing_Error reason = meshtastic_Routing_Error_NONE, + bool pki = true, uint32_t from = peer, uint32_t to = self, + int variant = 1) +{ + static uint32_t sequence = 0x10000; + const auto id = ++sequence; + meshtastic_Data data = meshtastic_Data_init_default; + data.portnum = meshtastic_PortNum_ROUTING_APP; + data.request_id = request; + meshtastic_Routing routing = meshtastic_Routing_init_default; + routing.which_variant = variant ? meshtastic_Routing_error_reason_tag : 0; + routing.error_reason = reason; + auto routing_stream = pb_ostream_from_buffer(data.payload.bytes, sizeof(data.payload.bytes)); + assert(pb_encode(&routing_stream, meshtastic_Routing_fields, &routing)); + data.payload.size = routing_stream.bytes_written; + if (variant == 2) + { + data.payload.bytes[0] = 0x80; + data.payload.size = 1; + } + uint8_t plaintext[256]{}; + auto stream = pb_ostream_from_buffer(plaintext, sizeof(plaintext)); + assert(pb_encode(&stream, meshtastic_Data_fields, &data)); + uint8_t payload[256]{}, psk[32]{}; + size_t payload_size = stream.bytes_written, psk_size = 0; + if (pki) + { + constexpr uint32_t extra = 0x71345; + uint8_t nonce[mt::kPkiNonceSize]{}; + mt::initPkiNonce(from, id, extra, nonce); + assert(mt::encryptPkiAesCcm(shared.data(), shared.size(), nonce, 8, nullptr, 0, + plaintext, payload_size, payload, payload + payload_size)); + payload_size += 8; + for (unsigned i = 0; i < 4; ++i) payload[payload_size++] = uint8_t(extra >> (8 * i)); + } + else + { + std::memcpy(payload, plaintext, payload_size); + mt::expandShortPsk(1, psk, &psk_size); + } + runtime::RadioPacket packet{}; + packet.size = packet.data.size(); + assert(mt::buildWirePacket(payload, payload_size, from, id, to, pki ? 0 : 0x08, 2, false, + nullptr, 0, packet.data.data(), &packet.size)); + if (!pki) + { + uint8_t nonce[16]{}; + const uint64_t packet_id = id; + std::memcpy(nonce, &packet_id, sizeof(packet_id)); + std::memcpy(nonce + 8, &from, sizeof(from)); + EVP_CIPHER_CTX* cipher = EVP_CIPHER_CTX_new(); + int count = 0, tail = 0; + auto* bytes = packet.data.data() + sizeof(mt::PacketHeaderWire); + assert(cipher && psk_size == 16 && EVP_EncryptInit_ex(cipher, EVP_aes_128_ctr(), nullptr, psk, nonce) == 1 && + EVP_EncryptUpdate(cipher, bytes, &count, payload, payload_size) == 1 && + EVP_EncryptFinal_ex(cipher, bytes + count, &tail) == 1 && size_t(count + tail) == payload_size); + EVP_CIPHER_CTX_free(cipher); + } + return packet; +} +int main() +{ + // Current official firmware consumes want_response from bitfield bit 1. + // Exercise serialized bytes, including reuse of caller-owned scratch. + for (bool want_response : {true, false}) + { + uint8_t encoded[256]{}; + size_t size = sizeof(encoded); + meshtastic_Data scratch = meshtastic_Data_init_default; + assert(mt::encodeNodeInfoMessage("!12345678", "Node", "ND", meshtastic_HardwareModel_PRIVATE_HW, + nullptr, nullptr, 0, want_response, encoded, &size, &scratch)); + meshtastic_Data decoded = meshtastic_Data_init_default; + auto stream = pb_istream_from_buffer(encoded, size); + assert(pb_decode(&stream, meshtastic_Data_fields, &decoded)); + assert(decoded.want_response == want_response && decoded.has_bitfield); + assert(((decoded.bitfield & 0x02) != 0) == want_response); + assert((decoded.bitfield & 0x01) == 0); // Never grant MQTT forwarding implicitly. + const uint8_t payload[] = {1, 2, 3}; + size = sizeof(encoded); + assert(mt::encodeAppDataWithRequestId(meshtastic_PortNum_KEY_VERIFICATION_APP, payload, sizeof(payload), + want_response, 0x1234, encoded, &size, &scratch)); + decoded = meshtastic_Data_init_default; + stream = pb_istream_from_buffer(encoded, size); + assert(pb_decode(&stream, meshtastic_Data_fields, &decoded)); + assert(decoded.request_id == 0x1234 && decoded.want_response == want_response && decoded.has_bitfield); + assert(((decoded.bitfield & 0x02) != 0) == want_response && (decoded.bitfield & 0x01) == 0); + } + const auto root = std::filesystem::temp_directory_path() / ("trailmate-mt-ack-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + const auto local = identity(0x37), remote = identity(0x51); + runtime::mesh::LinuxSqliteLocalIdentityStore local_store; + assert(local_store.save(local).ok); + runtime::mesh::LinuxSqlitePeerKeyStore peer_store; + mesh::PeerPublicKey peer_key{}; + peer_key.node_id = mesh::NodeId{peer}; + std::copy(std::begin(remote.public_key), std::end(remote.public_key), peer_key.public_key); + assert(peer_store.put(peer_key).ok); + peer_key.node_id = mesh::NodeId{peer + 1}; // Same test key, different authenticated peer ID. + assert(peer_store.put(peer_key).ok); + const auto shared = shared_key(remote, local); + Radio radio; + Adapter adapter(radio, self, clock_ms); + chat::MeshConfig config{}; + adapter.applyProtocolConfig(chat::MeshProtocol::Meshtastic, config); + assert(adapter.begin() && adapter.isPkiReady() && adapter.hasPkiKey(peer)); + assert(adapter.getCapabilities().supports_protocol_ack_tracking); + assert(!adapter.getCapabilities().supports_appdata_ack); + std::vector results; + adapter.setSendResultSink(result_sink, &results); + const auto send = [&](uint32_t id, uint32_t destination = peer) + { + auto result = adapter.sendTextDetailed(chat::ChannelId::PRIMARY, "ACK test", id, destination); + uint32_t sent_id; + bool ok; + while (adapter.takePendingSendResult(sent_id, ok)) + { + } + return result; + }; + const auto receive = [&](runtime::RadioPacket packet) + { radio.incoming.push_back(packet); adapter.tick(); }; + assert(send(0x2001).ok); + receive(routing_packet(shared, 0x2002)); // Wrong request. + receive(routing_packet(shared, 0x2001, meshtastic_Routing_Error_NONE, true, peer + 1)); + receive(routing_packet(shared, 0x2001, meshtastic_Routing_Error_NONE, true, peer, self + 1)); + receive(routing_packet(shared, 0x2001, meshtastic_Routing_Error_NONE, false, peer + 1)); + receive(routing_packet(shared, 0x2001, meshtastic_Routing_Error_NONE, true, peer, self, 0)); + receive(routing_packet(shared, 0x2001, meshtastic_Routing_Error_NONE, true, peer, self, 2)); + assert(results.empty()); + receive(routing_packet(shared, 0x2001, meshtastic_Routing_Error_NONE, false)); // Official shared-channel Routing ACK. + assert(results.size() == 1 && results.back().id == 0x2001 && results.back().status == chat::MessageStatus::Delivered); + receive(routing_packet(shared, 0x2001)); + assert(results.size() == 1); + assert(send(0x2003).ok); + receive(routing_packet(shared, 0x2003, meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, false)); + assert(results.size() == 2 && results.back().status == chat::MessageStatus::Failed && results.back().failure == Failure::PeerKeyMissing); + radio.tx_ok = false; + assert(!send(0x2004).ok); + radio.tx_ok = true; + now_ms += 60000; + adapter.tick(); + assert(results.size() == 2); // Failed physical TX must not reserve an ACK slot. + for (uint32_t id = 1; id <= 32; ++id) assert(send(0x3000 + id).ok); + auto transmissions = radio.transmissions; + auto full = send(0x3033); + assert(!full.ok && full.failure == chat::MeshOperationFailure::Busy && radio.transmissions == transmissions); + auto duplicate = send(0x3001); + assert(!duplicate.ok && duplicate.failure == chat::MeshOperationFailure::Busy && radio.transmissions == transmissions); + now_ms += 59999; + adapter.tick(); + assert(results.size() == 2); + ++now_ms; + adapter.tick(); + assert(results.size() == 34); + for (size_t i = 2; i < results.size(); ++i) assert(results[i].failure == Failure::AckTimeout); + now_ms = UINT32_MAX - 100; + assert(send(0x4001).ok); + now_ms += 59999; + adapter.tick(); + assert(results.size() == 34); + ++now_ms; + adapter.tick(); + assert(results.size() == 35 && results.back().failure == Failure::AckTimeout); + assert(send(0x4002).ok); + adapter.applyProtocolConfig(chat::MeshProtocol::MeshCore, config); + assert(results.size() == 36 && results.back().id == 0x4002 && results.back().failure == Failure::Rejected); + adapter.applyProtocolConfig(chat::MeshProtocol::Meshtastic, config); + receive(routing_packet(shared, 0x4002)); + assert(results.size() == 36); + assert(send(0x4003, 0).ok); // Broadcast does not wait for peer confirmation. + now_ms += 60000; + adapter.tick(); + assert(results.size() == 36); + assert(send(0x4004).ok); + adapter.cancelPendingSends(); + assert(results.size() == 37 && results.back().failure == Failure::Rejected); + receive(routing_packet(shared, 0x4004)); + assert(results.size() == 37); + assert(send(0x4005).ok); + auto corrupted = routing_packet(shared, 0x4005); + corrupted.data[corrupted.size - 5] ^= 1; + receive(corrupted); + assert(results.size() == 37); // Invalid CCM tag cannot prove delivery. + now_ms += 60000; + adapter.tick(); + assert(results.size() == 38 && results.back().failure == Failure::AckTimeout); + // The invalid-tag case intentionally discarded the peer key. Start the + // NACK fixture with a known key, as for a cached-but-stale real peer. + peer_key.node_id = mesh::NodeId{peer}; + assert(peer_store.put(peer_key).ok); + Radio resync_radio; + Adapter resync_adapter(resync_radio, self, clock_ms); + resync_adapter.applyProtocolConfig(chat::MeshProtocol::Meshtastic, config); + assert(resync_adapter.begin() && resync_adapter.hasPkiKey(peer)); + std::vector resync_results; + resync_adapter.setSendResultSink(result_sink, &resync_results); + for (auto reason : {meshtastic_Routing_Error_NO_CHANNEL, meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY}) + { + const uint32_t id = 0x5000 + static_cast(reason); + assert(resync_adapter.sendTextDetailed(chat::ChannelId::PRIMARY, "stale key", id, peer).ok); + const auto transmissions_before = resync_radio.transmissions; + const auto results_before = resync_results.size(); + const auto nack = routing_packet(shared, id, reason, false); + resync_radio.incoming.push_back(nack); + resync_adapter.tick(); + assert(resync_results.size() == results_before + 1 && resync_results.back().status == chat::MessageStatus::Failed); + assert(resync_radio.transmissions == transmissions_before + 1); // Request fresh NodeInfo after a matched NACK. + resync_radio.incoming.push_back(nack); + resync_adapter.tick(); + assert(resync_results.size() == results_before + 1 && resync_radio.transmissions == transmissions_before + 1); + } + resync_adapter.setSendResultSink(nullptr, nullptr); + resync_adapter.suspendRadio(); + adapter.setSendResultSink(nullptr, nullptr); + adapter.suspendRadio(); + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/linux_sx126x_tx_receive_test.cpp b/apps/linux_cardputer_zero/tests/linux_sx126x_tx_receive_test.cpp new file mode 100644 index 00000000..4f71c10d --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_sx126x_tx_receive_test.cpp @@ -0,0 +1,89 @@ +#include "platform/linux/packet_radio.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr uint16_t kIrqTxDone = 0x0001, kIrqRxDone = 0x0002, kIrqHeaderErr = 0x0020, + kIrqCrcErr = 0x0040, kIrqTimeout = 0x0200, kIrqAll = 0xffff; +constexpr uint32_t kRxTimeoutInf = 0xffffff; +constexpr uint8_t kLoRaHeaderExplicit = 0, kLoRaCrcOn = 1, kLoRaCrcOff = 0, + kLoRaIqStandard = 0, kCmdSetPacketParams = 0x8c, kCmdWriteBuffer = 0x0e; +void sleep_ms(unsigned ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } + +struct Transmitter +{ + std::mutex mutex_; + bool online_ = true; + uint8_t packet_type_ = 1; + platform::linux_runtime::RadioSettings lora_config_; + platform::linux_runtime::FskSettings fsk_config_; + unsigned tx_packets_ = 0, receive_starts = 0; + uint16_t irq_mask = kIrqRxDone, dio_mask = kIrqRxDone, outcome = kIrqTxDone; + bool fail_start = false, fail_write = false, fail_receive = false; + std::string error; + bool setBufferBaseLocked(uint8_t, uint8_t) { return true; } + bool writeCommandLocked(uint8_t, const uint8_t*, size_t, bool) { return true; } + bool transferLocked(const uint8_t*, uint8_t*, size_t) { return !fail_write; } + bool setDioIrqParamsLocked(uint16_t mask, uint16_t dio) + { + irq_mask = mask; + dio_mask = dio; + return true; + } + bool clearIrqLocked(uint16_t) { return true; } + bool setTxLocked(uint32_t) { return !fail_start; } + uint32_t getIrqFlagsLocked() const { return outcome & irq_mask; } + bool setRxLocked(uint32_t) + { + ++receive_starts; + return !fail_receive; + } + void setErrorLocked(const char* text) { error = text; } +#include "linux_sx126x_tx.inc" +}; +} // namespace + +int main() +{ + const uint8_t payload[] = {0x42, 0x23}; + for (const uint8_t modem : {uint8_t{0}, uint8_t{1}}) + { + Transmitter radio; + radio.packet_type_ = modem; + std::array maximum{}; + assert(radio.transmit(maximum.data(), 255)); + assert(!radio.transmit(maximum.data(), 256)); + assert(radio.tx_packets_ == 1 && radio.receive_starts == 1); + } + for (const uint8_t modem : {uint8_t{0}, uint8_t{1}}) + { + for (unsigned scenario = 0; scenario < 6; ++scenario) + { + Transmitter radio; + radio.packet_type_ = modem; + if (scenario == 1) radio.outcome = kIrqTimeout; + if (scenario == 2) radio.fail_start = true; + if (scenario == 3) radio.fail_write = true; + if (scenario == 4) radio.fail_receive = true; + if (scenario == 5) radio.outcome = 0; // Exercise the host wait timeout. + assert(radio.transmit(payload, sizeof(payload)) == (scenario == 0)); + assert(radio.tx_packets_ == (scenario == 0 || scenario == 4 ? 1 : 0)); + assert(radio.receive_starts == 1); + assert((radio.irq_mask & (kIrqRxDone | kIrqCrcErr | kIrqHeaderErr | kIrqTimeout)) == + (kIrqRxDone | kIrqCrcErr | kIrqHeaderErr | kIrqTimeout)); + assert(radio.dio_mask == kIrqRxDone); + // Model a subsequent peer frame: it must be visible through the + // restored chip mask, which SetRx by itself does not change. + radio.outcome = kIrqRxDone; + assert(radio.getIrqFlagsLocked() == kIrqRxDone); + } + } + std::puts("Production SX126x TX: LoRa/FSK restore RX IRQs after success, chip/host timeout, start/write failure; RX restart failure is reported"); +} diff --git a/apps/linux_cardputer_zero/tests/linux_tmap_keyboard_test.cpp b/apps/linux_cardputer_zero/tests/linux_tmap_keyboard_test.cpp new file mode 100644 index 00000000..759f3b0a --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_tmap_keyboard_test.cpp @@ -0,0 +1,306 @@ +#include "app/input_event.h" +#include "platform/ui/tmap_creation_runtime.h" +#include "ui/app_runtime.h" +#include "ui/shell_ui_runner.h" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; +using Key = trailmate::cardputer_zero::app::InputKey; +namespace tc = platform::ui::tmap_creation; +struct Service final : tc::Service +{ + tc::Snapshot state{}; + tc::Plan received{}; + tc::AreaSearchSnapshot search_state{}; + bool ready = false, reject = false; + unsigned starts = 0; + Service() + { + state.producer_ready = true; + state.downloader_ready = true; + state.source_directory = "/home/pi/.trailmate_cardputer_zero/sdcard/map-sources"; + } + std::vector sources() override + { + return ready ? std::vector{{"/test.osm.pbf", "test.osm.pbf", 300}} : std::vector{}; + } + tc::Estimate estimate(const tc::Plan&) override + { + tc::Estimate result; + result.valid = true; + result.tiles = 4; + result.raster_bytes = 524288; + result.working_bytes = 67108864; + result.available_bytes = 1024ULL * 1024 * 1024; + return result; + } + bool start(const tc::Plan& plan, std::string& error) override + { + if (reject) + { + error = "Test producer error"; + return false; + } + ++starts; + received = plan; + state.busy = true; + state.phase = tc::Phase::Building; + state.stage = "Rendering map tiles"; + return true; + } + tc::Snapshot snapshot() override { return state; } + bool searchAreas(const std::string& query) override + { + if (query.empty()) return false; + search_state = {search_state.revision + 1, true, false, query, {}, {}}; + return true; + } + tc::AreaSearchSnapshot areaSearch() override { return search_state; } + void cancel() override + { + state.busy = false; + state.phase = tc::Phase::Cancelled; + state.stage = "Creation cancelled"; + } +}; +lv_obj_t* label(lv_obj_t* object, const char* text) +{ + if (lv_obj_has_flag(object, LV_OBJ_FLAG_HIDDEN)) return nullptr; + if (lv_obj_check_type(object, &lv_label_class) && !std::strcmp(lv_label_get_text(object), text)) return object; + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) + if (auto* found = label(lv_obj_get_child(object, i), text)) return found; + return nullptr; +} +void objects(lv_obj_t* object, const lv_obj_class_t* type, std::vector& output) +{ + if (lv_obj_check_type(object, type)) output.push_back(object); + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) objects(lv_obj_get_child(object, i), type, output); +} +lv_obj_t* button(const char* text) +{ + auto* object = label(lv_screen_active(), text); + assert(object); + while (object && !lv_obj_check_type(object, &lv_button_class)) object = lv_obj_get_parent(object); + assert(object); + return object; +} +int main(int argc, char** argv) +{ + const auto root = std::filesystem::temp_directory_path() / ("trailmate-tmap-keyboard-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_CACHE_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_SD_ROOT", (root / "sd").c_str(), 1); + setenv("TRAIL_MATE_PACK_CATALOG_URL", "http://127.0.0.1:1/catalog.json", 1); + setenv("TRAIL_MATE_RUNTIME_MODE", "local", 1); + setenv("TRAIL_MATE_LORA_DISABLE", "1", 1); + setenv("SDL_AUDIODRIVER", "dummy", 1); + { + trailmate::cardputer_zero::linux_ui::ShellSession shell; + trailmate::cardputer_zero::linux_ui::CanvasLvglHost host(shell); + assert(shell.begin()); + const auto tick = [&](auto duration) + { + const auto end = std::chrono::steady_clock::now() + duration; + do + { + host.tick(); + std::this_thread::sleep_for(5ms); + } while (std::chrono::steady_clock::now() < end); + }; + const auto key = [&](Key code, char value = 0) + { + shell.enqueueInputs({{code, value ? std::string(1, value) : std::string{}, value}}); + tick(80ms); + }; + const auto snapshot = [&](const char* name) + { + if (argc < 2) return; + std::filesystem::create_directories(argv[1]); + host.tick(); + const auto& canvas = host.canvas(); + std::ofstream out(std::filesystem::path(argv[1]) / (std::string(name) + ".ppm"), std::ios::binary); + out << "P6\n320 170\n255\n"; + for (const auto pixel : canvas.pixels()) + { + const char rgb[]{char(pixel.r), char(pixel.g), char(pixel.b)}; + out.write(rgb, sizeof(rgb)); + } + }; + tick(3300ms); + Service service; + auto* previous_service = tc::service(); + tc::bind(&service); + for (unsigned i = 0; i < 10; ++i) key(Key::Next); + key(Key::Enter); + tick(300ms); + key(Key::Character, 't'); + tick(300ms); + assert(ui_get_active_app() && !std::strcmp(ui_get_active_app()->stable_id(), "extensions")); + assert(lv_obj_has_state(button("Next"), LV_STATE_DISABLED)); + assert(lv_obj_get_height(button("Search")) == 20); + const auto activate = [&](const char* caption) + { + auto* target = button(caption); + assert(!lv_obj_has_state(target, LV_STATE_DISABLED)); + unsigned tabs = 0; + while (lv_group_get_focused(app_g) != target && tabs++ < 24) key(Key::Tab); + assert(lv_group_get_focused(app_g) == target); + lv_obj_update_layout(lv_screen_active()); + lv_area_t area{}; + lv_obj_get_coords(target, &area); + assert(area.x1 >= 0 && area.x2 < 320 && area.y1 >= 0 && area.y2 < 170); + assert(lv_obj_get_height(target) == 20); + key(Key::Enter); + tick(100ms); + }; + const auto get = [&](const lv_obj_class_t* type) + { + std::vector result; + // Fields live in the wizard body, alongside the footer. + objects(lv_obj_get_parent(lv_obj_get_parent(label(lv_screen_active(), "Tab / Enter"))), type, result); + return result; + }; + const auto fill = [&](std::initializer_list values) + { + auto editors = get(&lv_textarea_class); + assert(editors.size() == values.size()); + size_t index = 0; + for (const auto* value : values) + { + auto* editor = editors[index++]; + unsigned tabs = 0; + while (lv_group_get_focused(app_g) != editor && tabs++ < 24) key(Key::Tab); + assert(lv_group_get_focused(app_g) == editor); + lv_textarea_set_text(editor, ""); + for (const char* character = value; *character; ++character) key(Key::Character, *character); + assert(!std::strcmp(lv_textarea_get_text(editor), value)); + } + }; + fill({"test district"}); + key(Key::Enter); + assert(service.search_state.busy && service.search_state.query == "test district"); + service.search_state.busy = false; + service.search_state.error = "Test network failure; retry search."; + ++service.search_state.revision; + tick(350ms); + assert(label(lv_screen_active(), "Test network failure; retry search.")); + assert(lv_obj_has_state(button("Next"), LV_STATE_DISABLED)); + activate("Search"); + service.search_state.busy = false; + service.search_state.results = { + {"R10", "Test district", "Test district, Yunnan, China", "CN", 102.70, 25, 102.71, 25.01}, + {"R11", "Test district", "Test district, California, United States", "US", -122.5, 37.7, -122.4, 37.8}}; + ++service.search_state.revision; + tick(350ms); + assert(!lv_obj_has_state(button("Next"), LV_STATE_DISABLED)); + snapshot("area-search"); + activate("Next"); + activate("Back"); + activate("Coordinates"); + fill({"", "", "", ""}); + activate("Next"); // Validation stays on Area, with a visible error. + assert(label(lv_screen_active(), "Enter all four coordinates as decimal degrees.")); + fill({"102.70", "25", "102.71", "25.01"}); + assert(!label(lv_screen_active(), "Enter all four coordinates as decimal degrees.")); + snapshot("area"); + activate("Next"); + auto zooms = get(&lv_dropdown_class); + assert(zooms.size() == 3); + lv_dropdown_set_selected(zooms[2], 15); + lv_obj_send_event(zooms[2], LV_EVENT_VALUE_CHANGED, nullptr); + activate("Back"); + activate("Next"); + zooms = get(&lv_dropdown_class); + assert(lv_dropdown_get_selected(zooms[2]) == 15); + lv_dropdown_set_selected(zooms[2], 14); + lv_obj_send_event(zooms[2], LV_EVENT_VALUE_CHANGED, nullptr); + snapshot("layers"); + activate("Next"); + fill({"hwst map", "keyboard-area.tmap"}); + service.ready = true; + activate("Advanced"); + fill({"hwst map", "keyboard-area.tmap", "keyboard-area", "2"}); + auto budgets = get(&lv_dropdown_class); + assert(budgets.size() == 2); + // Exercise actual keyboard selection, including the shared Pager router. + // A popup's Down key must select an option rather than focus Back. + unsigned source_tabs = 0; + while (lv_group_get_focused(app_g) != budgets[1] && source_tabs++ < 24) key(Key::Tab); + assert(lv_group_get_focused(app_g) == budgets[1]); + key(Key::Enter); + assert(lv_dropdown_is_open(budgets[1])); + key(Key::Down); + assert(lv_group_get_focused(app_g) == budgets[1]); + assert(lv_dropdown_get_selected(budgets[1]) == 1); + key(Key::Enter); + assert(!lv_dropdown_is_open(budgets[1])); + key(Key::Enter); + key(Key::Up); + key(Key::Enter); + assert(lv_dropdown_get_selected(budgets[1]) == 0); + lv_dropdown_set_selected(budgets[0], 0); + activate("Back"); + activate("Next"); + auto editors = get(&lv_textarea_class); + assert(!std::strcmp(lv_textarea_get_text(editors[3]), "2")); + assert(lv_dropdown_get_selected(get(&lv_dropdown_class)[0]) == 0); + activate("Next"); + snapshot("review"); + service.reject = true; + activate("Create"); + assert(label(lv_screen_active(), "Test producer error")); + service.reject = false; + activate("Create"); + assert(service.starts == 1 && service.received.download_source && service.received.source.empty() && service.received.name == "hwst map" && service.received.revision == 2 && + service.received.maximum_bytes == 128ULL * 1024 * 1024 && service.received.max_zoom == 14); + assert(lv_obj_has_state(button("New map"), LV_STATE_DISABLED)); + activate("Close"); + key(Key::Character, 't'); + tick(300ms); + assert(label(lv_screen_active(), "Rendering map tiles")); + service.state.busy = false; + service.state.phase = tc::Phase::Succeeded; + service.state.stage = "Map created and available in Map layers"; + service.state.output = "maps/tmap/osm/keyboard-area-osm.tmap"; + tick(350ms); + snapshot("result"); + const auto bars = get(&lv_bar_class); + assert(bars.size() == 1 && lv_bar_get_value(bars.front()) == 100); + assert(lv_obj_has_state(button("Cancel"), LV_STATE_DISABLED)); + activate("View map"); + tick(300ms); + assert(label(lv_screen_active(), "Map")); + lv_area_t map_title{}; + lv_obj_get_coords(label(lv_screen_active(), "Map"), &map_title); + assert(map_title.x1 >= 0 && map_title.x2 < 320 && map_title.y1 >= 0 && map_title.y2 < 170); + snapshot("view-map"); + assert(!label(lv_screen_active(), "Tab / Enter")); + key(Key::Home); + tick(350ms); + assert(button("View map") && button("New map")); + activate("Close"); + key(Key::Character, 't'); + tick(300ms); + assert(!lv_obj_has_state(button("New map"), LV_STATE_DISABLED)); + activate("New map"); + activate("Close"); + service.state.busy = true; + service.state.phase = tc::Phase::Building; + key(Key::Character, 't'); + tick(300ms); + activate("Cancel"); + assert(!service.state.busy && service.state.phase == tc::Phase::Cancelled); + activate("Close"); + ui_exit_active_app(lv_screen_active()); + tc::bind(previous_service); + } + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/linux_tmap_online_test.cpp b/apps/linux_cardputer_zero/tests/linux_tmap_online_test.cpp new file mode 100644 index 00000000..ef738709 --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_tmap_online_test.cpp @@ -0,0 +1,137 @@ +#include "platform/linux/packaged_map.h" +#include "platform/linux/tmap_area_search.h" +#include "platform/linux/tmap_creation.h" +#include "platform/ui/map_search_runtime.h" +#include +#include +#include +#include +#include +#include +#include + +namespace tc = platform::ui::tmap_creation; +namespace native = platform::linux_runtime; +using namespace std::chrono_literals; +template +void wait_for(Predicate predicate, std::chrono::seconds limit = 10s) +{ + const auto deadline = std::chrono::steady_clock::now() + limit; + while (!predicate()) + { + assert(std::chrono::steady_clock::now() < deadline); + std::this_thread::sleep_for(5ms); + } +} +int main(int argc, char** argv) +{ + assert(argc == 3); + const std::string endpoint = argv[1]; + { + native::TmapAreaSearch search(endpoint + "/search"); + assert(!search.search("")); + assert(search.search("ambiguous")); + wait_for([&] + { return !search.snapshot().busy; }); + auto result = search.snapshot(); + if (!result.error.empty()) std::cerr << result.error << '\n'; + assert(result.error.empty() && result.results.size() == 2); + assert(result.results[0].country == "CN" && result.results[1].country == "US"); + assert(result.results[0].west == 102.70 && result.results[0].south == 25 && result.results[0].north == 25.01); + assert(result.results[0].description != result.results[1].description); + assert(search.search("ambiguous")); + wait_for([&] + { return !search.snapshot().busy; }); + assert(search.snapshot().from_cache); + assert(search.search("malformed")); + wait_for([&] + { return !search.snapshot().busy; }); + assert(!search.snapshot().error.empty() && search.snapshot().results.empty()); + assert(search.search("point-only")); + wait_for([&] + { return !search.snapshot().busy; }); + assert(!search.snapshot().error.empty() && search.snapshot().results.empty()); + assert(search.search("slow")); + std::this_thread::sleep_for(1100ms); + assert(search.search("ambiguous")); + wait_for([&] + { return !search.snapshot().busy; }); + assert(search.snapshot().query == "ambiguous" && search.snapshot().results.size() == 2); + } + const auto root = std::filesystem::temp_directory_path() / ("trailmate-tmap-online-" + std::to_string(getpid())); + std::filesystem::create_directories(root / "sd"); + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_SD_ROOT", (root / "sd").c_str(), 1); + setenv("TRAIL_MATE_AREA_SEARCH_URL", (endpoint + "/search").c_str(), 1); + tc::Plan plan; + plan.download_source = true; + plan.name = "Online test area"; + plan.package_key = "online-test"; + plan.filename = "online-test.tmap"; + plan.west = 102.70; + plan.south = 25; + plan.east = 102.71; + plan.north = 25.01; + plan.min_zoom = plan.max_zoom = 13; + plan.maximum_bytes = 128ULL * 1024 * 1024; + const auto run = [&](const std::string& scenario, tc::Phase expected) + { + const auto storage = root / scenario; + std::filesystem::create_directories(storage); + native::TmapCreationOptions options{storage, root / "cache", argv[2]}; + options.map_endpoint = endpoint + "/map-" + scenario; + native::LinuxTmapCreation service(options); + wait_for([&] + { return service.snapshot().source_revision != 0; }); + assert(service.sources().empty()); // No user-supplied PBF is required. + assert(service.snapshot().producer_ready && service.snapshot().downloader_ready); + assert(service.estimate(plan).valid); + auto too_large = plan; + too_large.east += 2; + too_large.north += 2; + assert(!service.estimate(too_large).valid); + std::string error; + assert(service.start(plan, error)); + assert(!service.start(plan, error)); + if (scenario == "cancel") + { + wait_for([&] + { return service.snapshot().stage.find("Downloading") != std::string::npos; }); + service.cancel(); + } + wait_for([&] + { return !service.snapshot().busy; }, + 120s); + const auto result = service.snapshot(); + if (result.phase != expected) std::cerr << scenario << ": " << result.stage << ": " << result.error << '\n'; + assert(result.phase == expected); + for (const auto& file : std::filesystem::directory_iterator(storage / "trailmate/.tmap-jobs")) + assert(file.path().filename() == ".owner"); + return storage; + }; + const auto storage = run("success", tc::Phase::Succeeded); + setenv("TRAIL_MATE_SD_ROOT", storage.c_str(), 1); + { + native::PackagedMapSession session(storage); + wait_for([&] + { return session.snapshot().mode != native::PackagedMapMode::Pending; }); + assert(session.snapshot().mode == native::PackagedMapMode::Tmap); + ui::map_tiles::MapTileRef ref{ui::map_tiles::MapTileLayer::Osm, 13, 6433, 3507}; + wait_for([&] + { return session.request(ref); }); + assert(platform::ui::map_search::submit("Test Spring", 25.005, 102.705)); + platform::ui::map_search::Snapshot search; + std::array results{}; + wait_for([&] + { + platform::ui::map_search::poll(search, results.data(), results.size()); + return search.status != platform::ui::map_search::Status::Idle && search.status != platform::ui::map_search::Status::Searching; }); + assert(search.status == platform::ui::map_search::Status::Ready && search.count > 0); + platform::ui::map_search::cancel(); + } + run("failure", tc::Phase::Failed); + run("empty", tc::Phase::Failed); + run("oversize", tc::Phase::Failed); + run("cancel", tc::Phase::Cancelled); + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/tmap_online_fixture.py b/apps/linux_cardputer_zero/tests/tmap_online_fixture.py new file mode 100644 index 00000000..a7fb3f3c --- /dev/null +++ b/apps/linux_cardputer_zero/tests/tmap_online_fixture.py @@ -0,0 +1,90 @@ +"""Bounded local HTTP fixture for search, online creation and cancellation.""" +import collections +import http.server +import json +import subprocess +import sys +import threading +import time +import urllib.parse + +calls = collections.Counter() +xml = ''' + + + + + +'''.encode() + + +def area(identity, country, extent, osm_type="R"): + return {"type": "Feature", "properties": { + "osm_type": osm_type, "osm_id": identity, "name": "Test district", + "countrycode": country, "country": "China" if country == "CN" else "United States", + "state": "Yunnan" if country == "CN" else "California", "type": "district", + **({"extent": extent} if extent else {})}} + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + query = urllib.parse.parse_qs(parsed.query) + if parsed.path == "/search": + text = query["q"][0] + calls["search:" + text] += 1 + if text == "slow": + time.sleep(3) + if text == "malformed": + body = b"{bad" + else: + features = [area(12, "CN", None, "N")] + if text != "point-only": + features += [area(10, "CN", [102.70, 25.01, 102.71, 25]), + area(11, "US", [-122.5, 37.8, -122.4, 37.7]), + area(10, "CN", [102.70, 25.01, 102.71, 25]), + area(13, "CN", ["bad", 25.01, 102.71, 25])] + body = json.dumps({"features": features}).encode() + else: + assert parsed.path.startswith("/map-") + assert "[out:xml]" in query["data"][0] + assert "(._;>;);out body qt;" in query["data"][0], "Recurse before a single output so OSM objects are not duplicated" + assert "25,102.7,25.01,102.71" in query["data"][0] + scenario = parsed.path.removeprefix("/map-") + calls["map:" + scenario] += 1 + if scenario == "failure": + self.send_error(503) + return + if scenario == "oversize": + self.send_response(200) + self.send_header("Content-Length", str(65 * 1024 * 1024)) + self.end_headers() + return + if scenario == "cancel": + time.sleep(3) + body = b'' if scenario == "empty" else xml + try: + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +try: + result = subprocess.run([sys.argv[1], f"http://127.0.0.1:{server.server_port}", sys.argv[2]]) + if result.returncode == 0: + assert calls["search:ambiguous"] == 1, calls + assert calls["map:success"] == 1, calls + raise SystemExit(result.returncode) +finally: + server.shutdown() + server.server_close() + thread.join() diff --git a/apps/linux_cardputer_zero/tools/cardputer_zero_screenshot_capture.cpp b/apps/linux_cardputer_zero/tools/cardputer_zero_screenshot_capture.cpp index 172684ea..0a09936e 100644 --- a/apps/linux_cardputer_zero/tools/cardputer_zero_screenshot_capture.cpp +++ b/apps/linux_cardputer_zero/tools/cardputer_zero_screenshot_capture.cpp @@ -281,7 +281,7 @@ void return_to_menu(ShellSession& shell, CanvasLvglHost& host) void focus_next_menu_item(ShellSession& shell, CanvasLvglHost& host) { - press(shell, InputKey::Right); + press(shell, InputKey::Next); tick_for(shell, host, std::chrono::milliseconds(180)); } diff --git a/apps/linux_lvgl/CMakeLists.txt b/apps/linux_lvgl/CMakeLists.txt index 7e68528a..337077c9 100644 --- a/apps/linux_lvgl/CMakeLists.txt +++ b/apps/linux_lvgl/CMakeLists.txt @@ -306,10 +306,21 @@ if(TRAIL_MATE_LINUX_BUILD_APP) COMMAND_ERROR_IS_FATAL ANY) add_executable(trailmate_linux_lr2021_receive_test tests/linux_lr2021_receive_test.cpp) target_include_directories(trailmate_linux_lr2021_receive_test PRIVATE - "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include" "${CMAKE_CURRENT_BINARY_DIR}") + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include" + "${TRAIL_MATE_REPO_ROOT}/platform/shared/include" "${CMAKE_CURRENT_BINARY_DIR}") target_compile_options(trailmate_linux_lr2021_receive_test PRIVATE -UNDEBUG) target_link_libraries(trailmate_linux_lr2021_receive_test PRIVATE trailmate_linux_radiolib) add_test(NAME linux_lr2021_receive COMMAND trailmate_linux_lr2021_receive_test) + add_executable(trailmate_idf_radio_receive_test tests/idf_radio_receive_test.cpp + "${TRAIL_MATE_REPO_ROOT}/platform/esp/radio/idf_lora_radio_pump.cpp") + target_include_directories(trailmate_idf_radio_receive_test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/tests/idf_radio_receive_fakes" + "${TRAIL_MATE_REPO_ROOT}/platform/esp/radio" + "${TRAIL_MATE_REPO_ROOT}/platform/shared/include") + target_compile_definitions(trailmate_idf_radio_receive_test PRIVATE ESP_PLATFORM=1) + target_compile_options(trailmate_idf_radio_receive_test PRIVATE -UNDEBUG) + target_compile_features(trailmate_idf_radio_receive_test PRIVATE cxx_std_17) + add_test(NAME idf_radio_receive COMMAND trailmate_idf_radio_receive_test) add_executable(trailmate_idf_lr2021_irq_test tests/idf_lr2021_irq_test.cpp) target_include_directories(trailmate_idf_lr2021_irq_test PRIVATE "${TRAIL_MATE_REPO_ROOT}/platform/shared/include" @@ -1234,6 +1245,14 @@ if(TRAIL_MATE_LINUX_BUILD_APP) target_link_options(trailmate_linux_meshcore_direct_test PRIVATE -fsanitize=address,undefined) endif() add_executable(trailmate_linux_meshcore_services_test tests/linux_meshcore_services_test.cpp) + add_executable(trailmate_desktop_identity_storage_stress_test tests/linux_identity_storage_stress_test.cpp) + target_link_libraries(trailmate_desktop_identity_storage_stress_test PRIVATE trailmate_desktop_linux_common) + target_compile_options(trailmate_desktop_identity_storage_stress_test PRIVATE -UNDEBUG) + add_test(NAME linux_identity_storage_stress COMMAND trailmate_desktop_identity_storage_stress_test) + add_executable(trailmate_linux_identity_database_lock_test tests/linux_identity_database_lock_test.cpp) + target_link_libraries(trailmate_linux_identity_database_lock_test PRIVATE trailmate_desktop_linux_common) + target_compile_options(trailmate_linux_identity_database_lock_test PRIVATE -UNDEBUG) + add_test(NAME linux_identity_database_lock COMMAND trailmate_linux_identity_database_lock_test) add_executable(trailmate_linux_reticulum_identity_test tests/linux_reticulum_identity_test.cpp "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/mesh/linux_reticulum_identity.cpp") add_executable(trailmate_linux_identity_replacement_test tests/linux_identity_replacement_test.cpp diff --git a/apps/linux_lvgl/tests/idf_radio_receive_fakes/board/LoraBoard.h b/apps/linux_lvgl/tests/idf_radio_receive_fakes/board/LoraBoard.h new file mode 100644 index 00000000..ab28e525 --- /dev/null +++ b/apps/linux_lvgl/tests/idf_radio_receive_fakes/board/LoraBoard.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include + +class LoraBoard +{ + public: + bool online = true; + uint32_t irq = 0; + int length = 8, read_result = 0, start_result = 0; + unsigned starts = 0, reads = 0, clears = 0; + bool isRadioOnline() const { return online; } + int startRadioReceive() + { + ++starts; + return start_result; + } + uint32_t getRadioIrqFlags() const { return irq; } + void clearRadioIrqFlags(uint32_t flags) + { + ++clears; + irq &= ~flags; + } + int getRadioPacketLength(bool) const { return length; } + int readRadioData(uint8_t* output, std::size_t size) + { + ++reads; + // Model RadioLib's explicit-header validation. Clearing its metadata + // before readData destroys the evidence needed to accept the packet. + if (!(irq & 0x0002) || !(irq & 0x0010)) return -1; + if (read_result) return read_result; + std::memset(output, 0x42, size); + return 0; + } + float getRadioRSSI() const { return -55; } + float getRadioSNR() const { return 9; } +}; diff --git a/apps/linux_lvgl/tests/idf_radio_receive_test.cpp b/apps/linux_lvgl/tests/idf_radio_receive_test.cpp new file mode 100644 index 00000000..c2ff98c7 --- /dev/null +++ b/apps/linux_lvgl/tests/idf_radio_receive_test.cpp @@ -0,0 +1,93 @@ +#include "idf_lora_radio_pump.h" +#include "platform/shared/radio/restart_receive.h" +#include +#include + +using namespace platform::esp::radio; + +struct ContinuousRadio +{ + bool receiving = true; + unsigned standby_calls = 0, receive_calls = 0; + int standby_error = 0, receive_error = 0; + int standby() + { + ++standby_calls; + if (standby_error) return standby_error; + receiving = false; + return 0; + } + int startReceive() + { + ++receive_calls; + if (receiving) return -706; + if (receive_error) return receive_error; + receiving = true; + return 0; + } +}; + +int main() +{ + for (unsigned failure = 0; failure < 3; ++failure) + { + ContinuousRadio radio; + if (failure == 1) radio.standby_error = -1; + if (failure == 2) radio.receive_error = -2; + const bool result = platform::shared::radio::restartReceive( + radio, [](int code, const char*) + { return code == 0; }, + "test receive"); + assert(result == (failure == 0)); + assert(radio.standby_calls == 1 && radio.receive_calls == (failure == 1 ? 0 : 1)); + } + for (const uint32_t progress : {0x0004u, 0x0008u, 0x0010u, 0x0018u}) + { + LoraBoard board; + IdfLoraRadioPump pump(board); + IdfLoraRadioFrame packet; + assert(pump.ensureReceiveStarted()); + board.irq = progress; + assert(pump.poll(packet) == IdfLoraPollResult::None); + assert(board.starts == 1 && board.clears == 0 && board.irq == progress); + board.irq |= 0x0012; + assert(pump.poll(packet) == IdfLoraPollResult::Frame); + assert(packet.len == 8 && packet.data[0] == 0x42 && packet.rssi == -55); + assert(board.reads == 1 && board.clears == 1 && board.starts == 2); + } + for (const uint32_t error : {0x0020u, 0x0040u, 0x0200u}) + for (const uint32_t done : {0u, 0x0002u}) + { + LoraBoard board; + IdfLoraRadioPump pump(board); + IdfLoraRadioFrame packet; + assert(pump.ensureReceiveStarted()); + board.irq = error | done; + const auto result = pump.poll(packet); + assert(result == (done ? IdfLoraPollResult::ReadFailed : IdfLoraPollResult::ReceiveRestarted)); + assert(!packet.data && !packet.len && board.reads == 0 && board.starts == 2); + } + { + LoraBoard board; + IdfLoraRadioPump pump(board); + IdfLoraRadioFrame packet; + assert(pump.ensureReceiveStarted()); + board.irq = 0x0012; + board.start_result = -1; + assert(pump.poll(packet) == IdfLoraPollResult::Frame); + assert(packet.len == 8 && packet.data[0] == 0x42 && board.reads == 1); + assert(pump.poll(packet) == IdfLoraPollResult::ReceiveStartFailed); + assert(!packet.data && !packet.len && board.reads == 1); + } + { + LoraBoard board; + IdfLoraRadioPump pump(board); + IdfLoraRadioFrame packet; + assert(pump.ensureReceiveStarted()); + board.irq = 0x0013; // Receive pump must leave the TX waiter's IRQ intact. + assert(pump.poll(packet) == IdfLoraPollResult::None && board.clears == 0); + board.irq &= ~0x0001u; + assert(pump.poll(packet) == IdfLoraPollResult::Frame); + } + std::puts("Production IDF packet receive: progress preserved, IRQ validated before clear, damaged frames rejected, valid payload retained after failed rearm; shared Linux/ESP standby ordering passed"); +} diff --git a/apps/linux_lvgl/tests/linux_agenda_source_test.cpp b/apps/linux_lvgl/tests/linux_agenda_source_test.cpp index c1adff86..71150fe8 100644 --- a/apps/linux_lvgl/tests/linux_agenda_source_test.cpp +++ b/apps/linux_lvgl/tests/linux_agenda_source_test.cpp @@ -37,6 +37,7 @@ struct Clock final : agenda::IAgendaClock mutable std::mutex mutex; std::set frontend{std::this_thread::get_id()}; agenda::ClockSample value; + mutable std::atomic_uint samples{0}; Clock() { assert(agenda::toCalendarSeconds({2026, 10, 21, 12, 0, 0}, value.calendar_seconds)); @@ -46,6 +47,7 @@ struct Clock final : agenda::IAgendaClock { std::lock_guard lock(mutex); assert(!frontend.count(std::this_thread::get_id())); + ++samples; return value; } void advance(uint64_t seconds) @@ -172,6 +174,11 @@ int main() wait([&] { return snapshot(source).storage_ready; }); assert(snapshot(source).page.count == 0); + // The real clock reads durable timezone settings. An idle calendar + // must not repeatedly open SQLite at the UI's subsecond cadence. + const auto idle_samples = clock.samples.load(); + std::this_thread::sleep_for(650ms); + assert(clock.samples.load() - idle_samples <= 2); const int fd = factory.file(); assert(fd >= 0); assert((fcntl(fd, F_GET_SEALS) & F_SEAL_WRITE) != 0); @@ -279,7 +286,7 @@ int main() { return source.currentTime().calendar_seconds == clock.now(); }); assert(!source.reminderSnapshot().reminder.valid); clock.advance(1); - source.requestRefresh(); + // A due reminder must be discovered by the idle timer itself. wait([&] { return source.reminderSnapshot().reminder.valid; }); assert(!source.dismissReminder(shown.revision).ok); diff --git a/apps/linux_lvgl/tests/linux_identity_database_lock_test.cpp b/apps/linux_lvgl/tests/linux_identity_database_lock_test.cpp new file mode 100644 index 00000000..40756207 --- /dev/null +++ b/apps/linux_lvgl/tests/linux_identity_database_lock_test.cpp @@ -0,0 +1,45 @@ +#include "platform/linux/mesh/linux_reticulum_identity.h" +#include +#include +#include +#include +#include +#include + +bool reservedLockHeld(const std::filesystem::path& path) +{ + const pid_t child = fork(); + assert(child >= 0); + if (child == 0) + { + const int file = open(path.c_str(), O_RDWR); + struct flock lock + { + }; + lock.l_type = F_WRLCK; + lock.l_whence = SEEK_SET; + lock.l_start = 0x40000001; + lock.l_len = 1; + const bool held = file >= 0 && fcntl(file, F_GETLK, &lock) == 0 && lock.l_type != F_UNLCK; + _exit(held ? 0 : 1); + } + int status = 0; + assert(waitpid(child, &status, 0) == child); + return WIFEXITED(status) && WEXITSTATUS(status) == 0; +} +int main() +{ + const auto root = std::filesystem::temp_directory_path() / ("trailmate-identity-lock-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + const auto path = root / "identity.sqlite3"; + sqlite3* database = nullptr; + assert(sqlite3_open(path.c_str(), &database) == SQLITE_OK); + assert(sqlite3_exec(database, "PRAGMA journal_mode=DELETE; CREATE TABLE fixture(x); BEGIN IMMEDIATE; INSERT INTO fixture VALUES(1);", nullptr, nullptr, nullptr) == SQLITE_OK); + assert(reservedLockHeld(path)); + platform::linux_runtime::LinuxReticulumIdentity identity(path); + assert(!identity.begin()); // Another connection owns the pending writer. + assert(reservedLockHeld(path)); // Identity validation must not unlock it. + assert(sqlite3_exec(database, "ROLLBACK", nullptr, nullptr, nullptr) == SQLITE_OK); + sqlite3_close(database); + std::filesystem::remove_all(root); +} diff --git a/apps/linux_lvgl/tests/linux_identity_storage_stress_test.cpp b/apps/linux_lvgl/tests/linux_identity_storage_stress_test.cpp new file mode 100644 index 00000000..01a742c7 --- /dev/null +++ b/apps/linux_lvgl/tests/linux_identity_storage_stress_test.cpp @@ -0,0 +1,74 @@ +#include "platform/linux/mesh/linux_reticulum_identity.h" +#include "platform/ui/settings_store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main() +{ + const auto root = std::filesystem::temp_directory_path() / ("trailmate-identity-storage-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + const auto path = root / "trailmate.sqlite3"; + platform::linux_runtime::LinuxReticulumIdentity original(path); + assert(original.begin()); + const auto public_key = original.publicKey(); + sqlite3* database = nullptr; + assert(sqlite3_open(path.c_str(), &database) == SQLITE_OK); + assert(sqlite3_exec(database, "PRAGMA journal_mode=WAL; CREATE TABLE stress_messages(id INTEGER PRIMARY KEY,text TEXT NOT NULL);", nullptr, nullptr, nullptr) == SQLITE_OK); + sqlite3_close(database); + std::atomic_bool start{false}; + std::thread config([&] + { + while(!start.load()) std::this_thread::yield(); + std::array snapshot{}; + for(unsigned index=0;index<128;++index) + { + snapshot.fill(uint8_t(index)); + assert(platform::ui::settings_store::put_blob("stress","snapshot",snapshot.data(),snapshot.size())); + } }); + std::thread messages([&] + { + sqlite3* connection=nullptr; + assert(sqlite3_open(path.c_str(),&connection)==SQLITE_OK); + sqlite3_busy_timeout(connection,5000); + while(!start.load()) std::this_thread::yield(); + for(unsigned index=0;index<128;++index) + { + const std::string sql="INSERT INTO stress_messages VALUES("+std::to_string(index)+",'message-"+std::to_string(index)+"')"; + assert(sqlite3_exec(connection,sql.c_str(),nullptr,nullptr,nullptr)==SQLITE_OK); + } + sqlite3_close(connection); }); + start = true; + for (unsigned index = 0; index < 128; ++index) + { + platform::linux_runtime::LinuxReticulumIdentity identity(path); + // Contention may report busy; a successful load must preserve the key. + if (identity.begin()) assert(identity.publicKey() == public_key); + } + config.join(); + messages.join(); + assert(sqlite3_open(path.c_str(), &database) == SQLITE_OK); + sqlite3_stmt* query = nullptr; + assert(sqlite3_prepare_v2(database, "PRAGMA integrity_check", -1, &query, nullptr) == SQLITE_OK); + assert(sqlite3_step(query) == SQLITE_ROW && std::string(reinterpret_cast(sqlite3_column_text(query, 0))) == "ok"); + sqlite3_finalize(query); + assert(sqlite3_prepare_v2(database, "SELECT count(*) FROM stress_messages WHERE text='message-' || id", -1, &query, nullptr) == SQLITE_OK); + assert(sqlite3_step(query) == SQLITE_ROW && sqlite3_column_int(query, 0) == 128); + sqlite3_finalize(query); + sqlite3_close(database); + std::array snapshot{}; + size_t length = 0; + assert(platform::ui::settings_store::get_blob_into("stress", "snapshot", snapshot.data(), snapshot.size(), &length)); + assert(length == snapshot.size() && std::all_of(snapshot.begin(), snapshot.end(), [](uint8_t byte) + { return byte == 127; })); + platform::linux_runtime::LinuxReticulumIdentity reopened(path); + assert(reopened.begin() && reopened.publicKey() == public_key); + std::filesystem::remove_all(root); +} diff --git a/apps/linux_lvgl/tests/linux_lr2021_receive_test.cpp b/apps/linux_lvgl/tests/linux_lr2021_receive_test.cpp index 9d4b33bc..65505bbd 100644 --- a/apps/linux_lvgl/tests/linux_lr2021_receive_test.cpp +++ b/apps/linux_lvgl/tests/linux_lr2021_receive_test.cpp @@ -1,4 +1,5 @@ #include "platform/linux/packet_radio.h" +#include "platform/shared/radio/restart_receive.h" #include #include #include @@ -27,16 +28,22 @@ struct Receiver uint32_t irq = 0; size_t size = 8; unsigned reads = 0, receives = 0, standbys = 0; - int receive_error = 0, read_error = 0; + int receive_error = 0, read_error = 0, standby_error = 0; + bool receiving = true; uint32_t getIrqFlags() { return irq; } int startReceive() { ++receives; - return receive_error; + if (receiving) return -706; + if (receive_error) return receive_error; + receiving = true; + return 0; } int standby() { ++standbys; + if (standby_error) return standby_error; + receiving = false; return 0; } size_t getPacketLength() { return size; } @@ -118,6 +125,22 @@ int main() assert(receiver.pollReceive(&packet)); assert(packet.size == 8 && packet.data[0] == 0x42 && packet.rssi_dbm == -60 && packet.snr_db == 9); assert(receiver.statistics_.rx_packets == 1 && receiver.radio_.receives == 1); + assert(receiver.radio_.standbys == 1 && receiver.radio_.receiving); + } + for (const bool standby_fails : {false, true}) + { + Receiver receiver; + receiver.radio_.irq = RADIOLIB_LR2021_IRQ_RX_DONE; + if (standby_fails) receiver.radio_.standby_error = -42; + else receiver.radio_.receive_error = -42; + RadioPacket packet; + assert(receiver.pollReceive(&packet)); + assert(packet.size == 8 && packet.data[0] == 0x42); + assert(receiver.statistics_.rx_packets == 1 && !receiver.online_); + assert(receiver.radio_.standbys == 1); + assert(receiver.radio_.receives == (standby_fails ? 0 : 1)); + // A failed rearm cannot deliver the same payload a second time. + assert(!receiver.pollReceive(&packet) && receiver.radio_.reads == 1); } std::puts("Linux LR2021 production RX: damaged-frame rejection with/without RX_DONE, fatal reset/offline, progress preservation and valid packet acceptance passed"); } diff --git a/apps/linux_lvgl/tests/linux_reticulum_radio_announce_test.cpp b/apps/linux_lvgl/tests/linux_reticulum_radio_announce_test.cpp new file mode 100644 index 00000000..1a18869c --- /dev/null +++ b/apps/linux_lvgl/tests/linux_reticulum_radio_announce_test.cpp @@ -0,0 +1,64 @@ +#include "chat/infra/reticulum/path_request_wire.h" +#include "chat/linux_reticulum_radio_announce.h" +#include +#include +#include +#include + +int main() +{ + auto path = std::filesystem::temp_directory_path() / ("trailmate-radio-announce-" + std::to_string(getpid())); + std::filesystem::create_directory(path); + { + platform::linux_runtime::LinuxReticulumIdentity identity(path / "identity.sqlite3"); + assert(identity.begin()); + trailmate::linux_app::LinuxReticulumRadioAnnounce announce(identity); + struct Sent + { + int count = 0; + bool ok = true; + std::vector packet; + } sent; + auto send = [](void* context, const uint8_t* bytes, size_t size) + { + auto& sent = *static_cast(context); + ++sent.count; + sent.packet.assign(bytes, bytes + size); + return sent.ok; + }; + announce.configure(true, "Radio only", 100); + announce.tick(5099, 1794130000, send, &sent); + assert(sent.count == 0); + announce.tick(5100, 1794130000, send, &sent); + assert(sent.count == 1); + chat::reticulum::ParsedPacket packet; + assert(chat::reticulum::parsePacket(sent.packet.data(), sent.packet.size(), &packet)); + assert(packet.packet_type == chat::reticulum::PacketType::Announce); + assert(std::memcmp(packet.destination_hash, identity.destinationHash().data(), 16) == 0); + announce.tick(5101, 1794130000, send, &sent); + assert(sent.count == 1); + uint8_t request[chat::reticulum::kPathRequestPacketSize], tag[16] = {1}; + size_t size = sizeof(request); + assert(chat::reticulum::buildPathRequest(identity.destinationHash().data(), tag, request, &size)); + assert(announce.receive(request, size, 5200, 1794130001, send, &sent)); + assert(sent.count == 2); + assert(chat::reticulum::parsePacket(sent.packet.data(), sent.packet.size(), &packet)); + assert(packet.context == uint8_t(chat::reticulum::PacketContext::PathResponse)); + announce.receive(request, size, 5201, 1794130001, send, &sent); + assert(sent.count == 2); + announce.configure(false, "Radio only", 6000); + announce.tick(700000, 1794130002, send, &sent); + assert(!announce.receive(request, size, 700000, 1794130002, send, &sent)); + assert(sent.count == 2); + announce.configure(true, "Retry", 8000); + sent.ok = false; + announce.tick(13000, 1794130003, send, &sent); + assert(sent.count == 3); + announce.tick(13001, 1794130003, send, &sent); + assert(sent.count == 3); + sent.ok = true; + announce.tick(43000, 1794130004, send, &sent); + assert(sent.count == 4); + } + std::filesystem::remove_all(path); +} diff --git a/apps/linux_lvgl/tests/linux_tmap_creator_ui_test.cpp b/apps/linux_lvgl/tests/linux_tmap_creator_ui_test.cpp index 070df684..2922357e 100644 --- a/apps/linux_lvgl/tests/linux_tmap_creator_ui_test.cpp +++ b/apps/linux_lvgl/tests/linux_tmap_creator_ui_test.cpp @@ -28,6 +28,7 @@ struct Service final : tc::Service tc::Snapshot state; tc::Plan received; bool source_ready = false; + tc::AreaSearchSnapshot area_state; Service() { state.producer_ready = true; } std::vector sources() override { @@ -53,6 +54,14 @@ struct Service final : tc::Service return true; } tc::Snapshot snapshot() override { return state; } + bool searchAreas(const std::string& query) override + { + area_state.query = query; + ++area_state.revision; + area_state.results = {{"R10", "Test area", "Test area, Yunnan, China", "CN", 102.70, 25, 102.71, 25.01}}; + return true; + } + tc::AreaSearchSnapshot areaSearch() override { return area_state; } void cancel() override { state.busy = false; @@ -136,13 +145,11 @@ int main(int argc, char** argv) lv_obj_send_event(found[i], LV_EVENT_VALUE_CHANGED, nullptr); } }; - click("Next"); + click("Coordinates"); fill({"102.70", "25", "102.71", "25.01"}); click("Next"); click("Next"); - fill({"Created on device", "device-area"}); - click("Next"); - fill({"device-area.tmap", "1"}); + fill({"Created on device", "device-area.tmap"}); click("Next"); if (argc > 2) { @@ -152,7 +159,7 @@ int main(int argc, char** argv) host.saveScreenshot(argv[2]); } click("Create"); - assert(service.received.source == "/sources/test.osm.pbf" && service.received.name == "Created on device" && service.received.west == 102.70 && service.received.max_zoom == 14); + assert(service.received.download_source && service.received.name == "Created on device" && service.received.west == 102.70 && service.received.max_zoom == 14); click("Close"); assert(closed == 1 && service.state.busy); extensions::ui::tmap_creator::open( @@ -234,10 +241,11 @@ int main(int argc, char** argv) activate("Close"); press(SDLK_t); assert(find_label(root, "Next")); + press(SDLK_TAB); // Search is now the initial editor; leave it before H Help. press(SDLK_h); assert(find_label(root, "Activate Next / Back / Create")); press(SDLK_h); - activate("Next"); + activate("Coordinates"); std::vector editors; fields(root, editors); assert(editors.size() == 4); @@ -249,7 +257,6 @@ int main(int argc, char** argv) assert(!std::strcmp(lv_textarea_get_text(editors.front()), "ht")); assert(!find_label(root, "Activate Next / Back / Create")); press(SDLK_ESCAPE); - activate("Close"); assert(find_label(root, "Extensions")); ui_exit_active_app(root); platform::linux_runtime::stop_resource_pack_tasks(); diff --git a/apps/linux_lvgl/tools/build_cm0_native_release.sh b/apps/linux_lvgl/tools/build_cm0_native_release.sh new file mode 100644 index 00000000..659281aa --- /dev/null +++ b/apps/linux_lvgl/tools/build_cm0_native_release.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +# CM0 uses Raspbian's GCC 14 mutex ABI, not generic Debian's ARMHF ABI. +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +work="${TRAIL_MATE_CM0_RELEASE_WORK:-/tmp/trailmate-cm0-release}" +root="${TRAIL_MATE_CM0_ROOTFS:-$work/rootfs}" +jobs="${TRAIL_MATE_PACKAGE_JOBS:-4}" +producer="${TRAIL_MATE_TMAP_PRODUCER_DIR:?A published linux-arm TMAP producer is required}" +test "$(id -u)" = 0 +test "$root" != / && test "$root" != "$repo" +test -f "$producer/producer-manifest.json" +mkdir -p "$work" "$repo/build/cm0-deb" + +if [ "${1:-}" != --namespace ]; then + if [ ! -f "$root/etc/os-release" ]; then + curl --fail --location --retry 3 https://archive.raspbian.org/raspbian.public.key -o "$work/raspbian.key" + fingerprint=$(gpg --show-keys --with-colons "$work/raspbian.key" | awk -F: '$1=="fpr" {print $10; exit}') + test "$fingerprint" = A0DA38D0D76E8B5D638872819165938D90FDDD2E + gpg --batch --yes --dearmor -o "$work/raspbian.gpg" "$work/raspbian.key" + debootstrap --arch=armhf --variant=minbase --foreign --include=raspbian-archive-keyring \ + --keyring="$work/raspbian.gpg" trixie "$root" https://archive.raspbian.org/raspbian + chroot "$root" /debootstrap/debootstrap --second-stage + fi + exec unshare --mount --pid --fork --propagation private bash "$0" --namespace +fi + +grep -q '^ID=raspbian$' "$root/etc/os-release" +test "$(chroot "$root" dpkg --print-architecture)" = armhf +mkdir -p "$root/proc" "$root/workspace" "$root/tmap-producer" +mount -t proc proc "$root/proc" +mount --bind "$repo" "$root/workspace" +mount -o remount,bind,ro "$root/workspace" +mount --bind "$producer" "$root/tmap-producer" +mount -o remount,bind,ro "$root/tmap-producer" +trap 'umount "$root/tmap-producer"; umount "$root/workspace"; umount "$root/proc"' EXIT +printf '#!/bin/sh\nexit 101\n' > "$root/usr/sbin/policy-rc.d" +chmod 755 "$root/usr/sbin/policy-rc.d" +printf 'deb https://archive.raspbian.org/raspbian trixie main\n' > "$root/etc/apt/sources.list" +chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive /bin/bash -eu -c ' + apt-get -o APT::Update::Error-Mode=any update + apt-get install -y --no-install-recommends ca-certificates build-essential g++-14 cmake ninja-build pkg-config dpkg-dev git curl python3 python3-dev python3-numpy pybind11-dev gnuradio gnuradio-dev libgsl-dev libsimde-dev libsdl2-dev libavformat-dev libavcodec-dev libavutil-dev libswresample-dev libswscale-dev libaribb24-dev ffmpeg libcurl4-openssl-dev libarchive-dev libsqlite3-dev libssl-dev libjpeg-dev libpng-dev libbluetooth-dev libicu76 +' +chroot "$root" /usr/bin/env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin HOME=/root LANG=C.UTF-8 \ + CC=gcc-14 CXX=g++-14 JOBS="$jobs" /bin/bash -eu -c ' + python3 /workspace/apps/linux_lvgl/tools/build_oneseg_backend.py --cm0 --work-dir /build/gr-isdbt --jobs "$JOBS" + python3 /workspace/apps/linux_lvgl/tools/package_oneseg_backend.py --backend-work /build/gr-isdbt --output /build/release-packages + cmake -S /workspace/apps/linux_lvgl -B /build/release-application -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \ + "-DCMAKE_C_FLAGS=-march=armv7-a -mfpu=vfpv3-d16 -mfloat-abi=hard" \ + "-DCMAKE_CXX_FLAGS=-march=armv7-a -mfpu=vfpv3-d16 -mfloat-abi=hard" \ + -DTRAIL_MATE_LINUX_DEVICE_CONFIG=/workspace/boards/t_display_cm0/package.cmake \ + -DTRAIL_MATE_LINUX_ENABLE_ONESEG_MEDIA=ON -DTRAIL_MATE_LINUX_DEB=ON -DBUILD_TESTING=OFF \ + -DTRAIL_MATE_TMAP_PRODUCER_DIR=/tmap-producer \ + -DTRAIL_MATE_ONESEG_GENERATE_BROADCAST_FONT=OFF \ + -DTRAIL_MATE_ONESEG_BROADCAST_FONT_FILE=/workspace/packs/ja-broadcast/fonts/ja-broadcast-cjk/font.bin + cmake --build /build/release-application --parallel "$JOBS" + cpack --config /build/release-application/CPackConfig.cmake -G DEB -B /build/release-packages +' +cp "$root"/build/release-packages/*.deb "$repo/build/cm0-deb/" diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index f93cd7ed..a6c3e6b8 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -218,6 +218,17 @@ if(BUILD_TESTING) add_test(NAME trailmate_chat_service_smoke COMMAND trailmate_chat_service_smoke) + get_target_property(auto_reply_sources trailmate_chat_service_smoke SOURCES) + list(REMOVE_ITEM auto_reply_sources "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_chat_service_resend.cpp") + add_executable(trailmate_auto_reply_observer_test + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_auto_reply_observer.cpp" + ${auto_reply_sources}) + target_include_directories(trailmate_auto_reply_observer_test PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_auto_reply_observer_test PRIVATE cxx_std_17) + add_test(NAME trailmate_auto_reply_observer_test COMMAND trailmate_auto_reply_observer_test) + add_executable(trailmate_chat_delivery_message_projection_smoke "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_chat_delivery_message_projection.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp" diff --git a/boards/t_display_cm0/package.cmake b/boards/t_display_cm0/package.cmake index 123ed62d..16f31eae 100644 --- a/boards/t_display_cm0/package.cmake +++ b/boards/t_display_cm0/package.cmake @@ -13,7 +13,7 @@ set(CPACK_DEBIAN_PACKAGE_DESCRIPTION set(TRAIL_MATE_DEVICE_DEBIAN_DEPENDS "libc6 (>= 2.38), libstdc++6 (>= 14), libgcc-s1, libsdl2-2.0-0 (>= 2.0.18), libcurl4t64, libarchive13t64, libjpeg62-turbo, libpng16-16t64, libsqlite3-0, libssl3t64, ca-certificates, network-manager, systemd, init-system-helpers (>= 1.54~), kmod, python3, wlopm") if(TRAIL_MATE_TMAP_PRODUCER_DIR) - string(APPEND TRAIL_MATE_DEVICE_DEBIAN_DEPENDS ", libicu76") + string(APPEND TRAIL_MATE_DEVICE_DEBIAN_DEPENDS ", libicu76, osmium-tool, gdal-bin, unzip") endif() if(TRAIL_MATE_LINUX_ENABLE_ONESEG_MEDIA) if(NOT TARGET trailmate_linux_oneseg_media) diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index 82bc8910..2912f57f 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -204,6 +204,7 @@ set(TRAIL_MATE_LINUX_COMMON_SOURCES "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/node_identity.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/geocaching_draft_store.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/tmap_creation.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/tmap_area_search.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/hostlink_geocaching_drafts.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/geocaching_crypto.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/geocaching_record_store.cpp" @@ -943,6 +944,8 @@ function(trailmate_add_linux_ui_shell target_name common_target) ) if(UI_UX_PACK STREQUAL "cardputer_compact") target_compile_definitions(${target_name} PRIVATE TRAIL_MATE_CARDPUTER_ZERO_LINUX=1) + target_sources(${target_name} PRIVATE + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/cardputer_cap.cpp") elseif(UI_UX_PACK STREQUAL "t_display_p4_touch") target_compile_definitions(${target_name} PRIVATE TRAIL_MATE_UI_P4_TOUCH=1) target_sources(${target_name} PRIVATE diff --git a/docs/engineering/cardputer-zero-device-validation-20261108.md b/docs/engineering/cardputer-zero-device-validation-20261108.md new file mode 100644 index 00000000..25d0cd31 --- /dev/null +++ b/docs/engineering/cardputer-zero-device-validation-20261108.md @@ -0,0 +1,382 @@ +# Cardputer Zero device validation — 2026-11-08 + +For the current communication, UI, sleep and storage acceptance, see the later +[paired three-protocol qualification](linux-paired-mesh-validation-20261108.md). +It supersedes the historical outstanding private-chat items recorded below. + +## Accepted GPS scope + +At 17:06 CST the user explicitly accepted satellite reception as sufficient for +GPS acceptance. This criterion is met: current NMEA traffic is valid, GSV records +contain visible satellites and signal values, and the real GPS widget displays +the satellite plot and separate used/visible counts. A successful position fix +is not required for this deployment's GPS acceptance and no longer blocks it. +The receiver still has no valid position; historical fix investigations below +remain evidence, not an outstanding requirement. Real private-chat peer receipt +or a delivery ACK remains unconfirmed and was not waived by this scope update. + +## Source and installed artifact + +The later paired-device session superseded the package snapshot below: Zero +now runs `1.0.1+cardputer.20261108.11`, including the SX1262 receive-IRQ restore +fix. Physical NodeInfo discovery and private text with delivery ACKs passed in +both directions against T-Display-CM0 on CN/478.875 MHz. See the +[paired-device validation record](linux-paired-meshtastic-validation-20261108.md) +for final package hashes, driver fixes and device evidence. Earlier `.10` +map, framebuffer, UI and GPS results below remain historical qualification. + +The working checkout was fast-forwarded from `05d75d66` to +`4732374d0457429db16bb8d70e02dcfdf4a9ca89` before building the adaptation. +Existing local changes were preserved. The deployed package includes the +uncommitted Cardputer menu, widget, Cap power/presence, magnetic heading and +framebuffer presentation changes in this checkout. + +- Debian package: `trailmate-cardputer-zero` +- Installed version: `1.0.1+cardputer.20261108.10` +- Architecture: `arm64` +- Package SHA-256: `c1e8dc5de19df385ca6e1fad0268b8db88b8a26a5ab55688d3e35dc61315ed53` +- Device OS: Debian 13, kernel `6.18.39+rpt-rpi-v8` +- Display: `/dev/fb0`, 320 × 170, RGB565, 640-byte stride +- Keyboard: `/dev/input/cardputer-zero-internal` + +This OS runs APPLaunch directly on the framebuffer. Its explicit system +environment selects `TRAIL_MATE_DISPLAY_BACKEND=fbdev`, `/dev/fb0` and the +keyboard alias. The package's Wayland default was retained. Installation, +runtime library resolution, normal APPLaunch launch, keyboard focus movement +and physical backlight setting were checked on the device as user `pi`. + +## Menu and widgets + +The menu uses the shared Pager grid, adapted to four columns and two visible +rows. All 13 current application routes remain registered. V opens/closes the +widget feed; Up/Down or W/S select a page, and Left/Right or A/D plus Enter +operate supported widget controls. + +Seven widget pages were captured on the actual framebuffer: Mesh, GPS, Recent, +Compass, Agenda, Music and Controls. The Linux magnetic sensor runtime reads +the BMM150 IIO device in its existing background sensor worker. The compass +reports magnetic heading, explicitly labelled uncalibrated and requiring the +device to be held level. True north, tilt compensation and calibration accuracy +were not established by this test. + +## Framebuffer flashing + +The old presenter cleared the live framebuffer and repainted it byte by byte +on every presentation request, including unchanged frames. The replacement +converts into private native-format storage, publishes with a contiguous copy, +and skips conversion and framebuffer writes when the Canvas pixels are +unchanged. + +A regression fixture verifies 1,000 unchanged presentations produce one +commit. A separate reader samples alternating nonzero frames to detect the +previous clearing interval. The device was also sampled without pausing its +UI process: + +| Scene | Duration | Samples | Distinct frames | Maximum zero-byte fraction | Process CPU, one core | +| --- | ---: | ---: | ---: | ---: | ---: | +| Idle menu | 6.019 s | 283 | 1 | 0 | 33.89% | +| Compass widget | 6.002 s | 283 | 5 | 0 | 34.65% | + +The earlier device run consumed approximately 62% of one core. These samples +support elimination of the observed live-clear flashing; they do not prove +tear-free scanout or optimal total background CPU usage. The main UI thread +used approximately 4.5% of one core in a separate short thread sample; remaining +worker activity is a further performance investigation. + +## Cap power, presence and GPS + +The Cap was reported physically attached. Its external 5 V supply was initially +off. Enabling the OS-owned `ext_5v_out` endpoint changed the SX1262 from +all-zero readback to a responding radio, and enabled valid GPS NMEA traffic. +The shell now owns this supply for the service lifetime and restores the +previous value after workers stop. A scoped udev rule grants the GPIO group +write access to this attribute. + +Presence uses a real radio response or GPS bytes. An eight-second startup +grace period precedes one brief notice when neither responds. Permission and +resource conflicts have a distinct notice; disabled services do not imply a +missing Cap. The missing-device scenarios were covered by the focused test +fixture. A physical removal test was not performed; the module must not be +hot-swapped. + +GPS parsing was verified, but the receiver had no position fix and reported +`ANTENNA OPEN`. Navigation accuracy and satellite acquisition are consequently +unverified. + +## Meshtastic CN radio exchange + +The peer was reported to use CN, default LongFast and the default PSK. A +temporary diagnostic executable linked to the same application service and +radio libraries exercised the existing business APIs. MQTT was disabled. +The saved database was backed up on the device before changing configuration. + +Actual derived settings were CN, 478.875 MHz, slot 35, bandwidth 250 kHz, SF11, +CR 4/5, sync word `0x2B`, preamble 16, primary channel hash `0x08` and 5 dBm TX. +Local identity was `!4BE5DDEF`, `CZ / Cardputer Zero`. + +The diagnostic sent a NodeInfo broadcast, a NodeInfo broadcast with +`want_response`, and a marked broadcast text. Driver counters advanced to +TX 3 with no reported TX timeout. No received frames, peer NodeInfo, ACK or +private-chat exchange were observed during approximately 150 seconds. A +private message was conditional on discovering a peer; none was sent because +no peer was received. + +The diagnostic temporarily stopped the UI to obtain exclusive radio ownership. +This left the last framebuffer image visible and was mistaken for a freeze. +APPLaunch and Trail Mate were restored, and keyboard movement was checked. +After restoring the UI, its independent LoRa master switch was found disabled +and re-enabled at 11:45:53 CST. A subsequent peer-triggered NodeInfo broadcast +was monitored with the normal UI running; RX remained zero through the +11:48:53 CST sample. The app was left running with LoRa enabled on the above +CN settings. + +The official [Cap LoRa-1262 documentation](https://docs.m5stack.com/en/cap/Cap_LoRa-1262) +specifies an 868–923 MHz module/antenna range and a PI4IO-controlled antenna +switch. CN's derived 478.875 MHz falls outside that advertised module band. +This is a possible hardware compatibility cause, not a proven diagnosis. +The documented I2C address `0x43` did not respond on the device's exposed I2C +buses, including a short reversible check with the Grove 5 V supply enabled. +No undocumented GPIO routing changes were made. The exact fitted Cap revision, +antenna band and peer's actual tuned frequency still need reconciliation. + +Chip TX completion establishes the local command/driver path only. This run +does **not** establish successful over-the-air delivery, NodeInfo exchange, +PKI interoperability or private-chat delivery. + +## Follow-up: received NodeInfo and keyboard chat + +The normal application subsequently received three NodeInfo packets at +11:52:26, 11:52:53 and 11:52:54 CST. All decoded to `!A1B3B57C`, +`B57C / TM-B57C`, with RSSI between -88 and -95 dBm and SNR between 5.8 and +7.5 dB. The peer's broadcast text `hi` was also decoded, stored and displayed +in Chat. This establishes real CN reception and NodeInfo processing; the +earlier advertised-band concern is not a demonstrated explanation for the +initial lack of packets. + +Using the ordinary Contacts → Nearby → Chat route, the app transmitted +`cardputer zero private test please reply` to that peer using PKI. Its status +was Sent, with no ACK observed. After installing `.4`, the real keyboard path +typed `new keyboard send test`, moved directly to Send with one Tab press, +and sent it with Enter. Exactly one corresponding durable message record +was created, and the radio logged a PKI direct transmission to the correct +peer. Peer receipt/private reply remains unconfirmed. + +At 12:17:04 CST, `.5` also broadcast the existing `CZ / Cardputer Zero` +NodeInfo again through the normal Settings save/application command path, +without changing the node name or regenerating its identity. This gives the +currently listening peer another opportunity to obtain the local public key. + +The Cardputer Compose action bar was reduced from 44 to 26 pixels, with +20-pixel-high buttons. Conversation actions occupy 24 pixels. A visible +`Tab to Send, then Enter` hint describes keyboard submission, while Enter +inside the text field remains a newline. A focused regression fixture also +exposed and corrected two send callbacks for a single physical Enter key +press; the Cardputer now uses LVGL's ordinary release/click event once. + +The remaining hot background thread was identified as `LinuxAgendaSource`. +Its 100 ms idle sampling repeatedly read timezone settings from SQLite. +The idle wait is now one second, with commands and presentation changes +retaining immediate wakeups. The regression first failed on the old idle +sampling rate and passed after the change; reminders were also tested without +an explicit refresh request. On-device `.4` Chat sampling measured 15.33% of +one CPU core over 6.003 seconds, with 282 samples and one distinct frame. + +The OS exit gesture was tested separately. `.4` did not exit on a long Esc +hold because the framebuffer input adapter did not track that hold. `.5` +tracks the hold, ignores repeats for timing, and cancels it after a lost-input +notification. The on-device hold test exited successfully, restored EXT5V +to zero and left APPLaunch active. Normal APPLaunch re-entry was verified. + +## Make TMAP: global online workflow + +Version `.10` bundles the unchanged Center producer built from clean +`trail-mate-center` revision `efa12462a526aabad7212cbadfa537ace0b7dbd6`. +The shared wizard follows its area-first workflow: administrative search, +map content/detail, suggested save metadata, review, and creation/result. +Coordinates and a local PBF are optional alternatives. Photon supplies global +administrative extents; namesakes include their country and parent context. +Only real extents are accepted. Creation uses the enclosing rectangle rather +than clipping to the administrative polygon. + +The physical keyboard successfully searched `vatican`, selected Vatican City +(`R36989`, VA), and advanced through the entire review. The selection was +approximately 1.0 by 0.8 km, with Balanced choosing zoom 13–14 and four tiles. +The review estimated 0.5 MiB of raw pixels and 257.5 MiB working space, against +approximately 20.5 GiB free. Compact controls use the existing amber theme; +Tab and Enter reach every action. The completion action opens the generated +area in the existing Map renderer, and Back returns to the result. + +Online source acquisition is streamed XML followed by `osmium` conversion, +then Center build and verification. Jobs are capped at 2500 square kilometres, +64 MiB of downloaded source and a default 128 MiB output budget. These are +small-device limits, not a claim that arbitrary countries can be generated. + +Actual network validation exposed direct-network failures. The original +`overpass-api.de` endpoint timed out and also returned HTTP 502. A diagnostic +request to Private.coffee successfully downloaded 4,898,944 bytes in 24.287 s, +but the subsequent `.8` application job timed out after its 150 s limit. +The UI remained responsive, showed the failure and Retry/edit, and removed its +job workspace. App RSS peaked at 38,228 KiB in that run; the producer was never +started, so this is not a successful production-memory measurement. Repeating +the exact request with IPv4 and POST also timed out; a second provider returned +HTTP 504. These failed direct requests did not pass online acceptance; the +subsequent explicit-proxy run below did. + +The user's existing desktop Clash was subsequently reused through a loopback-only +SSH reverse forward: device `127.0.0.1:17897` to the tool host's +`127.0.0.1:7897`. An explicit-proxy request returned Photon HTTP 200 in 2.156 s +and the single-output Overpass response, 3,491,251 bytes, HTTP 200 in 84.835 s. +The separate user-specified LAN host `192.168.50.210` answers ping, but its +7897 TCP port still timed out in the latest check. No public proxy listener or +Clash subscription/configuration copy was created. The SSH bridge requires the +tool host and its Clash to remain running; it is not a standalone proxy installed +on Zero. Trail Mate's user environment was configured for this working bridge, +with the previous environment backed up on the device. The initial in-memory +password bridge was replaced by a hidden, reconnecting background process using +a dedicated restricted SSH key. That key cannot run a shell, allocate a PTY, +forward an agent or run a user RC; its reverse listener is restricted to +`127.0.0.1:17897`. Its private half remains in the local SSH directory, and no +login password is stored. The restart helper and logs live beneath +`%LOCALAPPDATA%\TrailMate\CardputerZeroProxy`; no automatic logon task is installed. + +The ordinary `.9` keyboard workflow then completed a real online job through +this Clash path. The monitor recorded 115.281 s through completion, a sampled +app peak RSS of 38,564 KiB and producer peak RSS of 145,052 KiB. The installed +`maps/tmap/osm/area-R36989-osm.tmap` is 10,342,400 bytes. No local PBF was selected: +the application downloaded its own 3,491,251-byte XML source and converted it. +The private job directory was removed, leaving its namespace owner marker. +View map displayed actual Vatican roads and labels at zoom 14 on the framebuffer; +Esc returned to the successful result with View map focused. + +Version `.10` additionally replaces the zero-byte download caption with +`Waiting for map server (N s)` until bytes arrive, so server processing does not +look like a stalled transfer. Its source/HTTP/Center/SDK integration, keyboard +flow and compact/P4 layout regressions all passed again; the preceding full +32-test suite and separate agenda regression passed on `.9`. + +The final `.10` package was installed and launched normally through APPLaunch. +A second actual-device online job, revision 2, completed in 125.114 monitored +seconds. Its XML source was 3,540,548 bytes and its final +`maps/tmap/osm/vaticanr2-osm.tmap` is 10,350,592 bytes. The app's sampled peak RSS +was 37,204 KiB; the producer's was 143,672 KiB. The actual framebuffer showed +`Waiting for map server (5 s)` before transfer. The private workspace was again +removed after successful production, verification and installation. +View map and Esc were repeated on this final build, and both displayed the +generated map and returned to the successful result. The device was left in +the generated map view. + +The retained `built-source-manifest.json` records hashes for 52 modified compiled +inputs and verifies that all match the deployed build mirror. The final local +package directory retains `.10` only; obsolete packages, ad hoc regression +binaries/objects, the producer source tar and unused offline-catalog research +dependencies were removed after validation. + +Real keyboard inspection also exposed an open-dropdown navigation conflict: +the shared Pager router consumed Up/Down before the popup could select an option. +The new regression failed on the previous code (exit 134), then passed after +open dropdowns were excluded from ordinary focus navigation. Version `.9` +includes that fix and popup theme/font adjustments. + +The installed package already contains the concurrent node-discovery changes +subsequently committed as `8bc50ffc22aab1ea2c88472c9dcc3050e61590d4`. +Its 16 committed files were compared byte-for-byte with the build source mirror; +all matched. The package additionally contains this task's uncommitted changes. + +## Automated checks + +The final selected native suite passed **32/32** tests, including the 13 application +help/navigation cases, calculator, menu profile, Cap presence/power, widget +feed, keyboard chat submission/compact layout, short/long Esc handling, +magnetic heading, IIO, framebuffer presentation, Meshtastic ACK tracking, +TMAP keyboard flow, online source acquisition and packaging checks. The +separate full Linux agenda-source regression also passed. After the final +single-output Overpass query adjustment, the online integration test was rerun +successfully against the real Center producer and SDK tile/search reader. +The five-page keyboard test and both compact/P4 creator layout regressions +also passed. These localhost fixtures establish program behaviour; the separate +actual-device network and map-display acceptance is described above. +The final cross-package build exited successfully. `git diff --check`, the +platform UI boundary checker and root build artifact checker passed. + +GitNexus refresh was attempted twice and failed on parser worker timeouts. +Available impact results were LOW where symbols were indexed, and UNKNOWN +for new/unindexed symbols. Its change report is therefore partial, not proof +of complete call-graph coverage. No commit was made. + +## Remaining physical acceptance, 14:31 CST + +A fresh completion audit confirmed `.10` running as PID 124101, Cap power enabled, +no creator job directories, and the loopback proxy listener still present. +GPS sentences continued to parse, but GSV reported zero visible satellites, +GLL remained invalid, and GPTXT still reported `ANTENNA OPEN`. This is not a +successful position-fix test. Physical antenna/sky-view confirmation was requested. + +The radio remained in CN/LongFast receive monitoring. Its most recent actual +received peer records were the earlier NodeInfo and `hi`; no later private reply +or Routing ACK was found, and outgoing private chat records remained Sent. +Fresh peer availability and an incoming private message were requested before +another over-the-air private exchange. The UI was left running throughout. + +The persistent bridge's dedicated key was also tested independently: shell +execution, another reverse-listener port and a direct tunnel to another local +target were all denied by the SSH server. The intended existing loopback bridge +remained available. LAN proxy `192.168.50.210:7897` still timed out on a new probe. + +## Outdoor GPS follow-up + +After the user moved the device outdoors, the receiver's output changed: +UTC time/date became valid, GP GSV reported four visible satellites with signal +values, and BD GSV reported one. Seven samples from 14:38:17 through 14:40:17 CST +still had GGA quality 0 / satellites used 00, RMC status V, and no position. +The read-only observation used the application's existing log rather than +opening or interrupting its serial device. + +The current [official Cap LoRa-1262 documentation](https://docs.m5stack.com/en/cap/Cap_LoRa-1262) +specifies an ATGM336H-6N/AT6668 receiver with a built-in ceramic GPS antenna. +The earlier request to confirm an external GPS antenna was premature. +`ANTENNA OPEN` remains a receiver diagnostic, but it alone does not establish +that the built-in antenna is disconnected or that hardware is defective. +Acceptance requires a genuine receiver fix and valid coordinates. +The Linux NMEA path opens the serial device read-only and does not send +constellation-configuration commands. The exact outdoor sky view was requested +before attributing the remaining lack of a fix to software or hardware. + +The user clarified that the current position is a balcony or window. At +14:42:53 CST the latest GP GSV reported three visible satellites, with GGA +quality 0 and satellites used 00. This placement does not rule out obstruction +or reflected signals; [GPS.gov](https://www.gps.gov/gps-accuracy) identifies +building blockage and reflected signals as common positioning problems. +An unobstructed outdoor comparison, retaining power and allowing a short +acquisition window, is the next physical test. No receiver reset, forced fix, +constellation rewrite or application restart was performed. + +On the explicitly resumed run, the app was still PID 124101 at 16:52 CST. +The retained rotating logs covered 16:43:46–16:52:33 and contained 527 GGA and +527 RMC records, with no valid fix in either set. Visible counts were GP 6, +GL 1, GA 1 and BD 3, but several entries had no signal value. At 16:54:21, +the GP entries at azimuths 020 and 038 had empty signal values, while reported +signals were concentrated toward azimuths 143–227. This is consistent with +restricted sky view, not proof of a hardware defect or a complete diagnosis. + +The official product photos locate the built-in antenna beneath the side marked +`GNSS / ANTENNA Inside` next to the blue location icon. An unobstructed outdoor +comparison with this face toward the sky was requested. No application or +receiver changes were made while awaiting that physical comparison. The latest +peer reception and private-delivery counts were unchanged, and the LAN proxy +port still timed out; the existing SSH/Clash bridge remained running. + +The user then confirmed that only balcony placement was available and adjusted +the GNSS face toward the open side, keeping the device still. A new bounded +observation ran from 16:57:04 through 17:00:04 CST and exited normally after ten +samples. Every sample reported GP 7, GL 1, GA 1 and BD 3 visible entries, with +seven entries carrying a signal value and the strongest ranging from 39 to 35. +All ten still had GGA quality 0, satellites used 00 and no valid combined +GGA/RMC fix. The raw result is retained on the device as a mode-0600 JSONL file. + +The live GPS widget was opened through ordinary keyboard navigation without +restarting the app or receiver. Its satellite plot displayed received satellites +and `Used 0/12`, `NO FIX`, matching the receiver data. This establishes working +serial reception and satellite-status presentation, not successful navigation. +The device was left on this widget so visible satellites and satellites used +are directly distinguishable. Further unrestricted-sky tests are unavailable +under the user's current physical constraint; the cause of no fix is not proven. diff --git a/docs/engineering/linux-paired-mesh-validation-20261108.md b/docs/engineering/linux-paired-mesh-validation-20261108.md new file mode 100644 index 00000000..51b52c7e --- /dev/null +++ b/docs/engineering/linux-paired-mesh-validation-20261108.md @@ -0,0 +1,179 @@ +# Paired Linux mesh validation — 2026-11-08 + +This record extends the earlier [Meshtastic qualification](linux-paired-meshtastic-validation-20261108.md). +Final installed versions are Zero `1.0.1+cardputer.20261108.20` (ARM64) and CM0 +`1.0.1+mesh.20261108.10` (ARMHF). Both package builds and SHA-256-verified +installations completed successfully. + +Package SHA-256: + +- Zero `.20`: `1c8ae5c52be5b6c434ffedb637b0029e846c0b08c261940e68332b4a8a6a9437`. +- CM0 `.10`: `c9ca418f6c6cae24c162ad719b8874ceef2ab39a6c923cad9a41a5ae6b84c5bf`. +Both devices were physically in the same room, approximately 1–3 m apart. +The Cardputer Zero uses its SX1262 Cap and framebuffer UI; T-Display-CM0 uses +its LR2021 through normal RadioLib packet operations. Mesh qualification did +not use SDR/IQ streaming or the custom SDR interrupt interface. + +The base checkout is `8bc50ffc22aab1ea2c88472c9dcc3050e61590d4`, including the +preserved working changes. HTTPS `ls-remote` confirmed the same upstream main +revision after an SSH fetch transport failure. Device identities and private +keys remained on their devices. + +## Radio and protocol evidence + +| Protocol | Air configuration | Physical result | +| --- | --- | --- | +| Meshtastic | CN, LongFast, slot 35, 478.875 MHz, BW250, SF11, CR4/5, sync2B, default primary hash08 | Paired bidirectional private text and remote delivery ACKs passed. Both devices also decoded official firmware broadcasts. | +| MeshCore | 869.525 MHz, BW250, SF11, CR4/5, sync12, preamble16 | Signed identity discovery, bidirectional private text and remote delivery ACKs passed. | +| Reticulum | LoRa only, 869.525 MHz, BW125, SF9, CR4/5, sync12, preamble18 | Signed Announce discovery, bidirectional LXMF private text, fragmented longer text and delivery proofs passed without IP transport. | + +MeshCore identities were Zero `!95D5541B` and CM0 `!466B5FAE`. The private +texts `mc cm0 zero tm3` and `mc zero cm0 tm3` were received on the opposite +device and their originating rows reached Delivered. Protocol message IDs +differ between the MeshCore local and receiving ledgers and are not compared +as if they were identical wire identifiers. + +For Reticulum, CM0's `rt cm0 zero tm3` reached Delivered, and Zero's longer +`rt zero cm0 tm3 ...` reached CM0 and obtained a delivery proof. Physical +keyboard automation reordered part of the longer draft's digit suffix; the +claim is delivery of the actual recorded text, not equivalence to the intended +keystroke sequence. Full Reticulum destination identities were used for sends. + +## Official Meshtastic interoperability + +The user confirmed receipt in the official client of both broadcasts: + +- CM0: `cm0 official cn tm4`, packet `AA3D67B2`, sent at 20:18:01 CST. +- Zero: `zero official cn tm4`, packet `5EA5ABC1`, sent at 20:19:57 CST. + +Official peers `!A1B3B57C` and `!D917FE08` sent readable broadcasts and NodeInfo. +Repeated packet `9B2B8422` was decoded multiple times and stored once on CM0. +After the storage repair, Zero stored the official broadcast `Bye`, packet +`2651561A`, and the user confirmed an audible notification. That packet was +addressed to `FFFFFFFF`; it is not evidence of an incoming private exchange. + +Zero's first new PKI private attempt to `!A1B3B57C`, packet `5EA5ABC2`, received +an authenticated, addressed `NO_CHANNEL` NACK. It was marked Failed. Its cached +peer public key was older than the later received NodeInfo key. Linux omitted +the existing shared key-resynchronization policy already used by ESP32/nRF52. +The Linux routing handler now executes that policy on a matched `NO_CHANNEL` +or `PKI_UNKNOWN_PUBKEY` NACK. It consumes the pending ACK first so a duplicate +NACK cannot repeat the NodeInfo request. + +At 20:37:22 CST, Zero received fresh official NodeInfo and updated the peer key. +The next private text, `zero private official tm6`, packet `6D697301`, was +transmitted at 20:39:54 and acknowledged by `!A1B3B57C` at 20:39:56. The persisted +row reached Delivered, and the user confirmed the official client displayed it. +At 20:40:29, the official peer sent private `Hi`, packet `48CBBE29`, addressed +to `!4BE5DDEF`. Zero decrypted and stored it once and transmitted the Routing +ACK at 20:40:30. The live conversation showed both the Delivered outbound text +and the incoming `Hi` without reopening the conversation. + +After the final `.20` installation, restart qualification and restoration of +the normal launcher/settings, Zero sent `zero private official tm7`, packet +`DAC68999`. The official peer ACK again produced a persisted Delivered row. +The final database contained 16 intact message rows. The final framebuffer +capture showed the received `Hi` and this newly Delivered message together. + +The shared protobuf encoder also set `want_response` while writing a present +`bitfield` with bit 1 clear. NodeInfo and application-data requests now encode +the response-request bit consistently with the existing ESP32/nRF52 adapters. +MQTT permission remains clear. A serialized-byte regression failed against the +old encoder and passed after the fix, covering both boolean values and reused +caller-owned scratch. The paired Linux NACK/ACK regression also passed. + +## Storage defect and acceptance + +Zero's old database became malformed during the session. A configuration +record contained bytes from a chat message and subsequently failed startup +validation. The complete damaged database and recovery artifacts were retained +privately on the device. A verified device-local baseline was installed, with +three actually received official broadcasts reconstructed from packet logs. +Some temporary test history was not recovered. The user explicitly waived +further historical recovery and requires healthy storage after the repair. + +An independent, deterministic regression exposed a Linux identity preflight +bug: opening and closing the SQLite inode directly removed the process's +existing POSIX database locks. The new check opens the database through SQLite, +then verifies ownership and sets private permissions through a pinned `O_PATH` +handle. Closing that handle preserves the SQLite locks. The old implementation +failed the lock regression; the new implementation passes. This establishes a +specific unsafe locking defect, without claiming forensic proof that it was the +only possible cause of the damaged database. + +The local X25519 pair and the device, MeshCore and Reticulum identities were +compared against the device-local evidence and retained unchanged. Private keys +and complete identity databases were not downloaded. + +Validation completed so far: + +- Identity round trip, file protection and replacement regressions passed. +- SQLite reserved-lock preservation passed, including the failure path when + another connection already owns the writer transaction. +- A 128-iteration stress test concurrently writes 4 KiB configuration records, + writes 128 messages on another SQLite connection, and opens 128 identity + instances. Integrity, every message, the complete final configuration buffer + and the unchanged identity public key passed. +- Long-chat/configuration/protocol round trips passed on the native host and + the cross-compiled test executed on the actual Zero. +- Subsequent fresh broadcasts and the official `Bye` were stored on Zero, and + its database integrity check returned `ok`. + +The cross-compiled reserved-lock and concurrent-storage tests subsequently ran +on the actual Zero and passed. CM0 completed three graceful restarts with +integrity `ok`, all 20 original message rows preserved and unchanged identities. +Zero then completed three startups, with graceful restarts between them, on +the final package. All 15 message rows and their statuses, including the +official private message and Delivered outbound record, survived unchanged. +All identities remained unchanged and every integrity check returned `ok`. +The normal APPLaunch service was restored and used to open the final version. +Both final running databases passed another read-only audit: configuration +length 4564 bytes, valid magic/version and Meshtastic protocol value 1. + +## UI, sleep and automatic replies + +Zero's topbar now reserves a complete compact status area, uses one line for +the conversation title with an ellipsis, and displays a compact help hint. +Actual screenshots show the full battery percentage beside a long peer name. +The default All inbox includes broadcasts. A new broadcast updated the preview +and unread count while the same message-list screen stayed open. + +The framebuffer composition now connects the asynchronous backlight worker to +the shared screen state machine. Its first wake input is consumed; a subsequent +Enter/Space resumes the previous screen. Shutdown restores the saved brightness. +The native integration regression and actual backlight-off/wake checks passed. + +Automatic reply remains sleep-only. A shared incoming-message observer is used +by Linux and the ESP32 wrapper. Actual Reticulum tests demonstrated: + +- Zero awake: received `rt awake no reply tm3`, no automatic reply. +- Zero asleep with backlight zero: received `rt asleep auto tm3`, sent + `zero asleep tm3`, and obtained Delivered. +- CM0 asleep with its compositor output off: received `rt cm0 sleep test tm3`, + sent `cm0 asleep tm3`, and obtained Delivered. + +The temporary enabled/text and Always On timeout values were restored to the +exact original database records. Both final running devices passed a readback +comparison against their device-local pre-test settings. Automatic reply is +left disabled as originally configured, with no test reply text; enabling it +continues to use the sleep-only policy. + +## Cross-platform scope + +The valid-frame/rearm fixes were checked against the ESP-IDF radio pump. Packet +IRQ progress must not clear a frame before `readData`; damaged frames are +rejected, and a successfully read frame survives failure to arm the next receive. +A shared normal-radio standby/startReceive helper is used for LR2021 restart. +No SDR or IQ capture API is called by these mesh paths. + +The Reticulum contact-menu capability correction and automatic-reply observer +are shared UI/business changes. The SQLite descriptor fix is Linux-specific. +ESP32 already used the shared Meshtastic NACK resynchronization policy that is +now connected to Linux. Host production-code regressions and the ESP stack +hygiene check passed. No full ESP32 firmware build or physical ESP32 qualification +is claimed by this record. + +All started package builds and focused regressions reached terminal success. +The final read-only audits reported no CRC, header, timeout, invalid-frame or +read errors in either device's current Meshtastic radio counters. Both devices +remain in CN / LongFast with their normal identities and profiles. diff --git a/docs/engineering/linux-paired-meshtastic-validation-20261108.md b/docs/engineering/linux-paired-meshtastic-validation-20261108.md new file mode 100644 index 00000000..30ad7db1 --- /dev/null +++ b/docs/engineering/linux-paired-meshtastic-validation-20261108.md @@ -0,0 +1,131 @@ +# Cardputer Zero and T-Display-CM0 Meshtastic validation — 2026-11-08 + +This is the earlier paired-Meshtastic checkpoint. The later +[three-protocol, official-firmware and storage qualification](linux-paired-mesh-validation-20261108.md) +supersedes its package versions and outstanding items. + +## Scope and devices + +The user authorized simultaneous physical testing of both Linux devices, placed +in the same room approximately 1–3 m apart. Both use Meshtastic CN, LongFast, +slot 35, 478.875 MHz, 250 kHz bandwidth, SF11, CR 4/5, sync word 0x2B, +preamble 16 and the default primary channel key (hash 0x08). MQTT was not used +for these exchanges. Node identities and private keys were retained; database +backups remain on the devices with mode 0600 and were not downloaded. + +| Device | Radio | Node identity | Installed package | +| --- | --- | --- | --- | +| Cardputer Zero | Cap LoRa-1262 / SX1262 | `!4BE5DDEF` / CZ | `trailmate-cardputer-zero 1.0.1+cardputer.20261108.11` (arm64) | +| T-Display-CM0 | LR2021 | `!F48DA2AE` / TM | `trailmate-cm0 1.0.1+mesh.20261108.3` (armhf) | + +The checkout was refreshed against `origin/main` at +`8bc50ffc22aab1ea2c88472c9dcc3050e61590d4` before the paired work. Existing +Cardputer adaptation changes were preserved. The packages include the relevant +working changes and the driver fixes described below. The CM0 build used the +existing native Raspbian armhf chroot to retain its C++ ABI and 1seg support; +the installed 1seg backend was not replaced. + +The user confirmed that the Cap is the standard version and explicitly asked +to continue using 478.875 MHz, which they regularly use successfully at home. +Its published 868–923 MHz specification was not treated as a blocker. +The successful CN exchanges below are actual device evidence; they do not +establish RF performance at other distances or in other environments. + +## Driver faults and repairs + +### CM0: receive restart during continuous RX + +The original CM0 package (`1.0.1+git4732374d`) reported a hardware RX count +without delivering the received NodeInfo to the protocol adapter. Diagnostic +builds captured the first failure: after RX_DONE (`0x40170`), RadioLib's receive +restart returned `-706` at the RX-path configuration command. It was invoked +while continuous RX was still active. Later hardware-error IRQs caused radio +reinitialization approximately every 0.4 seconds. + +`Lr2021Radio::pollReceive` now enters standby before restarting RX on valid +packets and all damaged/timeout/read recovery paths. A successfully read packet +is delivered once even when restarting RX fails; the existing error handling +marks the driver offline for recovery. Optional diagnostics record the operation, +return code, IRQ flags and transport health without dumping packet data. + +The regression extracts the production receive method and uses a fake that +rejects a restart while already receiving. It checks damaged-packet rejection, +fatal-error handling, standby ordering and one-time valid delivery when standby +or RX restart fails. The test passed, and subsequent physical packets were +decoded without the reinitialization loop. + +### Zero: TX IRQ mask retained in receive mode + +`Sx126xRadio::transmit` selected TX_DONE/TIMEOUT reporting before transmission, +then returned to RX with SetRx alone. The chip retained the TX IRQ mask, so +software could not observe RX_DONE after a send, including the automatic startup +NodeInfo. This explained the persistent zero receive count despite successful +transmission. + +All TX exit paths now restore RX_DONE, CRC, header-error and timeout reporting, +clear IRQs, and restart receive. A successful physical TX increments its counter, +but a failed receive restart is reported to the caller. The regression compiles +the production TX method against a command-state fake and checks both LoRa and +FSK after success, hardware timeout, host timeout, failed start and failed write, +as well as failed RX restart. All 12 cases passed in 8.01 seconds. + +## Physical results + +Times below are UTC on 2026-11-08; add eight hours for CST. + +| Evidence | Result | +| --- | --- | +| Zero NodeInfo `15F94CDA`, transmitted 10:03:00.331 | CM0 decoded `!4BE5DDEF / CZ / Cardputer Zero` at 10:03:01.374 and 10:03:03.072; the latter had RSSI −55.5 dBm. | +| Zero `.11` startup after a TX at 10:10:52.311 | Received a relayed copy at 10:10:55.369, confirming receive reporting remained active after TX. | +| CM0 NodeInfo after Zero `.11` installation | Zero decoded `!F48DA2AE / TM / T-Display-CM0` at 10:12:42.411. | +| CM0 private text `cm0 retry RF1813`, message `26B4F95C` | Zero stored the incoming plaintext and transmitted a Routing ACK at 10:16:16.264. CM0 persisted status `Delivered` (enum value 4). | +| Zero private reply `zero to cm0 rf1813`, message `044E2BCC` | CM0 stored the incoming plaintext with LoRa origin. Zero persisted status `Delivered` (enum value 4). | +| Zero keyboard workflow | Opened the received conversation, pressed S to compose, typed the reply, pressed Tab to focus Send, and pressed Enter. A live framebuffer capture shows both messages and Delivered. | + +The first CM0 private attempt did not decrypt. Zero requested fresh NodeInfo; +public-key fingerprints were then checked on both devices and matched each +other's local public key. The subsequent private text and reply succeeded. +That initial unsatisfied send remains in history; it was not relabeled as +delivered or deleted. No private-key material was printed or transferred. + +The CM0 Nearby view visibly displayed Cardputer Zero. Both incoming messages +and both delivery statuses were checked in the devices' own SQLite databases, +in addition to radio logs and live UI captures. The test environment contains +another relaying node, so these results demonstrate physical LoRa interoperability +and receipt, without claiming every packet took a single direct hop. + +## Startup configuration and artifacts + +Both canonical CM0 profiles had `runtime_mode=mesh` followed by an appended +`runtime_mode=local`. The duplicate local overrides were removed after device +backups. The existing KMS/1seg system service was left inactive as found; +the current desktop session uses the canonical capture profile through the +paired-test user service. Verbose SPI tracing was disabled when this normal +profile was restarted. Zero was installed with dpkg and reopened through its +existing APPLaunch entry. + +After the normal-profile restart, CM0 retained its identity and conversation +history. Another private message (`524A0CF9`, actual text `tart RF1813`) was +stored by Zero and acknowledged at 10:20:59.283; CM0 again persisted Delivered. +The automated UI input began before the cold compose screen was fully ready, +so this test's text was shorter than the intended draft; the record above uses +the actual transmitted and received text. Final read-only audits at 10:22:33–36 +reported CM0 PID 19332, Zero PID 264170, tracing disabled on both, and radio +counters CM0 RX 8 / TX 4 since restart and Zero RX 23 / TX 8. CRC, header, +timeout, invalid-length and read-error counters were all zero in these sessions. + +| Package | SHA-256 | +| --- | --- | +| `trailmate-cm0_1.0.1+mesh.20261108.3_armhf.deb` | `530253e271aab5f3dfbe3db4881b9e9d0f0c73297433145afd83403c11761cd2` | +| `trailmate-cardputer-zero_1.0.1+cardputer.20261108.11_arm64.deb` | `3678b6395f2ddd2a01a33bc81952c7fdc3ccd28809f4c923db04d426a0fc87d5` | + +All started builds completed with exit code 0. GitNexus upstream analysis +reported MEDIUM impact for the LR2021 shared error checker (12 direct methods) +and LOW for the receive/TX methods and test symbols. The graph did not identify +their virtual callers; physical verification was therefore essential. No commit +was created. The ESP stack hygiene check and focused whitespace checks passed. + +The previously accepted GPS criterion remains satellite reception and display; +a position fix is not required for this deployment. Earlier map creation, +framebuffer and keyboard evidence is retained in +[the Cardputer deployment record](cardputer-zero-device-validation-20261108.md). diff --git a/docs/engineering/linux-tmap-creation.md b/docs/engineering/linux-tmap-creation.md index 89e94061..546e4a8f 100644 --- a/docs/engineering/linux-tmap-creation.md +++ b/docs/engineering/linux-tmap-creation.md @@ -4,21 +4,53 @@ CM0 and Cardputer Zero expose **Extensions → Make TMAP** directly in the botto action bar, without opening Menu. It also opens directly with **T** while on Extensions; the button displays **T Make TMAP** and H Help lists the shortcut. Text editors keep literal T input. -The shared LVGL wizard -uses seven pages: local PBF source, WGS84 bounds, layers/zoom, map identity, -output/revision/budget, review, and progress/result. Touch text editing and keyboard -focus navigation use the existing shared UI contracts. Read-only text panels can -be scrolled with arrow keys. Closing the page leaves a job running; Cancel stops -it. Reopening the entry shows the current job, and a completed job offers New map. +The shared LVGL wizard uses five pages: global administrative area, map content, +save name, review, and progress/result. This follows Center's area-first authoring +flow. Administrative search is the default; Coordinates switches to explicit +WGS84 bounds and back. Results show country and administrative context to +distinguish namesakes. Only actual boundary extents are accepted; point-only +geocoding results are not turned into invented administrative boundaries. + +Enter a place name and press Enter or activate Search. Tab moves focus and Enter +activates the selected control. Map name, filename and output defaults are +prefilled after selecting an area. Advanced contains the stable package key, +revision, output budget and optional local PBF input. Balanced adjusts zoom for +the chosen area; Overview, Walking, Detailed and custom zoom remain available. +Read-only panels scroll with arrow keys. Closing the page leaves a job running; +Cancel stops it. Reopening shows the job, and a completed job offers View map +and New map. Before the first source bytes arrive, the job shows the elapsed +wait for the map server; it then reports received KiB and later producer stages. ## Sources and outputs -Put a local `.osm.pbf` file in `map-sources` beneath the application's configured -storage root. The source page displays that actual directory. Refresh scans in -the background and updates the list when finished. `maps/sources` and an explicit -`TRAIL_MATE_TMAP_SOURCE_ROOT` override are also supported. The wizard does not -download regional PBF extracts or infer a boundary from a country name. Enter -explicit bounds and split areas crossing the antimeridian. +The normal flow downloads OSM data for the selected enclosing rectangle into its +private job workspace. The installed `osmium-tool` converts that XML stream to +PBF before the existing Center producer runs. A user does not need to obtain or +copy a PBF first. Downloading, conversion, production, verification and installation +all run away from the UI thread and can be cancelled. Network or producer errors +remain visible and do not publish an incomplete map. + +Global administrative lookup uses the [Photon API](https://github.com/komoot/photon/blob/master/docs/api-v1.md). +Searches are explicit, serialized and spaced by at least a second, with a bounded +cache of successful queries. A new search cancels obsolete work; an older response +cannot replace the latest results. `TRAIL_MATE_AREA_SEARCH_URL` can select another +compatible deployment without rebuilding. Map acquisition uses an Overpass +interpreter, currently defaulting to the [Private.coffee public instance](https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances); +`TRAIL_MATE_MAP_SOURCE_URL` can select another interpreter. The original default +returned timeouts and HTTP 502 during device validation. Attribution +is shown in Review. Public services can be unavailable or throttle requests; the +UI exposes retry and coordinate entry instead of treating a failure as a result. + +Online jobs on the small device are limited to 2500 square kilometres and a +64 MiB source response. Larger selections must be reduced or use an appropriate +local PBF through Advanced. The default map output budget is 128 MiB and is +independent of the source response limit. The estimate also checks working space. +Bounds use WGS84 and must not cross the antimeridian. + +Optional local `.osm.pbf` files are scanned beneath `map-sources` or `maps/sources` +in the configured storage root. `TRAIL_MATE_TMAP_SOURCE_ROOT` remains available +for a development source directory. Advanced lists these inputs alongside Online +download; local-file scans remain asynchronous. OSM, terrain and satellite layers use Center's existing PBF rendering and raster sources. Terrain and satellite may fetch source data over the system network. @@ -36,18 +68,14 @@ map filenames are refused; select another name instead of silently replacing a map. Keep a package key stable for updates to the same region and choose its revision explicitly. -For the default CM0 desktop session, the source directory is -`/home/pi/.local/share/trailmate/map-sources`. The page's displayed path is -authoritative if XDG or application storage overrides are configured. For example, -copy `area.osm.pbf` there, open Extensions → Make TMAP and select Refresh. -Bounds are WGS84 decimal degrees: West/South/East/North, with negative numbers -for western longitudes and southern latitudes. For a small example, enter -`102.70`, `25.00`, `102.71`, `25.01` respectively, provided the PBF covers it. -Start with OSM and zoom 13–14. Give the map a name, a simple package key such as -`test-area`, filename `test-area.tmap`, revision 1 and an appropriate output limit. -Review the estimate and start creation. After success, return to Map → Map Layer -and choose the produced base layer, move to the covered region and use a zoom -in the produced range. No manual output copy or separate import is required. +Search for a small city or district, select the correctly disambiguated result, +choose OSM and Balanced, then review and create. A manual example uses West +`102.70`, South `25.00`, East `102.71`, North `25.01`; those numbers are optional +input, not a default inferred location. After success, return to Map → Map Layer +and select the produced base layer, move to the covered region and use a zoom +in its produced range. The completed job also offers View map, which opens the +existing map renderer at the generated area's centre and zoom, with Back returning +to the result. No manual output copy or separate import is required. Only the original PBF and final map resources need to remain after a normal job. Final output includes the `.tmap` files and any referenced resources under @@ -112,7 +140,8 @@ Use `linux-arm` for CM0, `linux-arm64` for the Cardputer package builder, and runtime, Skia assets, Center license and a producer/hash manifest. CMake's `TRAIL_MATE_TMAP_PRODUCER_DIR` installs it under `/usr/lib/trailmate/tmap-producer` and checks its architecture. CM0 and Cardputer package metadata include their -distribution's ICU dependency when bundling this producer. No .NET SDK or Center +distribution's ICU dependency when bundling this producer, and the Cardputer +package depends on `osmium-tool` for online source conversion. No .NET SDK or Center checkout is required on the device. An explicit `TRAIL_MATE_TMAP_PRODUCER` executable override is available for development. @@ -133,5 +162,9 @@ destination. A small real OSM PBF is processed by the published Center producer; the resulting map is independently discovered/read by the application's TMAP SDK and its POI is found through the normal map-search runtime. Both 1232×568 and 320×170 wizard tests cover every step, form preservation, background continuation, -cancellation and unsupported-service behavior. Release/device results are recorded +cancellation and unsupported-service behavior. The online fixture also exercises +namesakes, invalid and point-only search results, caching, obsolete searches, +source download/conversion, HTTP failures, response limits and cancellation. Its +successful output is read and searched through the ordinary device SDK. +Release/device results are recorded in the CM0 acceptance audit after those checks run. diff --git a/docs/targets/cardputerzero-adaptation.md b/docs/targets/cardputerzero-adaptation.md index ee080fe1..22a4846c 100644 --- a/docs/targets/cardputerzero-adaptation.md +++ b/docs/targets/cardputerzero-adaptation.md @@ -60,13 +60,14 @@ hardware/session closure. Cardputer Zero uses the compact Pager visual language under a 320 x 170 keyboard-device constraint. -The shared LVGL menu profile keeps an independent compact focus carousel: +The shared LVGL menu profile adapts the Pager grid and widget feed: ```text make_cardputer_zero_profile() - owns compact geometry independently of the Pager grid - keeps PagerFocus menu behavior - shrinks geometry for 320 x 170 + inherits the Pager CompactGrid style + fits four columns and two rows between the fixed status bars + exposes the shared widget feed with V and Up/Down (W/S) + scales complete widget cards for 320 x 170 enables keyboard directional navigation hides memory statistics ``` diff --git a/modules/core_chat/include/chat/usecase/auto_reply_observer.h b/modules/core_chat/include/chat/usecase/auto_reply_observer.h new file mode 100644 index 00000000..76c7a158 --- /dev/null +++ b/modules/core_chat/include/chat/usecase/auto_reply_observer.h @@ -0,0 +1,59 @@ +#pragma once + +#include "chat/usecase/auto_reply_policy.h" +#include "chat/usecase/chat_service.h" +#include +#include + +namespace chat::auto_reply +{ +struct ObserverHooks +{ + bool (*read_reply_text)(std::string&) = nullptr; + bool (*screen_sleeping)() = nullptr; + NodeId (*self_node)() = nullptr; + size_t max_text_bytes = 0; +}; + +class Observer final : public ChatService::IncomingMessageObserver +{ + public: + Observer(ChatService& service, ObserverHooks hooks) : service_(service), hooks_(hooks) + { + service_.addIncomingMessageObserver(this); + } + ~Observer() override { service_.removeIncomingMessageObserver(this); } + + void onIncomingMessage(const ChatMessage& message, const RxMeta*) override + { + std::string text; + if (!hooks_.read_reply_text || !hooks_.read_reply_text(text)) return; + bool valid_text = false; + if (!text.empty() && text.size() <= hooks_.max_text_bytes) + { + for (unsigned char character : text) + { + if (!std::isspace(character)) + { + valid_text = true; + break; + } + } + } + const auto conversation = conversationIdForMessage(message); + const NodeId self = hooks_.self_node ? hooks_.self_node() : 0; + const Context context{true, valid_text, + hooks_.screen_sleeping && hooks_.screen_sleeping(), + self != 0 && message.from == self, + service_.canSendToConversation(conversation)}; + if (decide(message, context) == Decision::Reply) + { + (void)service_.sendTextToConversationDetailed(conversation, text); + } + } + + private: + ChatService& service_; + ObserverHooks hooks_; +}; +} // namespace chat::auto_reply diff --git a/modules/core_chat/src/infra/meshtastic/mt_codec_pb.cpp b/modules/core_chat/src/infra/meshtastic/mt_codec_pb.cpp index 814f85e7..6853b686 100644 --- a/modules/core_chat/src/infra/meshtastic/mt_codec_pb.cpp +++ b/modules/core_chat/src/infra/meshtastic/mt_codec_pb.cpp @@ -21,6 +21,8 @@ namespace meshtastic namespace { +constexpr uint32_t kBitfieldWantResponseMask = 0x02; + meshtastic_Data* acquireDataScratch(meshtastic_Data* supplied, std::unique_ptr& owned) { @@ -265,7 +267,7 @@ bool encodeNodeInfoMessage(const std::string& user_id, const std::string& long_n data->portnum = meshtastic_PortNum_NODEINFO_APP; data->want_response = want_response; data->has_bitfield = true; - data->bitfield = 0; + data->bitfield = want_response ? kBitfieldWantResponseMask : 0; if (user_len > sizeof(data->payload.bytes)) { @@ -314,7 +316,7 @@ bool encodeAppDataWithRequestId(uint32_t portnum, const uint8_t* payload, size_t data->portnum = static_cast(portnum); data->want_response = want_response; data->has_bitfield = true; - data->bitfield = 0; + data->bitfield = want_response ? kBitfieldWantResponseMask : 0; data->request_id = request_id; if (payload_len > sizeof(data->payload.bytes)) diff --git a/modules/core_chat/tests/test_auto_reply_observer.cpp b/modules/core_chat/tests/test_auto_reply_observer.cpp new file mode 100644 index 00000000..152d762d --- /dev/null +++ b/modules/core_chat/tests/test_auto_reply_observer.cpp @@ -0,0 +1,113 @@ +#include "chat/domain/chat_model.h" +#include "chat/infra/store/ram_store.h" +#include "chat/ports/i_mesh_adapter.h" +#include "chat/usecase/auto_reply_observer.h" +#include +#include + +namespace +{ +bool enabled = true; +bool sleeping = false; +std::string reply = "asleep"; +bool readReply(std::string& out) +{ + out = reply; + return enabled; +} +bool isSleeping() { return sleeping; } +chat::NodeId selfNode() { return 123; } + +class Adapter : public chat::IMeshAdapter +{ + public: + bool sendText(chat::ChannelId, const std::string& text, chat::MessageId* id, chat::NodeId peer) override + { + ++sent; + last_peer = peer; + last_text = text; + if (id) *id = sent; + return true; + } + chat::MeshSendResult sendTextToReticulumDestination(chat::ChannelId, + const std::string& text, chat::MessageId, const chat::ReticulumPeerIdentity& destination) override + { + last_destination = destination; + last_text = text; + return chat::MeshSendResult::success(++sent); + } + bool pollIncomingText(chat::MeshIncomingText* out) override + { + if (!pending) return false; + *out = incoming; + pending = false; + return true; + } + bool sendAppData(chat::ChannelId, uint32_t, const uint8_t*, size_t, chat::NodeId, + bool, chat::MessageId, bool) override { return false; } + bool pollIncomingData(chat::MeshIncomingData*) override { return false; } + void applyConfig(const chat::MeshConfig&) override {} + bool isReady() const override { return true; } + chat::NodeId getNodeId() const override { return selfNode(); } + bool pollIncomingRawPacket(uint8_t*, size_t&, size_t) override { return false; } + int sent = 0; + chat::NodeId last_peer = 0; + std::string last_text; + chat::ReticulumPeerIdentity last_destination{}; + chat::MeshIncomingText incoming{}; + bool pending = false; +}; +} // namespace + +int main() +{ + for (auto protocol : {chat::MeshProtocol::Meshtastic, chat::MeshProtocol::MeshCore, + chat::MeshProtocol::Reticulum}) + { + chat::ChatModel model; + chat::RamStore store; + Adapter adapter; + chat::ChatService service(model, adapter, store); + service.setActiveProtocol(protocol); + chat::auto_reply::Observer observer(service, {readReply, isSleeping, selfNode, 171}); + adapter.incoming.from = 456; + adapter.incoming.to = selfNode(); + adapter.incoming.text = "hello"; + if (protocol == chat::MeshProtocol::Reticulum) + { + uint8_t destination[16], identity[16]; + std::memset(destination, 0x42, sizeof(destination)); + std::memset(identity, 0x24, sizeof(identity)); + adapter.incoming.reticulum_identity = chat::makeReticulumPeerIdentity(destination, identity); + } + auto receive = [&](uint32_t id) + { + adapter.incoming.msg_id = id; + adapter.pending = true; + service.processIncoming(); + }; + sleeping = false; + receive(10); + assert(adapter.sent == 0); + sleeping = true; + receive(11); + assert(adapter.sent == 1 && adapter.last_text == reply); + if (protocol == chat::MeshProtocol::Reticulum) + assert(std::memcmp(adapter.last_destination.destination_hash, + adapter.incoming.reticulum_identity.destination_hash, 16) == 0); + else assert(adapter.last_peer == 456); + receive(11); // A repeated radio frame must not generate another reply. + assert(adapter.sent == 1); + enabled = false; + receive(12); + enabled = true; + reply = " "; + receive(13); + reply.assign(172, 'x'); + receive(14); + reply = "asleep"; + adapter.incoming.to = 0xFFFFFFFFU; + receive(15); + assert(adapter.sent == 1); + } +} diff --git a/modules/core_sys/include/platform/ui/tmap_creation_runtime.h b/modules/core_sys/include/platform/ui/tmap_creation_runtime.h index f4c41d7b..8c23ff96 100644 --- a/modules/core_sys/include/platform/ui/tmap_creation_runtime.h +++ b/modules/core_sys/include/platform/ui/tmap_creation_runtime.h @@ -13,6 +13,8 @@ struct Source struct Plan { std::string source, name, filename, package_key, country, admin; + // Online creation acquires its own OSM source inside the private job. + bool download_source = false; double west = 0, south = 0, east = 0, north = 0; uint8_t min_zoom = 13, max_zoom = 14; bool osm = true, terrain = false, satellite = false; @@ -38,13 +40,25 @@ struct Estimate struct Snapshot { Phase phase = Phase::Idle; - bool busy = false, producer_ready = false; + bool busy = false, producer_ready = false, downloader_ready = false; int percent = -1; uint64_t completed = 0, total = 0; uint32_t source_revision = 0; std::string source_directory; std::string stage, error, output; }; +struct Area +{ + std::string id, name, description, country; + double west = 0, south = 0, east = 0, north = 0; +}; +struct AreaSearchSnapshot +{ + uint32_t revision = 0; + bool busy = false, from_cache = false; + std::string query, error; + std::vector results; +}; // Composition registers a capable producer. Unsupported targets carry no job, // thread or producer dependency. Views own their bytes across UI callbacks. class Service @@ -56,6 +70,8 @@ class Service virtual bool start(const Plan&, std::string& error) = 0; virtual Snapshot snapshot() = 0; virtual void cancel() = 0; + virtual bool searchAreas(const std::string&) { return false; } + virtual AreaSearchSnapshot areaSearch() { return {}; } }; inline Service*& registered_service() { diff --git a/modules/ui_shared/include/ui/menu/dashboard/dashboard_key_focus.h b/modules/ui_shared/include/ui/menu/dashboard/dashboard_key_focus.h index 663482a1..964468c2 100644 --- a/modules/ui_shared/include/ui/menu/dashboard/dashboard_key_focus.h +++ b/modules/ui_shared/include/ui/menu/dashboard/dashboard_key_focus.h @@ -18,7 +18,7 @@ struct DashboardKeyFocus { if (count == 5) return; items[count++] = item; -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) lv_obj_set_style_outline_width(item, 2, LV_STATE_FOCUS_KEY); lv_obj_set_style_outline_pad(item, 1, LV_STATE_FOCUS_KEY); lv_obj_set_style_outline_color(item, lv_color_hex(0x6B4A1E), LV_STATE_FOCUS_KEY); @@ -31,7 +31,7 @@ struct DashboardKeyFocus } void show() { -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) for (unsigned i = 0; i < count; ++i) { constexpr auto state = static_cast(LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY); diff --git a/modules/ui_shared/include/ui/menu/menu_dashboard.h b/modules/ui_shared/include/ui/menu/menu_dashboard.h index 80fe71a2..7ad7ebb0 100644 --- a/modules/ui_shared/include/ui/menu/menu_dashboard.h +++ b/modules/ui_shared/include/ui/menu/menu_dashboard.h @@ -24,7 +24,7 @@ class IService; namespace ui::menu::dashboard { -#if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_UI_P4_TOUCH) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) +#if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_UI_P4_TOUCH) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions& options); void bringToFront(); void refresh_localized_text(); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h b/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h index 02a5d55c..de0b1589 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h @@ -102,7 +102,8 @@ class ChatMessageListScreen { Direct, Broadcast, - Team + Team, + All }; enum class ModalCommand @@ -136,7 +137,12 @@ class ChatMessageListScreen ModalButtonContext modal_button_contexts_[5]{}; int selected_index_ = -1; - FilterMode filter_mode_ = FilterMode::Direct; + FilterMode filter_mode_ = +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + FilterMode::All; +#else + FilterMode::Direct; +#endif bool filter_panel_visible_ = true; bool data_loading_ = false; char search_query_[32] = {}; diff --git a/modules/ui_shared/src/ui/app_catalog_builder.cpp b/modules/ui_shared/src/ui/app_catalog_builder.cpp index 1a30cb00..74ee814d 100644 --- a/modules/ui_shared/src/ui/app_catalog_builder.cpp +++ b/modules/ui_shared/src/ui/app_catalog_builder.cpp @@ -88,19 +88,6 @@ extern "C" const lv_image_dsc_t music; #endif #include "ui/screens/walkie_talkie/walkie_talkie_page_shell.h" -namespace -{ - -constexpr size_t kMaxMenuApps = ui::kAppCatalogCapacity; - -#define APP_CATALOG_LOG(...) std::printf("[UI][Catalog] " __VA_ARGS__) - -#if defined(TRAIL_MATE_ESP_BOARD_TAB5) -constexpr bool kTab5SkipSkyPlot = true; -#else -constexpr bool kTab5SkipSkyPlot = false; -#endif - #if !TRAIL_MATE_USE_MONO_SCREEN_240X320 extern "C" { @@ -135,6 +122,19 @@ extern "C" #define CATALOG_ICON(symbol) nullptr #endif +namespace +{ + +constexpr size_t kMaxMenuApps = ui::kAppCatalogCapacity; + +#define APP_CATALOG_LOG(...) std::printf("[UI][Catalog] " __VA_ARGS__) + +#if defined(TRAIL_MATE_ESP_BOARD_TAB5) +constexpr bool kTab5SkipSkyPlot = true; +#else +constexpr bool kTab5SkipSkyPlot = false; +#endif + void request_menu_exit(void*) { ui_request_exit_to_menu(); diff --git a/modules/ui_shared/src/ui/input/pager_navigation.cpp b/modules/ui_shared/src/ui/input/pager_navigation.cpp index 08e37b25..3b95c8a3 100644 --- a/modules/ui_shared/src/ui/input/pager_navigation.cpp +++ b/modules/ui_shared/src/ui/input/pager_navigation.cpp @@ -312,8 +312,9 @@ bool handle_key(uint32_t key, bool pressed, lv_group_t* group) if (!page || in_text_editor(focused) || ui::widgets::ime_owns_text_focus(focused)) return false; auto* app = ui_get_active_app(); const char* id = app ? app->stable_id() : nullptr; + const bool dropdown_open = lv_obj_check_type(focused, &lv_dropdown_class) && lv_dropdown_is_open(focused); if (!help::is_open(page->modal) && simple_navigation(id) && - !lv_group_get_editing(group) && + !lv_group_get_editing(group) && !dropdown_open && (key == 'w' || key == 'W' || key == 's' || key == 'S' || key == LV_KEY_UP || key == LV_KEY_DOWN)) { lv_group_set_editing(group, false); 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 c86512c5..57cb13af 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 @@ -256,7 +256,11 @@ void refresh_compass_widget(void* context) heading.available ? "COMPASS" : "NO FIX", heading.available ? color_soft_blue() : color_soft_warn(), heading.available ? color_info() : color_warn()); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + set_label_text_if_changed(compass.target_name, heading.available ? "Magnetic north" : "Need local GPS"); +#else set_label_text_if_changed(compass.target_name, heading.available ? "North locked" : "Need local GPS"); +#endif char heading_buf[48]; if (heading.available) @@ -273,6 +277,9 @@ void refresh_compass_widget(void* context) heading.available ? "magnetometer heading active" : "heading unlocks after first fix"); set_label_text_if_changed(compass.footer_label, heading.available ? "device heading from 9-axis module" : "compass wakes after GPS fix"); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + if (heading.available) set_label_text_if_changed(compass.footer_label, "Hold level | uncalibrated magnetic heading"); +#endif update_needle(compass, needle_bearing); lv_obj_set_style_bg_opa(compass.north_marker, LV_OPA_COVER, 0); return; diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp index 742ee577..ec46f694 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp @@ -106,7 +106,7 @@ void slider_event(lv_event_t* event) bool controls_key(void* context, uint32_t key) { auto& self = *static_cast(context); -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) const bool left = key == 'a' || key == 'A' || key == LV_KEY_LEFT; const bool right = key == 'd' || key == 'D' || key == LV_KEY_RIGHT; if (left || right || key == LV_KEY_ENTER) @@ -160,7 +160,7 @@ WidgetHandle create_controls_widget(lv_obj_t* parent, lv_coord_t width, lv_coord self->chrome = create_card_chrome(parent, ui::i18n::tr("Quick controls"), width, height); auto* body = self->chrome.body; // Touch devices need no footer. Pager uses a compact, transparent key hint. -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) lv_obj_set_height(self->chrome.footer, 18); lv_obj_set_style_bg_opa(self->chrome.footer, LV_OPA_TRANSP, 0); lv_obj_set_style_pad_top(self->chrome.footer, 0, 0); diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp index 3a9b0351..41b42b6a 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp @@ -106,7 +106,7 @@ void clicked(lv_event_t* event) bool music_key(void* context, uint32_t key) { auto& self = *static_cast(context); -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) if (key == 'a' || key == 'A' || key == LV_KEY_LEFT || key == 'd' || key == 'D' || key == LV_KEY_RIGHT) { self.focus.move(key == 'a' || key == 'A' || key == LV_KEY_LEFT ? -1 : 1); @@ -135,7 +135,7 @@ bool music_key(void* context, uint32_t key) } if (key == LV_KEY_ENTER) { -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) send(self, self.focus.selected); #else send(self, 4); @@ -213,7 +213,7 @@ WidgetHandle create_music_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t lv_obj_add_flag(footer, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_user_data(footer, reinterpret_cast(4)); lv_obj_add_event_cb(footer, clicked, LV_EVENT_CLICKED, self); -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) lv_obj_set_style_bg_opa(self->chrome.footer, LV_OPA_TRANSP, 0); ui::i18n::set_label_text(footer, "A/D select Enter run W/S wheel widgets V menu"); self->focus.selected = 1; diff --git a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp index bec01aff..1831b46c 100644 --- a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp +++ b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp @@ -5,7 +5,7 @@ #include "ui/menu/menu_layout.h" #include "ui/ux_build_config.h" -#if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_UI_P4_TOUCH) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) +#if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_UI_P4_TOUCH) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) #include "ui/app_runtime.h" #include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" @@ -56,7 +56,7 @@ unsigned page_count(const DashboardUi& state) bool dashboard_enabled() { -#if defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) return true; #else return std::strcmp(ui::menu_profile::current().name, "t_display_p4") == 0; @@ -128,7 +128,21 @@ void build_page() else { const auto kind = widget_at(state->dock_right ? overview_count() + state->page - 1U : state->page); - state->widgets[0] = create_widget(kind, state->grid, width, height); + const bool compact = std::strcmp(ui::menu_profile::current().name, "cardputer_zero") == 0; + // Render the same complete widget content on the short LCD. The virtual + // card retains enough body space for GPS stats and keyboard controls. + constexpr int compact_scale = 170; + state->widgets[0] = create_widget(kind, state->grid, + compact ? width * 256 / compact_scale : width, + compact ? height * 256 / compact_scale : height); + if (compact && state->widgets[0].root) + { + auto* root = state->widgets[0].root; + lv_obj_set_style_transform_pivot_x(root, 0, 0); + lv_obj_set_style_transform_pivot_y(root, 0, 0); + lv_obj_set_style_transform_scale_x(root, compact_scale, 0); + lv_obj_set_style_transform_scale_y(root, compact_scale, 0); + } } char page[24]; std::snprintf(page, sizeof(page), "%u / %u", state->page + 1U, @@ -178,7 +192,7 @@ void slide_finished(lv_anim_t*) // never scrolls; only the launcher grid owns vertical scrolling. lv_obj_scroll_to(lv_obj_get_parent(state->panel), 0, 0, LV_ANIM_OFF); state->feed_open = state->feed_target; -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) ui::menu_layout::set_bottom_bar_help_text("H Help"); #endif feed_offset(state, state->feed_open ? lv_obj_get_width(state->panel) : 0); @@ -447,7 +461,7 @@ bool handleKey(uint32_t key, bool at_left_edge) if (!state || !state->active || state->dock_right || ui::menu_runtime::currentScene() != ui::menu_runtime::Scene::Menu || ui_is_overlay_active() || (lv_group_get_default() && lv_group_get_default() != menu_g)) return false; -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) if (key == 'v' || key == 'V') { set_feed_open(!state->feed_open); @@ -461,7 +475,7 @@ bool handleKey(uint32_t key, bool at_left_edge) if (key == 'w' || key == 'W' || key == LV_KEY_UP || key == LV_KEY_PREV) change_page(-1); else if (key == 's' || key == 'S' || key == LV_KEY_DOWN || key == LV_KEY_NEXT) change_page(1); else if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) set_feed_open(false); -#if !defined(ARDUINO_T_LORA_PAGER) +#if !defined(ARDUINO_T_LORA_PAGER) && !defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) else if (key == 'd' || key == 'D' || key == LV_KEY_RIGHT) set_feed_open(false); #endif else if (key == 'd' || key == 'D' || key == LV_KEY_RIGHT) return true; @@ -601,7 +615,7 @@ void setActive(bool active) build_page(); if (state->timer) lv_timer_resume(state->timer); } -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) ui::menu_layout::set_bottom_bar_help_text("H Help"); #endif } diff --git a/modules/ui_shared/src/ui/menu/menu_layout.cpp b/modules/ui_shared/src/ui/menu/menu_layout.cpp index 8094f368..b373850c 100644 --- a/modules/ui_shared/src/ui/menu/menu_layout.cpp +++ b/modules/ui_shared/src/ui/menu/menu_layout.cpp @@ -92,7 +92,7 @@ void syncFocusedDescLabel(); bool showBottomHelpShortcut() { -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) return true; #else return false; @@ -1091,6 +1091,10 @@ void setBottomBarChipText(const BottomBarChipUi& chip, const char* text) std::string renderBottomHelpText(const char* text) { +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + (void)text; + return ui::menu::dashboard::isFeedOpen() ? "#D6403A V# Menu" : "#D6403A V# Widgets"; +#else const char* safe_text = text ? text : ""; const size_t prefix_len = std::strlen(kBottomHelpPlainPrefix); if (std::strncmp(safe_text, kBottomHelpPlainPrefix, prefix_len) != 0) @@ -1104,6 +1108,7 @@ std::string renderBottomHelpText(const char* text) rendered += ui::menu::dashboard::isFeedOpen() ? " #D6403A V# Menu" : " #D6403A V# Widgets"; #endif return rendered; +#endif } void setBottomBarHelpText(const char* text) diff --git a/modules/ui_shared/src/ui/menu/menu_profile.cpp b/modules/ui_shared/src/ui/menu/menu_profile.cpp index c3098c02..f14dced5 100644 --- a/modules/ui_shared/src/ui/menu/menu_profile.cpp +++ b/modules/ui_shared/src/ui/menu/menu_profile.cpp @@ -174,42 +174,26 @@ MenuLayoutProfile make_t_display_p4_profile() MenuLayoutProfile make_cardputer_zero_profile() { - // Cardputer keeps its independent carousel after Pager joins the grid. - MenuLayoutProfile profile{}; + // Keep Pager's adaptive grid and focus styling, sized for the 320 x 170 LCD. + auto profile = make_pager_profile(); profile.name = "cardputer_zero"; profile.transition_animation = false; - profile.variant = LayoutVariant::PagerFocus; - profile.badge_anchor_mode = BadgeAnchorMode::IconTopLeft; - profile.grid_pad_row = 0; - profile.badge_offset_x = -4; - profile.wrap_grid = false; - profile.vertical_scroll = false; - profile.snap_center = true; - profile.show_card_label = false; - profile.show_desc_label = true; 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.card_height = 56; + profile.icon_scale = 128; profile.grid_top_offset = 26; - profile.grid_pad_column = 8; - profile.grid_pad_left = 6; - profile.grid_pad_right = 6; - profile.top_bar_height = ::ui::kUnifiedTopBarHeight; + profile.grid_pad_row = 4; 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.card_pad_top = 2; + profile.card_pad_bottom = 2; + profile.card_pad_row = 0; + profile.card_label_font = &lv_font_montserrat_10; profile.node_id_font = &lv_font_montserrat_12; profile.top_bar_font = &lv_font_montserrat_12; profile.badge_font = &lv_font_montserrat_12; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp index 857ac588..81d6c84e 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp @@ -481,10 +481,16 @@ void ChatComposeScreen::syncFocusOrder(bool focus_ime) } }; add_visible(impl_->w.textarea); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + // The hardware keyboard's next stop after typing is the primary action. + add_visible(impl_->w.send_btn); +#endif add_visible(ime_toggle); add_visible(impl_->sym_btn); add_visible(impl_->emoji_btn); +#if !defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) add_visible(impl_->w.send_btn); +#endif add_visible(impl_->w.position_btn); add_visible(impl_->w.cancel_btn); add_visible(impl_->w.top_bar.back_btn); @@ -732,9 +738,15 @@ void ChatComposeScreen::on_key(lv_event_t* e) } if (target == screen->impl_->w.send_btn || focused == screen->impl_->w.send_btn) { +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + // Keypad ENTER emits CLICKED on release. Injecting it here as + // well schedules a second send for the same physical key press. + return; +#else lv_obj_send_event(screen->impl_->w.send_btn, LV_EVENT_CLICKED, nullptr); lv_event_stop_processing(e); return; +#endif } } diff --git a/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp b/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp index df854ff2..2657fa15 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp @@ -59,6 +59,19 @@ static lv_obj_t* create_btn_with_label(lv_obj_t* parent, int w, int h, const cha void create(lv_obj_t* parent, const Spec& spec, Widgets& w) { const auto& profile = ::ui::page_profile::current(); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + (void)spec; + const int button_height = 20; + const int action_bar_height = 26; + const int send_width = 50; + const int position_width = 52; + const int cancel_width = 54; + const int content_pad = 4; + const int content_row_pad = 3; + const int action_pad_lr = 4; + const int action_pad_tb = 2; + const int button_gap = 4; +#else const int button_height = std::max(spec.btn_h, static_cast(profile.filter_button_height)); const int action_bar_height = std::max(spec.action_bar_h, button_height + 16); const int send_width = std::max(spec.send_w, profile.large_touch_hitbox ? 108 : spec.send_w); @@ -69,6 +82,7 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) const int action_pad_lr = std::max(spec.action_pad_lr, profile.large_touch_hitbox ? 14 : spec.action_pad_lr); const int action_pad_tb = std::max(spec.action_pad_tb, profile.large_touch_hitbox ? 6 : spec.action_pad_tb); const int button_gap = std::max(spec.btn_gap, profile.large_touch_hitbox ? 12 : spec.btn_gap); +#endif w.container = lv_obj_create(parent); lv_obj_set_size(w.container, LV_PCT(100), LV_PCT(100)); @@ -91,6 +105,13 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) w.textarea = lv_textarea_create(w.content); lv_obj_set_width(w.textarea, LV_PCT(100)); lv_obj_set_flex_grow(w.textarea, 1); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + lv_obj_set_style_pad_all(w.textarea, 4, 0); + auto* keyboard_hint = lv_label_create(w.content); + ::ui::i18n::set_label_text(keyboard_hint, "Tab to Send, then Enter"); + lv_obj_set_style_text_font(keyboard_hint, ::ui::page_profile::resolve_tiny_font(), 0); + lv_obj_set_style_text_color(keyboard_hint, lv_color_hex(0x6A5646), 0); +#endif w.action_bar = lv_obj_create(w.container); lv_obj_set_size(w.action_bar, LV_PCT(100), action_bar_height); @@ -109,6 +130,17 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) lv_obj_set_style_pad_right(w.position_btn, button_gap, 0); w.cancel_btn = create_btn_with_label(w.action_bar, cancel_width, button_height, "Cancel"); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + lv_obj_set_style_pad_column(w.action_bar, button_gap, 0); + for (auto* button : {w.send_btn, w.position_btn, w.cancel_btn}) + { + lv_obj_set_style_pad_left(button, 3, 0); + lv_obj_set_style_pad_right(button, 3, 0); + lv_obj_set_style_pad_top(button, 1, 0); + lv_obj_set_style_pad_bottom(button, 1, 0); + lv_obj_set_style_text_font(button, ::ui::page_profile::resolve_caption_font(), 0); + } +#endif lv_obj_t* spacer = lv_obj_create(w.action_bar); lv_obj_set_size(spacer, 1, 1); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp index fbe6ce3d..d6f155be 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp @@ -83,8 +83,17 @@ static void style_help_chip(lv_obj_t* chip, lv_obj_set_style_pad_bottom(chip, profile.dense ? 2 : 3, 0); lv_obj_set_style_min_height( chip, +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + 18, +#else ::ui::page_profile::resolve_control_button_height() - (profile.dense ? 4 : 6), +#endif 0); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + lv_obj_set_style_pad_top(chip, 1, 0); + lv_obj_set_style_pad_bottom(chip, 1, 0); + lv_obj_set_style_radius(chip, 3, 0); +#endif make_non_scrollable(chip); lv_obj_clear_flag(chip, LV_OBJ_FLAG_CLICKABLE); } @@ -131,8 +140,12 @@ ConversationWidgets create_conversation_base(lv_obj_t* parent) // Action bar (fixed height) w.action_bar = lv_obj_create(w.right_column); lv_obj_set_size(w.action_bar, LV_PCT(100), +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + 24); +#else ::ui::page_profile::resolve_control_button_height() + (profile.dense ? 2 : 2)); +#endif lv_obj_set_flex_grow(w.action_bar, 0); lv_obj_set_flex_flow(w.action_bar, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(w.action_bar, @@ -153,16 +166,26 @@ ConversationWidgets create_conversation_base(lv_obj_t* parent) 0); lv_label_set_long_mode(w.help_label, LV_LABEL_LONG_CLIP); lv_label_set_recolor(w.help_label, true); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + lv_label_set_text(w.help_label, "#D00000 S# Send #D00000 H# Help"); +#else lv_label_set_text(w.help_label, "#D00000 H#elp"); +#endif lv_obj_center(w.help_label); create_action_bar_spacer(w.action_bar); // Primary compose button w.reply_btn = lv_btn_create(w.action_bar); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + lv_obj_set_size(w.reply_btn, 54, 20); + lv_obj_set_style_pad_all(w.reply_btn, 2, 0); + lv_obj_set_style_text_font(w.reply_btn, ::ui::page_profile::resolve_caption_font(), 0); +#else lv_obj_set_size(w.reply_btn, profile.dense ? 74 : 96, ::ui::page_profile::resolve_control_button_height()); +#endif make_non_scrollable(w.reply_btn); w.reply_label = lv_label_create(w.reply_btn); 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 b5b132e4..c5a27be4 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 @@ -373,7 +373,6 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) broadcast_btn_(nullptr), list_back_btn_(nullptr), selected_index_(-1), - filter_mode_(FilterMode::Direct), action_cb_(nullptr), action_cb_user_data_(nullptr) { @@ -402,6 +401,10 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) direct_btn_ = w.direct_btn; broadcast_btn_ = w.broadcast_btn; team_btn_ = w.team_btn; +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + if (direct_btn_) + ::ui::i18n::set_label_text(lv_obj_get_child(direct_btn_, 0), "All"); +#endif // ---------- Styles ---------- chat::ui::message_list::styles::apply_root_container(container_); @@ -752,7 +755,11 @@ void ChatMessageListScreen::buildFilteredConversations( for (const auto& conv : convs_) { bool mode_match = false; - if (is_team_conversation(conv.id)) + if (filter_mode_ == FilterMode::All) + { + mode_match = true; + } + else if (is_team_conversation(conv.id)) { mode_match = filter_mode_ == FilterMode::Team; } @@ -1362,7 +1369,13 @@ void ChatMessageListScreen::filter_click_cb(lv_event_t* e) lv_obj_t* tgt = static_cast(lv_event_get_current_target(e)); if (tgt == screen->direct_btn_) { - screen->setFilterMode(FilterMode::Direct); + screen->setFilterMode( +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + FilterMode::All +#else + FilterMode::Direct +#endif + ); } else if (tgt == screen->broadcast_btn_) { @@ -1538,7 +1551,7 @@ void ChatMessageListScreen::updateFilterHighlight() { lv_obj_clear_state(team_btn_, LV_STATE_CHECKED); } - if (filter_mode_ == FilterMode::Direct) + if (filter_mode_ == FilterMode::Direct || filter_mode_ == FilterMode::All) { lv_obj_add_state(direct_btn_, LV_STATE_CHECKED); } 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 65b3fafb..60ba542a 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 @@ -3654,6 +3654,9 @@ static void open_action_menu_modal() ? chat_support::supports_team_chat() : (g_contacts_state.current_mode == ContactsMode::Groups) ? chat_support::supports_reticulum_destination_text() + : (node && chat_support::active_mesh_protocol() == chat::MeshProtocol::Reticulum && + chat::hasReticulumDestinationIdentity(node->reticulum_identity)) + ? chat_support::supports_reticulum_destination_text() : chat_support::supports_local_text_chat(); int action_count = allow_chat_action ? 2 : 1; // Chat + Cancel if (allow_reticulum_call) diff --git a/modules/ui_shared/src/ui/screens/extensions/tmap_creator_page.cpp b/modules/ui_shared/src/ui/screens/extensions/tmap_creator_page.cpp index b5b50654..76fa4b92 100644 --- a/modules/ui_shared/src/ui/screens/extensions/tmap_creator_page.cpp +++ b/modules/ui_shared/src/ui/screens/extensions/tmap_creator_page.cpp @@ -4,13 +4,16 @@ #include "ui/input/pager_navigation.h" #include "ui/localization.h" #include "ui/page/page_profile.h" +#include "ui/screens/gps/gps_page_runtime.h" #include "ui/ui_theme.h" #include "ui_lvgl_ux_packs/common/touch_text_editor.h" +#include "ui_presentation/map/map_target_request.h" #include #include #include #include #include +#include #include namespace extensions::ui::tmap_creator @@ -18,31 +21,140 @@ namespace extensions::ui::tmap_creator namespace { namespace tc = ::platform::ui::tmap_creation; +constexpr unsigned kCreation = 4; struct Page { lv_obj_t *root = nullptr, *body = nullptr, *error = nullptr, *status = nullptr, *progress = nullptr; - lv_obj_t *cancel = nullptr, *new_map = nullptr; - std::array fields{}; + lv_obj_t *cancel = nullptr, *new_map = nullptr, *forward = nullptr, *area_summary = nullptr, *view_map = nullptr, *map_surface = nullptr; + std::array fields{}; std::array layers{}; std::vector focus; std::vector sources; + std::vector areas; lv_timer_t* timer = nullptr; unsigned step = 0; - bool awaiting_sources = false; - uint32_t source_revision = 0; + bool coordinates_mode = false, advanced = false, awaiting_sources = false; + bool map_open = false; + ::ui::map::MapTargetRequest map_target{}; + ::gps::ui::shell::Host map_host{}; + uint32_t search_revision = 0, source_revision = 0; + std::string last_status; + int last_percent = -2; void (*closed)(void*) = nullptr; void* context = nullptr; }; std::unique_ptr page; tc::Plan draft; +tc::Area selected_area; std::array coordinates; -const char* titles[] = {"Source", "Area", "Layers and zoom", "Map identity", "Output", "Review", "Creation"}; +std::string query_text, revision_text = "1"; +unsigned quality = 0; +const char* titles[] = {"Area", "Map content", "Save map", "Review", "Creation"}; void render(); -void remember_source() +void search_area(lv_event_t*); +void update_area_summary(); +void choose_quality(); +void show_error(const std::string&); +void return_from_map(void*) { - if (!page || !page->fields[0] || page->step != 0) return; - const auto index = lv_dropdown_get_selected(page->fields[0]); - if (index < page->sources.size()) draft.source = page->sources[index].path; + if (!page || !page->map_open) return; + ::gps::ui::runtime::exit(page->map_surface); + if (page->map_surface) lv_obj_del(page->map_surface); + page->map_surface = nullptr; + page->map_open = false; + lv_obj_remove_flag(page->root, LV_OBJ_FLAG_HIDDEN); + set_default_group(app_g); + if (page->timer) lv_timer_resume(page->timer); + render(); + if (app_g && page->view_map) lv_group_focus_obj(page->view_map); +} +void view_map(lv_event_t*) +{ + if (!page || page->map_open || !tc::service() || tc::service()->snapshot().phase != tc::Phase::Succeeded) return; + page->map_target = {}; + page->map_target.viewport = {(draft.north + draft.south) * .5, (draft.east + draft.west) * .5, draft.max_zoom}; + ::ui::copyText(page->map_target.label, draft.name.c_str()); + page->map_host.request_exit = [](void*) + { lv_async_call_cancel(return_from_map, nullptr); lv_async_call(return_from_map, nullptr); }; + page->map_open = true; + lv_obj_add_flag(page->root, LV_OBJ_FLAG_HIDDEN); + if (page->timer) lv_timer_pause(page->timer); + page->map_surface = lv_obj_create(lv_obj_get_parent(page->root)); + lv_obj_remove_style_all(page->map_surface); + lv_obj_set_size(page->map_surface, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(page->map_surface, 0, 0); + lv_obj_add_flag(page->map_surface, LV_OBJ_FLAG_FLOATING); + lv_obj_remove_flag(page->map_surface, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_move_foreground(page->map_surface); + for (auto* input = lv_indev_get_next(nullptr); input; input = lv_indev_get_next(input)) lv_indev_wait_release(input); + ::gps::ui::runtime::enter(&page->map_host, page->map_surface, ::gps::ui::shell::Projection::Map, nullptr, &page->map_target); + if (!page->map_target.entered) + { + return_from_map(nullptr); + show_error("Map is unavailable; the completed files are retained."); + } +} + +std::string size(uint64_t bytes) +{ + char text[48]; + std::snprintf(text, sizeof(text), "%.1f MiB", double(bytes) / (1024 * 1024)); + return text; +} +void show_error(const std::string& message) +{ + if (!page || !page->error) return; + lv_label_set_text(page->error, message.c_str()); + lv_obj_remove_flag(page->error, LV_OBJ_FLAG_HIDDEN); + lv_obj_update_layout(page->root); + lv_obj_scroll_to_view(page->error, LV_ANIM_OFF); +} +void suggest_zoom() +{ + unsigned layers = unsigned(draft.osm) + unsigned(draft.terrain) + unsigned(draft.satellite); + layers = std::max(1U, layers); + for (int z = 14; z >= 0; --z) + { + const auto tiles = [&](int level) + { + const double n = double(uint64_t(1) << level); + const auto x = [&](double longitude) + { return std::clamp(int64_t(std::floor((longitude + 180) / 360 * n)), 0, int64_t(n) - 1); }; + const auto y = [&](double latitude) + { + const double rad = latitude * 3.14159265358979323846 / 180; + return std::clamp(int64_t(std::floor((1 - std::asinh(std::tan(rad)) / 3.14159265358979323846) * .5 * n)), 0, int64_t(n) - 1); + }; + return uint64_t(x(draft.east) - x(draft.west) + 1) * uint64_t(y(draft.south) - y(draft.north) + 1); + }; + if ((tiles(z) + (z ? tiles(z - 1) : 0)) * layers * 131072ULL > 96ULL * 1024 * 1024 && z) continue; + draft.max_zoom = z; + draft.min_zoom = std::max(0, z - 1); + return; + } +} +void remember_fields() +{ + if (!page) return; + if (page->step == 1) + { + draft.osm = lv_obj_has_state(page->layers[0], LV_STATE_CHECKED); + draft.terrain = lv_obj_has_state(page->layers[1], LV_STATE_CHECKED); + draft.satellite = lv_obj_has_state(page->layers[2], LV_STATE_CHECKED); + quality = lv_dropdown_get_selected(page->fields[0]); + draft.min_zoom = lv_dropdown_get_selected(page->fields[1]); + draft.max_zoom = lv_dropdown_get_selected(page->fields[2]); + if (!quality) suggest_zoom(); + } + if (page->step == 2 && page->advanced && page->fields[4]) + { + constexpr uint64_t budgets[] = {128, 512, 1024, 2048}; + draft.maximum_bytes = budgets[lv_dropdown_get_selected(page->fields[4])] * 1024 * 1024; + const auto source = lv_dropdown_get_selected(page->fields[5]); + draft.download_source = source == 0; + if (source && source <= page->sources.size()) draft.source = page->sources[source - 1].path; + else if (!source) draft.source.clear(); + } } void finish() { @@ -54,13 +166,12 @@ void finish() } void key(lv_event_t* event) { - const auto code = lv_event_get_code(event); - if (code == LV_EVENT_FOCUSED) + if (lv_event_get_code(event) == LV_EVENT_FOCUSED) { lv_obj_scroll_to_view(lv_event_get_target_obj(event), LV_ANIM_OFF); return; } - if (code != LV_EVENT_KEY) return; + if (lv_event_get_code(event) != LV_EVENT_KEY) return; const auto pressed = lv_event_get_key(event); if (page && lv_event_get_target_obj(event) == page->body && (pressed == LV_KEY_UP || pressed == LV_KEY_DOWN)) { @@ -70,11 +181,10 @@ void key(lv_event_t* event) } if (pressed != LV_KEY_ESC) return; lv_event_stop_processing(event); - // LVGL sends CANCEL after KEY/ESC. Reset before replacing its focused - // object so that follow-up event cannot target the deleted step editor. if (auto* device = lv_indev_active()) lv_indev_reset(device, nullptr); - if (page->step && page->step != 6) + if (page->step && page->step != kCreation) { + remember_fields(); --page->step; render(); } @@ -85,6 +195,9 @@ void focus(lv_obj_t* object) page->focus.push_back(object); lv_obj_add_event_cb(object, key, LV_EVENT_KEY, nullptr); lv_obj_add_event_cb(object, key, LV_EVENT_FOCUSED, nullptr); + lv_obj_set_style_outline_width(object, 2, LV_STATE_FOCUS_KEY); + lv_obj_set_style_outline_pad(object, 0, LV_STATE_FOCUS_KEY); + lv_obj_set_style_outline_color(object, ::ui::theme::accent(), LV_STATE_FOCUS_KEY); if (app_g) lv_group_add_obj(app_g, object); } lv_obj_t* label(lv_obj_t* parent, const std::string& text) @@ -101,6 +214,7 @@ lv_obj_t* row(lv_obj_t* parent) auto* object = lv_obj_create(parent); lv_obj_set_size(object, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_pad_all(object, 0, 0); + lv_obj_set_style_pad_column(object, 4, 0); lv_obj_set_style_border_width(object, 0, 0); lv_obj_set_style_bg_opa(object, LV_OPA_TRANSP, 0); lv_obj_set_flex_flow(object, LV_FLEX_FLOW_ROW); @@ -111,7 +225,17 @@ lv_obj_t* row(lv_obj_t* parent) lv_obj_t* button(lv_obj_t* parent, const char* text, lv_event_cb_t callback) { auto* object = lv_btn_create(parent); - lv_obj_set_height(object, ::ui::page_profile::current().dense ? 25 : 36); + const bool dense = ::ui::page_profile::current().dense; + lv_obj_set_height(object, dense ? 20 : 36); + lv_obj_set_style_pad_hor(object, dense ? 7 : 12, 0); + lv_obj_set_style_pad_ver(object, 0, 0); + lv_obj_set_style_radius(object, dense ? 4 : 6, 0); + lv_obj_set_style_shadow_width(object, 0, 0); + lv_obj_set_style_border_width(object, 1, 0); + lv_obj_set_style_border_color(object, ::ui::theme::border(), 0); + lv_obj_set_style_bg_color(object, ::ui::theme::surface_alt(), 0); + lv_obj_set_style_text_color(object, ::ui::theme::text(), 0); + lv_obj_set_style_bg_color(object, ::ui::theme::accent(), LV_STATE_PRESSED); auto* caption = lv_label_create(object); lv_label_set_text(caption, ::ui::i18n::tr(text)); lv_obj_center(caption); @@ -123,10 +247,15 @@ lv_obj_t* field(const char* title, const std::string& value, unsigned index, siz { auto* line = row(page->body); auto* caption = label(line, ::ui::i18n::tr(title)); - lv_obj_set_width(caption, ::ui::page_profile::current().dense ? 78 : 160); + lv_obj_set_width(caption, ::ui::page_profile::current().dense ? 62 : 160); auto* object = page->fields[index] = lv_textarea_create(line); lv_obj_set_flex_grow(object, 1); - lv_obj_set_height(object, ::ui::page_profile::current().dense ? 28 : 40); + lv_obj_set_height(object, ::ui::page_profile::current().dense ? 22 : 40); + lv_obj_set_style_pad_all(object, ::ui::page_profile::current().dense ? 2 : 6, 0); + lv_obj_set_style_radius(object, 3, 0); + lv_obj_set_style_border_color(object, ::ui::theme::border(), 0); + lv_obj_set_style_bg_color(object, ::ui::theme::surface(), 0); + lv_obj_set_style_text_color(object, ::ui::theme::text(), 0); lv_textarea_set_one_line(object, true); lv_textarea_set_max_length(object, maximum); lv_textarea_set_text(object, value.c_str()); @@ -137,10 +266,20 @@ lv_obj_t* field(const char* title, const std::string& value, unsigned index, siz { if (!page) return; const auto index = reinterpret_cast(lv_event_get_user_data(event)); + if (page->error) lv_obj_add_flag(page->error, LV_OBJ_FLAG_HIDDEN); const std::string text = lv_textarea_get_text(lv_event_get_target_obj(event)); - if (page->step == 1 && index < 4) coordinates[index] = text; - if (page->step == 3) { if (index == 0) draft.name = text; else draft.package_key = text; } - if (page->step == 4 && index == 0) draft.filename = text; }, + if (page->step == 0) + { + if (page->coordinates_mode && index < 4) coordinates[index] = text; + else if (!page->coordinates_mode && index == 0) query_text = text; + } + if (page->step == 2) + { + if (index == 0) draft.name = text; + else if (index == 1) draft.filename = text; + else if (index == 2) draft.package_key = text; + else if (index == 3) revision_text = text; + } }, LV_EVENT_VALUE_CHANGED, reinterpret_cast(uintptr_t(index))); return object; } @@ -149,101 +288,164 @@ lv_obj_t* dropdown(lv_obj_t* parent, const char* options, unsigned selected) auto* object = lv_dropdown_create(parent); lv_dropdown_set_options(object, options); lv_dropdown_set_selected(object, selected); - lv_obj_set_height(object, ::ui::page_profile::current().dense ? 28 : 40); + lv_obj_set_height(object, ::ui::page_profile::current().dense ? 22 : 40); + lv_obj_set_style_pad_all(object, ::ui::page_profile::current().dense ? 2 : 6, 0); + lv_obj_set_style_radius(object, 3, 0); + lv_obj_set_style_border_color(object, ::ui::theme::border(), 0); + lv_obj_set_style_bg_color(object, ::ui::theme::surface(), 0); + lv_obj_set_style_text_color(object, ::ui::theme::text(), 0); + lv_obj_add_event_cb( + object, [](lv_event_t* event) + { + auto* list = lv_dropdown_get_list(lv_event_get_target_obj(event)); + if (!list) return; + const auto& profile = ::ui::page_profile::current(); + lv_obj_set_style_text_font(list, profile.dense ? profile.tiny_font : profile.body_font, 0); + lv_obj_set_style_bg_color(list, ::ui::theme::surface(), 0); + lv_obj_set_style_text_color(list, ::ui::theme::text(), 0); + lv_obj_set_style_border_color(list, ::ui::theme::border(), 0); + lv_obj_set_style_shadow_width(list, 0, 0); + lv_obj_set_style_bg_color(list, ::ui::theme::accent(), LV_PART_SELECTED); + lv_obj_set_style_bg_color(list, ::ui::theme::accent(), lv_style_selector_t(LV_PART_SELECTED) | lv_style_selector_t(LV_STATE_CHECKED)); + lv_obj_set_style_text_color(list, ::ui::theme::text(), LV_PART_SELECTED); }, + LV_EVENT_READY, nullptr); focus(object); return object; } -std::string size(uint64_t bytes) +void update_area_summary() { - char text[48]; - std::snprintf(text, sizeof(text), "%.1f MiB", double(bytes) / (1024 * 1024)); - return text; + if (!page || !page->area_summary || page->areas.empty()) return; + const auto index = lv_dropdown_get_selected(page->fields[1]); + if (index >= page->areas.size()) return; + const auto& area = page->areas[index]; + char dimensions[96]; + std::snprintf(dimensions, sizeof(dimensions), "\nEnclosing area %.1f x %.1f km", + (area.east - area.west) * 111.32 * std::cos((area.north + area.south) * .5 * 3.14159265358979323846 / 180), + (area.north - area.south) * 111.32); + lv_label_set_text(page->area_summary, (area.description + dimensions).c_str()); +} +void search_area(lv_event_t*) +{ + if (!page || !tc::service() || tc::service()->areaSearch().busy) return; + if (!tc::service()->searchAreas(query_text)) + { + show_error("Enter a city, district, state or country name."); + return; + } + page->search_revision = tc::service()->areaSearch().revision; + render(); } bool collect() { + remember_fields(); if (page->step == 0) { - if (page->sources.empty()) + if (page->coordinates_mode) { - lv_label_set_text(page->error, "Add a local .osm.pbf file to map-sources, then Refresh."); - return false; - } - const auto index = lv_dropdown_get_selected(page->fields[0]); - if (index >= page->sources.size()) return false; - draft.source = page->sources[index].path; - } - if (page->step == 1) - { - double* values[] = {&draft.west, &draft.south, &draft.east, &draft.north}; - for (unsigned i = 0; i < 4; ++i) - { - char* end = nullptr; - const double value = std::strtod(coordinates[i].c_str(), &end); - if (coordinates[i].empty() || !end || *end || !std::isfinite(value)) + double* values[] = {&draft.west, &draft.south, &draft.east, &draft.north}; + bool changed = false; + for (unsigned i = 0; i < 4; ++i) { - lv_label_set_text(page->error, "Enter all four coordinates as decimal degrees."); + char* end = nullptr; + const double value = std::strtod(coordinates[i].c_str(), &end); + if (coordinates[i].empty() || !end || *end || !std::isfinite(value)) + { + show_error("Enter all four coordinates as decimal degrees."); + return false; + } + changed |= *values[i] != value; + *values[i] = value; + } + if (draft.west < -180 || draft.east > 180 || draft.south < -85.051128 || draft.north > 85.051128 || + draft.west >= draft.east || draft.south >= draft.north) + { + show_error("West must be below East; South below North. Split date-line areas."); return false; } - *values[i] = value; + if (changed) + { + quality = 0; + suggest_zoom(); + } } - if (draft.west < -180 || draft.east > 180 || draft.south < -85.051128 || draft.north > 85.051128 || draft.west >= draft.east || draft.south >= draft.north) + else { - lv_label_set_text(page->error, "West must be below East; South below North. Split date-line areas."); - return false; + if (page->areas.empty()) + { + show_error("Search for an area and select a result."); + return false; + } + const auto index = lv_dropdown_get_selected(page->fields[1]); + if (index >= page->areas.size()) return false; + const auto& area = page->areas[index]; + draft.west = area.west; + draft.south = area.south; + draft.east = area.east; + draft.north = area.north; + if (selected_area.id != area.id) + { + draft.name = area.name; + draft.country = area.country; + draft.admin = area.id; + draft.package_key = "area-" + area.id; + draft.filename = draft.package_key + ".tmap"; + quality = 0; + suggest_zoom(); + } + selected_area = area; + const double values[]{draft.west, draft.south, draft.east, draft.north}; + for (unsigned i = 0; i < 4; ++i) + { + char text[32]; + std::snprintf(text, sizeof(text), "%.6f", values[i]); + coordinates[i] = text; + } } } + if (page->step == 1 && ((!draft.osm && !draft.terrain && !draft.satellite) || draft.min_zoom > draft.max_zoom)) + { + show_error("Choose a map layer and an increasing zoom range."); + return false; + } if (page->step == 2) { - draft.osm = lv_obj_has_state(page->layers[0], LV_STATE_CHECKED); - draft.terrain = lv_obj_has_state(page->layers[1], LV_STATE_CHECKED); - draft.satellite = lv_obj_has_state(page->layers[2], LV_STATE_CHECKED); - draft.min_zoom = lv_dropdown_get_selected(page->fields[0]); - draft.max_zoom = lv_dropdown_get_selected(page->fields[1]); - if ((!draft.osm && !draft.terrain && !draft.satellite) || draft.min_zoom > draft.max_zoom) + if (draft.name.empty() || draft.package_key.empty()) { - lv_label_set_text(page->error, "Choose a base layer and an increasing zoom range."); + show_error("A map name and package key are required."); return false; } - } - if (page->step == 3 && (draft.name.empty() || draft.package_key.empty())) - { - lv_label_set_text(page->error, "A name and stable package key are required."); - return false; - } - if (page->step == 4) - { char* end = nullptr; - const auto revision = std::strtoull(lv_textarea_get_text(page->fields[1]), &end, 10); - if (!end || *end || !revision || lv_textarea_get_text(page->fields[1])[0] == '-') + const auto revision = std::strtoull(revision_text.c_str(), &end, 10); + if (!end || *end || !revision || revision_text.front() == '-') { - lv_label_set_text(page->error, "Enter a positive package revision."); + show_error("Enter a positive package revision."); return false; } draft.revision = revision; - constexpr uint64_t budgets[] = {128, 512, 1024, 2048}; - draft.maximum_bytes = budgets[lv_dropdown_get_selected(page->fields[2])] * 1024 * 1024; } return true; } void next(lv_event_t*) { if (!page || !tc::service()) return; - if (page->step == 6) + if (page->step == kCreation) { if (!tc::service()->snapshot().busy) { + draft.filename = draft.package_key + "-r" + std::to_string(++draft.revision) + ".tmap"; + revision_text = std::to_string(draft.revision); page->step = 0; render(); } return; } if (!collect()) return; - if (page->step == 5) + if (page->step == kCreation - 1) { std::string error; if (!tc::service()->start(draft, error)) { - lv_label_set_text(page->error, error.c_str()); + show_error(error); return; } } @@ -253,8 +455,9 @@ void next(lv_event_t*) void previous(lv_event_t*) { if (!page) return; - if (page->step && page->step != 6) + if (page->step && page->step != kCreation) { + remember_fields(); --page->step; render(); } @@ -262,24 +465,59 @@ void previous(lv_event_t*) } void progress() { - if (!page || page->step != 6 || !tc::service()) return; + if (!page || page->step != kCreation || !tc::service()) return; const auto state = tc::service()->snapshot(); std::string text = state.stage; if (state.total) text += "\n" + std::to_string(state.completed) + " / " + std::to_string(state.total); if (!state.error.empty()) text += "\n" + state.error; - if (!state.output.empty()) text += "\n" + state.output; - lv_label_set_text(page->status, text.c_str()); - lv_bar_set_value(page->progress, state.percent < 0 ? 0 : state.percent, LV_ANIM_OFF); + if (state.phase == tc::Phase::Succeeded) text += "\n" + draft.name + "\nView map opens the completed area."; + if (text != page->last_status) + { + lv_label_set_text(page->status, text.c_str()); + page->last_status = text; + } + const int percent = state.phase == tc::Phase::Succeeded ? 100 : std::clamp(state.percent, 0, 100); + if (percent != page->last_percent) + { + lv_bar_set_value(page->progress, percent, LV_ANIM_OFF); + page->last_percent = percent; + } if (page->new_map) { if (state.busy) lv_obj_add_state(page->new_map, LV_STATE_DISABLED); else lv_obj_remove_state(page->new_map, LV_STATE_DISABLED); + auto* caption = lv_obj_get_child(page->new_map, 0); + const char* text = state.phase == tc::Phase::Failed ? "Retry / edit" : "New map"; + if (std::strcmp(lv_label_get_text(caption), text)) lv_label_set_text(caption, text); } if (page->cancel) { if (state.busy) lv_obj_remove_state(page->cancel, LV_STATE_DISABLED); else lv_obj_add_state(page->cancel, LV_STATE_DISABLED); } + if (page->view_map) + { + if (state.phase == tc::Phase::Succeeded && ::gps::ui::runtime::is_available()) lv_obj_remove_flag(page->view_map, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(page->view_map, LV_OBJ_FLAG_HIDDEN); + } +} +void choose_quality() +{ + if (!page || page->step != 1) return; + quality = lv_dropdown_get_selected(page->fields[0]); + draft.osm = lv_obj_has_state(page->layers[0], LV_STATE_CHECKED); + draft.terrain = lv_obj_has_state(page->layers[1], LV_STATE_CHECKED); + draft.satellite = lv_obj_has_state(page->layers[2], LV_STATE_CHECKED); + if (!quality) suggest_zoom(); + else if (quality < 4) + { + constexpr uint8_t minimum[]{0, 8, 12, 14}, maximum[]{0, 11, 14, 16}; + draft.min_zoom = minimum[quality]; + draft.max_zoom = maximum[quality]; + } + else return; + if (page->fields[1]) lv_dropdown_set_selected(page->fields[1], draft.min_zoom); + if (page->fields[2]) lv_dropdown_set_selected(page->fields[2], draft.max_zoom); } void render() { @@ -288,19 +526,26 @@ void render() page->focus.clear(); page->fields.fill(nullptr); page->layers.fill(nullptr); - page->cancel = page->new_map = nullptr; + page->cancel = page->new_map = page->forward = page->area_summary = page->error = page->view_map = nullptr; + page->last_status.clear(); + page->last_percent = -2; lv_obj_clean(page->root); - const bool dense = ::ui::page_profile::current().dense; + const auto& profile = ::ui::page_profile::current(); + const bool dense = profile.dense; lv_obj_set_style_pad_all(page->root, dense ? 3 : 12, 0); + lv_obj_set_style_pad_row(page->root, dense ? 3 : 8, 0); + lv_obj_set_style_text_font(page->root, dense ? profile.tiny_font : profile.body_font, 0); + lv_obj_set_style_text_color(page->root, ::ui::theme::text(), 0); lv_obj_set_flex_flow(page->root, LV_FLEX_FLOW_COLUMN); char heading[100]; - std::snprintf(heading, sizeof(heading), "%s %u/7 - %s", ::ui::i18n::tr("Make TMAP"), page->step + 1, ::ui::i18n::tr(titles[page->step])); + std::snprintf(heading, sizeof(heading), "%s %u/5 - %s", ::ui::i18n::tr("Make TMAP"), page->step + 1, ::ui::i18n::tr(titles[page->step])); auto* header = row(page->root); + lv_obj_set_style_text_font(header, profile.title_font, 0); auto* title = label(header, heading); lv_obj_set_width(title, 0); lv_obj_set_flex_grow(title, 1); - auto* help_hint = label(header, "H Help"); - lv_obj_set_width(help_hint, LV_SIZE_CONTENT); + auto* help = label(header, "H Help"); + lv_obj_set_width(help, LV_SIZE_CONTENT); ::ui::input::pager::install_help(page->root, title); auto* body = page->body = lv_obj_create(page->root); lv_obj_set_width(body, LV_PCT(100)); @@ -308,46 +553,85 @@ void render() lv_obj_set_flex_grow(body, 1); lv_obj_set_style_pad_all(body, dense ? 2 : 8, 0); lv_obj_set_style_pad_row(body, dense ? 3 : 10, 0); + lv_obj_set_style_radius(body, dense ? 4 : 6, 0); + lv_obj_set_style_border_color(body, ::ui::theme::separator(), 0); + lv_obj_set_style_border_width(body, 1, 0); lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_bg_color(body, ::ui::theme::surface(), 0); - lv_obj_set_style_text_font(body, ::ui::page_profile::resolve_body_font(), 0); + lv_obj_set_style_text_font(body, profile.body_font, 0); if (page->step == 0) { - focus(body); - page->sources = tc::service()->sources(); - const auto directory = tc::service()->snapshot().source_directory; - label(body, directory.empty() ? "Select a local OSM PBF." : "Local .osm.pbf folder:\n" + directory); - std::string options; - unsigned selected = 0; - for (size_t i = 0; i < page->sources.size(); ++i) + if (page->coordinates_mode) { - if (i) options += '\n'; - options += page->sources[i].name; - if (page->sources[i].path == draft.source) selected = i; + const char* names[]{"West", "South", "East", "North"}; + for (unsigned i = 0; i < 4; ++i) field(names[i], coordinates[i], i, 24); + } + else + { + const auto state = tc::service()->areaSearch(); + page->areas = state.results; + auto* editor = field("Place", query_text, 0, 192); + lv_textarea_set_placeholder_text(editor, "City / district / country"); + lv_obj_add_event_cb(editor, search_area, LV_EVENT_READY, nullptr); + auto* line = row(body); + auto* search = button(line, state.busy ? "Searching" : "Search", search_area); + if (state.busy) lv_obj_add_state(search, LV_STATE_DISABLED); + auto* hint = label(line, "Global search / Photon"); + lv_obj_set_width(hint, 0); + lv_obj_set_flex_grow(hint, 1); + lv_obj_set_style_text_font(hint, profile.tiny_font, 0); + std::string options; + unsigned selected = 0; + for (size_t i = 0; i < page->areas.size(); ++i) + { + if (i) options += '\n'; + options += page->areas[i].name + " / " + page->areas[i].country; + if (page->areas[i].id == selected_area.id) selected = i; + } + page->fields[1] = dropdown(body, options.empty() ? "Search, then select an area" : options.c_str(), selected); + lv_obj_set_width(page->fields[1], LV_PCT(100)); + if (page->areas.empty() || state.busy) lv_obj_add_state(page->fields[1], LV_STATE_DISABLED); + lv_obj_add_event_cb( + page->fields[1], [](lv_event_t*) + { update_area_summary(); }, + LV_EVENT_VALUE_CHANGED, nullptr); + page->area_summary = label(body, state.error.empty() ? "Enter a place name, then press Enter or choose Search." : state.error); + lv_obj_set_style_text_font(page->area_summary, profile.tiny_font, 0); + if (!state.error.empty()) lv_obj_set_style_text_color(page->area_summary, ::ui::theme::error(), 0); + if (!page->areas.empty()) update_area_summary(); } - page->fields[0] = dropdown(body, options.empty() ? "No local PBF files" : options.c_str(), selected); - lv_obj_set_width(page->fields[0], LV_PCT(100)); - button(body, "Refresh", [](lv_event_t*) - { remember_source(); page->awaiting_sources = true; page->source_revision = tc::service()->snapshot().source_revision; render(); }); - if (!tc::service()->snapshot().producer_ready) label(body, "TMAP producer is not installed or is still being checked."); } else if (page->step == 1) - { - const char* names[] = {"West", "South", "East", "North"}; - for (unsigned i = 0; i < 4; ++i) field(names[i], coordinates[i], i, 24); - } - else if (page->step == 2) { auto* line = row(body); - const char* names[] = {"OSM", "Terrain", "Satellite"}; - const bool values[] = {draft.osm, draft.terrain, draft.satellite}; + const char* names[]{"OSM", "Terrain", "Satellite"}; + const bool values[]{draft.osm, draft.terrain, draft.satellite}; for (unsigned i = 0; i < 3; ++i) { auto* box = page->layers[i] = lv_checkbox_create(line); + lv_obj_set_style_pad_all(box, 0, 0); + lv_obj_set_style_pad_column(box, 3, 0); + lv_obj_set_style_pad_all(box, dense ? 1 : 3, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(box, ::ui::theme::surface(), LV_PART_INDICATOR); + lv_obj_set_style_border_color(box, ::ui::theme::border(), LV_PART_INDICATOR); + lv_obj_set_style_border_color(box, ::ui::theme::accent(), lv_style_selector_t(LV_PART_INDICATOR) | lv_style_selector_t(LV_STATE_CHECKED)); + lv_obj_set_style_border_color(box, ::ui::theme::accent(), lv_style_selector_t(LV_PART_INDICATOR) | lv_style_selector_t(LV_STATE_FOCUS_KEY)); + lv_obj_set_style_text_color(box, ::ui::theme::text(), lv_style_selector_t(LV_PART_INDICATOR) | lv_style_selector_t(LV_STATE_CHECKED)); + lv_obj_set_style_bg_color(box, ::ui::theme::accent(), lv_style_selector_t(LV_PART_INDICATOR) | lv_style_selector_t(LV_STATE_CHECKED)); lv_checkbox_set_text(box, names[i]); if (values[i]) lv_obj_add_state(box, LV_STATE_CHECKED); focus(box); + lv_obj_add_event_cb( + box, [](lv_event_t*) + { choose_quality(); }, + LV_EVENT_VALUE_CHANGED, nullptr); } + page->fields[0] = dropdown(body, "Balanced (automatic)\nOverview (z8-11)\nWalking (z12-14)\nDetailed (z14-16)\nCustom zoom", quality); + lv_obj_set_width(page->fields[0], LV_PCT(100)); + lv_obj_add_event_cb( + page->fields[0], [](lv_event_t*) + { choose_quality(); }, + LV_EVENT_VALUE_CHANGED, nullptr); std::string zooms; for (unsigned z = 0; z <= 18; ++z) { @@ -355,59 +639,108 @@ void render() zooms += std::to_string(z); } auto* zoom = row(body); - label(zoom, "Zoom"); - lv_obj_set_width(lv_obj_get_child(zoom, 0), dense ? 50 : 120); - page->fields[0] = dropdown(zoom, zooms.c_str(), draft.min_zoom); - page->fields[1] = dropdown(zoom, zooms.c_str(), draft.max_zoom); - lv_obj_set_width(page->fields[0], dense ? 72 : 120); - lv_obj_set_width(page->fields[1], dense ? 72 : 120); - label(body, "POI, search and labels are included. Terrain/imagery may download source data."); + auto* caption = label(zoom, "Zoom"); + lv_obj_set_width(caption, dense ? 50 : 120); + for (unsigned i = 1; i <= 2; ++i) + { + page->fields[i] = dropdown(zoom, zooms.c_str(), i == 1 ? draft.min_zoom : draft.max_zoom); + lv_obj_set_width(page->fields[i], dense ? 72 : 120); + lv_obj_add_event_cb( + page->fields[i], [](lv_event_t*) + { quality = 4; lv_dropdown_set_selected(page->fields[0], quality); }, + LV_EVENT_VALUE_CHANGED, nullptr); + } + auto* hint = label(body, "Map data downloads automatically. POI, search and labels are included."); + lv_obj_set_style_text_font(hint, profile.tiny_font, 0); } - else if (page->step == 3) + else if (page->step == 2) { field("Name", draft.name, 0); - field("Package key", draft.package_key, 1); - label(body, "Keep the package key stable for the same area. Separate areas use separate keys."); + field("Filename", draft.filename, 1); + button(body, page->advanced ? "Hide advanced" : "Advanced", [](lv_event_t*) + { + remember_fields(); page->advanced = !page->advanced; + if (page->advanced) { page->awaiting_sources = true; page->source_revision = tc::service()->snapshot().source_revision; } + render(); }); + if (page->advanced) + { + field("Key", draft.package_key, 2); + field("Revision", revision_text, 3, 18); + auto* line = row(body); + auto* caption = label(line, "Limit"); + lv_obj_set_width(caption, dense ? 62 : 160); + const unsigned selected = draft.maximum_bytes <= 128ULL * 1024 * 1024 ? 0 : draft.maximum_bytes <= 512ULL * 1024 * 1024 ? 1 + : draft.maximum_bytes <= 1024ULL * 1024 * 1024 ? 2 + : 3; + page->fields[4] = dropdown(line, "128 MiB\n512 MiB\n1 GiB\n2 GiB", selected); + lv_obj_set_width(page->fields[4], dense ? 110 : 160); + page->sources = tc::service()->sources(); + std::string sources = "Online download"; + unsigned source = 0; + for (size_t i = 0; i < page->sources.size(); ++i) + { + sources += "\n" + page->sources[i].name; + if (!draft.download_source && page->sources[i].path == draft.source) source = i + 1; + } + page->fields[5] = dropdown(body, sources.c_str(), source); + lv_obj_set_width(page->fields[5], LV_PCT(100)); + auto* hint = label(body, "Online is the default. Local PBF is optional."); + lv_obj_set_style_text_font(hint, profile.tiny_font, 0); + } } - else if (page->step == 4) - { - field("Filename", draft.filename, 0); - field("Revision", std::to_string(draft.revision), 1, 18); - auto* line = row(body); - auto* caption = label(line, "Output limit"); - lv_obj_set_width(caption, dense ? 78 : 160); - const unsigned selected = draft.maximum_bytes <= 128ULL * 1024 * 1024 ? 0 : draft.maximum_bytes <= 512ULL * 1024 * 1024 ? 1 - : draft.maximum_bytes <= 1024ULL * 1024 * 1024 ? 2 - : 3; - page->fields[2] = dropdown(line, "128 MiB\n512 MiB\n1 GiB\n2 GiB", selected); - } - else if (page->step == 5) + else if (page->step == kCreation - 1) { const auto estimate = tc::service()->estimate(draft); - label(body, draft.name + "\n" + draft.filename + "\n" + std::to_string(estimate.tiles) + " tiles; raw pixels " + size(estimate.raster_bytes) + "\nWorking space " + size(estimate.working_bytes) + " / free " + size(estimate.available_bytes)); - label(body, dense ? "Estimate excludes indexes/fonts." : "Search, indexes and fonts add space; deduplication may reduce pixels. Existing output files are preserved."); - if (!estimate.valid) label(body, estimate.error); + label(body, draft.name + "\n" + (draft.download_source ? "Download selected area" : "Use local PBF") + + "; zoom " + std::to_string(draft.min_zoom) + "-" + std::to_string(draft.max_zoom) + + "\n" + std::to_string(estimate.tiles) + " tiles; raw pixels " + size(estimate.raster_bytes) + + "\nWorking space " + size(estimate.working_bytes) + " / free " + size(estimate.available_bytes)); + auto* attribution = label(body, "Map data: OpenStreetMap contributors. Photon area search. Estimates exclude indexes/fonts."); + lv_obj_set_style_text_font(attribution, profile.tiny_font, 0); + if (!estimate.valid) + { + auto* error = label(body, estimate.error); + lv_obj_set_style_text_color(error, ::ui::theme::error(), 0); + } } else { page->status = label(body, "Preparing map creation..."); page->progress = lv_bar_create(body); + lv_obj_set_style_bg_color(page->progress, ::ui::theme::separator(), 0); + lv_obj_set_style_bg_color(page->progress, ::ui::theme::accent(), LV_PART_INDICATOR); lv_obj_set_width(page->progress, LV_PCT(100)); lv_obj_set_height(page->progress, 10); progress(); } page->error = label(body, ""); - if (page->step >= 5) focus(body); + lv_obj_set_style_text_color(page->error, ::ui::theme::error(), 0); + lv_obj_add_flag(page->error, LV_OBJ_FLAG_HIDDEN); + if (page->step >= kCreation - 1) focus(body); auto* navigation = row(page->root); - button(navigation, page->step && page->step != 6 ? "Back" : "Close", previous); - if (page->step == 6) + button(navigation, page->step && page->step != kCreation ? "Back" : "Close", previous); + if (page->step == kCreation) { page->cancel = button(navigation, "Cancel", [](lv_event_t*) { if (tc::service()) tc::service()->cancel(); }); + page->view_map = button(navigation, "View map", view_map); page->new_map = button(navigation, "New map", next); progress(); } - else button(navigation, page->step == 5 ? "Create" : "Next", next); + else + { + page->forward = button(navigation, page->step == kCreation - 1 ? "Create" : "Next", next); + if ((page->step == 0 && !page->coordinates_mode && (page->areas.empty() || tc::service()->areaSearch().busy)) || + (page->step == kCreation - 1 && !tc::service()->estimate(draft).valid)) + lv_obj_add_state(page->forward, LV_STATE_DISABLED); + if (!page->step) button(navigation, page->coordinates_mode ? "Region search" : "Coordinates", [](lv_event_t*) + { page->coordinates_mode = !page->coordinates_mode; render(); }); + } + auto* hint = label(navigation, "Tab / Enter"); + lv_obj_set_width(hint, 0); + lv_obj_set_flex_grow(hint, 1); + lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_set_style_text_color(hint, ::ui::theme::text_muted(), 0); if (app_g && !page->focus.empty()) lv_group_focus_obj(page->focus.front()); } } // namespace @@ -420,10 +753,13 @@ void open(lv_obj_t* parent, void (*closed)(void*), void* context) draft.name = "Local map"; draft.filename = "local-area.tmap"; draft.package_key = "local-area"; + draft.download_source = true; + draft.maximum_bytes = 128ULL * 1024 * 1024; } page = std::make_unique(); page->closed = closed; page->context = context; + page->search_revision = tc::service()->areaSearch().revision; page->root = lv_obj_create(parent); lv_obj_set_size(page->root, LV_PCT(100), LV_PCT(100)); lv_obj_set_pos(page->root, 0, 0); @@ -433,19 +769,24 @@ void open(lv_obj_t* parent, void (*closed)(void*), void* context) lv_obj_set_style_bg_color(page->root, ::ui::theme::page_bg(), 0); lv_obj_set_style_border_width(page->root, 0, 0); lv_obj_remove_flag(page->root, LV_OBJ_FLAG_SCROLLABLE); - if (tc::service()->snapshot().phase != tc::Phase::Idle) page->step = 6; - page->awaiting_sources = true; - page->source_revision = tc::service()->snapshot().source_revision; + if (tc::service()->snapshot().phase != tc::Phase::Idle) page->step = kCreation; render(); page->timer = lv_timer_create([](lv_timer_t*) { - if (page && app_g && lv_group_get_default() != app_g) return; - if (page && page->step == 0 && page->awaiting_sources && tc::service() && tc::service()->snapshot().source_revision != page->source_revision) + if (!page || !tc::service() || (app_g && lv_group_get_default() != app_g)) return; + if (page->step == 0 && !page->coordinates_mode) { - const auto selected = app_g ? lv_group_get_focused(app_g) : nullptr; - const auto index = std::find(page->focus.begin(), page->focus.end(), selected) - page->focus.begin(); - remember_source(); page->awaiting_sources = false; render(); - if (app_g && size_t(index) < page->focus.size()) lv_group_focus_obj(page->focus[index]); + const auto state = tc::service()->areaSearch(); + if (state.revision != page->search_revision && !state.busy) + { + page->search_revision = state.revision; render(); + if (app_g && !page->areas.empty() && query_text == state.query) lv_group_focus_obj(page->fields[1]); + } + } + if (page && page->step == 2 && page->advanced && page->awaiting_sources && + tc::service()->snapshot().source_revision != page->source_revision) + { + remember_fields(); page->awaiting_sources = false; render(); } progress(); }, 250, nullptr); @@ -453,6 +794,9 @@ void open(lv_obj_t* parent, void (*closed)(void*), void* context) void shutdown() { if (!page) return; + lv_async_call_cancel(return_from_map, nullptr); + if (page->map_open) ::gps::ui::runtime::exit(page->map_surface); + if (page->map_surface) lv_obj_del(page->map_surface); if (page->timer) lv_timer_del(page->timer); if (app_g) lv_group_remove_all_objs(app_g); if (page->root && lv_obj_is_valid(page->root)) lv_obj_del(page->root); diff --git a/modules/ui_shared/src/ui/widgets/top_bar.cpp b/modules/ui_shared/src/ui/widgets/top_bar.cpp index b7558c72..c5dda816 100644 --- a/modules/ui_shared/src/ui/widgets/top_bar.cpp +++ b/modules/ui_shared/src/ui/widgets/top_bar.cpp @@ -124,8 +124,18 @@ void top_bar_init(TopBar& bar, lv_obj_t* parent, const TopBarConfig& config) #else const lv_coord_t back_btn_radius = std::max(large_touch ? 16 : 10, back_btn_height / 2); #endif - const lv_coord_t right_label_width = large_touch ? 156 : (narrow ? 56 : (dense ? 72 : (resolved_height >= 40 ? 120 : 90))); + const lv_coord_t right_label_width = +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + 64; +#else + large_touch ? 156 : (narrow ? 56 : (dense ? 72 : (resolved_height >= 40 ? 120 : 90))); +#endif const lv_font_t* text_font = resolve_top_bar_font(resolved_height); + const lv_font_t* status_font = text_font; +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + // Preserve room for the full battery percentage on the 320-pixel display. + status_font = &lv_font_montserrat_12; +#endif bar.container = lv_obj_create(parent); lv_obj_set_size(bar.container, LV_PCT(100), resolved_height); @@ -208,11 +218,16 @@ void top_bar_init(TopBar& bar, lv_obj_t* parent, const TopBarConfig& config) lv_obj_set_style_min_width(bar.title_label, 0, 0); lv_obj_set_style_text_align(bar.title_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_max_width(bar.title_label, LV_PCT(100), 0); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + lv_label_set_long_mode(bar.title_label, LV_LABEL_LONG_DOT); + lv_obj_set_height(bar.title_label, lv_font_get_line_height(text_font)); + lv_obj_set_style_text_align(bar.title_label, LV_TEXT_ALIGN_LEFT, 0); +#endif bar.right_label = lv_label_create(bar.container); lv_label_set_text(bar.right_label, ""); lv_obj_set_width(bar.right_label, right_label_width); - lv_obj_set_height(bar.right_label, lv_font_get_line_height(text_font)); + lv_obj_set_height(bar.right_label, lv_font_get_line_height(status_font)); lv_label_set_long_mode(bar.right_label, LV_LABEL_LONG_DOT); lv_obj_set_style_text_color(bar.right_label, #if defined(ARDUINO_T_DECK_PRO) @@ -222,7 +237,7 @@ void top_bar_init(TopBar& bar, lv_obj_t* parent, const TopBarConfig& config) #endif 0); lv_obj_set_style_text_align(bar.right_label, LV_TEXT_ALIGN_RIGHT, 0); - lv_obj_set_style_text_font(bar.right_label, text_font, 0); + lv_obj_set_style_text_font(bar.right_label, status_font, 0); lv_obj_set_style_bg_opa(bar.right_label, LV_OPA_TRANSP, 0); if (config.power_indicator) @@ -235,7 +250,13 @@ void top_bar_init(TopBar& bar, lv_obj_t* parent, const TopBarConfig& config) // Dialog headers own their shortcuts. Registering them as pages would // make H open another Help instead of closing the current dialog. auto* help_hint = lv_label_create(bar.container); - lv_label_set_text(help_hint, narrow ? "H ?" : "H Help"); + lv_label_set_text(help_hint, +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + "H?" +#else + narrow ? "H ?" : "H Help" +#endif + ); lv_obj_set_style_text_font(help_hint, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(help_hint, ui::theme::text(), 0); lv_obj_add_flag(help_hint, LV_OBJ_FLAG_CLICKABLE); @@ -270,6 +291,13 @@ void top_bar_set_title(TopBar& bar, const char* title) lv_label_set_text(bar.title_label, title); ::ui::fonts::apply_content_font( bar.title_label, title, resolve_top_bar_font(lv_obj_get_height(bar.container))); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + // DOT can wrap when height is LV_SIZE_CONTENT. Constrain the label to + // exactly one line, including when a fallback font handles a peer name. + const auto* font = lv_obj_get_style_text_font(bar.title_label, LV_PART_MAIN); + lv_obj_set_height(bar.title_label, std::min( + lv_font_get_line_height(font), lv_obj_get_content_height(bar.container))); +#endif } void top_bar_set_right_text(TopBar& bar, const char* text) @@ -289,7 +317,13 @@ void top_bar_set_right_text(TopBar& bar, const char* text) lv_label_set_text(bar.right_label, text); ::ui::fonts::apply_content_font( - bar.right_label, text, resolve_top_bar_font(lv_obj_get_height(bar.container))); + bar.right_label, text, +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + &lv_font_montserrat_12 +#else + resolve_top_bar_font(lv_obj_get_height(bar.container)) +#endif + ); } void top_bar_set_right_text_ascii(TopBar& bar, const char* text) @@ -307,7 +341,13 @@ void top_bar_set_right_text_ascii(TopBar& bar, const char* text) lv_label_set_text(bar.right_label, text); ::ui::fonts::apply_font( - bar.right_label, resolve_top_bar_font(lv_obj_get_height(bar.container))); + bar.right_label, +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + &lv_font_montserrat_12 +#else + resolve_top_bar_font(lv_obj_get_height(bar.container)) +#endif + ); } void top_bar_set_back_callback(TopBar& bar, void (*cb)(void*), void* user_data) diff --git a/modules/ui_shared/tests/test_cardputer_zero_menu_profile.cpp b/modules/ui_shared/tests/test_cardputer_zero_menu_profile.cpp index f0939f8f..66f8f4d0 100644 --- a/modules/ui_shared/tests/test_cardputer_zero_menu_profile.cpp +++ b/modules/ui_shared/tests/test_cardputer_zero_menu_profile.cpp @@ -23,24 +23,32 @@ int main() const auto& profile = ui::menu_profile::current(); assert(std::strcmp(profile.name, "cardputer_zero") == 0); - assert(profile.variant == ui::menu_profile::LayoutVariant::PagerFocus); - assert(profile.badge_anchor_mode == ui::menu_profile::BadgeAnchorMode::IconTopLeft); - assert(profile.snap_center); - assert(!profile.vertical_scroll); - assert(!profile.wrap_grid); + const auto pager = ui::menu_profile::make_pager_profile(); + assert(profile.variant == pager.variant); + assert(profile.variant == ui::menu_profile::LayoutVariant::CompactGrid); + assert(profile.badge_anchor_mode == pager.badge_anchor_mode); + assert(!profile.snap_center); + assert(profile.vertical_scroll); + assert(profile.wrap_grid); + assert(profile.transparent_cards); assert(profile.input_mode == ui::menu_profile::InputMode::Hybrid); assert(profile.directional_key_nav); assert(profile.show_top_bar); assert(!profile.show_memory_stats); - assert(!profile.show_card_label); - assert(profile.show_desc_label); + assert(profile.show_card_label); + assert(!profile.show_desc_label); assert(profile.show_node_id); - assert(profile.card_width == 112); - assert(profile.card_height == 80); - assert(profile.icon_scale == 208); - assert(profile.grid_height_pct == 50); + // Four columns and two complete rows fit between the fixed status bars. + const int columns = (320 - profile.grid_pad_left - profile.grid_pad_right + profile.grid_pad_column) / + (profile.card_width + profile.grid_pad_column); + assert(columns == 4); + const int available_height = 170 - 2 * profile.top_bar_height; + assert(2 * profile.card_height + profile.grid_pad_row <= available_height); + assert(3 * profile.card_height + 2 * profile.grid_pad_row > available_height); + assert(profile.icon_scale == 128); + assert(profile.card_label_font == &lv_font_montserrat_10); assert(profile.grid_top_offset == 26); assert(profile.top_bar_height == 24); return 0; diff --git a/platform/esp/arduino_common/src/chat/infra/auto_reply_observer.cpp b/platform/esp/arduino_common/src/chat/infra/auto_reply_observer.cpp index 3bb294fd..56385c2a 100644 --- a/platform/esp/arduino_common/src/chat/infra/auto_reply_observer.cpp +++ b/platform/esp/arduino_common/src/chat/infra/auto_reply_observer.cpp @@ -1,95 +1,11 @@ #include "platform/esp/arduino_common/chat/infra/auto_reply_observer.h" - -#include "app/app_facade_access.h" -#include "chat/usecase/auto_reply_policy.h" -#include "platform/ui/auto_reply_settings.h" -#include "platform/ui/screen_runtime.h" -#include "platform/ui/settings_store.h" - -#include -#include +#include "platform/shared/chat/auto_reply_observer.h" namespace chat::infra { -namespace -{ - -bool has_reply_text(const std::string& text) -{ - if (text.empty() || text.size() > platform::ui::auto_reply::kTextMaxBytes) - { - return false; - } - - for (const unsigned char character : text) - { - if (!std::isspace(character)) - { - return true; - } - } - return false; -} - -class AutoReplyObserver final : public chat::ChatService::IncomingMessageObserver -{ - public: - explicit AutoReplyObserver(chat::ChatService& service) : service_(service) - { - service_.addIncomingMessageObserver(this); - } - - ~AutoReplyObserver() override - { - service_.removeIncomingMessageObserver(this); - } - - void onIncomingMessage(const chat::ChatMessage& message, - const chat::RxMeta* /*rx_meta*/) override - { - const bool enabled = platform::ui::settings_store::get_bool( - platform::ui::auto_reply::kSettingsNamespace, - platform::ui::auto_reply::kEnabledKey, - false); - if (!enabled) - { - return; - } - - std::string reply_text; - const bool has_saved_text = platform::ui::settings_store::get_string( - platform::ui::auto_reply::kSettingsNamespace, - platform::ui::auto_reply::kTextKey, - reply_text); - const chat::ConversationId conversation = chat::conversationIdForMessage(message); - const chat::NodeId self_node = app::appFacade().getSelfNodeId(); - const chat::auto_reply::Context context{ - enabled, - has_saved_text && has_reply_text(reply_text), - platform::ui::screen::is_sleeping(), - self_node != 0 && message.from == self_node, - service_.canSendToConversation(conversation), - }; - if (chat::auto_reply::decide(message, context) != - chat::auto_reply::Decision::Reply) - { - return; - } - - (void)service_.sendTextToConversationDetailed(conversation, reply_text); - } - - private: - chat::ChatService& service_; -}; - -} // namespace - std::unique_ptr create_auto_reply_observer(chat::ChatService& service) { - return std::unique_ptr( - new AutoReplyObserver(service)); + return platform::shared::chat::create_auto_reply_observer(service); } - } // namespace chat::infra diff --git a/platform/esp/idf_common/src/lr2021_radio.cpp b/platform/esp/idf_common/src/lr2021_radio.cpp index ade76812..3b2b84bc 100644 --- a/platform/esp/idf_common/src/lr2021_radio.cpp +++ b/platform/esp/idf_common/src/lr2021_radio.cpp @@ -16,6 +16,7 @@ #include "platform/radio/radio_power.h" #include "platform/shared/radio/fm_lr2021_stream.h" #include "platform/shared/radio/lora_receive_config.h" +#include "platform/shared/radio/restart_receive.h" #include #include #include @@ -278,7 +279,11 @@ struct State if (!Board::instance().setLoraRfSwitchTransmit(false)) return check(-1, "LR2021 antenna RX path failed"); if (transmitting && !check(radio.finishTransmit(), "LR2021 finish TX failed")) return false; transmitting = false; - receiving = check(radio.startReceive(), "LR2021 start RX failed"); + receiving = platform::shared::radio::restartReceive( + radio, + [this](int code, const char* step) + { return check(code, step); }, + "LR2021 start RX failed"); return receiving; } bool beginLoRa(const LoRaConfig& config) 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 10539011..44bb64a8 100644 --- a/platform/esp/idf_common/src/platform_ui_lora_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_lora_runtime.cpp @@ -177,9 +177,11 @@ bool poll_received_packet(uint8_t* buffer, std::size_t capacity, ReceivedPacket* const float rssi_dbm = packet_fits ? radio().readRssi() : 0.0f; radio().clearIrqFlags(irq); - const bool receiving = radio().startReceive(); + // A valid completed read belongs to the caller even if the next receive + // window cannot be armed. The driver records that independent failure. + (void)radio().startReceive(); - if (!packet_fits || read_state != 0 || !receiving) + if (!packet_fits || read_state != 0) { return false; } diff --git a/platform/esp/radio/idf_lora_radio_pump.cpp b/platform/esp/radio/idf_lora_radio_pump.cpp index d908599f..d79c2358 100644 --- a/platform/esp/radio/idf_lora_radio_pump.cpp +++ b/platform/esp/radio/idf_lora_radio_pump.cpp @@ -9,6 +9,7 @@ namespace constexpr uint32_t kIrqRxDone = 0x0002; constexpr uint32_t kIrqTxDone = 0x0001; +constexpr uint32_t kIrqReceiveError = 0x0020 | 0x0040 | 0x0200; constexpr int kRadioOk = 0; } // namespace @@ -74,17 +75,27 @@ IdfLoraPollResult IdfLoraRadioPump::poll(IdfLoraRadioFrame& out) return IdfLoraPollResult::None; } - board_.clearRadioIrqFlags(out.irq); + // Preamble/sync/header-valid IRQs describe a packet still in flight. + // Restart only on receive completion or a terminal error, never progress. if ((out.irq & kIrqRxDone) == 0) { + if ((out.irq & kIrqReceiveError) == 0) return IdfLoraPollResult::None; + board_.clearRadioIrqFlags(out.irq); return restartReceive() ? IdfLoraPollResult::ReceiveRestarted : IdfLoraPollResult::ReceiveRestartFailed; } + if (out.irq & kIrqReceiveError) + { + board_.clearRadioIrqFlags(out.irq); + if (!restartReceive()) return IdfLoraPollResult::ReceiveRestartFailed; + return IdfLoraPollResult::ReadFailed; + } out.packet_length = board_.getRadioPacketLength(true); if (out.packet_length <= 0 || out.packet_length > static_cast(rx_scratch_.size())) { + board_.clearRadioIrqFlags(out.irq); if (!restartReceive()) return IdfLoraPollResult::ReceiveRestartFailed; return IdfLoraPollResult::InvalidPacketLength; } @@ -92,6 +103,7 @@ IdfLoraPollResult IdfLoraRadioPump::poll(IdfLoraRadioFrame& out) if (board_.readRadioData(rx_scratch_.data(), static_cast(out.packet_length)) != kRadioOk) { + board_.clearRadioIrqFlags(out.irq); if (!restartReceive()) return IdfLoraPollResult::ReceiveRestartFailed; return IdfLoraPollResult::ReadFailed; } @@ -100,6 +112,8 @@ IdfLoraPollResult IdfLoraRadioPump::poll(IdfLoraRadioFrame& out) out.len = static_cast(out.packet_length); out.rssi = board_.getRadioRSSI(); out.snr = board_.getRadioSNR(); + // RadioLib validates IRQ metadata while reading. Clear it only after read. + board_.clearRadioIrqFlags(out.irq); // Deliver a packet that has already been read even if arming the next // receive window fails. The next poll will retry it and report that // failure instead of silently throwing away real traffic. diff --git a/platform/linux/common/include/chat/linux_raw_lora_mesh_adapter.h b/platform/linux/common/include/chat/linux_raw_lora_mesh_adapter.h index 991266a7..801ec9e7 100644 --- a/platform/linux/common/include/chat/linux_raw_lora_mesh_adapter.h +++ b/platform/linux/common/include/chat/linux_raw_lora_mesh_adapter.h @@ -286,6 +286,16 @@ class LinuxRawLoraMeshAdapter final : public ::chat::IMeshAdapter void* message_id_context_ = nullptr; void publishSendResult(::chat::MessageId id, ::chat::MeshProtocol protocol, ::chat::MessageStatus status, ::chat::delivery::SendFailureKind failure); + struct MeshtasticPendingAck + { + ::chat::MessageId message = 0; + ::chat::NodeId peer = 0; + uint32_t sent_at = 0; + }; + std::array meshtastic_pending_acks_{}; + void expireMeshtasticAcks(); + void cancelMeshtasticAcks(); + void consumeMeshtasticRouting(const ::chat::meshtastic::PacketHeaderWire&, const meshtastic_Data&, bool encrypted); ::platform::linux_runtime::RetryBackoff radio_retry_; Clock retry_clock_; uint32_t self_announcement_started_ms_ = 0; diff --git a/platform/linux/common/include/chat/linux_reticulum_radio_announce.h b/platform/linux/common/include/chat/linux_reticulum_radio_announce.h new file mode 100644 index 00000000..7f28bf9d --- /dev/null +++ b/platform/linux/common/include/chat/linux_reticulum_radio_announce.h @@ -0,0 +1,70 @@ +#pragma once + +#include "chat/linux_reticulum_announce.h" +#include +#include + +namespace trailmate::linux_app +{ +// Owned by the mesh worker. Uses its normal packet sender and never opens a +// second radio or IP interface. +class LinuxReticulumRadioAnnounce +{ + public: + using Sender = bool (*)(void*, const uint8_t*, size_t); + explicit LinuxReticulumRadioAnnounce(const platform::linux_runtime::LinuxReticulumIdentity& identity) + : announce_(identity) {} + + void configure(bool enabled, const std::string& name, uint32_t now) + { + enabled_ = enabled; + name_ = name; + started_ = now; + interval_ = 5000; + responded_ = false; + } + + void tick(uint32_t now, uint32_t utc, Sender send, void* context) + { + if (!enabled_ || uint32_t(now - started_) < interval_) return; + started_ = now; + interval_ = 30000; + if (advertise(utc, false, send, context)) interval_ = 600000; + } + + bool receive(const uint8_t* bytes, size_t size, uint32_t now, uint32_t utc, Sender send, void* context) + { + if (!enabled_) return false; + ::chat::reticulum::ParsedPacket packet; + if (!::chat::reticulum::parsePacket(bytes, size, &packet)) return false; + uint8_t control[16], local[16]; + ::chat::reticulum::computePathRequestDestinationHash(control); + if (packet.packet_type != ::chat::reticulum::PacketType::Data || + packet.destination_type != ::chat::reticulum::DestinationType::Plain || + packet.context != 0 || packet.payload_len < 16 || + std::memcmp(packet.destination_hash, control, 16)) return false; + announce_.destination(LinuxReticulumAnnounce::Aspect::Delivery, local); + if (std::memcmp(packet.payload, local, 16)) return false; + if (!responded_ || uint32_t(now - responded_at_) >= 1000) + { + if (advertise(utc, true, send, context)) + { + responded_ = true; + responded_at_ = now; + } + } + return true; + } + + private: + bool advertise(uint32_t utc, bool response, Sender send, void* context) + { + return send && announce_.build(LinuxReticulumAnnounce::Aspect::Delivery, name_.c_str(), utc, response) && + send(context, announce_.data(), announce_.size()); + } + LinuxReticulumAnnounce announce_; + std::string name_; + uint32_t started_ = 0, interval_ = 5000, responded_at_ = 0; + bool enabled_ = false, responded_ = false; +}; +} // namespace trailmate::linux_app diff --git a/platform/linux/common/include/platform/linux/backlight_screen.h b/platform/linux/common/include/platform/linux/backlight_screen.h new file mode 100644 index 00000000..a0ca01b2 --- /dev/null +++ b/platform/linux/common/include/platform/linux/backlight_screen.h @@ -0,0 +1,69 @@ +#pragma once + +#include "platform/linux/backlight.h" +#include "platform/linux/screen_service.h" +#include +#include +#include + +namespace platform::linux_runtime +{ +// Native framebuffer composition: reuse the asynchronous backlight worker. +// Screen state still belongs to the shared runtime used by auto reply. +class BacklightScreenBinding +{ + public: + explicit BacklightScreenBinding(BacklightOptions backlight = {}, std::function now = {}) + { + if (!configure_backlight(backlight)) throw std::runtime_error("Backlight already configured"); + prepare_backlight(); + ScreenRuntimeOptions options; + options.now_ms = std::move(now); + options.power_snapshot = [this] + { + const auto light = backlight_snapshot(); + if (light.available && light.level > 0) awake_level_ = light.level; + return OutputPowerSnapshot{light.available, light.selection_required, light.failed, + light.level > 0, "backlight"}; + }; + options.request_power = [this](bool on) + { + const bool accepted = request_backlight(on ? awake_level_ : 0); + if (!on && accepted) owns_off_ = true; + return accepted; + }; + if (!configure_screen_runtime(std::move(options))) throw std::runtime_error("Screen already configured"); + } + ~BacklightScreenBinding() + { + shutdown_screen_runtime(); + if (owns_off_) + { + (void)request_backlight(awake_level_); + for (unsigned attempt = 0; attempt < 100 && backlight_snapshot().level != awake_level_; ++attempt) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + shutdown_backlight(); + } + + // The first input wakes; a later confirm dismisses the saver. Neither is + // forwarded to the page underneath it. + static bool consumeWakeInput(bool confirm) + { + namespace screen = ::platform::ui::screen; + if (screen::is_sleeping() || screen::is_saver_active()) + { + if (screen::is_saver_active() && confirm) screen::request_resume(); + else screen::handle_input(); + service_screen_runtime(); + return true; + } + screen::record_activity(); + return false; + } + + private: + uint8_t awake_level_ = 255; + bool owns_off_ = false; +}; +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/cardputer_cap.h b/platform/linux/common/include/platform/linux/cardputer_cap.h new file mode 100644 index 00000000..677f2f5f --- /dev/null +++ b/platform/linux/common/include/platform/linux/cardputer_cap.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace platform::linux_runtime +{ + +struct CardputerCapObservation +{ + bool radio_enabled = true; + bool radio_online = false; + bool gps_enabled = true; + uint32_t gps_chars = 0; + std::string radio_error; + bool radio_endpoint_present = true; +}; + +// The Cap shares its supply between LoRa and GPS. Own the supply outside the +// application session so all radio/UART workers stop before the lease restores it. +class CardputerCap +{ + public: + explicit CardputerCap(std::string power_path = "/sys/class/leds/ext_5v_out/brightness", + uint32_t settle_ms = 250); + ~CardputerCap(); + CardputerCap(const CardputerCap&) = delete; + CardputerCap& operator=(const CardputerCap&) = delete; + + void begin(); + bool pending() const { return active_ && !decided_; } + // Called on the UI thread after startup. The returned notice is emitted at + // most once; an enabled UART without received bytes is not presence evidence. + const char* takeNotice(uint64_t elapsed_ms, const CardputerCapObservation& observation); + + private: + std::string power_path_; + uint32_t settle_ms_; + int original_power_ = 0; + bool active_ = false; + bool restore_power_ = false; + bool power_failed_ = false; + bool decided_ = false; +}; + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/http_download.h b/platform/linux/common/include/platform/linux/http_download.h index 3f59aed0..7d52023d 100644 --- a/platform/linux/common/include/platform/linux/http_download.h +++ b/platform/linux/common/include/platform/linux/http_download.h @@ -7,5 +7,6 @@ namespace platform::linux_runtime bool http_download(const platform::ui::http_client::Request& request, platform::ui::http_client::WriteCallback write, void* context, std::string& error, platform::ui::http_client::TransferStats* stats, - const std::atomic_bool* cancelled = nullptr); + const std::atomic_bool* cancelled = nullptr, + const char* user_agent = nullptr); } // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/iio_sensors.h b/platform/linux/common/include/platform/linux/iio_sensors.h index c8782c31..7f28a88e 100644 --- a/platform/linux/common/include/platform/linux/iio_sensors.h +++ b/platform/linux/common/include/platform/linux/iio_sensors.h @@ -25,8 +25,18 @@ struct ImuSnapshot uint32_t updated_ms = 0, last_motion_ms = 0; }; +struct MagnetometerSnapshot +{ + bool present = false, ready = false; + float x = 0, y = 0, z = 0, heading_deg = 0; + int16_t raw_x = 0, raw_y = 0, raw_z = 0; + uint32_t updated_ms = 0; +}; + // Standard IIO channel names/scales; no sensor-model or board identification. bool read_iio_imu(const std::filesystem::path& device, ImuSnapshot& output); +// Level-device magnetic heading. This is not a calibrated true-north bearing. +bool read_iio_magnetometer(const std::filesystem::path& device, MagnetometerSnapshot& output, int mount_rotation = 0); unsigned gravity_rotation(float x, float y, unsigned previous, int mount_rotation = 0); class IioSensorRuntime @@ -35,6 +45,7 @@ class IioSensorRuntime explicit IioSensorRuntime(IioSensorOptions options); ~IioSensorRuntime(); ImuSnapshot snapshot() const; + MagnetometerSnapshot magnetometerSnapshot() const; private: struct Impl; @@ -43,5 +54,6 @@ class IioSensorRuntime bool configure_iio_sensors(const IioSensorOptions& options); ImuSnapshot linux_imu_snapshot(); +MagnetometerSnapshot linux_magnetometer_snapshot(); void shutdown_iio_sensors(); } // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/private_database_file.h b/platform/linux/common/include/platform/linux/private_database_file.h new file mode 100644 index 00000000..0ba4e564 --- /dev/null +++ b/platform/linux/common/include/platform/linux/private_database_file.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#if defined(__linux__) +#include +#include +#include +#endif + +namespace platform::linux_runtime +{ +// A normal open/close of a SQLite inode drops ALL POSIX locks held by this +// process on that inode. O_PATH handles do not participate in those locks. +// SQLite must create/open the file before this ownership/permission check. +inline bool protect_private_database_file(const std::filesystem::path& path) +{ +#if defined(__linux__) && defined(O_PATH) + const int descriptor = ::open(path.c_str(), O_PATH | O_CLOEXEC | O_NOFOLLOW); + if (descriptor < 0) return false; + struct stat status + { + }; + const bool regular = ::fstat(descriptor, &status) == 0 && S_ISREG(status.st_mode) && status.st_uid == ::geteuid(); + // chmod resolves the pinned O_PATH inode through procfs without opening a + // second read/write descriptor. A substituted path cannot redirect it. + const std::string pinned = "/proc/self/fd/" + std::to_string(descriptor); + const bool protected_file = regular && ::chmod(pinned.c_str(), 0600) == 0; + ::close(descriptor); + return protected_file; +#else + (void)path; + return false; +#endif +} +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/screen_service.h b/platform/linux/common/include/platform/linux/screen_service.h index d69ab0b6..8daff74e 100644 --- a/platform/linux/common/include/platform/linux/screen_service.h +++ b/platform/linux/common/include/platform/linux/screen_service.h @@ -14,6 +14,10 @@ struct ScreenRuntimeOptions platform::ui::screen::ResumeMethod resume_method = platform::ui::screen::ResumeMethod::SpaceKey; // Optional monotonic clock port. The callback must not reenter this service. std::function now_ms; + // Optional native output port. These callbacks only read cached state or + // enqueue work, with the same ownership contract as OutputPowerRuntime. + std::function power_snapshot; + std::function request_power; }; // Configure before UI initialization and before other threads use screen ports. // A null power port leaves automatic hardware sleep unavailable. diff --git a/platform/linux/common/include/platform/linux/tmap_area_search.h b/platform/linux/common/include/platform/linux/tmap_area_search.h new file mode 100644 index 00000000..1b1cab5a --- /dev/null +++ b/platform/linux/common/include/platform/linux/tmap_area_search.h @@ -0,0 +1,21 @@ +#pragma once +#include "platform/ui/tmap_creation_runtime.h" +#include +#include + +namespace platform::linux_runtime +{ +// Explicit searches only. HTTP, parsing and bounded caching run off the UI thread. +class TmapAreaSearch +{ + public: + explicit TmapAreaSearch(std::string endpoint = "https://photon.komoot.io/api/"); + ~TmapAreaSearch(); + bool search(const std::string& query); + ::platform::ui::tmap_creation::AreaSearchSnapshot snapshot(); + + private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/tmap_creation.h b/platform/linux/common/include/platform/linux/tmap_creation.h index 4e769d2d..071c9c61 100644 --- a/platform/linux/common/include/platform/linux/tmap_creation.h +++ b/platform/linux/common/include/platform/linux/tmap_creation.h @@ -10,6 +10,8 @@ struct TmapCreationOptions std::filesystem::path storage, cache; // An installed executable or an explicit composition/test override. std::filesystem::path producer = "/usr/lib/trailmate/tmap-producer/TmapTool"; + std::filesystem::path osmium = "/usr/bin/osmium"; + std::string map_endpoint = "https://overpass.private.coffee/api/interpreter"; }; class LinuxTmapCreation final : public ::platform::ui::tmap_creation::Service { @@ -21,6 +23,8 @@ class LinuxTmapCreation final : public ::platform::ui::tmap_creation::Service bool start(const ::platform::ui::tmap_creation::Plan&, std::string& error) override; ::platform::ui::tmap_creation::Snapshot snapshot() override; void cancel() override; + bool searchAreas(const std::string&) override; + ::platform::ui::tmap_creation::AreaSearchSnapshot areaSearch() override; private: struct Impl; diff --git a/platform/linux/common/src/app/linux_app_services.cpp b/platform/linux/common/src/app/linux_app_services.cpp index 66270281..f9da5bb9 100644 --- a/platform/linux/common/src/app/linux_app_services.cpp +++ b/platform/linux/common/src/app/linux_app_services.cpp @@ -61,6 +61,7 @@ extern char** environ; #include "platform/linux/route_image_tasks.h" #include "platform/linux/settings_reset_binding.h" #include "platform/linux/settings_reset_storage.h" +#include "platform/shared/chat/auto_reply_observer.h" #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" #include "platform/ui/network_runtime.h" @@ -1578,6 +1579,7 @@ struct LinuxAppServices::Implementation : static_cast<::chat::IMeshAdapter&>(raw_lora_mesh_adapter)), chat_service(chat_model, mesh_adapter, chat_store), chat_event_bridge(chat_service), + auto_reply_observer(::platform::shared::chat::create_auto_reply_observer(chat_service)), team_runtime(), team_crypto(), team_event_sink(), @@ -1736,6 +1738,7 @@ struct LinuxAppServices::Implementation ::chat::IMeshAdapter& mesh_adapter; ::chat::ChatService chat_service; LinuxChatEventBusBridge chat_event_bridge; + std::unique_ptr<::chat::ChatService::IncomingMessageObserver> auto_reply_observer; LinuxTeamRuntime team_runtime; LinuxTeamCrypto team_crypto; LinuxTeamEventBusSink team_event_sink; diff --git a/platform/linux/common/src/chat/linux_mesh_worker.cpp b/platform/linux/common/src/chat/linux_mesh_worker.cpp index 6ed260fd..cd45dd37 100644 --- a/platform/linux/common/src/chat/linux_mesh_worker.cpp +++ b/platform/linux/common/src/chat/linux_mesh_worker.cpp @@ -11,6 +11,7 @@ #include "chat/linux_reticulum_links.h" #include "chat/linux_reticulum_network.h" #include "chat/linux_reticulum_propagation.h" +#include "chat/linux_reticulum_radio_announce.h" #include "chat/linux_reticulum_upload.h" #include "chat/linux_track_directories.h" #include "chat/linux_track_publication.h" @@ -563,6 +564,7 @@ struct LinuxMeshWorker::Impl } engine->setMessageIdSource(allocateId, this); platform::linux_runtime::LinuxReticulumIdentity reticulum_identity(platform::linux_runtime::sqlite_database_path()); + LinuxReticulumRadioAnnounce radio_announce(reticulum_identity); std::array propagation_address{}; auto& peer_directory = ::platform::ui::reticulum_directory::mesh_peer_directory(); (void)peer_directory.begin(); @@ -893,11 +895,12 @@ struct LinuxMeshWorker::Impl struct ReticulumRxContext { LinuxReticulumIngress& ingress; + LinuxReticulumRadioAnnounce& announce; PeerSink& sink; LinuxReticulumLinks& links; LinuxReticulumLinks::Sender sender; void* sender_context; - } receive_context{reticulum_ingress, peer_sink, reticulum_links, link_sender, &link_sender_context}; + } receive_context{reticulum_ingress, radio_announce, peer_sink, reticulum_links, link_sender, &link_sender_context}; engine->setReticulumPacketSink([](void* context, const LinuxRawLoraMeshAdapter::ReticulumPacket& packet) { auto& received = *static_cast(context); @@ -912,6 +915,8 @@ struct LinuxMeshWorker::Impl meta.time_source = meta.rx_timestamp_s ? chat::RxTimeSource::DeviceUtc : chat::RxTimeSource::Uptime; meta.rssi_dbm_x10 = static_cast(packet.rssi_dbm * 10); meta.snr_db_x10 = static_cast(packet.snr_db * 10); + if (received.announce.receive(packet.data.data(), packet.size, meta.rx_timestamp_ms, + meta.rx_timestamp_s, received.sender, received.sender_context)) return; if (received.links.receive(packet.data.data(), packet.size, meta.rx_timestamp_ms, received.sender, received.sender_context, &meta)) return; received.ingress.receive(packet.data.data(), packet.size, ::sys::millis_now(), seconds > 0 && uint64_t(seconds) <= UINT32_MAX ? static_cast(seconds) : 0); }, @@ -1066,6 +1071,10 @@ struct LinuxMeshWorker::Impl engine->setPrivacyConfig(settings.privacy); if (apply_radio) engine->applyProtocolConfig(settings.protocol, settings.config); applied_protocol = settings.protocol; + radio_announce.configure(settings.protocol == chat::MeshProtocol::Reticulum && + settings.config.tx_enabled && !settings.config.reticulum_anonymous_peer && + chat::reticulum::integratedLoRaAllowed(settings.config), + settings.name, ::sys::millis_now()); } if (have_command) { @@ -1136,6 +1145,8 @@ struct LinuxMeshWorker::Impl track_publication.tick(trackDirectoryNow(), track_current, applied_epoch); track_read.tick(trackDirectoryNow(), track_current, applied_epoch); engine->tick(); + if (applied_protocol == chat::MeshProtocol::Reticulum && engine->isReady()) + radio_announce.tick(::sys::millis_now(), ::sys::epoch_seconds_now(), link_sender, &link_sender_context); if (applied_protocol == chat::MeshProtocol::Reticulum) reticulum_network.tick(::sys::millis_now(), ::sys::epoch_seconds_now()); bool propagation_current; { diff --git a/platform/linux/common/src/chat/linux_raw_lora_mesh_adapter.cpp b/platform/linux/common/src/chat/linux_raw_lora_mesh_adapter.cpp index 90363760..08d52129 100644 --- a/platform/linux/common/src/chat/linux_raw_lora_mesh_adapter.cpp +++ b/platform/linux/common/src/chat/linux_raw_lora_mesh_adapter.cpp @@ -7,6 +7,7 @@ #include "chat/linux_meshcore_direct_wire.h" #include "chat/linux_meshcore_group_wire.h" #include "chat/runtime/meshcore_runtime.h" +#include "chat/runtime/meshtastic_runtime.h" #include "platform/linux/mesh/linux_radio_identity_store.h" #include "platform/linux/runtime_paths.h" @@ -59,6 +60,7 @@ constexpr std::uint32_t kRxMonitorHeartbeatSeconds = 15; constexpr std::uint8_t kBitfieldWantResponseMask = 0x02; constexpr std::uint32_t kNodeInfoReplySuppressMs = 60000; constexpr std::uint32_t kKeyVerificationTimeoutMs = 60000; +constexpr std::uint32_t kMeshtasticAckTimeoutMs = 60000; constexpr std::size_t kMaxPkiNodes = 16; constexpr std::size_t kPkiKeySize = 32; template @@ -1213,6 +1215,7 @@ bool LinuxRawLoraMeshAdapter::begin() void LinuxRawLoraMeshAdapter::tick() { + expireMeshtasticAcks(); updateMeshCoreVerification(); meshcore_wire::DirectOutcome expired; while (meshcore_direct_.expire(::sys::millis_now(), expired)) @@ -1387,6 +1390,107 @@ void LinuxRawLoraMeshAdapter::publishSendResult(::chat::MessageId id, ::chat::Me if (send_result_sink_) send_result_sink_(send_result_context_, id, protocol, status, failure); else ::sys::EventBus::publish(new ::sys::ChatSendResultEvent(id, status, protocol, failure), 0); } +void LinuxRawLoraMeshAdapter::expireMeshtasticAcks() +{ + const auto now = retry_clock_(); + for (auto& pending : meshtastic_pending_acks_) + { + if (!pending.message || uint32_t(now - pending.sent_at) < kMeshtasticAckTimeoutMs) continue; + const auto id = pending.message; + pending = {}; + publishSendResult(id, ::chat::MeshProtocol::Meshtastic, ::chat::MessageStatus::Failed, + ::chat::delivery::SendFailureKind::AckTimeout); + append_lora_system_log("Meshtastic ACK timeout", "No peer confirmation for " + node_hex(id) + "."); + } +} +void LinuxRawLoraMeshAdapter::cancelMeshtasticAcks() +{ + for (auto& pending : meshtastic_pending_acks_) + { + if (!pending.message) continue; + const auto id = pending.message; + pending = {}; + publishSendResult(id, ::chat::MeshProtocol::Meshtastic, ::chat::MessageStatus::Failed, + ::chat::delivery::SendFailureKind::Rejected); + } +} +void LinuxRawLoraMeshAdapter::consumeMeshtasticRouting( + const ::chat::meshtastic::PacketHeaderWire& header, const meshtastic_Data& decoded, bool encrypted) +{ + if (!encrypted || header.to != self_node_id_ || !decoded.request_id || + decoded.payload.size > sizeof(decoded.payload.bytes)) return; + meshtastic_Routing routing = meshtastic_Routing_init_default; + auto stream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); + if (!pb_decode(&stream, meshtastic_Routing_fields, &routing) || + routing.which_variant != meshtastic_Routing_error_reason_tag) return; + // Official Meshtastic Routing replies use channel encryption even for PKI + // text. Match the addressed recipient and request, rather than treating an + // overheard relay or an unrelated channel packet as delivery confirmation. + for (auto& pending : meshtastic_pending_acks_) + { + if (pending.message != decoded.request_id || pending.peer != header.from) continue; + using Failure = ::chat::delivery::SendFailureKind; + Failure failure = Failure::Unknown; + switch (routing.error_reason) + { + case meshtastic_Routing_Error_NONE: + failure = Failure::None; + break; + case meshtastic_Routing_Error_TIMEOUT: + case meshtastic_Routing_Error_MAX_RETRANSMIT: + case meshtastic_Routing_Error_NO_RESPONSE: + case meshtastic_Routing_Error_NO_ROUTE: + failure = Failure::AckTimeout; + break; + case meshtastic_Routing_Error_NO_INTERFACE: + case meshtastic_Routing_Error_DUTY_CYCLE_LIMIT: + case meshtastic_Routing_Error_RATE_LIMIT_EXCEEDED: + failure = Failure::RadioSendFailed; + break; + case meshtastic_Routing_Error_NO_CHANNEL: + failure = Failure::ChannelKeyMissing; + break; + case meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY: + case meshtastic_Routing_Error_PKI_FAILED: + failure = Failure::PeerKeyMissing; + break; + case meshtastic_Routing_Error_GOT_NAK: + case meshtastic_Routing_Error_BAD_REQUEST: + case meshtastic_Routing_Error_NOT_AUTHORIZED: + case meshtastic_Routing_Error_ADMIN_BAD_SESSION_KEY: + case meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED: + case meshtastic_Routing_Error_TOO_LARGE: + failure = Failure::Rejected; + break; + } + const auto id = pending.message; + pending = {}; + publishSendResult(id, ::chat::MeshProtocol::Meshtastic, + failure == Failure::None ? ::chat::MessageStatus::Delivered : ::chat::MessageStatus::Failed, + failure); + append_lora_system_log(failure == Failure::None ? "Meshtastic ACK received" : "Meshtastic NACK received", + "Message " + node_hex(id) + " peer " + node_hex(header.from) + + " reason " + std::to_string(routing.error_reason) + "."); + if (routing.error_reason == meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY || + routing.error_reason == meshtastic_Routing_Error_NO_CHANNEL) + { + // Reuse the same key-resynchronization policy as ESP32/nRF52. The + // matched ACK slot was consumed above, so duplicate NACKs cannot + // trigger repeated NodeInfo exchanges. + ::chat::runtime::ProtocolEffects effects; + const auto cause = routing.error_reason == meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY + ? ::chat::runtime::MeshtasticPkiResyncCause::PeerReportsUnknownPubkey + : ::chat::runtime::MeshtasticPkiResyncCause::PeerReportsNoChannel; + auto channel = ::chat::ChannelId::PRIMARY; + (void)channel_from_hash(build_meshtastic_air_plan(config_), header.channel, &channel); + ::chat::runtime::MeshtasticPkiResyncState{}.handle({cause, header.from, 0, channel}, effects); + for (const auto& effect : effects.items) + if (const auto* info = std::get_if<::chat::runtime::SendNodeInfoEffect>(&effect)) + (void)sendMeshtasticNodeInfoTo(info->peer, info->want_response, info->channel); + } + return; + } +} std::vector<::chat::NodeId> LinuxRawLoraMeshAdapter::encryptedPeerIds() const { std::vector<::chat::NodeId> peers; @@ -1417,6 +1521,7 @@ std::vector<::mesh::PeerPublicKey> LinuxRawLoraMeshAdapter::peerPublicKeys() con } void LinuxRawLoraMeshAdapter::cancelPendingSends() { + cancelMeshtasticAcks(); meshcore_repeater_.reset(); meshcore_direct_routes_ = {}; cancelMeshCoreVerification(); @@ -1484,7 +1589,7 @@ void LinuxRawLoraMeshAdapter::suspendRadio() .supports_trace_route_request = true, .supports_trace_route_reply = true, .supports_protocol_app_response = true, - .supports_protocol_ack_tracking = false, + .supports_protocol_ack_tracking = true, }; } @@ -1792,9 +1897,9 @@ bool LinuxRawLoraMeshAdapter::sendMeshCoreAdvert(bool broadcast) if (!tx_enabled_ || meshcore_discovery_.listening(::sys::millis_now()) || !ensureRadioReady()) return false; meshcore_wire::Frame frame; const bool sent = meshcore_wire::encodeAdvert(meshcore_identity_, config_, - short_name_.empty() ? long_name_ : short_name_, - now_seconds(), broadcast, frame) && - transmitMeshCoreFrame(frame.bytes.data(), frame.size); + short_name_.empty() ? long_name_ : short_name_, + now_seconds(), broadcast, frame) && + transmitMeshCoreFrame(frame.bytes.data(), frame.size); if (sent) markSelfAnnounced(); return sent; } @@ -2364,6 +2469,8 @@ void LinuxRawLoraMeshAdapter::applyProtocolConfig( meshcore_seen_ = {}; meshcore_seen_cursor_ = 0; } + // Flush local transmit results first, then publish terminal cancellation. + cancelMeshtasticAcks(); if (radio_changed) { meshcore_direct_routes_ = {}; @@ -2778,6 +2885,26 @@ bool LinuxRawLoraMeshAdapter::sendMeshtasticPayload( return false; } + MeshtasticPendingAck* pending = nullptr; + if (portnum == meshtastic_PortNum_TEXT_MESSAGE_APP && dest != 0 && dest != kBroadcastNodeId) + { + expireMeshtasticAcks(); + for (auto& candidate : meshtastic_pending_acks_) + { + if (candidate.message == msg_id) + { + meshcore_send_failure_ = ::chat::MeshOperationFailure::Busy; + return false; + } + if (!candidate.message && !pending) pending = &candidate; + } + if (!pending) + { + meshcore_send_failure_ = ::chat::MeshOperationFailure::Busy; + return false; + } + } + std::uint8_t data_payload[256] = {}; std::size_t data_size = sizeof(data_payload); bool encoded = false; @@ -2902,7 +3029,9 @@ bool LinuxRawLoraMeshAdapter::sendMeshtasticPayload( header.id = msg_id; header.channel = channel_hash; header.flags = (config_.hop_limit & 7) | ((config_.hop_limit & 7) << 5) | (air_want_ack ? ::chat::meshtastic::PACKET_FLAGS_WANT_ACK_MASK : 0); - return mqtt_->publishMeshtasticPayload(header, channel, wire_payload, wire_payload_size, use_pki); + const bool ok = mqtt_->publishMeshtasticPayload(header, channel, wire_payload, wire_payload_size, use_pki); + if (ok && pending) *pending = {msg_id, dest, retry_clock_()}; + return ok; } std::uint8_t wire[255] = {}; std::size_t wire_size = sizeof(wire); @@ -2955,6 +3084,7 @@ bool LinuxRawLoraMeshAdapter::sendMeshtasticPayload( ::platform::linux_runtime::append_packet_log(std::move(entry)); const bool ok = transmitMeshtasticFrame(wire, wire_size); + if (ok && pending) *pending = {msg_id, dest, retry_clock_()}; if (!ok) { append_lora_system_log( @@ -3755,31 +3885,19 @@ bool LinuxRawLoraMeshAdapter::sendRoutingAck(::chat::NodeId dest, } const ::chat::MessageId msg_id = nextMessageId(); - const std::uint8_t* payload = data_buf; - std::size_t payload_len = data_stream.bytes_written; - std::uint8_t pki_buf[256] = {}; - if (channel_hash == 0) + const MeshtasticAirPlan plan = build_meshtastic_air_plan(config_); + if (channel_hash == 0 && psk_len == 0) { - std::size_t pki_len = sizeof(pki_buf); - if (!encryptPkiPayload(dest, - msg_id, - data_buf, - data_stream.bytes_written, - pki_buf, - &pki_len)) - { - return false; - } - payload = pki_buf; - payload_len = pki_len; - psk = nullptr; - psk_len = 0; + // PKI requests arrive on air channel zero; ROUTING_APP is returned on + // the primary shared channel, as in the official firmware. + psk = psk_for_channel(plan, ::chat::ChannelId::PRIMARY, &psk_len); + channel_hash = channel_hash_for(plan, ::chat::ChannelId::PRIMARY); } std::uint8_t wire[255] = {}; std::size_t wire_size = sizeof(wire); - if (!build_meshtastic_wire_packet(payload, - payload_len, + if (!build_meshtastic_wire_packet(data_buf, + data_stream.bytes_written, self_node_id_, msg_id, dest, @@ -4451,6 +4569,7 @@ bool LinuxRawLoraMeshAdapter::consumeMeshtasticData( if (decoded.portnum == meshtastic_PortNum_ROUTING_APP) { + consumeMeshtasticRouting(header, decoded, encrypted); append_lora_system_log( "Meshtastic routing RX", "Routing payload from " + node_hex(header.from) + ".", diff --git a/platform/linux/common/src/mesh/linux_reticulum_identity.cpp b/platform/linux/common/src/mesh/linux_reticulum_identity.cpp index 8d08edf4..3b0e073a 100644 --- a/platform/linux/common/src/mesh/linux_reticulum_identity.cpp +++ b/platform/linux/common/src/mesh/linux_reticulum_identity.cpp @@ -2,6 +2,7 @@ #include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" #include "chat/infra/reticulum/reticulum_wire.h" #include "platform/linux/identity_owner_lease.h" +#include "platform/linux/private_database_file.h" #include #include #include @@ -110,22 +111,18 @@ bool LinuxReticulumIdentity::begin() const auto owner_path = database_.string() + ".identity-owner"; if (!open_shared_identity_owner(owner_path, owner_fd_)) return fail("Reticulum identity is retired or exclusively owned"); } - const int file = ::open(database_.c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, 0600); - struct stat status - { - }; - if (file < 0) return fail("Cannot open Reticulum identity file"); - const bool regular = ::fstat(file, &status) == 0 && S_ISREG(status.st_mode) && status.st_uid == ::geteuid(); - const bool protected_file = regular && ::fchmod(file, 0600) == 0; - ::close(file); - if (!protected_file) return fail("Reticulum identity requires a private, user-owned regular file"); #endif Database db; int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX; #ifdef SQLITE_OPEN_NOFOLLOW flags |= SQLITE_OPEN_NOFOLLOW; #endif - if (sqlite3_open_v2(database_.string().c_str(), &db.value, flags, nullptr) != SQLITE_OK || sqlite3_busy_timeout(db.value, 250) != SQLITE_OK || + if (sqlite3_open_v2(database_.string().c_str(), &db.value, flags, nullptr) != SQLITE_OK) + return fail("Cannot open Reticulum identity file"); +#if defined(__linux__) + if (!protect_private_database_file(database_)) return fail("Reticulum identity requires a private, user-owned regular file"); +#endif + if (sqlite3_busy_timeout(db.value, 250) != SQLITE_OK || !db.execute("PRAGMA synchronous=FULL; BEGIN IMMEDIATE")) return fail("Cannot open Reticulum identity transaction"); // Closing an uncommitted connection rolls back. No replacement identity is // generated when the existing row, schema or key relationship is damaged. diff --git a/platform/linux/common/src/platform/linux/agenda_source.cpp b/platform/linux/common/src/platform/linux/agenda_source.cpp index ccc687ba..f4f867bc 100644 --- a/platform/linux/common/src/platform/linux/agenda_source.cpp +++ b/platform/linux/common/src/platform/linux/agenda_source.cpp @@ -225,7 +225,10 @@ struct LinuxAgendaSource::Impl bool have_command = false, allow = false, refresh = false; { std::unique_lock lock(mutex); - wake.wait_for(lock, std::chrono::milliseconds(100), [&] + // Calendar/reminder clocks have one-second resolution. + // Commands and presentation changes still wake immediately; + // idle sampling must not reopen timezone storage at 10 Hz. + wake.wait_for(lock, std::chrono::seconds(1), [&] { return stopping || has_command || refresh_requested || presentation_changed; }); if (stopping && !has_command) break; if (has_command) diff --git a/platform/linux/common/src/platform/linux/cardputer_cap.cpp b/platform/linux/common/src/platform/linux/cardputer_cap.cpp new file mode 100644 index 00000000..af7c67af --- /dev/null +++ b/platform/linux/common/src/platform/linux/cardputer_cap.cpp @@ -0,0 +1,96 @@ +#include "platform/linux/cardputer_cap.h" + +#include +#include +#include +#include +#include +#include + +namespace platform::linux_runtime +{ +namespace +{ +bool writePower(const std::string& path, int value) +{ + std::ofstream output(path); + output << value << '\n'; + output.close(); + return !output.fail(); +} +} // namespace + +CardputerCap::CardputerCap(std::string power_path, uint32_t settle_ms) + : power_path_(std::move(power_path)), settle_ms_(settle_ms) +{ +} + +CardputerCap::~CardputerCap() +{ + if (restore_power_ && !writePower(power_path_, original_power_)) + std::fprintf(stderr, "[Cap] Failed to restore EXT5V power state\n"); +} + +void CardputerCap::begin() +{ + if (active_) return; + std::error_code error; + // This OS interface also identifies the physical Cardputer environment. + // Desktop screenshot tools and simulator sessions do not manage its power. + if (!std::filesystem::exists(power_path_, error) || error) return; + active_ = true; + std::ifstream input(power_path_); + if (!(input >> original_power_)) + { + power_failed_ = true; + return; + } + if (original_power_ != 0) return; + if (!writePower(power_path_, 1)) + { + power_failed_ = true; + return; + } + restore_power_ = true; + std::ifstream readback(power_path_); + int power = 0; + power_failed_ = !(readback >> power) || power == 0; + if (!power_failed_) std::this_thread::sleep_for(std::chrono::milliseconds(settle_ms_)); + std::fprintf(stderr, "[Cap] EXT5V %s\n", power_failed_ ? "enable failed" : "enabled"); +} + +const char* CardputerCap::takeNotice(uint64_t elapsed_ms, const CardputerCapObservation& observation) +{ + if (!active_ || decided_) return nullptr; + if (power_failed_) + { + decided_ = true; + return "Cap power unavailable. Check device permissions."; + } + if (observation.radio_online || observation.gps_chars != 0) + { + decided_ = true; + std::fprintf(stderr, "[Cap] Detected by %s\n", observation.radio_online ? "LoRa response" : "GPS traffic"); + return nullptr; + } + if (elapsed_ms < 8000) return nullptr; + // A user's disabled services are not evidence of a missing module. + if (!observation.radio_enabled || !observation.gps_enabled) + { + decided_ = true; + return nullptr; + } + decided_ = true; + std::fprintf(stderr, "[Cap] No module response: %s\n", observation.radio_error.c_str()); + if (observation.radio_error.find("Permission denied") != std::string::npos || + observation.radio_error.find("Device or resource busy") != std::string::npos) + return "Cap access unavailable. Check permissions or other apps."; + // All-zero SPI readback (or no SPI endpoint) plus no UART traffic cannot + // identify a connected module. Do not claim that the OS device nodes prove it. + if (!observation.radio_endpoint_present || observation.radio_error.find("status=00") != std::string::npos || + observation.radio_error.find("No such file") != std::string::npos) + return "Cap not detected. Local apps remain available."; + return "Cap not responding. Check module connection and power."; +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/platform/linux/iio_sensors.cpp b/platform/linux/common/src/platform/linux/iio_sensors.cpp index 04abb0cc..7d7b291a 100644 --- a/platform/linux/common/src/platform/linux/iio_sensors.cpp +++ b/platform/linux/common/src/platform/linux/iio_sensors.cpp @@ -77,6 +77,32 @@ unsigned gravity_rotation(float x, float y, unsigned previous, int mount_rotatio return static_cast((static_cast(raw) + mount_rotation % 360 + 360) % 360); } +bool read_iio_magnetometer(const std::filesystem::path& device, MagnetometerSnapshot& output, int mount_rotation) +{ + output.ready = false; + const char* axes[] = {"x", "y", "z"}; + float* physical[] = {&output.x, &output.y, &output.z}; + int16_t* counts[] = {&output.raw_x, &output.raw_y, &output.raw_z}; + for (unsigned i = 0; i < 3; ++i) + { + const auto channel = std::string("in_magn_") + axes[i]; + double raw = 0, scale = 0, offset = 0; + if (!number(device / (channel + "_raw"), raw)) return false; + if (!number(device / (channel + "_scale"), scale) && !number(device / "in_magn_scale", scale)) return false; + if (!number(device / (channel + "_offset"), offset)) number(device / "in_magn_offset", offset); + const auto value = (raw + offset) * scale; + if (scale <= 0 || !std::isfinite(value)) return false; + *physical[i] = static_cast(value); + *counts[i] = static_cast(std::clamp(raw, -32768.0, 32767.0)); + } + if (std::hypot(output.x, output.y) < 1e-9f) return false; + constexpr float radians_to_degrees = 57.295779513f; + output.heading_deg = std::fmod(std::atan2(-output.y, output.x) * radians_to_degrees + mount_rotation + 360.0f, 360.0f); + output.present = output.ready = true; + output.updated_ms = milliseconds(); + return true; +} + struct IioSensorRuntime::Impl { explicit Impl(IioSensorOptions value) : options(std::move(value)) @@ -110,6 +136,7 @@ struct IioSensorRuntime::Impl void run() { std::filesystem::path device; + std::filesystem::path magnetometer; ImuSnapshot previous = data; unsigned candidate = 0, consecutive = 0; while (!stopping) @@ -141,13 +168,35 @@ struct IioSensorRuntime::Impl sample.last_motion_ms = sample.updated_ms; } else device.clear(); + if (magnetometer.empty()) + { + std::error_code scan_error; + for (std::filesystem::directory_iterator it(options.root, scan_error), end; + !scan_error && it != end; it.increment(scan_error)) + { + const auto path = it->path(); + if (std::filesystem::exists(path / "in_magn_x_raw", scan_error) && + std::filesystem::exists(path / "in_magn_y_raw", scan_error) && + std::filesystem::exists(path / "in_magn_z_raw", scan_error)) + { + magnetometer = path; + break; + } + } + } + MagnetometerSnapshot magnetic; + magnetic.present = !magnetometer.empty() && std::filesystem::exists(magnetometer, error); + if (magnetic.present) + read_iio_magnetometer(magnetometer, magnetic, options.mount_rotation); + else magnetometer.clear(); { std::lock_guard lock(mutex); data = sample; + magnetic_data = magnetic; } previous = sample; std::unique_lock lock(mutex); - changed.wait_for(lock, std::chrono::milliseconds(sample.ready ? 100 : 2000), [&] + changed.wait_for(lock, std::chrono::milliseconds(sample.ready || magnetic.ready ? 100 : 2000), [&] { return stopping.load(); }); } } @@ -156,6 +205,7 @@ struct IioSensorRuntime::Impl std::condition_variable changed; std::atomic_bool stopping{false}; ImuSnapshot data; + MagnetometerSnapshot magnetic_data; std::thread worker; }; @@ -166,6 +216,11 @@ ImuSnapshot IioSensorRuntime::snapshot() const std::lock_guard lock(impl_->mutex); return impl_->data; } +MagnetometerSnapshot IioSensorRuntime::magnetometerSnapshot() const +{ + std::lock_guard lock(impl_->mutex); + return impl_->magnetic_data; +} namespace { @@ -197,6 +252,12 @@ ImuSnapshot linux_imu_snapshot() std::lock_guard lock(state.mutex); return state.sensor ? state.sensor->snapshot() : ImuSnapshot{}; } +MagnetometerSnapshot linux_magnetometer_snapshot() +{ + auto& state = runtime(); + std::lock_guard lock(state.mutex); + return state.sensor ? state.sensor->magnetometerSnapshot() : MagnetometerSnapshot{}; +} void shutdown_iio_sensors() { std::unique_ptr retiring; diff --git a/platform/linux/common/src/platform/linux/linux_radiolib_hal.cpp b/platform/linux/common/src/platform/linux/linux_radiolib_hal.cpp index e45963de..bceca208 100644 --- a/platform/linux/common/src/platform/linux/linux_radiolib_hal.cpp +++ b/platform/linux/common/src/platform/linux/linux_radiolib_hal.cpp @@ -221,6 +221,14 @@ void LinuxRadioLibHal::spiTransfer(uint8_t* output, size_t size, uint8_t* input) fail("SPI transfer"); return; } + // Opt-in packet-command diagnostics. Never dump packet payloads or IQ data. + const char* trace = std::getenv("TRAIL_MATE_LR2021_TRACE_SPI"); + if (!iq_mode_ && trace && std::strcmp(trace, "1") == 0) + { + std::fprintf(stderr, "[LR2021 SPI] bytes=%zu command=%02x%02x status=%02x%02x\n", + size, output[0], size > 1 ? output[1] : 0, + input[0], size > 1 ? input[1] : 0); + } // BUSY may assert after NSS rises. Do not require observing its high edge; // short pulses may already be over when userspace is scheduled again. delayMicroseconds(iq_mode_ ? 1 : 100); diff --git a/platform/linux/common/src/platform/linux/lr2021_radio.cpp b/platform/linux/common/src/platform/linux/lr2021_radio.cpp index efe9a29c..ea5f46f3 100644 --- a/platform/linux/common/src/platform/linux/lr2021_radio.cpp +++ b/platform/linux/common/src/platform/linux/lr2021_radio.cpp @@ -8,11 +8,13 @@ #include "platform/linux/packet_radio.h" #include "platform/shared/radio/fm_lr2021_stream.h" #include "platform/shared/radio/lora_receive_config.h" +#include "platform/shared/radio/restart_receive.h" #include #include #include #include +#include #include #include #include @@ -382,6 +384,16 @@ class Lr2021Radio final : public IPacketRadio const uint32_t irq = radio_.getIrqFlags(); if (!hal_.healthy()) return check(-1, "read IRQ"); statistics_.last_irq_flags = irq; + const auto restart_receive = [this](const char* operation) + { + // RX is continuous, including after RX_DONE. RadioLib configures + // the receive path before StartRx; LR2021 requires standby first. + return platform::shared::radio::restartReceive( + radio_, + [this](int code, const char* step) + { return check(code, step); }, + operation); + }; if (irq & (RADIOLIB_LR2021_IRQ_ERROR | RADIOLIB_LR2021_IRQ_CMD_ERROR | RADIOLIB_LR2021_IRQ_PA_OCP_OVP)) { // RX_DONE must never hide a hardware failure. Best-effort stop and @@ -400,13 +412,13 @@ class Lr2021Radio final : public IPacketRadio if (irq & (RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR | RADIOLIB_LR2021_IRQ_ADDR_ERROR)) ++statistics_.rx_header_errors; if (irq & RADIOLIB_LR2021_IRQ_LEN_ERROR) ++statistics_.rx_invalid_lengths; - check(radio_.startReceive(), "recover damaged packet"); + restart_receive("recover damaged packet"); return false; } if (irq & RADIOLIB_LR2021_IRQ_TIMEOUT) { ++statistics_.rx_timeouts; - check(radio_.startReceive(), "recover RX timeout"); + restart_receive("recover RX timeout"); return false; } if (!(irq & RADIOLIB_LR2021_IRQ_RX_DONE)) return false; @@ -414,7 +426,7 @@ class Lr2021Radio final : public IPacketRadio if (!size || size > packet->data.size()) { ++statistics_.rx_invalid_lengths; - check(radio_.startReceive(), "recover length"); + restart_receive("recover length"); return false; } const float rssi = radio_.getRSSI(); @@ -423,7 +435,7 @@ class Lr2021Radio final : public IPacketRadio if (code != RADIOLIB_ERR_NONE || !hal_.healthy()) { ++statistics_.rx_read_errors; - check(radio_.startReceive(), "recover read"); + restart_receive("recover read"); return false; } packet->size = size; @@ -440,7 +452,10 @@ class Lr2021Radio final : public IPacketRadio packet->sf = packet->cr = 0; } ++statistics_.rx_packets; - return check(radio_.startReceive(), "resume receive"); + // The payload is valid even if restarting RX fails. Deliver it once; + // check() marks the transport offline for the next acquisition. + restart_receive("resume receive"); + return true; } float readRssi() override { @@ -648,6 +663,13 @@ class Lr2021Radio final : public IPacketRadio online_ = false; statistics_.online = false; lora_config_.invalidate(); + const char* trace = std::getenv("TRAIL_MATE_LR2021_TRACE_SPI"); + if (trace && std::strcmp(trace, "1") == 0) + { + std::fprintf(stderr, "[LR2021 error] operation=%s code=%d irq=0x%08lx transport=%s\n", + operation, code, static_cast(statistics_.last_irq_flags), + hal_.healthy() ? "healthy" : hal_.error().c_str()); + } return fail(hal_.healthy() ? std::string("LR2021 ") + operation + " failed: " + std::to_string(code) : hal_.error()); } PacketRadioHardware hardware_; diff --git a/platform/linux/common/src/platform/linux/sx126x_radio.cpp b/platform/linux/common/src/platform/linux/sx126x_radio.cpp index 229cc62e..1342004b 100644 --- a/platform/linux/common/src/platform/linux/sx126x_radio.cpp +++ b/platform/linux/common/src/platform/linux/sx126x_radio.cpp @@ -496,7 +496,7 @@ bool Sx126xRadio::configureFsk(const FskSettings& config) bool Sx126xRadio::transmit(const std::uint8_t* data, std::size_t size) { - if (data == nullptr || size == 0 || size > 220) + if (data == nullptr || size == 0 || size > 255) { return false; } @@ -507,6 +507,14 @@ bool Sx126xRadio::transmit(const std::uint8_t* data, std::size_t size) return false; } + const auto resume_receive = [this]() + { + // TX temporarily replaces the IRQ mask. SetRx alone does not restore + // RX_DONE reporting, leaving the radio deaf to software after a send. + return setDioIrqParamsLocked(kIrqRxDone | kIrqTimeout | kIrqCrcErr | kIrqHeaderErr, + kIrqRxDone) && + clearIrqLocked(kIrqAll) && setRxLocked(kRxTimeoutInf); + }; bool ok = setBufferBaseLocked(0x00, 0x00); const std::uint8_t packet[6] = { static_cast((lora_config_.preamble_len >> 8) & 0xFF), @@ -537,7 +545,7 @@ bool Sx126xRadio::transmit(const std::uint8_t* data, std::size_t size) clearIrqLocked(kIrqAll) && setTxLocked(0x000000); if (!ok) { - (void)setRxLocked(kRxTimeoutInf); + (void)resume_receive(); return false; } @@ -547,22 +555,20 @@ bool Sx126xRadio::transmit(const std::uint8_t* data, std::size_t size) const std::uint32_t irq = getIrqFlagsLocked(); if ((irq & kIrqTxDone) != 0) { - (void)clearIrqLocked(kIrqAll); - (void)setRxLocked(kRxTimeoutInf); + const bool receiving = resume_receive(); ++tx_packets_; - return true; + return receiving; } if ((irq & kIrqTimeout) != 0) { - (void)clearIrqLocked(kIrqAll); - (void)setRxLocked(kRxTimeoutInf); + (void)resume_receive(); setErrorLocked("LoRa TX timeout"); return false; } if (std::chrono::steady_clock::now() - start > std::chrono::seconds(4)) { - (void)setRxLocked(kRxTimeoutInf); + (void)resume_receive(); setErrorLocked("LoRa TX wait timed out"); return false; } diff --git a/platform/linux/common/src/platform/linux/tmap_area_search.cpp b/platform/linux/common/src/platform/linux/tmap_area_search.cpp new file mode 100644 index 00000000..bbde3fdd --- /dev/null +++ b/platform/linux/common/src/platform/linux/tmap_area_search.cpp @@ -0,0 +1,225 @@ +#include "platform/linux/tmap_area_search.h" +#include "platform/linux/http_download.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace platform::linux_runtime +{ +namespace +{ +namespace tc = ::platform::ui::tmap_creation; +std::string escape_query(const std::string& input) +{ + std::string output; + for (const unsigned char c : input) + { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') output += char(c); + else + { + output += '%'; + output += "0123456789ABCDEF"[c >> 4]; + output += "0123456789ABCDEF"[c & 15]; + } + } + return output; +} +std::vector parse_areas(const std::string& text) +{ + sqlite3* database = nullptr; + if (sqlite3_open(":memory:", &database) != SQLITE_OK) + { + sqlite3_close(database); + throw std::runtime_error("Area response parser unavailable."); + } + struct Close + { + sqlite3* value; + ~Close() { sqlite3_close(value); } + } close{database}; + sqlite3_limit(database, SQLITE_LIMIT_LENGTH, 512 * 1024); + constexpr const char* sql = R"SQL( +SELECT json_extract(value,'$.properties.osm_type'), json_extract(value,'$.properties.osm_id'), + json_extract(value,'$.properties.name'), json_extract(value,'$.properties.countrycode'), + json_extract(value,'$.properties.extent[0]'),json_extract(value,'$.properties.extent[3]'), + json_extract(value,'$.properties.extent[2]'),json_extract(value,'$.properties.extent[1]'), + json_extract(value,'$.properties.city'),json_extract(value,'$.properties.county'), + json_extract(value,'$.properties.state'),json_extract(value,'$.properties.country') +FROM json_each(?1,'$.features') +WHERE json_array_length(value,'$.properties.extent')=4 + AND json_extract(value,'$.properties.type') IN ('country','state','county','city','district') LIMIT 12 +)SQL"; + sqlite3_stmt* statement = nullptr; + if (sqlite3_prepare_v2(database, sql, -1, &statement, nullptr) != SQLITE_OK) throw std::runtime_error("Area response parser unavailable."); + struct Finalize + { + sqlite3_stmt* value; + ~Finalize() { sqlite3_finalize(value); } + } finalize{statement}; + sqlite3_bind_text(statement, 1, text.data(), int(text.size()), SQLITE_TRANSIENT); + const auto string = [&](int index) + { + const auto* value = sqlite3_column_text(statement, index); + return value ? std::string(reinterpret_cast(value)) : std::string{}; + }; + std::vector result; + int status; + while ((status = sqlite3_step(statement)) == SQLITE_ROW) + { + tc::Area area; + const auto type = string(0); + const auto id = sqlite3_column_int64(statement, 1); + area.name = string(2); + area.country = string(3); + area.west = sqlite3_column_double(statement, 4); + area.south = sqlite3_column_double(statement, 5); + area.east = sqlite3_column_double(statement, 6); + area.north = sqlite3_column_double(statement, 7); + bool numeric = true; + for (int i = 4; i < 8; ++i) + numeric &= sqlite3_column_type(statement, i) == SQLITE_FLOAT || sqlite3_column_type(statement, i) == SQLITE_INTEGER; + if (!numeric || id <= 0 || (type != "R" && type != "W") || area.name.empty() || area.name.size() > 192 || + !std::isfinite(area.west) || !std::isfinite(area.south) || !std::isfinite(area.east) || !std::isfinite(area.north) || + area.west < -180 || area.east > 180 || area.south < -90 || area.north > 90 || area.west >= area.east || area.south >= area.north) continue; + area.south = std::max(area.south, -85.051128); + area.north = std::min(area.north, 85.051128); + if (area.south >= area.north) continue; + area.id = type + std::to_string(id); + area.description = area.name; + for (int i = 8; i <= 11; ++i) + { + const auto part = string(i); + if (!part.empty() && part != area.name && area.description.find(part) == std::string::npos) + area.description += ", " + part; + } + if (area.description.size() > 512) area.description.resize(512); + if (std::none_of(result.begin(), result.end(), [&](const auto& existing) + { return existing.id == area.id; })) result.push_back(std::move(area)); + } + if (status != SQLITE_DONE) throw std::runtime_error("Invalid area search response."); + return result; +} +} // namespace +struct TmapAreaSearch::Impl +{ + explicit Impl(std::string endpoint) : endpoint(std::move(endpoint)), worker([this] + { run(); }) {} + ~Impl() + { + { + std::lock_guard lock(mutex); + stopping = true; + interrupted = true; + } + changed.notify_all(); + worker.join(); + } + void run() + { + auto next_request = std::chrono::steady_clock::now(); + for (;;) + { + std::string query; + uint32_t request; + { + std::unique_lock lock(mutex); + changed.wait(lock, [&] + { return stopping || pending.has_value(); }); + if (stopping) return; + changed.wait_until(lock, next_request, [&] + { return stopping; }); + if (stopping) return; + query = std::move(*pending); + pending.reset(); + request = generation; + interrupted = false; + } + tc::AreaSearchSnapshot response; + response.query = query; + const auto cached = cache.find(query); + if (cached != cache.end()) + { + response = cached->second; + response.from_cache = true; + } + else + { + std::string body, error; + const auto url = endpoint + (endpoint.find('?') == std::string::npos ? "?" : "&") + + "q=" + escape_query(query) + "&limit=12&lang=en&layer=country&layer=state&layer=county&layer=city&layer=district"; + ::platform::ui::http_client::Request http; + http.url = url.c_str(); + http.timeout_ms = 20000; + http.max_bytes = 256 * 1024; + const bool ok = http_download( + http, [](const uint8_t* data, size_t size, void* context) + { static_cast(context)->append(reinterpret_cast(data), size); return true; }, + &body, error, nullptr, &interrupted, "TrailMate/1.0 (+https://github.com/vicliu624/trail-mate)"); + next_request = std::chrono::steady_clock::now() + std::chrono::seconds(1); + if (!ok) response.error = "Area search failed: " + error + ". Check Wi-Fi and retry, or use coordinates."; + else + { + try + { + response.results = parse_areas(body); + } + catch (const std::exception& exception) + { + response.error = exception.what(); + } + if (response.error.empty() && response.results.empty()) response.error = "No administrative boundary found. Add a country or state to the search."; + if (response.error.empty()) + { + if (cache.size() >= 16) cache.erase(cache.begin()); + cache[query] = response; + } + } + } + std::lock_guard lock(mutex); + if (request != generation || stopping) continue; + response.revision = state.revision + 1; + state = std::move(response); + } + } + std::string endpoint; + std::mutex mutex; + std::condition_variable changed; + bool stopping = false; + std::atomic_bool interrupted{false}; + uint32_t generation = 0; + std::optional pending; + tc::AreaSearchSnapshot state; + std::map cache; + std::thread worker; +}; +TmapAreaSearch::TmapAreaSearch(std::string endpoint) : impl_(std::make_unique(std::move(endpoint))) {} +TmapAreaSearch::~TmapAreaSearch() = default; +bool TmapAreaSearch::search(const std::string& query) +{ + if (query.empty() || query.size() > 192) return false; + std::lock_guard lock(impl_->mutex); + impl_->interrupted = true; + ++impl_->generation; + impl_->pending = query; + const auto revision = impl_->state.revision + 1; + impl_->state = {}; + impl_->state.revision = revision; + impl_->state.busy = true; + impl_->state.query = query; + impl_->changed.notify_all(); + return true; +} +tc::AreaSearchSnapshot TmapAreaSearch::snapshot() +{ + std::lock_guard lock(impl_->mutex); + return impl_->state; +} +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/platform/linux/tmap_creation.cpp b/platform/linux/common/src/platform/linux/tmap_creation.cpp index 5025a38b..b9148a51 100644 --- a/platform/linux/common/src/platform/linux/tmap_creation.cpp +++ b/platform/linux/common/src/platform/linux/tmap_creation.cpp @@ -1,10 +1,13 @@ #include "platform/linux/tmap_creation.h" #include "platform/linux/hostlink_upload_io.h" +#include "platform/linux/http_download.h" #include "platform/linux/route_image_source_io.h" #include "platform/linux/runtime_paths.h" +#include "platform/linux/tmap_area_search.h" #include "platform/linux/tmap_job_workspace.h" #include #include +#include #include #include #include @@ -65,7 +68,7 @@ std::string output_filename(const std::string& filename, const std::string& styl ? stem.substr(0, zoom) + '-' + style + stem.substr(zoom) + ".tmap" : stem + '-' + style + ".tmap"; } -std::string plan_json(const tc::Plan& p, const std::filesystem::path& output, const std::filesystem::path& cache) +std::string plan_json(const tc::Plan& p, const std::filesystem::path& output, const std::filesystem::path& cache, const std::string& source_endpoint) { std::ostringstream out; out << std::setprecision(15) @@ -73,7 +76,8 @@ std::string plan_json(const tc::Plan& p, const std::filesystem::path& output, co << ",\"Bounds\":{\"West\":" << p.west << ",\"South\":" << p.south << ",\"East\":" << p.east << ",\"North\":" << p.north << "}}," << "\"BaseLayers\":{\"IncludeOsm\":" << (p.osm ? "true" : "false") << ",\"IncludeTerrain\":" << (p.terrain ? "true" : "false") << ",\"IncludeSatellite\":" << (p.satellite ? "true" : "false") << ",\"MinimumZoom\":" << unsigned(p.min_zoom) << ",\"MaximumZoom\":" << unsigned(p.max_zoom) << "}," - << "\"Poi\":{\"PbfPath\":\"/proc/self/fd/198\",\"EnablePoiSeparation\":true},\"Annotations\":{},\"OutputDirectory\":" << json(output.string()) + << "\"Poi\":{\"PbfPath\":\"/proc/self/fd/198\",\"EnablePoiSeparation\":true,\"SourceProvider\":" << json(p.download_source ? "overpass" : "local") + << ",\"SourceDownloadUrl\":" << json(p.download_source ? source_endpoint : "") << "},\"Annotations\":{},\"OutputDirectory\":" << json(output.string()) << ",\"Tmap\":{\"FileName\":" << json(p.filename) << ",\"PackageKey\":" << json(p.package_key) << ",\"CountryCode\":" << json(p.country) << ",\"AdminCode\":" << json(p.admin) << ",\"Tier\":\"Custom\",\"Revision\":" << p.revision << ",\"MaximumOutputBytes\":" << p.maximum_bytes @@ -83,8 +87,8 @@ std::string plan_json(const tc::Plan& p, const std::filesystem::path& output, co } // namespace struct LinuxTmapCreation::Impl { - explicit Impl(TmapCreationOptions options) : options(std::move(options)), worker([this] - { run(); }) {} + explicit Impl(TmapCreationOptions options) : options(std::move(options)), area_search(std::getenv("TRAIL_MATE_AREA_SEARCH_URL") ? std::getenv("TRAIL_MATE_AREA_SEARCH_URL") : "https://photon.komoot.io/api/"), worker([this] + { run(); }) {} ~Impl() { stopping = true; @@ -121,6 +125,7 @@ struct LinuxTmapCreation::Impl state.source_directory = roots.front().string(); state.source_revision = ++source_revision; state.producer_ready = ::access(options.producer.c_str(), X_OK) == 0; + state.downloader_ready = ::access(options.osmium.c_str(), X_OK) == 0; } tc::Estimate estimate(const tc::Plan& p) { @@ -137,8 +142,19 @@ struct LinuxTmapCreation::Impl std::lock_guard lock(mutex); const auto source = std::find_if(inputs.begin(), inputs.end(), [&](const auto& value) { return value.path == p.source; }); - if (source == inputs.end()) return invalid("Select a local .osm.pbf file from map-sources."); - source_bytes = source->bytes; + if (p.download_source) + { + if (!state.downloader_ready) return invalid("The map downloader is incomplete. Install the full Trail Mate package."); + constexpr double radians = 3.14159265358979323846 / 180; + const double area_km2 = (p.east - p.west) * (p.north - p.south) * 111.32 * 111.32 * std::cos((p.north + p.south) * .5 * radians); + if (area_km2 > 2500) return invalid("This area is too large for an online job on this device. Choose a district or a smaller coordinate area (up to 2500 km2)."); + source_bytes = 64ULL * 1024 * 1024; + } + else + { + if (source == inputs.end()) return invalid("Select a local .osm.pbf file from map-sources."); + source_bytes = source->bytes; + } result.available_bytes = available; } const unsigned layers = unsigned(p.osm) + unsigned(p.terrain) + unsigned(p.satellite); @@ -169,7 +185,8 @@ struct LinuxTmapCreation::Impl { if (cancelled || stopping) throw std::runtime_error("Cancelled"); } - bool process(const std::vector& arguments, int source = -1, int lease = -1) + bool process(const std::vector& arguments, int source = -1, int lease = -1, + const std::filesystem::path& executable = {}) { int descriptors[2]; if (pipe2(descriptors, O_CLOEXEC) != 0) throw std::runtime_error("Cannot open producer output pipe."); @@ -185,7 +202,7 @@ struct LinuxTmapCreation::Impl posix_spawnattr_init(&attributes); posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP); posix_spawnattr_setpgroup(&attributes, 0); - std::vector owned{options.producer.string()}; + std::vector owned{executable.empty() ? options.producer.string() : executable.string()}; owned.insert(owned.end(), arguments.begin(), arguments.end()); std::vector argv; for (auto& value : owned) argv.push_back(value.data()); @@ -263,6 +280,94 @@ struct LinuxTmapCreation::Impl if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) throw std::runtime_error(tail.empty() ? "TMAP producer failed." : tail); return true; } + std::filesystem::path download_source(const tc::Plan& plan, TmapJobWorkspace& workspace) + { + update(tc::Phase::Preparing, "Downloading map data for the selected area"); + std::ostringstream query; + query << std::setprecision(15) << "[out:xml][timeout:90][maxsize:67108864];(node(" + << plan.south << ',' << plan.west << ',' << plan.north << ',' << plan.east << ");way(" + << plan.south << ',' << plan.west << ',' << plan.north << ',' << plan.east << ");rel[\"type\"=\"multipolygon\"](" + << plan.south << ',' << plan.west << ',' << plan.north << ',' << plan.east << "););(._;>;);out body qt;"; + std::string escaped; + for (const unsigned char c : query.str()) + { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-') escaped += char(c); + else + { + escaped += '%'; + escaped += "0123456789ABCDEF"[c >> 4]; + escaped += "0123456789ABCDEF"[c & 15]; + } + } + const auto url = options.map_endpoint + (options.map_endpoint.find('?') == std::string::npos ? "?data=" : "&data=") + escaped; + const auto xml = workspace.path() / "download.osm", pbf = workspace.path() / "source.osm.pbf"; + struct Download + { + int fd = -1; + bool nodes = false, remark = false; + std::string tail; + ~Download() + { + if (fd >= 0) ::close(fd); + } + } download; + download.fd = ::open(xml.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (download.fd < 0) throw std::runtime_error("Cannot prepare map download storage."); + ::platform::ui::http_client::Request request; + request.url = url.c_str(); + request.timeout_ms = 150000; + request.max_bytes = 64U * 1024 * 1024; + struct Progress + { + Impl* owner; + std::chrono::steady_clock::time_point started; + } progress{this, std::chrono::steady_clock::now()}; + request.progress_context = &progress; + request.progress = [](uint32_t bytes, uint32_t total, void* context) + { + const auto& progress = *static_cast(context); + auto& self = *progress.owner; + std::lock_guard lock(self.mutex); + self.state.completed = bytes; + self.state.total = total; + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - progress.started).count(); + self.state.stage = bytes ? "Downloading map data (" + std::to_string(bytes / 1024) + " KiB)" + : "Waiting for map server (" + std::to_string(elapsed) + " s)"; + self.state.percent = total ? int(uint64_t(bytes) * 20 / total) : -1; + }; + std::string error; + const bool ok = http_download( + request, [](const uint8_t* data, size_t count, void* context) + { + auto& output = *static_cast(context); + const std::string scan = output.tail + std::string(reinterpret_cast(data), count); + output.nodes |= scan.find(" 32 ? scan.size() - 32 : 0); + while (count) + { + const auto written = ::write(output.fd, data, count); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) return false; + data += written; count -= size_t(written); + } + return true; }, + &download, error, nullptr, &cancelled, "TrailMate/1.0 (+https://github.com/vicliu624/trail-mate)"); + check(); + if (!ok) throw std::runtime_error("Map download failed: " + error + ". Check Wi-Fi and retry, or choose a smaller area."); + if (download.remark || !download.nodes) throw std::runtime_error("The map server could not provide this area. Retry or choose a smaller area."); + if (::fsync(download.fd)) throw std::runtime_error("Cannot save downloaded map data."); + ::close(download.fd); + download.fd = -1; + update(tc::Phase::Preparing, "Preparing downloaded map data"); + process({"cat", xml.string(), "-o", pbf.string(), "--output-format=pbf"}, -1, workspace.lease(), options.osmium); + { + std::lock_guard lock(mutex); + state.completed = state.total = 0; + state.percent = -1; + } + return pbf; + } void build(const tc::Plan& p) { const auto checked = estimate(p); @@ -282,7 +387,8 @@ struct LinuxTmapCreation::Impl if (existing.handle) destination.close(existing.handle); if (existing.status != UploadIo::OpenStatus::Missing) throw std::runtime_error("Output already exists or is unavailable; choose a new filename."); } - const int source = ::open(p.source.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + const auto source_path = p.download_source ? download_source(p, workspace) : std::filesystem::path(p.source); + const int source = ::open(source_path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); if (source < 0) throw std::runtime_error("Cannot open selected PBF source."); struct SourceLease { @@ -297,7 +403,7 @@ struct LinuxTmapCreation::Impl const auto plan = work / "plan.json"; { std::ofstream file(plan); - file << plan_json(p, out, work / "sources"); + file << plan_json(p, out, work / "sources", options.map_endpoint); if (!file) throw std::runtime_error("Cannot persist TMAP production plan."); } update(tc::Phase::Building, "Importing PBF and rendering tiles"); @@ -464,6 +570,7 @@ struct LinuxTmapCreation::Impl } } TmapCreationOptions options; + TmapAreaSearch area_search; std::mutex mutex; std::condition_variable changed; std::vector inputs; @@ -505,7 +612,9 @@ bool LinuxTmapCreation::start(const tc::Plan& plan, std::string& error) return false; } impl_->cancelled = false; + const bool downloader_ready = impl_->state.downloader_ready; impl_->state = {}; + impl_->state.downloader_ready = downloader_ready; impl_->state.source_directory = (impl_->options.storage / "map-sources").string(); impl_->state.source_revision = impl_->source_revision; impl_->state.producer_ready = true; @@ -521,6 +630,8 @@ tc::Snapshot LinuxTmapCreation::snapshot() return impl_->state; } void LinuxTmapCreation::cancel() { impl_->cancelled = true; } +bool LinuxTmapCreation::searchAreas(const std::string& query) { return impl_->area_search.search(query); } +tc::AreaSearchSnapshot LinuxTmapCreation::areaSearch() { return impl_->area_search.snapshot(); } void configure_tmap_creation(bool enabled) { static std::unique_ptr service; @@ -530,6 +641,7 @@ void configure_tmap_creation(bool enabled) const auto paths = resolve_paths(); TmapCreationOptions options{paths.sd_root, paths.cache_root}; if (const auto* value = std::getenv("TRAIL_MATE_TMAP_PRODUCER")) options.producer = value; + if (const auto* value = std::getenv("TRAIL_MATE_MAP_SOURCE_URL")) options.map_endpoint = value; service = std::make_unique(std::move(options)); tc::bind(service.get()); } diff --git a/platform/linux/common/src/platform/ui/http_client_runtime.cpp b/platform/linux/common/src/platform/ui/http_client_runtime.cpp index 02874a4f..4c74f4be 100644 --- a/platform/linux/common/src/platform/ui/http_client_runtime.cpp +++ b/platform/linux/common/src/platform/ui/http_client_runtime.cpp @@ -69,7 +69,8 @@ int progress(void* context, curl_off_t total, curl_off_t, curl_off_t, curl_off_t } } // namespace bool http_download(const http::Request& request, http::WriteCallback write, void* context, - std::string& error, http::TransferStats* stats, const std::atomic_bool* cancelled) + std::string& error, http::TransferStats* stats, const std::atomic_bool* cancelled, + const char* user_agent) { error.clear(); if (stats) *stats = {}; @@ -88,6 +89,7 @@ bool http_download(const http::Request& request, http::WriteCallback write, void // Zero means no caller-specific limit; the host still bounds allocations. Transfer transfer{request, write, context, cancelled, 0, request.max_bytes ? request.max_bytes : 16U * 1024U * 1024U}; curl_easy_setopt(curl.get(), CURLOPT_URL, request.url); + if (user_agent && *user_agent) curl_easy_setopt(curl.get(), CURLOPT_USERAGENT, user_agent); curl_easy_setopt(curl.get(), CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT_MS, static_cast(request.timeout_ms)); curl_easy_setopt(curl.get(), CURLOPT_CONNECTTIMEOUT_MS, static_cast(std::min(request.timeout_ms, 10000))); diff --git a/platform/linux/common/src/platform/ui/orientation_runtime.cpp b/platform/linux/common/src/platform/ui/orientation_runtime.cpp index 9251e500..606502f8 100644 --- a/platform/linux/common/src/platform/ui/orientation_runtime.cpp +++ b/platform/linux/common/src/platform/ui/orientation_runtime.cpp @@ -3,13 +3,22 @@ namespace platform::ui::orientation { -bool heading_supported() { return false; } -bool acquire_heading() { return false; } +bool heading_supported() { return ::platform::linux_runtime::linux_magnetometer_snapshot().present; } +bool acquire_heading() { return heading_supported(); } void release_heading() {} HeadingState get_heading() { - return {}; + const auto magnetic = ::platform::linux_runtime::linux_magnetometer_snapshot(); + HeadingState state; + state.sensor_ready = magnetic.ready; + state.available = magnetic.ready; + state.heading_deg = magnetic.heading_deg; + state.raw_x = magnetic.raw_x; + state.raw_y = magnetic.raw_y; + state.raw_z = magnetic.raw_z; + state.last_update_ms = magnetic.updated_ms; + return state; } void ensure_heading_runtime() diff --git a/platform/linux/common/src/platform/ui/screen_runtime.cpp b/platform/linux/common/src/platform/ui/screen_runtime.cpp index 72e7c19e..db56f2fb 100644 --- a/platform/linux/common/src/platform/ui/screen_runtime.cpp +++ b/platform/linux/common/src/platform/ui/screen_runtime.cpp @@ -57,8 +57,9 @@ uint16_t timeout_secs() bool supports_app_timeout_setting() { std::lock_guard lock(s_mutex); - if (!s_options.power || s_stopped) return false; - const auto power = s_options.power->snapshot(); + if (s_stopped) return false; + const auto power = s_options.power_snapshot ? s_options.power_snapshot() : s_options.power ? s_options.power->snapshot() + : ::platform::linux_runtime::OutputPowerSnapshot{}; return power.available && !power.failed; } @@ -178,6 +179,7 @@ void service_screen_runtime() using namespace ::platform::ui::screen_power; Hooks hooks; std::shared_ptr power; + std::function request_power; State before; State next; bool wake = false; @@ -186,7 +188,9 @@ void service_screen_runtime() if (!s_initialized || s_stopped) return; hooks = s_hooks; power = s_options.power; - const auto actual = power ? power->snapshot() : OutputPowerSnapshot{}; + request_power = s_options.request_power; + const auto actual = s_options.power_snapshot ? s_options.power_snapshot() : power ? power->snapshot() + : OutputPowerSnapshot{}; // Loss of the selected output or a failed operation must not strand the // UI in a software-only sleep state. A healthy port enables timeouts. s_machine.dispatch(actual.available && !actual.failed ? Event::Tick : Event::ModalWake, now_ms()); @@ -200,7 +204,9 @@ void service_screen_runtime() wake = s_wake_pending; s_presented = next; } - if (next == State::Sleeping && before != State::Sleeping && (!power || !power->request(false))) + const auto request = [&](bool on) + { return request_power ? request_power(on) : power && power->request(on); }; + if (next == State::Sleeping && before != State::Sleeping && !request(false)) { std::lock_guard lock(s_mutex); s_machine.dispatch(Event::ModalWake, now_ms()); @@ -208,7 +214,7 @@ void service_screen_runtime() s_wake_pending = true; wake = true; } - if (wake && power) (void)power->request(true); + if (wake) (void)request(true); // Hooks may reenter the public screen ports. Invoke them without the state // mutex, only on the UI thread; background callers only mutate the machine. if (before != next) @@ -234,6 +240,8 @@ void shutdown_screen_runtime() s_hooks = {}; s_machine.dispatch(::platform::ui::screen_power::Event::Initialize, now_ms()); power = std::move(s_options.power); + s_options.power_snapshot = {}; + s_options.request_power = {}; } // Do not join the hardware worker while holding the state mutex. power.reset(); diff --git a/platform/linux/common/src/ui/shell_ui_runner.cpp b/platform/linux/common/src/ui/shell_ui_runner.cpp index 054341d3..d30539fe 100644 --- a/platform/linux/common/src/ui/shell_ui_runner.cpp +++ b/platform/linux/common/src/ui/shell_ui_runner.cpp @@ -19,6 +19,14 @@ #include "platform/linux/packet_radio.h" #include "platform/linux/resource_pack_policy.h" #include "platform/linux/runtime_paths.h" +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) +#include "platform/linux/backlight_screen.h" +#include "platform/linux/cardputer_cap.h" +#include "platform/linux/iio_sensors.h" +#include "platform/ui/gps_runtime.h" +#include "ui/components/screen_saver_overlay.h" +#include "ui/runtime/ui_feedback.h" +#endif #include "platform/ui/device_runtime.h" #include "platform/ui/quick_controls_runtime.h" #include "platform/ui/settings_store.h" @@ -119,6 +127,19 @@ struct QueuedKeyEvent lv_indev_state_t state{LV_INDEV_STATE_RELEASED}; }; +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) +struct CardputerSensorBinding +{ + CardputerSensorBinding() + { + ::platform::linux_runtime::IioSensorOptions options; + options.expected = true; + ::platform::linux_runtime::configure_iio_sensors(options); + } + ~CardputerSensorBinding() { ::platform::linux_runtime::shutdown_iio_sensors(); } +}; +#endif + bool dispatchTeamUiEvent(sys::Event* event) { return team::ui::shell::handle_event(nullptr, event); @@ -383,6 +404,12 @@ void CanvasLvglHost::copyFrameBufferToCanvas() void runShellUi(platform::SurfacePresenter& presenter, std::chrono::milliseconds auto_exit_after, bool tmap_creation) { +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + ::platform::linux_runtime::BacklightScreenBinding screen; + ::platform::linux_runtime::CardputerCap cap; + cap.begin(); + CardputerSensorBinding sensors; +#endif ShellSession shell; CanvasLvglHost host{shell}; @@ -394,9 +421,24 @@ void runShellUi(platform::SurfacePresenter& presenter, auto next_frame = clock::now(); const auto started_at = next_frame; bool presenting = true; +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + bool screen_initialized = false; +#endif while ((presenting = presenter.pump()) && !::platform::linux_runtime::application_restart_requested()) { - shell.enqueueInputs(presenter.drainInput()); + const auto input = presenter.drainInput(); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + for (const auto& event : input) + { + const bool confirm = event.key == app::InputKey::Enter || + (event.key == app::InputKey::Character && event.text == ' '); + if (!::platform::linux_runtime::BacklightScreenBinding::consumeWakeInput(confirm)) + shell.enqueueInputs({event}); + } + ::platform::linux_runtime::service_screen_runtime(); +#else + shell.enqueueInputs(input); +#endif if (auto_exit_after > std::chrono::milliseconds::zero() && (clock::now() - started_at) >= auto_exit_after) @@ -406,6 +448,29 @@ void runShellUi(platform::SurfacePresenter& presenter, } host.tick(); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + if (shell.ready() && !screen_initialized) + { + ::platform::ui::screen::Hooks hooks; + hooks.show_screen_saver = ::ui::components::screen_saver_overlay::show; + hooks.hide_screen_saver = ::ui::components::screen_saver_overlay::hide; + hooks.present_screen_saver = ::ui::components::screen_saver_overlay::request_present; + ::platform::ui::screen::init(hooks); + screen_initialized = true; + } + if (shell.ready() && cap.pending()) + { + auto& radio = ::platform::linux_runtime::mesh_packet_radio(); + const auto gps = ::platform::ui::gps::diagnostics(); + const char* radio_error = radio.lastError(); + const ::platform::linux_runtime::CardputerCapObservation observation{ + ::platform::linux_runtime::packet_radio_enabled(), radio.isOnline(), + gps.enabled, gps.chars_total, radio_error ? radio_error : "", radio.hardwareCandidatePresent()}; + const auto elapsed = std::chrono::duration_cast(clock::now() - started_at).count(); + if (const char* notice = cap.takeNotice(static_cast(elapsed), observation)) + ::ui::feedback::show_notice(notice, 4500); + } +#endif presenter.setTextInputState(shell.textInputState()); presenter.present(host.canvas()); diff --git a/platform/linux/rpi/src/platform/device/evdev_input.cpp b/platform/linux/rpi/src/platform/device/evdev_input.cpp index f3a188ee..bee72de3 100644 --- a/platform/linux/rpi/src/platform/device/evdev_input.cpp +++ b/platform/linux/rpi/src/platform/device/evdev_input.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -425,6 +426,9 @@ struct EvdevInput::Impl bool shift_held{false}; bool ctrl_held{false}; bool alt_held{false}; + bool escape_held{false}; + bool quit_requested{false}; + std::chrono::steady_clock::time_point escape_pressed_at{}; bool openDevice(const std::string& path) { @@ -459,6 +463,8 @@ struct EvdevInput::Impl shift_held = false; ctrl_held = false; alt_held = false; + escape_held = false; + quit_requested = false; } bool tryAutoDiscovery() @@ -605,12 +611,27 @@ std::vector EvdevInput::drainInput() continue; } - // We only handle EV_KEY events. + if (raw.type == EV_SYN && raw.code == SYN_DROPPED) + { + // A lost release must never become an unintended long-press exit. + impl_->escape_held = false; + } + // We only emit EV_KEY events. if (raw.type != EV_KEY) { continue; } + if (raw.code == KEY_ESC) + { + if (raw.value == 1 && !impl_->escape_held) + { + impl_->escape_held = true; + impl_->escape_pressed_at = std::chrono::steady_clock::now(); + } + else if (raw.value == 0) impl_->escape_held = false; + } + // Track modifier state. if (raw.code == KEY_LEFTSHIFT || raw.code == KEY_RIGHTSHIFT) { @@ -641,11 +662,18 @@ std::vector EvdevInput::drainInput() events.push_back(makeInputEvent(*mapping, impl_->shift_held)); } + if (impl_->escape_held && std::chrono::steady_clock::now() - impl_->escape_pressed_at >= std::chrono::milliseconds(1500)) + impl_->quit_requested = true; #endif return events; } +bool EvdevInput::quitRequested() const noexcept +{ + return impl_->quit_requested; +} + const std::string& EvdevInput::devicePath() const noexcept { return impl_->device_path; diff --git a/platform/linux/rpi/src/platform/device/evdev_input.h b/platform/linux/rpi/src/platform/device/evdev_input.h index 3ff56fbe..160e4d59 100644 --- a/platform/linux/rpi/src/platform/device/evdev_input.h +++ b/platform/linux/rpi/src/platform/device/evdev_input.h @@ -44,6 +44,9 @@ class EvdevInput /// when no device is open or no events are available. std::vector drainInput(); + /// A long Esc hold requests a clean return to APPLaunch. + bool quitRequested() const noexcept; + /// Returns the device path that was actually opened (for diagnostics). const std::string& devicePath() const noexcept; diff --git a/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.cpp b/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.cpp index cb765717..37c5573f 100644 --- a/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.cpp +++ b/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.cpp @@ -102,6 +102,7 @@ struct LinuxFramebufferPlatform::Impl } framebuffer_memory = static_cast(mapped); + staging.resize(framebuffer_size); } ~Impl() @@ -116,7 +117,7 @@ struct LinuxFramebufferPlatform::Impl } } - void writePackedPixel(int x, int y, std::uint32_t packed) const noexcept + void writePackedPixel(int x, int y, std::uint32_t packed) noexcept { const int pixel_x = x + static_cast(vinfo.xoffset); const int pixel_y = y + static_cast(vinfo.yoffset); @@ -124,7 +125,7 @@ struct LinuxFramebufferPlatform::Impl for (int byte_index = 0; byte_index < bytes_per_pixel; ++byte_index) { - framebuffer_memory[offset + static_cast(byte_index)] = + staging[offset + static_cast(byte_index)] = static_cast((packed >> (byte_index * 8)) & 0xFFU); } } @@ -136,6 +137,10 @@ struct LinuxFramebufferPlatform::Impl fb_var_screeninfo vinfo{}; std::size_t framebuffer_size{}; std::byte* framebuffer_memory{}; + std::vector staging; + std::vector previous_pixels; + int previous_width = 0, previous_height = 0; + FramebufferStatistics statistics; EvdevInput evdev_input{}; }; @@ -148,7 +153,7 @@ LinuxFramebufferPlatform::~LinuxFramebufferPlatform() = default; bool LinuxFramebufferPlatform::pump() { - return g_running.load(); + return g_running.load() && !impl_->evdev_input.quitRequested(); } std::vector LinuxFramebufferPlatform::drainInput() @@ -158,6 +163,15 @@ std::vector LinuxFramebufferPlatform::drainInput() void LinuxFramebufferPlatform::present(const core::Canvas& canvas) { + ++impl_->statistics.requests; + if (canvas.width() <= 0 || canvas.height() <= 0) + throw std::runtime_error("invalid logical display size"); + const auto& pixels = canvas.pixels(); + static_assert(sizeof(core::Color) == 4, "Framebuffer cache compares packed RGBA pixels"); + if (impl_->previous_width == canvas.width() && impl_->previous_height == canvas.height() && + impl_->previous_pixels.size() == pixels.size() && + std::memcmp(impl_->previous_pixels.data(), pixels.data(), pixels.size() * sizeof(core::Color)) == 0) + return; 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(); @@ -174,7 +188,9 @@ void LinuxFramebufferPlatform::present(const core::Canvas& canvas) 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); + // The panel may scan mmap memory at any time. Clearing it before drawing + // exposes black/partial frames on the LCD. Compose privately, then publish. + std::fill(impl_->staging.begin(), impl_->staging.end(), std::byte{0}); for (int y = 0; y < canvas.height(); ++y) { @@ -191,6 +207,17 @@ void LinuxFramebufferPlatform::present(const core::Canvas& canvas) } } } + std::memcpy(impl_->framebuffer_memory, impl_->staging.data(), impl_->framebuffer_size); + ++impl_->statistics.commits; + impl_->statistics.bytes_written += impl_->framebuffer_size; + impl_->previous_pixels.assign(pixels.begin(), pixels.end()); + impl_->previous_width = canvas.width(); + impl_->previous_height = canvas.height(); +} + +FramebufferStatistics LinuxFramebufferPlatform::statistics() const noexcept +{ + return impl_->statistics; } } // 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 index 7f753a03..c58f19b7 100644 --- a/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.h +++ b/platform/linux/rpi/src/platform/device/linux_framebuffer_platform.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -9,6 +10,13 @@ namespace trailmate::cardputer_zero::platform::device { +struct FramebufferStatistics +{ + std::uint64_t requests = 0; + std::uint64_t commits = 0; + std::uint64_t bytes_written = 0; +}; + class LinuxFramebufferPlatform : public SurfacePresenter { public: @@ -18,6 +26,7 @@ class LinuxFramebufferPlatform : public SurfacePresenter [[nodiscard]] bool pump() override; [[nodiscard]] std::vector drainInput() override; void present(const core::Canvas& canvas) override; + [[nodiscard]] FramebufferStatistics statistics() const noexcept; private: struct Impl; diff --git a/platform/shared/include/platform/shared/chat/auto_reply_observer.h b/platform/shared/include/platform/shared/chat/auto_reply_observer.h new file mode 100644 index 00000000..f5b808dd --- /dev/null +++ b/platform/shared/include/platform/shared/chat/auto_reply_observer.h @@ -0,0 +1,30 @@ +#pragma once + +#include "app/app_facade_access.h" +#include "chat/usecase/auto_reply_observer.h" +#include "platform/ui/auto_reply_settings.h" +#include "platform/ui/screen_runtime.h" +#include "platform/ui/settings_store.h" +#include + +namespace platform::shared::chat +{ +inline std::unique_ptr<::chat::ChatService::IncomingMessageObserver> +create_auto_reply_observer(::chat::ChatService& service) +{ + ::chat::auto_reply::ObserverHooks hooks; + hooks.read_reply_text = [](std::string& text) + { + return ui::settings_store::get_bool(ui::auto_reply::kSettingsNamespace, + ui::auto_reply::kEnabledKey, false) && + ui::settings_store::get_string(ui::auto_reply::kSettingsNamespace, + ui::auto_reply::kTextKey, text); + }; + hooks.screen_sleeping = ui::screen::is_sleeping; + hooks.self_node = []() + { return app::appFacade().getSelfNodeId(); }; + hooks.max_text_bytes = ui::auto_reply::kTextMaxBytes; + return std::unique_ptr<::chat::ChatService::IncomingMessageObserver>( + new ::chat::auto_reply::Observer(service, hooks)); +} +} // namespace platform::shared::chat diff --git a/platform/shared/include/platform/shared/radio/restart_receive.h b/platform/shared/include/platform/shared/radio/restart_receive.h new file mode 100644 index 00000000..3f46eedd --- /dev/null +++ b/platform/shared/include/platform/shared/radio/restart_receive.h @@ -0,0 +1,14 @@ +#pragma once + +namespace platform::shared::radio +{ +// Continuous RX remains active after RX_DONE. Radios such as LR2021 reject +// receive-path configuration until standby, even after their FIFO was drained. +// The caller owns the radio lock and transport-specific failure handling. +template +bool restartReceive(Radio& radio, Check check, const char* operation) +{ + return check(radio.standby(), "standby before receive") && + check(radio.startReceive(), operation); +} +} // namespace platform::shared::radio diff --git a/scripts/verify_linux_release_packages.py b/scripts/verify_linux_release_packages.py new file mode 100644 index 00000000..b3c4a084 --- /dev/null +++ b/scripts/verify_linux_release_packages.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Verify actual Linux release archives before uploading them as release assets.""" +import argparse +import hashlib +import json +from pathlib import Path +import struct +import subprocess +import tempfile + + +def field(package, name): + return subprocess.check_output(["dpkg-deb", "-f", str(package), name], text=True).strip() + + +def verify(directory, version, device): + name, arch, runtime, binary, machine = ( + ("trailmate-cardputer-zero", "arm64", "linux-arm64", "usr/lib/trailmate-cardputer-zero/trailmate-cardputer-zero-fbdev", 183) + if device == "cardputer-zero" else + ("trailmate-cm0", "armhf", "linux-arm", "usr/bin/trailmate-linux", 40)) + packages = [p for p in directory.rglob("*.deb") if field(p, "Package") == name] + if len(packages) != 1: + raise RuntimeError(f"Expected one {name} archive, found {len(packages)}") + package = packages[0] + assert field(package, "Architecture") == arch, "Incorrect target architecture" + assert field(package, "Version") == version, "Package version differs from release" + depends = field(package, "Depends") + assert "osmium-tool" in depends and "libicu" in depends, "Missing online-map dependencies" + with tempfile.TemporaryDirectory(prefix="trailmate-release-verify-") as temporary: + root = Path(temporary) + subprocess.run(["dpkg-deb", "-x", str(package), str(root)], check=True) + executable = root / binary + header = executable.read_bytes()[:20] + assert header[:4] == b"\x7fELF" and struct.unpack_from("