diff --git a/src/aprs.rs b/src/aprs.rs
index 0194f44a4489084c2047aa335f922373abed795c..9c6b613f1797aac6b866fd462f4b9f737af684f3 100644
--- a/src/aprs.rs
+++ b/src/aprs.rs
@@ -6,6 +6,8 @@ use base64::{engine::general_purpose, Engine};
 use kiss_tnc::Tnc;
 use sha3::{Digest, Sha3_256};
 
+use crate::packet::Packet;
+
 #[derive(Debug)]
 enum Command {
     ReqImageId(u8),
@@ -40,6 +42,8 @@ pub struct CommandHandler {
     burst_command: String,
     cutdown_command: String,
     img_request: Sender<u8>,
+
+    telem_tx: Sender<Packet>,
 }
 
 impl CommandHandler {
@@ -49,6 +53,7 @@ impl CommandHandler {
         burst_command: String,
         cutdown_command: String,
         img_request: Sender<u8>,
+        telem_tx: Sender<Packet>,
     ) -> Self {
         Self {
             callsign,
@@ -58,6 +63,8 @@ impl CommandHandler {
             burst_command,
             cutdown_command,
             img_request,
+
+            telem_tx,
         }
     }
 
@@ -127,8 +134,18 @@ impl CommandHandler {
         let cmd = Command::decode(cmd.as_bytes()).context("Invalid command")?;
 
         match cmd {
-            Command::ReqImageId(id) => self.img_request.send(id)?,
+            Command::ReqImageId(id) => {
+                let _ = self
+                    .telem_tx
+                    .send(Packet::new_text_message("HQ image request received"));
+
+                self.img_request.send(id)?
+            }
             Command::BurstBalloon => {
+                let _ = self
+                    .telem_tx
+                    .send(Packet::new_text_message("Burst balloon command received"));
+
                 let (_, stderr) = subprocess::Exec::shell(&self.burst_command)
                     .communicate()?
                     .read()?;
@@ -140,6 +157,10 @@ impl CommandHandler {
                 }
             }
             Command::CutPayload => {
+                let _ = self
+                    .telem_tx
+                    .send(Packet::new_text_message("Payload cutdown command received"));
+
                 let (_, stderr) = subprocess::Exec::shell(&self.cutdown_command)
                     .communicate()?
                     .read()?;
diff --git a/src/control.rs b/src/control.rs
index 99bf5166f559f5a50f51a973c4c2a48e33580c08..407d1a8daeb216e029c33c012d7697ff273e4982 100644
--- a/src/control.rs
+++ b/src/control.rs
@@ -1,15 +1,18 @@
 use std::{
-    io::{Read, Write},
     sync::mpsc::{self, Receiver, Sender, SyncSender},
     thread,
     time::Duration,
 };
 
 use anyhow::Context;
-use serial::{BaudRate::BaudOther, SerialPort};
 
 use crate::{
-    aprs::CommandHandler, config::Config, img::ImgManager, packet::FecPacket, ssdv::ssdv_encode,
+    aprs::CommandHandler,
+    config::Config,
+    img::ImgManager,
+    packet::{FecPacket, Packet},
+    radio::UartRadio,
+    ssdv::ssdv_encode,
 };
 
 const IMAGE_PACKET_QUEUE_LENGTH: usize = 1024;
@@ -25,12 +28,13 @@ impl Controller {
 
     pub fn run_forever(self) {
         let (img_tx, img_rx) = mpsc::sync_channel(IMAGE_PACKET_QUEUE_LENGTH);
+        let (telem_tx, telem_rx) = mpsc::channel();
         let (cmd_tx, cmd_rx) = mpsc::channel();
 
-        thread::spawn(|| Self::tx_thread(img_rx));
+        thread::spawn(|| Self::tx_thread(img_rx, telem_rx));
         {
             let config = self.config.clone();
-            thread::spawn(|| Self::aprs_thread(config, cmd_tx));
+            thread::spawn(|| Self::aprs_thread(config, cmd_tx, telem_tx));
         }
 
         let mut manager = ImgManager::new(self.config.paths.clone(), cmd_rx);
@@ -59,7 +63,7 @@ impl Controller {
     // manages incoming APRS packets
     // used to request HD images, as well as
     // to initiate burst/cutdown
-    fn aprs_thread(config: Config, tx: Sender<u8>) {
+    fn aprs_thread(config: Config, tx: Sender<u8>, telem_tx: Sender<Packet>) {
         if let Some(ctrl) = config.control {
             let mut handler = CommandHandler::new(
                 config.callsign,
@@ -67,6 +71,7 @@ impl Controller {
                 ctrl.burst_command,
                 ctrl.cutdown_command,
                 tx,
+                telem_tx,
             );
 
             handler.process_forever();
@@ -77,23 +82,21 @@ impl Controller {
     // TODO currently very hacky, because we're going to rip
     // most of this out when we stop using a lobotomized NPR-70
     // and move to just accessing the RF4463 directly over SPI
-    fn tx_thread(rx: Receiver<FecPacket>) {
-        let mut uart = serial::open("/dev/ttyACM0").unwrap();
-        uart.reconfigure(&|settings| settings.set_baud_rate(BaudOther(921600)))
-            .unwrap();
-        uart.set_timeout(Duration::from_millis(5)).unwrap();
-
-        while let Ok(packet) = rx.recv() {
-            uart.write_all(&packet.0).unwrap();
-            let mut buf = [0; 1];
-            if let Ok(1) = uart.read(&mut buf) {
-                if buf[0] == b'F' {
-                    // uC is full. Need to wait until we receive
-                    // the empty signal
-                    while buf[0] != b'E' {
-                        let _ = uart.read(&mut buf);
-                    }
-                }
+    fn tx_thread(image_rx: Receiver<FecPacket>, telem_rx: Receiver<Packet>) {
+        let mut radio = UartRadio::new("/dev/ttyACM0");
+
+        let mut text_msg_id = 0;
+        loop {
+            while let Ok(tm) = telem_rx.try_recv() {
+                let tm = tm.into_raw(&mut text_msg_id).into();
+
+                radio.send_packet(&tm);
+            }
+
+            if let Ok(img) = image_rx.try_recv() {
+                radio.send_packet(&img);
+            } else {
+                thread::sleep(Duration::from_millis(5));
             }
         }
     }
diff --git a/src/main.rs b/src/main.rs
index 95e7a48caad1412b4ebbbda0304ca0fff3d084ba..168e135c6c7a0fd5889128fd56fd2d06e32640b9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,6 +8,7 @@ mod hrow;
 mod img;
 mod ldpc;
 mod packet;
+mod radio;
 mod ssdv;
 
 fn main() -> anyhow::Result<()> {
diff --git a/src/packet.rs b/src/packet.rs
index fca3637af7bfb6b25fbc4ab6023c32eddd07595e..9a5a91aa2633bdef0f47a0b4005d8502cad2ba1d 100644
--- a/src/packet.rs
+++ b/src/packet.rs
@@ -2,6 +2,57 @@ use crc::{Crc, CRC_16_IBM_3740};
 
 use crate::ldpc::ldpc_encode;
 
+const TEXT_MESSAGE_LEN: usize = 252;
+
+pub enum Packet {
+    TextMessage(u8, [u8; TEXT_MESSAGE_LEN]),
+}
+
+impl Packet {
+    // Cuts off messages that are too long
+    pub fn new_text_message(msg: &str) -> Self {
+        let mut out = [0x55; TEXT_MESSAGE_LEN];
+
+        // this technically would allow part of a character to be sent
+        // (unicode multi-byte character that gets cut off at the end)
+        // ideally, we would iterate over characters and add them, so that
+        // we could abort before adding a partial character
+        for (i, b) in msg.as_bytes().iter().enumerate().take(TEXT_MESSAGE_LEN) {
+            out[i] = *b;
+        }
+
+        let len = msg
+            .as_bytes()
+            .len()
+            .min(TEXT_MESSAGE_LEN)
+            .try_into()
+            .unwrap();
+
+        Self::TextMessage(len, out)
+    }
+}
+
+impl Packet {
+    // increments text_id if we're sending a text message packet
+    pub fn into_raw(self, text_id: &mut u16) -> RawPacket {
+        match self {
+            Packet::TextMessage(len, txt) => {
+                let id = *text_id;
+                *text_id = text_id.wrapping_add(1);
+
+                let mut out = [0; 256];
+
+                out[0] = 0x00; // packet type
+                out[1] = len;
+                out[2..4].clone_from_slice(&id.to_be_bytes());
+                out[4..].clone_from_slice(&txt);
+
+                RawPacket(out)
+            }
+        }
+    }
+}
+
 pub struct RawPacket(pub [u8; 256]);
 
 pub struct FecPacket(pub [u8; 256 + 2 + 65]);
diff --git a/src/radio.rs b/src/radio.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c05d167de559bc6eb4b2923498943095bc6b7beb
--- /dev/null
+++ b/src/radio.rs
@@ -0,0 +1,40 @@
+use serial::{unix::TTYPort, BaudRate::BaudOther, SerialPort};
+use std::{
+    io::{Read, Write},
+    time::Duration,
+};
+
+use crate::packet::FecPacket;
+
+// Used for talking to an RF4463 via a microcontroller
+// The microcontroller takes in packet data over UART and sends it to the RF4463
+// Note that this is a little hacky. It's only intended to be used
+// for debugging, and not for an actual balloon flight
+pub struct UartRadio {
+    uart: TTYPort,
+}
+
+impl UartRadio {
+    pub fn new(uart: &str) -> Self {
+        let mut uart = serial::open(uart).unwrap();
+        uart.reconfigure(&|settings| settings.set_baud_rate(BaudOther(921600)))
+            .unwrap();
+        uart.set_timeout(Duration::from_millis(5)).unwrap();
+
+        Self { uart }
+    }
+
+    pub fn send_packet(&mut self, packet: &FecPacket) {
+        self.uart.write_all(&packet.0).unwrap();
+        let mut buf = [0; 1];
+        if let Ok(1) = self.uart.read(&mut buf) {
+            if buf[0] == b'F' {
+                // uC is full. Need to wait until we receive
+                // the empty signal
+                while buf[0] != b'E' {
+                    let _ = self.uart.read(&mut buf);
+                }
+            }
+        }
+    }
+}