Skip to main content

peat_btle/
peer.rs

1// Copyright (c) 2025-2026 (r)evolve - Revolve Team LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Peer management types for Peat BLE mesh
17//!
18//! This module provides the core peer representation and configuration
19//! for centralized peer management across all platforms (iOS, Android, ESP32).
20
21#[cfg(not(feature = "std"))]
22use alloc::{string::String, vec::Vec};
23
24use crate::NodeId;
25
26/// Unified peer representation across all platforms
27///
28/// Represents a discovered or connected Peat mesh peer with all
29/// relevant metadata for mesh operations.
30#[derive(Debug, Clone)]
31pub struct PeatPeer {
32    /// Peat node identifier (32-bit)
33    pub node_id: NodeId,
34
35    /// Platform-specific BLE identifier
36    /// - iOS: CBPeripheral UUID string
37    /// - Android: MAC address string
38    /// - ESP32: MAC address or NimBLE handle
39    pub identifier: String,
40
41    /// Mesh ID this peer belongs to (e.g., "DEMO")
42    pub mesh_id: Option<String>,
43
44    /// Advertised device name (e.g., "PEAT_DEMO-12345678")
45    pub name: Option<String>,
46
47    /// Last known signal strength (RSSI in dBm)
48    pub rssi: i8,
49
50    /// Whether we have an active BLE connection to this peer
51    pub is_connected: bool,
52
53    /// Timestamp when this peer was last seen (milliseconds since epoch/boot)
54    pub last_seen_ms: u64,
55}
56
57impl PeatPeer {
58    /// Create a new peer from discovery data
59    pub fn new(
60        node_id: NodeId,
61        identifier: String,
62        mesh_id: Option<String>,
63        name: Option<String>,
64        rssi: i8,
65    ) -> Self {
66        Self {
67            node_id,
68            identifier,
69            mesh_id,
70            name,
71            rssi,
72            is_connected: false,
73            last_seen_ms: 0,
74        }
75    }
76
77    /// Update the peer's last seen timestamp
78    pub fn touch(&mut self, now_ms: u64) {
79        self.last_seen_ms = now_ms;
80    }
81
82    /// Check if this peer is stale (not seen within timeout)
83    pub fn is_stale(&self, now_ms: u64, timeout_ms: u64) -> bool {
84        if self.last_seen_ms == 0 {
85            return false; // Never seen, don't consider stale
86        }
87        now_ms.saturating_sub(self.last_seen_ms) > timeout_ms
88    }
89
90    /// Get display name for this peer
91    pub fn display_name(&self) -> &str {
92        self.name.as_deref().unwrap_or(self.identifier.as_str())
93    }
94
95    /// Get signal strength category
96    pub fn signal_strength(&self) -> SignalStrength {
97        match self.rssi {
98            r if r >= -50 => SignalStrength::Excellent,
99            r if r >= -70 => SignalStrength::Good,
100            r if r >= -85 => SignalStrength::Fair,
101            _ => SignalStrength::Weak,
102        }
103    }
104}
105
106impl Default for PeatPeer {
107    fn default() -> Self {
108        Self {
109            node_id: NodeId::default(),
110            identifier: String::new(),
111            mesh_id: None,
112            name: None,
113            rssi: -100,
114            is_connected: false,
115            last_seen_ms: 0,
116        }
117    }
118}
119
120/// Signal strength categories for display
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SignalStrength {
123    /// RSSI >= -50 dBm
124    Excellent,
125    /// RSSI >= -70 dBm
126    Good,
127    /// RSSI >= -85 dBm
128    Fair,
129    /// RSSI < -85 dBm
130    Weak,
131}
132
133/// Connection state aligned with peat-protocol abstractions
134///
135/// Represents the lifecycle states of a peer connection, from initial
136/// discovery through connection, degradation, and disconnection.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
138pub enum ConnectionState {
139    /// Peer has been seen via BLE advertisement but never connected
140    #[default]
141    Discovered,
142    /// BLE connection is being established
143    Connecting,
144    /// Active BLE connection with healthy signal
145    Connected,
146    /// Connected but with degraded quality (low RSSI or packet loss)
147    Degraded,
148    /// Graceful disconnect in progress
149    Disconnecting,
150    /// Was previously connected, now disconnected
151    Disconnected,
152    /// Disconnected and no longer seen in advertisements
153    Lost,
154}
155
156impl ConnectionState {
157    /// Returns true if this state represents an active connection
158    pub fn is_connected(&self) -> bool {
159        matches!(self, Self::Connected | Self::Degraded)
160    }
161
162    /// Returns true if this state indicates the peer was previously known
163    pub fn was_connected(&self) -> bool {
164        matches!(
165            self,
166            Self::Connected
167                | Self::Degraded
168                | Self::Disconnecting
169                | Self::Disconnected
170                | Self::Lost
171        )
172    }
173
174    /// Returns true if this state indicates potential connectivity issues
175    pub fn is_degraded_or_worse(&self) -> bool {
176        matches!(
177            self,
178            Self::Degraded | Self::Disconnecting | Self::Disconnected | Self::Lost
179        )
180    }
181}
182
183// Re-export DisconnectReason from platform module
184pub use crate::platform::DisconnectReason;
185
186/// Minimal per-peer link info exposed by `BluetoothLETransport::peer_link_info`.
187///
188/// Surfaces only the fields that peat-mesh's `PeatBleTransport`
189/// synthesises into the unified `LinkState` for ADR-032 §Amendment A
190/// (per-peer link state query + host-rendering rule). Adapters that
191/// already track per-peer state (advertisement RSSI, GATT lifecycle)
192/// return `Some`; adapters that don't fall through to the trait's
193/// default `None`. The richer [`PeerConnectionState`] (timestamps,
194/// byte counters, mesh metadata) is defined in this crate alongside
195/// this type and is held by [`crate::PeatMesh`] (peat-btle's high-
196/// level mesh facade) via its [`ConnectionStateGraph`]; it is
197/// intentionally not surfaced here — the visualization layer doesn't
198/// need that detail, and `BluetoothLETransport` (which this accessor
199/// hangs off of) doesn't own a `ConnectionStateGraph` in any case.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct BlePeerLinkInfo {
202    /// Current connection lifecycle state.
203    pub state: ConnectionState,
204    /// Most recent RSSI reading in dBm, where the adapter has one.
205    pub last_rssi: Option<i8>,
206}
207
208/// Per-peer connection state with history
209///
210/// Provides a comprehensive view of a peer's connection lifecycle,
211/// including timestamps, statistics, and associated data metrics.
212/// This enables apps to display appropriate UI indicators and track
213/// data provenance.
214#[derive(Debug, Clone)]
215pub struct PeerConnectionState {
216    /// Peat node identifier
217    pub node_id: NodeId,
218
219    /// Platform-specific BLE identifier
220    pub identifier: String,
221
222    /// Current connection state
223    pub state: ConnectionState,
224
225    /// Timestamp when peer was first discovered (ms since epoch)
226    pub discovered_at: u64,
227
228    /// Timestamp of most recent connection (ms since epoch)
229    pub connected_at: Option<u64>,
230
231    /// Timestamp of most recent disconnection (ms since epoch)
232    pub disconnected_at: Option<u64>,
233
234    /// Reason for most recent disconnection
235    pub disconnect_reason: Option<DisconnectReason>,
236
237    /// Most recent RSSI reading (dBm)
238    pub last_rssi: Option<i8>,
239
240    /// Total number of successful connections to this peer
241    pub connection_count: u32,
242
243    /// Number of documents synced with this peer
244    pub documents_synced: u32,
245
246    /// Bytes received from this peer
247    pub bytes_received: u64,
248
249    /// Bytes sent to this peer
250    pub bytes_sent: u64,
251
252    /// Last time peer was seen (advertisement or data, ms since epoch)
253    pub last_seen_ms: u64,
254
255    /// Optional device name
256    pub name: Option<String>,
257
258    /// Mesh ID this peer belongs to
259    pub mesh_id: Option<String>,
260}
261
262impl PeerConnectionState {
263    /// Create a new connection state for a discovered peer
264    pub fn new_discovered(node_id: NodeId, identifier: String, now_ms: u64) -> Self {
265        Self {
266            node_id,
267            identifier,
268            state: ConnectionState::Discovered,
269            discovered_at: now_ms,
270            connected_at: None,
271            disconnected_at: None,
272            disconnect_reason: None,
273            last_rssi: None,
274            connection_count: 0,
275            documents_synced: 0,
276            bytes_received: 0,
277            bytes_sent: 0,
278            last_seen_ms: now_ms,
279            name: None,
280            mesh_id: None,
281        }
282    }
283
284    /// Create from an existing PeatPeer
285    pub fn from_peer(peer: &PeatPeer, now_ms: u64) -> Self {
286        let state = if peer.is_connected {
287            ConnectionState::Connected
288        } else {
289            ConnectionState::Discovered
290        };
291
292        Self {
293            node_id: peer.node_id,
294            identifier: peer.identifier.clone(),
295            state,
296            discovered_at: now_ms,
297            connected_at: if peer.is_connected {
298                Some(now_ms)
299            } else {
300                None
301            },
302            disconnected_at: None,
303            disconnect_reason: None,
304            last_rssi: Some(peer.rssi),
305            connection_count: if peer.is_connected { 1 } else { 0 },
306            documents_synced: 0,
307            bytes_received: 0,
308            bytes_sent: 0,
309            last_seen_ms: peer.last_seen_ms,
310            name: peer.name.clone(),
311            mesh_id: peer.mesh_id.clone(),
312        }
313    }
314
315    /// Transition to connecting state
316    pub fn set_connecting(&mut self, now_ms: u64) {
317        self.state = ConnectionState::Connecting;
318        self.last_seen_ms = now_ms;
319    }
320
321    /// Transition to connected state
322    pub fn set_connected(&mut self, now_ms: u64) {
323        self.state = ConnectionState::Connected;
324        self.connected_at = Some(now_ms);
325        self.connection_count += 1;
326        self.last_seen_ms = now_ms;
327        self.disconnect_reason = None;
328    }
329
330    /// Transition to degraded state (still connected but poor quality)
331    pub fn set_degraded(&mut self, now_ms: u64) {
332        if self.state == ConnectionState::Connected {
333            self.state = ConnectionState::Degraded;
334            self.last_seen_ms = now_ms;
335        }
336    }
337
338    /// Transition to disconnected state
339    pub fn set_disconnected(&mut self, now_ms: u64, reason: DisconnectReason) {
340        self.state = ConnectionState::Disconnected;
341        self.disconnected_at = Some(now_ms);
342        self.disconnect_reason = Some(reason);
343        self.last_seen_ms = now_ms;
344    }
345
346    /// Transition to lost state (not seen in advertisements)
347    pub fn set_lost(&mut self, now_ms: u64) {
348        if self.state == ConnectionState::Disconnected {
349            self.state = ConnectionState::Lost;
350            self.last_seen_ms = now_ms;
351        }
352    }
353
354    /// Update RSSI and check for degradation
355    ///
356    /// Returns true if state changed to Degraded
357    pub fn update_rssi(&mut self, rssi: i8, now_ms: u64, degraded_threshold: i8) -> bool {
358        self.last_rssi = Some(rssi);
359        self.last_seen_ms = now_ms;
360
361        if self.state == ConnectionState::Connected && rssi < degraded_threshold {
362            self.state = ConnectionState::Degraded;
363            return true;
364        } else if self.state == ConnectionState::Degraded && rssi >= degraded_threshold {
365            self.state = ConnectionState::Connected;
366        }
367        false
368    }
369
370    /// Record data transfer statistics
371    pub fn record_transfer(&mut self, bytes_received: u64, bytes_sent: u64) {
372        self.bytes_received += bytes_received;
373        self.bytes_sent += bytes_sent;
374    }
375
376    /// Record a document sync
377    pub fn record_sync(&mut self) {
378        self.documents_synced += 1;
379    }
380
381    /// Get time since last connection (if ever connected)
382    pub fn time_since_connected(&self, now_ms: u64) -> Option<u64> {
383        self.connected_at.map(|t| now_ms.saturating_sub(t))
384    }
385
386    /// Get time since disconnection (if disconnected)
387    pub fn time_since_disconnected(&self, now_ms: u64) -> Option<u64> {
388        self.disconnected_at.map(|t| now_ms.saturating_sub(t))
389    }
390
391    /// Get connection duration if currently connected
392    pub fn connection_duration(&self, now_ms: u64) -> Option<u64> {
393        if self.state.is_connected() {
394            self.connected_at.map(|t| now_ms.saturating_sub(t))
395        } else {
396            None
397        }
398    }
399
400    /// Get signal strength category
401    pub fn signal_strength(&self) -> Option<SignalStrength> {
402        self.last_rssi.map(|rssi| match rssi {
403            r if r >= -50 => SignalStrength::Excellent,
404            r if r >= -70 => SignalStrength::Good,
405            r if r >= -85 => SignalStrength::Fair,
406            _ => SignalStrength::Weak,
407        })
408    }
409}
410
411#[cfg(feature = "std")]
412use std::collections::BTreeMap;
413
414#[cfg(not(feature = "std"))]
415use alloc::collections::BTreeMap;
416
417/// Connection state graph for tracking all peer connection states
418///
419/// Provides a queryable view of all known peers and their connection
420/// lifecycle state. Apps can use this to display appropriate UI indicators
421/// and associate data with connection state at time of receipt.
422///
423/// # Example
424///
425/// ```ignore
426/// let graph = mesh.get_connection_graph();
427///
428/// // Show connected peers with green indicator
429/// for peer in graph.get_connected() {
430///     ui.show_peer_connected(&peer);
431/// }
432///
433/// // Show recently disconnected peers with yellow indicator
434/// for peer in graph.get_recently_disconnected(30_000) {
435///     ui.show_peer_stale(&peer, peer.time_since_disconnected(now));
436/// }
437///
438/// // Show lost peers with gray indicator
439/// for peer in graph.get_lost() {
440///     ui.show_peer_lost(&peer);
441/// }
442/// ```
443#[derive(Debug, Clone, Default)]
444pub struct ConnectionStateGraph {
445    /// Direct peers (degree 0) indexed by node ID
446    peers: BTreeMap<NodeId, PeerConnectionState>,
447
448    /// Indirect peers (degree 1-3) indexed by node ID
449    indirect_peers: BTreeMap<NodeId, IndirectPeer>,
450
451    /// RSSI threshold for degraded state
452    rssi_degraded_threshold: i8,
453
454    /// Time after disconnect before Lost state
455    lost_timeout_ms: u64,
456
457    /// Time after which indirect peers are considered stale
458    indirect_peer_timeout_ms: u64,
459}
460
461impl ConnectionStateGraph {
462    /// Create a new empty connection state graph
463    pub fn new() -> Self {
464        Self {
465            peers: BTreeMap::new(),
466            indirect_peers: BTreeMap::new(),
467            rssi_degraded_threshold: -80,
468            lost_timeout_ms: 30_000,
469            indirect_peer_timeout_ms: 120_000, // 2 minutes for indirect peers
470        }
471    }
472
473    /// Create with custom thresholds
474    pub fn with_config(rssi_degraded_threshold: i8, lost_timeout_ms: u64) -> Self {
475        Self {
476            peers: BTreeMap::new(),
477            indirect_peers: BTreeMap::new(),
478            rssi_degraded_threshold,
479            lost_timeout_ms,
480            indirect_peer_timeout_ms: 120_000,
481        }
482    }
483
484    /// Get all tracked peers
485    pub fn get_all(&self) -> Vec<&PeerConnectionState> {
486        self.peers.values().collect()
487    }
488
489    /// Get all peers as owned values
490    pub fn get_all_owned(&self) -> Vec<PeerConnectionState> {
491        self.peers.values().cloned().collect()
492    }
493
494    /// Get a specific peer's state
495    pub fn get_peer(&self, node_id: NodeId) -> Option<&PeerConnectionState> {
496        self.peers.get(&node_id)
497    }
498
499    /// Get a mutable reference to a peer's state
500    pub fn get_peer_mut(&mut self, node_id: NodeId) -> Option<&mut PeerConnectionState> {
501        self.peers.get_mut(&node_id)
502    }
503
504    /// Get all currently connected peers (Connected or Degraded state)
505    pub fn get_connected(&self) -> Vec<&PeerConnectionState> {
506        self.peers
507            .values()
508            .filter(|p| p.state.is_connected())
509            .collect()
510    }
511
512    /// Get all peers in Degraded state
513    pub fn get_degraded(&self) -> Vec<&PeerConnectionState> {
514        self.peers
515            .values()
516            .filter(|p| p.state == ConnectionState::Degraded)
517            .collect()
518    }
519
520    /// Get peers disconnected within the specified time window
521    pub fn get_recently_disconnected(
522        &self,
523        within_ms: u64,
524        now_ms: u64,
525    ) -> Vec<&PeerConnectionState> {
526        self.peers
527            .values()
528            .filter(|p| {
529                p.state == ConnectionState::Disconnected
530                    && p.disconnected_at
531                        .map(|t| now_ms.saturating_sub(t) <= within_ms)
532                        .unwrap_or(false)
533            })
534            .collect()
535    }
536
537    /// Get all peers in Lost state
538    pub fn get_lost(&self) -> Vec<&PeerConnectionState> {
539        self.peers
540            .values()
541            .filter(|p| p.state == ConnectionState::Lost)
542            .collect()
543    }
544
545    /// Get peers that were previously connected (have connection history)
546    pub fn get_with_history(&self) -> Vec<&PeerConnectionState> {
547        self.peers
548            .values()
549            .filter(|p| p.state.was_connected())
550            .collect()
551    }
552
553    /// Count of peers in each state
554    pub fn state_counts(&self) -> StateCountSummary {
555        let mut summary = StateCountSummary::default();
556        for peer in self.peers.values() {
557            match peer.state {
558                ConnectionState::Discovered => summary.discovered += 1,
559                ConnectionState::Connecting => summary.connecting += 1,
560                ConnectionState::Connected => summary.connected += 1,
561                ConnectionState::Degraded => summary.degraded += 1,
562                ConnectionState::Disconnecting => summary.disconnecting += 1,
563                ConnectionState::Disconnected => summary.disconnected += 1,
564                ConnectionState::Lost => summary.lost += 1,
565            }
566        }
567        summary
568    }
569
570    /// Total number of tracked peers
571    pub fn len(&self) -> usize {
572        self.peers.len()
573    }
574
575    /// Check if graph is empty
576    pub fn is_empty(&self) -> bool {
577        self.peers.is_empty()
578    }
579
580    /// Register a newly discovered peer
581    pub fn on_discovered(
582        &mut self,
583        node_id: NodeId,
584        identifier: String,
585        name: Option<String>,
586        mesh_id: Option<String>,
587        rssi: i8,
588        now_ms: u64,
589    ) -> &PeerConnectionState {
590        let entry = self.peers.entry(node_id).or_insert_with(|| {
591            PeerConnectionState::new_discovered(node_id, identifier.clone(), now_ms)
592        });
593
594        // Update metadata
595        entry.last_rssi = Some(rssi);
596        entry.last_seen_ms = now_ms;
597        if name.is_some() {
598            entry.name = name;
599        }
600        if mesh_id.is_some() {
601            entry.mesh_id = mesh_id;
602        }
603
604        // If was disconnected/lost and now seen again, update state
605        if entry.state == ConnectionState::Lost {
606            entry.state = ConnectionState::Disconnected;
607        }
608
609        entry
610    }
611
612    /// Handle connection start
613    pub fn on_connecting(&mut self, node_id: NodeId, now_ms: u64) {
614        if let Some(peer) = self.peers.get_mut(&node_id) {
615            peer.set_connecting(now_ms);
616        }
617    }
618
619    /// Handle successful connection
620    pub fn on_connected(&mut self, node_id: NodeId, now_ms: u64) {
621        if let Some(peer) = self.peers.get_mut(&node_id) {
622            peer.set_connected(now_ms);
623        }
624    }
625
626    /// Handle disconnection
627    pub fn on_disconnected(&mut self, node_id: NodeId, reason: DisconnectReason, now_ms: u64) {
628        if let Some(peer) = self.peers.get_mut(&node_id) {
629            peer.set_disconnected(now_ms, reason);
630        }
631    }
632
633    /// Update RSSI for a peer, checking for degradation
634    ///
635    /// Returns true if peer transitioned to Degraded state
636    pub fn update_rssi(&mut self, node_id: NodeId, rssi: i8, now_ms: u64) -> bool {
637        if let Some(peer) = self.peers.get_mut(&node_id) {
638            return peer.update_rssi(rssi, now_ms, self.rssi_degraded_threshold);
639        }
640        false
641    }
642
643    /// Record data transfer for a peer
644    pub fn record_transfer(&mut self, node_id: NodeId, bytes_received: u64, bytes_sent: u64) {
645        if let Some(peer) = self.peers.get_mut(&node_id) {
646            peer.record_transfer(bytes_received, bytes_sent);
647        }
648    }
649
650    /// Record a document sync for a peer
651    pub fn record_sync(&mut self, node_id: NodeId) {
652        if let Some(peer) = self.peers.get_mut(&node_id) {
653            peer.record_sync();
654        }
655    }
656
657    /// Run periodic maintenance (transition Disconnected → Lost)
658    ///
659    /// Returns list of peers that transitioned to Lost state
660    pub fn tick(&mut self, now_ms: u64) -> Vec<NodeId> {
661        let mut newly_lost = Vec::new();
662
663        for (node_id, peer) in self.peers.iter_mut() {
664            if peer.state == ConnectionState::Disconnected {
665                if let Some(disconnected_at) = peer.disconnected_at {
666                    if now_ms.saturating_sub(disconnected_at) > self.lost_timeout_ms {
667                        peer.set_lost(now_ms);
668                        newly_lost.push(*node_id);
669                    }
670                }
671            }
672        }
673
674        newly_lost
675    }
676
677    /// Remove peers that have been lost for longer than the specified duration
678    pub fn cleanup_lost(&mut self, older_than_ms: u64, now_ms: u64) -> Vec<NodeId> {
679        let to_remove: Vec<NodeId> = self
680            .peers
681            .iter()
682            .filter(|(_, p)| {
683                p.state == ConnectionState::Lost
684                    && now_ms.saturating_sub(p.last_seen_ms) > older_than_ms
685            })
686            .map(|(id, _)| *id)
687            .collect();
688
689        for id in &to_remove {
690            self.peers.remove(id);
691        }
692
693        to_remove
694    }
695
696    /// Import state from a PeatPeer
697    pub fn import_peer(&mut self, peer: &PeatPeer, now_ms: u64) {
698        let state = PeerConnectionState::from_peer(peer, now_ms);
699        self.peers.insert(peer.node_id, state);
700    }
701
702    // ========== Indirect Peer Methods ==========
703
704    /// Record that we received a relay message with given origin
705    ///
706    /// This updates the indirect peer graph when we receive a relay message
707    /// where the origin differs from the immediate sender.
708    ///
709    /// # Arguments
710    /// * `source_peer` - The direct peer we received the relay from
711    /// * `origin_node` - The original sender (from relay envelope)
712    /// * `hop_count` - Current hop count from the relay envelope
713    /// * `now_ms` - Current timestamp
714    ///
715    /// # Returns
716    /// `true` if this is a newly discovered indirect peer
717    pub fn on_relay_received(
718        &mut self,
719        source_peer: NodeId,
720        origin_node: NodeId,
721        hop_count: u8,
722        now_ms: u64,
723    ) -> bool {
724        // Don't track peers beyond our max degree
725        if hop_count > MAX_TRACKED_DEGREE {
726            return false;
727        }
728
729        // Don't track ourselves
730        if self.peers.contains_key(&origin_node) {
731            // Origin is a direct peer, not indirect
732            return false;
733        }
734
735        // Update or create indirect peer entry
736        if let Some(existing) = self.indirect_peers.get_mut(&origin_node) {
737            existing.update_path(source_peer, hop_count, now_ms);
738            false
739        } else {
740            self.indirect_peers.insert(
741                origin_node,
742                IndirectPeer::new(origin_node, source_peer, hop_count, now_ms),
743            );
744            true
745        }
746    }
747
748    /// Get all indirect peers
749    pub fn get_indirect_peers(&self) -> Vec<&IndirectPeer> {
750        self.indirect_peers.values().collect()
751    }
752
753    /// Get all indirect peers as owned values
754    pub fn get_indirect_peers_owned(&self) -> Vec<IndirectPeer> {
755        self.indirect_peers.values().cloned().collect()
756    }
757
758    /// Get a specific indirect peer
759    pub fn get_indirect_peer(&self, node_id: NodeId) -> Option<&IndirectPeer> {
760        self.indirect_peers.get(&node_id)
761    }
762
763    /// Get peers by degree
764    pub fn get_peers_by_degree(&self, degree: PeerDegree) -> Vec<NodeId> {
765        match degree {
766            PeerDegree::Direct => self.peers.keys().copied().collect(),
767            _ => self
768                .indirect_peers
769                .iter()
770                .filter(|(_, p)| p.degree() == Some(degree))
771                .map(|(id, _)| *id)
772                .collect(),
773        }
774    }
775
776    /// Get the degree of a specific peer (direct or indirect)
777    pub fn peer_degree(&self, node_id: NodeId) -> Option<PeerDegree> {
778        if self.peers.contains_key(&node_id) {
779            Some(PeerDegree::Direct)
780        } else {
781            self.indirect_peers.get(&node_id).and_then(|p| p.degree())
782        }
783    }
784
785    /// Get all paths to reach an indirect peer
786    ///
787    /// Returns Vec of (via_peer_id, hop_count) pairs
788    pub fn get_paths_to(&self, node_id: NodeId) -> Vec<(NodeId, u8)> {
789        self.indirect_peers
790            .get(&node_id)
791            .map(|p| p.paths())
792            .unwrap_or_default()
793    }
794
795    /// Check if a node is known (either direct or indirect)
796    pub fn is_known(&self, node_id: NodeId) -> bool {
797        self.peers.contains_key(&node_id) || self.indirect_peers.contains_key(&node_id)
798    }
799
800    /// Cleanup stale indirect peers
801    ///
802    /// Returns list of removed peer IDs
803    pub fn cleanup_indirect(&mut self, now_ms: u64) -> Vec<NodeId> {
804        let to_remove: Vec<NodeId> = self
805            .indirect_peers
806            .iter()
807            .filter(|(_, p)| p.is_stale(now_ms, self.indirect_peer_timeout_ms))
808            .map(|(id, _)| *id)
809            .collect();
810
811        for id in &to_remove {
812            self.indirect_peers.remove(id);
813        }
814
815        to_remove
816    }
817
818    /// Remove a via_peer path from all indirect peers
819    ///
820    /// Called when a direct peer disconnects - the indirect paths through
821    /// that peer may no longer be valid.
822    pub fn remove_via_peer(&mut self, via_peer: NodeId) {
823        let mut to_remove = Vec::new();
824
825        for (node_id, indirect) in self.indirect_peers.iter_mut() {
826            indirect.via_peers.remove(&via_peer);
827
828            // Recalculate min_hops
829            if indirect.via_peers.is_empty() {
830                to_remove.push(*node_id);
831            } else {
832                indirect.min_hops = indirect.via_peers.values().copied().min().unwrap_or(255);
833            }
834        }
835
836        // Remove peers with no remaining paths
837        for id in to_remove {
838            self.indirect_peers.remove(&id);
839        }
840    }
841
842    /// Combined count summary including indirect peers
843    pub fn full_state_counts(&self) -> FullStateCountSummary {
844        let direct = self.state_counts();
845
846        let mut one_hop = 0;
847        let mut two_hop = 0;
848        let mut three_hop = 0;
849
850        for peer in self.indirect_peers.values() {
851            match peer.min_hops {
852                1 => one_hop += 1,
853                2 => two_hop += 1,
854                3 => three_hop += 1,
855                _ => {}
856            }
857        }
858
859        FullStateCountSummary {
860            direct,
861            one_hop,
862            two_hop,
863            three_hop,
864        }
865    }
866
867    /// Number of indirect peers
868    pub fn indirect_peer_count(&self) -> usize {
869        self.indirect_peers.len()
870    }
871
872    /// Set callsign for an indirect peer (learned from document)
873    pub fn set_indirect_callsign(&mut self, node_id: NodeId, callsign: String) {
874        if let Some(peer) = self.indirect_peers.get_mut(&node_id) {
875            peer.callsign = Some(callsign);
876        }
877    }
878}
879
880/// Summary of peer counts by state
881#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
882pub struct StateCountSummary {
883    /// Peers discovered but never connected
884    pub discovered: usize,
885    /// Peers currently connecting
886    pub connecting: usize,
887    /// Peers with healthy connection
888    pub connected: usize,
889    /// Peers connected but with degraded signal
890    pub degraded: usize,
891    /// Peers currently disconnecting
892    pub disconnecting: usize,
893    /// Peers recently disconnected
894    pub disconnected: usize,
895    /// Peers disconnected and not seen in advertisements
896    pub lost: usize,
897}
898
899impl StateCountSummary {
900    /// Total number of peers actively connected
901    pub fn active_connections(&self) -> usize {
902        self.connected + self.degraded
903    }
904
905    /// Total number of tracked peers
906    pub fn total(&self) -> usize {
907        self.discovered
908            + self.connecting
909            + self.connected
910            + self.degraded
911            + self.disconnecting
912            + self.disconnected
913            + self.lost
914    }
915}
916
917/// Maximum number of hops to track for indirect peers
918pub const MAX_TRACKED_DEGREE: u8 = 3;
919
920/// Peer degree classification for multi-hop mesh topology
921#[derive(Debug, Clone, Copy, PartialEq, Eq)]
922pub enum PeerDegree {
923    /// Directly connected via BLE (degree 0)
924    Direct,
925    /// Reachable via 1 hop through a direct peer (degree 1)
926    OneHop,
927    /// Reachable via 2 hops (degree 2)
928    TwoHop,
929    /// Reachable via 3 hops (degree 3)
930    ThreeHop,
931}
932
933impl PeerDegree {
934    /// Create from hop count
935    pub fn from_hops(hops: u8) -> Option<Self> {
936        match hops {
937            0 => Some(Self::Direct),
938            1 => Some(Self::OneHop),
939            2 => Some(Self::TwoHop),
940            3 => Some(Self::ThreeHop),
941            _ => None, // Beyond tracking range
942        }
943    }
944
945    /// Get the hop count for this degree
946    pub fn hops(&self) -> u8 {
947        match self {
948            Self::Direct => 0,
949            Self::OneHop => 1,
950            Self::TwoHop => 2,
951            Self::ThreeHop => 3,
952        }
953    }
954}
955
956/// Reachability information for an indirect (multi-hop) peer
957///
958/// Tracks peers that are not directly connected via BLE but are
959/// reachable through relay messages via intermediate nodes.
960#[derive(Debug, Clone)]
961pub struct IndirectPeer {
962    /// The indirect peer's node ID
963    pub node_id: NodeId,
964
965    /// Minimum hop count to reach this peer (1-3)
966    pub min_hops: u8,
967
968    /// Direct peers through which we can reach this peer
969    /// Maps via_peer NodeId → hop count through that peer
970    pub via_peers: BTreeMap<NodeId, u8>,
971
972    /// When we first learned about this peer (ms since epoch)
973    pub discovered_at: u64,
974
975    /// Last time we received data from/about this peer (ms since epoch)
976    pub last_seen_ms: u64,
977
978    /// Number of messages relayed from this peer
979    pub messages_received: u32,
980
981    /// Optional callsign if learned from documents
982    pub callsign: Option<String>,
983}
984
985impl IndirectPeer {
986    /// Create a new indirect peer entry
987    pub fn new(node_id: NodeId, via_peer: NodeId, hop_count: u8, now_ms: u64) -> Self {
988        let mut via_peers = BTreeMap::new();
989        via_peers.insert(via_peer, hop_count);
990
991        Self {
992            node_id,
993            min_hops: hop_count,
994            via_peers,
995            discovered_at: now_ms,
996            last_seen_ms: now_ms,
997            messages_received: 1,
998            callsign: None,
999        }
1000    }
1001
1002    /// Update with a new path to this peer
1003    ///
1004    /// Returns true if this is a better (shorter) path
1005    pub fn update_path(&mut self, via_peer: NodeId, hop_count: u8, now_ms: u64) -> bool {
1006        self.last_seen_ms = now_ms;
1007        self.messages_received += 1;
1008
1009        let is_better = hop_count < self.min_hops;
1010
1011        // Update or add this path
1012        self.via_peers.insert(via_peer, hop_count);
1013
1014        // Recalculate min_hops
1015        if is_better {
1016            self.min_hops = hop_count;
1017        } else {
1018            // May need to recalculate if we updated an existing path
1019            self.min_hops = self.via_peers.values().copied().min().unwrap_or(hop_count);
1020        }
1021
1022        is_better
1023    }
1024
1025    /// Get the degree classification for this peer
1026    pub fn degree(&self) -> Option<PeerDegree> {
1027        PeerDegree::from_hops(self.min_hops)
1028    }
1029
1030    /// Check if this peer is stale (not seen within timeout)
1031    pub fn is_stale(&self, now_ms: u64, timeout_ms: u64) -> bool {
1032        now_ms.saturating_sub(self.last_seen_ms) > timeout_ms
1033    }
1034
1035    /// Get all paths to this peer as (via_peer, hop_count) pairs
1036    pub fn paths(&self) -> Vec<(NodeId, u8)> {
1037        self.via_peers.iter().map(|(&k, &v)| (k, v)).collect()
1038    }
1039}
1040
1041/// Extended state summary including indirect peers
1042#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1043pub struct FullStateCountSummary {
1044    /// Direct peer counts by connection state
1045    pub direct: StateCountSummary,
1046    /// Number of 1-hop indirect peers
1047    pub one_hop: usize,
1048    /// Number of 2-hop indirect peers
1049    pub two_hop: usize,
1050    /// Number of 3-hop indirect peers
1051    pub three_hop: usize,
1052}
1053
1054impl FullStateCountSummary {
1055    /// Total number of all known peers (direct + indirect)
1056    pub fn total(&self) -> usize {
1057        self.direct.total() + self.one_hop + self.two_hop + self.three_hop
1058    }
1059
1060    /// Total indirect peers
1061    pub fn total_indirect(&self) -> usize {
1062        self.one_hop + self.two_hop + self.three_hop
1063    }
1064}
1065
1066/// Configuration for the PeerManager
1067///
1068/// Provides configurable timeouts and behaviors for peer management.
1069/// All time values are in milliseconds.
1070#[derive(Debug, Clone)]
1071pub struct PeerManagerConfig {
1072    /// Time after which a peer is considered stale and removed (default: 45000ms)
1073    pub peer_timeout_ms: u64,
1074
1075    /// How often to run cleanup of stale peers (default: 10000ms)
1076    pub cleanup_interval_ms: u64,
1077
1078    /// How often to sync documents with peers (default: 5000ms)
1079    pub sync_interval_ms: u64,
1080
1081    /// Minimum time between syncs to the same peer (default: 30000ms)
1082    /// Prevents "thrashing" when peers keep reconnecting
1083    pub sync_cooldown_ms: u64,
1084
1085    /// Whether to automatically connect to discovered peers (default: true)
1086    pub auto_connect: bool,
1087
1088    /// Local mesh ID for filtering peers (e.g., "DEMO")
1089    pub mesh_id: String,
1090
1091    /// Maximum number of tracked peers (for no_std/embedded, default: 8)
1092    pub max_peers: usize,
1093
1094    /// RSSI threshold below which a connection is considered degraded (default: -80 dBm)
1095    pub rssi_degraded_threshold: i8,
1096
1097    /// Time after disconnect before peer transitions to Lost state (default: 30000ms)
1098    pub lost_timeout_ms: u64,
1099}
1100
1101impl Default for PeerManagerConfig {
1102    fn default() -> Self {
1103        Self {
1104            peer_timeout_ms: 45_000,     // 45 seconds
1105            cleanup_interval_ms: 10_000, // 10 seconds
1106            sync_interval_ms: 5_000,     // 5 seconds
1107            sync_cooldown_ms: 30_000,    // 30 seconds
1108            auto_connect: true,
1109            mesh_id: String::from("DEMO"),
1110            max_peers: 8,
1111            rssi_degraded_threshold: -80, // -80 dBm (Fair/Weak boundary)
1112            lost_timeout_ms: 30_000,      // 30 seconds after disconnect
1113        }
1114    }
1115}
1116
1117impl PeerManagerConfig {
1118    /// Create a new config with the specified mesh ID
1119    pub fn with_mesh_id(mesh_id: impl Into<String>) -> Self {
1120        Self {
1121            mesh_id: mesh_id.into(),
1122            ..Default::default()
1123        }
1124    }
1125
1126    /// Set peer timeout
1127    pub fn peer_timeout(mut self, timeout_ms: u64) -> Self {
1128        self.peer_timeout_ms = timeout_ms;
1129        self
1130    }
1131
1132    /// Set sync interval
1133    pub fn sync_interval(mut self, interval_ms: u64) -> Self {
1134        self.sync_interval_ms = interval_ms;
1135        self
1136    }
1137
1138    /// Set auto-connect behavior
1139    pub fn auto_connect(mut self, enabled: bool) -> Self {
1140        self.auto_connect = enabled;
1141        self
1142    }
1143
1144    /// Set max peers (for embedded systems)
1145    pub fn max_peers(mut self, max: usize) -> Self {
1146        self.max_peers = max;
1147        self
1148    }
1149
1150    /// Check if a device mesh ID matches our mesh
1151    ///
1152    /// Returns true if:
1153    /// - Device mesh ID matches our mesh ID exactly, OR
1154    /// - Device mesh ID is None (legacy device, matches any mesh)
1155    pub fn matches_mesh(&self, device_mesh_id: Option<&str>) -> bool {
1156        match device_mesh_id {
1157            Some(id) => id == self.mesh_id,
1158            None => true, // Legacy devices match any mesh
1159        }
1160    }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165    use super::*;
1166
1167    #[test]
1168    fn test_peer_stale_detection() {
1169        let mut peer = PeatPeer::new(
1170            NodeId::new(0x12345678),
1171            "test-id".into(),
1172            Some("DEMO".into()),
1173            Some("PEAT_DEMO-12345678".into()),
1174            -70,
1175        );
1176
1177        // Fresh peer is not stale
1178        peer.touch(1000);
1179        assert!(!peer.is_stale(2000, 45_000));
1180
1181        // Peer becomes stale after timeout
1182        assert!(peer.is_stale(50_000, 45_000));
1183    }
1184
1185    #[test]
1186    fn test_signal_strength() {
1187        let peer_excellent = PeatPeer {
1188            rssi: -45,
1189            ..Default::default()
1190        };
1191        assert_eq!(peer_excellent.signal_strength(), SignalStrength::Excellent);
1192
1193        let peer_good = PeatPeer {
1194            rssi: -65,
1195            ..Default::default()
1196        };
1197        assert_eq!(peer_good.signal_strength(), SignalStrength::Good);
1198
1199        let peer_fair = PeatPeer {
1200            rssi: -80,
1201            ..Default::default()
1202        };
1203        assert_eq!(peer_fair.signal_strength(), SignalStrength::Fair);
1204
1205        let peer_weak = PeatPeer {
1206            rssi: -95,
1207            ..Default::default()
1208        };
1209        assert_eq!(peer_weak.signal_strength(), SignalStrength::Weak);
1210    }
1211
1212    #[test]
1213    fn test_mesh_matching() {
1214        let config = PeerManagerConfig::with_mesh_id("ALPHA");
1215
1216        // Exact match
1217        assert!(config.matches_mesh(Some("ALPHA")));
1218
1219        // No match
1220        assert!(!config.matches_mesh(Some("BETA")));
1221
1222        // Legacy device (no mesh ID) matches any
1223        assert!(config.matches_mesh(None));
1224    }
1225}