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}