std.serial

Talking to something on the other end of a serial line: a modem, a GPS receiver, a microcontroller, an industrial sensor on RS-485. Needs device.

import std.io
import std.serial

p = serial.open("/dev/ttyUSB0", 115200)
io.timeout(p, 2000)

serial.flush(p)              # drop whatever was already buffered
io.write(p, "AT\r\n")
serial.drain(p)              # wait for it to actually go out

print(io.read_line(p))
io.close(p)

A port is a stream, so the verbs that read it are the same ones that read a file or a socket: io.read, io.read_exact, io.read_until, io.read_line, io.lines, io.write, io.timeout, io.close, and for chunk in p. There is no new vocabulary, which is why this module is small.

Why not just open it as a file

std.fs will happily open /dev/ttyUSB0, and it mostly does not work. Without termios setup the port keeps whatever baud rate, parity and line discipline it was left in by whoever used it last, canonical mode buffers until a newline and rewrites CR and LF on the way past, and there is no read deadline short of waiting forever. A port that appears to open and then silently misbehaves is worse than one that refuses.

serial.open sets the line up and puts it in raw mode, so the bytes on the wire are the bytes you get.

Options

optionvaluesdefault
data_bits5, 6, 7, 88
stop_bits1, 21
parity"none", "even", "odd""none"
flow"none", "rts_cts", "xon_xoff""none"
p = serial.open("/dev/ttyUSB0", 9600, { parity: "even", stop_bits: 2 })

Standard baud rates only. Anything else is refused with the supported set named, because the set is genuinely not the same everywhere - Linux reaches much higher than macOS. Arbitrary rates need termios2 on Linux and IOSSIOSPEED on macOS, two separate mechanisms for a rare need.

Testing without hardware

serial.open("loop://", baud) is an in-memory echo port. What you write comes back when you read. It touches no hardware, so it needs no grant, and it is what makes an offline test suite and a runnable example possible.

p = serial.open("loop://", 115200)
io.write(p, "AT\r\n")
print(io.read_line(p))
io.close(p)

It is a loopback, not a simulator. It cannot show you a baud mismatch, a cable with the wrong pinout, a device that answers late, or framing that goes wrong under load - the failures that actually happen with serial hardware. Treat it the way you treat AI mock mode: it proves your code runs, not that your wiring is right.

serial.flush really does discard its buffer there, because that is what flush means. drain, dtr and rts are accepted and do nothing, so one program runs against both without branching.

Waiting for the wire

call
serial.drain(p)block until buffered writes have actually gone out
serial.flush(p)discard input that arrived and has not been read
serial.dtr(p, on)assert or clear DTR
serial.rts(p, on)assert or clear RTS

dtr and rts drive physical lines. They are how a microcontroller board is auto-reset before flashing, and how an RS-485 transceiver is switched between transmit and receive. Without them the module would be technically complete and practically useless.

Deadlines

io.timeout(p, ms) bounds reads and writes. A read that passes the deadline raises serial: timed out rather than returning null - null means end of stream, and a port that has gone quiet has not ended.

A write passes the deadline when the port stops draining, which is what flow control does on purpose: a peer using rts_cts or xon_xoff can hold the output buffer full for as long as it likes. The error names how far it got - write: timed out after 4096 of 65536 bytes - because those bytes are already on the wire and retrying the whole buffer would send them twice.

With no deadline set, a write waits indefinitely, exactly as a read does.

The deadline has millisecond precision and no ceiling, because it is built on poll rather than the termios VTIME field, which counts deciseconds and saturates at 25.5 seconds. Polling also means a read that is waiting can notice a cancelled task, which most blocking sources cannot.

Which ports exist

serial.ports()    # ["/dev/ttyUSB0", "/dev/ttyACM0"]

Best effort. On Linux it reads /dev/serial/by-id, which survives a replug, and falls back to scanning /sys/class/tty; on macOS it matches /dev/cu.*. An empty list is a real answer, not an error.

The device capability

Opening a port needs device, and it is path-scoped like the fs pair:

"dependencies": {
  "gps": { "path": "github.com/me/gps", "grant": ["device:/dev/ttyUSB0"] }
}

That package can open /dev/ttyUSB0 and nothing else.

It is a separate capability rather than a reuse of fs:read and fs:write, even though a serial port is a file on Unix. Handing a package fs:write is a common and reasonable thing to do, and it must not quietly also mean "may drive any attached hardware". serial.ports() needs device too: which devices exist is information about the machine.

Platform support

Linux and macOS. On Windows, opening a real port reports that serial needs a unix terminal; loop:// works everywhere, so a test suite and an example still run there.