Skip to main content

offline_protocol/
transport_manager.rs

1//! Transport management for multi-transport protocol.
2//!
3//! This module provides the TransportManager which manages multiple transports
4//! and uses DORS (Dynamic Offline Relay Switch) to select the optimal transport
5//! for each message.
6
7use 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::{Message, Username, WireCodec};
13use offline_protocol_router::{
14    display_routing_score, DorsConfig, EscalationTriggerReason, TransportScore, TransportSelector,
15};
16use offline_protocol_transport::{
17    nostr::ResolveRequest, Error as TransportError, Transport, TransportMetrics, TransportStatus,
18    TransportType,
19};
20use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22use std::time::Instant;
23use tracing::{debug, warn};
24
25/// Manages multiple transports and handles transport selection.
26pub struct TransportManager {
27    /// Available transports mapped by type.
28    ///
29    /// Transports are fully interior-mutable (every `Transport` method takes
30    /// `&self`), so they are shared as plain `Arc`s — no outer mutex
31    /// serializing sends against status/metrics reads.
32    transports: HashMap<TransportType, Arc<dyn Transport>>,
33
34    /// Current active transport.
35    current_transport: Option<TransportType>,
36
37    /// Transport selector (DORS).
38    selector: TransportSelector,
39
40    /// Locally observed delivery outcomes used to enrich transport metrics.
41    observations: HashMap<TransportType, ObservedStats>,
42
43    /// Optional callback for DORS lifecycle/decision events (OFF-258).
44    dors_event_callback: Option<Arc<dyn Fn(Event) + Send + Sync>>,
45
46    /// Optional callback for structured routing decisions. Fires alongside
47    /// every legacy `dors_event_callback` emission so the telemetry sink
48    /// receives the richer `RoutingDecision` shape while `EventCallback`
49    /// consumers continue to see the flattened `Event::Dors*` variants.
50    routing_decision_callback: Option<Arc<dyn Fn(RoutingDecision) + Send + Sync>>,
51
52    /// When `true`, every emitted [`RoutingDecision`] carries the full
53    /// per-transport [`TransportScore`] breakdown in its `scores` field.
54    /// Mirrors `TelemetryConfig::routing_diagnostic` and is toggled by
55    /// `OfflineProtocol::install_telemetry_sink`.
56    routing_diagnostic: bool,
57
58    /// Last escalation trigger event emitted (reason, time) for dedupe window.
59    last_escalation_trigger_emitted: Option<(DorsEscalationReasonCode, std::time::Instant)>,
60
61    /// Peers known to decode the binary wire codec (from their signed key
62    /// package). Messages addressed to a listed peer are stamped
63    /// [`WireCodec::BinaryV1`] at send; all others stay JSON.
64    peer_binary_wire: HashSet<String>,
65
66    /// Host-reported battery level for this device, if the platform publishes
67    /// one. Merged into every transport's metrics that does not report its own
68    /// — see [`TransportManager::set_device_battery`].
69    device_battery: Option<u8>,
70
71    /// Host-reported charging state, applied alongside [`Self::device_battery`].
72    device_is_charging: bool,
73}
74
75/// Carriers whose links are peer-to-peer, in the order a forwarding caller
76/// should prefer them.
77///
78/// These are the only transports that can carry a *mesh hop*: a frame handed
79/// straight to a neighbor's radio. Wi-Fi Direct leads because a link that
80/// exists at all is the higher-bandwidth one; BLE is the carrier that is
81/// almost always present. Everything else reaches peers through
82/// infrastructure, which is not a hop — it is an exit from the mesh.
83const MESH_TRANSPORTS: &[TransportType] = &[TransportType::WiFiDirect, TransportType::BLE];
84
85/// A peer reachable over a direct mesh link.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct MeshNeighbor {
88    /// User-level id of the neighbor.
89    pub peer_id: String,
90    /// Signal strength for the link, when the carrier measures one.
91    pub rssi: Option<i16>,
92    /// The carrier holding the link.
93    pub transport: TransportType,
94}
95
96impl MeshNeighbor {
97    /// How good this link is, on a 0–100 scale.
98    ///
99    /// Derived from signal strength where the carrier reports it, and a
100    /// mid-scale value where it does not — an unmeasured link should not
101    /// outrank a measured good one, nor be discarded like a measured bad one.
102    pub fn link_quality(&self) -> u8 {
103        match self.rssi {
104            Some(rssi) => offline_protocol_transport::LinkQuality::from_rssi(rssi).value(),
105            None => offline_protocol_transport::DEFAULT_LINK_QUALITY_WITHOUT_RSSI,
106        }
107    }
108}
109
110/// Dedupe window: don't re-emit same escalation trigger reason within this duration.
111const ESCALATION_TRIGGER_DEDUPE_SECS: u64 = 30;
112
113/// Cap on the per-peer binary-wire capability set. Keyed by the wire-claimed
114/// peer id, so — like `key_package_sent_to` — it is cleared at capacity to bound
115/// a forged-sender flood; the only cost of forgetting a peer is re-learning its
116/// capability from the next key package (one JSON send in the meantime).
117const MAX_PEER_BINARY_WIRE_ENTRIES: usize = 4096;
118
119/// Whether the platform reports each send's outcome back to this transport,
120/// so its own `success_count` / `failure_count` are real delivery evidence.
121/// Exhaustive on purpose: a new transport must decide which side it is on.
122fn has_send_confirmation_loop(transport_type: TransportType) -> bool {
123    match transport_type {
124        TransportType::Internet | TransportType::Reticulum | TransportType::Nostr => true,
125        TransportType::BLE | TransportType::WiFiDirect => false,
126    }
127}
128
129#[derive(Debug, Default, Clone)]
130struct ObservedStats {
131    success_count: u32,
132    failure_count: u32,
133    latency_ema: Option<f32>,
134    hop_ema: Option<f32>,
135    last_success_at: Option<Instant>,
136}
137
138impl ObservedStats {
139    fn record_success(&mut self, latency_ms: u32, hop_count: u8) {
140        self.success_count = self.success_count.saturating_add(1);
141        self.latency_ema = Some(update_ema(
142            self.latency_ema,
143            latency_ms as f32,
144            LATENCY_EMA_ALPHA,
145        ));
146        self.hop_ema = Some(update_ema(
147            self.hop_ema,
148            hop_count as f32,
149            HOP_COUNT_EMA_ALPHA,
150        ));
151        self.last_success_at = Some(Instant::now());
152        self.compact();
153    }
154
155    fn record_failure(&mut self) {
156        self.failure_count = self.failure_count.saturating_add(1);
157        self.compact();
158    }
159
160    fn compact(&mut self) {
161        let total = self.success_count.saturating_add(self.failure_count);
162        if total > OBSERVED_STATS_COMPACT_THRESHOLD {
163            self.success_count /= 2;
164            self.failure_count /= 2;
165        }
166    }
167
168    fn delivery_ratio(&self) -> Option<f32> {
169        let total = self.success_count + self.failure_count;
170        if total == 0 {
171            None
172        } else {
173            Some(self.success_count as f32 / total as f32)
174        }
175    }
176
177    fn drop_ratio(&self) -> Option<f32> {
178        self.delivery_ratio()
179            .map(|ratio| (1.0 - ratio).clamp(0.0, 1.0))
180    }
181
182    /// Merges the engine's delivery observations into `metrics`.
183    ///
184    /// A transport with a send confirmation loop (Internet, Reticulum,
185    /// Nostr: the platform reports each send's outcome back) owns its
186    /// success and failure counts, and they are kept when it has any. Every
187    /// other transport has no outcome of its own to report, so the engine's
188    /// acknowledgements are its delivery evidence and always win. The
189    /// previous rule was value-based ("keep the transport's counts when they
190    /// are non-zero"), so one stray count from BLE's reassembly path silently
191    /// discarded every later acknowledgement for that transport (#557).
192    fn apply_to_metrics(&self, transport_type: TransportType, metrics: &mut TransportMetrics) {
193        if self.success_count + self.failure_count > 0 {
194            let transport_owns_counts = has_send_confirmation_loop(transport_type)
195                && metrics.success_count + metrics.failure_count > 0;
196            if !transport_owns_counts {
197                metrics.success_count = self.success_count;
198                metrics.failure_count = self.failure_count;
199                metrics.delivery_ratio = self.delivery_ratio();
200                metrics.drop_rate = self.drop_ratio();
201            }
202        }
203
204        if let Some(latency) = self.latency_ema {
205            metrics.latency_ms = Some(latency.round().clamp(0.0, u32::MAX as f32) as u32);
206        }
207
208        if let Some(hop) = self.hop_ema {
209            metrics.average_hop_count = Some(hop);
210        }
211    }
212}
213
214fn update_ema(current: Option<f32>, new_value: f32, alpha: f32) -> f32 {
215    let alpha = alpha.clamp(0.0, 1.0);
216    match current {
217        Some(existing) => existing * (1.0 - alpha) + new_value * alpha,
218        None => new_value,
219    }
220}
221
222impl TransportManager {
223    /// Creates a new transport manager with default configuration.
224    pub fn new(selector: TransportSelector) -> Self {
225        Self {
226            transports: HashMap::new(),
227            current_transport: None,
228            selector,
229            observations: HashMap::new(),
230            dors_event_callback: None,
231            routing_decision_callback: None,
232            routing_diagnostic: false,
233            last_escalation_trigger_emitted: None,
234            peer_binary_wire: HashSet::new(),
235            device_battery: None,
236            device_is_charging: false,
237        }
238    }
239
240    /// Records the host's battery reading for this device.
241    ///
242    /// Battery is a property of the *device*, not of any one radio, but every
243    /// consumer inside the SDK reads it from the per-transport
244    /// [`TransportMetrics`] map: DORS energy scoring, the relay
245    /// promote/demote policy, the forwarding battery floor, and the telemetry
246    /// device-capability snapshot all resolve it through
247    /// `device_battery_from_available`. This setter is therefore the single
248    /// host-facing feed, and it is merged into the two snapshot loops that
249    /// build that map.
250    ///
251    /// `level` is clamped to 0-100. A transport that reports its own battery
252    /// level keeps it: the merge fills the field, never overwrites it.
253    pub fn set_device_battery(&mut self, level: u8, is_charging: bool) {
254        self.device_battery = Some(level.min(100));
255        self.device_is_charging = is_charging;
256    }
257
258    /// Returns the host-reported `(battery_level, is_charging)` pair, if the
259    /// platform has published one.
260    pub fn device_battery(&self) -> (Option<u8>, bool) {
261        (self.device_battery, self.device_is_charging)
262    }
263
264    /// Fills the device-level battery fields on one transport's metrics.
265    ///
266    /// **Fill-if-absent, never overwrite.** A transport that publishes its own
267    /// `battery_level` is reporting something the host feed cannot know better
268    /// (a peripheral's own cell, say), so its reading wins; the host value is
269    /// the fallback for the ordinary case where no transport reports one at
270    /// all. `is_charging` is only applied alongside a battery level that came
271    /// from the host, so a transport's own (level, charging) pair is never
272    /// split across two sources — the same coherence rule
273    /// `device_battery_from_available` applies when choosing between
274    /// transports.
275    fn apply_device_battery(&self, metrics: &mut TransportMetrics) {
276        let Some(level) = self.device_battery else {
277            return;
278        };
279        if metrics.battery_level.is_none() {
280            metrics.battery_level = Some(level);
281            metrics.is_charging = self.device_is_charging;
282        }
283    }
284
285    /// Returns whether a peer is recorded as able to decode the binary wire
286    /// codec (and would therefore be sent binary frames).
287    pub fn peer_supports_binary_wire(&self, peer_id: &str) -> bool {
288        self.peer_binary_wire.contains(peer_id)
289    }
290
291    /// Records whether a peer can decode our binary wire frames, learned from
292    /// its signed key package. Messages addressed to a capable peer are stamped
293    /// [`WireCodec::BinaryV1`] at send; all others stay JSON.
294    pub fn mark_peer_binary_wire(&mut self, peer_id: &str, supported: bool) {
295        if supported {
296            // Bound the wire-keyed set: clear at capacity, then insert.
297            if !self.peer_binary_wire.contains(peer_id)
298                && self.peer_binary_wire.len() >= MAX_PEER_BINARY_WIRE_ENTRIES
299            {
300                self.peer_binary_wire.clear();
301            }
302            self.peer_binary_wire.insert(peer_id.to_string());
303        } else {
304            self.peer_binary_wire.remove(peer_id);
305        }
306    }
307
308    /// Forwards a peer's advertised Nostr public key to the Nostr transport, so
309    /// gift wraps addressed to them are sealed to a key only their install
310    /// holds instead of to the publicly computable bootstrap key.
311    ///
312    /// `None` means "this peer advertises no Nostr key", which **forgets** any
313    /// key already held rather than leaving it in place. That is what keeps the
314    /// in-memory copy consistent with the durable record, whose downgrade
315    /// semantics delete on an empty advertisement: retaining it here instead
316    /// would keep sealing to a key the peer has stopped claiming, and survive
317    /// only until the next restart — a divergence with no symptom.
318    ///
319    /// A no-op when the Nostr transport is not installed (the common case —
320    /// `nostr_enabled` defaults off). The bound on the underlying map lives in
321    /// the transport.
322    pub fn mark_peer_nostr_pubkey(&mut self, peer_id: &str, pubkey_hex: Option<&str>) {
323        let Some(transport) = self.transports.get(&TransportType::Nostr) else {
324            return;
325        };
326        if let Some(nostr) = transport
327            .as_any()
328            .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
329        {
330            match pubkey_hex {
331                Some(pubkey_hex) => nostr.set_peer_nostr_pubkey(peer_id, pubkey_hex),
332                None => nostr.forget_peer_nostr_pubkey(peer_id),
333            }
334        }
335    }
336
337    /// Forgets a peer's Nostr public key, so frames to them revert to the
338    /// publicly computable bootstrap key.
339    ///
340    /// Used by the unblock clean slate, which declares everything learned about
341    /// a peer stale. Reverting is the right fallback rather than refusing to
342    /// send: the bootstrap key is derived from the peer's user id, so it works
343    /// whatever install they are on now.
344    pub fn clear_peer_nostr_pubkey(&mut self, peer_id: &str) {
345        if let Some(nostr) = self.transports.get(&TransportType::Nostr).and_then(|t| {
346            t.as_any()
347                .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
348        }) {
349            nostr.forget_peer_nostr_pubkey(peer_id);
350        }
351    }
352
353    /// This install's Nostr public key, for advertising in outgoing key
354    /// packages. `None` when the Nostr transport is not installed.
355    pub fn nostr_public_key(&self) -> Option<String> {
356        self.transports
357            .get(&TransportType::Nostr)?
358            .as_any()
359            .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
360            .map(|nostr| nostr.public_key_hex())
361    }
362
363    /// Applies the sealed-envelope kill switch to the Nostr transport.
364    pub fn set_nostr_sealing_enabled(&mut self, enabled: bool) {
365        if let Some(nostr) = self.transports.get(&TransportType::Nostr).and_then(|t| {
366            t.as_any()
367                .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
368        }) {
369            nostr.set_sealing_enabled(enabled);
370        }
371    }
372
373    /// Applies the cold-contact kill switch to the Nostr transport.
374    pub fn set_nostr_cold_contact_enabled(&mut self, enabled: bool) {
375        if let Some(nostr) = self.nostr_transport() {
376            nostr.set_cold_contact_enabled(enabled);
377        }
378    }
379
380    /// Queues a key-package record for publication at `slot_id`.
381    ///
382    /// A no-op when the Nostr transport is not installed, which is the common
383    /// case: publication is driven from the engine's tick, and that tick runs
384    /// whether or not Nostr is enabled.
385    ///
386    /// Serializes through the transport's own chokepoint, which yields JSON for
387    /// an unstamped message — and a published record must never be anything
388    /// else. The binary codec is negotiated per peer from that peer's key
389    /// package, and the reader of this record is by definition a stranger whose
390    /// key package we have not seen. Emitting binary here would make the record
391    /// unreadable by exactly the audience it exists for.
392    pub fn publish_nostr_key_package(&mut self, slot_id: &str, message: &Message) -> Result<()> {
393        let Some(nostr) = self.nostr_transport() else {
394            return Ok(());
395        };
396        let payload = nostr.serialize_message(message)?;
397        nostr.publish_key_package(slot_id, payload);
398        Ok(())
399    }
400
401    /// Drains the slot ids whose publication never reached a relay.
402    ///
403    /// Empty when Nostr is not installed, which keeps the caller's tick free of
404    /// a transport check it would otherwise need.
405    pub fn take_failed_nostr_publications(&self) -> Vec<String> {
406        self.nostr_transport()
407            .map(|nostr| nostr.take_failed_publications())
408            .unwrap_or_default()
409    }
410
411    /// Whether the Nostr transport is installed and publishing records.
412    pub fn nostr_cold_contact_active(&self) -> bool {
413        self.nostr_transport()
414            .map(|nostr| nostr.cold_contact_enabled())
415            .unwrap_or(false)
416    }
417
418    /// Enables or disables username discovery on the Nostr transport.
419    pub fn set_nostr_username_discovery_enabled(&mut self, enabled: bool) {
420        if let Some(nostr) = self.nostr_transport() {
421            nostr.set_discovery_enabled(enabled);
422        }
423    }
424
425    /// Whether the Nostr transport is installed and username discovery is on.
426    ///
427    /// Already accounts for the cold-contact coupling: the transport reports
428    /// false when either switch is off.
429    pub fn nostr_discovery_active(&self) -> bool {
430        self.nostr_transport()
431            .map(|nostr| nostr.discovery_enabled())
432            .unwrap_or(false)
433    }
434
435    /// Queues this install's username claim for publication.
436    pub fn publish_nostr_discovery_record(&mut self, username: Username, payload: Vec<u8>) {
437        if let Some(nostr) = self.nostr_transport() {
438            nostr.publish_discovery_record(username, payload);
439        }
440    }
441
442    /// Queues a retraction of this install's username claim.
443    pub fn retract_nostr_discovery_record(&mut self, username: Username, payload: Vec<u8>) {
444        if let Some(nostr) = self.nostr_transport() {
445            nostr.retract_discovery_record(username, payload);
446        }
447    }
448
449    /// Drains the discovery tags whose publication never reached a relay.
450    pub fn take_failed_nostr_discovery_publications(&self) -> Vec<String> {
451        self.nostr_transport()
452            .map(|nostr| nostr.take_failed_discovery_publications())
453            .unwrap_or_default()
454    }
455
456    /// Drains the discovery tags whose publication a relay acknowledged.
457    pub fn take_confirmed_nostr_discovery_publications(&self) -> Vec<String> {
458        self.nostr_transport()
459            .map(|nostr| nostr.take_confirmed_discovery_publications())
460            .unwrap_or_default()
461    }
462
463    /// Whether a Nostr transport is installed at all.
464    ///
465    /// Distinct from [`Self::nostr_discovery_active`]: a retraction is owed
466    /// whether or not discovery is still switched on, so the engine has to be
467    /// able to tell "the feature is off" from "there is nothing here that could
468    /// carry a tombstone". Without the second, queueing a retraction silently
469    /// does nothing while the engine records it as done.
470    pub fn has_nostr_transport(&self) -> bool {
471        self.nostr_transport().is_some()
472    }
473
474    /// Requests resolution of the devices claiming `username`.
475    ///
476    /// A missing Nostr transport reports `Disabled`, which is what it is from
477    /// the caller's side: no query was sent and no event will follow.
478    pub fn resolve_nostr_username(&self, username: Username) -> ResolveRequest {
479        self.nostr_transport()
480            .map(|nostr| nostr.resolve_username(username))
481            .unwrap_or(ResolveRequest::Disabled)
482    }
483
484    /// Withdraws a queued username lookup that was never issued to a relay.
485    ///
486    /// Returns whether one was removed. Called when the engine gives up on a
487    /// lookup, so the name does not sit in the queue making every later request
488    /// for it return "already queued" without ever answering.
489    pub fn cancel_nostr_username_resolution(&self, username: &Username) -> bool {
490        self.nostr_transport()
491            .map(|nostr| nostr.cancel_username_resolution(username))
492            .unwrap_or(false)
493    }
494
495    fn nostr_transport(&self) -> Option<&offline_protocol_transport::nostr::NostrTransport> {
496        self.transports.get(&TransportType::Nostr).and_then(|t| {
497            t.as_any()
498                .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
499        })
500    }
501
502    /// Sets the callback for DORS decision events (dors_score_updated, dors_transport_selected,
503    pub fn set_dors_event_callback(&mut self, callback: Option<Arc<dyn Fn(Event) + Send + Sync>>) {
504        self.dors_event_callback = callback;
505    }
506
507    /// Sets the callback that receives structured
508    /// [`crate::telemetry::RoutingDecision`] records.
509    ///
510    /// Fires at the same sites as `dors_event_callback`, carrying a richer
511    /// shape suitable for the unified telemetry sink.
512    ///
513    /// Crate-private: the callback is installed together with the telemetry
514    /// sink (`OfflineProtocol::enable_telemetry` installs the pipe's) and is
515    /// never wired directly, which keeps
516    /// [`crate::telemetry::TelemetryConfig`] the single source of truth for
517    /// its configuration.
518    pub(crate) fn set_routing_decision_callback(
519        &mut self,
520        callback: Option<Arc<dyn Fn(RoutingDecision) + Send + Sync>>,
521    ) {
522        self.routing_decision_callback = callback;
523    }
524
525    /// Enables or disables the routing-diagnostic detail level.
526    ///
527    /// When `true`, [`RoutingDecision::scores`] is populated with the full
528    /// seven-factor [`TransportScore`] for every ranked transport on every
529    /// emission. When `false` (default), the vector is empty and the hot
530    /// path avoids the per-emission allocation. Mirrors
531    /// `TelemetryConfig::routing_diagnostic`.
532    ///
533    /// Crate-private: see [`Self::set_routing_decision_callback`].
534    pub(crate) fn set_routing_diagnostic(&mut self, enabled: bool) {
535        self.routing_diagnostic = enabled;
536    }
537
538    fn emit_dors_event(&self, event: Event) {
539        if let Some(ref cb) = self.dors_event_callback {
540            cb(event);
541        }
542    }
543
544    /// Lazy-emit a [`RoutingDecision`]. The `build` closure only runs when a
545    /// callback is actually installed, so the no-sink path pays only an
546    /// `Option::is_some` check — not a `Utc::now()` call and struct build.
547    fn emit_routing_decision<F: FnOnce() -> RoutingDecision>(&self, build: F) {
548        if let Some(ref cb) = self.routing_decision_callback {
549            cb(build());
550        }
551    }
552
553    fn escalation_trigger_reason_to_code(r: EscalationTriggerReason) -> DorsEscalationReasonCode {
554        match r {
555            EscalationTriggerReason::RetryThreshold => DorsEscalationReasonCode::RetryThreshold,
556            EscalationTriggerReason::PoorSignal => DorsEscalationReasonCode::PoorSignal,
557            EscalationTriggerReason::Congestion => DorsEscalationReasonCode::Congestion,
558            EscalationTriggerReason::LowTtl => DorsEscalationReasonCode::LowTtl,
559            EscalationTriggerReason::LowSuccessRate => DorsEscalationReasonCode::LowSuccessRate,
560        }
561    }
562
563    fn dors_reason_code_to_routing(code: DorsReasonCode) -> RoutingReasonCode {
564        match code {
565            DorsReasonCode::InitialSelection => RoutingReasonCode::InitialSelection,
566            DorsReasonCode::PrimarySelected => RoutingReasonCode::PrimarySelected,
567            DorsReasonCode::PrimarySuccess => RoutingReasonCode::PrimarySuccess,
568            DorsReasonCode::FallbackSuccess => RoutingReasonCode::FallbackSuccess,
569            DorsReasonCode::EscalationApplied => RoutingReasonCode::EscalationApplied,
570            DorsReasonCode::CurrentUnavailable => RoutingReasonCode::CurrentUnavailable,
571        }
572    }
573
574    fn escalation_reason_code_to_routing(code: DorsEscalationReasonCode) -> RoutingReasonCode {
575        match code {
576            DorsEscalationReasonCode::FallbackSuccess => RoutingReasonCode::FallbackSuccess,
577            DorsEscalationReasonCode::RetryThreshold => RoutingReasonCode::RetryThreshold,
578            DorsEscalationReasonCode::PoorSignal => RoutingReasonCode::PoorSignal,
579            DorsEscalationReasonCode::Congestion => RoutingReasonCode::Congestion,
580            DorsEscalationReasonCode::LowTtl => RoutingReasonCode::LowTtl,
581            DorsEscalationReasonCode::LowSuccessRate => RoutingReasonCode::LowSuccessRate,
582        }
583    }
584
585    /// Builds a [`RoutingDecision`]. The `detailed_scores` slice is cloned
586    /// into `scores` when `routing_diagnostic` is enabled on the manager;
587    /// otherwise the vector stays empty (allocation-free).
588    ///
589    /// Takes `now_ms` by argument rather than calling `Utc::now()` per
590    /// build so every decision emitted from a single `send()` cycle shares
591    /// one timestamp — easier to correlate downstream and saves three to
592    /// four syscalls on the send hot path.
593    #[allow(clippy::too_many_arguments)]
594    fn build_routing_decision(
595        &self,
596        now_ms: i64,
597        phase: RoutingPhase,
598        from: Option<TransportType>,
599        to: Option<TransportType>,
600        winning_score: Option<f32>,
601        reason_code: Option<RoutingReasonCode>,
602        detailed_scores: Option<&[(TransportType, TransportScore)]>,
603    ) -> RoutingDecision {
604        // Sanitize the internal fallback-demotion sentinel out of every score
605        // that leaves the engine. DORS keeps Internet's `total` negative to rank
606        // it last; telemetry/UniFFI consumers must see the real 0–125 quality
607        // score instead. This is the single chokepoint for all RoutingDecision
608        // emitters, so `winning_score` is also sanitized here defensively
609        // (idempotent for an already-positive value).
610        let scores = match (self.routing_diagnostic, detailed_scores) {
611            (true, Some(detailed)) => detailed
612                .iter()
613                .map(|(t, s)| {
614                    let mut s = s.clone();
615                    s.total = display_routing_score(s.total);
616                    (*t, s)
617                })
618                .collect(),
619            _ => Vec::new(),
620        };
621        RoutingDecision {
622            timestamp_ms: now_ms,
623            phase,
624            from,
625            to,
626            winning_score: winning_score.map(display_routing_score),
627            reason_code,
628            scores,
629        }
630    }
631
632    /// Emit dors_escalation_triggered at trigger boundary (typed reason), deduped by reason + time window.
633    fn emit_escalation_trigger_if_deduped(
634        &mut self,
635        now_ms: i64,
636        reason_code: DorsEscalationReasonCode,
637        from: TransportType,
638        to: TransportType,
639        reason_detail: Option<String>,
640        detailed_scores: Option<&[(TransportType, TransportScore)]>,
641    ) {
642        let now = std::time::Instant::now();
643        let emit = match &self.last_escalation_trigger_emitted {
644            Some((last_code, last_at)) => {
645                *last_code != reason_code
646                    || last_at.elapsed().as_secs() >= ESCALATION_TRIGGER_DEDUPE_SECS
647            }
648            None => true,
649        };
650        if emit {
651            self.last_escalation_trigger_emitted = Some((reason_code, now));
652            self.emit_dors_event(Event::dors_escalation_triggered(
653                DorsEscalationPhase::Triggered,
654                from.to_string(),
655                to.to_string(),
656                reason_code,
657                reason_detail,
658            ));
659            self.emit_routing_decision(|| {
660                self.build_routing_decision(
661                    now_ms,
662                    RoutingPhase::Escalated,
663                    Some(from),
664                    Some(to),
665                    None,
666                    Some(Self::escalation_reason_code_to_routing(reason_code)),
667                    detailed_scores,
668                )
669            });
670        }
671    }
672
673    /// Adds a transport to the manager.
674    ///
675    /// # Arguments
676    ///
677    /// * `transport_type` - Type of transport to add
678    /// * `transport` - The transport implementation
679    pub fn add_transport(&mut self, transport_type: TransportType, transport: Box<dyn Transport>) {
680        self.transports.insert(transport_type, Arc::from(transport));
681    }
682
683    /// Sends a message through the best available transport, with fallback.
684    ///
685    /// DORS selects a primary transport (applying hysteresis, cooldown, and
686    /// stability checks). If the primary's `send()` fails synchronously, the
687    /// remaining transports are tried in descending score order before
688    /// returning an error. Each synchronous failure is recorded via
689    /// `record_retry_failure` so DORS can adjust future scoring.
690    ///
691    /// # Internet transport — fallback does NOT apply
692    ///
693    /// Internet `send()` enqueues the message and returns `Ok(())`
694    /// immediately. The actual wire-level outcome is reported asynchronously
695    /// via the `confirm_sent` / `report_send_failure` confirmation loop on
696    /// `InternetTransport`. Because `send()` never fails synchronously, the
697    /// fallback loop below will **not** trigger for Internet. If the
698    /// WebSocket is silently broken (status still `Available` but sends
699    /// fail), messages will be enqueued, detected as failures by the
700    /// confirmation-loop timeout, and counted in DORS metrics — but they
701    /// are **not** retried on an alternative transport by this method.
702    /// Retry/re-routing for those messages is handled by the higher-level
703    /// outbox retry mechanism.
704    pub fn send(&mut self, message: &Message) -> Result<()> {
705        // Stamp the negotiated wire codec for this recipient. The clone happens
706        // only on the binary path (peers known to support it); JSON and unknown
707        // peers reuse the borrowed message. The internet transport ignores the
708        // stamp (its relay bridge is JSON-only), and mesh transports only ever
709        // emit to a recipient that is a directly connected peer, so `recipient`
710        // is the physical peer whose capability was recorded — keying by it
711        // matches the same peer-string the MTU path already keys by.
712        //
713        // The codec travels on the message, and both `Transport::send` and this
714        // method take `&Message` as public API, so carrying the stamp across
715        // that boundary needs an owned message — hence this clone. Note it is a
716        // full-message clone: it copies `binary_content` (up to the max message
717        // size), not just the envelope, so for media-sized payloads sent to a
718        // binary-capable peer it is a real payload memcpy on the hot path. It is
719        // a deliberate tradeoff: threading the codec as a separate argument would
720        // shave the clone but is a breaking change to the public `Transport`
721        // trait every downstream implementor relies on. The transport's own
722        // queue-clone is unaffected either way. If profiling ever flags this,
723        // stamp via a lightweight wrapper rather than cloning the payload.
724        let stamped = self.stamp_negotiated_wire_codec(message);
725        let message = stamped.as_ref().unwrap_or(message);
726
727        let available = self.get_available_transports();
728        if available.is_empty() {
729            return Err(Error::Transport(TransportError::TransportNotAvailable(
730                "No available transport".to_string(),
731            )));
732        }
733
734        // Single timestamp shared by every RoutingDecision emitted from this
735        // send cycle — cheaper than per-emit `Utc::now()` and lets downstream
736        // consumers group decisions by timestamp.
737        let now_ms = Utc::now().timestamp_millis();
738
739        let previous = self.current_transport;
740
741        // DORS selects the primary transport (applies hysteresis/cooldown/stability).
742        let primary = self
743            .selector
744            .select_transport(message, &available)
745            .ok_or_else(|| {
746                Error::Transport(TransportError::TransportNotAvailable(
747                    "No suitable transport selected from available transports".to_string(),
748                ))
749            })?;
750
751        // Emit DORS observability: scores and selection (before send attempt).
752        // At the default (non-diagnostic) tier we project the detailed
753        // breakdown down to `(transport, total)` — a single scoring pass
754        // feeds both the legacy event and the routing record.
755        let detailed_scores: Option<Vec<(TransportType, TransportScore)>> =
756            if self.routing_diagnostic && self.routing_decision_callback.is_some() {
757                Some(self.selector.score_and_rank_detailed(message, &available))
758            } else {
759                None
760            };
761        let detailed_slice = detailed_scores.as_deref();
762        let scored: Vec<(TransportType, f32)> = match detailed_slice {
763            Some(d) => d.iter().map(|(t, s)| (*t, s.total)).collect(),
764            None => self.selector.score_and_rank(message, &available),
765        };
766        // Sanitize the fallback-demotion sentinel out of the emitted scores (the
767        // `scored` vec itself keeps the raw totals — the fallback loop below
768        // relies on Internet ranking last).
769        let scores: Vec<(String, f32)> = scored
770            .iter()
771            .map(|(t, s)| (t.to_string(), display_routing_score(*s)))
772            .collect();
773        self.emit_dors_event(Event::dors_score_updated(scores));
774
775        self.emit_routing_decision(|| {
776            self.build_routing_decision(
777                now_ms,
778                RoutingPhase::ScoreUpdated,
779                previous,
780                None,
781                None,
782                None,
783                detailed_slice,
784            )
785        });
786        // Telemetry-only; sanitize the fallback-demotion sentinel so the emitted
787        // selection score is the real 0–125 quality value, not the negative rank.
788        let primary_score = scored
789            .iter()
790            .find(|(t, _)| *t == primary)
791            .map(|(_, s)| display_routing_score(*s))
792            .unwrap_or(0.0);
793        let selection_reason = if previous.is_none() {
794            DorsReasonCode::InitialSelection
795        } else {
796            DorsReasonCode::PrimarySelected
797        };
798        self.emit_dors_event(Event::dors_transport_selected(
799            previous.map(|t| t.to_string()),
800            primary.to_string(),
801            selection_reason,
802            primary_score,
803        ));
804        self.emit_routing_decision(|| {
805            self.build_routing_decision(
806                now_ms,
807                RoutingPhase::Selected,
808                previous,
809                Some(primary),
810                Some(primary_score),
811                Some(Self::dors_reason_code_to_routing(selection_reason)),
812                detailed_slice,
813            )
814        });
815
816        // Try the primary transport first.
817        let mut primary_refusal = None;
818        let primary_result = {
819            let transport = self
820                .transports
821                .get(&primary)
822                .ok_or_else(|| Error::Other(format!("Transport {:?} not found", primary)))?;
823            transport.send(message)
824        };
825
826        match primary_result {
827            Ok(()) => {
828                self.current_transport = Some(primary);
829                if previous != Some(primary) {
830                    let reason_code = if previous.is_some_and(|p| !available.contains_key(&p)) {
831                        DorsReasonCode::CurrentUnavailable
832                    } else {
833                        DorsReasonCode::PrimarySuccess
834                    };
835                    self.emit_dors_event(Event::dors_transport_switched(
836                        previous.map(|t| t.to_string()),
837                        primary.to_string(),
838                        reason_code,
839                        None,
840                    ));
841                    self.emit_routing_decision(|| {
842                        self.build_routing_decision(
843                            now_ms,
844                            RoutingPhase::Switched,
845                            previous,
846                            Some(primary),
847                            Some(primary_score),
848                            Some(Self::dors_reason_code_to_routing(reason_code)),
849                            detailed_slice,
850                        )
851                    });
852                }
853                return Ok(());
854            }
855            Err(e) => {
856                let is_peer_routing = matches!(e, TransportError::PeerNotReachable(_));
857                if is_peer_routing {
858                    debug!(
859                        transport = ?primary,
860                        error = %e,
861                        "Peer not reachable via primary transport, trying fallback"
862                    );
863                    // Kept as the terminal error until a fallback replaces it.
864                    // Without it, a primary that refused the recipient with
865                    // nothing left to try ended as `SendFailed("All transports
866                    // failed")`, which every classifier reads as a carrier that
867                    // broke mid-send: the deferral said `transport_send_failed`
868                    // for a recipient no carrier could address.
869                    primary_refusal = Some(e);
870                } else {
871                    warn!(
872                        transport = ?primary,
873                        error = %e,
874                        "Primary transport send failed, trying fallback"
875                    );
876                    self.selector.record_retry_failure(primary);
877                }
878            }
879        }
880
881        // Primary failed — try remaining transports in score order.
882        // Reuse the scored ranking computed above; record_retry_failure only
883        // mutates retry_counts which score_and_rank does not read.
884        let mut last_error = primary_refusal;
885        let mut attempted: Vec<TransportType> = vec![primary];
886
887        for (transport_type, _score) in &scored {
888            if *transport_type == primary {
889                continue;
890            }
891
892            // Emit escalation trigger at DORS boundary (typed reason), deduped.
893            if primary == TransportType::BLE && *transport_type == TransportType::WiFiDirect {
894                if let Some(trigger_reason) = self.selector.escalation_trigger_reason() {
895                    let reason_code = Self::escalation_trigger_reason_to_code(trigger_reason);
896                    self.emit_escalation_trigger_if_deduped(
897                        now_ms,
898                        reason_code,
899                        primary,
900                        *transport_type,
901                        None,
902                        detailed_slice,
903                    );
904                }
905            }
906
907            let transport = match self.transports.get(transport_type) {
908                Some(t) => t,
909                None => continue,
910            };
911
912            attempted.push(*transport_type);
913
914            match transport.send(message) {
915                Ok(()) => {
916                    self.current_transport = Some(*transport_type);
917                    self.selector.set_current_transport(*transport_type);
918                    // Emit switch only when active transport actually changed (fallback success).
919                    if previous != Some(*transport_type) {
920                        let reason_code = if primary == TransportType::BLE
921                            && *transport_type == TransportType::WiFiDirect
922                        {
923                            DorsReasonCode::EscalationApplied
924                        } else {
925                            DorsReasonCode::FallbackSuccess
926                        };
927                        self.emit_dors_event(Event::dors_transport_switched(
928                            previous.map(|t| t.to_string()),
929                            transport_type.to_string(),
930                            reason_code,
931                            Some("primary send failed, fallback succeeded".to_string()),
932                        ));
933                        let fallback = *transport_type;
934                        self.emit_routing_decision(|| {
935                            self.build_routing_decision(
936                                now_ms,
937                                RoutingPhase::Switched,
938                                previous,
939                                Some(fallback),
940                                None,
941                                Some(Self::dors_reason_code_to_routing(reason_code)),
942                                detailed_slice,
943                            )
944                        });
945                    }
946                    // Escalation applied only when BLE→WiFi fallback actually succeeded.
947                    if primary == TransportType::BLE && *transport_type == TransportType::WiFiDirect
948                    {
949                        self.emit_dors_event(Event::dors_escalation_triggered(
950                            DorsEscalationPhase::Applied,
951                            primary.to_string(),
952                            transport_type.to_string(),
953                            DorsEscalationReasonCode::FallbackSuccess,
954                            Some("primary BLE send failed, fallback to WiFi succeeded".to_string()),
955                        ));
956                        let escalated = *transport_type;
957                        self.emit_routing_decision(|| {
958                            self.build_routing_decision(
959                                now_ms,
960                                RoutingPhase::Escalated,
961                                Some(primary),
962                                Some(escalated),
963                                None,
964                                Some(Self::escalation_reason_code_to_routing(
965                                    DorsEscalationReasonCode::FallbackSuccess,
966                                )),
967                                detailed_slice,
968                            )
969                        });
970                    }
971                    return Ok(());
972                }
973                Err(e) => {
974                    if !matches!(e, TransportError::PeerNotReachable(_)) {
975                        warn!(
976                            transport = ?transport_type,
977                            error = %e,
978                            "Fallback transport send failed, trying next"
979                        );
980                        self.selector.record_retry_failure(*transport_type);
981                    }
982                    last_error = Some(e);
983                }
984            }
985        }
986
987        let terminal_error = last_error.unwrap_or_else(|| {
988            TransportError::SendFailed(format!("All transports failed (tried {:?})", attempted))
989        });
990        Err(Error::Transport(terminal_error))
991    }
992
993    /// Sends a message via a specific transport, bypassing DORS selection.
994    ///
995    /// # Arguments
996    ///
997    /// * `message` - The message to send
998    /// * `transport_type` - The transport to use
999    ///
1000    /// # Returns
1001    ///
1002    /// Returns Ok(()) if sent successfully, Err otherwise.
1003    pub fn send_via_transport(
1004        &mut self,
1005        message: &Message,
1006        transport_type: TransportType,
1007    ) -> Result<()> {
1008        let transport = self
1009            .transports
1010            .get(&transport_type)
1011            .ok_or_else(|| Error::Other(format!("Transport {:?} not found", transport_type)))?;
1012
1013        if transport.status() != TransportStatus::Available {
1014            return Err(Error::Other(format!(
1015                "Transport {:?} is not available",
1016                transport_type
1017            )));
1018        }
1019
1020        // Preserved as `Error::Transport`, not flattened into `Error::Other`.
1021        // Flattening discarded the variant every classifier keys on —
1022        // `SessionStateError::classify` and `Protocol::is_no_carrier_error`
1023        // both answer on `Error::Transport(_)`, so on this path they saw
1024        // `Unknown`/`false` and the rendered string was the only surviving
1025        // signal. That is exactly the string that must stop being read.
1026        // Stamped exactly as `send` does. This path carries ordinary DMs
1027        // whenever a recipient is a live mesh neighbour, so skipping the stamp
1028        // would downgrade binary-capable peers to JSON on the mesh while the
1029        // DORS path kept them on binary.
1030        let stamped = self.stamp_negotiated_wire_codec(message);
1031        let message = stamped.as_ref().unwrap_or(message);
1032        transport.send(message).map_err(Error::Transport)?;
1033
1034        // Only update current transport after successful send
1035        self.current_transport = Some(transport_type);
1036
1037        Ok(())
1038    }
1039
1040    /// Lists every peer reachable over a direct mesh link, with the transport
1041    /// that link runs on.
1042    ///
1043    /// Only the peer-to-peer carriers are consulted: a mesh neighbor is one we
1044    /// can hand a frame to over our own radio, which is what makes it a
1045    /// candidate next hop. Infrastructure carriers (Internet, Nostr, Reticulum)
1046    /// reach peers through something else, so they report no links and appear
1047    /// here as nothing — a message routed through them is not taking a mesh
1048    /// hop, it is leaving the mesh.
1049    ///
1050    /// A peer visible on more than one carrier is reported once, on the first
1051    /// carrier that claims it; [`Self::send_to_neighbor`] resolves the same way,
1052    /// so the pair stay consistent.
1053    pub fn mesh_neighbors(&self) -> Vec<MeshNeighbor> {
1054        let mut neighbors: Vec<MeshNeighbor> = Vec::new();
1055
1056        for transport_type in MESH_TRANSPORTS {
1057            let Some(transport) = self.transports.get(transport_type) else {
1058                continue;
1059            };
1060            for link in transport.connected_peers() {
1061                if neighbors.iter().any(|n| n.peer_id == link.peer_id) {
1062                    continue;
1063                }
1064                neighbors.push(MeshNeighbor {
1065                    peer_id: link.peer_id,
1066                    rssi: link.rssi,
1067                    transport: *transport_type,
1068                });
1069            }
1070        }
1071
1072        neighbors
1073    }
1074
1075    /// Returns an owned copy stamped with the negotiated binary codec, when
1076    /// this recipient has one and the message is not already stamped.
1077    ///
1078    /// `None` means the borrowed message is already correct, which is the
1079    /// common case (JSON peers, unknown peers, already-stamped messages) and
1080    /// costs no allocation.
1081    ///
1082    /// Shared by every send path rather than living inline in [`Self::send`].
1083    /// The codec is negotiated per recipient, so a path that skips the stamp
1084    /// silently downgrades a binary-capable peer to JSON — a regression with no
1085    /// error and no event, visible only as larger frames on the wire.
1086    fn stamp_negotiated_wire_codec(&self, message: &Message) -> Option<Message> {
1087        if message.wire_codec() == WireCodec::BinaryV1
1088            || !self.peer_binary_wire.contains(message.recipient.as_str())
1089        {
1090            return None;
1091        }
1092        let mut stamped = message.clone();
1093        stamped.set_wire_codec(WireCodec::BinaryV1);
1094        Some(stamped)
1095    }
1096
1097    /// The mesh carrier holding a live link straight to `peer_id`, if any.
1098    ///
1099    /// Ordered by mesh-carrier preference, so a peer reachable over both answers
1100    /// with the higher-bandwidth carrier. This is ground truth rather than a
1101    /// claim: it is read from the transport's live link view at the moment it
1102    /// is asked, never remembered.
1103    pub fn mesh_transport_for(&self, peer_id: &str) -> Option<TransportType> {
1104        MESH_TRANSPORTS.iter().copied().find(|transport_type| {
1105            self.transports
1106                .get(transport_type)
1107                .filter(|transport| transport.status() == TransportStatus::Available)
1108                .is_some_and(|transport| {
1109                    transport
1110                        .connected_peers()
1111                        .iter()
1112                        .any(|link| link.peer_id == peer_id)
1113                })
1114        })
1115    }
1116
1117    /// Whether the mesh carrier `transport_type` holds a live link straight
1118    /// to `peer_id`.
1119    ///
1120    /// [`Self::mesh_transport_for`] answers with the preferred carrier only,
1121    /// so a peer linked over both never names the second; this asks about one
1122    /// carrier. `false` for a carrier that is not registered, not available,
1123    /// or not a mesh carrier.
1124    pub fn holds_mesh_link(&self, transport_type: TransportType, peer_id: &str) -> bool {
1125        MESH_TRANSPORTS.contains(&transport_type)
1126            && self
1127                .transports
1128                .get(&transport_type)
1129                .filter(|transport| transport.status() == TransportStatus::Available)
1130                .is_some_and(|transport| {
1131                    transport
1132                        .connected_peers()
1133                        .iter()
1134                        .any(|link| link.peer_id == peer_id)
1135                })
1136    }
1137
1138    /// Every available carrier that does its own routing.
1139    ///
1140    /// The enumerated form of [`Self::has_infrastructure_carrier`], for callers
1141    /// that hold a per-recipient fact and must ask each carrier separately
1142    /// rather than accepting "one of them is up" for every recipient.
1143    pub fn available_infrastructure_carriers(&self) -> Vec<TransportType> {
1144        self.transports
1145            .iter()
1146            .filter(|(transport_type, transport)| {
1147                !MESH_TRANSPORTS.contains(transport_type)
1148                    && transport.status() == TransportStatus::Available
1149            })
1150            .map(|(transport_type, _)| *transport_type)
1151            .collect()
1152    }
1153
1154    /// Whether a frame for `peer_id` can plausibly arrive without being carried
1155    /// by other devices.
1156    ///
1157    /// This exists because **a send returning `Ok` is not evidence of
1158    /// reachability**. BLE and Wi-Fi Direct refuse a recipient they hold no
1159    /// link to, but the internet transport and Reticulum enqueue for any
1160    /// recipient and report success, so a frame handed to them for someone
1161    /// neither can reach is reported as sent and never arrives. Anything that
1162    /// decides "the mesh has to carry this" from a send failure therefore never
1163    /// fires on a device where one of those carriers is up. Asking this instead
1164    /// keeps that decision on facts we can check.
1165    ///
1166    /// Two ways the answer is yes:
1167    ///
1168    /// - An **infrastructure** carrier is available. Internet, Nostr and
1169    ///   Reticulum do their own routing and reach peers we hold no radio link
1170    ///   to, so a direct send is a real delivery attempt. (Reticulum counts
1171    ///   here because it is a routing network in its own right — its `Ok` means
1172    ///   "accepted for routing", which is as much as any relay-style carrier
1173    ///   promises.)
1174    /// - The peer is one of our own **mesh neighbors**, so a mesh carrier can
1175    ///   hand the frame straight over.
1176    ///
1177    /// Checked in that order so the common online case costs a status scan and
1178    /// never enumerates links.
1179    ///
1180    /// **This answer is deliberately peer-blind on its first arm**, and that is
1181    /// a real limit rather than an oversight: an infrastructure carrier being up
1182    /// says nothing about whether *this* recipient is on it. Callers that hold a
1183    /// peer-specific fact must not settle for this answer — the relay's own
1184    /// `recipient_unreachable` verdict is exactly such a fact, and the park it
1185    /// drives offers the frame to the mesh regardless of what this returns (see
1186    /// `park_unreachable_dm`).
1187    pub fn can_reach_without_carrying(&self, peer_id: &str) -> bool {
1188        self.has_infrastructure_carrier() || self.mesh_can_address(peer_id)
1189    }
1190
1191    /// Whether any carrier that does its own routing is available.
1192    ///
1193    /// Peer-blind by nature: these carriers reach peers we hold no radio link
1194    /// to, so there is no per-peer link to consult. Note the status is
1195    /// bridge-reported and means "the platform says this carrier is up", not
1196    /// "the relay connection is authenticated" — a distinction that matters
1197    /// wherever this stands in for reachability.
1198    pub fn has_infrastructure_carrier(&self) -> bool {
1199        self.transports.iter().any(|(transport_type, transport)| {
1200            !MESH_TRANSPORTS.contains(transport_type)
1201                && transport.status() == TransportStatus::Available
1202        })
1203    }
1204
1205    /// Ids of messages some carrier has accepted but not yet started writing
1206    /// (see [`Transport::queued_message_ids`]).
1207    pub fn queued_message_ids(&self) -> std::collections::HashSet<String> {
1208        self.transports
1209            .values()
1210            .flat_map(|transport| transport.queued_message_ids())
1211            .collect()
1212    }
1213
1214    /// Whether a mesh carrier holds a live link straight to `peer_id`.
1215    ///
1216    /// The peer-specific half of [`Self::can_reach_without_carrying`], split out
1217    /// because callers that already know infrastructure is up still need to ask
1218    /// whether the mesh can hand a frame over without anyone carrying it.
1219    pub fn mesh_can_address(&self, peer_id: &str) -> bool {
1220        self.mesh_transport_for(peer_id).is_some()
1221    }
1222
1223    /// Whether `transport_type` is a short-range carrier that can only address
1224    /// peers it holds a live link to — as opposed to an infrastructure carrier
1225    /// that does its own routing.
1226    ///
1227    /// A frame that arrived over one of these travelled either from a neighbor
1228    /// or through them, which is what makes it evidence about the route back.
1229    pub fn is_mesh_transport(transport_type: TransportType) -> bool {
1230        MESH_TRANSPORTS.contains(&transport_type)
1231    }
1232
1233    /// Whether `transport_type` can actually put a frame in front of `peer_id`.
1234    ///
1235    /// The narrower question [`Self::can_reach_without_carrying`] answers across
1236    /// every carrier, asked of one. It exists for the same reason: a transport
1237    /// returning `Ok` is not evidence the frame can arrive. A **mesh** carrier
1238    /// can only address a peer it holds a live link to: BLE and Wi-Fi Direct
1239    /// both refuse a send to anyone else, and asking first lets a caller choose
1240    /// another route before it spends a send, rather than after the refusal.
1241    /// **Infrastructure** carriers do their own routing, so for them the answer
1242    /// is yes whenever they are available.
1243    ///
1244    /// Callers that would otherwise trust a preferred transport — replying on
1245    /// the link a message arrived over, above all — must ask this first, or the
1246    /// reply dies on exactly the multi-hop path that made forwarding necessary.
1247    pub fn can_address_via(&self, transport_type: TransportType, peer_id: &str) -> bool {
1248        let Some(transport) = self.transports.get(&transport_type) else {
1249            return false;
1250        };
1251        if transport.status() != TransportStatus::Available {
1252            return false;
1253        }
1254        if !MESH_TRANSPORTS.contains(&transport_type) {
1255            return true;
1256        }
1257        transport
1258            .connected_peers()
1259            .iter()
1260            .any(|link| link.peer_id == peer_id)
1261    }
1262
1263    /// Hands `message` to a specific mesh neighbor, whatever the message's own
1264    /// recipient is.
1265    ///
1266    /// This is the forwarding primitive: the frame crosses one link, to the
1267    /// peer named by `peer_id`. Because that peer is the physical target, the
1268    /// binary wire codec is negotiated against **its** capabilities rather than
1269    /// the recipient's — stamping a forwarded frame binary because the distant
1270    /// recipient supports it could put bytes a neighbor cannot decode onto that
1271    /// neighbor's link.
1272    ///
1273    /// Returns the transport the frame went out on.
1274    pub fn send_to_neighbor(&self, peer_id: &str, message: &Message) -> Result<TransportType> {
1275        let stamped;
1276        let message = if message.wire_codec() != WireCodec::BinaryV1
1277            && self.peer_binary_wire.contains(peer_id)
1278        {
1279            let mut m = message.clone();
1280            m.set_wire_codec(WireCodec::BinaryV1);
1281            stamped = m;
1282            &stamped
1283        } else if message.wire_codec() == WireCodec::BinaryV1
1284            && !self.peer_binary_wire.contains(peer_id)
1285        {
1286            // The frame arrived stamped binary (or was stamped for a different
1287            // peer) but this hop cannot decode it. Fall back to the JSON floor
1288            // rather than writing bytes the neighbor will drop.
1289            let mut m = message.clone();
1290            m.set_wire_codec(WireCodec::Json);
1291            stamped = m;
1292            &stamped
1293        } else {
1294            message
1295        };
1296
1297        let mut last_error: Option<String> = None;
1298
1299        for transport_type in MESH_TRANSPORTS {
1300            let Some(transport) = self.transports.get(transport_type) else {
1301                continue;
1302            };
1303            if transport.status() != TransportStatus::Available {
1304                continue;
1305            }
1306            match transport.send_to_peer(peer_id, message) {
1307                Ok(()) => return Ok(*transport_type),
1308                Err(e) => last_error = Some(e.to_string()),
1309            }
1310        }
1311
1312        Err(Error::Transport(TransportError::PeerNotReachable(format!(
1313            "no mesh transport holds a link to {}{}",
1314            peer_id,
1315            last_error.map(|e| format!(" ({})", e)).unwrap_or_default()
1316        ))))
1317    }
1318
1319    /// Attempts to receive a message from any transport.
1320    ///
1321    /// # Returns
1322    ///
1323    /// Returns Ok(Some((TransportType, Message))) if a message was received, Ok(None) if no message available.
1324    pub fn receive(&self) -> Result<Option<(TransportType, Message)>> {
1325        // Check all transports for messages
1326        for (transport_type, transport) in &self.transports {
1327            match transport.receive() {
1328                Ok(Some(message)) => {
1329                    debug!(
1330                        transport = ?transport_type,
1331                        sender = %message.sender,
1332                        recipient = %message.recipient,
1333                        "TransportManager found message"
1334                    );
1335                    return Ok(Some((*transport_type, message)));
1336                }
1337                Ok(None) => {
1338                    // No message from this transport, continue checking others
1339                }
1340                Err(e) => {
1341                    warn!(
1342                        transport = ?transport_type,
1343                        error = %e,
1344                        "Transport receive error"
1345                    );
1346                }
1347            }
1348        }
1349        Ok(None)
1350    }
1351
1352    /// Gets metrics for all available transports.
1353    ///
1354    /// # Returns
1355    ///
1356    /// Returns a map of transport type to metrics.
1357    pub fn get_available_transports(&self) -> HashMap<TransportType, TransportMetrics> {
1358        let mut available = HashMap::new();
1359
1360        for (transport_type, transport) in &self.transports {
1361            let maybe_metrics = {
1362                let status = transport.status();
1363                if status == TransportStatus::Available {
1364                    Some(transport.metrics())
1365                } else {
1366                    debug!(
1367                        transport = ?transport_type,
1368                        status = ?status,
1369                        "Transport excluded from available set"
1370                    );
1371                    None
1372                }
1373            };
1374
1375            if let Some(mut metrics) = maybe_metrics {
1376                if let Some(stats) = self.observations.get(transport_type) {
1377                    stats.apply_to_metrics(*transport_type, &mut metrics);
1378                }
1379                self.apply_device_battery(&mut metrics);
1380                available.insert(*transport_type, metrics);
1381            }
1382        }
1383
1384        available
1385    }
1386
1387    /// Gets the current active transport type.
1388    pub fn current_transport(&self) -> Option<TransportType> {
1389        self.current_transport
1390    }
1391
1392    /// Returns the current status of every registered transport, including
1393    /// those not currently [`TransportStatus::Available`].
1394    ///
1395    /// Used by the telemetry aggregator to detect state transitions; unlike
1396    /// [`TransportManager::get_available_transports`], this only reads each
1397    /// transport's status with no metrics work.
1398    pub fn get_all_transport_statuses(&self) -> HashMap<TransportType, TransportStatus> {
1399        let mut out = HashMap::with_capacity(self.transports.len());
1400        for (transport_type, transport) in &self.transports {
1401            out.insert(*transport_type, transport.status());
1402        }
1403        out
1404    }
1405
1406    /// Returns the per-transport status map AND the available-only metrics
1407    /// map in a single pass.
1408    ///
1409    /// Equivalent to calling [`Self::get_all_transport_statuses`] and
1410    /// [`Self::get_available_transports`] back-to-back, but reads each
1411    /// transport's status once instead of twice. Used by the per-tick
1412    /// telemetry aggregator.
1413    pub fn snapshot_status_and_available(
1414        &self,
1415    ) -> (
1416        HashMap<TransportType, TransportStatus>,
1417        HashMap<TransportType, TransportMetrics>,
1418    ) {
1419        let mut statuses = HashMap::with_capacity(self.transports.len());
1420        let mut available = HashMap::new();
1421
1422        for (transport_type, transport) in &self.transports {
1423            let status = transport.status();
1424            let metrics = if status == TransportStatus::Available {
1425                Some(transport.metrics())
1426            } else {
1427                None
1428            };
1429            statuses.insert(*transport_type, status);
1430            if let Some(mut metrics) = metrics {
1431                if let Some(stats) = self.observations.get(transport_type) {
1432                    stats.apply_to_metrics(*transport_type, &mut metrics);
1433                }
1434                self.apply_device_battery(&mut metrics);
1435                available.insert(*transport_type, metrics);
1436            }
1437        }
1438
1439        (statuses, available)
1440    }
1441
1442    /// Starts all transports.
1443    pub fn start(&mut self) -> Result<()> {
1444        for (transport_type, transport) in &self.transports {
1445            transport.start().map_err(|e| {
1446                Error::Other(format!(
1447                    "Failed to start transport {:?}: {}",
1448                    transport_type, e
1449                ))
1450            })?;
1451        }
1452        Ok(())
1453    }
1454
1455    /// Stops all transports.
1456    pub fn stop(&mut self) -> Result<()> {
1457        for (transport_type, transport) in &self.transports {
1458            transport.stop().map_err(|e| {
1459                Error::Other(format!(
1460                    "Failed to stop transport {:?}: {}",
1461                    transport_type, e
1462                ))
1463            })?;
1464        }
1465        Ok(())
1466    }
1467
1468    /// Gets a shared handle to a specific transport.
1469    pub fn get_transport(&self, transport_type: TransportType) -> Option<Arc<dyn Transport>> {
1470        self.transports.get(&transport_type).cloned()
1471    }
1472
1473    /// Removes a transport from the manager.
1474    ///
1475    /// # Arguments
1476    ///
1477    /// * `transport_type` - Type of transport to remove
1478    pub fn remove_transport(&mut self, transport_type: TransportType) {
1479        self.transports.remove(&transport_type);
1480
1481        // Clear current transport if it was the one removed
1482        if self.current_transport == Some(transport_type) {
1483            self.current_transport = None;
1484        }
1485
1486        self.observations.remove(&transport_type);
1487    }
1488
1489    /// Gets a list of all active transport types.
1490    ///
1491    /// # Returns
1492    ///
1493    /// Returns a vector of transport types that are currently added to the manager.
1494    pub fn get_active_transports(&self) -> Vec<TransportType> {
1495        self.transports.keys().copied().collect()
1496    }
1497
1498    /// Checks if DORS suggests escalating from BLE to WiFi Direct.
1499    pub fn should_escalate_to_wifi(&self) -> bool {
1500        self.selector.should_escalate_to_wifi()
1501    }
1502
1503    /// Records a retry failure for the given transport.
1504    pub fn record_retry_failure(&mut self, transport_type: TransportType) {
1505        self.selector.record_retry_failure(transport_type);
1506    }
1507
1508    /// Resets retry count for the given transport after successful delivery.
1509    pub fn reset_retry_count(&mut self, transport_type: TransportType) {
1510        self.selector.reset_retry_count(transport_type);
1511    }
1512
1513    /// Records a successful end-to-end delivery for the given transport.
1514    pub fn record_delivery_success(
1515        &mut self,
1516        transport_type: TransportType,
1517        latency_ms: u32,
1518        hop_count: u8,
1519    ) {
1520        let stats = self.observations.entry(transport_type).or_default();
1521        stats.record_success(latency_ms, hop_count);
1522    }
1523
1524    /// Records a delivery failure (after exhausting retries) for the given transport.
1525    pub fn record_delivery_failure(&mut self, transport_type: TransportType) {
1526        let stats = self.observations.entry(transport_type).or_default();
1527        stats.record_failure();
1528    }
1529
1530    #[cfg(test)]
1531    pub(crate) fn observed_failure_count(&self, transport_type: TransportType) -> u32 {
1532        self.observations
1533            .get(&transport_type)
1534            .map_or(0, |stats| stats.failure_count)
1535    }
1536
1537    /// Updates the DORS selector configuration at runtime, preserving
1538    /// accumulated state (transport history, retry counts, signal tracking).
1539    pub fn update_selector_config(&mut self, config: DorsConfig) {
1540        self.selector.update_config(config);
1541    }
1542
1543    /// Returns the DORS configuration transport scoring runs against.
1544    pub fn selector_config(&self) -> &DorsConfig {
1545        self.selector.config()
1546    }
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551    use super::*;
1552    use offline_protocol_core::{AppId, UserId};
1553    use offline_protocol_router::DorsConfig;
1554    use offline_protocol_transport::{mock::MockTransport, TransportType};
1555    use std::sync::Mutex;
1556
1557    fn create_test_message() -> Message {
1558        Message::new(
1559            UserId::new("alice").unwrap(),
1560            UserId::new("bob").unwrap(),
1561            AppId::new("test").unwrap(),
1562            "Test message",
1563        )
1564    }
1565
1566    #[test]
1567    fn test_transport_manager_creation() {
1568        let selector = TransportSelector::with_config(DorsConfig::default());
1569        let manager = TransportManager::new(selector);
1570        assert_eq!(manager.transports.len(), 0);
1571        assert!(manager.current_transport().is_none());
1572    }
1573
1574    #[test]
1575    fn test_add_transport() {
1576        let selector = TransportSelector::with_config(DorsConfig::default());
1577        let mut manager = TransportManager::new(selector);
1578
1579        let transport = Box::new(MockTransport::new(TransportType::BLE));
1580        manager.add_transport(TransportType::BLE, transport);
1581
1582        assert_eq!(manager.transports.len(), 1);
1583    }
1584
1585    #[test]
1586    fn test_start_stop_through_shared_transport_handle() {
1587        let selector = TransportSelector::with_config(DorsConfig::default());
1588        let mut manager = TransportManager::new(selector);
1589        manager.add_transport(
1590            TransportType::BLE,
1591            Box::new(MockTransport::new(TransportType::BLE)),
1592        );
1593
1594        // The handle shares the same transport the manager drives: start()
1595        // and stop() go through &self, no per-transport mutex involved.
1596        let handle = manager
1597            .get_transport(TransportType::BLE)
1598            .expect("transport registered");
1599        assert_eq!(handle.status(), TransportStatus::Unavailable);
1600
1601        manager.start().unwrap();
1602        assert_eq!(handle.status(), TransportStatus::Available);
1603
1604        manager.stop().unwrap();
1605        assert_eq!(handle.status(), TransportStatus::Disconnected);
1606    }
1607
1608    #[test]
1609    fn test_send_message() {
1610        let selector = TransportSelector::with_config(DorsConfig::default());
1611        let mut manager = TransportManager::new(selector);
1612
1613        let transport = MockTransport::new(TransportType::BLE);
1614        transport.start().unwrap();
1615        manager.add_transport(TransportType::BLE, Box::new(transport));
1616
1617        let message = create_test_message();
1618        let result = manager.send(&message);
1619        assert!(result.is_ok());
1620    }
1621
1622    #[test]
1623    fn send_stamps_binary_only_for_capable_peer() {
1624        let selector = TransportSelector::with_config(DorsConfig::default());
1625        let mut manager = TransportManager::new(selector);
1626        let transport = MockTransport::new(TransportType::BLE);
1627        transport.start().unwrap();
1628        manager.add_transport(TransportType::BLE, Box::new(transport));
1629
1630        // Unknown peer -> JSON (the default).
1631        manager.send(&create_test_message()).unwrap();
1632        // Peer recorded as binary-capable -> the send is stamped binary.
1633        manager.mark_peer_binary_wire("bob", true);
1634        manager.send(&create_test_message()).unwrap();
1635
1636        let mock = manager
1637            .transports
1638            .get(&TransportType::BLE)
1639            .unwrap()
1640            .as_any()
1641            .downcast_ref::<MockTransport>()
1642            .unwrap();
1643        let sent = mock.sent_messages();
1644        assert_eq!(sent.len(), 2);
1645        assert_eq!(sent[0].wire_codec(), WireCodec::Json);
1646        assert_eq!(sent[1].wire_codec(), WireCodec::BinaryV1);
1647    }
1648
1649    #[test]
1650    fn send_reverts_to_json_after_peer_downgrades() {
1651        let selector = TransportSelector::with_config(DorsConfig::default());
1652        let mut manager = TransportManager::new(selector);
1653        let transport = MockTransport::new(TransportType::BLE);
1654        transport.start().unwrap();
1655        manager.add_transport(TransportType::BLE, Box::new(transport));
1656
1657        // Learn the peer is binary-capable -> the next send is stamped binary.
1658        manager.mark_peer_binary_wire("bob", true);
1659        assert!(manager.peer_supports_binary_wire("bob"));
1660        manager.send(&create_test_message()).unwrap();
1661
1662        // A later key package that no longer advertises support downgrades the
1663        // peer (e.g. after a session reset, or the codec being turned off on
1664        // their side); subsequent sends must fall back to JSON, not keep
1665        // emitting binary a legacy build could not decode.
1666        manager.mark_peer_binary_wire("bob", false);
1667        assert!(!manager.peer_supports_binary_wire("bob"));
1668        manager.send(&create_test_message()).unwrap();
1669
1670        let mock = manager
1671            .transports
1672            .get(&TransportType::BLE)
1673            .unwrap()
1674            .as_any()
1675            .downcast_ref::<MockTransport>()
1676            .unwrap();
1677        let sent = mock.sent_messages();
1678        assert_eq!(sent.len(), 2);
1679        assert_eq!(sent[0].wire_codec(), WireCodec::BinaryV1);
1680        assert_eq!(sent[1].wire_codec(), WireCodec::Json);
1681    }
1682
1683    #[test]
1684    fn test_dors_events_emitted_when_callback_set() {
1685        let selector = TransportSelector::with_config(DorsConfig::default());
1686        let mut manager = TransportManager::new(selector);
1687
1688        let transport = MockTransport::new(TransportType::BLE);
1689        transport.start().unwrap();
1690        manager.add_transport(TransportType::BLE, Box::new(transport));
1691
1692        let events: std::sync::Arc<Mutex<Vec<Event>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1693        let events_clone = events.clone();
1694        manager.set_dors_event_callback(Some(Arc::new(move |e| {
1695            events_clone.lock().unwrap().push(e);
1696        })));
1697
1698        let message = create_test_message();
1699        let _ = manager.send(&message);
1700
1701        let captured = events.lock().unwrap();
1702        assert!(
1703            captured
1704                .iter()
1705                .any(|e| matches!(e, crate::events::Event::DorsScoreUpdated { .. })),
1706            "expected dors_score_updated"
1707        );
1708        assert!(
1709            captured
1710                .iter()
1711                .any(|e| { matches!(e, crate::events::Event::DorsTransportSelected { .. }) }),
1712            "expected dors_transport_selected"
1713        );
1714    }
1715
1716    #[test]
1717    fn test_receive_message() {
1718        let selector = TransportSelector::with_config(DorsConfig::default());
1719        let mut manager = TransportManager::new(selector);
1720
1721        let transport = MockTransport::new(TransportType::BLE);
1722        transport.start().unwrap();
1723        let message = create_test_message();
1724        transport.queue_message(message.clone());
1725
1726        manager.add_transport(TransportType::BLE, Box::new(transport));
1727
1728        let received = manager.receive().unwrap();
1729        assert!(matches!(received, Some((TransportType::BLE, _))));
1730    }
1731
1732    #[test]
1733    fn test_record_delivery_success_enriches_metrics() {
1734        let selector = TransportSelector::with_config(DorsConfig::default());
1735        let mut manager = TransportManager::new(selector);
1736
1737        let transport = MockTransport::new(TransportType::BLE);
1738        transport.start().unwrap();
1739        manager.add_transport(TransportType::BLE, Box::new(transport));
1740
1741        manager.record_delivery_success(TransportType::BLE, 150, 2);
1742
1743        let metrics = manager.get_available_transports();
1744        let ble_metrics = metrics.get(&TransportType::BLE).expect("metrics");
1745
1746        assert_eq!(ble_metrics.success_count, 1);
1747        assert_eq!(ble_metrics.failure_count, 0);
1748        assert!(ble_metrics.delivery_ratio.expect("delivery ratio") > 0.99);
1749        assert_eq!(ble_metrics.average_hop_count, Some(2.0));
1750        assert_eq!(ble_metrics.latency_ms, Some(150));
1751    }
1752
1753    #[test]
1754    fn test_record_delivery_failure_enriches_metrics() {
1755        let selector = TransportSelector::with_config(DorsConfig::default());
1756        let mut manager = TransportManager::new(selector);
1757
1758        let transport = MockTransport::new(TransportType::BLE);
1759        transport.start().unwrap();
1760        manager.add_transport(TransportType::BLE, Box::new(transport));
1761
1762        manager.record_delivery_failure(TransportType::BLE);
1763        let metrics = manager.get_available_transports();
1764        let ble_metrics = metrics.get(&TransportType::BLE).expect("metrics");
1765
1766        assert_eq!(ble_metrics.success_count, 0);
1767        assert_eq!(ble_metrics.failure_count, 1);
1768        assert!(ble_metrics.drop_rate.expect("drop ratio") > 0.99);
1769    }
1770
1771    /// A mesh transport with no send confirmation loop takes the engine's
1772    /// delivery evidence even when it carries a stray count of its own: the
1773    /// value-based rule discarded every acknowledgement after one (#557).
1774    #[test]
1775    fn engine_observations_win_over_a_mesh_transports_own_counts() {
1776        let selector = TransportSelector::with_config(DorsConfig::default());
1777        let mut manager = TransportManager::new(selector);
1778
1779        let transport = MockTransport::new(TransportType::BLE);
1780        transport.start().unwrap();
1781        transport.set_metrics(TransportMetrics {
1782            failure_count: 1,
1783            delivery_ratio: Some(0.0),
1784            drop_rate: Some(1.0),
1785            ..Default::default()
1786        });
1787        manager.add_transport(TransportType::BLE, Box::new(transport));
1788
1789        manager.record_delivery_success(TransportType::BLE, 150, 1);
1790
1791        let metrics = manager.get_available_transports();
1792        let ble = metrics.get(&TransportType::BLE).expect("metrics");
1793        assert_eq!((ble.success_count, ble.failure_count), (1, 0));
1794        assert_eq!(ble.delivery_ratio, Some(1.0));
1795        assert_eq!(ble.drop_rate, Some(0.0));
1796    }
1797
1798    /// A transport with a confirmation loop owns its counts: the platform's
1799    /// per-send outcomes are better evidence than the engine's
1800    /// acknowledgements and must not be overwritten.
1801    #[test]
1802    fn a_confirmation_loop_transport_keeps_its_own_counts() {
1803        let selector = TransportSelector::with_config(DorsConfig::default());
1804        let mut manager = TransportManager::new(selector);
1805
1806        let transport = MockTransport::new(TransportType::Internet);
1807        transport.start().unwrap();
1808        transport.set_metrics(TransportMetrics {
1809            success_count: 5,
1810            failure_count: 1,
1811            delivery_ratio: Some(5.0 / 6.0),
1812            drop_rate: Some(1.0 / 6.0),
1813            ..Default::default()
1814        });
1815        manager.add_transport(TransportType::Internet, Box::new(transport));
1816
1817        manager.record_delivery_failure(TransportType::Internet);
1818
1819        let metrics = manager.get_available_transports();
1820        let internet = metrics.get(&TransportType::Internet).expect("metrics");
1821        assert_eq!((internet.success_count, internet.failure_count), (5, 1));
1822        assert_eq!(internet.delivery_ratio, Some(5.0 / 6.0));
1823    }
1824
1825    /// End to end over a real BLE transport: an inbound partial message
1826    /// evicted at capacity must not read as a 0% delivery ratio, so a
1827    /// handful of sends afterwards neither trips the low-success-rate
1828    /// escalation nor hides the engine's acknowledgements (#557).
1829    #[test]
1830    fn a_dropped_inbound_fragment_does_not_escalate_ble() {
1831        use offline_protocol_transport::constants::BLE_MAX_FRAGMENT_ASSEMBLIES;
1832        use offline_protocol_transport::{BleTransport, PeerDevice};
1833
1834        let selector = TransportSelector::with_config(DorsConfig::default());
1835        let mut manager = TransportManager::new(selector);
1836
1837        let ble = BleTransport::new("alice-device");
1838        ble.start().unwrap();
1839        ble.on_peer_discovered(PeerDevice {
1840            device_id: "bob".to_string(),
1841            address: "AA:BB:CC:DD:EE:FF".to_string(),
1842            rssi: -60,
1843            last_seen: std::time::SystemTime::now(),
1844            connected: true,
1845        });
1846        manager.add_transport(TransportType::BLE, Box::new(ble));
1847        let wifi = MockTransport::new(TransportType::WiFiDirect);
1848        wifi.start().unwrap();
1849        manager.add_transport(TransportType::WiFiDirect, Box::new(wifi));
1850
1851        // Fill the reassembly cache with first fragments of multi-fragment
1852        // messages, then one more: the cache evicts a partial assembly.
1853        let ble = manager.get_transport(TransportType::BLE).expect("ble");
1854        let ble_ref = ble.as_any().downcast_ref::<BleTransport>().expect("ble");
1855        for _ in 0..=BLE_MAX_FRAGMENT_ASSEMBLIES {
1856            let large = Message::new(
1857                UserId::new("bob").unwrap(),
1858                UserId::new("alice").unwrap(),
1859                AppId::new("test").unwrap(),
1860                "x".repeat(2_000),
1861            );
1862            let fragments = ble_ref.fragment_message(&large).expect("fragments");
1863            assert!(fragments.len() > 1, "the message must span fragments");
1864            ble.on_fragment_received_from(fragments[0].clone(), "bob".to_string())
1865                .expect("fragment accepted");
1866        }
1867        drop(ble);
1868
1869        // More sends than the low-success-rate sample floor.
1870        for _ in 0..8 {
1871            manager.send(&create_test_message()).expect("send");
1872        }
1873
1874        assert!(
1875            !manager.should_escalate_to_wifi(),
1876            "a receive-side reassembly drop must not read as a failing BLE link"
1877        );
1878        let metrics = manager.get_available_transports();
1879        assert_ne!(
1880            metrics[&TransportType::BLE].delivery_ratio,
1881            Some(0.0),
1882            "BLE must not report a 0% delivery ratio with no failed delivery"
1883        );
1884
1885        manager.record_delivery_success(TransportType::BLE, 120, 1);
1886        let metrics = manager.get_available_transports();
1887        assert_eq!(metrics[&TransportType::BLE].delivery_ratio, Some(1.0));
1888    }
1889
1890    /// A carrier that refuses the recipient with nothing left to fall back to
1891    /// ends the send as `PeerNotReachable`, not as a carrier that broke
1892    /// mid-send. The deferral token and every classifier read this variant.
1893    #[test]
1894    fn a_sole_carrier_refusing_the_recipient_ends_as_peer_not_reachable() {
1895        let selector = TransportSelector::with_config(DorsConfig::default());
1896        let mut manager = TransportManager::new(selector);
1897
1898        let transport = MockTransport::new(TransportType::BLE);
1899        transport.start().unwrap();
1900        transport.set_reject_unknown_recipients(true);
1901        manager.add_transport(TransportType::BLE, Box::new(transport));
1902
1903        let err = manager.send(&create_test_message()).unwrap_err();
1904        assert!(
1905            matches!(err, Error::Transport(TransportError::PeerNotReachable(_))),
1906            "got {err:?}"
1907        );
1908    }
1909
1910    /// Two transports, neither reporting a battery level of its own — the
1911    /// ordinary case, since no platform transport publishes one. Both must
1912    /// come back carrying the host reading, because every downstream consumer
1913    /// (DORS energy scoring, the relay role, the forwarding floor) resolves
1914    /// battery through this map and would otherwise take its unknown branch.
1915    #[test]
1916    fn test_device_battery_fills_transports_that_report_none() {
1917        let selector = TransportSelector::with_config(DorsConfig::default());
1918        let mut manager = TransportManager::new(selector);
1919
1920        for transport_type in [TransportType::BLE, TransportType::WiFiDirect] {
1921            let transport = MockTransport::new(transport_type);
1922            transport.start().unwrap();
1923            manager.add_transport(transport_type, Box::new(transport));
1924        }
1925
1926        assert_eq!(manager.device_battery(), (None, false));
1927        manager.set_device_battery(42, true);
1928        assert_eq!(manager.device_battery(), (Some(42), true));
1929
1930        let metrics = manager.get_available_transports();
1931        for transport_type in [TransportType::BLE, TransportType::WiFiDirect] {
1932            let m = metrics.get(&transport_type).expect("metrics");
1933            assert_eq!(m.battery_level, Some(42));
1934            assert!(m.is_charging);
1935        }
1936
1937        // The one-pass snapshot builds the same map and must agree with it —
1938        // the telemetry aggregator and the relay-role evaluation read this one.
1939        let (_statuses, available) = manager.snapshot_status_and_available();
1940        let m = available.get(&TransportType::BLE).expect("metrics");
1941        assert_eq!(m.battery_level, Some(42));
1942        assert!(m.is_charging);
1943    }
1944
1945    /// A transport that publishes its own battery reading keeps it, and keeps
1946    /// its own charging state with it: mixing a level from one source with a
1947    /// charging flag from another is what produces the spurious
1948    /// charging-state flips `device_battery_from_available` documents.
1949    #[test]
1950    fn test_transport_reported_battery_wins_over_device_feed() {
1951        let selector = TransportSelector::with_config(DorsConfig::default());
1952        let mut manager = TransportManager::new(selector);
1953
1954        let transport = MockTransport::new(TransportType::BLE);
1955        transport.start().unwrap();
1956        transport.set_metrics(TransportMetrics {
1957            battery_level: Some(7),
1958            is_charging: false,
1959            ..Default::default()
1960        });
1961        manager.add_transport(TransportType::BLE, Box::new(transport));
1962
1963        manager.set_device_battery(90, true);
1964
1965        let metrics = manager.get_available_transports();
1966        let m = metrics.get(&TransportType::BLE).expect("metrics");
1967        assert_eq!(m.battery_level, Some(7));
1968        assert!(!m.is_charging);
1969    }
1970
1971    #[test]
1972    fn test_device_battery_is_clamped() {
1973        let selector = TransportSelector::with_config(DorsConfig::default());
1974        let mut manager = TransportManager::new(selector);
1975        manager.set_device_battery(240, false);
1976        assert_eq!(manager.device_battery(), (Some(100), false));
1977    }
1978}