diff --git a/CHANGELOG.md b/CHANGELOG.md index e40a21cb..1e0160f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ stable firmware release. Hardware support and validation remain target-specific. ## [Unreleased] +### Fixed + +- Shared Team background presence continues after enrollment retries finish. + Leaders periodically send a complete authenticated roster, while members + retain the leader's roster and announce their own presence. Linux uses the + same scheduler as ESP rather than a separate platform implementation. +- Team member activity and map summaries handle persisted local aliases and + mixed uptime/Unix timestamps without showing stale activity as current. + Snapshot reload restores its encoded last-update time. +- A returned local radio identity updates the existing Team "You" entry + instead of creating a duplicate member. + ## [1.2.3] - 2026-11-11 ### Added diff --git a/apps/linux_cardputer_zero/tests/linux_team_presence_test.cpp b/apps/linux_cardputer_zero/tests/linux_team_presence_test.cpp index c865df4a..9862b130 100644 --- a/apps/linux_cardputer_zero/tests/linux_team_presence_test.cpp +++ b/apps/linux_cardputer_zero/tests/linux_team_presence_test.cpp @@ -1,5 +1,6 @@ -#include "platform/linux/team_presence.h" #include "platform/linux/team_protocol_crypto.h" +#include "platform/ui/team_ui_snapshot_store.h" +#include "ui/screens/team/team_page_deferred_dispatch.h" #include "ui/screens/team/team_page_event_reducer.h" #include #include @@ -87,6 +88,26 @@ class Sink final : public team::ITeamEventSink void onTeamError(const team::TeamErrorEvent&) override { ++errors; } }; +class Dispatch final : public team::ui::ITeamPageDeferredDispatchPort +{ + public: + explicit Dispatch(team::TeamService& service) : service_(service) {} + bool hasController() const override { return true; } + bool sendKeyDistPlain(const team::proto::TeamKeyDist&, chat::ChannelId, chat::NodeId) override { return false; } + bool sendStatus(const team::proto::TeamStatus& status, chat::ChannelId channel, chat::NodeId peer) override + { + return service_.sendStatus(status, channel, peer); + } + bool sendStatusPlain(const team::proto::TeamStatus& status, chat::ChannelId channel, chat::NodeId peer) override + { + return service_.sendStatusPlain(status, channel, peer); + } + team::TeamService::SendError lastSendError() const override { return service_.getLastSendError(); } + + private: + team::TeamService& service_; +}; + int main() { Runtime runtime; @@ -102,11 +123,18 @@ int main() snapshot.team_psk.fill(42); assert(a.setKeysFromPsk(snapshot.team_id, 7, snapshot.team_psk.data(), 16)); assert(b.setKeysFromPsk(snapshot.team_id, 7, snapshot.team_psk.data(), 16)); - platform::linux_runtime::LinuxTeamPresence presence(a, runtime); - presence.update(snapshot, alice.self, true); + team::ui::TeamPageDeferredDispatchQueue presence; + team::ui::TeamPageDeferredDispatchState dispatch_state; + dispatch_state.in_team = dispatch_state.has_team_id = dispatch_state.has_team_psk = true; + dispatch_state.team_id = snapshot.team_id; + dispatch_state.security_round = 7; + Dispatch port(a); + team::proto::TeamStatus status; + status.key_id = 7; + presence.processStatusBroadcasts(dispatch_state, status, port, 1000); assert(alice.sent.empty()); - runtime.now = 10000; - presence.update(snapshot, alice.self, true); + runtime.now = 5000; + presence.processStatusBroadcasts(dispatch_state, status, port, 1005); assert(alice.sent.size() == 1); // A late member understands the ordinary authenticated Status wire without @@ -129,49 +157,46 @@ int main() assert(reducer.reduceStatus(state, bob_sink.last).accepted); assert(state.members.size() == 3 && state.members[1].leader); - for (unsigned i = 0; i < 10000; ++i) presence.update(snapshot, alice.self, true); + for (unsigned i = 0; i < 10000; ++i) + presence.processStatusBroadcasts(dispatch_state, status, port, 1006); assert(alice.sent.size() == 1); - presence.update(snapshot, alice.self, false); - runtime.now += 120000; - presence.update(snapshot, alice.self, false); - assert(alice.sent.size() == 1); - presence.update(snapshot, alice.self, true); alice.send_ok = false; - runtime.now += 10000; - presence.update(snapshot, alice.self, true); + presence.processStatusBroadcasts(dispatch_state, status, port, 1035); const auto failed_attempts = alice.attempts; - for (unsigned i = 0; i < 1000; ++i) presence.update(snapshot, alice.self, true); + for (unsigned i = 0; i < 1000; ++i) + presence.processStatusBroadcasts(dispatch_state, status, port, 1036); assert(alice.attempts == failed_attempts); alice.send_ok = true; - runtime.now += 10000; - presence.update(snapshot, alice.self, true); + presence.processStatusBroadcasts(dispatch_state, status, port, 1040); assert(alice.sent.size() == 2); - // Only a leader supplies a complete roster, with the local UI alias resolved. - snapshot.self_is_leader = true; - snapshot.members = {self, leader}; - presence.update(snapshot, alice.self, true); - runtime.now += 10000; - presence.update(snapshot, alice.self, true); + // A receiver that missed all enrollment broadcasts learns the whole roster + // through the actual shared scheduler, TeamService and authenticated codec. + dispatch_state.self_is_leader = true; + status.has_members = true; + status.leader_id = alice.self; + status.members = {alice.self, bob.self, 0x9A94}; + presence.processStatusBroadcasts(dispatch_state, status, port, 1070); bob.incoming.push_back(alice.sent.back()); b.processIncoming(); assert(bob_sink.last.msg.has_members && bob_sink.last.msg.leader_id == alice.self); assert(std::find(bob_sink.last.msg.members.begin(), bob_sink.last.msg.members.end(), 0) == bob_sink.last.msg.members.end()); + assert(reducer.reduceStatus(state, bob_sink.last).accepted); + assert(state.members.size() == 3 && !state.self_is_leader); + assert(reducer.findMemberIndex(state, 0) >= 0); + assert(reducer.findMemberIndex(state, alice.self) >= 0); + assert(reducer.findMemberIndex(state, 0x9A94) >= 0); const auto sent = alice.sent.size(); - snapshot.in_team = false; - presence.update(snapshot, alice.self, true); - runtime.now += 120000; - presence.update(snapshot, alice.self, true); + dispatch_state.in_team = false; + presence.processStatusBroadcasts(dispatch_state, status, port, 1080); + presence.processStatusBroadcasts(dispatch_state, status, port, 1200); assert(alice.sent.size() == sent); - runtime.now = UINT32_MAX - 1000; - snapshot.in_team = true; - presence.update(snapshot, alice.self, true); - runtime.now = 6000; - presence.update(snapshot, alice.self, true); + dispatch_state.in_team = true; + presence.processStatusBroadcasts(dispatch_state, status, port, UINT32_MAX - 1); + presence.processStatusBroadcasts(dispatch_state, status, port, 4); assert(alice.sent.size() == sent + 1); a.clearKeys(); - runtime.now += 120000; - presence.update(snapshot, alice.self, true); + presence.processStatusBroadcasts(dispatch_state, status, port, 34); assert(alice.sent.size() == sent + 1); } diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index 0df535ad..77c6fe5e 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -134,7 +134,6 @@ _trailmate_set_linux_paths() set(TRAIL_MATE_LINUX_COMMON_SOURCES "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/chat/linux_meshtastic_repeater.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/team_espnow_pairing.cpp" - "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/team_presence.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/team_protocol_crypto.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/team_snapshot_store.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/team_persistence/team_ui_snapshot_codec.cpp" diff --git a/docs/engineering/linux-espnow-team.md b/docs/engineering/linux-espnow-team.md index d1e5ae84..11ddf027 100644 --- a/docs/engineering/linux-espnow-team.md +++ b/docs/engineering/linux-espnow-team.md @@ -109,9 +109,17 @@ drivers differ: CM0 uses LR2021; Zero uses the SX1262 CAP. SX1262 transmission enters checked standby before changing packet parameters or writing the shared RX/TX FIFO, then restores reception and reports errors. -Presence sends authenticated status about every 30 seconds, with a per-node -offset. Member presence touches the sender without replacing the authoritative -leader roster. Protocol, region, actual frequency, modulation and channel keys +The shared Team background dispatcher sends authenticated status every 30 +seconds after a successful enqueue, with a five-second startup/retry delay. +This schedule continues after the finite enrollment broadcasts have drained and +does not depend on keeping the Team page open. Leaders send their complete +roster; members send presence without a roster, retaining the leader's list. +Linux uses the same dispatcher rather than a second platform scheduler. A +single 32-bit deadline is added to the dispatcher; periodic sends do not allocate +queue entries. Existing finite enrollment retries retain their compatibility +messages, while periodic presence is authenticated only. + +Protocol, region, actual frequency, modulation and channel keys must agree across participants. The live CN / LongFast exercises used 478.875 MHz, BW 250 kHz, SF11, CR 4/5, sync 0x2B and a 16-symbol preamble. @@ -136,6 +144,13 @@ events; reliable direct duplicates still receive routing ACKs. The position-ring reader projects the newest timestamp per member. An older relayed sample cannot replace a newer displayed location. +Team relative-time presentation uses wall-clock seconds when available and +does not subtract a process uptime from a Unix timestamp. A persisted timestamp +from a different clock domain is shown as unknown. The local zero-ID alias is +the running observer, not a stale RF peer; returned local radio identities map +back to that same alias instead of adding another member. Snapshot reload also +restores the encoded last-update timestamp. + ## Verification Native fixtures cover compatible crypto and tamper rejection, private snapshot diff --git a/modules/ui_shared/include/ui/screens/team/team_page_deferred_dispatch.h b/modules/ui_shared/include/ui/screens/team/team_page_deferred_dispatch.h index 8da87b35..dc6bc356 100644 --- a/modules/ui_shared/include/ui/screens/team/team_page_deferred_dispatch.h +++ b/modules/ui_shared/include/ui/screens/team/team_page_deferred_dispatch.h @@ -99,6 +99,8 @@ class TeamPageDeferredDispatchQueue void scheduleStatusBroadcast(uint8_t repeats, uint32_t now_s, uint32_t delay_s); + bool statusDue(const TeamPageDeferredDispatchState& state, + uint32_t now_s) const; TeamPageDeferredDispatchEffects processKeyDistRetries( const TeamPageDeferredDispatchState& state, @@ -131,6 +133,7 @@ class TeamPageDeferredDispatchQueue TeamPageDeferredDispatchConfig config_; std::vector keydist_pending_; std::vector status_pending_; + uint32_t next_presence_s_ = 0; }; class TeamPageDeferredDispatchRuntimeAdapter final diff --git a/modules/ui_shared/src/ui/presentation_sources/runtime_map_workspace_source.cpp b/modules/ui_shared/src/ui/presentation_sources/runtime_map_workspace_source.cpp index 855cd375..fc97aee5 100644 --- a/modules/ui_shared/src/ui/presentation_sources/runtime_map_workspace_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/runtime_map_workspace_source.cpp @@ -63,7 +63,8 @@ void projectTeamSummary(team::ui::ITeamUiSnapshotStore* store, for (const auto& member : snapshot.members) { ++out.visible_members; - if (!::ui::team_presence::isTeamMemberOnline(presenceNowForMember(member.last_seen_s), + if (member.node_id != 0 && + !::ui::team_presence::isTeamMemberOnline(presenceNowForMember(member.last_seen_s), member.last_seen_s)) { ++out.stale_members; diff --git a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp index 8c30828b..a7f08618 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp @@ -860,16 +860,25 @@ void process_keydist_retries() void process_status_broadcasts() { + const auto state = deferred_dispatch_state_from_page(); + const uint32_t now_s = now_secs(); + if (!team_page_context().deferred_dispatch.statusDue(state, now_s)) + { + return; + } const auto runtime = current_runtime_port(); TeamPageDeferredDispatchRuntimeAdapter dispatch_port(runtime); team::proto::TeamStatus status{}; status.key_id = team_page_state().security_round; - fill_status_members(status); + if (state.self_is_leader) + { + fill_status_members(status); + } const auto effects = team_page_context().deferred_dispatch.processStatusBroadcasts( - deferred_dispatch_state_from_page(), + state, status, dispatch_port, - now_secs()); + now_s); if (effects.sent_status) { std::printf("[Team] status rebroadcast members=%u leader=%08lX\n", @@ -1038,7 +1047,8 @@ TeamPageReadModelInput current_read_model_input() TeamPageReadModel current_read_model() { - return TeamPageReadModel(now_secs()); + const uint32_t epoch_s = sys::epoch_seconds_now(); + return TeamPageReadModel(epoch_s >= 1577836800U ? epoch_s : now_secs()); } std::string resolve_node_label(uint32_t node_id) @@ -1772,7 +1782,9 @@ void render_page() input.pairing_peer_id = team_page_state().pairing_peer_id; input.pairing_available = app::teamFacade().getTeamPairing() != nullptr; - TeamPageLvglRenderer(now_secs()).render(context, input, handlers, team_page_name_resolver()); + const uint32_t epoch_s = sys::epoch_seconds_now(); + TeamPageLvglRenderer(epoch_s >= 1577836800U ? epoch_s : now_secs()) + .render(context, input, handlers, team_page_name_resolver()); ui_update_top_bar_battery(team_page_lvgl_context().top_bar_widget); refresh_team_input(input_context_from_lvgl()); diff --git a/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp b/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp index 5ad4c797..2e68a5b3 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp @@ -45,6 +45,7 @@ void TeamPageDeferredDispatchQueue::clearKeyDist() void TeamPageDeferredDispatchQueue::clearStatusBroadcasts() { status_pending_.clear(); + next_presence_s_ = 0; } void TeamPageDeferredDispatchQueue::enqueueKeyDist(uint32_t node_id, @@ -95,6 +96,33 @@ void TeamPageDeferredDispatchQueue::scheduleStatusBroadcast(uint8_t repeats, status_pending_.push_back(item); } +bool TeamPageDeferredDispatchQueue::statusDue( + const TeamPageDeferredDispatchState& state, + uint32_t now_s) const +{ + if (!state.in_team || !state.has_team_id) + { + return next_presence_s_ != 0 || !status_pending_.empty(); + } + if (!state.self_is_leader && !status_pending_.empty()) + { + return true; + } + for (const auto& item : status_pending_) + { + if (static_cast(now_s - item.next_send_s) >= 0) + { + return true; + } + } + if (!state.has_team_psk || state.security_round == 0) + { + return next_presence_s_ != 0; + } + return next_presence_s_ == 0 || + static_cast(now_s - next_presence_s_) >= 0; +} + TeamPageDeferredDispatchEffects TeamPageDeferredDispatchQueue::processKeyDistRetries( const TeamPageDeferredDispatchState& state, @@ -163,16 +191,15 @@ TeamPageDeferredDispatchQueue::processStatusBroadcasts( uint32_t now_s) { TeamPageDeferredDispatchEffects effects; - if (status_pending_.empty()) - { - return effects; - } - - if (!state.in_team || !state.has_team_id || !state.self_is_leader) + if (!state.in_team || !state.has_team_id) { clearStatusBroadcasts(); return effects; } + if (!state.self_is_leader) + { + status_pending_.clear(); + } if (!port.hasController()) { @@ -181,7 +208,7 @@ TeamPageDeferredDispatchQueue::processStatusBroadcasts( for (auto it = status_pending_.begin(); it != status_pending_.end();) { - if (now_s < it->next_send_s) + if (static_cast(now_s - it->next_send_s) < 0) { ++it; continue; @@ -195,6 +222,7 @@ TeamPageDeferredDispatchQueue::processStatusBroadcasts( true)); } effects.sent_status = true; + next_presence_s_ = now_s + 30U; if (!port.sendStatusPlain(status, chat::ChannelId::PRIMARY, 0)) { @@ -220,6 +248,39 @@ TeamPageDeferredDispatchQueue::processStatusBroadcasts( } } + // This schedule survives the finite enrollment retry queue. Only leaders + // advertise a full roster; members send an authenticated, roster-free + // presence. It needs one deadline and never allocates a periodic queue item. + if (!state.has_team_psk || state.security_round == 0) + { + next_presence_s_ = 0; + return effects; + } + if (next_presence_s_ == 0) + { + next_presence_s_ = now_s + 5U; + return effects; + } + if (static_cast(now_s - next_presence_s_) < 0) + { + return effects; + } + next_presence_s_ = now_s + 5U; + if (status.key_id != state.security_round || + (!state.self_is_leader && status.has_members)) + { + return effects; + } + const bool sent = port.sendStatus(status, chat::ChannelId::PRIMARY, 0); + effects.sent_status = sent; + if (sent) + { + next_presence_s_ = now_s + 30U; + } + // Background presence retries silently when the carrier is unavailable. + // It is not an explicit user send and must not open a failure notice on + // every retry or mutate membership/keys. + return effects; } diff --git a/modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp b/modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp index 1eba8a22..1336f929 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp @@ -33,6 +33,13 @@ void TeamPageEventReducer::touchMember(TeamPageEventState& state, uint32_t node_id, uint32_t last_seen_s) const { + // Radio and loopback events use the actual node ID; UI snapshots use zero + // for the local observer. A returned local packet must not create a fourth + // member beside the existing "You" alias. + if (node_id == context_.self_node_id) + { + node_id = 0; + } const uint32_t seen_s = ::ui::team_presence::normalizeSeenSeconds(last_seen_s, context_.now_s); diff --git a/modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp b/modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp index 4bbcffc9..9b4cb2df 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp @@ -9,6 +9,14 @@ namespace team { namespace ui { +namespace +{ +bool sameTimeDomain(uint32_t now_s, uint32_t observed_s) +{ + constexpr uint32_t kMinValidEpoch = 1577836800U; + return (now_s >= kMinValidEpoch) == (observed_s >= kMinValidEpoch); +} +} // namespace TeamPageReadModel::TeamPageReadModel(uint32_t now_s) : now_s_(now_s) @@ -137,7 +145,7 @@ TeamMemberDetailView TeamPageReadModel::buildSelectedMember( const auto index = static_cast(input.selected_member_index); detail.valid = true; detail.member = buildMemberRow(input.members[index], index); - detail.last_seen = buildLastSeen(input.members[index].last_seen_s); + detail.last_seen = detail.member.last_seen; detail.management_actions_enabled = input.has_team_psk && input.has_team_id && @@ -149,7 +157,7 @@ TeamMemberDetailView TeamPageReadModel::buildSelectedMember( TeamRelativeTimeView TeamPageReadModel::buildLastSeen(uint32_t last_seen_s) const { TeamRelativeTimeView view; - if (last_seen_s == 0) + if (last_seen_s == 0 || !sameTimeDomain(now_s_, last_seen_s)) { return view; } @@ -176,7 +184,7 @@ TeamRelativeTimeView TeamPageReadModel::buildLastSeen(uint32_t last_seen_s) cons TeamRelativeTimeView TeamPageReadModel::buildLastUpdate(uint32_t last_update_s) const { TeamRelativeTimeView view; - if (last_update_s == 0) + if (last_update_s == 0 || !sameTimeDomain(now_s_, last_update_s)) { return view; } @@ -199,7 +207,9 @@ TeamMemberRowView TeamPageReadModel::buildMemberRow( row.name = member.node_id == 0 ? member.name : ::ui::team_presentation::shortTeamMemberLabel(member.node_id); - row.last_seen = buildLastSeen(member.last_seen_s); + // The running observer is its own presence proof. A persisted local alias + // may still carry uptime from an earlier process or boot. + row.last_seen = buildLastSeen(row.self ? now_s_ : member.last_seen_s); row.leader = member.leader; row.color_index = member.color_index; return row; diff --git a/modules/ui_shared/src/ui/team_persistence/team_ui_snapshot_codec.cpp b/modules/ui_shared/src/ui/team_persistence/team_ui_snapshot_codec.cpp index acdbab9b..982e9fa3 100644 --- a/modules/ui_shared/src/ui/team_persistence/team_ui_snapshot_codec.cpp +++ b/modules/ui_shared/src/ui/team_persistence/team_ui_snapshot_codec.cpp @@ -240,6 +240,7 @@ bool decodeTeamUiSnapshot(const uint8_t* data, decoded.team_id = teamIdFromU64(team_id); decoded.security_round = epoch; decoded.last_event_seq = last_event_seq; + decoded.last_update_s = updated_ts; decoded.self_is_leader = (self_role == kRoleLeader); for (uint16_t i = 0; i < member_count; ++i) @@ -293,7 +294,6 @@ bool decodeTeamUiSnapshot(const uint8_t* data, (void)reserved; (void)reserved3; - (void)updated_ts; (void)self_node_id; (void)leader_node_id; out = decoded; diff --git a/modules/ui_shared/tests/test_runtime_map_presentation_adapters.cpp b/modules/ui_shared/tests/test_runtime_map_presentation_adapters.cpp index 4851fc90..857dc292 100644 --- a/modules/ui_shared/tests/test_runtime_map_presentation_adapters.cpp +++ b/modules/ui_shared/tests/test_runtime_map_presentation_adapters.cpp @@ -161,6 +161,16 @@ int main() assert(snapshot.team.visible_members == 2); assert(snapshot.team.stale_members == 1); + // The local alias is alive because this application is rendering it; its + // old persisted uptime must not count as a stale radio peer. + team::ui::TeamMemberUi self; + self.node_id = 0; + self.last_seen_s = 1; + team_store.snapshot.members.push_back(self); + assert(source.buildMapWorkspaceSnapshot(request, snapshot)); + assert(snapshot.team.visible_members == 3); + assert(snapshot.team.stale_members == 1); + state.has_viewport = false; state.last_viewport = {}; gps.snapshot.fix_valid = false; diff --git a/modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp b/modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp index cd6b8531..d97c522b 100644 --- a/modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp +++ b/modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp @@ -230,6 +230,76 @@ void testStatusBroadcastFailureAndInvalidLeaderState() assert(queue.statusBroadcastPendingCount() == 0); } +void testPeriodicRosterRepairsMissedEnrollmentBroadcast() +{ + auto queue = makeQueue(); + FakeDispatchPort port; + auto state = readyState(); + team::proto::TeamStatus status; + status.key_id = state.security_round; + status.has_members = true; + status.leader_id = 0x11111111; + status.members = {0x11111111, 0x22222222, 0x33333333}; + + // No pairing event or queued retry: a restored leader still advertises its + // complete roster. A late receiver can miss every enrollment broadcast. + queue.processStatusBroadcasts(state, status, port, 100); + queue.processStatusBroadcasts(state, status, port, 101); + assert(port.status_send_count == 0); + auto effects = queue.processStatusBroadcasts(state, status, port, 105); + assert(effects.sent_status && port.status_send_count == 1); + assert(port.last_status.has_members && port.last_status.members == status.members); + assert(port.status_plain_send_count == 0); + assert(queue.statusBroadcastPendingCount() == 0); + for (uint32_t now = 106; now < 135; ++now) + queue.processStatusBroadcasts(state, status, port, now); + assert(port.status_send_count == 1); + queue.processStatusBroadcasts(state, status, port, 135); + assert(port.status_send_count == 2); + + // A failed carrier retries on a bounded delay, rather than every UI tick. + port.status_ok = false; + effects = queue.processStatusBroadcasts(state, status, port, 165); + assert(!effects.sent_status && effects.failures.empty()); + assert(port.status_send_count == 3); + for (uint32_t now = 166; now < 170; ++now) + queue.processStatusBroadcasts(state, status, port, now); + assert(port.status_send_count == 3); + port.status_ok = true; + queue.processStatusBroadcasts(state, status, port, 170); + assert(port.status_send_count == 4); + + state.in_team = false; + queue.processStatusBroadcasts(state, status, port, 180); + queue.processStatusBroadcasts(state, status, port, 1000); + assert(port.status_send_count == 4); +} + +void testPeriodicMemberPresenceDoesNotReplaceLeaderRoster() +{ + auto queue = makeQueue(); + FakeDispatchPort port; + auto state = readyState(); + state.self_is_leader = false; + team::proto::TeamStatus status; + status.key_id = state.security_round; + queue.processStatusBroadcasts(state, status, port, 100); + queue.processStatusBroadcasts(state, status, port, 105); + assert(port.status_send_count == 1); + assert(!port.last_status.has_members && port.last_status.members.empty()); + assert(port.status_plain_send_count == 0); + + // Membership credentials never become periodic plaintext status. + state.has_team_psk = false; + queue.processStatusBroadcasts(state, status, port, 135); + assert(port.status_send_count == 1 && port.status_plain_send_count == 0); + + state.has_team_psk = true; + queue.processStatusBroadcasts(state, status, port, UINT32_MAX - 1); + queue.processStatusBroadcasts(state, status, port, 4); + assert(port.status_send_count == 2); +} + } // namespace int main() @@ -239,5 +309,7 @@ int main() testKeyDistMissingRuntimeDoesNothing(); testStatusBroadcastSendsAndReschedules(); testStatusBroadcastFailureAndInvalidLeaderState(); + testPeriodicRosterRepairsMissedEnrollmentBroadcast(); + testPeriodicMemberPresenceDoesNotReplaceLeaderRoster(); return 0; } diff --git a/modules/ui_shared/tests/test_team_page_event_reducer.cpp b/modules/ui_shared/tests/test_team_page_event_reducer.cpp index 2236948c..39c89dd5 100644 --- a/modules/ui_shared/tests/test_team_page_event_reducer.cpp +++ b/modules/ui_shared/tests/test_team_page_event_reducer.cpp @@ -421,10 +421,29 @@ void testPairingCompletedAndFailedDrivePageEffects() assert(!failed_state.pending_join); } +void testOwnRadioIdentityTouchesTheLocalAlias() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.in_team = state.has_team_id = true; + state.team_id = testTeamId(); + team::ui::TeamMemberUi self; + self.node_id = 0; + self.name = "You"; + state.members.push_back(self); + auto context = makeEventContext(); + context.from = 0x11111111; + assert(reducer.reduceActivity(state, context, context.from).accepted); + assert(state.members.size() == 1); + assert(state.members[0].node_id == 0 && state.members[0].name == "You"); + assert(state.members[0].last_seen_s == context.timestamp); +} + } // namespace int main() { + testOwnRadioIdentityTouchesTheLocalAlias(); testStatusRostersPreservePresenceAndLeader(); testStatusFromOtherTeamIsIgnored(); testActivityTouchesSenderAndConfirmsKeys(); diff --git a/modules/ui_shared/tests/test_team_page_read_model.cpp b/modules/ui_shared/tests/test_team_page_read_model.cpp index f26bbc3e..01c25153 100644 --- a/modules/ui_shared/tests/test_team_page_read_model.cpp +++ b/modules/ui_shared/tests/test_team_page_read_model.cpp @@ -152,6 +152,33 @@ void testSnapshotInputProjection() assert(!input.waiting_new_keys); } +void testRelativeTimesDoNotMixEpochWithProcessUptime() +{ + const team::ui::TeamPageReadModel uptime_model(1000); + assert(uptime_model.buildLastSeen(1794369005).kind == + team::ui::TeamRelativeTimeKind::Unknown); + assert(uptime_model.buildLastUpdate(1794369005).kind == + team::ui::TeamRelativeTimeKind::Unknown); + + const team::ui::TeamPageReadModel epoch_model(1794382005); + const auto leader = epoch_model.buildLastSeen(1794369005); + assert(leader.kind == team::ui::TeamRelativeTimeKind::HoursAgo); + assert(leader.value == 3); + assert(epoch_model.buildLastSeen(30739).kind == + team::ui::TeamRelativeTimeKind::Unknown); + + auto input = makeInput(); + input.members[0].node_id = 0; + input.members[0].name = "You"; + input.members[0].last_seen_s = 30739; + input.selected_member_index = 0; + const auto self = epoch_model.buildSelectedMember(input); + assert(self.member.self); + assert(self.last_seen.kind == team::ui::TeamRelativeTimeKind::MinutesAgo); + assert(self.last_seen.value == 0); + assert(input.members[0].last_seen_s == 30739); +} + } // namespace int main() @@ -161,5 +188,6 @@ int main() testMemberDetailProjection(); testJoinPendingProjection(); testSnapshotInputProjection(); + testRelativeTimesDoNotMixEpochWithProcessUptime(); return 0; } diff --git a/modules/ui_shared/tests/test_team_presence_and_snapshot_codec.cpp b/modules/ui_shared/tests/test_team_presence_and_snapshot_codec.cpp index 4e626477..3d4b6a7c 100644 --- a/modules/ui_shared/tests/test_team_presence_and_snapshot_codec.cpp +++ b/modules/ui_shared/tests/test_team_presence_and_snapshot_codec.cpp @@ -167,6 +167,7 @@ void testSnapshotV2RoundTrip() assert(decoded.self_is_leader); assert(decoded.security_round == 9); assert(decoded.last_event_seq == 33); + assert(decoded.last_update_s == 1234); assert(decoded.team_chat_unread == 4); assert(decoded.members.size() == 2); assert(decoded.members[0].node_id == 0); diff --git a/platform/linux/common/include/platform/linux/team_presence.h b/platform/linux/common/include/platform/linux/team_presence.h deleted file mode 100644 index 6569301f..00000000 --- a/platform/linux/common/include/platform/linux/team_presence.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "platform/ui/team_ui_snapshot_store.h" -#include "team/usecase/team_service.h" - -namespace platform::linux_runtime -{ -// Rejoin the existing Team status exchange after startup or carrier recovery. -// Members announce presence without claiming an authoritative member list. -class LinuxTeamPresence -{ - public: - LinuxTeamPresence(team::TeamService& service, team::ITeamRuntime& runtime); - void update(const team::ui::TeamUiSnapshot& snapshot, uint32_t self_id, bool transport_ready); - - private: - team::TeamService& service_; - team::ITeamRuntime& runtime_; - team::TeamId team_id_{}; - uint32_t key_id_ = 0; - uint32_t next_send_ms_ = 0; - bool scheduled_ = false; - bool leader_ = false; -}; -} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/app/linux_app_services.cpp b/platform/linux/common/src/app/linux_app_services.cpp index 7678de67..4ac2285f 100644 --- a/platform/linux/common/src/app/linux_app_services.cpp +++ b/platform/linux/common/src/app/linux_app_services.cpp @@ -11,7 +11,6 @@ #include "platform/linux/mesh/linux_reticulum_identity.h" #include "platform/linux/runtime_paths.h" #include "platform/linux/team_espnow_pairing.h" -#include "platform/linux/team_presence.h" #include "platform/linux/team_protocol_crypto.h" #include "platform/linux/team_snapshot_store.h" #include "platform/linux/tms_import_bridge.h" @@ -1516,7 +1515,6 @@ struct LinuxAppServices::Implementation track_source(), team_service(team_crypto, mesh_adapter, team_event_sink, team_runtime), team_controller(team_service), - team_presence(team_service, team_runtime), team_track_sampler(team_runtime, track_source) { if (!demo_world_enabled) @@ -1692,7 +1690,6 @@ struct LinuxAppServices::Implementation LinuxGpsTrackSource track_source; ::team::TeamService team_service; ::team::TeamController team_controller; - ::platform::linux_runtime::LinuxTeamPresence team_presence; ::team::TeamTrackSampler team_track_sampler; bool started = false; bool demo_seeded = false; @@ -2575,13 +2572,6 @@ void LinuxAppServices::updateCoreServices() #endif ::team::ui::TeamUiSnapshot snap; const bool has_team = ::team::ui::team_ui_snapshot_store().load(snap) && snap.in_team; - if (!impl_->demo_world_enabled) - { - const auto capabilities = impl_->mesh_adapter.getCapabilities(); - const bool ready = impl_->mesh_adapter.isReady() && - capabilities.supports_broadcast_appdata && capabilities.provides_appdata_sender; - impl_->team_presence.update(snap, getSelfNodeId(), ready); - } ::platform::ui::gps::tick_service(); impl_->team_track_sampler.update(&impl_->team_controller, has_team); ::platform::ui::tracker::poll(); diff --git a/platform/linux/common/src/platform/linux/team_presence.cpp b/platform/linux/common/src/platform/linux/team_presence.cpp deleted file mode 100644 index a2766f5a..00000000 --- a/platform/linux/common/src/platform/linux/team_presence.cpp +++ /dev/null @@ -1,58 +0,0 @@ -#include "platform/linux/team_presence.h" - -#include -#include - -namespace platform::linux_runtime -{ -LinuxTeamPresence::LinuxTeamPresence(team::TeamService& service, team::ITeamRuntime& runtime) - : service_(service), runtime_(runtime) -{ -} - -void LinuxTeamPresence::update(const team::ui::TeamUiSnapshot& snapshot, uint32_t self_id, bool transport_ready) -{ - if (!snapshot.in_team || !snapshot.has_team_id || !snapshot.has_team_psk || - !snapshot.security_round || !service_.hasKeys() || !self_id || !transport_ready) - { - scheduled_ = false; - return; - } - - const auto now = runtime_.nowMillis(); - if (!scheduled_ || team_id_ != snapshot.team_id || key_id_ != snapshot.security_round || - leader_ != snapshot.self_is_leader) - { - team_id_ = snapshot.team_id; - key_id_ = snapshot.security_round; - leader_ = snapshot.self_is_leader; - // Let the carrier's own startup announcement settle, and separate peers - // restored at nearly the same time without spending additional entropy. - next_send_ms_ = now + 2000 + self_id % 2000; - scheduled_ = true; - } - if (static_cast(now - next_send_ms_) < 0) return; - - team::proto::TeamStatus status; - status.key_id = snapshot.security_round; - if (snapshot.self_is_leader) - { - status.has_members = true; - status.leader_id = self_id; - for (const auto& member : snapshot.members) - { - const auto id = member.node_id ? member.node_id : self_id; - if (std::find(status.members.begin(), status.members.end(), id) == status.members.end()) - status.members.push_back(id); - } - if (std::find(status.members.begin(), status.members.end(), self_id) == status.members.end()) - status.members.push_back(self_id); - } - // An authenticated status with has_members=false is understood by existing - // ESP receivers: touch the sender while retaining the leader's full roster. - const bool sent = service_.sendStatus(status, chat::ChannelId::PRIMARY); - next_send_ms_ = now + (sent ? 30000 : 5000) + self_id % 3000; - std::printf("[Team] presence role=%s sent=%u members=%zu\n", - snapshot.self_is_leader ? "leader" : "member", sent ? 1U : 0U, status.members.size()); -} -} // namespace platform::linux_runtime