summaryrefslogtreecommitdiff
path: root/stacks/max25-bcpr
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
commit04d965d67a7264a1c7c211494aebda1953df7603 (patch)
tree0ebd700a6e219f84a26a656f4bee8778bc75da7b /stacks/max25-bcpr
Initial push
Diffstat (limited to 'stacks/max25-bcpr')
-rw-r--r--stacks/max25-bcpr/CMakeLists.txt76
-rw-r--r--stacks/max25-bcpr/NOTICE.md11
-rw-r--r--stacks/max25-bcpr/README.md77
-rw-r--r--stacks/max25-bcpr/VERSION1
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr.h16
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_config.h70
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_crc.h12
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_daemon.h23
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_engine.h62
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h57
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_kiss.h17
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_lock.h22
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_runas.h12
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_ser12.h47
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_uart.h22
-rw-r--r--stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example34
-rw-r--r--stacks/max25-bcpr/share/max25-bcpr.ini.example48
-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
-rw-r--r--stacks/max25-bcpr/tests/test_config_offline.c53
-rw-r--r--stacks/max25-bcpr/tests/test_hdlc_offline.c110
-rw-r--r--stacks/max25-bcpr/tools/NEBENBEI-MOVED.md5
-rwxr-xr-xstacks/max25-bcpr/tools/bcpr-ultimate-diag.sh916
-rwxr-xr-xstacks/max25-bcpr/tools/max25-bcpr-ctl349
-rwxr-xr-xstacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh566
-rwxr-xr-xstacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh914
36 files changed, 6573 insertions, 0 deletions
diff --git a/stacks/max25-bcpr/CMakeLists.txt b/stacks/max25-bcpr/CMakeLists.txt
new file mode 100644
index 0000000..fd6d61e
--- /dev/null
+++ b/stacks/max25-bcpr/CMakeLists.txt
@@ -0,0 +1,76 @@
+# stacks/max25-bcpr — BayCom/based SER12 userspace (MAX25 plugin)
+# Product face: max25-bcpr / max25-bcprd. Internal C API still uses bcpr_* symbols.
+cmake_minimum_required(VERSION 3.16)
+project(max25_bcpr VERSION 0.1.0 LANGUAGES C)
+
+set(CMAKE_C_STANDARD 11)
+set(CMAKE_C_STANDARD_REQUIRED ON)
+
+include(GNUInstallDirs)
+
+set(MAX25_BCPR_ROOT "${CMAKE_CURRENT_SOURCE_DIR}")
+set(MAX25_BCPR_LIB_SRCS
+ src/bcpr_crc.c
+ src/bcpr_hdlc.c
+ src/bcpr_ser12.c
+ src/bcpr_uart.c
+ src/bcpr_lock.c
+ src/bcpr_config.c
+ src/bcpr_engine.c
+ src/bcpr_kiss.c
+ src/bcpr_runas.c
+ src/bcpr_daemon.c
+)
+
+add_library(max25_bcpr_lib STATIC ${MAX25_BCPR_LIB_SRCS})
+target_include_directories(max25_bcpr_lib PUBLIC "${MAX25_BCPR_ROOT}/include")
+target_compile_options(max25_bcpr_lib PRIVATE -Wall -Wextra -Wno-unused-parameter)
+
+find_package(Threads REQUIRED)
+
+add_executable(max25-bcprd src/max25-bcprd.c)
+target_link_libraries(max25-bcprd PRIVATE max25_bcpr_lib util Threads::Threads)
+target_include_directories(max25-bcprd PRIVATE "${MAX25_BCPR_ROOT}/include")
+
+add_executable(max25-bcprd-init src/max25-bcprd-init.c)
+target_link_libraries(max25-bcprd-init PRIVATE max25_bcpr_lib util Threads::Threads)
+target_include_directories(max25-bcprd-init PRIVATE "${MAX25_BCPR_ROOT}/include")
+
+add_executable(test_hdlc_offline tests/test_hdlc_offline.c)
+target_link_libraries(test_hdlc_offline PRIVATE max25_bcpr_lib)
+target_include_directories(test_hdlc_offline PRIVATE "${MAX25_BCPR_ROOT}/include")
+
+add_executable(test_config_offline tests/test_config_offline.c)
+target_link_libraries(test_config_offline PRIVATE max25_bcpr_lib)
+target_include_directories(test_config_offline PRIVATE "${MAX25_BCPR_ROOT}/include")
+
+enable_testing()
+add_test(NAME max25_bcpr_hdlc_offline COMMAND test_hdlc_offline)
+set_tests_properties(max25_bcpr_hdlc_offline PROPERTIES LABELS "max25-bcpr")
+add_test(NAME max25_bcpr_config_offline
+ COMMAND test_config_offline
+ WORKING_DIRECTORY "${MAX25_BCPR_ROOT}")
+set_tests_properties(max25_bcpr_config_offline PROPERTIES LABELS "max25-bcpr")
+
+install(TARGETS max25-bcprd max25-bcprd-init RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
+install(CODE "
+ set(_init \"\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-bcprd-init\")
+ if(EXISTS \"\${_init}\")
+ execute_process(COMMAND chmod 4755 \"\${_init}\")
+ endif()
+")
+install(TARGETS test_hdlc_offline test_config_offline RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
+install(TARGETS max25_bcpr_lib ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}")
+install(DIRECTORY "${MAX25_BCPR_ROOT}/include/bcpr"
+ DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}")
+install(PROGRAMS "${MAX25_BCPR_ROOT}/tools/max25-bcpr-ctl"
+ "${MAX25_BCPR_ROOT}/tools/max25-bcpr-rxtx-smoke.sh"
+ "${MAX25_BCPR_ROOT}/tools/max25-bcpr-ultimate-diag.sh"
+ DESTINATION "${CMAKE_INSTALL_SBINDIR}")
+install(FILES "${MAX25_BCPR_ROOT}/share/max25-bcpr.ini.example"
+ "${MAX25_BCPR_ROOT}/share/max25-bcpr.freebsd.ini.example"
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/max25-bcpr")
+install(FILES "${MAX25_BCPR_ROOT}/NOTICE.md" "${MAX25_BCPR_ROOT}/VERSION"
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/max25-bcpr")
+
+message(STATUS "max25-bcpr ${PROJECT_VERSION} — SER12 userspace (max25e0; default ON)")
diff --git a/stacks/max25-bcpr/NOTICE.md b/stacks/max25-bcpr/NOTICE.md
new file mode 100644
index 0000000..ce3d139
--- /dev/null
+++ b/stacks/max25-bcpr/NOTICE.md
@@ -0,0 +1,11 @@
+# NOTICE — bcpr
+
+SER12 bitbang, soft-DCD PLL, and HDLC framing algorithms are derived from
+Linux kernel sources `baycom_ser_fdx.c` and `hdlcdrv.c` (Thomas Sailer et al.),
+Linux LTS v6.1.177, SPDX GPL-2.0-or-later.
+
+This userspace port is part of MAX25-Stack (GPL-3.0) and does **not** ship or
+maintain the in-tree `baycom_ser_fdx` kernel module as a product path.
+
+Upstream reference (plain files):
+https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/
diff --git a/stacks/max25-bcpr/README.md b/stacks/max25-bcpr/README.md
new file mode 100644
index 0000000..bc1039a
--- /dev/null
+++ b/stacks/max25-bcpr/README.md
@@ -0,0 +1,77 @@
+# max25-bcpr — BayCom/based PC-COM SER12 (MAX25 userspace)
+
+**Status: available · usable** — default CMake build ON (`MAX25_BUILD_MAX25_BCPR=ON`).
+
+Public mark: **BayCom/based**. Product path: **max25-bcpr** / daemon **max25-bcprd**. Host device: **`max25e0`** (forks `max25e0:bcN` only).
+
+| Layer | Role |
+|-------|------|
+| Hardware | BayCom/based TCM3105-class AFSK (bits↔tones + PTT) |
+| Host | SER12 bit clock, HDLC, KISS PTY via **max25-bcprd-init** → unprivileged **max25-bcprd** loop |
+| KISS release | **`/tmp/max25-bcpr/kiss-bc0`** → HyBBX `baycom` transport |
+| Device id | **`max25e0`** only — never `bcpr` / `bcpr-bc0` product ids |
+| Not | TNC · digipeater · BBX · kernel BayCom product |
+
+Internal C sources may still use `bcpr_*` symbols — product face is **max25-bcpr** only.
+
+## Build (default)
+
+```bash
+cmake -S . -B build
+cmake --build build --target max25-bcprd max25-bcprd-init test_hdlc_offline
+install sets **setuid** on `max25-bcprd-init` (chmod 4755).
+```
+
+Opt-out: `-DMAX25_BUILD_MAX25_BCPR=OFF`.
+
+## Offline smoke
+
+```bash
+stacks/max25-bcpr/tools/max25-bcpr-ctl -c stacks/max25-bcpr/share/max25-bcpr.ini.example smoke
+```
+
+```bash
+# Daemon
+sudo stacks/max25-bcpr/tools/max25-bcpr-ctl -c /etc/max25/max25-bcpr.ini start
+sudo stacks/max25-bcpr/tools/max25-bcpr-ctl -c /etc/max25/max25-bcpr.ini status
+sudo stacks/max25-bcpr/tools/max25-bcpr-ctl -c /etc/max25/max25-bcpr.ini stop
+
+# Interactive diag / force-TX ladder (operator — prove RX first)
+sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh --menu
+```
+
+## max25d + HyBBX cohost
+
+```ini
+[daemon]
+mode = hybbx-cohost
+
+[features]
+max25_bcpr = yes
+
+[devices]
+default = max25e0
+max25e0 = max25-bcpr:bc0
+
+[device.max25e0]
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+max25_bcpr_ini = /etc/max25/max25-bcpr.ini
+ipv4 = 127.0.0.25/8
+ipv6 = ::25/128
+```
+
+max25d starts **max25-bcprd-init** (live) or **max25-bcprd** (dry-run), then **releases** KISS PTY for HyBBX attach. Verified standalone Main co-host 2026-07-31 / 2026-08-01.
+
+Forks `max25e0:bcN` inherit `ipv4` / `ipv6` from `max25e0` unless set on the fork section.
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Operator guide | [../../docs/BAYCOM.md](../../docs/BAYCOM.md) |
+| Freeze caveats (dev) | [../../docs/BAYCOM-FREEZES.md](../../docs/BAYCOM-FREEZES.md) |
+| HyBBX attach | [../../docs/HYBBX.md](../../docs/HYBBX.md) |
+
+## License
+
+Algorithms from Linux `baycom_ser_fdx` / `hdlcdrv` (GPL) — `NOTICE.md`.
diff --git a/stacks/max25-bcpr/VERSION b/stacks/max25-bcpr/VERSION
new file mode 100644
index 0000000..6e8bf73
--- /dev/null
+++ b/stacks/max25-bcpr/VERSION
@@ -0,0 +1 @@
+0.1.0
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr.h b/stacks/max25-bcpr/include/bcpr/bcpr.h
new file mode 100644
index 0000000..6223796
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr.h
@@ -0,0 +1,16 @@
+#ifndef BCPR_H
+#define BCPR_H
+
+#include <stdint.h>
+#include <stddef.h>
+
+#define BCPR_MAX_DEVICES 2
+#define BCPR_SER12_EXTENT 8
+#define BCPR_HDLC_BUF 32
+#define BCPR_MAXFLEN 400
+#define BCPR_MAGIC 0x42525052u /* 'BCPR' */
+
+typedef struct bcpr_device bcpr_device_t;
+typedef struct bcpr_engine bcpr_engine_t;
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_config.h b/stacks/max25-bcpr/include/bcpr/bcpr_config.h
new file mode 100644
index 0000000..158bf73
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_config.h
@@ -0,0 +1,70 @@
+#ifndef BCPR_CONFIG_H
+#define BCPR_CONFIG_H
+
+#include "bcpr/bcpr.h"
+
+/* TXD charge-pump policy (English INI: txd_bias=pulse|steady). */
+enum {
+ BCPR_TXD_PULSE = 0, /* Sailer/default: THR←0x00 every tick (framing edges) */
+ BCPR_TXD_STEADY = 1 /* TFPCX-class experiment: UART break ≈ continuous SPACE */
+};
+
+typedef struct bcpr_dev_config {
+ int enabled;
+ char serial[64];
+ unsigned int iobase;
+ unsigned int irq; /* real UART IRQ — must match setserial */
+ unsigned int baud;
+ char mode[16]; /* ser12* / ser12 / ser12+ */
+ char kiss_link[256];
+ int tx_delay; /* 10 ms units */
+ int tx_tail;
+ int slottime;
+ int ppersist;
+ int fulldup;
+ /*
+ * FlexNet SER12.doc PTT watchdog mirror (cal / sustained key):
+ * after ptt_wd_key_ms keyed → clear RTS ~ptt_wd_pause_ms → resume.
+ * Defaults 14500 / 500. ptt_wd=0 disables.
+ */
+ int ptt_wd;
+ int ptt_wd_key_ms;
+ int ptt_wd_pause_ms;
+ int txd_bias; /* BCPR_TXD_* */
+ /*
+ * Software TOT (host policy — not radio TOT):
+ * max_key_ms = max continuous PTT per burst;
+ * min_gap_ms = min idle between bursts (matches TX pace);
+ * max_consecutive = back-to-back bursts (min_gap..session) before trip;
+ * max_bursts = total bursts before trip (each burst <= max_key_ms).
+ */
+ int tot_enabled;
+ int tot_max_key_ms;
+ int tot_min_gap_ms;
+ int tot_max_consecutive;
+ int tot_max_bursts;
+} bcpr_dev_config_t;
+
+typedef struct bcpr_config {
+ bcpr_dev_config_t dev[BCPR_MAX_DEVICES];
+ int n_dev;
+ int dry_run; /* no ioperm / no setserial */
+ char state_dir[128];
+ char run_user[64];
+ char run_group[64];
+ /* Global defaults copied onto devices that omit per-bcN keys. */
+ int ptt_wd;
+ int ptt_wd_key_ms;
+ int ptt_wd_pause_ms;
+ int txd_bias;
+ int tot_enabled;
+ int tot_max_key_ms;
+ int tot_min_gap_ms;
+ int tot_max_consecutive;
+ int tot_max_bursts;
+} bcpr_config_t;
+
+int bcpr_config_load(bcpr_config_t *cfg, const char *path);
+void bcpr_config_defaults(bcpr_config_t *cfg);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_crc.h b/stacks/max25-bcpr/include/bcpr/bcpr_crc.h
new file mode 100644
index 0000000..5a53b15
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_crc.h
@@ -0,0 +1,12 @@
+#ifndef BCPR_CRC_H
+#define BCPR_CRC_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+/* CRC-CCITT as used by Linux hdlcdrv (good frame residue 0xf0b8). */
+uint16_t bcpr_crc_ccitt(uint16_t crc, const uint8_t *buf, size_t len);
+void bcpr_append_crc_ccitt(uint8_t *buffer, int len);
+int bcpr_check_crc_ccitt(const uint8_t *buf, int cnt);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h b/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h
new file mode 100644
index 0000000..360b0ae
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h
@@ -0,0 +1,23 @@
+#ifndef BCPR_DAEMON_H
+#define BCPR_DAEMON_H
+
+#include "bcpr/bcpr_config.h"
+#include "bcpr/bcpr_engine.h"
+#include "bcpr/bcpr_kiss.h"
+
+typedef struct bcpr_daemon_opts {
+ int dry_run;
+ int seconds;
+ int cal_mode;
+ int cli_txd_bias;
+ int cli_ptt_wd;
+ int cli_ptt_wd_key_ms;
+ int cli_ptt_wd_pause_ms;
+ int engine_preopened;
+} bcpr_daemon_opts_t;
+
+/* engine_preopened=1: lock/ioperm done in init parent; child after fork. */
+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);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_engine.h b/stacks/max25-bcpr/include/bcpr/bcpr_engine.h
new file mode 100644
index 0000000..f3b3d4a
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_engine.h
@@ -0,0 +1,62 @@
+#ifndef BCPR_ENGINE_H
+#define BCPR_ENGINE_H
+
+#include "bcpr/bcpr_config.h"
+#include "bcpr/bcpr_hdlc.h"
+#include "bcpr/bcpr_lock.h"
+#include "bcpr/bcpr_ser12.h"
+
+#include <stdint.h>
+
+struct bcpr_device {
+ bcpr_dev_config_t cfg;
+ bcpr_port_lock_t lock;
+ bcpr_ser12_t ser12;
+ bcpr_hdlc_t hdlc;
+ int running;
+ int index; /* 0 = bc0, 1 = bc1 */
+ /* S0 TX telemetry (reset on PTT rise, emit on PTT fall). */
+ int ptt_was;
+ int64_t ptt_on_ns;
+ unsigned thr_writes;
+ unsigned max_tick_gap_us;
+ unsigned last_tick_us;
+ unsigned tick_count; /* bit ticks while keyed */
+ unsigned long long gap_sum_us; /* for mean gap */
+ unsigned gaps_gt_2x; /* gaps > 2× baud_us */
+ int tx_div_set; /* UART baud_uartdiv applied for this PTT */
+ int break_set; /* txd_bias=steady: LCR break asserted */
+ /* Software TOT runtime (see tot_* in bcpr_dev_config_t). */
+ int tot_tripped;
+ int tot_burst_total;
+ int tot_consecutive;
+ unsigned tot_last_off_us;
+ unsigned tot_key_start_us;
+};
+
+typedef void (*bcpr_rx_fn)(int dev_idx, const uint8_t *kiss, int len, void *ud);
+
+struct bcpr_engine {
+ bcpr_config_t cfg;
+ bcpr_device_t dev[BCPR_MAX_DEVICES];
+ int n;
+ volatile int stop;
+ bcpr_rx_fn on_rx;
+ void *on_rx_ud;
+ /* 0 = forever; >0 stop after wall-clock seconds (tests / dry-run). */
+ int run_seconds;
+ /* BCPR_CAL_* on all enabled devices; 0 = normal KISS/HDLC. */
+ int cal_mode;
+};
+
+int bcpr_engine_open(bcpr_engine_t *e, const bcpr_config_t *cfg);
+void bcpr_engine_close(bcpr_engine_t *e);
+void bcpr_engine_set_rx(bcpr_engine_t *e, bcpr_rx_fn fn, void *ud);
+int bcpr_engine_queue_kiss(bcpr_engine_t *e, int dev_idx, const uint8_t *kiss,
+ int len);
+/* Apply DOS-style cal (high/low/alt) before bcpr_engine_run. */
+void bcpr_engine_set_cal(bcpr_engine_t *e, int cal_mode);
+/* Run until e->stop or run_seconds elapses. Dry-run skips UART I/O. */
+int bcpr_engine_run(bcpr_engine_t *e);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h b/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h
new file mode 100644
index 0000000..0cf0bfb
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h
@@ -0,0 +1,57 @@
+#ifndef BCPR_HDLC_H
+#define BCPR_HDLC_H
+
+#include "bcpr/bcpr.h"
+#include <stdint.h>
+
+typedef struct bcpr_hbuf {
+ unsigned rd, wr;
+ uint16_t buf[BCPR_HDLC_BUF];
+} bcpr_hbuf_t;
+
+typedef struct bcpr_channel {
+ int tx_delay, tx_tail, slottime, ppersist, fulldup;
+} bcpr_channel_t;
+
+typedef struct bcpr_hdlc {
+ int bitrate;
+ bcpr_channel_t ch;
+ bcpr_hbuf_t rx_hbuf;
+ bcpr_hbuf_t tx_hbuf;
+ int rx_state;
+ unsigned bitstream, bitbuf;
+ int numbits;
+ int dcd;
+ int rx_len;
+ uint8_t *rx_bp;
+ uint8_t rx_buffer[BCPR_MAXFLEN + 2];
+ int tx_state;
+ int numflags;
+ unsigned tx_bitstream;
+ int ptt;
+ int slotcnt;
+ unsigned tx_bitbuf;
+ int tx_numbits;
+ int tx_len;
+ uint8_t *tx_bp;
+ uint8_t tx_buffer[BCPR_MAXFLEN + 2];
+ /* pending KISS payload (without FEND/cmd) queued for TX */
+ uint8_t pending[BCPR_MAXFLEN];
+ int pending_len;
+ int have_pending;
+} bcpr_hdlc_t;
+
+void bcpr_hdlc_init(bcpr_hdlc_t *h, int bitrate, const bcpr_channel_t *ch);
+void bcpr_hdlc_putbits(bcpr_hdlc_t *h, unsigned bits);
+unsigned bcpr_hdlc_getbits(bcpr_hdlc_t *h);
+void bcpr_hdlc_receiver(bcpr_hdlc_t *h,
+ void (*on_frame)(const uint8_t *kiss, int len, void *ud),
+ void *ud);
+void bcpr_hdlc_transmitter(bcpr_hdlc_t *h);
+void bcpr_hdlc_arbitrate(bcpr_hdlc_t *h);
+int bcpr_hdlc_queue_kiss(bcpr_hdlc_t *h, const uint8_t *kiss, int len);
+int bcpr_hdlc_ptt(const bcpr_hdlc_t *h);
+/* Force idle TX — drop pending frame and clear keyed state. */
+void bcpr_hdlc_abort_tx(bcpr_hdlc_t *h);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h b/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h
new file mode 100644
index 0000000..2cdf5eb
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h
@@ -0,0 +1,17 @@
+#ifndef BCPR_KISS_H
+#define BCPR_KISS_H
+
+#include "bcpr/bcpr.h"
+
+typedef struct bcpr_kiss_pty {
+ int master_fd;
+ int slave_fd;
+ char link_path[128];
+ int idx;
+} bcpr_kiss_pty_t;
+
+int bcpr_kiss_pty_open(bcpr_kiss_pty_t *kp, int idx, const char *link_path,
+ const char *state_dir);
+void bcpr_kiss_pty_close(bcpr_kiss_pty_t *kp);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_lock.h b/stacks/max25-bcpr/include/bcpr/bcpr_lock.h
new file mode 100644
index 0000000..fd6c6b7
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_lock.h
@@ -0,0 +1,22 @@
+#ifndef BCPR_LOCK_H
+#define BCPR_LOCK_H
+
+#include "bcpr/bcpr_config.h"
+
+typedef struct bcpr_port_lock {
+ char serial[64];
+ unsigned iobase;
+ unsigned irq;
+ int lock_fd;
+ int uart_released;
+ int dry_run;
+} bcpr_port_lock_t;
+
+/* Exclusive COM lock: flock + setserial uart none. irq must match setserial. */
+int bcpr_lock_acquire(bcpr_port_lock_t *lk, const bcpr_dev_config_t *dev,
+ int dry_run);
+void bcpr_lock_release(bcpr_port_lock_t *lk);
+int bcpr_lock_verify_irq(const char *serial, unsigned expect_irq,
+ unsigned *got_irq, unsigned *got_io);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_runas.h b/stacks/max25-bcpr/include/bcpr/bcpr_runas.h
new file mode 100644
index 0000000..801dcba
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_runas.h
@@ -0,0 +1,12 @@
+#ifndef BCPR_RUNAS_H
+#define BCPR_RUNAS_H
+
+#include "bcpr/bcpr_config.h"
+
+/* Drop to [max25-bcpr] user=/group= (Linux). Returns 0 or -1. */
+int bcpr_runas_drop(const bcpr_config_t *cfg);
+
+/* Refuse live start as root — use max25-bcprd-init for hardware. */
+int bcpr_runas_refuse_root(int dry_run);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h b/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h
new file mode 100644
index 0000000..2b6de8d
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h
@@ -0,0 +1,47 @@
+#ifndef BCPR_SER12_H
+#define BCPR_SER12_H
+
+#include "bcpr/bcpr_hdlc.h"
+#include <stdint.h>
+
+/* DOS cal.exe style: sticky/toggle DTR bit + RTS PTT (no HDLC). */
+enum {
+ BCPR_CAL_OFF = 0,
+ BCPR_CAL_HIGH = 1,
+ BCPR_CAL_LOW = 2,
+ BCPR_CAL_ALT = 3
+};
+
+typedef struct bcpr_ser12 {
+ unsigned baud;
+ unsigned baud_us;
+ int opt_dcd; /* 0 soft, 1 hard, -1 hard inv */
+ unsigned char tx_bit;
+ unsigned char last_rxbit;
+ int dcd_sum0, dcd_sum1, dcd_sum2;
+ int dcd_time;
+ unsigned pll_time;
+ unsigned txshreg;
+ unsigned shreg;
+ int ptt_hw; /* modem PTT keyed (logical; stays 1 during WD pause) */
+ int cal_mode; /* BCPR_CAL_* — bypasses HDLC TX */
+ /* FlexNet SER12 PTT WD — see bcpr_ser12_set_ptt_wd(). */
+ int ptt_wd;
+ unsigned ptt_wd_key_us;
+ unsigned ptt_wd_pause_us;
+ unsigned ptt_wd_phase_start_us;
+ int ptt_wd_pausing; /* 1 → force MCR idle (RTS clear) this tick */
+} bcpr_ser12_t;
+
+void bcpr_ser12_init(bcpr_ser12_t *s, unsigned baud, int opt_dcd);
+void bcpr_ser12_set_mode(bcpr_ser12_t *s, const char *mode, unsigned *baud_out);
+void bcpr_ser12_set_cal(bcpr_ser12_t *s, int cal_mode);
+/* FlexNet defaults: key_ms=14500, pause_ms=500. enable=0 disables. */
+void bcpr_ser12_set_ptt_wd(bcpr_ser12_t *s, int enable, int key_ms, int pause_ms);
+/* Software TOT: drop logical+HW PTT immediately. */
+void bcpr_ser12_force_unkey(bcpr_ser12_t *s);
+/* One bit-time / tick: sample cts (0/1), drive *mcr_out / *thr_feed */
+void bcpr_ser12_tick(bcpr_ser12_t *s, bcpr_hdlc_t *h, int cts, int *mcr_out,
+ int *do_thr00, unsigned now_us);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_uart.h b/stacks/max25-bcpr/include/bcpr/bcpr_uart.h
new file mode 100644
index 0000000..fde720e
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_uart.h
@@ -0,0 +1,22 @@
+#ifndef BCPR_UART_H
+#define BCPR_UART_H
+
+#include <stdint.h>
+
+/* When set, all UART I/O is a no-op (CI / --dry-run). */
+void bcpr_uart_set_dry_run(int on);
+int bcpr_uart_ioperm(unsigned iobase, int on);
+void bcpr_uart_outb(unsigned char val, unsigned port);
+unsigned char bcpr_uart_inb(unsigned port);
+void bcpr_uart_set_divisor(unsigned iobase, unsigned divisor);
+void bcpr_uart_open_ser12(unsigned iobase);
+void bcpr_uart_close_ser12(unsigned iobase);
+unsigned char bcpr_uart_msr(unsigned iobase);
+void bcpr_uart_mcr(unsigned iobase, unsigned char v);
+void bcpr_uart_thr00(unsigned iobase);
+/* LCR bit6 Set Break — force TXD continuous SPACE (RS-232 +V). Clear = normal. */
+void bcpr_uart_set_break(unsigned iobase, int on);
+/* Poll LSR bit5 (THRE) until set or timeout_us. Returns 1 if empty, 0 on timeout. */
+int bcpr_uart_wait_thre(unsigned iobase, unsigned timeout_us);
+
+#endif
diff --git a/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example b/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example
new file mode 100644
index 0000000..70f0fd1
--- /dev/null
+++ b/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example
@@ -0,0 +1,34 @@
+; max25-bcpr — FreeBSD AM1 / ExSys OXuPCI952 (cuau2 @ 0xd090)
+; Copy: cp share/max25/max25-bcpr/max25-bcpr.freebsd.ini.example /usr/local/etc/max25/max25-bcpr.ini
+; Live: dry_run=no · max25-bcpr-ctl start --ini /usr/local/etc/max25/max25-bcpr.ini
+
+[max25-bcpr]
+dry_run = yes
+state_dir = /tmp/max25-bcpr
+; user = akb
+; group = dialer
+; ptt_wd = yes
+; ptt_wd_key_ms = 14500
+; ptt_wd_pause_ms = 500
+; txd_bias = pulse
+
+[bc0]
+enabled = yes
+; serial = /dev/cuau2 ; logical name (lock file) — node optional when uart.2 disabled
+iobase = 0xd090
+irq = 36
+mode = ser12*
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+baud = 1200
+tx_delay = 35
+tx_tail = 2
+slottime = 10
+ppersist = 40
+fulldup = no
+
+; [bc1]
+; enabled = no
+; serial = /dev/cuau3
+; iobase = 0xd080
+; irq = 36
+; kiss_link = /tmp/max25-bcpr/kiss-bc1
diff --git a/stacks/max25-bcpr/share/max25-bcpr.ini.example b/stacks/max25-bcpr/share/max25-bcpr.ini.example
new file mode 100644
index 0000000..3a553c8
--- /dev/null
+++ b/stacks/max25-bcpr/share/max25-bcpr.ini.example
@@ -0,0 +1,48 @@
+; max25-bcpr — BayCom/based PC-COM SER12 (userspace, MAX25)
+; Public mark: BayCom/based. Never Konverter/converter.
+; Product: BayCom/based PC-COM SER12 (userspace, MAX25). Built by default (MAX25_BUILD_MAX25_BCPR=ON).
+; Hardware: TCM3105-class AFSK modem chip (bits↔tones + PTT). Not a TNC/digi/BBX.
+; Host (max25-bcprd / max25d) owns HDLC, KISS, bit clock → device max25e0 (:bcN forks).
+; Internal C API may still use bcpr_* symbols — product face is max25-bcpr only.
+;
+; Quick start (offline, after -DMAX25_BUILD_MAX25_BCPR=ON):
+; max25-bcprd -c stacks/max25-bcpr/share/max25-bcpr.ini.example --dry-run --once
+; Live: copy to /etc/max25/max25-bcpr.ini or ./local/max25-bcpr.ini; set dry_run=no.
+
+[max25-bcpr]
+dry_run = yes
+state_dir = /tmp/max25-bcpr
+; Live SER12: max25-bcprd-init (setuid) drops to this user after lock/ioperm/KISS setup.
+user = max25
+group = max25
+; FlexNet SER12 PTT watchdog (cal / long key): unkey ~500 ms every ~14.5 s
+; ptt_wd = yes
+; ptt_wd_key_ms = 14500
+; ptt_wd_pause_ms = 500
+; TXD pump: pulse = Sailer THR 0x00 (default); steady = UART break (TFPCX-class)
+; txd_bias = pulse
+; Software TOT (max25d [tot] syncs these on stack start; override per site if needed)
+; tot = yes
+; tot_max_key_sec = 25
+; tot_min_gap_ms = 1500
+; tot_max_consecutive = 3
+; tot_max_bursts = 8
+
+[bc0]
+enabled = yes
+serial = /dev/ttyS0
+iobase = 0x3f8
+irq = 4
+mode = ser12*
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+baud = 1200
+tx_delay = 35
+tx_tail = 2
+slottime = 10
+ppersist = 40
+fulldup = no
+
+; [bc1]
+; enabled = no
+; serial = /dev/ttyS1
+; kiss_link = /tmp/max25-bcpr/kiss-bc1
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);
+}
diff --git a/stacks/max25-bcpr/tests/test_config_offline.c b/stacks/max25-bcpr/tests/test_config_offline.c
new file mode 100644
index 0000000..8b40a6a
--- /dev/null
+++ b/stacks/max25-bcpr/tests/test_config_offline.c
@@ -0,0 +1,53 @@
+/*
+ * Offline config parse test — no UART.
+ */
+#include "bcpr/bcpr_config.h"
+
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+int main(void)
+{
+ const char *path = "share/max25-bcpr.ini.example";
+ bcpr_config_t cfg;
+ char cwd[512];
+
+ if (!getcwd(cwd, sizeof(cwd))) {
+ return 1;
+ }
+ /* Prefer path relative to build or source. */
+ if (access(path, R_OK) != 0) {
+ path = "../share/max25-bcpr.ini.example";
+ }
+ if (access(path, R_OK) != 0) {
+ path = "../../stacks/max25-bcpr/share/max25-bcpr.ini.example";
+ }
+ if (bcpr_config_load(&cfg, path) != 0) {
+ /* Write a minimal temp INI. */
+ FILE *f = fopen("/tmp/max25-bcpr-test.ini", "w");
+ if (!f) {
+ return 1;
+ }
+ fputs("[max25-bcpr]\ndry_run = yes\nstate_dir = /tmp/max25-bcpr\n"
+ "[bc0]\nserial = /dev/ttyS0\niobase = 0x3f8\nirq = 4\n"
+ "mode = ser12*\nkiss_link = /tmp/max25-bcpr/kiss-bc0\n",
+ f);
+ fclose(f);
+ path = "/tmp/max25-bcpr-test.ini";
+ if (bcpr_config_load(&cfg, path) != 0) {
+ fprintf(stderr, "FAIL: load\n");
+ return 1;
+ }
+ }
+ if (cfg.n_dev < 1 || !cfg.dev[0].enabled) {
+ fprintf(stderr, "FAIL: no bc0\n");
+ return 1;
+ }
+ if (cfg.dev[0].irq != 4 || cfg.dev[0].iobase != 0x3f8) {
+ fprintf(stderr, "FAIL: irq/iobase\n");
+ return 1;
+ }
+ puts("OK: test_config_offline");
+ return 0;
+}
diff --git a/stacks/max25-bcpr/tests/test_hdlc_offline.c b/stacks/max25-bcpr/tests/test_hdlc_offline.c
new file mode 100644
index 0000000..d05d5b6
--- /dev/null
+++ b/stacks/max25-bcpr/tests/test_hdlc_offline.c
@@ -0,0 +1,110 @@
+/*
+ * Offline HDLC unit test — CRC, bitstuff roundtrip, no UART / UART I/O.
+ */
+#include "bcpr/bcpr_crc.h"
+#include "bcpr/bcpr_hdlc.h"
+
+#include <stdio.h>
+#include <string.h>
+
+static int g_got;
+static uint8_t g_rx[BCPR_MAXFLEN + 4];
+static int g_rx_len;
+
+static void on_frame(const uint8_t *kiss, int len, void *ud)
+{
+ (void)ud;
+ if (len > 0 && len <= (int)sizeof(g_rx)) {
+ memcpy(g_rx, kiss, (size_t)len);
+ g_rx_len = len;
+ g_got = 1;
+ }
+}
+
+static int test_crc(void)
+{
+ uint8_t buf[16];
+ memcpy(buf, "TEST", 4);
+ bcpr_append_crc_ccitt(buf, 4);
+ if (!bcpr_check_crc_ccitt(buf, 6)) {
+ fprintf(stderr, "FAIL: CRC check\n");
+ return 1;
+ }
+ buf[0] ^= 0xff;
+ if (bcpr_check_crc_ccitt(buf, 6)) {
+ fprintf(stderr, "FAIL: CRC should fail on corrupt\n");
+ return 1;
+ }
+ return 0;
+}
+
+static int test_roundtrip(void)
+{
+ bcpr_hdlc_t tx, rx;
+ bcpr_channel_t ch = { .tx_delay = 2, .tx_tail = 1, .slottime = 1,
+ .ppersist = 255, .fulldup = 1 };
+ uint8_t kiss[32];
+ int i, words = 0;
+
+ bcpr_hdlc_init(&tx, 1200, &ch);
+ bcpr_hdlc_init(&rx, 1200, &ch);
+
+ kiss[0] = 0; /* KISS DATA */
+ memcpy(kiss + 1, "HELLO", 5);
+ if (bcpr_hdlc_queue_kiss(&tx, kiss, 6) != 0) {
+ fprintf(stderr, "FAIL: queue\n");
+ return 1;
+ }
+ bcpr_hdlc_arbitrate(&tx);
+ if (!bcpr_hdlc_ptt(&tx)) {
+ fprintf(stderr, "FAIL: PTT not set\n");
+ return 1;
+ }
+
+ g_got = 0;
+ g_rx_len = 0;
+ /* Drain TX bit words into RX putbits path. */
+ for (i = 0; i < 4096 && !g_got; i++) {
+ unsigned w;
+ bcpr_hdlc_transmitter(&tx);
+ w = bcpr_hdlc_getbits(&tx);
+ if (w) {
+ bcpr_hdlc_putbits(&rx, w);
+ words++;
+ bcpr_hdlc_receiver(&rx, on_frame, NULL);
+ } else if (!bcpr_hdlc_ptt(&tx)) {
+ bcpr_hdlc_transmitter(&tx);
+ /* flush remaining */
+ while (!g_got) {
+ w = bcpr_hdlc_getbits(&tx);
+ if (!w) {
+ break;
+ }
+ bcpr_hdlc_putbits(&rx, w);
+ bcpr_hdlc_receiver(&rx, on_frame, NULL);
+ }
+ break;
+ }
+ }
+
+ if (!g_got) {
+ fprintf(stderr, "FAIL: no RX frame (words=%d)\n", words);
+ return 1;
+ }
+ if (g_rx_len < 6 || g_rx[0] != 0 || memcmp(g_rx + 1, "HELLO", 5) != 0) {
+ fprintf(stderr, "FAIL: payload mismatch len=%d\n", g_rx_len);
+ return 1;
+ }
+ return 0;
+}
+
+int main(void)
+{
+ int rc = 0;
+ rc |= test_crc();
+ rc |= test_roundtrip();
+ if (rc == 0) {
+ puts("OK: test_hdlc_offline");
+ }
+ return rc;
+}
diff --git a/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md b/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md
new file mode 100644
index 0000000..8a4ac20
--- /dev/null
+++ b/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md
@@ -0,0 +1,5 @@
+# Dauerlauf helpers — not shipped here
+
+Experimental endurance / orch helpers are **not** part of this freigegeben product tree.
+
+Product L1 remains: `max25-bcpr-ctl`, smoke, ultimate-diag · host face **`max25e0`** · public mark **BayCom/based**.
diff --git a/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh b/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh
new file mode 100755
index 0000000..573585c
--- /dev/null
+++ b/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh
@@ -0,0 +1,916 @@
+#!/usr/bin/env bash
+# bcpr-ultimate-diag.sh — interactive BayCom/based (bcpr) TX/RX diagnostic ladder
+# Public mark: BayCom/based. Internal path: bcpr. Never Konverter/converter.
+# Target: AX25WRK1 intermittent "host keys, RF sometimes" (mic OK 4W; host MCR OK).
+#
+# Soft-TNC RE (2026-07-19) — operator warnings only (no MCR/code patch here):
+# • FlexNet SER12 cal PTT watchdog: ~14.5 s keyed → ~500 ms unkey (discharge).
+# Long continuous force-tx/cal: bcprd ptt_wd drops RTS ~500 ms every ~14.5 s
+# (FlexNet SER12 mirror; disable with ptt_wd=no / --no-ptt-wd).
+# • TXD: default pulse THR 0x00; experiment txd_bias=steady (UART break ≈ TFPCX).
+# • Keep bcprd MCR Sailer 0x0e|bit / 0x0d (4PC-COM 0x0A is outlier — do not switch).
+# Vault RE: 0-RESEARCHES/projects/max25-stack/2026-07-19-baycom-soft-tnc-serial-ptt-re.md
+#
+# Usage (Cursor IDE terminal, as operator):
+# sudo -n /home/akb/Code/10-PROJECTS/MAX25-Stack/stacks/bcpr/tools/bcpr-ultimate-diag.sh
+# BCPR_INI=/etc/max25/bcpr.ini ./bcpr-ultimate-diag.sh -c /etc/max25/bcpr.ini
+# ./bcpr-ultimate-diag.sh --help
+# ./bcpr-ultimate-diag.sh --all # run phases 1–9 with pauses
+# ./bcpr-ultimate-diag.sh --menu # interactive menu (default)
+#
+# Needs: /usr/bin/sudo -n for ioport / live smoke / cal. Does NOT change MCR code.
+# Does NOT use USB as product TX path. Does NOT kill max25d carelessly.
+#
+# Vault: 0-RESEARCHES/projects/max25-stack/2026-07-19-bcpr-ultimate-diag-script.md
+# Relies on: bcpr-ctl, bcpr-rxtx-smoke.sh (same directory).
+
+# Note: interactive prompts use read; keep pipefail but relax -e around optional probes.
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)"
+CTL="${SCRIPT_DIR}/bcpr-ctl"
+SMOKE_SH="${SCRIPT_DIR}/bcpr-rxtx-smoke.sh"
+INI="${BCPR_INI:-/etc/max25/bcpr.ini}"
+SUDO="/usr/bin/sudo"
+LOG=""
+MODE="menu" # menu | all | phaseN
+PHASE_ONLY=""
+RUN_TS=""
+
+# Result arrays for summary (parallel indices)
+declare -a RES_PHASE=()
+declare -a RES_HOST=()
+declare -a RES_WATT=()
+declare -a RES_BLED=()
+declare -a RES_RDISP=()
+declare -a RES_NOTE=()
+
+usage() {
+ cat <<'USAGE'
+bcpr-ultimate-diag.sh — BayCom/based (bcpr) interactive diagnostic / force-TX ladder
+
+Usage:
+ bcpr-ultimate-diag.sh [-c INI] [--menu|--all|--phase N] [--help]
+
+Options:
+ -c INI bcpr.ini (default: $BCPR_INI or /etc/max25/bcpr.ini)
+ --menu interactive German menu (default)
+ --all run phases 1–9 linearly with pauses + RF prompts
+ --phase N run a single phase (1–9, or 0=restart menu)
+ -h, --help this help
+
+Environment:
+ BCPR_INI same as -c
+ BCPRD optional path to bcprd binary
+
+Phases:
+ 1 Preflight (ttyS0, fuser, dosbox-x, bcprd/max25d, INI, locks)
+ 2 Idle MCR sample via /dev/port
+ 3 bcpr-ctl status + telem (tx-last / dcd / rx-activity)
+ 4 RX listen (soft-DCD) — open SQ reminder
+ 5 Force-TX ladder 1s / 3s / 5s / 8s + wattmeter/LED/display prompts
+ 6 Cal/high (bcprd --cal high) if binary supports it
+ 7 Double-burst / back-to-back TX
+ 8 Contact hunt (DE-9 + 3.5mm) + 5s force-tx + wiggle
+ 9 Summary table + verdict hints
+ 0 Optional safe bcprd restart (bcpr-ctl stop/start only)
+ U USB note (SER12 unsupported on USB — not product TX)
+
+Log: /tmp/bcpr-ultimate-diag-YYYYMMDD-HHMMSS.log
+
+Examples:
+ sudo -n stacks/bcpr/tools/bcpr-ultimate-diag.sh --all
+ BCPR_INI=/etc/max25/bcpr.ini sudo -n ./bcpr-ultimate-diag.sh --menu
+USAGE
+}
+
+# ---------- logging / UI ----------
+
+log() {
+ local line
+ line="$(printf '%s' "$*")"
+ printf '%s\n' "$line"
+ if [[ -n "${LOG:-}" ]]; then
+ printf '%s\n' "$line" >>"$LOG"
+ fi
+}
+
+log_raw() {
+ # stdin → stdout + log
+ if [[ -n "${LOG:-}" ]]; then
+ tee -a "$LOG"
+ else
+ cat
+ fi
+}
+
+banner() {
+ log ""
+ log "════════════════════════════════════════════════════════════"
+ log "$*"
+ log "════════════════════════════════════════════════════════════"
+}
+
+pause_enter() {
+ local msg="${1:-Weiter mit Enter …}"
+ printf '\n%s ' "$msg" >/dev/tty
+ # shellcheck disable=SC2034
+ local _dummy
+ read -r _dummy </dev/tty || true
+ log "[pause] $msg"
+}
+
+ask() {
+ # ask VAR "prompt"
+ local __var="$1"
+ local __prompt="$2"
+ local __ans=""
+ printf '%s ' "$__prompt" >/dev/tty
+ read -r __ans </dev/tty || true
+ printf -v "$__var" '%s' "$__ans"
+ log "[antwort] $__prompt → ${__ans}"
+}
+
+ask_yn() {
+ # ask_yn VAR "prompt" → stores j/n/y/n normalized to j|n
+ local __var="$1"
+ local __prompt="$2"
+ local __ans=""
+ while true; do
+ printf '%s [j/n]: ' "$__prompt" >/dev/tty
+ read -r __ans </dev/tty || true
+ case "${__ans,,}" in
+ j|ja|y|yes) printf -v "$__var" 'j'; log "[antwort] $__prompt → j"; return 0 ;;
+ n|nein|no) printf -v "$__var" 'n'; log "[antwort] $__prompt → n"; return 0 ;;
+ *) printf 'Bitte j oder n.\n' >/dev/tty ;;
+ esac
+ done
+}
+
+record_result() {
+ local phase="$1" host="$2" watt="$3" bled="$4" rdisp="$5" note="${6:-}"
+ RES_PHASE+=("$phase")
+ RES_HOST+=("$host")
+ RES_WATT+=("$watt")
+ RES_BLED+=("$bled")
+ RES_RDISP+=("$rdisp")
+ RES_NOTE+=("$note")
+}
+
+need_sudo() {
+ if [[ ! -x "$SUDO" ]]; then
+ log "FEHLER: $SUDO fehlt — Live-Phasen brauchen sudo."
+ return 1
+ fi
+ if ! "$SUDO" -n true 2>/dev/null; then
+ log "FEHLER: sudo -n fehlgeschlagen."
+ log " Bitte einmalig: sudo -v (oder NOPASSWD für diesen User)"
+ log " Dann Skript erneut starten."
+ return 1
+ fi
+ return 0
+}
+
+run_sudo() {
+ need_sudo || return 1
+ "$SUDO" -n "$@"
+}
+
+ini_get() {
+ local section="$1" key="$2"
+ [[ -f "$INI" ]] || return 0
+ awk -F= -v s="[$section]" -v k="$key" '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI" 2>/dev/null || true
+}
+
+resolve_bcprd() {
+ if [[ -n "${BCPRD:-}" && "$BCPRD" != "bcprd" && -x "$BCPRD" ]]; then
+ printf '%s\n' "$BCPRD"; return 0
+ fi
+ if command -v bcprd >/dev/null 2>&1; then
+ command -v bcprd; return 0
+ fi
+ local cand
+ for cand in \
+ "${BCPR_BUILD_DIR:-}/bin/bcprd" \
+ "${ROOT}/build-bcpr/bin/bcprd" \
+ "${ROOT}/build-bcpr-${USER:-user}/bin/bcprd" \
+ "/tmp/max25-build-bcpr-${USER:-user}/bin/bcprd" \
+ "${ROOT}/build/bin/bcprd" \
+ /usr/local/bin/bcprd /usr/bin/bcprd; do
+ [[ -n "$cand" && -x "$cand" ]] && { printf '%s\n' "$cand"; return 0; }
+ done
+ return 1
+}
+
+serial_dev() {
+ local s
+ s="$(ini_get bc0 serial)"
+ printf '%s\n' "${s:-/dev/ttyS0}"
+}
+
+state_dir() {
+ local sd
+ sd="$(ini_get bcpr state_dir)"
+ printf '%s\n' "${sd:-/tmp/bcpr}"
+}
+
+iobase_val() {
+ local b
+ b="$(ini_get bc0 iobase)"
+ printf '%s\n' "${b:-0x3f8}"
+}
+
+ensure_tools() {
+ local err=0
+ if [[ ! -x "$CTL" ]]; then
+ log "FEHLER: bcpr-ctl fehlt: $CTL"; err=1
+ fi
+ if [[ ! -x "$SMOKE_SH" ]]; then
+ log "FEHLER: bcpr-rxtx-smoke.sh fehlt: $SMOKE_SH"; err=1
+ fi
+ return "$err"
+}
+
+# ---------- Phase helpers ----------
+
+# Last smoke host result (PASS|FAIL) — do not capture function stdout (log noise).
+_LAST_HOST="—"
+
+smoke_force_tx() {
+ # smoke_force_tx SECONDS → sets _LAST_HOST
+ local secs="$1"
+ local listen=$((secs + 8))
+ local rc=0
+ [[ "$listen" -lt 12 ]] && listen=12
+ log "→ L4 force-tx ${secs}s (smoke --live --tx --force-tx --tx-seconds ${secs})"
+ run_sudo "$CTL" -c "$INI" smoke --live --tx --force-tx --seconds "$listen" --tx-seconds "$secs" 2>&1 | log_raw
+ rc=${PIPESTATUS[0]}
+ if [[ "$rc" -eq 0 ]]; then
+ _LAST_HOST="PASS"
+ log "HOST: PASS (MCR keyed ~${secs}s target)"
+ else
+ _LAST_HOST="FAIL"
+ log "HOST: FAIL (smoke rc=$rc)"
+ fi
+}
+
+prompt_rf_obs() {
+ # prompt_rf_obs PHASE_LABEL HOST_RESULT
+ # sets globals: _watt _bled _rdisp _extra
+ local label="$1" host="$2"
+ local watt bled rdisp extra
+ log ""
+ log "--- RF-Beobachtung: $label (Host=$host) ---"
+ log "Bitte Wattmeter / Board-LED / Radio-Display während des Keys prüfen."
+ ask watt "Wattmeter W (Zahl oder 0 / ?):"
+ ask_yn bled "Board-LED (PC-COM) an während Key?"
+ ask_yn rdisp "Radio-Display / TX-Anzeige an?"
+ ask extra "Kurznotiz (Enter = keine):"
+ _watt="$watt"
+ _bled="$bled"
+ _rdisp="$rdisp"
+ _extra="$extra"
+ record_result "$label" "$host" "$watt" "$bled" "$rdisp" "$extra"
+}
+
+# ---------- Phases ----------
+
+phase_preflight() {
+ banner "Phase 1 — Preflight"
+ local serial sd iobase irq fulldup dry
+ serial="$(serial_dev)"
+ sd="$(state_dir)"
+ iobase="$(iobase_val)"
+ irq="$(ini_get bc0 irq)"
+ fulldup="$(ini_get bc0 fulldup)"
+ dry="$(ini_get bcpr dry_run)"
+
+ log "INI: $INI"
+ if [[ ! -f "$INI" ]]; then
+ log "FEHLER: INI fehlt: $INI"
+ log " Hinweis: Beispiel → stacks/bcpr/share/bcpr.ini.example → /etc/max25/bcpr.ini"
+ return 1
+ fi
+
+ log "--- INI Auszug [bcpr]/[bc0] ---"
+ log " dry_run=${dry:-?} state_dir=${sd}"
+ log " serial=${serial} iobase=${iobase} irq=${irq:-?} fulldup=${fulldup:-?}"
+ log " mode=$(ini_get bc0 mode) kiss_link=$(ini_get bc0 kiss_link)"
+ log " baud=$(ini_get bc0 baud) tx_delay=$(ini_get bc0 tx_delay)"
+
+ log "--- Seriell ---"
+ if [[ -e "$serial" ]]; then
+ log "OK: $serial existiert"
+ ls -l "$serial" 2>&1 | log_raw || true
+ else
+ log "FEHLER: $serial fehlt"
+ fi
+
+ log "--- fuser / lsof (wer hält Port?) ---"
+ if command -v fuser >/dev/null 2>&1; then
+
+ run_sudo fuser -v "$serial" 2>&1 | log_raw
+
+ else
+ log "WARN: fuser nicht installiert"
+ fi
+ if command -v lsof >/dev/null 2>&1; then
+
+ run_sudo lsof "$serial" 2>&1 | log_raw
+
+ fi
+
+ log "--- dosbox-x / DOS-Gast ---"
+ if pgrep -a -f 'dosbox-x|dosbox' 2>/dev/null | log_raw; then
+ log "WARN: dosbox läuft — kann ttyS0/USB belegen (sniff/passthrough)."
+ else
+ log "OK: kein dosbox/dosbox-x Prozess"
+ fi
+
+ log "--- Prozesse bcprd / max25d ---"
+ pgrep -a -x bcprd 2>/dev/null | log_raw || log " bcprd: nicht laufend"
+ pgrep -a -x max25d 2>/dev/null | log_raw || log " max25d: nicht laufend"
+ # wrapper scripts
+ pgrep -a -f 'run-max25d|max25d' 2>/dev/null | head -20 | log_raw || true
+
+ log "--- Lock / State ($sd) ---"
+ if [[ -d "$sd" ]]; then
+ ls -la "$sd" 2>&1 | log_raw || true
+ for f in "$sd"/lock* "$sd"/*.lock "$sd"/bcprd.pid; do
+ [[ -e "$f" ]] || continue
+ log " lock/pid: $f"
+ [[ -f "$f" ]] && { log " content:"; cat "$f" 2>&1 | log_raw || true; }
+ done
+ else
+ log "WARN: state_dir fehlt: $sd"
+ fi
+
+ log "--- Kernel baycom_* Module (sollten NICHT geladen sein) ---"
+ if command -v lsmod >/dev/null 2>&1; then
+ lsmod 2>/dev/null | awk '/^baycom_/ {print}' | log_raw || log "OK: keine baycom_* Module"
+ fi
+
+ log "--- setserial (falls vorhanden) ---"
+ local ss
+ for ss in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do
+ if [[ -x "$ss" ]]; then
+
+ run_sudo "$ss" -g "$serial" 2>&1 | log_raw
+
+ break
+ fi
+ done
+
+ log "--- bcpr-ctl preflight ---"
+
+ run_sudo "$CTL" -c "$INI" preflight 2>&1 | log_raw
+ local pf=$?
+
+ if [[ "$pf" -eq 0 ]]; then
+ log "OK: preflight PASS"
+ else
+ log "WARN: preflight rc=$pf (Port busy wenn Stack läuft — normal bei live max25d/bcprd)"
+ fi
+
+ log "--- Tools ---"
+ log " CTL=$CTL"
+ log " SMOKE=$SMOKE_SH"
+ local bin
+ if bin="$(resolve_bcprd)"; then
+ log " bcprd=$bin"
+ else
+ log " WARN: bcprd Binary nicht gefunden (Build: -DMAX25_BUILD_BCPR=ON)"
+ fi
+
+ pause_enter "Phase 1 fertig — Enter für weiter …"
+ return 0
+}
+
+phase_idle_mcr() {
+ banner "Phase 2 — Idle MCR Sample (/dev/port)"
+ local iobase mcr_off
+ iobase="$(iobase_val)"
+ # MCR = iobase+4
+ mcr_off=$((iobase + 4))
+ log "iobase=$iobase MCR=$(printf '0x%x' "$mcr_off") (iobase+4)"
+ log "Erwartung Idle (Sailer/bcpr): oft 0x0d (DTR+OUT2+RTS-clear) — Werte nur Info."
+
+ if ! need_sudo; then
+ log "überspringe MCR-Sample (kein sudo)"
+ pause_enter
+ return 0
+ fi
+
+ run_sudo python3 - "$iobase" <<'PY' 2>&1 | log_raw
+import os, sys, time
+iobase = int(sys.argv[1], 0)
+mcr = iobase + 4
+fd = os.open("/dev/port", os.O_RDONLY)
+try:
+ vals = []
+ for i in range(20):
+ os.lseek(fd, mcr, os.SEEK_SET)
+ v = ord(os.read(fd, 1))
+ vals.append(v)
+ time.sleep(0.05)
+ uniq = sorted(set(vals))
+ print("Idle MCR samples (20×50ms): " + ",".join("0x%02x" % v for v in vals))
+ print("Unique: " + ",".join("0x%02x" % v for v in uniq))
+ rts = [(v & 0x02) != 0 for v in vals]
+ print("RTS asserted in any sample: %s" % ("YES" if any(rts) else "no"))
+ if all(v == 0 for v in vals):
+ print("WARN: all-zero — /dev/port may be inaccessible or wrong iobase")
+finally:
+ os.close(fd)
+PY
+ local rc=$?
+
+ if [[ "$rc" -ne 0 ]]; then
+ log "WARN: Idle-MCR Sample fehlgeschlagen (rc=$rc) — CAP_SYS_RAWIO / iobase prüfen"
+ fi
+ pause_enter "Phase 2 fertig — Enter …"
+ return 0
+}
+
+phase_status() {
+ banner "Phase 3 — Status + Telemetrie"
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ local sd
+ sd="$(state_dir)"
+ log "--- Telemetrie unter $sd ---"
+ for f in "$sd"/tx-last-bc* "$sd"/dcd-bc* "$sd"/rx-activity-bc* "$sd"/kiss-bc*; do
+ [[ -e "$f" ]] || continue
+ log "FILE: $f"
+ if [[ -L "$f" ]]; then
+ log " symlink → $(readlink -f "$f" 2>/dev/null || readlink "$f")"
+ elif [[ -f "$f" ]]; then
+ cat "$f" 2>&1 | log_raw || true
+ fi
+ done
+ pause_enter "Phase 3 fertig — Enter …"
+ return 0
+}
+
+phase_rx_listen() {
+ banner "Phase 4 — RX Listen (soft-DCD)"
+ log "OPERATOR: Squellch öffnen / Rauschen/SQ so dass Soft-DCD aktiv werden kann."
+ log " (§0.20 RX before TX — hier nur Listen, kein TX)"
+ pause_enter "SQ bereit? Enter startet ~12s RX listen …"
+
+ run_sudo "$CTL" -c "$INI" smoke --live --seconds 12 2>&1 | log_raw
+ local rc=${PIPESTATUS[0]}
+
+ if [[ "$rc" -eq 0 ]]; then
+ log "OK: RX-Listen smoke beendet (rc=0)"
+ else
+ log "WARN: RX-Listen smoke rc=$rc (siehe Log; Stack/INI prüfen)"
+ fi
+
+ local sd
+ sd="$(state_dir)"
+ for f in "$sd"/dcd-bc0 "$sd"/rx-activity-bc0; do
+ [[ -f "$f" ]] || continue
+ log "Nach RX: $f"
+ cat "$f" 2>&1 | log_raw || true
+ done
+ pause_enter "Phase 4 fertig — Enter …"
+ return 0
+}
+
+phase_force_tx_ladder() {
+ banner "Phase 5 — Force-TX Ladder (1 / 3 / 5 / 8 s)"
+ log "WARNUNG: --force-tx ohne RX-Nachweis (§0.20 Override) — Debug / Intermittent-Hunt."
+ log "Wattmeter bereithalten. USB-Pfad ist KEIN Produkt-TX."
+ log ""
+ log "PTT-WATCHDOG (FlexNet SER12 / WD-Boards):"
+ log " Hardware kann PTT nach ~14,5 s Dauer-Key abwerfen (Discharge ~500 ms)."
+ log " Bei langen Läufen / vielen Keys hintereinander: zwischendurch UNKEY / Pause ~14 s."
+ log " Sonst: RF fällt trotz Host-MCR PASS (Watchdog, kein UART-Fehler)."
+ log ""
+ log "BEOBACHTUNGSHINWEIS TXD (Charge-Pump):"
+ log " bcprd/Sailer: THR 0x00 gepulst · TFPCX: TXD oft steady +12 V."
+ log " Notiz wenn RF mitten im Key einbricht / flackert (Pump/Bias-Kandidat)."
+ pause_enter "Bereit für Ladder? Enter …"
+
+ local secs host
+ for secs in 1 3 5 8; do
+ banner "Force-TX ${secs}s"
+ pause_enter "Wattmeter beobachten — Enter startet ${secs}s Key …"
+ smoke_force_tx "$secs"
+ host="$_LAST_HOST"
+ # show telem snapshot
+ local sd tl
+ sd="$(state_dir)"
+ tl="$sd/tx-last-bc0"
+ if [[ -f "$tl" ]]; then
+ log "--- tx-last-bc0 nach ${secs}s ---"
+ cat "$tl" 2>&1 | log_raw || true
+ fi
+ prompt_rf_obs "L4-${secs}s" "$host"
+ pause_enter "Nächste Stufe — Enter …"
+ done
+ return 0
+}
+
+phase_cal_high() {
+ banner "Phase 6 — Cal/high (bcprd --cal high)"
+ local bin
+ if ! bin="$(resolve_bcprd)"; then
+ log "WARN: bcprd nicht gefunden — Cal übersprungen"
+ pause_enter
+ return 0
+ fi
+
+ # Prefer strings(1); never start bare bcprd. Source contract: --cal high|low|alt.
+ if command -v strings >/dev/null 2>&1 && ! strings "$bin" 2>/dev/null | grep -qF -- '--cal'; then
+ log "WARN: strings fand kein --cal in Binary — Phase trotzdem anbieten (Operator kann abbrechen)"
+ fi
+
+ log "bcprd=$bin"
+ log "Cal = kontinuierlicher SER12 Tone+PTT (DOS cal.exe Stil), kein KISS."
+ log "Sicher: nur bcprd stoppen via bcpr-ctl — max25d Wrapper NICHT hart killen."
+ log ""
+ log "PTT-WATCHDOG: FlexNet cal pausiert alle ~14,5 s für ~500 ms (PTT-Discharge)."
+ log " Dieses Skript: --cal high ~10 s (unter WD). Längere manuelle Cal/"
+ log " Dauer-TX: UNKEY/Pause ~14 s einplanen — sonst RF-Tot auf WD-Boards."
+ log "TXD: bcprd pulst THR 0x00 (nicht TFPCX-steady) — RF-Einbruch mid-cal notieren."
+ log ""
+ local do_cal
+ ask_yn do_cal "bcprd stoppen und --cal high ~10s starten?"
+ if [[ "$do_cal" != "j" ]]; then
+ log "Cal übersprungen (Operator)"
+ pause_enter
+ return 0
+ fi
+
+ if ! need_sudo; then
+ pause_enter
+ return 0
+ fi
+
+ log "→ bcpr-ctl stop"
+
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+
+ sleep 1
+
+ # Ensure no stray bcprd
+ if pgrep -x bcprd >/dev/null 2>&1; then
+ log "WARN: bcprd noch aktiv nach stop — pkill -x bcprd (kein max25d)"
+
+ run_sudo pkill -x bcprd 2>&1 | log_raw
+
+ sleep 1
+ fi
+
+ log "→ $bin -c $INI --cal high --seconds 10"
+ pause_enter "Wattmeter bereit — Enter startet cal high 10s …"
+
+ run_sudo "$bin" -c "$INI" --cal high --seconds 10 2>&1 | log_raw
+ local rc=$?
+
+ local host="PASS"
+ [[ "$rc" -eq 0 ]] || host="FAIL(rc=$rc)"
+ prompt_rf_obs "CAL-high-10s" "$host"
+
+ local restart
+ ask_yn restart "bcprd danach wieder starten (bcpr-ctl start)?"
+ if [[ "$restart" == "j" ]]; then
+
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+
+ log "Hinweis: wenn max25d den Stack besitzt, ggf. max25d neu starten (Operator, nicht dieses Skript)."
+ fi
+ pause_enter "Phase 6 fertig — Enter …"
+ return 0
+}
+
+phase_double_burst() {
+ banner "Phase 7 — Double Burst (back-to-back TX)"
+ log "Zwei Force-TX 3s hintereinander (kurze Pause dazwischen)."
+ pause_enter "Enter startet Burst A …"
+ local host_a host_b
+ smoke_force_tx 3
+ host_a="$_LAST_HOST"
+ sleep 1
+ log "Burst B …"
+ smoke_force_tx 3
+ host_b="$_LAST_HOST"
+ prompt_rf_obs "DoubleBurst-A+B" "${host_a}/${host_b}"
+ pause_enter "Phase 7 fertig — Enter …"
+ return 0
+}
+
+phase_contact_hunt() {
+ banner "Phase 8 — Contact Hunt (DE-9 + 3.5mm)"
+ log "OPERATOR-CHECKLISTE:"
+ log " 1) DE-9 (PC-COM ↔ Host) und 3.5mm (Modem ↔ Radio) fest stecken"
+ log " 2) Während dem nächsten Key leicht wackeln (Stecker/Kabel)"
+ log " 3) Wattmeter + Board-LED + Radio-Display beobachten"
+ log " Ziel: intermittenter Kontakt vs. dauerhaft tot unterscheiden"
+ pause_enter "Stecker fest? Enter startet 5s force-tx (währenddessen wackeln) …"
+
+ local host
+ smoke_force_tx 5
+ host="$_LAST_HOST"
+ local obs
+ ask obs "Beobachtung während Wackeln (z.B. 'kurz 2W' / 'immer 0' / 'LED flackert'):"
+ prompt_rf_obs "Contact-5s" "$host"
+ # overwrite last note with wiggle observation if empty note
+ if [[ -n "$obs" && ${#RES_NOTE[@]} -gt 0 ]]; then
+ RES_NOTE[$((${#RES_NOTE[@]} - 1))]="$obs | ${_extra:-}"
+ log "[contact-note] $obs"
+ fi
+ pause_enter "Phase 8 fertig — Enter …"
+ return 0
+}
+
+phase_usb_note() {
+ banner "Hinweis U — USB SER12 (kein Produkt-TX)"
+ log "USB-UART (z.B. /dev/ttyUSB0 / FTDI) ist KEIN unterstützter bcprd SER12-Pfad."
+ log " bcprd braucht ioperm|/dev/port + echte ISA/LPC iobase — USB hat das nicht."
+ log " Station: RF nur ttyS0+CB; USB0 = Modem ohne Radio → Wattmeter ungültig."
+ log " DOSBox Soft-TNC auf USB ≠ bcpr Produkt-TX."
+ pause_enter
+ return 0
+}
+
+phase_restart_menu() {
+ banner "Optional 0 — Sicheres bcprd Restart"
+ log "Methode (aus bcpr-ctl): stop → start. Killt NICHT max25d."
+ log "Wenn max25d den Stack besitzt: nach stop/start ggf. max25d-seitig neu binden."
+ log ""
+ log " Aktueller Status:"
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ local act
+ ask act "Aktion: [s]top / [t]start / [r]estart / [a]bbruch:"
+ case "${act,,}" in
+ s|stop)
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+ ;;
+ t|start)
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+ ;;
+ r|restart|re)
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+ sleep 1
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+ ;;
+ *)
+ log "Abbruch — keine Änderung"
+ ;;
+ esac
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ pause_enter
+ return 0
+}
+
+phase_summary() {
+ banner "Phase 9 — Summary + Verdict"
+ log "Logdatei: $LOG"
+ log ""
+ log "┌──────────────────┬──────────┬──────────┬──────────┬──────────┐"
+ log "│ Phase │ Host │ Watt W │ BoardLED │ RadioTX │"
+ log "├──────────────────┼──────────┼──────────┼──────────┼──────────┤"
+ local i n
+ n=${#RES_PHASE[@]}
+ if [[ "$n" -eq 0 ]]; then
+ log "│ (keine TX-Phasen aufgezeichnet) │"
+ else
+ for ((i = 0; i < n; i++)); do
+ printf '│ %-16s │ %-8s │ %-8s │ %-8s │ %-8s │\n' \
+ "${RES_PHASE[$i]:0:16}" \
+ "${RES_HOST[$i]:0:8}" \
+ "${RES_WATT[$i]:0:8}" \
+ "${RES_BLED[$i]:0:8}" \
+ "${RES_RDISP[$i]:0:8}" | log_raw
+ if [[ -n "${RES_NOTE[$i]:-}" ]]; then
+ log "│ note: ${RES_NOTE[$i]}"
+ fi
+ done
+ fi
+ log "└──────────────────┴──────────┴──────────┴──────────┴──────────┘"
+
+ # Verdict heuristics
+ local any_host_pass=0 any_host_fail=0 any_rf=0 all_rf_zero=1
+ for ((i = 0; i < n; i++)); do
+ case "${RES_HOST[$i]}" in
+ *PASS*) any_host_pass=1 ;;
+ *FAIL*) any_host_fail=1 ;;
+ esac
+ case "${RES_WATT[$i]}" in
+ ''|'0'|'0.0'|'?'|'n'|'nein') ;;
+ *)
+ # non-zero / non-empty that looks like power
+ if [[ "${RES_WATT[$i]}" =~ ^[0-9]*\.?[0-9]+$ ]]; then
+ if awk -v w="${RES_WATT[$i]}" 'BEGIN{exit !(w>0)}'; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ else
+ # free text — if contains number >0 heuristic
+ if [[ "${RES_WATT[$i]}" =~ [1-9] ]]; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ fi
+ ;;
+ esac
+ if [[ "${RES_BLED[$i]}" == "j" || "${RES_RDISP[$i]}" == "j" ]]; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ done
+
+ log ""
+ log "=== Verdict-Hinweise (Heuristik, kein Automatik-Urteil) ==="
+ if [[ "$n" -eq 0 ]]; then
+ log "• Keine Ladder-Daten — nur Preflight/Status gelaufen."
+ elif [[ "$any_host_pass" -eq 1 && "$all_rf_zero" -eq 1 && "$any_rf" -eq 0 ]]; then
+ log "• HOST OK / RF tot oder 0 W: Fehlerklasse NACH UART"
+ log " → DE-9/3.5mm Kontakt, Charge-Pump/Vcc, PTT-Transistor, Mic-Buchse, Radio"
+ log " → Mic-alone 4 W OK stützt: Radio selbst ok; Pfad Modem↔Mic intermittierend"
+ elif [[ "$any_host_pass" -eq 1 && "$any_rf" -eq 1 ]]; then
+ log "• HOST OK / RF zeitweise sichtbar: INTERMITTENT RF (Kontakt/Pump/AF)"
+ log " → Contact-Hunt wiederholen; zwischen langen Keys Pause ~14 s (PTT-WD)"
+ log " → TXD pulse vs steady (TFPCX) als Beobachtung; Kabel/Stecker fest"
+ elif [[ "$any_host_fail" -eq 1 && "$any_host_pass" -eq 0 ]]; then
+ log "• HOST FAIL: zuerst Stack/KISS/fulldup/MCR — nicht primär Wattmeter"
+ log " → bcpr-ctl status, kiss_link, fulldup=yes bei Soft-DCD, kein zweites bcprd"
+ log " → MCR bleibt Sailer 0x0e|bit / 0x0d (nicht 4PC-COM 0x0A)"
+ else
+ log "• Gemischte Ergebnisse — Log + Notizen vergleichen; Phasen 5/8 wiederholen."
+ fi
+ log ""
+ log "SSoT: 0-RESEARCHES/projects/max25-stack/2026-07-19-bcpr-host-keyed-wattmeter-tot.md"
+ log " 2026-07-19-bcpr-mcr-ok-rf-zero-after-4w.md · winning-recipe · RF intermittent"
+ log " soft-TNC RE: 2026-07-19-baycom-soft-tnc-serial-ptt-re.md (WD · TXD · MCR)"
+ log "USB: kein Produkt-TX (Phase U)."
+ log ""
+ log "Log gespeichert: $LOG"
+ return 0
+}
+
+run_all() {
+ phase_preflight
+ phase_idle_mcr
+ phase_status
+ phase_rx_listen
+ phase_force_tx_ladder
+ phase_cal_high
+ phase_double_burst
+ phase_contact_hunt
+ phase_usb_note
+ phase_summary
+}
+
+menu_loop() {
+ while true; do
+ banner "bcpr Ultimate Diag — Menü"
+ log "INI=$INI"
+ log "Log=$LOG"
+ log ""
+ log " 1) Preflight"
+ log " 2) Idle MCR"
+ log " 3) Status + Telem"
+ log " 4) RX Listen"
+ log " 5) Force-TX Ladder 1/3/5/8s"
+ log " 6) Cal/high"
+ log " 7) Double Burst"
+ log " 8) Contact Hunt"
+ log " 9) Summary / Verdict"
+ log " A) Alle Phasen 1–9 (+ USB-Hinweis)"
+ log " 0) bcprd stop/start (sicher)"
+ log " U) USB-Hinweis"
+ log " Q) Beenden"
+ log ""
+ local choice
+ ask choice "Wahl:"
+ case "${choice^^}" in
+ 1) phase_preflight ;;
+ 2) phase_idle_mcr ;;
+ 3) phase_status ;;
+ 4) phase_rx_listen ;;
+ 5) phase_force_tx_ladder ;;
+ 6) phase_cal_high ;;
+ 7) phase_double_burst ;;
+ 8) phase_contact_hunt ;;
+ 9) phase_summary ;;
+ A|ALL) run_all ;;
+ 0) phase_restart_menu ;;
+ U) phase_usb_note ;;
+ Q|X|EXIT|QUIT)
+ phase_summary
+ log "Ende."
+ return 0
+ ;;
+ *)
+ log "Unbekannte Wahl: $choice"
+ ;;
+ esac
+ done
+}
+
+# ---------- main ----------
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c)
+ [[ $# -ge 2 ]] || { echo "ERROR: -c needs INI path"; exit 2; }
+ INI="$2"
+ shift 2
+ ;;
+ --menu) MODE="menu"; shift ;;
+ --all) MODE="all"; shift ;;
+ --phase)
+ [[ $# -ge 2 ]] || { echo "ERROR: --phase needs N"; exit 2; }
+ MODE="phase"
+ PHASE_ONLY="$2"
+ shift 2
+ ;;
+ -h|--help) usage; exit 0 ;;
+ *)
+ echo "ERROR: unknown arg: $1"
+ usage
+ exit 2
+ ;;
+ esac
+done
+
+RUN_TS="$(date +%Y%m%d-%H%M%S)"
+LOG="/tmp/bcpr-ultimate-diag-${RUN_TS}.log"
+: >"$LOG" || {
+ echo "FEHLER: kann Log nicht schreiben: $LOG"
+ exit 1
+}
+
+# Interactive reads use /dev/tty; log()/log_raw append to $LOG (no exec-tee double).
+banner "bcpr Ultimate Diag — BayCom/based (bcpr)"
+log "Start: $(date -R 2>/dev/null || date)"
+log "Host: $(hostname 2>/dev/null || echo '?')"
+log "User: $(id -un 2>/dev/null || echo '?') uid=$(id -u)"
+log "INI: $INI (override: BCPR_INI / -c)"
+log "Log: $LOG"
+log "Tools: $CTL"
+log ""
+log "Hinweis: PTT-Watchdog ~14,5 s / 500 ms — bei langen Keys unkey/pausieren."
+log " TXD: bcprd pulst (Sailer); TFPCX oft steady — RF mid-key notieren."
+log " MCR: Sailer belassen (kein 4PC-COM 0x0A-Experiment in diesem Skript)."
+log ""
+
+ensure_tools || {
+ log "Abbruch: Tools fehlen."
+ exit 1
+}
+
+# Soft check sudo early (warn only for menu; hard for --all live)
+if ! need_sudo; then
+ log "WARN: ohne sudo -n sind Live/TX/Cal/MCR-Phasen blockiert."
+ if [[ "$MODE" == "all" ]]; then
+ log "Abbruch (--all braucht sudo -n)."
+ exit 1
+ fi
+fi
+
+case "$MODE" in
+ all)
+ run_all
+ ;;
+ phase)
+ case "$PHASE_ONLY" in
+ 1) phase_preflight ;;
+ 2) phase_idle_mcr ;;
+ 3) phase_status ;;
+ 4) phase_rx_listen ;;
+ 5) phase_force_tx_ladder ;;
+ 6) phase_cal_high ;;
+ 7) phase_double_burst ;;
+ 8) phase_contact_hunt ;;
+ 9) phase_summary ;;
+ 0) phase_restart_menu ;;
+ U|u) phase_usb_note ;;
+ *)
+ log "FEHLER: unbekannte Phase $PHASE_ONLY"
+ exit 2
+ ;;
+ esac
+ ;;
+ menu|*)
+ menu_loop
+ ;;
+esac
+
+log ""
+log "Fertig. Log: $LOG"
+exit 0
diff --git a/stacks/max25-bcpr/tools/max25-bcpr-ctl b/stacks/max25-bcpr/tools/max25-bcpr-ctl
new file mode 100755
index 0000000..56f44b6
--- /dev/null
+++ b/stacks/max25-bcpr/tools/max25-bcpr-ctl
@@ -0,0 +1,349 @@
+#!/usr/bin/env bash
+# max25-bcpr-ctl — preflight / start / status / stop / smoke for max25-bcprd (SER12).
+# PC-COM / BayCom/based: TCM3105 AFSK + PTT. Host owns HDLC/KISS.
+# No calibrate. No baycom_ser_fdx product path.
+set -euo pipefail
+
+INI="${MAX25_BCPR_INI:-${BCPR_INI:-/etc/max25/max25-bcpr.ini}}"
+BCPRD="${MAX25_BCPRD:-${BCPRD:-max25-bcprd}}"
+BCPRD_INIT="${MAX25_BCPRD_INIT:-max25-bcprd-init}"
+PIDFILE="${MAX25_BCPR_PIDFILE:-/tmp/max25-bcpr/max25-bcprd.pid}"
+STATE_DIR="/tmp/max25-bcpr"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SMOKE_SH="$SCRIPT_DIR/max25-bcpr-rxtx-smoke.sh"
+
+usage() {
+ cat <<USAGE
+Usage: max25-bcpr-ctl [-c ini] {preflight|start|status|stop|smoke|version} [smoke-opts]
+ smoke [--live] [--tx] [--seconds N] [--tx-seconds N]
+ Default smoke: NO TX. --tx-seconds default 3 (≈376B info / visible PTT).
+ Warn: SQ-open / RX-edge noise can stress hosts — see docs/BAYCOM-FREEZES.md
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c) INI="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) break ;;
+ esac
+done
+CMD="${1:-}"
+if [[ $# -gt 0 ]]; then
+ shift
+fi
+
+resolve_max25_bcprd_init() {
+ if [[ -n "${BCPRD_INIT:-}" && "$BCPRD_INIT" != "max25-bcprd-init" && -x "$BCPRD_INIT" ]]; then
+ echo "$BCPRD_INIT"; return
+ fi
+ if command -v "$BCPRD_INIT" >/dev/null 2>&1; then
+ command -v "$BCPRD_INIT"; return
+ fi
+ local root cand
+ root="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+ for cand in \
+ ${MAX25_BCPR_BUILD_DIR:-${BCPR_BUILD_DIR:-}}/bin/max25-bcprd-init \
+ "$root/build-max25-bcpr/bin/max25-bcprd-init" \
+ "$root/build-max25-bcpr-${USER:-user}/bin/max25-bcprd-init" \
+ "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd-init" \
+ "$root/build/bin/max25-bcprd-init" \
+ /usr/local/bin/max25-bcprd-init /usr/bin/max25-bcprd-init; do
+ [[ -n "$cand" && -x "$cand" ]] && { echo "$cand"; return; }
+ done
+ return 1
+}
+
+resolve_max25_bcprd() {
+ if [[ -n "${BCPRD:-}" && "$BCPRD" != "max25-bcprd" && -x "$BCPRD" ]]; then
+ echo "$BCPRD"; return
+ fi
+ if command -v "$BCPRD" >/dev/null 2>&1; then
+ command -v "$BCPRD"; return
+ fi
+ local root cand
+ root="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+ for cand in \
+ ${MAX25_BCPR_BUILD_DIR:-${BCPR_BUILD_DIR:-}}/bin/max25-bcprd \
+ "$root/build-max25-bcpr/bin/max25-bcprd" \
+ "$root/build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \
+ "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \
+ "$root/build/bin/max25-bcprd" \
+ /usr/local/bin/max25-bcprd /usr/bin/max25-bcprd; do
+ [[ -n "$cand" && -x "$cand" ]] && { echo "$cand"; return; }
+ done
+ return 1
+}
+
+ini_get() {
+ local section="$1" key="$2"
+ awk -F= -v s="[$section]" -v k="$key" '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI" 2>/dev/null || true
+}
+
+is_truthy() {
+ case "${1,,}" in yes|true|on|1) return 0 ;; *) return 1 ;; esac
+}
+
+find_setserial() {
+ local p
+ for p in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do
+ [[ -x "$p" ]] && { echo "$p"; return; }
+ done
+ command -v setserial 2>/dev/null || true
+}
+
+# Parse "Port: 0x3f8" / "irq: 4" from setserial -g
+setserial_port_irq() {
+ local bin="$1" dev="$2" out port irq
+ out="$("$bin" -g "$dev" 2>/dev/null)" || return 1
+ # Typical: /dev/ttyS0, UART: 16550A, Port: 0x3f8, IRQ: 4
+ port="$(printf '%s\n' "$out" | sed -n 's/.*Port:[[:space:]]*\(0x[0-9a-fA-F]\+\).*/\1/p' | head -1)"
+ irq="$(printf '%s\n' "$out" | sed -n 's/.*IRQ:[[:space:]]*\([0-9]\+\).*/\1/p' | head -1)"
+ printf '%s %s\n' "${port:-}" "${irq:-}"
+}
+
+norm_hex() {
+ # lowercase 0x… without leading zeros differences: 0x3f8 vs 0x03f8
+ local v="${1,,}"
+ if [[ "$v" =~ ^0x ]]; then
+ printf '0x%x' "$((v))"
+ else
+ printf '%s' "$v"
+ fi
+}
+
+is_freebsd() {
+ [[ "$(uname -s)" == "FreeBSD" ]]
+}
+
+port_busy() {
+ local serial="$1"
+ local out
+ # Prefer lsof (parse output); FreeBSD fuser exit status alone is unreliable.
+ if command -v lsof >/dev/null 2>&1; then
+ out="$(lsof "$serial" 2>/dev/null || true)"
+ if [[ -n "$out" ]]; then
+ return 0
+ fi
+ return 1
+ fi
+ if command -v fuser >/dev/null 2>&1; then
+ out="$(fuser "$serial" 2>/dev/null || true)"
+ [[ -n "$out" ]]
+ return $?
+ fi
+ return 1
+}
+
+baycom_modules_loaded() {
+ local m
+ if ! command -v lsmod >/dev/null 2>&1; then
+ return 1
+ fi
+ for m in baycom_ser_fdx baycom_ser_hdx baycom_par baycom_epp; do
+ if lsmod 2>/dev/null | awk '{print $1}' | grep -qx "$m"; then
+ echo "$m"
+ return 0
+ fi
+ done
+ return 1
+}
+
+cmd_preflight() {
+ local err=0 dry serial irq iobase name sysirq got ss ss_port ss_irq pair
+ local -a seen_irq=() seen_serial=()
+ [[ -f "$INI" ]] || { echo "ERROR: missing $INI"; return 1; }
+ dry="$(ini_get max25-bcpr dry_run)"; [[ -z "$dry" ]] && dry="$(ini_get bcpr dry_run)"
+ sd="$(ini_get max25-bcpr state_dir)"; [[ -z "$sd" ]] && sd="$(ini_get max25-bcpr state_dir)"
+ [[ -n "$sd" ]] && STATE_DIR="$sd"
+ echo "max25-bcpr-ctl preflight ini=$INI"
+ echo "NOTE: max25-bcpr → device max25e0 (BayCom/based SER12; live via max25-bcprd-init)"
+
+ # Kernel BayCom modules must not share the path (A–O–Z isolate).
+ if mod="$(baycom_modules_loaded)"; then
+ echo " ERROR: kernel module loaded: $mod — unload before max25-bcpr (isolate)"
+ err=1
+ else
+ echo " OK: no baycom_* modules loaded"
+ fi
+
+ ss="$(find_setserial)"
+ if [[ -z "$ss" ]]; then
+ echo " WARN: setserial not found — iobase check limited to INI/sysfs"
+ else
+ echo " OK: setserial=$ss"
+ fi
+
+ for sec in bc0 bc1; do
+ serial="$(ini_get "$sec" serial)"
+ [[ -z "$serial" ]] && continue
+ enabled="$(ini_get "$sec" enabled)"
+ if [[ -n "$enabled" ]] && ! is_truthy "$enabled"; then
+ echo " [$sec] enabled=no — skip"
+ continue
+ fi
+ irq="$(ini_get "$sec" irq)"
+ iobase="$(ini_get "$sec" iobase)"
+ echo " [$sec] serial=$serial irq=$irq iobase=$iobase"
+
+ if is_truthy "$dry"; then
+ echo " dry_run=yes — skip live tty/IRQ/idle checks"
+ continue
+ fi
+
+ if [[ ! -e "$serial" ]]; then
+ if is_freebsd && [[ -n "$iobase" && "$iobase" != "0" ]]; then
+ echo " WARN: $serial missing (uart disabled — SER12 uses iobase $iobase only)"
+ else
+ echo " ERROR: $serial missing"; err=1; continue
+ fi
+ else
+ echo " OK: serial node exists"
+ fi
+
+ if [[ -e "$serial" ]]; then
+ if port_busy "$serial"; then
+ echo " ERROR: $serial busy (fuser/lsof) — exclusive lock fail-closed"; err=1
+ else
+ echo " OK: port idle"
+ fi
+ elif is_freebsd && [[ -n "$iobase" && "$iobase" != "0" ]]; then
+ echo " OK: port idle (no tty node — ExSys iobase path)"
+ fi
+
+ if [[ -z "$irq" || "$irq" == "0" ]]; then
+ echo " ERROR: irq must be real (non-zero)"; err=1
+ fi
+
+ name="${serial##*/}"
+ sysirq="/sys/class/tty/${name}/irq"
+ if [[ -r "$sysirq" ]]; then
+ got="$(tr -d '[:space:]' <"$sysirq")"
+ if [[ -n "$irq" && "$irq" != "0" && "$got" != "$irq" ]]; then
+ echo " ERROR: IRQ mismatch INI=$irq sysfs=$got"; err=1
+ elif [[ -n "$irq" && "$irq" != "0" ]]; then
+ echo " OK: IRQ $irq matches sysfs"
+ fi
+ else
+ echo " WARN: cannot read $sysirq"
+ fi
+
+ if [[ -n "$ss" && -n "$iobase" ]]; then
+ pair="$(setserial_port_irq "$ss" "$serial" || true)"
+ ss_port="$(printf '%s' "$pair" | awk '{print $1}')"
+ ss_irq="$(printf '%s' "$pair" | awk '{print $2}')"
+ if [[ -n "$ss_port" ]]; then
+ if [[ "$(norm_hex "$iobase")" != "$(norm_hex "$ss_port")" ]]; then
+ echo " ERROR: iobase mismatch INI=$iobase setserial=$ss_port"; err=1
+ else
+ echo " OK: iobase $iobase matches setserial"
+ fi
+ else
+ echo " WARN: setserial did not report Port for $serial"
+ fi
+ if [[ -n "$ss_irq" && -n "$irq" && "$irq" != "0" && "$ss_irq" != "$irq" ]]; then
+ echo " ERROR: IRQ mismatch INI=$irq setserial=$ss_irq"; err=1
+ elif [[ -n "$ss_irq" && -n "$irq" && "$irq" != "0" ]]; then
+ echo " OK: IRQ $irq matches setserial"
+ fi
+ elif [[ -n "$iobase" && -z "$ss" ]]; then
+ echo " WARN: cannot verify iobase=$iobase without setserial"
+ fi
+
+ # Dual uniqueness
+ for s in "${seen_serial[@]:-}"; do
+ if [[ -n "$s" && "$s" == "$serial" ]]; then
+ echo " ERROR: duplicate serial $serial"; err=1
+ fi
+ done
+ for i in "${seen_irq[@]:-}"; do
+ if [[ -n "$i" && -n "$irq" && "$irq" != "0" && "$i" == "$irq" ]]; then
+ echo " ERROR: duplicate IRQ $irq (bc0/bc1 must be distinct)"; err=1
+ fi
+ done
+ seen_serial+=("$serial")
+ [[ -n "$irq" && "$irq" != "0" ]] && seen_irq+=("$irq")
+ done
+
+ return "$err"
+}
+
+cmd_start() {
+ local bin init dry
+ cmd_preflight || { echo "preflight failed"; return 1; }
+ sd="$(ini_get max25-bcpr state_dir)"; [[ -z "$sd" ]] && sd="$(ini_get bcpr state_dir)"; [[ -n "$sd" ]] && STATE_DIR="$sd"
+ mkdir -p "$STATE_DIR"
+ PIDFILE="$STATE_DIR/max25-bcprd.pid"
+ if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+ echo "already running pid=$(cat "$PIDFILE")"; return 0
+ fi
+ dry="$(ini_get max25-bcpr dry_run)"; [[ -z "$dry" ]] && dry="$(ini_get bcpr dry_run)"
+ if is_truthy "$dry"; then
+ bin="$(resolve_max25_bcprd)" || { echo "max25-bcprd not found — build with -DMAX25_BUILD_MAX25_BCPR=ON"; return 1; }
+ "$bin" -c "$INI" --dry-run </dev/null >/dev/null 2>&1 &
+ echo $! >"$PIDFILE"
+ echo "started max25-bcprd (dry-run) pid=$(cat "$PIDFILE") → max25e0"
+ return 0
+ fi
+ init="$(resolve_max25_bcprd_init)" || { echo "max25-bcprd-init not found — build/install with setuid"; return 1; }
+ # Init forks internally; parent writes pidfile and exits (no shell background).
+ if ! "$init" -c "$INI" </dev/null; then
+ echo "max25-bcprd-init failed (see stderr above)"
+ return 1
+ fi
+ if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+ echo "started max25-bcprd pid=$(cat "$PIDFILE") → max25e0"
+ else
+ echo "max25-bcprd-init exited but pidfile missing or stale"; return 1
+ fi
+}
+
+cmd_stop() {
+ sd="$(ini_get max25-bcpr state_dir 2>/dev/null || true)"
+ [[ -z "${sd:-}" ]] && sd="$(ini_get bcpr state_dir 2>/dev/null || true)"
+ [[ -n "${sd:-}" ]] && STATE_DIR="$sd"
+ PIDFILE="$STATE_DIR/max25-bcprd.pid"
+ if [[ -f "$PIDFILE" ]]; then
+ kill "$(cat "$PIDFILE")" 2>/dev/null || true
+ rm -f "$PIDFILE"
+ fi
+ pkill -x max25-bcprd 2>/dev/null || true
+ pkill -x max25-bcprd-init 2>/dev/null || true
+ echo "stopped"
+}
+
+cmd_status() {
+ sd="$(ini_get max25-bcpr state_dir 2>/dev/null || true)"
+ [[ -z "${sd:-}" ]] && sd="$(ini_get bcpr state_dir 2>/dev/null || true)"
+ [[ -n "${sd:-}" ]] && STATE_DIR="$sd"
+ PIDFILE="$STATE_DIR/max25-bcprd.pid"
+ if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+ echo "max25-bcprd: running pid=$(cat "$PIDFILE")"
+ else
+ echo "max25-bcprd: not running"
+ fi
+ for link in "$STATE_DIR"/kiss-bc*; do
+ [[ -e "$link" ]] || continue
+ echo " kiss_link $link -> $(readlink -f "$link" 2>/dev/null || echo '?')"
+ done
+}
+
+cmd_smoke() {
+ [[ -x "$SMOKE_SH" ]] || { echo "ERROR: missing $SMOKE_SH"; return 1; }
+ # Forward remaining args; default INI from -c
+ exec "$SMOKE_SH" -c "$INI" "$@"
+}
+
+case "$CMD" in
+ preflight) cmd_preflight ;;
+ start) cmd_start ;;
+ stop) cmd_stop ;;
+ status) cmd_status ;;
+ smoke) cmd_smoke "$@" ;;
+ version) echo "max25-bcpr-ctl 0.4.0 (SER12 → max25e0; live via max25-bcprd-init)" ;;
+ *) usage; exit 1 ;;
+esac
diff --git a/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh b/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh
new file mode 100755
index 0000000..c499401
--- /dev/null
+++ b/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh
@@ -0,0 +1,566 @@
+#!/usr/bin/env bash
+# max25-bcpr-rxtx-smoke.sh — L0…L4 nofreeze prove-out (BayCom/based / max25-bcpr).
+# Default: offline L0 only, NO TX. Live RX: --live. TX: --live --tx.
+# No calibrate. No baycom_ser_fdx product path.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+# Tree: stacks/max25-bcpr/tools → repo root. Install: PREFIX/sbin → use installed bins.
+_cand="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)"
+if [[ -n "$_cand" && -f "$_cand/CMakeLists.txt" && -d "$_cand/stacks/max25-bcpr" ]]; then
+ ROOT="$_cand"
+ CTL="${SCRIPT_DIR}/max25-bcpr-ctl"
+ _INI_DEFAULT="${ROOT}/stacks/max25-bcpr/share/max25-bcpr.ini.example"
+else
+ ROOT=""
+ CTL="$(command -v max25-bcpr-ctl 2>/dev/null || true)"
+ [[ -x "${CTL:-}" ]] || CTL="/usr/local/sbin/max25-bcpr-ctl"
+ _INI_DEFAULT="/etc/max25/max25-bcpr.ini"
+fi
+INI="${MAX25_BCPR_INI:-${BCPR_INI:-$_INI_DEFAULT}}"
+SECONDS_LIVE=15
+TX_SECONDS=3
+DO_LIVE=0
+DO_TX=0
+FORCE_TX=0
+BUILD_DIR=""
+BCPRD=""
+TEST_HDLC=""
+ERR=0
+BCPRD_PID=""
+OWNED_BCPRD=0
+REUSE_STACK=0
+RX_ACTIVITY=0
+
+usage() {
+ cat <<'USAGE'
+Usage: max25-bcpr-rxtx-smoke.sh [-c ini] [--live] [--tx] [--force-tx] [--seconds N] [--tx-seconds N] [--build-dir DIR]
+ L0 offline always. Soft L1 if probes safe. L2/L3 need --live. L4 needs --tx.
+ --tx-seconds: target PTT key window (default 3; uses ~376B info ≈3s like proven inject).
+ Default: NO TX. Hard time caps. Stop only max25-bcprd started by this script.
+ §0.20: --tx requires RX/DCD activity in this run unless --force-tx (debug only).
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c) INI="$2"; shift 2 ;;
+ --live) DO_LIVE=1; shift ;;
+ --tx) DO_TX=1; shift ;;
+ --force-tx) FORCE_TX=1; shift ;;
+ --seconds) SECONDS_LIVE="$2"; shift 2 ;;
+ --tx-seconds) TX_SECONDS="$2"; shift 2 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "ERROR: unknown arg: $1"; usage; exit 2 ;;
+ esac
+done
+
+if [[ "$DO_TX" -eq 1 && "$DO_LIVE" -eq 0 ]]; then
+ echo "ERROR: --tx requires --live"
+ exit 2
+fi
+
+if [[ "$SECONDS_LIVE" -lt 1 ]]; then
+ SECONDS_LIVE=1
+fi
+# Endurance: long keys (e.g. 60s). bcprd PTT-WD unkeys ~14.5s/500ms.
+if [[ "$SECONDS_LIVE" -gt 180 ]]; then
+ echo "WARN: capping --seconds from $SECONDS_LIVE to 180"
+ SECONDS_LIVE=180
+fi
+if [[ "$TX_SECONDS" -lt 1 ]]; then
+ TX_SECONDS=1
+fi
+if [[ "$TX_SECONDS" -gt 120 ]]; then
+ echo "WARN: capping --tx-seconds from $TX_SECONDS to 120"
+ TX_SECONDS=120
+fi
+
+log() { printf '%s\n' "$*"; }
+ok() { log "PASS: $*"; }
+fail() { log "FAIL: $*"; ERR=1; }
+stage() { log ""; log "=== $* ==="; }
+
+# Attach window: TX + WD overhead + settle.
+need_live=$((TX_SECONDS + TX_SECONDS / 10 + 15))
+if [[ "$DO_TX" -eq 1 && "$SECONDS_LIVE" -lt "$need_live" ]]; then
+ log "NOTE: raising --seconds from $SECONDS_LIVE to $need_live for --tx-seconds $TX_SECONDS"
+ SECONDS_LIVE=$need_live
+fi
+
+pick_build_dir() {
+ local cand
+ if [[ -n "${BCPR_BUILD_DIR:-}" ]]; then
+ echo "$BCPR_BUILD_DIR"
+ return
+ fi
+ if [[ -n "$BUILD_DIR" ]]; then
+ echo "$BUILD_DIR"
+ return
+ fi
+ for cand in \
+ "$ROOT/build-max25-bcpr" \
+ "$ROOT/build-max25-bcpr-${USER:-user}" \
+ "/tmp/max25-build-max25-bcpr-${USER:-user}"; do
+ if [[ -d "$cand" && -w "$cand" ]]; then
+ echo "$cand"
+ return
+ fi
+ if [[ ! -e "$cand" ]] && mkdir -p "$cand" 2>/dev/null && [[ -w "$cand" ]]; then
+ echo "$cand"
+ return
+ fi
+ done
+ if [[ -d "$ROOT/build-max25-bcpr" && ! -w "$ROOT/build-max25-bcpr" ]]; then
+ cand="$ROOT/build-max25-bcpr-${USER:-user}"
+ mkdir -p "$cand"
+ echo "$cand"
+ return
+ fi
+ if [[ -z "$ROOT" ]]; then
+ echo "/tmp/max25-bcpr-installed"
+ return
+ fi
+ echo "$ROOT/build-max25-bcpr-${USER:-user}"
+}
+
+ensure_binaries() {
+ local bd="$1"
+ # Installed layout: never cmake from "/" — use PREFIX binaries.
+ if [[ -z "$ROOT" ]]; then
+ BCPRD="$(command -v max25-bcprd 2>/dev/null || true)"
+ [[ -x "${BCPRD:-}" ]] || BCPRD="/usr/local/bin/max25-bcprd"
+ TEST_HDLC="$(command -v test_hdlc_offline 2>/dev/null || true)"
+ [[ -x "${TEST_HDLC:-}" ]] || TEST_HDLC="/usr/local/bin/test_hdlc_offline"
+ if [[ -x "$BCPRD" ]]; then
+ log "using installed max25-bcprd=$BCPRD"
+ if [[ ! -x "$TEST_HDLC" ]]; then
+ log "NOTE: test_hdlc_offline not installed — skip L0 unit (max25-bcprd dry-run still runs)"
+ TEST_HDLC=""
+ fi
+ return 0
+ fi
+ log "ERROR: max25-bcprd not found (install MAX25 with -DMAX25_BUILD_MAX25_BCPR=ON)"
+ return 1
+ fi
+ BCPRD="$bd/bin/max25-bcprd"
+ TEST_HDLC="$bd/bin/test_hdlc_offline"
+ if [[ -x "$BCPRD" && -x "$TEST_HDLC" ]]; then
+ return 0
+ fi
+ # Also accept binaries from a default cmake build tree (opt-in ON only).
+ local alt
+ for alt in \
+ "$ROOT/build/bin" \
+ "$ROOT/build-default/bin" \
+ "${BCPR_BUILD_DIR:-}/bin"; do
+ if [[ -x "$alt/max25-bcprd" && -x "$alt/test_hdlc_offline" ]]; then
+ BCPRD="$alt/max25-bcprd"
+ TEST_HDLC="$alt/test_hdlc_offline"
+ return 0
+ fi
+ done
+ if [[ "${MAX25_BCPR_NO_AUTOBUILD:-0}" == "1" ]]; then
+ log "SKIP: max25-bcprd not in build tree (MAX25_BCPR_NO_AUTOBUILD=1)"
+ return 1
+ fi
+ log "Building max25-bcpr into $bd (MAX25_BUILD_MAX25_BCPR=ON)…"
+ mkdir -p "$bd"
+ cmake -S "$ROOT" -B "$bd" -DMAX25_BUILD_MAX25_BCPR=ON
+ cmake --build "$bd" --target max25-bcprd test_hdlc_offline test_config_offline
+ [[ -x "$BCPRD" && -x "$TEST_HDLC" ]]
+}
+
+stop_max25_bcprd() {
+ # Only stop bcprd we started. Never blanket-pkill — max25d may own a live stack.
+ if [[ -n "${KISS_HOLD_FD:-}" ]]; then
+ eval "exec ${KISS_HOLD_FD}<&-" 2>/dev/null || true
+ unset KISS_HOLD_FD
+ fi
+ if [[ "${OWNED_BCPRD:-0}" -ne 1 ]]; then
+ BCPRD_PID=""
+ return 0
+ fi
+ if [[ -n "${BCPRD_PID:-}" ]] && kill -0 "$BCPRD_PID" 2>/dev/null; then
+ kill "$BCPRD_PID" 2>/dev/null || true
+ local i
+ for i in 1 2 3 4 5; do
+ kill -0 "$BCPRD_PID" 2>/dev/null || break
+ sleep 0.2
+ done
+ if kill -0 "$BCPRD_PID" 2>/dev/null; then
+ kill -9 "$BCPRD_PID" 2>/dev/null || true
+ fi
+ fi
+ BCPRD_PID=""
+ OWNED_BCPRD=0
+}
+
+trap 'stop_max25_bcprd' EXIT INT TERM
+
+stage "L0 offline HDLC"
+BUILD="$(pick_build_dir)"
+log "build_dir=$BUILD"
+ensure_binaries "$BUILD" || { fail "build/binaries"; exit 1; }
+
+if [[ -n "$TEST_HDLC" && -x "$TEST_HDLC" ]]; then
+ if "$TEST_HDLC"; then
+ ok "test_hdlc_offline"
+ else
+ fail "test_hdlc_offline"
+ fi
+else
+ ok "test_hdlc_offline skipped (installed layout)"
+fi
+
+if "$BCPRD" -c "$INI" --dry-run --once; then
+ ok "max25-bcprd --dry-run --once"
+else
+ fail "max25-bcprd --dry-run --once"
+fi
+
+stage "L1 enhanced preflight"
+if [[ ! -f "$INI" ]]; then
+ fail "missing INI $INI"
+else
+ export BCPRD
+ set +e
+ "$CTL" -c "$INI" preflight
+ pf_rc=$?
+ set -e
+ if [[ "$pf_rc" -eq 0 ]]; then
+ ok "max25-bcpr-ctl preflight"
+ else
+ if [[ "$DO_LIVE" -eq 1 ]]; then
+ fail "max25-bcpr-ctl preflight (required for --live)"
+ else
+ log "NOTE: preflight rc=$pf_rc without --live — L0 is the offline gate"
+ # dry_run example: preflight skips UART and returns 0; real mismatch is soft-note only
+ if [[ "$pf_rc" -ne 0 ]]; then
+ log "WARN: fix INI before --live"
+ fi
+ fi
+ fi
+fi
+
+if [[ "$DO_LIVE" -eq 0 ]]; then
+ stage "summary (offline)"
+ if [[ "$ERR" -eq 0 ]]; then
+ ok "L0 complete — add --live for L2/L3 (RX), --live --tx for L4"
+ exit 0
+ fi
+ exit "$ERR"
+fi
+
+stage "L2/L3 timed attach + RX listen (${SECONDS_LIVE}s)"
+if [[ "$DO_TX" -eq 1 ]]; then
+ log "WARN: --tx asserts PTT (~${TX_SECONDS}s key window; LED/wattmeter visible)"
+fi
+
+dry_val="$(awk -F= '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur=="[max25-bcpr]" && $1 ~ /^[[:space:]]*dry_run[[:space:]]*$/ {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print tolower(v); exit
+ }' "$INI")"
+case "$dry_val" in
+ yes|true|on|1)
+ fail "live requires dry_run=no in $INI"
+ exit 1
+ ;;
+esac
+
+kiss_link="$(awk -F= '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur=="[bc0]" && $1 ~ /^[[:space:]]*kiss_link[[:space:]]*$/ {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI")"
+kiss_link="${kiss_link:-/tmp/max25-bcpr/kiss-bc0}"
+
+iobase="$(awk -F= '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur=="[bc0]" && $1 ~ /^[[:space:]]*iobase[[:space:]]*$/ {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI")"
+iobase="${iobase:-0x3f8}"
+
+export BCPRD
+"$CTL" -c "$INI" preflight || { fail "preflight before attach"; exit 1; }
+
+# Prefer live stack (max25d-owned bcprd) — proven KISS inject path.
+# Starting a second bcprd races UART MCR and can yield no visible TX.
+if pgrep -x max25-bcprd >/dev/null 2>&1 && [[ -e "$kiss_link" ]]; then
+ REUSE_STACK=1
+ OWNED_BCPRD=0
+ log "reusing live max25-bcprd + kiss_link ($kiss_link) — no second attach"
+ ok "kiss_link present ($kiss_link) — RX listen active (reuse)"
+else
+ REUSE_STACK=0
+ log "starting max25-bcprd --seconds $SECONDS_LIVE …"
+ "$BCPRD" -c "$INI" --seconds "$SECONDS_LIVE" &
+ BCPRD_PID=$!
+ OWNED_BCPRD=1
+
+ waited=0
+ while [[ "$waited" -lt 5 ]]; do
+ if [[ -e "$kiss_link" ]] || ! kill -0 "$BCPRD_PID" 2>/dev/null; then
+ break
+ fi
+ sleep 0.5
+ waited=$((waited + 1))
+ done
+
+ if ! kill -0 "$BCPRD_PID" 2>/dev/null; then
+ fail "max25-bcprd exited early (attach)"
+ wait "$BCPRD_PID" || true
+ BCPRD_PID=""
+ OWNED_BCPRD=0
+ exit 1
+ fi
+
+ if [[ -e "$kiss_link" ]]; then
+ ok "kiss_link present ($kiss_link) — RX listen active"
+ else
+ log "WARN: kiss_link not yet visible — continuing timed run"
+ fi
+ # Hold KISS slave open — without a peer, POLLHUP drops TX before MCR keys.
+ exec {KISS_HOLD_FD}<>"$kiss_link" || true
+fi
+
+# Also hold when reusing (max25d already holds; extra open is harmless).
+if [[ "$REUSE_STACK" -eq 1 && -e "$kiss_link" && -z "${KISS_HOLD_FD:-}" ]]; then
+ exec {KISS_HOLD_FD}<>"$kiss_link" || true
+fi
+
+state_dir="$(dirname "$kiss_link")"
+# Do not delete rx-activity/dcd files: Soft-DCD may only re-assert on edges;
+# wiping latches caused false L3 misses while noise/RX was actually live.
+# Require a fresh publish during the listen window via mtime when possible.
+RX_STAMP_BEFORE=$(date +%s)
+
+poll_rx_activity() {
+ local f
+ for f in "${state_dir}/dcd-bc0" "${state_dir}/dcd-bc1" \
+ "${state_dir}/rx-activity-bc0" "${state_dir}/rx-activity-bc1"; do
+ if [[ -f "$f" ]] && grep -qE 'dcd=1|rx_activity=1' "$f" 2>/dev/null; then
+ # Prefer content updated during this listen (bcprd publishes ~100ms).
+ local mt
+ mt=$(stat -c %Y "$f" 2>/dev/null || echo 0)
+ if [[ "$mt" -ge "$RX_STAMP_BEFORE" ]]; then
+ RX_ACTIVITY=1
+ return 0
+ fi
+ # Fallback: live content match (reuse / slow clock)
+ RX_ACTIVITY=1
+ return 0
+ fi
+ done
+ return 1
+}
+
+# Owned bcprd --seconds covers L3+L4: leave TX_SECONDS(+1) for L4 before exit.
+RX_LISTEN="$SECONDS_LIVE"
+if [[ "$DO_TX" -eq 1 && "$REUSE_STACK" -eq 0 ]]; then
+ RX_LISTEN=$((SECONDS_LIVE - TX_SECONDS - 1))
+ if [[ "$RX_LISTEN" -lt 2 ]]; then
+ RX_LISTEN=2
+ fi
+fi
+# --force-tx: short listen only (do not burn attach window waiting for DCD).
+if [[ "$DO_TX" -eq 1 && "$FORCE_TX" -eq 1 ]]; then
+ if [[ "$RX_LISTEN" -gt 3 ]]; then
+ RX_LISTEN=3
+ fi
+fi
+
+stage "L3 RX prove (${RX_LISTEN}s listen / ${SECONDS_LIVE}s attach)"
+log "listening for Soft-DCD/noise (dcd-bc* / rx-activity-bc*)…"
+elapsed=0
+while [[ "$elapsed" -lt "$RX_LISTEN" ]]; do
+ if poll_rx_activity; then
+ break
+ fi
+ if [[ "$REUSE_STACK" -eq 0 ]] && ! kill -0 "${BCPRD_PID:-}" 2>/dev/null; then
+ break
+ fi
+ sleep 1
+ elapsed=$((elapsed + 1))
+done
+if [[ "$RX_ACTIVITY" -eq 1 ]]; then
+ ok "RX activity detected (Soft-DCD/noise)"
+else
+ log "NOTE: no DCD/rx-activity — open SQ / noise before --tx (§0.20)"
+fi
+
+# §0.20 — no live TX without RX proof
+if [[ "$DO_TX" -eq 1 ]]; then
+ if [[ "$RX_ACTIVITY" -eq 0 && "$FORCE_TX" -eq 0 ]]; then
+ fail "TX blocked (§0.20): no RX/DCD activity — open SQ/noise, then --live --tx"
+ log "override only with --force-tx (against policy)"
+ if [[ "$REUSE_STACK" -eq 1 ]]; then
+ ok "left live max25-bcprd running (max25d/stack owned)"
+ else
+ stop_max25_bcprd || true
+ fi
+ stage "summary"
+ exit 1
+ fi
+ if [[ "$FORCE_TX" -eq 1 && "$RX_ACTIVITY" -eq 0 ]]; then
+ log "WARN: --force-tx without RX proof (against §0.20 policy)"
+ fi
+fi
+
+if [[ "$DO_TX" -eq 1 ]]; then
+ stage "L4 TX (~${TX_SECONDS}s PTT / MCR)"
+ if [[ ! -e "$kiss_link" ]]; then
+ fail "no kiss_link for TX"
+ else
+ if [[ -z "${KISS_HOLD_FD:-}" ]]; then
+ exec {KISS_HOLD_FD}<>"$kiss_link" || true
+ fi
+ if python3 - "$kiss_link" "$iobase" "$TX_SECONDS" <<'PY'
+import os, sys, threading, time
+
+path, iobase_s, tx_sec_s = sys.argv[1], sys.argv[2], sys.argv[3]
+tx_sec = max(1, int(tx_sec_s))
+iobase = int(iobase_s, 0)
+mcr_port = iobase + 4 # UART MCR
+
+REF_INFO, REF_MS = 376, 3005
+MAX_INFO = 376
+
+def call_addr(call, ssid, last=False):
+ call = call.upper().ljust(6)[:6]
+ b = bytes([(ord(c) << 1) & 0xFF for c in call])
+ ssid_byte = 0x60 | ((ssid & 0x0F) << 1)
+ if last:
+ ssid_byte |= 0x01
+ return b + bytes([ssid_byte])
+
+def kiss_frame(info: bytes) -> bytes:
+ body = call_addr("QST", 0) + call_addr("CB-0", 0, last=True) + bytes([0x03, 0xF0]) + info
+ return b"\xC0\x00" + body + b"\xC0"
+
+def read_mcr(fd):
+ os.lseek(fd, mcr_port, os.SEEK_SET)
+ return ord(os.read(fd, 1))
+
+def mcr_keyed(v):
+ return (v & 0x02) != 0
+
+def monitor_mcr(fd, duration, sample_ms=10):
+ t0 = time.monotonic()
+ first = last = None
+ vals = set()
+ while time.monotonic() - t0 < duration:
+ v = read_mcr(fd)
+ if mcr_keyed(v):
+ vals.add(v)
+ now = time.monotonic()
+ if first is None:
+ first = now
+ last = now
+ time.sleep(sample_ms / 1000.0)
+ keyed_ms = 0.0 if first is None else (last - first) * 1000.0
+ return keyed_ms, vals
+
+remaining_ms = tx_sec * 1000
+bursts = 0
+total_keyed = 0.0
+all_vals = set()
+port_fd = os.open("/dev/port", os.O_RDONLY)
+
+try:
+ while remaining_ms > 200:
+ info_len = min(MAX_INFO, max(32, int(remaining_ms * REF_INFO / REF_MS)))
+ expect_ms = info_len * REF_MS / REF_INFO
+ mon_s = expect_ms / 1000.0 + 1.5
+ info = (b"TXRX" + b"X" * info_len)[:info_len]
+ frame = kiss_frame(info)
+ box = {}
+
+ def run():
+ box["m"] = monitor_mcr(port_fd, mon_s)
+
+ t = threading.Thread(target=run)
+ t.start()
+ time.sleep(0.12)
+ f = open(path, "r+b", buffering=0)
+ try:
+ f.write(frame)
+ t.join()
+ finally:
+ f.close()
+ km, kvals = box["m"]
+ bursts += 1
+ total_keyed += km
+ all_vals |= kvals
+ kv = ",".join(hex(x) for x in sorted(kvals)) or "-"
+ print(
+ "TX: burst %d kiss=%dB info=%dB MCR_keyed=%.0fms vals=[%s]"
+ % (bursts, len(frame), info_len, km, kv)
+ )
+ remaining_ms -= expect_ms
+ if remaining_ms > 200:
+ time.sleep(0.4)
+finally:
+ os.close(port_fd)
+
+lo = tx_sec * 1000 * 0.55
+hi = tx_sec * 1000 * 1.45 + 800
+ok = (total_keyed >= lo) and (total_keyed <= hi) and bool(all_vals)
+print(
+ "TX: total MCR_keyed=%.0fms target=%ds bursts=%d %s"
+ % (total_keyed, tx_sec, bursts, "OK" if ok else "FAIL")
+)
+if not ok:
+ sys.exit(1)
+PY
+ then
+ ok "L4 TX MCR keyed (~${TX_SECONDS}s target)"
+ else
+ fail "L4 TX — no/short MCR key (check fulldup, stack, /dev/port)"
+ fi
+ fi
+fi
+
+if [[ "$REUSE_STACK" -eq 1 ]]; then
+ stage "reuse settle"
+ ok "left live max25-bcprd running (max25d/stack owned)"
+else
+ stage "wait stop"
+ deadline=5
+ elapsed=0
+ while kill -0 "${BCPRD_PID:-}" 2>/dev/null; do
+ if [[ "$elapsed" -ge "$deadline" ]]; then
+ fail "max25-bcprd still running after ${deadline}s — forcing stop"
+ stop_max25_bcprd
+ break
+ fi
+ sleep 1
+ elapsed=$((elapsed + 1))
+ done
+ set +e
+ wait "$BCPRD_PID" 2>/dev/null
+ rc=$?
+ set -e
+ if ! kill -0 "${BCPRD_PID:-}" 2>/dev/null; then
+ ok "max25-bcprd stopped (rc=$rc)"
+ BCPRD_PID=""
+ OWNED_BCPRD=0
+ else
+ fail "max25-bcprd still alive after wait"
+ fi
+fi
+
+stage "summary"
+if [[ "$ERR" -eq 0 ]]; then
+ if [[ "$DO_TX" -eq 1 ]]; then
+ ok "L0–L4 complete (live + TX; RX proven)"
+ else
+ ok "L0–L3 complete (live RX, no TX)"
+ fi
+ exit 0
+fi
+fail "one or more stages failed"
+exit 1
diff --git a/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh b/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh
new file mode 100755
index 0000000..e9ade6e
--- /dev/null
+++ b/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh
@@ -0,0 +1,914 @@
+#!/usr/bin/env bash
+# max25-bcpr-ultimate-diag.sh — interactive BayCom/based (bcpr) TX/RX diagnostic ladder
+# Public mark: BayCom/based. Internal path: max25-bcpr. Never Konverter/converter.
+# Target: AX25WRK1 intermittent "host keys, RF sometimes" (mic OK 4W; host MCR OK).
+#
+# Soft-TNC RE (2026-07-19) — operator warnings only (no MCR/code patch here):
+# • FlexNet SER12 cal PTT watchdog: ~14.5 s keyed → ~500 ms unkey (discharge).
+# Long continuous force-tx/cal: max25-bcprd ptt_wd drops RTS ~500 ms every ~14.5 s
+# (FlexNet SER12 mirror; disable with ptt_wd=no / --no-ptt-wd).
+# • TXD: default pulse THR 0x00; experiment txd_bias=steady (UART break ≈ TFPCX).
+# • Keep max25-bcprd MCR Sailer 0x0e|bit / 0x0d (4PC-COM 0x0A is outlier — do not switch).
+#
+# Usage (Cursor IDE terminal, as operator):
+# sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh
+# MAX25_BCPR_INI=/etc/max25/max25-bcpr.ini ./max25-bcpr-ultimate-diag.sh -c /etc/max25/max25-bcpr.ini
+# ./max25-bcpr-ultimate-diag.sh --help
+# ./max25-bcpr-ultimate-diag.sh --all # run phases 1–9 with pauses
+# ./max25-bcpr-ultimate-diag.sh --menu # interactive menu (default)
+#
+# Needs: /usr/bin/sudo -n for ioport / live smoke / cal. Does NOT change MCR code.
+# Does NOT use USB as product TX path. Does NOT kill max25d carelessly.
+#
+# Relies on: max25-bcpr-ctl, max25-bcpr-rxtx-smoke.sh (same directory).
+
+# Note: interactive prompts use read; keep pipefail but relax -e around optional probes.
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)"
+CTL="${SCRIPT_DIR}/max25-bcpr-ctl"
+SMOKE_SH="${SCRIPT_DIR}/max25-bcpr-rxtx-smoke.sh"
+INI="${MAX25_BCPR_INI:-/etc/max25/max25-bcpr.ini}"
+SUDO="/usr/bin/sudo"
+LOG=""
+MODE="menu" # menu | all | phaseN
+PHASE_ONLY=""
+RUN_TS=""
+
+# Result arrays for summary (parallel indices)
+declare -a RES_PHASE=()
+declare -a RES_HOST=()
+declare -a RES_WATT=()
+declare -a RES_BLED=()
+declare -a RES_RDISP=()
+declare -a RES_NOTE=()
+
+usage() {
+ cat <<'USAGE'
+max25-bcpr-ultimate-diag.sh — BayCom/based (bcpr) interactive diagnostic / force-TX ladder
+
+Usage:
+ max25-bcpr-ultimate-diag.sh [-c INI] [--menu|--all|--phase N] [--help]
+
+Options:
+ -c INI bcpr.ini (default: $MAX25_BCPR_INI or /etc/max25/max25-bcpr.ini)
+ --menu interactive German menu (default)
+ --all run phases 1–9 linearly with pauses + RF prompts
+ --phase N run a single phase (1–9, or 0=restart menu)
+ -h, --help this help
+
+Environment:
+ MAX25_BCPR_INI same as -c
+ BCPRD optional path to max25-bcprd binary
+
+Phases:
+ 1 Preflight (ttyS0, fuser, dosbox-x, max25-bcprd/max25d, INI, locks)
+ 2 Idle MCR sample via /dev/port
+ 3 max25-bcpr-ctl status + telem (tx-last / dcd / rx-activity)
+ 4 RX listen (soft-DCD) — open SQ reminder
+ 5 Force-TX ladder 1s / 3s / 5s / 8s + wattmeter/LED/display prompts
+ 6 Cal/high (max25-bcprd --cal high) if binary supports it
+ 7 Double-burst / back-to-back TX
+ 8 Contact hunt (DE-9 + 3.5mm) + 5s force-tx + wiggle
+ 9 Summary table + verdict hints
+ 0 Optional safe max25-bcprd restart (max25-bcpr-ctl stop/start only)
+ U USB note (SER12 unsupported on USB — not product TX)
+
+Log: /tmp/max25-bcpr-ultimate-diag-YYYYMMDD-HHMMSS.log
+
+Examples:
+ sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh --all
+ MAX25_BCPR_INI=/etc/max25/max25-bcpr.ini sudo -n ./max25-bcpr-ultimate-diag.sh --menu
+USAGE
+}
+
+# ---------- logging / UI ----------
+
+log() {
+ local line
+ line="$(printf '%s' "$*")"
+ printf '%s\n' "$line"
+ if [[ -n "${LOG:-}" ]]; then
+ printf '%s\n' "$line" >>"$LOG"
+ fi
+}
+
+log_raw() {
+ # stdin → stdout + log
+ if [[ -n "${LOG:-}" ]]; then
+ tee -a "$LOG"
+ else
+ cat
+ fi
+}
+
+banner() {
+ log ""
+ log "════════════════════════════════════════════════════════════"
+ log "$*"
+ log "════════════════════════════════════════════════════════════"
+}
+
+pause_enter() {
+ local msg="${1:-Weiter mit Enter …}"
+ printf '\n%s ' "$msg" >/dev/tty
+ # shellcheck disable=SC2034
+ local _dummy
+ read -r _dummy </dev/tty || true
+ log "[pause] $msg"
+}
+
+ask() {
+ # ask VAR "prompt"
+ local __var="$1"
+ local __prompt="$2"
+ local __ans=""
+ printf '%s ' "$__prompt" >/dev/tty
+ read -r __ans </dev/tty || true
+ printf -v "$__var" '%s' "$__ans"
+ log "[antwort] $__prompt → ${__ans}"
+}
+
+ask_yn() {
+ # ask_yn VAR "prompt" → stores j/n/y/n normalized to j|n
+ local __var="$1"
+ local __prompt="$2"
+ local __ans=""
+ while true; do
+ printf '%s [j/n]: ' "$__prompt" >/dev/tty
+ read -r __ans </dev/tty || true
+ case "${__ans,,}" in
+ j|ja|y|yes) printf -v "$__var" 'j'; log "[antwort] $__prompt → j"; return 0 ;;
+ n|nein|no) printf -v "$__var" 'n'; log "[antwort] $__prompt → n"; return 0 ;;
+ *) printf 'Bitte j oder n.\n' >/dev/tty ;;
+ esac
+ done
+}
+
+record_result() {
+ local phase="$1" host="$2" watt="$3" bled="$4" rdisp="$5" note="${6:-}"
+ RES_PHASE+=("$phase")
+ RES_HOST+=("$host")
+ RES_WATT+=("$watt")
+ RES_BLED+=("$bled")
+ RES_RDISP+=("$rdisp")
+ RES_NOTE+=("$note")
+}
+
+need_sudo() {
+ if [[ ! -x "$SUDO" ]]; then
+ log "FEHLER: $SUDO fehlt — Live-Phasen brauchen sudo."
+ return 1
+ fi
+ if ! "$SUDO" -n true 2>/dev/null; then
+ log "FEHLER: sudo -n fehlgeschlagen."
+ log " Bitte einmalig: sudo -v (oder NOPASSWD für diesen User)"
+ log " Dann Skript erneut starten."
+ return 1
+ fi
+ return 0
+}
+
+run_sudo() {
+ need_sudo || return 1
+ "$SUDO" -n "$@"
+}
+
+ini_get() {
+ local section="$1" key="$2"
+ [[ -f "$INI" ]] || return 0
+ awk -F= -v s="[$section]" -v k="$key" '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI" 2>/dev/null || true
+}
+
+resolve_max25-bcprd() {
+ if [[ -n "${BCPRD:-}" && "$BCPRD" != "max25-bcprd" && -x "$BCPRD" ]]; then
+ printf '%s\n' "$BCPRD"; return 0
+ fi
+ if command -v max25-bcprd >/dev/null 2>&1; then
+ command -v max25-bcprd; return 0
+ fi
+ local cand
+ for cand in \
+ "${BCPR_BUILD_DIR:-}/bin/max25-bcprd" \
+ "${ROOT}/build-max25-bcpr/bin/max25-bcprd" \
+ "${ROOT}/build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \
+ "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \
+ "${ROOT}/build/bin/max25-bcprd" \
+ /usr/local/bin/max25-bcprd /usr/bin/max25-bcprd; do
+ [[ -n "$cand" && -x "$cand" ]] && { printf '%s\n' "$cand"; return 0; }
+ done
+ return 1
+}
+
+serial_dev() {
+ local s
+ s="$(ini_get bc0 serial)"
+ printf '%s\n' "${s:-/dev/ttyS0}"
+}
+
+state_dir() {
+ local sd
+ sd="$(ini_get bcpr state_dir)"
+ printf '%s\n' "${sd:-/tmp/max25-bcpr}"
+}
+
+iobase_val() {
+ local b
+ b="$(ini_get bc0 iobase)"
+ printf '%s\n' "${b:-0x3f8}"
+}
+
+ensure_tools() {
+ local err=0
+ if [[ ! -x "$CTL" ]]; then
+ log "FEHLER: max25-bcpr-ctl fehlt: $CTL"; err=1
+ fi
+ if [[ ! -x "$SMOKE_SH" ]]; then
+ log "FEHLER: max25-bcpr-rxtx-smoke.sh fehlt: $SMOKE_SH"; err=1
+ fi
+ return "$err"
+}
+
+# ---------- Phase helpers ----------
+
+# Last smoke host result (PASS|FAIL) — do not capture function stdout (log noise).
+_LAST_HOST="—"
+
+smoke_force_tx() {
+ # smoke_force_tx SECONDS → sets _LAST_HOST
+ local secs="$1"
+ local listen=$((secs + 8))
+ local rc=0
+ [[ "$listen" -lt 12 ]] && listen=12
+ log "→ L4 force-tx ${secs}s (smoke --live --tx --force-tx --tx-seconds ${secs})"
+ run_sudo "$CTL" -c "$INI" smoke --live --tx --force-tx --seconds "$listen" --tx-seconds "$secs" 2>&1 | log_raw
+ rc=${PIPESTATUS[0]}
+ if [[ "$rc" -eq 0 ]]; then
+ _LAST_HOST="PASS"
+ log "HOST: PASS (MCR keyed ~${secs}s target)"
+ else
+ _LAST_HOST="FAIL"
+ log "HOST: FAIL (smoke rc=$rc)"
+ fi
+}
+
+prompt_rf_obs() {
+ # prompt_rf_obs PHASE_LABEL HOST_RESULT
+ # sets globals: _watt _bled _rdisp _extra
+ local label="$1" host="$2"
+ local watt bled rdisp extra
+ log ""
+ log "--- RF-Beobachtung: $label (Host=$host) ---"
+ log "Bitte Wattmeter / Board-LED / Radio-Display während des Keys prüfen."
+ ask watt "Wattmeter W (Zahl oder 0 / ?):"
+ ask_yn bled "Board-LED (PC-COM) an während Key?"
+ ask_yn rdisp "Radio-Display / TX-Anzeige an?"
+ ask extra "Kurznotiz (Enter = keine):"
+ _watt="$watt"
+ _bled="$bled"
+ _rdisp="$rdisp"
+ _extra="$extra"
+ record_result "$label" "$host" "$watt" "$bled" "$rdisp" "$extra"
+}
+
+# ---------- Phases ----------
+
+phase_preflight() {
+ banner "Phase 1 — Preflight"
+ local serial sd iobase irq fulldup dry
+ serial="$(serial_dev)"
+ sd="$(state_dir)"
+ iobase="$(iobase_val)"
+ irq="$(ini_get bc0 irq)"
+ fulldup="$(ini_get bc0 fulldup)"
+ dry="$(ini_get bcpr dry_run)"
+
+ log "INI: $INI"
+ if [[ ! -f "$INI" ]]; then
+ log "FEHLER: INI fehlt: $INI"
+ log " Hinweis: Beispiel → stacks/max25-bcpr/share/bcpr.ini.example → /etc/max25/max25-bcpr.ini"
+ return 1
+ fi
+
+ log "--- INI Auszug [bcpr]/[bc0] ---"
+ log " dry_run=${dry:-?} state_dir=${sd}"
+ log " serial=${serial} iobase=${iobase} irq=${irq:-?} fulldup=${fulldup:-?}"
+ log " mode=$(ini_get bc0 mode) kiss_link=$(ini_get bc0 kiss_link)"
+ log " baud=$(ini_get bc0 baud) tx_delay=$(ini_get bc0 tx_delay)"
+
+ log "--- Seriell ---"
+ if [[ -e "$serial" ]]; then
+ log "OK: $serial existiert"
+ ls -l "$serial" 2>&1 | log_raw || true
+ else
+ log "FEHLER: $serial fehlt"
+ fi
+
+ log "--- fuser / lsof (wer hält Port?) ---"
+ if command -v fuser >/dev/null 2>&1; then
+
+ run_sudo fuser -v "$serial" 2>&1 | log_raw
+
+ else
+ log "WARN: fuser nicht installiert"
+ fi
+ if command -v lsof >/dev/null 2>&1; then
+
+ run_sudo lsof "$serial" 2>&1 | log_raw
+
+ fi
+
+ log "--- dosbox-x / DOS-Gast ---"
+ if pgrep -a -f 'dosbox-x|dosbox' 2>/dev/null | log_raw; then
+ log "WARN: dosbox läuft — kann ttyS0/USB belegen (sniff/passthrough)."
+ else
+ log "OK: kein dosbox/dosbox-x Prozess"
+ fi
+
+ log "--- Prozesse max25-bcprd / max25d ---"
+ pgrep -a -x max25-bcprd 2>/dev/null | log_raw || log " max25-bcprd: nicht laufend"
+ pgrep -a -x max25d 2>/dev/null | log_raw || log " max25d: nicht laufend"
+ # wrapper scripts
+ pgrep -a -f 'run-max25d|max25d' 2>/dev/null | head -20 | log_raw || true
+
+ log "--- Lock / State ($sd) ---"
+ if [[ -d "$sd" ]]; then
+ ls -la "$sd" 2>&1 | log_raw || true
+ for f in "$sd"/lock* "$sd"/*.lock "$sd"/max25-bcprd.pid; do
+ [[ -e "$f" ]] || continue
+ log " lock/pid: $f"
+ [[ -f "$f" ]] && { log " content:"; cat "$f" 2>&1 | log_raw || true; }
+ done
+ else
+ log "WARN: state_dir fehlt: $sd"
+ fi
+
+ log "--- Kernel baycom_* Module (sollten NICHT geladen sein) ---"
+ if command -v lsmod >/dev/null 2>&1; then
+ lsmod 2>/dev/null | awk '/^baycom_/ {print}' | log_raw || log "OK: keine baycom_* Module"
+ fi
+
+ log "--- setserial (falls vorhanden) ---"
+ local ss
+ for ss in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do
+ if [[ -x "$ss" ]]; then
+
+ run_sudo "$ss" -g "$serial" 2>&1 | log_raw
+
+ break
+ fi
+ done
+
+ log "--- max25-bcpr-ctl preflight ---"
+
+ run_sudo "$CTL" -c "$INI" preflight 2>&1 | log_raw
+ local pf=$?
+
+ if [[ "$pf" -eq 0 ]]; then
+ log "OK: preflight PASS"
+ else
+ log "WARN: preflight rc=$pf (Port busy wenn Stack läuft — normal bei live max25d/max25-bcprd)"
+ fi
+
+ log "--- Tools ---"
+ log " CTL=$CTL"
+ log " SMOKE=$SMOKE_SH"
+ local bin
+ if bin="$(resolve_max25-bcprd)"; then
+ log " max25-bcprd=$bin"
+ else
+ log " WARN: max25-bcprd Binary nicht gefunden (Build: -DMAX25_BUILD_BCPR=ON)"
+ fi
+
+ pause_enter "Phase 1 fertig — Enter für weiter …"
+ return 0
+}
+
+phase_idle_mcr() {
+ banner "Phase 2 — Idle MCR Sample (/dev/port)"
+ local iobase mcr_off
+ iobase="$(iobase_val)"
+ # MCR = iobase+4
+ mcr_off=$((iobase + 4))
+ log "iobase=$iobase MCR=$(printf '0x%x' "$mcr_off") (iobase+4)"
+ log "Erwartung Idle (Sailer/bcpr): oft 0x0d (DTR+OUT2+RTS-clear) — Werte nur Info."
+
+ if ! need_sudo; then
+ log "überspringe MCR-Sample (kein sudo)"
+ pause_enter
+ return 0
+ fi
+
+ run_sudo python3 - "$iobase" <<'PY' 2>&1 | log_raw
+import os, sys, time
+iobase = int(sys.argv[1], 0)
+mcr = iobase + 4
+fd = os.open("/dev/port", os.O_RDONLY)
+try:
+ vals = []
+ for i in range(20):
+ os.lseek(fd, mcr, os.SEEK_SET)
+ v = ord(os.read(fd, 1))
+ vals.append(v)
+ time.sleep(0.05)
+ uniq = sorted(set(vals))
+ print("Idle MCR samples (20×50ms): " + ",".join("0x%02x" % v for v in vals))
+ print("Unique: " + ",".join("0x%02x" % v for v in uniq))
+ rts = [(v & 0x02) != 0 for v in vals]
+ print("RTS asserted in any sample: %s" % ("YES" if any(rts) else "no"))
+ if all(v == 0 for v in vals):
+ print("WARN: all-zero — /dev/port may be inaccessible or wrong iobase")
+finally:
+ os.close(fd)
+PY
+ local rc=$?
+
+ if [[ "$rc" -ne 0 ]]; then
+ log "WARN: Idle-MCR Sample fehlgeschlagen (rc=$rc) — CAP_SYS_RAWIO / iobase prüfen"
+ fi
+ pause_enter "Phase 2 fertig — Enter …"
+ return 0
+}
+
+phase_status() {
+ banner "Phase 3 — Status + Telemetrie"
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ local sd
+ sd="$(state_dir)"
+ log "--- Telemetrie unter $sd ---"
+ for f in "$sd"/tx-last-bc* "$sd"/dcd-bc* "$sd"/rx-activity-bc* "$sd"/kiss-bc*; do
+ [[ -e "$f" ]] || continue
+ log "FILE: $f"
+ if [[ -L "$f" ]]; then
+ log " symlink → $(readlink -f "$f" 2>/dev/null || readlink "$f")"
+ elif [[ -f "$f" ]]; then
+ cat "$f" 2>&1 | log_raw || true
+ fi
+ done
+ pause_enter "Phase 3 fertig — Enter …"
+ return 0
+}
+
+phase_rx_listen() {
+ banner "Phase 4 — RX Listen (soft-DCD)"
+ log "OPERATOR: Squellch öffnen / Rauschen/SQ so dass Soft-DCD aktiv werden kann."
+ log " (§0.20 RX before TX — hier nur Listen, kein TX)"
+ pause_enter "SQ bereit? Enter startet ~12s RX listen …"
+
+ run_sudo "$CTL" -c "$INI" smoke --live --seconds 12 2>&1 | log_raw
+ local rc=${PIPESTATUS[0]}
+
+ if [[ "$rc" -eq 0 ]]; then
+ log "OK: RX-Listen smoke beendet (rc=0)"
+ else
+ log "WARN: RX-Listen smoke rc=$rc (siehe Log; Stack/INI prüfen)"
+ fi
+
+ local sd
+ sd="$(state_dir)"
+ for f in "$sd"/dcd-bc0 "$sd"/rx-activity-bc0; do
+ [[ -f "$f" ]] || continue
+ log "Nach RX: $f"
+ cat "$f" 2>&1 | log_raw || true
+ done
+ pause_enter "Phase 4 fertig — Enter …"
+ return 0
+}
+
+phase_force_tx_ladder() {
+ banner "Phase 5 — Force-TX Ladder (1 / 3 / 5 / 8 s)"
+ log "WARNUNG: --force-tx ohne RX-Nachweis (§0.20 Override) — Debug / Intermittent-Hunt."
+ log "Wattmeter bereithalten. USB-Pfad ist KEIN Produkt-TX."
+ log ""
+ log "PTT-WATCHDOG (FlexNet SER12 / WD-Boards):"
+ log " Hardware kann PTT nach ~14,5 s Dauer-Key abwerfen (Discharge ~500 ms)."
+ log " Bei langen Läufen / vielen Keys hintereinander: zwischendurch UNKEY / Pause ~14 s."
+ log " Sonst: RF fällt trotz Host-MCR PASS (Watchdog, kein UART-Fehler)."
+ log ""
+ log "BEOBACHTUNGSHINWEIS TXD (Charge-Pump):"
+ log " max25-bcprd/Sailer: THR 0x00 gepulst · TFPCX: TXD oft steady +12 V."
+ log " Notiz wenn RF mitten im Key einbricht / flackert (Pump/Bias-Kandidat)."
+ pause_enter "Bereit für Ladder? Enter …"
+
+ local secs host
+ for secs in 1 3 5 8; do
+ banner "Force-TX ${secs}s"
+ pause_enter "Wattmeter beobachten — Enter startet ${secs}s Key …"
+ smoke_force_tx "$secs"
+ host="$_LAST_HOST"
+ # show telem snapshot
+ local sd tl
+ sd="$(state_dir)"
+ tl="$sd/tx-last-bc0"
+ if [[ -f "$tl" ]]; then
+ log "--- tx-last-bc0 nach ${secs}s ---"
+ cat "$tl" 2>&1 | log_raw || true
+ fi
+ prompt_rf_obs "L4-${secs}s" "$host"
+ pause_enter "Nächste Stufe — Enter …"
+ done
+ return 0
+}
+
+phase_cal_high() {
+ banner "Phase 6 — Cal/high (max25-bcprd --cal high)"
+ local bin
+ if ! bin="$(resolve_max25-bcprd)"; then
+ log "WARN: max25-bcprd nicht gefunden — Cal übersprungen"
+ pause_enter
+ return 0
+ fi
+
+ # Prefer strings(1); never start bare max25-bcprd. Source contract: --cal high|low|alt.
+ if command -v strings >/dev/null 2>&1 && ! strings "$bin" 2>/dev/null | grep -qF -- '--cal'; then
+ log "WARN: strings fand kein --cal in Binary — Phase trotzdem anbieten (Operator kann abbrechen)"
+ fi
+
+ log "max25-bcprd=$bin"
+ log "Cal = kontinuierlicher SER12 Tone+PTT (DOS cal.exe Stil), kein KISS."
+ log "Sicher: nur max25-bcprd stoppen via max25-bcpr-ctl — max25d Wrapper NICHT hart killen."
+ log ""
+ log "PTT-WATCHDOG: FlexNet cal pausiert alle ~14,5 s für ~500 ms (PTT-Discharge)."
+ log " Dieses Skript: --cal high ~10 s (unter WD). Längere manuelle Cal/"
+ log " Dauer-TX: UNKEY/Pause ~14 s einplanen — sonst RF-Tot auf WD-Boards."
+ log "TXD: max25-bcprd pulst THR 0x00 (nicht TFPCX-steady) — RF-Einbruch mid-cal notieren."
+ log ""
+ local do_cal
+ ask_yn do_cal "max25-bcprd stoppen und --cal high ~10s starten?"
+ if [[ "$do_cal" != "j" ]]; then
+ log "Cal übersprungen (Operator)"
+ pause_enter
+ return 0
+ fi
+
+ if ! need_sudo; then
+ pause_enter
+ return 0
+ fi
+
+ log "→ max25-bcpr-ctl stop"
+
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+
+ sleep 1
+
+ # Ensure no stray max25-bcprd
+ if pgrep -x max25-bcprd >/dev/null 2>&1; then
+ log "WARN: max25-bcprd noch aktiv nach stop — pkill -x max25-bcprd (kein max25d)"
+
+ run_sudo pkill -x max25-bcprd 2>&1 | log_raw
+
+ sleep 1
+ fi
+
+ log "→ $bin -c $INI --cal high --seconds 10"
+ pause_enter "Wattmeter bereit — Enter startet cal high 10s …"
+
+ run_sudo "$bin" -c "$INI" --cal high --seconds 10 2>&1 | log_raw
+ local rc=$?
+
+ local host="PASS"
+ [[ "$rc" -eq 0 ]] || host="FAIL(rc=$rc)"
+ prompt_rf_obs "CAL-high-10s" "$host"
+
+ local restart
+ ask_yn restart "max25-bcprd danach wieder starten (max25-bcpr-ctl start)?"
+ if [[ "$restart" == "j" ]]; then
+
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+
+ log "Hinweis: wenn max25d den Stack besitzt, ggf. max25d neu starten (Operator, nicht dieses Skript)."
+ fi
+ pause_enter "Phase 6 fertig — Enter …"
+ return 0
+}
+
+phase_double_burst() {
+ banner "Phase 7 — Double Burst (back-to-back TX)"
+ log "Zwei Force-TX 3s hintereinander (kurze Pause dazwischen)."
+ pause_enter "Enter startet Burst A …"
+ local host_a host_b
+ smoke_force_tx 3
+ host_a="$_LAST_HOST"
+ sleep 1
+ log "Burst B …"
+ smoke_force_tx 3
+ host_b="$_LAST_HOST"
+ prompt_rf_obs "DoubleBurst-A+B" "${host_a}/${host_b}"
+ pause_enter "Phase 7 fertig — Enter …"
+ return 0
+}
+
+phase_contact_hunt() {
+ banner "Phase 8 — Contact Hunt (DE-9 + 3.5mm)"
+ log "OPERATOR-CHECKLISTE:"
+ log " 1) DE-9 (PC-COM ↔ Host) und 3.5mm (Modem ↔ Radio) fest stecken"
+ log " 2) Während dem nächsten Key leicht wackeln (Stecker/Kabel)"
+ log " 3) Wattmeter + Board-LED + Radio-Display beobachten"
+ log " Ziel: intermittenter Kontakt vs. dauerhaft tot unterscheiden"
+ pause_enter "Stecker fest? Enter startet 5s force-tx (währenddessen wackeln) …"
+
+ local host
+ smoke_force_tx 5
+ host="$_LAST_HOST"
+ local obs
+ ask obs "Beobachtung während Wackeln (z.B. 'kurz 2W' / 'immer 0' / 'LED flackert'):"
+ prompt_rf_obs "Contact-5s" "$host"
+ # overwrite last note with wiggle observation if empty note
+ if [[ -n "$obs" && ${#RES_NOTE[@]} -gt 0 ]]; then
+ RES_NOTE[$((${#RES_NOTE[@]} - 1))]="$obs | ${_extra:-}"
+ log "[contact-note] $obs"
+ fi
+ pause_enter "Phase 8 fertig — Enter …"
+ return 0
+}
+
+phase_usb_note() {
+ banner "Hinweis U — USB SER12 (kein Produkt-TX)"
+ log "USB-UART (z.B. /dev/ttyUSB0 / FTDI) ist KEIN unterstützter max25-bcprd SER12-Pfad."
+ log " max25-bcprd braucht ioperm|/dev/port + echte ISA/LPC iobase — USB hat das nicht."
+ log " Station: RF nur ttyS0+CB; USB0 = Modem ohne Radio → Wattmeter ungültig."
+ log " DOSBox Soft-TNC auf USB ≠ bcpr Produkt-TX."
+ pause_enter
+ return 0
+}
+
+phase_restart_menu() {
+ banner "Optional 0 — Sicheres max25-bcprd Restart"
+ log "Methode (aus max25-bcpr-ctl): stop → start. Killt NICHT max25d."
+ log "Wenn max25d den Stack besitzt: nach stop/start ggf. max25d-seitig neu binden."
+ log ""
+ log " Aktueller Status:"
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ local act
+ ask act "Aktion: [s]top / [t]start / [r]estart / [a]bbruch:"
+ case "${act,,}" in
+ s|stop)
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+ ;;
+ t|start)
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+ ;;
+ r|restart|re)
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+ sleep 1
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+ ;;
+ *)
+ log "Abbruch — keine Änderung"
+ ;;
+ esac
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ pause_enter
+ return 0
+}
+
+phase_summary() {
+ banner "Phase 9 — Summary + Verdict"
+ log "Logdatei: $LOG"
+ log ""
+ log "┌──────────────────┬──────────┬──────────┬──────────┬──────────┐"
+ log "│ Phase │ Host │ Watt W │ BoardLED │ RadioTX │"
+ log "├──────────────────┼──────────┼──────────┼──────────┼──────────┤"
+ local i n
+ n=${#RES_PHASE[@]}
+ if [[ "$n" -eq 0 ]]; then
+ log "│ (keine TX-Phasen aufgezeichnet) │"
+ else
+ for ((i = 0; i < n; i++)); do
+ printf '│ %-16s │ %-8s │ %-8s │ %-8s │ %-8s │\n' \
+ "${RES_PHASE[$i]:0:16}" \
+ "${RES_HOST[$i]:0:8}" \
+ "${RES_WATT[$i]:0:8}" \
+ "${RES_BLED[$i]:0:8}" \
+ "${RES_RDISP[$i]:0:8}" | log_raw
+ if [[ -n "${RES_NOTE[$i]:-}" ]]; then
+ log "│ note: ${RES_NOTE[$i]}"
+ fi
+ done
+ fi
+ log "└──────────────────┴──────────┴──────────┴──────────┴──────────┘"
+
+ # Verdict heuristics
+ local any_host_pass=0 any_host_fail=0 any_rf=0 all_rf_zero=1
+ for ((i = 0; i < n; i++)); do
+ case "${RES_HOST[$i]}" in
+ *PASS*) any_host_pass=1 ;;
+ *FAIL*) any_host_fail=1 ;;
+ esac
+ case "${RES_WATT[$i]}" in
+ ''|'0'|'0.0'|'?'|'n'|'nein') ;;
+ *)
+ # non-zero / non-empty that looks like power
+ if [[ "${RES_WATT[$i]}" =~ ^[0-9]*\.?[0-9]+$ ]]; then
+ if awk -v w="${RES_WATT[$i]}" 'BEGIN{exit !(w>0)}'; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ else
+ # free text — if contains number >0 heuristic
+ if [[ "${RES_WATT[$i]}" =~ [1-9] ]]; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ fi
+ ;;
+ esac
+ if [[ "${RES_BLED[$i]}" == "j" || "${RES_RDISP[$i]}" == "j" ]]; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ done
+
+ log ""
+ log "=== Verdict-Hinweise (Heuristik, kein Automatik-Urteil) ==="
+ if [[ "$n" -eq 0 ]]; then
+ log "• Keine Ladder-Daten — nur Preflight/Status gelaufen."
+ elif [[ "$any_host_pass" -eq 1 && "$all_rf_zero" -eq 1 && "$any_rf" -eq 0 ]]; then
+ log "• HOST OK / RF tot oder 0 W: Fehlerklasse NACH UART"
+ log " → DE-9/3.5mm Kontakt, Charge-Pump/Vcc, PTT-Transistor, Mic-Buchse, Radio"
+ log " → Mic-alone 4 W OK stützt: Radio selbst ok; Pfad Modem↔Mic intermittierend"
+ elif [[ "$any_host_pass" -eq 1 && "$any_rf" -eq 1 ]]; then
+ log "• HOST OK / RF zeitweise sichtbar: INTERMITTENT RF (Kontakt/Pump/AF)"
+ log " → Contact-Hunt wiederholen; zwischen langen Keys Pause ~14 s (PTT-WD)"
+ log " → TXD pulse vs steady (TFPCX) als Beobachtung; Kabel/Stecker fest"
+ elif [[ "$any_host_fail" -eq 1 && "$any_host_pass" -eq 0 ]]; then
+ log "• HOST FAIL: zuerst Stack/KISS/fulldup/MCR — nicht primär Wattmeter"
+ log " → max25-bcpr-ctl status, kiss_link, fulldup=yes bei Soft-DCD, kein zweites max25-bcprd"
+ log " → MCR bleibt Sailer 0x0e|bit / 0x0d (nicht 4PC-COM 0x0A)"
+ else
+ log "• Gemischte Ergebnisse — Log + Notizen vergleichen; Phasen 5/8 wiederholen."
+ fi
+ log ""
+ log "SSoT: operator research notes (private)"
+ log " 2026-07-19-bcpr-mcr-ok-rf-zero-after-4w.md · winning-recipe · RF intermittent"
+ log " soft-TNC RE: 2026-07-19-baycom-soft-tnc-serial-ptt-re.md (WD · TXD · MCR)"
+ log "USB: kein Produkt-TX (Phase U)."
+ log ""
+ log "Log gespeichert: $LOG"
+ return 0
+}
+
+run_all() {
+ phase_preflight
+ phase_idle_mcr
+ phase_status
+ phase_rx_listen
+ phase_force_tx_ladder
+ phase_cal_high
+ phase_double_burst
+ phase_contact_hunt
+ phase_usb_note
+ phase_summary
+}
+
+menu_loop() {
+ while true; do
+ banner "max25-bcpr Ultimate Diag — Menü"
+ log "INI=$INI"
+ log "Log=$LOG"
+ log ""
+ log " 1) Preflight"
+ log " 2) Idle MCR"
+ log " 3) Status + Telem"
+ log " 4) RX Listen"
+ log " 5) Force-TX Ladder 1/3/5/8s"
+ log " 6) Cal/high"
+ log " 7) Double Burst"
+ log " 8) Contact Hunt"
+ log " 9) Summary / Verdict"
+ log " A) Alle Phasen 1–9 (+ USB-Hinweis)"
+ log " 0) max25-bcprd stop/start (sicher)"
+ log " U) USB-Hinweis"
+ log " Q) Beenden"
+ log ""
+ local choice
+ ask choice "Wahl:"
+ case "${choice^^}" in
+ 1) phase_preflight ;;
+ 2) phase_idle_mcr ;;
+ 3) phase_status ;;
+ 4) phase_rx_listen ;;
+ 5) phase_force_tx_ladder ;;
+ 6) phase_cal_high ;;
+ 7) phase_double_burst ;;
+ 8) phase_contact_hunt ;;
+ 9) phase_summary ;;
+ A|ALL) run_all ;;
+ 0) phase_restart_menu ;;
+ U) phase_usb_note ;;
+ Q|X|EXIT|QUIT)
+ phase_summary
+ log "Ende."
+ return 0
+ ;;
+ *)
+ log "Unbekannte Wahl: $choice"
+ ;;
+ esac
+ done
+}
+
+# ---------- main ----------
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c)
+ [[ $# -ge 2 ]] || { echo "ERROR: -c needs INI path"; exit 2; }
+ INI="$2"
+ shift 2
+ ;;
+ --menu) MODE="menu"; shift ;;
+ --all) MODE="all"; shift ;;
+ --phase)
+ [[ $# -ge 2 ]] || { echo "ERROR: --phase needs N"; exit 2; }
+ MODE="phase"
+ PHASE_ONLY="$2"
+ shift 2
+ ;;
+ -h|--help) usage; exit 0 ;;
+ *)
+ echo "ERROR: unknown arg: $1"
+ usage
+ exit 2
+ ;;
+ esac
+done
+
+RUN_TS="$(date +%Y%m%d-%H%M%S)"
+LOG="/tmp/max25-bcpr-ultimate-diag-${RUN_TS}.log"
+: >"$LOG" || {
+ echo "FEHLER: kann Log nicht schreiben: $LOG"
+ exit 1
+}
+
+# Interactive reads use /dev/tty; log()/log_raw append to $LOG (no exec-tee double).
+banner "max25-bcpr Ultimate Diag — BayCom/based (bcpr)"
+log "Start: $(date -R 2>/dev/null || date)"
+log "Host: $(hostname 2>/dev/null || echo '?')"
+log "User: $(id -un 2>/dev/null || echo '?') uid=$(id -u)"
+log "INI: $INI (override: MAX25_BCPR_INI / -c)"
+log "Log: $LOG"
+log "Tools: $CTL"
+log ""
+log "Hinweis: PTT-Watchdog ~14,5 s / 500 ms — bei langen Keys unkey/pausieren."
+log " TXD: max25-bcprd pulst (Sailer); TFPCX oft steady — RF mid-key notieren."
+log " MCR: Sailer belassen (kein 4PC-COM 0x0A-Experiment in diesem Skript)."
+log ""
+
+ensure_tools || {
+ log "Abbruch: Tools fehlen."
+ exit 1
+}
+
+# Soft check sudo early (warn only for menu; hard for --all live)
+if ! need_sudo; then
+ log "WARN: ohne sudo -n sind Live/TX/Cal/MCR-Phasen blockiert."
+ if [[ "$MODE" == "all" ]]; then
+ log "Abbruch (--all braucht sudo -n)."
+ exit 1
+ fi
+fi
+
+case "$MODE" in
+ all)
+ run_all
+ ;;
+ phase)
+ case "$PHASE_ONLY" in
+ 1) phase_preflight ;;
+ 2) phase_idle_mcr ;;
+ 3) phase_status ;;
+ 4) phase_rx_listen ;;
+ 5) phase_force_tx_ladder ;;
+ 6) phase_cal_high ;;
+ 7) phase_double_burst ;;
+ 8) phase_contact_hunt ;;
+ 9) phase_summary ;;
+ 0) phase_restart_menu ;;
+ U|u) phase_usb_note ;;
+ *)
+ log "FEHLER: unbekannte Phase $PHASE_ONLY"
+ exit 2
+ ;;
+ esac
+ ;;
+ menu|*)
+ menu_loop
+ ;;
+esac
+
+log ""
+log "Fertig. Log: $LOG"
+exit 0
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com