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