summaryrefslogtreecommitdiff
path: root/stacks/terminal
diff options
context:
space:
mode:
Diffstat (limited to 'stacks/terminal')
-rw-r--r--stacks/terminal/.gitignore9
-rw-r--r--stacks/terminal/CMakeLists.txt40
-rw-r--r--stacks/terminal/README.md31
-rw-r--r--stacks/terminal/amiga/Makefile32
-rw-r--r--stacks/terminal/amiga/max25_terminal_amiga.c309
-rw-r--r--stacks/terminal/max25_proto.c472
-rw-r--r--stacks/terminal/max25_proto.h55
-rw-r--r--stacks/terminal/max25_terminal.c767
-rw-r--r--stacks/terminal/max25_ui.c407
-rw-r--r--stacks/terminal/max25_ui.h45
-rwxr-xr-xstacks/terminal/test-terminal.sh28
-rw-r--r--stacks/terminal/test_apply_event.c30
-rw-r--r--stacks/terminal/test_connect.py78
13 files changed, 2303 insertions, 0 deletions
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 <id>` / `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 <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/select.h>
+
+#ifdef __AMIGA__
+#include <sys/socket.h>
+#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 <ctype.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdint.h>
+#include <unistd.h>
+
+#ifndef MAX25_AMIGA
+#include <netdb.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+#else
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <netdb.h>
+#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 <stddef.h>
+
+#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 <errno.h>
+#include <getopt.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+
+#ifdef MAX25_HAVE_NCURSES
+#include <ncurses.h>
+#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 <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#ifdef MAX25_HAVE_NCURSES
+#include <ncurses.h>
+#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 <stdio.h>
+#include <string.h>
+
+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())
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com