summaryrefslogtreecommitdiff
path: root/stacks/max25-bcpr/src
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:23:28 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:23:28 +0000
commitfa05a5f8238e9e1417235711656e5062ccc843a7 (patch)
tree46fbbd18de54492b59307c16efcc7f3152723238 /stacks/max25-bcpr/src
Initial push
Diffstat (limited to 'stacks/max25-bcpr/src')
-rw-r--r--stacks/max25-bcpr/src/bcpr_config.c397
-rw-r--r--stacks/max25-bcpr/src/bcpr_crc.c55
-rw-r--r--stacks/max25-bcpr/src/bcpr_daemon.c289
-rw-r--r--stacks/max25-bcpr/src/bcpr_engine.c652
-rw-r--r--stacks/max25-bcpr/src/bcpr_hdlc.c293
-rw-r--r--stacks/max25-bcpr/src/bcpr_kiss.c127
-rw-r--r--stacks/max25-bcpr/src/bcpr_lock.c202
-rw-r--r--stacks/max25-bcpr/src/bcpr_runas.c81
-rw-r--r--stacks/max25-bcpr/src/bcpr_ser12.c246
-rw-r--r--stacks/max25-bcpr/src/bcpr_uart.c251
-rw-r--r--stacks/max25-bcpr/src/max25-bcprd-init.c255
-rw-r--r--stacks/max25-bcpr/src/max25-bcprd.c205
12 files changed, 3053 insertions, 0 deletions
diff --git a/stacks/max25-bcpr/src/bcpr_config.c b/stacks/max25-bcpr/src/bcpr_config.c
new file mode 100644
index 0000000..3707891
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_config.c
@@ -0,0 +1,397 @@
+/*
+ * Simple INI loader for max25-bcpr (max 2 devices: [bc0] / [bc1]).
+ */
+#include "bcpr/bcpr_config.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+void bcpr_config_defaults(bcpr_config_t *cfg)
+{
+ int i;
+ memset(cfg, 0, sizeof(*cfg));
+ snprintf(cfg->state_dir, sizeof(cfg->state_dir), "%s", "/tmp/max25-bcpr");
+ cfg->dry_run = 0;
+ cfg->n_dev = 0;
+ /* FlexNet SER12.doc: 14.5 s keyed / 500 ms unkey. */
+ cfg->ptt_wd = 1;
+ cfg->ptt_wd_key_ms = 14500;
+ cfg->ptt_wd_pause_ms = 500;
+ cfg->txd_bias = BCPR_TXD_PULSE;
+ cfg->tot_enabled = 1;
+ cfg->tot_max_key_ms = 25000;
+ cfg->tot_min_gap_ms = 1500;
+ cfg->tot_max_consecutive = 3;
+ cfg->tot_max_bursts = 8;
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ bcpr_dev_config_t *d = &cfg->dev[i];
+ d->enabled = 0;
+ d->baud = 1200;
+ snprintf(d->mode, sizeof(d->mode), "%s", "ser12*");
+ d->tx_delay = 35;
+ d->tx_tail = 2;
+ d->slottime = 10;
+ d->ppersist = 40;
+ d->fulldup = 0;
+ d->ptt_wd = 1;
+ d->ptt_wd_key_ms = 14500;
+ d->ptt_wd_pause_ms = 500;
+ d->txd_bias = BCPR_TXD_PULSE;
+ d->tot_enabled = 1;
+ d->tot_max_key_ms = 25000;
+ d->tot_min_gap_ms = 1500;
+ d->tot_max_consecutive = 3;
+ d->tot_max_bursts = 8;
+ }
+}
+
+static void trim(char *s)
+{
+ char *e;
+ while (*s && isspace((unsigned char)*s)) {
+ memmove(s, s + 1, strlen(s));
+ }
+ e = s + strlen(s);
+ while (e > s && isspace((unsigned char)e[-1])) {
+ *--e = '\0';
+ }
+}
+
+static int parse_u(const char *v, unsigned *out)
+{
+ char *end = NULL;
+ unsigned long x = strtoul(v, &end, 0);
+ if (!v[0] || (end && *end)) {
+ return -1;
+ }
+ *out = (unsigned)x;
+ return 0;
+}
+
+static int parse_i(const char *v, int *out)
+{
+ char *end = NULL;
+ long x = strtol(v, &end, 0);
+ if (!v[0] || (end && *end)) {
+ return -1;
+ }
+ *out = (int)x;
+ return 0;
+}
+
+static int truthy(const char *v)
+{
+ return (strcasecmp(v, "yes") == 0 || strcasecmp(v, "true") == 0 ||
+ strcasecmp(v, "on") == 0 || strcmp(v, "1") == 0);
+}
+
+static int parse_txd_bias(const char *v, int *out)
+{
+ if (strcasecmp(v, "pulse") == 0 || strcasecmp(v, "sailer") == 0) {
+ *out = BCPR_TXD_PULSE;
+ return 0;
+ }
+ if (strcasecmp(v, "steady") == 0 || strcasecmp(v, "tfpcx") == 0 ||
+ strcasecmp(v, "break") == 0) {
+ *out = BCPR_TXD_STEADY;
+ return 0;
+ }
+ return -1;
+}
+
+static int apply_tot(int *enabled, int *max_key_ms, int *min_gap_ms,
+ int *max_consecutive, int *max_bursts,
+ const char *key, const char *val)
+{
+ if (strcmp(key, "tot") == 0 || strcmp(key, "tot_enabled") == 0) {
+ *enabled = truthy(val) ? 1 : 0;
+ return 0;
+ }
+ if (strcmp(key, "tot_max_key_sec") == 0 || strcmp(key, "tot_max_key_ms") == 0) {
+ if (strcmp(key, "tot_max_key_sec") == 0) {
+ int sec;
+ if (parse_i(val, &sec) != 0 || sec < 1) {
+ return -1;
+ }
+ *max_key_ms = sec * 1000;
+ return 0;
+ }
+ return parse_i(val, max_key_ms);
+ }
+ if (strcmp(key, "tot_min_gap_sec") == 0 || strcmp(key, "tot_min_gap_ms") == 0) {
+ if (strcmp(key, "tot_min_gap_sec") == 0) {
+ int sec;
+ if (parse_i(val, &sec) != 0 || sec < 0) {
+ return -1;
+ }
+ *min_gap_ms = sec * 1000;
+ return 0;
+ }
+ return parse_i(val, min_gap_ms);
+ }
+ if (strcmp(key, "tot_max_consecutive") == 0) {
+ return parse_i(val, max_consecutive);
+ }
+ if (strcmp(key, "tot_max_bursts") == 0) {
+ return parse_i(val, max_bursts);
+ }
+ return 1;
+}
+
+static int apply_wd_txd(int *ptt_wd, int *key_ms, int *pause_ms, int *txd_bias,
+ const char *key, const char *val)
+{
+ if (strcmp(key, "ptt_wd") == 0 || strcmp(key, "ptt_watchdog") == 0) {
+ *ptt_wd = truthy(val) ? 1 : 0;
+ return 0;
+ }
+ if (strcmp(key, "ptt_wd_key_ms") == 0) {
+ return parse_i(val, key_ms);
+ }
+ if (strcmp(key, "ptt_wd_pause_ms") == 0) {
+ return parse_i(val, pause_ms);
+ }
+ if (strcmp(key, "txd_bias") == 0) {
+ return parse_txd_bias(val, txd_bias);
+ }
+ return 1; /* not handled */
+}
+
+static void normalize_legacy_path(char *buf, size_t len)
+{
+ static const struct {
+ const char *from;
+ const char *to;
+ } map[] = {
+ {"/tmp/bcpr/", "/tmp/max25-bcpr/"},
+ {"/tmp/bcpr", "/tmp/max25-bcpr"},
+ {"/var/run/bcpr/", "/tmp/max25-bcpr/"},
+ {"/var/run/bcpr", "/tmp/max25-bcpr"},
+ };
+ size_t i;
+ size_t flen;
+ if (!buf || !len || !buf[0]) {
+ return;
+ }
+ for (i = 0; i < sizeof(map) / sizeof(map[0]); i++) {
+ flen = strlen(map[i].from);
+ if (strncmp(buf, map[i].from, flen) != 0) {
+ continue;
+ }
+ if (buf[flen] != '\0' && buf[flen] != '/') {
+ continue;
+ }
+ {
+ char tmp[256];
+ snprintf(tmp, sizeof(tmp), "%s%s", map[i].to, buf + flen);
+ snprintf(buf, len, "%s", tmp);
+ }
+ return;
+ }
+}
+
+static int apply_kv(bcpr_config_t *cfg, int section, const char *key,
+ const char *val)
+{
+ /* section: -1 = [max25-bcpr]/[bcpr] global, 0 = bc0, 1 = bc1 */
+ if (section < 0) {
+ int rc;
+ if (strcmp(key, "dry_run") == 0) {
+ cfg->dry_run = truthy(val);
+ return 0;
+ }
+ if (strcmp(key, "state_dir") == 0) {
+ snprintf(cfg->state_dir, sizeof(cfg->state_dir), "%s", val);
+ return 0;
+ }
+ if (strcmp(key, "user") == 0) {
+ snprintf(cfg->run_user, sizeof(cfg->run_user), "%s", val);
+ return 0;
+ }
+ if (strcmp(key, "group") == 0) {
+ snprintf(cfg->run_group, sizeof(cfg->run_group), "%s", val);
+ return 0;
+ }
+ rc = apply_wd_txd(&cfg->ptt_wd, &cfg->ptt_wd_key_ms,
+ &cfg->ptt_wd_pause_ms, &cfg->txd_bias, key, val);
+ if (rc <= 0) {
+ /* Propagate global WD/TXD defaults onto both devices. */
+ int i;
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ cfg->dev[i].ptt_wd = cfg->ptt_wd;
+ cfg->dev[i].ptt_wd_key_ms = cfg->ptt_wd_key_ms;
+ cfg->dev[i].ptt_wd_pause_ms = cfg->ptt_wd_pause_ms;
+ cfg->dev[i].txd_bias = cfg->txd_bias;
+ }
+ return rc;
+ }
+ rc = apply_tot(&cfg->tot_enabled, &cfg->tot_max_key_ms, &cfg->tot_min_gap_ms,
+ &cfg->tot_max_consecutive, &cfg->tot_max_bursts, key, val);
+ if (rc <= 0) {
+ int i;
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ cfg->dev[i].tot_enabled = cfg->tot_enabled;
+ cfg->dev[i].tot_max_key_ms = cfg->tot_max_key_ms;
+ cfg->dev[i].tot_min_gap_ms = cfg->tot_min_gap_ms;
+ cfg->dev[i].tot_max_consecutive = cfg->tot_max_consecutive;
+ cfg->dev[i].tot_max_bursts = cfg->tot_max_bursts;
+ }
+ return rc;
+ }
+ return 0;
+ }
+ if (section >= BCPR_MAX_DEVICES) {
+ return -1;
+ }
+ {
+ bcpr_dev_config_t *d = &cfg->dev[section];
+ int rc;
+ if (strcmp(key, "enabled") == 0) {
+ d->enabled = truthy(val);
+ } else if (strcmp(key, "serial") == 0 || strcmp(key, "tty") == 0) {
+ snprintf(d->serial, sizeof(d->serial), "%s", val);
+ d->enabled = 1;
+ } else if (strcmp(key, "iobase") == 0) {
+ return parse_u(val, &d->iobase);
+ } else if (strcmp(key, "irq") == 0) {
+ return parse_u(val, &d->irq);
+ } else if (strcmp(key, "baud") == 0) {
+ return parse_u(val, &d->baud);
+ } else if (strcmp(key, "mode") == 0) {
+ snprintf(d->mode, sizeof(d->mode), "%s", val);
+ } else if (strcmp(key, "kiss_link") == 0) {
+ snprintf(d->kiss_link, sizeof(d->kiss_link), "%s", val);
+ } else if (strcmp(key, "tx_delay") == 0 || strcmp(key, "txdelay") == 0) {
+ return parse_i(val, &d->tx_delay);
+ } else if (strcmp(key, "tx_tail") == 0 || strcmp(key, "txtail") == 0) {
+ return parse_i(val, &d->tx_tail);
+ } else if (strcmp(key, "slottime") == 0) {
+ return parse_i(val, &d->slottime);
+ } else if (strcmp(key, "ppersist") == 0) {
+ return parse_i(val, &d->ppersist);
+ } else if (strcmp(key, "fulldup") == 0) {
+ d->fulldup = truthy(val);
+ } else {
+ rc = apply_wd_txd(&d->ptt_wd, &d->ptt_wd_key_ms, &d->ptt_wd_pause_ms,
+ &d->txd_bias, key, val);
+ if (rc < 0) {
+ return -1;
+ }
+ if (rc == 0) {
+ return 0;
+ }
+ rc = apply_tot(&d->tot_enabled, &d->tot_max_key_ms, &d->tot_min_gap_ms,
+ &d->tot_max_consecutive, &d->tot_max_bursts, key, val);
+ if (rc < 0) {
+ return -1;
+ }
+ if (rc == 0) {
+ return 0;
+ }
+ }
+ }
+ return 0;
+}
+
+int bcpr_config_load(bcpr_config_t *cfg, const char *path)
+{
+ FILE *f;
+ char line[512];
+ int section = -1;
+ int i;
+
+ if (!cfg || !path) {
+ return -1;
+ }
+ bcpr_config_defaults(cfg);
+ f = fopen(path, "r");
+ if (!f) {
+ return -1;
+ }
+ while (fgets(line, sizeof(line), f)) {
+ char *eq;
+ char *p = line;
+ trim(p);
+ if (p[0] == '\0' || p[0] == '#' || p[0] == ';') {
+ continue;
+ }
+ if (p[0] == '[') {
+ char *end = strchr(p, ']');
+ if (!end) {
+ continue;
+ }
+ *end = '\0';
+ p++;
+ if (strcasecmp(p, "max25-bcpr") == 0 || strcasecmp(p, "bcpr") == 0 ||
+ strcasecmp(p, "global") == 0) {
+ section = -1;
+ } else if (strcasecmp(p, "bc0") == 0 ||
+ strcasecmp(p, "device.bc0") == 0) {
+ section = 0;
+ } else if (strcasecmp(p, "bc1") == 0 ||
+ strcasecmp(p, "device.bc1") == 0) {
+ section = 1;
+ } else {
+ section = -2; /* ignore unknown */
+ }
+ continue;
+ }
+ if (section == -2) {
+ continue;
+ }
+ eq = strchr(p, '=');
+ if (!eq) {
+ continue;
+ }
+ *eq = '\0';
+ trim(p);
+ trim(eq + 1);
+ (void)apply_kv(cfg, section, p, eq + 1);
+ }
+ fclose(f);
+
+ normalize_legacy_path(cfg->state_dir, sizeof(cfg->state_dir));
+
+ cfg->n_dev = 0;
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (cfg->dev[i].enabled && cfg->dev[i].serial[0]) {
+ if (!cfg->dev[i].kiss_link[0]) {
+ snprintf(cfg->dev[i].kiss_link, sizeof(cfg->dev[i].kiss_link),
+ "%s/kiss-bc%d", cfg->state_dir, i);
+ }
+ normalize_legacy_path(cfg->dev[i].kiss_link,
+ sizeof(cfg->dev[i].kiss_link));
+ /* Clamp WD timings. */
+ if (cfg->dev[i].ptt_wd_key_ms < 1000) {
+ cfg->dev[i].ptt_wd_key_ms = 1000;
+ }
+ if (cfg->dev[i].ptt_wd_pause_ms < 50) {
+ cfg->dev[i].ptt_wd_pause_ms = 50;
+ }
+ if (cfg->dev[i].txd_bias != BCPR_TXD_STEADY) {
+ cfg->dev[i].txd_bias = BCPR_TXD_PULSE;
+ }
+ if (cfg->dev[i].tot_max_key_ms < 1000) {
+ cfg->dev[i].tot_max_key_ms = 1000;
+ }
+ if (cfg->dev[i].tot_min_gap_ms < 0) {
+ cfg->dev[i].tot_min_gap_ms = 0;
+ }
+ if (cfg->dev[i].tot_max_consecutive < 1) {
+ cfg->dev[i].tot_max_consecutive = 1;
+ }
+ if (cfg->dev[i].tot_max_bursts < 1) {
+ cfg->dev[i].tot_max_bursts = 1;
+ }
+ cfg->n_dev++;
+ } else {
+ cfg->dev[i].enabled = 0;
+ }
+ }
+ if (cfg->n_dev > BCPR_MAX_DEVICES) {
+ return -1;
+ }
+ return 0;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_crc.c b/stacks/max25-bcpr/src/bcpr_crc.c
new file mode 100644
index 0000000..6aabc49
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_crc.c
@@ -0,0 +1,55 @@
+#include "bcpr/bcpr_crc.h"
+
+/* CRC-CCITT poly 0x8408 reflected (hdlcdrv / WAMPES style via crc_ccitt). */
+static const uint16_t bcpr_crc_table[256] = {
+ 0x0000, 0x1189, 0x2312, 0x329b, 0x4624, 0x57ad, 0x6536, 0x74bf, 0x8c48,
+ 0x9dc1, 0xaf5a, 0xbed3, 0xca6c, 0xdbe5, 0xe97e, 0xf8f7, 0x1081, 0x0108,
+ 0x3393, 0x221a, 0x56a5, 0x472c, 0x75b7, 0x643e, 0x9cc9, 0x8d40, 0xbfdb,
+ 0xae52, 0xdaed, 0xcb64, 0xf9ff, 0xe876, 0x2102, 0x308b, 0x0210, 0x1399,
+ 0x6726, 0x76af, 0x4434, 0x55bd, 0xad4a, 0xbcc3, 0x8e58, 0x9fd1, 0xeb6e,
+ 0xfae7, 0xc87c, 0xd9f5, 0x3183, 0x200a, 0x1291, 0x0318, 0x77a7, 0x662e,
+ 0x54b5, 0x453c, 0xbdcb, 0xac42, 0x9ed9, 0x8f50, 0xfbef, 0xea66, 0xd8fd,
+ 0xc974, 0x4204, 0x538d, 0x6116, 0x709f, 0x0420, 0x15a9, 0x2732, 0x36bb,
+ 0xce4c, 0xdfc5, 0xed5e, 0xfcd7, 0x8868, 0x99e1, 0xab7a, 0xbaf3, 0x5285,
+ 0x430c, 0x7197, 0x601e, 0x14a1, 0x0528, 0x37b3, 0x263a, 0xdecd, 0xcf44,
+ 0xfddf, 0xec56, 0x98e9, 0x8960, 0xbbfb, 0xaa72, 0x6306, 0x728f, 0x4014,
+ 0x519d, 0x2522, 0x34ab, 0x0630, 0x17b9, 0xef4e, 0xfec7, 0xcc5c, 0xddd5,
+ 0xa96a, 0xb8e3, 0x8a78, 0x9bf1, 0x7387, 0x620e, 0x5095, 0x411c, 0x35a3,
+ 0x242a, 0x16b1, 0x0738, 0xffcf, 0xee46, 0xdcdd, 0xcd54, 0xb9eb, 0xa862,
+ 0x9af9, 0x8b70, 0x8408, 0x9581, 0xa71a, 0xb693, 0xc22c, 0xd3a5, 0xe13e,
+ 0xf0b7, 0x0840, 0x19c9, 0x2b52, 0x3adb, 0x4e64, 0x5fed, 0x6d76, 0x7cff,
+ 0x9489, 0x8500, 0xb79b, 0xa612, 0xd2ad, 0xc324, 0xf1bf, 0xe036, 0x18c1,
+ 0x0948, 0x3bd3, 0x2a5a, 0x5ee5, 0x4f6c, 0x7df7, 0x6c7e, 0xa50a, 0xb483,
+ 0x8618, 0x9791, 0xe32e, 0xf2a7, 0xc03c, 0xd1b5, 0x2942, 0x38cb, 0x0a50,
+ 0x1bd9, 0x6f66, 0x7eef, 0x4c74, 0x5dfd, 0xb58b, 0xa402, 0x9699, 0x8710,
+ 0xf3af, 0xe226, 0xd0bd, 0xc134, 0x39c3, 0x284a, 0x1ad1, 0x0b58, 0x7fe7,
+ 0x6e6e, 0x5cf5, 0x4d7c, 0xc60c, 0xd785, 0xe51e, 0xf497, 0x8028, 0x91a1,
+ 0xa33a, 0xb2b3, 0x4a44, 0x5bcd, 0x6956, 0x78df, 0x0c60, 0x1de9, 0x2f72,
+ 0x3efb, 0xd68d, 0xc704, 0xf59f, 0xe416, 0x90a9, 0x8120, 0xb3bb, 0xa232,
+ 0x5ac5, 0x4b4c, 0x79d7, 0x685e, 0x1ce1, 0x0d68, 0x3ff3, 0x2e7a, 0xe70e,
+ 0xf687, 0xc41c, 0xd595, 0xa12a, 0xb0a3, 0x8238, 0x93b1, 0x6b46, 0x7acf,
+ 0x4854, 0x59dd, 0x2d62, 0x3ceb, 0x0e70, 0x1ff9, 0xf78f, 0xe606, 0xd49d,
+ 0xc514, 0xb1ab, 0xa022, 0x92b9, 0x8330, 0x7bc7, 0x6a4e, 0x58d5, 0x495c,
+ 0x3de3, 0x2c6a, 0x1ef1, 0x0f78
+};
+
+uint16_t bcpr_crc_ccitt(uint16_t crc, const uint8_t *buf, size_t len)
+{
+ while (len--) {
+ crc = (crc >> 8) ^ bcpr_crc_table[(crc ^ *buf++) & 0xff];
+ }
+ return crc;
+}
+
+void bcpr_append_crc_ccitt(uint8_t *buffer, int len)
+{
+ unsigned int crc = bcpr_crc_ccitt(0xffff, buffer, (size_t)len) ^ 0xffff;
+ buffer += len;
+ *buffer++ = (uint8_t)(crc & 0xff);
+ *buffer++ = (uint8_t)(crc >> 8);
+}
+
+int bcpr_check_crc_ccitt(const uint8_t *buf, int cnt)
+{
+ return (bcpr_crc_ccitt(0xffff, buf, (size_t)cnt) & 0xffff) == 0xf0b8;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_daemon.c b/stacks/max25-bcpr/src/bcpr_daemon.c
new file mode 100644
index 0000000..9019159
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_daemon.c
@@ -0,0 +1,289 @@
+/*
+ * Shared max25-bcprd run loop (KISS thread + SER12 engine).
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_daemon.h"
+#include "bcpr/bcpr_ser12.h"
+
+#include <errno.h>
+#include <poll.h>
+#include <pthread.h>
+#include <signal.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+static volatile sig_atomic_t g_stop;
+
+static void on_sig(int sig)
+{
+ (void)sig;
+ g_stop = 1;
+}
+
+static void kiss_write_frame(int fd, const uint8_t *kiss, int len)
+{
+ uint8_t out[BCPR_MAXFLEN * 2 + 4];
+ int o = 0;
+ int i;
+
+ if (fd < 0 || !kiss || len <= 0) {
+ return;
+ }
+ out[o++] = 0xC0;
+ for (i = 0; i < len && o < (int)sizeof(out) - 2; i++) {
+ if (kiss[i] == 0xC0) {
+ out[o++] = 0xDB;
+ out[o++] = 0xDC;
+ } else if (kiss[i] == 0xDB) {
+ out[o++] = 0xDB;
+ out[o++] = 0xDD;
+ } else {
+ out[o++] = kiss[i];
+ }
+ }
+ out[o++] = 0xC0;
+ (void)write(fd, out, (size_t)o);
+}
+
+static bcpr_kiss_pty_t *g_pty;
+static int g_npty;
+
+static void on_rx(int dev_idx, const uint8_t *kiss, int len, void *ud)
+{
+ int i;
+ (void)ud;
+ for (i = 0; i < g_npty; i++) {
+ if (g_pty[i].idx == dev_idx) {
+ kiss_write_frame(g_pty[i].master_fd, kiss, len);
+ return;
+ }
+ }
+}
+
+static void kiss_feed(bcpr_engine_t *e, bcpr_kiss_pty_t *kp)
+{
+ static uint8_t acc[BCPR_MAX_DEVICES][BCPR_MAXFLEN + 8];
+ static int alen[BCPR_MAX_DEVICES];
+ static int esc[BCPR_MAX_DEVICES];
+ static int in_frame[BCPR_MAX_DEVICES];
+ uint8_t buf[512];
+ ssize_t n;
+ int i;
+ int di = kp->idx;
+
+ if (di < 0 || di >= BCPR_MAX_DEVICES) {
+ return;
+ }
+ n = read(kp->master_fd, buf, sizeof(buf));
+ if (n <= 0) {
+ return;
+ }
+ for (i = 0; i < (int)n; i++) {
+ uint8_t b = buf[i];
+ if (!in_frame[di]) {
+ if (b == 0xC0) {
+ in_frame[di] = 1;
+ alen[di] = 0;
+ esc[di] = 0;
+ }
+ continue;
+ }
+ if (esc[di]) {
+ esc[di] = 0;
+ if (b == 0xDC) {
+ b = 0xC0;
+ } else if (b == 0xDD) {
+ b = 0xDB;
+ }
+ if (alen[di] < (int)sizeof(acc[di])) {
+ acc[di][alen[di]++] = b;
+ }
+ continue;
+ }
+ if (b == 0xDB) {
+ esc[di] = 1;
+ continue;
+ }
+ if (b == 0xC0) {
+ if (alen[di] >= 2) {
+ int q;
+ int w;
+ for (w = 0; w < 70; w++) {
+ q = bcpr_engine_queue_kiss(e, di, acc[di], alen[di]);
+ if (q == 0) {
+ break;
+ }
+ usleep(50000);
+ }
+ if (q != 0) {
+ fprintf(stderr,
+ "max25-bcprd: queue_kiss drop bc%d len=%d (busy)\n",
+ di, alen[di]);
+ }
+ }
+ in_frame[di] = 0;
+ alen[di] = 0;
+ continue;
+ }
+ if (alen[di] < (int)sizeof(acc[di])) {
+ acc[di][alen[di]++] = b;
+ }
+ }
+}
+
+static void *kiss_thread(void *arg)
+{
+ bcpr_engine_t *e = (bcpr_engine_t *)arg;
+
+ while (!g_stop && !e->stop) {
+ struct pollfd pf[BCPR_MAX_DEVICES];
+ int nf = 0;
+ int map[BCPR_MAX_DEVICES];
+ int i;
+ int ret;
+ int backoff = 0;
+
+ for (i = 0; i < g_npty; i++) {
+ if (g_pty[i].master_fd < 0) {
+ continue;
+ }
+ pf[nf].fd = g_pty[i].master_fd;
+ pf[nf].events = POLLIN | POLLHUP | POLLERR;
+ map[nf] = i;
+ nf++;
+ }
+ if (nf == 0) {
+ usleep(50000);
+ continue;
+ }
+ ret = poll(pf, (nfds_t)nf, 50);
+ if (ret <= 0) {
+ continue;
+ }
+ for (i = 0; i < nf; i++) {
+ short re = pf[i].revents;
+ if (re & POLLIN) {
+ kiss_feed(e, &g_pty[map[i]]);
+ }
+ if (re & (POLLHUP | POLLERR | POLLNVAL)) {
+ backoff = 1;
+ }
+ }
+ if (backoff) {
+ usleep(50000);
+ }
+ }
+ return NULL;
+}
+
+static int ensure_dir(const char *path)
+{
+ char tmp[256];
+ char *p;
+ size_t len;
+
+ if (!path || !path[0]) {
+ return -1;
+ }
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ len = strlen(tmp);
+ if (len == 0) {
+ return -1;
+ }
+ if (tmp[len - 1] == '/') {
+ tmp[len - 1] = '\0';
+ }
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ (void)mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+ return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1;
+}
+
+int bcpr_daemon_run(bcpr_config_t *cfg, bcpr_kiss_pty_t *ptys, int npty,
+ bcpr_engine_t *engine, const bcpr_daemon_opts_t *opts)
+{
+ pthread_t thr;
+ int thr_ok = 0;
+ int i;
+ bcpr_engine_t local_engine;
+ bcpr_engine_t *e = engine;
+ int cal_mode;
+ int seconds;
+
+ if (!cfg || !opts) {
+ return 1;
+ }
+
+ g_stop = 0;
+ g_pty = ptys;
+ g_npty = npty;
+ cal_mode = opts->cal_mode;
+ seconds = opts->seconds;
+
+ signal(SIGINT, on_sig);
+ signal(SIGTERM, on_sig);
+
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (opts->cli_txd_bias >= 0) {
+ cfg->dev[i].txd_bias = opts->cli_txd_bias;
+ }
+ if (opts->cli_ptt_wd >= 0) {
+ cfg->dev[i].ptt_wd = opts->cli_ptt_wd;
+ }
+ if (opts->cli_ptt_wd_key_ms > 0) {
+ cfg->dev[i].ptt_wd_key_ms = opts->cli_ptt_wd_key_ms;
+ }
+ if (opts->cli_ptt_wd_pause_ms > 0) {
+ cfg->dev[i].ptt_wd_pause_ms = opts->cli_ptt_wd_pause_ms;
+ }
+ }
+
+ (void)ensure_dir(cfg->state_dir);
+
+ if (!opts->engine_preopened) {
+ e = &local_engine;
+ if (bcpr_engine_open(e, cfg) != 0) {
+ for (i = 0; i < npty; i++) {
+ bcpr_kiss_pty_close(&ptys[i]);
+ }
+ return 1;
+ }
+ }
+
+ e->run_seconds = seconds;
+ if (cal_mode != BCPR_CAL_OFF) {
+ bcpr_engine_set_cal(e, cal_mode);
+ }
+ bcpr_engine_set_rx(e, on_rx, NULL);
+
+ if (npty > 0 && cal_mode == BCPR_CAL_OFF &&
+ pthread_create(&thr, NULL, kiss_thread, e) == 0) {
+ thr_ok = 1;
+ }
+
+ if (cal_mode != BCPR_CAL_OFF && !cfg->dry_run) {
+ fprintf(stderr,
+ "bcprd: *** WATCH NOW *** cal PTT+tone for %d s — then unkey\n",
+ seconds);
+ }
+
+ (void)bcpr_engine_run(e);
+
+ g_stop = 1;
+ e->stop = 1;
+ if (thr_ok) {
+ pthread_join(thr, NULL);
+ }
+ bcpr_engine_close(e);
+ for (i = 0; i < npty; i++) {
+ bcpr_kiss_pty_close(&ptys[i]);
+ }
+ fprintf(stderr, "max25-bcprd: stopped\n");
+ return 0;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_engine.c b/stacks/max25-bcpr/src/bcpr_engine.c
new file mode 100644
index 0000000..2367817
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_engine.c
@@ -0,0 +1,652 @@
+/*
+ * Bit-clock engine: RT timer loop drives SER12 + HDLC for up to 2 devices.
+ * Userspace timing (not kernel hard-IRQ). See NOTICE.md.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_engine.h"
+#include "bcpr/bcpr_uart.h"
+#include "bcpr/bcpr_hdlc.h"
+#include "bcpr/bcpr_ser12.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+#include <stdint.h>
+
+#if defined(__linux__)
+#include <pthread.h>
+#include <sched.h>
+#include <sys/mman.h>
+#endif
+
+typedef struct {
+ bcpr_engine_t *e;
+ int idx;
+} rx_ctx_t;
+
+static unsigned now_us(void)
+{
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ /* Full monotonic µs — not nsec-within-second (gap spikes were misread). */
+ return (unsigned)(ts.tv_sec * 1000000ull + (unsigned long long)ts.tv_nsec / 1000ull);
+}
+
+/* Idle longer than this resets consecutive-burst counting (new session). */
+#define TOT_SESSION_RESET_US 120000000u
+
+static void tot_write_trip_file(const bcpr_engine_t *e, const bcpr_device_t *d,
+ const char *reason)
+{
+ char path[192];
+ FILE *f;
+
+ if (!e || !d || e->cfg.dry_run || e->cfg.state_dir[0] == '\0') {
+ return;
+ }
+ snprintf(path, sizeof(path), "%s/tot-trip-bc%d", e->cfg.state_dir, d->index);
+ f = fopen(path, "w");
+ if (!f) {
+ return;
+ }
+ fprintf(f,
+ "reason=%s\nburst_total=%d\nconsecutive=%d\ntripped=1\n"
+ "max_key_ms=%d\nmax_bursts=%d\nmax_consecutive=%d\n",
+ reason ? reason : "unknown", d->tot_burst_total, d->tot_consecutive,
+ d->cfg.tot_max_key_ms, d->cfg.tot_max_bursts,
+ d->cfg.tot_max_consecutive);
+ fclose(f);
+}
+
+static void tot_trip(bcpr_engine_t *e, bcpr_device_t *d, const char *reason)
+{
+ if (!d || d->tot_tripped) {
+ return;
+ }
+ d->tot_tripped = 1;
+ bcpr_ser12_force_unkey(&d->ser12);
+ bcpr_hdlc_abort_tx(&d->hdlc);
+ fprintf(stderr,
+ "bcpr: bc%d TOT TRIP reason=%s bursts=%d consecutive=%d\n",
+ d->index, reason ? reason : "unknown", d->tot_burst_total,
+ d->tot_consecutive);
+ tot_write_trip_file(e, d, reason);
+}
+
+static void tot_on_ptt_rise(bcpr_engine_t *e, bcpr_device_t *d, unsigned now_us)
+{
+ unsigned gap_us;
+
+ if (!d->cfg.tot_enabled || d->tot_tripped) {
+ return;
+ }
+ d->tot_key_start_us = now_us;
+ if (d->tot_last_off_us == 0u) {
+ d->tot_consecutive = 1;
+ return;
+ }
+ gap_us = now_us - d->tot_last_off_us;
+ if (gap_us >= (unsigned)d->cfg.tot_min_gap_ms * 1000u) {
+ if (gap_us < TOT_SESSION_RESET_US) {
+ d->tot_consecutive++;
+ } else {
+ d->tot_consecutive = 1;
+ d->tot_burst_total = 0;
+ }
+ }
+ if (d->tot_consecutive > d->cfg.tot_max_consecutive) {
+ tot_trip(e, d, "max_consecutive");
+ }
+}
+
+static void tot_on_ptt_fall(bcpr_engine_t *e, bcpr_device_t *d, unsigned now_us)
+{
+ if (!d->cfg.tot_enabled || d->tot_tripped) {
+ return;
+ }
+ d->tot_last_off_us = now_us;
+ d->tot_burst_total++;
+ if (d->tot_burst_total >= d->cfg.tot_max_bursts) {
+ tot_trip(e, d, "max_bursts");
+ }
+}
+
+static void tot_check_key_duration(bcpr_engine_t *e, bcpr_device_t *d,
+ unsigned now_us)
+{
+ unsigned elapsed_us;
+ unsigned max_us;
+
+ if (!d->cfg.tot_enabled || d->tot_tripped || !d->ptt_was) {
+ return;
+ }
+ if (d->tot_key_start_us == 0u) {
+ d->tot_key_start_us = now_us;
+ return;
+ }
+ max_us = (unsigned)d->cfg.tot_max_key_ms * 1000u;
+ elapsed_us = now_us - d->tot_key_start_us;
+ if (elapsed_us >= max_us) {
+ bcpr_ser12_force_unkey(&d->ser12);
+ bcpr_hdlc_abort_tx(&d->hdlc);
+ tot_on_ptt_fall(e, d, now_us);
+ tot_trip(e, d, "max_key");
+ }
+}
+
+static void on_frame_ctx(const uint8_t *kiss, int len, void *ud)
+{
+ rx_ctx_t *ctx = (rx_ctx_t *)ud;
+ if (ctx && ctx->e && ctx->e->on_rx) {
+ ctx->e->on_rx(ctx->idx, kiss, len, ctx->e->on_rx_ud);
+ }
+}
+
+void bcpr_engine_set_rx(bcpr_engine_t *e, bcpr_rx_fn fn, void *ud)
+{
+ if (!e) {
+ return;
+ }
+ e->on_rx = fn;
+ e->on_rx_ud = ud;
+}
+
+int bcpr_engine_queue_kiss(bcpr_engine_t *e, int dev_idx, const uint8_t *kiss,
+ int len)
+{
+ int i;
+ static unsigned last_tx_us;
+ unsigned now;
+ unsigned elapsed;
+
+ if (!e || !kiss) {
+ return -1;
+ }
+ now = now_us();
+ if (last_tx_us != 0u) {
+ elapsed = now - last_tx_us;
+ if (elapsed < 1500000u) {
+ usleep(1500000u - elapsed);
+ }
+ }
+ last_tx_us = now_us();
+ for (i = 0; i < e->n; i++) {
+ if (e->dev[i].index == dev_idx) {
+ if (e->dev[i].tot_tripped) {
+ fprintf(stderr, "bcpr: bc%d TOT drop queue_kiss (tripped)\n",
+ dev_idx);
+ return -1;
+ }
+ return bcpr_hdlc_queue_kiss(&e->dev[i].hdlc, kiss, len);
+ }
+ }
+ return -1;
+}
+
+int bcpr_engine_open(bcpr_engine_t *e, const bcpr_config_t *cfg)
+{
+ int i;
+ int n = 0;
+
+ if (!e || !cfg) {
+ return -1;
+ }
+ memset(e, 0, sizeof(*e));
+ e->cfg = *cfg;
+ e->stop = 0;
+ e->run_seconds = 0;
+ bcpr_uart_set_dry_run(cfg->dry_run);
+
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ bcpr_device_t *d;
+ bcpr_channel_t ch;
+ unsigned baud = 1200;
+ int opt_dcd = 0;
+
+ if (!cfg->dev[i].enabled) {
+ continue;
+ }
+ d = &e->dev[n];
+ memset(d, 0, sizeof(*d));
+ d->cfg = cfg->dev[i];
+ d->index = i;
+ d->running = 0;
+
+ if (bcpr_lock_acquire(&d->lock, &d->cfg, cfg->dry_run) != 0) {
+ fprintf(stderr, "bcpr: lock failed for max25e0:bc%d\n", i);
+ bcpr_engine_close(e);
+ return -1;
+ }
+
+ bcpr_ser12_set_mode(&d->ser12, d->cfg.mode, &baud);
+ if (d->cfg.baud) {
+ baud = d->cfg.baud;
+ }
+ opt_dcd = d->ser12.opt_dcd;
+ bcpr_ser12_init(&d->ser12, baud, opt_dcd);
+ bcpr_ser12_set_ptt_wd(&d->ser12, d->cfg.ptt_wd, d->cfg.ptt_wd_key_ms,
+ d->cfg.ptt_wd_pause_ms);
+
+ ch.tx_delay = d->cfg.tx_delay;
+ ch.tx_tail = d->cfg.tx_tail;
+ ch.slottime = d->cfg.slottime;
+ ch.ppersist = d->cfg.ppersist;
+ ch.fulldup = d->cfg.fulldup;
+ bcpr_hdlc_init(&d->hdlc, (int)baud, &ch);
+
+ if (!cfg->dry_run) {
+ if (bcpr_uart_ioperm(d->cfg.iobase, 1) != 0) {
+ fprintf(stderr, "bcpr: ioperm failed 0x%x\n", d->cfg.iobase);
+ bcpr_engine_close(e);
+ return -1;
+ }
+ bcpr_uart_set_divisor(d->cfg.iobase, 115200u / 100u / 8u);
+ bcpr_uart_open_ser12(d->cfg.iobase);
+ /*
+ * txd_bias=steady: assert UART break after open (LCR.SB).
+ * THR framing cannot hold DC-steady TXD; break ≈ TFPCX +12 V.
+ * Default remains pulse (Sailer THR 0x00). MCR unchanged.
+ */
+ if (d->cfg.txd_bias == BCPR_TXD_STEADY) {
+ bcpr_uart_set_break(d->cfg.iobase, 1);
+ d->break_set = 1;
+ }
+ }
+ d->running = 1;
+ n++;
+ }
+ e->n = n;
+ if (n == 0) {
+ fprintf(stderr, "bcpr: no enabled devices in config\n");
+ return -1;
+ }
+ fprintf(stderr, "bcpr: open max25e0 (%d device%s)%s\n", n,
+ n == 1 ? "" : "s", cfg->dry_run ? " [dry-run]" : "");
+ for (i = 0; i < n; i++) {
+ const bcpr_device_t *d = &e->dev[i];
+ fprintf(stderr,
+ "bcpr: bc%d ptt_wd=%s key_ms=%d pause_ms=%d txd_bias=%s tot=%s "
+ "max_key_ms=%d max_consecutive=%d max_bursts=%d\n",
+ d->index, d->cfg.ptt_wd ? "on" : "off", d->cfg.ptt_wd_key_ms,
+ d->cfg.ptt_wd_pause_ms,
+ d->cfg.txd_bias == BCPR_TXD_STEADY ? "steady" : "pulse",
+ d->cfg.tot_enabled ? "on" : "off", d->cfg.tot_max_key_ms,
+ d->cfg.tot_max_consecutive, d->cfg.tot_max_bursts);
+ }
+ return 0;
+}
+
+void bcpr_engine_close(bcpr_engine_t *e)
+{
+ int i;
+ if (!e) {
+ return;
+ }
+ e->stop = 1;
+ for (i = 0; i < e->n; i++) {
+ bcpr_device_t *d = &e->dev[i];
+ if (d->running && !e->cfg.dry_run) {
+ if (d->break_set) {
+ bcpr_uart_set_break(d->cfg.iobase, 0);
+ d->break_set = 0;
+ }
+ bcpr_uart_close_ser12(d->cfg.iobase);
+ (void)bcpr_uart_ioperm(d->cfg.iobase, 0);
+ }
+ bcpr_lock_release(&d->lock);
+ d->running = 0;
+ }
+ e->n = 0;
+}
+
+static void try_rt(void)
+{
+#if defined(__linux__)
+ struct sched_param sp;
+ cpu_set_t set;
+ int rc;
+ memset(&sp, 0, sizeof(sp));
+ /* Pin pages — fault during PTT = multi-ms TXD gap → pump collapse. */
+ if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) {
+ fprintf(stderr, "bcpr: mlockall failed errno=%d (page faults risk gaps)\n",
+ errno);
+ }
+ /* Prefer one CPU — migration mid-PTT causes multi-ms gaps. */
+ CPU_ZERO(&set);
+ CPU_SET(0, &set);
+ if (sched_setaffinity(0, sizeof(set), &set) != 0) {
+ fprintf(stderr, "bcpr: sched_setaffinity(0) errno=%d\n", errno);
+ }
+ /*
+ * High FIFO while bit-clocking — charge-pump cannot tolerate ms preemption.
+ * Needs root or CAP_SYS_NICE; log hard if denied (S1++ gaps often follow).
+ */
+ sp.sched_priority = 80;
+ rc = sched_setscheduler(0, SCHED_FIFO, &sp);
+ if (rc != 0) {
+ sp.sched_priority = 50;
+ rc = pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp);
+ }
+ if (rc != 0) {
+ sp.sched_priority = 10;
+ rc = pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp);
+ }
+ if (rc != 0) {
+ fprintf(stderr,
+ "bcpr: SCHED_FIFO failed errno=%d — expect max_gap multi-ms "
+ "(need root/CAP_SYS_NICE for bcprd)\n",
+ errno);
+ } else {
+ fprintf(stderr, "bcpr: SCHED_FIFO ok prio=%d cpu0\n", sp.sched_priority);
+ }
+#endif
+}
+
+void bcpr_engine_set_cal(bcpr_engine_t *e, int cal_mode)
+{
+ int i;
+ if (!e) {
+ return;
+ }
+ if (cal_mode < BCPR_CAL_OFF || cal_mode > BCPR_CAL_ALT) {
+ cal_mode = BCPR_CAL_OFF;
+ }
+ e->cal_mode = cal_mode;
+ for (i = 0; i < e->n; i++) {
+ bcpr_ser12_set_cal(&e->dev[i].ser12, cal_mode);
+ }
+}
+
+static int64_t now_ns(void)
+{
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (int64_t)ts.tv_sec * 1000000000LL + (int64_t)ts.tv_nsec;
+}
+
+static void emit_tx_telemetry(const bcpr_engine_t *e, bcpr_device_t *d,
+ int64_t ptt_off_ns)
+{
+ char path[192];
+ FILE *f;
+ int64_t dur_ns;
+ unsigned mean_gap = 0;
+ double thr_rate = 0.0;
+ double ptt_ms;
+
+ if (!d || !e) {
+ return;
+ }
+ dur_ns = ptt_off_ns - d->ptt_on_ns;
+ if (dur_ns < 0) {
+ dur_ns = 0;
+ }
+ ptt_ms = (double)dur_ns / 1.0e6;
+ if (d->tick_count > 0) {
+ mean_gap = (unsigned)(d->gap_sum_us / d->tick_count);
+ }
+ if (ptt_ms > 0.5) {
+ thr_rate = (double)d->thr_writes * 1000.0 / ptt_ms;
+ }
+ fprintf(stderr,
+ "bcpr: tx-telemetry bc%d ptt_ms=%.1f thr_writes=%u thr_rate=%.0f "
+ "max_tick_gap_us=%u mean_gap_us=%u gaps_gt_2x=%u baud_us=%u\n",
+ d->index, ptt_ms, d->thr_writes, thr_rate, d->max_tick_gap_us,
+ mean_gap, d->gaps_gt_2x, d->ser12.baud_us);
+ if (e->cfg.dry_run || e->cfg.state_dir[0] == '\0') {
+ return;
+ }
+ snprintf(path, sizeof(path), "%s/tx-last-bc%d", e->cfg.state_dir, d->index);
+ f = fopen(path, "w");
+ if (!f) {
+ return;
+ }
+ fprintf(f,
+ "ptt_on_ns=%lld\nptt_off_ns=%lld\nptt_ms=%.1f\nthr_writes=%u\n"
+ "thr_rate=%.0f\nmax_tick_gap_us=%u\nmean_gap_us=%u\n"
+ "gaps_gt_2x=%u\nbaud_us=%u\ntick_count=%u\n",
+ (long long)d->ptt_on_ns, (long long)ptt_off_ns, ptt_ms,
+ d->thr_writes, thr_rate, d->max_tick_gap_us, mean_gap,
+ d->gaps_gt_2x, d->ser12.baud_us, d->tick_count);
+ fclose(f);
+}
+
+static unsigned tx_baud_div(const bcpr_device_t *d)
+{
+ unsigned baud = d->ser12.baud ? d->ser12.baud : 1200u;
+ unsigned div = (115200u / 8u) / baud;
+ return div ? div : 1u;
+}
+
+static void tick_device(bcpr_engine_t *e, bcpr_device_t *d, rx_ctx_t *ctx)
+{
+ int cts = 0;
+ int mcr = 0x0d;
+ int do_thr = 0;
+ unsigned t = now_us();
+ int ptt;
+ int keyed = d->ptt_was; /* already in TX — keep path minimal for TXD pump */
+
+ if (!e->cfg.dry_run && !keyed) {
+ unsigned char msr = bcpr_uart_msr(d->cfg.iobase);
+ cts = (msr & 0x10) ? 1 : 0;
+ if (d->ser12.opt_dcd > 0) {
+ d->hdlc.dcd = (msr & 0x80) ? 1 : 0;
+ } else if (d->ser12.opt_dcd < 0) {
+ d->hdlc.dcd = (msr & 0x80) ? 0 : 1;
+ }
+ }
+
+ /* S0: tick-gap while PTT keyed (full monotonic µs; unsigned wrap OK). */
+ if (d->ptt_was && d->last_tick_us) {
+ unsigned gap = t - d->last_tick_us;
+ unsigned lim2;
+ if (gap > d->max_tick_gap_us) {
+ d->max_tick_gap_us = gap;
+ }
+ d->gap_sum_us += gap;
+ d->tick_count++;
+ lim2 = d->ser12.baud_us * 2u;
+ if (lim2 < 2u) {
+ lim2 = 2u;
+ }
+ if (gap > lim2) {
+ d->gaps_gt_2x++;
+ }
+ }
+ d->last_tick_us = t;
+
+ bcpr_ser12_tick(&d->ser12, &d->hdlc, cts, &mcr, &do_thr, t);
+ ptt = d->ser12.ptt_hw ? 1 : 0;
+
+ if (d->tot_tripped) {
+ bcpr_ser12_force_unkey(&d->ser12);
+ bcpr_hdlc_abort_tx(&d->hdlc);
+ ptt = 0;
+ mcr = 0x0d;
+ } else {
+ tot_check_key_duration(e, d, t);
+ ptt = d->ser12.ptt_hw ? 1 : 0;
+ }
+
+ if (ptt && !d->ptt_was) {
+ tot_on_ptt_rise(e, d, t);
+ d->ptt_on_ns = now_ns();
+ d->thr_writes = 0;
+ d->max_tick_gap_us = 0;
+ d->gap_sum_us = 0;
+ d->tick_count = 0;
+ d->gaps_gt_2x = 0;
+ d->last_tick_us = t;
+ d->tx_div_set = 0;
+ } else if (!ptt && d->ptt_was) {
+ if (!d->tot_tripped) {
+ tot_on_ptt_fall(e, d, t);
+ }
+ if (!e->cfg.dry_run) {
+ /* Match baycom_ser_fdx: idle divisor only on PTT fall. */
+ bcpr_uart_set_divisor(d->cfg.iobase, 115200u / 100u / 8u);
+ d->tx_div_set = 0;
+ }
+ emit_tx_telemetry(e, d, now_ns());
+ }
+ d->ptt_was = ptt;
+
+ if (!e->cfg.dry_run) {
+ /*
+ * S1++: set baud_uartdiv once on PTT rise (kernel ser12_fdx).
+ * Re-writing divisor every bit toggles DLAB mid-shift → intermittent
+ * TXD charge-pump starve while MCR RTS still keys (MCR PASS / no RF).
+ */
+ if (ptt && !d->tx_div_set) {
+ bcpr_uart_set_divisor(d->cfg.iobase, tx_baud_div(d));
+ d->tx_div_set = 1;
+ }
+ /* Kernel order: THR 0x00 first (charge-pump), then MCR bit+PTT.
+ * txd_bias=steady: skip THR — break already holds TXD SPACE;
+ * pulse is Sailer default (framing edges feed BayCom pump). */
+ if (d->cfg.txd_bias == BCPR_TXD_STEADY) {
+ if (!d->break_set) {
+ bcpr_uart_set_break(d->cfg.iobase, 1);
+ d->break_set = 1;
+ }
+ if (ptt) {
+ d->thr_writes++; /* count pump-equivalent ticks for telem */
+ }
+ } else {
+ if (d->break_set) {
+ bcpr_uart_set_break(d->cfg.iobase, 0);
+ d->break_set = 0;
+ }
+ if (do_thr) {
+ bcpr_uart_thr00(d->cfg.iobase);
+ if (ptt) {
+ d->thr_writes++;
+ }
+ }
+ }
+ bcpr_uart_mcr(d->cfg.iobase, (unsigned char)mcr);
+ }
+
+ /* Defer HDLC RX drain while keyed — keeps bit deadline tight (S1+). */
+ if (!ptt) {
+ ctx->e = e;
+ ctx->idx = d->index;
+ bcpr_hdlc_receiver(&d->hdlc, on_frame_ctx, ctx);
+ }
+}
+
+/* Publish Soft-/hard-DCD for RX-before-TX gates (state_dir/dcd-bcN).
+ * Also refresh rx-activity-bcN whenever Soft-DCD is asserted so smoke/L3
+ * can delete-and-rewait without a permanent false miss when dcd flickers. */
+static void publish_dcd_status(const bcpr_engine_t *e)
+{
+ int i;
+ char path[192];
+ FILE *f;
+
+ if (!e || e->cfg.dry_run || e->cfg.state_dir[0] == '\0') {
+ return;
+ }
+ for (i = 0; i < e->n; i++) {
+ const bcpr_device_t *d = &e->dev[i];
+ int dcd = d->hdlc.dcd ? 1 : 0;
+ snprintf(path, sizeof(path), "%s/dcd-bc%d", e->cfg.state_dir, d->index);
+ f = fopen(path, "w");
+ if (f) {
+ fprintf(f, "dcd=%d\n", dcd);
+ fclose(f);
+ }
+ snprintf(path, sizeof(path), "%s/rx-activity-bc%d", e->cfg.state_dir,
+ d->index);
+ f = fopen(path, "w");
+ if (f) {
+ /* Latch: dcd=1 → activity; dcd=0 clears so L3 needs live Soft-DCD. */
+ fprintf(f, "rx_activity=%d\n", dcd ? 1 : 0);
+ fclose(f);
+ }
+ }
+}
+
+int bcpr_engine_run(bcpr_engine_t *e)
+{
+ struct timespec next;
+ rx_ctx_t ctx;
+ time_t t0;
+ unsigned period_ns;
+ unsigned tick = 0;
+ int64_t bit_deadline_ns = 0;
+
+ if (!e || e->n <= 0) {
+ return -1;
+ }
+ try_rt();
+ t0 = time(NULL);
+ period_ns = e->dev[0].ser12.baud_us * 1000u;
+ if (period_ns < 100000u) {
+ period_ns = 833000u;
+ }
+
+ clock_gettime(CLOCK_MONOTONIC, &next);
+ while (!e->stop) {
+ int i;
+ int any_ptt = 0;
+ unsigned baud_us = e->dev[0].ser12.baud_us;
+
+ for (i = 0; i < e->n; i++) {
+ tick_device(e, &e->dev[i], &ctx);
+ if (e->dev[i].ser12.ptt_hw || e->dev[i].ptt_was) {
+ any_ptt = 1;
+ }
+ }
+ /* ~100 ms at 1200 baud — skip file I/O while PTT (stretches TXD gaps). */
+ if ((++tick % 120u) == 0u && !any_ptt) {
+ publish_dcd_status(e);
+ }
+
+ /*
+ * S1/S1+: while PTT, absolute bit deadline + busy-spin (not
+ * nanosleep). THRE wait alone stacks with tick work → ~2× baud gaps.
+ * Idle RX keeps absolute nanosleep schedule.
+ */
+ if (any_ptt && !e->cfg.dry_run) {
+ int64_t now;
+
+ if (baud_us < 200u) {
+ baud_us = 200u;
+ }
+ now = now_ns();
+ if (bit_deadline_ns == 0 ||
+ now > bit_deadline_ns + (int64_t)baud_us * 1000LL * 4) {
+ /* PTT edge / large slip — resync. */
+ bit_deadline_ns = now + (int64_t)baud_us * 1000LL;
+ } else {
+ bit_deadline_ns += (int64_t)baud_us * 1000LL;
+ }
+ /*
+ * Pure busy-spin to absolute bit deadline — no nanosleep, no
+ * wait_thre syscalls (those stacked gaps and starved the pump).
+ * Target: thr_writes ≈ baud for whole PTT; max_gap < ~2× baud_us.
+ */
+ while (now_ns() < bit_deadline_ns) {
+ }
+ clock_gettime(CLOCK_MONOTONIC, &next);
+ } else {
+ bit_deadline_ns = 0;
+ next.tv_nsec += (long)period_ns;
+ while (next.tv_nsec >= 1000000000L) {
+ next.tv_nsec -= 1000000000L;
+ next.tv_sec++;
+ }
+ while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next,
+ NULL) == EINTR) {
+ }
+ }
+ if (e->run_seconds > 0 && (time(NULL) - t0) >= e->run_seconds) {
+ break;
+ }
+ }
+ return 0;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_hdlc.c b/stacks/max25-bcpr/src/bcpr_hdlc.c
new file mode 100644
index 0000000..09c8d32
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_hdlc.c
@@ -0,0 +1,293 @@
+#include "bcpr/bcpr_hdlc.h"
+#include "bcpr/bcpr_crc.h"
+
+#include <string.h>
+#include <stdlib.h>
+
+static int hbuf_empty(const bcpr_hbuf_t *hb)
+{
+ return hb->rd == hb->wr;
+}
+
+static int hbuf_full(const bcpr_hbuf_t *hb)
+{
+ return !((BCPR_HDLC_BUF - 1 + hb->rd - hb->wr) % BCPR_HDLC_BUF);
+}
+
+static void hbuf_put(bcpr_hbuf_t *hb, uint16_t val)
+{
+ unsigned newp = (hb->wr + 1) % BCPR_HDLC_BUF;
+ if (newp != hb->rd) {
+ hb->buf[hb->wr] = val;
+ hb->wr = newp;
+ }
+}
+
+static uint16_t hbuf_get(bcpr_hbuf_t *hb)
+{
+ uint16_t val;
+ if (hb->rd == hb->wr) {
+ return 0;
+ }
+ val = hb->buf[hb->rd];
+ hb->rd = (hb->rd + 1) % BCPR_HDLC_BUF;
+ return val;
+}
+
+#define tenms_to_2flags(h, tenms) (((tenms) * (h)->bitrate) / 100 / 16)
+
+void bcpr_hdlc_init(bcpr_hdlc_t *h, int bitrate, const bcpr_channel_t *ch)
+{
+ memset(h, 0, sizeof(*h));
+ h->bitrate = bitrate > 0 ? bitrate : 1200;
+ if (ch) {
+ h->ch = *ch;
+ } else {
+ h->ch.tx_delay = 35;
+ h->ch.tx_tail = 2;
+ h->ch.slottime = 10;
+ h->ch.ppersist = 40;
+ h->ch.fulldup = 0;
+ }
+ h->slotcnt = 1;
+ h->rx_bp = h->rx_buffer;
+ h->tx_bp = h->tx_buffer;
+}
+
+void bcpr_hdlc_putbits(bcpr_hdlc_t *h, unsigned bits)
+{
+ hbuf_put(&h->rx_hbuf, (uint16_t)(bits & 0xffff));
+}
+
+unsigned bcpr_hdlc_getbits(bcpr_hdlc_t *h)
+{
+ if (hbuf_empty(&h->tx_hbuf)) {
+ h->ptt = 0;
+ return 0;
+ }
+ return hbuf_get(&h->tx_hbuf);
+}
+
+int bcpr_hdlc_ptt(const bcpr_hdlc_t *h)
+{
+ return h->ptt;
+}
+
+void bcpr_hdlc_abort_tx(bcpr_hdlc_t *h)
+{
+ if (!h) {
+ return;
+ }
+ h->ptt = 0;
+ h->tx_state = 0;
+ h->numflags = 0;
+ h->tx_bitstream = 0;
+ h->tx_bitbuf = 0;
+ h->tx_numbits = 0;
+ h->tx_len = 0;
+ h->tx_bp = h->tx_buffer;
+ h->have_pending = 0;
+ h->pending_len = 0;
+ h->tx_hbuf.rd = h->tx_hbuf.wr;
+}
+
+int bcpr_hdlc_queue_kiss(bcpr_hdlc_t *h, const uint8_t *kiss, int len)
+{
+ if (!kiss || len < 2 || len - 1 > BCPR_MAXFLEN) {
+ return -1;
+ }
+ if (h->have_pending || h->ptt) {
+ return -1;
+ }
+ /* strip KISS command byte */
+ memcpy(h->pending, kiss + 1, (size_t)(len - 1));
+ h->pending_len = len - 1;
+ h->have_pending = 1;
+ return 0;
+}
+
+static int hdlc_rx_add_bytes(bcpr_hdlc_t *h, unsigned bits, int num)
+{
+ int added = 0;
+ while (h->rx_state && num >= 8) {
+ if (h->rx_len >= (int)sizeof(h->rx_buffer)) {
+ h->rx_state = 0;
+ return 0;
+ }
+ *h->rx_bp++ = (uint8_t)(bits >> (32 - num));
+ h->rx_len++;
+ num -= 8;
+ added += 8;
+ }
+ return added;
+}
+
+static void hdlc_rx_flag(bcpr_hdlc_t *h,
+ void (*on_frame)(const uint8_t *kiss, int len, void *ud),
+ void *ud)
+{
+ uint8_t kiss[BCPR_MAXFLEN + 3];
+ int pkt_len;
+ if (h->rx_len < 4) {
+ return;
+ }
+ if (!bcpr_check_crc_ccitt(h->rx_buffer, h->rx_len)) {
+ return;
+ }
+ pkt_len = h->rx_len - 2 + 1;
+ kiss[0] = 0;
+ memcpy(kiss + 1, h->rx_buffer, (size_t)(pkt_len - 1));
+ if (on_frame) {
+ on_frame(kiss, pkt_len, ud);
+ }
+}
+
+void bcpr_hdlc_receiver(bcpr_hdlc_t *h,
+ void (*on_frame)(const uint8_t *kiss, int len, void *ud),
+ void *ud)
+{
+ int i;
+ unsigned mask1, mask2, mask3, mask4, mask5, mask6, word;
+
+ while (!hbuf_empty(&h->rx_hbuf)) {
+ word = hbuf_get(&h->rx_hbuf);
+ h->bitstream >>= 16;
+ h->bitstream |= word << 16;
+ h->bitbuf >>= 16;
+ h->bitbuf |= word << 16;
+ h->numbits += 16;
+ for (i = 15, mask1 = 0x1fc00, mask2 = 0x1fe00, mask3 = 0x0fc00,
+ mask4 = 0x1f800, mask5 = 0xf800, mask6 = 0xffff;
+ i >= 0; i--, mask1 <<= 1, mask2 <<= 1, mask3 <<= 1, mask4 <<= 1,
+ mask5 <<= 1, mask6 = (mask6 << 1) | 1) {
+ if ((h->bitstream & mask1) == mask1) {
+ h->rx_state = 0;
+ } else if ((h->bitstream & mask2) == mask3) {
+ if (h->rx_state) {
+ hdlc_rx_add_bytes(h, h->bitbuf << (8 + i),
+ h->numbits - 8 - i);
+ hdlc_rx_flag(h, on_frame, ud);
+ }
+ h->rx_len = 0;
+ h->rx_bp = h->rx_buffer;
+ h->rx_state = 1;
+ h->numbits = i;
+ } else if ((h->bitstream & mask4) == mask5) {
+ h->numbits--;
+ h->bitbuf = (h->bitbuf & (~mask6)) |
+ ((h->bitbuf & mask6) << 1);
+ }
+ }
+ h->numbits -= hdlc_rx_add_bytes(h, h->bitbuf, h->numbits);
+ }
+}
+
+void bcpr_hdlc_transmitter(bcpr_hdlc_t *h)
+{
+ unsigned mask1, mask2, mask3;
+ int i;
+
+ for (;;) {
+ if (h->tx_numbits >= 16) {
+ if (hbuf_full(&h->tx_hbuf)) {
+ return;
+ }
+ hbuf_put(&h->tx_hbuf, (uint16_t)(h->tx_bitbuf & 0xffff));
+ h->tx_bitbuf >>= 16;
+ h->tx_numbits -= 16;
+ }
+ switch (h->tx_state) {
+ default:
+ return;
+ case 0:
+ case 1:
+ if (h->numflags) {
+ h->numflags--;
+ h->tx_bitbuf |= 0x7e7e << h->tx_numbits;
+ h->tx_numbits += 16;
+ break;
+ }
+ if (h->tx_state == 1) {
+ return;
+ }
+ if (!h->have_pending) {
+ int flgs = tenms_to_2flags(h, h->ch.tx_tail);
+ if (flgs < 2) {
+ flgs = 2;
+ }
+ h->tx_state = 1;
+ h->numflags = flgs;
+ break;
+ }
+ /* pending[] holds up to BCPR_MAXFLEN; reject only oversize / empty. */
+ if (h->pending_len > BCPR_MAXFLEN || h->pending_len < 2) {
+ h->have_pending = 0;
+ h->tx_state = 0;
+ h->numflags = 1;
+ break;
+ }
+ memcpy(h->tx_buffer, h->pending, (size_t)h->pending_len);
+ h->have_pending = 0;
+ h->tx_bp = h->tx_buffer;
+ bcpr_append_crc_ccitt(h->tx_buffer, h->pending_len);
+ h->tx_len = h->pending_len + 2;
+ h->tx_state = 2;
+ h->tx_bitstream = 0;
+ break;
+ case 2:
+ if (!h->tx_len) {
+ h->tx_state = 0;
+ h->numflags = 1;
+ break;
+ }
+ h->tx_len--;
+ h->tx_bitbuf |= *h->tx_bp << h->tx_numbits;
+ h->tx_bitstream >>= 8;
+ h->tx_bitstream |= (*h->tx_bp++) << 16;
+ mask1 = 0x1f000;
+ mask2 = 0x10000;
+ mask3 = 0xffffffffu >> (31 - h->tx_numbits);
+ h->tx_numbits += 8;
+ for (i = 0; i < 8;
+ i++, mask1 <<= 1, mask2 <<= 1, mask3 = (mask3 << 1) | 1) {
+ if ((h->tx_bitstream & mask1) != mask1) {
+ continue;
+ }
+ h->tx_bitstream &= ~mask2;
+ h->tx_bitbuf = (h->tx_bitbuf & mask3) |
+ ((h->tx_bitbuf & (~mask3)) << 1);
+ h->tx_numbits++;
+ mask3 = (mask3 << 1) | 1;
+ }
+ break;
+ }
+ }
+}
+
+void bcpr_hdlc_arbitrate(bcpr_hdlc_t *h)
+{
+ if (h->ptt || !h->have_pending) {
+ return;
+ }
+ if (h->ch.fulldup) {
+ h->tx_state = 0;
+ h->numflags = tenms_to_2flags(h, h->ch.tx_delay);
+ h->tx_bitbuf = h->tx_bitstream = 0;
+ h->tx_numbits = 0;
+ bcpr_hdlc_transmitter(h);
+ h->ptt = 1;
+ return;
+ }
+ if (!h->dcd && (--h->slotcnt <= 0)) {
+ h->slotcnt = h->ch.slottime;
+ if ((rand() % 256) > h->ch.ppersist) {
+ return;
+ }
+ h->tx_state = 0;
+ h->numflags = tenms_to_2flags(h, h->ch.tx_delay);
+ h->tx_bitbuf = h->tx_bitstream = 0;
+ h->tx_numbits = 0;
+ bcpr_hdlc_transmitter(h);
+ h->ptt = 1;
+ }
+}
diff --git a/stacks/max25-bcpr/src/bcpr_kiss.c b/stacks/max25-bcpr/src/bcpr_kiss.c
new file mode 100644
index 0000000..495f4d1
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_kiss.c
@@ -0,0 +1,127 @@
+/*
+ * KISS PTY bridge for max25-bcpr (HyBBX attach via kiss_link symlink).
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_kiss.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <grp.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#if defined(__linux__)
+#include <pty.h>
+#elif defined(__FreeBSD__)
+#include <libutil.h>
+#else
+#include <util.h>
+#endif
+
+static int ensure_dir(const char *path)
+{
+ char tmp[256];
+ char *p;
+ size_t len;
+
+ if (!path || !path[0]) {
+ return -1;
+ }
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ len = strlen(tmp);
+ if (len == 0) {
+ return -1;
+ }
+ if (tmp[len - 1] == '/') {
+ tmp[len - 1] = '\0';
+ }
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ (void)mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+ return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1;
+}
+
+int bcpr_kiss_pty_open(bcpr_kiss_pty_t *kp, int idx, const char *link_path,
+ const char *state_dir)
+{
+ int master = -1, slave = -1;
+ char slave_name[128];
+ char dir[128];
+ const char *slash;
+
+ memset(kp, 0, sizeof(*kp));
+ kp->idx = idx;
+ kp->master_fd = -1;
+ kp->slave_fd = -1;
+ snprintf(kp->link_path, sizeof(kp->link_path), "%s", link_path);
+
+ slash = strrchr(link_path, '/');
+ if (slash && slash > link_path) {
+ size_t n = (size_t)(slash - link_path);
+ if (n >= sizeof(dir)) {
+ n = sizeof(dir) - 1;
+ }
+ memcpy(dir, link_path, n);
+ dir[n] = '\0';
+ (void)ensure_dir(dir);
+ } else {
+ (void)ensure_dir(state_dir);
+ }
+
+ if (openpty(&master, &slave, slave_name, NULL, NULL) != 0) {
+ perror("max25-bcpr openpty");
+ return -1;
+ }
+ if (fchmod(slave, 0660) != 0) {
+ perror("max25-bcpr fchmod kiss slave");
+ } else {
+ struct group *gr = getgrnam("dialout");
+ if (gr == NULL) {
+ gr = getgrnam("uucp");
+ }
+ if (gr == NULL) {
+ gr = getgrnam("tty");
+ }
+ if (gr != NULL && fchown(slave, (uid_t)-1, gr->gr_gid) != 0) {
+ perror("max25-bcpr fchown kiss slave");
+ }
+ }
+ unlink(link_path);
+ if (symlink(slave_name, link_path) != 0) {
+ perror("max25-bcpr symlink kiss_link");
+ close(slave);
+ close(master);
+ return -1;
+ }
+ fcntl(master, F_SETFL, O_NONBLOCK);
+ kp->master_fd = master;
+ close(slave);
+ kp->slave_fd = -1;
+ fprintf(stderr, "max25-bcpr: max25e0:bc%d KISS → %s -> %s\n", idx, link_path,
+ slave_name);
+ return 0;
+}
+
+void bcpr_kiss_pty_close(bcpr_kiss_pty_t *kp)
+{
+ if (!kp) {
+ return;
+ }
+ if (kp->slave_fd >= 0) {
+ close(kp->slave_fd);
+ kp->slave_fd = -1;
+ }
+ if (kp->master_fd >= 0) {
+ close(kp->master_fd);
+ kp->master_fd = -1;
+ }
+ if (kp->link_path[0]) {
+ unlink(kp->link_path);
+ }
+}
diff --git a/stacks/max25-bcpr/src/bcpr_lock.c b/stacks/max25-bcpr/src/bcpr_lock.c
new file mode 100644
index 0000000..b179323
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_lock.c
@@ -0,0 +1,202 @@
+/*
+ * Exclusive lock on owned COM ports only (flock + optional setserial uart none).
+ * Pass real IRQ — refuse mismatch. See vault IRQ/lock contract.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_lock.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+static void tty_basename(const char *serial, char *out, size_t out_sz)
+{
+ const char *p = strrchr(serial, '/');
+ p = p ? p + 1 : serial;
+ snprintf(out, out_sz, "%s", p);
+}
+
+int bcpr_lock_verify_irq(const char *serial, unsigned expect_irq,
+ unsigned *got_irq, unsigned *got_io)
+{
+ char name[64];
+ char path[128];
+ char buf[64];
+ FILE *f;
+ unsigned irq = 0;
+ unsigned io = 0;
+
+ if (!serial || !serial[0]) {
+ return -1;
+ }
+ tty_basename(serial, name, sizeof(name));
+ snprintf(path, sizeof(path), "/sys/class/tty/%s/irq", name);
+ f = fopen(path, "r");
+ if (f) {
+ if (fgets(buf, sizeof(buf), f)) {
+ irq = (unsigned)strtoul(buf, NULL, 0);
+ }
+ fclose(f);
+ } else {
+ /* Fallback: setserial -g (best-effort). */
+ char cmd[256];
+ FILE *p;
+ snprintf(cmd, sizeof(cmd), "setserial -g %s 2>/dev/null", serial);
+ p = popen(cmd, "r");
+ if (!p) {
+ return -1;
+ }
+ if (fgets(buf, sizeof(buf), p)) {
+ char *ip = strstr(buf, "IRQ: ");
+ char *pp = strstr(buf, "Port: ");
+ if (ip) {
+ irq = (unsigned)strtoul(ip + 5, NULL, 0);
+ }
+ if (pp) {
+ io = (unsigned)strtoul(pp + 6, NULL, 0);
+ }
+ }
+ pclose(p);
+ }
+
+ snprintf(path, sizeof(path), "/sys/class/tty/%s/iomem_base", name);
+ f = fopen(path, "r");
+ if (f) {
+ if (fgets(buf, sizeof(buf), f)) {
+ io = (unsigned)strtoul(buf, NULL, 0);
+ }
+ fclose(f);
+ }
+
+ if (got_irq) {
+ *got_irq = irq;
+ }
+ if (got_io) {
+ *got_io = io;
+ }
+ if (expect_irq == 0) {
+ return -1; /* never invent; refuse zero when required */
+ }
+ if (irq == 0 || irq != expect_irq) {
+ return -1;
+ }
+ return 0;
+}
+
+static int make_lock_path(const bcpr_dev_config_t *dev, char *out, size_t sz)
+{
+ char name[64];
+ const char *dir = "/var/run/max25-bcpr";
+ tty_basename(dev->serial, name, sizeof(name));
+ if (name[0] == '\0') {
+ return -1;
+ }
+ snprintf(out, sz, "%s/lock-%s", dir, name);
+ return 0;
+}
+
+int bcpr_lock_acquire(bcpr_port_lock_t *lk, const bcpr_dev_config_t *dev,
+ int dry_run)
+{
+ char lockpath[192];
+ unsigned got_irq = 0, got_io = 0;
+
+ if (!lk || !dev) {
+ return -1;
+ }
+ memset(lk, 0, sizeof(*lk));
+ snprintf(lk->serial, sizeof(lk->serial), "%s", dev->serial);
+ lk->iobase = dev->iobase;
+ lk->irq = dev->irq;
+ lk->lock_fd = -1;
+ lk->dry_run = dry_run ? 1 : 0;
+
+ if (dry_run) {
+ return 0;
+ }
+ if (!dev->serial[0] || dev->irq == 0 || dev->iobase == 0) {
+ return -1;
+ }
+ if (bcpr_lock_verify_irq(dev->serial, dev->irq, &got_irq, &got_io) != 0) {
+#if defined(__FreeBSD__)
+ /*
+ * No Linux sysfs/setserial — operator INI is SSoT for iobase/irq
+ * (see vault ExSys AM1 map). Skip hard fail when probe unavailable.
+ */
+ got_irq = dev->irq;
+ got_io = dev->iobase;
+#else
+ fprintf(stderr,
+ "bcpr: IRQ mismatch or unreadable for %s (expect %u got %u)\n",
+ dev->serial, dev->irq, got_irq);
+ return -1;
+#endif
+ }
+ if (got_io && got_io != dev->iobase) {
+ fprintf(stderr,
+ "bcpr: iobase mismatch for %s (expect 0x%x got 0x%x)\n",
+ dev->serial, dev->iobase, got_io);
+ return -1;
+ }
+
+ if (make_lock_path(dev, lockpath, sizeof(lockpath)) != 0) {
+ return -1;
+ }
+ (void)mkdir("/var/run/max25-bcpr", 0755);
+ lk->lock_fd = open(lockpath, O_RDWR | O_CREAT | O_CLOEXEC, 0644);
+ if (lk->lock_fd < 0) {
+ perror("bcpr lock open");
+ return -1;
+ }
+ if (flock(lk->lock_fd, LOCK_EX | LOCK_NB) != 0) {
+ fprintf(stderr, "bcpr: port %s already locked\n", dev->serial);
+ close(lk->lock_fd);
+ lk->lock_fd = -1;
+ return -1;
+ }
+
+ /* Release 8250 claim on this COM only so userspace can bit-bang. */
+ {
+ char cmd[256];
+ snprintf(cmd, sizeof(cmd), "setserial %s uart none 2>/dev/null",
+ dev->serial);
+ if (system(cmd) == 0) {
+ lk->uart_released = 1;
+ }
+ }
+ return 0;
+}
+
+void bcpr_lock_release(bcpr_port_lock_t *lk)
+{
+ if (!lk) {
+ return;
+ }
+ if (lk->dry_run) {
+ memset(lk, 0, sizeof(*lk));
+ lk->lock_fd = -1;
+ return;
+ }
+ if (lk->uart_released && lk->serial[0]) {
+ char cmd[256];
+ /* Restore 16550A claim — best effort. */
+ snprintf(cmd, sizeof(cmd),
+ "setserial %s uart 16550A port 0x%x irq %u 2>/dev/null",
+ lk->serial, lk->iobase, lk->irq);
+ (void)system(cmd);
+ lk->uart_released = 0;
+ }
+ if (lk->lock_fd >= 0) {
+ flock(lk->lock_fd, LOCK_UN);
+ close(lk->lock_fd);
+ lk->lock_fd = -1;
+ }
+ memset(lk, 0, sizeof(*lk));
+ lk->lock_fd = -1;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_runas.c b/stacks/max25-bcpr/src/bcpr_runas.c
new file mode 100644
index 0000000..8c930bf
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_runas.c
@@ -0,0 +1,81 @@
+/*
+ * Process identity: max25-bcprd runs unprivileged; init drops from root.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_runas.h"
+
+#include <errno.h>
+#include <grp.h>
+#include <pwd.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+int bcpr_runas_refuse_root(int dry_run)
+{
+ if (geteuid() == 0 && !dry_run) {
+ fprintf(stderr,
+ "max25-bcprd: refusing to run as root — use max25-bcprd-init "
+ "(setuid) for live SER12\n");
+ return -1;
+ }
+ return 0;
+}
+
+int bcpr_runas_drop(const bcpr_config_t *cfg)
+{
+ struct passwd *pw;
+ struct group *gr = NULL;
+ gid_t gid;
+ uid_t uid;
+ const char *name;
+
+ if (!cfg || !cfg->run_user[0]) {
+ fprintf(stderr, "max25-bcpr: [max25-bcpr] user= required for daemon\n");
+ return -1;
+ }
+ if (geteuid() != 0) {
+ fprintf(stderr, "max25-bcpr: privilege drop requires euid=0\n");
+ return -1;
+ }
+
+ pw = getpwnam(cfg->run_user);
+ if (!pw) {
+ fprintf(stderr, "max25-bcpr: unknown user=%s\n", cfg->run_user);
+ return -1;
+ }
+ uid = pw->pw_uid;
+ gid = pw->pw_gid;
+ name = pw->pw_name;
+
+ if (cfg->run_group[0]) {
+ gr = getgrnam(cfg->run_group);
+ if (!gr) {
+ fprintf(stderr, "max25-bcpr: unknown group=%s\n", cfg->run_group);
+ return -1;
+ }
+ gid = gr->gr_gid;
+ }
+
+ if (initgroups(name, gid) != 0) {
+ fprintf(stderr, "max25-bcpr: initgroups(%s) failed: %s\n", name,
+ strerror(errno));
+ return -1;
+ }
+ if (setgid(gid) != 0) {
+ fprintf(stderr, "max25-bcpr: setgid failed: %s\n", strerror(errno));
+ return -1;
+ }
+ if (setuid(uid) != 0) {
+ fprintf(stderr, "max25-bcpr: setuid failed: %s\n", strerror(errno));
+ return -1;
+ }
+ if (setuid(0) == 0) {
+ fprintf(stderr, "max25-bcpr: privilege drop incomplete\n");
+ return -1;
+ }
+
+ fprintf(stderr, "max25-bcpr: running as uid=%u gid=%u (%s)\n",
+ (unsigned)uid, (unsigned)gid, name);
+ return 0;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_ser12.c b/stacks/max25-bcpr/src/bcpr_ser12.c
new file mode 100644
index 0000000..32091e0
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_ser12.c
@@ -0,0 +1,246 @@
+#include "bcpr/bcpr_ser12.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+void bcpr_ser12_set_mode(bcpr_ser12_t *s, const char *mode, unsigned *baud_out)
+{
+ unsigned baud = 1200;
+ if (mode && strncmp(mode, "ser", 3) == 0) {
+ unsigned n = (unsigned)strtoul(mode + 3, NULL, 10);
+ if (n >= 3 && n <= 48) {
+ baud = n * 100u;
+ }
+ }
+ if (baud_out) {
+ *baud_out = baud;
+ }
+ s->baud = baud;
+ s->baud_us = 1000000u / baud;
+ if (mode && strchr(mode, '*')) {
+ s->opt_dcd = 0;
+ } else if (mode && strchr(mode, '+')) {
+ s->opt_dcd = -1;
+ } else {
+ s->opt_dcd = 1;
+ }
+}
+
+void bcpr_ser12_init(bcpr_ser12_t *s, unsigned baud, int opt_dcd)
+{
+ memset(s, 0, sizeof(*s));
+ s->baud = baud ? baud : 1200;
+ s->baud_us = 1000000u / s->baud;
+ s->opt_dcd = opt_dcd;
+ s->shreg = 0x10000;
+ s->dcd_sum0 = 2;
+ s->dcd_time = 120;
+ s->cal_mode = BCPR_CAL_OFF;
+ /* FlexNet SER12.doc defaults until bcpr_ser12_set_ptt_wd(). */
+ s->ptt_wd = 1;
+ s->ptt_wd_key_us = 14500u * 1000u;
+ s->ptt_wd_pause_us = 500u * 1000u;
+}
+
+void bcpr_ser12_set_ptt_wd(bcpr_ser12_t *s, int enable, int key_ms, int pause_ms)
+{
+ if (!s) {
+ return;
+ }
+ s->ptt_wd = enable ? 1 : 0;
+ if (key_ms < 1000) {
+ key_ms = 1000;
+ }
+ if (pause_ms < 50) {
+ pause_ms = 50;
+ }
+ s->ptt_wd_key_us = (unsigned)key_ms * 1000u;
+ s->ptt_wd_pause_us = (unsigned)pause_ms * 1000u;
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = 0;
+}
+
+void bcpr_ser12_force_unkey(bcpr_ser12_t *s)
+{
+ if (!s) {
+ return;
+ }
+ s->ptt_hw = 0;
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = 0;
+ s->txshreg = 0;
+}
+
+void bcpr_ser12_set_cal(bcpr_ser12_t *s, int cal_mode)
+{
+ if (!s) {
+ return;
+ }
+ if (cal_mode < BCPR_CAL_OFF || cal_mode > BCPR_CAL_ALT) {
+ cal_mode = BCPR_CAL_OFF;
+ }
+ s->cal_mode = cal_mode;
+ if (cal_mode != BCPR_CAL_OFF) {
+ s->ptt_hw = 1;
+ if (cal_mode == BCPR_CAL_HIGH) {
+ s->tx_bit = 1;
+ } else if (cal_mode == BCPR_CAL_LOW) {
+ s->tx_bit = 0;
+ } else {
+ s->tx_bit = 0; /* alt starts low, toggles each tick */
+ }
+ s->txshreg = 1;
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = 0;
+ }
+}
+
+/*
+ * FlexNet SER12.doc / PAR96: during long calibrate, drop PTT every ~14.5 s for
+ * ~500 ms so the radio PTT watchdog can discharge. Mirror for --cal and any
+ * sustained key. Logical ptt_hw stays 1 (keep baud divisor / telemetry); only
+ * MCR RTS is cleared for the pause window.
+ */
+static void ser12_apply_ptt_wd(bcpr_ser12_t *s, int *mcr_out, unsigned now_us)
+{
+ unsigned elapsed;
+
+ if (!s->ptt_wd || !s->ptt_hw) {
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = 0;
+ return;
+ }
+ if (s->ptt_wd_phase_start_us == 0) {
+ s->ptt_wd_phase_start_us = now_us ? now_us : 1u;
+ s->ptt_wd_pausing = 0;
+ return;
+ }
+ elapsed = now_us - s->ptt_wd_phase_start_us;
+ if (!s->ptt_wd_pausing) {
+ if (elapsed >= s->ptt_wd_key_us) {
+ s->ptt_wd_pausing = 1;
+ s->ptt_wd_phase_start_us = now_us ? now_us : 1u;
+ *mcr_out = 0x0d; /* RTS clear — PTT off */
+ }
+ return;
+ }
+ /* In pause: force idle MCR regardless of cal/HDLC bit. */
+ *mcr_out = 0x0d;
+ if (elapsed >= s->ptt_wd_pause_us) {
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = now_us ? now_us : 1u;
+ /* Next tick restores keyed MCR from cal/HDLC path. */
+ }
+}
+
+static void ser12_rx(bcpr_ser12_t *s, bcpr_hdlc_t *h, unsigned curs,
+ unsigned now_us)
+{
+ int timediff;
+ int bdus8 = (int)(s->baud_us >> 3);
+ int bdus4 = (int)(s->baud_us >> 2);
+ int bdus2 = (int)(s->baud_us >> 1);
+
+ timediff = (int)(now_us - s->pll_time);
+ /* wrap handling for us modulo ~1s window */
+ while (timediff >= 500000) {
+ timediff -= 1000000;
+ }
+ while (timediff <= -500000) {
+ timediff += 1000000;
+ }
+ while (timediff >= bdus2) {
+ timediff -= (int)s->baud_us;
+ s->pll_time += s->baud_us;
+ s->dcd_time--;
+ if (s->shreg & 1) {
+ bcpr_hdlc_putbits(h, (s->shreg >> 1) ^ 0xffffu);
+ s->shreg = 0x10000;
+ }
+ s->shreg >>= 1;
+ }
+ if (s->dcd_time <= 0) {
+ if (!s->opt_dcd) {
+ h->dcd = (s->dcd_sum0 + s->dcd_sum1 + s->dcd_sum2) < 0;
+ }
+ s->dcd_sum2 = s->dcd_sum1;
+ s->dcd_sum1 = s->dcd_sum0;
+ s->dcd_sum0 = 2;
+ s->dcd_time += 120;
+ }
+ if (s->last_rxbit != (unsigned char)curs) {
+ s->last_rxbit = (unsigned char)curs;
+ s->shreg |= 0x10000;
+ if (timediff > 0) {
+ s->pll_time += (unsigned)bdus8;
+ } else {
+ s->pll_time += 1000000u - (unsigned)bdus8;
+ }
+ if (abs(timediff) > bdus4) {
+ s->dcd_sum0 += 4;
+ } else {
+ s->dcd_sum0--;
+ }
+ }
+ while (s->pll_time >= 1000000u) {
+ s->pll_time -= 1000000u;
+ }
+}
+
+void bcpr_ser12_tick(bcpr_ser12_t *s, bcpr_hdlc_t *h, int cts, int *mcr_out,
+ int *do_thr00, unsigned now_us)
+{
+ *do_thr00 = 1;
+
+ /* DOS cal.exe: continuous PTT + sticky/toggle DTR; no HDLC. */
+ if (s->cal_mode != BCPR_CAL_OFF) {
+ s->ptt_hw = 1;
+ if (s->cal_mode == BCPR_CAL_HIGH) {
+ s->tx_bit = 1;
+ } else if (s->cal_mode == BCPR_CAL_LOW) {
+ s->tx_bit = 0;
+ } else {
+ s->tx_bit = (unsigned char)!s->tx_bit;
+ }
+ *mcr_out = 0x0e | (!!s->tx_bit);
+ ser12_apply_ptt_wd(s, mcr_out, now_us);
+ return;
+ }
+
+ /*
+ * While PTT: skip Soft-DCD RX PLL — PC-COM charge-pump needs unbroken
+ * THR 0x00 @ baud; RX work causes multi-ms TXD gaps → underpowered AFSK.
+ */
+ if (!s->ptt_hw) {
+ ser12_rx(s, h, cts ? 1u : 0u, now_us);
+ }
+
+ if (s->ptt_hw) {
+ *mcr_out = 0x0e | (!!s->tx_bit);
+ } else {
+ *mcr_out = 0x0d;
+ }
+
+ if (s->ptt_hw) {
+ if (s->txshreg <= 1) {
+ s->txshreg = 0x10000u | bcpr_hdlc_getbits(h);
+ if (!bcpr_hdlc_ptt(h)) {
+ s->ptt_hw = 0;
+ *mcr_out = 0x0d;
+ ser12_apply_ptt_wd(s, mcr_out, now_us);
+ return;
+ }
+ }
+ s->tx_bit = (unsigned char)(!(s->tx_bit ^ (s->txshreg & 1)));
+ s->txshreg >>= 1;
+ } else {
+ bcpr_hdlc_arbitrate(h);
+ if (bcpr_hdlc_ptt(h)) {
+ s->txshreg = 1;
+ s->ptt_hw = 1;
+ }
+ }
+ bcpr_hdlc_transmitter(h);
+ ser12_apply_ptt_wd(s, mcr_out, now_us);
+ /* Receiver drain is owned by bcpr_engine (RX callback). */
+}
diff --git a/stacks/max25-bcpr/src/bcpr_uart.c b/stacks/max25-bcpr/src/bcpr_uart.c
new file mode 100644
index 0000000..22ccba3
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_uart.c
@@ -0,0 +1,251 @@
+/*
+ * Userspace 8250/16550 register access for SER12 bit-bang.
+ * Prefer ioperm(2); fall back to /dev/port. Dry-run: no-ops.
+ * Algorithms match Linux baycom_ser_fdx (see NOTICE.md).
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_uart.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/types.h>
+#include <time.h>
+#include <unistd.h>
+
+#if defined(__linux__)
+#include <sys/io.h>
+#elif defined(__FreeBSD__)
+#include <machine/cpufunc.h>
+#include <machine/sysarch.h>
+#endif
+
+#if defined(__FreeBSD__)
+static int bcpr_freebsd_set_ioperm(unsigned start, unsigned len, int enable)
+{
+ struct {
+ unsigned int start;
+ unsigned int length;
+ int enable;
+ } args;
+
+ args.start = start;
+ args.length = len;
+ args.enable = enable;
+ return sysarch(I386_SET_IOPERM, &args);
+}
+#endif
+
+static int g_dry;
+static int g_port_fd = -1;
+static int g_ioperm_ok;
+
+void bcpr_uart_set_dry_run(int on)
+{
+ g_dry = on ? 1 : 0;
+}
+
+int bcpr_uart_ioperm(unsigned iobase, int on)
+{
+ if (g_dry) {
+ return 0;
+ }
+#if defined(__linux__)
+ if (ioperm((unsigned long)iobase, 8, on ? 1 : 0) == 0) {
+ g_ioperm_ok = on ? 1 : 0;
+ return 0;
+ }
+#elif defined(__FreeBSD__)
+ /*
+ * Prefer /dev/io (process-wide inb/outb). sysarch range is fallback.
+ * ExSys port: disable matching uart(4) unit (e.g. uart2/cuau2) in
+ * loader.conf — kernel uart + userspace inb on same iobase → SIGBUS.
+ */
+ if (on) {
+ if (g_port_fd < 0) {
+ g_port_fd = open("/dev/io", O_RDWR | O_CLOEXEC);
+ }
+ if (g_port_fd >= 0) {
+ g_ioperm_ok = 1;
+ return 0;
+ }
+ }
+ if (bcpr_freebsd_set_ioperm((unsigned)iobase, 8, on ? 1 : 0) == 0) {
+ g_ioperm_ok = on ? 1 : 0;
+ return 0;
+ }
+#endif
+ /* Fallback: /dev/io (FreeBSD) or /dev/port (Linux) — needs root. */
+ if (on) {
+ if (g_port_fd < 0) {
+#if defined(__FreeBSD__)
+ g_port_fd = open("/dev/io", O_RDWR | O_CLOEXEC);
+#else
+ g_port_fd = open("/dev/port", O_RDWR | O_CLOEXEC);
+#endif
+ if (g_port_fd >= 0) {
+ g_ioperm_ok = 1;
+ return 0;
+ }
+ }
+ if (g_port_fd >= 0) {
+ return 0;
+ }
+ return -1;
+ }
+ if (g_port_fd >= 0) {
+ close(g_port_fd);
+ g_port_fd = -1;
+ }
+ g_ioperm_ok = 0;
+ return 0;
+}
+
+static int port_rw(unsigned port, int do_write, unsigned char *val)
+{
+ off_t off = (off_t)port;
+ if (g_port_fd < 0) {
+ return -1;
+ }
+ if (lseek(g_port_fd, off, SEEK_SET) != off) {
+ return -1;
+ }
+ if (do_write) {
+ return (write(g_port_fd, val, 1) == 1) ? 0 : -1;
+ }
+ return (read(g_port_fd, val, 1) == 1) ? 0 : -1;
+}
+
+void bcpr_uart_outb(unsigned char val, unsigned port)
+{
+ if (g_dry) {
+ return;
+ }
+ if (g_ioperm_ok) {
+ outb(val, port);
+ return;
+ }
+ (void)port_rw(port, 1, &val);
+}
+
+unsigned char bcpr_uart_inb(unsigned port)
+{
+ unsigned char v = 0;
+ if (g_dry) {
+ return 0;
+ }
+ if (g_ioperm_ok) {
+ return inb(port);
+ }
+ (void)port_rw(port, 0, &v);
+ return v;
+}
+
+void bcpr_uart_set_divisor(unsigned iobase, unsigned divisor)
+{
+ unsigned char lcr;
+ if (g_dry) {
+ return;
+ }
+ lcr = bcpr_uart_inb(iobase + 3);
+ bcpr_uart_outb((unsigned char)(lcr | 0x80), iobase + 3); /* DLAB */
+ bcpr_uart_outb((unsigned char)(divisor & 0xff), iobase + 0);
+ bcpr_uart_outb((unsigned char)((divisor >> 8) & 0xff), iobase + 1);
+ bcpr_uart_outb((unsigned char)(lcr & 0x7f), iobase + 3);
+}
+
+void bcpr_uart_open_ser12(unsigned iobase)
+{
+ if (g_dry) {
+ return;
+ }
+ /* Match baycom_ser_fdx open: FIFO off, 6-bit word, IER THRE+MSR. */
+ bcpr_uart_outb(0x00, iobase + 2); /* FCR */
+ bcpr_uart_outb(0x01, iobase + 3); /* LCR 6N1 */
+ bcpr_uart_outb(0x0a, iobase + 1); /* IER */
+ bcpr_uart_outb(0x0d, iobase + 4); /* MCR idle */
+ bcpr_uart_thr00(iobase);
+}
+
+void bcpr_uart_close_ser12(unsigned iobase)
+{
+ if (g_dry) {
+ return;
+ }
+ bcpr_uart_outb(0x00, iobase + 1); /* IER off */
+ bcpr_uart_outb(0x01, iobase + 4); /* MCR close */
+}
+
+unsigned char bcpr_uart_msr(unsigned iobase)
+{
+ if (g_dry) {
+ return 0;
+ }
+ return bcpr_uart_inb(iobase + 6);
+}
+
+void bcpr_uart_mcr(unsigned iobase, unsigned char v)
+{
+ if (g_dry) {
+ return;
+ }
+ bcpr_uart_outb(v, iobase + 4);
+}
+
+void bcpr_uart_thr00(unsigned iobase)
+{
+ if (g_dry) {
+ return;
+ }
+ bcpr_uart_outb(0x00, iobase + 0);
+}
+
+void bcpr_uart_set_break(unsigned iobase, int on)
+{
+ unsigned char lcr;
+ if (g_dry) {
+ return;
+ }
+ /*
+ * 8250 LCR bit6 (Set Break): force TXD to continuous SPACE (RS-232 +V).
+ * Honest limit: THR writes alone cannot hold DC-steady TXD — each byte is
+ * framed (start/data/stop). Break is the practical TFPCX-class approximation
+ * (docs: static ≈ +12 V modem supply). Does not change MCR/PTT.
+ */
+ lcr = bcpr_uart_inb(iobase + 3);
+ if (on) {
+ bcpr_uart_outb((unsigned char)(lcr | 0x40), iobase + 3);
+ } else {
+ bcpr_uart_outb((unsigned char)(lcr & (unsigned char)~0x40), iobase + 3);
+ }
+}
+
+int bcpr_uart_wait_thre(unsigned iobase, unsigned timeout_us)
+{
+ struct timespec t0, now;
+ unsigned char lsr;
+
+ if (g_dry) {
+ return 1;
+ }
+ if (clock_gettime(CLOCK_MONOTONIC, &t0) != 0) {
+ return 0;
+ }
+ for (;;) {
+ lsr = bcpr_uart_inb(iobase + 5);
+ if (lsr & 0x20) { /* THRE */
+ return 1;
+ }
+ if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) {
+ return 0;
+ }
+ {
+ long elapsed = (long)(now.tv_sec - t0.tv_sec) * 1000000L +
+ (now.tv_nsec - t0.tv_nsec) / 1000L;
+ if (elapsed >= (long)timeout_us) {
+ return 0;
+ }
+ }
+ }
+}
diff --git a/stacks/max25-bcpr/src/max25-bcprd-init.c b/stacks/max25-bcpr/src/max25-bcprd-init.c
new file mode 100644
index 0000000..6a02240
--- /dev/null
+++ b/stacks/max25-bcpr/src/max25-bcprd-init.c
@@ -0,0 +1,255 @@
+/*
+ * max25-bcprd-init — privileged SER12 setup (setuid root), then unprivileged daemon.
+ * Fork keeps ioperm in child; parent writes pidfile and exits.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_config.h"
+#include "bcpr/bcpr_daemon.h"
+#include "bcpr/bcpr_engine.h"
+#include "bcpr/bcpr_kiss.h"
+#include "bcpr/bcpr_runas.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+static void usage(const char *argv0)
+{
+ fprintf(stderr,
+ "Usage: %s -c <bcpr.ini> [--once] [--seconds N]\n"
+ " [--cal high|low|alt] [--txd-bias pulse|steady]\n"
+ " [--ptt-wd|--no-ptt-wd] [--ptt-wd-key-ms N]\n"
+ " [--ptt-wd-pause-ms N] [--version]\n"
+ " Privileged init for live SER12 — drops to [max25-bcpr] user= after setup.\n"
+ " Dry-run: use max25-bcprd --dry-run (unprivileged).\n",
+ argv0);
+}
+
+static int ensure_dir(const char *path)
+{
+ char tmp[256];
+ char *p;
+ size_t len;
+
+ if (!path || !path[0]) {
+ return -1;
+ }
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ len = strlen(tmp);
+ if (len == 0) {
+ return -1;
+ }
+ if (tmp[len - 1] == '/') {
+ tmp[len - 1] = '\0';
+ }
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ (void)mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+ return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1;
+}
+
+static int write_pidfile(const char *path, pid_t pid)
+{
+ FILE *f;
+
+ f = fopen(path, "w");
+ if (!f) {
+ fprintf(stderr, "max25-bcprd-init: cannot write pidfile %s\n", path);
+ return -1;
+ }
+ fprintf(f, "%d\n", (int)pid);
+ fclose(f);
+ return 0;
+}
+
+int main(int argc, char **argv)
+{
+ const char *cfg_path = NULL;
+ int seconds = 0;
+ int cal_mode = BCPR_CAL_OFF;
+ int cli_txd_bias = -1;
+ int cli_ptt_wd = -1;
+ int cli_ptt_wd_key_ms = -1;
+ int cli_ptt_wd_pause_ms = -1;
+ int i;
+ bcpr_config_t cfg;
+ bcpr_engine_t engine;
+ bcpr_kiss_pty_t ptys[BCPR_MAX_DEVICES];
+ int npty = 0;
+ char pidpath[256];
+ pid_t child;
+ bcpr_daemon_opts_t opts;
+
+ for (i = 1; i < argc; i++) {
+ if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--config") == 0) &&
+ i + 1 < argc) {
+ cfg_path = argv[++i];
+ } else if (strcmp(argv[i], "--once") == 0) {
+ if (seconds <= 0) {
+ seconds = 1;
+ }
+ } else if (strcmp(argv[i], "--seconds") == 0 && i + 1 < argc) {
+ seconds = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--cal") == 0 && i + 1 < argc) {
+ const char *m = argv[++i];
+ if (strcmp(m, "high") == 0 || strcmp(m, "1") == 0) {
+ cal_mode = BCPR_CAL_HIGH;
+ } else if (strcmp(m, "low") == 0 || strcmp(m, "2") == 0) {
+ cal_mode = BCPR_CAL_LOW;
+ } else if (strcmp(m, "alt") == 0 || strcmp(m, "3") == 0) {
+ cal_mode = BCPR_CAL_ALT;
+ } else {
+ fprintf(stderr, "max25-bcprd-init: --cal needs high|low|alt\n");
+ return 2;
+ }
+ } else if (strcmp(argv[i], "--txd-bias") == 0 && i + 1 < argc) {
+ const char *m = argv[++i];
+ if (strcasecmp(m, "pulse") == 0 || strcasecmp(m, "sailer") == 0) {
+ cli_txd_bias = BCPR_TXD_PULSE;
+ } else if (strcasecmp(m, "steady") == 0 ||
+ strcasecmp(m, "tfpcx") == 0 ||
+ strcasecmp(m, "break") == 0) {
+ cli_txd_bias = BCPR_TXD_STEADY;
+ } else {
+ fprintf(stderr, "max25-bcprd-init: --txd-bias needs pulse|steady\n");
+ return 2;
+ }
+ } else if (strcmp(argv[i], "--ptt-wd") == 0) {
+ cli_ptt_wd = 1;
+ } else if (strcmp(argv[i], "--no-ptt-wd") == 0) {
+ cli_ptt_wd = 0;
+ } else if (strcmp(argv[i], "--ptt-wd-key-ms") == 0 && i + 1 < argc) {
+ cli_ptt_wd_key_ms = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--ptt-wd-pause-ms") == 0 &&
+ i + 1 < argc) {
+ cli_ptt_wd_pause_ms = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--version") == 0 ||
+ strcmp(argv[i], "-V") == 0) {
+ printf("bcprd-init 0.1.0\n");
+ return 0;
+ } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
+ usage(argv[0]);
+ return 0;
+ } else {
+ usage(argv[0]);
+ return 2;
+ }
+ }
+ if (!cfg_path) {
+ usage(argv[0]);
+ return 2;
+ }
+ if (geteuid() != 0) {
+ fprintf(stderr,
+ "max25-bcprd-init: requires root (setuid) for SER12 lock/ioperm/KISS\n");
+ return 1;
+ }
+ if (cal_mode != BCPR_CAL_OFF && seconds <= 0) {
+ seconds = 5;
+ }
+
+ if (bcpr_config_load(&cfg, cfg_path) != 0) {
+ fprintf(stderr, "max25-bcprd-init: cannot load %s\n", cfg_path);
+ return 1;
+ }
+ if (cfg.dry_run) {
+ fprintf(stderr,
+ "max25-bcprd-init: dry_run=yes — use max25-bcprd --dry-run instead\n");
+ return 1;
+ }
+ if (!cfg.run_user[0]) {
+ fprintf(stderr,
+ "max25-bcprd-init: [max25-bcpr] user= required for privilege drop\n");
+ return 1;
+ }
+
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (cli_txd_bias >= 0) {
+ cfg.dev[i].txd_bias = cli_txd_bias;
+ }
+ if (cli_ptt_wd >= 0) {
+ cfg.dev[i].ptt_wd = cli_ptt_wd;
+ }
+ if (cli_ptt_wd_key_ms > 0) {
+ cfg.dev[i].ptt_wd_key_ms = cli_ptt_wd_key_ms;
+ }
+ if (cli_ptt_wd_pause_ms > 0) {
+ cfg.dev[i].ptt_wd_pause_ms = cli_ptt_wd_pause_ms;
+ }
+ }
+
+ (void)ensure_dir(cfg.state_dir);
+ snprintf(pidpath, sizeof(pidpath), "%s/max25-bcprd.pid", cfg.state_dir);
+
+ if (cal_mode == BCPR_CAL_OFF) {
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (!cfg.dev[i].enabled) {
+ continue;
+ }
+ if (bcpr_kiss_pty_open(&ptys[npty], i, cfg.dev[i].kiss_link,
+ cfg.state_dir) != 0) {
+ while (npty > 0) {
+ npty--;
+ bcpr_kiss_pty_close(&ptys[npty]);
+ }
+ return 1;
+ }
+ npty++;
+ }
+ }
+
+ if (bcpr_engine_open(&engine, &cfg) != 0) {
+ for (i = 0; i < npty; i++) {
+ bcpr_kiss_pty_close(&ptys[i]);
+ }
+ return 1;
+ }
+
+ child = fork();
+ if (child < 0) {
+ perror("max25-bcprd-init fork");
+ bcpr_engine_close(&engine);
+ for (i = 0; i < npty; i++) {
+ bcpr_kiss_pty_close(&ptys[i]);
+ }
+ return 1;
+ }
+ if (child > 0) {
+ if (write_pidfile(pidpath, child) != 0) {
+ kill(child, SIGTERM);
+ waitpid(child, NULL, 0);
+ return 1;
+ }
+ _exit(0);
+ }
+
+ if (setsid() < 0) {
+ perror("max25-bcprd-init setsid");
+ }
+
+ if (bcpr_runas_drop(&cfg) != 0) {
+ _exit(1);
+ }
+
+ memset(&opts, 0, sizeof(opts));
+ opts.seconds = seconds;
+ opts.cal_mode = cal_mode;
+ opts.cli_txd_bias = cli_txd_bias;
+ opts.cli_ptt_wd = cli_ptt_wd;
+ opts.cli_ptt_wd_key_ms = cli_ptt_wd_key_ms;
+ opts.cli_ptt_wd_pause_ms = cli_ptt_wd_pause_ms;
+ opts.engine_preopened = 1;
+
+ return bcpr_daemon_run(&cfg, ptys, npty, &engine, &opts);
+}
diff --git a/stacks/max25-bcpr/src/max25-bcprd.c b/stacks/max25-bcpr/src/max25-bcprd.c
new file mode 100644
index 0000000..084c5df
--- /dev/null
+++ b/stacks/max25-bcpr/src/max25-bcprd.c
@@ -0,0 +1,205 @@
+/*
+ * max25-bcprd — BayCom/based SER12 userspace daemon (unprivileged live path).
+ * Live SER12: max25-bcprd-init (setuid). Dry-run / offline: this binary.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_config.h"
+#include "bcpr/bcpr_daemon.h"
+#include "bcpr/bcpr_kiss.h"
+#include "bcpr/bcpr_runas.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <unistd.h>
+
+static void usage(const char *argv0)
+{
+ fprintf(stderr,
+ "Usage: %s -c <bcpr.ini> [--dry-run] [--once] [--seconds N]\n"
+ " [--cal high|low|alt] [--txd-bias pulse|steady]\n"
+ " [--ptt-wd|--no-ptt-wd] [--ptt-wd-key-ms N]\n"
+ " [--ptt-wd-pause-ms N] [--version]\n"
+ " Host face: max25e0:bc0 / bc1 (KISS PTY via kiss_link)\n"
+ " Live SER12: max25-bcprd-init (setuid) — this binary refuses root.\n"
+ " --cal: continuous SER12 tone+PTT (use init for live hardware)\n",
+ argv0);
+}
+
+int main(int argc, char **argv)
+{
+ const char *cfg_path = NULL;
+ int dry = 0;
+ int seconds = 0;
+ int cal_mode = BCPR_CAL_OFF;
+ int cli_txd_bias = -1;
+ int cli_ptt_wd = -1;
+ int cli_ptt_wd_key_ms = -1;
+ int cli_ptt_wd_pause_ms = -1;
+ int i;
+ bcpr_config_t cfg;
+ bcpr_kiss_pty_t ptys[BCPR_MAX_DEVICES];
+ int npty = 0;
+ bcpr_daemon_opts_t opts;
+ char ver[32] = "0.1.0";
+
+ for (i = 1; i < argc; i++) {
+ if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--config") == 0) &&
+ i + 1 < argc) {
+ cfg_path = argv[++i];
+ } else if (strcmp(argv[i], "--dry-run") == 0) {
+ dry = 1;
+ } else if (strcmp(argv[i], "--once") == 0) {
+ if (seconds <= 0) {
+ seconds = 1;
+ }
+ } else if (strcmp(argv[i], "--seconds") == 0 && i + 1 < argc) {
+ seconds = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--cal") == 0 && i + 1 < argc) {
+ const char *m = argv[++i];
+ if (strcmp(m, "high") == 0 || strcmp(m, "1") == 0) {
+ cal_mode = BCPR_CAL_HIGH;
+ } else if (strcmp(m, "low") == 0 || strcmp(m, "2") == 0) {
+ cal_mode = BCPR_CAL_LOW;
+ } else if (strcmp(m, "alt") == 0 || strcmp(m, "3") == 0) {
+ cal_mode = BCPR_CAL_ALT;
+ } else {
+ fprintf(stderr, "max25-bcprd: --cal needs high|low|alt\n");
+ return 2;
+ }
+ } else if (strcmp(argv[i], "--txd-bias") == 0 && i + 1 < argc) {
+ const char *m = argv[++i];
+ if (strcasecmp(m, "pulse") == 0 || strcasecmp(m, "sailer") == 0) {
+ cli_txd_bias = BCPR_TXD_PULSE;
+ } else if (strcasecmp(m, "steady") == 0 ||
+ strcasecmp(m, "tfpcx") == 0 ||
+ strcasecmp(m, "break") == 0) {
+ cli_txd_bias = BCPR_TXD_STEADY;
+ } else {
+ fprintf(stderr, "max25-bcprd: --txd-bias needs pulse|steady\n");
+ return 2;
+ }
+ } else if (strcmp(argv[i], "--ptt-wd") == 0) {
+ cli_ptt_wd = 1;
+ } else if (strcmp(argv[i], "--no-ptt-wd") == 0) {
+ cli_ptt_wd = 0;
+ } else if (strcmp(argv[i], "--ptt-wd-key-ms") == 0 && i + 1 < argc) {
+ cli_ptt_wd_key_ms = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--ptt-wd-pause-ms") == 0 &&
+ i + 1 < argc) {
+ cli_ptt_wd_pause_ms = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--version") == 0 ||
+ strcmp(argv[i], "-V") == 0) {
+ FILE *vf = fopen("/usr/local/share/max25/max25-bcpr/VERSION", "r");
+ if (!vf) {
+ vf = fopen("stacks/max25-bcpr/VERSION", "r");
+ }
+ if (vf) {
+ if (fgets(ver, sizeof(ver), vf)) {
+ ver[strcspn(ver, "\r\n")] = '\0';
+ }
+ fclose(vf);
+ }
+ printf("bcprd %s\n", ver);
+ return 0;
+ } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
+ usage(argv[0]);
+ return 0;
+ } else {
+ usage(argv[0]);
+ return 2;
+ }
+ }
+ if (!cfg_path) {
+ usage(argv[0]);
+ return 2;
+ }
+ if (cal_mode != BCPR_CAL_OFF && seconds <= 0) {
+ seconds = 5;
+ }
+
+ if (bcpr_config_load(&cfg, cfg_path) != 0) {
+ fprintf(stderr, "max25-bcprd: cannot load %s\n", cfg_path);
+ return 1;
+ }
+ if (dry) {
+ cfg.dry_run = 1;
+ }
+
+ if (bcpr_runas_refuse_root(cfg.dry_run) != 0) {
+ return 1;
+ }
+ if (!cfg.dry_run && cal_mode == BCPR_CAL_OFF) {
+ fprintf(stderr,
+ "max25-bcprd: live SER12 requires max25-bcprd-init — "
+ "not this binary alone\n");
+ return 1;
+ }
+
+ {
+ char self[512];
+ ssize_t n = readlink("/proc/self/exe", self, sizeof(self) - 1);
+ FILE *vf = fopen("/usr/local/share/bcpr/VERSION", "r");
+ if (!vf) {
+ vf = fopen("stacks/max25-bcpr/VERSION", "r");
+ }
+ ver[0] = '\0';
+ if (vf) {
+ if (fgets(ver, sizeof(ver), vf)) {
+ ver[strcspn(ver, "\r\n")] = '\0';
+ }
+ fclose(vf);
+ }
+ if (n > 0) {
+ self[n] = '\0';
+ fprintf(stderr, "max25-bcprd: path=%s version=%s\n", self,
+ ver[0] ? ver : "0.1.0");
+ } else {
+ fprintf(stderr, "max25-bcprd: version=%s\n", ver[0] ? ver : "0.1.0");
+ }
+ }
+
+ if (cfg.dry_run) {
+ fprintf(stderr, "max25-bcprd: dry-run (no KISS PTY, no UART I/O)\n");
+ } else if (cal_mode == BCPR_CAL_OFF) {
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (!cfg.dev[i].enabled) {
+ continue;
+ }
+ if (bcpr_kiss_pty_open(&ptys[npty], i, cfg.dev[i].kiss_link,
+ cfg.state_dir) != 0) {
+ while (npty > 0) {
+ npty--;
+ bcpr_kiss_pty_close(&ptys[npty]);
+ }
+ return 1;
+ }
+ npty++;
+ }
+ }
+ if (cal_mode != BCPR_CAL_OFF) {
+ static const char *cal_names[] = {"off", "high", "low", "alt"};
+ fprintf(stderr,
+ "max25-bcprd: CAL mode=%s seconds=%d (no KISS; SER12 tone+PTT)%s\n",
+ cal_names[cal_mode], seconds,
+ cfg.dry_run ? " [dry-run]" : "");
+ if (!cfg.dry_run) {
+ fprintf(stderr,
+ "max25-bcprd: live cal needs max25-bcprd-init for hardware access\n");
+ return 1;
+ }
+ }
+
+ memset(&opts, 0, sizeof(opts));
+ opts.dry_run = cfg.dry_run;
+ opts.seconds = seconds;
+ opts.cal_mode = cal_mode;
+ opts.cli_txd_bias = cli_txd_bias;
+ opts.cli_ptt_wd = cli_ptt_wd;
+ opts.cli_ptt_wd_key_ms = cli_ptt_wd_key_ms;
+ opts.cli_ptt_wd_pause_ms = cli_ptt_wd_pause_ms;
+ opts.engine_preopened = 0;
+
+ return bcpr_daemon_run(&cfg, ptys, npty, NULL, &opts);
+}
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com