diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 0cd842f6..65778505 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -127,6 +127,7 @@ Cycle views with **LEFT / RIGHT**: | ---------- | --------- | ------------------------------------------------------- | | Min dist | always | Sample gate, 4 levels — metric: 5/10/25/100 m, imperial: 15/30/75/300 ft | | Auto-pause | always | Off / 1 / 2 / 5 min — auto-freeze the trail after a stop, resume on movement (see below) | +| Mark avg | always | Off / 5 / 10 / 30 s — GPS averaging for **Mark here** (see Waypoints below) | | Readout | Summary view | Summary shows Speed or Pace (in the global unit system) | | Grid | Map view | Toggle scale grid on the map | @@ -140,6 +141,8 @@ A waypoint is a saved spot — your car, camp, a water source — that you can n **Dropping a waypoint** — **Hold Enter → Mark here**. This captures the current GPS fix and opens the on-screen keyboard for a short label (up to 11 characters — e.g. `CAR`, `CAMP`, `H2O`). Leaving it blank auto-names it `WP1`, `WP2`, … Marking works whether or not the trail is being recorded; it needs a GPS fix (otherwise it reports *No GPS fix*). +**GPS averaging** — with **Settings → Mark avg** set (5 / 10 / 30 s), *Mark here* doesn't snapshot a single fix; it samples the GPS once a second for that window and stores the **mean** position, for a steadier mark than one instantaneous reading (handy for a precise spot — a cache, a car, a trailhead). A short screen shows the time left and the sample count while it runs; **Cancel** aborts. When the window closes it opens the label keyboard as usual. With **Mark avg = Off** (the default) marking is instant. + **Adding by coordinates** — open **Hold Enter → Waypoints** and select the **+ Add by coords** row (always the last entry in the list). This creates a waypoint without being there — no GPS fix required (handy for a meeting point or a spot read off a map). It opens a small form with three editable rows plus **Save**: | OLED | E-Ink | diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 38a046d6..8b0d0fc7 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -418,6 +418,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no 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; + // → 0xC0DE0016: 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; // Pre-0x10 files leave stray sentinel bytes here, same as a never-configured // device. Either way there's no valid saved profile, so default to a profile // in the same band as the companion's own network (_prefs.freq, already read @@ -618,6 +621,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.locator_beeper, sizeof(_prefs.locator_beeper)); file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind)); file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key)); + file.write((uint8_t *)&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx)); // 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/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 545e8991..7dcb3fd7 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -264,6 +264,12 @@ struct NodePrefs { // persisted to file // discrete arrive/leave alert (locator_mode). uint8_t locator_beeper; // 0=off (default), 1=on + // GPS averaging for waypoint marking — when set, "Mark here" samples the GPS + // fix for this many seconds and stores the mean position, for a more accurate + // mark than a single instantaneous fix. 0 = off (instant mark, the default). + // Index into gpsAvgSecs(). [Tools › Trail › Settings › Mark avg] + uint8_t gps_avg_idx; + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; @@ -295,6 +301,17 @@ struct NodePrefs { // persisted to file return L[m < LOCATOR_MODE_COUNT ? m : 0]; } + // GPS-averaging durations for waypoint marking (seconds). 0 = off (instant). + static const uint8_t GPS_AVG_COUNT = 4; + static uint16_t gpsAvgSecs(uint8_t idx) { + static const uint16_t S[GPS_AVG_COUNT] = { 0, 5, 10, 30 }; + return S[idx < GPS_AVG_COUNT ? idx : 0]; + } + static const char* gpsAvgLabel(uint8_t idx) { + static const char* L[GPS_AVG_COUNT] = { "Off", "5s", "10s", "30s" }; + return L[idx < GPS_AVG_COUNT ? idx : 0]; + } + // Trail auto-pause delays (seconds). 0 = off. static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4; static uint16_t trailAutoPauseSecs(uint8_t idx) { @@ -315,7 +332,7 @@ struct NodePrefs { // persisted to file // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). // High 24 bits identify the file format; low byte is the schema revision. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0015; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0016; // 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 diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 543682d9..1ad33722 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -72,7 +72,7 @@ class TrailScreen : public UIScreen { // (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so // the labels live in member buffers that get refreshed in openActionMenu() // and after every LEFT/RIGHT cycle. - enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS, + enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_MARK_AVG, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS, ACT_SHARE_NOW }; // The action popup is multi-level: a short main menu, plus "Trail file…" and // "Settings…" submenus. _menu_level tracks which is open so input is routed @@ -88,6 +88,7 @@ class TrailScreen : public UIScreen { char _act_units_label[24]; char _act_grid_label[16]; char _act_autopause_label[24]; + char _act_mark_avg_label[24]; char _act_toggle_label[20]; static const int SUMMARY_ITEM_COUNT = 5; @@ -136,6 +137,10 @@ public: return _store->isActive() ? 1000 : 5000; } + // Forward the loop tick to the waypoint UI so GPS averaging (Mark avg) can + // sample independently of the render cadence (slow on e-ink). + void poll() override { _wp.poll(); } + bool handleInput(char c) override { // Waypoint management UI consumes all input while active. if (_wp.active()) return _wp.handleInput(c); @@ -172,6 +177,7 @@ public: case ACT_MIN_DIST: case ACT_UNITS: case ACT_GRID: + case ACT_MARK_AVG: case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true; case ACT_SHARE_NOW: shareMyLocationNow(); break; case ACT_TOGGLE: @@ -318,6 +324,8 @@ private: "Grid: %s", _map_grid ? "ON" : "OFF"); snprintf(_act_autopause_label, sizeof(_act_autopause_label), "Auto-pause: %s", NodePrefs::trailAutoPauseLabel(p ? p->trail_autopause_idx : 0)); + snprintf(_act_mark_avg_label, sizeof(_act_mark_avg_label), + "Mark avg: %s", NodePrefs::gpsAvgLabel(p ? p->gps_avg_idx : 0)); snprintf(_act_toggle_label, sizeof(_act_toggle_label), "%s tracking", _store->isActive() ? "Stop" : "Start"); } @@ -382,6 +390,7 @@ private: _action_menu.begin("Settings", 4); pushAction(ACT_MIN_DIST, _act_min_dist_label); pushAction(ACT_AUTOPAUSE, _act_autopause_label); + pushAction(ACT_MARK_AVG, _act_mark_avg_label); if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label); if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label); } @@ -393,6 +402,7 @@ private: if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; } if (act == ACT_AUTOPAUSE && p){ cycleAutoPause(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } + if (act == ACT_MARK_AVG && p){ cycleMarkAvg(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } return false; } @@ -435,6 +445,13 @@ private: : (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT; p->trail_autopause_idx = idx; } + void cycleMarkAvg(NodePrefs* p, int dir) { + uint8_t idx = p->gps_avg_idx; + if (idx >= NodePrefs::GPS_AVG_COUNT) idx = 0; + idx = (dir > 0) ? (idx + 1) % NodePrefs::GPS_AVG_COUNT + : (idx + NodePrefs::GPS_AVG_COUNT - 1) % NodePrefs::GPS_AVG_COUNT; + p->gps_avg_idx = idx; + } // One-shot manual share: build "[LOC]lat,lon" and hand it to the Messages // screen, where the user picks a DM or channel recipient. (Auto live-share diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index 8b921523..5d03b405 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -20,11 +20,22 @@ class WaypointsView { TrailStore* _store; // Sub-modes layered over the trail views. OFF = the component is dormant. - enum Mode { OFF, LIST, NAV, ADD }; + enum Mode { OFF, LIST, NAV, ADD, AVG }; uint8_t _mode = OFF; int _sel = 0; int _scroll = 0; + // 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) + PopupMenu _ctx; // Rename / Delete / Send on a selected waypoint bool _kb_active = false; int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index @@ -99,6 +110,17 @@ class WaypointsView { _kb_active = true; } + // Capture a mark position and open the keyboard for its label. Shared by the + // instant "Mark here" and the end of GPS averaging. _mode goes OFF: the + // keyboard takes over the screen, and there's no sub-view to return to once + // the label is committed (active() then tracks _kb_active alone). + void beginLabel(int32_t lat, int32_t lon) { + _mode = OFF; + _mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime(); + _kb_rename_idx = -1; + openKb("", WAYPOINT_LABEL_LEN - 1); + } + // Commit a mark-here / rename label from the keyboard. void commitLabel(const char* buf) { if (_kb_rename_idx >= 0) { @@ -144,6 +166,23 @@ class WaypointsView { } } + // GPS-averaging progress screen. Sampling itself happens in poll(); this only + // reports remaining time and the running sample count. + void renderAvg(DisplayDriver& display) { + display.setColor(DisplayDriver::LIGHT); + 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); + display.setCursor(2, top); display.print(line); + snprintf(line, sizeof(line), "Samples: %u", (unsigned)_avg_n); + display.setCursor(2, top + step); display.print(line); + display.setCursor(2, top + 2 * step); display.print("Cancel to abort"); + } + void renderWpList(DisplayDriver& display) { display.setColor(DisplayDriver::LIGHT); char title[24]; @@ -220,9 +259,35 @@ public: int32_t lat, lon; if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; } if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; } - _mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime(); - _kb_rename_idx = -1; - openKb("", WAYPOINT_LABEL_LEN - 1); + NodePrefs* p = _task->getNodePrefs(); + 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; + _mode = AVG; + } + + // Accumulate GPS fixes while averaging. Driven from TrailScreen::poll() so it + // ticks on the main loop, not on the (slow on e-ink) render cadence. No-op + // unless an averaging window is open. + void poll() { + if (_mode != AVG) return; + 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 + } } // Only called while active(). @@ -231,6 +296,7 @@ public: display.setColor(DisplayDriver::LIGHT); if (_kb_active) return _task->keyboard().render(display); // keyboard owns the screen if (_mode == ADD) { renderAddForm(display); return 1000; } + if (_mode == AVG) { renderAvg(display); return display.isEink() ? 1000 : 300; } if (_mode == NAV) { renderWpNav(display); return 1000; } renderWpList(display); // LIST if (_ctx.active) _ctx.render(display); @@ -293,6 +359,12 @@ public: return true; } + // Averaging window — any cancel aborts the mark; otherwise just wait it out. + if (_mode == AVG) { + if (c == KEY_CANCEL) _mode = OFF; + return true; + } + // ADD form: scroll-edit lat/lon (magnitude) + hemisphere toggle + label. if (_mode == ADD) { // The digit editor, while open, consumes all input (UP/DOWN change the