stabilty and multiple interfaces

This commit is contained in:
2026-08-21 14:38:52 +02:00
parent b67ed823c2
commit 37f5f135da
2 changed files with 326 additions and 89 deletions
+61 -10
View File
@@ -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 `<interface> 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 \<iface\> | 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 <iface1> [iface2 ...] <wireguard_iface> <disk_path>
```
**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`
```bash
systemctl daemon-reload
systemctl enable /lib/systemd/system/LCDinfo.service
systemctl start LCDinfo.service
```
+265 -79
View File
@@ -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: "<pubkey>\t<endpoint_ip>:<port>"
if output:
parts = output.split()
if len(parts) >= 2:
endpoint = parts[1] # "<ip>:<port>" 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 <iface1> [iface2 ...] <wireguard_iface> <disk_path>")
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()