Reliable Raspberry Pi–Arduino Serial Communication with Python

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.

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 timeout bounds 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

Leave a Reply