Add scheduled telemetry history packets

This commit is contained in:
mikecarper
2026-08-13 00:52:17 -07:00
parent c0aeb8fc3a
commit 8ffafddd6a
7 changed files with 588 additions and 7 deletions
+2 -2
View File
@@ -69,7 +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 |
| Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; 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 |
@@ -225,7 +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 |
| Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; 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 |
+56 -4
View File
@@ -306,10 +306,11 @@ 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.
and battery voltage retain 336 samples (seven rolling days). GPS-capable builds
retain three GPS days by default and request a seven-day default at startup.
Builds without a GPS provider omit the GPS history commands to conserve flash.
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.
@@ -319,6 +320,10 @@ The feature is omitted from flash-constrained STM32 repeater images.
- `get telemetry.volt [page]`
- `get telemetry.gps [page]`
- `set telemetry.gps <days>`
- `get telemetry.tx`
- `set telemetry.tx <off|direct|path>`
- `set telemetry.tx schedule <off|1-30d>`
- `send telemetry.tx now`
**Parameters:**
@@ -331,6 +336,12 @@ The feature is omitted from flash-constrained STM32 repeater images.
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`.
- `direct`: Send the binary temperature and voltage snapshots zero-hop to a
neighboring MQTT observer.
- `path`: A comma-separated direct route using the same one-, two-, or
three-byte hop hashes accepted by `set outpath`.
- `schedule`: Automatic interval in whole days. The default is `2d`; `off`
retains the configured direct path for manual test sends.
Local serial and remote administrator CLI sessions can read the history.
Collection uses the MCU temperature, battery voltage, and an already-valid
@@ -345,6 +356,47 @@ Use the browser-based [Telemetry history decoder](telemetry_decoder.md) to
turn a reply into a timestamped table or downloadable CSV without uploading
the data.
`telemetry.tx` is disabled by default. Its schedule and direct path are stored
across reboots. Configuring `direct` or a routed path enables the default `2d`
schedule; `set telemetry.tx schedule` changes it from one through 30 days or
turns it off. An automatic run waits until 165 half-hour positions are
available, then sends one maximum-size temperature packet and one maximum-size
voltage packet. GPS is never included in this raw transmission. The history
remains boot-local, so the first scheduled pair after a reboot needs about
82.5 hours to fill. On the default two-day interval, each 82.5-hour packet
overlaps the previous packet by 34.5 hours. A queue failure is retried after
30 minutes only for the packet that did not queue; successfully queued packets
are not duplicated.
`send telemetry.tx now` is an administrator-only test action. It immediately
queues one temperature and one voltage direct packet over the configured path,
using all currently available positions up to 165. It works while the schedule
is off and does not move the next scheduled send time. RAW_CUSTOM direct
packets do not enter the normal encrypted direct-message retry mechanism.
Full snapshots are 184-byte RAW_CUSTOM payloads. Manual tests can be shorter
while history fills. Both formats are binary, not Base64 and not encrypted:
| Bytes | Meaning |
|---|---|
| `0`-`3` | ASCII magic `TTB1` for temperature or `TVB1` for voltage |
| `4`-`11` | First eight bytes of the repeater public key |
| `12`-`15` | First sample UTC epoch, unsigned 32-bit little-endian |
| `16`-`17` | Sample interval in minutes, unsigned 16-bit little-endian (`30`) |
| `18` | Sample count (`1`-`165`) |
| `19` onward | Oldest-to-newest encoded samples for the selected series |
Temperature codes reserve `0` for no reading, `1` for below `-50 C`, and `2`
for above `+77 C`. Codes `3` through `130` represent exact whole degrees from
`-50 C` through `+77 C`; decode them as `code - 53`. Voltage codes use the
same encoding as the paged voltage payload documented below.
Match bytes `4`-`11` to the first 16 hex characters of the repeater public key
shown by its advert or `get public.key`. This compact identifier is useful for
association but is not authenticated and can be spoofed. An MQTT observer can
upload the received raw packet to LetsMesh Analyzer `/packets`; its packet hex
contains the direct-route header and path followed by this payload.
Temperature payload (`0x11`, 61 bytes):
| Bytes | Meaning |
+1 -1
View File
@@ -75,7 +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` |
| `telemetry.temp`, `telemetry.volt`, `telemetry.gps` | Records 30-minute MCU temperature and battery samples for seven days. GPS-capable builds retain GPS separately for authenticated paging. `telemetry.tx` sends one temperature RAW_CUSTOM packet and one voltage packet over a configured direct path, on a persistent 1-30 day schedule (default two days) or immediately for testing; GPS is never included. Scheduled packets contain 165 points. History resets on reboot. | `get telemetry.temp [page]`, `get telemetry.volt [page]`, `get telemetry.gps [page]`, `set telemetry.gps <1-30>`, `get telemetry.tx`, `set telemetry.tx <off|direct|path>`, `set telemetry.tx schedule <off|1-30d>`, `send telemetry.tx now` | `set telemetry.tx A1B2,C3D4` |
| `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` |
+335
View File
@@ -157,6 +157,13 @@ static const char FLOOD_CHANNEL_SCOPE_USAGE[] =
#define RX_INACTIVITY_WATCHDOG_INTERVAL (12UL * 60UL * 60UL * 1000UL)
#if MESH_ENABLE_TELEMETRY_HISTORY
#define TELEMETRY_GPS_HEAP_RESERVE_BYTES 2048U
#define TELEMETRY_HISTORY_TX_PREFS_FILE "/telemetry_tx"
static const uint64_t TELEMETRY_HISTORY_TX_RETRY_MILLIS =
30ULL * 60ULL * 1000ULL;
static const uint8_t TELEMETRY_HISTORY_TX_DEFAULT_DAYS = 2U;
static const uint8_t TELEMETRY_HISTORY_TX_MAX_DAYS = 30U;
static const uint8_t TELEMETRY_HISTORY_TX_TEMPERATURE = 1U;
static const uint8_t TELEMETRY_HISTORY_TX_VOLTAGE = 2U;
#endif
#define CLOCK_SYNC_VALID_YEARS 10
@@ -2952,6 +2959,15 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
clock_sync_internet_requested_millis = 0;
clock_sync_next_attempt_uptime = CLOCK_SYNC_STARTUP_DELAY_MILLIS;
#if MESH_ENABLE_TELEMETRY_HISTORY
telemetry_history_tx_enabled = false;
memset(telemetry_history_tx_path, 0, sizeof(telemetry_history_tx_path));
telemetry_history_tx_path_len = OUT_PATH_UNKNOWN;
telemetry_history_tx_interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS;
telemetry_history_tx_pending = 0;
telemetry_history_next_tx_uptime = 0;
#endif
#if MAX_NEIGHBOURS
memset(neighbours, 0, sizeof(neighbours));
#endif
@@ -3063,6 +3079,9 @@ void MyMesh::begin(FILESYSTEM *fs) {
_fs = fs;
// load persisted prefs
_cli.loadPrefs(_fs);
#if MESH_ENABLE_TELEMETRY_HISTORY
loadTelemetryHistoryTxPrefs();
#endif
#ifdef SIM_WIFI_SSID
// Emulator builds (Wokwi) boot with fresh NVS every run. Seed WiFi so the
@@ -10041,8 +10060,183 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
mesh::TelemetryHistory::SERIES_TEMPERATURE;
static const char telemetry_temp_command[] = "get telemetry.temp";
static const char telemetry_volt_command[] = "get telemetry.volt";
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
static const char telemetry_gps_command[] = "get telemetry.gps";
static const char telemetry_gps_set_command[] = "set telemetry.gps";
#endif
static const char telemetry_tx_get_command[] = "get telemetry.tx";
static const char telemetry_tx_set_command[] = "set telemetry.tx";
static const char telemetry_tx_schedule_command[] =
"set telemetry.tx schedule";
static const char telemetry_tx_send_now_command[] = "send telemetry.tx now";
if (strcmp(command, telemetry_tx_send_now_command) == 0) {
if (sender != NULL && !sender->isAdmin()) {
strcpy(reply, "Err - not permitted");
return;
}
if (telemetry_history_tx_path_len == OUT_PATH_UNKNOWN
|| telemetry_history_tx_path_len == OUT_PATH_FORCE_FLOOD
|| !mesh::Packet::isValidPathLen(telemetry_history_tx_path_len)) {
strcpy(reply, "Err - configure telemetry.tx direct or path first");
return;
}
if (telemetry_history.voltageSampleCount() == 0) {
strcpy(reply, "Err - telemetry history is empty");
return;
}
const bool temperature_queued = sendTelemetryHistorySnapshot(
mesh::TelemetryHistory::SERIES_TEMPERATURE);
const bool voltage_queued = sendTelemetryHistorySnapshot(
mesh::TelemetryHistory::SERIES_VOLTAGE);
if (temperature_queued && voltage_queued) {
const uint16_t available = telemetry_history.voltageSampleCount();
const unsigned sent = available < mesh::TelemetryHistory::BINARY_MAX_SAMPLES
? (unsigned)available
: (unsigned)mesh::TelemetryHistory::BINARY_MAX_SAMPLES;
snprintf(reply, 160, "OK - telemetry.tx queued temp=%u volt=%u",
sent, sent);
} else {
snprintf(reply, 160, "Err - telemetry.tx queue temp=%s volt=%s",
temperature_queued ? "yes" : "no",
voltage_queued ? "yes" : "no");
}
return;
}
if (strcmp(command, telemetry_tx_get_command) == 0) {
if (sender != NULL && !sender->isAdmin()) {
strcpy(reply, "Err - not permitted");
} else {
formatTelemetryHistoryTxStatus(reply, 160);
}
return;
}
if (strncmp(command, telemetry_tx_schedule_command,
sizeof(telemetry_tx_schedule_command) - 1U) == 0
&& (command[sizeof(telemetry_tx_schedule_command) - 1U] == 0
|| command[sizeof(telemetry_tx_schedule_command) - 1U] == ' ')) {
if (sender != NULL && !sender->isAdmin()) {
strcpy(reply, "Err - not permitted");
return;
}
char* spec = trimSpaces(
command + sizeof(telemetry_tx_schedule_command) - 1U);
bool enable = false;
unsigned long days = telemetry_history_tx_interval_days;
if (strcmp(spec, "off") == 0) {
enable = false;
} else {
char* end = NULL;
days = strtoul(spec, &end, 10);
if (end == spec) days = 0;
if (*end == 'd') end++;
end = trimSpaces(end);
if (*end != 0 || days < 1U
|| days > TELEMETRY_HISTORY_TX_MAX_DAYS) {
strcpy(reply, "Err - use: set telemetry.tx schedule <off|1-30d>");
return;
}
if (telemetry_history_tx_path_len == OUT_PATH_UNKNOWN
|| telemetry_history_tx_path_len == OUT_PATH_FORCE_FLOOD
|| !mesh::Packet::isValidPathLen(telemetry_history_tx_path_len)) {
strcpy(reply, "Err - configure telemetry.tx direct or path first");
return;
}
enable = true;
}
const bool previous_enabled = telemetry_history_tx_enabled;
const uint8_t previous_days = telemetry_history_tx_interval_days;
const uint8_t previous_pending = telemetry_history_tx_pending;
const uint64_t previous_next_tx = telemetry_history_next_tx_uptime;
telemetry_history_tx_enabled = enable;
if (enable) telemetry_history_tx_interval_days = (uint8_t)days;
telemetry_history_tx_pending = 0;
telemetry_history_next_tx_uptime = 0;
if (!saveTelemetryHistoryTxPrefs()) {
telemetry_history_tx_enabled = previous_enabled;
telemetry_history_tx_interval_days = previous_days;
telemetry_history_tx_pending = previous_pending;
telemetry_history_next_tx_uptime = previous_next_tx;
strcpy(reply, "Err - unable to save telemetry.tx schedule");
} else if (enable) {
snprintf(reply, 160, "OK - telemetry.tx schedule=%ud", (unsigned)days);
} else {
strcpy(reply, "OK - telemetry.tx schedule=off");
}
return;
}
if (strncmp(command, telemetry_tx_set_command,
sizeof(telemetry_tx_set_command) - 1U) == 0
&& (command[sizeof(telemetry_tx_set_command) - 1U] == 0
|| command[sizeof(telemetry_tx_set_command) - 1U] == ' ')) {
if (sender != NULL && !sender->isAdmin()) {
strcpy(reply, "Err - not permitted");
return;
}
char* spec = trimSpaces(
command + sizeof(telemetry_tx_set_command) - 1U);
if (*spec == 0) {
strcpy(reply,
"Err - use: set telemetry.tx <off|direct|path>");
return;
}
const bool previous_enabled = telemetry_history_tx_enabled;
const uint8_t previous_path_len = telemetry_history_tx_path_len;
const uint8_t previous_pending = telemetry_history_tx_pending;
const uint64_t previous_next_tx = telemetry_history_next_tx_uptime;
uint8_t previous_path[MAX_PATH_SIZE];
memcpy(previous_path, telemetry_history_tx_path, sizeof(previous_path));
if (strcmp(spec, "off") == 0) {
telemetry_history_tx_enabled = false;
telemetry_history_tx_pending = 0;
telemetry_history_next_tx_uptime = 0;
} else {
uint8_t path[MAX_PATH_SIZE];
uint8_t path_len = OUT_PATH_UNKNOWN;
const char* err = NULL;
if (!parsePathCommand(spec, path, path_len, err)
|| path_len == OUT_PATH_UNKNOWN
|| path_len == OUT_PATH_FORCE_FLOOD) {
strcpy(reply, err != NULL ? err
: "Err - telemetry.tx needs a direct path");
return;
}
telemetry_history_tx_enabled = true;
telemetry_history_tx_path_len = path_len;
telemetry_history_tx_pending = 0;
telemetry_history_next_tx_uptime = 0;
memset(telemetry_history_tx_path, 0,
sizeof(telemetry_history_tx_path));
if ((path_len & 63U) != 0) {
mesh::Packet::copyPath(telemetry_history_tx_path, path, path_len);
}
}
if (!saveTelemetryHistoryTxPrefs()) {
telemetry_history_tx_enabled = previous_enabled;
telemetry_history_tx_path_len = previous_path_len;
telemetry_history_tx_pending = previous_pending;
telemetry_history_next_tx_uptime = previous_next_tx;
memcpy(telemetry_history_tx_path, previous_path,
sizeof(telemetry_history_tx_path));
strcpy(reply, "Err - unable to save telemetry.tx");
} else if (telemetry_history_tx_enabled) {
snprintf(reply, 160, "OK - telemetry.tx schedule=%ud temp=165 volt=165",
(unsigned)telemetry_history_tx_interval_days);
} else {
strcpy(reply, "OK - telemetry.tx off");
}
return;
}
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
if (strncmp(command, telemetry_gps_set_command,
sizeof(telemetry_gps_set_command) - 1U) == 0
&& (command[sizeof(telemetry_gps_set_command) - 1U] == 0
@@ -10066,6 +10260,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
(unsigned)requested_days);
return;
}
#endif
if (strncmp(command, telemetry_temp_command,
sizeof(telemetry_temp_command) - 1U) == 0
@@ -10078,12 +10273,14 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|| command[sizeof(telemetry_volt_command) - 1U] == ' ')) {
telemetry_series = mesh::TelemetryHistory::SERIES_VOLTAGE;
telemetry_args = command + sizeof(telemetry_volt_command) - 1U;
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
} 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;
#endif
}
if (telemetry_args != NULL) {
@@ -10536,6 +10733,7 @@ void MyMesh::loop() {
}
#if MESH_ENABLE_TELEMETRY_HISTORY
#if MESH_ENABLE_TELEMETRY_GPS_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
@@ -10566,6 +10764,142 @@ uint8_t MyMesh::resizeTelemetryGpsDays(uint8_t requested_days) {
}
return actual_days;
}
#endif
void MyMesh::loadTelemetryHistoryTxPrefs() {
telemetry_history_tx_enabled = false;
memset(telemetry_history_tx_path, 0, sizeof(telemetry_history_tx_path));
telemetry_history_tx_path_len = OUT_PATH_UNKNOWN;
telemetry_history_tx_interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS;
telemetry_history_tx_pending = 0;
telemetry_history_next_tx_uptime = 0;
if (_fs == NULL || !_fs->exists(TELEMETRY_HISTORY_TX_PREFS_FILE)) return;
File file = openFloodSettingsRead(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE);
if (!file) return;
uint8_t magic[4];
uint8_t enabled = 0;
uint8_t path_len = OUT_PATH_UNKNOWN;
uint8_t interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS;
uint8_t path[MAX_PATH_SIZE];
bool valid = file.read(magic, sizeof(magic)) == sizeof(magic)
&& memcmp(magic, "THT2", sizeof(magic)) == 0
&& file.read(&enabled, sizeof(enabled)) == sizeof(enabled)
&& file.read(&path_len, sizeof(path_len)) == sizeof(path_len)
&& file.read(&interval_days, sizeof(interval_days))
== sizeof(interval_days)
&& file.read(path, sizeof(path)) == sizeof(path);
file.close();
valid = valid && enabled <= 1 && interval_days >= 1
&& interval_days <= TELEMETRY_HISTORY_TX_MAX_DAYS;
if (enabled != 0) {
valid = valid && path_len != OUT_PATH_UNKNOWN
&& path_len != OUT_PATH_FORCE_FLOOD
&& mesh::Packet::isValidPathLen(path_len);
}
if (!valid) return;
telemetry_history_tx_enabled = enabled != 0;
telemetry_history_tx_path_len = path_len;
telemetry_history_tx_interval_days = interval_days;
memcpy(telemetry_history_tx_path, path, sizeof(telemetry_history_tx_path));
}
bool MyMesh::saveTelemetryHistoryTxPrefs() {
if (_fs == NULL) return false;
File file = openFloodSettingsWrite(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE);
if (!file) return false;
const uint8_t magic[4] = {'T', 'H', 'T', '2'};
const uint8_t enabled = telemetry_history_tx_enabled ? 1 : 0;
bool success = file.write(magic, sizeof(magic)) == sizeof(magic)
&& file.write(&enabled, sizeof(enabled)) == sizeof(enabled)
&& file.write(&telemetry_history_tx_path_len,
sizeof(telemetry_history_tx_path_len))
== sizeof(telemetry_history_tx_path_len)
&& file.write(&telemetry_history_tx_interval_days,
sizeof(telemetry_history_tx_interval_days))
== sizeof(telemetry_history_tx_interval_days)
&& file.write(telemetry_history_tx_path,
sizeof(telemetry_history_tx_path))
== sizeof(telemetry_history_tx_path);
file.close();
return success;
}
void MyMesh::formatTelemetryHistoryTxStatus(char* reply,
size_t reply_size) const {
char source_id[mesh::TelemetryHistory::BINARY_SOURCE_ID_SIZE * 2U + 1U];
mesh::Utils::toHex(source_id, self_id.pub_key,
mesh::TelemetryHistory::BINARY_SOURCE_ID_SIZE);
char path_reply[132];
formatPathReply(telemetry_history_tx_path,
telemetry_history_tx_path_len,
path_reply, sizeof(path_reply));
snprintf(reply, reply_size, "> %s%ud id=%s p=%s",
telemetry_history_tx_enabled ? "on " : "off ",
(unsigned)telemetry_history_tx_interval_days,
source_id, path_reply[0] == '>' && path_reply[1] == ' '
? path_reply + 2 : path_reply);
}
bool MyMesh::sendTelemetryHistorySnapshot(
mesh::TelemetryHistory::Series series) {
uint8_t payload[mesh::TelemetryHistory::BINARY_PAYLOAD_SIZE];
const size_t payload_len = series == mesh::TelemetryHistory::SERIES_VOLTAGE
? telemetry_history.formatVoltageBinarySnapshot(
self_id.pub_key, payload, sizeof(payload))
: telemetry_history.formatTemperatureBinarySnapshot(
self_id.pub_key, payload, sizeof(payload));
if (payload_len <= mesh::TelemetryHistory::BINARY_HEADER_SIZE) return false;
mesh::Packet* packet = createRawData(payload, payload_len);
if (packet == NULL) return false;
return sendDirect(packet, telemetry_history_tx_path,
telemetry_history_tx_path_len);
}
void MyMesh::serviceTelemetryHistoryTx() {
if (!telemetry_history_tx_enabled
|| telemetry_history.voltageSampleCount()
< mesh::TelemetryHistory::BINARY_MAX_SAMPLES) {
return;
}
const uint64_t current_uptime_millis =
uptime_millis + (uint32_t)(millis() - last_millis);
if (telemetry_history_next_tx_uptime != 0
&& current_uptime_millis < telemetry_history_next_tx_uptime) {
return;
}
if (telemetry_history_tx_pending == 0) {
telemetry_history_tx_pending = TELEMETRY_HISTORY_TX_TEMPERATURE
| TELEMETRY_HISTORY_TX_VOLTAGE;
}
if ((telemetry_history_tx_pending & TELEMETRY_HISTORY_TX_TEMPERATURE) != 0
&& sendTelemetryHistorySnapshot(
mesh::TelemetryHistory::SERIES_TEMPERATURE)) {
telemetry_history_tx_pending &=
(uint8_t)~TELEMETRY_HISTORY_TX_TEMPERATURE;
}
if ((telemetry_history_tx_pending & TELEMETRY_HISTORY_TX_VOLTAGE) != 0
&& sendTelemetryHistorySnapshot(mesh::TelemetryHistory::SERIES_VOLTAGE)) {
telemetry_history_tx_pending &= (uint8_t)~TELEMETRY_HISTORY_TX_VOLTAGE;
}
if (telemetry_history_tx_pending == 0) {
telemetry_history_next_tx_uptime =
current_uptime_millis
+ (uint64_t)telemetry_history_tx_interval_days
* 24ULL * 60ULL * 60ULL * 1000ULL;
} else {
telemetry_history_next_tx_uptime =
current_uptime_millis + TELEMETRY_HISTORY_TX_RETRY_MILLIS;
}
}
void MyMesh::sampleTelemetryHistory() {
const uint32_t now = rtc_clock.getCurrentTime();
@@ -10613,6 +10947,7 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
checkRxInactivityWatchdog();
#if MESH_ENABLE_TELEMETRY_HISTORY
sampleTelemetryHistory();
serviceTelemetryHistoryTx();
#endif
#if !defined(PORTABLE_MQTT_OBSERVER)
checkBatteryAlert();
+23
View File
@@ -18,6 +18,16 @@
#endif
#endif
#ifndef MESH_ENABLE_TELEMETRY_GPS_HISTORY
// Builds without a GPS provider only collect missing GPS positions. Omit
// their unused decoder and resize CLI to preserve flash for useful history.
#if ENV_INCLUDE_GPS == 1
#define MESH_ENABLE_TELEMETRY_GPS_HISTORY 1
#else
#define MESH_ENABLE_TELEMETRY_GPS_HISTORY 0
#endif
#endif
#ifndef MESH_ENABLE_RECENT_REPEATERS
#define MESH_ENABLE_RECENT_REPEATERS 1
#endif
@@ -426,6 +436,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
CayenneLPP telemetry;
#if MESH_ENABLE_TELEMETRY_HISTORY
mesh::TelemetryHistory telemetry_history;
bool telemetry_history_tx_enabled;
uint8_t telemetry_history_tx_path[MAX_PATH_SIZE];
uint8_t telemetry_history_tx_path_len;
uint8_t telemetry_history_tx_interval_days;
uint8_t telemetry_history_tx_pending;
uint64_t telemetry_history_next_tx_uptime;
#endif
unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled)
float active_bw; // live BW, including temporary radio overrides
@@ -562,7 +578,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
void servicePostMeshLoop();
#if MESH_ENABLE_TELEMETRY_HISTORY
void sampleTelemetryHistory();
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
uint8_t resizeTelemetryGpsDays(uint8_t requested_days);
#endif
void loadTelemetryHistoryTxPrefs();
bool saveTelemetryHistoryTxPrefs();
void serviceTelemetryHistoryTx();
bool sendTelemetryHistorySnapshot(mesh::TelemetryHistory::Series series);
void formatTelemetryHistoryTxStatus(char* reply, size_t reply_size) const;
#endif
void sendSelfAdvertisementNow(uint32_t delay_millis, bool flood);
bool sendRepeatersFloodText(const char* text, const TransportKey* scope = nullptr,
+78
View File
@@ -41,6 +41,14 @@ public:
static constexpr uint8_t VOLTAGE_PAYLOAD_TYPE_V1 = 0x12;
static constexpr uint8_t GPS_PAYLOAD_TYPE_V1 = 0x13;
// A raw custom temperature ("TTB1") or voltage ("TVB1") snapshot fills one
// 184-byte mesh payload: 19 metadata bytes plus 165 chronological samples.
static constexpr size_t BINARY_HEADER_SIZE = 19;
static constexpr uint8_t BINARY_SOURCE_ID_SIZE = 8;
static constexpr uint8_t BINARY_MAX_SAMPLES = 165;
static constexpr size_t BINARY_PAYLOAD_SIZE =
BINARY_HEADER_SIZE + BINARY_MAX_SAMPLES;
enum Series : uint8_t {
SERIES_TEMPERATURE = 0,
SERIES_VOLTAGE = 1,
@@ -169,6 +177,68 @@ public:
return (uint8_t)(_gps_capacity / GPS_SAMPLES_PER_PAGE);
}
uint16_t voltageSampleCount() const {
return _tv_count;
}
size_t formatTemperatureBinarySnapshot(
const uint8_t source_id[BINARY_SOURCE_ID_SIZE],
uint8_t* dest, size_t dest_size) const {
return formatBinarySnapshot(false, source_id, dest, dest_size);
}
size_t formatVoltageBinarySnapshot(
const uint8_t source_id[BINARY_SOURCE_ID_SIZE],
uint8_t* dest, size_t dest_size) const {
return formatBinarySnapshot(true, source_id, dest, dest_size);
}
private:
// Formats a RAW_CUSTOM payload without Base64. The source ID is normally
// the first eight bytes of the repeater public key. Samples are oldest
// first, and the payload contains as many as fit, capped at 165.
size_t formatBinarySnapshot(
bool voltage_series, const uint8_t source_id[BINARY_SOURCE_ID_SIZE],
uint8_t* dest, size_t dest_size) const {
if (source_id == NULL || dest == NULL
|| dest_size <= BINARY_HEADER_SIZE || !_has_bucket) {
return 0;
}
size_t sample_count = dest_size - BINARY_HEADER_SIZE;
if (sample_count > BINARY_MAX_SAMPLES) {
sample_count = BINARY_MAX_SAMPLES;
}
if (sample_count > _tv_count) sample_count = _tv_count;
if (sample_count == 0) return 0;
memcpy(dest, voltage_series ? "TVB1" : "TTB1", 4);
memcpy(&dest[4], source_id, BINARY_SOURCE_ID_SIZE);
const uint16_t oldest_offset = (uint16_t)(sample_count - 1U);
putUint32LE(&dest[12], startEpochForOffset(oldest_offset));
putUint16LE(&dest[16], (uint16_t)(SAMPLE_INTERVAL_SECONDS / 60U));
dest[18] = (uint8_t)sample_count;
for (uint16_t slot = 0; slot < sample_count; 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);
uint8_t value = voltage;
if (!voltage_series) {
if (temperature_status == TEMPERATURE_NONE) value = 0;
else if (temperature_status == TEMPERATURE_LOW) value = 1;
else if (temperature_status == TEMPERATURE_HIGH) value = 2;
else value = (uint8_t)(temperature + 3U);
}
dest[BINARY_HEADER_SIZE + slot] = value;
}
return BINARY_HEADER_SIZE + sample_count;
}
public:
// 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
@@ -244,6 +314,8 @@ public:
cursor = skipSpaces(cursor);
if (*cursor != 0) return formatUsage(series, reply, reply_size);
#if !defined(MESH_ENABLE_TELEMETRY_GPS_HISTORY) \
|| MESH_ENABLE_TELEMETRY_GPS_HISTORY
if (series == SERIES_GPS) {
if (page < 1U || page > gpsPageCount()) {
snprintf(reply, reply_size, "Err - telemetry.gps page must be 1-%u",
@@ -252,6 +324,7 @@ public:
}
return formatGpsPage((uint8_t)page, reply, reply_size);
}
#endif
if (page < 1U || page > TV_PAGE_COUNT) {
copyReply(reply, reply_size, series == SERIES_TEMPERATURE
@@ -506,6 +579,11 @@ private:
dest[3] = (uint8_t)(value >> 24);
}
static void putUint16LE(uint8_t* dest, uint16_t value) {
dest[0] = (uint8_t)value;
dest[1] = (uint8_t)(value >> 8);
}
static void putInt32LE(uint8_t* dest, int32_t value) {
putUint32LE(dest, (uint32_t)value);
}
@@ -48,6 +48,10 @@ uint32_t uint32LE(const uint8_t* source) {
| ((uint32_t)source[3] << 24);
}
uint16_t uint16LE(const uint8_t* source) {
return (uint16_t)source[0] | ((uint16_t)source[1] << 8);
}
int32_t int32LE(const uint8_t* source) {
return (int32_t)uint32LE(source);
}
@@ -191,6 +195,95 @@ TEST(TelemetryHistory, RetainsExactlySevenTemperatureVoltageDays) {
EXPECT_EQ(3U, voltage_payload[7]);
}
TEST(TelemetryHistory, FormatsMaximumRawVoltageSnapshot) {
using mesh::TelemetryHistory;
TelemetryHistory history;
const uint32_t first_bucket = 500000U;
const uint32_t total_samples = 200U;
for (uint32_t sample = 0; sample < total_samples; sample++) {
history.record((first_bucket + sample)
* TelemetryHistory::SAMPLE_INTERVAL_SECONDS,
20, true,
(uint16_t)(1880U + (sample % 253U) * 10U),
0, 0, false);
}
const uint8_t source_id[TelemetryHistory::BINARY_SOURCE_ID_SIZE] = {
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF};
uint8_t payload[TelemetryHistory::BINARY_PAYLOAD_SIZE];
ASSERT_EQ(sizeof(payload), history.formatVoltageBinarySnapshot(
source_id, payload, sizeof(payload)));
EXPECT_EQ(0, memcmp(payload, "TVB1", 4));
EXPECT_EQ(0, memcmp(&payload[4], source_id, sizeof(source_id)));
const uint32_t oldest_sample =
total_samples - TelemetryHistory::BINARY_MAX_SAMPLES;
EXPECT_EQ((first_bucket + oldest_sample)
* TelemetryHistory::SAMPLE_INTERVAL_SECONDS,
uint32LE(&payload[12]));
EXPECT_EQ(30, uint16LE(&payload[16]));
EXPECT_EQ(TelemetryHistory::BINARY_MAX_SAMPLES, payload[18]);
for (uint16_t slot = 0;
slot < TelemetryHistory::BINARY_MAX_SAMPLES; slot++) {
const uint32_t sample = oldest_sample + slot;
EXPECT_EQ(TelemetryHistory::encodeVoltage(
(uint16_t)(1880U + (sample % 253U) * 10U)),
payload[TelemetryHistory::BINARY_HEADER_SIZE + slot]);
}
}
TEST(TelemetryHistory, FormatsMaximumRawTemperatureSnapshot) {
using mesh::TelemetryHistory;
TelemetryHistory history;
const uint32_t first_bucket = 700000U;
const uint32_t total_samples = TelemetryHistory::BINARY_MAX_SAMPLES;
for (uint32_t sample = 0; sample < total_samples; sample++) {
const int16_t temperature = sample == 0 ? -51
: sample == 1 ? 78 : (int16_t)(-50 + sample % 128U);
history.record((first_bucket + sample)
* TelemetryHistory::SAMPLE_INTERVAL_SECONDS,
temperature, sample != 2, 3700, 0, 0, false);
}
const uint8_t source_id[TelemetryHistory::BINARY_SOURCE_ID_SIZE] = {
0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE};
uint8_t payload[TelemetryHistory::BINARY_PAYLOAD_SIZE];
ASSERT_EQ(sizeof(payload), history.formatTemperatureBinarySnapshot(
source_id, payload, sizeof(payload)));
EXPECT_EQ(0, memcmp(payload, "TTB1", 4));
EXPECT_EQ(0, memcmp(&payload[4], source_id, sizeof(source_id)));
EXPECT_EQ(first_bucket * TelemetryHistory::SAMPLE_INTERVAL_SECONDS,
uint32LE(&payload[12]));
EXPECT_EQ(30, uint16LE(&payload[16]));
EXPECT_EQ(TelemetryHistory::BINARY_MAX_SAMPLES, payload[18]);
EXPECT_EQ(1, payload[TelemetryHistory::BINARY_HEADER_SIZE]);
EXPECT_EQ(2, payload[TelemetryHistory::BINARY_HEADER_SIZE + 1U]);
EXPECT_EQ(0, payload[TelemetryHistory::BINARY_HEADER_SIZE + 2U]);
for (uint16_t slot = 3; slot < TelemetryHistory::BINARY_MAX_SAMPLES; slot++) {
EXPECT_EQ((uint8_t)(slot % 128U + 3U),
payload[TelemetryHistory::BINARY_HEADER_SIZE + slot]);
}
}
TEST(TelemetryHistory, RawVoltageSnapshotRequiresHistoryAndDataSpace) {
using mesh::TelemetryHistory;
TelemetryHistory history;
const uint8_t source_id[TelemetryHistory::BINARY_SOURCE_ID_SIZE] = {};
uint8_t payload[TelemetryHistory::BINARY_PAYLOAD_SIZE];
EXPECT_EQ(0U, history.formatVoltageBinarySnapshot(
source_id, payload, sizeof(payload)));
history.record(1800000000U, 20, true, 3700, 0, 0, false);
EXPECT_EQ(0U, history.formatVoltageBinarySnapshot(
source_id, payload,
TelemetryHistory::BINARY_HEADER_SIZE));
EXPECT_EQ(TelemetryHistory::BINARY_HEADER_SIZE + 1U,
history.formatVoltageBinarySnapshot(
source_id, payload,
TelemetryHistory::BINARY_HEADER_SIZE + 1U));
EXPECT_EQ(1, payload[18]);
}
TEST(TelemetryHistory, EmitsZeroGpsOriginAndDeltasWithoutFixes) {
using mesh::TelemetryHistory;
TelemetryHistory history;