Add KISS TNC external app for APRS RX/TX over USB serial (#3078)

* Add KISS TNC external app for APRS RX/TX over USB serial

Adds a KISS TNC (Terminal Node Controller) app that bridges APRS
packets between the HackRF radio and a connected PC over USB CDC
serial using the KISS protocol.

Features:
- APRS receive at 144.390 MHz (configurable), decoding packets via
  the existing APRS RX baseband (PAPR.bin)
- Received AX.25 frames forwarded to host as KISS-framed data over
  USB serial
- KISS frames received from host transmitted as AFSK audio using the
  existing AFSK TX baseband (PAFT.bin)
- Clean RX->TX->RX baseband switching with proper timing delays
- USB connection status indicator with periodic refresh
- Compatible with standard KISS TNC clients (Xastir, APRSISCE/32,
  Dire Wolf, etc.)

Firmware changes required to support the external app:
- baseband_api.cpp: mark set_aprs() __attribute__((used)) so it is
  retained by the linker for external app use
- protocols/ax25: add make_frame_from_raw() to build NRZI bitstream
  from raw AX.25 bytes, marked __attribute__((used))
- usb_serial_host_to_device: add kiss_raw_handler hook to route
  incoming USB bytes directly to a registered callback, bypassing
  the shell; marked __attribute__((used))
- baseband/proc_aprsrx: add constructor that auto-configures at
  1200 baud so external apps do not need to call set_aprs() at init

* Address PR review: generic RAII USB handler, fix TX cutoff and buffer overflow

- Replace KISS-specific set_kiss_raw_handler() in shared USB code with a
  generic UsbSerialInputHandler RAII class; any app can now register a
  handler via the ctor and it auto-clears in the dtor — no app-specific
  'if' checks in shared code, no __attribute__((used)) needed
- Remove __attribute__((used)) from set_aprs() — it is referenced directly
  by ui_aprs_rx.cpp so it will never be dead-stripped
- Fix send_kiss_frame() buffer overflow: per-byte bounds check prevents
  2-byte escape sequences from writing past the end of the output buffer
- Replace fillOBuffer(TIME_INFINITE) with chOQWriteTimeout(TIME_IMMEDIATE)
  to drop bytes instead of blocking the event loop
- Remove chThdSleepMilliseconds() calls from start_tx() and finish_tx()
- Fix TX cutoff bug: call start_tx() directly from process_kiss_frame()
  instead of deferring via tx_pending_ flag (deferral caused kiss_idx_
  to be 0 by the time start_tx() fired, producing an empty frame)

* Move USB write into UsbSerialInputHandler class

Add a write() method to UsbSerialInputHandler so all USB I/O
(both input callback and output) is encapsulated in the class.
KISS TNC now uses usb_input_handler_->write() instead of calling
chOQWriteTimeout(&SUSBD1.oqueue, ...) directly, keeping raw USB
details out of app code.

* Address PR review: remove auto-configure from APRSRxProcessor, add timeout param to write()

- Remove default_config from APRSRxProcessor() constructor; baseband should
  start unconfigured and wait for APRSRxConfigureMessage from the app side
  (already sent via baseband::set_aprs() in KISS TNC and APRS RX app)
- Remove stray #include "stdio.h" from proc_aprsrx.cpp
- Add systime_t timeout parameter with default TIME_IMMEDIATE to
  UsbSerialInputHandler::write() so callers can choose blocking behaviour
This commit is contained in:
Sarah Rose
2026-03-10 05:40:27 -04:00
committed by GitHub
parent bf166bb2fc
commit bf5ea0a004
12 changed files with 550 additions and 13 deletions
+5
View File
@@ -320,6 +320,10 @@ set(EXTCPPSRC
#time_sink
external/time_sink/main.cpp
external/time_sink/ui_time_sink.cpp
#kiss_tnc
external/kiss_tnc/main.cpp
external/kiss_tnc/ui_kiss_tnc.cpp
)
set(EXTAPPLIST
@@ -400,6 +404,7 @@ set(EXTAPPLIST
rtty_tx
pocsag_tx
time_sink
kiss_tnc
)
# sdusb has type conflicts with PRALINE (HackRF Pro) - add only for non-PRALINE builds
+8
View File
@@ -101,6 +101,7 @@ MEMORY
ram_external_app_pocsag_tx (rwx) : org = 0xADFC0000, len = 32k
ram_external_app_time_sink (rwx) : org = 0xADFD0000, len = 32k
ram_external_app_same_tx (rwx) : org = 0xADFE0000, len = 32k
ram_external_app_kiss_tnc (rwx) : org = 0xADFF0000, len = 32k
}
@@ -574,5 +575,12 @@ SECTIONS
KEEP(*(.external_app.app_same_tx.application_information));
*(*ui*external_app*same_tx*);
} > ram_external_app_same_tx
.external_app_kiss_tnc : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_kiss_tnc.application_information));
*(*ui*external_app*kiss_tnc*);
} > ram_external_app_kiss_tnc
}
+84
View File
@@ -0,0 +1,84 @@
/*
* Copyright (C) 2024 Sarah Rose
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#include "ui.hpp"
#include "ui_kiss_tnc.hpp"
#include "ui_navigation.hpp"
#include "external_app.hpp"
namespace ui::external_app::kiss_tnc {
void initialize_app(ui::NavigationView& nav) {
nav.push<KissTncView>();
}
} // namespace ui::external_app::kiss_tnc
extern "C" {
__attribute__((section(".external_app.app_kiss_tnc.application_information"), used)) application_information_t _application_information_kiss_tnc = {
/*.memory_location = */ (uint8_t*)0x00000000,
/*.externalAppEntry = */ ui::external_app::kiss_tnc::initialize_app,
/*.header_version = */ CURRENT_HEADER_VERSION,
/*.app_version = */ VERSION_MD5,
/*.app_name = */ "KISS TNC",
/*.bitmap_data = */ {
// 16x16 icon - radio tower / TNC
0x00,
0x00,
0x08,
0x00,
0x14,
0x00,
0x22,
0x00,
0x08,
0x00,
0x08,
0x00,
0x08,
0x00,
0x3E,
0x00,
0x22,
0x00,
0x22,
0x00,
0x22,
0x00,
0x22,
0x00,
0x3E,
0x00,
0x1C,
0x00,
0x08,
0x00,
0x00,
0x00,
},
/*.icon_color = */ ui::Color::green().v,
/*.menu_location = */ app_location_t::TRX,
/*.desired_menu_position = */ -1,
/*.m4_app_tag = portapack::spi_flash::image_tag_aprs_rx */ {'P', 'A', 'P', 'R'},
/*.m4_app_offset = */ 0x00000000,
};
}
+222
View File
@@ -0,0 +1,222 @@
/*
* Copyright (C) 2024 Sarah Rose
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#include "ui_kiss_tnc.hpp"
#include "baseband_api.hpp"
#include "portapack.hpp"
#include "receiver_model.hpp"
#include "transmitter_model.hpp"
#include "string_format.hpp"
#include "memory_map.hpp"
#include "ax25.hpp"
#include "spi_image.hpp"
extern "C" {
#include "usb_serial_device_to_host.h"
}
using namespace portapack;
namespace ui::external_app::kiss_tnc {
namespace {
KissTncView* g_kiss_tnc_instance = nullptr;
}
void KissTncView::kiss_input_trampoline(const uint8_t* data, size_t len) {
if (g_kiss_tnc_instance) g_kiss_tnc_instance->on_kiss_bytes(data, len);
}
KissTncView::KissTncView(NavigationView& nav)
: nav_{nav} {
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
add_children({&field_frequency,
&field_rf_amp,
&field_lna,
&field_vga,
&rssi,
&channel,
&text_status,
&text_usb_status,
&text_rx_count,
&text_tx_count,
&labels,
&console});
receiver_model.enable();
baseband::set_aprs(1200);
g_kiss_tnc_instance = this;
usb_input_handler_.emplace(kiss_input_trampoline);
update_stats();
}
KissTncView::~KissTncView() {
g_kiss_tnc_instance = nullptr;
usb_input_handler_.reset();
if (tx_active_) {
transmitter_model.disable();
} else {
receiver_model.disable();
}
baseband::shutdown();
}
void KissTncView::focus() {
field_frequency.focus();
}
void KissTncView::update_stats() {
text_rx_count.set(to_string_dec_uint(rx_count_));
text_tx_count.set(to_string_dec_uint(tx_count_));
text_usb_status.set(portapack::usb_serial.serial_connected() ? "Connected" : "Disconnected");
}
void KissTncView::send_kiss_frame(const uint8_t* data, size_t len) {
// Stack-allocate output buffer: max KISS overhead is 2x data + 2 FENDs + 1 cmd byte
uint8_t buf[512];
size_t i = 0;
buf[i++] = 0xC0; // FEND
buf[i++] = 0x00; // data command
for (size_t j = 0; j < len; j++) {
if (data[j] == 0xC0) {
if (i + 3 > sizeof(buf)) break; // need 2 for escape + 1 for final FEND
buf[i++] = 0xDB;
buf[i++] = 0xDC;
} else if (data[j] == 0xDB) {
if (i + 3 > sizeof(buf)) break; // need 2 for escape + 1 for final FEND
buf[i++] = 0xDB;
buf[i++] = 0xDD;
} else {
if (i + 2 > sizeof(buf)) break; // need 1 for data + 1 for final FEND
buf[i++] = data[j];
}
}
buf[i++] = 0xC0; // FEND
if (usb_input_handler_)
usb_input_handler_->write(buf, i);
}
void KissTncView::on_packet(const APRSPacketMessage* message) {
aprs::APRSPacket pkt = message->packet;
uint8_t payload_size = pkt.size();
if (payload_size > 2) {
uint8_t raw[256];
size_t raw_len = payload_size - 2;
for (size_t i = 0; i < raw_len; i++)
raw[i] = static_cast<uint8_t>(pkt[i]);
send_kiss_frame(raw, raw_len);
}
console.writeln(pkt.get_source_formatted() + ">" + pkt.get_destination_formatted());
rx_count_++;
update_stats();
}
void KissTncView::on_kiss_bytes(const uint8_t* data, size_t len) {
for (size_t i = 0; i < len; i++) {
uint8_t b = data[i];
switch (kiss_state_) {
case KissState::IDLE:
if (b == 0xC0) kiss_state_ = KissState::CMD;
break;
case KissState::CMD:
if (b == 0xC0) break;
if (b == 0x00) {
kiss_idx_ = 0;
kiss_state_ = KissState::DATA;
} else {
kiss_state_ = KissState::IDLE;
}
break;
case KissState::DATA:
if (b == 0xC0) {
if (kiss_idx_ > 0) process_kiss_frame();
kiss_idx_ = 0;
kiss_state_ = KissState::CMD;
} else if (b == 0xDB) {
kiss_state_ = KissState::ESC;
} else if (kiss_idx_ < sizeof(kiss_buf_)) {
kiss_buf_[kiss_idx_++] = b;
}
break;
case KissState::ESC:
if (b == 0xDC) {
if (kiss_idx_ < sizeof(kiss_buf_)) kiss_buf_[kiss_idx_++] = 0xC0;
} else if (b == 0xDD) {
if (kiss_idx_ < sizeof(kiss_buf_)) kiss_buf_[kiss_idx_++] = 0xDB;
}
kiss_state_ = KissState::DATA;
break;
}
}
}
void KissTncView::process_kiss_frame() {
if (tx_active_ || kiss_idx_ == 0) return;
// AFSK shared buffer is 512 bytes (256 uint16_t words). Each raw byte
// produces ~10 encoded bits after bit-stuffing + flags, so cap at 200 bytes
// to ensure the bitstream plus required 0-word terminator always fits.
if (kiss_idx_ > 200) return;
start_tx();
}
void KissTncView::start_tx() {
tx_active_ = true;
ax25::AX25Frame frame;
frame.make_frame_from_raw(kiss_buf_, kiss_idx_);
rx_frequency_ = receiver_model.target_frequency();
receiver_model.disable();
baseband::shutdown();
baseband::run_image(portapack::spi_flash::image_tag_afsk);
transmitter_model.set_target_frequency(rx_frequency_);
transmitter_model.set_sampling_rate(AFSK_SAMPLE_RATE);
transmitter_model.set_baseband_bandwidth(AFSK_BASEBAND_BW);
transmitter_model.enable();
baseband::set_afsk_data(AFSK_SAMPLE_RATE / 1200, 1200, 2200, 1, 10000, 8);
text_status.set("Transmitting");
tx_count_++;
update_stats();
}
void KissTncView::finish_tx() {
transmitter_model.disable();
baseband::shutdown();
// Must reload from SPI flash — run_image(afsk) overwrote m4_code.base()
baseband::run_image(portapack::spi_flash::image_tag_aprs_rx);
receiver_model.set_target_frequency(rx_frequency_);
receiver_model.set_sampling_rate(APRS_RX_SAMPLE_RATE);
receiver_model.set_baseband_bandwidth(APRS_RX_BASEBAND_BW);
receiver_model.enable();
baseband::set_aprs(1200);
tx_active_ = false;
text_status.set("Listening");
update_stats();
}
} // namespace ui::external_app::kiss_tnc
+138
View File
@@ -0,0 +1,138 @@
/*
* Copyright (C) 2024 Sarah Rose
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#pragma once
#include "ui.hpp"
#include "ui_widget.hpp"
#include "ui_navigation.hpp"
#include "ui_freq_field.hpp"
#include "ui_rssi.hpp"
#include "radio_state.hpp"
#include "app_settings.hpp"
#include "message.hpp"
#include "baseband_api.hpp"
#include "portapack.hpp"
#include "receiver_model.hpp"
#include "aprs_packet.hpp"
#include "usb_serial_host_to_device.hpp"
#include <optional>
namespace ui::external_app::kiss_tnc {
class KissTncView : public View {
public:
KissTncView(NavigationView& nav);
~KissTncView();
void focus() override;
std::string title() const override { return "KISS TNC"; }
private:
static constexpr rf::Frequency APRS_FREQ_DEFAULT = 144390000;
static constexpr uint32_t APRS_RX_SAMPLE_RATE = 3072000U;
static constexpr uint32_t APRS_RX_BASEBAND_BW = 1750000U;
static constexpr uint32_t AFSK_SAMPLE_RATE = 1536000U;
static constexpr uint32_t AFSK_BASEBAND_BW = 1750000U;
NavigationView& nav_;
// CRITICAL: RxRadioState must be declared before SettingsManager
RxRadioState radio_state_{
APRS_FREQ_DEFAULT,
APRS_RX_BASEBAND_BW,
APRS_RX_SAMPLE_RATE};
app_settings::SettingsManager settings_{"kiss_tnc", app_settings::Mode::RX};
RxFrequencyField field_frequency{
{0, 0},
nav_};
RFAmpField field_rf_amp{{13 * 8, 0}};
LNAGainField field_lna{{15 * 8, 0}};
VGAGainField field_vga{{18 * 8, 0}};
RSSI rssi{{21 * 8, 0, 6 * 8, 4}};
Channel channel{{21 * 8, 5, 6 * 8, 4}};
Text text_status{{0, 16, 240, 16}, "Listening"};
Text text_usb_status{{4 * 8, 2 * 16, 14 * 8, 16}, "Disconnected"};
Text text_rx_count{{3 * 8, 3 * 16, 5 * 8, 16}, "0"};
Text text_tx_count{{11 * 8, 3 * 16, 5 * 8, 16}, "0"};
Labels labels{{
{{0, 2 * 16}, "USB:", Color::light_grey()},
{{0, 3 * 16}, "RX:", Color::light_grey()},
{{8 * 8, 3 * 16}, "TX:", Color::light_grey()},
}};
Console console{{0, 4 * 16, 240, 180}};
enum class KissState : uint8_t { IDLE,
CMD,
DATA,
ESC };
KissState kiss_state_{KissState::IDLE};
uint8_t kiss_buf_[350]{};
size_t kiss_idx_{0};
bool tx_active_{false};
rf::Frequency rx_frequency_{APRS_FREQ_DEFAULT};
uint32_t rx_count_{0};
uint32_t tx_count_{0};
void on_packet(const APRSPacketMessage* message);
void on_kiss_bytes(const uint8_t* data, size_t len);
void process_kiss_frame();
void send_kiss_frame(const uint8_t* data, size_t len);
void start_tx();
void finish_tx();
void update_stats();
uint32_t frame_counter_{0};
static void kiss_input_trampoline(const uint8_t* data, size_t len);
std::optional<UsbSerialInputHandler> usb_input_handler_{};
MessageHandlerRegistration message_handler_packet{
Message::ID::APRSPacket,
[this](Message* const p) {
on_packet(reinterpret_cast<const APRSPacketMessage*>(p));
}};
MessageHandlerRegistration message_handler_tx_progress{
Message::ID::TXProgress,
[this](Message* const p) {
auto* msg = reinterpret_cast<const TXProgressMessage*>(p);
if (msg->done) finish_tx();
}};
MessageHandlerRegistration message_handler_frame_sync{
Message::ID::DisplayFrameSync,
[this](const Message* const) {
if (++frame_counter_ == 60) {
frame_counter_ = 0;
text_usb_status.set(portapack::usb_serial.serial_connected() ? "Connected" : "Disconnected");
}
}};
};
} // namespace ui::external_app::kiss_tnc
+26
View File
@@ -131,4 +131,30 @@ void AX25Frame::make_ui_frame(char* const address, const uint8_t control, const
flush();
}
// __attribute__((used)) prevents dead-stripping; called only from KISS TNC external app.
__attribute__((used)) void AX25Frame::make_frame_from_raw(const uint8_t* data, size_t len) {
bb_data_ptr = (uint16_t*)shared_memory.bb_data.data;
memset(bb_data_ptr, 0, sizeof(shared_memory.bb_data.data));
bit_counter = 0;
current_bit = 0;
current_byte = 0;
ones_counter = 0;
crc_ccitt.reset();
add_flag();
add_flag();
add_flag();
add_flag();
for (size_t i = 0; i < len; i++)
add_data(data[i]);
add_checksum();
add_flag();
add_flag();
flush();
}
} /* namespace ax25 */
+1
View File
@@ -44,6 +44,7 @@ enum protocol_id_t {
class AX25Frame {
public:
void make_ui_frame(char* const address, const uint8_t control, const uint8_t protocol, const std::string& info, const std::string& path = "");
void make_frame_from_raw(const uint8_t* data, size_t len);
private:
void NRZI_add_bit(const uint32_t bit);
@@ -33,6 +33,7 @@ extern "C" {
#include <vector>
static Thread* thread_usb_event = NULL;
usb_serial_input_handler_t usb_serial_active_input_handler = nullptr;
struct usb_bulk_buffer_t {
uint8_t* data;
@@ -110,20 +111,27 @@ void complete_host_to_device_transfer() {
return;
chSysLock();
for (unsigned int i = 0; i < transfer_data->length; i++) {
msg_t ret;
do {
ret = chIQPutI(&SUSBD1.iqueue, transfer_data->data[i]);
if (usb_serial_active_input_handler) {
// An input handler is active: route raw bytes directly to it
chSysUnlock();
usb_serial_active_input_handler(transfer_data->data, transfer_data->length);
} else {
// Normal operation: feed bytes into the shell iqueue
for (unsigned int i = 0; i < transfer_data->length; i++) {
msg_t ret;
do {
ret = chIQPutI(&SUSBD1.iqueue, transfer_data->data[i]);
if (ret == Q_FULL) {
chSysUnlock();
chThdSleepMilliseconds(1); // wait for shell thread when buffer is full
chSysLock();
}
if (ret == Q_FULL) {
chSysUnlock();
chThdSleepMilliseconds(1); // wait for shell thread when buffer is full
chSysLock();
}
} while (ret == Q_FULL);
} while (ret == Q_FULL);
}
chSysUnlock();
}
chSysUnlock();
usb_bulk_buffer_spare.push(transfer_data);
}
@@ -24,6 +24,7 @@
#include "ch.h"
#include "hal.h"
#include "usb_serial_device_to_host.h"
#define USB_BULK_BUFFER_SIZE 64
@@ -33,4 +34,46 @@ void serial_bulk_transfer_complete(void* user_data, unsigned int bytes_transferr
void schedule_host_to_device_transfer();
void complete_host_to_device_transfer();
typedef void (*usb_serial_input_handler_t)(const uint8_t* data, size_t len);
/**
* Global storing the currently active USB serial input handler.
* When non-null, all incoming USB bytes are routed to this handler
* instead of the normal shell iqueue.
*
* Managed via UsbSerialInputHandler RAII below — do not write directly.
*/
extern usb_serial_input_handler_t usb_serial_active_input_handler;
/**
* RAII wrapper that registers a USB serial input handler on construction
* and automatically deregisters it on destruction.
*
* Only one handler may be active at a time.
*
* The handler is called from the USB transfer completion context (main event
* loop thread). It must return quickly; blocking will stall USB/UI servicing.
* The data pointer is only valid for the duration of the call.
*
* Use write() to send data to the host. By default bytes are dropped if the
* TX queue is full (TIME_IMMEDIATE); pass a timeout in ticks to block instead.
*/
class UsbSerialInputHandler {
public:
UsbSerialInputHandler() = delete;
explicit UsbSerialInputHandler(usb_serial_input_handler_t handler) {
usb_serial_active_input_handler = handler;
}
~UsbSerialInputHandler() {
usb_serial_active_input_handler = nullptr;
}
UsbSerialInputHandler(const UsbSerialInputHandler&) = delete;
UsbSerialInputHandler& operator=(const UsbSerialInputHandler&) = delete;
// Send bytes to the USB host. timeout defaults to TIME_IMMEDIATE (drop if full).
void write(const uint8_t* data, size_t len, systime_t timeout = TIME_IMMEDIATE) {
chOQWriteTimeout(&SUSBD1.oqueue, data, len, timeout);
}
};
#endif
+2 -1
View File
@@ -27,7 +27,8 @@
#include "event_m4.hpp"
#include "stdio.h"
APRSRxProcessor::APRSRxProcessor() {
}
void APRSRxProcessor::execute(const buffer_c8_t& buffer) {
// This is called at 3072000 / 2048 = 1500Hz
+1
View File
@@ -74,6 +74,7 @@ static uint16_t crc_ccitt_tab[256] = {
class APRSRxProcessor : public BasebandProcessor {
public:
APRSRxProcessor();
void execute(const buffer_c8_t& buffer) override;
void on_message(const Message* const message) override;
+1 -1
View File
@@ -24,4 +24,4 @@
# external app address ranges below must match those in linker file "external.ld"
maximum_application_size = 32*1024
external_apps_address_start = 0xADB00000
external_apps_address_end = 0xADFC0000
external_apps_address_end = 0xADFC0000