//! 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)); }