From 299fd4af026311ba0ae99c8d7d6c64699de9b60c Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:28:15 +0000 Subject: [PATCH] fix: align telemetry with pinned field semantics --- docs/plans/2026-07-28-map-location-v2.md | 42 ++++++++++++++++--- .../Telemetry/LocationTelemetryCodec.cpp | 42 ++++++++++++++++++- .../Telemetry/LocationTelemetryCodec.h | 19 ++++++++- .../native/test_location_telemetry_codec.cpp | 20 +++++++-- tests/native/test_location_telemetry_codec.py | 2 +- 5 files changed, 114 insertions(+), 11 deletions(-) diff --git a/docs/plans/2026-07-28-map-location-v2.md b/docs/plans/2026-07-28-map-location-v2.md index f815e4d9..8b54ad4c 100644 --- a/docs/plans/2026-07-28-map-location-v2.md +++ b/docs/plans/2026-07-28-map-location-v2.md @@ -58,7 +58,9 @@ Portable code must avoid `Arduino.h`, `String`, LVGL, FreeRTOS, and filesystem h - Plain value types, field IDs, validity/error enums, explicit units. - `lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h` - `lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp` - - Bounded MessagePack encoder/decoder over caller-owned buffers. + - Bounded MessagePack encoder/decoder over caller-owned buffers. Handles both + the inner Sideband Telemeter map and the outer raw MessagePack BIN span that + current pinned microLXMF stores in `LXMessage::FieldEntry`. - `lib/tdeck_ui/Telemetry/LocationShareState.h` - `lib/tdeck_ui/Telemetry/LocationShareState.cpp` - Fixed-capacity peer locations and outbound sessions; deterministic eviction. @@ -152,6 +154,8 @@ Telemetry::DecodeResult decodeLocationTelemetry( - Skip unknown values with bounded depth and bounded aggregate item count. - Reject overflow, truncation, invalid MessagePack types, duplicate location ambiguity, non-finite/out-of-range coordinates, impossible negative unsigned values, excessive nesting, and oversized payloads. - Treat `last_update` as authoritative location timestamp; preserve outer sensor timestamp separately if useful. +- Name and store the speed fixed-point value as centi-km/h. Sideband's location + sensor uses km/h; TinyGPS `speed.kmph()` must feed the encoder, not `mps()`. **TDD cycle per behavior:** 1. Add one failing vector assertion. @@ -181,7 +185,7 @@ Telemetry::EncodeResult encodeLocationTelemetry( **Behavior:** - Caller-owned fixed buffer; explicit `BUFFER_TOO_SMALL` result. - Big-endian fixed-point fields with documented rounding/truncation selected from the pinned Sideband reference. -- Clamp speed to non-negative and accuracy to unsigned 16-bit range. +- Clamp speed (km/h) to non-negative and accuracy to unsigned 16-bit range. - Reject invalid latitude, longitude, bearing policy violations, non-finite values, and timestamps that cannot be represented by the selected wire contract. - Compare exact bytes for canonical fixture inputs. @@ -189,9 +193,31 @@ Telemetry::EncodeResult encodeLocationTelemetry( **Commit:** `feat: encode canonical location telemetry`. -### Task 4: Implement `FIELD_CUSTOM_META` codec +### Task 4: Implement the pinned microLXMF raw-field envelope -**Objective:** Correctly encode/decode current Columba metadata at LXMF field `0xFD`. +**Objective:** Prove that the inner Telemeter map crosses current microLXMF as +the MessagePack BIN value Python LXMF/Sideband expects. + +**Critical pinned-API contract:** microLXMF `d9bbc04` stores field keys and +values as raw MessagePack spans and emits both with `packRawBytes()`. Therefore +the raw key span for telemetry is positive-fixint `0x02`, while the raw value +span is `bin8`/`bin16`/`bin32` containing the packed Telemeter map. Passing the +inner map directly to `fields_set()` would emit a map value rather than bytes +and Sideband's `Telemeter.from_packed()` would reject it. + +**Tests:** +- Wrap inner payload as bin8/bin16/bin32 at exact boundaries. +- Unwrap each valid BIN width without copying. +- Reject non-BIN, truncation, declared-length mismatch, and trailing bytes. +- Full pinned microLXMF pack → unpack → `fields_get(0x02)` → unwrap → Telemeter + decode, plus the reverse direction. + +**Commit:** `feat: wrap telemetry for current microLXMF fields`. + +### Task 4b: Implement optional Columba `FIELD_CUSTOM_META` codec + +**Objective:** Correctly encode/decode the optional current Columba extension at +LXMF field `0xFD` without treating it as core Sideband telemetry. **Files:** codec production and native test files. @@ -202,7 +228,10 @@ Telemetry::EncodeResult encodeLocationTelemetry( - Unknown keys skipped safely. - Absent, malformed, false, and true cease values remain distinct. - Empty metadata is omitted outbound. -- Cease frame requires both a valid zeroed Telemeter body and `{ "cease": true }` metadata for current Columba interoperability. +- Core stop-sharing semantics stop future sends locally. Sideband has no remote + history-deletion/cease contract. When Columba compatibility is enabled, its + cease frame requires both a valid zeroed Telemeter body and + `{ "cease": true }` metadata. **Tests:** canonical byte vectors, key reordering, integer widths, malformed values, JSON legacy input explicitly rejected or separately compatibility-gated by a documented test. @@ -324,6 +353,9 @@ Telemetry::EncodeResult encodeLocationTelemetry( **Behavior:** - Construct empty-content LXMF messages carrying field `0x02` and optional `0xFD`. +- Supply `fields_set()` with raw MessagePack spans: key `02` and a BIN-wrapped + Telemeter value. On receive, unwrap the raw BIN returned by `fields_get(02)` + before decoding the inner map. - Prefer opportunistic delivery; allow router promotion when required, matching short UI messages. - Do not fake a known destination identity. - Advance scheduler only after `handle_outbound` accepts ownership/queueing under the available API. diff --git a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp index 7e2aced6..b76c5a0f 100644 --- a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp +++ b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp @@ -100,7 +100,7 @@ bool readLocation(Cursor& cursor, LocationTelemetry& location) { if (!cursor.readBinary(word, sizeof(word))) return false; location.altitude_cm = decodeI32(word); if (!cursor.readBinary(word, sizeof(word))) return false; - location.speed_cms = decodeU32(word); + location.speed_centi_kmh = decodeU32(word); if (!cursor.readBinary(word, sizeof(word))) return false; location.bearing_cdeg = decodeI32(word); if (!cursor.readBinary(half, sizeof(half))) return false; @@ -113,6 +113,46 @@ bool readLocation(Cursor& cursor, LocationTelemetry& location) { } // namespace +FieldValueResult unwrapLxmfBinaryFieldValue( + const uint8_t* raw_value, + std::size_t raw_size, + BinaryView& output) { + if (raw_value == nullptr || raw_size == 0) { + return FieldValueResult::INVALID_ARGUMENT; + } + + const uint8_t marker = raw_value[0]; + std::size_t header_size = 0; + std::size_t payload_size = 0; + if (marker == 0xc4U) { + if (raw_size < 2) return FieldValueResult::MALFORMED; + header_size = 2; + payload_size = raw_value[1]; + } else if (marker == 0xc5U) { + if (raw_size < 3) return FieldValueResult::MALFORMED; + header_size = 3; + payload_size = (static_cast(raw_value[1]) << 8U) | + raw_value[2]; + } else if (marker == 0xc6U) { + if (raw_size < 5) return FieldValueResult::MALFORMED; + header_size = 5; + payload_size = (static_cast(raw_value[1]) << 24U) | + (static_cast(raw_value[2]) << 16U) | + (static_cast(raw_value[3]) << 8U) | + raw_value[4]; + } else { + return FieldValueResult::NOT_BINARY; + } + + if (payload_size != raw_size - header_size) { + return FieldValueResult::MALFORMED; + } + + BinaryView candidate{raw_value + header_size, payload_size}; + output = candidate; + return FieldValueResult::OK; +} + DecodeResult decodeLocationTelemetry( const uint8_t* data, std::size_t size, diff --git a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h index 13b70a62..fdcae668 100644 --- a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h +++ b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h @@ -16,7 +16,7 @@ struct LocationTelemetry { int32_t latitude_e6 = 0; int32_t longitude_e6 = 0; int32_t altitude_cm = 0; - uint32_t speed_cms = 0; + uint32_t speed_centi_kmh = 0; int32_t bearing_cdeg = 0; uint16_t accuracy_cm = 0; uint64_t timestamp_seconds = 0; @@ -30,6 +30,23 @@ enum class DecodeResult : uint8_t { MISSING_LOCATION, }; +enum class FieldValueResult : uint8_t { + OK, + INVALID_ARGUMENT, + NOT_BINARY, + MALFORMED, +}; + +struct BinaryView { + const uint8_t* data = nullptr; + std::size_t size = 0; +}; + +FieldValueResult unwrapLxmfBinaryFieldValue( + const uint8_t* raw_value, + std::size_t raw_size, + BinaryView& output); + DecodeResult decodeLocationTelemetry( const uint8_t* data, std::size_t size, diff --git a/tests/native/test_location_telemetry_codec.cpp b/tests/native/test_location_telemetry_codec.cpp index 5f0e6a8c..4f9eff8a 100644 --- a/tests/native/test_location_telemetry_codec.cpp +++ b/tests/native/test_location_telemetry_codec.cpp @@ -23,7 +23,7 @@ void decodes_canonical_sideband_location() { 0xc4, 0x04, 0x02, 0x40, 0x66, 0x34, // 37.774900 degrees 0xc4, 0x04, 0xf8, 0xb4, 0x07, 0x38, // -122.419400 degrees 0xc4, 0x04, 0x00, 0x00, 0x06, 0x40, // 16.00 m - 0xc4, 0x04, 0x00, 0x00, 0x00, 0x00, // 0.00 m/s + 0xc4, 0x04, 0x00, 0x00, 0x04, 0xd2, // 12.34 km/h 0xc4, 0x04, 0x00, 0x00, 0x10, 0x68, // 42.00 degrees 0xc4, 0x02, 0x01, 0x5e, // 3.50 m 0xce, 0x65, 0x53, 0xf1, 0x00, @@ -37,10 +37,24 @@ void decodes_canonical_sideband_location() { CHECK(output.latitude_e6 == 37774900); CHECK(output.longitude_e6 == -122419400); CHECK(output.altitude_cm == 1600); - CHECK(output.speed_cms == 0); + CHECK(output.speed_centi_kmh == 1234); CHECK(output.bearing_cdeg == 4200); CHECK(output.accuracy_cm == 350); CHECK(output.timestamp_seconds == 1700000000ULL); + + uint8_t field_value[sizeof(packed) + 2]{}; + field_value[0] = 0xc4; // MessagePack bin8 + field_value[1] = static_cast(sizeof(packed)); + for (std::size_t index = 0; index < sizeof(packed); ++index) { + field_value[index + 2] = packed[index]; + } + + Telemetry::BinaryView inner{}; + CHECK(Telemetry::unwrapLxmfBinaryFieldValue( + field_value, sizeof(field_value), inner) == + Telemetry::FieldValueResult::OK); + CHECK(inner.data == field_value + 2); + CHECK(inner.size == sizeof(packed)); } } // namespace @@ -48,7 +62,7 @@ void decodes_canonical_sideband_location() { int main() { decodes_canonical_sideband_location(); if (failures == 0) { - std::cout << "location telemetry codec: 7 passed, 0 failed\n"; + std::cout << "location telemetry codec: 10 passed, 0 failed\n"; } return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/tests/native/test_location_telemetry_codec.py b/tests/native/test_location_telemetry_codec.py index baa97c29..e87a531c 100644 --- a/tests/native/test_location_telemetry_codec.py +++ b/tests/native/test_location_telemetry_codec.py @@ -23,4 +23,4 @@ def test_location_telemetry_codec(tmp_path): include_dirs=[PYXIS_ROOT / "lib" / "tdeck_ui"], sanitize=True, ) - assert "location telemetry codec: 7 passed, 0 failed" in ran.stdout + assert "location telemetry codec: 10 passed, 0 failed" in ran.stdout