diff --git a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp index b76c5a0f..70784845 100644 --- a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp +++ b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp @@ -2,10 +2,18 @@ #include #include +#include +#include namespace Telemetry { namespace { +constexpr std::size_t MAX_MAP_ENTRIES = 32; +constexpr std::size_t MAX_LOCATION_ELEMENTS = 16; +constexpr std::size_t MAX_SKIP_DEPTH = 8; +constexpr std::size_t MAX_SKIP_ITEMS = 64; +constexpr std::size_t MAX_ENCODED_TELEMETRY = 96; + class Cursor { public: Cursor(const uint8_t* data, std::size_t size) : data_(data), size_(size) {} @@ -16,101 +24,337 @@ public: return true; } + bool readBytes(std::size_t count, const uint8_t*& value) { + if (count > remaining()) return false; + value = data_ + position_; + position_ += count; + return true; + } + bool readUnsigned(uint64_t& value) { uint8_t marker = 0; if (!readByte(marker)) return false; - if (marker <= 0x7f) { + if (marker <= 0x7fU) { value = marker; return true; } std::size_t width = 0; + bool signed_value = false; switch (marker) { case 0xcc: width = 1; break; case 0xcd: width = 2; break; case 0xce: width = 4; break; case 0xcf: width = 8; break; + case 0xd0: width = 1; signed_value = true; break; + case 0xd1: width = 2; signed_value = true; break; + case 0xd2: width = 4; signed_value = true; break; + case 0xd3: width = 8; signed_value = true; break; default: return false; } - if (size_ - position_ < width) return false; - value = 0; + const uint8_t* bytes = nullptr; + if (!readBytes(width, bytes)) return false; + uint64_t decoded = 0; for (std::size_t index = 0; index < width; ++index) { - value = (value << 8U) | data_[position_++]; + decoded = (decoded << 8U) | bytes[index]; } + if (signed_value && (bytes[0] & 0x80U) != 0) return false; + value = decoded; return true; } bool readMapSize(std::size_t& count) { uint8_t marker = 0; - if (!readByte(marker) || (marker & 0xf0U) != 0x80U) return false; - count = marker & 0x0fU; - return true; + if (!readByte(marker)) return false; + if ((marker & 0xf0U) == 0x80U) { + count = marker & 0x0fU; + return true; + } + return readSizedContainer(marker, 0xdeU, 0xdfU, count); } bool readArraySize(std::size_t& count) { uint8_t marker = 0; - if (!readByte(marker) || (marker & 0xf0U) != 0x90U) return false; - count = marker & 0x0fU; + if (!readByte(marker)) return false; + if ((marker & 0xf0U) == 0x90U) { + count = marker & 0x0fU; + return true; + } + return readSizedContainer(marker, 0xdcU, 0xddU, count); + } + + bool readBinary(BinaryView& value) { + uint8_t marker = 0; + if (!readByte(marker)) return false; + + std::size_t length = 0; + if (marker == 0xc4U) { + uint8_t byte_length = 0; + if (!readByte(byte_length)) return false; + length = byte_length; + } else if (marker == 0xc5U) { + if (!readBigEndianSize(2, length)) return false; + } else if (marker == 0xc6U) { + if (!readBigEndianSize(4, length)) return false; + } else { + return false; + } + + const uint8_t* bytes = nullptr; + if (!readBytes(length, bytes)) return false; + value = BinaryView{bytes, length}; return true; } - bool readBinary(uint8_t* destination, std::size_t required) { + bool skipValue(std::size_t depth, std::size_t& budget) { + if (depth > MAX_SKIP_DEPTH || budget == 0) return false; + --budget; + uint8_t marker = 0; - uint8_t encoded_size = 0; - if (!readByte(marker) || marker != 0xc4U || !readByte(encoded_size)) return false; - if (encoded_size != required || size_ - position_ < required) return false; - for (std::size_t index = 0; index < required; ++index) { - destination[index] = data_[position_++]; + if (!peekByte(marker)) return false; + + if (marker <= 0x7fU || marker >= 0xe0U || marker == 0xc0U || + marker == 0xc2U || marker == 0xc3U) { + ++position_; + return true; + } + if ((marker & 0xe0U) == 0xa0U) { + ++position_; + return skipBytes(marker & 0x1fU); + } + if ((marker & 0xf0U) == 0x90U) { + ++position_; + return skipChildren(marker & 0x0fU, depth, budget, false); + } + if ((marker & 0xf0U) == 0x80U) { + ++position_; + return skipChildren(marker & 0x0fU, depth, budget, true); + } + + ++position_; + switch (marker) { + case 0xc4: return skipLengthPrefixed(1, 0); + case 0xc5: return skipLengthPrefixed(2, 0); + case 0xc6: return skipLengthPrefixed(4, 0); + case 0xca: return skipBytes(4); + case 0xcb: return skipBytes(8); + case 0xcc: case 0xd0: return skipBytes(1); + case 0xcd: case 0xd1: return skipBytes(2); + case 0xce: case 0xd2: return skipBytes(4); + case 0xcf: case 0xd3: return skipBytes(8); + case 0xd4: return skipBytes(2); // type + 1-byte payload + case 0xd5: return skipBytes(3); // type + 2-byte payload + case 0xd6: return skipBytes(5); // type + 4-byte payload + case 0xd7: return skipBytes(9); // type + 8-byte payload + case 0xd8: return skipBytes(17); // type + 16-byte payload + case 0xd9: return skipLengthPrefixed(1, 0); + case 0xda: return skipLengthPrefixed(2, 0); + case 0xdb: return skipLengthPrefixed(4, 0); + case 0xc7: return skipLengthPrefixed(1, 1); + case 0xc8: return skipLengthPrefixed(2, 1); + case 0xc9: return skipLengthPrefixed(4, 1); + case 0xdc: + case 0xdd: { + std::size_t count = 0; + if (!readBigEndianSize(marker == 0xdcU ? 2 : 4, count)) return false; + return skipChildren(count, depth, budget, false); + } + case 0xde: + case 0xdf: { + std::size_t count = 0; + if (!readBigEndianSize(marker == 0xdeU ? 2 : 4, count)) return false; + return skipChildren(count, depth, budget, true); + } + default: return false; } - return true; } bool atEnd() const { return position_ == size_; } private: + bool peekByte(uint8_t& value) const { + if (position_ >= size_) return false; + value = data_[position_]; + return true; + } + + std::size_t remaining() const { return size_ - position_; } + + bool skipBytes(std::size_t count) { + if (count > remaining()) return false; + position_ += count; + return true; + } + + bool readBigEndianSize(std::size_t width, std::size_t& value) { + const uint8_t* bytes = nullptr; + if (!readBytes(width, bytes)) return false; + uint64_t decoded = 0; + for (std::size_t index = 0; index < width; ++index) { + decoded = (decoded << 8U) | bytes[index]; + } + if (decoded > std::numeric_limits::max()) return false; + value = static_cast(decoded); + return true; + } + + bool readSizedContainer(uint8_t marker, uint8_t marker16, uint8_t marker32, + std::size_t& count) { + if (marker == marker16) return readBigEndianSize(2, count); + if (marker == marker32) return readBigEndianSize(4, count); + return false; + } + + bool skipLengthPrefixed(std::size_t width, std::size_t suffix) { + std::size_t length = 0; + if (!readBigEndianSize(width, length)) return false; + if (length > std::numeric_limits::max() - suffix) return false; + return skipBytes(length + suffix); + } + + bool skipChildren(std::size_t count, std::size_t depth, std::size_t& budget, + bool map) { + if (map) { + if (count > MAX_SKIP_ITEMS / 2) return false; + count *= 2; + } + if (count > budget) return false; + for (std::size_t index = 0; index < count; ++index) { + if (!skipValue(depth + 1, budget)) return false; + } + return true; + } + const uint8_t* data_; std::size_t size_; std::size_t position_ = 0; }; -uint32_t decodeU32(const uint8_t bytes[4]) { +class Writer { +public: + Writer(uint8_t* data, std::size_t capacity) : data_(data), capacity_(capacity) {} + + bool writeByte(uint8_t value) { + if (size_ >= capacity_) return false; + data_[size_++] = value; + return true; + } + + bool writeBytes(const uint8_t* data, std::size_t size) { + if (size > capacity_ - size_) return false; + std::memcpy(data_ + size_, data, size); + size_ += size; + return true; + } + + bool writeUnsigned(uint64_t value) { + if (value <= 0x7fU) return writeByte(static_cast(value)); + if (value <= 0xffU) { + return writeByte(0xccU) && writeBigEndian(value, 1); + } + if (value <= 0xffffU) { + return writeByte(0xcdU) && writeBigEndian(value, 2); + } + if (value <= 0xffffffffULL) { + return writeByte(0xceU) && writeBigEndian(value, 4); + } + return writeByte(0xcfU) && writeBigEndian(value, 8); + } + + bool writeBinary(const uint8_t* data, std::size_t size) { + if (size > 0xffU) return false; + return writeByte(0xc4U) && writeByte(static_cast(size)) && + writeBytes(data, size); + } + + std::size_t size() const { return size_; } + +private: + bool writeBigEndian(uint64_t value, std::size_t width) { + for (std::size_t index = width; index > 0; --index) { + if (!writeByte(static_cast(value >> ((index - 1) * 8U)))) { + return false; + } + } + return true; + } + + uint8_t* data_; + std::size_t capacity_; + std::size_t size_ = 0; +}; + +uint32_t decodeU32(const uint8_t* bytes) { return (static_cast(bytes[0]) << 24U) | (static_cast(bytes[1]) << 16U) | (static_cast(bytes[2]) << 8U) | static_cast(bytes[3]); } -int32_t decodeI32(const uint8_t bytes[4]) { - return static_cast(decodeU32(bytes)); +int32_t decodeI32(const uint8_t* bytes) { + const uint32_t raw = decodeU32(bytes); + if (raw <= static_cast(std::numeric_limits::max())) { + return static_cast(raw); + } + // Convert two's-complement wire bits without relying on an + // implementation-defined uint32_t -> int32_t narrowing conversion. + const uint32_t distance_from_minus_one = + std::numeric_limits::max() - raw; + return -1 - static_cast(distance_from_minus_one); +} + +void encodeU32(uint32_t value, uint8_t bytes[4]) { + bytes[0] = static_cast(value >> 24U); + bytes[1] = static_cast(value >> 16U); + bytes[2] = static_cast(value >> 8U); + bytes[3] = static_cast(value); +} + +bool readFixedBinary(Cursor& cursor, std::size_t expected, BinaryView& value) { + return cursor.readBinary(value) && value.size == expected; } bool readLocation(Cursor& cursor, LocationTelemetry& location) { std::size_t count = 0; - if (!cursor.readArraySize(count) || count != 7) return false; + if (!cursor.readArraySize(count) || count < 7 || + count > MAX_LOCATION_ELEMENTS) { + return false; + } - uint8_t word[4]{}; - uint8_t half[2]{}; + BinaryView value{}; uint64_t timestamp = 0; - if (!cursor.readBinary(word, sizeof(word))) return false; - location.latitude_e6 = decodeI32(word); - if (!cursor.readBinary(word, sizeof(word))) return false; - location.longitude_e6 = decodeI32(word); - if (!cursor.readBinary(word, sizeof(word))) return false; - location.altitude_cm = decodeI32(word); - if (!cursor.readBinary(word, sizeof(word))) return false; - 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; + if (!readFixedBinary(cursor, 4, value)) return false; + location.latitude_e6 = decodeI32(value.data); + if (!readFixedBinary(cursor, 4, value)) return false; + location.longitude_e6 = decodeI32(value.data); + if (!readFixedBinary(cursor, 4, value)) return false; + location.altitude_cm = decodeI32(value.data); + if (!readFixedBinary(cursor, 4, value)) return false; + location.speed_centi_kmh = decodeU32(value.data); + if (!readFixedBinary(cursor, 4, value)) return false; + location.bearing_cdeg = decodeI32(value.data); + if (!readFixedBinary(cursor, 2, value)) return false; location.accuracy_cm = static_cast( - (static_cast(half[0]) << 8U) | half[1]); + (static_cast(value.data[0]) << 8U) | value.data[1]); if (!cursor.readUnsigned(timestamp)) return false; location.timestamp_seconds = timestamp; + + std::size_t budget = MAX_SKIP_ITEMS; + for (std::size_t index = 7; index < count; ++index) { + if (!cursor.skipValue(0, budget)) return false; + } return true; } +bool locationInRange(const LocationTelemetry& location) { + return location.latitude_e6 >= -90000000 && + location.latitude_e6 <= 90000000 && + location.longitude_e6 >= -180000000 && + location.longitude_e6 <= 180000000; +} + } // namespace FieldValueResult unwrapLxmfBinaryFieldValue( @@ -122,34 +366,59 @@ FieldValueResult unwrapLxmfBinaryFieldValue( } 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 { + if (marker != 0xc4U && marker != 0xc5U && marker != 0xc6U) { return FieldValueResult::NOT_BINARY; } - if (payload_size != raw_size - header_size) { - return FieldValueResult::MALFORMED; + Cursor cursor(raw_value, raw_size); + BinaryView candidate{}; + if (!cursor.readBinary(candidate)) return FieldValueResult::MALFORMED; + if (!cursor.atEnd()) return FieldValueResult::MALFORMED; + output = candidate; + return FieldValueResult::OK; +} + +FieldValueResult wrapLxmfBinaryFieldValue( + const uint8_t* payload, + std::size_t payload_size, + uint8_t* output, + std::size_t capacity, + std::size_t& written) { + if ((payload == nullptr && payload_size != 0) || output == nullptr) { + return FieldValueResult::INVALID_ARGUMENT; } - BinaryView candidate{raw_value + header_size, payload_size}; - output = candidate; + std::size_t header_size = 0; + if (payload_size <= 0xffU) { + header_size = 2; + } else if (payload_size <= 0xffffU) { + header_size = 3; + } else if (payload_size <= 0xffffffffULL) { + header_size = 5; + } else { + return FieldValueResult::INVALID_ARGUMENT; + } + if (payload_size > std::numeric_limits::max() - header_size || + capacity < header_size + payload_size) { + return FieldValueResult::BUFFER_TOO_SMALL; + } + + if (header_size == 2) { + output[0] = 0xc4U; + output[1] = static_cast(payload_size); + } else if (header_size == 3) { + output[0] = 0xc5U; + output[1] = static_cast(payload_size >> 8U); + output[2] = static_cast(payload_size); + } else { + output[0] = 0xc6U; + output[1] = static_cast(payload_size >> 24U); + output[2] = static_cast(payload_size >> 16U); + output[3] = static_cast(payload_size >> 8U); + output[4] = static_cast(payload_size); + } + if (payload_size != 0) std::memcpy(output + header_size, payload, payload_size); + written = header_size + payload_size; return FieldValueResult::OK; } @@ -161,10 +430,13 @@ DecodeResult decodeLocationTelemetry( Cursor cursor(data, size); std::size_t map_size = 0; - if (!cursor.readMapSize(map_size)) return DecodeResult::MALFORMED; + if (!cursor.readMapSize(map_size) || map_size > MAX_MAP_ENTRIES) { + return DecodeResult::MALFORMED; + } LocationTelemetry candidate{}; bool has_location = false; + std::size_t skip_budget = MAX_SKIP_ITEMS; for (std::size_t index = 0; index < map_size; ++index) { uint64_t key = 0; if (!cursor.readUnsigned(key)) return DecodeResult::MALFORMED; @@ -177,15 +449,60 @@ DecodeResult decodeLocationTelemetry( return DecodeResult::MALFORMED; } has_location = true; - } else { + } else if (!cursor.skipValue(0, skip_budget)) { return DecodeResult::MALFORMED; } } if (!cursor.atEnd()) return DecodeResult::MALFORMED; if (!has_location) return DecodeResult::MISSING_LOCATION; + if (!locationInRange(candidate)) return DecodeResult::OUT_OF_RANGE; output = candidate; return DecodeResult::OK; } +EncodeResult encodeLocationTelemetry( + const LocationTelemetry& input, + uint8_t* output, + std::size_t capacity, + std::size_t& written) { + if (output == nullptr) return EncodeResult::INVALID_ARGUMENT; + if (!locationInRange(input)) return EncodeResult::OUT_OF_RANGE; + + uint8_t temporary[MAX_ENCODED_TELEMETRY]{}; + Writer writer(temporary, sizeof(temporary)); + uint8_t word[4]{}; + uint8_t half[2]{}; + const uint64_t sensor_timestamp = input.sensor_timestamp_seconds == 0 + ? input.timestamp_seconds + : input.sensor_timestamp_seconds; + + bool ok = writer.writeByte(0x82U) && + writer.writeUnsigned(SID_TIME) && + writer.writeUnsigned(sensor_timestamp) && + writer.writeUnsigned(SID_LOCATION) && + writer.writeByte(0x97U); + + encodeU32(static_cast(input.latitude_e6), word); + ok = ok && writer.writeBinary(word, sizeof(word)); + encodeU32(static_cast(input.longitude_e6), word); + ok = ok && writer.writeBinary(word, sizeof(word)); + encodeU32(static_cast(input.altitude_cm), word); + ok = ok && writer.writeBinary(word, sizeof(word)); + encodeU32(input.speed_centi_kmh, word); + ok = ok && writer.writeBinary(word, sizeof(word)); + encodeU32(static_cast(input.bearing_cdeg), word); + ok = ok && writer.writeBinary(word, sizeof(word)); + half[0] = static_cast(input.accuracy_cm >> 8U); + half[1] = static_cast(input.accuracy_cm); + ok = ok && writer.writeBinary(half, sizeof(half)) && + writer.writeUnsigned(input.timestamp_seconds); + + if (!ok) return EncodeResult::INVALID_ARGUMENT; + if (capacity < writer.size()) return EncodeResult::BUFFER_TOO_SMALL; + std::memcpy(output, temporary, writer.size()); + written = writer.size(); + return EncodeResult::OK; +} + } // namespace Telemetry diff --git a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h index fdcae668..f7a494c6 100644 --- a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h +++ b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h @@ -16,6 +16,7 @@ struct LocationTelemetry { int32_t latitude_e6 = 0; int32_t longitude_e6 = 0; int32_t altitude_cm = 0; + // Sideband Location.speed is kilometres/hour, scaled by 100 on wire. uint32_t speed_centi_kmh = 0; int32_t bearing_cdeg = 0; uint16_t accuracy_cm = 0; @@ -28,6 +29,14 @@ enum class DecodeResult : uint8_t { INVALID_ARGUMENT, MALFORMED, MISSING_LOCATION, + OUT_OF_RANGE, +}; + +enum class EncodeResult : uint8_t { + OK, + INVALID_ARGUMENT, + BUFFER_TOO_SMALL, + OUT_OF_RANGE, }; enum class FieldValueResult : uint8_t { @@ -35,9 +44,14 @@ enum class FieldValueResult : uint8_t { INVALID_ARGUMENT, NOT_BINARY, MALFORMED, + BUFFER_TOO_SMALL, }; struct BinaryView { + BinaryView() = default; + BinaryView(const uint8_t* bytes, std::size_t length) + : data(bytes), size(length) {} + const uint8_t* data = nullptr; std::size_t size = 0; }; @@ -47,11 +61,24 @@ FieldValueResult unwrapLxmfBinaryFieldValue( std::size_t raw_size, BinaryView& output); +FieldValueResult wrapLxmfBinaryFieldValue( + const uint8_t* payload, + std::size_t payload_size, + uint8_t* output, + std::size_t capacity, + std::size_t& written); + DecodeResult decodeLocationTelemetry( const uint8_t* data, std::size_t size, LocationTelemetry& output); +EncodeResult encodeLocationTelemetry( + const LocationTelemetry& input, + uint8_t* output, + std::size_t capacity, + std::size_t& written); + } // namespace Telemetry #endif // PYXIS_TELEMETRY_LOCATION_TELEMETRY_CODEC_H diff --git a/lib/tdeck_ui/library.json b/lib/tdeck_ui/library.json index dde0f17b..9a90a374 100644 --- a/lib/tdeck_ui/library.json +++ b/lib/tdeck_ui/library.json @@ -17,6 +17,7 @@ "flags": "-std=gnu++11 -I../../../../deps/microReticulum/src", "srcFilter": [ "+", + "+", "+", "+", "+", diff --git a/tests/build_scripts/test_telemetry_build_contract.py b/tests/build_scripts/test_telemetry_build_contract.py new file mode 100644 index 00000000..05d9b970 --- /dev/null +++ b/tests/build_scripts/test_telemetry_build_contract.py @@ -0,0 +1,11 @@ +"""Build-contract checks for the portable telemetry production sources.""" + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_tdeck_ui_builds_telemetry_cpp_sources(): + manifest = json.loads((ROOT / "lib" / "tdeck_ui" / "library.json").read_text()) + assert "+" in manifest["build"]["srcFilter"] diff --git a/tests/fixtures/location_telemetry_vectors.json b/tests/fixtures/location_telemetry_vectors.json new file mode 100644 index 00000000..246d3418 --- /dev/null +++ b/tests/fixtures/location_telemetry_vectors.json @@ -0,0 +1,48 @@ +{ + "reference": { + "repository": "https://github.com/markqvist/Sideband.git", + "commit": "2000d81a44bff57e3b3cb7d45915ba29bbde4e18", + "source": "sbapp/sideband/sense.py Telemeter.packed and Location.pack" + }, + "vectors": [ + { + "name": "san_francisco", + "sensor_timestamp_seconds": 1700000000, + "latitude": 37.7749, + "longitude": -122.4194, + "altitude": 16.0, + "speed_kmh": 12.34, + "bearing": 42.0, + "accuracy": 3.5, + "location_timestamp_seconds": 1700000000, + "packed_hex": "8201ce6553f1000297c40402406634c404f8b40738c40400000640c404000004d2c40400001068c402015ece6553f100", + "microlxmf_raw_value_hex": "c4308201ce6553f1000297c40402406634c404f8b40738c40400000640c404000004d2c40400001068c402015ece6553f100" + }, + { + "name": "bounds_signed", + "sensor_timestamp_seconds": 128, + "latitude": -90.0, + "longitude": 180.0, + "altitude": -123.45, + "speed_kmh": 0.0, + "bearing": -45.67, + "accuracy": 655.35, + "location_timestamp_seconds": 127, + "packed_hex": "8201cc800297c404faa2b580c4040aba9500c404ffffcfc7c40400000000c404ffffee29c402ffff7f", + "microlxmf_raw_value_hex": "c4298201cc800297c404faa2b580c4040aba9500c404ffffcfc7c40400000000c404ffffee29c402ffff7f" + }, + { + "name": "southern", + "sensor_timestamp_seconds": 65536, + "latitude": -33.8688, + "longitude": 151.2093, + "altitude": 58.25, + "speed_kmh": 88.88, + "bearing": 359.99, + "accuracy": 0.01, + "location_timestamp_seconds": 65535, + "packed_hex": "8201ce000100000297c404fdfb3400c40409034554c404000016c1c404000022b8c40400008c9fc4020001cdffff", + "microlxmf_raw_value_hex": "c42e8201ce000100000297c404fdfb3400c40409034554c404000016c1c404000022b8c40400008c9fc4020001cdffff" + } + ] +} diff --git a/tests/microlxmf/CMakeLists.txt b/tests/microlxmf/CMakeLists.txt new file mode 100644 index 00000000..5dcde8f5 --- /dev/null +++ b/tests/microlxmf/CMakeLists.txt @@ -0,0 +1,34 @@ +cmake_minimum_required(VERSION 3.14) +project(PyxisMicroLXMFTelemetryFieldTest LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT DEFINED MICROLXMF_BRIDGE_DIR) + message(FATAL_ERROR "MICROLXMF_BRIDGE_DIR is required") +endif() +if(NOT DEFINED MICROLXMF_SRC) + message(FATAL_ERROR "MICROLXMF_SRC is required") +endif() + +add_subdirectory(${MICROLXMF_BRIDGE_DIR} microlxmf-bridge) + +add_executable(test_pyxis_telemetry_field test_telemetry_field_roundtrip.cpp) +target_link_libraries(test_pyxis_telemetry_field PRIVATE MicroLXMFLib) +target_compile_definitions(test_pyxis_telemetry_field PRIVATE + MICROLXMF_NATIVE=1 + NATIVE=1 +) +target_compile_options(test_pyxis_telemetry_field PRIVATE + -Wall -Wextra + -Wno-unused-parameter + -Wno-unused-variable + -Wno-cpp + -Wno-missing-field-initializers + -fsanitize=address,undefined + -fno-omit-frame-pointer +) +target_link_options(test_pyxis_telemetry_field PRIVATE + -fsanitize=address,undefined +) diff --git a/tests/microlxmf/test_telemetry_field_roundtrip.cpp b/tests/microlxmf/test_telemetry_field_roundtrip.cpp new file mode 100644 index 00000000..87d42863 --- /dev/null +++ b/tests/microlxmf/test_telemetry_field_roundtrip.cpp @@ -0,0 +1,60 @@ +#include +#include +#include +#include + +#include +#include +#include +#include + +int main() { + constexpr uint8_t key_bytes[] = {0x02}; + constexpr uint8_t inner_telemeter[] = { + 0x82, 0x01, 0xce, 0x65, 0x53, 0xf1, 0x00, 0x02, 0x97, + 0xc4, 0x04, 0x02, 0x40, 0x66, 0x34, + 0xc4, 0x04, 0xf8, 0xb4, 0x07, 0x38, + 0xc4, 0x04, 0x00, 0x00, 0x06, 0x40, + 0xc4, 0x04, 0x00, 0x00, 0x04, 0xd2, + 0xc4, 0x04, 0x00, 0x00, 0x10, 0x68, + 0xc4, 0x02, 0x01, 0x5e, + 0xce, 0x65, 0x53, 0xf1, 0x00, + }; + uint8_t raw_value[sizeof(inner_telemeter) + 2]{}; + raw_value[0] = 0xc4; + raw_value[1] = static_cast(sizeof(inner_telemeter)); + for (std::size_t index = 0; index < sizeof(inner_telemeter); ++index) { + raw_value[index + 2] = inner_telemeter[index]; + } + + RNS::Identity source_identity; + RNS::Identity destination_identity; + RNS::Destination source( + source_identity, RNS::Type::Destination::IN, + RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + RNS::Destination destination( + destination_identity, RNS::Type::Destination::OUT, + RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + + LXMF::LXMessage message( + destination, source, RNS::Bytes{}, RNS::Bytes{}, + LXMF::Type::Message::OPPORTUNISTIC); + const RNS::Bytes key(key_bytes, sizeof(key_bytes)); + const RNS::Bytes value(raw_value, sizeof(raw_value)); + if (!message.fields_set(key, value)) { + std::cerr << "fields_set rejected telemetry\n"; + return EXIT_FAILURE; + } + + const RNS::Bytes packed = message.pack(); + LXMF::LXMessage decoded = LXMF::LXMessage::unpack_from_bytes( + packed, LXMF::Type::Message::OPPORTUNISTIC, true); + const RNS::Bytes* decoded_value = decoded.fields_get(key); + if (decoded_value == nullptr || *decoded_value != value) { + std::cerr << "raw telemetry BIN span changed across microLXMF pack/unpack\n"; + return EXIT_FAILURE; + } + + std::cout << "microLXMF telemetry field roundtrip: passed\n"; + return EXIT_SUCCESS; +} diff --git a/tests/native/test_location_telemetry_codec.cpp b/tests/native/test_location_telemetry_codec.cpp index 4f9eff8a..580af72b 100644 --- a/tests/native/test_location_telemetry_codec.cpp +++ b/tests/native/test_location_telemetry_codec.cpp @@ -1,68 +1,168 @@ +#include #include #include +#include #include #include "Telemetry/LocationTelemetryCodec.h" namespace { +int passed = 0; int failures = 0; #define CHECK(expr) \ do { \ - if (!(expr)) { \ + if (expr) { \ + ++passed; \ + } else { \ ++failures; \ std::cerr << "FAIL line " << __LINE__ << ": " #expr << '\n'; \ } \ } while (false) -void decodes_canonical_sideband_location() { - // MessagePack: {SID_TIME: 1700000000, SID_LOCATION: [fixed-width fields...]} - const uint8_t packed[] = { - 0x82, 0x01, 0xce, 0x65, 0x53, 0xf1, 0x00, 0x02, 0x97, - 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, 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, - }; +constexpr uint8_t CANONICAL[] = { + 0x82, 0x01, 0xce, 0x65, 0x53, 0xf1, 0x00, 0x02, 0x97, + 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, 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, +}; +Telemetry::LocationTelemetry expectedLocation() { + Telemetry::LocationTelemetry expected{}; + expected.latitude_e6 = 37774900; + expected.longitude_e6 = -122419400; + expected.altitude_cm = 1600; + expected.speed_centi_kmh = 1234; + expected.bearing_cdeg = 4200; + expected.accuracy_cm = 350; + expected.timestamp_seconds = 1700000000ULL; + expected.sensor_timestamp_seconds = 1700000000ULL; + return expected; +} + +bool equalLocation(const Telemetry::LocationTelemetry& left, + const Telemetry::LocationTelemetry& right) { + return left.latitude_e6 == right.latitude_e6 && + left.longitude_e6 == right.longitude_e6 && + left.altitude_cm == right.altitude_cm && + left.speed_centi_kmh == right.speed_centi_kmh && + left.bearing_cdeg == right.bearing_cdeg && + left.accuracy_cm == right.accuracy_cm && + left.timestamp_seconds == right.timestamp_seconds && + left.sensor_timestamp_seconds == right.sensor_timestamp_seconds; +} + +void decodesCanonicalSidebandLocation() { Telemetry::LocationTelemetry output{}; - const auto result = Telemetry::decodeLocationTelemetry( - packed, sizeof(packed), output); + CHECK(Telemetry::decodeLocationTelemetry( + CANONICAL, sizeof(CANONICAL), output) == + Telemetry::DecodeResult::OK); + CHECK(equalLocation(output, expectedLocation())); +} - CHECK(result == Telemetry::DecodeResult::OK); - CHECK(output.latitude_e6 == 37774900); - CHECK(output.longitude_e6 == -122419400); - CHECK(output.altitude_cm == 1600); - CHECK(output.speed_centi_kmh == 1234); - CHECK(output.bearing_cdeg == 4200); - CHECK(output.accuracy_cm == 350); - CHECK(output.timestamp_seconds == 1700000000ULL); +void emitsCanonicalSidebandLocation() { + uint8_t encoded[128]{}; + std::size_t written = 99; + CHECK(Telemetry::encodeLocationTelemetry( + expectedLocation(), encoded, sizeof(encoded), written) == + Telemetry::EncodeResult::OK); + CHECK(written == sizeof(CANONICAL)); + CHECK(std::memcmp(encoded, CANONICAL, sizeof(CANONICAL)) == 0); +} - uint8_t field_value[sizeof(packed) + 2]{}; +void unwrapsAndWrapsCurrentMicroLxmfFieldValue() { + uint8_t field_value[sizeof(CANONICAL) + 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]; - } + field_value[1] = static_cast(sizeof(CANONICAL)); + std::memcpy(field_value + 2, CANONICAL, sizeof(CANONICAL)); 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)); + CHECK(inner.size == sizeof(CANONICAL)); + + uint8_t wrapped[128]{}; + std::size_t written = 99; + CHECK(Telemetry::wrapLxmfBinaryFieldValue( + CANONICAL, sizeof(CANONICAL), wrapped, sizeof(wrapped), written) == + Telemetry::FieldValueResult::OK); + CHECK(written == sizeof(field_value)); + CHECK(std::memcmp(wrapped, field_value, sizeof(field_value)) == 0); +} + +void preservesOutputOnEveryTruncation() { + const auto sentinel = [] { + auto value = expectedLocation(); + value.latitude_e6 = 123; + return value; + }(); + + for (std::size_t size = 0; size < sizeof(CANONICAL); ++size) { + auto output = sentinel; + CHECK(Telemetry::decodeLocationTelemetry(CANONICAL, size, output) != + Telemetry::DecodeResult::OK); + CHECK(equalLocation(output, sentinel)); + } +} + +void acceptsUnknownNestedSensorAndReorderedKeys() { + // {32: [1, {"x": true}], 2: location, 1: time}. Sideband ignores unknown + // sensor IDs and dictionary order is not semantically significant. + constexpr uint8_t packed[] = { + 0x83, + 0x20, 0x92, 0x01, 0x81, 0xa1, 0x78, 0xc3, + 0x02, 0x97, + 0xc4, 0x04, 0x02, 0x40, 0x66, 0x34, + 0xc4, 0x04, 0xf8, 0xb4, 0x07, 0x38, + 0xc4, 0x04, 0x00, 0x00, 0x06, 0x40, + 0xc4, 0x04, 0x00, 0x00, 0x04, 0xd2, + 0xc4, 0x04, 0x00, 0x00, 0x10, 0x68, + 0xc4, 0x02, 0x01, 0x5e, + 0xce, 0x65, 0x53, 0xf1, 0x00, + 0x01, 0xce, 0x65, 0x53, 0xf1, 0x00, + }; + Telemetry::LocationTelemetry output{}; + CHECK(Telemetry::decodeLocationTelemetry(packed, sizeof(packed), output) == + Telemetry::DecodeResult::OK); + CHECK(equalLocation(output, expectedLocation())); +} + +void rejectsMalformedOuterFieldWithoutMutatingView() { + constexpr uint8_t not_binary[] = {0x81, 0x01, 0x02}; + constexpr uint8_t truncated[] = {0xc4, 0x04, 0x01, 0x02}; + const uint8_t sentinel_byte = 0; + const Telemetry::BinaryView sentinel{&sentinel_byte, 77}; + + auto output = sentinel; + CHECK(Telemetry::unwrapLxmfBinaryFieldValue( + not_binary, sizeof(not_binary), output) == + Telemetry::FieldValueResult::NOT_BINARY); + CHECK(output.data == sentinel.data && output.size == sentinel.size); + + output = sentinel; + CHECK(Telemetry::unwrapLxmfBinaryFieldValue( + truncated, sizeof(truncated), output) == + Telemetry::FieldValueResult::MALFORMED); + CHECK(output.data == sentinel.data && output.size == sentinel.size); } } // namespace int main() { - decodes_canonical_sideband_location(); - if (failures == 0) { - std::cout << "location telemetry codec: 10 passed, 0 failed\n"; - } + decodesCanonicalSidebandLocation(); + emitsCanonicalSidebandLocation(); + unwrapsAndWrapsCurrentMicroLxmfFieldValue(); + preservesOutputOnEveryTruncation(); + acceptsUnknownNestedSensorAndReorderedKeys(); + rejectsMalformedOuterFieldWithoutMutatingView(); + std::cout << "location telemetry codec: " << passed << " passed, " + << failures << " 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 e87a531c..98925598 100644 --- a/tests/native/test_location_telemetry_codec.py +++ b/tests/native/test_location_telemetry_codec.py @@ -23,4 +23,5 @@ def test_location_telemetry_codec(tmp_path): include_dirs=[PYXIS_ROOT / "lib" / "tdeck_ui"], sanitize=True, ) - assert "location telemetry codec: 10 passed, 0 failed" in ran.stdout + assert "location telemetry codec:" in ran.stdout + assert "0 failed" in ran.stdout diff --git a/tests/reference/test_microlxmf_telemetry_field.py b/tests/reference/test_microlxmf_telemetry_field.py new file mode 100644 index 00000000..5f8f0e30 --- /dev/null +++ b/tests/reference/test_microlxmf_telemetry_field.py @@ -0,0 +1,66 @@ +"""Build and run telemetry fields through the exact pinned microLXMF source.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_MICROLXMF = ROOT / ".pio" / "libdeps" / "tdeck" / "microLXMF" + + +def test_pinned_microlxmf_telemetry_field_roundtrip(tmp_path): + if os.environ.get("PYXIS_RUN_MICROLXMF_NATIVE") != "1": + pytest.skip("set PYXIS_RUN_MICROLXMF_NATIVE=1 for the dependency-level test") + + microlxmf = Path(os.environ.get("PYXIS_MICROLXMF_SRC", DEFAULT_MICROLXMF)) + bridge = microlxmf / "conformance-bridge" + source = microlxmf / "src" + if not (source / "LXMF" / "LXMessage.cpp").is_file(): + pytest.fail(f"pinned microLXMF source is unavailable at {microlxmf}") + + expected = "d9bbc04cf69bfa9b555c3f293b89b440b4820518" + actual = subprocess.run( + ["git", "-C", str(microlxmf), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert actual == expected + + build = tmp_path / "build" + configure = subprocess.run( + [ + "cmake", + "-S", + str(ROOT / "tests" / "microlxmf"), + "-B", + str(build), + f"-DMICROLXMF_BRIDGE_DIR={bridge}", + f"-DMICROLXMF_SRC={source}", + "-DCMAKE_BUILD_TYPE=Debug", + ], + capture_output=True, + text=True, + timeout=300, + ) + assert configure.returncode == 0, configure.stdout + configure.stderr + + compiled = subprocess.run( + ["cmake", "--build", str(build), "--target", "test_pyxis_telemetry_field", "-j2"], + capture_output=True, + text=True, + timeout=600, + ) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + + binary = build / "test_pyxis_telemetry_field" + ran = subprocess.run( + [str(binary)], capture_output=True, text=True, timeout=60, + 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 diff --git a/tests/reference/test_sideband_location_vectors.py b/tests/reference/test_sideband_location_vectors.py new file mode 100644 index 00000000..e41b2db9 --- /dev/null +++ b/tests/reference/test_sideband_location_vectors.py @@ -0,0 +1,70 @@ +"""Verify committed vectors byte-for-byte against authoritative Sideband.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +VECTORS = ROOT / "tests" / "fixtures" / "location_telemetry_vectors.json" +DEFAULT_SIDEBAND = Path("/tmp/pyxis-sideband-reference-20260728") + + +def _sideband_source() -> Path: + source = Path(os.environ.get("SIDEBAND_SRC", DEFAULT_SIDEBAND)) + if not (source / "sbapp" / "sideband" / "sense.py").is_file(): + pytest.skip("set SIDEBAND_SRC to an authoritative Sideband checkout") + return source + + +def test_vectors_match_pinned_sideband_commit(): + fixture = json.loads(VECTORS.read_text()) + source = _sideband_source() + actual = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert actual == fixture["reference"]["commit"] + + sys.path.insert(0, str(source)) + import importlib + + sense = importlib.import_module("sbapp.sideband.sense") + from RNS.vendor import umsgpack + + for vector in fixture["vectors"]: + now = vector["sensor_timestamp_seconds"] + sense.time.time = lambda now=now: now + telemeter = sense.Telemeter(from_packed=True) + telemeter.synthesize("location") + telemeter.sensors["location"].data = { + "latitude": vector["latitude"], + "longitude": vector["longitude"], + "altitude": vector["altitude"], + "speed": vector["speed_kmh"], + "bearing": vector["bearing"], + "accuracy": vector["accuracy"], + "last_update": vector["location_timestamp_seconds"], + } + packed = telemeter.packed() + assert packed.hex() == vector["packed_hex"], vector["name"] + + # Pinned microLXMF stores field values as raw MessagePack spans and + # splices them with packRawBytes(). FIELD_TELEMETRY must therefore be a + # raw BIN token whose decoded Python LXMF value is the packed bytes. + raw_value = bytes.fromhex(vector["microlxmf_raw_value_hex"]) + assert umsgpack.unpackb(raw_value) == packed, vector["name"] + + decoded = sense.Telemeter.from_packed(packed) + assert decoded is not None, vector["name"] + readings = decoded.read_all() + assert readings["time"]["utc"] == vector["sensor_timestamp_seconds"] + assert readings["location"]["speed"] == vector["speed_kmh"] + assert readings["location"]["last_update"] == vector["location_timestamp_seconds"]