Skip to main content

hackrf_nusb/
radio.rs

1//! Explicit half-duplex direction control and the TX bulk-OUT transport.
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
5#[cfg(not(target_arch = "wasm32"))]
6use std::time::{Duration, Instant};
7
8use crate::Complex32;
9use crate::commands::TransceiverMode;
10use crate::constants::{TRANSFER_COUNT, TRANSFER_SIZE};
11use crate::device::HackRf;
12use crate::errors::{Error, Result};
13use crate::usb::{NusbBulkOut, NusbControl};
14#[cfg(not(target_arch = "wasm32"))]
15use nusb::MaybeFuture;
16
17/// The physical direction selected by a stream operation.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub(crate) enum Direction {
20    /// Receive from the radio.
21    Rx,
22    /// Transmit to the radio.
23    Tx,
24}
25
26impl Direction {
27    fn transceiver_mode(self) -> TransceiverMode {
28        match self {
29            Self::Rx => TransceiverMode::Receive,
30            Self::Tx => TransceiverMode::Transmit,
31        }
32    }
33}
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36enum DeviceState {
37    Open,
38    Closed,
39}
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42enum Phase {
43    Off,
44    Starting(Direction),
45    Active(Direction),
46    Stopping(Direction),
47    Recovery(Direction),
48}
49
50#[derive(Debug)]
51struct ControllerState {
52    device: DeviceState,
53    phase: Phase,
54    control_in_progress: bool,
55    style: Option<ExecutionStyle>,
56    rx_stream_claimed: bool,
57    tx_stream_claimed: bool,
58    // WebUSB cannot cancel the browser's pending bulk-IN promises. Once the
59    // queue-owning RX stream is dropped, reopening a queue on the same USB
60    // device would leave two independent queues behind.
61    #[cfg(target_arch = "wasm32")]
62    rx_queue_abandoned: bool,
63}
64
65impl Default for ControllerState {
66    fn default() -> Self {
67        Self {
68            device: DeviceState::Open,
69            phase: Phase::Off,
70            control_in_progress: false,
71            style: None,
72            rx_stream_claimed: false,
73            tx_stream_claimed: false,
74            #[cfg(target_arch = "wasm32")]
75            rx_queue_abandoned: false,
76        }
77    }
78}
79
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub(crate) enum ExecutionStyle {
82    #[cfg(not(target_arch = "wasm32"))]
83    Blocking,
84    Async,
85}
86
87/// Counters collected during one TX `start`/`stop` period.
88#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
89pub struct TxStreamingStats {
90    /// Complex application samples accepted by the driver.
91    pub samples_accepted: u64,
92    /// Bulk-OUT transfers submitted to the USB backend.
93    pub buffers_submitted: u64,
94    /// Bulk-OUT transfers completed successfully.
95    pub buffers_completed: u64,
96    /// Zero-filled terminal flush transfers submitted.
97    ///
98    /// A flush is submitted explicitly with `end_burst` or automatically by
99    /// `TxStream::stop` when accepted samples still need a boundary.
100    pub flush_buffers: u64,
101}
102
103#[derive(Debug, Default)]
104struct TxTail {
105    flush_required: bool,
106    terminal_flush_pending: bool,
107}
108
109impl TxTail {
110    fn submitted_samples(&mut self) {
111        self.flush_required = true;
112    }
113
114    fn submitted_flush(&mut self) {
115        self.flush_required = false;
116        self.terminal_flush_pending = false;
117    }
118
119    fn flush_required(&self) -> bool {
120        self.flush_required
121    }
122
123    fn require_terminal_flush(&mut self) {
124        self.flush_required = true;
125        self.terminal_flush_pending = true;
126    }
127
128    fn terminal_flush_pending(&self) -> bool {
129        self.terminal_flush_pending
130    }
131}
132
133/// The TX endpoint and its in-flight transfers.
134///
135/// This lives in `TxStream` between successful `start` and `stop` calls, so
136/// the TX write path has no controller lock.
137#[derive(Debug)]
138pub(crate) struct TxTransport {
139    endpoint: NusbBulkOut,
140    flush_size: usize,
141    stats: TxStreamingStats,
142    tail: TxTail,
143    unusable: bool,
144}
145
146impl TxTransport {
147    fn new(endpoint: NusbBulkOut, flush_size: usize) -> Self {
148        Self {
149            endpoint,
150            flush_size,
151            stats: TxStreamingStats::default(),
152            tail: TxTail::default(),
153            unusable: false,
154        }
155    }
156
157    fn reset(&mut self) {
158        self.stats = TxStreamingStats::default();
159        self.tail = TxTail::default();
160    }
161
162    pub(crate) fn stats(&self) -> TxStreamingStats {
163        self.stats
164    }
165
166    /// A drain failure leaves outstanding endpoint transfers with no safe
167    /// cancellation path. Such a transport must never be reused.
168    pub(crate) fn mark_unusable(&mut self) {
169        self.unusable = true;
170    }
171
172    pub(crate) fn is_reusable(&self) -> bool {
173        !self.unusable
174    }
175
176    fn submit_samples(&mut self, samples: &[Complex32]) {
177        let mut bytes = Vec::with_capacity((samples.len() * 2).next_multiple_of(512));
178        for sample in samples {
179            bytes.push(f32_to_cs8(sample.re) as u8);
180            bytes.push(f32_to_cs8(sample.im) as u8);
181        }
182        bytes.resize(bytes.len().next_multiple_of(512), 0);
183        self.endpoint.submit(bytes);
184        self.tail.submitted_samples();
185        self.stats.samples_accepted += samples.len() as u64;
186        self.stats.buffers_submitted += 1;
187    }
188
189    fn submit_flush(&mut self) {
190        self.endpoint.submit(vec![0; self.flush_size]);
191        self.tail.submitted_flush();
192        self.stats.buffers_submitted += 1;
193        self.stats.flush_buffers += 1;
194    }
195
196    fn flush_if_needed(&mut self) {
197        if self.tail.flush_required() {
198            self.submit_flush();
199        }
200    }
201
202    #[cfg(not(target_arch = "wasm32"))]
203    fn complete_one_blocking(&mut self, timeout: Duration) -> Result<bool> {
204        match self.endpoint.wait_next_complete(timeout) {
205            Some(result) => {
206                result?;
207                self.stats.buffers_completed += 1;
208                Ok(true)
209            }
210            None => Ok(false),
211        }
212    }
213
214    async fn complete_one_async(&mut self) -> Result<()> {
215        self.endpoint.next_complete().await?;
216        self.stats.buffers_completed += 1;
217        Ok(())
218    }
219
220    #[cfg(not(target_arch = "wasm32"))]
221    pub(crate) fn clear_halt_blocking(&mut self) -> Result<()> {
222        self.endpoint.clear_halt_blocking()
223    }
224
225    pub(crate) async fn clear_halt_async(&mut self) -> Result<()> {
226        self.endpoint.clear_halt_async().await
227    }
228
229    #[cfg(not(target_arch = "wasm32"))]
230    pub(crate) fn write_blocking(
231        &mut self,
232        samples: &[Complex32],
233        timeout: Duration,
234        end_burst: bool,
235    ) -> Result<usize> {
236        let deadline = (timeout != Duration::MAX).then(|| Instant::now() + timeout);
237        let had_pending_terminal_flush = self.tail.terminal_flush_pending();
238        if had_pending_terminal_flush {
239            while self.endpoint.pending() >= TRANSFER_COUNT {
240                if !self.complete_one_blocking(remaining(deadline))? {
241                    return Ok(0);
242                }
243            }
244            self.submit_flush();
245        }
246        if samples.is_empty() && had_pending_terminal_flush {
247            return Ok(0);
248        }
249
250        let mut accepted = 0;
251        for chunk in samples.chunks(TRANSFER_SIZE / 2) {
252            while self.endpoint.pending() >= TRANSFER_COUNT {
253                if !self.complete_one_blocking(remaining(deadline))? {
254                    if end_burst && accepted != 0 {
255                        self.tail.require_terminal_flush();
256                    }
257                    return Ok(accepted);
258                }
259            }
260            self.submit_samples(chunk);
261            accepted += chunk.len();
262        }
263        if end_burst && (accepted != 0 || samples.is_empty()) {
264            self.tail.require_terminal_flush();
265            while self.endpoint.pending() >= TRANSFER_COUNT {
266                if !self.complete_one_blocking(remaining(deadline))? {
267                    return Ok(accepted);
268                }
269            }
270            self.submit_flush();
271        }
272        Ok(accepted)
273    }
274
275    pub(crate) async fn write_async(
276        &mut self,
277        samples: &[Complex32],
278        end_burst: bool,
279    ) -> Result<usize> {
280        let had_pending_terminal_flush = self.tail.terminal_flush_pending();
281        if had_pending_terminal_flush {
282            while self.endpoint.pending() >= TRANSFER_COUNT {
283                self.complete_one_async().await?;
284            }
285            self.submit_flush();
286        }
287        if samples.is_empty() && had_pending_terminal_flush {
288            return Ok(0);
289        }
290
291        let mut accepted = 0;
292        for chunk in samples.chunks(TRANSFER_SIZE / 2) {
293            while self.endpoint.pending() >= TRANSFER_COUNT {
294                self.complete_one_async().await?;
295            }
296            self.submit_samples(chunk);
297            accepted += chunk.len();
298            // From this point cancellation must leave an explicit boundary
299            // behind: the next write or stop flushes it before accepting
300            // anything new. Mark it immediately after the first accepted
301            // samples, before the next await can observe cancellation.
302            if end_burst && accepted == chunk.len() {
303                self.tail.require_terminal_flush();
304            }
305        }
306        if end_burst && (accepted != 0 || samples.is_empty()) {
307            // An empty terminal write has no accepted sample after which to
308            // record the boundary, so record it before its possible await.
309            if samples.is_empty() {
310                self.tail.require_terminal_flush();
311            }
312            while self.endpoint.pending() >= TRANSFER_COUNT {
313                self.complete_one_async().await?;
314            }
315            self.submit_flush();
316        }
317        Ok(accepted)
318    }
319
320    #[cfg(not(target_arch = "wasm32"))]
321    pub(crate) fn flush_and_drain_blocking(&mut self) -> Result<()> {
322        // Keep the terminal flush adjacent to the queued burst. This may
323        // temporarily exceed the normal TRANSFER_COUNT write water mark by
324        // one transfer before the drain below reclaims completions.
325        self.flush_if_needed();
326        while self.endpoint.pending() != 0 {
327            self.complete_one_blocking(Duration::MAX)?;
328        }
329        Ok(())
330    }
331
332    pub(crate) async fn flush_and_drain_async(&mut self) -> Result<()> {
333        // See the blocking counterpart for why the terminal flush is queued
334        // before draining, even when the normal water mark is full.
335        self.flush_if_needed();
336        while self.endpoint.pending() != 0 {
337            self.complete_one_async().await?;
338        }
339        Ok(())
340    }
341}
342
343fn f32_to_cs8(value: f32) -> i8 {
344    if !value.is_finite() {
345        0
346    } else {
347        (value * 128.0).round().clamp(-128.0, 127.0) as i8
348    }
349}
350
351#[cfg(not(target_arch = "wasm32"))]
352fn remaining(deadline: Option<Instant>) -> Duration {
353    deadline.map_or(Duration::MAX, |value| {
354        value.saturating_duration_since(Instant::now())
355    })
356}
357
358/// Shared slow-path direction controller.
359///
360/// Its mutex is taken only to claim or finish `start`, `stop`, and
361/// configuration operations. The controller deliberately never changes the
362/// selected direction on behalf of a read or write.
363#[derive(Debug)]
364pub(crate) struct LifecycleController {
365    direct: HackRf<NusbControl>,
366    bias_tee: AtomicBool,
367    state: Mutex<ControllerState>,
368    transport: Mutex<Option<TxTransport>>,
369}
370
371/// Keeps one logical stream object claimed for its lifetime.
372#[derive(Debug)]
373pub(crate) struct StreamClaim {
374    radio: Arc<LifecycleController>,
375    direction: Direction,
376}
377
378impl Drop for StreamClaim {
379    fn drop(&mut self) {
380        let mut state = lock(&self.radio.state);
381        match self.direction {
382            Direction::Rx => state.rx_stream_claimed = false,
383            Direction::Tx => state.tx_stream_claimed = false,
384        }
385    }
386}
387
388/// Owns a claimed direction until hardware start commits or the attempt is
389/// abandoned. An abandoned operation requires an explicit stream `stop`.
390#[derive(Debug)]
391pub(crate) struct StartTransition {
392    radio: Arc<LifecycleController>,
393    direction: Direction,
394    complete: bool,
395}
396
397impl StartTransition {
398    #[cfg(not(target_arch = "wasm32"))]
399    pub(crate) fn finish_blocking(&mut self) -> Result<()> {
400        self.radio
401            .direct
402            .set_transceiver_mode(self.direction.transceiver_mode())
403            .wait()?;
404        self.radio
405            .direct
406            .set_bias_tee(self.radio.bias_tee.load(Ordering::Acquire))
407            .wait()?;
408        self.finish_success()
409    }
410
411    pub(crate) async fn finish_async(&mut self) -> Result<()> {
412        self.radio
413            .direct
414            .set_transceiver_mode(self.direction.transceiver_mode())
415            .await?;
416        self.radio
417            .direct
418            .set_bias_tee(self.radio.bias_tee.load(Ordering::Acquire))
419            .await?;
420        self.finish_success()
421    }
422
423    fn finish_success(&mut self) -> Result<()> {
424        let mut state = lock(&self.radio.state);
425        if state.phase != Phase::Starting(self.direction) {
426            return Err(Error::Busy);
427        }
428        state.phase = Phase::Active(self.direction);
429        self.complete = true;
430        Ok(())
431    }
432}
433
434impl Drop for StartTransition {
435    fn drop(&mut self) {
436        if !self.complete {
437            let mut state = lock(&self.radio.state);
438            if state.phase == Phase::Starting(self.direction) {
439                state.phase = Phase::Recovery(self.direction);
440            }
441        }
442    }
443}
444
445/// Owns a claimed stop/recovery attempt until off mode commits.
446#[derive(Debug)]
447pub(crate) struct StopTransition {
448    radio: Arc<LifecycleController>,
449    direction: Direction,
450    complete: bool,
451}
452
453impl StopTransition {
454    #[cfg(not(target_arch = "wasm32"))]
455    pub(crate) fn finish_blocking(&mut self) -> Result<()> {
456        self.radio
457            .direct
458            .set_transceiver_mode(TransceiverMode::Off)
459            .wait()?;
460        self.finish_success()
461    }
462
463    pub(crate) async fn finish_async(&mut self) -> Result<()> {
464        self.radio
465            .direct
466            .set_transceiver_mode(TransceiverMode::Off)
467            .await?;
468        self.finish_success()
469    }
470
471    fn finish_success(&mut self) -> Result<()> {
472        let mut state = lock(&self.radio.state);
473        if state.phase != Phase::Stopping(self.direction) {
474            return Err(Error::Busy);
475        }
476        state.phase = Phase::Off;
477        self.complete = true;
478        Ok(())
479    }
480}
481
482impl Drop for StopTransition {
483    fn drop(&mut self) {
484        if !self.complete {
485            let mut state = lock(&self.radio.state);
486            if state.phase == Phase::Stopping(self.direction) {
487                state.phase = Phase::Recovery(self.direction);
488            }
489        }
490    }
491}
492
493/// Holds the TX transport while its initial hardware start is in progress.
494#[derive(Debug)]
495pub(crate) struct TxStartTransition {
496    start: StartTransition,
497    transport: Option<TxTransport>,
498}
499
500impl TxStartTransition {
501    #[cfg(not(target_arch = "wasm32"))]
502    pub(crate) fn finish_blocking(&mut self) -> Result<TxTransport> {
503        self.transport
504            .as_mut()
505            .expect("TX start owns transport")
506            .clear_halt_blocking()?;
507        self.start.finish_blocking()?;
508        let transport = self.transport.as_mut().expect("TX start owns transport");
509        transport.reset();
510        Ok(self.transport.take().expect("TX start owns transport"))
511    }
512
513    pub(crate) async fn finish_async(&mut self) -> Result<TxTransport> {
514        self.transport
515            .as_mut()
516            .expect("TX start owns transport")
517            .clear_halt_async()
518            .await?;
519        self.start.finish_async().await?;
520        let transport = self.transport.as_mut().expect("TX start owns transport");
521        transport.reset();
522        Ok(self.transport.take().expect("TX start owns transport"))
523    }
524}
525
526impl Drop for TxStartTransition {
527    fn drop(&mut self) {
528        if let Some(transport) = self.transport.take() {
529            self.start.radio.put_transport(transport);
530        }
531    }
532}
533
534/// Serializes configuration with start and stop operations.
535#[derive(Debug)]
536pub(crate) struct ReconfigureGuard {
537    radio: Arc<LifecycleController>,
538}
539
540impl Drop for ReconfigureGuard {
541    fn drop(&mut self) {
542        let mut state = lock(&self.radio.state);
543        // Configuration does not change stream direction. The driver only
544        // caches settings after a successful request, so an error or a
545        // cancelled future may simply leave the cache unchanged.
546        state.control_in_progress = false;
547    }
548}
549
550impl LifecycleController {
551    #[cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))]
552    pub(crate) fn new(
553        direct: HackRf<NusbControl>,
554        endpoint: NusbBulkOut,
555        flush_size: usize,
556        bias_tee: bool,
557    ) -> Arc<Self> {
558        Arc::new(Self {
559            direct,
560            bias_tee: AtomicBool::new(bias_tee),
561            state: Mutex::new(ControllerState::default()),
562            transport: Mutex::new(Some(TxTransport::new(endpoint, flush_size))),
563        })
564    }
565
566    pub(crate) fn set_bias_tee(&self, enabled: bool) {
567        self.bias_tee.store(enabled, Ordering::Release);
568    }
569
570    pub(crate) fn claim_stream(self: &Arc<Self>, direction: Direction) -> Result<StreamClaim> {
571        let mut state = lock(&self.state);
572        if state.device != DeviceState::Open {
573            return Err(Error::DeviceClosed);
574        }
575        #[cfg(target_arch = "wasm32")]
576        if direction == Direction::Rx && state.rx_queue_abandoned {
577            return Err(Error::stream_closed(
578                "WebUSB RX queue was dropped; reopen the device before creating another RX stream",
579            ));
580        }
581        let claimed = match direction {
582            Direction::Rx => &mut state.rx_stream_claimed,
583            Direction::Tx => &mut state.tx_stream_claimed,
584        };
585        if *claimed {
586            return Err(Error::Busy);
587        }
588        *claimed = true;
589        Ok(StreamClaim {
590            radio: Arc::clone(self),
591            direction,
592        })
593    }
594
595    #[cfg(not(target_arch = "wasm32"))]
596    pub(crate) fn begin_rx_start_blocking(self: &Arc<Self>) -> Result<StartTransition> {
597        self.begin_start(Direction::Rx, ExecutionStyle::Blocking)
598    }
599
600    pub(crate) async fn begin_rx_start_async(self: &Arc<Self>) -> Result<StartTransition> {
601        self.begin_start(Direction::Rx, ExecutionStyle::Async)
602    }
603
604    #[cfg(not(target_arch = "wasm32"))]
605    pub(crate) fn begin_tx_start_blocking(self: &Arc<Self>) -> Result<TxStartTransition> {
606        self.begin_tx_start(ExecutionStyle::Blocking)
607    }
608
609    pub(crate) async fn begin_tx_start_async(self: &Arc<Self>) -> Result<TxStartTransition> {
610        self.begin_tx_start(ExecutionStyle::Async)
611    }
612
613    fn begin_tx_start(self: &Arc<Self>, style: ExecutionStyle) -> Result<TxStartTransition> {
614        let transport = lock(&self.transport).take().ok_or(Error::stream_closed(
615            "TX transport has unresolved transfers; reopen the device",
616        ))?;
617        let start = match self.begin_start(Direction::Tx, style) {
618            Ok(start) => start,
619            Err(error) => {
620                self.put_transport(transport);
621                return Err(error);
622            }
623        };
624        Ok(TxStartTransition {
625            start,
626            transport: Some(transport),
627        })
628    }
629
630    fn begin_start(
631        self: &Arc<Self>,
632        direction: Direction,
633        style: ExecutionStyle,
634    ) -> Result<StartTransition> {
635        let mut state = lock(&self.state);
636        if state.device != DeviceState::Open {
637            return Err(Error::DeviceClosed);
638        }
639        if state.control_in_progress || state.phase != Phase::Off {
640            return Err(Error::Busy);
641        }
642        match state.style {
643            Some(selected) if selected != style => return Err(Error::Busy),
644            Some(_) => {}
645            None => state.style = Some(style),
646        }
647        state.phase = Phase::Starting(direction);
648        Ok(StartTransition {
649            radio: Arc::clone(self),
650            direction,
651            complete: false,
652        })
653    }
654
655    #[cfg(not(target_arch = "wasm32"))]
656    pub(crate) fn begin_stop_blocking(
657        self: &Arc<Self>,
658        direction: Direction,
659    ) -> Result<Option<StopTransition>> {
660        self.begin_stop(direction, Some(ExecutionStyle::Blocking), false)
661    }
662
663    pub(crate) async fn begin_stop_async(
664        self: &Arc<Self>,
665        direction: Direction,
666    ) -> Result<Option<StopTransition>> {
667        self.begin_stop(direction, Some(ExecutionStyle::Async), false)
668    }
669
670    #[cfg(not(target_arch = "wasm32"))]
671    pub(crate) fn begin_stop_on_drop_blocking(
672        self: &Arc<Self>,
673        direction: Direction,
674    ) -> Result<Option<StopTransition>> {
675        self.begin_stop(direction, None, true)
676    }
677
678    fn begin_stop(
679        self: &Arc<Self>,
680        direction: Direction,
681        style: Option<ExecutionStyle>,
682        allow_closed_device: bool,
683    ) -> Result<Option<StopTransition>> {
684        let mut state = lock(&self.state);
685        if !allow_closed_device && state.device != DeviceState::Open {
686            return Err(Error::DeviceClosed);
687        }
688        if let Some(style) = style {
689            match state.style {
690                Some(selected) if selected != style => return Err(Error::Busy),
691                Some(_) | None => {}
692            }
693        }
694        match state.phase {
695            Phase::Off => Ok(None),
696            Phase::Active(active) if active == direction => {
697                state.phase = Phase::Stopping(direction);
698                Ok(Some(StopTransition {
699                    radio: Arc::clone(self),
700                    direction,
701                    complete: false,
702                }))
703            }
704            Phase::Recovery(owner) if owner == direction => {
705                state.phase = Phase::Stopping(direction);
706                Ok(Some(StopTransition {
707                    radio: Arc::clone(self),
708                    direction,
709                    complete: false,
710                }))
711            }
712            _ => Err(Error::Busy),
713        }
714    }
715
716    pub(crate) fn mark_stream_failed(&self, direction: Direction) {
717        let mut state = lock(&self.state);
718        if state.phase == Phase::Active(direction) {
719            state.phase = Phase::Recovery(direction);
720        }
721    }
722
723    /// WebUSB cannot cancel the browser-owned transfer promises that back an
724    /// RX queue. Prevent a replacement stream from creating a second queue
725    /// on this device session after the first queue owner has been dropped.
726    #[cfg(target_arch = "wasm32")]
727    pub(crate) fn mark_rx_queue_abandoned(&self) {
728        lock(&self.state).rx_queue_abandoned = true;
729    }
730
731    pub(crate) fn begin_reconfigure(
732        self: &Arc<Self>,
733        style: ExecutionStyle,
734    ) -> Result<ReconfigureGuard> {
735        let mut state = lock(&self.state);
736        if state.device != DeviceState::Open {
737            return Err(Error::DeviceClosed);
738        }
739        if state.control_in_progress || !matches!(state.phase, Phase::Off | Phase::Active(_)) {
740            return Err(Error::Busy);
741        }
742        match state.style {
743            Some(selected) if selected != style => return Err(Error::Busy),
744            Some(_) | None => {}
745        }
746        state.control_in_progress = true;
747        Ok(ReconfigureGuard {
748            radio: Arc::clone(self),
749        })
750    }
751
752    #[cfg(not(target_arch = "wasm32"))]
753    pub(crate) fn shutdown_blocking(&self) -> Result<()> {
754        self.begin_device_shutdown()?;
755        self.direct
756            .set_transceiver_mode(TransceiverMode::Off)
757            .wait()
758    }
759
760    pub(crate) async fn shutdown_async(&self) -> Result<()> {
761        self.begin_device_shutdown()?;
762        self.direct.set_transceiver_mode(TransceiverMode::Off).await
763    }
764
765    fn begin_device_shutdown(&self) -> Result<()> {
766        let mut state = lock(&self.state);
767        if state.device == DeviceState::Closed {
768            return Ok(());
769        }
770        if state.control_in_progress || state.phase != Phase::Off {
771            return Err(Error::Busy);
772        }
773        state.device = DeviceState::Closed;
774        Ok(())
775    }
776
777    pub(crate) fn close_on_drop(&self) {
778        lock(&self.state).device = DeviceState::Closed;
779    }
780
781    fn put_transport(&self, transport: TxTransport) {
782        let previous = lock(&self.transport).replace(transport);
783        debug_assert!(previous.is_none());
784    }
785
786    pub(crate) fn return_tx_transport(&self, transport: TxTransport) {
787        self.put_transport(transport);
788    }
789}
790
791fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
792    mutex.lock().unwrap_or_else(PoisonError::into_inner)
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    #[test]
800    fn tx_tail_requires_one_flush_after_samples() {
801        let mut tail = TxTail::default();
802        tail.submitted_samples();
803        assert!(tail.flush_required());
804        tail.require_terminal_flush();
805        assert!(tail.terminal_flush_pending());
806        tail.submitted_flush();
807        assert!(!tail.flush_required());
808        assert!(!tail.terminal_flush_pending());
809    }
810
811    #[test]
812    fn directions_are_distinct() {
813        assert_ne!(Direction::Rx, Direction::Tx);
814        assert_ne!(Phase::Active(Direction::Rx), Phase::Active(Direction::Tx));
815        assert_ne!(
816            Phase::Recovery(Direction::Rx),
817            Phase::Recovery(Direction::Tx)
818        );
819    }
820}