If you are building permanent embedded hardware, the command line for Raspberry Pi is not just an alternative to the desktop—it is the correct way to operate. Running a headless Raspberry Pi 5 via SSH eliminates the Wayland display server overhead, freeing up roughly 400MB of RAM and dropping idle CPU temperatures by 3°C to 5°C. More importantly, it forces you to interact with the hardware stack directly, which is critical when debugging GPIO state changes and timing issues.
In this guide, we will build a headless 12V solenoid valve controller using the modern lgpio library. We will cover the exact hardware needed, the pin mapping, a production-ready Python script with error handling, and how to debug the most common CLI GPIO failures.
Why the Modern CLI GPIO Stack Matters
Historically, makers relied on the RPi.GPIO Python library or the sysfs interface to toggle pins. Both are effectively dead for modern hardware. The Raspberry Pi 5 uses the RP1 southbridge chip, which fundamentally changed how GPIO is addressed at the kernel level. If you try to run legacy RPi.GPIO on a Pi 5 running Raspberry Pi OS Bookworm or Trixie, it will fail to map the memory addresses.
When working via the command line for Raspberry Pi, you must use the Linux character device (chardev) interface. Below is a comparison of the GPIO tools you will encounter in the wild.
| Interface / Library | Kernel / Hardware Support | Overhead & Daemon | CLI / Bash Friendly? | Verdict for 2026 |
|---|---|---|---|---|
sysfs (/sys/class/gpio) |
Deprecated in kernel 4.8+. Fails on RP1. | None, but slow file I/O. | Yes, via echo |
Do not use. Obsolete. |
| RPi.GPIO | Pi 1-4 only. Fails on Pi 5 (RP1 chip). | None. Python only. | No | Unmaintained. Avoid. |
| pigpio | Pi 1-4. Requires patching for Pi 5. | Requires pigpiod daemon (10-15MB RAM). |
Yes, via pigs CLI |
Good for PWM, but heavy. |
| lgpio / libgpiod | Native chardev. Full Pi 5 / RP1 support. | Zero daemon overhead. Direct kernel calls. | Yes, via gpioset / gpioget |
The 2026 Standard. |
For this project, we are using lgpio, the official Python binding for the libgpiod C library. It provides direct, low-latency access to the GPIO chardev without requiring a background daemon.
Hardware Build: 12V Solenoid Controller
We are switching a 12V inductive load (a solenoid water valve) using a logic-level MOSFET. The Raspberry Pi's 3.3V GPIO pins cannot source the current required to drive an inductive load, nor can they tolerate the 12V flyback voltage.
Parts List
- Compute: Raspberry Pi 5 (8GB RAM, BCM2712) - ~$80
- Switch: IRLZ44N N-Channel Logic-Level MOSFET (Fully enhanced at 3.3V Vgs)
- Protection: 1N4007 Rectifier Diode (Flyback protection)
- Resistors: 1kΩ Gate series resistor, 10kΩ Gate-to-Source pull-down resistor
- Power: 12V 2A Switching Power Supply (Mean Well LRS-25-12 or equivalent)
- Load: 12V DC Solenoid Valve
Pin Mapping Table
| Pi 5 Physical Pin | BCM GPIO | Function | Wiring Destination |
|---|---|---|---|
| Pin 12 | GPIO 18 | Control Signal (Output) | 1kΩ Resistor → IRLZ44N Gate |
| Pin 9 | GND | Logic Ground | IRLZ44N Source & 10kΩ Pull-down |
| N/A (Ext PSU) | N/A | 12V Load Power | 12V PSU (+) → Solenoid (+) |
| N/A (Ext PSU) | N/A | 12V Load Return | Solenoid (-) → IRLZ44N Drain |
Note: Ensure the 1N4007 flyback diode is placed in reverse bias across the solenoid terminals (cathode stripe facing the 12V positive). This safely dissipates the inductive voltage spike when the MOSFET turns off.
The Code: Headless Python Control via lgpio
This script targets the Raspberry Pi 5 (8GB) running Raspberry Pi OS Bookworm (64-bit). It uses lgpio to claim the pin, pulse the solenoid, and handle interrupts gracefully.
import lgpio
import time
import sys
import signal
# --- Pin Definitions (BCM Numbering) ---
GPIO_CHIP = 0 # gpiochip0 is the standard RP1 chardev on Pi 5
SOLENOID_PIN = 18 # Physical Pin 12
PULSE_ON_SEC = 2.0
PULSE_OFF_SEC = 5.0
# Graceful shutdown handler for systemd / CLI Ctrl+C
def handle_exit(sig, frame):
print('\n[INFO] Caught exit signal. Releasing GPIO and shutting down.')
sys.exit(0)
signal.signal(signal.SIGINT, handle_exit)
signal.signal(signal.SIGTERM, handle_exit)
def main():
# Open the GPIO chip
try:
chip = lgpio.gpiochip_open(GPIO_CHIP)
except lgpio.error as e:
print(f'[FATAL] Cannot open gpiochip{GPIO_CHIP}: {e}')
print('Verify you are running on a Pi and /dev/gpiochip0 exists.')
sys.exit(1)
# Claim the pin as an output, defaulting to LOW (0)
try:
lgpio.gpio_claim_output(chip, SOLENOID_PIN, 0)
except lgpio.error as e:
print(f'[FATAL] Cannot claim GPIO {SOLENOID_PIN}: {e}')
lgpio.gpiochip_close(chip)
sys.exit(1)
print(f'[OK] Controlling Solenoid on GPIO {SOLENOID_PIN}. Press Ctrl+C to stop.')
try:
while True:
# Energize solenoid
lgpio.gpio_write(chip, SOLENOID_PIN, 1)
print(f'[STATE] Solenoid OPEN (GPIO {SOLENOID_PIN} HIGH)')
time.sleep(PULSE_ON_SEC)
# De-energize solenoid
lgpio.gpio_write(chip, SOLENOID_PIN, 0)
print(f'[STATE] Solenoid CLOSED (GPIO {SOLENOID_PIN} LOW)')
time.sleep(PULSE_OFF_SEC)
finally:
# CRITICAL: Always free the pin and close the chip to prevent lockouts
lgpio.gpio_free(chip, SOLENOID_PIN)
lgpio.gpiochip_close(chip)
print('[OK] GPIO released. Hardware safe.')
if __name__ == '__main__':
main()
Save this as solenoid_ctrl.py. Install the library via the command line with sudo apt install python3-lgpio, then execute it using python3 solenoid_ctrl.py. The finally block is mandatory; it ensures the kernel releases the chardev handle even if the script crashes or loses SSH connection.
Debugging: Fixing the 'GPIO Already Claimed' Error
When working heavily in the command line for Raspberry Pi, you will inevitably kill a script improperly (e.g., closing the SSH terminal window without sending SIGINT). When you try to run the script again, you will hit this exact error:
lgpio.error: 'GPIO 18 is already claimed'
This happens because the Linux kernel tracks GPIO line requests. If the process that claimed the line dies without closing the file descriptor, the kernel holds the claim to prevent hardware conflicts.
Ranked Causes
- Zombie Python Process: The previous instance of your script is still running in the background, holding the file descriptor open.
- Boot Overlay Conflict: A device tree overlay in
/boot/firmware/config.txt(likedtoverlay=pwm) has claimed GPIO 18 at the kernel level before your script started. - Hardware Fault / Short: Rare, but a physical short pulling the pin low while configured as an output can sometimes cause the RP1 chip to flag a line state error, though this usually manifests as a bus error rather than a claim error.
The First Three Things to Check
When this error halts your build, execute these three checks in order:
- Hunt for Zombie Processes: Run
ps aux | grep python3. If you see your script listed with a different PID, kill it forcefully withsudo kill -9 <PID>. This forces the kernel to reclaim the file descriptor. - Check Chardev Holders: If no Python process is visible, find exactly what holds the GPIO chip open by running
sudo lsof | grep gpiochip0. This will reveal if a background service (like a rogue MQTT daemon or a leftoverpigpiodinstance) is hogging the interface. - Verify Device Tree Overlays: Open your boot config with
sudo nano /boot/firmware/config.txt. Ensure you do not havedtoverlay=pwm,pin=18ordtparam=spi=on(which can conflict with specific pin banks). If you change this file, you mustsudo rebootto clear the kernel state.
sudo rmmod raspberrypi-gpio && sudo modprobe raspberrypi-gpio will reset the kernel driver without requiring a full reboot. (Note: Module names may vary slightly between Bookworm and Trixie kernels; a full reboot is the safest fallback).
Extending and Simplifying Your CLI Build
Once your hardware is validated via the Python script, you have two paths for production deployment via the command line.
Simplify: Drop Python Entirely
If you only need to toggle the pin based on a cron schedule or a bash script, you do not need Python. The libgpiod package includes native CLI utilities. Install them via sudo apt install gpiod. You can now turn the solenoid on and off directly from the bash prompt:
# Turn Solenoid ON
gpioset gpiochip0 18=1
# Turn Solenoid OFF
gpioset gpiochip0 18=0
This is vastly superior for simple bash automation, as it eliminates the Python interpreter startup latency (which takes ~150ms on a Pi 5) and reduces memory footprint to near zero.
Extend: Systemd and MQTT Integration
To make the Python script survive reboots and network drops, wrap it in a systemd service. Create a file at /etc/systemd/system/solenoid.service:
[Unit]
Description=Headless Solenoid Controller
After=network.target
[Service]
ExecStart=/usr/bin/python3 /home/pi/solenoid_ctrl.py
Restart=always
User=root
[Install]
WantedBy=multi-user.target
Enable it with sudo systemctl enable --now solenoid.service. From here, you can extend the Python script to listen to an MQTT broker, allowing you to trigger the lgpio.gpio_write() function remotely from Home Assistant or Node-RED, completing the transition from a bench prototype to a robust, headless IoT node.
For deeper reading on the Linux chardev GPIO architecture that powers this stack, refer to the official Linux Kernel GPIO documentation and the Raspberry Pi OS GPIO header guide.






