summaryrefslogtreecommitdiff
path: root/tools/serial.zig
blob: 7a740cdfc7c64b089e5a9aefd0f31a1fc56a964a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
//! 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();
    }
};