From fa05a5f8238e9e1417235711656e5062ccc843a7 Mon Sep 17 00:00:00 2001 From: "info@mode42.com" Date: Fri, 7 Aug 2026 18:23:28 +0000 Subject: Initial push --- stacks/terminal/.gitignore | 9 + stacks/terminal/CMakeLists.txt | 40 ++ stacks/terminal/README.md | 31 ++ stacks/terminal/amiga/Makefile | 32 ++ stacks/terminal/amiga/max25_terminal_amiga.c | 309 +++++++++++ stacks/terminal/max25_proto.c | 472 +++++++++++++++++ stacks/terminal/max25_proto.h | 55 ++ stacks/terminal/max25_terminal.c | 767 +++++++++++++++++++++++++++ stacks/terminal/max25_ui.c | 407 ++++++++++++++ stacks/terminal/max25_ui.h | 45 ++ stacks/terminal/test-terminal.sh | 28 + stacks/terminal/test_apply_event.c | 30 ++ stacks/terminal/test_connect.py | 78 +++ 13 files changed, 2303 insertions(+) create mode 100644 stacks/terminal/.gitignore create mode 100644 stacks/terminal/CMakeLists.txt create mode 100644 stacks/terminal/README.md create mode 100644 stacks/terminal/amiga/Makefile create mode 100644 stacks/terminal/amiga/max25_terminal_amiga.c create mode 100644 stacks/terminal/max25_proto.c create mode 100644 stacks/terminal/max25_proto.h create mode 100644 stacks/terminal/max25_terminal.c create mode 100644 stacks/terminal/max25_ui.c create mode 100644 stacks/terminal/max25_ui.h create mode 100755 stacks/terminal/test-terminal.sh create mode 100644 stacks/terminal/test_apply_event.c create mode 100644 stacks/terminal/test_connect.py (limited to 'stacks/terminal') diff --git a/stacks/terminal/.gitignore b/stacks/terminal/.gitignore new file mode 100644 index 0000000..bb6aac1 --- /dev/null +++ b/stacks/terminal/.gitignore @@ -0,0 +1,9 @@ +# Built client artifacts (make -C stacks/terminal all) +max25-terminal +max25-client +*.o +amiga/max25-terminal +amiga/*.o + +# vault/agent — never ship +AGENT-INDEX.md diff --git a/stacks/terminal/CMakeLists.txt b/stacks/terminal/CMakeLists.txt new file mode 100644 index 0000000..d391b9e --- /dev/null +++ b/stacks/terminal/CMakeLists.txt @@ -0,0 +1,40 @@ +set(MAX25_TERMINAL_SRCS + max25_terminal.c + max25_proto.c + max25_ui.c +) + +add_executable(max25-terminal ${MAX25_TERMINAL_SRCS}) +target_include_directories(max25-terminal PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") + +find_package(Curses) +if(Curses_FOUND AND TARGET Curses::Curses) + target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES) + target_link_libraries(max25-terminal PRIVATE Curses::Curses) +elseif(Curses_FOUND) + target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES) + target_include_directories(max25-terminal PRIVATE ${CURSES_INCLUDE_DIR}) + target_link_libraries(max25-terminal PRIVATE ${CURSES_LIBRARIES}) +elseif(UNIX AND NOT APPLE) + target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES) + target_link_libraries(max25-terminal PRIVATE ncurses tinfo) +endif() + +target_compile_options(max25-terminal PRIVATE -Wall -Wextra -std=c11 -O2) + +add_custom_command(TARGET max25-terminal POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E rm -f "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/max25-client" + COMMAND "${CMAKE_COMMAND}" -E create_symlink max25-terminal "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/max25-client" + COMMENT "max25-client -> max25-terminal" +) + +install(TARGETS max25-terminal RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") + +install(CODE " + set(_dst \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-client\") + set(_src \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-terminal\") + if(EXISTS \"\${_dst}\") + file(REMOVE \"\${_dst}\") + endif() + execute_process(COMMAND \"${CMAKE_COMMAND}\" -E create_symlink max25-terminal \"\${_dst}\") +") diff --git a/stacks/terminal/README.md b/stacks/terminal/README.md new file mode 100644 index 0000000..609468d --- /dev/null +++ b/stacks/terminal/README.md @@ -0,0 +1,31 @@ +# MAX25 Terminal (`max25-terminal` / `max25-client`) + +Official text-only operator client for `max25d`. Cross-platform via M25/1 (TCP or Unix). + +| Doc | Audience | +|-----|----------| +| [../../include/max25/protocol.md](../../include/max25/protocol.md) | Developer — M25/1 binding | +| [../../docs/README.md](../../docs/README.md) | Operator — doc index (stub; depth in research vault) | + +## Build & test + +```bash +./scripts/build.sh +bash stacks/terminal/test-terminal.sh +``` + +Requires C11 compiler and ncurses (`libncurses-dev` on Linux). + +## Run (canonical) + +Prefer install/PATH or `build*/bin/max25-terminal`. Tree binary may live at `./max25-terminal` after copy (gitignored). + +```bash +max25-terminal -U /run/max25/modem.sock +# with [devices] default set in max25d.ini — no -d needed +# then: CONNECT (F10→6) → SEND … +``` + +Optional: `-d ` / `SET DEVICE` / F10→7 to override the default device. + +Daemon: [stacks/daemon/README.md](../daemon/README.md) — **not** under this directory. diff --git a/stacks/terminal/amiga/Makefile b/stacks/terminal/amiga/Makefile new file mode 100644 index 0000000..84cc53b --- /dev/null +++ b/stacks/terminal/amiga/Makefile @@ -0,0 +1,32 @@ +# MAX25 Terminal — AmigaOS 3.9+ cross-build (bebbo-gcc / clib2) + +AMIGA_SDK ?= /opt/amiga +CC := $(AMIGA_SDK)/bin/m68k-amigaos-gcc +CFLAGS ?= -Wall -Wextra -std=c11 -O2 -mcrt=clib2 +LDFLAGS ?= -mcrt=clib2 +LIBS := -lnet -lunix + +PROTO_DIR := .. +SRCS := max25_terminal_amiga.c $(PROTO_DIR)/max25_proto.c +OBJS := max25_terminal_amiga.o max25_proto.o +TARGET := max25-terminal + +.PHONY: all test clean + +all: $(TARGET) + +$(TARGET): $(OBJS) + $(CC) $(LDFLAGS) -o $@ $(OBJS) $(LIBS) + +max25_terminal_amiga.o: max25_terminal_amiga.c $(PROTO_DIR)/max25_proto.h + $(CC) $(CFLAGS) -DMAX25_AMIGA=1 -I$(PROTO_DIR) -c -o $@ max25_terminal_amiga.c + +max25_proto.o: $(PROTO_DIR)/max25_proto.c $(PROTO_DIR)/max25_proto.h + $(CC) $(CFLAGS) -DMAX25_AMIGA=1 -I$(PROTO_DIR) -c -o $@ $(PROTO_DIR)/max25_proto.c + +test: all + @test -x ./$(TARGET) + file ./$(TARGET) | grep -qiE 'amiga|68k|m68k' && echo "OK: Amiga m68k binary" + +clean: + rm -f $(OBJS) $(TARGET) diff --git a/stacks/terminal/amiga/max25_terminal_amiga.c b/stacks/terminal/amiga/max25_terminal_amiga.c new file mode 100644 index 0000000..3b0f513 --- /dev/null +++ b/stacks/terminal/amiga/max25_terminal_amiga.c @@ -0,0 +1,309 @@ +/* + * max25-terminal — AmigaOS 3.9+ reduced operator client (TCP/M25/1 only). + * + * HyBBX-style thin client: no ncurses/F10 menu; connects to Linux max25d. + */ +#define _DEFAULT_SOURCE 1 + +#include "../max25_proto.h" + +#include +#include +#include +#include +#include +#include + +#ifdef __AMIGA__ +#include +#endif + +typedef struct { + char host[256]; + unsigned port; + char tcp_password[128]; + int ax25_ui; + int verbose; + int probe; +} amiga_config_t; + +static void config_defaults(amiga_config_t *cfg) +{ + const char *env; + + memset(cfg, 0, sizeof(*cfg)); + strncpy(cfg->host, "127.0.0.1", sizeof(cfg->host) - 1); + cfg->port = MAX25_DEFAULT_PORT; + cfg->ax25_ui = 1; + + env = getenv("MAX25_HOST"); + if (env != NULL && env[0] != '\0') { + strncpy(cfg->host, env, sizeof(cfg->host) - 1); + } + env = getenv("MAX25_PORT"); + if (env != NULL && env[0] != '\0') { + cfg->port = (unsigned)strtoul(env, NULL, 10); + } + env = getenv("MAX25_TCP_PASSWORD"); + if (env != NULL) { + strncpy(cfg->tcp_password, env, sizeof(cfg->tcp_password) - 1); + } +} + +static void usage(const char *prog) +{ + fprintf(stderr, + "MAX25 Terminal (AmigaOS) — reduced CLI client for max25d\n\n" + "Usage: %s [options] [host] [port]\n\n" + "Options:\n" + " -H HOST max25d TCP host (default 127.0.0.1)\n" + " -p PORT max25d TCP port (default %u)\n" + " -P PASSWORD TCP auth password (plain-text AUTH)\n" + " --ax25-ui AX.25 UI mode (default on)\n" + " --no-ax25-ui\n" + " -v Verbose protocol log on stderr\n" + " --probe Connect, print STATUS, exit\n" + " -h Help\n\n" + "Session: type a line to SEND; /status /callerid /callid /connect /quit\n" + "Environment: MAX25_HOST, MAX25_PORT, MAX25_TCP_PASSWORD\n\n", + prog, MAX25_DEFAULT_PORT); +} + +static int parse_args(int argc, char **argv, amiga_config_t *cfg) +{ + int i; + int positional = 0; + + for (i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(argv[0]); + return 1; + } + if (strcmp(argv[i], "-H") == 0 && i + 1 < argc) { + strncpy(cfg->host, argv[++i], sizeof(cfg->host) - 1); + continue; + } + if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { + cfg->port = (unsigned)strtoul(argv[++i], NULL, 10); + continue; + } + if ((strcmp(argv[i], "-P") == 0 || strcmp(argv[i], "--password") == 0) && + i + 1 < argc) { + strncpy(cfg->tcp_password, argv[++i], sizeof(cfg->tcp_password) - 1); + continue; + } + if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) { + cfg->verbose = 1; + continue; + } + if (strcmp(argv[i], "--probe") == 0) { + cfg->probe = 1; + continue; + } + if (strcmp(argv[i], "--ax25-ui") == 0) { + cfg->ax25_ui = 1; + continue; + } + if (strcmp(argv[i], "--no-ax25-ui") == 0) { + cfg->ax25_ui = 0; + continue; + } + if (argv[i][0] == '-') { + fprintf(stderr, "%s: unknown option %s\n", argv[0], argv[i]); + return -1; + } + if (positional == 0) { + strncpy(cfg->host, argv[i], sizeof(cfg->host) - 1); + } else if (positional == 1) { + cfg->port = (unsigned)strtoul(argv[i], NULL, 10); + } + positional++; + } + return 0; +} + +static void print_rx(const char *line, int verbose) +{ + if (verbose) { + fprintf(stderr, "max25-terminal: << %s\n", line); + } + if (strncmp(line, "RX ", 3) == 0) { + printf("<< %s\n", line + 3); + } else if (strncmp(line, "ERR ", 4) == 0) { + fprintf(stderr, "!! %s\n", line); + } else if (strncmp(line, "EVENT ", 6) == 0) { + printf("-- %s\n", line); + } + fflush(stdout); + fflush(stderr); +} + +static int drain_socket(max25_client_t *client, int verbose) +{ + char line[MAX25_LINE_MAX]; + int got = 0; + + for (;;) { + fd_set rfds; + struct timeval tv; + int fd = max25_client_fd(client); + + FD_ZERO(&rfds); + FD_SET(fd, &rfds); + tv.tv_sec = 0; + tv.tv_usec = 0; + if (select(fd + 1, &rfds, NULL, NULL, &tv) <= 0) { + break; + } + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + return -1; + } + print_rx(line, verbose); + got = 1; + if (strcmp(line, "OK") == 0) { + break; + } + } + return got ? 0 : 0; +} + +static int send_cmd_wait(max25_client_t *client, const char *cmd, int verbose) +{ + char line[MAX25_LINE_MAX]; + + if (verbose) { + fprintf(stderr, "max25-terminal: >> %s\n", cmd); + } + if (max25_client_write(client, cmd) != 0) { + return -1; + } + for (;;) { + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + return -1; + } + print_rx(line, verbose); + if (strncmp(line, "STATUS ", 7) == 0) { + continue; + } + if (strncmp(line, "RX ", 3) == 0) { + continue; + } + if (strncmp(line, "EVENT ", 6) == 0) { + continue; + } + return strcmp(line, "OK") == 0 ? 0 : -1; + } +} + +static int run_session(max25_client_t *client, amiga_config_t *cfg, + max25_status_t *status) +{ + char line[MAX25_LINE_MAX]; + + printf("MAX25 Terminal (Amiga) — connected to %s:%u\n", cfg->host, cfg->port); + printf("Type text to SEND. Commands: /status /callerid X /callid X /connect /quit\n"); + + if (!status->connected) { + send_cmd_wait(client, "CONNECT", cfg->verbose); + status->connected = 1; + } + + while (fgets(line, sizeof(line), stdin) != NULL) { + line[strcspn(line, "\r\n")] = '\0'; + if (line[0] == '\0') { + continue; + } + if (strcmp(line, "/quit") == 0 || strcmp(line, "/exit") == 0) { + break; + } + if (strcmp(line, "/status") == 0) { + send_cmd_wait(client, "GET STATUS", cfg->verbose); + continue; + } + if (strcmp(line, "/connect") == 0) { + send_cmd_wait(client, "CONNECT", cfg->verbose); + status->connected = 1; + continue; + } + if (strcmp(line, "/disconnect") == 0) { + send_cmd_wait(client, "DISCONNECT", cfg->verbose); + status->connected = 0; + continue; + } + if (strncmp(line, "/callerid ", 10) == 0) { + char cmd[MAX25_LINE_MAX]; + if (!max25_valid_callsign(line + 10)) { + fprintf(stderr, "invalid CALLERID\n"); + continue; + } + snprintf(cmd, sizeof(cmd), "SET CALLERID %s", line + 10); + send_cmd_wait(client, cmd, cfg->verbose); + continue; + } + if (strncmp(line, "/callid ", 8) == 0) { + char cmd[MAX25_LINE_MAX]; + if (!max25_valid_callsign(line + 8)) { + fprintf(stderr, "invalid CALLID\n"); + continue; + } + snprintf(cmd, sizeof(cmd), "SET CALLID %s", line + 8); + send_cmd_wait(client, cmd, cfg->verbose); + continue; + } + { + char cmd[MAX25_LINE_MAX + 8]; + snprintf(cmd, sizeof(cmd), "SEND %s", line); + send_cmd_wait(client, cmd, cfg->verbose); + } + (void)drain_socket(client, cfg->verbose); + } + return 0; +} + +int main(int argc, char **argv) +{ + amiga_config_t cfg; + max25_client_t *client = NULL; + max25_status_t status; + char reply[MAX25_LINE_MAX]; + int rc; + + config_defaults(&cfg); + rc = parse_args(argc, argv, &cfg); + if (rc != 0) { + return rc < 0 ? EXIT_FAILURE : EXIT_SUCCESS; + } + + if (max25_client_connect(cfg.host, cfg.port, "", 1, cfg.tcp_password, + &client, &status) != 0) { + fprintf(stderr, "max25-terminal: connect failed (tcp %s:%u)\n", + cfg.host, cfg.port); + return EXIT_FAILURE; + } + + if (cfg.probe) { + printf("STATUS hardware=%s device=%s mode=%s callerid=%s callid=%s " + "ax25_ui=%s connected=%s stack=%s\n", + status.hardware, status.device, status.mode, + status.callerid, status.callid, + status.ax25_ui ? "on" : "off", + status.connected ? "yes" : "no", + status.stack); + max25_client_write(client, "DISCONNECT"); + max25_client_free(client); + return EXIT_SUCCESS; + } + + if (cfg.ax25_ui) { + max25_client_command(client, "SET AX25_UI on", reply, sizeof(reply)); + status.ax25_ui = 1; + } else { + max25_client_command(client, "SET AX25_UI off", reply, sizeof(reply)); + status.ax25_ui = 0; + } + + run_session(client, &cfg, &status); + max25_client_write(client, "DISCONNECT"); + max25_client_free(client); + return EXIT_SUCCESS; +} diff --git a/stacks/terminal/max25_proto.c b/stacks/terminal/max25_proto.c new file mode 100644 index 0000000..b4a9725 --- /dev/null +++ b/stacks/terminal/max25_proto.c @@ -0,0 +1,472 @@ +#define _POSIX_C_SOURCE 200809L + +#include "max25_proto.h" + +#if defined(__AMIGA__) || defined(__amigaos__) +#define MAX25_AMIGA 1 +#endif + +#include +#include +#include +#include +#include +#include +#include + +#ifndef MAX25_AMIGA +#include +#include +#include +#else +#include +#include +#include +#include +#endif + +struct max25_client { + int fd; +}; + +static int read_line_fd(int fd, char *line, size_t line_sz) +{ + size_t pos = 0; + char ch; + + while (pos + 1 < line_sz) { + ssize_t n = read(fd, &ch, 1); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + if (n == 0) { + return -1; + } + if (ch == '\n') { + line[pos] = '\0'; + return 0; + } + if (ch != '\r') { + line[pos++] = ch; + } + } + return -1; +} + +static int write_cmd(int fd, const char *cmd) +{ + char buf[MAX25_LINE_MAX + 2]; + int len = snprintf(buf, sizeof(buf), "%s\n", cmd); + ssize_t off = 0; + + if (len < 0 || (size_t)len >= sizeof(buf)) { + return -1; + } + while (off < len) { + ssize_t n = write(fd, buf + off, (size_t)len - (size_t)off); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + off += n; + } + return 0; +} + +static int connect_tcp(const char *host, unsigned port) +{ +#ifdef MAX25_AMIGA + struct hostent *he; + struct sockaddr_in addr; + int fd; + + he = gethostbyname(host); + if (he == NULL || he->h_addr_list[0] == NULL) { + return -1; + } + + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + return -1; + } + + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)port); + memcpy(&addr.sin_addr, he->h_addr_list[0], (size_t)he->h_length); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + return fd; +#else + char portbuf[16]; + struct addrinfo hints; + struct addrinfo *res = NULL; + struct addrinfo *ai; + int fd = -1; + + snprintf(portbuf, sizeof(portbuf), "%u", port); + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + if (getaddrinfo(host, portbuf, &hints, &res) != 0) { + return -1; + } + + for (ai = res; ai != NULL; ai = ai->ai_next) { + fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) { + continue; + } + if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) { + break; + } + close(fd); + fd = -1; + } + freeaddrinfo(res); + return fd; +#endif +} + +static int connect_unix(const char *path) +{ +#ifndef MAX25_AMIGA + struct sockaddr_un addr; + int fd; + + if (path == NULL || path[0] == '\0') { + return -1; + } + + fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + return -1; + } + + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + return fd; +#else + (void)path; + return -1; +#endif +} + +static int tcp_authenticate(int fd, const char *password) +{ + char line[MAX25_LINE_MAX]; + char cmd[MAX25_LINE_MAX + 8]; + + if (password == NULL || password[0] == '\0') { + return -1; + } + snprintf(cmd, sizeof(cmd), "AUTH %s", password); + if (write_cmd(fd, cmd) != 0) { + return -1; + } + if (read_line_fd(fd, line, sizeof(line)) != 0) { + return -1; + } + return strcmp(line, "OK") == 0 ? 0 : -1; +} + +static int handshake(int fd, const char *tcp_password, max25_status_t *initial_status) +{ + char line[MAX25_LINE_MAX]; + + if (read_line_fd(fd, line, sizeof(line)) != 0) { + return -1; + } + if (strcmp(line, "AUTH required") == 0) { + if (tcp_authenticate(fd, tcp_password) != 0) { + return -1; + } + if (read_line_fd(fd, line, sizeof(line)) != 0 || strcmp(line, "OK") != 0) { + return -1; + } + } else if (strcmp(line, "OK") != 0) { + return -1; + } + + if (read_line_fd(fd, line, sizeof(line)) != 0 || + strncmp(line, "STATUS ", 7) != 0) { + return -1; + } + + if (initial_status != NULL) { + max25_client_parse_status(line, initial_status); + } + return 0; +} + +int max25_client_connect(const char *host, unsigned port, const char *unix_path, + int tcp_only, const char *tcp_password, + max25_client_t **out, max25_status_t *initial_status) +{ + max25_client_t *client; + int fd = -1; + + if (out == NULL) { + return -1; + } + + if (!tcp_only && unix_path != NULL && unix_path[0] != '\0') { +#ifndef MAX25_AMIGA + fd = connect_unix(unix_path); +#endif + } + if (fd < 0 && host != NULL && host[0] != '\0') { + fd = connect_tcp(host, port); + } + if (fd < 0) { + return -1; + } + + client = calloc(1, sizeof(*client)); + if (client == NULL) { + close(fd); + return -1; + } + client->fd = fd; + + if (handshake(fd, tcp_password, initial_status) != 0) { + max25_client_free(client); + return -1; + } + + *out = client; + return 0; +} + +void max25_client_free(max25_client_t *client) +{ + if (client == NULL) { + return; + } + if (client->fd >= 0) { + close(client->fd); + } + free(client); +} + +int max25_client_fd(const max25_client_t *client) +{ + return client != NULL ? client->fd : -1; +} + +int max25_client_write(max25_client_t *client, const char *cmd) +{ + if (client == NULL || cmd == NULL) { + return -1; + } + return write_cmd(client->fd, cmd); +} + +max25_line_kind_t max25_client_classify_line(const char *line) +{ + if (line == NULL || line[0] == '\0') { + return MAX25_LINE_IGNORE; + } + if (strcmp(line, "OK") == 0) { + return MAX25_LINE_OK; + } + if (strncmp(line, "ERR ", 4) == 0) { + return MAX25_LINE_ERR; + } + if (strncmp(line, "STATUS ", 7) == 0) { + return MAX25_LINE_STATUS; + } + if (strncmp(line, "RX ", 3) == 0) { + return MAX25_LINE_RX; + } + if (strncmp(line, "EVENT ", 6) == 0) { + return MAX25_LINE_EVENT; + } + if (strncmp(line, "DEVICE ", 7) == 0) { + return MAX25_LINE_DEVICE; + } + return MAX25_LINE_OTHER; +} + +int max25_client_command(max25_client_t *client, const char *cmd, + char *reply, size_t reply_sz) +{ + char line[MAX25_LINE_MAX]; + + if (client == NULL || cmd == NULL) { + return -1; + } + if (write_cmd(client->fd, cmd) != 0) { + return -1; + } + for (;;) { + if (read_line_fd(client->fd, line, sizeof(line)) != 0) { + return -1; + } + if (strncmp(line, "STATUS ", 7) == 0) { + if (reply != NULL && reply_sz > 0) { + strncpy(reply, line, reply_sz - 1); + reply[reply_sz - 1] = '\0'; + } + continue; + } + if (strncmp(line, "RX ", 3) == 0) { + continue; + } + if (strncmp(line, "EVENT ", 6) == 0) { + continue; + } + if (strncmp(line, "DEVICE ", 7) == 0) { + continue; + } + /* GET STATUS replies: STATUS … then OK — keep STATUS in reply. */ + if (strcmp(line, "OK") == 0) { + if (reply != NULL && reply_sz > 0 && + strncmp(reply, "STATUS ", 7) != 0) { + strncpy(reply, line, reply_sz - 1); + reply[reply_sz - 1] = '\0'; + } + return 0; + } + if (reply != NULL && reply_sz > 0) { + strncpy(reply, line, reply_sz - 1); + reply[reply_sz - 1] = '\0'; + } + return -1; + } +} + +int max25_client_read_line(max25_client_t *client, char *line, size_t line_sz) +{ + if (client == NULL) { + return -1; + } + return read_line_fd(client->fd, line, line_sz); +} + +static void copy_field(const char *src, char *dst, size_t dst_sz) +{ + if (dst == NULL || dst_sz == 0) { + return; + } + if (src == NULL) { + dst[0] = '\0'; + return; + } + strncpy(dst, src, dst_sz - 1); + dst[dst_sz - 1] = '\0'; +} + +int max25_client_apply_event(const char *line, max25_status_t *status) +{ + if (line == NULL || status == NULL || strncmp(line, "EVENT ", 6) != 0) { + return 0; + } + if (strcmp(line, "EVENT connected") == 0) { + status->connected = 1; + return 1; + } + if (strcmp(line, "EVENT disconnected") == 0) { + status->connected = 0; + return 1; + } + return 0; +} + +int max25_client_parse_status(const char *line, max25_status_t *status) +{ + char buf[MAX25_LINE_MAX]; + char *save = NULL; + char *tok; + + if (line == NULL || status == NULL || strncmp(line, "STATUS ", 7) != 0) { + return -1; + } + + strncpy(buf, line + 7, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + + memset(status, 0, sizeof(*status)); + for (tok = strtok_r(buf, " ", &save); tok != NULL; + tok = strtok_r(NULL, " ", &save)) { + char *eq = strchr(tok, '='); + if (eq == NULL) { + continue; + } + *eq = '\0'; + if (strcmp(tok, "hardware") == 0) { + copy_field(eq + 1, status->hardware, sizeof(status->hardware)); + } else if (strcmp(tok, "device") == 0) { + copy_field(eq + 1, status->device, sizeof(status->device)); + } else if (strcmp(tok, "mode") == 0) { + copy_field(eq + 1, status->mode, sizeof(status->mode)); + } else if (strcmp(tok, "callerid") == 0) { + copy_field(eq + 1, status->callerid, sizeof(status->callerid)); + } else if (strcmp(tok, "callid") == 0) { + copy_field(eq + 1, status->callid, sizeof(status->callid)); + } else if (strcmp(tok, "ax25_ui") == 0) { + status->ax25_ui = (strcmp(eq + 1, "on") == 0); + } else if (strcmp(tok, "connected") == 0) { + status->connected = (strcmp(eq + 1, "yes") == 0); + } else if (strcmp(tok, "stack") == 0) { + copy_field(eq + 1, status->stack, sizeof(status->stack)); + } else if (strcmp(tok, "error") == 0) { + copy_field(eq + 1, status->error, sizeof(status->error)); + } else if (strcmp(tok, "voice") == 0) { + copy_field(eq + 1, status->voice, sizeof(status->voice)); + } + } + return 0; +} + +int max25_valid_callsign(const char *value) +{ + const char *dash; + size_t call_len; + unsigned long ssid; + char *end = NULL; + size_t i; + + if (value == NULL) { + return 0; + } + + dash = strchr(value, '-'); + if (dash != NULL) { + call_len = (size_t)(dash - value); + ssid = strtoul(dash + 1, &end, 10); + if (end == dash + 1 || ssid > 15) { + return 0; + } + } else { + call_len = strlen(value); + } + + if (call_len == 0 || call_len > 6) { + return 0; + } + + for (i = 0; i < call_len; i++) { + char c = (char)toupper((unsigned char)value[i]); + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) { + return 0; + } + } + + return 1; +} diff --git a/stacks/terminal/max25_proto.h b/stacks/terminal/max25_proto.h new file mode 100644 index 0000000..25ae5d5 --- /dev/null +++ b/stacks/terminal/max25_proto.h @@ -0,0 +1,55 @@ +#ifndef MAX25_PROTO_H +#define MAX25_PROTO_H + +#include + +#define MAX25_DEFAULT_PORT 7325 +#define MAX25_DEFAULT_UNIX "/run/max25/modem.sock" +#define MAX25_LINE_MAX 512 +#define MAX25_CALLSIGN_MAX 16 + +typedef struct max25_client max25_client_t; + +typedef struct max25_status { + char hardware[32]; + char device[32]; + char mode[32]; + char callerid[MAX25_CALLSIGN_MAX]; + char callid[MAX25_CALLSIGN_MAX]; + int ax25_ui; + int connected; + int monitor; + char stack[32]; + char error[16]; + char voice[16]; +} max25_status_t; + +typedef enum { + MAX25_LINE_IGNORE = 0, + MAX25_LINE_OK, + MAX25_LINE_ERR, + MAX25_LINE_STATUS, + MAX25_LINE_RX, + MAX25_LINE_EVENT, + MAX25_LINE_DEVICE, + MAX25_LINE_OTHER +} max25_line_kind_t; + +int max25_client_connect(const char *host, unsigned port, const char *unix_path, + int tcp_only, const char *tcp_password, + max25_client_t **out, max25_status_t *initial_status); +void max25_client_free(max25_client_t *client); +int max25_client_fd(const max25_client_t *client); + +int max25_client_write(max25_client_t *client, const char *cmd); +int max25_client_command(max25_client_t *client, const char *cmd, + char *reply, size_t reply_sz); +int max25_client_read_line(max25_client_t *client, char *line, size_t line_sz); +max25_line_kind_t max25_client_classify_line(const char *line); +int max25_client_parse_status(const char *line, max25_status_t *status); +/** Apply EVENT connected / EVENT disconnected to status. Returns 1 if handled. */ +int max25_client_apply_event(const char *line, max25_status_t *status); + +int max25_valid_callsign(const char *value); + +#endif /* MAX25_PROTO_H */ diff --git a/stacks/terminal/max25_terminal.c b/stacks/terminal/max25_terminal.c new file mode 100644 index 0000000..8b4eb52 --- /dev/null +++ b/stacks/terminal/max25_terminal.c @@ -0,0 +1,767 @@ +#define _POSIX_C_SOURCE 200809L + +#include "max25_proto.h" +#include "max25_ui.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef MAX25_HAVE_NCURSES +#include +#endif + +typedef struct { + char host[256]; + unsigned port; + char unix_path[256]; + char tcp_password[128]; + char default_device[32]; + int tcp_only; + int ax25_ui; + int verbose; + int probe; +} terminal_config_t; + +typedef struct { + int waiting_reply; + char last_err[MAX25_LINE_MAX]; +} session_io_t; + +static struct termios g_saved_tty; +static int g_tty_raw; + +static void config_defaults(terminal_config_t *cfg) +{ + const char *env; + + memset(cfg, 0, sizeof(*cfg)); + strncpy(cfg->host, "127.0.0.1", sizeof(cfg->host) - 1); + cfg->port = MAX25_DEFAULT_PORT; + strncpy(cfg->unix_path, MAX25_DEFAULT_UNIX, sizeof(cfg->unix_path) - 1); + cfg->ax25_ui = 1; + + env = getenv("MAX25_HOST"); + if (env != NULL && env[0] != '\0') { + strncpy(cfg->host, env, sizeof(cfg->host) - 1); + } + env = getenv("MAX25_PORT"); + if (env != NULL && env[0] != '\0') { + cfg->port = (unsigned)strtoul(env, NULL, 10); + } + env = getenv("MAX25_UNIX"); + if (env != NULL) { + strncpy(cfg->unix_path, env, sizeof(cfg->unix_path) - 1); + } + env = getenv("MAX25_TCP_PASSWORD"); + if (env != NULL) { + strncpy(cfg->tcp_password, env, sizeof(cfg->tcp_password) - 1); + } +} + +static void usage(const char *prog) +{ + fprintf(stderr, + "MAX25 Terminal — CLI operator client (max25d via TCP/M25/1)\n\n" + "Usage: %s [options] [host] [port]\n\n" + "Options:\n" + " -H, --host HOST max25d TCP host (default 127.0.0.1)\n" + " -p, --port PORT max25d TCP port (default %u)\n" + " -T, --tcp-only TCP only — do not try Unix socket\n" + " -U, --unix PATH Unix socket (default %s, before TCP)\n" + " --ax25-ui AX.25 UI mode (default on)\n" + " --no-ax25-ui Plain SEND lines\n" + " -P, --password PASS TCP auth password (plain-text M25/1 AUTH)\n" + " -d, --device ID SET DEVICE after connect (configured device id)\n" + " -v, --verbose Protocol log on stderr\n" + " --probe Connect, print STATUS, exit (CI)\n" + " -h, --help Show help\n\n" + "Remote example:\n" + " %s -T -H linux-host.local -p 7325\n\n" + "Environment: MAX25_HOST, MAX25_PORT, MAX25_UNIX, MAX25_TCP_PASSWORD\n" + "Symlink: max25-client -> max25-terminal\n\n", + prog, MAX25_DEFAULT_PORT, MAX25_DEFAULT_UNIX, prog); +} + +static int tty_enable_raw(void) +{ + struct termios tty; + + if (!isatty(STDIN_FILENO)) { + return 0; + } + if (tcgetattr(STDIN_FILENO, &g_saved_tty) != 0) { + return -1; + } + tty = g_saved_tty; + tty.c_lflag &= (tcflag_t)~(ICANON | ECHO); + tty.c_cc[VMIN] = 0; + tty.c_cc[VTIME] = 1; + if (tcsetattr(STDIN_FILENO, TCSANOW, &tty) != 0) { + return -1; + } + g_tty_raw = 1; + return 0; +} + +static void tty_restore(void) +{ + if (g_tty_raw) { + tcsetattr(STDIN_FILENO, TCSANOW, &g_saved_tty); + g_tty_raw = 0; + } +} + +static int begin_cmd(session_io_t *io) +{ + if (io == NULL) { + return -1; + } + io->waiting_reply = 1; + io->last_err[0] = '\0'; + return 0; +} + +static int send_cmd_async(max25_client_t *client, session_io_t *io, + const char *cmd) +{ + if (max25_client_write(client, cmd) != 0) { + return -1; + } + return begin_cmd(io); +} + +static int dispatch_daemon_line(const char *line, session_io_t *io, + max25_ui_t *ui, max25_status_t *status, + terminal_config_t *cfg, int *need_redraw) +{ + max25_line_kind_t kind = max25_client_classify_line(line); + + if (need_redraw != NULL) { + *need_redraw = 0; + } + + if (cfg->verbose) { + fprintf(stderr, "max25-terminal: << %s\n", line); + } + + if (kind == MAX25_LINE_RX) { + max25_ui_append_rx(ui, line + 3); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + + /* CONNECT/DISCONNECT emit EVENT before OK — update header even while waiting. */ + if (kind == MAX25_LINE_EVENT) { + if (max25_client_apply_event(line, status)) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + } else if (cfg->verbose) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + } + if (io->waiting_reply) { + return 0; + } + return 0; + } + + if (io->waiting_reply) { + if (kind == MAX25_LINE_STATUS && status != NULL) { + char summary[MAX25_LINE_MAX]; + + max25_client_parse_status(line, status); + /* Show STATUS in RX pane (F10→3 and other GET STATUS callers). */ + snprintf(summary, sizeof(summary), + "STATUS device=%s stack=%s connected=%s callerid=%s callid=%s", + status->device[0] != '\0' ? status->device : "-", + status->stack[0] != '\0' ? status->stack : "-", + status->connected ? "yes" : "no", + status->callerid[0] != '\0' ? status->callerid : "-", + status->callid[0] != '\0' ? status->callid : "-"); + max25_ui_append_rx(ui, summary); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + if (kind == MAX25_LINE_DEVICE) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + if (kind == MAX25_LINE_OK) { + io->waiting_reply = 0; + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + if (kind == MAX25_LINE_ERR) { + strncpy(io->last_err, line, sizeof(io->last_err) - 1); + io->last_err[sizeof(io->last_err) - 1] = '\0'; + io->waiting_reply = 0; + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return -1; + } + return 0; + } + + if (kind == MAX25_LINE_ERR) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + } + return 0; +} + + +static int send_callsign(max25_client_t *client, session_io_t *io, + const char *field, const char *value) +{ + char cmd[MAX25_LINE_MAX]; + + if (!max25_valid_callsign(value)) { + fprintf(stderr, "max25-terminal: invalid %s\n", field); + return -1; + } + snprintf(cmd, sizeof(cmd), "SET %s %s", field, value); + return send_cmd_async(client, io, cmd); +} + +static int send_line_async(max25_client_t *client, session_io_t *io, + const char *line) +{ + char cmd[MAX25_LINE_MAX + 8]; + size_t max_payload = sizeof(cmd) - 6; + + if (strlen(line) > max_payload) { + return -1; + } + snprintf(cmd, sizeof(cmd), "SEND %s", line); + return send_cmd_async(client, io, cmd); +} + +static int refresh_status(max25_client_t *client, max25_status_t *status) +{ + char reply[MAX25_LINE_MAX]; + + if (max25_client_command(client, "GET STATUS", reply, sizeof(reply)) != 0) { + return -1; + } + return max25_client_parse_status(reply, status); +} + +static int set_device_sync(max25_client_t *client, max25_status_t *status, + const char *device_id) +{ + char cmd[MAX25_LINE_MAX + 16]; + char reply[MAX25_LINE_MAX]; + + if (device_id == NULL || device_id[0] == '\0') { + return -1; + } + snprintf(cmd, sizeof(cmd), "SET DEVICE %s", device_id); + if (max25_client_command(client, cmd, reply, sizeof(reply)) != 0) { + return -1; + } + return refresh_status(client, status); +} + +static int fetch_devices(max25_client_t *client, max25_ui_t *ui) +{ + char line[MAX25_LINE_MAX]; + + if (max25_client_write(client, "GET DEVICES") != 0) { + return -1; + } + max25_ui_append_rx(ui, "-- max25d devices --"); + for (;;) { + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + return -1; + } + if (strcmp(line, "OK") == 0) { + break; + } + if (strncmp(line, "DEVICE ", 7) == 0) { + max25_ui_append_rx(ui, line); + continue; + } + if (strncmp(line, "ERR ", 4) == 0) { + max25_ui_append_rx(ui, line); + return -1; + } + } + return 0; +} + +static int menu_action_needs_prompt(max25_menu_action_t action) +{ + return action == MAX25_MENU_CALLERID || action == MAX25_MENU_CALLID || + action == MAX25_MENU_SEND || action == MAX25_MENU_DEVICE; +} + +/* Returns 1 to quit session, 0 otherwise. Hides menu before action. */ +static int apply_menu_pick(max25_menu_action_t action, max25_client_t *client, + session_io_t *io, max25_ui_t *ui, + max25_status_t *status, const char *input_line) +{ + char buf[MAX25_LINE_MAX]; + char cmd[64]; + + if (action == MAX25_MENU_NONE) { + return 0; + } + if (action == MAX25_MENU_QUIT) { + return 1; + } + + /* Leave overlay so prompts / STATUS RX / header redraw are visible. */ + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + + switch (action) { + case MAX25_MENU_CALLERID: + if (max25_ui_prompt(ui, "CALLERID", buf, sizeof(buf)) > 0) { + send_callsign(client, io, "CALLERID", buf); + } + break; + case MAX25_MENU_CALLID: + if (max25_ui_prompt(ui, "CALLID", buf, sizeof(buf)) > 0) { + send_callsign(client, io, "CALLID", buf); + } + break; + case MAX25_MENU_STATUS: + send_cmd_async(client, io, "GET STATUS"); + break; + case MAX25_MENU_SEND: + if (max25_ui_prompt(ui, "Send", buf, sizeof(buf)) > 0) { + send_line_async(client, io, buf); + } + break; + case MAX25_MENU_MONITOR: + snprintf(cmd, sizeof(cmd), "MONITOR %s", + status->monitor ? "off" : "on"); + status->monitor = !status->monitor; + send_cmd_async(client, io, cmd); + max25_ui_append_rx(ui, status->monitor ? "MONITOR on" : "MONITOR off"); + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + break; + case MAX25_MENU_CONNECT: + send_cmd_async(client, io, + status->connected ? "DISCONNECT" : "CONNECT"); + break; + case MAX25_MENU_DEVICE: + (void)fetch_devices(client, ui); + if (max25_ui_prompt(ui, "DEVICE id", buf, sizeof(buf)) > 0) { + if (set_device_sync(client, status, buf) != 0) { + max25_ui_append_rx(ui, "ERR SET DEVICE failed"); + } + max25_ui_draw_screen(ui, status, + input_line != NULL ? input_line : ""); + } + break; + case MAX25_MENU_DEVICES: + (void)fetch_devices(client, ui); + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + break; + default: + break; + } + + if (!menu_action_needs_prompt(action) && action != MAX25_MENU_MONITOR) { + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + } + return 0; +} + +#ifdef MAX25_HAVE_NCURSES +static int read_key_ncurses(void) +{ + int ch = getch(); + + if (ch == KEY_F(10)) { + return 999; + } + return ch; +} +#endif + +static int read_plain_key(void) +{ + unsigned char buf[8]; + ssize_t n; + + n = read(STDIN_FILENO, buf, sizeof(buf)); + if (n <= 0) { + if (n < 0 && errno == EAGAIN) { + return -1; + } + return 0; + } + + if (buf[0] == 0x1b && n >= 3 && buf[1] == '[') { + if (n >= 4 && buf[2] == '2' && buf[3] == '1' && (n < 5 || buf[4] == '~')) { + return 999; + } + } + + return (int)buf[0]; +} + +static int run_session(max25_client_t *client, max25_ui_t *ui, + terminal_config_t *cfg, max25_status_t *status) +{ + struct pollfd pfds[2]; + session_io_t io; + char line[MAX25_LINE_MAX]; + char input[MAX25_UI_INPUT_MAX]; + size_t input_len = 0; + int running = 1; + int use_ncurses = max25_ui_has_ncurses(ui); + int use_plain_raw = 0; + + memset(&io, 0, sizeof(io)); + input[0] = '\0'; + + if (!use_ncurses) { + max25_ui_apply_palette(); + } + max25_ui_reset_rx(ui); + max25_ui_draw_screen(ui, status, input); + + /* Arm TX session before accepting Enter/F10 SEND (daemon also auto-arms + * on SEND; sync CONNECT here so header connected=yes before first key). */ + if (!status->connected) { + char creply[MAX25_LINE_MAX]; + + if (max25_client_command(client, "CONNECT", creply, sizeof(creply)) == 0) { + status->connected = 1; + max25_ui_append_rx(ui, "EVENT connected"); + } else { + max25_ui_append_rx(ui, creply[0] ? creply : "ERR CONNECT failed"); + } + max25_ui_draw_screen(ui, status, input); + } + + (void)fetch_devices(client, ui); + max25_ui_draw_screen(ui, status, input); + + if (!use_ncurses && isatty(STDIN_FILENO)) { + use_plain_raw = (tty_enable_raw() == 0); + } + + while (running) { + if (!io.waiting_reply) { + max25_ui_draw_screen(ui, status, input); + } + + pfds[0].fd = STDIN_FILENO; + pfds[0].events = POLLIN; + pfds[0].revents = 0; + pfds[1].fd = max25_client_fd(client); + pfds[1].events = POLLIN; + pfds[1].revents = 0; + + if (poll(pfds, 2, 200) < 0) { + if (errno == EINTR) { + continue; + } + break; + } + + if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { + max25_ui_append_rx(ui, "max25d connection lost"); + break; + } + + if ((pfds[1].revents & POLLIN) != 0) { + int need_redraw = 0; + + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + max25_ui_append_rx(ui, "max25d connection closed"); + break; + } + dispatch_daemon_line(line, &io, ui, status, cfg, &need_redraw); + if (io.last_err[0] != '\0' && cfg->verbose) { + fprintf(stderr, "max25-terminal: %s\n", io.last_err); + } + if (need_redraw && !max25_ui_menu_visible(ui)) { + max25_ui_draw_screen(ui, status, input); + } + continue; + } + + if ((pfds[0].revents & POLLIN) == 0) { + continue; + } + +#ifdef MAX25_HAVE_NCURSES + if (use_ncurses) { + int ch = read_key_ncurses(); + if (ch == -1) { + continue; + } + if (ch == 999) { + if (max25_ui_menu_visible(ui)) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + } else { + max25_ui_show_menu(ui, status); + } + continue; + } + if (max25_ui_menu_visible(ui)) { + if (ch == 27) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + continue; + } + if (apply_menu_pick(max25_ui_menu_pick(ui, ch), client, &io, ui, + status, input) != 0) { + running = 0; + } + continue; + } + if (ch >= 32 && ch < 127) { + if (input_len + 1 < sizeof(input)) { + input[input_len++] = (char)ch; + input[input_len] = '\0'; + } + } else if (ch == '\n' || ch == KEY_ENTER || ch == '\r') { + if (input_len > 0) { + send_line_async(client, &io, input); + input_len = 0; + input[0] = '\0'; + } + } else if (ch == KEY_BACKSPACE || ch == 127 || ch == '\b') { + if (input_len > 0) { + input[--input_len] = '\0'; + } + } + continue; + } +#endif + + if (use_plain_raw) { + int ch = read_plain_key(); + if (ch <= 0) { + continue; + } + if (ch == 999) { + if (max25_ui_menu_visible(ui)) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + } else { + max25_ui_show_menu(ui, status); + } + continue; + } + if (max25_ui_menu_visible(ui)) { + if (ch == 27) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + continue; + } + if (apply_menu_pick(max25_ui_menu_pick(ui, ch), client, &io, ui, + status, input) != 0) { + running = 0; + } + continue; + } + if (ch >= 32 && ch < 127) { + if (input_len + 1 < sizeof(input)) { + input[input_len++] = (char)ch; + input[input_len] = '\0'; + } + } else if (ch == '\n' || ch == '\r') { + if (input_len > 0) { + send_line_async(client, &io, input); + input_len = 0; + input[0] = '\0'; + } + } else if (ch == 127 || ch == '\b') { + if (input_len > 0) { + input[--input_len] = '\0'; + } + } + continue; + } + + if (fgets(input, sizeof(input), stdin) == NULL) { + running = 0; + } else { + input[strcspn(input, "\r\n")] = '\0'; + if (max25_ui_menu_visible(ui)) { + if (apply_menu_pick(max25_ui_menu_pick(ui, input[0]), client, + &io, ui, status, "") != 0) { + running = 0; + } + } else if (input[0] != '\0') { + send_line_async(client, &io, input); + } + input[0] = '\0'; + input_len = 0; + } + } + + tty_restore(); + return 0; +} + +int main(int argc, char *argv[]) +{ + terminal_config_t cfg; + max25_client_t *client = NULL; + max25_ui_t *ui = NULL; + max25_status_t status; + char reply[MAX25_LINE_MAX]; + int opt; + int argi; + static const struct option long_opts[] = { + {"host", required_argument, NULL, 'H'}, + {"port", required_argument, NULL, 'p'}, + {"unix", required_argument, NULL, 'U'}, + {"tcp-only", no_argument, NULL, 'T'}, + {"ax25-ui", no_argument, NULL, 1000}, + {"no-ax25-ui", no_argument, NULL, 1001}, + {"password", required_argument, NULL, 'P'}, + {"device", required_argument, NULL, 'd'}, + {"verbose", no_argument, NULL, 'v'}, + {"probe", no_argument, NULL, 1002}, + {"help", no_argument, NULL, 'h'}, + {NULL, 0, NULL, 0} + }; + + config_defaults(&cfg); + + while ((opt = getopt_long(argc, argv, "H:p:TU:P:d:v:h", long_opts, NULL)) != -1) { + switch (opt) { + case 'H': + strncpy(cfg.host, optarg, sizeof(cfg.host) - 1); + break; + case 'p': + cfg.port = (unsigned)strtoul(optarg, NULL, 10); + break; + case 'T': + cfg.tcp_only = 1; + break; + case 'U': + strncpy(cfg.unix_path, optarg, sizeof(cfg.unix_path) - 1); + break; + case 'P': + strncpy(cfg.tcp_password, optarg, sizeof(cfg.tcp_password) - 1); + break; + case 'd': + strncpy(cfg.default_device, optarg, sizeof(cfg.default_device) - 1); + break; + case 'v': + cfg.verbose = 1; + break; + case 1002: + cfg.probe = 1; + cfg.tcp_only = 1; + break; + case 'h': + usage(argv[0]); + return EXIT_SUCCESS; + case 1000: + cfg.ax25_ui = 1; + break; + case 1001: + cfg.ax25_ui = 0; + break; + default: + usage(argv[0]); + return EXIT_FAILURE; + } + } + + argi = optind; + if (argi < argc) { + strncpy(cfg.host, argv[argi++], sizeof(cfg.host) - 1); + } + if (argi < argc) { + cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10); + } + + if (max25_client_connect(cfg.host, cfg.port, + cfg.tcp_only ? "" : cfg.unix_path, + cfg.tcp_only, cfg.tcp_password, + &client, &status) != 0) { + fprintf(stderr, "max25-terminal: connect failed"); + if (cfg.tcp_only) { + fprintf(stderr, " (tcp %s:%u)", cfg.host, cfg.port); + } else { + fprintf(stderr, " (unix %s / tcp %s:%u)", + cfg.unix_path, cfg.host, cfg.port); + } + fputc('\n', stderr); + return EXIT_FAILURE; + } + + if (cfg.probe) { + printf("STATUS hardware=%s device=%s mode=%s callerid=%s callid=%s " + "ax25_ui=%s connected=%s stack=%s\n", + status.hardware, status.device, status.mode, + status.callerid, status.callid, + status.ax25_ui ? "on" : "off", + status.connected ? "yes" : "no", + status.stack); + max25_client_write(client, "DISCONNECT"); + max25_client_free(client); + return EXIT_SUCCESS; + } + + if (max25_ui_init(&ui) != 0) { + max25_client_free(client); + return EXIT_FAILURE; + } + + if (cfg.default_device[0] != '\0') { + if (set_device_sync(client, &status, cfg.default_device) != 0) { + fprintf(stderr, "max25-terminal: SET DEVICE %s failed\n", + cfg.default_device); + max25_client_free(client); + return EXIT_FAILURE; + } + } + + if (cfg.ax25_ui) { + max25_client_command(client, "SET AX25_UI on", reply, sizeof(reply)); + status.ax25_ui = 1; + } else { + max25_client_command(client, "SET AX25_UI off", reply, sizeof(reply)); + status.ax25_ui = 0; + } + + if (cfg.verbose) { + fprintf(stderr, "max25-terminal: connected %s:%u tcp_only=%s ax25-ui=%s\n", + cfg.host, cfg.port, cfg.tcp_only ? "yes" : "no", + status.ax25_ui ? "on" : "off"); + } + + run_session(client, ui, &cfg, &status); + + max25_client_write(client, "DISCONNECT"); + max25_ui_shutdown(ui); + max25_client_free(client); + return EXIT_SUCCESS; +} diff --git a/stacks/terminal/max25_ui.c b/stacks/terminal/max25_ui.c new file mode 100644 index 0000000..91cc977 --- /dev/null +++ b/stacks/terminal/max25_ui.c @@ -0,0 +1,407 @@ +#include "max25_ui.h" + +#include +#include +#include +#include + +#ifdef MAX25_HAVE_NCURSES +#include +#endif + +struct max25_ui { + int menu_open; + char rx_lines[MAX25_UI_RX_LINES][MAX25_LINE_MAX]; + int rx_count; + int rx_head; +#ifdef MAX25_HAVE_NCURSES + int ncurses_on; +#endif +}; + +#ifdef MAX25_HAVE_NCURSES +static int term_ok_for_ncurses(void) +{ + const char *term = getenv("TERM"); + + if (term == NULL || term[0] == '\0') { + return 0; + } + if (strcmp(term, "dumb") == 0) { + return 0; + } + if (strncmp(term, "unknown", 7) == 0) { + return 0; + } + return 1; +} +#endif + +int max25_ui_init(max25_ui_t **ui) +{ + max25_ui_t *u; + + if (ui == NULL) { + return -1; + } + + u = calloc(1, sizeof(*u)); + if (u == NULL) { + return -1; + } + +#ifdef MAX25_HAVE_NCURSES + if (isatty(STDOUT_FILENO) && isatty(STDIN_FILENO) && term_ok_for_ncurses() + && initscr() != NULL) { + cbreak(); + noecho(); + keypad(stdscr, TRUE); + nodelay(stdscr, TRUE); + if (has_colors()) { + start_color(); + init_pair(1, COLOR_WHITE, COLOR_BLACK); + } + bkgd(COLOR_PAIR(1)); + curs_set(0); + clear(); + refresh(); + u->ncurses_on = 1; + } +#endif + + *ui = u; + return 0; +} + +void max25_ui_shutdown(max25_ui_t *ui) +{ + if (ui == NULL) { + return; + } +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on) { + endwin(); + } +#endif + free(ui); +} + +void max25_ui_apply_palette(void) +{ + fputs(MAX25_TERM_SGR, stdout); + fflush(stdout); +} + +void max25_ui_clear_screen(void) +{ + fputs(MAX25_TERM_CLEAR, stdout); + fputs(MAX25_TERM_SGR, stdout); + fflush(stdout); +} + +int max25_ui_has_ncurses(const max25_ui_t *ui) +{ +#ifdef MAX25_HAVE_NCURSES + return ui != NULL && ui->ncurses_on; +#else + (void)ui; + return 0; +#endif +} + +void max25_ui_reset_rx(max25_ui_t *ui) +{ + if (ui == NULL) { + return; + } + ui->rx_count = 0; + ui->rx_head = 0; + memset(ui->rx_lines, 0, sizeof(ui->rx_lines)); +} + +void max25_ui_append_rx(max25_ui_t *ui, const char *text) +{ + int slot; + + if (ui == NULL || text == NULL) { + return; + } + + slot = ui->rx_head; + strncpy(ui->rx_lines[slot], text, MAX25_LINE_MAX - 1); + ui->rx_lines[slot][MAX25_LINE_MAX - 1] = '\0'; + ui->rx_head = (ui->rx_head + 1) % MAX25_UI_RX_LINES; + if (ui->rx_count < MAX25_UI_RX_LINES) { + ui->rx_count++; + } +} + +static void draw_header_plain(const max25_status_t *status) +{ + printf("%sMAX25 Terminal DEVICE: %-12s CALLERID: %-9s CALLID: %-9s ax25-ui: %s connected: %s F10=Menu\n", + MAX25_TERM_SGR, + status->device[0] != '\0' ? status->device : "-", + status->callerid, status->callid, + status->ax25_ui ? "on" : "off", + status->connected ? "yes" : "no"); + fflush(stdout); +} + +static void draw_rx_plain(const max25_ui_t *ui) +{ + int i; + int start; + int idx; + + if (ui == NULL) { + return; + } + + start = ui->rx_count < MAX25_UI_RX_LINES ? 0 : ui->rx_head; + for (i = 0; i < ui->rx_count; i++) { + idx = (start + i) % MAX25_UI_RX_LINES; + printf("%s%s\n", MAX25_TERM_SGR, ui->rx_lines[idx]); + } + fflush(stdout); +} + +void max25_ui_draw_screen(max25_ui_t *ui, const max25_status_t *status, + const char *input_line) +{ + int i; + int start; + int idx; + int rows; + int y; + + if (ui == NULL || status == NULL) { + return; + } + +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on && !ui->menu_open) { + getmaxyx(stdscr, rows, y); + (void)y; + if (rows < 4) { + rows = 24; + } + + attron(COLOR_PAIR(1)); + mvprintw(0, 0, + "MAX25 Terminal DEVICE: %-12s CALLERID: %-9s CALLID: %-9s ax25-ui: %s connected: %s F10=Menu", + status->device[0] != '\0' ? status->device : "-", + status->callerid, status->callid, + status->ax25_ui ? "on" : "off", + status->connected ? "yes" : "no"); + clrtoeol(); + + start = ui->rx_count < MAX25_UI_RX_LINES ? 0 : ui->rx_head; + for (i = 0; i < ui->rx_count && i + 2 < rows - 2; i++) { + idx = (start + i) % MAX25_UI_RX_LINES; + mvprintw(i + 2, 0, "%.*s", (int)(rows > 0 ? 200 : 80), ui->rx_lines[idx]); + clrtoeol(); + } + + mvprintw(rows - 2, 0, "%s", "--------------------------------------------------------"); + clrtoeol(); + mvprintw(rows - 1, 0, "> %s", input_line != NULL ? input_line : ""); + clrtoeol(); + attroff(COLOR_PAIR(1)); + refresh(); + return; + } +#endif + + if (!ui->menu_open) { + fputs(MAX25_TERM_CLEAR, stdout); + fputs(MAX25_TERM_SGR, stdout); + draw_header_plain(status); + draw_rx_plain(ui); + printf("%s> %s\n", MAX25_TERM_SGR, input_line != NULL ? input_line : ""); + fflush(stdout); + } +} + +static void draw_menu_plain(const max25_status_t *status) +{ + puts(""); + puts("+- MAX25 Terminal ---------------------+"); + printf("| DEVICE: %-12s |\n", + status->device[0] != '\0' ? status->device : "-"); + printf("| CALLERID: %-9s CALLID: %-9s |\n", + status->callerid, status->callid); + puts("+---------------------------------------+"); + puts("| 1 Change CALLERID (live) |"); + puts("| 2 Change CALLID (live) |"); + puts("| 3 Status |"); + puts("| 4 Send line |"); + puts("| 5 RX only (Monitor) |"); + puts("| 6 Connection on/off |"); + puts("| 7 Change DEVICE (TX target) |"); + puts("| 8 List DEVICES (max25d) |"); + puts("| 0 Quit client |"); + puts("+---------------------------------------+"); + puts("Pick a number - F10 closes"); + fflush(stdout); +} + +#ifdef MAX25_HAVE_NCURSES +static void draw_menu_ncurses(const max25_status_t *status, int y0) +{ + attron(COLOR_PAIR(1)); + mvprintw(y0, 0, "+- MAX25 Terminal ---------------------+"); + mvprintw(y0 + 1, 0, "| DEVICE: %-12s |", + status->device[0] != '\0' ? status->device : "-"); + mvprintw(y0 + 2, 0, "| CALLERID: %-9s CALLID: %-9s |", + status->callerid, status->callid); + mvprintw(y0 + 3, 0, "+---------------------------------------+"); + mvprintw(y0 + 4, 0, "| 1 Change CALLERID (live) |"); + mvprintw(y0 + 5, 0, "| 2 Change CALLID (live) |"); + mvprintw(y0 + 6, 0, "| 3 Status |"); + mvprintw(y0 + 7, 0, "| 4 Send line |"); + mvprintw(y0 + 8, 0, "| 5 RX only (Monitor) |"); + mvprintw(y0 + 9, 0, "| 6 Connection on/off |"); + mvprintw(y0 + 10, 0, "| 7 Change DEVICE (TX target) |"); + mvprintw(y0 + 11, 0, "| 8 List DEVICES (max25d) |"); + mvprintw(y0 + 12, 0, "| 0 Quit client |"); + mvprintw(y0 + 13, 0, "+---------------------------------------+"); + mvprintw(y0 + 14, 0, "Pick a number - F10 closes"); + attroff(COLOR_PAIR(1)); +} +#endif + +void max25_ui_show_menu(max25_ui_t *ui, const max25_status_t *status) +{ + if (ui == NULL || status == NULL) { + return; + } + ui->menu_open = 1; + +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on) { + clear(); + bkgd(COLOR_PAIR(1)); + draw_menu_ncurses(status, 2); + refresh(); + return; + } +#endif + fputs(MAX25_TERM_CLEAR, stdout); + fputs(MAX25_TERM_SGR, stdout); + draw_menu_plain(status); +} + +void max25_ui_hide_menu(max25_ui_t *ui) +{ + if (ui == NULL) { + return; + } + ui->menu_open = 0; +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on) { + clear(); + bkgd(COLOR_PAIR(1)); + refresh(); + } +#endif +} + +int max25_ui_menu_visible(const max25_ui_t *ui) +{ + return ui != NULL && ui->menu_open; +} + +max25_menu_action_t max25_ui_menu_pick(max25_ui_t *ui, int ch) +{ + (void)ui; + switch (ch) { + case '1': + return MAX25_MENU_CALLERID; + case '2': + return MAX25_MENU_CALLID; + case '3': + return MAX25_MENU_STATUS; + case '4': + return MAX25_MENU_SEND; + case '5': + return MAX25_MENU_MONITOR; + case '6': + return MAX25_MENU_CONNECT; + case '7': + return MAX25_MENU_DEVICE; + case '8': + return MAX25_MENU_DEVICES; + case '0': + return MAX25_MENU_QUIT; + default: + return MAX25_MENU_NONE; + } +} + +int max25_ui_prompt(max25_ui_t *ui, const char *label, char *buf, size_t buf_sz) +{ + if (label == NULL || buf == NULL || buf_sz == 0) { + return -1; + } + +#ifdef MAX25_HAVE_NCURSES + if (ui != NULL && ui->ncurses_on) { + int ch; + size_t len = 0; + int rows; + + getmaxyx(stdscr, rows, ch); + (void)rows; + echo(); + curs_set(1); + attron(COLOR_PAIR(1)); + mvprintw(14, 0, "%s: ", label); + clrtoeol(); + refresh(); + buf[0] = '\0'; + + nodelay(stdscr, FALSE); + while (len + 1 < buf_sz) { + ch = getch(); + if (ch == '\n' || ch == KEY_ENTER || ch == '\r') { + break; + } + if (ch == 27) { + nodelay(stdscr, TRUE); + noecho(); + curs_set(0); + return -1; + } + if (ch == KEY_BACKSPACE || ch == 127 || ch == '\b') { + if (len > 0) { + len--; + buf[len] = '\0'; + mvprintw(14, (int)strlen(label) + 2, "%s ", buf); + clrtoeol(); + refresh(); + } + continue; + } + if (ch >= 32 && ch < 127) { + buf[len++] = (char)ch; + buf[len] = '\0'; + mvprintw(14, (int)strlen(label) + 2, "%s", buf); + refresh(); + } + } + nodelay(stdscr, TRUE); + noecho(); + curs_set(0); + attroff(COLOR_PAIR(1)); + return (int)len; + } +#endif + + printf("%s%s: ", MAX25_TERM_SGR, label); + fflush(stdout); + if (fgets(buf, (int)buf_sz, stdin) == NULL) { + return -1; + } + buf[strcspn(buf, "\r\n")] = '\0'; + return (int)strlen(buf); +} diff --git a/stacks/terminal/max25_ui.h b/stacks/terminal/max25_ui.h new file mode 100644 index 0000000..9c1928d --- /dev/null +++ b/stacks/terminal/max25_ui.h @@ -0,0 +1,45 @@ +#ifndef MAX25_UI_H +#define MAX25_UI_H + +#include "max25_proto.h" + +#define MAX25_TERM_SGR "\033[37;40m" +#define MAX25_TERM_CLEAR "\033[2J\033[H" +#define MAX25_UI_RX_LINES 12 +#define MAX25_UI_INPUT_MAX 256 + +typedef struct max25_ui max25_ui_t; + +typedef enum { + MAX25_MENU_NONE = 0, + MAX25_MENU_CALLERID, + MAX25_MENU_CALLID, + MAX25_MENU_STATUS, + MAX25_MENU_SEND, + MAX25_MENU_MONITOR, + MAX25_MENU_CONNECT, + MAX25_MENU_DEVICE, + MAX25_MENU_DEVICES, + MAX25_MENU_QUIT +} max25_menu_action_t; + +int max25_ui_init(max25_ui_t **ui); +void max25_ui_shutdown(max25_ui_t *ui); + +void max25_ui_apply_palette(void); +void max25_ui_clear_screen(void); + +int max25_ui_has_ncurses(const max25_ui_t *ui); + +void max25_ui_reset_rx(max25_ui_t *ui); +void max25_ui_append_rx(max25_ui_t *ui, const char *text); +void max25_ui_draw_screen(max25_ui_t *ui, const max25_status_t *status, + const char *input_line); +void max25_ui_show_menu(max25_ui_t *ui, const max25_status_t *status); +void max25_ui_hide_menu(max25_ui_t *ui); +int max25_ui_menu_visible(const max25_ui_t *ui); + +max25_menu_action_t max25_ui_menu_pick(max25_ui_t *ui, int ch); +int max25_ui_prompt(max25_ui_t *ui, const char *label, char *buf, size_t buf_sz); + +#endif /* MAX25_UI_H */ diff --git a/stacks/terminal/test-terminal.sh b/stacks/terminal/test-terminal.sh new file mode 100755 index 0000000..6a510f3 --- /dev/null +++ b/stacks/terminal/test-terminal.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# max25-terminal offline probe — binary + TCP connect test. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "${ROOT}/../.." && pwd)" +TERM="${MAX25_TERMINAL:-${REPO}/build/bin/max25-terminal}" + +[[ -x "${TERM}" ]] || { echo "FAIL: ${TERM} missing" >&2; exit 1; } + +CLIENT="${REPO}/build/bin/max25-client" +if [[ -L "${CLIENT}" ]] || [[ -x "${CLIENT}" ]]; then + : +else + echo "WARN: max25-client symlink missing until cmake --install" >&2 +fi + +"${TERM}" --help >/dev/null + +# Unit: EVENT connected/disconnected updates status (header bug regression) +UNIT="${ROOT}/.test_apply_event" +gcc -std=c11 -Wall -Wextra -O2 -I"${ROOT}" \ + -o "${UNIT}" "${ROOT}/test_apply_event.c" "${ROOT}/max25_proto.c" +"${UNIT}" +rm -f "${UNIT}" + +MAX25_TERMINAL="${TERM}" python3 "${ROOT}/test_connect.py" +echo "OK: max25-terminal" diff --git a/stacks/terminal/test_apply_event.c b/stacks/terminal/test_apply_event.c new file mode 100644 index 0000000..36e10f0 --- /dev/null +++ b/stacks/terminal/test_apply_event.c @@ -0,0 +1,30 @@ +/* Offline unit test: EVENT connected/disconnected → status.connected */ +#include "max25_proto.h" + +#include +#include + +int main(void) +{ + max25_status_t st; + + memset(&st, 0, sizeof(st)); + if (max25_client_apply_event("EVENT connected", &st) != 1 || !st.connected) { + fprintf(stderr, "FAIL: EVENT connected\n"); + return 1; + } + if (max25_client_apply_event("EVENT disconnected", &st) != 1 || st.connected) { + fprintf(stderr, "FAIL: EVENT disconnected\n"); + return 1; + } + if (max25_client_apply_event("EVENT device=max25e0 serial=ready", &st) != 0) { + fprintf(stderr, "FAIL: unrelated EVENT should be ignored\n"); + return 1; + } + if (max25_client_apply_event("OK", &st) != 0) { + fprintf(stderr, "FAIL: non-EVENT\n"); + return 1; + } + puts("OK: apply_event"); + return 0; +} diff --git a/stacks/terminal/test_connect.py b/stacks/terminal/test_connect.py new file mode 100644 index 0000000..6496b69 --- /dev/null +++ b/stacks/terminal/test_connect.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""TCP connect smoke test for max25-terminal --probe.""" +import socket +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DAEMON = ROOT / "stacks/daemon/max25d" +INI = ROOT / "share/max25/max25d.ini.example" + + +def terminal_binary() -> Path: + import os + + env = os.environ.get("MAX25_TERMINAL") + if env: + return Path(env) + for candidate in ( + ROOT / "build/bin/max25-terminal", + ROOT / "stacks/terminal/max25-terminal", + ): + if candidate.is_file(): + return candidate + return ROOT / "build/bin/max25-terminal" + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def main() -> int: + terminal = terminal_binary() + if not terminal.is_file(): + print( + f"FAIL: max25-terminal missing — run cmake --build build --target max25-terminal " + f"(looked for {terminal})", + file=sys.stderr, + ) + return 1 + + port = free_port() + proc = subprocess.Popen( + [str(DAEMON), "--no-stack", "-c", str(INI), "--tcp-port", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(0.6) + try: + out = subprocess.check_output( + [str(terminal), "-T", "-H", "127.0.0.1", "-p", str(port), "--probe"], + text=True, + timeout=10, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + proc.terminate() + proc.wait(timeout=5) + print(f"FAIL: terminal probe: {exc}", file=sys.stderr) + return 1 + finally: + proc.terminate() + proc.wait(timeout=5) + + if "STATUS hardware=" not in out or "callerid=" not in out: + print(f"FAIL: unexpected probe output: {out!r}", file=sys.stderr) + return 1 + + print("OK: max25-terminal TCP probe") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.3.1