stabilty and multiple interfaces
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
+262
-76
@@ -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')
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user