From 37f5f135da58baedfe1d1798774c807f713d6828 Mon Sep 17 00:00:00 2001 From: localhorst Date: Fri, 21 Aug 2026 14:38:52 +0200 Subject: [PATCH] stabilty and multiple interfaces --- README.md | 71 +++++++++-- pyLCDinfo.py | 344 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 326 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index bdd4346..d7aa80d 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,69 @@ # pyLCDInfo -display current linux/network info on a 16x2 LCD via I2C +Display current Linux/network info on a 16×2 LCD via I2C. +## Features + +- Shows IPv4 and IPv6 for **multiple** network interfaces +- Displays ` not connected` gracefully when an interface is unavailable — no crashes +- WireGuard VPN state detection with animated waiting screen +- Startup sequence showing kernel version, OS version, MAC addresses, and WireGuard endpoint +- Backlight turns off automatically once VPN is connected + +## Startup screens (2 s each) + +| Screen | Content | +|---|---| +| Welcome | "Offsite Backup" banner | +| Kernel | Running kernel version (`uname -r`) | +| OS | Pretty name from `/etc/os-release` | +| MAC \ | MAC address per interface (repeated for each) | +| WG Endpoint | Resolved hostname or IP of the WireGuard peer | ## Install -`apt-get install i2c-tools pip python3-dev` +```bash +apt-get install i2c-tools pip python3-dev +pip3 install rpi_lcd netifaces +``` -`pip3 install rpi_lcd` +Search for the I2C LCD controller on bus X (0, 1, 2, 3, …) and note the device address: -search I2C LCD Controller on Bus X (0, 1, 2, 3, ...) and note address of device -`i2cdetect -y X` +```bash +i2cdetect -y X +``` -`nano /lib/systemd/system/LCDinfo.service` +## Usage ``` +python3 pyLCDinfo.py [iface2 ...] +``` + +**Arguments (in order):** + +| Position | Description | +|---|---| +| `iface1 [iface2 ...]` | One or more local network interfaces (e.g. `eth0 eth1`) | +| `wireguard_iface` | WireGuard interface name (e.g. `wg0`) | +| `disk_path` | Path to monitor for free disk space (e.g. `/mnt/hdd/backups`) | + +**Examples:** + +```bash +# Single interface +python3 pyLCDinfo.py eth0 wg0 /mnt/hdd/backups + +# Multiple interfaces +python3 pyLCDinfo.py eth0 eth1 wlan0 wg0 /mnt/hdd/backups +``` + +## systemd service + +```bash +nano /lib/systemd/system/LCDinfo.service +``` + +```ini [Unit] Description=LCD info After=syslog.target @@ -26,12 +75,14 @@ User=root Group=root Restart=on-failure RestartSec=5s -ExecStart=sudo /usr/bin/nice -n 19 sudo -u root /usr/bin/python3 /root/pyLCDinfo.py eth0 wg0 /mnt/hdd/backups +ExecStart=sudo /usr/bin/nice -n 19 sudo -u root /usr/bin/python3 /root/pyLCDinfo.py end0 wlx000f007740cd wg0 /mnt/hdd/backups [Install] WantedBy=multi-user.target ``` -`systemctl daemon-reload` - -`systemctl enable /lib/systemd/system/LCDinfo.service` \ No newline at end of file +```bash +systemctl daemon-reload +systemctl enable /lib/systemd/system/LCDinfo.service +systemctl start LCDinfo.service +``` diff --git a/pyLCDinfo.py b/pyLCDinfo.py index 761d1fb..dbe1e5b 100644 --- a/pyLCDinfo.py +++ b/pyLCDinfo.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" Author: Hendrik Schutter, mail@hendrikschutter.com - Date of creation: 2022/05/23 - Date of last modification: 2022/10/17 +"""Author: Hendrik Schutter, mail@hendrikschutter.com +Date of creation: 2022/05/23 +Date of last modification: 2026/08/21 """ from datetime import datetime @@ -12,123 +12,309 @@ import subprocess import time import shutil import sys +import socket -lcd = LCD(address=0x3f, bus=2, width=16, rows=2, backlight=True) +lcd = LCD(address=0x3F, bus=2, width=16, rows=2, backlight=True) -def get_ip_addr(interfce_name): - return { - "ipv4": ni.ifaddresses(interfce_name)[ni.AF_INET][0]['addr'], - "ipv6": ni.ifaddresses(interfce_name)[ni.AF_INET6][0]['addr'] , - } +LCD_WIDTH = 16 -def get_wireguard_state(config_name): + +# --------------------------------------------------------------------------- +# Helper: truncate a string to fit the LCD width +# --------------------------------------------------------------------------- +def lcd_fit(text: str, width: int = LCD_WIDTH) -> str: + """Truncate text to at most `width` characters.""" + return text[:width] + + +# --------------------------------------------------------------------------- +# Network helpers +# --------------------------------------------------------------------------- +def get_ip_addr(interface_name: str) -> dict: + """Return IPv4 and IPv6 address for the given interface. + + Returns a dict with keys 'ipv4' and 'ipv6'. Values are the address string + or None when not available. + """ + result = {"ipv4": None, "ipv6": None} try: - last_handshake_unix_timestamp = subprocess.check_output(f"wg show {config_name} latest-handshakes", - shell=True, stderr=subprocess.STDOUT) + addrs = ni.ifaddresses(interface_name) + if ni.AF_INET in addrs: + result["ipv4"] = addrs[ni.AF_INET][0].get("addr") + if ni.AF_INET6 in addrs: + result["ipv6"] = addrs[ni.AF_INET6][0].get("addr") + except (ValueError, KeyError): + pass + return result + + +def get_mac_addr(interface_name: str) -> str | None: + """Return the MAC address for the given interface, or None on error.""" + try: + addrs = ni.ifaddresses(interface_name) + if ni.AF_LINK in addrs: + return addrs[ni.AF_LINK][0].get("addr") + except (ValueError, KeyError): + pass + return None + + +def is_interface_up(interface_name: str) -> bool: + """Return True when the interface exists and has an IPv4 address.""" + try: + addrs = ni.ifaddresses(interface_name) + return ni.AF_INET in addrs + except (ValueError, KeyError): + return False + + +# --------------------------------------------------------------------------- +# WireGuard helper +# --------------------------------------------------------------------------- +def get_wireguard_state(config_name: str) -> bool: + """Return True when the WireGuard interface reports a latest handshake.""" + try: + subprocess.check_output( + f"wg show {config_name} latest-handshakes", + shell=True, + stderr=subprocess.STDOUT, + ) + return True except subprocess.CalledProcessError: return False - return True - -def get_disk_usage(disk_path): - total, used, free = shutil.disk_usage(disk_path) - return str(format((free // (2**30)) / (total // (2**30)) * 100.0,'.2f')) + '%' - -def get_uptime(): - with open('/proc/uptime', 'r') as f: - uptime_seconds = float(f.readline().split()[0]) - if (uptime_seconds >= (60.0*60.0*24.0)): - return str(format(uptime_seconds/(60.0*60.0*24.0),'.2f')) + ' days' - else: - return str(format((uptime_seconds/(60.0*60.0)),'.2f')) + ' hours' - return strftime("%H:%M:%S", gmtime(60*60*24)) +def get_wireguard_endpoint(config_name: str) -> str | None: + """Return the endpoint hostname/IP of the first WireGuard peer, or None.""" + try: + output = ( + subprocess.check_output( + f"wg show {config_name} endpoints", + shell=True, + stderr=subprocess.STDOUT, + ) + .decode() + .strip() + ) + # Output format: "\t:" + if output: + parts = output.split() + if len(parts) >= 2: + endpoint = parts[1] # ":" or "[ipv6]:port" + # Try to resolve hostname; fall back to raw IP + host = endpoint.rsplit(":", 1)[0].strip("[]") + try: + hostname = socket.gethostbyaddr(host)[0] + return hostname + except socket.herror: + return host + except subprocess.CalledProcessError: + pass + return None -def get_system_time(): +# --------------------------------------------------------------------------- +# System info helpers +# --------------------------------------------------------------------------- +def get_kernel_version() -> str: + """Return the running kernel version string.""" + try: + return ( + subprocess.check_output("uname -r", shell=True, stderr=subprocess.STDOUT) + .decode() + .strip() + ) + except subprocess.CalledProcessError: + return "unknown" + + +def get_os_version() -> str: + """Return a short OS description from /etc/os-release.""" + try: + with open("/etc/os-release", "r") as f: + for line in f: + if line.startswith("PRETTY_NAME="): + return line.split("=", 1)[1].strip().strip('"') + except OSError: + pass + return "unknown OS" + + +def get_disk_usage(disk_path: str) -> str: + """Return free disk space as a percentage string, or an error string.""" + try: + total, used, free = shutil.disk_usage(disk_path) + if total == 0: + return "N/A" + pct = (free // (2**30)) / (total // (2**30)) * 100.0 + return f"{pct:.2f}%" + except OSError: + return "disk error" + + +def get_uptime() -> str: + """Return system uptime as a human-readable string.""" + try: + with open("/proc/uptime", "r") as f: + uptime_seconds = float(f.readline().split()[0]) + if uptime_seconds >= 60.0 * 60.0 * 24.0: + return f"{uptime_seconds / (60.0 * 60.0 * 24.0):.2f} days" + return f"{uptime_seconds / 3600.0:.2f} hours" + except (OSError, ValueError): + return "unknown" + + +def get_system_time() -> str: + """Return the current local time as HH:MM.""" return datetime.now().strftime("%H:%M") -def main(): - if len(sys.argv) != 4: - print("exiting due to few arguments") +# --------------------------------------------------------------------------- +# LCD helpers +# --------------------------------------------------------------------------- +def show_two_line( + top: str, bottom: str, duration: float, align_bottom: str = "right" +) -> None: + """Display two lines on the LCD and wait for `duration` seconds.""" + lcd.clear() + lcd.text(lcd_fit(top), 1) + lcd.text(lcd_fit(bottom), 2, align=align_bottom) + time.sleep(duration) + + +# --------------------------------------------------------------------------- +# Startup sequence +# --------------------------------------------------------------------------- +def show_startup_screens(interfaces: list[str], wireguard_interface: str) -> None: + """Display system info screens at startup (2 s each).""" + + # Welcome banner + show_two_line(" Offsite Backup", " fckaf.de/FHA", 2.0, align_bottom="left") + + # Kernel version + kernel = get_kernel_version() + show_two_line("Kernel:", kernel, 2.0) + + # OS version + os_ver = get_os_version() + show_two_line("OS:", os_ver, 2.0) + + # MAC address for each interface + for iface in interfaces: + mac = get_mac_addr(iface) + if mac: + show_two_line(f"MAC {iface}:", mac, 2.0) + else: + show_two_line(f"MAC {iface}:", "not available", 2.0) + + # WireGuard endpoint + endpoint = get_wireguard_endpoint(wireguard_interface) + if endpoint: + show_two_line("WG Endpoint:", endpoint, 2.0) + else: + show_two_line("WG Endpoint:", "not available", 2.0) + + +# --------------------------------------------------------------------------- +# Interface display helpers +# --------------------------------------------------------------------------- +def show_interface_info(iface: str, ip_version: str, duration: float) -> None: + """Show IPv4 or IPv6 address for an interface, or a 'not connected' message.""" + label = f"{ip_version} {iface}:" + addrs = get_ip_addr(iface) + + if ip_version == "IPv4": + addr = addrs["ipv4"] + else: + raw = addrs["ipv6"] + # Strip well-known link-local prefix suffix used in display + addr = raw[14:-1] if raw and len(raw) > 15 else raw + + if addr: + show_two_line(label, addr, duration) + else: + show_two_line(label, f"{iface} not connected", duration, align_bottom="left") + + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- +def main() -> None: + if len(sys.argv) < 4: + print("Usage: pyLCDinfo.py [iface2 ...] ") + print( + " At least one local interface, one WireGuard interface, and a disk path are required." + ) exit(-1) - local_interface = sys.argv[1] - wireguard_interface = sys.argv[2] - disk_path = sys.argv[3] + # Last argument: disk path + disk_path = sys.argv[-1] + # Second-to-last argument: WireGuard interface + wireguard_interface = sys.argv[-2] + # Everything before that: local interfaces (at least one) + local_interfaces = sys.argv[1:-2] - #print(get_ip_addr(local_interface)) - #print(get_wireguard_state(wireguard_interface)) - #print(get_disk_usage(disk_path)) - #print(get_uptime()) - #print(get_system_time()) + if not local_interfaces: + print("Error: at least one local interface must be provided.") + exit(-1) - lcd.clear() - lcd.text(" Offsite Backup", 1) - lcd.text(" fckaf.de/FHA", 2) - time.sleep(2.0) + # --- Startup screens --- + show_startup_screens(local_interfaces, wireguard_interface) state_ok = False - while(True): - if state_ok == False: + while True: + if not state_ok: lcd.backlight(turn_on=True) - lcd.clear() - lcd.text("Local IPv4:", 1) - lcd.text(get_ip_addr(local_interface)["ipv4"], 2, align='right') - time.sleep(2.4) - lcd.clear() - lcd.text("Local IPv6:", 1) - lcd.text(get_ip_addr(local_interface)["ipv6"][14:-1], 2, align='right') - time.sleep(2.4) - + # Show IPv4 and IPv6 for each local interface + for iface in local_interfaces: + show_interface_info(iface, "IPv4", 2.4) + show_interface_info(iface, "IPv6", 2.4) + + # WireGuard waiting animation lcd.clear() lcd.text("Waiting for VPN", 1) - for loops in range(4): for fill in range(14): - if loops % 2 == 0: - lcd.text("<>".rjust(fill), 2, align='left') + if loops % 2 == 0: + lcd.text("<>".rjust(fill), 2, align="left") else: - lcd.text("<>".ljust(fill), 2, align='right') + lcd.text("<>".ljust(fill), 2, align="right") time.sleep(0.1) - if get_wireguard_state(wireguard_interface): state_ok = True lcd.clear() lcd.text(" VPN connected ", 1) - lcd.text(get_system_time().rjust(16-len(get_system_time())), 2, align='left') + t = get_system_time() + lcd.text(t.rjust(LCD_WIDTH - len(t)), 2, align="left") time.sleep(10.0) + else: lcd.backlight(turn_on=False) - - lcd.clear() - lcd.text("Local IPv4:", 1) - lcd.text(get_ip_addr(local_interface)["ipv4"], 2, align='right') - time.sleep(5.0) - lcd.clear() - lcd.text("Local IPv6:", 1) - lcd.text(get_ip_addr(local_interface)["ipv6"][14:-1], 2, align='right') - time.sleep(5.0) - lcd.clear() - lcd.text("Free Disk:", 1) - lcd.text(get_disk_usage(disk_path), 2, align='right') - time.sleep(5.0) - lcd.clear() - lcd.text("System Uptime:", 1) - lcd.text(get_uptime(), 2, align='right') - time.sleep(5.0) + # Show IPv4 and IPv6 for each local interface + for iface in local_interfaces: + show_interface_info(iface, "IPv4", 5.0) + show_interface_info(iface, "IPv6", 5.0) + + # Disk usage + show_two_line("Free Disk:", get_disk_usage(disk_path), 5.0) + + # Uptime + show_two_line("System Uptime:", get_uptime(), 5.0) + + # VPN status + time — long idle sleep to reduce CPU usage lcd.clear() lcd.text(" VPN Connected ", 1) - lcd.text(get_system_time().rjust(16-len(get_system_time())), 2, align='left') - time.sleep(40.0) #sleep a lot to idle cpu + t = get_system_time() + lcd.text(t.rjust(LCD_WIDTH - len(t)), 2, align="left") + time.sleep(40.0) - #check if wireguard is still connected + # Re-check WireGuard connectivity state_ok = get_wireguard_state(wireguard_interface) + if __name__ == "__main__": main()