diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 89e44745..3ccf5f8e 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -394,6 +394,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no // → 0xC0DE0014: geo-alert proximity beeper. rd(&_prefs.geo_alert_beeper, sizeof(_prefs.geo_alert_beeper)); if (_prefs.geo_alert_beeper > 1) _prefs.geo_alert_beeper = 0; + // → 0xC0DE0015: geo-alert can target a live contact (kind + pubkey prefix). + rd(&_prefs.geo_alert_target_kind, sizeof(_prefs.geo_alert_target_kind)); + rd(_prefs.geo_alert_key, sizeof(_prefs.geo_alert_key)); + if (_prefs.geo_alert_target_kind > 1) _prefs.geo_alert_target_kind = 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 @@ -590,6 +594,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)_prefs.geo_alert_label, sizeof(_prefs.geo_alert_label)); file.write((uint8_t *)&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx)); file.write((uint8_t *)&_prefs.geo_alert_beeper, sizeof(_prefs.geo_alert_beeper)); + file.write((uint8_t *)&_prefs.geo_alert_target_kind, sizeof(_prefs.geo_alert_target_kind)); + file.write((uint8_t *)_prefs.geo_alert_key, sizeof(_prefs.geo_alert_key)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL; diff --git a/examples/companion_radio/LiveTrack.h b/examples/companion_radio/LiveTrack.h index b1fbdae9..8301f5f5 100644 --- a/examples/companion_radio/LiveTrack.h +++ b/examples/companion_radio/LiveTrack.h @@ -78,6 +78,19 @@ public: return n; } + // Latest active position for a verified (DM/pubkey) share, or null if that + // node hasn't shared recently. Used by the geo-alert engine to follow a + // moving contact. + const Entry* activeByKey(const uint8_t* key, uint32_t now) const { + if (!key) return nullptr; + for (int i = 0; i < CAPACITY; i++) { + if (!isActive(i, now)) continue; + const Entry& e = _e[i]; + if (e.verified && memcmp(e.key, key, KEY_LEN) == 0) return &e; + } + return nullptr; + } + private: Entry _e[CAPACITY] = {}; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index d393a0f7..c8bc3935 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -244,9 +244,14 @@ struct NodePrefs { // persisted to file uint8_t geo_alert_has_target; // 0=no target chosen yet, 1=target set uint8_t geo_alert_radius_idx; // index into geoAlertRadiusMeters uint8_t geo_alert_mode; // 0=arrive, 1=leave, 2=both - int32_t geo_alert_lat_1e6; // target latitude (1e6-scaled) - int32_t geo_alert_lon_1e6; // target longitude (1e6-scaled) + int32_t geo_alert_lat_1e6; // target latitude (1e6-scaled; last-known for a contact) + int32_t geo_alert_lon_1e6; // target longitude (1e6-scaled; last-known for a contact) char geo_alert_label[12]; // target name for the alert text (WAYPOINT_LABEL_LEN) + // 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 geo_alert_target_kind; // 0=waypoint (static), 1=live contact + uint8_t geo_alert_key[6]; // contact pubkey prefix when target_kind==1 // Trail auto-pause — when tracking, automatically freeze the trail (timer + // sampling) after the device has sat still for this long, and resume on the @@ -310,7 +315,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 = 0xC0DE0014; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0015; // 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/GeoAlertScreen.h b/examples/companion_radio/ui-new/GeoAlertScreen.h index 56ed208f..db406b11 100644 --- a/examples/companion_radio/ui-new/GeoAlertScreen.h +++ b/examples/companion_radio/ui-new/GeoAlertScreen.h @@ -9,6 +9,7 @@ #include "../NodePrefs.h" #include "../Waypoint.h" +#include "../LiveTrack.h" #include "icons.h" // drawList (shared scrolling-list helper) class GeoAlertScreen : public UIScreen { @@ -43,12 +44,14 @@ public: snprintf(buf, n, "%s", (_prefs && _prefs->geo_alert_enabled) ? "ON" : "OFF"); break; case K_TARGET: - if (_prefs && _prefs->geo_alert_has_target && _prefs->geo_alert_label[0]) - snprintf(buf, n, "%s", _prefs->geo_alert_label); - else if (_prefs && _prefs->geo_alert_has_target) - snprintf(buf, n, "(unnamed)"); - else + if (_prefs && _prefs->geo_alert_has_target) { + const char* nm = _prefs->geo_alert_label[0] ? _prefs->geo_alert_label : "(unnamed)"; + // '@' prefix marks a live contact target (a moving person) vs a waypoint. + if (_prefs->geo_alert_target_kind == 1) snprintf(buf, n, "@%s", nm); + else snprintf(buf, n, "%s", nm); + } else { snprintf(buf, n, "none"); + } break; case K_RADIUS: { uint16_t r = NodePrefs::geoAlertRadiusMeters(_prefs ? _prefs->geo_alert_radius_idx : 1); @@ -118,27 +121,48 @@ public: _task->resetGeoAlert(); } - // Cycle the target through the saved waypoints, snapshotting the chosen one's - // coordinate + label into prefs so the alert is independent of later edits. + // Cycle the target through saved waypoints (static) and verified live contacts + // (a person sharing [LOC] over a DM), snapshotting the chosen one into prefs. + // A waypoint stores coord+label; a contact stores the pubkey prefix so the + // engine can follow its live position, plus the name and last-known coord. void cycleTarget(int dir) { - WaypointStore& wp = _task->waypoints(); - int total = wp.count(); - if (total == 0) { _task->showAlert("Mark a waypoint first", 1400); return; } + WaypointStore& wp = _task->waypoints(); + LiveTrackStore& lt = _task->liveTrack(); + uint32_t now = rtc_clock.getCurrentTime(); - // Locate the current target among the waypoints (by coordinate match). + struct T { uint8_t kind; int idx; }; // kind 0=waypoint, 1=live contact (slot) + T list[32]; int n = 0; + for (int i = 0; i < wp.count() && n < 32; i++) list[n++] = { 0, i }; + for (int i = 0; i < LiveTrackStore::CAPACITY && n < 32; i++) + if (lt.isActive(i, now) && lt.slotAt(i).verified) list[n++] = { 1, i }; + if (n == 0) { _task->showAlert("No waypoints / live", 1400); return; } + + // Locate the current target in the combined list. int cur = -1; - if (_prefs->geo_alert_has_target) { - for (int i = 0; i < total; i++) - if (wp.at(i).lat_1e6 == _prefs->geo_alert_lat_1e6 && - wp.at(i).lon_1e6 == _prefs->geo_alert_lon_1e6) { cur = i; break; } + for (int i = 0; i < n; i++) { + if (list[i].kind == 0 && _prefs->geo_alert_target_kind == 0) { + const Waypoint& w = wp.at(list[i].idx); + if (w.lat_1e6 == _prefs->geo_alert_lat_1e6 && w.lon_1e6 == _prefs->geo_alert_lon_1e6) { cur = i; break; } + } else if (list[i].kind == 1 && _prefs->geo_alert_target_kind == 1) { + if (memcmp(lt.slotAt(list[i].idx).key, _prefs->geo_alert_key, LiveTrackStore::KEY_LEN) == 0) { cur = i; break; } + } } - int nx = (cur < 0) ? (dir >= 0 ? 0 : total - 1) - : ((cur + (dir >= 0 ? 1 : total - 1)) % total); + int nx = (cur < 0) ? (dir >= 0 ? 0 : n - 1) : ((cur + (dir >= 0 ? 1 : n - 1)) % n); - const Waypoint& w = wp.at(nx); - _prefs->geo_alert_lat_1e6 = w.lat_1e6; - _prefs->geo_alert_lon_1e6 = w.lon_1e6; - snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", w.label); + if (list[nx].kind == 0) { + const Waypoint& w = wp.at(list[nx].idx); + _prefs->geo_alert_target_kind = 0; + _prefs->geo_alert_lat_1e6 = w.lat_1e6; + _prefs->geo_alert_lon_1e6 = w.lon_1e6; + snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", w.label); + } else { + const LiveTrackStore::Entry& e = lt.slotAt(list[nx].idx); + _prefs->geo_alert_target_kind = 1; + memcpy(_prefs->geo_alert_key, e.key, LiveTrackStore::KEY_LEN); + _prefs->geo_alert_lat_1e6 = e.lat_1e6; + _prefs->geo_alert_lon_1e6 = e.lon_1e6; + snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", e.name); + } _prefs->geo_alert_has_target = 1; _dirty = true; } diff --git a/examples/companion_radio/ui-new/NavView.h b/examples/companion_radio/ui-new/NavView.h index 10bea16e..4375d3ac 100644 --- a/examples/companion_radio/ui-new/NavView.h +++ b/examples/companion_radio/ui-new/NavView.h @@ -12,14 +12,42 @@ 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 +// shown with the approach speed and estimated time of arrival inline void draw(DisplayDriver& d, bool own_valid, int32_t own_lat, int32_t own_lon, int32_t tgt_lat, int32_t tgt_lon, const char* label, bool cog_valid, int cog_deg, - bool imperial) { + bool imperial, + EtaTracker* eta = nullptr) { const int cx = d.width() / 2; const int hdr = d.headerH(); @@ -52,6 +80,23 @@ inline void draw(DisplayDriver& d, if (cog_valid) snprintf(line, sizeof(line), "Hdg: %d %s", cog_deg, geo::bearingCardinal(cog_deg)); else snprintf(line, sizeof(line), "Hdg: --"); d.drawTextLeftAlign(2, y, line); y += step; + + // Optional ETA / closing-speed line. Approaching only — a receding or + // 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); + 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: --"); + } + d.drawTextLeftAlign(2, y, line); y += step; + } } } // namespace navview diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 85d9618e..2b13c9d0 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -58,6 +58,7 @@ class NearbyScreen : public UIScreen { int _scroll; bool _detail; bool _nav = false; // full-screen navigate-to-node view (over detail) + navview::EtaTracker _nav_eta; // closing-speed/ETA for the navigate view int32_t _own_lat, _own_lon; bool _own_gps; @@ -68,6 +69,7 @@ class NearbyScreen : public UIScreen { unsigned long _detail_refresh_ms; unsigned long _list_refresh_ms = 0; static const unsigned long DETAIL_REFRESH_MS = 10000UL; + static const unsigned long NAV_REFRESH_MS = 1000UL; // navigate view tracks a moving target static const unsigned long TIME_LIST_REFRESH_MS = 3000UL; // ── live-scan state ────────────────────────────────────────────────────────── @@ -447,7 +449,7 @@ class NearbyScreen : public UIScreen { switch (a) { case ACT_NAV: { const Entry* e = selected(); - if (e && (e->lat_e6 != 0 || e->lon_e6 != 0)) _nav = true; + if (e && (e->lat_e6 != 0 || e->lon_e6 != 0)) { _nav = true; _nav_eta.reset(); } else _task->showAlert("No node GPS", 1000); break; } @@ -493,7 +495,7 @@ class NearbyScreen : public UIScreen { fmtAge(age, sizeof(age), e.lastmod); // For a live [LOC] row, label the timestamp as a position share and note // whether the sender's identity is verified (DM) or name-only (channel). - if (e.is_live) snprintf(buf, sizeof(buf), "Loc: %s %s", age, e.live_verified ? "(DM)" : "(chan~)"); + if (e.is_live) snprintf(buf, sizeof(buf), "Sharing pos: %s %s", age, e.live_verified ? "(DM)" : "(chan)"); else snprintf(buf, sizeof(buf), "Seen: %s", age); display.drawTextEllipsized(2, hdr + step * 4, display.width() - 4, buf); } @@ -573,16 +575,33 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); - // periodic refresh in stored-detail — preserve selected contact by idx - if (_detail && _source == SRC_STORED && - millis() - _detail_refresh_ms >= DETAIL_REFRESH_MS) { - int saved = (_sel < _count) ? _entries[_sel].contact_idx : -1; + // Periodic refresh of the selected entry while in detail or navigate view, + // preserving the selection across the list rebuild. Navigate refreshes + // faster so a moving live target (a contact sharing [LOC]) tracks smoothly. + // Re-selection keys on contact index for stored nodes, and on name for a + // non-contact live sender — without the latter, navigating to someone who + // only shares on a channel would drop out on the first refresh. + unsigned long refresh_due = _nav ? NAV_REFRESH_MS : DETAIL_REFRESH_MS; + if ((_detail || _nav) && _source == SRC_STORED && + millis() - _detail_refresh_ms >= refresh_due) { + int saved_idx = (_sel < _count) ? _entries[_sel].contact_idx : -1; + bool saved_live = (_sel < _count) && _entries[_sel].is_live && saved_idx < 0; + char saved_name[sizeof(_entries[0].name)]; saved_name[0] = '\0'; + if (_sel < _count) { + strncpy(saved_name, _entries[_sel].name, sizeof(saved_name) - 1); + saved_name[sizeof(saved_name) - 1] = '\0'; + } refreshStored(); bool found = false; - if (saved >= 0) + if (saved_idx >= 0) { for (int i = 0; i < _count; i++) - if (_entries[i].contact_idx == saved) { _sel = i; found = true; break; } - if (!found) { _detail = false; _nav = false; } // contact gone — drop both views + if (_entries[i].contact_idx == saved_idx) { _sel = i; found = true; break; } + } else if (saved_live && saved_name[0]) { + for (int i = 0; i < _count; i++) + if (_entries[i].is_live && strncmp(_entries[i].name, saved_name, sizeof(saved_name) - 1) == 0) + { _sel = i; found = true; break; } + } + if (!found) { _detail = false; _nav = false; } // node/share gone — drop both views _detail_refresh_ms = millis(); } @@ -591,7 +610,7 @@ public: const Entry& e = _entries[_sel]; int cog; bool cogv = _task->currentCourse(cog); navview::draw(display, _own_gps, _own_lat, _own_lon, - e.lat_e6, e.lon_e6, e.name, cogv, cog, useImperial()); + e.lat_e6, e.lon_e6, e.name, cogv, cog, useImperial(), &_nav_eta); return 1000; } @@ -655,18 +674,20 @@ public: display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); char filt[32]; + int tx = 2; if (e.is_live) { - // Mark a live [LOC] row: '*' = verified DM share, '~' = channel share. - char nm[34]; - snprintf(nm, sizeof(nm), "%s%s", e.live_verified ? "*" : "~", e.name); - display.translateUTF8ToBlocks(filt, nm, sizeof(filt)); - } else { - display.translateUTF8ToBlocks(filt, e.name, sizeof(filt)); + // Diamond marker = this node is broadcasting its position ([LOC]), + // the same marker the map uses for a live-tracked contact. DM-verified + // vs channel-only is spelled out in the detail view. + int iw = ICON_MAP_CONTACT.w * miniIconScale(display); + miniIconDrawCentered(display, 2 + iw / 2, y + display.getLineHeight() / 2 - 1, ICON_MAP_CONTACT); + tx = 2 + iw + 2; } + display.translateUTF8ToBlocks(filt, e.name, sizeof(filt)); if (_source == SRC_SCAN && !e.name[0]) { // unknown node → "[Type]" snprintf(filt, sizeof(filt), "[%s]", typeName(e.type)); } - display.drawTextEllipsized(2, y, dist_col - 4, filt); + display.drawTextEllipsized(tx, y, dist_col - tx - 2, filt); display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); char right[10]; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9e9d0b6a..a9fd2f74 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1225,6 +1225,10 @@ public: _task->gotoDashboardConfig(); return true; } + if (c == KEY_CONTEXT_MENU && _page == HomePage::MAP) { + _task->quickShareMyLocation(); + return true; + } return false; } }; @@ -2129,10 +2133,20 @@ void UITask::loop() { // evaluator and the proximity beeper. bool UITask::geoAlertDistance(float& dist_m, float& radius_m) const { if (!_node_prefs || !_node_prefs->geo_alert_has_target) return false; + int32_t tlat, tlon; + if (_node_prefs->geo_alert_target_kind == 1) { + // Live contact: follow the latest [LOC] position. No recent share → no + // distance to evaluate (engine waits rather than using a stale fix). + const LiveTrackStore::Entry* e = + _livetrack.activeByKey(_node_prefs->geo_alert_key, (uint32_t)rtc_clock.getCurrentTime()); + if (!e) return false; + tlat = e->lat_1e6; tlon = e->lon_1e6; + } else { + tlat = _node_prefs->geo_alert_lat_1e6; tlon = _node_prefs->geo_alert_lon_1e6; + } int32_t lat, lon; if (!currentLocation(lat, lon)) return false; - dist_m = geo::haversineKm(lat, lon, _node_prefs->geo_alert_lat_1e6, - _node_prefs->geo_alert_lon_1e6) * 1000.0f; + dist_m = geo::haversineKm(lat, lon, tlat, tlon) * 1000.0f; radius_m = (float)NodePrefs::geoAlertRadiusMeters(_node_prefs->geo_alert_radius_idx); return true; } @@ -2160,8 +2174,12 @@ void UITask::evaluateGeoAlert() { void UITask::fireGeoAlert(bool arrived) { const char* lbl = _node_prefs->geo_alert_label[0] ? _node_prefs->geo_alert_label : "target"; + bool person = _node_prefs->geo_alert_target_kind == 1; char msg[40]; - snprintf(msg, sizeof(msg), arrived ? "Arrived: %s" : "Left: %s", lbl); + // "Near/Away" reads naturally for a moving person; "Arrived/Left" for a place. + snprintf(msg, sizeof(msg), + arrived ? (person ? "Near: %s" : "Arrived: %s") + : (person ? "Away: %s" : "Left: %s"), lbl); showAlert(msg, 3000); if (!isBuzzerQuiet()) playMelody(arrived ? "geoarr:d=8,o=6,b=140:c,e,g" : "geolv:d=8,o=6,b=140:g,e,c"); @@ -2277,6 +2295,22 @@ bool UITask::sendLocationShare(int32_t lat, int32_t lon) { return the_mesh.sendMessage(*c, rtc_clock.getCurrentTime(), 0, text, expected_ack, est_timeout) > 0; } +// One-shot "share my position" from the home Map page (Hold Enter). When live +// sharing is already on, push an immediate [LOC] to the same target; otherwise +// hand a [LOC] message to the recipient picker so the user chooses where it +// goes (no accidental broadcast to a default channel). +void UITask::quickShareMyLocation() { + int32_t lat, lon; + if (!currentLocation(lat, lon)) { showAlert("No GPS fix", 1000); return; } + if (_node_prefs && _node_prefs->loc_share_enabled && sendLocationShare(lat, lon)) { + showAlert("Position shared", 900); + return; + } + char text[40]; + snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6); + shareToMessage(text); +} + void UITask::saveWaypoints() { DataStore* ds = the_mesh.getDataStore(); if (!ds) return; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 97307a35..84457e22 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -179,6 +179,7 @@ public: void gotoQuickMsgScreen(); void openContactDM(const ContactInfo& ci); void shareToMessage(const char* text); // open Messages pre-loaded to share `text` + void quickShareMyLocation(); // Home Map Hold-Enter: one-shot position share void pickLocShareTarget(); // open Messages to choose the live-share target int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const; void gotoToolsScreen();