summaryrefslogtreecommitdiff
path: root/tools/console.zig
diff options
context:
space:
mode:
Diffstat (limited to 'tools/console.zig')
-rw-r--r--tools/console.zig239
1 files changed, 239 insertions, 0 deletions
diff --git a/tools/console.zig b/tools/console.zig
new file mode 100644
index 0000000..2e924e3
--- /dev/null
+++ b/tools/console.zig
@@ -0,0 +1,239 @@
+//! An interactive terminal on the far side of the serial port.
+//!
+//! `MonitorStep` prints the board's output for N seconds and never sends anything. That is the
+//! right tool for a program that only reports. It is the wrong tool for a program the human is
+//! supposed to *use*, which needs the host's keystrokes on the wire and the host's terminal out of
+//! the way. So this is the other half: raw-mode stdin forwarded to the UART, UART forwarded to
+//! stdout, until the escape byte.
+//!
+//! The division of labour is the interesting part. The board runs the application and emits ANSI;
+//! the host terminal emulator (ghostty here) does the actual terminal work - fonts, scrollback,
+//! selection - and answers the application's capability queries itself. This process is a wire, and
+//! deliberately almost transparent: a `\x1b[?1049h` from the board reaches ghostty, ghostty's reply
+//! to a `CSI c` reaches the board, and neither end needs to know there are 3 metres of USB cable
+//! and a CH340 in between.
+//!
+//! Almost transparent, because of one thing a wire cannot pass through: **size**. A terminal
+//! application learns its window size from `ioctl(TIOCGWINSZ)`, and firmware has no ioctl. DEC mode
+//! 2048 ("in-band resize") solves half of it - a terminal that has been sent `\x1b[?2048h` reports
+//! every subsequent resize as `CSI 48 ; rows ; cols ; ypix ; xpix t`, and those reports flow down
+//! the wire like any other bytes. The half it does not solve is the *first* size, because a change
+//! notification is only sent on a change. This process owns the real tty, so it is the only party
+//! that can answer, and it injects that same sequence itself:
+//!
+//! * once at attach, for a board that never asks;
+//! * on SIGWINCH, because the host's window changed and mode 2048 may not be enabled;
+//! * and on seeing `\x1b[?2048h` come *back* from the board - which is the exact moment the
+//! application has declared itself ready to understand one. That is the deterministic trigger;
+//! the other two are belt and braces.
+//!
+//! Injecting a resize report the host terminal would also have sent is harmless: it carries the
+//! size as data, so a duplicate is idempotent, and `vaxis`'s parser (`src/Parser.zig:528-551`)
+//! treats both identically because they are byte-for-byte the same sequence.
+
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const serial = @import("serial.zig");
+
+/// Ctrl-] , telnet's escape and not a key any full-screen application binds. Ctrl-C, Ctrl-Q and
+/// Ctrl-Z all had to be rejected: an editor wants every one of them, and a bridge that swallowed
+/// them would be lying about being transparent.
+pub const escape_byte: u8 = 0x1d;
+
+/// Set by the SIGWINCH handler, read by the loop. `volatile` rather than atomic because a signal
+/// handler on the same thread is not a concurrent writer - it is an interruption - and this only
+/// needs the compiler to stop caching the load.
+var winch_pending: bool = false;
+
+/// The handler takes `posix.SIG`, not an int: std's `Sigaction.handler_fn` is
+/// `*align(1) const fn (SIG) callconv(.c) void` (std/os/linux.zig:6026).
+fn onWinch(_: posix.SIG) callconv(.c) void {
+ @as(*volatile bool, &winch_pending).* = true;
+}
+
+const Winsize = extern struct { row: u16, col: u16, xpixel: u16, ypixel: u16 };
+const TIOCGWINSZ = 0x5413;
+
+fn windowSize() Winsize {
+ var ws: Winsize = .{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
+ // A failure here is not fatal: 80x24 is a defensible terminal, and the alternative is refusing
+ // to attach because the size could not be read.
+ _ = linux.ioctl(0, TIOCGWINSZ, @intFromPtr(&ws));
+ if (ws.row == 0) ws.row = 24;
+ if (ws.col == 0) ws.col = 80;
+ return ws;
+}
+
+/// The in-band resize report, in the form `vaxis` parses: `CSI 48 ; rows ; cols ; ypix ; xpix t`.
+/// Note the pixel fields are height-then-width, which is the opposite order from the
+/// `struct winsize` they come out of - `Parser.zig:536-539` reads height first.
+fn sendWinsize(port: *serial.Port, ws: Winsize) void {
+ var buf: [64]u8 = undefined;
+ const seq = std.fmt.bufPrint(&buf, "\x1b[48;{d};{d};{d};{d}t", .{
+ ws.row, ws.col, ws.ypixel, ws.xpixel,
+ }) catch return;
+ port.write(seq) catch {};
+}
+
+/// Matches `\x1b[?2048h` in the board's output stream one byte at a time, because the sequence can
+/// be split across reads. Returns true on the byte that completes it.
+const ModeWatch = struct {
+ const want = "\x1b[?2048h";
+ at: usize = 0,
+
+ fn feed(m: *ModeWatch, byte: u8) bool {
+ if (byte == want[m.at]) {
+ m.at += 1;
+ if (m.at == want.len) {
+ m.at = 0;
+ return true;
+ }
+ } else {
+ // Restart, and allow this byte to be a fresh start - otherwise "\x1b\x1b[?2048h" is
+ // missed.
+ m.at = if (byte == want[0]) 1 else 0;
+ }
+ return false;
+ }
+};
+
+pub const Options = struct {
+ /// Pulse reset so the application starts from boot with the console already attached. Without
+ /// it, attaching to a board that has been running for a while shows a screen mid-session with
+ /// no redraw until something changes.
+ reset: bool = true,
+ /// Print the escape-key hint. Suppressed for scripted runs, whose output is being asserted on.
+ banner: bool = true,
+};
+
+/// Forward bytes both ways until the escape byte arrives on stdin.
+///
+/// Returns normally on escape; the terminal is always restored, including on error, because the
+/// alternative is handing the user back a shell with no echo.
+pub fn attach(port_path: []const u8, baud: serial.Baud, opts: Options) !void {
+ var port = try serial.Port.open(port_path, baud);
+ defer port.close();
+
+ // No `isatty`: `tcgetattr` answers the same question with the same syscall this needs anyway,
+ // and a null here means "stdin is a pipe" - which is a supported way to run this, for scripted
+ // sessions whose input is a file.
+ const saved: ?posix.termios = posix.tcgetattr(0) catch null;
+ if (saved) |prev| {
+ var raw = prev;
+ // The same raw mode `serial.Port.open` builds for the port, for the same reason: every byte
+ // the user types has to reach the board unmodified, including the ones the line discipline
+ // would otherwise interpret. ISIG off is what lets Ctrl-C reach the application instead of
+ // killing this process.
+ raw.lflag.ICANON = false;
+ raw.lflag.ECHO = false;
+ raw.lflag.ISIG = false;
+ raw.lflag.IEXTEN = false;
+ raw.iflag.IXON = false;
+ raw.iflag.ICRNL = false;
+ raw.iflag.INLCR = false;
+ raw.iflag.BRKINT = false;
+ raw.oflag.OPOST = false;
+ try posix.tcsetattr(0, .FLUSH, raw);
+ }
+ defer if (saved) |prev| posix.tcsetattr(0, .FLUSH, prev) catch {};
+
+ // Installed after raw mode so a resize during setup cannot be missed-but-flagged.
+ posix.sigaction(posix.SIG.WINCH, &.{
+ .handler = .{ .handler = onWinch },
+ .mask = posix.sigemptyset(),
+ .flags = 0,
+ }, null);
+
+ // stdin/stdout as `std.Io.File`, because std 0.16 has no `posix.read`/`posix.write` any more -
+ // byte traffic goes through std.Io. The `io` is borrowed from the port, which already holds the
+ // single-threaded instance `serial.Port.open` created.
+ const io = port.io;
+ const stdin: std.Io.File = .{ .handle = 0, .flags = .{ .nonblocking = false } };
+ const stdout: std.Io.File = .{ .handle = 1, .flags = .{ .nonblocking = false } };
+
+ if (opts.banner) {
+ var hint: [96]u8 = undefined;
+ const line = std.fmt.bufPrint(&hint, "[zig-p4 console @ {d} baud - Ctrl-] to detach]\r\n", .{
+ baud.rate(),
+ }) catch "[zig-p4 console - Ctrl-] to detach]\r\n";
+ stdout.writeStreamingAll(io, line) catch {};
+ }
+
+ if (opts.reset) try port.resetToRun(.{});
+ sendWinsize(&port, windowSize());
+
+ var watch: ModeWatch = .{};
+ var from_board: [4096]u8 = undefined;
+ var from_user: [256]u8 = undefined;
+
+ while (true) {
+ if (@as(*volatile bool, &winch_pending).*) {
+ @as(*volatile bool, &winch_pending).* = false;
+ sendWinsize(&port, windowSize());
+ }
+
+ var pfd = [_]posix.pollfd{
+ .{ .fd = 0, .events = posix.POLL.IN, .revents = 0 },
+ .{ .fd = port.file.handle, .events = posix.POLL.IN, .revents = 0 },
+ };
+ // A bounded wait rather than an infinite one so a SIGWINCH that lands between the check
+ // above and the poll below is still serviced promptly; poll reports the signal itself as
+ // an interrupt, which is handled as "go round again".
+ const ready = posix.poll(&pfd, 200) catch continue;
+ if (ready == 0) continue;
+
+ // POLL.IN is not the only thing poll reports, and ignoring the rest is a hot spin, not a
+ // no-op: unplug the CH340 mid-session and the port's revents carries HUP|ERR|NVAL forever.
+ // poll then returns immediately with a non-zero count, neither branch below matches because
+ // both test POLL.IN, and the loop burns a core with stdin still in raw mode and ISIG off.
+ // `std.posix.poll` cannot surface it as an error either - it maps INVAL to `unreachable`
+ // (std/posix.zig:1007-1017) because a dead descriptor is reported in `revents`, not errno.
+ const gone = posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL;
+ if (pfd[1].revents & gone != 0) return error.PortDisconnected;
+ // stdin dying is ordinary: a pipe ran out, or the terminal closed. Detach quietly.
+ if (pfd[0].revents & gone != 0) return;
+
+ if (pfd[1].revents & posix.POLL.IN != 0) {
+ const n = port.read(&from_board) catch 0;
+ if (n > 0) {
+ stdout.writeStreamingAll(io, from_board[0..n]) catch {};
+ for (from_board[0..n]) |b| {
+ if (watch.feed(b)) sendWinsize(&port, windowSize());
+ }
+ }
+ }
+
+ if (pfd[0].revents & posix.POLL.IN != 0) {
+ const n = stdin.readStreaming(io, &.{&from_user}) catch 0;
+ if (n == 0) return; // stdin closed: a pipe ran out, so detach
+ if (std.mem.indexOfScalar(u8, from_user[0..n], escape_byte)) |cut| {
+ // Everything before the escape still belongs to the board.
+ if (cut > 0) port.write(from_user[0..cut]) catch {};
+ if (opts.banner) stdout.writeStreamingAll(io, "\r\n[detached]\r\n") catch {};
+ return;
+ }
+ port.write(from_user[0..n]) catch {};
+ }
+ }
+}
+
+test "ModeWatch completes only on the full sequence" {
+ var m: ModeWatch = .{};
+ for ("\x1b[?2048") |b| try std.testing.expect(!m.feed(b));
+ try std.testing.expect(m.feed('h'));
+}
+
+test "ModeWatch resynchronises on a false start" {
+ var m: ModeWatch = .{};
+ // A prefix that dies, then the real thing immediately after: the naive reset-to-zero misses
+ // this because the byte that broke the match is itself the next match's ESC.
+ for ("\x1b[?20") |b| try std.testing.expect(!m.feed(b));
+ for ("\x1b[?2048") |b| try std.testing.expect(!m.feed(b));
+ try std.testing.expect(m.feed('h'));
+}
+
+test "ModeWatch ignores unrelated traffic" {
+ var m: ModeWatch = .{};
+ for ("hello \x1b[?1049h world \x1b[0m") |b| try std.testing.expect(!m.feed(b));
+}