From 3ba0fb184780a96567f9d27ea63dc48c191bf86c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 4 Aug 2026 15:35:15 -0700 Subject: [PATCH] Add configurable repeater telemetry history --- docs/cli_command_availability.md | 2 + docs/cli_commands.md | 95 +++ docs/halo_keymind_settings.md | 1 + examples/simple_repeater/MyMesh.cpp | 184 +++++ examples/simple_repeater/MyMesh.h | 19 + src/helpers/TelemetryHistory.h | 688 ++++++++++++++++++ test/README.md | 1 + .../test_telemetry_history.cpp | 344 +++++++++ 8 files changed, 1334 insertions(+) create mode 100644 src/helpers/TelemetryHistory.h create mode 100644 test/test_telemetry_history/test_telemetry_history.cpp diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 36db2626..d0e4c95a 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -69,6 +69,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | Statistics | [`stats-radio`](cli_commands.md#radio-stats---noise-floor-last-rssisnr-airtime-receive-errors) | Local serial | Serial | Serial | Serial | | Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial | | Statistics | [`stats-packets`](cli_commands.md#packet-stats---packet-counters-received-sent) | Local serial | Serial | Serial | Serial | +| Statistics | [`get telemetry.temp/volt/gps`; `set telemetry.gps`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; remote access requires administrator | Yes | Yes | Yes | | Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data; other roles can return empty data | Yes | Yes | Yes | | Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | | Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | @@ -224,6 +225,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | Statistics | [`stats-radio`](cli_commands.md#radio-stats---noise-floor-last-rssisnr-airtime-receive-errors) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial | | Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial | | Statistics | [`stats-packets`](cli_commands.md#packet-stats---packet-counters-received-sent) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial | +| Statistics | [`get telemetry.temp/volt/gps`; `set telemetry.gps`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; remote access requires administrator | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | No | No | Yes | Yes | | Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial | | Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | Yes | Yes | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9641e9c2..aead0b0a 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -303,6 +303,101 @@ refresh is already in flight, the scope pass is queued behind it. --- +### Read repeater telemetry history + +Repeater firmware records one UTC-aligned sample every 30 minutes. Temperature +and battery voltage retain 336 samples (seven rolling days). GPS retains three +days by default. Sensor builds with an onboard GPS provider request a seven-day +default at startup. The history and any runtime retention change are held in +RAM and reset after a reboot. + +The feature is omitted from flash-constrained STM32 repeater images. + +**Usage:** + +- `get telemetry.temp [page]` +- `get telemetry.volt [page]` +- `get telemetry.gps [page]` +- `set telemetry.gps ` + +**Parameters:** + +- `page`: Page `1` is always newest. Temperature and voltage pages each hold + 24 hours and accept `1`-`7`. GPS pages each hold 12 hours and accept `1` + through twice the current GPS retention in days. Omitting the page selects + page `1`. +- `days`: Requested GPS retention from `1` through `30` days. Retention above + three days uses heap memory. The allocator reduces the requested value as + needed to leave at least 2048 bytes free and replies with the days and pages + actually available. For example, a request can return + `OK - telemetry.gps days=18 pages=36 requested=30`. + +Local serial and remote administrator CLI sessions can read the history. +Collection uses the MCU temperature, battery voltage, and an already-valid +onboard GPS fix. It does not wake GPS, so an off, sleeping, or unfixed GPS +produces a missing location sample without changing its power-saving schedule. + +Replies contain `> ` followed by standard padded Base64. After decoding, all +multi-byte integers are little-endian. Packed fields are written most +significant bit first, oldest sample first. + +Temperature payload (`0x11`, 61 bytes): + +| Bytes | Meaning | +|---|---| +| `0` | Format/type `0x11` | +| `1`-`4` | First sample UTC epoch, unsigned 32-bit | +| `5` | Sample interval in minutes (`30`) | +| `6` | Sample count (`48`) | +| `7`-`18` | 48 packed 2-bit temperature statuses | +| `19`-`60` | 48 packed 7-bit temperatures | + +Temperature status codes are `0` none, `1` value, `2` below range, and `3` +above range. For status `1`, the 7-bit temperature is an exact whole-degree +integer from `-50 C` through `+77 C`; decode it as `code - 50`. Low values use +code `0`, and high values use code `127`. The separate status map is required +because 7 bits contain exactly 128 codes, leaving no spare code for none, low, +or high when the complete range is represented at 1 C resolution. No +fractional temperature is stored or transmitted. + +Voltage payload (`0x12`, 55 bytes): + +| Bytes | Meaning | +|---|---| +| `0` | Format/type `0x12` | +| `1`-`4` | First sample UTC epoch, unsigned 32-bit | +| `5` | Sample interval in minutes (`30`) | +| `6` | Sample count (`48`) | +| `7`-`54` | 48 8-bit voltage codes | + +Voltage codes reserve `0` for no reading, `1` for below `1.88 V`, and `255` +for above `4.40 V`. Codes `2`-`254` represent `1.88 V` through `4.40 V` in +`0.01 V` steps; decode millivolts as `1880 + (code - 2) * 10`. + +GPS payload (`0x13`, 101 bytes): + +| Bytes | Meaning | +|---|---| +| `0` | Format/type `0x13` | +| `1`-`4` | First sample UTC epoch, unsigned 32-bit | +| `5` | Sample interval in minutes (`30`) | +| `6` | Sample count (`24`) | +| `7`-`10` | Page origin latitude in signed degrees times `10^7` | +| `11`-`14` | Page origin longitude in signed degrees times `10^7` | +| `15` | Origin sample index, or `255` when the page has no GPS fix | +| `16` | Flags; bit 0 means at least one differential was clipped | +| `17`-`100` | 24 records: signed 14-bit north then signed 14-bit east | + +GPS differentials use signed 14-bit two's-complement values at 10-meter +resolution and are applied to the preceding decoded valid point. The origin +sample begins at the header coordinates. A no-fix slot encodes `0,0` and does +not advance the reference; a stationary valid fix also quantizes to `0,0`. +When a page has no fixes, its origin is `0,0`, origin index is `255`, and all +differentials are `0,0`. Values outside `-8192` through `8191` are clipped and +set flag bit 0. + +--- + ## Logging Builds compiled with `MESH_PACKET_LOGGING` emit one `RAW:` line for every diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index b0dd88e3..873b71c8 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -75,6 +75,7 @@ set flood.retry.ignore none | Setting | What it does | How to use | Example | | --- | --- | --- | --- | +| `telemetry.temp`, `telemetry.volt`, `telemetry.gps` | Records 30-minute whole-degree MCU temperature and battery samples for seven days. GPS defaults to three days, or requests seven days at startup on onboard-GPS sensor builds. Runtime GPS retention can be `1`-`30` days and is reduced if needed to retain 2 KB of free memory. Pages are 1-based and newest first. History and runtime sizing reset on reboot. | `get telemetry.temp [page]`, `get telemetry.volt [page]`, `get telemetry.gps [page]`, `set telemetry.gps <1-30>` | `set telemetry.gps 30` | | `battery.alert` | Sends opt-in, region-scoped low-battery warnings to `#repeaters` after 30 minutes of uptime. | `get battery.alert`, `get battery.alert.region`, `set battery.alert on [region]`, `set battery.alert off` | `set battery.alert on sea` | | `battery.alert.low` | Warning threshold percentage. Must be greater than `battery.alert.critical`. | `get battery.alert.low`, `set battery.alert.low <1-100>` | `set battery.alert.low 20` | | `battery.alert.critical` | Critical threshold percentage. Critical and warning alerts use the same 12-hour resend cooldown. | `get battery.alert.critical`, `set battery.alert.critical <0-99>` | `set battery.alert.critical 10` | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8c7e86af..26e2df9e 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -8,6 +8,10 @@ #ifdef WITH_WEBCONFIG #include #endif +#if MESH_ENABLE_TELEMETRY_HISTORY && defined(STM32_PLATFORM) +#include +extern "C" caddr_t _sbrk(int increment); +#endif /* ------------------------------ Config -------------------------------- */ @@ -133,9 +137,50 @@ static const char FLOOD_CHANNEL_SCOPE_USAGE[] = #define LOW_BATTERY_CHECK_INTERVAL (30UL * 60UL * 1000UL) #define LOW_BATTERY_ALERT_INTERVAL (12UL * 60UL * 60UL * 1000UL) #define RX_INACTIVITY_WATCHDOG_INTERVAL (12UL * 60UL * 60UL * 1000UL) +#if MESH_ENABLE_TELEMETRY_HISTORY +#define TELEMETRY_GPS_HEAP_RESERVE_BYTES 2048U +#endif #define CLOCK_SYNC_VALID_YEARS 10 +#if MESH_ENABLE_TELEMETRY_HISTORY +static size_t telemetryFreeHeapBytes() { +#if defined(ESP_PLATFORM) + return (size_t)ESP.getFreeHeap(); +#elif defined(NRF52_PLATFORM) + const int free_bytes = dbgHeapFree(); + return free_bytes > 0 ? (size_t)free_bytes : 0; +#elif defined(RP2040_PLATFORM) + const int free_bytes = rp2040.getFreeHeap(); + return free_bytes > 0 ? (size_t)free_bytes : 0; +#elif defined(STM32_PLATFORM) + uint8_t stack_marker; + const caddr_t heap_end = _sbrk(0); + if (heap_end == (caddr_t)-1) return 0; + const uintptr_t stack_address = (uintptr_t)&stack_marker; + const uintptr_t heap_address = (uintptr_t)heap_end; + return stack_address > heap_address ? stack_address - heap_address : 0; +#else + return 0; +#endif +} + +static bool parseTelemetryGpsDays(const char* args, uint8_t& days) { + while (*args == ' ') args++; + if (*args < '0' || *args > '9') return false; + + unsigned parsed = 0; + while (*args >= '0' && *args <= '9') { + parsed = parsed * 10U + (unsigned)(*args++ - '0'); + if (parsed > mesh::TelemetryHistory::GPS_MAX_RETENTION_DAYS) return false; + } + while (*args == ' ') args++; + if (*args != 0 || parsed < 1U) return false; + days = (uint8_t)parsed; + return true; +} +#endif + enum ClockSyncSource : uint8_t { CLOCK_SYNC_SOURCE_NONE = 0, CLOCK_SYNC_SOURCE_MESH = 1, @@ -3089,6 +3134,12 @@ void MyMesh::begin(FILESYSTEM *fs) { #if ENV_INCLUDE_GPS == 1 applyGpsPrefs(); +#if MESH_ENABLE_TELEMETRY_HISTORY + if (sensors.getLocationProvider() != NULL) { + const uint8_t gps_days = resizeTelemetryGpsDays(7); + MESH_DEBUG_PRINTLN("Telemetry GPS retention: %u days", (unsigned)gps_days); + } +#endif #endif } @@ -8142,6 +8193,68 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * const mesh::cli::NoArgCommandMatch discover_neighbors_match = mesh::cli::matchNoArgCommand(command, "discover.neighbors"); +#if MESH_ENABLE_TELEMETRY_HISTORY + const char* telemetry_args = NULL; + mesh::TelemetryHistory::Series telemetry_series = + mesh::TelemetryHistory::SERIES_TEMPERATURE; + static const char telemetry_temp_command[] = "get telemetry.temp"; + static const char telemetry_volt_command[] = "get telemetry.volt"; + static const char telemetry_gps_command[] = "get telemetry.gps"; + static const char telemetry_gps_set_command[] = "set telemetry.gps"; + if (strncmp(command, telemetry_gps_set_command, + sizeof(telemetry_gps_set_command) - 1U) == 0 + && (command[sizeof(telemetry_gps_set_command) - 1U] == 0 + || command[sizeof(telemetry_gps_set_command) - 1U] == ' ')) { + if (sender != NULL && !sender->isAdmin()) { + strcpy(reply, "Err - not permitted"); + return; + } + uint8_t requested_days = 0; + if (!parseTelemetryGpsDays( + command + sizeof(telemetry_gps_set_command) - 1U, + requested_days)) { + strcpy(reply, "Err - use: set telemetry.gps <1-30>"); + return; + } + const uint8_t actual_days = resizeTelemetryGpsDays(requested_days); + snprintf(reply, 160, + "OK - telemetry.gps days=%u pages=%u requested=%u", + (unsigned)actual_days, + (unsigned)telemetry_history.gpsPageCount(), + (unsigned)requested_days); + return; + } + + if (strncmp(command, telemetry_temp_command, + sizeof(telemetry_temp_command) - 1U) == 0 + && (command[sizeof(telemetry_temp_command) - 1U] == 0 + || command[sizeof(telemetry_temp_command) - 1U] == ' ')) { + telemetry_args = command + sizeof(telemetry_temp_command) - 1U; + } else if (strncmp(command, telemetry_volt_command, + sizeof(telemetry_volt_command) - 1U) == 0 + && (command[sizeof(telemetry_volt_command) - 1U] == 0 + || command[sizeof(telemetry_volt_command) - 1U] == ' ')) { + telemetry_series = mesh::TelemetryHistory::SERIES_VOLTAGE; + telemetry_args = command + sizeof(telemetry_volt_command) - 1U; + } else if (strncmp(command, telemetry_gps_command, + sizeof(telemetry_gps_command) - 1U) == 0 + && (command[sizeof(telemetry_gps_command) - 1U] == 0 + || command[sizeof(telemetry_gps_command) - 1U] == ' ')) { + telemetry_series = mesh::TelemetryHistory::SERIES_GPS; + telemetry_args = command + sizeof(telemetry_gps_command) - 1U; + } + + if (telemetry_args != NULL) { + if (sender != NULL && !sender->isAdmin()) { + strcpy(reply, "Err - not permitted"); + } else { + telemetry_history.formatPageReply(telemetry_series, telemetry_args, + reply, 160); + } + return; + } +#endif + #if defined(PORTABLE_MQTT_OBSERVER) // Neighbor refresh is a core repeater operation, not an MQTT feature. Keep // it ahead of the portable observer's reduced CommonCLI handoff so every @@ -8562,6 +8675,74 @@ void MyMesh::loop() { servicePostMeshLoop(); } +#if MESH_ENABLE_TELEMETRY_HISTORY +uint8_t MyMesh::resizeTelemetryGpsDays(uint8_t requested_days) { + size_t free_bytes = telemetryFreeHeapBytes(); + const size_t allocation_budget = free_bytes > TELEMETRY_GPS_HEAP_RESERVE_BYTES + ? free_bytes - TELEMETRY_GPS_HEAP_RESERVE_BYTES : 0; + uint8_t actual_days = telemetry_history.resizeGpsDays( + requested_days, allocation_budget); + + free_bytes = telemetryFreeHeapBytes(); + while (actual_days > mesh::TelemetryHistory::GPS_DEFAULT_RETENTION_DAYS + && free_bytes < TELEMETRY_GPS_HEAP_RESERVE_BYTES) { + const size_t deficit = TELEMETRY_GPS_HEAP_RESERVE_BYTES - free_bytes; + size_t days_to_release = + (deficit + mesh::TelemetryHistory::GPS_HEAP_BYTES_PER_DAY - 1U) + / mesh::TelemetryHistory::GPS_HEAP_BYTES_PER_DAY; + if (days_to_release == 0) days_to_release = 1; + const uint8_t target_days = days_to_release + < actual_days - mesh::TelemetryHistory::GPS_DEFAULT_RETENTION_DAYS + ? (uint8_t)(actual_days - days_to_release) + : mesh::TelemetryHistory::GPS_DEFAULT_RETENTION_DAYS; + const uint8_t reduced_days = telemetry_history.resizeGpsDays(target_days, 0); + if (reduced_days >= actual_days) { + actual_days = telemetry_history.resizeGpsDays( + mesh::TelemetryHistory::GPS_DEFAULT_RETENTION_DAYS, 0); + } else { + actual_days = reduced_days; + } + free_bytes = telemetryFreeHeapBytes(); + } + return actual_days; +} + +void MyMesh::sampleTelemetryHistory() { + const uint32_t now = rtc_clock.getCurrentTime(); + if (!telemetry_history.sampleDue(now)) return; + + int32_t latitude_e7 = 0; + int32_t longitude_e7 = 0; + bool gps_valid = false; +#if ENV_INCLUDE_GPS == 1 + LocationProvider* location = sensors.getLocationProvider(); + if (location != NULL && location->isEnabled() && location->isValid()) { + const long latitude_e6 = location->getLatitude(); + const long longitude_e6 = location->getLongitude(); + if (latitude_e6 >= -90000000L && latitude_e6 <= 90000000L + && longitude_e6 >= -180000000L && longitude_e6 <= 180000000L) { + latitude_e7 = (int32_t)((int64_t)latitude_e6 * 10); + longitude_e7 = (int32_t)((int64_t)longitude_e6 * 10); + gps_valid = latitude_e7 != 0 || longitude_e7 != 0; + } + } +#endif + + const float measured_temperature = _cli.getBoard()->getMCUTemperature(); + const bool temperature_valid = isfinite(measured_temperature); + int16_t temperature_c = 0; + if (temperature_valid) { + if (measured_temperature < -32768.0f) temperature_c = INT16_MIN; + else if (measured_temperature > 32767.0f) temperature_c = INT16_MAX; + else temperature_c = (int16_t)lroundf(measured_temperature); + } + + telemetry_history.record(now, temperature_c, temperature_valid, + _cli.getBoard()->getBattMilliVolts(), + latitude_e7, longitude_e7, gps_valid); +} +#endif + void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { if (pending_self_advert) { const uint32_t delay_millis = pending_self_advert_delay; @@ -8570,6 +8751,9 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { sendSelfAdvertisementNow(delay_millis, flood); } checkRxInactivityWatchdog(); +#if MESH_ENABLE_TELEMETRY_HISTORY + sampleTelemetryHistory(); +#endif #if !defined(PORTABLE_MQTT_OBSERVER) checkBatteryAlert(); expireRecentRepeatersIfDue(); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index cf423bf2..890c8fb1 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -9,6 +9,15 @@ #include #include +#ifndef MESH_ENABLE_TELEMETRY_HISTORY + // LoRa-E5-class STM32 repeater images have less than 3 KB of spare flash. + #if defined(STM32_PLATFORM) + #define MESH_ENABLE_TELEMETRY_HISTORY 0 + #else + #define MESH_ENABLE_TELEMETRY_HISTORY 1 + #endif +#endif + #ifndef MESH_ENABLE_RECENT_REPEATERS #define MESH_ENABLE_RECENT_REPEATERS 1 #endif @@ -71,6 +80,9 @@ #include #include #include +#if MESH_ENABLE_TELEMETRY_HISTORY +#include +#endif #include #include #include "RateLimiter.h" @@ -358,6 +370,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif CayenneLPP telemetry; +#if MESH_ENABLE_TELEMETRY_HISTORY + mesh::TelemetryHistory telemetry_history; +#endif unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled) float active_bw; // live BW, including temporary radio overrides uint8_t active_sf; // live SF, including temporary radio overrides @@ -491,6 +506,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks unsigned long delay_millis, uint8_t path_hash_size, const TransportKey* fallback_scope); void servicePostMeshLoop(); +#if MESH_ENABLE_TELEMETRY_HISTORY + void sampleTelemetryHistory(); + uint8_t resizeTelemetryGpsDays(uint8_t requested_days); +#endif void sendSelfAdvertisementNow(uint32_t delay_millis, bool flood); bool sendRepeatersFloodText(const char* text, const TransportKey* scope = nullptr, mesh::Packet** queued_packet = nullptr); diff --git a/src/helpers/TelemetryHistory.h b/src/helpers/TelemetryHistory.h new file mode 100644 index 00000000..d3e4b509 --- /dev/null +++ b/src/helpers/TelemetryHistory.h @@ -0,0 +1,688 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace mesh { + +// A bounded, boot-local telemetry history for repeater diagnostics. +// Samples are aligned to 30-minute UTC buckets. Temperature and voltage keep +// seven days; GPS keeps three days by default and can grow to 30 days. Missing +// buckets are inserted explicitly so a temperature or voltage page always +// describes the same 48 half-hour positions. GPS pages contain 24 half-hour +// positions. +class TelemetryHistory { + struct GpsSample { + int32_t latitude_e7; + int32_t longitude_e7; + }; + static_assert(sizeof(GpsSample) == 8, "GPS sample must remain eight bytes"); + +public: + static constexpr uint32_t SAMPLE_INTERVAL_SECONDS = 30UL * 60UL; + static constexpr uint8_t SAMPLES_PER_DAY = 48; + static constexpr uint16_t TV_RETENTION_SAMPLES = 7U * SAMPLES_PER_DAY; + static constexpr uint8_t GPS_DEFAULT_RETENTION_DAYS = 3; + static constexpr uint8_t GPS_MAX_RETENTION_DAYS = 30; + static constexpr uint16_t GPS_RETENTION_SAMPLES = + GPS_DEFAULT_RETENTION_DAYS * SAMPLES_PER_DAY; + static constexpr uint8_t GPS_SAMPLES_PER_PAGE = 24; + static constexpr uint8_t TV_PAGE_COUNT = 7; + static constexpr uint8_t GPS_DEFAULT_PAGE_COUNT = + GPS_DEFAULT_RETENTION_DAYS * 2U; + static constexpr size_t GPS_HEAP_BYTES_PER_DAY = + SAMPLES_PER_DAY * sizeof(GpsSample); + + static constexpr uint8_t TEMPERATURE_PAYLOAD_TYPE_V1 = 0x11; + static constexpr uint8_t VOLTAGE_PAYLOAD_TYPE_V1 = 0x12; + static constexpr uint8_t GPS_PAYLOAD_TYPE_V1 = 0x13; + + enum Series : uint8_t { + SERIES_TEMPERATURE = 0, + SERIES_VOLTAGE = 1, + SERIES_GPS = 2, + }; + + enum TemperatureStatus : uint8_t { + TEMPERATURE_NONE = 0, + TEMPERATURE_VALUE = 1, + TEMPERATURE_LOW = 2, + TEMPERATURE_HIGH = 3, + }; + + TelemetryHistory() + : _gps_samples(_gps_default_samples), + _gps_capacity(GPS_RETENTION_SAMPLES), + _gps_allocated_capacity(0) { + clear(); + } + + ~TelemetryHistory() { + if (_gps_allocated_capacity != 0) free(_gps_samples); + } + + TelemetryHistory(const TelemetryHistory&) = delete; + TelemetryHistory& operator=(const TelemetryHistory&) = delete; + + void clear() { + memset(_temperature, 0, sizeof(_temperature)); + memset(_temperature_status, 0, sizeof(_temperature_status)); + memset(_voltage, 0, sizeof(_voltage)); + memset(_gps_samples, 0, gpsPhysicalCapacity() * sizeof(GpsSample)); + _tv_next = 0; + _tv_count = 0; + _gps_next = 0; + _gps_count = 0; + _last_bucket = 0; + _has_bucket = false; + } + + bool sampleDue(uint32_t epoch_seconds) const { + return !_has_bucket || epoch_seconds / SAMPLE_INTERVAL_SECONDS != _last_bucket; + } + + void record(uint32_t epoch_seconds, int16_t temperature_c, bool temperature_valid, + uint16_t battery_mv, + int32_t gps_lat_e7, int32_t gps_lon_e7, bool gps_valid) { + const uint32_t bucket = epoch_seconds / SAMPLE_INTERVAL_SECONDS; + uint8_t temperature_status; + const uint8_t temperature = encodeTemperature(temperature_c, temperature_valid, + temperature_status); + const uint8_t voltage = encodeVoltage(battery_mv); + gps_valid = gps_valid + && gps_lat_e7 >= -900000000 && gps_lat_e7 <= 900000000 + && gps_lon_e7 >= -1800000000 && gps_lon_e7 <= 1800000000 + && (gps_lat_e7 != 0 || gps_lon_e7 != 0); + + if (!_has_bucket) { + append(temperature, temperature_status, voltage, + gps_lat_e7, gps_lon_e7, gps_valid); + _last_bucket = bucket; + _has_bucket = true; + return; + } + + if (bucket == _last_bucket) return; + + const uint32_t maximum_retention = _gps_capacity > TV_RETENTION_SAMPLES + ? _gps_capacity : TV_RETENTION_SAMPLES; + if (bucket < _last_bucket || bucket - _last_bucket > maximum_retention) { + clear(); + append(temperature, temperature_status, voltage, + gps_lat_e7, gps_lon_e7, gps_valid); + _last_bucket = bucket; + _has_bucket = true; + return; + } + + const uint32_t skipped = bucket - _last_bucket - 1U; + for (uint32_t i = 0; i < skipped; i++) { + append(0, TEMPERATURE_NONE, 0, 0, 0, false); + } + append(temperature, temperature_status, voltage, + gps_lat_e7, gps_lon_e7, gps_valid); + _last_bucket = bucket; + } + + // The seven-bit value is an exact whole-degree offset: 0=-50 C, 127=+77 C. + // A separate two-bit status carries none/value/low/high because seven bits + // alone cannot represent 128 temperatures plus three sentinel states. + static uint8_t encodeTemperature(int16_t temperature_c, bool valid, + uint8_t& status) { + if (!valid) { + status = TEMPERATURE_NONE; + return 0; + } + if (temperature_c < -50) { + status = TEMPERATURE_LOW; + return 0; + } + if (temperature_c > 77) { + status = TEMPERATURE_HIGH; + return 127; + } + status = TEMPERATURE_VALUE; + return (uint8_t)(temperature_c + 50); + } + + // Voltage uses all 256 eight-bit codes: + // 0 no reading + // 1 below 1.88 V + // 2..254 1.88 V through 4.40 V in 0.01 V steps + // 255 above 4.40 V + static uint8_t encodeVoltage(uint16_t battery_mv) { + if (battery_mv == 0) return 0; + if (battery_mv < 1880) return 1; + if (battery_mv > 4400) return 255; + return (uint8_t)(2U + (battery_mv - 1880U + 5U) / 10U); + } + + uint8_t gpsRetentionDays() const { + return (uint8_t)(_gps_capacity / SAMPLES_PER_DAY); + } + + uint8_t gpsPageCount() const { + return (uint8_t)(_gps_capacity / GPS_SAMPLES_PER_PAGE); + } + + // Changes the logical GPS retention and preserves the newest samples. The + // heap budget is the maximum additional allocation allowed for this call. + // Requests above the budget are reduced one day at a time. The returned + // value is the number of days actually available. + uint8_t resizeGpsDays(uint8_t requested_days, + size_t max_additional_heap_bytes) { + if (requested_days < 1U) requested_days = 1U; + if (requested_days > GPS_MAX_RETENTION_DAYS) { + requested_days = GPS_MAX_RETENTION_DAYS; + } + + normalizeGpsRing(); + const uint8_t current_days = gpsRetentionDays(); + if (requested_days == current_days) return current_days; + + if (requested_days <= GPS_DEFAULT_RETENTION_DAYS) { + resizeGpsToDefaultStorage(requested_days); + return gpsRetentionDays(); + } + + if (_gps_allocated_capacity != 0 + && requested_days * SAMPLES_PER_DAY <= _gps_allocated_capacity) { + resizeGpsLogicalCapacity((uint16_t)(requested_days * SAMPLES_PER_DAY)); + shrinkGpsAllocation(); + return gpsRetentionDays(); + } + + if (_gps_allocated_capacity == 0 + && _gps_capacity < GPS_RETENTION_SAMPLES) { + resizeGpsLogicalCapacity(GPS_RETENTION_SAMPLES); + } + + const uint16_t old_allocated_capacity = _gps_allocated_capacity; + const size_t old_heap_bytes = + (size_t)old_allocated_capacity * sizeof(GpsSample); + for (uint8_t days = requested_days; + days > gpsRetentionDays(); days--) { + const uint16_t candidate_capacity = (uint16_t)(days * SAMPLES_PER_DAY); + const size_t candidate_bytes = + (size_t)candidate_capacity * sizeof(GpsSample); + const size_t additional_bytes = candidate_bytes > old_heap_bytes + ? candidate_bytes - old_heap_bytes : 0; + if (additional_bytes > max_additional_heap_bytes) continue; + if (expandGpsStorage(candidate_capacity)) return days; + } + return gpsRetentionDays(); + } + + // Formats a complete CLI reply in at most 139 bytes, including NUL: + // get telemetry.temp [page] (page 1..7) + // get telemetry.volt [page] (page 1..7) + // get telemetry.gps [page] (page 1..configured days * 2) + // Page 1 is always the newest page. Temperature and voltage pages hold one + // day; GPS pages hold half a day. + // The reply is "> " followed by standard padded Base64. + bool formatPageReply(Series series, const char* args, + char* reply, size_t reply_size) const { + if (reply == NULL || reply_size == 0) return false; + reply[0] = 0; + if (!_has_bucket) { + copyReply(reply, reply_size, "Err - telemetry history is empty"); + return false; + } + + const char* cursor = skipSpaces(args == NULL ? "" : args); + char token[12]; + unsigned page = 1; + + if (readToken(cursor, token, sizeof(token))) { + if (!parseUnsigned(token, page)) return formatUsage(series, reply, reply_size); + } + + cursor = skipSpaces(cursor); + if (*cursor != 0) return formatUsage(series, reply, reply_size); + + if (series == SERIES_GPS) { + if (page < 1U || page > gpsPageCount()) { + snprintf(reply, reply_size, "Err - telemetry.gps page must be 1-%u", + (unsigned)gpsPageCount()); + return false; + } + return formatGpsPage((uint8_t)page, reply, reply_size); + } + + if (page < 1U || page > TV_PAGE_COUNT) { + copyReply(reply, reply_size, series == SERIES_TEMPERATURE + ? "Err - telemetry.temp page must be 1-7" + : "Err - telemetry.volt page must be 1-7"); + return false; + } + return series == SERIES_TEMPERATURE + ? formatTemperaturePage((uint8_t)page, reply, reply_size) + : formatVoltagePage((uint8_t)page, reply, reply_size); + } + +private: + class BitWriter { + uint8_t* _dest; + size_t _capacity; + size_t _bits; + + public: + BitWriter(uint8_t* dest, size_t capacity) + : _dest(dest), _capacity(capacity), _bits(0) { + memset(dest, 0, capacity); + } + + bool write(uint32_t value, uint8_t width) { + if (_bits + width > _capacity * 8U) return false; + for (int bit = width - 1; bit >= 0; bit--) { + if (value & (1UL << bit)) { + _dest[_bits / 8U] |= (uint8_t)(1U << (7U - (_bits % 8U))); + } + _bits++; + } + return true; + } + }; + + uint8_t _temperature[TV_RETENTION_SAMPLES]; + uint8_t _temperature_status[(TV_RETENTION_SAMPLES * 2U + 7U) / 8U]; + uint8_t _voltage[TV_RETENTION_SAMPLES]; + GpsSample _gps_default_samples[GPS_RETENTION_SAMPLES]; + GpsSample* _gps_samples; + uint16_t _gps_capacity; + uint16_t _gps_allocated_capacity; + uint16_t _tv_next; + uint16_t _tv_count; + uint16_t _gps_next; + uint16_t _gps_count; + uint32_t _last_bucket; + bool _has_bucket; + + static const char* skipSpaces(const char* value) { + while (*value == ' ') value++; + return value; + } + + static bool readToken(const char*& cursor, char* token, size_t token_size) { + cursor = skipSpaces(cursor); + if (*cursor == 0) return false; + + size_t len = 0; + while (*cursor != 0 && *cursor != ' ') { + if (len + 1U >= token_size) { + while (*cursor != 0 && *cursor != ' ') cursor++; + token[0] = 0; + return true; + } + token[len++] = *cursor++; + } + token[len] = 0; + return true; + } + + static bool parseUnsigned(const char* token, unsigned& value) { + if (token == NULL || *token == 0) return false; + unsigned parsed = 0; + while (*token != 0) { + if (*token < '0' || *token > '9') return false; + if (parsed > 1000U) return false; + parsed = parsed * 10U + (unsigned)(*token++ - '0'); + } + value = parsed; + return true; + } + + static void copyReply(char* reply, size_t reply_size, const char* text) { + if (reply_size == 0) return; + snprintf(reply, reply_size, "%s", text); + } + + static bool formatUsage(Series series, char* reply, size_t reply_size) { + const char* command = series == SERIES_TEMPERATURE ? "telemetry.temp" + : series == SERIES_VOLTAGE ? "telemetry.volt" : "telemetry.gps"; + snprintf(reply, reply_size, "Err - use: get %s [page]", command); + return false; + } + + uint16_t gpsPhysicalCapacity() const { + return _gps_allocated_capacity != 0 + ? _gps_allocated_capacity : GPS_RETENTION_SAMPLES; + } + + static void reverseGpsSamples(GpsSample* samples, + uint16_t begin, uint16_t end) { + while (begin < end && begin < --end) { + const GpsSample saved = samples[begin]; + samples[begin++] = samples[end]; + samples[end] = saved; + } + } + + void normalizeGpsRing() { + if (_gps_count == 0) { + _gps_next = 0; + return; + } + + const uint16_t oldest = (uint16_t)( + (_gps_next + _gps_capacity - _gps_count) % _gps_capacity); + if (oldest != 0) { + reverseGpsSamples(_gps_samples, 0, oldest); + reverseGpsSamples(_gps_samples, oldest, _gps_capacity); + reverseGpsSamples(_gps_samples, 0, _gps_capacity); + } + _gps_next = _gps_count == _gps_capacity ? 0 : _gps_count; + } + + void resizeGpsLogicalCapacity(uint16_t new_capacity) { + const uint16_t keep_count = _gps_count < new_capacity + ? _gps_count : new_capacity; + if (keep_count < _gps_count) { + memmove(_gps_samples, &_gps_samples[_gps_count - keep_count], + (size_t)keep_count * sizeof(GpsSample)); + } + _gps_capacity = new_capacity; + _gps_count = keep_count; + _gps_next = keep_count == new_capacity ? 0 : keep_count; + } + + void resizeGpsToDefaultStorage(uint8_t days) { + const uint16_t new_capacity = (uint16_t)(days * SAMPLES_PER_DAY); + if (_gps_allocated_capacity == 0) { + resizeGpsLogicalCapacity(new_capacity); + return; + } + + const uint16_t keep_count = _gps_count < new_capacity + ? _gps_count : new_capacity; + memset(_gps_default_samples, 0, sizeof(_gps_default_samples)); + memcpy(_gps_default_samples, &_gps_samples[_gps_count - keep_count], + (size_t)keep_count * sizeof(GpsSample)); + free(_gps_samples); + _gps_samples = _gps_default_samples; + _gps_allocated_capacity = 0; + _gps_capacity = new_capacity; + _gps_count = keep_count; + _gps_next = keep_count == new_capacity ? 0 : keep_count; + } + + void shrinkGpsAllocation() { + if (_gps_allocated_capacity == 0 + || _gps_allocated_capacity == _gps_capacity) return; + GpsSample* resized = static_cast( + realloc(_gps_samples, (size_t)_gps_capacity * sizeof(GpsSample))); + if (resized != NULL) { + _gps_samples = resized; + _gps_allocated_capacity = _gps_capacity; + } + } + + bool expandGpsStorage(uint16_t new_capacity) { + GpsSample* resized; + if (_gps_allocated_capacity == 0) { + resized = static_cast( + malloc((size_t)new_capacity * sizeof(GpsSample))); + if (resized == NULL) return false; + memset(resized, 0, (size_t)new_capacity * sizeof(GpsSample)); + memcpy(resized, _gps_samples, (size_t)_gps_count * sizeof(GpsSample)); + } else { + resized = static_cast( + realloc(_gps_samples, (size_t)new_capacity * sizeof(GpsSample))); + if (resized == NULL) return false; + } + + _gps_samples = resized; + _gps_capacity = new_capacity; + _gps_allocated_capacity = new_capacity; + _gps_next = _gps_count == new_capacity ? 0 : _gps_count; + return true; + } + + void setTemperatureStatus(uint16_t index, uint8_t status) { + const uint8_t shift = (uint8_t)((index & 3U) * 2U); + const uint8_t mask = (uint8_t)(0x03U << shift); + _temperature_status[index / 4U] = (uint8_t)( + (_temperature_status[index / 4U] & (uint8_t)~mask) + | ((status & 0x03U) << shift)); + } + + uint8_t temperatureStatus(uint16_t index) const { + const uint8_t shift = (uint8_t)((index & 3U) * 2U); + return (uint8_t)((_temperature_status[index / 4U] >> shift) & 0x03U); + } + + void append(uint8_t temperature, uint8_t temperature_status, uint8_t voltage, + int32_t gps_lat_e7, + int32_t gps_lon_e7, bool gps_valid) { + _temperature[_tv_next] = temperature; + setTemperatureStatus(_tv_next, temperature_status); + _voltage[_tv_next] = voltage; + _tv_next = (uint16_t)((_tv_next + 1U) % TV_RETENTION_SAMPLES); + if (_tv_count < TV_RETENTION_SAMPLES) _tv_count++; + + _gps_samples[_gps_next].latitude_e7 = gps_valid ? gps_lat_e7 : 0; + _gps_samples[_gps_next].longitude_e7 = gps_valid ? gps_lon_e7 : 0; + _gps_next = (uint16_t)((_gps_next + 1U) % _gps_capacity); + if (_gps_count < _gps_capacity) _gps_count++; + } + + bool tvAtOffset(uint16_t offset, uint8_t& temperature, + uint8_t& temperature_status, uint8_t& voltage) const { + if (offset >= _tv_count) { + temperature = 0; + temperature_status = TEMPERATURE_NONE; + voltage = 0; + return false; + } + const uint16_t index = (uint16_t)((_tv_next + TV_RETENTION_SAMPLES - 1U - offset) + % TV_RETENTION_SAMPLES); + temperature = _temperature[index]; + temperature_status = temperatureStatus(index); + voltage = _voltage[index]; + return true; + } + + bool gpsAtOffset(uint16_t offset, int32_t& latitude_e7, int32_t& longitude_e7) const { + if (offset >= _gps_count) { + latitude_e7 = 0; + longitude_e7 = 0; + return false; + } + const uint16_t index = (uint16_t)((_gps_next + _gps_capacity - 1U - offset) + % _gps_capacity); + latitude_e7 = _gps_samples[index].latitude_e7; + longitude_e7 = _gps_samples[index].longitude_e7; + return latitude_e7 != 0 || longitude_e7 != 0; + } + + static void putUint32LE(uint8_t* dest, uint32_t value) { + dest[0] = (uint8_t)value; + dest[1] = (uint8_t)(value >> 8); + dest[2] = (uint8_t)(value >> 16); + dest[3] = (uint8_t)(value >> 24); + } + + static void putInt32LE(uint8_t* dest, int32_t value) { + putUint32LE(dest, (uint32_t)value); + } + + static size_t base64Encode(const uint8_t* source, size_t source_len, + char* dest, size_t dest_size) { + static const char alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const size_t required = 4U * ((source_len + 2U) / 3U); + if (dest_size <= required) return 0; + + size_t in = 0; + size_t out = 0; + while (in < source_len) { + const size_t remaining = source_len - in; + const uint32_t a = source[in++]; + const uint32_t b = remaining > 1U ? source[in++] : 0U; + const uint32_t c = remaining > 2U ? source[in++] : 0U; + const uint32_t value = (a << 16) | (b << 8) | c; + + dest[out++] = alphabet[(value >> 18) & 0x3FU]; + dest[out++] = alphabet[(value >> 12) & 0x3FU]; + dest[out++] = remaining > 1U ? alphabet[(value >> 6) & 0x3FU] : '='; + dest[out++] = remaining > 2U ? alphabet[value & 0x3FU] : '='; + } + dest[out] = 0; + return out; + } + + static bool formatBase64Reply(const uint8_t* payload, size_t payload_len, + char* reply, size_t reply_size) { + if (reply_size < 3U) return false; + reply[0] = '>'; + reply[1] = ' '; + if (base64Encode(payload, payload_len, &reply[2], reply_size - 2U) == 0) { + copyReply(reply, reply_size, "Err - telemetry reply buffer too small"); + return false; + } + return true; + } + + uint32_t startEpochForOffset(uint16_t oldest_offset) const { + const uint32_t start_bucket = _last_bucket >= oldest_offset + ? _last_bucket - oldest_offset : 0; + return start_bucket * SAMPLE_INTERVAL_SECONDS; + } + + bool formatTemperaturePage(uint8_t page, char* reply, size_t reply_size) const { + static constexpr size_t HEADER_SIZE = 7; + static constexpr size_t STATUS_SIZE = (SAMPLES_PER_DAY * 2U) / 8U; + static constexpr size_t DATA_SIZE = (SAMPLES_PER_DAY * 7U) / 8U; + uint8_t payload[HEADER_SIZE + STATUS_SIZE + DATA_SIZE]; + memset(payload, 0, sizeof(payload)); + + const uint16_t oldest_offset = (uint16_t)(page * SAMPLES_PER_DAY - 1U); + payload[0] = TEMPERATURE_PAYLOAD_TYPE_V1; + putUint32LE(&payload[1], startEpochForOffset(oldest_offset)); + payload[5] = 30; + payload[6] = SAMPLES_PER_DAY; + + BitWriter status_writer(&payload[HEADER_SIZE], STATUS_SIZE); + BitWriter writer(&payload[HEADER_SIZE + STATUS_SIZE], DATA_SIZE); + for (uint8_t slot = 0; slot < SAMPLES_PER_DAY; slot++) { + uint8_t temperature = 0; + uint8_t temperature_status = TEMPERATURE_NONE; + uint8_t voltage = 0; + tvAtOffset((uint16_t)(oldest_offset - slot), temperature, + temperature_status, voltage); + status_writer.write(temperature_status, 2); + writer.write(temperature, 7); + } + return formatBase64Reply(payload, sizeof(payload), reply, reply_size); + } + + bool formatVoltagePage(uint8_t page, char* reply, size_t reply_size) const { + static constexpr size_t HEADER_SIZE = 7; + uint8_t payload[HEADER_SIZE + SAMPLES_PER_DAY]; + memset(payload, 0, sizeof(payload)); + + const uint16_t oldest_offset = (uint16_t)(page * SAMPLES_PER_DAY - 1U); + payload[0] = VOLTAGE_PAYLOAD_TYPE_V1; + putUint32LE(&payload[1], startEpochForOffset(oldest_offset)); + payload[5] = 30; + payload[6] = SAMPLES_PER_DAY; + + for (uint8_t slot = 0; slot < SAMPLES_PER_DAY; slot++) { + uint8_t temperature = 0; + uint8_t temperature_status = TEMPERATURE_NONE; + uint8_t voltage = 0; + tvAtOffset((uint16_t)(oldest_offset - slot), temperature, + temperature_status, voltage); + payload[HEADER_SIZE + slot] = voltage; + } + return formatBase64Reply(payload, sizeof(payload), reply, reply_size); + } + + static int clampGpsDelta(long value, bool& clipped) { + if (value < -8192L) { + clipped = true; + return -8192; + } + if (value > 8191L) { + clipped = true; + return 8191; + } + return (int)value; + } + + bool formatGpsPage(uint8_t page, char* reply, size_t reply_size) const { + static constexpr size_t HEADER_SIZE = 17; + static constexpr size_t DATA_SIZE = (GPS_SAMPLES_PER_PAGE * 28U) / 8U; + static constexpr double METERS_PER_DEGREE = 111320.0; + static constexpr double DEGREES_TO_RADIANS = 0.017453292519943295; + uint8_t payload[HEADER_SIZE + DATA_SIZE]; + memset(payload, 0, sizeof(payload)); + + const uint16_t newest_offset = (uint16_t)((page - 1U) * GPS_SAMPLES_PER_PAGE); + const uint16_t oldest_offset = (uint16_t)(newest_offset + + GPS_SAMPLES_PER_PAGE - 1U); + + payload[0] = GPS_PAYLOAD_TYPE_V1; + putUint32LE(&payload[1], startEpochForOffset(oldest_offset)); + payload[5] = 30; + payload[6] = GPS_SAMPLES_PER_PAGE; + payload[15] = 0xFF; // origin slot; 0xFF means no GPS fix on this page + + int32_t origin_lat_e7 = 0; + int32_t origin_lon_e7 = 0; + for (uint8_t slot = 0; slot < GPS_SAMPLES_PER_PAGE; slot++) { + int32_t latitude_e7; + int32_t longitude_e7; + if (gpsAtOffset((uint16_t)(oldest_offset - slot), latitude_e7, longitude_e7)) { + origin_lat_e7 = latitude_e7; + origin_lon_e7 = longitude_e7; + payload[15] = slot; + break; + } + } + putInt32LE(&payload[7], origin_lat_e7); + putInt32LE(&payload[11], origin_lon_e7); + + double reference_lat = (double)origin_lat_e7 / 10000000.0; + double reference_lon = (double)origin_lon_e7 / 10000000.0; + bool clipped = false; + BitWriter writer(&payload[HEADER_SIZE], DATA_SIZE); + for (uint8_t slot = 0; slot < GPS_SAMPLES_PER_PAGE; slot++) { + int north_units = 0; + int east_units = 0; + int32_t latitude_e7; + int32_t longitude_e7; + const bool valid = payload[15] != 0xFF && slot >= payload[15] + && gpsAtOffset((uint16_t)(oldest_offset - slot), latitude_e7, longitude_e7); + + if (valid && slot != payload[15]) { + const double latitude = (double)latitude_e7 / 10000000.0; + const double longitude = (double)longitude_e7 / 10000000.0; + const double mean_latitude = (latitude + reference_lat) * 0.5; + const double north_m = (latitude - reference_lat) * METERS_PER_DEGREE; + const double east_m = (longitude - reference_lon) * METERS_PER_DEGREE + * cos(mean_latitude * DEGREES_TO_RADIANS); + north_units = clampGpsDelta(lround(north_m / 10.0), clipped); + east_units = clampGpsDelta(lround(east_m / 10.0), clipped); + + reference_lat += (double)north_units * 10.0 / METERS_PER_DEGREE; + const double longitude_scale = METERS_PER_DEGREE + * cos(reference_lat * DEGREES_TO_RADIANS); + if (fabs(longitude_scale) > 0.001) { + reference_lon += (double)east_units * 10.0 / longitude_scale; + } + } + + writer.write((uint16_t)north_units & 0x3FFFU, 14); + writer.write((uint16_t)east_units & 0x3FFFU, 14); + } + if (clipped) payload[16] |= 0x01; + return formatBase64Reply(payload, sizeof(payload), reply, reply_size); + } +}; + +} // namespace mesh diff --git a/test/README.md b/test/README.md index bcbaba84..c8eaa035 100644 --- a/test/README.md +++ b/test/README.md @@ -37,6 +37,7 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation | | `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation | | `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads | +| `test_telemetry_history` | `src/helpers/TelemetryHistory.h` | 30-minute rings; seven-day temperature/voltage and dynamically sized GPS retention; exact 1 C temperature/status encoding; separate Base64 series payloads; 14-bit GPS differentials; resize preservation, heap budgets, and 1-based paging bounds | | `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered 3-byte and 2-byte-prefix path matching; match thresholds; repeated path-entry semantics; blacklist and bridge-bucket channel-scope selection; `require=region` and per-channel scope-gate truth tables; fast/slow scope timing; adding, replacing, and preserving packet scope | | `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | diff --git a/test/test_telemetry_history/test_telemetry_history.cpp b/test/test_telemetry_history/test_telemetry_history.cpp new file mode 100644 index 00000000..f6fd8282 --- /dev/null +++ b/test/test_telemetry_history/test_telemetry_history.cpp @@ -0,0 +1,344 @@ +#include + +#include +#include +#include +#include +#include + +#include + +namespace { + +int base64Value(char value) { + if (value >= 'A' && value <= 'Z') return value - 'A'; + if (value >= 'a' && value <= 'z') return value - 'a' + 26; + if (value >= '0' && value <= '9') return value - '0' + 52; + if (value == '+') return 62; + if (value == '/') return 63; + return -1; +} + +std::vector decodeReply(const char* reply) { + EXPECT_EQ('>', reply[0]); + EXPECT_EQ(' ', reply[1]); + const char* encoded = reply + 2; + std::vector decoded; + uint32_t accumulator = 0; + unsigned bits = 0; + for (const char* cursor = encoded; *cursor != 0 && *cursor != '='; cursor++) { + const int value = base64Value(*cursor); + EXPECT_GE(value, 0); + if (value < 0) break; + accumulator = (accumulator << 6) | (uint32_t)value; + bits += 6; + if (bits >= 8) { + bits -= 8; + decoded.push_back((uint8_t)(accumulator >> bits)); + accumulator &= bits == 0 ? 0U : ((1U << bits) - 1U); + } + } + return decoded; +} + +uint32_t uint32LE(const uint8_t* source) { + return (uint32_t)source[0] + | ((uint32_t)source[1] << 8) + | ((uint32_t)source[2] << 16) + | ((uint32_t)source[3] << 24); +} + +int32_t int32LE(const uint8_t* source) { + return (int32_t)uint32LE(source); +} + +uint32_t readBits(const uint8_t* source, size_t& bit_offset, uint8_t width) { + uint32_t value = 0; + for (uint8_t i = 0; i < width; i++) { + value = (value << 1) + | ((source[bit_offset / 8U] >> (7U - bit_offset % 8U)) & 1U); + bit_offset++; + } + return value; +} + +int signed14(uint32_t value) { + return (value & 0x2000U) != 0 ? (int)value - 0x4000 : (int)value; +} + +} // namespace + +TEST(TelemetryHistory, EncodesTemperatureSentinelsAndEndpoints) { + using mesh::TelemetryHistory; + uint8_t status = 0xFF; + + EXPECT_EQ(0, TelemetryHistory::encodeTemperature(0, false, status)); + EXPECT_EQ(TelemetryHistory::TEMPERATURE_NONE, status); + EXPECT_EQ(0, TelemetryHistory::encodeTemperature(-51, true, status)); + EXPECT_EQ(TelemetryHistory::TEMPERATURE_LOW, status); + EXPECT_EQ(0, TelemetryHistory::encodeTemperature(-50, true, status)); + EXPECT_EQ(TelemetryHistory::TEMPERATURE_VALUE, status); + EXPECT_EQ(50, TelemetryHistory::encodeTemperature(0, true, status)); + EXPECT_EQ(TelemetryHistory::TEMPERATURE_VALUE, status); + EXPECT_EQ(127, TelemetryHistory::encodeTemperature(77, true, status)); + EXPECT_EQ(TelemetryHistory::TEMPERATURE_VALUE, status); + EXPECT_EQ(127, TelemetryHistory::encodeTemperature(78, true, status)); + EXPECT_EQ(TelemetryHistory::TEMPERATURE_HIGH, status); +} + +TEST(TelemetryHistory, EncodesVoltageSentinelsAndHundredths) { + using mesh::TelemetryHistory; + + EXPECT_EQ(0, TelemetryHistory::encodeVoltage(0)); + EXPECT_EQ(1, TelemetryHistory::encodeVoltage(1879)); + EXPECT_EQ(2, TelemetryHistory::encodeVoltage(1880)); + EXPECT_EQ(3, TelemetryHistory::encodeVoltage(1890)); + EXPECT_EQ(254, TelemetryHistory::encodeVoltage(4400)); + EXPECT_EQ(255, TelemetryHistory::encodeVoltage(4401)); +} + +TEST(TelemetryHistory, FormatsRollingDayAndExplicitMissingBuckets) { + using mesh::TelemetryHistory; + TelemetryHistory history; + const uint32_t first_bucket = 1000000U; + const uint32_t first_epoch = first_bucket * TelemetryHistory::SAMPLE_INTERVAL_SECONDS; + + history.record(first_epoch, -51, true, 1880, 0, 0, false); + history.record(first_epoch + 2U * TelemetryHistory::SAMPLE_INTERVAL_SECONDS, + 78, true, 4400, 0, 0, false); + + char reply[160]; + ASSERT_TRUE(history.formatPageReply(TelemetryHistory::SERIES_TEMPERATURE, + "", reply, sizeof(reply))); + EXPECT_EQ(86U, strlen(reply)); + const std::vector temperature_payload = decodeReply(reply); + ASSERT_EQ(61U, temperature_payload.size()); + EXPECT_EQ(TelemetryHistory::TEMPERATURE_PAYLOAD_TYPE_V1, + temperature_payload[0]); + EXPECT_EQ((first_bucket + 2U - 47U) * TelemetryHistory::SAMPLE_INTERVAL_SECONDS, + uint32LE(&temperature_payload[1])); + EXPECT_EQ(30, temperature_payload[5]); + EXPECT_EQ(48, temperature_payload[6]); + + size_t status_offset = 0; + size_t bit_offset = 0; + for (int slot = 0; slot < 48; slot++) { + const uint8_t status = (uint8_t)readBits(&temperature_payload[7], + status_offset, 2); + const uint8_t temperature = (uint8_t)readBits(&temperature_payload[19], + bit_offset, 7); + if (slot == 45) { + EXPECT_EQ(TelemetryHistory::TEMPERATURE_LOW, status); + EXPECT_EQ(0, temperature); + } else if (slot == 47) { + EXPECT_EQ(TelemetryHistory::TEMPERATURE_HIGH, status); + EXPECT_EQ(127, temperature); + } else { + EXPECT_EQ(TelemetryHistory::TEMPERATURE_NONE, status); + EXPECT_EQ(0, temperature); + } + } + + ASSERT_TRUE(history.formatPageReply(TelemetryHistory::SERIES_VOLTAGE, + "1", reply, sizeof(reply))); + EXPECT_EQ(78U, strlen(reply)); + const std::vector voltage_payload = decodeReply(reply); + ASSERT_EQ(55U, voltage_payload.size()); + EXPECT_EQ(TelemetryHistory::VOLTAGE_PAYLOAD_TYPE_V1, voltage_payload[0]); + EXPECT_EQ((first_bucket + 2U - 47U) * TelemetryHistory::SAMPLE_INTERVAL_SECONDS, + uint32LE(&voltage_payload[1])); + for (int slot = 0; slot < 48; slot++) { + if (slot == 45) { + EXPECT_EQ(2, voltage_payload[7 + slot]); + } else if (slot == 47) { + EXPECT_EQ(254, voltage_payload[7 + slot]); + } else { + EXPECT_EQ(0, voltage_payload[7 + slot]); + } + } +} + +TEST(TelemetryHistory, RetainsExactlySevenTemperatureVoltageDays) { + using mesh::TelemetryHistory; + TelemetryHistory history; + const uint32_t first_bucket = 1000U; + for (uint32_t sample = 0; sample <= TelemetryHistory::TV_RETENTION_SAMPLES; sample++) { + history.record((first_bucket + sample) * TelemetryHistory::SAMPLE_INTERVAL_SECONDS, + 0, true, (uint16_t)(1880U + (sample % 253U) * 10U), + 0, 0, false); + } + + char reply[160]; + ASSERT_TRUE(history.formatPageReply(TelemetryHistory::SERIES_TEMPERATURE, + "7", reply, sizeof(reply))); + const std::vector payload = decodeReply(reply); + ASSERT_EQ(61U, payload.size()); + EXPECT_EQ((first_bucket + 1U) * TelemetryHistory::SAMPLE_INTERVAL_SECONDS, + uint32LE(&payload[1])); + + size_t status_offset = 0; + EXPECT_EQ(TelemetryHistory::TEMPERATURE_VALUE, + readBits(&payload[7], status_offset, 2)); + size_t bit_offset = 0; + EXPECT_EQ(50U, readBits(&payload[19], bit_offset, 7)); + + ASSERT_TRUE(history.formatPageReply(TelemetryHistory::SERIES_VOLTAGE, + "7", reply, sizeof(reply))); + const std::vector voltage_payload = decodeReply(reply); + ASSERT_EQ(55U, voltage_payload.size()); + EXPECT_EQ((first_bucket + 1U) * TelemetryHistory::SAMPLE_INTERVAL_SECONDS, + uint32LE(&voltage_payload[1])); + EXPECT_EQ(3U, voltage_payload[7]); +} + +TEST(TelemetryHistory, EmitsZeroGpsOriginAndDeltasWithoutFixes) { + using mesh::TelemetryHistory; + TelemetryHistory history; + const uint32_t epoch = 1000000U * TelemetryHistory::SAMPLE_INTERVAL_SECONDS; + history.record(epoch, 20, true, 3700, 0, 0, false); + + char reply[160]; + ASSERT_TRUE(history.formatPageReply(TelemetryHistory::SERIES_GPS, + "1", reply, sizeof(reply))); + EXPECT_EQ(138U, strlen(reply)); + const std::vector payload = decodeReply(reply); + ASSERT_EQ(101U, payload.size()); + EXPECT_EQ(TelemetryHistory::GPS_PAYLOAD_TYPE_V1, payload[0]); + EXPECT_EQ(0, int32LE(&payload[7])); + EXPECT_EQ(0, int32LE(&payload[11])); + EXPECT_EQ(0xFF, payload[15]); + for (size_t i = 17; i < payload.size(); i++) EXPECT_EQ(0, payload[i]); +} + +TEST(TelemetryHistory, EncodesGpsAsTenMeterFourteenBitDeltas) { + using mesh::TelemetryHistory; + TelemetryHistory history; + const uint32_t first_bucket = 1000000U; + const uint32_t interval = TelemetryHistory::SAMPLE_INTERVAL_SECONDS; + const int32_t latitude = 470000000; + const int32_t longitude = -1220000000; + + history.record(first_bucket * interval, 20, true, 3700, + latitude, longitude, true); + history.record((first_bucket + 1U) * interval, 20, true, 3700, + latitude + 8983, longitude, true); // about 100 m north + history.record((first_bucket + 2U) * interval, 20, true, 3700, + latitude + 8983, longitude + 13160, true); // about 100 m east + + char reply[160]; + ASSERT_TRUE(history.formatPageReply(TelemetryHistory::SERIES_GPS, + "1", reply, sizeof(reply))); + const std::vector payload = decodeReply(reply); + ASSERT_EQ(101U, payload.size()); + EXPECT_EQ(latitude, int32LE(&payload[7])); + EXPECT_EQ(longitude, int32LE(&payload[11])); + EXPECT_EQ(21, payload[15]); + EXPECT_EQ(0, payload[16]); + + size_t bit_offset = 0; + for (int slot = 0; slot < 24; slot++) { + const int north = signed14(readBits(&payload[17], bit_offset, 14)); + const int east = signed14(readBits(&payload[17], bit_offset, 14)); + if (slot == 22) { + EXPECT_NEAR(10, north, 1); + EXPECT_NEAR(0, east, 1); + } else if (slot == 23) { + EXPECT_NEAR(0, north, 1); + EXPECT_NEAR(10, east, 1); + } else { + EXPECT_EQ(0, north); + EXPECT_EQ(0, east); + } + } +} + +TEST(TelemetryHistory, ExpandsGpsRetentionWithinHeapBudget) { + using mesh::TelemetryHistory; + TelemetryHistory history; + + EXPECT_EQ(3, history.gpsRetentionDays()); + EXPECT_EQ(6, history.gpsPageCount()); + EXPECT_EQ(5, history.resizeGpsDays( + 30, 5U * TelemetryHistory::GPS_HEAP_BYTES_PER_DAY)); + EXPECT_EQ(10, history.gpsPageCount()); + EXPECT_EQ(4, history.resizeGpsDays(4, 0)); + EXPECT_EQ(8, history.gpsPageCount()); + EXPECT_EQ(3, history.resizeGpsDays(3, 0)); + EXPECT_EQ(6, history.gpsPageCount()); + + TelemetryHistory maximum; + EXPECT_EQ(30, maximum.resizeGpsDays( + 30, 30U * TelemetryHistory::GPS_HEAP_BYTES_PER_DAY)); + EXPECT_EQ(60, maximum.gpsPageCount()); +} + +TEST(TelemetryHistory, PreservesNewestGpsSamplesAcrossResizes) { + using mesh::TelemetryHistory; + TelemetryHistory history; + const uint32_t first_bucket = 1000000U; + const uint32_t interval = TelemetryHistory::SAMPLE_INTERVAL_SECONDS; + const int32_t latitude = 470000000; + const int32_t longitude = -1220000000; + + for (uint32_t sample = 0; sample <= TelemetryHistory::GPS_RETENTION_SAMPLES; + sample++) { + history.record((first_bucket + sample) * interval, 20, true, 3700, + latitude + (int32_t)sample * 1000, + longitude, true); + } + ASSERT_EQ(7, history.resizeGpsDays( + 7, 7U * TelemetryHistory::GPS_HEAP_BYTES_PER_DAY)); + for (uint32_t sample = TelemetryHistory::GPS_RETENTION_SAMPLES + 1U; + sample <= 7U * TelemetryHistory::SAMPLES_PER_DAY; sample++) { + history.record((first_bucket + sample) * interval, 20, true, 3700, + latitude + (int32_t)sample * 1000, + longitude, true); + } + + char reply[160]; + ASSERT_TRUE(history.formatPageReply(TelemetryHistory::SERIES_GPS, + "14", reply, sizeof(reply))); + std::vector payload = decodeReply(reply); + ASSERT_EQ(101U, payload.size()); + EXPECT_EQ((first_bucket + 1U) * interval, uint32LE(&payload[1])); + EXPECT_EQ(latitude + 1000, int32LE(&payload[7])); + EXPECT_FALSE(history.formatPageReply(TelemetryHistory::SERIES_GPS, + "15", reply, sizeof(reply))); + EXPECT_STREQ("Err - telemetry.gps page must be 1-14", reply); + + ASSERT_EQ(2, history.resizeGpsDays(2, 0)); + ASSERT_TRUE(history.formatPageReply(TelemetryHistory::SERIES_GPS, + "4", reply, sizeof(reply))); + payload = decodeReply(reply); + ASSERT_EQ(101U, payload.size()); + EXPECT_EQ((first_bucket + 241U) * interval, uint32LE(&payload[1])); + EXPECT_EQ(latitude + 241000, int32LE(&payload[7])); +} + +TEST(TelemetryHistory, ValidatesRetentionAndPagingArguments) { + mesh::TelemetryHistory history; + history.record(1800000000U, 20, true, 3700, 0, 0, false); + char reply[160]; + + EXPECT_TRUE(history.formatPageReply(mesh::TelemetryHistory::SERIES_TEMPERATURE, + "7", reply, sizeof(reply))); + EXPECT_FALSE(history.formatPageReply(mesh::TelemetryHistory::SERIES_TEMPERATURE, + "8", reply, sizeof(reply))); + EXPECT_STREQ("Err - telemetry.temp page must be 1-7", reply); + EXPECT_FALSE(history.formatPageReply(mesh::TelemetryHistory::SERIES_VOLTAGE, + "0", reply, sizeof(reply))); + EXPECT_STREQ("Err - telemetry.volt page must be 1-7", reply); + EXPECT_TRUE(history.formatPageReply(mesh::TelemetryHistory::SERIES_GPS, + "6", reply, sizeof(reply))); + EXPECT_FALSE(history.formatPageReply(mesh::TelemetryHistory::SERIES_GPS, + "7", reply, sizeof(reply))); + EXPECT_STREQ("Err - telemetry.gps page must be 1-6", reply); + EXPECT_FALSE(history.formatPageReply(mesh::TelemetryHistory::SERIES_GPS, + "1 extra", reply, sizeof(reply))); + EXPECT_NE(nullptr, strstr(reply, "get telemetry.gps")); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}