Add configurable repeater telemetry history

This commit is contained in:
mikecarper
2026-08-04 15:35:15 -07:00
parent bb348ac83c
commit 3ba0fb1847
8 changed files with 1334 additions and 0 deletions
+2
View File
@@ -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 |
+95
View File
@@ -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 <days>`
**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
+1
View File
@@ -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` |
+184
View File
@@ -8,6 +8,10 @@
#ifdef WITH_WEBCONFIG
#include <WiFi.h>
#endif
#if MESH_ENABLE_TELEMETRY_HISTORY && defined(STM32_PLATFORM)
#include <sys/types.h>
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();
+19
View File
@@ -9,6 +9,15 @@
#include <CayenneLPP.h>
#include <target.h>
#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 <helpers/SimpleMeshTables.h>
#include <helpers/StaticPoolPacketManager.h>
#include <helpers/StatsFormatHelper.h>
#if MESH_ENABLE_TELEMETRY_HISTORY
#include <helpers/TelemetryHistory.h>
#endif
#include <helpers/TxtDataHelpers.h>
#include <helpers/RegionMap.h>
#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);
+688
View File
@@ -0,0 +1,688 @@
#pragma once
#include <math.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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<GpsSample*>(
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<GpsSample*>(
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<GpsSample*>(
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
+1
View File
@@ -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) |
@@ -0,0 +1,344 @@
#include <gtest/gtest.h>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include <helpers/TelemetryHistory.h>
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<uint8_t> decodeReply(const char* reply) {
EXPECT_EQ('>', reply[0]);
EXPECT_EQ(' ', reply[1]);
const char* encoded = reply + 2;
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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();
}