mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-08-28 17:58:21 +00:00
Add configurable repeater telemetry history
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user