From ebe4e37ff08cf5ca56116cbe345b37b76da4d785 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 10 Oct 2026 16:19:50 -0500 Subject: [PATCH] Add a backend for macOS and the BSDs One backend on the 4.4BSD libc termios covers macOS, FreeBSD, NetBSD, OpenBSD and DragonFly. A speed_t there is the rate itself, so any rate is asked for by number; on macOS a rate the driver refuses through tcsetattr is retried through IOSSIOSPEED. Mark and space parity have no CMSPAR to express them and are refused. The module links libc on these targets, and `zig build check` now compiles the tests for each of them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012fcrFCsAeNziaz3Y5QR8E2 --- README.md | 38 ++- build.zig | 55 +++-- package.nix | 2 +- src/bsd.zig | 659 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/root.zig | 16 +- src/types.zig | 17 +- 6 files changed, 743 insertions(+), 44 deletions(-) create mode 100644 src/bsd.zig 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, -- knotmirror 0.0.1