diff --git a/Cargo.lock b/Cargo.lock
index 79aa3aeef6726adfbcf333daadc020a6598d9489..fbc96f04cf891edfccba6e0babd32cce2d6a18fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1799,6 +1799,16 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "io-kit-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
+dependencies = [
+ "core-foundation-sys",
+ "mach2",
+]
+
 [[package]]
 name = "ioctl-rs"
 version = "0.1.6"
@@ -1914,6 +1924,26 @@ dependencies = [
  "redox_syscall 0.5.8",
 ]
 
+[[package]]
+name = "libudev"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
+dependencies = [
+ "libc",
+ "libudev-sys",
+]
+
+[[package]]
+name = "libudev-sys"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
 [[package]]
 name = "linux-raw-sys"
 version = "0.4.14"
@@ -1948,6 +1978,15 @@ version = "0.4.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
 
+[[package]]
+name = "mach2"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "malloc_buf"
 version = "0.0.6"
@@ -2130,6 +2169,17 @@ dependencies = [
  "jni-sys",
 ]
 
+[[package]]
+name = "nix"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
+dependencies = [
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+]
+
 [[package]]
 name = "nix"
 version = "0.29.0"
@@ -2874,6 +2924,7 @@ dependencies = [
  "ring-channel",
  "serde",
  "serde_json",
+ "serialport",
  "skyward_mavlink",
  "strum",
  "strum_macros",
@@ -2978,6 +3029,25 @@ dependencies = [
  "serial-core",
 ]
 
+[[package]]
+name = "serialport"
+version = "4.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ecfc4858c2266c7695d8b8460bbd612fa81bd2e250f5f0dd16195e4b4f8b3d8"
+dependencies = [
+ "bitflags 2.6.0",
+ "cfg-if",
+ "core-foundation 0.10.0",
+ "core-foundation-sys",
+ "io-kit-sys",
+ "libudev",
+ "mach2",
+ "nix 0.26.4",
+ "scopeguard",
+ "unescaper",
+ "winapi",
+]
+
 [[package]]
 name = "sha1"
 version = "0.10.6"
@@ -3441,6 +3511,15 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "unescaper"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c878a167baa8afd137494101a688ef8c67125089ff2249284bd2b5f9bfedb815"
+dependencies = [
+ "thiserror 1.0.69",
+]
+
 [[package]]
 name = "unicase"
 version = "2.8.1"
@@ -4364,7 +4443,7 @@ dependencies = [
  "futures-sink",
  "futures-util",
  "hex",
- "nix",
+ "nix 0.29.0",
  "ordered-stream",
  "rand",
  "serde",
diff --git a/Cargo.toml b/Cargo.toml
index 74d48c05a7f7710372ada9a6f21f9ed8979fc846..be31c45f165358550e065d57e2ae05e09efa6070 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,6 +28,7 @@ skyward_mavlink = { git = "https://git.skywarder.eu/avn/swd/mavlink/mavlink-skyw
     "serde",
 ] }
 mavlink-bindgen = { version = "0.13.1", features = ["serde"] }
+serialport = "4.7.0"
 # ========= Persistency =========
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
diff --git a/src/main.rs b/src/main.rs
index df0dcc6b3874cde18eeee4307973f6d20715102a..baa1fb9f8d6916358b8d9e19bfce4f7a1fe94799 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,7 +4,9 @@
 
 mod error;
 mod mavlink;
+mod serial;
 mod ui;
+mod utils;
 
 use std::{
     num::NonZeroUsize,
diff --git a/src/mavlink/base.rs b/src/mavlink/base.rs
index a1aa3c53a4ef2bab982ecaaef808c7af0e66ffbe..063871967cae1df0f5dc50a98d2366a4878caa95 100644
--- a/src/mavlink/base.rs
+++ b/src/mavlink/base.rs
@@ -61,7 +61,9 @@ where
 }
 
 /// Read a stream of bytes and return an iterator of MavLink messages
-pub fn byte_parser(buf: &[u8]) -> impl Iterator<Item = (MavHeader, MavMessage)> + '_ {
-    let mut reader = PeekReader::new(buf);
+pub fn byte_parser<'a>(
+    reader: impl std::io::Read + 'a,
+) -> impl Iterator<Item = (MavHeader, MavMessage)> + 'a {
+    let mut reader = PeekReader::new(reader);
     std::iter::from_fn(move || read_v1_msg(&mut reader).ok())
 }
diff --git a/src/mavlink/message_broker.rs b/src/mavlink/message_broker.rs
index 2f611c806d0691163b1d3a4ed724685408ff7dd1..cb7e90c64e1ac7379c38fd5ea5429a6ddb90c6cd 100644
--- a/src/mavlink/message_broker.rs
+++ b/src/mavlink/message_broker.rs
@@ -7,6 +7,7 @@
 
 use std::{
     collections::{HashMap, VecDeque},
+    io::Write,
     num::NonZeroUsize,
     sync::{
         atomic::{AtomicBool, Ordering},
@@ -21,7 +22,7 @@ use tokio::{net::UdpSocket, task::JoinHandle};
 use tracing::{debug, trace};
 use uuid::Uuid;
 
-use crate::mavlink::byte_parser;
+use crate::{error::ErrInstrument, mavlink::byte_parser, utils::RingBuffer};
 
 use super::{MavlinkResult, Message, TimedMessage};
 
@@ -111,8 +112,7 @@ impl MessageBroker {
     }
 
     /// Start a listener task that listens to incoming messages from the given
-    /// Ethernet port, and accumulates them in a ring buffer, read only when
-    /// views request a refresh.
+    /// Ethernet port and stores them in a ring buffer.
     pub fn listen_from_ethernet_port(&mut self, port: u16) {
         // Stop the current listener if it exists
         self.stop_listening();
@@ -150,6 +150,51 @@ impl MessageBroker {
         self.task = Some(handle);
     }
 
+    /// Start a listener task that listens to incoming messages from the given
+    /// serial port and stores them in a ring buffer.
+    pub fn listen_from_serial_port(&mut self, port: String, baud_rate: u32) {
+        // Stop the current listener if it exists
+        self.stop_listening();
+        self.running_flag.store(true, Ordering::Relaxed);
+
+        let tx = self.tx.clone();
+        let ctx = self.ctx.clone();
+
+        let running_flag = self.running_flag.clone();
+
+        debug!("Spawning listener task at {}", port);
+        let handle = tokio::task::spawn_blocking(move || {
+            let mut serial_port = serialport::new(port, baud_rate)
+                .timeout(std::time::Duration::from_millis(100))
+                .open()
+                .context("Failed to open serial port")?;
+            debug!("Listening on serial port");
+
+            let mut ring_buf = RingBuffer::<1024>::new();
+            let mut temp_buf = [0; 512];
+            // need to do a better error handling for this (need toast errors)
+            while running_flag.load(Ordering::Relaxed) {
+                let result = serial_port
+                    .read(&mut temp_buf)
+                    .log_expect("Failed to read from serial port");
+                debug!("Read {} bytes from serial port", result);
+                trace!("data read from serial: {:?}", &temp_buf[..result]);
+                ring_buf
+                    .write(&temp_buf[..result])
+                    .log_expect("Failed to write to ring buffer, check buffer size");
+                for (_, mav_message) in byte_parser(&mut ring_buf) {
+                    debug!("Received message: {:?}", mav_message);
+                    tx.send(TimedMessage::just_received(mav_message))
+                        .context("Failed to send message")?;
+                    ctx.request_repaint();
+                }
+            }
+
+            Ok::<(), anyhow::Error>(())
+        });
+        self.task = Some(handle);
+    }
+
     pub fn unsubscribe_all_views(&mut self) {
         self.update_queues.clear();
     }
diff --git a/src/serial.rs b/src/serial.rs
new file mode 100644
index 0000000000000000000000000000000000000000..29ffb2d17feb44a2167696ec9c9e5226a151641d
--- /dev/null
+++ b/src/serial.rs
@@ -0,0 +1,28 @@
+//! Serial port utilities
+//!
+//! This module provides utilities for working with serial ports, such as listing all available serial ports and finding the first serial port that contains "STM32" or "ST-LINK" in its product name.
+
+use anyhow::Context;
+
+use crate::error::ErrInstrument;
+
+/// Get the first serial port that contains "STM32" or "ST-LINK" in its product name
+pub fn get_first_stm32_serial_port() -> Option<String> {
+    let ports = serialport::available_ports().log_expect("Serial ports cannot be listed!");
+    for port in ports {
+        if let serialport::SerialPortType::UsbPort(info) = port.port_type {
+            if let Some(p) = info.product {
+                if p.contains("STM32") || p.contains("ST-LINK") {
+                    return Some(port.port_name);
+                }
+            }
+        }
+    }
+    None
+}
+
+/// Get a list of all serial ports available on the system
+pub fn list_all_serial_ports() -> anyhow::Result<Vec<String>> {
+    let ports = serialport::available_ports().context("No serial ports found!")?;
+    Ok(ports.iter().map(|p| p.port_name.clone()).collect())
+}
diff --git a/src/ui/composable_view.rs b/src/ui/composable_view.rs
index 2d5ecd5e0d887f9e5441dabf00fa947cdd067236..743f366a8301290a22b5ebcf1054a562b5c921f9 100644
--- a/src/ui/composable_view.rs
+++ b/src/ui/composable_view.rs
@@ -1,4 +1,9 @@
-use crate::{error::ErrInstrument, mavlink, msg_broker, ui::panes::PaneKind};
+use crate::{
+    error::ErrInstrument,
+    mavlink, msg_broker,
+    serial::{get_first_stm32_serial_port, list_all_serial_ports},
+    ui::panes::PaneKind,
+};
 
 use super::{
     panes::{Pane, PaneBehavior},
@@ -12,7 +17,8 @@ use std::{
     path::{Path, PathBuf},
 };
 
-use egui::{Key, Modifiers};
+use egui::{Align2, Button, ComboBox, Key, Modifiers, Vec2};
+use egui_extras::{Size, StripBuilder};
 use egui_tiles::{Behavior, Container, Linear, LinearDir, Tile, TileId, Tiles, Tree};
 use serde::{Deserialize, Serialize};
 use tracing::{debug, error, trace};
@@ -272,29 +278,45 @@ impl ComposableViewState {
     }
 }
 
-struct SourceWindow {
-    port: u16,
-    visible: bool,
+#[derive(Debug, PartialEq, Eq, Default)]
+enum ConnectionKind {
+    #[default]
+    Ethernet,
+    Serial,
 }
 
-impl Default for SourceWindow {
+#[derive(Debug)]
+enum ConnectionDetails {
+    Ethernet { port: u16 },
+    Serial { port: String, baud_rate: u32 },
+}
+
+impl Default for ConnectionDetails {
     fn default() -> Self {
-        Self {
+        ConnectionDetails::Ethernet {
             port: mavlink::DEFAULT_ETHERNET_PORT,
-            visible: false,
         }
     }
 }
 
+#[derive(Debug, Default)]
+struct SourceWindow {
+    visible: bool,
+    connected: bool,
+    connection_kind: ConnectionKind,
+    connection_details: ConnectionDetails,
+}
+
 impl SourceWindow {
     fn show_window(&mut self, ui: &mut egui::Ui) {
         let mut window_is_open = self.visible;
         let mut can_be_closed = false;
         egui::Window::new("Sources")
             .id(ui.id())
-            .auto_sized()
+            .anchor(Align2::CENTER_CENTER, [0.0, 0.0])
+            .max_width(200.0)
             .collapsible(false)
-            .movable(true)
+            .resizable(false)
             .open(&mut window_is_open)
             .show(ui.ctx(), |ui| {
                 self.ui(ui, &mut can_be_closed);
@@ -303,22 +325,123 @@ impl SourceWindow {
     }
 
     fn ui(&mut self, ui: &mut egui::Ui, can_be_closed: &mut bool) {
-        egui::Grid::new(ui.id())
-            .num_columns(2)
-            .spacing([10.0, 5.0])
-            .show(ui, |ui| {
-                ui.label("Ethernet Port:");
-                ui.add(
-                    egui::DragValue::new(&mut self.port)
-                        .range(0..=65535)
-                        .speed(10),
-                );
-                ui.end_row();
-            });
-        if ui.button("Connect").clicked() {
-            msg_broker!().listen_from_ethernet_port(self.port);
-            *can_be_closed = true;
-        }
+        let SourceWindow {
+            connected,
+            connection_kind,
+            connection_details,
+            ..
+        } = self;
+        ui.label("Select Source:");
+        ui.horizontal_top(|ui| {
+            ui.radio_value(connection_kind, ConnectionKind::Ethernet, "Ethernet");
+            ui.radio_value(connection_kind, ConnectionKind::Serial, "Serial");
+        });
+
+        ui.separator();
+
+        match *connection_kind {
+            ConnectionKind::Ethernet => {
+                if !matches!(connection_details, ConnectionDetails::Ethernet { .. }) {
+                    *connection_details = ConnectionDetails::Ethernet {
+                        port: mavlink::DEFAULT_ETHERNET_PORT,
+                    };
+                }
+                let ConnectionDetails::Ethernet { port } = connection_details else {
+                    error!("UNREACHABLE: Connection kind is not Ethernet");
+                    unreachable!("Connection kind is not Ethernet");
+                };
+
+                egui::Grid::new("grid")
+                    .num_columns(2)
+                    .spacing([10.0, 5.0])
+                    .show(ui, |ui| {
+                        ui.label("Ethernet Port:");
+                        ui.add(egui::DragValue::new(port).range(0..=65535).speed(10));
+                        ui.end_row();
+                    });
+            }
+            ConnectionKind::Serial => {
+                if !matches!(connection_details, ConnectionDetails::Serial { .. }) {
+                    *connection_details = ConnectionDetails::Serial {
+                        // Default to the first STM32 serial port if available, otherwise
+                        // default to the first serial port available
+                        port: get_first_stm32_serial_port().unwrap_or(
+                            list_all_serial_ports()
+                                .ok()
+                                .and_then(|ports| ports.first().cloned())
+                                .unwrap_or_default(),
+                        ),
+                        baud_rate: 115200,
+                    };
+                }
+                let ConnectionDetails::Serial { port, baud_rate } = connection_details else {
+                    error!("UNREACHABLE: Connection kind is not Serial");
+                    unreachable!("Connection kind is not Serial");
+                };
+
+                egui::Grid::new("grid")
+                    .num_columns(2)
+                    .spacing([10.0, 5.0])
+                    .show(ui, |ui| {
+                        ui.label("Serial Port:");
+                        ComboBox::from_id_salt("serial_port")
+                            .selected_text(port.clone())
+                            .show_ui(ui, |ui| {
+                                for available_port in list_all_serial_ports().unwrap_or_default() {
+                                    ui.selectable_value(
+                                        port,
+                                        available_port.clone(),
+                                        available_port,
+                                    );
+                                }
+                            });
+                        ui.end_row();
+                        ui.label("Baud Rate:");
+                        ui.add(
+                            egui::DragValue::new(baud_rate)
+                                .range(110..=256000)
+                                .speed(100),
+                        );
+                        ui.end_row();
+                    });
+            }
+        };
+
+        ui.separator();
+
+        ui.allocate_ui(Vec2::new(ui.available_width(), 20.0), |ui| {
+            StripBuilder::new(ui)
+                .sizes(Size::remainder(), 2) // top cell
+                .horizontal(|mut strip| {
+                    strip.cell(|ui| {
+                        let btn1 = Button::new("Connect");
+                        ui.add_enabled_ui(!*connected, |ui| {
+                            if ui.add_sized(ui.available_size(), btn1).clicked() {
+                                match connection_details {
+                                    ConnectionDetails::Ethernet { port } => {
+                                        msg_broker!().listen_from_ethernet_port(*port);
+                                    }
+                                    ConnectionDetails::Serial { port, baud_rate } => {
+                                        msg_broker!()
+                                            .listen_from_serial_port(port.clone(), *baud_rate);
+                                    }
+                                }
+                                *can_be_closed = true;
+                                *connected = true;
+                            }
+                        });
+                    });
+                    strip.cell(|ui| {
+                        let btn2 = Button::new("Disconnect");
+                        ui.add_enabled_ui(*connected, |ui| {
+                            if ui.add_sized(ui.available_size(), btn2).clicked() {
+                                msg_broker!().stop_listening();
+                                *connected = false;
+                            }
+                        });
+                    });
+                });
+        });
     }
 }
 
diff --git a/src/utils.rs b/src/utils.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d3699bbc4d745f0db992d5721d08abbd1ec550ba
--- /dev/null
+++ b/src/utils.rs
@@ -0,0 +1,58 @@
+#[derive(Debug)]
+pub struct RingBuffer<const G: usize> {
+    buffer: Box<[u8; G]>,
+    write_cursor: usize,
+    read_cursor: usize,
+}
+
+impl<const G: usize> std::io::Write for RingBuffer<G> {
+    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+        let mut written = 0;
+        for byte in buf {
+            if self.is_full() {
+                Err(std::io::Error::new(
+                    std::io::ErrorKind::WriteZero,
+                    "Buffer full",
+                ))?;
+            }
+            self.buffer[self.write_cursor] = *byte;
+            self.write_cursor = (self.write_cursor + 1) % G;
+            written += 1;
+        }
+        Ok(written)
+    }
+
+    fn flush(&mut self) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+impl<const G: usize> std::io::Read for RingBuffer<G> {
+    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
+        let mut read = 0;
+        while read < buf.len() {
+            if self.read_cursor == self.write_cursor {
+                break;
+            }
+            buf[read] = self.buffer[self.read_cursor];
+            self.read_cursor = (self.read_cursor + 1) % G;
+            read += 1;
+        }
+        Ok(read)
+    }
+}
+
+impl<const G: usize> RingBuffer<G> {
+    pub fn new() -> Self {
+        Self {
+            buffer: Box::new([0; G]),
+            write_cursor: 0,
+            read_cursor: 0,
+        }
+    }
+
+    #[inline]
+    pub fn is_full(&self) -> bool {
+        (self.write_cursor + 1) % G == self.read_cursor
+    }
+}