- Zig 96.2%
- Nix 3.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The zon2nix README adds -fno-sys for simdutf and highway, and a real copy in place of symlinks for simdutf's headers. Neither applies with SIMD off, which builds neither library; and Zig refusing a fork that matches nothing is what keeps the list honest. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MfnPVdTHsWUXhKnDGxsSAE |
||
| .forgejo/workflows | ||
| examples | ||
| LICENSES | ||
| src | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
zettacom
A terminal program for serial ports.
Bytes from the port go into a real terminal emulator rather than straight at your screen. That is what buys a scrollback you can search, a status line that port output cannot scribble over, and keystrokes spelled for the modes the far end has actually set. File transfers are built in — XMODEM, YMODEM, ZMODEM and Kermit — so nothing is forked, the port never changes hands, and a download can start by itself.
Zig 0.17, and four kinds of system: Linux, Windows, macOS and the BSDs.
Commands are reached with Alt-a and a letter; see
Commands. What each build can and
cannot be said to do is set out under Windows and
macOS and the BSDs.
The interface
A program that hands port output to your terminal never knows what is on the screen, because it never interpreted any of it — which is why such a program can have no scrollback of its own, no search, and nowhere to put a status line.
zettacom interprets it. Bytes from the port go into a real terminal emulator (libghostty-vt) with a screen, a scrollback and styles, and what that emulator holds is drawn with libvaxis under a status bar. Two terminals are in play: the emulated one, which is the far end's idea of a screen, and yours, which zettacom draws on.
The status bar
One line at the bottom that port output cannot reach, saying what the port is and what it is doing:
/dev/ttyUSB0 115200 8N1 none DTR RTS CTS TX RX LOG ZMODEM [Alt-a] for commands
The modem lines name only what is up, so a change is visible rather than
having to be read. TX and RX light red when bytes are moving — every
byte of them, transfers included, since a transfer is when the line is
busiest. A light stays on for a quarter of a second after the last byte,
which is both what makes it long enough to see and what keeps it cheap: the
bar is redrawn when a light changes, not while it is on, so a chatty line
costs a handful of redraws a second rather than one per frame.
--activity-color changes the color; activity_color in the configuration
file says the same.
LOG appears only when something is being recorded, and says which of the
three things it is doing — LOG, LOG-PAUSED or LOG-FAILED. A log nobody
started says nothing, which is the usual case. See
Logging the traffic.
The bar turns a color of its own for the two states where what you type is not reaching the far end — the command prefix and the scrollback — and a transfer takes it over entirely while one runs. See Transfers.
Yours is /dev/tty, not stdin and stdout. libvaxis opens the terminal
this process is attached to, puts it in raw mode, and puts it back on the way
out; the keyboard is read from the same place. So redirecting output does not
stop zettacom from drawing and does not send a screenful of escape sequences
down the pipe.
It says nothing on your terminal. No banner, no settings dump, no "Terminal ready", nothing on the way out — the shell prompt you started from is where you come back to. Everything zettacom has to say goes on the status bar or into the scrollback, where it is next to the port output it is about and can be searched later. Only a failure it cannot recover from is written to your terminal, because by then there is nowhere else.
What it does need is a controlling terminal to open. Without one there is
nothing to draw on and it says so rather than starting. --exit is the
exception, since it draws nothing: it applies the settings, puts the init
string on the wire and leaves, which is the one thing zettacom usefully does
from a script.
Scrollback and search
Alt-a o walks back through what the far end has already said, and so does
rolling the wheel back. The emulator keeps ten megabytes of it, which is
hours of a chatty line.
While the scrollback is up it owns the keyboard, and nothing typed reaches the
port — which is why leaving is bound to q and to escape, and why the
status bar turns cyan and lists the keys:
| Key | |
|---|---|
| wheel | Three lines at a time; one click past the end leaves |
j k, ↓ ↑ |
A line at a time |
space, b, PgDn, PgUp |
A screen at a time |
g, G, Home, End |
The very top, or back to the end |
/ |
Search backwards for a string |
n, N |
The next match, or the previous one |
q, escape |
Leave, returning to the end |
--scrollback-color changes that color, or none leaves the bar the shade it
always is; scrollback_color in the configuration file says the same. It is a
different color from the one the command prefix
turns the bar, because the two last very differently — the prefix is gone in
about a second, and this stays until you leave it — but both mean the same
thing about the keyboard, which is why both are colored at all.
A scrollbar down the right edge says where in the scrollback you are, since "1200 lines back" on the status bar is a number and a bar is a position. Its thumb moves by eighths of a row, so stepping back a line at a time moves it a little each time rather than a whole row every eighth line. It is drawn only while the scrollback is up, over the rightmost column — giving it a column of its own would mean narrowing the far end's terminal, and a terminal that reflows every time you scroll back is one nobody wants. The column is back the moment you leave.
The thumb and the track are two shades of the terminal's own palette rather
than a bright glyph on a dim one, because a row the thumb only half covers has
to be both at once — one shade above the edge and the other below it — and
there is no way to say "dim" about half a cell. --scrollbar-color sets the
thumb's shade and --scrollbar-track-color the track's; out of the box they
are the terminal's white and its dim grey, so a theme change moves both.
Alt-a k opens the command palette.
A search scrolls to its match and marks every match on screen in reverse video. Output carries on arriving and carries on being emulated meanwhile; the viewport simply stops following it, as it does in any terminal you scroll back in, and leaving returns to the end so nothing said in between is missed.
Rolling forward off the end leaves the scrollback, which is the other half of rolling back to enter it — so the whole visit can be one gesture, and the way out is the way you were already going. Reaching the bottom is not enough by itself: that is where you arrive by catching up with the port, and being put out for it would be a mode that ends when you stop wanting it rather than when you say so. It takes one more click past the end, where there is nothing below to look at and the gesture can only mean "done".
The wheel is a wheel, not four arrow keys. A terminal on its alternate screen turns a scroll gesture into arrow keys unless the program asks for mouse reports, and those are indistinguishable from pressed ones — so rolling the wheel used to walk through the far end's shell history instead of back through the screen. zettacom asks for the reports, which is why the wheel can do the thing it was meant to do. Rolling it forward at the end of the scrollback does nothing, there being nothing below the end to look at, and the wheel is left alone while a transfer is running, since the keyboard is watched by a much smaller state machine then and a mode entered there could not be left.
The command palette
Alt-a k lists the commands, in a box drawn over the screen that says what
each one is set to:
╭──────────────────────────────────────────────────────────────╮
│> par_ │
│ [Alt-a y] Change parity mode none │
╰──────────────────────────────────────────────────────────────╯
Type to narrow the list — on the description or on the value, so 9600 finds
the baud rate — then ↑ ↓ (or Ctrl-P and Ctrl-N) to choose and enter to run it.
Escape closes without running anything.
The same widget is the protocol picker below, and every other list zettacom asks you to choose from: what a row means is carried by the row, not by the code that draws it.
It is drawn over the emulated screen rather than beside it: giving it a region of its own would mean resizing the far end's terminal to make room, and a terminal that reflows every time a menu opens is not one anybody wants. What is underneath is intact and back the moment the palette closes.
Nothing about the command keys changes. The palette runs the command the same way the key does, so it is a way of finding a command rather than a second implementation of every one.
Picking a file
Typing a path is fine when the path is known and tedious when it is not, so
Alt-a s shows what is actually there:
╭──────────────────────────────────────────────────────────────╮
│ …/scratchpad/picker/files > _ │
│ .. │
│ sub / │
│ beta.bin │
│ hello.txt │
╰──────────────────────────────────────────────────────────────╯
Directories first, then files, each sorted without regard to case — ASCII
would file Makefile above bin, which is a rule nobody reading a list has
in mind. Enter walks into a directory or sends a file; the same typing,
arrows and escape as every other list. It reopens where it was left, so
sending three files from one directory means walking there once.
Alt-a r shows the same thing for the other direction, listing only
directories and offering [receive here] at the top: the sender names the
file, so the question worth asking at this end is where it should land. What
is chosen becomes the receive directory from then on.
XMODEM carries no name on the wire, so it asks for one afterwards — see Answering a question below.
Changing the baud rate
Alt-a b lists the rates the driver has a constant for, opening on the one in
force and marking it with a dot:
╭──────────────────────────────────────────────────────────────╮
│ baud rate > _ │
│ 2400 │
│ 4800 │
│ ● 9600 │
│ 19200 │
│ 38400 │
╰──────────────────────────────────────────────────────────────╯
The list is not the whole story, because any rate the driver accepts can be
asked for — 31250 for MIDI, say. So the first row is type a rate, and it
stays put however the list is narrowed: typing 31250 filters every rate away
and leaves that row, which is exactly when it is wanted. Choosing it opens a
box with 31250 already in it.
Alt-a u and Alt-a d step up and down the same list without opening it.
Answering a question
Two things have to be typed rather than chosen, and both are typed into the same box:
╭──────────────────────────────────────────────────────────────╮
│ baud rate (now 9600) │
│ > 31250_ │
╰──────────────────────────────────────────────────────────────╯
Alt-a w |
Bytes to write, in hex — de ad be ef, or deadbeef |
after Alt-a r |
What to call an XMODEM download, which carries no name on the wire |
— and a third from the baud picker's type a rate row, where 0x1c200 works
as well as 115200.
Enter answers, escape abandons, Ctrl-U clears the line. An answer that will
not do is not thrown away with a message scrolled past — the box asks again
with what was wrong with it in the question, keeping what was typed so a
typo can be corrected rather than retyped: baud rate (now 9600) -- that was not a number.
The question carries the current value where there is one. The XMODEM name is
asked for as a name, not a path — the directory was settled a moment earlier
by the picker — so / is not accepted there, which is the same thing zettacom
refuses when a peer sends it.
Changing the protocol
Alt-a z changes which protocol sending and receiving use, without
restarting. It is the same kind of picker as the baud rates, with a dot on the
one in use. An incoming ZMODEM or Kermit transfer is still recognized whatever
this says — the setting only decides what this end starts.
Transfers
A transfer takes the status bar, as a gauge, with the filled part in color:
recv disk.img [████▌ ] 25% 50.0MiB/200.0MiB 1.0MiB/s eta 2m30s
It fills by eighths of a cell rather than whole ones, using the block
elements from ▏ up to █, so eighteen cells give it a hundred and
forty-four steps instead of eighteen. A gauge that can only move a whole cell
sits still for five per cent at this width, which on a slow line is long
enough to wonder whether anything is happening.
--gauge-color says which color — a name (green, bright-cyan), a palette
number from 0 to 255, a #rrggbb, or none to leave it the shade the rest of
the bar is. The brackets take it too, since they are the ends of the gauge
rather than part of the sentence around it. gauge_color in the configuration
file says the same thing. The default is green.
The terminal is told as well. While a transfer runs, zettacom sends the OSC 9;4 progress reports that ConEmu introduced and Windows Terminal, WezTerm, Ghostty and foot have since picked up, so the terminal can show how far along it is somewhere that is visible when zettacom is not — a tab, a taskbar icon, a window badge. A ten-minute download is exactly the sort of thing to start and then go and look at something else.
One report per percentage point, not one per frame, and the last one clears it
— including on the way out, so a progress bar is never left behind on a
taskbar by a program that has exited. A protocol that carries no length says
"running, length unknown" instead of a percentage. --no-terminal-progress,
or terminal_progress = false in the configuration file, turns the whole
thing off for a terminal that does something unwelcome with it; a terminal
that does not know the sequence discards it.
And it says so when the transfer is done. A finished file raises a desktop notification through OSC 9, which is the other half of the same idea: the gauge and the scrollback both want somebody to be looking, and a transfer is the one thing here that takes long enough to walk away from.
received disk.img (190.7MiB in 2m53s)
did not finish receiving disk.img (12.4MiB in 9s)
--no-notify, or notify = false, turns it off. So does --quiet, which
silences this along with everything else — a flag by that name that still
raised notifications would be a surprising one.
A transfer can be cancelled and does not deafen the program. ^C aborts
one in flight, and the exit command aborts it and leaves; anything else typed
meanwhile is queued for the port and goes out when the line is free. zig-modem
has no way to cancel a session from outside — Filter.stop would take the
whole port down — so cancelling works by refusing to go on reading the file
being sent, or writing the one being received, which the library turns into
the protocol's own abort sequence. The far end is told in its own words rather
than left talking to a line that went quiet.
It is the only thing on that bar that changes by itself and the only thing
with an end to wait for; the port's settings are still what you set them to
and are back the moment the transfer finishes. What stays behind is the record
— the *** receiving ... *** line, a final summary with the total and the
average rate, and *** received: complete *** — all of it in the scrollback,
where a search can find it later.
Commands, and the key that starts them
A command is Alt-a followed by a plain letter: Alt-a x to leave,
Alt-a o for the scrollback, Alt-a k for the palette, Alt-a l to pause
the log.
--escape moves it, so --escape b gives Alt-b. While it is waiting for the
letter the whole status bar turns orange and says so:
Alt-a -- press a command key, or wait to cancel
That is worth noticing before typing, because the next key will not reach the
far end — and it is a state that ends by itself in about a second, so it has
to be seen at a glance or not at all. --prefix-color changes the color, or
none leaves the bar the shade it always is; prefix_color in the
configuration file says the same. If nothing follows within a second it gives
up and the next key is ordinary input again. Alt-a twice sends Alt-a to
the port.
--key-wait <msec> is how long that is; the default is 1000. 0 waits
indefinitely, which is the right answer on a terminal that speaks the Kitty
keyboard protocol, because there the wait buys nothing:
Under the Kitty keyboard protocol Alt-a arrives as a key with a modifier on
it, so it is simply not the escape key and there is nothing to disambiguate.
Without that protocol it reaches a program as escape followed by a — byte
for byte what pressing escape and then typing a looks like, which is how
anybody leaves insert mode in vi and appends — and vaxis's parser separates
them by whether anything followed in the same read. Either way a lone escape
reaches the far end without being held back.
Escape also leaves the scrollback, and the search prompt within it, without needing a second key to prove it was pressed.
Keystrokes are read, not forwarded. libvaxis's parser says which key was pressed, and libghostty-vt's encoder says what that key should be on the wire for the modes the far end has actually set.
That is more faithful than passing the bytes through. A host that has asked
for application cursor
keys wants ESC O A from the up arrow; a terminal configured for normal mode
sends ESC [ A, and a program forwarding bytes cannot tell the difference.
Pressing the same key before and after such a host sets the mode puts
ESC [ A and then ESC O A on the wire.
It is also what makes the Kitty keyboard protocol usable where the terminal
speaks it. zettacom still owns its own descriptors and its own raw mode:
vaxis's Tty and Loop are not used, only its parser.
Building
nix develop # Zig 0.17, plus zon2nix and reuse for the license lint
zig build # -> zig-out/bin/zettacom
zig build test # unit tests
zig build check # type-check without emitting a binary
zig build run -- /dev/ttyUSB0
Or as a Nix package, which builds and runs the tests in the sandbox:
nix build # -> result/bin/zettacom
Linux and Windows link no libc: raw syscalls in src/sys/linux.zig, Win32 in
src/sys/windows.zig. On Linux that means a static executable with no
shared-library dependencies at all; on Windows it means the system DLLs every
process already has, and nothing else — no runtime to install.
macOS and the BSDs do link one, and not by preference: Darwin's system call
ABI is private, libSystem is the supported interface, and Zig's standard
library reaches the BSDs only through std.c. src/sys/bsd.zig is that
backend, and build.zig links libc for those targets alone.
Cross-compiling needs no more than naming the target:
zig build -Dtarget=x86_64-windows -Doptimize=ReleaseSafe # -> zig-out/bin/zettacom.exe
zig build -Dtarget=aarch64-macos -Doptimize=ReleaseSafe
zig build -Dtarget=x86_64-freebsd -Doptimize=ReleaseSafe
Development builds
If you would rather not build it, every push to main publishes an
x86_64-linux binary and an x86_64-windows one, both built ReleaseSafe, as
the development
release. It replaces the one before it, so there is one such build and it is
whatever main is at the moment you fetch it. Neither needs anything installed
alongside it. Both keep their debug info, so a crash prints a stack trace worth
sending — on Windows that information is in zettacom.pdb, which is worth
downloading into the same directory as the .exe for the trace to name
anything. And they say which commit they are:
$ zettacom --version
zettacom v0.2.0-dev+e5c50aa3
Nothing is published from a commit whose tests failed: the test workflow calls the release one when it is done, so a red build leaves the previous binary in place. It is still a development build in the plain sense of the word — the tests passing is a lower bar than a release.
Build options
| Option | Default | Meaning |
|---|---|---|
-Dversion=<string> |
0.2.0-dev |
Version reported by --version |
-Dtty-queue-max=<bytes> |
0 (unlimited) |
Cap on the output queue |
-Duucp-lock-dir=<path> |
unset | Use UUCP LCK.. lock files instead of flock(2) |
Locking defaults to flock(2). Set
-Duucp-lock-dir=/var/lock only if you need to interoperate with programs that
use UUCP lock files (minicom, cu, pppd); enabling both conventions at once
is normally a mistake.
Dependencies
Four everywhere, plus one on Windows, all pinned in build.zig.zon: ziggy
for the configuration file, zig-modem for the file transfer protocols, and
libghostty-vt and libvaxis for the interface.
The fifth is zigwin32, the Win32 declarations, which src/sys/windows.zig
and src/term/windows.zig are written against. It is marked lazy in the
manifest: sixty-four megabytes of generated bindings that a Linux build has no
use for, and never fetches.
There is no line-editing library. Everything zettacom asks for is typed into a box it draws itself; see Answering a question.
libghostty-vt comes from a fork of ghostty, pinned to its zig-0.17-jeff
branch. What zettacom needs from ghostty's build is that it be usable alongside
libvaxis: both libraries read Unicode properties out of uucode and each would
otherwise instantiate its own, from the same source file, which Zig refuses.
ghostty exports its instance so vaxis can be handed it through
-Dexternal_uucode, and makes the uucode tables vaxis reads available to it.
Both changes began in the fork and have since been merged into ghostty itself.
Depending on ghostty means depending on its whole manifest: build.zig.zon.nix
lists 39 packages, most of which — harfbuzz, fontconfig, GTK bindings, fonts —
are fetched to build the link farm and never compiled. Building the Nix package
takes correspondingly long.
A Nix build has no network, so the Zig package manager cannot fetch these
itself. zon2nix reads build.zig.zon, follows every transitive dependency,
and writes build.zig.zon.nix — a farm laid out the way Zig's global cache
is, which package.nix hands to zig build --system. That file is generated
and committed, so adding, removing or updating a dependency is a matter of
editing build.zig.zon and regenerating it:
nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon
ghostty is copied out of that farm and passed as --fork= first, because Zig
cannot build a fetched package with .path dependencies of its own under
--system: 0.17.0 rejects them as absolute paths, where 0.16.0 spun forever
rather than failing. package.nix says why at length; which packages need it
is zon2nix's pathDependencyPackages rather than a name written here.
Windows
Ports are named the way Windows names them:
> zettacom COM3
> zettacom --baud 115200 COM12
COM10 and beyond have no DOS device alias and can only be opened through the
\\.\ namespace, so every port name is given that prefix before it is opened
and the awkward case stops being one. A path that already names a device is
used as it stands.
The configuration file lives at %APPDATA%\zettacom\config.ziggy, which is
what --help reports. Everything in it means what it means on Linux, except
that uucp-lock-dir is a build option with no Windows counterpart.
The console has to be a real one, and a recent one. zettacom reads the
keyboard as a byte stream, which is what ENABLE_VIRTUAL_TERMINAL_INPUT makes
a console deliver — Windows 10 1809 and later. Windows Terminal is the obvious
place to run it; conhost works. Output goes through the console's own virtual
terminal processing, so the emulated screen is drawn the same way it is
everywhere else. A redirected stdin or stdout is refused at startup rather
than half-drawn, the same as having no controlling terminal on Linux.
What genuinely differs, beyond the spelling:
- DTR and RTS are remembered, not read back.
GetCommModemStatusreports the four input lines — CTS, DSR, DCD, RI — and says nothing about the two outputs, so the status bar shows what zettacom last set them to. A port another program has been driving will be described as this one left it. - Every baud rate is available. A
DCBcarries the rate as a plain number, so there is no equivalent of theBOTHERmachinery Linux needs andNO_CUSTOM_BAUDhas nothing to disable. The ratesAlt-a bsteps through are theCBR_list, which differs a little from Linux's: it starts at 110 rather than 50, and includes 14400. - Exclusion comes from the open. A port is opened with no sharing at all, so a second program is refused by the system rather than by a lock this one takes out; there are no UUCP lock files and nothing to clean up after a crash.
- A break is a quarter of a second. Win32 has no timed break — one call starts it and another ends it — so the duration is chosen here.
--hangupis implicit. Windows drops DTR when the port is closed and offers no way to ask otherwise.
What has been tested, and what has not
The Windows build compiles from a clean checkout, and its unit tests — the same ones the Linux build runs, with the two that assert POSIX path syntax skipping themselves — pass under Wine:
zig build test -Dtarget=x86_64-windows -fwine # needs wine on PATH
That covers the program's own startup, argument parsing, configuration loading, port opening through Wine's COM mapping, screen drawing and exit paths as well; typed characters reach the far end of a serial line and output from it reaches the screen. Continuous integration builds for Windows on every push but does not run this, since Wine is a large thing to install on a runner for the purpose.
It has not been run against real serial hardware on a real Windows
machine, and two things in particular could not be exercised under Wine: the
Alt-a command prefix, because Wine's console swallows the escape byte that
carries the modifier, and the exact wait that Windows performs on a console
handle, because Wine never signals one — which is why the wait proves itself
before relying on it, and slices its waiting until it has. Reports from a real
machine are welcome.
macOS and the BSDs
One backend covers macOS, FreeBSD, NetBSD, OpenBSD and DragonFly, because at the level zettacom works at they are the same system. Ports are named the way each system names them:
$ zettacom /dev/cu.usbserial-A9007UX1 # macOS
$ zettacom /dev/cuau0 # FreeBSD
$ zettacom /dev/dty00 # OpenBSD
Everything reads as it does on Linux — the same options, the same
configuration file at $XDG_CONFIG_HOME/zettacom/config.ziggy, the same
Alt-a — with three real differences:
- Mark and space parity are refused, because there is no
CMSPARto ask for them with. picocom defines the bit as zero on these systems, which means asking for mark quietly gets you odd;--parity markhere fails and says so. - Any baud rate works, and more simply than on Linux. A BSD
speed_tis the rate, so 31250 goes in the same field 9600 does. macOS is the exception: rates it does not know go in throughIOSSIOSPEEDafter the attributes are applied rather than as part of them, which is whyterm/bsd.zigcarries such a rate separately until it can be applied in the right order. - libc is linked, where the Linux and Windows builds link none. Darwin's
syscall ABI is private and the BSDs are wrapped only through
std.c; see Building.
Flow control is spelled two ways across these systems — CCTS_OFLOW with
CRTS_IFLOW on macOS, FreeBSD and DragonFly, the single CRTSCTS on NetBSD
and OpenBSD — and term/bsd.zig is where that difference begins and ends.
What has been tested, and what has not
FreeBSD is tested properly. The whole suite — 206 tests, the same ones
Linux runs plus the backend's own — passes on FreeBSD 14.3 in a virtual
machine, and zettacom itself was run there against a real serial port: it
opened /dev/cuau1 at 9600 8N1, reported the modem lines the driver gave it,
drew text arriving from the far end onto the emulated screen with the RX light
coming on, put typed characters on the wire byte for byte, and exited cleanly.
macOS, NetBSD, OpenBSD and DragonFly are compiled and not run. They share every line of the code FreeBSD exercised, and what is not shared is small and named above — the flow-control bits and, on macOS, the extra ioctl for an unusual baud rate. That is a reasonable position to be in and it is not the same as tested: nobody has watched a Mac talk to a serial port with this. If you do, reports are welcome.
File transfers
zettacom speaks XMODEM, YMODEM, ZMODEM and Kermit itself, through zig-modem.
Nothing is forked, no lrzsz has to be installed, and the port never changes
hands. Which protocol to use is named rather than spelled as a command line:
| Flag | Effect |
|---|---|
--protocol zmodem | xmodem | xmodem-1k | ymodem | ymodem-1k | kermit |
Protocol for Alt-a s and Alt-a r. Default zmodem |
--receive-dir <directory> |
Where received files are written. Default . |
Downloads start by themselves. Port traffic runs through a filter that
copies bytes through unchanged until a ZMODEM frame header or a Kermit S
packet appears in it, at which point it stops forwarding and receives the file.
So typing sz report.txt on the far end downloads it here, with nothing typed
on this end.
XMODEM and YMODEM cannot work that way, because their senders say nothing at all until a receiver prompts them; there is nothing in the stream to notice. What the far end says while being waited for does reach the screen, though — the echo of the command that started the receiver, the question it asked, or the message it printed instead of prompting. On a console line that is usually the only explanation of why a transfer is not starting.
Those downloads are asked for with Alt-a r, which for XMODEM also asks what
to call the file, since XMODEM carries no name.
xmodem-1k and ymodem-1k are separate protocols, not a setting. They
send 1024 byte blocks where the other two send 128. Whether the far end takes
the larger block is the one thing about XMODEM and YMODEM that cannot be
negotiated — a receiver that predates the extension refuses every block
without saying why, which looks from this end like a transfer failing at
nought per cent after a file name that crossed perfectly well. Making it part
of the name means the choice is visible in the log, in the status bar and in
the picker, rather than hidden in a defaulted field. Both spellings work, so
xmodem-1k, xmodem_1k and xmodem1k all name the same thing.
Only the sender is affected: a receiver takes whichever block sizes it is
sent, so ymodem and ymodem-1k receive identically. The 1K senders fall
back to 128 byte blocks if the first two large ones are refused.
A file received under a name the sender chose is written to its last path
component only, so a peer cannot ask for ../../etc/passwd.
A transfer says how it is getting on. The moving part is the gauge on the
status bar; what is left behind in the scrollback is the two *** ... ***
lines that announce a file and its outcome, with a summary of what the
transfer did between them:
*** receiving disk.img (200000000 bytes) ***
receiving disk.img 100% 190.7MiB/190.7MiB 1.1MiB/s in 2m53s
*** received: complete ***
The percentage needs a length, so it is absent for XMODEM, which carries none,
and for a sender that declined to give one; the byte count and the rate are
always there. The rate is measured over the last minute once there has been a
minute to measure, and over the file so far before that. --quiet suppresses
both the gauge and the line, but not what they count: a transfer is the
busiest the port ever is even though not a byte of it reaches the screen, so
--exit-after will not time out in the middle of one.
The summary is skipped rather than waited for if something else holds the console when it is written. It comes from inside the protocol engine, with a sender at the other end of the line waiting on it, and that sender will not wait for the console.
Logging the traffic
--logfile <file> records the wire: every byte the port delivered and
every byte handed to it, raw, in the order they crossed. Nothing else is in
the file — no framing, no direction marks, no timestamps — because anything
added is something a reader has to strip before the file is the bytes again.
zettacom --logfile port.log -b 115200 /dev/ttyUSB0
Logging is off unless that flag names a file, or Alt-a n opens one later.
logfile in the configuration file says the same thing as the flag.
The tap is either side of the transfer filter, which is what makes it the wire
and not the screen: a ZMODEM download is in the log, and so is everything sent
during one. picocom's -g, which zettacom's flag is spelled after, logged only
the received bytes that reached the screen.
What you typed appears twice. A far end that echoes sends your keystrokes
back, so ls typed at a shell is in the file once going out and once coming
back:
l l s s \r \r\n total 4\r\n drwxr-xr-x ...
^ ^
| +-- echoed back by the far end
+---- what you typed
That is what "all the traffic" means, and it is the thing to know before
reading one. If what you want is a transcript of the session rather than a
recording of the line, the emulator already keeps one — Alt-a o walks it,
and a snapshot preserves it.
A log is opened for appending, so pointing at a name twice adds to it
rather than destroying what was there. Both it and its sidecar are created
0600: a recording of the wire holds what was typed as well as what was
shown, which is to say passwords.
| Command | Effect |
|---|---|
Alt-a l |
Pause the log, or resume it |
Alt-a n |
Start writing to a new file |
Alt-a m |
Take a snapshot now |
Pausing drops bytes rather than buffering them. A log paused to keep a password out of it is not one that should be holding the password in memory until you resume.
Alt-a n opens a box with a name made out of the current UTC time already in
it — zettacom-20260922-181500Z.log — so enter accepts it and typing replaces
it. It is also how logging is started when --logfile named nothing. A name
that cannot be opened leaves the log running where it was, rather than
stopping it.
The sidecar
A file of raw bytes cannot be replayed into a terminal unless the reader knows what state that terminal was in when the recording started — where the cursor was, which modes were set, what was already on the screen. Across a pause it cannot be replayed at all, because the bytes that would have moved the cursor are exactly the ones that were dropped.
So beside port.log there is port.log.snapshot, holding a libghostty-vt
snapshot: the far end's screen as zettacom understands it, its alternate
screen, its scrollback, its styles and modes, and the bytes of whatever escape
sequence the parser was part way through. One is written when logging starts,
one when it resumes, and one every time Alt-a m asks for one.
Each is preceded by a 32-byte header saying which of those it was, when, and
how many bytes of the log had been written by then — which is what ties a
snapshot to a position in the byte stream beside it. The snapshot's own
framing ends itself, at its FINISH record, so entries simply follow one
another:
+----------------+----------------------------+
| entry header | snapshot, through FINISH |
+----------------+----------------------------+
| entry header | snapshot, through FINISH |
+----------------+----------------------------+
| Offset | Size | Field |
|---|---|---|
| 0 | 8 | ZCSNAP01 |
| 8 | 1 | 0 start, 1 resumed, 2 taken by hand |
| 9 | 7 | Reserved, zero |
| 16 | 8 | Bytes of the log written when it was taken, little-endian |
| 24 | 8 | Unix nanoseconds, little-endian |
The offset is read as the snapshot is encoded, under the same lock that holds the emulator still, so it is approximate by whatever the filter has read from the port and not yet fed to the emulator — at most one read. It is a place to start replaying from, not a byte-exact cut.
Alt-a m with nothing being logged still writes a snapshot, to a file of its
own named for the moment it was taken. There is no log to point into then, so
its offset is zero. It is the scrollback, in a form something else can read.
Configuration
zettacom reads a configuration file, in Ziggy, from
$XDG_CONFIG_HOME/zettacom/config.ziggy (or ~/.config/zettacom/config.ziggy
when XDG_CONFIG_HOME is unset). Having no file is not an error.
.defaults = .{
.baud = 115200,
},
.profiles = {
"usb-serial": .{
.match = "/dev/ttyUSB*",
.baud = 9600,
.flow = .rtscts,
},
},
Settings resolve in this order, each overriding the one before:
built-in defaults -> defaults -> matching profile -> command line
A profile applies when its match glob accepts the port path, the first match
in file order winning; a profile with no match is reachable only by name.
Matching uses the port path exactly as typed, so a pattern written for
/dev/serial/by-id/... will not fire when the same device is opened as
/dev/ttyUSB0.
| Flag | Effect |
|---|---|
--config <file> |
Read this file instead of the default location; a missing file is an error |
--no-config |
Read no file at all |
--profile <name> |
Use this profile and skip match entirely |
examples/config.ziggy is a commented example covering every setting.
Errors point at the line: an unknown setting reports
config.ziggy: 3:5: unknown setting, and a bad value says what was wrong with
it. There is no .ziggy-schema file — the Zig types in src/config.zig are
the schema, and ziggy validates against them as it deserializes, which is what
produces those messages. A schema file would add editor tooling support and
could be written later.
Layout
There are two loops, because the filter has to own both directions of the port
to notice a transfer going past. modem.Filter reads the port and drains the
output queue on tasks of its own, under a std.Io.Threaded; the keyboard loop
reads the terminal and runs the command-prefix state machine. A console lock
keeps zettacom's own messages and a burst of port output off each other's toes.
Everything that waits goes through std.Io. The port, the timers and the
keyboard alike. The keyboard loop awaits an Io.Batch of two reads — the
terminal, and a pipe that a deadly signal or the port's tasks finishing write
a byte to — with a deadline only when the command prefix or --exit-after has
one. So an idle zettacom is asleep rather than waking on a timer to look at
flags, and which mechanism does that sleeping is the Io implementation's
business: under the Io.Threaded it runs on today it is one poll(2) over
both descriptors, and under an io_uring one it would be two submissions, with
nothing here changing.
With one exception, and it is not by choice: Zig 0.17's Io.Threaded has no
concurrent batch on Windows — asking it to await two operations at once
returns error.ConcurrencyUnavailable — so there the keyboard loop waits on
WaitForMultipleObjects over the console and an event, which is the same wait
by the platform's own name. Everything either side of it is unchanged, and the
day Io.Threaded grows the capability the special case can go. See
Program.waitForKeyWindows.
Two terminals are owned by two different things, and keeping that straight is
most of reading main.zig. The serial port belongs to term.Table, which
speaks termios2 on Linux so that any baud rate the driver takes can be asked
for, and a DCB on Windows, where every rate is a plain number and the
question does not arise. Yours belongs to vaxis.Tty, which opens it, makes it
raw, restores it, and dispatches SIGWINCH. The reading is still zettacom's, because the keyboard
loop has to wake for a signal, the port going away, an idle period and the
command prefix giving up — none of which vaxis.Loop knows how to wait on.
Two implementation notes worth knowing before reading any of it. Terminals are
restored explicitly rather than through atexit, because Zig's
std.process.exit does not run atexit handlers — term.Table.restoreAll is
called from each exit path instead. And nothing is ever forked, so a deadly
signal has no children to take down with it and only sets a flag the keyboard
loop notices.
src/
main.zig the program: options, keyboard loop, commands
cli.zig argument parsing
config.zig the Ziggy configuration file
glob.zig port-path matching for config profiles
charmap.zig --omap/--emap character mappings
ttyq.zig the output queue and its reading end
ui.zig the emulated screen and the chrome round it
browse.zig the file and directory pickers
keys.zig keystrokes to the bytes the far end expects
streams.zig the console lock, and the taps either side of the wire
log.zig the traffic log and its snapshot sidecar
transfer.zig file transfers, on zig-modem
hex.zig the hex parser, for [Alt-a w]
fdio.zig writing to a raw descriptor
lock.zig flock and UUCP lock files
term.zig terminal settings framework
term/
backend.zig comptime backend selection + the contract
types.zig Parity, Flow, Modem, error set
linux.zig termios2 backend
windows.zig DCB backend
bsd.zig termios backend, macOS and the BSDs
sys.zig OS primitives: the comptime seam
sys/
linux.zig raw syscalls, no libc
windows.zig Win32, through zigwin32
bsd.zig libc, macOS and the BSDs
All four are implemented. There is no platform left whose file is a stub; a system none of them names fails to compile with a message saying so, rather than building something that does nothing.
Baud rates
Any rate the driver will accept can be requested, not just the ones with a
Bxxxx constant, because the Linux backend uses the termios2 interface
(TCGETS2/TCSETS2 with BOTHER). So -b 31250 for MIDI, or -b 76800, work
directly. Set NO_CUSTOM_BAUD in the environment to restrict zettacom to the
standard rates, for drivers that mishandle the others.
Note that a driver may quietly substitute a rate it prefers. zettacom always reads back what was actually applied and shows both when they differ:
*** baud: 31250 (38400) ***
Where this lives
git clone https://git.jcollie.dev/jeff/zettacom.git
The Tangled mirror
The repository is mirrored on Tangled, an atproto-backed forge, at https://tangled.org/jcollie.dev/zettacom:
git clone https://tangled.org/jcollie.dev/zettacom
Cloning with Radicle
The repository is also published on Radicle, a peer-to-peer code forge, where it is findable only by its Repository ID:
rad clone rad:zovE5mSkp3Wkbyo1EUsPVGnQByqF
If you do not have Radicle set up yet, install the rad CLI and create an
identity first:
curl -sSf https://radicle.xyz/install | sh
rad auth
Cloning also seeds the repository, which helps keep it available on the network.
Acknowledgements
zettacom began as a port of picocom by Nick Patavalis, and owes it a great deal. The terminal settings framework, the character mappings, the output queue and the shape of the command line all started there, and much of the command line is still compatible with it.
It has grown apart from picocom since — a terminal emulator and everything that follows from one, file transfers of its own, a configuration file — but the debt stands. Thanks to Nick Patavalis and to everyone who has contributed to picocom over the years.
The terminal emulator that made most of that divergence possible is libghostty-vt, out of Ghostty by Mitchell Hashimoto and the Ghostty developers. zettacom does not so much use it as stand on it: the screen, the scrollback, the search and the key encoder are all its work, and what is here is chrome around them.
The chrome itself is drawn with libvaxis by Tim Culverhouse, which also reads the keyboard, negotiates what the terminal can do, and owns the terminal while zettacom is running.
zig-modem speaks the four transfer protocols, and ziggy by Loris Cro parses the configuration file.
References cited
What zettacom was built from and what it was built against: the libraries it stands on, the program it is a port of, and the specifications that say what a serial port, a terminal and a configuration directory are. The transfer protocols themselves — XMODEM, YMODEM, ZMODEM and Kermit — are cited in zig-modem, which implements them, rather than here.
Software
Cro, Loris. Ziggy: a data serialization language. https://ziggy-lang.io.
The configuration file's language and parser. The Zig types in
src/config.zig are the schema, and ziggy validates against them as it
deserializes, which is what produces the line-and-column errors.
Culverhouse, Tim. libvaxis. https://github.com/rockorager/libvaxis.
Draws zettacom's own terminal, reads the keyboard, negotiates what the
terminal can do, opens and restores /dev/tty, and dispatches SIGWINCH. Its
parser is what tells Alt-a from a pressed escape key.
Hashimoto, Mitchell, and the Ghostty contributors. libghostty-vt, in
Ghostty. https://github.com/ghostty-org/ghostty. The terminal emulator port
output is fed into: the screen, the scrollback, the styles, the search behind
Alt-a o, the key encoder behind keys.zig, and the state snapshot the
traffic log's sidecar holds. Its src/terminal/snapshot/ documents the record
format log.zig writes entries around.
Marler, Jonathan. zigwin32: Zig bindings for the Win32 API.
https://github.com/marlersoft/zigwin32. Where the Win32 declarations in
sys/windows.zig and term/windows.zig come from, generated from the same
metadata Microsoft's own projections are built from rather than written out by
hand.
Ollie, Jeffrey C. zig-datetime.
https://git.jcollie.dev/jeff/zig-datetime. Dates, times and timezones. One
thing here wants them — the name a new traffic log is given, which is the
moment it was started — and that one thing is a calendar. See
log.stampedName.
Ollie, Jeffrey C. zig-modem. https://git.jcollie.dev/jeff/zig-modem. XMODEM, YMODEM, ZMODEM and Kermit, and the filter that owns both directions of the port so that a transfer starting by itself is noticed.
Ollie, Jeffrey C. zon2nix. https://github.com/jcollie/zon2nix. Reads
build.zig.zon, follows every transitive dependency and writes the Nix
expression zig build --system is handed, so the sandboxed build fetches
nothing.
Patavalis, Nick. picocom: minimal dumb-terminal emulation program.
https://github.com/npat-efault/picocom. The program zettacom is a port of.
picocom.c, term.c, termios2.c, custbaud_bsd.c, fdio.c and the
USE_FLOCK/UUCP_LOCK_DIR handling are each cited from the zettacom file
that ports them.
Zig Software Foundation. The Zig programming language, version 0.17.0.
https://ziglang.org/. std.Io is what everything that waits goes through —
the port, the timers, the keyboard — and std.Io.Reader and std.Io.Writer
are the interfaces the taps in streams.zig implement.
The terminal, three ways
IEEE and The Open Group. The Open Group Base Specifications Issue 8,
Chapter 11: General Terminal Interface. IEEE Std 1003.1-2024. 2024.
https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap11.html.
termios, and the meaning of the flags term.zig is a framework around: the
c_cflag bits for parity, data bits and stop bits, CLOCAL, HUPCL,
CRTSCTS, IXON/IXOFF, and the VMIN/VTIME pair the reading task
depends on. term/bsd.zig is this interface and nothing else.
The Linux man-pages project. ioctl_tty(2) — ioctls for terminals and serial
lines. https://man7.org/linux/man-pages/man2/ioctl_tty.2.html. TCGETS2
and TCSETS2 with BOTHER, which is how term/linux.zig asks a Linux driver
for a rate that has no Bxxxx constant — 31250 for MIDI, say — and
TIOCMGET, TIOCMBIS and TIOCMBIC, which are the modem lines on the status
bar.
Microsoft. DCB structure (winbase.h).
https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-dcb.
A serial port's settings on Windows, read and written whole with
GetCommState and SetCommState. term/windows.zig is a read-modify-write
of this structure, with COMMTIMEOUTS travelling beside it so that restoring
the port restores its timeouts too.
What the two terminals say to each other
ConEmu. ANSI escape codes: OSC 9;4 taskbar progress.
https://conemu.github.io/en/AnsiEscapeCodes.html. The sequence ui.zig
writes to tell the terminal how far a transfer has got, so a tab or a taskbar
icon can show it while zettacom is not on screen. --no-terminal-progress
turns it off.
kitty. The kitty keyboard protocol.
https://sw.kovidgoyal.net/kitty/keyboard-protocol/. Under this protocol
Alt-a arrives as a key with a modifier on it rather than as an escape
followed by a letter, so the command prefix is simply not the escape key and
--key-wait 0 becomes the right answer. zettacom asks for it at startup and
takes the legacy encoding from a terminal that does not answer.
Nachman, George. Proprietary escape codes: OSC 9 notifications.
https://iterm2.com/documentation-escape-codes.html. The desktop notification
a finished transfer raises, sent through libvaxis. --no-notify turns it off,
and so does --quiet.
Where files and locks go
freedesktop.org. XDG Base Directory Specification, version 0.8.
https://specifications.freedesktop.org/basedir-spec/latest/. Why the
configuration file is at $XDG_CONFIG_HOME/zettacom/config.ziggy, and why
~/.config is the fallback when that variable is unset rather than a second
place to look. See config.defaultPath.
Free Software Foundation Europe. REUSE Specification, version 3.3.
https://reuse.software/spec-3.3/. How every file in the tree carries its
copyright and licence, and what reuse lint checks against. REUSE.toml
covers the two generated files that cannot hold a header.
The Linux Foundation. Filesystem Hierarchy Standard, version 3.0. 2015.
https://refspecs.linuxfoundation.org/FHS_3.0/fhs-3.0.html. Section 5.9,
/var/lock, which is where the UUCP lock files in lock.zig go when the
build asks for that convention instead of flock — the one older programs
such as minicom, cu and pppd know about.
License
GPL-2.0-or-later. Copyright is held by Nick Patavalis for the picocom code this
began as, and by Jeffrey C. Ollie for zettacom. The tree is
REUSE compliant; run reuse lint.