From 8ffafddd6a092017990261664573b4e0c99b8e11 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 13 Aug 2026 00:52:17 -0700 Subject: [PATCH] Add scheduled telemetry history packets --- docs/cli_command_availability.md | 4 +- docs/cli_commands.md | 60 +++- docs/halo_keymind_settings.md | 2 +- examples/simple_repeater/MyMesh.cpp | 335 ++++++++++++++++++ examples/simple_repeater/MyMesh.h | 23 ++ src/helpers/TelemetryHistory.h | 78 ++++ .../test_telemetry_history.cpp | 93 +++++ 7 files changed, 588 insertions(+), 7 deletions(-) diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index dcafbcb6..6a5c9f8f 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -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 | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 7c9351cd..e83c071d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 ` +- `get telemetry.tx` +- `set telemetry.tx ` +- `set telemetry.tx schedule ` +- `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 | diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 7c5bbd4c..97cd1e92 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -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 `, `set telemetry.tx schedule `, `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` | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 5a93ed0f..a38e8e15 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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 "); + 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 "); + 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(); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 84ee08ca..09bc2a0e 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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, diff --git a/src/helpers/TelemetryHistory.h b/src/helpers/TelemetryHistory.h index d3e4b509..bb27e521 100644 --- a/src/helpers/TelemetryHistory.h +++ b/src/helpers/TelemetryHistory.h @@ -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); } diff --git a/test/test_telemetry_history/test_telemetry_history.cpp b/test/test_telemetry_history/test_telemetry_history.cpp index f6fd8282..83472813 100644 --- a/test/test_telemetry_history/test_telemetry_history.cpp +++ b/test/test_telemetry_history/test_telemetry_history.cpp @@ -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;