diff --git a/README.md b/README.md index 5198729..e8c76b9 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ The API documentation is generated from the doc comments and published at - **Modem lines and line control.** `modem` reads the modem lines and `setLine` raises or lowers DTR and RTS. `sendBreak`, `drain` and `flush` do what their names say. -- **Exclusive by default.** On Linux an advisory `flock` and `TIOCEXCL` keep a - second program off the port; on Windows a port is always exclusive. +- **Exclusive by default.** On Linux, macOS and the BSDs an advisory `flock` + and `TIOCEXCL` keep a second program off the port; on Windows a port is + always exclusive. - **Non-blocking by default.** `read` and `write` return `error.WouldBlock` rather than waiting, which is what an event loop polling `Port.handle` wants. Pass `.nonblocking = false` for a thread that does nothing but read. @@ -57,22 +58,31 @@ The API documentation is generated from the doc comments and published at be read back on Windows, so `modem` reports them as this port last set them. This backend is compiled by `zig build check` but has not been run against hardware. -- **The BSDs and macOS** have no backend yet. They want one on their libc - `termios`, with `IOSSIOSPEED` on macOS for arbitrary rates. +- **macOS, FreeBSD, NetBSD, OpenBSD and DragonFly.** One backend, on the libc + `termios` all five inherit from 4.4BSD, so a program for these links libc; + the module asks for it on its own. A `speed_t` there is the rate itself, so + any rate is asked for by number. A macOS driver that refuses a rate through + `tcsetattr` is asked again through `IOSSIOSPEED`, which takes two calls + rather than one. There is no `CMSPAR`, so mark and space parity are refused. + On macOS, open the `/dev/cu.*` node rather than the dial-in `/dev/tty.*`. + This backend is compiled by `zig build check` and its tests link for every + one of these but DragonFly, whose libc Zig does not ship; none of it has yet + been run on any of them. ## Testing ```console $ nix develop -c zig build test --summary all -$ nix develop -c zig build check # Windows and aarch64-linux, compiled only +$ nix develop -c zig build check # every other platform, compiled only ``` -The tests use pseudo-terminals in place of a cable. A pty cannot test -everything, because it always reports eight data bits and no parity whatever it -is told. So the conversion between `Settings` and `termios2` is tested on its -own, in memory, and the pty tests cover what a pty does keep: the rate, the -stop bits, flow control, raw data in both directions, exclusivity, and the -restoring of the original settings on close. +The tests use pseudo-terminals in place of a cable, on Linux and on macOS and +the BSDs alike. A pty cannot test everything, because it always reports eight +data bits and no parity whatever it is told. So the conversion between +`Settings` and each platform's `termios` is tested on its own, in memory, and +the pty tests cover what a pty does keep: the rate, the stop bits, flow +control, raw data in both directions, exclusivity, and the restoring of the +original settings on close. ## Where this came from @@ -112,6 +122,12 @@ MIT; see [LICENSES/MIT.txt](LICENSES/MIT.txt). The project follows the - The Linux man-pages project. *termios(3) — get and set terminal attributes, line control, get and set baud rate*. Linux manual pages. +- Apple Inc. *ioss.h — IOKit serial ioctls* (IOSerialFamily). Apple Open + Source. +- FreeBSD Project. *termios(4) — general terminal line discipline*. FreeBSD + manual pages. +- FreeBSD Project. *tty(4) — general terminal interface*. FreeBSD manual + pages. - Microsoft. *COMMTIMEOUTS structure (winbase.h)*. Microsoft Learn. - Microsoft. *DCB structure (winbase.h)*. Microsoft Learn. diff --git a/build.zig b/build.zig index 6d951d5..62722cb 100644 --- a/build.zig +++ b/build.zig @@ -11,6 +11,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize, + .link_libc = needsLibC(target), }); const test_step = b.step("test", "Run tests"); @@ -22,24 +23,32 @@ pub fn build(b: *std.Build) void { const check_step = b.step("check", "Compile everything without running it"); - // Windows cannot be tested from here, but it can be compiled, and a - // backend nobody compiles is a backend that has stopped compiling. - const windows_lib = b.addTest(.{ - .name = "serial-windows", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/root.zig"), - .target = b.resolveTargetQuery(.{ .cpu_arch = .x86_64, .os_tag = .windows }), - .optimize = optimize, - }), - }); - check_step.dependOn(&windows_lib.step); - check_step.dependOn(&b.addTest(.{ - .root_module = b.createModule(.{ - .root_source_file = b.path("src/root.zig"), - .target = b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = .linux }), - .optimize = optimize, - }), - }).step); + // The other platforms cannot be tested from here, but they can be + // compiled, and a backend nobody compiles is a backend that has stopped + // compiling. Nothing asks for the binaries, so none is linked -- which is + // what lets DragonFly, whose libc Zig does not ship, be checked at all. + for ([_]std.Target.Query{ + .{ .cpu_arch = .x86_64, .os_tag = .windows }, + .{ .cpu_arch = .aarch64, .os_tag = .linux }, + .{ .cpu_arch = .x86_64, .os_tag = .macos }, + .{ .cpu_arch = .aarch64, .os_tag = .macos }, + .{ .cpu_arch = .x86_64, .os_tag = .freebsd }, + .{ .cpu_arch = .aarch64, .os_tag = .freebsd }, + .{ .cpu_arch = .x86_64, .os_tag = .netbsd }, + .{ .cpu_arch = .x86_64, .os_tag = .openbsd }, + .{ .cpu_arch = .x86_64, .os_tag = .dragonfly }, + }) |query| { + const other = b.resolveTargetQuery(query); + check_step.dependOn(&b.addTest(.{ + .name = b.fmt("serial-{s}-{s}", .{ @tagName(query.cpu_arch.?), @tagName(query.os_tag.?) }), + .root_module = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = other, + .optimize = optimize, + .link_libc = needsLibC(other), + }), + }).step); + } // -- documentation ------------------------------------------------------- // @@ -86,3 +95,13 @@ pub fn build(b: *std.Build) void { ).step); check_step.dependOn(&docs_server.step); } + +/// The Linux backend is raw system calls and the Windows one is kernel32, but +/// macOS and the BSDs are reached through their libc, which is the only +/// interface to the terminal those systems promise to keep stable. +fn needsLibC(target: std.Build.ResolvedTarget) ?bool { + return switch (target.result.os.tag) { + .linux, .windows => null, + else => true, + }; +} diff --git a/package.nix b/package.nix index d0a4979..199083a 100644 --- a/package.nix +++ b/package.nix @@ -39,6 +39,6 @@ stdenv.mkDerivation { description = "Serial ports for Zig"; homepage = "https://git.jcollie.dev/jeff/zig-serial"; license = lib.licenses.mit; - platforms = lib.platforms.linux ++ lib.platforms.windows; + platforms = lib.platforms.unix ++ lib.platforms.windows; }; } diff --git a/src/bsd.zig b/src/bsd.zig new file mode 100644 index 0000000..178a443 --- /dev/null +++ b/src/bsd.zig @@ -0,0 +1,659 @@ +// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie +// SPDX-License-Identifier: MIT + +//! The backend for macOS and the BSDs — FreeBSD, NetBSD, OpenBSD and +//! DragonFly — on their libc `termios`. +//! +//! All five inherit 4.4BSD's terminal interface, so one file covers them, +//! and the few places they part company are switched on below. The useful +//! inheritance is that a `speed_t` *is* the rate: `B9600` is 9600, so any +//! rate can be written into `c_ospeed` by number and it is up to the driver +//! whether it can produce it. FreeBSD's `uart(4)` divides down to whatever +//! is asked; macOS's serial drivers refuse a rate outside the traditional +//! table through `tcsetattr`, and take one through the `IOSSIOSPEED` ioctl +//! instead, which `configure` falls back to. +//! +//! Three things differ from Linux in ways a caller can see: +//! +//! * **No mark or space parity.** There is no `CMSPAR`, so those are +//! refused as `error.UnsupportedSetting`, as one and a half stop bits +//! are. +//! * **On macOS, use `/dev/cu.*` rather than `/dev/tty.*`.** The `tty` +//! node is the dial-in side, which waits for carrier before it lets +//! anyone else in. A `Port` opens non-blocking, so it will not hang +//! there, but the `cu` node is the one meant for calling out. +//! * **A pseudo-terminal has modem lines, of a sort.** FreeBSD answers +//! `TIOCMGET` on a pty with a fixed set of lines rather than refusing +//! it, so `modem` working is no proof that a cable is attached. +//! +//! The structure and the constants are written out here rather than taken +//! from `std.c`, whose `speed_t` is an exhaustive enum with no room for a +//! rate like 31250, and whose `ioctl` takes its request as a signed `int` +//! where these systems want an `unsigned long`. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = std.c; + +const types = @import("types.zig"); +const Settings = types.Settings; + +pub const Handle = c.fd_t; + +const os = builtin.target.os.tag; +const is_darwin = os.isDarwin(); + +comptime { + switch (os) { + .freebsd, .netbsd, .openbsd, .dragonfly => {}, + else => if (!is_darwin) @compileError("the BSD backend does not know " ++ @tagName(os)), + } +} + +/// `tcflag_t` and `speed_t`: an `unsigned long` on Darwin, an `unsigned int` +/// on the BSDs. +const Flag = if (is_darwin) c_ulong else c_uint; +const Speed = Flag; + +/// `struct termios` from ``, the same shape on all five. +const Termios = extern struct { + iflag: Flag, + oflag: Flag, + cflag: Flag, + lflag: Flag, + cc: [20]u8, + ispeed: Speed, + ospeed: Speed, +}; + +// `c_iflag` +const IGNBRK: Flag = 0x1; +const BRKINT: Flag = 0x2; +const IGNPAR: Flag = 0x4; +const PARMRK: Flag = 0x8; +const INPCK: Flag = 0x10; +const ISTRIP: Flag = 0x20; +const INLCR: Flag = 0x40; +const IGNCR: Flag = 0x80; +const ICRNL: Flag = 0x100; +const IXON: Flag = 0x200; +const IXOFF: Flag = 0x400; +const IXANY: Flag = 0x800; +/// OpenBSD alone kept the upper-to-lower-case mapping. +const IUCLC: Flag = if (os == .openbsd) 0x1000 else 0; +const IMAXBEL: Flag = 0x2000; + +// `c_oflag` +const OPOST: Flag = 0x1; + +// `c_cflag` +const CSIZE: Flag = 0x300; +const CS5: Flag = 0x000; +const CS6: Flag = 0x100; +const CS7: Flag = 0x200; +const CS8: Flag = 0x300; +const CSTOPB: Flag = 0x400; +const CREAD: Flag = 0x800; +const PARENB: Flag = 0x1000; +const PARODD: Flag = 0x2000; +const HUPCL: Flag = 0x4000; +const CLOCAL: Flag = 0x8000; +/// Two bits on Darwin, FreeBSD and DragonFly, `CCTS_OFLOW | CRTS_IFLOW`, +/// which can be set apart; one bit on NetBSD and OpenBSD, where those two +/// names are both aliases for it. +const CRTSCTS: Flag = switch (os) { + .netbsd, .openbsd => 0x10000, + else => 0x30000, +}; + +// `c_lflag` +const ECHOE: Flag = 0x2; +const ECHOK: Flag = 0x4; +const ECHO: Flag = 0x8; +const ECHONL: Flag = 0x10; +const ISIG: Flag = 0x80; +const ICANON: Flag = 0x100; +const IEXTEN: Flag = 0x400; + +// `c_cc` indices +const VSTART = 12; +const VSTOP = 13; +const VMIN = 16; +const VTIME = 17; + +// `tcsetattr` actions and `tcflush` queues +const TCSANOW = 0; +const TCIFLUSH = 1; +const TCOFLUSH = 2; +const TCIOFLUSH = 3; + +// `flock` operations +const LOCK_EX = 2; +const LOCK_NB = 4; + +// The ioctls, from ``, which every one of these encodes the +// same way. +const IOC_VOID: c_ulong = 0x20000000; +const IOC_OUT: c_ulong = 0x40000000; +const IOC_IN: c_ulong = 0x80000000; + +fn ioc(inout: c_ulong, group: u8, num: u8, len: usize) c_ulong { + return inout | ((@as(c_ulong, len) & 0x1fff) << 16) | (@as(c_ulong, group) << 8) | num; +} + +const TIOCEXCL = ioc(IOC_VOID, 't', 13, 0); +const TIOCNXCL = ioc(IOC_VOID, 't', 14, 0); +const TIOCMGET = ioc(IOC_OUT, 't', 106, @sizeOf(c_int)); +const TIOCMBIC = ioc(IOC_IN, 't', 107, @sizeOf(c_int)); +const TIOCMBIS = ioc(IOC_IN, 't', 108, @sizeOf(c_int)); +/// Darwin's `IOSSIOSPEED`, from ``: set both rates to +/// the `speed_t` pointed at, whatever it is. +const IOSSIOSPEED = ioc(IOC_IN, 'T', 2, @sizeOf(Speed)); + +// `TIOCMGET` bits, the same as Linux's. +const TIOCM_DTR = 0x002; +const TIOCM_RTS = 0x004; +const TIOCM_CTS = 0x020; +const TIOCM_CAR = 0x040; +const TIOCM_RNG = 0x080; +const TIOCM_DSR = 0x100; + +extern "c" fn ioctl(fd: Handle, request: c_ulong, ...) c_int; +extern "c" fn tcgetattr(fd: Handle, t: *Termios) c_int; +extern "c" fn tcsetattr(fd: Handle, action: c_int, t: *const Termios) c_int; +extern "c" fn tcdrain(fd: Handle) c_int; +extern "c" fn tcflush(fd: Handle, queue: c_int) c_int; +extern "c" fn tcsendbreak(fd: Handle, duration: c_int) c_int; + +/// Writes `settings` and raw mode into `t`, leaving alone what neither +/// touches. Kept apart from the libc calls so that it can be tested without +/// a device, for the same reason as on Linux: a pseudo-terminal does not +/// keep everything it is told. +fn encode(t: *Termios, settings: Settings) types.ConfigureError!void { + // Raw mode, as cfmakeraw(3) describes it. + t.iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IUCLC | IMAXBEL); + t.oflag &= ~OPOST; + t.lflag &= ~(ECHO | ECHOE | ECHOK | ECHONL | ICANON | ISIG | IEXTEN); + t.cflag |= CREAD; + // A read returns as soon as there is one byte, and not before. + t.cc[VMIN] = 1; + t.cc[VTIME] = 0; + + // cfsetspeed(3) is an assignment on every one of these systems. + t.ospeed = settings.baud; + t.ispeed = settings.baud; + + t.cflag &= ~CSIZE; + t.cflag |= switch (settings.data_bits) { + .five => CS5, + .six => CS6, + .seven => CS7, + .eight => CS8, + }; + + t.cflag &= ~(PARENB | PARODD); + t.cflag |= switch (settings.parity) { + .none => 0, + .even => PARENB, + .odd => PARENB | PARODD, + .mark, .space => return error.UnsupportedSetting, + }; + // As on Linux: check what arrives when there is a bit to check, and let + // a character that fails read as NUL. + if (settings.parity == .none) { + t.iflag &= ~(INPCK | IGNPAR); + } else { + t.iflag |= INPCK; + t.iflag &= ~IGNPAR; + } + + switch (settings.stop_bits) { + .one => t.cflag &= ~CSTOPB, + .two => t.cflag |= CSTOPB, + .one_and_half => return error.UnsupportedSetting, + } + + t.cflag &= ~CRTSCTS; + t.iflag &= ~(IXON | IXOFF | IXANY); + switch (settings.flow) { + .none => {}, + .rts_cts => t.cflag |= CRTSCTS, + .xon_xoff => { + t.iflag |= IXON | IXOFF; + t.cc[VSTART] = 0x11; + t.cc[VSTOP] = 0x13; + }, + .other => return error.UnsupportedSetting, + } + + if (settings.hang_up_on_close) t.cflag |= HUPCL else t.cflag &= ~HUPCL; + if (settings.local) t.cflag |= CLOCAL else t.cflag &= ~CLOCAL; +} + +fn decode(t: Termios) Settings { + const rtscts = t.cflag & CRTSCTS; + const ixon = t.iflag & IXON != 0; + const ixoff = t.iflag & IXOFF != 0; + return .{ + .baud = std.math.cast(u32, t.ospeed) orelse std.math.maxInt(u32), + .data_bits = switch (t.cflag & CSIZE) { + CS5 => .five, + CS6 => .six, + CS7 => .seven, + else => .eight, + }, + .parity = if (t.cflag & PARENB == 0) + .none + else if (t.cflag & PARODD != 0) + .odd + else + .even, + .stop_bits = if (t.cflag & CSTOPB != 0) .two else .one, + // Only one of Darwin's two hardware flow bits — CTS gating output + // without RTS gating input, say — is a combination this library + // does not name. + .flow = if (rtscts == 0 and !ixon and !ixoff) + .none + else if (rtscts == CRTSCTS and !ixon and !ixoff) + .rts_cts + else if (rtscts == 0 and ixon and ixoff) + .xon_xoff + else + .other, + .hang_up_on_close = t.cflag & HUPCL != 0, + .local = t.cflag & CLOCAL != 0, + }; +} + +pub const Port = struct { + handle: Handle, + /// The attributes the device had when it was opened, put back by + /// `close`. + original: Termios, + exclusive: bool, + + pub fn open(path: []const u8, settings: Settings, options: types.OpenOptions) types.OpenError!Port { + var path_buf: [c.PATH_MAX]u8 = undefined; + if (path.len >= path_buf.len) return error.NameTooLong; + @memcpy(path_buf[0..path.len], path); + path_buf[path.len] = 0; + + // Non-blocking even when the caller wants blocking I/O, for the same + // reason as on Linux: without CLOCAL an open waits for carrier, and + // that is the dial-in `/dev/tty.*` node's whole purpose on macOS. + const fd = c.open(@ptrCast(&path_buf), .{ + .ACCMODE = .RDWR, + .NOCTTY = true, + .NONBLOCK = true, + .CLOEXEC = true, + }); + switch (c.errno(fd)) { + .SUCCESS => {}, + .NOENT, .NXIO, .NODEV => return error.FileNotFound, + .ACCES, .PERM, .ROFS => return error.AccessDenied, + .BUSY => return error.Busy, + .ISDIR => return error.NotASerialPort, + .NAMETOOLONG => return error.NameTooLong, + .MFILE, .NFILE, .NOMEM => return error.SystemResources, + else => return error.Unexpected, + } + errdefer _ = c.close(fd); + + var original: Termios = undefined; + switch (c.errno(tcgetattr(fd, &original))) { + .SUCCESS => {}, + .NOTTY, .INVAL, .OPNOTSUPP => return error.NotASerialPort, + .IO => return error.InputOutput, + else => return error.Unexpected, + } + + if (options.exclusive) { + // As on Linux: the advisory lock for anything that asks, and + // TIOCEXCL for everything else short of root. + switch (c.errno(c.flock(fd, LOCK_EX | LOCK_NB))) { + .SUCCESS => {}, + .AGAIN => return error.Busy, + else => return error.Unexpected, + } + _ = ioctl(fd, TIOCEXCL); + } + + var port: Port = .{ .handle = fd, .original = original, .exclusive = options.exclusive }; + try port.configure(settings); + + if (!options.nonblocking) { + const flags = c.fcntl(fd, c.F.GETFL); + if (c.errno(flags) != .SUCCESS) return error.Unexpected; + var o: c.O = @bitCast(@as(u32, @bitCast(flags))); + o.NONBLOCK = false; + if (c.errno(c.fcntl(fd, c.F.SETFL, @as(c_int, @bitCast(o)))) != .SUCCESS) + return error.Unexpected; + } + + return port; + } + + /// Puts the device's original settings back and closes it. + /// + /// `TCSANOW` rather than the draining `TCSADRAIN`, for the reason given + /// on Linux: a drain that waits on a far end that is not there never + /// finishes. + pub fn close(port: *Port) void { + _ = tcsetattr(port.handle, TCSANOW, &port.original); + if (port.exclusive) _ = ioctl(port.handle, TIOCNXCL); + _ = c.close(port.handle); + port.* = undefined; + } + + /// Applies `settings`, along with raw mode, in one `tcsetattr`, so the + /// line never runs at a mixture of the old configuration and the new. + /// + /// On macOS, a rate the driver refuses through `tcsetattr` is tried + /// again through `IOSSIOSPEED`. That takes two calls: everything else is + /// applied first at the rate the port already had, and then the rate. + pub fn configure(port: *Port, settings: Settings) types.ConfigureError!void { + var t = try port.get(); + const old_ispeed = t.ispeed; + const old_ospeed = t.ospeed; + try encode(&t, settings); + switch (c.errno(tcsetattr(port.handle, TCSANOW, &t))) { + .SUCCESS => return, + .INVAL => if (!is_darwin) return error.UnsupportedBaud, + .IO => return error.InputOutput, + else => return error.Unexpected, + } + + t.ispeed = old_ispeed; + t.ospeed = old_ospeed; + switch (c.errno(tcsetattr(port.handle, TCSANOW, &t))) { + .SUCCESS => {}, + // The rate was not what it objected to after all. + .INVAL => return error.UnsupportedSetting, + .IO => return error.InputOutput, + else => return error.Unexpected, + } + var speed: Speed = settings.baud; + switch (c.errno(ioctl(port.handle, IOSSIOSPEED, &speed))) { + .SUCCESS => {}, + .INVAL, .NOTTY => return error.UnsupportedBaud, + .IO => return error.InputOutput, + else => return error.Unexpected, + } + } + + /// The settings as the driver reports them, which is not necessarily what + /// was asked for: a driver may round a rate, and is free to ignore what + /// it does not support. + pub fn current(port: *Port) types.ConfigureError!Settings { + return decode(try port.get()); + } + + fn get(port: *Port) types.ConfigureError!Termios { + var t: Termios = undefined; + return switch (c.errno(tcgetattr(port.handle, &t))) { + .SUCCESS => t, + .IO => error.InputOutput, + else => error.Unexpected, + }; + } + + pub fn read(port: *Port, buf: []u8) types.ReadError!usize { + while (true) { + const rc = c.read(port.handle, buf.ptr, buf.len); + return switch (c.errno(rc)) { + .SUCCESS => @intCast(rc), + .INTR => continue, + .AGAIN => error.WouldBlock, + .IO => error.InputOutput, + else => error.Unexpected, + }; + } + } + + pub fn write(port: *Port, bytes: []const u8) types.WriteError!usize { + while (true) { + const rc = c.write(port.handle, bytes.ptr, bytes.len); + return switch (c.errno(rc)) { + .SUCCESS => @intCast(rc), + .INTR => continue, + .AGAIN => error.WouldBlock, + .IO => error.InputOutput, + else => error.Unexpected, + }; + } + } + + /// Waits until everything written has left the driver's buffer, which + /// is not quite the same as having left the UART. + pub fn drain(port: *Port) types.WriteError!void { + while (true) { + return switch (c.errno(tcdrain(port.handle))) { + .SUCCESS => {}, + .INTR => continue, + .IO => error.InputOutput, + else => error.Unexpected, + }; + } + } + + pub fn flush(port: *Port, queue: types.Queue) types.ConfigureError!void { + const arg: c_int = switch (queue) { + .input => TCIFLUSH, + .output => TCOFLUSH, + .both => TCIOFLUSH, + }; + return switch (c.errno(tcflush(port.handle, arg))) { + .SUCCESS => {}, + .IO => error.InputOutput, + else => error.Unexpected, + }; + } + + /// Holds the line at space for about four tenths of a second, which is + /// what these systems' `tcsendbreak` does whatever duration it is given. + pub fn sendBreak(port: *Port) types.WriteError!void { + return switch (c.errno(tcsendbreak(port.handle, 0))) { + .SUCCESS => {}, + .IO => error.InputOutput, + else => error.Unexpected, + }; + } + + pub fn modem(port: *Port) types.LineError!types.Modem { + var bits: c_int = 0; + return switch (c.errno(ioctl(port.handle, TIOCMGET, &bits))) { + .SUCCESS => .{ + .dtr = bits & TIOCM_DTR != 0, + .rts = bits & TIOCM_RTS != 0, + .cts = bits & TIOCM_CTS != 0, + .dsr = bits & TIOCM_DSR != 0, + .dcd = bits & TIOCM_CAR != 0, + .ri = bits & TIOCM_RNG != 0, + }, + .NOTTY, .INVAL, .OPNOTSUPP => error.Unsupported, + .IO => error.InputOutput, + else => error.Unexpected, + }; + } + + pub fn setLine(port: *Port, line: types.Line, on: bool) types.LineError!void { + const bits: c_int = switch (line) { + .dtr => TIOCM_DTR, + .rts => TIOCM_RTS, + }; + return switch (c.errno(ioctl(port.handle, if (on) TIOCMBIS else TIOCMBIC, &bits))) { + .SUCCESS => {}, + .NOTTY, .INVAL, .OPNOTSUPP => error.Unsupported, + .IO => error.InputOutput, + else => error.Unexpected, + }; + } +}; + +// --------------------------------------------------------------------------- +// Tests, against a pseudo-terminal, as on Linux. +// --------------------------------------------------------------------------- + +const testing = std.testing; + +extern "c" fn posix_openpt(oflag: c_int) c_int; +extern "c" fn grantpt(fd: Handle) c_int; +extern "c" fn unlockpt(fd: Handle) c_int; +extern "c" fn ptsname(fd: Handle) ?[*:0]const u8; + +const PtyPair = struct { + master: Handle, + path_buf: [64]u8, + path_len: usize, + + fn open() !PtyPair { + const flags: c.O = .{ .ACCMODE = .RDWR, .NOCTTY = true }; + const master = posix_openpt(@bitCast(flags)); + if (master < 0) return error.SkipZigTest; + errdefer _ = c.close(master); + if (grantpt(master) != 0 or unlockpt(master) != 0) return error.SkipZigTest; + const name = std.mem.span(ptsname(master) orelse return error.SkipZigTest); + var pair: PtyPair = .{ .master = master, .path_buf = undefined, .path_len = name.len }; + if (name.len > pair.path_buf.len) return error.SkipZigTest; + @memcpy(pair.path_buf[0..name.len], name); + return pair; + } + + fn path(pair: *const PtyPair) []const u8 { + return pair.path_buf[0..pair.path_len]; + } + + fn close(pair: *PtyPair) void { + _ = c.close(pair.master); + } +}; + +test "every setting survives encoding and decoding" { + const cases = [_]Settings{ + .{}, + .{ .baud = 9600, .data_bits = .seven, .parity = .even, .stop_bits = .two }, + .{ .baud = 300, .data_bits = .five, .parity = .odd, .flow = .rts_cts, .hang_up_on_close = false }, + .{ .baud = 31250, .data_bits = .six, .flow = .xon_xoff, .local = false }, + .{ .baud = 4_000_000 }, + }; + for (cases) |want| { + var t = std.mem.zeroes(Termios); + try encode(&t, want); + try testing.expectEqual(want, decode(t)); + // Raw: nothing that would edit, echo or translate. + try testing.expectEqual(@as(Flag, 0), t.lflag & (ICANON | ECHO | ISIG | IEXTEN)); + try testing.expectEqual(@as(Flag, 0), t.oflag & OPOST); + try testing.expectEqual(@as(Flag, 0), t.iflag & (ICRNL | INLCR | IGNCR | ISTRIP)); + try testing.expectEqual(@as(u8, 1), t.cc[VMIN]); + } +} + +test "what cannot be expressed is refused" { + var t = std.mem.zeroes(Termios); + try testing.expectError(error.UnsupportedSetting, encode(&t, .{ .stop_bits = .one_and_half })); + try testing.expectError(error.UnsupportedSetting, encode(&t, .{ .parity = .mark })); + try testing.expectError(error.UnsupportedSetting, encode(&t, .{ .parity = .space })); + try testing.expectError(error.UnsupportedSetting, encode(&t, .{ .flow = .other })); +} + +test "half of Darwin's hardware flow control is a combination with no name" { + if (CRTSCTS == 0x10000) return error.SkipZigTest; + var t = std.mem.zeroes(Termios); + try encode(&t, .{}); + t.cflag |= 0x10000; // CCTS_OFLOW alone + try testing.expectEqual(types.Flow.other, decode(t).flow); +} + +test "a setting a pty keeps survives a round trip through the driver" { + var pair = try PtyPair.open(); + defer pair.close(); + + var port = try Port.open(pair.path(), .{ + .baud = 9600, + .stop_bits = .two, + .flow = .rts_cts, + .hang_up_on_close = false, + }, .{}); + defer port.close(); + + const got = try port.current(); + try testing.expectEqual(@as(u32, 9600), got.baud); + try testing.expectEqual(types.StopBits.two, got.stop_bits); + try testing.expectEqual(types.Flow.rts_cts, got.flow); + try testing.expect(!got.hang_up_on_close); + try testing.expect(got.local); + + try port.configure(.{ .flow = .xon_xoff }); + try testing.expectEqual(types.Flow.xon_xoff, (try port.current()).flow); +} + +test "bytes cross in both directions untranslated" { + var pair = try PtyPair.open(); + defer pair.close(); + + var port = try Port.open(pair.path(), .{}, .{ .nonblocking = false }); + defer port.close(); + + // A CR, an LF and a ^C: each of which a cooked tty would act on. + const out = "a\rb\nc\x03"; + try testing.expectEqual(out.len, try port.write(out)); + var buf: [16]u8 = undefined; + const n = c.read(pair.master, &buf, buf.len); + try testing.expect(n > 0); + try testing.expectEqualStrings(out, buf[0..@intCast(n)]); + + const in = "x\ry\x04"; + _ = c.write(pair.master, in, in.len); + try testing.expectEqualStrings(in, buf[0..try port.read(&buf)]); +} + +test "a non-blocking read with nothing waiting says so" { + var pair = try PtyPair.open(); + defer pair.close(); + + var port = try Port.open(pair.path(), .{}, .{}); + defer port.close(); + var buf: [4]u8 = undefined; + try testing.expectError(error.WouldBlock, port.read(&buf)); +} + +test "an exclusive port refuses a second opener" { + var pair = try PtyPair.open(); + defer pair.close(); + + var port = try Port.open(pair.path(), .{}, .{}); + defer port.close(); + try testing.expectError(error.Busy, Port.open(pair.path(), .{}, .{})); +} + +test "the original settings come back on close" { + var pair = try PtyPair.open(); + defer pair.close(); + + var before: Termios = undefined; + _ = tcgetattr(pair.master, &before); + + var port = try Port.open(pair.path(), .{ .baud = 1200, .parity = .odd }, .{}); + port.close(); + + var after: Termios = undefined; + _ = tcgetattr(pair.master, &after); + try testing.expectEqual(before.cflag, after.cflag); + try testing.expectEqual(before.lflag, after.lflag); + try testing.expectEqual(before.ospeed, after.ospeed); +} + +test "what is not a terminal is refused" { + try testing.expectError(error.NotASerialPort, Port.open("/dev/null", .{}, .{})); + try testing.expectError(error.FileNotFound, Port.open("/dev/does-not-exist", .{}, .{})); +} + +test "a pty's modem lines either answer or say they are not there" { + var pair = try PtyPair.open(); + defer pair.close(); + + var port = try Port.open(pair.path(), .{}, .{}); + defer port.close(); + _ = port.modem() catch |err| try testing.expectEqual(error.Unsupported, err); + port.setLine(.dtr, false) catch |err| try testing.expectEqual(error.Unsupported, err); +} diff --git a/src/root.zig b/src/root.zig index 02ca3b3..f8a3287 100644 --- a/src/root.zig +++ b/src/root.zig @@ -17,7 +17,9 @@ //! //! The backend is chosen at compile time. Linux talks to the kernel directly //! through `termios2`, which is what makes any baud rate expressible, and -//! needs no libc. Windows uses the `DCB` through kernel32. +//! needs no libc. macOS and the BSDs use their libc `termios`, whose speeds +//! are already plain numbers, with `IOSSIOSPEED` for the rates a macOS +//! driver will not take otherwise. Windows uses the `DCB` through kernel32. const builtin = @import("builtin"); @@ -41,14 +43,14 @@ pub const LineError = types.LineError; const backend = switch (builtin.target.os.tag) { .linux => @import("linux.zig"), .windows => @import("windows.zig"), - else => @compileError( - "zig-serial has no backend for " ++ @tagName(builtin.target.os.tag) ++ - " yet: the BSDs and macOS want one on their libc termios, with" ++ - " IOSSIOSPEED for arbitrary rates on macOS", - ), + .freebsd, .netbsd, .openbsd, .dragonfly => @import("bsd.zig"), + else => |os| if (os.isDarwin()) + @import("bsd.zig") + else + @compileError("zig-serial has no backend for " ++ @tagName(os)), }; -/// A file descriptor on Linux, a `HANDLE` on Windows: whatever the platform +/// A file descriptor on Linux, macOS and the BSDs, a `HANDLE` on Windows: whatever the platform /// waits on. An event loop polls `Port.handle` for readability. pub const Handle = backend.Handle; diff --git a/src/types.zig b/src/types.zig index 6df3531..b89514a 100644 --- a/src/types.zig +++ b/src/types.zig @@ -41,16 +41,17 @@ pub const Parity = enum { none, even, odd, - /// The parity bit is always one. + /// The parity bit is always one. Linux and Windows only; macOS and the + /// BSDs have no `CMSPAR` and refuse it as `error.UnsupportedSetting`. mark, - /// The parity bit is always zero. + /// The parity bit is always zero. Linux and Windows only, as `mark`. space, }; pub const StopBits = enum { one, /// Only meaningful with five data bits, and only Windows can ask for it; - /// Linux refuses it as `error.UnsupportedSetting`. + /// everywhere else it is refused as `error.UnsupportedSetting`. one_and_half, two, }; @@ -104,9 +105,10 @@ pub const Queue = enum { pub const OpenOptions = struct { /// Refuse the port if another process has it open through this library, - /// and keep any other process from opening it afterwards. On Linux that - /// is an advisory `flock` plus `TIOCEXCL`; a Windows serial port is - /// always exclusive, so this changes nothing there. + /// and keep any other process from opening it afterwards. On Linux, + /// macOS and the BSDs that is an advisory `flock` plus `TIOCEXCL`; a + /// Windows serial port is always exclusive, so this changes nothing + /// there. exclusive: bool = true, /// Return `error.WouldBlock` from `read` and `write` rather than waiting. /// What an event loop wants; a thread that does nothing but read wants @@ -130,7 +132,8 @@ pub const ConfigureError = error{ /// The driver refused the rate outright. UnsupportedBaud, /// The platform cannot express the setting at all: one and a half stop - /// bits on Linux, or `Flow.other` anywhere. + /// bits anywhere but Windows, mark or space parity on macOS and the BSDs, + /// or `Flow.other` anywhere. UnsupportedSetting, /// The device went away. InputOutput,