From 77d68a9d07e504a78e8d0d10beee319d0a6b0dda Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:14:29 +0000 Subject: [PATCH] feat: route live location telemetry --- lib/tdeck_ui/Telemetry/LocationFixAdapter.cpp | 81 ++++++++++ lib/tdeck_ui/Telemetry/LocationFixAdapter.h | 37 +++++ lib/tdeck_ui/UI/LXMF/UIManager.cpp | 148 ++++++++++++++++++ lib/tdeck_ui/UI/LXMF/UIManager.h | 15 ++ .../test_location_live_integration.py | 34 ++++ tests/native/test_location_fix_adapter.cpp | 111 +++++++++++++ tests/native/test_location_fix_adapter.py | 19 +++ 7 files changed, 445 insertions(+) create mode 100644 lib/tdeck_ui/Telemetry/LocationFixAdapter.cpp create mode 100644 lib/tdeck_ui/Telemetry/LocationFixAdapter.h create mode 100644 tests/build_scripts/test_location_live_integration.py create mode 100644 tests/native/test_location_fix_adapter.cpp create mode 100644 tests/native/test_location_fix_adapter.py diff --git a/lib/tdeck_ui/Telemetry/LocationFixAdapter.cpp b/lib/tdeck_ui/Telemetry/LocationFixAdapter.cpp new file mode 100644 index 00000000..5a3e5b4a --- /dev/null +++ b/lib/tdeck_ui/Telemetry/LocationFixAdapter.cpp @@ -0,0 +1,81 @@ +#include "LocationFixAdapter.h" + +#include +#include + +namespace Telemetry { +namespace { + +template +Integer roundedClamped(double value) { + const double minimum = static_cast(std::numeric_limits::min()); + const double maximum = static_cast(std::numeric_limits::max()); + if (value <= minimum) return std::numeric_limits::min(); + if (value >= maximum) return std::numeric_limits::max(); + return static_cast(std::llround(value)); +} + +template +Integer roundedUnsignedClamped(double value) { + if (value <= 0.0) return 0; + const double maximum = static_cast(std::numeric_limits::max()); + if (value >= maximum) return std::numeric_limits::max(); + return static_cast(std::llround(value)); +} + +bool finiteInRange(double value, double minimum, double maximum) { + return std::isfinite(value) && value >= minimum && value <= maximum; +} + +} // namespace + +bool locationTelemetryFromGpsFix( + const GpsFixSample& sample, + uint64_t wall_now_millis, + LocationTelemetry& output) { + if (!sample.location_valid || + sample.location_age_millis > MAX_GPS_FIX_AGE_MILLIS || + wall_now_millis == 0 || + !finiteInRange(sample.latitude_degrees, -90.0, 90.0) || + !finiteInRange(sample.longitude_degrees, -180.0, 180.0)) { + return false; + } + if ((sample.altitude_valid && !std::isfinite(sample.altitude_meters)) || + (sample.speed_valid && + (!std::isfinite(sample.speed_kilometers_per_hour) || + sample.speed_kilometers_per_hour < 0.0)) || + (sample.bearing_valid && + (!std::isfinite(sample.bearing_degrees) || + sample.bearing_degrees < 0.0 || sample.bearing_degrees >= 360.0)) || + (sample.hdop_valid && (!std::isfinite(sample.hdop) || sample.hdop < 0.0))) { + return false; + } + + LocationTelemetry candidate{}; + candidate.latitude_e6 = roundedClamped(sample.latitude_degrees * 1000000.0); + candidate.longitude_e6 = roundedClamped(sample.longitude_degrees * 1000000.0); + if (sample.altitude_valid) { + candidate.altitude_cm = roundedClamped(sample.altitude_meters * 100.0); + } + if (sample.speed_valid) { + candidate.speed_centi_kmh = roundedUnsignedClamped( + sample.speed_kilometers_per_hour * 100.0); + } + if (sample.bearing_valid) { + candidate.bearing_cdeg = roundedUnsignedClamped( + sample.bearing_degrees * 100.0); + } + if (sample.hdop_valid) { + // TinyGPS++ exposes dimensionless HDOP. Preserve the existing Pyxis + // approximation of horizontal accuracy as HDOP * 5 metres. + candidate.accuracy_cm = roundedUnsignedClamped(sample.hdop * 500.0); + } + candidate.timestamp_seconds = wall_now_millis / 1000ULL; + candidate.sensor_timestamp_seconds = candidate.timestamp_seconds; + if (candidate.timestamp_seconds == 0) return false; + + output = candidate; + return true; +} + +} // namespace Telemetry diff --git a/lib/tdeck_ui/Telemetry/LocationFixAdapter.h b/lib/tdeck_ui/Telemetry/LocationFixAdapter.h new file mode 100644 index 00000000..35101c01 --- /dev/null +++ b/lib/tdeck_ui/Telemetry/LocationFixAdapter.h @@ -0,0 +1,37 @@ +#ifndef PYXIS_TELEMETRY_LOCATION_FIX_ADAPTER_H +#define PYXIS_TELEMETRY_LOCATION_FIX_ADAPTER_H + +#include + +#include "LocationTelemetryCodec.h" + +namespace Telemetry { + +constexpr uint32_t MAX_GPS_FIX_AGE_MILLIS = 10000U; + +struct GpsFixSample { + bool location_valid = false; + uint32_t location_age_millis = 0; + double latitude_degrees = 0.0; + double longitude_degrees = 0.0; + + bool altitude_valid = false; + double altitude_meters = 0.0; + bool speed_valid = false; + double speed_kilometers_per_hour = 0.0; + bool bearing_valid = false; + double bearing_degrees = 0.0; + bool hdop_valid = false; + double hdop = 0.0; +}; + +// Converts a fresh GPS sample into the fixed-unit telemetry contract. Output is +// modified only on success. wall_now_millis supplies the observation timestamp. +bool locationTelemetryFromGpsFix( + const GpsFixSample& sample, + uint64_t wall_now_millis, + LocationTelemetry& output); + +} // namespace Telemetry + +#endif diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 9e9e4e76..d8d99b86 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -23,6 +23,8 @@ #include #include #include +#include +#include using namespace RNS; @@ -106,6 +108,83 @@ public: }; static std::shared_ptr s_lxst_announce_handler; +uint64_t monotonicMillis() { + return static_cast(esp_timer_get_time() / 1000LL); +} + +bool peerIdFromHash(const Bytes& hash, Telemetry::PeerId& output) { + if (hash.size() != Telemetry::PEER_ID_SIZE) return false; + std::memcpy(output.bytes, hash.data(), Telemetry::PEER_ID_SIZE); + return true; +} + +class LiveLocationEnvelopeRouter : public Telemetry::LocationEnvelopeRouter { +public: + explicit LiveLocationEnvelopeRouter(::LXMF::LXMRouter& router) + : router_(router) {} + + bool queue( + const Telemetry::OutboundLocationEnvelope& envelope, + uint64_t exclusive_deadline_monotonic_millis, + uint64_t& ownership_monotonic_millis) override { + Bytes destination_hash(envelope.destination.bytes, Telemetry::PEER_ID_SIZE); + Identity destination_identity = Identity::recall(destination_hash); + Destination destination(Type::NONE); + if (destination_identity) { + destination = Destination( + destination_identity, Type::Destination::OUT, + Type::Destination::SINGLE, "lxmf", "delivery"); + } + + ::LXMF::LXMessage message( + destination, + router_.delivery_destination(), + Bytes(), + Bytes(), + ::LXMF::Type::Message::OPPORTUNISTIC); + if (!destination_identity) { + message.destination_hash(destination_hash); + } + for (std::size_t index = 0; index < envelope.field_count; ++index) { + const auto& field = envelope.fields[index]; + if (!message.fields_set( + Bytes(field.key, field.key_size), + Bytes(field.value, field.value_size))) { + return false; + } + } + + GuardContext guard_context{ + exclusive_deadline_monotonic_millis, + &ownership_monotonic_millis}; + try { + return router_.try_handle_outbound( + message, claimOwnership, &guard_context) == + ::LXMF::OutboundAdmissionResult::ACCEPTED; + } catch (const std::exception& error) { + WARNING((std::string("Location outbound preparation failed: ") + + error.what()).c_str()); + return false; + } + } + +private: + struct GuardContext { + uint64_t deadline; + uint64_t* ownership_time; + }; + + static bool claimOwnership(void* raw_context) { + auto& context = *static_cast(raw_context); + const uint64_t now = monotonicMillis(); + if (now >= context.deadline) return false; + *context.ownership_time = now; + return true; + } + + ::LXMF::LXMRouter& router_; +}; + // LoRa bandwidth is a production constraint: Pyxis advertises and accepts only // LXST ULBW/Codec2-700C. int UIManager::_preferred_profile = UIManager::LXST_PROFILE_ULBW; @@ -116,6 +195,7 @@ int UIManager::profile_to_codec2_mode(int profile) { UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::MessageStore& store) : _reticulum(reticulum), _router(router), _store(store), + _gps(nullptr), // Vanilla upstream RNS::Destination has no default ctor; construct in // a Type::NONE state, then assign a real Destination later. (The fork // had a default ctor that pyxis was implicitly relying on.) @@ -460,6 +540,44 @@ void UIManager::update() { const RadioActivity::Snapshot snapshot = _radio_activity_snapshot_provider(); _radio_activity_screen->render(snapshot, _radio_activity_config, now); } + + // Poll one consent-gated location item outside LVGL_LOCK. Packing and the + // router's synchronous ownership copy may perform crypto work; neither is + // a rendering operation. With no explicit sessions this remains a no-op. + const uint64_t wall_now_millis = + static_cast(RNS::Utilities::OS::ltime()); + Telemetry::GpsFixSample gps_sample{}; + if (_gps) { + gps_sample.location_valid = _gps->location.isValid(); + gps_sample.location_age_millis = _gps->location.age(); + gps_sample.latitude_degrees = _gps->location.lat(); + gps_sample.longitude_degrees = _gps->location.lng(); + gps_sample.altitude_valid = _gps->altitude.isValid(); + gps_sample.altitude_meters = _gps->altitude.meters(); + gps_sample.speed_valid = _gps->speed.isValid(); + gps_sample.speed_kilometers_per_hour = _gps->speed.kmph(); + gps_sample.bearing_valid = _gps->course.isValid(); + gps_sample.bearing_degrees = _gps->course.deg(); + gps_sample.hdop_valid = _gps->hdop.isValid(); + gps_sample.hdop = _gps->hdop.hdop(); + } + Telemetry::LocationTelemetry current_location{}; + const bool current_location_valid = Telemetry::locationTelemetryFromGpsFix( + gps_sample, wall_now_millis, current_location); + LiveLocationEnvelopeRouter location_router(_router); + const Telemetry::DispatchResult location_result = + Telemetry::dispatchLocationShare( + _location_shares, + wall_now_millis, + monotonicMillis(), + current_location_valid, + current_location, + location_router); + if (location_result == Telemetry::DispatchResult::QUEUED) { + INFO("Location telemetry queued"); + } else if (location_result == Telemetry::DispatchResult::CEASE_QUEUED) { + INFO("Location cease queued"); + } LVGL_LOCK(); // Outgoing starts are initiated here while the recursive LVGL mutex is @@ -824,11 +942,41 @@ void UIManager::set_ble_interface(Interface* iface) { } void UIManager::set_gps(TinyGPSPlus* gps) { + _gps = gps; if (_conversation_list_screen) { _conversation_list_screen->set_gps(gps); } } +Telemetry::ShareSessionResult UIManager::start_location_sharing( + const Bytes& peer_hash, + const Telemetry::ShareStartOptions& options) { + Telemetry::PeerId peer{}; + if (!peerIdFromHash(peer_hash, peer)) { + return Telemetry::ShareSessionResult::INVALID_ARGUMENT; + } + return _location_shares.start( + peer, options, + static_cast(RNS::Utilities::OS::ltime())); +} + +Telemetry::ShareSessionResult UIManager::stop_location_sharing( + const Bytes& peer_hash) { + Telemetry::PeerId peer{}; + if (!peerIdFromHash(peer_hash, peer)) { + return Telemetry::ShareSessionResult::INVALID_ARGUMENT; + } + return _location_shares.stop( + peer, static_cast(RNS::Utilities::OS::ltime())); +} + +bool UIManager::get_location_share_session( + const Bytes& peer_hash, + Telemetry::ShareSession& output) const { + Telemetry::PeerId peer{}; + return peerIdFromHash(peer_hash, peer) && _location_shares.get(peer, output); +} + void UIManager::on_back_to_conversation_list() { back(); } diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index acd7702a..2d999cb9 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -41,6 +41,8 @@ #include "LXMF/PropagationNodeManager.h" #include "LXMF/MessageStore.h" #include "Telemetry/LocationMessagePolicy.h" +#include "Telemetry/LocationFixAdapter.h" +#include "Telemetry/LocationLxmfAdapter.h" #include #include @@ -187,6 +189,17 @@ public: */ void set_gps(TinyGPSPlus* gps); + // Location sharing is always explicit and peer-scoped. No session exists + // until the UI calls start_location_sharing(). + Telemetry::ShareSessionResult start_location_sharing( + const RNS::Bytes& peer_hash, + const Telemetry::ShareStartOptions& options); + Telemetry::ShareSessionResult stop_location_sharing( + const RNS::Bytes& peer_hash); + bool get_location_share_session( + const RNS::Bytes& peer_hash, + Telemetry::ShareSession& output) const; + /** * Get settings screen for external configuration */ @@ -318,6 +331,8 @@ private: ::LXMF::LXMRouter& _router; ::LXMF::MessageStore& _store; Telemetry::PeerLocationStore _peer_locations; + Telemetry::LocationShareScheduler _location_shares; + TinyGPSPlus* _gps; RNS::Destination _lxst_destination; NavigationStack _navigation; diff --git a/tests/build_scripts/test_location_live_integration.py b/tests/build_scripts/test_location_live_integration.py new file mode 100644 index 00000000..af05808b --- /dev/null +++ b/tests/build_scripts/test_location_live_integration.py @@ -0,0 +1,34 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CPP = ROOT / "lib/tdeck_ui/UI/LXMF/UIManager.cpp" +HEADER = ROOT / "lib/tdeck_ui/UI/LXMF/UIManager.h" + + +def test_live_location_dispatch_uses_authenticated_scheduler_and_guarded_router_before_lvgl(): + cpp = CPP.read_text() + header = HEADER.read_text() + update = cpp[cpp.index("void UIManager::update()") : cpp.index("void UIManager::show_conversation_list")] + + assert "Telemetry::LocationShareScheduler _location_shares;" in header + assert "TinyGPSPlus* _gps;" in header + assert "locationTelemetryFromGpsFix" in update + assert "dispatchLocationShare" in update + assert update.index("dispatchLocationShare") < update.index("LVGL_LOCK();") + + assert "class LiveLocationEnvelopeRouter" in cpp + router_block = cpp[cpp.index("class LiveLocationEnvelopeRouter") : cpp.index("UIManager::UIManager")] + assert "try_handle_outbound" in router_block + assert "OutboundAdmissionResult::ACCEPTED" in router_block + assert "exclusive_deadline_monotonic_millis" in router_block + assert "esp_timer_get_time" in cpp + assert "claimOwnership" in router_block + assert "fields_set" in router_block + assert "save_message" not in router_block + + +def test_live_location_control_surface_is_explicit_opt_in(): + header = HEADER.read_text() + assert "start_location_sharing(" in header + assert "stop_location_sharing(" in header + assert "get_location_share_session(" in header diff --git a/tests/native/test_location_fix_adapter.cpp b/tests/native/test_location_fix_adapter.cpp new file mode 100644 index 00000000..cd347cd5 --- /dev/null +++ b/tests/native/test_location_fix_adapter.cpp @@ -0,0 +1,111 @@ +#include "Telemetry/LocationFixAdapter.h" + +#include +#include +#include +#include + +namespace { +int failures = 0; +#define CHECK(expr) do { if (!(expr)) { ++failures; std::cerr << "FAIL line " << __LINE__ << ": " #expr "\n"; } } while (false) + +Telemetry::GpsFixSample validSample() { + Telemetry::GpsFixSample sample{}; + sample.location_valid = true; + sample.location_age_millis = 250; + sample.latitude_degrees = 37.7749; + sample.longitude_degrees = -122.4194; + sample.altitude_valid = true; + sample.altitude_meters = 16.25; + sample.speed_valid = true; + sample.speed_kilometers_per_hour = 12.34; + sample.bearing_valid = true; + sample.bearing_degrees = 42.0; + sample.hdop_valid = true; + sample.hdop = 0.7; + return sample; +} + +void convertsFreshFixWithExplicitUnits() { + const auto sample = validSample(); + Telemetry::LocationTelemetry output{}; + CHECK(Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, output)); + CHECK(output.latitude_e6 == 37774900); + CHECK(output.longitude_e6 == -122419400); + CHECK(output.altitude_cm == 1625); + CHECK(output.speed_centi_kmh == 1234U); + CHECK(output.bearing_cdeg == 4200U); + CHECK(output.accuracy_cm == 350U); + CHECK(output.timestamp_seconds == 1700000000ULL); + CHECK(output.sensor_timestamp_seconds == 1700000000ULL); +} + +void rejectsUnavailableStaleAndInvalidFixesTransactionally() { + Telemetry::LocationTelemetry sentinel{}; + sentinel.latitude_e6 = 123; + auto sample = validSample(); + sample.location_valid = false; + CHECK(!Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, sentinel)); + CHECK(sentinel.latitude_e6 == 123); + + sample = validSample(); + sample.location_age_millis = Telemetry::MAX_GPS_FIX_AGE_MILLIS + 1U; + CHECK(!Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, sentinel)); + CHECK(sentinel.latitude_e6 == 123); + + sample = validSample(); + sample.latitude_degrees = 91.0; + CHECK(!Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, sentinel)); + sample = validSample(); + CHECK(!Telemetry::locationTelemetryFromGpsFix(sample, 0, sentinel)); +} + +void defaultsMissingOptionalSensorsAndClampsRepresentableValues() { + auto sample = validSample(); + sample.altitude_valid = false; + sample.speed_valid = false; + sample.bearing_valid = false; + sample.hdop_valid = false; + Telemetry::LocationTelemetry output{}; + CHECK(Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, output)); + CHECK(output.altitude_cm == 0); + CHECK(output.speed_centi_kmh == 0U); + CHECK(output.bearing_cdeg == 0U); + CHECK(output.accuracy_cm == 0U); + + sample = validSample(); + sample.altitude_meters = 1.0e20; + sample.speed_kilometers_per_hour = 1.0e20; + sample.hdop = 1.0e20; + CHECK(Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, output)); + CHECK(output.altitude_cm == std::numeric_limits::max()); + CHECK(output.speed_centi_kmh == std::numeric_limits::max()); + CHECK(output.accuracy_cm == std::numeric_limits::max()); +} + +void rejectsNonFiniteAndInvalidOptionalDomains() { + auto sample = validSample(); + Telemetry::LocationTelemetry output{}; + sample.longitude_degrees = std::numeric_limits::quiet_NaN(); + CHECK(!Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, output)); + sample = validSample(); + sample.speed_kilometers_per_hour = -1.0; + CHECK(!Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, output)); + sample = validSample(); + sample.bearing_degrees = 360.0; + CHECK(!Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, output)); + sample = validSample(); + sample.hdop = -0.1; + CHECK(!Telemetry::locationTelemetryFromGpsFix(sample, 1700000000123ULL, output)); +} +} + +int main() { + convertsFreshFixWithExplicitUnits(); + rejectsUnavailableStaleAndInvalidFixesTransactionally(); + defaultsMissingOptionalSensorsAndClampsRepresentableValues(); + rejectsNonFiniteAndInvalidOptionalDomains(); + if (failures != 0) return 1; + std::cout << "location fix adapter tests passed\n"; + return 0; +} diff --git a/tests/native/test_location_fix_adapter.py b/tests/native/test_location_fix_adapter.py new file mode 100644 index 00000000..d0fb79c7 --- /dev/null +++ b/tests/native/test_location_fix_adapter.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from native_test import compile_and_run + +ROOT = Path(__file__).resolve().parents[2] + + +def test_location_fix_adapter_native(tmp_path): + result = compile_and_run( + tmp_path, + name="test_location_fix_adapter", + sources=[ + ROOT / "tests/native/test_location_fix_adapter.cpp", + ROOT / "lib/tdeck_ui/Telemetry/LocationFixAdapter.cpp", + ], + include_dirs=[ROOT / "lib/tdeck_ui"], + sanitize=True, + ) + assert "location fix adapter tests passed" in result.stdout