1use crate::constants::{HOP_COUNT_EMA_ALPHA, LATENCY_EMA_ALPHA, OBSERVED_STATS_COMPACT_THRESHOLD};
8use crate::events::{DorsEscalationPhase, DorsEscalationReasonCode, DorsReasonCode, Event};
9use crate::telemetry::routing::{RoutingDecision, RoutingPhase, RoutingReasonCode};
10use crate::{Error, Result};
11use chrono::Utc;
12use offline_protocol_core::time::Instant;
13use offline_protocol_core::{Message, Username, WireCodec};
14use offline_protocol_router::{
15 display_routing_score, DorsConfig, EscalationTriggerReason, TransportScore, TransportSelector,
16};
17use offline_protocol_transport::{
18 nostr::ResolveRequest, Error as TransportError, Transport, TransportMetrics, TransportStatus,
19 TransportType,
20};
21use std::collections::{HashMap, HashSet};
22use std::sync::Arc;
23use tracing::{debug, warn};
24
25pub struct TransportManager {
27 transports: HashMap<TransportType, Arc<dyn Transport>>,
33
34 current_transport: Option<TransportType>,
36
37 selector: TransportSelector,
39
40 observations: HashMap<TransportType, ObservedStats>,
42
43 dors_event_callback: Option<Arc<dyn Fn(Event) + Send + Sync>>,
45
46 routing_decision_callback: Option<Arc<dyn Fn(RoutingDecision) + Send + Sync>>,
51
52 routing_diagnostic: bool,
57
58 last_escalation_trigger_emitted: Option<(
60 DorsEscalationReasonCode,
61 offline_protocol_core::time::Instant,
62 )>,
63
64 peer_binary_wire: HashSet<String>,
68
69 device_battery: Option<u8>,
73
74 device_is_charging: bool,
76}
77
78const MESH_TRANSPORTS: &[TransportType] = &[TransportType::WiFiDirect, TransportType::BLE];
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct MeshNeighbor {
91 pub peer_id: String,
93 pub rssi: Option<i16>,
95 pub transport: TransportType,
97}
98
99impl MeshNeighbor {
100 pub fn link_quality(&self) -> u8 {
106 match self.rssi {
107 Some(rssi) => offline_protocol_transport::LinkQuality::from_rssi(rssi).value(),
108 None => offline_protocol_transport::DEFAULT_LINK_QUALITY_WITHOUT_RSSI,
109 }
110 }
111}
112
113const ESCALATION_TRIGGER_DEDUPE_SECS: u64 = 30;
115
116const MAX_PEER_BINARY_WIRE_ENTRIES: usize = 4096;
121
122fn has_send_confirmation_loop(transport_type: TransportType) -> bool {
126 match transport_type {
127 TransportType::Internet | TransportType::Reticulum | TransportType::Nostr => true,
128 TransportType::BLE | TransportType::WiFiDirect => false,
129 }
130}
131
132#[derive(Debug, Default, Clone)]
133struct ObservedStats {
134 success_count: u32,
135 failure_count: u32,
136 latency_ema: Option<f32>,
137 hop_ema: Option<f32>,
138 last_success_at: Option<Instant>,
139}
140
141impl ObservedStats {
142 fn record_success(&mut self, latency_ms: u32, hop_count: u8) {
143 self.success_count = self.success_count.saturating_add(1);
144 self.latency_ema = Some(update_ema(
145 self.latency_ema,
146 latency_ms as f32,
147 LATENCY_EMA_ALPHA,
148 ));
149 self.hop_ema = Some(update_ema(
150 self.hop_ema,
151 hop_count as f32,
152 HOP_COUNT_EMA_ALPHA,
153 ));
154 self.last_success_at = Some(Instant::now());
155 self.compact();
156 }
157
158 fn record_failure(&mut self) {
159 self.failure_count = self.failure_count.saturating_add(1);
160 self.compact();
161 }
162
163 fn compact(&mut self) {
164 let total = self.success_count.saturating_add(self.failure_count);
165 if total > OBSERVED_STATS_COMPACT_THRESHOLD {
166 self.success_count /= 2;
167 self.failure_count /= 2;
168 }
169 }
170
171 fn delivery_ratio(&self) -> Option<f32> {
172 let total = self.success_count + self.failure_count;
173 if total == 0 {
174 None
175 } else {
176 Some(self.success_count as f32 / total as f32)
177 }
178 }
179
180 fn drop_ratio(&self) -> Option<f32> {
181 self.delivery_ratio()
182 .map(|ratio| (1.0 - ratio).clamp(0.0, 1.0))
183 }
184
185 fn apply_to_metrics(&self, transport_type: TransportType, metrics: &mut TransportMetrics) {
196 if self.success_count + self.failure_count > 0 {
197 let transport_owns_counts = has_send_confirmation_loop(transport_type)
198 && metrics.success_count + metrics.failure_count > 0;
199 if !transport_owns_counts {
200 metrics.success_count = self.success_count;
201 metrics.failure_count = self.failure_count;
202 metrics.delivery_ratio = self.delivery_ratio();
203 metrics.drop_rate = self.drop_ratio();
204 }
205 }
206
207 if let Some(latency) = self.latency_ema {
208 metrics.latency_ms = Some(latency.round().clamp(0.0, u32::MAX as f32) as u32);
209 }
210
211 if let Some(hop) = self.hop_ema {
212 metrics.average_hop_count = Some(hop);
213 }
214 }
215}
216
217fn update_ema(current: Option<f32>, new_value: f32, alpha: f32) -> f32 {
218 let alpha = alpha.clamp(0.0, 1.0);
219 match current {
220 Some(existing) => existing * (1.0 - alpha) + new_value * alpha,
221 None => new_value,
222 }
223}
224
225impl TransportManager {
226 pub fn new(selector: TransportSelector) -> Self {
228 Self {
229 transports: HashMap::new(),
230 current_transport: None,
231 selector,
232 observations: HashMap::new(),
233 dors_event_callback: None,
234 routing_decision_callback: None,
235 routing_diagnostic: false,
236 last_escalation_trigger_emitted: None,
237 peer_binary_wire: HashSet::new(),
238 device_battery: None,
239 device_is_charging: false,
240 }
241 }
242
243 pub fn set_device_battery(&mut self, level: u8, is_charging: bool) {
257 self.device_battery = Some(level.min(100));
258 self.device_is_charging = is_charging;
259 }
260
261 pub fn device_battery(&self) -> (Option<u8>, bool) {
264 (self.device_battery, self.device_is_charging)
265 }
266
267 fn apply_device_battery(&self, metrics: &mut TransportMetrics) {
279 let Some(level) = self.device_battery else {
280 return;
281 };
282 if metrics.battery_level.is_none() {
283 metrics.battery_level = Some(level);
284 metrics.is_charging = self.device_is_charging;
285 }
286 }
287
288 pub fn peer_supports_binary_wire(&self, peer_id: &str) -> bool {
291 self.peer_binary_wire.contains(peer_id)
292 }
293
294 pub fn mark_peer_binary_wire(&mut self, peer_id: &str, supported: bool) {
298 if supported {
299 if !self.peer_binary_wire.contains(peer_id)
301 && self.peer_binary_wire.len() >= MAX_PEER_BINARY_WIRE_ENTRIES
302 {
303 self.peer_binary_wire.clear();
304 }
305 self.peer_binary_wire.insert(peer_id.to_string());
306 } else {
307 self.peer_binary_wire.remove(peer_id);
308 }
309 }
310
311 pub fn mark_peer_nostr_pubkey(&mut self, peer_id: &str, pubkey_hex: Option<&str>) {
326 let Some(transport) = self.transports.get(&TransportType::Nostr) else {
327 return;
328 };
329 if let Some(nostr) = transport
330 .as_any()
331 .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
332 {
333 match pubkey_hex {
334 Some(pubkey_hex) => nostr.set_peer_nostr_pubkey(peer_id, pubkey_hex),
335 None => nostr.forget_peer_nostr_pubkey(peer_id),
336 }
337 }
338 }
339
340 pub fn clear_peer_nostr_pubkey(&mut self, peer_id: &str) {
348 if let Some(nostr) = self.transports.get(&TransportType::Nostr).and_then(|t| {
349 t.as_any()
350 .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
351 }) {
352 nostr.forget_peer_nostr_pubkey(peer_id);
353 }
354 }
355
356 pub fn nostr_public_key(&self) -> Option<String> {
359 self.transports
360 .get(&TransportType::Nostr)?
361 .as_any()
362 .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
363 .map(|nostr| nostr.public_key_hex())
364 }
365
366 pub fn set_nostr_sealing_enabled(&mut self, enabled: bool) {
368 if let Some(nostr) = self.transports.get(&TransportType::Nostr).and_then(|t| {
369 t.as_any()
370 .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
371 }) {
372 nostr.set_sealing_enabled(enabled);
373 }
374 }
375
376 pub fn set_nostr_cold_contact_enabled(&mut self, enabled: bool) {
378 if let Some(nostr) = self.nostr_transport() {
379 nostr.set_cold_contact_enabled(enabled);
380 }
381 }
382
383 pub fn publish_nostr_key_package(&mut self, slot_id: &str, message: &Message) -> Result<()> {
396 let Some(nostr) = self.nostr_transport() else {
397 return Ok(());
398 };
399 let payload = nostr.serialize_message(message)?;
400 nostr.publish_key_package(slot_id, payload);
401 Ok(())
402 }
403
404 pub fn take_failed_nostr_publications(&self) -> Vec<String> {
409 self.nostr_transport()
410 .map(|nostr| nostr.take_failed_publications())
411 .unwrap_or_default()
412 }
413
414 pub fn nostr_cold_contact_active(&self) -> bool {
416 self.nostr_transport()
417 .map(|nostr| nostr.cold_contact_enabled())
418 .unwrap_or(false)
419 }
420
421 pub fn nostr_resolution_due(&self, peer_id: &str) -> bool {
424 self.nostr_transport()
425 .is_some_and(|nostr| nostr.resolution_due(peer_id))
426 }
427
428 pub fn request_nostr_key_packages(&self, peer_id: &str) -> bool {
431 self.nostr_transport()
432 .is_some_and(|nostr| nostr.request_peer_key_packages(peer_id))
433 }
434
435 pub fn set_nostr_username_discovery_enabled(&mut self, enabled: bool) {
437 if let Some(nostr) = self.nostr_transport() {
438 nostr.set_discovery_enabled(enabled);
439 }
440 }
441
442 pub fn nostr_discovery_active(&self) -> bool {
447 self.nostr_transport()
448 .map(|nostr| nostr.discovery_enabled())
449 .unwrap_or(false)
450 }
451
452 pub fn publish_nostr_discovery_record(&mut self, username: Username, payload: Vec<u8>) {
454 if let Some(nostr) = self.nostr_transport() {
455 nostr.publish_discovery_record(username, payload);
456 }
457 }
458
459 pub fn retract_nostr_discovery_record(&mut self, username: Username, payload: Vec<u8>) {
461 if let Some(nostr) = self.nostr_transport() {
462 nostr.retract_discovery_record(username, payload);
463 }
464 }
465
466 pub fn take_failed_nostr_discovery_publications(&self) -> Vec<String> {
468 self.nostr_transport()
469 .map(|nostr| nostr.take_failed_discovery_publications())
470 .unwrap_or_default()
471 }
472
473 pub fn take_confirmed_nostr_discovery_publications(&self) -> Vec<String> {
475 self.nostr_transport()
476 .map(|nostr| nostr.take_confirmed_discovery_publications())
477 .unwrap_or_default()
478 }
479
480 pub fn has_nostr_transport(&self) -> bool {
488 self.nostr_transport().is_some()
489 }
490
491 pub fn resolve_nostr_username(&self, username: Username) -> ResolveRequest {
496 self.nostr_transport()
497 .map(|nostr| nostr.resolve_username(username))
498 .unwrap_or(ResolveRequest::Disabled)
499 }
500
501 pub fn cancel_nostr_username_resolution(&self, username: &Username) -> bool {
507 self.nostr_transport()
508 .map(|nostr| nostr.cancel_username_resolution(username))
509 .unwrap_or(false)
510 }
511
512 fn nostr_transport(&self) -> Option<&offline_protocol_transport::nostr::NostrTransport> {
513 self.transports.get(&TransportType::Nostr).and_then(|t| {
514 t.as_any()
515 .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
516 })
517 }
518
519 pub fn set_dors_event_callback(&mut self, callback: Option<Arc<dyn Fn(Event) + Send + Sync>>) {
521 self.dors_event_callback = callback;
522 }
523
524 pub(crate) fn set_routing_decision_callback(
536 &mut self,
537 callback: Option<Arc<dyn Fn(RoutingDecision) + Send + Sync>>,
538 ) {
539 self.routing_decision_callback = callback;
540 }
541
542 pub(crate) fn set_routing_diagnostic(&mut self, enabled: bool) {
552 self.routing_diagnostic = enabled;
553 }
554
555 fn emit_dors_event(&self, event: Event) {
556 if let Some(ref cb) = self.dors_event_callback {
557 cb(event);
558 }
559 }
560
561 fn emit_routing_decision<F: FnOnce() -> RoutingDecision>(&self, build: F) {
565 if let Some(ref cb) = self.routing_decision_callback {
566 cb(build());
567 }
568 }
569
570 fn escalation_trigger_reason_to_code(r: EscalationTriggerReason) -> DorsEscalationReasonCode {
571 match r {
572 EscalationTriggerReason::RetryThreshold => DorsEscalationReasonCode::RetryThreshold,
573 EscalationTriggerReason::PoorSignal => DorsEscalationReasonCode::PoorSignal,
574 EscalationTriggerReason::Congestion => DorsEscalationReasonCode::Congestion,
575 EscalationTriggerReason::LowTtl => DorsEscalationReasonCode::LowTtl,
576 EscalationTriggerReason::LowSuccessRate => DorsEscalationReasonCode::LowSuccessRate,
577 }
578 }
579
580 fn dors_reason_code_to_routing(code: DorsReasonCode) -> RoutingReasonCode {
581 match code {
582 DorsReasonCode::InitialSelection => RoutingReasonCode::InitialSelection,
583 DorsReasonCode::PrimarySelected => RoutingReasonCode::PrimarySelected,
584 DorsReasonCode::PrimarySuccess => RoutingReasonCode::PrimarySuccess,
585 DorsReasonCode::FallbackSuccess => RoutingReasonCode::FallbackSuccess,
586 DorsReasonCode::EscalationApplied => RoutingReasonCode::EscalationApplied,
587 DorsReasonCode::CurrentUnavailable => RoutingReasonCode::CurrentUnavailable,
588 }
589 }
590
591 fn escalation_reason_code_to_routing(code: DorsEscalationReasonCode) -> RoutingReasonCode {
592 match code {
593 DorsEscalationReasonCode::FallbackSuccess => RoutingReasonCode::FallbackSuccess,
594 DorsEscalationReasonCode::RetryThreshold => RoutingReasonCode::RetryThreshold,
595 DorsEscalationReasonCode::PoorSignal => RoutingReasonCode::PoorSignal,
596 DorsEscalationReasonCode::Congestion => RoutingReasonCode::Congestion,
597 DorsEscalationReasonCode::LowTtl => RoutingReasonCode::LowTtl,
598 DorsEscalationReasonCode::LowSuccessRate => RoutingReasonCode::LowSuccessRate,
599 }
600 }
601
602 #[allow(clippy::too_many_arguments)]
611 fn build_routing_decision(
612 &self,
613 now_ms: i64,
614 phase: RoutingPhase,
615 from: Option<TransportType>,
616 to: Option<TransportType>,
617 winning_score: Option<f32>,
618 reason_code: Option<RoutingReasonCode>,
619 detailed_scores: Option<&[(TransportType, TransportScore)]>,
620 ) -> RoutingDecision {
621 let scores = match (self.routing_diagnostic, detailed_scores) {
628 (true, Some(detailed)) => detailed
629 .iter()
630 .map(|(t, s)| {
631 let mut s = s.clone();
632 s.total = display_routing_score(s.total);
633 (*t, s)
634 })
635 .collect(),
636 _ => Vec::new(),
637 };
638 RoutingDecision {
639 timestamp_ms: now_ms,
640 phase,
641 from,
642 to,
643 winning_score: winning_score.map(display_routing_score),
644 reason_code,
645 scores,
646 }
647 }
648
649 fn emit_escalation_trigger_if_deduped(
651 &mut self,
652 now_ms: i64,
653 reason_code: DorsEscalationReasonCode,
654 from: TransportType,
655 to: TransportType,
656 reason_detail: Option<String>,
657 detailed_scores: Option<&[(TransportType, TransportScore)]>,
658 ) {
659 let now = offline_protocol_core::time::Instant::now();
660 let emit = match &self.last_escalation_trigger_emitted {
661 Some((last_code, last_at)) => {
662 *last_code != reason_code
663 || last_at.elapsed().as_secs() >= ESCALATION_TRIGGER_DEDUPE_SECS
664 }
665 None => true,
666 };
667 if emit {
668 self.last_escalation_trigger_emitted = Some((reason_code, now));
669 self.emit_dors_event(Event::dors_escalation_triggered(
670 DorsEscalationPhase::Triggered,
671 from.to_string(),
672 to.to_string(),
673 reason_code,
674 reason_detail,
675 ));
676 self.emit_routing_decision(|| {
677 self.build_routing_decision(
678 now_ms,
679 RoutingPhase::Escalated,
680 Some(from),
681 Some(to),
682 None,
683 Some(Self::escalation_reason_code_to_routing(reason_code)),
684 detailed_scores,
685 )
686 });
687 }
688 }
689
690 pub fn add_transport(&mut self, transport_type: TransportType, transport: Box<dyn Transport>) {
697 self.transports.insert(transport_type, Arc::from(transport));
698 }
699
700 pub fn send(&mut self, message: &Message) -> Result<()> {
722 let stamped = self.stamp_negotiated_wire_codec(message);
742 let message = stamped.as_ref().unwrap_or(message);
743
744 let available = self.get_available_transports();
745 if available.is_empty() {
746 return Err(Error::Transport(TransportError::TransportNotAvailable(
747 "No available transport".to_string(),
748 )));
749 }
750
751 let now_ms = Utc::now().timestamp_millis();
755
756 let previous = self.current_transport;
757
758 let primary = self
760 .selector
761 .select_transport(message, &available)
762 .ok_or_else(|| {
763 Error::Transport(TransportError::TransportNotAvailable(
764 "No suitable transport selected from available transports".to_string(),
765 ))
766 })?;
767
768 let detailed_scores: Option<Vec<(TransportType, TransportScore)>> =
773 if self.routing_diagnostic && self.routing_decision_callback.is_some() {
774 Some(self.selector.score_and_rank_detailed(message, &available))
775 } else {
776 None
777 };
778 let detailed_slice = detailed_scores.as_deref();
779 let scored: Vec<(TransportType, f32)> = match detailed_slice {
780 Some(d) => d.iter().map(|(t, s)| (*t, s.total)).collect(),
781 None => self.selector.score_and_rank(message, &available),
782 };
783 let scores: Vec<(String, f32)> = scored
787 .iter()
788 .map(|(t, s)| (t.to_string(), display_routing_score(*s)))
789 .collect();
790 self.emit_dors_event(Event::dors_score_updated(scores));
791
792 self.emit_routing_decision(|| {
793 self.build_routing_decision(
794 now_ms,
795 RoutingPhase::ScoreUpdated,
796 previous,
797 None,
798 None,
799 None,
800 detailed_slice,
801 )
802 });
803 let primary_score = scored
806 .iter()
807 .find(|(t, _)| *t == primary)
808 .map(|(_, s)| display_routing_score(*s))
809 .unwrap_or(0.0);
810 let selection_reason = if previous.is_none() {
811 DorsReasonCode::InitialSelection
812 } else {
813 DorsReasonCode::PrimarySelected
814 };
815 self.emit_dors_event(Event::dors_transport_selected(
816 previous.map(|t| t.to_string()),
817 primary.to_string(),
818 selection_reason,
819 primary_score,
820 ));
821 self.emit_routing_decision(|| {
822 self.build_routing_decision(
823 now_ms,
824 RoutingPhase::Selected,
825 previous,
826 Some(primary),
827 Some(primary_score),
828 Some(Self::dors_reason_code_to_routing(selection_reason)),
829 detailed_slice,
830 )
831 });
832
833 let mut primary_refusal = None;
835 let primary_result = {
836 let transport = self
837 .transports
838 .get(&primary)
839 .ok_or_else(|| Error::Other(format!("Transport {:?} not found", primary)))?;
840 transport.send(message)
841 };
842
843 match primary_result {
844 Ok(()) => {
845 self.current_transport = Some(primary);
846 if previous != Some(primary) {
847 let reason_code = if previous.is_some_and(|p| !available.contains_key(&p)) {
848 DorsReasonCode::CurrentUnavailable
849 } else {
850 DorsReasonCode::PrimarySuccess
851 };
852 self.emit_dors_event(Event::dors_transport_switched(
853 previous.map(|t| t.to_string()),
854 primary.to_string(),
855 reason_code,
856 None,
857 ));
858 self.emit_routing_decision(|| {
859 self.build_routing_decision(
860 now_ms,
861 RoutingPhase::Switched,
862 previous,
863 Some(primary),
864 Some(primary_score),
865 Some(Self::dors_reason_code_to_routing(reason_code)),
866 detailed_slice,
867 )
868 });
869 }
870 return Ok(());
871 }
872 Err(e) => {
873 let is_peer_routing = matches!(e, TransportError::PeerNotReachable(_));
874 if is_peer_routing {
875 debug!(
876 transport = ?primary,
877 error = %e,
878 "Peer not reachable via primary transport, trying fallback"
879 );
880 primary_refusal = Some(e);
887 } else {
888 warn!(
889 transport = ?primary,
890 error = %e,
891 "Primary transport send failed, trying fallback"
892 );
893 self.selector.record_retry_failure(primary);
894 }
895 }
896 }
897
898 let mut last_error = primary_refusal;
902 let mut attempted: Vec<TransportType> = vec![primary];
903
904 for (transport_type, _score) in &scored {
905 if *transport_type == primary {
906 continue;
907 }
908
909 if primary == TransportType::BLE && *transport_type == TransportType::WiFiDirect {
911 if let Some(trigger_reason) = self.selector.escalation_trigger_reason() {
912 let reason_code = Self::escalation_trigger_reason_to_code(trigger_reason);
913 self.emit_escalation_trigger_if_deduped(
914 now_ms,
915 reason_code,
916 primary,
917 *transport_type,
918 None,
919 detailed_slice,
920 );
921 }
922 }
923
924 let transport = match self.transports.get(transport_type) {
925 Some(t) => t,
926 None => continue,
927 };
928
929 attempted.push(*transport_type);
930
931 match transport.send(message) {
932 Ok(()) => {
933 self.current_transport = Some(*transport_type);
934 self.selector.set_current_transport(*transport_type);
935 if previous != Some(*transport_type) {
937 let reason_code = if primary == TransportType::BLE
938 && *transport_type == TransportType::WiFiDirect
939 {
940 DorsReasonCode::EscalationApplied
941 } else {
942 DorsReasonCode::FallbackSuccess
943 };
944 self.emit_dors_event(Event::dors_transport_switched(
945 previous.map(|t| t.to_string()),
946 transport_type.to_string(),
947 reason_code,
948 Some("primary send failed, fallback succeeded".to_string()),
949 ));
950 let fallback = *transport_type;
951 self.emit_routing_decision(|| {
952 self.build_routing_decision(
953 now_ms,
954 RoutingPhase::Switched,
955 previous,
956 Some(fallback),
957 None,
958 Some(Self::dors_reason_code_to_routing(reason_code)),
959 detailed_slice,
960 )
961 });
962 }
963 if primary == TransportType::BLE && *transport_type == TransportType::WiFiDirect
965 {
966 self.emit_dors_event(Event::dors_escalation_triggered(
967 DorsEscalationPhase::Applied,
968 primary.to_string(),
969 transport_type.to_string(),
970 DorsEscalationReasonCode::FallbackSuccess,
971 Some("primary BLE send failed, fallback to WiFi succeeded".to_string()),
972 ));
973 let escalated = *transport_type;
974 self.emit_routing_decision(|| {
975 self.build_routing_decision(
976 now_ms,
977 RoutingPhase::Escalated,
978 Some(primary),
979 Some(escalated),
980 None,
981 Some(Self::escalation_reason_code_to_routing(
982 DorsEscalationReasonCode::FallbackSuccess,
983 )),
984 detailed_slice,
985 )
986 });
987 }
988 return Ok(());
989 }
990 Err(e) => {
991 if !matches!(e, TransportError::PeerNotReachable(_)) {
992 warn!(
993 transport = ?transport_type,
994 error = %e,
995 "Fallback transport send failed, trying next"
996 );
997 self.selector.record_retry_failure(*transport_type);
998 }
999 last_error = Some(e);
1000 }
1001 }
1002 }
1003
1004 let terminal_error = last_error.unwrap_or_else(|| {
1005 TransportError::SendFailed(format!("All transports failed (tried {:?})", attempted))
1006 });
1007 Err(Error::Transport(terminal_error))
1008 }
1009
1010 pub fn send_via_transport(
1021 &mut self,
1022 message: &Message,
1023 transport_type: TransportType,
1024 ) -> Result<()> {
1025 let transport = self
1026 .transports
1027 .get(&transport_type)
1028 .ok_or_else(|| Error::Other(format!("Transport {:?} not found", transport_type)))?;
1029
1030 if transport.status() != TransportStatus::Available {
1031 return Err(Error::Other(format!(
1032 "Transport {:?} is not available",
1033 transport_type
1034 )));
1035 }
1036
1037 let stamped = self.stamp_negotiated_wire_codec(message);
1048 let message = stamped.as_ref().unwrap_or(message);
1049 transport.send(message).map_err(Error::Transport)?;
1050
1051 self.current_transport = Some(transport_type);
1053
1054 Ok(())
1055 }
1056
1057 pub fn mesh_neighbors(&self) -> Vec<MeshNeighbor> {
1071 let mut neighbors: Vec<MeshNeighbor> = Vec::new();
1072
1073 for transport_type in MESH_TRANSPORTS {
1074 let Some(transport) = self.transports.get(transport_type) else {
1075 continue;
1076 };
1077 for link in transport.connected_peers() {
1078 if neighbors.iter().any(|n| n.peer_id == link.peer_id) {
1079 continue;
1080 }
1081 neighbors.push(MeshNeighbor {
1082 peer_id: link.peer_id,
1083 rssi: link.rssi,
1084 transport: *transport_type,
1085 });
1086 }
1087 }
1088
1089 neighbors
1090 }
1091
1092 fn stamp_negotiated_wire_codec(&self, message: &Message) -> Option<Message> {
1104 if message.wire_codec() == WireCodec::BinaryV1
1105 || !self.peer_binary_wire.contains(message.recipient.as_str())
1106 {
1107 return None;
1108 }
1109 let mut stamped = message.clone();
1110 stamped.set_wire_codec(WireCodec::BinaryV1);
1111 Some(stamped)
1112 }
1113
1114 pub fn mesh_transport_for(&self, peer_id: &str) -> Option<TransportType> {
1121 MESH_TRANSPORTS.iter().copied().find(|transport_type| {
1122 self.transports
1123 .get(transport_type)
1124 .filter(|transport| transport.status() == TransportStatus::Available)
1125 .is_some_and(|transport| {
1126 transport
1127 .connected_peers()
1128 .iter()
1129 .any(|link| link.peer_id == peer_id)
1130 })
1131 })
1132 }
1133
1134 pub fn holds_mesh_link(&self, transport_type: TransportType, peer_id: &str) -> bool {
1142 MESH_TRANSPORTS.contains(&transport_type)
1143 && self
1144 .transports
1145 .get(&transport_type)
1146 .filter(|transport| transport.status() == TransportStatus::Available)
1147 .is_some_and(|transport| {
1148 transport
1149 .connected_peers()
1150 .iter()
1151 .any(|link| link.peer_id == peer_id)
1152 })
1153 }
1154
1155 pub fn available_infrastructure_carriers(&self) -> Vec<TransportType> {
1161 self.transports
1162 .iter()
1163 .filter(|(transport_type, transport)| {
1164 !MESH_TRANSPORTS.contains(transport_type)
1165 && transport.status() == TransportStatus::Available
1166 })
1167 .map(|(transport_type, _)| *transport_type)
1168 .collect()
1169 }
1170
1171 pub fn can_reach_without_carrying(&self, peer_id: &str) -> bool {
1205 self.has_infrastructure_carrier() || self.mesh_can_address(peer_id)
1206 }
1207
1208 pub fn has_infrastructure_carrier(&self) -> bool {
1216 self.transports.iter().any(|(transport_type, transport)| {
1217 !MESH_TRANSPORTS.contains(transport_type)
1218 && transport.status() == TransportStatus::Available
1219 })
1220 }
1221
1222 pub fn queued_message_ids(&self) -> std::collections::HashSet<String> {
1225 self.transports
1226 .values()
1227 .flat_map(|transport| transport.queued_message_ids())
1228 .collect()
1229 }
1230
1231 pub fn mesh_can_address(&self, peer_id: &str) -> bool {
1237 self.mesh_transport_for(peer_id).is_some()
1238 }
1239
1240 pub fn is_mesh_transport(transport_type: TransportType) -> bool {
1247 MESH_TRANSPORTS.contains(&transport_type)
1248 }
1249
1250 pub fn can_address_via(&self, transport_type: TransportType, peer_id: &str) -> bool {
1265 let Some(transport) = self.transports.get(&transport_type) else {
1266 return false;
1267 };
1268 if transport.status() != TransportStatus::Available {
1269 return false;
1270 }
1271 if !MESH_TRANSPORTS.contains(&transport_type) {
1272 return true;
1273 }
1274 transport
1275 .connected_peers()
1276 .iter()
1277 .any(|link| link.peer_id == peer_id)
1278 }
1279
1280 pub fn send_to_neighbor(&self, peer_id: &str, message: &Message) -> Result<TransportType> {
1292 let stamped;
1293 let message = if message.wire_codec() != WireCodec::BinaryV1
1294 && self.peer_binary_wire.contains(peer_id)
1295 {
1296 let mut m = message.clone();
1297 m.set_wire_codec(WireCodec::BinaryV1);
1298 stamped = m;
1299 &stamped
1300 } else if message.wire_codec() == WireCodec::BinaryV1
1301 && !self.peer_binary_wire.contains(peer_id)
1302 {
1303 let mut m = message.clone();
1307 m.set_wire_codec(WireCodec::Json);
1308 stamped = m;
1309 &stamped
1310 } else {
1311 message
1312 };
1313
1314 let mut last_error: Option<String> = None;
1315
1316 for transport_type in MESH_TRANSPORTS {
1317 let Some(transport) = self.transports.get(transport_type) else {
1318 continue;
1319 };
1320 if transport.status() != TransportStatus::Available {
1321 continue;
1322 }
1323 match transport.send_to_peer(peer_id, message) {
1324 Ok(()) => return Ok(*transport_type),
1325 Err(e) => last_error = Some(e.to_string()),
1326 }
1327 }
1328
1329 Err(Error::Transport(TransportError::PeerNotReachable(format!(
1330 "no mesh transport holds a link to {}{}",
1331 peer_id,
1332 last_error.map(|e| format!(" ({})", e)).unwrap_or_default()
1333 ))))
1334 }
1335
1336 pub fn receive(&self) -> Result<Option<(TransportType, Message)>> {
1342 for (transport_type, transport) in &self.transports {
1344 match transport.receive() {
1345 Ok(Some(message)) => {
1346 debug!(
1347 transport = ?transport_type,
1348 sender = %message.sender,
1349 recipient = %message.recipient,
1350 "TransportManager found message"
1351 );
1352 return Ok(Some((*transport_type, message)));
1353 }
1354 Ok(None) => {
1355 }
1357 Err(e) => {
1358 warn!(
1359 transport = ?transport_type,
1360 error = %e,
1361 "Transport receive error"
1362 );
1363 }
1364 }
1365 }
1366 Ok(None)
1367 }
1368
1369 pub fn get_available_transports(&self) -> HashMap<TransportType, TransportMetrics> {
1375 let mut available = HashMap::new();
1376
1377 for (transport_type, transport) in &self.transports {
1378 let maybe_metrics = {
1379 let status = transport.status();
1380 if status == TransportStatus::Available {
1381 Some(transport.metrics())
1382 } else {
1383 debug!(
1384 transport = ?transport_type,
1385 status = ?status,
1386 "Transport excluded from available set"
1387 );
1388 None
1389 }
1390 };
1391
1392 if let Some(mut metrics) = maybe_metrics {
1393 if let Some(stats) = self.observations.get(transport_type) {
1394 stats.apply_to_metrics(*transport_type, &mut metrics);
1395 }
1396 self.apply_device_battery(&mut metrics);
1397 available.insert(*transport_type, metrics);
1398 }
1399 }
1400
1401 available
1402 }
1403
1404 pub fn current_transport(&self) -> Option<TransportType> {
1406 self.current_transport
1407 }
1408
1409 pub fn get_all_transport_statuses(&self) -> HashMap<TransportType, TransportStatus> {
1416 let mut out = HashMap::with_capacity(self.transports.len());
1417 for (transport_type, transport) in &self.transports {
1418 out.insert(*transport_type, transport.status());
1419 }
1420 out
1421 }
1422
1423 pub fn snapshot_status_and_available(
1431 &self,
1432 ) -> (
1433 HashMap<TransportType, TransportStatus>,
1434 HashMap<TransportType, TransportMetrics>,
1435 ) {
1436 let mut statuses = HashMap::with_capacity(self.transports.len());
1437 let mut available = HashMap::new();
1438
1439 for (transport_type, transport) in &self.transports {
1440 let status = transport.status();
1441 let metrics = if status == TransportStatus::Available {
1442 Some(transport.metrics())
1443 } else {
1444 None
1445 };
1446 statuses.insert(*transport_type, status);
1447 if let Some(mut metrics) = metrics {
1448 if let Some(stats) = self.observations.get(transport_type) {
1449 stats.apply_to_metrics(*transport_type, &mut metrics);
1450 }
1451 self.apply_device_battery(&mut metrics);
1452 available.insert(*transport_type, metrics);
1453 }
1454 }
1455
1456 (statuses, available)
1457 }
1458
1459 pub fn start(&mut self) -> Result<()> {
1461 for (transport_type, transport) in &self.transports {
1462 transport.start().map_err(|e| {
1463 Error::Other(format!(
1464 "Failed to start transport {:?}: {}",
1465 transport_type, e
1466 ))
1467 })?;
1468 }
1469 Ok(())
1470 }
1471
1472 pub fn stop(&mut self) -> Result<()> {
1474 for (transport_type, transport) in &self.transports {
1475 transport.stop().map_err(|e| {
1476 Error::Other(format!(
1477 "Failed to stop transport {:?}: {}",
1478 transport_type, e
1479 ))
1480 })?;
1481 }
1482 Ok(())
1483 }
1484
1485 pub fn get_transport(&self, transport_type: TransportType) -> Option<Arc<dyn Transport>> {
1487 self.transports.get(&transport_type).cloned()
1488 }
1489
1490 pub fn remove_transport(&mut self, transport_type: TransportType) {
1496 self.transports.remove(&transport_type);
1497
1498 if self.current_transport == Some(transport_type) {
1500 self.current_transport = None;
1501 }
1502
1503 self.observations.remove(&transport_type);
1504 }
1505
1506 pub fn get_active_transports(&self) -> Vec<TransportType> {
1512 self.transports.keys().copied().collect()
1513 }
1514
1515 pub fn should_escalate_to_wifi(&self) -> bool {
1517 self.selector.should_escalate_to_wifi()
1518 }
1519
1520 pub fn record_retry_failure(&mut self, transport_type: TransportType) {
1522 self.selector.record_retry_failure(transport_type);
1523 }
1524
1525 pub fn reset_retry_count(&mut self, transport_type: TransportType) {
1527 self.selector.reset_retry_count(transport_type);
1528 }
1529
1530 pub fn record_delivery_success(
1532 &mut self,
1533 transport_type: TransportType,
1534 latency_ms: u32,
1535 hop_count: u8,
1536 ) {
1537 let stats = self.observations.entry(transport_type).or_default();
1538 stats.record_success(latency_ms, hop_count);
1539 }
1540
1541 pub fn record_delivery_failure(&mut self, transport_type: TransportType) {
1543 let stats = self.observations.entry(transport_type).or_default();
1544 stats.record_failure();
1545 }
1546
1547 #[cfg(test)]
1548 pub(crate) fn observed_failure_count(&self, transport_type: TransportType) -> u32 {
1549 self.observations
1550 .get(&transport_type)
1551 .map_or(0, |stats| stats.failure_count)
1552 }
1553
1554 pub fn update_selector_config(&mut self, config: DorsConfig) {
1557 self.selector.update_config(config);
1558 }
1559
1560 pub fn selector_config(&self) -> &DorsConfig {
1562 self.selector.config()
1563 }
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568 use super::*;
1569 use offline_protocol_core::{AppId, UserId};
1570 use offline_protocol_router::DorsConfig;
1571 use offline_protocol_transport::{mock::MockTransport, TransportType};
1572 use std::sync::Mutex;
1573
1574 fn create_test_message() -> Message {
1575 Message::new(
1576 UserId::new("alice").unwrap(),
1577 UserId::new("bob").unwrap(),
1578 AppId::new("test").unwrap(),
1579 "Test message",
1580 )
1581 }
1582
1583 #[test]
1584 fn test_transport_manager_creation() {
1585 let selector = TransportSelector::with_config(DorsConfig::default());
1586 let manager = TransportManager::new(selector);
1587 assert_eq!(manager.transports.len(), 0);
1588 assert!(manager.current_transport().is_none());
1589 }
1590
1591 #[test]
1592 fn test_add_transport() {
1593 let selector = TransportSelector::with_config(DorsConfig::default());
1594 let mut manager = TransportManager::new(selector);
1595
1596 let transport = Box::new(MockTransport::new(TransportType::BLE));
1597 manager.add_transport(TransportType::BLE, transport);
1598
1599 assert_eq!(manager.transports.len(), 1);
1600 }
1601
1602 #[test]
1603 fn test_start_stop_through_shared_transport_handle() {
1604 let selector = TransportSelector::with_config(DorsConfig::default());
1605 let mut manager = TransportManager::new(selector);
1606 manager.add_transport(
1607 TransportType::BLE,
1608 Box::new(MockTransport::new(TransportType::BLE)),
1609 );
1610
1611 let handle = manager
1614 .get_transport(TransportType::BLE)
1615 .expect("transport registered");
1616 assert_eq!(handle.status(), TransportStatus::Unavailable);
1617
1618 manager.start().unwrap();
1619 assert_eq!(handle.status(), TransportStatus::Available);
1620
1621 manager.stop().unwrap();
1622 assert_eq!(handle.status(), TransportStatus::Disconnected);
1623 }
1624
1625 #[test]
1626 fn test_send_message() {
1627 let selector = TransportSelector::with_config(DorsConfig::default());
1628 let mut manager = TransportManager::new(selector);
1629
1630 let transport = MockTransport::new(TransportType::BLE);
1631 transport.start().unwrap();
1632 manager.add_transport(TransportType::BLE, Box::new(transport));
1633
1634 let message = create_test_message();
1635 let result = manager.send(&message);
1636 assert!(result.is_ok());
1637 }
1638
1639 #[test]
1640 fn send_stamps_binary_only_for_capable_peer() {
1641 let selector = TransportSelector::with_config(DorsConfig::default());
1642 let mut manager = TransportManager::new(selector);
1643 let transport = MockTransport::new(TransportType::BLE);
1644 transport.start().unwrap();
1645 manager.add_transport(TransportType::BLE, Box::new(transport));
1646
1647 manager.send(&create_test_message()).unwrap();
1649 manager.mark_peer_binary_wire("bob", true);
1651 manager.send(&create_test_message()).unwrap();
1652
1653 let mock = manager
1654 .transports
1655 .get(&TransportType::BLE)
1656 .unwrap()
1657 .as_any()
1658 .downcast_ref::<MockTransport>()
1659 .unwrap();
1660 let sent = mock.sent_messages();
1661 assert_eq!(sent.len(), 2);
1662 assert_eq!(sent[0].wire_codec(), WireCodec::Json);
1663 assert_eq!(sent[1].wire_codec(), WireCodec::BinaryV1);
1664 }
1665
1666 #[test]
1667 fn send_reverts_to_json_after_peer_downgrades() {
1668 let selector = TransportSelector::with_config(DorsConfig::default());
1669 let mut manager = TransportManager::new(selector);
1670 let transport = MockTransport::new(TransportType::BLE);
1671 transport.start().unwrap();
1672 manager.add_transport(TransportType::BLE, Box::new(transport));
1673
1674 manager.mark_peer_binary_wire("bob", true);
1676 assert!(manager.peer_supports_binary_wire("bob"));
1677 manager.send(&create_test_message()).unwrap();
1678
1679 manager.mark_peer_binary_wire("bob", false);
1684 assert!(!manager.peer_supports_binary_wire("bob"));
1685 manager.send(&create_test_message()).unwrap();
1686
1687 let mock = manager
1688 .transports
1689 .get(&TransportType::BLE)
1690 .unwrap()
1691 .as_any()
1692 .downcast_ref::<MockTransport>()
1693 .unwrap();
1694 let sent = mock.sent_messages();
1695 assert_eq!(sent.len(), 2);
1696 assert_eq!(sent[0].wire_codec(), WireCodec::BinaryV1);
1697 assert_eq!(sent[1].wire_codec(), WireCodec::Json);
1698 }
1699
1700 #[test]
1701 fn test_dors_events_emitted_when_callback_set() {
1702 let selector = TransportSelector::with_config(DorsConfig::default());
1703 let mut manager = TransportManager::new(selector);
1704
1705 let transport = MockTransport::new(TransportType::BLE);
1706 transport.start().unwrap();
1707 manager.add_transport(TransportType::BLE, Box::new(transport));
1708
1709 let events: std::sync::Arc<Mutex<Vec<Event>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1710 let events_clone = events.clone();
1711 manager.set_dors_event_callback(Some(Arc::new(move |e| {
1712 events_clone.lock().unwrap().push(e);
1713 })));
1714
1715 let message = create_test_message();
1716 let _ = manager.send(&message);
1717
1718 let captured = events.lock().unwrap();
1719 assert!(
1720 captured
1721 .iter()
1722 .any(|e| matches!(e, crate::events::Event::DorsScoreUpdated { .. })),
1723 "expected dors_score_updated"
1724 );
1725 assert!(
1726 captured
1727 .iter()
1728 .any(|e| { matches!(e, crate::events::Event::DorsTransportSelected { .. }) }),
1729 "expected dors_transport_selected"
1730 );
1731 }
1732
1733 #[test]
1734 fn test_receive_message() {
1735 let selector = TransportSelector::with_config(DorsConfig::default());
1736 let mut manager = TransportManager::new(selector);
1737
1738 let transport = MockTransport::new(TransportType::BLE);
1739 transport.start().unwrap();
1740 let message = create_test_message();
1741 transport.queue_message(message.clone());
1742
1743 manager.add_transport(TransportType::BLE, Box::new(transport));
1744
1745 let received = manager.receive().unwrap();
1746 assert!(matches!(received, Some((TransportType::BLE, _))));
1747 }
1748
1749 #[test]
1750 fn test_record_delivery_success_enriches_metrics() {
1751 let selector = TransportSelector::with_config(DorsConfig::default());
1752 let mut manager = TransportManager::new(selector);
1753
1754 let transport = MockTransport::new(TransportType::BLE);
1755 transport.start().unwrap();
1756 manager.add_transport(TransportType::BLE, Box::new(transport));
1757
1758 manager.record_delivery_success(TransportType::BLE, 150, 2);
1759
1760 let metrics = manager.get_available_transports();
1761 let ble_metrics = metrics.get(&TransportType::BLE).expect("metrics");
1762
1763 assert_eq!(ble_metrics.success_count, 1);
1764 assert_eq!(ble_metrics.failure_count, 0);
1765 assert!(ble_metrics.delivery_ratio.expect("delivery ratio") > 0.99);
1766 assert_eq!(ble_metrics.average_hop_count, Some(2.0));
1767 assert_eq!(ble_metrics.latency_ms, Some(150));
1768 }
1769
1770 #[test]
1771 fn test_record_delivery_failure_enriches_metrics() {
1772 let selector = TransportSelector::with_config(DorsConfig::default());
1773 let mut manager = TransportManager::new(selector);
1774
1775 let transport = MockTransport::new(TransportType::BLE);
1776 transport.start().unwrap();
1777 manager.add_transport(TransportType::BLE, Box::new(transport));
1778
1779 manager.record_delivery_failure(TransportType::BLE);
1780 let metrics = manager.get_available_transports();
1781 let ble_metrics = metrics.get(&TransportType::BLE).expect("metrics");
1782
1783 assert_eq!(ble_metrics.success_count, 0);
1784 assert_eq!(ble_metrics.failure_count, 1);
1785 assert!(ble_metrics.drop_rate.expect("drop ratio") > 0.99);
1786 }
1787
1788 #[test]
1792 fn engine_observations_win_over_a_mesh_transports_own_counts() {
1793 let selector = TransportSelector::with_config(DorsConfig::default());
1794 let mut manager = TransportManager::new(selector);
1795
1796 let transport = MockTransport::new(TransportType::BLE);
1797 transport.start().unwrap();
1798 transport.set_metrics(TransportMetrics {
1799 failure_count: 1,
1800 delivery_ratio: Some(0.0),
1801 drop_rate: Some(1.0),
1802 ..Default::default()
1803 });
1804 manager.add_transport(TransportType::BLE, Box::new(transport));
1805
1806 manager.record_delivery_success(TransportType::BLE, 150, 1);
1807
1808 let metrics = manager.get_available_transports();
1809 let ble = metrics.get(&TransportType::BLE).expect("metrics");
1810 assert_eq!((ble.success_count, ble.failure_count), (1, 0));
1811 assert_eq!(ble.delivery_ratio, Some(1.0));
1812 assert_eq!(ble.drop_rate, Some(0.0));
1813 }
1814
1815 #[test]
1819 fn a_confirmation_loop_transport_keeps_its_own_counts() {
1820 let selector = TransportSelector::with_config(DorsConfig::default());
1821 let mut manager = TransportManager::new(selector);
1822
1823 let transport = MockTransport::new(TransportType::Internet);
1824 transport.start().unwrap();
1825 transport.set_metrics(TransportMetrics {
1826 success_count: 5,
1827 failure_count: 1,
1828 delivery_ratio: Some(5.0 / 6.0),
1829 drop_rate: Some(1.0 / 6.0),
1830 ..Default::default()
1831 });
1832 manager.add_transport(TransportType::Internet, Box::new(transport));
1833
1834 manager.record_delivery_failure(TransportType::Internet);
1835
1836 let metrics = manager.get_available_transports();
1837 let internet = metrics.get(&TransportType::Internet).expect("metrics");
1838 assert_eq!((internet.success_count, internet.failure_count), (5, 1));
1839 assert_eq!(internet.delivery_ratio, Some(5.0 / 6.0));
1840 }
1841
1842 #[test]
1847 fn a_dropped_inbound_fragment_does_not_escalate_ble() {
1848 use offline_protocol_transport::constants::BLE_MAX_FRAGMENT_ASSEMBLIES;
1849 use offline_protocol_transport::{BleTransport, PeerDevice};
1850
1851 let selector = TransportSelector::with_config(DorsConfig::default());
1852 let mut manager = TransportManager::new(selector);
1853
1854 let ble = BleTransport::new("alice-device");
1855 ble.start().unwrap();
1856 ble.on_peer_discovered(PeerDevice {
1857 device_id: "bob".to_string(),
1858 address: "AA:BB:CC:DD:EE:FF".to_string(),
1859 rssi: -60,
1860 last_seen: offline_protocol_core::time::SystemTime::now(),
1861 connected: true,
1862 });
1863 manager.add_transport(TransportType::BLE, Box::new(ble));
1864 let wifi = MockTransport::new(TransportType::WiFiDirect);
1865 wifi.start().unwrap();
1866 manager.add_transport(TransportType::WiFiDirect, Box::new(wifi));
1867
1868 let ble = manager.get_transport(TransportType::BLE).expect("ble");
1871 let ble_ref = ble.as_any().downcast_ref::<BleTransport>().expect("ble");
1872 for _ in 0..=BLE_MAX_FRAGMENT_ASSEMBLIES {
1873 let large = Message::new(
1874 UserId::new("bob").unwrap(),
1875 UserId::new("alice").unwrap(),
1876 AppId::new("test").unwrap(),
1877 "x".repeat(2_000),
1878 );
1879 let fragments = ble_ref.fragment_message(&large).expect("fragments");
1880 assert!(fragments.len() > 1, "the message must span fragments");
1881 ble.on_fragment_received_from(fragments[0].clone(), "bob".to_string())
1882 .expect("fragment accepted");
1883 }
1884 drop(ble);
1885
1886 for _ in 0..8 {
1888 manager.send(&create_test_message()).expect("send");
1889 }
1890
1891 assert!(
1892 !manager.should_escalate_to_wifi(),
1893 "a receive-side reassembly drop must not read as a failing BLE link"
1894 );
1895 let metrics = manager.get_available_transports();
1896 assert_ne!(
1897 metrics[&TransportType::BLE].delivery_ratio,
1898 Some(0.0),
1899 "BLE must not report a 0% delivery ratio with no failed delivery"
1900 );
1901
1902 manager.record_delivery_success(TransportType::BLE, 120, 1);
1903 let metrics = manager.get_available_transports();
1904 assert_eq!(metrics[&TransportType::BLE].delivery_ratio, Some(1.0));
1905 }
1906
1907 #[test]
1911 fn a_sole_carrier_refusing_the_recipient_ends_as_peer_not_reachable() {
1912 let selector = TransportSelector::with_config(DorsConfig::default());
1913 let mut manager = TransportManager::new(selector);
1914
1915 let transport = MockTransport::new(TransportType::BLE);
1916 transport.start().unwrap();
1917 transport.set_reject_unknown_recipients(true);
1918 manager.add_transport(TransportType::BLE, Box::new(transport));
1919
1920 let err = manager.send(&create_test_message()).unwrap_err();
1921 assert!(
1922 matches!(err, Error::Transport(TransportError::PeerNotReachable(_))),
1923 "got {err:?}"
1924 );
1925 }
1926
1927 #[test]
1933 fn test_device_battery_fills_transports_that_report_none() {
1934 let selector = TransportSelector::with_config(DorsConfig::default());
1935 let mut manager = TransportManager::new(selector);
1936
1937 for transport_type in [TransportType::BLE, TransportType::WiFiDirect] {
1938 let transport = MockTransport::new(transport_type);
1939 transport.start().unwrap();
1940 manager.add_transport(transport_type, Box::new(transport));
1941 }
1942
1943 assert_eq!(manager.device_battery(), (None, false));
1944 manager.set_device_battery(42, true);
1945 assert_eq!(manager.device_battery(), (Some(42), true));
1946
1947 let metrics = manager.get_available_transports();
1948 for transport_type in [TransportType::BLE, TransportType::WiFiDirect] {
1949 let m = metrics.get(&transport_type).expect("metrics");
1950 assert_eq!(m.battery_level, Some(42));
1951 assert!(m.is_charging);
1952 }
1953
1954 let (_statuses, available) = manager.snapshot_status_and_available();
1957 let m = available.get(&TransportType::BLE).expect("metrics");
1958 assert_eq!(m.battery_level, Some(42));
1959 assert!(m.is_charging);
1960 }
1961
1962 #[test]
1967 fn test_transport_reported_battery_wins_over_device_feed() {
1968 let selector = TransportSelector::with_config(DorsConfig::default());
1969 let mut manager = TransportManager::new(selector);
1970
1971 let transport = MockTransport::new(TransportType::BLE);
1972 transport.start().unwrap();
1973 transport.set_metrics(TransportMetrics {
1974 battery_level: Some(7),
1975 is_charging: false,
1976 ..Default::default()
1977 });
1978 manager.add_transport(TransportType::BLE, Box::new(transport));
1979
1980 manager.set_device_battery(90, true);
1981
1982 let metrics = manager.get_available_transports();
1983 let m = metrics.get(&TransportType::BLE).expect("metrics");
1984 assert_eq!(m.battery_level, Some(7));
1985 assert!(!m.is_charging);
1986 }
1987
1988 #[test]
1989 fn test_device_battery_is_clamped() {
1990 let selector = TransportSelector::with_config(DorsConfig::default());
1991 let mut manager = TransportManager::new(selector);
1992 manager.set_device_battery(240, false);
1993 assert_eq!(manager.device_battery(), (Some(100), false));
1994 }
1995}