A reliable serial link needs more than matching baud rates. Define the physical connection, frame boundary, encoding, maximum message size, timeout, acknowledgement, and reset behavior before building application commands.
This 2026 maintenance layer turns the original five-line note into a complete USB-serial workflow using Arduino’s documented Serial API and pySerial. The complete 2019 export remains at the end as a source archive.
Table of Contents
1. Choose USB serial or the GPIO UART
For a first implementation, connect the Arduino’s normal USB data port to a Raspberry Pi USB port. The Arduino appears as a USB serial device, commonly /dev/ttyACM0 or /dev/ttyUSB0. This route does not require enabling the Raspberry Pi’s GPIO UART or wiring TX and RX pins. Opening the port may reset some Arduino boards through DTR, so the software must tolerate a short restart.
A direct TTL UART is a different electrical design. It needs crossed TX/RX and a common ground. Raspberry Pi UART pins use 3.3 V logic; the official documentation warns that connecting a 5 V signal can cause damage. Use a suitable level shifter or a USB-to-3.3 V serial adapter when the other device is not 3.3 V safe. For a direct UART, enable the UART hardware and disable the serial login console through raspi-config, then verify the model-specific /dev/serial* mapping.
Do not connect USB serial and direct TX/RX wiring simultaneously unless the complete circuit and board behavior have been reviewed.
2. Define a small protocol first
This example uses a deliberately narrow contract:
| Property | Contract |
|---|---|
| Transport | USB serial, 115200 baud, 8 data bits, no parity, 1 stop bit |
| Frame boundary | One ASCII command per line, terminated by LF (\n); CR is ignored |
| Maximum command | 63 bytes before the terminator |
| Request | PING <decimal-request-id> |
| Success | OK <same-request-id> |
| Failure | ERR bad_command, ERR invalid_byte, or ERR line_too_long |
| Startup notice | READY 1; optional because it may be sent before the host opens the port |
The request ID lets the host match a response. PING is safe to retry because it has no side effect. Commands that move hardware, charge money, or write state need an explicit idempotency design; blindly retrying them can repeat the action.
ASCII is sufficient for protocol keywords and numeric IDs. If a later command carries human text, define UTF-8 explicitly, bound its byte length, and decide how invalid input is rejected. Never depend on whatever encoding happens to be active on either machine.
3. Upload a bounded Arduino parser
This sketch avoids an unbounded String and never waits inside loop(). It buffers one line, rejects non-ASCII control/data bytes, and discards an overlong frame until its newline arrives.
#include <Arduino.h>
#include <string.h>
constexpr unsigned long BAUD_RATE = 115200;
constexpr size_t MAX_LINE = 64;
enum class DropReason {
none,
invalid_byte,
line_too_long
};
char line_buffer[MAX_LINE];
size_t line_length = 0;
DropReason drop_reason = DropReason::none;
bool is_request_id(const char *text) {
if (*text == '\0') {
return false;
}
while (*text != '\0') {
if (*text < '0' || *text > '9') {
return false;
}
++text;
}
return true;
}
void handle_line(const char *line) {
if (strncmp(line, "PING ", 5) == 0 && is_request_id(line + 5)) {
Serial.print(F("OK "));
Serial.println(line + 5);
} else {
Serial.println(F("ERR bad_command"));
}
}
void finish_line() {
if (drop_reason == DropReason::invalid_byte) {
Serial.println(F("ERR invalid_byte"));
} else if (drop_reason == DropReason::line_too_long) {
Serial.println(F("ERR line_too_long"));
} else {
line_buffer[line_length] = '\0';
handle_line(line_buffer);
}
line_length = 0;
drop_reason = DropReason::none;
}
void setup() {
Serial.begin(BAUD_RATE);
Serial.println(F("READY 1"));
}
void loop() {
while (Serial.available() > 0) {
const int raw = Serial.read();
if (raw < 0) {
break;
}
const char value = static_cast<char>(raw);
if (value == '\r') {
continue;
}
if (value == '\n') {
finish_line();
continue;
}
if (drop_reason != DropReason::none) {
continue;
}
if (raw < 0x20 || raw > 0x7e) {
drop_reason = DropReason::invalid_byte;
continue;
}
if (line_length + 1 >= MAX_LINE) {
drop_reason = DropReason::line_too_long;
continue;
}
line_buffer[line_length++] = value;
}
}
Serial.available() reports bytes already received, and Serial.read() returns the next byte or -1 when none is available. The parser still checks the return value. The baud rate must match the host, but the frame contract is what prevents partial reads from becoming partial commands.
4. Install pySerial and identify the device
On Raspberry Pi OS, either use the distribution package with system Python:
sudo apt update
sudo apt install --yes python3-serial
python3 -c 'import serial; print(serial.VERSION)'
Or isolate the dependency in a project virtual environment:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pyserial
python -c 'import serial; print(serial.VERSION)'
List ports before and after connecting the Arduino:
python -m serial.tools.list_ports --verbose
ls -l /dev/serial/by-id/ 2>/dev/null || true
When available, a /dev/serial/by-id/... link is more stable than assuming the board will always be /dev/ttyACM0. Record the vendor, product, and serial identity rather than selecting the first port blindly.
5. Verify serial-port permission
Inspect the chosen node and the effective groups of the process that will open it:
serial_port="/dev/ttyACM0"
ls -l "$serial_port"
id
id -nG
On Raspberry Pi OS, USB serial devices are commonly accessible to the dialout group. If the intended account is missing that group, add only that account and begin a genuinely new login session:
serial_user="$(id -un)"
sudo usermod -a -G dialout "$serial_user"
Log out completely and sign in again, then rerun id -nG. Do not run the application as root or make the device world-writable. A systemd service or container may use a different account and device policy from the interactive terminal; diagnose that context directly.
6. Run a reset-tolerant Python client
Save this as serial_ping.py. It sends the same idempotent request until it receives the matching response. This handles boards that reset when the USB serial port opens without relying on one guessed sleep duration.
import argparse
import time
import serial
BAUD_RATE = 115200
MAX_RESPONSE = 80
class ProtocolError(RuntimeError):
pass
def read_ascii_line(port):
raw = port.read_until(b"\n", size=MAX_RESPONSE)
if not raw:
return None
if not raw.endswith(b"\n"):
raise ProtocolError("response exceeded the frame limit")
try:
return raw.rstrip(b"\r\n").decode("ascii")
except UnicodeDecodeError as error:
raise ProtocolError("response was not ASCII") from error
def ping(port_name, request_id):
if not request_id.isdecimal() or len(request_id) > 32:
raise ValueError("request ID must contain 1 to 32 decimal digits")
request = f"PING {request_id}\n".encode("ascii")
expected = f"OK {request_id}"
with serial.Serial(
port=port_name,
baudrate=BAUD_RATE,
timeout=0.4,
write_timeout=1.0,
exclusive=True,
) as port:
port.reset_input_buffer()
for _ in range(12):
port.write(request)
port.flush()
deadline = time.monotonic() + 0.6
while time.monotonic() < deadline:
response = read_ascii_line(port)
if response is None:
break
if response == "READY 1":
continue
if response == expected:
return response
if response.startswith("ERR "):
raise ProtocolError(response)
raise TimeoutError(f"no matching response from {port_name}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("port")
parser.add_argument("--request-id", default="1")
arguments = parser.parse_args()
print(ping(arguments.port, arguments.request_id))
if __name__ == "__main__":
main()
Run it with the stable device link or the confirmed device node:
python -m py_compile serial_ping.py
python serial_ping.py /dev/ttyACM0 --request-id 42
Expected output:
OK 42
exclusive=True asks POSIX to reject another opener while this client owns the port. It does not replace coordination among threads inside one process. If the installed platform or driver does not support exclusive mode, document and test the ownership mechanism used by the application.
7. Test byte framing without hardware
pySerial includes a loop:// URL handler. It echoes bytes and can verify the host-side frame boundary in a local test:
import serial
frame = b"PING 42\n"
with serial.serial_for_url("loop://", timeout=1) as port:
written = port.write(frame)
reply = port.read_until(b"\n", size=64)
assert written == len(frame)
assert reply == frame
print("loop framing test passed")
This test proves that the Python environment can open pySerial’s loop handler and round-trip the exact bytes. It does not test the USB cable, Arduino firmware, reset behavior, electrical UART, or real device permissions.
8. Keep bytes, text, and integers distinct
pySerial’s write() accepts bytes-like data, and reads return bytes. Encode and decode only at the protocol boundary:
text = "hello"
frame = (text + "\n").encode("utf-8")
decoded = frame.rstrip(b"\n").decode("utf-8")
one_byte = bytes([65])
integer_value = one_byte[0]
chr(65) returns the text character "A"; it does not create a serial byte frame. bytes([value]) requires 0 <= value <= 255 and makes that intent explicit. For multi-byte integers, define byte order and width with int.to_bytes()/int.from_bytes() or struct on both ends.
Do not name variables str, bytes, serial, or time; those names hide built-in types or imported modules. Decode with errors="strict" for a control protocol so corruption becomes evidence instead of silently turning into replacement characters.
9. Understand resets, timeouts, and buffers
- Many Arduino USB boards reset when the host opens the serial port. A handshake with bounded retries is more robust than a fixed
sleep(2). - A positive pySerial
timeoutbounds reads; without one, a missing newline can block forever. write()is blocking unless a write timeout is configured.flush()waits until queued output is written; it does not clear input or output buffers.reset_input_buffer()deliberately discards received bytes. Use it only at a known session boundary, not while meaningful replies may be arriving.- Serial delivery is a byte stream. One
write()is not guaranteed to correspond to one device-side read; only the framing protocol defines messages. - Arduino receive buffers are finite. Avoid long blocking work in
loop(), bound frames, and add flow control or application-level backpressure before increasing traffic.
10. Troubleshoot by layer
| Symptom | Likely layer | Evidence to collect |
|---|---|---|
| No port appears | USB cable is power-only, device is unpowered, kernel driver did not bind, or USB power is insufficient | python -m serial.tools.list_ports --verbose, dmesg, another known data cable/port |
Permission denied |
Effective account lacks the device group or a service/container policy blocks it | ls -l on the node, id -nG, service/container configuration |
| Port opens, then disconnects | Board reset, unstable USB power/cable, or firmware reboot | Kernel log, board LED/reset behavior, handshake timestamps |
| Random characters | Baud/config mismatch or electrical level/noise problem | Both endpoint settings, physical wiring, logic analyser if direct UART |
| Timeout with no reply | Wrong port, missing newline, firmware not running, reset delay, or request rejected | Raw transmitted frame, Arduino sketch/version, bounded debug log |
| Replies shifted by one request | Stale input, missing request IDs, or more than one client | Port ownership, protocol trace, response IDs |
Device or resource busy |
Another process owns the port | lsof/fuser where authorized, IDE serial monitor, service list |
Close the Arduino IDE Serial Monitor before running Python; only one process should own this simple protocol endpoint.
11. Grow the protocol deliberately
For more than a few commands, version the protocol and write a table for every request, response, units, range, side effect, timeout, and retry rule. Add request IDs before concurrent operations. State-changing requests need deduplication or another idempotency mechanism.
For binary data, use a frame with a version, message type, explicit length, payload, and checksum/CRC. Validate the length before allocating or writing. A checksum detects corruption but does not authenticate commands; exposed or safety-critical links need a separate security design.
Measure throughput and worst-case latency before increasing baud rate. USB serial, the Arduino main loop, sensor work, and Raspberry Pi scheduling all contribute. If missed deadlines can damage equipment, move the safety loop to an appropriate controller instead of relying on a desktop-style serial process.
12. Complete 2019 export archive
The following is the complete 2019 WordPress export, including its metadata and short body. It is preserved as provenance, with trailing whitespace normalized. The names bytes and str in the original code shadow Python built-ins, and chr(int) creates text rather than a byte frame; use the maintained guide above.
---
id: 1933
title: 'RaspberryPi and Arduino Talk with Serial Port Using Python'
slug: 'raspberrypi-and-arduino-talk-with-serial-port'
date: '2019-05-09T13:37:55'
modified: '2019-05-09T13:57:59'
status: 'publish'
link: 'https://blog.lazying.art/en/html/computer_internet/hardware_system/raspberry-pi/1933/raspberrypi-and-arduino-talk-with-serial-port.html'
author: 'Lachlan Chen'
categories:
- 'Raspberry Pi'
---
char, str; bytes, unicode, string
bytes = b”string”
str = chr(int)
Arduino
Raspberry Pi
Primary references
- Arduino language reference: `Serial.begin()`
- Arduino language reference: `Serial.available()`
- Arduino language reference: `Serial.read()`
- Arduino language reference: `Serial.println()`
- pySerial API: timeouts, reads, writes, buffers, and context management
- pySerial tools: port listing, miniterm, and loop testing
- pySerial URL handlers: `loop://`
- Raspberry Pi configuration: serial hardware, console, UART mappings, and voltage
- Raspberry Pi Debug Probe: USB CDC serial device examples
