//! POSIX serial port: raw mode, standard baud rates, and the DTR/RTS lines that put an Espressif //! chip into download mode. No libc, no external tool - std.posix for termios, std.os.linux for //! the two modem-control ioctls std does not wrap, std.Io.File for the byte traffic. //! //! On this board DTR drives the boot strap (GPIO35) and RTS drives CHIP_PU through a transistor //! pair, which is why the reset sequence below needs no button press. const std = @import("std"); const posix = std.posix; const linux = std.os.linux; /// Any rate the hardware can divide to, not just the historical enum values: the port is /// configured through termios2, where the baud is a plain integer. The ESP32 ROM loader /// auto-detects the host's rate from the 0x55 pattern in SYNC, so raising this needs no /// CHANGE_BAUDRATE handshake. pub const Baud = enum(u32) { b115200 = 115200, b230400 = 230400, b460800 = 460800, b921600 = 921600, b1500000 = 1500000, b2000000 = 2000000, pub fn rate(b: Baud) u32 { return @intFromEnum(b); } }; /// The remedy for the one error every first run hits, in the module that owns port opening so the /// build steps and `p4-console` cannot drift apart. Deliberately says nothing about the port's /// path: the caller names that on its own first line, which is the only part that differs. /// /// The last case is the one worth spelling out, because it is the one that looks like the fix did /// not work: a shell started before `usermod` never sees the new group, since credentials are /// captured at login and not re-read. pub const access_denied_help = \\ It is owned by a group your session is not in (uucp on Arch, dialout on Debian). \\ \\ If you are NOT in that group yet, join it and log in again: \\ sudo usermod -aG uucp $USER \\ \\ If `id -nG` already lists it, this shell simply predates the change - group membership is \\ captured at login. Either log out and back in, or take it in this shell: \\ newgrp uucp \\ \\ Or run one command with the group, without touching this shell at all: \\ sg uucp -c '...' ; /// `struct termios2` as the Linux kernel defines it: 19 control characters, then the two integer /// baud fields. std's `linux.termios2` uses NCCS = 32, which makes it 60 bytes instead of 44 and /// therefore encodes TCGETS2/TCSETS2 with the wrong size field - the ioctl then fails with ENOTTY. /// Declaring the struct here keeps the numbers right and the intent visible. const Termios2 = extern struct { iflag: u32, oflag: u32, cflag: u32, lflag: u32, line: u8, cc: [19]u8, ispeed: u32, ospeed: u32, /// _IOR('T', 0x2A, struct termios2) and _IOW('T', 0x2B, struct termios2) for a 44-byte struct. const TCGETS2: u32 = 0x802C542A; const TCSETS2: u32 = 0x402C542B; /// _IOW('T', 0x2C, ...): TCSETS2's draining sibling. Changing the divisor while bytes are /// still in the kernel's output queue sends the tail of the old line at the new rate, which on /// a mid-session baud switch corrupts exactly the handshake line the switch was announced by. const TCSETSW2: u32 = 0x402C542C; /// CBAUD escape meaning "take the rate from ispeed/ospeed" (asm-generic/termbits.h). const BOTHER: u32 = 0o010000; const CBAUD: u32 = 0o010017; const CS8: u32 = 0o000060; const CREAD: u32 = 0o000200; const CLOCAL: u32 = 0o004000; }; pub const Port = struct { file: std.Io.File, io: std.Io, saved: Termios2, /// The rate currently programmed, kept because the wire's capacity in bytes per second is /// `rate/10` and anything measuring this link against its ceiling needs that number. The /// kernel would answer a TCGETS2, but a syscall per sample to re-read a value only this file /// ever changes is worse than a field. baud: Baud, // std exposes neither these ioctl numbers nor the TIOCM bits. const TIOCEXCL = 0x540C; const TCFLSH = 0x540B; /// tcdrain, with a nonzero argument. Zero would transmit a break instead. const TCSBRK = 0x5409; const TCIFLUSH = 0; const TIOCMGET = 0x5415; const TIOCMSET = 0x5418; const DTR: u32 = 0x002; const RTS: u32 = 0x004; pub fn open(path: []const u8, baud: Baud) !Port { const fd = try posix.openat(posix.AT.FDCWD, path, .{ .ACCMODE = .RDWR, .NOCTTY = true, .CLOEXEC = true, }, 0); const file: std.Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; const io = std.Io.Threaded.global_single_threaded.io(); errdefer file.close(io); // Configure through termios2: it is the only interface whose baud fields the kernel // populates. `tcsetattr` uses TCSETS, whose struct has no ispeed/ospeed, so assigning // those fields silently does nothing and leaves the port at whatever rate it had - which // is exactly the bug that made a 1.5 KB flash take 230 ms instead of 60. var saved: Termios2 = undefined; if (@as(isize, @bitCast(linux.ioctl(fd, Termios2.TCGETS2, @intFromPtr(&saved)))) < 0) return error.NotATerminal; var raw = saved; // A raw byte pipe: no canonical mode, no echo, no signals, no flow control, no CR/LF // translation, 8N1, CLOCAL so a missing carrier-detect cannot block reads, and the baud // taken from the integer fields. raw.iflag = 0; raw.oflag = 0; raw.lflag = 0; raw.cflag = Termios2.CS8 | Termios2.CREAD | Termios2.CLOCAL | Termios2.BOTHER; raw.ispeed = baud.rate(); raw.ospeed = baud.rate(); @memset(&raw.cc, 0); raw.cc[6] = 0; // VMIN: never block for a minimum count raw.cc[5] = 0; // VTIME: poll() owns the timeouts if (@as(isize, @bitCast(linux.ioctl(fd, Termios2.TCSETS2, @intFromPtr(&raw)))) < 0) return error.SetAttrFailed; // Claim the port exclusively, the way pyserial (and therefore esptool) does: otherwise a // `zig build monitor` left running in another terminal splits the ROM's replies between // two readers, and the failure looks like random SyncFailed. if (@as(isize, @bitCast(linux.ioctl(fd, TIOCEXCL, 0))) < 0) return error.PortBusy; // Drop anything the kernel captured at the previous line rate. _ = linux.ioctl(fd, TCFLSH, TCIFLUSH); return .{ .file = file, .io = io, .saved = saved, .baud = baud }; } /// Re-rate an already-open port, leaving the raw-mode flags and the exclusive claim alone. /// /// This exists because the console and the flasher want different rates on the same wire. The /// ROM loader auto-detects the host's rate from SYNC's 0x55 pattern, so `open` can simply pick /// one; a running application cannot, because its UART divider was programmed by the /// second-stage bootloader and the only way to change it is for the firmware to reprogram its /// own divider and for the host to follow. That makes the switch a two-sided handshake, and /// `TCSETSW2` is the half that has to be ordered: the firmware announces the change on the old /// rate, and the announcement must have physically left the wire before either side moves. pub fn setBaud(p: *Port, baud: Baud) !void { var t: Termios2 = undefined; if (@as(isize, @bitCast(linux.ioctl(p.file.handle, Termios2.TCGETS2, @intFromPtr(&t)))) < 0) return error.NotATerminal; t.cflag = (t.cflag & ~Termios2.CBAUD) | Termios2.BOTHER; t.ispeed = baud.rate(); t.ospeed = baud.rate(); if (@as(isize, @bitCast(linux.ioctl(p.file.handle, Termios2.TCSETSW2, @intFromPtr(&t)))) < 0) return error.SetAttrFailed; p.baud = baud; } /// Bytes per second the wire can carry: one 8N1 byte occupies ten bit times. pub fn capacity(p: *const Port) u32 { return p.baud.rate() / 10; } /// Block until every byte written has physically left the wire - `tcdrain`, spelled as the /// ioctl because std exposes neither. /// /// Distinct from `drain` above in both direction and meaning, which is worth stating because /// getting them the wrong way round silently invalidates a measurement: `drain` discards what /// has ARRIVED, this waits for what is LEAVING. A `write` returns once the kernel has accepted /// the bytes, so timing a transfer to the write measures a memcpy into a tty buffer - at 115200 /// that reported 202% of the wire's capacity, which is how the confusion was noticed. pub fn flushOutput(p: *Port) void { // TCSBRK with a nonzero argument is tcdrain on Linux; with zero it would send a break. _ = linux.ioctl(p.file.handle, TCSBRK, 1); } pub fn close(p: *Port) void { _ = linux.ioctl(p.file.handle, Termios2.TCSETS2, @intFromPtr(&p.saved)); p.file.close(p.io); } pub fn write(p: *Port, bytes: []const u8) !void { try p.file.writeStreamingAll(p.io, bytes); } /// Read whatever is available, waiting at most `timeout_ms`. Returns 0 on timeout. pub fn read(p: *Port, buf: []u8) !usize { var pfd = [_]posix.pollfd{.{ .fd = p.file.handle, .events = posix.POLL.IN, .revents = 0 }}; const ready = try posix.poll(&pfd, 0); if (ready == 0) return 0; return p.file.readStreaming(p.io, &.{buf}) catch |err| switch (err) { error.EndOfStream => 0, else => err, }; } pub fn readTimeout(p: *Port, buf: []u8, timeout_ms: i32) !usize { var pfd = [_]posix.pollfd{.{ .fd = p.file.handle, .events = posix.POLL.IN, .revents = 0 }}; const ready = try posix.poll(&pfd, timeout_ms); if (ready == 0) return 0; return p.file.readStreaming(p.io, &.{buf}) catch |err| switch (err) { error.EndOfStream => 0, else => err, }; } pub fn drain(p: *Port) void { var scratch: [512]u8 = undefined; while (true) { const n = p.read(&scratch) catch return; if (n == 0) return; } } fn setLines(p: *Port, dtr: bool, rts: bool) !void { var flags: u32 = 0; if (linux.ioctl(p.file.handle, TIOCMGET, @intFromPtr(&flags)) != 0) return error.IoctlFailed; flags = if (dtr) flags | DTR else flags & ~DTR; flags = if (rts) flags | RTS else flags & ~RTS; if (linux.ioctl(p.file.handle, TIOCMSET, @intFromPtr(&flags)) != 0) return error.IoctlFailed; } /// How long to hold each phase of the reset sequence. esptool uses 100/50/50 ms; the shorter /// numbers below were measured on this board over repeated runs. Tunable because a different /// carrier's RC network may need longer. pub const ResetTiming = struct { hold_reset_ms: i64 = 40, strap_settle_ms: i64 = 25, release_ms: i64 = 15, }; /// Classic reset into the ROM download loader: hold the boot strap asserted across a reset /// pulse. Both lines are inverted by the board's transistor pair, so an asserted RS-232 line /// pulls its pin low. pub fn resetToDownload(p: *Port, timing: ResetTiming) !void { // Never leave the board held in reset because a modem-line ioctl failed halfway through. errdefer p.setLines(false, false) catch {}; try p.setLines(false, true); // strap released, EN low: held in reset p.sleepMs(timing.hold_reset_ms); try p.setLines(true, false); // strap asserted, EN high: enters the ROM loader p.sleepMs(timing.strap_settle_ms); try p.setLines(false, false); p.sleepMs(timing.release_ms); p.drain(); } /// Reset and let the flashed application run. pub fn resetToRun(p: *Port, timing: ResetTiming) !void { errdefer p.setLines(false, false) catch {}; try p.setLines(false, true); p.sleepMs(timing.hold_reset_ms); try p.setLines(false, false); p.sleepMs(timing.release_ms); } fn sleepMs(p: *Port, ms: i64) void { std.Io.sleep(p.io, .fromMilliseconds(ms), .boot) catch {}; } /// Milliseconds on a monotonic clock, for deadlines. pub fn nowMs(p: *Port) i64 { return std.Io.Timestamp.now(p.io, .boot).toMilliseconds(); } };