From a87c3016cc9f94aba15022b697eb82dbc7b2bdf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sun, 11 Oct 2026 17:01:57 +0200 Subject: [PATCH] GNSS: Places, kept as GPX waypoints A Place is a named position the user marked (issue #16). The list is read from and written to a GPX file, so a map program opens it and waypoints made by one are read here. Host-tested. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk --- lib/gnss/src/places.cpp | 102 +++++++++++++++++++++++++++++++ lib/gnss/src/places.h | 24 ++++++++ test/test_places/test_places.cpp | 75 +++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 lib/gnss/src/places.cpp create mode 100644 lib/gnss/src/places.h create mode 100644 test/test_places/test_places.cpp diff --git a/lib/gnss/src/places.cpp b/lib/gnss/src/places.cpp new file mode 100644 index 0000000..a22c03a --- /dev/null +++ b/lib/gnss/src/places.cpp @@ -0,0 +1,102 @@ +#include "places.h" + +#include +#include + +namespace roro::gnss { + +namespace { + +struct Entity { + const char* text; + char ch; +}; +constexpr Entity kEntities[] = {{"&", '&'}, {"<", '<'}, {">", '>'}, {""", '"'}, {"'", '\''}}; + +std::string escaped(const std::string& s) { + std::string out; + for (char c : s) { + const char* entity = nullptr; + for (auto& e : kEntities) + if (e.ch == c) entity = e.text; + if (entity) out += entity; + else out += c; + } + return out; +} + +std::string unescaped(const std::string& s) { + std::string out; + for (size_t i = 0; i < s.size();) { + bool found = false; + if (s[i] == '&') + for (auto& e : kEntities) { + std::string text = e.text; + if (s.compare(i, text.size(), text) == 0) { + out += e.ch; + i += text.size(); + found = true; + break; + } + } + if (!found) out += s[i++]; + } + return out; +} + +// The value of ` name="..."` in a tag; false if it isn't there or isn't a number. +bool attribute(const std::string& tag, const char* name, double& value) { + std::string key = std::string(" ") + name + "="; + size_t at = tag.find(key); + if (at == std::string::npos || at + key.size() >= tag.size()) return false; + at += key.size(); + char quote = tag[at]; + if (quote != '"' && quote != '\'') return false; + size_t end = tag.find(quote, at + 1); + if (end == std::string::npos) return false; + std::string text = tag.substr(at + 1, end - at - 1); + char* rest = nullptr; + value = std::strtod(text.c_str(), &rest); + return !text.empty() && rest && *rest == 0; +} + +} // namespace + +std::vector parsePlaces(const std::string& gpx) { + std::vector places; + size_t at = 0; + while (places.size() < kMaxPlaces && (at = gpx.find("', at); + if (tagEnd == std::string::npos) break; + std::string tag = gpx.substr(at, tagEnd - at); + bool alone = !tag.empty() && tag.back() == '/'; // : no name + size_t close = alone ? tagEnd : gpx.find("", tagEnd); + if (close == std::string::npos) break; + Place p; + if (attribute(tag, "lat", p.latitude) && attribute(tag, "lon", p.longitude) && p.latitude >= -90 && + p.latitude <= 90 && p.longitude >= -180 && p.longitude <= 180) { + size_t name = alone ? std::string::npos : gpx.find("", tagEnd); + if (name != std::string::npos && name < close) { + size_t nameEnd = gpx.find("", name); + if (nameEnd != std::string::npos && nameEnd < close) p.name = unescaped(gpx.substr(name + 6, nameEnd - name - 6)); + } + if (p.name.empty()) p.name = "Place " + std::to_string(places.size() + 1); + places.push_back(p); + } + at = close + 1; + } + return places; +} + +std::string placesGpx(const std::vector& places) { + std::string out = "\n" + "\n"; + for (auto& p : places) { + char buf[80]; + std::snprintf(buf, sizeof buf, "", p.latitude, p.longitude); + out += buf + escaped(p.name) + "\n"; + } + return out + "\n"; +} + +} // namespace roro::gnss diff --git a/lib/gnss/src/places.h b/lib/gnss/src/places.h new file mode 100644 index 0000000..0dc1948 --- /dev/null +++ b/lib/gnss/src/places.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +namespace roro::gnss { + +// A Place (issue #16): somewhere the user marked, to be led back to by the Compass. +struct Place { + std::string name; + double latitude = 0, longitude = 0; +}; + +constexpr const char* kPlacesPath = "/gnss/places.gpx"; +constexpr size_t kMaxPlaces = 50; +constexpr size_t kPlaceNameBytes = 24; + +// The Places are kept as GPX waypoints, so a map program opens the file, and a file made by one +// is read here: every with a latitude and a longitude, named by its . +std::vector parsePlaces(const std::string& gpx); +std::string placesGpx(const std::vector& places); + +} // namespace roro::gnss diff --git a/test/test_places/test_places.cpp b/test/test_places/test_places.cpp new file mode 100644 index 0000000..653924d --- /dev/null +++ b/test/test_places/test_places.cpp @@ -0,0 +1,75 @@ +#include + +#include +#include + +#include "places.h" + +using namespace roro::gnss; + +void setUp() {} +void tearDown() {} + +void test_places_round_trip() { + std::vector places = {{"Car", 50.8503123, 4.3517456}, {"Tent ", -33.9, -151.2}}; + std::string gpx = placesGpx(places); + TEST_ASSERT_NOT_EQUAL(std::string::npos, gpx.find("Car")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, gpx.find("Tent <A&B>")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, gpx.find("")); + std::vector back = parsePlaces(gpx); + TEST_ASSERT_EQUAL(2, back.size()); + TEST_ASSERT_EQUAL_STRING("Car", back[0].name.c_str()); + TEST_ASSERT_TRUE(std::fabs(back[0].latitude - (50.8503123)) < 1e-7); + TEST_ASSERT_TRUE(std::fabs(back[0].longitude - (4.3517456)) < 1e-7); + TEST_ASSERT_EQUAL_STRING("Tent ", back[1].name.c_str()); + TEST_ASSERT_TRUE(std::fabs(back[1].longitude - (-151.2)) < 1e-7); +} + +void test_an_empty_list_is_still_gpx() { + std::string gpx = placesGpx({}); + TEST_ASSERT_NOT_EQUAL(std::string::npos, gpx.find("\n\n" + " \n 7\n Antwerp\n City\n \n" + " \n" + " Nowhere\n" + " Broken\n" + " no name\n" + " not a place\n" + "\n"; + std::vector p = parsePlaces(gpx); + TEST_ASSERT_EQUAL(3, p.size()); + TEST_ASSERT_EQUAL_STRING("Antwerp", p[0].name.c_str()); + TEST_ASSERT_TRUE(std::fabs(p[0].latitude - (51.22)) < 1e-7); + TEST_ASSERT_TRUE(std::fabs(p[0].longitude - (4.40)) < 1e-7); + TEST_ASSERT_EQUAL_STRING("Place 2", p[1].name.c_str()); + TEST_ASSERT_EQUAL_STRING("Place 3", p[2].name.c_str()); +} + +void test_a_cut_file_gives_what_is_whole() { + std::string gpx = placesGpx({{"A", 1, 2}, {"B", 3, 4}}); + gpx.resize(gpx.find("B")); + std::vector p = parsePlaces(gpx); + TEST_ASSERT_EQUAL(1, p.size()); + TEST_ASSERT_EQUAL_STRING("A", p[0].name.c_str()); +} + +void test_no_more_than_the_limit() { + std::vector many(kMaxPlaces + 10, Place{"P", 1, 2}); + TEST_ASSERT_EQUAL(kMaxPlaces, parsePlaces(placesGpx(many)).size()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_places_round_trip); + RUN_TEST(test_an_empty_list_is_still_gpx); + RUN_TEST(test_waypoints_from_a_map_program); + RUN_TEST(test_a_cut_file_gives_what_is_whole); + RUN_TEST(test_no_more_than_the_limit); + return UNITY_END(); +}