mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-11-11 14:47:39 +00:00
Keep Team rosters synchronized through shared background presence
This commit is contained in:
1 parent
c14ae3bfbc
commit
3ca345b6fa
19 files changed
+330
-148
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user