Serial port I/O for .NET that does one job well: cancellable, deadline-honouring reads and writes that never
abort spuriously, a bounded open and close, typed failures, and port enumeration with a stable hardware
identity. Namespace SharpAstro.Serial, net10.0, AOT and trim compatible. Written for the astronomy devices
TianWen drives (mounts, focusers, flat panels, filter wheels), where
"it mostly works" is not good enough.
System.IO.Ports.SerialPort async reads are not trustworthy:
- On a CH34x USB bridge (very common on cheap devices) the first
BaseStream.ReadAsyncsucceeds and every later one aborts withERROR_OPERATION_ABORTEDwhile the reply still arrives, so replies land one frame late. - The "async" is a blocking read on a pool thread anyway (dotnet/runtime#28968),
and it ignores
ReadTimeout, so a timeout built fromTask.WhenAnyleaves the read hanging. - A write to a Bluetooth serial port with nobody on the far end never completes and ignores its token.
This library drives the port only through blocking calls, each bounded, and builds every guarantee above them once.
using SharpAstro.Serial;
await using var port = await SerialPorts.OpenAsync("COM3", new SerialSettings(9600) { AssertDtr = true, AssertRts = true });
await port.WriteAsync(":00#"u8.ToArray());
var reply = new byte[32];
var n = await port.ReadTerminatedAsync(reply, "#"u8.ToArray()); // throws SerialTimeoutException, never returns a default- A read completes, or throws
SerialTimeoutExceptionat its deadline (carrying the bytes that did arrive), orOperationCanceledExceptionfor the caller's token, or anotherSerialException. Cancelling never leaves a read pending that could eat the next reply. - Bytes after a reply's terminator are kept for the next read; a reply longer than the buffer is refused
(
SerialFramingException), never truncated. - A write still pending at its deadline marks the port (
HasAbandonedIo); every later write throwsSerialIoAbandonedExceptionat once instead of stranding another thread. - A fault on a port that is no longer enumerated is
SerialPortRemovedException, distinct from a timeout. - Open and close are bounded; a close that cannot finish abandons the handle, not the caller.
- Every exception derives from
SerialException, which derives fromIOException. OpenedAtsays when the open finished: opening resets many boards (every CH340 one), and some firmware saves state on a delay.
SerialLoopback.CreatePair(settings) returns two ports wired to each other in memory: what one writes, the
other reads. They are real ports in every respect but the wire, so a test through them gets the same deadlines,
framing and carry-over as a COM port.
SerialPorts.Enumerate() lists each port with what the OS knows about it: USB vendor and product id, serial
number, device instance id, and the USB socket's location path (Windows device tree; Linux sysfs and
/dev/serial/by-path / by-id). SerialPortInfo.Identity says what a saved configuration can key on:
Identity |
keyed on | survives |
|---|---|---|
Device |
vendor, product, serial number | moving the device to another socket |
Socket |
the USB location path | renames and re-enumeration, but two identical devices swapped between sockets swap identities |
PortName |
the OS name | nothing; a COM name follows the socket, a /dev/ttyUSBn name follows enumeration order |
IdentityKey renders the strongest one as a string (usb:1a86:7523:SERIAL, socket:..., name:COM3).
A Bluetooth serial port on Windows also says what it leads to (SerialPortInfo.Bluetooth): the paired device's address,
its name and its Class of Device as Windows recorded them at pairing (MajorClass), or IsIncoming for Windows' own
incoming port, which nothing dials. That is what tells a paired headset (AudioVideo), whose serial channel takes every
write and answers none, from a serial module (an HC-05 reports Uncategorized), before a probe spends its budget on it.
1.0 is the managed backend (the blocking half of System.IO.Ports); 1.1 adds reads with no deadline
(Timeout.InfiniteTimeSpan, the token alone ends them) and the loopback pair; 1.2 the Bluetooth device behind a port. A native Win32 backend (overlapped I/O
driven correctly) is planned as 2.0, behind the same API. Design notes: docs/plans/serial-lib.md in tianwen.