Skip to content

Commit 2551cd5

Browse files
authored
Merge pull request #1106 from makermelissa-piclaw/wrap-pyserial-uart-linux
Wrap PySerial inside busio.UART for Linux platforms
2 parents b9232f4 + 08dece2 commit 2551cd5

2 files changed

Lines changed: 240 additions & 10 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# SPDX-FileCopyrightText: 2026 Melissa LeBlanc-Williams for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
"""Generic Linux UART class wrapping PySerial"""
5+
6+
import os
7+
8+
import serial
9+
10+
11+
class UART:
12+
"""UART class for generic Linux using PySerial.
13+
14+
Wraps a ``serial.Serial`` instance so that CircuitPython UART code
15+
runs unchanged on Linux / SBC boards.
16+
"""
17+
18+
# Map of known port IDs to device paths, by platform.
19+
# If the port entry in uartPorts is a string it is used directly;
20+
# otherwise we try common symlink / device conventions.
21+
_PORT_SEARCH_PATTERNS = (
22+
"/dev/serial{}",
23+
"/dev/ttyS{}",
24+
"/dev/ttyAMA{}",
25+
)
26+
27+
# pylint: disable=too-many-arguments
28+
def __init__(
29+
self,
30+
port_id,
31+
baudrate=9600,
32+
bits=8,
33+
parity=None,
34+
stop=1,
35+
timeout=1,
36+
receiver_buffer_size=64, # pylint: disable=unused-argument
37+
):
38+
device = self._resolve_device(port_id)
39+
40+
# Translate CircuitPython parity values to pyserial constants.
41+
if parity is None:
42+
ser_parity = serial.PARITY_NONE
43+
elif parity == 0:
44+
ser_parity = serial.PARITY_EVEN
45+
elif parity == 1:
46+
ser_parity = serial.PARITY_ODD
47+
else:
48+
raise ValueError("Invalid parity: {}".format(parity))
49+
50+
stop_map = {1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO}
51+
ser_stop = stop_map.get(stop)
52+
if ser_stop is None:
53+
raise ValueError("Invalid stop bits: {}".format(stop))
54+
55+
byte_size_map = {
56+
5: serial.FIVEBITS,
57+
6: serial.SIXBITS,
58+
7: serial.SEVENBITS,
59+
8: serial.EIGHTBITS,
60+
}
61+
ser_bytesize = byte_size_map.get(bits)
62+
if ser_bytesize is None:
63+
raise ValueError("Invalid bits: {}".format(bits))
64+
65+
# PySerial timeout is in seconds (float); CircuitPython also uses
66+
# seconds as of CP 4.0+. Blinka's older MicroPython path passed
67+
# timeout in *milliseconds* to machine.UART; we accept seconds here.
68+
self._serial = serial.Serial(
69+
device,
70+
baudrate=baudrate,
71+
bytesize=ser_bytesize,
72+
parity=ser_parity,
73+
stopbits=ser_stop,
74+
timeout=timeout,
75+
write_timeout=timeout,
76+
)
77+
78+
# ----- helpers -----
79+
80+
@classmethod
81+
def _resolve_device(cls, port_id):
82+
"""Turn a port identifier into a ``/dev/`` path.
83+
84+
*port_id* may be:
85+
- A string that is already a device path (e.g. ``"/dev/serial0"``).
86+
- An integer that will be resolved via common naming conventions.
87+
"""
88+
if isinstance(port_id, str) and os.path.exists(port_id):
89+
return port_id
90+
91+
if isinstance(port_id, int):
92+
for pattern in cls._PORT_SEARCH_PATTERNS:
93+
path = pattern.format(port_id)
94+
if os.path.exists(path):
95+
return path
96+
97+
raise RuntimeError(
98+
"Could not find UART device for port {!r}. "
99+
"Make sure the serial port is enabled.".format(port_id)
100+
)
101+
102+
# ----- CircuitPython-compatible API -----
103+
104+
def deinit(self):
105+
"""Close the serial port."""
106+
if self._serial is not None:
107+
self._serial.close()
108+
self._serial = None
109+
110+
def read(self, nbytes=None):
111+
"""Read up to *nbytes* bytes. Returns ``None`` when no data is
112+
available (matching CircuitPython behaviour, not pyserial's ``b""``).
113+
"""
114+
if nbytes is None:
115+
# Read whatever is available; if nothing, wait up to timeout.
116+
data = self._serial.read(self._serial.in_waiting or 1)
117+
else:
118+
data = self._serial.read(nbytes)
119+
return data if data else None
120+
121+
def readinto(self, buf, nbytes=None):
122+
"""Read bytes into *buf*. Returns number of bytes read or ``None``."""
123+
if nbytes is None:
124+
nbytes = len(buf)
125+
data = self._serial.read(nbytes)
126+
if not data:
127+
return None
128+
n = len(data)
129+
buf[:n] = data
130+
return n
131+
132+
def readline(self):
133+
"""Read a line (up to ``\\n``). Returns ``None`` on timeout with no data."""
134+
data = self._serial.readline()
135+
return data if data else None
136+
137+
def write(self, buf):
138+
"""Write bytes from *buf*. Returns the number of bytes written."""
139+
return self._serial.write(buf)
140+
141+
@property
142+
def baudrate(self):
143+
"""The current baudrate."""
144+
return self._serial.baudrate
145+
146+
@baudrate.setter
147+
def baudrate(self, value):
148+
self._serial.baudrate = value
149+
150+
@property
151+
def in_waiting(self):
152+
"""The number of bytes in the input buffer, available to be read."""
153+
return self._serial.in_waiting
154+
155+
@property
156+
def timeout(self):
157+
"""Read timeout in seconds (float)."""
158+
return self._serial.timeout
159+
160+
@timeout.setter
161+
def timeout(self, value):
162+
self._serial.timeout = value
163+
self._serial.write_timeout = value
164+
165+
def reset_input_buffer(self):
166+
"""Discard any unread data in the input buffer."""
167+
self._serial.reset_input_buffer()

src/busio.py

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ def write_readinto(
522522
class UART(Lockable):
523523
"""
524524
Busio UART Class for CircuitPython Compatibility. Used
525-
for MicroPython and a few other non-Linux boards.
525+
for MicroPython, Linux (via PySerial), and other boards.
526526
"""
527527

528528
class Parity(Enum):
@@ -541,15 +541,15 @@ def __init__(
541541
bits=8,
542542
parity=None,
543543
stop=1,
544-
timeout=1000,
544+
timeout=1,
545545
receiver_buffer_size=64,
546546
flow=None,
547547
):
548548
if detector.board.any_embedded_linux:
549-
raise RuntimeError(
550-
"busio.UART not supported on this platform. Please use pyserial instead."
549+
from adafruit_blinka.microcontroller.generic_linux.uart import (
550+
UART as _UART,
551551
)
552-
if detector.board.binho_nova:
552+
elif detector.board.binho_nova:
553553
from adafruit_blinka.microcontroller.nova.uart import UART as _UART
554554
elif detector.board.greatfet_one:
555555
from adafruit_blinka.microcontroller.nxp_lpc4330.uart import UART as _UART
@@ -560,7 +560,8 @@ def __init__(
560560

561561
from microcontroller.pin import uartPorts
562562

563-
self.baudrate = baudrate
563+
self._baudrate = baudrate
564+
self._timeout = timeout
564565

565566
if flow is not None: # default 0
566567
raise NotImplementedError(
@@ -577,7 +578,27 @@ def __init__(
577578
else:
578579
raise ValueError("Invalid parity")
579580

580-
if detector.chip.id in (ap_chip.RP2040, ap_chip.RP2350):
581+
if detector.board.any_embedded_linux:
582+
# check tx and rx have hardware support
583+
for portId, portTx, portRx in uartPorts:
584+
if portTx == tx and portRx == rx:
585+
self._uart = _UART(
586+
portId,
587+
baudrate=baudrate,
588+
bits=bits,
589+
parity=parity,
590+
stop=stop,
591+
timeout=timeout,
592+
receiver_buffer_size=receiver_buffer_size,
593+
)
594+
break
595+
else:
596+
raise ValueError(
597+
"No Hardware UART on (tx,rx)={}\nValid UART ports: {}".format(
598+
(tx, rx), uartPorts
599+
)
600+
)
601+
elif detector.chip.id in (ap_chip.RP2040, ap_chip.RP2350):
581602
self._uart = _UART(
582603
tx,
583604
rx,
@@ -609,9 +630,18 @@ def __init__(
609630

610631
def deinit(self):
611632
"""Deinitialization"""
612-
if detector.board.binho_nova:
613-
self._uart.deinit()
614-
self._uart = None
633+
if self._uart is not None:
634+
if hasattr(self._uart, "deinit"):
635+
self._uart.deinit()
636+
self._uart = None
637+
638+
def __enter__(self):
639+
"""No-op used by Context Managers."""
640+
return self
641+
642+
def __exit__(self, *args):
643+
"""Automatically deinitializes the hardware when exiting a context."""
644+
self.deinit()
615645

616646
def read(self, nbytes=None):
617647
"""Read from the UART"""
@@ -629,6 +659,17 @@ def write(self, buf):
629659
"""Write to the UART from a buffer"""
630660
return self._uart.write(buf)
631661

662+
@property
663+
def baudrate(self):
664+
"""The current baudrate."""
665+
return self._baudrate
666+
667+
@baudrate.setter
668+
def baudrate(self, value):
669+
self._baudrate = value
670+
if hasattr(self, "_uart") and hasattr(self._uart, "baudrate"):
671+
self._uart.baudrate = value
672+
632673
@property
633674
def in_waiting(self):
634675
"""The number of bytes in the input buffer, available to be read"""
@@ -639,3 +680,25 @@ def in_waiting(self):
639680
if hasattr(self._uart, "any"):
640681
return self._uart.any()
641682
raise NotImplementedError("in_waiting not supported on this platform")
683+
684+
@property
685+
def timeout(self):
686+
"""The timeout in seconds (float)."""
687+
if hasattr(self._uart, "timeout"):
688+
return self._uart.timeout
689+
return self._timeout
690+
691+
@timeout.setter
692+
def timeout(self, value):
693+
self._timeout = value
694+
if hasattr(self._uart, "timeout"):
695+
self._uart.timeout = value
696+
697+
def reset_input_buffer(self):
698+
"""Discard any unread data in the input buffer."""
699+
if hasattr(self._uart, "reset_input_buffer"):
700+
self._uart.reset_input_buffer()
701+
else:
702+
# Fallback: read and discard all available bytes
703+
while self.in_waiting:
704+
self._uart.read(self.in_waiting)

0 commit comments

Comments
 (0)