Keep Team rosters synchronized through shared background presence

This commit is contained in:
liu weikai committed 2026-11-11 16:04:37 +08:00
1 parent c14ae3bfbc
commit 3ca345b6fa
19 files changed
+330 -148

No files matched your search

+12
View File
@@ -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
@@ -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 <algorithm>
#include <cassert>
@@ -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);
}
-1
View File
@@ -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"
+18 -3
View File
@@ -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
@@ -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<KeyDistPending> keydist_pending_;
std::vector<StatusBroadcastPending> status_pending_;
uint32_t next_presence_s_ = 0;
};
class TeamPageDeferredDispatchRuntimeAdapter final
@@ -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;
@@ -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());
@@ -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<int32_t>(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<int32_t>(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<int32_t>(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<int32_t>(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;
}
@@ -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);
@@ -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<std::size_t>(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;
@@ -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;
@@ -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;
@@ -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;
}
@@ -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();
@@ -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;
}
@@ -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);
@@ -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
@@ -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();
@@ -1,58 +0,0 @@
#include "platform/linux/team_presence.h"
#include <algorithm>
#include <cstdio>
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<int32_t>(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