summaryrefslogtreecommitdiff
path: root/plugins/ardop
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/ardop
#2
Diffstat (limited to 'plugins/ardop')
-rw-r--r--plugins/ardop/CMakeLists.txt57
-rw-r--r--plugins/ardop/ardop.c49
-rw-r--r--plugins/ardop/ardop_config.c186
-rw-r--r--plugins/ardop/ardop_crc.c69
-rw-r--r--plugins/ardop/ardop_crc.h15
-rw-r--r--plugins/ardop/ardop_host.c650
-rw-r--r--plugins/ardop/ardop_host.h47
-rw-r--r--plugins/ardop/ardop_link.c446
-rw-r--r--plugins/ardop/ardop_link.h37
9 files changed, 1556 insertions, 0 deletions
diff --git a/plugins/ardop/CMakeLists.txt b/plugins/ardop/CMakeLists.txt
new file mode 100644
index 0000000..c39ab91
--- /dev/null
+++ b/plugins/ardop/CMakeLists.txt
@@ -0,0 +1,57 @@
+add_library(hybbx_ardop_common STATIC
+ ardop_link.c
+ ardop_host.c
+ ardop_crc.c
+ ardop_config.c
+)
+
+target_include_directories(hybbx_ardop_common
+ PUBLIC
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_ardop_common
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(Threads REQUIRED)
+target_link_libraries(hybbx_ardop_common PUBLIC hybbx_core Threads::Threads)
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_link_libraries(hybbx_ardop_common PUBLIC amiga)
+endif()
+hybbx_apply_hardening(hybbx_ardop_common)
+
+if(HYBBX_PLUGIN_ARDOP)
+ add_library(hybbx_plugin_ardop STATIC
+ ardop.c
+ )
+
+ target_include_directories(hybbx_plugin_ardop
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ )
+
+ target_compile_definitions(hybbx_plugin_ardop
+ PRIVATE HYBBX_PLUGIN_BUILD
+ )
+
+ target_link_libraries(hybbx_plugin_ardop PRIVATE hybbx_ardop_common)
+ hybbx_link_plugin_instances(hybbx_plugin_ardop HYBBX_HAVE_PLUGIN_ARDOP)
+ hybbx_apply_hardening(hybbx_plugin_ardop)
+
+ if(HYBBX_INSTALL_DEV)
+ install(TARGETS hybbx_plugin_ardop
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ )
+ endif()
+endif()
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_ardop_common
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/ardop/ardop.c b/plugins/ardop/ardop.c
new file mode 100644
index 0000000..e07a158
--- /dev/null
+++ b/plugins/ardop/ardop.c
@@ -0,0 +1,49 @@
+/*
+ * ardop — ARDOP host-client link adapter (external ARDOPC / ardopcf).
+ * INI: [transport.ardop] or [transport.ardopN].
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/ardop.h"
+#include "ardop_link.h"
+
+static const ardop_link_plugin_t g_ardop_link = {
+ .name = "ardop",
+ .kind = HYBBX_TRANSPORT_ARDOP,
+ .default_link_id = "ardop-link",
+ .modem_hint = "ARDOPC or ardopcf",
+ .parse_config = (ardop_link_config_fn)hybbx_ardop_config_parse,
+ .config_free = (void (*)(void *))hybbx_ardop_config_free,
+ .config_size = sizeof(hybbx_ardop_config_t),
+};
+
+static hybbx_result_t ardop_init(hybbx_service_t *service)
+{
+ return ardop_link_init(service, &g_ardop_link);
+}
+
+static void ardop_shutdown(void)
+{
+ ardop_link_shutdown(&g_ardop_link);
+}
+
+static hybbx_result_t ardop_start(const char *config)
+{
+ return ardop_link_start(&g_ardop_link, config);
+}
+
+static hybbx_result_t ardop_stop(void)
+{
+ return ardop_link_stop(&g_ardop_link);
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_ardop = {
+ .name = "ardop",
+ .kind = HYBBX_TRANSPORT_ARDOP,
+ .version = 1,
+ .init = ardop_init,
+ .shutdown = ardop_shutdown,
+ .start = ardop_start,
+ .stop = ardop_stop,
+ .write = NULL,
+};
diff --git a/plugins/ardop/ardop_config.c b/plugins/ardop/ardop_config.c
new file mode 100644
index 0000000..71b40dc
--- /dev/null
+++ b/plugins/ardop/ardop_config.c
@@ -0,0 +1,186 @@
+#include "hybbx/ardop.h"
+#include "hybbx/crdop.h"
+#include "hybbx/circuit.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static char *ardop_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;
+}
+
+void hybbx_ardop_config_free(hybbx_ardop_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ free(config->ardop_host);
+ free(config->mycall);
+ free(config->arq_bandwidth);
+ free(config->peer_call);
+ free(config->circuit_host);
+ free(config->link_id);
+ free(config->link_password);
+ free(config->link_role);
+ memset(config, 0, sizeof(*config));
+}
+
+hybbx_result_t hybbx_ardop_config_parse(const char *config,
+ hybbx_ardop_config_t *out)
+{
+ char scratch[128];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_ardop_config_free(out);
+
+ if (config == NULL) {
+ config = "";
+ }
+
+ value = find_kv(config, "ardop_host", scratch, sizeof(scratch));
+ out->ardop_host = ardop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->ardop_port = (unsigned)strtoul(
+ find_kv(config, "ardop_port", scratch, sizeof(scratch)) != NULL ?
+ scratch : "8515", NULL, 10);
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ out->mycall = ardop_strdup(value != NULL ? value : "NOCALL");
+
+ value = find_kv(config, "radio_profile", scratch, sizeof(scratch));
+ out->radio_profile = hybbx_crdop_profile_parse(value);
+
+ value = find_kv(config, "arq_bandwidth", scratch, sizeof(scratch));
+ out->arq_bandwidth = ardop_strdup(
+ value != NULL ? value :
+ hybbx_crdop_default_arq_bandwidth(out->radio_profile));
+
+ value = find_kv(config, "peer_call", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->peer_call = ardop_strdup(value);
+ }
+
+ value = find_kv(config, "listen", scratch, sizeof(scratch));
+ out->listen = value == NULL || str_ieq(value, "yes") || str_ieq(value, "1") ||
+ str_ieq(value, "true");
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ out->circuit_host = ardop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->circuit_port = (unsigned)strtoul(
+ find_kv(config, "circuit_port", scratch, sizeof(scratch)) != NULL ?
+ scratch : "7323", NULL, 10);
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ out->link_id = ardop_strdup(value != NULL ? value : "ardop-link");
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_password = ardop_strdup(value);
+ }
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ out->link_role = ardop_strdup(value != NULL ? value : "link");
+
+ 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 = strtod(value, NULL);
+ }
+
+ if (out->radio_profile == HYBBX_CRDOP_PROFILE_CB &&
+ hybbx_crdop_bandwidth_exceeds_cb(out->arq_bandwidth)) {
+ hybbx_log_warn("[ardop] warning: arq_bandwidth=%s high for CB profile "
+ "(CRDOP Level 2 experimental)",
+ out->arq_bandwidth);
+ }
+
+ if (out->ardop_host == NULL || out->mycall == NULL ||
+ out->arq_bandwidth == NULL || out->circuit_host == NULL ||
+ out->link_id == NULL || out->link_role == NULL) {
+ hybbx_ardop_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/ardop/ardop_crc.c b/plugins/ardop/ardop_crc.c
new file mode 100644
index 0000000..4cf205a
--- /dev/null
+++ b/plugins/ardop/ardop_crc.c
@@ -0,0 +1,69 @@
+#include "ardop_crc.h"
+
+/* Algorithm from ARDOPC (KN6KB / G8BPQ) — CRC-16-CCITT variant, poly 0x8810. */
+
+unsigned int ardop_crc16(const unsigned char *data, unsigned short length)
+{
+ int int_register = 0xffff;
+ int i;
+ int j;
+ int bit;
+ const int int_poly = 0x8810;
+
+ if (data == NULL || length == 0) {
+ return 0xffffu;
+ }
+
+ for (j = 0; j < (int)length; j++) {
+ int mask = 0x80;
+
+ for (i = 0; i < 8; i++) {
+ bit = data[j] & mask;
+ mask >>= 1;
+
+ if (int_register & 0x8000) {
+ if (bit) {
+ int_register = 0xffff & (1 + (int_register << 1));
+ } else {
+ int_register = 0xffff & (int_register << 1);
+ }
+ int_register = int_register ^ int_poly;
+ } else {
+ if (bit) {
+ int_register = 0xffff & (1 + (int_register << 1));
+ } else {
+ int_register = 0xffff & (int_register << 1);
+ }
+ }
+ }
+ }
+
+ return (unsigned int)int_register;
+}
+
+int ardop_crc16_valid(const unsigned char *data, unsigned short length)
+{
+ unsigned int crc;
+
+ if (data == NULL || length < 3) {
+ return 0;
+ }
+
+ crc = ardop_crc16(data, (unsigned short)(length - 2));
+ return data[length - 2] == (unsigned char)(crc >> 8) &&
+ data[length - 1] == (unsigned char)(crc & 0xff);
+}
+
+size_t ardop_crc16_append(unsigned char *data, size_t length, size_t cap)
+{
+ unsigned int crc;
+
+ if (data == NULL || length + 2 > cap) {
+ return 0;
+ }
+
+ crc = ardop_crc16(data, (unsigned short)length);
+ data[length] = (unsigned char)(crc >> 8);
+ data[length + 1] = (unsigned char)(crc & 0xff);
+ return length + 2;
+}
diff --git a/plugins/ardop/ardop_crc.h b/plugins/ardop/ardop_crc.h
new file mode 100644
index 0000000..69c2f07
--- /dev/null
+++ b/plugins/ardop/ardop_crc.h
@@ -0,0 +1,15 @@
+#ifndef ARDOP_CRC_H
+#define ARDOP_CRC_H
+
+#include <stddef.h>
+
+/** ARDOP host/TNC CRC-16 (poly 0x8810, init 0xFFFF) per ARDOPC reference. */
+unsigned int ardop_crc16(const unsigned char *data, unsigned short length);
+
+/** Non-zero when @p length includes trailing 2-byte CRC that validates. */
+int ardop_crc16_valid(const unsigned char *data, unsigned short length);
+
+/** Append CRC16 (MSB, LSB) after @p length bytes; returns new length. */
+size_t ardop_crc16_append(unsigned char *data, size_t length, size_t cap);
+
+#endif /* ARDOP_CRC_H */
diff --git a/plugins/ardop/ardop_host.c b/plugins/ardop/ardop_host.c
new file mode 100644
index 0000000..aeac403
--- /dev/null
+++ b/plugins/ardop/ardop_host.c
@@ -0,0 +1,650 @@
+#include "ardop_host.h"
+#include "ardop_crc.h"
+
+#include "hybbx/socket.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define ARDOP_HOST_RX_MAX 8192u
+#define ARDOP_HOST_CMD_MAX 256u
+#define ARDOP_HOST_FRAME_MAX (HYBBX_ARDOP_DATA_MAX + 64u)
+
+struct ardop_host {
+ hybbx_ardop_config_t cfg;
+ ardop_host_data_fn on_data;
+ ardop_host_event_fn on_event;
+ void *userdata;
+ int ctrl_fd;
+ int data_fd;
+ int tnc_ready;
+ int link_up;
+ char peer_call[16];
+ unsigned char ctrl_rx[ARDOP_HOST_RX_MAX];
+ size_t ctrl_len;
+ unsigned char data_rx[ARDOP_HOST_RX_MAX];
+ size_t data_len;
+ int init_sent;
+ const char *log_tag;
+};
+
+static const char *host_log_tag(const ardop_host_t *host)
+{
+ if (host != NULL && host->log_tag != NULL && host->log_tag[0] != '\0') {
+ return host->log_tag;
+ }
+ return "ardop";
+}
+
+static const char *host_modem_name(const ardop_host_t *host)
+{
+ if (host != NULL && host->log_tag != NULL &&
+ strcmp(host->log_tag, "crdop") == 0) {
+ return "CRDOPC";
+ }
+ return "ARDOPC";
+}
+
+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 void ardop_host_fire_event(ardop_host_t *host, const char *event,
+ const char *detail)
+{
+ if (host != NULL && host->on_event != NULL) {
+ host->on_event(event, detail, host->userdata);
+ }
+}
+
+static int ardop_host_set_socket_opts(int fd)
+{
+ int on = 1;
+
+ if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static int ardop_host_tcp_connect(const char *host, unsigned port)
+{
+ struct sockaddr_in addr;
+ int fd;
+ int rc;
+
+ if (host == NULL || host[0] == '\0' || port == 0) {
+ return -1;
+ }
+
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+
+ if (ardop_host_set_socket_opts(fd) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
+ if (rc != 0) {
+ close(fd);
+ return -1;
+ }
+
+ return fd;
+}
+
+static hybbx_result_t ardop_host_write_all(int fd, const unsigned char *buf,
+ size_t len)
+{
+ size_t off = 0;
+
+ if (fd < 0 || buf == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (off < len) {
+ ssize_t n = send(fd, buf + off, len - off, MSG_NOSIGNAL);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+ off += (size_t)n;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ardop_host_send_cmd_frame(ardop_host_t *host,
+ const char *cmd)
+{
+ unsigned char frame[ARDOP_HOST_CMD_MAX + 4];
+ size_t len;
+
+ if (host == NULL || cmd == NULL || host->ctrl_fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ len = strlen(cmd);
+ if (len + 3 > sizeof(frame)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(frame, cmd, len);
+ frame[len++] = '\r';
+ len = ardop_crc16_append(frame, len, sizeof(frame));
+ if (len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return ardop_host_write_all(host->ctrl_fd, frame, len);
+}
+
+static hybbx_result_t ardop_host_send_rdy(ardop_host_t *host)
+{
+ return ardop_host_send_cmd_frame(host, "RDY");
+}
+
+static void ardop_host_strip_prefix(char *line)
+{
+ if (line == NULL) {
+ return;
+ }
+
+ if (strncmp(line, "c:", 2) == 0 || strncmp(line, "C:", 2) == 0) {
+ memmove(line, line + 2, strlen(line + 2) + 1);
+ }
+}
+
+static void ardop_host_handle_ctrl_line(ardop_host_t *host, char *line)
+{
+ char *sp;
+
+ if (host == NULL || line == NULL) {
+ return;
+ }
+
+ ardop_host_strip_prefix(line);
+
+ if (str_ieq(line, "RDY")) {
+ host->tnc_ready = 1;
+ return;
+ }
+
+ if (strncmp(line, "VERSION", 7) == 0) {
+ hybbx_log_debug("[%s] modem %s", host_log_tag(host), line);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (strncmp(line, "CONNECTED", 9) == 0) {
+ char *tok;
+
+ sp = line + 9;
+ while (*sp == ' ') {
+ sp++;
+ }
+ hybbx_strlcpy(host->peer_call, sp, sizeof(host->peer_call));
+ tok = strchr(host->peer_call, ' ');
+ if (tok != NULL) {
+ *tok = '\0';
+ }
+ host->link_up = 1;
+ ardop_host_fire_event(host, "connected", host->peer_call);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (str_ieq(line, "DISCONNECTED")) {
+ host->link_up = 0;
+ host->peer_call[0] = '\0';
+ ardop_host_fire_event(host, "disconnected", NULL);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (strncmp(line, "FAULT", 5) == 0 ||
+ strncmp(line, "CRCFAULT", 8) == 0) {
+ hybbx_log_warn("[%s] TNC fault: %s", host_log_tag(host), line);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (strncmp(line, "BUSY", 4) == 0 ||
+ strncmp(line, "NEWSTATE", 8) == 0 ||
+ strncmp(line, "BUFFER", 6) == 0 ||
+ strncmp(line, "STATUS", 6) == 0 ||
+ strncmp(line, "PENDING", 7) == 0 ||
+ strncmp(line, "TARGET", 6) == 0) {
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ (void)ardop_host_send_rdy(host);
+}
+
+static void ardop_host_process_ctrl_rx(ardop_host_t *host)
+{
+ unsigned char *cr;
+ unsigned char frame[ARDOP_HOST_CMD_MAX + 8];
+ size_t msg_len;
+ char line[ARDOP_HOST_CMD_MAX];
+
+ if (host == NULL) {
+ return;
+ }
+
+ for (;;) {
+ cr = memchr(host->ctrl_rx, '\r', host->ctrl_len);
+ if (cr == NULL) {
+ return;
+ }
+
+ msg_len = (size_t)(cr - host->ctrl_rx) + 1u + 2u;
+ if (host->ctrl_len < msg_len) {
+ return;
+ }
+
+ if (msg_len > sizeof(frame)) {
+ host->ctrl_len = 0;
+ return;
+ }
+
+ memcpy(frame, host->ctrl_rx, msg_len);
+ if (!ardop_crc16_valid(frame, (unsigned short)msg_len)) {
+ hybbx_log_warn("[%s] control CRC fault", host_log_tag(host));
+ host->ctrl_len = 0;
+ return;
+ }
+
+ frame[msg_len - 3] = '\0';
+ hybbx_strlcpy(line, (const char *)frame, sizeof(line));
+ ardop_host_handle_ctrl_line(host, line);
+
+ memmove(host->ctrl_rx, host->ctrl_rx + msg_len, host->ctrl_len - msg_len);
+ host->ctrl_len -= msg_len;
+ }
+}
+
+static void ardop_host_process_data_rx(ardop_host_t *host)
+{
+ size_t off = 0;
+
+ if (host == NULL) {
+ return;
+ }
+
+ while (host->data_len >= off + 6u) {
+ const unsigned char *buf = host->data_rx + off;
+ size_t remain = host->data_len - off;
+ size_t payload_len;
+ size_t frame_len;
+ const uint8_t *payload;
+ const char *tag;
+
+ if (buf[0] != 'd' && buf[0] != 'D') {
+ off++;
+ continue;
+ }
+ if (buf[1] != ':') {
+ off++;
+ continue;
+ }
+
+ payload_len = ((size_t)buf[2] << 8) | (size_t)buf[3];
+ if (payload_len < 3 || payload_len > HYBBX_ARDOP_DATA_MAX + 8u) {
+ off++;
+ continue;
+ }
+
+ frame_len = 2u + 2u + payload_len + 2u;
+ if (remain < frame_len) {
+ break;
+ }
+
+ if (!ardop_crc16_valid(buf, (unsigned short)frame_len)) {
+ off++;
+ continue;
+ }
+
+ tag = (const char *)(buf + 4);
+ payload = buf + 7;
+ payload_len -= 3u;
+
+ if (strncmp(tag, "ARQ", 3) == 0 && host->on_data != NULL &&
+ payload_len > 0) {
+ host->on_data(payload, payload_len, host->userdata);
+ }
+
+ (void)ardop_host_send_cmd_frame(host, "RDY");
+ off += frame_len;
+ }
+
+ if (off > 0) {
+ memmove(host->data_rx, host->data_rx + off, host->data_len - off);
+ host->data_len -= off;
+ }
+}
+
+static hybbx_result_t ardop_host_send_init(ardop_host_t *host)
+{
+ char cmd[96];
+ hybbx_result_t rc;
+
+ if (host == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = ardop_host_send_cmd_frame(host, "INITIALIZE");
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(cmd, sizeof(cmd), "MYCALL %s",
+ host->cfg.mycall != NULL ? host->cfg.mycall : "NOCALL");
+ rc = ardop_host_send_cmd_frame(host, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = ardop_host_send_cmd_frame(host, "PROTOCOLMODE ARQ");
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(cmd, sizeof(cmd), "ARQBW %s",
+ host->cfg.arq_bandwidth != NULL ? host->cfg.arq_bandwidth :
+ "500MAX");
+ rc = ardop_host_send_cmd_frame(host, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = ardop_host_send_cmd_frame(host,
+ host->cfg.listen ? "LISTEN TRUE" :
+ "LISTEN FALSE");
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (host->cfg.peer_call != NULL && host->cfg.peer_call[0] != '\0') {
+ snprintf(cmd, sizeof(cmd), "ARQCALL %s 5", host->cfg.peer_call);
+ rc = ardop_host_send_cmd_frame(host, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ host->init_sent = 1;
+ hybbx_log_info("[%s] host init sent (external %s at %s:%u)",
+ host_log_tag(host), host_modem_name(host),
+ host->cfg.ardop_host != NULL ? host->cfg.ardop_host : "?",
+ host->cfg.ardop_port);
+ return HYBBX_OK;
+}
+
+static void ardop_host_read_fd(int fd, unsigned char *buf, size_t *len,
+ size_t cap)
+{
+ ssize_t n;
+
+ if (fd < 0 || buf == NULL || len == NULL || *len >= cap) {
+ return;
+ }
+
+ n = recv(fd, buf + *len, cap - *len, 0);
+ if (n > 0) {
+ *len += (size_t)n;
+ } else if (n == 0) {
+ close(fd);
+ }
+}
+
+ardop_host_t *ardop_host_create(const hybbx_ardop_config_t *cfg,
+ ardop_host_data_fn on_data,
+ ardop_host_event_fn on_event,
+ void *userdata,
+ const char *log_tag)
+{
+ ardop_host_t *host;
+
+ if (cfg == NULL) {
+ return NULL;
+ }
+
+ host = calloc(1, sizeof(*host));
+ if (host == NULL) {
+ return NULL;
+ }
+
+ host->cfg = *cfg;
+ host->on_data = on_data;
+ host->on_event = on_event;
+ host->userdata = userdata;
+ host->ctrl_fd = -1;
+ host->data_fd = -1;
+ host->log_tag = log_tag;
+ return host;
+}
+
+void ardop_host_destroy(ardop_host_t *host)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ ardop_host_disconnect(host);
+ free(host);
+}
+
+hybbx_result_t ardop_host_connect(ardop_host_t *host)
+{
+ const char *h;
+ unsigned port;
+
+ if (host == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ardop_host_disconnect(host);
+
+ h = host->cfg.ardop_host;
+ if (h == NULL || h[0] == '\0') {
+ h = "127.0.0.1";
+ }
+ port = host->cfg.ardop_port;
+ if (port == 0) {
+ port = HYBBX_ARDOP_DEFAULT_PORT;
+ }
+
+ host->ctrl_fd = ardop_host_tcp_connect(h, port);
+ if (host->ctrl_fd < 0) {
+ hybbx_log_warn("[%s] cannot connect to %s control %s:%u",
+ host_log_tag(host), host_modem_name(host), h, port);
+ return HYBBX_ERR_IO;
+ }
+
+ host->data_fd = ardop_host_tcp_connect(h, port + 1u);
+ if (host->data_fd < 0) {
+ hybbx_log_warn("[%s] cannot connect to %s data %s:%u",
+ host_log_tag(host), host_modem_name(host), h, port + 1u);
+ ardop_host_disconnect(host);
+ return HYBBX_ERR_IO;
+ }
+
+ host->tnc_ready = 0;
+ host->init_sent = 0;
+ hybbx_log_info("[%s] connected to external %s %s:%u (data %u)",
+ host_log_tag(host), host_modem_name(host), h, port, port + 1u);
+
+ (void)ardop_host_send_init(host);
+ {
+ unsigned spin;
+
+ for (spin = 0; spin < 8u; spin++) {
+ ardop_host_poll(host);
+ if (host->tnc_ready) {
+ break;
+ }
+ }
+ }
+ return HYBBX_OK;
+}
+
+void ardop_host_disconnect(ardop_host_t *host)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ if (host->ctrl_fd >= 0) {
+ close(host->ctrl_fd);
+ host->ctrl_fd = -1;
+ }
+ if (host->data_fd >= 0) {
+ close(host->data_fd);
+ host->data_fd = -1;
+ }
+
+ host->ctrl_len = 0;
+ host->data_len = 0;
+ host->tnc_ready = 0;
+ host->link_up = 0;
+ host->init_sent = 0;
+ host->peer_call[0] = '\0';
+}
+
+void ardop_host_poll(ardop_host_t *host)
+{
+ struct pollfd pfds[2];
+ int count = 0;
+ int pr;
+
+ if (host == NULL || host->ctrl_fd < 0) {
+ return;
+ }
+
+ pfds[0].fd = host->ctrl_fd;
+ pfds[0].events = POLLIN;
+ count = 1;
+
+ if (host->data_fd >= 0) {
+ pfds[1].fd = host->data_fd;
+ pfds[1].events = POLLIN;
+ count = 2;
+ }
+
+ pr = poll(pfds, (nfds_t)count, 0);
+ if (pr <= 0) {
+ if (host->tnc_ready && !host->init_sent) {
+ (void)ardop_host_send_init(host);
+ }
+ return;
+ }
+
+ if ((pfds[0].revents & POLLIN) != 0) {
+ ardop_host_read_fd(host->ctrl_fd, host->ctrl_rx, &host->ctrl_len,
+ ARDOP_HOST_RX_MAX);
+ ardop_host_process_ctrl_rx(host);
+ }
+
+ if (count > 1 && (pfds[1].revents & POLLIN) != 0) {
+ ardop_host_read_fd(host->data_fd, host->data_rx, &host->data_len,
+ ARDOP_HOST_RX_MAX);
+ ardop_host_process_data_rx(host);
+ }
+
+ if (host->tnc_ready && !host->init_sent) {
+ (void)ardop_host_send_init(host);
+ }
+}
+
+int ardop_host_link_up(const ardop_host_t *host)
+{
+ return host != NULL && host->link_up;
+}
+
+int ardop_host_modem_connected(const ardop_host_t *host)
+{
+ return host != NULL && host->ctrl_fd >= 0;
+}
+
+hybbx_result_t ardop_host_send_data(ardop_host_t *host,
+ const uint8_t *data, size_t len)
+{
+ unsigned char frame[ARDOP_HOST_FRAME_MAX];
+ size_t frame_len;
+
+ if (host == NULL || data == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!host->link_up || host->data_fd < 0) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (len > HYBBX_ARDOP_DATA_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (len + 8 > sizeof(frame)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame[0] = 'D';
+ frame[1] = ':';
+ frame[2] = (unsigned char)((len >> 8) & 0xff);
+ frame[3] = (unsigned char)(len & 0xff);
+ memcpy(frame + 4, data, len);
+
+ frame_len = 4u + len;
+ frame_len = ardop_crc16_append(frame, frame_len, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return ardop_host_write_all(host->data_fd, frame, frame_len);
+}
diff --git a/plugins/ardop/ardop_host.h b/plugins/ardop/ardop_host.h
new file mode 100644
index 0000000..4aee53d
--- /dev/null
+++ b/plugins/ardop/ardop_host.h
@@ -0,0 +1,47 @@
+#ifndef ARDOP_HOST_H
+#define ARDOP_HOST_H
+
+/**
+ * Minimal ARDOP TNC Host Interface client (TCP control + data ports).
+ * Implements the subset HyBBX needs — see docs/MANUAL.md § ARDOP.
+ */
+
+#include "hybbx/types.h"
+#include "hybbx/ardop.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct ardop_host ardop_host_t;
+
+typedef void (*ardop_host_data_fn)(const uint8_t *data, size_t len, void *ctx);
+typedef void (*ardop_host_event_fn)(const char *event, const char *detail,
+ void *ctx);
+
+ardop_host_t *ardop_host_create(const hybbx_ardop_config_t *cfg,
+ ardop_host_data_fn on_data,
+ ardop_host_event_fn on_event,
+ void *userdata,
+ const char *log_tag);
+void ardop_host_destroy(ardop_host_t *host);
+
+hybbx_result_t ardop_host_connect(ardop_host_t *host);
+void ardop_host_disconnect(ardop_host_t *host);
+
+/** Drive I/O and state machine; call periodically from plugin poll thread. */
+void ardop_host_poll(ardop_host_t *host);
+
+int ardop_host_link_up(const ardop_host_t *host);
+
+/** Control TCP session to external ARDOPC is open. */
+int ardop_host_modem_connected(const ardop_host_t *host);
+
+hybbx_result_t ardop_host_send_data(ardop_host_t *host,
+ const uint8_t *data, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* ARDOP_HOST_H */
diff --git a/plugins/ardop/ardop_link.c b/plugins/ardop/ardop_link.c
new file mode 100644
index 0000000..e0500b9
--- /dev/null
+++ b/plugins/ardop/ardop_link.c
@@ -0,0 +1,446 @@
+#include "ardop_link.h"
+#include "ardop_host.h"
+
+#include "hybbx/ardop.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/crdop.h"
+#include "hybbx/log.h"
+
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <sys/select.h>
+#endif
+
+static const ardop_link_plugin_t *g_plugin;
+static hybbx_ardop_config_t g_active_config;
+static ardop_host_t *g_host;
+static int g_circuit_fd = -1;
+static hybbx_circuit_decoder_t g_circuit_dec;
+static pthread_t g_poll_thread;
+static volatile int g_running;
+static int g_poll_thread_started;
+static volatile int g_circuit_flow_paused;
+static volatile int g_circuit_flow_cancel;
+static unsigned g_modem_reconnect_backoff_ms;
+
+static const char *link_tag(void)
+{
+ return g_plugin != NULL && g_plugin->name != NULL ? g_plugin->name : "ardop";
+}
+
+static int circuit_uplink_allowed(void)
+{
+ return !g_circuit_flow_paused && !g_circuit_flow_cancel;
+}
+
+static hybbx_result_t circuit_uplink_terminal(const uint8_t *payload, size_t len)
+{
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+
+ if (g_circuit_fd < 0 || len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (!circuit_uplink_allowed()) {
+ return HYBBX_OK;
+ }
+
+ hbx_len = hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_TERMINAL,
+ HYBBX_CIRCUIT_FLAG_RX,
+ payload, len, hbx, sizeof(hbx));
+ if (hbx_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(g_circuit_fd, hbx, hbx_len);
+}
+
+static void on_modem_data(const uint8_t *data, size_t len, void *userdata)
+{
+ (void)userdata;
+
+ if (len == 0 || g_circuit_fd < 0 || !circuit_uplink_allowed()) {
+ return;
+ }
+
+ (void)circuit_uplink_terminal(data, len);
+}
+
+static void on_modem_event(const char *event, const char *detail, void *userdata)
+{
+ (void)userdata;
+
+ if (event == NULL) {
+ return;
+ }
+
+ if (strcmp(event, "connected") == 0) {
+ if (detail != NULL && detail[0] != '\0') {
+ hybbx_log_stats("[%s] RF link up (%s)", link_tag(), detail);
+ } else {
+ hybbx_log_stats("[%s] RF link up", link_tag());
+ }
+ g_modem_reconnect_backoff_ms = 1000u;
+ return;
+ }
+
+ if (strcmp(event, "disconnected") == 0) {
+ hybbx_log_stats("[%s] RF link down", link_tag());
+ }
+}
+
+static void on_circuit_flow_ctrl(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ hybbx_circuit_balance_action_t action;
+ char reason[64];
+
+ (void)proto;
+ (void)flags;
+ (void)userdata;
+
+ if (hybbx_circuit_flow_ctrl_parse((const char *)payload, len, &action,
+ reason, sizeof(reason)) != HYBBX_OK) {
+ return;
+ }
+
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE:
+ g_circuit_flow_paused = 1;
+ hybbx_log_stats("[%s] circuit flow pause (%s)", link_tag(), reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_BREAK:
+ g_circuit_flow_paused = 0;
+ hybbx_circuit_decoder_init(&g_circuit_dec);
+ hybbx_log_stats("[%s] circuit flow break (%s)", link_tag(), reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ g_circuit_flow_cancel = 1;
+ hybbx_log_warn("[%s] circuit flow cancel (%s)", link_tag(), reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ g_circuit_flow_paused = 0;
+ hybbx_log_stats("[%s] circuit flow resume (%s)", link_tag(), 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)
+{
+ (void)flags;
+ (void)userdata;
+
+ if (proto == HYBBX_CIRCUIT_PROTO_FLOW_CTRL) {
+ on_circuit_flow_ctrl(proto, flags, payload, len, userdata);
+ return;
+ }
+
+ if (g_host == NULL || len == 0 || g_circuit_flow_paused) {
+ return;
+ }
+
+ if (proto != HYBBX_CIRCUIT_PROTO_TERMINAL) {
+ return;
+ }
+
+ if (!ardop_host_link_up(g_host)) {
+ return;
+ }
+
+ (void)ardop_host_send_data(g_host, payload, len);
+}
+
+static void link_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 link_connect_circuit(void);
+
+static void link_maybe_reconnect_modem(void)
+{
+ static unsigned ticks;
+
+ if (g_host == NULL) {
+ return;
+ }
+
+ if (ardop_host_modem_connected(g_host)) {
+ ticks = 0;
+ return;
+ }
+
+ ticks++;
+ if (ticks < (g_modem_reconnect_backoff_ms / 20u)) {
+ return;
+ }
+
+ ticks = 0;
+ if (ardop_host_connect(g_host) != HYBBX_OK) {
+ if (g_modem_reconnect_backoff_ms < 30000u) {
+ g_modem_reconnect_backoff_ms += 1000u;
+ }
+ }
+}
+
+static void *link_poll_thread(void *arg)
+{
+ uint8_t buf[512];
+
+ (void)arg;
+
+ while (g_running) {
+ if (g_host != NULL) {
+ ardop_host_poll(g_host);
+ link_maybe_reconnect_modem();
+ }
+
+ if (g_circuit_fd >= 0) {
+ size_t read_len = 0;
+ hybbx_result_t rc = hybbx_circuit_link_read(g_circuit_fd, buf,
+ sizeof(buf), &read_len);
+ if (rc == HYBBX_OK && read_len > 0) {
+ hybbx_circuit_decoder_feed(&g_circuit_dec, buf, read_len,
+ on_circuit_downlink, NULL);
+ }
+ } else {
+ static unsigned circuit_ticks;
+
+ circuit_ticks++;
+ if (circuit_ticks >= 50u) {
+ circuit_ticks = 0;
+ (void)link_connect_circuit();
+ }
+ }
+
+ if (g_circuit_flow_cancel) {
+ g_circuit_flow_cancel = 0;
+ g_circuit_flow_paused = 0;
+ if (g_circuit_fd >= 0) {
+ close(g_circuit_fd);
+ g_circuit_fd = -1;
+ }
+ (void)link_connect_circuit();
+ }
+
+ link_poll_sleep_ms(20);
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t link_connect_circuit(void)
+{
+ const char *host = g_active_config.circuit_host;
+ unsigned port = g_active_config.circuit_port;
+ const char *link_id = g_active_config.link_id;
+ unsigned attempt;
+
+ if (host == NULL || host[0] == '\0') {
+ host = "127.0.0.1";
+ }
+ if (port == 0) {
+ port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = g_plugin != NULL && g_plugin->default_link_id != NULL ?
+ g_plugin->default_link_id : "ardop-link";
+ }
+
+ for (attempt = 0; attempt < 50; attempt++) {
+ hybbx_result_t rc = hybbx_circuit_link_connect(host, port,
+ &g_circuit_fd);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_decoder_init(&g_circuit_dec);
+ if (g_active_config.link_password != NULL &&
+ g_active_config.link_password[0] != '\0') {
+ const char *link_role = g_active_config.link_role;
+ hybbx_circuit_link_qos_t qos;
+ char freq_buf[16];
+ const char *freq_ptr = NULL;
+
+ if (link_role == NULL || link_role[0] == '\0') {
+ link_role = "link";
+ }
+
+ memset(&qos, 0, sizeof(qos));
+ qos.bandwidth = "low";
+ qos.duplex = 1;
+ if (g_active_config.frequency_mhz > 0.0) {
+ snprintf(freq_buf, sizeof(freq_buf), "%.3f",
+ g_active_config.frequency_mhz);
+ freq_ptr = freq_buf;
+ }
+ qos.frequency_mhz = freq_ptr;
+
+ rc = hybbx_circuit_link_authenticate_ex(
+ g_circuit_fd, g_active_config.link_password,
+ link_role, link_id, &qos);
+ if (rc != HYBBX_OK) {
+ close(g_circuit_fd);
+ g_circuit_fd = -1;
+ link_poll_sleep_ms(100);
+ continue;
+ }
+ }
+ hybbx_log_info("[%s] linked to internal circuit %s:%u (HBX)",
+ link_tag(), host, port);
+ return HYBBX_OK;
+ }
+ link_poll_sleep_ms(100);
+ }
+
+ hybbx_log_warn("[%s] could not connect to internal circuit %s:%u",
+ link_tag(), host, port);
+ return HYBBX_ERR_IO;
+}
+
+static void link_shutdown(void)
+{
+ g_running = 0;
+ if (g_poll_thread_started) {
+ pthread_join(g_poll_thread, NULL);
+ g_poll_thread_started = 0;
+ }
+
+ if (g_circuit_fd >= 0) {
+ close(g_circuit_fd);
+ g_circuit_fd = -1;
+ }
+
+ if (g_host != NULL) {
+ ardop_host_destroy(g_host);
+ g_host = NULL;
+ }
+
+ if (g_plugin != NULL && g_plugin->config_free != NULL) {
+ g_plugin->config_free(&g_active_config);
+ }
+}
+
+hybbx_result_t ardop_link_init(struct hybbx_service *service,
+ const ardop_link_plugin_t *plugin)
+{
+ (void)service;
+ g_plugin = plugin;
+ g_host = NULL;
+ g_circuit_fd = -1;
+ g_running = 0;
+ g_circuit_flow_paused = 0;
+ g_circuit_flow_cancel = 0;
+ g_modem_reconnect_backoff_ms = 1000u;
+ return HYBBX_OK;
+}
+
+void ardop_link_shutdown(const ardop_link_plugin_t *plugin)
+{
+ (void)plugin;
+ link_shutdown();
+}
+
+hybbx_result_t ardop_link_start(const ardop_link_plugin_t *plugin,
+ const char *config)
+{
+ hybbx_result_t rc;
+ const char *modem_hint;
+
+ if (plugin == NULL || plugin->parse_config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ g_plugin = plugin;
+ link_shutdown();
+ memset(&g_active_config, 0, sizeof(g_active_config));
+ g_poll_thread = (pthread_t)0;
+
+ rc = plugin->parse_config(config, &g_active_config);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[%s] invalid configuration", link_tag());
+ return rc;
+ }
+
+ hybbx_log_info("[%s] external modem %s:%u mycall=%s arq=%s profile=%s circuit=%s:%u "
+ "link_id=%s",
+ link_tag(),
+ g_active_config.ardop_host,
+ g_active_config.ardop_port,
+ g_active_config.mycall,
+ g_active_config.arq_bandwidth,
+ hybbx_crdop_profile_name(g_active_config.radio_profile),
+ g_active_config.circuit_host,
+ g_active_config.circuit_port,
+ g_active_config.link_id != NULL ? g_active_config.link_id : "?");
+
+ if (g_active_config.radio_profile == HYBBX_CRDOP_PROFILE_CB) {
+ hybbx_log_info("[%s] CB half-duplex profile (experimental Level 2)",
+ link_tag());
+ }
+ if (g_active_config.frequency_mhz > 0.0) {
+ hybbx_log_info("[%s] frequency_mhz=%.3f", link_tag(),
+ g_active_config.frequency_mhz);
+ }
+
+ modem_hint = plugin->modem_hint != NULL ? plugin->modem_hint : "ARDOPC";
+ hybbx_log_info("[%s] operator must run external %s separately (e.g. ardopc TCPIP %u)",
+ link_tag(), modem_hint, g_active_config.ardop_port);
+
+ g_host = ardop_host_create(&g_active_config, on_modem_data, on_modem_event,
+ NULL, link_tag());
+ if (g_host == NULL) {
+ link_shutdown();
+ return HYBBX_ERR_NOMEM;
+ }
+
+ rc = ardop_host_connect(g_host);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[%s] external modem not reachable yet — will retry in poll loop",
+ link_tag());
+ }
+
+ rc = link_connect_circuit();
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[%s] circuit hub not reachable — HBX uplink deferred",
+ link_tag());
+ }
+
+ g_running = 1;
+ if (pthread_create(&g_poll_thread, NULL, link_poll_thread, NULL) != 0) {
+ hybbx_log_warn("[%s] poll thread failed", link_tag());
+ link_shutdown();
+ return HYBBX_ERR_IO;
+ }
+ g_poll_thread_started = 1;
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t ardop_link_stop(const ardop_link_plugin_t *plugin)
+{
+ (void)plugin;
+ hybbx_log_info("[%s] stop", link_tag());
+ link_shutdown();
+ return HYBBX_OK;
+}
diff --git a/plugins/ardop/ardop_link.h b/plugins/ardop/ardop_link.h
new file mode 100644
index 0000000..0f10dba
--- /dev/null
+++ b/plugins/ardop/ardop_link.h
@@ -0,0 +1,37 @@
+#ifndef ARDOP_LINK_H
+#define ARDOP_LINK_H
+
+#include "hybbx/plugin.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+
+typedef hybbx_result_t (*ardop_link_config_fn)(const char *config,
+ void *config_out);
+
+typedef struct ardop_link_plugin {
+ const char *name;
+ hybbx_transport_kind_t kind;
+ const char *default_link_id;
+ const char *modem_hint;
+ ardop_link_config_fn parse_config;
+ void (*config_free)(void *config_out);
+ size_t config_size;
+} ardop_link_plugin_t;
+
+hybbx_result_t ardop_link_init(struct hybbx_service *service,
+ const ardop_link_plugin_t *plugin);
+void ardop_link_shutdown(const ardop_link_plugin_t *plugin);
+hybbx_result_t ardop_link_start(const ardop_link_plugin_t *plugin,
+ const char *config);
+hybbx_result_t ardop_link_stop(const ardop_link_plugin_t *plugin);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* ARDOP_LINK_H */
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com