diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 2d8b20e0..83891d4e 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -234,7 +234,7 @@ Periodically broadcasts a 0-hop advert with your GPS position. Configurable inte | :------------------------: | :------------------------: | | ![](./liveshare_oled.png) | ![](./liveshare_eink.png) | - + Share your live position over the mesh **as ordinary chat messages**, and put other people who do the same on your map. A position is sent as a `[LOC],` message — the same coordinate format waypoints use, so it stays readable on other firmware and the phone app (it just looks like a coordinate to anything that doesn't know the tag). @@ -245,8 +245,10 @@ The tool holds both directions of sharing in one flat list. Navigate with **UP/D | Setting | Options | Notes | | ---------- | --------------------------- | ---------------------------------------------------------------------------------------------- | | Track loc | ON / OFF | Receive incoming `[LOC]` shares (DM, monitored channels, and room-server posts) and pin those senders on the map / in Nearby. Off by default. | -| Auto share | ON / OFF | Periodically broadcast **your own** position to the target below while you move. | +| Auto share | ON / OFF | Periodically broadcast **your own** position to the target below while you move, for the length of **Stop after**. | +| Stop after | 1 / 2 / 4 / 8 / 12 h | **Length of one auto-share session** (default 1 h). When it runs out, Auto share switches itself off and shows `Live share ended` — sharing can never run indefinitely. The clock starts when you turn **Auto share** on (or change this value) and restarts if the device reboots while sharing is on. | | To | channel or contact | **Enter** opens the Messages recipient chooser to pick the target channel or DM contact. | +| Scope | Target / `*` / a named scope | Region for these `[LOC]` posts **only**, independent of your chat. **Target** (default) follows the target as before — a channel's own scope pick, or the list default for a DM. Any other value (from the shared list in Settings › Radio › Scope; `*` = unscoped) overrides it for Live Share and never changes how your normal messages are sent. A DM that already has a known route goes direct, where a scope has no effect. | | Move | 50 / 100 / 250 / 500 m | Movement gate — only send after you've moved at least this far since the last share. | | Min gap | 30 s / 1 / 2 / 5 min | Minimum time between sends, so fast movement can't flood the channel. | | Heartbeat | OFF / 5 / 15 min | Optional keep-alive: re-send even while stationary, so the other end knows you're still there. | diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 6201954d..da67ecb0 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -613,6 +613,15 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no rd(&_prefs.contact_expiry_idx, sizeof(_prefs.contact_expiry_idx)); if (_prefs.contact_expiry_idx >= NodePrefs::CONTACT_EXPIRY_COUNT) _prefs.contact_expiry_idx = 0; + // loc_share_scope. 0 = follow the target. A stray byte from an older file is + // out of range (or is zeroed below on the sentinel transition) -> follow. + rd(&_prefs.loc_share_scope, sizeof(_prefs.loc_share_scope)); + if (_prefs.loc_share_scope > ScopeList::MAX_SCOPE_ENTRIES + 1) _prefs.loc_share_scope = 0; + + // loc_share_duration_idx. A stray byte from an older file is out of range -> 1 h. + 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; + // 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 — @@ -635,6 +644,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no _prefs.repeat_extra_scope_mask = 0; memset(_prefs.ch_scope_idx, 0, sizeof(_prefs.ch_scope_idx)); } + // 0xC0DE002C → 0xC0DE002D: loc_share_scope appended. A 0x2C file's sentinel + // tail (0x2C) lands here and is in range, so zero it explicitly. + if (sentinel < 0xC0DE002D) _prefs.loc_share_scope = 0; } file.close(); @@ -805,6 +817,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask)); file.write((uint8_t *)_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx)); 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)); // 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/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 5b590379..b321c47b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -822,6 +822,12 @@ void MyMesh::removeScope(uint8_t idx) { if (ci == idx) _prefs.ch_scope_idx[i] = 0; else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1; } + // Live Share's own scope (stored as list index + 1; 0 = follow the target). + if (_prefs.loc_share_scope) { + uint8_t li = _prefs.loc_share_scope - 1; + if (li == idx) _prefs.loc_share_scope = 0; + else if (li > idx) _prefs.loc_share_scope--; + } syncLegacyDefaultScope(); // ScopeList::remove() may have moved or cleared the default if (_store) _store->saveScopeList(_scope_list); // The fix-ups above live in NodePrefs, not in /scopes1, so both files have to @@ -874,7 +880,9 @@ void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint3 void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) { // TODO: dynamic send_scope, depending on recipient and current 'home' Region - if (send_unscoped) { + if (_oneshot_scope_on) { + sendFloodScoped(_oneshot_scope, pkt, delay_millis); + } else if (send_unscoped) { sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped } else { TransportKey default_scope = _scope_list.key(_scope_list.default_idx); @@ -885,7 +893,9 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) { if (apcActive()) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK) trackRelaySend(pkt); // and for the UI "relayed" marker - if (send_unscoped) { + if (_oneshot_scope_on) { + sendFloodScoped(_oneshot_scope, pkt, delay_millis); + } else if (send_unscoped) { sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped } else if (!send_scope.isNull()) { // App-driven per-send override (CMD_SET_FLOOD_SCOPE_KEY) still wins over diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 509b2079..af20a432 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -396,6 +396,12 @@ public: // MyMesh -- edits go through setPrimaryScope()/addScope()/setChannelScope()/ // setDefaultScope() so repeat_scopes[]/persistence stay in sync. const ScopeList& scopeList() const { return _scope_list; } + // Force the scope of the NEXT sends to list index idx (0 = "*", unscoped), + // beating the app's send_scope/send_unscoped and a channel's own pick, until + // clearOneShotScope(). Bracket a synchronous send with these -- used by Live + // Share so its [LOC] posts can go to a different region than the chat does. + void setOneShotScope(uint8_t idx) { _oneshot_scope = _scope_list.key(idx); _oneshot_scope_on = true; } + void clearOneShotScope() { _oneshot_scope_on = false; } // Adds a new named entry (see ScopeList::add()), persists the list, and // returns its list index (0 if the name's empty or the list's full). uint8_t addScope(const char* name); @@ -610,6 +616,8 @@ private: void resetPendingBotActions(); TransportKey send_scope; + TransportKey _oneshot_scope; // see setOneShotScope() + bool _oneshot_scope_on = false; // The shared named-scope list backing Settings > Radio > Scope, the // channel context menu's Scope: row, and Tools > Repeater > Extra scopes. diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 86331b95..8ee65e4c 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -446,6 +446,14 @@ struct NodePrefs { // persisted to file uint8_t loc_share_move_idx; // movement gate level (index into locShareMoveMeters) uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs) uint8_t loc_share_heartbeat_idx; // stationary heartbeat (index into locShareHeartbeatSecs) + // Scope used for these [LOC] sends only. 0 = follow the target (a channel's own + // scope pick / the list default for a DM -- the behaviour before this field); + // n>0 = scope-list index (n-1), so 1 is "*" (unscoped). [del→MyMesh::removeScope] + uint8_t loc_share_scope; + // How long one auto-share session runs before it switches itself off (index into + // locShareDurationMins). There is deliberately no "never" option. 0 = the + // shortest/default, so a zeroed field is already valid. + uint8_t loc_share_duration_idx; // 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 @@ -540,6 +548,11 @@ struct NodePrefs { // persisted to file static const uint16_t S[LOC_SHARE_INTERVAL_COUNT] = { 30, 60, 120, 300 }; return S[idx < LOC_SHARE_INTERVAL_COUNT ? idx : 1]; } + static const uint8_t LOC_SHARE_DURATION_COUNT = 5; + static uint16_t locShareDurationMins(uint8_t idx) { + static const uint16_t D[LOC_SHARE_DURATION_COUNT] = { 60, 120, 240, 480, 720 }; // 1 / 2 / 4 / 8 / 12 h + return D[idx < LOC_SHARE_DURATION_COUNT ? idx : 0]; + } static const uint8_t LOC_SHARE_HEARTBEAT_COUNT = 3; static uint16_t locShareHeartbeatSecs(uint8_t idx) { static const uint16_t H[LOC_SHARE_HEARTBEAT_COUNT] = { 0, 300, 900 }; // off / 5 min / 15 min @@ -610,7 +623,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 = 0xC0DE002C; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE002E; // 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 @@ -759,6 +772,11 @@ struct NodePrefs { // persisted to file // the struct -- confirmed via a real sim_companion_radio (native) build and a // real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof unchanged // at 2824 on both. +// loc_share_scope (0xC0DE002D) landed in existing padding next to the other +// 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). 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-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h index 49a81626..47225d5b 100644 --- a/examples/companion_radio/ui-new/LiveShareScreen.h +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -18,14 +18,16 @@ class LiveShareScreen : public UIScreen { int _sel = 0; int _scroll = 0; - enum Kind : uint8_t { K_TRACK, K_AUTO, K_TARGET, K_MOVE, K_GAP, K_HB }; + enum Kind : uint8_t { K_TRACK, K_AUTO, K_DURATION, K_TARGET, K_SCOPE, K_MOVE, K_GAP, K_HB }; struct Row { Kind kind; const char* label; }; - static const int ROW_COUNT = 6; + static const int ROW_COUNT = 8; static Row rows(int i) { static const Row R[ROW_COUNT] = { { K_TRACK, "Track loc" }, { K_AUTO, "Auto share" }, + { K_DURATION, "Stop after" }, { K_TARGET, "To" }, + { K_SCOPE, "Scope" }, { K_MOVE, "Move" }, { K_GAP, "Min gap" }, { K_HB, "Heartbeat" }, @@ -62,9 +64,20 @@ public: case K_AUTO: snprintf(buf, n, "%s", (_prefs && _prefs->loc_share_enabled) ? "ON" : "OFF"); break; + case K_DURATION: + snprintf(buf, n, "%uh", (unsigned)(NodePrefs::locShareDurationMins(_prefs ? _prefs->loc_share_duration_idx : 0) / 60)); + break; case K_TARGET: currentTargetName(buf, n); break; + case K_SCOPE: { + // 0 = follow the target; n>0 = scope-list index n-1 ("*" first). + uint8_t v = _prefs ? _prefs->loc_share_scope : 0; + const ScopeList& sl = the_mesh.scopeList(); + if (v == 0 || v > sl.count + 1) snprintf(buf, n, "Target"); + else snprintf(buf, n, "%s", sl.name(v - 1)); + break; + } case K_MOVE: snprintf(buf, n, "%um", (unsigned)NodePrefs::locShareMoveMeters(_prefs ? _prefs->loc_share_move_idx : 1)); break; @@ -114,11 +127,27 @@ public: if (!_prefs) return; switch (rows(_sel).kind) { case K_TRACK: _prefs->track_shared_loc ^= 1; _dirty = true; break; - case K_AUTO: _prefs->loc_share_enabled ^= 1; _dirty = true; break; + case K_AUTO: + _prefs->loc_share_enabled ^= 1; + if (_prefs->loc_share_enabled) _task->restartLocShareSession(); + _dirty = true; break; + case K_DURATION: + _prefs->loc_share_duration_idx = (uint8_t)((_prefs->loc_share_duration_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_DURATION_COUNT - 1)) % NodePrefs::LOC_SHARE_DURATION_COUNT); + _task->restartLocShareSession(); // a new length starts the session over + _dirty = true; break; case K_TARGET: if (enter) { _task->pickLocShareTarget(); return; } // full chooser cycleTarget(dir); _dirty = true; // L/R quick cycle break; + case K_SCOPE: { + // Target, then every list entry ("*" included): count + 2 stops, wrapping. + int total = the_mesh.scopeList().count + 2; + int v = _prefs->loc_share_scope; + if (v >= total) v = 0; + v = (v + (dir >= 0 ? 1 : total - 1)) % total; + _prefs->loc_share_scope = (uint8_t)v; + _dirty = true; break; + } case K_MOVE: _prefs->loc_share_move_idx = (uint8_t)((_prefs->loc_share_move_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_MOVE_COUNT - 1)) % NodePrefs::LOC_SHARE_MOVE_COUNT); _dirty = true; break; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 39e26e4c..c1a720ef 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -3043,13 +3043,26 @@ void UITask::loop() { _livetrack.expire((uint32_t)rtc_clock.getCurrentTime()); } + // A session always ends: switch off once the chosen duration has run out. + // Counted from enable / boot (RAM only), so a reboot starts a fresh session. + if (_node_prefs && _node_prefs->loc_share_enabled && _loc_share_was_enabled + && (uint32_t)(millis() - _loc_share_session_ms) + >= (uint32_t)NodePrefs::locShareDurationMins(_node_prefs->loc_share_duration_idx) * 60000UL) { + _node_prefs->loc_share_enabled = 0; + _loc_share_was_enabled = false; + the_mesh.savePrefs(); + showAlert("Live share ended", 2500); + } // Live location sharing — periodically broadcast my [LOC] to the configured // target while moving (Map › Live share). Movement-gated so a stationary // device stays quiet unless a heartbeat is configured. if (_node_prefs && _node_prefs->loc_share_enabled && (int32_t)(millis() - _next_loc_share_check_ms) >= 0) { _next_loc_share_check_ms = millis() + 2000UL; - if (!_loc_share_was_enabled) _loc_share_has_last = false; // re-announce on enable + if (!_loc_share_was_enabled) { + _loc_share_has_last = false; // re-announce on enable + _loc_share_session_ms = millis(); + } _loc_share_was_enabled = true; int32_t lat, lon; if (currentLocation(lat, lon)) { @@ -3414,6 +3427,13 @@ void UITask::onSharedLocation(const uint8_t* pub_key, const char* name, bool UITask::sendLocationShare(int32_t lat, int32_t lon) { if (!_node_prefs) return false; + // Live Share's own scope, if set: applies to these sends only (0 = follow the + // target's usual scope). The sends below are synchronous, so bracketing works. + struct ScopeGuard { + bool on; + explicit ScopeGuard(uint8_t v) : on(v != 0) { if (on) the_mesh.setOneShotScope(v - 1); } + ~ScopeGuard() { if (on) the_mesh.clearOneShotScope(); } + } scope_guard(_node_prefs->loc_share_scope); char text[80]; if (_node_prefs->loc_share_target_type == 0) { // Channel: sendGroupMessage prepends ": ", so the payload already diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index aa9d5971..48573d32 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -126,6 +126,7 @@ class UITask : public AbstractUITask { int32_t _loc_share_last_lat = 0, _loc_share_last_lon = 0; bool _loc_share_has_last = false; bool _loc_share_was_enabled = false; + uint32_t _loc_share_session_ms = 0; // when the current auto-share session began (RAM only) // Trail auto-pause engine state. _trail_pause_ref is the last position the // device was considered "at"; if it doesn't move beyond the trail min-delta @@ -334,6 +335,8 @@ public: void gotoDashboardConfig(); void gotoAutoAdvertScreen(); void gotoLiveShareScreen(); + // Start the auto-share session clock afresh (next engine tick treats it as a new enable). + void restartLocShareSession() { _loc_share_was_enabled = false; } void gotoLocatorScreen(); // Re-arm the locator state machine so the next evaluation initialises // silently (called by the Locator tool after the target/radius changes,