Skip to main content

librtlsdr_rs/
error.rs

1//! Error types for the RTL-SDR driver.
2//!
3//! Two enums:
4//! - [`RtlSdrError`] — the unified error type returned by every
5//!   fallible operation on the public API. `#[non_exhaustive]`
6//!   since 0.2 (per #16); always include a catch-all `_ => ...`
7//!   arm in exhaustive matches.
8//! - [`TunerError`] — typed sub-variant carried by
9//!   [`RtlSdrError::Tuner`] since 0.2 (was `String` in 0.1.x).
10//!   Lets consumers programmatically discriminate PLL-not-locked
11//!   from gain-out-of-range etc. without parsing message strings.
12
13/// Errors from RTL-SDR USB operations.
14///
15/// `Clone`, `PartialEq`, and `Eq` are derived to support the
16/// common consumer patterns of stashing the last error in an
17/// `Arc<Mutex<Option<RtlSdrError>>>`, snapshotting it across UI
18/// re-renders, or asserting equality in tests. Per #15.
19///
20/// `#[non_exhaustive]` so adding a new variant in a future patch
21/// release is non-breaking. Consumers should always include a
22/// catch-all `_ => ...` arm in exhaustive match. Per #16 / 0.2.
23#[non_exhaustive]
24#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
25pub enum RtlSdrError {
26    /// USB communication error.
27    #[error("USB error: {0}")]
28    Usb(#[from] rusb::Error),
29
30    /// Device not found at the specified index. **Struct variant
31    /// since 0.2** — was `DeviceNotFound(u32)` in 0.1.x.
32    #[error("device not found at index {index}")]
33    DeviceNotFound { index: u32 },
34
35    /// No supported tuner detected on the device.
36    #[error("no supported tuner found")]
37    NoTuner,
38
39    /// Tuner operation failed. **Carries [`TunerError`] since
40    /// 0.2** — was `Tuner(String)` in 0.1.x. Match on the inner
41    /// [`TunerError`] for typed discrimination of the underlying
42    /// failure (e.g. `Err(Tuner(TunerError::PllNotLocked { .. }))`).
43    #[error("tuner error: {0}")]
44    Tuner(#[from] TunerError),
45
46    /// Invalid sample rate. **Struct variant since 0.2** — was
47    /// `InvalidSampleRate(u32)` in 0.1.x.
48    #[error("invalid sample rate: {rate_hz} Hz")]
49    InvalidSampleRate { rate_hz: u32 },
50
51    /// Invalid parameter.
52    #[error("invalid parameter: {0}")]
53    InvalidParameter(String),
54
55    /// Device is busy (another bulk-read activity is already in
56    /// flight on this device — see `RtlSdrReader`'s busy-flag
57    /// guard added in 0.1.1 / #7).
58    #[error("device busy")]
59    DeviceBusy,
60
61    /// Device was lost (USB disconnect).
62    #[error("device lost")]
63    DeviceLost,
64
65    /// Register write/read failed: the USB control transfer
66    /// reported fewer bytes than the operation requested.
67    /// `block` names the access category (block-addressed write,
68    /// demod-addressed write, I2C / EEPROM, etc.); `address` is
69    /// the register address the operation was targeting.
70    /// **Struct variant with context fields since 0.2** — was
71    /// `RegisterAccess` (no payload) in 0.1.x.
72    #[error("register access failed (block={block:?}, addr=0x{address:04x})")]
73    RegisterAccess {
74        block: crate::reg::Block,
75        address: u16,
76    },
77}
78
79/// Typed sub-variant of [`RtlSdrError::Tuner`].
80///
81/// Since 0.2, tuner-side failures carry this enum instead of a
82/// stringly-typed `String`. Consumers can match on the variants
83/// to discriminate failure modes (e.g. retry on
84/// `PllNotLocked`, fail-fast on `XtalIsZero`).
85///
86/// Some variants carry a `&'static str` `backend` field naming
87/// the IC family (`"R82xx"`, `"FC0012"`, `"FC0013"`, `"E4K"`,
88/// `"FC2580"`); use it to disambiguate when the same failure
89/// shape can happen on multiple ICs.
90///
91/// `#[non_exhaustive]` so adding a new variant in any future
92/// patch release is non-breaking. Per #16.
93#[non_exhaustive]
94#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
95pub enum TunerError {
96    /// PLL did not achieve lock for the requested LO frequency
97    /// within the IC's retry budget. Usually means the
98    /// frequency is at an awkward divider boundary or the
99    /// crystal/clock is misconfigured. Backends: R82xx, E4K.
100    #[error("PLL not locked for {freq_hz} Hz")]
101    PllNotLocked { freq_hz: u32 },
102
103    /// The configured crystal reference is zero, which would
104    /// divide-by-zero in PLL math. Backends: R82xx (general),
105    /// FC2580 (more specifically "crystal frequency too low").
106    #[error("PLL reference (xtal) is zero or below the minimum")]
107    XtalIsZero,
108
109    /// PLL programming failed for an IC-specific reason that
110    /// doesn't share a common shape with other backends. Catch-
111    /// all for "no valid divider found", "computed PLL value
112    /// exceeds register range", "VCO out of range", etc. The
113    /// `reason` carries a static diagnostic string identifying
114    /// the specific failure.
115    ///
116    /// Backends: R82xx, FC0012, FC0013, FC2580, E4K.
117    #[error("{backend}: PLL programming failed for {freq_hz} Hz ({reason})")]
118    PllProgrammingFailed {
119        backend: &'static str,
120        freq_hz: u32,
121        reason: &'static str,
122    },
123
124    /// I2C transfer to the tuner returned fewer bytes than
125    /// expected. `operation` names which step failed
126    /// (`"write"`, `"read addr"`, `"read data"`).
127    #[error("I2C {operation} failed: got {got} bytes, expected {expected}")]
128    I2cTransferFailed {
129        operation: &'static str,
130        got: usize,
131        expected: usize,
132    },
133
134    /// R82xx: register read attempted before the shadow cache
135    /// was populated. Indicates a programming error in the
136    /// crate (caller used the helper before `init`), not a
137    /// hardware fault.
138    #[error("R82xx: no cached value for register 0x{reg:02x}")]
139    ShadowCacheMiss { reg: u8 },
140
141    /// FC2580: the configured filter-bandwidth mode index
142    /// doesn't match any supported bandwidth in the IC's LUT.
143    /// `mode` is the internal mode tag (not a Hz value).
144    #[error("FC2580: unsupported filter bandwidth mode {mode}")]
145    UnsupportedFilterBandwidth { mode: u8 },
146
147    /// Gain parameter out of valid range. `what` names the
148    /// parameter (`"E4K IF gain stage"`, `"E4K mixer gain"`,
149    /// `"E4K enhancement gain"`, etc.) and `detail` is a
150    /// human-readable specifier describing the bad value.
151    /// Backends: E4K (the only IC with multi-stage gain that
152    /// validates per stage).
153    #[error("invalid gain ({what}): {detail}")]
154    InvalidGain { what: &'static str, detail: String },
155
156    /// Operation context wrapper. Used to add a `&'static str`
157    /// prefix (e.g. `"filter calibration"`) to an inner
158    /// `TunerError` without losing the typed inner variant.
159    /// `#[source]` makes the inner error walkable via
160    /// `std::error::Error::source` for consumers using
161    /// `anyhow`-style chained-error UI.
162    ///
163    /// **Coverage caveat (per audit pass-2 #74):** `Context`
164    /// only wraps `TunerError` — by construction it can't carry
165    /// a `Usb(rusb::Error)` or `DeviceLost` from the same
166    /// operation. The R82xx filter-calibration path uses this
167    /// shape today: failures from the calibration's tuner-side
168    /// math get wrapped (`Context { context: "filter
169    /// calibration", source: ... }`), but a USB transport error
170    /// during the same calibration sequence propagates as a
171    /// bare `RtlSdrError::Usb(...)` with no calibration-context
172    /// breadcrumb. Consumers building diagnostic UIs should
173    /// match on both shapes if they want full coverage of the
174    /// "what was the device doing when it failed" question.
175    #[error("{context}: {source}")]
176    Context {
177        context: &'static str,
178        #[source]
179        source: Box<TunerError>,
180    },
181}
182
183impl RtlSdrError {
184    /// Returns `true` if the error indicates the dongle was
185    /// disconnected (USB unplug, kernel-driver-rebind, etc.).
186    ///
187    /// Useful in reconnect loops:
188    ///
189    /// ```
190    /// use librtlsdr_rs::RtlSdrError;
191    /// // Synthesize for the doctest; in practice this would come
192    /// // from a method like `read_sync`.
193    /// let e = RtlSdrError::DeviceLost;
194    /// assert!(e.is_disconnected());
195    /// ```
196    ///
197    /// Recognises [`RtlSdrError::DeviceLost`] (the crate's
198    /// internal "we observed disconnect" sentinel — see `read_sync`
199    /// and friends) plus the underlying rusb variants that
200    /// commonly surface a yanked dongle:
201    /// - [`rusb::Error::NoDevice`] — libusb's authoritative
202    ///   disconnect signal, fires on the next call after the
203    ///   kernel observes the unplug
204    /// - [`rusb::Error::Pipe`] — endpoint stall; on Linux this
205    ///   commonly surfaces from a mid-flight bulk read at the
206    ///   moment the device disappears, before libusb downgrades
207    ///   subsequent calls to `NoDevice`
208    /// - [`rusb::Error::Io`] — generic transport I/O failure;
209    ///   same Linux mid-flight-disconnect surrogate
210    ///
211    /// Pre-#43 (0.2.0 and earlier) only matched `DeviceLost` and
212    /// `NoDevice`, so a reconnect loop using `is_disconnected`
213    /// to gate the retry path mistreated `Pipe`/`Io` from a
214    /// hot-unplug as transient and waited a full bulk-read cycle
215    /// before getting an actionable signal. Per audit pass-2 #43.
216    #[must_use]
217    pub fn is_disconnected(&self) -> bool {
218        matches!(
219            self,
220            Self::DeviceLost
221                | Self::Usb(rusb::Error::NoDevice | rusb::Error::Pipe | rusb::Error::Io)
222        )
223    }
224
225    /// Returns `true` if the error is a transient transport
226    /// timeout (the USB transfer didn't complete within the
227    /// configured deadline).
228    ///
229    /// Useful in retry-with-backoff wrappers around bulk reads.
230    /// The example uses the crate's `pub use rusb` re-export so
231    /// the consumer doesn't need a direct `rusb` dependency
232    /// (the whole point of [`crate::rusb`]):
233    ///
234    /// ```
235    /// use librtlsdr_rs::{RtlSdrError, rusb};
236    /// let e = RtlSdrError::Usb(rusb::Error::Timeout);
237    /// assert!(e.is_timeout());
238    /// ```
239    ///
240    /// A timeout typically means "device is alive but didn't have
241    /// data ready" — distinct from [`Self::is_disconnected`].
242    /// Per #15.
243    #[must_use]
244    pub fn is_timeout(&self) -> bool {
245        matches!(self, Self::Usb(rusb::Error::Timeout))
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn is_disconnected_recognises_device_lost_and_no_device() {
255        assert!(RtlSdrError::DeviceLost.is_disconnected());
256        assert!(RtlSdrError::Usb(rusb::Error::NoDevice).is_disconnected());
257    }
258
259    /// Per audit pass-2 #43: Linux hot-unplug commonly surfaces
260    /// `Pipe` / `Io` from a mid-flight bulk read before libusb
261    /// downgrades to `NoDevice`. A reconnect-loop consumer using
262    /// `is_disconnected` should treat both as disconnect, not
263    /// transient.
264    #[test]
265    fn is_disconnected_recognises_linux_hot_unplug_surrogates() {
266        assert!(RtlSdrError::Usb(rusb::Error::Pipe).is_disconnected());
267        assert!(RtlSdrError::Usb(rusb::Error::Io).is_disconnected());
268    }
269
270    #[test]
271    fn is_disconnected_returns_false_for_other_variants() {
272        assert!(!RtlSdrError::DeviceBusy.is_disconnected());
273        assert!(!RtlSdrError::Usb(rusb::Error::Timeout).is_disconnected());
274        assert!(!RtlSdrError::NoTuner.is_disconnected());
275        assert!(!RtlSdrError::DeviceNotFound { index: 0 }.is_disconnected());
276        assert!(!RtlSdrError::Tuner(TunerError::XtalIsZero).is_disconnected());
277        // `Overflow`, `Access`, `Other`, etc. are not Linux
278        // disconnect surrogates; pin them as not-disconnect so a
279        // future widening doesn't sweep too broadly.
280        assert!(!RtlSdrError::Usb(rusb::Error::Overflow).is_disconnected());
281        assert!(!RtlSdrError::Usb(rusb::Error::Access).is_disconnected());
282    }
283
284    #[test]
285    fn is_timeout_recognises_only_usb_timeout() {
286        assert!(RtlSdrError::Usb(rusb::Error::Timeout).is_timeout());
287    }
288
289    #[test]
290    fn is_timeout_returns_false_for_other_variants() {
291        assert!(!RtlSdrError::DeviceLost.is_timeout());
292        assert!(!RtlSdrError::DeviceBusy.is_timeout());
293        assert!(!RtlSdrError::Usb(rusb::Error::NoDevice).is_timeout());
294        assert!(!RtlSdrError::Usb(rusb::Error::Io).is_timeout());
295        assert!(!RtlSdrError::Usb(rusb::Error::Pipe).is_timeout());
296        assert!(
297            !RtlSdrError::RegisterAccess {
298                block: crate::reg::Block::Demod,
299                address: 0
300            }
301            .is_timeout()
302        );
303    }
304}