ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
web_terminal.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file web_terminal.cpp
6 * @brief Browser web-serial terminal over WebSocket (PC_ENABLE_WEB_TERMINAL).
7 */
8
10#include "shared_primitives/strbuf.h" // pc_sb frame builder
11
12#if PC_ENABLE_WEB_TERMINAL
13
14// Dependency (WEB_TERMINAL requires WEBSOCKET) is enforced centrally in protocore_config.h.
15
16#include "network_drivers/application/web_assets.h" // PC_TERMINAL_PAGE
18#include <stdarg.h>
19#include <string.h>
20
21// ---------------------------------------------------------------------------
22// State (all static / BSS - no heap)
23// ---------------------------------------------------------------------------
24// All web-terminal state, owned by one instance (internal linkage): the server handle, the
25// command callback, the WebSocket path, and which ws slots are terminal browsers. Grouped so
26// it is one named owner, unreachable cross-TU. (The route/ws handlers are fixed-signature
27// callbacks, so they reach this single owner directly.)
28struct WebTerminalCtx
29{
30 PC *srv = nullptr;
31 TermCommandCb cb = nullptr;
32 char ws_path[MAX_PATH_LEN] = {0};
33 bool is_client[MAX_WS_CONNS] = {}; // which ws slots are terminal browsers
34};
35static WebTerminalCtx s_term;
36
37// ---- internal route handlers ----------------------------------------------
38
39static void term_page_handler(uint8_t slot_id, HttpReq *req)
40{
41 (void)req;
42 // This route only exists once pc_web_terminal_begin() has installed it, and that call stores a
43 // non-null server first, so the null arm is unreachable from any registered route.
44 if (s_term.srv) // GCOVR_EXCL_LINE
45 {
46 s_term.srv->send(slot_id, 200, PC_MIME_TEXT_HTML, PC_TERMINAL_PAGE);
47 }
48}
49
50static void term_ws_connect(uint8_t ws_id)
51{
52 // ws_id always addresses a real pool slot: the WebSocket layer numbers ws_pool[i].ws_id = i for
53 // i < MAX_WS_CONNS and dispatches every route callback as cb(ws->ws_id), so the bound check
54 // cannot fail. Same reasoning for the ws_id checks in term_ws_message / term_ws_close below.
55 if (ws_id < MAX_WS_CONNS) // GCOVR_EXCL_LINE
56 {
57 s_term.is_client[ws_id] = true;
58 }
59 // As in term_page_handler: this handler is only reachable once begin() has stored the server.
60 if (s_term.srv) // GCOVR_EXCL_LINE
61 {
62 s_term.srv->ws_send_text(ws_id, "ProtoCore terminal ready\n");
63 }
64}
65
66static void term_ws_message(uint8_t ws_id)
67{
68 // Branch-excluded for the ws_id bound only (see term_ws_connect); the s_term.cb arms are both
69 // exercised by the suite (with and without a registered command callback).
70 if (s_term.cb && ws_id < MAX_WS_CONNS) // GCOVR_EXCL_LINE
71 {
72 s_term.cb(ws_payload(ws_id), ws_id);
73 }
74}
75
76static void term_ws_close(uint8_t ws_id)
77{
78 if (ws_id < MAX_WS_CONNS) // GCOVR_EXCL_LINE ws_id is always a valid pool index (see term_ws_connect)
79 {
80 s_term.is_client[ws_id] = false;
81 }
82}
83
84// ---- public API -----------------------------------------------------------
85
86void pc_web_terminal_begin(PC &server, const char *path)
87{
88 s_term.srv = &server;
89 for (uint8_t i = 0; i < MAX_WS_CONNS; i++)
90 {
91 s_term.is_client[i] = false;
92 }
93
94 if (!path || !path[0])
95 {
96 path = "/terminal";
97 }
98 pc_sb sb_ws_path = {s_term.ws_path, sizeof(s_term.ws_path), 0, true};
99 pc_sb_put(&sb_ws_path, path);
100 pc_sb_put(&sb_ws_path, "/ws");
101 if (pc_sb_finish(&sb_ws_path) == 0)
102 {
103 s_term.ws_path[0] = '\0';
104 }
105
106 server.on(path, HttpMethod::HTTP_GET, term_page_handler);
107 server.on_ws(s_term.ws_path, term_ws_connect, term_ws_message, term_ws_close);
108}
109
110void pc_web_terminal_on_command(TermCommandCb cb)
111{
112 s_term.cb = cb;
113}
114
115void pc_web_terminal_print(const char *s)
116{
117 if (!s_term.srv || !s)
118 {
119 return;
120 }
121 for (uint8_t i = 0; i < MAX_WS_CONNS; i++)
122 {
123 if (s_term.is_client[i] && ws_active(i))
124 {
125 s_term.srv->ws_send_text(i, s);
126 }
127 }
128}
129
130void pc_web_terminal_println(const char *s)
131{
132 char buf[TERM_TX_BUF_SIZE];
133 pc_sb sb_buf = {buf, sizeof(buf), 0, true};
134 pc_sb_put(&sb_buf, s ? s : "");
135 pc_sb_put(&sb_buf, "\n");
136 if (pc_sb_finish(&sb_buf) == 0)
137 {
138 buf[0] = '\0';
139 }
140 pc_web_terminal_print(buf);
141}
142
143void pc_web_terminal_frame(const pc_field *spec, ...)
144{
145 char buf[TERM_TX_BUF_SIZE];
146 va_list ap;
147 va_start(ap, spec);
148 // A frame that does not fit leaves buf a valid empty string, so the print is a no-op rather
149 // than a partial line.
150 (void)pc_frame_vbuild(buf, sizeof(buf), spec, ap);
151 va_end(ap);
152 pc_web_terminal_print(buf);
153}
154
155uint8_t pc_web_terminal_client_count()
156{
157 uint8_t n = 0;
158 for (uint8_t i = 0; i < MAX_WS_CONNS; i++)
159 {
160 if (s_term.is_client[i] && ws_active(i))
161 {
162 n++;
163 }
164 }
165 return n;
166}
167
168#endif // PC_ENABLE_WEB_TERMINAL
#define MAX_WS_CONNS
Definition c2_defaults.h:72
Single-port HTTP server with deterministic, zero-allocation execution.
Definition protocore.h:348
void on(const char *path, HttpMethod method, Handler callback)
Register a route handler.
PC_OPTIMIZE_O2 size_t pc_frame_vbuild(char *out, size_t cap, const pc_field *spec, va_list ap)
va_list form, for a caller that already has one.
Definition frame.cpp:25
Shared HTTP Content-Type ("MIME") string constants (one source of truth).
@ HTTP_GET
Safe, idempotent read.
#define TERM_TX_BUF_SIZE
Stack scratch for pc_web_terminal_frame()/println() line building.
#define MAX_PATH_LEN
Maximum URL path length (including leading /).
Bounded no-heap string builder that fails closed on overflow (one shared copy).
size_t pc_sb_finish(pc_sb *b)
NUL-terminate and return the built length, or 0 if the build overflowed.
Definition strbuf.h:648
void pc_sb_put(pc_sb *b, const char *s)
Append NUL-terminated s; leaves the buffer untouched and clears ok if it would not fit.
Definition strbuf.h:60
Fully-parsed HTTP/1.1 request.
One field of a frame. Frames are static const pc_field[], so they live in rodata.
Definition frame.h:80
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30
const char PC_TERMINAL_PAGE[]
Self-contained WebSocket terminal page (green-phosphor CRT theme; auto ws/wss).
Layer 7 (Application) - embedded web assets generated from web_assets/input/.
Browser "web serial" terminal over WebSocket (PC_ENABLE_WEB_TERMINAL).
void(* TermCommandCb)(const char *line, uint8_t client_id)
const char * ws_payload(uint8_t ws_id)
The NUL-terminated reassembled message payload for ws_id, or nullptr if the slot is out of range / in...
Definition websocket.cpp:45
bool ws_active(uint8_t ws_id)
True if ws_id is a valid, in-use WebSocket slot. Use this instead of reaching into ws_pool[ws_id]....
Definition websocket.cpp:40