Direct-GPIO “multi-channel” boards look simple on paper: toggle a pin, drive a channel. In practice, many 8-channel boards behave inconsistently when a control process exits—channels can remain latched ON, appear “sticky,” or revert unpredictably due to input polarity and floating states when GPIO lines are released.
This white paper documents a field-proven method to control a Waveshare 8-channel GPIO-driven board from a Raspberry Pi 4 running Debian Trixie (headless). The approach uses Debian’s gpiod tools and a simple Python script that maintains a state-holding gpioset process, ensuring all channels remain driven and deterministic between commands.
Included:
In automation and integration work, “GPIO works” is not sufficient. A bench test that toggles outputs successfully can still fail in the field if:
A deterministic method must guarantee:
8channelRecommended to capture exact OS identity in your build log:
cat /etc/os-release uname -a
This implementation was validated using Debian’s gpiod toolchain (v2 series).
Example package baseline (Debian Trixie):
apt-cache policy libgpiod3 gpiod libgpiod-dev python3-libgpiod | sed -n '1,160p'
Observed in-field:
2.2.1-2+deb13u12.2.1-2+deb13u1gpioset --mode=time). Debian Trixie uses gpiod v2 syntax, which differs.
CH1 = GPIO5 CH2 = GPIO6 CH3 = GPIO13 CH4 = GPIO16 CH5 = GPIO19 CH6 = GPIO20 CH7 = GPIO21 CH8 = GPIO26
This mapping was verified by manual toggle testing on the live system.
SPI can claim GPIO7/GPIO8 as CS lines depending on overlays and kernel config. If a board uses those pins, SPI can cause “it works sometimes” behavior.
Check for pin consumers:
gpioinfo -c gpiochip0 | grep 'consumer='
Many 8-channel boards are active-low (LOW = ON), so “OFF” often means driving HIGH.
Add to /boot/firmware/config.txt:
# Waveshare 8CH default OFF (active-low): drive outputs high at boot gpio=5,6,13,16,19,20,21,26=op,dh
Then reboot:
sudo reboot
On Debian, /dev/gpiochip0 is typically owned by root:gpio with 0660 permissions. Ensure your user is in the gpio group.
Verify device node:
ls -l /dev/gpiochip0
Verify your groups:
id | tr ' ' '\n' | grep -E '(gpio|i2c|spi)' || true
If needed, add user to gpio/i2c/spi:
sudo usermod -aG gpio,i2c,spi $USER sudo reboot
Recommended udev rule to enforce ownership/mode:
sudo tee /etc/udev/rules.d/60-gpiochip.rules >/dev/null <<'EOF' SUBSYSTEM=="gpio", KERNEL=="gpiochip*", GROUP="gpio", MODE="0660" EOF sudo udevadm control --reload-rules sudo udevadm trigger
sudo apt update sudo apt install -y \ python3 python3-venv python3-pip \ gpiod \ i2c-tools \ spi-tools
libgpiod3 (not libgpiod2).
A venv keeps Python tooling clean for development while the actual hardware control relies on stable system tools.
mkdir -p ~/venvs python3 -m venv ~/venvs/PI4venv source ~/venvs/PI4venv/bin/activate python -m pip install --upgrade pip wheel setuptools
A common pattern is:
gpioset warns that the output state is only maintained while the process is running; after exit it is not guaranteed.
This is especially visible on active-low boards where a floating/weakly-low input can look like ON.
The proven approach:
gpioset running in the background to prevent floating~/waveshare_8ch.py(The script is intentionally tool-based for stability across OS upgrades and Python binding variations.)
#!/usr/bin/env python3
"""
Waveshare 8-channel GPIO board controller (Debian Trixie headless).
Uses the gpioset CLI (gpiod tools) for maximum reliability.
Confirmed mapping (CH -> BCM GPIO):
CH1 = GPIO5
CH2 = GPIO6
CH3 = GPIO13
CH4 = GPIO16
CH5 = GPIO19
CH6 = GPIO20
CH7 = GPIO21
CH8 = GPIO26
Commands:
waveshare_8ch.py status
waveshare_8ch.py all_off
waveshare_8ch.py all_on
waveshare_8ch.py set <ch> <on|off>
waveshare_8ch.py pulse <ch> <ms>
waveshare_8ch.py sweep [ms]
waveshare_8ch.py stop
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
CH_GPIO = {1: 5, 2: 6, 3: 13, 4: 16, 5: 19, 6: 20, 7: 21, 8: 26}
# Many 8-channel relay/LED driver boards are active-low: LOW = ON.
ACTIVE_LOW = True
STATE_DIR = Path.home() / ".cache" / "waveshare_8ch"
STATE_FILE = STATE_DIR / "state.json"
PID_FILE = STATE_DIR / "holder.pid"
def _ensure_dirs() -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
def _read_state() -> dict[str, object]:
if STATE_FILE.exists():
try:
data = json.loads(STATE_FILE.read_text())
if "channels" in data:
return data
except Exception:
pass
return {
"active_low": ACTIVE_LOW,
"channels": {str(ch): False for ch in sorted(CH_GPIO.keys())},
}
def _write_state(data: dict[str, object]) -> None:
_ensure_dirs()
tmp = STATE_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True))
tmp.replace(STATE_FILE)
def _pid_running(pid: int) -> bool:
if pid <= 1:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _read_pid() -> int | None:
try:
if PID_FILE.exists():
return int(PID_FILE.read_text().strip())
except Exception:
return None
return None
def _write_pid(pid: int) -> None:
_ensure_dirs()
PID_FILE.write_text(str(pid))
def _clear_pid() -> None:
try:
PID_FILE.unlink(missing_ok=True)
except Exception:
pass
def _stop_holder() -> None:
pid = _read_pid()
if pid is None:
return
if _pid_running(pid):
try:
os.kill(pid, signal.SIGTERM)
except Exception:
pass
for _ in range(20):
if not _pid_running(pid):
break
time.sleep(0.05)
if _pid_running(pid):
try:
os.kill(pid, signal.SIGKILL)
except Exception:
pass
_clear_pid()
def _build_gpioset_cmd(channels: dict[str, bool], active_low: bool) -> list[str]:
cmd = ["gpioset", "-c", "gpiochip0"]
if active_low:
cmd.append("-l")
# Assign every line explicitly so nothing floats
for ch in sorted(CH_GPIO.keys()):
gpio = CH_GPIO[ch]
on = bool(channels.get(str(ch), False))
value = "1" if on else "0"
cmd.append(f"GPIO{gpio}={value}")
return cmd
def _start_holder(state: dict[str, object]) -> None:
channels: dict[str, bool] = state["channels"] # type: ignore[assignment]
active_low = bool(state.get("active_low", ACTIVE_LOW))
_stop_holder()
cmd = _build_gpioset_cmd(channels, active_low)
p = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
_write_pid(p.pid)
def status() -> int:
state = _read_state()
pid = _read_pid()
running = (pid is not None) and _pid_running(pid)
print("Waveshare 8CH status")
print(f" active_low: {state.get('active_low', ACTIVE_LOW)}")
print(f" holder_pid: {pid if pid is not None else 'none'} running={running}")
ch = state.get("channels", {})
for i in range(1, 9):
print(f" CH{i}: {'ON' if ch.get(str(i), False) else 'OFF'} (GPIO{CH_GPIO[i]})")
return 0
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser()
sp = ap.add_subparsers(dest="cmd", required=True)
sp.add_parser("status")
sp.add_parser("all_off")
sp.add_parser("all_on")
p_set = sp.add_parser("set")
p_set.add_argument("ch", type=int, choices=range(1, 9))
p_set.add_argument("state", choices=["on", "off"])
p_pulse = sp.add_parser("pulse")
p_pulse.add_argument("ch", type=int, choices=range(1, 9))
p_pulse.add_argument("ms", type=int)
p_sweep = sp.add_parser("sweep")
p_sweep.add_argument("ms", nargs="?", type=int, default=250)
sp.add_parser("stop")
args = ap.parse_args(argv[1:])
state = _read_state()
state["active_low"] = bool(state.get("active_low", ACTIVE_LOW))
channels: dict[str, bool] = state["channels"] # type: ignore[assignment]
if args.cmd == "status":
return status()
if args.cmd == "stop":
_stop_holder()
return 0
if args.cmd == "all_off":
for i in range(1, 9):
channels[str(i)] = False
_write_state(state)
_start_holder(state)
return 0
if args.cmd == "all_on":
for i in range(1, 9):
channels[str(i)] = True
_write_state(state)
_start_holder(state)
return 0
if args.cmd == "set":
channels[str(args.ch)] = (args.state == "on")
_write_state(state)
_start_holder(state)
return 0
if args.cmd == "pulse":
orig = dict(channels)
channels[str(args.ch)] = True
_write_state(state)
_start_holder(state)
time.sleep(args.ms / 1000.0)
state["channels"] = orig
_write_state(state)
_start_holder(state)
return 0
if args.cmd == "sweep":
ms = int(args.ms)
base = {str(i): False for i in range(1, 9)}
state["channels"] = base
_write_state(state)
_start_holder(state)
for i in range(1, 9):
base[str(i)] = True
state["channels"] = base
_write_state(state)
_start_holder(state)
time.sleep(ms / 1000.0)
base[str(i)] = False
state["channels"] = base
_write_state(state)
_start_holder(state)
time.sleep(0.10)
return 0
return 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
chmod +x ~/waveshare_8ch.py source ~/venvs/PI4venv/bin/activate python3 ~/waveshare_8ch.py all_off python3 ~/waveshare_8ch.py all_on python3 ~/waveshare_8ch.py set 3 off python3 ~/waveshare_8ch.py pulse 8 500 python3 ~/waveshare_8ch.py sweep 250 python3 ~/waveshare_8ch.py status python3 ~/waveshare_8ch.py stop
A) Confirm lines exist and are named:
gpioinfo -c gpiochip0 | sed -n '1,90p'
B) Confirm pins are not claimed by other interfaces:
gpioinfo -c gpiochip0 | grep 'consumer='
C) Confirm permissions:
ls -l /dev/gpiochip0 id | tr ' ' '\n' | grep gpio || true
D) Confirm holder is running after a command:
python3 ~/waveshare_8ch.py status ps -ef | grep -E 'gpioset|waveshare_8ch' | grep -v grep || true
This method is ideal for:
For production systems, apply appropriate engineering controls:
© Validus Group Inc. All Rights Reserved.
Authored by Fred Fisher — President & Principal Engineer, Validus Group Inc.