diff options
Diffstat (limited to 'stacks/max25-bcpr')
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 |
