Technical White Paper
Validus Group Inc. | Technical Library
Raspberry Pi 4 • Waveshare 8-Channel GPIO Board • Debian Trixie (Headless) • gpiod Tools
Title: Deterministic Output Control for 8-Channel GPIO Boards
Document Date: 2025-12-28
Audience: System Builders • Integrators • Controls Engineers

Raspberry Pi 4 + Waveshare 8-Channel GPIO Board: Deterministic Output Control on Debian Trixie (Headless)

Abstract

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:

  • Confirmed channel-to-GPIO mapping (CH1..CH8)
  • Pi configuration notes (I2C/SPI/GPIO)
  • Package baseline (Debian Trixie / gpiod v2)
  • Permissions / udev rules for /dev/gpiochip*
  • Python venv setup for clean development
  • Working control script (gpioset holder pattern)
  • Validation + troubleshooting steps

1. Background

Why deterministic GPIO control matters in real automation

In automation and integration work, “GPIO works” is not sufficient. A bench test that toggles outputs successfully can still fail in the field if:

  • outputs float when software exits
  • the board is active-low and software assumes active-high
  • system interfaces (SPI/UART overlays) silently claim pins
  • reboot defaults are unsafe (outputs start up ON)

A deterministic method must guarantee:

  • known-safe behavior at boot
  • known-safe behavior at script exit
  • repeatable channel mapping
  • clear verification steps (for serviceability)

2. Hardware / Software Overview

Platform + OS baseline

  • Platform: Raspberry Pi 4 (headless)
  • Hostname: 8channel
  • OS: Debian Trixie (Debian 13), arm64 (headless)

Recommended to capture exact OS identity in your build log:

cat /etc/os-release
uname -a

GPIO stack baseline (Debian Trixie)

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:

  • libgpiod3: 2.2.1-2+deb13u1
  • gpiod: 2.2.1-2+deb13u1
Practical note: Older online examples often reference gpiod v1 syntax (e.g., gpioset --mode=time). Debian Trixie uses gpiod v2 syntax, which differs.

3. Channel Mapping (Confirmed)

Manual toggle verified map (CH → BCM GPIO)

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.

4. Pi Configuration Notes

Interfaces: I2C, SPI, GPIO

  • SSH already configured (headless)
  • I2C enabled
  • SPI enabled initially (later evaluated due to pin claims)

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='

Recommended boot defaults: force outputs OFF at boot

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

5. Permissions & Device Access

Ensure your user can access /dev/gpiochip0

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

6. Package Install (CLI Baseline)

Tools for verification and development

sudo apt update
sudo apt install -y \
  python3 python3-venv python3-pip \
  gpiod \
  i2c-tools \
  spi-tools
Note: On Debian Trixie, the runtime library package is libgpiod3 (not libgpiod2).

7. Python Development Environment

Virtual environment (venv)

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

8. The Root Cause of “Some channels won’t turn off”

Why a simple pulse test can mislead you

A common pattern is:

  • Set a line HIGH (looks like ON)
  • Process exits
  • Line is released
  • Board input floats (or is weakly pulled)
  • Some channels remain “stuck” ON
Even 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.

9. The Working Solution: “gpioset holder process”

Deterministic state control (hold lines driven)

The proven approach:

  • Always drive all 8 channels explicitly
  • Keep gpioset running in the background to prevent floating
  • Persist desired state to disk
  • Restart holder process on state changes
Result: All 8 channels behave consistently across on/off transitions, pulses, and repeated commands in a headless environment.

10. Working Control Script (Validated)

Save as: ~/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))

Install and run

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

11. Validation Checklist

Quick checks that catch 90% of real-world issues

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

12. Integration Notes (Why system builders should care)

Where this pattern fits

This method is ideal for:

  • prototype I/O nodes for machine subsystems
  • test fixtures / commissioning tools
  • panel indicators, permissives, auxiliary relays
  • low-cost edge control where “deterministic OFF” matters

For production systems, apply appropriate engineering controls:

  • fused power domains and interlocks
  • e-stop and safety logic handled by certified safety components
  • clear electrical isolation between Pi logic and load voltage
If you’re building automation equipment and need integration-ready subassemblies, controls support, or custom machined parts (mounting plates, brackets, fixtures, protective housings, enclosure components), Validus Group can help you deliver a more reliable system—faster.

13. Summary

Practical takeaways

  • Many GPIO boards are active-low and behave poorly when GPIO lines are released.
  • Debian Trixie ships gpiod v2; syntax differs from older online examples.
  • The gpioset “holder process” pattern eliminates floating states and makes behavior deterministic.
  • A confirmed channel-to-GPIO map and boot-default OFF settings reduce integration risk.

© Validus Group Inc. All Rights Reserved.
Authored by Fred Fisher — President & Principal Engineer, Validus Group Inc.

This document is the intellectual property of Validus Group Inc. and is provided for informational and professional reference only. No portion of this publication may be reproduced, distributed, or transmitted in any form or by any means without prior written consent. Permission is granted to share internally for engineering, maintenance, or training purposes provided content remains unaltered and full attribution is retained.