Skip to main content

fips_core/transport/ble/
io.rs

1//! BLE I/O abstraction layer.
2//!
3//! Defines the `BleIo` trait that separates transport logic from operating
4//! system BLE APIs. BlueZ and host-command implementations provide production
5//! adapters; `MockBleIo` provides an in-memory test double.
6
7use crate::transport::TransportError;
8
9use super::{DEFAULT_PSM, addr::BleAddr, bootstrap::BleBootstrap};
10
11/// One peer discovered through the BLE v2 GATT bootstrap service.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct BleCandidate {
14    pub addr: BleAddr,
15    pub bootstrap: BleBootstrap,
16}
17
18// ============================================================================
19// BLE I/O Traits
20// ============================================================================
21
22/// A connected L2CAP stream for sending and receiving data.
23pub trait BleStream: Send + Sync {
24    /// Send data over the L2CAP connection.
25    fn send(
26        &self,
27        data: &[u8],
28    ) -> impl std::future::Future<Output = Result<(), TransportError>> + Send;
29
30    /// Receive data from the L2CAP connection.
31    ///
32    /// Returns the number of bytes read into `buf`.
33    fn recv(
34        &self,
35        buf: &mut [u8],
36    ) -> impl std::future::Future<Output = Result<usize, TransportError>> + Send;
37
38    /// Get the L2CAP send MTU for this connection.
39    fn send_mtu(&self) -> u16;
40
41    /// Get the L2CAP receive MTU for this connection.
42    fn recv_mtu(&self) -> u16;
43
44    /// Get the remote device address.
45    fn remote_addr(&self) -> &BleAddr;
46}
47
48/// An acceptor that yields inbound L2CAP connections.
49pub trait BleAcceptor: Send {
50    /// The concrete stream type yielded by this acceptor.
51    type Stream: BleStream + 'static;
52
53    /// Accept the next inbound connection.
54    fn accept(
55        &mut self,
56    ) -> impl std::future::Future<Output = Result<Self::Stream, TransportError>> + Send;
57
58    /// Platform-assigned PSM on which this acceptor is listening.
59    fn psm(&self) -> u16;
60}
61
62/// A scanner that yields discovered BLE devices advertising the FIPS UUID.
63pub trait BleScanner: Send {
64    /// Wait for the next discovered device.
65    ///
66    /// Returns `None` when scanning is stopped.
67    fn next(&mut self) -> impl std::future::Future<Output = Option<BleCandidate>> + Send;
68}
69
70/// Core BLE I/O operations.
71///
72/// This trait abstracts the BlueZ/bluer stack so that `BleTransport`
73/// can be tested with `MockBleIo` (in-memory channels) in CI without
74/// requiring Bluetooth hardware, D-Bus, or bluetoothd.
75pub trait BleIo: Send + Sync + 'static {
76    /// The concrete stream type returned by this I/O implementation.
77    type Stream: BleStream + 'static;
78    /// The concrete acceptor type.
79    type Acceptor: BleAcceptor<Stream = Self::Stream> + 'static;
80    /// The concrete scanner type.
81    type Scanner: BleScanner + 'static;
82
83    /// Start listening for inbound L2CAP connections on the given PSM.
84    fn listen(
85        &self,
86        psm: u16,
87    ) -> impl std::future::Future<Output = Result<Self::Acceptor, TransportError>> + Send;
88
89    /// Connect to a remote BLE device on the given PSM.
90    fn connect(
91        &self,
92        addr: &BleAddr,
93        psm: u16,
94    ) -> impl std::future::Future<Output = Result<Self::Stream, TransportError>> + Send;
95
96    /// Start advertising the FIPS service UUID.
97    fn start_advertising(
98        &self,
99        bootstrap: BleBootstrap,
100    ) -> impl std::future::Future<Output = Result<(), TransportError>> + Send;
101
102    /// Stop advertising.
103    fn stop_advertising(
104        &self,
105    ) -> impl std::future::Future<Output = Result<(), TransportError>> + Send;
106
107    /// Start passive scanning for FIPS service UUID advertisements.
108    fn start_scanning(
109        &self,
110    ) -> impl std::future::Future<Output = Result<Self::Scanner, TransportError>> + Send;
111
112    /// Get the adapter's BLE address.
113    fn local_addr(&self) -> Result<BleAddr, TransportError>;
114
115    /// Get the adapter name (e.g., "hci0").
116    fn adapter_name(&self) -> &str;
117}
118
119// ============================================================================
120// BluerIo — Production BLE I/O via BlueZ D-Bus
121// ============================================================================
122
123#[cfg(bluer_available)]
124mod bluer_impl {
125    use super::*;
126    use crate::transport::TransportError;
127    use crate::transport::ble::bootstrap::{
128        FIPS_BLE_V2_BOOTSTRAP_CHARACTERISTIC_UUID, FIPS_BLE_V2_SERVICE_UUID,
129    };
130
131    use bluer::gatt::local::{Application, Characteristic, CharacteristicRead, Service};
132    use bluer::l2cap::{SeqPacket, SeqPacketListener, Socket, SocketAddr};
133    use bluer::{
134        AdapterEvent, AddressType, DiscoveryFilter, DiscoveryTransport, adv::Advertisement,
135    };
136    use futures::{FutureExt, StreamExt};
137    use std::collections::{BTreeSet, HashSet, VecDeque};
138    use std::pin::Pin;
139    use tokio::sync::Mutex;
140    use tracing::{debug, trace};
141
142    /// FIPS BLE service UUID.
143    ///
144    /// Derived from SHA-256("FIPS: welcome to cryptoanarchy") with UUID v4
145    /// version/variant bits applied.
146    pub const FIPS_SERVICE_UUID: bluer::Uuid = bluer::Uuid::from_u128(FIPS_BLE_V2_SERVICE_UUID);
147    pub const FIPS_BOOTSTRAP_CHARACTERISTIC_UUID: bluer::Uuid =
148        bluer::Uuid::from_u128(FIPS_BLE_V2_BOOTSTRAP_CHARACTERISTIC_UUID);
149
150    /// Map a bluer error to a TransportError.
151    fn map_err(context: &str, e: bluer::Error) -> TransportError {
152        TransportError::Io(std::io::Error::other(format!("{}: {}", context, e)))
153    }
154
155    /// Map a std::io::Error to a TransportError.
156    fn map_io_err(context: &str, e: std::io::Error) -> TransportError {
157        TransportError::Io(std::io::Error::new(e.kind(), format!("{}: {}", context, e)))
158    }
159
160    // ----------------------------------------------------------------
161    // BluerStream
162    // ----------------------------------------------------------------
163
164    /// BLE stream wrapping a bluer L2CAP SeqPacket connection.
165    pub struct BluerStream {
166        conn: SeqPacket,
167        remote: BleAddr,
168        send_mtu: u16,
169        recv_mtu: u16,
170    }
171
172    impl BluerStream {
173        /// Construct from a connected SeqPacket, querying MTU values.
174        pub fn new(conn: SeqPacket, remote: BleAddr) -> Result<Self, TransportError> {
175            let send_mtu = conn.send_mtu().map_err(|e| map_io_err("send_mtu", e))? as u16;
176            let recv_mtu = conn.recv_mtu().map_err(|e| map_io_err("recv_mtu", e))? as u16;
177
178            // Log negotiated PHY for diagnostics (2M vs 1M)
179            match conn.as_ref().phy() {
180                Ok(phy) => {
181                    debug!(addr = %remote, phy, send_mtu, recv_mtu, "BLE connection established")
182                }
183                Err(_) => {
184                    debug!(addr = %remote, send_mtu, recv_mtu, "BLE connection established (PHY query unsupported)")
185                }
186            }
187
188            Ok(Self {
189                conn,
190                remote,
191                send_mtu,
192                recv_mtu,
193            })
194        }
195    }
196
197    impl BleStream for BluerStream {
198        async fn send(&self, data: &[u8]) -> Result<(), TransportError> {
199            self.conn
200                .send(data)
201                .await
202                .map(|_| ())
203                .map_err(|e| TransportError::SendFailed(format!("{}", e)))
204        }
205
206        async fn recv(&self, buf: &mut [u8]) -> Result<usize, TransportError> {
207            self.conn
208                .recv(buf)
209                .await
210                .map_err(|e| TransportError::RecvFailed(format!("{}", e)))
211        }
212
213        fn send_mtu(&self) -> u16 {
214            self.send_mtu
215        }
216
217        fn recv_mtu(&self) -> u16 {
218            self.recv_mtu
219        }
220
221        fn remote_addr(&self) -> &BleAddr {
222            &self.remote
223        }
224    }
225
226    // ----------------------------------------------------------------
227    // BluerAcceptor
228    // ----------------------------------------------------------------
229
230    /// Acceptor wrapping a bluer L2CAP SeqPacketListener.
231    pub struct BluerAcceptor {
232        listener: SeqPacketListener,
233        adapter_name: String,
234        psm: u16,
235    }
236
237    impl BleAcceptor for BluerAcceptor {
238        type Stream = BluerStream;
239
240        async fn accept(&mut self) -> Result<BluerStream, TransportError> {
241            let (conn, peer_sa) = self
242                .listener
243                .accept()
244                .await
245                .map_err(|e| map_io_err("accept", e))?;
246
247            let remote = BleAddr::from_bluer(peer_sa.addr, &self.adapter_name);
248            BluerStream::new(conn, remote)
249        }
250
251        fn psm(&self) -> u16 {
252            self.psm
253        }
254    }
255
256    // ----------------------------------------------------------------
257    // BluerScanner
258    // ----------------------------------------------------------------
259
260    /// Scanner wrapping a bluer discovery event stream.
261    pub struct BluerScanner {
262        events: Pin<Box<dyn futures::Stream<Item = AdapterEvent> + Send>>,
263        adapter: bluer::Adapter,
264        adapter_name: String,
265        seeded: VecDeque<bluer::Address>,
266    }
267
268    impl BleScanner for BluerScanner {
269        async fn next(&mut self) -> Option<BleCandidate> {
270            loop {
271                let addr = match self.seeded.pop_front() {
272                    Some(addr) => addr,
273                    None => match self.events.next().await {
274                        Some(AdapterEvent::DeviceAdded(addr)) => addr,
275                        Some(_) => continue,
276                        None => return None,
277                    },
278                };
279                let Ok(device) = self.adapter.device(addr) else {
280                    continue;
281                };
282                match device.uuids().await {
283                    Ok(Some(uuids)) if uuids.contains(&FIPS_SERVICE_UUID) => {}
284                    Ok(_) => {
285                        trace!(addr = %addr, "BLE scanner: device without FIPS UUID");
286                        continue;
287                    }
288                    Err(error) => {
289                        trace!(addr = %addr, %error, "BLE scanner: failed to read UUIDs");
290                        continue;
291                    }
292                }
293                match read_bootstrap(&device).await {
294                    Ok(bootstrap) => {
295                        let addr = BleAddr::from_bluer(addr, &self.adapter_name);
296                        debug!(addr = %addr, psm = bootstrap.psm, "BLE scanner: FIPS peer found");
297                        return Some(BleCandidate { addr, bootstrap });
298                    }
299                    Err(error) => {
300                        trace!(addr = %addr, %error, "BLE scanner: bootstrap read failed");
301                    }
302                }
303            }
304        }
305    }
306
307    async fn read_bootstrap(device: &bluer::Device) -> Result<BleBootstrap, TransportError> {
308        tokio::time::timeout(
309            std::time::Duration::from_secs(10),
310            read_bootstrap_inner(device),
311        )
312        .await
313        .map_err(|_| TransportError::Timeout)?
314    }
315
316    async fn read_bootstrap_inner(device: &bluer::Device) -> Result<BleBootstrap, TransportError> {
317        if !device
318            .is_connected()
319            .await
320            .map_err(|error| map_err("is_connected", error))?
321            && let Err(error) = device.connect().await
322            && !device.is_connected().await.unwrap_or(false)
323        {
324            return Err(map_err("GATT connect", error));
325        }
326        for service in device
327            .services()
328            .await
329            .map_err(|error| map_err("enumerate GATT services", error))?
330        {
331            if service
332                .uuid()
333                .await
334                .map_err(|error| map_err("read GATT service UUID", error))?
335                != FIPS_SERVICE_UUID
336            {
337                continue;
338            }
339            for characteristic in service
340                .characteristics()
341                .await
342                .map_err(|error| map_err("enumerate GATT characteristics", error))?
343            {
344                if characteristic
345                    .uuid()
346                    .await
347                    .map_err(|error| map_err("read GATT characteristic UUID", error))?
348                    == FIPS_BOOTSTRAP_CHARACTERISTIC_UUID
349                {
350                    let bytes = characteristic
351                        .read()
352                        .await
353                        .map_err(|error| map_err("read BLE bootstrap", error))?;
354                    return BleBootstrap::decode(&bytes).map_err(|error| {
355                        TransportError::RecvFailed(format!("invalid BLE bootstrap: {error}"))
356                    });
357                }
358            }
359        }
360        Err(TransportError::RecvFailed(
361            "FIPS BLE bootstrap characteristic not found".into(),
362        ))
363    }
364
365    // ----------------------------------------------------------------
366    // BluerIo
367    // ----------------------------------------------------------------
368
369    /// Production BLE I/O implementation via BlueZ D-Bus (bluer crate).
370    pub struct BluerIo {
371        _session: bluer::Session,
372        adapter: bluer::Adapter,
373        adapter_name: String,
374        adv_handle: Mutex<Option<bluer::adv::AdvertisementHandle>>,
375        gatt_handle: Mutex<Option<bluer::gatt::local::ApplicationHandle>>,
376        mtu: u16,
377    }
378
379    impl BluerIo {
380        /// Create a new BluerIo for the given adapter.
381        ///
382        /// Connects to BlueZ via D-Bus and powers on the adapter.
383        pub async fn new(adapter_name: &str, mtu: u16) -> Result<Self, TransportError> {
384            let session = bluer::Session::new()
385                .await
386                .map_err(|e| map_err("Session::new", e))?;
387
388            let adapter = if adapter_name == "default" {
389                session
390                    .default_adapter()
391                    .await
392                    .map_err(|e| map_err("default_adapter", e))?
393            } else {
394                session
395                    .adapter(adapter_name)
396                    .map_err(|e| map_err("adapter", e))?
397            };
398
399            adapter
400                .set_powered(true)
401                .await
402                .map_err(|e| map_err("set_powered", e))?;
403
404            let name = adapter.name().to_string();
405            debug!(adapter = %name, "BluerIo initialized");
406
407            Ok(Self {
408                _session: session,
409                adapter,
410                adapter_name: name,
411                adv_handle: Mutex::new(None),
412                gatt_handle: Mutex::new(None),
413                mtu,
414            })
415        }
416    }
417
418    impl BleIo for BluerIo {
419        type Stream = BluerStream;
420        type Acceptor = BluerAcceptor;
421        type Scanner = BluerScanner;
422
423        async fn listen(&self, psm: u16) -> Result<Self::Acceptor, TransportError> {
424            let local_addr = self
425                .adapter
426                .address()
427                .await
428                .map_err(|e| map_err("address", e))?;
429
430            let sa = SocketAddr::new(local_addr, AddressType::LePublic, psm);
431            let listener = SeqPacketListener::bind(sa)
432                .await
433                .map_err(|e| map_io_err("bind", e))?;
434
435            // Request high MTU for accepted connections
436            listener
437                .as_ref()
438                .set_recv_mtu(self.mtu)
439                .map_err(|e| map_io_err("set_recv_mtu", e))?;
440
441            // Prevent sniff mode to reduce latency during data transfer
442            if let Err(e) = listener.as_ref().set_power_forced_active(true) {
443                debug!(error = %e, "BLE listener: set_power_forced_active not supported");
444            }
445
446            debug!(psm, mtu = self.mtu, "BLE listener bound");
447
448            Ok(BluerAcceptor {
449                listener,
450                adapter_name: self.adapter_name.clone(),
451                psm,
452            })
453        }
454
455        async fn connect(&self, addr: &BleAddr, psm: u16) -> Result<Self::Stream, TransportError> {
456            let target_sa = addr.to_socket_addr(psm)?;
457
458            let socket = Socket::<SeqPacket>::new_seq_packet()
459                .map_err(|e| map_io_err("new_seq_packet", e))?;
460            socket
461                .bind(SocketAddr::any_le())
462                .map_err(|e| map_io_err("bind", e))?;
463            socket
464                .set_recv_mtu(self.mtu)
465                .map_err(|e| map_io_err("set_recv_mtu", e))?;
466
467            // Prevent sniff mode to reduce latency during data transfer
468            if let Err(e) = socket.set_power_forced_active(true) {
469                debug!(error = %e, "BLE connect: set_power_forced_active not supported");
470            }
471
472            let conn = socket
473                .connect(target_sa)
474                .await
475                .map_err(|e| map_io_err("connect", e))?;
476
477            let remote = addr.clone();
478            BluerStream::new(conn, remote)
479        }
480
481        async fn start_advertising(&self, bootstrap: BleBootstrap) -> Result<(), TransportError> {
482            let bootstrap_bytes = bootstrap.encode().to_vec();
483            let app = Application {
484                services: vec![Service {
485                    uuid: FIPS_SERVICE_UUID,
486                    primary: true,
487                    characteristics: vec![Characteristic {
488                        uuid: FIPS_BOOTSTRAP_CHARACTERISTIC_UUID,
489                        read: Some(CharacteristicRead {
490                            read: true,
491                            fun: Box::new(move |_| {
492                                let value = bootstrap_bytes.clone();
493                                async move { Ok(value) }.boxed()
494                            }),
495                            ..Default::default()
496                        }),
497                        ..Default::default()
498                    }],
499                    ..Default::default()
500                }],
501                ..Default::default()
502            };
503            let gatt_handle = self
504                .adapter
505                .serve_gatt_application(app)
506                .await
507                .map_err(|error| map_err("serve BLE bootstrap GATT service", error))?;
508            let adv = Advertisement {
509                advertisement_type: bluer::adv::Type::Peripheral,
510                service_uuids: {
511                    let mut s = BTreeSet::new();
512                    s.insert(FIPS_SERVICE_UUID);
513                    s
514                },
515                local_name: Some("fips".to_string()),
516                min_interval: Some(std::time::Duration::from_millis(400)),
517                max_interval: Some(std::time::Duration::from_millis(600)),
518                ..Default::default()
519            };
520
521            let handle = match self.adapter.advertise(adv).await {
522                Ok(handle) => handle,
523                Err(error) => {
524                    drop(gatt_handle);
525                    return Err(map_err("advertise", error));
526                }
527            };
528
529            *self.gatt_handle.lock().await = Some(gatt_handle);
530            *self.adv_handle.lock().await = Some(handle);
531            debug!(
532                psm = bootstrap.psm,
533                max_packet = bootstrap.max_packet,
534                "BLE advertising started"
535            );
536            Ok(())
537        }
538
539        async fn stop_advertising(&self) -> Result<(), TransportError> {
540            let _ = self.adv_handle.lock().await.take();
541            let _ = self.gatt_handle.lock().await.take();
542            debug!("BLE advertising stopped");
543            Ok(())
544        }
545
546        async fn start_scanning(&self) -> Result<Self::Scanner, TransportError> {
547            // Set discovery filter for LE transport with FIPS UUID
548            let filter = DiscoveryFilter {
549                transport: DiscoveryTransport::Le,
550                uuids: {
551                    let mut s = HashSet::new();
552                    s.insert(FIPS_SERVICE_UUID);
553                    s
554                },
555                ..Default::default()
556            };
557
558            self.adapter
559                .set_discovery_filter(filter)
560                .await
561                .map_err(|e| map_err("set_discovery_filter", e))?;
562
563            let events = self
564                .adapter
565                .discover_devices()
566                .await
567                .map_err(|e| map_err("discover_devices", e))?;
568
569            // Seed already-known matching devices without removing them.
570            // Removing a BlueZ device can also remove pairing information.
571            let mut seeded = VecDeque::new();
572            if let Ok(cached) = self.adapter.device_addresses().await {
573                for addr in cached {
574                    let Ok(device) = self.adapter.device(addr) else {
575                        continue;
576                    };
577                    if device
578                        .uuids()
579                        .await
580                        .ok()
581                        .flatten()
582                        .is_some_and(|uuids| uuids.contains(&FIPS_SERVICE_UUID))
583                    {
584                        seeded.push_back(addr);
585                    }
586                }
587            }
588
589            debug!("BLE scanning started");
590
591            Ok(BluerScanner {
592                events: Box::pin(events),
593                adapter: self.adapter.clone(),
594                adapter_name: self.adapter_name.clone(),
595                seeded,
596            })
597        }
598
599        fn local_addr(&self) -> Result<BleAddr, TransportError> {
600            // Use futures::executor::block_on since this is a sync method
601            // but needs an async call. The adapter address is cached so
602            // the D-Bus call is fast.
603            let addr = futures::executor::block_on(self.adapter.address())
604                .map_err(|e| map_err("address", e))?;
605            Ok(BleAddr::from_bluer(addr, &self.adapter_name))
606        }
607
608        fn adapter_name(&self) -> &str {
609            &self.adapter_name
610        }
611    }
612
613    // Compile-time assertion that BluerIo satisfies Send + Sync.
614    const _: () = {
615        fn require<T: Send + Sync>() {}
616        let _ = require::<BluerIo>;
617    };
618}
619
620#[cfg(bluer_available)]
621pub use bluer_impl::{BluerAcceptor, BluerIo, BluerScanner, BluerStream, FIPS_SERVICE_UUID};
622
623// ============================================================================
624// Mock BLE I/O (for testing without hardware)
625// ============================================================================
626
627/// Mock BLE stream backed by tokio channels.
628pub struct MockBleStream {
629    addr: BleAddr,
630    send_mtu: u16,
631    recv_mtu: u16,
632    tx: tokio::sync::mpsc::Sender<Vec<u8>>,
633    rx: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Vec<u8>>>,
634}
635
636impl MockBleStream {
637    /// Create a linked pair of mock streams simulating an L2CAP connection.
638    pub fn pair(addr_a: BleAddr, addr_b: BleAddr, mtu: u16) -> (Self, Self) {
639        let (tx_a, rx_a) = tokio::sync::mpsc::channel(64);
640        let (tx_b, rx_b) = tokio::sync::mpsc::channel(64);
641        let stream_a = Self {
642            addr: addr_b.clone(),
643            send_mtu: mtu,
644            recv_mtu: mtu,
645            tx: tx_a,
646            rx: tokio::sync::Mutex::new(rx_b),
647        };
648        let stream_b = Self {
649            addr: addr_a,
650            send_mtu: mtu,
651            recv_mtu: mtu,
652            tx: tx_b,
653            rx: tokio::sync::Mutex::new(rx_a),
654        };
655        (stream_a, stream_b)
656    }
657}
658
659impl BleStream for MockBleStream {
660    async fn send(&self, data: &[u8]) -> Result<(), TransportError> {
661        self.tx
662            .send(data.to_vec())
663            .await
664            .map_err(|_| TransportError::SendFailed("channel closed".into()))
665    }
666
667    async fn recv(&self, buf: &mut [u8]) -> Result<usize, TransportError> {
668        let mut rx = self.rx.lock().await;
669        match rx.recv().await {
670            Some(data) => {
671                let len = data.len().min(buf.len());
672                buf[..len].copy_from_slice(&data[..len]);
673                Ok(len)
674            }
675            None => Ok(0), // channel closed = connection closed = zero-length read
676        }
677    }
678
679    fn send_mtu(&self) -> u16 {
680        self.send_mtu
681    }
682
683    fn recv_mtu(&self) -> u16 {
684        self.recv_mtu
685    }
686
687    fn remote_addr(&self) -> &BleAddr {
688        &self.addr
689    }
690}
691
692/// Mock BLE acceptor backed by a channel of pre-connected streams.
693pub struct MockBleAcceptor {
694    rx: tokio::sync::mpsc::Receiver<MockBleStream>,
695    psm: u16,
696}
697
698impl BleAcceptor for MockBleAcceptor {
699    type Stream = MockBleStream;
700
701    async fn accept(&mut self) -> Result<MockBleStream, TransportError> {
702        self.rx
703            .recv()
704            .await
705            .ok_or(TransportError::RecvFailed("acceptor channel closed".into()))
706    }
707
708    fn psm(&self) -> u16 {
709        self.psm
710    }
711}
712
713/// Mock BLE scanner backed by a channel of discovered addresses.
714pub struct MockBleScanner {
715    rx: tokio::sync::mpsc::Receiver<BleCandidate>,
716}
717
718impl BleScanner for MockBleScanner {
719    async fn next(&mut self) -> Option<BleCandidate> {
720        self.rx.recv().await
721    }
722}
723
724/// Handler type for outbound mock connections.
725type ConnectHandler =
726    Box<dyn Fn(&BleAddr, u16) -> Result<MockBleStream, TransportError> + Send + Sync>;
727
728/// Mock BLE I/O for testing without hardware.
729///
730/// Create with `MockBleIo::new()`, then use `inject_*` methods to
731/// feed connections and scan results into the transport under test.
732pub struct MockBleIo {
733    adapter: String,
734    local_addr: BleAddr,
735    accept_tx: tokio::sync::mpsc::Sender<MockBleStream>,
736    accept_rx: std::sync::Mutex<Option<tokio::sync::mpsc::Receiver<MockBleStream>>>,
737    scan_tx: tokio::sync::mpsc::Sender<BleCandidate>,
738    scan_rx: std::sync::Mutex<Option<tokio::sync::mpsc::Receiver<BleCandidate>>>,
739    connect_handler: std::sync::Mutex<Option<ConnectHandler>>,
740    assigned_psm: u16,
741    advertised_bootstrap: std::sync::Mutex<Option<BleBootstrap>>,
742}
743
744impl MockBleIo {
745    /// Create a new mock BLE I/O with the given adapter name and address.
746    pub fn new(adapter: &str, local_addr: BleAddr) -> Self {
747        let (accept_tx, accept_rx) = tokio::sync::mpsc::channel(16);
748        let (scan_tx, scan_rx) = tokio::sync::mpsc::channel(64);
749        Self {
750            adapter: adapter.to_string(),
751            local_addr,
752            accept_tx,
753            accept_rx: std::sync::Mutex::new(Some(accept_rx)),
754            scan_tx,
755            scan_rx: std::sync::Mutex::new(Some(scan_rx)),
756            connect_handler: std::sync::Mutex::new(None),
757            assigned_psm: DEFAULT_PSM,
758            advertised_bootstrap: std::sync::Mutex::new(None),
759        }
760    }
761
762    /// Override the listener PSM to model platforms that allocate it.
763    pub fn with_listener_psm(mut self, psm: u16) -> Self {
764        self.assigned_psm = psm;
765        self
766    }
767
768    /// Inject an inbound connection (simulates a remote device connecting).
769    pub async fn inject_inbound(&self, stream: MockBleStream) {
770        let _ = self.accept_tx.send(stream).await;
771    }
772
773    /// Inject a scan result (simulates discovering a remote device).
774    pub async fn inject_scan_result(&self, addr: BleAddr) {
775        let _ = self
776            .scan_tx
777            .send(BleCandidate {
778                addr,
779                bootstrap: BleBootstrap::new(DEFAULT_PSM, 2048)
780                    .expect("default mock bootstrap is valid"),
781            })
782            .await;
783    }
784
785    /// Inject a complete BLE v2 bootstrap discovery record.
786    pub async fn inject_scan_candidate(&self, candidate: BleCandidate) {
787        let _ = self.scan_tx.send(candidate).await;
788    }
789
790    /// Last bootstrap value passed to the mock advertiser.
791    pub fn advertised_bootstrap(&self) -> Option<BleBootstrap> {
792        *self
793            .advertised_bootstrap
794            .lock()
795            .unwrap_or_else(|error| error.into_inner())
796    }
797
798    /// Set a handler for outbound connect calls.
799    pub fn set_connect_handler<F>(&self, handler: F)
800    where
801        F: Fn(&BleAddr, u16) -> Result<MockBleStream, TransportError> + Send + Sync + 'static,
802    {
803        *self
804            .connect_handler
805            .lock()
806            .unwrap_or_else(|e| e.into_inner()) = Some(Box::new(handler));
807    }
808}
809
810impl BleIo for MockBleIo {
811    type Stream = MockBleStream;
812    type Acceptor = MockBleAcceptor;
813    type Scanner = MockBleScanner;
814
815    async fn listen(&self, _psm: u16) -> Result<Self::Acceptor, TransportError> {
816        let rx = self
817            .accept_rx
818            .lock()
819            .unwrap_or_else(|e| e.into_inner())
820            .take()
821            .ok_or_else(|| TransportError::NotSupported("acceptor already taken".into()))?;
822        Ok(MockBleAcceptor {
823            rx,
824            psm: self.assigned_psm,
825        })
826    }
827
828    async fn connect(&self, addr: &BleAddr, psm: u16) -> Result<Self::Stream, TransportError> {
829        let handler = self
830            .connect_handler
831            .lock()
832            .unwrap_or_else(|e| e.into_inner());
833        match handler.as_ref() {
834            Some(f) => f(addr, psm),
835            None => Err(TransportError::ConnectionRefused),
836        }
837    }
838
839    async fn start_advertising(&self, bootstrap: BleBootstrap) -> Result<(), TransportError> {
840        *self
841            .advertised_bootstrap
842            .lock()
843            .unwrap_or_else(|error| error.into_inner()) = Some(bootstrap);
844        Ok(())
845    }
846
847    async fn stop_advertising(&self) -> Result<(), TransportError> {
848        Ok(())
849    }
850
851    async fn start_scanning(&self) -> Result<Self::Scanner, TransportError> {
852        let rx = self
853            .scan_rx
854            .lock()
855            .unwrap_or_else(|e| e.into_inner())
856            .take()
857            .ok_or_else(|| TransportError::NotSupported("scanner already taken".into()))?;
858        Ok(MockBleScanner { rx })
859    }
860
861    fn local_addr(&self) -> Result<BleAddr, TransportError> {
862        Ok(self.local_addr.clone())
863    }
864
865    fn adapter_name(&self) -> &str {
866        &self.adapter
867    }
868}
869
870#[cfg(test)]
871mod tests;