// Copyright (c) 2026 Pyxis contributors // SPDX-License-Identifier: MIT #include "MapScreen.h" #include "MapTileLookupPolicy.h" #include "MapTileStreamReader.h" #include "Hardware/TDeck/MapTileCa.h" #ifdef ARDUINO #include "Theme.h" #include "../LVGL/LVGLInit.h" #include "../LVGL/LVGLLock.h" #include #include #define LODEPNG_NO_COMPILE_CPP extern "C" { #include } #include #include #include namespace UI { namespace LXMF { namespace { constexpr std::size_t TILE_PIXEL_COUNT = 256U * 256U; constexpr std::uint32_t STORE_BYTE_QUOTA = 64U * 1024U * 1024U; constexpr std::uint16_t STORE_ENTRY_CAPACITY = 128U; #ifndef FIRMWARE_VERSION #define FIRMWARE_VERSION "dev" #endif Hardware::TDeck::MapTileDownloadConfig makeDownloadConfig() { Hardware::TDeck::MapTileDownloadConfig config; config.ca_certificate = Hardware::TDeck::MAP_TILE_GLOBALSIGN_ROOT_R3; config.firmware_version = FIRMWARE_VERSION; return config; } class PackReadStream final : public Pyxis::MapTileReadStream { public: explicit PackReadStream(Hardware::TDeck::MapTilePack& pack) : pack_(pack) {} Pyxis::MapTileStreamResult begin(const Hardware::TDeck::TileKey& key, std::uint32_t& size) override { const Hardware::TDeck::MapTilePackResult result = pack_.beginGet(key, size); if (result == Hardware::TDeck::MapTilePackResult::OK) return Pyxis::MapTileStreamResult::OK; if (result == Hardware::TDeck::MapTilePackResult::UNCOVERED || result == Hardware::TDeck::MapTilePackResult::TILE_MISSING || result == Hardware::TDeck::MapTilePackResult::NOT_INITIALIZED) { return Pyxis::MapTileStreamResult::MISS; } if (result == Hardware::TDeck::MapTilePackResult::STORAGE_UNAVAILABLE) { return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; } return Pyxis::MapTileStreamResult::IO_ERROR; } Pyxis::MapTileStreamResult read(std::uint8_t* output, std::size_t capacity, std::size_t& count) override { const Hardware::TDeck::MapTilePackResult result = pack_.readGetChunk(output, capacity, count); if (result == Hardware::TDeck::MapTilePackResult::OK) return Pyxis::MapTileStreamResult::OK; if (result == Hardware::TDeck::MapTilePackResult::STORAGE_UNAVAILABLE) { return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; } return Pyxis::MapTileStreamResult::IO_ERROR; } void end() override { pack_.endGet(); } private: Hardware::TDeck::MapTilePack& pack_; }; class LiveReadStream final : public Pyxis::MapTileReadStream { public: explicit LiveReadStream(Hardware::TDeck::MapTileStore& store) : store_(store) {} Pyxis::MapTileStreamResult begin(const Hardware::TDeck::TileKey& key, std::uint32_t& size) override { const Hardware::TDeck::TileStoreResult result = store_.beginGet(key, size); if (result == Hardware::TDeck::TileStoreResult::OK) return Pyxis::MapTileStreamResult::OK; if (result == Hardware::TDeck::TileStoreResult::MISS) return Pyxis::MapTileStreamResult::MISS; if (result == Hardware::TDeck::TileStoreResult::STORAGE_UNAVAILABLE || result == Hardware::TDeck::TileStoreResult::NOT_INITIALIZED) { return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; } return Pyxis::MapTileStreamResult::IO_ERROR; } Pyxis::MapTileStreamResult read(std::uint8_t* output, std::size_t capacity, std::size_t& count) override { const Hardware::TDeck::TileStoreResult result = store_.readGetChunk(output, capacity, count); if (result == Hardware::TDeck::TileStoreResult::OK) return Pyxis::MapTileStreamResult::OK; if (result == Hardware::TDeck::TileStoreResult::STORAGE_UNAVAILABLE) { return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; } return Pyxis::MapTileStreamResult::IO_ERROR; } void end() override { store_.endGet(); } private: Hardware::TDeck::MapTileStore& store_; }; class AtomicStopSource final : public Pyxis::MapTileStopSource { public: explicit AtomicStopSource(const std::atomic& stop) : stop_(stop) {} bool stopRequested() const override { return stop_.load(std::memory_order_acquire); } private: const std::atomic& stop_; }; lv_obj_t* createToolbarButton(lv_obj_t* parent, const char* text, lv_event_cb_t callback, void* context, lv_coord_t width) { lv_obj_t* button = lv_btn_create(parent); lv_obj_set_size(button, width, 26); lv_obj_set_style_pad_all(button, 0, 0); lv_obj_set_style_bg_color(button, Theme::surfaceInput(), 0); lv_obj_set_style_bg_color(button, Theme::primaryPressed(), LV_STATE_PRESSED); lv_obj_add_event_cb(button, callback, LV_EVENT_CLICKED, context); lv_obj_t* label = lv_label_create(button); lv_label_set_text(label, text); lv_obj_center(label); return button; } } // namespace using Hardware::TDeck::MapTileStore; static_assert(MapTileStore::HARD_MAX_ENTRIES == 128, "map cache index contract changed; revisit bounded RAM budget"); static_assert(sizeof(lv_color_t) == 2U, "offline tile buffers assume LVGL RGB565 true color"); MapScreen::MapScreen(lv_obj_t* parent) : screen_(nullptr), toolbar_(nullptr), viewport_(nullptr), status_label_(nullptr), attribution_label_(nullptr), zoom_label_(nullptr), zoom_out_button_(nullptr), zoom_in_button_(nullptr), recenter_button_(nullptr), pan_buttons_{}, tile_images_{}, tile_descriptors_{}, tile_pixels_{}, decoded_tile_cache_(TILE_PIXEL_COUNT), decoded_cache_pixels_{}, approximation_halos_{}, markers_{}, marker_labels_{}, presenter_(), storage_(), store_config_{STORE_ENTRY_CAPACITY, STORE_BYTE_QUOTA, MAX_COMPRESSED_TILE_BYTES}, store_(storage_, store_config_), pack_(storage_), download_store_(store_), download_transport_(), download_clock_(), download_policy_(), download_config_(makeDownloadConfig()), downloader_(download_store_, download_transport_, download_clock_, download_policy_, download_config_), downloads_enabled_(false), screen_visible_(false), pack_refresh_epoch_(0U), transport_close_epoch_(0U), download_failed_frame_epoch_(0U), decode_failed_keys_{}, decode_failed_generations_{}, compressed_staging_(nullptr), state_mutex_(nullptr), transport_start_mutex_(nullptr), worker_task_(nullptr), worker_exited_(true), worker_started_(false), store_initialized_(false), requests_released_(false), has_location_fix_(false), center_initialized_(false), current_location_{}, dragging_(false), last_drag_point_{0, 0}, back_callback_() { LVGL_LOCK(); state_mutex_ = xSemaphoreCreateMutex(); transport_start_mutex_ = xSemaphoreCreateMutex(); // Reserve the mandatory SD/PNG staging buffer before optional decoded // cache entries. A partial cache must never prevent the worker starting. compressed_staging_ = static_cast(heap_caps_malloc( MAX_COMPRESSED_TILE_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); screen_ = lv_obj_create(parent ? parent : lv_scr_act()); lv_obj_set_size(screen_, 320, 240); lv_obj_set_style_pad_all(screen_, 0, 0); lv_obj_set_style_border_width(screen_, 0, 0); lv_obj_set_style_radius(screen_, 0, 0); lv_obj_set_style_bg_color(screen_, Theme::surface(), 0); lv_obj_clear_flag(screen_, LV_OBJ_FLAG_SCROLLABLE); toolbar_ = lv_obj_create(screen_); lv_obj_set_size(toolbar_, 320, 32); lv_obj_align(toolbar_, LV_ALIGN_TOP_MID, 0, 0); lv_obj_set_style_pad_all(toolbar_, 2, 0); lv_obj_set_style_pad_gap(toolbar_, 3, 0); lv_obj_set_style_border_width(toolbar_, 0, 0); lv_obj_set_style_radius(toolbar_, 0, 0); lv_obj_set_style_bg_color(toolbar_, Theme::surfaceHeader(), 0); lv_obj_set_flex_flow(toolbar_, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(toolbar_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); back_button_ = createToolbarButton(toolbar_, LV_SYMBOL_LEFT, onBack, this, 36); zoom_out_button_ = createToolbarButton(toolbar_, LV_SYMBOL_MINUS, onZoomOut, this, 36); zoom_label_ = lv_label_create(toolbar_); lv_obj_set_width(zoom_label_, 62); lv_obj_set_style_text_align(zoom_label_, LV_TEXT_ALIGN_CENTER, 0); lv_label_set_text(zoom_label_, "z2"); zoom_in_button_ = createToolbarButton(toolbar_, LV_SYMBOL_PLUS, onZoomIn, this, 36); recenter_button_ = createToolbarButton(toolbar_, LV_SYMBOL_GPS, onRecenter, this, 44); viewport_ = lv_obj_create(screen_); lv_obj_set_size(viewport_, Pyxis::MapScreenPresenter::VIEWPORT_WIDTH, Pyxis::MapScreenPresenter::VIEWPORT_HEIGHT); lv_obj_align(viewport_, LV_ALIGN_TOP_MID, 0, 32); lv_obj_set_style_pad_all(viewport_, 0, 0); lv_obj_set_style_border_width(viewport_, 0, 0); lv_obj_set_style_radius(viewport_, 0, 0); lv_obj_set_style_bg_color(viewport_, lv_color_hex(0x25282b), 0); lv_obj_set_style_bg_opa(viewport_, LV_OPA_COVER, 0); lv_obj_add_flag(viewport_, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_flag(viewport_, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(viewport_, onMapPressed, LV_EVENT_PRESSED, this); lv_obj_add_event_cb(viewport_, onMapPressing, LV_EVENT_PRESSING, this); lv_obj_add_event_cb(viewport_, onMapReleased, LV_EVENT_RELEASED, this); lv_obj_add_event_cb(viewport_, onMapReleased, LV_EVENT_PRESS_LOST, this); for (std::size_t index = 0; index < TILE_COUNT; ++index) { tile_pixels_[index] = static_cast(heap_caps_malloc( TILE_PIXEL_COUNT * sizeof(lv_color_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); if (tile_pixels_[index]) { for (std::size_t pixel = 0; pixel < TILE_PIXEL_COUNT; ++pixel) { const bool light = (((pixel % 256U) / 32U) + ((pixel / 256U) / 32U)) % 2U == 0U; tile_pixels_[index][pixel] = lv_color_hex(light ? 0x303438 : 0x272a2e); } } lv_img_dsc_t& descriptor = tile_descriptors_[index]; std::memset(&descriptor, 0, sizeof(descriptor)); descriptor.header.always_zero = 0; descriptor.header.w = 256; descriptor.header.h = 256; descriptor.header.cf = LV_IMG_CF_TRUE_COLOR; descriptor.data_size = TILE_PIXEL_COUNT * sizeof(lv_color_t); descriptor.data = reinterpret_cast( tile_pixels_[index]); tile_images_[index] = lv_img_create(viewport_); lv_obj_add_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN); } if (compressed_staging_) { for (std::size_t index = 0U; index < Pyxis::DecodedTileCache::CAPACITY; ++index) { decoded_cache_pixels_[index] = static_cast(heap_caps_malloc( TILE_PIXEL_COUNT * sizeof(std::uint16_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); if (decoded_cache_pixels_[index]) { (void)decoded_tile_cache_.attach(index, decoded_cache_pixels_[index]); } } } for (std::size_t index = 0; index < MARKER_COUNT; ++index) { approximation_halos_[index] = lv_obj_create(viewport_); lv_obj_set_style_radius(approximation_halos_[index], LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(approximation_halos_[index], Theme::info(), 0); lv_obj_set_style_bg_opa(approximation_halos_[index], LV_OPA_20, 0); lv_obj_set_style_border_width(approximation_halos_[index], 1, 0); lv_obj_set_style_border_color(approximation_halos_[index], Theme::info(), 0); lv_obj_set_style_pad_all(approximation_halos_[index], 0, 0); lv_obj_clear_flag(approximation_halos_[index], LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(approximation_halos_[index], LV_OBJ_FLAG_HIDDEN); markers_[index] = lv_obj_create(viewport_); lv_obj_set_size(markers_[index], 10, 10); lv_obj_set_style_radius(markers_[index], LV_RADIUS_CIRCLE, 0); lv_obj_set_style_border_width(markers_[index], 2, 0); lv_obj_set_style_border_color(markers_[index], lv_color_white(), 0); lv_obj_set_style_pad_all(markers_[index], 0, 0); lv_obj_clear_flag(markers_[index], LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(markers_[index], LV_OBJ_FLAG_HIDDEN); marker_labels_[index] = lv_label_create(viewport_); lv_label_set_text(marker_labels_[index], ""); lv_obj_set_style_text_font(marker_labels_[index], &lv_font_montserrat_12, 0); lv_obj_add_flag(marker_labels_[index], LV_OBJ_FLAG_HIDDEN); } static const char* pan_symbols[4] = { LV_SYMBOL_UP, LV_SYMBOL_DOWN, LV_SYMBOL_LEFT, LV_SYMBOL_RIGHT}; static const lv_align_t pan_alignments[4] = { LV_ALIGN_TOP_MID, LV_ALIGN_BOTTOM_MID, LV_ALIGN_LEFT_MID, LV_ALIGN_RIGHT_MID}; static const lv_coord_t pan_x[4] = {0, 0, 2, -2}; static const lv_coord_t pan_y[4] = {2, -2, 0, 0}; for (std::size_t index = 0; index < 4U; ++index) { pan_buttons_[index] = createToolbarButton( viewport_, pan_symbols[index], onPan, this, 30); lv_obj_set_user_data(pan_buttons_[index], reinterpret_cast(index)); lv_obj_align(pan_buttons_[index], pan_alignments[index], pan_x[index], pan_y[index]); lv_obj_set_style_bg_opa(pan_buttons_[index], LV_OPA_60, 0); } status_label_ = lv_label_create(viewport_); lv_label_set_text(status_label_, "Offline tiles"); lv_obj_set_style_text_color(status_label_, Theme::textTertiary(), 0); lv_obj_set_style_bg_color(status_label_, Theme::surfaceElevated(), 0); lv_obj_set_style_bg_opa(status_label_, LV_OPA_70, 0); lv_obj_set_style_pad_all(status_label_, 3, 0); lv_obj_align(status_label_, LV_ALIGN_BOTTOM_LEFT, 4, -4); attribution_label_ = lv_label_create(viewport_); lv_label_set_text(attribution_label_, "© OpenStreetMap contributors"); lv_obj_set_style_text_color(attribution_label_, Theme::textTertiary(), 0); lv_obj_set_style_text_font(attribution_label_, &lv_font_montserrat_12, 0); lv_obj_set_style_bg_color(attribution_label_, Theme::surfaceElevated(), 0); lv_obj_set_style_bg_opa(attribution_label_, LV_OPA_70, 0); lv_obj_set_style_pad_all(attribution_label_, 2, 0); lv_obj_align(attribution_label_, LV_ALIGN_BOTTOM_RIGHT, -3, -3); lv_obj_add_flag(screen_, LV_OBJ_FLAG_HIDDEN); } MapScreen::~MapScreen() { stopWorker(); LVGL_LOCK(); if (screen_) lv_obj_del(screen_); screen_ = nullptr; for (std::size_t index = 0; index < TILE_COUNT; ++index) { if (tile_pixels_[index]) heap_caps_free(tile_pixels_[index]); tile_pixels_[index] = nullptr; } for (std::size_t index = 0U; index < Pyxis::DecodedTileCache::CAPACITY; ++index) { if (decoded_cache_pixels_[index]) heap_caps_free(decoded_cache_pixels_[index]); decoded_cache_pixels_[index] = nullptr; } if (compressed_staging_) heap_caps_free(compressed_staging_); compressed_staging_ = nullptr; if (state_mutex_) vSemaphoreDelete(state_mutex_); state_mutex_ = nullptr; if (transport_start_mutex_) vSemaphoreDelete(transport_start_mutex_); transport_start_mutex_ = nullptr; } bool MapScreen::lockState(TickType_t ticks) { return state_mutex_ && xSemaphoreTake(state_mutex_, ticks) == pdTRUE; } void MapScreen::unlockState() { xSemaphoreGive(state_mutex_); } void MapScreen::synchronizeTransportStart() { if (transport_start_mutex_ && xSemaphoreTake(transport_start_mutex_, portMAX_DELAY) == pdTRUE) { xSemaphoreGive(transport_start_mutex_); } } void MapScreen::setDownloadEnabled(bool enabled) { const bool was_enabled = downloads_enabled_.exchange(enabled, std::memory_order_acq_rel); if (was_enabled && !enabled) { transport_close_epoch_.fetch_add(1U, std::memory_order_acq_rel); } if (worker_task_) xTaskNotifyGive(worker_task_); if (!enabled) synchronizeTransportStart(); } bool MapScreen::startWorker() { if (worker_started_) return true; if (!state_mutex_ || !transport_start_mutex_ || !compressed_staging_) return false; stop_requested_.store(false, std::memory_order_release); worker_exited_.store(false, std::memory_order_release); const BaseType_t created = xTaskCreatePinnedToCore( workerEntry, "map-tile-worker", 16384, this, 1, &worker_task_, 0); if (created != pdPASS) { worker_exited_.store(true, std::memory_order_release); worker_task_ = nullptr; return false; } worker_started_ = true; return true; } void MapScreen::stopWorker() { if (!worker_started_) return; stop_requested_.store(true, std::memory_order_release); if (worker_task_) xTaskNotifyGive(worker_task_); const TickType_t started = xTaskGetTickCount(); const TickType_t shutdown_limit = pdMS_TO_TICKS(20000); while (!worker_exited_.load(std::memory_order_acquire)) { if ((xTaskGetTickCount() - started) >= shutdown_limit) { // Do not destroy storage/decoder/transport state while the worker // may still own it. A controlled restart is safer than UAF or an // unbounded UI teardown after a wedged hardware operation. esp_restart(); for (;;) vTaskDelay(pdMS_TO_TICKS(1000)); } vTaskDelay(pdMS_TO_TICKS(1)); } worker_task_ = nullptr; worker_started_ = false; } void MapScreen::workerEntry(void* context) { static_cast(context)->workerLoop(); } void MapScreen::workerLoop() { Hardware::TDeck::TileStoreResult initialized = store_.initialize(); store_initialized_ = initialized == Hardware::TDeck::TileStoreResult::OK; std::uint32_t handled_pack_refresh_epoch = pack_refresh_epoch_.load(std::memory_order_acquire); (void)pack_.initialize(); std::uint32_t handled_close_epoch = transport_close_epoch_.load(std::memory_order_acquire); while (!stop_requested_.load(std::memory_order_acquire)) { // The worker exclusively owns HTTP/TLS teardown. Keep a successful // session across frame/zoom boundaries only while the map is visible // and online acquisition remains explicitly enabled. const std::uint32_t close_epoch = transport_close_epoch_.load(std::memory_order_acquire); if (close_epoch != handled_close_epoch) { downloader_.setEnabled(false); download_transport_.disconnectIdle(); handled_close_epoch = close_epoch; } const bool screen_visible = screen_visible_.load(std::memory_order_acquire); const std::uint32_t pack_refresh_epoch = pack_refresh_epoch_.load(std::memory_order_acquire); if (pack_refresh_epoch != handled_pack_refresh_epoch) { const bool had_selection = pack_.hasSelection(); char previous_pack_id[Pyxis::MapPackManifest::PACK_ID_CAPACITY] = {}; if (had_selection) { std::memcpy(previous_pack_id, pack_.metadata().pack_id, sizeof(previous_pack_id)); } (void)pack_.initialize(); const bool has_selection = pack_.hasSelection(); if (had_selection != has_selection || (had_selection && has_selection && std::strcmp(previous_pack_id, pack_.metadata().pack_id) != 0)) { decoded_tile_cache_.clear(); } handled_pack_refresh_epoch = pack_refresh_epoch; } Pyxis::MapTileRequest request{}; bool have_request = false; if (lockState(pdMS_TO_TICKS(20))) { if (requests_released_ && screen_visible) { have_request = presenter_.takeRequest(request); } unlockState(); } if (!have_request) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)); continue; } Pyxis::MapTileCompletion completion{}; completion.generation = request.generation; completion.frame_epoch = request.frame_epoch; completion.slot_token = request.slot_token; completion.slot_index = request.slot_index; completion.key = request.key; completion.result = loadTile(request, handled_close_epoch); if (lockState(portMAX_DELAY)) { (void)presenter_.publishCompletion(completion); unlockState(); } } download_transport_.disconnectIdle(); worker_exited_.store(true, std::memory_order_release); vTaskDelete(nullptr); } Pyxis::MapTileLoadResult MapScreen::loadTile( const Pyxis::MapTileRequest& request, std::uint32_t transport_epoch) { const Pyxis::MapTileLoadResult cached = readTile(request); const bool decode_failed = decodeFailedFor(request); if (!Pyxis::MapTileLookupPolicy::shouldStartOnline( cached, downloads_enabled_.load(std::memory_order_acquire), screen_visible_.load(std::memory_order_acquire), decode_failed, transport_epoch, transport_close_epoch_.load(std::memory_order_acquire))) { return decode_failed ? Pyxis::MapTileLoadResult::DOWNLOAD_FAILED : cached; } const Pyxis::MapTileLoadResult downloaded = downloadTile(request, transport_epoch); if (downloaded != Pyxis::MapTileLoadResult::READY) return downloaded; const Pyxis::MapTileLoadResult decoded = readTile(request); if (decoded == Pyxis::MapTileLoadResult::INVALID_PNG) { markDecodeFailed(request); return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; } return decoded; } bool MapScreen::decodeFailedFor(const Pyxis::MapTileRequest& request) const { for (std::size_t index = 0U; index < TILE_COUNT; ++index) { const Hardware::TDeck::TileKey& key = decode_failed_keys_[index]; if (decode_failed_generations_[index] == request.generation && key.zoom == request.key.zoom && key.x == request.key.x && key.y == request.key.y) return true; } return false; } void MapScreen::markDecodeFailed(const Pyxis::MapTileRequest& request) { const std::size_t index = static_cast(request.slot_index) % TILE_COUNT; decode_failed_keys_[index] = request.key; decode_failed_generations_[index] = request.generation; } Pyxis::MapTileLoadResult MapScreen::downloadTile( const Pyxis::MapTileRequest& request, std::uint32_t transport_epoch) { const bool enabled = downloads_enabled_.load(std::memory_order_acquire); downloader_.setEnabled(enabled); if (!enabled || !screen_visible_.load(std::memory_order_acquire) || transport_close_epoch_.load(std::memory_order_acquire) != transport_epoch) { downloader_.setEnabled(false); download_transport_.disconnectIdle(); return Pyxis::MapTileLoadResult::MISS; } if (download_failed_frame_epoch_ == request.frame_epoch) { return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; } Hardware::TDeck::MapTileDownloadResult ignored{}; while (downloader_.takeResult(ignored)) {} const Hardware::TDeck::MapTileEnqueueResult queued = downloader_.enqueue(request.key, request.frame_epoch); if (queued != Hardware::TDeck::MapTileEnqueueResult::ACCEPTED) { return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; } while (downloader_.isBusy()) { if (stop_requested_.load(std::memory_order_acquire) || !downloads_enabled_.load(std::memory_order_acquire) || !screen_visible_.load(std::memory_order_acquire) || transport_close_epoch_.load(std::memory_order_acquire) != transport_epoch) { downloader_.setEnabled(false); download_transport_.disconnectIdle(); return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; } bool stale = false; if (lockState(pdMS_TO_TICKS(20))) { stale = presenter_.generation() != request.generation || presenter_.frameEpoch() != request.frame_epoch; unlockState(); } if (stale) { (void)downloader_.cancelGeneration(request.frame_epoch); download_transport_.disconnectIdle(); } if (downloader_.willStartTransportOnNextPump()) { if (xSemaphoreTake(transport_start_mutex_, portMAX_DELAY) == pdTRUE) { const bool start_allowed = !stop_requested_.load(std::memory_order_acquire) && downloads_enabled_.load(std::memory_order_acquire) && screen_visible_.load(std::memory_order_acquire) && transport_close_epoch_.load(std::memory_order_acquire) == transport_epoch; if (start_allowed) { (void)downloader_.pump(); } else { downloader_.setEnabled(false); download_transport_.disconnectIdle(); } xSemaphoreGive(transport_start_mutex_); } } else { (void)downloader_.pump(); } if (downloader_.isBusy()) vTaskDelay(pdMS_TO_TICKS(1)); } Hardware::TDeck::MapTileDownloadResult result{}; while (downloader_.takeResult(result)) { if (result.generation == request.frame_epoch && result.key.zoom == request.key.zoom && result.key.x == request.key.x && result.key.y == request.key.y) { if (result.code == Hardware::TDeck::MapTileResultCode::SUCCESS) { return Pyxis::MapTileLoadResult::READY; } download_failed_frame_epoch_ = request.frame_epoch; download_transport_.disconnectIdle(); return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; } } download_failed_frame_epoch_ = request.frame_epoch; download_transport_.disconnectIdle(); return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; } Pyxis::MapTileLoadResult MapScreen::readTile( const Pyxis::MapTileRequest& request) { if (request.slot_index < TILE_COUNT && tile_pixels_[request.slot_index] && decoded_tile_cache_.get( request.key, reinterpret_cast(tile_pixels_[request.slot_index]), TILE_PIXEL_COUNT)) { return Pyxis::MapTileLoadResult::READY; } struct LocalReadContext { MapScreen* screen; const Pyxis::MapTileRequest* request; } context = {this, &request}; return Pyxis::MapTileLookupPolicy::readLocal( &context, [](void* opaque, Pyxis::MapTileLookupPolicy::LocalSource source) -> Pyxis::MapTileLoadResult { LocalReadContext* local = static_cast(opaque); const CompressedTileSource mapped = source == Pyxis::MapTileLookupPolicy::LocalSource::PACK ? CompressedTileSource::PACK : CompressedTileSource::LIVE_STORE; return local->screen->readCompressedTile(*local->request, mapped); }); } Pyxis::MapTileLoadResult MapScreen::readCompressedTile( const Pyxis::MapTileRequest& request, CompressedTileSource source) { if (source == CompressedTileSource::LIVE_STORE && !store_initialized_) { return Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE; } PackReadStream pack_stream(pack_); LiveReadStream live_stream(store_); Pyxis::MapTileReadStream& stream = source == CompressedTileSource::PACK ? static_cast(pack_stream) : static_cast(live_stream); AtomicStopSource stop(stop_requested_); std::size_t total = 0U; const Pyxis::MapTileStreamResult read = Pyxis::MapTileStreamReader::readExact( stream, stop, request.key, compressed_staging_, MAX_COMPRESSED_TILE_BYTES, READ_CHUNK_BYTES, total); if (read == Pyxis::MapTileStreamResult::MISS) { return Pyxis::MapTileLoadResult::MISS; } if (read == Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE) { return Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE; } if (read == Pyxis::MapTileStreamResult::TOO_LARGE) { return Pyxis::MapTileLoadResult::TOO_LARGE; } if (read != Pyxis::MapTileStreamResult::OK) { return Pyxis::MapTileLoadResult::IO_ERROR; } unsigned char* rgb = nullptr; unsigned width = 0U; unsigned height = 0U; LodePNGState decode_state; lodepng_state_init(&decode_state); // Bound the largest legal 256x256 source scanline stream (RGBA16 plus // filter bytes/interlace overhead) independently of compressed size. decode_state.decoder.zlibsettings.max_output_size = 528U * 1024U; unsigned decode_error = lodepng_inspect( &width, &height, &decode_state, compressed_staging_, total); if (decode_error != 0U || width != 256U || height != 256U) { lodepng_state_cleanup(&decode_state); if (source == CompressedTileSource::LIVE_STORE) { const Hardware::TDeck::TileStoreResult removed = store_.removeTile(request.key); return (removed == Hardware::TDeck::TileStoreResult::OK || removed == Hardware::TDeck::TileStoreResult::MISS) ? Pyxis::MapTileLoadResult::INVALID_PNG : Pyxis::MapTileLoadResult::IO_ERROR; } return Pyxis::MapTileLoadResult::INVALID_PNG; } decode_state.info_raw.colortype = LCT_RGB; decode_state.info_raw.bitdepth = 8U; decode_error = lodepng_decode( &rgb, &width, &height, &decode_state, compressed_staging_, total); lodepng_state_cleanup(&decode_state); if (decode_error != 0U || rgb == nullptr || width != 256U || height != 256U) { if (rgb) lv_mem_free(rgb); if (source == CompressedTileSource::LIVE_STORE) { const Hardware::TDeck::TileStoreResult removed = store_.removeTile(request.key); return (removed == Hardware::TDeck::TileStoreResult::OK || removed == Hardware::TDeck::TileStoreResult::MISS) ? Pyxis::MapTileLoadResult::INVALID_PNG : Pyxis::MapTileLoadResult::IO_ERROR; } return Pyxis::MapTileLoadResult::INVALID_PNG; } if (request.slot_index >= TILE_COUNT || !tile_pixels_[request.slot_index]) { lv_mem_free(rgb); return Pyxis::MapTileLoadResult::IO_ERROR; } lv_color_t* pixels = tile_pixels_[request.slot_index]; for (std::size_t index = 0; index < TILE_PIXEL_COUNT; ++index) { const std::size_t source = index * 3U; pixels[index] = lv_color_make(rgb[source], rgb[source + 1U], rgb[source + 2U]); } lv_mem_free(rgb); (void)decoded_tile_cache_.put( request.key, reinterpret_cast(pixels), TILE_PIXEL_COUNT); return Pyxis::MapTileLoadResult::READY; } void MapScreen::serviceIo() { if (!worker_started_ && !startWorker()) return; if (worker_task_) xTaskNotifyGive(worker_task_); } void MapScreen::updateModel(const Pyxis::MapView::Request& request) { if (!lockState(pdMS_TO_TICKS(100))) return; has_location_fix_ = request.has_local_location; current_location_ = request.local_location; if (!center_initialized_ && request.has_local_location && presenter_.recenter(true, request.local_location)) { center_initialized_ = true; } requests_released_ = false; (void)presenter_.buildFrame(request); unlockState(); } void MapScreen::setPlaceholder(std::size_t index) { lv_obj_add_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN); } void MapScreen::applyFrame() { if (!lockState(pdMS_TO_TICKS(100))) return; const Pyxis::MapView::Frame& frame = presenter_.frame(); for (std::size_t index = 0; index < TILE_COUNT; ++index) { const Pyxis::MapTileSlot& slot = presenter_.slot(index); if (slot.state == Pyxis::MapTileSlot::READY && tile_pixels_[index]) { lv_img_set_src(tile_images_[index], &tile_descriptors_[index]); lv_obj_set_pos(tile_images_[index], static_cast(std::lround(slot.screen_x)), static_cast(std::lround(slot.screen_y))); lv_obj_clear_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN); } else { setPlaceholder(index); } } for (std::size_t index = 0; index < MARKER_COUNT; ++index) { if (index >= frame.marker_count) { lv_obj_add_flag(approximation_halos_[index], LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(markers_[index], LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(marker_labels_[index], LV_OBJ_FLAG_HIDDEN); continue; } const Pyxis::MapView::Marker& marker = frame.markers[index]; const lv_coord_t x = static_cast(std::lround(marker.screen_x)); const lv_coord_t y = static_cast(std::lround(marker.screen_y)); if (marker.has_approx_radius && marker.approx_radius_pixels > 0.0) { const double bounded_radius = marker.approx_radius_pixels > 320.0 ? 320.0 : (marker.approx_radius_pixels < 2.0 ? 2.0 : marker.approx_radius_pixels); const lv_coord_t radius = static_cast(std::lround(bounded_radius)); lv_obj_set_size(approximation_halos_[index], radius * 2, radius * 2); lv_obj_set_pos(approximation_halos_[index], x - radius, y - radius); lv_obj_clear_flag(approximation_halos_[index], LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(approximation_halos_[index], LV_OBJ_FLAG_HIDDEN); } lv_obj_set_pos(markers_[index], x - 5, y - 5); lv_obj_set_style_bg_color( markers_[index], marker.kind == Pyxis::MapView::MarkerKind::LOCAL ? Theme::info() : Theme::success(), 0); lv_obj_clear_flag(markers_[index], LV_OBJ_FLAG_HIDDEN); char label[8] = {}; if (marker.kind == Pyxis::MapView::MarkerKind::LOCAL) { std::snprintf(label, sizeof(label), "me"); } else { std::snprintf(label, sizeof(label), "%02x%02x", marker.peer.bytes[Telemetry::PEER_ID_SIZE - 2U], marker.peer.bytes[Telemetry::PEER_ID_SIZE - 1U]); } lv_label_set_text(marker_labels_[index], label); lv_obj_set_pos(marker_labels_[index], x + 6, y - 7); lv_obj_clear_flag(marker_labels_[index], LV_OBJ_FLAG_HIDDEN); } char zoom_text[12] = {}; std::snprintf(zoom_text, sizeof(zoom_text), "z%lu", static_cast(presenter_.zoom())); lv_label_set_text(zoom_label_, zoom_text); // Non-ready images are now detached under LVGL_LOCK, so the worker may // safely decode into their permanent buffers without a draw race. requests_released_ = true; unlockState(); if (worker_task_) xTaskNotifyGive(worker_task_); } void MapScreen::setStatusFor(Pyxis::MapTileLoadResult result) { switch (result) { case Pyxis::MapTileLoadResult::READY: lv_label_set_text(status_label_, "Tile ready"); break; case Pyxis::MapTileLoadResult::MISS: lv_label_set_text(status_label_, "Tile unavailable"); break; case Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE: lv_label_set_text(status_label_, "SD unavailable"); break; case Pyxis::MapTileLoadResult::INVALID_PNG: lv_label_set_text(status_label_, "Bad tile"); break; case Pyxis::MapTileLoadResult::TOO_LARGE: lv_label_set_text(status_label_, "Tile too large"); break; case Pyxis::MapTileLoadResult::DOWNLOAD_FAILED: lv_label_set_text(status_label_, "Download failed"); break; case Pyxis::MapTileLoadResult::IO_ERROR: lv_label_set_text(status_label_, "Tile I/O error"); break; } } bool MapScreen::applyOneCompletion() { if (!lockState(pdMS_TO_TICKS(100))) return false; Pyxis::MapTileCompletion completion{}; const bool applied = presenter_.takeApplicableCompletion(completion); if (applied) { setStatusFor(completion.result); if (completion.result == Pyxis::MapTileLoadResult::READY && completion.slot_index < TILE_COUNT) { const std::size_t index = completion.slot_index; lv_img_set_src(tile_images_[index], &tile_descriptors_[index]); const Pyxis::MapTileSlot& slot = presenter_.slot(index); lv_obj_set_pos(tile_images_[index], static_cast(std::lround(slot.screen_x)), static_cast(std::lround(slot.screen_y))); lv_obj_clear_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN); } } unlockState(); return applied; } void MapScreen::show() { pack_refresh_epoch_.fetch_add(1U, std::memory_order_acq_rel); screen_visible_.store(true, std::memory_order_release); if (worker_task_) xTaskNotifyGive(worker_task_); if (lockState(pdMS_TO_TICKS(100))) { presenter_.show(); unlockState(); } lv_obj_clear_flag(screen_, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(screen_); lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { lv_group_add_obj(group, back_button_); lv_group_add_obj(group, zoom_out_button_); lv_group_add_obj(group, zoom_in_button_); lv_group_add_obj(group, recenter_button_); for (std::size_t i = 0; i < 4U; ++i) lv_group_add_obj(group, pan_buttons_[i]); lv_group_focus_obj(back_button_); } } void MapScreen::hide() { if (screen_visible_.exchange(false, std::memory_order_acq_rel)) { transport_close_epoch_.fetch_add(1U, std::memory_order_acq_rel); } if (worker_task_) xTaskNotifyGive(worker_task_); synchronizeTransportStart(); if (lockState(pdMS_TO_TICKS(100))) { presenter_.hide(); unlockState(); } lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { lv_group_remove_obj(back_button_); lv_group_remove_obj(zoom_out_button_); lv_group_remove_obj(zoom_in_button_); lv_group_remove_obj(recenter_button_); for (std::size_t i = 0; i < 4U; ++i) lv_group_remove_obj(pan_buttons_[i]); } lv_obj_add_flag(screen_, LV_OBJ_FLAG_HIDDEN); } MapScreen* MapScreen::fromEvent(lv_event_t* event) { return static_cast(lv_event_get_user_data(event)); } void MapScreen::pan(double dx, double dy) { if (!lockState(pdMS_TO_TICKS(100))) return; center_initialized_ = true; (void)presenter_.panPixels(dx, dy); unlockState(); } void MapScreen::onBack(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (screen && screen->back_callback_) screen->back_callback_(); } void MapScreen::onZoomIn(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (screen && screen->lockState(pdMS_TO_TICKS(100))) { screen->center_initialized_ = true; (void)screen->presenter_.zoomBy(1); screen->unlockState(); } } void MapScreen::onZoomOut(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (screen && screen->lockState(pdMS_TO_TICKS(100))) { screen->center_initialized_ = true; (void)screen->presenter_.zoomBy(-1); screen->unlockState(); } } void MapScreen::onRecenter(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (!screen || !screen->lockState(pdMS_TO_TICKS(100))) return; const bool centered = screen->presenter_.recenter( screen->has_location_fix_, screen->current_location_); if (centered) screen->center_initialized_ = true; screen->unlockState(); if (!centered) lv_label_set_text(screen->status_label_, "No GPS fix"); } void MapScreen::onPan(lv_event_t* event) { MapScreen* screen = fromEvent(event); lv_obj_t* target = lv_event_get_current_target(event); if (!screen || !target) return; const std::size_t direction = reinterpret_cast( lv_obj_get_user_data(target)); switch (direction) { case 0U: screen->pan(0.0, -64.0); break; case 1U: screen->pan(0.0, 64.0); break; case 2U: screen->pan(-64.0, 0.0); break; case 3U: screen->pan(64.0, 0.0); break; default: break; } } void MapScreen::onMapPressed(lv_event_t* event) { MapScreen* screen = fromEvent(event); lv_indev_t* input = lv_indev_get_act(); if (!screen || !input) return; screen->dragging_ = true; lv_indev_get_point(input, &screen->last_drag_point_); } void MapScreen::onMapPressing(lv_event_t* event) { MapScreen* screen = fromEvent(event); lv_indev_t* input = lv_indev_get_act(); if (!screen || !input || !screen->dragging_) return; lv_point_t point{}; lv_indev_get_point(input, &point); const double dx = static_cast(screen->last_drag_point_.x - point.x); const double dy = static_cast(screen->last_drag_point_.y - point.y); screen->last_drag_point_ = point; if (dx != 0.0 || dy != 0.0) screen->pan(dx, dy); } void MapScreen::onMapReleased(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (screen) screen->dragging_ = false; } } // namespace LXMF } // namespace UI #endif // ARDUINO