diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index da67ecb0..15be5fcc 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -431,7 +431,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no // Locator can target a live contact (kind + pubkey prefix). rd(&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind)); rd(_prefs.locator_key, sizeof(_prefs.locator_key)); - if (_prefs.locator_target_kind > 1) _prefs.locator_target_kind = 0; + if (_prefs.locator_target_kind > 2) _prefs.locator_target_kind = 0; // GPS-averaging duration for waypoint marking. rd(&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx)); if (_prefs.gps_avg_idx >= NodePrefs::GPS_AVG_COUNT) _prefs.gps_avg_idx = 0; @@ -622,6 +622,11 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no rd(&_prefs.loc_share_duration_idx, sizeof(_prefs.loc_share_duration_idx)); if (_prefs.loc_share_duration_idx >= NodePrefs::LOC_SHARE_DURATION_COUNT) _prefs.loc_share_duration_idx = 0; + // display_brightness_pct. Over 100 -> 0 (use the level); an older file's + // sentinel byte landing here is zeroed below on the sentinel mismatch. + rd(&_prefs.display_brightness_pct, sizeof(_prefs.display_brightness_pct)); + if (_prefs.display_brightness_pct > 100) _prefs.display_brightness_pct = 0; + // Schema sentinel: bumped on layout changes. Mismatch means an older file // (or a different schema); rd() and the clamps above already keep every // field within its valid range regardless, so we just log it here — @@ -631,6 +636,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (sentinel != NodePrefs::SCHEMA_SENTINEL) { MESH_DEBUG_PRINTLN("prefs schema sentinel mismatch: got 0x%08X, expected 0x%08X — re-saving on next change", (unsigned)sentinel, (unsigned)NodePrefs::SCHEMA_SENTINEL); + // 0xC0DE002E → 0xC0DE002F: display_brightness_pct appended; from an older + // file it holds a stray sentinel byte (0x2E = "46 %"), so start from the level. + _prefs.display_brightness_pct = 0; // 0xC0DE002A (v1.27) → 0xC0DE002B: repeat_extra_scope_mask + ch_scope_idx // appended. Unlike the range-clamped fields, these can't be left with whatever // stray bytes rd() picked up from a pre-0x2B file's own sentinel tail: @@ -819,6 +827,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.contact_expiry_idx, sizeof(_prefs.contact_expiry_idx)); file.write((uint8_t *)&_prefs.loc_share_scope, sizeof(_prefs.loc_share_scope)); file.write((uint8_t *)&_prefs.loc_share_duration_idx, sizeof(_prefs.loc_share_duration_idx)); + file.write((uint8_t *)&_prefs.display_brightness_pct, sizeof(_prefs.display_brightness_pct)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good diff --git a/examples/companion_radio/LiveTrack.h b/examples/companion_radio/LiveTrack.h index 78e71e6b..c27078aa 100644 --- a/examples/companion_radio/LiveTrack.h +++ b/examples/companion_radio/LiveTrack.h @@ -84,6 +84,20 @@ public: return nullptr; } + // Latest active position for a channel share by sender name. `name` may be + // cut short (the Locator keeps 11 bytes): then it matches as a prefix. + const Entry* activeByName(const char* name, size_t stored_max, uint32_t now) const { + if (!name || !name[0]) return nullptr; + size_t n = strlen(name); + for (int i = 0; i < CAPACITY; i++) { + if (!isActive(i, now)) continue; + const Entry& e = _e[i]; + if (e.verified) continue; + if (n >= stored_max ? strncmp(e.name, name, n) == 0 : strcmp(e.name, name) == 0) return &e; + } + return nullptr; + } + private: Entry _e[CAPACITY] = {}; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 8ee65e4c..c9c4e96d 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -454,6 +454,9 @@ struct NodePrefs { // persisted to file // locShareDurationMins). There is deliberately no "never" option. 0 = the // shortest/default, so a zeroed field is already valid. uint8_t loc_share_duration_idx; + // Backlight as a percentage (1-100) for frontends with a brightness slider + // (ui-lvgl); 0 = use the 0-4 display_brightness level (ui-new, and the default). + uint8_t display_brightness_pct; // Locator — a single geofence around a saved point. When enabled the device // watches its own GPS fix and beeps + shows an alert when it crosses into // (arrive) or out of (leave) the radius. The target coordinate/label is a @@ -469,7 +472,8 @@ struct NodePrefs { // persisted to file // Target can be a static waypoint or a live contact: for a contact the engine // re-reads the latest [LOC] position each evaluation (keyed by pubkey prefix), // so the geofence follows a moving person ("alert when my friend is near"). - uint8_t locator_target_kind; // 0=waypoint (static), 1=live contact + // Kind 2 is a channel [LOC] share: no key, followed by sender name (label). + uint8_t locator_target_kind; // 0=waypoint (static), 1=live contact, 2=channel live share uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1 [del→onContactRemoved] // Locator proximity beeper — when on (and the alert is armed with a target), // the device ticks while inside the radius and shortens the gap between ticks @@ -623,7 +627,7 @@ struct NodePrefs { // persisted to file // repeat_* fields) instead of at the tail, which shifted every field after // them by 25 bytes when loading an older file. Never released, but a dev // build wrote it, so the number must not be reused for anything else. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE002E; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE002F; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage @@ -776,7 +780,8 @@ struct NodePrefs { // persisted to file // loc_share_* bytes -- confirmed via real sim_companion_radio (native), // WioTrackerL1_companion_solo_dual (nRF52/ARM) and Heltec_v3_companion_radio_ble // (ESP32) builds, sizeof unchanged at 2824. loc_share_duration_idx (0xC0DE002E) -// likewise (sim build; see the check below). +// likewise (sim build; see the check below). display_brightness_pct (0xC0DE002F) +// likewise (L2 ESP32 + L1 nRF52 builds). static_assert(sizeof(NodePrefs) == 2824, "NodePrefs layout changed — sync DataStore save/load + clamp, bump " "SCHEMA_SENTINEL, then update this size (see steps above)."); diff --git a/examples/companion_radio/ui-core/EtaTracker.h b/examples/companion_radio/ui-core/EtaTracker.h new file mode 100644 index 00000000..0d5b62a2 --- /dev/null +++ b/examples/companion_radio/ui-core/EtaTracker.h @@ -0,0 +1,41 @@ +#pragma once +// Closing-speed / ETA estimator for the "navigate to a point" views. The caller +// keeps one instance per navigation session and feeds it the live target +// distance. Speed is the rate the gap closes (smoothed), so it works for both a +// fixed waypoint and a moving live contact; ETA = remaining distance / closing +// speed, meaningful only while actually approaching. + +#include +#include + +namespace navview { + +struct EtaTracker { + float prev_km = -1.0f; + uint32_t prev_ms = 0; + float closing_kmh = 0.0f; // > 0 = gap shrinking (approaching) + bool have = false; + void reset() { prev_km = -1.0f; have = false; closing_kmh = 0.0f; } + void update(float dist_km, uint32_t now_ms) { + if (prev_km >= 0.0f && now_ms > prev_ms) { + float dt_h = (now_ms - prev_ms) / 3600000.0f; + if (dt_h > 0.0f) { + float inst = (prev_km - dist_km) / dt_h; // +approaching, -receding + closing_kmh = have ? (closing_kmh * 0.6f + inst * 0.4f) : inst; // light EMA + have = true; + } + } + prev_km = dist_km; + prev_ms = now_ms; + } + // "12m" / "1h05m" into out, or false while not approaching. + bool eta(float dist_km, char* out, size_t n) const { + if (!have || closing_kmh <= 0.3f) return false; + int secs = (int)(dist_km / closing_kmh * 3600.0f); + if (secs < 3600) snprintf(out, n, "%dm", (secs + 59) / 60); + else snprintf(out, n, "%dh%02dm", secs / 3600, (secs % 3600) / 60); + return true; + } +}; + +} // namespace navview diff --git a/examples/companion_radio/ui-core/GpsAverager.h b/examples/companion_radio/ui-core/GpsAverager.h new file mode 100644 index 00000000..74f45abc --- /dev/null +++ b/examples/companion_radio/ui-core/GpsAverager.h @@ -0,0 +1,52 @@ +#pragma once +// GPS averaging for marking a waypoint (NodePrefs::gps_avg_idx): collect one +// fix a second for N seconds and mark their mean -- a steadier position than a +// single fix. The caller feeds the current fix on every poll; sampling is on +// its own 1 s gate, independent of how often the screen redraws. int64 sums: +// 30 samples x ~180e6 would overflow int32. + +#include + +class GpsAverager { +public: + enum Step : uint8_t { RUNNING, DONE, NO_FIX }; + + void start(uint16_t secs, int32_t lat, int32_t lon) { + _sum_lat = lat; _sum_lon = lon; _n = 1; + uint32_t now = millis(); + _end_ms = now + (uint32_t)secs * 1000UL; + _next_ms = now + 1000UL; + _total_s = secs; + _active = true; + } + void cancel() { _active = false; } + bool active() const { return _active; } + + // DONE: lat/lon hold the mean (averaging is over). NO_FIX: the window closed + // without a single fix. RUNNING otherwise. + Step poll(bool have_fix, int32_t fix_lat, int32_t fix_lon, int32_t& lat, int32_t& lon) { + if (!_active) return NO_FIX; + uint32_t now = millis(); + if ((int32_t)(now - _next_ms) >= 0) { + if (have_fix) { _sum_lat += fix_lat; _sum_lon += fix_lon; _n++; } + _next_ms = now + 1000UL; + } + if ((int32_t)(now - _end_ms) < 0) return RUNNING; + _active = false; + if (_n == 0) return NO_FIX; + lat = (int32_t)(_sum_lat / (long long)_n); + lon = (int32_t)(_sum_lon / (long long)_n); + return DONE; + } + + int remainingSecs() const { int r = (int)((int32_t)(_end_ms - millis()) / 1000); return r < 0 ? 0 : r; } + uint32_t samples() const { return _n; } + uint16_t totalSecs() const { return _total_s; } + +private: + long long _sum_lat = 0, _sum_lon = 0; + uint32_t _n = 0; + uint32_t _end_ms = 0, _next_ms = 0; + uint16_t _total_s = 0; + bool _active = false; +}; diff --git a/examples/companion_radio/ui-core/LiveShareEngine.h b/examples/companion_radio/ui-core/LiveShareEngine.h index a6593d02..a70fe14f 100644 --- a/examples/companion_radio/ui-core/LiveShareEngine.h +++ b/examples/companion_radio/ui-core/LiveShareEngine.h @@ -78,6 +78,16 @@ public: // changed. No-op before a session has started (loop() sets the clock then). void restartClock() { if (_was_enabled) _session_ms = millis(); } + // Seconds left in the running session (the whole length before its first + // tick), 0 when sharing is off. + uint32_t remainingSecs() const { + if (!_prefs || !_prefs->loc_share_enabled) return 0; + uint32_t total = (uint32_t)NodePrefs::locShareDurationMins(_prefs->loc_share_duration_idx) * 60UL; + if (!_was_enabled) return total; + uint32_t el = (uint32_t)(millis() - _session_ms) / 1000UL; + return el >= total ? 0 : total - el; + } + // Send one [LOC] message to the configured live-share target. Returns false // if the target can't be resolved (no such channel / contact). bool send(int32_t lat, int32_t lon) { diff --git a/examples/companion_radio/ui-core/LocatorEngine.h b/examples/companion_radio/ui-core/LocatorEngine.h index 672b833d..b1399312 100644 --- a/examples/companion_radio/ui-core/LocatorEngine.h +++ b/examples/companion_radio/ui-core/LocatorEngine.h @@ -36,7 +36,9 @@ public: // ── Target ───────────────────────────────────────────────────────────────── // setTarget() only *defines* the target (fields + re-arm); the caller decides - // when to persist. kind 0 = waypoint (key ignored), 1 = person (key required). + // when to persist. kind 0 = waypoint (key ignored), 1 = person (key required), + // 2 = channel live share (no key: followed by the sender name in `name`, + // lat/lon = where they were, used once their share goes stale). void setTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name) { if (!_prefs) return; _prefs->locator_target_kind = kind; @@ -104,6 +106,11 @@ public: if (!_prefs || !_prefs->locator_has_target) return false; if (_prefs->locator_target_kind == 1) return resolvePersonPos(_prefs->locator_key, lat, lon); + if (_prefs->locator_target_kind == 2) { + const LiveTrackStore::Entry* e = _live->track().activeByName( + _prefs->locator_label, sizeof(_prefs->locator_label) - 1, (uint32_t)rtc_clock.getCurrentTime()); + if (e) { lat = e->lat_1e6; lon = e->lon_1e6; return true; } + } lat = _prefs->locator_lat_1e6; lon = _prefs->locator_lon_1e6; return true; @@ -148,7 +155,7 @@ private: void fireCrossing(bool arrived) { const char* lbl = _prefs->locator_label[0] ? _prefs->locator_label : "target"; - bool person = _prefs->locator_target_kind == 1; + bool person = _prefs->locator_target_kind != 0; char msg[sizeof(UiEvent::text)]; // "Near/Away" reads naturally for a moving person; "Arrived/Left" for a place. snprintf(msg, sizeof(msg), diff --git a/examples/companion_radio/ui-core/RadioControl.h b/examples/companion_radio/ui-core/RadioControl.h new file mode 100644 index 00000000..8d1d9e01 --- /dev/null +++ b/examples/companion_radio/ui-core/RadioControl.h @@ -0,0 +1,70 @@ +#pragma once +// Radio settings actions shared by the frontends: push the companion's +// NodePrefs radio fields (freq / bw / sf / cr, TX power, Adaptive Power +// Control) to the radio, and the preset list (../RadioPresets.h built-ins, +// then the user's saved presets in NodePrefs) the Settings > Radio pickers show. +// ui-new's Settings / Repeater screens and ui-lvgl's Radio screen both go +// through here. + +#include "../RadioPresets.h" + +namespace radioctl { + +// freq / bw / sf / cr -> the radio (or the repeater profile, while relaying with one). +static void applyParams() { the_mesh.applyRepeaterRadio(); } + +static void applyTxPower(const NodePrefs* p) { + if (!p) return; + // With APC on, tx_power_dbm is the ceiling -- re-baseline the controller to + // it (which also sets the radio) so the live power tracks the new ceiling. + if (p->tx_apc) { the_mesh.applyApc(); return; } + radio_driver.setTxPower(p->tx_power_dbm); +} + +static void applyApc() { the_mesh.applyApc(); } // (re)initialise Adaptive Power Control from prefs + +// ── Presets ───────────────────────────────────────────────────────────────── +// One list: built-ins 0..RADIO_PRESET_COUNT-1, then the non-empty user slots. + +static int presetCount(const NodePrefs* p) { + int n = RADIO_PRESET_COUNT; + for (int i = 0; p && i < NodePrefs::USER_RADIO_PRESET_MAX; i++) if (p->user_radio_presets[i].name[0]) n++; + return n; +} + +// Preset `idx` of the list; false past the end. +static bool presetAt(const NodePrefs* p, int idx, const char*& name, float& freq, float& bw, uint8_t& sf, uint8_t& cr) { + if (idx < 0) return false; + if (idx < RADIO_PRESET_COUNT) { + const RadioPreset& r = RADIO_PRESETS[idx]; + name = r.name; freq = r.freq; bw = r.bw; sf = r.sf; cr = r.cr; + return true; + } + int k = idx - RADIO_PRESET_COUNT; + for (int i = 0; p && i < NodePrefs::USER_RADIO_PRESET_MAX; i++) { + const NodePrefs::UserRadioPreset& u = p->user_radio_presets[i]; + if (!u.name[0]) continue; + if (k-- == 0) { name = u.name; freq = u.freq; bw = u.bw; sf = u.sf; cr = u.cr; return true; } + } + return false; +} + +// List index of the preset the current companion params match, else -1 ("Custom"). +static int currentPreset(const NodePrefs* p) { + if (!p) return -1; + const char* name; float f, b; uint8_t s, c; + for (int i = 0; presetAt(p, i, name, f, b, s, c); i++) + if (radioParamsMatchPreset(p->freq, p->bw, p->sf, p->cr, f, b, s, c)) return i; + return -1; +} + +// Take preset `idx` into the companion params and apply it. +static bool choosePreset(NodePrefs* p, int idx) { + const char* name; float f, b; uint8_t s, c; + if (!p || !presetAt(p, idx, name, f, b, s, c)) return false; + p->freq = f; p->bw = b; p->sf = s; p->cr = c; + applyParams(); + return true; +} + +} // namespace radioctl diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h new file mode 100644 index 00000000..df31b20c --- /dev/null +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -0,0 +1,222 @@ +#pragma once +// Declarative settings (docs/development/ui-core.md, step 4): one table of +// NodePrefs options with their labels, value lists and side effects, so every +// frontend renders the same settings without its own per-item code. ui-lvgl +// draws it as switches and dropdowns, one page per Page below; ui-new still has +// its hand-written screens and moves over later. +// +// Each entry picks one of `count` values. By default the NodePrefs field is a +// uint8_t holding that index; with a `values` table the field (1, 2 or 4 bytes, +// `size`) holds values[index] instead -- seconds, millivolts, an hour offset, +// or {1, 0} for a switch whose field is stored inverted. count == 2 with no +// option labeller is an on/off switch. + +#include +#include "../NodePrefs.h" +#include "../Trail.h" + +namespace settings { + +// Pages (one screen each) and the sections on them, in display order. +enum Page : uint8_t { PG_NAV, PG_DEVICE, PG_MESSAGES, PG_COUNT }; +enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_UNITS, + SEC_DISPLAY, SEC_POWER, SEC_TIME, + SEC_MESSAGES, SEC_CONTACTS, SEC_COUNT }; + +static const char* pageTitle(uint8_t p) { + static const char* T[PG_COUNT] = { "Trail, live share, alerts", "Display & power", "Messages & contacts" }; + return p < PG_COUNT ? T[p] : ""; +} +static const char* sectionTitle(uint8_t s) { + static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "UNITS", + "DISPLAY", "POWER", "TIME", + "MESSAGES", "CONTACTS" }; + return s < SEC_COUNT ? T[s] : ""; +} +static uint8_t sectionPage(uint8_t s) { + static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_NAV, + PG_DEVICE, PG_DEVICE, PG_DEVICE, + PG_MESSAGES, PG_MESSAGES }; + return s < SEC_COUNT ? P[s] : PG_NAV; +} + +struct Setting { + const char* label; + const char* hint; // one short line under the label, or nullptr + uint8_t section; + uint16_t offset; // offsetof(NodePrefs, field) + uint8_t count; // number of values + // Label for value v (nullptr: an on/off switch). + void (*option)(uint8_t v, char* buf, int n, const NodePrefs& p); + // Side effect after a change (nullptr: none). The caller saves the prefs. + void (*changed)(UiCore& core); + const int32_t* values; // stored value per index; nullptr: the index itself + uint8_t size; // field bytes (1, 2, 4) +}; + +// ── Value labels ────────────────────────────────────────────────────────────── +static void optMinDelta(uint8_t v, char* b, int n, const NodePrefs& p) { + snprintf(b, n, "%s", TrailStore::minDeltaLabel(v, p.units_imperial)); +} +static void optAutoPause(uint8_t v, char* b, int n, const NodePrefs&) { + uint16_t s = NodePrefs::trailAutoPauseSecs(v); + if (s == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u min", (unsigned)(s / 60)); +} +static void optGpsAvg(uint8_t v, char* b, int n, const NodePrefs&) { + uint16_t s = NodePrefs::gpsAvgSecs(v); + if (s == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u s", (unsigned)s); +} +static void optDuration(uint8_t v, char* b, int n, const NodePrefs&) { + snprintf(b, n, "%u h", (unsigned)(NodePrefs::locShareDurationMins(v) / 60)); +} +static void optMove(uint8_t v, char* b, int n, const NodePrefs&) { + snprintf(b, n, "%u m", (unsigned)NodePrefs::locShareMoveMeters(v)); +} +static void optGap(uint8_t v, char* b, int n, const NodePrefs&) { + uint16_t s = NodePrefs::locShareIntervalSecs(v); + if (s < 60) snprintf(b, n, "%u s", (unsigned)s); else snprintf(b, n, "%u min", (unsigned)(s / 60)); +} +static void optHeartbeat(uint8_t v, char* b, int n, const NodePrefs&) { + uint16_t s = NodePrefs::locShareHeartbeatSecs(v); + if (s == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u min", (unsigned)(s / 60)); +} +static void optRadius(uint8_t v, char* b, int n, const NodePrefs&) { + uint16_t m = NodePrefs::locatorRadiusMeters(v); + if (m < 1000) snprintf(b, n, "%u m", (unsigned)m); else snprintf(b, n, "%u km", (unsigned)(m / 1000)); +} +static void optLocMode(uint8_t v, char* b, int n, const NodePrefs&) { + snprintf(b, n, "%s", NodePrefs::locatorModeLabel(v)); +} +static void optBrightness(uint8_t v, char* b, int n, const NodePrefs&) { + static const char* L[5] = { "Lowest", "Low", "Medium", "High", "Max" }; + snprintf(b, n, "%s", L[v < 5 ? v : 2]); +} +// Seconds -> "30 s" / "2 min" / "1 h"; 0 reads as `zero`. +static void fmtSecs(char* b, int n, int32_t s, const char* zero) { + if (s == 0) snprintf(b, n, "%s", zero); + else if (s < 60) snprintf(b, n, "%ld s", (long)s); + else if (s < 3600) snprintf(b, n, "%ld min", (long)(s / 60)); + else snprintf(b, n, "%ld h", (long)(s / 3600)); +} +static const int32_t AUTO_OFF[] = { 15, 30, 60, 120, 300, 0 }; +static void optAutoOff(uint8_t v, char* b, int n, const NodePrefs&) { fmtSecs(b, n, AUTO_OFF[v], "Never"); } +static const int32_t GPS_DUTY[] = { 0, 60, 300, 900, 1800, 3600 }; +static void optGpsDuty(uint8_t v, char* b, int n, const NodePrefs&) { fmtSecs(b, n, GPS_DUTY[v], "Always on"); } +static const int32_t LOW_BATT[] = { 0, 3000, 3100, 3200, 3300, 3400, 3500 }; +static void optLowBatt(uint8_t v, char* b, int n, const NodePrefs&) { + if (LOW_BATT[v] == 0) snprintf(b, n, "Off"); + else snprintf(b, n, "%ld.%ld V", (long)(LOW_BATT[v] / 1000), (long)(LOW_BATT[v] % 1000 / 100)); +} +static const int32_t TZ[] = { -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 }; +static void optTz(uint8_t v, char* b, int n, const NodePrefs&) { + if (TZ[v] == 0) snprintf(b, n, "UTC"); else snprintf(b, n, "UTC%+ld", (long)TZ[v]); +} +static const int32_t INVERTED[] = { 1, 0 }; // switch over a field stored as "off" flag +static void optResend(uint8_t v, char* b, int n, const NodePrefs&) { + if (v == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u more", (unsigned)v); +} +static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) { + snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v)); +} + +// ── Side effects ────────────────────────────────────────────────────────────── +static void rearmLocator(UiCore& c) { c.locator.reset(); } +static void restartShareClock(UiCore& c) { c.live_share.restartClock(); } // a new length starts over +static void applyDisplay(UiCore& c) { c.host()->applyDisplayPrefs(); } +static void applyGpsDuty(UiCore& c) { c.applyGpsInterval(); } + +#define NP_OFF(f) (uint16_t)offsetof(NodePrefs, f) +#define NP_SIZE(f) (uint8_t)sizeof(((NodePrefs*)0)->f) +#define COUNT_OF(a) (uint8_t)(sizeof(a) / sizeof(a[0])) +// Index fields (uint8_t index), switches, and fields holding values[index]. +#define IDX(label, hint, sec, f, count, opt, chg) { label, hint, sec, NP_OFF(f), count, opt, chg, nullptr, 1 } +#define SW(label, hint, sec, f, chg) { label, hint, sec, NP_OFF(f), 2, nullptr, chg, nullptr, 1 } +#define MAP(label, hint, sec, f, vals, opt, chg) { label, hint, sec, NP_OFF(f), COUNT_OF(vals), opt, chg, vals, NP_SIZE(f) } +static const Setting ALL[] = { + IDX("Point spacing", "Distance between trail points", SEC_TRAIL, trail_min_delta_idx, + TrailStore::MIN_DELTA_COUNT, optMinDelta, nullptr), + IDX("Auto-pause", "Pause the trail when standing still", SEC_TRAIL, trail_autopause_idx, + NodePrefs::TRAIL_AUTOPAUSE_COUNT, optAutoPause, nullptr), + SW("Save on low battery", "Keep the trail when the battery runs out", SEC_TRAIL, trail_autosave_lowbatt, nullptr), + IDX("Waypoint averaging", "Average GPS fixes when marking", SEC_TRAIL, gps_avg_idx, + NodePrefs::GPS_AVG_COUNT, optGpsAvg, nullptr), + + SW("Show others' positions", "People sharing [LOC] on the map", SEC_LIVE_SHARE, track_shared_loc, nullptr), + IDX("Stop sharing after", nullptr, SEC_LIVE_SHARE, loc_share_duration_idx, + NodePrefs::LOC_SHARE_DURATION_COUNT, optDuration, restartShareClock), + IDX("Send after moving", nullptr, SEC_LIVE_SHARE, loc_share_move_idx, + NodePrefs::LOC_SHARE_MOVE_COUNT, optMove, nullptr), + IDX("At most every", nullptr, SEC_LIVE_SHARE, loc_share_interval_idx, + NodePrefs::LOC_SHARE_INTERVAL_COUNT, optGap, nullptr), + IDX("Heartbeat", "Resend while standing still", SEC_LIVE_SHARE, loc_share_heartbeat_idx, + NodePrefs::LOC_SHARE_HEARTBEAT_COUNT, optHeartbeat, nullptr), + + SW("Arrival alert", "When you reach the target", SEC_LOCATOR, locator_enabled, rearmLocator), + IDX("Radius", nullptr, SEC_LOCATOR, locator_radius_idx, + NodePrefs::LOCATOR_RADIUS_COUNT, optRadius, rearmLocator), + IDX("Alert on", nullptr, SEC_LOCATOR, locator_mode, + NodePrefs::LOCATOR_MODE_COUNT, optLocMode, rearmLocator), + SW("Proximity beeper", "Faster ticks as you get closer", SEC_LOCATOR, locator_beeper, nullptr), + + SW("Imperial units", "Miles and feet", SEC_UNITS, units_imperial, nullptr), + + IDX("Brightness", nullptr, SEC_DISPLAY, display_brightness, 5, optBrightness, applyDisplay), + MAP("Screen off after", "Without a touch", SEC_DISPLAY, auto_off_secs, AUTO_OFF, optAutoOff, nullptr), + MAP("Wake on message", "Turn the screen on for new messages", SEC_DISPLAY, msg_wake_screen_off, INVERTED, + nullptr, nullptr), + + MAP("Battery shutdown", "Power off below this voltage", SEC_POWER, low_batt_mv, LOW_BATT, optLowBatt, nullptr), + MAP("GPS power saving", "Sleep between fixes", SEC_POWER, gps_interval, GPS_DUTY, optGpsDuty, applyGpsDuty), + + MAP("Time zone", "For the clock and the alarm", SEC_TIME, tz_offset_hours, TZ, optTz, nullptr), + + IDX("Resend direct messages", "Extra tries without a delivery tick", SEC_MESSAGES, dm_resend_count, 6, + optResend, nullptr), + + IDX("Contact expiry", "Inactive this long can be pruned", SEC_CONTACTS, contact_expiry_idx, + NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr), + MAP("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort_off, INVERTED, nullptr, nullptr), +}; +#undef IDX +#undef SW +#undef MAP +#undef COUNT_OF +#undef NP_SIZE +#undef NP_OFF +static const int COUNT = (int)(sizeof(ALL) / sizeof(ALL[0])); + +static int32_t readRaw(const NodePrefs& p, const Setting& s) { + const uint8_t* f = (const uint8_t*)&p + s.offset; + bool sgn = false; // a table with negative values is over a signed field + for (uint8_t i = 0; s.values && i < s.count; i++) if (s.values[i] < 0) sgn = true; + if (s.size == 2) { uint16_t v; memcpy(&v, f, 2); return sgn ? (int32_t)(int16_t)v : (int32_t)v; } + if (s.size == 4) { uint32_t v; memcpy(&v, f, 4); return (int32_t)v; } + return sgn ? (int32_t)(int8_t)f[0] : (int32_t)f[0]; +} +static void writeRaw(NodePrefs& p, const Setting& s, int32_t v) { + uint8_t* f = (uint8_t*)&p + s.offset; + if (s.size == 2) { uint16_t w = (uint16_t)v; memcpy(f, &w, 2); } + else if (s.size == 4) { uint32_t w = (uint32_t)v; memcpy(f, &w, 4); } + else f[0] = (uint8_t)v; +} + +// Current index. A stored value outside the table reads as the nearest entry +// (e.g. an auto-off time set on another frontend). +static uint8_t get(const NodePrefs& p, const Setting& s) { + int32_t raw = readRaw(p, s); + if (!s.values) return raw >= 0 && raw < s.count ? (uint8_t)raw : 0; + uint8_t best = 0; + int32_t best_d = INT32_MAX; + for (uint8_t i = 0; i < s.count; i++) { + int32_t d = s.values[i] > raw ? s.values[i] - raw : raw - s.values[i]; + if (d < best_d) { best_d = d; best = i; } + } + return best; +} +static void set(NodePrefs& p, const Setting& s, uint8_t v) { + if (v >= s.count) return; + writeRaw(p, s, s.values ? s.values[v] : v); +} + +} // namespace settings diff --git a/examples/companion_radio/ui-core/TrackBack.h b/examples/companion_radio/ui-core/TrackBack.h new file mode 100644 index 00000000..43f95d58 --- /dev/null +++ b/examples/companion_radio/ui-core/TrackBack.h @@ -0,0 +1,56 @@ +#pragma once +// Track back: retrace the recorded trail in reverse. Snaps onto the route at +// the recorded point nearest to you, then targets each earlier breadcrumb in +// turn -- advancing when you come within ARRIVE_M of it -- until the trail's +// start is reached. The frontend shows the current breadcrumb as a navigate +// target (ui-new: NavView; ui-lvgl: the Navigation map). + +#include "../Trail.h" +#include "../GeoUtils.h" + +class TrackBack { +public: + static const int ARRIVE_M = 20; + enum Step : uint8_t { NONE, ADVANCED, ARRIVED }; + + // False when there's no trail to walk back (fewer than 2 points). + bool start(const TrailStore& s, bool have_fix, int32_t lat, int32_t lon) { + if (s.count() < 2) return false; + int idx = s.count() - 1; // default: the newest end of the trail + if (have_fix) { + float best = 1e30f; + for (int i = 0; i < s.count(); i++) { + float d = geo::haversineKm(lat, lon, s.at(i).lat_1e6, s.at(i).lon_1e6); + if (d < best) { best = d; idx = i; } + } + } + _idx = idx; + _active = true; + return true; + } + void stop() { _active = false; } + bool active() const { return _active; } + int index() const { return _idx; } // current breadcrumb; 0 = the trail start + + // Feed the current fix; advances past a reached breadcrumb, ends at the start. + Step poll(const TrailStore& s, bool have_fix, int32_t lat, int32_t lon) { + if (!_active) return NONE; + if (_idx < 0 || _idx >= s.count()) { _active = false; return NONE; } // trail reset under us + if (!have_fix) return NONE; + float d_m = geo::haversineKm(lat, lon, s.at(_idx).lat_1e6, s.at(_idx).lon_1e6) * 1000.0f; + if (d_m > (float)ARRIVE_M) return NONE; + if (_idx > 0) { _idx--; return ADVANCED; } + _active = false; + return ARRIVED; + } + + // Header text for the current leg: "Trail start" on the last one, else points to go. + void label(char* out, int n) const { + if (_idx == 0) snprintf(out, n, "Trail start"); + else snprintf(out, n, "Back: %d pt", _idx); + } + +private: + int _idx = 0; + bool _active = false; +}; diff --git a/examples/companion_radio/ui-core/TrailEngine.h b/examples/companion_radio/ui-core/TrailEngine.h index 13009804..191317e9 100644 --- a/examples/companion_radio/ui-core/TrailEngine.h +++ b/examples/companion_radio/ui-core/TrailEngine.h @@ -1,8 +1,8 @@ #pragma once // GPS trail: the RAM TrailStore plus its background sampling (runs while the -// trail is active, independent of which screen is shown), auto-pause, and the -// pre-shutdown auto-save. Recording control (start/stop/clear/save/load) is -// still done by TrailScreen directly on the store. +// trail is active, independent of which screen is shown), auto-pause, the +// pre-shutdown auto-save, and the recording actions both frontends use +// (start/stop, reset, save/load of the one saved trail in /trail). #include "../Trail.h" #include "../GeoUtils.h" @@ -65,6 +65,41 @@ public: TrailStore& store() { return _trail; } + // ── Actions ──────────────────────────────────────────────────────────────── + enum FileResult : uint8_t { FILE_OK, FILE_NO_FS, FILE_MISSING, FILE_FAILED }; + static constexpr const char* TRAIL_FILE = "/trail"; + + bool isActive() const { return _trail.isActive(); } + void setActive(bool on) { _trail.setActive(on); } + void reset() { _trail.setActive(false); _trail.clear(); } + + FileResult save() { + DataStore* ds = the_mesh.getDataStore(); + if (!ds) return FILE_NO_FS; + File f = ds->openWrite(TRAIL_FILE); + if (!f) return FILE_FAILED; + bool ok = _trail.writeTo(f); + f.close(); + return ok ? FILE_OK : FILE_FAILED; + } + FileResult load() { + DataStore* ds = the_mesh.getDataStore(); + if (!ds) return FILE_NO_FS; + File f = ds->openRead(TRAIL_FILE); + if (!f) return FILE_MISSING; + bool ok = _trail.readFrom(f); + f.close(); + return ok ? FILE_OK : FILE_FAILED; + } + static bool savedExists() { + DataStore* ds = the_mesh.getDataStore(); + if (!ds) return false; + File f = ds->openRead(TRAIL_FILE); + bool ok = (bool)f; + if (f) f.close(); + return ok; + } + private: NodePrefs* _prefs = nullptr; const CourseEngine* _course = nullptr; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 665186ce..1c6cdd50 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -22,18 +22,21 @@ #include "LiveShareEngine.h" #include "LocatorEngine.h" #include "TrailEngine.h" +#include "WaypointModel.h" class UiCore : public MyMesh::Listener { public: void begin(NodePrefs* prefs, SensorManager* sensors, UiCoreHost* host) { _host = host; _prefs = prefs; + _sensors = sensors; clock.begin(prefs, &events); ping.begin(prefs); course.begin(sensors); live_share.begin(prefs, &course, &events); locator.begin(prefs, &course, &live_share, &events); trail.begin(prefs, &course); + waypoints.begin(&locator); // loads /waypoints } // Driven from the frontend's loop(), before it drains `events`. @@ -62,6 +65,7 @@ public: // ── Models ──────────────────────────────────────────────────────────────── MessageHistory history; // channel + DM rings, delivery state, channel unread DmUnreadTable dm_unread; // per-contact DM unread counters + WaypointModel waypoints; // saved places, persisted to /waypoints // ── Unread ──────────────────────────────────────────────────────────────── // Messages waiting in the companion-app offline queue (0 = the app synced). @@ -111,6 +115,32 @@ public: return true; } + // GPS power: the sensor manager's "gps" setting, persisted as + // NodePrefs::gps_enabled (MyMesh::applyGpsPrefs() restores it at boot). + bool gpsAvailable() const { return gpsSettingIndex() >= 0; } + bool gpsEnabled() const { + int i = gpsSettingIndex(); + return i >= 0 && strcmp(_sensors->getSettingValue(i), "1") == 0; + } + // False when the board has no GPS. + bool setGpsEnabled(bool on) { + if (gpsSettingIndex() < 0) return false; + _sensors->setSettingValue("gps", on ? "1" : "0"); + if (_prefs) _prefs->gps_enabled = on ? 1 : 0; + the_mesh.savePrefs(); + return true; + } + + // GPS duty cycle (NodePrefs::gps_interval, seconds; 0 = always on) to the sensor layer. + void applyGpsInterval() { + if (!_sensors || !_prefs) return; + char buf[12]; + snprintf(buf, sizeof(buf), "%lu", (unsigned long)_prefs->gps_interval); + _sensors->setSettingValue("gps_interval", buf); + } + + UiCoreHost* host() { return _host; } + // ════ MyMesh::Listener ════════════════════════════════════════════════════ void onQueueSizeChanged(int msgcount) override { @@ -226,8 +256,16 @@ private: return ev; } + int gpsSettingIndex() const { + if (!_sensors) return -1; + for (int i = 0; i < _sensors->getNumSettings(); i++) + if (strcmp(_sensors->getSettingName(i), "gps") == 0) return i; + return -1; + } + UiCoreHost* _host = nullptr; NodePrefs* _prefs = nullptr; + SensorManager* _sensors = nullptr; int _queue_len = 0; // last onQueueSizeChanged() int _room_unread = 0; }; diff --git a/examples/companion_radio/ui-core/UiCoreHost.h b/examples/companion_radio/ui-core/UiCoreHost.h index 7095c8b7..c707a7d8 100644 --- a/examples/companion_radio/ui-core/UiCoreHost.h +++ b/examples/companion_radio/ui-core/UiCoreHost.h @@ -30,6 +30,10 @@ public: virtual bool botGetGPIO(int idx, bool& is_output, bool& value) { return false; } virtual bool botGetGPIOAnalog(int idx, int& millivolts) { return false; } + // A display pref changed through the settings schema (brightness, ...): + // push it to the panel. + virtual void applyDisplayPrefs() {} + // Controlled power-down / restart (flush state first). See // AbstractUITask::shutdown(). virtual void shutdown(bool restart) = 0; diff --git a/examples/companion_radio/ui-core/WaypointModel.h b/examples/companion_radio/ui-core/WaypointModel.h new file mode 100644 index 00000000..e0703aa2 --- /dev/null +++ b/examples/companion_radio/ui-core/WaypointModel.h @@ -0,0 +1,85 @@ +#pragma once +// Saved waypoints: the ../Waypoint.h table plus its persistence (/waypoints on +// the node's DataStore, loaded at boot, rewritten on every change) and the one +// rule that ties it to the Locator: deleting the waypoint that is the active +// target clears the target. + +#include "../Waypoint.h" +#include "LocatorEngine.h" + +class WaypointModel { +public: + void begin(LocatorEngine* locator) { + _locator = locator; + DataStore* ds = the_mesh.getDataStore(); + if (!ds) return; + File f = ds->openRead("/waypoints"); + if (f) { _store.readFrom(f); f.close(); } + } + + WaypointStore& store() { return _store; } + const WaypointStore& store() const { return _store; } + int count() const { return _store.count(); } + bool full() const { return _store.full(); } + const Waypoint& at(int i) const { return _store.at(i); } + + void save() { + DataStore* ds = the_mesh.getDataStore(); + if (!ds) return; + File f = ds->openWrite("/waypoints"); + if (!f) return; + _store.writeTo(f); + f.close(); + } + + // `label` cut to what a waypoint holds (WAYPOINT_LABEL_LEN - 1 bytes) at a + // UTF-8 character boundary, so a two-byte letter is never split. + static void fitLabel(char* out, const char* label) { + size_t n = label ? strlen(label) : 0; + if (n > WAYPOINT_LABEL_LEN - 1) { + n = WAYPOINT_LABEL_LEN - 1; + while (n > 0 && ((uint8_t)label[n] & 0xC0) == 0x80) n--; // back off a continuation byte + } + if (n) memcpy(out, label, n); + out[n] = '\0'; + } + + // Add and persist. An empty label becomes "WP". False when the table is full. + bool add(int32_t lat, int32_t lon, uint32_t ts, const char* label) { + char lbl[WAYPOINT_LABEL_LEN]; + fitLabel(lbl, label); + if (!lbl[0]) snprintf(lbl, sizeof(lbl), "WP%d", _store.count() + 1); + if (!_store.add(lat, lon, ts, lbl)) return false; + save(); + return true; + } + + void rename(int i, const char* label) { + if (i < 0 || i >= _store.count()) return; + char lbl[WAYPOINT_LABEL_LEN]; + fitLabel(lbl, label); + _store.rename(i, lbl); + save(); + } + + void remove(int i) { + if (i < 0 || i >= _store.count()) return; + const Waypoint& w = _store.at(i); + if (_locator) _locator->clearTargetIfWaypoint(w.lat_1e6, w.lon_1e6); + _store.remove(i); + save(); + } + + // "[WAY]lat,lon label" -- the message form other Solo nodes turn back into a waypoint. + void shareText(int i, char* out, size_t n) const { + if (i < 0 || i >= _store.count()) { out[0] = '\0'; return; } + const Waypoint& w = _store.at(i); + double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0; + if (w.label[0]) snprintf(out, n, WAYPOINT_MSG_TAG "%.5f,%.5f %s", lat, lon, w.label); + else snprintf(out, n, WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon); + } + +private: + WaypointStore _store; + LocatorEngine* _locator = nullptr; +}; diff --git a/examples/companion_radio/ui-new/LocatorScreen.h b/examples/companion_radio/ui-new/LocatorScreen.h index 9afe983a..97ae041d 100644 --- a/examples/companion_radio/ui-new/LocatorScreen.h +++ b/examples/companion_radio/ui-new/LocatorScreen.h @@ -84,7 +84,7 @@ public: if (_prefs && _prefs->locator_has_target) { const char* nm = _prefs->locator_label[0] ? _prefs->locator_label : "(unnamed)"; // '@' prefix marks a live contact target (a moving person) vs a waypoint. - if (_prefs->locator_target_kind == 1) snprintf(buf, n, "@%s", nm); + if (_prefs->locator_target_kind != 0) snprintf(buf, n, "@%s", nm); else snprintf(buf, n, "%s", nm); } else { snprintf(buf, n, "none"); diff --git a/examples/companion_radio/ui-new/NavView.h b/examples/companion_radio/ui-new/NavView.h index 4375d3ac..59888452 100644 --- a/examples/companion_radio/ui-new/NavView.h +++ b/examples/companion_radio/ui-new/NavView.h @@ -9,34 +9,10 @@ #include #include "../GeoUtils.h" +#include "../ui-core/EtaTracker.h" // navview::EtaTracker (shared with ui-lvgl) namespace navview { -// Closing-speed / ETA estimator. The caller keeps one instance per navigation -// session and passes it to draw(), which feeds it the live target distance each -// frame. Speed is the rate the gap closes (smoothed), so it works for both a -// fixed waypoint and a moving live contact; ETA = remaining distance / closing -// speed, shown only while actually approaching. -struct EtaTracker { - float prev_km = -1.0f; - uint32_t prev_ms = 0; - float closing_kmh = 0.0f; // > 0 = gap shrinking (approaching) - bool have = false; - void reset() { prev_km = -1.0f; have = false; closing_kmh = 0.0f; } - void update(float dist_km, uint32_t now_ms) { - if (prev_km >= 0.0f && now_ms > prev_ms) { - float dt_h = (now_ms - prev_ms) / 3600000.0f; - if (dt_h > 0.0f) { - float inst = (prev_km - dist_km) / dt_h; // +approaching, -receding - closing_kmh = have ? (closing_kmh * 0.6f + inst * 0.4f) : inst; // light EMA - have = true; - } - } - prev_km = dist_km; - prev_ms = now_ms; - } -}; - // own_valid : is there a usable GPS fix for the device right now // cog_valid : is there a stable course-over-ground (UITask::currentCourse) // eta : optional closing-speed/ETA tracker; when supplied a third line is @@ -85,12 +61,9 @@ inline void draw(DisplayDriver& d, // stationary target shows "ETA: --" rather than a misleading time. if (eta) { eta->update(dist_km, millis()); - if (eta->have && eta->closing_kmh > 0.3f) { - int secs = (int)(dist_km / eta->closing_kmh * 3600.0f); + char eta_s[12]; + if (eta->eta(dist_km, eta_s, sizeof(eta_s))) { float spd = imperial ? eta->closing_kmh * 0.621371f : eta->closing_kmh; - char eta_s[12]; - if (secs < 3600) snprintf(eta_s, sizeof(eta_s), "%dm", (secs + 59) / 60); - else snprintf(eta_s, sizeof(eta_s), "%dh%02dm", secs / 3600, (secs % 3600) / 60); snprintf(line, sizeof(line), "ETA: %s @%d%s", eta_s, (int)(spd + 0.5f), imperial ? "mph" : "kmh"); } else { snprintf(line, sizeof(line), "ETA: --"); diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 7b2c7c1f..665fdaf1 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -272,27 +272,17 @@ private: was ? 800 : 1000); } void handleReset() { - if (_store->isActive()) _store->setActive(false); - _store->clear(); + _task->core().trail.reset(); _task->showAlert("Trail reset", 800); } void handleSave() { - DataStore* ds = the_mesh.getDataStore(); - if (!ds) { _task->showAlert("FS unavailable", 800); return; } - File f = ds->openWrite(TRAIL_FILE); - if (!f) { _task->showAlert("Save failed", 800); return; } - bool ok = _store->writeTo(f); - f.close(); - _task->showAlert(ok ? "Trail saved" : "Save failed", 800); + TrailEngine::FileResult r = _task->core().trail.save(); + _task->showAlert(r == TrailEngine::FILE_OK ? "Trail saved" : r == TrailEngine::FILE_NO_FS ? "FS unavailable" : "Save failed", 800); } void handleLoad() { - DataStore* ds = the_mesh.getDataStore(); - if (!ds) { _task->showAlert("FS unavailable", 800); return; } - File f = ds->openRead(TRAIL_FILE); - if (!f) { _task->showAlert("No saved trail", 800); return; } - bool ok = _store->readFrom(f); - f.close(); - _task->showAlert(ok ? "Trail loaded" : "Load failed", 800); + TrailEngine::FileResult r = _task->core().trail.load(); + _task->showAlert(r == TrailEngine::FILE_OK ? "Trail loaded" : r == TrailEngine::FILE_NO_FS ? "FS unavailable" + : r == TrailEngine::FILE_MISSING ? "No saved trail" : "Load failed", 800); } void handleExport() { if (!Serial) { _task->showAlert("Connect USB first", 1200); return; } @@ -448,14 +438,7 @@ private: return false; } - static bool savedTrailExists() { - DataStore* ds = the_mesh.getDataStore(); - if (!ds) return false; - File f = ds->openRead(TRAIL_FILE); - bool ok = (bool)f; - if (f) f.close(); - return ok; - } + static bool savedTrailExists() { return TrailEngine::savedExists(); } // GPS / unit helpers shared by the trail views and the map. (Waypoint list / // navigation / add / mark / rename / delete / send moved to WaypointsView.) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index f21e62e2..6f855c15 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -168,6 +168,7 @@ static const int QUICK_MSGS_MAX = 10; #include "BotScreen.h" #include "AdminScreen.h" #include "../ui-core/NearbyModel.h" +#include "../ui-core/RadioControl.h" #include "NearbyScreen.h" #include "DashboardConfigScreen.h" #include "AutoAdvertScreen.h" @@ -1638,16 +1639,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no _alert_expiry = 0; _batt_mv = UITaskBase::getBattMilliVolts(); // seed EMA with first reading - // Load persisted waypoints (table survives reboots, unlike the RAM trail). - { - DataStore* ds = the_mesh.getDataStore(); - if (ds) { - File f = ds->openRead("/waypoints"); - if (f) { _waypoints.readFrom(f); f.close(); } - } - } - - _core = new UiCore(); // before any screen -- MessagesScreen binds to its history + _core = new UiCore(); // also loads the persisted waypoints // before any screen -- MessagesScreen binds to its history _core->begin(node_prefs, sensors, this); splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); @@ -3031,19 +3023,12 @@ void UITask::quickShareMyLocation() { shareToMessage(text); } -void UITask::saveWaypoints() { - DataStore* ds = the_mesh.getDataStore(); - if (!ds) return; - File f = ds->openWrite("/waypoints"); - if (!f) return; - _waypoints.writeTo(f); - f.close(); -} +WaypointStore& UITask::waypoints() { return _core->waypoints.store(); } +void UITask::saveWaypoints() { _core->waypoints.save(); } bool UITask::addWaypoint(int32_t lat, int32_t lon, uint32_t ts, const char* label) { - if (_waypoints.full()) { showAlert("Waypoints full", 1000); return false; } - if (_waypoints.add(lat, lon, ts, label)) { - saveWaypoints(); + if (_core->waypoints.full()) { showAlert("Waypoints full", 1000); return false; } + if (_core->waypoints.add(lat, lon, ts, label)) { showAlert("Waypoint saved", 800); return true; } @@ -3138,19 +3123,10 @@ void UITask::toggleGPS() { // Home-page manual toggle and the bot's !gps on/off command, which needs to // set a specific state rather than flip whatever it currently is. void UITask::applyGpsState(bool on) { - if (_sensors == NULL) return; - int num = _sensors->getNumSettings(); - for (int i = 0; i < num; i++) { - if (strcmp(_sensors->getSettingName(i), "gps") == 0) { - _sensors->setSettingValue("gps", on ? "1" : "0"); - _node_prefs->gps_enabled = on ? 1 : 0; - notify(UIEventType::ack); - the_mesh.savePrefs(); - showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); - _next_refresh = 0; - break; - } - } + if (!_core->setGpsEnabled(on)) return; // no GPS on this board + notify(UIEventType::ack); + showAlert(on ? "GPS: Enabled" : "GPS: Disabled", 800); + _next_refresh = 0; } void UITask::botSetGPS(bool on) { @@ -3355,11 +3331,7 @@ bool UITask::botGetGPIOAnalog(int idx, int& millivolts) { } void UITask::applyTxPower() { - if (_node_prefs == NULL) return; - // With APC on, tx_power_dbm is the ceiling — re-baseline the controller to it - // (which also sets the radio) so the live power tracks the new ceiling at once. - if (_node_prefs->tx_apc) { the_mesh.applyApc(); return; } - radio_driver.setTxPower(_node_prefs->tx_power_dbm); + radioctl::applyTxPower(_node_prefs); } void UITask::applyPowerSave() { @@ -3375,7 +3347,7 @@ void UITask::applyPowerSave() { } void UITask::applyApc() { - the_mesh.applyApc(); // (re)initialise Adaptive Power Control from prefs + radioctl::applyApc(); } #if ENV_INCLUDE_GPS == 1 @@ -3389,7 +3361,7 @@ void UITask::applyGpsInterval() { void UITask::applyRadioParams() { if (_node_prefs == NULL) return; - the_mesh.applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set + radioctl::applyParams(); // companion params, or the repeater profile if relaying with one set } void UITask::applyBrightness() { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 9b9ea870..7cdf47e8 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -118,7 +118,6 @@ class UITask : public UITaskBase, public UiCoreHost { UIScreen* gpio_screen = nullptr; #endif UIScreen* curr = nullptr; - WaypointStore _waypoints; // Runs the UI Core engines and reacts to their events (alert overlay, buzzer, // display wake). Driven from loop() regardless of the current screen. @@ -343,7 +342,7 @@ public: bool isRinging() const; void dismissRing(); TrailStore& trail(); - WaypointStore& waypoints() { return _waypoints; } + WaypointStore& waypoints(); // the Core's (ui-core/WaypointModel.h) LiveTrackStore& liveTrack(); // Shared on-screen keyboard — only one screen drives it at a time. KeyboardWidget& keyboard() { return _kb; } diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index be2239ec..7a7bdb32 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -14,6 +14,8 @@ #include "PopupMenu.h" #include "NavView.h" #include "DigitEditor.h" +#include "../ui-core/GpsAverager.h" +#include "../ui-core/TrackBack.h" class WaypointsView { UITask* _task; @@ -26,23 +28,14 @@ class WaypointsView { int _scroll = 0; // Track-back (Tools › Trail › Track back): retrace the recorded trail in - // reverse using NavView. _tb_idx is the current target breadcrumb; it walks - // down to 0 (the start) as each is reached within TB_ARRIVE_M. - static const int TB_ARRIVE_M = 20; - int _tb_idx = 0; + // reverse using NavView (ui-core/TrackBack.h picks the breadcrumb). + TrackBack _tb; navview::EtaTracker _tb_eta; navview::EtaTracker _wp_eta; // same readout for plain waypoint navigation - // GPS averaging (Tools › Trail › Settings › Mark avg). When enabled, markHere() - // accumulates fixes for gps_avg_idx seconds and marks the mean position — a - // steadier mark than one instantaneous fix. Sampling runs in poll() on a 1 s - // gate, independent of (slow, on e-ink) redraws. int64 sums: 30 samples × - // ~180e6 overflows int32. - long long _avg_sum_lat = 0, _avg_sum_lon = 0; - uint32_t _avg_n = 0; // fixes accumulated so far - uint32_t _avg_end_ms = 0; // millis() when the averaging window closes - uint32_t _avg_next_ms = 0; // millis() of the next sample - uint16_t _avg_total_s = 0; // configured window length (for the readout) + // GPS averaging (Tools › Trail › Settings › Mark avg): markHere() averages + // fixes for gps_avg_idx seconds (ui-core/GpsAverager.h), sampled from poll(). + GpsAverager _avg; PopupMenu _ctx; // Rename / Delete / Send on a selected waypoint bool _kb_active = false; @@ -181,12 +174,10 @@ class WaypointsView { display.drawCenteredHeader("AVERAGING GPS"); const int top = display.listStart(); const int step = display.lineStep(); - int remain = (int)((int32_t)(_avg_end_ms - millis()) / 1000); - if (remain < 0) remain = 0; char line[28]; - snprintf(line, sizeof(line), "%ds left (of %us)", remain, (unsigned)_avg_total_s); + snprintf(line, sizeof(line), "%ds left (of %us)", _avg.remainingSecs(), (unsigned)_avg.totalSecs()); display.setCursor(2, top); display.print(line); - snprintf(line, sizeof(line), "Samples: %u", (unsigned)_avg_n); + snprintf(line, sizeof(line), "Samples: %u", (unsigned)_avg.samples()); display.setCursor(2, top + step); display.print(line); display.setCursor(2, top + 2 * step); display.print("Cancel to abort"); } @@ -242,13 +233,12 @@ class WaypointsView { // Navigate to the current track-back breadcrumb. The header doubles as the // progress readout: "Trail start" on the last leg, else points still to go. void renderTrackBack(DisplayDriver& display) { - if (_tb_idx < 0 || _tb_idx >= _store->count()) { _mode = OFF; return; } - const TrailPoint& t = _store->at(_tb_idx); + if (!_tb.active() || _tb.index() >= _store->count()) { _mode = OFF; return; } + const TrailPoint& t = _store->at(_tb.index()); int32_t mylat, mylon; bool have = ownPos(mylat, mylon); int cog; bool cogv = _task->currentCourse(cog); char label[20]; - if (_tb_idx == 0) snprintf(label, sizeof(label), "Trail start"); - else snprintf(label, sizeof(label), "Back: %d pt", _tb_idx); + _tb.label(label, sizeof(label)); navview::draw(display, have, mylat, mylon, t.lat_1e6, t.lon_1e6, label, cogv, cog, useImperial(), &_tb_eta); } @@ -296,11 +286,7 @@ public: uint8_t avg_idx = p ? p->gps_avg_idx : 0; if (avg_idx == 0) { beginLabel(lat, lon); return; } // instant mark (default) // Averaging: seed with the current fix, then sample for N more seconds. - _avg_total_s = NodePrefs::gpsAvgSecs(avg_idx); - _avg_sum_lat = lat; _avg_sum_lon = lon; _avg_n = 1; - uint32_t now = millis(); - _avg_end_ms = now + (uint32_t)_avg_total_s * 1000; - _avg_next_ms = now + 1000; + _avg.start(NodePrefs::gpsAvgSecs(avg_idx), lat, lon); _mode = AVG; } @@ -308,17 +294,9 @@ public: // nearest recorded point, then NavView guides to each earlier breadcrumb in // turn (poll() advances the target) until the start is reached. void startTrackBack() { - if (_store->count() < 2) { _task->showAlert("No trail", 1000); return; } - int idx = _store->count() - 1; // default: the newest end of the trail - int32_t lat, lon; - if (ownPos(lat, lon)) { // else snap to the nearest recorded point - float best = 1e30f; - for (int i = 0; i < _store->count(); i++) { - float d = geo::haversineKm(lat, lon, _store->at(i).lat_1e6, _store->at(i).lon_1e6); - if (d < best) { best = d; idx = i; } - } - } - _tb_idx = idx; + int32_t lat = 0, lon = 0; + bool have = ownPos(lat, lon); + if (!_tb.start(*_store, have, lat, lon)) { _task->showAlert("No trail", 1000); return; } _tb_eta.reset(); _mode = TRACKBACK; } @@ -332,30 +310,22 @@ public: // Accumulate GPS fixes while averaging; mark the mean when the window closes. void pollAvg() { - uint32_t now = millis(); - if ((int32_t)(now - _avg_next_ms) >= 0) { - int32_t lat, lon; - if (ownPos(lat, lon)) { _avg_sum_lat += lat; _avg_sum_lon += lon; _avg_n++; } - _avg_next_ms = now + 1000; - } - if ((int32_t)(now - _avg_end_ms) >= 0) { // window closed → mark the mean - if (_avg_n == 0) { _task->showAlert("No GPS fix", 1000); _mode = OFF; return; } - int32_t mlat = (int32_t)(_avg_sum_lat / (long long)_avg_n); - int32_t mlon = (int32_t)(_avg_sum_lon / (long long)_avg_n); - beginLabel(mlat, mlon); // opens the label keyboard - } + int32_t lat = 0, lon = 0, mlat, mlon; + bool fix = ownPos(lat, lon); + GpsAverager::Step st = _avg.poll(fix, lat, lon, mlat, mlon); + if (st == GpsAverager::DONE) beginLabel(mlat, mlon); // window closed: opens the label keyboard + else if (st == GpsAverager::NO_FIX) { _task->showAlert("No GPS fix", 1000); _mode = OFF; } } // Advance the track-back target when the current breadcrumb is reached; once // at the start (index 0) and within range, announce arrival and exit. void pollTrackBack() { - int32_t lat, lon; - if (!ownPos(lat, lon)) return; - float d_m = geo::haversineKm(lat, lon, _store->at(_tb_idx).lat_1e6, - _store->at(_tb_idx).lon_1e6) * 1000.0f; - if (d_m > (float)TB_ARRIVE_M) return; - if (_tb_idx > 0) { _tb_idx--; _tb_eta.reset(); } - else { _task->showAlert("Back at start", 1500); _mode = OFF; } + int32_t lat = 0, lon = 0; + bool have = ownPos(lat, lon); + TrackBack::Step st = _tb.poll(*_store, have, lat, lon); + if (st == TrackBack::ADVANCED) _tb_eta.reset(); + else if (st == TrackBack::ARRIVED) { _task->showAlert("Back at start", 1500); _mode = OFF; } + else if (!_tb.active()) _mode = OFF; // trail went away } // Only called while active(). @@ -430,7 +400,7 @@ public: // Averaging window — any cancel aborts the mark; otherwise just wait it out. if (_mode == AVG) { - if (c == KEY_CANCEL) _mode = OFF; + if (c == KEY_CANCEL) { _avg.cancel(); _mode = OFF; } return true; } @@ -472,7 +442,7 @@ public: // Track-back — launched from the action menu, so Cancel returns to the // trail views (not the waypoint list). if (_mode == TRACKBACK) { - if (c == KEY_CANCEL) _mode = OFF; + if (c == KEY_CANCEL) { _tb.stop(); _mode = OFF; } return true; }