/* termtcp_gui.cpp — FLTK GUI for TermTCP * BPQTermTCP protocol client for BPQ32 packet radio nodes. * Compile: see Makefile target 'gui' */ #include #include #include #include #include #include #include #include #include #include #include #include #include extern "C" { #include #include #include #include #include #include #include #include #include } #define GUI_VERSION "0.0.60" #define MAX_HOSTS 10 #define RECV_SZ 4096 #define MON_FBUF_SZ 4096 /* ── Config ──────────────────────────────────────────────── */ struct HostEntry { char host[100]; char port[10]; char username[80]; char password[80]; char name[50]; }; static HostEntry hosts[MAX_HOSTS]; static int num_hosts = 0; static char cfg_path[512]; static int mon_visible = 1; static int mon_tx = 1; static int mon_com = 1; static int mon_nodes = 0; static int mon_colour = 1; static int mon_ui_only = 1; static int port_mask = 0xFFFF; static char port_names[32][32]; /* ── Session state ───────────────────────────────────────── */ static int g_sock = -1; static int g_connected = 0; static int g_host_idx = -1; static time_t g_conn_time = 0; static int g_in_mon = 0; static char g_mon_fbuf[MON_FBUF_SZ]; static int g_mon_flen = 0; /* ── GUI widgets ─────────────────────────────────────────── */ static Fl_Double_Window *main_win = nullptr; static Fl_Text_Display *out_disp = nullptr; static Fl_Text_Display *mon_disp = nullptr; static Fl_Text_Buffer *out_buf = nullptr; static Fl_Text_Buffer *out_styl = nullptr; static Fl_Text_Buffer *mon_buf = nullptr; static Fl_Text_Buffer *mon_styl = nullptr; static Fl_Input *inp = nullptr; static Fl_Box *status_lbl = nullptr; static Fl_Menu_Bar *menu_bar = nullptr; /* Style table — index from 'A'. * A=default B=green(monitor) C=red(error) * D=yellow(status) E=cyan(info) F=magenta */ static const Fl_Text_Display::Style_Table_Entry STYLES[] = { { FL_FOREGROUND_COLOR, FL_COURIER, 13 }, /* A */ { fl_rgb_color(0,180,0), FL_COURIER, 13 }, /* B */ { FL_RED, FL_COURIER, 13 }, /* C */ { fl_rgb_color(180,140,0), FL_COURIER, 13 }, /* D */ { fl_rgb_color(0,190,210), FL_COURIER, 13 }, /* E */ { FL_MAGENTA, FL_COURIER, 13 }, /* F */ }; #define N_STYLES 6 /* ── Forward declarations ────────────────────────────────── */ static void tcp_disconnect(void); static void update_status(void); static void cb_timer(void *); /* ── Config I/O ──────────────────────────────────────────── */ static void load_config(void) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; snprintf(cfg_path, sizeof(cfg_path), "%s/.BPQTermTCP.ini", home); FILE *fp = fopen(cfg_path, "r"); if (!fp) return; char line[256]; int hi = -1; while (fgets(line, sizeof(line), fp)) { /* strip trailing newline */ int l = (int)strlen(line); while (l > 0 && (line[l-1]=='\n'||line[l-1]=='\r'||line[l-1]==' ')) line[--l] = '\0'; if (line[0] == '[') { /* section header */ if (strncmp(line, "[Host", 5) == 0 && num_hosts < MAX_HOSTS) { hi = num_hosts++; memset(&hosts[hi], 0, sizeof(HostEntry)); } else { hi = -1; } continue; } char *eq = strchr(line, '='); if (!eq) continue; *eq = '\0'; const char *key = line, *val = eq + 1; if (hi >= 0) { if (!strcmp(key,"Host")) { strncpy(hosts[hi].host, val, 99); } else if (!strcmp(key,"Port")) { strncpy(hosts[hi].port, val, 9); } else if (!strcmp(key,"Username")) { strncpy(hosts[hi].username, val, 79); } else if (!strcmp(key,"Password")) { strncpy(hosts[hi].password, val, 79); } else if (!strcmp(key,"Name")) { strncpy(hosts[hi].name, val, 49); } } else { if (!strcmp(key,"MonTX")) mon_tx = atoi(val); else if (!strcmp(key,"MonCom")) mon_com = atoi(val); else if (!strcmp(key,"MonNodes")) mon_nodes = atoi(val); else if (!strcmp(key,"MonUIOnly")) mon_ui_only = atoi(val); else if (!strcmp(key,"MonVisible")) mon_visible = atoi(val); else if (!strcmp(key,"PortMask")) port_mask = atoi(val); else if (strncmp(key,"PortName",8)==0) { int pn = atoi(key+8); if (pn >= 1 && pn <= 32) snprintf(port_names[pn-1], 32, "%s", val); } } } fclose(fp); } /* ── Text display helpers ────────────────────────────────── */ static void append_styled(Fl_Text_Buffer *tbuf, Fl_Text_Buffer *sbuf, const char *text, int len, char style) { if (len <= 0) return; /* Build a style string the same length as text (minus any stripped * BPQ 0x1B colour-escape pairs which we skip). */ char *tmp = (char *)malloc(len + 1); char *sty = (char *)malloc(len + 1); if (!tmp || !sty) { free(tmp); free(sty); return; } int ti = 0; char cur = style; for (int i = 0; i < len; ) { unsigned char c = (unsigned char)text[i]; if (c == 0x1B && i + 1 < len) { /* BPQ colour byte — map to style char */ unsigned char cb = (unsigned char)text[i+1]; int idx = (int)cb - 10; if (idx < 0) cur = 'A'; else if (idx < 4) cur = 'E'; /* dark blue → cyan */ else if (idx < 20) cur = 'B'; /* green */ else if (idx < 60) cur = 'C'; /* reds */ else cur = 'D'; /* bright → yellow */ i += 2; } else if (c == '\r') { i++; /* skip bare CR */ } else { tmp[ti] = (char)c; sty[ti] = cur; ti++; i++; } } tmp[ti] = '\0'; sty[ti] = '\0'; tbuf->append(tmp); sbuf->append(sty); free(tmp); free(sty); } static void out_append(const char *text, int len, char style = 'A') { append_styled(out_buf, out_styl, text, len, style); /* scroll to bottom */ out_disp->scroll(out_buf->count_lines(0, out_buf->length()), 0); } static void mon_append(const char *text, int len, char style = 'B') { if (!mon_visible) return; append_styled(mon_buf, mon_styl, text, len, style); mon_disp->scroll(mon_buf->count_lines(0, mon_buf->length()), 0); } /* ── BPQ protocol ────────────────────────────────────────── */ static void send_trace_options(bool with_ports) { if (g_sock < 0) return; char buf[80]; int len; if (with_ports) len = snprintf(buf, sizeof(buf), "\\\\\\\\%x %x %x %x %x %x 0 1\r", port_mask, mon_tx, mon_com, mon_nodes, mon_colour, mon_ui_only); else len = snprintf(buf, sizeof(buf), "\\\\\\\\%x %x %x %x %x %x\r", port_mask, mon_tx, mon_com, mon_nodes, mon_colour, mon_ui_only); send(g_sock, buf, len, 0); } static void parse_portinfo(const char *data, int len) { const char *p = data, *e = data + len; int nports = 0; while (p < e && *p >= '0' && *p <= '9') nports = nports*10 + (*p++ - '0'); if (p >= e || *p != '|') return; p++; for (int i = 0; i < nports && p < e; i++) { int n = 0; while (p < e && *p >= '0' && *p <= '9') n = n*10 + (*p++ - '0'); if (p >= e || *p != ' ') break; p++; const char *desc = p; while (p < e && *p != '|') p++; int dlen = (int)(p - desc); if (n >= 1 && n <= 32 && dlen > 0) { int copy = dlen < 31 ? dlen : 31; memcpy(port_names[n-1], desc, copy); port_names[n-1][copy] = '\0'; } if (p < e && *p == '|') p++; } } static void bpq_process(const char *buf, int len) { const char *p = buf; const char *end = buf + len; while (p < end) { if (!g_in_mon) { const char *ff = (const char *)memchr(p, 0xFF, end - p); if (!ff) { out_append(p, (int)(end - p)); break; } if (ff > p) out_append(p, (int)(ff - p)); p = ff + 1; if (p < end && (unsigned char)*p == 0xFF) { parse_portinfo(p + 1, (int)(end - p - 1)); p = end; } else { g_in_mon = 1; g_mon_flen = 0; } } else { const char *fe = (const char *)memchr(p, 0xFE, end - p); int seg_len = fe ? (int)(fe - p) : (int)(end - p); if (seg_len > 0) { int room = MON_FBUF_SZ - 1 - g_mon_flen; int add = seg_len < room ? seg_len : room; memcpy(g_mon_fbuf + g_mon_flen, p, add); g_mon_flen += add; } if (fe) { if (g_mon_flen > 0) { /* NODES filter */ int is_nodes = 0; for (int i = 0; i <= g_mon_flen - 6 && !is_nodes; i++) if (memcmp(g_mon_fbuf + i, ">NODES", 6) == 0) is_nodes = 1; if (mon_nodes || !is_nodes) { g_mon_fbuf[g_mon_flen] = '\n'; mon_append(g_mon_fbuf, g_mon_flen + 1); } } g_mon_flen = 0; g_in_mon = 0; p = fe + 1; } else { break; } } } } /* ── Socket handling ─────────────────────────────────────── */ static void on_socket_ready(int /*fd*/, void * /*data*/) { char buf[RECV_SZ]; int n = recv(g_sock, buf, sizeof(buf) - 1, 0); if (n > 0) { bpq_process(buf, n); } else if (n == 0 || (n < 0 && errno != EAGAIN)) { out_append("\n*** Connection closed by server ***\n", 37, 'C'); tcp_disconnect(); } } static int tcp_connect_to(int idx) { if (idx < 0 || idx >= num_hosts) return -1; struct addrinfo hints, *res = nullptr; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; if (getaddrinfo(hosts[idx].host, hosts[idx].port, &hints, &res) != 0 || !res) return -1; int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol); if (fd < 0) { freeaddrinfo(res); return -1; } if (connect(fd, res->ai_addr, res->ai_addrlen) != 0) { close(fd); freeaddrinfo(res); return -1; } freeaddrinfo(res); return fd; } static void tcp_disconnect(void) { if (g_sock >= 0) { Fl::remove_fd(g_sock); close(g_sock); g_sock = -1; } g_connected = 0; g_host_idx = -1; g_conn_time = 0; g_in_mon = 0; g_mon_flen = 0; update_status(); } static void tcp_send(const char *text, int len) { if (g_sock < 0 || !g_connected) return; send(g_sock, text, len, 0); } /* ── Status bar ──────────────────────────────────────────── */ static void update_status(void) { static char buf[160]; if (!g_connected || g_host_idx < 0) { snprintf(buf, sizeof(buf), " Not connected"); } else { time_t elapsed = time(nullptr) - g_conn_time; int h = (int)(elapsed / 3600); int m = (int)((elapsed % 3600) / 60); int s = (int)(elapsed % 60); time_t now = time(nullptr); struct tm *utc = gmtime(&now); snprintf(buf, sizeof(buf), " %s (%s:%s) %02d:%02d:%02d %02d:%02d UTC", hosts[g_host_idx].name, hosts[g_host_idx].host, hosts[g_host_idx].port, h, m, s, utc->tm_hour, utc->tm_min); } status_lbl->copy_label(buf); status_lbl->redraw(); } static void cb_timer(void *) { update_status(); Fl::repeat_timeout(1.0, cb_timer, nullptr); } /* ── Connect dialog ──────────────────────────────────────── */ static int g_dlg_result = -1; static void cb_dlg_connect(Fl_Widget *w, void *browser_ptr) { Fl_Hold_Browser *br = (Fl_Hold_Browser *)browser_ptr; int sel = br->value(); g_dlg_result = (sel > 0) ? sel - 1 : -1; w->window()->hide(); } static void cb_dlg_cancel(Fl_Widget *w, void *) { g_dlg_result = -1; w->window()->hide(); } static void cb_dlg_dblclick(Fl_Widget *w, void *) { Fl_Hold_Browser *br = (Fl_Hold_Browser *)w; if (br->value() > 0) { g_dlg_result = br->value() - 1; br->window()->hide(); } } static int show_connect_dialog(void) { if (num_hosts == 0) { fl_message("No hosts configured.\nEdit %s to add BPQ nodes.", cfg_path); return -1; } Fl_Window *dlg = new Fl_Window(320, 240, "Connect to Host"); dlg->begin(); Fl_Hold_Browser *br = new Fl_Hold_Browser(10, 10, 300, 170); for (int i = 0; i < num_hosts; i++) { char lbl[140]; snprintf(lbl, sizeof(lbl), "%s (%s:%s)", hosts[i].name[0] ? hosts[i].name : hosts[i].host, hosts[i].host, hosts[i].port); br->add(lbl); } br->select(1); br->callback(cb_dlg_dblclick); br->when(FL_WHEN_CHANGED); /* double-click triggers when() */ Fl_Return_Button *btn_ok = new Fl_Return_Button(130, 195, 90, 30, "Connect"); btn_ok->callback(cb_dlg_connect, br); Fl_Button *btn_cancel = new Fl_Button(230, 195, 80, 30, "Cancel"); btn_cancel->callback(cb_dlg_cancel); dlg->end(); dlg->set_modal(); g_dlg_result = -1; dlg->show(); while (dlg->shown()) Fl::wait(); int result = g_dlg_result; delete dlg; return result; } /* ── Menu callbacks ──────────────────────────────────────── */ static void do_connect(int idx) { if (g_connected) tcp_disconnect(); char msg[160]; snprintf(msg, sizeof(msg), "Connecting to %s (%s:%s)...\n", hosts[idx].name[0] ? hosts[idx].name : hosts[idx].host, hosts[idx].host, hosts[idx].port); out_append(msg, (int)strlen(msg), 'E'); int fd = tcp_connect_to(idx); if (fd < 0) { snprintf(msg, sizeof(msg), "Connection failed: %s\n", strerror(errno)); out_append(msg, (int)strlen(msg), 'C'); return; } g_sock = fd; g_connected = 1; g_host_idx = idx; g_conn_time = time(nullptr); Fl::add_fd(g_sock, FL_READ, on_socket_ready, nullptr); send_trace_options(true); update_status(); snprintf(msg, sizeof(msg), "Connected.\n"); out_append(msg, (int)strlen(msg), 'E'); } static void cb_connect(Fl_Widget *, void *) { int idx = show_connect_dialog(); if (idx >= 0) do_connect(idx); } static void cb_disconnect(Fl_Widget *, void *) { if (!g_connected) { fl_message("Not connected."); return; } out_append("*** Disconnected\n", 17, 'C'); tcp_disconnect(); } static void cb_quit(Fl_Widget *, void *) { if (g_connected) tcp_disconnect(); exit(0); } static void cb_clear_out(Fl_Widget *, void *) { out_buf->remove(0, out_buf->length()); out_styl->remove(0, out_styl->length()); } static void cb_clear_mon(Fl_Widget *, void *) { mon_buf->remove(0, mon_buf->length()); mon_styl->remove(0, mon_styl->length()); } static void cb_toggle_mon(Fl_Widget *, void *) { mon_visible = !mon_visible; if (mon_visible) out_append("[Monitor ON]\n", 13, 'D'); else out_append("[Monitor OFF]\n", 14, 'D'); } /* ── Input send ──────────────────────────────────────────── */ static void cb_send(Fl_Widget *, void *) { const char *text = inp->value(); if (!text || text[0] == '\0') return; if (!g_connected) { out_append("Not connected.\n", 15, 'C'); inp->value(""); return; } /* Echo locally */ char echo[514]; int elen = snprintf(echo, sizeof(echo), "> %s\n", text); out_append(echo, elen, 'D'); /* Send to server with CR */ int tlen = (int)strlen(text); char *tbuf = (char *)malloc(tlen + 2); if (tbuf) { memcpy(tbuf, text, tlen); tbuf[tlen] = '\r'; tbuf[tlen+1] = '\0'; tcp_send(tbuf, tlen + 1); free(tbuf); } inp->value(""); } /* ── Main window ─────────────────────────────────────────── */ static Fl_Menu_Item menu_items[] = { { "&File", 0, nullptr, nullptr, FL_SUBMENU }, { "&Connect...", FL_CTRL+'k', cb_connect }, { "&Disconnect", FL_CTRL+'d', cb_disconnect }, { "Clear output", 0, cb_clear_out }, { "Clear monitor", 0, cb_clear_mon }, { "E&xit", FL_CTRL+'q', cb_quit, nullptr, FL_MENU_DIVIDER }, { nullptr }, { "&Monitor", 0, nullptr, nullptr, FL_SUBMENU }, { "Toggle monitor pane", FL_CTRL+'m', cb_toggle_mon }, { nullptr }, { "&Help", 0, nullptr, nullptr, FL_SUBMENU }, { "About", 0, [](Fl_Widget*, void*){ fl_message("TermTCP GUI v" GUI_VERSION "\n" "BPQTermTCP client for BPQ32 packet radio nodes.\n" "Config: ~/.BPQTermTCP.ini"); }}, { nullptr }, { nullptr } }; static void build_window(void) { const int W = 900, H = 650; const int MENU_H = 25; const int STATUS_H = 22; const int INPUT_H = 32; const int SEND_W = 70; const int MON_FRAC = 3; /* monitor gets 1/3 of inner height */ main_win = new Fl_Double_Window(W, H, "TermTCP " GUI_VERSION); main_win->begin(); main_win->color(FL_WHITE); /* Menu bar */ menu_bar = new Fl_Menu_Bar(0, 0, W, MENU_H); menu_bar->menu(menu_items); menu_bar->color(fl_rgb_color(230,230,230)); /* Resizable split area */ int tile_y = MENU_H; int tile_h = H - MENU_H - INPUT_H - STATUS_H; int out_h = tile_h - tile_h / MON_FRAC; int mon_h = tile_h / MON_FRAC; Fl_Tile *tile = new Fl_Tile(0, tile_y, W, tile_h); /* Output display */ out_disp = new Fl_Text_Display(0, tile_y, W, out_h); out_buf = new Fl_Text_Buffer(); out_styl = new Fl_Text_Buffer(); out_disp->buffer(out_buf); out_disp->highlight_data(out_styl, STYLES, N_STYLES, 'A', nullptr, 0); out_disp->textfont(FL_COURIER); out_disp->textsize(13); out_disp->color(fl_rgb_color(20, 20, 20)); out_disp->textcolor(FL_WHITE); out_disp->cursor_color(FL_WHITE); out_disp->scrollbar_width(14); out_disp->wrap_mode(Fl_Text_Display::WRAP_AT_BOUNDS, 0); /* Monitor display */ mon_disp = new Fl_Text_Display(0, tile_y + out_h, W, mon_h); mon_buf = new Fl_Text_Buffer(); mon_styl = new Fl_Text_Buffer(); mon_disp->buffer(mon_buf); mon_disp->highlight_data(mon_styl, STYLES, N_STYLES, 'B', nullptr, 0); mon_disp->textfont(FL_COURIER); mon_disp->textsize(12); mon_disp->color(fl_rgb_color(10, 30, 10)); mon_disp->textcolor(fl_rgb_color(0, 220, 0)); mon_disp->cursor_color(fl_rgb_color(0,220,0)); mon_disp->scrollbar_width(14); tile->end(); /* Input row */ int inp_y = tile_y + tile_h; inp = new Fl_Input(0, inp_y, W - SEND_W, INPUT_H); inp->textfont(FL_COURIER); inp->textsize(13); inp->color(fl_rgb_color(30,30,30)); inp->textcolor(FL_WHITE); inp->cursor_color(FL_WHITE); inp->when(FL_WHEN_ENTER_KEY_ALWAYS); inp->callback(cb_send); Fl_Return_Button *send_btn = new Fl_Return_Button(W - SEND_W, inp_y, SEND_W, INPUT_H, "Send"); send_btn->callback(cb_send); /* Status bar */ int st_y = inp_y + INPUT_H; status_lbl = new Fl_Box(0, st_y, W, STATUS_H, " Not connected"); status_lbl->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); status_lbl->box(FL_FLAT_BOX); status_lbl->color(fl_rgb_color(0, 0, 140)); status_lbl->labelcolor(FL_WHITE); status_lbl->labelfont(FL_HELVETICA); status_lbl->labelsize(12); main_win->resizable(tile); main_win->end(); main_win->callback([](Fl_Widget *, void *) { if (g_connected) tcp_disconnect(); exit(0); }); } /* ── main ────────────────────────────────────────────────── */ int main(int argc, char **argv) { /* Init port names */ for (int i = 0; i < 32; i++) snprintf(port_names[i], 32, "Port %d", i+1); load_config(); Fl::scheme("gtk+"); build_window(); main_win->show(argc, argv); /* Status timer */ Fl::add_timeout(1.0, cb_timer, nullptr); /* Welcome banner */ const char *banner = "TermTCP GUI v" GUI_VERSION " — File > Connect to get started\n"; out_append(banner, (int)strlen(banner), 'E'); /* Auto-connect if exactly one host and -c flag or single host */ int auto_idx = -1; for (int i = 1; i < argc; i++) { if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--connect") == 0) && i+1 < argc) auto_idx = atoi(argv[++i]); } if (auto_idx < 0 && num_hosts == 1) auto_idx = 0; if (auto_idx >= 0 && auto_idx < num_hosts) do_connect(auto_idx); return Fl::run(); }