From 70730e53d995a32f0cf4526e6878a455b6c81024 Mon Sep 17 00:00:00 2001 From: Josh Bowyer Date: Sun, 9 Aug 2026 13:02:52 -0500 Subject: [PATCH 01/29] fix: battery ADC calibration for third-party battery packs Stock T-Deck Plus batteries are read through a hardcoded 2.0x ADC divider ratio. Extended/third-party packs commonly wire their sense line through a different divider network, causing the reported voltage (and therefore percentage) to be wrong even on a healthy, fully-charged pack - e.g. capped at ~69% when actually full. - Power.h/.cpp: adcDividerRatio now user-settable instead of hardcoded; batteryRawAdc() exposes raw ADC for calibration. - UserConfig.h/.cpp: adcDividerRatio persisted (default 2.0, matches prior hardcoded behavior for stock packs - no behavior change unless calibrated). - main.cpp: new serial console command `b` prints raw ADC, computed voltage, divider ratio, percent, and the exact calibration formula. New `B` command sets + persists a new ratio. - Also corrects two related defaults: BATTERY_CHARGE_THRESHOLD_DEFAULT 4.0V -> 4.18V (4.0V misclassified any battery at >=~80% SoC as "actively charging" even at rest - LiPo cells routinely sit above 4.0V while simply resting near full). Renamed the "Full Voltage" settings label to "Full Threshold" - it's a discharge-curve calibration anchor, not literally "the voltage that means 100%", and the old name read as directly comparable to Charge Above when it isn't. - README: documents the calibration workflow and the specific hardware combo this was found/fixed against (makernova.io 8000mAh extended pack + AlleyCat's printables.com #1345336 extended case). Verified end-to-end on real hardware: multimeter-measured 4.04V vs. firmware's previous 4.315V reading (stock ratio); after calibration to 1.8762, firmware reports 4.055V - closely matching. --- README.md | 36 ++++++++++++++++++++ src/config/UserConfig.cpp | 4 +++ src/config/UserConfig.h | 19 ++++++++++- src/hal/Power.cpp | 17 +++++++-- src/hal/Power.h | 7 +++- src/main.cpp | 53 +++++++++++++++++++++++++++-- src/ui/screens/LvSettingsScreen.cpp | 2 +- 7 files changed, 130 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 01dcc1c..b2d70a2 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,42 @@ AP mode exposes a local TCP endpoint for a nearby Reticulum host: The bridging UI and interface behavior may change as Ratspeak's client release stabilizes. +## Battery + +The stock T-Deck Plus battery is read through a fixed ADC voltage-divider +ratio. Third-party/extended battery packs are commonly wired through a +different divider network on their sense line, which can make the reported +voltage (and therefore percentage) wrong even though the battery itself is +fine — e.g. reading ~69% when actually fully charged. + +Tested and confirmed working with a +[makernova.io](https://makernova.io/) 8000mAh pack (JST 1.25 connector) in +[AlleyCat's extended T-Deck Plus case](https://www.printables.com/model/1345336) +(3D-printed, Printables #1345336). + +If your battery percentage looks wrong, calibrate the ADC divider ratio +against a multimeter reading on the battery terminals (ideally with USB +disconnected, to avoid charge-line voltage bias): + +``` +b # print raw ADC, computed voltage, divider ratio, percent +B # set + persist a new divider ratio, e.g. B1.8762 +``` + +over the device's serial console (115200 baud). The firmware computes the +correct ratio for you from a live raw ADC reading + your multimeter value — +see the in-firmware `b` output for the exact formula. Settings persist across +reboots and firmware updates (stored alongside your identity/config, not +touched by a normal OTA/reflash). + +The `Charge Above` and `Full Threshold` settings (Settings → Battery, hold +trackball click to reveal developer controls) are separate knobs: +`Charge Above` is the voltage above which the device assumes it's actively on +a charger (typically just under the LiPo absolute max of ~4.2V, not the +resting-full voltage); `Full Threshold` compensates the discharge curve for +your particular pack's typical resting-voltage sag under load. They're not +meant to be compared to each other directly. + ## Build From Source ```bash diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp index 3078534..2c240de 100644 --- a/src/config/UserConfig.cpp +++ b/src/config/UserConfig.cpp @@ -27,6 +27,8 @@ void UserConfig::sanitizeSettings() { BATTERY_CHARGE_THRESHOLD_MIN, BATTERY_CHARGE_THRESHOLD_MAX); _settings.fullBatteryV = constrain(_settings.fullBatteryV, BATTERY_FULL_VOLTAGE_MIN, BATTERY_FULL_VOLTAGE_MAX); + _settings.adcDividerRatio = constrain(_settings.adcDividerRatio, + BATTERY_ADC_DIVIDER_MIN, BATTERY_ADC_DIVIDER_MAX); } bool UserConfig::parseJson(const String& json) { @@ -119,6 +121,7 @@ bool UserConfig::parseJson(const String& json) { _settings.batteryModel = doc["batt_model"] | BATTERY_MODEL_LIPO; _settings.chargeThresholdV = doc["charge_thresh_v"] | BATTERY_CHARGE_THRESHOLD_DEFAULT; _settings.fullBatteryV = doc["full_battery_v"] | BATTERY_FULL_VOLTAGE_DEFAULT; + _settings.adcDividerRatio = doc["adc_divider"] | BATTERY_ADC_DIVIDER_DEFAULT; _settings.gpsTimeEnabled = doc["gps_time"] | true; _settings.gpsLocationEnabled = doc["gps_location"] | false; @@ -200,6 +203,7 @@ String UserConfig::serializeToJson() { doc["batt_model"] = _settings.batteryModel; doc["charge_thresh_v"] = _settings.chargeThresholdV; doc["full_battery_v"] = _settings.fullBatteryV; + doc["adc_divider"] = _settings.adcDividerRatio; doc["gps_time"] = _settings.gpsTimeEnabled; doc["gps_location"] = _settings.gpsLocationEnabled; diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index b8baa45..018d995 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -21,12 +21,28 @@ constexpr uint8_t BATTERY_DISPLAY_PERCENT = 0; constexpr uint8_t BATTERY_DISPLAY_BAR = 1; constexpr uint8_t BATTERY_MODEL_LIPO = 0; constexpr uint8_t BATTERY_MODEL_LINEAR = 1; -constexpr float BATTERY_CHARGE_THRESHOLD_DEFAULT = 4.0f; +// 4.18V, not the 4.2V absolute LiPo max - actively charging cells commonly +// sit in the high-4.1/low-4.2 range well before topping out, so a threshold +// right at 4.2 would misclassify a nearly-full charging cell as +// "discharging". Previously 4.0f, which misclassified anything >=80% SoC as +// actively charging even when just resting on battery. +constexpr float BATTERY_CHARGE_THRESHOLD_DEFAULT = 4.18f; constexpr float BATTERY_FULL_VOLTAGE_DEFAULT = 3.9f; constexpr float BATTERY_CHARGE_THRESHOLD_MIN = 3.80f; constexpr float BATTERY_CHARGE_THRESHOLD_MAX = 4.30f; constexpr float BATTERY_FULL_VOLTAGE_MIN = 3.50f; constexpr float BATTERY_FULL_VOLTAGE_MAX = 4.20f; +// ADC voltage-divider ratio for the battery-sense pin. Was hardcoded to +// 2.0f in Power.cpp (matches the stock T-Deck Plus battery's sense-line +// wiring) — aftermarket/extended battery packs can be wired through a +// different physical divider network on their sense connection, which no +// amount of curve/offset tuning (fullBatteryV, chargeThresholdV) can +// correct, since those only reshape an already-correct voltage reading. +// Derive the right value the same way as a multimeter-based ADC +// calibration: measured_mv / (raw_computed_mv_at_2.0x_ratio) * 2.0. +constexpr float BATTERY_ADC_DIVIDER_DEFAULT = 2.0f; +constexpr float BATTERY_ADC_DIVIDER_MIN = 1.0f; +constexpr float BATTERY_ADC_DIVIDER_MAX = 4.0f; struct TCPEndpoint { String host; @@ -74,6 +90,7 @@ struct UserSettings { uint8_t batteryModel = BATTERY_MODEL_LIPO; float chargeThresholdV = BATTERY_CHARGE_THRESHOLD_DEFAULT; float fullBatteryV = BATTERY_FULL_VOLTAGE_DEFAULT; + float adcDividerRatio = BATTERY_ADC_DIVIDER_DEFAULT; // Keyboard uint8_t keyboardBrightness = 100; // Percentage 0-100 (0 = off) diff --git a/src/hal/Power.cpp b/src/hal/Power.cpp index cdc2fc5..281420b 100644 --- a/src/hal/Power.cpp +++ b/src/hal/Power.cpp @@ -55,8 +55,17 @@ void Power::begin() { float Power::batteryVoltage() const { // T-Deck Plus: voltage divider on GPIO 4 int raw = analogRead(BAT_ADC_PIN); - // Voltage divider: 2x ratio, 3.3V reference, 12-bit ADC - return (raw / 4095.0f) * 3.3f * 2.0f; + // Voltage divider ratio, 3.3V reference, 12-bit ADC. Stock hardware is + // ~2.0x; aftermarket/extended battery packs can be wired through a + // different sense-line divider network, so this is user-calibratable + // via setAdcDividerRatio() rather than hardcoded. Calibrate with a + // multimeter on the actual battery terminals: + // correct_ratio = measured_mv / raw_mv_at_current_ratio * current_ratio + return (raw / 4095.0f) * 3.3f * _adcDividerRatio; +} + +int Power::batteryRawAdc() const { + return analogRead(BAT_ADC_PIN); } int Power::batteryPercent() const { @@ -105,6 +114,10 @@ void Power::setFullBatteryVoltage(float v) { _fullBatteryV = v; } +void Power::setAdcDividerRatio(float ratio) { + _adcDividerRatio = ratio; +} + uint8_t Power::percentToPWM(uint8_t pct) const { if (pct == 0) return 0; diff --git a/src/hal/Power.h b/src/hal/Power.h index 2c92821..4a4c46a 100644 --- a/src/hal/Power.h +++ b/src/hal/Power.h @@ -26,6 +26,10 @@ class Power { void setBatteryModel(uint8_t model); // 0=lipo, 1=linear void setChargeThreshold(float v); void setFullBatteryVoltage(float v); + void setAdcDividerRatio(float ratio); + float adcDividerRatio() const { return _adcDividerRatio; } + // Raw ADC reading on BAT_ADC_PIN, unscaled — for calibration only. + int batteryRawAdc() const; // Display backlight — accepts percentage 1-100 void setBrightness(uint8_t percent); @@ -69,6 +73,7 @@ class Power { // Battery uint8_t _batteryModel = 0; - float _chargeThreshold = 4.0f; + float _chargeThreshold = 4.18f; float _fullBatteryV = 3.9f; + float _adcDividerRatio = 2.0f; }; diff --git a/src/main.cpp b/src/main.cpp index 8a5b229..3c36863 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -948,6 +948,26 @@ static void handleSerialLineCommand(const char* line) { sendDiagnosticLiteLinkProof(line + 1); break; } + case 'B': { + const char* p = line + 1; + while (*p == ' ' || *p == '\t') p++; + if (!*p) { + Serial.println("[SERIAL] usage: B, for example B2.15 (see 'b' for the current value + calibration formula)"); + return; + } + char* end = nullptr; + float ratio = strtof(p, &end); + if (end == p || ratio < BATTERY_ADC_DIVIDER_MIN || ratio > BATTERY_ADC_DIVIDER_MAX) { + Serial.printf("[SERIAL] invalid ratio (must be %.1f..%.1f)\n", + BATTERY_ADC_DIVIDER_MIN, BATTERY_ADC_DIVIDER_MAX); + return; + } + powerMgr.setAdcDividerRatio(ratio); + userConfig.settings().adcDividerRatio = ratio; + userConfig.save(sdStore, flash); + Serial.printf("[SERIAL] adc_divider_ratio set to %.4f (saved)\n", ratio); + break; + } default: Serial.printf("[SERIAL] unknown line command '%c'\n", line[0]); break; @@ -955,11 +975,34 @@ static void handleSerialLineCommand(const char* line) { } static void printSerialHelp() { - Serial.println("[SERIAL] commands: ? help | a announce | t raw-test | d diag | r rssi | i irq | p tx-power-cycle | m min-power | q iq | +/- freq"); - Serial.println("[SERIAL] line commands: F exact-frequency | P exact-tx-power | L [dest_hash] LXMF test"); + Serial.println("[SERIAL] commands: ? help | a announce | t raw-test | d diag | r rssi | i irq | p tx-power-cycle | m min-power | q iq | b battery | +/- freq"); + Serial.println("[SERIAL] line commands: F exact-frequency | P exact-tx-power | L [dest_hash] LXMF test | B set-adc-divider-ratio"); Serial.println("[SERIAL] lite relay diag: H [dest] Header2 data | J [dest] linkreq | K link-data | Y link-proof"); } +static void onHotkeyBatteryDiag() { + int raw = powerMgr.batteryRawAdc(); + float ratio = powerMgr.adcDividerRatio(); + float v = powerMgr.batteryVoltage(); + int pct = powerMgr.batteryPercent(); + bool charging = powerMgr.isCharging(); + float rawMv = (raw / 4095.0f) * 3300.0f; // raw ADC as mV, BEFORE divider scaling + + Serial.println("=== BATTERY DIAGNOSTIC ==="); + Serial.printf("raw_adc=%d raw_mv=%.1f divider_ratio=%.4f\n", raw, rawMv, ratio); + Serial.printf("voltage=%.3fV percent=%d%% charging=%s\n", + v, pct, charging ? "yes" : "no"); + Serial.println("Calibrate: measure the battery with a multimeter (ideally with"); + Serial.println("USB disconnected), then compute + set the correct ratio:"); + if (raw > 0) { + Serial.printf(" divider_ratio = measured_mv / %.1f\n", rawMv); + Serial.println(" Then set it with: B e.g. B2.15"); + } else { + Serial.println(" (raw_adc is 0 - can't compute a ratio right now)"); + } + Serial.println("=========================="); +} + static void handleSerialCommands() { static char line[96]; static size_t lineLen = 0; @@ -986,7 +1029,7 @@ static void handleSerialCommands() { } if (c == '\r' || c == '\n' || c == ' ' || c == '\t') continue; - if (c == 'F' || c == 'P' || c == 'L' || c == 'H' || c == 'J' || c == 'K' || c == 'Y') { + if (c == 'F' || c == 'P' || c == 'L' || c == 'H' || c == 'J' || c == 'K' || c == 'Y' || c == 'B') { lineActive = true; lineLen = 0; line[lineLen++] = c; @@ -1028,6 +1071,9 @@ static void handleSerialCommands() { case 'Q': toggleDiagnosticInvertIQ(); break; + case 'b': + onHotkeyBatteryDiag(); + break; case '+': case '=': nudgeDiagnosticFrequency(1000); @@ -1547,6 +1593,7 @@ void setup() { powerMgr.setBatteryModel(userConfig.settings().batteryModel); powerMgr.setChargeThreshold(userConfig.settings().chargeThresholdV); powerMgr.setFullBatteryVoltage(userConfig.settings().fullBatteryV); + powerMgr.setAdcDividerRatio(userConfig.settings().adcDividerRatio); diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 7cd3036..11fca65 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -611,7 +611,7 @@ void LvSettingsScreen::buildItems() { char buf[8]; snprintf(buf, sizeof(buf), "%.2fV", v / 100.0f); return String(buf); }, 380, 430, 1}); idx++; - _items.push_back({"Full Voltage", SettingType::INTEGER, + _items.push_back({"Full Threshold", SettingType::INTEGER, [&s]() { return (int)roundf(s.fullBatteryV * 100); }, [&s](int v) { s.fullBatteryV = v / 100.0f; }, [](int v) -> String { From 44f1c3eca9d3da780dbfa50443e81d69135e4ca7 Mon Sep 17 00:00:00 2001 From: Josh Bowyer Date: Sun, 9 Aug 2026 13:16:59 -0500 Subject: [PATCH 02/29] fix: clamp battery percent to 0-100; live-refresh readonly battery fields - Power.cpp: batteryPercent() could return >100% (observed 119% on real hardware at 4.09V) - the LiPo curve's top entry is 3.90V/100%, well below the LiPo absolute max (~4.2V), and any voltage above 3.90V not otherwise caught by isCharging() extrapolated past the table's top pair with no clamp. Exposed by raising the charge-detection threshold (previously masked since anything >=4.0V was always treated as "charging" -> 100% directly). Clamped both the LiPo curve and linear model's return paths to 0-100. - LvSettingsScreen: Voltage/Estimated Charge readonly rows only updated on navigation/edit events, so they could show an indefinitely stale snapshot (e.g. a transient boot-time reading) while just sitting on the Battery category. Added a 1s-throttled refresh via the existing refreshUI() hook (already called every frame by UIManager::update()), skipped while any edit mode is active. --- src/hal/Power.cpp | 35 ++++++++++++++++++++++++----- src/ui/screens/LvSettingsScreen.cpp | 17 ++++++++++++++ src/ui/screens/LvSettingsScreen.h | 6 +++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/hal/Power.cpp b/src/hal/Power.cpp index 281420b..fd7ed51 100644 --- a/src/hal/Power.cpp +++ b/src/hal/Power.cpp @@ -52,9 +52,28 @@ void Power::begin() { Serial.println("[POWER] Power manager initialized"); } +namespace { +// Oversample the raw ADC to smooth out transient noise/sag - a single +// analogRead() can catch a momentary dip (e.g. inrush current right at +// boot while the display/radio power up) that doesn't reflect the +// battery's actual resting voltage. 8 samples with a short settling delay +// between each is cheap (<1ms total) and removes most single-sample spikes +// without meaningfully slowing down callers. +constexpr int BATTERY_ADC_SAMPLES = 8; + +int readBatteryRawAveraged() { + long sum = 0; + for (int i = 0; i < BATTERY_ADC_SAMPLES; i++) { + sum += analogRead(BAT_ADC_PIN); + if (i < BATTERY_ADC_SAMPLES - 1) delayMicroseconds(100); + } + return (int)(sum / BATTERY_ADC_SAMPLES); +} +} // namespace + float Power::batteryVoltage() const { // T-Deck Plus: voltage divider on GPIO 4 - int raw = analogRead(BAT_ADC_PIN); + int raw = readBatteryRawAveraged(); // Voltage divider ratio, 3.3V reference, 12-bit ADC. Stock hardware is // ~2.0x; aftermarket/extended battery packs can be wired through a // different sense-line divider network, so this is user-calibratable @@ -65,7 +84,7 @@ float Power::batteryVoltage() const { } int Power::batteryRawAdc() const { - return analogRead(BAT_ADC_PIN); + return readBatteryRawAveraged(); } int Power::batteryPercent() const { @@ -85,14 +104,20 @@ int Power::batteryPercent() const { if (_batteryModel == 1) { // Linear: distribution across 3.0–4.2V. - return (int)((v - 3.0f) / 1.2f * 100.0f); + return constrain((int)((v - 3.0f) / 1.2f * 100.0f), 0, 100); } - // LiPo: interpolate between nearest table entries. + // LiPo: interpolate between nearest table entries. The table's top + // entry (3.90V=100%) is well below the LiPo absolute max (~4.2V), so + // any v above 3.90V - not otherwise caught by isCharging() - would + // extrapolate past the top entry pair without a clamp (e.g. 119% at + // 4.09V observed on real hardware after raising the charge-detection + // threshold). Clamp every return path to a valid 0-100 percent. for (int i = 0; i < LIPO_CURVE_N - 1; i++) { if (v >= LIPO_CURVE[i + 1].v) { float t = (v - LIPO_CURVE[i + 1].v) / (LIPO_CURVE[i].v - LIPO_CURVE[i + 1].v); - return (int)(LIPO_CURVE[i + 1].pct + t * (LIPO_CURVE[i].pct - LIPO_CURVE[i + 1].pct)); + int pct = (int)(LIPO_CURVE[i + 1].pct + t * (LIPO_CURVE[i].pct - LIPO_CURVE[i + 1].pct)); + return constrain(pct, 0, 100); } } return 0; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 11fca65..12c64ad 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -1247,6 +1247,23 @@ void LvSettingsScreen::refreshUI() { if (_view == SettingsView::ITEM_LIST) rebuildItemList(); } + // Periodically refresh live readonly rows (Voltage, Estimated Charge) + // while sitting on the item list, so they don't show a stale snapshot + // from whenever the screen was last entered/edited. Skip while any + // edit mode is active so we don't clobber an in-progress "< Bar >"- + // style edit display. + if (_view == SettingsView::ITEM_LIST && !_editing && !_textEditing && !_freqEditing) { + unsigned long now = millis(); + if (now - _lastReadonlyRefresh >= 1000) { + _lastReadonlyRefresh = now; + bool hasReadonly = false; + for (auto& item : _items) { + if (item.type == SettingType::READONLY) { hasReadonly = true; break; } + } + if (hasReadonly) rebuildItemList(); + } + } + if (_view != SettingsView::WIFI_PICKER || !_wifiScanActive) return; if (!WiFiInterface::isScanComplete()) return; diff --git a/src/ui/screens/LvSettingsScreen.h b/src/ui/screens/LvSettingsScreen.h index 51447ac..7881906 100644 --- a/src/ui/screens/LvSettingsScreen.h +++ b/src/ui/screens/LvSettingsScreen.h @@ -184,6 +184,12 @@ class LvSettingsScreen : public LvScreen { int _wifiPickerIdx = 0; size_t _wifiTargetSlot = 0; bool _wifiScanActive = false; + // Throttled live-value refresh (Voltage / Estimated Charge readonly + // rows) while sitting on the item list - without this, those labels + // only update on navigation/edit events and can show a stale snapshot + // (e.g. a transient boot-time reading) indefinitely if the user just + // stays on the screen watching it. + unsigned long _lastReadonlyRefresh = 0; // Reboot-required tracking bool _rebootNeeded = false; From c6d36a43021af96db2773a82c360887570407038 Mon Sep 17 00:00:00 2001 From: Josh Bowyer Date: Wed, 12 Aug 2026 11:42:48 -0500 Subject: [PATCH 03/29] feat: GPS telemetry Stage 1 (issue #64) - on-demand LXMF position send Implements on-demand, privacy-gated GPS position sharing to a configurable telemetry hub, verified live on-air against the Lyra nomadnet-telemetry-collector. - TelemetryEncoder: Sideband-compatible FIELD_TELEMETRY msgpack (SID_TIME, SID_LOCATION as BE fixed-point bins, SID_BATTERY), pure functions with no RNS dependency. - TelemetryManager: privacy gate (GPS fix must be <=30s fresh), identity/path discovery state machine (IDLE->WAIT_IDENTITY-> WAIT_PATH->SENDING) mirroring LXMFManager's opportunistic delivery pattern, inline LXMF packFull clone (microReticulum's packContent doesn't yet support a fields map - TODO markers left for when it does), single-frame LoRa size fallback ladder (full -> no-battery -> location-only) since Token-encrypted padding can exceed the 254B budget even after dropping fields. - Settings UI: new 'Send Telemetry' toggle (default OFF) and hub destination hash under Time & Location, plus a 'Select Hub' peer picker (mirrors the WiFi picker) to choose a seen/announced destination instead of typing 32 hex chars by hand. - UserConfig: gpsTelemetryEnabled (bool, default false) and gpsTelemetryHubHash (string, defaults to Lyra collector hash), persisted, sanitized (must be exactly 32 hex chars or reset). - Serial: g (send now), G<32hex> (session hub override), help text. Zero edits to LXMFManager.cpp or vendored microReticulum - encryption is free via Packet::pack() for SINGLE destinations, no chat-path risk. Verified: pio run -e rsdeck_915 clean build; flashed to ESP32-S3 T-Deck; 4/5 on-demand sends landed on Lyra's collector (one silent RF loss at the 243B near-limit single-frame size, acceptable for on-demand use); state.json + NomadNet grid page confirmed showing the new node's lat/lon. --- src/config/UserConfig.cpp | 28 ++ src/config/UserConfig.h | 8 + src/main.cpp | 39 +- src/reticulum/TelemetryEncoder.cpp | 237 ++++++++++++ src/reticulum/TelemetryEncoder.h | 69 ++++ src/reticulum/TelemetryManager.cpp | 578 ++++++++++++++++++++++++++++ src/reticulum/TelemetryManager.h | 116 ++++++ src/ui/screens/LvSettingsScreen.cpp | 237 ++++++++++++ src/ui/screens/LvSettingsScreen.h | 15 +- 9 files changed, 1323 insertions(+), 4 deletions(-) create mode 100644 src/reticulum/TelemetryEncoder.cpp create mode 100644 src/reticulum/TelemetryEncoder.h create mode 100644 src/reticulum/TelemetryManager.cpp create mode 100644 src/reticulum/TelemetryManager.h diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp index 2c240de..b9326a0 100644 --- a/src/config/UserConfig.cpp +++ b/src/config/UserConfig.cpp @@ -29,6 +29,26 @@ void UserConfig::sanitizeSettings() { BATTERY_FULL_VOLTAGE_MIN, BATTERY_FULL_VOLTAGE_MAX); _settings.adcDividerRatio = constrain(_settings.adcDividerRatio, BATTERY_ADC_DIVIDER_MIN, BATTERY_ADC_DIVIDER_MAX); + + // GPS telemetry hub hash must be exactly 32 hex chars; otherwise reset + // to the default Lyra collector hash. Empty / malformed values would + // make TelemetryManager refuse every send anyway, but persisting a + // broken hash causes silent UI confusion. + if (_settings.gpsTelemetryHubHash.length() != UserSettings::GPS_TELEMETRY_HUB_HASH_LEN) { + _settings.gpsTelemetryHubHash = UserSettings::GPS_TELEMETRY_DEFAULT_HUB_HASH; + } else { + bool allHex = true; + for (size_t i = 0; i < UserSettings::GPS_TELEMETRY_HUB_HASH_LEN; i++) { + char c = _settings.gpsTelemetryHubHash.charAt(i); + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + allHex = false; + break; + } + } + if (!allHex) { + _settings.gpsTelemetryHubHash = UserSettings::GPS_TELEMETRY_DEFAULT_HUB_HASH; + } + } } bool UserConfig::parseJson(const String& json) { @@ -129,6 +149,10 @@ bool UserConfig::parseJson(const String& json) { _settings.timezoneSet = doc["tz_set"] | false; _settings.use24HourTime = doc["time_24h"] | false; + // GPS telemetry (issue #64) — opt-in position sharing to a hub. + _settings.gpsTelemetryEnabled = doc["gps_telemetry_enabled"] | false; + _settings.gpsTelemetryHubHash = doc["gps_telemetry_hub"] | UserSettings::GPS_TELEMETRY_DEFAULT_HUB_HASH; + _settings.audioEnabled = doc["audio_on"] | true; _settings.audioVolume = doc["audio_vol"] | 80; @@ -211,6 +235,10 @@ String UserConfig::serializeToJson() { doc["tz_set"] = _settings.timezoneSet; doc["time_24h"] = _settings.use24HourTime; + // GPS telemetry (issue #64) — opt-in position sharing to a hub. + doc["gps_telemetry_enabled"] = _settings.gpsTelemetryEnabled; + doc["gps_telemetry_hub"] = _settings.gpsTelemetryHubHash; + doc["audio_on"] = _settings.audioEnabled; doc["audio_vol"] = _settings.audioVolume; diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index 018d995..2e3b23e 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -113,6 +113,14 @@ struct UserSettings { bool timezoneSet = false; // false = show timezone picker at boot bool use24HourTime = false; // false = 12h (no AM/PM), true = 24h + // GPS Telemetry (issue #64) — opt-in position sharing to a hub. Off by + // default per privacy requirement; user must explicitly enable AND have + // a working hub hash configured. + bool gpsTelemetryEnabled = false; // master opt-in switch, default OFF + String gpsTelemetryHubHash = "da424e0f47657d7575df58a2b83b111b"; // 32 hex chars, Lyra collector default + static constexpr size_t GPS_TELEMETRY_HUB_HASH_LEN = 32; + static constexpr const char* GPS_TELEMETRY_DEFAULT_HUB_HASH = "da424e0f47657d7575df58a2b83b111b"; + // Audio bool audioEnabled = true; uint8_t audioVolume = 80; // 0-100 diff --git a/src/main.cpp b/src/main.cpp index 3c36863..8729688 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -47,6 +47,7 @@ #include "reticulum/AnnounceManager.h" #include "reticulum/LXMFManager.h" #include "reticulum/IdentityManager.h" +#include "reticulum/TelemetryManager.h" #include "transport/LoRaInterface.h" #include "transport/WiFiInterface.h" #include @@ -112,6 +113,7 @@ UserConfig userConfig; Power powerMgr; AudioNotify audio; IdentityManager identityMgr; +TelemetryManager telemetryManager; #if HAS_GPS GPSManager gps; #endif @@ -968,6 +970,11 @@ static void handleSerialLineCommand(const char* line) { Serial.printf("[SERIAL] adc_divider_ratio set to %.4f (saved)\n", ratio); break; } + case 'G': { + // G<32hex> set telemetry collector hub hash (session only) + telemetryManager.setHubHashHex(line + 1); + break; + } default: Serial.printf("[SERIAL] unknown line command '%c'\n", line[0]); break; @@ -975,9 +982,10 @@ static void handleSerialLineCommand(const char* line) { } static void printSerialHelp() { - Serial.println("[SERIAL] commands: ? help | a announce | t raw-test | d diag | r rssi | i irq | p tx-power-cycle | m min-power | q iq | b battery | +/- freq"); - Serial.println("[SERIAL] line commands: F exact-frequency | P exact-tx-power | L [dest_hash] LXMF test | B set-adc-divider-ratio"); + Serial.println("[SERIAL] commands: ? help | a announce | t raw-test | d diag | r rssi | i irq | p tx-power-cycle | m min-power | q iq | b battery | g telemetry-send | +/- freq"); + Serial.println("[SERIAL] line commands: F exact-frequency | P exact-tx-power | L [dest_hash] LXMF test | B set-adc-divider-ratio | G<32hex> telemetry-hub"); Serial.println("[SERIAL] lite relay diag: H [dest] Header2 data | J [dest] linkreq | K link-data | Y link-proof"); + telemetryManager.printHelp(); } static void onHotkeyBatteryDiag() { @@ -1029,7 +1037,7 @@ static void handleSerialCommands() { } if (c == '\r' || c == '\n' || c == ' ' || c == '\t') continue; - if (c == 'F' || c == 'P' || c == 'L' || c == 'H' || c == 'J' || c == 'K' || c == 'Y' || c == 'B') { + if (c == 'F' || c == 'P' || c == 'L' || c == 'H' || c == 'J' || c == 'K' || c == 'Y' || c == 'B' || c == 'G') { lineActive = true; lineLen = 0; line[lineLen++] = c; @@ -1074,6 +1082,9 @@ static void handleSerialCommands() { case 'b': onHotkeyBatteryDiag(); break; + case 'g': + telemetryManager.handleSerial('g', nullptr); + break; case '+': case '=': nudgeDiagnosticFrequency(1000); @@ -1403,6 +1414,26 @@ void setup() { // (LVGL boot renders via lv_timer_handler in setProgress) bootTraceStage("lxmf-begin"); + // Step 17.5: Telemetry manager (Stage 1 rsDeck #64 / thin hybrid) + // Pure optional component — no UI yet, default hub hash is set at + // build time. GPS may be disabled; we still bind Power + RNS so + // serial `g` can run the privacy-gate diagnostics even before a + // first fix lands. + telemetryManager.begin(&rns, +#if HAS_GPS + &gps, +#else + nullptr, +#endif + &powerMgr); + // Wire UserConfig so Settings > Time & Location > Send Telemetry / + // Telemetry Hub can gate the send and override the compiled-in hub + // hash. Optional — telemetry still works with the default hub if + // this is never called. + telemetryManager.setUserConfig(&userConfig); + Serial.println("[TELEMETRY] manager bound (rsDeck #64 Stage 1, default hub=da424e0f47657d7575df58a2b83b111b)"); + bootTraceStage("telemetry-manager"); + // Step 18: Announce manager lvBootScreen.setProgress(0.78f, "Loading contacts..."); // (LVGL boot renders via lv_timer_handler in setProgress) @@ -1784,6 +1815,7 @@ void setup() { lvSettingsScreen.setTCPClients(&tcpClients); lvSettingsScreen.setRNS(&rns); lvSettingsScreen.setIdentityManager(&identityMgr); + lvSettingsScreen.setAnnounceManager(announceManager); lvSettingsScreen.setUIManager(&ui); lvSettingsScreen.setIdentityHash(rns.destinationHashStr()); lvSettingsScreen.setDestinationHash(rns.destinationHashHex()); @@ -2118,6 +2150,7 @@ void loop() { lxmf.loop(); if (announceManager) announceManager->loop(); audio.loop(); + telemetryManager.loop(); // 7. WiFi STA connection handler if (wifiSTAStarted) { diff --git a/src/reticulum/TelemetryEncoder.cpp b/src/reticulum/TelemetryEncoder.cpp new file mode 100644 index 0000000..f949cf3 --- /dev/null +++ b/src/reticulum/TelemetryEncoder.cpp @@ -0,0 +1,237 @@ +// ============================================================================= +// TelemetryEncoder.cpp — see TelemetryEncoder.h. Hand-rolled msgpack writer +// tuned for the few LXMF / Telemeter shapes rsDeck needs in Stage 1. +// +// Mirrors the writer used by the RLR/Sideband telemetry reference (see +// /tmp/opencode/rak/reticulum-lora-repeater/src/Msgpack.cpp) and emits +// the canonical big-endian envelopes Sideband signs against. +// +// We only encode the shapes that show up in the Stage 1 Telemeter +// snapshot — there is no need to drag in a full msgpack dependency for +// three map entries. +// ============================================================================= +#include "TelemetryEncoder.h" + +#include +#include +#include + +namespace TelemetryEncoder { +namespace { + +// ------------------------------------------------------------------ +// Big-endian byte writers +// ------------------------------------------------------------------ +inline void put_u8 (std::vector& b, uint8_t v) { b.push_back(v); } +inline void put_be16(std::vector& b, uint16_t v) { + b.push_back((uint8_t)(v >> 8)); + b.push_back((uint8_t)(v)); +} +inline void put_be32(std::vector& b, uint32_t v) { + b.push_back((uint8_t)(v >> 24)); + b.push_back((uint8_t)(v >> 16)); + b.push_back((uint8_t)(v >> 8)); + b.push_back((uint8_t)v); +} +inline void put_be64(std::vector& b, uint64_t v) { + for (int s = 56; s >= 0; s -= 8) b.push_back((uint8_t)(v >> s)); +} + +// ------------------------------------------------------------------ +// Msgpack scalar/structure emitters (canonical smallest-envelope) +// ------------------------------------------------------------------ +void emit_uint(std::vector& b, uint64_t v) { + if (v < 0x80) { + b.push_back((uint8_t)v); // positive fixint + } else if (v <= 0xff) { + b.push_back(0xcc); b.push_back((uint8_t)v); // uint8 + } else if (v <= 0xffff) { + b.push_back(0xcd); put_be16(b, (uint16_t)v); // uint16 + } else if (v <= 0xffffffffULL) { + b.push_back(0xce); put_be32(b, (uint32_t)v); // uint32 + } else { + b.push_back(0xcf); put_be64(b, v); // uint64 + } +} + +void emit_fixarray(std::vector& b, size_t n) { + if (n < 16) { + b.push_back((uint8_t)(0x90 | (n & 0x0f))); // fixarray + } else if (n < 0x10000) { + b.push_back(0xdc); put_be16(b, (uint16_t)n); // array16 + } else { + b.push_back(0xdd); put_be32(b, (uint32_t)n); // array32 + } +} + +void emit_fixmap(std::vector& b, size_t n) { + if (n < 16) { + b.push_back((uint8_t)(0x80 | (n & 0x0f))); // fixmap + } else if (n < 0x10000) { + b.push_back(0xde); put_be16(b, (uint16_t)n); // map16 + } else { + b.push_back(0xdf); put_be32(b, (uint32_t)n); // map32 + } +} + +void emit_nil(std::vector& b) { b.push_back(0xc0); } +void emit_bool(std::vector& b, bool v) { b.push_back(v ? 0xc3 : 0xc2); } + +void emit_float64(std::vector& b, double v) { + uint64_t bits = 0; + std::memcpy(&bits, &v, sizeof(bits)); + b.push_back(0xcb); + put_be64(b, bits); +} + +void emit_bin(std::vector& b, const uint8_t* data, size_t len) { + if (len < 0x100) { + b.push_back(0xc4); b.push_back((uint8_t)len); // bin8 + } else if (len < 0x10000) { + b.push_back(0xc5); put_be16(b, (uint16_t)len); // bin16 + } else { + b.push_back(0xc6); put_be32(b, (uint32_t)len); // bin32 + } + if (len) b.insert(b.end(), data, data + len); +} + +// Emit a big-endian struct int wrapped as a msgpack bin — matches the +// upstream Python `sbapp/sideband/sense.py` which wraps struct.pack +// output as a Python bytes (msgpack bin). +void emit_be32_bin(std::vector& b, uint32_t v) { + uint8_t tmp[4] = { + (uint8_t)(v >> 24), (uint8_t)(v >> 16), + (uint8_t)(v >> 8), (uint8_t)v + }; + emit_bin(b, tmp, sizeof(tmp)); +} +void emit_be16_bin(std::vector& b, uint16_t v) { + uint8_t tmp[2] = { (uint8_t)(v >> 8), (uint8_t)v }; + emit_bin(b, tmp, sizeof(tmp)); +} + +// NaN-aware "double is sane" check — the rsDeck GPS HAL exposes the +// current lat/lon as raw doubles and we refuse if they're stale, so the +// privacy gate passes a real 0.0 (== "no location" in Sideband sense) +// by leaving the value as NaN here. This isolates the encoder from the +// gating logic. +inline bool is_sane(double v) { + return !std::isnan(v) && !std::isinf(v); +} + +} // anonymous namespace + +// ------------------------------------------------------------------ +// Public API +// ------------------------------------------------------------------ + +std::vector buildSnapshot(double lat_deg, + double lon_deg, + double alt_m, + double speed_mps, + double bearing_deg, + double accuracy_m, + uint32_t last_update_s, + uint64_t time_sid, + double battery_val, + bool battery_charging, + bool include_time) { + // Count present sensors first so we can emit the correct map header + // size — Sideband/umsgpack default-size maps (0x80..0x8f) round-trip + // the receiver's iter order the same way, but the LYRA collector grid + // is happier with explicit count. + size_t n = include_time ? 1 : 0; + const bool have_loc = is_sane(lat_deg) && is_sane(lon_deg); + const bool have_bat = is_sane(battery_val); + if (have_loc) n++; + if (have_bat) n++; + + std::vector b; + b.reserve(40 + (have_loc ? 32 : 0) + (have_bat ? 16 : 0)); + emit_fixmap(b, n); + + // SID_TIME — Unix epoch seconds when GPS is valid; uptime as fallback. + // May be omitted when callers need to fit the encrypted packet into a + // single-frame LoRa budget (the SID_LOCATION entry carries its own + // `last_update_s` so the collector still has a usable timestamp). + if (include_time) { + emit_uint(b, SID_TIME); + emit_uint(b, time_sid); + } + + if (have_loc) { + // SID_LOCATION → [lat_be32, lon_be32, alt_be32, speed_be32, + // bearing_be32, accuracy_be16, last_update_uint] + emit_uint(b, SID_LOCATION); + emit_fixarray(b, 7); + + // lat / lon are stored as int32 of (deg * 1e6), per Sideband sense.py + const int32_t lat_udeg = (int32_t)lround(lat_deg * 1e6); + const int32_t lon_udeg = (int32_t)lround(lon_deg * 1e6); + emit_be32_bin(b, (uint32_t)lat_udeg); + emit_be32_bin(b, (uint32_t)lon_udeg); + + // alt_m × 100 as int32 (one centimetre). NaN/inf are filtered by + // the speed/bearing check above; here they reach us as 0.0 when + // GPS has no altitude fix — Sideband renders 0 m as "unknown". + if (is_sane(alt_m)) { + const int32_t alt_cm = (int32_t)lround(alt_m * 100.0); + emit_be32_bin(b, (uint32_t)alt_cm); + } else { + emit_be32_bin(b, 0); + } + + // speed_mps × 100 as int32 — 0 if unavailable (no live speed on the + // rsDeck's MIA-M10Q unless the user enables course-over-ground NMEA). + if (is_sane(speed_mps)) { + const int32_t speed_cms = (int32_t)lround(speed_mps * 100.0); + emit_be32_bin(b, (uint32_t)speed_cms); + } else { + emit_be32_bin(b, 0); + } + + // bearing_deg × 100 — 0 if unavailable. + if (is_sane(bearing_deg)) { + const int32_t bearing_cdeg = (int32_t)lround(bearing_deg * 100.0); + emit_be32_bin(b, (uint32_t)bearing_cdeg); + } else { + emit_be32_bin(b, 0); + } + + // accuracy: HDOP-derived metres × 100, 0 if unknown. Collectors + // treat 0 as "do not draw accuracy circle". + if (is_sane(accuracy_m)) { + const uint16_t accuracy_cm = (uint16_t)lround(accuracy_m * 100.0); + emit_be16_bin(b, accuracy_cm); + } else { + emit_be16_bin(b, 0); + } + + emit_uint(b, last_update_s); + } + + if (have_bat) { + // SID_BATTERY → [charge_percent_or_voltage_f64, charging_bool, nil] + // The (f64, bool, nil) triple matches RLR/Sideband shape — the + // third slot is reserved for temperature and left nil here since + // the rsDeck has no battery thermistor exposed. + emit_uint(b, SID_BATTERY); + emit_fixarray(b, 3); + emit_float64(b, battery_val); + emit_bool(b, battery_charging); + emit_nil(b); + } + + return b; +} + +std::vector buildFieldsBin(const std::vector& snapshot) { + std::vector b; + b.reserve(8 + snapshot.size()); + emit_fixmap(b, 1); + emit_uint(b, FIELD_TELEMETRY); + emit_bin(b, snapshot.empty() ? nullptr : snapshot.data(), snapshot.size()); + return b; +} + +} // namespace TelemetryEncoder diff --git a/src/reticulum/TelemetryEncoder.h b/src/reticulum/TelemetryEncoder.h new file mode 100644 index 0000000..b9ffa84 --- /dev/null +++ b/src/reticulum/TelemetryEncoder.h @@ -0,0 +1,69 @@ +// ============================================================================= +// TelemetryEncoder.h — pure msgpack builders for Sideband-compatible +// Telemeter snapshot + LXMF FIELD_TELEMETRY wrapping. +// +// Stage 1 (rsDeck #64 / thin hybrid): the snapshot values come from the +// rsDeck HAL (GPS + Power); the shape and field codes match Sideband's +// Telemeter.packed() (sbapp/sideband/sense.py). All functions are pure +// and have no Reticulum / microReticulum dependency — easy to unit test +// on host. +// ============================================================================= +#pragma once + +#include +#include + +namespace TelemetryEncoder { + +// --- Sideband sensor IDs (sbapp/sideband/sense.py) --- +constexpr uint8_t SID_TIME = 0x01; +constexpr uint8_t SID_LOCATION = 0x02; +constexpr uint8_t SID_BATTERY = 0x04; + +// --- LXMF field key (reticulum-specifications SPEC.md §5.9.1) --- +constexpr uint8_t FIELD_TELEMETRY = 0x02; + +// Build the Sideband Telemeter snapshot msgpack map bytes. +// +// Fields are emitted only when data is sane: +// SID_TIME → uint UNIX seconds (or uptime seconds if unavailable) +// SID_LOCATION → 7-element array of msgpack bins wrapping big-endian +// struct ints (matches sbapp struct.pack("!i"/"!I"/"!H")) +// SID_BATTERY → [percent_or_voltage_f64, charging_bool, nil] +// +// Any of lat/lon/alt/speed/bearing/accuracy_m may be NaN to omit the +// SID_LOCATION entry (used by the privacy gate when there is no fresh +// GPS fix). Functions take simple doubles — callers convert from the +// rsDeck units (degrees / metres / m·s⁻¹). +// +// `include_time` (default true) controls whether the SID_TIME key/value +// pair is emitted. Callers that need to fit a single-frame LoRa budget +// can pass false as a fallback after dropping optional sensors — the +// SID_LOCATION entry carries its own `last_update_s` so the collector +// still gets a usable timestamp. +std::vector buildSnapshot(double lat_deg, + double lon_deg, + double alt_m, + double speed_mps, + double bearing_deg, + double accuracy_m, + uint32_t last_update_s, + uint64_t time_sid, + double battery_val, + bool battery_charging, + bool include_time = true); + +// Wrap the snapshot bytes as the LXMF FIELD_TELEMETRY value inside a +// one-entry fields map: +// +// msgpack { 0x02: bin(snapshot) } +// +// That fields map is the value `LXMFMessage.packed()` puts in slot [3] of +// the outer [timestamp, title, content, fields] array. +// +// `snapshot` may be empty — in that case the fields map is still emitted +// with `0x02 -> bin "" ` so the message structure stays valid even when +// the privacy gate stripped every optional sensor. +std::vector buildFieldsBin(const std::vector& snapshot); + +} // namespace TelemetryEncoder diff --git a/src/reticulum/TelemetryManager.cpp b/src/reticulum/TelemetryManager.cpp new file mode 100644 index 0000000..397dbee --- /dev/null +++ b/src/reticulum/TelemetryManager.cpp @@ -0,0 +1,578 @@ +// ============================================================================= +// TelemetryManager.cpp — see TelemetryManager.h. +// +// Stage 1 rsDeck #64 (thin hybrid): on-demand opportunistic LXMF push of +// a Sideband-shaped FIELD_TELEMETRY value to a configured collector. +// +// Critical constraints (per the issue brief): +// * No periodic scheduler. The serial `g` command is the only trigger. +// * Privacy gate refuses if no fresh fix (≤ FRESH_FIX_MAX_AGE_MS old). +// * No edits to LXMFManager.{h,cpp} — the chat path is untouched. +// * No edits to the microReticulum library — the sign + wrap is a +// clone of LXMFMessage::packFull() from upstream, kept inline here +// because packContent() in this fork does not yet expose the +// `fields` map slot. +// +// TODO: DELETE the inline signAndWrap once LXMFMessage::packContent +// grows a `fields` arg — until then we reproduce the wire layout +// exactly (see microReticulum/src/LXMFMessage.cpp ~lines 103-136). +// ============================================================================= + +#include "TelemetryManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "config/BoardConfig.h" +#include "config/UserConfig.h" +#include "ReticulumManager.h" +#if HAS_GPS +#include "hal/GPSManager.h" +#endif +#include "hal/Power.h" + +// ============================================================================= +// Construction / wiring +// ============================================================================= + +TelemetryManager::TelemetryManager() = default; + +void TelemetryManager::begin(ReticulumManager* rns, GPSManager* gps, Power* power) { + _rns = rns; + _gps = gps; + _power = power; + // If the bound UserConfig has a valid hub hash, override the + // compiled-in default — the manager still works standalone + // (with its baked-in Lyra collector default) if UserConfig is + // never wired. + applyUserConfigHubHash(); + loadHubHashIfNeeded(); + Serial.printf("[TELEMETRY] default hub=%s\n", _hubHex); +} + +void TelemetryManager::applyUserConfigHubHash() { + if (!_cfg) return; + const String& fromCfg = _cfg->settings().gpsTelemetryHubHash; + if (fromCfg.isEmpty()) return; + if (fromCfg.length() == sizeof(_hubHex) - 1) { + // setHubHashHex validates hex + length; bail silently if the + // stored value is malformed (sanitizeSettings should already + // have caught that, but be defensive). + setHubHashHex(fromCfg.c_str()); + } +} + +void TelemetryManager::loadHubHashIfNeeded() { + if (_hubHashLoaded) return; + _hubHash.assignHex(_hubHex); + if (_hubHash.size() != 16) { + Serial.printf("[TELEMETRY] WARN: hub hex did not decode to 16 bytes (got %u)\n", + (unsigned)_hubHash.size()); + _hubHash.clear(); + } + _hubHashLoaded = true; +} + +// ============================================================================= +// Public API +// ============================================================================= + +bool TelemetryManager::sendNow() { + if (_state != State::IDLE) { + Serial.println("[TELEMETRY] sendNow refused: attempt already in flight"); + return false; + } + if (!_rns || !_gps || !_power) { + Serial.println("[TELEMETRY] sendNow refused: subsystems not bound"); + return false; + } + + // --- Privacy gate (rsDeck #64 specification) --- + // User opt-in must be enabled in Settings > Time & Location > + // "Send Telemetry". Off by default — refuse loudly so the user + // gets an obvious hint pointing at the setting. + if (_cfg && !_cfg->settings().gpsTelemetryEnabled) { + Serial.println("[TELEMETRY] refused: GPS telemetry disabled in Settings > Time & Location > Send Telemetry"); + return false; + } + + // Re-pull the hub hash from UserConfig on every send so live edits + // in the Settings UI take effect without a callback. If the bound + // UserConfig is empty/malformed, fall back to whatever is already + // baked into _hubHex (the compiled-in default, or a previously + // applied value). + if (_cfg) { + const String& h = _cfg->settings().gpsTelemetryHubHash; + if (h.length() == sizeof(_hubHex) - 1) { + // Cheap string compare against current hub — skip the + // log spam of setHubHashHex when the value is unchanged. + if (strncmp(_hubHex, h.c_str(), sizeof(_hubHex) - 1) != 0) { + setHubHashHex(h.c_str()); + } + } + } + + loadHubHashIfNeeded(); + if (_hubHash.size() != 16) { + Serial.println("[TELEMETRY] sendNow refused: hub hash unset/invalid"); + return false; + } + + const LocSample loc = readLocation(); + if (!loc.valid) { + Serial.println("[TELEMETRY] refused: no GPS fix"); + return false; + } + if (loc.age_ms > FRESH_FIX_MAX_AGE_MS) { + Serial.printf("[TELEMETRY] refused: GPS fix stale (%lu ms > %lu ms)\n", + (unsigned long)loc.age_ms, + (unsigned long)FRESH_FIX_MAX_AGE_MS); + return false; + } + if (!_rns->isTransportActive()) { + Serial.println("[TELEMETRY] refused: Reticulum transport inactive"); + return false; + } + + // Start the discovery state machine. We don't try to pack & send in + // the same call — that lets loop() handle the rare case where + // Identity::recall() returns false the first time after a reset. + Serial.printf("[TELEMETRY] requested send to %s (fix=%lu ms)\n", + _hubHex, (unsigned long)loc.age_ms); + _state = State::WAIT_IDENTITY; + _attempts = 0; + _stateAtMs = millis(); + return true; +} + +bool TelemetryManager::hasFreshFix() const { + if (!_gps) return false; +#if HAS_GPS + if (!_gps->hasLocationFix()) return false; + return _gps->fixAgeMs() <= FRESH_FIX_MAX_AGE_MS; +#else + return false; +#endif +} + +const char* TelemetryManager::stateName() const { + switch (_state) { + case State::IDLE: return "IDLE"; + case State::WAIT_IDENTITY: return "WAIT_IDENTITY"; + case State::WAIT_PATH: return "WAIT_PATH"; + case State::SENDING: return "SENDING"; + } + return "?"; +} + +// ============================================================================= +// State machine (driven from main loop) +// ============================================================================= + +void TelemetryManager::loop() { + if (_state == State::IDLE) return; + // Unsigned-subtraction idiom matches LXMFManager — safe as long as + // STATE_RETRY_INTERVAL_MS is small relative to millis() wrap + // (~49 days). + if (millis() - _stateAtMs < STATE_RETRY_INTERVAL_MS) return; + _stateAtMs = millis(); + + switch (_state) { + case State::IDLE: + return; + case State::WAIT_IDENTITY: + driveIdentityDiscovery(); + return; + case State::WAIT_PATH: + drivePathDiscovery(); + return; + case State::SENDING: + // One-shot: buildAndSendSnapshot runs synchronously inside + // drivePathDiscovery() before flipping state back to IDLE. + return; + } +} + +void TelemetryManager::driveIdentityDiscovery() { + _attempts++; + RNS::Identity recipient = RNS::Identity::recall(_hubHash); + if (!recipient) { + Serial.printf("[TELEMETRY] no identity yet for %s — requesting path (attempt %u/%u)\n", + _hubHex, (unsigned)_attempts, (unsigned)DISCOVERY_MAX_ATTEMPTS); + RNS::Transport::request_path(_hubHash); + if (_attempts >= DISCOVERY_MAX_ATTEMPTS) { + resetToIdle("no identity after retries"); + } + return; + } + Serial.printf("[TELEMETRY] identity recalled (%s) for %s\n", + recipient.hexhash().c_str(), _hubHex); + + // Advance to the path check. _stateAtMs=0 lets the next loop tick + // re-enter within the same iteration budget. + _state = State::WAIT_PATH; + _attempts = 0; + _stateAtMs = millis() - STATE_RETRY_INTERVAL_MS; + RNS::Transport::request_path(_hubHash); +} + +void TelemetryManager::drivePathDiscovery() { + _attempts++; + if (!RNS::Transport::has_path(_hubHash)) { + Serial.printf("[TELEMETRY] no path yet — requesting (attempt %u/%u)\n", + (unsigned)_attempts, (unsigned)DISCOVERY_MAX_ATTEMPTS); + RNS::Transport::request_path(_hubHash); + if (_attempts >= DISCOVERY_MAX_ATTEMPTS) { + resetToIdle("no path after retries"); + } + return; + } + Serial.printf("[TELEMETRY] path OK (%d hops) — building packet\n", + RNS::Transport::hops_to(_hubHash)); + + _state = State::SENDING; + bool ok = false; + try { + ok = buildAndSendSnapshot(); + } catch (const std::exception& e) { + Serial.printf("[TELEMETRY] exception during build/send: %s\n", e.what()); + ok = false; + } + if (ok) { + Serial.println("[TELEMETRY] send complete"); + } else { + Serial.println("[TELEMETRY] build/send failed"); + } + _state = State::IDLE; +} + +// ============================================================================= +// Build + sign + wrap + send +// ============================================================================= +// +// The wire layout is identical to what microReticulum's +// LXMFMessage::packFull produces for chat messages, just with the +// optional `fields` map populated instead of empty — but that field is +// not yet reachable in packContent(), so we hand-roll the same steps +// here. See TODO at the top of this file. +// +// DELETE this function when upstream packContent gains a fields arg. +bool TelemetryManager::buildAndSendSnapshot() { +#if !HAS_GPS + Serial.println("[TELEMETRY] GPS not compiled in — refusing"); + return false; +#else + if (!_gps || !_rns) return false; + + const LocSample loc = readLocation(); + if (!loc.valid || loc.age_ms > FRESH_FIX_MAX_AGE_MS) { + Serial.println("[TELEMETRY] re-gate fail: fix no longer fresh"); + return false; + } + + const RNS::Identity recipient = RNS::Identity::recall(_hubHash); + if (!recipient) { + Serial.println("[TELEMETRY] identity vanished mid-send (rare)"); + return false; + } + + // --- Build the snapshot (Sideband Telemeter shape) --- + // + // We may have to shrink the snapshot to fit the encrypted packet + // inside the LoRa single-frame budget (254 bytes raw). The fallback + // ladder below tries progressively smaller shapes until one packs + // under the limit, or refuses to send (split-frame TX is known + // broken on this 2-hop LoRa path). + const double battery_pct = (double)_power->batteryPercent(); + const bool battery_chrg = _power->isCharging(); + // HDOP × ~2.5 m is a rough lower bound on horizontal accuracy (DOP + // multiplies baseline pseudorange error). The MIA-M10Q spec sheet + // quotes ~1 m CEP under good skies; we just expose what GPS gives. + const double accuracy_m = isfinite(_gps->hdop()) ? (_gps->hdop() * 2.5) : NAN; + const uint32_t now_s = (uint32_t)epochSecondsOrUptime(); + const uint64_t now_full_s = (uint64_t)epochSecondsOrUptime(); + + const RNS::Identity& self = _rns->identity(); + RNS::Bytes srcHash = RNS::Destination::hash(self, "lxmf", "delivery"); + if (srcHash.size() != 16) { + Serial.println("[TELEMETRY] unexpected src_hash length"); + return false; + } + + // RNS::Destination::OUT/SINGLE on a (recipient, "lxmf", "delivery") + // tuple. Packet::pack() will Token-encrypt the payload to the + // recipient. We then call packet.send() through Transport which + // picks the right interface (LoRa, TCP, AutoInterface, ...). + RNS::Destination outDest(recipient, + RNS::Type::Destination::OUT, + RNS::Type::Destination::SINGLE, + "lxmf", "delivery"); + + // --- Hand-roll signAndWrap() --- + // + // We have to reproduce the upstream LXMFMessage::packFull here + // because packContent() in this microReticulum fork does not yet + // accept a fields map. The structure is identical — only the + // payload array differs in element [3]. + // + // packed_content = msgpack [ + // timestamp_f64, + // title_bin "", // empty + // content_bin "", // empty + // fields_map // 1 entry: 0x02 → bin(snapshot) + // ] + // + // signable = dest_hash(16) || src_hash(16) || packed_content + // msgHash = SHA256(signable) + // signed = signable || msgHash + // signature = Ed25519(signed) (64 bytes) + // body = src_hash(16) || signature(64) || packed_content + // + // TODO: DELETE when microReticulum packContent gains fields support. + + struct Variant { + const char* label; + bool include_time; + bool include_battery; + }; + // Order matters: most informative first; smallest last. If even the + // "location-only" variant overflows, we refuse rather than split. + const Variant variants[] = { + { "full", true, true }, + { "no-battery", true, false }, + { "location", false, false }, + }; + + size_t lastRawLen = 0; + const char* lastLabel = "?"; + + for (const auto& v : variants) { + const double b_val = v.include_battery ? battery_pct : NAN; + + std::vector snapshot = TelemetryEncoder::buildSnapshot( + loc.lat, + loc.lon, + loc.alt, + loc.speed, + loc.bearing, + accuracy_m, + now_s, + now_full_s, + b_val, + battery_chrg, + v.include_time + ); + + std::vector fields = TelemetryEncoder::buildFieldsBin(snapshot); + Serial.printf("[TELEMETRY] [%s] snapshot=%uB fields=%uB\n", + v.label, (unsigned)snapshot.size(), (unsigned)fields.size()); + + // Build packed_content: fixarray(4) [ts_f64, bin"", bin"", fields_map] + std::vector packed; + packed.reserve(32 + fields.size()); + packed.push_back(0x94); // fixarray(4) + // ts_f64 — prefer real epoch seconds when the system clock is valid, + // otherwise monotonic uptime (Sideband renders against its own + // receive clock in either case — this is metadata only). + { + uint64_t bits = 0; + double ts = (double)epochSecondsOrUptime(); + memcpy(&bits, &ts, sizeof(bits)); + packed.push_back(0xcb); // float64 marker + for (int s = 56; s >= 0; s -= 8) packed.push_back((uint8_t)(bits >> s)); + } + // title bin "" and content bin "" (bin8 len 0) + packed.push_back(0xc4); packed.push_back(0x00); + packed.push_back(0xc4); packed.push_back(0x00); + // fields map — paste the prebuilt map bytes verbatim + packed.insert(packed.end(), fields.begin(), fields.end()); + + // Prepare `signed` = dest(16) || src(16) || packed + std::vector hashedPart; + hashedPart.reserve(32 + packed.size()); + hashedPart.insert(hashedPart.end(), _hubHash.data(), _hubHash.data() + 16); + hashedPart.insert(hashedPart.end(), srcHash.data(), srcHash.data() + 16); + hashedPart.insert(hashedPart.end(), packed.begin(), packed.end()); + + RNS::Bytes hashedBytes(hashedPart.data(), hashedPart.size()); + RNS::Bytes msgHash = RNS::Identity::full_hash(hashedBytes); + + std::vector signedPart; + signedPart.reserve(hashedPart.size() + msgHash.size()); + signedPart.insert(signedPart.end(), hashedPart.begin(), hashedPart.end()); + signedPart.insert(signedPart.end(), msgHash.data(), msgHash.data() + msgHash.size()); + + RNS::Bytes sigBytes(self.sign(RNS::Bytes(signedPart.data(), signedPart.size()))); + if (sigBytes.size() < 64) { + Serial.printf("[TELEMETRY] [%s] signature short (%u bytes) — refusing\n", + v.label, (unsigned)sigBytes.size()); + return false; + } + + // Wire body = src(16) || sig(64) || packed + std::vector payload; + payload.reserve(16 + 64 + packed.size()); + payload.insert(payload.end(), srcHash.data(), srcHash.data() + 16); + payload.insert(payload.end(), sigBytes.data(), sigBytes.data() + 64); + payload.insert(payload.end(), packed.begin(), packed.end()); + + Serial.printf("[TELEMETRY] [%s] payload=%uB (src:16 + sig:64 + packed:%u)\n", + v.label, (unsigned)payload.size(), (unsigned)packed.size()); + + RNS::Bytes payloadBytes(payload.data(), payload.size()); + RNS::Packet packet(outDest, payloadBytes); + + // Pack first so we can warn the user if the encrypted raw packet + // would exceed the LoRa single-frame budget before we burn airtime. + size_t rawLen = 0; + try { + packet.pack(); + rawLen = packet.raw().size(); + } catch (const std::exception& e) { + Serial.printf("[TELEMETRY] [%s] pack failed: %s\n", v.label, e.what()); + return false; + } + lastRawLen = rawLen; + lastLabel = v.label; + Serial.printf("[TELEMETRY] [%s] packed raw=%uB (single-frame LoRa limit=%u)\n", + v.label, (unsigned)rawLen, + (unsigned)RSDECK_RNODE_SINGLE_FRAME_RAW_MAX); + + if (rawLen <= RSDECK_RNODE_SINGLE_FRAME_RAW_MAX) { + // Fits — send it and we're done. + RNS::PacketReceipt receipt = packet.send(); + if (!receipt) { + Serial.println("[TELEMETRY] Packet::send: no interface accepted the packet"); + return false; + } + Serial.println("[TELEMETRY] packet accepted by Transport"); + return true; + } + + // Over budget — pick the next variant (or refuse at the end of + // the loop). Don't call packet.send() on an oversized packet: + // split-frame TX has proven unreliable on the current 2-hop LoRa + // path (see Stage 1 proof 2026-08-12). + if (v.include_time && v.include_battery) { + Serial.println("[TELEMETRY] over budget — retrying without SID_BATTERY"); + } else if (v.include_time) { + Serial.println("[TELEMETRY] over budget — retrying without SID_TIME (location-only)"); + } else { + // We already tried everything; refuse cleanly below. + } + } + + Serial.printf("[TELEMETRY] refusing send: [%s] raw=%uB still exceeds single-frame LoRa budget (%uB) — no split-frame TX on this path\n", + lastLabel, (unsigned)lastRawLen, + (unsigned)RSDECK_RNODE_SINGLE_FRAME_RAW_MAX); + return false; +#endif // HAS_GPS +} + +void TelemetryManager::resetToIdle(const char* reason) { + Serial.printf("[TELEMETRY] aborting send: %s\n", reason ? reason : "?"); + _state = State::IDLE; + _attempts = 0; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +TelemetryManager::LocSample TelemetryManager::readLocation() const { + LocSample s; +#if HAS_GPS + if (!_gps || !_gps->hasLocationFix()) return s; + s.age_ms = _gps->fixAgeMs(); + s.valid = s.age_ms <= FRESH_FIX_MAX_AGE_MS; + s.lat = _gps->latitude(); + s.lon = _gps->longitude(); + s.alt = _gps->altitude(); + // rsDeck's GPS driver does not expose speed/bearing — leave NaN so + // the SID_LOCATION encoder emits zeros for those slots while still + // publishing the position (matches RLR's behaviour for unknown + // velocity). + s.speed = NAN; + s.bearing = NAN; +#endif + return s; +} + +uint64_t TelemetryManager::epochSecondsOrUptime() const { + // Prefer real epoch when the system clock is plausibly set (≥ + // 2024-01-01 = 1704067200). Otherwise fall back to monotonic + // uptime, same way the RLR reference does on hardware without RTC. + time_t now = time(nullptr); + if (now >= 1704067200) return (uint64_t)now; + return (uint64_t)(millis() / 1000UL); +} + +// ============================================================================= +// Hub-hash mutation (serial `G` command) +// ============================================================================= + +void TelemetryManager::setHubHashHex(const char* hex32) { + if (!hex32) return; + size_t len = strlen(hex32); + if (len != 32) { + Serial.printf("[TELEMETRY] setHubHashHex: need 32 hex chars (got %u)\n", + (unsigned)len); + return; + } + for (size_t i = 0; i < 32; i++) { + if (!std::isxdigit((unsigned char)hex32[i])) { + Serial.printf("[TELEMETRY] setHubHashHex: invalid hex at index %u\n", + (unsigned)i); + return; + } + } + memcpy(_hubHex, hex32, 32); + _hubHex[32] = '\0'; + _hubHash.clear(); + _hubHashLoaded = false; + loadHubHashIfNeeded(); + Serial.printf("[TELEMETRY] hub set to %s\n", _hubHex); +} + +void TelemetryManager::resetHubHashToDefault() { + memcpy(_hubHex, "da424e0f47657d7575df58a2b83b111b", 32); + _hubHex[32] = '\0'; + _hubHash.clear(); + _hubHashLoaded = false; + loadHubHashIfNeeded(); + Serial.printf("[TELEMETRY] hub reset to default %s\n", _hubHex); +} + +// ============================================================================= +// Serial glue — handleSerial() is invoked by main.cpp for the single-char +// commands; the line-mode `G<32hex>` form is handled directly in main.cpp +// (where the 32-hex arg is already on the buffer) and lands in +// setHubHashHex(). +// ============================================================================= + +bool TelemetryManager::handleSerial(char c, const char* /*line*/) { + switch (c) { + case 'g': + // Lowercase 'g' = trigger sendNow (privacy-gated). + sendNow(); + return true; + default: + return false; + } +} + +void TelemetryManager::printHelp() const { + Serial.println("[TELEMETRY] g send-GPS-now (privacy-gated)"); + Serial.println("[TELEMETRY] G<32hex> set-hub-hash (default da424e0f47657d7575df58a2b83b111b)"); +} diff --git a/src/reticulum/TelemetryManager.h b/src/reticulum/TelemetryManager.h new file mode 100644 index 0000000..7e17760 --- /dev/null +++ b/src/reticulum/TelemetryManager.h @@ -0,0 +1,116 @@ +// ============================================================================= +// TelemetryManager.h — on-demand GPS telemetry sender for rsDeck #64 +// (Stage 1 / thin hybrid). +// +// Wire format is one opportunistic LXMF packet to a configured collector +// (default: Lyra telemetry-collector hub). The packet carries an +// LXMF FIELD_TELEMETRY value built by `TelemetryEncoder` whose inner +// snapshot matches Sideband's Telemeter.packed(). +// +// Trigger: serial console `g` only. There is no periodic scheduler — the +// caller asserts privacy (no fix / stale fix → refuse) before queuing. +// +// State machine implemented in `loop()` drives the same Reticulum path / +// identity discovery pattern LXMFManager already uses for chat, without +// touching any of the LXMF chat code. +// ============================================================================= +#pragma once + +#include +#include +#include + +#include "TelemetryEncoder.h" + +class GPSManager; +class Power; +class ReticulumManager; +class UserConfig; + +class TelemetryManager { +public: + TelemetryManager(); + + // Wire dependencies at setup time. None may be null when + // sendNow()/loop() are exercised at runtime. + void begin(ReticulumManager* rns, GPSManager* gps, Power* power); + + // Bind UserConfig so sendNow() can read the live opt-in flag + + // hub hash from Settings > Time & Location. Optional — the manager + // still works standalone with its compiled-in default hub hash if + // never bound. Reading is fresh on every sendNow() so toggling the + // "Send Telemetry" item in the UI applies without a live callback. + void setUserConfig(UserConfig* cfg) { _cfg = cfg; } + + // Drive the path/identity discovery retry state machine. Safe to call + // every loop iteration; cheap when no attempt is in flight. + void loop(); + + // Serial helpers — share a 96-byte input buffer with the main + // serial parser. + bool handleSerial(char c, const char* line); + void printHelp() const; + + // Programmatic API — invoked by the serial `g` command. Returns + // false if the privacy gate refuses (no fix / stale fix) or if a + // send is already in flight. + bool sendNow(); + + // 32-hex default hash is set at build time (see .cpp). The serial + // `G` command can update it for the current session. + void setHubHashHex(const char* hex32); + void resetHubHashToDefault(); + const char* hubHashHex() const { return _hubHex; } + + // Status queries (used by serial help / heartbeat diagnostics). + bool hasFreshFix() const; + bool isAttemptInFlight() const { return _state != State::IDLE; } + const char* stateName() const; + + // Privacy gate threshold (ms). 30 s per the rsDeck #64 spec. + static constexpr uint32_t FRESH_FIX_MAX_AGE_MS = 30000UL; + static constexpr uint8_t DISCOVERY_MAX_ATTEMPTS = 10; + +private: + // ----- bound subsystems ----- + ReticulumManager* _rns = nullptr; + GPSManager* _gps = nullptr; + Power* _power = nullptr; + UserConfig* _cfg = nullptr; // optional, gates sendNow() privacy + + // ----- destination / identity ----- + // Default = Lyra telemetry-collector (da424e0f47657d7575df58a2b83b111b). + char _hubHex[33] = "da424e0f47657d7575df58a2b83b111b"; + RNS::Bytes _hubHash; // 16 bytes (truncated dest hash) + bool _hubHashLoaded = false; + + // ----- send state machine ----- + enum class State : uint8_t { + IDLE = 0, + WAIT_IDENTITY, // requested, polling Identity::recall() + WAIT_PATH, // identity known, polling has_path() + SENDING, // path & identity present, packing+sending + }; + State _state = State::IDLE; + uint8_t _attempts = 0; + unsigned long _stateAtMs = 0; + static constexpr unsigned long STATE_RETRY_INTERVAL_MS = 1000; + + // Helpers + void loadHubHashIfNeeded(); + void applyUserConfigHubHash(); + void driveIdentityDiscovery(); + void drivePathDiscovery(); + bool buildAndSendSnapshot(); + void resetToIdle(const char* reason); + uint64_t epochSecondsOrUptime() const; + + // Cheap sanity: snapshot read of GPS -> doubles + struct LocSample { + bool valid = false; + double lat = NAN, lon = NAN, alt = NAN, speed = NAN, bearing = NAN; + double accuracy_m = NAN; + uint32_t age_ms = 0; + }; + LocSample readLocation() const; +}; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 12c64ad..3673b3c 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -13,6 +13,7 @@ #include "transport/WiFiInterface.h" #include "reticulum/ReticulumManager.h" #include "reticulum/IdentityManager.h" +#include "reticulum/AnnounceManager.h" #include #include #include @@ -951,6 +952,49 @@ void LvSettingsScreen::buildItems() { [&s](int v) { s.gpsLocationEnabled = (v != 0); }, [](int v) { return String(onOff(v != 0)); }}); idx++; + // GPS Telemetry (issue #64) — opt-in position sharing to a hub. Off + // by default for privacy. The hub hash persists; the manager reads it + // fresh on every send so changes apply without a live callback. + _items.push_back({"Send Telemetry", SettingType::TOGGLE, + [&s]() { return s.gpsTelemetryEnabled ? 1 : 0; }, + [&s](int v) { s.gpsTelemetryEnabled = (v != 0); }, + [](int v) { return String(onOff(v != 0)); }}); + idx++; + { + // Pick a discovered/saved peer as the telemetry hub. Free-text hex + // entry below still covers hubs not yet announced on the mesh. + SettingItem selectHub; + selectHub.label = "Select Hub"; + selectHub.type = SettingType::ACTION; + selectHub.formatter = [this, &s](int) -> String { + String cur = s.gpsTelemetryHubHash; + if (cur.isEmpty()) return String("[Enter]"); + std::string hex(cur.c_str()); + String name; + if (_am) { + std::string nm = _am->lookupName(hex); + if (!nm.empty()) name = String(nm.c_str()); + } + if (!name.isEmpty()) { + if ((int)name.length() > 10) name = name.substring(0, 10); + return name + " " + cur.substring(0, 8); + } + return cur.substring(0, 8) + "..."; + }; + selectHub.action = [this]() { showPeerPicker(); }; + _items.push_back(selectHub); + idx++; + } + { + SettingItem hubItem; + hubItem.label = "Telemetry Hub"; + hubItem.type = SettingType::TEXT_INPUT; + hubItem.textGetter = [&s]() { return s.gpsTelemetryHubHash; }; + hubItem.textSetter = [&s](const String& v) { s.gpsTelemetryHubHash = v; }; + hubItem.maxTextLen = (int)UserSettings::GPS_TELEMETRY_HUB_HASH_LEN; + _items.push_back(hubItem); + idx++; + } #endif _items.push_back({"Timezone", SettingType::INTEGER, [&s]() { return (int)s.timezoneIdx; }, @@ -1224,6 +1268,8 @@ void LvSettingsScreen::onEnter() { _textEditing = false; _wifiScanActive = false; _wifiTargetSlot = 0; + _peerPickerIndices.clear(); + _peerPickerIdx = 0; clearConfirmations(); _kbBrightness = _cfg ? _cfg->settings().keyboardBrightness : 0; rebuildCategoryList(); @@ -1733,6 +1779,160 @@ void LvSettingsScreen::rebuildWifiList() { } } +void LvSettingsScreen::showPeerPicker() { + _peerPickerIndices.clear(); + _peerPickerIdx = 0; + if (!_am) { + if (_ui) _ui->lvStatusBar().showToast("No peers seen yet", 1500); + return; + } + const auto& nodes = _am->nodes(); + if (nodes.empty()) { + if (_ui) _ui->lvStatusBar().showToast("No peers seen yet", 1500); + return; + } + // Saved contacts first (in their original order), then transient peers. + for (int i = 0; i < (int)nodes.size(); i++) { + if (nodes[i].saved) _peerPickerIndices.push_back(i); + } + for (int i = 0; i < (int)nodes.size(); i++) { + if (!nodes[i].saved) _peerPickerIndices.push_back(i); + } + if ((int)_peerPickerIndices.size() > PEER_PICKER_CAP) { + _peerPickerIndices.resize(PEER_PICKER_CAP); + } + _view = SettingsView::PEER_PICKER; + rebuildPeerList(); +} + +void LvSettingsScreen::rebuildPeerList() { + if (!_scrollContainer) return; + _rowObjs.clear(); + lv_obj_clean(_scrollContainer); + + const lv_font_t* font = &lv_font_rsdeck_12; + + // Header (tappable to go back) + lv_obj_t* headerRow = lv_obj_create(_scrollContainer); + lv_obj_set_size(headerRow, Theme::CONTENT_W, 22); + lv_obj_set_style_bg_opa(headerRow, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_color(headerRow, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_width(headerRow, 1, 0); + lv_obj_set_style_border_side(headerRow, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(headerRow, 0, 0); + lv_obj_set_style_radius(headerRow, 0, 0); + lv_obj_clear_flag(headerRow, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* headerLbl = lv_label_create(headerRow); + lv_obj_set_style_text_font(headerLbl, font, 0); + lv_obj_set_style_text_color(headerLbl, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(headerLbl, "< Select Telemetry Hub"); + lv_obj_align(headerLbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_add_flag(headerRow, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(headerRow, [](lv_event_t* e) { + auto* self = (LvSettingsScreen*)lv_event_get_user_data(e); + self->_view = SettingsView::ITEM_LIST; + self->rebuildItemList(); + }, LV_EVENT_CLICKED, this); + + if (_peerPickerIndices.empty()) { + lv_obj_t* emptyLbl = lv_label_create(_scrollContainer); + lv_obj_set_style_text_font(emptyLbl, font, 0); + lv_obj_set_style_text_color(emptyLbl, lv_color_hex(Theme::TEXT_MUTED), 0); + lv_label_set_text(emptyLbl, "No peers seen yet"); + return; + } + + for (int i = 0; i < (int)_peerPickerIndices.size(); i++) { + int nodeIdx = _peerPickerIndices[i]; + const auto& node = _am->nodes()[nodeIdx]; + std::string hex = node.hash.toHex(); + + lv_obj_t* row = lv_obj_create(_scrollContainer); + lv_obj_set_size(row, Theme::CONTENT_W, 28); + lv_obj_add_style(row, LvTheme::styleListBtn(), 0); + lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0); + lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_user_data(row, (void*)(intptr_t)i); + lv_obj_add_event_cb(row, [](lv_event_t* e) { + auto* self = (LvSettingsScreen*)lv_event_get_user_data(e); + int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e)); + self->selectPeerResult(idx); + }, LV_EVENT_CLICKED, this); + lv_group_add_obj(LvInput::group(), row); + lv_obj_add_event_cb(row, [](lv_event_t* e) { + lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON); + }, LV_EVENT_FOCUSED, nullptr); + + // Primary line: name (if known) or short hash + std::string name = node.name; + if (name.empty()) name = _am->lookupName(hex); + char line[80]; + if (!name.empty()) { + // Truncate name to fit alongside the hash suffix on one line + int nlen = (int)name.size(); + if (nlen > 22) { + snprintf(line, sizeof(line), "%.22s %s...", name.c_str(), hex.substr(0, 8).c_str()); + } else { + snprintf(line, sizeof(line), "%s %s...", name.c_str(), hex.substr(0, 8).c_str()); + } + } else { + snprintf(line, sizeof(line), "%s...", hex.substr(0, 8).c_str()); + } + lv_obj_t* lbl = lv_label_create(row); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); + clipLabel(lbl, Theme::CONTENT_W - 12); + lv_label_set_text(lbl, line); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + + // Saved-contact star marker + if (node.saved) { + lv_obj_t* star = lv_label_create(row); + lv_obj_set_style_text_font(star, font, 0); + lv_obj_set_style_text_color(star, lv_color_hex(Theme::ACCENT), 0); + lv_label_set_text(star, "*"); + lv_obj_align(star, LV_ALIGN_RIGHT_MID, -4, 0); + } + + _rowObjs.push_back(row); + } + + if (_peerPickerIdx >= 0 && _peerPickerIdx < (int)_rowObjs.size()) { + lv_group_focus_obj(_rowObjs[_peerPickerIdx]); + } +} + +void LvSettingsScreen::selectPeerResult(int listIdx) { + if (!_cfg || !_am) return; + if (listIdx < 0 || listIdx >= (int)_peerPickerIndices.size()) return; + const auto& node = _am->nodes()[_peerPickerIndices[listIdx]]; + std::string hex = node.hash.toHex(); + if (hex.size() != (size_t)UserSettings::GPS_TELEMETRY_HUB_HASH_LEN) { + if (_ui) _ui->lvStatusBar().showToast("Invalid peer hash", 1500); + return; + } + // Save in memory; persist on the normal settings-save path (matches + // WiFi picker behavior — no flash write until the user backs out). + _cfg->settings().gpsTelemetryHubHash = String(hex.c_str()); + String toast; + std::string name = node.name; + if (name.empty()) name = _am->lookupName(hex); + if (!name.empty()) { + toast = String("Hub set: ") + String(name.c_str()); + } else { + toast = String("Hub set: ") + String(hex.substr(0, 8).c_str()) + "..."; + } + if (_ui) _ui->lvStatusBar().showToast(toast.c_str(), 1500); + _view = SettingsView::ITEM_LIST; + rebuildItemList(); +} + void LvSettingsScreen::updateCategorySelection(int oldIdx, int newIdx) { if (oldIdx >= 0 && oldIdx < (int)_rowObjs.size()) { bool pending = categoryNeedsReboot(oldIdx); @@ -1787,6 +1987,20 @@ void LvSettingsScreen::updateWifiSelection(int oldIdx, int newIdx) { } } +void LvSettingsScreen::updatePeerSelection(int oldIdx, int newIdx) { + if (oldIdx >= 0 && oldIdx < (int)_rowObjs.size()) { + lv_obj_set_style_bg_color(_rowObjs[oldIdx], lv_color_hex(Theme::BG), 0); + lv_obj_t* lbl = lv_obj_get_child(_rowObjs[oldIdx], 0); + if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); + } + if (newIdx >= 0 && newIdx < (int)_rowObjs.size()) { + lv_obj_set_style_bg_color(_rowObjs[newIdx], lv_color_hex(Theme::BG_HOVER), 0); + lv_obj_t* lbl = lv_obj_get_child(_rowObjs[newIdx], 0); + if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::TEXT_PRIMARY), 0); + lv_obj_scroll_to_view(_rowObjs[newIdx], LV_ANIM_OFF); + } +} + void LvSettingsScreen::enterCategory(int catIdx) { if (catIdx < 0 || catIdx >= (int)_categories.size()) return; _categoryIdx = catIdx; @@ -2032,6 +2246,29 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) { } return false; } + + case SettingsView::PEER_PICKER: { + if (event.enter || event.character == '\n' || event.character == '\r') { + lv_obj_t* focused = lv_group_get_focused(LvInput::group()); + if (focused) { + int idx = (int)(intptr_t)lv_obj_get_user_data(focused); + if (idx >= 0 && idx < (int)_peerPickerIndices.size()) { + selectPeerResult(idx); + return true; + } + } + // No focused row — bail out to item list + _view = SettingsView::ITEM_LIST; + rebuildItemList(); + return true; + } + if (event.del || event.character == 8 || event.character == 0x1B) { + _view = SettingsView::ITEM_LIST; + rebuildItemList(); + return true; + } + return false; + } } return false; } diff --git a/src/ui/screens/LvSettingsScreen.h b/src/ui/screens/LvSettingsScreen.h index 7881906..c62794c 100644 --- a/src/ui/screens/LvSettingsScreen.h +++ b/src/ui/screens/LvSettingsScreen.h @@ -17,6 +17,7 @@ class WiFiInterface; class TCPClientInterface; class ReticulumManager; class IdentityManager; +class AnnounceManager; enum class SettingType : uint8_t { READONLY, @@ -30,7 +31,8 @@ enum class SettingType : uint8_t { enum class SettingsView : uint8_t { CATEGORY_LIST, ITEM_LIST, - WIFI_PICKER + WIFI_PICKER, + PEER_PICKER }; struct SettingItem { @@ -74,6 +76,7 @@ class LvSettingsScreen : public LvScreen { void setTCPClients(std::vector* tcp) { _tcp = tcp; } void setRNS(ReticulumManager* rns) { _rns = rns; } void setIdentityManager(IdentityManager* idm) { _idMgr = idm; } + void setAnnounceManager(AnnounceManager* am) { _am = am; } void setUIManager(UIManager* ui) { _ui = ui; } void setIdentityHash(const String& hash) { _identityHash = hash; } void setDestinationHash(const String& hash) { _destinationHash = hash; } @@ -95,16 +98,20 @@ class LvSettingsScreen : public LvScreen { void showCategoryList(); void showItemList(int catIdx); void showWifiPicker(); + void showPeerPicker(); void rebuildCategoryList(); void rebuildItemList(); void rebuildWifiList(); + void rebuildPeerList(); void selectWifiResult(int resultIdx); + void selectPeerResult(int listIdx); void enterCategory(int catIdx); void exitToCategories(); void updateCategorySelection(int oldIdx, int newIdx); void updateItemSelection(int oldIdx, int newIdx); void updateWifiSelection(int oldIdx, int newIdx); + void updatePeerSelection(int oldIdx, int newIdx); bool settingNeedsReboot(const SettingItem& item) const; bool categoryNeedsReboot(int catIdx) const; bool confirmableAction(const SettingItem& item) const; @@ -131,6 +138,7 @@ class LvSettingsScreen : public LvScreen { std::vector* _tcp = nullptr; ReticulumManager* _rns = nullptr; IdentityManager* _idMgr = nullptr; + AnnounceManager* _am = nullptr; UIManager* _ui = nullptr; String _identityHash; String _destinationHash; @@ -184,6 +192,11 @@ class LvSettingsScreen : public LvScreen { int _wifiPickerIdx = 0; size_t _wifiTargetSlot = 0; bool _wifiScanActive = false; + + // Peer picker (seen-announce picker for telemetry hub) + std::vector _peerPickerIndices; // indices into _am->nodes() + int _peerPickerIdx = 0; + static constexpr int PEER_PICKER_CAP = 50; // Throttled live-value refresh (Voltage / Estimated Charge readonly // rows) while sitting on the item list - without this, those labels // only update on navigation/edit events and can show a stale snapshot From 0480d240784f27dc3f79e4dd637333d12a03e9da Mon Sep 17 00:00:00 2001 From: Josh Bowyer Date: Wed, 12 Aug 2026 12:11:28 -0500 Subject: [PATCH 04/29] rsDeck #64: GPS status UI (fix age/sats), periodic telemetry send interval - LvHomeScreen: GPS stat panel shows NO FIX / FIX s / STALE instead of binary ON/OFF, age always paired with fix state - UserConfig: gpsTelemetryIntervalS (0=on-demand only, 60s floor, 86400s ceiling), persisted + sanitized - Settings UI: Telemetry Interval (s) item in Time & Location, Off when 0 - TelemetryManager: checkPeriodicSend() reuses sendNow()'s privacy gate (fresh-fix, enabled toggle, hub hash) - no duplicated logic - main.cpp: wire GPSManager into LvHomeScreen --- src/config/UserConfig.cpp | 18 +++++++++ src/config/UserConfig.h | 11 ++++++ src/main.cpp | 1 + src/reticulum/TelemetryManager.cpp | 58 ++++++++++++++++++++++++++--- src/reticulum/TelemetryManager.h | 23 +++++++++++- src/ui/screens/LvHomeScreen.cpp | 46 ++++++++++++++++++++--- src/ui/screens/LvHomeScreen.h | 3 ++ src/ui/screens/LvSettingsScreen.cpp | 16 ++++++++ 8 files changed, 163 insertions(+), 13 deletions(-) diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp index b9326a0..a9a0982 100644 --- a/src/config/UserConfig.cpp +++ b/src/config/UserConfig.cpp @@ -49,6 +49,19 @@ void UserConfig::sanitizeSettings() { _settings.gpsTelemetryHubHash = UserSettings::GPS_TELEMETRY_DEFAULT_HUB_HASH; } } + + // GPS telemetry periodic interval (seconds). 0 is a valid "off" value + // (the on-demand-only default). Negative values are coerced to 0. Any + // positive value below the LoRa channel-protection floor is clamped + // up to the floor; values above the ceiling are clamped down. + if (_settings.gpsTelemetryIntervalS < 0) { + _settings.gpsTelemetryIntervalS = 0; + } else if (_settings.gpsTelemetryIntervalS > 0 && + _settings.gpsTelemetryIntervalS < UserSettings::GPS_TELEMETRY_INTERVAL_MIN_S) { + _settings.gpsTelemetryIntervalS = UserSettings::GPS_TELEMETRY_INTERVAL_MIN_S; + } else if (_settings.gpsTelemetryIntervalS > UserSettings::GPS_TELEMETRY_INTERVAL_MAX_S) { + _settings.gpsTelemetryIntervalS = UserSettings::GPS_TELEMETRY_INTERVAL_MAX_S; + } } bool UserConfig::parseJson(const String& json) { @@ -152,6 +165,10 @@ bool UserConfig::parseJson(const String& json) { // GPS telemetry (issue #64) — opt-in position sharing to a hub. _settings.gpsTelemetryEnabled = doc["gps_telemetry_enabled"] | false; _settings.gpsTelemetryHubHash = doc["gps_telemetry_hub"] | UserSettings::GPS_TELEMETRY_DEFAULT_HUB_HASH; + // Periodic interval (seconds). 0 = on-demand only (default). Non-zero + // values are clamped in sanitizeSettings() to enforce the LoRa + // channel-protection minimum. + _settings.gpsTelemetryIntervalS = doc["gps_telemetry_interval_s"] | 0; _settings.audioEnabled = doc["audio_on"] | true; _settings.audioVolume = doc["audio_vol"] | 80; @@ -238,6 +255,7 @@ String UserConfig::serializeToJson() { // GPS telemetry (issue #64) — opt-in position sharing to a hub. doc["gps_telemetry_enabled"] = _settings.gpsTelemetryEnabled; doc["gps_telemetry_hub"] = _settings.gpsTelemetryHubHash; + doc["gps_telemetry_interval_s"] = _settings.gpsTelemetryIntervalS; doc["audio_on"] = _settings.audioEnabled; doc["audio_vol"] = _settings.audioVolume; diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index 2e3b23e..d19da7d 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -121,6 +121,17 @@ struct UserSettings { static constexpr size_t GPS_TELEMETRY_HUB_HASH_LEN = 32; static constexpr const char* GPS_TELEMETRY_DEFAULT_HUB_HASH = "da424e0f47657d7575df58a2b83b111b"; + // Periodic telemetry cadence (seconds). 0 = on-demand only (default): + // periodic sending must never silently start just because the user + // enabled "Send Telemetry"; the user must also pick a non-zero + // interval explicitly. Non-zero values are clamped to + // [GPS_TELEMETRY_INTERVAL_MIN_S, GPS_TELEMETRY_INTERVAL_MAX_S] by + // sanitizeSettings(). The 60 s minimum protects the LoRa channel + // from being hammered by a careless 1-second setting. + int gpsTelemetryIntervalS = 0; + static constexpr int GPS_TELEMETRY_INTERVAL_MIN_S = 60; // 1 min floor + static constexpr int GPS_TELEMETRY_INTERVAL_MAX_S = 86400; // 24 h ceiling + // Audio bool audioEnabled = true; uint8_t audioVolume = 80; // 0-100 diff --git a/src/main.cpp b/src/main.cpp index 8729688..68883ed 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1655,6 +1655,7 @@ void setup() { lvHomeScreen.setRadio(&radio); lvHomeScreen.setUserConfig(&userConfig); lvHomeScreen.setLXMFManager(&lxmf); + lvHomeScreen.setGPSManager(&gps); lvHomeScreen.setAnnounceManager(announceManager); lvHomeScreen.setRadioOnline(radioOnline); lvHomeScreen.setTCPClients(&tcpClients); diff --git a/src/reticulum/TelemetryManager.cpp b/src/reticulum/TelemetryManager.cpp index 397dbee..01fc0ff 100644 --- a/src/reticulum/TelemetryManager.cpp +++ b/src/reticulum/TelemetryManager.cpp @@ -1,12 +1,18 @@ // ============================================================================= // TelemetryManager.cpp — see TelemetryManager.h. // -// Stage 1 rsDeck #64 (thin hybrid): on-demand opportunistic LXMF push of -// a Sideband-shaped FIELD_TELEMETRY value to a configured collector. +// Stage 1 rsDeck #64 (thin hybrid): on-demand + optional periodic +// opportunistic LXMF push of a Sideband-shaped FIELD_TELEMETRY value to +// a configured collector. // // Critical constraints (per the issue brief): -// * No periodic scheduler. The serial `g` command is the only trigger. -// * Privacy gate refuses if no fresh fix (≤ FRESH_FIX_MAX_AGE_MS old). +// * Two triggers: serial `g` (on-demand) and a periodic tick driven +// by UserSettings::gpsTelemetryIntervalS (issue #64 "configurable by +// interval" privacy requirement). 0 = periodic disabled (default). +// * Privacy gate refuses if no fresh fix (≤ FRESH_FIX_MAX_AGE_MS old) +// for both triggers — periodic must never force-send with a stale +// or absent fix; the periodic tick just skips and retries on the +// next interval. // * No edits to LXMFManager.{h,cpp} — the chat path is untouched. // * No edits to the microReticulum library — the sign + wrap is a // clone of LXMFMessage::packFull() from upstream, kept inline here @@ -166,6 +172,39 @@ bool TelemetryManager::hasFreshFix() const { #endif } +void TelemetryManager::checkPeriodicSend() { + // Configuration gate. All four conditions must hold for a periodic + // tick to fire: + // 1. UserConfig is bound (so we have a live opt-in / interval). + // 2. gpsTelemetryEnabled is true (master opt-in switch). + // 3. gpsTelemetryIntervalS > 0 (0 = on-demand only; periodic must + // never start silently just because the user enabled the + // toggle). + // 4. Enough time has elapsed since the last periodic attempt. + // sendNow() re-reads _cfg->settings() on every call and enforces its + // own privacy gate (fresh fix ≤ FRESH_FIX_MAX_AGE_MS); we do NOT + // re-implement that gate here so the two paths cannot drift. + if (!_cfg) return; + const UserSettings& s = _cfg->settings(); + if (!s.gpsTelemetryEnabled) return; + const int intervalS = s.gpsTelemetryIntervalS; + if (intervalS <= 0) return; + + const unsigned long intervalMs = (unsigned long)intervalS * 1000UL; + if (millis() - _lastPeriodicSendMs < intervalMs) return; + + // Advance the timer *before* sendNow() so a refused send (e.g. fix + // not fresh, transport inactive) does not retry on every loop() + // iteration — next periodic attempt happens at the configured + // cadence regardless of refusal reason. sendNow() logs its own + // refusal reason, so the serial console still surfaces why the + // periodic tick was skipped without becoming a per-loop() spam. + _lastPeriodicSendMs = millis(); + + Serial.printf("[TELEMETRY] periodic tick firing (interval=%ds)\n", intervalS); + sendNow(); +} + const char* TelemetryManager::stateName() const { switch (_state) { case State::IDLE: return "IDLE"; @@ -181,7 +220,16 @@ const char* TelemetryManager::stateName() const { // ============================================================================= void TelemetryManager::loop() { - if (_state == State::IDLE) return; + // Periodic send tick runs only while the state machine is IDLE — an + // in-flight on-demand send (or a periodic send that just started) + // must not have another periodic attempt piled on top of it. The + // check itself is gated on the live UserConfig so toggling + // gpsTelemetryEnabled off or changing the interval takes effect + // immediately at the next loop() tick without needing a callback. + if (_state == State::IDLE) { + checkPeriodicSend(); + return; + } // Unsigned-subtraction idiom matches LXMFManager — safe as long as // STATE_RETRY_INTERVAL_MS is small relative to millis() wrap // (~49 days). diff --git a/src/reticulum/TelemetryManager.h b/src/reticulum/TelemetryManager.h index 7e17760..28412bd 100644 --- a/src/reticulum/TelemetryManager.h +++ b/src/reticulum/TelemetryManager.h @@ -7,8 +7,14 @@ // LXMF FIELD_TELEMETRY value built by `TelemetryEncoder` whose inner // snapshot matches Sideband's Telemeter.packed(). // -// Trigger: serial console `g` only. There is no periodic scheduler — the -// caller asserts privacy (no fix / stale fix → refuse) before queuing. +// Triggers: +// * Serial console `g` (on-demand, privacy-gated). +// * Periodic tick driven by `UserSettings::gpsTelemetryIntervalS` +// (issue #64 "configurable by interval" privacy requirement). 0 +// disables periodic sending — the same on-demand gate (fresh fix ≤ +// FRESH_FIX_MAX_AGE_MS) must pass for both paths. The periodic +// timer is advanced even on a refused send so a persistently stale +// fix doesn't trigger a retry-every-loop() spam. // // State machine implemented in `loop()` drives the same Reticulum path / // identity discovery pattern LXMFManager already uses for chat, without @@ -105,6 +111,19 @@ class TelemetryManager { void resetToIdle(const char* reason); uint64_t epochSecondsOrUptime() const; + // Periodic-send gate. Called from loop() when the state machine is + // IDLE. Reads the live UserConfig, so toggling "Send Telemetry" or + // changing the interval takes effect on the next loop tick without a + // callback. Reuses sendNow() so the privacy gate is enforced + // identically between on-demand and periodic paths. + void checkPeriodicSend(); + + // Periodic-send bookkeeping. _lastPeriodicSendMs is updated *before* + // sendNow() so a refused send (e.g. stale fix) does not retry on + // every loop() iteration — the next attempt is scheduled at the + // configured interval from this tick. + unsigned long _lastPeriodicSendMs = 0; + // Cheap sanity: snapshot read of GPS -> doubles struct LocSample { bool valid = false; diff --git a/src/ui/screens/LvHomeScreen.cpp b/src/ui/screens/LvHomeScreen.cpp index 3442f24..40e2aef 100644 --- a/src/ui/screens/LvHomeScreen.cpp +++ b/src/ui/screens/LvHomeScreen.cpp @@ -371,14 +371,48 @@ void LvHomeScreen::refreshUI() { #if HAS_GPS bool gpsOn = _cfg && _cfg->settings().gpsTimeEnabled; + if (_gps && gpsOn) { + bool hasFix = _gps->hasLocationFix(); + uint32_t ageMs = _gps->fixAgeMs(); + bool everFixed = (ageMs != UINT32_MAX); + bool stale = everFixed && (ageMs > 60000); + String status; + uint32_t color; + if (!everFixed) { + status = "NO FIX"; + color = Theme::TEXT_MUTED; + } else if (hasFix && !stale) { + status = "FIX"; + if (_gps->satellites() > 0) status += " " + String(_gps->satellites()) + "s"; + color = Theme::SUCCESS; + } else { + status = "STALE"; + color = Theme::WARNING_CLR; + } + if (everFixed) { + char ageStr[16]; + uint32_t secs = ageMs / 1000; + if (secs < 60) snprintf(ageStr, sizeof(ageStr), "%lus", (unsigned long)secs); + else snprintf(ageStr, sizeof(ageStr), "%lum", (unsigned long)(secs/60)); + status += " "; + status += ageStr; + } + lv_label_set_text(_lblLinks, status.c_str()); + lv_obj_set_style_text_color(_lblLinks, lv_color_hex(color), 0); + uint32_t border = hasFix && !stale ? Theme::SUCCESS : (everFixed ? Theme::WARNING_CLR : Theme::BORDER); + setPanelTone(_statLinks, gpsOn ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED, border); + } else { + lv_label_set_text(_lblLinks, gpsOn ? "ON" : "OFF"); + lv_obj_set_style_text_color(_lblLinks, lv_color_hex( + gpsOn ? Theme::SUCCESS : Theme::TEXT_MUTED), 0); + setPanelTone(_statLinks, gpsOn ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED, + gpsOn ? Theme::PRIMARY : Theme::BORDER); + } #else - bool gpsOn = false; + lv_label_set_text(_lblLinks, "OFF"); + lv_obj_set_style_text_color(_lblLinks, lv_color_hex(Theme::TEXT_MUTED), 0); + setPanelTone(_statLinks, Theme::BG_ELEVATED, Theme::BORDER); #endif - lv_label_set_text(_lblLinks, gpsOn ? "ON" : "OFF"); - lv_obj_set_style_text_color(_lblLinks, lv_color_hex( - gpsOn ? Theme::SUCCESS : Theme::TEXT_MUTED), 0); - setPanelTone(_statLinks, gpsOn ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED, - gpsOn ? Theme::PRIMARY : Theme::BORDER); if (!_rns) { lv_label_set_text(_lblSummary, "Identity unavailable"); diff --git a/src/ui/screens/LvHomeScreen.h b/src/ui/screens/LvHomeScreen.h index 1ac9a58..14df88b 100644 --- a/src/ui/screens/LvHomeScreen.h +++ b/src/ui/screens/LvHomeScreen.h @@ -1,6 +1,7 @@ #pragma once #include "ui/UIManager.h" +#include "hal/GPSManager.h" #include #include #include @@ -33,6 +34,7 @@ class LvHomeScreen : public LvScreen { void setWiFiToggleCallback(std::function cb) { _wifiToggleCb = cb; } void setGPSToggleCallback(std::function cb) { _gpsToggleCb = cb; } void setPeersCallback(std::function cb) { _peersCb = cb; } + void setGPSManager(GPSManager* gps) { _gps = gps; } const char* title() const override { return "Home"; } @@ -51,6 +53,7 @@ class LvHomeScreen : public LvScreen { std::function _wifiToggleCb; std::function _gpsToggleCb; std::function _peersCb; + GPSManager* _gps = nullptr; unsigned long _lastRefreshMs = 0; unsigned long _lastUptime = 0; uint32_t _lastHeap = 0; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 3673b3c..0b5d44d 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -995,6 +995,22 @@ void LvSettingsScreen::buildItems() { _items.push_back(hubItem); idx++; } + // Periodic telemetry interval (issue #64 "configurable by interval" + // privacy requirement). 0 = on-demand only (default — periodic must + // never start silently just because "Send Telemetry" is enabled; the + // user must pick a non-zero interval explicitly). Non-zero values are + // clamped at sanitizeSettings() to the LoRa channel-protection floor. + _items.push_back({"Telemetry Interval (s)", SettingType::INTEGER, + [&s]() { return s.gpsTelemetryIntervalS; }, + [&s](int v) { s.gpsTelemetryIntervalS = v; }, + [](int v) -> String { + // 0 is the on-demand-only sentinel; present it as "Off" so the + // user is never confused about whether periodic is running. + if (v == 0) return String("Off"); + return String(v) + "s"; + }, + 0, UserSettings::GPS_TELEMETRY_INTERVAL_MAX_S, 60}); + idx++; #endif _items.push_back({"Timezone", SettingType::INTEGER, [&s]() { return (int)s.timezoneIdx; }, From 443d5702d6fbe93bc9be0bc1084f4c7f4cf2f823 Mon Sep 17 00:00:00 2001 From: Josh Bowyer Date: Wed, 12 Aug 2026 12:51:59 -0500 Subject: [PATCH 05/29] rsDeck: fix GPS status UI jitter, add periodic-send skip diagnostics - LvHomeScreen: sticky UI-only checkpoint timer for GPS fix age display, decoupled from GPSManager's raw fixAgeMs() which resets on every NMEA sentence (~1Hz jitter). Counts 0->30s continuously, resets to 0 at 30s if still receiving, else rolls into STALE Ns uninterrupted. Fixed confusing sat-count 's' suffix (was concatenated with age, e.g. 'FIX 8s 4s') now '(N sat)'. Stale threshold corrected to 30s per privacy-gate parity with TelemetryManager's FRESH_FIX_MAX_AGE_MS. - TelemetryManager: log once per cause when periodic send is skipped due to cfg not bound / telemetry disabled / interval=0. Confirmed via live serial test that periodic ticks fire exactly on schedule and are correctly refused by the existing no-fix privacy gate when indoors - not a bug, working as designed. --- src/reticulum/TelemetryManager.cpp | 22 +++++++++++++--- src/reticulum/TelemetryManager.h | 7 ++++++ src/ui/screens/LvHomeScreen.cpp | 40 ++++++++++++++++++++++++------ src/ui/screens/LvHomeScreen.h | 8 ++++++ 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/reticulum/TelemetryManager.cpp b/src/reticulum/TelemetryManager.cpp index 01fc0ff..bfbb47d 100644 --- a/src/reticulum/TelemetryManager.cpp +++ b/src/reticulum/TelemetryManager.cpp @@ -172,6 +172,12 @@ bool TelemetryManager::hasFreshFix() const { #endif } +void TelemetryManager::_logPeriodicSkipOnce(const char* reason) { + if (_periodicSkipLogged) return; + _periodicSkipLogged = true; + Serial.printf("[TELEMETRY] periodic disabled: %s\n", reason); +} + void TelemetryManager::checkPeriodicSend() { // Configuration gate. All four conditions must hold for a periodic // tick to fire: @@ -184,11 +190,21 @@ void TelemetryManager::checkPeriodicSend() { // sendNow() re-reads _cfg->settings() on every call and enforces its // own privacy gate (fresh fix ≤ FRESH_FIX_MAX_AGE_MS); we do NOT // re-implement that gate here so the two paths cannot drift. - if (!_cfg) return; + if (!_cfg) { + _logPeriodicSkipOnce("cfg not bound"); + return; + } const UserSettings& s = _cfg->settings(); - if (!s.gpsTelemetryEnabled) return; + if (!s.gpsTelemetryEnabled) { + _logPeriodicSkipOnce("gpsTelemetryEnabled=false"); + return; + } const int intervalS = s.gpsTelemetryIntervalS; - if (intervalS <= 0) return; + if (intervalS <= 0) { + _logPeriodicSkipOnce("gpsTelemetryIntervalS<=0 (Off)"); + return; + } + _periodicSkipLogged = false; // config is valid; re-arm the one-shot skip log const unsigned long intervalMs = (unsigned long)intervalS * 1000UL; if (millis() - _lastPeriodicSendMs < intervalMs) return; diff --git a/src/reticulum/TelemetryManager.h b/src/reticulum/TelemetryManager.h index 28412bd..0588bd2 100644 --- a/src/reticulum/TelemetryManager.h +++ b/src/reticulum/TelemetryManager.h @@ -118,6 +118,13 @@ class TelemetryManager { // identically between on-demand and periodic paths. void checkPeriodicSend(); + // One-shot diagnostic log for why checkPeriodicSend() isn't arming + // (cfg unbound / toggle off / interval 0). Logs once per cause change + // instead of spamming every loop() tick, so "why isn't periodic + // firing?" is answerable from the serial console without guessing. + void _logPeriodicSkipOnce(const char* reason); + bool _periodicSkipLogged = false; + // Periodic-send bookkeeping. _lastPeriodicSendMs is updated *before* // sendNow() so a refused send (e.g. stale fix) does not retry on // every loop() iteration — the next attempt is scheduled at the diff --git a/src/ui/screens/LvHomeScreen.cpp b/src/ui/screens/LvHomeScreen.cpp index 40e2aef..7da19f8 100644 --- a/src/ui/screens/LvHomeScreen.cpp +++ b/src/ui/screens/LvHomeScreen.cpp @@ -372,18 +372,44 @@ void LvHomeScreen::refreshUI() { #if HAS_GPS bool gpsOn = _cfg && _cfg->settings().gpsTimeEnabled; if (_gps && gpsOn) { - bool hasFix = _gps->hasLocationFix(); - uint32_t ageMs = _gps->fixAgeMs(); - bool everFixed = (ageMs != UINT32_MAX); - bool stale = everFixed && (ageMs > 60000); + // Sticky checkpoint counter (see LvHomeScreen.h comment). This is + // deliberately decoupled from GPSManager::fixAgeMs(), which resets + // on every ~1Hz NMEA sentence and would otherwise make the UI + // jitter between small values every refresh. + static constexpr unsigned long CHECK_WINDOW_MS = 30000; // 30s per spec + bool receivingNow = _gps->hasLocationFix(); + unsigned long nowMs = millis(); + + if (receivingNow && !_gpsCheckpointSet) { + // First fix ever, or first fix after having gone fully idle — + // start a fresh 0s..30s cycle. + _gpsCheckpointMs = nowMs; + _gpsCheckpointSet = true; + } + + unsigned long ageMs = _gpsCheckpointSet ? (nowMs - _gpsCheckpointMs) : 0; + if (_gpsCheckpointSet && ageMs >= CHECK_WINDOW_MS) { + if (receivingNow) { + // Checkpoint reached and still receiving -> reset to FIX 0s. + _gpsCheckpointMs = nowMs; + ageMs = 0; + } + // else: not receiving -> leave checkpoint alone; ageMs keeps + // growing past 30s uninterrupted (STALE 31s, 32s, ...). + } + + bool everFixed = _gpsCheckpointSet; + bool stale = everFixed && (ageMs >= CHECK_WINDOW_MS); String status; uint32_t color; if (!everFixed) { status = "NO FIX"; color = Theme::TEXT_MUTED; - } else if (hasFix && !stale) { + } else if (!stale) { status = "FIX"; - if (_gps->satellites() > 0) status += " " + String(_gps->satellites()) + "s"; + if (_gps->satellites() > 0) { + status += " (" + String(_gps->satellites()) + " sat)"; + } color = Theme::SUCCESS; } else { status = "STALE"; @@ -399,7 +425,7 @@ void LvHomeScreen::refreshUI() { } lv_label_set_text(_lblLinks, status.c_str()); lv_obj_set_style_text_color(_lblLinks, lv_color_hex(color), 0); - uint32_t border = hasFix && !stale ? Theme::SUCCESS : (everFixed ? Theme::WARNING_CLR : Theme::BORDER); + uint32_t border = (everFixed && !stale) ? Theme::SUCCESS : (everFixed ? Theme::WARNING_CLR : Theme::BORDER); setPanelTone(_statLinks, gpsOn ? Theme::PRIMARY_SUBTLE : Theme::BG_ELEVATED, border); } else { lv_label_set_text(_lblLinks, gpsOn ? "ON" : "OFF"); diff --git a/src/ui/screens/LvHomeScreen.h b/src/ui/screens/LvHomeScreen.h index 14df88b..3dbc6ba 100644 --- a/src/ui/screens/LvHomeScreen.h +++ b/src/ui/screens/LvHomeScreen.h @@ -54,6 +54,14 @@ class LvHomeScreen : public LvScreen { std::function _gpsToggleCb; std::function _peersCb; GPSManager* _gps = nullptr; + // GPS status display state — decoupled from GPSManager::fixAgeMs() + // (which resets on every ~1Hz NMEA sentence and would jitter the UI). + // Instead we show a sticky "checkpoint" counter: while receiving, + // it counts 0s -> 30s; at the 30s mark it either resets to 0s (if + // still receiving) or flips to STALE and keeps counting up from + // there uninterrupted until a fresh fix arrives again. + unsigned long _gpsCheckpointMs = 0; + bool _gpsCheckpointSet = false; unsigned long _lastRefreshMs = 0; unsigned long _lastUptime = 0; uint32_t _lastHeap = 0; From 10ec198c41316ec5b06370569c895f2f10aaf477 Mon Sep 17 00:00:00 2001 From: Josh Bowyer Date: Wed, 12 Aug 2026 13:08:51 -0500 Subject: [PATCH 06/29] rsDeck: add telemetry diagnostic counters for periodic-send failure isolation Additive instrumentation only, no gating/threshold/behavior changes. Counters: ticksFired, refusedNoFix, refusedStale, discoveryAborted, txAccepted. Serial 'V' command dumps [TELEMETRY-STATUS] one-liner; counters also appended to the existing [GPS] heart-line. Purpose: distinguish RF-out-of-range vs GPS-staleness-gate-mismatch during the outstanding 47-min periodic-send silent-failure investigation. --- src/main.cpp | 19 +++++++++-- src/reticulum/TelemetryManager.cpp | 53 ++++++++++++++++++++++++++++++ src/reticulum/TelemetryManager.h | 30 +++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 68883ed..439a62a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -982,7 +982,7 @@ static void handleSerialLineCommand(const char* line) { } static void printSerialHelp() { - Serial.println("[SERIAL] commands: ? help | a announce | t raw-test | d diag | r rssi | i irq | p tx-power-cycle | m min-power | q iq | b battery | g telemetry-send | +/- freq"); + Serial.println("[SERIAL] commands: ? help | a announce | t raw-test | d diag | r rssi | i irq | p tx-power-cycle | m min-power | q iq | b battery | g telemetry-send | V telemetry-status | +/- freq"); Serial.println("[SERIAL] line commands: F exact-frequency | P exact-tx-power | L [dest_hash] LXMF test | B set-adc-divider-ratio | G<32hex> telemetry-hub"); Serial.println("[SERIAL] lite relay diag: H [dest] Header2 data | J [dest] linkreq | K link-data | Y link-proof"); telemetryManager.printHelp(); @@ -1085,6 +1085,13 @@ static void handleSerialCommands() { case 'g': telemetryManager.handleSerial('g', nullptr); break; + case 'v': + case 'V': + // 'V' = telemetry status dump (counters + state). + // Avoided 'T' because it is already mapped to + // onHotkeyRadioTest() at the case 't'/'T' branch above. + telemetryManager.handleSerial('V', nullptr); + break; case '+': case '=': nudgeDiagnosticFrequency(1000); @@ -2385,12 +2392,18 @@ void loop() { } #if HAS_GPS if (userConfig.settings().gpsTimeEnabled) { - Serial.printf("[GPS] sats=%d timeFix=%s locFix=%s syncs=%lu chars=%lu\n", + const auto tc = telemetryManager.counters(); + Serial.printf("[GPS] sats=%d timeFix=%s locFix=%s syncs=%lu chars=%lu telemTicks=%lu telemNoFix=%lu telemStale=%lu telemAbort=%lu telemTx=%lu\n", gps.satellites(), gps.hasTimeFix() ? "YES" : "NO", gps.hasLocationFix() ? "YES" : "NO", (unsigned long)gps.timeSyncCount(), - (unsigned long)gps.charsProcessed()); + (unsigned long)gps.charsProcessed(), + (unsigned long)tc.ticksFired, + (unsigned long)tc.refusedNoFix, + (unsigned long)tc.refusedStale, + (unsigned long)tc.discoveryAborted, + (unsigned long)tc.txAccepted); } #endif maxLoopTime = 0; diff --git a/src/reticulum/TelemetryManager.cpp b/src/reticulum/TelemetryManager.cpp index bfbb47d..9a6100c 100644 --- a/src/reticulum/TelemetryManager.cpp +++ b/src/reticulum/TelemetryManager.cpp @@ -137,10 +137,22 @@ bool TelemetryManager::sendNow() { const LocSample loc = readLocation(); if (!loc.valid) { + // Distinguish "no fix at all" vs "fix exists but too old" for + // the diagnostic counters. Log message kept identical to + // existing behavior (the combined !loc.valid check catches + // both cases at the same point). The counters are purely + // additive — no change to gating decision. +#if HAS_GPS + if (_gps && _gps->hasLocationFix()) _refusedStale++; + else _refusedNoFix++; +#else + _refusedNoFix++; +#endif Serial.println("[TELEMETRY] refused: no GPS fix"); return false; } if (loc.age_ms > FRESH_FIX_MAX_AGE_MS) { + _refusedStale++; Serial.printf("[TELEMETRY] refused: GPS fix stale (%lu ms > %lu ms)\n", (unsigned long)loc.age_ms, (unsigned long)FRESH_FIX_MAX_AGE_MS); @@ -216,6 +228,7 @@ void TelemetryManager::checkPeriodicSend() { // refusal reason, so the serial console still surfaces why the // periodic tick was skipped without becoming a per-loop() spam. _lastPeriodicSendMs = millis(); + _ticksFired++; Serial.printf("[TELEMETRY] periodic tick firing (interval=%ds)\n", intervalS); sendNow(); @@ -520,6 +533,7 @@ bool TelemetryManager::buildAndSendSnapshot() { Serial.println("[TELEMETRY] Packet::send: no interface accepted the packet"); return false; } + _txAccepted++; Serial.println("[TELEMETRY] packet accepted by Transport"); return true; } @@ -548,6 +562,12 @@ void TelemetryManager::resetToIdle(const char* reason) { Serial.printf("[TELEMETRY] aborting send: %s\n", reason ? reason : "?"); _state = State::IDLE; _attempts = 0; + // resetToIdle() is only invoked from the WAIT_IDENTITY / WAIT_PATH + // timeout branches (driveIdentityDiscovery / drivePathDiscovery); + // the SENDING → IDLE transition is a direct assignment, not a call + // here. So this counter precisely tracks discovery-timeout aborts + // (i.e. discovery that never reached SENDING), per spec. + _discoveryAborted++; } // ============================================================================= @@ -631,6 +651,14 @@ bool TelemetryManager::handleSerial(char c, const char* /*line*/) { // Lowercase 'g' = trigger sendNow (privacy-gated). sendNow(); return true; + case 'V': + // Uppercase 'V' = telemetry status dump (counters + state). + // 'T' was avoided because it is already mapped to the raw + // radio-test hotkey (onHotkeyRadioTest) in main.cpp's + // handleSerialCommands switch — see the 'V' override + // rationale in the orchestrator task. + printStatus(); + return true; default: return false; } @@ -639,4 +667,29 @@ bool TelemetryManager::handleSerial(char c, const char* /*line*/) { void TelemetryManager::printHelp() const { Serial.println("[TELEMETRY] g send-GPS-now (privacy-gated)"); Serial.println("[TELEMETRY] G<32hex> set-hub-hash (default da424e0f47657d7575df58a2b83b111b)"); + Serial.println("[TELEMETRY] V telemetry-status (counters + state)"); +} + +void TelemetryManager::printStatus() const { + const Counters c = counters(); + // Pull interval + enabled from live UserConfig (same source + // checkPeriodicSend() uses), so the dump reflects what the + // periodic gate actually sees right now. + int intervalS = 0; + char enabled = '?'; + if (_cfg) { + intervalS = _cfg->settings().gpsTelemetryIntervalS; + enabled = _cfg->settings().gpsTelemetryEnabled ? 'Y' : 'N'; + } else { + enabled = 'N'; + } + Serial.printf("[TELEMETRY-STATUS] ticks=%lu noFix=%lu stale=%lu discoveryAbort=%lu txOk=%lu interval=%ds enabled=%c state=%s\n", + (unsigned long)c.ticksFired, + (unsigned long)c.refusedNoFix, + (unsigned long)c.refusedStale, + (unsigned long)c.discoveryAborted, + (unsigned long)c.txAccepted, + intervalS, + enabled, + stateName()); } diff --git a/src/reticulum/TelemetryManager.h b/src/reticulum/TelemetryManager.h index 0588bd2..5a65c2d 100644 --- a/src/reticulum/TelemetryManager.h +++ b/src/reticulum/TelemetryManager.h @@ -73,6 +73,29 @@ class TelemetryManager { bool isAttemptInFlight() const { return _state != State::IDLE; } const char* stateName() const; + // Diagnostic counters (RAM-only, no persistence; reset on boot). + // Used to disambiguate two failure hypotheses for periodic GPS + // telemetry sends: RF-out-of-range vs GPS-fix-staleness-gate mismatch. + struct Counters { + uint32_t ticksFired; // checkPeriodicSend() interval met → attempted act + uint32_t refusedNoFix; // sendNow() refused: !hasLocationFix() + uint32_t refusedStale; // sendNow() refused: fix age > FRESH_FIX_MAX_AGE_MS + uint32_t discoveryAborted; // WAIT_IDENTITY/WAIT_PATH timed out before SENDING + uint32_t txAccepted; // buildAndSendSnapshot(): packet.send() succeeded + }; + Counters counters() const { + return { + _ticksFired, + _refusedNoFix, + _refusedStale, + _discoveryAborted, + _txAccepted, + }; + } + + // Serial command 'V' handler — prints one-line counter + state dump. + void printStatus() const; + // Privacy gate threshold (ms). 30 s per the rsDeck #64 spec. static constexpr uint32_t FRESH_FIX_MAX_AGE_MS = 30000UL; static constexpr uint8_t DISCOVERY_MAX_ATTEMPTS = 10; @@ -131,6 +154,13 @@ class TelemetryManager { // configured interval from this tick. unsigned long _lastPeriodicSendMs = 0; + // ----- diagnostic counters (RAM-only; see Counters above) ----- + uint32_t _ticksFired = 0; + uint32_t _refusedNoFix = 0; + uint32_t _refusedStale = 0; + uint32_t _discoveryAborted = 0; + uint32_t _txAccepted = 0; + // Cheap sanity: snapshot read of GPS -> doubles struct LocSample { bool valid = false; From 2deb7e1d5637306384f6770a32ea8a0dce5ddbaa Mon Sep 17 00:00:00 2001 From: Josh Bowyer Date: Wed, 12 Aug 2026 18:48:55 -0500 Subject: [PATCH 07/29] rsDeck: make status-bar branding text overridable via untracked local config Adds RSDECK_BRAND_TEXT macro (Config.h) with the existing 'Ratspeak.org' default preserved for upstream/PR builds. Adds optional platformio_override.ini support (extra_configs, gitignored) that defines a separate rsdeck_915_local build env for personal overrides -- the base rsdeck_915 env used for any upstream-facing work is untouched. No behavior change for anyone not using the override file. --- .gitignore | 1 + platformio.ini | 5 +++++ src/config/Config.h | 9 +++++++++ src/ui/LvStatusBar.cpp | 3 ++- 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 06c4216..36d0bb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .pio +platformio_override.ini .platformio-core/ .vscode/.browse.c_cpp.db* .vscode/c_cpp_properties.json diff --git a/platformio.ini b/platformio.ini index e029fb2..e66458d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,3 +1,8 @@ +[platformio] +; Optional local-only overrides (gitignored, safe to create/edit freely — +; never appears in commits or PRs). See platformio_override.ini.example. +extra_configs = platformio_override.ini + [env:rsdeck_915] platform = espressif32@6.7.0 board = esp32-s3-devkitc-1 diff --git a/src/config/Config.h b/src/config/Config.h index 2b8f1ca..14beac0 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -31,6 +31,15 @@ #define WIFI_AP_PORT 4242 #define WIFI_AP_PASSWORD "ratspeak" +// --- Branding --- +// Text shown in the top status bar. Override locally via +// platformio_override.ini (gitignored, never touches tracked source) with: +// build_flags = -DRSDECK_BRAND_TEXT='"YourTextHere"' +// Left at the project default here so upstream builds/PRs always retain it. +#ifndef RSDECK_BRAND_TEXT +#define RSDECK_BRAND_TEXT "Ratspeak.org" +#endif + // --- Storage Paths --- #define PATH_IDENTITY "/identity/identity.key" #define PATH_IDENTITY_BAK "/identity/identity.key.bak" diff --git a/src/ui/LvStatusBar.cpp b/src/ui/LvStatusBar.cpp index e628e74..7aa42f3 100644 --- a/src/ui/LvStatusBar.cpp +++ b/src/ui/LvStatusBar.cpp @@ -1,6 +1,7 @@ #include "LvStatusBar.h" #include "Theme.h" #include "LvTheme.h" +#include "config/Config.h" #include #include #include @@ -236,7 +237,7 @@ void LvStatusBar::refreshIndicators() { const bool txFlash = _announceFlashEnd > 0 && millis() < _announceFlashEnd; const uint32_t brandColor = txFlash ? Theme::WARNING_CLR : Theme::ACCENT; char buf[160]; - snprintf(buf, sizeof(buf), "#%06X Ratspeak.org#", (unsigned int)brandColor); + snprintf(buf, sizeof(buf), "#%06X " RSDECK_BRAND_TEXT "#", (unsigned int)brandColor); lv_label_set_text(_lblLinks, buf); if (_bar) { From 094756fd3c62e82b43bd90c2cbba87c90cb5da08 Mon Sep 17 00:00:00 2001 From: Josh Bowyer Date: Wed, 12 Aug 2026 20:15:16 -0500 Subject: [PATCH 08/29] rsDeck: offline map tile pipeline (stage 1) + boot chime override Map pipeline (headless, no UI yet - LvMapScreen still a stub): - Vendor pngle v1.1.0 (streaming PNG decoder + miniz) in lib/pngle/ - src/maps/SlippyMath.h: Web Mercator lat/lon <-> tile/world-px math - src/maps/TileStore.h: /maps/