From cfe8615f9aeb663e8bd3fe1d3e0e6aadad583471 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:49:01 +0000 Subject: [PATCH] feat: add LXMF location envelope adapter --- .../Telemetry/LocationLxmfAdapter.cpp | 155 ++++++++++++++ lib/tdeck_ui/Telemetry/LocationLxmfAdapter.h | 82 ++++++++ .../Telemetry/LocationMessagePolicy.cpp | 10 +- .../Telemetry/LocationShareScheduler.cpp | 6 + .../Telemetry/LocationShareScheduler.h | 4 + .../Telemetry/LocationStateRecord.cpp | 8 +- platformio.ini | 7 +- .../test_telemetry_field_roundtrip.cpp | 57 ++++- tests/native/test_location_lxmf_adapter.cpp | 199 ++++++++++++++++++ tests/native/test_location_lxmf_adapter.py | 37 ++++ tests/native/test_location_message_policy.cpp | 10 +- tests/native/test_location_state_record.cpp | 18 ++ .../test_microlxmf_telemetry_field.py | 4 +- 13 files changed, 582 insertions(+), 15 deletions(-) create mode 100644 lib/tdeck_ui/Telemetry/LocationLxmfAdapter.cpp create mode 100644 lib/tdeck_ui/Telemetry/LocationLxmfAdapter.h create mode 100644 tests/native/test_location_lxmf_adapter.cpp create mode 100644 tests/native/test_location_lxmf_adapter.py diff --git a/lib/tdeck_ui/Telemetry/LocationLxmfAdapter.cpp b/lib/tdeck_ui/Telemetry/LocationLxmfAdapter.cpp new file mode 100644 index 00000000..df000eb3 --- /dev/null +++ b/lib/tdeck_ui/Telemetry/LocationLxmfAdapter.cpp @@ -0,0 +1,155 @@ +#include "LocationLxmfAdapter.h" + +#include + +namespace Telemetry { +namespace { + +bool toSignedMillis(uint64_t value, int64_t& output) { + const uint64_t maximum = + static_cast(std::numeric_limits::max()); + if (value > maximum) return false; + output = static_cast(value); + return true; +} + +EnvelopeBuildResult encodeTelemetryField( + const LocationTelemetry& location, + RawLxmfField& field) { + uint8_t inner[128]{}; + std::size_t inner_size = 0; + if (encodeLocationTelemetry(location, inner, sizeof(inner), inner_size) != + EncodeResult::OK) { + return EnvelopeBuildResult::ENCODE_FAILED; + } + field.key[0] = FIELD_TELEMETRY; + field.key_size = 1; + if (wrapLxmfBinaryFieldValue( + inner, inner_size, field.value, sizeof(field.value), + field.value_size) != FieldValueResult::OK) { + return EnvelopeBuildResult::ENCODE_FAILED; + } + return EnvelopeBuildResult::OK; +} + +} // namespace + +EnvelopeBuildResult buildOutboundLocationEnvelope( + const ShareWork& work, + const LocationTelemetry& current_location, + uint64_t wall_now_millis, + OutboundLocationEnvelope& output) { + OutboundLocationEnvelope candidate{}; + candidate.destination = work.peer; + + LocationTelemetry wire_location = current_location; + CustomLocationMeta meta{}; + if (work.type == ShareWorkType::CEASE) { + wire_location = LocationTelemetry{}; + wire_location.timestamp_seconds = wall_now_millis / 1000ULL; + wire_location.sensor_timestamp_seconds = + wire_location.timestamp_seconds; + meta.has_cease = true; + meta.cease = true; + } else if (!isValidPeerLocationInput( + wire_location, CustomLocationMeta{}, wall_now_millis)) { + return EnvelopeBuildResult::INVALID_ARGUMENT; + } + + if (encodeTelemetryField(wire_location, candidate.fields[0]) != + EnvelopeBuildResult::OK) { + return EnvelopeBuildResult::ENCODE_FAILED; + } + candidate.field_count = 1; + + const bool has_radius = + work.has_approx_radius || work.approx_radius_meters != 0; + const bool has_meta = meta.has_cease || work.has_expiry || has_radius; + if (!has_meta) { + output = candidate; + return EnvelopeBuildResult::OK; + } + + if (work.has_expiry) { + if (!toSignedMillis(work.expires_at_millis, meta.expires_millis)) { + return EnvelopeBuildResult::INVALID_ARGUMENT; + } + meta.has_expires = true; + } + if (has_radius) { + if (work.approx_radius_meters < 0) { + return EnvelopeBuildResult::INVALID_ARGUMENT; + } + meta.has_approx_radius = true; + meta.approx_radius_meters = work.approx_radius_meters; + } + if (meta.has_cease) { + if (!toSignedMillis(wall_now_millis, meta.timestamp_millis)) { + return EnvelopeBuildResult::INVALID_ARGUMENT; + } + meta.has_timestamp = true; + } + + RawLxmfField& meta_field = candidate.fields[1]; + meta_field.key[0] = 0xccU; // MessagePack uint8 + meta_field.key[1] = FIELD_CUSTOM_META; + meta_field.key_size = 2; + uint8_t encoded_meta[128]{}; + std::size_t encoded_meta_size = 0; + const CustomMetaResult meta_result = encodeCustomLocationMeta( + meta, encoded_meta, sizeof(encoded_meta), encoded_meta_size); + if (meta_result != CustomMetaResult::OK) { + return EnvelopeBuildResult::ENCODE_FAILED; + } + if (wrapLxmfBinaryFieldValue( + encoded_meta, encoded_meta_size, + meta_field.value, sizeof(meta_field.value), + meta_field.value_size) != FieldValueResult::OK) { + return EnvelopeBuildResult::ENCODE_FAILED; + } + candidate.field_count = 2; + output = candidate; + return EnvelopeBuildResult::OK; +} + +DispatchResult dispatchLocationShare( + LocationShareScheduler& scheduler, + uint64_t wall_now_millis, + uint64_t monotonic_now_millis, + bool current_location_valid, + const LocationTelemetry& current_location, + LocationEnvelopeRouter& router) { + ShareWork work{}; + const SharePollResult poll_result = scheduler.poll( + wall_now_millis, monotonic_now_millis, current_location_valid, work); + if (poll_result == SharePollResult::NO_WORK) return DispatchResult::NO_WORK; + if (poll_result == SharePollResult::CLOCK_UNAVAILABLE) { + return DispatchResult::CLOCK_UNAVAILABLE; + } + + OutboundLocationEnvelope envelope{}; + const EnvelopeBuildResult build_result = buildOutboundLocationEnvelope( + work, current_location, wall_now_millis, envelope); + if (build_result != EnvelopeBuildResult::OK) { + scheduler.acknowledge(work.peer, work.token, false, + wall_now_millis, monotonic_now_millis); + return DispatchResult::ENCODE_FAILED; + } + + uint64_t ownership_monotonic_millis = 0; + const bool accepted = router.queue( + envelope, work.ack_deadline_monotonic_millis, + ownership_monotonic_millis); + const ShareAckResult ack = scheduler.acknowledge( + work.peer, work.token, accepted, + wall_now_millis, + accepted ? ownership_monotonic_millis : monotonic_now_millis); + if (!accepted) return DispatchResult::QUEUE_REJECTED; + if (ack == ShareAckResult::CEASED) return DispatchResult::CEASE_QUEUED; + if (ack == ShareAckResult::ACCEPTED) return DispatchResult::QUEUED; + // A synchronous router must return before the scheduler lease expires. + // If that invariant is violated, never claim queue success to the caller. + return DispatchResult::CLOCK_UNAVAILABLE; +} + +} // namespace Telemetry diff --git a/lib/tdeck_ui/Telemetry/LocationLxmfAdapter.h b/lib/tdeck_ui/Telemetry/LocationLxmfAdapter.h new file mode 100644 index 00000000..497a1b5d --- /dev/null +++ b/lib/tdeck_ui/Telemetry/LocationLxmfAdapter.h @@ -0,0 +1,82 @@ +#ifndef PYXIS_TELEMETRY_LOCATION_LXMF_ADAPTER_H +#define PYXIS_TELEMETRY_LOCATION_LXMF_ADAPTER_H + +#include +#include + +#include "LocationShareScheduler.h" + +namespace Telemetry { + +constexpr std::size_t LOCATION_LXMF_MAX_FIELDS = 2; +constexpr std::size_t LOCATION_LXMF_MAX_KEY_SIZE = 2; +constexpr std::size_t LOCATION_LXMF_MAX_VALUE_SIZE = 192; + +enum class LocationDeliveryMethod : uint8_t { + OPPORTUNISTIC, +}; + +struct RawLxmfField { + uint8_t key[LOCATION_LXMF_MAX_KEY_SIZE]{}; + std::size_t key_size = 0; + uint8_t value[LOCATION_LXMF_MAX_VALUE_SIZE]{}; + std::size_t value_size = 0; +}; + +struct OutboundLocationEnvelope { + PeerId destination{}; + LocationDeliveryMethod method = LocationDeliveryMethod::OPPORTUNISTIC; + std::size_t title_size = 0; + std::size_t content_size = 0; + RawLxmfField fields[LOCATION_LXMF_MAX_FIELDS]{}; + std::size_t field_count = 0; +}; + +enum class EnvelopeBuildResult : uint8_t { + OK, + INVALID_ARGUMENT, + ENCODE_FAILED, +}; + +enum class DispatchResult : uint8_t { + NO_WORK, + CLOCK_UNAVAILABLE, + ENCODE_FAILED, + QUEUE_REJECTED, + QUEUED, + CEASE_QUEUED, +}; + +class LocationEnvelopeRouter { +public: + virtual ~LocationEnvelopeRouter() {} + // true means the implementation synchronously copied/took ownership of the + // complete envelope. false means it retained nothing and queued nothing. + // On true, ownership_monotonic_millis is the monotonic instant immediately + // before the synchronous ownership copy and is strictly before the supplied + // exclusive deadline. On false, the router retained and queued nothing. + virtual bool queue( + const OutboundLocationEnvelope& envelope, + uint64_t exclusive_deadline_monotonic_millis, + uint64_t& ownership_monotonic_millis) = 0; +}; + +EnvelopeBuildResult buildOutboundLocationEnvelope( + const ShareWork& work, + const LocationTelemetry& current_location, + uint64_t wall_now_millis, + OutboundLocationEnvelope& output); + +// Polls and resolves exactly one scheduler lease. Scheduler acknowledgement is +// positive only after queue() confirms synchronous ownership. +DispatchResult dispatchLocationShare( + LocationShareScheduler& scheduler, + uint64_t wall_now_millis, + uint64_t monotonic_now_millis, + bool current_location_valid, + const LocationTelemetry& current_location, + LocationEnvelopeRouter& router); + +} // namespace Telemetry + +#endif diff --git a/lib/tdeck_ui/Telemetry/LocationMessagePolicy.cpp b/lib/tdeck_ui/Telemetry/LocationMessagePolicy.cpp index 919ab4ab..139317d4 100644 --- a/lib/tdeck_ui/Telemetry/LocationMessagePolicy.cpp +++ b/lib/tdeck_ui/Telemetry/LocationMessagePolicy.cpp @@ -43,10 +43,14 @@ LocationMessageDecision classifyInboundLocationMessage( return decision; } + BinaryView custom_meta_payload; if (message.custom_meta.present && - decodeCustomLocationMeta(message.custom_meta.raw_value.data, - message.custom_meta.raw_value.size, - decision.meta) != CustomMetaResult::OK) { + (unwrapLxmfBinaryFieldValue(message.custom_meta.raw_value.data, + message.custom_meta.raw_value.size, + custom_meta_payload) != FieldValueResult::OK || + decodeCustomLocationMeta(custom_meta_payload.data, + custom_meta_payload.size, + decision.meta) != CustomMetaResult::OK)) { // Metadata controls cease, expiry, and source ordering. Applying the // telemetry while ignoring malformed metadata would violate all three. decision.kind = LocationMessageKind::MALFORMED_LOCATION; diff --git a/lib/tdeck_ui/Telemetry/LocationShareScheduler.cpp b/lib/tdeck_ui/Telemetry/LocationShareScheduler.cpp index d502c32c..6de4c683 100644 --- a/lib/tdeck_ui/Telemetry/LocationShareScheduler.cpp +++ b/lib/tdeck_ui/Telemetry/LocationShareScheduler.cpp @@ -225,6 +225,8 @@ ShareSessionResult LocationShareScheduler::start( ShareSession session{}; session.peer = peer; session.cadence_millis = options.cadence_millis; + session.has_approx_radius = + options.has_approx_radius || options.approx_radius_meters != 0; session.approx_radius_meters = options.approx_radius_meters; session.has_expiry = has_expiry; session.expires_at_millis = expires_at_millis; @@ -268,6 +270,8 @@ ShareSessionResult LocationShareScheduler::restore( ShareSession session{}; session.peer = peer; session.cadence_millis = record.cadence_millis; + session.has_approx_radius = + record.has_approx_radius || record.approx_radius_meters != 0; session.approx_radius_meters = record.approx_radius_meters; session.has_expiry = record.has_expiry; session.expires_at_millis = record.has_expiry ? record.expires_at_millis : 0; @@ -364,6 +368,7 @@ SharePollResult LocationShareScheduler::poll( monotonic_now_millis + ACKNOWLEDGEMENT_LEASE_MILLIS; candidate.has_expiry = session.has_expiry; candidate.expires_at_millis = session.expires_at_millis; + candidate.has_approx_radius = session.has_approx_radius; candidate.approx_radius_meters = session.approx_radius_meters; session.awaiting_ack = true; @@ -461,6 +466,7 @@ ShareSnapshotResult LocationShareScheduler::snapshot( ShareRestoreEntry entry{}; entry.peer = session.peer; entry.record.cadence_millis = session.cadence_millis; + entry.record.has_approx_radius = session.has_approx_radius; entry.record.approx_radius_meters = session.approx_radius_meters; entry.record.has_expiry = session.has_expiry; entry.record.expires_at_millis = session.has_expiry diff --git a/lib/tdeck_ui/Telemetry/LocationShareScheduler.h b/lib/tdeck_ui/Telemetry/LocationShareScheduler.h index c2deefa3..920a2b7c 100644 --- a/lib/tdeck_ui/Telemetry/LocationShareScheduler.h +++ b/lib/tdeck_ui/Telemetry/LocationShareScheduler.h @@ -72,6 +72,7 @@ enum class MidnightResult : uint8_t { struct ShareStartOptions { ShareDuration duration = ShareDuration::MINUTES_15; uint32_t cadence_millis = 60000; + bool has_approx_radius = false; int32_t approx_radius_meters = 0; // Required only for LOCAL_MIDNIGHT. The platform resolves the exact // DST-aware local boundary before calling the scheduler. @@ -80,6 +81,7 @@ struct ShareStartOptions { struct ShareRestoreRecord { uint32_t cadence_millis = 60000; + bool has_approx_radius = false; int32_t approx_radius_meters = 0; bool has_expiry = false; uint64_t expires_at_millis = 0; @@ -94,6 +96,7 @@ struct ShareRestoreEntry { struct ShareSession { PeerId peer{}; uint32_t cadence_millis = 60000; + bool has_approx_radius = false; int32_t approx_radius_meters = 0; bool has_expiry = false; uint64_t expires_at_millis = 0; @@ -115,6 +118,7 @@ struct ShareWork { uint64_t ack_deadline_monotonic_millis = 0; bool has_expiry = false; uint64_t expires_at_millis = 0; + bool has_approx_radius = false; int32_t approx_radius_meters = 0; }; diff --git a/lib/tdeck_ui/Telemetry/LocationStateRecord.cpp b/lib/tdeck_ui/Telemetry/LocationStateRecord.cpp index 10a38a49..d660e629 100644 --- a/lib/tdeck_ui/Telemetry/LocationStateRecord.cpp +++ b/lib/tdeck_ui/Telemetry/LocationStateRecord.cpp @@ -11,6 +11,7 @@ namespace { constexpr uint8_t MAGIC[4] = {'P', 'Y', 'L', 'S'}; constexpr uint8_t SESSION_HAS_EXPIRY = 0x01; constexpr uint8_t SESSION_CEASE_PENDING = 0x02; +constexpr uint8_t SESSION_HAS_APPROX_RADIUS = 0x04; constexpr uint8_t LOCATION_HAS_EXPIRY = 0x01; uint16_t readU16(const uint8_t* data) { @@ -131,7 +132,8 @@ void encodeSession(const ShareRestoreEntry& entry, uint8_t* output) { std::memcpy(output, entry.peer.bytes, PEER_ID_SIZE); output[16] = static_cast( (entry.record.has_expiry ? SESSION_HAS_EXPIRY : 0U) | - (entry.record.cease_pending ? SESSION_CEASE_PENDING : 0U)); + (entry.record.cease_pending ? SESSION_CEASE_PENDING : 0U) | + (entry.record.has_approx_radius ? SESSION_HAS_APPROX_RADIUS : 0U)); output[17] = 0; writeU32(output + 18, entry.record.cadence_millis); writeI32(output + 22, entry.record.approx_radius_meters); @@ -145,6 +147,8 @@ ShareRestoreEntry decodeSession(const uint8_t* data) { entry.record.cease_pending = (data[16] & SESSION_CEASE_PENDING) != 0; entry.record.cadence_millis = readU32(data + 18); entry.record.approx_radius_meters = readI32(data + 22); + entry.record.has_approx_radius = + (data[16] & SESSION_HAS_APPROX_RADIUS) != 0; entry.record.expires_at_millis = readU64(data + 26); return entry; } @@ -193,7 +197,7 @@ LocationStateRecordResult validateEncodedRecords( std::size_t location_count) { for (std::size_t index = 0; index < session_count; ++index) { const uint8_t* current = payload + index * LOCATION_STATE_SESSION_BYTES; - if ((current[16] & ~0x03U) != 0 || current[17] != 0) { + if ((current[16] & ~0x07U) != 0 || current[17] != 0) { return LocationStateRecordResult::MALFORMED; } const ShareRestoreEntry entry = decodeSession(current); diff --git a/platformio.ini b/platformio.ini index 177af3ed..e526511c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -106,10 +106,9 @@ lib_deps = ; aad87f5: transactional message persistence and rollback recovery, keeps the ; rollback snapshot off the LVGL task stack, bounds-checks empty LXMF fields, ; and attaches delivery callbacks to the transport-owned PacketReceipt. - ; d9bbc04: merged microLXMF main with transactional persistence, announced - ; direct-delivery stamp costs, bounded cache handling, and the combined - ; native/conformance regression coverage used for this test build. - https://github.com/torlando-tech/microLXMF.git#d9bbc04cf69bfa9b555c3f293b89b440b4820518 + ; e672a28: d9bbc04 plus explicit non-evicting outbound admission, + ; synchronous ownership reporting, and an optional pre-ownership guard. + https://github.com/torlando-tech/microLXMF.git#e672a28673b138564177e30d6c94f29ba5f7b066 lvgl/lvgl@^8.3.11 bblanchon/ArduinoJson@^7.4.2 hideakitai/MsgPack@^0.4.2 diff --git a/tests/microlxmf/test_telemetry_field_roundtrip.cpp b/tests/microlxmf/test_telemetry_field_roundtrip.cpp index 7b09def0..a0986248 100644 --- a/tests/microlxmf/test_telemetry_field_roundtrip.cpp +++ b/tests/microlxmf/test_telemetry_field_roundtrip.cpp @@ -12,6 +12,8 @@ int main() { constexpr uint8_t key_bytes[] = {Telemetry::FIELD_TELEMETRY}; + constexpr uint8_t meta_key_bytes[] = { + 0xccU, Telemetry::FIELD_CUSTOM_META}; constexpr std::size_t TELEMETRY_CAPACITY = 128; Telemetry::LocationTelemetry outbound{}; @@ -42,6 +44,28 @@ int main() { return EXIT_FAILURE; } + Telemetry::CustomLocationMeta outbound_meta{}; + outbound_meta.has_expires = true; + outbound_meta.expires_millis = 1700000900000LL; + outbound_meta.has_approx_radius = true; + outbound_meta.approx_radius_meters = 0; + uint8_t inner_meta[96]{}; + std::size_t inner_meta_size = 0; + if (Telemetry::encodeCustomLocationMeta( + outbound_meta, inner_meta, sizeof(inner_meta), inner_meta_size) != + Telemetry::CustomMetaResult::OK) { + std::cerr << "codec failed to encode custom metadata\n"; + return EXIT_FAILURE; + } + uint8_t raw_meta[101]{}; + std::size_t raw_meta_size = 0; + if (Telemetry::wrapLxmfBinaryFieldValue( + inner_meta, inner_meta_size, raw_meta, sizeof(raw_meta), + raw_meta_size) != Telemetry::FieldValueResult::OK) { + std::cerr << "codec failed to wrap custom metadata\n"; + return EXIT_FAILURE; + } + RNS::Identity source_identity; RNS::Identity destination_identity; RNS::Destination source( @@ -56,8 +80,11 @@ int main() { LXMF::Type::Message::OPPORTUNISTIC); const RNS::Bytes key(key_bytes, sizeof(key_bytes)); const RNS::Bytes value(raw_value, raw_size); - if (!message.fields_set(key, value)) { - std::cerr << "fields_set rejected telemetry\n"; + const RNS::Bytes meta_key(meta_key_bytes, sizeof(meta_key_bytes)); + const RNS::Bytes meta_value(raw_meta, raw_meta_size); + if (!message.fields_set(key, value) || + !message.fields_set(meta_key, meta_value)) { + std::cerr << "fields_set rejected location fields\n"; return EXIT_FAILURE; } @@ -69,6 +96,11 @@ int main() { std::cerr << "raw telemetry BIN span changed across microLXMF pack/unpack\n"; return EXIT_FAILURE; } + const RNS::Bytes* decoded_meta_value = decoded_message.fields_get(meta_key); + if (decoded_meta_value == nullptr || *decoded_meta_value != meta_value) { + std::cerr << "raw metadata BIN span changed across microLXMF pack/unpack\n"; + return EXIT_FAILURE; + } Telemetry::BinaryView unwrapped{}; if (Telemetry::unwrapLxmfBinaryFieldValue( @@ -90,6 +122,25 @@ int main() { return EXIT_FAILURE; } - std::cout << "microLXMF telemetry field roundtrip: passed\n"; + Telemetry::BinaryView unwrapped_meta{}; + if (Telemetry::unwrapLxmfBinaryFieldValue( + decoded_meta_value->data(), decoded_meta_value->size(), + unwrapped_meta) != Telemetry::FieldValueResult::OK) { + std::cerr << "codec failed to unwrap microLXMF metadata field\n"; + return EXIT_FAILURE; + } + Telemetry::CustomLocationMeta inbound_meta{}; + if (Telemetry::decodeCustomLocationMeta( + unwrapped_meta.data, unwrapped_meta.size, inbound_meta) != + Telemetry::CustomMetaResult::OK || + !inbound_meta.has_expires || + inbound_meta.expires_millis != outbound_meta.expires_millis || + !inbound_meta.has_approx_radius || + inbound_meta.approx_radius_meters != 0) { + std::cerr << "custom metadata changed across codec/microLXMF roundtrip\n"; + return EXIT_FAILURE; + } + + std::cout << "microLXMF location fields roundtrip: passed\n"; return EXIT_SUCCESS; } diff --git a/tests/native/test_location_lxmf_adapter.cpp b/tests/native/test_location_lxmf_adapter.cpp new file mode 100644 index 00000000..fa1b7d5c --- /dev/null +++ b/tests/native/test_location_lxmf_adapter.cpp @@ -0,0 +1,199 @@ +#include +#include +#include +#include +#include + +#include "Telemetry/LocationLxmfAdapter.h" + +namespace { +int passed = 0; +int failures = 0; +#define CHECK(expr) do { if (expr) { ++passed; } else { ++failures; std::cerr << "FAIL line " << __LINE__ << ": " #expr << '\n'; } } while (false) + +Telemetry::PeerId peer(uint8_t seed) { + Telemetry::PeerId value{}; + for (std::size_t i = 0; i < Telemetry::PEER_ID_SIZE; ++i) value.bytes[i] = static_cast(seed + i); + return value; +} + +Telemetry::LocationTelemetry location(uint64_t seconds) { + Telemetry::LocationTelemetry value{}; + value.latitude_e6 = 37774900; + value.longitude_e6 = -122419400; + value.altitude_cm = 1600; + value.speed_centi_kmh = 1234; + value.bearing_cdeg = 4200; + value.accuracy_cm = 350; + value.timestamp_seconds = seconds; + value.sensor_timestamp_seconds = seconds; + return value; +} + +class FakeRouter : public Telemetry::LocationEnvelopeRouter { +public: + bool accepted = true; + uint64_t ownership_time = 0; + int calls = 0; + Telemetry::OutboundLocationEnvelope envelope{}; + bool queue(const Telemetry::OutboundLocationEnvelope& value, + uint64_t deadline, + uint64_t& ownership) override { + ++calls; + envelope = value; + ownership = ownership_time == 0 + ? deadline - Telemetry::ACKNOWLEDGEMENT_LEASE_MILLIS + : ownership_time; + if (ownership >= deadline) return false; + return accepted; + } +}; + +void exactLocationEnvelope() { + Telemetry::ShareWork work{}; + work.peer = peer(7); + work.type = Telemetry::ShareWorkType::LOCATION; + work.has_expiry = true; + work.expires_at_millis = 1700000900000ULL; + work.approx_radius_meters = 25; + Telemetry::OutboundLocationEnvelope envelope{}; + CHECK(Telemetry::buildOutboundLocationEnvelope(work, location(1700000000ULL), 1700000000123ULL, envelope) == Telemetry::EnvelopeBuildResult::OK); + CHECK(std::memcmp(envelope.destination.bytes, work.peer.bytes, Telemetry::PEER_ID_SIZE) == 0); + CHECK(envelope.method == Telemetry::LocationDeliveryMethod::OPPORTUNISTIC); + CHECK(envelope.title_size == 0 && envelope.content_size == 0); + CHECK(envelope.field_count == 2); + CHECK(envelope.fields[0].key_size == 1 && envelope.fields[0].key[0] == 0x02); + CHECK(envelope.fields[1].key_size == 2 && envelope.fields[1].key[0] == 0xcc && envelope.fields[1].key[1] == 0xfd); + Telemetry::BinaryView inner{}; + CHECK(Telemetry::unwrapLxmfBinaryFieldValue(envelope.fields[0].value, envelope.fields[0].value_size, inner) == Telemetry::FieldValueResult::OK); + Telemetry::LocationTelemetry decoded{}; + CHECK(Telemetry::decodeLocationTelemetry(inner.data, inner.size, decoded) == Telemetry::DecodeResult::OK); + CHECK(decoded.latitude_e6 == 37774900 && decoded.timestamp_seconds == 1700000000ULL); + Telemetry::BinaryView encoded_meta{}; + CHECK(Telemetry::unwrapLxmfBinaryFieldValue( + envelope.fields[1].value, envelope.fields[1].value_size, + encoded_meta) == Telemetry::FieldValueResult::OK); + Telemetry::CustomLocationMeta meta{}; + CHECK(Telemetry::decodeCustomLocationMeta( + encoded_meta.data, encoded_meta.size, meta) == + Telemetry::CustomMetaResult::OK); + CHECK(!meta.has_cease && meta.has_expires && meta.expires_millis == 1700000900000LL); + CHECK(meta.has_approx_radius && meta.approx_radius_meters == 25); + CHECK(!meta.has_timestamp); +} + +void acceptanceAndRetryAreHonest() { + Telemetry::LocationShareScheduler scheduler; + Telemetry::ShareStartOptions options{}; + options.duration = Telemetry::ShareDuration::INDEFINITE; + CHECK(scheduler.start(peer(1), options, 100000) == Telemetry::ShareSessionResult::STARTED); + FakeRouter router; + router.accepted = false; + Telemetry::DispatchResult result = Telemetry::dispatchLocationShare(scheduler, 100000, 5000, true, location(100), router); + CHECK(result == Telemetry::DispatchResult::QUEUE_REJECTED); + Telemetry::ShareSession session{}; + CHECK(scheduler.get(peer(1), session) && !session.has_sent && !session.awaiting_ack); + CHECK(session.next_attempt_millis == 100000 + Telemetry::INITIAL_RETRY_MILLIS); + CHECK(Telemetry::dispatchLocationShare(scheduler, session.next_attempt_millis - 1, 5001, true, location(100), router) == Telemetry::DispatchResult::NO_WORK); + router.accepted = true; + CHECK(Telemetry::dispatchLocationShare(scheduler, session.next_attempt_millis, 5002, true, location(105), router) == Telemetry::DispatchResult::QUEUED); + CHECK(scheduler.get(peer(1), session) && session.has_sent && !session.awaiting_ack); +} + +void ceaseShapeAndOwnership() { + Telemetry::LocationShareScheduler scheduler; + Telemetry::ShareStartOptions options{}; + options.duration = Telemetry::ShareDuration::INDEFINITE; + CHECK(scheduler.start(peer(3), options, 1000) == Telemetry::ShareSessionResult::STARTED); + FakeRouter router; + CHECK(Telemetry::dispatchLocationShare(scheduler, 1000, 1000, true, location(1), router) == Telemetry::DispatchResult::QUEUED); + CHECK(scheduler.stop(peer(3), 1001) == Telemetry::ShareSessionResult::STOPPING); + router.accepted = false; + CHECK(Telemetry::dispatchLocationShare(scheduler, 1001, 1001, false, location(1), router) == Telemetry::DispatchResult::QUEUE_REJECTED); + Telemetry::CustomLocationMeta meta{}; + Telemetry::BinaryView encoded_meta{}; + CHECK(Telemetry::unwrapLxmfBinaryFieldValue( + router.envelope.fields[1].value, + router.envelope.fields[1].value_size, + encoded_meta) == Telemetry::FieldValueResult::OK); + CHECK(Telemetry::decodeCustomLocationMeta( + encoded_meta.data, encoded_meta.size, meta) == + Telemetry::CustomMetaResult::OK); + CHECK(meta.has_cease && meta.cease); + Telemetry::BinaryView inner{}; + CHECK(Telemetry::unwrapLxmfBinaryFieldValue(router.envelope.fields[0].value, router.envelope.fields[0].value_size, inner) == Telemetry::FieldValueResult::OK); + Telemetry::LocationTelemetry zero{}; + CHECK(Telemetry::decodeLocationTelemetry(inner.data, inner.size, zero) == Telemetry::DecodeResult::OK); + CHECK(zero.latitude_e6 == 0 && zero.longitude_e6 == 0); + CHECK(zero.timestamp_seconds == 1U && + zero.sensor_timestamp_seconds == 1U); + Telemetry::ShareSession session{}; + CHECK(scheduler.get(peer(3), session)); + router.accepted = true; + CHECK(Telemetry::dispatchLocationShare(scheduler, session.next_attempt_millis, 2000, false, location(1), router) == Telemetry::DispatchResult::CEASE_QUEUED); + CHECK(scheduler.size() == 0); +} + +void optionalMetadataIsOmitted() { + Telemetry::ShareWork work{}; + work.peer = peer(8); + Telemetry::OutboundLocationEnvelope envelope{}; + CHECK(Telemetry::buildOutboundLocationEnvelope( + work, location(1), 1000, envelope) == + Telemetry::EnvelopeBuildResult::OK); + CHECK(envelope.field_count == 1); + + work.has_approx_radius = true; + CHECK(Telemetry::buildOutboundLocationEnvelope( + work, location(1), 1000, envelope) == + Telemetry::EnvelopeBuildResult::OK); + CHECK(envelope.field_count == 2); + Telemetry::BinaryView encoded_meta{}; + CHECK(Telemetry::unwrapLxmfBinaryFieldValue( + envelope.fields[1].value, envelope.fields[1].value_size, + encoded_meta) == Telemetry::FieldValueResult::OK); + Telemetry::CustomLocationMeta meta{}; + CHECK(Telemetry::decodeCustomLocationMeta( + encoded_meta.data, encoded_meta.size, meta) == + Telemetry::CustomMetaResult::OK); + CHECK(meta.has_approx_radius && meta.approx_radius_meters == 0); +} + +void expiredOwnershipLeaseIsRejected() { + Telemetry::LocationShareScheduler scheduler; + Telemetry::ShareStartOptions options{}; + options.duration = Telemetry::ShareDuration::INDEFINITE; + CHECK(scheduler.start(peer(12), options, 1000) == + Telemetry::ShareSessionResult::STARTED); + FakeRouter router; + router.ownership_time = 2000; + CHECK(Telemetry::dispatchLocationShare( + scheduler, 1000, 1000, true, location(1), router) == + Telemetry::DispatchResult::QUEUE_REJECTED); + Telemetry::ShareSession session{}; + CHECK(scheduler.get(peer(12), session) && !session.has_sent && + !session.awaiting_ack); +} + +void noSideEffectsOrWorkWithoutValidGps() { + Telemetry::LocationShareScheduler scheduler; + Telemetry::ShareStartOptions options{}; + options.duration = Telemetry::ShareDuration::INDEFINITE; + CHECK(scheduler.start(peer(4), options, 1000) == Telemetry::ShareSessionResult::STARTED); + FakeRouter router; + CHECK(Telemetry::dispatchLocationShare(scheduler, 1000, 1000, false, location(1), router) == Telemetry::DispatchResult::NO_WORK); + CHECK(router.calls == 0); + CHECK(sizeof(Telemetry::OutboundLocationEnvelope) <= 768U); +} +} + +int main() { + exactLocationEnvelope(); + acceptanceAndRetryAreHonest(); + ceaseShapeAndOwnership(); + optionalMetadataIsOmitted(); + expiredOwnershipLeaseIsRejected(); + noSideEffectsOrWorkWithoutValidGps(); + std::cout << "location LXMF adapter: " << passed << " passed, " << failures << " failed\n"; + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/native/test_location_lxmf_adapter.py b/tests/native/test_location_lxmf_adapter.py new file mode 100644 index 00000000..1286a2b4 --- /dev/null +++ b/tests/native/test_location_lxmf_adapter.py @@ -0,0 +1,37 @@ +"""Compile and execute the portable location LXMF adapter integration harness.""" +from pathlib import Path +import os +import subprocess + +from native_test import find_cxx + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +def test_location_lxmf_adapter(tmp_path): + binary = tmp_path / "test_location_lxmf_adapter" + sources = [ + HERE / "test_location_lxmf_adapter.cpp", + ROOT / "lib/tdeck_ui/Telemetry/LocationLxmfAdapter.cpp", + ROOT / "lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp", + ROOT / "lib/tdeck_ui/Telemetry/LocationShareState.cpp", + ROOT / "lib/tdeck_ui/Telemetry/LocationShareScheduler.cpp", + ] + command = [ + find_cxx(), "-std=c++11", "-Wall", "-Wextra", "-Werror", + "-Wconversion", "-Wsign-conversion", "-pedantic", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", + f"-I{ROOT / 'lib/tdeck_ui'}", *map(str, sources), "-o", str(binary), + ] + compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + ran = subprocess.run( + [str(binary)], capture_output=True, text=True, timeout=90, + env={**os.environ, + "ASAN_OPTIONS": "detect_leaks=1:halt_on_error=1", + "UBSAN_OPTIONS": "halt_on_error=1:print_stacktrace=1"}, + ) + assert ran.returncode == 0, ran.stdout + ran.stderr + assert "location LXMF adapter:" in ran.stdout + assert "0 failed" in ran.stdout diff --git a/tests/native/test_location_message_policy.cpp b/tests/native/test_location_message_policy.cpp index 7f93c1a3..3a840de0 100644 --- a/tests/native/test_location_message_policy.cpp +++ b/tests/native/test_location_message_policy.cpp @@ -50,7 +50,15 @@ Fields validFields(uint64_t timestamp, bool with_meta = false, bool cease = fals meta.cease = cease; meta.has_timestamp = true; meta.timestamp_millis = static_cast(timestamp * 1000U); - CHECK(Telemetry::encodeCustomLocationMeta(meta, fields.meta, sizeof(fields.meta), fields.meta_size) == Telemetry::CustomMetaResult::OK); + uint8_t encoded_meta[96]{}; + std::size_t encoded_meta_size = 0; + CHECK(Telemetry::encodeCustomLocationMeta( + meta, encoded_meta, sizeof(encoded_meta), encoded_meta_size) == + Telemetry::CustomMetaResult::OK); + CHECK(Telemetry::wrapLxmfBinaryFieldValue( + encoded_meta, encoded_meta_size, + fields.meta, sizeof(fields.meta), fields.meta_size) == + Telemetry::FieldValueResult::OK); } return fields; } diff --git a/tests/native/test_location_state_record.cpp b/tests/native/test_location_state_record.cpp index 5083254e..d7dca1e1 100644 --- a/tests/native/test_location_state_record.cpp +++ b/tests/native/test_location_state_record.cpp @@ -286,6 +286,23 @@ void encodingIsAtomicAndBounded() { CHECK(sameState(decoded, maximum)); } +void preservesPresentZeroApproximateRadius() { + auto state = sample(); + state.sessions[0].record.has_approx_radius = true; + state.sessions[0].record.approx_radius_meters = 0; + uint8_t encoded[Telemetry::MAX_LOCATION_STATE_RECORD_BYTES]{}; + std::size_t written = 0; + CHECK(Telemetry::encodeLocationStateRecord( + state, encoded, sizeof(encoded), written) == + Telemetry::LocationStateRecordResult::OK); + Telemetry::LocationStateSnapshot decoded{}; + CHECK(Telemetry::decodeLocationStateRecord( + encoded, written, decoded) == + Telemetry::LocationStateRecordResult::OK); + CHECK(decoded.sessions[0].record.has_approx_radius); + CHECK(decoded.sessions[0].record.approx_radius_meters == 0); +} + } // namespace int main() { @@ -294,6 +311,7 @@ int main() { rejectsCorruptionAndUnsupportedHeadersTransactionally(); rejectsCountOverflowDuplicatesAndInvalidCoordinates(); encodingIsAtomicAndBounded(); + preservesPresentZeroApproximateRadius(); std::cout << "location state record: " << passed << " passed, " << failures << " failed\n"; return failures == 0 ? 0 : 1; diff --git a/tests/reference/test_microlxmf_telemetry_field.py b/tests/reference/test_microlxmf_telemetry_field.py index 09742878..4db52fcb 100644 --- a/tests/reference/test_microlxmf_telemetry_field.py +++ b/tests/reference/test_microlxmf_telemetry_field.py @@ -22,7 +22,7 @@ def test_pinned_microlxmf_telemetry_field_roundtrip(tmp_path): if not (source / "LXMF" / "LXMessage.cpp").is_file(): pytest.fail(f"pinned microLXMF source is unavailable at {microlxmf}") - expected = "d9bbc04cf69bfa9b555c3f293b89b440b4820518" + expected = "e672a28673b138564177e30d6c94f29ba5f7b066" actual = subprocess.run( ["git", "-C", str(microlxmf), "rev-parse", "HEAD"], capture_output=True, @@ -64,4 +64,4 @@ def test_pinned_microlxmf_telemetry_field_roundtrip(tmp_path): env={**os.environ, "ASAN_OPTIONS": "detect_leaks=1:halt_on_error=1"}, ) assert ran.returncode == 0, ran.stdout + ran.stderr - assert "microLXMF telemetry field roundtrip: passed" in ran.stdout + assert "microLXMF location fields roundtrip: passed" in ran.stdout