//! 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; } }; /// How long a motion report may be held while a newer one might replace it. /// /// A report is ~12 bytes, so 40 ms caps drag traffic at 25 reports a second - about 300 B/s, or 2.6% /// of a 115200 line. Below roughly 30 ms the thinning stops paying for itself on this link; far above /// it a drag visibly lags the pointer. const coalesce_ms: i64 = 40; /// Thins out mouse reports on their way to the board. /// /// The board asks for DEC 1002, so the terminal reports presses, releases, and motion WHILE A BUTTON /// IS HELD. A press is one report; a drag across this grid is one report per cell crossed, each /// `\x1b[<0;12;5M` at roughly a dozen bytes. A hand can cross forty cells in a tenth of a second, /// which is ~500 bytes, which is 43 ms of a 115200 line - and every one of those bytes is input the /// board has to parse while it is trying to paint the result of the previous one. Unthinned, a drag /// makes the editor unusable for as long as the drag lasts and for a while after. /// /// The thinning is NEWEST-WINS, and only for motion. Where the pointer passed through is not /// information the editor can use - a selection is defined by where the drag started and where it is /// now - so an intermediate report that is already stale by the time it reaches the wire is pure /// cost. Presses, releases and wheel events are never held: those are discrete, each one means /// something different, and dropping one loses a click. /// /// A held report is released when the interval expires or when any non-motion byte follows it, so a /// drag that stops moving still delivers its final position, and a release always arrives after the /// motion that preceded it. const MouseFilter = struct { /// The most recent motion report not yet sent, if any. held: [max_report]u8 = undefined, held_len: usize = 0, /// A report arriving in pieces across reads. SGR reports are short, but a read boundary can /// still land inside one, and a half-parsed report must not be forwarded as loose bytes. partial: [max_report]u8 = undefined, partial_len: usize = 0, dropped: usize = 0, /// Longest `\x1b[ 0) { m.partial[m.partial_len] = in[i]; m.partial_len += 1; i += 1; switch (classify(m.partial[0..m.partial_len])) { .incomplete => if (m.partial_len < max_report) continue else { // Too long to be a report: it was never one, so pass it on untouched. n += m.flushHeld(out[n..]); @memcpy(out[n..][0..m.partial_len], m.partial[0..m.partial_len]); n += m.partial_len; m.partial_len = 0; continue; }, .motion => { m.hold(m.partial[0..m.partial_len]); m.partial_len = 0; continue; }, .other => { n += m.flushHeld(out[n..]); @memcpy(out[n..][0..m.partial_len], m.partial[0..m.partial_len]); n += m.partial_len; m.partial_len = 0; continue; }, } } // A report can only start at an ESC. if (in[i] == 0x1b) { m.partial[0] = in[i]; m.partial_len = 1; i += 1; continue; } // Ordinary byte: it orders after anything held, so the held report goes first. n += m.flushHeld(out[n..]); out[n] = in[i]; n += 1; i += 1; } return n; } fn hold(m: *MouseFilter, report: []const u8) void { if (m.held_len > 0) m.dropped += 1; @memcpy(m.held[0..report.len], report); m.held_len = report.len; } /// Emit the held report, if there is one. Called when ordering requires it and by the caller when /// the coalescing interval expires. fn flushHeld(m: *MouseFilter, out: []u8) usize { if (m.held_len == 0) return 0; @memcpy(out[0..m.held_len], m.held[0..m.held_len]); const n = m.held_len; m.held_len = 0; return n; } fn pending(m: *const MouseFilter) bool { return m.held_len > 0; } const Kind = enum { incomplete, motion, other }; /// Is `bytes` a complete SGR mouse report, and is it motion? /// /// `\x1b[<` then decimal parameters separated by `;` then `M` (press or motion) or `m` (release). /// Motion is the low two bits of the button field being 3 for a plain move, or bit 5 (32) set for /// a drag; a wheel report has bit 6 (64) set and is never motion however it is encoded. fn classify(bytes: []const u8) Kind { if (bytes.len < 3) { const prefix = "\x1b[<"; return if (std.mem.startsWith(u8, prefix, bytes)) .incomplete else .other; } if (!std.mem.startsWith(u8, bytes, "\x1b[<")) return .other; var button: u32 = 0; var digits: usize = 0; var i: usize = 3; while (i < bytes.len) : (i += 1) { const b = bytes[i]; if (b >= '0' and b <= '9') { if (digits == 0) button = button * 10 + (b - '0'); if (button > 1 << 20) return .other; continue; } if (b == ';') { digits += 1; continue; } if (b == 'M' or b == 'm') { if (digits != 2) return .other; const wheel = button & 64 != 0; const drag = button & 32 != 0; return if (!wheel and drag) .motion else .other; } return .other; } return .incomplete; } }; 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 mouse: MouseFilter = .{}; var held_at: ?i64 = null; var from_board: [4096]u8 = undefined; var from_user: [256]u8 = undefined; var to_board: [512]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". // A bounded wait, and shorter while a motion report is being held: the hold has to end on // time even when the human has stopped moving the mouse and nothing else is arriving. const wait: i32 = if (held_at) |at| blk: { const left = coalesce_ms - (port.nowMs() - at); break :blk if (left <= 0) 0 else @intCast(left); } else 200; const ready = posix.poll(&pfd, wait) catch continue; if (ready == 0 and held_at == null) 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 // The escape byte is looked for in the RAW stream, before any filtering: Ctrl-] has to // detach whatever else is in flight, including a half-parsed mouse report. var raw = from_user[0..n]; const detaching = std.mem.indexOfScalar(u8, raw, escape_byte); if (detaching) |cut| raw = raw[0..cut]; const send = mouse.feed(raw, &to_board); if (send > 0) port.write(to_board[0..send]) catch {}; if (detaching != null) { // Anything still held belongs to the board before we go. const tail = mouse.flushHeld(&to_board); if (tail > 0) port.write(to_board[0..tail]) catch {}; if (opts.banner) stdout.writeStreamingAll(io, "\r\n[detached]\r\n") catch {}; return; } } // The coalescing window. A held motion report goes out when the interval has elapsed, which // is what turns a drag into a bounded stream of positions rather than one per cell crossed. if (mouse.pending()) { const at = held_at orelse port.nowMs(); held_at = at; if (port.nowMs() - at >= coalesce_ms) { const send = mouse.flushHeld(&to_board); if (send > 0) port.write(to_board[0..send]) catch {}; held_at = null; } } else held_at = null; } } 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)); } // ------------------------------------------------------------------------------- the mouse filter test "MouseFilter passes an ordinary keystroke straight through" { var m: MouseFilter = .{}; var out: [64]u8 = undefined; const n = m.feed("hello", &out); try std.testing.expectEqualStrings("hello", out[0..n]); try std.testing.expect(!m.pending()); } test "MouseFilter never holds a press, a release or a wheel" { var m: MouseFilter = .{}; var out: [64]u8 = undefined; // 0 = left press, 0 with 'm' = release, 64/65 = wheel up/down. All discrete: dropping one loses // a click or a scroll notch, so none of them may be coalesced. for ([_][]const u8{ "\x1b[<0;10;5M", "\x1b[<0;10;5m", "\x1b[<64;10;5M", "\x1b[<65;10;5M" }) |report| { const n = m.feed(report, &out); try std.testing.expectEqualStrings(report, out[0..n]); try std.testing.expect(!m.pending()); } } test "MouseFilter keeps only the newest drag position" { var m: MouseFilter = .{}; var out: [64]u8 = undefined; // 32 = motion with the left button held: a drag. Three cells crossed in one read. const n = m.feed("\x1b[<32;10;5M\x1b[<32;11;5M\x1b[<32;12;5M", &out); try std.testing.expectEqual(@as(usize, 0), n); // nothing goes out yet try std.testing.expect(m.pending()); try std.testing.expectEqual(@as(usize, 2), m.dropped); const flushed = m.flushHeld(&out); try std.testing.expectEqualStrings("\x1b[<32;12;5M", out[0..flushed]); } test "MouseFilter releases a held drag before anything that follows it" { var m: MouseFilter = .{}; var out: [64]u8 = undefined; // The release must not overtake the motion that preceded it, or the editor ends a selection at // the wrong cell. const n = m.feed("\x1b[<32;10;5M\x1b[<0;12;5m", &out); try std.testing.expectEqualStrings("\x1b[<32;10;5M\x1b[<0;12;5m", out[0..n]); try std.testing.expect(!m.pending()); } test "MouseFilter holds a drag across a read boundary" { var m: MouseFilter = .{}; var out: [64]u8 = undefined; // A report split by the read: neither half may reach the board as loose bytes. try std.testing.expectEqual(@as(usize, 0), m.feed("\x1b[<32;10", &out)); try std.testing.expectEqual(@as(usize, 0), m.feed(";5M", &out)); try std.testing.expect(m.pending()); const flushed = m.flushHeld(&out); try std.testing.expectEqualStrings("\x1b[<32;10;5M", out[0..flushed]); } test "MouseFilter passes a non-mouse escape sequence through unchanged" { var m: MouseFilter = .{}; var out: [64]u8 = undefined; // An arrow key and an in-band resize report both start with ESC and must survive intact. const n = m.feed("\x1b[A\x1b[48;12;40;0;0t", &out); try std.testing.expectEqualStrings("\x1b[A\x1b[48;12;40;0;0t", out[0..n]); try std.testing.expect(!m.pending()); } test "MouseFilter does not swallow a lone escape" { var m: MouseFilter = .{}; var out: [64]u8 = undefined; // Esc is how you leave insert mode; holding it would be the worst possible bug here. _ = m.feed("\x1b", &out); const n = m.feed("x", &out); try std.testing.expectEqualStrings("\x1bx", out[0..n]); }