diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71626ac8..67662a01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,26 @@ jobs: - name: Verify USB MSC SD transaction boundary run: python3 scripts/check_shared_spi_usb_msc_boundary.py + - name: Verify SD transport strategies and FAT/exFAT transfers + run: | + cmake -S tests/sd_transport -B .codex-build/sd-transport -DCMAKE_BUILD_TYPE=Debug + cmake --build .codex-build/sd-transport --parallel 2 + ctest --test-dir .codex-build/sd-transport --output-on-failure + python3 tests/sd_transport/test_build_configuration.py + + - name: Verify bounded map request and completion delivery + run: | + cmake -S tests/map_tile_pipeline -B .codex-build/map-tile-pipeline -DCMAKE_BUILD_TYPE=Debug + cmake --build .codex-build/map-tile-pipeline --parallel 2 + ctest --test-dir .codex-build/map-tile-pipeline --output-on-failure + python3 tests/map_tile_pipeline/test_ui_contract.py + - name: Verify ESP stack hygiene run: python3 scripts/check_esp_stack_hygiene.py + - name: Verify firmware version selection + run: python3 tests/test_firmware_version.py + - name: Verify Wi-Fi access policy run: python3 scripts/check_wifi_access_policy.py @@ -112,7 +129,7 @@ jobs: if [[ "${GITHUB_REF}" == refs/tags/* ]]; then VERSION="${GITHUB_REF_NAME#v}" else - VERSION=$(iconv -f UTF-16LE -t UTF-8 CHANGELOG.md | awk -F'[][]' '/^## \\[[0-9]/ {print $2; exit}') + VERSION=$(iconv -f UTF-16LE -t UTF-8 CHANGELOG.md | awk -F'[][]' '/^## \[[0-9]/ {print $2; exit}') fi if [ -z "$VERSION" ]; then VERSION="unknown" @@ -162,6 +179,15 @@ jobs: cmake --build .codex-build/wio-touch --parallel 2 ctest --test-dir .codex-build/wio-touch --output-on-failure + - name: Verify map gesture release with LVGL + if: matrix.env == 'wio_tracker_l2' + run: | + cmake -S tests/map_gesture -B .codex-build/map-gesture \ + -DCMAKE_BUILD_TYPE=Debug \ + -DLVGL_DIR="$PWD/.pio/libdeps/wio_tracker_l2/lvgl" + cmake --build .codex-build/map-gesture --parallel 2 + ctest --test-dir .codex-build/map-gesture --output-on-failure + - name: Prepare and verify nRF52840 UF2 if: matrix.env == 'gat562_mesh_evb_pro' || matrix.env == 't-echo-lite' run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index bfdfdefd..1222177c 100644 Binary files a/CHANGELOG.md and b/CHANGELOG.md differ diff --git a/boards/wio_tracker_l2.json b/boards/wio_tracker_l2.json index 92ce1c71..0779786d 100644 --- a/boards/wio_tracker_l2.json +++ b/boards/wio_tracker_l2.json @@ -19,7 +19,8 @@ "psram_type": "opi", "hwids": [["0x303A", "0x1001"]], "mcu": "esp32s3", - "variant": "esp32s3" + "variant": "wio_tracker_l2", + "variants_dir": "variants" }, "connectivity": ["wifi", "bluetooth", "lora"], "debug": { diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index 6f58fd72..36664127 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -471,6 +471,7 @@ set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/idf_common/src/gps_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/lv_helper.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_device_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_map_diagnostics.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/platform_ui_firmware_update_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_http_client_runtime.cpp" diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index c4b7250f..dadbc390 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -159,6 +159,7 @@ set(TRAIL_MATE_LINUX_COMMON_SOURCES "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/device_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/firmware_update_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/gps_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/map_diagnostics.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/hostlink_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/lora_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/orientation_runtime.cpp" diff --git a/docs/map-tile-pipeline-reliability.md b/docs/map-tile-pipeline-reliability.md new file mode 100644 index 00000000..cd8fad59 --- /dev/null +++ b/docs/map-tile-pipeline-reliability.md @@ -0,0 +1,198 @@ +# Map tile request and rendering lifecycle + +This document describes the shared map pipeline, the boundaries between its +components, and the tests that protect those boundaries. Storage-specific +transfer policy is documented in [SD transport optimization](sd-transport-optimization.md). + +## Request flow + +The ESP integration is in +[map_tiles.cpp](../platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp). + +1. `tile_loader_step()` selects a visible tile without an image, a confirmed + missing flag, or an outstanding request. Its retry deadline must have elapsed. +2. `request_base_tile_async()` submits it through `MapTileAsyncHost`. + An accepted or duplicate request supplies a generation and command ID; + these are stored with the tile's pending flag. +3. `MapTileCommandQueue` records the request in one of 16 slots. A duplicate + tile in the same generation reuses the existing ID. Popping a command + changes its slot from queued to in flight; it does not free the slot. +4. The single worker reserves a completion slot before reading. If no slot + is available, it retains the command and waits without a filesystem lock. +5. The worker reads the source and publishes a ready, failed, or retry result. + The completion owns its payload. Payload allocation failure produces a + failed result rather than silently dropping the request. +6. The UI dequeues the result. `popEvent()` or `popEventIf()` completes the + matching command slot, making it available for another request. +7. For a current result, `apply_map_tile_event()` checks the tile's generation + and ID, clears pending, and either decodes/renders or sets failure/retry + state. Maintenance releases obsolete results. + +For example, request `(generation=2, id=17)` still occupies its command slot +after the read finishes. The slot is released on result consumption or +generation cancellation. A late result for ID 17 cannot clear the pending +state of a replacement request with ID 18. + +## Component responsibilities + +| Code | Responsibility | +| --- | --- | +| [map_tile_request_queue.h](../modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_request_queue.h) | Fixed request slots, duplicate detection, queued/in-flight state, matching completion | +| [map_tile_completion_queue.h](../modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_completion_queue.h) | Result reservations, cancellation and stale reservation tokens | +| [owned_map_tile_event.h](../modules/ui_map_runtime/include/ui_map_runtime/map_tiles/owned_map_tile_event.h) | Move-only payload ownership | +| [ESP queue adapters](../platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles) | FreeRTOS synchronization and capacity notifications | +| [map_viewport.cpp](../modules/ui_shared/src/ui/widgets/map/map_viewport.cpp) | Gesture lifecycle, preview/committed view, loader timer | +| [sd_card_runtime.cpp](../platform/esp/arduino_common/src/storage/sd_card_runtime.cpp) | File access, synchronization and transport policy integration | + +Map request/result handling is shared by WIO, T-Deck and Pager. Shared-SPI +and SDMMC decisions remain in storage. Allocation/free and file I/O occur +outside queue locks. Payload ownership moves from producer to queue to UI. + +## Capacity, cancellation and retries + +| Condition | Behavior | +| --- | --- | +| Command slots are full | Submission returns backpressure; UI retries later | +| Completion slots are full | Worker waits before file I/O; it does not read and then drop the result | +| User is dragging | Maintenance removes obsolete results; valid results wait for rendering to resume | +| Result belongs to an invisible tile or outdated request | Maintenance releases its payload and clears matching pending state | +| Generation is cancelled during a read | Publication is invalidated; the hardware read itself is not forcibly aborted | +| Producer still owns a cancelled reservation | Slot is retained until acknowledgement; reservation epochs reject stale tokens | +| Last viewport closes | Active generation is cancelled; a capacity-blocked worker is woken for shutdown | +| File access is busy or fails | Deliver retry/error; do not treat this as proof that the file is absent | + +Ordinary panning does not create a new generation or immediately cancel all +old queued reads. Outdated completions are cleaned up by visibility/request +checks. There is no automatic request-expiration timer. + +After a failed open, storage probes the path under the same filesystem lock. +Successful opens do not perform this extra directory scan. Probe scratch is +allocated lazily with PSRAM preference, not on the ESP task stack. SDMMC +retains the first block I/O error for the operation even if a later metadata +read succeeds; SPI uses its scoped card-error state. + +A corrupt FAT long-name checksum or unsupported path form produces uncertain +evidence, not a confirmed missing file. An explicit end-of-directory marker +is needed to prove absence; full directories without it remain uncertain. +POI manifest/index errors remain retryable rather than permanently marking +the package as checked and unavailable. + +## Gesture handling and UI scheduling + +`loader_timer_cb()` runs maintenance before checking the gesture flags. +Maintenance may discard obsolete results, but cannot start file I/O, decode +images or update LVGL objects. Rendering pauses while any of these are set: + +```cpp +impl->gesture_pressed || impl->gesture_dragging || impl->drag_preview_active +``` + +During dragging, the viewport translates existing objects for preview. +On release/cancellation, the map callback finishes the gesture and requests +a committed viewport update. `apply_model()` clears preview state; subsequent +loader steps can consume valid results and render tiles again. + +The map's object-level event handler uses `lv_event_stop_bubbling(e)`, not +`lv_indev_stop_processing()`. In LVGL 9.4, setting the input-device stop flag +inside an object `PRESSING` callback can suppress delivery of a later +`RELEASED` event. The map would then remain marked as dragging and keep +skipping the loader. Stopping only current-event bubbling preserves release +delivery. The gesture surface is already non-scrollable. + +The loader applies at most one image event per drain, with a 60 ms cooldown +and a 4 ms admission budget. Failed/control events do not consume the image +quota or cooldown. The worker retains its 32 ms spacing. The admission +budget cannot interrupt a synchronous PNG decode already running. + +## Diagnostic output + +`TRAIL_MATE_MAP_DIAGNOSTICS=1` explicitly enables detailed `[MAPD]` records; +it is disabled by default. Arduino builds write these through `Serial` +rather than assuming libc stdout and USB CDC are the same channel. +Shared UI calls only the `platform/ui/map_diagnostics.h` contract. Output +selection belongs to the Arduino, ESP-IDF and Linux platform implementations; +the shared diagnostic header contains no SDK includes or platform switches. + +| Record | Meaning | +| --- | --- | +| `heartbeat` | Every two seconds, including during a rendering pause: gesture flags, valid focus, zoom and pan | +| `gesture-end`, `commit-view` | Release/cancellation followed by committing the viewport | +| `submit` | Tile/layer, generation, request ID and submission status | +| `worker-start`, `worker-end` | Start/end of a worker execution | +| `worker-capacity-wait` | Worker is waiting for result space without a file lock | +| `consume`, `discard`, `pending-clear` | Result delivery, obsolete-result cleanup and matching pending state | +| `decode-fail`, `event-fail`, `render` | Failure stage or image-object binding result | +| `tile`, `viewport` | Visibility, image/hidden state, request ID, retry deadline, actual/projected positions and cache references | +| `queues` | Queued/in-flight counts, result occupancy, pressure, consumption/render counts and free heap | + +Only interpret queue counts when `cmd_ok`/`result_ok` are true. `inflight` +includes results awaiting consumption, not just a currently executing read. +A successful `render` record does not prove panel refresh completion. + +For a stall, first check whether gesture flags return to zero after release. +Then compare queue occupancy with capacity, and match IDs across submission, +worker execution and UI consumption. If results are consumed but tiles are +not visible, compare positions/visibility across commit and eviction, and +inspect decode errors. Do not infer a full queue from a frozen image alone. + +Serial output adds execution time. Disable detailed diagnostics before +throughput comparisons. Heartbeats and capacity-wait records are rate-limited; +lifecycle records are per request or commit, not per SD sector. + +### Timing summaries + +The separate `[GPS][MAP][perf]` summary uses the libc console and is emitted +by the idle loader at most every five seconds when results have been consumed. + +| Field | Measurement | +| --- | --- | +| `cmd` | Submission to successful execution attempt, including completion-capacity waiting | +| `worker` | Source work plus payload allocation/copy up to publication | +| `lock`, `open`, `read` | File stages; subsets of worker time, not extra times to add to it | +| `result` | Payload-ready timestamp to dequeue, including gesture pauses | +| `decode` | Decode/cache preparation, including failed attempts | +| `apply` | LVGL update/bookkeeping, not physical display completion | + +Times are integer milliseconds, average/maximum. `file_n` counts measured +file accesses; source-only results do not dilute those averages. `consumed` +includes obsolete-result cleanup; `rendered` counts successful raster +applications. Queue high water and reservation-pressure counts are lifetime +values; timing/submission summaries are interval values. + +SDMMC may additionally emit `[GPS][MAP][block]` with read-call count, sectors, +largest batch and hardware-call time. Failed attempts are included, so these +are not successful-throughput measurements. Shared SPI does not fabricate +equivalent counters when the adapter cannot supply them. + +## Regression tests + +```sh +cmake -S tests/map_tile_pipeline -B .codex-build/map-tile-pipeline +cmake --build .codex-build/map-tile-pipeline +ctest --test-dir .codex-build/map-tile-pipeline --output-on-failure +python tests/map_tile_pipeline/test_ui_contract.py + +cmake -S tests/map_gesture -B .codex-build/map-gesture -DLVGL_DIR=/path/to/lvgl-9.4 -DCMAKE_BUILD_TYPE=Debug +cmake --build .codex-build/map-gesture +ctest --test-dir .codex-build/map-gesture --output-on-failure +``` + +The pipeline tests cover bounded requests/results, reservation exhaustion +before I/O, duplicates, cancellation/publication races, stale tokens, +allocation failure, source errors, timing aggregation and POI retries. +The ESP queue adapter is production code; RTOS synchronization is replaced +by host primitives. +Diagnostic tests compile the Arduino output adapter with a host serial stub, +check formatted output when enabled, and verify that disabled diagnostic +arguments are not evaluated. UI source checks protect the platform boundary. + +The UI source checks guard maintenance-before-pause ordering and prohibit +board/transport branches in the map pipeline. The real-LVGL gesture test +compiles the production callback and feeds pointer samples through LVGL; +it does not invoke the release callback directly. A tap and 100 repeated +drags, including movement outside the surface, must clear gesture state and +emit exactly one end/cancellation per drag. + +These tests do not emulate the touch controller or panel scanout, and do not +measure real-card throughput. Device validation should cover repeated pans, +returning to earlier regions, zoom changes and closing/reopening the map. diff --git a/docs/sd-transport-optimization.md b/docs/sd-transport-optimization.md new file mode 100644 index 00000000..901b16ce --- /dev/null +++ b/docs/sd-transport-optimization.md @@ -0,0 +1,190 @@ +# SD transport strategies and WIO L2 memory budget + +The Arduino SD runtime retains one file API and one filesystem ownership lock. +Boards supply wiring; transport selection is a firmware build choice, not a +board-name branch inside file reads, writes, or the block driver. + +## Responsibilities + +| Layer | Responsibility | +| --- | --- | +| Board | Power sequencing and typed wiring/host configuration | +| SD runtime | Files/directories, filesystem mutex, application/external raw owner, errors and flush semantics | +| Transfer policy | Maximum file request slice for the selected transport | +| Operation scope | Transport-specific context within the filesystem lock | +| SPI driver hooks | Existing shared coordinator leases at physical transactions | +| SDMMC block device | Host/card lifetime, DMA compatibility, multi-sector batches and owned bounce buffer | + +`SharedSpiSdTransferPolicy` keeps the existing 512-byte file slice. The SPI +operation scope preserves policy, deadline input, owner label, failure status, +and nested context restoration. The coordinator, SPI mount/recovery sequence, +bus pin restoration and physical transaction hooks keep their existing behavior. +File slicing alone does not release a physical bus lease. + +`SdmmcSdTransferPolicy` uses 16 KiB file requests, at most 32 sectors per direct +DMA call, and a 4 KiB (8-sector) internal DMA bounce buffer. File requests and +DMA batch sizes are deliberately independent. SdFat still controls metadata, +unaligned file offsets, cluster boundaries and fragmented extents. + +The SDMMC scope does not create SPI context or acquire the shared SPI bus. +Both transports retain the filesystem lock and external raw-access ownership +checks. Chunk boundaries do not release the filesystem lock or permit an +ownership handoff in the middle of an operation. + +## SDMMC configuration and lifecycle + +New boards use `mount_sd_card(const SdmmcSdConfig&)`. The config describes slot, +CLK/CMD/data pins, 1-bit or 4-bit width, permitted clock ceiling and pullups. +WIO supplies its three pins and retains 1-bit, 20 MHz maximum configuration. +The maximum is not a measured or negotiated bus frequency. No high-speed +clock change is part of this optimization; 4-bit support requires actual +wiring and validation on that board. The old three-pin entry point delegates +to the same typed implementation for compatibility. + +The block device owns a DMA buffer allocated once at initialization and freed +on initialization failure or unmount. It is non-copyable. Direct transfers +require an aligned buffer whose entire address range is conservatively within +the target's internal DMA-capable region. PSRAM and unaligned callers use the +bounce buffer. Each hardware submission has a bounded sector count. + +Zero-length operations perform no I/O. Range/size overflow is rejected before +transfer. A failed batch stops the operation; there is no implicit retry after +a possibly partial write. The bool block-device API cannot report how many +sectors were committed before a failure. A failed bounced read is not copied +to the caller. Earlier successful batches may already have changed the caller +buffer or card. + +## RAM changes + +- WIO release firmware opts into the existing build-private TinyUSB DFU trim. + The framework package remains untouched. Hardware USB serial/JTAG and ROM + download behavior are independent of the removed application DFU classes. +- A version-checked, idempotent prebuild transformation adds an opt-out around + audio-driver 0.3.1's global codec instances in WIO's environment-local + dependency. WIO defines that opt-out and owns one ES8311 codec instance. + Codec classes and their implementation remain upstream. The transformation + does not run for T-Deck/Pager and refuses unknown library versions/layouts. +- Removing the 512-byte static SD scratch buffer lowers static BSS, but its + replacement consumes 4096 bytes of internal heap while mounted. This is a + throughput/memory tradeoff, not a 4096-byte memory saving. +- Touch IME button-map pointer arrays are immutable as well as their strings. + The six arrays can live in flash instead of consuming 872 bytes of WIO's + static internal RAM. This is a shared UI fix, not a board-specific bypass. +- The WIO board singleton follows T-Deck's placement policy: prefer PSRAM, + fall back to internal heap, and keep only the pointer in static storage. + The current OPI SDK initializes PSRAM before global constructors. This + relocates the 1,256-byte object; it does not eliminate its memory use. + Radio handling is polled in task context. RTOS locks and peripheral DMA + buffers remain separately owned and are not moved to PSRAM. +- WIO uses its own Arduino pin variant. The generic ESP32-S3 variant declared + a GPIO48 NeoPixel even though WIO uses GPIO48 for I2C SCL and its user LED + is on the I/O expander. Correcting the variant removes the unintended + GPIO -> RGB -> RMT dependency. Compile-time checks keep the UART/I2C/SPI + defaults consistent with the board profile and reject built-in LED aliases. + This does not disable RMT globally or change another board's variant. + +## Verification + +The first successful WIO build after these changes reports 100,236 bytes of +static RAM and 4,069,033 bytes of flash, versus 108,156 and 4,137,609 bytes in +the pre-change build. The reductions are 7,920 bytes and 68,576 bytes. The new +4 KiB mounted DMA heap allocation must be accounted for separately. The ELF +no longer contains `_dfu_ctx`, the old `s_sector_scratch`, or unused global +codec instances; the board-owned ES8311 instance remains. + +All three firmware builds completed successfully: + +| Environment | Static RAM | Flash | Comparison | +| --- | ---: | ---: | --- | +| `wio_tracker_l2` | 100,236 B | 4,069,033 B | RAM -7,920 B; flash -68,576 B | +| `tdeck` | 95,080 B | 3,982,017 B | RAM unchanged; flash +16 B | +| `tlora_pager_sx1262` | 98,728 B | 4,179,165 B | Successful regression build; no same-revision pre-change size baseline | + +The first T-Deck attempt stopped before C++ compilation because SCons could +not import `SCons.Tool.FortranCommon`. A subsequent direct import succeeded, +and the queued retry completed successfully without a firmware-source change +or a modification to the shared tool package. + +The second RAM pass reduces WIO to **97,652 bytes static RAM** and +**4,059,745 bytes flash**: another 2,584 bytes of static RAM and 9,288 bytes +of flash below the first pass, or 10,504 / 77,864 bytes below the original +build. ELF inspection confirms all six updated IME maps are flash-mapped +(`0x3c...`), the board singleton is a four-byte pointer rather than a +1,256-byte static object, and `g_rmt_objects`, `rmt_contex`, `neopixelWrite` +and `rmtWriteBlocking` are absent. The 4 KiB mounted SDMMC DMA allocation +and transfer policies are unchanged by this pass. Static RAM is not a +measurement of total runtime memory consumption. + +Second-pass firmware verification completed with exit code zero for all +three environments (T-Deck succeeded on a queued retry after the same +pre-compilation SCons import failure described above): + +| Environment | Static RAM | Flash | Change from first pass | +| --- | ---: | ---: | --- | +| `wio_tracker_l2` | 97,652 B | 4,059,745 B | RAM -2,584 B; flash -9,288 B | +| `tdeck` | 95,080 B | 3,982,017 B | Unchanged | +| `tlora_pager_sx1262` | 98,728 B | 4,179,165 B | Unchanged | + +WIO's remaining static-RAM gap to T-Deck is 2,572 bytes. Its display, audio, +SDMMC and synchronization state is not identical to T-Deck's; exact equality +is not an acceptance criterion. Check runtime internal heap and PSRAM on +hardware rather than treating a smaller static number as sufficient proof. + +The real-LVGL touch editor host regression also passes with the immutable +maps, alongside all four SD transport/filesystem host tests and the three +build-configuration tests. + +Host tests run the production block driver with injected SDMMC/heap failures, +and run the repository's real SdFat FAT16/FAT32/exFAT sources over the same +driver with a sparse in-memory card. They check actual multi-sector calls, +data after close/remount, non-sector-sized tails, unaligned/PSRAM-like buffers, +unaligned file seeks, interleaved file allocations, direct DMA batches, bounds, +partial failures and cleanup. Policy tests cover +both build selections and nested SPI operation status restoration. + +For the 71,717-byte test file, changing the file slice from 512 bytes to 16 KiB +reduced fake-hardware read submissions from 142 to 37 (FAT16), 142 to 20 +(FAT32), and 141 to 19 (exFAT). FAT16's smaller clusters cap batches at four +sectors in this fixture; FAT32/exFAT reach eight. These are request-count +measurements with the real filesystem/driver, not real-card throughput claims. + +```sh +cmake -S tests/sd_transport -B build-sd-transport -G Ninja +cmake --build build-sd-transport +ctest --test-dir build-sd-transport --output-on-failure +python tests/sd_transport/test_build_configuration.py +``` + +Use GCC/Clang for these standalone host tests. Firmware verification covers +`wio_tracker_l2`, `tdeck` and `tlora_pager_sx1262`, plus the repository's +`check_shared_spi_*` boundary checks. Builds alone do not prove electrical +stability or real-card throughput. + +On-device acceptance can use the existing SD card/filesystem and measure +sequential 4 KiB/64 KiB/1 MiB/16 MiB reads and writes, checking readback hashes. +Repeat with UI, LoRa and BLE activity. Record throughput, operation latency, +minimum internal free heap and largest free block after mount and after +repeated unmount/remount. Retain configuration-save flush behavior. Real-card +MB/s, tail latency, audio and USB behavior must be reported as hardware results +only after those checks have actually run. + +For this change, hardware acceptance is performed by the device owner. Useful +checks, in order: + +1. WIO boots, mounts the existing card, reads map tiles, saves/reloads config, + and plays/records audio using ES8311. +2. Exercise file operations on the candidate firmware, with per-chunk serial + tracing disabled for throughput measurements. Check data after close and + remount; do not count only bytes accepted into a write cache. The owner has + declined A/B flashing; it is not required by this verification plan. +3. Repeat during map scrolling and radio traffic. Check internal heap after + several mount/unmount cycles, accounting for the mounted 4 KiB DMA buffer. +4. On T-Deck/Pager, repeat map scrolling, radio traffic and SD save/load together; + check display/radio recovery after SD unmount/remount and USB raw ownership + transitions where that feature is enabled. +5. Confirm WIO USB serial and the ROM BOOT/RESET download path. Application + TinyUSB DFU is intentionally absent from the release firmware. + +The subsequent map pipeline reliability work is documented in +`map-tile-pipeline-reliability.md`. Its phase timings distinguish storage work, +completion waiting, decode and UI updates without a board-specific map path. diff --git a/modules/core_sys/include/platform/ui/README.md b/modules/core_sys/include/platform/ui/README.md index 5883fa0a..f06d8e3e 100644 --- a/modules/core_sys/include/platform/ui/README.md +++ b/modules/core_sys/include/platform/ui/README.md @@ -21,6 +21,11 @@ They must not depend on concrete implementations under `platform/esp/*`, ## Current contract set +- `map_diagnostics.h` + Optional map diagnostic output. Shared UI submits formatted records without + choosing an SDK or output device; platform implementations select Arduino + serial or the native console. Disabled builds do not evaluate diagnostic calls. + - `capability_status.h` Shared `CapabilityState` / `CapabilityStatus` types used by all runtime contracts to honestly signal Unsupported / Simulated / Available / Degraded / diff --git a/modules/core_sys/include/platform/ui/map_diagnostics.h b/modules/core_sys/include/platform/ui/map_diagnostics.h new file mode 100644 index 00000000..c54ab19d --- /dev/null +++ b/modules/core_sys/include/platform/ui/map_diagnostics.h @@ -0,0 +1,7 @@ +#pragma once + +namespace platform::ui +{ +// Optional diagnostic output. Callers must not hold filesystem or queue locks. +void mapDiagnosticLog(const char* format, ...); +} // namespace platform::ui diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_async_runtime.h b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_async_runtime.h index fdd6b523..e94f8dc1 100644 --- a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_async_runtime.h +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_async_runtime.h @@ -42,6 +42,35 @@ struct LoadTileCommand MapTileRef tile{}; }; +struct TileRequestHandle +{ + uint32_t generation = 0; + uint32_t command_id = 0; + explicit operator bool() const { return generation != 0 && command_id != 0; } + bool matches(uint32_t event_generation, uint32_t event_id) const + { + return *this && generation == event_generation && command_id == event_id; + } +}; + +enum class TileSubmitStatus : uint8_t +{ + Accepted, + AlreadyPending, + Backpressured, + Unavailable +}; + +struct TileSubmitResult +{ + TileSubmitStatus status = TileSubmitStatus::Unavailable; + TileRequestHandle handle{}; + explicit operator bool() const + { + return status == TileSubmitStatus::Accepted || status == TileSubmitStatus::AlreadyPending; + } +}; + using MapTileEventKind = MapTileAsyncEventKind; class MapTileEvent @@ -55,6 +84,11 @@ class MapTileEvent MapTilePayload payload{}; std::size_t payload_size = 0; int32_t error = 0; + MapTileReadTiming read_timing{}; + uint32_t command_wait_ms = 0; + uint32_t worker_started_ms = 0; + uint32_t published_ms = 0; + bool timing_available = false; }; using MapTileAsyncEvent = MapTileEvent; @@ -93,16 +127,36 @@ class IMapTileCommandSink public: virtual ~IMapTileCommandSink() = default; - virtual bool enqueue(const LoadTileCommand& command) = 0; + virtual TileSubmitResult enqueue(const LoadTileCommand& command) = 0; virtual std::size_t cancelGeneration(uint32_t generation) = 0; }; +enum class MapTileReservationStatus : uint8_t +{ + Reserved, + Backpressured, + Cancelled, +}; + +enum class MapTileExecutionStatus : uint8_t +{ + Completed, + Backpressured, + Cancelled, +}; + class IMapTileEventSink { public: virtual ~IMapTileEventSink() = default; + // Single producer: reserve before reading. A successful reservation makes + // publication capacity-safe. false from publish means cancellation only, + // not queue-full or transient lock contention. Payload copying is owned + // by the adapter; allocation failure must publish a Failed completion. + virtual MapTileReservationStatus reserve(const LoadTileCommand& command) = 0; virtual bool publish(const MapTileAsyncEvent& event) = 0; + virtual void releaseReservation() = 0; }; class IMapTileWorkerBackend @@ -173,6 +227,8 @@ class MapTileAsyncRuntime explicit MapTileAsyncRuntime(IMapTileCommandSink& commands); uint32_t activeGeneration() const; + TileSubmitResult requestTile(const MapTileRef& tile, uint32_t generation, + MapTileInteractionMode mode, uint32_t now_ms); std::size_t requestVisibleTiles(const MapViewportPlan& plan, uint32_t now_ms); std::size_t cancelGeneration(uint32_t generation); bool handleEvent(const MapTileAsyncEvent& event, MapTileRenderQueue& render_queue); @@ -202,6 +258,12 @@ class MapTileRuntime return runtime_.requestVisibleTiles(plan, now_ms); } + TileSubmitResult requestTile(const MapTileRef& tile, uint32_t generation, + MapTileInteractionMode mode, uint32_t now_ms) + { + return runtime_.requestTile(tile, generation, mode, now_ms); + } + std::size_t cancelGeneration(uint32_t generation) { state_machine_.cancelGeneration(generation); @@ -243,7 +305,7 @@ class MapTileWorker uint8_t* scratch, std::size_t scratch_size); - bool execute(const LoadTileCommand& command, uint32_t now_ms); + MapTileExecutionStatus execute(const LoadTileCommand& command, uint32_t now_ms); private: IMapTileWorkerBackend& backend_; diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_completion_queue.h b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_completion_queue.h new file mode 100644 index 00000000..32001506 --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_completion_queue.h @@ -0,0 +1,182 @@ +#pragma once + +#include +#include +#include +#include + +namespace ui::map_tiles +{ +// Bounded completion storage. The platform serializes calls; this component +// deliberately knows nothing about RTOS locks, SD transports or payload heaps. +// A reservation counts against capacity before any I/O is started. Cancelled +// reservations remain occupied until their producer acknowledges cancellation. +template +class MapTileCompletionQueue +{ + static_assert(Capacity > 0, "A completion queue needs storage"); + + public: + struct Reservation + { + std::size_t slot = Capacity; + uint32_t epoch = 0; + explicit operator bool() const { return slot < Capacity && epoch != 0; } + }; + + MapTileCompletionQueue() = default; + MapTileCompletionQueue(const MapTileCompletionQueue&) = delete; + MapTileCompletionQueue& operator=(const MapTileCompletionQueue&) = delete; + + Reservation reserve(uint32_t generation) + { + for (std::size_t i = 0; i < Capacity; ++i) + { + auto& slot = slots_[i]; + if (slot.state != State::Free) continue; + if (++slot.epoch == 0) ++slot.epoch; + slot.generation = generation; + slot.state = State::Reserved; + ++occupied_; + if (occupied_ > high_water_) high_water_ = occupied_; + return {i, slot.epoch}; + } + ++backpressure_count_; + return {}; + } + + bool active(Reservation token) const + { + return matches(token) && slots_[token.slot].state == State::Reserved; + } + + // false means cancellation (or a stale token), never queue-full. On false + // the caller retains payload ownership and must release its reservation. + bool commit(Reservation token, T&& value) + { + if (!active(token)) return false; + auto& slot = slots_[token.slot]; + slot.value = std::move(value); + slot.sequence = next_sequence_++; + slot.state = State::Ready; + return true; + } + + // Only the producer releases a reservation. This cannot release a newer + // reservation after its slot has been recycled (epoch/ABA protection). + bool release(Reservation token) + { + if (!matches(token)) return false; + auto& slot = slots_[token.slot]; + if (slot.state != State::Reserved && slot.state != State::Cancelled) return false; + slot.state = State::Free; + --occupied_; + return true; + } + + std::size_t cancelGeneration(uint32_t generation) + { + std::size_t count = 0; + for (auto& slot : slots_) + { + if (slot.generation != generation) continue; + if (slot.state == State::Reserved) + { + slot.state = State::Cancelled; + ++count; + } + else if (slot.state == State::Ready) + { + slot.state = State::Discarded; + ++count; + } + } + return count; + } + + // Discarded payloads are moved to the caller, so expensive heap release + // can happen outside the queue lock. They do not consume render budget. + bool takeDiscarded(T& out) { return take(State::Discarded, out); } + bool pop(T& out) { return take(State::Ready, out); } + + template + bool popIf(T& out, Predicate eligible) + { + std::size_t index = Capacity; + for (std::size_t i = 0; i < Capacity; ++i) + { + if (slots_[i].state == State::Ready && eligible(slots_[i].value) && + (index == Capacity || static_cast(slots_[i].sequence - slots_[index].sequence) < 0)) index = i; + } + if (index == Capacity) return false; + out = std::move(slots_[index].value); + slots_[index].value = T{}; + slots_[index].state = State::Free; + --occupied_; + return true; + } + + const T* front() const + { + const auto index = oldest(State::Ready); + return index == Capacity ? nullptr : &slots_[index].value; + } + + std::size_t occupied() const { return occupied_; } + std::size_t highWater() const { return high_water_; } + uint32_t backpressureCount() const { return backpressure_count_; } + + private: + enum class State : uint8_t + { + Free, + Reserved, + Ready, + Cancelled, + Discarded + }; + struct Slot + { + T value{}; + uint32_t generation = 0; + uint32_t epoch = 0; + uint32_t sequence = 0; + State state = State::Free; + }; + + bool matches(Reservation token) const + { + return token && slots_[token.slot].epoch == token.epoch; + } + + std::size_t oldest(State state) const + { + std::size_t found = Capacity; + for (std::size_t i = 0; i < Capacity; ++i) + { + if (slots_[i].state == state && + (found == Capacity || static_cast(slots_[i].sequence - slots_[found].sequence) < 0)) + found = i; + } + return found; + } + + bool take(State state, T& out) + { + const auto index = oldest(state); + if (index == Capacity) return false; + auto& slot = slots_[index]; + out = std::move(slot.value); + slot.value = T{}; + slot.state = State::Free; + --occupied_; + return true; + } + + std::array slots_{}; + std::size_t occupied_ = 0; + std::size_t high_water_ = 0; + uint32_t next_sequence_ = 0; + uint32_t backpressure_count_ = 0; +}; +} // namespace ui::map_tiles diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_pipeline_metrics.h b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_pipeline_metrics.h new file mode 100644 index 00000000..9a27227f --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_pipeline_metrics.h @@ -0,0 +1,82 @@ +#pragma once + +#include "ui_map_runtime/map_tiles/map_tile_async_runtime.h" +#include +#include + +namespace ui::map_tiles +{ +// UI-thread-owned, bounded summaries. Worker evidence travels with completion +// ownership; no shared mutable worker/UI counters and no per-sector logging. +class MapTilePipelineMetrics +{ + public: + enum Stage : unsigned + { + CommandWait, + Worker, + FileLock, + FileOpen, + FileRead, + ResultWait, + Decode, + Apply, + StageCount + }; + struct Sample + { + uint64_t total_ms = 0; + uint32_t count = 0; + uint32_t max_ms = 0; + void add(uint32_t ms) + { + total_ms += ms; + ++count; + if (ms > max_ms) max_ms = ms; + } + uint32_t average() const { return count ? static_cast(total_ms / count) : 0; } + }; + + void consumed(const MapTileAsyncEvent& event, uint32_t now_ms) + { + ++consumed_count; + if (event.kind == MapTileAsyncEventKind::Ready) ++ready_count; + else if (event.kind == MapTileAsyncEventKind::RetryLater) ++retry_count; + else ++failed_count; + if (event.timing_available) + { + stages[CommandWait].add(event.command_wait_ms); + stages[Worker].add(event.published_ms - event.worker_started_ms); + stages[ResultWait].add(now_ms - event.published_ms); + } + if (event.read_timing.available) + { + stages[FileLock].add(event.read_timing.lock_wait_ms); + stages[FileOpen].add(event.read_timing.open_ms); + stages[FileRead].add(event.read_timing.read_ms); + } + if (event.read_timing.block_available) + { + ++block_samples; + block_calls += event.read_timing.block_calls; + block_sectors += event.read_timing.block_sectors; + block_us += event.read_timing.block_us; + if (event.read_timing.block_max_sectors > block_max_sectors) + block_max_sectors = event.read_timing.block_max_sectors; + } + } + + std::array stages{}; + uint32_t consumed_count = 0; + uint32_t ready_count = 0; + uint32_t retry_count = 0; + uint32_t failed_count = 0; + uint32_t rendered_count = 0; + uint32_t submission_backpressure = 0; + uint32_t block_samples = 0; + uint64_t block_calls = 0; + uint64_t block_sectors = 0; + uint64_t block_us = 0; + uint32_t block_max_sectors = 0; +}; +} // namespace ui::map_tiles diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_request_queue.h b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_request_queue.h new file mode 100644 index 00000000..0dfc8e47 --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_request_queue.h @@ -0,0 +1,111 @@ +#pragma once + +#include "ui_map_runtime/map_tiles/map_tile_async_runtime.h" +#include + +namespace ui::map_tiles +{ +// A command slot remains owned until its completion is consumed or it is +// cancelled. This is both the dedupe index and the bounded in-flight ledger; +// no second unbounded registry is needed. Platform owns synchronization. +template +class MapTileRequestQueue +{ + public: + void statistics(std::size_t& queued, std::size_t& in_flight) const + { + queued = in_flight = 0; + for (const auto& slot : slots_) + { + if (slot.state == State::Queued) ++queued; + if (slot.state == State::InFlight) ++in_flight; + } + } + + TileSubmitResult enqueue(const LoadTileCommand& command) + { + Slot* free = nullptr; + for (auto& slot : slots_) + { + if (slot.state == State::Free) + { + if (!free) free = &slot; + continue; + } + if (slot.command.runtime.generation == command.runtime.generation && sameTile(slot.command.tile, command.tile)) + return {TileSubmitStatus::AlreadyPending, handle(slot.command)}; + } + if (!free) return {TileSubmitStatus::Backpressured, {}}; + free->command = command; + free->state = State::Queued; + return {TileSubmitStatus::Accepted, handle(command)}; + } + + bool pop(uint32_t now_ms, LoadTileCommand& out) + { + (void)now_ms; // Tile requests currently have no deadlines; cancellation is explicit. + Slot* best = nullptr; + for (auto& slot : slots_) + { + if (slot.state != State::Queued) continue; + if (!best || slot.command.runtime.priority < best->command.runtime.priority || + (slot.command.runtime.priority == best->command.runtime.priority && + static_cast(slot.command.runtime.created_at_ms - best->command.runtime.created_at_ms) < 0)) + best = &slot; + } + if (!best) return false; + best->state = State::InFlight; + out = best->command; + return true; + } + + bool complete(TileRequestHandle request) + { + for (auto& slot : slots_) + { + if (slot.state == State::InFlight && request.matches(slot.command.runtime.generation, slot.command.runtime.command_id)) + { + slot.state = State::Free; + return true; + } + } + return false; + } + + std::size_t cancelGeneration(uint32_t generation) + { + std::size_t removed = 0; + for (auto& slot : slots_) + { + if (slot.state != State::Free && slot.command.runtime.generation == generation) + { + slot.state = State::Free; + ++removed; + } + } + return removed; + } + + private: + static TileRequestHandle handle(const LoadTileCommand& command) + { + return {command.runtime.generation, command.runtime.command_id}; + } + static bool sameTile(const MapTileRef& left, const MapTileRef& right) + { + return left.layer == right.layer && left.z == right.z && left.x == right.x && left.y == right.y; + } + enum class State : uint8_t + { + Free, + Queued, + InFlight + }; + struct Slot + { + LoadTileCommand command{}; + State state = State::Free; + }; + std::array slots_{}; +}; +} // namespace ui::map_tiles diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_source.h b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_source.h index 1e70b33d..b22fc719 100644 --- a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_source.h +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_source.h @@ -19,12 +19,28 @@ enum class MapTileReadStatus : uint8_t Invalid, }; +// Optional file-stage evidence. Cached/generated sources leave available false; +// zero milliseconds on a measured stage is a valid sub-tick measurement. +struct MapTileReadTiming +{ + uint32_t lock_wait_ms = 0; + uint32_t open_ms = 0; + uint32_t read_ms = 0; + bool available = false; + uint32_t block_calls = 0; + uint32_t block_sectors = 0; + uint32_t block_max_sectors = 0; + uint32_t block_us = 0; + bool block_available = false; +}; + struct MapTileReadResult { MapTileReadStatus status = MapTileReadStatus::Error; std::size_t size = 0; int32_t error = -1; MapTileFormat format = MapTileFormat::Unknown; + MapTileReadTiming timing{}; }; class IMapTileSource diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/owned_map_tile_event.h b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/owned_map_tile_event.h new file mode 100644 index 00000000..2beb9fcb --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/owned_map_tile_event.h @@ -0,0 +1,48 @@ +#pragma once + +#include "ui_map_runtime/map_tiles/map_tile_async_runtime.h" +#include + +namespace ui::map_tiles +{ +// Platform-provided deleter keeps heap/PSRAM policy outside the map domain. +class OwnedMapTileEvent +{ + public: + using Deleter = void (*)(MapTileAsyncEvent&); + OwnedMapTileEvent() = default; + OwnedMapTileEvent(MapTileAsyncEvent event, Deleter deleter) + : event_(event), deleter_(deleter) {} + ~OwnedMapTileEvent() { reset(); } + OwnedMapTileEvent(const OwnedMapTileEvent&) = delete; + OwnedMapTileEvent& operator=(const OwnedMapTileEvent&) = delete; + OwnedMapTileEvent(OwnedMapTileEvent&& other) noexcept + : event_(other.release()), deleter_(other.deleter_) {} + OwnedMapTileEvent& operator=(OwnedMapTileEvent&& other) noexcept + { + if (this != &other) + { + reset(); + event_ = other.release(); + deleter_ = other.deleter_; + } + return *this; + } + const MapTileAsyncEvent& get() const { return event_; } + MapTileAsyncEvent release() + { + auto result = event_; + event_ = {}; + return result; + } + + private: + void reset() + { + if (deleter_ && event_.payload.data) deleter_(event_); + event_ = {}; + } + MapTileAsyncEvent event_{}; + Deleter deleter_ = nullptr; +}; +} // namespace ui::map_tiles diff --git a/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp b/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp index 39efe383..0b86f3db 100644 --- a/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp +++ b/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp @@ -48,7 +48,9 @@ map_tiles::MapTileReadResult PoiTileSource::read(const map_tiles::MapTileRef& re if (written < 0 || static_cast(written) >= sizeof(path)) return {MapTileReadStatus::Invalid, 0, -2, MapTileFormat::PoiRecords}; const auto read = files_.readFile(path, raw, raw_capacity); - if (read.status == MapTileReadStatus::RetryLater) return read; + // Only a definitive missing/invalid package may be remembered. A + // transient SD failure must not disable annotations until page reopen. + if (read.status == MapTileReadStatus::RetryLater || read.status == MapTileReadStatus::Error) return read; policy_checked_ = true; policy_valid_ = read.status == MapTileReadStatus::Ready && parser_.manifest(reinterpret_cast(raw), read.size, policy_); diff --git a/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp b/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp index 1201213b..2108dbc8 100644 --- a/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp +++ b/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp @@ -36,43 +36,47 @@ uint32_t MapTileAsyncRuntime::activeGeneration() const return active_generation_; } +TileSubmitResult MapTileAsyncRuntime::requestTile(const MapTileRef& tile, uint32_t generation, + MapTileInteractionMode mode, uint32_t now_ms) +{ + if (generation == 0) return {}; + if (generation != active_generation_) + { + if (active_generation_ != 0) (void)commands_.cancelGeneration(active_generation_); + active_generation_ = generation; + } + sys::runtime::RuntimeIntent intent{}; + intent.kind = sys::runtime::RuntimeCommandKind::MapTileLoad; + intent.priority_hint = priorityFor(mode); + intent.cancel_policy = sys::runtime::RuntimeCancelPolicy::CancelByGeneration; + intent.submitted_at_ms = now_ms; + intent.generation = generation; + intent.origin = static_cast(mode); + intent.dedupe_key = dedupeKeyForTile(tile); + LoadTileCommand command{}; + command.tile = tile; + command.runtime = commandFromIntent(intent); + const auto result = commands_.enqueue(command); + if (result) + { + state_.status = sys::runtime::RuntimeStatus::Queued; + state_.active_kind = command.runtime.kind; + state_.active_command_id = result.handle.command_id; + } + return result; +} + std::size_t MapTileAsyncRuntime::requestVisibleTiles(const MapViewportPlan& plan, uint32_t now_ms) { if (plan.generation != active_generation_) { - if (active_generation_ != 0) - { - (void)commands_.cancelGeneration(active_generation_); - } + if (active_generation_ != 0) (void)commands_.cancelGeneration(active_generation_); active_generation_ = plan.generation; } - std::size_t queued = 0; - const std::size_t count = plan.tile_count < MapViewportPlan::kMaxTiles ? plan.tile_count - : MapViewportPlan::kMaxTiles; + const auto count = plan.tile_count < MapViewportPlan::kMaxTiles ? plan.tile_count : MapViewportPlan::kMaxTiles; for (std::size_t i = 0; i < count; ++i) - { - sys::runtime::RuntimeIntent intent{}; - intent.kind = sys::runtime::RuntimeCommandKind::MapTileLoad; - intent.priority_hint = priorityFor(plan.interaction_mode); - intent.cancel_policy = sys::runtime::RuntimeCancelPolicy::CancelByGeneration; - intent.submitted_at_ms = now_ms; - intent.generation = plan.generation; - intent.origin = static_cast(plan.interaction_mode); - intent.dedupe_key = dedupeKeyForTile(plan.tiles[i]); - - LoadTileCommand command{}; - command.tile = plan.tiles[i]; - command.runtime = commandFromIntent(intent); - - if (commands_.enqueue(command)) - { - ++queued; - state_.status = sys::runtime::RuntimeStatus::Queued; - state_.active_kind = command.runtime.kind; - state_.active_command_id = command.runtime.command_id; - } - } + if (requestTile(plan.tiles[i], plan.generation, plan.interaction_mode, now_ms)) ++queued; return queued; } @@ -124,6 +128,7 @@ sys::runtime::RuntimeCommand MapTileAsyncRuntime::commandFromIntent( { sys::runtime::RuntimeCommand command{}; command.command_id = next_command_id_++; + if (command.command_id == 0) command.command_id = next_command_id_++; command.kind = intent.kind; command.priority = intent.priority_hint; command.cancel_policy = intent.cancel_policy; @@ -146,12 +151,23 @@ MapTileWorker::MapTileWorker(IMapTileWorkerBackend& backend, { } -bool MapTileWorker::execute(const LoadTileCommand& command, uint32_t now_ms) +MapTileExecutionStatus MapTileWorker::execute(const LoadTileCommand& command, uint32_t now_ms) { + const auto reservation = events_.reserve(command); + if (reservation == MapTileReservationStatus::Backpressured) return MapTileExecutionStatus::Backpressured; + if (reservation == MapTileReservationStatus::Cancelled) return MapTileExecutionStatus::Cancelled; + struct ReleaseReservation + { + IMapTileEventSink& sink; + ~ReleaseReservation() { sink.releaseReservation(); } + } release{events_}; + MapTileAsyncEvent event{}; event.command_id = command.runtime.command_id; event.generation = command.runtime.generation; event.tile = command.tile; + event.command_wait_ms = now_ms - command.runtime.created_at_ms; + event.worker_started_ms = now_ms; const MapTileReadResult read_result = backend_.read(command.tile, scratch_, scratch_size_); @@ -165,6 +181,7 @@ bool MapTileWorker::execute(const LoadTileCommand& command, uint32_t now_ms) event.kind = ok ? MapTileAsyncEventKind::Ready : MapTileAsyncEventKind::Failed; } event.format = read_result.format; + event.read_timing = read_result.timing; event.payload_size = read_result.size; if (ok) { @@ -174,9 +191,9 @@ bool MapTileWorker::execute(const LoadTileCommand& command, uint32_t now_ms) event.payload.size = read_result.size; } event.error = ok ? 0 : read_result.error; - (void)events_.publish(event); + const bool delivered = events_.publish(event); (void)now_ms; - return ok; + return delivered ? MapTileExecutionStatus::Completed : MapTileExecutionStatus::Cancelled; } } // namespace map_tiles diff --git a/modules/ui_map_runtime/tests/test_map_tile_async_runtime.cpp b/modules/ui_map_runtime/tests/test_map_tile_async_runtime.cpp index b9dbf7f0..e312666a 100644 --- a/modules/ui_map_runtime/tests/test_map_tile_async_runtime.cpp +++ b/modules/ui_map_runtime/tests/test_map_tile_async_runtime.cpp @@ -23,14 +23,14 @@ class FakeCommandSink final : public ui::map_tiles::IMapTileCommandSink std::size_t count = 0; std::size_t cancelled = 0; - bool enqueue(const ui::map_tiles::LoadTileCommand& command) override + ui::map_tiles::TileSubmitResult enqueue(const ui::map_tiles::LoadTileCommand& command) override { if (count >= 8) { - return false; + return {ui::map_tiles::TileSubmitStatus::Backpressured, {}}; } commands[count++] = command; - return true; + return {ui::map_tiles::TileSubmitStatus::Accepted, {command.runtime.generation, command.runtime.command_id}}; } std::size_t cancelGeneration(uint32_t generation) override @@ -112,13 +112,24 @@ class FakeEventSink final : public ui::map_tiles::IMapTileEventSink public: ui::map_tiles::MapTileAsyncEvent events[4]{}; std::size_t count = 0; + bool reserved = false; + bool cancelled = false; + + ui::map_tiles::MapTileReservationStatus reserve(const ui::map_tiles::LoadTileCommand&) override + { + if (cancelled) return ui::map_tiles::MapTileReservationStatus::Cancelled; + if (count == 4) return ui::map_tiles::MapTileReservationStatus::Backpressured; + assert(!reserved); + reserved = true; + return ui::map_tiles::MapTileReservationStatus::Reserved; + } + + void releaseReservation() override { reserved = false; } bool publish(const ui::map_tiles::MapTileAsyncEvent& event) override { - if (count >= 4) - { - return false; - } + assert(reserved && count < 4); + if (cancelled) return false; events[count++] = event; return true; } @@ -260,7 +271,7 @@ void test_worker_success_publishes_ready() command.runtime.deadline_ms = 333; command.tile = make_tile(40); - assert(worker.execute(command, 300)); + assert(worker.execute(command, 300) == ui::map_tiles::MapTileExecutionStatus::Completed); assert(backend.lookup_count == 0); assert(backend.read_count == 1); assert(events.count == 1); @@ -286,7 +297,7 @@ void test_worker_missing_reads_once_without_lookup_probe() command.runtime.priority = sys::runtime::RuntimePriority::Normal; command.tile = make_tile(41); - assert(!worker.execute(command, 310)); + assert(worker.execute(command, 310) == ui::map_tiles::MapTileExecutionStatus::Completed); assert(backend.lookup_count == 0); assert(backend.read_count == 1); assert(events.count == 1); @@ -311,7 +322,7 @@ void test_worker_retry_later_read_publishes_retry_later() command.runtime.priority = sys::runtime::RuntimePriority::Normal; command.tile = make_tile(42); - assert(!worker.execute(command, 320)); + assert(worker.execute(command, 320) == ui::map_tiles::MapTileExecutionStatus::Completed); assert(backend.read_count == 1); assert(events.count == 1); assert(events.events[0].kind == ui::map_tiles::MapTileAsyncEventKind::RetryLater); diff --git a/modules/ui_shared/include/ui/widgets/map/map_diagnostics.h b/modules/ui_shared/include/ui/widgets/map/map_diagnostics.h new file mode 100644 index 00000000..293b192e --- /dev/null +++ b/modules/ui_shared/include/ui/widgets/map/map_diagnostics.h @@ -0,0 +1,13 @@ +#pragma once + +// Shared UI knows only the diagnostic contract; platforms select the output. +#ifndef TRAIL_MATE_MAP_DIAGNOSTICS +#define TRAIL_MATE_MAP_DIAGNOSTICS 0 +#endif + +#if TRAIL_MATE_MAP_DIAGNOSTICS +#include "platform/ui/map_diagnostics.h" +#define MAP_DIAG(...) ::platform::ui::mapDiagnosticLog(__VA_ARGS__) +#else +#define MAP_DIAG(...) ((void)0) +#endif 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 1d02718e..d6e777b9 100644 --- a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp +++ b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp @@ -138,37 +138,37 @@ bool hardware_keyboard_available() } #if UI_SHARED_TOUCH_IME_ENABLED -static const char* kTouchEnMap[] = { +static const char* const kTouchEnMap[] = { "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "Bksp", "\n", "a", "s", "d", "f", "g", "h", "j", "k", "l", "Enter", "\n", "z", "x", "c", "v", "b", "n", "m", ",", ".", "?", "\n", "Space", ""}; -static const char* kCompactTouchEnMap[] = { +static const char* const kCompactTouchEnMap[] = { "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "\n", "a", "s", "d", "f", "g", "h", "j", "k", "l", "\n", "Shift", "z", "x", "c", "v", "b", "n", "m", "Bksp", "\n", ",", ".", "Space", "?", "Enter", ""}; -static const char* kCompactTouchUpperMap[] = { +static const char* const kCompactTouchUpperMap[] = { "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "\n", "A", "S", "D", "F", "G", "H", "J", "K", "L", "\n", "Shift", "Z", "X", "C", "V", "B", "N", "M", "Bksp", "\n", ",", ".", "Space", "?", "Enter", ""}; -static const char* kCompactTouchNumMap[] = { +static const char* const kCompactTouchNumMap[] = { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "\n", "@", "#", "$", "%", "&", "*", "-", "_", "=", "\n", "Shift", "/", ":", ";", "(", ")", "+", "!", "Bksp", "\n", "'", "\"", "Space", ".", "Enter", ""}; -static const char* kCompactTouchSymbolMap[] = { +static const char* const kCompactTouchSymbolMap[] = { "[", "]", "{", "}", "<", ">", "\\", "|", "~", "^", "\n", "@", "#", "$", "%", "&", "*", "-", "_", "=", "\n", "Shift", "/", ":", ";", "(", ")", "+", "!", "Bksp", "\n", "'", "`", "Space", ".", "Enter", ""}; -static const char* kTouchNumMap[] = { +static const char* const kTouchNumMap[] = { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "Bksp", "\n", "-", "/", ":", ";", "(", ")", "$", "&", "@", "Enter", "\n", ".", ",", "?", "!", "'", "\"", "%", "+", "\n", diff --git a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp index 281ce0ae..362767cb 100644 --- a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp +++ b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp @@ -4,6 +4,7 @@ */ #include "ui/widgets/map/map_viewport.h" +#include "ui/widgets/map/map_diagnostics.h" #include "app/app_config.h" #include "app/app_config_changes.h" @@ -322,10 +323,11 @@ void gesture_surface_event_cb(lv_event_t* e) if (impl->gesture_dragging) { - if (lv_indev_t* indev = resolve_event_indev(e)) - { - lv_indev_stop_processing(indev); - } + // This is an object callback, not an input-device interceptor. + // stop_processing persists across PRESSING in LVGL 9.4 and can + // swallow the following RELEASED before it reaches this object. + // Consume only this event's bubbling; preserve the release lifecycle. + lv_event_stop_bubbling(e); MAP_VIEWPORT_LOG("drag_update root=%p dx=%d dy=%d\n", impl->widgets.root, static_cast(point.x - impl->gesture_start.x), @@ -348,10 +350,10 @@ void gesture_surface_event_cb(lv_event_t* e) if (impl->gesture_dragging) { - if (lv_indev_t* indev = resolve_event_indev(e)) - { - lv_indev_stop_processing(indev); - } + lv_event_stop_bubbling(e); + MAP_DIAG("[MAPD][gesture-end] t=%lu code=%d dx=%d dy=%d\n", + static_cast(lv_tick_get()), static_cast(code), + static_cast(point.x - impl->gesture_start.x), static_cast(point.y - impl->gesture_start.y)); MAP_VIEWPORT_LOG("drag_end root=%p dx=%d dy=%d code=%d\n", impl->widgets.root, static_cast(point.x - impl->gesture_start.x), @@ -731,12 +733,25 @@ void refresh_poi_overlay(RuntimeImpl& impl, bool force) void loader_timer_cb(lv_timer_t* timer) { auto* impl = static_cast(lv_timer_get_user_data(timer)); - if (!impl || !is_runtime_alive(*impl) || !impl->model.focus_point.valid || - impl->gesture_pressed || impl->gesture_dragging || impl->drag_preview_active) +#if TRAIL_MATE_MAP_DIAGNOSTICS + static uint32_t last_diagnostic_ms = 0; + if (impl && lv_tick_get() - last_diagnostic_ms >= 2000U) + { + last_diagnostic_ms = lv_tick_get(); + MAP_DIAG("[MAPD][heartbeat] t=%lu alive=%d focus=%d pressed=%d dragging=%d preview=%d paused=%d z=%d pan=%d,%d records=%u\n", + static_cast(last_diagnostic_ms), is_runtime_alive(*impl), impl->model.focus_point.valid, + impl->gesture_pressed, impl->gesture_dragging, impl->drag_preview_active, impl->loader_paused, + impl->model.zoom, impl->model.pan_x, impl->model.pan_y, static_cast(impl->tiles.size())); + } +#endif + if (!impl || !is_runtime_alive(*impl) || !impl->model.focus_point.valid) { return; } + tile_loader_maintenance(impl->tile_ctx); + if (impl->gesture_pressed || impl->gesture_dragging || impl->drag_preview_active) return; + const uint32_t now_ms = lv_tick_get(); if (impl->last_loader_active_log_ms == 0 || now_ms - impl->last_loader_active_log_ms >= 5000U) @@ -969,6 +984,9 @@ void apply_model(Runtime& runtime, const Model& model) } RuntimeImpl* impl = runtime.impl_; impl->drag_preview_active = false; + MAP_DIAG("[MAPD][commit-view] t=%lu focus=%.7f,%.7f z=%d pan=%d,%d source=%u\n", + static_cast(lv_tick_get()), model.focus_point.lat, model.focus_point.lon, + model.zoom, model.pan_x, model.pan_y, static_cast(model.map_source)); impl->model = model; impl->model.map_source = sanitize_map_source(impl->model.map_source); MAP_VIEWPORT_LOG("apply_model focus_valid=%d lat=%.7f lon=%.7f zoom=%d pan=%d,%d src=%u contour=%d coord=%u\n", @@ -998,6 +1016,9 @@ void apply_model_lightweight(Runtime& runtime, const Model& model) RuntimeImpl* impl = runtime.impl_; const int dx = model.pan_x - impl->model.pan_x; const int dy = model.pan_y - impl->model.pan_y; + if (!impl->drag_preview_active) + MAP_DIAG("[MAPD][preview-begin] t=%lu z=%d pan=%d,%d\n", + static_cast(lv_tick_get()), model.zoom, model.pan_x, model.pan_y); impl->model = model; impl->model.map_source = sanitize_map_source(impl->model.map_source); MAP_VIEWPORT_LOG("apply_model_lightweight focus_valid=%d lat=%.7f lon=%.7f zoom=%d pan=%d,%d src=%u contour=%d coord=%u\n", diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/map_tile_command_queue.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/map_tile_command_queue.h new file mode 100644 index 00000000..4b983ea6 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/map_tile_command_queue.h @@ -0,0 +1,67 @@ +#pragma once + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "ui_map_runtime/map_tiles/map_tile_request_queue.h" + +namespace platform::esp::arduino_common::map_tiles +{ +class MapTileCommandQueue final : public ui::map_tiles::IMapTileCommandSink +{ + public: + MapTileCommandQueue() : mutex_(xSemaphoreCreateMutex()) {} + ~MapTileCommandQueue() + { + if (mutex_) vSemaphoreDelete(mutex_); + } + MapTileCommandQueue(const MapTileCommandQueue&) = delete; + MapTileCommandQueue& operator=(const MapTileCommandQueue&) = delete; + bool available() const { return mutex_ != nullptr; } + + bool statistics(std::size_t& queued, std::size_t& in_flight) + { + if (!mutex_ || xSemaphoreTake(mutex_, 0) != pdTRUE) return false; + queue_.statistics(queued, in_flight); + xSemaphoreGive(mutex_); + return true; + } + + ui::map_tiles::TileSubmitResult enqueue(const ui::map_tiles::LoadTileCommand& command) override + { + if (!mutex_) return {}; + if (xSemaphoreTake(mutex_, 0) != pdTRUE) return {ui::map_tiles::TileSubmitStatus::Backpressured, {}}; + const auto result = queue_.enqueue(command); + xSemaphoreGive(mutex_); + return result; + } + + std::size_t cancelGeneration(uint32_t generation) override + { + if (!mutex_) return 0; + xSemaphoreTake(mutex_, portMAX_DELAY); + const auto removed = queue_.cancelGeneration(generation); + xSemaphoreGive(mutex_); + return removed; + } + + bool pop(uint32_t now_ms, ui::map_tiles::LoadTileCommand& out) + { + if (!mutex_ || xSemaphoreTake(mutex_, pdMS_TO_TICKS(10)) != pdTRUE) return false; + const bool found = queue_.pop(now_ms, out); + xSemaphoreGive(mutex_); + return found; + } + + void complete(ui::map_tiles::TileRequestHandle handle) + { + if (!mutex_) return; + xSemaphoreTake(mutex_, portMAX_DELAY); + queue_.complete(handle); + xSemaphoreGive(mutex_); + } + + private: + SemaphoreHandle_t mutex_; + ui::map_tiles::MapTileRequestQueue<16> queue_{}; +}; +} // namespace platform::esp::arduino_common::map_tiles diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/map_tile_event_queue.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/map_tile_event_queue.h new file mode 100644 index 00000000..963c9051 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/map_tile_event_queue.h @@ -0,0 +1,171 @@ +#pragma once + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "ui_map_runtime/map_tiles/map_tile_completion_queue.h" +#include "ui_map_runtime/map_tiles/owned_map_tile_event.h" +#include + +namespace platform::esp::arduino_common::map_tiles +{ +// One worker produces; UI consumes/cancels. Payload creation and destruction +// are outside the mutex. Only fixed-size bookkeeping is performed under it. +class MapTileEventQueue final : public ui::map_tiles::IMapTileEventSink +{ + public: + using Event = ui::map_tiles::MapTileAsyncEvent; + using Owned = ui::map_tiles::OwnedMapTileEvent; + using Copy = Event (*)(const Event&); + using Queue = ui::map_tiles::MapTileCompletionQueue; + + MapTileEventQueue(Copy copy, Owned::Deleter release) + : copy_(copy), release_(release), mutex_(xSemaphoreCreateMutex()), capacity_changed_(xSemaphoreCreateBinary()) {} + ~MapTileEventQueue() + { + clear(); + if (capacity_changed_) vSemaphoreDelete(capacity_changed_); + if (mutex_) vSemaphoreDelete(mutex_); + } + MapTileEventQueue(const MapTileEventQueue&) = delete; + MapTileEventQueue& operator=(const MapTileEventQueue&) = delete; + + bool available() const { return mutex_ && capacity_changed_; } + + struct Statistics + { + std::size_t occupied = 0; + std::size_t high_water = 0; + uint32_t backpressure = 0; + }; + bool statistics(Statistics& out) + { + if (!mutex_ || xSemaphoreTake(mutex_, 0) != pdTRUE) return false; + out = {queue_.occupied(), queue_.highWater(), queue_.backpressureCount()}; + xSemaphoreGive(mutex_); + return true; + } + + void activateGeneration(uint32_t generation) + { + if (!mutex_) return; + xSemaphoreTake(mutex_, portMAX_DELAY); + if (active_generation_ != generation) + { + queue_.cancelGeneration(active_generation_); + active_generation_ = generation; + } + xSemaphoreGive(mutex_); + collectDiscarded(); + notifyCapacity(); + } + + void cancelGeneration(uint32_t generation) + { + if (!mutex_) return; + xSemaphoreTake(mutex_, portMAX_DELAY); + if (active_generation_ == generation) active_generation_ = 0; + queue_.cancelGeneration(generation); + xSemaphoreGive(mutex_); + collectDiscarded(); + notifyCapacity(); + } + + ui::map_tiles::MapTileReservationStatus reserve(const ui::map_tiles::LoadTileCommand& command) override + { + using Status = ui::map_tiles::MapTileReservationStatus; + if (!available()) return Status::Backpressured; + xSemaphoreTake(mutex_, portMAX_DELAY); + assert(!reservation_); + const bool current = active_generation_ != 0 && command.runtime.generation == active_generation_; + if (current) reservation_ = queue_.reserve(command.runtime.generation); + const auto result = !current ? Status::Cancelled : reservation_ ? Status::Reserved + : Status::Backpressured; + xSemaphoreGive(mutex_); + return result; + } + + bool publish(const Event& event) override + { + // Copy may allocate and may turn allocation failure into Failed. It + // never calls the filesystem, and always returns an owned completion. + Owned owned(copy_(event), release_); + xSemaphoreTake(mutex_, portMAX_DELAY); + const bool committed = queue_.commit(reservation_, std::move(owned)); + if (committed) reservation_ = {}; + xSemaphoreGive(mutex_); + return committed; + } + + void releaseReservation() override + { + if (!mutex_) return; + xSemaphoreTake(mutex_, portMAX_DELAY); + if (reservation_) queue_.release(reservation_); + reservation_ = {}; + xSemaphoreGive(mutex_); + notifyCapacity(); + } + + bool pop(Event& out) + { + return popIf(out, [](const Event&) + { return true; }); + } + + template + bool popIf(Event& out, Predicate eligible) + { + if (!mutex_ || xSemaphoreTake(mutex_, 0) != pdTRUE) return false; + Owned owned; + const bool found = queue_.popIf(owned, [&](const Owned& candidate) + { return eligible(candidate.get()); }); + xSemaphoreGive(mutex_); + if (found) + { + out = owned.release(); + notifyCapacity(); + } + return found; + } + + void waitForCapacity(TickType_t timeout) + { + if (capacity_changed_) xSemaphoreTake(capacity_changed_, timeout); + } + + void collectDiscarded() + { + if (!mutex_) return; + for (;;) + { + Owned discarded; + xSemaphoreTake(mutex_, portMAX_DELAY); + const bool found = queue_.takeDiscarded(discarded); + xSemaphoreGive(mutex_); + if (!found) break; + notifyCapacity(); + } + } + + void clear() + { + if (!mutex_) return; + activateGeneration(0); + Event event; + while (pop(event)) release_(event); + } + + private: + void notifyCapacity() + { + if (capacity_changed_) xSemaphoreGive(capacity_changed_); + } + Copy copy_; + Owned::Deleter release_; + SemaphoreHandle_t mutex_; + SemaphoreHandle_t capacity_changed_; + Queue queue_{}; + Queue::Reservation reservation_{}; + uint32_t active_generation_ = 0; +}; +} // namespace platform::esp::arduino_common::map_tiles diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h index f2886e04..839aebfa 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -1,5 +1,6 @@ #pragma once +#include "platform/esp/arduino_common/storage/sd_transport_config.h" #include #include @@ -63,6 +64,8 @@ bool mount_sd_card(int sd_cs, void unmount_sd_card(); #if defined(TRAIL_MATE_SDFAT_SDMMC) +bool mount_sd_card(const SdmmcSdConfig& config); +// Compatibility for existing one-bit callers; new boards use typed config. bool mount_sdmmc_card(int clock, int command, int data0); #endif @@ -109,6 +112,14 @@ struct SdFileReadResult std::size_t bytes_read = 0; uint64_t file_size = 0; int32_t error = -1; + uint32_t lock_wait_ms = 0; + uint32_t open_ms = 0; + uint32_t read_ms = 0; + uint32_t block_calls = 0; + uint32_t block_sectors = 0; + uint32_t block_max_sectors = 0; + uint32_t block_us = 0; + bool block_timing_available = false; }; // Reads a file through bounded device-owned transactions. Callers receive a diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_file_probe.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_file_probe.h new file mode 100644 index 00000000..53655e0c --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_file_probe.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include + +namespace platform::esp::arduino_common::storage +{ +enum class SdPathEvidence +{ + Present, + Absent, + Uncertain +}; + +// Allocate as runtime-owned scratch, never as an ESP automatic local. Only +// used after failed open; successful tile reads pay no directory-scan cost. +struct SdPathProbeScratch +{ + FsFile directory; + FsFile entry; + char name[256]{}; +}; + +// A failed boolean open is not proof of absence. Walk the directory using its +// end/error distinction. Generated map paths are ASCII; unsupported relative +// components/non-ASCII inputs stay uncertain rather than entering a negative +// cache. The enclosing filesystem lock must cover the complete probe. +inline SdPathEvidence probe_sd_path(FsVolume& volume, const char* path, SdPathProbeScratch& scratch) +{ + scratch.entry.close(); + scratch.directory.close(); + struct Close + { + SdPathProbeScratch& scratch; + ~Close() + { + scratch.entry.close(); + scratch.directory.close(); + } + } close{scratch}; + if (!path || path[0] != '/' || !scratch.directory.openRoot(&volume)) return SdPathEvidence::Uncertain; + while (*path == '/') ++path; + if (!*path) return SdPathEvidence::Present; + while (*path) + { + const char* end = path; + while (*end && *end != '/') + { + const auto c = static_cast(*end); + if (c < 32 || c >= 127) return SdPathEvidence::Uncertain; + ++end; + } + const auto length = static_cast(end - path); + if (length == 0 || length > 255 || (length == 1 && path[0] == '.') || + (length == 2 && path[0] == '.' && path[1] == '.')) return SdPathEvidence::Uncertain; + bool found = false; + while (scratch.entry.openNext(&scratch.directory, O_RDONLY)) + { + const auto name_length = scratch.entry.getName(scratch.name, sizeof(scratch.name)); + if (name_length == 0 || scratch.entry.getError()) return SdPathEvidence::Uncertain; + bool equal = name_length == length; + for (std::size_t i = 0; equal && i < length; ++i) + { + const auto lower = [](unsigned char c) + { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; }; + equal = lower(static_cast(scratch.name[i])) == lower(static_cast(path[i])); + } + if (equal) + { + found = true; + break; + } + if (!scratch.entry.close()) return SdPathEvidence::Uncertain; + } + if (!found) + { + if (scratch.directory.getError()) return SdPathEvidence::Uncertain; + // openNext also fails on malformed entries (e.g. FAT LFN checksum) + // without setting the parent's read-error bit. Require the actual + // zero end-of-directory marker, shared by FAT and exFAT. A full + // directory with no marker stays uncertain rather than risking a + // false negative cache entry. Only one byte of the last 32-byte + // directory record is inspected; no private SdFat cache access. + const auto position = scratch.directory.curPosition(); + if (position < 32 || !scratch.directory.seekSet(position - 32)) return SdPathEvidence::Uncertain; + uint8_t marker = 0xFF; + if (scratch.directory.read(&marker, 1) != 1 || scratch.directory.getError()) return SdPathEvidence::Uncertain; + return marker == 0 ? SdPathEvidence::Absent : SdPathEvidence::Uncertain; + } + while (*end == '/') ++end; + if (!*end) return SdPathEvidence::Present; + if (!scratch.entry.isDir()) return SdPathEvidence::Uncertain; + if (!scratch.directory.close()) return SdPathEvidence::Uncertain; + scratch.directory.move(&scratch.entry); + path = end; + } + return SdPathEvidence::Uncertain; +} +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_operation_scope.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_operation_scope.h new file mode 100644 index 00000000..0ebe5d11 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_operation_scope.h @@ -0,0 +1,58 @@ +#pragma once + +#include "sys/shared_spi_access.h" + +namespace platform::esp::arduino_common::storage +{ +// These scopes contain transport context only. The enclosing runtime owns the +// filesystem mutex and must enter/leave the scope while holding that mutex. +struct SdSpiOperationProfile +{ + sys::runtime::BusAccessPolicy policy = sys::runtime::BusAccessPolicy::BackgroundWorkerBounded; + uint32_t wait_ms = 25U; + const char* owner = "sd_spi_unscoped"; + sys::runtime::BusAcquireStatus last_bus_status = sys::runtime::BusAcquireStatus::Unavailable; + bool active = false; +}; + +class SharedSpiSdOperationScope +{ + public: + void enter(const char* owner, sys::runtime::BusAccessPolicy policy, uint32_t wait_ms) + { + previous_ = profile(); + auto& current = profile(); + current.policy = policy; + current.wait_ms = wait_ms; + current.owner = owner != nullptr && owner[0] != '\0' ? owner : "sd_runtime"; + current.last_bus_status = sys::runtime::BusAcquireStatus::Acquired; + current.active = true; + } + void leave() { profile() = previous_; } + sys::runtime::BusAcquireStatus status() const { return profile().last_bus_status; } + static SdSpiOperationProfile& profile() + { + static SdSpiOperationProfile current; + return current; + } + + private: + SdSpiOperationProfile previous_{}; +}; + +class SdmmcSdOperationScope +{ + public: + void enter(const char*, sys::runtime::BusAccessPolicy, uint32_t) {} + void leave() {} + // No shared-bus acquisition can fail. SDMMC I/O errors are reported by + // the block device/file operation, never disguised as SPI contention. + sys::runtime::BusAcquireStatus status() const { return sys::runtime::BusAcquireStatus::Acquired; } +}; + +#if defined(TRAIL_MATE_SDFAT_SDMMC) +using ActiveSdOperationScope = SdmmcSdOperationScope; +#else +using ActiveSdOperationScope = SharedSpiSdOperationScope; +#endif +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_transfer_policy.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_transfer_policy.h new file mode 100644 index 00000000..54637d74 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_transfer_policy.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +namespace platform::esp::arduino_common::storage +{ +// File request sizes and physical DMA batches are deliberately independent. +// SPI keeps its existing transaction boundaries; SDMMC can expose contiguous +// extents to SdFat without requiring an equally large internal DMA buffer. +struct SharedSpiSdTransferPolicy +{ + static constexpr std::size_t file_slice_bytes = 512; +}; + +struct SdmmcSdTransferPolicy +{ + static constexpr std::size_t sector_bytes = 512; + static constexpr std::size_t file_slice_bytes = 16 * 1024; + static constexpr std::size_t dma_buffer_bytes = 4 * 1024; + static constexpr std::size_t max_direct_sectors = file_slice_bytes / sector_bytes; +}; + +#if defined(TRAIL_MATE_SDFAT_SDMMC) && defined(TRAIL_MATE_SDFAT_SHARED_SPI) +#error "Select exactly one SD transport for this runtime." +#endif + +#if defined(TRAIL_MATE_SDFAT_SDMMC) +using ActiveSdTransferPolicy = SdmmcSdTransferPolicy; +#else +using ActiveSdTransferPolicy = SharedSpiSdTransferPolicy; +#endif +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_transport_config.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_transport_config.h new file mode 100644 index 00000000..044ca6d3 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_transport_config.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace platform::esp::arduino_common::storage +{ +// Hardware facts only. Buffer sizing and transfer algorithms belong to the +// shared transport implementation, not to individual board profiles. +struct SdmmcSdConfig +{ + int clock = -1; + int command = -1; + int data0 = -1; + int data1 = -1; + int data2 = -1; + int data3 = -1; + int slot = 1; + uint8_t width = 1; + uint32_t max_frequency_khz = 20000; + bool internal_pullups = true; +}; +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sdmmc_block_device.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sdmmc_block_device.h index 4459ad7c..c2e254fb 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sdmmc_block_device.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sdmmc_block_device.h @@ -1,6 +1,7 @@ #pragma once #if defined(TRAIL_MATE_SDFAT_SDMMC) +#include "platform/esp/arduino_common/storage/sd_transport_config.h" #include #include @@ -10,7 +11,12 @@ namespace platform::esp::arduino_common::storage class ArduinoSdmmcBlockDevice final : public FsBlockDeviceInterface { public: + ArduinoSdmmcBlockDevice() = default; + ArduinoSdmmcBlockDevice(const ArduinoSdmmcBlockDevice&) = delete; + ArduinoSdmmcBlockDevice& operator=(const ArduinoSdmmcBlockDevice&) = delete; + ~ArduinoSdmmcBlockDevice() override { end(); } bool begin(int clock, int command, int data0); + bool begin(const SdmmcSdConfig& config); void end() override; bool isBusy() override { return false; } bool readSector(Sector_t sector, uint8_t* destination) override; @@ -20,11 +26,32 @@ class ArduinoSdmmcBlockDevice final : public FsBlockDeviceInterface Sector_t sectorCount() override { return ready_ ? card_.csd.capacity : 0; } bool syncDevice() override { return ready_; } bool highCapacity() const { return (card_.ocr & (1UL << 30)) != 0; } + // Sticky within an operation, including a failed batch followed by a + // successful metadata read. Access only under the enclosing FS mutex. + void clearIoError() { io_error_ = ESP_OK; } + esp_err_t ioError() const { return io_error_; } + struct ReadMetrics + { + uint32_t calls = 0; + uint32_t sectors = 0; + uint32_t max_sectors = 0; + uint32_t elapsed_us = 0; + }; + void resetReadMetrics() { read_metrics_ = {}; } + const ReadMetrics& readMetrics() const { return read_metrics_; } private: + bool failIo(esp_err_t error) + { + if (io_error_ == ESP_OK) io_error_ = error; + return false; + } + esp_err_t io_error_ = ESP_OK; + ReadMetrics read_metrics_{}; sdmmc_card_t card_{}; bool host_ready_ = false; bool ready_ = false; + uint8_t* dma_buffer_ = nullptr; }; } // namespace platform::esp::arduino_common::storage #endif diff --git a/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h b/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h index fd1a0350..3d3c0e17 100644 --- a/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h +++ b/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h @@ -70,6 +70,8 @@ struct MapTile std::unique_ptr poi; bool poi_checked = false; bool poi_pending = false; + uint32_t poi_request_generation = 0; + uint32_t poi_request_id = 0; uint32_t poi_retry_not_before_ms = 0; #endif int32_t x; // Tile X coordinate (wrapped, max 262,143 at zoom 18) @@ -79,20 +81,22 @@ struct MapTile lv_obj_t* img_obj; // NULL = not loaded, non-NULL = loaded (image or label placeholder) lv_obj_t* contour_obj; // Optional contour overlay object (child of img_obj) bool visible; - bool ever_visible; // Track if tile was ever visible (for eviction priority) - uint32_t last_used_ms; // For LRU cache eviction - uint32_t obj_evicted_ms; // Timestamp when object was evicted (0 = not evicted) - bool record_evicted; // Record should be removed from vector - int priority; // Loading priority (distance from screen center, lower = higher priority) - bool has_png_file; // True if tile has loaded base image file (PNG/JPG) - bool base_missing; // True if the base tile file was confirmed missing for this render state - bool base_request_pending; // True while a worker request is in flight for the base tile - uint32_t base_request_generation; // Generation associated with the in-flight base request - uint32_t base_retry_not_before_ms; // Backoff deadline after resource pressure/busy failures - bool contour_checked; // True if contour file lookup was attempted for this tile - bool contour_loaded; // True if contour overlay object is present - bool contour_request_pending; // True while a worker request is in flight for the contour tile - uint32_t contour_request_generation; // Generation associated with the in-flight contour request + bool ever_visible; // Track if tile was ever visible (for eviction priority) + uint32_t last_used_ms; // For LRU cache eviction + uint32_t obj_evicted_ms; // Timestamp when object was evicted (0 = not evicted) + bool record_evicted; // Record should be removed from vector + int priority; // Loading priority (distance from screen center, lower = higher priority) + bool has_png_file; // True if tile has loaded base image file (PNG/JPG) + bool base_missing; // True if the base tile file was confirmed missing for this render state + bool base_request_pending; // True while a worker request is in flight for the base tile + uint32_t base_request_generation; // Generation associated with the in-flight base request + uint32_t base_request_id = 0; + uint32_t base_retry_not_before_ms; // Backoff deadline after resource pressure/busy failures + bool contour_checked; // True if contour file lookup was attempted for this tile + bool contour_loaded; // True if contour overlay object is present + bool contour_request_pending; // True while a worker request is in flight for the contour tile + uint32_t contour_request_generation; // Generation associated with the in-flight contour request + uint32_t contour_request_id = 0; uint32_t contour_retry_not_before_ms; // Backoff deadline after contour resource pressure/busy failures DecodedTileCache* cached_img; // Decoded image entry currently bound to img_obj (NULL if not cached) DecodedTileCache* contour_cached_img; // Decoded contour entry currently bound to contour_obj @@ -180,6 +184,9 @@ void calculate_required_tiles(TileContext& ctx, double lat, double lng, int zoom * Load one tile (called by timer, not in calculate_required_tiles) */ void tile_loader_step(TileContext& ctx); +// UI-thread housekeeping only: reclaim obsolete results during gestures. +// Does not read storage, decode images or mutate LVGL objects. +void tile_loader_maintenance(TileContext& ctx); /** * Normalize map source configuration value. diff --git a/platform/esp/arduino_common/src/platform_ui_map_diagnostics.cpp b/platform/esp/arduino_common/src/platform_ui_map_diagnostics.cpp new file mode 100644 index 00000000..5d7f9d8c --- /dev/null +++ b/platform/esp/arduino_common/src/platform_ui_map_diagnostics.cpp @@ -0,0 +1,33 @@ +#include "platform/ui/map_diagnostics.h" + +#if defined(TRAIL_MATE_MAP_DIAGNOSTICS) && TRAIL_MATE_MAP_DIAGNOSTICS +#include +#include +#include +#include + +namespace platform::ui +{ +void mapDiagnosticLog(const char* format, ...) +{ + va_list args; + va_start(args, format); + va_list measure; + va_copy(measure, args); + const int size = std::vsnprintf(nullptr, 0, format, measure); + va_end(measure); + if (size > 0) + { + // Diagnostic-only transient storage, not a large ESP task-stack buffer. + auto* text = static_cast(std::malloc(static_cast(size) + 1)); + if (text) + { + std::vsnprintf(text, static_cast(size) + 1, format, args); + Serial.write(reinterpret_cast(text), static_cast(size)); + std::free(text); + } + } + va_end(args); +} +} // namespace platform::ui +#endif diff --git a/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp b/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp index f0c095b9..da510ab8 100644 --- a/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp +++ b/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp @@ -1,4 +1,6 @@ #include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/arduino_common/storage/sd_operation_scope.h" +#include "platform/esp/arduino_common/storage/sd_transfer_policy.h" #include "platform/esp/arduino_common/storage/sd_spi_bus_hooks.h" #include "platform/esp/boards/board_runtime.h" @@ -14,6 +16,7 @@ #ifndef DISABLE_FS_H_WARNING #define DISABLE_FS_H_WARNING 1 #endif +#include "platform/esp/arduino_common/storage/sd_file_probe.h" #include "platform/esp/arduino_common/storage/sdmmc_block_device.h" #include #include @@ -45,7 +48,7 @@ constexpr uint32_t kSdInteractiveReadLockWaitMs = 200U; constexpr uint32_t kSdDurableLockWaitMs = 250U; constexpr uint32_t kSdExternalTransferLockWaitMs = 500U; constexpr uint32_t kSdExternalOwnerTransitionLockWaitMs = 2000U; -constexpr std::size_t kSdTransferSliceBytes = kSdSectorSize; +constexpr std::size_t kSdTransferSliceBytes = ActiveSdTransferPolicy::file_slice_bytes; #ifndef TRAIL_MATE_SD_IO_LOG_ENABLE #define TRAIL_MATE_SD_IO_LOG_ENABLE 1 @@ -77,6 +80,45 @@ uint32_t s_suppressed_sd_io_logs = 0; StaticSemaphore_t s_filesystem_mutex_storage{}; SemaphoreHandle_t s_filesystem_mutex = nullptr; FsFile* s_transient_file = nullptr; +SdPathProbeScratch* s_path_probe = nullptr; + +// Created only inside the filesystem operation guard. A previous operation's +// sticky card error must not turn a genuinely absent path into a permanent +// I/O failure. Keep the transport choice here, not in map callers. +class SdIoErrorScope +{ + public: + SdIoErrorScope() + { +#if defined(TRAIL_MATE_SDFAT_SDMMC) + s_sdmmc.clearIoError(); + s_sdmmc.resetReadMetrics(); +#else + if (s_sdfat.card()) s_sdfat.card()->sdError(0); +#endif + } + int32_t error() const + { +#if defined(TRAIL_MATE_SDFAT_SDMMC) + return s_sdmmc.ioError(); +#else + return s_sdfat.card() ? s_sdfat.card()->errorCode() : -3; +#endif + } + static void captureReadMetrics(SdFileReadResult& result) + { +#if defined(TRAIL_MATE_SDFAT_SDMMC) + const auto& metrics = s_sdmmc.readMetrics(); + result.block_calls = metrics.calls; + result.block_sectors = metrics.sectors; + result.block_max_sectors = metrics.max_sectors; + result.block_us = metrics.elapsed_us; + result.block_timing_available = true; +#else + (void)result; // No fabricated physical-call evidence for SPI. +#endif + } +}; // Persistent scalar description of the last mounted shared SPI wiring. SdFat // may call SPIClass::end() internally, so cleanup must restore the same board @@ -91,19 +133,6 @@ struct ActiveSdSpiBus ActiveSdSpiBus s_active_spi_bus{}; -struct SdSpiOperationProfile -{ - sys::runtime::BusAccessPolicy policy = - sys::runtime::BusAccessPolicy::BackgroundWorkerBounded; - uint32_t wait_ms = kSdRuntimeLockWaitMs; - const char* owner = "sd_spi_unscoped"; - sys::runtime::BusAcquireStatus last_bus_status = - sys::runtime::BusAcquireStatus::Unavailable; - bool active = false; -}; - -SdSpiOperationProfile s_spi_operation_profile{}; - enum class SdAccessOwner : uint8_t { Application, @@ -202,14 +231,7 @@ class SdRuntimeOperationGuard return; } - previous_profile_ = s_spi_operation_profile; - s_spi_operation_profile.policy = policy; - s_spi_operation_profile.wait_ms = wait_ms; - s_spi_operation_profile.owner = - owner != nullptr && owner[0] != '\0' ? owner : "sd_runtime"; - s_spi_operation_profile.last_bus_status = - sys::runtime::BusAcquireStatus::Acquired; - s_spi_operation_profile.active = true; + transport_scope_.enter(owner, policy, wait_ms); status_ = sys::runtime::BusAcquireStatus::Acquired; locked_ = true; } @@ -218,7 +240,7 @@ class SdRuntimeOperationGuard { if (locked_) { - s_spi_operation_profile = previous_profile_; + transport_scope_.leave(); xSemaphoreGiveRecursive(s_filesystem_mutex); } } @@ -227,11 +249,11 @@ class SdRuntimeOperationGuard sys::runtime::BusAcquireStatus status() const { return status_; } sys::runtime::BusAcquireStatus busStatus() const { - return locked_ ? s_spi_operation_profile.last_bus_status : status_; + return locked_ ? transport_scope_.status() : status_; } private: - SdSpiOperationProfile previous_profile_{}; + ActiveSdOperationScope transport_scope_{}; sys::runtime::BusAcquireStatus status_ = sys::runtime::BusAcquireStatus::Unavailable; bool locked_ = false; @@ -636,7 +658,7 @@ bool sd_spi_bus_acquire(sys::runtime::BusAccessToken& token) { #if defined(TRAIL_MATE_SDFAT_SHARED_SPI) const uint32_t now_ms = sys::millis_now(); - const SdSpiOperationProfile& profile = s_spi_operation_profile; + SdSpiOperationProfile& profile = SharedSpiSdOperationScope::profile(); sys::runtime::BusAcquireRequest request{}; request.resource = ::platform::esp::common::SharedSpiCoordinator::kSharedBusResource; @@ -655,7 +677,7 @@ bool sd_spi_bus_acquire(sys::runtime::BusAccessToken& token) ::platform::esp::common::shared_spi_coordinator().acquire(request); if (result.status != sys::runtime::BusAcquireStatus::Acquired) { - s_spi_operation_profile.last_bus_status = result.status; + profile.last_bus_status = result.status; } token = result.token; return result.status == sys::runtime::BusAcquireStatus::Acquired && @@ -799,11 +821,20 @@ bool mount_sd_card(int sd_cs, #if defined(TRAIL_MATE_SDFAT_SDMMC) bool mount_sdmmc_card(int clock, int command, int data0) +{ + SdmmcSdConfig config; + config.clock = clock; + config.command = command; + config.data0 = data0; + return mount_sd_card(config); +} + +bool mount_sd_card(const SdmmcSdConfig& config) { SdRuntimeOperationGuard operation("sdmmc_mount", sys::runtime::BusAccessPolicy::RecoveryExclusive, 500U); if (!operation.locked() || s_external_block_owner_active) return false; if (s_sdfat_mounted) return true; - if (!s_sdmmc.begin(clock, command, data0)) return false; + if (!s_sdmmc.begin(config)) return false; bool mounted = s_sdfat.begin(&s_sdmmc, true, 1); if (!mounted) mounted = s_sdfat.begin(&s_sdmmc, true, 0); if (!mounted || s_sdfat.fatType() == 0) @@ -816,7 +847,9 @@ bool mount_sdmmc_card(int clock, int command, int data0) s_sdfat_mounted = true; // SPI frequency metadata is inapplicable to the SDMMC transport. record_sdfat_info(0); - Serial.printf("[SD] backend=sdfat bus=sdmmc width=1 fs=%s\n", sd_card_filesystem_name()); + Serial.printf("[SD] backend=sdfat bus=sdmmc width=%u max_khz=%lu fs=%s\n", + config.width, static_cast(config.max_frequency_khz), + sd_card_filesystem_name()); return true; } #endif @@ -963,6 +996,7 @@ SdFileReadResult sd_read_file(const char* path, const char* normalized = normalize_sd_path(path); const uint32_t start_ms = sd_io_begin("map_file_read", normalized, capacity); SdFileReadResult result{}; + bool io_evidence_active = false; auto finish = [&](SdFileReadStatus status, std::size_t bytes_read, @@ -973,6 +1007,7 @@ SdFileReadResult sd_read_file(const char* path, result.bytes_read = bytes_read; result.file_size = file_size; result.error = error; + if (io_evidence_active) SdIoErrorScope::captureReadMetrics(result); sd_io_end("map_file_read", normalized, start_ms, @@ -991,10 +1026,12 @@ SdFileReadResult sd_read_file(const char* path, return finish(SdFileReadStatus::Unavailable, 0, 0, -3); } + const uint32_t lock_start_ms = sys::millis_now(); SdRuntimeOperationGuard operation( "sd_map_file", sys::runtime::BusAccessPolicy::InteractiveWorkerBounded, kSdInteractiveReadLockWaitMs); + result.lock_wait_ms = sys::millis_now() - lock_start_ms; if (!operation.locked()) { return finish(SdFileReadStatus::Busy, @@ -1021,21 +1058,30 @@ SdFileReadResult sd_read_file(const char* path, : bus_acquire_error(status)); } + SdIoErrorScope io_errors; + io_evidence_active = true; uint64_t file_size = 0; + const uint32_t open_start_ms = sys::millis_now(); file = s_sdfat.open(normalized, O_RDONLY); + result.open_ms = sys::millis_now() - open_start_ms; if (!file) { - const sys::runtime::BusAcquireStatus status = operation.busStatus(); - // Map tile paths are immutable generated artifacts. Only a completed - // open may establish that the artifact is missing. - return finish(status == sys::runtime::BusAcquireStatus::Acquired - ? SdFileReadStatus::Missing - : SdFileReadStatus::Busy, - 0, - 0, - status == sys::runtime::BusAcquireStatus::Acquired - ? -1 - : bus_acquire_error(status)); + if (operation.busStatus() != sys::runtime::BusAcquireStatus::Acquired) + return finish(SdFileReadStatus::Busy, 0, 0, bus_acquire_error(operation.busStatus())); + if (io_errors.error() != 0) + return finish(SdFileReadStatus::IoError, 0, 0, io_errors.error()); + if (!s_path_probe) s_path_probe = psram_preferred_object(); + if (!s_path_probe) return finish(SdFileReadStatus::IoError, 0, 0, -8); + const auto evidence = probe_sd_path(s_sdfat, normalized, *s_path_probe); + result.open_ms = sys::millis_now() - open_start_ms; + if (operation.busStatus() != sys::runtime::BusAcquireStatus::Acquired) + return finish(SdFileReadStatus::Busy, 0, 0, bus_acquire_error(operation.busStatus())); + if (io_errors.error() != 0) + return finish(SdFileReadStatus::IoError, 0, 0, io_errors.error()); + // Even Present is an error here: the requested open already failed. + return finish(evidence == SdPathEvidence::Absent ? SdFileReadStatus::Missing + : SdFileReadStatus::IoError, + 0, 0, evidence == SdPathEvidence::Absent ? -1 : -6); } file_size = file.fileSize(); @@ -1055,6 +1101,7 @@ SdFileReadResult sd_read_file(const char* path, const uint32_t chunk_start_ms = sd_io_begin("map_file_read_chunk", normalized, chunk_size); const int bytes_read = file.read(buffer + total_read, chunk_size); + result.read_ms += sys::millis_now() - chunk_start_ms; if (bytes_read <= 0) { const sys::runtime::BusAcquireStatus status = operation.busStatus(); diff --git a/platform/esp/arduino_common/src/storage/sdmmc_block_device.cpp b/platform/esp/arduino_common/src/storage/sdmmc_block_device.cpp index d1005928..91a88e1e 100644 --- a/platform/esp/arduino_common/src/storage/sdmmc_block_device.cpp +++ b/platform/esp/arduino_common/src/storage/sdmmc_block_device.cpp @@ -1,32 +1,69 @@ #if defined(TRAIL_MATE_SDFAT_SDMMC) #include "platform/esp/arduino_common/storage/sdmmc_block_device.h" +#include "platform/esp/arduino_common/storage/sd_transfer_policy.h" +#include #include #include -#include +#include #include +#include +#include +#include namespace platform::esp::arduino_common::storage { namespace { -DMA_ATTR uint8_t s_sector_scratch[512]; +using Transfer = SdmmcSdTransferPolicy; + +bool direct_dma_buffer(const void* buffer, size_t bytes) +{ + const auto address = reinterpret_cast(buffer); + if (bytes == 0 || address % 4 != 0 || bytes - 1 > UINTPTR_MAX - address) return false; + // Conservative ESP32 internal-DMA path: PSRAM and unaligned buffers use + // the owned bounce buffer, even on targets with additional DMA features. + return esp_ptr_dma_capable(buffer) && + esp_ptr_dma_capable(reinterpret_cast(address + bytes - 1)); } +} // namespace bool ArduinoSdmmcBlockDevice::begin(int clock, int command, int data0) +{ + SdmmcSdConfig config; + config.clock = clock; + config.command = command; + config.data0 = data0; + return begin(config); +} + +bool ArduinoSdmmcBlockDevice::begin(const SdmmcSdConfig& config) { if (ready_) return true; - if (clock < 0 || command < 0 || data0 < 0) return false; + if (config.clock < 0 || config.command < 0 || config.data0 < 0 || + (config.width != 1 && config.width != 4) || config.slot < 0 || config.slot > 1 || + config.max_frequency_khz == 0 || config.max_frequency_khz > 40000 || + (config.width == 4 && (config.data1 < 0 || config.data2 < 0 || config.data3 < 0))) return false; end(); + dma_buffer_ = static_cast(heap_caps_malloc( + Transfer::dma_buffer_bytes, MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA)); + if (!dma_buffer_) return false; sdmmc_host_t host = SDMMC_HOST_DEFAULT(); - host.flags = SDMMC_HOST_FLAG_1BIT; - host.max_freq_khz = SDMMC_FREQ_DEFAULT; + host.flags = config.width == 4 ? SDMMC_HOST_FLAG_4BIT : SDMMC_HOST_FLAG_1BIT; + host.slot = config.slot; + host.max_freq_khz = static_cast(config.max_frequency_khz); sdmmc_slot_config_t slot = SDMMC_SLOT_CONFIG_DEFAULT(); - slot.width = 1; - slot.clk = static_cast(clock); - slot.cmd = static_cast(command); - slot.d0 = static_cast(data0); - slot.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; + slot.width = config.width; + slot.clk = static_cast(config.clock); + slot.cmd = static_cast(config.command); + slot.d0 = static_cast(config.data0); + if (config.width == 4) + { + slot.d1 = static_cast(config.data1); + slot.d2 = static_cast(config.data2); + slot.d3 = static_cast(config.data3); + } + if (config.internal_pullups) slot.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; esp_err_t result = sdmmc_host_init(); if (result == ESP_OK) { @@ -40,7 +77,7 @@ bool ArduinoSdmmcBlockDevice::begin(int clock, int command, int data0) end(); return false; } - ready_ = card_.csd.sector_size == sizeof(s_sector_scratch) && card_.csd.capacity > 0; + ready_ = card_.csd.sector_size == Transfer::sector_bytes && card_.csd.capacity > 0; if (!ready_) end(); return ready_; } @@ -53,39 +90,64 @@ void ArduinoSdmmcBlockDevice::end() sdmmc_host_deinit(); host_ready_ = false; } + heap_caps_free(dma_buffer_); + dma_buffer_ = nullptr; } bool ArduinoSdmmcBlockDevice::readSector(Sector_t sector, uint8_t* destination) { - if (!ready_ || !destination || sector >= sectorCount()) return false; - if (sdmmc_read_sectors(&card_, s_sector_scratch, sector, 1) != ESP_OK) return false; - std::memcpy(destination, s_sector_scratch, sizeof(s_sector_scratch)); - return true; + return readSectors(sector, destination, 1); } bool ArduinoSdmmcBlockDevice::readSectors(Sector_t sector, uint8_t* destination, size_t count) { - if (!ready_ || (!destination && count) || sector > sectorCount() || count > sectorCount() - sector) return false; - for (size_t index = 0; index < count; ++index) + if (!ready_ || (!destination && count) || sector > sectorCount() || count > sectorCount() - sector || + count > std::numeric_limits::max() / Transfer::sector_bytes) return failIo(ESP_ERR_INVALID_ARG); + if (count == 0) return true; + const bool direct = direct_dma_buffer(destination, count * Transfer::sector_bytes); + while (count != 0) { - if (!readSector(sector + index, destination + index * sizeof(s_sector_scratch))) return false; + const size_t batch = std::min(count, direct ? Transfer::max_direct_sectors + : Transfer::dma_buffer_bytes / Transfer::sector_bytes); + uint8_t* target = direct ? destination : dma_buffer_; + const auto begin_us = esp_timer_get_time(); + const esp_err_t error = sdmmc_read_sectors(&card_, target, sector, batch); + read_metrics_.elapsed_us += static_cast(esp_timer_get_time() - begin_us); + ++read_metrics_.calls; + read_metrics_.sectors += static_cast(batch); + read_metrics_.max_sectors = std::max(read_metrics_.max_sectors, static_cast(batch)); + if (error != ESP_OK) return failIo(error); + const size_t bytes = batch * Transfer::sector_bytes; + if (!direct) std::memcpy(destination, target, bytes); + destination += bytes; + sector += batch; + count -= batch; } return true; } bool ArduinoSdmmcBlockDevice::writeSector(Sector_t sector, const uint8_t* source) { - if (!ready_ || !source || sector >= sectorCount()) return false; - std::memcpy(s_sector_scratch, source, sizeof(s_sector_scratch)); - return sdmmc_write_sectors(&card_, s_sector_scratch, sector, 1) == ESP_OK; + return writeSectors(sector, source, 1); } bool ArduinoSdmmcBlockDevice::writeSectors(Sector_t sector, const uint8_t* source, size_t count) { - if (!ready_ || (!source && count) || sector > sectorCount() || count > sectorCount() - sector) return false; - for (size_t index = 0; index < count; ++index) + if (!ready_ || (!source && count) || sector > sectorCount() || count > sectorCount() - sector || + count > std::numeric_limits::max() / Transfer::sector_bytes) return failIo(ESP_ERR_INVALID_ARG); + if (count == 0) return true; + const bool direct = direct_dma_buffer(source, count * Transfer::sector_bytes); + while (count != 0) { - if (!writeSector(sector + index, source + index * sizeof(s_sector_scratch))) return false; + const size_t batch = std::min(count, direct ? Transfer::max_direct_sectors + : Transfer::dma_buffer_bytes / Transfer::sector_bytes); + const size_t bytes = batch * Transfer::sector_bytes; + if (!direct) std::memcpy(dma_buffer_, source, bytes); + const esp_err_t error = sdmmc_write_sectors(&card_, direct ? source : dma_buffer_, sector, batch); + if (error != ESP_OK) return failIo(error); + source += bytes; + sector += batch; + count -= batch; } return true; } diff --git a/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp b/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp index 9a76ad87..610bf591 100644 --- a/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp +++ b/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp @@ -8,6 +8,8 @@ #include "freertos/semphr.h" #include "freertos/task.h" #include "lvgl.h" +#include "platform/esp/arduino_common/map_tiles/map_tile_command_queue.h" +#include "platform/esp/arduino_common/map_tiles/map_tile_event_queue.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "src/draw/lv_image_decoder_private.h" #include "src/misc/cache/instance/lv_image_cache.h" @@ -15,10 +17,12 @@ #include "ui/page/page_profile.h" #include "ui/runtime/memory_profile.h" #include "ui/screens/gps/gps_constants.h" +#include "ui/widgets/map/map_diagnostics.h" #include "ui_map_runtime/map_tiles/filesystem_map_tile_source.h" #include "ui_map_runtime/map_tiles/map_tile_async_runtime.h" #include "ui_map_runtime/map_tiles/map_tile_decoder_cache.h" #include "ui_map_runtime/map_tiles/map_tile_geometry.h" +#include "ui_map_runtime/map_tiles/map_tile_pipeline_metrics.h" #include "ui_map_runtime/map_tiles/map_tile_types.h" #if defined(TRAIL_MATE_MAP_POI_AVAILABLE) #include "platform/esp/arduino_common/map_poi/cjson_poi_parser.h" @@ -190,6 +194,12 @@ class SdMapTileFileSystem final : public ui::map_tiles::IMapTileFileSystem ui::map_tiles::MapTileReadResult mapped{}; mapped.size = result.bytes_read; mapped.error = result.error; + mapped.timing = {result.lock_wait_ms, result.open_ms, result.read_ms, true}; + mapped.timing.block_calls = result.block_calls; + mapped.timing.block_sectors = result.block_sectors; + mapped.timing.block_max_sectors = result.block_max_sectors; + mapped.timing.block_us = result.block_us; + mapped.timing.block_available = result.block_timing_available; switch (result.status) { case ::platform::esp::arduino_common::storage::SdFileReadStatus::Ready: @@ -215,8 +225,6 @@ class SdMapTileFileSystem final : public ui::map_tiles::IMapTileFileSystem }; #endif -constexpr std::size_t kMapTileCommandQueueCapacity = 16; -constexpr std::size_t kMapTileEventQueueCapacity = 16; constexpr std::size_t kMapTileWorkerScratchBytes = 192U * 1024U; constexpr std::size_t kMapTileWorkerTaskStackBytes = 4U * 1024U; constexpr int kMapTileEventsPerUiDrain = 1; @@ -346,6 +354,9 @@ void log_map_tile_decode_failure(const char* stage, { return; } + MAP_DIAG("[MAPD][decode-fail] t=%lu stage=%s layer=%u z=%u x=%lu y=%lu bytes=%u err=%ld\n", + static_cast(now_ms), stage, static_cast(ref.layer), static_cast(ref.z), + static_cast(ref.x), static_cast(ref.y), static_cast(size), error); std::printf("[GPS][MAP][decode] fail stage=%s layer=%u z=%u x=%lu y=%lu fmt=%s size=%lu err=%ld\n", stage ? stage : "unknown", static_cast(ref.layer), @@ -369,6 +380,9 @@ void log_map_tile_event_failure(const char* stage, } char path[160]{}; (void)resolve_map_tile_log_path(event.tile, path, sizeof(path)); + MAP_DIAG("[MAPD][event-fail] t=%lu stage=%s gen=%lu id=%lu kind=%u err=%ld path=%s\n", + static_cast(now_ms), stage, static_cast(event.generation), + static_cast(event.command_id), static_cast(event.kind), error, path); std::printf("[GPS][MAP][event] fail stage=%s kind=%s layer=%s(%u) z=%u x=%lu y=%lu gen=%lu active_gen=%lu err=%ld path=%s\n", stage ? stage : "unknown", map_tile_event_kind_name(event.kind), @@ -636,260 +650,62 @@ MapTileAvailabilityMemory& map_tile_availability_memory() return *memory; } -class MapTileCommandQueue final : public ui::map_tiles::IMapTileCommandSink +using MapTileCommandQueue = platform::esp::arduino_common::map_tiles::MapTileCommandQueue; + +ui::map_tiles::MapTileAsyncEvent copy_map_tile_event(const ui::map_tiles::MapTileAsyncEvent& event) { - public: - bool enqueue(const ui::map_tiles::LoadTileCommand& command) override + ui::map_tiles::MapTileAsyncEvent owned = event; + if (owned.kind == ui::map_tiles::MapTileAsyncEventKind::Ready && + event.payload.data != nullptr && + event.payload.size > 0) { - if (!ensureMutex() || xSemaphoreTake(mutex_, 0) != pdTRUE) + uint8_t* payload = nullptr; + int allocation_error = -12; +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + if (event.payload.format == ui::map_tiles::MapTileFormat::PoiRecords) { - return false; + if (ui::map_poi::validPayload(event.payload.data, event.payload.size)) + payload = static_cast(heap_caps_aligned_alloc(alignof(ui::map_poi::TileHeader), event.payload.size, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + else allocation_error = -22; } - - bool ok = false; - for (std::size_t i = 0; i < queue_.size(); ++i) + else +#endif + payload = allocate_tile_payload(event.payload.size); + if (payload == nullptr) { - auto* existing = queue_.at(i); - if (existing && - existing->runtime.generation == command.runtime.generation && - same_tile_ref(existing->tile, command.tile)) - { - *existing = command; - ok = true; - break; - } + log_map_tile_event_failure("payload_alloc", owned, allocation_error); + owned.kind = ui::map_tiles::MapTileAsyncEventKind::Failed; + owned.error = allocation_error; + owned.payload = {}; + owned.payload_size = 0; } - if (!ok) + else { - ok = queue_.enqueue(command); - } - xSemaphoreGive(mutex_); - return ok; - } - - std::size_t cancelGeneration(uint32_t generation) override - { - if (!ensureMutex() || xSemaphoreTake(mutex_, 0) != pdTRUE) - { - return 0; - } - - sys::runtime::FixedRuntimeQueue - kept; - std::size_t removed = 0; - for (std::size_t i = 0; i < queue_.size(); ++i) - { - const auto* command = queue_.at(i); - if (!command) - { - continue; - } - if (command->runtime.generation == generation) - { - ++removed; - continue; - } - (void)kept.enqueue(*command); - } - queue_ = kept; - xSemaphoreGive(mutex_); - return removed; - } - - bool pop(uint32_t now_ms, ui::map_tiles::LoadTileCommand& out) - { - if (!ensureMutex() || xSemaphoreTake(mutex_, pdMS_TO_TICKS(10)) != pdTRUE) - { - return false; - } - - std::size_t best_index = kMapTileCommandQueueCapacity; - for (std::size_t i = 0; i < queue_.size(); ++i) - { - const auto* candidate = queue_.at(i); - if (!candidate) - { - continue; - } - if (candidate->runtime.deadline_ms != 0 && - static_cast(candidate->runtime.deadline_ms - now_ms) < 0) - { - continue; - } - if (best_index == kMapTileCommandQueueCapacity) - { - best_index = i; - continue; - } - - const auto* best = queue_.at(best_index); - if (!best || - static_cast(candidate->runtime.priority) < - static_cast(best->runtime.priority) || - (candidate->runtime.priority == best->runtime.priority && - candidate->runtime.created_at_ms < best->runtime.created_at_ms)) - { - best_index = i; - } - } - - if (best_index == kMapTileCommandQueueCapacity) - { - xSemaphoreGive(mutex_); - return false; - } - - sys::runtime::FixedRuntimeQueue - kept; - for (std::size_t i = 0; i < queue_.size(); ++i) - { - const auto* command = queue_.at(i); - if (!command) - { - continue; - } - if (i == best_index) - { - out = *command; - } - else - { - (void)kept.enqueue(*command); - } - } - queue_ = kept; - xSemaphoreGive(mutex_); - return true; - } - - private: - bool ensureMutex() - { - if (mutex_ == nullptr) - { - mutex_ = xSemaphoreCreateMutex(); - } - return mutex_ != nullptr; - } - - SemaphoreHandle_t mutex_ = nullptr; - sys::runtime::FixedRuntimeQueue - queue_{}; -}; - -class MapTileEventQueue final : public ui::map_tiles::IMapTileEventSink -{ - public: - bool publish(const ui::map_tiles::MapTileAsyncEvent& event) override - { - ui::map_tiles::MapTileAsyncEvent owned = event; - if (owned.kind == ui::map_tiles::MapTileAsyncEventKind::Ready && - event.payload.data != nullptr && - event.payload.size > 0) - { - uint8_t* payload = nullptr; - int allocation_error = -12; #if defined(TRAIL_MATE_MAP_POI_AVAILABLE) if (event.payload.format == ui::map_tiles::MapTileFormat::PoiRecords) { - if (ui::map_poi::validPayload(event.payload.data, event.payload.size)) - payload = static_cast(heap_caps_aligned_alloc(alignof(ui::map_poi::TileHeader), event.payload.size, - MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); - else allocation_error = -22; + const auto* source_header = reinterpret_cast(event.payload.data); + new (payload) ui::map_poi::TileHeader(*source_header); + const auto* records = ui::map_poi::payloadRecords(event.payload.data); + for (std::size_t i = 0; i < source_header->count; ++i) + new (payload + sizeof(ui::map_poi::TileHeader) + i * sizeof(ui::map_poi::Record)) ui::map_poi::Record(records[i]); } else #endif - payload = allocate_tile_payload(event.payload.size); - if (payload == nullptr) - { - log_map_tile_event_failure("payload_alloc", owned, allocation_error); - owned.kind = ui::map_tiles::MapTileAsyncEventKind::Failed; - owned.error = allocation_error; - owned.payload = {}; - owned.payload_size = 0; - } - else - { -#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) - if (event.payload.format == ui::map_tiles::MapTileFormat::PoiRecords) - { - const auto* source_header = reinterpret_cast(event.payload.data); - new (payload) ui::map_poi::TileHeader(*source_header); - const auto* records = ui::map_poi::payloadRecords(event.payload.data); - for (std::size_t i = 0; i < source_header->count; ++i) - new (payload + sizeof(ui::map_poi::TileHeader) + i * sizeof(ui::map_poi::Record)) ui::map_poi::Record(records[i]); - } - else -#endif - std::memcpy(payload, event.payload.data, event.payload.size); - owned.payload.data = payload; - owned.payload.size = event.payload.size; - owned.payload.format = event.payload.format; - owned.payload.ref = event.payload.ref; - } + std::memcpy(payload, event.payload.data, event.payload.size); + owned.payload.data = payload; + owned.payload.size = event.payload.size; + owned.payload.format = event.payload.format; + owned.payload.ref = event.payload.ref; } - - if (!ensureMutex() || xSemaphoreTake(mutex_, pdMS_TO_TICKS(20)) != pdTRUE) - { - log_map_tile_event_failure("event_queue_lock", owned, -1); - release_tile_payload(owned); - return false; - } - const bool ok = queue_.enqueue(owned); - xSemaphoreGive(mutex_); - if (!ok) - { - log_map_tile_event_failure("event_queue_full", owned, -2); - release_tile_payload(owned); - } - return ok; } + owned.published_ms = sys::millis_now(); + owned.timing_available = true; + return owned; +} - bool pop(ui::map_tiles::MapTileAsyncEvent& out) - { - if (!ensureMutex() || xSemaphoreTake(mutex_, 0) != pdTRUE) - { - return false; - } - const bool ok = queue_.pop(out); - xSemaphoreGive(mutex_); - return ok; - } - - void clear() - { - if (!ensureMutex() || - xSemaphoreTake(mutex_, pdMS_TO_TICKS(20)) != pdTRUE) - { - return; - } - ui::map_tiles::MapTileAsyncEvent event{}; - while (queue_.pop(event)) - { - release_tile_payload(event); - event = {}; - } - xSemaphoreGive(mutex_); - } - - private: - bool ensureMutex() - { - if (mutex_ == nullptr) - { - mutex_ = xSemaphoreCreateMutex(); - } - return mutex_ != nullptr; - } - - SemaphoreHandle_t mutex_ = nullptr; - sys::runtime::FixedRuntimeQueue - queue_{}; -}; +using MapTileEventQueue = platform::esp::arduino_common::map_tiles::MapTileEventQueue; class EspMapTileWorkerBackend final : public ui::map_tiles::IMapTileWorkerBackend { @@ -926,6 +742,7 @@ class EspMapTileWorkerBackend final : public ui::map_tiles::IMapTileWorkerBacken result.format = storage_result.format; result.size = storage_result.size; result.error = storage_result.error; + result.timing = storage_result.timing; switch (storage_result.status) { case ui::map_tiles::MapTileReadStatus::Ready: @@ -1186,29 +1003,48 @@ class MapTileAsyncHost final { --lease_count_; } + const bool last_viewport = lease_count_ == 0; portEXIT_CRITICAL(&lock_); + // Wake a capacity-blocked producer even when the UI is gone. It will + // acknowledge cancellation, then follow the normal worker shutdown. + if (last_viewport) cancelGeneration(async_runtime_.activeGeneration()); } - bool request(const ui::map_tiles::MapTileRef& ref, - uint32_t generation, - ui::map_tiles::MapTileInteractionMode mode) + ui::map_tiles::TileSubmitResult request(const ui::map_tiles::MapTileRef& ref, + uint32_t generation, + ui::map_tiles::MapTileInteractionMode mode) { if (!ensureStarted()) { - return false; + return {}; } - ui::map_tiles::MapViewportPlan plan{}; - plan.generation = generation; - plan.interaction_mode = mode; - plan.tiles[0] = ref; - plan.tile_count = 1; - return runtime_.requestVisibleTiles(plan, sys::millis_now()) == 1; + events_.activateGeneration(generation); + const auto result = runtime_.requestTile(ref, generation, mode, sys::millis_now()); + MAP_DIAG("[MAPD][submit] t=%lu gen=%lu id=%lu status=%u layer=%u z=%u x=%lu y=%lu\n", + static_cast(sys::millis_now()), static_cast(generation), + static_cast(result.handle.command_id), static_cast(result.status), + static_cast(ref.layer), static_cast(ref.z), + static_cast(ref.x), static_cast(ref.y)); + if (result.status == ui::map_tiles::TileSubmitStatus::Backpressured) ++metrics_.submission_backpressure; + return result; } bool popEvent(ui::map_tiles::MapTileAsyncEvent& out) { - return events_.pop(out); + if (!events_.pop(out)) return false; + metrics_.consumed(out, sys::millis_now()); + commands_.complete({out.generation, out.command_id}); + return true; + } + + template + bool popEventIf(ui::map_tiles::MapTileAsyncEvent& out, Predicate eligible) + { + if (!events_.popIf(out, eligible)) return false; + metrics_.consumed(out, sys::millis_now()); + commands_.complete({out.generation, out.command_id}); + return true; } bool acceptEvent(const ui::map_tiles::MapTileAsyncEvent& event, @@ -1216,14 +1052,81 @@ class MapTileAsyncHost final { (void)render_queue; ui::map_tiles::MapTileRenderQueue acceptance_queue; - return runtime_.handle(event, acceptance_queue); + const bool accepted = runtime_.handle(event, acceptance_queue); + MAP_DIAG("[MAPD][consume] t=%lu gen=%lu id=%lu kind=%u accepted=%d bytes=%u err=%ld\n", + static_cast(sys::millis_now()), static_cast(event.generation), + static_cast(event.command_id), static_cast(event.kind), accepted, + static_cast(event.payload_size), static_cast(event.error)); + return accepted; } void cancelGeneration(uint32_t generation) { + MAP_DIAG("[MAPD][cancel-generation] t=%lu gen=%lu\n", static_cast(sys::millis_now()), static_cast(generation)); + events_.cancelGeneration(generation); (void)runtime_.cancelGeneration(generation); } + ui::map_tiles::MapTilePipelineMetrics& metrics() { return metrics_; } + + void reportDiagnostics() + { +#if TRAIL_MATE_MAP_DIAGNOSTICS + std::size_t queued = 0, in_flight = 0; + MapTileEventQueue::Statistics result; + const bool command_ok = commands_.statistics(queued, in_flight); + const bool result_ok = events_.statistics(result); + MAP_DIAG("[MAPD][queues] t=%lu gen=%lu cmd_ok=%d queued=%u inflight=%u result_ok=%d occupied=%u high=%u pressure=%lu consumed=%lu rendered=%lu heap=%u psram=%u\n", + static_cast(sys::millis_now()), static_cast(async_runtime_.activeGeneration()), + command_ok, static_cast(queued), static_cast(in_flight), result_ok, + static_cast(result.occupied), static_cast(result.high_water), static_cast(result.backpressure), + static_cast(metrics_.consumed_count), static_cast(metrics_.rendered_count), + static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)), static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM))); +#endif + } + + // Called only by the idle UI loader, never under queue/filesystem locks or + // from gesture maintenance. Times are integer ms, each as average/max. + void reportMetrics() + { + const auto now = sys::millis_now(); + if (now - last_metrics_ms_ < 5000 || metrics_.consumed_count == 0) return; + MapTileEventQueue::Statistics queue; + if (!events_.statistics(queue)) return; + const auto& s = metrics_.stages; + using Metrics = ui::map_tiles::MapTilePipelineMetrics; + std::printf("[GPS][MAP][perf] consumed=%lu ready=%lu failed=%lu retry=%lu rendered=%lu " + "q=%u/%u reserve_bp=%lu submit_bp=%lu file_n=%lu ms(avg/max) " + "cmd=%lu/%lu worker=%lu/%lu lock=%lu/%lu open=%lu/%lu read=%lu/%lu " + "result=%lu/%lu decode=%lu/%lu apply=%lu/%lu\n", + static_cast(metrics_.consumed_count), + static_cast(metrics_.ready_count), + static_cast(metrics_.failed_count), + static_cast(metrics_.retry_count), + static_cast(metrics_.rendered_count), + static_cast(queue.occupied), static_cast(queue.high_water), + static_cast(queue.backpressure), + static_cast(metrics_.submission_backpressure), + static_cast(s[Metrics::FileLock].count), + static_cast(s[Metrics::CommandWait].average()), static_cast(s[Metrics::CommandWait].max_ms), + static_cast(s[Metrics::Worker].average()), static_cast(s[Metrics::Worker].max_ms), + static_cast(s[Metrics::FileLock].average()), static_cast(s[Metrics::FileLock].max_ms), + static_cast(s[Metrics::FileOpen].average()), static_cast(s[Metrics::FileOpen].max_ms), + static_cast(s[Metrics::FileRead].average()), static_cast(s[Metrics::FileRead].max_ms), + static_cast(s[Metrics::ResultWait].average()), static_cast(s[Metrics::ResultWait].max_ms), + static_cast(s[Metrics::Decode].average()), static_cast(s[Metrics::Decode].max_ms), + static_cast(s[Metrics::Apply].average()), static_cast(s[Metrics::Apply].max_ms)); + if (metrics_.block_samples) + std::printf("[GPS][MAP][block] files=%lu calls=%llu sectors=%llu max_batch=%lu total_us=%llu\n", + static_cast(metrics_.block_samples), + static_cast(metrics_.block_calls), + static_cast(metrics_.block_sectors), + static_cast(metrics_.block_max_sectors), + static_cast(metrics_.block_us)); + metrics_ = {}; + last_metrics_ms_ = now; + } + private: static void taskThunk(void* self) { @@ -1232,15 +1135,43 @@ class MapTileAsyncHost final void taskLoop() { + ui::map_tiles::LoadTileCommand command{}; + bool have_command = false; for (;;) { - ui::map_tiles::LoadTileCommand command{}; - if (commands_.pop(sys::millis_now(), command)) + if (!have_command) + { + have_command = commands_.pop(sys::millis_now(), command); + if (have_command) + MAP_DIAG("[MAPD][worker-start] t=%lu gen=%lu id=%lu layer=%u z=%u x=%lu y=%lu\n", + static_cast(sys::millis_now()), static_cast(command.runtime.generation), + static_cast(command.runtime.command_id), static_cast(command.tile.layer), + static_cast(command.tile.z), static_cast(command.tile.x), static_cast(command.tile.y)); + } + if (have_command) { if (worker_ != nullptr) { - (void)worker_->execute(command, sys::millis_now()); + const auto result = worker_->execute(command, sys::millis_now()); + if (result == ui::map_tiles::MapTileExecutionStatus::Backpressured) + { +#if TRAIL_MATE_MAP_DIAGNOSTICS + static uint32_t last_pressure_ms = 0; + if (sys::millis_now() - last_pressure_ms >= 2000U) + { + last_pressure_ms = sys::millis_now(); + MAP_DIAG("[MAPD][worker-capacity-wait] t=%lu id=%lu\n", static_cast(last_pressure_ms), static_cast(command.runtime.command_id)); + } +#endif + // Retain the command; no SD lock is held while waiting. + events_.waitForCapacity(pdMS_TO_TICKS(20)); + continue; + } + MAP_DIAG("[MAPD][worker-end] t=%lu gen=%lu id=%lu status=%u\n", + static_cast(sys::millis_now()), static_cast(command.runtime.generation), + static_cast(command.runtime.command_id), static_cast(result)); } + have_command = false; vTaskDelay(kMapTileWorkerPostCommandYieldTicks); continue; } @@ -1277,18 +1208,19 @@ class MapTileAsyncHost final bool ensureStarted() { + if (!events_.available() || !commands_.available()) return false; portENTER_CRITICAL(&lock_); const bool started = started_; const bool stopping = stopping_; portEXIT_CRITICAL(&lock_); - if (started) - { - return true; - } if (stopping) { return false; } + if (started) + { + return true; + } if (scratch_ == nullptr) { @@ -1363,7 +1295,9 @@ class MapTileAsyncHost final } MapTileCommandQueue commands_{}; - MapTileEventQueue events_{}; + ui::map_tiles::MapTilePipelineMetrics metrics_{}; + uint32_t last_metrics_ms_ = 0; + MapTileEventQueue events_{copy_map_tile_event, release_tile_payload}; EspMapTileWorkerBackend backend_{worker_tile_source()}; uint8_t* scratch_ = nullptr; ui::map_tiles::MapTileWorker* worker_ = nullptr; @@ -2463,12 +2397,13 @@ static bool request_base_tile_async(MapTile& tile) return false; } - if (map_tile_async_host().request(ref, - g_map_tile_runtime_generation, - ui::map_tiles::MapTileInteractionMode::InteractiveDrag)) + if (const auto submitted = map_tile_async_host().request(ref, + g_map_tile_runtime_generation, + ui::map_tiles::MapTileInteractionMode::InteractiveDrag)) { tile.base_request_pending = true; tile.base_request_generation = g_map_tile_runtime_generation; + tile.base_request_id = submitted.handle.command_id; return true; } tile.base_retry_not_before_ms = now_ms + kMapTileLayerBusyBackoffMs; @@ -2514,12 +2449,13 @@ static bool request_contour_tile_async(MapTile& tile) } } - if (map_tile_async_host().request(ref, - g_map_tile_runtime_generation, - ui::map_tiles::MapTileInteractionMode::InteractiveDrag)) + if (const auto submitted = map_tile_async_host().request(ref, + g_map_tile_runtime_generation, + ui::map_tiles::MapTileInteractionMode::InteractiveDrag)) { tile.contour_request_pending = true; tile.contour_request_generation = g_map_tile_runtime_generation; + tile.contour_request_id = submitted.handle.command_id; return true; } tile.contour_retry_not_before_ms = now_ms + kMapTileLayerBusyBackoffMs; @@ -2530,6 +2466,12 @@ static bool request_contour_tile_async(MapTile& tile) static bool apply_poi_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEvent& event) { MapTile* tile = find_tile(ctx, static_cast(event.tile.x), static_cast(event.tile.y), event.tile.z); + if (tile && (!tile->poi_pending || tile->poi_request_generation != event.generation || + tile->poi_request_id != event.command_id)) + { + release_tile_payload(event); + return false; + } if (!tile || !tile->visible || !ctx.anchor || event.tile.z != ctx.anchor->z) { if (tile) @@ -2596,18 +2538,18 @@ static void request_visible_poi_tile(TileContext& ctx) for (auto& tile : *ctx.tiles) { if (!tile.visible || tile.z != ctx.anchor->z || tile.map_source != g_active_map_source || - tile.poi_checked || + tile.poi_checked || tile.poi_pending || (tile.poi_retry_not_before_ms && static_cast(tile.poi_retry_not_before_ms - now) > 0)) continue; - // A full event queue can drop completion. Reuse the retry deadline as - // an in-flight lease so that a dropped event cannot stall this tile. - tile.poi_pending = false; if (!best || tile.priority < best->priority) best = &tile; } if (!best) return; ui::map_tiles::MapTileRef ref{ui::map_tiles::MapTileLayer::Poi, static_cast(best->z), static_cast(best->x), static_cast(best->y)}; - best->poi_pending = map_tile_async_host().request(ref, g_map_tile_runtime_generation, ui::map_tiles::MapTileInteractionMode::Idle); - best->poi_retry_not_before_ms = now + (best->poi_pending ? 10000U : kMapTileLayerBusyBackoffMs); + const auto submitted = map_tile_async_host().request(ref, g_map_tile_runtime_generation, ui::map_tiles::MapTileInteractionMode::Idle); + best->poi_pending = static_cast(submitted); + best->poi_request_generation = submitted.handle.generation; + best->poi_request_id = submitted.handle.command_id; + best->poi_retry_not_before_ms = submitted ? 0 : now + kMapTileLayerBusyBackoffMs; } #endif @@ -2650,7 +2592,17 @@ static bool apply_map_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEv uint32_t& retry_not_before = is_contour ? tile->contour_retry_not_before_ms : tile->base_retry_not_before_ms; bool& pending = is_contour ? tile->contour_request_pending : tile->base_request_pending; + const ui::map_tiles::TileRequestHandle request{ + is_contour ? tile->contour_request_generation : tile->base_request_generation, + is_contour ? tile->contour_request_id : tile->base_request_id}; + if (!pending || !request.matches(event.generation, event.command_id)) + { + release_tile_payload(event); + return false; + } pending = false; + MAP_DIAG("[MAPD][pending-clear] gen=%lu id=%lu visible=%d kind=%u\n", + static_cast(event.generation), static_cast(event.command_id), tile->visible, static_cast(event.kind)); const uint32_t now_ms = sys::millis_now(); if (event.kind == ui::map_tiles::MapTileAsyncEventKind::RetryLater) @@ -2695,7 +2647,11 @@ static bool apply_map_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEv return true; } + using Metrics = ui::map_tiles::MapTilePipelineMetrics; + auto& metrics = map_tile_async_host().metrics(); + const auto decode_start = sys::millis_now(); DecodedTileCache* cache = decode_payload_to_cache(ctx, event.tile, event.payload); + metrics.stages[Metrics::Decode].add(sys::millis_now() - decode_start); if (cache == nullptr) { retry_not_before = now_ms + kMapTileLayerCacheBackoffMs; @@ -2704,6 +2660,7 @@ static bool apply_map_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEv return false; } + const auto apply_start = sys::millis_now(); const bool rendered = is_contour ? render_contour_from_cache(*tile, *cache) : render_base_tile_from_cache(ctx, *tile, *cache); if (!rendered) @@ -2715,11 +2672,120 @@ static bool apply_map_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEv release_tile_payload(event); update_visible_map_data_flag(ctx); rebuild_render_queue(ctx); + metrics.stages[Metrics::Apply].add(sys::millis_now() - apply_start); + if (rendered) ++metrics.rendered_count; + MAP_DIAG("[MAPD][render] t=%lu id=%lu ok=%d obj=%p hidden=%d refs=%u decode_ms=%lu\n", + static_cast(sys::millis_now()), static_cast(event.command_id), rendered, + tile->img_obj, tile->img_obj ? lv_obj_has_flag(tile->img_obj, LV_OBJ_FLAG_HIDDEN) : -1, + static_cast(cache->lvgl_ref_count), static_cast(apply_start - decode_start)); return rendered; } +static bool current_tile_request(const MapTile& tile, const ui::map_tiles::MapTileAsyncEvent& event) +{ +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + if (event.tile.layer == ui::map_tiles::MapTileLayer::Poi) + return tile.poi_pending && tile.poi_request_generation == event.generation && tile.poi_request_id == event.command_id; +#endif + const bool contour = ui::map_tiles::mapTileLayerIsContour(event.tile.layer); + return (contour ? tile.contour_request_pending : tile.base_request_pending) && + (contour ? tile.contour_request_generation : tile.base_request_generation) == event.generation && + (contour ? tile.contour_request_id : tile.base_request_id) == event.command_id; +} + +static void map_tile_diagnostic_snapshot(const TileContext& ctx, const char* stage) +{ +#if TRAIL_MATE_MAP_DIAGNOSTICS + if (!ctx.tiles) return; + unsigned visible = 0, loaded = 0, pending = 0, objects = 0; + for (const auto& tile : *ctx.tiles) + { + objects += tile.img_obj != nullptr; + pending += tile.base_request_pending; + if (!tile.visible) continue; + ++visible; + loaded += tile.has_png_file; + int sx = 0, sy = 0; + const bool projected = tile_screen_pos_xyz(ctx, tile.x, tile.y, tile.z, sx, sy); + MAP_DIAG("[MAPD][tile] stage=%s z=%d x=%d y=%d source=%u loaded=%d missing=%d pending=%d gen=%lu id=%lu retry=%lu obj=%p hidden=%d pos=%d,%d projected=%d at=%d,%d refs=%u\n", + stage, tile.z, static_cast(tile.x), static_cast(tile.y), static_cast(tile.map_source), + tile.has_png_file, tile.base_missing, tile.base_request_pending, static_cast(tile.base_request_generation), + static_cast(tile.base_request_id), static_cast(tile.base_retry_not_before_ms), tile.img_obj, + tile.img_obj ? lv_obj_has_flag(tile.img_obj, LV_OBJ_FLAG_HIDDEN) : -1, + tile.img_obj ? lv_obj_get_x(tile.img_obj) : 0, tile.img_obj ? lv_obj_get_y(tile.img_obj) : 0, projected, sx, sy, + tile.cached_img ? static_cast(tile.cached_img->lvgl_ref_count) : 0); + } + MAP_DIAG("[MAPD][viewport] stage=%s t=%lu gen=%lu records=%u visible=%u loaded=%u pending=%u objects=%u anchor=%d z=%d size=%d,%d\n", + stage, static_cast(sys::millis_now()), static_cast(g_map_tile_runtime_generation), + static_cast(ctx.tiles->size()), visible, loaded, pending, objects, ctx.anchor && ctx.anchor->valid, + ctx.anchor ? ctx.anchor->z : -1, ctx.map_container ? lv_obj_get_width(ctx.map_container) : 0, ctx.map_container ? lv_obj_get_height(ctx.map_container) : 0); +#else + (void)ctx; + (void)stage; +#endif +} + +void tile_loader_maintenance(TileContext& ctx) +{ +#if TRAIL_MATE_MAP_DIAGNOSTICS + static uint32_t last_snapshot_ms = 0; + if (sys::millis_now() - last_snapshot_ms >= 2000U) + { + last_snapshot_ms = sys::millis_now(); + map_tile_diagnostic_snapshot(ctx, "heartbeat"); + map_tile_async_host().reportDiagnostics(); + } +#endif + if (!ctx.runtime_acquired || !ctx.tiles) return; + const auto start_ms = sys::millis_now(); + ui::map_tiles::MapTileAsyncEvent event; + // At most the fixed queue capacity, additionally bounded by wall time. + for (unsigned i = 0; i < 16; ++i) + { + const bool found = map_tile_async_host().popEventIf(event, [&](const auto& candidate) + { + if (candidate.generation != g_map_tile_runtime_generation) return true; + const auto* tile = find_tile(ctx, candidate.tile.x, candidate.tile.y, candidate.tile.z); + return !tile || !tile->visible || !current_tile_request(*tile, candidate); }); + if (!found) break; + auto* tile = find_tile(ctx, event.tile.x, event.tile.y, event.tile.z); + MAP_DIAG("[MAPD][discard] t=%lu gen=%lu active=%lu id=%lu exists=%d visible=%d matches=%d\n", + static_cast(sys::millis_now()), static_cast(event.generation), + static_cast(g_map_tile_runtime_generation), static_cast(event.command_id), + tile != nullptr, tile ? tile->visible : 0, tile ? current_tile_request(*tile, event) : 0); + if (tile && current_tile_request(*tile, event)) + { +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + if (event.tile.layer == ui::map_tiles::MapTileLayer::Poi) tile->poi_pending = false; + else +#endif + if (ui::map_tiles::mapTileLayerIsContour(event.tile.layer)) + tile->contour_request_pending = false; + else tile->base_request_pending = false; + } + release_tile_payload(event); + if (static_cast(sys::millis_now() - start_ms) >= kMapTileUiDrainBudgetMs) break; + } +} + static void drain_map_tile_events(TileContext& ctx, uint32_t start_ms, uint32_t budget_ms) { + struct ReportOnExit + { + ~ReportOnExit() { map_tile_async_host().reportMetrics(); } + } report; + tile_loader_maintenance(ctx); + // Failure/cancellation bookkeeping must not consume a PNG decode slot or + // incur image cooldown. Stop between events when the UI budget is spent. + ui::map_tiles::MapTileAsyncEvent control; + while (static_cast(sys::millis_now() - start_ms) < budget_ms && + map_tile_async_host().popEventIf(control, [](const auto& event) + { return event.kind != ui::map_tiles::MapTileAsyncEventKind::Ready; })) + { + if (map_tile_async_host().acceptEvent(control, ctx.render_queue)) (void)apply_map_tile_event(ctx, control); + else release_tile_payload(control); + } + if (static_cast(sys::millis_now() - start_ms) >= budget_ms) return; const uint32_t now_ms = sys::millis_now(); if (g_map_tile_next_event_drain_ms != 0 && static_cast(g_map_tile_next_event_drain_ms - now_ms) > 0) @@ -3139,6 +3205,8 @@ static void evict_cache(TileContext& ctx) size_t idx = obj_candidates[i].second; if ((*ctx.tiles)[idx].img_obj != NULL) { + MAP_DIAG("[MAPD][evict-object] z=%d x=%d y=%d visible=%d pending=%d\n", (*ctx.tiles)[idx].z, + static_cast((*ctx.tiles)[idx].x), static_cast((*ctx.tiles)[idx].y), (*ctx.tiles)[idx].visible, (*ctx.tiles)[idx].base_request_pending); reset_tile_runtime((*ctx.tiles)[idx]); (*ctx.tiles)[idx].obj_evicted_ms = sys::millis_now(); } @@ -3194,6 +3262,9 @@ static void evict_cache(TileContext& ctx) { if (deleted >= to_delete) break; (*ctx.tiles)[candidate.second].record_evicted = true; + MAP_DIAG("[MAPD][evict-record] z=%d x=%d y=%d visible=%d pending=%d\n", (*ctx.tiles)[candidate.second].z, + static_cast((*ctx.tiles)[candidate.second].x), static_cast((*ctx.tiles)[candidate.second].y), + (*ctx.tiles)[candidate.second].visible, (*ctx.tiles)[candidate.second].base_request_pending); deleted++; } @@ -3201,6 +3272,9 @@ static void evict_cache(TileContext& ctx) { if (deleted >= to_delete) break; (*ctx.tiles)[candidate.second].record_evicted = true; + MAP_DIAG("[MAPD][evict-record] z=%d x=%d y=%d visible=%d pending=%d\n", (*ctx.tiles)[candidate.second].z, + static_cast((*ctx.tiles)[candidate.second].x), static_cast((*ctx.tiles)[candidate.second].y), + (*ctx.tiles)[candidate.second].visible, (*ctx.tiles)[candidate.second].base_request_pending); deleted++; } @@ -3238,7 +3312,9 @@ void calculate_required_tiles(TileContext& ctx, double lat, double lng, int zoom layout_loaded_tile_objects(ctx); + map_tile_diagnostic_snapshot(ctx, "layout"); evict_cache(ctx); + map_tile_diagnostic_snapshot(ctx, "evicted"); rebuild_render_queue(ctx); // Count tiles to load for logging diff --git a/platform/esp/idf_common/src/platform_ui_map_diagnostics.cpp b/platform/esp/idf_common/src/platform_ui_map_diagnostics.cpp new file mode 100644 index 00000000..64cc513a --- /dev/null +++ b/platform/esp/idf_common/src/platform_ui_map_diagnostics.cpp @@ -0,0 +1,17 @@ +#include "platform/ui/map_diagnostics.h" + +#if defined(TRAIL_MATE_MAP_DIAGNOSTICS) && TRAIL_MATE_MAP_DIAGNOSTICS +#include +#include + +namespace platform::ui +{ +void mapDiagnosticLog(const char* format, ...) +{ + va_list args; + va_start(args, format); + std::vprintf(format, args); + va_end(args); +} +} // namespace platform::ui +#endif diff --git a/platform/esp/wio_tracker_l2/src/wio_tracker_l2_board.cpp b/platform/esp/wio_tracker_l2/src/wio_tracker_l2_board.cpp index 98be07d9..8e33f147 100644 --- a/platform/esp/wio_tracker_l2/src/wio_tracker_l2_board.cpp +++ b/platform/esp/wio_tracker_l2/src/wio_tracker_l2_board.cpp @@ -7,6 +7,7 @@ // Keep the codec library's GPIO class out of the global ESP/Lovyan namespace. #define NO_USING_NAMESPACE_AUDIO_DRIVER 1 +#define TRAIL_MATE_AUDIO_DRIVER_EXPLICIT_INSTANCES 1 #include #include #include @@ -22,10 +23,21 @@ #include #include #include +#include #include namespace boards::wio_tracker_l2 { +static_assert(SDA == gpio::kI2cSda && SCL == gpio::kI2cScl, + "Arduino variant I2C pins must match the WIO board profile"); +static_assert(TX == gpio::kGpsTx && RX == gpio::kGpsRx, + "Arduino variant UART pins must match the WIO board profile"); +static_assert(SCK == gpio::kRadioSck && MISO == gpio::kRadioMiso && + MOSI == gpio::kRadioMosi && SS == gpio::kRadioCs, + "Arduino variant SPI pins must match the WIO board profile"); +#if defined(RGB_BUILTIN) || defined(LED_BUILTIN) +#error "WIO's user LED belongs to the expander, not an Arduino GPIO/NeoPixel alias" +#endif namespace { StaticSemaphore_t s_i2c_mutex_storage; @@ -36,7 +48,7 @@ StaticSemaphore_t s_audio_mutex_storage; SemaphoreHandle_t s_audio_mutex = nullptr; std::atomic s_tone_pending{false}; audio_driver::DriverDeviceInfo s_codec_pins; -auto& s_codec = audio_driver::AudioDriverES8311; +audio_driver::AudioDriverES8311Class s_codec; constexpr uint32_t kAudioSampleRate = 16000; int16_t s_tone_pcm[256]; @@ -155,8 +167,23 @@ WioTrackerL2Board::WioTrackerL2Board() WioTrackerL2Board& WioTrackerL2Board::instance() { - static WioTrackerL2Board board; - return board; + // Match T-Deck's placement policy. The current OPI SDK initializes PSRAM + // before global constructors; internal RAM remains the fallback. This + // object is task-context state (radio IRQs are polled). RTOS locks and + // peripheral DMA buffers retain their separate internal-memory ownership. + static WioTrackerL2Board* board = []() -> WioTrackerL2Board* + { + void* storage = heap_caps_malloc_prefer(sizeof(WioTrackerL2Board), + 2, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (storage == nullptr) + { + storage = ::operator new(sizeof(WioTrackerL2Board)); + } + return new (storage) WioTrackerL2Board(); + }(); + return *board; } bool WioTrackerL2Board::writeExpander(ExpanderPin pin, bool high) @@ -471,8 +498,11 @@ bool WioTrackerL2Board::installSD() { if (!writeExpander(ExpanderPin::SdPower, true)) return false; delay(10); - return platform::esp::arduino_common::storage::mount_sdmmc_card( - gpio::kSdClock, gpio::kSdCommand, gpio::kSdData0); + platform::esp::arduino_common::storage::SdmmcSdConfig config; + config.clock = gpio::kSdClock; + config.command = gpio::kSdCommand; + config.data0 = gpio::kSdData0; + return platform::esp::arduino_common::storage::mount_sd_card(config); } bool WioTrackerL2Board::ensureSDReady() { return isCardReady() || installSD(); } bool WioTrackerL2Board::isSDReady() const { return platform::esp::arduino_common::storage::sd_card_ready(); } diff --git a/platform/linux/common/include/ui/widgets/map/map_tiles.h b/platform/linux/common/include/ui/widgets/map/map_tiles.h index 5408c466..89771f02 100644 --- a/platform/linux/common/include/ui/widgets/map/map_tiles.h +++ b/platform/linux/common/include/ui/widgets/map/map_tiles.h @@ -78,6 +78,8 @@ bool tile_in_rect(int sx, int sy, int w, int h, int margin); void update_map_anchor(TileContext& ctx, double lat, double lng, int zoom, int pan_x, int pan_y, bool has_fix); void calculate_required_tiles(TileContext& ctx, double lat, double lng, int zoom, int pan_x, int pan_y, bool has_fix); void tile_loader_step(TileContext& ctx); +// This backend loads synchronously and has no worker completions to reclaim. +inline void tile_loader_maintenance(TileContext&) {} uint8_t sanitize_map_source(uint8_t map_source); const char* map_source_label(uint8_t map_source); bool build_base_tile_path(int z, int x, int y, uint8_t map_source, char* out_path, size_t out_size); diff --git a/platform/linux/common/src/platform/ui/map_diagnostics.cpp b/platform/linux/common/src/platform/ui/map_diagnostics.cpp new file mode 100644 index 00000000..64cc513a --- /dev/null +++ b/platform/linux/common/src/platform/ui/map_diagnostics.cpp @@ -0,0 +1,17 @@ +#include "platform/ui/map_diagnostics.h" + +#if defined(TRAIL_MATE_MAP_DIAGNOSTICS) && TRAIL_MATE_MAP_DIAGNOSTICS +#include +#include + +namespace platform::ui +{ +void mapDiagnosticLog(const char* format, ...) +{ + va_list args; + va_start(args, format); + std::vprintf(format, args); + va_end(args); +} +} // namespace platform::ui +#endif diff --git a/scripts/platformio-pre.py b/scripts/platformio-pre.py index dba4a7b7..2128f80b 100644 --- a/scripts/platformio-pre.py +++ b/scripts/platformio-pre.py @@ -60,7 +60,7 @@ def extract_version_from_ci_tag(): tag_name = "" if github_ref.startswith("refs/tags/"): tag_name = github_ref.rsplit("/", 1)[-1] - elif github_ref_name: + elif os.environ.get("GITHUB_REF_TYPE") == "tag" and github_ref_name: tag_name = github_ref_name if not tag_name: @@ -301,6 +301,41 @@ def configure_esp8266audio_for_esp32(): ) +def configure_explicit_audio_driver_instances(): + # audio-driver 0.3.1 instantiates every codec in AudioDriver.h, including + # codecs with nontrivial constructors. Offer an opt-out in the env-local + # dependency; the board then owns only its actual codec. Other environments + # and translation units retain the upstream default. + if not is_esp32_env or pio_env != "wio_tracker_l2": + return + library_dir = os.path.join(project_dir, ".pio", "libdeps", pio_env, "audio-driver") + header = os.path.join(library_dir, "src", "AudioDriver.h") + if not os.path.isfile(header): + raise RuntimeError("audio-driver dependency is required for WIO codec configuration") + with open(os.path.join(library_dir, "library.properties"), encoding="utf-8") as fp: + properties = fp.read() + if "version=0.3.1" not in properties.splitlines(): + raise RuntimeError("Review the explicit-instance patch for this audio-driver version") + with open(header, encoding="utf-8") as fp: + source = fp.read() + guard = "#ifndef TRAIL_MATE_AUDIO_DRIVER_EXPLICIT_INSTANCES" + if guard in source: + return + start_marker = "// -- Drivers\n" + end_marker = "} // namespace audio_driver" + if source.count(start_marker) != 1 or source.count(end_marker) != 1: + raise RuntimeError("Unexpected AudioDriver.h layout; refusing to patch codec instances") + start = source.index(start_marker) + len(start_marker) + end = source.index(end_marker, start) + instances = source[start:end] + if "static AudioDriverES8311Class AudioDriverES8311;" not in instances: + raise RuntimeError("Expected ES8311 instance declaration missing") + patched = source[:start] + guard + "\n" + instances + "#endif\n\n" + source[end:] + with open(header, "w", encoding="utf-8", newline="\n") as fp: + fp.write(patched) + print("[pio] pre: Enabled explicit audio-driver codec instances for WIO") + + def configure_radiolib_for_sx1262_esp32(): if not is_esp32_env or pio_env not in ( "tlora_pager_sx1262", @@ -650,6 +685,7 @@ def disable_arduino_tinyusb_dfu_for_release_esp32(): "tdeck", "tdeck_pro_a7682e", "tdeck_pro_pcm512a", + "wio_tracker_l2", } if not is_esp32_env or pio_env not in dfu_disabled_envs: return @@ -765,6 +801,7 @@ guard_no_arduino_sd_audio_paths() configure_radiolib_for_gat562() configure_crypto_for_gat562() configure_esp8266audio_for_esp32() +configure_explicit_audio_driver_instances() configure_radiolib_for_sx1262_esp32() configure_lvgl_for_esp32_ui() configure_crypto_for_sx1262_esp32() diff --git a/tests/map_gesture/CMakeLists.txt b/tests/map_gesture/CMakeLists.txt new file mode 100644 index 00000000..e3355411 --- /dev/null +++ b/tests/map_gesture/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.20) +project(map_gesture_regression LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +set(REPO "${CMAKE_CURRENT_LIST_DIR}/../..") +set(LVGL_DIR "" CACHE PATH "Pinned LVGL 9.4 source directory") +set(LV_BUILD_CONF_PATH "${REPO}/tests/map_poi_view/lv_conf.h" CACHE PATH "" FORCE) +set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) +set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(CONFIG_LV_USE_THORVG_INTERNAL OFF CACHE BOOL "" FORCE) +add_subdirectory(${LVGL_DIR} lvgl) + +# Compile the real map gesture callbacks, not a rewritten state machine. The +# surrounding tile/SD/POI runtime is intentionally excluded from this input test. +set(VIEWPORT "${REPO}/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${VIEWPORT}") +file(READ "${VIEWPORT}" SOURCE) +string(FIND "${SOURCE}" "bool is_runtime_alive(" FIRST) +string(FIND "${SOURCE}" "void update_gesture_surface_visibility(" LAST) +math(EXPR LENGTH "${LAST} - ${FIRST}") +string(SUBSTRING "${SOURCE}" ${FIRST} ${LENGTH} HELPERS) +string(FIND "${SOURCE}" "lv_indev_t* resolve_event_indev(" FIRST) +string(FIND "${SOURCE}" "void prime_visible_tiles(" LAST) +math(EXPR LENGTH "${LAST} - ${FIRST}") +string(SUBSTRING "${SOURCE}" ${FIRST} ${LENGTH} CALLBACKS) +string(REGEX MATCH "constexpr int kGestureDragStartPx = [0-9]+;" THRESHOLD "${SOURCE}") +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/map_gesture_actual.inc" "${THRESHOLD}\n${HELPERS}\n${CALLBACKS}") + +add_executable(map_gesture_regression test_gesture.cpp) +target_include_directories(map_gesture_regression PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/ui_shared/include" + "${REPO}/modules/ui_presentation/include") +target_link_libraries(map_gesture_regression PRIVATE lvgl) +if(MSVC) + target_compile_options(map_gesture_regression PRIVATE /UNDEBUG) +else() + target_compile_options(map_gesture_regression PRIVATE -UNDEBUG -Wall -Wextra) +endif() +enable_testing() +add_test(NAME map_gesture_regression COMMAND map_gesture_regression) +set_tests_properties(map_gesture_regression PROPERTIES TIMEOUT 30) diff --git a/tests/map_gesture/README.md b/tests/map_gesture/README.md new file mode 100644 index 00000000..191fddc8 --- /dev/null +++ b/tests/map_gesture/README.md @@ -0,0 +1,25 @@ +# Map gesture release regression + +This test compiles the production gesture callback and its helpers extracted +from `map_viewport.cpp`, then sends pointer samples through real LVGL 9.4. +It does not simulate release by directly calling the map callback. The fixture +only substitutes the surrounding runtime fields; no SD or board is involved. + +```sh +cmake -S tests/map_gesture -B .codex-build/map-gesture \ + -DLVGL_DIR=/path/to/lvgl-9.4 -DCMAKE_BUILD_TYPE=Debug +cmake --build .codex-build/map-gesture --parallel +ctest --test-dir .codex-build/map-gesture --output-on-failure +``` + +It checks a tap and 100 consecutive drags, including dragging outside the +surface. Each drag must emit exactly one DragEnd or Cancel and clear both +pressed and dragging state. Assertions are enabled even in release builds. + +Before the fix the pinned dependency reproduces on the first drag: +`release lost: cycle=0 pressed=1 dragging=1 ended=0`. Calling +`lv_indev_stop_processing` from the map's object PRESSING handler leaves a flag +which suppresses delivery of a subsequent RELEASED to that object. Limiting +consumption to the current event with `lv_event_stop_bubbling` passes the same +test. Physical touch-driver and complete viewport/loader integration are +separately verified on the board with the MAPD diagnostics. diff --git a/tests/map_gesture/test_gesture.cpp b/tests/map_gesture/test_gesture.cpp new file mode 100644 index 00000000..863dee4a --- /dev/null +++ b/tests/map_gesture/test_gesture.cpp @@ -0,0 +1,100 @@ +#include "ui/widgets/map/map_viewport.h" +#include +#include +#include +#include + +#define MAP_VIEWPORT_LOG(...) ((void)0) +#define MAP_DIAG(...) ((void)0) + +namespace ui::widgets::map +{ +// Minimal host fixture for the fields used by the actual production callbacks. +struct RuntimeImpl +{ + Widgets widgets{}; + bool alive = true; + bool gesture_enabled = true; + bool gesture_pressed = false; + bool gesture_dragging = false; + lv_point_t gesture_start{}; + lv_point_t gesture_last{}; + GestureCallback gesture_callback = nullptr; + void* gesture_user_data = nullptr; +}; +#include "map_gesture_actual.inc" +} // namespace ui::widgets::map + +int main() +{ + using namespace ui::widgets::map; + lv_init(); + auto* display = lv_display_create(320, 240); + lv_display_set_color_format(display, LV_COLOR_FORMAT_RGB565); + std::vector pixels(320 * 240); + lv_display_set_buffers(display, pixels.data(), nullptr, pixels.size() * 2, LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_flush_cb(display, [](lv_display_t* d, const lv_area_t*, uint8_t*) + { lv_display_flush_ready(d); }); + auto* surface = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(surface); + lv_obj_set_size(surface, 320, 240); + lv_obj_clear_flag(surface, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(surface, LV_OBJ_FLAG_CLICKABLE); + lv_obj_update_layout(surface); + + RuntimeImpl runtime; + runtime.widgets.root = runtime.widgets.tile_layer = runtime.widgets.gesture_surface = surface; + std::vector events; + runtime.gesture_user_data = &events; + runtime.gesture_callback = [](const GestureEvent& event, void* data) + { + static_cast*>(data)->push_back(event); + }; + lv_obj_add_event_cb(surface, gesture_surface_event_cb, LV_EVENT_ALL, &runtime); + + lv_indev_data_t sample{}; + auto* input = lv_indev_create(); + lv_indev_set_type(input, LV_INDEV_TYPE_POINTER); + lv_indev_set_display(input, display); + lv_indev_set_user_data(input, &sample); + lv_indev_set_read_cb(input, [](lv_indev_t* device, lv_indev_data_t* data) + { *data = *static_cast(lv_indev_get_user_data(device)); }); + auto feed = [&](int x, int y, lv_indev_state_t state) + { + sample.point = {x, y}; + sample.state = state; + lv_tick_inc(20); + lv_indev_read(input); + }; + + // A tap must not generate a drag or leave the loader's pressed gate set. + feed(100, 100, LV_INDEV_STATE_PRESSED); + feed(100, 100, LV_INDEV_STATE_RELEASED); + assert(events.size() == 1 && events[0].phase == GesturePhase::Pressed); + assert(!runtime.gesture_pressed && !runtime.gesture_dragging); + + // Real LVGL pointer dispatch, including repeated drags and moving outside + // the surface. No direct invocation of the RELEASED callback can hide the bug. + for (int cycle = 0; cycle < 100; ++cycle) + { + events.clear(); + feed(100, 100, LV_INDEV_STATE_PRESSED); + feed(120, 110, LV_INDEV_STATE_PRESSED); + feed(cycle % 2 ? 400 : 180, 140, LV_INDEV_STATE_PRESSED); + feed(cycle % 2 ? 400 : 180, 140, LV_INDEV_STATE_RELEASED); + unsigned ended = 0; + for (const auto& event : events) + ended += event.phase == GesturePhase::DragEnd || event.phase == GesturePhase::Cancel; + if (runtime.gesture_pressed || runtime.gesture_dragging || ended != 1) + { + std::fprintf(stderr, "release lost: cycle=%d pressed=%d dragging=%d ended=%u\n", + cycle, runtime.gesture_pressed, runtime.gesture_dragging, ended); + return 1; + } + } + lv_indev_delete(input); + lv_obj_delete(surface); + lv_display_delete(display); + lv_deinit(); + std::puts("real LVGL: tap and 100 drag/release cycles passed"); +} diff --git a/tests/map_tile_pipeline/CMakeLists.txt b/tests/map_tile_pipeline/CMakeLists.txt new file mode 100644 index 00000000..169486eb --- /dev/null +++ b/tests/map_tile_pipeline/CMakeLists.txt @@ -0,0 +1,62 @@ +cmake_minimum_required(VERSION 3.20) +project(map_tile_pipeline LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(REPO "${CMAKE_CURRENT_LIST_DIR}/../..") +enable_testing() +add_executable(map_tile_poi_retry test_poi_retry.cpp + ${REPO}/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/map_tile_resolver.cpp) +target_include_directories(map_tile_poi_retry PRIVATE + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/ui_presentation/include) +add_test(NAME map_tile_poi_retry COMMAND map_tile_poi_retry) +add_executable(map_tile_metrics test_pipeline_metrics.cpp) +target_include_directories(map_tile_metrics PRIVATE + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/core_sys/include) +add_test(NAME map_tile_metrics COMMAND map_tile_metrics) +add_executable(map_tile_file_source + ${REPO}/modules/ui_map_runtime/tests/test_filesystem_map_tile_source.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/filesystem_map_tile_source.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/map_tile_resolver.cpp) +target_include_directories(map_tile_file_source PRIVATE ${REPO}/modules/ui_map_runtime/include) +add_test(NAME map_tile_file_source COMMAND map_tile_file_source) +add_executable(map_tile_completion test_completion_queue.cpp) +target_include_directories(map_tile_completion PRIVATE ${REPO}/modules/ui_map_runtime/include) +add_test(NAME map_tile_completion COMMAND map_tile_completion) +add_executable(map_tile_requests test_request_queue.cpp) +target_include_directories(map_tile_requests PRIVATE + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/core_sys/include) +add_test(NAME map_tile_requests COMMAND map_tile_requests) +add_executable(map_tile_async + ${REPO}/modules/ui_map_runtime/tests/test_map_tile_async_runtime.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/map_tile_render_queue.cpp) +target_include_directories(map_tile_async PRIVATE + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/core_sys/include) +add_test(NAME map_tile_async COMMAND map_tile_async) +add_executable(map_tile_esp_completion test_esp_completion.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/map_tile_render_queue.cpp) +target_include_directories(map_tile_esp_completion PRIVATE stubs + ${REPO}/platform/esp/arduino_common/include + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/core_sys/include) +find_package(Threads REQUIRED) +target_link_libraries(map_tile_esp_completion PRIVATE Threads::Threads) +add_test(NAME map_tile_esp_completion COMMAND map_tile_esp_completion) +foreach(mode on off) + add_executable(map_tile_diagnostics_${mode} test_diagnostics.cpp + ${REPO}/platform/esp/arduino_common/src/platform_ui_map_diagnostics.cpp) + target_include_directories(map_tile_diagnostics_${mode} PRIVATE stubs + ${REPO}/modules/core_sys/include ${REPO}/modules/ui_shared/include) + if(mode STREQUAL "on") + target_compile_definitions(map_tile_diagnostics_${mode} PRIVATE TRAIL_MATE_MAP_DIAGNOSTICS=1) + endif() + add_test(NAME map_tile_diagnostics_${mode} COMMAND map_tile_diagnostics_${mode}) +endforeach() +foreach(target map_tile_completion map_tile_requests map_tile_async map_tile_esp_completion map_tile_metrics map_tile_file_source map_tile_poi_retry map_tile_diagnostics_on map_tile_diagnostics_off) + set_tests_properties(${target} PROPERTIES TIMEOUT 30) + if(MSVC) + target_compile_options(${target} PRIVATE /UNDEBUG) + else() + target_compile_options(${target} PRIVATE -UNDEBUG -Wall -Wextra) + endif() +endforeach() diff --git a/tests/map_tile_pipeline/stubs/Arduino.h b/tests/map_tile_pipeline/stubs/Arduino.h new file mode 100644 index 00000000..cd13f010 --- /dev/null +++ b/tests/map_tile_pipeline/stubs/Arduino.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +struct DiagnosticSerialStub +{ + std::string output; + std::size_t write(const uint8_t* data, std::size_t size) + { + output.append(reinterpret_cast(data), size); + return size; + } +}; +inline DiagnosticSerialStub Serial; diff --git a/tests/map_tile_pipeline/stubs/freertos/FreeRTOS.h b/tests/map_tile_pipeline/stubs/freertos/FreeRTOS.h new file mode 100644 index 00000000..970f12ba --- /dev/null +++ b/tests/map_tile_pipeline/stubs/freertos/FreeRTOS.h @@ -0,0 +1,8 @@ +#pragma once +#include +using TickType_t = uint32_t; +using BaseType_t = int; +constexpr BaseType_t pdTRUE = 1; +constexpr BaseType_t pdFALSE = 0; +constexpr TickType_t portMAX_DELAY = UINT32_MAX; +#define pdMS_TO_TICKS(ms) (ms) diff --git a/tests/map_tile_pipeline/stubs/freertos/semphr.h b/tests/map_tile_pipeline/stubs/freertos/semphr.h new file mode 100644 index 00000000..73637b0b --- /dev/null +++ b/tests/map_tile_pipeline/stubs/freertos/semphr.h @@ -0,0 +1,61 @@ +#pragma once +#include "FreeRTOS.h" +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +using SemaphoreHandle_t = HANDLE; +inline SemaphoreHandle_t xSemaphoreCreateMutex() { return CreateSemaphoreW(nullptr, 1, 1, nullptr); } +inline SemaphoreHandle_t xSemaphoreCreateBinary() { return CreateSemaphoreW(nullptr, 0, 1, nullptr); } +inline void vSemaphoreDelete(SemaphoreHandle_t semaphore) { CloseHandle(semaphore); } +inline BaseType_t xSemaphoreTake(SemaphoreHandle_t semaphore, TickType_t timeout) +{ + return WaitForSingleObject(semaphore, timeout == portMAX_DELAY ? INFINITE : timeout) == WAIT_OBJECT_0 ? pdTRUE : pdFALSE; +} +inline BaseType_t xSemaphoreGive(SemaphoreHandle_t semaphore) +{ + return ReleaseSemaphore(semaphore, 1, nullptr) ? pdTRUE : pdFALSE; +} +#else +#include +#include +#include + +struct TestSemaphore +{ + std::mutex mutex; + std::condition_variable changed; + bool available = false; +}; +using SemaphoreHandle_t = TestSemaphore*; +inline SemaphoreHandle_t xSemaphoreCreateMutex() +{ + auto* value = new TestSemaphore; + value->available = true; + return value; +} +inline SemaphoreHandle_t xSemaphoreCreateBinary() { return new TestSemaphore; } +inline void vSemaphoreDelete(SemaphoreHandle_t semaphore) { delete semaphore; } +inline BaseType_t xSemaphoreTake(SemaphoreHandle_t semaphore, TickType_t timeout) +{ + std::unique_lock lock(semaphore->mutex); + if (timeout == portMAX_DELAY) + semaphore->changed.wait(lock, [&] + { return semaphore->available; }); + else if (!semaphore->changed.wait_for(lock, std::chrono::milliseconds(timeout), [&] + { return semaphore->available; })) + return pdFALSE; + semaphore->available = false; + return pdTRUE; +} +inline BaseType_t xSemaphoreGive(SemaphoreHandle_t semaphore) +{ + { + std::lock_guard lock(semaphore->mutex); + semaphore->available = true; + } + semaphore->changed.notify_one(); + return pdTRUE; +} +#endif diff --git a/tests/map_tile_pipeline/test_completion_queue.cpp b/tests/map_tile_pipeline/test_completion_queue.cpp new file mode 100644 index 00000000..73e6fb84 --- /dev/null +++ b/tests/map_tile_pipeline/test_completion_queue.cpp @@ -0,0 +1,61 @@ +#include "ui_map_runtime/map_tiles/map_tile_completion_queue.h" +#include +#include + +using Queue = ui::map_tiles::MapTileCompletionQueue, 2>; + +int main() +{ + Queue queue; + auto first = queue.reserve(1); + auto second = queue.reserve(1); + assert(first && second && queue.occupied() == 2); + assert(!queue.reserve(1)); + assert(queue.highWater() == 2 && queue.backpressureCount() == 1); + // Reverse completion order must not expose an unfinished reservation. + assert(queue.commit(second, std::make_unique(2))); + std::unique_ptr out; + assert(queue.pop(out) && *out == 2); + assert(queue.commit(first, std::make_unique(1))); + assert(queue.pop(out) && *out == 1); + assert(queue.occupied() == 0); + + auto reserved = queue.reserve(2); + auto ready = queue.reserve(2); + assert(queue.commit(ready, std::make_unique(3))); + assert(queue.cancelGeneration(2) == 2); + assert(!queue.active(reserved)); + assert(!queue.pop(out)); + // Cancellation must not recycle memory still owned by the producer. + assert(!queue.reserve(3)); + auto payload = std::make_unique(4); + assert(!queue.commit(reserved, std::move(payload)) && payload && *payload == 4); + assert(queue.release(reserved)); + assert(queue.takeDiscarded(out) && *out == 3); + assert(!queue.takeDiscarded(out)); + assert(queue.occupied() == 0); + + auto replacement = queue.reserve(3); + assert(replacement); + assert(!queue.release(reserved)); + assert(!queue.commit(reserved, std::move(payload)) && payload); + assert(queue.active(replacement)); + assert(queue.release(replacement)); + assert(!queue.release(replacement)); + + // Slow/paused consumers and fast producers stay bounded and lose nothing. + int delivered = 0; + for (int i = 0; i < 1000; ++i) + { + auto token = queue.reserve(7); + if (!token) + { + assert(queue.pop(out) && *out == delivered++); + token = queue.reserve(7); + } + assert(queue.commit(token, std::make_unique(i))); + assert(queue.occupied() <= 2); + } + while (queue.pop(out)) assert(*out == delivered++); + assert(delivered == 1000 && queue.occupied() == 0); +} diff --git a/tests/map_tile_pipeline/test_diagnostics.cpp b/tests/map_tile_pipeline/test_diagnostics.cpp new file mode 100644 index 00000000..3a3fa77e --- /dev/null +++ b/tests/map_tile_pipeline/test_diagnostics.cpp @@ -0,0 +1,23 @@ +#include "ui/widgets/map/map_diagnostics.h" +#include + +#if TRAIL_MATE_MAP_DIAGNOSTICS +#include +#include +#endif + +int main() +{ + int calls = 0; + MAP_DIAG("request=%d result=%s\n", ++calls, "ready"); +#if TRAIL_MATE_MAP_DIAGNOSTICS + assert(calls == 1); + assert(Serial.output == "request=1 result=ready\n"); + Serial.output.clear(); + const std::string long_record(1024, 'x'); + MAP_DIAG("%s", long_record.c_str()); + assert(Serial.output == long_record); +#else + assert(calls == 0); +#endif +} diff --git a/tests/map_tile_pipeline/test_esp_completion.cpp b/tests/map_tile_pipeline/test_esp_completion.cpp new file mode 100644 index 00000000..5be2dfa6 --- /dev/null +++ b/tests/map_tile_pipeline/test_esp_completion.cpp @@ -0,0 +1,266 @@ +#include "platform/esp/arduino_common/map_tiles/map_tile_command_queue.h" +#include "platform/esp/arduino_common/map_tiles/map_tile_event_queue.h" +#include +#include +#include +#include +#ifndef _WIN32 +#include +#endif + +using namespace ui::map_tiles; +using Queue = platform::esp::arduino_common::map_tiles::MapTileEventQueue; +static std::atomic live_payloads{0}; +static bool fail_allocation = false; +static Queue* allocation_lock_probe = nullptr; + +static MapTileAsyncEvent copy_event(const MapTileAsyncEvent& input) +{ + Queue::Statistics stats; + assert(!allocation_lock_probe || allocation_lock_probe->statistics(stats)); + auto owned = input; + if (input.payload.data) + { + if (fail_allocation) + { + owned.payload = {}; + owned.payload_size = 0; + owned.kind = MapTileAsyncEventKind::Failed; + owned.error = -12; + } + else + { + auto* bytes = new uint8_t[input.payload.size]; + std::memcpy(bytes, input.payload.data, input.payload.size); + owned.payload.data = bytes; + ++live_payloads; + } + } + return owned; +} + +static void release_event(MapTileAsyncEvent& event) +{ + Queue::Statistics stats; + assert(!allocation_lock_probe || allocation_lock_probe->statistics(stats)); + if (event.payload.data) + { + delete[] event.payload.data; + --live_payloads; + } + event = {}; +} + +class Backend : public IMapTileWorkerBackend +{ + public: + Queue* cancel_during_read = nullptr; + int reads = 0; + MapTileLookupResult lookup(const MapTileRef&) override { return {}; } + MapTileReadResult read(const MapTileRef&, uint8_t* data, std::size_t) override + { + ++reads; + data[0] = 42; + if (cancel_during_read) cancel_during_read->cancelGeneration(1); + return {MapTileReadStatus::Ready, 1, 0, MapTileFormat::Png}; + } +}; + +struct CancelContext +{ + Queue* queue; + uint32_t generation; +}; +struct WaitContext +{ + Queue* queue; + std::atomic waiting{false}; + uint32_t elapsed_ms = 0; +}; +static void wait_capacity(WaitContext& context) +{ + const auto start = std::chrono::steady_clock::now(); + context.waiting = true; + context.queue->waitForCapacity(pdMS_TO_TICKS(2000)); + context.elapsed_ms = static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count()); +} +#ifdef _WIN32 +static DWORD WINAPI capacity_wait_thread(void* data) +{ + wait_capacity(*static_cast(data)); + return 0; +} +static DWORD WINAPI cancel_thread(void* data) +{ + const auto& context = *static_cast(data); + context.queue->cancelGeneration(context.generation); + return 0; +} +#endif + +int main() +{ + Queue queue(copy_event, release_event); + allocation_lock_probe = &queue; + assert(queue.available()); + queue.activateGeneration(1); + Backend backend; + uint8_t scratch[8]{}; + MapTileWorker worker(backend, queue, scratch, sizeof(scratch)); + LoadTileCommand command; + command.runtime.generation = 1; + for (uint32_t id = 1; id <= 16; ++id) + { + command.runtime.command_id = id; + assert(worker.execute(command, 0) == MapTileExecutionStatus::Completed); + } + assert(live_payloads == 16 && backend.reads == 16); + assert(worker.execute(command, 0) == MapTileExecutionStatus::Backpressured); + assert(backend.reads == 16); // No read or payload allocation under pressure. + MapTileAsyncEvent event; + for (uint32_t id = 1; id <= 16; ++id) + { + assert(queue.pop(event) && event.command_id == id && event.payload.data[0] == 42); + release_event(event); + } + assert(live_payloads == 0); + + fail_allocation = true; + assert(worker.execute(command, 0) == MapTileExecutionStatus::Completed); + assert(queue.pop(event) && event.kind == MapTileAsyncEventKind::Failed && event.error == -12); + release_event(event); + fail_allocation = false; + + // Gesture maintenance must be able to remove an obsolete completion + // behind a valid ready head, without decoding or dropping the valid head. + command.runtime.command_id = 41; + assert(worker.execute(command, 10) == MapTileExecutionStatus::Completed); + command.runtime.command_id = 42; + assert(worker.execute(command, 10) == MapTileExecutionStatus::Completed); + assert(queue.popIf(event, [](const auto& value) + { return value.command_id == 42; })); + assert(event.command_id == 42); + release_event(event); + assert(queue.pop(event) && event.command_id == 41); + release_event(event); + assert(live_payloads == 0); + Queue::Statistics stats; + assert(queue.statistics(stats) && stats.occupied == 0 && stats.high_water == 16 && stats.backpressure == 1); + + backend.cancel_during_read = &queue; + assert(worker.execute(command, 0) == MapTileExecutionStatus::Cancelled); + assert(live_payloads == 0 && !queue.pop(event)); + assert(worker.execute(command, 0) == MapTileExecutionStatus::Cancelled); + backend.cancel_during_read = nullptr; + + // Exercise real producer/canceller interleavings, not only a fake sink. + // A try-lock cannot distinguish another thread's legitimate ownership. + allocation_lock_probe = nullptr; + for (uint32_t generation = 2; generation < 102; ++generation) + { + command.runtime.generation = generation; + queue.activateGeneration(generation); + assert(queue.reserve(command) == MapTileReservationStatus::Reserved); + MapTileAsyncEvent ready; + ready.generation = generation; + ready.kind = MapTileAsyncEventKind::Ready; + ready.payload = {command.tile, MapTileFormat::Png, scratch, 1}; + CancelContext context{&queue, generation}; +#ifdef _WIN32 + HANDLE cancel = CreateThread(nullptr, 0, cancel_thread, &context, 0, nullptr); + assert(cancel); +#else + std::thread cancel([&] + { context.queue->cancelGeneration(context.generation); }); +#endif + (void)queue.publish(ready); + queue.releaseReservation(); +#ifdef _WIN32 + assert(WaitForSingleObject(cancel, INFINITE) == WAIT_OBJECT_0); + CloseHandle(cancel); +#else + cancel.join(); +#endif + assert(!queue.pop(event) && live_payloads == 0); + } + allocation_lock_probe = &queue; + queue.activateGeneration(103); + command.runtime.generation = 103; + assert(worker.execute(command, 0) == MapTileExecutionStatus::Completed); + queue.clear(); + assert(live_payloads == 0); + + // Drive the production command adapter, runtime, worker and completion + // adapter together. Dedupe covers queued, running and completed-but-not- + // consumed work, and old completions cannot retire replacement requests. + platform::esp::arduino_common::map_tiles::MapTileCommandQueue commands; + MapTileAsyncRuntime runtime(commands); + queue.activateGeneration(200); + MapTileRef tile{MapTileLayer::Osm, 12, 100, 200}; + const auto first = runtime.requestTile(tile, 200, MapTileInteractionMode::Idle, 10); + assert(first.status == TileSubmitStatus::Accepted); + auto duplicate = runtime.requestTile(tile, 200, MapTileInteractionMode::Idle, 11); + assert(duplicate.status == TileSubmitStatus::AlreadyPending); + assert(duplicate.handle.matches(first.handle.generation, first.handle.command_id)); + assert(commands.pop(12, command)); + assert(worker.execute(command, 12) == MapTileExecutionStatus::Completed); + duplicate = runtime.requestTile(tile, 200, MapTileInteractionMode::Idle, 13); + assert(duplicate.status == TileSubmitStatus::AlreadyPending); + assert(!commands.pop(13, command)); + assert(queue.pop(event)); + assert(event.command_wait_ms == 2 && event.worker_started_ms == 12); + commands.complete({event.generation, event.command_id}); + release_event(event); + const auto replacement = runtime.requestTile(tile, 200, MapTileInteractionMode::Idle, 14); + assert(replacement.status == TileSubmitStatus::Accepted); + assert(replacement.handle.command_id != first.handle.command_id); + assert(commands.pop(15, command)); + commands.complete(first.handle); + duplicate = runtime.requestTile(tile, 200, MapTileInteractionMode::Idle, 16); + assert(duplicate.status == TileSubmitStatus::AlreadyPending); + assert(duplicate.handle.command_id == replacement.handle.command_id); + assert(queue.reserve(command) == MapTileReservationStatus::Reserved); + queue.cancelGeneration(200); + assert(runtime.cancelGeneration(200) == 1); + queue.activateGeneration(201); + const auto next = runtime.requestTile(tile, 201, MapTileInteractionMode::Idle, 17); + assert(next.status == TileSubmitStatus::Accepted); + MapTileAsyncEvent late; + late.generation = 200; + late.command_id = replacement.handle.command_id; + late.kind = MapTileAsyncEventKind::Ready; + late.payload = {tile, MapTileFormat::Png, scratch, 1}; + assert(!queue.publish(late)); + queue.releaseReservation(); + commands.complete(replacement.handle); + assert(commands.pop(18, command) && command.runtime.generation == 201); + assert(worker.execute(command, 18) == MapTileExecutionStatus::Completed); + queue.cancelGeneration(201); // close viewport with a ready result + assert(runtime.cancelGeneration(201) == 1); + assert(!commands.pop(19, command) && !queue.pop(event) && live_payloads == 0); + allocation_lock_probe = nullptr; + // Closing the last viewport must wake a worker blocked on capacity, not + // leave it waiting until a UI consumer (which no longer exists) pops data. + queue.activateGeneration(300); + queue.waitForCapacity(0); // consume the activation notification + WaitContext waiting{&queue}; +#ifdef _WIN32 + HANDLE waiter = CreateThread(nullptr, 0, capacity_wait_thread, &waiting, 0, nullptr); + assert(waiter); + while (!waiting.waiting) SwitchToThread(); +#else + std::thread waiter([&] + { wait_capacity(waiting); }); + while (!waiting.waiting) std::this_thread::yield(); +#endif + queue.cancelGeneration(300); +#ifdef _WIN32 + assert(WaitForSingleObject(waiter, 3000) == WAIT_OBJECT_0); + CloseHandle(waiter); +#else + waiter.join(); +#endif + assert(waiting.elapsed_ms < 1000); +} diff --git a/tests/map_tile_pipeline/test_pipeline_metrics.cpp b/tests/map_tile_pipeline/test_pipeline_metrics.cpp new file mode 100644 index 00000000..31190c94 --- /dev/null +++ b/tests/map_tile_pipeline/test_pipeline_metrics.cpp @@ -0,0 +1,42 @@ +#include "ui_map_runtime/map_tiles/map_tile_pipeline_metrics.h" +#include +#include + +using namespace ui::map_tiles; +int main() +{ + MapTilePipelineMetrics metrics; + MapTileAsyncEvent event; + event.kind = MapTileAsyncEventKind::Ready; + event.timing_available = true; + event.command_wait_ms = 17; + event.worker_started_ms = UINT32_MAX - 4; + event.published_ms = 3; // wraparound is a normal monotonic-clock event + event.read_timing = {2, 3, 4, true}; + event.read_timing.block_available = true; + event.read_timing.block_calls = 3; + event.read_timing.block_sectors = 19; + event.read_timing.block_max_sectors = 8; + event.read_timing.block_us = 1234; + metrics.consumed(event, 13); + assert(metrics.block_samples == 1 && metrics.block_calls == 3 && metrics.block_sectors == 19); + assert(metrics.block_max_sectors == 8 && metrics.block_us == 1234); + assert(metrics.stages[MapTilePipelineMetrics::Worker].average() == 8); + assert(metrics.stages[MapTilePipelineMetrics::ResultWait].average() == 10); + assert(metrics.stages[MapTilePipelineMetrics::CommandWait].average() == 17); + assert(metrics.stages[MapTilePipelineMetrics::FileOpen].average() == 3); + event.read_timing = {}; // cache-only result must not dilute measured file times + event.timing_available = false; + event.kind = MapTileAsyncEventKind::RetryLater; + metrics.consumed(event, 20); + assert(metrics.consumed_count == 2 && metrics.ready_count == 1 && metrics.retry_count == 1); + assert(metrics.stages[MapTilePipelineMetrics::FileOpen].count == 1); + assert(metrics.stages[MapTilePipelineMetrics::Worker].count == 1); + assert(metrics.block_samples == 1); + auto& decode = metrics.stages[MapTilePipelineMetrics::Decode]; + decode.add(UINT32_MAX); + decode.add(UINT32_MAX); + assert(decode.average() == UINT32_MAX && decode.max_ms == UINT32_MAX); + metrics = {}; + assert(metrics.consumed_count == 0 && metrics.stages[MapTilePipelineMetrics::Decode].count == 0); +} diff --git a/tests/map_tile_pipeline/test_poi_retry.cpp b/tests/map_tile_pipeline/test_poi_retry.cpp new file mode 100644 index 00000000..bdbe6a28 --- /dev/null +++ b/tests/map_tile_pipeline/test_poi_retry.cpp @@ -0,0 +1,66 @@ +#include "ui_map_runtime/map_poi/poi_tile_source.h" +#include +#include +#include + +using namespace ui::map_tiles; +using namespace ui::map_poi; +struct Files : IMapTileFileSystem +{ + MapTileReadStatus manifest_status = MapTileReadStatus::Error; + MapTileReadStatus index_status = MapTileReadStatus::Missing; + mutable unsigned manifest_reads = 0, index_reads = 0; + bool exists(const char*) const override { return true; } + bool isDirectory(const char*) const override { return true; } + MapTileReadResult readFile(const char* path, uint8_t* buffer, std::size_t) const override + { + const bool manifest = std::strstr(path, "manifest.json") != nullptr; + if (manifest) ++manifest_reads; + else ++index_reads; + const auto status = manifest ? manifest_status : index_status; + buffer[0] = 'x'; + return {status, status == MapTileReadStatus::Ready ? 1U : 0U, status == MapTileReadStatus::Ready ? 0 : -6}; + } +}; +struct TestParser : Parser +{ + bool manifest(const char*, std::size_t, Policy& policy) const override + { + policy.enabled_levels = 1U << 16; + return true; + } + bool record(const char*, std::size_t, Record&) const override { return true; } +}; +int main() +{ + std::vector scratch(192 * 1024 / sizeof(uint64_t)); + auto* bytes = reinterpret_cast(scratch.data()); + const MapTileRef tile{MapTileLayer::Poi, 16, 1, 2}; + TestParser parser; + for (auto transient : {MapTileReadStatus::Error, MapTileReadStatus::RetryLater}) + { + Files files; + files.manifest_status = transient; + PoiTileSource source(files, parser); + assert(source.read(tile, bytes, scratch.size() * 8).status == transient); + assert(files.manifest_reads == 1 && files.index_reads == 0); + files.manifest_status = MapTileReadStatus::Ready; + auto recovered = source.read(tile, bytes, scratch.size() * 8); + assert(recovered.status == MapTileReadStatus::Ready && validPayload(bytes, recovered.size)); + assert(reinterpret_cast(bytes)->manifest_valid); + assert(files.manifest_reads == 2 && files.index_reads == 1); + files.index_status = transient; + assert(source.read(tile, bytes, scratch.size() * 8).status == transient); + files.index_status = MapTileReadStatus::Ready; + recovered = source.read(tile, bytes, scratch.size() * 8); + assert(recovered.status == MapTileReadStatus::Ready && reinterpret_cast(bytes)->count == 1); + assert(files.manifest_reads == 2); // recovered policy stays cached + } + Files absent; + absent.manifest_status = MapTileReadStatus::Missing; + PoiTileSource source(absent, parser); + assert(source.read(tile, bytes, scratch.size() * 8).status == MapTileReadStatus::Ready); + assert(!reinterpret_cast(bytes)->manifest_valid); + assert(source.read(tile, bytes, scratch.size() * 8).status == MapTileReadStatus::Ready); + assert(absent.manifest_reads == 1 && absent.index_reads == 0); +} diff --git a/tests/map_tile_pipeline/test_request_queue.cpp b/tests/map_tile_pipeline/test_request_queue.cpp new file mode 100644 index 00000000..1ca67533 --- /dev/null +++ b/tests/map_tile_pipeline/test_request_queue.cpp @@ -0,0 +1,55 @@ +#include "ui_map_runtime/map_tiles/map_tile_request_queue.h" +#include + +using namespace ui::map_tiles; + +int main() +{ + MapTileRequestQueue<2> queue; + std::size_t queued = 99, in_flight = 99; + queue.statistics(queued, in_flight); + assert(queued == 0 && in_flight == 0); + LoadTileCommand first; + first.runtime.generation = 1; + first.runtime.command_id = 1; + first.runtime.created_at_ms = 10; + first.tile.x = 10; + const auto accepted = queue.enqueue(first); + assert(accepted.status == TileSubmitStatus::Accepted); + queue.statistics(queued, in_flight); + assert(queued == 1 && in_flight == 0); + auto duplicate = first; + duplicate.runtime.command_id = 2; + assert(queue.enqueue(duplicate).handle.command_id == 1); + LoadTileCommand running; + assert(queue.pop(11, running) && running.runtime.command_id == 1); + queue.statistics(queued, in_flight); + assert(queued == 0 && in_flight == 1); + assert(!queue.pop(11, running)); + assert(queue.enqueue(duplicate).status == TileSubmitStatus::AlreadyPending); + + auto second = first; + second.tile.layer = MapTileLayer::Poi; + second.runtime.command_id = 3; + assert(queue.enqueue(second).status == TileSubmitStatus::Accepted); + queue.statistics(queued, in_flight); + assert(queued == 1 && in_flight == 1); + auto third = first; + third.tile.x = 11; + third.runtime.command_id = 4; + assert(queue.enqueue(third).status == TileSubmitStatus::Backpressured); + assert(!queue.complete({1, 2})); // Duplicate submission did not replace identity. + assert(queue.complete(accepted.handle)); + assert(queue.enqueue(third).status == TileSubmitStatus::Accepted); + assert(queue.cancelGeneration(1) == 2); + queue.statistics(queued, in_flight); + assert(queued == 0 && in_flight == 0); + assert(!queue.pop(20, running)); + + first.runtime.generation = 2; + assert(queue.enqueue(first)); + assert(queue.pop(21, running)); + assert(!queue.complete({1, 1})); // A late old-generation result cannot retire new work. + assert(queue.complete({2, 1})); + assert(!queue.complete({2, 1})); +} diff --git a/tests/map_tile_pipeline/test_ui_contract.py b/tests/map_tile_pipeline/test_ui_contract.py new file mode 100644 index 00000000..2303a02e --- /dev/null +++ b/tests/map_tile_pipeline/test_ui_contract.py @@ -0,0 +1,62 @@ +"""Structural guards for the real UI integration; complements runtime C++ tests.""" +from pathlib import Path +import re +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +def function_body(source: str, signature: str) -> str: + start = source.index("{", source.index(signature)) + depth = 1 + end = start + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start + 1:end - 1] + + +class UiContract(unittest.TestCase): + def test_diagnostics_use_platform_contract(self): + source = (ROOT / "modules/ui_shared/include/ui/widgets/map/map_diagnostics.h").read_text(encoding="utf-8") + self.assertIn('"platform/ui/map_diagnostics.h"', source) + self.assertNotRegex(source, r"Arduino\.h|\bARDUINO\b|\bESP_PLATFORM\b|Serial\.|std::printf") + + def setUp(self): + self.tiles = (ROOT / "platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp").read_text(encoding="utf-8") + + def test_gesture_still_services_maintenance(self): + source = (ROOT / "modules/ui_shared/src/ui/widgets/map/map_viewport.cpp").read_text(encoding="utf-8") + body = function_body(source, "void loader_timer_cb(") + self.assertLess(body.index("tile_loader_maintenance"), body.index("tile_loader_step")) + # Diagnostic reads of gesture state do not pause work; check the actual gate. + self.assertLess(body.index("tile_loader_maintenance"), body.index("if (impl->gesture_pressed")) + + def test_maintenance_never_starts_io_decode_or_lvgl_updates(self): + body = function_body(self.tiles, "void tile_loader_maintenance(") + self.assertIn("popEventIf", body) + self.assertIn("current_tile_request", body) + self.assertIn("kMapTileUiDrainBudgetMs", body) + self.assertIn("release_tile_payload", body) + self.assertNotRegex(body, r"\b(?:lv_\w+|sd_\w+|decode_\w+|render_\w+)\s*\(") + self.assertNotIn(".request(", body) + self.assertNotIn("reportMetrics", body) + + def test_map_pipeline_has_no_transport_or_board_bypass(self): + paths = list((ROOT / "modules/ui_map_runtime/include/ui_map_runtime/map_tiles").glob("*.h")) + paths += list((ROOT / "platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles").glob("*.h")) + paths += [ROOT / "platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp"] + for path in paths: + source = path.read_text(encoding="utf-8") + # Comments may explain the boundary; executable configuration may not. + source = re.sub(r"//[^\n]*|/\*.*?\*/", "", source, flags=re.S) + self.assertNotRegex(source, r"\b(?:TRAIL_MATE_SDFAT_SDMMC|TRAIL_MATE_SDFAT_SHARED_SPI|WIO_TRACKER_L2)\b", str(path)) + self.assertNotRegex(source, r"\bsdmmc_\w+\s*\(", str(path)) + + def test_shutdown_rejects_new_work_before_started_check(self): + body = function_body(self.tiles, "bool ensureStarted(") + self.assertLess(body.index("if (stopping)"), body.index("if (started)")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sd_transport/CMakeLists.txt b/tests/sd_transport/CMakeLists.txt new file mode 100644 index 00000000..4ef8f7cd --- /dev/null +++ b/tests/sd_transport/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.16) +project(sd_transport_tests LANGUAGES CXX) +enable_testing() +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +get_filename_component(REPO "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +set(STORAGE "${REPO}/platform/esp/arduino_common") +add_executable(sdmmc_transfer_test + test_sdmmc_transfer.cpp + "${STORAGE}/src/storage/sdmmc_block_device.cpp") +target_include_directories(sdmmc_transfer_test PRIVATE + "${REPO}/third_party/sdfat/src" stubs "${STORAGE}/include") +target_compile_definitions(sdmmc_transfer_test PRIVATE TRAIL_MATE_SDFAT_SDMMC=1 + ENABLE_ARDUINO_FEATURES=0 ENABLE_ARDUINO_SERIAL=0 ENABLE_ARDUINO_STRING=0) +add_test(NAME sdmmc_transfer COMMAND sdmmc_transfer_test) +foreach(transport IN ITEMS SHARED_SPI SDMMC) + add_executable(sd_policy_${transport} test_sd_policy.cpp) + target_include_directories(sd_policy_${transport} PRIVATE + "${STORAGE}/include" "${REPO}/modules/core_sys/include") + target_compile_definitions(sd_policy_${transport} PRIVATE TRAIL_MATE_SDFAT_${transport}=1) + add_test(NAME sd_policy_${transport} COMMAND sd_policy_${transport}) +endforeach() + +# Exercise the real FAT/exFAT implementation and the production SDMMC block +# device together, replacing only the hardware with a sparse in-memory card. +file(GLOB SDFAT_SOURCES + "${REPO}/third_party/sdfat/src/common/*.cpp" + "${REPO}/third_party/sdfat/src/FatLib/*.cpp" + "${REPO}/third_party/sdfat/src/ExFatLib/*.cpp" + "${REPO}/third_party/sdfat/src/FsLib/*.cpp") +add_executable(sd_filesystem_test test_sd_filesystem.cpp ${SDFAT_SOURCES} + "${STORAGE}/src/storage/sdmmc_block_device.cpp") +target_include_directories(sd_filesystem_test PRIVATE + "${REPO}/third_party/sdfat/src" stubs "${STORAGE}/include") +target_compile_definitions(sd_filesystem_test PRIVATE + TRAIL_MATE_SDFAT_SDMMC=1 USE_BLOCK_DEVICE_INTERFACE=1 SDFAT_FILE_TYPE=3 + ENABLE_ARDUINO_FEATURES=0 ENABLE_ARDUINO_SERIAL=0 ENABLE_ARDUINO_STRING=0) +target_compile_options(sd_filesystem_test PRIVATE + -include "${CMAKE_CURRENT_SOURCE_DIR}/stubs/host_clock.h") +add_test(NAME sd_filesystem COMMAND sd_filesystem_test) diff --git a/tests/sd_transport/stubs/driver/sdmmc_host.h b/tests/sd_transport/stubs/driver/sdmmc_host.h new file mode 100644 index 00000000..c271e1f3 --- /dev/null +++ b/tests/sd_transport/stubs/driver/sdmmc_host.h @@ -0,0 +1,19 @@ +#pragma once +#include +using gpio_num_t = int; +constexpr int SDMMC_HOST_FLAG_1BIT = 1; +constexpr int SDMMC_HOST_FLAG_4BIT = 4; +constexpr int SDMMC_SLOT_FLAG_INTERNAL_PULLUP = 1; +struct sdmmc_slot_config_t +{ + int width = 1; + int clk = -1, cmd = -1, d0 = -1, d1 = -1, d2 = -1, d3 = -1; + int flags = 0; +}; +#define SDMMC_HOST_DEFAULT() \ + sdmmc_host_t {} +#define SDMMC_SLOT_CONFIG_DEFAULT() \ + sdmmc_slot_config_t {} +esp_err_t sdmmc_host_init(); +esp_err_t sdmmc_host_init_slot(int, const sdmmc_slot_config_t*); +esp_err_t sdmmc_host_deinit(); diff --git a/tests/sd_transport/stubs/esp_heap_caps.h b/tests/sd_transport/stubs/esp_heap_caps.h new file mode 100644 index 00000000..975f193f --- /dev/null +++ b/tests/sd_transport/stubs/esp_heap_caps.h @@ -0,0 +1,6 @@ +#pragma once +#include +constexpr int MALLOC_CAP_INTERNAL = 1; +constexpr int MALLOC_CAP_DMA = 2; +void* heap_caps_malloc(size_t, unsigned); +void heap_caps_free(void*); diff --git a/tests/sd_transport/stubs/esp_log.h b/tests/sd_transport/stubs/esp_log.h new file mode 100644 index 00000000..bcad59c4 --- /dev/null +++ b/tests/sd_transport/stubs/esp_log.h @@ -0,0 +1,2 @@ +#pragma once +#define ESP_LOGW(...) ((void)0) diff --git a/tests/sd_transport/stubs/esp_timer.h b/tests/sd_transport/stubs/esp_timer.h new file mode 100644 index 00000000..0d624123 --- /dev/null +++ b/tests/sd_transport/stubs/esp_timer.h @@ -0,0 +1,9 @@ +#pragma once +#include +// Deterministic hardware-call timing for the production driver's host tests. +inline int64_t esp_timer_get_time() +{ + static int64_t ticks = 0; + ticks += 10; + return ticks; +} diff --git a/tests/sd_transport/stubs/host_clock.h b/tests/sd_transport/stubs/host_clock.h new file mode 100644 index 00000000..31cccad6 --- /dev/null +++ b/tests/sd_transport/stubs/host_clock.h @@ -0,0 +1,3 @@ +#pragma once +#include +inline uint32_t millis() { return 1234; } diff --git a/tests/sd_transport/stubs/sdmmc_cmd.h b/tests/sd_transport/stubs/sdmmc_cmd.h new file mode 100644 index 00000000..7a064c83 --- /dev/null +++ b/tests/sd_transport/stubs/sdmmc_cmd.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include +using esp_err_t = int; +constexpr esp_err_t ESP_OK = 0; +constexpr esp_err_t ESP_ERR_INVALID_ARG = 0x102; +struct sdmmc_host_t +{ + int flags = 0; + int slot = 1; + int max_freq_khz = 20000; +}; +struct sdmmc_card_t +{ + struct + { + size_t sector_size = 512; + uint32_t capacity = 256; + } csd; + uint32_t ocr = 0; +}; +esp_err_t sdmmc_card_init(const sdmmc_host_t*, sdmmc_card_t*); +esp_err_t sdmmc_read_sectors(sdmmc_card_t*, void*, size_t, size_t); +esp_err_t sdmmc_write_sectors(sdmmc_card_t*, const void*, size_t, size_t); +inline const char* esp_err_to_name(esp_err_t) { return "injected error"; } diff --git a/tests/sd_transport/stubs/soc/soc_memory_types.h b/tests/sd_transport/stubs/soc/soc_memory_types.h new file mode 100644 index 00000000..3bd3e6a8 --- /dev/null +++ b/tests/sd_transport/stubs/soc/soc_memory_types.h @@ -0,0 +1,2 @@ +#pragma once +bool esp_ptr_dma_capable(const void*); diff --git a/tests/sd_transport/test_build_configuration.py b/tests/sd_transport/test_build_configuration.py new file mode 100644 index 00000000..dd578e68 --- /dev/null +++ b/tests/sd_transport/test_build_configuration.py @@ -0,0 +1,68 @@ +"""Exercise the dependency transformation without importing SCons or building.""" + +import ast +import os +from pathlib import Path +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +def load_audio_hook(project, environment): + source = ast.parse((ROOT / "scripts/platformio-pre.py").read_text(encoding="utf-8")) + function = next(node for node in source.body + if isinstance(node, ast.FunctionDef) + and node.name == "configure_explicit_audio_driver_instances") + namespace = {"os": os, "project_dir": str(project), "pio_env": environment, + "is_esp32_env": True} + exec(compile(ast.Module(body=[function], type_ignores=[]), "audio-hook", "exec"), namespace) + return namespace[function.name] + + +class AudioConfigurationTest(unittest.TestCase): + def test_explicit_instances_are_opt_in_and_idempotent(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + library = root / ".pio/libdeps/wio_tracker_l2/audio-driver" + (library / "src").mkdir(parents=True) + (library / "library.properties").write_text("version=0.3.1\n", encoding="utf-8") + header = library / "src/AudioDriver.h" + original = ("namespace audio_driver {\nclass AudioDriverES8311Class {};\n" + "// -- Drivers\nstatic AudioDriverES8311Class AudioDriverES8311;\n" + "} // namespace audio_driver\n") + header.write_text(original, encoding="utf-8") + hook = load_audio_hook(root, "wio_tracker_l2") + hook() + patched = header.read_text(encoding="utf-8") + self.assertIn("#ifndef TRAIL_MATE_AUDIO_DRIVER_EXPLICIT_INSTANCES\n", patched) + self.assertIn("class AudioDriverES8311Class {};\n// -- Drivers", patched) + self.assertIn("#endif\n\n} // namespace audio_driver", patched) + hook() + self.assertEqual(header.read_text(encoding="utf-8"), patched) + + def test_shared_spi_environments_do_not_touch_dependency(self): + with tempfile.TemporaryDirectory() as temporary: + # No audio-driver dependency exists: these environments must return + # before attempting any file lookup or mutation. + for environment in ("tdeck", "tdeck_debug", "tlora_pager_sx1262", + "tlora_pager_lr1121", "tlora_pager_sx1262_debug"): + load_audio_hook(Path(temporary), environment)() + self.assertEqual(list(Path(temporary).iterdir()), []) + + def test_unknown_dependency_fails_without_mutation(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + library = root / ".pio/libdeps/wio_tracker_l2/audio-driver" + (library / "src").mkdir(parents=True) + (library / "library.properties").write_text("version=0.4.0\n", encoding="utf-8") + header = library / "src/AudioDriver.h" + header.write_text("unexpected upstream source", encoding="utf-8") + with self.assertRaises(RuntimeError): + load_audio_hook(root, "wio_tracker_l2")() + self.assertEqual(header.read_text(encoding="utf-8"), "unexpected upstream source") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sd_transport/test_sd_filesystem.cpp b/tests/sd_transport/test_sd_filesystem.cpp new file mode 100644 index 00000000..3975dd3b --- /dev/null +++ b/tests/sd_transport/test_sd_filesystem.cpp @@ -0,0 +1,219 @@ +#include "platform/esp/arduino_common/storage/sd_file_probe.h" +#include "platform/esp/arduino_common/storage/sd_transfer_policy.h" +#include "platform/esp/arduino_common/storage/sdmmc_block_device.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace platform::esp::arduino_common::storage; +#define CHECK(x) \ + do \ + { \ + if (!(x)) \ + { \ + std::cerr << __LINE__ << ": " #x << '\n'; \ + std::abort(); \ + } \ + } while (0) + +namespace +{ +uint32_t capacity = 0; +std::unordered_map> sectors; +size_t largest_read = 0, largest_write = 0, read_calls = 0; +bool fail_reads = false; +} // namespace + +bool esp_ptr_dma_capable(const void*) { return false; } // PSRAM-like caller buffers +void* heap_caps_malloc(size_t bytes, unsigned) { return std::malloc(bytes); } +void heap_caps_free(void* ptr) { std::free(ptr); } +esp_err_t sdmmc_host_init() { return ESP_OK; } +esp_err_t sdmmc_host_init_slot(int, const sdmmc_slot_config_t*) { return ESP_OK; } +esp_err_t sdmmc_host_deinit() { return ESP_OK; } +esp_err_t sdmmc_card_init(const sdmmc_host_t*, sdmmc_card_t* card) +{ + card->csd.capacity = capacity; + card->csd.sector_size = 512; + return ESP_OK; +} +esp_err_t sdmmc_read_sectors(sdmmc_card_t*, void* out, size_t sector, size_t count) +{ + if (fail_reads) return -91; + CHECK(sector <= capacity && count <= capacity - sector); + largest_read = std::max(largest_read, count); + ++read_calls; + auto* dst = static_cast(out); + for (size_t i = 0; i < count; ++i) + { + const auto found = sectors.find(static_cast(sector + i)); + if (found == sectors.end()) std::memset(dst + i * 512, 0, 512); + else std::memcpy(dst + i * 512, found->second.data(), 512); + } + return ESP_OK; +} +esp_err_t sdmmc_write_sectors(sdmmc_card_t*, const void* input, size_t sector, size_t count) +{ + CHECK(sector <= capacity && count <= capacity - sector); + largest_write = std::max(largest_write, count); + const auto* src = static_cast(input); + for (size_t i = 0; i < count; ++i) + { + const auto key = static_cast(sector + i); + const auto* block = src + i * 512; + if (std::all_of(block, block + 512, [](uint8_t byte) + { return byte == 0; })) sectors.erase(key); + else std::memcpy(sectors[key].data(), block, 512); + } + return ESP_OK; +} + +void round_trip(uint8_t fat_type, uint32_t card_sectors) +{ + sectors.clear(); + capacity = card_sectors; + ArduinoSdmmcBlockDevice device; + CHECK(device.begin(2, 3, 1)); + std::array scratch{}; + if (fat_type == 64) + { + ExFatFormatter formatter; + CHECK(formatter.format(&device, scratch.data())); + } + else + { + FatFormatter formatter; + CHECK(formatter.format(&device, scratch.data())); + } + FsVolume volume; + CHECK(volume.begin(&device)); + CHECK(volume.fatType() == fat_type); + std::vector expected(70 * 1024 + 37); + for (size_t i = 0; i < expected.size(); ++i) expected[i] = static_cast((i * 13 + i / 512) % 251); + auto file = volume.open("batch.bin", O_CREAT | O_RDWR | O_TRUNC); + CHECK(file); + auto neighbor = volume.open("neighbor.bin", O_CREAT | O_RDWR | O_TRUNC); + CHECK(neighbor); + largest_write = 0; + for (size_t offset = 0; offset < expected.size();) + { + const size_t bytes = std::min(SdmmcSdTransferPolicy::file_slice_bytes, expected.size() - offset); + CHECK(file.write(expected.data() + offset, bytes) == bytes); + // Interleave allocations so reads also cross noncontiguous extents. + CHECK(neighbor.write(scratch.data(), scratch.size()) == scratch.size()); + offset += bytes; + } + CHECK(file.sync()); + CHECK(largest_write > 1); + CHECK(file.close()); + CHECK(neighbor.close()); + volume.end(); + + // Remount to exclude file/volume cache as the explanation for correctness. + CHECK(volume.begin(&device)); + size_t old_calls = 0; + for (const size_t slice : {SharedSpiSdTransferPolicy::file_slice_bytes, SdmmcSdTransferPolicy::file_slice_bytes}) + { + file = volume.open("batch.bin", O_RDONLY); + CHECK(file && file.fileSize() == expected.size()); + std::vector result(expected.size() + 2, 0xA5); + largest_read = read_calls = 0; + for (size_t offset = 0; offset < expected.size();) + { + const size_t bytes = std::min(slice, expected.size() - offset); + CHECK(file.read(result.data() + 1 + offset, bytes) == static_cast(bytes)); + offset += bytes; + } + CHECK(result.front() == 0xA5 && result.back() == 0xA5); + CHECK(std::memcmp(result.data() + 1, expected.data(), expected.size()) == 0); + if (slice == 512) + { + CHECK(largest_read == 1); + old_calls = read_calls; + } + else + { + CHECK(largest_read > 1 && largest_read <= 8); + CHECK(read_calls < old_calls); + std::cout << "FAT type " << static_cast(fat_type) << ": " << old_calls + << " -> " << read_calls << " hardware reads; max sectors=" << largest_read << '\n'; + } + for (const uint32_t offset : {1U, 511U, 8191U, 16383U, 65535U}) + { + CHECK(file.seekSet(offset)); + std::array partial{}; + CHECK(file.read(partial.data(), partial.size()) == static_cast(partial.size())); + CHECK(std::memcmp(partial.data(), expected.data() + offset, partial.size()) == 0); + } + CHECK(file.close()); + } + SdPathProbeScratch probe; + CHECK(probe_sd_path(volume, "/batch.bin", probe) == SdPathEvidence::Present); + CHECK(probe_sd_path(volume, "/BATCH.BIN", probe) == SdPathEvidence::Present); + CHECK(probe_sd_path(volume, "/absent.bin", probe) == SdPathEvidence::Absent); + CHECK(probe_sd_path(volume, "/absent/child.png", probe) == SdPathEvidence::Absent); + CHECK(probe_sd_path(volume, "/batch.bin/child.png", probe) == SdPathEvidence::Uncertain); + CHECK(probe_sd_path(volume, "../batch.bin", probe) == SdPathEvidence::Uncertain); + CHECK(probe_sd_path(volume, "/../batch.bin", probe) == SdPathEvidence::Uncertain); + CHECK(volume.mkdir("/maps/base", true)); + file = volume.open("/maps/base/long tile name.png", O_CREAT | O_WRONLY); + CHECK(file && file.close()); + CHECK(probe_sd_path(volume, "/maps/base/long tile name.png", probe) == SdPathEvidence::Present); + CHECK(probe_sd_path(volume, "/maps/base/not there.png", probe) == SdPathEvidence::Absent); + CHECK(!probe.directory && !probe.entry); + + if (fat_type != 64) + { + // A corrupt LFN checksum makes openNext return false without setting + // the directory's read-error flag. It is not proof of directory EOF. + file = volume.open("/maps/base", O_RDONLY); + CHECK(file); + const auto directory_sector = file.firstSector(); + CHECK(file.close()); + volume.end(); + auto& directory_bytes = sectors[directory_sector]; + std::size_t checksum_offset = 512; + for (std::size_t offset = 0; offset < 512; offset += 32) + { + if (directory_bytes[offset + 11] == 0x0F) + { + checksum_offset = offset + 13; + break; + } + } + CHECK(checksum_offset < 512); + const auto checksum = directory_bytes[checksum_offset]; + directory_bytes[checksum_offset] ^= 1; + CHECK(volume.begin(&device)); + CHECK(probe_sd_path(volume, "/maps/base/not there.png", probe) == SdPathEvidence::Uncertain); + volume.end(); + sectors[directory_sector][checksum_offset] = checksum; + CHECK(volume.begin(&device)); + } + + volume.end(); + CHECK(volume.begin(&device)); + fail_reads = true; + CHECK(probe_sd_path(volume, "/maps/base/not there.png", probe) == SdPathEvidence::Uncertain); + CHECK(device.ioError() == -91); + fail_reads = false; + CHECK(!probe.directory && !probe.entry); + volume.end(); + CHECK(volume.begin(&device)); + device.clearIoError(); + CHECK(probe_sd_path(volume, "/maps/base/not there.png", probe) == SdPathEvidence::Absent); + CHECK(device.ioError() == ESP_OK); + volume.end(); +} + +int main() +{ + round_trip(16, 65536); + round_trip(32, 8 * 1024 * 1024); + round_trip(64, 1024 * 1024); +} diff --git a/tests/sd_transport/test_sd_policy.cpp b/tests/sd_transport/test_sd_policy.cpp new file mode 100644 index 00000000..2bfb156b --- /dev/null +++ b/tests/sd_transport/test_sd_policy.cpp @@ -0,0 +1,48 @@ +#include "platform/esp/arduino_common/storage/sd_operation_scope.h" +#include "platform/esp/arduino_common/storage/sd_transfer_policy.h" +#include +#include +#include + +using namespace platform::esp::arduino_common::storage; +using namespace sys::runtime; +#define CHECK(x) \ + do \ + { \ + if (!(x)) \ + { \ + std::cerr << "failed: " #x << '\n'; \ + std::abort(); \ + } \ + } while (0) + +int main() +{ +#if defined(TRAIL_MATE_SDFAT_SHARED_SPI) + static_assert(ActiveSdTransferPolicy::file_slice_bytes == 512); +#else + static_assert(ActiveSdTransferPolicy::file_slice_bytes == 16384); +#endif + auto& profile = SharedSpiSdOperationScope::profile(); + CHECK(!profile.active); + CHECK(profile.wait_ms == 25); + SharedSpiSdOperationScope outer; + outer.enter("read", BusAccessPolicy::BackgroundWorkerBounded, 200); + CHECK(profile.active && profile.wait_ms == 200); + profile.last_bus_status = BusAcquireStatus::TimedOut; + SharedSpiSdOperationScope inner; + inner.enter("write", BusAccessPolicy::DurableCommit, 250); + CHECK(profile.policy == BusAccessPolicy::DurableCommit); + CHECK(inner.status() == BusAcquireStatus::Acquired); + inner.leave(); + CHECK(outer.status() == BusAcquireStatus::TimedOut); + CHECK(profile.wait_ms == 200 && std::strcmp(profile.owner, "read") == 0); + SdmmcSdOperationScope sdmmc; + sdmmc.enter("sdmmc", BusAccessPolicy::DurableCommit, 500); + CHECK(sdmmc.status() == BusAcquireStatus::Acquired); + sdmmc.leave(); + CHECK(outer.status() == BusAcquireStatus::TimedOut); + outer.leave(); + CHECK(!profile.active && profile.wait_ms == 25); + std::cout << "policy selection and nested SPI context passed\n"; +} diff --git a/tests/sd_transport/test_sdmmc_transfer.cpp b/tests/sd_transport/test_sdmmc_transfer.cpp new file mode 100644 index 00000000..27421e6a --- /dev/null +++ b/tests/sd_transport/test_sdmmc_transfer.cpp @@ -0,0 +1,214 @@ +#include "platform/esp/arduino_common/storage/sdmmc_block_device.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace platform::esp::arduino_common::storage; +#define CHECK(x) \ + do \ + { \ + if (!(x)) \ + { \ + std::cerr << __LINE__ << ": " #x << '\n'; \ + std::abort(); \ + } \ + } while (0) + +namespace +{ +std::array disk{}; +std::vector batches; +void* allocated = nullptr; +size_t allocated_size = 0; +uintptr_t direct_start = 0, direct_end = 0; +bool fail_alloc = false; +int fail_stage = 0, fail_transfer = 0, deinits = 0; +sdmmc_host_t seen_host; +sdmmc_slot_config_t seen_slot; +bool in_range(uintptr_t p, uintptr_t start, size_t bytes) +{ + return start != 0 && p >= start && p - start < bytes; +} +} // namespace + +bool esp_ptr_dma_capable(const void* ptr) +{ + const auto p = reinterpret_cast(ptr); + return in_range(p, reinterpret_cast(allocated), allocated_size) || + (direct_start != 0 && p >= direct_start && p < direct_end); +} +void* heap_caps_malloc(size_t bytes, unsigned caps) +{ + CHECK(caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA)); + CHECK(allocated == nullptr); + if (fail_alloc) return nullptr; + allocated = std::malloc(bytes); + allocated_size = bytes; + return allocated; +} +void heap_caps_free(void* ptr) +{ + if (!ptr) return; + CHECK(ptr == allocated); + std::free(ptr); + allocated = nullptr; + allocated_size = 0; +} +esp_err_t sdmmc_host_init() { return fail_stage == 1 ? -1 : ESP_OK; } +esp_err_t sdmmc_host_init_slot(int slot, const sdmmc_slot_config_t* config) +{ + CHECK(slot == 0 || slot == 1); + seen_slot = *config; + return fail_stage == 2 ? -1 : ESP_OK; +} +esp_err_t sdmmc_host_deinit() +{ + ++deinits; + return ESP_OK; +} +esp_err_t sdmmc_card_init(const sdmmc_host_t* host, sdmmc_card_t* card) +{ + seen_host = *host; + card->csd.sector_size = fail_stage == 4 ? 1024 : 512; + card->csd.capacity = 256; + return fail_stage == 3 ? -1 : ESP_OK; +} +esp_err_t sdmmc_read_sectors(sdmmc_card_t*, void* dst, size_t sector, size_t count) +{ + CHECK(sector + count <= 256 && count > 0); + CHECK(esp_ptr_dma_capable(dst)); + CHECK(reinterpret_cast(dst) % 4 == 0); + batches.push_back(count); + if (fail_transfer == static_cast(batches.size())) return -1; + std::memcpy(dst, disk.data() + sector * 512, count * 512); + return ESP_OK; +} +esp_err_t sdmmc_write_sectors(sdmmc_card_t*, const void* src, size_t sector, size_t count) +{ + CHECK(sector + count <= 256 && count > 0); + CHECK(esp_ptr_dma_capable(src)); + CHECK(reinterpret_cast(src) % 4 == 0); + batches.push_back(count); + if (fail_transfer == static_cast(batches.size())) return -1; + std::memcpy(disk.data() + sector * 512, src, count * 512); + return ESP_OK; +} + +int main() +{ + ArduinoSdmmcBlockDevice device; + CHECK(!device.begin(-1, 3, 1)); + fail_alloc = true; + CHECK(!device.begin(2, 3, 1)); + fail_alloc = false; + for (int stage = 1; stage <= 4; ++stage) + { + fail_stage = stage; + const int before = deinits; + CHECK(!device.begin(2, 3, 1)); + CHECK(allocated == nullptr); + CHECK(deinits == before + (stage == 1 ? 0 : 1)); + } + fail_stage = 0; + CHECK(device.begin(2, 3, 1)); + CHECK(seen_host.flags == SDMMC_HOST_FLAG_1BIT && seen_host.max_freq_khz == 20000); + CHECK(seen_slot.width == 1 && seen_slot.clk == 2 && seen_slot.cmd == 3 && seen_slot.d0 == 1); + CHECK(allocated_size == 4096); + for (size_t i = 0; i < disk.size(); ++i) disk[i] = static_cast((i * 17 + i / 512) % 251); + std::vector buffer(65 * 512 + 8, 0); + uint8_t* unaligned = buffer.data() + 1; + device.resetReadMetrics(); + CHECK(device.readSectors(3, unaligned, 19)); + CHECK(device.readMetrics().calls == 3 && device.readMetrics().sectors == 19); + CHECK(device.readMetrics().max_sectors == 8 && device.readMetrics().elapsed_us == 30); + CHECK((batches == std::vector{8, 8, 3})); + CHECK(std::memcmp(unaligned, disk.data() + 3 * 512, 19 * 512) == 0); + batches.clear(); + CHECK(device.writeSectors(40, unaligned, 19)); + CHECK((batches == std::vector{8, 8, 3})); + CHECK(std::memcmp(unaligned, disk.data() + 40 * 512, 19 * 512) == 0); + CHECK(device.readMetrics().calls == 3); // writes are not counted as reads + device.resetReadMetrics(); + CHECK(device.readMetrics().calls == 0 && device.readMetrics().elapsed_us == 0); + + auto* aligned = buffer.data(); + CHECK(reinterpret_cast(aligned) % 4 == 0); + direct_start = reinterpret_cast(aligned); + direct_end = direct_start + buffer.size(); + batches.clear(); + CHECK(device.readSectors(0, aligned, 65)); + CHECK((batches == std::vector{32, 32, 1})); + CHECK(std::memcmp(aligned, disk.data(), 65 * 512) == 0); + batches.clear(); + CHECK(device.writeSectors(100, aligned, 65)); + CHECK((batches == std::vector{32, 32, 1})); + CHECK(std::memcmp(aligned, disk.data() + 100 * 512, 65 * 512) == 0); + // Merely DMA-capable at the start is insufficient for the whole range. + direct_end = direct_start + 512; + batches.clear(); + CHECK(device.readSectors(0, aligned, 9)); + CHECK((batches == std::vector{8, 1})); + direct_start = direct_end = 0; + batches.clear(); + CHECK(device.readSectors(256, nullptr, 0)); + CHECK(!device.readSector(256, aligned)); + CHECK(!device.readSectors(255, aligned, 2)); + CHECK(!device.writeSectors(0, nullptr, 1)); + CHECK(!device.writeSectors(0, aligned, std::numeric_limits::max())); + CHECK(batches.empty()); + CHECK(device.readSector(255, aligned)); + CHECK((batches == std::vector{1})); + + std::fill(buffer.begin(), buffer.end(), 0xEE); + batches.clear(); + fail_transfer = 2; + device.resetReadMetrics(); + device.clearIoError(); + CHECK(device.ioError() == ESP_OK); + CHECK(!device.readSectors(0, unaligned, 19)); + const auto read_error = device.ioError(); + CHECK(read_error != ESP_OK); + CHECK(device.readMetrics().calls == 2 && device.readMetrics().sectors == 16); + CHECK(device.readMetrics().elapsed_us == 20); // failed physical calls are included + CHECK((batches == std::vector{8, 8})); + CHECK(unaligned[8 * 512] == 0xEE); // failed batch must not be copied out + batches.clear(); + CHECK(!device.writeSectors(80, unaligned, 19)); + CHECK((batches == std::vector{8, 8})); // no retry after partial write + fail_transfer = 0; + CHECK(device.readSector(0, aligned)); + CHECK(device.ioError() == read_error); // success cannot erase earlier failure + device.clearIoError(); + CHECK(device.ioError() == ESP_OK); + CHECK(device.readSector(0, aligned)); + CHECK(device.ioError() == ESP_OK); + device.end(); + CHECK(allocated == nullptr && !device.readSector(0, aligned)); + const int before = deinits; + device.end(); + CHECK(deinits == before); + + SdmmcSdConfig config; + config.clock = 2; + config.command = 3; + config.data0 = 1; + config.width = 4; + CHECK(!device.begin(config)); + config.data1 = 4; + config.data2 = 5; + config.data3 = 6; + config.slot = 0; + config.max_frequency_khz = 40000; + CHECK(device.begin(config)); + CHECK(seen_host.slot == 0 && seen_host.flags == SDMMC_HOST_FLAG_4BIT); + CHECK(seen_slot.width == 4 && seen_slot.d3 == 6); + device.end(); + CHECK(allocated == nullptr); + std::cout << "SDMMC batching, DMA, data integrity, bounds and lifecycle passed\n"; +} diff --git a/tests/test_firmware_version.py b/tests/test_firmware_version.py new file mode 100644 index 00000000..29009edc --- /dev/null +++ b/tests/test_firmware_version.py @@ -0,0 +1,51 @@ +"""Run the production version helpers without importing the SCons build script.""" + +import ast +import os +from pathlib import Path +import re +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +HELPERS = { + "read_text_best_effort", "extract_version_from_changelog", + "extract_version_from_ci_tag", "resolve_project_version", +} +source = ast.parse((ROOT / "scripts/platformio-pre.py").read_text(encoding="utf-8")) +namespace = {"os": os, "re": re, "project_dir": str(ROOT)} +functions = [node for node in source.body + if isinstance(node, ast.FunctionDef) and node.name in HELPERS] +assert len(functions) == len(HELPERS) +exec(compile(ast.Module(body=functions, type_ignores=[]), "version-helpers", "exec"), namespace) + + +class FirmwareVersionTest(unittest.TestCase): + def test_local_build_uses_changelog(self): + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(namespace["resolve_project_version"](), + namespace["extract_version_from_changelog"]()) + + def test_branch_and_pull_request_names_are_not_versions(self): + for ref, name in (("refs/heads/perf/wio-l2-sdmmc-ram", "perf/wio-l2-sdmmc-ram"), + ("refs/pull/123/merge", "123/merge")): + with patch.dict(os.environ, {"GITHUB_REF": ref, "GITHUB_REF_NAME": name, + "GITHUB_REF_TYPE": "branch"}, clear=True): + self.assertIsNone(namespace["extract_version_from_ci_tag"]()) + self.assertEqual(namespace["resolve_project_version"](), + namespace["extract_version_from_changelog"]()) + + def test_tag_overrides_changelog(self): + with patch.dict(os.environ, {"GITHUB_REF": "refs/tags/v9.8.7-alpha", + "GITHUB_REF_NAME": "v9.8.7-alpha"}, clear=True): + self.assertEqual(namespace["resolve_project_version"](), "9.8.7-alpha") + + def test_explicit_tag_type_supports_ref_name(self): + with patch.dict(os.environ, {"GITHUB_REF_TYPE": "tag", + "GITHUB_REF_NAME": "v9.8.7-alpha"}, clear=True): + self.assertEqual(namespace["resolve_project_version"](), "9.8.7-alpha") + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/wio_tracker_l2/pins_arduino.h b/variants/wio_tracker_l2/pins_arduino.h new file mode 100644 index 00000000..48859ebc --- /dev/null +++ b/variants/wio_tracker_l2/pins_arduino.h @@ -0,0 +1,59 @@ +#pragma once + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// WIO's user LED is on the I/O expander (P12), not a CPU GPIO or NeoPixel. +// Do not define LED_BUILTIN/RGB_BUILTIN: the generic ESP32-S3 variant's +// GPIO48 NeoPixel alias is incorrect here (GPIO48 is the I2C clock), and +// causes digitalWrite() to retain the otherwise unused RGB/RMT drivers. + +// Defaults mirror boards/wio_tracker_l2/board_profile.h. Keep this header +// usable by Arduino's C GPIO implementation as well as C++ library code. +static const uint8_t TX = 17; +static const uint8_t RX = 18; +static const uint8_t SDA = 47; +static const uint8_t SCL = 48; +static const uint8_t SS = 21; +static const uint8_t MOSI = 6; +static const uint8_t MISO = 5; +static const uint8_t SCK = 4; + +// ESP32-S3 analog/touch aliases retained from the generic variant. Actual +// board peripheral ownership still comes from the board profile. +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14;