[opentitantool] Introduce serializable TransportError

Introducing a serializable TransportError, and convenience
transport::{bail, ensure, Result}, to be used instead of anyhow::Result
on all methods of the Transport trait and its delegate traits.

This change allows propagation of error messages from implementation
of the Transport trait across the proposed proxy protocol to a client
calling a stub implementation of the same Transport trait on another
machine.

Many underlying libraries used by the various structs implementing the
Transport trait use other error enums and anyhow.  A convenience
method is provided to assist in converting such errors into
TransportError.

If a transport implementation previously made a call such as this:
serialport::available_ports()?;

It will now have to be replaced with:
use crate::transport::WrapInTransportError;
serialport::available_ports().wrap(UartError::EnumarationError)?;

This will cause e.g. an Err(io::Error(kind: PermissionDenied)) result
to be converted into Err(TransportError::UartError(EnumerationError,
"Permission denied"), in such a way that it collects the additional
context.  To the end user, the error message will look like this:

UART error: Enumerating: Permission denied

My hope is that in addition to allowing Transport errors to be sent
across RPC calls, the messages will also become more useful to end
users.

It can be debated how deep into the libraries and helper classes used
by Transport implementations methods should be converted to
exclusively use TransportError.

A consequence of this change is that errors must own the data they
carry, that is, any fields of the type `&'static str` will have to be
replaced with `String`, as the remote receiver may not have all the
same strings in its executable.

This marks the first step on issue: #10889

Smaller tangentially related changes also in this CL:
*) Merge hyperdebug/uart.rs and cw310/uart.rs into common/uart.rs,
   as both implementations used the same serialport library.
*) Clarified convention of Uart::read_timeout() such that timeout
   is always indicated by Ok(0), and serious errors with Err(_).

Signed-off-by: Jes B. Klinke <jbk@chromium.org>
Change-Id: I027189d44ee01e2bc83b8baac3f6d968e5586c34
diff --git a/sw/host/opentitanlib/BUILD b/sw/host/opentitanlib/BUILD
index 23254d0..a8f9b08 100644
--- a/sw/host/opentitanlib/BUILD
+++ b/sw/host/opentitanlib/BUILD
@@ -40,17 +40,18 @@
         "src/spiflash/flash.rs",
         "src/spiflash/mod.rs",
         "src/spiflash/sfdp.rs",
+        "src/transport/common/mod.rs",
+        "src/transport/common/uart.rs",
         "src/transport/cw310/gpio.rs",
         "src/transport/cw310/mod.rs",
         "src/transport/cw310/spi.rs",
-        "src/transport/cw310/uart.rs",
         "src/transport/cw310/usb.rs",
+        "src/transport/errors.rs",
         "src/transport/hyperdebug/c2d2.rs",
         "src/transport/hyperdebug/gpio.rs",
         "src/transport/hyperdebug/i2c.rs",
         "src/transport/hyperdebug/mod.rs",
         "src/transport/hyperdebug/spi.rs",
-        "src/transport/hyperdebug/uart.rs",
         "src/transport/mod.rs",
         "src/transport/ultradebug/gpio.rs",
         "src/transport/ultradebug/mod.rs",
diff --git a/sw/host/opentitanlib/src/app/mod.rs b/sw/host/opentitanlib/src/app/mod.rs
index b2b1a6c..b82bcf6 100644
--- a/sw/host/opentitanlib/src/app/mod.rs
+++ b/sw/host/opentitanlib/src/app/mod.rs
@@ -10,20 +10,14 @@
 use crate::io::i2c::Bus;
 use crate::io::spi::Target;
 use crate::io::uart::Uart;
+use crate::transport::{Result, Transport, TransportError};
 
-use anyhow::Result;
 use erased_serde::Serialize;
 use std::any::Any;
 use std::cell::RefCell;
 use std::collections::HashMap;
 use std::rc::Rc;
 
-#[derive(Debug, thiserror::Error)]
-pub enum Error {
-    #[error("Invalid pin strapping name \"{0}\"")]
-    InvalidStrappingName(String),
-}
-
 #[derive(Default)]
 pub struct PinConfiguration {
     /// The input/output mode of the GPIO pin.
@@ -38,7 +32,7 @@
 // replacing the "bare" Transport argument.  The fields other than
 // transport will have been computed from a number ConfigurationFiles.
 pub struct TransportWrapper {
-    transport: RefCell<Box<dyn crate::transport::Transport>>,
+    transport: RefCell<Box<dyn Transport>>,
     pin_map: HashMap<String, String>,
     uart_map: HashMap<String, String>,
     spi_map: HashMap<String, String>,
@@ -135,18 +129,18 @@
         Ok(())
     }
 
-    /// Configure all pins as input/output, pullup, etc. as declared in cofiguration files.
+    /// Configure all pins as input/output, pullup, etc. as declared in configuration files.
     pub fn apply_default_pin_configurations(&self) -> Result<()> {
         self.apply_pin_configurations(&self.pin_conf_map)
     }
 
     /// Configure a specific set of pins as strong/weak pullup/pulldown as declared in
-    /// cofiguration files under a given strapping name.
+    /// configuration files under a given strapping name.
     pub fn apply_pin_strapping(&self, strapping_name: &str) -> Result<()> {
         if let Some(strapping_conf_map) = self.strapping_conf_map.get(strapping_name) {
             self.apply_pin_configurations(&strapping_conf_map)
         } else {
-            Err(Error::InvalidStrappingName(strapping_name.to_string()).into())
+            Err(TransportError::InvalidStrappingName(strapping_name.to_string()))
         }
     }
 
@@ -162,7 +156,7 @@
             }
             Ok(())
         } else {
-            Err(Error::InvalidStrappingName(strapping_name.to_string()).into())
+            Err(TransportError::InvalidStrappingName(strapping_name.to_string()))
         }
     }
 
@@ -197,7 +191,7 @@
         }
     }
 
-    pub fn add_configuration_file(&mut self, file: conf::ConfigurationFile) -> Result<()> {
+    pub fn add_configuration_file(&mut self, file: conf::ConfigurationFile) -> anyhow::Result<()> {
         // Merge content of configuration file into pin_map and other
         // members.
         for pin_conf in file.pins {
diff --git a/sw/host/opentitanlib/src/io/gpio.rs b/sw/host/opentitanlib/src/io/gpio.rs
index a45bee1..a3ab4eb 100644
--- a/sw/host/opentitanlib/src/io/gpio.rs
+++ b/sw/host/opentitanlib/src/io/gpio.rs
@@ -2,29 +2,36 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
 use structopt::clap::arg_enum;
 use thiserror::Error;
 
-#[derive(Debug, Error)]
+use crate::transport::Result;
+
+/// Errors related to the GPIO interface.  These error messages will be printed in the context of
+/// a TransportError::GpioError, that is "GPIO error: {}".  So including the words "error" or
+/// "gpio" in texts below will probably be redundant.
+#[derive(Debug, Error, Serialize, Deserialize)]
 pub enum GpioError {
-    #[error("Invalid GPIO pin name {0}")]
+    #[error("Invalid pin name {0}")]
     InvalidPinName(String),
-    #[error("Invalid GPIO pin number {0}")]
+    #[error("Invalid pin number {0}")]
     InvalidPinNumber(u8),
     /// The current mode of the pin (input) does not support the requested operation (set
     /// level).
-    #[error("Invalid GPIO mode for pin {0}")]
+    #[error("Invalid mode for pin {0}")]
     InvalidPinMode(u8),
     /// The hardware does not support the requested mode (open drain, pull down input, etc.)
-    #[error("Unsupported GPIO mode requested")]
-    UnsupportedPinMode(),
+    #[error("Unsupported mode {0} requested")]
+    UnsupportedPinMode(PinMode),
+    /// The hardware does not support the requested mode (open drain, pull down input, etc.)
+    #[error("Unsupported pull mode {0} requested")]
+    UnsupportedPullMode(PullMode),
 }
 
 arg_enum! {
     /// Mode of I/O pins.
-    #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
+    #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
     pub enum PinMode {
         Input,
         PushPull,
@@ -34,7 +41,7 @@
 
 arg_enum! {
     /// Mode of weak pull (relevant in Input and OpenDrain modes).
-    #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
+    #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
     pub enum PullMode {
         None,
         PullUp,
diff --git a/sw/host/opentitanlib/src/io/i2c.rs b/sw/host/opentitanlib/src/io/i2c.rs
index 26cd00e..9099230 100644
--- a/sw/host/opentitanlib/src/io/i2c.rs
+++ b/sw/host/opentitanlib/src/io/i2c.rs
@@ -2,12 +2,13 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
-use thiserror::Error;
+use serde::{Deserialize, Serialize};
 use std::rc::Rc;
 use structopt::StructOpt;
+use thiserror::Error;
 
 use crate::app::TransportWrapper;
+use crate::transport::Result;
 
 #[derive(Debug, StructOpt)]
 pub struct I2cParams {
@@ -22,11 +23,17 @@
     }
 }
 
-/// Errors related to the I2C interface and I2C transactions.
-#[derive(Error, Debug)]
+/// Errors related to the I2C interface and I2C transactions.  These error messages will be
+/// printed in the context of a TransportError::I2cError, that is "I2C error: {}".  So
+/// including the words "error" or "i2c" in texts below will probably be redundant.
+#[derive(Error, Debug, Deserialize, Serialize)]
 pub enum I2cError {
     #[error("Invalid data length: {0}")]
     InvalidDataLength(usize),
+    #[error("Bus timeout")]
+    Timeout,
+    #[error("Bus busy")]
+    Busy,
 }
 
 /// Represents a I2C transfer.
diff --git a/sw/host/opentitanlib/src/io/spi.rs b/sw/host/opentitanlib/src/io/spi.rs
index 80ea329..c8f0881 100644
--- a/sw/host/opentitanlib/src/io/spi.rs
+++ b/sw/host/opentitanlib/src/io/spi.rs
@@ -2,13 +2,14 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
+use serde::{Deserialize, Serialize};
 use std::rc::Rc;
 use std::str::FromStr;
 use structopt::StructOpt;
 use thiserror::Error;
 
 use crate::app::TransportWrapper;
+use crate::transport::Result;
 use crate::util::voltage::Voltage;
 
 #[derive(Debug, StructOpt)]
@@ -42,8 +43,10 @@
     }
 }
 
-/// Errors related to the SPI interface and SPI transactions.
-#[derive(Error, Debug)]
+/// Errors related to the SPI interface and SPI transactions.  These error messages will be
+/// printed in the context of a TransportError::SpiError, that is "SPI error: {}".  So including
+/// the words "error" or "spi" in texts below will probably be redundant.
+#[derive(Error, Debug, Serialize, Deserialize)]
 pub enum SpiError {
     #[error("Invalid option: {0}")]
     InvalidOption(String),
diff --git a/sw/host/opentitanlib/src/io/uart.rs b/sw/host/opentitanlib/src/io/uart.rs
index 7728be0..566cea3 100644
--- a/sw/host/opentitanlib/src/io/uart.rs
+++ b/sw/host/opentitanlib/src/io/uart.rs
@@ -2,12 +2,14 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
+use serde::{Deserialize, Serialize};
 use std::rc::Rc;
 use std::time::Duration;
 use structopt::StructOpt;
+use thiserror::Error;
 
 use crate::app::TransportWrapper;
+use crate::transport::Result;
 
 #[derive(Debug, StructOpt)]
 pub struct UartParams {
@@ -42,8 +44,29 @@
 
     /// Reads UART receive data into `buf`, returning the number of bytes read.
     /// The `timeout` may be used to specify a duration to wait for data.
+    /// If timeout expires without any data arriving `Ok(0)` will be returned, never `Err(_)`.
     fn read_timeout(&self, buf: &mut [u8], timeout: Duration) -> Result<usize>;
 
     /// Writes data from `buf` to the UART.
     fn write(&self, buf: &[u8]) -> Result<()>;
 }
+
+/// Errors related to the UART interface.  These error messages will be printed in the context
+/// of a TransportError::UartError, that is "UART error: {}".  So including the words "error" or
+/// "serial" in texts below will probably be redundant.
+#[derive(Error, Debug, Serialize, Deserialize)]
+pub enum UartError {
+    #[error("Enumerating: {0}")]
+    EnumerationError(String),
+    #[error("Opening: {0}")]
+    OpenError(String),
+    #[error("Invalid option: {0}")]
+    InvalidOption(String),
+    #[error("Invalid speed: {0}")]
+    InvalidSpeed(u32),
+    #[error("Reading: {0}")]
+    ReadError(String),
+    #[error("Writing: {0}")]
+    WriteError(String),
+}
+
diff --git a/sw/host/opentitanlib/src/spiflash/flash.rs b/sw/host/opentitanlib/src/spiflash/flash.rs
index 500880b..3ad1896 100644
--- a/sw/host/opentitanlib/src/spiflash/flash.rs
+++ b/sw/host/opentitanlib/src/spiflash/flash.rs
@@ -134,7 +134,8 @@
     /// Send the WRITE_ENABLE opcode to the `spi` target.
     pub fn set_write_enable(spi: &dyn Target) -> Result<()> {
         let wren = [SpiFlash::WRITE_ENABLE];
-        spi.run_transaction(&mut [Transfer::Write(&wren)])
+        spi.run_transaction(&mut [Transfer::Write(&wren)])?;
+        Ok(())
     }
 
     /// Read and parse the SFDP table from the `spi` target.
diff --git a/sw/host/opentitanlib/src/transport/common/mod.rs b/sw/host/opentitanlib/src/transport/common/mod.rs
new file mode 100644
index 0000000..af23e89
--- /dev/null
+++ b/sw/host/opentitanlib/src/transport/common/mod.rs
@@ -0,0 +1,5 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+pub mod uart;
diff --git a/sw/host/opentitanlib/src/transport/common/uart.rs b/sw/host/opentitanlib/src/transport/common/uart.rs
new file mode 100644
index 0000000..11e8b38
--- /dev/null
+++ b/sw/host/opentitanlib/src/transport/common/uart.rs
@@ -0,0 +1,74 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+use serialport::SerialPort;
+use std::cell::RefCell;
+use std::time::Duration;
+
+use crate::io::uart::{Uart, UartError};
+use crate::transport::{Result, WrapInTransportError};
+
+/// Implementation of the `Uart` trait on top of a serial device, such as `/dev/ttyUSB0`.
+pub struct SerialPortUart {
+    port: RefCell<Box<dyn SerialPort>>,
+}
+
+impl SerialPortUart {
+    // Not really forever, but close enough.  I'd rather use Duration::MAX, but
+    // it seems that the serialport library can compute an invalid `timeval` struct
+    // to pass to `poll`, which then leads to an `Invalid argument` error when
+    // trying to `read` or `write` without a timeout.  One hundred years should be
+    // longer than any invocation of this program.
+    const FOREVER: Duration = Duration::from_secs(100 * 365 * 86400);
+
+    /// Open the given serial device, such as `/dev/ttyUSB0`.
+    pub fn open(port_name: &str) -> Result<Self> {
+        Ok(SerialPortUart {
+            port: RefCell::new(
+                serialport::new(port_name, 115200).open().wrap(UartError::OpenError)?,
+            ),
+        })
+    }
+}
+
+impl Uart for SerialPortUart {
+    /// Returns the UART baudrate.  May return zero for virtual UARTs.
+    fn get_baudrate(&self) -> u32 {
+        self.port.borrow().baud_rate().unwrap()
+    }
+
+    /// Sets the UART baudrate.  May do nothing for virtual UARTs.
+    fn set_baudrate(&self, baudrate: u32) -> Result<()> {
+        self.port
+            .borrow_mut()
+            .set_baud_rate(baudrate)
+            .wrap(|_| UartError::InvalidSpeed(baudrate))?;
+        Ok(())
+    }
+
+    /// Reads UART receive data into `buf`, returning the number of bytes read.
+    /// This function _may_ block.
+    fn read(&self, buf: &mut [u8]) -> Result<usize> {
+        Ok(self.port.borrow_mut().read(buf).wrap(UartError::ReadError)?)
+    }
+
+    /// Reads UART receive data into `buf`, returning the number of bytes read.
+    /// The `timeout` may be used to specify a duration to wait for data.
+    fn read_timeout(&self, buf: &mut [u8], timeout: Duration) -> Result<usize> {
+        let mut port = self.port.borrow_mut();
+        port.set_timeout(timeout).wrap(UartError::ReadError)?;
+        let len = port.read(buf);
+        port.set_timeout(Self::FOREVER).wrap(UartError::ReadError)?;
+        Ok(len.wrap(UartError::ReadError)?)
+    }
+
+    /// Writes data from `buf` to the UART.
+    fn write(&self, mut buf: &[u8]) -> Result<()> {
+        while buf.len() > 0 {
+            let written = self.port.borrow_mut().write(buf).wrap(UartError::WriteError)?;
+            buf = &buf[written..];
+        }
+        Ok(())
+    }
+}
diff --git a/sw/host/opentitanlib/src/transport/cw310/gpio.rs b/sw/host/opentitanlib/src/transport/cw310/gpio.rs
index 86083a5..e3c1e14 100644
--- a/sw/host/opentitanlib/src/transport/cw310/gpio.rs
+++ b/sw/host/opentitanlib/src/transport/cw310/gpio.rs
@@ -2,12 +2,12 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
 use std::cell::RefCell;
 use std::rc::Rc;
 
 use crate::io::gpio::{GpioError, GpioPin, PinMode, PullMode};
 use crate::transport::cw310::usb::Backend;
+use crate::transport::Result;
 
 pub struct CW310GpioPin {
     device: Rc<RefCell<Backend>>,
@@ -41,12 +41,15 @@
         match mode {
             PinMode::Input => usb.pin_set_output(&self.pinname, false)?,
             PinMode::PushPull => usb.pin_set_output(&self.pinname, true)?,
-            PinMode::OpenDrain => return Err(GpioError::UnsupportedPinMode().into()),
+            PinMode::OpenDrain => return Err(GpioError::UnsupportedPinMode(mode).into()),
         }
         Ok(())
     }
 
-    fn set_pull_mode(&self, _mode: PullMode) -> Result<()> {
-        Err(GpioError::UnsupportedPinMode().into())
+    fn set_pull_mode(&self, mode: PullMode) -> Result<()> {
+        match mode {
+            PullMode::None => Ok(()),
+            _ => Err(GpioError::UnsupportedPullMode(mode).into()),
+        }
     }
 }
diff --git a/sw/host/opentitanlib/src/transport/cw310/mod.rs b/sw/host/opentitanlib/src/transport/cw310/mod.rs
index 9e44d4b..94683d2 100644
--- a/sw/host/opentitanlib/src/transport/cw310/mod.rs
+++ b/sw/host/opentitanlib/src/transport/cw310/mod.rs
@@ -2,23 +2,27 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{ensure, Result};
 use erased_serde::Serialize;
+use serialport::SerialPortType;
 use std::any::Any;
 use std::cell::RefCell;
 use std::collections::hash_map::Entry;
 use std::collections::HashMap;
 use std::rc::Rc;
 
+use crate::ensure;
 use crate::io::gpio::GpioPin;
 use crate::io::spi::Target;
-use crate::io::uart::Uart;
-use crate::transport::{Capabilities, Capability, Transport, TransportError};
+use crate::io::uart::{Uart, UartError};
+use crate::transport::{
+    Capabilities, Capability, Result, Transport, TransportError, TransportInterfaceType,
+    WrapInTransportError,
+};
+use crate::transport::common::uart::SerialPortUart;
 use crate::util::parse_int::ParseInt;
 
 pub mod gpio;
 pub mod spi;
-pub mod uart;
 pub mod usb;
 
 #[derive(Default)]
@@ -48,7 +52,7 @@
         usb_vid: Option<u16>,
         usb_pid: Option<u16>,
         usb_serial: Option<&str>,
-    ) -> Result<Self> {
+    ) -> anyhow::Result<Self> {
         let board = CW310 {
             device: Rc::new(RefCell::new(usb::Backend::new(
                 usb_vid, usb_pid, usb_serial,
@@ -60,13 +64,36 @@
     }
 
     // Initialize the IO direction of some basic pins on the board.
-    fn init_direction(&self) -> Result<()> {
+    fn init_direction(&self) -> anyhow::Result<()> {
         let device = self.device.borrow();
         device.pin_set_output(Self::PIN_SRST, true)?;
         device.pin_set_output(Self::PIN_JTAG, true)?;
         device.pin_set_output(Self::PIN_BOOTSTRAP, true)?;
         Ok(())
     }
+
+    fn open_uart(&self, instance: u32) -> Result<SerialPortUart> {
+        let usb = self.device.borrow();
+        let serial_number = usb.get_serial_number();
+
+        let mut ports = serialport::available_ports().wrap(UartError::EnumerationError)?;
+        ports.retain(|port| {
+            if let SerialPortType::UsbPort(info) = &port.port_type {
+                if info.serial_number.as_deref() == Some(serial_number) {
+                    return true;
+                }
+            }
+            false
+        });
+        // The CW board seems to have the last port connected as OpenTitan UART 0.
+        // Reverse the sort order so the last port will be instance 0.
+        ports.sort_by(|a, b| b.port_name.cmp(&a.port_name));
+
+        let port = ports.get(instance as usize).ok_or_else(|| {
+            TransportError::InvalidInstance(TransportInterfaceType::Uart, instance.to_string())
+        })?;
+        SerialPortUart::open(&port.port_name)
+    }
 }
 
 impl Transport for CW310 {
@@ -76,11 +103,12 @@
 
     fn uart(&self, instance: &str) -> Result<Rc<dyn Uart>> {
         let mut inner = self.inner.borrow_mut();
-        let instance = u32::from_str(instance)?;
+        let instance = u32::from_str(instance).ok().ok_or_else(|| {
+            TransportError::InvalidInstance(TransportInterfaceType::Uart, instance.to_string())
+        })?;
         let uart = match inner.uart.entry(instance) {
             Entry::Vacant(v) => {
-                let u = v.insert(Rc::new(uart::CW310Uart::open(
-                    Rc::clone(&self.device),
+                let u = v.insert(Rc::new(self.open_uart(
                     instance,
                 )?));
                 Rc::clone(u)
@@ -107,7 +135,7 @@
     fn spi(&self, instance: &str) -> Result<Rc<dyn Target>> {
         ensure!(
             instance == "0",
-            TransportError::InvalidInstance("spi", instance.to_string())
+            TransportError::InvalidInstance(TransportInterfaceType::Spi, instance.to_string())
         );
         let mut inner = self.inner.borrow_mut();
         if inner.spi.is_none() {
diff --git a/sw/host/opentitanlib/src/transport/cw310/spi.rs b/sw/host/opentitanlib/src/transport/cw310/spi.rs
index c0913c7..a48c4aa 100644
--- a/sw/host/opentitanlib/src/transport/cw310/spi.rs
+++ b/sw/host/opentitanlib/src/transport/cw310/spi.rs
@@ -2,7 +2,6 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
 use log;
 use std::cell::RefCell;
 use std::rc::Rc;
@@ -10,6 +9,7 @@
 use crate::io::spi::{SpiError, Target, Transfer, TransferMode};
 use crate::transport::cw310::usb::Backend;
 use crate::transport::cw310::CW310;
+use crate::transport::Result;
 
 pub struct CW310Spi {
     device: Rc<RefCell<Backend>>,
diff --git a/sw/host/opentitanlib/src/transport/cw310/uart.rs b/sw/host/opentitanlib/src/transport/cw310/uart.rs
deleted file mode 100644
index 1038e4e..0000000
--- a/sw/host/opentitanlib/src/transport/cw310/uart.rs
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright lowRISC contributors.
-// Licensed under the Apache License, Version 2.0, see LICENSE for details.
-// SPDX-License-Identifier: Apache-2.0
-
-use crate::transport::TransportError;
-use anyhow::Result;
-use serialport::{SerialPort, SerialPortType};
-use std::cell::RefCell;
-use std::rc::Rc;
-use std::time::Duration;
-
-use crate::io::uart::Uart;
-use crate::transport::cw310::usb::Backend;
-
-pub struct CW310Uart {
-    port: RefCell<Box<dyn SerialPort>>,
-}
-
-impl CW310Uart {
-    // Not really forever, but close enough.  I'd rather use Duration::MAX, but
-    // it seems that the serialport library can compute an invalid `timeval` struct
-    // to pass to `poll`, which then leads to an `Invalid argument` error when
-    // trying to `read` or `write` without a timeout.  One hundred years should be
-    // longer than any invocation of this program.
-    const FOREVER: Duration = Duration::from_secs(100 * 365 * 86400);
-
-    pub fn open(backend: Rc<RefCell<Backend>>, instance: u32) -> Result<Self> {
-        let usb = backend.borrow();
-        let serial_number = usb.get_serial_number();
-
-        let mut ports = serialport::available_ports()?;
-        ports.retain(|port| {
-            if let SerialPortType::UsbPort(info) = &port.port_type {
-                if info.serial_number.as_deref() == Some(serial_number) {
-                    return true;
-                }
-            }
-            false
-        });
-        // The CW board seems to have the last port connected as OpenTitan UART 0.
-        // Reverse the sort order so the last port will be instance 0.
-        ports.sort_by(|a, b| b.port_name.cmp(&a.port_name));
-
-        let port = ports
-            .get(instance as usize)
-            .ok_or_else(|| TransportError::InvalidInstance("uart", instance.to_string()))?;
-        Ok(CW310Uart {
-            port: RefCell::new(serialport::new(&port.port_name, 115200).open()?),
-        })
-    }
-}
-
-impl Uart for CW310Uart {
-    /// Returns the UART baudrate.  May return zero for virtual UARTs.
-    fn get_baudrate(&self) -> u32 {
-        self.port.borrow().baud_rate().unwrap()
-    }
-
-    /// Sets the UART baudrate.  May do nothing for virtual UARTs.
-    fn set_baudrate(&self, baudrate: u32) -> Result<()> {
-        self.port.borrow_mut().set_baud_rate(baudrate)?;
-        Ok(())
-    }
-
-    /// Reads UART receive data into `buf`, returning the number of bytes read.
-    /// This function _may_ block.
-    fn read(&self, buf: &mut [u8]) -> Result<usize> {
-        Ok(self.port.borrow_mut().read(buf)?)
-    }
-
-    /// Reads UART receive data into `buf`, returning the number of bytes read.
-    /// The `timeout` may be used to specify a duration to wait for data.
-    fn read_timeout(&self, buf: &mut [u8], timeout: Duration) -> Result<usize> {
-        let mut port = self.port.borrow_mut();
-        port.set_timeout(timeout)?;
-        let len = port.read(buf);
-        port.set_timeout(Self::FOREVER)?;
-        Ok(len?)
-    }
-
-    /// Writes data from `buf` to the UART.
-    fn write(&self, mut buf: &[u8]) -> Result<()> {
-        while buf.len() > 0 {
-            let written = self.port.borrow_mut().write(buf)?;
-            buf = &buf[written..];
-        }
-        Ok(())
-    }
-}
diff --git a/sw/host/opentitanlib/src/transport/cw310/usb.rs b/sw/host/opentitanlib/src/transport/cw310/usb.rs
index 88f1ab5..3491ce5 100644
--- a/sw/host/opentitanlib/src/transport/cw310/usb.rs
+++ b/sw/host/opentitanlib/src/transport/cw310/usb.rs
@@ -2,24 +2,18 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{ensure, Result};
 use lazy_static::lazy_static;
 use std::collections::HashMap;
 use std::time::Duration;
-use thiserror::Error;
 
 use crate::collection;
+use crate::ensure;
 use crate::io::gpio::GpioError;
 use crate::io::spi::SpiError;
+use crate::transport::{Result, TransportError, TransportInterfaceType, WrapInTransportError};
 use crate::util::parse_int::ParseInt;
 use crate::util::usb::UsbBackend;
 
-#[derive(Debug, Error)]
-pub enum Error {
-    #[error("FPGA programming failed: {0}")]
-    FpgaProgramFailed(String),
-}
-
 /// The `Backend` struct provides high-level access to the CW310 board.
 pub struct Backend {
     usb: UsbBackend,
@@ -148,9 +142,12 @@
 
     /// Get the state of GPIO `pinname`.
     pub fn pin_get_state(&self, pinname: &str) -> Result<u8> {
-        let pinnum = Backend::pin_name_to_number(pinname)? as u16;
+        let pinnum = Backend::pin_name_to_number(pinname).ok().ok_or_else(|| {
+            TransportError::InvalidInstance(TransportInterfaceType::Gpio, pinname.to_string())
+        })? as u16;
         let mut buf = [0u8; 1];
-        self.read_ctrl(Backend::CMD_FPGAIO_UTIL, pinnum, &mut buf)?;
+        self.read_ctrl(Backend::CMD_FPGAIO_UTIL, pinnum, &mut buf)
+            .wrap(TransportError::UsbGenericError)?;
         Ok(buf[0])
     }
 
@@ -330,9 +327,9 @@
         self.send_ctrl(Backend::CMD_FPGA_PROGRAM, Backend::PROGRAM_EXIT, &[])?;
 
         if let Err(e) = result {
-            Err(Error::FpgaProgramFailed(e.to_string()).into())
+            Err(TransportError::FpgaProgramFailed(e.to_string()).into())
         } else if !status {
-            Err(Error::FpgaProgramFailed("unknown error".to_string()).into())
+            Err(TransportError::FpgaProgramFailed("unknown error".to_string()).into())
         } else {
             Ok(())
         }
diff --git a/sw/host/opentitanlib/src/transport/errors.rs b/sw/host/opentitanlib/src/transport/errors.rs
new file mode 100644
index 0000000..327389d
--- /dev/null
+++ b/sw/host/opentitanlib/src/transport/errors.rs
@@ -0,0 +1,96 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+/// Contains all the errors that any method on the `Transport` trait could generate.  This
+/// struct is serializable, such that it can be transmitted across a network for instance as
+/// part of the session proxy functionality.
+#[derive(Error, Debug, Serialize, Deserialize)]
+pub enum TransportError {
+    #[error("USB device did not match")]
+    NoMatch,
+    #[error("Found no USB device")]
+    NoDevice,
+    #[error("Found multiple USB devices, use --serial")]
+    MultipleDevices,
+    #[error("USB error: {0}")]
+    UsbGenericError(String),
+    #[error("Error opening USB device: {0}")]
+    UsbOpenError(String),
+    #[error("Transport does not support {0:?} instance {1}")]
+    InvalidInstance(TransportInterfaceType, String),
+    #[error("Encountered non-unicode device path")]
+    UnicodePathError,
+    #[error("Error opening {0}: {1}")]
+    OpenError(String, String),
+    #[error("Error reading from {0}: {1}")]
+    ReadError(String, String),
+    #[error("FPGA programming failed: {0}")]
+    FpgaProgramFailed(String),
+    #[error("Invalid pin strapping name \"{0}\"")]
+    InvalidStrappingName(String),
+    #[error("Transport does not support the requested operation")]
+    UnsupportedOperation,
+    #[error("Error communicating with FTDI: {0}")]
+    FtdiError(String),
+    #[error("Error communicating with debugger: {0}")]
+    CommunicationError(String),
+
+    // Include sub-enums for the various sub-traits of Tranport.
+    #[error("GPIO error: {0}")]
+    GpioError(#[from] crate::io::gpio::GpioError),
+    #[error("UART error: {0}")]
+    UartError(#[from] crate::io::uart::UartError),
+    #[error("SPI error: {0}")]
+    SpiError(#[from] crate::io::spi::SpiError),
+    #[error("SPI error: {0}")]
+    I2cError(#[from] crate::io::i2c::I2cError),
+}
+
+/// Enum value used by `TransportError::InvalidInstance`.
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+pub enum TransportInterfaceType {
+    Gpio,
+    Uart,
+    Spi,
+    I2c,
+}
+
+/// Return type to be used in `Transport` methods.
+pub type Result<T> = anyhow::Result<T, TransportError>;
+
+/// Convenience macro to be used in implementations of `Transport`.
+#[macro_export]
+macro_rules! bail {
+    ($err:expr $(,)?) => {
+        return Err($err.into());
+    };
+}
+
+/// Convenience macro to be used in implementations of `Transport`.
+#[macro_export]
+macro_rules! ensure {
+    ($cond:expr, $err:expr $(,)?) => {
+        if !$cond {
+            return Err($err.into());
+        }
+    };
+}
+
+/// Function for conveniently wrapping (the flat string representation of) an anyhow::Error in a
+/// TransportError.
+///
+/// Example usage:
+/// serialport::available_ports().wrap(UartError::EnumerationError)?;
+pub trait WrapInTransportError<T> {
+    fn wrap<S: Into<TransportError>>(self, kind: impl FnOnce(String) -> S) -> Result<T>;
+}
+
+impl<T, E: ToString> WrapInTransportError<T> for anyhow::Result<T, E> {
+    fn wrap<S: Into<TransportError>>(self, kind: impl FnOnce(String) -> S) -> Result<T> {
+        self.map_err(|e| kind(e.to_string()).into())
+    }
+}
diff --git a/sw/host/opentitanlib/src/transport/hyperdebug/c2d2.rs b/sw/host/opentitanlib/src/transport/hyperdebug/c2d2.rs
index cde8d1a..135d41a 100644
--- a/sw/host/opentitanlib/src/transport/hyperdebug/c2d2.rs
+++ b/sw/host/opentitanlib/src/transport/hyperdebug/c2d2.rs
@@ -2,11 +2,12 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{bail, Result};
 use std::rc::Rc;
 
+use crate::bail;
 use crate::io::gpio::{GpioPin, PinMode, PullMode};
-use crate::transport::hyperdebug::{Error, Flavor, Inner, StandardFlavor, VID_GOOGLE};
+use crate::transport::hyperdebug::{Flavor, Inner, StandardFlavor, VID_GOOGLE};
+use crate::transport::{Result, TransportError};
 
 /// The C2D2 (Case Closed Debugging Debugger) is used to bring up GSC and EC chips sitting
 /// inside a Chrome OS devices, such that those GSC chips can provide Case Closed Debugging
@@ -51,8 +52,9 @@
 impl GpioPin for C2d2ResetPin {
     /// Reads the value of the the reset pin.
     fn read(&self) -> Result<bool> {
-        let mut result: Result<bool> =
-            Err(Error::CommunicationError("No output from gpioget").into());
+        let mut result: Result<bool> = Err(TransportError::CommunicationError(
+            "No output from gpioget".to_string(),
+        ));
         self.inner
             .execute_command("gpioget SPIVREF_RSVD_H1VREF_H1_RST_ODL", |line| {
                 result = Ok(line.trim_start().starts_with("1"))
@@ -63,16 +65,17 @@
     /// Sets the value of the GPIO reset pin by means of the special h1_reset command.
     fn write(&self, value: bool) -> Result<()> {
         self.inner
-            .execute_command(&format!("h1_reset {}", if value { 0 } else { 1 }), |_| {})
+            .execute_command(&format!("h1_reset {}", if value { 0 } else { 1 }), |_| {})?;
+        Ok(())
     }
 
     /// Sets the mode of the GPIO pin as input, output, or open drain I/O.
     fn set_mode(&self, _mode: PinMode) -> Result<()> {
-        bail!(Error::UnsupportedOperationError)
+        bail!(TransportError::UnsupportedOperation)
     }
 
     /// Sets the weak pull resistors of the GPIO pin.
     fn set_pull_mode(&self, _mode: PullMode) -> Result<()> {
-        bail!(Error::UnsupportedOperationError)
+        bail!(TransportError::UnsupportedOperation)
     }
 }
diff --git a/sw/host/opentitanlib/src/transport/hyperdebug/gpio.rs b/sw/host/opentitanlib/src/transport/hyperdebug/gpio.rs
index 30ef4c5..3a67ab8 100644
--- a/sw/host/opentitanlib/src/transport/hyperdebug/gpio.rs
+++ b/sw/host/opentitanlib/src/transport/hyperdebug/gpio.rs
@@ -2,11 +2,11 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
 use std::rc::Rc;
 
 use crate::io::gpio::{GpioPin, PinMode, PullMode};
-use crate::transport::hyperdebug::{Error, Inner};
+use crate::transport::hyperdebug::Inner;
+use crate::transport::{Result, TransportError};
 
 pub struct HyperdebugGpioPin {
     inner: Rc<Inner>,
@@ -26,8 +26,9 @@
 impl GpioPin for HyperdebugGpioPin {
     /// Reads the value of the the GPIO pin `id`.
     fn read(&self) -> Result<bool> {
-        let mut result: Result<bool> =
-            Err(Error::CommunicationError("No output from gpioget").into());
+        let mut result: Result<bool> = Err(TransportError::CommunicationError(
+            "No output from gpioget".to_string(),
+        ));
         self.inner
             .execute_command(&format!("gpioget {}", &self.pinname), |line| {
                 result = Ok(line.trim_start().starts_with("1"))
diff --git a/sw/host/opentitanlib/src/transport/hyperdebug/i2c.rs b/sw/host/opentitanlib/src/transport/hyperdebug/i2c.rs
index 46104ed..92eb4a1 100644
--- a/sw/host/opentitanlib/src/transport/hyperdebug/i2c.rs
+++ b/sw/host/opentitanlib/src/transport/hyperdebug/i2c.rs
@@ -2,13 +2,14 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{ensure, Result};
 use std::cmp;
 use std::rc::Rc;
 use zerocopy::{AsBytes, FromBytes};
 
+use crate::{bail, ensure};
 use crate::io::i2c::{Bus, I2cError, Transfer};
-use crate::transport::hyperdebug::{BulkInterface, Error, Inner};
+use crate::transport::hyperdebug::{BulkInterface, Inner};
+use crate::transport::{Result, TransportError};
 
 pub struct HyperdebugI2cBus {
     inner: Rc<Inner>,
@@ -131,12 +132,14 @@
         let bytecount = self.usb_read_bulk(&mut resp.as_bytes_mut())?;
         ensure!(
             bytecount >= 4,
-            Error::CommunicationError("Unrecognized response to I2C request")
+            TransportError::CommunicationError("Unrecognized response to I2C request".to_string())
         );
-        ensure!(
-            resp.status_code == 0,
-            Error::CommunicationError("I2C error")
-        );
+        match resp.status_code {
+            0 => (),
+            1 => bail!(I2cError::Timeout),
+            2 => bail!(I2cError::Busy),
+            n => bail!(TransportError::CommunicationError(format!("I2C error: {}", n))),
+        }
         let databytes = bytecount - 4;
         rbuf[..databytes].clone_from_slice(&resp.data[..databytes]);
         let mut index = databytes;
@@ -144,7 +147,9 @@
             let databytes = self.usb_read_bulk(&mut resp.data[index..])?;
             ensure!(
                 databytes > 0,
-                Error::CommunicationError("Unrecognized reponse to I2C request")
+                TransportError::CommunicationError(
+                    "Unrecognized reponse to I2C request".to_string()
+                )
             );
             index += databytes;
         }
diff --git a/sw/host/opentitanlib/src/transport/hyperdebug/mod.rs b/sw/host/opentitanlib/src/transport/hyperdebug/mod.rs
index 204c72e..8d1ffad 100644
--- a/sw/host/opentitanlib/src/transport/hyperdebug/mod.rs
+++ b/sw/host/opentitanlib/src/transport/hyperdebug/mod.rs
@@ -2,8 +2,6 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{bail, ensure, Result};
-
 use std::cell::RefCell;
 use std::collections::hash_map::Entry;
 use std::collections::HashMap;
@@ -15,21 +13,22 @@
 use std::path::{Path, PathBuf};
 use std::rc::Rc;
 
-use thiserror::Error;
-
-use crate::collection;
 use crate::io::gpio::GpioPin;
 use crate::io::i2c::Bus;
 use crate::io::spi::Target;
 use crate::io::uart::Uart;
-use crate::transport::{Capabilities, Capability, Transport, TransportError};
+use crate::transport::{
+    Capabilities, Capability, Result, Transport, TransportError, TransportInterfaceType,
+    WrapInTransportError,
+};
+use crate::transport::common::uart::SerialPortUart;
 use crate::util::usb::UsbBackend;
+use crate::{bail, collection, ensure};
 
 pub mod c2d2;
 pub mod gpio;
 pub mod i2c;
 pub mod spi;
-pub mod uart;
 
 /// Implementation of the Transport trait for HyperDebug based on the
 /// Nucleo-L552ZE-Q.
@@ -131,7 +130,8 @@
                         console_tty = Some(Self::find_tty(&interface_path)?)
                     } else {
                         // We found an UART forwarding USB interface.
-                        uart_ttys.insert(interface_name.to_string(), Self::find_tty(&interface_path)?);
+                        uart_ttys
+                            .insert(interface_name.to_string(), Self::find_tty(&interface_path)?);
                     }
                 }
                 if interface_desc.class_code() == Self::USB_CLASS_VENDOR
@@ -143,7 +143,8 @@
                     Self::find_endpoints_for_interface(
                         &mut spi_interface,
                         &interface,
-                        &interface_desc)?;
+                        &interface_desc,
+                    )?;
                 }
                 if interface_desc.class_code() == Self::USB_CLASS_VENDOR
                     && interface_desc.sub_class_code() == Self::USB_SUBCLASS_I2C
@@ -154,7 +155,8 @@
                     Self::find_endpoints_for_interface(
                         &mut i2c_interface,
                         &interface,
-                        &interface_desc)?;
+                        &interface_desc,
+                    )?;
                 }
             }
         }
@@ -169,15 +171,18 @@
         };
         let result = Hyperdebug::<T> {
             spi_names,
-            spi_interface: spi_interface
-                .ok_or(Error::CommunicationError("Missing SPI interface"))?,
+            spi_interface: spi_interface.ok_or(TransportError::CommunicationError(
+                "Missing SPI interface".to_string(),
+            ))?,
             i2c_names,
-            i2c_interface: i2c_interface
-                .ok_or(Error::CommunicationError("Missing I2C interface"))?,
+            i2c_interface: i2c_interface.ok_or(TransportError::CommunicationError(
+                "Missing I2C interface".to_string(),
+            ))?,
             uart_ttys,
             inner: Rc::new(Inner {
-                console_tty: console_tty
-                    .ok_or(Error::CommunicationError("Missing console interface"))?,
+                console_tty: console_tty.ok_or(TransportError::CommunicationError(
+                    "Missing console interface".to_string(),
+                ))?,
                 usb_device: RefCell::new(device),
                 gpio: Default::default(),
                 spis: Default::default(),
@@ -192,22 +197,24 @@
     /// Locates the /dev/ttyUSBn node corresponding to a given interface in the sys directory
     /// tree, e.g. /sys/bus/usb/devices/1-4/1-4:1.0 .
     fn find_tty(path: &Path) -> Result<PathBuf> {
-        for entry in fs::read_dir(path)? {
-            let entry = entry?;
+        for entry in fs::read_dir(path).wrap(|e| TransportError::ReadError(path.to_str().unwrap().to_string(), e))? {
+            let entry = entry.wrap(|e| TransportError::ReadError(path.to_str().unwrap().to_string(), e))?;
             if let Ok(filename) = entry.file_name().into_string() {
                 if filename.starts_with("tty") {
                     return Ok(PathBuf::from("/dev").join(entry.file_name()));
                 }
             }
         }
-        Err(Error::CommunicationError("Did not find ttyUSBn device").into())
+        Err(TransportError::CommunicationError(
+            "Did not find ttyUSBn device".to_string(),
+        ))
     }
 
     fn find_endpoints_for_interface(
         interface_variable_output: &mut Option<BulkInterface>,
         interface: &rusb::Interface,
-        interface_desc: &rusb::InterfaceDescriptor) -> Result<()>
-    {
+        interface_desc: &rusb::InterfaceDescriptor,
+    ) -> Result<()> {
         let mut in_endpoint: Option<u8> = None;
         let mut out_endpoint: Option<u8> = None;
         for endpoint_desc in interface_desc.endpoint_descriptors() {
@@ -216,21 +223,27 @@
             }
             match endpoint_desc.direction() {
                 rusb::Direction::In => {
-                    ensure!(in_endpoint.is_none(),
-                            Error::CommunicationError("Multiple IN endpoints"));
+                    ensure!(
+                        in_endpoint.is_none(),
+                        TransportError::CommunicationError("Multiple IN endpoints".to_string())
+                    );
                     in_endpoint.replace(endpoint_desc.address());
                 }
                 rusb::Direction::Out => {
-                    ensure!(out_endpoint.is_none(),
-                            Error::CommunicationError("Multiple OUT endpoints"));
+                    ensure!(
+                        out_endpoint.is_none(),
+                        TransportError::CommunicationError("Multiple OUT endpoints".to_string())
+                    );
                     out_endpoint.replace(endpoint_desc.address());
                 }
             }
         }
         match (in_endpoint, out_endpoint) {
             (Some(in_endpoint), Some(out_endpoint)) => {
-                ensure!(interface_variable_output.is_none(),
-                        Error::CommunicationError("Multiple identical interfaces"));
+                ensure!(
+                    interface_variable_output.is_none(),
+                    TransportError::CommunicationError("Multiple identical interfaces".to_string())
+                );
                 interface_variable_output.replace(BulkInterface {
                     interface: interface.number(),
                     in_endpoint,
@@ -238,7 +251,9 @@
                 });
                 Ok(())
             }
-            _ => bail!(Error::CommunicationError("Missing one or more endpoints"))
+            _ => bail!(TransportError::CommunicationError(
+                "Missing one or more endpoints".to_string()
+            )),
         }
     }
 }
@@ -259,7 +274,7 @@
     /// Send a command to HyperDebug firmware, with a callback to receive any output.
     pub fn execute_command(&self, cmd: &str, mut callback: impl FnMut(&str)) -> Result<()> {
         let mut port = serialport::new(
-            self.console_tty.to_str().ok_or(Error::UnicodePathError)?,
+            self.console_tty.to_str().ok_or(TransportError::UnicodePathError)?,
             115_200,
         )
         .timeout(std::time::Duration::from_millis(10))
@@ -283,12 +298,13 @@
                 Err(error) if error.kind() == ErrorKind::TimedOut => {
                     break;
                 }
-                Err(error) => return Err(error.into()),
+                Err(error) => return Err(error).wrap(TransportError::CommunicationError),
             }
         }
         // Send Ctrl-C, followed by the command, then newline.  This will discard any previous
         // partial input, before executing our command.
-        port.write(format!("\x03{}\n", cmd).as_bytes())?;
+        port.write(format!("\x03{}\n", cmd).as_bytes())
+            .wrap(TransportError::CommunicationError)?;
 
         // Now process response from HyperDebug.  First we expect to see the echo of the command
         // we just "typed". Then zero, one or more lines of useful output, which we want to pass
@@ -314,7 +330,8 @@
                             if line_end > line_start && buf[line_end - 1] == 13 {
                                 line_end -= 1;
                             }
-                            let line = std::str::from_utf8(&buf[line_start..line_end])?;
+                            let line = std::str::from_utf8(&buf[line_start..line_end])
+                                .wrap(TransportError::CommunicationError)?;
                             if seen_echo {
                                 callback(line);
                             } else {
@@ -333,7 +350,10 @@
                     }
                 }
                 Err(error) if error.kind() == ErrorKind::TimedOut => {
-                    if std::str::from_utf8(&buf[0..len])? == "> " {
+                    if std::str::from_utf8(&buf[0..len])
+                        .wrap(TransportError::CommunicationError)?
+                        == "> "
+                    {
                         // No data arrived for a while, and the last we got was a command
                         // prompt, this is what we expect when the command has finished
                         // successfully.
@@ -346,32 +366,16 @@
                         // setting of the underlying serial port object.)
                         repeated_timeouts += 1;
                         if repeated_timeouts == 10 {
-                            return Err(error.into());
+                            return Err(error).wrap(TransportError::CommunicationError);
                         }
                     }
                 }
-                Err(error) => return Err(error.into()),
+                Err(error) => return Err(error).wrap(TransportError::CommunicationError),
             }
         }
     }
 }
 
-#[derive(Debug, Error)]
-pub enum Error {
-    #[error("USB device did not match")]
-    NoMatch,
-    #[error("Found no HyperDebug USB device")]
-    NoDevice,
-    #[error("Found multiple HyperDebug USB devices, use --serial")]
-    MultipleDevices,
-    #[error("Error communicating with HyperDebug: {0}")]
-    CommunicationError(&'static str),
-    #[error("Unsupported operation")]
-    UnsupportedOperationError,
-    #[error("Encountered non-unicode path")]
-    UnicodePathError,
-}
-
 impl<T: Flavor> Transport for Hyperdebug<T> {
     fn capabilities(&self) -> Capabilities {
         Capabilities::new(Capability::UART | Capability::GPIO | Capability::SPI | Capability::I2C)
@@ -379,10 +383,9 @@
 
     // Crate SPI Target instance, or return one from a cache of previously created instances.
     fn spi(&self, instance: &str) -> Result<Rc<dyn Target>> {
-        let &idx = self
-            .spi_names
-            .get(instance)
-            .ok_or_else(|| TransportError::InvalidInstance("spi", instance.to_string()))?;
+        let &idx = self.spi_names.get(instance).ok_or_else(|| {
+            TransportError::InvalidInstance(TransportInterfaceType::Spi, instance.to_string())
+        })?;
         if let Some(instance) = self.inner.spis.borrow().get(&idx) {
             return Ok(Rc::clone(instance));
         }
@@ -397,10 +400,9 @@
 
     // Crate I2C Target instance, or return one from a cache of previously created instances.
     fn i2c(&self, instance: &str) -> Result<Rc<dyn Bus>> {
-        let &idx = self
-            .i2c_names
-            .get(instance)
-            .ok_or_else(|| TransportError::InvalidInstance("i2c", instance.to_string()))?;
+        let &idx = self.i2c_names.get(instance).ok_or_else(|| {
+            TransportError::InvalidInstance(TransportInterfaceType::I2c, instance.to_string())
+        })?;
         if let Some(instance) = self.inner.i2cs.borrow().get(&idx) {
             return Ok(Rc::clone(instance));
         }
@@ -420,14 +422,20 @@
                 if let Some(instance) = self.inner.uarts.borrow().get(tty) {
                     return Ok(Rc::clone(instance));
                 }
-                let instance: Rc<dyn Uart> = Rc::new(uart::HyperdebugUart::open(tty)?);
+                let instance: Rc<dyn Uart> = Rc::new(
+                    SerialPortUart::open(tty.to_str().ok_or(TransportError::UnicodePathError)?)?,
+                );
                 self.inner
                     .uarts
                     .borrow_mut()
                     .insert(tty.clone(), Rc::clone(&instance));
                 Ok(instance)
             }
-            _ => Err(TransportError::InvalidInstance("uart", instance.to_string()).into()),
+            _ => Err(TransportError::InvalidInstance(
+                TransportInterfaceType::Uart,
+                instance.to_string(),
+            )
+            .into()),
         }
     }
 
diff --git a/sw/host/opentitanlib/src/transport/hyperdebug/spi.rs b/sw/host/opentitanlib/src/transport/hyperdebug/spi.rs
index 96d2df0..5b96253 100644
--- a/sw/host/opentitanlib/src/transport/hyperdebug/spi.rs
+++ b/sw/host/opentitanlib/src/transport/hyperdebug/spi.rs
@@ -2,14 +2,15 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{ensure, Result};
 use rusb::{Direction, Recipient, RequestType};
 use std::mem::size_of;
 use std::rc::Rc;
 use zerocopy::{AsBytes, FromBytes};
 
+use crate::ensure;
 use crate::io::spi::{SpiError, Target, Transfer, TransferMode};
-use crate::transport::hyperdebug::{BulkInterface, Error, Inner};
+use crate::transport::hyperdebug::{BulkInterface, Inner};
+use crate::transport::{Result, TransportError};
 
 pub struct HyperdebugSpiTarget {
     inner: Rc<Inner>,
@@ -136,16 +137,22 @@
         let rc = usb_handle.read_bulk(spi_interface.in_endpoint, resp.as_bytes_mut())?;
         ensure!(
             rc == size_of::<RspUsbSpiConfig>(),
-            Error::CommunicationError("Unrecognized reponse to GET_USB_SPI_CONFIG")
+            TransportError::CommunicationError(
+                "Unrecognized reponse to GET_USB_SPI_CONFIG".to_string()
+            )
         );
         ensure!(
             resp.packet_id == USB_SPI_PKT_ID_RSP_USB_SPI_CONFIG,
-            Error::CommunicationError("Unrecognized reponse to GET_USB_SPI_CONFIG")
+            TransportError::CommunicationError(
+                "Unrecognized reponse to GET_USB_SPI_CONFIG".to_string()
+            )
         );
         // Verify that interface supports concurrent read/write.
         ensure!(
             (resp.feature_bitmap & 0x0001) != 0,
-            Error::CommunicationError("HyperDebug does not support bidirectional SPI")
+            TransportError::CommunicationError(
+                "HyperDebug does not support bidirectional SPI".to_string()
+            )
         );
 
         Ok(Self {
@@ -183,15 +190,19 @@
         let bytecount = self.usb_read_bulk(&mut resp.as_bytes_mut())?;
         ensure!(
             bytecount >= 4,
-            Error::CommunicationError("Unrecognized reponse to TRANSFER_START")
+            TransportError::CommunicationError(
+                "Unrecognized reponse to TRANSFER_START".to_string()
+            )
         );
         ensure!(
             resp.packet_id == USB_SPI_PKT_ID_RSP_TRANSFER_START,
-            Error::CommunicationError("Unrecognized reponse to TRANSFER_START")
+            TransportError::CommunicationError(
+                "Unrecognized reponse to TRANSFER_START".to_string()
+            )
         );
         ensure!(
             resp.status_code == 0,
-            Error::CommunicationError("SPI error")
+            TransportError::CommunicationError("SPI error".to_string())
         );
         let databytes = bytecount - 4;
         rbuf[0..databytes].clone_from_slice(&resp.data[0..databytes]);
@@ -201,15 +212,21 @@
             let bytecount = self.usb_read_bulk(&mut resp.as_bytes_mut())?;
             ensure!(
                 bytecount > 4,
-                Error::CommunicationError("Unrecognized reponse to TRANSFER_START")
+                TransportError::CommunicationError(
+                    "Unrecognized reponse to TRANSFER_START".to_string()
+                )
             );
             ensure!(
                 resp.packet_id == USB_SPI_PKT_ID_RSP_TRANSFER_CONTINUE,
-                Error::CommunicationError("Unrecognized reponse to TRANSFER_START")
+                TransportError::CommunicationError(
+                    "Unrecognized reponse to TRANSFER_START".to_string()
+                )
             );
             ensure!(
                 resp.data_index == index as u16,
-                Error::CommunicationError("Unexpected byte index in reponse to TRANSFER_START")
+                TransportError::CommunicationError(
+                    "Unexpected byte index in reponse to TRANSFER_START".to_string()
+                )
             );
             let databytes = bytecount - 4;
             rbuf[index..index + databytes].clone_from_slice(&resp.data[0..0 + databytes]);
diff --git a/sw/host/opentitanlib/src/transport/hyperdebug/uart.rs b/sw/host/opentitanlib/src/transport/hyperdebug/uart.rs
deleted file mode 100644
index af4ebcd..0000000
--- a/sw/host/opentitanlib/src/transport/hyperdebug/uart.rs
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright lowRISC contributors.
-// Licensed under the Apache License, Version 2.0, see LICENSE for details.
-// SPDX-License-Identifier: Apache-2.0
-
-use anyhow::Result;
-
-use std::cell::RefCell;
-use std::io::Read;
-use std::io::Write;
-use std::path::Path;
-use std::time::Duration;
-
-use crate::io::uart::Uart;
-use crate::transport::hyperdebug::Error;
-
-pub struct HyperdebugUart {
-    port: RefCell<Box<dyn serialport::SerialPort>>,
-}
-
-impl HyperdebugUart {
-    pub fn open(tty: &Path) -> Result<Self> {
-        let port = serialport::new(tty.to_str().ok_or(Error::UnicodePathError)?, 115_200)
-            .timeout(Duration::from_millis(100))
-            .open()
-            .expect("Failed to open port");
-        Ok(HyperdebugUart {
-            port: RefCell::new(port),
-        })
-    }
-
-    // Not really forever, but close enough.  I'd rather use Duration::MAX, but
-    // it seems that the serialport library can compute an invalid `timeval` struct
-    // to pass to `poll`, which then leads to an `Invalid argument` error when
-    // trying to `read` or `write` without a timeout.  One hundred years should be
-    // longer than any invocation of this program.
-    const FOREVER: Duration = Duration::from_secs(100 * 365 * 86400);
-}
-
-impl Uart for HyperdebugUart {
-    fn read(&self, buf: &mut [u8]) -> Result<usize> {
-        self.port.borrow_mut().set_timeout(Self::FOREVER)?;
-        Ok(self.port.borrow_mut().read(buf)?)
-    }
-
-    fn write(&self, mut buf: &[u8]) -> Result<()> {
-        while buf.len() > 0 {
-            let written = self.port.borrow_mut().write(buf)?;
-            buf = &buf[written..];
-        }
-        Ok(())
-    }
-
-    fn get_baudrate(&self) -> u32 {
-        match self.port.borrow().baud_rate() {
-            Ok(baud_rate) => baud_rate,
-            _ => panic!("SerialPort::baud_rate() returned Err"),
-        }
-    }
-
-    fn set_baudrate(&self, baudrate: u32) -> Result<()> {
-        Ok(self.port.borrow_mut().set_baud_rate(baudrate)?)
-    }
-
-    fn read_timeout(&self, buf: &mut [u8], timeout: Duration) -> Result<usize> {
-        self.port.borrow_mut().set_timeout(timeout)?;
-        Ok(self.port.borrow_mut().read(buf)?)
-    }
-}
diff --git a/sw/host/opentitanlib/src/transport/mod.rs b/sw/host/opentitanlib/src/transport/mod.rs
index 4b3744e..2afd2ec 100644
--- a/sw/host/opentitanlib/src/transport/mod.rs
+++ b/sw/host/opentitanlib/src/transport/mod.rs
@@ -2,24 +2,27 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{anyhow, Result};
 use bitflags::bitflags;
 use erased_serde::Serialize;
 use std::any::Any;
 use std::path::PathBuf;
 use std::rc::Rc;
-use thiserror::Error;
 
 use crate::io::gpio::GpioPin;
 use crate::io::i2c::Bus;
 use crate::io::spi::Target;
 use crate::io::uart::Uart;
 
+pub mod common;
 pub mod cw310;
 pub mod hyperdebug;
 pub mod ultradebug;
 pub mod verilator;
 
+// Export custom error types
+mod errors;
+pub use errors::{Result, TransportError, TransportInterfaceType, WrapInTransportError};
+
 bitflags! {
     /// A bitmap of capabilities which may be provided by a transport.
     pub struct Capability: u32 {
@@ -54,9 +57,9 @@
     }
 
     /// Checks that the requested capabilities are provided.
-    pub fn ok(&self) -> Result<()> {
+    pub fn ok(&self) -> anyhow::Result<()> {
         if self.capabilities & self.needed != self.needed {
-            Err(anyhow!(
+            Err(anyhow::anyhow!(
                 "Requested capabilities {:?}, but capabilities {:?} are supplied",
                 self.needed,
                 self.capabilities
@@ -67,15 +70,6 @@
     }
 }
 
-/// Errors related to the SPI interface and SPI transactions.
-#[derive(Error, Debug)]
-pub enum TransportError {
-    #[error("This transport does not support {0} instance {1}")]
-    InvalidInstance(&'static str, String),
-    #[error("This transport does not support the requested operation")]
-    UnsupportedOperation,
-}
-
 /// A transport object is a factory for the low-level interfaces provided
 /// by a given communications backend.
 pub trait Transport {
@@ -128,14 +122,14 @@
     use super::*;
 
     #[test]
-    fn test_capabilities_met() -> Result<()> {
+    fn test_capabilities_met() -> anyhow::Result<()> {
         let mut cap = Capabilities::new(Capability::UART | Capability::SPI);
         assert!(cap.request(Capability::UART).ok().is_ok());
         Ok(())
     }
 
     #[test]
-    fn test_capabilities_not_met() -> Result<()> {
+    fn test_capabilities_not_met() -> anyhow::Result<()> {
         let mut cap = Capabilities::new(Capability::UART | Capability::SPI);
         assert!(cap.request(Capability::GPIO).ok().is_err());
         Ok(())
diff --git a/sw/host/opentitanlib/src/transport/ultradebug/gpio.rs b/sw/host/opentitanlib/src/transport/ultradebug/gpio.rs
index 1eefe83..167c5d3 100644
--- a/sw/host/opentitanlib/src/transport/ultradebug/gpio.rs
+++ b/sw/host/opentitanlib/src/transport/ultradebug/gpio.rs
@@ -2,18 +2,18 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{ensure, Result};
 use lazy_static::lazy_static;
 use safe_ftdi as ftdi;
 use std::cell::RefCell;
 use std::collections::HashMap;
 use std::rc::Rc;
 
-use crate::collection;
 use crate::io::gpio::{GpioError, GpioPin, PinMode, PullMode};
 use crate::transport::ultradebug::mpsse;
 use crate::transport::ultradebug::Ultradebug;
+use crate::transport::{Result, TransportError, WrapInTransportError};
 use crate::util::parse_int::ParseInt;
+use crate::{collection, ensure};
 
 /// Represents the Ultradebug GPIO pins.
 pub struct UltradebugGpio {
@@ -73,13 +73,14 @@
 impl GpioPin for UltradebugGpioPin {
     /// Reads the value of the the GPIO pin `id`.
     fn read(&self) -> Result<bool> {
-        let bits = self.device.borrow_mut().gpio_get()?;
+        let bits = self.device.borrow_mut().gpio_get().wrap(TransportError::FtdiError)?;
         Ok(bits & (1 << self.pin_id) != 0)
     }
 
     /// Sets the value of the GPIO pin `id` to `value`.
     fn write(&self, value: bool) -> Result<()> {
-        self.device.borrow_mut().gpio_set(self.pin_id, value)
+        self.device.borrow_mut().gpio_set(self.pin_id, value).wrap(TransportError::FtdiError)?;
+        Ok(())
     }
 
     /// Sets the `direction` of GPIO `id` as input or output.
@@ -87,15 +88,17 @@
         let direction = match mode {
             PinMode::Input => false,
             PinMode::PushPull => true,
-            PinMode::OpenDrain => return Err(GpioError::UnsupportedPinMode().into()),
+            PinMode::OpenDrain => return Err(GpioError::UnsupportedPinMode(mode).into()),
         };
         self.device
             .borrow_mut()
             .gpio_set_direction(self.pin_id, direction)
+            .wrap(TransportError::FtdiError)?;
+        Ok(())
     }
 
-    fn set_pull_mode(&self, _mode: PullMode) -> Result<()> {
-        Err(GpioError::UnsupportedPinMode().into())
+    fn set_pull_mode(&self, mode: PullMode) -> Result<()> {
+        Err(GpioError::UnsupportedPullMode(mode).into())
     }
 }
 
diff --git a/sw/host/opentitanlib/src/transport/ultradebug/mod.rs b/sw/host/opentitanlib/src/transport/ultradebug/mod.rs
index 181a6ac..95f139d 100644
--- a/sw/host/opentitanlib/src/transport/ultradebug/mod.rs
+++ b/sw/host/opentitanlib/src/transport/ultradebug/mod.rs
@@ -2,8 +2,6 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{bail, ensure, Result};
-
 use safe_ftdi as ftdi;
 use std::cell::RefCell;
 use std::rc::Rc;
@@ -11,7 +9,11 @@
 use crate::io::gpio::GpioPin;
 use crate::io::spi::Target;
 use crate::io::uart::Uart;
-use crate::transport::{Capabilities, Capability, Transport, TransportError};
+use crate::transport::{
+    Capabilities, Capability, Result, Transport, TransportError, TransportInterfaceType,
+    WrapInTransportError,
+};
+use crate::{bail, ensure};
 
 pub mod gpio;
 pub mod mpsse;
@@ -58,7 +60,8 @@
             self.usb_pid.unwrap_or(Ultradebug::PID_ULTRADEBUG),
             None,
             self.usb_serial.clone(),
-        )?)
+        )
+        .wrap(TransportError::FtdiError)?)
     }
 
     // Create an instance of an MPSSE context bound to Ultradebug interface B.
@@ -71,7 +74,7 @@
             device.set_timeouts(5000, 5000);
 
             // Create a new MPSSE context and configure it
-            let mut mpdev = mpsse::Context::new(device)?;
+            let mut mpdev = mpsse::Context::new(device).wrap(TransportError::FtdiError)?;
             mpdev.gpio_direction.insert(
                 mpsse::GpioDirection::OUT_0 |   // Clock out
                 mpsse::GpioDirection::OUT_1 |   // Master out
@@ -83,7 +86,7 @@
                 mpsse::GpioDirection::OUT_7, // TGT_RESET
             );
 
-            let _ = mpdev.gpio_get()?;
+            let _ = mpdev.gpio_get().wrap(TransportError::FtdiError)?;
             // Clear the low 3 bits as they are mapped to the SPI pins.
             // The SPI chip select is managed like a normal GPIO.
             // We don't need to change the GPIOs immediately; it is sufficient
@@ -102,10 +105,10 @@
             // For now, only interface B is supported.
             ftdi::Interface::B => self.mpsse_interface_b(),
             _ => {
-                bail!(
+                bail!(TransportError::UsbOpenError(format!(
                     "I don't know how to create an MPSSE context for interface {:?}",
                     interface
-                );
+                )));
             }
         }
     }
@@ -119,11 +122,13 @@
     fn uart(&self, instance: &str) -> Result<Rc<dyn Uart>> {
         ensure!(
             instance == "0",
-            TransportError::InvalidInstance("uart", instance.to_string())
+            TransportError::InvalidInstance(TransportInterfaceType::Uart, instance.to_string())
         );
         let mut inner = self.inner.borrow_mut();
         if inner.uart.is_none() {
-            inner.uart = Some(Rc::new(uart::UltradebugUart::open(self)?));
+            inner.uart = Some(Rc::new(
+                uart::UltradebugUart::open(self)?,
+            ));
         }
         Ok(Rc::clone(inner.uart.as_ref().unwrap()))
     }
@@ -139,7 +144,7 @@
     fn spi(&self, instance: &str) -> Result<Rc<dyn Target>> {
         ensure!(
             instance == "0",
-            TransportError::InvalidInstance("spi", instance.to_string())
+            TransportError::InvalidInstance(TransportInterfaceType::Spi, instance.to_string())
         );
         let mut inner = self.inner.borrow_mut();
         if inner.spi.is_none() {
diff --git a/sw/host/opentitanlib/src/transport/ultradebug/mpsse.rs b/sw/host/opentitanlib/src/transport/ultradebug/mpsse.rs
index 324ded7..4bfbd47 100644
--- a/sw/host/opentitanlib/src/transport/ultradebug/mpsse.rs
+++ b/sw/host/opentitanlib/src/transport/ultradebug/mpsse.rs
@@ -2,14 +2,16 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{bail, Result};
+use anyhow::Result;
 use bitflags::bitflags;
 use log;
 use safe_ftdi as ftdi;
 use std::time::{Duration, Instant};
 use thiserror::Error;
 
+use crate::bail;
 use crate::io::gpio::GpioError;
+use crate::io::spi::SpiError;
 
 pub const MPSSE_WRCLK_FALLING: u8 = 0x01;
 pub const MPSSE_RDCLK_FALLING: u8 = 0x04;
@@ -140,22 +142,25 @@
         match self {
             Command::ReadData(options, data) => {
                 if data.len() > Command::MAX_LENGTH {
-                    bail!(Error::InvalidDataLength(data.len()));
+                    bail!(SpiError::InvalidDataLength(data.len()));
                 }
                 buf.push(options.as_opcode());
                 buf.extend_from_slice(&((data.len() - 1) as u16).to_le_bytes());
             }
             Command::WriteData(options, data) => {
                 if data.len() > Command::MAX_LENGTH {
-                    bail!(Error::InvalidDataLength(data.len()));
+                    bail!(SpiError::InvalidDataLength(data.len()));
                 }
                 buf.push(options.as_opcode());
                 buf.extend_from_slice(&((data.len() - 1) as u16).to_le_bytes());
                 buf.extend(data.iter());
             }
             Command::TransactData(woptions, wdata, roptions, rdata) => {
-                if wdata.len() > Command::MAX_LENGTH || wdata.len() != rdata.len() {
-                    bail!(Error::InvalidDataLength(wdata.len()));
+                if wdata.len() > Command::MAX_LENGTH {
+                    bail!(SpiError::InvalidDataLength(wdata.len()));
+                }
+                if wdata.len() != rdata.len() {
+                    bail!(SpiError::MismatchedDataLength(wdata.len(), rdata.len()));
                 }
                 buf.push(woptions.as_opcode() | roptions.as_opcode());
                 buf.extend_from_slice(&((wdata.len() - 1) as u16).to_le_bytes());
@@ -186,12 +191,8 @@
 
 #[derive(Error, Debug)]
 pub enum Error {
-    #[error("timeout waiting for MPSSE")]
-    MpsseTimeout,
     #[error("unknown MPSSE error: {0:02x} {1:02x}")]
     MpsseUnknown(u8, u8),
-    #[error("Invalid data length: {0}")]
-    InvalidDataLength(usize),
 }
 
 /// An MPSSE `Context` is the high-level interface to an MPSSE FTDI interface.
@@ -238,7 +239,7 @@
         let mut rxlen = 0;
         while rxlen < rxbuf.len() {
             if Instant::now() > deadline {
-                return Err(Error::MpsseTimeout.into());
+                return Ok(0);
             }
             let n = self.device.read_data(&mut rxbuf[rxlen..])?;
             rxlen += n as usize;
diff --git a/sw/host/opentitanlib/src/transport/ultradebug/spi.rs b/sw/host/opentitanlib/src/transport/ultradebug/spi.rs
index 80b02c7..28d564d 100644
--- a/sw/host/opentitanlib/src/transport/ultradebug/spi.rs
+++ b/sw/host/opentitanlib/src/transport/ultradebug/spi.rs
@@ -2,7 +2,6 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
 use log;
 use safe_ftdi as ftdi;
 use std::cell::RefCell;
@@ -11,6 +10,7 @@
 use crate::io::spi::{ClockPolarity, SpiError, Target, Transfer, TransferMode};
 use crate::transport::ultradebug::mpsse;
 use crate::transport::ultradebug::Ultradebug;
+use crate::transport::{Result, TransportError, WrapInTransportError};
 
 struct Inner {
     mode: TransferMode,
@@ -36,7 +36,7 @@
         log::debug!("Setting SPI_ZB");
         mpsse
             .borrow_mut()
-            .gpio_set(UltradebugSpi::PIN_SPI_ZB, false)?;
+            .gpio_set(UltradebugSpi::PIN_SPI_ZB, false).wrap(TransportError::FtdiError)?;
 
         Ok(UltradebugSpi {
             device: mpsse,
@@ -71,7 +71,8 @@
     }
     fn set_max_speed(&self, frequency: u32) -> Result<()> {
         let mut device = self.device.borrow_mut();
-        device.set_clock_frequency(frequency)
+        device.set_clock_frequency(frequency).wrap(TransportError::FtdiError)?;
+        Ok(())
     }
 
     fn get_max_transfer_count(&self) -> usize {
@@ -139,6 +140,7 @@
             device.gpio_direction,
             device.gpio_value | chip_select,
         ));
-        device.execute(&mut command)
+        device.execute(&mut command).wrap(TransportError::FtdiError)?;
+        Ok(())
     }
 }
diff --git a/sw/host/opentitanlib/src/transport/ultradebug/uart.rs b/sw/host/opentitanlib/src/transport/ultradebug/uart.rs
index 7af655b..0c5ff1f 100644
--- a/sw/host/opentitanlib/src/transport/ultradebug/uart.rs
+++ b/sw/host/opentitanlib/src/transport/ultradebug/uart.rs
@@ -2,15 +2,15 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
 use safe_ftdi as ftdi;
 use std::cell::RefCell;
 use std::cmp;
 use std::thread;
 use std::time::{Duration, Instant};
 
-use crate::io::uart::Uart;
+use crate::io::uart::{Uart, UartError};
 use crate::transport::ultradebug::Ultradebug;
+use crate::transport::{Result, TransportError, WrapInTransportError};
 
 pub struct Inner {
     device: ftdi::Device,
@@ -24,9 +24,9 @@
 
 impl UltradebugUart {
     pub fn open(ultradebug: &Ultradebug) -> Result<Self> {
-        let device = ultradebug.from_interface(ftdi::Interface::C)?;
-        device.set_bitmode(0, ftdi::BitMode::Reset)?;
-        device.set_baudrate(115200)?;
+        let device = ultradebug.from_interface(ftdi::Interface::C).wrap(TransportError::FtdiError)?;
+        device.set_bitmode(0, ftdi::BitMode::Reset).wrap(TransportError::FtdiError)?;
+        device.set_baudrate(115200).wrap(TransportError::FtdiError)?;
         // Read and write timeouts:
         device.set_timeouts(5000, 5000);
         Ok(UltradebugUart {
@@ -45,21 +45,21 @@
 
     fn set_baudrate(&self, baudrate: u32) -> Result<()> {
         let mut inner = self.inner.borrow_mut();
-        inner.device.set_baudrate(baudrate)?;
+        inner.device.set_baudrate(baudrate).wrap(TransportError::FtdiError)?;
         inner.baudrate = baudrate;
         Ok(())
     }
 
     fn read_timeout(&self, buf: &mut [u8], timeout: Duration) -> Result<usize> {
         let now = Instant::now();
-        let count = self.read(buf)?;
+        let count = self.read(buf).wrap(UartError::ReadError)?;
         if count > 0 {
             return Ok(count);
         }
         let short_delay = cmp::min(timeout.mul_f32(0.1), Duration::from_millis(20));
         while now.elapsed() < timeout {
             thread::sleep(short_delay);
-            let count = self.read(buf)?;
+            let count = self.read(buf).wrap(UartError::ReadError)?;
             if count > 0 {
                 return Ok(count);
             }
@@ -68,14 +68,14 @@
     }
 
     fn read(&self, buf: &mut [u8]) -> Result<usize> {
-        let n = self.inner.borrow().device.read_data(buf)?;
+        let n = self.inner.borrow().device.read_data(buf).wrap(UartError::ReadError)?;
         Ok(n as usize)
     }
 
     fn write(&self, mut buf: &[u8]) -> Result<()> {
         let inner = self.inner.borrow();
         while buf.len() > 0 {
-            let n = inner.device.write_data(buf)?;
+            let n = inner.device.write_data(buf).wrap(UartError::WriteError)?;
             buf = &buf[n as usize..];
         }
         Ok(())
diff --git a/sw/host/opentitanlib/src/transport/verilator/transport.rs b/sw/host/opentitanlib/src/transport/verilator/transport.rs
index 046501c..9417072 100644
--- a/sw/host/opentitanlib/src/transport/verilator/transport.rs
+++ b/sw/host/opentitanlib/src/transport/verilator/transport.rs
@@ -2,7 +2,7 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{ensure, Result};
+use anyhow::Result;
 use lazy_static::lazy_static;
 use log::info;
 use regex::Regex;
@@ -10,10 +10,13 @@
 use std::rc::Rc;
 use std::time::Duration;
 
+use crate::ensure;
 use crate::io::uart::Uart;
 use crate::transport::verilator::subprocess::{Options, Subprocess};
 use crate::transport::verilator::uart::VerilatorUart;
-use crate::transport::{Capabilities, Capability, Transport, TransportError};
+use crate::transport::{
+    Capabilities, Capability, Transport, TransportError, TransportInterfaceType,
+};
 
 #[derive(Default)]
 struct Inner {
@@ -85,10 +88,10 @@
         Capabilities::new(Capability::UART)
     }
 
-    fn uart(&self, instance: &str) -> Result<Rc<dyn Uart>> {
+    fn uart(&self, instance: &str) -> Result<Rc<dyn Uart>, TransportError> {
         ensure!(
             instance == "0",
-            TransportError::InvalidInstance("uart", instance.to_string())
+            TransportError::InvalidInstance(TransportInterfaceType::Uart, instance.to_string())
         );
         let mut inner = self.inner.borrow_mut();
         if inner.uart.is_none() {
diff --git a/sw/host/opentitanlib/src/transport/verilator/uart.rs b/sw/host/opentitanlib/src/transport/verilator/uart.rs
index 3759ca7..91c7d50 100644
--- a/sw/host/opentitanlib/src/transport/verilator/uart.rs
+++ b/sw/host/opentitanlib/src/transport/verilator/uart.rs
@@ -2,14 +2,15 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::Result;
 use std::cell::RefCell;
 use std::fs::File;
 use std::fs::OpenOptions;
-use std::io::{Read, Write};
+use std::io;
+use std::io::{ErrorKind, Read, Write};
 use std::time::Duration;
 
-use crate::io::uart::Uart;
+use crate::io::uart::{Uart, UartError};
+use crate::transport::{Result, TransportError, WrapInTransportError};
 use crate::util::file;
 
 /// Represents the verilator virtual UART.
@@ -20,7 +21,8 @@
 impl VerilatorUart {
     pub fn open(path: &str) -> Result<Self> {
         Ok(VerilatorUart {
-            file: RefCell::new(OpenOptions::new().read(true).write(true).open(path)?),
+            file: RefCell::new(OpenOptions::new().read(true).write(true).open(path)
+                               .wrap(|e| TransportError::OpenError(path.to_string(), e))?),
         })
     }
 }
@@ -39,15 +41,26 @@
 
     fn read_timeout(&self, buf: &mut [u8], timeout: Duration) -> Result<usize> {
         let mut file = self.file.borrow_mut();
-        file::wait_read_timeout(&*file, timeout)?;
-        Ok(file.read(buf)?)
+        match file::wait_read_timeout(&*file, timeout) {
+            Ok(()) => Ok(file.read(buf).wrap(UartError::ReadError)?),
+            Err(e) => {
+                // If we got a timeout from the uart, return 0 as per convention.
+                // Let all other errors propagate (wrapped in TransportError).
+                if let Some(ioerr) = e.downcast_ref::<io::Error>() {
+                    if ioerr.kind() != ErrorKind::TimedOut {
+                        return Ok(0);
+                    }
+                }
+                Err(e).wrap(UartError::ReadError)
+            }
+        }
     }
 
     fn read(&self, buf: &mut [u8]) -> Result<usize> {
-        Ok(self.file.borrow_mut().read(buf)?)
+        Ok(self.file.borrow_mut().read(buf).wrap(UartError::ReadError)?)
     }
 
     fn write(&self, buf: &[u8]) -> Result<()> {
-        Ok(self.file.borrow_mut().write_all(buf)?)
+        Ok(self.file.borrow_mut().write_all(buf).wrap(UartError::WriteError)?)
     }
 }
diff --git a/sw/host/opentitanlib/src/util/usb.rs b/sw/host/opentitanlib/src/util/usb.rs
index f2cac88..5f6d06d 100644
--- a/sw/host/opentitanlib/src/util/usb.rs
+++ b/sw/host/opentitanlib/src/util/usb.rs
@@ -2,18 +2,11 @@
 // Licensed under the Apache License, Version 2.0, see LICENSE for details.
 // SPDX-License-Identifier: Apache-2.0
 
-use anyhow::{ensure, Result};
 use rusb;
 use std::time::Duration;
-use thiserror::Error;
 
-#[derive(Debug, Error)]
-pub enum Error {
-    #[error("Could not find USB device")]
-    NotFound,
-    #[error("Found multiple USB devices, use --serial")]
-    MultipleDevices,
-}
+use crate::ensure;
+use crate::transport::{Result, TransportError, WrapInTransportError};
 
 /// The `UsbBackend` provides low-level USB access to debugging devices.
 pub struct UsbBackend {
@@ -32,7 +25,7 @@
         usb_serial: Option<&str>,
     ) -> Result<Vec<(rusb::Device<rusb::GlobalContext>, String)>> {
         let mut devices = Vec::new();
-        for device in rusb::devices()?.iter() {
+        for device in rusb::devices().wrap(TransportError::UsbGenericError)?.iter() {
             let descriptor = match device.device_descriptor() {
                 Ok(desc) => desc,
                 _ => {
@@ -85,12 +78,12 @@
     /// Create a new UsbBackend.
     pub fn new(usb_vid: u16, usb_pid: u16, usb_serial: Option<&str>) -> Result<Self> {
         let mut devices = UsbBackend::scan(usb_vid, usb_pid, usb_serial)?;
-        ensure!(!devices.is_empty(), Error::NotFound);
-        ensure!(devices.len() == 1, Error::MultipleDevices);
+        ensure!(!devices.is_empty(), TransportError::NoDevice);
+        ensure!(devices.len() == 1, TransportError::MultipleDevices);
 
         let (device, serial_number) = devices.remove(0);
         Ok(UsbBackend {
-            handle: device.open()?,
+            handle: device.open().wrap(TransportError::UsbOpenError)?,
             device,
             serial_number,
             timeout: Duration::from_millis(500),
@@ -109,11 +102,11 @@
     //
 
     pub fn claim_interface(&mut self, iface: u8) -> Result<()> {
-        Ok(self.handle.claim_interface(iface)?)
+        Ok(self.handle.claim_interface(iface).wrap(TransportError::UsbGenericError)?)
     }
 
     pub fn active_config_descriptor(&self) -> Result<rusb::ConfigDescriptor> {
-        Ok(self.device.active_config_descriptor()?)
+        Ok(self.device.active_config_descriptor().wrap(TransportError::UsbGenericError)?)
     }
 
     pub fn bus_number(&self) -> u8 {
@@ -121,11 +114,11 @@
     }
 
     pub fn port_numbers(&self) -> Result<Vec<u8>> {
-        Ok(self.device.port_numbers()?)
+        Ok(self.device.port_numbers().wrap(TransportError::UsbGenericError)?)
     }
 
     pub fn read_string_descriptor_ascii(&self, idx: u8) -> Result<String> {
-        Ok(self.handle.read_string_descriptor_ascii(idx)?)
+        Ok(self.handle.read_string_descriptor_ascii(idx).wrap(TransportError::UsbGenericError)?)
     }
 
     //
@@ -143,7 +136,8 @@
     ) -> Result<usize> {
         Ok(self
             .handle
-            .write_control(request_type, request, value, index, buf, self.timeout)?)
+            .write_control(request_type, request, value, index, buf, self.timeout)
+            .wrap(TransportError::UsbGenericError)?)
     }
 
     /// Issue a USB control request with optional device-to-host data.
@@ -157,18 +151,25 @@
     ) -> Result<usize> {
         Ok(self
             .handle
-            .read_control(request_type, request, value, index, buf, self.timeout)?)
+            .read_control(request_type, request, value, index, buf, self.timeout)
+            .wrap(TransportError::UsbGenericError)?)
     }
 
     /// Read bulk data bytes to given USB endpoint.
     pub fn read_bulk(&self, endpoint: u8, data: &mut [u8]) -> Result<usize> {
-        let len = self.handle.read_bulk(endpoint, data, self.timeout)?;
+        let len = self
+            .handle
+            .read_bulk(endpoint, data, self.timeout)
+            .wrap(TransportError::UsbGenericError)?;
         Ok(len)
     }
 
     /// Write bulk data bytes to given USB endpoint.
     pub fn write_bulk(&self, endpoint: u8, data: &[u8]) -> Result<usize> {
-        let len = self.handle.write_bulk(endpoint, data, self.timeout)?;
+        let len = self
+            .handle
+            .write_bulk(endpoint, data, self.timeout)
+            .wrap(TransportError::UsbGenericError)?;
         Ok(len)
     }
 }
diff --git a/sw/host/opentitantool/src/command/bootstrap.rs b/sw/host/opentitantool/src/command/bootstrap.rs
index aa552d5..93872e6 100644
--- a/sw/host/opentitantool/src/command/bootstrap.rs
+++ b/sw/host/opentitantool/src/command/bootstrap.rs
@@ -51,9 +51,9 @@
             !(self.filename.len() > 1 || self.filename[0].contains('@')),
             "The `emulator` protocol does not support image assembly"
         );
-        transport.dispatch(&transport::Bootstrap {
+        Ok(transport.dispatch(&transport::Bootstrap {
             image_path: PathBuf::from(&self.filename[0]),
-        })
+        })?)
     }
 
     fn payload(&self) -> Result<Vec<u8>> {
diff --git a/sw/host/opentitantool/src/command/console.rs b/sw/host/opentitantool/src/command/console.rs
index 51febe7..f978fda 100644
--- a/sw/host/opentitantool/src/command/console.rs
+++ b/sw/host/opentitantool/src/command/console.rs
@@ -9,8 +9,7 @@
 use regex::Regex;
 use std::any::Any;
 use std::fs::File;
-use std::io;
-use std::io::{ErrorKind, Read, Write};
+use std::io::{Read, Write};
 use std::os::unix::io::AsRawFd;
 use std::time::{Duration, Instant};
 use structopt::StructOpt;
@@ -192,45 +191,33 @@
         stdout: &mut impl Write,
     ) -> Result<ExitStatus> {
         let mut buf = [0u8; 256];
-        match uart.read_timeout(&mut buf, timeout) {
-            Ok(len) => {
-                stdout.write_all(&buf[..len])?;
-                stdout.flush()?;
+        let len = uart.read_timeout(&mut buf, timeout)?;
+        if len > 0 {
+            stdout.write_all(&buf[..len])?;
+            stdout.flush()?;
 
-                // If we're logging, save it to the logfile.
-                if let Some(logfile) = &mut self.logfile {
-                    logfile.write_all(&buf[..len])?;
-                }
-
-                // If we have exit condition regexes check them.
-                self.append_buffer(&buf[..len]);
-                if self
-                    .exit_success
-                    .as_ref()
-                    .map(|rx| rx.is_match(&self.buffer))
-                    == Some(true)
-                {
-                    return Ok(ExitStatus::ExitSuccess);
-                }
-                if self
-                    .exit_failure
-                    .as_ref()
-                    .map(|rx| rx.is_match(&self.buffer))
-                    == Some(true)
-                {
-                    return Ok(ExitStatus::ExitFailure);
-                }
+            // If we're logging, save it to the logfile.
+            if let Some(logfile) = &mut self.logfile {
+                logfile.write_all(&buf[..len])?;
             }
-            Err(e) => {
-                // If we got a timeout from the uart, ignore it.
-                // Return all other errors.
-                if let Some(ioerr) = e.downcast_ref::<io::Error>() {
-                    if ioerr.kind() != ErrorKind::TimedOut {
-                        return Err(e);
-                    }
-                } else {
-                    return Err(e);
-                }
+
+            // If we have exit condition regexes check them.
+            self.append_buffer(&buf[..len]);
+            if self
+                .exit_success
+                .as_ref()
+                .map(|rx| rx.is_match(&self.buffer))
+                == Some(true)
+            {
+                return Ok(ExitStatus::ExitSuccess);
+            }
+            if self
+                .exit_failure
+                .as_ref()
+                .map(|rx| rx.is_match(&self.buffer))
+                == Some(true)
+            {
+                return Ok(ExitStatus::ExitFailure);
             }
         }
         Ok(ExitStatus::None)
diff --git a/sw/host/opentitantool/src/command/load_bitstream.rs b/sw/host/opentitantool/src/command/load_bitstream.rs
index 261545b..bbc9174 100644
--- a/sw/host/opentitantool/src/command/load_bitstream.rs
+++ b/sw/host/opentitantool/src/command/load_bitstream.rs
@@ -26,8 +26,8 @@
         _context: &dyn Any,
         transport: &TransportWrapper,
     ) -> Result<Option<Box<dyn Serialize>>> {
-        transport.dispatch(&cw310::FpgaProgram {
+        Ok(transport.dispatch(&cw310::FpgaProgram {
             bitstream: fs::read(&self.filename)?,
-        })
+        })?)
     }
 }