diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cb525c3..3288ae72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,11 +85,21 @@ jobs: cp .pio/build/${{ matrix.env }}/firmware.bin dist/trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }}.bin fi + - name: Prepare web flasher image + if: matrix.env == 'tlora_pager_sx1262' || matrix.env == 'tdeck' || matrix.env == 'lilygo_twatch_s3' + run: | + python scripts/prepare_webflash_release.py \ + --env ${{ matrix.env }} \ + --build-root .pio/build \ + --dist dist \ + --boot-app0 ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ + --esptool ~/.platformio/packages/tool-esptoolpy/esptool.py + - name: Upload firmware artifact uses: actions/upload-artifact@v4 with: name: trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }} - path: dist/trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }}.* + path: dist/* release: name: Publish GitHub Release diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..3dfad5c5 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,63 @@ +name: Pages + +on: + push: + branches: + - main + paths: + - site/** + - docs/images/** + - scripts/prepare_pages_site.py + - scripts/webflash_targets.py + - .github/workflows/pages.yml + release: + types: + - published + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: github-pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Prepare site assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/prepare_pages_site.py --site-root site --repo ${{ github.repository }} + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b5cf65..df0e4e1d 100644 Binary files a/CHANGELOG.md and b/CHANGELOG.md differ diff --git a/boards/tdeck/src/tdeck_board.cpp b/boards/tdeck/src/tdeck_board.cpp index 7cf9f4b1..791b2885 100644 --- a/boards/tdeck/src/tdeck_board.cpp +++ b/boards/tdeck/src/tdeck_board.cpp @@ -23,6 +23,8 @@ constexpr uint8_t kTDeckKeyboardDefaultBrightnessCmd = 0x02; constexpr uint8_t kTDeckKeyboardModeRawCmd = 0x03; constexpr uint8_t kTDeckKeyboardModeKeyCmd = 0x04; constexpr uint32_t kTDeckKeyboardBootDelayMs = 500; +constexpr float kTDeckRadioTcxoVoltage = 1.8f; +constexpr float kTDeckRadioCurrentLimitMa = 140.0f; // Civil date to UNIX epoch days (UTC), based on Howard Hinnant's algorithm. int64_t days_from_civil(int year, unsigned month, unsigned day) @@ -260,11 +262,23 @@ uint32_t TDeckBoard::begin(uint32_t disable_hw_init) // Initialize radio before SD to align with the pager begin() sequence. radio_.reset(); - int radio_state = radio_.begin(); + int radio_state = radio_.begin(434.0f, 125.0f, 9, 7, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, + 10, 8, kTDeckRadioTcxoVoltage, false); if (radio_state == RADIOLIB_ERR_NONE) { - devices_probe_ |= HW_RADIO_ONLINE; - Serial.println("[TDeckBoard] radio init OK"); + const int rf_switch_state = radio_.setDio2AsRfSwitch(true); + const int current_limit_state = radio_.setCurrentLimit(kTDeckRadioCurrentLimitMa); + if (rf_switch_state == RADIOLIB_ERR_NONE && current_limit_state == RADIOLIB_ERR_NONE) + { + devices_probe_ |= HW_RADIO_ONLINE; + Serial.println("[TDeckBoard] radio init OK"); + } + else + { + Serial.printf("[TDeckBoard] radio post-init failed: rf_switch=%d current_limit=%d\n", + rf_switch_state, + current_limit_state); + } } else { @@ -866,7 +880,7 @@ bool TDeckBoard::syncTimeFromGPS(uint32_t gps_task_interval_ms) int TDeckBoard::transmitRadio(const uint8_t* data, size_t len) { // Share the SPI bus with display to avoid tearing due to contention. - if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50))) + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) { int rc = radio_.transmit(data, len); LilyGoDispArduinoSPI::unlock(); @@ -877,7 +891,7 @@ int TDeckBoard::transmitRadio(const uint8_t* data, size_t len) int TDeckBoard::startRadioReceive() { - if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50))) + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) { int rc = radio_.startReceive(); LilyGoDispArduinoSPI::unlock(); @@ -888,7 +902,7 @@ int TDeckBoard::startRadioReceive() uint32_t TDeckBoard::getRadioIrqFlags() { - if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) { uint32_t flags = radio_.getIrqFlags(); LilyGoDispArduinoSPI::unlock(); @@ -899,7 +913,7 @@ uint32_t TDeckBoard::getRadioIrqFlags() int TDeckBoard::getRadioPacketLength(bool update) { - if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) { int len = static_cast(radio_.getPacketLength(update)); LilyGoDispArduinoSPI::unlock(); @@ -910,7 +924,7 @@ int TDeckBoard::getRadioPacketLength(bool update) int TDeckBoard::readRadioData(uint8_t* buf, size_t len) { - if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50))) + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) { int rc = radio_.readData(buf, len); LilyGoDispArduinoSPI::unlock(); @@ -921,7 +935,7 @@ int TDeckBoard::readRadioData(uint8_t* buf, size_t len) void TDeckBoard::clearRadioIrqFlags(uint32_t flags) { - if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) { radio_.clearIrqFlags(flags); LilyGoDispArduinoSPI::unlock(); @@ -930,7 +944,7 @@ void TDeckBoard::clearRadioIrqFlags(uint32_t flags) float TDeckBoard::getRadioRSSI() { - if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) { float rssi = radio_.getRSSI(); LilyGoDispArduinoSPI::unlock(); @@ -941,7 +955,7 @@ float TDeckBoard::getRadioRSSI() float TDeckBoard::getRadioSNR() { - if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) { float snr = radio_.getSNR(); LilyGoDispArduinoSPI::unlock(); @@ -964,8 +978,12 @@ void TDeckBoard::configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, ui int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, uint8_t crc_len) { - if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(100))) + if (LilyGoDispArduinoSPI::lock(portMAX_DELAY)) { +#if defined(ARDUINO_LILYGO_LORA_SX1262) + radio_.setDio2AsRfSwitch(true); + radio_.setCurrentLimit(kTDeckRadioCurrentLimitMa); +#endif radio_.setFrequency(freq_mhz); radio_.setBandwidth(bw_khz); radio_.setSpreadingFactor(sf); diff --git a/docs/RETICULUM_LXMF_DEVICE_MODE.md b/docs/RETICULUM_LXMF_DEVICE_MODE.md index 5d498c25..eea7d5d8 100644 --- a/docs/RETICULUM_LXMF_DEVICE_MODE.md +++ b/docs/RETICULUM_LXMF_DEVICE_MODE.md @@ -1,5 +1,14 @@ # Reticulum / LXMF Device Mode Plan +> Note +> +> This document captures the earlier phase-based device-mode plan and remains +> useful for historical context. +> +> The current authoritative implementation target is now documented in +> [RETICULUM_LXMF_RUNTIME_ALIGNMENT_PLAN.md](RETICULUM_LXMF_RUNTIME_ALIGNMENT_PLAN.md), +> which reflects the present codebase and the full runtime-alignment direction. + ## Why this document exists Trail Mate already has two very different things under the "RNode" umbrella: diff --git a/docs/RETICULUM_LXMF_RUNTIME_ALIGNMENT_PLAN.md b/docs/RETICULUM_LXMF_RUNTIME_ALIGNMENT_PLAN.md new file mode 100644 index 00000000..99f93a7e --- /dev/null +++ b/docs/RETICULUM_LXMF_RUNTIME_ALIGNMENT_PLAN.md @@ -0,0 +1,339 @@ +# Reticulum / LXMF Runtime Alignment Plan + +## Purpose + +This document defines the full Trail Mate adaptation target for device-side +Reticulum and LXMF support over the existing ESP RNode-compatible radio path. + +It replaces the earlier phase-oriented view that treated `LxmfAdapter` as a +single adapter with incremental feature add-ons. The runtime has now grown into + transport, path discovery, link relay, resource transfer, propagation, and + business-level LXMF delivery concerns. Continuing to extend it as one large + class would make protocol alignment, testing, and field debugging increasingly + fragile. + +The goal is to land a complete embedded runtime architecture that is: + +- protocol-accurate enough to interoperate with upstream Reticulum/LXMF nodes +- structured enough to reason about path, link, and resource lifecycles +- observable enough to debug delivery failures in real deployments +- persistent enough to survive reboot without forgetting core identity and peer + recall + +## Scope + +This plan covers the device-side runtime used when Trail Mate is operating in + native `LXMF` mode. It does not redefine `RNode Bridge`, which remains the + host-controlled modem path for an external Reticulum instance. + +This plan is explicitly about: + +- transport runtime behavior +- path discovery and path recovery +- link establishment and teardown +- resource transfer lifecycle +- LXMF direct and propagated delivery +- persistence and recovery rules +- test and validation criteria + +This plan is not, by itself, a commitment to implement every upstream desktop +Reticulum feature such as management destinations or full shared-instance +service parity. The target is complete, system-level behavior for an embedded +device node. + +## Current Baseline + +The current `LxmfAdapter` already implements more than the original phase-1/2 +documents describe: + +- Reticulum announce validation and caching +- path request emission and path-response handling +- cached announce replay +- `HEADER_2` multi-hop forwarding +- reverse-path proof relay +- local link sessions +- link request relay +- resource advertise/request/hashmap/part/proof flows +- propagation offer/get request handling + +The main problem is not missing packet codecs. The main problem is that these +capabilities are still governed by a monolithic adapter with limited runtime +lifecycle control. + +## Current Implementation Status + +The codebase now includes the first full runtime-alignment landing for this +plan. + +Implemented runtime behaviors: + +- shared runtime state models for transport, link, and propagation ownership +- destination-keyed pending path request tracking and timeout cleanup +- path-resolution clearing when matching announces are learned +- link close reasons and teardown-driven state cleanup +- timeout-driven path expiry and rediscovery trigger on failed outbound links +- outbound link establishment for locally initiated large-payload delivery +- deferred link payload queuing until the link becomes active +- active-link keepalive, stale transition, and timeout teardown behavior +- split-resource segment assembly for inbound multi-segment resource delivery + +Still intentionally narrower than full upstream desktop/service parity: + +- no shared-instance service runtime parity +- no management destinations or tunnel handling +- no metadata/compressed resource handling yet +- no deep automated interoperability matrix inside CI + +## End-State Runtime Architecture + +The end state is a layered runtime with the following modules. + +### 1. Carrier Layer + +`RNodeAdapter` remains responsible for: + +- raw LoRa send/receive +- fragmentation/reassembly +- radio parameter application +- RX metadata capture + +The carrier layer is unaware of Reticulum destinations, links, or LXMF payload +semantics. + +### 2. Reticulum Transport Runtime + +The transport runtime owns all network-level state that is independent of a +particular link payload. + +Responsibilities: + +- packet duplicate filtering +- path learning from announces +- path request lifecycle management +- pending local path request tracking +- announce cache, replay, hold, and release policy +- reverse-path proof routing +- `HEADER_1` and `HEADER_2` forwarding decisions +- local destination lookup +- link relay discovery state +- stale state culling and rediscovery triggers + +Required state tables: + +- `path_table` +- `destinations_map` +- `announce_table` +- `held_announces` +- `path_requests` +- `pending_local_path_requests` +- `reverse_table` +- `link_table` +- `packet_filter` + +Implementation note: + +On ESP targets these tables can remain bounded containers instead of unbounded + maps, but they must still behave like keyed runtime tables with explicit + lookup, lifetime, and eviction rules. + +### 3. Reticulum Link Runtime + +The link runtime owns encrypted session establishment and lifecycle. + +Responsibilities: + +- link request handling +- handshake proof validation +- RTT exchange +- keepalive and stale detection +- teardown and close reason classification +- pending request tracking +- validated-state tracking +- link-level packet proof handling +- resource runtime coordination + +Required lifecycle: + +- `Pending` +- `Handshake` +- `Active` +- `Stale` +- `Closed` + +Every exit path from a live link must converge on a single teardown routine that +clears pending requests, cancels resources, shuts down channels, and erases +session secrets. + +### 4. Reticulum Resource Runtime + +The resource runtime owns large-payload delivery over links. + +Responsibilities: + +- advertisement encoding/decoding +- windowed part request scheduling +- hashmap segment tracking +- split-resource continuation +- cancel semantics +- proof completion semantics +- timeout and retry policy +- link-close cleanup + +The target behavior is not the current "single-segment encrypted resource only" +subset. The runtime must be able to reason about: + +- encrypted resources +- split resources +- resource proof timing +- in-flight cancellation +- partial hashmap knowledge + +### 5. LXMF Service Layer + +The LXMF service layer sits above Reticulum transport/link/resource machinery +and converts protocol behavior into product behavior. + +Responsibilities: + +- announce-derived contact updates +- opportunistic delivery +- direct link-based delivery +- propagation offer/get flows +- local chat/app-data injection +- proof/delivery result translation to UI/service events + +This layer should not own route discovery or link teardown policy directly. + +### 6. Persistence Layer + +Persistence rules must be explicit. + +Persisted state: + +- local identity keypairs +- known peer identities and display names +- known destinations needed for recall +- durable propagation storage entries that survive reboot if product policy + requires it + +Ephemeral runtime state: + +- packet filter entries +- reverse proof routes +- live link sessions +- in-flight resource transfers +- held announces +- pending path requests + +## Functional Requirements + +### Transport / Path + +- Support direct and multi-hop announce learning. +- Track outstanding path requests per destination. +- Coalesce duplicate path requests for the same destination. +- Expire stale pending path requests. +- Release held announces when a pending request completes or times out. +- Rediscover paths after failed link establishment where policy allows. +- Route proofs using reverse-path state with explicit expiry. + +### Link + +- Support local link establishment for delivery and propagation destinations. +- Relay remote link traffic with explicit relay/link table state. +- Track link validation state separately from mere session presence. +- Tear down links deterministically on timeout, close packet, or local error. +- Cancel all live resources during teardown. +- Avoid leaving half-closed sessions until TTL cleanup. + +### Resource + +- Support outbound and inbound resource lifecycle management. +- Track segment and hashmap state explicitly. +- Support cancel semantics that also stop in-flight transfers. +- Associate resource state with link lifecycle. +- Reject unsupported resource forms only when policy explicitly says so. +- Prefer protocol-correct support over long-term permanent rejection. + +### LXMF Delivery + +- Opportunistic text/app-data delivery must remain supported. +- Link-based delivery should be the preferred path when a validated link exists. +- Propagation sync must use the same resource runtime rather than a side + protocol path. +- Delivery success semantics must distinguish: + - raw packet send + - proof receipt + - link response readiness + - business-level response intent + +## Implementation Strategy + +The implementation proceeds in these layers, but each layer is expected to be +carried to a coherent, usable state before moving on. + +### Stage 1: Runtime Decomposition + +- Move path/transport state into a dedicated transport state container. +- Move link relay/session state into a dedicated link state container. +- Move propagation stores into a dedicated propagation state container. +- Move shared state types into a dedicated runtime state header. +- Keep the public `LxmfAdapter` API stable. + +### Stage 2: Transport Lifecycle Completion + +- Add destination-keyed pending path request tracking. +- Add explicit completion/timeout cleanup for path requests. +- Make path learning resolve pending requests. +- Add hooks for link-failure-driven rediscovery. +- Prepare local destination lookup as a first-class runtime concern. + +### Stage 3: Link Lifecycle Completion + +- Replace mark-only close behavior with real teardown. +- Introduce close reason tracking and stale-state transitions. +- Ensure timeout cleanup routes through the same teardown path. +- Make resource cancellation part of teardown. + +### Stage 4: Resource Lifecycle Completion + +- Normalize inbound and outbound transfer state machines. +- Add stronger cancellation semantics. +- Add split-resource support and proper segment continuation. +- Align proof timing and cancellation behavior with upstream expectations. + +### Stage 5: Service and Interop Hardening + +- Remove duplicated business logic across opportunistic/link/propagation paths. +- Add structured debug logging and counters. +- Add cross-validation against `.tmp/Reticulum` runtime behavior. + +## Validation Matrix + +The work is not complete until the following categories have been exercised. + +- Direct announce discovery and opportunistic text delivery +- Multi-hop path request, path response, and proof return +- Link establish, keepalive, close, and timeout recovery +- Large link resource transfer with proof completion +- Resource cancellation while transfer is still active +- Propagation offer/get with mixed payload sizes +- Reboot recovery with persisted identity and peer recall intact + +## Acceptance Criteria + +The adaptation is considered complete when all of the following are true: + +- The runtime is no longer governed by a single monolithic state blob. +- Transport, link, and resource lifecycle rules are explicit in code. +- Path requests and link teardown have dedicated runtime bookkeeping. +- Resource cleanup is tied to link lifecycle rather than passive TTL alone. +- The implementation can explain delivery success and failure at each stage. +- The architecture document and codebase describe the same system. + +## Relationship To Existing Documents + +- `RETICULUM_LXMF_DEVICE_MODE.md` remains useful as historical background and + for earlier phase context. +- This document is the authoritative implementation target for the current + alignment work. diff --git a/docs/issue-21-tdeck-meshtastic-fix-checklist.md b/docs/issue-21-tdeck-meshtastic-fix-checklist.md new file mode 100644 index 00000000..e59a12b7 --- /dev/null +++ b/docs/issue-21-tdeck-meshtastic-fix-checklist.md @@ -0,0 +1,39 @@ +# Issue #21 T-Deck Meshtastic Fix Checklist + +## 问题描述 + +用户反馈 Trail Mate 在原版 T-Deck 上出现 “Meshtastic 能收不能发,消息最终判定失败” 的现象,而 `.tmp/firmware` 中的参考固件在同一硬件上工作正常。 + +## 根因归纳 + +- T-Deck 的 SX1262 初始化没有对齐参考固件,缺少显式的 `TCXO 1.8V`、`DIO2 RF switch` 和 `140mA current limit` 对齐。 +- Radio 与 Display 共享 SPI 时,T-Deck 板级代码使用了很短的锁等待时间,可能在 UI flush 期间让 `TX -> RX` 切换失败。 +- `MtAdapter` 有多条直接发送路径会绕过 `radioTask()` 的本地 `rx_started` 状态,导致一旦 `startRadioReceive()` 失败,RX 可能被悄悄丢失。 +- `radioTask()` 之前只在 `RX_DONE` 后重启接收,对 `CRC_ERR`、`HEADER_ERR`、`TIMEOUT` 这类终止性 IRQ 没有统一恢复逻辑。 +- 当前 ACK 逻辑只会等待一次超时然后判失败,没有把已发出的可靠报文保留下来做再次发射,和参考固件的 reliable retransmission 模型存在明显差距。 + +## 修复清单 + +- [x] 对齐 T-Deck 的 SX1262 启动参数:显式使用 `TCXO 1.8V`、`DIO2 as RF switch`、`140mA current limit`。 +- [x] 把 T-Deck 的关键 Radio SPI 访问改成阻塞锁,避免显示刷新抢占导致的发射后无法及时回到接收态。 +- [x] 给 `AppTasks` 增加共享 RX 状态与“请求重启 RX”机制,让直接发送路径和 `radioTask()` 使用同一套接收恢复状态。 +- [x] 让 `radioTask()` 在 `RX_DONE / CRC_ERR / HEADER_ERR / TIMEOUT` 后统一执行 RX 重启,而不是只依赖单次局部状态变量。 +- [x] 统一 `MtAdapter` 的无线发射入口,所有 Meshtastic 直发路径都通过同一个 `transmitWirePacket()` 完成 TX 后的 RX 恢复。 +- [x] 为需要 ACK 的 Meshtastic 发送保留原始 wire packet,并在 ACK 超时后执行最多 `3` 次可靠重发。 +- [x] 修正 ACK 超时失败上报时的目标节点、频道与 `channel_hash` 元数据,避免一律误报成 primary channel。 +- [x] 在文本发送队列达到最大重试后,补发显式失败结果,避免静默丢弃。 + +## 范围说明 + +- 本次修复没有整包移植 `.tmp/firmware` 的 `ReliableRouter` / `NextHopRouter`,但已经把和该问题最直接相关的三层差异补齐: +- 板级射频初始化对齐。 +- 发送后回到接收态的恢复链路对齐。 +- 可靠发送的 ACK 超时重发能力补齐。 + +## 建议验证 + +- `pio run -e tdeck` +- `pio run -e tlora_pager_sx1262` +- `pio run -e lilygo_twatch_s3` +- `pio run -e gat562_mesh_evb_pro` +- `clang-format-14 --dry-run --Werror ...` 按 CI 规则检查格式 diff --git a/docs/ui_localization_plan.md b/docs/ui_localization_plan.md new file mode 100644 index 00000000..87884325 --- /dev/null +++ b/docs/ui_localization_plan.md @@ -0,0 +1,95 @@ +# UI Localization Plan + +## Goal + +Add runtime-selectable UI localization for English and Chinese across the LVGL-based Trail Mate interface. + +- Default language: English +- Supported languages: English / Chinese +- Language switch entry point: `Settings > System > Display Language` +- Switch behavior: apply immediately without reboot + +## Scope + +This task covers user-facing UI text produced by the device firmware itself, including: + +- Main menu app names +- Shared LVGL screens under `modules/ui_shared` +- ESP-specific LVGL screens under `platform/esp/arduino_common/src/ui` +- Shared notifications / prompts / modal labels +- Settings categories, items, enum labels, action labels, and validation messages +- Menu dashboard widgets +- Screen saver prompts and device-side status notices + +This task does not translate: + +- Incoming user messages +- Contact names, node names, callsigns, channel names, or other user-generated content +- Protocol brand names such as `Meshtastic`, `MeshCore`, `LXMF`, and `RNode` +- README / docs / release notes + +## Design + +### 1. Translation Dictionary + +Use a shared localization module in `modules/ui_shared` with: + +- `ui::i18n::Language` +- persistent current language state +- `ui::i18n::tr(const char* english)` as the canonical dictionary lookup + +The dictionary uses the existing English source text as the canonical lookup key and returns: + +- the original English string when language is English +- the translated Chinese string when language is Chinese and a translation exists +- the original English string as a fallback when no translation entry exists + +This approach minimizes risk while retrofitting a large existing UI codebase with many hardcoded literals. + +### 2. Persistence + +Persist the current UI language under the `settings` namespace: + +- key: `display_language` +- value: `0 = English`, `1 = Chinese` + +### 3. Runtime Refresh + +Changing the language should: + +- persist the new language +- refresh main menu labels +- rebuild the currently active app screen asynchronously + +This avoids requiring every widget to subscribe to a language-change event individually. + +### 4. Font Handling + +Localized labels must automatically switch to the CJK font when the translated text contains non-ASCII characters. + +Use the shared font helpers so that: + +- ASCII labels keep their existing UI font +- Chinese labels switch to the Noto CJK font when available +- boards compiled without CJK glyph support still fall back safely to the existing font configuration + +## Implementation Steps + +1. Add the shared localization module and persistence helpers. +2. Add menu/app refresh support so language changes take effect immediately. +3. Add `Display Language` to `Settings > System`. +4. Route shared settings labels, options, prompts, and validation messages through localization. +5. Localize menu titles, dashboard labels, shared widgets, modal buttons, and notifications. +6. Localize page-level fixed strings across Contacts / Chat / GPS / Tracker / PC Link / USB / SSTV / GNSS / Walkie Talkie / placeholder pages. +7. Localize ESP-specific prompts such as screen-saver text and battery / event notifications. +8. Run formatting and CI-equivalent builds, then fix any regressions. + +## Acceptance Criteria + +- English is the default UI language on clean startup. +- The language can be changed in `Settings > System > Display Language`. +- Changing the language updates the current screen immediately. +- Returning to the main menu shows localized app names. +- Shared prompts such as `Back`, `Save`, `Cancel`, `Loading...`, system toasts, and screen titles are localized. +- Chinese text renders legibly with CJK glyph coverage on supported targets. +- Existing PlatformIO CI builds still pass. diff --git a/modules/ui_shared/include/ui/app_runtime.h b/modules/ui_shared/include/ui/app_runtime.h index cffb43c2..2726e44d 100644 --- a/modules/ui_shared/include/ui/app_runtime.h +++ b/modules/ui_shared/include/ui/app_runtime.h @@ -13,6 +13,7 @@ AppScreen* ui_get_active_app(); void ui_switch_to_app(AppScreen* app, lv_obj_t* parent); void ui_exit_active_app(lv_obj_t* parent); void ui_request_exit_to_menu(); +void ui_request_rebuild_active_app(); void ui_set_overlay_active(bool active); bool ui_is_overlay_active(); void set_default_group(lv_group_t* group); diff --git a/modules/ui_shared/include/ui/app_screen.h b/modules/ui_shared/include/ui/app_screen.h index 918111f6..29e29021 100644 --- a/modules/ui_shared/include/ui/app_screen.h +++ b/modules/ui_shared/include/ui/app_screen.h @@ -6,6 +6,7 @@ class AppScreen { public: virtual ~AppScreen() = default; + virtual const char* stable_id() const = 0; virtual const char* name() const = 0; virtual const lv_image_dsc_t* icon() const = 0; virtual void enter(lv_obj_t* parent) = 0; diff --git a/modules/ui_shared/include/ui/assets/fonts/font_utils.h b/modules/ui_shared/include/ui/assets/fonts/font_utils.h index 232eef05..96849733 100644 --- a/modules/ui_shared/include/ui/assets/fonts/font_utils.h +++ b/modules/ui_shared/include/ui/assets/fonts/font_utils.h @@ -50,6 +50,17 @@ inline const lv_font_t* chat_content_font(const char* text) #endif } +inline const lv_font_t* localized_font(const char* text, const lv_font_t* ascii_font) +{ + const lv_font_t* fallback = ascii_font ? ascii_font : ui_chrome_font(); +#if defined(GAT562_NO_CJK) && GAT562_NO_CJK + (void)text; + return fallback; +#else + return utf8_has_non_ascii(text) ? &lv_font_noto_cjk_16_2bpp : fallback; +#endif +} + inline void apply_font(lv_obj_t* label, const lv_font_t* font) { if (label && font) @@ -68,4 +79,9 @@ inline void apply_chat_content_font(lv_obj_t* label, const char* text) apply_font(label, chat_content_font(text)); } +inline void apply_localized_font(lv_obj_t* label, const char* text, const lv_font_t* ascii_font) +{ + apply_font(label, localized_font(text, ascii_font)); +} + } // namespace ui::fonts diff --git a/modules/ui_shared/include/ui/callback_app_screen.h b/modules/ui_shared/include/ui/callback_app_screen.h index d3a4f513..263b380b 100644 --- a/modules/ui_shared/include/ui/callback_app_screen.h +++ b/modules/ui_shared/include/ui/callback_app_screen.h @@ -1,6 +1,7 @@ #pragma once #include "ui/app_screen.h" +#include "ui/localization.h" namespace ui { @@ -11,24 +12,32 @@ class CallbackAppScreen : public AppScreen using SimpleCallback = void (*)(lv_obj_t* parent); using Callback = void (*)(void* user_data, lv_obj_t* parent); - CallbackAppScreen(const char* name, + CallbackAppScreen(const char* stable_id, + const char* name, const lv_image_dsc_t* icon, SimpleCallback enter, SimpleCallback exit) - : name_(name), icon_(icon), simple_enter_(enter), simple_exit_(exit) + : stable_id_(stable_id), name_(name), icon_(icon), simple_enter_(enter), simple_exit_(exit) { } - CallbackAppScreen(const char* name, + CallbackAppScreen(const char* stable_id, + const char* name, const lv_image_dsc_t* icon, Callback enter, Callback exit, void* user_data = nullptr) - : name_(name), icon_(icon), callback_enter_(enter), callback_exit_(exit), user_data_(user_data) + : stable_id_(stable_id), + name_(name), + icon_(icon), + callback_enter_(enter), + callback_exit_(exit), + user_data_(user_data) { } - const char* name() const override { return name_; } + const char* stable_id() const override { return stable_id_; } + const char* name() const override { return ::ui::i18n::tr(name_); } const lv_image_dsc_t* icon() const override { return icon_; } void enter(lv_obj_t* parent) override @@ -58,6 +67,7 @@ class CallbackAppScreen : public AppScreen } private: + const char* stable_id_ = nullptr; const char* name_ = nullptr; const lv_image_dsc_t* icon_ = nullptr; SimpleCallback simple_enter_ = nullptr; diff --git a/modules/ui_shared/include/ui/localization.h b/modules/ui_shared/include/ui/localization.h new file mode 100644 index 00000000..b886fd67 --- /dev/null +++ b/modules/ui_shared/include/ui/localization.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +#include "lvgl.h" + +namespace ui::i18n +{ + +enum class Language : uint8_t +{ + English = 0, + Chinese = 1, +}; + +Language language_from_raw(int raw_value); +Language current_language(); +bool set_language(Language language, bool persist = true); +bool supports_chinese(); +const char* tr(const char* english); +std::string vformat(const char* english_fmt, va_list args); +std::string format(const char* english_fmt, ...); + +void set_label_text(lv_obj_t* label, const char* english); +void set_label_text_raw(lv_obj_t* label, const char* text); +void set_label_text_fmt(lv_obj_t* label, const char* english_fmt, ...); + +} // namespace ui::i18n diff --git a/modules/ui_shared/include/ui/menu/menu_dashboard.h b/modules/ui_shared/include/ui/menu/menu_dashboard.h index 195a2a12..ff741635 100644 --- a/modules/ui_shared/include/ui/menu/menu_dashboard.h +++ b/modules/ui_shared/include/ui/menu/menu_dashboard.h @@ -10,6 +10,7 @@ namespace ui::menu::dashboard #if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions& options); void bringToFront(); +void refresh_localized_text(); void setActive(bool active); #else inline void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions& options) @@ -23,6 +24,10 @@ inline void bringToFront() { } +inline void refresh_localized_text() +{ +} + inline void setActive(bool active) { (void)active; diff --git a/modules/ui_shared/include/ui/menu/menu_layout.h b/modules/ui_shared/include/ui/menu/menu_layout.h index fcbc17da..529eb9d6 100644 --- a/modules/ui_shared/include/ui/menu/menu_layout.h +++ b/modules/ui_shared/include/ui/menu/menu_layout.h @@ -19,6 +19,7 @@ struct InitOptions void init(const InitOptions& options); lv_obj_t* menuPanel(); void bringContentToFront(); +void refresh_localized_text(); void setMenuVisible(bool visible); } // namespace menu_layout diff --git a/modules/ui_shared/include/ui/screens/settings/settings_state.h b/modules/ui_shared/include/ui/screens/settings/settings_state.h index a1940c88..c6acf45a 100644 --- a/modules/ui_shared/include/ui/screens/settings/settings_state.h +++ b/modules/ui_shared/include/ui/screens/settings/settings_state.h @@ -111,6 +111,7 @@ struct SettingsData int screen_brightness = 16; int timezone_offset_min = 0; int speaker_volume = 45; + int display_language = 0; bool ble_enabled = true; bool vibration_enabled = true; diff --git a/modules/ui_shared/src/ui/app_catalog_builder.cpp b/modules/ui_shared/src/ui/app_catalog_builder.cpp index 1e0802f6..002c2b7a 100644 --- a/modules/ui_shared/src/ui/app_catalog_builder.cpp +++ b/modules/ui_shared/src/ui/app_catalog_builder.cpp @@ -83,59 +83,59 @@ ui::page::Host make_menu_host() ui::page::Host s_menu_host = make_menu_host(); -ui::CallbackAppScreen s_chat_app("Chat", &Chat, +ui::CallbackAppScreen s_chat_app("chat", "Chat", &Chat, chat::ui::shell::enter, chat::ui::shell::exit, &s_menu_host); -ui::CallbackAppScreen s_gps_app("Map", &gps_icon, +ui::CallbackAppScreen s_gps_app("map", "Map", &gps_icon, gps::ui::shell::enter, gps::ui::shell::exit, &s_menu_host); -ui::CallbackAppScreen s_skyplot_app("Sky Plot", &Satellite, +ui::CallbackAppScreen s_skyplot_app("sky_plot", "Sky Plot", &Satellite, gnss::ui::shell::enter, gnss::ui::shell::exit, &s_menu_host); -ui::CallbackAppScreen s_contacts_app("Contacts", &contact, +ui::CallbackAppScreen s_contacts_app("contacts", "Contacts", &contact, contacts::ui::shell::enter, contacts::ui::shell::exit, &s_menu_host); -ui::CallbackAppScreen s_energy_sweep_app("Energy Sweep", &Spectrum, +ui::CallbackAppScreen s_energy_sweep_app("energy_sweep", "Energy Sweep", &Spectrum, energy_sweep::ui::shell::enter, energy_sweep::ui::shell::exit, &s_menu_host); #if !defined(GAT562_NO_TEAM) || !GAT562_NO_TEAM -ui::CallbackAppScreen s_team_app("Team", &team_icon, +ui::CallbackAppScreen s_team_app("team", "Team", &team_icon, team::ui::shell::enter, team::ui::shell::exit, &s_menu_host); #endif -ui::CallbackAppScreen s_tracker_app("Tracker", &tracker_icon, +ui::CallbackAppScreen s_tracker_app("tracker", "Tracker", &tracker_icon, tracker::ui::shell::enter, tracker::ui::shell::exit, &s_menu_host); #if !defined(GAT562_NO_HOSTLINK) || !GAT562_NO_HOSTLINK -ui::CallbackAppScreen s_pc_link_app("Data Exchange", &rf, +ui::CallbackAppScreen s_pc_link_app("pc_link", "Data Exchange", &rf, pc_link::ui::shell::enter, pc_link::ui::shell::exit, &s_menu_host); #endif #if !defined(TRAIL_MATE_ENABLE_SSTV) || TRAIL_MATE_ENABLE_SSTV -ui::CallbackAppScreen s_sstv_app("SSTV", &sstv, +ui::CallbackAppScreen s_sstv_app("sstv", "SSTV", &sstv, sstv_page::ui::shell::enter, sstv_page::ui::shell::exit, &s_menu_host); #endif #if !defined(GAT562_NO_HOSTLINK) || !GAT562_NO_HOSTLINK -ui::CallbackAppScreen s_usb_app("USB Mass Storage", &img_usb, +ui::CallbackAppScreen s_usb_app("usb_mass_storage", "USB Mass Storage", &img_usb, usb_storage::ui::shell::enter, usb_storage::ui::shell::exit, &s_menu_host); #endif -ui::CallbackAppScreen s_setting_app("Setting", &Setting, +ui::CallbackAppScreen s_setting_app("settings", "Setting", &Setting, settings::ui::shell::enter, settings::ui::shell::exit, &s_menu_host); -ui::CallbackAppScreen s_walkie_app("Walkie Talkie", &walkie_talkie, +ui::CallbackAppScreen s_walkie_app("walkie_talkie", "Walkie Talkie", &walkie_talkie, walkie_page::ui::shell::enter, walkie_page::ui::shell::exit, &s_menu_host); diff --git a/modules/ui_shared/src/ui/app_runtime.cpp b/modules/ui_shared/src/ui/app_runtime.cpp index b7a207bb..964e90ed 100644 --- a/modules/ui_shared/src/ui/app_runtime.cpp +++ b/modules/ui_shared/src/ui/app_runtime.cpp @@ -20,6 +20,7 @@ constexpr const char* kTag = "ui-app-runtime"; AppScreen* s_active_app = nullptr; AppScreen* s_pending_exit = nullptr; lv_timer_t* s_exit_timer = nullptr; +lv_timer_t* s_rebuild_timer = nullptr; bool s_overlay_active = false; lv_anim_enable_t transition_anim() @@ -122,6 +123,37 @@ void exit_to_menu_timer_cb(lv_timer_t* timer) ESP_LOGI(kTag, "exit_to_menu_timer_cb complete"); #endif } + +void rebuild_active_app_timer_cb(lv_timer_t* timer) +{ +#if LVGL_VERSION_MAJOR >= 9 + auto* app = static_cast(timer ? lv_timer_get_user_data(timer) : nullptr); +#else + auto* app = static_cast(timer ? timer->user_data : nullptr); +#endif + if (timer) + { + lv_timer_del(timer); + } + s_rebuild_timer = nullptr; + if (app == nullptr || app != s_active_app || main_screen == nullptr) + { + return; + } + + lv_obj_t* parent = lv_obj_get_child(main_screen, 1); + if (parent == nullptr) + { + return; + } + + app->exit(parent); + if (parent != nullptr && child_count(parent) > 0) + { + lv_obj_clean(parent); + } + app->enter(parent); +} } // namespace void set_default_group(lv_group_t* group) @@ -234,6 +266,25 @@ void ui_request_exit_to_menu() } } +void ui_request_rebuild_active_app() +{ + AppScreen* app = s_active_app; + if (app == nullptr || s_pending_exit == app) + { + return; + } + if (s_rebuild_timer) + { + lv_timer_del(s_rebuild_timer); + s_rebuild_timer = nullptr; + } + s_rebuild_timer = lv_timer_create(rebuild_active_app_timer_cb, 1, app); + if (s_rebuild_timer) + { + lv_timer_set_repeat_count(s_rebuild_timer, 1); + } +} + void ui_set_overlay_active(bool active) { s_overlay_active = active; diff --git a/modules/ui_shared/src/ui/localization.cpp b/modules/ui_shared/src/ui/localization.cpp new file mode 100644 index 00000000..4f75141d --- /dev/null +++ b/modules/ui_shared/src/ui/localization.cpp @@ -0,0 +1,694 @@ +#include "ui/localization.h" + +#include +#include +#include +#include +#include + +#include "platform/ui/settings_store.h" +#include "ui/assets/fonts/font_utils.h" + +namespace ui::i18n +{ +namespace +{ + +constexpr const char* kSettingsNamespace = "settings"; +constexpr const char* kDisplayLanguageKey = "display_language"; + +struct TranslationEntry +{ + const char* english; + const char* chinese; +}; + +constexpr TranslationEntry kTranslations[] = { + {"Action", "操作"}, + {"Add", "添加"}, + {"Advanced", "高级"}, + {"Always", "常亮"}, + {"Altitude", "高度"}, + {"Altitude Reference", "高度参考"}, + {"App", "应用"}, + {"Area Cleared", "区域清除"}, + {"Auto", "自动"}, + {"Awaiting teammate", "等待队友"}, + {"Back", "返回"}, + {"Base Camp", "营地"}, + {"Beijing (UTC+8)", "北京 (UTC+8)"}, + {"BLE bridge ready | %s", "BLE 桥接已就绪 | %s"}, + {"BLE linked | %s", "BLE 已连接 | %s"}, + {"Bluetooth", "蓝牙"}, + {"Bluetooth Pairing", "蓝牙配对"}, + {"Bcast", "广播"}, + {"Bridge error", "桥接错误"}, + {"Broadcast", "广播"}, + {"Cancel", "取消"}, + {"Channel", "信道"}, + {"Chat", "聊天"}, + {"Chat unavailable", "聊天不可用"}, + {"Chinese", "中文"}, + {"Closes automatically after pairing", "配对完成后将自动关闭"}, + {"Close", "关闭"}, + {"Command", "指令"}, + {"Command: %s", "指令:%s"}, + {"Command: %s %s", "指令:%s %s"}, + {"Command: %s %s %s", "指令:%s %s %s"}, + {"Compare Verification Code", "比对验证码"}, + {"Compass", "指南针"}, + {"Connected", "已连接"}, + {"Connected, handshaking...", "已连接,正在握手..."}, + {"Contacts", "联系人"}, + {"Contacts (%s)", "联系人(%s)"}, + {"Contacts (LXMF)", "联系人(LXMF)"}, + {"Contacts (LXMF - %.3fMHz)", "联系人(LXMF - %.3fMHz)"}, + {"Contacts (MeshCore)", "联系人(MeshCore)"}, + {"Contacts (Meshtastic - %.3fMHz)", "联系人(Meshtastic - %.3fMHz)"}, + {"Contacts (RNode)", "联系人(RNode)"}, + {"Contacts (RNode - %.3fMHz)", "联系人(RNode - %.3fMHz)"}, + {"Conversation protocol mismatch", "会话协议不匹配"}, + {"Data Exchange", "数据交换"}, + {"Debug Logs", "调试日志"}, + {"Decoding: %d%%", "解码中:%d%%"}, + {"Decoding: 0%", "解码中:0%"}, + {"Delete", "删除"}, + {"Delete %s?", "删除 %s?"}, + {"Delete contact %s?", "删除联系人 %s?"}, + {"Delete failed", "删除失败"}, + {"Device: %s", "设备:%s"}, + {"Direct", "私聊"}, + {"Disabled", "已禁用"}, + {"Discover", "发现"}, + {"Display Language", "显示语言"}, + {"Duplicate name not allowed", "名称不能重复"}, + {"Edit", "编辑"}, + {"Edit nickname", "编辑昵称"}, + {"Emergency SOS", "紧急求救"}, + {"Energy Sweep", "频谱扫描"}, + {"English", "English"}, + {"English keyboard", "英文键盘"}, + {"Enter nickname", "输入昵称"}, + {"Enter number for %s", "输入 %s 的校验数字"}, + {"Enter the 6-digit number", "请输入 6 位数字"}, + {"Enter this 6-digit PIN on your phone", "请在手机上输入这个 6 位 PIN 码"}, + {"Error", "错误"}, + {"Error: %lu", "错误:%lu"}, + {"FSK Voice", "FSK 语音"}, + {"Failed", "失败"}, + {"Fix", "定位"}, + {"Fixed PIN", "固定 PIN"}, + {"Gauge Design (mAh)", "电池设计容量 (mAh)"}, + {"Gauge Full (mAh)", "电池满充容量 (mAh)"}, + {"Good Find", "发现目标"}, + {"GPS", "GPS"}, + {"GPS Status", "GPS 状态"}, + {"HDOP", "HDOP"}, + {"Heading starting", "航向启动中"}, + {"Hide", "隐藏"}, + {"Host bridge", "主机桥接"}, + {"Host connected, probing modem...", "主机已连接,正在探测调制解调器..."}, + {"IDLE", "空闲"}, + {"Ignored", "已忽略"}, + {"Ignore", "忽略"}, + {"Ignore unavailable", "当前无法忽略"}, + {"Ignore update failed", "忽略状态更新失败"}, + {"Image received", "图像已接收"}, + {"Info", "信息"}, + {"Initializing...", "初始化中..."}, + {"Invalid airtime factor", "空口系数无效"}, + {"Invalid design capacity (mAh)", "设计容量 (mAh) 无效"}, + {"Invalid frequency offset", "频偏无效"}, + {"Invalid frequency value", "频率值无效"}, + {"Invalid full capacity (mAh)", "满充容量 (mAh) 无效"}, + {"Invalid marker", "无效标记"}, + {"Invalid MeshCore bandwidth", "MeshCore 带宽无效"}, + {"Invalid MeshCore frequency", "MeshCore 频率无效"}, + {"Invalid number", "数字无效"}, + {"Invalid RX delay", "接收延迟无效"}, + {"Key marked trusted", "密钥已标记为可信"}, + {"Key must be 32 hex or 16 chars", "密钥必须为 32 位十六进制或 16 个字符"}, + {"Key trust failed", "密钥信任失败"}, + {"Key Verification", "密钥校验"}, + {"LINKED", "已组网"}, + {"LISTEN", "监听"}, + {"LIVE", "实时"}, + {"Loading...", "加载中..."}, + {"Location", "位置"}, + {"Location: %s", "位置:%s"}, + {"Location: %s %s", "位置:%s %s"}, + {"LoRa direct path | %s", "LoRa 直连 | %s"}, + {"Low battery - audio disabled", "电量低,已禁用音频"}, + {"Map", "地图"}, + {"Map Layer", "地图图层"}, + {"MESH", "网状"}, + {"Mesh Status", "网状状态"}, + {"Mesh unavailable", "Mesh 不可用"}, + {"MeshCore only", "仅支持 MeshCore"}, + {"Message DB cleared", "消息数据库已清空"}, + {"Messages", "消息"}, + {"MESSAGES", "消息"}, + {"Messages and broadcasts show here", "消息和广播会显示在这里"}, + {"Mini Compass", "迷你指南针"}, + {"MT send uses slot 0/1 only", "Meshtastic 发送仅支持槽位 0/1"}, + {"N/A", "不适用"}, + {"Nearby", "附近"}, + {"Need local GPS", "需要本机 GPS"}, + {"Network", "网络"}, + {"New", "新建"}, + {"new activity", "有新动态"}, + {"Next", "下一页"}, + {"No GPS fix", "没有 GPS 定位"}, + {"No image", "没有图像"}, + {"No messages", "暂无消息"}, + {"No preview", "无预览"}, + {"No recent traffic", "暂无最近活动"}, + {"No response", "无响应"}, + {"No route selected", "未选择路线"}, + {"No target", "没有目标"}, + {"No track files", "没有轨迹文件"}, + {"Node DB reset", "节点数据库已重置"}, + {"Node ignored", "节点已忽略"}, + {"Node ignored and hidden", "节点已忽略并隐藏"}, + {"Node unignored", "已取消忽略节点"}, + {"North locked", "北向已锁定"}, + {"Not set", "未设置"}, + {"NO", "否"}, + {"NO FIX", "无定位"}, + {"Number keyboard", "数字键盘"}, + {"OFF", "关"}, + {"OK", "确定"}, + {"ON", "开"}, + {"online", "在线"}, + {"Pairing", "配对"}, + {"PC Link", "电脑连接"}, + {"peer lock | turn %03.0f deg", "目标锁定 | 转向 %03.0f 度"}, + {"Pinyin keyboard", "拼音键盘"}, + {"Pinyin: %s", "拼音:%s"}, + {"Position", "位置"}, + {"position stream active | %u in view", "定位流活跃 | 可见 %u 颗"}, + {"Power", "电量"}, + {"Press Back", "按返回键"}, + {"Press RX to start", "按 RX 开始"}, + {"Primary", "主信道"}, + {"Protocol", "协议"}, + {"Protocol switch failed", "协议切换失败"}, + {"Protocol switched", "协议已切换"}, + {"quiet net", "网络安静"}, + {"Random PIN", "随机 PIN"}, + {"Ready", "就绪"}, + {"RECEIVING", "接收中"}, + {"Recent Activity", "最近活动"}, + {"Record", "记录"}, + {"Recording", "记录中"}, + {"Region", "区域"}, + {"Remain: %u", "剩余:%u"}, + {"Reply", "回复"}, + {"Reply disabled for this protocol", "此协议下不能回复"}, + {"Reset Mesh Profiles", "重置 Mesh 配置"}, + {"Reset Node DB", "重置节点数据库"}, + {"Resetting...", "重置中..."}, + {"Return", "返回"}, + {"Rally Point", "集结点"}, + {"RNode Bridge", "RNode Bridge"}, + {"RNode modem ready", "RNode 调制解调器已就绪"}, + {"RNode text chat runs on host", "RNode 文本聊天在主机侧运行"}, + {"Route", "路线"}, + {"Run", "执行"}, + {"RX", "RX"}, + {"RX: %lu TX: %lu", "接收:%lu 发送:%lu"}, + {"RX: 0 TX: 0", "接收:0 发送:0"}, + {"Save", "保存"}, + {"Saved: %s", "已保存:%s"}, + {"Scan failed", "扫描失败"}, + {"Scan Local", "本地扫描"}, + {"Scanning 5s...", "扫描 5 秒中..."}, + {"Screen Brightness", "屏幕亮度"}, + {"Screen Timeout", "熄屏超时"}, + {"SEARCH", "搜索中"}, + {"Secondary", "次信道"}, + {"Select a route", "请选择路线"}, + {"Select a track", "请选择轨迹"}, + {"Select marker", "选择标记"}, + {"Select Track", "选择轨迹"}, + {"Selected: %s", "已选:%s"}, + {"Send", "发送"}, + {"Send failed", "发送失败"}, + {"Send this code and compare:\n", "发送这个验证码并比对:\n"}, + {"Send ID Broadcast", "广播发送 ID"}, + {"Send ID Local", "本地发送 ID"}, + {"Sending...", "发送中..."}, + {"Sent", "已发送"}, + {"Settings", "设置"}, + {"Setting", "设置"}, + {"Share Position Marker", "分享位置标记"}, + {"Share this number:\n", "请分享这个数字:\n"}, + {"Short Name", "短名称"}, + {"SOLO", "单机"}, + {"Speaker Volume", "提示音音量"}, + {"SSTV RX READY", "SSTV 接收就绪"}, + {"SSTV start failed", "SSTV 启动失败"}, + {"standing by", "待命"}, + {"Status", "状态"}, + {"Stopped", "已停止"}, + {"Stop", "停止"}, + {"Stop recording first", "请先停止记录"}, + {"Submit", "提交"}, + {"Submit failed", "提交失败"}, + {"System", "系统"}, + {"SATELLITE STATUS", "卫星状态"}, + {"Talk", "说话"}, + {"TALK", "说话"}, + {"Team", "团队"}, + {"TEAM", "团队"}, + {"Team chat send failed", "团队消息发送失败"}, + {"Team chat unavailable", "团队聊天不可用"}, + {"Team chat unavailable in RNode mode", "RNode 模式下团队聊天不可用"}, + {"team member position will appear here", "队友位置会显示在这里"}, + {"Team keys not ready", "团队密钥未就绪"}, + {"Team location encode failed", "团队位置编码失败"}, + {"Text chat unavailable", "文本聊天不可用"}, + {"threads active", "活跃会话"}, + {"Time Zone", "时区"}, + {"Tracker", "轨迹"}, + {"traffic active", "流量活跃"}, + {"Trust Key", "信任密钥"}, + {"U", "用"}, + {"Unignore", "取消忽略"}, + {"Unread", "未读"}, + {"Unknown", "未知"}, + {"USB Mass Storage", "USB 大容量存储"}, + {"USB CDC KISS modem for Reticulum", "面向 Reticulum 的 USB CDC KISS 调制解调器"}, + {"USE", "用"}, + {"Used", "已用"}, + {"User Name", "用户名"}, + {"Verification Number", "校验数字"}, + {"Verification number sent", "校验数字已发送"}, + {"Vibration", "振动"}, + {"VOL %d", "音量 %d"}, + {"VOL 80", "音量 80"}, + {"waiting for better sky view | %u in view", "等待更好的天空视野 | 可见 %u 颗"}, + {"waiting for module GNSS compass", "等待 GNSS/指南针模块"}, + {"waiting for traffic", "等待流量"}, + {"Waiting for host...", "等待主机连接..."}, + {"Waiting for Reticulum host...", "等待 Reticulum 主机连接..."}, + {"WAIT IMU", "等待 IMU"}, + {"Walkie not available", "对讲功能不可用"}, + {"Walkie start failed", "对讲启动失败"}, + {"Walkie Talkie", "对讲机"}, + {"YES", "是"}, + {"6 digits", "6 位数字"}, + {"%u threads active", "%u 个会话活跃"}, + {"%ud", "%u天"}, + {"%uh", "%u小时"}, + {"%um", "%u分"}, + {"%umo", "%u月"}, + {"%uy", "%u年"}, + {"Confirm received code:\n", "确认收到的验证码:\n"}, + {"device heading from 9-axis module", "设备航向来自 9 轴模块"}, + {"heading unlocks after first fix", "首次定位后解锁航向"}, + {"If it matches, trust the key.", "如果一致,请信任此密钥。"}, + {"magnetometer heading active", "磁力计航向已启用"}, + {"magnetometer still warming up", "磁力计仍在预热"}, + {"needle shows north until target lock", "锁定目标前指针指向北方"}, + {"nearest peer | %.0f m away", "最近队友 | 距离 %.0f 米"}, + {"no recent traffic", "暂无最近活动"}, + {"now", "刚刚"}, + {"%s layer missing", "%s 图层缺失"}, + {"Active: %s", "已激活:%s"}, + {"Alt: %.0f m", "高度:%.0f 米"}, + {"Alt: -- m", "高度:-- 米"}, + {"Base: %s", "底图:%s"}, + {"Battery critical", "电池电量危险"}, + {"Broadcast (%d)", "广播(%d)"}, + {"COMPLETE", "已完成"}, + {"Calibrating...", "校准中..."}, + {"Confirm", "确认"}, + {"Contour: OFF", "等高线:关"}, + {"Decoder error", "解码错误"}, + {"Direct (%d)", "私聊(%d)"}, + {"E", "东"}, + {"ERROR", "错误"}, + {"Exiting USB...", "正在退出 USB..."}, + {"Hold", "原地待命"}, + {"ID: !%08lX", "ID: !%08lX"}, + {"ID: -", "ID: -"}, + {"Key refresh not implemented", "暂未实现密钥刷新"}, + {"Key verify: %s %s", "密钥校验:%s %s"}, + {"Key verify: confirm %s %s", "密钥校验:请确认 %s %s"}, + {"Key verify: enter number for %s", "密钥校验:请为 %s 输入数字"}, + {"Key verify: send %s %s", "密钥校验:请发送 %s %s"}, + {"Later", "稍后"}, + {"Level %d", "级别 %d"}, + {"Listening for SSTV signal...", "正在监听 SSTV 信号..."}, + {"Low battery: %d%%", "电量低:%d%%"}, + {"MODE: %s", "模式:%s"}, + {"MODE: Auto", "模式:自动"}, + {"Map - %.48s", "地图 - %.48s"}, + {"Map - %s Missing", "地图 - %s 缺失"}, + {"Map - No Map Data", "地图 - 没有地图数据"}, + {"Map - No SD Card", "地图 - 没有 SD 卡"}, + {"Map - no gps data", "地图 - 没有 GPS 数据"}, + {"Message", "消息"}, + {"Mic TBD", "麦克风功能待定义"}, + {"Mic init failed", "麦克风初始化失败"}, + {"Morse Input", "摩斯电码输入"}, + {"MoveTo", "移动到"}, + {"N", "北"}, + {"NODE INFO", "节点信息"}, + {"Name required", "必须输入名称"}, + {"Name too long", "名称过长"}, + {"No KML routes", "没有 KML 路线"}, + {"No SD Card", "没有 SD 卡"}, + {"No routes folder", "没有 routes 文件夹"}, + {"No tracks yet", "暂无轨迹"}, + {"Only leader can manage", "只有队长可以管理"}, + {"Only leader can pair", "只有队长可以配对"}, + {"Paired successfully", "配对成功"}, + {"Pairing failed", "配对失败"}, + {"Pairing init failed", "配对初始化失败"}, + {"Pairing not available", "当前无法配对"}, + {"Pairing not ready", "配对尚未就绪"}, + {"Press Back to exit USB mode", "按返回键退出 USB 模式"}, + {"Press SPACE to resume", "按 SPACE 唤醒屏幕"}, + {"RallyTo", "集结到"}, + {"Route configured", "已配置路线"}, + {"Route: %s", "路线:%s"}, + {"SD Card Not Found\nPlease insert SD card", "未找到 SD 卡\n请插入 SD 卡"}, + {"SSTV RECEIVER", "SSTV 接收器"}, + {"Save failed", "保存失败"}, + {"Scan +%u/%u", "扫描新增:+%u/%u"}, + {"Select level", "选择级别"}, + {"Selected level: %d", "已选级别:%d"}, + {"Shutting down to protect battery", "正在关机以保护电池"}, + {"Status: CALIB", "状态:校准"}, + {"Step 1/2: Tap short dots", "第 1/2 步:点击短点"}, + {"Step 2/2: Tap long dashes", "第 2/2 步:点击长线"}, + {"Switch to %s to chat", "切换到 %s 以聊天"}, + {"Symbol: ", "符号:"}, + {"Tap to input, idle 3s to send", "点击输入,空闲 3 秒后发送"}, + {"Team keys mismatch", "团队密钥不匹配"}, + {"Team: ", "团队:"}, + {"Text: ", "文本:"}, + {"Unread: %d", "未读:%d"}, + {"Unread: 0", "未读:0"}, + {"W", "西"}, + {"0° Horizon", "0° 地平线"}, + {"PSK must be 32 hex or 16 chars", "PSK 必须是 32 位十六进制或 16 个字符"}, + {"Restarting...", "正在重启..."}, + {"awaiting position", "等待位置"}, + {"compass wakes after GPS fix", "GPS 定位后指南针将启用"}, + {"searching satellites", "正在搜索卫星"}, + {"%s -", "%s -"}, + {"%s 0s", "%s 0秒"}, + {"%s %us", "%s %u秒"}, + {"%s %um", "%s %u分"}, + {"%s %uh", "%s %u小时"}, + {"%s %ud", "%s %u天"}, + {"AUTO", "自动"}, + {"Alt: %ld m", "高度:%ld 米"}, + {"Alt: -", "高度:-"}, + {"BEST", "最佳"}, + {"BW: %.0fk", "带宽:%.0fk"}, + {"BW: %.1fk", "带宽:%.1fk"}, + {"BW: -", "带宽:-"}, + {"CAD", "CAD"}, + {"CURSOR", "游标"}, + {"Ch: %.3f", "信道:%.3f"}, + {"Ch: -", "信道:-"}, + {"Hop: %u", "跳数:%u"}, + {"Hop: -", "跳数:-"}, + {"Hop: - / NH: %02X", "跳数:- / 下一跳:%02X"}, + {"ID: !%06lx", "ID: !%06lx"}, + {"ID: !%08lx", "ID: !%08lx"}, + {"Last heard:", "最后收到:"}, + {"Link", "链路"}, + {"Link / MeshCore", "链路 / MeshCore"}, + {"Link / Meshtastic", "链路 / Meshtastic"}, + {"MODE: RSSI", "模式:RSSI"}, + {"NOISE %.0f dBm", "噪声 %.0f dBm"}, + {"NOISE -104 dBm", "噪声 -104 dBm"}, + {"PKI: -", "PKI:-"}, + {"PKI: known", "PKI:已知"}, + {"PKI: verified", "PKI:已验证"}, + {"RSSI %.0f dBm", "RSSI %.0f dBm"}, + {"RSSI -92 dBm", "RSSI -92 dBm"}, + {"RSSI: %.0f dBm", "RSSI:%.0f dBm"}, + {"RSSI: -", "RSSI:-"}, + {"Role: %s", "角色:%s"}, + {"SCAN", "扫描"}, + {"SF: %u", "扩频因子:%u"}, + {"SF: -", "扩频因子:-"}, + {"SIM", "模拟"}, + {"SNR +%d", "SNR +%d"}, + {"SNR +12", "SNR +12"}, + {"SNR: %.1f dB", "SNR:%.1f dB"}, + {"SNR: -", "SNR:-"}, + {"SNR %.0f", "信噪比 %.0f"}, + {"SNR -", "信噪比 -"}, + {"SNR Good", "信噪比良好"}, + {"SNR Weak", "信噪比较弱"}, + {"STEP %.0fk | BW %.0fk", "步进 %.0fk | 带宽 %.0fk"}, + {"STEP %.1fk | BW %.1fk", "步进 %.1fk | 带宽 %.1fk"}, + {"STEP -- | BW --", "步进 -- | 带宽 --"}, + {"STOP", "停止"}, + {"Slot", "槽位"}, + {"Slot %u", "槽位 %u"}, + {"SUB-GHz SCAN", "Sub-GHz 扫描"}, + {"Actions", "操作"}, + {"Actions: %s", "操作:%s"}, + {"Access to team data revoked", "队伍数据访问已撤销"}, + {"Capability:", "能力:"}, + {"Channel Actions", "频道操作"}, + {"Confirm Kick", "确认移除"}, + {"Create Team", "创建队伍"}, + {"ELEV", "仰角"}, + {"Join Team", "加入队伍"}, + {"In View", "可见"}, + {"Keep devices within 5m", "请将设备保持在 5 米内"}, + {"KeyDist", "密钥分发"}, + {"KeyId: %u", "密钥 ID:%u"}, + {"KeyId: --", "密钥 ID:--"}, + {"Keys", "密钥"}, + {"Kick", "移除"}, + {"Kick Member", "移除成员"}, + {"Last paired: %s", "最近配对:%s"}, + {"Last seen %uh ago", "上次在线 %u 小时前"}, + {"Last seen %um ago", "上次在线 %u 分钟前"}, + {"Last seen --", "上次在线:--"}, + {"Last update %us ago", "上次更新 %u 秒前"}, + {"Last update --", "上次更新:--"}, + {"Last update 0s ago", "上次更新 0 秒前"}, + {"Leader", "队长"}, + {"Leader online", "队长在线"}, + {"Leave", "离开"}, + {"Leave team?", "离开队伍?"}, + {"Manage", "管理"}, + {"Member: %s", "成员:%s"}, + {"Members", "成员"}, + {"Members: %u", "成员数:%u"}, + {"Members: %u Online: %u", "成员:%u 在线:%u"}, + {"Modem Bridge", "调制解调器桥接"}, + {"No members yet", "暂无成员"}, + {"Not Used", "未使用"}, + {"Offline", "离线"}, + {"Online", "在线"}, + {"Online: %u", "在线:%u"}, + {"Pair Again", "再次配对"}, + {"Pair Member", "配对成员"}, + {"Pairing in progress", "正在配对"}, + {"Prev", "上一页"}, + {"Remove %s from team?", "将 %s 移出队伍?"}, + {"Request Keys", "请求密钥"}, + {"Retry", "重试"}, + {"Security Round: %u", "安全轮次:%u"}, + {"Security Round: --", "安全轮次:--"}, + {"Security: OK (Round %u)", "安全状态:正常(轮次 %u)"}, + {"Security: OK (Round --)", "安全状态:正常(轮次 --)"}, + {"Seen %ud", "%u 天前"}, + {"Seen %uh", "%u 小时前"}, + {"Seen %um", "%u 分钟前"}, + {"Send failed (keys not ready)", "发送失败(密钥未就绪)"}, + {"Status: %s", "状态:%s"}, + {"Target: %s", "目标:%s"}, + {"Team / %s", "队伍 / %s"}, + {"Team Actions", "队伍操作"}, + {"Team Health", "队伍状态"}, + {"Team Status", "队伍状态"}, + {"Team: %s", "队伍:%s"}, + {"This clears local keys.", "这将清除本地密钥。"}, + {"This will update the security round.\nThe member will no longer receive\nteam messages or waypoints.", "这将更新安全轮次。\n该成员将不再接收\n队伍消息或航点。"}, + {"Transfer", "移交"}, + {"Transfer Leader", "移交队长"}, + {"View Team", "查看队伍"}, + {"Waiting for new keys...", "正在等待新密钥..."}, + {"Waypoint", "航点"}, + {"You are no longer in this team", "你已不在此队伍中"}, + {"You are not in a team", "你当前未加入队伍"}, + {"- No shared map\n- No team awareness", "- 无共享地图\n- 无队伍态势"}, + {"1 member stale", "1 名成员状态过期"}, + {"encrypt failed", "加密失败"}, + {"member", "成员"}, + {"queue full", "队列已满"}, + {"unsupported protocol", "协议不支持"}, +}; + +bool s_loaded = false; +Language s_language = Language::English; + +void ensure_loaded() +{ + if (s_loaded) + { + return; + } + + s_language = language_from_raw( + ::platform::ui::settings_store::get_int(kSettingsNamespace, kDisplayLanguageKey, 0)); + s_loaded = true; +} + +const char* lookup_translation(const char* english) +{ + if (!english) + { + return ""; + } + + for (const auto& entry : kTranslations) + { + if (std::strcmp(entry.english, english) == 0) + { + return entry.chinese; + } + } + + return english; +} + +void apply_label_font(lv_obj_t* label, const char* text) +{ + if (!label) + { + return; + } + + const lv_font_t* current_font = lv_obj_get_style_text_font(label, LV_PART_MAIN); + ::ui::fonts::apply_localized_font(label, text, current_font); +} + +} // namespace + +Language language_from_raw(int raw_value) +{ + return raw_value == static_cast(Language::Chinese) ? Language::Chinese : Language::English; +} + +Language current_language() +{ + ensure_loaded(); + return s_language; +} + +bool set_language(Language language, bool persist) +{ + ensure_loaded(); + const bool changed = s_language != language; + s_language = language; + + if (persist) + { + ::platform::ui::settings_store::put_int( + kSettingsNamespace, kDisplayLanguageKey, static_cast(language)); + } + + return changed; +} + +bool supports_chinese() +{ +#if defined(GAT562_NO_CJK) && GAT562_NO_CJK + return false; +#else + return true; +#endif +} + +const char* tr(const char* english) +{ + ensure_loaded(); + if (s_language == Language::Chinese) + { + return lookup_translation(english); + } + return english ? english : ""; +} + +std::string vformat(const char* english_fmt, va_list args) +{ + const char* localized_fmt = tr(english_fmt); + if (localized_fmt == nullptr) + { + return {}; + } + + va_list size_args; + va_copy(size_args, args); + const int size = std::vsnprintf(nullptr, 0, localized_fmt, size_args); + va_end(size_args); + if (size <= 0) + { + return localized_fmt; + } + + std::vector buffer(static_cast(size) + 1U, '\0'); + va_list write_args; + va_copy(write_args, args); + std::vsnprintf(buffer.data(), buffer.size(), localized_fmt, write_args); + va_end(write_args); + return std::string(buffer.data()); +} + +std::string format(const char* english_fmt, ...) +{ + va_list args; + va_start(args, english_fmt); + std::string out = vformat(english_fmt, args); + va_end(args); + return out; +} + +void set_label_text(lv_obj_t* label, const char* english) +{ + const char* localized = tr(english); + if (label) + { + lv_label_set_text(label, localized); + apply_label_font(label, localized); + } +} + +void set_label_text_raw(lv_obj_t* label, const char* text) +{ + if (label) + { + lv_label_set_text(label, text ? text : ""); + apply_label_font(label, text ? text : ""); + } +} + +void set_label_text_fmt(lv_obj_t* label, const char* english_fmt, ...) +{ + if (!label) + { + return; + } + + va_list args; + va_start(args, english_fmt); + const std::string text = vformat(english_fmt, args); + va_end(args); + set_label_text_raw(label, text.c_str()); +} + +} // namespace ui::i18n diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp index d484a388..bfb0cad2 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp @@ -9,6 +9,7 @@ #include "chat/usecase/contact_service.h" #include "platform/ui/gps_runtime.h" #include "platform/ui/orientation_runtime.h" +#include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" #include "ui/screens/team/team_ui_store.h" @@ -17,7 +18,7 @@ namespace ui::menu::dashboard namespace { -void set_label_text_if_changed(lv_obj_t* label, const char* text) +void set_label_text_if_changed_raw(lv_obj_t* label, const char* text) { if (label == nullptr || text == nullptr) { @@ -26,7 +27,21 @@ void set_label_text_if_changed(lv_obj_t* label, const char* text) const char* current = lv_label_get_text(label); if (current == nullptr || std::strcmp(current, text) != 0) { - lv_label_set_text(label, text); + ::ui::i18n::set_label_text_raw(label, text); + } +} + +void set_label_text_if_changed(lv_obj_t* label, const char* english) +{ + const char* localized = ::ui::i18n::tr(english); + if (label == nullptr || localized == nullptr) + { + return; + } + const char* current = lv_label_get_text(label); + if (current == nullptr || std::strcmp(current, localized) != 0) + { + ::ui::i18n::set_label_text_raw(label, localized); } } @@ -40,7 +55,7 @@ std::string best_name_for(const chat::contacts::NodeInfo& info) { return info.short_name; } - return "Unknown"; + return ::ui::i18n::tr("Unknown"); } } // namespace @@ -48,7 +63,7 @@ std::string best_name_for(const chat::contacts::NodeInfo& info) void create_compass_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) { auto& compass = dashboard_state().compass; - compass.chrome = create_card_chrome(parent, "Mini Compass", card_w, card_h); + compass.chrome = create_card_chrome(parent, ::ui::i18n::tr("Mini Compass"), card_w, card_h); const lv_coord_t body_w = lv_obj_get_width(compass.chrome.body); const lv_coord_t body_h = lv_obj_get_height(compass.chrome.body); @@ -115,23 +130,23 @@ void create_compass_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_ style_body_label(compass.target_name, &lv_font_montserrat_16, color_text()); lv_obj_set_width(compass.target_name, stats_w); lv_obj_set_pos(compass.target_name, stats_x, 4); - lv_label_set_text(compass.target_name, "No target"); + ::ui::i18n::set_label_text(compass.target_name, "No target"); compass.distance_value = lv_label_create(compass.chrome.body); style_body_label(compass.distance_value, &lv_font_montserrat_16, color_info()); lv_obj_set_pos(compass.distance_value, stats_x, 34); - lv_label_set_text(compass.distance_value, "--"); + ::ui::i18n::set_label_text_raw(compass.distance_value, "--"); compass.bearing_value = lv_label_create(compass.chrome.body); style_body_label(compass.bearing_value, &lv_font_montserrat_12, color_text_dim()); lv_obj_set_width(compass.bearing_value, stats_w); lv_obj_set_pos(compass.bearing_value, stats_x, 62); - lv_label_set_text(compass.bearing_value, "awaiting position"); + ::ui::i18n::set_label_text(compass.bearing_value, "awaiting position"); compass.footer_label = lv_label_create(compass.chrome.footer); style_footer_label(compass.footer_label); lv_obj_align(compass.footer_label, LV_ALIGN_LEFT_MID, 0, 0); - lv_label_set_text(compass.footer_label, "compass wakes after GPS fix"); + ::ui::i18n::set_label_text(compass.footer_label, "compass wakes after GPS fix"); } void refresh_compass_widget() @@ -204,7 +219,7 @@ void refresh_compass_widget() { std::snprintf(heading_buf, sizeof(heading_buf), "--"); } - set_label_text_if_changed(compass.distance_value, heading.available ? heading_buf : "--"); + set_label_text_if_changed_raw(compass.distance_value, heading.available ? heading_buf : "--"); set_label_text_if_changed(compass.bearing_value, heading.available ? "magnetometer heading active" : "heading unlocks after first fix"); set_label_text_if_changed(compass.footer_label, @@ -225,14 +240,14 @@ void refresh_compass_widget() { char heading_buf[48]; std::snprintf(heading_buf, sizeof(heading_buf), "%s | %03.0f deg", compass_rose(heading.heading_deg), heading.heading_deg); - set_label_text_if_changed(compass.distance_value, heading_buf); + set_label_text_if_changed_raw(compass.distance_value, heading_buf); set_label_text_if_changed(compass.bearing_value, "team member position will appear here"); set_label_text_if_changed(compass.footer_label, "needle shows north until target lock"); needle_bearing = normalize_degrees(360.0f - heading.heading_deg); } else { - set_label_text_if_changed(compass.distance_value, "--"); + set_label_text_if_changed_raw(compass.distance_value, "--"); set_label_text_if_changed(compass.bearing_value, "magnetometer still warming up"); set_label_text_if_changed(compass.footer_label, "waiting for module GNSS compass"); } @@ -242,11 +257,11 @@ void refresh_compass_widget() } set_status_chip(compass.chrome, compass_rose(best_bearing), color_soft_green(), color_ok()); - set_label_text_if_changed(compass.target_name, best_name.c_str()); + set_label_text_if_changed_raw(compass.target_name, best_name.c_str()); char distance_buf[24]; format_distance(best_distance, distance_buf, sizeof(distance_buf)); - set_label_text_if_changed(compass.distance_value, distance_buf); + set_label_text_if_changed_raw(compass.distance_value, distance_buf); char bearing_buf[48]; if (heading.available) @@ -264,18 +279,20 @@ void refresh_compass_widget() std::snprintf(bearing_buf, sizeof(bearing_buf), "%s | %03.0f deg", compass_rose(best_bearing), best_bearing); needle_bearing = best_bearing; } - set_label_text_if_changed(compass.bearing_value, bearing_buf); + set_label_text_if_changed_raw(compass.bearing_value, bearing_buf); char footer[64]; if (heading.available) { - std::snprintf(footer, sizeof(footer), "peer lock | turn %03.0f deg", needle_bearing); + std::snprintf( + footer, sizeof(footer), "%s", ::ui::i18n::format("peer lock | turn %03.0f deg", needle_bearing).c_str()); } else { - std::snprintf(footer, sizeof(footer), "nearest peer | %.0f m away", best_distance); + std::snprintf( + footer, sizeof(footer), "%s", ::ui::i18n::format("nearest peer | %.0f m away", best_distance).c_str()); } - set_label_text_if_changed(compass.footer_label, footer); + set_label_text_if_changed_raw(compass.footer_label, footer); lv_obj_set_style_transform_rotation(compass.needle, static_cast(needle_bearing * 10.0f), 0); lv_obj_set_style_bg_opa(compass.north_marker, LV_OPA_COVER, 0); diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp index 64406e81..70a7282f 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp @@ -8,6 +8,7 @@ #include "platform/ui/gps_runtime.h" #include "sys/clock.h" +#include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" namespace ui::menu::dashboard @@ -50,7 +51,7 @@ void logDashboardGpsFlow(bool has_snapshot, std::size_t sat_count, const gps::Gn static_cast(gnss.hdop)); } -void set_label_text_if_changed(lv_obj_t* label, const char* text) +void set_label_text_if_changed_raw(lv_obj_t* label, const char* text) { if (label == nullptr || text == nullptr) { @@ -59,7 +60,21 @@ void set_label_text_if_changed(lv_obj_t* label, const char* text) const char* current = lv_label_get_text(label); if (current == nullptr || std::strcmp(current, text) != 0) { - lv_label_set_text(label, text); + ::ui::i18n::set_label_text_raw(label, text); + } +} + +void set_label_text_if_changed(lv_obj_t* label, const char* english) +{ + const char* localized = ::ui::i18n::tr(english); + if (label == nullptr || localized == nullptr) + { + return; + } + const char* current = lv_label_get_text(label); + if (current == nullptr || std::strcmp(current, localized) != 0) + { + ::ui::i18n::set_label_text_raw(label, localized); } } @@ -115,12 +130,12 @@ void create_stat_tiles(GpsWidgetUi& gps_ui, lv_obj_t* body, lv_coord_t x, lv_coo gps_ui.stat_values[i] = lv_label_create(gps_ui.stat_tiles[i]); style_body_label(gps_ui.stat_values[i], &lv_font_montserrat_16, color_text()); lv_obj_align(gps_ui.stat_values[i], LV_ALIGN_LEFT_MID, 0, -1); - lv_label_set_text(gps_ui.stat_values[i], "--"); + ::ui::i18n::set_label_text_raw(gps_ui.stat_values[i], "--"); gps_ui.stat_captions[i] = lv_label_create(gps_ui.stat_tiles[i]); style_body_label(gps_ui.stat_captions[i], &lv_font_montserrat_12, color_text_dim()); lv_obj_align(gps_ui.stat_captions[i], LV_ALIGN_RIGHT_MID, 0, -1); - lv_label_set_text(gps_ui.stat_captions[i], kCaptions[i]); + ::ui::i18n::set_label_text(gps_ui.stat_captions[i], kCaptions[i]); } } @@ -129,7 +144,7 @@ void create_stat_tiles(GpsWidgetUi& gps_ui, lv_obj_t* body, lv_coord_t x, lv_coo void create_gps_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) { auto& gps_ui = dashboard_state().gps; - gps_ui.chrome = create_card_chrome(parent, "GPS Status", card_w, card_h); + gps_ui.chrome = create_card_chrome(parent, ::ui::i18n::tr("GPS Status"), card_w, card_h); const lv_coord_t body_w = lv_obj_get_width(gps_ui.chrome.body); const lv_coord_t body_h = lv_obj_get_height(gps_ui.chrome.body); @@ -193,7 +208,7 @@ void create_gps_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) lv_obj_set_style_pad_bottom(gps_ui.sat_use_tags[i], 1, 0); lv_obj_set_style_text_color(gps_ui.sat_use_tags[i], lv_color_white(), 0); lv_obj_set_style_text_font(gps_ui.sat_use_tags[i], &lv_font_montserrat_12, 0); - lv_label_set_text(gps_ui.sat_use_tags[i], "U"); + ::ui::i18n::set_label_text(gps_ui.sat_use_tags[i], "U"); lv_obj_add_flag(gps_ui.sat_use_tags[i], LV_OBJ_FLAG_HIDDEN); } @@ -203,7 +218,7 @@ void create_gps_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) gps_ui.footer_label = lv_label_create(gps_ui.chrome.footer); style_footer_label(gps_ui.footer_label); lv_obj_align(gps_ui.footer_label, LV_ALIGN_LEFT_MID, 0, 0); - lv_label_set_text(gps_ui.footer_label, "searching satellites"); + ::ui::i18n::set_label_text(gps_ui.footer_label, "searching satellites"); } void refresh_gps_widget() @@ -233,11 +248,11 @@ void refresh_gps_widget() std::snprintf(used_buf, sizeof(used_buf), "%u/%u", static_cast(has_snapshot ? gnss.sats_in_use : 0), static_cast(has_snapshot ? sat_count : 0)); - set_label_text_if_changed(gps_ui.stat_values[1], used_buf); + set_label_text_if_changed_raw(gps_ui.stat_values[1], used_buf); char hdop_buf[16]; std::snprintf(hdop_buf, sizeof(hdop_buf), "%.1f", has_snapshot ? gnss.hdop : 0.0f); - set_label_text_if_changed(gps_ui.stat_values[2], hdop_buf); + set_label_text_if_changed_raw(gps_ui.stat_values[2], hdop_buf); std::array ranked{}; for (size_t i = 0; i < sat_count && i < ranked.size(); ++i) @@ -285,7 +300,7 @@ void refresh_gps_widget() char label_buf[8]; std::snprintf(label_buf, sizeof(label_buf), "%u", static_cast(sat.id)); - set_label_text_if_changed(gps_ui.sat_labels[i], label_buf); + set_label_text_if_changed_raw(gps_ui.sat_labels[i], label_buf); if (sat.used) { @@ -307,10 +322,12 @@ void refresh_gps_widget() char footer[72]; std::snprintf(footer, sizeof(footer), - "%s | %u in view", - fix ? "position stream active" : "waiting for better sky view", - static_cast(has_snapshot ? sat_count : 0)); - set_label_text_if_changed(gps_ui.footer_label, footer); + "%s", + ::ui::i18n::format( + fix ? "position stream active | %u in view" : "waiting for better sky view | %u in view", + static_cast(has_snapshot ? sat_count : 0)) + .c_str()); + set_label_text_if_changed_raw(gps_ui.footer_label, footer); } } // namespace ui::menu::dashboard diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp index 8121aeff..7ac951cb 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp @@ -7,6 +7,7 @@ #include "app/app_facade_access.h" #include "ble/ble_manager.h" #include "platform/ui/device_runtime.h" +#include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" #include "ui/screens/team/team_ui_store.h" #include "ui/ui_status.h" @@ -16,7 +17,7 @@ namespace ui::menu::dashboard namespace { -void set_label_text_if_changed(lv_obj_t* label, const char* text) +void set_label_text_if_changed_raw(lv_obj_t* label, const char* text) { if (label == nullptr || text == nullptr) { @@ -25,7 +26,21 @@ void set_label_text_if_changed(lv_obj_t* label, const char* text) const char* current = lv_label_get_text(label); if (current == nullptr || std::strcmp(current, text) != 0) { - lv_label_set_text(label, text); + ::ui::i18n::set_label_text_raw(label, text); + } +} + +void set_label_text_if_changed(lv_obj_t* label, const char* english) +{ + const char* localized = ::ui::i18n::tr(english); + if (label == nullptr || localized == nullptr) + { + return; + } + const char* current = lv_label_get_text(label); + if (current == nullptr || std::strcmp(current, localized) != 0) + { + ::ui::i18n::set_label_text_raw(label, localized); } } @@ -46,12 +61,12 @@ void create_stat_column(MeshWidgetUi& mesh, lv_obj_t* parent, lv_coord_t x, lv_c mesh.stat_values[i] = lv_label_create(mesh.stat_tiles[i]); style_body_label(mesh.stat_values[i], &lv_font_montserrat_16, color_text()); lv_obj_align(mesh.stat_values[i], LV_ALIGN_TOP_LEFT, 0, 0); - lv_label_set_text(mesh.stat_values[i], "--"); + ::ui::i18n::set_label_text_raw(mesh.stat_values[i], "--"); mesh.stat_captions[i] = lv_label_create(mesh.stat_tiles[i]); style_body_label(mesh.stat_captions[i], &lv_font_montserrat_12, color_text_dim()); lv_obj_align(mesh.stat_captions[i], LV_ALIGN_BOTTOM_LEFT, 0, 0); - lv_label_set_text(mesh.stat_captions[i], kCaptions[i]); + ::ui::i18n::set_label_text(mesh.stat_captions[i], kCaptions[i]); } } @@ -60,7 +75,7 @@ void create_stat_column(MeshWidgetUi& mesh, lv_obj_t* parent, lv_coord_t x, lv_c void create_mesh_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) { auto& mesh = dashboard_state().mesh; - mesh.chrome = create_card_chrome(parent, "Mesh Status", card_w, card_h); + mesh.chrome = create_card_chrome(parent, ::ui::i18n::tr("Mesh Status"), card_w, card_h); const lv_coord_t body_w = lv_obj_get_width(mesh.chrome.body); const lv_coord_t body_h = lv_obj_get_height(mesh.chrome.body); @@ -85,12 +100,12 @@ void create_mesh_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) mesh.hero_value = lv_label_create(mesh.orbit_core); style_body_label(mesh.hero_value, &lv_font_montserrat_16, lv_color_white()); lv_obj_align(mesh.hero_value, LV_ALIGN_CENTER, 0, -8); - lv_label_set_text(mesh.hero_value, "MESH"); + ::ui::i18n::set_label_text(mesh.hero_value, "MESH"); mesh.hero_caption = lv_label_create(mesh.orbit_core); style_body_label(mesh.hero_caption, &lv_font_montserrat_12, lv_color_hex(0xFFF7E8)); lv_obj_align(mesh.hero_caption, LV_ALIGN_CENTER, 0, 10); - lv_label_set_text(mesh.hero_caption, "online"); + ::ui::i18n::set_label_text(mesh.hero_caption, "online"); create_stat_column(mesh, mesh.chrome.body, hero_d + 16, body_h); @@ -116,7 +131,7 @@ void create_mesh_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) mesh.footer_label = lv_label_create(mesh.chrome.footer); style_footer_label(mesh.footer_label); lv_obj_align(mesh.footer_label, LV_ALIGN_RIGHT_MID, 0, 0); - lv_label_set_text(mesh.footer_label, "waiting for traffic"); + ::ui::i18n::set_label_text(mesh.footer_label, "waiting for traffic"); } void refresh_mesh_widget() @@ -156,14 +171,14 @@ void refresh_mesh_widget() char team_buf[16]; std::snprintf(team_buf, sizeof(team_buf), "%u", has_team ? static_cast(team_snap.members.size()) : 0U); - set_label_text_if_changed(mesh.stat_values[0], team_buf); + set_label_text_if_changed_raw(mesh.stat_values[0], team_buf); char unread_buf[16]; std::snprintf(unread_buf, sizeof(unread_buf), "%d", unread); - set_label_text_if_changed(mesh.stat_values[1], unread_buf); + set_label_text_if_changed_raw(mesh.stat_values[1], unread_buf); lv_obj_set_style_text_color(mesh.stat_values[1], unread > 0 ? color_warn() : color_text(), 0); - set_label_text_if_changed(mesh.stat_values[2], power_buf); + set_label_text_if_changed_raw(mesh.stat_values[2], power_buf); lv_obj_set_style_text_color(mesh.stat_values[2], battery.charging ? color_info() : color_text(), 0); char footer[64]; @@ -180,10 +195,13 @@ void refresh_mesh_widget() } std::snprintf(footer, sizeof(footer), - "%s | %s", - ble_linked ? "BLE linked" : (ble_active ? "BLE bridge ready" : "LoRa direct path"), - unread > 0 ? "new activity" : "quiet net"); - set_label_text_if_changed(mesh.footer_label, footer); + "%s", + ::ui::i18n::format( + ble_linked ? "BLE linked | %s" + : (ble_active ? "BLE bridge ready | %s" : "LoRa direct path | %s"), + unread > 0 ? ::ui::i18n::tr("new activity") : ::ui::i18n::tr("quiet net")) + .c_str()); + set_label_text_if_changed_raw(mesh.footer_label, footer); lv_obj_set_style_border_opa(mesh.orbit_outer, LV_OPA_60, 0); lv_obj_set_style_border_opa(mesh.orbit_mid, LV_OPA_COVER, 0); diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp index 935a7e68..5c2832f9 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp @@ -7,6 +7,7 @@ #include "app/app_facade_access.h" #include "chat/domain/chat_types.h" #include "chat/usecase/chat_service.h" +#include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" namespace ui::menu::dashboard @@ -16,7 +17,7 @@ namespace constexpr size_t kVisibleRows = 2; -void set_label_text_if_changed(lv_obj_t* label, const char* text) +void set_label_text_if_changed_raw(lv_obj_t* label, const char* text) { if (label == nullptr || text == nullptr) { @@ -25,7 +26,21 @@ void set_label_text_if_changed(lv_obj_t* label, const char* text) const char* current = lv_label_get_text(label); if (current == nullptr || std::strcmp(current, text) != 0) { - lv_label_set_text(label, text); + ::ui::i18n::set_label_text_raw(label, text); + } +} + +void set_label_text_if_changed(lv_obj_t* label, const char* english) +{ + const char* localized = ::ui::i18n::tr(english); + if (label == nullptr || localized == nullptr) + { + return; + } + const char* current = lv_label_get_text(label); + if (current == nullptr || std::strcmp(current, localized) != 0) + { + ::ui::i18n::set_label_text_raw(label, localized); } } @@ -43,7 +58,7 @@ std::string trim_text(const std::string& text, size_t max_len) void create_recent_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) { auto& recent = dashboard_state().recent; - recent.chrome = create_card_chrome(parent, "Recent Activity", card_w, card_h); + recent.chrome = create_card_chrome(parent, ::ui::i18n::tr("Recent Activity"), card_w, card_h); const lv_coord_t body_w = lv_obj_get_width(recent.chrome.body); const lv_coord_t body_h = lv_obj_get_height(recent.chrome.body); @@ -78,14 +93,14 @@ void create_recent_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h lv_obj_set_width(recent.row_names[i], static_cast(body_w - 80)); lv_obj_align(recent.row_names[i], LV_ALIGN_TOP_LEFT, 18, 1); lv_label_set_long_mode(recent.row_names[i], LV_LABEL_LONG_CLIP); - lv_label_set_text(recent.row_names[i], "--"); + ::ui::i18n::set_label_text_raw(recent.row_names[i], "--"); recent.row_previews[i] = lv_label_create(recent.rows[i]); style_body_label(recent.row_previews[i], &lv_font_montserrat_12, color_text_dim()); lv_obj_set_width(recent.row_previews[i], static_cast(body_w - 80)); lv_obj_align(recent.row_previews[i], LV_ALIGN_BOTTOM_LEFT, 18, -1); lv_label_set_long_mode(recent.row_previews[i], LV_LABEL_LONG_CLIP); - lv_label_set_text(recent.row_previews[i], ""); + ::ui::i18n::set_label_text_raw(recent.row_previews[i], ""); recent.row_badges[i] = lv_obj_create(recent.rows[i]); lv_obj_set_size(recent.row_badges[i], LV_SIZE_CONTENT, 16); @@ -124,7 +139,7 @@ void create_recent_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h recent.footer_label = lv_label_create(recent.chrome.footer); style_footer_label(recent.footer_label); lv_obj_align(recent.footer_label, LV_ALIGN_RIGHT_MID, 0, 0); - lv_label_set_text(recent.footer_label, "no recent traffic"); + ::ui::i18n::set_label_text(recent.footer_label, "no recent traffic"); } void refresh_recent_widget() @@ -149,9 +164,9 @@ void refresh_recent_widget() { const auto& conv = conversations[i]; const std::string name = trim_text(conv.name, 16); - const std::string preview = trim_text(conv.preview.empty() ? "No preview" : conv.preview, 28); - set_label_text_if_changed(recent.row_names[i], name.c_str()); - set_label_text_if_changed(recent.row_previews[i], preview.c_str()); + const std::string preview = trim_text(conv.preview.empty() ? std::string(::ui::i18n::tr("No preview")) : conv.preview, 28); + set_label_text_if_changed_raw(recent.row_names[i], name.c_str()); + set_label_text_if_changed_raw(recent.row_previews[i], preview.c_str()); lv_obj_set_style_bg_color(recent.row_dots[i], conv.unread > 0 ? color_warn() : color_info(), 0); if (conv.unread > 0) @@ -176,8 +191,9 @@ void refresh_recent_widget() } char footer[64]; - std::snprintf(footer, sizeof(footer), "%u threads active", static_cast(total)); - set_label_text_if_changed(recent.footer_label, footer); + std::snprintf( + footer, sizeof(footer), "%s", ::ui::i18n::format("%u threads active", static_cast(total)).c_str()); + set_label_text_if_changed_raw(recent.footer_label, footer); // Keep the footer accent subtle; continuously animating it forces a redraw // every dashboard tick even when no conversation state changed. diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp index 11397402..b243e0a8 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp @@ -5,6 +5,8 @@ #include #include +#include "ui/assets/fonts/font_utils.h" + namespace ui::menu::dashboard { namespace @@ -75,6 +77,7 @@ DashboardCardChrome create_card_chrome(lv_obj_t* parent, lv_label_set_text(chrome.title, title); lv_obj_set_style_text_color(chrome.title, lv_color_hex(0x2A1A05), 0); lv_obj_set_style_text_font(chrome.title, &lv_font_montserrat_14, 0); + ::ui::fonts::apply_localized_font(chrome.title, title, &lv_font_montserrat_14); lv_obj_align(chrome.title, LV_ALIGN_LEFT_MID, 0, 0); chrome.status_chip = lv_obj_create(chrome.header); @@ -94,6 +97,7 @@ DashboardCardChrome create_card_chrome(lv_obj_t* parent, lv_label_set_text(chrome.status_label, "--"); lv_obj_set_style_text_color(chrome.status_label, color_text(), 0); lv_obj_set_style_text_font(chrome.status_label, &lv_font_montserrat_12, 0); + ::ui::fonts::apply_localized_font(chrome.status_label, "--", &lv_font_montserrat_12); lv_obj_center(chrome.status_label); chrome.body = lv_obj_create(chrome.card); @@ -146,6 +150,7 @@ void set_status_chip(DashboardCardChrome& chrome, const char* text, lv_color_t b if (text != nullptr && (current == nullptr || std::strcmp(current, text) != 0)) { lv_label_set_text(chrome.status_label, text); + ::ui::fonts::apply_localized_font(chrome.status_label, text, &lv_font_montserrat_12); } } @@ -166,12 +171,14 @@ void style_body_label(lv_obj_t* label, const lv_font_t* font, lv_color_t color) { lv_obj_set_style_text_font(label, font, 0); lv_obj_set_style_text_color(label, color, 0); + ::ui::fonts::apply_localized_font(label, lv_label_get_text(label), font); } void style_footer_label(lv_obj_t* label) { lv_obj_set_style_text_font(label, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(label, color_text_dim(), 0); + ::ui::fonts::apply_localized_font(label, lv_label_get_text(label), &lv_font_montserrat_12); } void style_ring_object(lv_obj_t* obj, lv_coord_t diameter, lv_coord_t border, lv_color_t color) diff --git a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp index 1991e6e1..115354bd 100644 --- a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp +++ b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp @@ -7,6 +7,7 @@ #include #include "platform/ui/orientation_runtime.h" +#include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" #include "ui/menu/dashboard/dashboard_widgets.h" #include "ui/menu/menu_profile.h" @@ -133,6 +134,64 @@ void bringToFront() } } +void refresh_localized_text() +{ + auto& dashboard = dashboard_state(); + if (dashboard.panel == nullptr) + { + return; + } + + if (dashboard.mesh.chrome.title != nullptr) + { + ::ui::i18n::set_label_text(dashboard.mesh.chrome.title, "Mesh Status"); + } + if (dashboard.mesh.stat_captions[0] != nullptr) + { + ::ui::i18n::set_label_text(dashboard.mesh.stat_captions[0], "Team"); + } + if (dashboard.mesh.stat_captions[1] != nullptr) + { + ::ui::i18n::set_label_text(dashboard.mesh.stat_captions[1], "Unread"); + } + if (dashboard.mesh.stat_captions[2] != nullptr) + { + ::ui::i18n::set_label_text(dashboard.mesh.stat_captions[2], "Power"); + } + + if (dashboard.gps.chrome.title != nullptr) + { + ::ui::i18n::set_label_text(dashboard.gps.chrome.title, "GPS Status"); + } + if (dashboard.gps.stat_captions[0] != nullptr) + { + ::ui::i18n::set_label_text(dashboard.gps.stat_captions[0], "Fix"); + } + if (dashboard.gps.stat_captions[1] != nullptr) + { + ::ui::i18n::set_label_text(dashboard.gps.stat_captions[1], "Used"); + } + if (dashboard.gps.stat_captions[2] != nullptr) + { + ::ui::i18n::set_label_text(dashboard.gps.stat_captions[2], "HDOP"); + } + + if (dashboard.recent.chrome.title != nullptr) + { + ::ui::i18n::set_label_text(dashboard.recent.chrome.title, "Recent Activity"); + } + + if (dashboard.compass.chrome.title != nullptr) + { + ::ui::i18n::set_label_text(dashboard.compass.chrome.title, "Mini Compass"); + } + + refresh_compass_widget(); + refresh_recent_widget(); + refresh_gps_widget(); + refresh_mesh_widget(); +} + void setActive(bool active) { auto& dashboard = dashboard_state(); diff --git a/modules/ui_shared/src/ui/menu/menu_layout.cpp b/modules/ui_shared/src/ui/menu/menu_layout.cpp index d25742d3..c423c7cb 100644 --- a/modules/ui_shared/src/ui/menu/menu_layout.cpp +++ b/modules/ui_shared/src/ui/menu/menu_layout.cpp @@ -10,6 +10,8 @@ #endif #include "ui/app_runtime.h" +#include "ui/assets/fonts/font_utils.h" +#include "ui/localization.h" #include "ui/menu/menu_dashboard.h" #include "ui/menu/menu_profile.h" #include "ui/menu/menu_runtime.h" @@ -34,9 +36,11 @@ constexpr size_t kMaxMenuApps = 16; struct MenuAppUi { + AppScreen* app = nullptr; const char* name = nullptr; lv_obj_t* icon = nullptr; lv_obj_t* button = nullptr; + lv_obj_t* label = nullptr; }; lv_obj_t* s_menu_panel = nullptr; @@ -206,6 +210,8 @@ void menuNameLabelEventCallback(lv_event_t* e) if (value) { lv_label_set_text(lv_event_get_target_obj(e), value); + ::ui::fonts::apply_localized_font( + lv_event_get_target_obj(e), value, ui::menu_profile::current().desc_font); } #else (void)e; @@ -383,24 +389,28 @@ void createAppButton(lv_obj_t* parent, AppScreen* app, size_t idx) if (profile.show_card_label && name && name[0] != '\0') { lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, name); + ::ui::i18n::set_label_text_raw(label, name); lv_obj_set_width(label, width - (profile.variant == ui::menu_profile::LayoutVariant::LargeTouchGrid ? 16 : 4)); lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_font(label, profile.card_label_font, 0); lv_obj_set_style_text_color(label, lv_color_hex(0x6B4A1E), 0); lv_obj_set_style_text_color(label, lv_color_hex(0x6B4A1E), LV_STATE_FOCUSED); lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + ::ui::fonts::apply_localized_font(label, name, profile.card_label_font); + s_menu_apps[idx].label = label; } if (idx < kMaxMenuApps) { + s_menu_apps[idx].app = app; s_menu_apps[idx].name = name; s_menu_apps[idx].icon = icon; s_menu_apps[idx].button = btn; lv_obj_set_user_data(btn, &s_menu_apps[idx]); } - if (icon != nullptr && name != nullptr && strcmp(name, "Chat") == 0) + if (icon != nullptr && app != nullptr && app->stable_id() != nullptr && + std::strcmp(app->stable_id(), "chat") == 0) { lv_obj_t* badge = lv_obj_create(btn); lv_obj_set_size(badge, LV_SIZE_CONTENT, LV_SIZE_CONTENT); @@ -644,6 +654,38 @@ void bringContentToFront() ui::menu::dashboard::bringToFront(); } +void refresh_localized_text() +{ + const auto& profile = ui::menu_profile::current(); + for (auto& item : s_menu_apps) + { + if (item.app == nullptr) + { + continue; + } + + item.name = item.app->name(); + if (item.label != nullptr) + { + ::ui::i18n::set_label_text_raw(item.label, item.name); + ::ui::fonts::apply_localized_font(item.label, item.name, profile.card_label_font); + } + } + + if (s_desc_label != nullptr && menu_g != nullptr) + { + lv_obj_t* focused = lv_group_get_focused(menu_g); + const int index = findMenuButtonIndex(focused); + if (index >= 0 && static_cast(index) < kMaxMenuApps) + { + ::ui::i18n::set_label_text_raw(s_desc_label, s_menu_apps[index].name); + ::ui::fonts::apply_localized_font(s_desc_label, s_menu_apps[index].name, profile.desc_font); + } + } + + ui::menu::dashboard::refresh_localized_text(); +} + void setMenuVisible(bool visible) { if (s_menu_panel != nullptr) 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 e7431ce2..a9fafedb 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 @@ -6,6 +6,7 @@ #include "ui/screens/chat/chat_compose_styles.h" #include "ui/assets/fonts/font_utils.h" +#include "ui/localization.h" #include "ui/ui_common.h" #include "ui/widgets/ime/ime_widget.h" #include "ui/widgets/toast/toast_widget.h" @@ -95,8 +96,8 @@ static void set_btn_label_text(lv_obj_t* btn, const char* text) lv_obj_t* child = lv_obj_get_child(btn, 0); if (child && lv_obj_check_type(child, &lv_label_class)) { - lv_label_set_text(child, text); - ::ui::fonts::apply_ui_chrome_font(child); + ::ui::i18n::set_label_text(child, text); + ::ui::fonts::apply_localized_font(child, lv_label_get_text(child), ::ui::fonts::ui_chrome_font()); } } @@ -147,7 +148,7 @@ void ChatComposeScreen::showSendToast(bool ok, bool timeout, const char* message else if (ok) type = ::ui::widgets::Toast::Type::Success; else type = ::ui::widgets::Toast::Type::Error; - ::ui::widgets::Toast::show(toastHost(), message ? message : "", type); + ::ui::widgets::Toast::show(toastHost(), ::ui::i18n::tr(message ? message : ""), type); } lv_timer_t* ChatComposeScreen::add_timer(ChatComposeScreen::Impl* impl, @@ -343,7 +344,7 @@ void ChatComposeScreen::init_topbar() if (conv_.peer == 0) { - snprintf(title_buf, sizeof(title_buf), "Broadcast"); + snprintf(title_buf, sizeof(title_buf), "%s", ::ui::i18n::tr("Broadcast")); } else { @@ -448,7 +449,7 @@ void ChatComposeScreen::beginSend(chat::ChatService* service, setEnabled(false); // Optional lightweight toast hint while sending. - ::ui::widgets::Toast::show(toastHost(), "Sending...", ::ui::widgets::Toast::Type::Info); + ::ui::widgets::Toast::show(toastHost(), ::ui::i18n::tr("Sending..."), ::ui::widgets::Toast::Type::Info); if (impl_->send_timer) { @@ -530,9 +531,12 @@ void ChatComposeScreen::refresh_len() size_t len = text ? strlen(text) : 0; size_t remaining = (len < kMaxInputBytes) ? (kMaxInputBytes - len) : 0; - char buf[16]; - snprintf(buf, sizeof(buf), "Remain: %u", static_cast(remaining)); - lv_label_set_text(impl_->w.len_label, buf); + char buf[32]; + snprintf(buf, + sizeof(buf), + "%s", + ::ui::i18n::format("Remain: %u", static_cast(remaining)).c_str()); + ::ui::i18n::set_label_text_raw(impl_->w.len_label, buf); } // ---------- LVGL callbacks ---------- 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 d9b9f7c2..8df62a38 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 @@ -35,6 +35,7 @@ #include #include "ui/assets/fonts/font_utils.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" namespace chat::ui::compose::layout @@ -46,10 +47,10 @@ static lv_obj_t* create_btn_with_label(lv_obj_t* parent, int w, int h, const cha lv_obj_set_size(btn, w, h); lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, text); + ::ui::i18n::set_label_text(label, text); lv_obj_set_width(label, LV_PCT(100)); lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - ::ui::fonts::apply_ui_chrome_font(label); + ::ui::fonts::apply_localized_font(label, lv_label_get_text(label), ::ui::fonts::ui_chrome_font()); lv_obj_set_style_text_color(label, lv_color_hex(0x3A2A1A), 0); lv_label_set_long_mode(label, LV_LABEL_LONG_CLIP); lv_obj_center(label); @@ -116,8 +117,8 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) lv_obj_clear_flag(spacer, LV_OBJ_FLAG_SCROLLABLE); w.len_label = lv_label_create(w.action_bar); - lv_label_set_text(w.len_label, "Remain: 233"); - ::ui::fonts::apply_ui_chrome_font(w.len_label); + ::ui::i18n::set_label_text_fmt(w.len_label, "Remain: %u", 233U); + ::ui::fonts::apply_localized_font(w.len_label, lv_label_get_text(w.len_label), ::ui::fonts::ui_chrome_font()); lv_obj_set_style_text_color(w.len_label, lv_color_hex(0x6A5646), 0); } diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp index ee8b9bd7..663f95a5 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp @@ -9,6 +9,7 @@ #include "app/app_facade_access.h" #include "chat/usecase/contact_service.h" #include "ui/assets/fonts/font_utils.h" +#include "ui/localization.h" #include "ui/screens/chat/chat_conversation_input.h" #include "ui/screens/chat/chat_conversation_layout.h" #include "ui/screens/chat/chat_conversation_styles.h" @@ -118,30 +119,30 @@ static void format_message_time(char* out, size_t out_len, uint32_t ts) { if (diff < 60U) { - snprintf(out, out_len, "now"); + snprintf(out, out_len, "%s", ::ui::i18n::tr("now")); return; } if (diff < 3600U) { - snprintf(out, out_len, "%um", static_cast(diff / 60U)); + snprintf(out, out_len, "%s", ::ui::i18n::format("%um", static_cast(diff / 60U)).c_str()); return; } if (diff < kSecondsPerDay) { - snprintf(out, out_len, "%uh", static_cast(diff / 3600U)); + snprintf(out, out_len, "%s", ::ui::i18n::format("%uh", static_cast(diff / 3600U)).c_str()); return; } if (diff < kSecondsPerMonth) { - snprintf(out, out_len, "%ud", static_cast(diff / kSecondsPerDay)); + snprintf(out, out_len, "%s", ::ui::i18n::format("%ud", static_cast(diff / kSecondsPerDay)).c_str()); return; } if (diff < kSecondsPerYear) { - snprintf(out, out_len, "%umo", static_cast(diff / kSecondsPerMonth)); + snprintf(out, out_len, "%s", ::ui::i18n::format("%umo", static_cast(diff / kSecondsPerMonth)).c_str()); return; } - snprintf(out, out_len, "%uy", static_cast(diff / kSecondsPerYear)); + snprintf(out, out_len, "%s", ::ui::i18n::format("%uy", static_cast(diff / kSecondsPerYear)).c_str()); return; } @@ -245,13 +246,13 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat chat::ui::conversation::styles::apply_reply_btn(reply_btn_); // Reply label text + style - lv_label_set_text(w.reply_label, "Reply"); + ::ui::i18n::set_label_text(w.reply_label, "Reply"); chat::ui::conversation::styles::apply_reply_label(w.reply_label); - ::ui::fonts::apply_ui_chrome_font(w.reply_label); + ::ui::fonts::apply_localized_font(w.reply_label, lv_label_get_text(w.reply_label), ::ui::fonts::ui_chrome_font()); // ----- Top bar (existing widget, unchanged behavior) ----- ::ui::widgets::top_bar_init(top_bar_, container_); - const char* title = (conv_.peer == 0) ? "Broadcast" : "Direct"; + const char* title = (conv_.peer == 0) ? ::ui::i18n::tr("Broadcast") : ::ui::i18n::tr("Direct"); ::ui::widgets::top_bar_set_title(top_bar_, title); ::ui::widgets::top_bar_set_right_text(top_bar_, ""); ::ui::widgets::top_bar_set_back_callback(top_bar_, handle_back, this); @@ -366,14 +367,16 @@ bool ChatConversationScreen::updateMessageStatus(const chat::MessageId msg_id, if (status == MessageStatus::Failed) { - lv_label_set_text(item.status_label, "Failed"); - ::ui::fonts::apply_ui_chrome_font(item.status_label); + ::ui::i18n::set_label_text(item.status_label, "Failed"); + ::ui::fonts::apply_localized_font( + item.status_label, lv_label_get_text(item.status_label), ::ui::fonts::ui_chrome_font()); lv_obj_clear_flag(item.status_label, LV_OBJ_FLAG_HIDDEN); } else { lv_label_set_text(item.status_label, ""); - ::ui::fonts::apply_ui_chrome_font(item.status_label); + ::ui::fonts::apply_localized_font( + item.status_label, lv_label_get_text(item.status_label), ::ui::fonts::ui_chrome_font()); lv_obj_add_flag(item.status_label, LV_OBJ_FLAG_HIDDEN); } return true; @@ -503,7 +506,7 @@ void ChatConversationScreen::createMessageItem(const chat::ChatMessage& msg) } else if (msg.from == 0) { - sender = inferred_sender.empty() ? "Unknown" : inferred_sender; + sender = inferred_sender.empty() ? ::ui::i18n::tr("Unknown") : inferred_sender; } else { @@ -517,13 +520,15 @@ void ChatConversationScreen::createMessageItem(const chat::ChatMessage& msg) } } std::string line = sender + " " + time_buf; - lv_label_set_text(item.time_label, line.c_str()); - ::ui::fonts::apply_ui_chrome_font(item.time_label); + ::ui::i18n::set_label_text_raw(item.time_label, line.c_str()); + ::ui::fonts::apply_localized_font( + item.time_label, lv_label_get_text(item.time_label), ::ui::fonts::ui_chrome_font()); } else { - lv_label_set_text(item.time_label, time_buf); - ::ui::fonts::apply_ui_chrome_font(item.time_label); + ::ui::i18n::set_label_text_raw(item.time_label, time_buf); + ::ui::fonts::apply_localized_font( + item.time_label, lv_label_get_text(item.time_label), ::ui::fonts::ui_chrome_font()); } if (team::proto::team_location_marker_icon_is_valid(msg.team_location_icon)) @@ -560,14 +565,16 @@ void ChatConversationScreen::createMessageItem(const chat::ChatMessage& msg) chat::ui::conversation::styles::apply_bubble_status(item.status_label); if (msg.status == MessageStatus::Failed) { - lv_label_set_text(item.status_label, "Failed"); - ::ui::fonts::apply_ui_chrome_font(item.status_label); + ::ui::i18n::set_label_text(item.status_label, "Failed"); + ::ui::fonts::apply_localized_font( + item.status_label, lv_label_get_text(item.status_label), ::ui::fonts::ui_chrome_font()); lv_obj_clear_flag(item.status_label, LV_OBJ_FLAG_HIDDEN); } else { lv_label_set_text(item.status_label, ""); - ::ui::fonts::apply_ui_chrome_font(item.status_label); + ::ui::fonts::apply_localized_font( + item.status_label, lv_label_get_text(item.status_label), ::ui::fonts::ui_chrome_font()); lv_obj_add_flag(item.status_label, LV_OBJ_FLAG_HIDDEN); } 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 1b89d0eb..9283b3da 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 @@ -8,6 +8,7 @@ #include "ui/assets/fonts/font_utils.h" #include "ui/components/info_card.h" +#include "ui/localization.h" #include "ui/screens/chat/chat_message_list_input.h" #include "ui/screens/chat/chat_message_list_layout.h" #include "ui/screens/chat/chat_message_list_styles.h" @@ -270,7 +271,7 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) // ---------- Top bar (existing widget, unchanged) ---------- ::ui::widgets::top_bar_init(top_bar_, container_); - ::ui::widgets::top_bar_set_title(top_bar_, "MESSAGES"); + ::ui::widgets::top_bar_set_title(top_bar_, ::ui::i18n::tr("MESSAGES")); ::ui::widgets::top_bar_set_right_text(top_bar_, "--:-- --%"); ::ui::widgets::top_bar_set_back_callback(top_bar_, handle_back, this); if (top_bar_.container) @@ -653,8 +654,8 @@ void ChatMessageListScreen::rebuildList() { lv_obj_t* placeholder = chat::ui::layout::create_placeholder(list_panel_); chat::ui::message_list::styles::apply_label_placeholder(placeholder); - lv_label_set_text(placeholder, "No messages"); - ::ui::fonts::apply_ui_chrome_font(placeholder); + ::ui::i18n::set_label_text(placeholder, "No messages"); + ::ui::fonts::apply_localized_font(placeholder, lv_label_get_text(placeholder), ::ui::fonts::ui_chrome_font()); } if (show_second_column_back()) @@ -680,8 +681,8 @@ void ChatMessageListScreen::rebuildList() lv_obj_clear_state(list_back_btn_, (lv_state_t)(LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY)); lv_obj_t* back_label = lv_label_create(list_back_btn_); lv_obj_add_flag(back_label, LV_OBJ_FLAG_EVENT_BUBBLE); - lv_label_set_text(back_label, "Back"); - ::ui::fonts::apply_ui_chrome_font(back_label); + ::ui::i18n::set_label_text(back_label, "Back"); + ::ui::fonts::apply_localized_font(back_label, lv_label_get_text(back_label), ::ui::fonts::ui_chrome_font()); chat::ui::message_list::styles::apply_label_name(back_label); lv_obj_center(back_label); lv_obj_add_event_cb(list_back_btn_, list_back_event_cb, LV_EVENT_CLICKED, this); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp b/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp index 8c409814..798b90b6 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp @@ -9,6 +9,7 @@ #include "ui/assets/fonts/font_utils.h" #include "ui/components/info_card.h" #include "ui/components/two_pane_layout.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/ui_common.h" @@ -94,27 +95,27 @@ void format_time_hhmm(char out[16], uint32_t ts) uint32_t diff = now_secs - ts; if (diff < 60U) { - std::snprintf(out, 16, "now"); + std::snprintf(out, 16, "%s", ::ui::i18n::tr("now")); } else if (diff < 3600U) { - std::snprintf(out, 16, "%um", static_cast(diff / 60U)); + std::snprintf(out, 16, "%s", ::ui::i18n::format("%um", static_cast(diff / 60U)).c_str()); } else if (diff < kSecondsPerDay) { - std::snprintf(out, 16, "%uh", static_cast(diff / 3600U)); + std::snprintf(out, 16, "%s", ::ui::i18n::format("%uh", static_cast(diff / 3600U)).c_str()); } else if (diff < kSecondsPerMonth) { - std::snprintf(out, 16, "%ud", static_cast(diff / kSecondsPerDay)); + std::snprintf(out, 16, "%s", ::ui::i18n::format("%ud", static_cast(diff / kSecondsPerDay)).c_str()); } else if (diff < kSecondsPerYear) { - std::snprintf(out, 16, "%umo", static_cast(diff / kSecondsPerMonth)); + std::snprintf(out, 16, "%s", ::ui::i18n::format("%umo", static_cast(diff / kSecondsPerMonth)).c_str()); } else { - std::snprintf(out, 16, "%uy", static_cast(diff / kSecondsPerYear)); + std::snprintf(out, 16, "%s", ::ui::i18n::format("%uy", static_cast(diff / kSecondsPerYear)).c_str()); } return; } @@ -169,7 +170,7 @@ std::string compact_list_name(const std::string& name) } std::string compact = name; - replace_all(compact, "Broadcast", "Bcast"); + replace_all(compact, ::ui::i18n::tr("Broadcast"), ::ui::i18n::tr("Bcast")); replace_all(compact, "Primary", "Pri"); replace_all(compact, "Secondary", "Sec"); return compact; @@ -190,7 +191,7 @@ void style_filter_label(lv_obj_t* label) lv_obj_set_width(label, LV_PCT(100)); lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - ::ui::fonts::apply_ui_chrome_font(label); + ::ui::fonts::apply_localized_font(label, lv_label_get_text(label), ::ui::fonts::ui_chrome_font()); lv_obj_set_style_text_color(label, lv_color_hex(0x3A2A1A), 0); lv_label_set_long_mode(label, LV_LABEL_LONG_CLIP); } @@ -224,7 +225,7 @@ lv_obj_t* create_filter_panel(lv_obj_t* parent, lv_obj_set_size(direct, LV_PCT(100), metrics.filter_button_height); ::ui::components::two_pane_layout::make_non_scrollable(direct); lv_obj_t* direct_label = lv_label_create(direct); - lv_label_set_text(direct_label, "Direct"); + ::ui::i18n::set_label_text(direct_label, "Direct"); style_filter_label(direct_label); lv_obj_center(direct_label); @@ -232,7 +233,7 @@ lv_obj_t* create_filter_panel(lv_obj_t* parent, lv_obj_set_size(broadcast, LV_PCT(100), metrics.filter_button_height); ::ui::components::two_pane_layout::make_non_scrollable(broadcast); lv_obj_t* broadcast_label = lv_label_create(broadcast); - lv_label_set_text(broadcast_label, broadcast_filter_text()); + ::ui::i18n::set_label_text(broadcast_label, broadcast_filter_text()); style_filter_label(broadcast_label); lv_obj_center(broadcast_label); @@ -240,7 +241,7 @@ lv_obj_t* create_filter_panel(lv_obj_t* parent, lv_obj_set_size(team, LV_PCT(100), metrics.filter_button_height); ::ui::components::two_pane_layout::make_non_scrollable(team); lv_obj_t* team_label = lv_label_create(team); - lv_label_set_text(team_label, "Team"); + ::ui::i18n::set_label_text(team_label, "Team"); style_filter_label(team_label); lv_obj_center(team_label); lv_obj_add_flag(team, LV_OBJ_FLAG_HIDDEN); @@ -342,7 +343,7 @@ void populate_message_item(const MessageItemWidgets& widgets, char time_buf[16]; format_time_hhmm(time_buf, conv.last_timestamp); lv_label_set_text(widgets.time_label, time_buf); - ::ui::fonts::apply_ui_chrome_font(widgets.time_label); + ::ui::fonts::apply_localized_font(widgets.time_label, time_buf, ::ui::fonts::ui_chrome_font()); if (use_info_card) { ::ui::components::info_card::apply_single_line_label(widgets.time_label); @@ -358,7 +359,8 @@ void populate_message_item(const MessageItemWidgets& widgets, { lv_label_set_text(widgets.unread_label, ""); } - ::ui::fonts::apply_ui_chrome_font(widgets.unread_label); + ::ui::fonts::apply_localized_font( + widgets.unread_label, lv_label_get_text(widgets.unread_label), ::ui::fonts::ui_chrome_font()); if (use_info_card) { ::ui::components::info_card::apply_single_line_label(widgets.unread_label); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index 33fc7476..7e90f6dc 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -16,6 +16,7 @@ #include "team/protocol/team_location_marker.h" #include "team/usecase/team_controller.h" #include "ui/assets/fonts/fonts.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/team/team_ui_store.h" #include "ui/ui_common.h" @@ -117,7 +118,7 @@ std::string base_conversation_name(const chat::ConversationId& conv) { if (conv.peer == 0) { - return "Broadcast"; + return ::ui::i18n::tr("Broadcast"); } std::string contact_name = app::messagingFacade().getContactService().getContactName(conv.peer); @@ -298,7 +299,7 @@ std::string team_title_from_snapshot(const team::ui::TeamUiSnapshot& snap) { return snap.team_name; } - return "Team"; + return ::ui::i18n::tr("Team"); } void handle_message_list_action(chat::ui::ChatMessageListScreen::ActionIntent intent, @@ -1132,17 +1133,17 @@ void UiController::updateTeamPositionPickerHint(uint8_t icon_id) } if (icon_id == 0) { - lv_label_set_text(team_position_picker_desc_, "Cancel"); + ::ui::i18n::set_label_text(team_position_picker_desc_, "Cancel"); return; } const TeamPositionIconOption* option = find_team_position_icon_option(icon_id); if (option) { - lv_label_set_text(team_position_picker_desc_, option->meaning); + ::ui::i18n::set_label_text(team_position_picker_desc_, option->meaning); } else { - lv_label_set_text(team_position_picker_desc_, "Select marker"); + ::ui::i18n::set_label_text(team_position_picker_desc_, "Select marker"); } } @@ -1251,7 +1252,7 @@ void UiController::openTeamPositionPicker() lv_obj_clear_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(title_bar); - lv_label_set_text(title, "Share Position Marker"); + ::ui::i18n::set_label_text(title, "Share Position Marker"); lv_obj_set_style_text_color(title, lv_color_hex(0x6B4A1E), 0); lv_obj_center(title); @@ -1297,7 +1298,7 @@ void UiController::openTeamPositionPicker() } team_position_picker_desc_ = lv_label_create(team_position_picker_panel_); - lv_label_set_text(team_position_picker_desc_, "Select marker"); + ::ui::i18n::set_label_text(team_position_picker_desc_, "Select marker"); lv_obj_set_width(team_position_picker_desc_, LV_PCT(100)); lv_obj_set_style_text_align(team_position_picker_desc_, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(team_position_picker_desc_, lv_color_hex(0x8A6A3A), 0); @@ -1316,7 +1317,7 @@ void UiController::openTeamPositionPicker() lv_obj_clear_flag(cancel_btn, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* cancel_label = lv_label_create(cancel_btn); - lv_label_set_text(cancel_label, "Cancel"); + ::ui::i18n::set_label_text(cancel_label, "Cancel"); lv_obj_set_style_text_color(cancel_label, lv_color_hex(0x6B4A1E), 0); lv_obj_center(cancel_label); @@ -1545,15 +1546,14 @@ void UiController::openKeyVerificationNumberModal(chat::NodeId node_id, uint64_t lv_obj_clear_flag(key_verify_panel_, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(key_verify_panel_); - lv_label_set_text(title, "Key Verification"); + ::ui::i18n::set_label_text(title, "Key Verification"); lv_obj_set_style_text_color(title, lv_color_hex(0x6B4A1E), 0); lv_obj_set_style_text_font(title, &lv_font_noto_cjk_16_2bpp, 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); - std::string desc = "Enter number for "; - desc += resolve_contact_name(node_id); + std::string desc = ::ui::i18n::format("Enter number for %s", resolve_contact_name(node_id).c_str()); key_verify_desc_ = lv_label_create(key_verify_panel_); - lv_label_set_text(key_verify_desc_, desc.c_str()); + ::ui::i18n::set_label_text_raw(key_verify_desc_, desc.c_str()); lv_obj_set_width(key_verify_desc_, LV_PCT(100)); lv_obj_set_style_text_align(key_verify_desc_, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(key_verify_desc_, lv_color_hex(0x8A6A3A), 0); @@ -1562,13 +1562,13 @@ void UiController::openKeyVerificationNumberModal(chat::NodeId node_id, uint64_t key_verify_textarea_ = lv_textarea_create(key_verify_panel_); lv_obj_set_width(key_verify_textarea_, LV_PCT(100)); lv_textarea_set_one_line(key_verify_textarea_, true); - lv_textarea_set_placeholder_text(key_verify_textarea_, "6 digits"); + lv_textarea_set_placeholder_text(key_verify_textarea_, ::ui::i18n::tr("6 digits")); lv_textarea_set_accepted_chars(key_verify_textarea_, "0123456789"); lv_textarea_set_max_length(key_verify_textarea_, 6); lv_obj_align(key_verify_textarea_, LV_ALIGN_TOP_MID, 0, 72); key_verify_error_label_ = lv_label_create(key_verify_panel_); - lv_label_set_text(key_verify_error_label_, ""); + ::ui::i18n::set_label_text_raw(key_verify_error_label_, ""); lv_obj_set_width(key_verify_error_label_, LV_PCT(100)); lv_obj_set_style_text_align(key_verify_error_label_, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(key_verify_error_label_, lv_color_hex(0xB94A2C), 0); @@ -1578,14 +1578,14 @@ void UiController::openKeyVerificationNumberModal(chat::NodeId node_id, uint64_t lv_obj_set_size(submit_btn, LV_PCT(48), ::ui::page_profile::resolve_control_button_height()); lv_obj_align(submit_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0); lv_obj_t* submit_label = lv_label_create(submit_btn); - lv_label_set_text(submit_label, "Submit"); + ::ui::i18n::set_label_text(submit_label, "Submit"); lv_obj_center(submit_label); lv_obj_t* cancel_btn = lv_btn_create(key_verify_panel_); lv_obj_set_size(cancel_btn, LV_PCT(48), ::ui::page_profile::resolve_control_button_height()); lv_obj_align(cancel_btn, LV_ALIGN_BOTTOM_RIGHT, 0, 0); lv_obj_t* cancel_label = lv_label_create(cancel_btn); - lv_label_set_text(cancel_label, "Cancel"); + ::ui::i18n::set_label_text(cancel_label, "Cancel"); lv_obj_center(cancel_label); lv_obj_add_event_cb(submit_btn, key_verify_submit_event_cb, LV_EVENT_CLICKED, this); @@ -1639,7 +1639,7 @@ void UiController::openKeyVerificationInfoModal(chat::NodeId node_id, uint32_t n lv_obj_center(key_verify_panel_); lv_obj_t* title = lv_label_create(key_verify_panel_); - lv_label_set_text(title, "Verification Number"); + ::ui::i18n::set_label_text(title, "Verification Number"); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); char num_buf[24] = {}; @@ -1649,9 +1649,9 @@ void UiController::openKeyVerificationInfoModal(chat::NodeId node_id, uint32_t n static_cast(number / 1000U), static_cast(number % 1000U)); key_verify_desc_ = lv_label_create(key_verify_panel_); - std::string desc = resolve_contact_name(node_id) + "\nShare this number:\n"; + std::string desc = resolve_contact_name(node_id) + "\n" + ::ui::i18n::tr("Share this number:\n"); desc += num_buf; - lv_label_set_text(key_verify_desc_, desc.c_str()); + ::ui::i18n::set_label_text_raw(key_verify_desc_, desc.c_str()); lv_obj_set_width(key_verify_desc_, LV_PCT(100)); lv_obj_set_style_text_align(key_verify_desc_, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(key_verify_desc_, LV_ALIGN_CENTER, 0, -12); @@ -1660,7 +1660,7 @@ void UiController::openKeyVerificationInfoModal(chat::NodeId node_id, uint32_t n lv_obj_set_size(close_btn, LV_PCT(100), ::ui::page_profile::resolve_control_button_height()); lv_obj_align(close_btn, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_t* close_label = lv_label_create(close_btn); - lv_label_set_text(close_label, "OK"); + ::ui::i18n::set_label_text(close_label, "OK"); lv_obj_center(close_label); lv_obj_add_event_cb(close_btn, key_verify_close_event_cb, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(close_btn, key_verify_close_event_cb, LV_EVENT_KEY, this); @@ -1700,16 +1700,17 @@ void UiController::openKeyVerificationFinalModal(chat::NodeId node_id, const cha lv_obj_center(key_verify_panel_); lv_obj_t* title = lv_label_create(key_verify_panel_); - lv_label_set_text(title, "Compare Verification Code"); + ::ui::i18n::set_label_text(title, "Compare Verification Code"); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); std::string desc = resolve_contact_name(node_id); desc += "\n"; - desc += is_sender ? "Send this code and compare:\n" : "Confirm received code:\n"; + desc += is_sender ? ::ui::i18n::tr("Send this code and compare:\n") : ::ui::i18n::tr("Confirm received code:\n"); desc += (code && code[0] != '\0') ? code : "--------"; - desc += "\n\nIf it matches, trust the key."; + desc += "\n\n"; + desc += ::ui::i18n::tr("If it matches, trust the key."); key_verify_desc_ = lv_label_create(key_verify_panel_); - lv_label_set_text(key_verify_desc_, desc.c_str()); + ::ui::i18n::set_label_text_raw(key_verify_desc_, desc.c_str()); lv_obj_set_width(key_verify_desc_, LV_PCT(100)); lv_obj_set_style_text_align(key_verify_desc_, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(key_verify_desc_, LV_ALIGN_CENTER, 0, -8); @@ -1718,14 +1719,14 @@ void UiController::openKeyVerificationFinalModal(chat::NodeId node_id, const cha lv_obj_set_size(trust_btn, LV_PCT(48), ::ui::page_profile::resolve_control_button_height()); lv_obj_align(trust_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0); lv_obj_t* trust_label = lv_label_create(trust_btn); - lv_label_set_text(trust_label, "Trust Key"); + ::ui::i18n::set_label_text(trust_label, "Trust Key"); lv_obj_center(trust_label); lv_obj_t* close_btn = lv_btn_create(key_verify_panel_); lv_obj_set_size(close_btn, LV_PCT(48), ::ui::page_profile::resolve_control_button_height()); lv_obj_align(close_btn, LV_ALIGN_BOTTOM_RIGHT, 0, 0); lv_obj_t* close_label = lv_label_create(close_btn); - lv_label_set_text(close_label, "Later"); + ::ui::i18n::set_label_text(close_label, "Later"); lv_obj_center(close_label); lv_obj_add_event_cb(trust_btn, key_verify_trust_event_cb, LV_EVENT_CLICKED, this); @@ -1751,7 +1752,7 @@ void UiController::submitKeyVerificationNumber() { if (key_verify_error_label_) { - lv_label_set_text(key_verify_error_label_, "Enter the 6-digit number"); + ::ui::i18n::set_label_text(key_verify_error_label_, "Enter the 6-digit number"); } return; } @@ -1762,7 +1763,7 @@ void UiController::submitKeyVerificationNumber() { if (key_verify_error_label_) { - lv_label_set_text(key_verify_error_label_, "Invalid number"); + ::ui::i18n::set_label_text(key_verify_error_label_, "Invalid number"); } return; } @@ -1772,7 +1773,7 @@ void UiController::submitKeyVerificationNumber() { if (key_verify_error_label_) { - lv_label_set_text(key_verify_error_label_, "Mesh unavailable"); + ::ui::i18n::set_label_text(key_verify_error_label_, "Mesh unavailable"); } return; } @@ -1783,7 +1784,7 @@ void UiController::submitKeyVerificationNumber() { if (key_verify_error_label_) { - lv_label_set_text(key_verify_error_label_, "Submit failed"); + ::ui::i18n::set_label_text(key_verify_error_label_, "Submit failed"); } return; } diff --git a/modules/ui_shared/src/ui/screens/chat_watch/chat_compose_components_watch.cpp b/modules/ui_shared/src/ui/screens/chat_watch/chat_compose_components_watch.cpp index f38b25b5..282b10ad 100644 --- a/modules/ui_shared/src/ui/screens/chat_watch/chat_compose_components_watch.cpp +++ b/modules/ui_shared/src/ui/screens/chat_watch/chat_compose_components_watch.cpp @@ -5,6 +5,7 @@ #include #include "input/morse_engine.h" +#include "ui/localization.h" #include "ui/ui_theme.h" #include "ui/widgets/system_notification.h" #include @@ -227,7 +228,7 @@ void ChatComposeScreen::showPreset() lv_obj_add_event_cb(back_btn, preset_event_cb, LV_EVENT_CLICKED, this); lv_obj_set_user_data(back_btn, (void*)(intptr_t)-1); lv_obj_t* back_label = lv_label_create(back_btn); - lv_label_set_text(back_label, "Back"); + ::ui::i18n::set_label_text(back_label, "Back"); lv_obj_align(back_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_style_text_color(back_label, ::ui::theme::text(), 0); lv_obj_set_style_text_font(back_label, &lv_font_montserrat_18, 0); @@ -253,12 +254,12 @@ void ChatComposeScreen::showMorse() lv_obj_set_flex_align(content_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); morse_title_label_ = lv_label_create(content_); - lv_label_set_text(morse_title_label_, "Morse Input"); + ::ui::i18n::set_label_text(morse_title_label_, "Morse Input"); lv_obj_set_style_text_color(morse_title_label_, ::ui::theme::text(), 0); lv_obj_set_style_text_font(morse_title_label_, &lv_font_montserrat_18, 0); morse_status_label_ = lv_label_create(content_); - lv_label_set_text(morse_status_label_, "Status: CALIB"); + ::ui::i18n::set_label_text(morse_status_label_, "Status: CALIB"); lv_obj_set_style_text_color(morse_status_label_, ::ui::theme::text(), 0); lv_obj_set_style_text_font(morse_status_label_, &lv_font_montserrat_14, 0); @@ -271,21 +272,21 @@ void ChatComposeScreen::showMorse() lv_obj_set_style_bg_color(morse_level_bar_, ::ui::theme::accent(), LV_PART_INDICATOR); morse_symbol_label_ = lv_label_create(content_); - lv_label_set_text(morse_symbol_label_, "Symbol: "); + ::ui::i18n::set_label_text(morse_symbol_label_, "Symbol: "); lv_obj_set_style_text_color(morse_symbol_label_, ::ui::theme::text(), 0); lv_obj_set_style_text_font(morse_symbol_label_, &lv_font_montserrat_16, 0); morse_text_label_ = lv_label_create(content_); lv_obj_set_width(morse_text_label_, LV_PCT(100)); lv_label_set_long_mode(morse_text_label_, LV_LABEL_LONG_WRAP); - lv_label_set_text(morse_text_label_, "Text: "); + ::ui::i18n::set_label_text(morse_text_label_, "Text: "); lv_obj_set_style_text_color(morse_text_label_, ::ui::theme::text(), 0); lv_obj_set_style_text_font(morse_text_label_, &lv_font_montserrat_16, 0); morse_hint_label_ = lv_label_create(content_); lv_obj_set_width(morse_hint_label_, LV_PCT(100)); lv_label_set_long_mode(morse_hint_label_, LV_LABEL_LONG_WRAP); - lv_label_set_text(morse_hint_label_, "Calibrating..."); + ::ui::i18n::set_label_text(morse_hint_label_, "Calibrating..."); lv_obj_set_style_text_color(morse_hint_label_, ::ui::theme::text_muted(), 0); lv_obj_set_style_text_font(morse_hint_label_, &lv_font_montserrat_12, 0); @@ -302,7 +303,7 @@ void ChatComposeScreen::showMorse() lv_obj_clear_flag(morse_back_btn_, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(morse_back_btn_, morse_back_event_cb, LV_EVENT_CLICKED, this); lv_obj_t* back_label = lv_label_create(morse_back_btn_); - lv_label_set_text(back_label, "Back"); + ::ui::i18n::set_label_text(back_label, "Back"); lv_obj_align(back_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_style_text_color(back_label, ::ui::theme::text(), 0); lv_obj_set_style_text_font(back_label, &lv_font_montserrat_18, 0); @@ -329,7 +330,7 @@ void ChatComposeScreen::showMorse() { delete morse_; morse_ = nullptr; - ::ui::SystemNotification::show("Mic init failed", 1200); + ::ui::SystemNotification::show(::ui::i18n::tr("Mic init failed"), 1200); showMain(); return; } @@ -399,7 +400,7 @@ void ChatComposeScreen::updateMorseUi() if (snap.calibrated) { - lv_label_set_text(morse_hint_label_, "Tap to input, idle 3s to send"); + ::ui::i18n::set_label_text(morse_hint_label_, "Tap to input, idle 3s to send"); } else { @@ -413,7 +414,7 @@ void ChatComposeScreen::updateMorseUi() } else { - lv_label_set_text(morse_hint_label_, "Step 2/2: Tap long dashes"); + ::ui::i18n::set_label_text(morse_hint_label_, "Step 2/2: Tap long dashes"); } } else @@ -425,7 +426,7 @@ void ChatComposeScreen::updateMorseUi() } else { - lv_label_set_text(morse_hint_label_, "Step 1/2: Tap short dots"); + ::ui::i18n::set_label_text(morse_hint_label_, "Step 1/2: Tap short dots"); } } } @@ -468,7 +469,7 @@ void ChatComposeScreen::main_event_cb(lv_event_t* e) lv_obj_t* target = static_cast(lv_event_get_target(e)); if (target == screen->mic_btn_) { - ::ui::SystemNotification::show("Mic TBD", 1200); + ::ui::SystemNotification::show(::ui::i18n::tr("Mic TBD"), 1200); return; } if (target == screen->morse_btn_) diff --git a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp index 3647dd51..a3d1d809 100644 --- a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp +++ b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp @@ -2,6 +2,7 @@ #include "ui/screens/chat_watch/chat_conversation_components_watch.h" +#include "ui/localization.h" #include "ui/ui_theme.h" namespace @@ -59,7 +60,7 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat lv_obj_set_style_radius(reply_btn_, 8, LV_PART_MAIN); lv_obj_add_event_cb(reply_btn_, action_event_cb, LV_EVENT_CLICKED, this); lv_obj_t* reply_label = lv_label_create(reply_btn_); - lv_label_set_text(reply_label, "Reply"); + ::ui::i18n::set_label_text(reply_label, "Reply"); lv_obj_center(reply_label); lv_obj_set_style_text_color(reply_label, ::ui::theme::white(), 0); @@ -72,7 +73,7 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat lv_obj_set_style_radius(back_btn_, 8, LV_PART_MAIN); lv_obj_add_event_cb(back_btn_, back_event_cb, LV_EVENT_CLICKED, this); lv_obj_t* back_label = lv_label_create(back_btn_); - lv_label_set_text(back_label, "Back"); + ::ui::i18n::set_label_text(back_label, "Back"); lv_obj_center(back_label); lv_obj_set_style_text_color(back_label, ::ui::theme::text(), 0); } diff --git a/modules/ui_shared/src/ui/screens/chat_watch/chat_message_list_components_watch.cpp b/modules/ui_shared/src/ui/screens/chat_watch/chat_message_list_components_watch.cpp index 57357f74..411592e0 100644 --- a/modules/ui_shared/src/ui/screens/chat_watch/chat_message_list_components_watch.cpp +++ b/modules/ui_shared/src/ui/screens/chat_watch/chat_message_list_components_watch.cpp @@ -5,9 +5,11 @@ #include "app/app_config.h" #include "app/app_facade_access.h" #include "chat/infra/mesh_protocol_utils.h" +#include "ui/localization.h" #include "ui/ui_common.h" #include "ui/ui_theme.h" #include +#include namespace { @@ -249,25 +251,25 @@ void ChatMessageListScreen::rebuildMenu() char buf[32]; if (direct_unread_ > 0) { - snprintf(buf, sizeof(buf), "Direct (%d)", direct_unread_); - lv_label_set_text(direct_label_, buf); + const std::string text = ::ui::i18n::format("Direct (%d)", direct_unread_); + lv_label_set_text(direct_label_, text.c_str()); } else { - lv_label_set_text(direct_label_, "Direct"); + ::ui::i18n::set_label_text(direct_label_, "Direct"); } if (broadcast_unread_ > 0) { - snprintf(buf, sizeof(buf), "Broadcast (%d)", broadcast_unread_); - lv_label_set_text(broadcast_label_, buf); + const std::string text = ::ui::i18n::format("Broadcast (%d)", broadcast_unread_); + lv_label_set_text(broadcast_label_, text.c_str()); } else { - lv_label_set_text(broadcast_label_, "Broadcast"); + ::ui::i18n::set_label_text(broadcast_label_, "Broadcast"); } - lv_label_set_text(back_label_, "Back"); + ::ui::i18n::set_label_text(back_label_, "Back"); if (!has_direct_) { @@ -365,7 +367,7 @@ void ChatMessageListScreen::rebuildList() lv_obj_clear_flag(new_item.btn, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(new_item.btn, menu_event_cb, LV_EVENT_CLICKED, this); new_item.label = lv_label_create(new_item.btn); - lv_label_set_text(new_item.label, "New"); + ::ui::i18n::set_label_text(new_item.label, "New"); lv_obj_align(new_item.label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_style_text_color(new_item.label, ::ui::theme::white(), 0); lv_obj_set_style_text_font(new_item.label, &lv_font_montserrat_18, 0); @@ -388,7 +390,7 @@ void ChatMessageListScreen::rebuildList() lv_obj_clear_flag(back_item.btn, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(back_item.btn, menu_event_cb, LV_EVENT_CLICKED, this); back_item.label = lv_label_create(back_item.btn); - lv_label_set_text(back_item.label, "Back"); + ::ui::i18n::set_label_text(back_item.label, "Back"); lv_obj_align(back_item.label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_style_text_color(back_item.label, ::ui::theme::text(), 0); lv_obj_set_style_text_font(back_item.label, &lv_font_montserrat_18, 0); diff --git a/modules/ui_shared/src/ui/screens/common/placeholder_page.cpp b/modules/ui_shared/src/ui/screens/common/placeholder_page.cpp index 13b5a8ac..b625a5eb 100644 --- a/modules/ui_shared/src/ui/screens/common/placeholder_page.cpp +++ b/modules/ui_shared/src/ui/screens/common/placeholder_page.cpp @@ -1,9 +1,12 @@ #include "ui/screens/common/placeholder_page.h" #include "ui/app_runtime.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/ui_theme.h" +#include + namespace { @@ -64,11 +67,12 @@ void show(State& state, lv_obj_t* parent) lv_obj_add_event_cb(back_btn, back_event_cb, LV_EVENT_CLICKED, &state); lv_obj_t* back_label = lv_label_create(back_btn); - lv_label_set_text(back_label, LV_SYMBOL_LEFT " Back"); + const std::string back_text = std::string(LV_SYMBOL_LEFT " ") + ::ui::i18n::tr("Back"); + ::ui::i18n::set_label_text_raw(back_label, back_text.c_str()); lv_obj_center(back_label); lv_obj_t* title = lv_label_create(state.root); - lv_label_set_text(title, state.title ? state.title : "App"); + ::ui::i18n::set_label_text(title, state.title ? state.title : "App"); lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(title, ui::theme::text(), 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 6); 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 ada280e0..ee48324d 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 @@ -16,6 +16,7 @@ #include "team/protocol/team_position.h" #include "team/usecase/team_controller.h" #include "ui/components/info_card.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/chat/chat_compose_components.h" #include "ui/screens/chat/chat_conversation_components.h" @@ -216,7 +217,7 @@ static lv_obj_t* create_bottom_bar_button(lv_obj_t* parent, lv_obj_set_style_bg_color(btn, lv_color_hex(bg_color), LV_PART_MAIN); lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, text); + ::ui::i18n::set_label_text(label, text); apply_primary_text(label); lv_obj_update_layout(label); lv_coord_t width = lv_obj_get_width(label) + (kBottomBtnPadH * 2); @@ -249,7 +250,7 @@ static std::string format_time_status(uint32_t last_seen) uint32_t now_secs = sys::epoch_seconds_now(); if (now_secs < last_seen) { - return "Offline"; + return ::ui::i18n::tr("Offline"); } uint32_t age_secs = now_secs - last_seen; @@ -257,49 +258,41 @@ static std::string format_time_status(uint32_t last_seen) // Online: 闂?2 minutes if (age_secs <= 120) { - return "Online"; + return ::ui::i18n::tr("Online"); } // Minutes: 3-59 minutes if (age_secs < 3600) { uint32_t minutes = age_secs / 60; - char buf[16]; - snprintf(buf, sizeof(buf), "Seen %um", static_cast(minutes)); - return std::string(buf); + return ::ui::i18n::format("Seen %um", static_cast(minutes)); } // Hours: 1-23 hours if (age_secs < 86400) { uint32_t hours = age_secs / 3600; - char buf[16]; - snprintf(buf, sizeof(buf), "Seen %uh", static_cast(hours)); - return std::string(buf); + return ::ui::i18n::format("Seen %uh", static_cast(hours)); } // Days: 1-6 days if (age_secs < 6 * 86400) { uint32_t days = age_secs / 86400; - char buf[16]; - snprintf(buf, sizeof(buf), "Seen %ud", static_cast(days)); - return std::string(buf); + return ::ui::i18n::format("Seen %ud", static_cast(days)); } // > 6 days: should be filtered out - return "Offline"; + return ::ui::i18n::tr("Offline"); } [[maybe_unused]] static std::string format_snr(float snr) { if (snr == 0.0f) { - return "SNR -"; + return ::ui::i18n::tr("SNR -"); } - char buf[16]; - snprintf(buf, sizeof(buf), "SNR %.0f", snr); - return std::string(buf); + return ::ui::i18n::format("SNR %.0f", snr); } static chat::MeshProtocol active_mesh_protocol() @@ -505,15 +498,15 @@ static std::string format_broadcast_target_label(const BroadcastTargetSpec& spec { if (spec.protocol == chat::MeshProtocol::Meshtastic) { - char buf[32]; - snprintf(buf, sizeof(buf), "[MT] Slot %u", static_cast(spec.channel_index)); - return std::string(buf); + return std::string("[MT] ") + + ::ui::i18n::format("Slot %u", static_cast(spec.channel_index)); } if (spec.protocol == chat::MeshProtocol::RNode) { - return "[RN] Modem Bridge"; + return std::string("[RN] ") + ::ui::i18n::tr("Modem Bridge"); } - return (spec.channel == chat::ChannelId::SECONDARY) ? "[MC] Secondary" : "[MC] Primary"; + return std::string("[MC] ") + + ::ui::i18n::tr((spec.channel == chat::ChannelId::SECONDARY) ? "Secondary" : "Primary"); } static std::string format_broadcast_target_status(const BroadcastTargetSpec& spec) @@ -522,23 +515,23 @@ static std::string format_broadcast_target_status(const BroadcastTargetSpec& spe { if (!spec.enabled) { - return "Disabled"; + return ::ui::i18n::tr("Disabled"); } if (spec.channel_index == 0) { - return "Primary"; + return ::ui::i18n::tr("Primary"); } if (spec.channel_index == 1) { - return "Secondary"; + return ::ui::i18n::tr("Secondary"); } - return spec.chat_supported ? "Ready" : "Slot"; + return spec.chat_supported ? ::ui::i18n::tr("Ready") : ::ui::i18n::tr("Slot"); } if (spec.protocol == chat::MeshProtocol::RNode) { - return "Host bridge"; + return ::ui::i18n::tr("Host bridge"); } - return "Ready"; + return ::ui::i18n::tr("Ready"); } static bool get_discovery_action_spec(int index, DiscoveryActionSpec* out) @@ -603,15 +596,18 @@ static std::string format_team_chat_entry(const team::ui::TeamChatLogEntry& entr ui_format_coords(lat, lon, coord_fmt, coord_buf, sizeof(coord_buf)); if (!loc.label.empty()) { - snprintf(buf, sizeof(buf), "Location: %s %s", loc.label.c_str(), coord_buf); + snprintf(buf, + sizeof(buf), + "%s", + ::ui::i18n::format("Location: %s %s", loc.label.c_str(), coord_buf).c_str()); } else { - snprintf(buf, sizeof(buf), "Location: %s", coord_buf); + snprintf(buf, sizeof(buf), "%s", ::ui::i18n::format("Location: %s", coord_buf).c_str()); } return std::string(buf); } - return "Location"; + return ::ui::i18n::tr("Location"); } if (entry.type == team::proto::TeamChatType::Command) { @@ -629,28 +625,35 @@ static std::string format_team_chat_entry(const team::ui::TeamChatLogEntry& entr { if (!cmd.note.empty()) { - snprintf(buf, sizeof(buf), "Command: %s %s %s", - name, coord_buf, cmd.note.c_str()); + snprintf(buf, + sizeof(buf), + "%s", + ::ui::i18n::format("Command: %s %s %s", name, coord_buf, cmd.note.c_str()).c_str()); } else { - snprintf(buf, sizeof(buf), "Command: %s %s", - name, coord_buf); + snprintf(buf, + sizeof(buf), + "%s", + ::ui::i18n::format("Command: %s %s", name, coord_buf).c_str()); } } else if (!cmd.note.empty()) { - snprintf(buf, sizeof(buf), "Command: %s %s", name, cmd.note.c_str()); + snprintf(buf, + sizeof(buf), + "%s", + ::ui::i18n::format("Command: %s %s", name, cmd.note.c_str()).c_str()); } else { - snprintf(buf, sizeof(buf), "Command: %s", name); + snprintf(buf, sizeof(buf), "%s", ::ui::i18n::format("Command: %s", name).c_str()); } return std::string(buf); } - return "Command"; + return ::ui::i18n::tr("Command"); } - return "Message"; + return ::ui::i18n::tr("Message"); } static void refresh_team_state_from_store() @@ -1049,8 +1052,8 @@ static void open_add_edit_modal(bool is_edit) lv_obj_t* win = lv_obj_get_child(g_contacts_state.add_edit_modal, 0); lv_obj_t* title = lv_label_create(win); - lv_label_set_text(title, is_edit ? "Edit nickname" : "Enter nickname"); apply_primary_text(title); + ::ui::i18n::set_label_text(title, is_edit ? "Edit nickname" : "Enter nickname"); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); g_contacts_state.add_edit_textarea = lv_textarea_create(win); @@ -1086,8 +1089,8 @@ static void open_add_edit_modal(bool is_edit) lv_obj_set_size(save_btn, ::ui::page_profile::resolve_control_button_min_width(), ::ui::page_profile::resolve_control_button_height()); contacts::ui::style::apply_btn_basic(save_btn); lv_obj_t* save_label = lv_label_create(save_btn); - lv_label_set_text(save_label, "Save"); apply_primary_text(save_label); + ::ui::i18n::set_label_text(save_label, "Save"); lv_obj_center(save_label); lv_obj_add_event_cb(save_btn, on_add_edit_save_clicked, LV_EVENT_CLICKED, nullptr); @@ -1095,8 +1098,8 @@ static void open_add_edit_modal(bool is_edit) lv_obj_set_size(cancel_btn, ::ui::page_profile::resolve_control_button_min_width(), ::ui::page_profile::resolve_control_button_height()); contacts::ui::style::apply_btn_basic(cancel_btn); lv_obj_t* cancel_label = lv_label_create(cancel_btn); - lv_label_set_text(cancel_label, "Cancel"); apply_primary_text(cancel_label); + ::ui::i18n::set_label_text(cancel_label, "Cancel"); lv_obj_center(cancel_label); lv_obj_add_event_cb(cancel_btn, on_add_edit_cancel_clicked, LV_EVENT_CLICKED, nullptr); @@ -1123,11 +1126,10 @@ static void open_delete_confirm_modal() g_contacts_state.del_confirm_modal = create_modal_root(280, 140); lv_obj_t* win = lv_obj_get_child(g_contacts_state.del_confirm_modal, 0); - char msg[64]; - snprintf(msg, sizeof(msg), "Delete contact %s?", node->display_name.c_str()); + const std::string msg = ::ui::i18n::format("Delete contact %s?", node->display_name.c_str()); lv_obj_t* label = lv_label_create(win); - lv_label_set_text(label, msg); apply_primary_text(label); + ::ui::i18n::set_label_text_raw(label, msg.c_str()); lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 10); lv_obj_t* btn_row = lv_obj_create(win); @@ -1145,8 +1147,8 @@ static void open_delete_confirm_modal() lv_obj_set_size(confirm_btn, ::ui::page_profile::resolve_control_button_min_width(), ::ui::page_profile::resolve_control_button_height()); contacts::ui::style::apply_btn_basic(confirm_btn); lv_obj_t* confirm_label = lv_label_create(confirm_btn); - lv_label_set_text(confirm_label, "Confirm"); apply_primary_text(confirm_label); + ::ui::i18n::set_label_text(confirm_label, "Confirm"); lv_obj_center(confirm_label); lv_obj_add_event_cb(confirm_btn, on_del_confirm_clicked, LV_EVENT_CLICKED, nullptr); @@ -1154,8 +1156,8 @@ static void open_delete_confirm_modal() lv_obj_set_size(cancel_btn, ::ui::page_profile::resolve_control_button_min_width(), ::ui::page_profile::resolve_control_button_height()); contacts::ui::style::apply_btn_basic(cancel_btn); lv_obj_t* cancel_label = lv_label_create(cancel_btn); - lv_label_set_text(cancel_label, "Cancel"); apply_primary_text(cancel_label); + ::ui::i18n::set_label_text(cancel_label, "Cancel"); lv_obj_center(cancel_label); lv_obj_add_event_cb(cancel_btn, on_del_cancel_clicked, LV_EVENT_CLICKED, nullptr); @@ -1344,10 +1346,10 @@ static void open_chat_compose() if (node_protocol_to_mesh(node->protocol, &node_protocol) && node_protocol != protocol) { - char buf[64]; - snprintf(buf, sizeof(buf), "Switch to %s to chat", - chat::infra::meshProtocolName(node_protocol)); - ::ui::SystemNotification::show(buf, 2200); + const std::string msg = + ::ui::i18n::format("Switch to %s to chat", + chat::infra::meshProtocolName(node_protocol)); + ::ui::SystemNotification::show(msg.c_str(), 2200); return; } if (g_contacts_state.contact_service) @@ -1931,13 +1933,13 @@ static void on_add_edit_save_clicked(lv_event_t* /*e*/) const char* nickname = lv_textarea_get_text(g_contacts_state.add_edit_textarea); if (!nickname || strlen(nickname) == 0) { - lv_label_set_text(g_contacts_state.add_edit_error_label, "Name required"); + ::ui::i18n::set_label_text(g_contacts_state.add_edit_error_label, "Name required"); lv_obj_clear_flag(g_contacts_state.add_edit_error_label, LV_OBJ_FLAG_HIDDEN); return; } if (strlen(nickname) > 12) { - lv_label_set_text(g_contacts_state.add_edit_error_label, "Name too long"); + ::ui::i18n::set_label_text(g_contacts_state.add_edit_error_label, "Name too long"); lv_obj_clear_flag(g_contacts_state.add_edit_error_label, LV_OBJ_FLAG_HIDDEN); return; } @@ -1955,7 +1957,7 @@ static void on_add_edit_save_clicked(lv_event_t* /*e*/) } if (c.display_name == nickname) { - lv_label_set_text(g_contacts_state.add_edit_error_label, "Duplicate name not allowed"); + ::ui::i18n::set_label_text(g_contacts_state.add_edit_error_label, "Duplicate name not allowed"); lv_obj_clear_flag(g_contacts_state.add_edit_error_label, LV_OBJ_FLAG_HIDDEN); return; } @@ -1973,7 +1975,7 @@ static void on_add_edit_save_clicked(lv_event_t* /*e*/) if (!ok) { - lv_label_set_text(g_contacts_state.add_edit_error_label, "Save failed"); + ::ui::i18n::set_label_text(g_contacts_state.add_edit_error_label, "Save failed"); lv_obj_clear_flag(g_contacts_state.add_edit_error_label, LV_OBJ_FLAG_HIDDEN); return; } @@ -2103,11 +2105,11 @@ static void on_discovery_scan_done(lv_timer_t* timer) const size_t total = g_contacts_state.nearby_list.size(); const size_t gained = (total > start_count) ? (total - start_count) : 0; - char msg[24] = {}; - snprintf(msg, sizeof(msg), "Scan +%u/%u", - static_cast(gained), - static_cast(total)); - ::ui::SystemNotification::show(msg, 2200); + const std::string msg = + ::ui::i18n::format("Scan +%u/%u", + static_cast(gained), + static_cast(total)); + ::ui::SystemNotification::show(msg.c_str(), 2200); if (timer) { @@ -2175,7 +2177,7 @@ static lv_obj_t* create_action_menu_button(lv_obj_t* parent, const char* text) lv_obj_set_style_bg_color(btn, lv_color_hex(kColorAmberDark), selector_for_state(LV_STATE_PRESSED)); lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, text); + ::ui::i18n::set_label_text(label, text); apply_primary_text(label); lv_obj_center(label); return btn; @@ -2293,14 +2295,14 @@ static void open_action_menu_modal() return; } - std::string title = "Actions"; + std::string title = ::ui::i18n::tr("Actions"); if (g_contacts_state.current_mode == ContactsMode::Team) { - title = "Team Actions"; + title = ::ui::i18n::tr("Team Actions"); } else if (g_contacts_state.current_mode == ContactsMode::Broadcast) { - title = "Channel Actions"; + title = ::ui::i18n::tr("Channel Actions"); } else if (const auto* node = get_selected_node()) { @@ -2311,7 +2313,7 @@ static void open_action_menu_modal() } if (!name.empty()) { - title = "Actions: " + name; + title = ::ui::i18n::format("Actions: %s", name.c_str()); } } @@ -2322,7 +2324,7 @@ static void open_action_menu_modal() lv_obj_set_style_pad_row(win, 4, LV_PART_MAIN); lv_obj_t* title_label = lv_label_create(win); - lv_label_set_text(title_label, title.c_str()); + ::ui::i18n::set_label_text_raw(title_label, title.c_str()); apply_primary_text(title_label); lv_obj_set_width(title_label, LV_PCT(100)); lv_label_set_long_mode(title_label, LV_LABEL_LONG_DOT); @@ -2526,7 +2528,7 @@ void refresh_ui() team_node.is_contact = false; team_node.protocol = chat::contacts::NodeProtocolType::Unknown; team_node.display_name = team::ui::g_team_state.team_name.empty() - ? "Team" + ? ::ui::i18n::tr("Team") : team::ui::g_team_state.team_name; team_list.push_back(team_node); current_list = &team_list; @@ -2537,7 +2539,7 @@ void refresh_ui() { chat::contacts::NodeInfo item{}; item.node_id = static_cast(i + 1); - item.display_name = kDiscoveryActionSpecs[i].label; + item.display_name = ::ui::i18n::tr(kDiscoveryActionSpecs[i].label); item.protocol = chat::contacts::NodeProtocolType::MeshCore; discover_list.push_back(item); } @@ -2635,7 +2637,7 @@ void refresh_ui() } else if (g_contacts_state.current_mode == ContactsMode::Ignored) { - status_text = "Ignored"; + status_text = ::ui::i18n::tr("Ignored"); const std::string seen = format_time_status(node.last_seen); if (!seen.empty()) { @@ -2651,7 +2653,7 @@ void refresh_ui() } else if (g_contacts_state.current_mode == ContactsMode::Team) { - status_text = "Team"; + status_text = ::ui::i18n::tr("Team"); } else if (g_contacts_state.current_mode == ContactsMode::Discover) { @@ -2662,7 +2664,7 @@ void refresh_ui() } else { - status_text = "Action"; + status_text = ::ui::i18n::tr("Action"); } } else @@ -2674,7 +2676,7 @@ void refresh_ui() } else { - status_text = "Channel"; + status_text = ::ui::i18n::tr("Channel"); } } @@ -2695,7 +2697,7 @@ void refresh_ui() if (append_back_item) { chat::contacts::NodeInfo back_node{}; - back_node.display_name = "Back"; + back_node.display_name = ::ui::i18n::tr("Back"); lv_obj_t* back_item = contacts::ui::layout::create_list_item( g_contacts_state.sub_container, back_node, diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp index a078869c..9f351ead 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp @@ -11,6 +11,7 @@ #include "chat/infra/meshtastic/mt_region.h" #include "ui/components/info_card.h" #include "ui/components/two_pane_layout.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include @@ -44,12 +45,12 @@ void format_contacts_title(char* out, size_t out_len) { float freq_mhz = chat::meshtastic::estimateFrequencyMhz(config.region, config.modem_preset); - snprintf(out, out_len, "Contacts (Meshtastic - %.3fMHz)", freq_mhz); + snprintf(out, out_len, "%s", ::ui::i18n::format("Contacts (Meshtastic - %.3fMHz)", freq_mhz).c_str()); return; } if (protocol == chat::MeshProtocol::MeshCore) { - snprintf(out, out_len, "Contacts (MeshCore)"); + snprintf(out, out_len, "%s", ::ui::i18n::tr("Contacts (MeshCore)")); return; } if (protocol == chat::MeshProtocol::RNode) @@ -57,11 +58,11 @@ void format_contacts_title(char* out, size_t out_len) const chat::MeshConfig& rnode = app_ctx.getConfig().rnode_config; if (rnode.override_frequency_mhz > 0.0f) { - snprintf(out, out_len, "Contacts (RNode - %.3fMHz)", rnode.override_frequency_mhz); + snprintf(out, out_len, "%s", ::ui::i18n::format("Contacts (RNode - %.3fMHz)", rnode.override_frequency_mhz).c_str()); } else { - snprintf(out, out_len, "Contacts (RNode)"); + snprintf(out, out_len, "%s", ::ui::i18n::tr("Contacts (RNode)")); } return; } @@ -70,15 +71,15 @@ void format_contacts_title(char* out, size_t out_len) const chat::MeshConfig& lxmf = app_ctx.getConfig().rnode_config; if (lxmf.override_frequency_mhz > 0.0f) { - snprintf(out, out_len, "Contacts (LXMF - %.3fMHz)", lxmf.override_frequency_mhz); + snprintf(out, out_len, "%s", ::ui::i18n::format("Contacts (LXMF - %.3fMHz)", lxmf.override_frequency_mhz).c_str()); } else { - snprintf(out, out_len, "Contacts (LXMF)"); + snprintf(out, out_len, "%s", ::ui::i18n::tr("Contacts (LXMF)")); } return; } - snprintf(out, out_len, "Contacts (%s)", chat::infra::meshProtocolName(protocol)); + snprintf(out, out_len, "%s", ::ui::i18n::format("Contacts (%s)", chat::infra::meshProtocolName(protocol)).c_str()); } } // namespace @@ -141,7 +142,7 @@ void create_filter_panel(lv_obj_t* parent) ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.contacts_btn); style::apply_btn_filter(g_contacts_state.contacts_btn); lv_obj_t* contacts_label = lv_label_create(g_contacts_state.contacts_btn); - lv_label_set_text(contacts_label, "Contacts"); + ::ui::i18n::set_label_text(contacts_label, "Contacts"); style::apply_label_primary(contacts_label); lv_obj_center(contacts_label); @@ -150,7 +151,7 @@ void create_filter_panel(lv_obj_t* parent) ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.nearby_btn); style::apply_btn_filter(g_contacts_state.nearby_btn); lv_obj_t* nearby_label = lv_label_create(g_contacts_state.nearby_btn); - lv_label_set_text(nearby_label, "Nearby"); + ::ui::i18n::set_label_text(nearby_label, "Nearby"); style::apply_label_primary(nearby_label); lv_obj_center(nearby_label); @@ -159,7 +160,7 @@ void create_filter_panel(lv_obj_t* parent) ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.ignored_btn); style::apply_btn_filter(g_contacts_state.ignored_btn); lv_obj_t* ignored_label = lv_label_create(g_contacts_state.ignored_btn); - lv_label_set_text(ignored_label, "Ignored"); + ::ui::i18n::set_label_text(ignored_label, "Ignored"); style::apply_label_primary(ignored_label); lv_obj_center(ignored_label); @@ -168,7 +169,7 @@ void create_filter_panel(lv_obj_t* parent) ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.broadcast_btn); style::apply_btn_filter(g_contacts_state.broadcast_btn); lv_obj_t* broadcast_label = lv_label_create(g_contacts_state.broadcast_btn); - lv_label_set_text(broadcast_label, "Broadcast"); + ::ui::i18n::set_label_text(broadcast_label, "Broadcast"); style::apply_label_primary(broadcast_label); lv_obj_center(broadcast_label); @@ -177,7 +178,7 @@ void create_filter_panel(lv_obj_t* parent) ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.team_btn); style::apply_btn_filter(g_contacts_state.team_btn); lv_obj_t* team_label = lv_label_create(g_contacts_state.team_btn); - lv_label_set_text(team_label, "Team"); + ::ui::i18n::set_label_text(team_label, "Team"); style::apply_label_primary(team_label); lv_obj_center(team_label); lv_obj_add_flag(g_contacts_state.team_btn, LV_OBJ_FLAG_HIDDEN); @@ -187,7 +188,7 @@ void create_filter_panel(lv_obj_t* parent) ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.discover_btn); style::apply_btn_filter(g_contacts_state.discover_btn); lv_obj_t* discover_label = lv_label_create(g_contacts_state.discover_btn); - lv_label_set_text(discover_label, "Discover"); + ::ui::i18n::set_label_text(discover_label, "Discover"); style::apply_label_primary(discover_label); lv_obj_center(discover_label); @@ -285,21 +286,21 @@ lv_obj_t* create_list_item(lv_obj_t* parent, if (::ui::components::info_card::use_tdeck_layout()) { const auto slots = ::ui::components::info_card::create_content(item); - lv_label_set_text(slots.header_main_label, display_name.c_str()); + ::ui::i18n::set_label_text_raw(slots.header_main_label, display_name.c_str()); style::apply_label_primary(slots.header_main_label); - lv_label_set_text(slots.body_main_label, status_text); + ::ui::i18n::set_label_text(slots.body_main_label, status_text); style::apply_label_muted(slots.body_main_label); } else { lv_obj_t* name_label = lv_label_create(item); - lv_label_set_text(name_label, display_name.c_str()); + ::ui::i18n::set_label_text_raw(name_label, display_name.c_str()); lv_obj_align(name_label, LV_ALIGN_LEFT_MID, 10, 0); style::apply_label_primary(name_label); lv_obj_t* status_label = lv_label_create(item); - lv_label_set_text(status_label, status_text); + ::ui::i18n::set_label_text(status_label, status_text); lv_obj_align(status_label, LV_ALIGN_RIGHT_MID, -10, 0); style::apply_label_muted(status_label); } diff --git a/modules/ui_shared/src/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp b/modules/ui_shared/src/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp index cb73133c..827d9179 100644 --- a/modules/ui_shared/src/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp @@ -10,6 +10,7 @@ #include "platform/ui/lora_runtime.h" #include "platform/ui/screen_runtime.h" #include "sys/clock.h" +#include "ui/localization.h" #include "ui/ui_common.h" #include #include @@ -17,6 +18,7 @@ #include #include #include +#include #include #if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 @@ -639,7 +641,7 @@ void set_scan_button_style() lv_obj_set_style_bg_color(s_ui.btn_scan, lv_color_hex(bg), 0); lv_obj_set_style_border_color(s_ui.btn_scan, lv_color_hex(border), 0); lv_obj_set_style_text_color(s_ui.btn_scan_label, lv_color_white(), 0); - lv_label_set_text(s_ui.btn_scan_label, s_state.scanning ? "STOP" : "SCAN"); + ::ui::i18n::set_label_text(s_ui.btn_scan_label, s_state.scanning ? "STOP" : "SCAN"); lv_obj_center(s_ui.btn_scan_label); } @@ -684,14 +686,14 @@ void refresh_top_status() lv_obj_set_style_bg_color(s_ui.cad_chip, lv_color_hex(blink ? kColorInfo : 0x245B95), 0); lv_obj_set_style_border_color(s_ui.cad_chip, lv_color_hex(0x1C4B7F), 0); lv_obj_set_style_text_color(s_ui.cad_chip_label, lv_color_white(), 0); - lv_label_set_text(s_ui.cad_chip_label, "CAD"); + ::ui::i18n::set_label_text(s_ui.cad_chip_label, "CAD"); } else { lv_obj_set_style_bg_color(s_ui.cad_chip, lv_color_hex(0xD3C8AE), 0); lv_obj_set_style_border_color(s_ui.cad_chip, lv_color_hex(kColorLine), 0); lv_obj_set_style_text_color(s_ui.cad_chip_label, lv_color_hex(kColorTextDim), 0); - lv_label_set_text(s_ui.cad_chip_label, "SIM"); + ::ui::i18n::set_label_text(s_ui.cad_chip_label, "SIM"); } } @@ -800,7 +802,8 @@ void refresh_right_panel_text() if (s_ui.rssi_label) { char buf[32]; - snprintf(buf, sizeof(buf), "RSSI %.0f dBm", cursor_rssi); + const std::string text = ::ui::i18n::format("RSSI %.0f dBm", cursor_rssi); + snprintf(buf, sizeof(buf), "%s", text.c_str()); lv_label_set_text(s_ui.rssi_label, buf); lv_obj_set_style_text_color( s_ui.rssi_label, @@ -811,7 +814,8 @@ void refresh_right_panel_text() if (s_ui.noise_label) { char buf[32]; - snprintf(buf, sizeof(buf), "NOISE %.0f dBm", s_state.noise_dbm); + const std::string text = ::ui::i18n::format("NOISE %.0f dBm", s_state.noise_dbm); + snprintf(buf, sizeof(buf), "%s", text.c_str()); lv_label_set_text(s_ui.noise_label, buf); } @@ -825,7 +829,8 @@ void refresh_right_panel_text() if (s_ui.best_snr) { char buf[24]; - snprintf(buf, sizeof(buf), "SNR +%d", cleanliness); + const std::string text = ::ui::i18n::format("SNR +%d", cleanliness); + snprintf(buf, sizeof(buf), "%s", text.c_str()); lv_label_set_text(s_ui.best_snr, buf); } @@ -876,15 +881,19 @@ void refresh_scale_labels() if (step_int && bw_int) { - snprintf(mid, sizeof(mid), "STEP %.0fk | BW %.0fk", - static_cast(step_khz), - static_cast(s_band.bw_khz)); + const std::string text = + ::ui::i18n::format("STEP %.0fk | BW %.0fk", + static_cast(step_khz), + static_cast(s_band.bw_khz)); + snprintf(mid, sizeof(mid), "%s", text.c_str()); } else { - snprintf(mid, sizeof(mid), "STEP %.1fk | BW %.1fk", - static_cast(step_khz), - static_cast(s_band.bw_khz)); + const std::string text = + ::ui::i18n::format("STEP %.1fk | BW %.1fk", + static_cast(step_khz), + static_cast(s_band.bw_khz)); + snprintf(mid, sizeof(mid), "%s", text.c_str()); } lv_label_set_text(s_ui.scale_left, left); @@ -1142,7 +1151,7 @@ void build_topbar(lv_obj_t* root) lv_obj_center(back_label); s_ui.title = lv_label_create(s_ui.topbar); - lv_label_set_text(s_ui.title, "SUB-GHz SCAN"); + ::ui::i18n::set_label_text(s_ui.title, "SUB-GHz SCAN"); lv_obj_set_style_text_font(s_ui.title, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(s_ui.title, lv_color_hex(kColorText), 0); lv_obj_set_pos(s_ui.title, 46, 0); @@ -1159,7 +1168,7 @@ void build_topbar(lv_obj_t* root) lv_obj_clear_flag(s_ui.mode_chip, LV_OBJ_FLAG_SCROLLABLE); s_ui.mode_chip_label = lv_label_create(s_ui.mode_chip); - lv_label_set_text(s_ui.mode_chip_label, "MODE: RSSI"); + ::ui::i18n::set_label_text(s_ui.mode_chip_label, "MODE: RSSI"); lv_obj_set_style_text_font(s_ui.mode_chip_label, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(s_ui.mode_chip_label, lv_color_hex(kColorText), 0); lv_obj_center(s_ui.mode_chip_label); @@ -1176,7 +1185,7 @@ void build_topbar(lv_obj_t* root) lv_obj_clear_flag(s_ui.cad_chip, LV_OBJ_FLAG_SCROLLABLE); s_ui.cad_chip_label = lv_label_create(s_ui.cad_chip); - lv_label_set_text(s_ui.cad_chip_label, "CAD"); + ::ui::i18n::set_label_text(s_ui.cad_chip_label, "CAD"); lv_obj_set_style_text_font(s_ui.cad_chip_label, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(s_ui.cad_chip_label, lv_color_white(), 0); lv_obj_center(s_ui.cad_chip_label); @@ -1261,7 +1270,7 @@ void build_left_panel(lv_obj_t* root) lv_obj_set_pos(s_ui.scale_left, 2, 6); s_ui.scale_mid = lv_label_create(scale_bar); - lv_label_set_text(s_ui.scale_mid, "STEP -- | BW --"); + ::ui::i18n::set_label_text(s_ui.scale_mid, "STEP -- | BW --"); lv_obj_set_style_text_font(s_ui.scale_mid, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(s_ui.scale_mid, lv_color_hex(kColorTextDim), 0); lv_obj_align(s_ui.scale_mid, LV_ALIGN_CENTER, 0, 5); @@ -1298,7 +1307,7 @@ void build_right_panel(lv_obj_t* root) lv_obj_clear_flag(sep1, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title_cursor = lv_label_create(s_ui.right_panel); - lv_label_set_text(title_cursor, "CURSOR"); + ::ui::i18n::set_label_text(title_cursor, "CURSOR"); lv_obj_set_style_text_font(title_cursor, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(title_cursor, lv_color_hex(kColorText), 0); lv_obj_set_pos(title_cursor, 8, 2); @@ -1316,19 +1325,19 @@ void build_right_panel(lv_obj_t* root) lv_obj_set_pos(s_ui.cursor_unit, 84, 22); s_ui.rssi_label = lv_label_create(s_ui.right_panel); - lv_label_set_text(s_ui.rssi_label, "RSSI -92 dBm"); + ::ui::i18n::set_label_text(s_ui.rssi_label, "RSSI -92 dBm"); lv_obj_set_style_text_font(s_ui.rssi_label, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(s_ui.rssi_label, lv_color_hex(kColorText), 0); lv_obj_set_pos(s_ui.rssi_label, 8, 43); s_ui.noise_label = lv_label_create(s_ui.right_panel); - lv_label_set_text(s_ui.noise_label, "NOISE -104 dBm"); + ::ui::i18n::set_label_text(s_ui.noise_label, "NOISE -104 dBm"); lv_obj_set_style_text_font(s_ui.noise_label, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(s_ui.noise_label, lv_color_hex(kColorTextDim), 0); lv_obj_set_pos(s_ui.noise_label, 8, 60); lv_obj_t* title_best = lv_label_create(s_ui.right_panel); - lv_label_set_text(title_best, "BEST"); + ::ui::i18n::set_label_text(title_best, "BEST"); lv_obj_set_style_text_font(title_best, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(title_best, lv_color_hex(kColorText), 0); lv_obj_set_pos(title_best, 8, 80); @@ -1340,7 +1349,7 @@ void build_right_panel(lv_obj_t* root) lv_obj_set_pos(s_ui.best_freq, 8, 97); s_ui.best_snr = lv_label_create(s_ui.right_panel); - lv_label_set_text(s_ui.best_snr, "SNR +12"); + ::ui::i18n::set_label_text(s_ui.best_snr, "SNR +12"); lv_obj_set_style_text_font(s_ui.best_snr, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(s_ui.best_snr, lv_color_hex(kColorTextDim), 0); lv_obj_set_pos(s_ui.best_snr, 8, 114); @@ -1378,7 +1387,7 @@ void build_right_panel(lv_obj_t* root) lv_obj_add_event_cb(s_ui.btn_scan, scan_btn_key_event_cb, LV_EVENT_KEY, nullptr); s_ui.btn_scan_label = lv_label_create(s_ui.btn_scan); - lv_label_set_text(s_ui.btn_scan_label, "STOP"); + ::ui::i18n::set_label_text(s_ui.btn_scan_label, "STOP"); lv_obj_set_style_text_font(s_ui.btn_scan_label, &lv_font_montserrat_16, 0); lv_obj_set_style_text_color(s_ui.btn_scan_label, lv_color_white(), 0); lv_obj_center(s_ui.btn_scan_label); @@ -1396,7 +1405,7 @@ void build_right_panel(lv_obj_t* root) lv_obj_add_event_cb(s_ui.btn_auto, auto_btn_key_event_cb, LV_EVENT_KEY, nullptr); s_ui.btn_auto_label = lv_label_create(s_ui.btn_auto); - lv_label_set_text(s_ui.btn_auto_label, "AUTO"); + ::ui::i18n::set_label_text(s_ui.btn_auto_label, "AUTO"); lv_obj_set_style_text_font(s_ui.btn_auto_label, &lv_font_montserrat_16, 0); lv_obj_set_style_text_color(s_ui.btn_auto_label, lv_color_hex(kColorInfo), 0); lv_obj_center(s_ui.btn_auto_label); diff --git a/modules/ui_shared/src/ui/screens/gnss/gnss_skyplot_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gnss/gnss_skyplot_page_runtime.cpp index 77f888e2..aed16f99 100644 --- a/modules/ui_shared/src/ui/screens/gnss/gnss_skyplot_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gnss/gnss_skyplot_page_runtime.cpp @@ -2,6 +2,7 @@ #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/ui_common.h" #include @@ -517,7 +518,7 @@ void ensure_sat_dot(int index) lv_obj_clear_flag(dot.use_tag, LV_OBJ_FLAG_SCROLLABLE); dot.use_label = lv_label_create(dot.use_tag); - lv_label_set_text(dot.use_label, "USE"); + ::ui::i18n::set_label_text(dot.use_label, "USE"); lv_obj_set_style_text_color(dot.use_label, lv_color_white(), 0); lv_obj_set_style_text_font(dot.use_label, s_layout.use_tag_font, 0); lv_obj_center(dot.use_label); @@ -639,7 +640,7 @@ void update_table_rows() lv_label_set_text(r.cells[2], buf); snprintf(buf, sizeof(buf), "%d", sat.snr); lv_label_set_text(r.cells[3], buf); - lv_label_set_text(r.cells[4], sat.used ? "YES" : "NO"); + ::ui::i18n::set_label_text(r.cells[4], sat.used ? "YES" : "NO"); lv_obj_set_style_text_color(r.cells[4], lv_color_hex(sat.used ? kColorOk : kColorWarn), 0); } else @@ -732,7 +733,7 @@ void set_status_overlay_visible(bool visible) if (s_ui.status_toggle_label) { - lv_label_set_text(s_ui.status_toggle_label, visible ? "Hide" : "Status"); + ::ui::i18n::set_label_text(s_ui.status_toggle_label, visible ? "Hide" : "Status"); lv_obj_center(s_ui.status_toggle_label); } if (s_ui.status_toggle_btn) @@ -991,13 +992,13 @@ lv_obj_t* ui_gnss_skyplot_create(lv_obj_t* parent) lv_obj_move_background(center_dot); s_ui.label_n = lv_label_create(s_ui.panel_sky); - lv_label_set_text(s_ui.label_n, "N"); + ::ui::i18n::set_label_text(s_ui.label_n, "N"); lv_obj_set_style_text_color(s_ui.label_n, lv_color_hex(kColorText), 0); lv_obj_set_style_text_font(s_ui.label_n, s_layout.compass_font, 0); place_label_center_x(s_ui.label_n, center_x, s_layout.label_n_top); s_ui.label_e = lv_label_create(s_ui.panel_sky); - lv_label_set_text(s_ui.label_e, "E"); + ::ui::i18n::set_label_text(s_ui.label_e, "E"); lv_obj_set_style_text_color(s_ui.label_e, lv_color_hex(kColorText), 0); lv_obj_set_style_text_font(s_ui.label_e, s_layout.compass_font, 0); lv_obj_set_pos(s_ui.label_e, @@ -1005,7 +1006,7 @@ lv_obj_t* ui_gnss_skyplot_create(lv_obj_t* parent) s_layout.sky_area_y + s_layout.sky_center - s_layout.label_e_y_adjust); s_ui.label_w = lv_label_create(s_ui.panel_sky); - lv_label_set_text(s_ui.label_w, "W"); + ::ui::i18n::set_label_text(s_ui.label_w, "W"); lv_obj_set_style_text_color(s_ui.label_w, lv_color_hex(kColorText), 0); lv_obj_set_style_text_font(s_ui.label_w, s_layout.compass_font, 0); lv_obj_set_pos(s_ui.label_w, @@ -1013,25 +1014,25 @@ lv_obj_t* ui_gnss_skyplot_create(lv_obj_t* parent) s_layout.sky_area_y + s_layout.sky_center - s_layout.label_w_y_adjust); s_ui.label_90 = lv_label_create(s_ui.panel_sky); - lv_label_set_text(s_ui.label_90, "90°"); + ::ui::i18n::set_label_text_raw(s_ui.label_90, u8"90°"); lv_obj_set_style_text_color(s_ui.label_90, lv_color_hex(kColorTextDim), 0); lv_obj_set_style_text_font(s_ui.label_90, s_layout.ring_label_font, 0); place_label_diagonal_1030(s_ui.label_90, center_x, center_y, s_layout.sky_radius); s_ui.label_60 = lv_label_create(s_ui.panel_sky); - lv_label_set_text(s_ui.label_60, "60°"); + ::ui::i18n::set_label_text_raw(s_ui.label_60, u8"60°"); lv_obj_set_style_text_color(s_ui.label_60, lv_color_hex(kColorTextDim), 0); lv_obj_set_style_text_font(s_ui.label_60, s_layout.ring_label_font, 0); place_label_diagonal_1030(s_ui.label_60, center_x, center_y, s_layout.sky_radius60); s_ui.label_30 = lv_label_create(s_ui.panel_sky); - lv_label_set_text(s_ui.label_30, "30°"); + ::ui::i18n::set_label_text_raw(s_ui.label_30, u8"30°"); lv_obj_set_style_text_color(s_ui.label_30, lv_color_hex(kColorTextDim), 0); lv_obj_set_style_text_font(s_ui.label_30, s_layout.ring_label_font, 0); place_label_diagonal_1030(s_ui.label_30, center_x, center_y, s_layout.sky_radius30); s_ui.label_horizon = lv_label_create(s_ui.panel_sky); - lv_label_set_text(s_ui.label_horizon, "0° Horizon"); + ::ui::i18n::set_label_text(s_ui.label_horizon, u8"0° Horizon"); lv_obj_set_style_text_color(s_ui.label_horizon, lv_color_hex(kColorTextDim), 0); lv_obj_set_style_text_font(s_ui.label_horizon, s_layout.horizon_font, 0); place_label_center(s_ui.label_horizon, center_x, s_layout.sky_area_y + s_layout.sky_center + s_layout.horizon_offset_y); @@ -1092,7 +1093,7 @@ lv_obj_t* ui_gnss_skyplot_create(lv_obj_t* parent) lv_obj_clear_flag(dot, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* label = lv_label_create(s_ui.panel_sky); - lv_label_set_text(label, snr_legend[i].text); + ::ui::i18n::set_label_text(label, snr_legend[i].text); lv_obj_set_style_text_color(label, lv_color_hex(kColorTextDim), 0); lv_obj_set_style_text_font(label, s_layout.legend_font, 0); lv_obj_set_pos(label, x + s_layout.legend_label_x_offset, y); @@ -1118,7 +1119,7 @@ lv_obj_t* ui_gnss_skyplot_create(lv_obj_t* parent) lv_obj_clear_flag(s_ui.status_header, LV_OBJ_FLAG_SCROLLABLE); s_ui.status_header_label = lv_label_create(s_ui.status_header); - lv_label_set_text(s_ui.status_header_label, "SATELLITE STATUS"); + ::ui::i18n::set_label_text(s_ui.status_header_label, "SATELLITE STATUS"); lv_obj_set_style_text_color(s_ui.status_header_label, lv_color_hex(0x2A1A05), 0); lv_obj_set_style_text_font(s_ui.status_header_label, s_layout.status_header_font, 0); lv_obj_center(s_ui.status_header_label); @@ -1138,7 +1139,7 @@ lv_obj_t* ui_gnss_skyplot_create(lv_obj_t* parent) for (int i = 0; i < 5; ++i) { s_ui.table_header_cells[i] = lv_label_create(s_ui.table_header); - lv_label_set_text(s_ui.table_header_cells[i], header_texts[i]); + ::ui::i18n::set_label_text(s_ui.table_header_cells[i], header_texts[i]); lv_label_set_long_mode(s_ui.table_header_cells[i], LV_LABEL_LONG_CLIP); lv_obj_set_style_text_color(s_ui.table_header_cells[i], lv_color_hex(kColorTextDim), 0); lv_obj_set_style_text_font(s_ui.table_header_cells[i], s_layout.table_header_font, 0); diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp index 220618fd..40d0b3e9 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp @@ -6,6 +6,7 @@ using Host = gps::ui::shell::Host; #include "app/app_facade_access.h" #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/gps/gps_constants.h" #include "ui/screens/gps/gps_modal.h" @@ -371,7 +372,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) ::ui::widgets::TopBarConfig cfg; cfg.height = ::ui::page_profile::current().top_bar_height; ::ui::widgets::top_bar_init(g_gps_state.top_bar, g_gps_state.header, cfg); - ::ui::widgets::top_bar_set_title(g_gps_state.top_bar, "Map"); + ::ui::widgets::top_bar_set_title(g_gps_state.top_bar, ::ui::i18n::tr("Map")); ::ui::widgets::top_bar_set_back_callback(g_gps_state.top_bar, gps_top_bar_back, nullptr); // Ensure layout sizes are finalized before any tile calculations. @@ -399,7 +400,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_label_set_text(g_gps_state.resolution_label, ""); if (g_gps_state.altitude_label) { - lv_label_set_text(g_gps_state.altitude_label, "Alt: -- m"); + ::ui::i18n::set_label_text(g_gps_state.altitude_label, "Alt: -- m"); } bind_controls_and_group(app_g); diff --git a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp index 15218330..2dfb7ca3 100644 --- a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp @@ -8,6 +8,7 @@ #include "app/app_facade_access.h" #include "chat/infra/meshtastic/mt_region.h" #include "sys/clock.h" +#include "ui/localization.h" #include "ui/screens/node_info/node_info_page_layout.h" #include "ui/ui_common.h" #include "ui/widgets/top_bar.h" @@ -15,6 +16,7 @@ #include #include #include +#include #ifndef M_PI #define M_PI 3.14159265358979323846 @@ -192,11 +194,15 @@ void format_node_id(uint32_t node_id, char* out, size_t out_len) } if (node_id <= 0xFFFFFF) { - snprintf(out, out_len, "ID: !%06lx", static_cast(node_id)); + const std::string text = + ::ui::i18n::format("ID: !%06lx", static_cast(node_id)); + snprintf(out, out_len, "%s", text.c_str()); } else { - snprintf(out, out_len, "ID: !%08lx", static_cast(node_id)); + const std::string text = + ::ui::i18n::format("ID: !%08lx", static_cast(node_id)); + snprintf(out, out_len, "%s", text.c_str()); } } @@ -208,32 +214,42 @@ void format_age(const char* prefix, uint32_t ts, char* out, size_t out_len) } if (ts == 0) { - snprintf(out, out_len, "%s -", prefix); + const std::string text = ::ui::i18n::format("%s -", prefix); + snprintf(out, out_len, "%s", text.c_str()); return; } uint32_t now = sys::epoch_seconds_now(); if (now < ts) { - snprintf(out, out_len, "%s 0s", prefix); + const std::string text = ::ui::i18n::format("%s 0s", prefix); + snprintf(out, out_len, "%s", text.c_str()); return; } uint32_t age = now - ts; if (age < 60) { - snprintf(out, out_len, "%s %us", prefix, static_cast(age)); + const std::string text = + ::ui::i18n::format("%s %us", prefix, static_cast(age)); + snprintf(out, out_len, "%s", text.c_str()); return; } if (age < 3600) { - snprintf(out, out_len, "%s %um", prefix, static_cast(age / 60)); + const std::string text = + ::ui::i18n::format("%s %um", prefix, static_cast(age / 60)); + snprintf(out, out_len, "%s", text.c_str()); return; } if (age < 86400) { - snprintf(out, out_len, "%s %uh", prefix, static_cast(age / 3600)); + const std::string text = + ::ui::i18n::format("%s %uh", prefix, static_cast(age / 3600)); + snprintf(out, out_len, "%s", text.c_str()); return; } - snprintf(out, out_len, "%s %ud", prefix, static_cast(age / 86400)); + const std::string text = + ::ui::i18n::format("%s %ud", prefix, static_cast(age / 86400)); + snprintf(out, out_len, "%s", text.c_str()); } void format_radio_params(char* ch_out, size_t ch_len, char* sf_out, size_t sf_len, @@ -257,9 +273,9 @@ void format_radio_params(char* ch_out, size_t ch_len, char* sf_out, size_t sf_le const chat::meshtastic::RegionInfo* region = chat::meshtastic::findRegion(region_code); if (!region) { - snprintf(ch_out, ch_len, "Ch: -"); - snprintf(sf_out, sf_len, "SF: -"); - snprintf(bw_out, bw_len, "BW: -"); + snprintf(ch_out, ch_len, "%s", ::ui::i18n::tr("Ch: -")); + snprintf(sf_out, sf_len, "%s", ::ui::i18n::tr("SF: -")); + snprintf(bw_out, bw_len, "%s", ::ui::i18n::tr("BW: -")); return; } @@ -314,16 +330,20 @@ void format_radio_params(char* ch_out, size_t ch_len, char* sf_out, size_t sf_le freq_mhz = region->freq_start_mhz + (bw_khz / 2000.0f); } - snprintf(ch_out, ch_len, "Ch: %.3f", static_cast(freq_mhz)); - snprintf(sf_out, sf_len, "SF: %u", static_cast(sf)); + const std::string ch_text = ::ui::i18n::format("Ch: %.3f", static_cast(freq_mhz)); + const std::string sf_text = ::ui::i18n::format("SF: %u", static_cast(sf)); + snprintf(ch_out, ch_len, "%s", ch_text.c_str()); + snprintf(sf_out, sf_len, "%s", sf_text.c_str()); float bw_round = std::round(bw_khz); if (std::fabs(bw_khz - bw_round) < 0.05f) { - snprintf(bw_out, bw_len, "BW: %.0fk", static_cast(bw_khz)); + const std::string bw_text = ::ui::i18n::format("BW: %.0fk", static_cast(bw_khz)); + snprintf(bw_out, bw_len, "%s", bw_text.c_str()); } else { - snprintf(bw_out, bw_len, "BW: %.1fk", static_cast(bw_khz)); + const std::string bw_text = ::ui::i18n::format("BW: %.1fk", static_cast(bw_khz)); + snprintf(bw_out, bw_len, "%s", bw_text.c_str()); } } @@ -524,7 +544,7 @@ NodeInfoWidgets create(lv_obj_t* parent) ::ui::widgets::TopBarConfig cfg; ::ui::widgets::top_bar_init(s_top_bar, s_widgets.header, cfg); - ::ui::widgets::top_bar_set_title(s_top_bar, "NODE INFO"); + ::ui::widgets::top_bar_set_title(s_top_bar, ::ui::i18n::tr("NODE INFO")); ui_update_top_bar_battery(s_top_bar); s_widgets.back_btn = s_top_bar.back_btn; @@ -748,7 +768,8 @@ void set_node_info(const chat::contacts::NodeInfo& node) format_node_id(node.node_id, id_buf, sizeof(id_buf)); char role_buf[32]; - snprintf(role_buf, sizeof(role_buf), "Role: %s", role_to_text(node.role)); + const std::string role_text = ::ui::i18n::format("Role: %s", role_to_text(node.role)); + snprintf(role_buf, sizeof(role_buf), "%s", role_text.c_str()); set_label_text(s_widgets.avatar_label, avatar_text); set_label_text(s_widgets.name_label, name.c_str()); @@ -779,11 +800,13 @@ void set_node_info(const chat::contacts::NodeInfo& node) char alt_buf[24]; if (node.position.has_altitude) { - snprintf(alt_buf, sizeof(alt_buf), "Alt: %ld m", static_cast(node.position.altitude)); + const std::string text = + ::ui::i18n::format("Alt: %ld m", static_cast(node.position.altitude)); + snprintf(alt_buf, sizeof(alt_buf), "%s", text.c_str()); } else { - snprintf(alt_buf, sizeof(alt_buf), "Alt: -"); + snprintf(alt_buf, sizeof(alt_buf), "%s", ::ui::i18n::tr("Alt: -")); } set_label_text(s_widgets.coords_latlon_label, latlon_buf); @@ -795,7 +818,7 @@ void set_node_info(const chat::contacts::NodeInfo& node) { set_label_text(s_widgets.coords_latlon_label, "No position"); set_label_text(s_widgets.coords_acc_label, "+/- -"); - set_label_text(s_widgets.coords_alt_label, "Alt: -"); + set_label_text(s_widgets.coords_alt_label, ::ui::i18n::tr("Alt: -")); set_label_text(s_widgets.map_label, "No map"); } @@ -804,15 +827,15 @@ void set_node_info(const chat::contacts::NodeInfo& node) char link_title[48]; if (node.protocol == chat::contacts::NodeProtocolType::Meshtastic) { - snprintf(link_title, sizeof(link_title), "Link / Meshtastic"); + snprintf(link_title, sizeof(link_title), "%s", ::ui::i18n::tr("Link / Meshtastic")); } else if (node.protocol == chat::contacts::NodeProtocolType::MeshCore) { - snprintf(link_title, sizeof(link_title), "Link / MeshCore"); + snprintf(link_title, sizeof(link_title), "%s", ::ui::i18n::tr("Link / MeshCore")); } else { - snprintf(link_title, sizeof(link_title), "Link"); + snprintf(link_title, sizeof(link_title), "%s", ::ui::i18n::tr("Link")); } set_label_text(s_widgets.link_title_label, link_title); @@ -820,21 +843,23 @@ void set_node_info(const chat::contacts::NodeInfo& node) char rssi_buf[24]; if (std::isnan(node.rssi)) { - snprintf(rssi_buf, sizeof(rssi_buf), "RSSI: -"); + snprintf(rssi_buf, sizeof(rssi_buf), "%s", ::ui::i18n::tr("RSSI: -")); } else { - snprintf(rssi_buf, sizeof(rssi_buf), "RSSI: %.0f dBm", node.rssi); + const std::string text = ::ui::i18n::format("RSSI: %.0f dBm", node.rssi); + snprintf(rssi_buf, sizeof(rssi_buf), "%s", text.c_str()); } set_label_text(s_widgets.link_rssi_label, rssi_buf); char snr_buf[24]; if (std::isnan(node.snr)) { - snprintf(snr_buf, sizeof(snr_buf), "SNR: -"); + snprintf(snr_buf, sizeof(snr_buf), "%s", ::ui::i18n::tr("SNR: -")); } else { - snprintf(snr_buf, sizeof(snr_buf), "SNR: %.1f dB", node.snr); + const std::string text = ::ui::i18n::format("SNR: %.1f dB", node.snr); + snprintf(snr_buf, sizeof(snr_buf), "%s", text.c_str()); } set_label_text(s_widgets.link_snr_label, snr_buf); char ch_buf[32]; @@ -846,7 +871,7 @@ void set_node_info(const chat::contacts::NodeInfo& node) set_label_text(s_widgets.link_bw_label, bw_buf); char last_heard_buf[64]; - format_age("Last heard:", node.last_seen, last_heard_buf, sizeof(last_heard_buf)); + format_age(::ui::i18n::tr("Last heard:"), node.last_seen, last_heard_buf, sizeof(last_heard_buf)); char hop_buf[64]; if (node.hops_away != 0xFF) { @@ -860,18 +885,22 @@ void set_node_info(const chat::contacts::NodeInfo& node) } else { - snprintf(hop_buf, sizeof(hop_buf), "Hop: %u", static_cast(node.hops_away)); + const std::string text = + ::ui::i18n::format("Hop: %u", static_cast(node.hops_away)); + snprintf(hop_buf, sizeof(hop_buf), "%s", text.c_str()); } set_label_text(s_widgets.link_hop_label, hop_buf); } else if (node.next_hop != 0) { - snprintf(hop_buf, sizeof(hop_buf), "Hop: - / NH: %02X", static_cast(node.next_hop)); + const std::string text = + ::ui::i18n::format("Hop: - / NH: %02X", static_cast(node.next_hop)); + snprintf(hop_buf, sizeof(hop_buf), "%s", text.c_str()); set_label_text(s_widgets.link_hop_label, hop_buf); } else { - set_label_text(s_widgets.link_hop_label, "Hop: -"); + set_label_text(s_widgets.link_hop_label, ::ui::i18n::tr("Hop: -")); } char status_buf[96]; @@ -887,15 +916,15 @@ void set_node_info(const chat::contacts::NodeInfo& node) char pk_buf[20]; if (node.key_manually_verified) { - snprintf(pk_buf, sizeof(pk_buf), "PKI: verified"); + snprintf(pk_buf, sizeof(pk_buf), "%s", ::ui::i18n::tr("PKI: verified")); } else if (node.has_public_key) { - snprintf(pk_buf, sizeof(pk_buf), "PKI: known"); + snprintf(pk_buf, sizeof(pk_buf), "%s", ::ui::i18n::tr("PKI: known")); } else { - snprintf(pk_buf, sizeof(pk_buf), "PKI: -"); + snprintf(pk_buf, sizeof(pk_buf), "%s", ::ui::i18n::tr("PKI: -")); } if (node.has_device_metrics && node.device_metrics.has_battery_level) diff --git a/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp b/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp index e4f00eef..ab9891ee 100644 --- a/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp @@ -7,6 +7,7 @@ #include "platform/ui/hostlink_runtime.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/fonts.h" +#include "ui/localization.h" #include "ui/ui_common.h" #include "ui/widgets/top_bar.h" @@ -106,21 +107,27 @@ void refresh_status_cb(lv_timer_t* timer) if (st.state == platform::ui::hostlink::LinkState::Error && st.last_error != 0) { char err_buf[24]; - snprintf(err_buf, sizeof(err_buf), "Error: %lu", - static_cast(st.last_error)); - lv_label_set_text(s_status_label, err_buf); + snprintf(err_buf, + sizeof(err_buf), + "%s", + ::ui::i18n::format("Error: %lu", static_cast(st.last_error)).c_str()); + ::ui::i18n::set_label_text_raw(s_status_label, err_buf); } else { - lv_label_set_text(s_status_label, status_text(st.state)); + ::ui::i18n::set_label_text(s_status_label, status_text(st.state)); } if (s_count_label) { char buf[32]; - snprintf(buf, sizeof(buf), "RX: %lu TX: %lu", - static_cast(st.rx_count), - static_cast(st.tx_count)); - lv_label_set_text(s_count_label, buf); + snprintf(buf, + sizeof(buf), + "%s", + ::ui::i18n::format("RX: %lu TX: %lu", + static_cast(st.rx_count), + static_cast(st.tx_count)) + .c_str()); + ::ui::i18n::set_label_text_raw(s_count_label, buf); } } @@ -169,7 +176,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_add_event_cb(s_root, root_key_event_cb, LV_EVENT_KEY, nullptr); ::ui::widgets::top_bar_init(s_top_bar, s_root); - ::ui::widgets::top_bar_set_title(s_top_bar, page_title()); + ::ui::widgets::top_bar_set_title(s_top_bar, ::ui::i18n::tr(page_title())); ::ui::widgets::top_bar_set_back_callback(s_top_bar, on_back, nullptr); if (s_top_bar.back_btn) { @@ -209,15 +216,15 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_center(stack); lv_obj_t* title = lv_label_create(stack); - lv_label_set_text(title, page_subtitle()); + ::ui::i18n::set_label_text(title, page_subtitle()); lv_obj_set_style_text_font(title, &lv_font_montserrat_18, 0); s_status_label = lv_label_create(stack); - lv_label_set_text(s_status_label, "Waiting for host..."); + ::ui::i18n::set_label_text(s_status_label, "Waiting for host..."); lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_18, 0); s_count_label = lv_label_create(stack); - lv_label_set_text(s_count_label, "RX: 0 TX: 0"); + ::ui::i18n::set_label_text(s_count_label, "RX: 0 TX: 0"); lv_obj_set_style_text_font(s_count_label, &lv_font_montserrat_16, 0); if (!s_timer) diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index 83a7dad7..d0ce109f 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -21,7 +21,10 @@ #include "platform/ui/settings_store.h" #include "platform/ui/time_runtime.h" #include "platform/ui/tracker_runtime.h" +#include "ui/app_runtime.h" #include "ui/components/info_card.h" +#include "ui/localization.h" +#include "ui/menu/menu_layout.h" #include "ui/page/page_profile.h" #include "ui/screens/settings/settings_page_components.h" #include "ui/screens/settings/settings_page_input.h" @@ -132,7 +135,7 @@ static void create_item_content(settings::ui::ItemWidget& widget, lv_obj_t* btn) if (use_tdeck_info_card_layout()) { const auto slots = ::ui::components::info_card::create_content(btn); - lv_label_set_text(slots.header_main_label, widget.def->label); + ::ui::i18n::set_label_text(slots.header_main_label, widget.def->label); style::apply_label_primary(slots.header_main_label); widget.value_label = slots.body_main_label; @@ -142,7 +145,7 @@ static void create_item_content(settings::ui::ItemWidget& widget, lv_obj_t* btn) } lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, widget.def->label); + ::ui::i18n::set_label_text(label, widget.def->label); style::apply_label_primary(label); widget.value_label = lv_label_create(btn); @@ -490,7 +493,7 @@ static void reset_mesh_settings() prefs_remove_keys(kPrefsNs, kResetKeys, sizeof(kResetKeys) / sizeof(kResetKeys[0])); build_item_list(); - ::ui::SystemNotification::show("Resetting...", 1500); + ::ui::SystemNotification::show(::ui::i18n::tr("Resetting..."), 1500); platform_delay_ms(300); platform_restart(); } @@ -500,14 +503,14 @@ static void reset_node_db() app::IAppFacade& app_ctx = app::appFacade(); app_ctx.clearNodeDb(); prefs_clear_ns("chat_pki"); - ::ui::SystemNotification::show("Node DB reset", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Node DB reset"), 3000); } static void clear_message_db() { app::IAppFacade& app_ctx = app::appFacade(); app_ctx.clearMessageDb(); - ::ui::SystemNotification::show("Message DB cleared", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Message DB cleared"), 3000); } static void settings_load() @@ -707,6 +710,7 @@ static void settings_load() g_settings.speaker_volume = 100; } apply_message_tone_volume(static_cast(g_settings.speaker_volume)); + g_settings.display_language = static_cast(::ui::i18n::current_language()); // BLE enable flag (System > Bluetooth toggle). Default ON so existing devices keep BLE active. g_settings.ble_enabled = prefs_get_bool("ble_enabled", true); @@ -739,17 +743,17 @@ static void format_value(const settings::ui::SettingItem& item, char* out, size_ switch (item.type) { case settings::ui::SettingType::Toggle: - snprintf(out, out_len, "%s", (item.bool_value && *item.bool_value) ? "ON" : "OFF"); + snprintf(out, out_len, "%s", ::ui::i18n::tr((item.bool_value && *item.bool_value) ? "ON" : "OFF")); break; case settings::ui::SettingType::Enum: { int value = item.enum_value ? *item.enum_value : 0; - const char* label = "N/A"; + const char* label = ::ui::i18n::tr("N/A"); for (size_t i = 0; i < item.option_count; ++i) { if (item.options[i].value == value) { - label = item.options[i].label; + label = ::ui::i18n::tr(item.options[i].label); break; } } @@ -770,11 +774,11 @@ static void format_value(const settings::ui::SettingItem& item, char* out, size_ } else { - snprintf(out, out_len, "Not set"); + snprintf(out, out_len, "%s", ::ui::i18n::tr("Not set")); } break; case settings::ui::SettingType::Action: - snprintf(out, out_len, "Run"); + snprintf(out, out_len, "%s", ::ui::i18n::tr("Run")); break; } } @@ -900,7 +904,7 @@ static void on_text_save_clicked(lv_event_t* e) uint8_t key[16] = {}; if (!parse_psk(g_state.editing_item->text_value, key, sizeof(key))) { - ::ui::SystemNotification::show("PSK must be 32 hex or 16 chars", 4000); + ::ui::SystemNotification::show(::ui::i18n::tr("PSK must be 32 hex or 16 chars"), 4000); modal_close(); return; } @@ -921,7 +925,7 @@ static void on_text_save_clicked(lv_event_t* e) float value = 0.0f; if (!parse_float_text(g_state.editing_item->text_value, &value)) { - ::ui::SystemNotification::show("Invalid frequency offset", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Invalid frequency offset"), 3000); modal_close(); return; } @@ -935,7 +939,7 @@ static void on_text_save_clicked(lv_event_t* e) float value = 0.0f; if (!parse_float_text(g_state.editing_item->text_value, &value)) { - ::ui::SystemNotification::show("Invalid frequency value", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Invalid frequency value"), 3000); modal_close(); return; } @@ -957,7 +961,7 @@ static void on_text_save_clicked(lv_event_t* e) float value = 0.0f; if (!parse_float_text(g_state.editing_item->text_value, &value)) { - ::ui::SystemNotification::show("Invalid MeshCore frequency", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Invalid MeshCore frequency"), 3000); modal_close(); return; } @@ -974,7 +978,7 @@ static void on_text_save_clicked(lv_event_t* e) float value = 0.0f; if (!parse_float_text(g_state.editing_item->text_value, &value)) { - ::ui::SystemNotification::show("Invalid MeshCore bandwidth", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Invalid MeshCore bandwidth"), 3000); modal_close(); return; } @@ -991,7 +995,7 @@ static void on_text_save_clicked(lv_event_t* e) float value = 0.0f; if (!parse_float_text(g_state.editing_item->text_value, &value)) { - ::ui::SystemNotification::show("Invalid RX delay", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Invalid RX delay"), 3000); modal_close(); return; } @@ -1005,7 +1009,7 @@ static void on_text_save_clicked(lv_event_t* e) float value = 0.0f; if (!parse_float_text(g_state.editing_item->text_value, &value)) { - ::ui::SystemNotification::show("Invalid airtime factor", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Invalid airtime factor"), 3000); modal_close(); return; } @@ -1029,7 +1033,7 @@ static void on_text_save_clicked(lv_event_t* e) uint8_t key[16] = {}; if (!parse_psk(g_state.editing_item->text_value, key, sizeof(key))) { - ::ui::SystemNotification::show("Key must be 32 hex or 16 chars", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Key must be 32 hex or 16 chars"), 3000); modal_close(); return; } @@ -1044,7 +1048,7 @@ static void on_text_save_clicked(lv_event_t* e) long value = strtol(g_state.editing_item->text_value, &end, 10); if (end == g_state.editing_item->text_value || (end && *end != '\0') || value <= 0 || value > 10000) { - ::ui::SystemNotification::show("Invalid design capacity (mAh)", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Invalid design capacity (mAh)"), 3000); modal_close(); return; } @@ -1057,7 +1061,7 @@ static void on_text_save_clicked(lv_event_t* e) long value = strtol(g_state.editing_item->text_value, &end, 10); if (end == g_state.editing_item->text_value || (end && *end != '\0') || value <= 0 || value > 10000) { - ::ui::SystemNotification::show("Invalid full capacity (mAh)", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Invalid full capacity (mAh)"), 3000); modal_close(); return; } @@ -1084,7 +1088,7 @@ static void open_text_modal(const settings::ui::SettingItem& item, settings::ui: lv_obj_t* win = lv_obj_get_child(g_state.modal_root, 0); lv_obj_t* title = lv_label_create(win); - lv_label_set_text(title, item.label); + ::ui::i18n::set_label_text(title, item.label); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); g_state.modal_textarea = lv_textarea_create(win); @@ -1116,14 +1120,14 @@ static void open_text_modal(const settings::ui::SettingItem& item, settings::ui: lv_obj_t* save_btn = lv_btn_create(btn_row); lv_obj_set_size(save_btn, ::ui::page_profile::resolve_control_button_min_width(), ::ui::page_profile::resolve_control_button_height()); lv_obj_t* save_label = lv_label_create(save_btn); - lv_label_set_text(save_label, "Save"); + ::ui::i18n::set_label_text(save_label, "Save"); lv_obj_center(save_label); lv_obj_add_event_cb(save_btn, on_text_save_clicked, LV_EVENT_CLICKED, nullptr); lv_obj_t* cancel_btn = lv_btn_create(btn_row); lv_obj_set_size(cancel_btn, ::ui::page_profile::resolve_control_button_min_width(), ::ui::page_profile::resolve_control_button_height()); lv_obj_t* cancel_label = lv_label_create(cancel_btn); - lv_label_set_text(cancel_label, "Cancel"); + ::ui::i18n::set_label_text(cancel_label, "Cancel"); lv_obj_center(cancel_label); lv_obj_add_event_cb(cancel_btn, on_text_cancel_clicked, LV_EVENT_CLICKED, nullptr); @@ -1145,10 +1149,21 @@ static void on_option_clicked(lv_event_t* e) } bool restart_now = false; bool rebuild_list = false; + bool rebuild_active_app = false; + bool refresh_menu_labels = false; int previous_value = *payload->item->enum_value; *payload->item->enum_value = payload->value; prefs_put_int(payload->item->pref_key, payload->value); update_item_value(*payload->widget); + if (payload->item->pref_key && strcmp(payload->item->pref_key, "display_language") == 0) + { + const ::ui::i18n::Language target_language = ::ui::i18n::language_from_raw(payload->value); + if (::ui::i18n::set_language(target_language, true)) + { + refresh_menu_labels = true; + rebuild_active_app = true; + } + } if (payload->item->pref_key && strcmp(payload->item->pref_key, "mesh_protocol") == 0) { app::IAppFacade& app_ctx = app::appFacade(); @@ -1158,12 +1173,12 @@ static void on_option_clicked(lv_event_t* e) *payload->item->enum_value = previous_value; prefs_put_int(payload->item->pref_key, previous_value); update_item_value(*payload->widget); - ::ui::SystemNotification::show("Protocol switch failed", 3000); + ::ui::SystemNotification::show(::ui::i18n::tr("Protocol switch failed"), 3000); } else { rebuild_list = true; - ::ui::SystemNotification::show("Protocol switched", 2000); + ::ui::SystemNotification::show(::ui::i18n::tr("Protocol switched"), 2000); restart_now = true; } } @@ -1507,13 +1522,22 @@ static void on_option_clicked(lv_event_t* e) restart_now = true; } modal_close(); + if (refresh_menu_labels) + { + ::ui::menu_layout::refresh_localized_text(); + } + if (rebuild_active_app) + { + ui_request_rebuild_active_app(); + return; + } if (rebuild_list) { build_item_list(); } if (restart_now) { - ::ui::SystemNotification::show("Restarting...", 1500); + ::ui::SystemNotification::show(::ui::i18n::tr("Restarting..."), 1500); platform_delay_ms(300); platform_restart(); } @@ -1579,7 +1603,7 @@ static void open_option_modal(const settings::ui::SettingItem& item, settings::u lv_obj_set_size(btn, LV_PCT(100), ::ui::page_profile::resolve_control_button_height()); style::apply_btn_modal(btn); lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, item.options[i].label); + ::ui::i18n::set_label_text(label, item.options[i].label); style::apply_label_primary(label); lv_obj_center(label); @@ -1600,7 +1624,7 @@ static void open_option_modal(const settings::ui::SettingItem& item, settings::u lv_obj_set_size(back_btn, LV_PCT(100), ::ui::page_profile::resolve_control_button_height()); style::apply_btn_modal(back_btn); lv_obj_t* back_label = lv_label_create(back_btn); - lv_label_set_text(back_label, "Back"); + ::ui::i18n::set_label_text(back_label, "Back"); style::apply_label_primary(back_label); lv_obj_center(back_label); lv_obj_add_event_cb(back_btn, on_option_modal_back_clicked, LV_EVENT_CLICKED, nullptr); @@ -1832,6 +1856,11 @@ static const settings::ui::SettingOption kSpeakerVolumeOptions[] = { {"100%", 100}, }; +static const settings::ui::SettingOption kLanguageOptions[] = { + {"English", static_cast(::ui::i18n::Language::English)}, + {"Chinese", static_cast(::ui::i18n::Language::Chinese)}, +}; + static const settings::ui::SettingOption kTimeZoneOptions[] = { {"UTC", 0}, {"Beijing (UTC+8)", 480}, @@ -1926,6 +1955,9 @@ static settings::ui::SettingItem kNetworkItems[] = { }; static settings::ui::SettingItem kScreenItems[] = { + {"Display Language", settings::ui::SettingType::Enum, kLanguageOptions, + sizeof(kLanguageOptions) / sizeof(kLanguageOptions[0]), &g_settings.display_language, nullptr, nullptr, 0, false, + "display_language"}, {"Screen Timeout", settings::ui::SettingType::Enum, kScreenTimeoutOptions, 4, &g_settings.screen_timeout_ms, nullptr, nullptr, 0, false, "screen_timeout"}, {"Screen Brightness", settings::ui::SettingType::Enum, kScreenBrightnessOptions, sizeof(kScreenBrightnessOptions) / sizeof(kScreenBrightnessOptions[0]), &g_settings.screen_brightness, nullptr, nullptr, 0, false, "screen_brightness"}, @@ -2048,6 +2080,10 @@ static bool should_show_item(const settings::ui::SettingItem& item) { return false; } + if (has_pref_key(item, "display_language") && !::ui::i18n::supports_chinese()) + { + return false; + } if (meshcore) { @@ -2201,7 +2237,7 @@ static void build_item_list() LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); style::apply_list_item(g_state.list_back_btn); lv_obj_t* back_label = lv_label_create(g_state.list_back_btn); - lv_label_set_text(back_label, "Back"); + ::ui::i18n::set_label_text(back_label, "Back"); style::apply_label_primary(back_label); lv_obj_add_event_cb(g_state.list_back_btn, on_list_back_clicked, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(g_state.list_back_btn, list_item_focused_cb, LV_EVENT_FOCUSED, nullptr); @@ -2437,7 +2473,7 @@ void create(lv_obj_t* parent) lv_obj_add_event_cb(btn, on_filter_focused, LV_EVENT_FOCUSED, reinterpret_cast(i)); } lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, kCategories[i].label); + ::ui::i18n::set_label_text(label, kCategories[i].label); style::apply_label_primary(label); lv_obj_center(label); g_state.filter_buttons[i] = btn; diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_layout.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_layout.cpp index af1a76b0..0e01517a 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_layout.cpp @@ -5,6 +5,7 @@ #include "ui/screens/settings/settings_page_layout.h" #include "ui/components/two_pane_layout.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/settings/settings_page_styles.h" #include "ui/screens/settings/settings_state.h" @@ -33,7 +34,7 @@ lv_obj_t* create_header(lv_obj_t* root, void (*back_callback)(void*), void* user ::ui::widgets::TopBarConfig cfg; cfg.height = profile.top_bar_height; ::ui::widgets::top_bar_init(g_state.top_bar, header, cfg); - ::ui::widgets::top_bar_set_title(g_state.top_bar, "Settings"); + ::ui::widgets::top_bar_set_title(g_state.top_bar, ::ui::i18n::tr("Settings")); ::ui::widgets::top_bar_set_back_callback(g_state.top_bar, back_callback, user_data); ui_update_top_bar_battery(g_state.top_bar); return header; diff --git a/modules/ui_shared/src/ui/screens/sstv/sstv_page_runtime.cpp b/modules/ui_shared/src/ui/screens/sstv/sstv_page_runtime.cpp index 68d61f98..981fcbcf 100644 --- a/modules/ui_shared/src/ui/screens/sstv/sstv_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/sstv/sstv_page_runtime.cpp @@ -6,12 +6,14 @@ #include "platform/ui/screen_runtime.h" #include "platform/ui/sstv_runtime.h" #include "ui/LV_Helper.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/ui_common.h" #include "ui/widgets/system_notification.h" #include #include #include +#include #if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 #define lv_font_montserrat_12 lv_font_montserrat_14 @@ -141,7 +143,8 @@ void update_rx_button_label() { return; } - lv_label_set_text(s_ui.btn_rx_label, platform::ui::sstv::is_active() ? "STOP" : "RX"); + ::ui::i18n::set_label_text(s_ui.btn_rx_label, + platform::ui::sstv::is_active() ? "STOP" : "RX"); } void on_rx_btn_clicked(lv_event_t*) @@ -156,7 +159,7 @@ void on_rx_btn_clicked(lv_event_t*) if (!ok && s_ui.label_state_sub) { const char* err = platform::ui::sstv::last_error(); - lv_label_set_text(s_ui.label_state_sub, err ? err : "SSTV start failed"); + ::ui::i18n::set_label_text(s_ui.label_state_sub, err ? err : "SSTV start failed"); } } update_rx_button_label(); @@ -214,11 +217,11 @@ void refresh_cb(lv_timer_t*) if (s_ui.label_state_sub) { const char* err = platform::ui::sstv::last_error(); - lv_label_set_text(s_ui.label_state_sub, err ? err : "Decoder error"); + ::ui::i18n::set_label_text(s_ui.label_state_sub, err ? err : "Decoder error"); } if (s_ui.label_ready) { - lv_label_set_text(s_ui.label_ready, "ERROR"); + ::ui::i18n::set_label_text(s_ui.label_ready, "ERROR"); lv_obj_set_style_text_color(s_ui.label_ready, lv_color_hex(kColorWarn), 0); } } @@ -229,9 +232,8 @@ void refresh_cb(lv_timer_t*) if (s_ui.label_state_sub) { int pct = static_cast(st.progress * 100.0f + 0.5f); - char buf[32]; - snprintf(buf, sizeof(buf), "Decoding: %d%%", pct); - lv_label_set_text(s_ui.label_state_sub, buf); + const std::string text = ::ui::i18n::format("Decoding: %d%%", pct); + lv_label_set_text(s_ui.label_state_sub, text.c_str()); } ui_sstv_set_progress(st.progress); } @@ -243,9 +245,8 @@ void refresh_cb(lv_timer_t*) const char* saved = platform::ui::sstv::last_saved_path(); if (saved && saved[0] != '\0') { - char buf[80]; - snprintf(buf, sizeof(buf), "Saved: %s", saved); - lv_label_set_text(s_ui.label_state_sub, buf); + const std::string text = ::ui::i18n::format("Saved: %s", saved); + lv_label_set_text(s_ui.label_state_sub, text.c_str()); } } } @@ -323,7 +324,7 @@ void build_top_bar(lv_obj_t* parent) { ::ui::widgets::TopBarConfig cfg; ::ui::widgets::top_bar_init(s_ui.top_bar, parent, cfg); - ::ui::widgets::top_bar_set_title(s_ui.top_bar, "SSTV RECEIVER"); + ::ui::widgets::top_bar_set_title(s_ui.top_bar, ::ui::i18n::tr("SSTV RECEIVER")); ::ui::widgets::top_bar_set_back_callback( s_ui.top_bar, [](void*) { request_exit(); }, @@ -377,9 +378,9 @@ void build_main_area(lv_obj_t* parent) lv_obj_add_flag(s_ui.img, LV_OBJ_FLAG_HIDDEN); s_ui.img_placeholder = lv_label_create(s_ui.img_box); - lv_label_set_text(s_ui.img_placeholder, "No image"); - lv_obj_center(s_ui.img_placeholder); apply_label_style(s_ui.img_placeholder, &lv_font_montserrat_12, kColorTextDim); + ::ui::i18n::set_label_text(s_ui.img_placeholder, "No image"); + lv_obj_center(s_ui.img_placeholder); s_ui.info_area = lv_obj_create(main); lv_obj_set_size(s_ui.info_area, kInfoW, kInfoH); @@ -419,8 +420,8 @@ void build_main_area(lv_obj_t* parent) lv_obj_set_style_border_color(s_ui.btn_rx, lv_color_hex(kColorLine), 0); lv_obj_set_style_radius(s_ui.btn_rx, 6, 0); lv_obj_t* rx_label = lv_label_create(s_ui.btn_rx); - lv_label_set_text(rx_label, "RX"); apply_label_style(rx_label, &lv_font_montserrat_16, kColorText); + ::ui::i18n::set_label_text(rx_label, "RX"); lv_obj_center(rx_label); s_ui.btn_rx_label = rx_label; lv_obj_add_event_cb(s_ui.btn_rx, on_rx_btn_clicked, LV_EVENT_CLICKED, nullptr); @@ -506,7 +507,7 @@ void ui_sstv_enter(lv_obj_t* parent) { if (platform::ui::device::power_tier() >= 1) { - ui::SystemNotification::show("Low battery - audio disabled", 3000); + ui::SystemNotification::show(::ui::i18n::tr("Low battery - audio disabled"), 3000); } lv_group_t* prev_group = lv_group_get_default(); @@ -533,7 +534,7 @@ void ui_sstv_enter(lv_obj_t* parent) if (s_ui.label_state_sub) { - lv_label_set_text(s_ui.label_state_sub, "Press RX to start"); + ::ui::i18n::set_label_text(s_ui.label_state_sub, "Press RX to start"); } update_rx_button_label(); @@ -574,20 +575,20 @@ void ui_sstv_set_state(SstvState state) switch (state) { case SSTV_STATE_WAITING: - lv_label_set_text(s_ui.label_state_sub, "Listening for SSTV signal..."); - lv_label_set_text(s_ui.label_ready, "SSTV RX READY"); + ::ui::i18n::set_label_text(s_ui.label_state_sub, "Listening for SSTV signal..."); + ::ui::i18n::set_label_text(s_ui.label_ready, "SSTV RX READY"); lv_obj_set_style_text_color(s_ui.label_ready, lv_color_hex(kColorText), 0); ui_sstv_set_image(nullptr); ui_sstv_set_progress(0.0f); break; case SSTV_STATE_RECEIVING: - lv_label_set_text(s_ui.label_state_sub, "Decoding: 0%"); - lv_label_set_text(s_ui.label_ready, "RECEIVING"); + ::ui::i18n::set_label_text(s_ui.label_state_sub, "Decoding: 0%"); + ::ui::i18n::set_label_text(s_ui.label_ready, "RECEIVING"); lv_obj_set_style_text_color(s_ui.label_ready, lv_color_hex(kColorOk), 0); break; case SSTV_STATE_COMPLETE: - lv_label_set_text(s_ui.label_state_sub, "Image received"); - lv_label_set_text(s_ui.label_ready, "COMPLETE"); + ::ui::i18n::set_label_text(s_ui.label_state_sub, "Image received"); + ::ui::i18n::set_label_text(s_ui.label_ready, "COMPLETE"); lv_obj_set_style_text_color(s_ui.label_ready, lv_color_hex(kColorOk), 0); ui_sstv_set_progress(1.0f); break; @@ -605,11 +606,12 @@ void ui_sstv_set_mode(const char* mode_str) char buf[48]; if (!mode_str || mode_str[0] == '\0') { - snprintf(buf, sizeof(buf), "MODE: Auto"); + snprintf(buf, sizeof(buf), "%s", ::ui::i18n::tr("MODE: Auto")); } else { - snprintf(buf, sizeof(buf), "MODE: %s", mode_str); + const std::string text = ::ui::i18n::format("MODE: %s", mode_str); + snprintf(buf, sizeof(buf), "%s", text.c_str()); } lv_label_set_text(s_ui.label_mode, buf); } diff --git a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp index bd4580d4..97b0737e 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp @@ -16,6 +16,7 @@ #include "team/usecase/team_pairing_service.h" #include "team/usecase/team_service.h" #include "ui/chat_ui_runtime.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/gps/gps_state.h" #include "ui/screens/gps/gps_tracker_overlay.h" @@ -134,16 +135,11 @@ bool append_key_event(TeamKeyEventType type, const std::vector& payload void notify_send_failed(const char* action, bool needs_keys) { - const char* msg = "Send failed"; - if (needs_keys) - { - msg = "Send failed (keys not ready)"; - } + const char* msg = needs_keys ? "Send failed (keys not ready)" : "Send failed"; if (action && action[0]) { - char buf[64]; - snprintf(buf, sizeof(buf), "%s: %s", action, msg); - ::ui::SystemNotification::show(buf, 2000); + const std::string notice = std::string(::ui::i18n::tr(action)) + ": " + ::ui::i18n::tr(msg); + ::ui::SystemNotification::show(notice.c_str(), 2000); return; } ::ui::SystemNotification::show(msg, 2000); @@ -172,9 +168,9 @@ void notify_send_failed_detail(const char* action, team::TeamService::SendError default: break; } - char buf[64]; - snprintf(buf, sizeof(buf), "%s: %s", action ? action : "Send", reason); - ::ui::SystemNotification::show(buf, 2000); + const char* action_text = (action && action[0]) ? action : "Send"; + const std::string notice = std::string(::ui::i18n::tr(action_text)) + ": " + ::ui::i18n::tr(reason); + ::ui::SystemNotification::show(notice.c_str(), 2000); } struct KeyDistPending @@ -524,7 +520,7 @@ int online_count() lv_obj_t* add_label(lv_obj_t* parent, const char* text, bool section = false, bool meta = false) { lv_obj_t* label = lv_label_create(parent); - lv_label_set_text(label, text); + ::ui::i18n::set_label_text_raw(label, text); lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); lv_obj_set_width(label, LV_PCT(100)); if (section) @@ -545,7 +541,7 @@ lv_obj_t* create_fitted_button(lv_obj_t* parent, const char* text, lv_event_cb_t style::apply_button_secondary(btn); lv_obj_set_style_pad_hor(btn, kActionBtnPadH, LV_PART_MAIN); lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, text); + ::ui::i18n::set_label_text(label, text); lv_obj_update_layout(label); lv_coord_t width = lv_obj_get_width(label) + (kActionBtnPadH * 2); if (width < ::ui::page_profile::resolve_compact_button_min_width()) @@ -577,12 +573,12 @@ lv_obj_t* create_list_item(const char* left, const char* right) style::apply_list_item(btn); lv_obj_t* left_label = lv_label_create(btn); - lv_label_set_text(left_label, left); + ::ui::i18n::set_label_text_raw(left_label, left); lv_obj_set_width(left_label, LV_PCT(70)); lv_label_set_long_mode(left_label, LV_LABEL_LONG_CLIP); lv_obj_t* right_label = lv_label_create(btn); - lv_label_set_text(right_label, right); + ::ui::i18n::set_label_text(right_label, right); lv_obj_set_width(right_label, LV_PCT(30)); lv_label_set_long_mode(right_label, LV_LABEL_LONG_CLIP); lv_obj_set_style_text_align(right_label, LV_TEXT_ALIGN_RIGHT, 0); @@ -595,44 +591,38 @@ std::string format_last_seen(uint32_t last_seen_s) { if (last_seen_s == 0) { - return "Last seen --"; + return ::ui::i18n::tr("Last seen --"); } uint32_t now = now_secs(); if (now <= last_seen_s) { - return "Online"; + return ::ui::i18n::tr("Online"); } uint32_t age = now - last_seen_s; if (age <= 120) { - return "Online"; + return ::ui::i18n::tr("Online"); } if (age < 3600) { - char buf[24]; - snprintf(buf, sizeof(buf), "Last seen %um ago", (unsigned)(age / 60)); - return std::string(buf); + return ::ui::i18n::format("Last seen %um ago", static_cast(age / 60)); } - char buf[24]; - snprintf(buf, sizeof(buf), "Last seen %uh ago", (unsigned)(age / 3600)); - return std::string(buf); + return ::ui::i18n::format("Last seen %uh ago", static_cast(age / 3600)); } std::string format_last_update(uint32_t last_update_s) { if (last_update_s == 0) { - return "Last update --"; + return ::ui::i18n::tr("Last update --"); } uint32_t now = now_secs(); if (now <= last_update_s) { - return "Last update 0s ago"; + return ::ui::i18n::tr("Last update 0s ago"); } uint32_t age = now - last_update_s; - char buf[32]; - snprintf(buf, sizeof(buf), "Last update %us ago", (unsigned)age); - return std::string(buf); + return ::ui::i18n::format("Last update %us ago", static_cast(age)); } std::string format_team_name_from_id(const TeamId& id) @@ -1737,11 +1727,11 @@ void handle_leave(lv_event_t*) lv_obj_t* win = lv_obj_get_child(g_team_state.leave_confirm_modal, 0); lv_obj_t* title_label = lv_label_create(win); - lv_label_set_text(title_label, "Leave team?"); + ::ui::i18n::set_label_text(title_label, "Leave team?"); lv_obj_align(title_label, LV_ALIGN_TOP_MID, 0, 0); lv_obj_t* desc_label = lv_label_create(win); - lv_label_set_text(desc_label, "This clears local keys."); + ::ui::i18n::set_label_text(desc_label, "This clears local keys."); lv_obj_align(desc_label, LV_ALIGN_TOP_MID, 0, ::ui::page_profile::current().large_touch_hitbox ? 40 : 28); lv_obj_t* btn_row = lv_obj_create(win); @@ -1979,11 +1969,11 @@ void handle_kicked_ok(lv_event_t*) void render_status_not_in_team() { - update_top_bar_title("Team"); + update_top_bar_title(::ui::i18n::tr("Team")); - add_label(g_team_state.body, "You are not in a team", true, false); - add_label(g_team_state.body, "- No shared map\n- No team awareness", false, true); - add_label(g_team_state.body, "Keep devices within 5m", false, false); + add_label(g_team_state.body, ::ui::i18n::tr("You are not in a team"), true, false); + add_label(g_team_state.body, ::ui::i18n::tr("- No shared map\n- No team awareness"), false, true); + add_label(g_team_state.body, ::ui::i18n::tr("Keep devices within 5m"), false, false); g_team_state.action_btns[0] = create_action_button("Create Team", handle_create); g_team_state.action_btns[1] = create_action_button("Join Team", handle_join); @@ -1993,47 +1983,63 @@ void render_status_not_in_team() void render_status_in_team() { - update_top_bar_title("Team Status"); + update_top_bar_title(::ui::i18n::tr("Team Status")); - char line[64]; std::string team_name = current_team_name(); - snprintf(line, sizeof(line), "Team: %s", team_name.c_str()); - add_label(g_team_state.body, line, true, false); - snprintf(line, sizeof(line), "Role: %s", g_team_state.self_is_leader ? "Leader" : "Member"); - add_label(g_team_state.body, line, false, true); - snprintf(line, sizeof(line), "Members: %u", (unsigned)g_team_state.members.size()); - add_label(g_team_state.body, line, false, true); - snprintf(line, sizeof(line), "Online: %u", (unsigned)online_count()); - add_label(g_team_state.body, line, false, true); + add_label(g_team_state.body, ::ui::i18n::format("Team: %s", team_name.c_str()).c_str(), true, false); + add_label(g_team_state.body, + ::ui::i18n::format("Role: %s", + ::ui::i18n::tr(g_team_state.self_is_leader ? "Leader" : "Member")) + .c_str(), + false, + true); + add_label(g_team_state.body, + ::ui::i18n::format("Members: %u", static_cast(g_team_state.members.size())).c_str(), + false, + true); + add_label(g_team_state.body, + ::ui::i18n::format("Online: %u", static_cast(online_count())).c_str(), + false, + true); if (g_team_state.security_round == 0) { - snprintf(line, sizeof(line), "KeyId: --"); + add_label(g_team_state.body, ::ui::i18n::tr("KeyId: --"), false, true); } else { - snprintf(line, sizeof(line), "KeyId: %u", (unsigned)g_team_state.security_round); + add_label(g_team_state.body, + ::ui::i18n::format("KeyId: %u", static_cast(g_team_state.security_round)).c_str(), + false, + true); } - add_label(g_team_state.body, line, false, true); if (g_team_state.security_round == 0) { - snprintf(line, sizeof(line), "Security: OK (Round --)"); + add_label(g_team_state.body, ::ui::i18n::tr("Security: OK (Round --)"), false, true); } else { - snprintf(line, sizeof(line), "Security: OK (Round %u)", (unsigned)g_team_state.security_round); + add_label( + g_team_state.body, + ::ui::i18n::format("Security: OK (Round %u)", static_cast(g_team_state.security_round)).c_str(), + false, + true); } - add_label(g_team_state.body, line, false, true); if (g_team_state.waiting_new_keys) { - add_label(g_team_state.body, "Waiting for new keys...", false, true); + add_label(g_team_state.body, ::ui::i18n::tr("Waiting for new keys..."), false, true); } - add_label(g_team_state.body, "Team Health", true, false); + add_label(g_team_state.body, ::ui::i18n::tr("Team Health"), true, false); std::string last_update = format_last_update(g_team_state.last_update_s); + const std::string leader_health = std::string("- ") + ::ui::i18n::tr("Leader online") + + "\n- " + last_update + + "\n- " + ::ui::i18n::tr("1 member stale"); +#if 0 std::string health = std::string("鈼?Leader online\n") + "鈼?" + last_update + "\n" + "鈼?1 member stale"; - add_label(g_team_state.body, health.c_str(), false, true); +#endif + add_label(g_team_state.body, leader_health.c_str(), false, true); g_team_state.action_btns[0] = create_action_button("View Team", handle_view_team); if (g_team_state.self_is_leader) @@ -2052,29 +2058,40 @@ void render_status_in_team() void render_team_home() { - update_top_bar_title(g_team_state.self_is_leader ? "Team 路 Leader" : "Team 路 Member"); + // Replaced immediately below with a localized title, but keep this call out of the active path. + + const std::string title = ::ui::i18n::format( + "Team / %s", + ::ui::i18n::tr(g_team_state.self_is_leader ? "Leader" : "Member")); + update_top_bar_title(title.c_str()); - char line[64]; std::string team_name = current_team_name(); - snprintf(line, sizeof(line), "Team: %s", team_name.c_str()); - add_label(g_team_state.body, line, true, false); - snprintf(line, sizeof(line), "Members: %u Online: %u", - (unsigned)g_team_state.members.size(), (unsigned)online_count()); - add_label(g_team_state.body, line, false, true); + add_label(g_team_state.body, ::ui::i18n::format("Team: %s", team_name.c_str()).c_str(), true, false); + add_label(g_team_state.body, + ::ui::i18n::format("Members: %u Online: %u", + static_cast(g_team_state.members.size()), + static_cast(online_count())) + .c_str(), + false, + true); if (g_team_state.security_round == 0) { - snprintf(line, sizeof(line), "Security Round: --"); + add_label(g_team_state.body, ::ui::i18n::tr("Security Round: --"), false, true); } else { - snprintf(line, sizeof(line), "Security Round: %u", (unsigned)g_team_state.security_round); + add_label(g_team_state.body, + ::ui::i18n::format("Security Round: %u", + static_cast(g_team_state.security_round)) + .c_str(), + false, + true); } - add_label(g_team_state.body, line, false, true); - add_label(g_team_state.body, "Members", true, false); + add_label(g_team_state.body, ::ui::i18n::tr("Members"), true, false); if (g_team_state.members.empty()) { - add_label(g_team_state.body, "No members yet", false, true); + add_label(g_team_state.body, ::ui::i18n::tr("No members yet"), false, true); } else { @@ -2093,7 +2110,7 @@ void render_team_home() for (const auto& m : g_team_state.members) { lv_obj_t* label = lv_label_create(row); - lv_label_set_text(label, m.name.c_str()); + ::ui::i18n::set_label_text_raw(label, m.name.c_str()); lv_label_set_long_mode(label, LV_LABEL_LONG_CLIP); lv_obj_set_width(label, LV_PCT(24)); lv_obj_set_style_bg_opa(label, LV_OPA_COVER, 0); @@ -2137,16 +2154,16 @@ void render_join_pending() { title = "Pairing (Member)"; } - update_top_bar_title(title); + update_top_bar_title(::ui::i18n::tr(title)); - add_label(g_team_state.body, "Pairing in progress", true, false); - add_label(g_team_state.body, "Keep devices within 5m", false, true); + add_label(g_team_state.body, ::ui::i18n::tr("Pairing in progress"), true, false); + add_label(g_team_state.body, ::ui::i18n::tr("Keep devices within 5m"), false, true); if (g_team_state.pairing_role == TeamPairingRole::Leader) { - add_label(g_team_state.body, "Members", true, false); + add_label(g_team_state.body, ::ui::i18n::tr("Members"), true, false); if (g_team_state.members.empty()) { - add_label(g_team_state.body, "No members yet", false, true); + add_label(g_team_state.body, ::ui::i18n::tr("No members yet"), false, true); } else { @@ -2165,7 +2182,7 @@ void render_join_pending() for (const auto& m : g_team_state.members) { lv_obj_t* label = lv_label_create(row); - lv_label_set_text(label, m.name.c_str()); + ::ui::i18n::set_label_text_raw(label, m.name.c_str()); lv_label_set_long_mode(label, LV_LABEL_LONG_CLIP); lv_obj_set_width(label, LV_PCT(24)); lv_obj_set_style_bg_opa(label, LV_OPA_COVER, 0); @@ -2187,13 +2204,13 @@ void render_join_pending() if (g_team_state.pairing_peer_id != 0) { std::string name = resolve_node_name(g_team_state.pairing_peer_id); - std::string paired = "Last paired: " + name; + std::string paired = ::ui::i18n::format("Last paired: %s", name.c_str()); add_label(g_team_state.body, paired.c_str(), false, true); } } if (!g_team_state.pairing_team_name.empty()) { - std::string line = "Target: " + g_team_state.pairing_team_name; + std::string line = ::ui::i18n::format("Target: %s", g_team_state.pairing_team_name.c_str()); add_label(g_team_state.body, line.c_str(), false, true); } @@ -2221,7 +2238,7 @@ void render_join_pending() default: break; } - add_label(g_team_state.body, state_line, false, true); + add_label(g_team_state.body, ::ui::i18n::tr(state_line), false, true); g_team_state.action_btns[0] = create_action_button("Cancel", handle_join_cancel); g_team_state.action_btns[1] = create_action_button("Retry", handle_join_retry); @@ -2231,11 +2248,11 @@ void render_join_pending() void render_members() { - update_top_bar_title("Members"); + update_top_bar_title(::ui::i18n::tr("Members")); if (g_team_state.members.empty()) { - add_label(g_team_state.body, "No members yet", false, true); + add_label(g_team_state.body, ::ui::i18n::tr("No members yet"), false, true); } for (size_t i = 0; i < g_team_state.members.size(); ++i) { @@ -2244,7 +2261,7 @@ void render_members() std::string left = std::string(dot) + m.name; if (m.leader) { - left += " (Leader)"; + left += " (" + std::string(::ui::i18n::tr("Leader")) + ")"; } lv_obj_t* item = create_list_item(left.c_str(), "Select"); lv_obj_set_user_data(item, (void*)(intptr_t)i); @@ -2263,19 +2280,23 @@ void render_member_detail() } const auto& member = g_team_state.members[g_team_state.selected_member_index]; - std::string title = "Member: " + member.name; + std::string title = ::ui::i18n::format("Member: %s", member.name.c_str()); update_top_bar_title(title.c_str()); std::string status = member.online ? "Online" : format_last_seen(member.last_seen_s); - std::string role = member.leader ? "Leader" : "Member"; char line[64]; - snprintf(line, sizeof(line), "Status: %s", status.c_str()); + snprintf(line, sizeof(line), "%s", ::ui::i18n::format("Status: %s", ::ui::i18n::tr(status.c_str())).c_str()); add_label(g_team_state.body, line, true, false); - snprintf(line, sizeof(line), "Role: %s", role.c_str()); + snprintf(line, + sizeof(line), + "%s", + ::ui::i18n::format("Role: %s", ::ui::i18n::tr(member.leader ? "Leader" : "Member")).c_str()); add_label(g_team_state.body, line, false, true); - add_label(g_team_state.body, "Device: Pager", false, true); - add_label(g_team_state.body, "Capability:", true, false); - add_label(g_team_state.body, "- Position\n- Waypoint", false, true); + add_label(g_team_state.body, ::ui::i18n::format("Device: %s", "Pager").c_str(), false, true); + add_label(g_team_state.body, ::ui::i18n::tr("Capability:"), true, false); + const std::string capabilities = std::string("- ") + ::ui::i18n::tr("Position") + + "\n- " + ::ui::i18n::tr("Waypoint"); + add_label(g_team_state.body, capabilities.c_str(), false, true); g_team_state.action_btns[0] = create_action_button("Kick", handle_kick); g_team_state.action_btns[1] = create_action_button("Transfer Leader", handle_transfer_leader); @@ -2291,21 +2312,19 @@ void render_member_detail() void render_kick_confirm() { - update_top_bar_title("Kick Member"); + update_top_bar_title(::ui::i18n::tr("Kick Member")); - std::string name = "member"; + std::string name = ::ui::i18n::tr("member"); if (g_team_state.selected_member_index >= 0 && g_team_state.selected_member_index < (int)g_team_state.members.size()) { name = g_team_state.members[g_team_state.selected_member_index].name; } - std::string line = "Remove " + name + " from team?"; + std::string line = ::ui::i18n::format("Remove %s from team?", name.c_str()); add_label(g_team_state.body, line.c_str(), true, false); add_label(g_team_state.body, - "This will update the security round.\n" - "The member will no longer receive\n" - "team messages or waypoints.", + ::ui::i18n::tr("This will update the security round.\nThe member will no longer receive\nteam messages or waypoints."), false, true); g_team_state.action_btns[0] = create_action_button("Cancel", handle_kick_cancel); @@ -2316,10 +2335,10 @@ void render_kick_confirm() void render_kicked_out() { - update_top_bar_title("Team"); + update_top_bar_title(::ui::i18n::tr("Team")); - add_label(g_team_state.body, "You are no longer in this team", true, false); - add_label(g_team_state.body, "Access to team data revoked", false, true); + add_label(g_team_state.body, ::ui::i18n::tr("You are no longer in this team"), true, false); + add_label(g_team_state.body, ::ui::i18n::tr("Access to team data revoked"), false, true); g_team_state.action_btns[0] = create_action_button("Pair Again", handle_kicked_join); g_team_state.action_btns[1] = create_action_button("OK", handle_kicked_ok); @@ -2448,7 +2467,7 @@ void team_page_create(lv_obj_t* parent) ::ui::widgets::TopBarConfig cfg; cfg.height = ::ui::page_profile::current().top_bar_height; ::ui::widgets::top_bar_init(g_team_state.top_bar_widget, g_team_state.header, cfg); - update_top_bar_title("Team"); + update_top_bar_title(::ui::i18n::tr("Team")); ::ui::widgets::top_bar_set_back_callback(g_team_state.top_bar_widget, top_bar_back, nullptr); ui_update_top_bar_battery(g_team_state.top_bar_widget); diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp index 9fc31c00..9f6a0201 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp @@ -5,6 +5,7 @@ #include "platform/ui/route_storage.h" #include "platform/ui/tracker_runtime.h" #include "ui/assets/fonts/fonts.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/tracker/tracker_page_input.h" #include "ui/screens/tracker/tracker_page_layout.h" @@ -443,7 +444,7 @@ lv_obj_t* create_list_item_button(const std::string& text, intptr_t user_data, b void append_back_list_item() { - create_list_item_button("Back", kBackListItemUserData, false, false); + create_list_item_button(::ui::i18n::tr("Back"), kBackListItemUserData, false, false); } void sync_list_item_checked_states() @@ -574,7 +575,8 @@ void update_record_status() const bool recording = platform::ui::tracker::is_recording(); if (state.mode == TrackerPageState::Mode::Record) { - lv_label_set_text(state.status_label, recording ? "Recording" : "Stopped"); + lv_label_set_text(state.status_label, + ::ui::i18n::tr(recording ? "Recording" : "Stopped")); } } @@ -584,7 +586,7 @@ void update_start_stop_button() const bool recording = platform::ui::tracker::is_recording(); if (state.start_stop_label) { - lv_label_set_text(state.start_stop_label, recording ? "Stop" : "New"); + lv_label_set_text(state.start_stop_label, ::ui::i18n::tr(recording ? "Stop" : "New")); } } @@ -718,11 +720,11 @@ void refresh_record_list() return; } s_record_names.clear(); - s_record_empty_text = "No tracks yet"; + s_record_empty_text = ::ui::i18n::tr("No tracks yet"); if (!platform::ui::device::sd_ready()) { - s_record_empty_text = "No SD Card"; + s_record_empty_text = ::ui::i18n::tr("No SD Card"); if (state.mode == TrackerPageState::Mode::Record) { update_record_page(); @@ -755,15 +757,17 @@ void update_route_status() { if (!state.active_route.empty()) { - lv_label_set_text_fmt(state.status_label, "Active: %s", state.active_route.c_str()); + const std::string text = ::ui::i18n::format("Active: %s", state.active_route.c_str()); + lv_label_set_text(state.status_label, text.c_str()); } else if (!state.selected_route.empty()) { - lv_label_set_text_fmt(state.status_label, "Selected: %s", state.selected_route.c_str()); + const std::string text = ::ui::i18n::format("Selected: %s", state.selected_route.c_str()); + lv_label_set_text(state.status_label, text.c_str()); } else { - lv_label_set_text(state.status_label, "No route selected"); + lv_label_set_text(state.status_label, ::ui::i18n::tr("No route selected")); } } } @@ -815,11 +819,11 @@ void refresh_route_list() s_route_names.clear(); state.selected_route_idx = -1; state.selected_route.clear(); - s_route_empty_text = "No KML routes"; + s_route_empty_text = ::ui::i18n::tr("No KML routes"); if (!platform::ui::device::sd_ready()) { - s_route_empty_text = "No SD Card"; + s_route_empty_text = ::ui::i18n::tr("No SD Card"); if (state.mode == TrackerPageState::Mode::Route) { update_route_page(); @@ -830,7 +834,7 @@ void refresh_route_list() const bool route_dir_ready = platform::ui::route_storage::list_routes(s_route_names, 64); if (!route_dir_ready) { - s_route_empty_text = "No routes folder"; + s_route_empty_text = ::ui::i18n::tr("No routes folder"); if (state.mode == TrackerPageState::Mode::Route) { update_route_page(); @@ -972,7 +976,7 @@ lv_obj_t* create_action_menu_button(lv_obj_t* parent, const char* text) lv_obj_set_size(btn, LV_PCT(100), action_menu_button_height()); lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, text); + lv_label_set_text(label, ::ui::i18n::tr(text)); lv_obj_center(label); apply_action_button(btn, label); return btn; @@ -1084,7 +1088,7 @@ void open_action_menu_modal() lv_obj_t* title_label = lv_label_create(win); const char* title = (state.mode == TrackerPageState::Mode::Record) ? "Track Actions" : "Route Actions"; - lv_label_set_text(title_label, title); + lv_label_set_text(title_label, ::ui::i18n::tr(title)); lv_obj_set_width(title_label, LV_PCT(100)); lv_label_set_long_mode(title_label, LV_LABEL_LONG_DOT); lv_obj_set_style_text_align(title_label, LV_TEXT_ALIGN_CENTER, 0); @@ -1158,7 +1162,7 @@ void on_list_item_focused(lv_event_t* e) { if (lv_obj_t* label = lv_obj_get_child(target, -1)) { - lv_label_set_text(label, "Back"); + lv_label_set_text(label, ::ui::i18n::tr("Back")); lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); } return; @@ -1201,7 +1205,7 @@ void on_list_item_defocused(lv_event_t* e) { if (lv_obj_t* label = lv_obj_get_child(target, -1)) { - lv_label_set_text(label, "Back"); + lv_label_set_text(label, ::ui::i18n::tr("Back")); lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); } return; @@ -1274,7 +1278,7 @@ void on_route_load_clicked(lv_event_t*) { if (state.status_label) { - lv_label_set_text(state.status_label, "Select a route"); + lv_label_set_text(state.status_label, ::ui::i18n::tr("Select a route")); } return; } @@ -1332,7 +1336,7 @@ void on_del_confirm_clicked(lv_event_t*) { if (state.status_label) { - lv_label_set_text(state.status_label, "Stop recording first"); + lv_label_set_text(state.status_label, ::ui::i18n::tr("Stop recording first")); } modal_close(state.del_confirm_modal); return; @@ -1378,7 +1382,7 @@ void on_del_confirm_clicked(lv_event_t*) if (!ok && state.status_label) { - lv_label_set_text(state.status_label, "Delete failed"); + lv_label_set_text(state.status_label, ::ui::i18n::tr("Delete failed")); } modal_close(state.del_confirm_modal); } @@ -1403,7 +1407,7 @@ void open_delete_confirm_modal() { if (state.status_label) { - lv_label_set_text(state.status_label, "Select a track"); + lv_label_set_text(state.status_label, ::ui::i18n::tr("Select a track")); } return; } @@ -1420,7 +1424,7 @@ void open_delete_confirm_modal() { if (state.status_label) { - lv_label_set_text(state.status_label, "Select a route"); + lv_label_set_text(state.status_label, ::ui::i18n::tr("Select a route")); } return; } @@ -1434,10 +1438,9 @@ void open_delete_confirm_modal() state.del_confirm_modal = create_modal_root(280, 140); lv_obj_t* win = lv_obj_get_child(state.del_confirm_modal, 0); - char msg[80]; - snprintf(msg, sizeof(msg), "Delete %s?", state.pending_delete_name.c_str()); + const std::string msg = ::ui::i18n::format("Delete %s?", state.pending_delete_name.c_str()); lv_obj_t* label = lv_label_create(win); - lv_label_set_text(label, msg); + lv_label_set_text(label, msg.c_str()); lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 10); lv_obj_t* btn_row = lv_obj_create(win); @@ -1454,7 +1457,7 @@ void open_delete_confirm_modal() lv_obj_t* confirm_btn = lv_btn_create(btn_row); lv_obj_set_size(confirm_btn, modal_button_width(), action_menu_button_height()); lv_obj_t* confirm_label = lv_label_create(confirm_btn); - lv_label_set_text(confirm_label, "Confirm"); + lv_label_set_text(confirm_label, ::ui::i18n::tr("Confirm")); lv_obj_center(confirm_label); apply_action_button(confirm_btn, confirm_label); lv_obj_add_event_cb(confirm_btn, on_del_confirm_clicked, LV_EVENT_CLICKED, nullptr); @@ -1462,7 +1465,7 @@ void open_delete_confirm_modal() lv_obj_t* cancel_btn = lv_btn_create(btn_row); lv_obj_set_size(cancel_btn, modal_button_width(), action_menu_button_height()); lv_obj_t* cancel_label = lv_label_create(cancel_btn); - lv_label_set_text(cancel_label, "Cancel"); + lv_label_set_text(cancel_label, ::ui::i18n::tr("Cancel")); lv_obj_center(cancel_label); apply_action_button(cancel_btn, cancel_label); lv_obj_add_event_cb(cancel_btn, on_del_cancel_clicked, LV_EVENT_CLICKED, nullptr); @@ -1510,18 +1513,18 @@ void init_page(lv_obj_t* parent) lv_obj_set_width(state.mode_record_btn, LV_PCT(100)); lv_obj_set_height(state.mode_record_btn, filter_button_height()); state.mode_record_label = lv_label_create(state.mode_record_btn); - lv_label_set_text(state.mode_record_label, "Record"); + lv_label_set_text(state.mode_record_label, ::ui::i18n::tr("Record")); lv_obj_center(state.mode_record_label); state.mode_route_btn = lv_btn_create(state.filter_panel); lv_obj_set_width(state.mode_route_btn, LV_PCT(100)); lv_obj_set_height(state.mode_route_btn, filter_button_height()); state.mode_route_label = lv_label_create(state.mode_route_btn); - lv_label_set_text(state.mode_route_label, "Route"); + lv_label_set_text(state.mode_route_label, ::ui::i18n::tr("Route")); lv_obj_center(state.mode_route_label); state.status_label = lv_label_create(state.list_panel); - lv_label_set_text(state.status_label, "Stopped"); + lv_label_set_text(state.status_label, ::ui::i18n::tr("Stopped")); lv_obj_set_width(state.status_label, LV_PCT(100)); lv_obj_set_style_text_font(state.status_label, &lv_font_noto_cjk_16_2bpp, 0); lv_obj_set_style_text_color(state.status_label, lv_color_hex(kPanelTextMuted), 0); @@ -1543,7 +1546,7 @@ void init_page(lv_obj_t* parent) ::ui::widgets::TopBarConfig cfg; cfg.height = page_profile().top_bar_height; ::ui::widgets::top_bar_init(state.top_bar, state.header, cfg); - ::ui::widgets::top_bar_set_title(state.top_bar, "Tracker"); + ::ui::widgets::top_bar_set_title(state.top_bar, ::ui::i18n::tr("Tracker")); ::ui::widgets::top_bar_set_back_callback(state.top_bar, on_back, nullptr); lv_obj_add_event_cb(state.mode_record_btn, on_mode_record_clicked, LV_EVENT_CLICKED, nullptr); diff --git a/modules/ui_shared/src/ui/screens/usb/usb_page_runtime.cpp b/modules/ui_shared/src/ui/screens/usb/usb_page_runtime.cpp index 472d0cad..d41ec5e1 100644 --- a/modules/ui_shared/src/ui/screens/usb/usb_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/usb/usb_page_runtime.cpp @@ -5,6 +5,7 @@ #include "platform/ui/device_runtime.h" #include "platform/ui/usb_support_runtime.h" #include "ui/app_runtime.h" +#include "ui/localization.h" #include "ui/ui_common.h" #include "ui/widgets/top_bar.h" @@ -80,7 +81,7 @@ void show_loading(const char* message) lv_obj_clear_flag(s_loading_box, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* label = lv_label_create(s_loading_box); - lv_label_set_text(label, message ? message : "Loading..."); + ::ui::i18n::set_label_text(label, message ? message : "Loading..."); lv_obj_set_style_text_color(label, lv_color_hex(0x3A2A1A), LV_PART_MAIN); lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_center(label); @@ -95,7 +96,7 @@ void update_status_label() const platform::ui::usb_support::Status st = platform::ui::usb_support::get_status(); const char* message = (st.message && st.message[0] != '\0') ? st.message : (st.active ? "USB Active" : "USB Idle"); - lv_label_set_text(s_status_label, message); + ::ui::i18n::set_label_text(s_status_label, message); } void finish_exit_cb(lv_timer_t* timer) @@ -140,7 +141,7 @@ void begin_exit() } s_exit_started = true; - show_loading("Exiting USB..."); + show_loading(::ui::i18n::tr("Exiting USB...")); if (s_start_timer) { lv_timer_del(s_start_timer); @@ -239,7 +240,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_add_event_cb(s_root, root_key_event_cb, LV_EVENT_KEY, nullptr); ::ui::widgets::top_bar_init(s_top_bar, s_root); - ::ui::widgets::top_bar_set_title(s_top_bar, "USB Mass Storage"); + ::ui::widgets::top_bar_set_title(s_top_bar, ::ui::i18n::tr("USB Mass Storage")); ::ui::widgets::top_bar_set_back_callback(s_top_bar, back_event_handler, nullptr); if (s_top_bar.back_btn) { @@ -271,7 +272,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) if (!platform::ui::device::card_ready()) { lv_obj_t* error_label = lv_label_create(s_content); - lv_label_set_text(error_label, "SD Card Not Found\nPlease insert SD card"); + ::ui::i18n::set_label_text(error_label, "SD Card Not Found\nPlease insert SD card"); lv_obj_center(error_label); lv_obj_set_style_text_font(error_label, &lv_font_montserrat_18, LV_PART_MAIN); lv_obj_set_style_text_color(error_label, lv_color_hex(0xCC0000), LV_PART_MAIN); @@ -279,14 +280,14 @@ void enter(const shell::Host* host, lv_obj_t* parent) } s_status_label = lv_label_create(s_content); - lv_label_set_text(s_status_label, "Initializing..."); + ::ui::i18n::set_label_text(s_status_label, "Initializing..."); lv_obj_center(s_status_label); lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_18, LV_PART_MAIN); lv_obj_set_style_text_color(s_status_label, lv_color_hex(0x3A2A1A), LV_PART_MAIN); lv_obj_set_style_text_align(s_status_label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_t* info_label = lv_label_create(s_content); - lv_label_set_text(info_label, "Press Back to exit USB mode"); + ::ui::i18n::set_label_text(info_label, "Press Back to exit USB mode"); lv_obj_align(info_label, LV_ALIGN_BOTTOM_MID, 0, -20); lv_obj_set_style_text_font(info_label, &lv_font_montserrat_14, LV_PART_MAIN); lv_obj_set_style_text_color(info_label, lv_color_hex(0x6A5646), LV_PART_MAIN); diff --git a/modules/ui_shared/src/ui/screens/walkie_talkie/walkie_talkie_page_runtime.cpp b/modules/ui_shared/src/ui/screens/walkie_talkie/walkie_talkie_page_runtime.cpp index b07f891b..9775060b 100644 --- a/modules/ui_shared/src/ui/screens/walkie_talkie/walkie_talkie_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/walkie_talkie/walkie_talkie_page_runtime.cpp @@ -6,6 +6,7 @@ #include "platform/ui/screen_runtime.h" #include "platform/ui/walkie_runtime.h" #include "ui/app_runtime.h" +#include "ui/localization.h" #include "ui/ui_common.h" #include "ui/widgets/system_notification.h" #include "ui/widgets/top_bar.h" @@ -97,7 +98,7 @@ void refresh_cb(lv_timer_t*) ui_update_top_bar_battery(s_top_bar); if (s_mode_label) { - lv_label_set_text(s_mode_label, st.tx ? "TALK" : "LISTEN"); + ::ui::i18n::set_label_text(s_mode_label, st.tx ? "TALK" : "LISTEN"); } update_vu(s_left_fill, st.tx ? st.tx_level : st.rx_level); update_vu(s_right_fill, st.tx ? st.tx_level : st.rx_level); @@ -111,8 +112,8 @@ void refresh_cb(lv_timer_t*) { char buf[24]; int vol = platform::ui::walkie::volume(); - snprintf(buf, sizeof(buf), "VOL %d", vol); - lv_label_set_text(s_volume_label, buf); + snprintf(buf, sizeof(buf), "%s", ::ui::i18n::format("VOL %d", vol).c_str()); + ::ui::i18n::set_label_text_raw(s_volume_label, buf); } } @@ -131,22 +132,22 @@ void set_freq_text(float freq_mhz) { snprintf(buf, sizeof(buf), "%.3f MHz", static_cast(freq_mhz)); } - lv_label_set_text(s_freq_label, buf); + ::ui::i18n::set_label_text_raw(s_freq_label, buf); } void set_error_text(const char* message) { if (s_freq_label) { - lv_label_set_text(s_freq_label, "Walkie Talkie"); + ::ui::i18n::set_label_text(s_freq_label, "Walkie Talkie"); } if (s_mod_label) { - lv_label_set_text(s_mod_label, message ? message : "Walkie not available"); + ::ui::i18n::set_label_text(s_mod_label, message ? message : "Walkie not available"); } if (s_mode_label) { - lv_label_set_text(s_mode_label, "Press Back"); + ::ui::i18n::set_label_text(s_mode_label, "Press Back"); } update_vu(s_left_fill, 0); update_vu(s_right_fill, 0); @@ -194,7 +195,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_add_event_cb(s_root, root_key_event_cb, LV_EVENT_KEY, nullptr); ::ui::widgets::top_bar_init(s_top_bar, s_root); - ::ui::widgets::top_bar_set_title(s_top_bar, "Walkie Talkie"); + ::ui::widgets::top_bar_set_title(s_top_bar, ::ui::i18n::tr("Walkie Talkie")); ::ui::widgets::top_bar_set_back_callback(s_top_bar, on_back, nullptr); if (s_top_bar.back_btn) { @@ -234,17 +235,17 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_clear_flag(stack, LV_OBJ_FLAG_SCROLLABLE); s_freq_label = lv_label_create(stack); - lv_label_set_text(s_freq_label, "--.- MHz"); + ::ui::i18n::set_label_text_raw(s_freq_label, "--.- MHz"); lv_obj_set_style_text_font(s_freq_label, &lv_font_montserrat_28, 0); lv_obj_set_style_text_align(s_freq_label, LV_TEXT_ALIGN_CENTER, 0); s_mod_label = lv_label_create(stack); - lv_label_set_text(s_mod_label, "FSK Voice"); + ::ui::i18n::set_label_text(s_mod_label, "FSK Voice"); lv_obj_set_style_text_font(s_mod_label, &lv_font_montserrat_14, 0); lv_obj_set_style_text_align(s_mod_label, LV_TEXT_ALIGN_CENTER, 0); s_mode_label = lv_label_create(stack); - lv_label_set_text(s_mode_label, "LISTEN"); + ::ui::i18n::set_label_text(s_mode_label, "LISTEN"); lv_obj_set_style_text_font(s_mode_label, &lv_font_montserrat_18, 0); lv_obj_set_style_text_align(s_mode_label, LV_TEXT_ALIGN_CENTER, 0); @@ -295,7 +296,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_clear_flag(vol_container, LV_OBJ_FLAG_SCROLLABLE); s_volume_label = lv_label_create(vol_container); - lv_label_set_text(s_volume_label, "VOL 80"); + ::ui::i18n::set_label_text(s_volume_label, "VOL 80"); lv_obj_set_style_text_font(s_volume_label, &lv_font_montserrat_14, 0); lv_obj_set_style_text_align(s_volume_label, LV_TEXT_ALIGN_CENTER, 0); diff --git a/modules/ui_shared/src/ui/watch_face.cpp b/modules/ui_shared/src/ui/watch_face.cpp index 8ccc1ee8..ef074cb2 100644 --- a/modules/ui_shared/src/ui/watch_face.cpp +++ b/modules/ui_shared/src/ui/watch_face.cpp @@ -4,6 +4,9 @@ #include #include #include +#include + +#include "ui/localization.h" namespace { @@ -207,7 +210,7 @@ void watch_face_create(lv_obj_t* parent) lv_obj_set_width(s_ui.node_id_label, LV_SIZE_CONTENT); lv_obj_set_style_text_align(s_ui.node_id_label, LV_TEXT_ALIGN_LEFT, 0); lv_obj_align(s_ui.node_id_label, LV_ALIGN_TOP_LEFT, 10, 10); - lv_label_set_text(s_ui.node_id_label, "ID: -"); + ::ui::i18n::set_label_text(s_ui.node_id_label, "ID: -"); s_ui.battery_label = lv_label_create(s_ui.root); apply_label_style(s_ui.battery_label, &lv_font_montserrat_14, kColorTextDim); @@ -310,16 +313,16 @@ void watch_face_set_node_id(uint32_t node_id) { return; } - char buf[24]; if (node_id != 0) { - snprintf(buf, sizeof(buf), "ID: !%08lX", static_cast(node_id)); + const std::string text = + ::ui::i18n::format("ID: !%08lX", static_cast(node_id)); + lv_label_set_text(s_ui.node_id_label, text.c_str()); } else { - snprintf(buf, sizeof(buf), "ID: -"); + ::ui::i18n::set_label_text(s_ui.node_id_label, "ID: -"); } - lv_label_set_text(s_ui.node_id_label, buf); } void watch_face_set_placeholder() diff --git a/modules/ui_shared/src/ui/widgets/ble_pairing_popup.cpp b/modules/ui_shared/src/ui/widgets/ble_pairing_popup.cpp index b59a3f1a..6a90d855 100644 --- a/modules/ui_shared/src/ui/widgets/ble_pairing_popup.cpp +++ b/modules/ui_shared/src/ui/widgets/ble_pairing_popup.cpp @@ -4,6 +4,9 @@ #include #include +#include "ui/assets/fonts/font_utils.h" +#include "ui/localization.h" + namespace ui { @@ -77,7 +80,7 @@ void BlePairingPopup::ensureCreated() lv_obj_set_style_pad_row(panel_, 8, 0); title_label_ = lv_label_create(panel_); - lv_label_set_text(title_label_, "Bluetooth Pairing"); + ::ui::i18n::set_label_text(title_label_, "Bluetooth Pairing"); lv_obj_set_style_text_color(title_label_, lv_color_hex(kColorText), 0); lv_obj_set_style_text_font(title_label_, &lv_font_montserrat_20, 0); lv_obj_set_style_text_align(title_label_, LV_TEXT_ALIGN_CENTER, 0); @@ -95,7 +98,7 @@ void BlePairingPopup::ensureCreated() lv_obj_set_style_radius(mode_label_, 10, 0); hint_label_ = lv_label_create(panel_); - lv_label_set_text(hint_label_, "Enter this 6-digit PIN on your phone"); + ::ui::i18n::set_label_text(hint_label_, "Enter this 6-digit PIN on your phone"); lv_obj_set_style_text_color(hint_label_, lv_color_hex(kColorTextDim), 0); lv_obj_set_style_text_font(hint_label_, &lv_font_montserrat_18, 0); lv_obj_set_style_text_align(hint_label_, LV_TEXT_ALIGN_CENTER, 0); @@ -114,7 +117,7 @@ void BlePairingPopup::ensureCreated() lv_obj_set_width(device_label_, LV_PCT(100)); footer_label_ = lv_label_create(panel_); - lv_label_set_text(footer_label_, "Closes automatically after pairing"); + ::ui::i18n::set_label_text(footer_label_, "Closes automatically after pairing"); lv_obj_set_style_text_color(footer_label_, lv_color_hex(kColorOk), 0); lv_obj_set_style_text_font(footer_label_, &lv_font_montserrat_18, 0); lv_obj_set_style_text_align(footer_label_, LV_TEXT_ALIGN_CENTER, 0); @@ -179,19 +182,14 @@ void BlePairingPopup::show(uint32_t passkey, bool is_fixed_pin, const char* devi static_cast(passkey / 1000U), static_cast(passkey % 1000U)); lv_label_set_text(pin_label_, pin_buf); - lv_label_set_text(mode_label_, is_fixed_pin ? "Fixed PIN" : "Random PIN"); + ::ui::i18n::set_label_text(mode_label_, is_fixed_pin ? "Fixed PIN" : "Random PIN"); lv_obj_set_style_bg_color(mode_label_, lv_color_hex(is_fixed_pin ? kColorOk : kColorInfo), 0); - std::string name_text = "Device: "; - if (device_name && device_name[0] != '\0') - { - name_text += device_name; - } - else - { - name_text += "Meshtastic"; - } + std::string name_text = ::ui::i18n::format( + "Device: %s", + (device_name && device_name[0] != '\0') ? device_name : "Meshtastic"); lv_label_set_text(device_label_, name_text.c_str()); + ::ui::fonts::apply_localized_font(device_label_, name_text.c_str(), &lv_font_montserrat_18); lv_obj_clear_flag(overlay_, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(overlay_); diff --git a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp index c28f1507..7b0f0d01 100644 --- a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp +++ b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp @@ -7,6 +7,7 @@ #include "ui/assets/fonts/font_utils.h" #include "ui/assets/fonts/fonts.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include @@ -331,7 +332,7 @@ void ImeWidget::init_touch_ui(lv_obj_t* parent) LV_EVENT_KEY, this); candidates_label_ = lv_label_create(top_row_); - lv_label_set_text(candidates_label_, "English keyboard"); + ::ui::i18n::set_label_text(candidates_label_, "English keyboard"); lv_obj_set_flex_grow(candidates_label_, 1); lv_obj_set_style_text_align(candidates_label_, LV_TEXT_ALIGN_LEFT, 0); lv_obj_set_style_text_font(candidates_label_, &lv_font_noto_cjk_16_2bpp, 0); @@ -798,7 +799,7 @@ void ImeWidget::refresh_labels() #if UI_SHARED_TOUCH_IME_ENABLED if (touch_keyboard_enabled_) { - lv_label_set_text(candidates_label_, "English keyboard"); + ::ui::i18n::set_label_text(candidates_label_, "English keyboard"); refresh_touch_keyboard(); refresh_touch_candidates(); return; @@ -814,7 +815,7 @@ void ImeWidget::refresh_labels() #if UI_SHARED_TOUCH_IME_ENABLED if (touch_keyboard_enabled_) { - lv_label_set_text(candidates_label_, "Number keyboard"); + ::ui::i18n::set_label_text(candidates_label_, "Number keyboard"); refresh_touch_keyboard(); refresh_touch_candidates(); return; @@ -828,7 +829,9 @@ void ImeWidget::refresh_labels() #if UI_SHARED_TOUCH_IME_ENABLED if (touch_keyboard_enabled_) { - std::string hint = ime_.hasBuffer() ? (std::string("Pinyin: ") + ime_.buffer()) : std::string("Pinyin keyboard"); + std::string hint = ime_.hasBuffer() + ? ::ui::i18n::format("Pinyin: %s", ime_.buffer().c_str()) + : std::string(::ui::i18n::tr("Pinyin keyboard")); lv_label_set_text(candidates_label_, hint.c_str()); refresh_touch_keyboard(); refresh_touch_candidates(); diff --git a/modules/ui_shared/src/ui/widgets/system_notification.cpp b/modules/ui_shared/src/ui/widgets/system_notification.cpp index e6cd0fe7..fdb3cb04 100644 --- a/modules/ui_shared/src/ui/widgets/system_notification.cpp +++ b/modules/ui_shared/src/ui/widgets/system_notification.cpp @@ -5,7 +5,9 @@ #include "ui/widgets/system_notification.h" +#include "ui/assets/fonts/font_utils.h" #include "ui/assets/fonts/fonts.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include @@ -84,21 +86,27 @@ void SystemNotification::show(const char* text, uint32_t duration_ms) init(); } - // Truncate text to 15 characters - char truncated[32]; - size_t len = strlen(text); + const char* localized = ::ui::i18n::tr(text ? text : ""); + const bool has_non_ascii = ::ui::fonts::utf8_has_non_ascii(localized); + + // Keep the historical short-toast behavior for ASCII text, but avoid + // byte-splitting UTF-8 strings. + char truncated[96]; + size_t len = strlen(localized); const size_t max_chars = ::ui::page_profile::current().large_touch_hitbox ? 30 : 15; - if (len > max_chars) + if (!has_non_ascii && len > max_chars) { - strncpy(truncated, text, max_chars); + strncpy(truncated, localized, max_chars); truncated[max_chars] = '\0'; } else { - strcpy(truncated, text); + strncpy(truncated, localized, sizeof(truncated) - 1); + truncated[sizeof(truncated) - 1] = '\0'; } lv_label_set_text(label_, truncated); + ::ui::fonts::apply_localized_font(label_, truncated, &lv_font_noto_cjk_16_2bpp); // Cancel existing timer if any if (hide_timer_) diff --git a/modules/ui_shared/src/ui/widgets/top_bar.cpp b/modules/ui_shared/src/ui/widgets/top_bar.cpp index e6ef0dd1..913c54bd 100644 --- a/modules/ui_shared/src/ui/widgets/top_bar.cpp +++ b/modules/ui_shared/src/ui/widgets/top_bar.cpp @@ -8,6 +8,7 @@ #include #include +#include "ui/assets/fonts/font_utils.h" #include "ui/page/page_profile.h" #include "ui/ui_theme.h" @@ -161,6 +162,8 @@ void top_bar_set_title(TopBar& bar, const char* title) } lv_label_set_text(bar.title_label, title); + ::ui::fonts::apply_localized_font( + bar.title_label, title, resolve_top_bar_font(lv_obj_get_height(bar.container))); } void top_bar_set_right_text(TopBar& bar, const char* text) @@ -177,6 +180,8 @@ void top_bar_set_right_text(TopBar& bar, const char* text) } lv_label_set_text(bar.right_label, text); + ::ui::fonts::apply_localized_font( + bar.right_label, text, resolve_top_bar_font(lv_obj_get_height(bar.container))); } void top_bar_set_back_callback(TopBar& bar, void (*cb)(void*), void* user_data) diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/app_tasks.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_tasks.h index 96682591..3e97a8d3 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/app_tasks.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_tasks.h @@ -84,6 +84,16 @@ class AppTasks return radio_tasks_paused_; } + /** + * @brief Record whether the shared radio receive path is currently armed. + */ + static void setRadioReceiveActive(bool active); + + /** + * @brief Ask the radio task to re-arm RX on its next poll cycle. + */ + static void requestRadioReceiveRestart(); + private: static QueueHandle_t radio_tx_queue_; static QueueHandle_t radio_rx_queue_; @@ -93,6 +103,8 @@ class AppTasks static LoraBoard* board_; static chat::IMeshAdapter* adapter_; static bool radio_tasks_paused_; + static volatile bool radio_receive_active_; + static volatile bool radio_receive_restart_pending_; }; } // namespace app diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h index 9b7e8eef..953eb32f 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h @@ -9,6 +9,7 @@ #include "chat/infra/lxmf/lxmf_wire.h" #include "chat/ports/i_mesh_adapter.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h" #include "platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h" #include @@ -43,160 +44,22 @@ class LxmfAdapter : public IMeshAdapter void setLastRxStats(float rssi, float snr) override; private: - struct PeerInfo - { - uint32_t node_id = 0; - uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; - uint8_t identity_hash[reticulum::kTruncatedHashSize] = {}; - uint8_t enc_pub[LxmfIdentity::kEncPubKeySize] = {}; - uint8_t sig_pub[LxmfIdentity::kSigPubKeySize] = {}; - char display_name[32] = {}; - uint32_t last_seen_s = 0; - uint32_t last_path_request_ms = 0; - }; - - struct PathEntry - { - uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; - uint8_t next_hop_transport[reticulum::kTruncatedHashSize] = {}; - uint8_t cached_packet_hash[reticulum::kFullHashSize] = {}; - uint8_t cached_announce[reticulum::kReticulumMtu] = {}; - size_t cached_announce_len = 0; - uint8_t hops = 0; - uint32_t last_seen_s = 0; - bool direct = false; - }; - - struct PacketFilterEntry - { - uint8_t packet_hash[reticulum::kFullHashSize] = {}; - uint32_t seen_ms = 0; - }; - - struct ReverseEntry - { - uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; - uint8_t expected_hops = 0; - uint32_t created_ms = 0; - }; - - struct LinkRelayEntry - { - uint8_t link_id[reticulum::kTruncatedHashSize] = {}; - uint8_t initiator_hops = 0; - uint8_t responder_hops = 0; - uint32_t last_seen_ms = 0; - }; - - enum class LocalDestinationKind : uint8_t - { - Delivery = 0, - Propagation = 1 - }; - - enum class LinkState : uint8_t - { - Pending = 0, - Handshake = 1, - Active = 2, - Closed = 3 - }; - - struct LinkPendingRequest - { - std::vector request_id; - uint32_t created_ms = 0; - bool awaiting_resource = false; - bool response_ready = false; - std::vector response; - }; - - struct LinkResourceTransfer - { - uint8_t resource_hash[reticulum::kFullHashSize] = {}; - uint8_t random_hash[4] = {}; - uint8_t original_hash[reticulum::kFullHashSize] = {}; - uint8_t expected_proof[reticulum::kFullHashSize] = {}; - std::vector request_id; - std::vector hashmap; - std::vector> map_hashes; - std::vector map_hash_known; - std::vector> parts; - std::vector received_bitmap; - uint32_t data_size = 0; - uint32_t transfer_size = 0; - uint32_t part_count = 0; - uint32_t hashmap_height = 0; - uint32_t next_request_index = 0; - uint32_t window_size = 4; - uint32_t created_ms = 0; - uint32_t last_activity_ms = 0; - uint8_t flags = 0; - bool incoming = true; - bool encrypted = false; - bool compressed = false; - bool has_metadata = false; - bool waiting_for_hashmap = false; - bool complete = false; - bool waiting_for_proof = false; - int32_t consecutive_complete_index = -1; - }; - - struct LinkSession - { - uint8_t link_id[reticulum::kTruncatedHashSize] = {}; - uint8_t remote_destination_hash[reticulum::kTruncatedHashSize] = {}; - uint8_t remote_identity_hash[reticulum::kTruncatedHashSize] = {}; - uint8_t local_enc_pub[LxmfIdentity::kEncPubKeySize] = {}; - uint8_t local_enc_priv[LxmfIdentity::kEncPrivKeySize] = {}; - uint8_t local_sig_pub[LxmfIdentity::kSigPubKeySize] = {}; - uint8_t local_sig_priv[LxmfIdentity::kSigPrivKeySize] = {}; - uint8_t peer_enc_pub[LxmfIdentity::kEncPubKeySize] = {}; - uint8_t peer_link_sig_pub[LxmfIdentity::kSigPubKeySize] = {}; - uint8_t peer_identity_sig_pub[LxmfIdentity::kSigPubKeySize] = {}; - uint8_t derived_key[reticulum::kDerivedTokenKeySize] = {}; - uint32_t created_ms = 0; - uint32_t request_ms = 0; - uint32_t last_inbound_ms = 0; - uint32_t last_outbound_ms = 0; - uint16_t mtu = reticulum::kReticulumMtu; - uint16_t mdu = reticulum::kReticulumMdu; - float rtt_s = 0.0f; - bool initiator = false; - bool remote_identity_known = false; - LocalDestinationKind destination = LocalDestinationKind::Delivery; - LinkState state = LinkState::Pending; - bool propagation_offer_validated = false; - std::vector pending_requests; - std::vector incoming_resources; - std::vector outgoing_resources; - }; - - struct PropagationEntry - { - uint8_t transient_id[reticulum::kFullHashSize] = {}; - uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; - std::vector lxmf_data; - uint32_t created_s = 0; - uint32_t served_count = 0; - }; - - struct PropagationTransientEntry - { - uint8_t transient_id[reticulum::kFullHashSize] = {}; - uint32_t seen_s = 0; - bool delivered = false; - }; - - struct PropagationPeerState - { - uint8_t propagation_hash[reticulum::kTruncatedHashSize] = {}; - uint8_t delivery_hash[reticulum::kTruncatedHashSize] = {}; - uint8_t identity_hash[reticulum::kTruncatedHashSize] = {}; - uint32_t last_seen_s = 0; - uint32_t incoming_messages = 0; - uint32_t served_messages = 0; - }; + using PeerInfo = runtime::PeerInfo; + using PathEntry = runtime::PathEntry; + using PacketFilterEntry = runtime::PacketFilterEntry; + using ReverseEntry = runtime::ReverseEntry; + using PendingPathRequest = runtime::PendingPathRequest; + using LinkRelayEntry = runtime::LinkRelayEntry; + using LocalDestinationKind = runtime::LocalDestinationKind; + using LinkState = runtime::LinkState; + using LinkCloseReason = runtime::LinkCloseReason; + using LinkPendingRequest = runtime::LinkPendingRequest; + using LinkResourceTransfer = runtime::LinkResourceTransfer; + using LinkResourceAssembly = runtime::LinkResourceAssembly; + using LinkSession = runtime::LinkSession; + using PropagationEntry = runtime::PropagationEntry; + using PropagationTransientEntry = runtime::PropagationTransientEntry; + using PropagationPeerState = runtime::PropagationPeerState; static constexpr uint32_t kAnnounceIntervalMs = 120000; static constexpr uint32_t kInitialAnnounceDelayMs = 1500; @@ -207,14 +70,9 @@ class LxmfAdapter : public IMeshAdapter std::queue text_receive_queue_; std::queue data_receive_queue_; std::vector peers_; - std::vector paths_; - std::vector packet_filter_; - std::vector reverse_table_; - std::vector link_relays_; - std::vector link_sessions_; - std::vector propagation_entries_; - std::vector propagation_transients_; - std::vector propagation_peers_; + runtime::TransportRuntime transport_; + runtime::LinkRuntime links_; + runtime::PropagationRuntime propagation_; std::string user_long_name_; std::string user_short_name_; uint32_t last_announce_ms_ = 0; @@ -245,6 +103,10 @@ class LxmfAdapter : public IMeshAdapter bool sendProofForPacket(const uint8_t* raw_packet, size_t raw_len); bool sendPathRequest(PeerInfo& peer); bool shouldRequestPath(const PeerInfo& peer) const; + LinkSession* ensureOutboundLinkSession(PeerInfo& peer, + LocalDestinationKind kind, + bool* out_started = nullptr); + bool sendLinkRequest(LinkSession& session); bool buildSignedMessagePacket(const PeerInfo& peer, const uint8_t* packed_payload, size_t packed_payload_len, uint8_t* out_packet, size_t* inout_len, @@ -264,6 +126,13 @@ class LxmfAdapter : public IMeshAdapter void rememberReversePath(const uint8_t proof_hash[reticulum::kTruncatedHashSize], uint8_t expected_hops); ReverseEntry* findReversePath(const uint8_t proof_hash[reticulum::kTruncatedHashSize]); + PendingPathRequest* findPendingPathRequest( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]); + const PendingPathRequest* findPendingPathRequest( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) const; + void notePendingPathRequest(const uint8_t destination_hash[reticulum::kTruncatedHashSize], + uint32_t now_ms); + void resolvePendingPathRequest(const uint8_t destination_hash[reticulum::kTruncatedHashSize]); void cullTransportState(); void cullLinkSessions(); PathEntry& upsertPath(const uint8_t destination_hash[reticulum::kTruncatedHashSize]); @@ -310,10 +179,14 @@ class LxmfAdapter : public IMeshAdapter bool encrypt_payload); bool sendLinkHandshakeProof(LinkSession& session); bool sendLinkRtt(LinkSession& session); + bool sendLinkKeepalive(LinkSession& session); bool sendLinkKeepaliveAck(LinkSession& session); bool sendLinkPacketProof(LinkSession& session, const uint8_t* raw_packet, size_t raw_len); - void closeLinkSession(LinkSession& session); + void closeLinkSession(LinkSession& session, + LinkCloseReason reason = LinkCloseReason::LocalClose); + void flushDeferredLinkPayloads(LinkSession& session); + void expirePath(const uint8_t destination_hash[reticulum::kTruncatedHashSize]); bool handleLinkDataPacket(LinkSession& session, const uint8_t* raw_packet, size_t raw_len, const reticulum::ParsedPacket& packet); @@ -324,6 +197,9 @@ class LxmfAdapter : public IMeshAdapter const uint8_t* raw_packet, size_t raw_len); LinkResourceTransfer* findLinkResource(std::vector& resources, const uint8_t resource_hash[reticulum::kFullHashSize]); + LinkResourceAssembly* findLinkResourceAssembly( + LinkSession& session, + const uint8_t original_hash[reticulum::kFullHashSize]); bool handleLinkResourceAdvertisement(LinkSession& session, const uint8_t* plaintext, size_t plaintext_len); bool handleLinkResourceRequest(LinkSession& session, diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h new file mode 100644 index 00000000..960665cf --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h @@ -0,0 +1,243 @@ +/** + * @file lxmf_runtime_state.h + * @brief Shared runtime state models for the embedded Reticulum/LXMF runtime + */ + +#pragma once + +#include "chat/infra/lxmf/lxmf_wire.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" + +#include +#include +#include +#include + +namespace chat::lxmf::runtime +{ + +struct PeerInfo +{ + uint32_t node_id = 0; + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t identity_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t enc_pub[LxmfIdentity::kEncPubKeySize] = {}; + uint8_t sig_pub[LxmfIdentity::kSigPubKeySize] = {}; + char display_name[32] = {}; + uint32_t last_seen_s = 0; + uint32_t last_path_request_ms = 0; +}; + +struct PathEntry +{ + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t next_hop_transport[reticulum::kTruncatedHashSize] = {}; + uint8_t cached_packet_hash[reticulum::kFullHashSize] = {}; + uint8_t cached_announce[reticulum::kReticulumMtu] = {}; + size_t cached_announce_len = 0; + uint8_t hops = 0; + uint32_t last_seen_s = 0; + bool direct = false; +}; + +struct PacketFilterEntry +{ + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + uint32_t seen_ms = 0; +}; + +struct ReverseEntry +{ + uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t expected_hops = 0; + uint32_t created_ms = 0; +}; + +struct PendingPathRequest +{ + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + uint32_t created_ms = 0; + uint32_t last_attempt_ms = 0; + uint8_t attempts = 0; + bool resolved = false; +}; + +struct LinkRelayEntry +{ + uint8_t link_id[reticulum::kTruncatedHashSize] = {}; + uint8_t initiator_hops = 0; + uint8_t responder_hops = 0; + uint32_t last_seen_ms = 0; +}; + +enum class LocalDestinationKind : uint8_t +{ + Delivery = 0, + Propagation = 1 +}; + +enum class LinkState : uint8_t +{ + Pending = 0, + Handshake = 1, + Active = 2, + Stale = 3, + Closed = 4 +}; + +enum class LinkCloseReason : uint8_t +{ + None = 0, + LocalClose = 1, + RemoteClose = 2, + Timeout = 3, + Error = 4 +}; + +struct LinkPendingRequest +{ + std::vector request_id; + uint32_t created_ms = 0; + bool awaiting_resource = false; + bool response_ready = false; + std::vector response; +}; + +struct DeferredLinkPayload +{ + std::vector payload; + std::vector request_id; + uint8_t resource_flags = 0; +}; + +struct LinkResourceTransfer +{ + uint8_t resource_hash[reticulum::kFullHashSize] = {}; + uint8_t random_hash[4] = {}; + uint8_t original_hash[reticulum::kFullHashSize] = {}; + uint8_t expected_proof[reticulum::kFullHashSize] = {}; + std::vector request_id; + std::vector hashmap; + std::vector> map_hashes; + std::vector map_hash_known; + std::vector> parts; + std::vector received_bitmap; + uint32_t data_size = 0; + uint32_t transfer_size = 0; + uint32_t part_count = 0; + uint32_t hashmap_height = 0; + uint32_t next_request_index = 0; + uint32_t window_size = 4; + uint32_t segment_index = 1; + uint32_t total_segments = 1; + uint32_t created_ms = 0; + uint32_t last_activity_ms = 0; + uint8_t flags = 0; + bool incoming = true; + bool encrypted = false; + bool compressed = false; + bool has_metadata = false; + bool split = false; + bool waiting_for_hashmap = false; + bool complete = false; + bool waiting_for_proof = false; + int32_t consecutive_complete_index = -1; +}; + +struct LinkResourceAssembly +{ + uint8_t original_hash[reticulum::kFullHashSize] = {}; + std::vector request_id; + std::vector payload; + uint32_t next_segment_index = 1; + uint32_t total_segments = 1; + uint32_t last_activity_ms = 0; + uint8_t flags = 0; +}; + +struct LinkSession +{ + uint8_t link_id[reticulum::kTruncatedHashSize] = {}; + uint8_t remote_destination_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t remote_identity_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t local_enc_pub[LxmfIdentity::kEncPubKeySize] = {}; + uint8_t local_enc_priv[LxmfIdentity::kEncPrivKeySize] = {}; + uint8_t local_sig_pub[LxmfIdentity::kSigPubKeySize] = {}; + uint8_t local_sig_priv[LxmfIdentity::kSigPrivKeySize] = {}; + uint8_t peer_enc_pub[LxmfIdentity::kEncPubKeySize] = {}; + uint8_t peer_link_sig_pub[LxmfIdentity::kSigPubKeySize] = {}; + uint8_t peer_identity_sig_pub[LxmfIdentity::kSigPubKeySize] = {}; + uint8_t derived_key[reticulum::kDerivedTokenKeySize] = {}; + uint32_t created_ms = 0; + uint32_t request_ms = 0; + uint32_t last_inbound_ms = 0; + uint32_t last_outbound_ms = 0; + uint16_t mtu = reticulum::kReticulumMtu; + uint16_t mdu = reticulum::kReticulumMdu; + float rtt_s = 0.0f; + uint32_t keepalive_interval_ms = 15000; + uint32_t stale_timeout_ms = 30000; + uint32_t last_keepalive_ms = 0; + uint8_t expected_hops = 0; + bool initiator = false; + bool remote_identity_known = false; + bool validated = false; + LocalDestinationKind destination = LocalDestinationKind::Delivery; + LinkState state = LinkState::Pending; + LinkCloseReason close_reason = LinkCloseReason::None; + bool propagation_offer_validated = false; + std::vector pending_requests; + std::vector deferred_payloads; + std::vector incoming_resources; + std::vector incoming_resource_assemblies; + std::vector outgoing_resources; +}; + +struct PropagationEntry +{ + uint8_t transient_id[reticulum::kFullHashSize] = {}; + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + std::vector lxmf_data; + uint32_t created_s = 0; + uint32_t served_count = 0; +}; + +struct PropagationTransientEntry +{ + uint8_t transient_id[reticulum::kFullHashSize] = {}; + uint32_t seen_s = 0; + bool delivered = false; +}; + +struct PropagationPeerState +{ + uint8_t propagation_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t delivery_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t identity_hash[reticulum::kTruncatedHashSize] = {}; + uint32_t last_seen_s = 0; + uint32_t incoming_messages = 0; + uint32_t served_messages = 0; +}; + +struct TransportRuntime +{ + std::vector paths; + std::vector packet_filter; + std::vector reverse_table; + std::vector pending_path_requests; + std::vector link_relays; +}; + +struct LinkRuntime +{ + std::vector sessions; +}; + +struct PropagationRuntime +{ + std::vector entries; + std::vector transients; + std::vector peers; +}; + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h index 98a7a192..b559125c 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h @@ -131,8 +131,6 @@ class MtAdapter : public chat::IMeshAdapter std::map node_last_channel_; std::map nodeinfo_last_seen_ms_; uint32_t last_position_reply_ms_; - std::map pending_ack_ms_; - std::map pending_ack_dest_; std::map node_long_names_; std::string user_long_name_; std::string user_short_name_; @@ -143,6 +141,16 @@ class MtAdapter : public chat::IMeshAdapter uint8_t radio_sf_ = 0; uint8_t radio_cr_ = 0; + struct PendingAckState + { + uint32_t dest = 0; + ChannelId channel = ChannelId::PRIMARY; + uint8_t channel_hash = 0; + uint32_t last_attempt_ms = 0; + uint8_t retransmit_count = 0; + std::vector wire_packet; + }; + enum class KeyVerificationState : uint8_t { Idle, @@ -182,16 +190,18 @@ class MtAdapter : public chat::IMeshAdapter std::queue app_receive_queue_; std::queue mqtt_proxy_queue_; MqttProxySettings mqtt_proxy_settings_; + std::map pending_ack_states_; static constexpr size_t MAX_PACKET_SIZE = 255; static constexpr uint32_t RETRY_DELAY_MS = 1000; - static constexpr uint8_t MAX_RETRIES = 1; + static constexpr uint8_t MAX_RETRIES = 3; static constexpr uint32_t NODEINFO_INTERVAL_MS = 3 * 60 * 60 * 1000; static constexpr uint32_t NODEINFO_REPLY_SUPPRESS_MS = 12 * 60 * 60 * 1000; static constexpr uint32_t POSITION_REPLY_SUPPRESS_MS = 3 * 60 * 1000; static constexpr uint32_t PKI_BACKOFF_MS = 5 * 60 * 1000; static constexpr size_t MAX_APP_QUEUE = 10; static constexpr uint32_t ACK_TIMEOUT_MS = 15000; + static constexpr uint8_t MAX_ACK_RETRIES = 3; static constexpr size_t kMaxPkiNodes = 16; static constexpr const char* kPkiPrefsNs = "chat_pki"; static constexpr const char* kPkiPrefsKey = "pki_nodes"; @@ -224,7 +234,12 @@ class MtAdapter : public chat::IMeshAdapter void configureRadio(); void initNodeIdentity(); void updateChannelKeys(); + bool transmitWirePacket(const uint8_t* wire_data, size_t wire_size); void startRadioReceive(); + void trackPendingAck(uint32_t msg_id, uint32_t dest, ChannelId channel, uint8_t channel_hash, + const uint8_t* wire_data, size_t wire_size); + void clearPendingAck(uint32_t msg_id); + void retryPendingAck(uint32_t msg_id, PendingAckState& pending); bool initPkiKeys(); void loadPkiNodeKeys(); void savePkiNodeKey(uint32_t node_id, const uint8_t* key, size_t key_len); diff --git a/platform/esp/arduino_common/src/app_event_runtime_support.cpp b/platform/esp/arduino_common/src/app_event_runtime_support.cpp index 2b23ed82..be1175b5 100644 --- a/platform/esp/arduino_common/src/app_event_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_event_runtime_support.cpp @@ -12,6 +12,7 @@ #include "sys/event_bus.h" #include "team/protocol/team_chat.h" #include "ui/chat_ui_runtime.h" +#include "ui/localization.h" #include "ui/screens/team/team_page_shell.h" #include "ui/widgets/system_notification.h" @@ -51,7 +52,7 @@ void handleTeamChatNotification(app::IAppFacade& app_context, const sys::TeamCha { triggerMessageFeedback(app_context); - std::string notice = "Team: "; + std::string notice = ::ui::i18n::tr("Team: "); const auto& msg = team_event.data.msg; if (msg.header.type == team::proto::TeamChatType::Text) { @@ -68,11 +69,11 @@ void handleTeamChatNotification(app::IAppFacade& app_context, const sys::TeamCha if (team::proto::decodeTeamChatLocation(msg.payload.data(), msg.payload.size(), &loc) && !loc.label.empty()) { - notice += "Location: " + loc.label; + notice += ::ui::i18n::format("Location: %s", loc.label.c_str()); } else { - notice += "Location"; + notice += ::ui::i18n::tr("Location"); } } else if (msg.header.type == team::proto::TeamChatType::Command) @@ -80,32 +81,31 @@ void handleTeamChatNotification(app::IAppFacade& app_context, const sys::TeamCha team::proto::TeamChatCommand cmd; if (team::proto::decodeTeamChatCommand(msg.payload.data(), msg.payload.size(), &cmd)) { - const char* name = "Command"; + const char* name = ::ui::i18n::tr("Command"); switch (cmd.cmd_type) { case team::proto::TeamCommandType::RallyTo: - name = "RallyTo"; + name = ::ui::i18n::tr("RallyTo"); break; case team::proto::TeamCommandType::MoveTo: - name = "MoveTo"; + name = ::ui::i18n::tr("MoveTo"); break; case team::proto::TeamCommandType::Hold: - name = "Hold"; + name = ::ui::i18n::tr("Hold"); break; default: break; } - notice += "Command: "; - notice += name; + notice += ::ui::i18n::format("Command: %s", name); } else { - notice += "Command"; + notice += ::ui::i18n::tr("Command"); } } else { - notice += "Message"; + notice += ::ui::i18n::tr("Message"); } ::ui::SystemNotification::show(notice.c_str(), 3000); @@ -152,7 +152,9 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) return true; } auto* kv_event = static_cast(event); - std::string msg = "Key verify: enter number for " + resolveContactName(app_context, kv_event->node_id); + const std::string msg = + ::ui::i18n::format("Key verify: enter number for %s", + resolveContactName(app_context, kv_event->node_id).c_str()); ::ui::SystemNotification::show(msg.c_str(), 4000); delete event; return true; @@ -170,7 +172,7 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) const uint32_t number = kv_event->security_number % 1000000; char number_buf[16]; snprintf(number_buf, sizeof(number_buf), "%03u %03u", number / 1000, number % 1000); - std::string msg = "Key verify: " + name + " " + number_buf; + const std::string msg = ::ui::i18n::format("Key verify: %s %s", name.c_str(), number_buf); ::ui::SystemNotification::show(msg.c_str(), 5000); delete event; return true; @@ -184,9 +186,13 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) return true; } auto* kv_event = static_cast(event); - std::string msg = std::string("Key verify: ") + (kv_event->is_sender ? "send " : "confirm ") + - kv_event->verification_code + " " + - resolveContactName(app_context, kv_event->node_id); + const std::string msg = kv_event->is_sender + ? ::ui::i18n::format("Key verify: send %s %s", + kv_event->verification_code, + resolveContactName(app_context, kv_event->node_id).c_str()) + : ::ui::i18n::format("Key verify: confirm %s %s", + kv_event->verification_code, + resolveContactName(app_context, kv_event->node_id).c_str()); ::ui::SystemNotification::show(msg.c_str(), 5000); delete event; return true; diff --git a/platform/esp/arduino_common/src/app_tasks.cpp b/platform/esp/arduino_common/src/app_tasks.cpp index a39174d7..831964a2 100644 --- a/platform/esp/arduino_common/src/app_tasks.cpp +++ b/platform/esp/arduino_common/src/app_tasks.cpp @@ -25,6 +25,42 @@ namespace app namespace { +uint32_t radio_rx_done_mask() +{ + uint32_t mask = 0; +#if defined(RADIOLIB_SX126X_IRQ_RX_DONE) + mask |= RADIOLIB_SX126X_IRQ_RX_DONE; +#endif +#if defined(RADIOLIB_SX128X_IRQ_RX_DONE) + mask |= RADIOLIB_SX128X_IRQ_RX_DONE; +#endif + return mask; +} + +uint32_t radio_terminal_irq_mask() +{ + uint32_t mask = radio_rx_done_mask(); +#if defined(RADIOLIB_SX126X_IRQ_CRC_ERR) + mask |= RADIOLIB_SX126X_IRQ_CRC_ERR; +#endif +#if defined(RADIOLIB_SX126X_IRQ_HEADER_ERR) + mask |= RADIOLIB_SX126X_IRQ_HEADER_ERR; +#endif +#if defined(RADIOLIB_SX126X_IRQ_TIMEOUT) + mask |= RADIOLIB_SX126X_IRQ_TIMEOUT; +#endif +#if defined(RADIOLIB_SX128X_IRQ_CRC_ERR) + mask |= RADIOLIB_SX128X_IRQ_CRC_ERR; +#endif +#if defined(RADIOLIB_SX128X_IRQ_HEADER_ERR) + mask |= RADIOLIB_SX128X_IRQ_HEADER_ERR; +#endif +#if defined(RADIOLIB_SX128X_IRQ_TIMEOUT) + mask |= RADIOLIB_SX128X_IRQ_TIMEOUT; +#endif + return mask; +} + void append_irq_flag(char* buf, size_t len, const char* name, bool set) { if (!set || !buf || len == 0) @@ -109,11 +145,15 @@ TaskHandle_t AppTasks::mesh_task_handle_ = nullptr; LoraBoard* AppTasks::board_ = nullptr; chat::IMeshAdapter* AppTasks::adapter_ = nullptr; bool AppTasks::radio_tasks_paused_ = false; +volatile bool AppTasks::radio_receive_active_ = false; +volatile bool AppTasks::radio_receive_restart_pending_ = true; bool AppTasks::init(LoraBoard& board, chat::IMeshAdapter* adapter) { board_ = &board; adapter_ = adapter; + radio_receive_active_ = false; + radio_receive_restart_pending_ = true; // Create queues radio_tx_queue_ = xQueueCreate(RADIO_QUEUE_SIZE, sizeof(RadioPacket)); @@ -158,6 +198,7 @@ void AppTasks::pauseRadioTasks() return; } radio_tasks_paused_ = true; + requestRadioReceiveRestart(); if (radio_task_handle_) { @@ -198,6 +239,19 @@ void AppTasks::resumeRadioTasks() { vTaskResume(mesh_task_handle_); } + requestRadioReceiveRestart(); +} + +void AppTasks::setRadioReceiveActive(bool active) +{ + radio_receive_active_ = active; + radio_receive_restart_pending_ = !active; +} + +void AppTasks::requestRadioReceiveRestart() +{ + radio_receive_active_ = false; + radio_receive_restart_pending_ = true; } void AppTasks::radioTask(void* pvParameters) @@ -206,10 +260,13 @@ void AppTasks::radioTask(void* pvParameters) const TickType_t poll_delay = pdMS_TO_TICKS(10); uint8_t rx_buffer[255]; - bool rx_started = false; + const uint32_t rx_done_mask = radio_rx_done_mask(); + const uint32_t terminal_irq_mask = radio_terminal_irq_mask(); while (true) { + bool should_restart_rx = radio_receive_restart_pending_; + // Process TX queue RadioPacket tx_packet; if (xQueueReceive(radio_tx_queue_, &tx_packet, 0) == pdPASS) @@ -219,6 +276,7 @@ void AppTasks::radioTask(void* pvParameters) // Send packet if (board_ && board_->isRadioOnline()) { + requestRadioReceiveRestart(); int state = RADIOLIB_ERR_NONE; state = board_->transmitRadio(tx_packet.data, tx_packet.size); LORA_LOG("[LORA] TX queue len=%u state=%d\n", (unsigned)tx_packet.size, state); @@ -227,46 +285,49 @@ void AppTasks::radioTask(void* pvParameters) int rx_state = board_->startRadioReceive(); if (rx_state == RADIOLIB_ERR_NONE) { - rx_started = true; + setRadioReceiveActive(true); + should_restart_rx = false; } else { - rx_started = false; + requestRadioReceiveRestart(); LORA_LOG("[LORA] RX start fail state=%d\n", rx_state); } } - // Free buffer (if allocated) - if (tx_packet.data) + else { - free(tx_packet.data); + requestRadioReceiveRestart(); } } else { LORA_LOG("[LORA] TX drop (radio offline) len=%u\n", (unsigned)tx_packet.size); } + free(tx_packet.data); } } // Poll for RX (non-blocking) if (board_ && board_->isRadioOnline()) { - if (!rx_started) + if (!radio_receive_active_ || radio_receive_restart_pending_ || should_restart_rx) { int rx_state = board_->startRadioReceive(); if (rx_state == RADIOLIB_ERR_NONE) { - rx_started = true; + setRadioReceiveActive(true); + should_restart_rx = false; } else { + requestRadioReceiveRestart(); LORA_LOG("[LORA] RX start fail state=%d\n", rx_state); } } // Check if data available using RadioLib IRQs int packet_length = 0; uint32_t irq = board_->getRadioIrqFlags(); - if (irq & (RADIOLIB_SX126X_IRQ_RX_DONE | RADIOLIB_SX128X_IRQ_RX_DONE)) + if ((irq & rx_done_mask) != 0) { char irq_desc[96]; const char* flags = describe_irq_flags(irq, irq_desc, sizeof(irq_desc)); @@ -306,6 +367,8 @@ void AppTasks::radioTask(void* pvParameters) board_->clearRadioIrqFlags(irq); } } + board_->clearRadioIrqFlags(irq); + should_restart_rx = true; } else if (irq) { @@ -315,17 +378,22 @@ void AppTasks::radioTask(void* pvParameters) static_cast(irq), flags); board_->clearRadioIrqFlags(irq); + if ((irq & terminal_irq_mask) != 0) + { + should_restart_rx = true; + } } - if (packet_length > 0) + if (packet_length > 0 || should_restart_rx) { + requestRadioReceiveRestart(); int rx_state = board_->startRadioReceive(); if (rx_state == RADIOLIB_ERR_NONE) { - rx_started = true; + setRadioReceiveActive(true); } else { - rx_started = false; + requestRadioReceiveRestart(); LORA_LOG("[LORA] RX restart fail state=%d\n", rx_state); } } diff --git a/platform/esp/arduino_common/src/battery_guard.cpp b/platform/esp/arduino_common/src/battery_guard.cpp index c239c3ee..e0c98853 100644 --- a/platform/esp/arduino_common/src/battery_guard.cpp +++ b/platform/esp/arduino_common/src/battery_guard.cpp @@ -1,7 +1,10 @@ #include "platform/esp/arduino_common/battery_guard.h" +#include + #include "board/BoardBase.h" #include "lvgl.h" +#include "ui/localization.h" #include "ui/widgets/system_notification.h" namespace @@ -78,12 +81,12 @@ void handleLowBattery(int level, bool charging) s_critical_battery_streak++; if (now_ms - s_last_critical_battery_warn_ms >= kCriticalBatteryWarnCooldownMs) { - ui::SystemNotification::show("Battery critical", 2500); + ui::SystemNotification::show(::ui::i18n::tr("Battery critical"), 2500); s_last_critical_battery_warn_ms = now_ms; } if (s_critical_battery_streak >= kCriticalStreakToShutdown && s_low_battery_shutdown_timer == nullptr) { - ui::SystemNotification::show("Shutting down to protect battery", 3000); + ui::SystemNotification::show(::ui::i18n::tr("Shutting down to protect battery"), 3000); s_low_battery_shutdown_timer = lv_timer_create(lowBatteryShutdownCallback, kCriticalShutdownDelayMs, nullptr); if (s_low_battery_shutdown_timer) { @@ -98,9 +101,8 @@ void handleLowBattery(int level, bool charging) if (level <= kLowBatteryWarnPercent && now_ms - s_last_low_battery_warn_ms >= kLowBatteryWarnCooldownMs) { - char msg[48]; - snprintf(msg, sizeof(msg), "Low battery: %d%%", level); - ui::SystemNotification::show(msg, 2500); + const std::string msg = ::ui::i18n::format("Low battery: %d%%", level); + ui::SystemNotification::show(msg.c_str(), 2500); s_last_low_battery_warn_ms = now_ms; } #else diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp index 8a15a8d8..23d6829d 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp @@ -35,10 +35,17 @@ constexpr size_t kMaxPacketFilter = 128; constexpr size_t kMaxReverseEntries = 64; constexpr size_t kMaxLinkRelays = 24; constexpr size_t kMaxLinkSessions = 12; +constexpr size_t kMaxPendingPathRequests = 32; constexpr uint32_t kLinkSessionTtlMs = 10UL * 60UL * 1000UL; constexpr uint32_t kLinkHandshakeTimeoutMs = 30000; constexpr uint32_t kLinkIdleTimeoutMs = 5UL * 60UL * 1000UL; constexpr uint32_t kLinkRequestTtlMs = 60000; +constexpr uint32_t kPendingPathRequestTtlMs = 45000; +constexpr uint32_t kLinkKeepaliveMinMs = 5000; +constexpr uint32_t kLinkKeepaliveMaxMs = 360000; +constexpr float kLinkKeepaliveMaxRttS = 1.75f; +constexpr uint32_t kLinkKeepaliveTimeoutFactor = 4; +constexpr uint32_t kLinkStaleGraceMs = 5000; constexpr uint32_t kResourceTransferTtlMs = 60000; constexpr uint32_t kResourceWindowSize = 4; constexpr size_t kMaxPropagationEntries = 64; @@ -429,6 +436,20 @@ uint8_t linkModeFromSignalling(const uint8_t* signalling_bytes, size_t len) return static_cast((signalling_bytes[0] & 0xE0U) >> 5); } +uint32_t keepaliveIntervalForRtt(float rtt_s) +{ + if (rtt_s <= 0.0f) + { + return kLinkKeepaliveMaxMs; + } + + const float scaled_ms = + rtt_s * (static_cast(kLinkKeepaliveMaxMs) / kLinkKeepaliveMaxRttS); + const uint32_t keepalive_ms = static_cast(scaled_ms); + return std::min(kLinkKeepaliveMaxMs, + std::max(kLinkKeepaliveMinMs, keepalive_ms)); +} + bool packFloat64(double value, uint8_t* out_payload, size_t* inout_len) { if (!out_payload || !inout_len || *inout_len < 9) @@ -579,46 +600,47 @@ bool LxmfAdapter::sendText(ChannelId channel, const std::string& text, } uint8_t message_hash[reticulum::kFullHashSize] = {}; + uint8_t signed_part[kSignedPartMaxLen] = {}; + size_t signed_part_len = sizeof(signed_part); + if (!buildSignedPart(peer_info->destination_hash, + identity_.destinationHash(), + packed_payload, + packed_payload_len, + signed_part, + &signed_part_len, + message_hash)) + { + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; + } + + uint8_t signature[reticulum::kSignatureSize] = {}; + uint8_t lxmf_message[kMaxLxmfMessageLen] = {}; + size_t lxmf_message_len = sizeof(lxmf_message); + if (!identity_.sign(signed_part, signed_part_len, signature) || + !packMessage(peer_info->destination_hash, + identity_.destinationHash(), + signature, + packed_payload, + packed_payload_len, + lxmf_message, + &lxmf_message_len)) + { + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; + } + LinkSession* active_link = findActiveLinkSessionByDestination(peer_info->destination_hash, LocalDestinationKind::Delivery); bool ok = false; if (active_link) { - uint8_t signed_part[kSignedPartMaxLen] = {}; - size_t signed_part_len = sizeof(signed_part); - if (!buildSignedPart(peer_info->destination_hash, - identity_.destinationHash(), - packed_payload, - packed_payload_len, - signed_part, - &signed_part_len, - message_hash)) - { - if (out_msg_id) - { - *out_msg_id = 0; - } - return false; - } - - uint8_t signature[reticulum::kSignatureSize] = {}; - uint8_t lxmf_message[kMaxLxmfMessageLen] = {}; - size_t lxmf_message_len = sizeof(lxmf_message); - if (!identity_.sign(signed_part, signed_part_len, signature) || - !packMessage(peer_info->destination_hash, - identity_.destinationHash(), - signature, - packed_payload, - packed_payload_len, - lxmf_message, - &lxmf_message_len)) - { - if (out_msg_id) - { - *out_msg_id = 0; - } - return false; - } if (lxmf_message_len <= active_link->mdu) { ok = sendLinkPacket(*active_link, @@ -642,20 +664,34 @@ bool LxmfAdapter::sendText(ChannelId channel, const std::string& text, { uint8_t packet[kMaxPacketLen] = {}; size_t packet_len = sizeof(packet); - if (!buildSignedMessagePacket(*peer_info, - packed_payload, - packed_payload_len, - packet, - &packet_len, - message_hash)) + if (buildSignedMessagePacket(*peer_info, + packed_payload, + packed_payload_len, + packet, + &packet_len, + message_hash) && + routeAndSendPacket(packet, packet_len, true)) { - if (out_msg_id) - { - *out_msg_id = 0; - } - return false; + ok = true; + } + + if (!ok) + { + bool started = false; + LinkSession* session = + ensureOutboundLinkSession(*peer_info, LocalDestinationKind::Delivery, &started); + if (session) + { + runtime::DeferredLinkPayload deferred{}; + deferred.payload.assign(lxmf_message, lxmf_message + lxmf_message_len); + session->deferred_payloads.push_back(std::move(deferred)); + if (session->state == LinkState::Active) + { + flushDeferredLinkPayloads(*session); + } + ok = true; + } } - ok = routeAndSendPacket(packet, packet_len, true); } const MessageId message_id = messageIdFromHash(message_hash); @@ -750,49 +786,49 @@ bool LxmfAdapter::sendAppData(ChannelId channel, uint32_t portnum, } uint8_t message_hash[reticulum::kFullHashSize] = {}; - LinkSession* active_link = - findActiveLinkSessionByDestination(peer_info->destination_hash, LocalDestinationKind::Delivery); - if (active_link) - { - uint8_t signed_part[kSignedPartMaxLen] = {}; - size_t signed_part_len = sizeof(signed_part); - uint8_t signature[reticulum::kSignatureSize] = {}; - uint8_t lxmf_message[kMaxLxmfMessageLen] = {}; - size_t lxmf_message_len = sizeof(lxmf_message); - if (buildSignedPart(peer_info->destination_hash, - identity_.destinationHash(), - packed_payload, - packed_payload_len, - signed_part, - &signed_part_len, - message_hash) && - identity_.sign(signed_part, signed_part_len, signature) && - packMessage(peer_info->destination_hash, + uint8_t signed_part[kSignedPartMaxLen] = {}; + size_t signed_part_len = sizeof(signed_part); + uint8_t signature[reticulum::kSignatureSize] = {}; + uint8_t lxmf_message[kMaxLxmfMessageLen] = {}; + size_t lxmf_message_len = sizeof(lxmf_message); + const bool have_link_payload = + buildSignedPart(peer_info->destination_hash, identity_.destinationHash(), - signature, packed_payload, packed_payload_len, - lxmf_message, - &lxmf_message_len)) + signed_part, + &signed_part_len, + message_hash) && + identity_.sign(signed_part, signed_part_len, signature) && + packMessage(peer_info->destination_hash, + identity_.destinationHash(), + signature, + packed_payload, + packed_payload_len, + lxmf_message, + &lxmf_message_len); + + LinkSession* active_link = + findActiveLinkSessionByDestination(peer_info->destination_hash, LocalDestinationKind::Delivery); + if (active_link && have_link_payload) + { + if (lxmf_message_len <= active_link->mdu) { - if (lxmf_message_len <= active_link->mdu) - { - ok = sendLinkPacket(*active_link, - reticulum::PacketType::Data, - reticulum::PacketContext::None, - lxmf_message, - lxmf_message_len, - true); - } - else - { - ok = queueOutgoingResource(*active_link, - lxmf_message, - lxmf_message_len, - 0, - nullptr, - 0); - } + ok = sendLinkPacket(*active_link, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + lxmf_message, + lxmf_message_len, + true); + } + else + { + ok = queueOutgoingResource(*active_link, + lxmf_message, + lxmf_message_len, + 0, + nullptr, + 0); } } else @@ -808,6 +844,24 @@ bool LxmfAdapter::sendAppData(ChannelId channel, uint32_t portnum, { ok = routeAndSendPacket(packet, packet_len, true); } + + if (!ok && have_link_payload) + { + bool started = false; + LinkSession* session = + ensureOutboundLinkSession(*peer_info, LocalDestinationKind::Delivery, &started); + if (session) + { + runtime::DeferredLinkPayload deferred{}; + deferred.payload.assign(lxmf_message, lxmf_message + lxmf_message_len); + session->deferred_payloads.push_back(std::move(deferred)); + if (session->state == LinkState::Active) + { + flushDeferredLinkPayloads(*session); + } + ok = true; + } + } } } else @@ -1196,6 +1250,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len path.hops = packet.hops; path.last_seen_s = now_s; path.direct = (packet.transport_id == nullptr); + resolvePendingPathRequest(packet.destination_hash); if (packet.transport_id) { copyHash(path.next_hop_transport, packet.transport_id, sizeof(path.next_hop_transport)); @@ -1425,13 +1480,13 @@ bool LxmfAdapter::handleLinkRequestPacket(const uint8_t* raw_packet, size_t raw_ LinkSession* session = findLinkSession(link_id); if (!session) { - if (link_sessions_.size() >= kMaxLinkSessions) + if (links_.sessions.size() >= kMaxLinkSessions) { - link_sessions_.erase(link_sessions_.begin()); + links_.sessions.erase(links_.sessions.begin()); } - link_sessions_.push_back(LinkSession{}); - session = &link_sessions_.back(); + links_.sessions.push_back(LinkSession{}); + session = &links_.sessions.back(); copyHash(session->link_id, link_id, sizeof(session->link_id)); memcpy(session->peer_enc_pub, packet.payload, LxmfIdentity::kEncPubKeySize); memcpy(session->peer_link_sig_pub, @@ -1452,7 +1507,7 @@ bool LxmfAdapter::handleLinkRequestPacket(const uint8_t* raw_packet, size_t raw_ if (!deriveLinkKey(*session)) { - link_sessions_.pop_back(); + links_.sessions.pop_back(); return false; } } @@ -1749,6 +1804,10 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, unpackFloat64(payload_ptr, payload_len, &rtt_value)) { session.rtt_s = static_cast(rtt_value); + session.validated = true; + session.keepalive_interval_ms = keepaliveIntervalForRtt(session.rtt_s); + session.stale_timeout_ms = session.keepalive_interval_ms * 2U; + session.last_keepalive_ms = 0; session.state = LinkState::Active; handled = true; } @@ -1758,19 +1817,23 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, if (payload_len == reticulum::kTruncatedHashSize && hashesEqual(payload_ptr, session.link_id, sizeof(session.link_id))) { - closeLinkSession(session); + closeLinkSession(session, LinkCloseReason::RemoteClose); handled = true; } } else if (context == static_cast(reticulum::PacketContext::Keepalive)) { - if (!session.initiator && payload_len == 1 && payload_ptr[0] == 0xFF) + if (payload_len == 1 && payload_ptr[0] == 0xFF) { handled = sendLinkKeepaliveAck(session); } else { - handled = (payload_len == 1); + handled = (payload_len == 1 && payload_ptr[0] == 0xFE); + if (handled && session.state == LinkState::Stale) + { + session.state = LinkState::Active; + } } } else if (context == static_cast(reticulum::PacketContext::ResourceAdv)) @@ -1855,6 +1918,11 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, return false; } + if (session.expected_hops != 0 && packet.hops != session.expected_hops) + { + return false; + } + const uint8_t* signature = packet.payload; const uint8_t* peer_enc_pub = packet.payload + reticulum::kSignatureSize; const uint8_t* signalling = (packet.payload_len >= (reticulum::kSignatureSize + LxmfIdentity::kEncPubKeySize + kLinkSignallingLen)) @@ -1901,8 +1969,14 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, } session.rtt_s = static_cast((millis() - session.request_ms) / 1000.0f); + session.validated = true; + session.keepalive_interval_ms = keepaliveIntervalForRtt(session.rtt_s); + session.stale_timeout_ms = session.keepalive_interval_ms * 2U; + session.last_keepalive_ms = 0; session.state = LinkState::Active; - return sendLinkRtt(session); + const bool rtt_sent = sendLinkRtt(session); + flushDeferredLinkPayloads(session); + return rtt_sent; } if (packet.context == static_cast(reticulum::PacketContext::ResourcePrf)) @@ -1932,6 +2006,26 @@ LxmfAdapter::LinkResourceTransfer* LxmfAdapter::findLinkResource( return nullptr; } +LxmfAdapter::LinkResourceAssembly* LxmfAdapter::findLinkResourceAssembly( + LinkSession& session, + const uint8_t original_hash[reticulum::kFullHashSize]) +{ + if (!original_hash) + { + return nullptr; + } + + for (auto& assembly : session.incoming_resource_assemblies) + { + if (hashesEqual(assembly.original_hash, original_hash, sizeof(assembly.original_hash))) + { + return &assembly; + } + } + + return nullptr; +} + bool LxmfAdapter::handleLinkResourceAdvertisement(LinkSession& session, const uint8_t* plaintext, size_t plaintext_len) { @@ -1945,8 +2039,10 @@ bool LxmfAdapter::handleLinkResourceAdvertisement(LinkSession& session, const bool compressed = (advertisement.flags & kResourceFlagCompressed) != 0; const bool split = (advertisement.flags & kResourceFlagSplit) != 0; const bool has_metadata = (advertisement.flags & kResourceFlagHasMetadata) != 0; - if (compressed || split || has_metadata || - advertisement.total_segments > 1 || advertisement.segment_index != 1) + if (compressed || has_metadata || + advertisement.segment_index == 0 || + advertisement.total_segments == 0 || + advertisement.segment_index > advertisement.total_segments) { (void)sendLinkPacket(session, reticulum::PacketType::Data, @@ -1978,6 +2074,8 @@ bool LxmfAdapter::handleLinkResourceAdvertisement(LinkSession& session, resource.data_size = advertisement.data_size; resource.transfer_size = advertisement.transfer_size; resource.part_count = advertisement.part_count; + resource.segment_index = advertisement.segment_index; + resource.total_segments = advertisement.total_segments; resource.hashmap_height = static_cast(advertisement.hashmap.size() / kResourceMapHashLen); resource.window_size = kResourceWindowSize; resource.created_ms = millis(); @@ -1987,6 +2085,7 @@ bool LxmfAdapter::handleLinkResourceAdvertisement(LinkSession& session, resource.encrypted = encrypted; resource.compressed = compressed; resource.has_metadata = has_metadata; + resource.split = split || advertisement.total_segments > 1; resource.parts.resize(resource.part_count); resource.received_bitmap.assign(resource.part_count, 0); resource.map_hashes.resize(resource.part_count); @@ -2395,6 +2494,57 @@ bool LxmfAdapter::handleLinkResourcePart(LinkSession& session, resource.complete = true; + if (resource.split || resource.total_segments > 1) + { + LinkResourceAssembly* assembly = + findLinkResourceAssembly(session, resource.original_hash); + if (!assembly) + { + if (resource.segment_index != 1) + { + return false; + } + + session.incoming_resource_assemblies.push_back(LinkResourceAssembly{}); + assembly = &session.incoming_resource_assemblies.back(); + copyHash(assembly->original_hash, resource.original_hash, sizeof(assembly->original_hash)); + assembly->next_segment_index = 1; + assembly->total_segments = resource.total_segments; + assembly->flags = resource.flags; + assembly->last_activity_ms = millis(); + assembly->request_id = resource.request_id; + } + + if (assembly->next_segment_index != resource.segment_index) + { + return false; + } + + assembly->payload.insert(assembly->payload.end(), + payload_data.begin(), + payload_data.end()); + assembly->next_segment_index = resource.segment_index + 1U; + assembly->last_activity_ms = millis(); + + if (resource.segment_index < resource.total_segments) + { + resource.last_activity_ms = 0; + return true; + } + + payload_data = std::move(assembly->payload); + session.incoming_resource_assemblies.erase( + std::remove_if(session.incoming_resource_assemblies.begin(), + session.incoming_resource_assemblies.end(), + [&resource](const LinkResourceAssembly& candidate) + { + return hashesEqual(candidate.original_hash, + resource.original_hash, + sizeof(candidate.original_hash)); + }), + session.incoming_resource_assemblies.end()); + } + const bool is_request = (resource.flags & kResourceFlagRequest) != 0; const bool is_response = (resource.flags & kResourceFlagResponse) != 0; if (is_request) @@ -2679,9 +2829,9 @@ bool LxmfAdapter::handlePropagationRequest(LinkSession& session, continue; } - propagation_entries_.erase( - std::remove_if(propagation_entries_.begin(), - propagation_entries_.end(), + propagation_.entries.erase( + std::remove_if(propagation_.entries.begin(), + propagation_.entries.end(), [&](const PropagationEntry& entry) { return hashesEqual(entry.transient_id, @@ -2691,7 +2841,7 @@ bool LxmfAdapter::handlePropagationRequest(LinkSession& session, remote_delivery_hash, reticulum::kTruncatedHashSize); }), - propagation_entries_.end()); + propagation_.entries.end()); rememberPropagationTransient(transient_id.data(), true); } } @@ -2699,8 +2849,8 @@ bool LxmfAdapter::handlePropagationRequest(LinkSession& session, std::vector> response_items; if (get_request.wants_is_nil && get_request.haves_is_nil) { - response_items.reserve(propagation_entries_.size()); - for (const auto& entry : propagation_entries_) + response_items.reserve(propagation_.entries.size()); + for (const auto& entry : propagation_.entries) { if (hashesEqual(entry.destination_hash, remote_delivery_hash, @@ -2811,9 +2961,9 @@ bool LxmfAdapter::acceptPropagatedMessage(const uint8_t* lxmf_data, return delivered; } - if (propagation_entries_.size() >= kMaxPropagationEntries) + if (propagation_.entries.size() >= kMaxPropagationEntries) { - propagation_entries_.erase(propagation_entries_.begin()); + propagation_.entries.erase(propagation_.entries.begin()); } PropagationEntry entry{}; @@ -2821,12 +2971,12 @@ bool LxmfAdapter::acceptPropagatedMessage(const uint8_t* lxmf_data, memcpy(entry.destination_hash, destination_hash, sizeof(entry.destination_hash)); entry.lxmf_data.assign(lxmf_data, lxmf_data + lxmf_len); entry.created_s = currentTimestampSeconds(); - propagation_entries_.push_back(std::move(entry)); + propagation_.entries.push_back(std::move(entry)); rememberPropagationTransient(transient_id, false); if (remote_propagation_hash) { - for (auto& peer : propagation_peers_) + for (auto& peer : propagation_.peers) { if (hashesEqual(peer.propagation_hash, remote_propagation_hash, @@ -2905,7 +3055,7 @@ LxmfAdapter::PropagationEntry* LxmfAdapter::findPropagationEntry( return nullptr; } - for (auto& entry : propagation_entries_) + for (auto& entry : propagation_.entries) { if (hashesEqual(entry.transient_id, transient_id, reticulum::kFullHashSize)) { @@ -2924,7 +3074,7 @@ const LxmfAdapter::PropagationEntry* LxmfAdapter::findPropagationEntry( return nullptr; } - for (const auto& entry : propagation_entries_) + for (const auto& entry : propagation_.entries) { if (hashesEqual(entry.transient_id, transient_id, reticulum::kFullHashSize)) { @@ -2940,7 +3090,7 @@ LxmfAdapter::PropagationPeerState& LxmfAdapter::upsertPropagationPeer( const uint8_t delivery_hash[reticulum::kTruncatedHashSize], const uint8_t identity_hash[reticulum::kTruncatedHashSize]) { - for (auto& peer : propagation_peers_) + for (auto& peer : propagation_.peers) { if (hashesEqual(peer.propagation_hash, propagation_hash, @@ -2958,13 +3108,13 @@ LxmfAdapter::PropagationPeerState& LxmfAdapter::upsertPropagationPeer( } } - if (propagation_peers_.size() >= kMaxPropagationPeers) + if (propagation_.peers.size() >= kMaxPropagationPeers) { - propagation_peers_.erase(propagation_peers_.begin()); + propagation_.peers.erase(propagation_.peers.begin()); } - propagation_peers_.push_back(PropagationPeerState{}); - PropagationPeerState& peer = propagation_peers_.back(); + propagation_.peers.push_back(PropagationPeerState{}); + PropagationPeerState& peer = propagation_.peers.back(); if (propagation_hash) { copyHash(peer.propagation_hash, propagation_hash, sizeof(peer.propagation_hash)); @@ -2994,7 +3144,7 @@ bool LxmfAdapter::hasSeenPropagationTransient( return false; } - for (const auto& entry : propagation_transients_) + for (const auto& entry : propagation_.transients) { if (hashesEqual(entry.transient_id, transient_id, reticulum::kFullHashSize)) { @@ -3018,7 +3168,7 @@ void LxmfAdapter::rememberPropagationTransient( return; } - for (auto& entry : propagation_transients_) + for (auto& entry : propagation_.transients) { if (hashesEqual(entry.transient_id, transient_id, reticulum::kFullHashSize)) { @@ -3028,54 +3178,54 @@ void LxmfAdapter::rememberPropagationTransient( } } - if (propagation_transients_.size() >= kMaxPropagationTransients) + if (propagation_.transients.size() >= kMaxPropagationTransients) { - propagation_transients_.erase(propagation_transients_.begin()); + propagation_.transients.erase(propagation_.transients.begin()); } PropagationTransientEntry entry{}; memcpy(entry.transient_id, transient_id, sizeof(entry.transient_id)); entry.seen_s = currentTimestampSeconds(); entry.delivered = delivered; - propagation_transients_.push_back(entry); + propagation_.transients.push_back(entry); } void LxmfAdapter::cullPropagationState() { const uint32_t now_s = currentTimestampSeconds(); - propagation_entries_.erase( - std::remove_if(propagation_entries_.begin(), - propagation_entries_.end(), + propagation_.entries.erase( + std::remove_if(propagation_.entries.begin(), + propagation_.entries.end(), [now_s](const PropagationEntry& entry) { return entry.created_s == 0 || now_s < entry.created_s || (now_s - entry.created_s) > kPropagationEntryTtlS; }), - propagation_entries_.end()); + propagation_.entries.end()); - propagation_transients_.erase( - std::remove_if(propagation_transients_.begin(), - propagation_transients_.end(), + propagation_.transients.erase( + std::remove_if(propagation_.transients.begin(), + propagation_.transients.end(), [now_s](const PropagationTransientEntry& entry) { return entry.seen_s == 0 || now_s < entry.seen_s || (now_s - entry.seen_s) > kPropagationTransientTtlS; }), - propagation_transients_.end()); + propagation_.transients.end()); - propagation_peers_.erase( - std::remove_if(propagation_peers_.begin(), - propagation_peers_.end(), + propagation_.peers.erase( + std::remove_if(propagation_.peers.begin(), + propagation_.peers.end(), [now_s](const PropagationPeerState& entry) { return entry.last_seen_s != 0 && now_s >= entry.last_seen_s && (now_s - entry.last_seen_s) > kPropagationEntryTtlS; }), - propagation_peers_.end()); + propagation_.peers.end()); } bool LxmfAdapter::maybeForwardTransportPacket(const uint8_t* raw_packet, size_t raw_len, @@ -3254,6 +3404,16 @@ bool LxmfAdapter::sendPathRequest(PeerInfo& peer) return false; } + if (const PendingPathRequest* pending = findPendingPathRequest(peer.destination_hash)) + { + if (!pending->resolved && + pending->last_attempt_ms != 0 && + (now_ms - pending->last_attempt_ms) < kPathRequestMinIntervalMs) + { + return false; + } + } + uint8_t request_payload[reticulum::kTruncatedHashSize + kPathRequestTagSize] = {}; memcpy(request_payload, peer.destination_hash, reticulum::kTruncatedHashSize); fillRandomBytes(request_payload + reticulum::kTruncatedHashSize, kPathRequestTagSize); @@ -3281,31 +3441,191 @@ bool LxmfAdapter::sendPathRequest(PeerInfo& peer) return false; } + notePendingPathRequest(peer.destination_hash, now_ms); peer.last_path_request_ms = now_ms; return true; } bool LxmfAdapter::shouldRequestPath(const PeerInfo& peer) const { + if (isZeroBytes(peer.destination_hash, sizeof(peer.destination_hash))) + { + return false; + } + const uint32_t now_ms = millis(); + if (const PendingPathRequest* pending = findPendingPathRequest(peer.destination_hash)) + { + if (!pending->resolved && + pending->created_ms != 0 && + (now_ms - pending->created_ms) < kPendingPathRequestTtlMs) + { + return false; + } + } + if (peer.last_path_request_ms != 0 && (now_ms - peer.last_path_request_ms) < kPathRequestMinIntervalMs) { return false; } - if (peer.last_seen_s == 0) + const PathEntry* path = findPath(peer.destination_hash); + if (!path || path->last_seen_s == 0) { return true; } const uint32_t now_s = currentTimestampSeconds(); - if (now_s < peer.last_seen_s) + if (now_s < path->last_seen_s) { return true; } - return (now_s - peer.last_seen_s) >= kPathRefreshAgeS; + return (now_s - path->last_seen_s) >= kPathRefreshAgeS; +} + +LxmfAdapter::LinkSession* LxmfAdapter::ensureOutboundLinkSession(PeerInfo& peer, + LocalDestinationKind kind, + bool* out_started) +{ + if (out_started) + { + *out_started = false; + } + + if (isZeroBytes(peer.destination_hash, sizeof(peer.destination_hash))) + { + return nullptr; + } + + for (auto& session : links_.sessions) + { + if (session.state != LinkState::Closed && + session.destination == kind && + hashesEqual(session.remote_destination_hash, + peer.destination_hash, + sizeof(session.remote_destination_hash))) + { + return &session; + } + } + + const PathEntry* path = findPath(peer.destination_hash); + if (!path) + { + if (shouldRequestPath(peer)) + { + (void)sendPathRequest(peer); + } + return nullptr; + } + + if (links_.sessions.size() >= kMaxLinkSessions) + { + auto closed = std::find_if(links_.sessions.begin(), + links_.sessions.end(), + [](const LinkSession& session) + { + return session.state == LinkState::Closed; + }); + if (closed != links_.sessions.end()) + { + links_.sessions.erase(closed); + } + else + { + links_.sessions.erase(links_.sessions.begin()); + } + } + + links_.sessions.push_back(LinkSession{}); + LinkSession& session = links_.sessions.back(); + session.created_ms = millis(); + session.request_ms = session.created_ms; + session.last_inbound_ms = session.created_ms; + session.last_outbound_ms = 0; + session.initiator = true; + session.destination = kind; + session.state = LinkState::Pending; + session.close_reason = LinkCloseReason::None; + session.expected_hops = path->hops; + session.remote_identity_known = !isZeroBytes(peer.identity_hash, sizeof(peer.identity_hash)); + session.validated = false; + session.keepalive_interval_ms = kLinkKeepaliveMaxMs; + session.stale_timeout_ms = kLinkKeepaliveMaxMs * 2U; + copyHash(session.remote_destination_hash, + peer.destination_hash, + sizeof(session.remote_destination_hash)); + copyHash(session.remote_identity_hash, + peer.identity_hash, + sizeof(session.remote_identity_hash)); + memcpy(session.peer_identity_sig_pub, peer.sig_pub, sizeof(session.peer_identity_sig_pub)); + + Curve25519::dh1(session.local_enc_pub, session.local_enc_priv); + if (isZeroBytes(session.local_enc_priv, sizeof(session.local_enc_priv)) || + !generateLinkSigningKey(session.local_sig_pub, session.local_sig_priv) || + !sendLinkRequest(session)) + { + links_.sessions.pop_back(); + return nullptr; + } + + if (out_started) + { + *out_started = true; + } + return &session; +} + +bool LxmfAdapter::sendLinkRequest(LinkSession& session) +{ + if (!isReady() || isZeroBytes(session.remote_destination_hash, sizeof(session.remote_destination_hash))) + { + return false; + } + + uint8_t signalling[kLinkSignallingLen] = {}; + buildLinkSignallingBytes(reticulum::kReticulumMtu, signalling); + + uint8_t request_payload[kLinkRequestBaseLen + kLinkSignallingLen] = {}; + memcpy(request_payload, session.local_enc_pub, LxmfIdentity::kEncPubKeySize); + memcpy(request_payload + LxmfIdentity::kEncPubKeySize, + session.local_sig_pub, + LxmfIdentity::kSigPubKeySize); + memcpy(request_payload + kLinkRequestBaseLen, signalling, sizeof(signalling)); + + uint8_t packet[kMaxPacketLen] = {}; + size_t packet_len = sizeof(packet); + if (!reticulum::buildHeader1Packet(reticulum::PacketType::LinkRequest, + reticulum::DestinationType::Single, + reticulum::PacketContext::None, + false, + session.remote_destination_hash, + request_payload, + sizeof(request_payload), + packet, + &packet_len)) + { + return false; + } + + reticulum::ParsedPacket parsed{}; + if (!reticulum::parsePacket(packet, packet_len, &parsed) || + !computeLinkIdFromLinkRequest(packet, packet_len, parsed, session.link_id)) + { + return false; + } + + session.request_ms = millis(); + const bool sent = routeAndSendPacket(packet, packet_len, true); + if (sent) + { + session.last_outbound_ms = session.request_ms; + session.mtu = reticulum::kReticulumMtu; + session.mdu = linkMduForMtu(session.mtu); + } + return sent; } bool LxmfAdapter::buildSignedMessagePacket(const PeerInfo& peer, @@ -3508,7 +3828,7 @@ bool LxmfAdapter::sendCachedPacketReplay(const uint8_t packet_hash[reticulum::kF return false; } - for (const auto& path : paths_) + for (const auto& path : transport_.paths) { if (path.cached_announce_len == 0) { @@ -3577,7 +3897,7 @@ bool LxmfAdapter::isDuplicatePacket(const uint8_t packet_hash[reticulum::kFullHa { return false; } - for (const auto& entry : packet_filter_) + for (const auto& entry : transport_.packet_filter) { if (entry.seen_ms != 0 && hashesEqual(entry.packet_hash, packet_hash, reticulum::kFullHashSize)) @@ -3595,15 +3915,15 @@ void LxmfAdapter::rememberPacket(const uint8_t packet_hash[reticulum::kFullHashS return; } - if (packet_filter_.size() >= kMaxPacketFilter) + if (transport_.packet_filter.size() >= kMaxPacketFilter) { - packet_filter_.erase(packet_filter_.begin()); + transport_.packet_filter.erase(transport_.packet_filter.begin()); } PacketFilterEntry entry{}; copyHash(entry.packet_hash, packet_hash, sizeof(entry.packet_hash)); entry.seen_ms = millis(); - packet_filter_.push_back(entry); + transport_.packet_filter.push_back(entry); } void LxmfAdapter::rememberReversePath(const uint8_t proof_hash[reticulum::kTruncatedHashSize], @@ -3614,7 +3934,7 @@ void LxmfAdapter::rememberReversePath(const uint8_t proof_hash[reticulum::kTrunc return; } - for (auto& entry : reverse_table_) + for (auto& entry : transport_.reverse_table) { if (hashesEqual(entry.proof_hash, proof_hash, sizeof(entry.proof_hash))) { @@ -3624,16 +3944,16 @@ void LxmfAdapter::rememberReversePath(const uint8_t proof_hash[reticulum::kTrunc } } - if (reverse_table_.size() >= kMaxReverseEntries) + if (transport_.reverse_table.size() >= kMaxReverseEntries) { - reverse_table_.erase(reverse_table_.begin()); + transport_.reverse_table.erase(transport_.reverse_table.begin()); } ReverseEntry entry{}; copyHash(entry.proof_hash, proof_hash, sizeof(entry.proof_hash)); entry.expected_hops = expected_hops; entry.created_ms = millis(); - reverse_table_.push_back(entry); + transport_.reverse_table.push_back(entry); } LxmfAdapter::ReverseEntry* LxmfAdapter::findReversePath( @@ -3643,7 +3963,7 @@ LxmfAdapter::ReverseEntry* LxmfAdapter::findReversePath( { return nullptr; } - for (auto& entry : reverse_table_) + for (auto& entry : transport_.reverse_table) { if (entry.created_ms != 0 && hashesEqual(entry.proof_hash, proof_hash, sizeof(entry.proof_hash))) @@ -3654,33 +3974,130 @@ LxmfAdapter::ReverseEntry* LxmfAdapter::findReversePath( return nullptr; } +LxmfAdapter::PendingPathRequest* LxmfAdapter::findPendingPathRequest( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) +{ + if (!destination_hash) + { + return nullptr; + } + + for (auto& pending : transport_.pending_path_requests) + { + if (hashesEqual(pending.destination_hash, destination_hash, sizeof(pending.destination_hash))) + { + return &pending; + } + } + return nullptr; +} + +const LxmfAdapter::PendingPathRequest* LxmfAdapter::findPendingPathRequest( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) const +{ + if (!destination_hash) + { + return nullptr; + } + + for (const auto& pending : transport_.pending_path_requests) + { + if (hashesEqual(pending.destination_hash, destination_hash, sizeof(pending.destination_hash))) + { + return &pending; + } + } + return nullptr; +} + +void LxmfAdapter::notePendingPathRequest( + const uint8_t destination_hash[reticulum::kTruncatedHashSize], + uint32_t now_ms) +{ + if (!destination_hash) + { + return; + } + + PendingPathRequest* pending = findPendingPathRequest(destination_hash); + if (!pending) + { + if (transport_.pending_path_requests.size() >= kMaxPendingPathRequests) + { + transport_.pending_path_requests.erase(transport_.pending_path_requests.begin()); + } + + transport_.pending_path_requests.push_back(PendingPathRequest{}); + pending = &transport_.pending_path_requests.back(); + copyHash(pending->destination_hash, destination_hash, sizeof(pending->destination_hash)); + pending->created_ms = now_ms; + } + + pending->last_attempt_ms = now_ms; + pending->resolved = false; + if (pending->attempts < 0xFFU) + { + pending->attempts += 1; + } +} + +void LxmfAdapter::resolvePendingPathRequest( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) +{ + if (!destination_hash) + { + return; + } + + transport_.pending_path_requests.erase( + std::remove_if(transport_.pending_path_requests.begin(), + transport_.pending_path_requests.end(), + [destination_hash](const PendingPathRequest& pending) + { + return hashesEqual(pending.destination_hash, + destination_hash, + sizeof(pending.destination_hash)); + }), + transport_.pending_path_requests.end()); +} + void LxmfAdapter::cullTransportState() { const uint32_t now_ms = millis(); - packet_filter_.erase( - std::remove_if(packet_filter_.begin(), packet_filter_.end(), + transport_.packet_filter.erase( + std::remove_if(transport_.packet_filter.begin(), transport_.packet_filter.end(), [now_ms](const PacketFilterEntry& entry) { return entry.seen_ms == 0 || (now_ms - entry.seen_ms) > kPacketFilterTtlMs; }), - packet_filter_.end()); + transport_.packet_filter.end()); - reverse_table_.erase( - std::remove_if(reverse_table_.begin(), reverse_table_.end(), + transport_.reverse_table.erase( + std::remove_if(transport_.reverse_table.begin(), transport_.reverse_table.end(), [now_ms](const ReverseEntry& entry) { return entry.created_ms == 0 || (now_ms - entry.created_ms) > kReverseEntryTtlMs; }), - reverse_table_.end()); + transport_.reverse_table.end()); - link_relays_.erase( - std::remove_if(link_relays_.begin(), link_relays_.end(), + transport_.link_relays.erase( + std::remove_if(transport_.link_relays.begin(), transport_.link_relays.end(), [now_ms](const LinkRelayEntry& entry) { return entry.last_seen_ms == 0 || (now_ms - entry.last_seen_ms) > kLinkRelayTtlMs; }), - link_relays_.end()); + transport_.link_relays.end()); + + transport_.pending_path_requests.erase( + std::remove_if(transport_.pending_path_requests.begin(), + transport_.pending_path_requests.end(), + [now_ms](const PendingPathRequest& pending) + { + return pending.created_ms == 0 || + (now_ms - pending.created_ms) > kPendingPathRequestTtlMs; + }), + transport_.pending_path_requests.end()); cullLinkSessions(); } @@ -3688,7 +4105,7 @@ void LxmfAdapter::cullTransportState() LxmfAdapter::PathEntry& LxmfAdapter::upsertPath( const uint8_t destination_hash[reticulum::kTruncatedHashSize]) { - for (auto& path : paths_) + for (auto& path : transport_.paths) { if (hashesEqual(path.destination_hash, destination_hash, reticulum::kTruncatedHashSize)) { @@ -3696,13 +4113,13 @@ LxmfAdapter::PathEntry& LxmfAdapter::upsertPath( } } - if (paths_.size() >= kMaxPaths) + if (transport_.paths.size() >= kMaxPaths) { - paths_.erase(paths_.begin()); + transport_.paths.erase(transport_.paths.begin()); } - paths_.push_back(PathEntry{}); - PathEntry& path = paths_.back(); + transport_.paths.push_back(PathEntry{}); + PathEntry& path = transport_.paths.back(); copyHash(path.destination_hash, destination_hash, sizeof(path.destination_hash)); return path; } @@ -3714,7 +4131,7 @@ const LxmfAdapter::PathEntry* LxmfAdapter::findPath( { return nullptr; } - for (const auto& path : paths_) + for (const auto& path : transport_.paths) { if (hashesEqual(path.destination_hash, destination_hash, sizeof(path.destination_hash))) { @@ -3727,7 +4144,7 @@ const LxmfAdapter::PathEntry* LxmfAdapter::findPath( LxmfAdapter::LinkRelayEntry& LxmfAdapter::upsertLinkRelay( const uint8_t link_id[reticulum::kTruncatedHashSize]) { - for (auto& relay : link_relays_) + for (auto& relay : transport_.link_relays) { if (hashesEqual(relay.link_id, link_id, sizeof(relay.link_id))) { @@ -3735,13 +4152,13 @@ LxmfAdapter::LinkRelayEntry& LxmfAdapter::upsertLinkRelay( } } - if (link_relays_.size() >= kMaxLinkRelays) + if (transport_.link_relays.size() >= kMaxLinkRelays) { - link_relays_.erase(link_relays_.begin()); + transport_.link_relays.erase(transport_.link_relays.begin()); } - link_relays_.push_back(LinkRelayEntry{}); - LinkRelayEntry& relay = link_relays_.back(); + transport_.link_relays.push_back(LinkRelayEntry{}); + LinkRelayEntry& relay = transport_.link_relays.back(); copyHash(relay.link_id, link_id, sizeof(relay.link_id)); return relay; } @@ -3753,7 +4170,7 @@ LxmfAdapter::LinkRelayEntry* LxmfAdapter::findLinkRelay( { return nullptr; } - for (auto& relay : link_relays_) + for (auto& relay : transport_.link_relays) { if (hashesEqual(relay.link_id, link_id, sizeof(relay.link_id))) { @@ -4036,6 +4453,22 @@ bool LxmfAdapter::sendLinkRtt(LinkSession& session) true); } +bool LxmfAdapter::sendLinkKeepalive(LinkSession& session) +{ + const uint8_t probe = 0xFF; + const bool ok = sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::Keepalive, + &probe, + sizeof(probe), + false); + if (ok) + { + session.last_keepalive_ms = millis(); + } + return ok; +} + bool LxmfAdapter::sendLinkKeepaliveAck(LinkSession& session) { const uint8_t ack = 0xFE; @@ -4321,10 +4754,124 @@ bool LxmfAdapter::queueOutgoingResource(LinkSession& session, return true; } -void LxmfAdapter::closeLinkSession(LinkSession& session) +void LxmfAdapter::closeLinkSession(LinkSession& session, LinkCloseReason reason) { + if (session.state == LinkState::Closed) + { + if (session.close_reason == LinkCloseReason::None) + { + session.close_reason = reason; + } + return; + } + + session.pending_requests.clear(); + session.deferred_payloads.clear(); + session.incoming_resources.clear(); + session.incoming_resource_assemblies.clear(); + session.outgoing_resources.clear(); + session.propagation_offer_validated = false; + session.remote_identity_known = false; + session.rtt_s = 0.0f; + session.validated = false; + session.last_keepalive_ms = 0; + memset(session.local_enc_priv, 0, sizeof(session.local_enc_priv)); + memset(session.local_sig_priv, 0, sizeof(session.local_sig_priv)); + memset(session.derived_key, 0, sizeof(session.derived_key)); + memset(session.peer_enc_pub, 0, sizeof(session.peer_enc_pub)); + memset(session.peer_link_sig_pub, 0, sizeof(session.peer_link_sig_pub)); + memset(session.peer_identity_sig_pub, 0, sizeof(session.peer_identity_sig_pub)); + session.state = LinkState::Closed; + session.close_reason = reason; session.last_inbound_ms = millis(); + session.last_outbound_ms = session.last_inbound_ms; + + transport_.link_relays.erase( + std::remove_if(transport_.link_relays.begin(), + transport_.link_relays.end(), + [&session](const LinkRelayEntry& relay) + { + return hashesEqual(relay.link_id, session.link_id, sizeof(relay.link_id)); + }), + transport_.link_relays.end()); + + if ((reason == LinkCloseReason::Timeout || reason == LinkCloseReason::Error) && + !isZeroBytes(session.remote_destination_hash, sizeof(session.remote_destination_hash))) + { + expirePath(session.remote_destination_hash); + for (auto& peer : peers_) + { + if (hashesEqual(peer.destination_hash, + session.remote_destination_hash, + sizeof(peer.destination_hash))) + { + peer.last_path_request_ms = 0; + (void)sendPathRequest(peer); + break; + } + } + } +} + +void LxmfAdapter::flushDeferredLinkPayloads(LinkSession& session) +{ + if (session.state != LinkState::Active) + { + return; + } + + while (!session.deferred_payloads.empty()) + { + const runtime::DeferredLinkPayload& deferred = session.deferred_payloads.front(); + bool sent = false; + if (deferred.payload.size() <= session.mdu) + { + sent = sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + deferred.payload.data(), + deferred.payload.size(), + true); + } + else + { + sent = queueOutgoingResource(session, + deferred.payload.data(), + deferred.payload.size(), + deferred.resource_flags, + deferred.request_id.empty() ? nullptr : deferred.request_id.data(), + deferred.request_id.size()); + } + + if (!sent) + { + break; + } + + session.deferred_payloads.erase(session.deferred_payloads.begin()); + } +} + +void LxmfAdapter::expirePath( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) +{ + if (!destination_hash) + { + return; + } + + transport_.paths.erase( + std::remove_if(transport_.paths.begin(), + transport_.paths.end(), + [destination_hash](const PathEntry& path) + { + return hashesEqual(path.destination_hash, + destination_hash, + sizeof(path.destination_hash)); + }), + transport_.paths.end()); + resolvePendingPathRequest(destination_hash); } LxmfAdapter::LinkSession* LxmfAdapter::findLinkSession( @@ -4335,7 +4882,7 @@ LxmfAdapter::LinkSession* LxmfAdapter::findLinkSession( return nullptr; } - for (auto& session : link_sessions_) + for (auto& session : links_.sessions) { if (hashesEqual(session.link_id, link_id, sizeof(session.link_id))) { @@ -4354,7 +4901,7 @@ LxmfAdapter::LinkSession* LxmfAdapter::findActiveLinkSessionByDestination( return nullptr; } - for (auto& session : link_sessions_) + for (auto& session : links_.sessions) { if (session.state == LinkState::Active && session.destination == kind && @@ -4372,7 +4919,7 @@ void LxmfAdapter::cullLinkSessions() { const uint32_t now_ms = millis(); - for (auto& session : link_sessions_) + for (auto& session : links_.sessions) { session.pending_requests.erase( std::remove_if(session.pending_requests.begin(), @@ -4391,17 +4938,76 @@ void LxmfAdapter::cullLinkSessions() resources.end(), [now_ms](const LinkResourceTransfer& resource) { - return resource.last_activity_ms == 0 || + return resource.complete || resource.last_activity_ms == 0 || (now_ms - resource.last_activity_ms) > kResourceTransferTtlMs; }), resources.end()); }; cull_resources(session.incoming_resources); cull_resources(session.outgoing_resources); + session.incoming_resource_assemblies.erase( + std::remove_if(session.incoming_resource_assemblies.begin(), + session.incoming_resource_assemblies.end(), + [now_ms](const LinkResourceAssembly& assembly) + { + return assembly.last_activity_ms == 0 || + (now_ms - assembly.last_activity_ms) > kResourceTransferTtlMs; + }), + session.incoming_resource_assemblies.end()); + + const uint32_t last_activity = + std::max(session.last_inbound_ms, session.last_outbound_ms); + const uint32_t age_ms = + (last_activity == 0) ? (now_ms - session.created_ms) : (now_ms - last_activity); + const uint32_t inbound_age_ms = + (session.last_inbound_ms == 0) ? (now_ms - session.created_ms) : (now_ms - session.last_inbound_ms); + + if (session.state == LinkState::Pending || session.state == LinkState::Handshake) + { + if (age_ms > kLinkHandshakeTimeoutMs) + { + closeLinkSession(session, LinkCloseReason::Timeout); + } + } + else if (session.state == LinkState::Active) + { + flushDeferredLinkPayloads(session); + + if (session.initiator && + session.keepalive_interval_ms != 0 && + inbound_age_ms >= session.keepalive_interval_ms && + (session.last_keepalive_ms == 0 || + (now_ms - session.last_keepalive_ms) >= session.keepalive_interval_ms)) + { + (void)sendLinkKeepalive(session); + } + + if (session.stale_timeout_ms != 0 && inbound_age_ms >= session.stale_timeout_ms) + { + session.state = LinkState::Stale; + } + } + else if (session.state == LinkState::Stale) + { + const uint32_t final_timeout_ms = + session.stale_timeout_ms + + static_cast(std::max(0.0f, session.rtt_s) * 1000.0f) * + kLinkKeepaliveTimeoutFactor + + kLinkStaleGraceMs; + if (inbound_age_ms >= final_timeout_ms) + { + closeLinkSession(session, LinkCloseReason::Timeout); + } + } + else if (session.state != LinkState::Closed && + (age_ms > kLinkIdleTimeoutMs || (now_ms - session.created_ms) > kLinkSessionTtlMs)) + { + closeLinkSession(session, LinkCloseReason::Timeout); + } } - link_sessions_.erase( - std::remove_if(link_sessions_.begin(), link_sessions_.end(), + links_.sessions.erase( + std::remove_if(links_.sessions.begin(), links_.sessions.end(), [now_ms](const LinkSession& session) { const uint32_t last_activity = @@ -4417,9 +5023,13 @@ void LxmfAdapter::cullLinkSessions() { return age_ms > kLinkHandshakeTimeoutMs; } + if (session.state == LinkState::Stale) + { + return age_ms > kLinkIdleTimeoutMs; + } return age_ms > kLinkIdleTimeoutMs || (now_ms - session.created_ms) > kLinkSessionTtlMs; }), - link_sessions_.end()); + links_.sessions.end()); } LxmfAdapter::PeerInfo* LxmfAdapter::rememberPeerIdentity( diff --git a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp index e4b498f1..9fa5f15b 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp @@ -9,6 +9,7 @@ #include "chat/domain/contact_types.h" #include "chat/ports/i_node_store.h" #include "chat/time_utils.h" +#include "platform/esp/arduino_common/app_tasks.h" #include "platform/esp/arduino_common/gps/gps_service_api.h" #include "sys/event_bus.h" #include "team/protocol/team_portnum.h" @@ -473,15 +474,7 @@ bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, return false; } - int state = RADIOLIB_ERR_UNSUPPORTED; -#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - state = board_.transmitRadio(wire_buffer, wire_size); -#else - // Other radio types - implement as needed - state = RADIOLIB_ERR_UNSUPPORTED; -#endif - - bool ok = (state == RADIOLIB_ERR_NONE); + bool ok = transmitWirePacket(wire_buffer, wire_size); mt_diag_log("[MT][TX] app id=%08lX dest=%08lX port=%u ok=%u air_ack=%u track_ack=%u len=%u\n", static_cast(msg_id), static_cast(dest_node), @@ -502,8 +495,7 @@ bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, last_tx_ms_ = now_ms; if (track_ack) { - pending_ack_ms_[msg_id] = millis(); - pending_ack_dest_[msg_id] = dest_node; + trackPendingAck(msg_id, dest_node, out_channel, channel_hash, wire_buffer, wire_size); } meshtastic_Data mqtt_data = meshtastic_Data_init_default; mqtt_data.portnum = static_cast(portnum); @@ -519,7 +511,6 @@ bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, queueMqttProxyPublishFromWire(wire_buffer, wire_size, use_pki ? nullptr : &mqtt_data, out_channel); - startRadioReceive(); } return ok; } @@ -644,26 +635,19 @@ bool MtAdapter::sendMeshPacket(const meshtastic_MeshPacket& packet) return false; } -#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - int state = board_.transmitRadio(wire_buffer, wire_size); -#else - int state = RADIOLIB_ERR_UNSUPPORTED; -#endif - - if (state == RADIOLIB_ERR_NONE) + if (transmitWirePacket(wire_buffer, wire_size)) { last_tx_ms_ = now_ms; last_send_error_ = meshtastic_Routing_Error_NONE; if (track_ack) { - pending_ack_ms_[msg_id] = millis(); - pending_ack_dest_[msg_id] = dest; + trackPendingAck(msg_id, dest, (packet.channel == 1) ? ChannelId::SECONDARY : ChannelId::PRIMARY, + channel_hash, wire_buffer, wire_size); } ChannelId mqtt_channel = (packet.channel == 1) ? ChannelId::SECONDARY : ChannelId::PRIMARY; queueMqttProxyPublishFromWire(wire_buffer, wire_size, (packet.which_payload_variant == meshtastic_MeshPacket_decoded_tag) ? &data : nullptr, mqtt_channel); - startRadioReceive(); return true; } last_send_error_ = meshtastic_Routing_Error_NO_INTERFACE; @@ -1441,24 +1425,23 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) if (header.from == node_id_) { - auto pending_it = pending_ack_ms_.find(header.id); - if (header.to == kBroadcastNodeId && pending_it != pending_ack_ms_.end()) + auto pending_it = pending_ack_states_.find(header.id); + if (header.to == kBroadcastNodeId && pending_it != pending_ack_states_.end()) { - ChannelId channel_id = - (header.channel == secondary_channel_hash_) ? ChannelId::SECONDARY : ChannelId::PRIMARY; + const ChannelId channel_id = pending_it->second.channel; + const uint8_t channel_hash = pending_it->second.channel_hash; mt_diag_log("[MT][IMPLICIT_ACK] observed self-broadcast id=%08lX relay=%08lX next=%08lX ch=%u\n", static_cast(header.id), static_cast(header.relay_node), static_cast(header.next_hop), static_cast(header.channel)); - pending_ack_ms_.erase(pending_it); - pending_ack_dest_.erase(header.id); + pending_ack_states_.erase(pending_it); emitRoutingResultToPhone(header.id, meshtastic_Routing_Error_NONE, node_id_, node_id_, channel_id, - header.channel, + channel_hash, &rx_meta); return; } @@ -1831,8 +1814,7 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) (unsigned long)header.from, routingErrorName(routing.error_reason)); } - pending_ack_ms_.erase(decoded.request_id); - pending_ack_dest_.erase(decoded.request_id); + clearPendingAck(decoded.request_id); mt_diag_log("[MT][ACK] req=%08lX from=%08lX reason=%u ok=%u\n", static_cast(decoded.request_id), static_cast(header.from), @@ -2085,28 +2067,36 @@ void MtAdapter::processSendQueue() maybeBroadcastNodeInfo(now); - for (auto it = pending_ack_ms_.begin(); it != pending_ack_ms_.end();) + for (auto it = pending_ack_states_.begin(); it != pending_ack_states_.end();) { - if (now - it->second >= ACK_TIMEOUT_MS) + PendingAckState& pending = it->second; + if (now - pending.last_attempt_ms >= ACK_TIMEOUT_MS) { - const auto dest_it = pending_ack_dest_.find(it->first); - const uint32_t dest = (dest_it != pending_ack_dest_.end()) ? dest_it->second : 0; - mt_diag_log("[MT][ACK_TIMEOUT] req=%08lX dest=%08lX age_ms=%lu\n", + if (pending.retransmit_count < MAX_ACK_RETRIES) + { + retryPendingAck(it->first, pending); + ++it; + continue; + } + + mt_diag_log("[MT][ACK_TIMEOUT] req=%08lX dest=%08lX age_ms=%lu retries=%u\n", static_cast(it->first), - static_cast(dest), - static_cast(now - it->second)); - LORA_LOG("[LORA] RX ack timeout req=%08lX dest=%08lX\n", + static_cast(pending.dest), + static_cast(now - pending.last_attempt_ms), + static_cast(pending.retransmit_count)); + LORA_LOG("[LORA] RX ack timeout req=%08lX dest=%08lX retries=%u\n", static_cast(it->first), - static_cast(dest)); - pending_ack_dest_.erase(it->first); + static_cast(pending.dest), + static_cast(pending.retransmit_count)); + last_send_error_ = meshtastic_Routing_Error_MAX_RETRANSMIT; emitRoutingResultToPhone(it->first, meshtastic_Routing_Error_MAX_RETRANSMIT, node_id_, - node_id_, - ChannelId::PRIMARY, - primary_channel_hash_, + pending.dest, + pending.channel, + pending.channel_hash, nullptr); - it = pending_ack_ms_.erase(it); + it = pending_ack_states_.erase(it); continue; } ++it; @@ -2149,6 +2139,16 @@ void MtAdapter::processSendQueue() if (pending.retry_count > MAX_RETRIES) { // Max retries reached, drop + const uint8_t channel_hash = + (pending.channel == ChannelId::SECONDARY) ? secondary_channel_hash_ : primary_channel_hash_; + last_send_error_ = meshtastic_Routing_Error_NO_INTERFACE; + emitRoutingResultToPhone(pending.msg_id, + meshtastic_Routing_Error_NO_INTERFACE, + node_id_, + pending.dest, + pending.channel, + channel_hash, + nullptr); send_queue_.pop(); } else @@ -2290,23 +2290,12 @@ bool MtAdapter::sendPacket(const PendingSend& pending) std::string tx_full_hex = toHex(wire_buffer, wire_size, wire_size); LORA_LOG("[LORA] TX full packet hex: %s\n", tx_full_hex.c_str()); - // Send via LoRa using RadioLib - int state = RADIOLIB_ERR_NONE; - if (!board_.isRadioOnline()) { return false; } -#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - // For now, send directly (in production, use task queue) - state = board_.transmitRadio(wire_buffer, wire_size); -#else - // Other radio types - implement as needed - state = RADIOLIB_ERR_UNSUPPORTED; -#endif - - bool ok = (state == RADIOLIB_ERR_NONE); + bool ok = transmitWirePacket(wire_buffer, wire_size); LORA_LOG("[LORA] TX text id=%08lX ch=%u len=%u ok=%d\n", (unsigned long)pending.msg_id, static_cast(channel), @@ -2314,15 +2303,13 @@ bool MtAdapter::sendPacket(const PendingSend& pending) ok ? 1 : 0); if (ok && track_ack) { - pending_ack_ms_[pending.msg_id] = millis(); - pending_ack_dest_[pending.msg_id] = dest; + trackPendingAck(pending.msg_id, dest, channel, channel_hash, wire_buffer, wire_size); } if (ok) { queueMqttProxyPublishFromWire(wire_buffer, wire_size, decoded_ok ? &decoded : nullptr, channel); - startRadioReceive(); } return ok; } @@ -2411,8 +2398,9 @@ bool MtAdapter::sendNodeInfoTo(uint32_t dest, bool want_response, ChannelId chan (channel == ChannelId::SECONDARY) ? secondary_psk_ : primary_psk_; size_t psk_len = (channel == ChannelId::SECONDARY) ? secondary_psk_len_ : primary_psk_len_; + const uint32_t msg_id = next_packet_id_++; - if (!buildWirePacket(data_buffer, data_size, node_id_, next_packet_id_++, + if (!buildWirePacket(data_buffer, data_size, node_id_, msg_id, dest, channel_hash, hop_limit, want_ack, psk, psk_len, wire_buffer, &wire_size)) { @@ -2431,20 +2419,11 @@ bool MtAdapter::sendNodeInfoTo(uint32_t dest, bool want_response, ChannelId chan return false; } -#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - int state = board_.transmitRadio(wire_buffer, wire_size); -#else - int state = RADIOLIB_ERR_UNSUPPORTED; -#endif - bool ok = (state == RADIOLIB_ERR_NONE); + bool ok = transmitWirePacket(wire_buffer, wire_size); LORA_LOG("[LORA] TX nodeinfo id=%08lX len=%u ok=%d\n", - (unsigned long)(next_packet_id_ - 1), + (unsigned long)msg_id, (unsigned)wire_size, ok ? 1 : 0); - if (ok) - { - startRadioReceive(); - } return ok; } @@ -2826,17 +2805,92 @@ void MtAdapter::startRadioReceive() { if (!board_.isRadioOnline()) { + app::AppTasks::requestRadioReceiveRestart(); return; } #if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) + app::AppTasks::requestRadioReceiveRestart(); int state = board_.startRadioReceive(); - if (state != RADIOLIB_ERR_NONE) + if (state == RADIOLIB_ERR_NONE) { + app::AppTasks::setRadioReceiveActive(true); + } + else + { + app::AppTasks::requestRadioReceiveRestart(); LORA_LOG("[LORA] RX start fail state=%d\n", state); } #endif } +bool MtAdapter::transmitWirePacket(const uint8_t* wire_data, size_t wire_size) +{ + if (!board_.isRadioOnline()) + { + return false; + } + + app::AppTasks::requestRadioReceiveRestart(); + +#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) + const int state = board_.transmitRadio(wire_data, wire_size); +#else + const int state = RADIOLIB_ERR_UNSUPPORTED; +#endif + const bool ok = (state == RADIOLIB_ERR_NONE); + if (ok) + { + startRadioReceive(); + } + else + { + app::AppTasks::requestRadioReceiveRestart(); + } + return ok; +} + +void MtAdapter::trackPendingAck(uint32_t msg_id, uint32_t dest, ChannelId channel, uint8_t channel_hash, + const uint8_t* wire_data, size_t wire_size) +{ + PendingAckState state; + state.dest = dest; + state.channel = channel; + state.channel_hash = channel_hash; + state.last_attempt_ms = millis(); + state.retransmit_count = 0; + state.wire_packet.assign(wire_data, wire_data + wire_size); + pending_ack_states_[msg_id] = std::move(state); +} + +void MtAdapter::clearPendingAck(uint32_t msg_id) +{ + pending_ack_states_.erase(msg_id); +} + +void MtAdapter::retryPendingAck(uint32_t msg_id, PendingAckState& pending) +{ + pending.last_attempt_ms = millis(); + ++pending.retransmit_count; + mt_diag_log("[MT][RETX] req=%08lX dest=%08lX try=%u len=%u\n", + static_cast(msg_id), + static_cast(pending.dest), + static_cast(pending.retransmit_count), + static_cast(pending.wire_packet.size())); + LORA_LOG("[LORA] TX retry req=%08lX dest=%08lX try=%u len=%u\n", + static_cast(msg_id), + static_cast(pending.dest), + static_cast(pending.retransmit_count), + static_cast(pending.wire_packet.size())); + if (transmitWirePacket(pending.wire_packet.data(), pending.wire_packet.size())) + { + last_tx_ms_ = pending.last_attempt_ms; + return; + } + LORA_LOG("[LORA] TX retry immediate fail req=%08lX try=%u\n", + static_cast(msg_id), + static_cast(pending.retransmit_count)); +} + bool MtAdapter::initPkiKeys() { std::array pub_before{}; @@ -3532,14 +3586,8 @@ bool MtAdapter::sendKeyVerificationPacket(uint32_t dest, const meshtastic_KeyVer return false; } -#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - int state = board_.transmitRadio(wire_buffer, wire_size); -#else - int state = RADIOLIB_ERR_UNSUPPORTED; -#endif - if (state == RADIOLIB_ERR_NONE) + if (transmitWirePacket(wire_buffer, wire_size)) { - startRadioReceive(); return true; } return false; @@ -3615,14 +3663,8 @@ bool MtAdapter::sendRoutingAck(uint32_t dest, uint32_t request_id, uint8_t chann std::string ack_full_hex = toHex(wire_buffer, wire_size, wire_size); LORA_LOG("[LORA] TX ack full packet hex: %s\n", ack_full_hex.c_str()); -#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - int state = board_.transmitRadio(wire_buffer, wire_size); -#else - int state = RADIOLIB_ERR_UNSUPPORTED; -#endif - if (state == RADIOLIB_ERR_NONE) + if (transmitWirePacket(wire_buffer, wire_size)) { - startRadioReceive(); return true; } return false; @@ -3642,14 +3684,8 @@ bool MtAdapter::sendRoutingAck(uint32_t dest, uint32_t request_id, uint8_t chann std::string ack_full_hex = toHex(wire_buffer, wire_size, wire_size); LORA_LOG("[LORA] TX ack full packet hex: %s\n", ack_full_hex.c_str()); -#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - int state = board_.transmitRadio(wire_buffer, wire_size); -#else - int state = RADIOLIB_ERR_UNSUPPORTED; -#endif - if (state == RADIOLIB_ERR_NONE) + if (transmitWirePacket(wire_buffer, wire_size)) { - startRadioReceive(); return true; } return false; @@ -3726,14 +3762,8 @@ bool MtAdapter::sendRoutingError(uint32_t dest, uint32_t request_id, uint8_t cha std::string err_full_hex = toHex(wire_buffer, wire_size, wire_size); LORA_LOG("[LORA] TX routing error full packet hex: %s\n", err_full_hex.c_str()); -#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - int state = board_.transmitRadio(wire_buffer, wire_size); -#else - int state = RADIOLIB_ERR_UNSUPPORTED; -#endif - if (state == RADIOLIB_ERR_NONE) + if (transmitWirePacket(wire_buffer, wire_size)) { - startRadioReceive(); return true; } return false; @@ -3753,14 +3783,8 @@ bool MtAdapter::sendRoutingError(uint32_t dest, uint32_t request_id, uint8_t cha std::string err_full_hex = toHex(wire_buffer, wire_size, wire_size); LORA_LOG("[LORA] TX routing error full packet hex: %s\n", err_full_hex.c_str()); -#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - int state = board_.transmitRadio(wire_buffer, wire_size); -#else - int state = RADIOLIB_ERR_UNSUPPORTED; -#endif - if (state == RADIOLIB_ERR_NONE) + if (transmitWirePacket(wire_buffer, wire_size)) { - startRadioReceive(); return true; } return false; diff --git a/platform/esp/arduino_common/src/screen_sleep.cpp b/platform/esp/arduino_common/src/screen_sleep.cpp index 15fec31c..240bf9b1 100644 --- a/platform/esp/arduino_common/src/screen_sleep.cpp +++ b/platform/esp/arduino_common/src/screen_sleep.cpp @@ -7,6 +7,7 @@ #include #include +#include #include "board/BoardBase.h" #include "display/DisplayConfig.h" @@ -14,6 +15,7 @@ #include "freertos/semphr.h" #include "freertos/task.h" #include "lvgl.h" +#include "ui/localization.h" namespace { @@ -151,16 +153,15 @@ void screen_saver_refresh() lv_label_set_text(s_screen_saver_time_label, time_buf); const int unread = readUnreadCount(); - char unread_buf[32]; if (unread > 0) { - snprintf(unread_buf, sizeof(unread_buf), "Unread: %d", unread); + const std::string text = ::ui::i18n::format("Unread: %d", unread); + lv_label_set_text(s_screen_saver_unread_label, text.c_str()); } else { - snprintf(unread_buf, sizeof(unread_buf), "Unread: 0"); + ::ui::i18n::set_label_text(s_screen_saver_unread_label, "Unread: 0"); } - lv_label_set_text(s_screen_saver_unread_label, unread_buf); } void screen_saver_timer_cb(lv_timer_t* /*timer*/) @@ -208,16 +209,16 @@ void init_screen_saver() s_screen_saver_unread_label = lv_label_create(s_screen_saver_layer); lv_obj_set_style_text_color(s_screen_saver_unread_label, lv_color_hex(0x6B4A1E), 0); lv_obj_set_style_text_font(s_screen_saver_unread_label, &lv_font_montserrat_20, 0); - lv_label_set_text(s_screen_saver_unread_label, "Unread: 0"); + ::ui::i18n::set_label_text(s_screen_saver_unread_label, "Unread: 0"); lv_obj_align(s_screen_saver_unread_label, LV_ALIGN_CENTER, 0, 10); s_screen_saver_hint_label = lv_label_create(s_screen_saver_layer); lv_obj_set_style_text_color(s_screen_saver_hint_label, lv_color_hex(0x8A6A3A), 0); lv_obj_set_style_text_font(s_screen_saver_hint_label, &lv_font_montserrat_14, 0); #if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) - lv_label_set_text(s_screen_saver_hint_label, "Press SPACE to resume"); + ::ui::i18n::set_label_text(s_screen_saver_hint_label, "Press SPACE to resume"); #else - lv_label_set_text(s_screen_saver_hint_label, "Press SPACE to resume"); + ::ui::i18n::set_label_text(s_screen_saver_hint_label, "Press SPACE to resume"); #endif lv_obj_align(s_screen_saver_hint_label, LV_ALIGN_CENTER, 0, 40); diff --git a/platform/esp/arduino_common/src/ui/screens/gps/gps_modal.cpp b/platform/esp/arduino_common/src/ui/screens/gps/gps_modal.cpp index 7affd693..55a257c9 100644 --- a/platform/esp/arduino_common/src/ui/screens/gps/gps_modal.cpp +++ b/platform/esp/arduino_common/src/ui/screens/gps/gps_modal.cpp @@ -6,6 +6,7 @@ #include "ui/screens/gps/gps_modal.h" #include "sys/clock.h" #include "ui/LV_Helper.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/gps/gps_page_lifetime.h" #include "ui/screens/gps/gps_page_styles.h" @@ -82,7 +83,7 @@ lv_obj_t* modal_create_touch_title_bar(lv_obj_t* win, const char* title) lv_obj_align(title_bar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_t* title_label = lv_label_create(title_bar); - lv_label_set_text(title_label, title ? title : ""); + ::ui::i18n::set_label_text(title_label, title ? title : ""); gps::ui::styles::apply_zoom_popup_title_label(title_label); lv_obj_center(title_label); return title_bar; @@ -137,7 +138,7 @@ lv_obj_t* modal_create_touch_action_button(lv_obj_t* parent, } lv_obj_t* label = lv_label_create(button); - lv_label_set_text(label, text ? text : ""); + ::ui::i18n::set_label_text(label, text ? text : ""); gps::ui::styles::apply_control_button_label(label); lv_obj_center(label); return button; diff --git a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_components.cpp b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_components.cpp index 57e42a40..59b4e85b 100644 --- a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_components.cpp +++ b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_components.cpp @@ -4,6 +4,7 @@ #include "app/app_facade_access.h" #include "lvgl.h" #include "platform/ui/device_runtime.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/screens/gps/gps_modal.h" #include "ui/screens/gps/gps_page_input.h" @@ -18,6 +19,7 @@ #include #include #include +#include #define GPS_DEBUG 0 #if GPS_DEBUG @@ -59,7 +61,7 @@ void show_loading() lv_obj_center(g_gps_state.loading_msgbox); lv_obj_t* loading_label = lv_label_create(g_gps_state.loading_msgbox); - lv_label_set_text(loading_label, "Loading..."); + ::ui::i18n::set_label_text(loading_label, "Loading..."); gps::ui::styles::apply_loading_label(loading_label); lv_obj_center(loading_label); } @@ -113,7 +115,7 @@ void show_toast(const char* message, uint32_t duration_ms) lv_obj_center(g_gps_state.toast_msgbox); lv_obj_t* toast_label = lv_label_create(g_gps_state.toast_msgbox); - lv_label_set_text(toast_label, message); + ::ui::i18n::set_label_text(toast_label, message); gps::ui::styles::apply_toast_label(toast_label); lv_obj_center(toast_label); @@ -323,9 +325,9 @@ void zoom_popup_sync_touch_selection_impl() if (g_gps_state.popup_label != nullptr) { - char summary[48]; - std::snprintf(summary, sizeof(summary), "Selected level: %d", g_gps_state.popup_zoom); - lv_label_set_text(g_gps_state.popup_label, summary); + const std::string summary = + ::ui::i18n::format("Selected level: %d", g_gps_state.popup_zoom); + lv_label_set_text(g_gps_state.popup_label, summary.c_str()); } const int selected_index = g_gps_state.popup_zoom - gps_ui::kMinZoom; @@ -462,7 +464,7 @@ static void build_zoom_popup_ui(lv_obj_t* win) lv_obj_align(title_bar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_t* title_label = lv_label_create(title_bar); - lv_label_set_text(title_label, "select level"); + ::ui::i18n::set_label_text(title_label, "Select level"); gps::ui::styles::apply_zoom_popup_title_label(title_label); lv_obj_center(title_label); @@ -491,7 +493,7 @@ static void build_zoom_popup_ui(lv_obj_t* win) lv_obj_align(title_bar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_t* title_label = lv_label_create(title_bar); - lv_label_set_text(title_label, "select level"); + ::ui::i18n::set_label_text(title_label, "Select level"); gps::ui::styles::apply_zoom_popup_title_label(title_label); lv_obj_center(title_label); @@ -543,8 +545,7 @@ static void build_zoom_popup_ui(lv_obj_t* win) : 58; for (int level = gps_ui::kMinZoom; level <= gps_ui::kMaxZoom; ++level) { - char level_text[24]; - std::snprintf(level_text, sizeof(level_text), "Level %d", level); + const std::string level_text = ::ui::i18n::format("Level %d", level); lv_obj_t* btn = lv_btn_create(level_list); lv_obj_set_width(btn, LV_PCT(100)); @@ -554,7 +555,7 @@ static void build_zoom_popup_ui(lv_obj_t* win) gps::ui::styles::apply_control_button(btn); lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, level_text); + lv_label_set_text(label, level_text.c_str()); gps::ui::styles::apply_control_button_label(label); lv_obj_set_width(label, LV_PCT(100)); lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); @@ -714,13 +715,14 @@ void refresh_layer_popup_labels() if (s_layer_source_label != nullptr) { - char text[48]; - snprintf(text, sizeof(text), "Base: %s", map_source_label(map_source)); - lv_label_set_text(s_layer_source_label, text); + const std::string text = + ::ui::i18n::format("Base: %s", map_source_label(map_source)); + lv_label_set_text(s_layer_source_label, text.c_str()); } if (s_layer_contour_label != nullptr) { - lv_label_set_text(s_layer_contour_label, contour ? "Contour: ON" : "Contour: OFF"); + ::ui::i18n::set_label_text(s_layer_contour_label, + contour ? "Contour: ON" : "Contour: OFF"); } for (uint8_t i = 0; i < 3; ++i) { @@ -732,7 +734,7 @@ void refresh_layer_popup_labels() lv_obj_t* label = lv_obj_get_child(s_layer_contour_btn, 0); if (label != nullptr) { - lv_label_set_text(label, contour ? "Contour: ON" : "Contour: OFF"); + ::ui::i18n::set_label_text(label, contour ? "Contour: ON" : "Contour: OFF"); } } } @@ -760,9 +762,9 @@ void layer_set_map_source(uint8_t map_source) } else if (!map_source_directory_available(normalized)) { - char message[44]; - snprintf(message, sizeof(message), "%s layer missing", map_source_label(normalized)); - show_toast(message, 1600); + const std::string message = + ::ui::i18n::format("%s layer missing", map_source_label(normalized)); + show_toast(message.c_str(), 1600); } refresh_layer_popup_labels(); } @@ -890,7 +892,7 @@ void show_layer_popup() lv_obj_set_size(btn, LV_PCT(100), 22); gps::ui::styles::apply_control_button(btn); lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, text); + lv_label_set_text(label, ::ui::i18n::tr(text)); gps::ui::styles::apply_control_button_label(label); lv_obj_center(label); lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, reinterpret_cast(user_data)); @@ -962,7 +964,7 @@ void show_layer_popup() else { lv_obj_t* title = lv_label_create(win); - lv_label_set_text(title, "Map Layer"); + ::ui::i18n::set_label_text(title, "Map Layer"); gps::ui::styles::apply_control_button_label(title); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 4); @@ -1097,19 +1099,22 @@ void refresh_route_popup_labels() char text[128]; if (base != nullptr && base[0] != '\0') { - std::snprintf(text, sizeof(text), "Route: %s", base); + const std::string localized = ::ui::i18n::format("Route: %s", base); + std::snprintf(text, sizeof(text), "%s", localized.c_str()); } else { - std::snprintf(text, sizeof(text), "Route configured"); + std::snprintf(text, sizeof(text), "%s", ::ui::i18n::tr("Route configured")); } lv_label_set_text(s_route_info_label, text); } if (s_route_hint_label != nullptr) { - lv_label_set_text(s_route_hint_label, - g_gps_state.route_overlay_active ? "Tap Center Route to focus the active route." : "Tap Center Route to load and focus the configured route."); + ::ui::i18n::set_label_text( + s_route_hint_label, + g_gps_state.route_overlay_active ? "Tap Center Route to focus the active route." + : "Tap Center Route to load and focus the configured route."); } } @@ -1207,7 +1212,7 @@ void show_route_popup() lv_obj_set_size(btn, LV_PCT(100), 22); gps::ui::styles::apply_control_button(btn); lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, text); + lv_label_set_text(label, ::ui::i18n::tr(text)); gps::ui::styles::apply_control_button_label(label); lv_obj_center(label); lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, nullptr); @@ -1246,7 +1251,7 @@ void show_route_popup() else { lv_obj_t* title = lv_label_create(win); - lv_label_set_text(title, "Route"); + ::ui::i18n::set_label_text(title, "Route"); gps::ui::styles::apply_control_button_label(title); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 4); diff --git a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_layout.cpp b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_layout.cpp index 9c5a5e3e..5eb76204 100644 --- a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_layout.cpp +++ b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_layout.cpp @@ -1,4 +1,5 @@ #include "ui/screens/gps/gps_page_layout.h" +#include "ui/localization.h" #include "ui/page/page_profile.h" namespace gps::ui::layout @@ -131,7 +132,7 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) lv_obj_set_width(w.route_btn, LV_PCT(100)); lv_obj_set_height(w.route_btn, spec.control_btn_h); w.route_label = lv_label_create(w.route_btn); - lv_label_set_text(w.route_label, touch_layout ? "Route" : "[R]oute"); + lv_label_set_text(w.route_label, ::ui::i18n::tr(touch_layout ? "Route" : "[R]oute")); lv_obj_center(w.route_label); } diff --git a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp index b2a41667..f61a68ae 100644 --- a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp +++ b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp @@ -8,6 +8,7 @@ #include "screen_sleep.h" #include "sys/clock.h" #include "team/protocol/team_location_marker.h" +#include "ui/localization.h" #include "ui/screens/gps/gps_constants.h" #include "ui/screens/gps/gps_page_components.h" #include "ui/screens/gps/gps_page_lifetime.h" @@ -23,6 +24,7 @@ #include #include #include +#include // GPS marker icon (room-24px), defined in C image file extern "C" @@ -551,11 +553,12 @@ void update_altitude_display(const GPSData& gps_data) char alt_text[32]; if (gps_data.valid && gps_data.has_alt) { - snprintf(alt_text, sizeof(alt_text), "Alt: %.0f m", gps_data.alt_m); + const std::string text = ::ui::i18n::format("Alt: %.0f m", gps_data.alt_m); + snprintf(alt_text, sizeof(alt_text), "%s", text.c_str()); } else { - snprintf(alt_text, sizeof(alt_text), "Alt: -- m"); + snprintf(alt_text, sizeof(alt_text), "%s", ::ui::i18n::tr("Alt: -- m")); } lv_label_set_text(g_gps_state.altitude_label, alt_text); } @@ -603,31 +606,34 @@ void update_title_and_status() char coord_buf[64]; uint8_t coord_fmt = app::configFacade().getConfig().gps_coord_format; ui_format_coords(g_gps_state.lat, g_gps_state.lng, coord_fmt, coord_buf, sizeof(coord_buf)); - snprintf(title_buffer, sizeof(title_buffer), "Map - %.48s", coord_buf); + const std::string text = ::ui::i18n::format("Map - %.48s", coord_buf); + snprintf(title_buffer, sizeof(title_buffer), "%s", text.c_str()); } else if (!sd_ready) { - snprintf(title_buffer, sizeof(title_buffer), "Map - No SD Card"); + snprintf(title_buffer, sizeof(title_buffer), "%s", ::ui::i18n::tr("Map - No SD Card")); } else if (!g_gps_state.has_visible_map_data) { uint8_t source = sanitize_map_source(app::configFacade().getConfig().map_source); if (!g_gps_state.has_fix) { - snprintf(title_buffer, sizeof(title_buffer), "Map - no gps data"); + snprintf(title_buffer, sizeof(title_buffer), "%s", ::ui::i18n::tr("Map - no gps data")); } else if (map_source_directory_available(source)) { - snprintf(title_buffer, sizeof(title_buffer), "Map - No Map Data"); + snprintf(title_buffer, sizeof(title_buffer), "%s", ::ui::i18n::tr("Map - No Map Data")); } else { - snprintf(title_buffer, sizeof(title_buffer), "Map - %s Missing", map_source_label(source)); + const std::string text = + ::ui::i18n::format("Map - %s Missing", map_source_label(source)); + snprintf(title_buffer, sizeof(title_buffer), "%s", text.c_str()); } } else { - snprintf(title_buffer, sizeof(title_buffer), "Map - no gps data"); + snprintf(title_buffer, sizeof(title_buffer), "%s", ::ui::i18n::tr("Map - no gps data")); } GPS_LOG("[GPS] Setting page title to: '%s' (page=%p)\n", diff --git a/platform/esp/arduino_common/src/ui/screens/gps/gps_tracker_overlay.cpp b/platform/esp/arduino_common/src/ui/screens/gps/gps_tracker_overlay.cpp index b9a6a08e..aac7a969 100644 --- a/platform/esp/arduino_common/src/ui/screens/gps/gps_tracker_overlay.cpp +++ b/platform/esp/arduino_common/src/ui/screens/gps/gps_tracker_overlay.cpp @@ -2,6 +2,7 @@ #include "platform/ui/device_runtime.h" #include "platform/ui/tracker_runtime.h" +#include "ui/localization.h" #include "ui/screens/gps/gps_modal.h" #include "ui/screens/gps/gps_page_lifetime.h" #include "ui/screens/gps/gps_page_map.h" @@ -441,7 +442,7 @@ void build_tracker_modal() else { lv_obj_t* title = lv_label_create(state.tracker_modal.win); - lv_label_set_text(title, "Select Track"); + ::ui::i18n::set_label_text(title, "Select Track"); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8); } @@ -473,7 +474,7 @@ void build_tracker_modal() lv_obj_align(close_btn, LV_ALIGN_BOTTOM_MID, 0, -4); gps::ui::styles::apply_control_button(close_btn); lv_obj_t* close_label = lv_label_create(close_btn); - lv_label_set_text(close_label, "Close"); + ::ui::i18n::set_label_text(close_label, "Close"); gps::ui::styles::apply_control_button_label(close_label); lv_obj_center(close_label); lv_obj_add_event_cb(close_btn, on_tracker_close_clicked, LV_EVENT_CLICKED, nullptr); @@ -487,7 +488,7 @@ void build_tracker_modal() if (s_modal_names.empty()) { lv_obj_t* label = lv_label_create(list); - lv_label_set_text(label, "No track files"); + ::ui::i18n::set_label_text(label, "No track files"); gps::ui::styles::apply_control_button_label(label); if (touch_layout) { diff --git a/scripts/prepare_pages_site.py b/scripts/prepare_pages_site.py new file mode 100644 index 00000000..30057101 --- /dev/null +++ b/scripts/prepare_pages_site.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import argparse +import json +import os +import shutil +import urllib.error +import urllib.request +from pathlib import Path + +from webflash_targets import WEBFLASH_TARGETS + +SHOWCASE_IMAGES = ( + {"source": "docs/images/logo_big.png", "dest": "brand-logo.png"}, + {"source": "docs/images/main.png", "dest": "home-main.png"}, + {"source": "docs/images/map_osm.png", "dest": "nav-map-osm.png"}, + {"source": "docs/images/map_terrain.png", "dest": "nav-map-terrain.png"}, + {"source": "docs/images/map_satellite.png", "dest": "nav-map-satellite.png"}, + {"source": "docs/images/SkyPlot.png", "dest": "nav-skyplot.png"}, + {"source": "docs/images/screenshot_20260118_200651.png", "dest": "chat-compose.png"}, + {"source": "docs/images/messages.png", "dest": "chat-messages.png"}, + {"source": "docs/images/contacts.png", "dest": "chat-contacts.png"}, + {"source": "docs/images/team_join.png", "dest": "team-join.png"}, + {"source": "docs/images/team_map.png", "dest": "team-map.png"}, + {"source": "docs/images/subGScan.png", "dest": "utility-spectrum.png"}, + {"source": "docs/images/sstv_page.jpg", "dest": "utility-sstv-page.jpg"}, + {"source": "docs/images/sstv_image_result.jpg", "dest": "utility-sstv-result.jpg"}, + {"source": "docs/images/data_exchange.png", "dest": "utility-data-exchange.png"}, + {"source": "docs/images/tracker1.png", "dest": "utility-tracker.png"}, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Download the latest web flasher assets and generate site metadata." + ) + parser.add_argument( + "--site-root", + default="site", + help="Root directory of the static GitHub Pages site", + ) + parser.add_argument( + "--repo", + required=True, + help="GitHub repository slug, for example owner/name", + ) + return parser.parse_args() + + +def github_request(url: str) -> urllib.request.Request: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "trail-mate-pages-prep", + } + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + return urllib.request.Request(url, headers=headers) + + +def fetch_latest_release(repo: str) -> dict | None: + url = f"https://api.github.com/repos/{repo}/releases?per_page=10" + try: + with urllib.request.urlopen(github_request(url)) as response: + releases = json.load(response) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise + + for release in releases: + if not release.get("draft"): + return release + return None + + +def download(url: str, destination: Path) -> None: + with urllib.request.urlopen(github_request(url)) as response: + destination.write_bytes(response.read()) + + +def build_unavailable_metadata() -> dict: + return { + "available": False, + "tag_name": None, + "release_url": None, + "published_at": None, + "targets": { + target["id"]: { + "available": False, + "manifest_path": f"manifests/{target['id']}.json", + } + for target in WEBFLASH_TARGETS + }, + } + + +def write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def clear_directory_contents(path: Path, preserve_names: tuple[str, ...] = ()) -> None: + path.mkdir(parents=True, exist_ok=True) + for child in path.iterdir(): + if child.name in preserve_names: + continue + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + + +def copy_showcase_images(site_root: Path) -> None: + showcase_dir = site_root / "assets" / "showcase" + shutil.rmtree(showcase_dir, ignore_errors=True) + showcase_dir.mkdir(parents=True, exist_ok=True) + + for image in SHOWCASE_IMAGES: + source = Path(image["source"]) + destination = showcase_dir / image["dest"] + if not source.exists(): + raise FileNotFoundError(f"Missing showcase image: {source}") + shutil.copy2(source, destination) + + +def prepare_site(site_root: Path, release: dict | None) -> None: + manifests_dir = site_root / "manifests" + assets_dir = site_root / "assets" / "webflash" + data_dir = site_root / "data" + + shutil.rmtree(assets_dir, ignore_errors=True) + clear_directory_contents(manifests_dir, preserve_names=(".gitignore",)) + assets_dir.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(parents=True, exist_ok=True) + copy_showcase_images(site_root) + + if release is None: + write_json(data_dir / "latest-release.json", build_unavailable_metadata()) + return + + assets_by_name = { + asset["name"]: asset["browser_download_url"] for asset in release.get("assets", []) + } + metadata = { + "available": True, + "tag_name": release.get("tag_name"), + "release_url": release.get("html_url"), + "published_at": release.get("published_at"), + "targets": {}, + } + + version = (release.get("tag_name") or "").removeprefix("v") + for target in WEBFLASH_TARGETS: + asset_name = target["merged_asset_name"] + manifest_path = f"manifests/{target['id']}.json" + download_url = assets_by_name.get(asset_name) + + if not download_url: + metadata["targets"][target["id"]] = { + "available": False, + "manifest_path": manifest_path, + } + continue + + asset_path = assets_dir / asset_name + download(download_url, asset_path) + + manifest = { + "name": f"Trail Mate - {target['name']}", + "version": version or "latest", + "new_install_prompt_erase": True, + "builds": [ + { + "chipFamily": target["chip_family"], + "parts": [ + { + "path": f"../assets/webflash/{asset_name}", + "offset": 0, + } + ], + } + ], + } + write_json(manifests_dir / f"{target['id']}.json", manifest) + metadata["targets"][target["id"]] = { + "available": True, + "manifest_path": manifest_path, + } + + write_json(data_dir / "latest-release.json", metadata) + + +def main() -> int: + args = parse_args() + prepare_site(Path(args.site_root), fetch_latest_release(args.repo)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_webflash_release.py b/scripts/prepare_webflash_release.py new file mode 100644 index 00000000..e1b789e6 --- /dev/null +++ b/scripts/prepare_webflash_release.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +from webflash_targets import TARGETS_BY_ENV + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create merged ESP Web Tools firmware binaries for a built PlatformIO env." + ) + parser.add_argument("--env", required=True, help="PlatformIO environment name") + parser.add_argument( + "--build-root", + default=".pio/build", + help="Root directory that contains PlatformIO build outputs", + ) + parser.add_argument( + "--dist", + default="dist", + help="Directory where merged web flasher binaries should be written", + ) + parser.add_argument( + "--boot-app0", + required=True, + help="Path to boot_app0.bin from the Arduino ESP32 framework package", + ) + parser.add_argument( + "--esptool", + required=True, + help="Path to esptool.py used to merge the firmware image", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + target = TARGETS_BY_ENV.get(args.env) + if target is None: + print(f"Skipping web flasher packaging for unsupported env: {args.env}") + return 0 + + build_dir = Path(args.build_root) / args.env + dist_dir = Path(args.dist) + dist_dir.mkdir(parents=True, exist_ok=True) + + bootloader = build_dir / "bootloader.bin" + partitions = build_dir / "partitions.bin" + firmware = build_dir / "firmware.bin" + boot_app0 = Path(args.boot_app0) + esptool = Path(args.esptool) + output = dist_dir / target["merged_asset_name"] + + required_paths = (bootloader, partitions, firmware, boot_app0, esptool) + missing_paths = [str(path) for path in required_paths if not path.exists()] + if missing_paths: + raise FileNotFoundError( + "Missing required files for web flasher packaging:\n " + + "\n ".join(missing_paths) + ) + + command = [ + sys.executable, + str(esptool), + "--chip", + "esp32s3", + "merge_bin", + "-o", + str(output), + "--flash_mode", + target["flash_mode"], + "--flash_freq", + target["flash_freq"], + "--flash_size", + target["flash_size"], + "0x0", + str(bootloader), + "0x8000", + str(partitions), + "0xe000", + str(boot_app0), + "0x10000", + str(firmware), + ] + + print(f"Creating merged web flasher image for {args.env} -> {output}") + subprocess.run(command, check=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/webflash_targets.py b/scripts/webflash_targets.py new file mode 100644 index 00000000..a769bfba --- /dev/null +++ b/scripts/webflash_targets.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Final + +WEBFLASH_TARGETS: Final = ( + { + "id": "tlora-pager-sx1262", + "env": "tlora_pager_sx1262", + "name": "LilyGo T-LoRa Pager", + "subtitle": "SX1262 build for the 480x222 keyboard pager", + "chip_family": "ESP32-S3", + "flash_mode": "dio", + "flash_freq": "80m", + "flash_size": "16MB", + "merged_asset_name": "trail-mate-tlora-pager-sx1262-webflash.bin", + }, + { + "id": "tdeck", + "env": "tdeck", + "name": "LilyGo T-Deck", + "subtitle": "ESP32-S3 handheld build with keyboard and touch", + "chip_family": "ESP32-S3", + "flash_mode": "dio", + "flash_freq": "80m", + "flash_size": "16MB", + "merged_asset_name": "trail-mate-tdeck-webflash.bin", + }, + { + "id": "lilygo-twatch-s3", + "env": "lilygo_twatch_s3", + "name": "LilyGo T-Watch-S3", + "subtitle": "Touch-first ESP32-S3 watch build", + "chip_family": "ESP32-S3", + "flash_mode": "dio", + "flash_freq": "80m", + "flash_size": "16MB", + "merged_asset_name": "trail-mate-lilygo-twatch-s3-webflash.bin", + }, +) + +TARGETS_BY_ENV: Final = {target["env"]: target for target in WEBFLASH_TARGETS} +TARGETS_BY_ID: Final = {target["id"]: target for target in WEBFLASH_TARGETS} diff --git a/site/.nojekyll b/site/.nojekyll new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/site/.nojekyll @@ -0,0 +1 @@ + diff --git a/site/assets/.gitignore b/site/assets/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/site/assets/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/site/data/latest-release.json b/site/data/latest-release.json new file mode 100644 index 00000000..544b7fd7 --- /dev/null +++ b/site/data/latest-release.json @@ -0,0 +1,20 @@ +{ + "available": true, + "tag_name": "v0.1.16-alpha", + "release_url": "https://github.com/vicliu624/trail-mate/releases/tag/v0.1.16-alpha", + "published_at": "2026-04-17T06:20:17Z", + "targets": { + "tlora-pager-sx1262": { + "available": false, + "manifest_path": "manifests/tlora-pager-sx1262.json" + }, + "tdeck": { + "available": false, + "manifest_path": "manifests/tdeck.json" + }, + "lilygo-twatch-s3": { + "available": false, + "manifest_path": "manifests/lilygo-twatch-s3.json" + } + } +} diff --git a/site/index.html b/site/index.html new file mode 100644 index 00000000..ad74bd37 --- /dev/null +++ b/site/index.html @@ -0,0 +1,298 @@ + + + + + + Trail Mate + + + + + + + + + + +
+
+ + + Trail Mate + + +
+ +
+
+
+

Offline Navigation + LoRa Communication

+

Trail Mate turns ESP32 handhelds into field-ready offline tools.

+

+ A low-power firmware stack for outdoor navigation, GNSS diagnostics, + LoRa chat, team coordination, and protocol interoperability on + compact devices you can actually carry into the field. +

+ +
+ +
+
+ Trail Mate main menu screenshot +
+
+

Latest Release

+

Waiting for first tagged release

+
+
+

Captured From Real UI

+

Every feature block below uses screens from the actual firmware.

+
+
+
+ +
+

+ Trail Mate is not a single-page radio demo. It is a compact offline field stack: + navigation, direct LoRa messaging, team coordination, RF diagnostics, and utility + tooling shaped for embedded hardware with tight power and UI constraints. +

+
+ Offline Maps + GNSS Diagnostics + Mesh Chat + Team Mode + Sub-GHz Scan + PC Link +
+
+ +
+
+
+

Navigation

+

Offline mapping is a first-class workflow, not an afterthought.

+

+ The navigation side is built around field use on constrained hardware: + north-up rendering, SD card map tiles, simple visual hierarchy, and fast + satellite diagnostics without overloading the screen. +

+
    +
  • North-up map orientation keeps movement reading predictable while walking.
  • +
  • OSM, terrain, and satellite layers cover different outdoor planning needs.
  • +
  • Sky plot and fix diagnostics make GNSS quality visible instead of opaque.
  • +
  • Tracker history and breadcrumb awareness stay available on-device.
  • +
+
+
+
+ Offline OSM map view +
+
+ Terrain map layer +
+
+ Satellite map layer +
+
+ GNSS sky plot screen +
+
+
+ +
+
+

Communication

+

Messaging and team coordination stay practical under low bandwidth.

+

+ Trail Mate focuses on short-form communication that still works when latency, + packet loss, and limited input make smartphone assumptions unrealistic. +

+
    +
  • Compose and review LoRa messages directly on-device without a phone dependency.
  • +
  • Meshtastic and MeshCore compatibility keep it interoperable with existing networks.
  • +
  • Contact discovery, recent activity, and quick actions reduce menu friction.
  • +
  • ESP-NOW pairing plus LoRa runtime gives small teams shared identity and map context.
  • +
+
+
+
+ Message compose interface +
+
+ Message history screen +
+
+ Contacts screen +
+
+ Team join screen +
+
+ Team map interface +
+
+
+ +
+
+

Field Utilities

+

The firmware expands into a toolkit: scan, decode, sync, and inspect.

+

+ The project is broader than chat and maps. It already includes utility surfaces + for RF awareness, host integration, SSTV experimentation, and route or track review. +

+
    +
  • Energy Sweep exposes Sub-GHz occupancy for quick in-field channel planning.
  • +
  • SSTV receive and on-device decoding add a real experimental radio workflow.
  • +
  • PC Link exports structured device data over USB for downstream integration.
  • +
  • Tracker views keep recorded movement accessible on the device itself.
  • +
+
+
+
+ Sub-GHz energy sweep view +
+
+ SSTV receiver screen +
+
+ SSTV decode result +
+
+ USB data exchange screen +
+
+ Tracker overview screen +
+
+
+
+ +
+
+
+

Install

+

Web Flasher

+
+

+ Use Google Chrome or Microsoft Edge over HTTPS with a USB data cable. + The browser installer currently targets the ESP32-S3 builds. +

+
+ +
+
+

ESP32-S3

+

LilyGo T-LoRa Pager

+

SX1262 build for the keyboard pager hardware.

+
+

Checking release assets...

+
+ + Open latest release + +
+ +
+

ESP32-S3

+

LilyGo T-Deck

+

Keyboard + touch build tuned for the T-Deck layout.

+
+

Checking release assets...

+
+ + Open latest release + +
+ +
+

ESP32-S3

+

LilyGo T-Watch-S3

+

Touch-first watch build for compact field experiments.

+
+

Checking release assets...

+
+ + Open latest release + +
+ +
+

nRF52

+

GAT562 Mesh EVB Pro

+

+ Browser flashing is not wired up for the nRF52 target yet. Use the + release package for manual flashing instead. +

+
+

+ Download the packaged firmware from the latest GitHub release. +

+
+ + Open latest release + +
+
+ +
+

1. Connect the device with a USB data cable.

+

2. Put the board into download mode if your hardware requires it.

+

3. Choose the correct target above and let the browser flash the merged image.

+
+
+ + +
+
+ + diff --git a/site/main.js b/site/main.js new file mode 100644 index 00000000..0e5fe307 --- /dev/null +++ b/site/main.js @@ -0,0 +1,88 @@ +const releaseVersionEls = document.querySelectorAll("[data-release-version]"); +const releaseLinkEls = document.querySelectorAll("[data-release-link]"); +const boardCards = document.querySelectorAll("[data-board-id]"); + +function setReleaseVersion(text) { + releaseVersionEls.forEach((element) => { + element.textContent = text; + }); +} + +function setReleaseLinks(url) { + releaseLinkEls.forEach((element) => { + element.href = url; + }); +} + +function createInstallButton(manifestPath) { + const wrapper = document.createElement("div"); + wrapper.className = "install-button-wrap"; + + const button = document.createElement("esp-web-install-button"); + button.setAttribute("manifest", `./${manifestPath}`); + button.innerHTML = ` + + Use Chrome or Edge on desktop with Web Serial enabled. + Open this page over HTTPS to use the web flasher. + `; + + wrapper.append(button); + return wrapper; +} + +function renderBoardInstall(card, releaseData) { + const installSlot = card.querySelector("[data-install-slot]"); + const boardId = card.dataset.boardId; + const target = releaseData.targets?.[boardId]; + + installSlot.replaceChildren(); + + if (target?.available) { + installSlot.append(createInstallButton(target.manifest_path)); + const note = document.createElement("p"); + note.className = "board-hint"; + note.textContent = "Merged firmware image from the latest published release."; + installSlot.append(note); + return; + } + + const fallback = document.createElement("p"); + fallback.className = "board-hint"; + fallback.textContent = releaseData.available + ? "This board is missing web flasher assets in the latest release. Use the manual download for now." + : "Web flasher assets will appear here after the first tagged release is published."; + installSlot.append(fallback); +} + +async function loadReleaseData() { + try { + const response = await fetch("./data/latest-release.json", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Unexpected status ${response.status}`); + } + + const releaseData = await response.json(); + const versionLabel = releaseData.tag_name + ? `${releaseData.tag_name} ready for install` + : "Waiting for first tagged release"; + + setReleaseVersion(versionLabel); + if (releaseData.release_url) { + setReleaseLinks(releaseData.release_url); + } + + boardCards.forEach((card) => renderBoardInstall(card, releaseData)); + } catch (error) { + setReleaseVersion("Release metadata unavailable"); + boardCards.forEach((card) => { + const installSlot = card.querySelector("[data-install-slot]"); + installSlot.replaceChildren(); + const note = document.createElement("p"); + note.className = "board-hint"; + note.textContent = "Could not load release metadata. Open the GitHub release page for manual downloads."; + installSlot.append(note); + }); + } +} + +loadReleaseData(); diff --git a/site/manifests/.gitignore b/site/manifests/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/site/manifests/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 00000000..1b85894f --- /dev/null +++ b/site/styles.css @@ -0,0 +1,615 @@ +:root { + color-scheme: light; + --amber: #eba341; + --amber-dark: #c98118; + --warm-bg: #f6e6c6; + --panel-base: #faf0d8; + --line-solid: #e7c98f; + --text: #6b4a1e; + --text-dim: #8a6a3a; + --warn: #b94a2c; + --ok: #3e7d3e; + --info: #2d6fb6; + --bg: var(--warm-bg); + --bg-deep: #efd5a1; + --panel: rgba(250, 240, 216, 0.84); + --panel-strong: rgba(250, 240, 216, 0.95); + --ink: var(--text); + --muted: var(--text-dim); + --line: rgba(231, 201, 143, 0.92); + --accent: var(--amber-dark); + --accent-soft: rgba(235, 163, 65, 0.14); + --signal: var(--amber); + --shadow: 0 22px 56px rgba(107, 74, 30, 0.14); + --radius-xl: 28px; + --radius-lg: 20px; + --radius-md: 14px; + --esp-tools-button-color: var(--amber-dark); + --esp-tools-button-text-color: #fff7ea; + --esp-tools-button-border-radius: 999px; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Space Grotesk", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(235, 163, 65, 0.28), transparent 30%), + radial-gradient(circle at top right, rgba(45, 111, 182, 0.14), transparent 28%), + linear-gradient(180deg, var(--panel-base) 0%, var(--bg) 38%, #f1ddb8 100%); +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: + repeating-radial-gradient(circle at 20% 20%, rgba(201, 129, 24, 0.05) 0 2px, transparent 2px 26px), + repeating-linear-gradient( + 115deg, + rgba(107, 74, 30, 0.03) 0 2px, + transparent 2px 34px + ); + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.85), transparent 92%); +} + +a { + color: inherit; + text-decoration: none; +} + +.page-shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 18px 0 56px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 14px 18px; + position: sticky; + top: 10px; + z-index: 5; + backdrop-filter: blur(18px); + background: rgba(250, 240, 216, 0.82); + border: 1px solid var(--line); + border-radius: 999px; + box-shadow: var(--shadow); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.brand-logo { + display: block; + height: 38px; + width: auto; + filter: drop-shadow(0 8px 14px rgba(201, 129, 24, 0.18)); +} + +.brand-text { + display: inline-block; + color: var(--amber-dark); +} + +.nav { + display: flex; + flex-wrap: wrap; + gap: 16px; + color: var(--muted); + font-size: 0.95rem; +} + +.nav a:hover { + color: var(--info); +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.8fr); + gap: 24px; + padding: 54px 0 28px; + align-items: stretch; +} + +.hero-copy, +.hero-visual, +.story-section, +.board-card, +.links-section article { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); +} + +.hero-copy { + padding: 34px; +} + +.eyebrow, +.feature-kicker, +.board-chip { + margin: 0 0 10px; + font-family: "IBM Plex Mono", "Consolas", monospace; + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--signal); +} + +h1, +h2, +h3, +p, +dd, +dt { + margin: 0; +} + +h1 { + font-size: clamp(2.6rem, 7vw, 4.6rem); + line-height: 0.96; + letter-spacing: -0.05em; + max-width: 11ch; +} + +.lede { + max-width: 62ch; + margin-top: 18px; + color: var(--muted); + font-size: 1.05rem; + line-height: 1.7; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 24px; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 48px; + padding: 0 18px; + border-radius: 999px; + font-weight: 500; + transition: transform 160ms ease, background-color 160ms ease; +} + +.button:hover, +.flash-button:hover { + transform: translateY(-1px); +} + +.button-primary { + background: linear-gradient(180deg, var(--amber), var(--amber-dark)); + color: #fff8ef; +} + +.button-secondary { + background: rgba(235, 163, 65, 0.1); + color: var(--ink); + border: 1px solid var(--line); +} + +dt { + color: var(--muted); + font-size: 0.95rem; + margin-bottom: 6px; +} + +dd { + font-size: 1.05rem; + line-height: 1.45; +} + +.hero-visual { + position: relative; + min-height: 420px; + padding: 26px; + background: + linear-gradient(160deg, rgba(235, 163, 65, 0.22), rgba(45, 111, 182, 0.08)), + var(--panel-strong); +} + +.hero-screen { + height: 100%; + min-height: 320px; + padding: 22px; + border-radius: 22px; + background: linear-gradient(180deg, rgba(235, 163, 65, 0.11), rgba(250, 240, 216, 0.9)); + border: 1px solid var(--line); +} + +.hero-screen img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + filter: drop-shadow(0 18px 32px rgba(107, 74, 30, 0.16)); +} + +.hero-note { + position: absolute; + max-width: 240px; + padding: 14px 16px; + border-radius: 18px; + backdrop-filter: blur(14px); + background: rgba(250, 240, 216, 0.88); + border: 1px solid var(--line); + box-shadow: 0 16px 32px rgba(107, 74, 30, 0.12); +} + +.hero-note-release { + left: 18px; + bottom: 18px; +} + +.hero-note-proof { + right: 18px; + top: 18px; +} + +.hero-note-value { + color: var(--ink); + line-height: 1.45; +} + +.overview-ribbon { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.95fr); + gap: 18px; + align-items: center; + margin-top: 8px; + padding: 24px 28px; + border-radius: var(--radius-xl); + background: rgba(250, 240, 216, 0.82); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.overview-copy, +.story-lede, +.links-section p:last-child, +.section-note, +.board-copy, +.board-hint, +.support-note, +.workflow-note p { + color: var(--muted); + line-height: 1.7; +} + +.overview-tags { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.overview-tags span { + display: inline-flex; + align-items: center; + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + background: rgba(235, 163, 65, 0.14); + border: 1px solid var(--line); + color: var(--amber-dark); + font-family: "IBM Plex Mono", "Consolas", monospace; + font-size: 0.82rem; +} + +.story-stack { + display: grid; + gap: 24px; + margin-top: 24px; +} + +.story-section { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 22px; + padding: 24px; +} + +.story-section-alt { + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); +} + +.story-copy { + align-self: center; +} + +.story-copy h2, +.links-section h2, +.install-section h2 { + font-size: clamp(1.55rem, 3vw, 2.35rem); + line-height: 1.04; + letter-spacing: -0.035em; + margin-bottom: 12px; +} + +.story-points { + margin: 18px 0 0; + padding-left: 18px; + color: var(--ink); + display: grid; + gap: 12px; + line-height: 1.55; +} + +.story-media { + display: grid; + gap: 12px; + min-height: 360px; +} + +.story-media-navigation { + grid-template-columns: 1.35fr 1fr 1fr; + grid-template-rows: 1.2fr 0.95fr; +} + +.story-media-communication { + grid-template-columns: 0.9fr 1fr 1fr; + grid-template-rows: 0.92fr 0.92fr 1.05fr; +} + +.story-media-utilities { + grid-template-columns: 1fr 1fr 1.1fr; + grid-template-rows: 0.95fr 0.9fr 1.05fr; +} + +.shot { + margin: 0; + min-width: 0; + overflow: hidden; + border-radius: 18px; + background: + linear-gradient(180deg, rgba(250, 240, 216, 0.98), rgba(246, 230, 198, 0.96)); + border: 1px solid var(--line); + box-shadow: 0 18px 40px rgba(107, 74, 30, 0.1); +} + +.shot img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.shot-wide { + grid-column: span 2; +} + +.shot-tall { + grid-row: span 2; +} + +.shot-panel { + padding: 10px; +} + +.install-section { + margin-top: 34px; + padding: 32px; + border-radius: 36px; + background: + radial-gradient(circle at top right, rgba(45, 111, 182, 0.07), transparent 28%), + linear-gradient(145deg, rgba(235, 163, 65, 0.22), rgba(201, 129, 24, 0.08)), + rgba(250, 240, 216, 0.98); + color: var(--ink); + border: 1px solid var(--line); + box-shadow: 0 28px 70px rgba(107, 74, 30, 0.14); +} + +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; +} + +.section-note { + max-width: 52ch; + color: var(--muted); +} + +.install-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-top: 24px; +} + +.board-card { + padding: 24px; + background: + linear-gradient(180deg, rgba(235, 163, 65, 0.14), rgba(250, 240, 216, 0.94)), + rgba(250, 240, 216, 0.96); + color: var(--ink); + border: 1px solid var(--line); + border-radius: var(--radius-lg); +} + +.board-card h3 { + font-size: 1.5rem; + letter-spacing: -0.03em; + margin-bottom: 8px; +} + +.board-copy { + min-height: 52px; + color: var(--muted); +} + +.board-install { + margin-top: 18px; + padding: 16px; + border-radius: var(--radius-md); + background: rgba(250, 240, 216, 0.72); + border: 1px solid var(--line); +} + +.board-card-muted { + background: + linear-gradient(180deg, rgba(185, 74, 44, 0.08), rgba(250, 240, 216, 0.94)), + rgba(250, 240, 216, 0.96); +} + +.install-button-wrap { + display: flex; + flex-direction: column; + gap: 12px; +} + +.flash-button { + appearance: none; + border: 0; + min-height: 48px; + padding: 0 18px; + border-radius: 999px; + background: linear-gradient(180deg, var(--amber), var(--amber-dark)); + color: #fff8ef; + box-shadow: 0 12px 24px rgba(201, 129, 24, 0.24); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.release-link { + display: inline-flex; + margin-top: 16px; + color: var(--info); + font-weight: 500; +} + +.workflow-note { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 22px; +} + +.workflow-note p { + padding: 14px 16px; + border-radius: var(--radius-md); + background: rgba(235, 163, 65, 0.12); + border: 1px solid var(--line); + color: var(--muted); +} + +.links-section { + margin-top: 28px; +} + +.links-section article { + padding: 26px; +} + +@media (max-width: 960px) { + .hero, + .overview-ribbon, + .story-section, + .story-section-alt, + .install-grid, + .workflow-note { + grid-template-columns: 1fr; + } + + .overview-tags { + justify-content: flex-start; + } + + .story-media-navigation, + .story-media-communication, + .story-media-utilities { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: auto; + } + + .shot-wide, + .shot-tall { + grid-column: auto; + grid-row: auto; + } + + .section-heading { + align-items: start; + flex-direction: column; + } +} + +@media (max-width: 720px) { + .page-shell { + width: min(100% - 24px, 1180px); + padding-top: 12px; + } + + .topbar { + flex-direction: column; + align-items: stretch; + border-radius: 24px; + } + + .brand { + justify-content: center; + } + + .nav { + justify-content: space-between; + gap: 10px; + } + + .hero-copy, + .hero-visual, + .overview-ribbon, + .story-section, + .install-section, + .board-card, + .links-section article { + padding: 22px; + } + + .hero-note { + position: static; + max-width: none; + margin-top: 12px; + } + + .hero-visual { + min-height: auto; + } + + .hero-screen { + min-height: 240px; + } + + h1 { + max-width: none; + font-size: clamp(2.4rem, 14vw, 3.5rem); + } +}