From 8e0b603e1110da3204dedda1d610cab3214cd5ab Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:00:58 +0000 Subject: [PATCH] feat: add bounded map tile downloader --- .../Hardware/TDeck/MapTileDownloader.cpp | 295 ++++++++++++++++++ .../Hardware/TDeck/MapTileDownloader.h | 170 ++++++++++ .../Hardware/TDeck/MapTileHttpArduino.cpp | 109 +++++++ .../Hardware/TDeck/MapTileHttpArduino.h | 50 +++ lib/tdeck_ui/Hardware/TDeck/MapTileStore.h | 2 + .../test_map_tile_downloader_contract.py | 31 ++ tests/native/test_map_tile_downloader.cpp | 168 ++++++++++ tests/native/test_map_tile_downloader.py | 32 ++ 8 files changed, 857 insertions(+) create mode 100644 lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp create mode 100644 lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h create mode 100644 lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.cpp create mode 100644 lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.h create mode 100644 tests/build_scripts/test_map_tile_downloader_contract.py create mode 100644 tests/native/test_map_tile_downloader.cpp create mode 100644 tests/native/test_map_tile_downloader.py diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp new file mode 100644 index 00000000..40404271 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp @@ -0,0 +1,295 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#include "MapTileDownloader.h" + +#include +#include + +namespace Hardware { +namespace TDeck { + +namespace { +const char DEFAULT_ENDPOINT[] = "https://tile.openstreetmap.org"; +const char USER_AGENT_PREFIX[] = "Pyxis/"; +const char USER_AGENT_SUFFIX[] = " (+https://github.com/torlando-tech/pyxis)"; + +char asciiLower(char value) { + return (value >= 'A' && value <= 'Z') ? static_cast(value - 'A' + 'a') : value; +} + +bool validKey(const TileKey& key) { + if (key.zoom > MapTileStore::MAX_ZOOM) return false; + const std::uint32_t count = UINT32_C(1) << key.zoom; + return key.x < count && key.y < count; +} +} + +MapTileDownloadConfig::MapTileDownloadConfig() + : endpoint(DEFAULT_ENDPOINT), ca_certificate(NULL), firmware_version(NULL), + overall_timeout_ms(15000U), connect_timeout_ms(5000U), read_timeout_ms(5000U) {} + +MapTileDownloader::MapTileDownloader(MapTileDownloadStore& store, MapTileTransport& transport, + MapTileDownloadClock& clock, const MapTileDownloadPolicy& policy, + const MapTileDownloadConfig& config) + : store_(store), transport_(transport), clock_(clock), policy_(policy), config_(config), + queue_{}, queue_count_(0U), results_{}, result_head_(0U), result_count_(0U), + dropped_results_(0U), current_{}, active_(false), transport_open_(false), + store_open_(false), stage_(Stage::SELECTED), last_now_(0U), deadline_(0U), + received_(0U), expected_length_(-1), url_{0}, user_agent_{0}, chunk_{0} {} + +MapTileDownloader::~MapTileDownloader() { + if (store_open_) store_.abortPut(); + if (transport_open_) transport_.close(); +} + +bool MapTileDownloader::sameKey(const TileKey& a, const TileKey& b) { + return a.zoom == b.zoom && a.x == b.x && a.y == b.y; +} + +MapTileUrlResult MapTileDownloader::canonicalUrl(const char* endpoint, const TileKey& key, + char* output, std::size_t capacity) { + if (output == NULL || capacity == 0U || endpoint == NULL || endpoint[0] == '\0') { + return MapTileUrlResult::INVALID_ARGUMENT; + } + output[0] = '\0'; + if (!validKey(key)) return MapTileUrlResult::INVALID_KEY; + static const char scheme[] = "https://"; + if (std::strncmp(endpoint, scheme, sizeof(scheme) - 1U) != 0) { + return MapTileUrlResult::INVALID_ARGUMENT; + } + const char* authority = endpoint + sizeof(scheme) - 1U; + if (*authority == '\0' || *authority == '/') { + return MapTileUrlResult::INVALID_ARGUMENT; + } + for (const char* cursor = authority; *cursor != '\0' && *cursor != '/'; ++cursor) { + if (*cursor == '@' || *cursor == '?' || *cursor == '#') { + return MapTileUrlResult::INVALID_ARGUMENT; + } + } + std::size_t endpoint_size = std::strlen(endpoint); + while (endpoint_size != 0U && endpoint[endpoint_size - 1U] == '/') --endpoint_size; + if (endpoint_size == 0U) return MapTileUrlResult::INVALID_ARGUMENT; + const int count = std::snprintf(output, capacity, "%.*s/%u/%lu/%lu.png", + static_cast(endpoint_size), endpoint, static_cast(key.zoom), + static_cast(key.x), static_cast(key.y)); + if (count < 0 || static_cast(count) >= capacity) { + output[0] = '\0'; + return MapTileUrlResult::TOO_LONG; + } + return MapTileUrlResult::OK; +} + +MapTileEnqueueResult MapTileDownloader::enqueue(const TileKey& key, std::uint32_t generation) { + if (!policy_.enabled) return MapTileEnqueueResult::DISABLED; + if (!validKey(key)) return MapTileEnqueueResult::INVALID_KEY; + if (active_ && sameKey(current_.key, key)) return MapTileEnqueueResult::DUPLICATE; + for (std::size_t i = 0U; i < queue_count_; ++i) { + if (sameKey(queue_[i].key, key)) return MapTileEnqueueResult::DUPLICATE; + } + if (queue_count_ == QUEUE_CAPACITY) return MapTileEnqueueResult::QUEUE_FULL; + queue_[queue_count_].key = key; + queue_[queue_count_].generation = generation; + ++queue_count_; + return MapTileEnqueueResult::ACCEPTED; +} + +void MapTileDownloader::publish(MapTileResultCode code) { + MapTileDownloadResult result = {current_.key, current_.generation, code, received_}; + if (result_count_ == RESULT_CAPACITY) { + ++dropped_results_; + return; + } + const std::size_t index = (result_head_ + result_count_) % RESULT_CAPACITY; + results_[index] = result; + ++result_count_; +} + +void MapTileDownloader::finish(MapTileResultCode code, bool abort_store) { + if (abort_store && store_open_) store_.abortPut(); + if (transport_open_) transport_.close(); + publish(code); + active_ = false; + transport_open_ = false; + store_open_ = false; + received_ = 0U; + expected_length_ = -1; +} + +std::size_t MapTileDownloader::cancelGeneration(std::uint32_t generation) { + std::size_t canceled = 0U; + if (active_ && current_.generation == generation) { + finish(MapTileResultCode::CANCELED, true); + ++canceled; + } + std::size_t write = 0U; + for (std::size_t read = 0U; read < queue_count_; ++read) { + if (queue_[read].generation == generation) { + current_ = queue_[read]; + received_ = 0U; + publish(MapTileResultCode::CANCELED); + ++canceled; + } else { + if (write != read) queue_[write] = queue_[read]; + ++write; + } + } + queue_count_ = write; + return canceled; +} + +bool MapTileDownloader::validPngContentType(const char* value) { + if (value == NULL) return false; + static const char expected[] = "image/png"; + std::size_t i = 0U; + for (; i < sizeof(expected) - 1U; ++i) { + if (value[i] == '\0' || asciiLower(value[i]) != expected[i]) return false; + } + const char* cursor = value + i; + while (*cursor == ' ' || *cursor == '\t') ++cursor; + return *cursor == '\0' || *cursor == ';'; +} + +bool MapTileDownloader::checkClock() { + const std::uint64_t now = clock_.nowMs(); + if (now < last_now_) { + finish(MapTileResultCode::CLOCK_ERROR, true); + return false; + } + last_now_ = now; + if (now > deadline_) { + finish(MapTileResultCode::TIMEOUT, true); + return false; + } + return true; +} + +MapTilePumpResult MapTileDownloader::pump() { + if (!active_) { + if (queue_count_ == 0U) return MapTilePumpResult::IDLE; + current_ = queue_[0]; + for (std::size_t i = 1U; i < queue_count_; ++i) queue_[i - 1U] = queue_[i]; + --queue_count_; + received_ = 0U; + expected_length_ = -1; + transport_open_ = false; + store_open_ = false; + stage_ = Stage::SELECTED; + last_now_ = clock_.nowMs(); + const std::uint64_t room = UINT64_MAX - last_now_; + deadline_ = config_.overall_timeout_ms > room ? UINT64_MAX : + last_now_ + static_cast(config_.overall_timeout_ms); + active_ = true; + if (canonicalUrl(config_.endpoint, current_.key, url_, sizeof(url_)) != MapTileUrlResult::OK || + config_.firmware_version == NULL || config_.ca_certificate == NULL || config_.ca_certificate[0] == '\0') { + finish(MapTileResultCode::URL_ERROR, false); + return MapTilePumpResult::PROGRESSED; + } + const int agent_size = std::snprintf(user_agent_, sizeof(user_agent_), "%s%s%s", + USER_AGENT_PREFIX, config_.firmware_version, USER_AGENT_SUFFIX); + if (agent_size < 0 || static_cast(agent_size) >= sizeof(user_agent_)) { + finish(MapTileResultCode::URL_ERROR, false); + } + return MapTilePumpResult::PROGRESSED; + } + + if (!checkClock()) return MapTilePumpResult::PROGRESSED; + if (!store_.isAvailable()) { + finish(MapTileResultCode::STORE_UNAVAILABLE, true); + return MapTilePumpResult::PROGRESSED; + } + + if (stage_ == Stage::SELECTED) { + TileHttpResponse response = {0, -1, NULL}; + const TileTransportResult started = transport_.start(url_, user_agent_, config_.ca_certificate, + config_.connect_timeout_ms, config_.read_timeout_ms, response); + if (started != TileTransportResult::OK) { + finish(started == TileTransportResult::TIMEOUT ? MapTileResultCode::TIMEOUT : + MapTileResultCode::TRANSPORT_ERROR, false); + return MapTilePumpResult::PROGRESSED; + } + transport_open_ = true; + if (response.status_code != 200) { + finish(MapTileResultCode::HTTP_STATUS_ERROR, false); + return MapTilePumpResult::PROGRESSED; + } + if (!validPngContentType(response.content_type)) { + finish(MapTileResultCode::CONTENT_TYPE_ERROR, false); + return MapTilePumpResult::PROGRESSED; + } + expected_length_ = response.content_length; + if (expected_length_ < -1) { + finish(MapTileResultCode::LENGTH_MISMATCH, false); + return MapTilePumpResult::PROGRESSED; + } + if (expected_length_ > static_cast(store_.maxTileBytes())) { + finish(MapTileResultCode::TOO_LARGE, false); + return MapTilePumpResult::PROGRESSED; + } + stage_ = Stage::TRANSPORT_STARTED; + return MapTilePumpResult::PROGRESSED; + } + + if (stage_ == Stage::TRANSPORT_STARTED) { + const TileStoreResult result = store_.beginPut(current_.key); + if (result != TileStoreResult::OK) { + finish(result == TileStoreResult::STORAGE_UNAVAILABLE ? MapTileResultCode::STORE_UNAVAILABLE : + MapTileResultCode::STORE_ERROR, false); + return MapTilePumpResult::PROGRESSED; + } + store_open_ = true; + stage_ = Stage::READING; + return MapTilePumpResult::PROGRESSED; + } + + std::size_t count = 0U; + bool eof = false; + const TileTransportResult read_result = transport_.read(chunk_, sizeof(chunk_), count, eof); + if (read_result != TileTransportResult::OK || count > sizeof(chunk_)) { + finish(read_result == TileTransportResult::TIMEOUT ? MapTileResultCode::TIMEOUT : + MapTileResultCode::READ_ERROR, true); + return MapTilePumpResult::PROGRESSED; + } + // A blocking transport read may consume the remaining overall budget. + // Recheck before committing bytes or accepting EOF. + if (!checkClock()) return MapTilePumpResult::PROGRESSED; + const std::uint32_t maximum = store_.maxTileBytes(); + if (count > maximum || received_ > maximum - static_cast(count)) { + finish(MapTileResultCode::TOO_LARGE, true); + return MapTilePumpResult::PROGRESSED; + } + if (count != 0U) { + const TileStoreResult written = store_.writePutChunk(chunk_, count); + if (written != TileStoreResult::OK) { + finish(written == TileStoreResult::STORAGE_UNAVAILABLE ? MapTileResultCode::STORE_UNAVAILABLE : + MapTileResultCode::STORE_ERROR, true); + return MapTilePumpResult::PROGRESSED; + } + received_ += static_cast(count); + } + if (!eof) return MapTilePumpResult::PROGRESSED; + if (expected_length_ >= 0 && static_cast(expected_length_) != received_) { + finish(MapTileResultCode::LENGTH_MISMATCH, true); + return MapTilePumpResult::PROGRESSED; + } + const TileStoreResult finished = store_.finishPut(); + if (finished != TileStoreResult::OK) { + finish(finished == TileStoreResult::STORAGE_UNAVAILABLE ? MapTileResultCode::STORE_UNAVAILABLE : + MapTileResultCode::STORE_ERROR, true); + return MapTilePumpResult::PROGRESSED; + } + store_open_ = false; + finish(MapTileResultCode::SUCCESS, false); + return MapTilePumpResult::PROGRESSED; +} + +bool MapTileDownloader::takeResult(MapTileDownloadResult& result) { + if (result_count_ == 0U) return false; + result = results_[result_head_]; + result_head_ = (result_head_ + 1U) % RESULT_CAPACITY; + --result_count_; + return true; +} + +} // namespace TDeck +} // namespace Hardware diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h new file mode 100644 index 00000000..e8f1a387 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_MAP_TILE_DOWNLOADER_H +#define HARDWARE_TDECK_MAP_TILE_DOWNLOADER_H + +#include "MapTileStore.h" + +#include +#include + +namespace Hardware { +namespace TDeck { + +/** Optional network policy. Construction is deliberately disabled by default. */ +struct MapTileDownloadPolicy { + bool enabled; + MapTileDownloadPolicy() : enabled(false) {} +}; + +struct MapTileDownloadConfig { + const char* endpoint; + const char* ca_certificate; + const char* firmware_version; + std::uint32_t overall_timeout_ms; + std::uint32_t connect_timeout_ms; + std::uint32_t read_timeout_ms; + MapTileDownloadConfig(); +}; + +enum class TileTransportResult : std::uint8_t { OK, ERROR, TIMEOUT }; + +struct TileHttpResponse { + int status_code; + std::int64_t content_length; // -1 means absent + const char* content_type; // valid until close() +}; + +class MapTileTransport { +public: + virtual ~MapTileTransport() {} + virtual TileTransportResult start(const char* url, const char* user_agent, + const char* ca_certificate, std::uint32_t connect_timeout_ms, + std::uint32_t read_timeout_ms, TileHttpResponse& response) = 0; + virtual TileTransportResult read(std::uint8_t* output, std::size_t capacity, + std::size_t& count, bool& eof) = 0; + virtual void close() = 0; +}; + +class MapTileDownloadClock { +public: + virtual ~MapTileDownloadClock() {} + virtual std::uint64_t nowMs() const = 0; +}; + +/** Narrow store boundary; MapTileStore can be wired through MapTileStoreDownloadAdapter. */ +class MapTileDownloadStore { +public: + virtual ~MapTileDownloadStore() {} + virtual bool isAvailable() const = 0; + virtual std::uint32_t maxTileBytes() const = 0; + virtual TileStoreResult beginPut(const TileKey& key) = 0; + virtual TileStoreResult writePutChunk(const std::uint8_t* data, std::size_t size) = 0; + virtual TileStoreResult finishPut() = 0; + virtual void abortPut() = 0; +}; + +class MapTileStoreDownloadAdapter : public MapTileDownloadStore { +public: + explicit MapTileStoreDownloadAdapter(MapTileStore& store) : store_(store) {} + virtual bool isAvailable() const { return store_.isAvailable(); } + virtual std::uint32_t maxTileBytes() const { return store_.maxTileBytes(); } + virtual TileStoreResult beginPut(const TileKey& key) { return store_.beginPut(key); } + virtual TileStoreResult writePutChunk(const std::uint8_t* data, std::size_t size) { return store_.writePutChunk(data, size); } + virtual TileStoreResult finishPut() { return store_.finishPut(); } + virtual void abortPut() { store_.abortPut(); } +private: + MapTileStore& store_; +}; + +enum class MapTileEnqueueResult : std::uint8_t { ACCEPTED, DISABLED, INVALID_KEY, DUPLICATE, QUEUE_FULL }; +enum class MapTileUrlResult : std::uint8_t { OK, INVALID_ARGUMENT, INVALID_KEY, TOO_LONG }; +enum class MapTilePumpResult : std::uint8_t { IDLE, PROGRESSED }; +enum class MapTileResultCode : std::uint8_t { + SUCCESS, CANCELED, URL_ERROR, TRANSPORT_ERROR, HTTP_STATUS_ERROR, + CONTENT_TYPE_ERROR, TOO_LARGE, LENGTH_MISMATCH, READ_ERROR, + STORE_UNAVAILABLE, STORE_ERROR, TIMEOUT, CLOCK_ERROR +}; + +struct MapTileDownloadResult { + TileKey key; + std::uint32_t generation; + MapTileResultCode code; + std::uint32_t bytes; +}; + +/** + * Fixed-capacity, caller-pumped downloader for visible slippy-map tiles. + * + * Requests contain only TileKey + generation. There is no retry, prefetch, + * background bulk mode, credential support, or hidden URL input. Keep one + * visible tile request active at a time. Users of the default public endpoint + * must preserve visible OpenStreetMap attribution in the eventual map UI and + * comply with https://operations.osmfoundation.org/policies/tiles/ . + */ +class MapTileDownloader { +public: + static const std::size_t QUEUE_CAPACITY = 6U; + static const std::size_t RESULT_CAPACITY = 6U; + static const std::size_t CHUNK_CAPACITY = 4096U; + static const std::size_t URL_CAPACITY = 128U; + static const std::size_t USER_AGENT_CAPACITY = 96U; + + MapTileDownloader(MapTileDownloadStore& store, MapTileTransport& transport, + MapTileDownloadClock& clock, const MapTileDownloadPolicy& policy, + const MapTileDownloadConfig& config); + ~MapTileDownloader(); + + static MapTileUrlResult canonicalUrl(const char* endpoint, const TileKey& key, + char* output, std::size_t capacity); + MapTileEnqueueResult enqueue(const TileKey& key, std::uint32_t generation); + std::size_t cancelGeneration(std::uint32_t generation); + MapTilePumpResult pump(); + bool takeResult(MapTileDownloadResult& result); + + bool isBusy() const { return active_ || queue_count_ != 0U; } + std::size_t queuedCount() const { return queue_count_; } + std::size_t resultCount() const { return result_count_; } + std::uint32_t droppedResultCount() const { return dropped_results_; } + std::uint64_t lastDeadline() const { return deadline_; } + +private: + struct Request { TileKey key; std::uint32_t generation; }; + enum class Stage : std::uint8_t { SELECTED, TRANSPORT_STARTED, STORE_STARTED, READING }; + + MapTileDownloadStore& store_; + MapTileTransport& transport_; + MapTileDownloadClock& clock_; + MapTileDownloadPolicy policy_; + MapTileDownloadConfig config_; + Request queue_[QUEUE_CAPACITY]; + std::size_t queue_count_; + MapTileDownloadResult results_[RESULT_CAPACITY]; + std::size_t result_head_; + std::size_t result_count_; + std::uint32_t dropped_results_; + Request current_; + bool active_; + bool transport_open_; + bool store_open_; + Stage stage_; + std::uint64_t last_now_; + std::uint64_t deadline_; + std::uint32_t received_; + std::int64_t expected_length_; + char url_[URL_CAPACITY]; + char user_agent_[USER_AGENT_CAPACITY]; + std::uint8_t chunk_[CHUNK_CAPACITY]; + + static bool sameKey(const TileKey& a, const TileKey& b); + static bool validPngContentType(const char* value); + void publish(MapTileResultCode code); + void finish(MapTileResultCode code, bool abort_store); + bool checkClock(); +}; + +} // namespace TDeck +} // namespace Hardware + +#endif diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.cpp b/lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.cpp new file mode 100644 index 00000000..54b1a173 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.cpp @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#include "MapTileHttpArduino.h" + +#ifdef ARDUINO +#include +#include + +namespace Hardware { +namespace TDeck { + +namespace { +std::uint16_t boundedReadTimeout(std::uint32_t value) { + return value > UINT16_MAX ? UINT16_MAX : static_cast(value); +} +std::int32_t boundedConnectTimeout(std::uint32_t value) { + return value > static_cast(INT32_MAX) ? INT32_MAX : static_cast(value); +} +} + +MapTileHttpArduino::MapTileHttpArduino() + : stream_(NULL), remaining_(-1), open_(false), content_type_{0} {} +MapTileHttpArduino::~MapTileHttpArduino() { close(); } + +TileTransportResult MapTileHttpArduino::start(const char* url, const char* user_agent, + const char* ca_certificate, std::uint32_t connect_timeout_ms, + std::uint32_t read_timeout_ms, TileHttpResponse& response) { + close(); + if (url == NULL || user_agent == NULL || ca_certificate == NULL || ca_certificate[0] == '\0' || + std::strncmp(url, "https://", 8U) != 0) return TileTransportResult::ERROR; + + client_.setCACert(ca_certificate); + client_.setTimeout(read_timeout_ms); + http_.setConnectTimeout(boundedConnectTimeout(connect_timeout_ms)); + http_.setTimeout(boundedReadTimeout(read_timeout_ms)); + http_.useHTTP10(true); // deterministic EOF for responses without Content-Length + http_.setUserAgent(user_agent); + const char* response_headers[] = {"Content-Type"}; + http_.collectHeaders(response_headers, 1U); + if (!http_.begin(client_, url)) { + http_.end(); + return TileTransportResult::ERROR; + } + + const int status = http_.GET(); + if (status < 0) { + http_.end(); + return status == HTTPC_ERROR_READ_TIMEOUT + ? TileTransportResult::TIMEOUT + : TileTransportResult::ERROR; + } + open_ = true; + stream_ = http_.getStreamPtr(); + remaining_ = static_cast(http_.getSize()); + const String type = http_.header("Content-Type"); + if (type.length() >= sizeof(content_type_)) { close(); return TileTransportResult::ERROR; } + std::memcpy(content_type_, type.c_str(), type.length() + 1U); + response.status_code = status; + response.content_length = remaining_; + response.content_type = content_type_; + return TileTransportResult::OK; +} + +TileTransportResult MapTileHttpArduino::read(std::uint8_t* output, std::size_t capacity, + std::size_t& count, bool& eof) { + count = 0U; + eof = false; + if (!open_ || stream_ == NULL || (output == NULL && capacity != 0U) || capacity == 0U) { + return TileTransportResult::ERROR; + } + if (remaining_ == 0) { eof = true; return TileTransportResult::OK; } + + std::size_t wanted = capacity; + if (remaining_ > 0 && static_cast(remaining_) < wanted) { + wanted = static_cast(remaining_); + } + const int available = stream_->available(); + if (available > 0 && static_cast(available) < wanted) wanted = static_cast(available); + else if (available == 0) wanted = 1U; + count = stream_->readBytes(output, wanted); + if (count == 0U) { + if (!stream_->connected() && stream_->available() == 0) { eof = true; return TileTransportResult::OK; } + return TileTransportResult::TIMEOUT; + } + if (remaining_ > 0) remaining_ -= static_cast(count); + eof = remaining_ == 0 || (!stream_->connected() && stream_->available() == 0); + return TileTransportResult::OK; +} + +void MapTileHttpArduino::close() { + if (open_) http_.end(); + stream_ = NULL; + remaining_ = -1; + open_ = false; + content_type_[0] = '\0'; +} + +MapTileMillisClock::MapTileMillisClock() : previous_(millis()), high_(0U) {} +std::uint64_t MapTileMillisClock::nowMs() const { + const std::uint32_t current = millis(); + if (current < previous_) high_ += (UINT64_C(1) << 32U); + previous_ = current; + return high_ | current; +} + +} // namespace TDeck +} // namespace Hardware +#endif // ARDUINO diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.h b/lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.h new file mode 100644 index 00000000..a001ac3e --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.h @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_MAP_TILE_HTTP_ARDUINO_H +#define HARDWARE_TDECK_MAP_TILE_HTTP_ARDUINO_H + +#include "MapTileDownloader.h" + +#ifdef ARDUINO +#include +#include +#include + +namespace Hardware { +namespace TDeck { + +/** HTTPS-only transport. A non-empty explicit CA is mandatory for peer verification. */ +class MapTileHttpArduino : public MapTileTransport { +public: + MapTileHttpArduino(); + virtual ~MapTileHttpArduino(); + virtual TileTransportResult start(const char* url, const char* user_agent, + const char* ca_certificate, std::uint32_t connect_timeout_ms, + std::uint32_t read_timeout_ms, TileHttpResponse& response); + virtual TileTransportResult read(std::uint8_t* output, std::size_t capacity, + std::size_t& count, bool& eof); + virtual void close(); +private: + WiFiClientSecure client_; + HTTPClient http_; + WiFiClient* stream_; + std::int64_t remaining_; + bool open_; + char content_type_[64]; +}; + +/** Widens Arduino millis() rollovers into a monotonic 64-bit clock. */ +class MapTileMillisClock : public MapTileDownloadClock { +public: + MapTileMillisClock(); + virtual std::uint64_t nowMs() const; +private: + mutable std::uint32_t previous_; + mutable std::uint64_t high_; +}; + +} // namespace TDeck +} // namespace Hardware +#endif // ARDUINO +#endif diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileStore.h b/lib/tdeck_ui/Hardware/TDeck/MapTileStore.h index 8a5dce83..964a57a5 100644 --- a/lib/tdeck_ui/Hardware/TDeck/MapTileStore.h +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileStore.h @@ -90,6 +90,8 @@ public: std::uint16_t entryCount() const { return entry_count_; } std::uint32_t totalBytes() const { return total_bytes_; } + std::uint32_t maxTileBytes() const { return config_.max_tile_bytes; } + bool isAvailable() const { return storage_.isAvailable(); } static std::size_t ramBytes() { return sizeof(MapTileStore); } private: diff --git a/tests/build_scripts/test_map_tile_downloader_contract.py b/tests/build_scripts/test_map_tile_downloader_contract.py new file mode 100644 index 00000000..5dfff801 --- /dev/null +++ b/tests/build_scripts/test_map_tile_downloader_contract.py @@ -0,0 +1,31 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CORE_H = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h" +CORE_CPP = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp" +ADAPTER_H = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.h" +ADAPTER_CPP = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.cpp" + + +def test_portable_core_is_bounded_and_allocation_free(): + source = CORE_H.read_text() + CORE_CPP.read_text() + for forbidden in ("std::vector", "std::map", "std::string", "new ", "malloc(", "LittleFS", "SD.begin", "format("): + assert forbidden not in source + assert "QUEUE_CAPACITY = 6U" in source + assert "RESULT_CAPACITY = 6U" in source + assert "CHUNK_CAPACITY = 4096U" in source + assert "URL_CAPACITY" in source + assert "TileKey" in source + assert "tile.openstreetmap.org" in source + assert "OpenStreetMap" in source and "attribution" in source.lower() + + +def test_https_adapter_verifies_peer_with_explicit_ca_and_has_no_credentials(): + source = ADAPTER_H.read_text() + ADAPTER_CPP.read_text() + assert "WiFiClientSecure" in source + assert "setCACert" in source + assert "setInsecure" not in source + assert "setConnectTimeout" in source + assert "setTimeout" in source + for forbidden in ("Authorization", "Cookie", "username", "password", "SD.begin", "format(", "LittleFS"): + assert forbidden not in source diff --git a/tests/native/test_map_tile_downloader.cpp b/tests/native/test_map_tile_downloader.cpp new file mode 100644 index 00000000..6dca33c9 --- /dev/null +++ b/tests/native/test_map_tile_downloader.cpp @@ -0,0 +1,168 @@ +#include "Hardware/TDeck/MapTileDownloader.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Hardware::TDeck; + +namespace { +std::size_t tests_run = 0U; +void fail(const char* expression, int line) { std::cerr << "line " << line << ": " << expression << '\n'; std::exit(1); } +#define CHECK(e) do { if (!(e)) fail(#e, __LINE__); } while (false) +void beginTest() { ++tests_run; } +bool same(const TileKey& a, const TileKey& b) { return a.zoom == b.zoom && a.x == b.x && a.y == b.y; } + +class FakeClock : public MapTileDownloadClock { +public: + std::uint64_t value; + FakeClock() : value(100U) {} + virtual std::uint64_t nowMs() const { return value; } +}; + +class FakeStore : public MapTileDownloadStore { +public: + std::uint32_t maximum; + bool available; + TileStoreResult begin_result; + TileStoreResult write_result; + TileStoreResult finish_result; + bool short_write; + bool open; + int begins; + int finishes; + int aborts; + std::vector chunks; + std::vector bytes; + FakeStore() : maximum(9000U), available(true), begin_result(TileStoreResult::OK), + write_result(TileStoreResult::OK), finish_result(TileStoreResult::OK), short_write(false), + open(false), begins(0), finishes(0), aborts(0) {} + virtual bool isAvailable() const { return available; } + virtual std::uint32_t maxTileBytes() const { return maximum; } + virtual TileStoreResult beginPut(const TileKey&) { ++begins; if (begin_result == TileStoreResult::OK) open = true; return begin_result; } + virtual TileStoreResult writePutChunk(const std::uint8_t* data, std::size_t size) { + if (write_result != TileStoreResult::OK) return write_result; + chunks.push_back(size); + const std::size_t written = (short_write && size != 0U) ? size - 1U : size; + bytes.insert(bytes.end(), data, data + written); + return short_write ? TileStoreResult::IO_ERROR : TileStoreResult::OK; + } + virtual TileStoreResult finishPut() { ++finishes; if (finish_result == TileStoreResult::OK) open = false; return finish_result; } + virtual void abortPut() { ++aborts; open = false; bytes.clear(); } +}; + +class FakeTransport : public MapTileTransport { +public: + TileTransportResult start_result; + TileTransportResult read_result; + int status; + std::int64_t length; + const char* type; + std::vector body; + std::size_t position; + std::size_t forced_chunk; + int starts; + int reads; + int closes; + std::string url; + std::string agent; + std::string ca; + std::uint32_t connect_timeout; + std::uint32_t read_timeout; + FakeClock* clock; + std::uint64_t advance_on_read; + FakeTransport() : start_result(TileTransportResult::OK), read_result(TileTransportResult::OK), + status(200), length(-1), type("image/png"), position(0U), forced_chunk(0U), starts(0), reads(0), closes(0), + connect_timeout(0U), read_timeout(0U), clock(NULL), advance_on_read(0U) {} + virtual TileTransportResult start(const char* u, const char* a, const char* c, + std::uint32_t ct, std::uint32_t rt, TileHttpResponse& response) { + ++starts; url = u == NULL ? "" : u; agent = a == NULL ? "" : a; ca = c == NULL ? "" : c; + connect_timeout = ct; read_timeout = rt; position = 0U; + response.status_code = status; response.content_length = length; response.content_type = type; + return start_result; + } + virtual TileTransportResult read(std::uint8_t* output, std::size_t capacity, std::size_t& count, bool& eof) { + ++reads; + if (clock != NULL) clock->value += advance_on_read; + if (read_result != TileTransportResult::OK) { count = 0U; eof = false; return read_result; } + std::size_t amount = std::min(capacity, body.size() - position); + if (forced_chunk != 0U) amount = std::min(amount, forced_chunk); + if (amount != 0U) std::memcpy(output, &body[position], amount); + position += amount; count = amount; eof = position == body.size(); return TileTransportResult::OK; + } + virtual void close() { ++closes; } +}; + +MapTileDownloadConfig config(const char* endpoint = "https://tile.openstreetmap.org") { + MapTileDownloadConfig c; + c.endpoint = endpoint; c.ca_certificate = "TEST CA"; c.firmware_version = "1.2.3"; + c.overall_timeout_ms = 10000U; c.connect_timeout_ms = 321U; c.read_timeout_ms = 654U; + return c; +} +MapTileDownloadPolicy enabled() { MapTileDownloadPolicy p; p.enabled = true; return p; } +TileKey key(std::uint32_t x = 1U) { TileKey k = {3U, x, 2U}; return k; } +std::vector bytes(std::size_t n) { return std::vector(n, 42U); } +void runUntilIdle(MapTileDownloader& d, FakeClock& clock, int limit = 40) { + for (int i = 0; i < limit && d.isBusy(); ++i) { CHECK(d.pump() == MapTilePumpResult::PROGRESSED); ++clock.value; } + CHECK(!d.isBusy()); +} +MapTileDownloadResult take(MapTileDownloader& d) { MapTileDownloadResult r; CHECK(d.takeResult(r)); return r; } + +void testDisabledByDefault() { beginTest(); FakeStore s; FakeTransport t; FakeClock c; MapTileDownloadPolicy p; MapTileDownloader d(s,t,c,p,config()); + CHECK(!p.enabled); CHECK(d.enqueue(key(),7U)==MapTileEnqueueResult::DISABLED); CHECK(d.queuedCount()==0U); CHECK(t.starts==0); } +void testCanonicalUrlAndBounds() { beginTest(); char out[MapTileDownloader::URL_CAPACITY]; + CHECK(MapTileDownloader::canonicalUrl("https://tile.openstreetmap.org/",TileKey{22U,4194303U,4194303U},out,sizeof(out))==MapTileUrlResult::OK); + CHECK(std::string(out)=="https://tile.openstreetmap.org/22/4194303/4194303.png"); + char tiny[12]; std::memset(tiny,'X',sizeof(tiny)); CHECK(MapTileDownloader::canonicalUrl("https://tile.openstreetmap.org",key(),tiny,sizeof(tiny))==MapTileUrlResult::TOO_LONG); CHECK(tiny[0]=='\0'); + CHECK(MapTileDownloader::canonicalUrl("https://tile.openstreetmap.org",TileKey{23U,0U,0U},out,sizeof(out))==MapTileUrlResult::INVALID_KEY); + CHECK(MapTileDownloader::canonicalUrl("http://tile.openstreetmap.org",key(),out,sizeof(out))==MapTileUrlResult::INVALID_ARGUMENT); + CHECK(MapTileDownloader::canonicalUrl("https://user@tile.example",key(),out,sizeof(out))==MapTileUrlResult::INVALID_ARGUMENT); + std::string huge("https://"); huge.append(MapTileDownloader::URL_CAPACITY,'a'); CHECK(MapTileDownloader::canonicalUrl(huge.c_str(),key(),out,sizeof(out))==MapTileUrlResult::TOO_LONG); } +void testDedupeAndQueueFullNoEviction() { beginTest(); FakeStore s; FakeTransport t; FakeClock c; MapTileDownloader d(s,t,c,enabled(),config()); + for(std::uint32_t i=0;i None: + binary = tmp_path / "test_map_tile_downloader" + command = [find_cxx(), "-std=c++11", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-Wconversion", "-Wsign-conversion", f"-I{ROOT / 'lib/tdeck_ui'}", + str(TEST_SOURCE), str(PRODUCTION_SOURCE), "-o", str(binary)] + if sanitize: + command[1:1] = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + env = os.environ.copy() + if sanitize: + env["ASAN_OPTIONS"] = "detect_leaks=1:halt_on_error=1" + env["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60, env=env) + assert ran.returncode == 0, ran.stdout + ran.stderr + assert ran.stdout == "map tile downloader: 12 tests passed\n"