Compare commits

...

10 Commits

Author SHA1 Message Date
c651dc4c5e Add Serial for Humans ELI5 intro page
Friendly introduction to serial concepts using everyday analogies:
- Baud rate as conversation speed
- Flow control as "wait, I'm not ready!"
- Wires explained (TX/RX/GND)
- Text vs binary encoding
- When things go wrong cheat sheet

Links throughout invite readers to "go deeper" into detailed pages.
Positioned first in Tutorials sidebar for discoverability.
2026-02-03 10:12:22 -07:00
63a4169ffa Add troubleshooting, encoding, and timeout documentation
New pages:
- guides/troubleshooting: error recovery, permissions, device reconnection
- guides/timeout-tuning: baud rate timing, slow device patterns
- concepts/encoding-and-binary: UTF-8 vs Latin-1, binary protocol handling

Enhanced existing pages:
- reference/url-handlers: feature comparison table for URL schemes
- guides/file-transfers: error recovery and ZMODEM resume section
- concepts/flow-control: USB adapter compatibility and deadlock debugging
2026-02-03 10:06:43 -07:00
38a33e38b1 Fix DE-9 logo accuracy and reframe docs for MCP client perspective
Logo/favicon: Replace crude rectangle with proper DE-9 connector geometry
derived from EIA/TIA-574 specs (D-shaped shell, 5-over-4 pin layout,
hex standoff mounting posts).

Documentation: Rewrite all code examples from the MCP client perspective.
Remove Python syntax highlighting, use JSON boolean conventions
(true/false instead of True/False), and add framing notes explaining
tool-call notation. Tutorials now guide users through natural language
interaction with the assistant rather than direct function calls.
2026-02-02 22:42:48 -07:00
dc8caddb8f Add Starlight documentation site with full content
Astro 5 + Starlight docs site with 17 pages covering all 30 mcserial
tools. Structured using Diataxis framework: tutorials, guides, reference,
and concepts. Includes Docker + caddy-docker-proxy deployment for
mcserial.l.zmesh.systems with HMR support behind reverse proxy.

Content pages:
- Landing page with feature overview and quick start
- Tutorials: getting started, loopback testing (loop://)
- Guides: RS-232 basics, RS-485/Modbus, file transfers, network ports
- Reference: common tools, RS-232 tools, RS-485 tools, file transfer
  tools, URL schemes, MCP resources, environment variables
- Concepts: RS-232 vs RS-485, flow control
2026-02-02 22:01:48 -07:00
5c655fe743 Add URL handlers, pyserial feature parity, and cp2110 extra
- open_serial_port now accepts URL schemes (socket://, rfc2217://,
  loop://, spy://, cp2110://) via serial_for_url()
- cp2110 HID-to-UART support gated behind optional [cp2110] extra
- Add grep parameter to list_serial_ports for regex filtering
- Add read_serial_lines for batch multi-line reads
- Add cancel_read/cancel_write for pending I/O interruption
- Add set_flow_control for manual XON/XOFF gating
- Add break_condition to get_modem_lines response
2026-02-01 22:39:09 -07:00
b5bd2bb470 Update README with RS-485, file transfer, and auto-baud docs
Reorganized tool tables by mode (RS-232, RS-485, file transfer) and
added usage examples for Modbus bus scanning and ZMODEM transfers.
2026-01-28 22:49:06 -07:00
e8a6197b8c Address code review findings for file transfer protocols
Critical fixes:
- Add max_transfer_size (100MB default) to XMODEM receive
- Validate ZMODEM position values with optional max_valid bound

High priority:
- Extract shared utils to _utils.py (sanitize_filename, open_file_atomic)
- Document XMODEM padding behavior (protocol limitation)
- Add filesize bounds checking in YMODEM (clamp to 10GB)
- Increase ZMODEM subpacket limit from 8KB to 32KB

Medium priority:
- Add timeout parameter to YMODEM/ZMODEM send/receive methods
- Narrow exception handling (SerialException, OSError, ValueError)
- Make ZMODEM cancel more robust (3 retries with delays)
- Add length validation in _verify_block to prevent IndexError
2026-01-28 19:59:22 -07:00
fb671a7c34 Add X/Y/ZMODEM file transfer protocols with security hardening
File transfer protocols:
- XMODEM: 128/1024-byte blocks with checksum or CRC-16
- YMODEM: Batch transfers with file metadata in block 0
- ZMODEM: Streaming protocol with auto-resume capability

Security and robustness fixes:
- Atomic file creation (O_CREAT|O_EXCL) prevents TOCTOU races
- Transfer size limits (100MB default) prevent memory exhaustion
- Byte range validation (0-255) with clear error messages
- 4GB overflow handling for ZMODEM position encoding
- CRC-32 table initialized at module load for thread safety
- Timeout enforcement prevents indefinite hangs

Server enhancements:
- file_transfer_send/receive/send_batch tools
- atexit cleanup for open connections
- Thread-safety documentation for _connections registry
2026-01-28 12:27:48 -07:00
974f2ee6dc Add RS-485 specific tools
New tools for RS-485 half-duplex communication:

- check_rs485_support: Detect hardware RS-485 capability via udev/ioctl
  Reports driver type (FTDI/CP210x/CH340), kernel support, and recommendations

- rs485_transact: Send request and receive response with automatic
  TX/RX turnaround timing. Handles manual RTS control when needed.

- rs485_scan_addresses: Scan bus for responding devices (1-247)
  Useful for Modbus device discovery
2026-01-27 22:42:40 -07:00
948775968d Default to auto-baud detection when opening ports
open_serial_port() now auto-detects baud rate when baudrate=None (default).
Returns detection confidence and top candidates in response.

Falls back to DEFAULT_BAUDRATE (9600) if no data received or low confidence.

Optional autobaud_probe parameter for sync-based detection on echo devices.
2026-01-27 22:29:19 -07:00
39 changed files with 14761 additions and 55 deletions

146
README.md
View File

@ -4,11 +4,13 @@ FastMCP server for serial port access via Model Context Protocol.
## Features
- **Tools** for serial port control (open, close, write, configure)
- **Dynamic Resources** for reading data (`serial://{port}/data`)
- Full pyserial support (baudrate, parity, stop bits, etc.)
- Multiple concurrent port connections
- Raw byte and text modes
- **RS-232 Mode**: Full modem control (RTS, DTR, CTS, DSR, RI, CD, break condition)
- **RS-485 Mode**: Half-duplex bus communication with auto direction control
- **File Transfer**: X/Y/ZMODEM protocols for reliable file transfers
- **Auto-baud Detection**: Smart detection using 0x55 sync pattern analysis
- **URL Handlers**: Open remote/virtual ports (`socket://`, `rfc2217://`, `loop://`, `spy://`, `cp2110://`)
- **Dynamic Resources**: Read data via `serial://{port}/data`
- Full pyserial support (baudrate, parity, stop bits, flow control)
## Installation
@ -18,6 +20,9 @@ uvx mcserial
# Or install directly
uv pip install mcserial
# With CP2110 HID-to-UART support
uv pip install mcserial[cp2110]
```
## Usage with Claude Code
@ -27,20 +32,88 @@ uv pip install mcserial
claude mcp add mcserial "uvx mcserial"
```
## Modes
Ports open in **RS-232 mode** by default. Switch with `set_port_mode()`:
| Mode | Use Case | Tools |
|------|----------|-------|
| RS-232 | Point-to-point serial | `get_modem_lines`, `set_modem_lines`, `pulse_line`, `send_break` |
| RS-485 | Multi-drop bus (Modbus) | `set_rs485_mode`, `rs485_transact`, `rs485_scan_addresses` |
## Tools
### Common (both modes)
| Tool | Description |
|------|-------------|
| `list_serial_ports` | Discover available serial ports |
| `open_serial_port` | Open a connection with config |
| `list_serial_ports` | Discover available ports (supports `grep` regex filtering) |
| `open_serial_port` | Open connection — local device or URL scheme (auto-detects baud if not specified) |
| `close_serial_port` | Close a connection |
| `set_port_mode` | Switch between RS-232 and RS-485 modes |
| `write_serial` | Send text data |
| `write_serial_bytes` | Send raw bytes |
| `write_serial_bytes` | Send raw bytes (atomic single-syscall write) |
| `read_serial` | Read available data |
| `read_serial_line` | Read until newline |
| `read_serial_lines` | Batch read multiple lines |
| `read_until` | Read until custom terminator |
| `configure_serial` | Change port settings |
| `flush_serial` | Clear buffers |
| `get_connection_status` | List open connections |
| `cancel_read` | Interrupt pending read operation |
| `cancel_write` | Interrupt pending write operation |
| `set_flow_control` | Manually gate XON/XOFF or RTS/CTS flow |
| `set_low_latency_mode` | Enable kernel low-latency mode (Linux) |
| `get_connection_status` | List open connections with mode |
| `detect_baud_rate` | Auto-detect baud rate |
### RS-232 Mode
| Tool | Description |
|------|-------------|
| `get_modem_lines` | Read CTS, DSR, RI, CD, RTS, DTR states + break condition |
| `set_modem_lines` | Control RTS and DTR outputs |
| `pulse_line` | Pulse RTS/DTR for reset sequences |
| `send_break` | Send timed break signal |
| `set_break_condition` | Hold/release break state |
### RS-485 Mode
| Tool | Description |
|------|-------------|
| `set_rs485_mode` | Configure hardware RS-485 (DE/RE control) |
| `check_rs485_support` | Detect hardware RS-485 capability |
| `rs485_transact` | Send/receive with automatic turnaround |
| `rs485_scan_addresses` | Scan bus for responding devices |
### File Transfer
| Tool | Description |
|------|-------------|
| `file_transfer_send` | Send file via XMODEM/YMODEM/ZMODEM |
| `file_transfer_receive` | Receive file via XMODEM/YMODEM/ZMODEM |
| `file_transfer_send_batch` | Send multiple files (YMODEM/ZMODEM) |
**Protocols:**
- `xmodem` - 128-byte blocks, simple (1977)
- `xmodem1k` - 1024-byte blocks
- `ymodem` - Batch mode with filename/size
- `zmodem` - Streaming, auto-resume (recommended)
## URL Handlers
The `open_serial_port` tool accepts URL schemes in addition to local device paths:
| Scheme | Description | Example |
|--------|-------------|---------|
| `socket://` | Raw TCP socket — serial-to-ethernet bridges | `socket://192.168.1.100:4001` |
| `rfc2217://` | Telnet COM Port Control — remote baud/flow config | `rfc2217://192.168.1.100:2217` |
| `loop://` | Loopback — writes echo back as reads (testing) | `loop://` |
| `spy://` | Debug wrapper — logs all traffic to stderr | `spy:///dev/ttyUSB0` |
| `cp2110://` | Silicon Labs HID-to-UART (requires `[cp2110]` extra) | `cp2110://` |
| `hwgrep://` | Open first port matching hardware pattern | `hwgrep://FTDI` |
| `alt://` | Alternate port backend | `alt:///dev/ttyUSB0` |
URL-opened ports skip auto-baud detection and exclusive access (not applicable to virtual/network ports).
## Resources
@ -48,7 +121,7 @@ claude mcp add mcserial "uvx mcserial"
|-----|-------------|
| `serial://ports` | List available ports |
| `serial://{port}/data` | Read data from open port |
| `serial://{port}/status` | Port configuration info |
| `serial://{port}/status` | Port config and mode |
| `serial://{port}/raw` | Read as hex dump |
## Environment Variables
@ -59,16 +132,59 @@ claude mcp add mcserial "uvx mcserial"
| `MCSERIAL_DEFAULT_TIMEOUT` | 1.0 | Read timeout (seconds) |
| `MCSERIAL_MAX_CONNECTIONS` | 10 | Max concurrent ports |
## Example Workflow
## Example Workflows
### Basic RS-232
```
1. list_serial_ports → find /dev/ttyUSB0
2. open_serial_port(port="/dev/ttyUSB0", baudrate=115200)
3. write_serial(port="/dev/ttyUSB0", data="AT\r\n")
4. Read resource: serial:///dev/ttyUSB0/data
1. list_serial_ports() → find /dev/ttyUSB0
2. open_serial_port(port="/dev/ttyUSB0") # auto-detects baud
3. write_serial_bytes(port="/dev/ttyUSB0", data=[65, 84, 13, 10]) # AT\r\n
4. read_serial_lines(port="/dev/ttyUSB0") # batch read response
5. close_serial_port(port="/dev/ttyUSB0")
```
### Filter Ports by Hardware
```
1. list_serial_ports(grep="FTDI") # find all FTDI devices
2. list_serial_ports(grep="CP210") # find Silicon Labs adapters
3. list_serial_ports(grep="VID:PID=0403:6001") # exact USB ID match
```
### RS-485 Modbus
```
1. open_serial_port(port="/dev/ttyUSB0", baudrate=9600)
2. set_port_mode(port="/dev/ttyUSB0", mode="rs485")
3. rs485_scan_addresses(port="/dev/ttyUSB0") # discover devices
4. rs485_transact(port="/dev/ttyUSB0", data="\x01\x03...")
```
### Network Serial (serial-to-ethernet bridge)
```
1. open_serial_port(port="socket://192.168.1.100:4001", baudrate=115200)
2. write_serial(port="socket://192.168.1.100:4001", data="AT\r\n")
3. read_serial(port="socket://192.168.1.100:4001")
```
### Loopback Testing (no hardware needed)
```
1. open_serial_port(port="loop://", baudrate=9600)
2. write_serial(port="loop://", data="hello")
3. read_serial(port="loop://") # → "hello"
4. close_serial_port(port="loop://")
```
### File Transfer
```
1. open_serial_port(port="/dev/ttyUSB0", baudrate=115200)
2. file_transfer_send(port="/dev/ttyUSB0", file_path="firmware.bin")
```
## License
MIT

17
docs/.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
# environment variables
.env
.env.production
# macOS
.DS_Store

48
docs/Dockerfile Normal file
View File

@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1
# ============================================
# Base stage — Node 22 slim
# ============================================
FROM node:22-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./
# ============================================
# Development — hot reload
# ============================================
FROM base AS dev
RUN npm ci
COPY . .
EXPOSE 4321
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# ============================================
# Build — static site generation
# ============================================
FROM base AS build
RUN npm ci
COPY . .
ENV ASTRO_TELEMETRY_DISABLED=1
RUN npm run build
# ============================================
# Production — Caddy serves static files
# ============================================
FROM caddy:2-alpine AS prod
COPY --from=build /app/dist /usr/share/caddy
RUN echo ':80 { \
root * /usr/share/caddy \
encode gzip \
try_files {path} {path}/ /index.html \
file_server \
}' > /etc/caddy/Caddyfile
EXPOSE 80
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]

50
docs/Makefile Normal file
View File

@ -0,0 +1,50 @@
.PHONY: dev prod build logs stop clean restart shell local local-build
.DEFAULT_GOAL := dev
# Development mode with hot reload
dev:
@echo "Starting mcserial docs in development mode..."
APP_ENV=dev docker compose up -d --build
@echo "Site available at https://$(shell grep PUBLIC_DOMAIN .env | cut -d= -f2)"
@sleep 2
docker compose logs -f
# Production mode with static build
prod:
@echo "Starting mcserial docs in production mode..."
APP_ENV=prod docker compose up -d --build
@echo "Site available at https://$(shell grep PUBLIC_DOMAIN .env | cut -d= -f2)"
@sleep 2
docker compose logs -f
# Build without starting
build:
docker compose build
# View logs
logs:
docker compose logs -f
# Stop containers
stop:
docker compose down
# Clean up containers, images, volumes
clean:
docker compose down -v --rmi local
# Restart containers
restart: stop dev
# Shell into running container
shell:
docker compose exec docs sh
# Local development without Docker
local:
npm run dev
# Production build locally
local-build:
npm run build && npm run preview

76
docs/astro.config.mjs Normal file
View File

@ -0,0 +1,76 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
site: process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
: 'http://localhost:4321',
telemetry: false,
devToolbar: { enabled: false },
vite: {
server: {
host: '0.0.0.0',
allowedHosts: process.env.PUBLIC_DOMAIN
? [process.env.PUBLIC_DOMAIN, 'localhost']
: ['localhost'],
...(process.env.VITE_HMR_HOST && {
hmr: {
host: process.env.VITE_HMR_HOST,
protocol: 'wss',
clientPort: 443,
},
}),
},
},
integrations: [
starlight({
title: 'mcserial',
description: 'MCP server for serial port communication — RS-232, RS-485, and file transfers via Model Context Protocol',
logo: {
src: './src/assets/logo.svg',
replacesTitle: false,
},
social: [
{
icon: 'github',
label: 'GitHub',
href: 'https://github.com/supported-systems/mcserial',
},
],
customCss: ['./src/styles/custom.css'],
sidebar: [
{
label: 'Overview',
items: [{ label: 'Introduction', link: '/' }],
},
{
label: 'Tutorials',
autogenerate: { directory: 'tutorials' },
},
{
label: 'Guides',
autogenerate: { directory: 'guides' },
},
{
label: 'Reference',
autogenerate: { directory: 'reference' },
},
{
label: 'Concepts',
autogenerate: { directory: 'concepts' },
},
],
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
}),
],
});

35
docs/docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
services:
docs:
build:
context: .
target: ${APP_ENV:-dev}
restart: unless-stopped
environment:
- PUBLIC_DOMAIN=${PUBLIC_DOMAIN:-mcserial.l.zmesh.systems}
- VITE_HMR_HOST=${PUBLIC_DOMAIN:-mcserial.l.zmesh.systems}
- ASTRO_TELEMETRY_DISABLED=1
volumes:
# Dev mode: mount source for hot reload
- ./src:/app/src:ro
- ./public:/app/public:ro
- ./astro.config.mjs:/app/astro.config.mjs:ro
networks:
- caddy
labels:
# Caddy reverse proxy
caddy: ${PUBLIC_DOMAIN:-mcserial.l.zmesh.systems}
caddy.reverse_proxy: "{{upstreams 4321}}"
# WebSocket/HMR support for dev mode
caddy.reverse_proxy.flush_interval: "-1"
caddy.reverse_proxy.transport: "http"
caddy.reverse_proxy.transport.read_timeout: "0"
caddy.reverse_proxy.transport.write_timeout: "0"
caddy.reverse_proxy.transport.keepalive: "5m"
caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
caddy.reverse_proxy.stream_timeout: "24h"
caddy.reverse_proxy.stream_close_delay: "5s"
networks:
caddy:
external: true

6394
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
docs/package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "mcserial-docs",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/starlight": "^0.37.4",
"astro": "5.16.15",
"sharp": "^0.34.2"
}
}

16
docs/public/favicon.svg Normal file
View File

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- DE-9 connector favicon -->
<circle cx="3" cy="16" r="2" stroke="#0d9488" stroke-width="1" fill="none"/>
<circle cx="29" cy="16" r="2" stroke="#0d9488" stroke-width="1" fill="none"/>
<path d="M 7,10 L 25,10 A 3,6 0 0 1 23,22 L 9,22 A 3,6 0 0 1 7,10 Z"
stroke="#0d9488" stroke-width="1.5" fill="none"/>
<circle cx="10" cy="14" r="1.1" fill="#0d9488"/>
<circle cx="13" cy="14" r="1.1" fill="#0d9488"/>
<circle cx="16" cy="14" r="1.1" fill="#0d9488"/>
<circle cx="19" cy="14" r="1.1" fill="#0d9488"/>
<circle cx="22" cy="14" r="1.1" fill="#0d9488"/>
<circle cx="11.5" cy="18" r="1.1" fill="#0d9488"/>
<circle cx="14.5" cy="18" r="1.1" fill="#0d9488"/>
<circle cx="17.5" cy="18" r="1.1" fill="#0d9488"/>
<circle cx="20.5" cy="18" r="1.1" fill="#0d9488"/>
</svg>

After

Width:  |  Height:  |  Size: 869 B

27
docs/src/assets/logo.svg Normal file
View File

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- DE-9 (D-subminiature 9-pin) connector, face-on view -->
<!-- Proportions derived from EIA/TIA-574 specifications -->
<!-- Hex standoff mounting posts (4-40 UNC) -->
<circle cx="3" cy="16" r="2" stroke="#0d9488" stroke-width="1" fill="none"/>
<circle cx="29" cy="16" r="2" stroke="#0d9488" stroke-width="1" fill="none"/>
<!-- D-shaped connector shell -->
<!-- Top edge wider (18 units), bottom edge narrower (14 units) = 22% reduction -->
<!-- Rounded end caps connect the two edges, forming the characteristic D shape -->
<path d="M 7,10 L 25,10 A 3,6 0 0 1 23,22 L 9,22 A 3,6 0 0 1 7,10 Z"
stroke="#0d9488" stroke-width="1.5" fill="none"/>
<!-- Pin row 1: 5 pins at 3-unit pitch, centered at x=16 -->
<circle cx="10" cy="14" r="1.1" fill="#0d9488"/>
<circle cx="13" cy="14" r="1.1" fill="#0d9488"/>
<circle cx="16" cy="14" r="1.1" fill="#0d9488"/>
<circle cx="19" cy="14" r="1.1" fill="#0d9488"/>
<circle cx="22" cy="14" r="1.1" fill="#0d9488"/>
<!-- Pin row 2: 4 pins offset by half pitch (1.5 units), 4-unit row spacing -->
<circle cx="11.5" cy="18" r="1.1" fill="#0d9488"/>
<circle cx="14.5" cy="18" r="1.1" fill="#0d9488"/>
<circle cx="17.5" cy="18" r="1.1" fill="#0d9488"/>
<circle cx="20.5" cy="18" r="1.1" fill="#0d9488"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@ -0,0 +1,270 @@
---
title: Encoding and Binary Data
description: Working with text encodings, binary protocols, and raw byte handling
---
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
Serial communication deals with bytes. How those bytes map to characters — or whether they represent characters at all — depends on your encoding choice. This guide covers when to use each encoding and how to handle binary protocols that have no character representation.
---
## The Encoding Parameter
Most mcserial read and write tools accept an `encoding` parameter that controls how strings convert to/from bytes:
```json
// Text with UTF-8 (default)
// write_serial(port="/dev/ttyUSB0", data="Hello, 世界\r\n", encoding="utf-8")
// Raw bytes as Latin-1
// write_serial(port="/dev/ttyUSB0", data="\x01\x03\x00\x00\x00\x01", encoding="latin-1")
```
When reading, mcserial decodes incoming bytes using the specified encoding. Invalid byte sequences are replaced with the Unicode replacement character (<28>) rather than throwing an error — this is the `errors="replace"` behavior in Python.
---
## UTF-8: The Default
UTF-8 is the default encoding for all string operations. It handles ASCII (bytes 0x000x7F) directly and encodes non-ASCII characters as multi-byte sequences.
**When to use UTF-8:**
- ASCII text protocols (AT commands, console output)
- Devices that explicitly use UTF-8 (modern systems, JSON/XML output)
- Human-readable text where you want proper Unicode support
```json
// Reading UTF-8 text
// read_serial_line(port="/dev/ttyUSB0", encoding="utf-8")
{
"line": "Temperature: 23.5°C",
"bytes_read": 21
}
```
<Aside type="caution" title="UTF-8 and binary data don't mix">
If binary data contains bytes that are invalid UTF-8 sequences (e.g., 0x800xFF by themselves), decoding replaces them with `<60>`. This corrupts binary data when you try to interpret it as text.
**Example:** The byte `0xC0` followed by `0x03` is invalid UTF-8. Reading this with UTF-8 encoding produces `<60>\x03` instead of the original bytes.
</Aside>
---
## Latin-1: The Raw Byte Passthrough
Latin-1 (ISO-8859-1) maps bytes 0x000xFF directly to Unicode code points U+0000U+00FF. This makes it a perfect passthrough for raw binary data — every possible byte value round-trips through encoding and decoding unchanged.
**When to use Latin-1:**
- Binary protocols (Modbus RTU, proprietary framing)
- Data with arbitrary byte values (firmware blobs, encrypted payloads)
- Protocol analysis where you need to see exact bytes
```json
// Writing a Modbus RTU request (address 1, read holding registers)
// write_serial(
// port="/dev/ttyUSB0",
// data="\x01\x03\x00\x00\x00\x01\x84\x0A",
// encoding="latin-1"
// )
{
"bytes_written": 8
}
```
mcserial defaults to Latin-1 for binary-oriented operations like `rs485_scan_addresses` precisely because it ensures byte-for-byte fidelity.
---
## ASCII: Strict 7-Bit
ASCII only covers bytes 0x000x7F. Bytes outside this range cause encoding errors.
**When to use ASCII:**
- Strict validation of 7-bit protocols
- Legacy systems that only accept ASCII
- When you want encoding to fail loudly on invalid data
```json
// This would fail if the device sends bytes > 0x7F
// read_serial(port="/dev/ttyUSB0", encoding="ascii")
```
<Aside type="tip">
You rarely need explicit ASCII. UTF-8 is a superset of ASCII — if your data is pure ASCII, UTF-8 handles it identically.
</Aside>
---
## Binary Data: write_serial_bytes
For precise binary control, use `write_serial_bytes` instead of `write_serial`. It accepts a list of integer byte values (0255) and writes them directly:
```json
// Write exact bytes without encoding conversion
// write_serial_bytes(port="/dev/ttyUSB0", data=[0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A])
{
"bytes_written": 8
}
```
This is clearer than escaping bytes in a string and avoids any encoding ambiguity.
---
## Reading Binary: The Hex Dump Resource
For binary analysis, use the `serial://{port}/raw` resource. It returns data as a hex dump with printable ASCII annotations:
```
Read resource: serial:///dev/ttyUSB0/raw
```
Output format:
```
00000000 01 03 02 00 64 B8 44 |....d.D|
```
This shows:
- Offset (00000000)
- Hex bytes (01 03 02 00 64 B8 44)
- ASCII representation (. for non-printable, actual character otherwise)
---
## Common Protocol Patterns
### Modbus RTU (Binary)
Modbus RTU is a binary protocol with CRC-16 error checking. Always use Latin-1 or `write_serial_bytes`:
<Tabs>
<TabItem label="Using Latin-1">
```json
// Request: Read holding register 0 from device 1
// write_serial(
// port="/dev/ttyUSB0",
// data="\x01\x03\x00\x00\x00\x01\x84\x0A",
// encoding="latin-1"
// )
// Read response
// read_serial(port="/dev/ttyUSB0", size=7, encoding="latin-1")
```
</TabItem>
<TabItem label="Using write_serial_bytes">
```json
// Same request using byte array
// write_serial_bytes(
// port="/dev/ttyUSB0",
// data=[0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A]
// )
```
</TabItem>
</Tabs>
### NMEA (GPS) — ASCII Text
NMEA sentences are pure ASCII with a simple checksum:
```json
// NMEA sentences are safe with UTF-8 or ASCII
// read_serial_line(port="/dev/ttyUSB0", encoding="utf-8")
{
"line": "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,47.0,M,,*47"
}
```
### Mixed Text/Binary Protocols
Some protocols embed binary data within text framing. Handle these by:
1. Reading with Latin-1 to preserve all bytes
2. Parsing the text portions as needed
3. Extracting binary payloads by position
```json
// Read with Latin-1 to preserve everything
// read_serial(port="/dev/ttyUSB0", size=100, encoding="latin-1")
// Then parse: "DATA:" prefix + 4-byte length + binary payload + "\r\n"
```
---
## Invalid Byte Handling
When decoding with UTF-8 (or any multi-byte encoding), invalid sequences are replaced with <20> (U+FFFD). This is intentional — it prevents crashes and makes problems visible.
**Symptoms of encoding mismatch:**
| What You See | Likely Cause |
|-------------|--------------|
| Scattered <20> in output | Binary data decoded as UTF-8 |
| Truncated strings | Multi-byte sequence split across reads |
| Missing bytes | XON/XOFF stripping 0x11/0x13 |
**Diagnosis:**
```json
// Switch to Latin-1 to see raw bytes
// read_serial(port="/dev/ttyUSB0", encoding="latin-1")
// Or use the hex dump resource for full visibility
// Read resource: serial:///dev/ttyUSB0/raw
```
---
## Encoding Quick Reference
| Encoding | Byte Range | Use Case |
|----------|-----------|----------|
| `utf-8` | Multi-byte | Text protocols, console I/O, JSON (default) |
| `latin-1` | 0x000xFF → U+0000U+00FF | Binary protocols, raw byte passthrough |
| `ascii` | 0x000x7F | Strict 7-bit validation |
| (bytes) | 0255 | `write_serial_bytes` for explicit binary |
**Rules of thumb:**
1. **Text you can read?** Use UTF-8 (default)
2. **Binary protocol?** Use Latin-1 or `write_serial_bytes`
3. **Seeing <20> characters?** You're decoding binary as UTF-8 — switch to Latin-1
4. **Need to analyze raw bytes?** Use the `serial://{port}/raw` resource
---
## CRC and Checksum Calculation
Many binary protocols include error-checking bytes (CRC, checksum). When constructing frames:
1. Build the data portion as a byte array
2. Calculate the check value
3. Append the check bytes
4. Send via `write_serial_bytes` or Latin-1
**Example: Simple XOR checksum**
```python
# In your MCP client or preprocessing
data = [0x01, 0x03, 0x00, 0x00, 0x00, 0x01]
checksum = 0
for b in data:
checksum ^= b
frame = data + [checksum]
# frame = [0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x03]
```
Then send:
```json
// write_serial_bytes(port="/dev/ttyUSB0", data=[1, 3, 0, 0, 0, 1, 3])
```
<Aside type="note">
mcserial does not calculate CRCs or checksums for you. Protocol-specific framing is the responsibility of the calling code. mcserial provides the transport layer — reliable byte delivery — not protocol encoding.
</Aside>

View File

@ -0,0 +1,276 @@
---
title: Flow Control
description: Understanding software and hardware flow control for serial communication
---
import { Aside } from '@astrojs/starlight/components';
Flow control prevents data loss when one side of a serial connection sends data faster than the other side can process it. Without flow control, the receiver's buffer fills up, and incoming bytes are silently dropped.
There are two approaches: software flow control using in-band control characters, and hardware flow control using dedicated signal lines. mcserial provides tools for enabling and managing both types -- your MCP assistant configures flow control when opening a port or via `configure_serial` on an already-open port.
## Software flow control (XON/XOFF)
Software flow control uses two special byte values embedded in the data stream:
| Character | Hex | ASCII | Meaning |
|-----------|-----|-------|---------|
| XON | `0x11` | Ctrl-Q | "Resume sending -- I can accept data" |
| XOFF | `0x13` | Ctrl-S | "Stop sending -- my buffer is full" |
When the receiver's buffer fills up, it sends XOFF to the sender. The sender pauses until it receives XON, then resumes transmission.
### Enabling XON/XOFF
Enable it when opening a port:
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=9600, xonxoff=true)
```
Or toggle it on an open port:
```json
// configure_serial(port="/dev/ttyUSB0", xonxoff=true)
{
"success": true,
"port": "/dev/ttyUSB0",
"xonxoff": true,
"rtscts": false,
"dsrdtr": false
}
```
### Manual flow gating
The `set_flow_control` tool lets you manually send XON/XOFF signals or pause/resume your own output:
```json
// Tell the remote device to stop sending
// set_flow_control(port="/dev/ttyUSB0", input_flow=false)
{
"success": true,
"port": "/dev/ttyUSB0",
"changes": {"input_flow": false},
"flow_control_enabled": {"xonxoff": true, "rtscts": false}
}
// Tell the remote device to resume sending
// set_flow_control(port="/dev/ttyUSB0", input_flow=true)
```
- `input_flow=true` sends XON (allow the remote device to send to us)
- `input_flow=false` sends XOFF (tell the remote device to pause)
- `output_flow=true` resumes our outgoing data
- `output_flow=false` pauses our outgoing data
Flow control must already be enabled (via `xonxoff=true` or `rtscts=true`) for these signals to have effect.
### Advantages and limitations
**Advantages:**
- Works on any serial connection, including 3-wire (TX, RX, GND) cables with no additional signal lines
- Works over network serial connections (`socket://`, `rfc2217://`)
- No extra hardware required
<Aside type="caution" title="Binary data and XON/XOFF">
XON/XOFF reserves the byte values 0x11 and 0x13 as control characters. If your data stream naturally contains these values -- binary files, compressed data, encrypted payloads, protocol frames with arbitrary byte values -- the flow control logic will misinterpret data bytes as flow signals.
This causes two problems: the receiver strips the "control" bytes from your data (silent corruption), and the sender may pause unexpectedly when it encounters what it thinks is an XOFF signal.
If you are transferring binary data, use hardware flow control (RTS/CTS) instead, or disable flow control entirely and manage buffer sizes at the application layer.
</Aside>
## Hardware flow control (RTS/CTS)
Hardware flow control uses dedicated signal lines rather than in-band bytes. The most common pair is **RTS** (Request To Send) and **CTS** (Clear To Send).
When the receiver's buffer is getting full, it deasserts CTS. The sender watches CTS and pauses transmission until CTS is asserted again. Because the signaling happens on separate wires, it works with any data content -- there are no reserved byte values.
### Enabling RTS/CTS
Enable it when opening a port:
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=115200, rtscts=true)
```
Or toggle it on an open port:
```json
// configure_serial(port="/dev/ttyUSB0", rtscts=true)
{
"success": true,
"port": "/dev/ttyUSB0",
"xonxoff": false,
"rtscts": true,
"dsrdtr": false
}
```
### Advantages and limitations
**Advantages:**
- Works with binary data -- no reserved byte values
- Faster response than XON/XOFF (hardware signal vs. byte transmission delay)
- Handled by the UART hardware, not software
**Limitations:**
- Requires a cable with RTS and CTS lines connected (some cheap cables omit them)
- Not available on 3-wire serial connections
- Not meaningful on `socket://` network connections (no physical lines to assert)
## Hardware flow control (DSR/DTR)
An alternative hardware flow control pair. DSR (Data Set Ready) and DTR (Data Terminal Ready) can serve as flow control signals when RTS/CTS is unavailable or already used for another purpose.
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=9600, dsrdtr=true)
```
DSR/DTR flow control is less common than RTS/CTS. Most devices that support hardware flow control use RTS/CTS.
<Aside type="tip" title="RS-485 and RTS">
In RS-485 mode, the RTS line is repurposed for TX direction control -- it enables or disables the bus driver, not flow control. If you need flow control on an RS-485 bus, use XON/XOFF (software flow control) or handle buffering at the application protocol level.
Do not enable `rtscts=true` on a port that is in RS-485 mode. The RTS line toggling for flow control would interfere with the direction control toggling, causing data corruption on the bus.
</Aside>
## Choosing the right flow control
| Scenario | Recommended | Reason |
|----------|-------------|--------|
| Text/ASCII protocols (AT commands, console) | XON/XOFF | Simple, no extra wires needed |
| Binary protocols (Modbus RTU, firmware transfer) | RTS/CTS | No byte value conflicts |
| Network serial (`socket://`) | None | TCP handles flow control at the transport layer |
| High-speed transfers (115200+) | RTS/CTS | Faster response, less latency than XON/XOFF |
| 3-wire cable (TX, RX, GND only) | XON/XOFF | RTS/CTS lines not available |
| RS-485 bus | XON/XOFF or None | RTS is used for direction control |
| Unknown device | Start with None | Add flow control if you see buffer overruns |
## Flow control in mcserial
All flow control options are available in two places:
**At open time:**
```json
// open_serial_port(
// port="/dev/ttyUSB0",
// baudrate=9600,
// xonxoff=false,
// rtscts=false,
// dsrdtr=false
// )
```
**On an already-open port:**
```json
// configure_serial(
// port="/dev/ttyUSB0",
// xonxoff=true,
// rtscts=false,
// dsrdtr=false
// )
```
You can enable multiple flow control methods simultaneously, though this is unusual. The most common configurations are:
- **No flow control** (all false) -- suitable when you control the data rate, or when using protocols that handle their own flow management
- **XON/XOFF only** -- text terminals, console access, simple ASCII protocols
- **RTS/CTS only** -- binary transfers, high-speed communication, GPS receivers
### Detecting flow control problems
If you suspect flow control issues, look for these symptoms:
- **Missing data in the middle of a transfer**: the receiver's buffer overflowed and bytes were dropped. Enable flow control.
- **Data stops flowing and never resumes**: the sender received XOFF but never got XON (or CTS was deasserted and never reasserted). Check cable connections and remote device state.
- **Corrupted binary data with 0x11 or 0x13 bytes missing**: XON/XOFF is enabled on a binary stream. Switch to RTS/CTS or disable flow control.
Use `get_connection_status` to check the current flow control configuration and modem line states for all open ports.
---
## USB Adapter Compatibility
Not all USB-to-serial adapters support all flow control features. The chipset determines what's available:
| Chipset | RTS/CTS | DSR/DTR | XON/XOFF | Notes |
|---------|---------|---------|----------|-------|
| FTDI FT232R | ✅ | ✅ | ✅ | Full support, reliable |
| FTDI FT2232H | ✅ | ✅ | ✅ | Dual port, same quality |
| CP2102 | ✅ | ⚠️ | ✅ | DTR may need jumper |
| CP2104 | ✅ | ✅ | ✅ | Full support |
| CH340/CH341 | ⚠️ | ❌ | ✅ | RTS timing may be poor |
| PL2303 | ⚠️ | ⚠️ | ✅ | Varies by clone quality |
**Legend:** ✅ = reliable, ⚠️ = works but may have issues, ❌ = not supported or unreliable
<Aside type="caution" title="Cheap adapters and flow control">
Budget CH340 and PL2303 clone adapters often have poor flow control timing or don't implement hardware handshaking correctly. If you experience:
- **Dropped data at high baud rates** — the adapter isn't responding to CTS fast enough
- **RTS/CTS not affecting behavior** — lines may not be physically connected
Consider upgrading to FTDI-based adapters for critical applications requiring flow control.
</Aside>
### Debugging with modem lines
Use `get_modem_lines` to verify flow control lines are behaving as expected:
```json
// get_modem_lines(port="/dev/ttyUSB0")
{
"success": true,
"input_lines": {
"cts": false,
"dsr": false,
"ri": false,
"cd": false
},
"output_lines": {
"rts": true,
"dtr": true
}
}
```
If CTS is always false when you expect it to be asserted:
1. **Check the cable** — CTS may not be wired through
2. **Check the remote device** — it may not be asserting CTS
3. **Check the adapter** — cheap adapters may not report CTS accurately
### Deadlock scenarios
Flow control can cause communication to stall if both ends wait on each other:
**XON/XOFF deadlock:**
1. You send XOFF (buffer full)
2. Remote stops sending
3. Your code processes data and clears buffer
4. You forget to send XON
5. Remote waits forever
**RTS/CTS deadlock:**
1. You deassert RTS (not ready to receive)
2. Remote stops sending and waits for RTS
3. Your code waits for data
4. Nothing happens
**Prevention:**
- Always re-enable flow after processing (XON or reassert RTS)
- Use timeouts to detect stalls
- Check `get_modem_lines` to diagnose which side is blocking
```json
// If data stops flowing, check line states
// get_modem_lines(port="/dev/ttyUSB0")
// If CTS is false, the remote is blocking our sends
// If we've deasserted RTS, the remote is waiting for us
```

View File

@ -0,0 +1,107 @@
---
title: RS-232 vs RS-485
description: Understanding the differences between RS-232 and RS-485 serial communication
---
import { Aside } from '@astrojs/starlight/components';
RS-232 and RS-485 are two serial communication standards that solve different problems. RS-232 connects one device to another over short distances. RS-485 connects many devices on a shared bus over long distances. Understanding when to use each one determines which mcserial tools your MCP assistant will reach for.
## Physical layer
The fundamental difference is how voltage signals are represented on the wire.
**RS-232** uses single-ended signaling: each signal is measured as a voltage relative to ground. A logic HIGH is -3V to -15V, and a logic LOW is +3V to +15V (inverted from what you might expect). This means every signal needs its own wire plus a shared ground, and the voltage swings are large.
**RS-485** uses differential signaling: each signal is carried on a pair of wires (typically labeled A and B). The receiver reads the voltage *difference* between the two wires, ignoring any common-mode noise that affects both wires equally. This is what gives RS-485 its noise immunity.
| Property | RS-232 | RS-485 |
|----------|--------|--------|
| Signaling | Single-ended (voltage to ground) | Differential (voltage between A and B) |
| Voltage levels | +/-3V to +/-15V | +/-1.5V to +/-6V differential |
| Max cable length | ~15 meters (50 feet) | ~1200 meters (4000 feet) |
| Max devices | 2 (point-to-point) | 32 standard, 256 with high-impedance receivers |
| Duplex | Full-duplex (separate TX and RX lines) | Half-duplex on 2 wires, full-duplex on 4 wires |
| Noise immunity | Low (susceptible to ground loops) | High (differential rejection of common-mode noise) |
| Typical connectors | DB-9, DB-25 | Screw terminals, RJ-45, DB-9 (repurposed) |
## Communication models
### RS-232: point-to-point
RS-232 is always a conversation between two devices. One device (the DTE, Data Terminal Equipment -- originally a terminal or computer) talks to another device (the DCE, Data Communications Equipment -- originally a modem). They have separate transmit and receive lines, so both can send data simultaneously (full-duplex).
RS-232 also defines modem control lines -- dedicated signals for hardware flow control, device presence, and status:
- **DTR / DSR**: "I'm here and ready" handshake
- **RTS / CTS**: "I want to send" / "Go ahead" flow control
- **RI**: "There's an incoming call" (modems)
- **CD**: "I have a connection" (modems)
These extra lines are what make RS-232 useful for tasks beyond raw data transfer: you can detect whether a device is connected, pause data flow when a buffer fills up, or pulse DTR to reset a microcontroller.
### RS-485: multi-drop bus
RS-485 is a shared bus. Multiple devices connect to the same pair of wires. Only one device transmits at a time while all others listen. This requires a protocol layer on top of the electrical standard to manage who talks when -- Modbus RTU being the most common example.
Because the bus is shared, RS-485 devices need a way to switch between transmitting and receiving. The transceiver chip has a Driver Enable (DE) and Receiver Enable (RE) pin. When a device wants to transmit, it asserts DE to drive the bus, sends its data, then deasserts DE to go back to listening. Some adapters handle this toggling in hardware; others require software control via the RTS line.
## When to use RS-232
RS-232 is the right choice when:
- **You are connecting directly to a single device** -- a sensor, a microcontroller, a modem, or a piece of lab equipment.
- **You need modem control lines** -- DTR for resetting an Arduino, CTS for hardware flow control, DSR for detecting device presence.
- **Cable runs are short** -- within the same desk, rack, or room (under 15 meters).
- **You are using a USB-serial adapter** -- the vast majority default to RS-232 behavior.
<Aside type="note">
Most USB-serial adapters (FTDI, CP210x, CH340, PL2303) present as RS-232 ports by default. Even adapters marketed as "RS-485" typically include an RS-485 transceiver chip on the board but still appear as a standard serial port to the operating system. The difference is in the electrical output, not the software interface.
</Aside>
## When to use RS-485
RS-485 is the right choice when:
- **Multiple devices share one bus** -- sensor networks, building automation, industrial control. RS-485 supports up to 32 devices (standard) or 256 (with high-impedance receivers) on a single pair of wires.
- **Long cable runs** -- RS-485's differential signaling works reliably over 1200 meters. RS-232 becomes unreliable beyond 15 meters.
- **Industrial environments** -- factories, power plants, and outdoor installations where electrical noise from motors, inverters, and power supplies would corrupt RS-232 signals. Differential signaling rejects common-mode noise.
- **Industrial protocols** -- Modbus RTU, PROFIBUS, and DMX-512 all run over RS-485.
- **Noisy environments** -- even in non-industrial settings, long cable runs near power wiring benefit from differential signaling.
## How mcserial handles the difference
mcserial tracks which mode each port is in and restricts tools accordingly. This prevents accidentally sending RS-232 modem control commands on an RS-485 bus (which would disrupt communication by toggling the direction control line). Your MCP assistant uses the `set_port_mode` tool to switch between modes on an open port.
### Default: RS-232 mode
Every port opens in RS-232 mode. The following tools are available:
- `get_modem_lines` -- read CTS, DSR, RI, CD, RTS, DTR
- `set_modem_lines` -- set RTS and DTR
- `pulse_line` -- pulse RTS or DTR for reset sequences
- `send_break` -- send a timed break signal
- `set_break_condition` -- hold or release sustained break
### After switching: RS-485 mode
Ask the assistant to call `set_port_mode` with `mode="rs485"` to switch. The RS-232 tools become unavailable and these tools become available:
- `set_rs485_mode` -- configure hardware DE/RE direction control
- `rs485_transact` -- send data and receive response with automatic turnaround
- `rs485_scan_addresses` -- discover devices on the bus
- `check_rs485_support` -- determine hardware capabilities
### Shared tools
These tools work in both modes:
- `open_serial_port` / `close_serial_port`
- `read_serial` / `write_serial` / `write_serial_bytes`
- `read_serial_line` / `read_serial_lines` / `read_until`
- `configure_serial` / `flush_serial`
- `detect_baud_rate`
- `set_flow_control` / `set_low_latency_mode`
- `file_transfer_send` / `file_transfer_receive` / `file_transfer_send_batch`
You can switch modes on an open port at any time with `set_port_mode`. No reconnection is needed.

View File

@ -0,0 +1,301 @@
---
title: File Transfers
description: Sending and receiving files over serial using X/Y/ZMODEM protocols
---
import { Steps, Aside } from '@astrojs/starlight/components';
Serial file transfers are useful when you need to move files to or from devices that lack network connectivity: embedded systems running a bootloader, legacy industrial equipment, air-gapped machines, or remote hardware accessed through a serial console.
All of the operations described in this guide are performed through your MCP client. Ask the assistant to send or receive files, and it will call the appropriate mcserial transfer tools on your behalf. The tool call notation below shows exactly what the assistant invokes.
mcserial implements four protocol variants, all built in without external dependencies.
## Protocol comparison
| Protocol | Block Size | Batch | Resume | Error Detection | Best For |
|----------|-----------|-------|--------|-----------------|----------|
| XMODEM | 128 bytes | No | No | Checksum or CRC-16 | Legacy compatibility, simple devices |
| XMODEM-1K | 1024 bytes | No | No | CRC-16 | Faster XMODEM with wider block size |
| YMODEM | 1024 bytes | Yes | No | CRC-16 | Multiple files, preserves filenames |
| ZMODEM | Streaming | Yes | Yes | CRC-32 | Everything else (recommended) |
**ZMODEM** is the recommended protocol in nearly all cases. It streams data without waiting for per-block acknowledgment, supports batch transfers, can resume interrupted transfers, and uses 32-bit CRC for reliable error detection.
Use XMODEM only when the remote device does not support anything newer -- some bootloaders and legacy equipment only speak XMODEM.
## Sending a file
Use `file_transfer_send` to transmit a file to the remote device.
```json
// file_transfer_send(
// port="/dev/ttyUSB0",
// file_path="/home/user/firmware.bin",
// protocol="zmodem"
// )
{
"success": true,
"protocol": "zmodem",
"file": "/home/user/firmware.bin",
"bytes_sent": 65536,
"blocks_sent": 64
}
```
<Aside type="note" title="Receiver must be ready first">
The remote device must be in receive mode before you call `file_transfer_send`. For ZMODEM, many receivers auto-start when they detect the initialization sequence, but XMODEM and YMODEM receivers must be explicitly started.
If you are working with a device that has a command-line interface, send the receive command first (e.g., `rz` for ZMODEM on Linux), then call `file_transfer_send`.
</Aside>
## Receiving a file
Use `file_transfer_receive` to download a file from the remote device.
The meaning of `save_path` depends on the protocol:
- **XMODEM**: `save_path` is the full file path (e.g., `/tmp/received.bin`). XMODEM does not transmit filenames.
- **YMODEM / ZMODEM**: `save_path` is a directory. The filename comes from the sender's metadata.
```json
// Receiving with XMODEM (save_path is a file path)
// file_transfer_receive(
// port="/dev/ttyUSB0",
// save_path="/tmp/received.bin",
// protocol="xmodem"
// )
{
"success": true,
"protocol": "xmodem",
"file": "/tmp/received.bin",
"bytes_received": 8192
}
// Receiving with ZMODEM (save_path is a directory)
// file_transfer_receive(
// port="/dev/ttyUSB0",
// save_path="/tmp/downloads/",
// protocol="zmodem"
// )
{
"success": true,
"protocol": "zmodem",
"files_received": 1,
"bytes_received": 65536
}
```
Set `overwrite=true` if you want to replace existing files at the destination. By default, the transfer fails if a file already exists.
## Batch transfers
YMODEM and ZMODEM support sending multiple files in a single session. Use `file_transfer_send_batch`:
```json
// file_transfer_send_batch(
// port="/dev/ttyUSB0",
// file_paths=[
// "/home/user/firmware.bin",
// "/home/user/config.json",
// "/home/user/certs/device.pem"
// ],
// protocol="zmodem"
// )
{
"success": true,
"protocol": "zmodem",
"files_sent": 3,
"total_bytes": 98304
}
```
XMODEM does not support batch transfers -- it has no concept of filenames or session boundaries. If you try to use `file_transfer_send_batch` with XMODEM, you will get an error.
## Security
mcserial validates all file paths during transfers:
- **Directory traversal prevention**: Filenames received from the remote side are sanitized. Paths containing `..` or absolute path components are rejected.
- **Overwrite protection**: By default, receiving a file that already exists on disk returns an error. Pass `overwrite=true` to allow replacement.
- **Parent directory creation**: When receiving, mcserial creates parent directories as needed using `mkdir -p` behavior.
## Practical example: uploading firmware
This example walks through uploading a firmware binary to an embedded device over its serial debug port.
<Steps>
1. **Open the serial port at the bootloader's baud rate**
Most bootloaders use 115200 baud. Check your device's documentation.
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=115200)
{
"success": true,
"port": "/dev/ttyUSB0",
"baudrate": 115200
}
```
2. **Reset the device into bootloader mode**
For an Arduino-style device, pulse DTR. For other devices, you may need to hold a specific button or send a command.
```json
// pulse_line(port="/dev/ttyUSB0", line="dtr", duration_ms=100, active_low=true)
```
3. **Wait for the bootloader prompt**
Read lines until you see the bootloader's ready message.
```json
// read_serial_lines(port="/dev/ttyUSB0", max_lines=10)
{
"success": true,
"lines": [
"Bootloader v2.1",
"Waiting for XMODEM transfer..."
],
"count": 2
}
```
4. **Send the firmware file**
Match the protocol to what the bootloader expects. Many embedded bootloaders support XMODEM; more capable ones may support YMODEM or ZMODEM.
```json
// file_transfer_send(
// port="/dev/ttyUSB0",
// file_path="/home/user/build/firmware.bin",
// protocol="xmodem"
// )
{
"success": true,
"protocol": "xmodem",
"file": "/home/user/build/firmware.bin",
"bytes_sent": 32768,
"blocks_sent": 256
}
```
5. **Verify the device booted with the new firmware**
After the transfer completes, the bootloader typically flashes the firmware and reboots. Read the startup output to confirm.
```json
// read_serial_lines(port="/dev/ttyUSB0", max_lines=10)
{
"success": true,
"lines": [
"Firmware v3.0.1 loaded",
"CRC OK",
"Booting..."
],
"count": 3
}
```
</Steps>
## Protocol details
### XMODEM
The oldest and simplest protocol (1977). Data is sent in 128-byte blocks with a 1-byte checksum or 16-bit CRC. The receiver must acknowledge each block before the next is sent, which makes it slow on high-latency connections.
**XMODEM-1K** is identical except it uses 1024-byte blocks, reducing overhead.
### YMODEM
An extension of XMODEM that adds batch capability. The first block of each file contains the filename and size as metadata. After all files are sent, a null filename block signals the end of the batch.
### ZMODEM
A streaming protocol that does not wait for per-block acknowledgments. It sends data continuously and only pauses if the receiver reports an error. Features include:
- **Auto-start**: receivers detect the ZMODEM init sequence and begin automatically
- **Resume**: interrupted transfers can pick up where they left off
- **CRC-32**: stronger error detection than XMODEM/YMODEM's CRC-16
- **Variable block size**: adapts to line quality
---
## Error Recovery
### Interrupted Transfers
Transfers can fail due to cable disconnection, power loss, or communication errors. Each protocol handles recovery differently:
| Protocol | Recovery Method | Resume Supported |
|----------|----------------|------------------|
| XMODEM | Restart from beginning | No |
| XMODEM-1K | Restart from beginning | No |
| YMODEM | Restart from beginning | No |
| ZMODEM | Automatic resume from interruption point | Yes |
**ZMODEM resume:** When a ZMODEM transfer is interrupted and restarted, the receiver compares the incoming file header with any partial file on disk. If the file matches (same name, size, and modification time), it requests the sender to skip already-received data:
```json
// Transfer interrupted at 50% — just retry
// file_transfer_send(
// port="/dev/ttyUSB0",
// file_path="/home/user/large_firmware.bin",
// protocol="zmodem"
// )
{
"success": true,
"protocol": "zmodem",
"bytes_sent": 32768,
"resumed_at": 32768,
"total_size": 65536
}
```
For XMODEM and YMODEM, you must delete the partial file on the receiver and restart the entire transfer.
### Detecting Partial Transfers
ZMODEM and YMODEM include file size metadata in their headers. XMODEM does not — it pads the final block to 128 or 1024 bytes, so the received file may be slightly larger than the original.
**Verify transfer integrity:**
1. **Compare file sizes** — YMODEM/ZMODEM receivers know the expected size
2. **Check CRC/hash** — calculate a checksum on both ends
3. **Look for padding** — XMODEM files end with padding bytes (typically 0x1A or 0x00)
### Block Corruption
All protocols detect corruption via checksum or CRC. When corruption is detected:
1. **Receiver sends NAK** — requests retransmission
2. **Sender resends block** — the same block, not the entire file
3. **Retry limit** — after several failed attempts, the transfer aborts
Excessive retries indicate:
- **Electrical noise** — use shielded cables, shorter runs
- **Baud rate too high** — reduce speed for better reliability
- **Flow control mismatch** — enable RTS/CTS if available
```json
// Conservative settings for unreliable links
// configure_serial(port="/dev/ttyUSB0", baudrate=9600, rtscts=true)
```
### Common Transfer Errors
| Error | Likely Cause | Solution |
|-------|--------------|----------|
| "Receiver not ready" | Receiver not started | Start receiver before sender |
| Immediate timeout | Wrong protocol | Match protocol on both ends |
| Repeated NAKs | Line noise or baud mismatch | Lower baud rate, check cables |
| File exists error | Overwrite protection | Pass `overwrite=true` or delete existing |
| Size mismatch | XMODEM padding | Use YMODEM/ZMODEM for exact sizes |
<Aside type="tip" title="When in doubt, use ZMODEM">
ZMODEM handles almost every edge case gracefully: resume, corruption recovery, batch transfers, and accurate file sizes. Only fall back to XMODEM when the receiver doesn't support anything newer.
</Aside>

View File

@ -0,0 +1,215 @@
---
title: Network Serial Ports
description: Connecting to serial devices over TCP, RFC 2217, and other network protocols
---
import { Tabs, TabItem, Aside } from '@astrojs/starlight/components';
Serial devices are not always physically attached to the machine running mcserial. Serial device servers, terminal servers, and networked test equipment expose serial ports over TCP. mcserial supports these through URL schemes passed to `open_serial_port` -- the same read, write, configure, and file transfer tools work regardless of how the port is connected.
All of the operations described in this guide are performed through your MCP client. Ask the assistant to open a network serial port by providing the URL scheme, and all subsequent tool calls work identically to a local port.
## Why network serial
- **Serial device servers** (Digi, Moxa, Lantronix) bridge RS-232/RS-485 ports to your LAN
- **Remote equipment** in server rooms, factory floors, or field sites
- **Terminal servers** providing shared console access to routers, switches, and PDUs
- **Virtual serial ports** for testing and development without physical hardware
## URL schemes
All URL schemes are passed directly to `open_serial_port` in the `port` parameter. Once opened, the connection is used identically to a local serial port.
### Raw TCP socket -- `socket://`
The simplest network serial option. Bytes are forwarded as-is between the TCP socket and your tools, with no protocol framing, baud rate control, or flow control signals.
### RFC 2217 -- `rfc2217://`
Full serial port emulation over a Telnet connection (RFC 2217 / Telnet COM Port Control). The remote device server translates baud rate changes, flow control, and modem line signals into commands that control the physical serial port.
<Tabs>
<TabItem label="Raw TCP">
```json
// Connect to a Digi PortServer on port 4001
// open_serial_port(
// port="socket://192.168.1.100:4001",
// baudrate=115200
// )
{
"success": true,
"port": "socket://192.168.1.100:4001",
"mode": "rs232",
"baudrate": 115200,
"url_scheme": "socket",
"hint": "Opened via URL handler. Some features (exclusive, auto-baud) are not available."
}
```
**Characteristics:**
- No baud rate negotiation -- the device server must be pre-configured to match
- No flow control or modem line signals
- Lowest overhead; good for simple byte-stream forwarding
- Works with any serial-to-ethernet bridge
</TabItem>
<TabItem label="RFC 2217">
```json
// Connect to a device server with full serial emulation
// open_serial_port(
// port="rfc2217://192.168.1.100:2217",
// baudrate=9600
// )
{
"success": true,
"port": "rfc2217://192.168.1.100:2217",
"mode": "rs232",
"baudrate": 9600,
"url_scheme": "rfc2217",
"hint": "Opened via URL handler. Some features (exclusive, auto-baud) are not available."
}
```
**Characteristics:**
- Baud rate, data bits, parity, and stop bits can be changed remotely via `configure_serial`
- Flow control (RTS/CTS, XON/XOFF) is supported and forwarded to the physical port
- Modem line states (DTR, RTS, CTS, DSR) can be read and set
- Higher overhead than raw TCP due to Telnet negotiation
- Requires a device server that implements RFC 2217
</TabItem>
</Tabs>
After opening, all tools work the same way:
```json
// write_serial(port="socket://192.168.1.100:4001", data="AT\r\n")
{
"success": true,
"bytes_written": 4,
"port": "socket://192.168.1.100:4001"
}
// read_serial_lines(port="socket://192.168.1.100:4001", max_lines=5)
{
"success": true,
"lines": ["OK"],
"count": 1,
"port": "socket://192.168.1.100:4001"
}
```
## Debug wrapper -- `spy://`
<Aside type="tip" title="Debugging misbehaving devices">
Wrap any connection with `spy://` to log all transmitted and received bytes to stderr. This is invaluable when a device is not responding as expected and you need to see the raw traffic. The spy wrapper adds zero latency to the connection -- it simply logs a copy of every byte that passes through.
</Aside>
The `spy://` scheme wraps another serial connection and logs all traffic. It works with local ports and other URL schemes.
```json
// Debug a local serial port
// open_serial_port(port="spy:///dev/ttyUSB0", baudrate=9600)
{
"success": true,
"port": "spy:///dev/ttyUSB0",
"baudrate": 9600,
"url_scheme": "spy"
}
// Debug a network connection
// open_serial_port(port="spy://socket://192.168.1.100:4001", baudrate=115200)
```
All reads and writes are logged to stderr in the mcserial server process. Check the server's stderr output to see the traffic.
## HID-to-UART -- `cp2110://`
Silicon Labs CP2110 chips present as USB HID devices rather than standard serial ports. They do not appear as `/dev/ttyUSB*` devices, so normal serial access does not work.
```json
// open_serial_port(port="cp2110://", baudrate=9600)
{
"success": true,
"port": "cp2110://",
"baudrate": 9600,
"url_scheme": "cp2110"
}
```
This requires the `hidapi` package. Install it with the optional extra:
```bash
uv pip install mcserial[cp2110]
```
If `hidapi` is not installed and you try to open a `cp2110://` port, mcserial returns a clear error:
```json
{
"error": "cp2110:// requires the hidapi package. Install with: pip install mcserial[cp2110]",
"success": false
}
```
## Hardware grep -- `hwgrep://`
`hwgrep://` opens the first serial port whose hardware information matches a pattern. This is useful when device paths change between reboots or when multiple adapters are connected.
```json
// Find and open an FTDI device by USB VID:PID
// open_serial_port(port="hwgrep://VID:PID=0403:6001", baudrate=115200)
{
"success": true,
"port": "hwgrep://VID:PID=0403:6001",
"baudrate": 115200,
"url_scheme": "hwgrep"
}
// Find by description string
// open_serial_port(port="hwgrep://FTDI", baudrate=9600)
// Find by manufacturer
// open_serial_port(port="hwgrep://Silicon Labs", baudrate=9600)
```
The pattern is matched against the port's device path, description, hardware ID, manufacturer, product, and serial number. The first matching port is opened.
<Aside type="note">
`hwgrep://` uses pyserial's built-in hardware grep URL handler, which is separate from the `grep` parameter on `list_serial_ports`. Both search the same hardware fields, but `hwgrep://` opens the first match directly while `list_serial_ports(grep=...)` returns all matches for you to choose from.
</Aside>
## Loopback -- `loop://`
The `loop://` scheme creates a virtual loopback port where everything written is immediately available to read. No hardware is needed. This is useful for testing and development.
```json
// open_serial_port(port="loop://", baudrate=9600)
// write_serial(port="loop://", data="hello")
// read_serial(port="loop://")
{
"success": true,
"data": "hello",
"bytes_read": 5,
"port": "loop://"
}
```
## Limitations of URL-opened ports
URL-opened ports differ from local device ports in a few ways:
| Feature | Local port | URL port |
|---------|-----------|----------|
| Auto-baud detection | Yes | No (not applicable) |
| Exclusive access lock | Yes | No |
| Low-latency mode | Yes (Linux) | No |
| `set_rs485_mode` hardware ioctl | Yes | No |
| Read/write/configure | Yes | Yes |
| File transfers | Yes | Yes |
Baud rate auto-detection is skipped for URL ports because it requires reopening the port at different speeds, which does not apply to TCP or virtual connections. For `socket://`, the baud rate parameter is informational only -- the device server must be configured separately.
For `rfc2217://`, baud rate and serial parameters are sent to the remote device server, which applies them to the physical port.

View File

@ -0,0 +1,259 @@
---
title: RS-232 Communication
description: Working with modem control lines, break signals, and device resets
---
import { Steps, Aside } from '@astrojs/starlight/components';
RS-232 is the default mode when you open a port with mcserial. It provides point-to-point serial communication with modem control lines -- the signals that let you detect device presence, manage hardware flow control, and trigger resets.
All of the operations described in this guide are performed through your MCP client -- ask the assistant to open ports, read modem lines, pulse reset signals, and so on. The tool call notation below shows exactly what the assistant calls on your behalf.
Every port opens in RS-232 mode automatically. No additional configuration is needed unless you want to switch to RS-485.
## Modem control lines
RS-232 defines six modem control lines. Four are **inputs** (read from the remote device) and two are **outputs** (set by you).
### Input lines
These reflect the state of the remote device. Read them with `get_modem_lines`:
| Line | Full Name | What it means |
|------|-----------|---------------|
| CTS | Clear To Send | Remote device is ready to receive data |
| DSR | Data Set Ready | Remote device is powered and present |
| RI | Ring Indicator | Incoming call (modems) or attention signal |
| CD | Carrier Detect | Active connection established |
```json
// get_modem_lines(port="/dev/ttyUSB0")
{
"success": true,
"port": "/dev/ttyUSB0",
"input_lines": {
"cts": true,
"dsr": true,
"ri": false,
"cd": false
},
"output_lines": {
"rts": true,
"dtr": true
},
"break_condition": false
}
```
### Output lines
These are signals you send to the remote device. Set them with `set_modem_lines`:
| Line | Full Name | Common uses |
|------|-----------|-------------|
| RTS | Request To Send | Hardware flow control, TX enable for RS-485 converters |
| DTR | Data Terminal Ready | Signal our presence, trigger device reset |
```json
// set_modem_lines(port="/dev/ttyUSB0", rts=true, dtr=true)
{
"success": true,
"port": "/dev/ttyUSB0",
"rts": true,
"dtr": true
}
```
You can set one or both lines in a single call. Pass `null` (omit the parameter) to leave a line unchanged.
## Device reset sequences
Many development boards use DTR or RTS to trigger a hardware reset. The `pulse_line` tool handles the timing for you.
### Arduino reset
Arduino boards use DTR to reset the microcontroller. When DTR goes low, the reset capacitor pulls the RESET pin low briefly, rebooting the chip.
```json
// pulse_line(port="/dev/ttyUSB0", line="dtr", duration_ms=100, active_low=true)
{
"success": true,
"port": "/dev/ttyUSB0",
"line": "dtr",
"duration_ms": 100,
"active_low": true
}
```
The `active_low=true` parameter (the default) means the line pulses LOW then returns HIGH -- exactly what Arduino expects.
<Aside type="tip" title="ESP32 bootloader entry">
ESP32 and ESP8266 boards use a more complex sequence involving both DTR and RTS to enter the bootloader. The typical sequence is:
1. Set DTR low, RTS high (hold EN high, pull GPIO0 low)
2. Set DTR high, RTS low (release GPIO0, pull EN low to reset)
3. Release both lines
You can achieve this with two `set_modem_lines` calls with a short delay between them, or use the `pulse_line` tool for each line in sequence. The exact timing depends on the board's RC circuit.
</Aside>
### Practical example: connect to Arduino, reset, read boot output
<Steps>
1. **Open the serial port**
Open the port at 115200 baud (common for Arduino). If you are unsure of the baud rate, omit it and mcserial will attempt auto-detection.
```json
// open_serial_port(port="/dev/ttyACM0", baudrate=115200)
{
"success": true,
"port": "/dev/ttyACM0",
"mode": "rs232",
"baudrate": 115200,
"resource_uri": "serial:///dev/ttyACM0/data"
}
```
2. **Flush any stale data in the buffer**
Clear out anything sitting in the receive buffer from before the reset.
```json
// flush_serial(port="/dev/ttyACM0")
{
"success": true,
"port": "/dev/ttyACM0",
"flushed_input": true,
"flushed_output": true
}
```
3. **Pulse DTR to reset the Arduino**
A 100ms DTR pulse is enough for any Arduino board.
```json
// pulse_line(port="/dev/ttyACM0", line="dtr", duration_ms=100, active_low=true)
{
"success": true,
"port": "/dev/ttyACM0",
"line": "dtr",
"duration_ms": 100,
"active_low": true
}
```
4. **Read the bootloader and startup output**
After reset, the Arduino bootloader prints a brief message, then your sketch starts. Read multiple lines to capture everything.
```json
// read_serial_lines(port="/dev/ttyACM0", max_lines=20)
{
"success": true,
"lines": [
"",
"Sketch starting...",
"Initializing sensors...",
"Ready."
],
"count": 4,
"bytes_read": 52,
"port": "/dev/ttyACM0"
}
```
5. **Interact with the running sketch**
Send commands and read responses as needed.
```json
// write_serial(port="/dev/ttyACM0", data="STATUS\r\n")
{
"success": true,
"bytes_written": 8,
"port": "/dev/ttyACM0"
}
```
</Steps>
## Break signals
A break signal is a sustained LOW state on the TX line that lasts longer than a normal character frame. Some devices use break signals to enter configuration mode, trigger a reset, or synchronize communication.
mcserial provides two ways to send breaks:
### send_break -- timed pulse
`send_break` holds the line LOW for a fixed duration, then releases it. This is the most common usage.
```json
// send_break(port="/dev/ttyUSB0", duration_ms=250)
{
"success": true,
"port": "/dev/ttyUSB0",
"duration_ms": 250
}
```
The default duration is 250ms, which is long enough for most devices. The valid range is 1ms to 5000ms.
### set_break_condition -- sustained hold
`set_break_condition` holds the TX line LOW indefinitely until you explicitly release it. Use this when a protocol requires a sustained break state.
```json
// Assert break (hold TX low)
// set_break_condition(port="/dev/ttyUSB0", enabled=true)
{
"success": true,
"port": "/dev/ttyUSB0",
"break_condition": true
}
// Release break (return to normal)
// set_break_condition(port="/dev/ttyUSB0", enabled=false)
{
"success": true,
"port": "/dev/ttyUSB0",
"break_condition": false
}
```
The current break state is also reported in the `get_modem_lines` response under `break_condition`.
<Aside type="note">
Both `send_break` and `set_break_condition` require RS-232 mode (the default). If you have switched to RS-485 mode, switch back with `set_port_mode(port, "rs232")` first.
</Aside>
## Checking line states
You can verify the state of all modem lines at any time with `get_modem_lines`. This is useful for:
- **Detecting device presence** -- check DSR before attempting communication
- **Debugging connections** -- verify CTS is asserted if hardware flow control is in use
- **Monitoring** -- poll RI or CD for incoming events on modem-style devices
```json
// get_modem_lines(port="/dev/ttyUSB0")
{
"success": true,
"port": "/dev/ttyUSB0",
"input_lines": {
"cts": true,
"dsr": false,
"ri": false,
"cd": false
},
"output_lines": {
"rts": true,
"dtr": true
},
"break_condition": false
}
```
In this example, CTS is asserted (the device can receive data) but DSR is low (the device may not be powered or connected). This is a common state for USB-serial adapters that loop CTS back internally but do not connect DSR.

View File

@ -0,0 +1,279 @@
---
title: RS-485 and Modbus
description: Multi-drop bus communication, address scanning, and request-response transactions
---
import { Steps, Aside } from '@astrojs/starlight/components';
RS-485 uses a shared differential bus where multiple devices communicate over two wires. Unlike RS-232's point-to-point model, RS-485 is half-duplex -- only one device transmits at a time while all others listen. This makes it the standard for industrial protocols like Modbus RTU.
All of the operations described in this guide are performed through your MCP client. Ask the assistant to open ports, switch modes, scan for devices, and transact on the bus. The tool call notation below shows exactly what the assistant calls on your behalf.
## Switching to RS-485 mode
Ports open in RS-232 mode by default. Switch to RS-485 mode before using any RS-485 tools.
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=9600)
{
"success": true,
"port": "/dev/ttyUSB0",
"mode": "rs232",
"baudrate": 9600
}
// set_port_mode(port="/dev/ttyUSB0", mode="rs485")
{
"success": true,
"port": "/dev/ttyUSB0",
"previous_mode": "rs232",
"current_mode": "rs485",
"mode_tools": [
"set_rs485_mode",
"rs485_transact",
"rs485_scan_addresses",
"check_rs485_support"
]
}
```
Once in RS-485 mode, the RS-232-specific tools (`get_modem_lines`, `pulse_line`, etc.) are unavailable. Common tools like `read_serial`, `write_serial`, and `configure_serial` work in both modes.
## Configuring RS-485 hardware
If your USB-serial adapter or UART has hardware RS-485 support, `set_rs485_mode` configures the driver to automatically toggle the TX enable (DE/RE) line during transmission. This eliminates the need for manual RTS toggling.
```json
// set_rs485_mode(
// port="/dev/ttyUSB0",
// enabled=true,
// rts_level_for_tx=true,
// rts_level_for_rx=false,
// delay_before_tx=0.0,
// delay_before_rx=0.0
// )
{
"success": true,
"port": "/dev/ttyUSB0",
"rs485_enabled": true,
"rts_level_for_tx": true,
"rts_level_for_rx": false,
"delay_before_tx": 0.0,
"delay_before_rx": 0.0,
"loopback": false
}
```
**Parameters explained:**
| Parameter | Default | Purpose |
|-----------|---------|---------|
| `rts_level_for_tx` | `true` | RTS state when transmitting (HIGH enables the driver on most RS-485 transceivers) |
| `rts_level_for_rx` | `false` | RTS state when receiving (LOW disables the driver, allows listening) |
| `delay_before_tx` | `0.0` | Seconds to wait after asserting TX enable before sending data |
| `delay_before_rx` | `0.0` | Seconds to wait after transmission before switching to receive |
| `loopback` | `false` | Echo transmitted data back (for testing) |
## Checking hardware support
Not all USB-serial adapters support hardware RS-485. Use `check_rs485_support` to find out what your hardware can do before configuring.
```json
// check_rs485_support(port="/dev/ttyUSB0")
{
"success": true,
"port": "/dev/ttyUSB0",
"driver": "ftdi_sio",
"chip": "FT232R",
"hardware_rs485": true,
"software_fallback": true,
"kernel_rs485_ioctl": true,
"notes": [
"FTDI chips have hardware RS-485 auto-direction",
"Kernel TIOCSRS485 ioctl supported"
],
"recommendation": "Use set_rs485_mode() for automatic DE/RE control"
}
```
This tool queries the kernel driver and USB device information to determine:
- Which driver is loaded (ftdi_sio, cp210x, ch341, pl2303)
- Whether the chip supports hardware DE/RE toggling
- Whether the kernel TIOCSRS485 ioctl is available
- A recommendation for how to proceed
### Hardware support by adapter
| Chip | Hardware RS-485 | Notes |
|------|----------------|-------|
| FTDI (FT232R, FT2232, etc.) | Yes | Automatic direction control via driver |
| CP2105 / CP2108 | Yes | Hardware support in these models |
| CP2102 | No | Software RTS control required |
| CH340 / CH341 | No | Timing may be unreliable at high baud rates |
| PL2303 | No | Software RTS control required |
| Native UART (ttyS, ttyAMA) | Depends | Check if RS-485 transceiver is connected |
## Half-duplex transactions
The `rs485_transact` tool handles the complete send-then-receive cycle that RS-485 communication requires. It manages TX/RX turnaround timing automatically, whether your hardware supports it natively or needs software RTS toggling.
```json
// rs485_transact(
// port="/dev/ttyUSB0",
// data="\x01\x03\x00\x00\x00\x01\x84\x0A",
// response_timeout=1.0,
// response_length=7
// )
{
"success": true,
"port": "/dev/ttyUSB0",
"bytes_sent": 8,
"data_sent": "\u0001\u0003\u0000\u0000\u0000\u0001\u0084\n",
"response": "\u0001\u0003\u0002\u0000\u0005\u0085\u0085",
"response_bytes": 7,
"response_hex": "01030200058585",
"hardware_rs485": true
}
```
When hardware RS-485 is **not** configured, `rs485_transact` falls back to manual RTS control:
1. Asserts RTS HIGH (enable driver / TX mode)
2. Sends the data and waits for transmission to complete
3. Drops RTS LOW (disable driver / RX mode)
4. Waits for the turnaround delay
5. Reads the response
The `turnaround_delay` parameter (default 5ms) controls the pause between sending and listening. Increase it for slow devices or long cable runs.
## Scanning for devices
`rs485_scan_addresses` probes a range of addresses and reports which ones respond. This is the fastest way to discover what is connected to a bus.
```json
// rs485_scan_addresses(
// port="/dev/ttyUSB0",
// start_address=1,
// end_address=30,
// probe_template="{addr:02x}03000001",
// response_timeout=0.1
// )
{
"success": true,
"port": "/dev/ttyUSB0",
"addresses_scanned": 30,
"devices_found": 3,
"responding_addresses": [
{"address": 1, "response_length": 7, "response_hex": "01030200058585"},
{"address": 5, "response_length": 7, "response_hex": "05030200124478"},
{"address": 16, "response_length": 7, "response_hex": "10030200009930"}
],
"hardware_rs485": true
}
```
The `probe_template` uses Python's string formatting. The placeholder `{addr}` is replaced with the current address being scanned. The default template `{addr:02x}03000001` formats the address as a two-character hex string followed by a Modbus "read holding register" frame.
<Aside type="caution" title="Bus termination and biasing">
RS-485 buses longer than a few meters need proper termination resistors (120 ohm) at both ends of the cable. Without termination, reflections cause bit errors, especially at higher baud rates.
The bus also requires biasing resistors to hold the differential lines in a known state when no device is transmitting. Without biasing, the idle bus floats and receivers may interpret noise as valid data. Many RS-485 adapters include built-in biasing -- check your hardware documentation.
</Aside>
## Practical Modbus RTU workflow
This example walks through a complete Modbus RTU session: discovering devices, then reading a holding register.
<Steps>
1. **Open the port and switch to RS-485 mode**
Modbus RTU typically runs at 9600 baud with 8N1 framing.
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=9600)
// set_port_mode(port="/dev/ttyUSB0", mode="rs485")
```
2. **Configure hardware RS-485 (if supported)**
If your adapter supports hardware direction control, enable it. If not, `rs485_transact` handles RTS toggling in software.
```json
// set_rs485_mode(port="/dev/ttyUSB0", enabled=true, rts_level_for_tx=true, rts_level_for_rx=false)
```
3. **Scan the bus for responding devices**
Scan addresses 1 through 247 (the Modbus valid range). Use a short timeout per address to keep the scan fast.
```json
// rs485_scan_addresses(
// port="/dev/ttyUSB0",
// start_address=1,
// end_address=247,
// response_timeout=0.1
// )
{
"success": true,
"addresses_scanned": 247,
"devices_found": 2,
"responding_addresses": [
{"address": 1, "response_length": 7, "response_hex": "01030200058585"},
{"address": 10, "response_length": 7, "response_hex": "0a030200003071"}
]
}
```
4. **Read a holding register from a device**
Construct a Modbus RTU "Read Holding Registers" frame for address 1, register 0, quantity 1. The frame format is: `[address][function 0x03][start_hi][start_lo][qty_hi][qty_lo][CRC_lo][CRC_hi]`.
```json
// rs485_transact(
// port="/dev/ttyUSB0",
// data="\x01\x03\x00\x00\x00\x01\x84\x0A",
// response_length=7,
// encoding="latin-1"
// )
{
"success": true,
"bytes_sent": 8,
"response_hex": "01030200058585",
"response_bytes": 7,
"hardware_rs485": true
}
```
The response `01 03 02 00 05 85 85` decodes as: address 1, function 3, 2 bytes of data, value 0x0005 (decimal 5), followed by a CRC.
5. **Close the port when finished**
```json
// close_serial_port(port="/dev/ttyUSB0")
```
</Steps>
### Understanding probe_template format
The `probe_template` parameter in `rs485_scan_addresses` is a Python format string. It supports the `{addr}` placeholder with standard format specifiers:
| Template | Address 16 becomes | Use case |
|----------|-------------------|----------|
| `{addr:02x}03000001` | `1003000001` | Modbus-style hex address prefix |
| `{addr:d}` | `16` | Decimal address prefix |
| `{addr:c}` | (ASCII char 16) | Single-byte binary address |
If the template does **not** contain `{addr`, the raw address byte is prepended automatically. This means a template of `03000001` with address 16 sends byte 0x10 followed by `03000001`.
## Software vs hardware RS-485
When `check_rs485_support` reports that your adapter lacks hardware RS-485, mcserial uses **software emulation**:
- Before transmitting, RTS is asserted HIGH to enable the transceiver's driver
- After transmission completes, RTS is dropped LOW to switch back to receive mode
- A configurable turnaround delay prevents missed response bytes
Software emulation works reliably at baud rates up to about 115200. At higher speeds, the timing of the RTS toggle may not be precise enough, causing the first byte of a response to be corrupted or missed. If you need high-speed RS-485, use an adapter with hardware support (FTDI-based adapters are widely available and well-supported).

View File

@ -0,0 +1,283 @@
---
title: Timeout Tuning
description: Configuring read timeouts, inter-byte timeouts, and handling slow devices
sidebar:
order: 6
---
import { Aside } from '@astrojs/starlight/components';
Timeouts control how long mcserial waits for data before giving up. Too short, and you miss valid responses. Too long, and your workflow stalls waiting for devices that will never respond. This guide helps you find the right balance.
---
## Timeout Types
mcserial supports three timeout parameters, each serving a different purpose:
| Parameter | Where Set | What It Controls |
|-----------|-----------|------------------|
| `timeout` | `open_serial_port`, `configure_serial`, per-read | How long to wait for *any* data to arrive |
| `inter_byte_timeout` | `open_serial_port`, `configure_serial` | Max gap between consecutive bytes during a read |
| `write_timeout` | `open_serial_port`, `configure_serial` | How long to wait for the write buffer to drain |
---
## Read Timeout (timeout)
The read timeout is how long `read_serial`, `read_serial_line`, and similar operations wait before returning with whatever data has arrived (possibly nothing).
**Default:** 1.0 second
### Setting at open time
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=115200, timeout=5.0)
{
"success": true,
"timeout": 5.0
}
```
### Changing on an open port
```json
// configure_serial(port="/dev/ttyUSB0", timeout=10.0)
{
"success": true,
"timeout": 10.0
}
```
### Per-read override
For one-off long waits without changing the port configuration:
```json
// read_serial(port="/dev/ttyUSB0", timeout=30.0)
```
This overrides the port's timeout for this single read, then reverts.
---
## Inter-Byte Timeout (inter_byte_timeout)
Inter-byte timeout specifies the maximum allowed gap between bytes *during* a read. If data starts arriving but then pauses longer than this threshold, the read completes with what has been received so far.
**Default:** None (disabled)
This is useful for protocols where the message length is unknown but messages end with a pause:
```json
// open_serial_port(
// port="/dev/ttyUSB0",
// baudrate=9600,
// timeout=5.0, // Wait up to 5s for first byte
// inter_byte_timeout=0.1 // Complete if 100ms gap between bytes
// )
```
**How it interacts with `timeout`:**
1. `timeout` controls waiting for the *first* byte
2. Once data starts arriving, `inter_byte_timeout` controls gaps between subsequent bytes
3. If either expires, the read returns
<Aside type="tip" title="When to use inter_byte_timeout">
Inter-byte timeout is most useful when:
- Message length varies and there's no explicit terminator
- The device sends data in bursts with gaps
- You want to capture complete responses without knowing their size
For protocols with terminators (newlines, specific byte sequences), prefer `read_until` or `read_serial_line` instead.
</Aside>
---
## Write Timeout (write_timeout)
Write timeout controls how long `write_serial` waits for data to be accepted by the OS buffer. Under normal conditions, writes complete immediately. Write timeout only matters when:
- The output buffer is full (flow control blocking)
- The driver is waiting on hardware handshaking
- The serial adapter is overwhelmed
**Default:** None (blocking — wait indefinitely)
```json
// configure_serial(port="/dev/ttyUSB0", write_timeout=2.0)
```
If a write times out, the operation returns an error and partial data may have been sent.
---
## Baud Rate and Minimum Timeout
Lower baud rates transmit bytes slower. Your timeout must account for transmission time:
| Baud Rate | Bytes/Second | Time for 100 bytes |
|-----------|--------------|-------------------|
| 9600 | ~960 | ~104ms |
| 19200 | ~1920 | ~52ms |
| 115200 | ~11520 | ~9ms |
| 1000000 | ~100000 | ~1ms |
**Rule of thumb:** Minimum timeout should be at least `(expected_bytes / bytes_per_second) * 2` to allow for processing time on the remote device.
For a 100-byte response at 9600 baud:
```
100 bytes / 960 bytes/sec = 0.104 sec
Minimum timeout = 0.104 * 2 = 0.2 sec
```
Add more margin for devices that need processing time before responding.
---
## Slow Device Patterns
### GPS Cold Start
GPS receivers can take 3060 seconds to acquire satellites and start outputting valid NMEA sentences during a cold start:
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=9600, timeout=60.0)
// Or use a moderate default and override per-read
// open_serial_port(port="/dev/ttyUSB0", baudrate=9600, timeout=1.0)
// read_serial_line(port="/dev/ttyUSB0", timeout=60.0)
```
### Industrial Sensors with Sample Cycles
Sensors that sample on a fixed interval may not respond until the next sample:
```json
// Sensor samples every 10 seconds
// configure_serial(port="/dev/ttyUSB0", timeout=12.0)
```
### Command-Response with Processing Delay
Devices that perform computation or flash writes before responding:
```json
// Write a command that triggers firmware flash
// write_serial(port="/dev/ttyUSB0", data="FLASH 0x1000\r\n")
// Wait for completion (could take several seconds)
// read_serial_line(port="/dev/ttyUSB0", timeout=30.0)
```
---
## Long Cables and Signal Degradation
Long serial cables introduce:
- **Propagation delay** — typically negligible (nanoseconds)
- **Signal degradation** — causes bit errors, retries, and timeouts
If you experience intermittent timeouts on long cable runs:
1. **Reduce baud rate** — lower rates are more tolerant of noise
2. **Enable hardware flow control** — RTS/CTS helps with buffering
3. **Check cable quality** — use shielded cables, avoid parallel power runs
4. **Add termination** — RS-485 buses need proper termination resistors
```json
// Conservative settings for a long cable
// open_serial_port(
// port="/dev/ttyUSB0",
// baudrate=9600, // Lower baud for reliability
// timeout=5.0, // Extra time for retries
// rtscts=true // Hardware flow control
// )
```
---
## Auto-Baud Timeout (autobaud_timeout)
When opening a port without specifying a baud rate, mcserial runs auto-detection. The `autobaud_timeout` parameter controls how long to wait at each candidate rate:
```json
// open_serial_port(
// port="/dev/ttyUSB0",
// autobaud_timeout=1.0 // Wait up to 1 second per baud rate candidate
// )
```
**Default:** 0.3 seconds
Increase this if:
- The device sends data infrequently
- You're also sending a probe string that needs time to be processed
```json
// Slow device that echoes with delay
// open_serial_port(
// port="/dev/ttyUSB0",
// autobaud_probe="AT\r\n",
// autobaud_timeout=0.5
// )
```
---
## Timeout Tuning Workflow
When you're not sure what timeout to use:
1. **Start conservative** — use a long timeout (1030 seconds)
2. **Verify communication works** — can you send and receive?
3. **Measure actual response times** — how long do real responses take?
4. **Add margin** — set timeout to 23× typical response time
5. **Handle timeouts gracefully** — zero bytes or timeout errors should trigger retry or user notification
**Example measurement:**
```json
// Time a typical exchange
// write_serial(port="/dev/ttyUSB0", data="STATUS\r\n")
// read_serial_line(port="/dev/ttyUSB0", timeout=30.0)
// If response arrives in 0.5 seconds, set timeout to 1.52.0 seconds
// configure_serial(port="/dev/ttyUSB0", timeout=2.0)
```
---
## Timeout Quick Reference
| Scenario | Recommended Timeout | Notes |
|----------|-------------------|-------|
| Fast device, known protocol | 0.51.0 s | Just enough for transmission + processing |
| Unknown device | 5.0 s | Conservative starting point |
| GPS cold start | 60.0 s | Satellite acquisition takes time |
| Firmware flash commands | 30.0+ s | Flash write cycles are slow |
| Modbus RTU | 0.10.5 s | Per-transaction, short timeout for bus response |
| Auto-baud detection | 0.31.0 s | Per candidate baud rate |
| Long cable (>15m) | 2× normal | Account for potential retries |
<Aside type="note" title="Timeouts and blocking">
A timeout of `0` means non-blocking — return immediately with whatever is in the buffer.
A timeout of `None` (the default for write_timeout) means blocking — wait forever.
For read operations, prefer a finite timeout to avoid hanging indefinitely if a device fails.
</Aside>
---
## Environment Variable Defaults
Set server-wide defaults via environment variables:
```bash
# Default read timeout for all new connections
export MCSERIAL_DEFAULT_TIMEOUT=5.0
```
Individual tool calls can still override these defaults. See [Environment Variables](/reference/environment/) for the complete list.

View File

@ -0,0 +1,470 @@
---
title: Troubleshooting
description: Common errors, diagnosis patterns, and recovery strategies for serial communication
sidebar:
order: 5
---
import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
Serial communication fails in predictable ways. This guide covers the most common errors, how to diagnose them, and the recovery patterns that work.
All troubleshooting operations described here are performed through your MCP client. Ask the assistant to check port status, re-enumerate devices, or test connections — it calls the appropriate mcserial tools on your behalf.
---
## Connection Errors
### "Port is already open"
You attempted to open a port that mcserial has already opened. This happens when:
- A previous session opened the port but the close was never called
- You called `open_serial_port` twice on the same device
- Multiple agents or conversations are trying to use the same hardware
**Recovery:**
```json
// Close the existing connection first
// close_serial_port(port="/dev/ttyUSB0")
{
"success": true,
"port": "/dev/ttyUSB0"
}
// Now open fresh
// open_serial_port(port="/dev/ttyUSB0", baudrate=115200)
```
If you are not sure which ports are open, check the connection status:
```json
// get_connection_status()
{
"open_connections": [
{
"port": "/dev/ttyUSB0",
"mode": "rs232",
"baudrate": 115200
}
]
}
```
### "Port is not open"
You tried to read, write, or configure a port that is not currently open in mcserial.
**Common causes:**
- The port was never opened
- The port was closed by a previous operation
- The device was physically disconnected, causing an automatic close
**Recovery:**
Open the port before performing operations:
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=115200)
```
If the device was disconnected, re-enumerate to confirm it is back:
```json
// list_serial_ports()
{
"ports": [
{
"device": "/dev/ttyUSB0",
"description": "USB-Serial Controller",
"hwid": "USB VID:PID=0403:6001"
}
]
}
```
### "Maximum connections reached"
mcserial limits concurrent open ports to prevent resource exhaustion (default: 10). You have hit that limit.
**Recovery:**
Close ports you are no longer using:
```json
// get_connection_status()
// Identify unused ports, then:
// close_serial_port(port="/dev/ttyUSB1")
```
If you genuinely need more concurrent connections, increase the limit via environment variable before starting mcserial:
```bash
export MCSERIAL_MAX_CONNECTIONS=20
```
See the [Environment Variables](/reference/environment/) reference for details.
---
## Permission Errors
### "Permission denied" (Linux)
The user running mcserial does not have access to the serial device. On Linux, serial ports are typically owned by the `dialout` or `uucp` group.
<Tabs>
<TabItem label="Add to dialout group">
```bash
# Add your user to the dialout group
sudo usermod -a -G dialout $USER
# Log out and back in for the change to take effect
# Or use newgrp for the current session:
newgrp dialout
```
</TabItem>
<TabItem label="Verify group membership">
```bash
# Check your groups
groups
# Should include: dialout
# Check device permissions
ls -la /dev/ttyUSB0
# Should show: crw-rw---- 1 root dialout ...
```
</TabItem>
</Tabs>
### udev Rules for Persistent Permissions
If devices keep losing permissions after reconnection, create a udev rule:
```bash
# /etc/udev/rules.d/99-serial.rules
SUBSYSTEM=="tty", GROUP="dialout", MODE="0660"
# Reload rules
sudo udevadm control --reload-rules
sudo udevadm trigger
```
For specific devices (e.g., all FTDI adapters):
```bash
# /etc/udev/rules.d/99-ftdi.rules
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", GROUP="dialout", MODE="0660"
```
---
## Device Disconnection
### Detecting a disconnected device
When a USB serial device is physically unplugged, subsequent operations return errors. The exact error varies by operation:
- Reads may return empty data or timeout
- Writes may throw `serial.serialutil.SerialException`
- Status queries may report the port as closed
**Detection pattern:**
```json
// Try a simple read with short timeout
// read_serial(port="/dev/ttyUSB0", size=1, timeout=0.5)
// If the device is gone, you'll see:
{
"error": "Port /dev/ttyUSB0 is not open",
"success": false
}
```
### Re-enumeration after reconnection
After plugging a device back in, it may appear on a different port (e.g., `/dev/ttyUSB1` instead of `/dev/ttyUSB0`). Always re-enumerate:
```json
// list_serial_ports(grep="FTDI")
{
"ports": [
{
"device": "/dev/ttyUSB1",
"description": "FT232R USB UART",
"hwid": "USB VID:PID=0403:6001 SER=A50285BI"
}
]
}
```
<Aside type="tip" title="Use hwgrep:// for reconnection resilience">
If you know the hardware identifier, open via `hwgrep://` instead of a device path. This finds the device regardless of which port it enumerated on:
```json
// open_serial_port(port="hwgrep://FTDI", baudrate=115200)
```
See [URL Schemes](/reference/url-handlers/) for details.
</Aside>
### Graceful reconnection workflow
<Steps>
1. **Detect the disconnection**
Monitor for errors or empty reads that indicate the device is gone.
2. **Close the stale connection**
```json
// close_serial_port(port="/dev/ttyUSB0")
```
This cleans up mcserial's internal state even if the device is gone.
3. **Wait for reconnection**
Poll `list_serial_ports` until the device reappears.
4. **Open the new port**
```json
// list_serial_ports(grep="FTDI")
// open_serial_port(port="/dev/ttyUSB1", baudrate=115200)
```
</Steps>
---
## No Response / Timeout Issues
### Differentiating "no data" from "wrong baud rate"
When `read_serial` returns empty, the cause could be:
1. **Correct baud, device not sending** — the device is connected but silent
2. **Wrong baud rate** — data is arriving but garbled or misframed
3. **Hardware issue** — cable problem, device powered off, TX/RX swapped
**Diagnosis steps:**
```json
// 1. Check if the port is actually open and configured
// get_connection_status()
// 2. Try auto-baud detection on an open port
// detect_baud_rate(port="/dev/ttyUSB0", probe="U", timeout_per_rate=0.5)
{
"detected_baudrate": 115200,
"confidence": 0.85,
"results": [
{"baudrate": 115200, "score": 0.85},
{"baudrate": 9600, "score": 0.12}
]
}
```
If auto-detection succeeds with a different baud rate, reconfigure:
```json
// configure_serial(port="/dev/ttyUSB0", baudrate=115200)
```
### Slow devices and timeout adjustment
Some devices take time to respond:
- GPS receivers during cold start: 30-60 seconds
- Industrial sensors with long sample cycles
- Devices that process commands before responding
**Adjust the read timeout:**
```json
// configure_serial(port="/dev/ttyUSB0", timeout=10.0)
```
Or specify per-read:
```json
// read_serial(port="/dev/ttyUSB0", timeout=30.0)
```
See the [Timeout Tuning](/guides/timeout-tuning/) guide for detailed guidance.
---
## RS-485 Bus Issues
### No response from any device
If RS-485 transactions return nothing, check:
1. **Termination** — RS-485 buses need 120Ω termination resistors at each end
2. **Bias resistors** — long buses or noisy environments may need bias
3. **A/B polarity** — some adapters swap A and B (try swapping the wires)
4. **DE/RE timing** — the driver enable timing may be wrong
**Check RS-485 configuration:**
```json
// The port should be in RS-485 mode
// get_connection_status()
{
"open_connections": [
{
"port": "/dev/ttyUSB0",
"mode": "rs485",
"rs485_config": {
"enabled": true,
"rts_level_for_tx": true,
"delay_before_tx": 0,
"delay_before_rx": 0.005
}
}
]
}
```
### Bus contention (garbled responses)
If multiple devices respond simultaneously or responses are corrupted:
- **Address collision** — two devices have the same address
- **Timing overlap** — insufficient turnaround delay between TX and RX
- **Echo enabled** — loopback mode is creating interference
**Increase turnaround delay:**
```json
// set_rs485_mode(port="/dev/ttyUSB0", delay_before_rx=0.01)
```
### Scanning for responsive devices
Use `rs485_scan_addresses` to discover which devices respond:
```json
// rs485_scan_addresses(
// port="/dev/ttyUSB0",
// start_address=1,
// end_address=32,
// response_timeout=0.1
// )
{
"success": true,
"responding_addresses": [1, 5, 12],
"total_scanned": 32,
"scan_time_seconds": 3.2
}
```
---
## Auto-Baud Detection Failures
### "No data received or low confidence"
Auto-baud detection relies on seeing data to analyze. It fails when:
- The device is not sending anything (waiting for a command)
- The device requires a specific wake-up sequence
- All baud rates produced only garbled data (hardware issue)
**Try with a probe string:**
```json
// Some devices echo input or respond to specific commands
// open_serial_port(
// port="/dev/ttyUSB0",
// autobaud_probe="ATI\r\n",
// autobaud_timeout=0.5
// )
```
The probe is sent at each candidate baud rate. If the device echoes or responds, the detection has data to analyze.
**Common probe strings:**
| Device Type | Probe | Notes |
|-------------|-------|-------|
| Modem/AT command | `ATI\r\n` | Returns device info |
| Echo-enabled | `UUUUU` | 0x55 is a sync pattern |
| GPS (NMEA) | (none) | Usually sends continuously |
| Modbus RTU | (protocol-specific) | Send a read holding register command |
### Detection returns wrong baud rate
If detection consistently picks the wrong rate, the heuristics may be confused by the data pattern. Fall back to explicit baud rate specification:
```json
// open_serial_port(port="/dev/ttyUSB0", baudrate=115200)
```
<Aside type="note">
Auto-baud detection works best with ASCII text or protocols that produce printable characters. Binary protocols with arbitrary byte values may confuse the heuristics.
</Aside>
---
## File Transfer Failures
### "Receiver not ready" or immediate timeout
The remote device must be in receive mode before you call `file_transfer_send`. For XMODEM and YMODEM, start the receiver first.
**Typical workflow:**
```json
// 1. Send the receive command to the device
// write_serial(port="/dev/ttyUSB0", data="rx firmware.bin\r\n")
// 2. Wait for the receiver to start (look for NAK or 'C' for CRC mode)
// read_serial(port="/dev/ttyUSB0", timeout=5.0)
// 3. Now send the file
// file_transfer_send(port="/dev/ttyUSB0", file_path="/tmp/firmware.bin", protocol="xmodem")
```
### Interrupted ZMODEM transfer
ZMODEM supports resume. If a transfer was interrupted:
```json
// Just try sending again — ZMODEM negotiates resume automatically
// file_transfer_send(port="/dev/ttyUSB0", file_path="/tmp/large_file.bin", protocol="zmodem")
```
The receiver should offer to resume from where it left off, skipping already-received data.
### Block errors and retransmissions
All protocols have error detection and will request retransmission of corrupted blocks. However, excessive errors indicate:
- **Electrical noise** — use shielded cables, add ferrite chokes
- **Baud rate too high** — reduce baud rate for long cables
- **Flow control issues** — enable RTS/CTS for high-speed transfers
See [File Transfers](/guides/file-transfers/) for protocol-specific details.
---
## Quick Reference: Error → Action
| Error Message | Likely Cause | First Action |
|--------------|--------------|--------------|
| "Port is already open" | Double open | `close_serial_port`, then reopen |
| "Port is not open" | Never opened or disconnected | `open_serial_port` |
| "Permission denied" | Missing dialout group | `sudo usermod -a -G dialout $USER` |
| "Maximum connections reached" | Too many open ports | Close unused ports |
| "Device or resource busy" | Another process has the port | Check `lsof /dev/ttyUSB0` |
| Empty reads, no data | Wrong baud or device silent | `detect_baud_rate` or probe |
| Garbled text | Wrong baud rate | `configure_serial(baudrate=...)` |
| "Unsupported URL scheme" | Typo in URL | Check scheme spelling |
| "cp2110:// requires hidapi" | Missing extra | `pip install mcserial[cp2110]` |

View File

@ -0,0 +1,82 @@
---
title: mcserial
description: MCP server for serial port communication -- RS-232, RS-485, and file transfers via Model Context Protocol.
template: splash
hero:
tagline: Serial ports meet the Model Context Protocol. Discover, configure, read, write, and transfer files -- RS-232, RS-485, and 7 URL schemes, all callable as MCP tools.
image:
file: ../../assets/logo.svg
actions:
- text: Get Started
link: /tutorials/getting-started/
icon: right-arrow
variant: primary
- text: Tool Reference
link: /reference/tools-common/
variant: minimal
---
import { Card, CardGrid, Aside } from '@astrojs/starlight/components';
## What mcserial does
<CardGrid stagger>
<Card title="RS-232 and RS-485" icon="setting">
Full RS-232 modem line control -- RTS, DTR, CTS, DSR, break signals, device reset pulses. Switch to RS-485 mode for half-duplex bus communication with automatic TX/RX direction, device scanning, and Modbus transactions.
</Card>
<Card title="File Transfers" icon="document">
Send and receive files over serial using XMODEM (128B blocks), XMODEM-1K, YMODEM (batch mode), or ZMODEM (streaming with auto-resume). Batch transfers supported for YMODEM and ZMODEM.
</Card>
<Card title="Network and Virtual Ports" icon="external">
Connect to serial-over-ethernet bridges via `socket://`, configure remote ports with `rfc2217://`, test without hardware on `loop://`, or debug traffic with `spy://`. Seven URL schemes total.
</Card>
<Card title="Baud Rate Detection" icon="random">
Omit the baud rate when opening a port and mcserial will detect it automatically by analyzing data patterns, sync bytes, and byte distributions. Returns a confidence score and ranked candidates.
</Card>
</CardGrid>
## Quick start
Install mcserial and register it with your MCP client:
```bash
# Add to Claude Code (one-time setup)
claude mcp add mcserial -- uvx mcserial
```
Then ask your assistant to work with serial ports. It calls the tools behind the scenes:
```
list_serial_ports() # find available ports
open_serial_port(port="loop://", baudrate=9600) # open (loop:// = no hardware needed)
write_serial(port="loop://", data="hello\n") # send data
read_serial(port="loop://") # read response
close_serial_port(port="loop://") # release the port
```
You interact in natural language -- "list my serial ports," "open the loopback port at 9600 baud," "send hello." See the [Getting Started tutorial](/tutorials/getting-started/) for the full walkthrough with example responses.
## Supported URL schemes
In addition to local device paths (`/dev/ttyUSB0`, `COM3`), `open_serial_port` accepts these URL schemes:
| Scheme | Description | Example |
|--------|-------------|---------|
| `loop://` | In-memory loopback -- writes echo back as reads | `loop://` |
| `socket://` | Raw TCP socket for serial-to-ethernet bridges | `socket://192.168.1.100:4001` |
| `rfc2217://` | Telnet COM Port Control with remote baud/flow config | `rfc2217://192.168.1.100:2217` |
| `spy://` | Debug wrapper that logs all traffic to stderr | `spy:///dev/ttyUSB0` |
| `hwgrep://` | Open first port matching a hardware pattern | `hwgrep://FTDI` |
| `cp2110://` | Silicon Labs HID-to-UART (requires `[cp2110]` extra) | `cp2110://` |
| `alt://` | Alternate port backend | `alt:///dev/ttyUSB0` |
## 30 tools across 4 categories
mcserial is a [FastMCP](https://gofastmcp.com) server wrapping [pyserial](https://pyserial.readthedocs.io/) into organized tool groups:
- **[Common tools](/reference/tools-common/)** (18) -- port discovery, open/close, read/write, configuration, baud detection
- **[RS-232 tools](/reference/tools-rs232/)** (5) -- modem line control, break signals, reset pulses
- **[RS-485 tools](/reference/tools-rs485/)** (4) -- bus configuration, transactions, address scanning
- **[File transfer tools](/reference/tools-file-transfer/)** (3) -- X/Y/ZMODEM send, receive, batch send
Ports open in RS-232 mode by default. Use `set_port_mode()` to switch to RS-485 when working with multi-drop buses. Common tools work in both modes.

View File

@ -0,0 +1,110 @@
---
title: Environment Variables
description: Configuration via environment variables
sidebar:
order: 7
---
import { Aside } from '@astrojs/starlight/components';
mcserial reads configuration from environment variables at startup. These variables set server-wide defaults that apply to all connections unless overridden by tool parameters.
---
## Server Configuration
### MCSERIAL_DEFAULT_BAUDRATE
Default baud rate used when `open_serial_port` is called without a `baudrate` parameter and auto-detection either fails or is not applicable (e.g., URL-based connections).
| | |
|---|---|
| **Default** | `9600` |
| **Type** | Integer |
| **Example** | `MCSERIAL_DEFAULT_BAUDRATE=115200` |
```bash
# Set a higher default for modern devices
export MCSERIAL_DEFAULT_BAUDRATE=115200
```
### MCSERIAL_DEFAULT_TIMEOUT
Default read timeout in seconds for new connections. Applied as the `timeout` parameter when `open_serial_port` is called without an explicit timeout.
| | |
|---|---|
| **Default** | `1.0` |
| **Type** | Float (seconds) |
| **Example** | `MCSERIAL_DEFAULT_TIMEOUT=2.0` |
```bash
# Longer timeout for slow devices
export MCSERIAL_DEFAULT_TIMEOUT=5.0
```
### MCSERIAL_MAX_CONNECTIONS
Maximum number of simultaneously open serial port connections. Attempts to open more ports than this limit return an error.
| | |
|---|---|
| **Default** | `10` |
| **Type** | Integer |
| **Example** | `MCSERIAL_MAX_CONNECTIONS=20` |
```bash
# Allow more concurrent connections
export MCSERIAL_MAX_CONNECTIONS=20
```
<Aside type="note">
Each open connection holds a file descriptor and kernel buffer memory. The default of 10 is sufficient for most use cases. Increase it only if you genuinely need many simultaneous connections.
</Aside>
---
## Setting Environment Variables
<Aside type="tip">
When using mcserial with Claude Code, set environment variables before adding the server:
```bash
# Option 1: Inline with the command
claude mcp add mcserial "env MCSERIAL_DEFAULT_BAUDRATE=115200 uvx mcserial"
# Option 2: Export in your shell profile
export MCSERIAL_DEFAULT_BAUDRATE=115200
claude mcp add mcserial "uvx mcserial"
```
</Aside>
For other MCP clients, consult their documentation for how to pass environment variables to server processes.
---
## Installation Extras
mcserial supports optional dependency groups for specialized hardware.
### mcserial[cp2110]
Adds the `hidapi` package for Silicon Labs CP2110 HID-to-UART bridge support. Required to use the `cp2110://` URL scheme with `open_serial_port`.
```bash
# Install with CP2110 support
uv pip install mcserial[cp2110]
# Or with uvx
uvx --with hidapi mcserial
```
Without this extra installed, attempting to open a `cp2110://` URL returns an error:
```
cp2110:// requires the hidapi package. Install with: pip install mcserial[cp2110]
```
<Aside type="note">
The `hidapi` package has system-level dependencies on some platforms. On Linux, you may need `libhidapi-dev` or equivalent. On macOS, it installs cleanly via pip. On Windows, no additional system packages are needed.
</Aside>

View File

@ -0,0 +1,143 @@
---
title: MCP Resources
description: Read-only data endpoints exposed as MCP resources
sidebar:
order: 6
---
import { Aside } from '@astrojs/starlight/components';
MCP resources are read-only data endpoints that clients can access without calling tools. They provide a way to passively observe port state and data, separate from the action-oriented tool interface.
<Aside type="note">
Resources are read-only. They do not modify port state, open connections, or send data. To interact with ports, use the [Common Tools](/reference/tools-common/).
</Aside>
---
## serial://ports
List all available serial ports on the system. Returns a Markdown-formatted list with device paths, descriptions, and open/closed status.
**URI:** `serial://ports`
**Example output:**
```markdown
# Available Serial Ports
- /dev/ttyUSB0 [OPEN]: USB-Serial Controller D
- /dev/ttyUSB1 [closed]: FT232R USB UART
- /dev/ttyACM0 [closed]: Arduino Mega 2560
```
This resource calls `list_serial_ports()` internally with default parameters (`usb_only=True`). For filtered results, use the `list_serial_ports` tool directly with the `grep` parameter.
---
## `serial://{port}/data`
Read available data from an open serial port. Returns the data decoded as UTF-8 with a byte count prefix.
**URI pattern:** `serial://{port}/data`
**Example URIs:**
```
serial:///dev/ttyUSB0/data
serial://COM3/data
serial://loop:///data
```
**Example output:**
```
[42 bytes from /dev/ttyUSB0]
AT+GMR
OK
ESP8266 firmware v2.1
```
If no data is available in the port's input buffer, returns:
```
[No data available on /dev/ttyUSB0]
```
If the port is not open, returns an error message prompting you to use `open_serial_port` first.
<Aside type="caution">
Reading data from a resource consumes the bytes from the serial buffer. Once read, the data is no longer available for subsequent reads (via either resources or tools).
</Aside>
---
## `serial://{port}/status`
Get the current configuration and connection status for an open serial port. Returns a Markdown-formatted status report.
**URI pattern:** `serial://{port}/status`
**Example URIs:**
```
serial:///dev/ttyUSB0/status
serial://COM3/status
```
**Example output:**
```markdown
# Serial Port Status: /dev/ttyUSB0
- Mode: RS232
- Open: True
- Baudrate: 115200
- Bytesize: 8
- Parity: N
- Stopbits: 1
- Timeout: 1.0s
- Bytes waiting (in): 0
- Bytes waiting (out): 0
- CTS: True
- DSR: True
- RI: False
- CD: False
- RTS: True
- DTR: True
```
This resource provides a snapshot of all port settings, buffer counts, and modem line states in a single read. Useful for diagnostics without calling multiple tools.
---
## `serial://{port}/raw`
Read available data from an open serial port as a hex dump. Same as `serial://{port}/data` but returns raw bytes in hexadecimal format instead of decoded text.
**URI pattern:** `serial://{port}/raw`
**Example URIs:**
```
serial:///dev/ttyUSB0/raw
serial://COM3/raw
```
**Example output:**
```
[8 bytes from /dev/ttyUSB0]
01 03 04 00 64 00 c8 fa
```
Use this resource when working with binary protocols (Modbus, proprietary framing) where hex representation is more useful than decoded text.
<Aside type="caution">
Like the data resource, reading raw data consumes the bytes from the serial buffer.
</Aside>
---
## URI Encoding
The `{port}` segment in resource URIs uses the device path directly. For paths containing special characters (like forward slashes in Linux device paths), the MCP client handles URL encoding:
| Device Path | Resource URI |
|-------------|-------------|
| `/dev/ttyUSB0` | `serial:///dev/ttyUSB0/data` |
| `COM3` | `serial://COM3/data` |
| `loop://` | `serial://loop:///data` |
The server URL-decodes the port parameter internally, so both encoded and unencoded paths work correctly.

View File

@ -0,0 +1,459 @@
---
title: Common Tools
description: Core serial port tools available in all modes
sidebar:
order: 1
---
import { Aside } from '@astrojs/starlight/components';
These 18 tools work in both RS-232 and RS-485 modes. They cover port discovery, connection management, reading and writing data, configuration, flow control, and diagnostics.
<Aside type="note">
Examples use tool-call notation: `tool_name(param=value)`. These are MCP tool calls made by the assistant, not code you write directly. Boolean values use JSON convention (`true`/`false`).
</Aside>
---
## Port Discovery
### list_serial_ports
List available serial ports on the system. Call this first to discover what ports are available before opening one.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `usb_only` | `bool` | `True` | Only show USB serial devices. Set `False` to include legacy/phantom ttyS ports. |
| `grep` | `str \| None` | `None` | Regex pattern to filter ports. Matches against device path, description, hardware ID, manufacturer, product, and serial number. |
**Returns:** List of port objects with `device`, `description`, `hwid`, `manufacturer`, `product`, `serial_number`, and `is_open` fields. Sorted with USB ports (ttyUSB, ttyACM) first.
```
# List all USB serial devices
list_serial_ports()
# Include legacy ttyS ports
list_serial_ports(usb_only=false)
# Find FTDI adapters
list_serial_ports(grep="FTDI")
# Match specific USB VID:PID
list_serial_ports(grep="VID:PID=0403:6001")
```
---
## Connection Management
### open_serial_port
Open a serial port connection with optional auto-baud detection. If `baudrate` is not specified, the server automatically detects the baud rate by analyzing incoming data patterns.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path (`/dev/ttyUSB0`, `COM3`) or URL scheme (`socket://`, `rfc2217://`, `loop://`, `spy://`, `cp2110://`). See [URL Schemes](/reference/url-handlers/) for details. |
| `baudrate` | `int \| None` | `None` | Baud rate. If `None`, auto-detect is attempted (recommended for unknown devices). Falls back to `MCSERIAL_DEFAULT_BAUDRATE` if detection fails. |
| `bytesize` | `Literal[5, 6, 7, 8]` | `8` | Number of data bits. |
| `parity` | `Literal["N", "E", "O", "M", "S"]` | `"N"` | Parity checking: None, Even, Odd, Mark, or Space. |
| `stopbits` | `Literal[1, 1.5, 2]` | `1` | Number of stop bits. |
| `timeout` | `float` | `1.0` | Read timeout in seconds. Default comes from `MCSERIAL_DEFAULT_TIMEOUT`. |
| `write_timeout` | `float \| None` | `None` | Write timeout in seconds. `None` means blocking writes. |
| `inter_byte_timeout` | `float \| None` | `None` | Timeout between bytes during read. `None` disables inter-byte timeout. |
| `xonxoff` | `bool` | `False` | Enable software flow control (XON/XOFF). |
| `rtscts` | `bool` | `False` | Enable hardware RTS/CTS flow control. |
| `dsrdtr` | `bool` | `False` | Enable hardware DSR/DTR flow control. |
| `exclusive` | `bool` | `False` | Request exclusive access (lock port from other processes). |
| `autobaud_probe` | `str \| None` | `None` | String to send during auto-detection (e.g., `"UUUUU"` for sync). |
| `autobaud_timeout` | `float` | `0.3` | Timeout per rate during auto-detection in seconds. |
**Returns:** Connection status dict with `success`, `port`, `mode`, `baudrate`, `bytesize`, `parity`, `stopbits`, flow control settings, and `resource_uri`. When auto-baud is used, includes an `autobaud` object with detection details.
<Aside type="tip">
When `baudrate` is `None` and the port is a local device (not a URL scheme), auto-baud detection runs automatically. For best results with devices that only respond to input, pass an `autobaud_probe` string like `"UUUUU"` or `"AT\r\n"`.
</Aside>
```
# Auto-detect baud rate
open_serial_port(port="/dev/ttyUSB0")
# Explicit baud rate with 8N1 (most common settings)
open_serial_port(port="/dev/ttyUSB0", baudrate=115200)
# RS-232 with hardware flow control
open_serial_port(port="/dev/ttyUSB0", baudrate=9600, rtscts=true)
# Exclusive access prevents other processes from using the port
open_serial_port(port="/dev/ttyUSB0", baudrate=9600, exclusive=true)
# Auto-detect with sync probe
open_serial_port(port="/dev/ttyUSB0", autobaud_probe="UUUUU")
# Open a network serial port
open_serial_port(port="socket://192.168.1.100:4001", baudrate=115200)
# Loopback for testing (no hardware needed)
open_serial_port(port="loop://", baudrate=9600)
```
<Aside type="note">
URL-opened ports skip auto-baud detection and exclusive access. The `inter_byte_timeout` and `exclusive` parameters are not passed to URL-based connections.
</Aside>
### close_serial_port
Close an open serial port connection and release it for other processes.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to close. |
**Returns:** Status dict with `success`, `port`, and `message` fields.
```
close_serial_port(port="/dev/ttyUSB0")
```
### set_port_mode
Switch a serial port between RS-232 and RS-485 modes. Mode determines which mode-specific tools are available.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to configure. |
| `mode` | `Literal["rs232", "rs485"]` | required | Target mode. |
**Returns:** Status dict with `success`, `previous_mode`, `current_mode`, and `mode_tools` (list of tools available in the new mode).
```
# Switch to RS-485 mode for Modbus communication
set_port_mode(port="/dev/ttyUSB0", mode="rs485")
# Switch back to RS-232
set_port_mode(port="/dev/ttyUSB0", mode="rs232")
```
<Aside type="caution">
Ports open in RS-232 mode by default. You must call `set_port_mode` before using RS-485 specific tools like `rs485_transact` or `rs485_scan_addresses`.
</Aside>
### get_connection_status
Get status of all open serial connections. Takes no parameters.
**Returns:** Dict with `connections` (map of port to status details) and `count`. Each connection includes `is_open`, `mode`, `baudrate`, `bytesize`, `parity`, `stopbits`, `timeout`, `write_timeout`, `inter_byte_timeout`, flow control flags, buffer counts (`in_waiting`, `out_waiting`), modem line states (`cts`, `dsr`, `ri`, `cd`, `rts`, `dtr`), and `resource_uri`.
```
get_connection_status()
```
---
## Writing Data
### write_serial
Write string data to an open serial port. Data is encoded using the specified encoding before sending.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to write to. |
| `data` | `str` | required | String data to send. |
| `encoding` | `str` | `"utf-8"` | Character encoding for the data. |
**Returns:** Dict with `success`, `bytes_written`, and `port`.
```
# Send an AT command
write_serial(port="/dev/ttyUSB0", data="AT\r\n")
# Send with specific encoding
write_serial(port="/dev/ttyUSB0", data="Hello", encoding="ascii")
```
### write_serial_bytes
Write raw bytes to an open serial port. Use this when you need to send binary data or specific byte sequences.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to write to. |
| `data` | `list[int]` | required | List of byte values (0-255). Values outside this range return an error. |
**Returns:** Dict with `success`, `bytes_written`, and `port`.
```
# Send AT\r\n as raw bytes
write_serial_bytes(port="/dev/ttyUSB0", data=[65, 84, 13, 10])
# Send a Modbus frame
write_serial_bytes(port="/dev/ttyUSB0", data=[0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A])
```
<Aside type="caution">
Byte values are validated before conversion. Any value outside 0-255 returns an error listing the invalid values, rather than silently truncating.
</Aside>
---
## Reading Data
### read_serial
Read data from an open serial port. Returns both decoded text and raw hex representation.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to read from. |
| `size` | `int \| None` | `None` | Maximum bytes to read. `None` reads all available data. |
| `encoding` | `str` | `"utf-8"` | Character encoding for decoding. |
| `timeout` | `float \| None` | `None` | Override the port's read timeout for this call. `None` uses the port default. |
**Returns:** Dict with `success`, `data` (decoded string), `bytes_read`, `raw_hex`, and `port`. Decoding errors are replaced rather than raising exceptions.
```
# Read all available data
read_serial(port="/dev/ttyUSB0")
# Read exactly 10 bytes
read_serial(port="/dev/ttyUSB0", size=10)
# Read with a longer timeout
read_serial(port="/dev/ttyUSB0", timeout=5.0)
```
### read_serial_line
Read a single line (until newline character) from an open serial port.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to read from. |
| `encoding` | `str` | `"utf-8"` | Character encoding for decoding. |
**Returns:** Dict with `success`, `line` (decoded string with trailing CR/LF stripped), `bytes_read`, and `port`.
```
read_serial_line(port="/dev/ttyUSB0")
```
### read_serial_lines
Read multiple lines from an open serial port. Reads up to `max_lines`, stopping early if no more data is available (readline returns empty due to timeout).
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to read from. |
| `max_lines` | `int` | `10` | Maximum number of lines to read. Must be 1-1000. |
| `encoding` | `str` | `"utf-8"` | Character encoding for decoding. |
**Returns:** Dict with `success`, `lines` (list of decoded strings), `count`, `bytes_read`, and `port`.
```
# Read up to 10 lines (default)
read_serial_lines(port="/dev/ttyUSB0")
# Read up to 50 lines of buffered output
read_serial_lines(port="/dev/ttyUSB0", max_lines=50)
```
### read_until
Read data until a specific terminator sequence is received. More flexible than `read_serial_line` because it supports any terminator string, not just newline.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to read from. |
| `terminator` | `str` | `"\n"` | Byte sequence to stop at. |
| `size` | `int \| None` | `None` | Maximum bytes to read. `None` means no limit (uses timeout). |
| `encoding` | `str` | `"utf-8"` | Character encoding for the terminator and result. |
**Returns:** Dict with `success`, `data` (decoded string), `bytes_read`, `raw_hex`, `port`, and `terminator_found` (bool indicating whether the terminator was actually found or the read timed out).
```
# Read until "OK\r\n"
read_until(port="/dev/ttyUSB0", terminator="OK\r\n")
# Read until a prompt character
read_until(port="/dev/ttyUSB0", terminator=">")
# Read until terminator with a size limit
read_until(port="/dev/ttyUSB0", terminator="\r\n", size=256)
```
---
## Configuration
### configure_serial
Modify settings on an already-open serial port. Only the parameters you provide are changed; everything else stays the same.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to configure. |
| `baudrate` | `int \| None` | `None` | New baud rate. `None` = no change. |
| `timeout` | `float \| None` | `None` | New read timeout in seconds. `None` = no change. |
| `write_timeout` | `float \| None` | `None` | New write timeout in seconds. `None` = no change. |
| `inter_byte_timeout` | `float \| None` | `None` | Timeout between bytes. `None` = no change. |
| `xonxoff` | `bool \| None` | `None` | Software flow control XON/XOFF. `None` = no change. |
| `rtscts` | `bool \| None` | `None` | Hardware RTS/CTS flow control. `None` = no change. |
| `dsrdtr` | `bool \| None` | `None` | Hardware DSR/DTR flow control. `None` = no change. |
| `rts` | `bool \| None` | `None` | Set RTS line state. `None` = no change. |
| `dtr` | `bool \| None` | `None` | Set DTR line state. `None` = no change. |
**Returns:** Dict with `success`, `port`, and all current settings (`baudrate`, `timeout`, `write_timeout`, `inter_byte_timeout`, `xonxoff`, `rtscts`, `dsrdtr`, `rts`, `dtr`).
```
# Change baud rate on the fly
configure_serial(port="/dev/ttyUSB0", baudrate=115200)
# Increase read timeout
configure_serial(port="/dev/ttyUSB0", timeout=5.0)
# Enable hardware flow control
configure_serial(port="/dev/ttyUSB0", rtscts=true)
# Set DTR low (useful for reset sequences)
configure_serial(port="/dev/ttyUSB0", dtr=false)
```
### flush_serial
Clear serial port input and/or output buffers. Discards any pending data.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to flush. |
| `input_buffer` | `bool` | `True` | Clear the input/receive buffer. |
| `output_buffer` | `bool` | `True` | Clear the output/transmit buffer. |
**Returns:** Dict with `success`, `port`, `flushed_input`, and `flushed_output`.
```
# Flush both buffers (default)
flush_serial(port="/dev/ttyUSB0")
# Flush only the input buffer
flush_serial(port="/dev/ttyUSB0", input_buffer=true, output_buffer=false)
```
---
## Flow Control
### set_flow_control
Manually gate input/output flow control signals on a serial port. Flow control must already be enabled via `configure_serial` (set `xonxoff=True` or `rtscts=True`) for these signals to have effect.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port. |
| `input_flow` | `bool \| None` | `None` | `True` sends XON (allow remote to send), `False` sends XOFF (tell remote to stop). `None` = no change. |
| `output_flow` | `bool \| None` | `None` | `True` resumes outgoing data, `False` pauses it. `None` = no change. |
**Returns:** Dict with `success`, `port`, `changes` (what was set), and `flow_control_enabled` (current `xonxoff` and `rtscts` state).
<Aside type="caution">
This function is not portable across all platforms. Behavior depends on the OS and driver support.
</Aside>
```
# Pause incoming data (send XOFF)
set_flow_control(port="/dev/ttyUSB0", input_flow=false)
# Resume incoming data (send XON)
set_flow_control(port="/dev/ttyUSB0", input_flow=true)
```
### cancel_read
Cancel any pending read operation on a serial port. Interrupts a blocking read by calling `cancel_read()` on the underlying pyserial connection.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port. |
**Returns:** Dict with `success`, `port`, and `cancelled`.
<Aside type="note">
Only effective on platforms that support it (POSIX with select-based reads).
</Aside>
```
cancel_read(port="/dev/ttyUSB0")
```
### cancel_write
Cancel any pending write operation on a serial port. Interrupts a blocking write by calling `cancel_write()` on the underlying pyserial connection.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port. |
**Returns:** Dict with `success`, `port`, and `cancelled`.
<Aside type="note">
Only effective on platforms that support it (POSIX with select-based writes).
</Aside>
```
cancel_write(port="/dev/ttyUSB0")
```
---
## Diagnostics
### set_low_latency_mode
Enable or disable low-latency mode on a serial port. Reduces latency by changing how the kernel buffers data. Linux only.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port. |
| `enabled` | `bool` | `True` | Enable or disable low latency mode. |
**Returns:** Dict with `success`, `port`, and `low_latency`.
<Aside type="note">
This is a Linux-specific feature. It may not be available on all hardware or kernel versions. Returns an error on unsupported systems rather than silently failing.
</Aside>
```
# Enable low latency for time-critical communication
set_low_latency_mode(port="/dev/ttyUSB0", enabled=true)
```
### detect_baud_rate
Auto-detect baud rate using multiple heuristics: 0x55 sync pattern analysis, byte distribution analysis, ASCII readability scoring, and framing indicators.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path. Opened temporarily if not already open. If open, the baud rate is changed during testing and restored afterward. |
| `probe` | `str \| None` | `None` | String to send to trigger a response. Use `"U"` or `"UUUUU"` for sync-based detection on echo-enabled devices. |
| `timeout_per_rate` | `float` | `0.5` | Seconds to wait for data at each baud rate. |
| `baudrates` | `list[int] \| None` | `None` | Custom list of rates to try. Default tries common rates: 115200, 9600, 57600, 38400, 19200, 230400, 460800, 921600, 4800, 2400, 1200, 300, 500000, 576000, 1000000, 1500000. |
| `use_sync_pattern` | `bool` | `True` | Analyze 0x55 sync patterns for improved detection accuracy. |
**Returns:** Dict with `success`, `port`, `detected_baudrate` (or `None` if confidence is below 50), `confidence` (0-100 score), `results` (top 5 candidates with detailed scoring), and `rates_tested`.
<Aside type="tip">
Detection works best when the remote device is actively sending data. For devices that only respond to input, use the `probe` parameter to send a string that triggers a response at each candidate baud rate.
</Aside>
```
# Detect baud rate on an active device
detect_baud_rate(port="/dev/ttyUSB0")
# Use a sync probe for echo devices
detect_baud_rate(port="/dev/ttyUSB0", probe="UUUUU")
# Test only common embedded rates
detect_baud_rate(port="/dev/ttyUSB0", baudrates=[9600, 115200, 57600])
# Longer wait time for slow devices
detect_baud_rate(port="/dev/ttyUSB0", timeout_per_rate=1.0)
```

View File

@ -0,0 +1,178 @@
---
title: File Transfer Tools
description: Send and receive files using X/Y/ZMODEM protocols
sidebar:
order: 4
---
import { Aside } from '@astrojs/starlight/components';
Transfer files over serial connections using classic X/Y/ZMODEM protocols. These tools work in both RS-232 and RS-485 modes.
<Aside type="note">
Examples use tool-call notation: `tool_name(param=value)`. These are MCP tool calls made by the assistant, not code you write directly. Boolean values use JSON convention (`true`/`false`).
</Aside>
## Protocol Comparison
| Protocol | Block Size | Batch | Resume | Error Correction | Recommended |
|----------|-----------|-------|--------|------------------|-------------|
| XMODEM | 128B | No | No | Checksum/CRC | Legacy only |
| XMODEM-1K| 1024B | No | No | CRC-16 | - |
| YMODEM | 1024B | Yes | No | CRC-16 | Batch needs |
| ZMODEM | Streaming | Yes | Yes | CRC-32 | Yes |
<Aside type="tip">
Use ZMODEM unless you have a specific reason not to. It is the fastest protocol, supports streaming (no stop-and-wait per block), handles batch transfers, and can resume interrupted transfers.
</Aside>
---
## file_transfer_send
Send a single file over serial using a selected protocol. The receiver must be waiting in receive mode before calling this tool. ZMODEM receivers typically auto-start when they detect the init sequence.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the open serial port. |
| `file_path` | `str` | required | Path to the file to send. |
| `protocol` | `Literal["xmodem", "xmodem1k", "ymodem", "zmodem"]` | `"zmodem"` | Transfer protocol to use. |
**Returns:** Transfer statistics dict with protocol-specific details including bytes sent and any errors. The dict also includes `protocol` and `file` fields.
```
# Send a firmware file using ZMODEM (default)
file_transfer_send(port="/dev/ttyUSB0", file_path="/home/user/firmware.bin")
# Send using XMODEM for legacy compatibility
file_transfer_send(
port="/dev/ttyUSB0",
file_path="/home/user/config.txt",
protocol="xmodem",
)
# XMODEM-1K for faster transfers on legacy systems
file_transfer_send(
port="/dev/ttyUSB0",
file_path="/home/user/data.bin",
protocol="xmodem1k",
)
# YMODEM preserves filename and size metadata
file_transfer_send(
port="/dev/ttyUSB0",
file_path="/home/user/update.bin",
protocol="ymodem",
)
```
<Aside type="caution">
The file must exist on the local filesystem. The tool validates the path before starting the transfer and returns an error if the file is not found.
</Aside>
---
## file_transfer_receive
Receive a file over serial using a selected protocol. The behavior of `save_path` depends on the protocol.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the open serial port. |
| `save_path` | `str` | required | For XMODEM: full file path. For YMODEM/ZMODEM: directory where received files are saved. |
| `protocol` | `Literal["xmodem", "ymodem", "zmodem"]` | `"zmodem"` | Transfer protocol to use. |
| `overwrite` | `bool` | `False` | Whether to overwrite existing files. |
**Returns:** Transfer statistics dict with protocol-specific details including bytes received and file paths. Includes the `protocol` field.
<Aside type="note">
XMODEM does not transmit the filename -- the sender and receiver must agree on it beforehand. Pass the full destination file path as `save_path`. YMODEM and ZMODEM carry the filename in the protocol, so `save_path` should be a directory.
</Aside>
```
# Receive via ZMODEM into a directory
file_transfer_receive(
port="/dev/ttyUSB0",
save_path="/home/user/downloads/",
)
# Receive a single file via XMODEM
file_transfer_receive(
port="/dev/ttyUSB0",
save_path="/home/user/received_file.bin",
protocol="xmodem",
)
# Receive via YMODEM, allowing overwrite
file_transfer_receive(
port="/dev/ttyUSB0",
save_path="/home/user/downloads/",
protocol="ymodem",
overwrite=true,
)
```
<Aside type="caution">
When `overwrite` is `False` (the default), the tool returns an error if the destination file already exists. For XMODEM this is checked before the transfer starts. For YMODEM/ZMODEM, the check happens as each file is received.
</Aside>
---
## file_transfer_send_batch
Send multiple files in a single batch transfer. Only YMODEM and ZMODEM support batch mode.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the open serial port. |
| `file_paths` | `list[str]` | required | List of file paths to send. |
| `protocol` | `Literal["ymodem", "zmodem"]` | `"zmodem"` | Transfer protocol. Must be `ymodem` or `zmodem`. |
**Returns:** Transfer statistics dict covering all files, including the `protocol` field.
<Aside type="note">
All files are validated before the transfer begins. If any file in the list does not exist, the entire batch is rejected with an error identifying the missing file.
</Aside>
```
# Batch send with ZMODEM (default)
file_transfer_send_batch(
port="/dev/ttyUSB0",
file_paths=[
"/home/user/config.json",
"/home/user/firmware.bin",
"/home/user/README.txt",
],
)
# Batch send with YMODEM
file_transfer_send_batch(
port="/dev/ttyUSB0",
file_paths=["/home/user/file1.txt", "/home/user/file2.txt"],
protocol="ymodem",
)
```
---
## Typical Workflow
A complete file transfer session looks like this:
```
# 1. Open the port at a suitable baud rate
open_serial_port(port="/dev/ttyUSB0", baudrate=115200)
# 2. Send a file
file_transfer_send(port="/dev/ttyUSB0", file_path="/home/user/firmware.bin")
# 3. Or receive a file
file_transfer_receive(port="/dev/ttyUSB0", save_path="/home/user/downloads/")
# 4. Close when done
close_serial_port(port="/dev/ttyUSB0")
```
<Aside type="tip">
Higher baud rates significantly improve transfer speed. For file transfers, use the highest baud rate both endpoints support -- 115200 is a common safe choice, but many adapters support 921600 or higher.
</Aside>

View File

@ -0,0 +1,174 @@
---
title: RS-232 Tools
description: Modem control lines, break signals, and RS-232 specific operations
sidebar:
order: 2
---
import { Aside } from '@astrojs/starlight/components';
RS-232 is the default mode for all newly opened ports. These five tools provide control over modem signal lines and break conditions -- the hardware handshaking and signaling layer of point-to-point serial communication.
<Aside type="note">
Examples use tool-call notation: `tool_name(param=value)`. These are MCP tool calls made by the assistant, not code you write directly. Boolean values use JSON convention (`true`/`false`).
</Aside>
<Aside type="note" title="RS-232 mode required">
All tools on this page require the port to be in RS-232 mode. Since RS-232 is the default mode when a port is opened, no extra setup is needed unless you previously switched to RS-485 mode. To switch back, call `set_port_mode(port, "rs232")`.
</Aside>
<Aside type="tip" title="Modem control lines explained">
RS-232 defines six signal lines beyond TX/RX data:
**Output lines** (controlled by you):
- **RTS** (Request To Send) -- signals that you are ready to receive data
- **DTR** (Data Terminal Ready) -- signals that your terminal is present and active
**Input lines** (read-only, set by the remote device):
- **CTS** (Clear To Send) -- remote device is ready to receive
- **DSR** (Data Set Ready) -- remote device is present and powered
- **RI** (Ring Indicator) -- incoming call (modem legacy)
- **CD** (Carrier Detect) -- connection is established
In practice, many embedded devices repurpose these lines for reset, bootloader entry, or power control.
</Aside>
---
## get_modem_lines
Read all RS-232 modem control and status line states, including the current break condition.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port to query. |
**Returns:** Dict with `success`, `port`, `input_lines` (object with `cts`, `dsr`, `ri`, `cd`), `output_lines` (object with `rts`, `dtr`), and `break_condition` (bool).
```
get_modem_lines(port="/dev/ttyUSB0")
# Example response:
# {
# "success": true,
# "port": "/dev/ttyUSB0",
# "input_lines": {"cts": true, "dsr": true, "ri": false, "cd": false},
# "output_lines": {"rts": true, "dtr": true},
# "break_condition": false
# }
```
---
## set_modem_lines
Set the RS-232 output control lines (RTS and DTR). Only the lines you specify are changed; pass `None` (or omit the parameter) to leave a line unchanged.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port. |
| `rts` | `bool \| None` | `None` | Set RTS (Request To Send) state. `None` = no change. |
| `dtr` | `bool \| None` | `None` | Set DTR (Data Terminal Ready) state. `None` = no change. |
**Returns:** Dict with `success`, `port`, `rts`, and `dtr` (current states after change).
These lines are commonly used for:
- Hardware flow control
- Device reset sequences (many boards use DTR for reset)
- Power control on some devices
- Custom signaling protocols
```
# Assert DTR to signal "terminal ready"
set_modem_lines(port="/dev/ttyUSB0", dtr=true)
# Drop RTS to signal "stop sending"
set_modem_lines(port="/dev/ttyUSB0", rts=false)
# Set both lines at once
set_modem_lines(port="/dev/ttyUSB0", rts=true, dtr=true)
```
---
## pulse_line
Pulse an RS-232 control line high-then-low or low-then-high. Commonly used for device reset sequences.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port. |
| `line` | `Literal["rts", "dtr"]` | required | Which line to pulse. |
| `duration_ms` | `int` | `100` | Pulse duration in milliseconds. Must be 1-5000. |
| `active_low` | `bool` | `True` | If `True`, pulse LOW then HIGH. If `False`, pulse HIGH then LOW. |
**Returns:** Dict with `success`, `port`, `line`, `duration_ms`, and `active_low`.
The line is set to its initial state, held for `duration_ms`, pulsed to the active state for `duration_ms`, then restored to its original value.
Typical reset sequences:
- **Arduino**: DTR pulse triggers auto-reset
- **ESP32/ESP8266**: DTR + RTS sequence enters bootloader
- **Custom hardware**: Various reset/trigger signals
```
# Arduino-style reset (DTR pulse, active low)
pulse_line(port="/dev/ttyUSB0", line="dtr", duration_ms=100)
# Active-high pulse on RTS
pulse_line(port="/dev/ttyUSB0", line="rts", duration_ms=200, active_low=false)
# Short trigger pulse
pulse_line(port="/dev/ttyUSB0", line="dtr", duration_ms=10)
```
---
## send_break
Send a timed serial break signal -- a sustained low state on the TX line longer than a character frame. Used to get the attention of a remote device or trigger special modes.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port. |
| `duration_ms` | `int` | `250` | Break duration in milliseconds. Must be 1-5000. |
**Returns:** Dict with `success`, `port`, and `duration_ms`.
```
# Standard break signal
send_break(port="/dev/ttyUSB0")
# Short break (100ms)
send_break(port="/dev/ttyUSB0", duration_ms=100)
# Long break to force attention
send_break(port="/dev/ttyUSB0", duration_ms=1000)
```
---
## set_break_condition
Set or clear the break condition on a serial port. Unlike `send_break()` which sends a timed pulse, this holds the break condition indefinitely until explicitly cleared. Useful for protocols that require sustained break states.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port. |
| `enabled` | `bool` | required | `True` to assert break (hold TX low), `False` to release. |
**Returns:** Dict with `success`, `port`, and `break_condition`.
<Aside type="caution">
The break condition holds the TX line low continuously. The remote device will not receive valid data while break is asserted. Always clear the condition with `enabled=False` when done.
</Aside>
```
# Assert break condition
set_break_condition(port="/dev/ttyUSB0", enabled=true)
# ... wait for device to respond ...
# Release break
set_break_condition(port="/dev/ttyUSB0", enabled=false)
```

View File

@ -0,0 +1,222 @@
---
title: RS-485 Tools
description: Half-duplex bus communication, transactions, and address scanning
sidebar:
order: 3
---
import { Aside } from '@astrojs/starlight/components';
RS-485 enables half-duplex multi-drop bus communication where multiple devices share a single pair of wires. These tools handle direction control, request/response transactions, and bus scanning.
<Aside type="note">
Examples use tool-call notation: `tool_name(param=value)`. These are MCP tool calls made by the assistant, not code you write directly. Boolean values use JSON convention (`true`/`false`).
</Aside>
<Aside type="caution" title="Switch to RS-485 mode first">
Ports open in RS-232 mode by default. Before using any tool on this page, switch the port to RS-485 mode:
```
open_serial_port(port="/dev/ttyUSB0", baudrate=9600)
set_port_mode(port="/dev/ttyUSB0", mode="rs485")
```
</Aside>
---
## set_rs485_mode
Configure the hardware RS-485 settings for half-duplex communication. This sets up automatic TX/RX direction switching via the RTS line (DE/RE control) on hardware that supports it.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path of the port. |
| `enabled` | `bool` | `True` | Enable or disable RS-485 hardware mode. |
| `delay_before_tx` | `float` | `0.0` | Delay in seconds before enabling TX. Allows bus settling time. |
| `delay_before_rx` | `float` | `0.0` | Delay in seconds before enabling RX after TX completes. |
| `rts_level_for_tx` | `bool` | `True` | RTS level when transmitting. `True` = high. |
| `rts_level_for_rx` | `bool` | `False` | RTS level when receiving. `True` = high. |
| `loopback` | `bool` | `False` | Enable RS-485 loopback mode (see your own transmissions). |
**Returns:** Dict with `success`, `port`, `rs485_enabled`, and the configured settings (`rts_level_for_tx`, `rts_level_for_rx`, `delay_before_tx`, `delay_before_rx`, `loopback`). Settings are `None` when RS-485 is disabled.
<Aside type="tip">
The `rts_level_for_tx` and `rts_level_for_rx` parameters control the polarity of the DE/RE enable pin. Most RS-485 transceivers use active-high for TX enable and active-low for RX enable, which is the default configuration.
</Aside>
```
# Enable hardware RS-485 with defaults
set_rs485_mode(port="/dev/ttyUSB0")
# Add bus settling delays for long cable runs
set_rs485_mode(
port="/dev/ttyUSB0",
delay_before_tx=0.001,
delay_before_rx=0.001,
)
# Inverted RTS polarity
set_rs485_mode(
port="/dev/ttyUSB0",
rts_level_for_tx=false,
rts_level_for_rx=true,
)
# Disable hardware RS-485 (revert to software control)
set_rs485_mode(port="/dev/ttyUSB0", enabled=false)
```
---
## check_rs485_support
Detect whether a serial port's hardware and driver support automatic RS-485 direction control, or whether software RTS toggling is needed. This tool queries `udevadm` for USB device information and tests the kernel RS-485 ioctl.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path to check. The port does not need to be open. |
**Returns:** Dict with `success`, `port`, `driver` (e.g., `ftdi_sio`, `cp210x`, `ch341`), `chip` (product string if detected), `hardware_rs485` (bool), `software_fallback` (bool), `kernel_rs485_ioctl` (bool), `notes` (list of informational strings), and `recommendation`.
Known hardware support:
| Driver | Chip | Hardware RS-485 |
|--------|------|-----------------|
| `ftdi_sio` | FT232, FT2232, etc. | Yes -- auto-direction control |
| `cp210x` | CP2105, CP2108 | Yes |
| `cp210x` | CP2102 | No -- use software RTS control |
| `ch341` | CH340, CH341 | No -- timing may be unreliable |
| `pl2303` | PL2303 | No -- use software RTS control |
| native | ttyS, ttyAMA | Yes -- check transceiver wiring |
```
check_rs485_support(port="/dev/ttyUSB0")
# Example response:
# {
# "success": true,
# "port": "/dev/ttyUSB0",
# "driver": "ftdi_sio",
# "chip": "FT232R",
# "hardware_rs485": true,
# "software_fallback": true,
# "kernel_rs485_ioctl": true,
# "notes": ["FTDI chips have hardware RS-485 auto-direction"],
# "recommendation": "Use set_rs485_mode() for automatic DE/RE control"
# }
```
<Aside type="note">
This tool uses `udevadm` for USB device detection. If `udevadm` is not installed, detection falls back to basic checks and a note is added to the response.
</Aside>
---
## rs485_transact
Send data and receive a response on the RS-485 bus in a single half-duplex transaction. Handles TX-to-RX turnaround timing automatically, including manual RTS control when hardware RS-485 is not available.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path (must be open in RS-485 mode). |
| `data` | `str` | required | Data string to send. |
| `response_timeout` | `float` | `1.0` | Maximum time in seconds to wait for a response. |
| `response_terminator` | `str \| None` | `None` | Stop reading when this sequence is received. |
| `response_length` | `int \| None` | `None` | Expected response length in bytes. Alternative to `response_terminator`. |
| `encoding` | `str` | `"utf-8"` | Character encoding for send and receive data. |
| `turnaround_delay` | `float` | `0.005` | Delay in seconds after TX completes before switching to RX. |
**Returns:** Dict with `success`, `port`, `bytes_sent`, `data_sent`, `response` (decoded string), `response_bytes`, `response_hex`, and `hardware_rs485` (whether hardware RS-485 was used).
The transaction flow:
1. Clear the input buffer
2. Assert RTS for TX (if no hardware RS-485)
3. Send encoded data and flush
4. Wait for TX completion + turnaround delay
5. De-assert RTS for RX (if no hardware RS-485)
6. Read response using terminator, length, or timeout
```
# Simple query with timeout-based response
rs485_transact(port="/dev/ttyUSB0", data="?ID\r\n")
# Modbus-style with known response length
rs485_transact(
port="/dev/ttyUSB0",
data="\x01\x03\x00\x00\x00\x01",
response_length=7,
encoding="latin-1",
)
# Wait for a specific terminator
rs485_transact(
port="/dev/ttyUSB0",
data="READ\r\n",
response_terminator="\r\n",
)
# Adjust turnaround for slow devices
rs485_transact(
port="/dev/ttyUSB0",
data="PING",
turnaround_delay=0.01,
response_timeout=2.0,
)
```
<Aside type="tip">
When neither `response_terminator` nor `response_length` is set, the tool waits for half the `response_timeout`, then reads all available bytes. For protocols with predictable framing, always specify a terminator or length for faster, more reliable reads.
</Aside>
---
## rs485_scan_addresses
Scan the RS-485 bus for responding devices by sending a probe message to each address in a range. Useful for discovering Modbus or similar addressed devices.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | `str` | required | Device path (must be open in RS-485 mode). |
| `start_address` | `int` | `1` | First address to scan. |
| `end_address` | `int` | `247` | Last address to scan. Default 247 is the Modbus maximum. |
| `probe_template` | `str` | `"{addr:02x}03000001"` | Message template with `{addr}` placeholder for the address. Default is a Modbus "read holding register" frame. Customize for your protocol. |
| `response_timeout` | `float` | `0.1` | Time in seconds to wait for a response per address. |
| `encoding` | `str` | `"latin-1"` | Encoding for the probe string. Use `latin-1` for raw byte protocols. |
**Returns:** Dict with `success`, `port`, `addresses_scanned`, `devices_found`, `responding_addresses` (list of objects with `address`, `response_length`, `response_hex`), and `hardware_rs485`.
<Aside type="caution">
Scanning 247 addresses at 0.1s per address takes approximately 25 seconds. Narrow the address range when possible, or reduce `response_timeout` for faster scans on responsive buses.
</Aside>
```
# Scan the full Modbus range
rs485_scan_addresses(port="/dev/ttyUSB0")
# Scan a known range of addresses
rs485_scan_addresses(
port="/dev/ttyUSB0",
start_address=1,
end_address=10,
)
# Custom probe template for a different protocol
rs485_scan_addresses(
port="/dev/ttyUSB0",
probe_template="{addr:c}ID?\r\n",
response_timeout=0.2,
encoding="ascii",
)
# Faster scan with short timeout
rs485_scan_addresses(
port="/dev/ttyUSB0",
start_address=1,
end_address=32,
response_timeout=0.05,
)
```
<Aside type="note">
If the `probe_template` contains the `{addr}` placeholder, it is formatted with the current address as an integer. If no placeholder is found, the address is prepended as a single character (`chr(addr)`) to the template string.
</Aside>

View File

@ -0,0 +1,210 @@
---
title: URL Schemes
description: Serial port URL schemes for physical, network, and virtual connections
sidebar:
order: 5
---
import { Aside } from '@astrojs/starlight/components';
The `port` parameter of `open_serial_port` accepts both local device paths and URL schemes. URL schemes let you connect to network serial devices, virtual loopbacks, debug wrappers, and specialized hardware without changing your read/write workflow.
<Aside type="note">
URL-opened ports skip auto-baud detection and exclusive access. The `inter_byte_timeout` and `exclusive` parameters are not passed to URL-based connections.
</Aside>
---
## Device Paths
Standard local serial port paths. These are the most common way to connect to physical hardware.
**Linux:**
```
/dev/ttyUSB0 -- USB-to-serial adapter (FTDI, CP210x, CH340)
/dev/ttyACM0 -- USB CDC ACM device (Arduino, STM32)
/dev/ttyS0 -- Built-in UART (motherboard, Raspberry Pi)
/dev/ttyAMA0 -- ARM UART (Raspberry Pi primary UART)
```
**Windows:**
```
COM3 -- USB-to-serial or built-in COM port
COM12 -- Higher-numbered ports
```
**macOS:**
```
/dev/tty.usbserial-1420 -- USB-to-serial adapter
/dev/tty.usbmodem14201 -- USB CDC ACM device
```
```
open_serial_port(port="/dev/ttyUSB0", baudrate=115200)
```
---
## socket://
Raw TCP socket connection. Connects to serial-to-Ethernet bridges, terminal servers, and networked serial devices that expose a plain TCP port.
**Format:** `socket://host:port`
Data sent to the socket appears on the remote serial port and vice versa. No protocol negotiation occurs -- the connection is a raw byte stream.
**When to use:** Serial-to-Ethernet bridges (Moxa, Digi, Lantronix), ESP32/ESP8266 with TCP server firmware, or any device that bridges serial to a TCP socket.
```
open_serial_port(port="socket://192.168.1.100:4001", baudrate=115200)
```
<Aside type="caution">
The baud rate, parity, and other serial parameters must be configured on the bridge device separately. The `baudrate` parameter here is stored locally but not transmitted to the remote device. Use `rfc2217://` if you need to configure the remote port's serial settings.
</Aside>
---
## rfc2217://
Telnet COM Port Control per RFC 2217. Extends the Telnet protocol to configure remote serial port settings (baud rate, data bits, parity, flow control) over the network.
**Format:** `rfc2217://host:port`
Unlike `socket://`, this scheme negotiates serial parameters with the remote device. When you set `baudrate=115200`, that setting is transmitted to and applied by the remote server.
**When to use:** Remote serial port servers that implement RFC 2217, such as ser2net, or commercial terminal servers with Telnet COM Port support.
```
# Remote serial with full parameter control
open_serial_port(port="rfc2217://192.168.1.100:2217", baudrate=115200)
```
<Aside type="tip">
If your serial server supports RFC 2217, always prefer it over `socket://`. It ensures the remote port is configured with the correct baud rate and framing, rather than relying on out-of-band configuration.
</Aside>
---
## loop://
Loopback virtual port. Everything written is echoed back as readable data. No hardware required.
**Format:** `loop://`
**When to use:** Testing, development, CI/CD pipelines, or verifying tool behavior without physical serial hardware.
```
open_serial_port(port="loop://", baudrate=9600)
write_serial(port="loop://", data="hello")
read_serial(port="loop://") # Returns "hello"
close_serial_port(port="loop://")
```
---
## spy://
Debug wrapper that logs all serial traffic to stderr while passing data through to the underlying port. Wraps a real device path.
**Format:** `spy://device_path`
All bytes read and written are printed to stderr in a human-readable format. The underlying serial connection works exactly as if you opened the device directly.
**When to use:** Debugging communication issues, reverse-engineering protocols, or logging traffic for analysis.
```
# Wraps /dev/ttyUSB0 with traffic logging
open_serial_port(port="spy:///dev/ttyUSB0", baudrate=9600)
```
<Aside type="note">
The spy output goes to stderr of the mcserial server process. Check your server's stderr output to see the logged traffic.
</Aside>
---
## cp2110://
Silicon Labs CP2110 HID-to-UART bridge. Connects to CP2110 devices via the USB HID interface rather than a traditional serial driver.
**Format:** `cp2110://`
**When to use:** Devices using the Silicon Labs CP2110 chip that present as HID devices rather than standard serial ports.
<Aside type="caution" title="Requires extra dependency">
This scheme requires the `hidapi` library. Install it with:
```bash
uv pip install mcserial[cp2110]
```
Without this extra, opening a `cp2110://` URL returns an error explaining the missing dependency.
</Aside>
```
open_serial_port(port="cp2110://", baudrate=9600)
```
---
## hwgrep://
Hardware grep -- opens the first serial port matching a hardware description pattern. Searches across device path, description, hardware ID, manufacturer, product, and serial number fields.
**Format:** `hwgrep://pattern`
**When to use:** When you know the type of device but not the exact port assignment. Useful for scripts that need to find a specific adapter regardless of which USB port it is plugged into.
```
# Open the first FTDI adapter found
open_serial_port(port="hwgrep://FTDI", baudrate=115200)
# Match by USB VID:PID
open_serial_port(port="hwgrep://VID:PID=0403:6001", baudrate=9600)
```
<Aside type="tip">
For port discovery without opening, use `list_serial_ports(grep="FTDI")` to see all matching ports first, then open a specific one by device path.
</Aside>
---
## Feature Comparison
Different URL schemes support different mcserial capabilities. Use this table to choose the right scheme for your needs:
| Scheme | Exclusive Access | Flow Control | Auto-Baud | Modem Lines | RS-485 | Notes |
|--------|-----------------|--------------|-----------|-------------|--------|-------|
| Device path | ✅ | ✅ | ✅ | ✅ | ✅ | Full feature set |
| `socket://` | ❌ | ❌ | ❌ | ❌ | ❌ | Raw TCP only |
| `rfc2217://` | ❌ | ✅ | ❌ | ✅ | ❌ | Via Telnet negotiation |
| `loop://` | ✅ | N/A | N/A | N/A | N/A | Testing only |
| `spy://` | ✅ | ✅ | ✅ | ✅ | ✅ | Wraps underlying device |
| `cp2110://` | ✅ | ❌ | ❌ | ❌ | ❌ | HID interface limitations |
| `hwgrep://` | ✅ | ✅ | ✅ | ✅ | ✅ | Opens matched device |
<Aside type="note" title="URL scheme limitations">
When opening via URL schemes (except `spy://` and `hwgrep://`, which wrap physical devices):
- `inter_byte_timeout` is not passed to URL-based connections
- `exclusive` access cannot be enforced
- Auto-baud detection is skipped
- Some operations may silently fail or return defaults
For full functionality, prefer physical device paths when possible.
</Aside>
---
## Summary
| Scheme | Use Case | Remote Config | Hardware Required |
|--------|----------|---------------|-------------------|
| Device path | Local serial hardware | N/A | Yes |
| `socket://` | Serial-to-Ethernet bridges | No (configure bridge separately) | Network bridge |
| `rfc2217://` | Remote serial with parameter control | Yes (via Telnet negotiation) | RFC 2217 server |
| `loop://` | Testing and development | N/A | None |
| `spy://` | Traffic debugging | N/A | Underlying device |
| `cp2110://` | Silicon Labs HID-to-UART | N/A | CP2110 chip |
| `hwgrep://` | Auto-detect by hardware description | N/A | Matched device |

View File

@ -0,0 +1,273 @@
---
title: Getting Started
description: Install mcserial and make your first serial connection.
---
import { Steps, Tabs, TabItem, Aside, Card, CardGrid } from '@astrojs/starlight/components';
mcserial is a FastMCP server that gives MCP clients full serial port access -- discovery, configuration, read/write, modem control, and file transfers. You use it through your MCP client (Claude Code, Claude Desktop, or any other MCP-compatible client) by talking to your assistant in natural language. This tutorial walks you through installation, client configuration, and your first serial connection.
<Aside type="note" title="How to read tool calls in this guide">
The `tool_name(param=value)` blocks below show what the assistant executes behind the scenes. You don't type these yourself -- instead, ask the assistant in plain English, like "list my serial ports" or "open /dev/ttyUSB0 at 115200 baud." The assistant translates your request into the appropriate tool call.
</Aside>
## Prerequisites
- **Python 3.10+** (check with `python --version`)
- **uv** (recommended) or pip for package management
- A serial device, or nothing at all -- `loop://` lets you test without hardware
## Install mcserial
<Tabs>
<TabItem label="uvx (recommended)">
```bash
# Run directly -- no install step needed
uvx mcserial
```
`uvx` fetches and runs the latest version in an isolated environment. Nothing is installed globally.
</TabItem>
<TabItem label="pip">
```bash
pip install mcserial
```
If you need CP2110 HID-to-UART support:
```bash
pip install mcserial[cp2110]
```
</TabItem>
</Tabs>
## Add to your MCP client
mcserial runs over stdio, so you register it with your MCP client and the client launches it on demand.
<Tabs>
<TabItem label="Claude Code">
```bash
claude mcp add mcserial -- uvx mcserial
```
That's it. Claude Code will start mcserial automatically when serial tools are needed.
</TabItem>
<TabItem label="Claude Desktop">
Add this to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"mcserial": {
"command": "uvx",
"args": ["mcserial"]
}
}
}
```
On macOS, the config file is at `~/Library/Application Support/Claude/claude_desktop_config.json`. On Windows, it's at `%APPDATA%\Claude\claude_desktop_config.json`.
</TabItem>
<TabItem label="Other MCP clients">
Any MCP client that supports stdio transport can run mcserial. The command is:
```bash
uvx mcserial
```
Pass this as the server command in your client's MCP configuration. Consult your client's documentation for the exact format.
</TabItem>
</Tabs>
<Aside type="tip">
You can tune defaults with environment variables before the server starts. Set `MCSERIAL_DEFAULT_BAUDRATE` (default: 9600), `MCSERIAL_DEFAULT_TIMEOUT` (default: 1.0s), or `MCSERIAL_MAX_CONNECTIONS` (default: 10). See the [Environment reference](/reference/environment/) for details.
</Aside>
## Your first connection
The natural workflow is: **discover** available ports, **open** one, **read/write** data, then **close** when done. You ask the assistant to do each step, and it calls the appropriate mcserial tools.
This walkthrough uses `loop://` so you can follow along without any hardware. Everything written to a loopback port is immediately available to read back.
<Steps>
1. **Ask the assistant to list available ports**
The assistant calls `list_serial_ports` to see what's connected:
```
list_serial_ports(usb_only=false)
```
Response:
```json
[
{
"device": "/dev/ttyUSB0",
"description": "FT232R USB UART",
"hwid": "USB VID:PID=0403:6001",
"manufacturer": "FTDI",
"product": "FT232R USB UART",
"serial_number": "A50285BI",
"is_open": false
}
]
```
Set `usb_only=false` to include built-in serial ports. Use `grep` to filter by hardware: `list_serial_ports(grep="FTDI")`.
<Aside type="note">
If you have no hardware, skip this step and go straight to opening `loop://` in the next step.
</Aside>
2. **Open a connection**
Ask the assistant to open the loopback port (or a real device path from step 1). The assistant calls:
```
open_serial_port(port="loop://", baudrate=9600)
```
Response:
```json
{
"success": true,
"port": "loop://",
"mode": "rs232",
"baudrate": 9600,
"bytesize": 8,
"parity": "N",
"stopbits": 1,
"xonxoff": false,
"rtscts": false,
"dsrdtr": false,
"resource_uri": "serial://loop:///data",
"url_scheme": "loop",
"hint": "Opened via URL handler. Some features (exclusive, auto-baud) are not available."
}
```
The port is now open in RS-232 mode with 8N1 framing (8 data bits, no parity, 1 stop bit) -- the most common configuration.
3. **Write data**
Tell the assistant to send a message. It calls:
```
write_serial(port="loop://", data="Hello, serial world!\n")
```
Response:
```json
{
"success": true,
"bytes_written": 21,
"port": "loop://"
}
```
4. **Read data back**
Ask the assistant to read from the port. It reads whatever is waiting in the receive buffer:
```
read_serial(port="loop://")
```
Response:
```json
{
"success": true,
"data": "Hello, serial world!\n",
"bytes_read": 21,
"raw_hex": "48656c6c6f2c2073657269616c20776f726c64210a",
"port": "loop://"
}
```
Since this is a loopback, you get back exactly what you wrote. With a real device, you'd see the device's response instead.
5. **Close the connection**
When you're done, ask the assistant to close the port to release the resource:
```
close_serial_port(port="loop://")
```
Response:
```json
{
"success": true,
"port": "loop://",
"message": "Port closed"
}
```
</Steps>
## Reading tools at a glance
mcserial provides several ways to read data, each suited to different situations:
| Tool | When to use |
|------|-------------|
| `read_serial` | Read whatever bytes are available right now |
| `read_serial_line` | Read one line (up to `\n`) |
| `read_serial_lines` | Drain multi-line responses (up to `max_lines`) |
| `read_until` | Read until a custom terminator (e.g., `>` prompt) |
## Auto-baud detection
When opening a real device, you can omit `baudrate` and mcserial will attempt to detect it automatically:
```
open_serial_port(port="/dev/ttyUSB0")
```
The response includes detection details:
```json
{
"success": true,
"port": "/dev/ttyUSB0",
"baudrate": 115200,
"autobaud": {
"auto_detected": true,
"detection_confidence": 87.3,
"detection_candidates": [
{"baudrate": 115200, "score": 87.3},
{"baudrate": 9600, "score": 12.1}
]
}
}
```
Auto-detection works best when the device is actively sending data. You can also pass `autobaud_probe="UUUUU"` to send a sync string that triggers a response.
<Aside type="caution">
Auto-baud detection is only available for local device paths. URL-based ports (`loop://`, `socket://`, etc.) skip detection and fall back to the default baud rate.
</Aside>
## What's next
<CardGrid>
<Card title="Loopback testing" icon="rocket">
Practice read/write patterns, encodings, and flow control without hardware using [loop:// virtual ports](/tutorials/loopback-testing/).
</Card>
<Card title="RS-232 basics" icon="setting">
Learn modem line control, device resets, and break signals in the [RS-232 guide](/guides/rs232-basics/).
</Card>
<Card title="RS-485 and Modbus" icon="random">
Set up multi-drop bus communication and device scanning in the [RS-485 guide](/guides/rs485-modbus/).
</Card>
<Card title="Tool reference" icon="open-book">
Full parameter details for all 30 tools in the [reference section](/reference/tools-common/).
</Card>
</CardGrid>

View File

@ -0,0 +1,396 @@
---
title: Loopback Testing
description: Test serial communication without any hardware using loop:// virtual ports.
---
import { Steps, Tabs, TabItem, Aside } from '@astrojs/starlight/components';
pyserial provides a built-in `loop://` URL scheme that creates a virtual serial port. Everything written to it is immediately available to read back -- no cables, no adapters, no hardware at all. This makes it a great way to verify that mcserial is working in your MCP client, practice serial workflows, validate encodings, and run automated checks in CI/CD.
<Aside type="note" title="MCP tool calls">
The tool calls shown below are what the assistant executes when you ask it to perform serial operations. You interact in natural language -- for example, "open a loopback port" or "write AT+VERSION to the serial port."
</Aside>
## Why use loopback
- **No hardware required** -- test serial workflows on any machine, including headless CI runners
- **Deterministic** -- what you write is exactly what you read, making assertions straightforward
- **Fast** -- no real baud rate delays; data transfers are instantaneous in memory
- **Safe** -- no risk of misconfiguring or locking a real device
## Basic round-trip
The simplest test: ask the assistant to write a string and read it back.
<Steps>
1. **Ask the assistant to open the loopback port**
```
open_serial_port(port="loop://", baudrate=9600)
```
```json
{
"success": true,
"port": "loop://",
"mode": "rs232",
"baudrate": 9600,
"bytesize": 8,
"parity": "N",
"stopbits": 1,
"resource_uri": "serial://loop:///data",
"url_scheme": "loop"
}
```
The `baudrate` parameter is accepted but has no effect on loopback -- data moves at memory speed regardless of the value. Setting it still validates that the parameter is handled correctly throughout the tool chain.
2. **Send a message**
The assistant writes your data to the port:
```
write_serial(port="loop://", data="AT+VERSION\r\n")
```
```json
{
"success": true,
"bytes_written": 12,
"port": "loop://"
}
```
3. **Read it back**
Ask the assistant to read from the port:
```
read_serial(port="loop://")
```
```json
{
"success": true,
"data": "AT+VERSION\r\n",
"bytes_read": 12,
"raw_hex": "41542b56455253494f4e0d0a",
"port": "loop://"
}
```
The `raw_hex` field confirms the exact bytes on the wire -- useful for verifying that line endings and control characters are correct.
4. **Close the port**
```
close_serial_port(port="loop://")
```
</Steps>
## Testing line-oriented reads
Many serial devices send responses as lines terminated with `\r\n` or `\n`. Use `read_serial_line` and `read_serial_lines` to handle these.
<Steps>
1. **Open and write multi-line data**
Ask the assistant to open a loopback port and send multi-line data:
```
open_serial_port(port="loop://", baudrate=115200)
```
```
write_serial(port="loop://", data="OK\r\nERROR 42\r\nDONE\r\n")
```
2. **Read one line at a time**
The assistant calls `read_serial_line` to get a single line:
```
read_serial_line(port="loop://")
```
```json
{
"success": true,
"line": "OK",
"bytes_read": 4,
"port": "loop://"
}
```
The `line` field strips trailing `\r\n` automatically. The `bytes_read` count includes the line ending bytes.
3. **Drain remaining lines in one call**
Ask the assistant to read all remaining lines:
```
read_serial_lines(port="loop://", max_lines=10)
```
```json
{
"success": true,
"lines": ["ERROR 42", "DONE"],
"count": 2,
"bytes_read": 16,
"port": "loop://"
}
```
`read_serial_lines` stops early when no more data is available, so setting `max_lines` higher than expected is fine.
4. **Close the port**
```
close_serial_port(port="loop://")
```
</Steps>
## Testing custom terminators
Not every protocol uses newlines. Some devices use `>` as a prompt, `\x00` as a null terminator, or multi-byte sequences. Use `read_until` for these.
<Steps>
1. **Open and write prompt-terminated data**
Ask the assistant to open a loopback port and write data with a `>` prompt at the end:
```
open_serial_port(port="loop://", baudrate=9600)
```
```
write_serial(port="loop://", data="ELM327 v1.5>")
```
2. **Read until the `>` prompt**
The assistant calls `read_until` with a custom terminator:
```
read_until(port="loop://", terminator=">")
```
```json
{
"success": true,
"data": "ELM327 v1.5>",
"bytes_read": 12,
"raw_hex": "454c4d33323720312e353e",
"port": "loop://",
"terminator_found": true
}
```
The `terminator_found` field confirms whether the terminator was actually received or whether the read timed out first.
3. **Close the port**
```
close_serial_port(port="loop://")
```
</Steps>
## Testing encodings
Serial devices don't always speak UTF-8. Industrial equipment, legacy systems, and some sensors use Latin-1 or other single-byte encodings. mcserial's `encoding` parameter handles the translation. Tell the assistant which encoding to use, and it passes the value through to the read/write calls.
<Steps>
1. **Open the port**
```
open_serial_port(port="loop://", baudrate=9600)
```
2. **Write with Latin-1 encoding**
Latin-1 maps bytes 0x00--0xFF directly, which makes it useful for binary-safe text. The assistant calls:
```
write_serial(port="loop://", data="Temp: 23\xb0C", encoding="latin-1")
```
The `\xb0` byte is the degree symbol in Latin-1.
3. **Read with matching encoding**
```
read_serial(port="loop://", encoding="latin-1")
```
```json
{
"success": true,
"data": "Temp: 23\u00b0C",
"bytes_read": 10,
"raw_hex": "54656d703a203233b043",
"port": "loop://"
}
```
The `b0` byte in `raw_hex` confirms the degree symbol was sent correctly.
4. **Close the port**
```
close_serial_port(port="loop://")
```
</Steps>
<Aside type="note">
If you see garbled characters, the most common cause is a read/write encoding mismatch. Always use the same `encoding` value for both `write_serial` and `read_serial` on a given port.
</Aside>
## Testing raw byte writes
For binary protocols (Modbus RTU, custom framing, firmware uploads), the assistant uses `write_serial_bytes` to send exact byte sequences:
<Steps>
1. **Open the port**
```
open_serial_port(port="loop://", baudrate=9600)
```
2. **Write raw bytes**
Send a Modbus-style query (address 0x01, function 0x03, register 0x0000, count 0x0001):
```
write_serial_bytes(port="loop://", data=[1, 3, 0, 0, 0, 1])
```
```json
{
"success": true,
"bytes_written": 6,
"port": "loop://"
}
```
3. **Read back and verify hex**
```
read_serial(port="loop://", encoding="latin-1")
```
```json
{
"success": true,
"data": "\u0001\u0003\u0000\u0000\u0000\u0001",
"bytes_read": 6,
"raw_hex": "010300000001",
"port": "loop://"
}
```
The `raw_hex` field is the most reliable way to verify binary data -- it shows the exact bytes without encoding ambiguity.
4. **Close the port**
```
close_serial_port(port="loop://")
```
</Steps>
## Testing port configuration changes
The assistant can reconfigure an open port without closing and reopening it. This is useful for testing parameter changes or simulating baud rate switching:
<Steps>
1. **Open at 9600 baud**
```
open_serial_port(port="loop://", baudrate=9600)
```
2. **Change to 115200 baud with hardware flow control**
```
configure_serial(port="loop://", baudrate=115200, rtscts=true)
```
```json
{
"success": true,
"port": "loop://",
"baudrate": 115200,
"timeout": 1.0,
"write_timeout": null,
"inter_byte_timeout": null,
"xonxoff": false,
"rtscts": true,
"dsrdtr": false,
"rts": true,
"dtr": true
}
```
The response shows the full port state after the change, making it easy to confirm the new settings.
3. **Verify the connection status**
```
get_connection_status()
```
This returns all open connections with their current settings, including the updated baud rate and flow control flags.
4. **Close the port**
```
close_serial_port(port="loop://")
```
</Steps>
## Testing buffer management
When working with protocol handlers, you sometimes need to clear stale data before sending a new command. Ask the assistant to flush the buffers:
```
flush_serial(port="loop://", input_buffer=true, output_buffer=true)
```
This discards any unread data in the input buffer and any unsent data in the output buffer. Call it before sending a command when you want a clean slate.
<Aside type="tip" title="Debugging real connections with spy://">
When you move from loopback testing to real hardware and things don't work, wrap your device path with `spy://` to log all traffic to stderr:
```
open_serial_port(port="spy:///dev/ttyUSB0", baudrate=9600)
```
Every byte sent and received will be printed to the server's stderr output, which shows up in your MCP client's server logs. This is invaluable for diagnosing timing issues, encoding mismatches, or unexpected device responses.
`spy://` works with any local device path -- just prefix the full path after the scheme. On Windows: `spy://COM3`.
</Aside>
## Using loopback in CI/CD
Since `loop://` requires no hardware or special permissions, it works in any CI environment. A typical test pattern:
1. Start mcserial as a subprocess
2. Connect via MCP client
3. Open `loop://`
4. Write known data, read it back, assert equality
5. Test edge cases: empty writes, large payloads, encoding boundaries
6. Close the port
This validates that your MCP tool chain works end-to-end without needing a physical device in the test runner.

View File

@ -0,0 +1,258 @@
---
title: Serial for Humans
description: Serial communication explained like you're five (with links to go deeper)
sidebar:
order: 0
---
import { Card, CardGrid, LinkCard, Aside } from '@astrojs/starlight/components';
Serial communication sounds scary, but it's just devices having a conversation — one letter at a time. This page explains the core ideas using everyday analogies, then points you to the deep dives when you're ready.
---
## 🗣️ What is Serial Communication?
Imagine two people talking through a very narrow pipe. They can only pass one letter at a time, so to say "HELLO" they pass:
```
H → E → L → L → O
```
That's serial communication. One bit after another, in a line (serial = "in a series").
Your computer and a device agree on how fast to pass letters, how to know when a word starts and ends, and what to do if a letter gets mangled in the pipe.
<LinkCard
title="Go deeper: RS-232 vs RS-485"
description="The two main flavors of serial — one-on-one conversations vs. group chats"
href="/concepts/rs232-vs-rs485/"
/>
---
## 🏎️ Baud Rate: How Fast Are We Talking?
**Baud rate** is how many signal changes per second. Think of it as the speed limit for the pipe.
| Baud Rate | Letters per Second | Real-World Analogy |
|-----------|-------------------|-------------------|
| 9600 | ~960 | Casual conversation |
| 115200 | ~11,520 | Speed-talking auctioneer |
| 1000000 | ~100,000 | Two computers screaming at each other |
If one side talks at 115200 and the other listens at 9600, it's like someone speed-reading to a toddler. Gibberish ensues.
<LinkCard
title="Go deeper: Timeout Tuning"
description="How long to wait for a response, and why baud rate affects it"
href="/guides/timeout-tuning/"
/>
---
## ✋ Flow Control: "Wait, I'm Not Ready!"
What happens when one side talks faster than the other can listen? The listener's "ears" fill up and they start missing words.
**Flow control** is the listener saying "hold on!" (and later "okay, continue").
Two ways to do this:
| Method | How It Works | Analogy |
|--------|--------------|---------|
| **XON/XOFF** | Send special "pause" and "resume" letters in the conversation | Saying "wait!" and "go!" out loud |
| **RTS/CTS** | Flip a physical switch to pause/resume | Holding up your hand to stop someone |
<Aside type="tip" title="The gotcha">
XON/XOFF uses byte values 0x11 and 0x13 as the "wait" and "go" signals. If you're sending a file and it happens to contain those bytes... the receiver thinks you said "pause" when you didn't. Binary data + XON/XOFF = trouble.
</Aside>
<LinkCard
title="Go deeper: Flow Control"
description="Software vs hardware flow control, and when to use each"
href="/concepts/flow-control/"
/>
---
## 🔌 Wires: The Physical Connection
### RS-232: The One-on-One Chat
RS-232 is like a phone call — two parties, direct connection, multiple wires for different purposes:
| Wire | What It Does | Analogy |
|------|--------------|---------|
| **TX** | Transmit (you talk) | Your mouth |
| **RX** | Receive (you listen) | Your ear |
| **GND** | Ground (reference point) | Shared understanding of "zero" |
| **RTS/CTS** | Flow control | "Can I talk?" / "Yes, go ahead" |
| **DTR/DSR** | Presence detection | "Are you there?" / "Yes, I'm here" |
At minimum, you need **TX, RX, and GND**. The rest are optional helpers.
### RS-485: The Group Chat
RS-485 is like a walkie-talkie channel — many devices, but only one can talk at a time:
- **Two wires** (A and B) carry the signal
- Everyone listens; one device talks
- You need an **address** so devices know who's being asked
- **Termination resistors** at each end prevent signal echoes
<CardGrid>
<LinkCard
title="RS-232 Basics"
description="Modem lines, reset sequences, and Arduino connections"
href="/guides/rs232-basics/"
/>
<LinkCard
title="RS-485 & Modbus"
description="Multi-drop buses, addressing, and industrial protocols"
href="/guides/rs485-modbus/"
/>
</CardGrid>
---
## 📝 Text vs Binary: What Language Are We Speaking?
Serial ports don't know if you're sending English text, emoji, or raw machine code. They just pass bytes.
**Text mode** (like AT commands):
```
AT+GMR\r\n
```
Human-readable, uses characters like letters and newlines.
**Binary mode** (like Modbus RTU):
```
01 03 00 00 00 01 84 0A
```
Machine-readable, exact byte values matter, no "text" interpretation.
The **encoding** setting tells mcserial how to convert between strings and bytes:
| Encoding | Use When | Example |
|----------|----------|---------|
| UTF-8 | Text protocols, console output | `"Hello"` → `48 65 6C 6C 6F` |
| Latin-1 | Binary protocols, raw bytes | `"\x01\x03"` → `01 03` (exact bytes) |
<LinkCard
title="Go deeper: Encoding and Binary Data"
description="UTF-8 vs Latin-1, CRC checksums, and why encoding matters"
href="/concepts/encoding-and-binary/"
/>
---
## ⏱️ Timeouts: How Long to Wait?
When you ask a device a question, how long do you wait for an answer before assuming it's not coming?
- **Too short**: You give up before the device finishes thinking
- **Too long**: You stare at a dead device forever
Rule of thumb: **2× the expected response time** is a safe starting point.
Some devices are slow thinkers:
- GPS during cold start: 30-60 seconds to find satellites
- Flash memory writes: several seconds to complete
- Sleepy microcontrollers: may need a wake-up poke first
<LinkCard
title="Go deeper: Timeout Tuning"
description="Timeout types, slow device patterns, and how to debug no-response issues"
href="/guides/timeout-tuning/"
/>
---
## 🔥 When Things Go Wrong
Serial communication fails in predictable ways. Here's the cheat sheet:
| Symptom | Likely Cause | Quick Fix |
|---------|--------------|-----------|
| Garbage characters | Wrong baud rate | Match baud rates on both ends |
| No response at all | Device off, wrong port, or TX/RX swapped | Check connections, try other ports |
| Partial data, missing bytes | Buffer overflow (no flow control) | Enable RTS/CTS or XON/XOFF |
| "Permission denied" | Your user can't access the port | Add yourself to the `dialout` group |
| Works once, then stops | Port left open from last time | Close and reopen the port |
<LinkCard
title="Go deeper: Troubleshooting"
description="Detailed error recovery, permission fixes, and reconnection patterns"
href="/guides/troubleshooting/"
/>
---
## 📁 Sending Files: The Slow Way
Before networks, people sent files over serial using protocols like XMODEM, YMODEM, and ZMODEM. These protocols:
1. Break the file into chunks
2. Add error-checking to each chunk
3. Resend chunks that got corrupted
4. Reassemble at the other end
**ZMODEM** is the best — it's fast, can resume interrupted transfers, and handles errors gracefully.
<LinkCard
title="Go deeper: File Transfers"
description="Protocol comparison, firmware uploads, and error recovery"
href="/guides/file-transfers/"
/>
---
## 🚀 Ready to Start?
Now that you know the basics, try talking to a real device:
<CardGrid>
<LinkCard
title="Getting Started"
description="Install mcserial and connect to your first serial device"
href="/tutorials/getting-started/"
/>
<LinkCard
title="Loopback Testing"
description="Test your setup without any hardware"
href="/tutorials/loopback-testing/"
/>
</CardGrid>
---
## 📚 The Full Map
Here's everything in the docs, organized by how deep you want to go:
### Just Getting Started
- [Getting Started](/tutorials/getting-started/) — Installation and first connection
- [Loopback Testing](/tutorials/loopback-testing/) — Test without hardware
### Practical Guides
- [RS-232 Basics](/guides/rs232-basics/) — Arduino, modem lines, reset sequences
- [RS-485 & Modbus](/guides/rs485-modbus/) — Industrial buses, addressing
- [File Transfers](/guides/file-transfers/) — X/Y/ZMODEM protocols
- [Network Ports](/guides/network-ports/) — Serial over TCP/IP
- [Troubleshooting](/guides/troubleshooting/) — When things break
- [Timeout Tuning](/guides/timeout-tuning/) — Timing and slow devices
### Deep Concepts
- [RS-232 vs RS-485](/concepts/rs232-vs-rs485/) — Fundamental differences
- [Flow Control](/concepts/flow-control/) — Preventing buffer overflows
- [Encoding & Binary](/concepts/encoding-and-binary/) — Text vs machine data
### Reference
- [Common Tools](/reference/tools-common/) — Universal operations
- [RS-232 Tools](/reference/tools-rs232/) — Modem line control
- [RS-485 Tools](/reference/tools-rs485/) — Bus operations
- [File Transfer Tools](/reference/tools-file-transfer/) — X/Y/ZMODEM
- [URL Schemes](/reference/url-handlers/) — Network and virtual ports
- [Resources](/reference/resources/) — MCP resource URIs
- [Environment](/reference/environment/) — Configuration options

View File

@ -0,0 +1,30 @@
/* mcserial docs — teal accent theme */
:root {
--sl-color-accent-low: #042f2e;
--sl-color-accent: #0d9488;
--sl-color-accent-high: #99f6e4;
--sl-color-white: #f0fdfa;
--sl-color-gray-1: #ccfbf1;
--sl-color-gray-2: #5eead4;
--sl-color-gray-3: #2dd4bf;
--sl-color-gray-4: #14b8a6;
--sl-color-gray-5: #0f766e;
--sl-color-gray-6: #115e59;
--sl-color-gray-7: #134e4a;
}
:root[data-theme='light'] {
--sl-color-accent-low: #ccfbf1;
--sl-color-accent: #0d9488;
--sl-color-accent-high: #042f2e;
--sl-color-white: #134e4a;
--sl-color-gray-1: #115e59;
--sl-color-gray-2: #0f766e;
--sl-color-gray-3: #14b8a6;
--sl-color-gray-4: #2dd4bf;
--sl-color-gray-5: #99f6e4;
--sl-color-gray-6: #ccfbf1;
--sl-color-gray-7: #f0fdfa;
}

5
docs/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

View File

@ -24,6 +24,9 @@ dependencies = [
]
[project.optional-dependencies]
cp2110 = [
"hidapi>=0.14.0",
]
dev = [
"ruff>=0.9.0",
"pytest>=8.0.0",

66
src/mcserial/_utils.py Normal file
View File

@ -0,0 +1,66 @@
"""Shared utility functions for file transfer protocols.
These functions provide security and robustness for file receive operations.
"""
from __future__ import annotations
import os
from pathlib import Path
def sanitize_filename(filename: str) -> str:
"""Remove path traversal attempts and dangerous characters from filename.
Security: Prevents directory traversal attacks where malicious senders
could write files outside the target directory using names like
'../../../etc/passwd' or absolute paths like '/etc/cron.d/backdoor'.
Args:
filename: Raw filename from remote sender (untrusted input)
Returns:
Safe filename with path components and dangerous characters removed
"""
# Get just the basename, removing any directory components
name = Path(filename).name
# Reject empty names
if not name:
name = "unnamed_file"
# Prefix hidden files (starting with dot) to make them visible
if name.startswith("."):
name = "_" + name[1:]
# Replace any remaining problematic characters
name = name.replace("\x00", "_").replace("/", "_").replace("\\", "_")
return name
def open_file_atomic(filepath: Path, overwrite: bool) -> tuple[object, str | None]:
"""Open file for writing with atomic creation to prevent TOCTOU races.
Uses O_CREAT | O_EXCL to atomically fail if file exists (when overwrite=False),
preventing race conditions between existence check and file creation.
Args:
filepath: Path to the file to create/open
overwrite: If True, overwrite existing files. If False, fail if exists.
Returns:
Tuple of (file_object, None) on success, or (None, error_message) on failure
"""
if overwrite:
# Overwrite mode: just open normally
return open(filepath, "wb"), None
try:
# Atomic create: fails if file exists (O_EXCL)
fd = os.open(filepath, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
return os.fdopen(fd, "wb"), None
except FileExistsError:
return None, "File exists"
except OSError as e:
return None, str(e)

File diff suppressed because it is too large Load Diff

436
src/mcserial/xmodem.py Normal file
View File

@ -0,0 +1,436 @@
"""XMODEM file transfer protocol implementation.
XMODEM is the simplest serial file transfer protocol:
- 128-byte blocks (XMODEM) or 1024-byte blocks (XMODEM-1K)
- Checksum or CRC-16 error detection
- Stop-and-wait: waits for ACK after each block
Protocol flow:
Receiver sends NAK (checksum) or 'C' (CRC) to initiate
Sender transmits: SOH + block# + ~block# + 128 bytes + check
Receiver sends ACK or NAK
Repeat until EOT
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from typing import BinaryIO
# Protocol constants
SOH = 0x01 # Start of 128-byte header
STX = 0x02 # Start of 1024-byte header (XMODEM-1K)
EOT = 0x04 # End of transmission
ACK = 0x06 # Acknowledge
NAK = 0x15 # Negative acknowledge
CAN = 0x18 # Cancel
CRC_MODE = 0x43 # 'C' - request CRC mode
# Default parameters
DEFAULT_RETRY_LIMIT = 16 # Max retries per block
DEFAULT_TIMEOUT = 60.0 # Total transfer timeout in seconds
READ_TIMEOUT_RETRIES = 30 # Retries when waiting for response byte
INIT_TIMEOUT_RETRIES = 10 # Retries when waiting for transfer initiation
DEFAULT_MAX_TRANSFER_SIZE = 100 * 1024 * 1024 # 100MB default limit
logger = logging.getLogger(__name__)
def _calc_checksum(data: bytes) -> int:
"""Calculate simple 8-bit checksum."""
return sum(data) & 0xFF
def _calc_crc16(data: bytes) -> int:
"""Calculate CRC-16-CCITT (XMODEM variant)."""
crc = 0
for byte in data:
crc ^= byte << 8
for _ in range(8):
if crc & 0x8000:
crc = (crc << 1) ^ 0x1021
else:
crc <<= 1
crc &= 0xFFFF
return crc
class XModemError(Exception):
"""Base exception for XMODEM errors."""
pass
class XModem:
"""XMODEM file transfer protocol handler.
Args:
read_func: Callable that reads n bytes from serial, returns bytes
write_func: Callable that writes bytes to serial
mode: "xmodem" (128-byte) or "xmodem1k" (1024-byte blocks)
use_crc: Use CRC-16 instead of checksum (default True)
"""
def __init__(
self,
read_func: Callable[[int], bytes],
write_func: Callable[[bytes], int],
mode: str = "xmodem",
use_crc: bool = True,
):
self.read = read_func
self.write = write_func
self.mode = mode
self.use_crc = use_crc
self.block_size = 1024 if mode == "xmodem1k" else 128
def _read_byte(self, timeout_retries: int = INIT_TIMEOUT_RETRIES) -> int | None:
"""Read a single byte with retry.
Args:
timeout_retries: Number of read attempts before returning None
Returns:
Byte value (0-255) or None if no data available after retries
"""
for _ in range(timeout_retries):
data = self.read(1)
if data:
return data[0]
return None
def _make_block(self, block_num: int, data: bytes) -> bytes:
"""Create a complete XMODEM block with header, data, and checksum/CRC.
Args:
block_num: Block sequence number (0-255, wraps)
data: Payload data (will be padded to block_size with SUB chars)
Returns:
Complete block bytes ready for transmission
"""
# Pad data to block size with SUB (0x1A) - standard XMODEM padding
if len(data) < self.block_size:
data = data + bytes([0x1A] * (self.block_size - len(data)))
header = STX if self.block_size == 1024 else SOH
block = bytes([header, block_num & 0xFF, (255 - block_num) & 0xFF]) + data
if self.use_crc:
crc = _calc_crc16(data)
block += bytes([crc >> 8, crc & 0xFF])
else:
block += bytes([_calc_checksum(data)])
return block
def _verify_block(self, data: bytes, check_bytes: bytes) -> bool:
"""Verify block integrity using checksum or CRC.
Args:
data: The block payload to verify
check_bytes: Received checksum (1 byte) or CRC (2 bytes)
Returns:
True if verification passes, False otherwise
"""
# Validate check_bytes length before accessing indices
expected_len = 2 if self.use_crc else 1
if len(check_bytes) < expected_len:
return False
if self.use_crc:
expected = _calc_crc16(data)
received = (check_bytes[0] << 8) | check_bytes[1]
return expected == received
else:
expected = _calc_checksum(data)
return expected == check_bytes[0]
def send(
self,
stream: BinaryIO,
callback: Callable[[int, int], None] | None = None,
retry_limit: int = DEFAULT_RETRY_LIMIT,
timeout: float = DEFAULT_TIMEOUT,
) -> dict:
"""Send a file via XMODEM.
Args:
stream: File-like object to read from
callback: Progress callback(bytes_sent, total_bytes)
retry_limit: Max retries per block
timeout: Total timeout in seconds (enforced across entire transfer)
Returns:
Dict with transfer statistics
"""
start_time = time.monotonic()
# Get file size for progress
stream.seek(0, 2) # Seek to end
total_size = stream.tell()
stream.seek(0) # Seek back to start
bytes_sent = 0
block_num = 1
errors = 0
# Wait for receiver to initiate
logger.debug("Waiting for receiver initiation...")
init_byte = None
for _ in range(retry_limit * 10):
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s) waiting for receiver initiation"}
b = self._read_byte(timeout_retries=3)
if b == CRC_MODE:
self.use_crc = True
init_byte = b
logger.debug("Receiver requested CRC mode")
break
elif b == NAK:
self.use_crc = False
init_byte = b
logger.debug("Receiver requested checksum mode")
break
elif b == CAN:
return {"success": False, "error": "Transfer cancelled by receiver"}
if init_byte is None:
return {"success": False, "error": "Timeout waiting for receiver"}
# Send blocks
while True:
# Check timeout
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s) during transfer at block {block_num}"}
data = stream.read(self.block_size)
if not data:
break
block = self._make_block(block_num, data)
retries = 0
while retries < retry_limit:
self.write(block)
logger.debug(f"Sent block {block_num}, {len(data)} bytes")
response = self._read_byte(timeout_retries=READ_TIMEOUT_RETRIES)
if response == ACK:
bytes_sent += len(data)
if callback:
callback(bytes_sent, total_size)
block_num = (block_num + 1) & 0xFF
break
elif response == NAK:
retries += 1
errors += 1
logger.debug(f"Block {block_num} NAK'd, retry {retries}")
elif response == CAN:
return {"success": False, "error": "Transfer cancelled by receiver"}
else:
retries += 1
errors += 1
logger.debug(f"Block {block_num} no response, retry {retries}")
else:
return {"success": False, "error": f"Max retries exceeded at block {block_num}"}
# Send EOT
for _ in range(retry_limit):
self.write(bytes([EOT]))
response = self._read_byte(timeout_retries=10)
if response == ACK:
break
elif response == NAK:
continue # Resend EOT
return {
"success": True,
"bytes_sent": bytes_sent,
"blocks": block_num - 1,
"errors": errors,
"mode": "crc" if self.use_crc else "checksum",
}
def receive(
self,
stream: BinaryIO,
callback: Callable[[int], None] | None = None,
retry_limit: int = DEFAULT_RETRY_LIMIT,
timeout: float = DEFAULT_TIMEOUT,
max_transfer_size: int = DEFAULT_MAX_TRANSFER_SIZE,
) -> dict:
"""Receive a file via XMODEM.
Note: XMODEM has no file metadata, so the exact file size is unknown.
Blocks are padded with SUB (0x1A) characters. For binary files, you may
need to know the expected size and truncate accordingly. For text files,
trailing 0x1A bytes are traditionally stripped by the receiver.
Args:
stream: File-like object to write to
callback: Progress callback(bytes_received)
retry_limit: Max retries per block
timeout: Total timeout in seconds (enforced across entire transfer)
max_transfer_size: Maximum bytes to receive (default 100MB).
Set to 0 to disable limit. Prevents disk exhaustion.
Returns:
Dict with transfer statistics
"""
start_time = time.monotonic()
bytes_received = 0
expected_block = 1
errors = 0
# Initiate transfer
init_char = CRC_MODE if self.use_crc else NAK
logger.debug(f"Initiating transfer with {'CRC' if self.use_crc else 'checksum'} mode")
for attempt in range(retry_limit):
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s) waiting for sender"}
self.write(bytes([init_char]))
# Wait for first byte
header = self._read_byte(timeout_retries=READ_TIMEOUT_RETRIES)
if header in (SOH, STX):
break
elif header == CAN:
return {"success": False, "error": "Transfer cancelled by sender"}
# Fallback to checksum if CRC not supported
if attempt == 3 and self.use_crc:
logger.debug("CRC mode not responding, trying checksum")
self.use_crc = False
init_char = NAK
else:
return {"success": False, "error": "Timeout waiting for sender"}
# Receive blocks
while True:
# Check timeout
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s) during receive at block {expected_block}"}
if header == EOT:
self.write(bytes([ACK]))
break
# Determine block size from header
block_size = 1024 if header == STX else 128
check_size = 2 if self.use_crc else 1
# Read block number
block_num = self._read_byte()
block_num_comp = self._read_byte()
if block_num is None or block_num_comp is None:
errors += 1
self.write(bytes([NAK]))
header = self._read_byte(timeout_retries=READ_TIMEOUT_RETRIES)
continue
# Verify block number complement
if (block_num + block_num_comp) & 0xFF != 0xFF:
logger.debug(f"Block number mismatch: {block_num} vs {block_num_comp}")
errors += 1
self.write(bytes([NAK]))
header = self._read_byte(timeout_retries=READ_TIMEOUT_RETRIES)
continue
# Read data
data = self.read(block_size)
if len(data) != block_size:
logger.debug(f"Short block: {len(data)} bytes")
errors += 1
self.write(bytes([NAK]))
header = self._read_byte(timeout_retries=READ_TIMEOUT_RETRIES)
continue
# Read and verify check bytes
check_bytes = self.read(check_size)
if len(check_bytes) != check_size:
errors += 1
self.write(bytes([NAK]))
header = self._read_byte(timeout_retries=READ_TIMEOUT_RETRIES)
continue
if not self._verify_block(data, check_bytes):
logger.debug(f"Block {block_num} checksum/CRC error")
errors += 1
self.write(bytes([NAK]))
header = self._read_byte(timeout_retries=READ_TIMEOUT_RETRIES)
continue
# Check sequence
if block_num == expected_block:
stream.write(data)
bytes_received += len(data)
# Block numbers wrap at 256 (8-bit counter)
expected_block = (expected_block + 1) & 0xFF
# Check transfer size limit to prevent disk exhaustion
if max_transfer_size > 0 and bytes_received > max_transfer_size:
logger.warning(
f"Transfer aborted: exceeded {max_transfer_size} byte limit "
f"(received {bytes_received} bytes)"
)
self.write(bytes([CAN, CAN]))
return {
"success": False,
"error": f"Transfer size exceeded {max_transfer_size} byte limit",
"bytes_received": bytes_received,
}
if callback:
callback(bytes_received)
elif block_num == (expected_block - 1) & 0xFF:
# Duplicate block, already ACK'd
logger.debug(f"Duplicate block {block_num}")
else:
logger.debug(f"Out of sequence: expected {expected_block}, got {block_num}")
errors += 1
self.write(bytes([NAK]))
header = self._read_byte(timeout_retries=READ_TIMEOUT_RETRIES)
continue
self.write(bytes([ACK]))
header = self._read_byte(timeout_retries=READ_TIMEOUT_RETRIES)
return {
"success": True,
"bytes_received": bytes_received,
"blocks": expected_block - 1,
"errors": errors,
"mode": "crc" if self.use_crc else "checksum",
}
def send_xmodem(
read_func: Callable[[int], bytes],
write_func: Callable[[bytes], int],
stream: BinaryIO,
mode: str = "xmodem",
callback: Callable[[int, int], None] | None = None,
) -> dict:
"""Convenience function to send a file via XMODEM."""
xm = XModem(read_func, write_func, mode=mode)
return xm.send(stream, callback)
def receive_xmodem(
read_func: Callable[[int], bytes],
write_func: Callable[[bytes], int],
stream: BinaryIO,
mode: str = "xmodem",
callback: Callable[[int], None] | None = None,
) -> dict:
"""Convenience function to receive a file via XMODEM."""
xm = XModem(read_func, write_func, mode=mode)
return xm.receive(stream, callback)

518
src/mcserial/ymodem.py Normal file
View File

@ -0,0 +1,518 @@
"""YMODEM file transfer protocol implementation.
YMODEM extends XMODEM-1K with:
- Batch transfers (multiple files in one session)
- File metadata in block 0 (filename, size, modification time)
- CRC-16 required (no checksum mode)
Protocol flow:
Receiver sends 'C' to initiate
Sender sends block 0: filename + size + mtime (null-terminated strings)
Receiver ACKs, then sends 'C' again for data
Sender transmits data blocks (1024 bytes, XMODEM-1K style)
Sender sends EOT, receiver NAKs, sender sends EOT again, receiver ACKs
For batch: repeat with next file, or send empty block 0 to end session
"""
from __future__ import annotations
import contextlib
import logging
import os
import time
from collections.abc import Callable
from pathlib import Path
from mcserial._utils import open_file_atomic, sanitize_filename
from mcserial.xmodem import (
ACK,
CAN,
CRC_MODE,
EOT,
NAK,
SOH,
STX,
XModemError,
_calc_crc16,
)
logger = logging.getLogger(__name__)
# Transfer limits and defaults
DEFAULT_MAX_TRANSFER_SIZE = 100 * 1024 * 1024 # 100MB default limit per file
DEFAULT_TIMEOUT = 60.0 # Total transfer timeout in seconds
DEFAULT_RETRY_LIMIT = 16 # Max retries per block
class YModemError(XModemError):
"""YMODEM-specific error."""
pass
class YModem:
"""YMODEM batch file transfer protocol handler.
Args:
read_func: Callable that reads n bytes from serial, returns bytes
write_func: Callable that writes bytes to serial
"""
def __init__(
self,
read_func: Callable[[int], bytes],
write_func: Callable[[bytes], int],
):
self.read = read_func
self.write = write_func
def _read_byte(self, timeout_retries: int = 10) -> int | None:
"""Read a single byte with retry."""
for _ in range(timeout_retries):
data = self.read(1)
if data:
return data[0]
return None
def _make_block(self, block_num: int, data: bytes, block_size: int = 1024) -> bytes:
"""Create a YMODEM block with CRC-16."""
# Pad data to block size
if len(data) < block_size:
data = data + bytes(block_size - len(data))
header = STX if block_size == 1024 else SOH
block = bytes([header, block_num & 0xFF, (255 - block_num) & 0xFF]) + data
crc = _calc_crc16(data)
block += bytes([crc >> 8, crc & 0xFF])
return block
def _make_block0(self, filename: str, filesize: int, mtime: int | None = None) -> bytes:
"""Create YMODEM block 0 with file metadata.
Block 0 format: filename\0size mtime\0 (space-separated, octal for time)
"""
# Build metadata string
if mtime is not None:
meta = f"{filename}\x00{filesize} {mtime:o}"
else:
meta = f"{filename}\x00{filesize}"
data = meta.encode("latin-1")
return data
def _parse_block0(self, data: bytes) -> tuple[dict | None, str]:
"""Parse YMODEM block 0 metadata.
Returns:
Tuple of (metadata_dict, status) where:
- (dict, "ok"): Successfully parsed file metadata
- (None, "end_of_batch"): Empty block 0, signals end of batch transfer
- (None, "parse_error"): Failed to parse block 0 data
"""
# Find null terminator after filename
try:
null_pos = data.index(0)
filename = data[:null_pos].decode("latin-1").strip()
if not filename:
return None, "end_of_batch"
# Parse size and optional mtime
rest = data[null_pos + 1:].split(b" ")
rest = [x for x in rest if x and x != b"\x00"]
filesize = int(rest[0]) if rest else 0
# Clamp filesize to reasonable bounds (prevent memory issues from malicious values)
if filesize < 0:
filesize = 0
elif filesize > 10 * 1024 * 1024 * 1024: # 10GB sanity limit
logger.warning(f"Filesize {filesize} exceeds 10GB limit, clamping")
filesize = 10 * 1024 * 1024 * 1024
mtime = int(rest[1], 8) if len(rest) > 1 else None
return {
"filename": filename,
"size": filesize,
"mtime": mtime,
}, "ok"
except (ValueError, IndexError):
return None, "parse_error"
def send(
self,
files: list[str | Path],
callback: Callable[[str, int, int], None] | None = None,
retry_limit: int = DEFAULT_RETRY_LIMIT,
timeout: float = DEFAULT_TIMEOUT,
) -> dict:
"""Send files via YMODEM batch transfer.
Args:
files: List of file paths to send
callback: Progress callback(filename, bytes_sent, total_bytes)
retry_limit: Max retries per block
timeout: Total timeout in seconds (enforced across entire transfer)
Returns:
Dict with transfer statistics
"""
start_time = time.monotonic()
results = []
total_bytes = 0
total_errors = 0
for filepath in files:
filepath = Path(filepath)
if not filepath.exists():
results.append({"file": str(filepath), "error": "File not found"})
continue
filesize = filepath.stat().st_size
mtime = int(filepath.stat().st_mtime)
# Wait for receiver 'C'
logger.debug(f"Waiting for receiver to initiate {filepath.name}...")
for _ in range(retry_limit * 10):
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s) waiting for receiver", "files": results}
b = self._read_byte(timeout_retries=3)
if b == CRC_MODE:
break
elif b == CAN:
return {"success": False, "error": "Transfer cancelled", "files": results}
else:
return {"success": False, "error": "Timeout waiting for receiver", "files": results}
# Send block 0 with metadata
block0_data = self._make_block0(filepath.name, filesize, mtime)
block0 = self._make_block(0, block0_data, block_size=128)
retries = 0
while retries < retry_limit:
self.write(block0)
response = self._read_byte(timeout_retries=30)
if response == ACK:
break
elif response == CAN:
return {"success": False, "error": "Transfer cancelled", "files": results}
retries += 1
else:
results.append({"file": str(filepath), "error": "Block 0 not acknowledged"})
continue
# Wait for second 'C' to start data
for _ in range(retry_limit * 5):
b = self._read_byte(timeout_retries=3)
if b == CRC_MODE:
break
else:
results.append({"file": str(filepath), "error": "No data initiation"})
continue
# Send file data
bytes_sent = 0
block_num = 1
errors = 0
with open(filepath, "rb") as f:
while True:
data = f.read(1024)
if not data:
break
block = self._make_block(block_num, data)
retries = 0
while retries < retry_limit:
self.write(block)
response = self._read_byte(timeout_retries=30)
if response == ACK:
bytes_sent += len(data)
if callback:
callback(filepath.name, bytes_sent, filesize)
block_num = (block_num + 1) & 0xFF
break
elif response == CAN:
results.append({"file": str(filepath), "error": "Cancelled"})
break
else:
retries += 1
errors += 1
else:
results.append({"file": str(filepath), "error": f"Max retries at block {block_num}"})
break
# Send EOT sequence (NAK then ACK expected)
for _ in range(retry_limit):
self.write(bytes([EOT]))
response = self._read_byte(timeout_retries=10)
if response == NAK:
self.write(bytes([EOT]))
response = self._read_byte(timeout_retries=10)
if response == ACK:
break
elif response == ACK:
break
results.append({
"file": str(filepath),
"bytes_sent": bytes_sent,
"blocks": block_num - 1,
"errors": errors,
"success": True,
})
total_bytes += bytes_sent
total_errors += errors
# Send empty block 0 to end batch
for _ in range(retry_limit * 5):
b = self._read_byte(timeout_retries=3)
if b == CRC_MODE:
break
empty_block0 = self._make_block(0, b"", block_size=128)
self.write(empty_block0)
self._read_byte(timeout_retries=10) # ACK
return {
"success": all(r.get("success", False) for r in results),
"files": results,
"total_bytes": total_bytes,
"total_errors": total_errors,
}
def receive(
self,
directory: str | Path,
callback: Callable[[str, int, int], None] | None = None,
retry_limit: int = DEFAULT_RETRY_LIMIT,
overwrite: bool = False,
max_transfer_size: int = DEFAULT_MAX_TRANSFER_SIZE,
timeout: float = DEFAULT_TIMEOUT,
) -> dict:
"""Receive files via YMODEM batch transfer.
Args:
directory: Directory to save received files
callback: Progress callback(filename, bytes_received, total_bytes)
retry_limit: Max retries per block
overwrite: Overwrite existing files
max_transfer_size: Maximum bytes to receive per file (default 100MB).
Set to 0 to disable limit. Prevents unbounded memory usage.
timeout: Total timeout in seconds (enforced across entire transfer)
Returns:
Dict with transfer statistics
"""
start_time = time.monotonic()
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
results = []
total_bytes = 0
total_errors = 0
while True:
# Check timeout
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s)", "files": results}
# Initiate with 'C' for CRC mode
logger.debug("Initiating YMODEM receive...")
for _attempt in range(retry_limit):
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s)", "files": results}
self.write(bytes([CRC_MODE]))
header = self._read_byte(timeout_retries=30)
if header in (SOH, STX):
break
elif header == CAN:
return {"success": False, "error": "Cancelled", "files": results}
else:
return {"success": False, "error": "Timeout", "files": results}
# Receive block 0
block_size = 1024 if header == STX else 128
block_num = self._read_byte()
block_num_comp = self._read_byte()
if block_num != 0:
self.write(bytes([NAK]))
continue
data = self.read(block_size)
crc_bytes = self.read(2)
# Verify CRC
expected_crc = _calc_crc16(data)
received_crc = (crc_bytes[0] << 8) | crc_bytes[1]
if expected_crc != received_crc:
self.write(bytes([NAK]))
continue
# Parse metadata
meta, status = self._parse_block0(data)
if status == "end_of_batch":
self.write(bytes([ACK]))
break
elif status == "parse_error":
logger.debug("Failed to parse block 0 metadata")
self.write(bytes([NAK]))
continue
filename = meta["filename"]
filesize = meta["size"]
logger.debug(f"Receiving: {filename} ({filesize} bytes)")
# Security: sanitize filename to prevent path traversal attacks
safe_filename = sanitize_filename(filename)
if safe_filename != filename:
logger.warning(f"Sanitized filename: {filename!r} -> {safe_filename!r}")
filepath = directory / safe_filename
# Atomic file creation to prevent TOCTOU race conditions
f, file_error = open_file_atomic(filepath, overwrite)
if file_error:
results.append({"file": filename, "error": file_error})
self.write(bytes([CAN, CAN]))
continue
self.write(bytes([ACK]))
# Send 'C' to start data transfer
self.write(bytes([CRC_MODE]))
# Receive data blocks
bytes_received = 0
expected_block = 1
errors = 0
transfer_aborted = False
try:
while bytes_received < filesize and not transfer_aborted:
header = self._read_byte(timeout_retries=60)
if header == EOT:
self.write(bytes([NAK]))
header = self._read_byte(timeout_retries=10)
if header == EOT:
self.write(bytes([ACK]))
break
continue
if header not in (SOH, STX):
errors += 1
self.write(bytes([NAK]))
continue
block_size = 1024 if header == STX else 128
block_num = self._read_byte()
block_num_comp = self._read_byte()
if (block_num + block_num_comp) & 0xFF != 0xFF:
errors += 1
self.write(bytes([NAK]))
continue
data = self.read(block_size)
crc_bytes = self.read(2)
if len(data) != block_size or len(crc_bytes) != 2:
errors += 1
self.write(bytes([NAK]))
continue
expected_crc = _calc_crc16(data)
received_crc = (crc_bytes[0] << 8) | crc_bytes[1]
if expected_crc != received_crc:
errors += 1
self.write(bytes([NAK]))
continue
if block_num == expected_block:
# Trim last block to actual file size
remaining = filesize - bytes_received
if remaining < block_size:
data = data[:remaining]
f.write(data)
bytes_received += len(data)
expected_block = (expected_block + 1) & 0xFF
# Check transfer size limit to prevent unbounded memory usage
if max_transfer_size > 0 and bytes_received > max_transfer_size:
logger.warning(
f"Transfer aborted: {filename} exceeded {max_transfer_size} byte limit "
f"(received {bytes_received} bytes)"
)
self.write(bytes([CAN, CAN]))
transfer_aborted = True
break
if callback:
callback(filename, bytes_received, filesize)
self.write(bytes([ACK]))
finally:
f.close()
if transfer_aborted:
# Transfer was aborted due to size limit
results.append({
"file": filename,
"path": str(filepath),
"error": f"Transfer size exceeded {max_transfer_size} byte limit",
"bytes_received": bytes_received,
"success": False,
})
# Try to clean up partial file
with contextlib.suppress(OSError):
filepath.unlink()
continue
# Set modification time if provided
if meta.get("mtime"):
os.utime(filepath, (time.time(), meta["mtime"]))
results.append({
"file": filename,
"path": str(filepath),
"bytes_received": bytes_received,
"errors": errors,
"success": True,
})
total_bytes += bytes_received
total_errors += errors
return {
"success": all(r.get("success", False) for r in results),
"files": results,
"total_bytes": total_bytes,
"total_errors": total_errors,
}
def send_ymodem(
read_func: Callable[[int], bytes],
write_func: Callable[[bytes], int],
files: list[str | Path],
callback: Callable[[str, int, int], None] | None = None,
) -> dict:
"""Convenience function to send files via YMODEM."""
ym = YModem(read_func, write_func)
return ym.send(files, callback)
def receive_ymodem(
read_func: Callable[[int], bytes],
write_func: Callable[[bytes], int],
directory: str | Path,
callback: Callable[[str, int, int], None] | None = None,
overwrite: bool = False,
) -> dict:
"""Convenience function to receive files via YMODEM."""
ym = YModem(read_func, write_func)
return ym.receive(directory, callback, overwrite=overwrite)

819
src/mcserial/zmodem.py Normal file
View File

@ -0,0 +1,819 @@
"""ZMODEM file transfer protocol implementation.
ZMODEM is the most sophisticated serial file transfer protocol:
- Streaming with selective retransmission (no stop-and-wait)
- CRC-16 or CRC-32 error detection
- Crash recovery (resume interrupted transfers)
- Auto-start capability
- Escape sequence encoding for 8-bit transparency
Frame structure:
ZPAD ZPAD ZDLE frame_type [4 bytes header data] [CRC]
Data subpacket:
[escaped data] ZDLE frame_end_type [CRC]
"""
from __future__ import annotations
import contextlib
import logging
import os
import time
from collections.abc import Callable
from pathlib import Path
from mcserial._utils import open_file_atomic, sanitize_filename
logger = logging.getLogger(__name__)
# Protocol constants
ZPAD = 0x2A # '*' - Padding character
# Transfer limits
DEFAULT_MAX_TRANSFER_SIZE = 100 * 1024 * 1024 # 100MB default limit per file
MAX_SUBPACKET_SIZE = 32768 # Per-packet size limit (32KB - matches common implementations)
DEFAULT_TIMEOUT = 60.0 # Total transfer timeout in seconds
MAX_HEADER_SEARCH_ITERATIONS = 1000 # Max iterations when searching for header sync
ZDLE = 0x18 # Data Link Escape
ZDLEE = 0x58 # Escaped ZDLE (ZDLE ^ 0x40)
# Frame types
ZBIN = 0x41 # 'A' - Binary header, CRC-16
ZHEX = 0x42 # 'B' - Hex header, CRC-16
ZBIN32 = 0x43 # 'C' - Binary header, CRC-32
# Header types
ZRQINIT = 0 # Request receive init
ZRINIT = 1 # Receive init
ZSINIT = 2 # Send init sequence
ZACK = 3 # ACK
ZFILE = 4 # File name/info
ZSKIP = 5 # Skip this file
ZNAK = 6 # Last packet was garbled
ZABORT = 7 # Abort batch transfers
ZFIN = 8 # Finish session
ZRPOS = 9 # Resume at position
ZDATA = 10 # Data packet follows
ZEOF = 11 # End of file
ZFERR = 12 # Fatal error
ZCRC = 13 # Request file CRC
ZCHALLENGE = 14 # Challenge
ZCOMPL = 15 # Request complete
ZCAN = 16 # Cancel (5 CANs)
ZFREECNT = 17 # Request free bytes
ZCOMMAND = 18 # Execute command
ZSTDERR = 19 # Output to stderr
# Data subpacket end types
ZCRCE = 0x68 # 'h' - CRC next, end, no ACK
ZCRCG = 0x69 # 'i' - CRC next, not end, no ACK
ZCRCQ = 0x6A # 'j' - CRC next, not end, ACK requested
ZCRCW = 0x6B # 'k' - CRC next, end, ACK requested
# Special characters to escape
ESCAPE_CHARS = {0x10, 0x11, 0x13, 0x90, 0x91, 0x93, ZDLE, 0x0D, 0x8D}
# CRC-32 lookup table - initialized at module load for thread-safety
def _build_crc32_table() -> list[int]:
"""Build CRC-32 lookup table (called once at module load)."""
table = []
for i in range(256):
crc = i
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xEDB88320
else:
crc >>= 1
table.append(crc)
return table
_CRC32_TABLE: list[int] = _build_crc32_table()
def _calc_crc16(data: bytes) -> int:
"""Calculate CRC-16-CCITT."""
crc = 0
for byte in data:
crc ^= byte << 8
for _ in range(8):
if crc & 0x8000:
crc = (crc << 1) ^ 0x1021
else:
crc <<= 1
crc &= 0xFFFF
return crc
def _calc_crc32(data: bytes) -> int:
"""Calculate CRC-32 using pre-computed lookup table."""
crc = 0xFFFFFFFF
for byte in data:
crc = _CRC32_TABLE[(crc ^ byte) & 0xFF] ^ (crc >> 8)
return crc ^ 0xFFFFFFFF
def _escape_byte(b: int) -> bytes:
"""Escape a byte if needed for ZMODEM transmission."""
if b == ZDLE:
return bytes([ZDLE, ZDLEE])
elif b in ESCAPE_CHARS:
return bytes([ZDLE, b ^ 0x40])
else:
return bytes([b])
def _escape_data(data: bytes) -> bytes:
"""Escape all bytes in data that need escaping."""
result = bytearray()
for b in data:
result.extend(_escape_byte(b))
return bytes(result)
def _to_hex(value: int, digits: int = 2) -> bytes:
"""Convert value to hex string bytes."""
return f"{value:0{digits}x}".encode("ascii")
class ZModemError(Exception):
"""ZMODEM protocol error."""
pass
class ZModem:
"""ZMODEM file transfer protocol handler.
Args:
read_func: Callable that reads n bytes from serial
write_func: Callable that writes bytes to serial
use_crc32: Use CRC-32 instead of CRC-16 (default True)
"""
def __init__(
self,
read_func: Callable[[int], bytes],
write_func: Callable[[bytes], int],
use_crc32: bool = True,
):
self.read = read_func
self.write = write_func
self.use_crc32 = use_crc32
self.last_sync_pos = 0
self.tx_buffer_size = 1024
self.rx_buffer_size = 0
def _read_byte(self, timeout_retries: int = 10) -> int | None:
"""Read a single byte with retry."""
for _ in range(timeout_retries):
data = self.read(1)
if data:
return data[0]
return None
def _read_zdle_byte(self) -> int | None:
"""Read a byte, handling ZDLE escaping."""
b = self._read_byte(timeout_retries=30)
if b is None:
return None
if b == ZDLE:
b = self._read_byte(timeout_retries=30)
if b is None:
return None
if b == ZDLEE:
return ZDLE
elif b in (ZCRCE, ZCRCG, ZCRCQ, ZCRCW):
return b | 0x100 # Flag as frame end
else:
return b ^ 0x40
return b
def _send_cancel(self, retries: int = 3) -> None:
"""Send ZMODEM cancel sequence with retry for reliability.
ZMODEM uses 8 CAN bytes followed by 8 backspaces to abort transfer.
This is the proper way to cancel - just sending ZCAN header may not
be recognized by all implementations.
Note: CAN (Cancel) and ZDLE share the same byte value 0x18. In ZMODEM,
ZDLE is the escape character, but when sent 5+ times consecutively
outside of frame context, it signals abort (CAN sequence).
Args:
retries: Number of times to send cancel sequence for reliability
"""
# CAN = 0x18 (same as ZDLE) - 8 consecutive cancels + 8 backspaces
cancel_seq = bytes([0x18] * 8 + [0x08] * 8)
for _ in range(retries):
self.write(cancel_seq)
# Brief delay between retries to ensure remote receives
time.sleep(0.1)
def _make_hex_header(self, frame_type: int, data: bytes = b"\x00\x00\x00\x00") -> bytes:
"""Create a hex-encoded ZMODEM header."""
header = bytes([frame_type]) + data[:4].ljust(4, b"\x00")
crc = _calc_crc16(header)
result = bytearray([ZPAD, ZPAD, ZDLE, ZHEX])
for b in header:
result.extend(_to_hex(b))
result.extend(_to_hex(crc >> 8))
result.extend(_to_hex(crc & 0xFF))
result.extend(b"\r\n")
return bytes(result)
def _make_bin_header(self, frame_type: int, data: bytes = b"\x00\x00\x00\x00") -> bytes:
"""Create a binary ZMODEM header."""
header = bytes([frame_type]) + data[:4].ljust(4, b"\x00")
if self.use_crc32:
crc = _calc_crc32(header)
result = bytearray([ZPAD, ZDLE, ZBIN32])
result.extend(_escape_data(header))
crc_bytes = crc.to_bytes(4, "little")
result.extend(_escape_data(crc_bytes))
else:
crc = _calc_crc16(header)
result = bytearray([ZPAD, ZDLE, ZBIN])
result.extend(_escape_data(header))
result.extend(_escape_data(bytes([crc >> 8, crc & 0xFF])))
return bytes(result)
def _make_data_subpacket(self, data: bytes, end_type: int) -> bytes:
"""Create a data subpacket with CRC."""
result = bytearray(_escape_data(data))
result.extend([ZDLE, end_type])
if self.use_crc32:
crc = _calc_crc32(data + bytes([end_type]))
crc_bytes = crc.to_bytes(4, "little")
result.extend(_escape_data(crc_bytes))
else:
crc = _calc_crc16(data + bytes([end_type]))
result.extend(_escape_data(bytes([crc >> 8, crc & 0xFF])))
return bytes(result)
def _read_hex_header(self) -> tuple[int, bytes] | None:
"""Read and parse a hex-encoded header."""
# Read hex digits: type(2) + data(8) + crc(4) = 14 hex chars
hex_data = self.read(14)
if len(hex_data) != 14:
return None
try:
raw = bytes.fromhex(hex_data.decode("ascii"))
except (ValueError, UnicodeDecodeError):
return None
frame_type = raw[0]
data = raw[1:5]
recv_crc = (raw[5] << 8) | raw[6]
calc_crc = _calc_crc16(raw[:5])
if calc_crc != recv_crc:
logger.debug(f"Hex header CRC mismatch: {calc_crc:04x} vs {recv_crc:04x}")
return None
# Read trailing CR/LF
self.read(2)
return frame_type, data
def _read_bin_header(self, use_crc32: bool) -> tuple[int, bytes] | None:
"""Read and parse a binary header."""
# Read 5 bytes: type + 4 data bytes
raw = bytearray()
for _ in range(5):
b = self._read_zdle_byte()
if b is None:
return None
raw.append(b & 0xFF)
frame_type = raw[0]
data = bytes(raw[1:5])
# Read CRC
crc_len = 4 if use_crc32 else 2
crc_bytes = bytearray()
for _ in range(crc_len):
b = self._read_zdle_byte()
if b is None:
return None
crc_bytes.append(b & 0xFF)
# Verify CRC
if use_crc32:
recv_crc = int.from_bytes(crc_bytes, "little")
calc_crc = _calc_crc32(bytes(raw))
else:
recv_crc = (crc_bytes[0] << 8) | crc_bytes[1]
calc_crc = _calc_crc16(bytes(raw))
if calc_crc != recv_crc:
logger.debug("Binary header CRC mismatch")
return None
return frame_type, data
def _read_header(self) -> tuple[int, bytes] | None:
"""Wait for and read any ZMODEM header."""
# Look for ZPAD ZPAD ZDLE or ZPAD ZDLE
sync_count = 0
for _ in range(MAX_HEADER_SEARCH_ITERATIONS):
b = self._read_byte(timeout_retries=10)
if b is None:
continue
if b == ZPAD:
sync_count += 1
elif b == ZDLE and sync_count >= 1:
# Found sync, read frame type
frame_marker = self._read_byte(timeout_retries=10)
if frame_marker == ZHEX:
return self._read_hex_header()
elif frame_marker == ZBIN:
return self._read_bin_header(use_crc32=False)
elif frame_marker == ZBIN32:
return self._read_bin_header(use_crc32=True)
sync_count = 0
else:
sync_count = 0
return None
def _read_data_subpacket(self) -> tuple[bytes, int] | None:
"""Read a data subpacket."""
data = bytearray()
while True:
b = self._read_zdle_byte()
if b is None:
return None
if b & 0x100: # Frame end marker
end_type = b & 0xFF
break
data.append(b)
if len(data) > MAX_SUBPACKET_SIZE:
logger.debug(f"Subpacket exceeded {MAX_SUBPACKET_SIZE} bytes limit")
return None
# Read and verify CRC
crc_len = 4 if self.use_crc32 else 2
crc_bytes = bytearray()
for _ in range(crc_len):
b = self._read_zdle_byte()
if b is None:
return None
crc_bytes.append(b & 0xFF)
# Verify CRC
check_data = bytes(data) + bytes([end_type])
if self.use_crc32:
recv_crc = int.from_bytes(crc_bytes, "little")
calc_crc = _calc_crc32(check_data)
else:
recv_crc = (crc_bytes[0] << 8) | crc_bytes[1]
calc_crc = _calc_crc16(check_data)
if calc_crc != recv_crc:
logger.debug("Data subpacket CRC error")
return None
return bytes(data), end_type
def _pos_to_bytes(self, pos: int) -> bytes:
"""Convert position to 4-byte little-endian.
Raises:
OverflowError: If position exceeds 4GB (ZMODEM protocol limit)
"""
try:
return pos.to_bytes(4, "little")
except OverflowError:
raise OverflowError(
f"File position {pos} exceeds ZMODEM's 4GB limit (max 4,294,967,295 bytes). "
"ZMODEM uses 32-bit position encoding and cannot handle files larger than 4GB."
) from None
def _bytes_to_pos(self, data: bytes, max_valid: int | None = None) -> int:
"""Convert 4-byte little-endian to position.
Args:
data: At least 4 bytes of position data
max_valid: Optional maximum valid position (e.g., filesize).
If provided and position exceeds this, returns max_valid.
Returns:
Position value, clamped to max_valid if specified
"""
if len(data) < 4:
return 0
pos = int.from_bytes(data[:4], "little")
# Validate against maximum if provided (prevents seeking past EOF)
if max_valid is not None and pos > max_valid:
logger.debug(f"Position {pos} exceeds max {max_valid}, clamping")
return max_valid
return pos
def send(
self,
files: list[str | Path],
callback: Callable[[str, int, int], None] | None = None,
retry_limit: int = 10,
timeout: float = DEFAULT_TIMEOUT,
) -> dict:
"""Send files via ZMODEM.
Args:
files: List of file paths to send
callback: Progress callback(filename, bytes_sent, total_bytes)
retry_limit: Max retries for errors
timeout: Total timeout in seconds (enforced across entire transfer)
Returns:
Dict with transfer statistics
"""
start_time = time.monotonic()
results = []
total_bytes = 0
# Send ZRQINIT to request receiver init
logger.debug("Sending ZRQINIT...")
self.write(self._make_hex_header(ZRQINIT))
# Wait for ZRINIT from receiver
for _ in range(retry_limit * 3):
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s) waiting for receiver", "files": results}
header = self._read_header()
if header is None:
self.write(self._make_hex_header(ZRQINIT))
continue
frame_type, data = header
if frame_type == ZRINIT:
self.rx_buffer_size = self._bytes_to_pos(data)
logger.debug(f"Received ZRINIT, buffer={self.rx_buffer_size}")
break
elif frame_type == ZCAN:
return {"success": False, "error": "Cancelled by receiver", "files": results}
else:
return {"success": False, "error": "No response from receiver", "files": results}
# Send each file
for filepath in files:
filepath = Path(filepath)
if not filepath.exists():
results.append({"file": str(filepath), "error": "File not found"})
continue
filesize = filepath.stat().st_size
mtime = int(filepath.stat().st_mtime)
# Send ZFILE with filename info
file_info = f"{filepath.name}\x00{filesize} {mtime:o} 0 0 0 0\x00".encode("latin-1")
self.write(self._make_bin_header(ZFILE))
self.write(self._make_data_subpacket(file_info, ZCRCW))
# Wait for ZRPOS or ZSKIP
start_pos = 0
for _ in range(retry_limit):
header = self._read_header()
if header is None:
continue
frame_type, data = header
if frame_type == ZRPOS:
start_pos = self._bytes_to_pos(data)
logger.debug(f"Receiver wants to start at {start_pos}")
break
elif frame_type == ZSKIP:
logger.debug(f"Receiver skipping {filepath.name}")
results.append({"file": str(filepath), "skipped": True})
break
elif frame_type == ZCAN:
return {"success": False, "error": "Cancelled", "files": results}
else:
results.append({"file": str(filepath), "error": "No ZRPOS received"})
continue
if frame_type == ZSKIP:
continue
# Send file data
bytes_sent = start_pos
errors = 0
with open(filepath, "rb") as f:
f.seek(start_pos)
# Send ZDATA header with position
self.write(self._make_bin_header(ZDATA, self._pos_to_bytes(bytes_sent)))
while bytes_sent < filesize:
chunk = f.read(min(self.tx_buffer_size, filesize - bytes_sent))
if not chunk:
break
# ZCRCE = end of file, ZCRCG = more data coming
end_type = ZCRCE if bytes_sent + len(chunk) >= filesize else ZCRCG
self.write(self._make_data_subpacket(chunk, end_type))
bytes_sent += len(chunk)
if callback:
callback(filepath.name, bytes_sent, filesize)
# Check for ZACK or ZRPOS occasionally
if end_type == ZCRCE or bytes_sent % (self.tx_buffer_size * 10) == 0:
header = self._read_header()
if header:
htype, hdata = header
if htype == ZRPOS:
# Receiver wants us to resend from position
new_pos = self._bytes_to_pos(hdata)
logger.debug(f"ZRPOS received, resending from {new_pos}")
f.seek(new_pos)
bytes_sent = new_pos
errors += 1
self.write(self._make_bin_header(ZDATA, self._pos_to_bytes(bytes_sent)))
elif htype == ZCAN:
return {"success": False, "error": "Cancelled", "files": results}
# Send ZEOF
self.write(self._make_bin_header(ZEOF, self._pos_to_bytes(bytes_sent)))
# Wait for ZRINIT (ready for next file)
for _ in range(retry_limit):
header = self._read_header()
if header and header[0] == ZRINIT:
break
results.append({
"file": str(filepath),
"bytes_sent": bytes_sent,
"errors": errors,
"success": True,
})
total_bytes += bytes_sent
# Send ZFIN to end session
self.write(self._make_hex_header(ZFIN))
# Wait for ZFIN response
for _ in range(retry_limit):
header = self._read_header()
if header and header[0] == ZFIN:
# Send OO to complete
self.write(b"OO")
break
return {
"success": all(r.get("success", False) or r.get("skipped", False) for r in results),
"files": results,
"total_bytes": total_bytes,
}
def receive(
self,
directory: str | Path,
callback: Callable[[str, int, int], None] | None = None,
retry_limit: int = 10,
overwrite: bool = False,
max_transfer_size: int = DEFAULT_MAX_TRANSFER_SIZE,
timeout: float = DEFAULT_TIMEOUT,
) -> dict:
"""Receive files via ZMODEM.
Args:
directory: Directory to save received files
callback: Progress callback(filename, bytes_received, total_bytes)
retry_limit: Max retries for errors
overwrite: Overwrite existing files
max_transfer_size: Maximum bytes to receive per file (default 100MB).
Set to 0 to disable limit. Prevents unbounded memory usage.
timeout: Total timeout in seconds (enforced across entire transfer)
Returns:
Dict with transfer statistics
"""
start_time = time.monotonic()
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
results = []
total_bytes = 0
# Send ZRINIT to indicate ready
buffer_size = 8192
logger.debug("Sending ZRINIT...")
self.write(self._make_hex_header(ZRINIT, self._pos_to_bytes(buffer_size)))
while True:
# Check timeout
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s)", "files": results}
# Wait for ZFILE or ZFIN
header = None
for _ in range(retry_limit * 3):
if time.monotonic() - start_time > timeout:
return {"success": False, "error": f"Timeout ({timeout}s)", "files": results}
header = self._read_header()
if header:
break
# Resend ZRINIT
self.write(self._make_hex_header(ZRINIT, self._pos_to_bytes(buffer_size)))
if header is None:
return {"success": False, "error": "Timeout", "files": results}
frame_type, data = header
if frame_type == ZFIN:
# Session complete
self.write(self._make_hex_header(ZFIN))
break
elif frame_type == ZCAN:
return {"success": False, "error": "Cancelled", "files": results}
elif frame_type != ZFILE:
continue
# Read file info subpacket
subpacket = self._read_data_subpacket()
if subpacket is None:
self.write(self._make_hex_header(ZNAK))
continue
file_data, _ = subpacket
# Parse filename and metadata
try:
null_pos = file_data.index(0)
filename = file_data[:null_pos].decode("latin-1")
rest = file_data[null_pos + 1:].split(b" ")
filesize = int(rest[0]) if rest[0] else 0
mtime = int(rest[1], 8) if len(rest) > 1 and rest[1] else None
except (ValueError, IndexError):
filename = file_data.split(b"\x00")[0].decode("latin-1", errors="replace")
filesize = 0
mtime = None
logger.debug(f"Receiving: {filename} ({filesize} bytes)")
# Security: sanitize filename to prevent path traversal attacks
safe_filename = sanitize_filename(filename)
if safe_filename != filename:
logger.warning(f"Sanitized filename: {filename!r} -> {safe_filename!r}")
filepath = directory / safe_filename
# Atomic file creation to prevent TOCTOU race conditions
f, file_error = open_file_atomic(filepath, overwrite)
if file_error:
logger.warning(f"Cannot create file {filepath}: {file_error}")
results.append({"file": filename, "error": file_error})
self.write(self._make_hex_header(ZSKIP))
continue
# Check for resume (TODO: implement crash recovery using existing file size)
start_pos = 0
# Send ZRPOS to indicate where to start
self.write(self._make_bin_header(ZRPOS, self._pos_to_bytes(start_pos)))
# Receive data
bytes_received = start_pos
errors = 0
transfer_aborted = False
try:
if start_pos > 0:
f.seek(start_pos)
while not transfer_aborted:
# Wait for ZDATA header
header = self._read_header()
if header is None:
errors += 1
self.write(self._make_bin_header(ZRPOS, self._pos_to_bytes(bytes_received)))
continue
frame_type, data = header
if frame_type == ZEOF:
eof_pos = self._bytes_to_pos(data)
logger.debug(f"ZEOF at position {eof_pos}")
break
elif frame_type == ZCAN:
results.append({"file": filename, "error": "Cancelled"})
break
elif frame_type != ZDATA:
continue
data_pos = self._bytes_to_pos(data)
if data_pos != bytes_received:
logger.debug(f"Position mismatch: expected {bytes_received}, got {data_pos}")
self.write(self._make_bin_header(ZRPOS, self._pos_to_bytes(bytes_received)))
continue
# Read data subpackets
while True:
subpacket = self._read_data_subpacket()
if subpacket is None:
errors += 1
self.write(self._make_bin_header(ZRPOS, self._pos_to_bytes(bytes_received)))
break
chunk, end_type = subpacket
f.write(chunk)
bytes_received += len(chunk)
# Check transfer size limit to prevent unbounded memory usage
if max_transfer_size > 0 and bytes_received > max_transfer_size:
logger.warning(
f"Transfer aborted: {filename} exceeded {max_transfer_size} byte limit "
f"(received {bytes_received} bytes)"
)
self._send_cancel()
transfer_aborted = True
break
if callback:
callback(filename, bytes_received, filesize)
if end_type in (ZCRCE, ZCRCW):
# End of this ZDATA frame
if end_type == ZCRCW:
self.write(self._make_bin_header(ZACK, self._pos_to_bytes(bytes_received)))
break
finally:
f.close()
if transfer_aborted:
# Transfer was aborted due to size limit
results.append({
"file": filename,
"path": str(filepath),
"error": f"Transfer size exceeded {max_transfer_size} byte limit",
"bytes_received": bytes_received,
"success": False,
})
# Try to clean up partial file
with contextlib.suppress(OSError):
filepath.unlink()
continue
# Set modification time
if mtime:
os.utime(filepath, (time.time(), mtime))
# Send ZRINIT for next file
self.write(self._make_hex_header(ZRINIT, self._pos_to_bytes(buffer_size)))
results.append({
"file": filename,
"path": str(filepath),
"bytes_received": bytes_received,
"errors": errors,
"success": True,
})
total_bytes += bytes_received
return {
"success": all(r.get("success", False) for r in results),
"files": results,
"total_bytes": total_bytes,
}
def send_zmodem(
read_func: Callable[[int], bytes],
write_func: Callable[[bytes], int],
files: list[str | Path],
callback: Callable[[str, int, int], None] | None = None,
) -> dict:
"""Convenience function to send files via ZMODEM."""
zm = ZModem(read_func, write_func)
return zm.send(files, callback)
def receive_zmodem(
read_func: Callable[[int], bytes],
write_func: Callable[[bytes], int],
directory: str | Path,
callback: Callable[[str, int, int], None] | None = None,
overwrite: bool = False,
) -> dict:
"""Convenience function to receive files via ZMODEM."""
zm = ZModem(read_func, write_func)
return zm.receive(directory, callback, overwrite=overwrite)