smon 0.1.3

Minimalistic TUI serial monitor
# smon

Minimalistic TUI serial monitor.

## Install

```
cargo install smon
```

## Updating

```
smon update
```

This downloads the newest release built for your platform, checks it against the
published SHA256SUMS, runs it once to confirm it works, and only then swaps the
installed binary. Name a version to install that exact one, for example
`smon update v0.1.2`, which also lets you go back to an older release. Add
`--from-source` to build with cargo instead of downloading.

Replacing the file on disk changes nothing for an smon that is already running,
so the update finishes by telling each one on this machine to stand down. A
daemon comes back on the new binary. A TUI just exits, so open sessions end.
You are asked first, unless you pass `--yes`.

## Usage

```
smon
```

smon lists what you can attach to and lets you pick with an fzf-style filter.
That list is the consoles a running smon already holds, followed by the serial
ports on this machine. Type to filter, arrow keys to move, Enter to select. The list refreshes
about once a second. A port already opened by another program or another smon is
shown dimmed and marked `busy`, and cannot be picked. Busy detection currently
works on Windows. After you pick a port smon asks for a baud rate and connects.

If you type a path that does not match any detected port, Enter accepts it as a
raw device path. This is useful for virtual PTYs and adapters that do not
enumerate.

Run `smon --help` for the full flag list.

### Sending and receiving

The bottom box is an input line. Type a command and press Enter to send it
followed by the line ending. Device output scrolls in the pane above, with a
scrollbar when there is more than one screen. Your sent lines are echoed in the
pane in cyan with a `>` prefix.

- Enter sends the current line plus the line ending.
- Up and Down recall previously sent commands.
- The mouse wheel scrolls the output history, three lines per notch.
- Tab, or Right at the end of the line, accepts the ghost autocomplete suggestion.
- Ctrl key combos such as Ctrl+C pass straight through to the device.
- Ctrl+Q quits.

### Scrolling

The wheel scrolls the output pane. While you are scrolled up the view stays on
what you are reading, new output does not drag it away. Scrolling up stops at
the oldest full screen, and the scrollbar shows where you are. Scrolling back
down to the bottom pins the view to live output again, and sending a command
with Enter snaps it there too.

smon captures the mouse to receive wheel events, which also means the terminal's
own text selection needs its usual override while smon runs, holding Shift while
dragging in most terminals. Text selection without Shift is on the roadmap.

If the device disappears mid-session, for example it reboots or the adapter is
replugged, smon keeps the scrollback, marks the session as
disconnected in the title, and reconnects on its own as soon as the port is
back.

### Autocomplete

Sent commands are saved and offered back as you type. The best fuzzy match from
your history is shown dimmed at the right of the input box with a Tab hint. Press
Tab, or Right at the end of the line, to accept it. The history is global, stored
in the config file, de-duplicated, and capped at the 200 most recent commands.

### Remembered baud

The baud rate is remembered per port and preselected the next time you open that
port. It is stored in the config file. Pressing Esc on the baud picker returns to
port selection.

### Session logs

Every console is written to a log file in real time. Each entry has a timestamp
and a direction marker: incoming bytes are tagged `RX`, lines you send are tagged
`TX`. Control bytes are escaped so the file stays readable plain text. The file
name holds the console and the start time, for example
`smon-COM3-20260629-143205.log`.

A log is a sequence of segments. A new one starts when the date changes, and a
client can start one at any time with `log_roll`, which returns the path. A run
that does that first can read its own output back from exactly that file instead
of picking its own lines out of a whole day. Segments older than
`log_retention_days` are deleted, 30 days by default.

Logs are stored in:

- `$XDG_STATE_HOME/smon/logs/` on Linux and macOS, or `~/.local/state/smon/logs/`
- `%LOCALAPPDATA%\smon\logs\` on Windows

While a session is running its log file is held open, so on Windows the size and
last write time shown by a directory listing are stale. Windows does not flush
them to the directory entry until the file is closed. `dir`, `ls` and
`Get-ChildItem` can report the active log as 0 bytes or with an old timestamp
even while bytes are being written to it. Do not decide a log is empty or
unchanged from its listed size or time. Read the file contents.

### Config file

The baud per port and the command history live in `config.json`, found in:

- `$XDG_CONFIG_HOME/smon/` or `~/.config/smon/` on Linux and macOS
- `%APPDATA%\smon\` on Windows

## Daemon

A daemon owns its serial ports and never lets go, so the consoles are up from
boot with nobody logged in, everything the devices say is captured whether or not
anyone is watching, and a monitor that dies does not take the port with it.

```
smon daemon --config /etc/smon/daemon.toml
```

The config lists the consoles. Only `device` is required:

```toml
bind = "127.0.0.1:4123"
log_retention_days = 30

[[console]]
device = "/dev/serial/by-id/usb-FTDI_TTL232R-3V3_FTCBUHQA-if00-port0"
label = "left"
baud = 115200
ring_kb = 512
bridge_port = 4224
```

Point `device` at a `/dev/serial/by-id/...` path rather than `/dev/ttyUSB0`. The
ttyUSB numbers are handed out in probe order, so with two adapters a replug can
swap them and a label would then name the wrong device.

`label` is a name for the console and nothing more. smon attaches no meaning to
it, it just lets a client say `left` instead of the whole path.

With no `--config` the first of `$SMON_CONFIG`, `<config dir>/smon/daemon.toml`
and `/etc/smon/daemon.toml` is used.

### Attaching

Plain `smon` finds a daemon on this machine and offers its consoles. Picking one
attaches over a websocket rather than opening the device, so it works while the
daemon holds the port, and several people can watch the same console at once and
see each other's input.

Picking a serial port the daemon does not hold hands it to the daemon first, so
it stays up and keeps being logged after you quit the viewer. With no daemon
running at all, the port is opened here and everything behaves as it always did.

`--host <ssh target>` attaches to a daemon on another machine:

```
smon --host pi
```

The daemon binds loopback only and is never put on a network. `--host` opens an
ssh tunnel itself and talks through that, so it inherits whatever already guards
ssh to that host and opens nothing new.

### Raw bridge

A console with a `bridge_port` is also offered as a plain byte stream on that
loopback port, which is what pyserial reaches with `socket://127.0.0.1:4224`. A
program that would otherwise need the device node can use that instead, so
nothing has to be stopped first, and what it says to the device is recorded in
the console log like any other input.

For anything that truly needs the device node, `console_release` makes smon let
go and `console_hold` takes it back. See [docs/mcp.md](docs/mcp.md).

The line ending is chosen once at launch with `--eol` and defaults to `crlf`:

```
smon --eol crlf   # \r\n, the default
smon --eol cr     # \r
smon --eol lf     # \n
smon --eol none   # send nothing extra
```

## MCP server

smon also serves a small [Model Context Protocol](https://modelcontextprotocol.io)
endpoint, so an agent or any MCP client can drive the serial console the same way
you can at the TUI. It exposes tools such as `serial_send`, `serial_read`, and
`serial_expect`.

It is always on and listens on `http://127.0.0.1:4123/mcp` over Streamable HTTP,
loopback only. Change the bind with `--mcp`:

```
smon --mcp 127.0.0.1:5000
```

Every tool takes an optional `console`. With one console open it can be left
out, with several it is required.

### Driving it from a shell

The smon binary is its own client, so no curl and no MCP session are needed.
`smon list` prints every console a running smon owns. `smon call <tool> [json]`
calls one tool and prints the result. Both take `--host` to reach another
machine over ssh.

```
smon list
smon call console_list
smon call serial_send '{"console":"left","text":"version"}'
smon call serial_status --host pi
```

See [docs/mcp.md](docs/mcp.md) for the tool list and how to connect a client.

## Testing with a fake device

A `Makefile` target spawns a virtual serial pair via [socat](https://www.dest-unreach.org/socat/) so you can try smon without real hardware:

```
make device
```

This symlinks one end at `/tmp/smon-fake` and runs the other end interactively in your terminal. In another terminal, run `cargo run`, type `/tmp/smon-fake` at the port picker. Lines you type in the `make device` terminal show up in smon, and lines you send from smon appear there. Ctrl+C stops the device.

Requires socat (`brew install socat` on macOS).

## WSL2

On WSL2 the Windows COM ports are not directly visible. Forward the USB adapter into WSL with [usbipd-win](https://github.com/dorssel/usbipd-win) and smon will list attachable devices in the port picker and attach them for you. See [docs/wsl.md](docs/wsl.md) for the full setup.

## Cutting a release

```
gh workflow run Release -R VladasZ/smon -f bump=patch
```

`bump` takes `patch`, `minor` or `major`. The workflow raises the version in
`Cargo.toml`, commits it as `release vX.Y.Z`, tags and pushes, builds the binary
for five targets, and publishes a GitHub release carrying those archives and a
`SHA256SUMS` file. `smon update` reads exactly those assets, so a release is what
makes the new version reachable.

A green workflow is not the whole release. Publishing to crates.io is left out of
CI on purpose, because the token belongs on your machine and not in a runner. So
when the run finishes:

```
git pull --rebase
cargo publish
```

Only after that does `cargo install smon` give people the new version. Skipping it
leaves crates.io behind while the GitHub release looks finished.

## License

Dual-licensed under MIT or Apache-2.0.