feat(live-share): own scope for [LOC] posts and time-limited sessions (#23, #24)

- Live Share gets a "Scope" row (Target / * / any scope from the shared
  list). It applies to [LOC] sends only, beats the app's send_scope and a
  channel's own pick, and never touches normal messages. Target keeps the
  previous behaviour. removeScope() fixes the stored index up.
- Auto share now always ends: "Stop after" 1/2/4/8/12 h (default 1 h, no
  unlimited option). On expiry it switches itself off and shows "Live share
  ended". The session clock is RAM-only, so a reboot starts a new session.
- NodePrefs: loc_share_scope + loc_share_duration_idx appended, sentinel
  0xC0DE002C -> 0xC0DE002E, sizeof unchanged (2824).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-19 14:22:27 +02:00
co-authored by Claude Sonnet 5
parent dafd6c1266
commit 4af6d7d013
8 changed files with 113 additions and 9 deletions
@@ -234,7 +234,7 @@ Periodically broadcasts a 0-hop advert with your GPS position. Configurable inte
| :------------------------: | :------------------------: |
| ![](./liveshare_oled.png) | ![](./liveshare_eink.png) |
<!-- screenshot pending: Live Share screen — Track loc / Auto share / To / Move / Min gap / Heartbeat rows -->
<!-- screenshot pending: Live Share screen — Track loc / Auto share / Stop after / To / Scope / Move / Min gap / Heartbeat rows -->
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]<lat>,<lon>` 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. |
+14
View File
@@ -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
+12 -2
View File
@@ -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
+8
View File
@@ -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.
+19 -1
View File
@@ -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).");
@@ -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;
+21 -1
View File
@@ -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 "<name>: ", so the payload already
+3
View File
@@ -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,