Use the GPIOs Like an Engineer
without giving up your server role
You are not wasting a Pi4 by running services on it. This track turns your existing host at 10.10.10.250 into an embedded control node that still runs DNS, SQL, NGINX, and RADIUS. The advanced path includes Rust on real GPIO/I2C stacks and PS4 controller-driven actuation loops with safety gates.
Control loops stay local on the Pi for reliability. Network services expose state and history, but actuator safety decisions must not depend on internet reachability.
Create Embedded Workspace, Files, and First Hardware Check
Objective: start from scratch with a clean embedded workspace and verify your first GPIO script can be created, run, and validated.
Learning Focus: This lesson removes setup assumptions by establishing exact directory structure, file paths, execution commands, and basic validation steps before any advanced control logic.
Embedded progress depends on disciplined setup. If file layout and run paths are inconsistent, later failures are difficult to isolate because wiring issues, package issues, and script issues all look similar. This lesson gives you a deterministic baseline so each subsequent lesson adds one controlled variable.
Create directories for code and services
Use a stable path so scripts and systemd units always reference the same location.
sudo mkdir -p /opt/embedded/{bin,logs,data} sudo chown -R $USER:$USER /opt/embedded cd /opt/embedded pwd
Create your first script file
This file proves your write path and Python execution path before adding sensors or service wrappers.
from gpiozero import LED
from time import sleep
led = LED(17)
for _ in range(3):
led.on()
sleep(0.2)
led.off()
sleep(0.2)
print("lesson0_gpio_ok")Run and validate
Use these checks together so you verify syntax, runtime, and basic hardware behavior in one pass.
python3 -m py_compile /opt/embedded/bin/hello_gpio.py python3 /opt/embedded/bin/hello_gpio.py ls -la /opt/embedded/bin
Create your first test-style guard
This gives a lightweight test pattern you can repeat for utility modules as the codebase grows.
def scale_temp(raw: int) -> float:
return raw / 100.0
if __name__ == "__main__":
assert scale_temp(2534) == 25.34
print("lesson0_test_ok")python3 /opt/embedded/bin/math_guard.pyCheckpoint: both scripts run, syntax checks pass, and you see lesson0_gpio_ok plus lesson0_test_ok outputs.
GPIO Electrical Safety and Pin Strategy
Objective: protect the Pi and your peripherals before any control loop is deployed.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Teaching Lens
This lesson teaches the non-negotiables that prevent board damage and unstable behavior.
This lesson also focuses on operational reasoning: what healthy behavior looks like, what failure signals look like, and how this step protects the reliability of the lessons that come next.
The first quality gate is electrical correctness. Every signal that enters a Pi GPIO must remain at 3.3V logic levels, and this is not negotiable. If you violate this once, you can permanently damage the SoC input path.
The second quality gate is isolation and return-path clarity. Relay and motor power rails must be isolated from GPIO drive lines through proper interface hardware, and common ground must be deliberate so digital state transitions are stable rather than noisy.
Pin map you will use
This baseline map keeps functions explicit so service scripts and wiring diagrams always match.
GPIO17 (pin 11): status LED output
GPIO27 (pin 13): button input (pull-up)
GPIO22 (pin 15): relay output (through transistor/opto board)
GPIO2/GPIO3 (pins 3/5): I2C sensor bus
Power rule: 5V rail powers modules that require 5V, but GPIO logic never sees 5V directly.Never drive relay coils directly from a GPIO pin. Use a proper relay module, transistor stage, or opto-isolated board with flyback protection.
Checkpoint: your wiring diagram is finalized and reviewed before power-on.
Install Embedded Toolchain on Your Existing Server
Objective: add GPIO, I2C, and runtime libraries without disrupting DNS and database services.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Teaching Lens
This lesson teaches staged package setup and interface enablement for stable mixed workloads.
This lesson also focuses on operational reasoning: what healthy behavior looks like, what failure signals look like, and how this step protects the reliability of the lessons that come next.
Run
This block installs command-line GPIO tools plus Python libraries used in the remaining lessons.
sudo apt-get update sudo apt-get install -y python3-pip python3-venv python3-gpiozero python3-rpi.gpio i2c-tools python3 -m pip install --break-system-packages lgpio adafruit-circuitpython-bme280 psycopg[binary] flask sudo raspi-config nonint do_i2c 0 sudo usermod -aG gpio,i2c $USER sudo reboot
You verify I2C is enabled and your user has gpio and i2c group membership after reboot.
Checkpoint: i2cdetect -y 1 runs and GPIO libraries import without error.
Build a Deterministic LED Heartbeat Task
Objective: run a low-overhead heartbeat process proving your GPIO output timing path is stable.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Create heartbeat script
The script toggles GPIO17 at known intervals so you can validate scheduler load effects while services run.
from gpiozero import LED from signal import pause status_led = LED(17) status_led.blink(on_time=0.2, off_time=0.8, background=True) pause()
Create systemd unit
systemd keeps the heartbeat supervised and restartable across reboot events.
sudo mkdir -p /opt/embedded sudo tee /opt/embedded/heartbeat.py >/dev/null <<'EOF' from gpiozero import LED from signal import pause status_led = LED(17) status_led.blink(on_time=0.2, off_time=0.8, background=True) pause() EOF sudo tee /etc/systemd/system/pi-heartbeat.service >/dev/null <<'EOF' [Unit] Description=Pi GPIO heartbeat After=network-online.target [Service] ExecStart=/usr/bin/python3 /opt/embedded/heartbeat.py Restart=always User=pi Group=pi [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable --now pi-heartbeat sudo systemctl status pi-heartbeat --no-pager
Checkpoint: LED blinks in a stable pattern and service auto-starts after reboot.
Read a Button Input with Debounce
Objective: collect clean digital input events suitable for control logic and audit logs.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Run
This script uses pull-up logic and software debounce so one press produces one event.
from gpiozero import Button
from signal import pause
from datetime import datetime
button = Button(27, pull_up=True, bounce_time=0.05)
def on_press():
print(f"{datetime.now().isoformat()} button_pressed")
button.when_pressed = on_press
pause()Checkpoint: each physical press logs exactly one event line.
Attach an I2C Sensor and Read Environmental Data
Objective: ingest real-world analog context (temperature, humidity, pressure) into your platform.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Scan bus and read BME280
Use this sequence to verify physical sensor presence first, then produce parsed values for storage.
i2cdetect -y 1 cat <<'EOF' > /opt/embedded/read_bme280.py import board import adafruit_bme280 i2c = board.I2C() sensor = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x76) print(f"temp_c={sensor.temperature:.2f}") print(f"humidity={sensor.humidity:.2f}") print(f"pressure_hpa={sensor.pressure:.2f}") EOF python3 /opt/embedded/read_bme280.py
Checkpoint: valid sensor values print consistently without bus errors.
Control a Relay with Fail-Safe Defaults
Objective: drive a real actuator while ensuring safe behavior during reboot and script failure.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Create relay controller
The controller starts in safe-off state and supports explicit on/off command arguments only.
import sys
from gpiozero import OutputDevice
relay = OutputDevice(22, active_high=True, initial_value=False)
if len(sys.argv) != 2 or sys.argv[1] not in {"on", "off"}:
raise SystemExit("Usage: relay_ctl.py [on|off]")
if sys.argv[1] == "on":
relay.on()
else:
relay.off()For pumps, locks, or heaters, add a hardware interlock and watchdog timeout that forces OFF if process heartbeat is lost.
Checkpoint: relay state changes only on explicit command and defaults to OFF at startup.
Map Service Health to a Physical LED
Objective: expose infrastructure status in hardware so you can diagnose at a glance without shell access.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Create health mapper
This script checks core services and changes blink rate by severity.
import subprocess
import time
from gpiozero import LED
led = LED(17)
services = ["bind9", "postgresql", "nginx", "freeradius"]
def healthy(name):
return subprocess.run(["systemctl", "is-active", "--quiet", name]).returncode == 0
while True:
failed = sum(1 for s in services if not healthy(s))
if failed == 0:
led.blink(on_time=0.06, off_time=1.2, n=1, background=False)
elif failed == 1:
led.blink(on_time=0.15, off_time=0.2, n=2, background=False)
else:
led.blink(on_time=0.1, off_time=0.1, n=5, background=False)
time.sleep(1.0)Checkpoint: LED pattern changes when you intentionally stop one service and returns to normal after recovery.
Write GPIO and Sensor Events into PostgreSQL
Objective: persist embedded events so control behavior can be analyzed, replayed, and audited.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Create schema and logger
This model stores event type, source pin, and payload JSON for flexible queries later.
psql -U dns_user -d dns_analytics -h localhost <<'EOF' CREATE TABLE IF NOT EXISTS embedded_events ( id BIGSERIAL PRIMARY KEY, ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), event_type TEXT NOT NULL, source_pin INT, payload JSONB NOT NULL ); EOF cat <<'EOF' > /opt/embedded/log_event.py import json import psycopg conn = psycopg.connect("host=localhost dbname=dns_analytics user=dns_user password=ChangeThisPassword!") with conn, conn.cursor() as cur: cur.execute( "INSERT INTO embedded_events(event_type, source_pin, payload) VALUES (%s, %s, %s)", ("button_pressed", 27, json.dumps({"state": "pressed"})) ) print("event_saved") EOF python3 /opt/embedded/log_event.py
Checkpoint: query returns inserted rows from embedded_events.
Expose Safe Device Control Behind NGINX
Objective: publish a minimal local API for controlled actuation while preserving strict command boundaries.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Create local API
The API only accepts known actions and calls your relay controller script as a constrained backend operation.
from flask import Flask, request, jsonify
import subprocess
app = Flask(__name__)
@app.post('/relay')
def relay():
action = request.json.get('action', '')
if action not in {'on', 'off'}:
return jsonify({'error': 'invalid action'}), 400
subprocess.run(['/usr/bin/python3', '/opt/embedded/relay_ctl.py', action], check=True)
return jsonify({'ok': True, 'action': action})
if __name__ == '__main__':
app.run(host='127.0.0.1', port=9100)Proxy with NGINX
This route keeps API private to your local domain and centralizes TLS/access policy in NGINX.
location /device/ {
proxy_pass http://127.0.0.1:9100/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}Checkpoint: local POST requests to https://home.codeandcore.home/device/relay switch the relay only with valid actions.
Production Hardening for a Real Embedded Node
Objective: make your Pi robust against power events, software faults, and accidental unsafe commands.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Teaching Lens
This lesson teaches reliability layers expected in real embedded deployments.
This lesson also focuses on operational reasoning: what healthy behavior looks like, what failure signals look like, and how this step protects the reliability of the lessons that come next.
Reliability in embedded systems is the outcome of layered controls, not one feature. You need restart policy coverage for process failure, watchdog behavior for lockups, and hardware-default safe states so outputs do not drift during boot transitions.
This lesson also teaches operational memory. If control events are not logged and backed up, failure analysis becomes guesswork. Auditability is part of control quality, not a separate concern.
Runbook implementation
Run these actions as a final commissioning sequence before connecting high-impact loads. The order matters because each step reduces a different class of risk.
Enable the hardware watchdog and validate forced process restart behavior. Apply systemd Restart=always and RestartSec to all embedded services. Use pull-up or pull-down resistors so floating inputs cannot trigger control logic. Confirm relay outputs default OFF during boot and after crash recovery. Add a physical emergency stop path for dangerous actuators. Back up /opt/embedded and PostgreSQL embedded_events daily. Document pin map and cabinet wiring inside your repository.
You are complete when the Pi provides stable infrastructure services and deterministic GPIO behavior with auditable event history and safe fallback states.
Install Rust Embedded Toolchain on Pi4
Objective: move your hardware control path from scripting to a compiled Rust runtime suitable for long-lived services.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Teaching Lens
This lesson teaches deterministic deployment: reproducible builds, pinned dependencies, and a dedicated service binary.
This lesson also focuses on operational reasoning: what healthy behavior looks like, what failure signals look like, and how this step protects the reliability of the lessons that come next.
Run
This sequence installs Rust, creates a new project, and brings in the crates needed for GPIO, I2C, controller input, JSON logging, and PostgreSQL writes.
sudo apt-get update sudo apt-get install -y build-essential pkg-config libudev-dev libdbus-1-dev bluetooth bluez bluez-tools curl https://sh.rustup.rs -sSf | sh -s -- -y source $HOME/.cargo/env cargo new --bin /opt/embedded/rust-edge cd /opt/embedded/rust-edge cargo add rppal gilrs serde serde_json anyhow tokio tokio-postgres chrono --features tokio/full
You verify cargo build succeeds and all crate dependencies resolve on the Pi.
Checkpoint: Rust project compiles locally and is ready for hardware interfaces.
Implement Real I2C Sensor Read Path in Rust
Objective: collect sensor data over I2C in Rust and push typed records to PostgreSQL.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Replace main.rs with I2C + SQL sample
This code reads a common BME280-compatible register block pattern over I2C, builds structured telemetry, and writes a JSON payload to your existing embedded_events table.
use anyhow::Result;
use chrono::Utc;
use rppal::i2c::I2c;
use serde_json::json;
use tokio_postgres::NoTls;
#[tokio::main]
async fn main() -> Result<()> {
let mut i2c = I2c::new()?;
i2c.set_slave_address(0x76)?;
let mut buf = [0u8; 8];
i2c.block_read(0xF7, &mut buf)?;
let raw_pressure = ((buf[0] as u32) << 12) | ((buf[1] as u32) << 4) | ((buf[2] as u32) >> 4);
let raw_temp = ((buf[3] as u32) << 12) | ((buf[4] as u32) << 4) | ((buf[5] as u32) >> 4);
let raw_humidity = ((buf[6] as u16) << 8) | (buf[7] as u16);
let payload = json!({
"ts": Utc::now().to_rfc3339(),
"raw_temp": raw_temp,
"raw_pressure": raw_pressure,
"raw_humidity": raw_humidity,
"sensor": "bme280",
"bus": "i2c-1",
"address": "0x76"
});
let (client, conn) = tokio_postgres::connect(
"host=localhost user=dns_user password=ChangeThisPassword! dbname=dns_analytics",
NoTls,
)
.await?;
tokio::spawn(async move {
if let Err(e) = conn.await {
eprintln!("postgres connection error: {e}");
}
});
client
.execute(
"INSERT INTO embedded_events (event_type, source_pin, payload) VALUES ($1, $2, $3::jsonb)",
&[&"i2c_sample", &2i32, &payload.to_string()],
)
.await?;
println!("i2c_sample_saved");
Ok(())
}Run and verify
Build once, execute, then confirm a new event row exists for the I2C sample.
cd /opt/embedded/rust-edge cargo run --release psql -U dns_user -d dns_analytics -h localhost -c "SELECT ts, event_type, payload->>'sensor' AS sensor FROM embedded_events ORDER BY id DESC LIMIT 5;"
Checkpoint: Rust process reads I2C and persists telemetry without Python in the loop.
Pair PS4 Controller and Map It to Safe GPIO Actions
Objective: use a DualShock 4 as a local control console with dead-man semantics and explicit safety limits.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Teaching Lens
This lesson teaches human-in-the-loop control design instead of raw remote toggling.
This lesson also focuses on operational reasoning: what healthy behavior looks like, what failure signals look like, and how this step protects the reliability of the lessons that come next.
The PS4 controller is treated as a supervised control surface, not an unrestricted remote. Pairing stability and input integrity come first because control semantics are meaningless if events are dropped or duplicated during reconnect conditions.
The key safety model is dead-man gating. A dedicated hold action enables actuation, and output must return to OFF on disconnect, idle timeout, or gate release. That behavior prevents stale input state from energizing hardware unexpectedly.
Pair controller on Pi
Pair via Bluetooth once, then keep trust enabled for reconnect after reboot.
sudo systemctl enable --now bluetooth bluetoothctl power on agent on default-agent scan on pair <DS4_MAC> trust <DS4_MAC> connect <DS4_MAC> scan off quit
Use Rust gamepad loop with dead-man gate
This control loop only allows relay actuation while L2 is held (dead-man). Releasing L2 forces immediate OFF even if other button events occur.
use anyhow::Result;
use gilrs::{Axis, Button, EventType, Gilrs};
use rppal::gpio::Gpio;
use std::time::{Duration, Instant};
fn main() -> Result<()> {
let mut gilrs = Gilrs::new()?;
let gpio = Gpio::new()?;
let mut relay = gpio.get(22)?.into_output_low();
let mut deadman = false;
let mut last_event = Instant::now();
loop {
while let Some(ev) = gilrs.next_event() {
last_event = Instant::now();
match ev.event {
EventType::ButtonPressed(Button::LeftTrigger2, _) => deadman = true,
EventType::ButtonReleased(Button::LeftTrigger2, _) => {
deadman = false;
relay.set_low();
}
EventType::ButtonPressed(Button::South, _) if deadman => relay.set_high(),
EventType::ButtonPressed(Button::East, _) => relay.set_low(),
EventType::AxisChanged(Axis::LeftStickY, v, _) if deadman => {
if v < -0.8 { relay.set_high(); }
if v > 0.8 { relay.set_low(); }
}
_ => {}
}
}
if last_event.elapsed() > Duration::from_secs(2) {
relay.set_low();
}
std::thread::sleep(Duration::from_millis(10));
}
}Do not connect this profile directly to hazardous loads until you add hardware cutoffs, watchdog relays, and a manual emergency stop.
Checkpoint: PS4 controller can command relay ON only while dead-man button is held and always returns OFF on disconnect/idle timeout.
Run Rust Control Stack as a Production Service
Objective: ship your Rust control path as a managed daemon with logs, restart policy, and service isolation.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
Build, install, and service-wrap
This packaging flow creates a dedicated binary target and runs it under systemd with restart semantics and environment isolation.
cd /opt/embedded/rust-edge cargo build --release sudo install -m 0755 target/release/rust-edge /usr/local/bin/rust-edge sudo tee /etc/systemd/system/rust-edge.service >/dev/null <<'EOF' [Unit] Description=Rust Edge Control Loop (GPIO/I2C/PS4) After=network-online.target bluetooth.service Wants=network-online.target bluetooth.service [Service] ExecStart=/usr/local/bin/rust-edge Restart=always RestartSec=2 User=pi Group=pi WorkingDirectory=/opt/embedded/rust-edge NoNewPrivileges=true Environment=RUST_LOG=info [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable --now rust-edge sudo systemctl status rust-edge --no-pager sudo journalctl -u rust-edge -n 80 --no-pager
This lesson teaches how to deploy Rust hardware control as a real service in your infrastructure stack.
You verify controller input, GPIO output, and auto-restart behavior all survive reboot and service crashes.
Checkpoint: your Pi now runs a production Rust control daemon with I2C ingestion and PS4 input control semantics.