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