summaryrefslogtreecommitdiff
path: root/plugins/packet_radio
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-08 03:54:55 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-08 03:54:55 +0000
commit20cb29c2f8c5c87bc590896854a20b1473ceb358 (patch)
tree2857f41513a56ad41af97b57362639298aa7f033 /plugins/packet_radio
#2
Diffstat (limited to 'plugins/packet_radio')
-rw-r--r--plugins/packet_radio/CMakeLists.txt36
-rw-r--r--plugins/packet_radio/ax25.c250
-rw-r--r--plugins/packet_radio/kiss.c152
-rw-r--r--plugins/packet_radio/packet_radio.c1005
-rw-r--r--plugins/packet_radio/packet_radio_config.c786
-rw-r--r--plugins/packet_radio/serial_port.c705
-rw-r--r--plugins/packet_radio/serial_port.h52
-rw-r--r--plugins/packet_radio/sixpack.c146
-rw-r--r--plugins/packet_radio/sixpack.h40
-rw-r--r--plugins/packet_radio/tnc.c1080
-rw-r--r--plugins/packet_radio/tnc_host.c143
-rw-r--r--plugins/packet_radio/tnc_host.h51
-rw-r--r--plugins/packet_radio/tnc_profiles.c399
13 files changed, 4845 insertions, 0 deletions
diff --git a/plugins/packet_radio/CMakeLists.txt b/plugins/packet_radio/CMakeLists.txt
new file mode 100644
index 0000000..1dc07a5
--- /dev/null
+++ b/plugins/packet_radio/CMakeLists.txt
@@ -0,0 +1,36 @@
+add_library(hybbx_plugin_packet_radio STATIC
+ packet_radio.c
+ packet_radio_config.c
+ ax25.c
+ kiss.c
+ serial_port.c
+ sixpack.c
+ tnc.c
+ tnc_host.c
+ tnc_profiles.c
+)
+
+target_include_directories(hybbx_plugin_packet_radio
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_plugin_packet_radio
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(Threads REQUIRED)
+target_link_libraries(hybbx_plugin_packet_radio PRIVATE hybbx_core Threads::Threads)
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_link_libraries(hybbx_plugin_packet_radio PRIVATE amiga)
+endif()
+hybbx_link_plugin_instances(hybbx_plugin_packet_radio HYBBX_HAVE_PLUGIN_PACKET_RADIO)
+hybbx_apply_hardening(hybbx_plugin_packet_radio)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_packet_radio
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/packet_radio/ax25.c b/plugins/packet_radio/ax25.c
new file mode 100644
index 0000000..5cbcfd8
--- /dev/null
+++ b/plugins/packet_radio/ax25.c
@@ -0,0 +1,250 @@
+#include "hybbx/ax25.h"
+
+#include <ctype.h>
+#include <stdlib.h>
+#include <string.h>
+
+static void address_upper(char *call)
+{
+ size_t i;
+
+ for (i = 0; call[i] != '\0'; i++) {
+ call[i] = (char)toupper((unsigned char)call[i]);
+ }
+}
+
+hybbx_result_t hybbx_ax25_address_parse(const char *text,
+ hybbx_ax25_address_t *out)
+{
+ const char *dash;
+ size_t call_len;
+ char *end = NULL;
+ unsigned long ssid;
+
+ if (text == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+ dash = strchr(text, '-');
+ if (dash != NULL) {
+ call_len = (size_t)(dash - text);
+ ssid = strtoul(dash + 1, &end, 10);
+ if (end == dash + 1 || ssid > HYBBX_AX25_SSID_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+ out->ssid = (unsigned)ssid;
+ } else {
+ call_len = strlen(text);
+ }
+
+ if (call_len == 0 || call_len > HYBBX_AX25_CALL_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(out->call, text, call_len);
+ out->call[call_len] = '\0';
+ address_upper(out->call);
+ return HYBBX_OK;
+}
+
+void hybbx_ax25_encode_address(uint8_t *out, const hybbx_ax25_address_t *addr,
+ int last)
+{
+ size_t i;
+ char padded[HYBBX_AX25_CALL_MAX];
+
+ memset(padded, ' ', sizeof(padded));
+ for (i = 0; i < HYBBX_AX25_CALL_MAX && addr->call[i] != '\0'; i++) {
+ padded[i] = addr->call[i];
+ }
+
+ for (i = 0; i < HYBBX_AX25_CALL_MAX; i++) {
+ out[i] = (uint8_t)((unsigned char)padded[i] << 1);
+ }
+
+ out[6] = (uint8_t)(((addr->ssid & 0x0Fu) << 1) | 0x60u);
+ if (last) {
+ out[6] |= 0x01u;
+ }
+}
+
+uint16_t hybbx_ax25_crc(const uint8_t *data, size_t len)
+{
+ uint16_t crc = 0xFFFFu;
+ size_t i;
+ int bit;
+
+ for (i = 0; i < len; i++) {
+ crc ^= (uint16_t)data[i] << 8;
+ for (bit = 0; bit < 8; bit++) {
+ if (crc & 0x8000u) {
+ crc = (uint16_t)((crc << 1) ^ 0x1021u);
+ } else {
+ crc = (uint16_t)(crc << 1);
+ }
+ }
+ }
+
+ return crc;
+}
+
+static size_t append_addresses(const hybbx_ax25_path_t *path,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos = 0;
+ unsigned i;
+
+ if (out_cap < (size_t)(2 + path->digi_count) * HYBBX_AX25_ADDR_ENCODED) {
+ return 0;
+ }
+
+ hybbx_ax25_encode_address(out + pos, &path->dest, 0);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+
+ for (i = 0; i < path->digi_count; i++) {
+ hybbx_ax25_encode_address(out + pos, &path->digi[i], 0);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ }
+
+ hybbx_ax25_encode_address(out + pos, &path->source, 1);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ return pos;
+}
+
+size_t hybbx_ax25_build_ui(const hybbx_ax25_path_t *path,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos;
+ uint16_t crc;
+
+ if (path == NULL || out == NULL ||
+ (payload_len > 0 && payload == NULL)) {
+ return 0;
+ }
+
+ if (payload_len + 32 > out_cap || payload_len > HYBBX_AX25_FRAME_MAX) {
+ return 0;
+ }
+
+ pos = append_addresses(path, out, out_cap);
+ if (pos + 4 + payload_len > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = HYBBX_AX25_CTRL_UI;
+ out[pos++] = HYBBX_AX25_PID_NOL3;
+
+ if (payload_len > 0) {
+ memcpy(out + pos, payload, payload_len);
+ pos += payload_len;
+ }
+
+ crc = hybbx_ax25_crc(out, pos);
+ out[pos++] = (uint8_t)(crc & 0xFFu);
+ out[pos++] = (uint8_t)((crc >> 8) & 0xFFu);
+ return pos;
+}
+
+static void decode_address(const uint8_t *in, hybbx_ax25_address_t *out)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_AX25_CALL_MAX; i++) {
+ char ch = (char)((in[i] >> 1) & 0x7Fu);
+ out->call[i] = ch == ' ' ? '\0' : ch;
+ }
+ out->call[HYBBX_AX25_CALL_MAX] = '\0';
+
+ for (i = HYBBX_AX25_CALL_MAX; i > 0; i--) {
+ if (out->call[i - 1] != '\0') {
+ break;
+ }
+ }
+ out->call[i] = '\0';
+ out->ssid = (unsigned)((in[6] >> 1) & 0x0Fu);
+}
+
+size_t hybbx_ax25_parse_ui(const uint8_t *frame, size_t frame_len,
+ hybbx_ax25_path_t *path,
+ uint8_t *payload, size_t payload_cap)
+{
+ size_t pos = 0;
+ unsigned digi = 0;
+ uint16_t crc_rx;
+ uint16_t crc_calc;
+
+ if (frame == NULL || frame_len < 16) {
+ return 0;
+ }
+
+ if (path != NULL) {
+ memset(path, 0, sizeof(*path));
+ }
+
+ while (pos + HYBBX_AX25_ADDR_ENCODED <= frame_len) {
+ int last = frame[pos + 6] & 0x01;
+ hybbx_ax25_address_t tmp;
+
+ decode_address(frame + pos, &tmp);
+ if (path != NULL) {
+ if (digi == 0) {
+ path->dest = tmp;
+ } else if (!last) {
+ if (digi - 1 < HYBBX_AX25_MAX_DIGI) {
+ path->digi[digi - 1] = tmp;
+ path->digi_count = digi;
+ }
+ } else {
+ path->source = tmp;
+ }
+ }
+
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ digi++;
+
+ if (last) {
+ break;
+ }
+
+ if (digi > HYBBX_AX25_MAX_DIGI + 1) {
+ return 0;
+ }
+ }
+
+ if (pos + 4 > frame_len) {
+ return 0;
+ }
+
+ if (frame[pos] != HYBBX_AX25_CTRL_UI) {
+ return 0;
+ }
+
+ pos += 2; /* control + PID */
+
+ if (frame_len < pos + 2) {
+ return 0;
+ }
+
+ crc_rx = (uint16_t)frame[frame_len - 2] |
+ ((uint16_t)frame[frame_len - 1] << 8);
+ crc_calc = hybbx_ax25_crc(frame, frame_len - 2);
+ if (crc_rx != crc_calc) {
+ return 0;
+ }
+
+ {
+ size_t payload_len = frame_len - pos - 2;
+
+ if (payload_len > payload_cap) {
+ return 0;
+ }
+
+ if (payload_len > 0 && payload != NULL) {
+ memcpy(payload, frame + pos, payload_len);
+ }
+
+ return payload_len;
+ }
+}
diff --git a/plugins/packet_radio/kiss.c b/plugins/packet_radio/kiss.c
new file mode 100644
index 0000000..a075442
--- /dev/null
+++ b/plugins/packet_radio/kiss.c
@@ -0,0 +1,152 @@
+#include "hybbx/kiss.h"
+
+#include <string.h>
+
+void hybbx_kiss_decoder_init(hybbx_kiss_decoder_t *dec)
+{
+ if (dec == NULL) {
+ return;
+ }
+
+ memset(dec, 0, sizeof(*dec));
+}
+
+static void deliver_frame(hybbx_kiss_decoder_t *dec,
+ hybbx_kiss_frame_cb cb, void *userdata)
+{
+ uint8_t port;
+ const uint8_t *payload;
+ size_t payload_len;
+
+ if (dec->len < 1) {
+ return;
+ }
+
+ port = (uint8_t)((dec->buf[0] >> 4) & 0x0Fu);
+ payload = dec->buf + 1;
+ payload_len = dec->len - 1;
+
+ if (cb != NULL) {
+ cb(port, payload, payload_len, userdata);
+ }
+}
+
+void hybbx_kiss_decoder_feed(hybbx_kiss_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_kiss_frame_cb cb, void *userdata)
+{
+ size_t i;
+
+ if (dec == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ uint8_t byte = data[i];
+
+ if (byte == HYBBX_KISS_FEND) {
+ if (dec->in_frame && dec->len > 0) {
+ deliver_frame(dec, cb, userdata);
+ }
+ dec->in_frame = 1;
+ dec->escape = 0;
+ dec->len = 0;
+ continue;
+ }
+
+ if (!dec->in_frame) {
+ continue;
+ }
+
+ if (dec->escape) {
+ if (byte == HYBBX_KISS_TFEND) {
+ byte = HYBBX_KISS_FEND;
+ } else if (byte == HYBBX_KISS_TFESC) {
+ byte = HYBBX_KISS_FESC;
+ }
+ dec->escape = 0;
+ } else if (byte == HYBBX_KISS_FESC) {
+ dec->escape = 1;
+ continue;
+ }
+
+ if (dec->len >= HYBBX_KISS_MAX_FRAME) {
+ dec->len = 0;
+ dec->in_frame = 0;
+ continue;
+ }
+
+ dec->buf[dec->len++] = byte;
+ }
+}
+
+static size_t kiss_write_byte(uint8_t byte, uint8_t *out, size_t out_cap, size_t pos)
+{
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+
+ if (byte == HYBBX_KISS_FEND) {
+ if (pos + 2 > out_cap) {
+ return 0;
+ }
+ out[pos++] = HYBBX_KISS_FESC;
+ out[pos++] = HYBBX_KISS_TFEND;
+ return pos;
+ }
+
+ if (byte == HYBBX_KISS_FESC) {
+ if (pos + 2 > out_cap) {
+ return 0;
+ }
+ out[pos++] = HYBBX_KISS_FESC;
+ out[pos++] = HYBBX_KISS_TFESC;
+ return pos;
+ }
+
+ out[pos++] = byte;
+ return pos;
+}
+
+size_t hybbx_kiss_encode(uint8_t port, hybbx_kiss_command_t cmd,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos = 0;
+ uint8_t cmd_byte;
+ size_t i;
+
+ if (out == NULL || out_cap < 3) {
+ return 0;
+ }
+
+ if (payload_len > 0 && payload == NULL) {
+ return 0;
+ }
+
+ if (payload_len + 4 > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = HYBBX_KISS_FEND;
+
+ cmd_byte = (uint8_t)(((port & 0x0Fu) << 4) | ((uint8_t)cmd & 0x0Fu));
+ pos = kiss_write_byte(cmd_byte, out, out_cap, pos);
+ if (pos == 0) {
+ return 0;
+ }
+
+ for (i = 0; i < payload_len; i++) {
+ pos = kiss_write_byte(payload[i], out, out_cap, pos);
+ if (pos == 0) {
+ return 0;
+ }
+ }
+
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = HYBBX_KISS_FEND;
+ return pos;
+}
diff --git a/plugins/packet_radio/packet_radio.c b/plugins/packet_radio/packet_radio.c
new file mode 100644
index 0000000..e8952ae
--- /dev/null
+++ b/plugins/packet_radio/packet_radio.c
@@ -0,0 +1,1005 @@
+/*
+ * packet_radio — link/repeater edge adapter: serial TNC, KISS/AX.25 → HBX client.
+ * INI: [transport.packet_radio] or [transport.packet_radioN]. See share/hybbx.ini.example.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/broadcast.h"
+#include "hybbx/packet_radio.h"
+#include "hybbx/tnc.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/traffic.h"
+#include "hybbx/limits.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/ax25.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+#include "hybbx/max25.h"
+
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <errno.h>
+#include <poll.h>
+#include <sys/select.h>
+#endif
+
+#define PACKET_RADIO_POLL_MS 20u
+
+static hybbx_service_t *g_service;
+
+typedef struct packet_radio_instance {
+ hybbx_packet_radio_config_t config;
+ hybbx_tnc_t *tnc;
+ int circuit_fd;
+ hybbx_circuit_decoder_t circuit_dec;
+ volatile int flow_paused;
+ volatile int flow_cancel;
+ unsigned index;
+ unsigned circuit_reconnect_polls;
+} packet_radio_instance_t;
+
+static packet_radio_instance_t g_instances[HYBBX_PACKET_RADIO_MAX_INSTANCES];
+static unsigned g_instance_count;
+static pthread_t g_poll_thread;
+static volatile int g_radio_running;
+static int g_poll_thread_started;
+
+extern const hybbx_transport_plugin_t hybbx_plugin_packet_radio;
+
+static void instance_note_rf_activity(packet_radio_instance_t *inst);
+
+static unsigned packet_radio_count_local_edges(const char *config)
+{
+ char scratch[4096];
+ const char *cursor = config;
+ unsigned count = 0;
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, HYBBX_PACKET_RADIO_INSTANCE_SEP);
+ size_t len = sep != NULL ? (size_t)(sep - cursor) : strlen(cursor);
+
+ if (len == 0) {
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ continue;
+ }
+
+ if (len >= sizeof(scratch)) {
+ return count;
+ }
+
+ memcpy(scratch, cursor, len);
+ scratch[len] = '\0';
+
+ if (hybbx_packet_radio_section_is_local_edge(scratch)) {
+ count++;
+ }
+
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ }
+
+ return count;
+}
+
+static hybbx_result_t instance_connect_circuit(packet_radio_instance_t *inst);
+static void packet_radio_poll_sleep_ms(unsigned ms);
+
+static const char *device_type_name(hybbx_packet_radio_device_type_t type)
+{
+ return type == HYBBX_PACKET_RADIO_DEVICE_SERIAL ? "serial" : "usb";
+}
+
+static const char *protocol_name(hybbx_packet_radio_protocol_t proto)
+{
+ switch (proto) {
+ case HYBBX_PACKET_RADIO_PROTO_HOSTMODE:
+ return "hostmode";
+ case HYBBX_PACKET_RADIO_PROTO_SIXPACK:
+ return "6pack";
+ case HYBBX_PACKET_RADIO_PROTO_KISS:
+ default:
+ return "kiss";
+ }
+}
+
+static const char *tnc_name(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ return "baycom";
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ return "pccom";
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ return "generic";
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ default:
+ return "tnc2c";
+ }
+}
+
+static int instance_circuit_uplink_allowed(const packet_radio_instance_t *inst)
+{
+ return inst != NULL && !inst->flow_paused && !inst->flow_cancel;
+}
+
+static int packet_radio_source_banned(const hybbx_ax25_path_t *path)
+{
+ char raw[HYBBX_CALLID_MAX];
+ char norm[HYBBX_CALLID_MAX];
+
+ if (path == NULL || path->source.call[0] == '\0') {
+ return 0;
+ }
+
+ if (path->source.ssid > 0u) {
+ snprintf(raw, sizeof(raw), "%s-%u", path->source.call,
+ path->source.ssid);
+ } else {
+ hybbx_strlcpy(raw, path->source.call, sizeof(raw));
+ }
+
+ if (!hybbx_security_callid_normalize(raw, norm, sizeof(norm))) {
+ return 0;
+ }
+
+ return hybbx_security_ban_callid_is_banned(norm);
+}
+
+static int packet_radio_frame_source_banned(const uint8_t *frame, size_t len)
+{
+ hybbx_ax25_path_t path;
+ uint8_t scratch[HYBBX_AX25_FRAME_MAX];
+
+ if (frame == NULL || len == 0) {
+ return 0;
+ }
+
+ if (hybbx_ax25_parse_ui(frame, len, &path, scratch, sizeof(scratch)) == 0) {
+ return 0;
+ }
+
+ return packet_radio_source_banned(&path);
+}
+
+static hybbx_result_t instance_circuit_uplink(packet_radio_instance_t *inst,
+ const uint8_t *frame,
+ size_t frame_len)
+{
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+ uint16_t flags = HYBBX_CIRCUIT_FLAG_RX;
+
+ if (inst == NULL || inst->circuit_fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (!instance_circuit_uplink_allowed(inst)) {
+ return HYBBX_OK;
+ }
+
+ if (hybbx_packet_radio_modulation_is_g3ruh(inst->config.params.modulation)) {
+ flags |= HYBBX_CIRCUIT_FLAG_G3RUH_FSK;
+ }
+
+ hbx_len = hybbx_circuit_encode_ax25(frame, frame_len, flags,
+ hbx, sizeof(hbx));
+ if (hbx_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(inst->circuit_fd, hbx, hbx_len);
+}
+
+static void on_tnc_ax25_frame(const uint8_t *frame, size_t len, void *userdata)
+{
+ packet_radio_instance_t *inst = (packet_radio_instance_t *)userdata;
+
+ if (len == 0 || inst == NULL) {
+ return;
+ }
+
+ if (packet_radio_frame_source_banned(frame, len)) {
+ return;
+ }
+
+ instance_note_rf_activity(inst);
+ (void)instance_circuit_uplink(inst, frame, len);
+}
+
+static void on_tnc_host_ui(const uint8_t *payload, size_t len,
+ const hybbx_ax25_path_t *path, void *userdata)
+{
+ packet_radio_instance_t *inst = (packet_radio_instance_t *)userdata;
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+
+ (void)path;
+
+ if (inst == NULL || inst->circuit_fd < 0 || len == 0) {
+ return;
+ }
+
+ if (path != NULL && packet_radio_source_banned(path)) {
+ return;
+ }
+
+ if (!instance_circuit_uplink_allowed(inst)) {
+ return;
+ }
+
+ hbx_len = hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_TERMINAL,
+ HYBBX_CIRCUIT_FLAG_RX,
+ payload, len, hbx, sizeof(hbx));
+ if (hbx_len > 0) {
+ (void)hybbx_circuit_link_write(inst->circuit_fd, hbx, hbx_len);
+ }
+}
+
+static void instance_format_call(const hybbx_ax25_address_t *addr,
+ char *out, size_t out_len)
+{
+ if (out == NULL || out_len == 0) {
+ return;
+ }
+
+ out[0] = '\0';
+ if (addr == NULL || addr->call[0] == '\0') {
+ return;
+ }
+
+ if (addr->ssid > 0u) {
+ unsigned ssid = addr->ssid;
+
+ if (ssid > HYBBX_AX25_SSID_MAX) {
+ ssid = HYBBX_AX25_SSID_MAX;
+ }
+ snprintf(out, out_len, "%s-%u", addr->call, ssid);
+ } else {
+ hybbx_strlcpy(out, addr->call, out_len);
+ }
+}
+
+static void instance_log_rf_tx(packet_radio_instance_t *inst,
+ hybbx_result_t rc,
+ const hybbx_ax25_path_t *path,
+ size_t payload_len)
+{
+ char src[HYBBX_AX25_CALL_MAX + 8];
+ char dst[HYBBX_AX25_CALL_MAX + 8];
+
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_format_call(path != NULL ? &path->source : NULL, src, sizeof(src));
+ instance_format_call(path != NULL ? &path->dest : NULL, dst, sizeof(dst));
+
+ if (rc == HYBBX_OK) {
+ hybbx_log_stats("[packet_radio%u] RF TX %s>%s (%zu bytes)",
+ inst->index + 1, src, dst, payload_len);
+ } else {
+ hybbx_log_warn("[packet_radio%u] RF TX failed %s>%s (%zu bytes, rc=%d)",
+ inst->index + 1, src, dst, payload_len, (int)rc);
+ }
+}
+
+static void instance_note_rf_activity(packet_radio_instance_t *inst)
+{
+ hybbx_circuit_hub_t *hub;
+ const char *link_id;
+
+ if (inst == NULL || g_service == NULL) {
+ return;
+ }
+
+ hub = hybbx_service_circuit_hub(g_service);
+ link_id = inst->config.link_id;
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = "packet-radio";
+ }
+
+ if (hub != NULL) {
+ hybbx_circuit_hub_note_rf_activity(hub, link_id);
+ }
+}
+
+static hybbx_result_t instance_tx_ax25_ui(packet_radio_instance_t *inst,
+ const hybbx_ax25_path_t *path,
+ const uint8_t *payload, size_t len)
+{
+ uint8_t frame[HYBBX_AX25_FRAME_MAX];
+ size_t frame_len;
+ hybbx_result_t rc;
+
+ if (inst == NULL || inst->tnc == NULL || path == NULL ||
+ payload == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_ax25_build_ui(path, payload, len, frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_tnc_send_frame(inst->tnc, frame, frame_len);
+ if (rc == HYBBX_OK) {
+ instance_note_rf_activity(inst);
+ }
+ instance_log_rf_tx(inst, rc, path, len);
+ return rc;
+}
+
+static hybbx_result_t instance_send_bytes(packet_radio_instance_t *inst,
+ const uint8_t *data, size_t len)
+{
+ if (inst == NULL || inst->tnc == NULL || data == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (inst->config.protocol == HYBBX_PACKET_RADIO_PROTO_HOSTMODE) {
+ if (hybbx_tnc_host_connected(inst->tnc)) {
+ return hybbx_tnc_send_converse(inst->tnc, (const char *)data, len);
+ }
+ return HYBBX_ERR_BUSY;
+ }
+
+ return hybbx_tnc_send_ui(inst->tnc, data, len);
+}
+
+static void instance_circuit_disconnect(packet_radio_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ inst->circuit_reconnect_polls = 0;
+}
+
+static void instance_circuit_reconnect(packet_radio_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_circuit_disconnect(inst);
+ packet_radio_poll_sleep_ms(250);
+ if (instance_connect_circuit(inst) == HYBBX_OK) {
+ hybbx_log_info("[packet_radio%u] circuit reconnected", inst->index + 1);
+ }
+}
+
+static void instance_on_circuit_flow_ctrl(packet_radio_instance_t *inst,
+ hybbx_circuit_proto_t proto,
+ uint16_t flags,
+ const uint8_t *payload, size_t len)
+{
+ hybbx_circuit_balance_action_t action;
+ char reason[64];
+
+ (void)proto;
+ (void)flags;
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (hybbx_circuit_flow_ctrl_parse((const char *)payload, len, &action,
+ reason, sizeof(reason)) != HYBBX_OK) {
+ return;
+ }
+
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE:
+ inst->flow_paused = 1;
+ hybbx_log_stats("[packet_radio%u] circuit flow pause (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_BREAK:
+ inst->flow_paused = 0;
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ hybbx_log_stats("[packet_radio%u] circuit flow break (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ inst->flow_cancel = 1;
+ hybbx_log_warn("[packet_radio%u] circuit flow cancel (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ inst->flow_paused = 0;
+ hybbx_log_stats("[packet_radio%u] circuit flow resume (%s)",
+ inst->index + 1, reason);
+ break;
+ default:
+ break;
+ }
+}
+
+static void on_circuit_downlink(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ packet_radio_instance_t *inst = (packet_radio_instance_t *)userdata;
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_FLOW_CTRL) {
+ instance_on_circuit_flow_ctrl(inst, proto, flags, payload, len);
+ return;
+ }
+
+ if (inst->tnc == NULL || len == 0) {
+ return;
+ }
+
+ switch (proto) {
+ case HYBBX_CIRCUIT_PROTO_TERMINAL:
+ (void)instance_send_bytes(inst, payload, len);
+ break;
+ case HYBBX_CIRCUIT_PROTO_AX25: {
+ hybbx_result_t rc = hybbx_tnc_send_frame(inst->tnc, payload, len);
+ if (rc == HYBBX_OK) {
+ hybbx_ax25_path_t path;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ size_t ui_len = hybbx_ax25_parse_ui(payload, len, &path,
+ ui, sizeof(ui));
+
+ instance_note_rf_activity(inst);
+ if (ui_len > 0) {
+ instance_log_rf_tx(inst, rc, &path, ui_len);
+ }
+ } else {
+ hybbx_log_warn("[packet_radio%u] RF TX frame failed (rc=%d, %zu bytes)",
+ inst->index + 1, (int)rc, len);
+ }
+ break;
+ }
+ case HYBBX_CIRCUIT_PROTO_AX25_UI: {
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t ui_len = hybbx_circuit_unpack_ax25_ui(payload, len, &path,
+ ui, sizeof(ui));
+ if (ui_len > 0) {
+ (void)instance_tx_ax25_ui(inst, &path, ui, ui_len);
+ } else {
+ hybbx_log_warn("[packet_radio%u] AX25_UI unpack failed (%zu bytes)",
+ inst->index + 1, len);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+static void packet_radio_poll_sleep_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)(ms / 1000u);
+ tv.tv_usec = (suseconds_t)((ms % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+#endif
+}
+
+static hybbx_result_t instance_connect_circuit(packet_radio_instance_t *inst)
+{
+ const char *host;
+ unsigned port;
+ unsigned attempt;
+
+ if (inst == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ host = inst->config.circuit_host;
+ port = inst->config.circuit_port;
+
+ if (host == NULL || host[0] == '\0') {
+ host = "127.0.0.1";
+ }
+ if (port == 0) {
+ port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+
+ if (inst->config.link_password == NULL ||
+ inst->config.link_password[0] == '\0') {
+ hybbx_log_warn("[packet_radio%u] missing link_password for HBX LINK_AUTH; "
+ "set link_password in [transport.packet_radioN] "
+ "(hub logs link_auth_fail reason=timeout_no_link_auth)",
+ inst->index + 1);
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (attempt = 0; attempt < 50; attempt++) {
+ hybbx_result_t rc = hybbx_circuit_link_connect(host, port,
+ &inst->circuit_fd);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ {
+ const char *link_id = inst->config.link_id;
+ const char *link_role = inst->config.link_role;
+
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = "packet-radio";
+ }
+ if (link_role == NULL || link_role[0] == '\0') {
+ link_role = "link";
+ }
+
+ {
+ hybbx_circuit_link_qos_t qos;
+ int duplex = (int)inst->config.params.duplex;
+
+ memset(&qos, 0, sizeof(qos));
+ qos.baud = inst->config.params.radio_baud;
+ if (duplex == HYBBX_PACKET_RADIO_DUPLEX_HALF) {
+ qos.duplex = 1;
+ } else if (duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL) {
+ qos.duplex = 2;
+ }
+ qos.bandwidth = "low";
+ qos.frequency_mhz = inst->config.frequency_mhz;
+
+ rc = hybbx_circuit_link_authenticate_ex(
+ inst->circuit_fd, inst->config.link_password,
+ link_role, link_id, &qos);
+ }
+ if (rc != HYBBX_OK) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ packet_radio_poll_sleep_ms(100);
+ continue;
+ }
+ }
+ hybbx_log_info("[packet_radio%u] linked to internal circuit %s:%u (HBX)",
+ inst->index + 1, host, port);
+ return HYBBX_OK;
+ }
+ packet_radio_poll_sleep_ms(100);
+ }
+
+ hybbx_log_warn("[packet_radio%u] could not connect to internal circuit %s:%u",
+ inst->index + 1, host, port);
+ return HYBBX_ERR_IO;
+}
+
+static void instance_shutdown(packet_radio_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+
+ if (inst->tnc != NULL) {
+ hybbx_tnc_close(inst->tnc);
+ inst->tnc = NULL;
+ }
+
+ hybbx_packet_radio_config_free(&inst->config);
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+}
+
+typedef enum {
+ PR_POLL_CIRCUIT = 0,
+ PR_POLL_SERIAL = 1
+} packet_radio_poll_kind_t;
+
+typedef struct {
+ unsigned instance_index;
+ packet_radio_poll_kind_t kind;
+} packet_radio_poll_map_t;
+
+static void instance_drain_circuit(packet_radio_instance_t *inst)
+{
+ uint8_t buf[512];
+
+ if (inst == NULL || inst->circuit_fd < 0) {
+ return;
+ }
+
+ for (;;) {
+ size_t read_len = 0;
+ hybbx_result_t rc = hybbx_circuit_link_read(inst->circuit_fd, buf,
+ sizeof(buf), &read_len);
+
+ if (rc == HYBBX_ERR_IO) {
+ hybbx_log_warn("[packet_radio%u] circuit disconnected — reconnecting",
+ inst->index + 1);
+ instance_circuit_reconnect(inst);
+ return;
+ }
+ if (rc != HYBBX_OK || read_len == 0) {
+ break;
+ }
+
+ hybbx_circuit_decoder_feed(&inst->circuit_dec, buf, read_len,
+ on_circuit_downlink, inst);
+ }
+}
+
+#if !defined(_WIN32)
+static void packet_radio_poll_instances(void)
+{
+ struct pollfd pfds[HYBBX_PACKET_RADIO_MAX_INSTANCES * 2u];
+ packet_radio_poll_map_t map[HYBBX_PACKET_RADIO_MAX_INSTANCES * 2u];
+ nfds_t nfds = 0;
+ unsigned i;
+
+ for (i = 0; i < g_instance_count; i++) {
+ packet_radio_instance_t *inst = &g_instances[i];
+ int circuit_fd = inst->circuit_fd;
+ int serial_fd = inst->tnc != NULL ? hybbx_tnc_serial_fd(inst->tnc) : -1;
+
+ if (circuit_fd >= 0) {
+ pfds[nfds].fd = circuit_fd;
+ pfds[nfds].events = POLLIN;
+ pfds[nfds].revents = 0;
+ map[nfds].instance_index = i;
+ map[nfds].kind = PR_POLL_CIRCUIT;
+ nfds++;
+ }
+
+ if (serial_fd >= 0) {
+ pfds[nfds].fd = serial_fd;
+ pfds[nfds].events = POLLIN;
+ pfds[nfds].revents = 0;
+ map[nfds].instance_index = i;
+ map[nfds].kind = PR_POLL_SERIAL;
+ nfds++;
+ }
+ }
+
+ if (nfds > 0) {
+ int pr = poll(pfds, nfds, (int)PACKET_RADIO_POLL_MS);
+
+ if (pr < 0) {
+ if (errno != EINTR) {
+ hybbx_log_warn("[packet_radio] poll failed");
+ }
+ } else if (pr > 0) {
+ nfds_t j;
+
+ for (j = 0; j < nfds; j++) {
+ short rev = pfds[j].revents;
+
+ if (rev == 0) {
+ continue;
+ }
+
+ if ((rev & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ if (map[j].kind == PR_POLL_CIRCUIT) {
+ packet_radio_instance_t *inst =
+ &g_instances[map[j].instance_index];
+
+ hybbx_log_warn("[packet_radio%u] circuit hangup — "
+ "reconnecting",
+ inst->index + 1);
+ instance_circuit_reconnect(inst);
+ }
+ continue;
+ }
+
+ if ((rev & POLLIN) == 0) {
+ continue;
+ }
+
+ if (map[j].kind == PR_POLL_CIRCUIT) {
+ instance_drain_circuit(&g_instances[map[j].instance_index]);
+ } else {
+ packet_radio_instance_t *inst =
+ &g_instances[map[j].instance_index];
+
+ if (inst->tnc != NULL) {
+ (void)hybbx_tnc_poll(inst->tnc);
+ }
+ }
+ }
+ }
+ } else {
+ packet_radio_poll_sleep_ms(PACKET_RADIO_POLL_MS);
+ }
+}
+#endif
+
+static void packet_radio_service_maintenance(void)
+{
+ unsigned i;
+
+ for (i = 0; i < g_instance_count; i++) {
+ packet_radio_instance_t *inst = &g_instances[i];
+
+ if (inst->circuit_fd < 0) {
+ inst->circuit_reconnect_polls++;
+ if (inst->circuit_reconnect_polls >= 50u) {
+ inst->circuit_reconnect_polls = 0;
+ (void)instance_connect_circuit(inst);
+ }
+ }
+
+ if (inst->flow_cancel) {
+ inst->flow_cancel = 0;
+ inst->flow_paused = 0;
+ instance_circuit_reconnect(inst);
+ }
+ }
+}
+
+#if defined(_WIN32)
+static void packet_radio_poll_win32(void)
+{
+ unsigned i;
+
+ for (i = 0; i < g_instance_count; i++) {
+ packet_radio_instance_t *inst = &g_instances[i];
+
+ if (inst->tnc != NULL) {
+ (void)hybbx_tnc_poll(inst->tnc);
+ }
+
+ if (inst->circuit_fd >= 0) {
+ instance_drain_circuit(inst);
+ }
+ }
+}
+#endif
+
+static void *packet_radio_poll_thread(void *arg)
+{
+ (void)arg;
+
+ while (g_radio_running) {
+#if !defined(_WIN32)
+ packet_radio_poll_instances();
+#else
+ packet_radio_poll_win32();
+ packet_radio_poll_sleep_ms(PACKET_RADIO_POLL_MS);
+#endif
+ packet_radio_service_maintenance();
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t instance_start(packet_radio_instance_t *inst,
+ const char *config,
+ unsigned index)
+{
+ hybbx_result_t rc;
+ hybbx_tnc_frame_cb frame_cb = on_tnc_ax25_frame;
+ hybbx_tnc_ui_cb ui_cb = NULL;
+
+ if (inst == NULL || config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+ inst->index = index;
+
+ rc = hybbx_packet_radio_config_parse(config, &inst->config);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[packet_radio%u] invalid configuration", index + 1);
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ if (g_service != NULL) {
+ const hybbx_broadcast_config_t *bc =
+ hybbx_service_get_broadcast(g_service);
+
+ if (bc != NULL && bc->ax25_mycall[0] != '\0') {
+ size_t n = strlen(bc->ax25_mycall) + 1;
+ char *mc = malloc(n);
+
+ if (mc != NULL) {
+ memcpy(mc, bc->ax25_mycall, n);
+ free(inst->config.mycall);
+ inst->config.mycall = mc;
+ hybbx_log_info("[packet_radio%u] ax25 source=%s (from [broadcast] ax25_mycall)",
+ index + 1, inst->config.mycall);
+ }
+ }
+ }
+
+ hybbx_log_info("[packet_radio%u] tnc=%s protocol=%s device_type=%s device=%s "
+ "band=%s duplex=%s modulation=%s circuit=%s:%u "
+ "(HBX over internal TCP)",
+ index + 1,
+ tnc_name(inst->config.tnc),
+ protocol_name(inst->config.protocol),
+ device_type_name(inst->config.device_type),
+ inst->config.device,
+ hybbx_packet_radio_band_name(inst->config.params.band),
+ hybbx_packet_radio_duplex_name(inst->config.params.duplex),
+ hybbx_packet_radio_modulation_name(inst->config.params.modulation),
+ inst->config.circuit_host,
+ inst->config.circuit_port);
+ if (inst->config.frequency_mhz != NULL &&
+ inst->config.frequency_mhz[0] != '\0') {
+ hybbx_log_info("[packet_radio%u] frequency_mhz=%s", index + 1,
+ inst->config.frequency_mhz);
+ }
+
+ rc = instance_connect_circuit(inst);
+ if (rc != HYBBX_OK) {
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ if (inst->config.protocol == HYBBX_PACKET_RADIO_PROTO_HOSTMODE) {
+ frame_cb = NULL;
+ ui_cb = on_tnc_host_ui;
+ }
+
+ rc = hybbx_tnc_open(&inst->tnc, &inst->config, frame_cb, ui_cb, inst);
+ if (rc != HYBBX_OK) {
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t packet_radio_init(hybbx_service_t *service)
+{
+ g_service = service;
+ g_instance_count = 0;
+ g_radio_running = 0;
+ return HYBBX_OK;
+}
+
+static void packet_radio_shutdown(void)
+{
+ unsigned i;
+
+ g_radio_running = 0;
+ if (g_poll_thread_started) {
+ pthread_join(g_poll_thread, NULL);
+ g_poll_thread_started = 0;
+ }
+
+ for (i = 0; i < g_instance_count; i++) {
+ instance_shutdown(&g_instances[i]);
+ }
+ g_instance_count = 0;
+}
+
+static hybbx_result_t packet_radio_start(const char *config)
+{
+ char scratch[4096];
+ const char *cursor;
+ hybbx_max25_config_t max25;
+ hybbx_max25_status_t max25_status;
+ hybbx_result_t rc = HYBBX_OK;
+ unsigned started = 0;
+ unsigned section_num = 0;
+ unsigned local_edges;
+
+ (void)g_service;
+
+ if (config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ packet_radio_shutdown();
+ g_poll_thread = (pthread_t)0;
+
+ cursor = hybbx_max25_config_skip_prefix(config, &max25);
+ local_edges = packet_radio_count_local_edges(cursor);
+ if (local_edges > 0u) {
+ rc = hybbx_max25_wait_ready(&max25);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ while (*cursor != '\0' && g_instance_count < HYBBX_PACKET_RADIO_MAX_INSTANCES) {
+ const char *sep = strchr(cursor, HYBBX_PACKET_RADIO_INSTANCE_SEP);
+ size_t len = sep != NULL ? (size_t)(sep - cursor) : strlen(cursor);
+
+ if (len == 0) {
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ continue;
+ }
+
+ if (len >= sizeof(scratch)) {
+ packet_radio_shutdown();
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(scratch, cursor, len);
+ scratch[len] = '\0';
+ section_num++;
+
+ if (!hybbx_packet_radio_section_is_local_edge(scratch)) {
+ hybbx_log_info("[packet_radio%u] bridge registry only — no local TNC",
+ section_num);
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ continue;
+ }
+
+ rc = instance_start(&g_instances[g_instance_count], scratch,
+ g_instance_count);
+ if (rc != HYBBX_OK) {
+ packet_radio_shutdown();
+ return rc;
+ }
+
+ g_instance_count++;
+ started++;
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ }
+
+ if (started == 0) {
+ return HYBBX_OK;
+ }
+
+ g_radio_running = 1;
+ if (pthread_create(&g_poll_thread, NULL, packet_radio_poll_thread,
+ NULL) != 0) {
+ hybbx_log_warn("[packet_radio] poll thread failed");
+ packet_radio_shutdown();
+ return HYBBX_ERR_IO;
+ }
+ g_poll_thread_started = 1;
+
+ hybbx_log_info("[packet_radio] %u TNC instance(s) active", started);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t packet_radio_stop(void)
+{
+ hybbx_log_info("[packet_radio] stop");
+ packet_radio_shutdown();
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_packet_radio = {
+ .name = "packet_radio",
+ .kind = HYBBX_TRANSPORT_PACKET_RADIO,
+ .version = 1,
+ .init = packet_radio_init,
+ .shutdown = packet_radio_shutdown,
+ .start = packet_radio_start,
+ .stop = packet_radio_stop,
+ .write = NULL,
+};
diff --git a/plugins/packet_radio/packet_radio_config.c b/plugins/packet_radio/packet_radio_config.c
new file mode 100644
index 0000000..f8e2d65
--- /dev/null
+++ b/plugins/packet_radio/packet_radio_config.c
@@ -0,0 +1,786 @@
+#include "hybbx/packet_radio.h"
+#include "hybbx/tnc.h"
+#include "hybbx/circuit.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+static char *hybbx_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+static const char *find_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = config;
+ size_t key_len = strlen(key);
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, ';');
+ const char *end = sep != NULL ? sep : cursor + strlen(cursor);
+ const char *eq = strchr(cursor, '=');
+
+ if (eq != NULL && eq < end && (size_t)(eq - cursor) == key_len &&
+ strncmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(end - value);
+
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+int hybbx_packet_radio_section_is_local_edge(const char *config)
+{
+ char scratch[64];
+
+ if (config == NULL) {
+ return 0;
+ }
+
+ if (find_kv(config, "circuit_host", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+ if (find_kv(config, "device", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+ if (find_kv(config, "tnc", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+ if (find_kv(config, "protocol", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+
+ return 0;
+}
+
+static hybbx_packet_radio_device_type_t parse_device_type(const char *value)
+{
+ if (value == NULL) {
+ return HYBBX_PACKET_RADIO_DEVICE_USB;
+ }
+
+ if (str_ieq(value, "usb") || str_ieq(value, "ax25") ||
+ str_ieq(value, "ax.25")) {
+ return HYBBX_PACKET_RADIO_DEVICE_USB;
+ }
+
+ if (str_ieq(value, "serial") || str_ieq(value, "com") ||
+ str_ieq(value, "rs232") || str_ieq(value, "v24")) {
+ return HYBBX_PACKET_RADIO_DEVICE_SERIAL;
+ }
+
+ return HYBBX_PACKET_RADIO_DEVICE_USB;
+}
+
+static const char *default_device_for_type(hybbx_packet_radio_device_type_t type)
+{
+ if (type == HYBBX_PACKET_RADIO_DEVICE_SERIAL) {
+ return HYBBX_PACKET_RADIO_DEFAULT_DEVICE_SERIAL;
+ }
+ return HYBBX_PACKET_RADIO_DEFAULT_DEVICE_USB;
+}
+
+static hybbx_packet_radio_tnc_t parse_tnc(const char *value)
+{
+ if (value == NULL || str_ieq(value, "tnc2c") || str_ieq(value, "tnc2-c")) {
+ return HYBBX_PACKET_RADIO_TNC_TNC2C;
+ }
+
+ if (str_ieq(value, "tnc2") || str_ieq(value, "tnc-2") ||
+ str_ieq(value, "pktnc2") || str_ieq(value, "pk-tnc2") ||
+ str_ieq(value, "tapr") || str_ieq(value, "thefirmware") ||
+ str_ieq(value, "tf")) {
+ return HYBBX_PACKET_RADIO_TNC_TNC2;
+ }
+
+ if (str_ieq(value, "pk232") || str_ieq(value, "pk-232") ||
+ str_ieq(value, "aea") || str_ieq(value, "pk87") ||
+ str_ieq(value, "pk-87")) {
+ return HYBBX_PACKET_RADIO_TNC_PK232;
+ }
+
+ if (str_ieq(value, "mfj1278") || str_ieq(value, "mfj-1278") ||
+ str_ieq(value, "mfj1278b") || str_ieq(value, "mfj")) {
+ return HYBBX_PACKET_RADIO_TNC_MFJ1278;
+ }
+
+ if (str_ieq(value, "kantronics") || str_ieq(value, "kpc") ||
+ str_ieq(value, "kpc3") || str_ieq(value, "kpc9612")) {
+ return HYBBX_PACKET_RADIO_TNC_KANTRONICS;
+ }
+
+ if (str_ieq(value, "baycom") || str_ieq(value, "bay-com")) {
+ return HYBBX_PACKET_RADIO_TNC_BAYCOM;
+ }
+
+ if (str_ieq(value, "pccom") || str_ieq(value, "pc-com") ||
+ str_ieq(value, "albrecht")) {
+ return HYBBX_PACKET_RADIO_TNC_PCCOM;
+ }
+
+ if (str_ieq(value, "generic") || str_ieq(value, "tnc")) {
+ return HYBBX_PACKET_RADIO_TNC_GENERIC;
+ }
+
+ return HYBBX_PACKET_RADIO_TNC_TNC2C;
+}
+
+static hybbx_packet_radio_protocol_t parse_protocol(const char *value)
+{
+ if (value == NULL || str_ieq(value, "kiss")) {
+ return HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+
+ if (str_ieq(value, "hostmode") || str_ieq(value, "host") ||
+ str_ieq(value, "tnc2")) {
+ return HYBBX_PACKET_RADIO_PROTO_HOSTMODE;
+ }
+
+ if (str_ieq(value, "6pack") || str_ieq(value, "sixpack")) {
+ return HYBBX_PACKET_RADIO_PROTO_SIXPACK;
+ }
+
+ return HYBBX_PACKET_RADIO_PROTO_KISS;
+}
+
+static hybbx_tnc2c_modem_t parse_modem(const char *value)
+{
+ if (value == NULL || str_ieq(value, "tcm3105") || str_ieq(value, "1200")) {
+ return HYBBX_TNC2C_MODEM_TCM3105;
+ }
+
+ if (str_ieq(value, "9600")) {
+ return HYBBX_TNC2C_MODEM_9600;
+ }
+
+ if (str_ieq(value, "g3ruh") || str_ieq(value, "g3ruh_fsk") ||
+ str_ieq(value, "fsk9600")) {
+ return HYBBX_TNC2C_MODEM_9600;
+ }
+
+ if (str_ieq(value, "19200")) {
+ return HYBBX_TNC2C_MODEM_19200;
+ }
+
+ return HYBBX_TNC2C_MODEM_TCM3105;
+}
+
+static float parse_clock_mhz(const char *value)
+{
+ if (value == NULL) {
+ return HYBBX_TNC2C_CLOCK_4_9_MHZ;
+ }
+
+ if (str_ieq(value, "9.8") || str_ieq(value, "9,8")) {
+ return HYBBX_TNC2C_CLOCK_9_8_MHZ;
+ }
+
+ if (str_ieq(value, "10") || str_ieq(value, "10.0")) {
+ return HYBBX_TNC2C_CLOCK_10_MHZ;
+ }
+
+ return HYBBX_TNC2C_CLOCK_4_9_MHZ;
+}
+
+static unsigned int parse_uint(const char *value, unsigned int default_value)
+{
+ char *end = NULL;
+ unsigned long n;
+
+ if (value == NULL || value[0] == '\0') {
+ return default_value;
+ }
+
+ n = strtoul(value, &end, 10);
+ if (end == value || n == 0) {
+ return default_value;
+ }
+
+ return (unsigned int)n;
+}
+
+static hybbx_packet_radio_band_t parse_radio_band(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_PACKET_RADIO_BAND_UNSET;
+ }
+
+ if (str_ieq(value, "cb") || str_ieq(value, "11m") ||
+ str_ieq(value, "27mhz") || str_ieq(value, "citizen")) {
+ return HYBBX_PACKET_RADIO_BAND_CB;
+ }
+
+ if (str_ieq(value, "amateur") || str_ieq(value, "ham") ||
+ str_ieq(value, "amateurfunk") || str_ieq(value, "afu")) {
+ return HYBBX_PACKET_RADIO_BAND_AMATEUR;
+ }
+
+ return HYBBX_PACKET_RADIO_BAND_UNSET;
+}
+
+static hybbx_packet_radio_duplex_t parse_radio_duplex(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_PACKET_RADIO_DUPLEX_UNSET;
+ }
+
+ if (str_ieq(value, "full") || str_ieq(value, "full-duplex") ||
+ str_ieq(value, "fullduplex") || str_ieq(value, "fulldup")) {
+ return HYBBX_PACKET_RADIO_DUPLEX_FULL;
+ }
+
+ if (str_ieq(value, "half") || str_ieq(value, "half-duplex") ||
+ str_ieq(value, "duplex") || str_ieq(value, "simplex")) {
+ return HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ return HYBBX_PACKET_RADIO_DUPLEX_UNSET;
+}
+
+static hybbx_packet_radio_modulation_t parse_modulation(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_PACKET_RADIO_MOD_UNSET;
+ }
+
+ if (str_ieq(value, "g3ruh") || str_ieq(value, "g3ruh_fsk") ||
+ str_ieq(value, "fsk9600") || str_ieq(value, "9600fsk")) {
+ return HYBBX_PACKET_RADIO_MOD_G3RUH_FSK;
+ }
+
+ if (str_ieq(value, "afsk") || str_ieq(value, "bell202") ||
+ str_ieq(value, "tcm3105") || str_ieq(value, "1200")) {
+ return HYBBX_PACKET_RADIO_MOD_AFSK;
+ }
+
+ return HYBBX_PACKET_RADIO_MOD_UNSET;
+}
+
+static hybbx_tnc_serial_parity_t parse_serial_parity(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_TNC_SERIAL_PARITY_UNSET;
+ }
+
+ if (str_ieq(value, "even") || str_ieq(value, "e")) {
+ return HYBBX_TNC_SERIAL_PARITY_EVEN;
+ }
+
+ if (str_ieq(value, "odd") || str_ieq(value, "o")) {
+ return HYBBX_TNC_SERIAL_PARITY_ODD;
+ }
+
+ if (str_ieq(value, "none") || str_ieq(value, "n") || str_ieq(value, "off")) {
+ return HYBBX_TNC_SERIAL_PARITY_NONE;
+ }
+
+ return HYBBX_TNC_SERIAL_PARITY_UNSET;
+}
+
+static void apply_serial_line(hybbx_packet_radio_config_t *out, const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return;
+ }
+
+ if (str_ieq(value, "7e1")) {
+ out->params.data_bits = 7;
+ out->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_EVEN;
+ out->params.stop_bits = 1;
+ return;
+ }
+
+ if (str_ieq(value, "8n1")) {
+ out->params.data_bits = 8;
+ out->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_NONE;
+ out->params.stop_bits = 1;
+ return;
+ }
+
+ if (str_ieq(value, "7o1")) {
+ out->params.data_bits = 7;
+ out->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_ODD;
+ out->params.stop_bits = 1;
+ }
+}
+
+static hybbx_kiss_entry_t parse_kiss_entry(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_KISS_ENTRY_UNSET;
+ }
+
+ if (str_ieq(value, "none") || str_ieq(value, "skip") ||
+ str_ieq(value, "off") || str_ieq(value, "no")) {
+ return HYBBX_KISS_ENTRY_NONE;
+ }
+
+ if (str_ieq(value, "kiss_on") || str_ieq(value, "kiss-on") ||
+ str_ieq(value, "command") || str_ieq(value, "on") ||
+ str_ieq(value, "yes")) {
+ return HYBBX_KISS_ENTRY_KISS_ON;
+ }
+
+ if (str_ieq(value, "esc_at_k") || str_ieq(value, "esc@k") ||
+ str_ieq(value, "esc") || str_ieq(value, "@k")) {
+ return HYBBX_KISS_ENTRY_ESC_AT_K;
+ }
+
+ if (str_ieq(value, "auto")) {
+ return HYBBX_KISS_ENTRY_AUTO;
+ }
+
+ return HYBBX_KISS_ENTRY_UNSET;
+}
+
+static hybbx_kiss_exit_t parse_kiss_exit(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_KISS_EXIT_UNSET;
+ }
+
+ if (str_ieq(value, "none") || str_ieq(value, "skip") ||
+ str_ieq(value, "off") || str_ieq(value, "no")) {
+ return HYBBX_KISS_EXIT_NONE;
+ }
+
+ if (str_ieq(value, "kiss_off") || str_ieq(value, "kiss-off") ||
+ str_ieq(value, "command")) {
+ return HYBBX_KISS_EXIT_KISS_OFF;
+ }
+
+ if (str_ieq(value, "kiss_frame") || str_ieq(value, "frame") ||
+ str_ieq(value, "fend")) {
+ return HYBBX_KISS_EXIT_KISS_FRAME;
+ }
+
+ if (str_ieq(value, "auto")) {
+ return HYBBX_KISS_EXIT_AUTO;
+ }
+
+ return HYBBX_KISS_EXIT_UNSET;
+}
+
+static unsigned int default_radio_baud_for_modem(hybbx_tnc2c_modem_t modem)
+{
+ switch (modem) {
+ case HYBBX_TNC2C_MODEM_9600:
+ return 9600;
+ case HYBBX_TNC2C_MODEM_19200:
+ return 19200;
+ case HYBBX_TNC2C_MODEM_TCM3105:
+ default:
+ return HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+}
+
+static hybbx_result_t parse_via_list(hybbx_packet_radio_config_t *out,
+ const char *value)
+{
+ char scratch[256];
+ char *cursor;
+ char *token;
+
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ if (strlen(value) >= sizeof(scratch)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(scratch, value, strlen(value) + 1);
+ cursor = scratch;
+
+ while ((token = strtok(cursor, ",")) != NULL && out->via_count < HYBBX_PACKET_RADIO_VIA_MAX) {
+ char *item = hybbx_strdup(token);
+ if (item == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ out->via[out->via_count++] = item;
+ cursor = NULL;
+ }
+
+ return HYBBX_OK;
+}
+
+int hybbx_packet_radio_baud_valid(unsigned int baud)
+{
+ return baud > 0;
+}
+
+hybbx_result_t hybbx_packet_radio_config_parse(const char *config,
+ hybbx_packet_radio_config_t *out)
+{
+ char scratch[256];
+ const char *value;
+ unsigned long baud;
+ hybbx_result_t rc;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+
+ out->tnc = HYBBX_PACKET_RADIO_TNC_TNC2C;
+ out->protocol = 0; /* profile may choose */
+ out->params.clock_mhz = HYBBX_TNC2C_CLOCK_4_9_MHZ;
+ out->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ out->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ out->params.kiss_port = 0;
+ out->params.txdelay = 50;
+ out->params.persist = 0;
+ out->params.slot = 10;
+ out->params.txtail = 0;
+ out->params.band = HYBBX_PACKET_RADIO_BAND_UNSET;
+ out->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_UNSET;
+ out->params.modulation = HYBBX_PACKET_RADIO_MOD_UNSET;
+ out->params.kiss_entry = HYBBX_KISS_ENTRY_UNSET;
+ out->params.kiss_exit = HYBBX_KISS_EXIT_UNSET;
+ out->params.host_connect_on_start = 0;
+ out->params.assert_modem_lines = -1;
+
+ value = find_kv(config, "device_type", scratch, sizeof(scratch));
+ out->device_type = parse_device_type(value);
+
+ value = find_kv(config, "device", scratch, sizeof(scratch));
+ if (value == NULL) {
+ out->device = hybbx_strdup(default_device_for_type(out->device_type));
+ } else {
+ out->device = hybbx_strdup(value);
+ }
+
+ if (out->device == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ value = find_kv(config, "baud", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ out->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ } else {
+ baud = strtoul(value, NULL, 10);
+ if (!hybbx_packet_radio_baud_valid((unsigned int)baud)) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_INVALID;
+ }
+ out->baud = (unsigned int)baud;
+ }
+
+ value = find_kv(config, "tnc", scratch, sizeof(scratch));
+ out->tnc = parse_tnc(value);
+
+ if (out->tnc == HYBBX_PACKET_RADIO_TNC_BAYCOM ||
+ out->tnc == HYBBX_PACKET_RADIO_TNC_PCCOM) {
+ hybbx_log_warn("[packet_radio] tnc=%s is not a packet_radio profile — "
+ "use tnc=tnc2c (or generic) with kiss_entry=none after "
+ "max25d prep; BayCom/based SER12 = MAX25 max25e0 (bcpr), "
+ "optional [networks] baycom=yes on non-UN1TME sites only",
+ hybbx_packet_radio_tnc_name(out->tnc));
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_INVALID;
+ }
+
+ value = find_kv(config, "protocol", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->protocol = parse_protocol(value);
+ }
+
+ value = find_kv(config, "clock_mhz", scratch, sizeof(scratch));
+ out->params.clock_mhz = parse_clock_mhz(value);
+
+ value = find_kv(config, "modem", scratch, sizeof(scratch));
+ out->params.modem = parse_modem(value);
+
+ value = find_kv(config, "radio_baud", scratch, sizeof(scratch));
+ if (value == NULL) {
+ out->params.radio_baud = default_radio_baud_for_modem(out->params.modem);
+ } else {
+ unsigned int radio_baud = parse_uint(value, 0);
+ if (!hybbx_packet_radio_baud_valid(radio_baud)) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_INVALID;
+ }
+ out->params.radio_baud = radio_baud;
+ }
+
+ value = find_kv(config, "kiss_port", scratch, sizeof(scratch));
+ out->params.kiss_port = parse_uint(value, 0);
+
+ value = find_kv(config, "txdelay", scratch, sizeof(scratch));
+ out->params.txdelay = parse_uint(value, out->params.txdelay);
+
+ value = find_kv(config, "persist", scratch, sizeof(scratch));
+ out->params.persist = parse_uint(value, out->params.persist);
+
+ value = find_kv(config, "slot", scratch, sizeof(scratch));
+ out->params.slot = parse_uint(value, out->params.slot);
+
+ value = find_kv(config, "txtail", scratch, sizeof(scratch));
+ out->params.txtail = parse_uint(value, out->params.txtail);
+
+ value = find_kv(config, "radio_band", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->params.band = parse_radio_band(value);
+ }
+
+ value = find_kv(config, "radio_duplex", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "duplex", scratch, sizeof(scratch));
+ }
+ if (value != NULL) {
+ out->params.duplex = parse_radio_duplex(value);
+ }
+
+ value = find_kv(config, "fullduplex", scratch, sizeof(scratch));
+ if (value != NULL &&
+ out->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_UNSET) {
+ out->params.duplex = hybbx_parse_bool(value, 0) ?
+ HYBBX_PACKET_RADIO_DUPLEX_FULL :
+ HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ value = find_kv(config, "g3ruh_fsk", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->params.modulation = hybbx_parse_bool(value, 0) ?
+ HYBBX_PACKET_RADIO_MOD_G3RUH_FSK :
+ HYBBX_PACKET_RADIO_MOD_AFSK;
+ }
+
+ value = find_kv(config, "modulation", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_packet_radio_modulation_t mod = parse_modulation(value);
+ if (mod != HYBBX_PACKET_RADIO_MOD_UNSET) {
+ out->params.modulation = mod;
+ }
+ }
+
+ value = find_kv(config, "kiss_entry", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_kiss_entry_t kiss_entry = parse_kiss_entry(value);
+ if (kiss_entry != HYBBX_KISS_ENTRY_UNSET) {
+ out->params.kiss_entry = kiss_entry;
+ }
+ }
+
+ value = find_kv(config, "kiss_exit", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_kiss_exit_t kiss_exit = parse_kiss_exit(value);
+ if (kiss_exit != HYBBX_KISS_EXIT_UNSET) {
+ out->params.kiss_exit = kiss_exit;
+ }
+ }
+
+ value = find_kv(config, "host_connect", scratch, sizeof(scratch));
+ out->params.host_connect_on_start = hybbx_parse_bool(value,
+ out->params.host_connect_on_start);
+
+ value = find_kv(config, "serial_line", scratch, sizeof(scratch));
+ if (value != NULL) {
+ apply_serial_line(out, value);
+ }
+
+ value = find_kv(config, "data_bits", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ unsigned int data_bits = parse_uint(value, 0);
+ if (data_bits == 7 || data_bits == 8) {
+ out->params.data_bits = data_bits;
+ }
+ }
+
+ value = find_kv(config, "parity", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_tnc_serial_parity_t parity = parse_serial_parity(value);
+ if (parity != HYBBX_TNC_SERIAL_PARITY_UNSET) {
+ out->params.serial_parity = parity;
+ }
+ }
+
+ value = find_kv(config, "stop_bits", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ unsigned int stop_bits = parse_uint(value, 0);
+ if (stop_bits == 1 || stop_bits == 2) {
+ out->params.stop_bits = stop_bits;
+ }
+ }
+
+ value = find_kv(config, "assert_modem_lines", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "rts_dtr", scratch, sizeof(scratch));
+ }
+ if (value != NULL) {
+ out->params.assert_modem_lines = hybbx_parse_bool(value, 0) ? 1 : 0;
+ }
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->mycall = hybbx_strdup(value);
+ if (out->mycall == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "dest", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "dest_call", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->dest_call = hybbx_strdup(value);
+ if (out->dest_call == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "via", scratch, sizeof(scratch));
+ rc = parse_via_list(out, value);
+ if (rc != HYBBX_OK) {
+ hybbx_packet_radio_config_free(out);
+ return rc;
+ }
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->circuit_host = hybbx_strdup(value);
+ if (out->circuit_host == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "circuit_port", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->circuit_port = parse_uint(value, HYBBX_CIRCUIT_DEFAULT_PORT);
+ }
+
+ if (out->circuit_port == 0) {
+ out->circuit_port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+ if (out->circuit_host == NULL) {
+ out->circuit_host = hybbx_strdup("127.0.0.1");
+ if (out->circuit_host == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_id = hybbx_strdup(value);
+ if (out->link_id == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_password = hybbx_strdup(value);
+ if (out->link_password == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_role = hybbx_strdup(value);
+ if (out->link_role == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "frequency_mhz", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ value = find_kv(config, "frequency", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->frequency_mhz = hybbx_strdup(value);
+ if (out->frequency_mhz == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ (void)hybbx_tnc_profile_apply_defaults(out);
+ return HYBBX_OK;
+}
+
+void hybbx_packet_radio_config_free(hybbx_packet_radio_config_t *config)
+{
+ unsigned i;
+
+ if (config == NULL) {
+ return;
+ }
+
+ free(config->device);
+ free(config->mycall);
+ free(config->dest_call);
+ free(config->circuit_host);
+ free(config->link_id);
+ free(config->link_password);
+ free(config->link_role);
+ free(config->frequency_mhz);
+ for (i = 0; i < config->via_count; i++) {
+ free(config->via[i]);
+ config->via[i] = NULL;
+ }
+
+ memset(config, 0, sizeof(*config));
+}
diff --git a/plugins/packet_radio/serial_port.c b/plugins/packet_radio/serial_port.c
new file mode 100644
index 0000000..70dd92c
--- /dev/null
+++ b/plugins/packet_radio/serial_port.c
@@ -0,0 +1,705 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "serial_port.h"
+#include "hybbx/log.h"
+#include "hybbx/util.h"
+
+#include <stdlib.h>
+#include <string.h>
+#include <stdio.h>
+
+void hybbx_serial_params_default(hybbx_serial_params_t *params,
+ unsigned int baud)
+{
+ if (params == NULL) {
+ return;
+ }
+
+ memset(params, 0, sizeof(*params));
+ params->baud = baud;
+ params->data_bits = 8;
+ params->parity = HYBBX_SERIAL_PARITY_NONE;
+ params->stop_bits = 1;
+ params->assert_modem_lines = 0;
+}
+
+#if defined(_WIN32)
+
+#define WIN32_LEAN_AND_MEAN
+#include <windows.h>
+
+struct hybbx_serial_port {
+ HANDLE handle;
+ unsigned int baud;
+};
+
+static int win32_normalize_device(const char *device, char *out, size_t out_len)
+{
+ const char *name;
+ unsigned com_num = 0;
+
+ if (device == NULL || out_len == 0) {
+ return 0;
+ }
+
+ if (strncmp(device, "\\\\.\\", 4) == 0) {
+ return hybbx_strlcpy(out, device, out_len) < out_len;
+ }
+
+ name = device;
+ if (name[0] == '/' && strncmp(name, "/dev/ttyS", 9) == 0 &&
+ name[9] >= '0' && name[9] <= '9' && name[10] == '\0') {
+ com_num = (unsigned)(name[9] - '0') + 1u;
+ return (size_t)snprintf(out, out_len, "\\\\.\\COM%u", com_num) < out_len;
+ }
+
+ if ((name[0] == 'C' || name[0] == 'c') &&
+ (name[1] == 'O' || name[1] == 'o') &&
+ (name[2] == 'M' || name[2] == 'm')) {
+ name += 3;
+ }
+
+ if (*name == '\0') {
+ return 0;
+ }
+
+ while (*name >= '0' && *name <= '9') {
+ com_num = com_num * 10u + (unsigned)(*name - '0');
+ name++;
+ }
+ if (*name != '\0' || com_num == 0) {
+ return 0;
+ }
+
+ return (size_t)snprintf(out, out_len, "\\\\.\\COM%u", com_num) < out_len;
+}
+
+static hybbx_result_t win32_configure(HANDLE handle,
+ const hybbx_serial_params_t *params)
+{
+ DCB dcb;
+ COMMTIMEOUTS timeouts;
+
+ memset(&dcb, 0, sizeof(dcb));
+ dcb.DCBlength = sizeof(dcb);
+ if (!GetCommState(handle, &dcb)) {
+ return HYBBX_ERR_IO;
+ }
+
+ dcb.BaudRate = (DWORD)params->baud;
+ dcb.ByteSize = (BYTE)(params->data_bits == 7 ? 7 : 8);
+ switch (params->parity) {
+ case HYBBX_SERIAL_PARITY_EVEN:
+ dcb.Parity = EVENPARITY;
+ dcb.fParity = TRUE;
+ break;
+ case HYBBX_SERIAL_PARITY_ODD:
+ dcb.Parity = ODDPARITY;
+ dcb.fParity = TRUE;
+ break;
+ case HYBBX_SERIAL_PARITY_NONE:
+ default:
+ dcb.Parity = NOPARITY;
+ dcb.fParity = FALSE;
+ break;
+ }
+ dcb.StopBits = params->stop_bits == 2 ? TWOSTOPBITS : ONESTOPBIT;
+ dcb.fBinary = TRUE;
+ dcb.fOutxCtsFlow = FALSE;
+ dcb.fOutxDsrFlow = FALSE;
+ dcb.fDtrControl = params->assert_modem_lines ?
+ DTR_CONTROL_ENABLE : DTR_CONTROL_DISABLE;
+ dcb.fRtsControl = params->assert_modem_lines ?
+ RTS_CONTROL_ENABLE : RTS_CONTROL_DISABLE;
+ dcb.fOutX = FALSE;
+ dcb.fInX = FALSE;
+ dcb.fNull = FALSE;
+
+ if (!SetCommState(handle, &dcb)) {
+ return HYBBX_ERR_IO;
+ }
+
+ memset(&timeouts, 0, sizeof(timeouts));
+ timeouts.ReadIntervalTimeout = MAXDWORD;
+ timeouts.ReadTotalTimeoutConstant = 0;
+ timeouts.ReadTotalTimeoutMultiplier = 0;
+ timeouts.WriteTotalTimeoutConstant = 0;
+ timeouts.WriteTotalTimeoutMultiplier = 0;
+ if (!SetCommTimeouts(handle, &timeouts)) {
+ return HYBBX_ERR_IO;
+ }
+
+ PurgeComm(handle, PURGE_RXCLEAR | PURGE_TXCLEAR);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params)
+{
+ hybbx_serial_port_t *port;
+ char path[64];
+ hybbx_result_t rc;
+
+ if (out == NULL || device == NULL || params == NULL || params->baud == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!win32_normalize_device(device, path, sizeof(path))) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port = calloc(1, sizeof(*port));
+ if (port == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->handle = CreateFileA(path, GENERIC_READ | GENERIC_WRITE, 0, NULL,
+ OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
+ if (port->handle == INVALID_HANDLE_VALUE) {
+ free(port);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = win32_configure(port->handle, params);
+ if (rc != HYBBX_OK) {
+ CloseHandle(port->handle);
+ free(port);
+ return rc;
+ }
+
+ port->baud = params->baud;
+ *out = port;
+ return HYBBX_OK;
+}
+
+void hybbx_serial_close(hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return;
+ }
+
+ if (port->handle != INVALID_HANDLE_VALUE) {
+ CloseHandle(port->handle);
+ }
+ free(port);
+}
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len)
+{
+ size_t written = 0;
+
+ if (port == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (written < len) {
+ DWORD chunk = 0;
+
+ if (!WriteFile(port->handle, data + written,
+ (DWORD)(len - written), &chunk, NULL)) {
+ return HYBBX_ERR_IO;
+ }
+ if (chunk == 0) {
+ return HYBBX_ERR_IO;
+ }
+ written += (size_t)chunk;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ DWORD n = 0;
+
+ if (port == NULL || buf == NULL || read_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!ReadFile(port->handle, buf, (DWORD)buf_len, &n, NULL)) {
+ return HYBBX_ERR_IO;
+ }
+
+ *read_len = (size_t)n;
+ return HYBBX_OK;
+}
+
+int hybbx_serial_fd(const hybbx_serial_port_t *port)
+{
+ (void)port;
+ return -1;
+}
+
+#elif defined(__AMIGA__)
+
+#include <proto/exec.h>
+#include <devices/serial.h>
+
+struct hybbx_serial_port {
+ struct IOExtSer *io;
+ struct MsgPort *port;
+ char device_name[32];
+ unsigned long unit;
+ unsigned int baud;
+};
+
+static int amiga_parse_device(const char *device, char *name, size_t name_len,
+ unsigned long *unit)
+{
+ const char *colon;
+ char *end;
+
+ if (unit == NULL || name == NULL || name_len == 0) {
+ return 0;
+ }
+
+ *unit = 0;
+ if (device == NULL || device[0] == '\0') {
+ hybbx_strlcpy(name, SERIALNAME, name_len);
+ return 1;
+ }
+
+ colon = strchr(device, ':');
+ if (colon != NULL) {
+ size_t prefix = (size_t)(colon - device);
+
+ if (prefix == 0 || prefix >= name_len) {
+ return 0;
+ }
+ memcpy(name, device, prefix);
+ name[prefix] = '\0';
+ *unit = strtoul(colon + 1, &end, 10);
+ return end != colon + 1 && *end == '\0';
+ }
+
+ if (device[0] >= '0' && device[0] <= '9') {
+ *unit = strtoul(device, &end, 10);
+ if (end != device && *end == '\0') {
+ hybbx_strlcpy(name, SERIALNAME, name_len);
+ return 1;
+ }
+ }
+
+ hybbx_strlcpy(name, device, name_len);
+ return 1;
+}
+
+static hybbx_result_t amiga_configure(struct IOExtSer *io,
+ const hybbx_serial_params_t *params)
+{
+ io->IOSer.io_Command = SDCMD_SETPARAMS;
+ io->io_Baud = (ULONG)params->baud;
+ io->io_ReadLen = (UBYTE)(params->data_bits == 7 ? 7 : 8);
+ io->io_WriteLen = (UBYTE)(params->data_bits == 7 ? 7 : 8);
+ io->io_StopBits = (UBYTE)(params->stop_bits == 2 ? 2 : 1);
+ io->io_SerFlags = (UBYTE)(io->io_SerFlags & (UBYTE)~SERF_PARTY_ON);
+ if (params->parity == HYBBX_SERIAL_PARITY_EVEN ||
+ params->parity == HYBBX_SERIAL_PARITY_ODD) {
+ io->io_SerFlags |= SERF_PARTY_ON;
+ }
+ io->io_SerFlags |= SERF_XDISABLED;
+
+ if (DoIO((struct IORequest *)io) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params)
+{
+ hybbx_serial_port_t *port;
+ hybbx_result_t rc;
+
+ if (out == NULL || device == NULL || params == NULL || params->baud == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port = calloc(1, sizeof(*port));
+ if (port == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ if (!amiga_parse_device(device, port->device_name, sizeof(port->device_name),
+ &port->unit)) {
+ free(port);
+ return HYBBX_ERR_INVALID;
+ }
+
+ port->port = CreatePort(NULL, 0);
+ if (port->port == NULL) {
+ free(port);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->io = (struct IOExtSer *)CreateExtIO(port->port,
+ (LONG)sizeof(*port->io));
+ if (port->io == NULL) {
+ DeletePort(port->port);
+ free(port);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->io->io_SerFlags = SERF_SHARED | SERF_XDISABLED;
+ if (OpenDevice(port->device_name, port->unit,
+ (struct IORequest *)port->io, 0) != 0) {
+ DeleteExtIO((struct IORequest *)port->io);
+ DeletePort(port->port);
+ free(port);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = amiga_configure(port->io, params);
+ if (rc != HYBBX_OK) {
+ CloseDevice((struct IORequest *)port->io);
+ DeleteExtIO((struct IORequest *)port->io);
+ DeletePort(port->port);
+ free(port);
+ return rc;
+ }
+
+ port->baud = params->baud;
+ *out = port;
+ return HYBBX_OK;
+}
+
+void hybbx_serial_close(hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return;
+ }
+
+ if (port->io != NULL) {
+ CloseDevice((struct IORequest *)port->io);
+ DeleteExtIO((struct IORequest *)port->io);
+ }
+ if (port->port != NULL) {
+ DeletePort(port->port);
+ }
+ free(port);
+}
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len)
+{
+ if (port == NULL || data == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port->io->IOSer.io_Command = CMD_WRITE;
+ port->io->IOSer.io_Data = (APTR)data;
+ port->io->IOSer.io_Length = (ULONG)len;
+ port->io->IOSer.io_Flags = 0;
+
+ if (DoIO((struct IORequest *)port->io) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ if (port == NULL || buf == NULL || read_len == NULL || buf_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port->io->IOSer.io_Command = CMD_READ;
+ port->io->IOSer.io_Data = (APTR)buf;
+ port->io->IOSer.io_Length = (ULONG)buf_len;
+ port->io->IOSer.io_Flags = IOF_QUICK;
+
+ if (DoIO((struct IORequest *)port->io) != 0) {
+ *read_len = 0;
+ return HYBBX_OK;
+ }
+
+ *read_len = (size_t)port->io->IOSer.io_Actual;
+ return HYBBX_OK;
+}
+
+int hybbx_serial_fd(const hybbx_serial_port_t *port)
+{
+ (void)port;
+ return -1;
+}
+
+#else /* POSIX */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+
+struct hybbx_serial_port {
+ int fd;
+ unsigned int baud;
+ int assert_modem_lines;
+};
+
+#ifndef cfmakeraw
+static void hybbx_cfmakeraw(struct termios *tty)
+{
+ tty->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR |
+ IGNCR | ICRNL | IXON);
+ tty->c_oflag &= ~OPOST;
+ tty->c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);
+ tty->c_cflag &= ~(CSIZE | PARENB);
+ tty->c_cflag |= CS8;
+}
+#define cfmakeraw hybbx_cfmakeraw
+#endif
+
+static hybbx_result_t set_baud_rate(struct termios *tty, unsigned int baud)
+{
+#if defined(__linux__) || defined(__GLIBC__)
+ if (cfsetspeed(tty, (speed_t)baud) == 0) {
+ return HYBBX_OK;
+ }
+#endif
+
+ {
+ speed_t speed = B0;
+ switch (baud) {
+ case 50: speed = B50; break;
+ case 75: speed = B75; break;
+ case 110: speed = B110; break;
+ case 134: speed = B134; break;
+ case 150: speed = B150; break;
+ case 200: speed = B200; break;
+ case 300: speed = B300; break;
+ case 600: speed = B600; break;
+ case 1200: speed = B1200; break;
+ case 1800: speed = B1800; break;
+ case 2400: speed = B2400; break;
+ case 4800: speed = B4800; break;
+ case 9600: speed = B9600; break;
+#ifdef B19200
+ case 19200: speed = B19200; break;
+#endif
+#ifdef B38400
+ case 38400: speed = B38400; break;
+#endif
+#ifdef B57600
+ case 57600: speed = B57600; break;
+#endif
+#ifdef B115200
+ case 115200: speed = B115200; break;
+#endif
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ cfsetispeed(tty, speed);
+ cfsetospeed(tty, speed);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t posix_assert_modem_lines(int fd, int on)
+{
+#ifdef TIOCMGET
+ int flags;
+
+ /* BayCom KISS PTY (max25-bcpr): assert_modem_lines=0 — no UART modem bits.
+ * TIOCMGET on /dev/pts/* often fails; skip when not asserting. */
+ if (!on) {
+ return HYBBX_OK;
+ }
+
+ if (ioctl(fd, TIOCMGET, &flags) != 0) {
+ return HYBBX_ERR_IO;
+ }
+ flags |= TIOCM_RTS | TIOCM_DTR;
+ if (ioctl(fd, TIOCMSET, &flags) != 0) {
+ return HYBBX_ERR_IO;
+ }
+#else
+ (void)fd;
+ (void)on;
+#endif
+ return HYBBX_OK;
+}
+
+static hybbx_result_t configure_port(int fd, const hybbx_serial_params_t *params)
+{
+ struct termios tty;
+ tcflag_t databits;
+
+ if (tcgetattr(fd, &tty) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ cfmakeraw(&tty);
+ tty.c_cflag |= (CLOCAL | CREAD);
+ tty.c_cflag &= ~(CSIZE | PARENB | PARODD | CSTOPB);
+ if (params->data_bits == 7) {
+ databits = CS7;
+ } else {
+ databits = CS8;
+ }
+ tty.c_cflag |= databits;
+ if (params->parity == HYBBX_SERIAL_PARITY_EVEN) {
+ tty.c_cflag |= PARENB;
+ } else if (params->parity == HYBBX_SERIAL_PARITY_ODD) {
+ tty.c_cflag |= PARENB | PARODD;
+ }
+ if (params->stop_bits == 2) {
+ tty.c_cflag |= CSTOPB;
+ }
+ tty.c_cc[VMIN] = 0;
+ tty.c_cc[VTIME] = 1;
+
+ if (set_baud_rate(&tty, params->baud) != HYBBX_OK) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (tcsetattr(fd, TCSANOW, &tty) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ tcflush(fd, TCIOFLUSH);
+ return posix_assert_modem_lines(fd, params->assert_modem_lines);
+}
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params)
+{
+ hybbx_serial_port_t *port;
+ hybbx_result_t rc;
+
+ if (out == NULL || device == NULL || params == NULL || params->baud == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port = calloc(1, sizeof(*port));
+ if (port == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->fd = open(device, O_RDWR | O_NOCTTY | O_NONBLOCK);
+ if (port->fd < 0) {
+ hybbx_log_warn("[serial] open %s failed: %s", device, strerror(errno));
+ free(port);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = configure_port(port->fd, params);
+ if (rc != HYBBX_OK) {
+ close(port->fd);
+ free(port);
+ return rc;
+ }
+
+ port->baud = params->baud;
+ port->assert_modem_lines = params->assert_modem_lines;
+ *out = port;
+ return HYBBX_OK;
+}
+
+void hybbx_serial_close(hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return;
+ }
+
+ if (port->fd >= 0) {
+ close(port->fd);
+ }
+ free(port);
+}
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len)
+{
+ size_t written = 0;
+
+ if (port == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (port->assert_modem_lines) {
+ hybbx_result_t rc = posix_assert_modem_lines(port->fd, 1);
+
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ while (written < len) {
+ ssize_t n = write(port->fd, data + written, len - written);
+
+ if (n < 0) {
+#if EAGAIN == EWOULDBLOCK
+ if (errno == EAGAIN) {
+#else
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+#endif
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+ written += (size_t)n;
+ }
+
+ if (tcdrain(port->fd) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ ssize_t n;
+
+ if (port == NULL || buf == NULL || read_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ n = read(port->fd, buf, buf_len);
+ if (n < 0) {
+#if EAGAIN == EWOULDBLOCK
+ if (errno == EAGAIN) {
+#else
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+#endif
+ *read_len = 0;
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+ }
+
+ *read_len = (size_t)n;
+ return HYBBX_OK;
+}
+
+int hybbx_serial_fd(const hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return -1;
+ }
+
+ return port->fd;
+}
+
+#endif
diff --git a/plugins/packet_radio/serial_port.h b/plugins/packet_radio/serial_port.h
new file mode 100644
index 0000000..549d3c5
--- /dev/null
+++ b/plugins/packet_radio/serial_port.h
@@ -0,0 +1,52 @@
+#ifndef HYBBX_SERIAL_PORT_H
+#define HYBBX_SERIAL_PORT_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_serial_port hybbx_serial_port_t;
+
+typedef enum hybbx_serial_parity {
+ HYBBX_SERIAL_PARITY_NONE = 0,
+ HYBBX_SERIAL_PARITY_EVEN = 1,
+ HYBBX_SERIAL_PARITY_ODD = 2
+} hybbx_serial_parity_t;
+
+typedef struct hybbx_serial_params {
+ unsigned int baud;
+ unsigned int data_bits;
+ hybbx_serial_parity_t parity;
+ unsigned int stop_bits;
+ int assert_modem_lines;
+} hybbx_serial_params_t;
+
+void hybbx_serial_params_default(hybbx_serial_params_t *params,
+ unsigned int baud);
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params);
+
+void hybbx_serial_close(hybbx_serial_port_t *port);
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len);
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len);
+
+/** POSIX serial fd for poll/select; returns -1 when unavailable. */
+int hybbx_serial_fd(const hybbx_serial_port_t *port);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SERIAL_PORT_H */
diff --git a/plugins/packet_radio/sixpack.c b/plugins/packet_radio/sixpack.c
new file mode 100644
index 0000000..28c0510
--- /dev/null
+++ b/plugins/packet_radio/sixpack.c
@@ -0,0 +1,146 @@
+#include "sixpack.h"
+
+#include <string.h>
+
+void hybbx_sixpack_decoder_init(hybbx_sixpack_decoder_t *dec)
+{
+ if (dec == NULL) {
+ return;
+ }
+
+ memset(dec, 0, sizeof(*dec));
+}
+
+static void sixpack_reset(hybbx_sixpack_decoder_t *dec)
+{
+ dec->len = 0;
+ dec->count = 0;
+ dec->checksum = 0;
+ dec->in_frame = 0;
+}
+
+static void sixpack_deliver(hybbx_sixpack_decoder_t *dec,
+ hybbx_sixpack_frame_cb cb, void *userdata)
+{
+ if (cb != NULL && dec->len > 0) {
+ cb(dec->buf, dec->len, userdata);
+ }
+
+ sixpack_reset(dec);
+}
+
+void hybbx_sixpack_decoder_feed(hybbx_sixpack_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_sixpack_frame_cb cb, void *userdata)
+{
+ size_t i;
+
+ if (dec == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ uint8_t raw = data[i];
+ uint8_t byte = raw;
+
+ if (!dec->in_frame) {
+ if (raw == HYBBX_SIXPACK_LEAD) {
+ dec->in_frame = 1;
+ dec->len = 0;
+ dec->count = 0;
+ dec->checksum = 0;
+ }
+ continue;
+ }
+
+ if (dec->count == 0) {
+ dec->count = raw;
+ dec->checksum = raw;
+ continue;
+ }
+
+ dec->count--;
+ dec->checksum = (uint8_t)(dec->checksum + raw);
+
+ if (raw < 0x30u) {
+ byte = (uint8_t)(raw + 0x40u);
+ }
+
+ if (dec->count > 0) {
+ if (dec->len >= HYBBX_KISS_MAX_FRAME) {
+ sixpack_reset(dec);
+ continue;
+ }
+ dec->buf[dec->len++] = byte;
+ continue;
+ }
+
+ if (dec->checksum == 0) {
+ sixpack_deliver(dec, cb, userdata);
+ } else {
+ sixpack_reset(dec);
+ }
+ }
+}
+
+static size_t sixpack_write_encoded(uint8_t byte, uint8_t *out, size_t out_cap,
+ size_t pos)
+{
+ if (byte < 0x30u) {
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+ out[pos++] = (uint8_t)(byte + 0x40u);
+ return pos;
+ }
+
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = byte;
+ return pos;
+}
+
+size_t hybbx_sixpack_encode(const uint8_t *frame, size_t frame_len,
+ uint8_t *out, size_t out_cap)
+{
+ uint8_t scratch[HYBBX_KISS_MAX_FRAME + 4];
+ size_t enc_len = 0;
+ size_t i;
+ uint8_t count;
+ uint8_t sum;
+ size_t pos = 0;
+
+ if (frame == NULL || out == NULL || frame_len == 0 ||
+ frame_len > HYBBX_KISS_MAX_FRAME) {
+ return 0;
+ }
+
+ for (i = 0; i < frame_len; i++) {
+ enc_len = sixpack_write_encoded(frame[i], scratch, sizeof(scratch),
+ enc_len);
+ if (enc_len == 0) {
+ return 0;
+ }
+ }
+
+ if (enc_len + 4 > out_cap || enc_len + 1 > 255) {
+ return 0;
+ }
+
+ count = (uint8_t)(enc_len + 1);
+ sum = count;
+ for (i = 0; i < enc_len; i++) {
+ sum = (uint8_t)(sum + scratch[i]);
+ }
+
+ out[pos++] = HYBBX_SIXPACK_LEAD;
+ out[pos++] = count;
+ for (i = 0; i < enc_len; i++) {
+ out[pos++] = scratch[i];
+ }
+ out[pos++] = (uint8_t)(0x100u - (unsigned)sum);
+
+ return pos;
+}
diff --git a/plugins/packet_radio/sixpack.h b/plugins/packet_radio/sixpack.h
new file mode 100644
index 0000000..0d60e87
--- /dev/null
+++ b/plugins/packet_radio/sixpack.h
@@ -0,0 +1,40 @@
+#ifndef HYBBX_SIXPACK_H
+#define HYBBX_SIXPACK_H
+
+#include "hybbx/kiss.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_SIXPACK_LEAD 0x53u /* 'S' */
+
+typedef void (*hybbx_sixpack_frame_cb)(const uint8_t *frame, size_t len,
+ void *userdata);
+
+typedef struct hybbx_sixpack_decoder {
+ uint8_t buf[HYBBX_KISS_MAX_FRAME];
+ size_t len;
+ int in_frame;
+ uint8_t count;
+ uint8_t checksum;
+} hybbx_sixpack_decoder_t;
+
+void hybbx_sixpack_decoder_init(hybbx_sixpack_decoder_t *dec);
+
+void hybbx_sixpack_decoder_feed(hybbx_sixpack_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_sixpack_frame_cb cb, void *userdata);
+
+size_t hybbx_sixpack_encode(const uint8_t *frame, size_t frame_len,
+ uint8_t *out, size_t out_cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SIXPACK_H */
diff --git a/plugins/packet_radio/tnc.c b/plugins/packet_radio/tnc.c
new file mode 100644
index 0000000..a42480d
--- /dev/null
+++ b/plugins/packet_radio/tnc.c
@@ -0,0 +1,1080 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "hybbx/tnc.h"
+#include "hybbx/kiss.h"
+#include "hybbx/rf_tx_pace.h"
+#include "hybbx/traffic.h"
+#include "serial_port.h"
+#include "sixpack.h"
+#include "tnc_host.h"
+
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#if !defined(_WIN32)
+#include <sys/select.h>
+#endif
+
+struct hybbx_tnc {
+ hybbx_packet_radio_config_t config;
+ hybbx_serial_port_t *serial;
+ hybbx_kiss_decoder_t kiss_dec;
+ hybbx_sixpack_decoder_t sixpack_dec;
+ hybbx_tnc_host_t host;
+ hybbx_ax25_path_t tx_path;
+ int kiss_active;
+ hybbx_kiss_entry_t kiss_entry_used;
+ int host_ready;
+ hybbx_tnc_frame_cb frame_cb;
+ hybbx_tnc_ui_cb ui_cb;
+ void *rx_userdata;
+ unsigned long long last_rx_ms;
+};
+
+static int tnc_profile_thefirmware(hybbx_packet_radio_tnc_t tnc);
+
+static void config_copy(hybbx_packet_radio_config_t *dst,
+ const hybbx_packet_radio_config_t *src)
+{
+ unsigned i;
+
+ memset(dst, 0, sizeof(*dst));
+ dst->device_type = src->device_type;
+ dst->device = src->device;
+ dst->baud = src->baud;
+ dst->tnc = src->tnc;
+ dst->protocol = src->protocol;
+ dst->params = src->params;
+ dst->mycall = src->mycall;
+ dst->dest_call = src->dest_call;
+ dst->via_count = src->via_count;
+ for (i = 0; i < src->via_count; i++) {
+ dst->via[i] = src->via[i];
+ }
+}
+
+static unsigned long long tnc_monotonic_ms(void)
+{
+#if defined(CLOCK_MONOTONIC)
+ struct timespec ts;
+
+ if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) {
+ return (unsigned long long)ts.tv_sec * 1000ull +
+ (unsigned long long)ts.tv_nsec / 1000000ull;
+ }
+#endif
+ return (unsigned long long)time(NULL) * 1000ull;
+}
+
+static void tnc_note_rx(hybbx_tnc_t *tnc)
+{
+ if (tnc != NULL) {
+ tnc->last_rx_ms = tnc_monotonic_ms();
+ }
+}
+
+static hybbx_result_t tnc_wait_half_duplex_clear(hybbx_tnc_t *tnc)
+{
+ unsigned guard_ms;
+ unsigned long long now;
+ unsigned long long elapsed;
+
+ if (tnc == NULL || hybbx_tnc_duplex_is_full(tnc)) {
+ return HYBBX_OK;
+ }
+
+ guard_ms = tnc->config.params.slot * 10u;
+ if (guard_ms < 50u) {
+ guard_ms = 50u;
+ }
+
+ now = tnc_monotonic_ms();
+ if (tnc->last_rx_ms == 0 || now <= tnc->last_rx_ms) {
+ return HYBBX_OK;
+ }
+
+ elapsed = now - tnc->last_rx_ms;
+ if (elapsed >= guard_ms) {
+ return HYBBX_OK;
+ }
+
+#if defined(_WIN32)
+ Sleep((DWORD)(guard_ms - elapsed));
+#else
+ {
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)((guard_ms - elapsed) / 1000u);
+ tv.tv_usec = (suseconds_t)(((guard_ms - elapsed) % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+ }
+#endif
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t send_raw(hybbx_tnc_t *tnc, const uint8_t *data, size_t len)
+{
+ return hybbx_serial_write(tnc->serial, data, len);
+}
+
+static hybbx_result_t send_cmd(hybbx_tnc_t *tnc, const char *cmd)
+{
+ char line[128];
+ size_t len;
+
+ len = hybbx_tnc_host_format_cmd(cmd, line, sizeof(line));
+ if (len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return send_raw(tnc, (const uint8_t *)line, len);
+}
+
+static hybbx_result_t drain_serial(hybbx_tnc_t *tnc, unsigned rounds)
+{
+ uint8_t buf[128];
+ size_t read_len;
+ unsigned i;
+
+ for (i = 0; i < rounds; i++) {
+ if (hybbx_serial_read(tnc->serial, buf, sizeof(buf),
+ &read_len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (read_len == 0) {
+ break;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t send_kiss_param(hybbx_tnc_t *tnc,
+ hybbx_kiss_command_t cmd,
+ uint8_t value)
+{
+ uint8_t frame[16];
+ size_t len;
+
+ len = hybbx_kiss_encode((uint8_t)tnc->config.params.kiss_port, cmd,
+ &value, 1, frame, sizeof(frame));
+ if (len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return send_raw(tnc, frame, len);
+}
+
+static hybbx_result_t tnc_apply_kiss_params(hybbx_tnc_t *tnc)
+{
+ hybbx_result_t rc;
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_TXDELAY,
+ (uint8_t)tnc->config.params.txdelay);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_PERSIST,
+ (uint8_t)tnc->config.params.persist);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_SLOTTIME,
+ (uint8_t)tnc->config.params.slot);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_TXTAIL,
+ (uint8_t)tnc->config.params.txtail);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return send_kiss_param(tnc, HYBBX_KISS_CMD_FULLDUPLEX,
+ (uint8_t)(tnc->config.params.fullduplex ? 1 : 0));
+}
+
+static hybbx_result_t tnc_send_esc_at_k(hybbx_tnc_t *tnc)
+{
+ const uint8_t seq[] = { 0x1b, '@', 'K' };
+
+ return send_raw(tnc, seq, sizeof(seq));
+}
+
+static hybbx_result_t tnc_send_kiss_return_frame(hybbx_tnc_t *tnc)
+{
+ const uint8_t frame[] = { HYBBX_KISS_FEND, 0xFFu, HYBBX_KISS_FEND };
+
+ return send_raw(tnc, frame, sizeof(frame));
+}
+
+static void tnc_serial_pause_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timespec ts;
+
+ ts.tv_sec = (time_t)(ms / 1000u);
+ ts.tv_nsec = (long)((ms % 1000u) * 1000000u);
+ (void)nanosleep(&ts, NULL);
+#endif
+}
+
+static hybbx_result_t tnc_send_jhost0(hybbx_tnc_t *tnc)
+{
+ static const uint8_t nuls[300] = { 0 };
+ static const uint8_t jhost0[] = {
+ 0x00, 0x01, 0x06, 'J', 'H', 'O', 'S', 'T', ' ', '0', '\r'
+ };
+ hybbx_result_t rc;
+
+ rc = send_raw(tnc, nuls, sizeof(nuls));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ tnc_serial_pause_ms(150);
+ return send_raw(tnc, jhost0, sizeof(jhost0));
+}
+
+static hybbx_result_t tnc_send_restart_escape(hybbx_tnc_t *tnc)
+{
+ unsigned i;
+
+ for (i = 0; i < 3u; i++) {
+ if (send_raw(tnc, (const uint8_t *)"\x03", 1) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ tnc_serial_pause_ms(80);
+ }
+ tnc_serial_pause_ms(300);
+ return send_cmd(tnc, "RESTART");
+}
+
+/** Leave KISS/host/transparency → terminal (TheFirmware ladder). */
+static hybbx_result_t tnc_recover_terminal_mode(hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ tnc->kiss_active = 0;
+ (void)tnc_send_kiss_return_frame(tnc);
+ tnc_serial_pause_ms(400);
+ drain_serial(tnc, 8);
+
+ (void)tnc_send_jhost0(tnc);
+ tnc_serial_pause_ms(800);
+ drain_serial(tnc, 8);
+
+ (void)send_cmd(tnc, "kiss off");
+ tnc_serial_pause_ms(400);
+ drain_serial(tnc, 6);
+ (void)send_cmd(tnc, "INFO");
+ drain_serial(tnc, 12);
+
+ (void)tnc_send_restart_escape(tnc);
+ tnc_serial_pause_ms(1200);
+ drain_serial(tnc, 8);
+ (void)send_cmd(tnc, "kiss off");
+ tnc_serial_pause_ms(300);
+ drain_serial(tnc, 4);
+
+ return HYBBX_OK;
+}
+
+/** Leave KISS (crash/kill) so host commands (MYCALL, kiss on) work again. */
+static hybbx_result_t tnc_kiss_wakeup_host(hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return tnc_recover_terminal_mode(tnc);
+}
+
+static hybbx_result_t tnc_enter_kiss_by_entry(hybbx_tnc_t *tnc,
+ hybbx_kiss_entry_t entry)
+{
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (entry) {
+ case HYBBX_KISS_ENTRY_NONE:
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_NONE;
+ return HYBBX_OK;
+
+ case HYBBX_KISS_ENTRY_ESC_AT_K:
+ if (tnc_send_esc_at_k(tnc) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_ESC_AT_K;
+ return HYBBX_OK;
+
+ case HYBBX_KISS_ENTRY_KISS_ON:
+ if (send_cmd(tnc, "kiss on") != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_KISS_ON;
+ return HYBBX_OK;
+
+ case HYBBX_KISS_ENTRY_AUTO:
+ if (send_cmd(tnc, "kiss on") == HYBBX_OK) {
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_KISS_ON;
+ return HYBBX_OK;
+ }
+ if (tnc_send_esc_at_k(tnc) == HYBBX_OK) {
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_ESC_AT_K;
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+
+ case HYBBX_KISS_ENTRY_UNSET:
+ default:
+ return HYBBX_ERR_INVALID;
+ }
+}
+
+static void tnc_exit_kiss_mode(hybbx_tnc_t *tnc)
+{
+ hybbx_kiss_exit_t exit_mode;
+
+ if (tnc == NULL || !tnc->kiss_active ||
+ tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_KISS) {
+ return;
+ }
+
+ exit_mode = tnc->config.params.kiss_exit;
+ if (exit_mode == HYBBX_KISS_EXIT_UNSET) {
+ exit_mode = HYBBX_KISS_EXIT_AUTO;
+ }
+ if (exit_mode == HYBBX_KISS_EXIT_AUTO) {
+ if (tnc->kiss_entry_used == HYBBX_KISS_ENTRY_ESC_AT_K) {
+ exit_mode = HYBBX_KISS_EXIT_KISS_FRAME;
+ } else {
+ exit_mode = HYBBX_KISS_EXIT_KISS_OFF;
+ }
+ }
+
+ switch (exit_mode) {
+ case HYBBX_KISS_EXIT_KISS_FRAME:
+ (void)tnc_send_kiss_return_frame(tnc);
+ break;
+ case HYBBX_KISS_EXIT_KISS_OFF:
+ (void)send_cmd(tnc, "kiss off");
+ break;
+ case HYBBX_KISS_EXIT_NONE:
+ case HYBBX_KISS_EXIT_UNSET:
+ default:
+ break;
+ }
+}
+
+static hybbx_result_t tnc_enter_kiss_mode(hybbx_tnc_t *tnc)
+{
+ return tnc_enter_kiss_by_entry(tnc, tnc->config.params.kiss_entry);
+}
+
+static void tnc_deliver_ui(hybbx_tnc_t *tnc, const uint8_t *frame, size_t len)
+{
+ uint8_t payload[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t payload_len;
+
+ payload_len = hybbx_ax25_parse_ui(frame, len, &path, payload,
+ sizeof(payload));
+ if (payload_len == 0) {
+ return;
+ }
+
+ if (tnc->ui_cb != NULL) {
+ tnc->ui_cb(payload, payload_len, &path, tnc->rx_userdata);
+ }
+
+ tnc_note_rx(tnc);
+}
+
+static void on_kiss_frame(uint8_t port, const uint8_t *frame, size_t len,
+ void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+ uint8_t payload[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t payload_len;
+
+ (void)port;
+
+ if (tnc->frame_cb != NULL) {
+ tnc->frame_cb(frame, len, tnc->rx_userdata);
+ }
+
+ if (tnc->ui_cb == NULL) {
+ return;
+ }
+
+ payload_len = hybbx_ax25_parse_ui(frame, len, &path, payload,
+ sizeof(payload));
+ if (payload_len > 0) {
+ tnc->ui_cb(payload, payload_len, &path, tnc->rx_userdata);
+ }
+}
+
+static void on_sixpack_frame(const uint8_t *frame, size_t len, void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+
+ if (tnc->frame_cb != NULL) {
+ tnc->frame_cb(frame, len, tnc->rx_userdata);
+ }
+
+ if (tnc->ui_cb != NULL) {
+ tnc_deliver_ui(tnc, frame, len);
+ }
+}
+
+static void on_host_text(const uint8_t *data, size_t len, void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+
+ if (tnc->ui_cb != NULL) {
+ tnc->ui_cb(data, len, NULL, tnc->rx_userdata);
+ }
+
+ tnc_note_rx(tnc);
+}
+
+static void on_host_event(hybbx_tnc_host_state_t state, void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+
+ if (state == HYBBX_TNC_HOST_CMD) {
+ tnc->host_ready = 1;
+ }
+
+ hybbx_log_debug("[tnc] host state -> %d", (int)state);
+}
+
+static hybbx_result_t tnc_build_tx_path(hybbx_tnc_t *tnc)
+{
+ unsigned i;
+ hybbx_result_t rc;
+
+ memset(&tnc->tx_path, 0, sizeof(tnc->tx_path));
+
+ if (tnc->config.dest_call != NULL && tnc->config.dest_call[0] != '\0') {
+ rc = hybbx_ax25_address_parse(tnc->config.dest_call,
+ &tnc->tx_path.dest);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ } else {
+ (void)hybbx_ax25_address_parse("QST", &tnc->tx_path.dest);
+ }
+
+ if (tnc->config.mycall != NULL && tnc->config.mycall[0] != '\0') {
+ rc = hybbx_ax25_address_parse(tnc->config.mycall,
+ &tnc->tx_path.source);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ } else {
+ (void)hybbx_ax25_address_parse("HYBBX", &tnc->tx_path.source);
+ }
+
+ for (i = 0; i < tnc->config.via_count; i++) {
+ rc = hybbx_ax25_address_parse(tnc->config.via[i],
+ &tnc->tx_path.digi[i]);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ tnc->tx_path.digi_count = tnc->config.via_count;
+
+ return HYBBX_OK;
+}
+
+static const char *serial_parity_letter(hybbx_tnc_serial_parity_t parity)
+{
+ switch (parity) {
+ case HYBBX_TNC_SERIAL_PARITY_EVEN:
+ return "E";
+ case HYBBX_TNC_SERIAL_PARITY_ODD:
+ return "O";
+ case HYBBX_TNC_SERIAL_PARITY_NONE:
+ default:
+ return "N";
+ }
+}
+
+static void tnc_serial_params_from_config(const hybbx_packet_radio_config_t *config,
+ hybbx_serial_params_t *out)
+{
+ hybbx_serial_params_default(out, config->baud);
+ out->data_bits = config->params.data_bits;
+ out->stop_bits = config->params.stop_bits;
+ out->assert_modem_lines = config->params.assert_modem_lines;
+
+ switch (config->params.serial_parity) {
+ case HYBBX_TNC_SERIAL_PARITY_EVEN:
+ out->parity = HYBBX_SERIAL_PARITY_EVEN;
+ break;
+ case HYBBX_TNC_SERIAL_PARITY_ODD:
+ out->parity = HYBBX_SERIAL_PARITY_ODD;
+ break;
+ case HYBBX_TNC_SERIAL_PARITY_NONE:
+ default:
+ out->parity = HYBBX_SERIAL_PARITY_NONE;
+ break;
+ }
+}
+
+static const char *tnc_profile_name(hybbx_packet_radio_tnc_t tnc)
+{
+ return hybbx_packet_radio_tnc_name(tnc);
+}
+
+static const char *modem_name(hybbx_tnc2c_modem_t modem)
+{
+ switch (modem) {
+ case HYBBX_TNC2C_MODEM_9600:
+ return "9600";
+ case HYBBX_TNC2C_MODEM_19200:
+ return "19200";
+ case HYBBX_TNC2C_MODEM_TCM3105:
+ default:
+ return "tcm3105";
+ }
+}
+
+
+static int tnc_profile_thefirmware(hybbx_packet_radio_tnc_t tnc);
+
+static hybbx_result_t tnc_profile_init(hybbx_tnc_t *tnc)
+{
+ hybbx_result_t rc;
+ char cmd[96];
+
+ rc = tnc_build_tx_path(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ if (tnc->config.params.kiss_entry == HYBBX_KISS_ENTRY_NONE) {
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_NONE;
+ hybbx_log_info("[tnc] KISS attach (MAX25 prep assumed)");
+ return HYBBX_OK;
+ }
+
+ if (tnc_profile_thefirmware(tnc->config.tnc)) {
+ (void)tnc_recover_terminal_mode(tnc);
+ hybbx_log_info("[tnc] TheFirmware terminal recovery before KISS entry");
+ }
+
+ rc = tnc_enter_kiss_mode(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ return tnc_apply_kiss_params(tnc);
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_SIXPACK) {
+ tnc->kiss_active = 0;
+ return HYBBX_OK;
+ }
+
+ /* Host mode setup (firmware TNC profiles only; not BayCom/based SER12 / PC-COM modem). */
+ drain_serial(tnc, 4);
+ (void)send_cmd(tnc, "");
+
+ if (tnc->config.mycall != NULL && tnc->config.mycall[0] != '\0') {
+ snprintf(cmd, sizeof(cmd), "MYCALL %s", tnc->config.mycall);
+ rc = send_cmd(tnc, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ snprintf(cmd, sizeof(cmd), "TXDELAY %u", tnc->config.params.txdelay);
+ (void)send_cmd(tnc, cmd);
+ snprintf(cmd, sizeof(cmd), "Persist %u", tnc->config.params.persist);
+ (void)send_cmd(tnc, cmd);
+ snprintf(cmd, sizeof(cmd), "SlotTime %u", tnc->config.params.slot);
+ (void)send_cmd(tnc, cmd);
+ snprintf(cmd, sizeof(cmd), "TXTAIL %u", tnc->config.params.txtail);
+ (void)send_cmd(tnc, cmd);
+
+ if (tnc->config.params.fullduplex) {
+ (void)send_cmd(tnc, "FULLDUP ON");
+ } else {
+ (void)send_cmd(tnc, "FULLDUP OFF");
+ }
+
+ if (tnc->config.tnc == HYBBX_PACKET_RADIO_TNC_PCCOM) {
+ (void)send_cmd(tnc, "PACLEN 256");
+ (void)send_cmd(tnc, "MAXFRAME 4");
+ }
+
+ if (tnc->config.tnc == HYBBX_PACKET_RADIO_TNC_BAYCOM) {
+ (void)send_cmd(tnc, "PACLEN 256");
+ }
+
+ if (tnc->config.params.host_connect_on_start &&
+ tnc->config.dest_call != NULL && tnc->config.dest_call[0] != '\0') {
+ snprintf(cmd, sizeof(cmd), "C %s", tnc->config.dest_call);
+ rc = send_cmd(tnc, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ drain_serial(tnc, 6);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_open(hybbx_tnc_t **out,
+ const hybbx_packet_radio_config_t *config,
+ hybbx_tnc_frame_cb frame_cb,
+ hybbx_tnc_ui_cb ui_cb,
+ void *rx_userdata)
+{
+ hybbx_tnc_t *tnc;
+ hybbx_result_t rc;
+
+ if (out == NULL || config == NULL || config->device == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (frame_cb == NULL && ui_cb == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ tnc = calloc(1, sizeof(*tnc));
+ if (tnc == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ config_copy(&tnc->config, config);
+ tnc->frame_cb = frame_cb;
+ tnc->ui_cb = ui_cb;
+ tnc->rx_userdata = rx_userdata;
+
+ hybbx_kiss_decoder_init(&tnc->kiss_dec);
+ hybbx_sixpack_decoder_init(&tnc->sixpack_dec);
+ hybbx_tnc_host_init(&tnc->host, on_host_text, on_host_event, tnc);
+
+ hybbx_serial_params_t serial_params;
+
+ tnc_serial_params_from_config(config, &serial_params);
+
+ rc = hybbx_serial_open(&tnc->serial, config->device, &serial_params);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] cannot open %s at %u baud (%u%c%u) — "
+ "device not available or permission denied",
+ config->device, config->baud, serial_params.data_bits,
+ *serial_parity_letter(config->params.serial_parity),
+ serial_params.stop_bits);
+ free(tnc);
+ return rc;
+ }
+
+ hybbx_log_info("[tnc] profile=%s protocol=%u device=%s host=%u %u%c%u rts_dtr=%s "
+ "kiss_entry=%s radio=%u modem=%s modulation=%s band=%s duplex=%s "
+ "txdelay=%u persist=%u",
+ tnc_profile_name(config->tnc), (unsigned)config->protocol,
+ config->device, config->baud, serial_params.data_bits,
+ *serial_parity_letter(config->params.serial_parity),
+ serial_params.stop_bits,
+ serial_params.assert_modem_lines ? "on" : "off",
+ hybbx_kiss_entry_name(config->params.kiss_entry),
+ config->params.radio_baud,
+ modem_name(config->params.modem),
+ hybbx_packet_radio_modulation_name(config->params.modulation),
+ hybbx_packet_radio_band_name(config->params.band),
+ hybbx_packet_radio_duplex_name(config->params.duplex),
+ config->params.txdelay,
+ config->params.persist);
+
+ if (config->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C &&
+ serial_params.data_bits == 8 &&
+ config->params.serial_parity == HYBBX_TNC_SERIAL_PARITY_NONE) {
+ hybbx_log_warn("[tnc] tnc2c %s: 8N1 host line — use serial_line=7E1 if KISS/UNPROTO TX fails",
+ config->device);
+ }
+
+ rc = tnc_profile_init(tnc);
+ if (rc != HYBBX_OK) {
+ hybbx_tnc_close(tnc);
+ return rc;
+ }
+
+ if (config->protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ hybbx_log_info("[tnc] KISS active (port %u)", config->params.kiss_port);
+ if (tnc_profile_thefirmware(config->tnc)) {
+ hybbx_log_info("[tnc] UI transmit=TheFirmware UNPROTO (KISS for RX)");
+ }
+ } else if (config->protocol == HYBBX_PACKET_RADIO_PROTO_SIXPACK) {
+ hybbx_log_info("[tnc] 6PACK active");
+ } else {
+ hybbx_log_info("[tnc] TNC2 host mode active");
+ }
+
+ *out = tnc;
+ return HYBBX_OK;
+}
+
+void hybbx_tnc_close(hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return;
+ }
+
+ if (tnc->serial != NULL) {
+ tnc_exit_kiss_mode(tnc);
+ hybbx_serial_close(tnc->serial);
+ }
+
+ free(tnc);
+}
+
+int hybbx_tnc_serial_fd(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL || tnc->serial == NULL) {
+ return -1;
+ }
+
+ return hybbx_serial_fd(tnc->serial);
+}
+
+static hybbx_result_t tnc_ensure_kiss_tx(hybbx_tnc_t *tnc)
+{
+ hybbx_result_t rc;
+
+ if (tnc == NULL ||
+ tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_KISS) {
+ return HYBBX_OK;
+ }
+
+ if (!tnc->kiss_active) {
+ rc = tnc_enter_kiss_mode(tnc);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] KISS TX blocked — host mode (re-entry failed)");
+ return rc;
+ }
+ hybbx_log_info("[tnc] KISS re-entered before RF TX");
+ return tnc_apply_kiss_params(tnc);
+ }
+
+ return HYBBX_OK;
+}
+
+/** KISS DATA: host sends AX.25 body only; TheFirmware adds FCS on air. */
+static size_t tnc_kiss_ax25_len(const uint8_t *frame, size_t len)
+{
+ uint16_t crc_rx;
+ uint16_t crc_calc;
+
+ if (len < 16) {
+ return len;
+ }
+
+ crc_rx = (uint16_t)frame[len - 2] | ((uint16_t)frame[len - 1] << 8);
+ crc_calc = hybbx_ax25_crc(frame, len - 2);
+ if (crc_rx == crc_calc) {
+ return len - 2;
+ }
+
+ return len;
+}
+
+static int tnc_profile_thefirmware(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ return 1;
+ default:
+ return 0;
+ }
+}
+
+static void tnc_format_ax25_call(const hybbx_ax25_address_t *addr,
+ char *out, size_t out_len)
+{
+ if (addr == NULL || out == NULL || out_len == 0) {
+ return;
+ }
+
+ snprintf(out, out_len, "%s", addr->call);
+ if (addr->ssid > 0) {
+ size_t n = strlen(out);
+
+ if (n + 4 < out_len) {
+ snprintf(out + n, out_len - n, "-%u", addr->ssid);
+ }
+ }
+}
+
+/**
+ * TheFirmware (TNC-2 class) often ignores KISS DATA on hybrid host/KISS builds
+ * while UNPROTO from cmd: mode keys PTT reliably. Exit KISS, send, re-enter.
+ */
+static hybbx_result_t tnc_thefirmware_unproto_send(hybbx_tnc_t *tnc,
+ const hybbx_ax25_path_t *path,
+ const uint8_t *payload,
+ size_t payload_len)
+{
+ char cmd[320];
+ char dest[HYBBX_AX25_CALL_MAX + 8];
+ hybbx_result_t rc;
+
+ if (tnc == NULL || path == NULL || payload == NULL || payload_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (payload_len + 64 >= sizeof(cmd)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ tnc_format_ax25_call(&path->dest, dest, sizeof(dest));
+
+ rc = tnc_kiss_wakeup_host(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ (void)send_cmd(tnc, "MON OFF");
+
+ snprintf(cmd, sizeof(cmd), "UNPROTO %s 0 %.*s",
+ dest, (int)payload_len, (const char *)payload);
+ rc = send_cmd(tnc, cmd);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] UNPROTO %s host send failed", dest);
+ return rc;
+ }
+
+ drain_serial(tnc, 24);
+ tnc_serial_pause_ms(500);
+
+ hybbx_log_info("[tnc] TheFirmware UNPROTO %s (%zu bytes)", dest, payload_len);
+
+ rc = tnc_enter_kiss_mode(tnc);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] KISS re-entry after UNPROTO failed");
+ return rc;
+ }
+
+ return tnc_apply_kiss_params(tnc);
+}
+
+hybbx_result_t hybbx_tnc_send_frame(hybbx_tnc_t *tnc,
+ const uint8_t *frame, size_t len)
+{
+ uint8_t buf[HYBBX_KISS_MAX_FRAME + 8];
+ size_t encoded;
+ hybbx_result_t rc;
+
+ if (tnc == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_rf_tx_pace();
+
+ rc = tnc_wait_half_duplex_clear(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_SIXPACK) {
+ encoded = hybbx_sixpack_encode(frame, len, buf, sizeof(buf));
+ if (encoded == 0) {
+ return HYBBX_ERR_IO;
+ }
+ return send_raw(tnc, buf, encoded);
+ }
+
+ if (!tnc->kiss_active &&
+ tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_KISS) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS &&
+ tnc_profile_thefirmware(tnc->config.tnc)) {
+ hybbx_ax25_path_t path;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ size_t ui_len = hybbx_ax25_parse_ui(frame, len, &path, ui, sizeof(ui));
+
+ if (ui_len > 0) {
+ return tnc_thefirmware_unproto_send(tnc, &path, ui, ui_len);
+ }
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ rc = tnc_ensure_kiss_tx(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ {
+ const uint8_t *tx_frame = frame;
+ size_t tx_len = len;
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ tx_len = tnc_kiss_ax25_len(frame, len);
+ tx_frame = frame;
+ (void)send_kiss_param(tnc, HYBBX_KISS_CMD_PERSIST,
+ (uint8_t)tnc->config.params.persist);
+ if (tnc_profile_thefirmware(tnc->config.tnc)) {
+ hybbx_log_warn("[tnc] UI parse failed (%zu bytes) — KISS DATA fallback",
+ len);
+ }
+ }
+
+ encoded = hybbx_kiss_encode((uint8_t)tnc->config.params.kiss_port,
+ HYBBX_KISS_CMD_DATA, tx_frame, tx_len,
+ buf, sizeof(buf));
+ if (encoded == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_log_debug("[tnc] KISS DATA ax25=%zu kiss=%zu%s",
+ tx_len, encoded,
+ tx_len + 2 == len ? " (fcs stripped)" : "");
+
+ return send_raw(tnc, buf, encoded);
+ }
+}
+
+hybbx_result_t hybbx_tnc_send_ui(hybbx_tnc_t *tnc,
+ const uint8_t *payload, size_t len)
+{
+ uint8_t frame[HYBBX_AX25_FRAME_MAX];
+ size_t frame_len;
+
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_ax25_build_ui(&tnc->tx_path, payload, len,
+ frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_tnc_send_frame(tnc, frame, frame_len);
+}
+
+hybbx_result_t hybbx_tnc_send_converse(hybbx_tnc_t *tnc,
+ const char *data, size_t len)
+{
+ hybbx_result_t rc;
+
+ if (tnc == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_HOSTMODE) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ rc = tnc_wait_half_duplex_clear(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return send_raw(tnc, (const uint8_t *)data, len);
+}
+
+hybbx_packet_radio_duplex_t hybbx_tnc_duplex(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ return tnc->config.params.duplex;
+}
+
+hybbx_packet_radio_band_t hybbx_tnc_band(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_PACKET_RADIO_BAND_UNSET;
+ }
+
+ return tnc->config.params.band;
+}
+
+int hybbx_tnc_duplex_is_full(const hybbx_tnc_t *tnc)
+{
+ return tnc != NULL &&
+ tnc->config.params.duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL;
+}
+
+int hybbx_tnc_host_connected(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return 0;
+ }
+
+ return tnc->host.state == HYBBX_TNC_HOST_CONNECTED;
+}
+
+hybbx_result_t hybbx_tnc_poll(hybbx_tnc_t *tnc)
+{
+ uint8_t buf[256];
+ size_t read_len;
+ hybbx_result_t rc;
+
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_serial_read(tnc->serial, buf, sizeof(buf), &read_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (read_len == 0) {
+ return HYBBX_OK;
+ }
+
+ switch (tnc->config.protocol) {
+ case HYBBX_PACKET_RADIO_PROTO_KISS:
+ hybbx_kiss_decoder_feed(&tnc->kiss_dec, buf, read_len,
+ on_kiss_frame, tnc);
+ break;
+ case HYBBX_PACKET_RADIO_PROTO_SIXPACK:
+ hybbx_sixpack_decoder_feed(&tnc->sixpack_dec, buf, read_len,
+ on_sixpack_frame, tnc);
+ break;
+ case HYBBX_PACKET_RADIO_PROTO_HOSTMODE:
+ hybbx_tnc_host_feed(&tnc->host, buf, read_len);
+ break;
+ default:
+ break;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/packet_radio/tnc_host.c b/plugins/packet_radio/tnc_host.c
new file mode 100644
index 0000000..8700e61
--- /dev/null
+++ b/plugins/packet_radio/tnc_host.c
@@ -0,0 +1,143 @@
+#include "tnc_host.h"
+
+#include <ctype.h>
+#include <string.h>
+
+void hybbx_tnc_host_init(hybbx_tnc_host_t *host,
+ hybbx_tnc_host_text_cb text_cb,
+ hybbx_tnc_host_event_cb event_cb,
+ void *userdata)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ memset(host, 0, sizeof(*host));
+ host->state = HYBBX_TNC_HOST_BOOT;
+ host->text_cb = text_cb;
+ host->event_cb = event_cb;
+ host->userdata = userdata;
+}
+
+void hybbx_tnc_host_reset(hybbx_tnc_host_t *host)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ host->line_len = 0;
+ host->line[0] = '\0';
+ host->state = HYBBX_TNC_HOST_BOOT;
+}
+
+static int host_line_ieq(const char *line, const char *prefix)
+{
+ size_t i;
+ size_t plen = strlen(prefix);
+
+ for (i = 0; i < plen; i++) {
+ char a = (char)tolower((unsigned char)line[i]);
+ char b = (char)tolower((unsigned char)prefix[i]);
+
+ if (a != b) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
+
+static void host_emit_event(hybbx_tnc_host_t *host, hybbx_tnc_host_state_t state)
+{
+ if (host->state == state) {
+ return;
+ }
+
+ host->state = state;
+ if (host->event_cb != NULL) {
+ host->event_cb(state, host->userdata);
+ }
+}
+
+static void host_process_line(hybbx_tnc_host_t *host)
+{
+ const char *line = host->line;
+
+ if (host_line_ieq(line, "cmd:") || host_line_ieq(line, "cmd: ")) {
+ host_emit_event(host, HYBBX_TNC_HOST_CMD);
+ return;
+ }
+
+ if (strstr(line, "***") != NULL &&
+ (host_line_ieq(line, "*** connected") ||
+ host_line_ieq(line, "*** connected ") ||
+ strstr(line, "CONNECTED") != NULL)) {
+ host_emit_event(host, HYBBX_TNC_HOST_CONNECTED);
+ return;
+ }
+
+ if (strstr(line, "***") != NULL &&
+ (host_line_ieq(line, "*** disconnected") ||
+ strstr(line, "DISCONNECTED") != NULL)) {
+ host_emit_event(host, HYBBX_TNC_HOST_CMD);
+ return;
+ }
+
+ if (host->state == HYBBX_TNC_HOST_CONNECTED && host->text_cb != NULL &&
+ line[0] != '\0') {
+ host->text_cb((const uint8_t *)line, strlen(line), host->userdata);
+ host->text_cb((const uint8_t *)"\r\n", 2, host->userdata);
+ }
+}
+
+static void host_push_byte(hybbx_tnc_host_t *host, uint8_t byte)
+{
+ if (byte == '\r' || byte == '\n') {
+ if (host->line_len > 0) {
+ host->line[host->line_len] = '\0';
+ host_process_line(host);
+ host->line_len = 0;
+ }
+ return;
+ }
+
+ if (host->line_len + 1 >= sizeof(host->line)) {
+ host->line_len = 0;
+ return;
+ }
+
+ host->line[host->line_len++] = (char)byte;
+}
+
+void hybbx_tnc_host_feed(hybbx_tnc_host_t *host,
+ const uint8_t *data, size_t len)
+{
+ size_t i;
+
+ if (host == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ host_push_byte(host, data[i]);
+ }
+}
+
+size_t hybbx_tnc_host_format_cmd(const char *cmd, char *out, size_t out_cap)
+{
+ size_t len;
+
+ if (cmd == NULL || out == NULL || out_cap < 3) {
+ return 0;
+ }
+
+ len = strlen(cmd);
+ if (len + 2 >= out_cap) {
+ return 0;
+ }
+
+ memcpy(out, cmd, len);
+ out[len++] = '\r';
+ out[len] = '\0';
+ return len;
+}
diff --git a/plugins/packet_radio/tnc_host.h b/plugins/packet_radio/tnc_host.h
new file mode 100644
index 0000000..af55505
--- /dev/null
+++ b/plugins/packet_radio/tnc_host.h
@@ -0,0 +1,51 @@
+#ifndef HYBBX_TNC_HOST_H
+#define HYBBX_TNC_HOST_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum hybbx_tnc_host_state {
+ HYBBX_TNC_HOST_BOOT = 0,
+ HYBBX_TNC_HOST_CMD = 1,
+ HYBBX_TNC_HOST_CONNECTED = 2
+} hybbx_tnc_host_state_t;
+
+typedef void (*hybbx_tnc_host_text_cb)(const uint8_t *data, size_t len,
+ void *userdata);
+
+typedef void (*hybbx_tnc_host_event_cb)(hybbx_tnc_host_state_t state,
+ void *userdata);
+
+typedef struct hybbx_tnc_host {
+ hybbx_tnc_host_state_t state;
+ char line[256];
+ size_t line_len;
+ hybbx_tnc_host_text_cb text_cb;
+ hybbx_tnc_host_event_cb event_cb;
+ void *userdata;
+} hybbx_tnc_host_t;
+
+void hybbx_tnc_host_init(hybbx_tnc_host_t *host,
+ hybbx_tnc_host_text_cb text_cb,
+ hybbx_tnc_host_event_cb event_cb,
+ void *userdata);
+
+void hybbx_tnc_host_reset(hybbx_tnc_host_t *host);
+
+void hybbx_tnc_host_feed(hybbx_tnc_host_t *host,
+ const uint8_t *data, size_t len);
+
+/** Build a TNC2 command line (adds CR). Returns length or 0. */
+size_t hybbx_tnc_host_format_cmd(const char *cmd, char *out, size_t out_cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TNC_HOST_H */
diff --git a/plugins/packet_radio/tnc_profiles.c b/plugins/packet_radio/tnc_profiles.c
new file mode 100644
index 0000000..05a2397
--- /dev/null
+++ b/plugins/packet_radio/tnc_profiles.c
@@ -0,0 +1,399 @@
+#include "hybbx/tnc.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+
+const char *hybbx_packet_radio_duplex_name(hybbx_packet_radio_duplex_t duplex)
+{
+ switch (duplex) {
+ case HYBBX_PACKET_RADIO_DUPLEX_FULL:
+ return "full";
+ case HYBBX_PACKET_RADIO_DUPLEX_HALF:
+ return "half";
+ case HYBBX_PACKET_RADIO_DUPLEX_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+const char *hybbx_packet_radio_band_name(hybbx_packet_radio_band_t band)
+{
+ switch (band) {
+ case HYBBX_PACKET_RADIO_BAND_CB:
+ return "cb";
+ case HYBBX_PACKET_RADIO_BAND_AMATEUR:
+ return "amateur";
+ case HYBBX_PACKET_RADIO_BAND_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+const char *hybbx_packet_radio_modulation_name(
+ hybbx_packet_radio_modulation_t modulation)
+{
+ switch (modulation) {
+ case HYBBX_PACKET_RADIO_MOD_G3RUH_FSK:
+ return "g3ruh_fsk";
+ case HYBBX_PACKET_RADIO_MOD_AFSK:
+ return "afsk";
+ case HYBBX_PACKET_RADIO_MOD_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+int hybbx_packet_radio_modulation_is_g3ruh(
+ hybbx_packet_radio_modulation_t modulation)
+{
+ return modulation == HYBBX_PACKET_RADIO_MOD_G3RUH_FSK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_modulation(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.modulation == HYBBX_PACKET_RADIO_MOD_UNSET) {
+ cfg->params.modulation = HYBBX_PACKET_RADIO_MOD_AFSK;
+ }
+
+ if (cfg->params.modulation == HYBBX_PACKET_RADIO_MOD_G3RUH_FSK) {
+ cfg->params.modem = HYBBX_TNC2C_MODEM_9600;
+ cfg->params.radio_baud = HYBBX_G3RUH_RADIO_BAUD;
+ if (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C &&
+ cfg->params.clock_mhz <= HYBBX_TNC2C_CLOCK_4_9_MHZ) {
+ cfg->params.clock_mhz = HYBBX_TNC2C_CLOCK_9_8_MHZ;
+ }
+ cfg->params.txdelay = 15;
+ cfg->params.persist = 128;
+ cfg->params.slot = 3;
+ return HYBBX_OK;
+ }
+
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_AFSK_RADIO_BAUD;
+ }
+ if (cfg->params.modem == 0) {
+ cfg->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_packet_radio_band_t default_band_for_tnc(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ return HYBBX_PACKET_RADIO_BAND_CB;
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ case HYBBX_PACKET_RADIO_TNC_PK232:
+ case HYBBX_PACKET_RADIO_TNC_MFJ1278:
+ case HYBBX_PACKET_RADIO_TNC_KANTRONICS:
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ default:
+ return HYBBX_PACKET_RADIO_BAND_AMATEUR;
+ }
+}
+
+const char *hybbx_packet_radio_tnc_name(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ return "baycom";
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ return "pccom";
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ return "generic";
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ return "tnc2";
+ case HYBBX_PACKET_RADIO_TNC_PK232:
+ return "pk232";
+ case HYBBX_PACKET_RADIO_TNC_MFJ1278:
+ return "mfj1278";
+ case HYBBX_PACKET_RADIO_TNC_KANTRONICS:
+ return "kantronics";
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ default:
+ return "tnc2c";
+ }
+}
+
+const char *hybbx_kiss_entry_name(hybbx_kiss_entry_t entry)
+{
+ switch (entry) {
+ case HYBBX_KISS_ENTRY_NONE:
+ return "none";
+ case HYBBX_KISS_ENTRY_KISS_ON:
+ return "kiss_on";
+ case HYBBX_KISS_ENTRY_ESC_AT_K:
+ return "esc_at_k";
+ case HYBBX_KISS_ENTRY_AUTO:
+ return "auto";
+ case HYBBX_KISS_ENTRY_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+static hybbx_kiss_entry_t default_kiss_entry_for_tnc(hybbx_packet_radio_tnc_t tnc)
+{
+ (void)tnc;
+ return HYBBX_KISS_ENTRY_NONE;
+}
+
+hybbx_result_t hybbx_tnc_finalize_kiss_entry(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.kiss_entry == HYBBX_KISS_ENTRY_UNSET) {
+ cfg->params.kiss_entry = default_kiss_entry_for_tnc(cfg->tnc);
+ }
+
+ if (cfg->params.kiss_exit == HYBBX_KISS_EXIT_UNSET) {
+ if (cfg->params.kiss_entry == HYBBX_KISS_ENTRY_NONE) {
+ cfg->params.kiss_exit = HYBBX_KISS_EXIT_NONE;
+ } else {
+ cfg->params.kiss_exit = HYBBX_KISS_EXIT_AUTO;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_radio_duplex(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_UNSET) {
+ cfg->params.band = default_band_for_tnc(cfg->tnc);
+ }
+
+ if (cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_UNSET) {
+ cfg->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_CB &&
+ cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL) {
+ hybbx_log_warn("[tnc] radio_band=cb allows half-duplex only; "
+ "ignoring radio_duplex=full");
+ cfg->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ cfg->params.fullduplex =
+ (cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL) ? 1 : 0;
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_csma(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.persist == 0) {
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_CB) {
+ cfg->params.persist = 255;
+ } else {
+ cfg->params.persist = 63;
+ }
+ } else if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_CB &&
+ cfg->params.persist < 255) {
+ hybbx_log_warn("[tnc] CB band persist=%u overridden to 255 for beacon TX",
+ cfg->params.persist);
+ cfg->params.persist = 255;
+ }
+
+ if (cfg->params.txdelay == 0) {
+ cfg->params.txdelay = 50;
+ }
+
+ if (cfg->params.slot == 0) {
+ cfg->params.slot = 10;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_serial_line(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.data_bits == 0) {
+ if (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C ||
+ cfg->tnc == HYBBX_PACKET_RADIO_TNC_MFJ1278) {
+ cfg->params.data_bits = HYBBX_TNC2C_DATA_BITS;
+ } else {
+ cfg->params.data_bits = 8;
+ }
+ }
+
+ if (cfg->params.serial_parity == HYBBX_TNC_SERIAL_PARITY_UNSET) {
+ if (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C ||
+ cfg->tnc == HYBBX_PACKET_RADIO_TNC_MFJ1278) {
+ cfg->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_EVEN;
+ } else {
+ cfg->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_NONE;
+ }
+ }
+
+ if (cfg->params.stop_bits == 0) {
+ cfg->params.stop_bits = 1;
+ }
+
+ if (cfg->params.assert_modem_lines < 0) {
+ cfg->params.assert_modem_lines =
+ (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C) ? 1 : 0;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_profile_apply_defaults(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (cfg->tnc) {
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ if (cfg->baud == 0) {
+ cfg->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ }
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_UNSET) {
+ cfg->params.band = HYBBX_PACKET_RADIO_BAND_CB;
+ }
+ cfg->params.radio_baud = 1200;
+ cfg->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ if (cfg->params.txdelay == 0) {
+ cfg->params.txdelay = 30;
+ }
+ if (cfg->params.persist == 0) {
+ cfg->params.persist = 63;
+ }
+ if (cfg->params.slot == 0) {
+ cfg->params.slot = 10;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ if (cfg->baud == 0) {
+ cfg->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ }
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_UNSET) {
+ cfg->params.band = HYBBX_PACKET_RADIO_BAND_CB;
+ }
+ cfg->params.radio_baud = 1200;
+ cfg->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_HOSTMODE;
+ }
+ if (cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_UNSET) {
+ cfg->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+ if (cfg->params.txdelay == 0) {
+ cfg->params.txdelay = 25;
+ }
+ if (cfg->params.persist == 0) {
+ cfg->params.persist = 128;
+ }
+ if (cfg->params.slot == 0) {
+ cfg->params.slot = 10;
+ }
+ cfg->params.host_connect_on_start = 1;
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ if (cfg->baud == 0) {
+ cfg->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ if (cfg->baud == 0) {
+ cfg->baud = 9600;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_PK232:
+ if (cfg->baud == 0) {
+ cfg->baud = 9600;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_MFJ1278:
+ if (cfg->baud == 0) {
+ cfg->baud = 4800;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_KANTRONICS:
+ if (cfg->baud == 0) {
+ cfg->baud = 9600;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ default:
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->params.clock_mhz == 0.0f) {
+ cfg->params.clock_mhz = HYBBX_TNC2C_CLOCK_4_9_MHZ;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+ }
+
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+
+ (void)hybbx_tnc_finalize_modulation(cfg);
+ (void)hybbx_tnc_finalize_radio_duplex(cfg);
+ (void)hybbx_tnc_finalize_csma(cfg);
+ (void)hybbx_tnc_finalize_serial_line(cfg);
+ return hybbx_tnc_finalize_kiss_entry(cfg);
+}
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com