# python-periphery: one pure-Python API over GPIO, LED, PWM, SPI, I2C, MMIO and serial

> python-periphery is a pure Python library for peripheral I/O on Linux, giving one API over the sysfs GPIO interface, LED class devices, PWM, SPI, I2C, memory-mapped registers and serial ports, with no C extension to compile. It suits an embedded Linux board project, and each peripheral family still needs its own kernel driver and permissions.

**vsergeev/python-periphery** — A pure Python 2/3 library for peripheral I/O (GPIO, LED, PWM, SPI, I2C, MMIO, Serial) in Linux.

- Repository: https://github.com/vsergeev/python-periphery
- Stars: 592 · Forks: 146
- Language: Python
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/vsergeev-python-periphery

## One vocabulary across seven peripheral families

python-periphery is a pure Python library for peripheral I/O interface access in userspace Linux, and the range it covers is what makes it more than a GPIO binding.

The seven families are GPIO, LED, PWM, SPI, I2C, MMIO and Serial. The stated use is embedded Linux environments, including Raspberry Pi and BeagleBone platforms, for interfacing with external peripherals. The implementation is pure Python with no C extension, it works on both Python 2 and Python 3, and the licence is MIT.

The design point is that the same operations read, write, close and transfer show up on every family, and the differences are in the constructor arguments rather than in the method names. A GPIO line takes a chip device path and a line number, an LED takes a device name and an initial state, a PWM takes a chip and a channel, an SPI takes a device path with a mode and a speed, an I2C takes a controller path, an MMIO takes a physical address and a page size, and a serial port takes a device path and a baud rate.

That uniformity is the argument for the library over writing each interface yourself. A project that reads a sensor over I2C and drives a relay over GPIO ends up with one import and one set of idioms rather than two unrelated APIs.

The project also exists in other languages, and the readme points at lua-periphery and c-periphery for Lua and C, plus contributed java-periphery and dart_periphery implementations. So the API shape is treated as a portable design rather than a Python-only sketch.

## GPIO through the sysfs chip interface, with explicit line numbers

The GPIO example is short enough to read in full, and it shows the conventions the rest of the library follows.

```python
from periphery import GPIO

# Open GPIO /dev/gpiochip0 line 10 with input direction
gpio_in = GPIO("/dev/gpiochip0", 10, "in")
# Open GPIO /dev/gpiochip0 line 12 with output direction
gpio_out = GPIO("/dev/gpiochip0", 12, "out")

value = gpio_in.read()
gpio_out.write(not value)

gpio_in.close()
gpio_out.close()
```

Two details stand out. The first argument is a chip device path and the second is a line number within that chip, so the addressing is the modern sysfs chip model rather than the old global pin numbering. The direction is a string, "in" or "out", and the value read from an input is used directly as the argument to write on the output, with not value inverting it.

Every device is closed explicitly. That is the pattern across all seven families and it is worth internalising, because these handles map onto kernel resources, and a script that exits without closing them leaves the line in whatever state it was last given.

The LED interface is the one that is not a plain GPIO line, and it shows why the separate class exists. An LED is opened with a device name and an initial state:

```python
from periphery import LED

# Open LED "led0" with initial state off
led0 = LED("led0", False)
# Open LED "led1" with initial state on
led1 = LED("led1", True)

value = led0.read()
led1.write(value)

# Set custom brightness level
led1.write(led1.max_brightness / 2)
```

The LED class abstracts over the kernel's LED class devices, which expose brightness rather than a binary level, so the same write and read calls carry a brightness value. The max_brightness attribute is how you scale it, and the example halves it.

## PWM, SPI and I2C, and the transfer semantics that differ

The other three bus interfaces follow the same shape but differ in how a transaction is expressed, and those differences are where the real work is.

PWM is configured through attributes and then enabled:

```python
from periphery import PWM

# Open PWM chip 0, channel 10
pwm = PWM(0, 10)

# Set frequency to 1 kHz
pwm.frequency = 1e3
# Set duty cycle to 75%
pwm.duty_cycle = 0.75

pwm.enable()
```

SPI is constructed with a device path, a mode and a maximum speed in hertz, and the operation is a transfer rather than a read or a write, because SPI always shifts out and in at once:

```python
from periphery import SPI

# Open spidev1.0 with mode 0 and max speed 1MHz
spi = SPI("/dev/spidev1.0", 0, 1000000)

data_out = [0xaa, 0xbb, 0xcc, 0xdd]
data_in = spi.transfer(data_out)
```

I2C is the most structured of the three, because it is addressed by device and carries a list of messages where each message is either a write or a read:

```python
from periphery import I2C

# Open i2c-0 controller
i2c = I2C("/dev/i2c-0")

# Read byte at address 0x100 of EEPROM at 0x50
msgs = [I2C.Message([0x01, 0x00]), I2C.Message([0x00], read=True)]
i2c.transfer(0x50, msgs)
print("0x100: 0x{:02x}".format(msgs[1].data[0]))
```

The first message sets the EEPROM's internal address, the second reads the byte back, and the read flag on a message is what distinguishes it. The data comes back in the message itself rather than as a return value, which is the pattern to expect from any bus that has a multi-part transaction.

## MMIO reaches past the driver layer, and that is its own category

MMIO is the odd one out and deserves separate treatment, because it is the interface where a userspace library is doing something the others are not.

The other six families sit on top of a kernel driver that exposes a device file. MMIO maps physical memory into the process, so the library is reading and writing hardware registers directly.

```python
from periphery import MMIO

# Open am335x real-time clock subsystem page
rtc_mmio = MMIO(0x44E3E000, 0x1000)

# Read current time
rtc_secs = rtc_mmio.read32(0x00)
rtc_mins = rtc_mmio.read32(0x04)
rtc_hrs = rtc_mmio.read32(0x08)
```

The constructor takes a physical base address and a page size. The read32 and write32 methods are the interface, and the offsets within the page are the register offsets. In the example those offsets are the seconds, minutes and hours registers of the real-time clock subsystem on an am335x board, and the second half of the example opens a different page, the control module, to read the MAC address from two registers.

Two consequences follow from this being raw register access. The addresses in the example are specific to that chip, so the code is not portable between boards, and there is no kernel driver validating what you write, so a wrong offset can do something unhelpful. That is the trade for reaching registers that no driver exposes, and it is the reason the MMIO example is the one with the most platform-specific detail in the readme.

The Serial interface is the most conventional of the set, opening a device path and a baud rate with 8N1 and no flow control as defaults, writing bytes and reading up to a given count with a timeout.

## Packaging, typing and the supported platform matrix

The setup.py is short and tells you what the project considers supported.

The package is python-periphery at version 2.4.3, the packages list is the single periphery package, and the package data includes the .pyi stubs and the py.typed marker, so the library ships type information. The setup script falls back from setuptools to distutils when setuptools is missing, which is a leftover of the Python 2 support it still carries.

The classifiers list the operating system as POSIX Linux and the implementation as CPython, with both Python 2 and Python 3 listed as supported versions. The development status is 5 - Production/Stable, and the keywords cover the hardware and platform names including raspberrypi, rpi, odroid, beaglebone, along with gpio, spi, led, pwm, i2c, mmio, serial and uart.

The tree at the top level is the periphery package itself, the tests directory, the docs directory, setup.py and setup.cfg, plus a .flake8 configuration and a .readthedocs.yaml for the documentation build.

The documentation is hosted on Read the Docs and is built with Sphinx. To build it locally you run:

```bash
cd docs
make html
```

and the HTML output lands in docs/_build/html. Running make help shows the other output targets, LaTeX, man and text among them.

One limitation of the Python 2 support is worth naming, since it is a compatibility shim rather than a feature: a library that still supports Python 2 is a library that has to keep working around the differences between the two, and the setup.py fallback to distutils is one visible trace of that.

## The test suite needs the hardware it is testing

The testing section is short and carries an important warning that is easy to skim past.

The tests in the tests folder may be run under Python to test the correctness and functionality of the library. But some of them require interactive probing with an oscilloscope, or the installation of a physical loopback, or the existence of a particular device on a bus. The documentation says to see the usage of each test for details of the required setup.

So this is not a suite you can run to green on a laptop and call the port done. A GPIO test needs a board with a line you can drive and read, a PWM test needs something that can measure the output, an I2C test needs the right peripheral attached, and a serial test needs two ports connected to each other. The loopback mention is the clearest example: a serial test needs a physical wire between two ports, and there is no way to fake that in software.

That is a normal property of a hardware library and it is not a criticism of the project, but it does change how you evaluate a change. A patch to the I2C path cannot be validated by a CI run; it needs a board and a device on the bus, and the CI badge in the readme is telling you about the tests that can run without them.

The release cadence is worth noting alongside that. The tags are v2.4.1 from 2023-04-21, v2.4.2 from 2026-09-15 and v2.4.3 from 2026-10-06, with the last push to the repository on 2026-10-14. So the gap here is three and a half years, and then two releases and a push inside a month. Whatever was happening between those versions, it was not a steady release train, and anyone pinning a version should read CHANGELOG.md before assuming the two releases either side of it are equivalent.

## Where python-periphery is the wrong tool

The first limit is that it is Linux only, and userspace only. The library talks to kernel device interfaces and maps memory for MMIO, so there is no version of this for a microcontroller, for Windows, or for a board that exposes its pins some other way. The keyword list makes the intent clear with raspberrypi, odroid and beaglebone in it: this is a library for single-board computers running Linux.

The second is that it does not remove the kernel work. Every peripheral still needs its driver loaded, its device node created, and the right permissions for the user running your script. On a system where the SPI or I2C bus is not enabled in the device tree, none of the examples will work, and the library will not tell you that, because the failure is in the kernel rather than in Python. Anyone coming from a microcontroller library where the pin configuration is in your source code has to relearn where configuration lives on this side of the line.

The third is that MMIO is a different level of risk from the other six. Reading and writing physical registers has no driver layer to validate an access, and the example addresses in the readme are tied to one chip family. If you only need the peripherals the kernel already exposes, MMIO is the one interface in the set where the library is giving you a raw pointer rather than a device abstraction.

The fourth is that the Python 2 support is a liability rather than a feature now. It is stated as a benefit, and it does widen the range of boards you can deploy to, but it constrains the implementation and it means the classifier list, the setup.py fallback and the type stub approach all reflect a codebase that has to serve two generations of the language.

The fifth is the pure-Python trade. It means there is nothing to compile for an ARM board and no cross-compilation toolchain to set up, which is a real convenience for the target audience. It also means the hot paths run in the interpreter rather than in C, and for a library whose callers are mostly waiting on hardware rather than on Python, that is usually the right side of the trade.

## Conclusion

python-periphery is worth adopting for an embedded Linux project that touches more than one kind of peripheral, because the same read, write, close and transfer vocabulary applies to a GPIO line, an LED class device, a PWM channel, an SPI bus, an I2C controller and a serial port, and because the pure-Python implementation means there is nothing to cross-compile for an ARM board. It is the wrong choice for a single peripheral, where the kernel's own sysfs interface or a small C binding is less code. Before you adopt it, check the three things the project states about itself: that it is Linux-only and userspace, with the MMIO interface in particular tied to a specific platform register map such as the am335x pages; that Python 2 and Python 3 are both supported but the classifiers list only CPython, so an alternative implementation such as PyPy is not a tested target; and that the test suite needs real hardware, with some tests requiring interactive probing with an oscilloscope, a physical loopback, or a particular device on a bus, so you cannot validate a change on a laptop alone.

## FAQ

### How do I install python-periphery?

Run pip install python-periphery. The project also supports easy_install python-periphery, and installing from source with git clone https://github.com/vsergeev/python-periphery.git followed by cd python-periphery and python setup.py install. The package is MIT licensed and pure Python, so there is no compilation step.

### Which peripherals does python-periphery support?

GPIO, LED, PWM, SPI, I2C, MMIO and Serial, all in userspace Linux. GPIO uses the sysfs chip interface with a device path and a line number, LED uses the kernel LED class devices with a brightness value, and MMIO maps physical memory for direct register access on platforms such as the am335x.

### Can I run the python-periphery tests without hardware?

Only partly. Some tests require interactive probing with an oscilloscope, the installation of a physical loopback, or the existence of a particular device on a bus, and the documentation says to check each test's usage for the required setup. A CI run therefore covers the tests that do not need physical hardware.

## Sources

- [Issues](https://github.com/vsergeev/python-periphery/issues)
- [License: MIT](https://github.com/vsergeev/python-periphery/blob/master/LICENSE)
- [README](https://github.com/vsergeev/python-periphery/blob/master/README.md)
- [Releases](https://github.com/vsergeev/python-periphery/releases)
- [vsergeev/python-periphery on GitHub](https://github.com/vsergeev/python-periphery)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/vsergeev-python-periphery
