diff --git a/Cargo.lock b/Cargo.lock
index 350ec80ab0e9c88c73123a4bdd456aff6bc30993..0e68525849f5d891d6c82dfda16cbabf426e371a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -40,8 +40,8 @@ dependencies = [
  "crc",
  "image",
  "kiss-tnc",
+ "rppal",
  "serde",
- "serial",
  "sha3",
  "subprocess",
  "tokio",
@@ -383,15 +383,6 @@ dependencies = [
  "hashbrown",
 ]
 
-[[package]]
-name = "ioctl-rs"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
-dependencies = [
- "libc",
-]
-
 [[package]]
 name = "jpeg-decoder"
 version = "0.3.0"
@@ -687,6 +678,15 @@ dependencies = [
  "bitflags",
 ]
 
+[[package]]
+name = "rppal"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "612e1a22e21f08a246657c6433fe52b773ae43d07c9ef88ccfc433cc8683caba"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "scopeguard"
 version = "1.1.0"
@@ -722,48 +722,6 @@ dependencies = [
  "serde",
 ]
 
-[[package]]
-name = "serial"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
-dependencies = [
- "serial-core",
- "serial-unix",
- "serial-windows",
-]
-
-[[package]]
-name = "serial-core"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "serial-unix"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
-dependencies = [
- "ioctl-rs",
- "libc",
- "serial-core",
- "termios",
-]
-
-[[package]]
-name = "serial-windows"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
-dependencies = [
- "libc",
- "serial-core",
-]
-
 [[package]]
 name = "sha3"
 version = "0.10.8"
@@ -841,15 +799,6 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
 
-[[package]]
-name = "termios"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
-dependencies = [
- "libc",
-]
-
 [[package]]
 name = "thiserror"
 version = "1.0.40"
diff --git a/Cargo.toml b/Cargo.toml
index a78d03c43cf29b5fb1bc981810760eef0c20cab2..0aaeac3aa08100814f2572872f2c2e0c9040808d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,10 +23,9 @@ bitvec = "1.0.1"
 crc = "3.0.1"
 image = "0.24.6"
 kiss-tnc = "0.2.2"
+rppal = "0.14.1"
 serde = { version = "1.0.160", features = ["derive"] }
-serial = "0.4.0"
 sha3 = "0.10.7"
 subprocess = "0.2.9"
 tokio = { version = "1.27.0", features = ["full"] }
 toml = "0.7.3"
-
diff --git a/config.toml.example b/config.toml.example
index afc4001fbfdcbb052b65dd6a1f3b8cb0d6a3af6d..109e6d26fa5e6417fb1d79cb97baf2aa95f5652d 100644
--- a/config.toml.example
+++ b/config.toml.example
@@ -14,9 +14,6 @@ callsign = "NOCALL"
 # Max image dimension. Comment this out to send full-sized images
 max_img_dimension = 1024
 
-# radio uart
-uart = "/dev/ttyAMA0"
-
 # Comment this section out if you're not using
 # APRS for control
 [control]
diff --git a/src/config.rs b/src/config.rs
index b4b8e2666ed490318a9d34fc4bce45574492f62c..f9057d1311a3d90aad91706b3eeb0804e563038b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -14,7 +14,6 @@ pub struct ControlConfig {
 pub struct Config {
     pub paths: Vec<PathBuf>,
     pub callsign: String,
-    pub uart: String,
     pub max_img_dimension: Option<u32>,
 
     pub control: Option<ControlConfig>,
diff --git a/src/control.rs b/src/control.rs
index 761d59f4336d131fcd1bb28ed20362aa76421801..ef709de4536cccaf30ab3f5705c4b2f066c0d371 100644
--- a/src/control.rs
+++ b/src/control.rs
@@ -11,13 +11,12 @@ use crate::{
     config::Config,
     img::ImgManager,
     packet::{FecPacket, Packet},
-    radio::UartRadio,
+    radio::McuRadio,
     ssdv::ssdv_encode,
 };
 
 const IMAGE_PACKET_QUEUE_LENGTH: usize = 8192;
 const TEMP_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
-const SYNC_REFRESH_INTERVAL: Duration = Duration::from_secs(7);
 
 pub struct Controller {
     config: Config,
@@ -35,8 +34,7 @@ impl Controller {
 
         {
             let callsign = self.config.callsign.clone();
-            let uart = self.config.uart.clone();
-            thread::spawn(|| Self::tx_thread(callsign, uart, img_rx, telem_rx));
+            thread::spawn(|| Self::tx_thread(callsign, img_rx, telem_rx));
         }
 
         {
@@ -86,14 +84,9 @@ impl Controller {
     }
 
     // manages our transceiver
-    fn tx_thread(
-        callsign: String,
-        uart: String,
-        image_rx: Receiver<FecPacket>,
-        telem_rx: Receiver<Packet>,
-    ) {
+    fn tx_thread(callsign: String, image_rx: Receiver<FecPacket>, telem_rx: Receiver<Packet>) {
         let mut radio = loop {
-            let r = UartRadio::new(&uart);
+            let r = McuRadio::new();
 
             match r {
                 Ok(r) => break r,
@@ -107,49 +100,53 @@ impl Controller {
 
         let mut text_msg_id = 0;
         let mut last_got_temp = Instant::now();
-        let mut last_synced_at = Instant::now();
         loop {
-            while let Ok(tm) = telem_rx.try_recv() {
-                let tm = tm.into_raw(&mut text_msg_id).into();
-
-                if let Err(e) = radio.send_packet(&tm) {
-                    eprintln!("Could not send packet: {}", e);
-                }
+            if let Err(e) = Self::tx_thread_single_iter(
+                &callsign,
+                &image_rx,
+                &telem_rx,
+                &mut text_msg_id,
+                &mut last_got_temp,
+                &mut radio,
+            ) {
+                eprintln!("Radio error: {}", e);
+                radio.reset();
             }
+        }
+    }
 
-            if let Ok(img) = image_rx.try_recv() {
-                if let Err(e) = radio.send_packet(&img) {
-                    eprintln!("Could not send packet: {}", e);
-                }
-            } else {
-                thread::sleep(Duration::from_millis(50));
-            }
+    fn tx_thread_single_iter(
+        callsign: &str,
+        image_rx: &Receiver<FecPacket>,
+        telem_rx: &Receiver<Packet>,
+        text_msg_id: &mut u16,
+        last_got_temp: &mut Instant,
+        radio: &mut McuRadio,
+    ) -> anyhow::Result<()> {
+        while let Ok(tm) = telem_rx.try_recv() {
+            let tm = tm.into_raw(text_msg_id).into();
+
+            radio.send_packet(&tm)?;
+        }
 
-            if Instant::now() - last_got_temp > TEMP_REFRESH_INTERVAL {
-                last_got_temp = Instant::now();
+        if let Ok(img) = image_rx.try_recv() {
+            radio.send_packet(&img)?;
+        } else {
+            radio.flush()?;
 
-                let temp = match radio.get_temp() {
-                    Ok(x) => x,
-                    Err(e) => {
-                        eprintln!("Could not get radio temp: {}", e);
-                        continue;
-                    }
-                };
+            thread::sleep(Duration::from_millis(50));
+        }
 
-                let packet = Packet::new_text_message(&callsign, &format!("Temp: {}", temp));
+        if Instant::now() - *last_got_temp > TEMP_REFRESH_INTERVAL {
+            *last_got_temp = Instant::now();
 
-                if let Err(e) = radio.send_packet(&packet.into_raw(&mut text_msg_id).into()) {
-                    eprintln!("Could not send packet: {}", e);
-                }
-            }
+            let temp = radio.get_temp()?;
 
-            if Instant::now() - last_synced_at > SYNC_REFRESH_INTERVAL {
-                last_synced_at = Instant::now();
+            let packet = Packet::new_text_message(callsign, &format!("Temp: {}", temp));
 
-                if let Err(e) = radio.sync() {
-                    eprintln!("Could not sync: {}", e);
-                }
-            }
+            radio.send_packet(&packet.into_raw(text_msg_id).into())?;
         }
+
+        Ok(())
     }
 }
diff --git a/src/packet.rs b/src/packet.rs
index 8bd7b63aba2f06b777c338088de06464b3f58038..1d52ce6bf13a224b9cb761911a346e8b367f7178 100644
--- a/src/packet.rs
+++ b/src/packet.rs
@@ -23,16 +23,13 @@ impl Packet {
             .iter()
             .chain(b": ".iter())
             .chain(msg.as_bytes().iter());
+        let mut len = 0;
         for (i, b) in iter.enumerate().take(TEXT_MESSAGE_LEN) {
             out[i] = *b;
+            len += 1;
         }
 
-        let len = msg
-            .as_bytes()
-            .len()
-            .min(TEXT_MESSAGE_LEN)
-            .try_into()
-            .unwrap();
+        let len = len.min(TEXT_MESSAGE_LEN).try_into().unwrap();
 
         Self::TextMessage(len, out)
     }
diff --git a/src/radio.rs b/src/radio.rs
index 8c338422d33c1d2c74cc3df3d45c4c5d6a905f65..6a35abfd577874854d3dac7667cbf3307b7378b7 100644
--- a/src/radio.rs
+++ b/src/radio.rs
@@ -1,102 +1,100 @@
-use serial::{unix::TTYPort, BaudRate::BaudOther, SerialPort};
-use std::{
-    io::{self, Read, Write},
-    thread::sleep,
-    time::Duration,
+use anyhow::anyhow;
+use rppal::{
+    gpio::{Gpio, OutputPin},
+    spi::{Bus, Mode, SlaveSelect, Spi},
 };
+use std::{thread::sleep, time::Duration};
 
 use crate::packet::FecPacket;
 
-const BUFFER_STATUS_CMD: u8 = 0x00;
 const SEND_PACKET_CMD: u8 = 0x01;
-const GET_TEMP_CMD: u8 = 0x02;
-const SYNC_CMD: u8 = 0x03;
+const NOP_CMD: u8 = 0x55;
 
-const PACKET_SPACE: u8 = 0x02;
+const PREAMBLE: u8 = 0xE5;
+const NO_SPACE: u8 = 0x55;
+const FREE_SPACE: u8 = 0xAA;
 
 // Used for talking to an RF4463 via a microcontroller
-// The microcontroller takes in packet data over UART and sends it to the RF4463
-pub struct UartRadio {
-    uart: TTYPort,
+// The microcontroller takes in packet data over SPI and sends it to the RF4463
+pub struct McuRadio {
+    spi: Spi,
+    reset_pin: OutputPin,
 }
 
-impl UartRadio {
-    pub fn new(uart: &str) -> anyhow::Result<Self> {
-        let mut uart = serial::open(uart)?;
-        uart.reconfigure(&|settings| settings.set_baud_rate(BaudOther(921_600)))?;
+impl McuRadio {
+    pub fn new() -> anyhow::Result<Self> {
+        let spi = Spi::new(Bus::Spi0, SlaveSelect::Ss1, 1_000_000, Mode::Mode0)?;
+        let reset_pin = Gpio::new()?.get(8)?.into_output_high();
 
-        Ok(Self { uart })
-    }
+        let mut s = Self { reset_pin, spi };
 
-    pub fn send_packet(&mut self, packet: &FecPacket) -> anyhow::Result<()> {
-        self.uart.write_all(&[SEND_PACKET_CMD])?;
-        self.uart.write_all(&packet.0)?;
+        s.reset();
 
-        let mut buf = [0; 1];
-        self.uart.read_exact(&mut buf)?;
+        Ok(s)
+    }
 
-        // wait until we have space for packets
-        while buf != [PACKET_SPACE] {
-            sleep(Duration::from_millis(10));
+    pub fn reset(&mut self) {
+        self.reset_pin.set_low();
+        sleep(Duration::from_millis(10));
+        self.reset_pin.set_high();
+        sleep(Duration::from_millis(1000));
+    }
 
-            // poll to see if the buffer has emptied yet
-            self.uart.write_all(&[BUFFER_STATUS_CMD])?;
+    pub fn send_packet(&mut self, packet: &FecPacket) -> anyhow::Result<()> {
+        // wait until we have packet space
 
-            self.uart.read_exact(&mut buf)?;
+        while !self.get_info()?.0 {
+            sleep(Duration::from_millis(10));
         }
 
+        self.spi.write(&[SEND_PACKET_CMD])?;
+        self.spi.write(&packet.0)?;
+
         Ok(())
     }
 
     pub fn get_temp(&mut self) -> anyhow::Result<f32> {
-        self.uart.write_all(&[GET_TEMP_CMD])?;
-
-        let mut buf = [0; 4];
-        self.uart.read_exact(&mut buf)?;
+        Ok(self.get_info()?.1)
+    }
 
-        let temp = f32::from_le_bytes(buf);
+    // sends a bunch of nops over SPI to clear out its DMA buffer
+    pub fn flush(&mut self) -> anyhow::Result<()> {
+        self.spi.write(&[NOP_CMD; 256])?;
 
-        Ok(temp)
+        Ok(())
     }
 
-    pub fn sync(&mut self) -> anyhow::Result<()> {
-        // clear any pending packets
-        let mut tries = 0;
-        let mut buf = [0; 100];
-        loop {
-            match self.uart.read(&mut buf) {
-                Ok(0) => break,
-                Ok(_) => {
-                    tries += 1;
-                }
-                Err(e) if matches!(e.kind(), io::ErrorKind::TimedOut) => break,
-                Err(e) => {
-                    return Err(e.into());
-                }
+    // try 10 times and then give up
+    fn get_info(&mut self) -> anyhow::Result<(bool, f32)> {
+        for _ in 0..9 {
+            if let Ok(x) = self.get_info_once() {
+                return Ok(x);
             }
         }
 
-        if tries > 0 {
-            eprintln!("[WARN] Sync had pending packets(tries={tries})");
-        }
+        self.get_info_once()
+    }
 
-        tries = 0;
-        let mut buf = [0; 10];
-        loop {
-            self.uart.write_all(&[SYNC_CMD; 10])?;
-            match self.uart.read_exact(&mut buf) {
-                Ok(()) => break,
-                Err(e) if matches!(e.kind(), io::ErrorKind::TimedOut) => {
-                    tries += 1;
-                }
-                Err(e) => {
-                    return Err(e.into());
-                }
-            }
-        }
+    fn get_info_once(&mut self) -> anyhow::Result<(bool, f32)> {
+        // get 256 bytes from the mcu
+        let mut buf = [0; 256];
+        self.spi.transfer(&mut buf, &[NOP_CMD; 256])?;
 
-        eprintln!("Synced in {tries} tries");
+        // look for our data
+        // it goes [0xE5, 0xE5, 0xAA || 0x55, ?, ?, ?, ?, 0xE5, 0xE5]
+        for i in 0..(buf.len() - 9) {
+            let chunk = &buf[i..(i + 9)];
 
-        Ok(())
+            if chunk[0..2] == [PREAMBLE, PREAMBLE]
+                && (chunk[2] == FREE_SPACE || chunk[2] == NO_SPACE)
+                && chunk[7..9] == [PREAMBLE, PREAMBLE]
+            {
+                let free = chunk[2] == FREE_SPACE;
+                let temp = f32::from_le_bytes(chunk[3..7].try_into().unwrap());
+
+                return Ok((free, temp));
+            }
+        }
+        Err(anyhow!("could not find valid data"))
     }
 }
diff --git a/src/ssdv.rs b/src/ssdv.rs
index 63784d3fc4026b94d2a7f54a3175f9a6245fec53..f8e75907b1620032d9e4a5980bbe5e5ddce3de0a 100644
--- a/src/ssdv.rs
+++ b/src/ssdv.rs
@@ -1,12 +1,14 @@
 use anyhow::{bail, Context};
+use subprocess::{Popen, PopenConfig, Redirection};
 
 use crate::packet::RawPacket;
 
 // TODO eventually rewrite Ssdv in Rust?
 // Don't want to use FFI because then segfaults can hurt us
 pub fn ssdv_encode(callsign: &str, img: &[u8], img_idx: u8) -> anyhow::Result<Vec<RawPacket>> {
-    let (stdout, stderr) = subprocess::Exec::cmd("ssdv")
-        .args(&[
+    let mut p = Popen::create(
+        &[
+            "ssdv",
             "-e",
             "-c",
             callsign,
@@ -15,10 +17,16 @@ pub fn ssdv_encode(callsign: &str, img: &[u8], img_idx: u8) -> anyhow::Result<Ve
             "6",
             "-i",
             &format!("{}", img_idx),
-        ])
-        .stdin(img.to_vec())
-        .communicate()?
-        .read()?;
+        ],
+        PopenConfig {
+            stdin: Redirection::Pipe,
+            stdout: Redirection::Pipe,
+            stderr: Redirection::Pipe,
+            ..Default::default()
+        },
+    )?;
+
+    let (stdout, stderr) = p.communicate_bytes(Some(img))?;
 
     if let Some(stderr) = stderr {
         if !stderr.is_empty() {