diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index 17eddc64..455d6a04 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -327,6 +327,39 @@ if(BUILD_TESTING) add_test(NAME trailmate_map_overlay_snapshot_source_smoke COMMAND trailmate_map_overlay_snapshot_source_smoke) + add_executable(trailmate_runtime_async_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/tests/test_runtime_async.cpp") + target_include_directories(trailmate_runtime_async_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_runtime_async_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_runtime_async_smoke + COMMAND trailmate_runtime_async_smoke) + + add_executable(trailmate_storage_event_runtime_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/tests/test_storage_event_runtime.cpp") + target_include_directories(trailmate_storage_event_runtime_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_storage_event_runtime_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_storage_event_runtime_smoke + COMMAND trailmate_storage_event_runtime_smoke) + + add_executable(trailmate_map_tile_async_runtime_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_map_runtime/tests/test_map_tile_async_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_map_runtime/src/map_tiles/map_tile_render_queue.cpp") + target_include_directories(trailmate_map_tile_async_runtime_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_map_runtime/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_map_tile_async_runtime_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_map_tile_async_runtime_smoke + COMMAND trailmate_map_tile_async_runtime_smoke) + add_executable(trailmate_runtime_map_presentation_adapters_smoke "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_runtime_map_presentation_adapters.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/presentation_sources/runtime_map_workspace_source.cpp" diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index 783e8f97..1f6df62b 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -174,6 +174,7 @@ set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES "${TRAILMATE_ROOT}/modules/ui_map_runtime/src/map_overlay_snapshot_source.cpp" "${TRAILMATE_ROOT}/modules/ui_map_runtime/src/map_overlay/map_overlay_projector.cpp" "${TRAILMATE_ROOT}/modules/ui_map_runtime/src/map_tiles/filesystem_map_tile_source.cpp" + "${TRAILMATE_ROOT}/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp" "${TRAILMATE_ROOT}/modules/ui_map_runtime/src/map_tiles/map_tile_render_queue.cpp" "${TRAILMATE_ROOT}/modules/ui_map_runtime/src/map_tiles/map_tile_resolver.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/app_catalog_builder.cpp" diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index a6e43e9c..63940abf 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -343,6 +343,7 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_MAP_RUNTIME_SRC_ROOT}/map_overlay_snapshot_source.cpp" "${TRAIL_MATE_UI_MAP_RUNTIME_SRC_ROOT}/map_overlay/map_overlay_projector.cpp" "${TRAIL_MATE_UI_MAP_RUNTIME_SRC_ROOT}/map_tiles/filesystem_map_tile_source.cpp" + "${TRAIL_MATE_UI_MAP_RUNTIME_SRC_ROOT}/map_tiles/map_tile_async_runtime.cpp" "${TRAIL_MATE_UI_MAP_RUNTIME_SRC_ROOT}/map_tiles/map_tile_render_queue.cpp" "${TRAIL_MATE_UI_MAP_RUNTIME_SRC_ROOT}/map_tiles/map_tile_resolver.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/team_presentation/team_rich_payload_projector.cpp" diff --git a/docs/specification/GPS_RUNTIME_SCHEDULING_SPEC.md b/docs/specification/GPS_RUNTIME_SCHEDULING_SPEC.md index 823d5858..1009a641 100644 --- a/docs/specification/GPS_RUNTIME_SCHEDULING_SPEC.md +++ b/docs/specification/GPS_RUNTIME_SCHEDULING_SPEC.md @@ -18,6 +18,7 @@ It does not own: - hardware GPS adapters - route/tracker stores - map tile loading +- SD writes or track file flushes ## UI Refresh Port @@ -65,6 +66,22 @@ When set inactive: Page runtime may keep LVGL timers as tick hooks. The timer callback must call the pump; the callback must not encode refresh cadence policy itself. +## Track Recording Boundary + +GPS parsing and GPS page refresh are not track storage execution contexts. A GPS +task may publish normalized points or submit append intents, but it must not +open, write, flush, list, or close track files directly. + +Track recording must follow `UI_STORAGE_EVENT_RUNTIME_DESIGN_SPEC.md`: + +```text +GPS point -> TrackRuntime intent -> TrackStorageWorker command + -> storage/bus adapter -> TrackRuntime event -> UI projection +``` + +The GPS page may render `Starting`, `Recording`, `Flushing`, `Stopping`, or +`Error` state. It must not block waiting for a storage transaction to finish. + ## Non-goals - No GPS hardware adapter rewrite. diff --git a/docs/specification/MAP_TILE_RENDER_QUEUE_CACHE_SPEC.md b/docs/specification/MAP_TILE_RENDER_QUEUE_CACHE_SPEC.md index 73b746f1..bec34bef 100644 --- a/docs/specification/MAP_TILE_RENDER_QUEUE_CACHE_SPEC.md +++ b/docs/specification/MAP_TILE_RENDER_QUEUE_CACHE_SPEC.md @@ -10,6 +10,11 @@ Render queue owns the current visible tile plan. Renderer draws widgets from the visible plan. It must not own tile path policy. +The render queue is a UI-facing projection of tile state. It is not the owner of +slow tile work. File lookup, SD access, shared-SPI arbitration, image decode, +and cache fill must run through the command/worker/event design described in +`UI_STORAGE_EVENT_RUNTIME_DESIGN_SPEC.md`. + ## Objects | Object | Pattern | Responsibility | Forbidden | @@ -34,6 +39,10 @@ It is C++11-compatible and does not allocate. Platform map tile runtimes may populate the queue from existing `MapTile` records during this burn-down phase. +When asynchronous tile workers are introduced, render queue updates must carry a +viewport generation. Results for stale generations must not mutate the current +renderer state. + ## Decoded Cache Contract `IMapTileDecoderCache` exposes only portable cache operations: diff --git a/docs/specification/MAP_TILE_SOURCE_CACHE_RUNTIME_SPEC.md b/docs/specification/MAP_TILE_SOURCE_CACHE_RUNTIME_SPEC.md index 4ed4a422..22bddb3c 100644 --- a/docs/specification/MAP_TILE_SOURCE_CACHE_RUNTIME_SPEC.md +++ b/docs/specification/MAP_TILE_SOURCE_CACHE_RUNTIME_SPEC.md @@ -10,6 +10,12 @@ contour data-source credentials. `MapWorkspaceSnapshot` carries viewport, layer, and tool state. It must not carry tile bitmap bytes, decoded image cache objects, or filesystem cache objects. +Tile source/cache work must also conform to +`UI_STORAGE_EVENT_RUNTIME_DESIGN_SPEC.md`: UI owner code submits tile intents, +workers perform filesystem/storage work, and tile results return as events. A +renderer must not open tile files, wait for shared-SPI, or decode tile payloads +from an input callback, LVGL timer, GTK callback, or page render callback. + ## Objects | Object | Pattern | Responsibility | Forbidden | diff --git a/docs/specification/RUNTIME_CONCURRENCY_SPEC.md b/docs/specification/RUNTIME_CONCURRENCY_SPEC.md index 91f607a7..ac7f0f2e 100644 --- a/docs/specification/RUNTIME_CONCURRENCY_SPEC.md +++ b/docs/specification/RUNTIME_CONCURRENCY_SPEC.md @@ -4,6 +4,13 @@ This specification defines the concurrency baseline for Trail Mate targets. It exists because ESP32, nRF52, Linux, and tests have different runtime mechanics but must preserve the same ownership model. +The detailed design patterns, event simulation requirements, and burn-down +guidance for UI/storage responsiveness are defined in +`UI_STORAGE_EVENT_RUNTIME_DESIGN_SPEC.md`. + +Active-path migrations that touch UI/storage/event responsiveness must satisfy +that document's UML coverage gate before implementation begins. + ## Sources of Concurrency Trail Mate targets may receive work from: @@ -43,6 +50,11 @@ Storage backends must declare their concurrency model. Mutable app-service state must have a single owner context. +The UI owner context must not wait for blocking storage, shared-SPI, +filesystem, decode, or persistence work. UI paths may submit commands, consume +ready events, or attempt explicitly non-blocking work that can be abandoned +within the frame budget. + Cross-thread and cross-task interaction must use one of: ```text @@ -80,6 +92,11 @@ radio_irq -> MeshSession gps_task -> lvgl gtk_worker -> GtkWidget ui_thread -> blocking storage write +ui_thread -> blocking shared-SPI wait +ui_thread -> filesystem open/read/write/list +ui_thread -> image decode from storage +ui_thread -> track file create/flush/list +ui_thread -> node/contact store synchronous save ui_renderer -> direct radio access ui_renderer -> direct GPS driver access platform_driver -> direct message policy @@ -148,6 +165,9 @@ transaction support async write support erase/write blocking behavior required owner context or mutex +UI-owner behavior +queue/backpressure behavior +diagnostic fields for slow waits ``` Examples: @@ -173,3 +193,44 @@ UI State -> UI context or presentation owner Other contexts may send commands, publish events, or consume snapshots. +## Slow Work Ownership + +Slow work must have an explicit owner. Page widgets, LVGL timers, GTK callbacks, +and input handlers are not valid owners for durable storage, filesystem walking, +tile decode, shared-SPI waits, protocol retries, or persistence flushes. + +Valid slow-work owners include: + +```text +command worker +storage worker +protocol runtime worker +map tile worker +track storage worker +persistence worker +declared platform service task +``` + +Slow-work owners communicate completion through events or immutable snapshots. +They must not mutate concrete UI objects directly. + +## Simulation Requirement + +Any runtime that introduces asynchronous command/event behavior must be +testable with deterministic simulated events. Tests must be able to script: + +```text +command enqueue +event publish +worker completion +timeout +cancellation +storage delay +storage failure +bus arbitration delay +UI event drain +``` + +The simulator must assert that UI owner code does not execute blocking +storage/shared-SPI/filesystem calls and that background code does not execute +concrete renderer calls. diff --git a/docs/specification/UI_FEEDBACK_RUNTIME_SPEC.md b/docs/specification/UI_FEEDBACK_RUNTIME_SPEC.md index e6540396..249c4f97 100644 --- a/docs/specification/UI_FEEDBACK_RUNTIME_SPEC.md +++ b/docs/specification/UI_FEEDBACK_RUNTIME_SPEC.md @@ -151,6 +151,21 @@ produce visible feedback. It then emits a stable feedback intent through The platform implementation of that port must post to the UI feedback runtime. It must not call `SystemNotification` directly. +## Simulation Requirement + +Feedback runtime behavior must be testable without concrete renderer widgets. +Tests must use a fake presenter/event drain to simulate: + +- producer posts feedback and immediately returns +- page is destroyed before the feedback is rendered +- chat send success or failure arrives while another page is active +- renderer presenter is unavailable or rejects a notice +- duplicate notices are deduplicated or queued according to policy + +These tests must assert that no feature/page/protocol code imports or calls +`SystemNotification` directly. `SystemNotification` remains only a platform +presenter implementation detail. + ## Compatibility Burn-Down The following are legacy coupling points and must not remain in active feature diff --git a/docs/specification/UI_STORAGE_EVENT_RUNTIME_DESIGN_SPEC.md b/docs/specification/UI_STORAGE_EVENT_RUNTIME_DESIGN_SPEC.md new file mode 100644 index 00000000..0de2bac9 --- /dev/null +++ b/docs/specification/UI_STORAGE_EVENT_RUNTIME_DESIGN_SPEC.md @@ -0,0 +1,1581 @@ +# UI / Storage / Event Runtime Design Spec + +Status date: 2026-06-14 + +This document defines the design baseline for removing UI stalls caused by +blocking storage, shared-SPI contention, synchronous persistence, and page-owned +runtime side effects. + +It complements `RUNTIME_CONCURRENCY_SPEC.md`. The concurrency spec states the +rules. This document explains the design that makes those rules implementable +and testable. + +## Problem Statement + +Trail Mate currently has several paths where renderer-owned code, runtime +services, and platform storage operations can enter the same slow resource path: + +- LVGL timers and page callbacks can trigger SD or shared-SPI access. +- Map tile loading can perform file lookup, image object setup, and decode work + from the UI owner context. +- GPS track recording can hold recorder state and shared-SPI access while it + opens, writes, flushes, and closes files. +- Node and contact persistence can be reached from event dispatch paths. +- Feedback notices, protocol completion, and UI mutation can be coupled to the + page that happened to start an operation. + +The failure mode is not only a crash. A target can keep GPS, radio, timers, and +sleep tasks alive while the UI owner task is blocked or starved. When that +happens, touch input, key input, page navigation, display refresh, and wake +handling appear frozen even though background logs continue. + +The design goal is therefore: + +```text +UI owner context never waits for storage, shared-SPI, filesystem, decode, +protocol completion, or persistence. +``` + +## Core Distinctions + +### UI Owner Context + +The UI owner context is the only context allowed to mutate concrete renderer +objects: + +- LVGL `lv_obj_*` +- GTK widgets +- terminal renderer output buffers +- target-specific display widgets + +It owns rendering and input response. It does not own storage transactions, +protocol business rules, or persistence durability. + +### Runtime Intent + +A runtime intent is a user or service request expressed without platform IO: + +- load these visible map tiles +- start a new track +- append this GPS point +- list available tracks +- save node store changes +- show transient feedback +- send this chat message + +Intent expresses what should happen. It does not say how long to wait for SD, +which SPI mutex to take, which LVGL file callback to invoke, or which hardware +adapter performs IO. + +### Command + +A command is the queued, executable form of an intent. Commands are the boundary +between caller responsiveness and slow work. A caller submits a command and +returns immediately. + +Commands must carry enough metadata for cancellation, deduplication, priority, +and diagnostics: + +```text +command_id +kind +created_at +origin +priority +generation +deadline +dedupe_key +cancel_policy +``` + +### Worker / Active Object + +A worker owns slow execution. It serializes or schedules commands and invokes +platform adapters. The worker is an Active Object: callers do not execute its +methods directly; they enqueue work. + +Examples: + +- `MapTileWorker` +- `TrackStorageWorker` +- `PersistenceWorker` +- `ProtocolRuntimeWorker` +- `FeedbackDispatchWorker` when a target needs one + +### Event + +An event is the only way slow work reports completion, failure, cancellation, or +state change back to interested runtimes and UI projections. + +Events must be safe to publish from non-UI contexts. Events that require UI +mutation must be consumed by the UI owner context before touching renderer +objects. + +### Platform Adapter + +A platform adapter wraps a technical API: + +- SD file open/read/write/flush +- SPI bus acquire/release +- LVGL image descriptor setup +- nRF flash write +- GTK idle invocation +- radio TX + +Adapters must not own business rules, retry policy, tile priority, feedback +eligibility, chat delivery semantics, or tracker state transitions. + +## Pattern Decision + +The design uses a small set of patterns with explicit responsibilities. + +| Pattern | Primary use | Why it is required | Must not become | +| --- | --- | --- | --- | +| Active Object | Storage, tile, track, persistence, protocol workers | Moves slow work behind queues so callers return immediately | A hidden synchronous service | +| Command | Runtime intents queued to workers | Separates request creation from execution and enables cancellation, priority, diagnostics | Wire-format payloads or platform calls | +| Observer / Event Bus | Completion, failure, state, feedback, UI effects | Reports asynchronous outcomes without coupling to the current page | Direct renderer mutation from background contexts | +| State | Track recording, tile loading, storage health, chat send lifecycle | Makes cross-time behavior explicit instead of encoded in locks or call stacks | Page-local boolean flags | +| Strategy | Bus access, tile loading, flush, persistence, retry policies | Keeps environment and mode differences explicit and swappable | Scattered `if target` branches | +| Bridge | Runtime rule side to platform execution side | Keeps shared business rules single-source while allowing ESP/nRF/Linux backends | Another adapter with business decisions | +| Adapter | Wrap platform APIs | Contains technical integration details | A second business implementation | +| Mediator / Arbiter | Shared resource scheduling | Replaces ad-hoc lock competition with observable scheduling | A new god object for product behavior | +| Unit of Work | Track flush, node/contact save, config persistence | Batches durable writes and reduces SD/SPI transaction count | An unbounded memory buffer | +| Circuit Breaker / Backpressure | Slow or failing SD/SPI/storage | Degrades features without freezing UI | Silent data loss | +| Visitor / Effect Apply | Optional UI-effect application in owner context | Centralizes renderer-side effect application | Business decision engine | + +The primary axis is: + +```text +Command -> Active Object -> Event -> State +``` + +`Strategy`, `Bridge`, and `Adapter` prevent the primary axis from drifting back +into target-specific duplicate implementations. `Mediator/Arbiter`, +`Unit of Work`, and `Backpressure` keep the system responsive on real hardware. + +## Skeleton-First Implementation Protocol + +Implementation must start with architecture skeletons before behavior is moved. +This is a hard rule for this refactor because the bug class is caused by weak +boundaries, not by one missing timeout. + +The first code slice for a runtime area must add: + +- value objects for intents, commands, events, state, priority, generation, and + diagnostics +- interfaces for queues, workers, bus/storage arbitration, platform adapters, + event sinks, and UI-effect application +- fake implementations for clock, command queue, event bus, storage backend, + bus arbiter, worker completion, and feedback presenter +- deterministic tests that simulate at least one success path, one timeout, one + cancellation, and one stale completion +- explicit legacy containment comments or adapters for the old implementation + that will be burned down in the same migration slice + +No active-path migration may begin until the UML coverage gate below is +complete for that runtime area. The UML is a design artifact, not a decorative +diagram. It must be detailed enough to identify the business owner, runtime +owner, worker owner, platform adapter, event path, state transitions, testing +fakes, and legacy deletion condition. + +The skeleton may return placeholder results, but it must compile and expose the +final ownership shape. Active product paths must not be rewired to a skeleton +until the relevant behavior and simulation tests are present. + +After the skeleton exists, behavior moves one vertical slice at a time: + +```text +write skeleton +add simulation test +move one runtime flow +delete or deactivate old direct path +verify no active dual implementation remains +repeat +``` + +At no point may an active feature keep two business-rule owners. A temporary +adapter may bridge old data into the new runtime, but the adapter must not +decide policy. + +## UML Design Skeleton + +The code skeleton must preserve the following ownership shape. Names may be +adapted to local module style, but any rename must keep the same roles and +relationships. + +```mermaid +classDiagram + class RuntimeFacade { + +submit(intent) + +tick(now_ms) + +drainEvents() + } + + class RuntimeIntent { + <> + +kind + +origin + } + + class RuntimeCommand { + <> + +command_id + +kind + +priority + +generation + +deadline_ms + +dedupe_key + +cancel_policy + } + + class RuntimeEvent { + <> + +event_id + +kind + +command_id + +timestamp_ms + } + + class RuntimeState { + <> + +status + +last_error + } + + class ICommandQueue { + <> + +enqueue(command) + +cancel(dedupe_key) + +popReady(now_ms) + } + + class IActiveWorker { + <> + +tick(now_ms) + +submit(command) + } + + class IEventSink { + <> + +publish(event) + } + + class IBusArbiter { + <> + +acquire(policy, command_id) + +release(token) + } + + class IPlatformStorageAdapter { + <> + +read(request) + +write(request) + +list(request) + +flush(handle) + } + + class IUiEffectSink { + <> + +apply(effect) + } + + class RuntimePolicyStrategy { + <> + +selectPriority(intent) + +selectBusPolicy(command) + +selectRetry(command, result) + } + + RuntimeFacade --> RuntimeIntent + RuntimeFacade --> RuntimeCommand + RuntimeFacade o-- ICommandQueue + RuntimeFacade o-- IEventSink + RuntimeFacade o-- RuntimeState + RuntimeFacade o-- RuntimePolicyStrategy + IActiveWorker --> RuntimeCommand + IActiveWorker --> RuntimeEvent + IActiveWorker o-- IBusArbiter + IActiveWorker o-- IPlatformStorageAdapter + IEventSink --> RuntimeEvent + IUiEffectSink --> RuntimeEvent +``` + +The important dependency direction is from stable runtime abstractions toward +ports. Platform adapters implement ports. Pages and widgets depend on facades or +presentation actions, not on adapters, workers, or concrete storage APIs. + +## UML Component Boundary + +```mermaid +flowchart TB + subgraph Presentation["Presentation / UI Owner"] + Page["Page / Widget"] + UiDrain["UI Event Drain"] + Renderer["LVGL / GTK / Mono Renderer"] + end + + subgraph Runtime["Shared Runtime"] + Facade["Runtime Facade"] + State["State Machine"] + Policies["Strategies"] + Commands["Command Queue"] + Events["Event Bus"] + end + + subgraph Workers["Active Objects"] + TileWorker["MapTileWorker"] + TrackWorker["TrackStorageWorker"] + PersistWorker["PersistenceWorker"] + end + + subgraph Platform["Platform Adapters"] + Bus["Bus / Storage Arbiter"] + Storage["Storage Adapter"] + Decode["Decode Adapter"] + end + + Page --> Facade + Facade --> Commands + Facade --> State + Facade --> Policies + Commands --> TileWorker + Commands --> TrackWorker + Commands --> PersistWorker + TileWorker --> Bus + TrackWorker --> Bus + PersistWorker --> Bus + Bus --> Storage + TileWorker --> Decode + TileWorker --> Events + TrackWorker --> Events + PersistWorker --> Events + Events --> State + Events --> UiDrain + UiDrain --> Renderer +``` + +Forbidden reverse dependencies: + +- platform adapter to page/widget +- worker to concrete renderer +- page/widget to storage adapter +- page/widget to shared-SPI lock +- adapter to product policy +- UI event drain to blocking worker execution + +## UML Coverage Gate + +Before coding an active-path migration, the design must include the following +views for the runtime area being changed: + +| UML view | Required question it answers | Minimum content | +| --- | --- | --- | +| Business context | Which product actors and features participate? | user action, GPS/radio input, storage media, power/sleep, feedback | +| Bounded context | Which layer owns each concept? | presentation, runtime, worker, platform adapter, simulator | +| Static class model | What is the code skeleton shape? | intents, commands, events, state, policies, ports, adapters | +| Component dependency | Which dependencies are legal? | page to facade, facade to queue/state, worker to ports, event to UI drain | +| Thread/deployment | Which task/thread owns each operation? | UI owner, GPS task, radio task, worker tasks, storage/bus owner | +| State model | What long-running states exist? | command, storage health, tile, track, persistence, feedback | +| Sequence model | How does one scenario flow end-to-end? | success, timeout, cancellation, stale completion, page destruction | +| Simulation model | How is it tested without hardware? | fake clock, fake queue, fake storage, fake arbiter, fake UI drain | +| Legacy burn-down map | What old path is removed? | old active caller, new owner, deletion condition, guard test | + +If one of these views is missing, the implementation is still in design debt and +must not proceed as a production migration. + +## UML Business Context + +This view shows the full product context for the UI/storage/event refactor. + +```mermaid +flowchart LR + User["User touch/key input"] --> UI["Presentation surfaces"] + Gps["GPS receiver"] --> GpsRuntime["GPS runtime"] + RadioPeer["Nearby radio peer"] --> Protocol["Protocol runtime"] + Power["Power/sleep manager"] --> UI + StorageMedia["SD / flash storage"] <--> StorageRuntime["Storage runtime"] + + UI --> MapUseCase["Map viewing / panning"] + UI --> TrackerUseCase["Tracker start / stop / list"] + UI --> ChatUseCase["Chat send / resend"] + UI --> ContactsUseCase["Contacts / node detail"] + + GpsRuntime --> TrackerUseCase + Protocol --> ChatUseCase + Protocol --> ContactsUseCase + Protocol --> MapUseCase + + MapUseCase --> Runtime["Shared runtime layer"] + TrackerUseCase --> Runtime + ChatUseCase --> Runtime + ContactsUseCase --> Runtime + + Runtime --> Workers["Async workers"] + Workers --> StorageRuntime + Runtime --> Feedback["Feedback runtime"] + Feedback --> UI +``` + +Business meaning: + +- The user, GPS receiver, radio peer, storage media, and power manager are + external stimuli. +- UI surfaces initiate product actions but do not own slow execution. +- Shared runtime owns business interpretation and state. +- Workers own slow operations. +- Feedback is global runtime output, not page-local rendering. + +## UML Bounded Contexts + +```mermaid +flowchart TB + subgraph Presentation["Presentation Context"] + Pages["Pages / widgets"] + UiActions["Presentation actions"] + UiDrain["UI event drain"] + Renderer["Concrete renderer"] + end + + subgraph RuntimeContext["Runtime Context"] + Facades["Use-case facades"] + StateMachines["State machines"] + Policies["Policy strategies"] + Events["Runtime events"] + Commands["Runtime commands"] + end + + subgraph WorkerContext["Worker Context"] + TileWorker["Tile worker"] + TrackWorker["Track worker"] + PersistWorker["Persistence worker"] + ProtocolWorker["Protocol worker"] + end + + subgraph PlatformContext["Platform Adapter Context"] + StorageAdapter["Storage adapter"] + BusAdapter["Bus / SPI adapter"] + DecodeAdapter["Decode adapter"] + ClockAdapter["Clock adapter"] + RadioAdapter["Radio adapter"] + end + + subgraph TestContext["Simulation Context"] + FakeClock["Fake clock"] + FakeStorage["Fake storage"] + FakeBus["Fake bus arbiter"] + FakeUi["Fake UI owner"] + FakeEvents["Fake event bus"] + end + + Pages --> UiActions + UiActions --> Facades + Facades --> Commands + Facades --> StateMachines + Facades --> Policies + Commands --> TileWorker + Commands --> TrackWorker + Commands --> PersistWorker + Commands --> ProtocolWorker + TileWorker --> StorageAdapter + TrackWorker --> StorageAdapter + PersistWorker --> StorageAdapter + TileWorker --> BusAdapter + TrackWorker --> BusAdapter + PersistWorker --> BusAdapter + TileWorker --> DecodeAdapter + ProtocolWorker --> RadioAdapter + WorkerContext --> Events + Events --> StateMachines + Events --> UiDrain + UiDrain --> Renderer + TestContext -.implements.-> PlatformContext +``` + +Boundary rules: + +- Presentation may depend on runtime facades, not workers or adapters. +- Runtime may depend on ports and value objects, not concrete platform APIs. +- Workers may depend on platform ports, not pages or renderer objects. +- Platform adapters may depend on platform SDKs, not product policy. +- Simulation implements the same ports as platform adapters. + +## UML Thread / Deployment View + +```mermaid +flowchart LR + subgraph UiTask["UI owner task/thread"] + Input["Input processing"] + UiTick["UI tick / lv_timer_handler / GTK loop"] + UiDrain["Runtime UI event drain"] + end + + subgraph GpsTask["GPS task"] + UartRx["UART RX"] + Parse["NMEA parse"] + PointIntent["Append point intent"] + end + + subgraph RadioTask["Radio task"] + RadioRx["RX decode"] + ProtocolIntent["Protocol incoming intent"] + end + + subgraph WorkerTasks["Worker tasks"] + CommandPump["Command pump"] + TileWorker["Tile worker"] + TrackWorker["Track worker"] + PersistWorker["Persistence worker"] + end + + subgraph ResourceOwner["Resource owner"] + Arbiter["Storage / bus arbiter"] + Storage["SD / flash adapter"] + Decode["Decode adapter"] + end + + Input --> UiTick + UiTick --> UiDrain + UiTick --> CommandPump + UartRx --> Parse + Parse --> PointIntent + PointIntent --> CommandPump + RadioRx --> ProtocolIntent + ProtocolIntent --> CommandPump + CommandPump --> TileWorker + CommandPump --> TrackWorker + CommandPump --> PersistWorker + TileWorker --> Arbiter + TrackWorker --> Arbiter + PersistWorker --> Arbiter + Arbiter --> Storage + TileWorker --> Decode + TileWorker --> UiDrain + TrackWorker --> UiDrain + PersistWorker --> UiDrain +``` + +The UI owner task is never deployed as the resource owner. If a target has only +one physical thread, the same ownership model still applies cooperatively: +slow work must be incremental, budgeted, and represented as commands/events +rather than blocking UI execution. + +## UML Bus Arbitration Class Model + +```mermaid +classDiagram + class BusAccessPolicy { + <> + UiNeverBlock + InteractiveWorkerBounded + BackgroundWorkerBounded + DurableCommit + RecoveryExclusive + } + + class BusAcquireRequest { + +resource + +policy + +command_id + +deadline_ms + +origin + } + + class BusAccessToken { + +resource + +owner + +acquired_ms + +valid + } + + class BusAcquireResult { + +status + +token + +diagnostics + } + + class BusDiagnostics { + +resource + +owner_task + +wait_ms + +hold_ms + +policy + +command_id + } + + class IBusArbiter { + <> + +acquire(request) BusAcquireResult + +release(token) + +health() StorageHealthState + } + + class IBusAdapter { + <> + +tryAcquire(timeout_ms) + +release() + } + + class BusPolicyStrategy { + <> + +select(command) BusAccessPolicy + +timeoutFor(policy) + } + + class StorageHealthState { + <> + +status + +last_error + +last_transition_ms + } + + class StorageBusArbiter { + +acquire(request) + +release(token) + +health() + } + + IBusArbiter <|.. StorageBusArbiter + StorageBusArbiter o-- IBusAdapter + StorageBusArbiter o-- BusPolicyStrategy + StorageBusArbiter o-- StorageHealthState + BusAcquireRequest --> BusAccessPolicy + BusAcquireResult --> BusAccessToken + BusAcquireResult --> BusDiagnostics +``` + +## UML Map Tile Class Model + +```mermaid +classDiagram + class MapTileRuntime { + +requestVisibleTiles(plan) + +cancelGeneration(generation) + +handle(event) + } + + class MapViewportPlan { + +generation + +tiles + +interaction_mode + } + + class LoadTileCommand { + +command_id + +tile_ref + +generation + +priority + +deadline_ms + } + + class MapTileWorker { + +submit(command) + +tick(now_ms) + } + + class IMapTileSource { + <> + +read(tile_ref) + } + + class IMapTileDecoder { + <> + +decode(payload) + } + + class MapTileEvent { + +kind + +tile_ref + +generation + +image_ref + +error + } + + class MapTileStateMachine { + +transition(event) + +snapshot() + } + + class IMapTileUiSink { + <> + +applyTile(event) + } + + MapTileRuntime --> MapViewportPlan + MapTileRuntime --> LoadTileCommand + MapTileRuntime o-- MapTileStateMachine + MapTileWorker --> LoadTileCommand + MapTileWorker o-- IBusArbiter + MapTileWorker o-- IMapTileSource + MapTileWorker o-- IMapTileDecoder + MapTileWorker --> MapTileEvent + MapTileRuntime --> MapTileEvent + IMapTileUiSink --> MapTileEvent +``` + +## UML Track Recording Class Model + +```mermaid +classDiagram + class TrackRuntime { + +startNewTrack() + +stopTrack() + +appendPoint(point) + +listTracks() + +handle(event) + } + + class TrackCommand { + +command_id + +kind + +track_id + +point_batch + +deadline_ms + } + + class TrackPointBuffer { + +append(point) + +takeBatch(policy) + +size() + } + + class TrackFlushPolicy { + <> + +shouldFlush(buffer, now_ms) + +isCritical(command) + } + + class TrackStateMachine { + +state() + +transition(event) + } + + class TrackStorageWorker { + +submit(command) + +tick(now_ms) + } + + class ITrackFileAdapter { + <> + +open(track_id) + +append(points) + +flush(track_id) + +close(track_id) + +list() + } + + class TrackEvent { + +kind + +command_id + +track_id + +error + } + + TrackRuntime --> TrackCommand + TrackRuntime o-- TrackPointBuffer + TrackRuntime o-- TrackStateMachine + TrackRuntime o-- TrackFlushPolicy + TrackStorageWorker --> TrackCommand + TrackStorageWorker o-- ITrackFileAdapter + TrackStorageWorker o-- IBusArbiter + TrackStorageWorker --> TrackEvent + TrackRuntime --> TrackEvent +``` + +## UML Persistence Class Model + +```mermaid +classDiagram + class PersistenceRuntime { + +markDirty(store_key) + +requestSave(store_key, policy) + +handle(event) + } + + class PersistenceCommand { + +command_id + +store_key + +policy + +deadline_ms + } + + class PersistencePolicy { + <> + DebouncedSave + BatchSave + ImmediateCriticalSave + DropDuplicateSave + } + + class DirtyStoreRegistry { + +markDirty(store_key) + +takeDue(now_ms) + +hasPending(store_key) + } + + class PersistenceWorker { + +submit(command) + +tick(now_ms) + } + + class IStoreSnapshotProvider { + <> + +snapshot(store_key) + } + + class IStoreStorageAdapter { + <> + +write(store_key, bytes) + +read(store_key) + } + + class PersistenceEvent { + +kind + +store_key + +command_id + +error + } + + PersistenceRuntime o-- DirtyStoreRegistry + PersistenceRuntime --> PersistenceCommand + PersistenceCommand --> PersistencePolicy + PersistenceWorker --> PersistenceCommand + PersistenceWorker o-- IStoreSnapshotProvider + PersistenceWorker o-- IStoreStorageAdapter + PersistenceWorker o-- IBusArbiter + PersistenceWorker --> PersistenceEvent + PersistenceRuntime --> PersistenceEvent +``` + +## UML Feedback Class Model + +```mermaid +classDiagram + class FeedbackRuntime { + +post(intent) + +handle(event) + +drainToUi() + } + + class NoticeIntent { + +notice_id + +category + +severity + +message + +duration_ms + +dedupe_key + } + + class FeedbackPolicy { + <> + +shouldShow(intent) + +dedupe(intent) + +duration(intent) + } + + class FeedbackEvent { + +kind + +notice_id + +error + } + + class IFeedbackPresenter { + <> + +present(intent) + } + + class FeedbackQueue { + +enqueue(intent) + +popReady(now_ms) + +dedupe(intent) + } + + FeedbackRuntime --> NoticeIntent + FeedbackRuntime o-- FeedbackQueue + FeedbackRuntime o-- FeedbackPolicy + FeedbackRuntime o-- IFeedbackPresenter + FeedbackRuntime --> FeedbackEvent +``` + +## UML State Models + +### Command Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Created + Created --> Queued + Queued --> Coalesced: duplicate accepted + Queued --> Cancelled: stale or user cancelled + Queued --> Running + Running --> WaitingForResource + WaitingForResource --> Running: acquired + WaitingForResource --> Retrying: timed out + Running --> Completed + Running --> Failed + Retrying --> Queued: retry allowed + Retrying --> Failed: retry exhausted + Completed --> [*] + Failed --> [*] + Cancelled --> [*] + Coalesced --> [*] +``` + +### Storage Health + +```mermaid +stateDiagram-v2 + [*] --> Healthy + Healthy --> Slow: wait or hold threshold exceeded + Slow --> Healthy: recovery window clean + Slow --> Degraded: repeated timeout + Degraded --> Unavailable: open/read/write unavailable + Unavailable --> Recovering: recovery probe scheduled + Recovering --> Healthy: probe succeeds + Recovering --> Unavailable: probe fails +``` + +### Map Tile + +```mermaid +stateDiagram-v2 + [*] --> Missing + Missing --> Requested + Requested --> Loading + Requested --> Cancelled: stale generation + Loading --> Ready + Loading --> Failed + Loading --> Cancelled: stale generation + Ready --> BoundToRenderer + Ready --> Evicted + Failed --> Requested: retry + BoundToRenderer --> Evicted +``` + +### Track Recorder + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Starting + Starting --> Recording + Starting --> Error + Recording --> Flushing + Flushing --> Recording + Recording --> Stopping + Stopping --> Stopped + Stopped --> Idle + Recording --> Error + Flushing --> Error + Stopping --> Error + Error --> Recovering + Recovering --> Recording + Recovering --> Stopped +``` + +### Persistence Save + +```mermaid +stateDiagram-v2 + [*] --> Clean + Clean --> Dirty: markDirty + Dirty --> SavePending: debounce elapsed + SavePending --> Saving + Saving --> Clean: save ok + Saving --> Dirty: save failed, retry allowed + Saving --> Degraded: repeated failure + Degraded --> SavePending: recovery retry +``` + +### Feedback Notice + +```mermaid +stateDiagram-v2 + [*] --> Posted + Posted --> Deduped: same dedupe key + Posted --> Queued + Queued --> Presenting + Presenting --> Visible + Presenting --> Dropped: presenter unavailable + Visible --> Expiring + Expiring --> Completed + Deduped --> [*] + Dropped --> [*] + Completed --> [*] +``` + +## UML Interaction Set + +These sequences are required design coverage. They may be split into separate +documents during implementation, but each active-path migration must keep the +relevant sequence current. + +### Mixed Map Drag and GPS Track Burst + +```mermaid +sequenceDiagram + participant UI as UI Owner + participant Map as MapTileRuntime + participant GPS as GPS Task + participant Track as TrackRuntime + participant Queue as Command Queue + participant Worker as Workers + participant Bus as Bus Arbiter + participant Events as Event Bus + + UI->>Map: requestVisibleTiles(generation=42, interactive) + Map->>Queue: enqueue LoadTileCommand priority=interactive + GPS->>Track: appendPoint(point) + Track->>Queue: enqueue/buffer AppendTrackPointCommand + Queue->>Worker: dispatch tile before idle/background work + Worker->>Bus: acquire InteractiveWorkerBounded + Bus-->>Worker: acquired + Worker->>Events: MapTileReady(generation=42) + Queue->>Worker: dispatch batched track write + Worker->>Bus: acquire BackgroundWorkerBounded + Bus-->>Worker: delayed or acquired + Worker->>Events: TrackFlushSucceeded or Backpressure + Events->>UI: drain UI-safe events +``` + +### NodeInfo Storm With Debounced Persistence + +```mermaid +sequenceDiagram + participant Radio as Radio Task + participant Runtime as Protocol Runtime + participant Contacts as Contact/Node Runtime + participant Persist as PersistenceRuntime + participant Worker as PersistenceWorker + participant Store as Storage Adapter + participant UI as UI Owner + + loop many packets + Radio->>Runtime: incoming NodeInfo/Position + Runtime->>Contacts: update in-memory projection + Contacts->>Persist: markDirty(nodes) + Contacts->>UI: publish projection update + end + Persist->>Persist: coalesce dirty notifications + Persist->>Worker: enqueue SaveStoreCommand after debounce + Worker->>Store: write snapshot + Worker->>Persist: PersistenceSaved/PersistenceFailed + Persist->>UI: optional storage feedback event +``` + +### Chat Send Result While Page Changes + +```mermaid +sequenceDiagram + participant ChatPage as Conversation Page + participant Chat as Chat Runtime + participant Protocol as Protocol Facade + participant Feedback as Feedback Runtime + participant ContactsPage as Contacts Page + participant UI as UI Owner + + ChatPage->>Chat: sendText(peer, text) + Chat->>Protocol: sendText intent + Protocol-->>Chat: message_id pending + ChatPage->>ContactsPage: navigate away + Protocol->>Chat: SendSucceeded or SendFailed + Chat->>Feedback: post delivery notice intent + Chat->>UI: publish message state update + UI->>Feedback: drain notice on current UI owner context +``` + +### Sleep / Wake While Storage Command Is Pending + +```mermaid +sequenceDiagram + participant UI as UI Owner + participant Power as Power Manager + participant Runtime as Runtime Facade + participant Worker as Storage Worker + participant Bus as Bus Arbiter + + UI->>Runtime: submit command + Runtime->>Worker: enqueue slow storage work + Worker->>Bus: acquire BackgroundWorkerBounded + Power->>UI: sleep timeout event + UI->>UI: render sleep transition + Power->>UI: wake input + UI->>UI: process wake input + Bus-->>Worker: storage complete later + Worker->>UI: publish completion event +``` + +## UML Simulation Harness + +```mermaid +classDiagram + class RuntimeHarness { + +advance(ms) + +drainUi() + +runUntilIdle() + } + + class FakeClock { + +now() + +advance(ms) + } + + class FakeCommandQueue { + +enqueue(command) + +popReady(now_ms) + +size() + } + + class FakeEventBus { + +publish(event) + +drain() + } + + class FakeStorageBackend { + +scriptDelay(operation, ms) + +scriptFailure(operation, error) + +read(request) + +write(request) + } + + class FakeBusArbiter { + +scriptAcquire(result) + +acquire(request) + +diagnostics() + } + + class FakeUiOwner { + +assertNoBlockingCalls() + +apply(effect) + +tick() + } + + class FakeFeedbackPresenter { + +present(intent) + +captured() + } + + RuntimeHarness o-- FakeClock + RuntimeHarness o-- FakeCommandQueue + RuntimeHarness o-- FakeEventBus + RuntimeHarness o-- FakeStorageBackend + RuntimeHarness o-- FakeBusArbiter + RuntimeHarness o-- FakeUiOwner + RuntimeHarness o-- FakeFeedbackPresenter +``` + +The simulation harness is part of the design, not only test utility code. It +proves that runtime behavior is event-driven and not dependent on real SD, +SPI, LVGL, GTK, or radio hardware. + +## Canonical Flow + +```mermaid +flowchart LR + Caller["UI / GPS / Protocol / App Service"] --> Intent["Runtime Intent"] + Intent --> Facade["Runtime Facade"] + Facade --> CommandQueue["Command Queue"] + CommandQueue --> Worker["Active Object Worker"] + Worker --> Arbiter["Storage / Bus Arbiter"] + Arbiter --> Adapter["Platform Adapter"] + Adapter --> Completion["Completion Result"] + Completion --> EventBus["Event Bus"] + EventBus --> State["Runtime State Projection"] + EventBus --> UiDrain["UI Owner Event Drain"] + UiDrain --> Renderer["Concrete Renderer Objects"] +``` + +The caller may be the UI owner context, GPS task, radio task, protocol runtime, +or app service. The rule is the same: submit intent or command, then return. + +The UI event drain is the only step in this flow that may touch concrete UI +objects. + +## Shared Resource Arbitration + +Direct shared-SPI locking from feature code is legacy. The target design is a +bus/storage arbiter with explicit policies: + +| Policy | Intended callers | Wait behavior | Failure behavior | +| --- | --- | --- | --- | +| `UiNeverBlock` | UI event/timer/input paths | no wait or frame-budget-only try | defer, cancel, or render pending state | +| `InteractiveWorkerBounded` | map tile worker during drag | short bounded wait | cancel stale tile or retry later | +| `BackgroundWorkerBounded` | track append, node save, prefetch | bounded wait with backpressure | reschedule, batch, or enter degraded state | +| `DurableCommit` | explicit stop/close/final flush | bounded but higher priority | report durable failure event | +| `RecoveryExclusive` | storage remount or card recovery | exclusive, never from UI | publish degraded/unavailable state | + +Every acquisition must be diagnosable: + +```text +resource +owner_task_or_thread +command_id +wait_start_ms +acquired_ms +released_ms +hold_ms +wait_ms +policy +``` + +The arbiter expresses scheduling intent. A mutex only expresses exclusion. Code +that uses a bare blocking mutex to coordinate UI, SD, and display refresh is not +conformant. + +## Map Tile Runtime Design + +Map rendering must be split into request planning, slow tile work, and UI +application. + +```mermaid +sequenceDiagram + participant UI as UI Owner / MapViewport + participant Runtime as MapTileRuntime + participant Worker as MapTileWorker + participant Store as Tile Storage Adapter + participant Bus as Storage/Bus Arbiter + participant Events as Event Bus + + UI->>Runtime: requestVisibleTiles(viewport, generation) + Runtime->>Worker: enqueue LoadTileCommand(ref, generation, priority) + UI-->>UI: return to input/render loop + Worker->>Bus: acquire(policy) + Bus-->>Worker: acquired or retry later + Worker->>Store: read/decode tile payload + Worker->>Events: MapTileReady/Failed(ref, generation, image_ref) + Events->>UI: drain event on UI owner context + UI->>UI: ignore stale generation or bind image to renderer object +``` + +Mandatory behavior: + +- LVGL timers and input callbacks must not open tile files. +- LVGL timers and input callbacks must not wait for shared-SPI. +- Tile requests carry a viewport generation. +- Completion for stale generations must be ignored or released without UI work. +- Dragging uses interactive priority and cancels stale pending requests. +- Idle prefetch uses lower priority and must yield to current viewport tiles. +- Missing, loading, ready, failed, and cancelled are explicit tile states. + +## Track Recording Runtime Design + +Track recording must not perform durable storage in the GPS task or UI page +callback. + +```mermaid +sequenceDiagram + participant GPS as GPS Task + participant UI as Tracker Page + participant Runtime as TrackRuntime + participant Worker as TrackStorageWorker + participant Store as Track Storage Adapter + participant Events as Event Bus + + UI->>Runtime: startNewTrack() + Runtime->>Worker: enqueue StartTrackCommand + UI-->>UI: render Starting state + Worker->>Store: create/open file + Worker->>Events: TrackStarted/TrackStartFailed + Events->>UI: update page state + + GPS->>Runtime: appendPoint(point) + Runtime->>Worker: enqueue/buffer point + GPS-->>GPS: continue parsing + Worker->>Store: batch write points + Worker->>Events: TrackFlushSucceeded/Failed +``` + +Mandatory behavior: + +- GPS parsing must not block on SD writes. +- UI actions such as New, Stop, Resume, List, Delete, and Export submit + commands and render pending state. +- Track points are buffered and flushed by policy. +- `flush every point` is not allowed as the normal policy. +- Stop/close performs a durable final flush through a command completion event. +- The runtime state machine owns `Idle`, `Starting`, `Recording`, `Flushing`, + `Stopping`, `Stopped`, `Error`, and `Recovering`. + +## Persistence Runtime Design + +Node/contact/config persistence must be decoupled from event dispatch. + +```text +Runtime event updates in-memory state. +Persistence intent is recorded. +PersistenceWorker batches/debounces writes. +Persistence result is published as an event. +``` + +Required strategies: + +- `DebouncedSave` for node/contact store updates. +- `ImmediateCriticalSave` only for explicit user settings or shutdown-critical + state. +- `BatchSave` for high-frequency updates. +- `DropDuplicateSave` for repeated dirty notifications while one save is + already pending. + +Event dispatch may mark a store dirty. It must not synchronously write storage +from the UI owner context. + +## Feedback Runtime Design + +Feedback is an event-driven runtime concern. + +```text +Feature/runtime detects a user-visible fact + -> publishes feedback intent or domain completion event + -> FeedbackRuntime applies eligibility/dedup policy + -> UI owner event drain presents through platform presenter +``` + +Pages may request feedback, but pages do not own global feedback delivery. +Protocol completion, chat send result, storage degraded state, and tracker +failure events must be able to produce feedback regardless of the current page. + +## State Machines + +State machines must replace hidden call-stack state for cross-time flows. + +### Storage Health + +```text +Healthy -> Slow -> Degraded -> Unavailable +Unavailable -> Recovering -> Healthy +``` + +Entering `Slow`, `Degraded`, or `Unavailable` publishes an event. UI can show +reduced functionality without blocking. + +### Map Tile + +```text +Missing -> Requested -> Loading -> Ready -> BoundToRenderer +Requested -> Cancelled +Loading -> Cancelled +Loading -> Failed +Ready -> Evicted +``` + +### Track Recorder + +```text +Idle -> Starting -> Recording -> Flushing -> Stopping -> Stopped +Recording -> Error +Error -> Recovering -> Recording +Error -> Stopped +``` + +### Chat Send Feedback + +```text +Queued -> SubmittedToRadio -> ObservedAck -> Succeeded +SubmittedToRadio -> Timeout -> Failed +SubmittedToRadio -> Rejected -> Failed +``` + +The feedback runtime observes facts from this state machine. It does not infer +delivery success from the current page. + +## Event Simulation and Robustness Testing + +The refactor is not complete until runtime behavior can be simulated without +real hardware. The test harness must be able to drive events, command queues, +worker completions, delays, failures, and UI drains deterministically. + +### Simulator Components + +The simulator must provide: + +| Component | Responsibility | +| --- | --- | +| `FakeClock` | deterministic time, timers, deadlines | +| `FakeUiThread` | records UI ticks and asserts no blocking operation runs on UI | +| `FakeEventBus` | publishes and drains runtime events deterministically | +| `FakeCommandQueue` | bounded queue, priorities, cancellation, dedupe | +| `FakeStorageBackend` | scripted read/write/list/flush delay and failure | +| `FakeBusArbiter` | scripted acquisition delay, timeout, owner diagnostics | +| `FakeMapTileWorker` | completes tile commands in controlled order | +| `FakeTrackStorageWorker` | batches points and emits track events | +| `FakeFeedbackPresenter` | captures notices without concrete renderer objects | + +The same simulator style should be usable by unit tests for ESP-shared runtime +code and host/linux tests. + +### Required Scenarios + +#### Map Drag Under Slow SD + +Script: + +```text +UI requests viewport generation 1. +Storage delays tile reads by 200 ms. +UI requests viewport generation 2 before generation 1 completes. +Generation 1 completes after generation 2 is active. +``` + +Assertions: + +- UI ticks continue. +- No UI-side blocking storage call occurs. +- Generation 1 results are ignored or released. +- Generation 2 visible tiles are applied when ready. +- Queue does not grow without bound. + +#### GPS Track Burst While Map Is Dragging + +Script: + +```text +Generate 100 GPS points. +Generate map drag requests every frame. +Make storage arbiter slow for 2 seconds. +``` + +Assertions: + +- GPS producer returns without waiting for SD. +- Track writer batches points. +- Map interactive requests keep higher priority than idle prefetch. +- UI event drain remains responsive. +- Backpressure is reported instead of freezing. + +#### Tracker New / Stop / List While Storage Busy + +Script: + +```text +Hold storage busy. +Click New. +Click Back or navigate away. +Release storage. +Complete StartTrackCommand. +Request listTracks. +``` + +Assertions: + +- UI renders pending state and remains navigable. +- Completion does not dereference destroyed page objects. +- Track runtime state is correct after navigation. +- List results are delivered through events. + +#### NodeInfo / Position Storm + +Script: + +```text +Publish many NodeInfo and Position events. +Mark node/contact store dirty repeatedly. +Advance fake time under debounce threshold. +Advance past threshold. +``` + +Assertions: + +- Memory projection updates are visible. +- Persistence writes are debounced and deduplicated. +- UI dispatch does not perform synchronous storage writes. +- Save failure produces a storage event and optional feedback intent. + +#### Chat Send Result From Any Page + +Script: + +```text +Send chat message from conversation page. +Navigate to contacts page. +Deliver ack timeout or success event. +``` + +Assertions: + +- The original message is updated, not duplicated. +- Feedback notice is captured by feedback runtime. +- Notice does not depend on current page. +- No page-owned `SystemNotification` call is required. + +#### Sleep / Wake During Pending Storage + +Script: + +```text +Start long storage command. +Trigger screen sleep timer. +Inject wake input. +Complete storage command. +``` + +Assertions: + +- Wake input is processed by UI owner context. +- Pending storage does not block wake path. +- UI state after completion is consistent. + +#### Storage Circuit Breaker + +Script: + +```text +Make storage time out repeatedly. +Submit tile, track, and persistence commands. +Advance clock through retry windows. +Recover storage. +``` + +Assertions: + +- Storage health transitions to Slow/Degraded/Unavailable. +- UI receives degraded state without freezing. +- Non-critical work is dropped, cancelled, or delayed by policy. +- Critical durable commands report failure or recover explicitly. + +### Test Invariants + +All runtime tests must assert: + +- UI owner context does not call blocking storage, blocking shared-SPI, or + filesystem APIs. +- Background workers do not call concrete renderer APIs. +- Every command completes, fails, is cancelled, or remains pending for an + explained reason. +- Queue bounds are enforced. +- Stale map tile generations cannot mutate the current viewport. +- Store saves are batched or debounced when high-frequency events arrive. +- Feedback capture does not require a live page object. +- Resource owner, wait time, and hold time diagnostics are emitted for slow + storage/bus operations. + +## Migration Order + +The burn-down should proceed in slices that each leave the system shippable. + +1. Add diagnostics around UI tick, storage/bus wait, command enqueue, command + completion, and event drain. +2. Introduce command/event simulator tests for feedback and chat send result. +3. Move map tile file access out of LVGL timer/input paths. +4. Move track start/stop/list/append/flush into an asynchronous track storage + worker. +5. Move node/contact persistence to a debounced persistence worker. +6. Replace direct shared-SPI lock calls in UI-facing code with arbiter policies. +7. Burn down adapter-owned business decisions and route them through shared + runtimes/facades. +8. Turn remaining synchronous storage calls in page/widget code into compile or + test failures. + +## Conformance Checklist + +A change conforms to this design only when: + +- UI code submits commands or intents instead of executing slow work. +- Slow work has an owning worker or declared active object. +- Cross-context results are events. +- UI mutation happens only in the UI owner drain. +- Platform adapters contain no business policy. +- Shared resource access is policy-driven and diagnosable. +- State machines describe visible long-running operation state. +- Simulation tests can trigger success, failure, timeout, cancellation, + backpressure, and stale completion. +- The same business rule is implemented once in runtime code and reused by + ESP32, nRF52, Linux LVGL, and Linux GTK targets through bridges/adapters. diff --git a/modules/core_sys/include/sys/runtime_async.h b/modules/core_sys/include/sys/runtime_async.h new file mode 100644 index 00000000..c1b9e9eb --- /dev/null +++ b/modules/core_sys/include/sys/runtime_async.h @@ -0,0 +1,408 @@ +#pragma once + +#include +#include + +namespace sys +{ +namespace runtime +{ + +enum class RuntimeCommandKind : uint8_t +{ + Unknown, + MapTileLoad, + MapTileCancelGeneration, + TrackStart, + TrackStop, + TrackAppendPoint, + TrackFlush, + TrackList, + PersistenceSave, + FeedbackNotice, + ProtocolAction, +}; + +enum class RuntimeEventKind : uint8_t +{ + Unknown, + CommandQueued, + CommandStarted, + CommandCompleted, + CommandFailed, + CommandCancelled, + ResourceBusy, + StorageSlow, + StorageDegraded, + MapTileReady, + MapTileFailed, + TrackStarted, + TrackStopped, + TrackFlushSucceeded, + TrackFlushFailed, + PersistenceSaved, + PersistenceFailed, + FeedbackPosted, + FeedbackDropped, +}; + +enum class RuntimePriority : uint8_t +{ + Realtime = 0, + Interactive = 16, + Normal = 64, + Background = 128, + Idle = 192, +}; + +enum class RuntimeCancelPolicy : uint8_t +{ + None, + CancelByGeneration, + CancelByDedupeKey, + DropIfStale, +}; + +enum class BusAccessPolicy : uint8_t +{ + UiNeverBlock, + InteractiveWorkerBounded, + BackgroundWorkerBounded, + DurableCommit, + RecoveryExclusive, +}; + +enum class BusAcquireStatus : uint8_t +{ + Acquired, + Busy, + TimedOut, + Unavailable, +}; + +enum class StorageHealthStatus : uint8_t +{ + Healthy, + Slow, + Degraded, + Unavailable, + Recovering, +}; + +struct RuntimeCommand +{ + uint32_t command_id = 0; + RuntimeCommandKind kind = RuntimeCommandKind::Unknown; + RuntimePriority priority = RuntimePriority::Normal; + RuntimeCancelPolicy cancel_policy = RuntimeCancelPolicy::None; + uint32_t created_at_ms = 0; + uint32_t deadline_ms = 0; + uint32_t generation = 0; + uint32_t dedupe_key = 0; + uint32_t origin = 0; +}; + +struct RuntimeEvent +{ + uint32_t event_id = 0; + RuntimeEventKind kind = RuntimeEventKind::Unknown; + uint32_t command_id = 0; + uint32_t timestamp_ms = 0; + uint32_t generation = 0; + int32_t error = 0; +}; + +struct BusAcquireRequest +{ + uint32_t resource = 0; + BusAccessPolicy policy = BusAccessPolicy::BackgroundWorkerBounded; + uint32_t command_id = 0; + uint32_t deadline_ms = 0; + uint32_t origin = 0; +}; + +struct BusAccessToken +{ + uint32_t resource = 0; + uint32_t owner = 0; + uint32_t acquired_ms = 0; + bool valid = false; +}; + +struct BusDiagnostics +{ + uint32_t resource = 0; + uint32_t owner = 0; + uint32_t command_id = 0; + uint32_t wait_ms = 0; + uint32_t hold_ms = 0; + BusAccessPolicy policy = BusAccessPolicy::BackgroundWorkerBounded; +}; + +struct BusAcquireResult +{ + BusAcquireStatus status = BusAcquireStatus::Unavailable; + BusAccessToken token{}; + BusDiagnostics diagnostics{}; +}; + +struct StorageHealthState +{ + StorageHealthStatus status = StorageHealthStatus::Healthy; + int32_t last_error = 0; + uint32_t last_transition_ms = 0; +}; + +class IBusArbiter +{ + public: + virtual ~IBusArbiter() = default; + + virtual BusAcquireResult acquire(const BusAcquireRequest& request) = 0; + virtual void release(const BusAccessToken& token) = 0; + virtual StorageHealthState health() const = 0; +}; + +class IUiOwnerGuard +{ + public: + virtual ~IUiOwnerGuard() = default; + + virtual bool isUiOwner() const = 0; + virtual void recordForbiddenBlockingCall(RuntimeCommandKind kind) = 0; +}; + +template +class FixedRuntimeQueue +{ + public: + FixedRuntimeQueue() + { + static_assert(N > 0, "FixedRuntimeQueue capacity must be > 0"); + } + + bool enqueue(const T& item) + { + if (size_ >= N) + { + return false; + } + items_[(head_ + size_) % N] = item; + ++size_; + return true; + } + + bool pop(T& out) + { + if (size_ == 0) + { + return false; + } + out = items_[head_]; + head_ = (head_ + 1) % N; + --size_; + return true; + } + + std::size_t size() const + { + return size_; + } + + constexpr std::size_t capacity() const + { + return N; + } + + bool empty() const + { + return size_ == 0; + } + + bool full() const + { + return size_ >= N; + } + + void clear() + { + head_ = 0; + size_ = 0; + } + + const T* at(std::size_t index) const + { + if (index >= size_) + { + return nullptr; + } + return &items_[(head_ + index) % N]; + } + + T* at(std::size_t index) + { + if (index >= size_) + { + return nullptr; + } + return &items_[(head_ + index) % N]; + } + + private: + T items_[N]{}; + std::size_t head_ = 0; + std::size_t size_ = 0; +}; + +template +class FixedCommandQueue +{ + public: + bool enqueue(const RuntimeCommand& command) + { + return queue_.enqueue(command); + } + + bool enqueueOrReplaceDedupe(const RuntimeCommand& command) + { + if (command.dedupe_key != 0) + { + for (std::size_t i = 0; i < queue_.size(); ++i) + { + RuntimeCommand* existing = queue_.at(i); + if (existing && existing->dedupe_key == command.dedupe_key) + { + *existing = command; + return true; + } + } + } + return queue_.enqueue(command); + } + + bool popNext(uint32_t now_ms, RuntimeCommand& out) + { + if (queue_.empty()) + { + return false; + } + + std::size_t best_index = N; + for (std::size_t i = 0; i < queue_.size(); ++i) + { + const RuntimeCommand* candidate = queue_.at(i); + if (!candidate) + { + continue; + } + if (candidate->deadline_ms != 0 && + static_cast(candidate->deadline_ms - now_ms) < 0) + { + continue; + } + if (best_index == N) + { + best_index = i; + continue; + } + const RuntimeCommand* best = queue_.at(best_index); + if (!best || + static_cast(candidate->priority) < static_cast(best->priority) || + (candidate->priority == best->priority && + candidate->created_at_ms < best->created_at_ms)) + { + best_index = i; + } + } + + if (best_index == N) + { + return false; + } + + RuntimeCommand selected{}; + FixedRuntimeQueue rebuilt; + for (std::size_t i = 0; i < queue_.size(); ++i) + { + const RuntimeCommand* item = queue_.at(i); + if (!item) + { + continue; + } + if (i == best_index) + { + selected = *item; + } + else + { + (void)rebuilt.enqueue(*item); + } + } + queue_ = rebuilt; + out = selected; + return true; + } + + std::size_t cancelGeneration(uint32_t generation) + { + return removeMatching(generation, 0, true); + } + + std::size_t cancelDedupeKey(uint32_t dedupe_key) + { + return removeMatching(0, dedupe_key, false); + } + + std::size_t size() const + { + return queue_.size(); + } + + bool empty() const + { + return queue_.empty(); + } + + void clear() + { + queue_.clear(); + } + + const RuntimeCommand* at(std::size_t index) const + { + return queue_.at(index); + } + + private: + std::size_t removeMatching(uint32_t generation, uint32_t dedupe_key, bool by_generation) + { + FixedRuntimeQueue rebuilt; + std::size_t removed = 0; + for (std::size_t i = 0; i < queue_.size(); ++i) + { + const RuntimeCommand* item = queue_.at(i); + if (!item) + { + continue; + } + const bool match = by_generation ? item->generation == generation + : item->dedupe_key == dedupe_key; + if (match) + { + ++removed; + continue; + } + (void)rebuilt.enqueue(*item); + } + queue_ = rebuilt; + return removed; + } + + FixedRuntimeQueue queue_; +}; + +template +using FixedEventQueue = FixedRuntimeQueue; + +} // namespace runtime +} // namespace sys diff --git a/modules/core_sys/include/sys/storage_event_runtime.h b/modules/core_sys/include/sys/storage_event_runtime.h new file mode 100644 index 00000000..8f0f9794 --- /dev/null +++ b/modules/core_sys/include/sys/storage_event_runtime.h @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include + +namespace sys::runtime +{ + +enum class StorageWorkKind : uint8_t +{ + SnapshotSave, + LogAppend, + SnapshotRead, + DeleteBlob, +}; + +enum class StorageWorkState : uint8_t +{ + Idle, + Pending, + InFlight, + FailedPendingRetry, +}; + +struct StorageWorkItem +{ + StorageWorkKind kind = StorageWorkKind::SnapshotSave; + uint32_t key = 0; + uint32_t generation = 0; + const uint8_t* data = nullptr; + size_t len = 0; +}; + +template +class LatestSnapshotStorageRuntime +{ + public: + bool requestSave(uint32_t key, const uint8_t* data, size_t len) + { + if (len > MaxBytes || (len > 0 && data == nullptr)) + { + return false; + } + key_ = key; + len_ = len; + if (len > 0) + { + std::memcpy(buffer_.data(), data, len); + } + ++generation_; + pending_ = true; + state_ = (state_ == StorageWorkState::InFlight) + ? StorageWorkState::InFlight + : StorageWorkState::Pending; + return true; + } + + bool takeNext(StorageWorkItem& out) + { + if (!pending_) + { + return false; + } + pending_ = false; + in_flight_generation_ = generation_; + state_ = StorageWorkState::InFlight; + out.kind = StorageWorkKind::SnapshotSave; + out.key = key_; + out.generation = in_flight_generation_; + out.data = buffer_.data(); + out.len = len_; + return true; + } + + void complete(uint32_t generation, bool ok) + { + if (generation != in_flight_generation_) + { + return; + } + if (ok) + { + state_ = pending_ ? StorageWorkState::Pending : StorageWorkState::Idle; + last_completed_generation_ = generation; + return; + } + pending_ = true; + state_ = StorageWorkState::FailedPendingRetry; + } + + bool pending() const { return pending_; } + bool busy() const { return state_ == StorageWorkState::InFlight; } + StorageWorkState state() const { return state_; } + uint32_t generation() const { return generation_; } + uint32_t lastCompletedGeneration() const { return last_completed_generation_; } + size_t len() const { return len_; } + + private: + std::array buffer_{}; + size_t len_ = 0; + uint32_t key_ = 0; + uint32_t generation_ = 0; + uint32_t in_flight_generation_ = 0; + uint32_t last_completed_generation_ = 0; + bool pending_ = false; + StorageWorkState state_ = StorageWorkState::Idle; +}; + +} // namespace sys::runtime diff --git a/modules/core_sys/tests/test_runtime_async.cpp b/modules/core_sys/tests/test_runtime_async.cpp new file mode 100644 index 00000000..bd8172d5 --- /dev/null +++ b/modules/core_sys/tests/test_runtime_async.cpp @@ -0,0 +1,102 @@ +#include "sys/runtime_async.h" + +#include + +namespace +{ + +void test_priority_pop_order() +{ + sys::runtime::FixedCommandQueue<4> queue; + + sys::runtime::RuntimeCommand background{}; + background.command_id = 1; + background.kind = sys::runtime::RuntimeCommandKind::PersistenceSave; + background.priority = sys::runtime::RuntimePriority::Background; + background.created_at_ms = 1; + + sys::runtime::RuntimeCommand interactive{}; + interactive.command_id = 2; + interactive.kind = sys::runtime::RuntimeCommandKind::MapTileLoad; + interactive.priority = sys::runtime::RuntimePriority::Interactive; + interactive.created_at_ms = 2; + + assert(queue.enqueue(background)); + assert(queue.enqueue(interactive)); + + sys::runtime::RuntimeCommand out{}; + assert(queue.popNext(10, out)); + assert(out.command_id == 2); + assert(queue.popNext(10, out)); + assert(out.command_id == 1); +} + +void test_dedupe_replace() +{ + sys::runtime::FixedCommandQueue<4> queue; + + sys::runtime::RuntimeCommand first{}; + first.command_id = 1; + first.dedupe_key = 42; + first.generation = 1; + + sys::runtime::RuntimeCommand replacement = first; + replacement.command_id = 2; + replacement.generation = 2; + + assert(queue.enqueueOrReplaceDedupe(first)); + assert(queue.enqueueOrReplaceDedupe(replacement)); + assert(queue.size() == 1); + + sys::runtime::RuntimeCommand out{}; + assert(queue.popNext(0, out)); + assert(out.command_id == 2); + assert(out.generation == 2); +} + +void test_cancel_generation() +{ + sys::runtime::FixedCommandQueue<4> queue; + + sys::runtime::RuntimeCommand a{}; + a.command_id = 1; + a.generation = 7; + sys::runtime::RuntimeCommand b{}; + b.command_id = 2; + b.generation = 8; + + assert(queue.enqueue(a)); + assert(queue.enqueue(b)); + assert(queue.cancelGeneration(7) == 1); + assert(queue.size() == 1); + + sys::runtime::RuntimeCommand out{}; + assert(queue.popNext(0, out)); + assert(out.command_id == 2); +} + +void test_event_queue_bounds() +{ + sys::runtime::FixedEventQueue<1> events; + sys::runtime::RuntimeEvent event{}; + event.event_id = 1; + event.kind = sys::runtime::RuntimeEventKind::MapTileReady; + + assert(events.enqueue(event)); + assert(!events.enqueue(event)); + assert(events.size() == 1); + sys::runtime::RuntimeEvent out{}; + assert(events.pop(out)); + assert(out.event_id == 1); +} + +} // namespace + +int main() +{ + test_priority_pop_order(); + test_dedupe_replace(); + test_cancel_generation(); + test_event_queue_bounds(); + return 0; +} diff --git a/modules/core_sys/tests/test_storage_event_runtime.cpp b/modules/core_sys/tests/test_storage_event_runtime.cpp new file mode 100644 index 00000000..b8a8fe8b --- /dev/null +++ b/modules/core_sys/tests/test_storage_event_runtime.cpp @@ -0,0 +1,77 @@ +#include "sys/storage_event_runtime.h" + +#include +#include + +using sys::runtime::LatestSnapshotStorageRuntime; +using sys::runtime::StorageWorkItem; +using sys::runtime::StorageWorkState; + +namespace +{ + +void burst_updates_coalesce_to_latest_snapshot() +{ + LatestSnapshotStorageRuntime<16> runtime; + const uint8_t first[] = {1, 2, 3}; + const uint8_t second[] = {9, 8}; + + assert(runtime.requestSave(42, first, sizeof(first))); + assert(runtime.requestSave(42, second, sizeof(second))); + + StorageWorkItem work; + assert(runtime.takeNext(work)); + assert(work.key == 42); + assert(work.generation == 2); + assert(work.len == sizeof(second)); + assert(std::memcmp(work.data, second, sizeof(second)) == 0); +} + +void completion_ignores_stale_generation() +{ + LatestSnapshotStorageRuntime<16> runtime; + const uint8_t first[] = {1}; + const uint8_t second[] = {2}; + + assert(runtime.requestSave(1, first, sizeof(first))); + StorageWorkItem work; + assert(runtime.takeNext(work)); + assert(runtime.requestSave(1, second, sizeof(second))); + + runtime.complete(work.generation + 100, true); + assert(runtime.busy()); + + runtime.complete(work.generation, true); + assert(runtime.pending()); + assert(runtime.state() == StorageWorkState::Pending); +} + +void failed_save_is_retried_with_same_generation() +{ + LatestSnapshotStorageRuntime<16> runtime; + const uint8_t blob[] = {4, 5, 6}; + + assert(runtime.requestSave(7, blob, sizeof(blob))); + StorageWorkItem first; + assert(runtime.takeNext(first)); + runtime.complete(first.generation, false); + + assert(runtime.pending()); + assert(runtime.state() == StorageWorkState::FailedPendingRetry); + + StorageWorkItem retry; + assert(runtime.takeNext(retry)); + assert(retry.generation == first.generation); + assert(retry.len == sizeof(blob)); + assert(std::memcmp(retry.data, blob, sizeof(blob)) == 0); +} + +} // namespace + +int main() +{ + burst_updates_coalesce_to_latest_snapshot(); + completion_ignores_stale_generation(); + failed_save_is_retried_with_same_generation(); + return 0; +} 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 new file mode 100644 index 00000000..3ce9585f --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_async_runtime.h @@ -0,0 +1,125 @@ +#pragma once + +#include "sys/runtime_async.h" +#include "ui_map_runtime/map_tiles/map_tile_render_queue.h" +#include "ui_map_runtime/map_tiles/map_tile_source.h" + +#include +#include + +namespace ui +{ +namespace map_tiles +{ + +enum class MapTileInteractionMode : uint8_t +{ + Idle, + InteractiveDrag, +}; + +enum class MapTileAsyncEventKind : uint8_t +{ + Ready, + Failed, + Cancelled, + ResourceBusy, +}; + +struct MapViewportPlan +{ + static constexpr std::size_t kMaxTiles = MapTileRenderQueue::kMaxTiles; + + uint32_t generation = 0; + MapTileInteractionMode interaction_mode = MapTileInteractionMode::Idle; + MapTileRef tiles[kMaxTiles]{}; + std::size_t tile_count = 0; +}; + +struct LoadTileCommand +{ + sys::runtime::RuntimeCommand runtime{}; + MapTileRef tile{}; +}; + +struct MapTileAsyncEvent +{ + MapTileAsyncEventKind kind = MapTileAsyncEventKind::Failed; + uint32_t command_id = 0; + uint32_t generation = 0; + MapTileRef tile{}; + MapTileFormat format = MapTileFormat::Unknown; + std::size_t payload_size = 0; + int32_t error = 0; +}; + +class IMapTileCommandSink +{ + public: + virtual ~IMapTileCommandSink() = default; + + virtual bool enqueue(const LoadTileCommand& command) = 0; + virtual std::size_t cancelGeneration(uint32_t generation) = 0; +}; + +class IMapTileEventSink +{ + public: + virtual ~IMapTileEventSink() = default; + + virtual bool publish(const MapTileAsyncEvent& event) = 0; +}; + +class IMapTileWorkerBackend +{ + public: + virtual ~IMapTileWorkerBackend() = default; + + virtual MapTileLookupResult lookup(const MapTileRef& ref) = 0; + virtual bool read(const MapTileRef& ref, + uint8_t* buffer, + std::size_t capacity, + std::size_t& out_size, + MapTileFormat& out_format) = 0; +}; + +class MapTileAsyncRuntime +{ + public: + explicit MapTileAsyncRuntime(IMapTileCommandSink& commands); + + uint32_t activeGeneration() const; + std::size_t requestVisibleTiles(const MapViewportPlan& plan, uint32_t now_ms); + bool handleEvent(const MapTileAsyncEvent& event, MapTileRenderQueue& render_queue); + + private: + static sys::runtime::RuntimePriority priorityFor(MapTileInteractionMode mode); + + IMapTileCommandSink& commands_; + uint32_t active_generation_ = 0; + uint32_t next_command_id_ = 1; +}; + +class MapTileWorker +{ + public: + MapTileWorker(IMapTileWorkerBackend& backend, + sys::runtime::IBusArbiter& bus, + IMapTileEventSink& events, + uint8_t* scratch, + std::size_t scratch_size); + + bool execute(const LoadTileCommand& command, uint32_t now_ms); + + private: + static sys::runtime::BusAccessPolicy busPolicyFor(sys::runtime::RuntimePriority priority); + + IMapTileWorkerBackend& backend_; + sys::runtime::IBusArbiter& bus_; + IMapTileEventSink& events_; + uint8_t* scratch_ = nullptr; + std::size_t scratch_size_ = 0; +}; + +} // namespace map_tiles +} // namespace ui diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_render_queue.h b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_render_queue.h index 9bb35dd5..f21a6bc7 100644 --- a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_render_queue.h +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_render_queue.h @@ -25,6 +25,7 @@ enum class MapTileRenderState : uint8_t Loading, Ready, Error, + Cancelled, }; struct MapTileRenderRef diff --git a/modules/ui_map_runtime/library.json b/modules/ui_map_runtime/library.json index e152a067..b2bfe96b 100644 --- a/modules/ui_map_runtime/library.json +++ b/modules/ui_map_runtime/library.json @@ -6,7 +6,8 @@ "srcDir": "src", "flags": [ "-I../../modules/ui_map_runtime/include", - "-I../../modules/ui_presentation/include" + "-I../../modules/ui_presentation/include", + "-I../../modules/core_sys/include" ] } } 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 new file mode 100644 index 00000000..144d0dcc --- /dev/null +++ b/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp @@ -0,0 +1,157 @@ +#include "ui_map_runtime/map_tiles/map_tile_async_runtime.h" + +namespace ui +{ +namespace map_tiles +{ + +MapTileAsyncRuntime::MapTileAsyncRuntime(IMapTileCommandSink& commands) + : commands_(commands) +{ +} + +uint32_t MapTileAsyncRuntime::activeGeneration() const +{ + return active_generation_; +} + +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_); + } + active_generation_ = plan.generation; + } + + std::size_t queued = 0; + const std::size_t count = plan.tile_count < MapViewportPlan::kMaxTiles ? plan.tile_count + : MapViewportPlan::kMaxTiles; + for (std::size_t i = 0; i < count; ++i) + { + LoadTileCommand command{}; + command.tile = plan.tiles[i]; + command.runtime.command_id = next_command_id_++; + command.runtime.kind = sys::runtime::RuntimeCommandKind::MapTileLoad; + command.runtime.priority = priorityFor(plan.interaction_mode); + command.runtime.cancel_policy = sys::runtime::RuntimeCancelPolicy::CancelByGeneration; + command.runtime.created_at_ms = now_ms; + command.runtime.generation = plan.generation; + command.runtime.dedupe_key = + (static_cast(command.tile.z & 0xFF) << 24) ^ + ((command.tile.x & 0xFFFu) << 12) ^ + (command.tile.y & 0xFFFu) ^ + (static_cast(command.tile.layer) << 28); + + if (commands_.enqueue(command)) + { + ++queued; + } + } + return queued; +} + +bool MapTileAsyncRuntime::handleEvent(const MapTileAsyncEvent& event, MapTileRenderQueue& render_queue) +{ + if (event.generation != active_generation_) + { + return false; + } + + MapTileRenderRef ref{}; + ref.tile = event.tile; + switch (event.kind) + { + case MapTileAsyncEventKind::Ready: + ref.state = MapTileRenderState::Ready; + break; + case MapTileAsyncEventKind::Cancelled: + ref.state = MapTileRenderState::Cancelled; + break; + case MapTileAsyncEventKind::ResourceBusy: + ref.state = MapTileRenderState::Loading; + break; + case MapTileAsyncEventKind::Failed: + default: + ref.state = MapTileRenderState::Error; + break; + } + return render_queue.push(ref); +} + +sys::runtime::RuntimePriority MapTileAsyncRuntime::priorityFor(MapTileInteractionMode mode) +{ + return mode == MapTileInteractionMode::InteractiveDrag ? sys::runtime::RuntimePriority::Interactive + : sys::runtime::RuntimePriority::Normal; +} + +MapTileWorker::MapTileWorker(IMapTileWorkerBackend& backend, + sys::runtime::IBusArbiter& bus, + IMapTileEventSink& events, + uint8_t* scratch, + std::size_t scratch_size) + : backend_(backend), bus_(bus), events_(events), scratch_(scratch), scratch_size_(scratch_size) +{ +} + +bool MapTileWorker::execute(const LoadTileCommand& command, uint32_t now_ms) +{ + sys::runtime::BusAcquireRequest request{}; + request.policy = busPolicyFor(command.runtime.priority); + request.command_id = command.runtime.command_id; + request.deadline_ms = command.runtime.deadline_ms; + + const auto acquired = bus_.acquire(request); + if (acquired.status != sys::runtime::BusAcquireStatus::Acquired) + { + MapTileAsyncEvent event{}; + event.kind = MapTileAsyncEventKind::ResourceBusy; + event.command_id = command.runtime.command_id; + event.generation = command.runtime.generation; + event.tile = command.tile; + event.error = static_cast(acquired.status); + (void)events_.publish(event); + return false; + } + + MapTileAsyncEvent event{}; + event.command_id = command.runtime.command_id; + event.generation = command.runtime.generation; + event.tile = command.tile; + + const auto lookup = backend_.lookup(command.tile); + if (lookup.status != MapTileStatus::Available) + { + event.kind = MapTileAsyncEventKind::Failed; + event.format = lookup.format; + event.error = static_cast(lookup.status); + bus_.release(acquired.token); + (void)events_.publish(event); + return false; + } + + std::size_t out_size = 0; + MapTileFormat out_format = MapTileFormat::Unknown; + const bool ok = backend_.read(command.tile, scratch_, scratch_size_, out_size, out_format); + bus_.release(acquired.token); + + event.kind = ok ? MapTileAsyncEventKind::Ready : MapTileAsyncEventKind::Failed; + event.format = out_format; + event.payload_size = out_size; + event.error = ok ? 0 : -1; + (void)events_.publish(event); + (void)now_ms; + return ok; +} + +sys::runtime::BusAccessPolicy MapTileWorker::busPolicyFor(sys::runtime::RuntimePriority priority) +{ + return priority == sys::runtime::RuntimePriority::Interactive + ? sys::runtime::BusAccessPolicy::InteractiveWorkerBounded + : sys::runtime::BusAccessPolicy::BackgroundWorkerBounded; +} + +} // namespace map_tiles +} // namespace ui 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 new file mode 100644 index 00000000..36982bc2 --- /dev/null +++ b/modules/ui_map_runtime/tests/test_map_tile_async_runtime.cpp @@ -0,0 +1,248 @@ +#include "ui_map_runtime/map_tiles/map_tile_async_runtime.h" + +#include +#include + +namespace +{ + +ui::map_tiles::MapTileRef make_tile(uint32_t x) +{ + ui::map_tiles::MapTileRef ref{}; + ref.layer = ui::map_tiles::MapTileLayer::Osm; + ref.z = 12; + ref.x = x; + ref.y = x + 1; + return ref; +} + +class FakeCommandSink final : public ui::map_tiles::IMapTileCommandSink +{ + public: + ui::map_tiles::LoadTileCommand commands[8]{}; + std::size_t count = 0; + std::size_t cancelled = 0; + + bool enqueue(const ui::map_tiles::LoadTileCommand& command) override + { + if (count >= 8) + { + return false; + } + commands[count++] = command; + return true; + } + + std::size_t cancelGeneration(uint32_t generation) override + { + std::size_t removed = 0; + ui::map_tiles::LoadTileCommand kept[8]{}; + std::size_t kept_count = 0; + for (std::size_t i = 0; i < count; ++i) + { + if (commands[i].runtime.generation == generation) + { + ++removed; + continue; + } + kept[kept_count++] = commands[i]; + } + for (std::size_t i = 0; i < kept_count; ++i) + { + commands[i] = kept[i]; + } + count = kept_count; + cancelled += removed; + return removed; + } +}; + +class FakeBusArbiter final : public sys::runtime::IBusArbiter +{ + public: + sys::runtime::BusAcquireStatus next_status = sys::runtime::BusAcquireStatus::Acquired; + sys::runtime::BusAccessPolicy last_policy = sys::runtime::BusAccessPolicy::BackgroundWorkerBounded; + int acquire_count = 0; + int release_count = 0; + + sys::runtime::BusAcquireResult acquire(const sys::runtime::BusAcquireRequest& request) override + { + ++acquire_count; + last_policy = request.policy; + sys::runtime::BusAcquireResult result{}; + result.status = next_status; + result.token.valid = next_status == sys::runtime::BusAcquireStatus::Acquired; + result.token.resource = request.resource; + result.token.owner = request.command_id; + return result; + } + + void release(const sys::runtime::BusAccessToken& token) override + { + assert(token.valid); + ++release_count; + } + + sys::runtime::StorageHealthState health() const override + { + return {}; + } +}; + +class FakeBackend final : public ui::map_tiles::IMapTileWorkerBackend +{ + public: + bool available = true; + bool read_ok = true; + + ui::map_tiles::MapTileLookupResult lookup(const ui::map_tiles::MapTileRef& ref) override + { + (void)ref; + ui::map_tiles::MapTileLookupResult result{}; + result.status = available ? ui::map_tiles::MapTileStatus::Available + : ui::map_tiles::MapTileStatus::Missing; + result.format = ui::map_tiles::MapTileFormat::Png; + return result; + } + + bool read(const ui::map_tiles::MapTileRef& ref, + uint8_t* buffer, + std::size_t capacity, + std::size_t& out_size, + ui::map_tiles::MapTileFormat& out_format) override + { + (void)ref; + out_size = 0; + out_format = ui::map_tiles::MapTileFormat::Png; + if (!read_ok || !buffer || capacity < 3) + { + return false; + } + buffer[0] = 1; + buffer[1] = 2; + buffer[2] = 3; + out_size = 3; + return true; + } +}; + +class FakeEventSink final : public ui::map_tiles::IMapTileEventSink +{ + public: + ui::map_tiles::MapTileAsyncEvent events[4]{}; + std::size_t count = 0; + + bool publish(const ui::map_tiles::MapTileAsyncEvent& event) override + { + if (count >= 4) + { + return false; + } + events[count++] = event; + return true; + } +}; + +void test_generation_cancels_old_commands() +{ + FakeCommandSink sink; + ui::map_tiles::MapTileAsyncRuntime runtime(sink); + + ui::map_tiles::MapViewportPlan first{}; + first.generation = 1; + first.tile_count = 1; + first.tiles[0] = make_tile(10); + assert(runtime.requestVisibleTiles(first, 100) == 1); + assert(sink.count == 1); + + ui::map_tiles::MapViewportPlan second{}; + second.generation = 2; + second.tile_count = 1; + second.tiles[0] = make_tile(20); + assert(runtime.requestVisibleTiles(second, 120) == 1); + assert(sink.cancelled == 1); + assert(sink.count == 1); + assert(sink.commands[0].runtime.generation == 2); +} + +void test_stale_event_is_ignored() +{ + FakeCommandSink sink; + ui::map_tiles::MapTileAsyncRuntime runtime(sink); + ui::map_tiles::MapViewportPlan plan{}; + plan.generation = 2; + plan.tile_count = 1; + plan.tiles[0] = make_tile(20); + assert(runtime.requestVisibleTiles(plan, 100) == 1); + + ui::map_tiles::MapTileRenderQueue render_queue; + ui::map_tiles::MapTileAsyncEvent stale{}; + stale.kind = ui::map_tiles::MapTileAsyncEventKind::Ready; + stale.generation = 1; + stale.tile = make_tile(10); + assert(!runtime.handleEvent(stale, render_queue)); + assert(render_queue.size() == 0); + + ui::map_tiles::MapTileAsyncEvent current = stale; + current.generation = 2; + current.tile = make_tile(20); + assert(runtime.handleEvent(current, render_queue)); + assert(render_queue.size() == 1); + assert(render_queue.items()[0].state == ui::map_tiles::MapTileRenderState::Ready); +} + +void test_worker_busy_does_not_read_storage() +{ + FakeBackend backend; + FakeBusArbiter bus; + FakeEventSink events; + uint8_t scratch[8]{}; + ui::map_tiles::MapTileWorker worker(backend, bus, events, scratch, sizeof(scratch)); + + ui::map_tiles::LoadTileCommand command{}; + command.runtime.command_id = 7; + command.runtime.generation = 3; + command.runtime.priority = sys::runtime::RuntimePriority::Interactive; + command.tile = make_tile(30); + + bus.next_status = sys::runtime::BusAcquireStatus::Busy; + assert(!worker.execute(command, 200)); + assert(bus.acquire_count == 1); + assert(bus.release_count == 0); + assert(events.count == 1); + assert(events.events[0].kind == ui::map_tiles::MapTileAsyncEventKind::ResourceBusy); + assert(bus.last_policy == sys::runtime::BusAccessPolicy::InteractiveWorkerBounded); +} + +void test_worker_success_publishes_ready() +{ + FakeBackend backend; + FakeBusArbiter bus; + FakeEventSink events; + uint8_t scratch[8]{}; + ui::map_tiles::MapTileWorker worker(backend, bus, events, scratch, sizeof(scratch)); + + ui::map_tiles::LoadTileCommand command{}; + command.runtime.command_id = 9; + command.runtime.generation = 4; + command.runtime.priority = sys::runtime::RuntimePriority::Normal; + command.tile = make_tile(40); + + assert(worker.execute(command, 300)); + assert(bus.acquire_count == 1); + assert(bus.release_count == 1); + assert(events.count == 1); + assert(events.events[0].kind == ui::map_tiles::MapTileAsyncEventKind::Ready); + assert(events.events[0].payload_size == 3); +} + +} // namespace + +int main() +{ + test_generation_cancels_old_commands(); + test_stale_event_is_ignored(); + test_worker_busy_does_not_read_storage(); + test_worker_success_publishes_ready(); + return 0; +} diff --git a/modules/ui_shared/include/ui/widgets/map/map_viewport.h b/modules/ui_shared/include/ui/widgets/map/map_viewport.h index 4c2d6e92..83f8edad 100644 --- a/modules/ui_shared/include/ui/widgets/map/map_viewport.h +++ b/modules/ui_shared/include/ui/widgets/map/map_viewport.h @@ -128,7 +128,7 @@ class Runtime friend void set_gesture_callback(Runtime& runtime, GestureCallback callback, void* user_data); }; -Widgets create(Runtime& runtime, lv_obj_t* parent, uint32_t loader_interval_ms = 200); +Widgets create(Runtime& runtime, lv_obj_t* parent, uint32_t loader_interval_ms = 50); void destroy(Runtime& runtime); const Widgets& widgets(const Runtime& runtime); void set_size(Runtime& runtime, lv_coord_t width, lv_coord_t height); 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 6be185cf..9e899824 100644 --- a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp +++ b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp @@ -30,7 +30,7 @@ struct RuntimeImpl ui::map_tiles::MapTileRenderQueue render_queue{}; TileContext tile_ctx{}; lv_timer_t* loader_timer = nullptr; - uint32_t loader_interval_ms = 200; + uint32_t loader_interval_ms = 50; bool alive = false; bool has_map_data = false; bool has_visible_map_data = false; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/node_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/node_store.h index 0e5aa81e..4f3ab091 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/node_store.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/node_store.h @@ -8,6 +8,13 @@ #include "chat/infra/node_store_core.h" #include "chat/ports/i_node_blob_store.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#include + namespace chat { namespace meshtastic @@ -53,10 +60,26 @@ class NodeStore : public contacts::INodeStore, bool loadFromSd(std::vector& out) const; bool saveToNvs(const uint8_t* data, size_t len) const; bool saveToSd(const uint8_t* data, size_t len) const; + bool saveToBackend(const uint8_t* data, size_t len, StorageBackend backend) const; + void ensureAsyncSaveWorker(); + bool enqueueAsyncSave(const uint8_t* data, size_t len); + bool waitForAsyncSave(TickType_t wait_ticks); + void asyncSaveLoop(); + static void asyncSaveTaskEntry(void* context); void clearNvs() const; contacts::NodeStoreCore core_; StorageBackend backend_ = StorageBackend::Nvs; + SemaphoreHandle_t async_save_mutex_ = nullptr; + QueueHandle_t async_save_queue_ = nullptr; + TaskHandle_t async_save_task_ = nullptr; + std::vector pending_save_blob_; + StorageBackend pending_save_backend_ = StorageBackend::Nvs; + uint32_t pending_save_generation_ = 0; + uint32_t completed_save_generation_ = 0; + bool async_save_pending_ = false; + bool async_save_busy_ = false; + bool async_save_failed_ = false; }; } // namespace meshtastic diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/track_recorder.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/track_recorder.h index bee7de23..acc2c493 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/track_recorder.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/track_recorder.h @@ -5,6 +5,8 @@ #include +#include + namespace gps { @@ -42,6 +44,7 @@ class TrackRecorder // Append a single point if recording is active and SD is ready. void appendPoint(const TrackPoint& pt); + void flushPending(bool force = false); // List GPX files under /trackers; returns count. size_t listTracks(String* out_names, size_t max_names) const; @@ -61,6 +64,11 @@ class TrackRecorder void updateActiveStateLocked(); bool writeActiveStateLocked() const; void clearActiveStateLocked() const; + bool shouldAcceptPointLocked(const TrackPoint& pt, uint32_t now_ms) const; + void markPointAcceptedLocked(const TrackPoint& pt, uint32_t now_ms); + bool writePendingPointsLocked(bool force); + + static constexpr size_t kPendingPointCapacity = 16; SemaphoreHandle_t mutex_ = xSemaphoreCreateMutex(); bool recording_ = false; @@ -74,6 +82,9 @@ class TrackRecorder uint32_t min_interval_ms_ = 0; bool distance_only_ = false; TrackFormat format_ = TrackFormat::GPX; + TrackPoint pending_points_[kPendingPointCapacity]{}; + size_t pending_point_count_ = 0; + uint32_t last_flush_ms_ = 0; }; } // namespace gps diff --git a/platform/esp/arduino_common/src/LV_Helper_v9.cpp b/platform/esp/arduino_common/src/LV_Helper_v9.cpp index ae2a4eec..b8e5762d 100644 --- a/platform/esp/arduino_common/src/LV_Helper_v9.cpp +++ b/platform/esp/arduino_common/src/LV_Helper_v9.cpp @@ -10,6 +10,7 @@ #include "display/DisplayInterface.h" #include "input/morse_engine.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/shared_spi_lock.h" #include "screen_sleep.h" #include "ui/LV_Helper.h" #include "ui/app_runtime.h" @@ -44,6 +45,8 @@ namespace { constexpr char kLvglFlashFsLetter = 'F'; constexpr const char* kLvglFlashFsMountPoint = "/fs"; +constexpr TickType_t kLvglSdFsWait = pdMS_TO_TICKS(2); +constexpr TickType_t kLvglSdFsCloseWait = pdMS_TO_TICKS(10); lv_fs_drv_t s_flash_fs_drv; lv_fs_drv_t s_sd_fs_drv; @@ -107,6 +110,11 @@ bool sd_fs_ready_cb(lv_fs_drv_t* drv) void* sd_fs_open(lv_fs_drv_t* drv, const char* path, lv_fs_mode_t mode) { LV_UNUSED(drv); + ::platform::esp::common::SharedSpiLockGuard spi_guard(kLvglSdFsWait); + if (!spi_guard.locked()) + { + return nullptr; + } const char* open_mode = "r"; if (mode == LV_FS_MODE_WR) @@ -139,6 +147,7 @@ lv_fs_res_t sd_fs_close(lv_fs_drv_t* drv, void* file_p) { return LV_FS_RES_INV_PARAM; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kLvglSdFsCloseWait); file->close(); delete file; return LV_FS_RES_OK; @@ -152,6 +161,11 @@ lv_fs_res_t sd_fs_read(lv_fs_drv_t* drv, void* file_p, void* buf, uint32_t btr, { return LV_FS_RES_INV_PARAM; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kLvglSdFsWait); + if (!spi_guard.locked()) + { + return LV_FS_RES_BUSY; + } const int result = file->read(buf, btr); if (result < 0) @@ -177,6 +191,11 @@ lv_fs_res_t sd_fs_write(lv_fs_drv_t* drv, { return LV_FS_RES_INV_PARAM; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kLvglSdFsWait); + if (!spi_guard.locked()) + { + return LV_FS_RES_BUSY; + } const std::size_t result = file->write(buf, btw); if (bw != nullptr) @@ -194,6 +213,11 @@ lv_fs_res_t sd_fs_seek(lv_fs_drv_t* drv, void* file_p, uint32_t pos, lv_fs_whenc { return LV_FS_RES_INV_PARAM; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kLvglSdFsWait); + if (!spi_guard.locked()) + { + return LV_FS_RES_BUSY; + } uint64_t target = pos; switch (whence) @@ -221,6 +245,11 @@ lv_fs_res_t sd_fs_tell(lv_fs_drv_t* drv, void* file_p, uint32_t* pos_p) { return LV_FS_RES_INV_PARAM; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kLvglSdFsWait); + if (!spi_guard.locked()) + { + return LV_FS_RES_BUSY; + } *pos_p = static_cast(file->position()); return LV_FS_RES_OK; } @@ -228,6 +257,11 @@ lv_fs_res_t sd_fs_tell(lv_fs_drv_t* drv, void* file_p, uint32_t* pos_p) void* sd_fs_dir_open(lv_fs_drv_t* drv, const char* path) { LV_UNUSED(drv); + ::platform::esp::common::SharedSpiLockGuard spi_guard(kLvglSdFsWait); + if (!spi_guard.locked()) + { + return nullptr; + } auto* dir = new (std::nothrow)::platform::esp::arduino_common::storage::SdRuntimeDir(); if (dir == nullptr) @@ -250,6 +284,11 @@ lv_fs_res_t sd_fs_dir_read(lv_fs_drv_t* drv, void* dir_p, char* fn, uint32_t fn_ { return LV_FS_RES_INV_PARAM; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kLvglSdFsWait); + if (!spi_guard.locked()) + { + return LV_FS_RES_BUSY; + } bool is_dir = false; if (!dir->read_next(fn, fn_len, &is_dir)) @@ -274,6 +313,7 @@ lv_fs_res_t sd_fs_dir_close(lv_fs_drv_t* drv, void* dir_p) { return LV_FS_RES_INV_PARAM; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kLvglSdFsCloseWait); dir->close(); delete dir; return LV_FS_RES_OK; diff --git a/platform/esp/arduino_common/src/app_runtime_support.cpp b/platform/esp/arduino_common/src/app_runtime_support.cpp index 7b7188d7..22ae7ba5 100644 --- a/platform/esp/arduino_common/src/app_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_runtime_support.cpp @@ -14,6 +14,7 @@ #include "platform/esp/arduino_common/device_identity.h" #include "platform/esp/arduino_common/hostlink/hostlink_service.h" #include "platform/ui/settings_store.h" +#include "platform/ui/tracker_runtime.h" #include "sys/event_bus.h" #include "team/usecase/team_pairing_service.h" #include "team/usecase/team_service.h" @@ -156,6 +157,8 @@ void updateCoreServices(app::IAppFacade& app_context) { hostlink::process_pending_commands(); + platform::ui::tracker::poll(); + app_context.getChatService().processIncoming(); app_context.getChatService().flushStore(); diff --git a/platform/esp/arduino_common/src/chat/infra/meshtastic/node_store.cpp b/platform/esp/arduino_common/src/chat/infra/meshtastic/node_store.cpp index 052a4189..5c2f1495 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshtastic/node_store.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshtastic/node_store.cpp @@ -7,6 +7,7 @@ #include "../../internal/blob_store_io.h" #include "chat/infra/node_store_blob_format.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/shared_spi_lock.h" #include #include @@ -31,6 +32,13 @@ namespace meshtastic namespace { +constexpr TickType_t kAsyncSaveMutexWait = pdMS_TO_TICKS(20); +constexpr TickType_t kAsyncSavePollInterval = pdMS_TO_TICKS(10); +constexpr TickType_t kAsyncSaveRetryDelay = pdMS_TO_TICKS(500); +constexpr TickType_t kSdLoadWait = pdMS_TO_TICKS(250); +constexpr TickType_t kSdPersistWait = pdMS_TO_TICKS(100); +constexpr uint32_t kAsyncSaveTaskStackBytes = 5 * 1024; +constexpr UBaseType_t kAsyncSaveTaskPriority = 2; void logNvsStats(const char* tag, const char* ns) { @@ -150,7 +158,9 @@ void NodeStore::clear() bool NodeStore::flush() { - return core_.flush(); + const bool accepted = core_.flush(); + const bool persisted = waitForAsyncSave(pdMS_TO_TICKS(1500)); + return accepted && persisted; } bool NodeStore::loadBlob(std::vector& out) @@ -184,30 +194,28 @@ bool NodeStore::loadBlob(std::vector& out) bool NodeStore::saveBlob(const uint8_t* data, size_t len) { - if (backend_ == StorageBackend::Sd) - { - const bool ok = saveToSd(data, len); - NODE_STORE_LOG("[NodeStore] save target=sd len=%u count=%u ok=%u\n", - static_cast(len), - static_cast(contacts::nodeBlobEntryCount(len)), - ok ? 1U : 0U); - return ok; - } - - const bool ok = saveToNvs(data, len); - NODE_STORE_LOG("[NodeStore] save target=nvs len=%u count=%u ok=%u\n", - static_cast(len), - static_cast(contacts::nodeBlobEntryCount(len)), - ok ? 1U : 0U); - return ok; + return enqueueAsyncSave(data, len); } void NodeStore::clearBlob() { - if (::platform::esp::arduino_common::storage::sd_card_ready() && - ::platform::esp::arduino_common::storage::sd_exists(kPersistNodesFile)) + if (async_save_mutex_ != nullptr && + xSemaphoreTake(async_save_mutex_, kAsyncSaveMutexWait) == pdTRUE) { - ::platform::esp::arduino_common::storage::sd_remove(kPersistNodesFile); + pending_save_blob_.clear(); + async_save_pending_ = false; + async_save_failed_ = false; + xSemaphoreGive(async_save_mutex_); + } + + if (::platform::esp::arduino_common::storage::sd_card_ready()) + { + ::platform::esp::common::SharedSpiLockGuard spi_guard(kSdPersistWait); + if (spi_guard.locked() && + ::platform::esp::arduino_common::storage::sd_exists(kPersistNodesFile)) + { + ::platform::esp::arduino_common::storage::sd_remove(kPersistNodesFile); + } } clearNvs(); } @@ -310,6 +318,13 @@ bool NodeStore::loadFromSd(std::vector& out) const return false; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kSdLoadWait); + if (!spi_guard.locked()) + { + NODE_STORE_LOG("[NodeStore] load SD skipped: spi busy\n"); + return false; + } + ::platform::esp::arduino_common::storage::SdRuntimeFile file; if (!file.open(kPersistNodesFile, "r")) { @@ -466,6 +481,14 @@ bool NodeStore::saveToSd(const uint8_t* data, size_t len) const return false; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kSdPersistWait); + if (!spi_guard.locked()) + { + NODE_STORE_LOG("[NodeStore] save SD skipped: spi busy len=%u\n", + static_cast(len)); + return false; + } + const std::string temp_path = std::string(kPersistNodesFile) + ".tmp"; if (::platform::esp::arduino_common::storage::sd_exists(temp_path.c_str())) { @@ -512,6 +535,213 @@ bool NodeStore::saveToSd(const uint8_t* data, size_t len) const return true; } +bool NodeStore::saveToBackend(const uint8_t* data, size_t len, StorageBackend backend) const +{ + if (backend == StorageBackend::Sd) + { + const bool ok = saveToSd(data, len); + NODE_STORE_LOG("[NodeStore] save target=sd len=%u count=%u ok=%u\n", + static_cast(len), + static_cast(contacts::nodeBlobEntryCount(len)), + ok ? 1U : 0U); + return ok; + } + + const bool ok = saveToNvs(data, len); + NODE_STORE_LOG("[NodeStore] save target=nvs len=%u count=%u ok=%u\n", + static_cast(len), + static_cast(contacts::nodeBlobEntryCount(len)), + ok ? 1U : 0U); + return ok; +} + +void NodeStore::ensureAsyncSaveWorker() +{ + if (async_save_mutex_ == nullptr) + { + async_save_mutex_ = xSemaphoreCreateMutex(); + } + if (async_save_queue_ == nullptr) + { + async_save_queue_ = xQueueCreate(1, sizeof(uint8_t)); + } + if (async_save_task_ == nullptr && async_save_mutex_ != nullptr && async_save_queue_ != nullptr) + { + BaseType_t ok = xTaskCreate(asyncSaveTaskEntry, + "node_store_io", + kAsyncSaveTaskStackBytes, + this, + kAsyncSaveTaskPriority, + &async_save_task_); + if (ok != pdPASS) + { + NODE_STORE_LOG("[NodeStore] async save task create failed\n"); + async_save_task_ = nullptr; + } + } +} + +bool NodeStore::enqueueAsyncSave(const uint8_t* data, size_t len) +{ + if (len > 0 && data == nullptr) + { + return false; + } + if (!contacts::isValidNodeBlobSize(len) || + contacts::nodeBlobEntryCount(len) > contacts::NodeStoreCore::kMaxNodes) + { + return false; + } + + ensureAsyncSaveWorker(); + if (async_save_mutex_ == nullptr || async_save_queue_ == nullptr || async_save_task_ == nullptr) + { + return false; + } + + if (xSemaphoreTake(async_save_mutex_, kAsyncSaveMutexWait) != pdTRUE) + { + NODE_STORE_LOG("[NodeStore] async save enqueue busy len=%u\n", + static_cast(len)); + return false; + } + + pending_save_blob_.assign(data, data + len); + pending_save_backend_ = backend_; + ++pending_save_generation_; + async_save_pending_ = true; + async_save_failed_ = false; + const uint32_t generation = pending_save_generation_; + const StorageBackend target = pending_save_backend_; + xSemaphoreGive(async_save_mutex_); + + const uint8_t signal = 1; + if (xQueueOverwrite(async_save_queue_, &signal) != pdTRUE) + { + NODE_STORE_LOG("[NodeStore] async save signal failed gen=%lu\n", + static_cast(generation)); + return false; + } + + NODE_STORE_LOG("[NodeStore] save queued gen=%lu target=%s len=%u count=%u\n", + static_cast(generation), + target == StorageBackend::Sd ? "sd" : "nvs", + static_cast(len), + static_cast(contacts::nodeBlobEntryCount(len))); + return true; +} + +bool NodeStore::waitForAsyncSave(TickType_t wait_ticks) +{ + ensureAsyncSaveWorker(); + if (async_save_mutex_ == nullptr || async_save_queue_ == nullptr || async_save_task_ == nullptr) + { + return false; + } + + const TickType_t start = xTaskGetTickCount(); + while (true) + { + bool done = false; + bool failed = false; + if (xSemaphoreTake(async_save_mutex_, kAsyncSaveMutexWait) == pdTRUE) + { + done = !async_save_pending_ && !async_save_busy_; + failed = async_save_failed_; + xSemaphoreGive(async_save_mutex_); + } + if (done) + { + return !failed; + } + if ((xTaskGetTickCount() - start) >= wait_ticks) + { + return false; + } + vTaskDelay(kAsyncSavePollInterval); + } +} + +void NodeStore::asyncSaveLoop() +{ + uint8_t signal = 0; + for (;;) + { + if (xQueueReceive(async_save_queue_, &signal, portMAX_DELAY) != pdTRUE) + { + continue; + } + + for (;;) + { + std::vector blob; + StorageBackend backend = StorageBackend::Nvs; + uint32_t generation = 0; + + if (xSemaphoreTake(async_save_mutex_, portMAX_DELAY) != pdTRUE) + { + break; + } + if (!async_save_pending_) + { + async_save_busy_ = false; + xSemaphoreGive(async_save_mutex_); + break; + } + blob = pending_save_blob_; + backend = pending_save_backend_; + generation = pending_save_generation_; + async_save_pending_ = false; + async_save_busy_ = true; + xSemaphoreGive(async_save_mutex_); + + const bool ok = saveToBackend(blob.data(), blob.size(), backend); + + bool has_more = false; + if (xSemaphoreTake(async_save_mutex_, portMAX_DELAY) == pdTRUE) + { + async_save_busy_ = false; + async_save_failed_ = !ok; + if (ok) + { + completed_save_generation_ = generation; + } + else if (!async_save_pending_) + { + pending_save_blob_ = blob; + pending_save_backend_ = backend; + async_save_pending_ = true; + } + has_more = async_save_pending_; + xSemaphoreGive(async_save_mutex_); + } + + NODE_STORE_LOG("[NodeStore] save done gen=%lu ok=%u more=%u\n", + static_cast(generation), + ok ? 1U : 0U, + has_more ? 1U : 0U); + if (!ok) + { + vTaskDelay(kAsyncSaveRetryDelay); + } + if (!has_more) + { + break; + } + } + } +} + +void NodeStore::asyncSaveTaskEntry(void* context) +{ + auto* self = static_cast(context); + if (self) + { + self->asyncSaveLoop(); + } + vTaskDelete(nullptr); +} + void NodeStore::clearNvs() const { chat::infra::clearPreferencesKeys(kPersistNodesNs, diff --git a/platform/esp/arduino_common/src/gps/gps_service.cpp b/platform/esp/arduino_common/src/gps/gps_service.cpp index dbba37fa..a6991b55 100644 --- a/platform/esp/arduino_common/src/gps/gps_service.cpp +++ b/platform/esp/arduino_common/src/gps/gps_service.cpp @@ -852,6 +852,7 @@ void GpsService::gpsTask(void* pvParameters) { gps::TrackRecorder::getInstance().appendPoint(track_pt); } + gps::TrackRecorder::getInstance().flushPending(false); } else { diff --git a/platform/esp/arduino_common/src/gps/track_recorder.cpp b/platform/esp/arduino_common/src/gps/track_recorder.cpp index 885f1855..e2fbf98d 100644 --- a/platform/esp/arduino_common/src/gps/track_recorder.cpp +++ b/platform/esp/arduino_common/src/gps/track_recorder.cpp @@ -40,6 +40,8 @@ constexpr uint8_t kActiveFlagManual = 0x01; constexpr uint8_t kActiveFlagAuto = 0x02; constexpr const char* kActivePath = "/trackers/active.bin"; constexpr TickType_t kSdTransactionLockWait = pdMS_TO_TICKS(250); +constexpr uint32_t kPendingFlushIntervalMs = 5000; +constexpr size_t kPendingFlushThreshold = 6; double deg2rad(double deg) { @@ -58,6 +60,66 @@ double haversine_m(double lat1, double lon1, double lat2, double lon2) return R * c; } +String track_iso_time(time_t t) +{ + if (t <= 0) + { + t = time(nullptr); + } + struct tm tm_utc + { + }; + char buf[32] = {0}; + if (t > 0 && gmtime_r(&t, &tm_utc)) + { + strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm_utc); + return String(buf); + } + return String("1970-01-01T00:00:00Z"); +} + +void write_track_point(SdRuntimeFile& f, TrackFormat format, const TrackPoint& pt) +{ + if (format == TrackFormat::CSV) + { + f.printf("%.7f,%.7f,%lu,%u\n", + pt.lat, + pt.lon, + static_cast(pt.timestamp), + static_cast(pt.satellites)); + } + else if (format == TrackFormat::Binary) + { + struct BinPoint + { + int32_t lat_e7; + int32_t lon_e7; + uint32_t ts; + uint8_t sat; + } rec{}; + + rec.lat_e7 = static_cast(lround(pt.lat * 1e7)); + rec.lon_e7 = static_cast(lround(pt.lon * 1e7)); + rec.ts = static_cast(pt.timestamp); + rec.sat = pt.satellites; + f.write(reinterpret_cast(&rec), sizeof(rec)); + } + else + { + const String time_str = track_iso_time(pt.timestamp); + f.printf("\n", pt.lat, pt.lon); + f.printf(" %.1f\n", 0.0); + f.printf(" \n", time_str.c_str()); + f.print(" \n"); + f.printf(" %.2f\n", 0.0); + f.printf(" %.1f\n", 0.0); + f.printf(" %.1f\n", 0.0); + f.printf(" %u\n", (unsigned)pt.satellites); + f.print(" \n"); + f.print("\n"); + } +} + } // namespace TrackRecorder& TrackRecorder::getInstance() @@ -160,6 +222,8 @@ void TrackRecorder::beginNewFile() last_point_valid_ = false; last_point_time_ = 0; last_point_ms_ = 0; + pending_point_count_ = 0; + last_flush_ms_ = millis(); updateActiveStateLocked(); } @@ -222,6 +286,7 @@ void TrackRecorder::stop() manual_recording_ = false; if (auto_recording_) { + writePendingPointsLocked(true); updateActiveStateLocked(); if (mutex_) { @@ -232,6 +297,7 @@ void TrackRecorder::stop() if (recording_ && current_path_.length() > 0) { + writePendingPointsLocked(true); SdRuntimeFile f; if (f.open(current_path_.c_str(), "a")) { @@ -248,6 +314,7 @@ void TrackRecorder::stop() last_point_valid_ = false; last_point_time_ = 0; last_point_ms_ = 0; + pending_point_count_ = 0; updateActiveStateLocked(); if (mutex_) @@ -285,6 +352,7 @@ void TrackRecorder::setAutoRecording(bool enabled) { if (current_path_.length() > 0) { + writePendingPointsLocked(true); SdRuntimeFile f; if (f.open(current_path_.c_str(), "a")) { @@ -301,6 +369,7 @@ void TrackRecorder::setAutoRecording(bool enabled) last_point_valid_ = false; last_point_time_ = 0; last_point_ms_ = 0; + pending_point_count_ = 0; } updateActiveStateLocked(); @@ -366,6 +435,7 @@ void TrackRecorder::setFormat(TrackFormat format) if (current_path_.length() > 0) { + writePendingPointsLocked(true); SdRuntimeFile f; if (f.open(current_path_.c_str(), "a")) { @@ -382,6 +452,7 @@ void TrackRecorder::setFormat(TrackFormat format) last_point_valid_ = false; last_point_time_ = 0; last_point_ms_ = 0; + pending_point_count_ = 0; if (auto_recording_ || manual_recording_) { @@ -404,15 +475,93 @@ void TrackRecorder::appendPoint(const TrackPoint& pt) return; } - if (mutex_ && xSemaphoreTake(mutex_, pdMS_TO_TICKS(200)) != pdTRUE) + bool force_flush_before_enqueue = false; + bool should_flush_after_enqueue = false; + + if (mutex_ && xSemaphoreTake(mutex_, pdMS_TO_TICKS(20)) != pdTRUE) { return; } - // SD and display share one board-level SPI bus on T-Deck/Pager. Acquire - // shared bus ownership before touching SD so runtime call sites describe - // the actual resource being arbitrated. - ::platform::esp::common::SharedSpiLockGuard spi_guard(kSdTransactionLockWait); + uint32_t now_ms = millis(); + if (!recording_ || current_path_.isEmpty() || !shouldAcceptPointLocked(pt, now_ms)) + { + if (mutex_) + { + xSemaphoreGive(mutex_); + } + return; + } + + if (pending_point_count_ >= kPendingPointCapacity) + { + force_flush_before_enqueue = true; + } + else + { + pending_points_[pending_point_count_++] = pt; + markPointAcceptedLocked(pt, now_ms); + should_flush_after_enqueue = + pending_point_count_ >= kPendingFlushThreshold || + last_flush_ms_ == 0 || + static_cast(now_ms - last_flush_ms_) >= kPendingFlushIntervalMs; + } + + if (mutex_) + { + xSemaphoreGive(mutex_); + } + + if (force_flush_before_enqueue) + { + flushPending(true); + if (mutex_ && xSemaphoreTake(mutex_, pdMS_TO_TICKS(20)) != pdTRUE) + { + return; + } + now_ms = millis(); + if (recording_ && !current_path_.isEmpty() && shouldAcceptPointLocked(pt, now_ms) && + pending_point_count_ < kPendingPointCapacity) + { + pending_points_[pending_point_count_++] = pt; + markPointAcceptedLocked(pt, now_ms); + should_flush_after_enqueue = true; + } + if (mutex_) + { + xSemaphoreGive(mutex_); + } + } + + if (should_flush_after_enqueue) + { + flushPending(false); + } +} + +void TrackRecorder::flushPending(bool force) +{ + const TickType_t mutex_wait = force ? pdMS_TO_TICKS(400) : pdMS_TO_TICKS(20); + if (mutex_ && xSemaphoreTake(mutex_, mutex_wait) != pdTRUE) + { + return; + } + + uint32_t now_ms = millis(); + if (pending_point_count_ == 0 || + (!force && pending_point_count_ < kPendingFlushThreshold && + last_flush_ms_ != 0 && + static_cast(now_ms - last_flush_ms_) < kPendingFlushIntervalMs)) + { + if (mutex_) + { + xSemaphoreGive(mutex_); + } + return; + } + + const TickType_t spi_wait = force ? kSdTransactionLockWait : 0; + ::platform::esp::common::SharedSpiLockGuard spi_guard(spi_wait); if (!spi_guard.locked()) { if (mutex_) @@ -422,39 +571,25 @@ void TrackRecorder::appendPoint(const TrackPoint& pt) return; } - if (!sd_card_ready()) - { - if (mutex_) - { - xSemaphoreGive(mutex_); - } - return; - } + (void)writePendingPointsLocked(force); - // Re-check under lock. - if (!recording_ || current_path_.isEmpty()) + if (mutex_) { - if (mutex_) - { - xSemaphoreGive(mutex_); - } - return; + xSemaphoreGive(mutex_); } +} +bool TrackRecorder::shouldAcceptPointLocked(const TrackPoint& pt, uint32_t now_ms) const +{ if (last_point_valid_) { const double d = haversine_m(last_point_.lat, last_point_.lon, pt.lat, pt.lon); if (d < kMinRecordDistanceM) { - if (mutex_) - { - xSemaphoreGive(mutex_); - } - return; + return false; } } - uint32_t now_ms = millis(); if (!distance_only_ && min_interval_ms_ > 0) { if (pt.timestamp > 0 && last_point_time_ > 0) @@ -462,76 +597,50 @@ void TrackRecorder::appendPoint(const TrackPoint& pt) uint32_t delta_ms = static_cast(pt.timestamp - last_point_time_) * 1000u; if (delta_ms < min_interval_ms_) { - if (mutex_) - { - xSemaphoreGive(mutex_); - } - return; + return false; } } else if (last_point_ms_ > 0 && (now_ms - last_point_ms_) < min_interval_ms_) { - if (mutex_) - { - xSemaphoreGive(mutex_); - } - return; + return false; } } + return true; +} + +void TrackRecorder::markPointAcceptedLocked(const TrackPoint& pt, uint32_t now_ms) +{ + last_point_ = pt; + last_point_valid_ = true; + last_point_time_ = pt.timestamp; + last_point_ms_ = now_ms; +} + +bool TrackRecorder::writePendingPointsLocked(bool force) +{ + if (pending_point_count_ == 0 || !recording_ || current_path_.isEmpty() || !sd_card_ready()) + { + return false; + } SdRuntimeFile f; if (f.open(current_path_.c_str(), "a")) { - if (format_ == TrackFormat::CSV) + for (size_t i = 0; i < pending_point_count_; ++i) { - f.printf("%.7f,%.7f,%lu,%u\n", - pt.lat, - pt.lon, - static_cast(pt.timestamp), - static_cast(pt.satellites)); + write_track_point(f, format_, pending_points_[i]); } - else if (format_ == TrackFormat::Binary) + if (force || pending_point_count_ >= kPendingFlushThreshold) { - struct BinPoint - { - int32_t lat_e7; - int32_t lon_e7; - uint32_t ts; - uint8_t sat; - } rec{}; - - rec.lat_e7 = static_cast(lround(pt.lat * 1e7)); - rec.lon_e7 = static_cast(lround(pt.lon * 1e7)); - rec.ts = static_cast(pt.timestamp); - rec.sat = pt.satellites; - f.write(reinterpret_cast(&rec), sizeof(rec)); + f.flush(); } - else - { - const String time_str = isoTime(pt.timestamp); - f.printf("\n", pt.lat, pt.lon); - f.printf(" %.1f\n", 0.0); - f.printf(" \n", time_str.c_str()); - f.print(" \n"); - f.printf(" %.2f\n", 0.0); - f.printf(" %.1f\n", 0.0); - f.printf(" %.1f\n", 0.0); - f.printf(" %u\n", (unsigned)pt.satellites); - f.print(" \n"); - f.print("\n"); - } - f.flush(); f.close(); - last_point_ = pt; - last_point_valid_ = true; - last_point_time_ = pt.timestamp; - last_point_ms_ = now_ms; + pending_point_count_ = 0; + last_flush_ms_ = millis(); + return true; } - if (mutex_) - { - xSemaphoreGive(mutex_); - } + return false; } bool TrackRecorder::restoreActiveSession() @@ -603,6 +712,8 @@ bool TrackRecorder::restoreActiveSession() last_point_valid_ = false; last_point_time_ = 0; last_point_ms_ = 0; + pending_point_count_ = 0; + last_flush_ms_ = millis(); format_ = static_cast(header.format); manual_recording_ = (header.flags & kActiveFlagManual) != 0; @@ -708,7 +819,7 @@ size_t TrackRecorder::listTracks(String* out_names, size_t max_names) const return 0; } - if (mutex_ && xSemaphoreTake(mutex_, pdMS_TO_TICKS(200)) != pdTRUE) + if (mutex_ && xSemaphoreTake(mutex_, pdMS_TO_TICKS(20)) != pdTRUE) { return 0; } @@ -721,7 +832,7 @@ size_t TrackRecorder::listTracks(String* out_names, size_t max_names) const } }; - ::platform::esp::common::SharedSpiLockGuard spi_guard(kSdTransactionLockWait); + ::platform::esp::common::SharedSpiLockGuard spi_guard(0); if (!spi_guard.locked()) { release_mutex(); diff --git a/platform/esp/arduino_common/src/platform_ui_tracker_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_tracker_runtime.cpp index 92df668c..30d5c578 100644 --- a/platform/esp/arduino_common/src/platform_ui_tracker_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_tracker_runtime.cpp @@ -4,8 +4,99 @@ #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "platform/ui/device_runtime.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +#include + namespace platform::ui::tracker { +namespace +{ + +enum class TrackerCommand : uint8_t +{ + Start, + Stop, +}; + +QueueHandle_t s_command_queue = nullptr; +TaskHandle_t s_worker_task = nullptr; +std::atomic_bool s_desired_recording{false}; +std::atomic_bool s_start_pending{false}; +std::atomic_bool s_stop_pending{false}; + +void tracker_worker_task(void*) +{ + TrackerCommand command = TrackerCommand::Start; + while (true) + { + if (xQueueReceive(s_command_queue, &command, portMAX_DELAY) != pdPASS) + { + continue; + } + + switch (command) + { + case TrackerCommand::Start: + { + s_start_pending.store(true); + s_stop_pending.store(false); + const bool ok = ::gps::TrackRecorder::getInstance().start(); + s_desired_recording.store(ok); + s_start_pending.store(false); + break; + } + case TrackerCommand::Stop: + s_stop_pending.store(true); + s_start_pending.store(false); + ::gps::TrackRecorder::getInstance().stop(); + s_desired_recording.store(false); + s_stop_pending.store(false); + break; + } + } +} + +bool ensure_worker() +{ + if (s_command_queue == nullptr) + { + s_command_queue = xQueueCreate(4, sizeof(TrackerCommand)); + if (s_command_queue == nullptr) + { + return false; + } + } + if (s_worker_task == nullptr) + { + BaseType_t ok = xTaskCreate(tracker_worker_task, + "tracker_io", + 4096, + nullptr, + 1, + &s_worker_task); + if (ok != pdPASS) + { + s_worker_task = nullptr; + return false; + } + } + return true; +} + +bool enqueue_command(TrackerCommand command) +{ + if (!ensure_worker()) + { + return false; + } + xQueueReset(s_command_queue); + return xQueueSend(s_command_queue, &command, 0) == pdPASS; +} + +} // namespace bool is_supported() { @@ -14,17 +105,34 @@ bool is_supported() bool is_recording() { - return ::gps::TrackRecorder::getInstance().isRecording(); + return s_desired_recording.load() || + s_start_pending.load() || + (!s_stop_pending.load() && ::gps::TrackRecorder::getInstance().isRecording()); } bool start_recording() { - return ::gps::TrackRecorder::getInstance().start(); + s_desired_recording.store(true); + s_start_pending.store(true); + s_stop_pending.store(false); + if (enqueue_command(TrackerCommand::Start)) + { + return true; + } + s_start_pending.store(false); + s_desired_recording.store(::gps::TrackRecorder::getInstance().isRecording()); + return false; } void stop_recording() { - ::gps::TrackRecorder::getInstance().stop(); + s_desired_recording.store(false); + s_stop_pending.store(true); + s_start_pending.store(false); + if (!enqueue_command(TrackerCommand::Stop)) + { + s_stop_pending.store(false); + } } bool current_path(std::string& out_path) @@ -57,6 +165,15 @@ bool list_tracks(std::vector& out_tracks, std::size_t max_count) return !out_tracks.empty(); } +void poll() +{ + (void)ensure_worker(); + if (!s_start_pending.load() && !s_stop_pending.load()) + { + s_desired_recording.store(::gps::TrackRecorder::getInstance().isRecording()); + } +} + bool remove_track(const std::string& path) { if (!platform::ui::device::sd_ready() || path.empty()) diff --git a/platform/esp/arduino_common/src/platform_ui_usb_support_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_usb_support_runtime.cpp index 988f171c..c7ffe405 100644 --- a/platform/esp/arduino_common/src/platform_ui_usb_support_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_usb_support_runtime.cpp @@ -56,7 +56,7 @@ bool s_backend_started = false; int32_t usbReadCallback(uint32_t lba, uint32_t offset, void* buffer, uint32_t bufsize) { (void)offset; - ::platform::esp::common::SharedSpiLockGuard spi_guard{}; + ::platform::esp::common::SharedSpiLockGuard spi_guard(pdMS_TO_TICKS(50)); if (!spi_guard.locked()) { USB_MSC_LOG("read lock failed lba=%lu size=%lu\n", @@ -87,7 +87,7 @@ int32_t usbReadCallback(uint32_t lba, uint32_t offset, void* buffer, uint32_t bu int32_t usbWriteCallback(uint32_t lba, uint32_t offset, uint8_t* buffer, uint32_t bufsize) { (void)offset; - ::platform::esp::common::SharedSpiLockGuard spi_guard{}; + ::platform::esp::common::SharedSpiLockGuard spi_guard(pdMS_TO_TICKS(50)); if (!spi_guard.locked()) { USB_MSC_LOG("write lock failed lba=%lu size=%lu\n", diff --git a/platform/esp/arduino_common/src/sstv/sstv_service.cpp b/platform/esp/arduino_common/src/sstv/sstv_service.cpp index 245f8ea7..5323055c 100644 --- a/platform/esp/arduino_common/src/sstv/sstv_service.cpp +++ b/platform/esp/arduino_common/src/sstv/sstv_service.cpp @@ -475,7 +475,12 @@ bool save_frame_to_sd() set_error("No frame"); return false; } - ::platform::esp::common::SharedSpiLockGuard guard; + ::platform::esp::common::SharedSpiLockGuard guard(pdMS_TO_TICKS(200)); + if (!guard.locked()) + { + set_error("SD busy"); + return false; + } #if defined(TRAIL_MATE_ESP_BOARD_TAB5) if (SD.cardType() == CARD_NONE) #else diff --git a/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp b/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp index 84886de1..b8635ac1 100644 --- a/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp +++ b/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp @@ -7,6 +7,7 @@ #include "ui/team_persistence/team_ui_snapshot_codec.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/shared_spi_lock.h" #include "sys/clock.h" #include #include @@ -67,6 +68,9 @@ constexpr uint32_t kPosHeaderSize = 24; constexpr uint32_t kPosMinIntervalSec = 15; constexpr uint32_t kPosMaxIntervalSec = 30; constexpr float kPosMinDistanceM = 20.0f; +constexpr TickType_t kTeamStoreLoadWait = pdMS_TO_TICKS(60); +constexpr TickType_t kTeamStoreReadWait = pdMS_TO_TICKS(20); +constexpr TickType_t kTeamStoreWriteWait = pdMS_TO_TICKS(20); constexpr size_t kChatlogMaxBytes = 256 * 1024; constexpr uint32_t kMinValidEpoch = 1577836800U; // 2020-01-01 @@ -892,16 +896,30 @@ bool write_posring_header(SdRuntimeFile& f, uint32_t write_offset) return true; } +TeamUiSnapshot s_cached_snapshot{}; +bool s_has_cached_snapshot = false; + class TeamUiSnapshotStorePersisted : public ITeamUiSnapshotStore { public: bool load(TeamUiSnapshot& out) override { + if (s_has_cached_snapshot) + { + out = s_cached_snapshot; + return true; + } if (!sd_card_ready()) { return false; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreLoadWait); + if (!spi_guard.locked()) + { + return false; + } + TeamUiSnapshot snap; std::string dir; bool has_current = read_current_dir(dir); @@ -939,19 +957,27 @@ class TeamUiSnapshotStorePersisted : public ITeamUiSnapshotStore std::fill(snap.team_psk.begin(), snap.team_psk.end(), 0); } + s_cached_snapshot = snap; + s_has_cached_snapshot = true; out = snap; return true; } void save(const TeamUiSnapshot& in) override { + s_cached_snapshot = in; + s_has_cached_snapshot = true; if (!sd_card_ready()) { return; } if (!in.has_team_id || !in.in_team) { - clear_current_dir(); + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreWriteWait); + if (spi_guard.locked()) + { + clear_current_dir(); + } return; } @@ -970,6 +996,12 @@ class TeamUiSnapshotStorePersisted : public ITeamUiSnapshotStore return; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreWriteWait); + if (!spi_guard.locked()) + { + return; + } + std::string dir = team_dir_from_id(in.team_id); std::string dir_path = std::string(kBaseDir) + "/" + dir; write_current_dir(dir); @@ -992,7 +1024,12 @@ class TeamUiSnapshotStorePersisted : public ITeamUiSnapshotStore void clear() override { - clear_current_dir(); + s_has_cached_snapshot = false; + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreWriteWait); + if (spi_guard.locked()) + { + clear_current_dir(); + } } private: @@ -1108,6 +1145,11 @@ bool team_ui_append_key_event(const TeamId& team_id, const uint8_t* payload, size_t len) { + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreWriteWait); + if (!spi_guard.locked()) + { + return false; + } return append_event(team_id, type, event_seq, ts, payload, len); } @@ -1124,6 +1166,12 @@ bool team_ui_posring_append(const TeamId& team_id, return false; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreWriteWait); + if (!spi_guard.locked()) + { + return false; + } + std::string dir_path; if (!ensure_team_dir_for_id(team_id, dir_path)) { @@ -1184,6 +1232,11 @@ bool team_ui_posring_load_latest(const TeamId& team_id, { return false; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreReadWait); + if (!spi_guard.locked()) + { + return false; + } std::string dir = team_dir_from_id(team_id); std::string dir_path = std::string(kBaseDir) + "/" + dir; std::string path = dir_path + "/" + kPosringName; @@ -1293,6 +1346,12 @@ bool TeamUiSdChatLogStore::appendStructured(const TeamId& team_id, team::proto::TeamChatType type, const std::vector& payload) { + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreWriteWait); + if (!spi_guard.locked()) + { + return false; + } + std::string dir_path; if (!ensure_team_dir_for_id(team_id, dir_path)) { @@ -1370,6 +1429,11 @@ bool TeamUiSdChatLogStore::loadRecent(const TeamId& team_id, { return false; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreReadWait); + if (!spi_guard.locked()) + { + return false; + } std::string dir_path; if (!ensure_team_dir_for_id(team_id, dir_path)) { @@ -1507,6 +1571,11 @@ bool team_ui_save_keys_now(const TeamId& team_id, { return false; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreWriteWait); + if (!spi_guard.locked()) + { + return false; + } std::string dir_path; if (!ensure_team_dir_for_id(team_id, dir_path)) { @@ -1529,6 +1598,11 @@ bool team_ui_get_member_track_path(const TeamId& team_id, { return false; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreWriteWait); + if (!spi_guard.locked()) + { + return false; + } std::string dir_path; if (!ensure_team_dir_for_id_internal(team_id, dir_path, false)) { @@ -1571,6 +1645,12 @@ bool team_ui_append_member_track(const TeamId& team_id, return false; } + ::platform::esp::common::SharedSpiLockGuard spi_guard(kTeamStoreWriteWait); + if (!spi_guard.locked()) + { + return false; + } + std::string dir_path; if (!ensure_team_dir_for_id_internal(team_id, dir_path, 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 f032da64..f4794ad9 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 @@ -6,7 +6,6 @@ #include "ui/widgets/map/map_tiles.h" #include "freertos/FreeRTOS.h" #include "lvgl.h" -#include "platform/esp/common/shared_spi_lock.h" #include "src/draw/lv_image_decoder_private.h" #include "src/misc/cache/instance/lv_image_cache.h" #include "sys/clock.h" @@ -1119,17 +1118,6 @@ static void load_tile_image(TileContext& ctx, MapTile& tile) return; } - ::platform::esp::common::SharedSpiLockGuard spi_lock(pdMS_TO_TICKS(20)); - if (!spi_lock.locked()) - { - GPS_LOG("[GPS] load_tile_image: SPI lock busy, deferring tile %d/%d/%d\n", - tile.z, - fmt_tile_coord(tile.x), - fmt_tile_coord(tile.y)); - tile.last_used_ms = sys::millis_now(); - return; - } - char path[96]; build_base_tile_path(tile.z, tile.x, tile.y, g_active_map_source, path, sizeof(path)); @@ -1959,8 +1947,8 @@ void tile_loader_step(TileContext& ctx) sync_render_settings(ctx); const uint32_t start_ms = sys::millis_now(); - const uint32_t budget_ms = 12; - const int max_tiles_per_step = 3; + const uint32_t budget_ms = 6; + const int max_tiles_per_step = 1; MapTile* attempted[max_tiles_per_step] = {NULL}; int attempted_count = 0; diff --git a/platform/esp/common/include/platform/esp/common/shared_spi_lock.h b/platform/esp/common/include/platform/esp/common/shared_spi_lock.h index e50daa51..196a26b3 100644 --- a/platform/esp/common/include/platform/esp/common/shared_spi_lock.h +++ b/platform/esp/common/include/platform/esp/common/shared_spi_lock.h @@ -8,13 +8,13 @@ namespace platform::esp::common // The shared SPI lock arbitrates ownership of the board-level SPI bus on // devices where display, SD, radio, NFC, or other peripherals physically // share one controller. It is not display-specific. -bool shared_spi_lock(TickType_t xTicksToWait = portMAX_DELAY); +bool shared_spi_lock(TickType_t xTicksToWait); void shared_spi_unlock(); class SharedSpiLockGuard { public: - explicit SharedSpiLockGuard(TickType_t wait_ticks = portMAX_DELAY) + explicit SharedSpiLockGuard(TickType_t wait_ticks) : locked_(shared_spi_lock(wait_ticks)) { } @@ -43,7 +43,7 @@ class SharedSpiLockGuard // Legacy compatibility alias. New code should use shared_spi_lock / // shared_spi_unlock or SharedSpiLockGuard so call sites describe the bus they // are arbitrating rather than implying the lock belongs only to display code. -inline bool display_spi_lock(TickType_t xTicksToWait = portMAX_DELAY) +inline bool display_spi_lock(TickType_t xTicksToWait) { return ::platform::esp::common::shared_spi_lock(xTicksToWait); }