Skip to main content

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    /// All tracked peers indexed by node ID
424    peers: BTreeMap<NodeId, PeerConnectionState>,
425
426    /// RSSI threshold for degraded state
427    rssi_degraded_threshold: i8,
428
429    /// Time after disconnect before Lost state
430    lost_timeout_ms: u64,
431}
432
433impl ConnectionStateGraph {
434    /// Create a new empty connection state graph
435    pub fn new() -> Self {
436        Self {
437            peers: BTreeMap::new(),
438            rssi_degraded_threshold: -80,
439            lost_timeout_ms: 30_000,
440        }
441    }
442
443    /// Create with custom thresholds
444    pub fn with_config(rssi_degraded_threshold: i8, lost_timeout_ms: u64) -> Self {
445        Self {
446            peers: BTreeMap::new(),
447            rssi_degraded_threshold,
448            lost_timeout_ms,
449        }
450    }
451
452    /// Get all tracked peers
453    pub fn get_all(&self) -> Vec<&PeerConnectionState> {
454        self.peers.values().collect()
455    }
456
457    /// Get all peers as owned values
458    pub fn get_all_owned(&self) -> Vec<PeerConnectionState> {
459        self.peers.values().cloned().collect()
460    }
461
462    /// Get a specific peer's state
463    pub fn get_peer(&self, node_id: NodeId) -> Option<&PeerConnectionState> {
464        self.peers.get(&node_id)
465    }
466
467    /// Get a mutable reference to a peer's state
468    pub fn get_peer_mut(&mut self, node_id: NodeId) -> Option<&mut PeerConnectionState> {
469        self.peers.get_mut(&node_id)
470    }
471
472    /// Get all currently connected peers (Connected or Degraded state)
473    pub fn get_connected(&self) -> Vec<&PeerConnectionState> {
474        self.peers
475            .values()
476            .filter(|p| p.state.is_connected())
477            .collect()
478    }
479
480    /// Get all peers in Degraded state
481    pub fn get_degraded(&self) -> Vec<&PeerConnectionState> {
482        self.peers
483            .values()
484            .filter(|p| p.state == ConnectionState::Degraded)
485            .collect()
486    }
487
488    /// Get peers disconnected within the specified time window
489    pub fn get_recently_disconnected(
490        &self,
491        within_ms: u64,
492        now_ms: u64,
493    ) -> Vec<&PeerConnectionState> {
494        self.peers
495            .values()
496            .filter(|p| {
497                p.state == ConnectionState::Disconnected
498                    && p.disconnected_at
499                        .map(|t| now_ms.saturating_sub(t) <= within_ms)
500                        .unwrap_or(false)
501            })
502            .collect()
503    }
504
505    /// Get all peers in Lost state
506    pub fn get_lost(&self) -> Vec<&PeerConnectionState> {
507        self.peers
508            .values()
509            .filter(|p| p.state == ConnectionState::Lost)
510            .collect()
511    }
512
513    /// Get peers that were previously connected (have connection history)
514    pub fn get_with_history(&self) -> Vec<&PeerConnectionState> {
515        self.peers
516            .values()
517            .filter(|p| p.state.was_connected())
518            .collect()
519    }
520
521    /// Count of peers in each state
522    pub fn state_counts(&self) -> StateCountSummary {
523        let mut summary = StateCountSummary::default();
524        for peer in self.peers.values() {
525            match peer.state {
526                ConnectionState::Discovered => summary.discovered += 1,
527                ConnectionState::Connecting => summary.connecting += 1,
528                ConnectionState::Connected => summary.connected += 1,
529                ConnectionState::Degraded => summary.degraded += 1,
530                ConnectionState::Disconnecting => summary.disconnecting += 1,
531                ConnectionState::Disconnected => summary.disconnected += 1,
532                ConnectionState::Lost => summary.lost += 1,
533            }
534        }
535        summary
536    }
537
538    /// Total number of tracked peers
539    pub fn len(&self) -> usize {
540        self.peers.len()
541    }
542
543    /// Check if graph is empty
544    pub fn is_empty(&self) -> bool {
545        self.peers.is_empty()
546    }
547
548    /// Register a newly discovered peer
549    pub fn on_discovered(
550        &mut self,
551        node_id: NodeId,
552        identifier: String,
553        name: Option<String>,
554        mesh_id: Option<String>,
555        rssi: i8,
556        now_ms: u64,
557    ) -> &PeerConnectionState {
558        let entry = self.peers.entry(node_id).or_insert_with(|| {
559            PeerConnectionState::new_discovered(node_id, identifier.clone(), now_ms)
560        });
561
562        // Update metadata
563        entry.last_rssi = Some(rssi);
564        entry.last_seen_ms = now_ms;
565        if name.is_some() {
566            entry.name = name;
567        }
568        if mesh_id.is_some() {
569            entry.mesh_id = mesh_id;
570        }
571
572        // If was disconnected/lost and now seen again, update state
573        if entry.state == ConnectionState::Lost {
574            entry.state = ConnectionState::Disconnected;
575        }
576
577        entry
578    }
579
580    /// Handle connection start
581    pub fn on_connecting(&mut self, node_id: NodeId, now_ms: u64) {
582        if let Some(peer) = self.peers.get_mut(&node_id) {
583            peer.set_connecting(now_ms);
584        }
585    }
586
587    /// Handle successful connection
588    pub fn on_connected(&mut self, node_id: NodeId, now_ms: u64) {
589        if let Some(peer) = self.peers.get_mut(&node_id) {
590            peer.set_connected(now_ms);
591        }
592    }
593
594    /// Handle disconnection
595    pub fn on_disconnected(&mut self, node_id: NodeId, reason: DisconnectReason, now_ms: u64) {
596        if let Some(peer) = self.peers.get_mut(&node_id) {
597            peer.set_disconnected(now_ms, reason);
598        }
599    }
600
601    /// Update RSSI for a peer, checking for degradation
602    ///
603    /// Returns true if peer transitioned to Degraded state
604    pub fn update_rssi(&mut self, node_id: NodeId, rssi: i8, now_ms: u64) -> bool {
605        if let Some(peer) = self.peers.get_mut(&node_id) {
606            return peer.update_rssi(rssi, now_ms, self.rssi_degraded_threshold);
607        }
608        false
609    }
610
611    /// Record data transfer for a peer
612    pub fn record_transfer(&mut self, node_id: NodeId, bytes_received: u64, bytes_sent: u64) {
613        if let Some(peer) = self.peers.get_mut(&node_id) {
614            peer.record_transfer(bytes_received, bytes_sent);
615        }
616    }
617
618    /// Record a document sync for a peer
619    pub fn record_sync(&mut self, node_id: NodeId) {
620        if let Some(peer) = self.peers.get_mut(&node_id) {
621            peer.record_sync();
622        }
623    }
624
625    /// Run periodic maintenance (transition Disconnected → Lost)
626    ///
627    /// Returns list of peers that transitioned to Lost state
628    pub fn tick(&mut self, now_ms: u64) -> Vec<NodeId> {
629        let mut newly_lost = Vec::new();
630
631        for (node_id, peer) in self.peers.iter_mut() {
632            if peer.state == ConnectionState::Disconnected {
633                if let Some(disconnected_at) = peer.disconnected_at {
634                    if now_ms.saturating_sub(disconnected_at) > self.lost_timeout_ms {
635                        peer.set_lost(now_ms);
636                        newly_lost.push(*node_id);
637                    }
638                }
639            }
640        }
641
642        newly_lost
643    }
644
645    /// Remove peers that have been lost for longer than the specified duration
646    pub fn cleanup_lost(&mut self, older_than_ms: u64, now_ms: u64) -> Vec<NodeId> {
647        let to_remove: Vec<NodeId> = self
648            .peers
649            .iter()
650            .filter(|(_, p)| {
651                p.state == ConnectionState::Lost
652                    && now_ms.saturating_sub(p.last_seen_ms) > older_than_ms
653            })
654            .map(|(id, _)| *id)
655            .collect();
656
657        for id in &to_remove {
658            self.peers.remove(id);
659        }
660
661        to_remove
662    }
663
664    /// Import state from a HivePeer
665    pub fn import_peer(&mut self, peer: &HivePeer, now_ms: u64) {
666        let state = PeerConnectionState::from_peer(peer, now_ms);
667        self.peers.insert(peer.node_id, state);
668    }
669}
670
671/// Summary of peer counts by state
672#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
673pub struct StateCountSummary {
674    /// Peers discovered but never connected
675    pub discovered: usize,
676    /// Peers currently connecting
677    pub connecting: usize,
678    /// Peers with healthy connection
679    pub connected: usize,
680    /// Peers connected but with degraded signal
681    pub degraded: usize,
682    /// Peers currently disconnecting
683    pub disconnecting: usize,
684    /// Peers recently disconnected
685    pub disconnected: usize,
686    /// Peers disconnected and not seen in advertisements
687    pub lost: usize,
688}
689
690impl StateCountSummary {
691    /// Total number of peers actively connected
692    pub fn active_connections(&self) -> usize {
693        self.connected + self.degraded
694    }
695
696    /// Total number of tracked peers
697    pub fn total(&self) -> usize {
698        self.discovered
699            + self.connecting
700            + self.connected
701            + self.degraded
702            + self.disconnecting
703            + self.disconnected
704            + self.lost
705    }
706}
707
708/// Configuration for the PeerManager
709///
710/// Provides configurable timeouts and behaviors for peer management.
711/// All time values are in milliseconds.
712#[derive(Debug, Clone)]
713pub struct PeerManagerConfig {
714    /// Time after which a peer is considered stale and removed (default: 45000ms)
715    pub peer_timeout_ms: u64,
716
717    /// How often to run cleanup of stale peers (default: 10000ms)
718    pub cleanup_interval_ms: u64,
719
720    /// How often to sync documents with peers (default: 5000ms)
721    pub sync_interval_ms: u64,
722
723    /// Minimum time between syncs to the same peer (default: 30000ms)
724    /// Prevents "thrashing" when peers keep reconnecting
725    pub sync_cooldown_ms: u64,
726
727    /// Whether to automatically connect to discovered peers (default: true)
728    pub auto_connect: bool,
729
730    /// Local mesh ID for filtering peers (e.g., "DEMO")
731    pub mesh_id: String,
732
733    /// Maximum number of tracked peers (for no_std/embedded, default: 8)
734    pub max_peers: usize,
735
736    /// RSSI threshold below which a connection is considered degraded (default: -80 dBm)
737    pub rssi_degraded_threshold: i8,
738
739    /// Time after disconnect before peer transitions to Lost state (default: 30000ms)
740    pub lost_timeout_ms: u64,
741}
742
743impl Default for PeerManagerConfig {
744    fn default() -> Self {
745        Self {
746            peer_timeout_ms: 45_000,     // 45 seconds
747            cleanup_interval_ms: 10_000, // 10 seconds
748            sync_interval_ms: 5_000,     // 5 seconds
749            sync_cooldown_ms: 30_000,    // 30 seconds
750            auto_connect: true,
751            mesh_id: String::from("DEMO"),
752            max_peers: 8,
753            rssi_degraded_threshold: -80, // -80 dBm (Fair/Weak boundary)
754            lost_timeout_ms: 30_000,      // 30 seconds after disconnect
755        }
756    }
757}
758
759impl PeerManagerConfig {
760    /// Create a new config with the specified mesh ID
761    pub fn with_mesh_id(mesh_id: impl Into<String>) -> Self {
762        Self {
763            mesh_id: mesh_id.into(),
764            ..Default::default()
765        }
766    }
767
768    /// Set peer timeout
769    pub fn peer_timeout(mut self, timeout_ms: u64) -> Self {
770        self.peer_timeout_ms = timeout_ms;
771        self
772    }
773
774    /// Set sync interval
775    pub fn sync_interval(mut self, interval_ms: u64) -> Self {
776        self.sync_interval_ms = interval_ms;
777        self
778    }
779
780    /// Set auto-connect behavior
781    pub fn auto_connect(mut self, enabled: bool) -> Self {
782        self.auto_connect = enabled;
783        self
784    }
785
786    /// Set max peers (for embedded systems)
787    pub fn max_peers(mut self, max: usize) -> Self {
788        self.max_peers = max;
789        self
790    }
791
792    /// Check if a device mesh ID matches our mesh
793    ///
794    /// Returns true if:
795    /// - Device mesh ID matches our mesh ID exactly, OR
796    /// - Device mesh ID is None (legacy device, matches any mesh)
797    pub fn matches_mesh(&self, device_mesh_id: Option<&str>) -> bool {
798        match device_mesh_id {
799            Some(id) => id == self.mesh_id,
800            None => true, // Legacy devices match any mesh
801        }
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    #[test]
810    fn test_peer_stale_detection() {
811        let mut peer = HivePeer::new(
812            NodeId::new(0x12345678),
813            "test-id".into(),
814            Some("DEMO".into()),
815            Some("HIVE_DEMO-12345678".into()),
816            -70,
817        );
818
819        // Fresh peer is not stale
820        peer.touch(1000);
821        assert!(!peer.is_stale(2000, 45_000));
822
823        // Peer becomes stale after timeout
824        assert!(peer.is_stale(50_000, 45_000));
825    }
826
827    #[test]
828    fn test_signal_strength() {
829        let peer_excellent = HivePeer {
830            rssi: -45,
831            ..Default::default()
832        };
833        assert_eq!(peer_excellent.signal_strength(), SignalStrength::Excellent);
834
835        let peer_good = HivePeer {
836            rssi: -65,
837            ..Default::default()
838        };
839        assert_eq!(peer_good.signal_strength(), SignalStrength::Good);
840
841        let peer_fair = HivePeer {
842            rssi: -80,
843            ..Default::default()
844        };
845        assert_eq!(peer_fair.signal_strength(), SignalStrength::Fair);
846
847        let peer_weak = HivePeer {
848            rssi: -95,
849            ..Default::default()
850        };
851        assert_eq!(peer_weak.signal_strength(), SignalStrength::Weak);
852    }
853
854    #[test]
855    fn test_mesh_matching() {
856        let config = PeerManagerConfig::with_mesh_id("ALPHA");
857
858        // Exact match
859        assert!(config.matches_mesh(Some("ALPHA")));
860
861        // No match
862        assert!(!config.matches_mesh(Some("BETA")));
863
864        // Legacy device (no mesh ID) matches any
865        assert!(config.matches_mesh(None));
866    }
867}