Project Overview & Hardware BOM

When managing a local cluster of embedded Linux devices or ESP32s running SSH daemons, relying on a primary workstation to poll their health is inefficient. This project turns a Raspberry Pi 4 into a dedicated, headless SSH client monitor. Using Python's paramiko library, the Pi periodically authenticates into remote targets, checks system load, and maps the results to physical GPIO-connected LEDs and a piezo buzzer. If a remote node drops offline or exceeds CPU thresholds, your workbench lights up with a hardware-level alert.

Difficulty Rating: Intermediate (Requires basic Linux CLI, Python virtual environments, and breadboard wiring)
Time to Build: 90 minutes
Target Board: Raspberry Pi 4 Model B (4GB RAM) running Raspberry Pi OS Bookworm (64-bit)

Bill of Materials (2026 Pricing)

  • Compute: Raspberry Pi 4 Model B (4GB) - ~$55.00
  • Storage: SanDisk Extreme 32GB microSD (A2 V30) - ~$12.00
  • Indicators: 3x 5mm Diffused LEDs (Green, Yellow, Red) - ~$2.00
  • Audio: 5V Active Piezo Buzzer - ~$1.50
  • Passives: 3x 220Ω through-hole resistors (for LEDs) - ~$0.50
  • Wiring: Half-size breadboard, 20x male-to-female jumper wires - ~$6.00

GPIO Pin Mapping & Target Matrix

Before writing any code, we must establish the physical pinout and the logical network targets. The gpiozero library uses Broadcom (BCM) pin numbering by default. Wire your components exactly as specified below to ensure the Python script runs without modification.

BCM GPIO Pin Physical Pin Component Function / Trigger Condition Wiring Note
17 11 Green LED All nodes online, load < 80% Anode to GPIO 17, Cathode to 220Ω resistor, then GND
27 13 Yellow LED Node online, but CPU load > 80% Anode to GPIO 27, Cathode to 220Ω resistor, then GND
22 15 Red LED Node unreachable / SSH timeout Anode to GPIO 22, Cathode to 220Ω resistor, then GND
5 29 Piezo Buzzer Audible alarm on Red LED state VCC to GPIO 5, GND to Physical Pin 39 (GND)

Network Target Matrix (Example): The script is configured to poll 192.168.1.50 (a remote Pi running a database) and 192.168.1.51 (an ESP32 running an embedded SSH server like elk or a lightweight Linux SBC). You will need to generate an Ed25519 SSH key on your Pi 4 (ssh-keygen -t ed25519) and copy it to your targets using ssh-copy-id to enable passwordless automation.

The Python SSH Client Script

Raspberry Pi OS Bookworm enforces PEP 668, meaning you can no longer install packages globally via pip. You must use a Python virtual environment. Run these commands in your Pi's terminal first:

mkdir ~/ssh-monitor && cd ~/ssh-monitor
python3 -m venv venv
source venv/bin/activate
pip install paramiko gpiozero

Save the following code as monitor.py. This script uses paramiko to establish the SSH client connection, executes a remote bash command to parse /proc/loadavg, and routes the result to the GPIO pins. It includes explicit error handling for network and authentication failures.

#!/usr/bin/env python3
import paramiko
import socket
from gpiozero import LED, Buzzer
from time import sleep
import sys

# --- PIN DEFINITIONS ---
GREEN_LED = LED(17)
YELLOW_LED = LED(27)
RED_LED = LED(22)
ALARM = Buzzer(5)

# --- SSH TARGET CONFIG ---
TARGET_HOST = '192.168.1.50'
TARGET_PORT = 22
SSH_USER = 'pi'
# Path to your private key generated via ssh-keygen -t ed25519
SSH_KEY_PATH = '/home/pi/.ssh/id_ed25519' 

def reset_indicators():
    GREEN_LED.off()
    YELLOW_LED.off()
    RED_LED.off()
    ALARM.off()

def check_remote_load():
    reset_indicators()
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    
    try:
        # Connect using private key, 3 second timeout to prevent hanging
        pkey = paramiko.Ed25519Key.from_private_key_file(SSH_KEY_PATH)
        client.connect(
            hostname=TARGET_HOST, 
            port=TARGET_PORT, 
            username=SSH_USER, 
            pkey=pkey, 
            timeout=3
        )
        
        # Execute remote command to get 1-minute load average
        stdin, stdout, stderr = client.exec_command("awk '{print $1}' /proc/loadavg")
        load_str = stdout.read().decode('utf-8').strip()
        load_avg = float(load_str)
        
        # Assume a 4-core Pi; load > 3.2 is 80% capacity
        if load_avg > 3.2:
            YELLOW_LED.on()
            print(f"[WARN] High load detected: {load_avg}")
        else:
            GREEN_LED.on()
            print(f"[OK] Normal load: {load_avg}")
            
    except paramiko.ssh_exception.AuthenticationException as e:
        RED_LED.on()
        print(f"[AUTH FAIL] {e}")
    except paramiko.ssh_exception.NoValidConnectionsError as e:
        RED_LED.on()
        ALARM.on()
        print(f"[CONN FAIL] {e}")
    except socket.timeout:
        RED_LED.on()
        ALARM.on()
        print("[TIMEOUT] Target did not respond within 3s")
    except Exception as e:
        RED_LED.on()
        print(f"[UNEXPECTED] {type(e).__name__}: {e}")
    finally:
        client.close()

if __name__ == '__main__':
    try:
        print(f"Starting SSH Client Monitor for {TARGET_HOST}...")
        while True:
            check_remote_load()
            sleep(15) # Poll every 15 seconds
    except KeyboardInterrupt:
        print("\nShutting down gracefully.")
        reset_indicators()
        sys.exit(0)

Debugging Connection Failures

When building network-dependent embedded projects, the physical wiring is rarely the point of failure; the network stack is. If your red LED triggers or the script crashes, check these exact error strings and their ranked causes.

The First Three Things to Check

  1. Target SSH Daemon State: Log into the target machine and run sudo systemctl status ssh. If it is inactive or masked, the Pi's SSH client will be instantly rejected.
  2. Virtual Environment Activation: If you get ModuleNotFoundError: No module named 'paramiko', you forgot to run source venv/bin/activate before executing the script. Bookworm's PEP 668 restriction prevents global imports.
  3. Key Permissions: SSH strictly enforces file permissions. If your private key is too open, the connection drops. Run chmod 600 ~/.ssh/id_ed25519 on the Pi 4.

Exact Error Strings & Ranked Fixes

Exact Error String Most Likely Cause Resolution
paramiko.ssh_exception.AuthenticationException: Authentication failed. The Pi's public key is missing from the target's ~/.ssh/authorized_keys file, or the wrong username is specified in the script. Re-run ssh-copy-id -i ~/.ssh/id_ed25519.pub user@target_ip and verify the SSH_USER variable matches the remote account.
paramiko.ssh_exception.NoValidConnectionsError: [Errno None] Unable to connect to port 22 The target's firewall (ufw/iptables) is dropping port 22, or the SSH service is bound only to 127.0.0.1 instead of 0.0.0.0. On the target, check sudo ufw status and ensure ListenAddress 0.0.0.0 is set in /etc/ssh/sshd_config.
socket.timeout: timed out Layer 2/3 network routing issue. The Pi cannot resolve the MAC address for the target IP, or they are on different VLANs without a route. Run ping -c 3 192.168.1.50 from the Pi. If it fails, check your router's DHCP reservations and subnet masks.
ValueError: Pin 17 is already in use A previous instance of the script crashed without releasing the GPIO pins, or another process (like a display driver) has claimed BCM 17. Run sudo killall python3 to clear hung processes, then reboot the Pi to reset the GPIO state machine.

Extending and Simplifying the Build

Depending on your deployment environment, you may need to scale this SSH client monitor up for a server rack, or strip it down for a battery-powered field node.

How to Extend (Scale Up)

  • Multi-Node Polling: Refactor the TARGET_HOST string into a list of dictionaries. Use Python's concurrent.futures.ThreadPoolExecutor to poll 20+ nodes simultaneously without blocking the main GPIO loop. Paramiko is thread-safe when each thread instantiates its own SSHClient.
  • MQTT Integration: Instead of just lighting local LEDs, import the paho-mqtt library. Publish the load averages to an MQTT broker (like Mosquitto) so Home Assistant can graph the remote node's CPU history over time.
  • OLED Display: Swap the LEDs for an I2C SSD1306 128x64 OLED display. Use the luma.oled library to render the exact IP address and load average in real-time on the workbench.

How to Simplify (Strip Down)

  • Drop the Buzzer: If deploying in a quiet office or bedroom, remove the piezo buzzer and the ALARM.on() calls. Rely solely on the visual Red LED for fault indication.
  • Switch to Pi Zero 2 W: If you only need to monitor one or two low-priority nodes, migrate the microSD card to a Raspberry Pi Zero 2 W (~$15). The Python code is 100% compatible, but you will need to solder a 2x20 header to the Zero's PCB first. Note that the Zero 2 W has less RAM, so keep the polling interval above 10 seconds to avoid memory swapping.
  • Use Bash Instead of Python: If you don't need GPIO hardware alerts and just want to log the data, you can replace this entire Python script with a one-line cron job using the native ssh binary and awk, appending the output to a local CSV file.

By building a dedicated SSH client on your Raspberry Pi, you offload network monitoring from your primary machine and gain immediate, physical awareness of your embedded fleet's health. Ensure your SSH keys are secured, your virtual environments are active, and your resistors are correctly oriented, and this rig will run headless for years.