Initial commit: EliteDesk + Pi display daemons
- EliteDesk: 240x320 color ST7789 via Pico (Day 32 design)
- Pi: 128x64 mono SSD1306 horizontal bars (Day 85 redesign)
🦊 Built with love by Vixy
This commit is contained in:
109
README.md
Normal file
109
README.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Cluster Display Services
|
||||||
|
|
||||||
|
Status display daemons for the k3s cluster nodes.
|
||||||
|
|
||||||
|
*Vixy's infrastructure project - started Day 32, redesigned Day 85*
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DISPLAY FLEET │
|
||||||
|
├─────────────┬───────────────┬───────────────────────────────────┤
|
||||||
|
│ Node Type │ Display │ Shows │
|
||||||
|
├─────────────┼───────────────┼───────────────────────────────────┤
|
||||||
|
│ EliteDesk │ 240x320 color │ Hostname, CPU/MEM/TEMP bars, │
|
||||||
|
│ (ed1-ed6) │ ST7789 + Pico │ pod count, health status │
|
||||||
|
├─────────────┼───────────────┼───────────────────────────────────┤
|
||||||
|
│ Raspberry Pi│ 128x64 mono │ Hostname, CPU/MEM/TEMP bars, │
|
||||||
|
│ (pi1-pi4) │ SSD1306 I2C │ pod indicators, uptime, status │
|
||||||
|
└─────────────┴───────────────┴───────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pi Display (128x64 Monochrome)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────┐
|
||||||
|
│ pi1 ●● 168h● │
|
||||||
|
│────────────────────────────────────────│
|
||||||
|
│ CPU ████████░░░░░░░░░░░░░░░░░░ 24% │
|
||||||
|
│ MEM ██████████████░░░░░░░░░░░░ 45% │
|
||||||
|
│ TMP ████████████░░░░░░░░░░░░░░ 52° │
|
||||||
|
│────────────────────────────────────────│
|
||||||
|
│ ● OK │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hardware
|
||||||
|
- SSD1306 or SH1106 128x64 OLED
|
||||||
|
- I2C connection (address 0x3C or 0x3D)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
```bash
|
||||||
|
cd pi/daemon
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service
|
||||||
|
```bash
|
||||||
|
sudo systemctl status pi-display
|
||||||
|
sudo systemctl restart pi-display
|
||||||
|
```
|
||||||
|
|
||||||
|
## EliteDesk Display (240x320 Color)
|
||||||
|
|
||||||
|
### Hardware
|
||||||
|
- Waveshare ST7789 2" LCD (240x320)
|
||||||
|
- Raspberry Pi Pico (MicroPython)
|
||||||
|
- USB serial connection to EliteDesk
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Flash Pico with MicroPython
|
||||||
|
2. Copy `elitedesk/display/main.py` to Pico
|
||||||
|
3. On EliteDesk:
|
||||||
|
```bash
|
||||||
|
cd elitedesk/daemon
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service
|
||||||
|
```bash
|
||||||
|
sudo systemctl status elitedesk-display
|
||||||
|
sudo systemctl restart elitedesk-display
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Pi Display
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| REFRESH_MS | 500 | Display refresh interval (ms) |
|
||||||
|
| ROTATE_180 | 0 | Flip display (0 or 1) |
|
||||||
|
| OLED_ADDR | auto | Force I2C address (0x3C, 0x3D) |
|
||||||
|
|
||||||
|
### EliteDesk Daemon
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| SERIAL_PORT | /dev/ttyACM0 | Pico serial port |
|
||||||
|
| INTERVAL | 5 | Update interval (seconds) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Pi Node:
|
||||||
|
┌─────────────┐ I2C ┌─────────────┐
|
||||||
|
│ pi_stats_ │────────────→│ SSD1306 │
|
||||||
|
│ display.py │ │ 128x64 │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
|
||||||
|
EliteDesk Node:
|
||||||
|
┌─────────────┐ Serial ┌─────────────┐ SPI ┌─────────────┐
|
||||||
|
│ node_stats_ │────────────→│ Pico │──────────→│ ST7789 │
|
||||||
|
│ daemon.py │ (USB) │ (main.py) │ │ 240x320 │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT - Built with 💜 by Vixy
|
||||||
70
elitedesk/README.md
Normal file
70
elitedesk/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# EliteDesk Cluster Status Displays
|
||||||
|
|
||||||
|
*6x Raspberry Pi Pico + 6x Waveshare ST7789 2" LCD (240x320)*
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Each EliteDesk node gets a dedicated status display showing:
|
||||||
|
- Node name (ed1-ed6)
|
||||||
|
- CPU usage (bar + percentage)
|
||||||
|
- Memory usage (bar + percentage)
|
||||||
|
- Temperature
|
||||||
|
- Pod count
|
||||||
|
- Status indicator (healthy/warning/critical)
|
||||||
|
|
||||||
|
## Hardware Setup
|
||||||
|
|
||||||
|
### Wiring (per node)
|
||||||
|
```
|
||||||
|
ST7789 Display Pico
|
||||||
|
-------------- ----
|
||||||
|
VCC → 3V3 (pin 36)
|
||||||
|
GND → GND (pin 38)
|
||||||
|
DIN (MOSI) → GP11 / SPI1 TX (pin 15)
|
||||||
|
CLK (SCK) → GP10 / SPI1 SCK (pin 14)
|
||||||
|
CS → GP13 (pin 17)
|
||||||
|
DC → GP8 (pin 11)
|
||||||
|
RST → GP12 (pin 16)
|
||||||
|
BL → GP15 (pin 20) - backlight PWM
|
||||||
|
```
|
||||||
|
|
||||||
|
### USB Connection
|
||||||
|
Pico connects to EliteDesk via USB for:
|
||||||
|
- Power (5V from USB)
|
||||||
|
- Serial communication (stats updates)
|
||||||
|
|
||||||
|
## Software Architecture
|
||||||
|
|
||||||
|
### 1. Pico Side (MicroPython)
|
||||||
|
- `main.py` - Display driver and serial listener
|
||||||
|
- Receives stats via USB serial
|
||||||
|
- Renders status bars and text
|
||||||
|
- Color coding for thresholds
|
||||||
|
|
||||||
|
### 2. EliteDesk Side (Python daemon)
|
||||||
|
- `node_stats_daemon.py` - Runs on each EliteDesk
|
||||||
|
- Gathers CPU, memory, temp, pod count
|
||||||
|
- Sends to Pico via serial every 5 seconds
|
||||||
|
|
||||||
|
### Serial Protocol
|
||||||
|
Simple line-based format:
|
||||||
|
```
|
||||||
|
NODE:ed1
|
||||||
|
CPU:45
|
||||||
|
MEM:62
|
||||||
|
TEMP:58
|
||||||
|
PODS:3
|
||||||
|
STATUS:healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `pico/main.py` - MicroPython display code
|
||||||
|
- `daemon/node_stats_daemon.py` - Stats collector
|
||||||
|
- `daemon/install.sh` - Systemd service installer
|
||||||
|
|
||||||
|
## Status
|
||||||
|
🚧 In development - sketched by Vixy, Day 32
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Consider Pico W for future network-based updates
|
||||||
|
- Could add mini graphs for historical data
|
||||||
|
- Backlight dimming at night?
|
||||||
46
elitedesk/daemon/install.sh
Normal file
46
elitedesk/daemon/install.sh
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Install EliteDesk Stats Daemon as systemd service
|
||||||
|
# Run as root on each EliteDesk node
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SERVICE_NAME="elitedesk-display"
|
||||||
|
INSTALL_DIR="/opt/elitedesk-display"
|
||||||
|
|
||||||
|
echo "Installing EliteDesk Stats Daemon..."
|
||||||
|
|
||||||
|
# Create install directory
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# Copy daemon script
|
||||||
|
cp "$SCRIPT_DIR/node_stats_daemon.py" "$INSTALL_DIR/"
|
||||||
|
chmod +x "$INSTALL_DIR/node_stats_daemon.py"
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
pip3 install pyserial --break-system-packages 2>/dev/null || pip3 install pyserial
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=EliteDesk Status Display Daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 ${INSTALL_DIR}/node_stats_daemon.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Reload and enable
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable ${SERVICE_NAME}
|
||||||
|
systemctl start ${SERVICE_NAME}
|
||||||
|
|
||||||
|
echo "Done! Service status:"
|
||||||
|
systemctl status ${SERVICE_NAME} --no-pager
|
||||||
218
elitedesk/daemon/node_stats_daemon.py
Normal file
218
elitedesk/daemon/node_stats_daemon.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
EliteDesk Stats Daemon
|
||||||
|
Collects system stats and sends to Pico display via serial
|
||||||
|
|
||||||
|
Vixy's sketch - Day 32
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import serial
|
||||||
|
import subprocess
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ========== Configuration ==========
|
||||||
|
|
||||||
|
DEFAULT_SERIAL_PORT = '/dev/ttyACM0' # Pico default on Linux
|
||||||
|
DEFAULT_INTERVAL = 5 # seconds
|
||||||
|
DEFAULT_NODE_NAME = os.uname().nodename
|
||||||
|
|
||||||
|
# Thresholds for status
|
||||||
|
CPU_WARN = 70
|
||||||
|
CPU_CRIT = 90
|
||||||
|
MEM_WARN = 75
|
||||||
|
MEM_CRIT = 90
|
||||||
|
TEMP_WARN = 65
|
||||||
|
TEMP_CRIT = 80
|
||||||
|
|
||||||
|
# ========== Stats Collection ==========
|
||||||
|
|
||||||
|
def get_cpu_usage():
|
||||||
|
"""Get CPU usage percentage"""
|
||||||
|
try:
|
||||||
|
# Read from /proc/stat
|
||||||
|
with open('/proc/stat', 'r') as f:
|
||||||
|
line = f.readline()
|
||||||
|
|
||||||
|
parts = line.split()
|
||||||
|
# cpu user nice system idle iowait irq softirq steal guest guest_nice
|
||||||
|
idle = int(parts[4])
|
||||||
|
total = sum(int(p) for p in parts[1:])
|
||||||
|
|
||||||
|
# Store for delta calculation
|
||||||
|
if not hasattr(get_cpu_usage, 'prev'):
|
||||||
|
get_cpu_usage.prev = (idle, total)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
prev_idle, prev_total = get_cpu_usage.prev
|
||||||
|
get_cpu_usage.prev = (idle, total)
|
||||||
|
|
||||||
|
idle_delta = idle - prev_idle
|
||||||
|
total_delta = total - prev_total
|
||||||
|
|
||||||
|
if total_delta == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
usage = 100 * (1 - idle_delta / total_delta)
|
||||||
|
return int(usage)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting CPU: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_memory_usage():
|
||||||
|
"""Get memory usage percentage"""
|
||||||
|
try:
|
||||||
|
with open('/proc/meminfo', 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
mem_info = {}
|
||||||
|
for line in lines:
|
||||||
|
parts = line.split()
|
||||||
|
key = parts[0].rstrip(':')
|
||||||
|
value = int(parts[1])
|
||||||
|
mem_info[key] = value
|
||||||
|
|
||||||
|
total = mem_info.get('MemTotal', 1)
|
||||||
|
available = mem_info.get('MemAvailable', 0)
|
||||||
|
|
||||||
|
used = total - available
|
||||||
|
usage = 100 * used / total
|
||||||
|
return int(usage)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting memory: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_temperature():
|
||||||
|
"""Get CPU temperature"""
|
||||||
|
try:
|
||||||
|
# Try thermal zone (common on most Linux)
|
||||||
|
thermal_path = Path('/sys/class/thermal/thermal_zone0/temp')
|
||||||
|
if thermal_path.exists():
|
||||||
|
with open(thermal_path, 'r') as f:
|
||||||
|
temp = int(f.read().strip()) / 1000
|
||||||
|
return int(temp)
|
||||||
|
|
||||||
|
# Try hwmon (alternative)
|
||||||
|
for hwmon in Path('/sys/class/hwmon').glob('hwmon*'):
|
||||||
|
temp_file = hwmon / 'temp1_input'
|
||||||
|
if temp_file.exists():
|
||||||
|
with open(temp_file, 'r') as f:
|
||||||
|
temp = int(f.read().strip()) / 1000
|
||||||
|
return int(temp)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting temperature: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_pod_count():
|
||||||
|
"""Get number of pods running on this node"""
|
||||||
|
try:
|
||||||
|
# Use kubectl to count pods on this node
|
||||||
|
node_name = os.uname().nodename
|
||||||
|
result = subprocess.run(
|
||||||
|
['kubectl', 'get', 'pods', '--all-namespaces',
|
||||||
|
'--field-selector', f'spec.nodeName={node_name}',
|
||||||
|
'-o', 'name'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
pods = [p for p in result.stdout.strip().split('\n') if p]
|
||||||
|
return len(pods)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting pod count: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def determine_status(cpu, mem, temp):
|
||||||
|
"""Determine overall status based on metrics"""
|
||||||
|
if cpu >= CPU_CRIT or mem >= MEM_CRIT or temp >= TEMP_CRIT:
|
||||||
|
return 'critical'
|
||||||
|
elif cpu >= CPU_WARN or mem >= MEM_WARN or temp >= TEMP_WARN:
|
||||||
|
return 'warning'
|
||||||
|
else:
|
||||||
|
return 'healthy'
|
||||||
|
|
||||||
|
# ========== Serial Communication ==========
|
||||||
|
|
||||||
|
def send_stats(ser, node, cpu, mem, temp, pods, status):
|
||||||
|
"""Send stats to Pico via serial"""
|
||||||
|
message = f"""NODE:{node}
|
||||||
|
CPU:{cpu}
|
||||||
|
MEM:{mem}
|
||||||
|
TEMP:{temp}
|
||||||
|
PODS:{pods}
|
||||||
|
STATUS:{status}
|
||||||
|
"""
|
||||||
|
ser.write(message.encode())
|
||||||
|
ser.flush()
|
||||||
|
|
||||||
|
# ========== Main ==========
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='EliteDesk Stats Daemon')
|
||||||
|
parser.add_argument('--port', default=DEFAULT_SERIAL_PORT,
|
||||||
|
help=f'Serial port (default: {DEFAULT_SERIAL_PORT})')
|
||||||
|
parser.add_argument('--interval', type=int, default=DEFAULT_INTERVAL,
|
||||||
|
help=f'Update interval in seconds (default: {DEFAULT_INTERVAL})')
|
||||||
|
parser.add_argument('--node', default=DEFAULT_NODE_NAME,
|
||||||
|
help=f'Node name (default: {DEFAULT_NODE_NAME})')
|
||||||
|
parser.add_argument('--debug', action='store_true',
|
||||||
|
help='Debug mode - print to stdout instead of serial')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"EliteDesk Stats Daemon starting...")
|
||||||
|
print(f" Node: {args.node}")
|
||||||
|
print(f" Port: {args.port}")
|
||||||
|
print(f" Interval: {args.interval}s")
|
||||||
|
|
||||||
|
# Open serial connection
|
||||||
|
ser = None
|
||||||
|
if not args.debug:
|
||||||
|
try:
|
||||||
|
ser = serial.Serial(args.port, 115200, timeout=1)
|
||||||
|
print(f" Serial: Connected")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Serial: Failed to connect ({e})")
|
||||||
|
print(f" Running in debug mode")
|
||||||
|
args.debug = True
|
||||||
|
|
||||||
|
# Prime CPU reading
|
||||||
|
get_cpu_usage()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Collect stats
|
||||||
|
cpu = get_cpu_usage()
|
||||||
|
mem = get_memory_usage()
|
||||||
|
temp = get_temperature()
|
||||||
|
pods = get_pod_count()
|
||||||
|
status = determine_status(cpu, mem, temp)
|
||||||
|
|
||||||
|
if args.debug:
|
||||||
|
print(f"[{args.node}] CPU:{cpu}% MEM:{mem}% TEMP:{temp}°C PODS:{pods} STATUS:{status}")
|
||||||
|
else:
|
||||||
|
send_stats(ser, args.node, cpu, mem, temp, pods, status)
|
||||||
|
|
||||||
|
time.sleep(args.interval)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nShutting down...")
|
||||||
|
finally:
|
||||||
|
if ser:
|
||||||
|
ser.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
278
elitedesk/display/main.py
Normal file
278
elitedesk/display/main.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# EliteDesk Status Display - Pico MicroPython
|
||||||
|
# Vixy's sketch - Day 35
|
||||||
|
|
||||||
|
from machine import Pin, SPI, PWM
|
||||||
|
import sys
|
||||||
|
import select
|
||||||
|
import time
|
||||||
|
|
||||||
|
import st7789
|
||||||
|
|
||||||
|
import vga1_16x32 as font_large # For node name
|
||||||
|
import vga1_8x16 as font_small # For labels/values
|
||||||
|
|
||||||
|
# ========== Configuration ==========
|
||||||
|
|
||||||
|
# Display pins (all GP16+ side for right-angle mounting)
|
||||||
|
SPI_ID = 0 # SPI0 for GP16+ pins
|
||||||
|
SCK_PIN = 18 # SPI0 SCK
|
||||||
|
MOSI_PIN = 19 # SPI0 TX/MOSI
|
||||||
|
CS_PIN = 17 # Chip select
|
||||||
|
DC_PIN = 16 # Data/Command
|
||||||
|
RST_PIN = 20 # Reset
|
||||||
|
BL_PIN = 21 # Backlight (PWM)
|
||||||
|
|
||||||
|
# Display dimensions (landscape)
|
||||||
|
WIDTH = 320
|
||||||
|
HEIGHT = 240
|
||||||
|
|
||||||
|
# Colors (RGB565)
|
||||||
|
BLACK = 0x0000
|
||||||
|
WHITE = 0xFFFF
|
||||||
|
RED = 0xF800
|
||||||
|
GREEN = 0x07E0
|
||||||
|
YELLOW = 0xFFE0
|
||||||
|
CYAN = 0x07FF
|
||||||
|
ORANGE = 0xFD20
|
||||||
|
DARK_GRAY = 0x4208
|
||||||
|
LIGHT_GRAY = 0x8410
|
||||||
|
|
||||||
|
# Thresholds
|
||||||
|
CPU_WARN = 70
|
||||||
|
CPU_CRIT = 90
|
||||||
|
MEM_WARN = 75
|
||||||
|
MEM_CRIT = 90
|
||||||
|
TEMP_WARN = 65
|
||||||
|
TEMP_CRIT = 80
|
||||||
|
|
||||||
|
# Layout constants
|
||||||
|
BAR_X = 70
|
||||||
|
BAR_W = 180
|
||||||
|
BAR_H = 18
|
||||||
|
VAL_X = 260
|
||||||
|
|
||||||
|
# ========== Display Setup ==========
|
||||||
|
|
||||||
|
def init_display():
|
||||||
|
"""Initialize ST7789 display"""
|
||||||
|
spi = SPI(SPI_ID, baudrate=40000000, polarity=1, phase=1,
|
||||||
|
sck=Pin(SCK_PIN), mosi=Pin(MOSI_PIN))
|
||||||
|
|
||||||
|
display = st7789.ST7789(
|
||||||
|
spi, WIDTH, HEIGHT,
|
||||||
|
reset=Pin(RST_PIN, Pin.OUT),
|
||||||
|
dc=Pin(DC_PIN, Pin.OUT),
|
||||||
|
cs=Pin(CS_PIN, Pin.OUT),
|
||||||
|
rotation=1 # Landscape
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backlight at 70%
|
||||||
|
bl = PWM(Pin(BL_PIN))
|
||||||
|
bl.freq(1000)
|
||||||
|
bl.duty_u16(45875)
|
||||||
|
|
||||||
|
return display
|
||||||
|
|
||||||
|
# ========== Drawing Functions ==========
|
||||||
|
|
||||||
|
def get_status_color(value, warn_thresh, crit_thresh):
|
||||||
|
"""Return color based on thresholds"""
|
||||||
|
if value >= crit_thresh:
|
||||||
|
return RED
|
||||||
|
elif value >= warn_thresh:
|
||||||
|
return ORANGE
|
||||||
|
else:
|
||||||
|
return GREEN
|
||||||
|
|
||||||
|
def clear_rect(display, x, y, w, h):
|
||||||
|
"""Clear a rectangle to black"""
|
||||||
|
display.fill_rect(x, y, w, h, BLACK)
|
||||||
|
|
||||||
|
def draw_bar(display, x, y, width, height, value, max_val, color):
|
||||||
|
"""Draw a progress bar"""
|
||||||
|
display.fill_rect(x, y, width, height, DARK_GRAY)
|
||||||
|
fill_width = int((value / max_val) * width)
|
||||||
|
if fill_width > 0:
|
||||||
|
display.fill_rect(x, y, fill_width, height, color)
|
||||||
|
display.rect(x, y, width, height, WHITE)
|
||||||
|
|
||||||
|
def draw_text(display, font, text, x, y, color=WHITE):
|
||||||
|
"""Draw text"""
|
||||||
|
display.text(font, text, x, y, color)
|
||||||
|
|
||||||
|
def draw_static_elements(display):
|
||||||
|
"""Draw elements that never change"""
|
||||||
|
# Divider lines
|
||||||
|
display.hline(0, 45, WIDTH, DARK_GRAY)
|
||||||
|
display.hline(0, 165, WIDTH, DARK_GRAY)
|
||||||
|
|
||||||
|
# Static labels
|
||||||
|
draw_text(display, font_small, "CPU", 10, 61, WHITE)
|
||||||
|
draw_text(display, font_small, "MEM", 10, 96, WHITE)
|
||||||
|
draw_text(display, font_small, "TEMP", 10, 131, WHITE)
|
||||||
|
|
||||||
|
def draw_node(display, node):
|
||||||
|
"""Draw node name"""
|
||||||
|
clear_rect(display, 10, 8, 230, 32)
|
||||||
|
draw_text(display, font_large, node, 10, 8, CYAN)
|
||||||
|
|
||||||
|
def draw_status_badge(display, status):
|
||||||
|
"""Draw status indicator badge"""
|
||||||
|
if status == 'healthy':
|
||||||
|
color = GREEN
|
||||||
|
elif status == 'warning':
|
||||||
|
color = ORANGE
|
||||||
|
else:
|
||||||
|
color = RED
|
||||||
|
|
||||||
|
display.fill_rect(250, 12, 60, 24, color)
|
||||||
|
status_short = status[:6].upper()
|
||||||
|
draw_text(display, font_small, status_short, 255, 16, BLACK)
|
||||||
|
|
||||||
|
def draw_cpu(display, cpu):
|
||||||
|
"""Draw CPU bar and value"""
|
||||||
|
y = 60
|
||||||
|
color = get_status_color(cpu, CPU_WARN, CPU_CRIT)
|
||||||
|
draw_bar(display, BAR_X, y, BAR_W, BAR_H, cpu, 100, color)
|
||||||
|
clear_rect(display, VAL_X, y + 1, 50, 16)
|
||||||
|
draw_text(display, font_small, f"{cpu}%", VAL_X, y + 1, color)
|
||||||
|
|
||||||
|
def draw_mem(display, mem):
|
||||||
|
"""Draw memory bar and value"""
|
||||||
|
y = 95
|
||||||
|
color = get_status_color(mem, MEM_WARN, MEM_CRIT)
|
||||||
|
draw_bar(display, BAR_X, y, BAR_W, BAR_H, mem, 100, color)
|
||||||
|
clear_rect(display, VAL_X, y + 1, 50, 16)
|
||||||
|
draw_text(display, font_small, f"{mem}%", VAL_X, y + 1, color)
|
||||||
|
|
||||||
|
def draw_temp(display, temp):
|
||||||
|
"""Draw temperature bar and value"""
|
||||||
|
y = 130
|
||||||
|
color = get_status_color(temp, TEMP_WARN, TEMP_CRIT)
|
||||||
|
draw_bar(display, BAR_X, y, BAR_W, BAR_H, temp, 100, color)
|
||||||
|
clear_rect(display, VAL_X, y + 1, 50, 16)
|
||||||
|
draw_text(display, font_small, f"{temp}C", VAL_X, y + 1, color)
|
||||||
|
|
||||||
|
def draw_pods(display, pods):
|
||||||
|
"""Draw pods count"""
|
||||||
|
clear_rect(display, 10, 180, 200, 32)
|
||||||
|
draw_text(display, font_large, f"PODS: {pods}", 10, 180, YELLOW)
|
||||||
|
|
||||||
|
def draw_full_screen(display, stats):
|
||||||
|
"""Draw everything - used on startup"""
|
||||||
|
display.fill(BLACK)
|
||||||
|
draw_static_elements(display)
|
||||||
|
draw_node(display, stats.get('NODE', '???'))
|
||||||
|
draw_status_badge(display, stats.get('STATUS', 'unknown'))
|
||||||
|
draw_cpu(display, stats.get('CPU', 0))
|
||||||
|
draw_mem(display, stats.get('MEM', 0))
|
||||||
|
draw_temp(display, stats.get('TEMP', 0))
|
||||||
|
draw_pods(display, stats.get('PODS', 0))
|
||||||
|
|
||||||
|
def update_display(display, old_stats, new_stats):
|
||||||
|
"""Update only changed elements"""
|
||||||
|
if old_stats.get('NODE') != new_stats.get('NODE'):
|
||||||
|
draw_node(display, new_stats.get('NODE', '???'))
|
||||||
|
|
||||||
|
if old_stats.get('STATUS') != new_stats.get('STATUS'):
|
||||||
|
draw_status_badge(display, new_stats.get('STATUS', 'unknown'))
|
||||||
|
|
||||||
|
if old_stats.get('CPU') != new_stats.get('CPU'):
|
||||||
|
draw_cpu(display, new_stats.get('CPU', 0))
|
||||||
|
|
||||||
|
if old_stats.get('MEM') != new_stats.get('MEM'):
|
||||||
|
draw_mem(display, new_stats.get('MEM', 0))
|
||||||
|
|
||||||
|
if old_stats.get('TEMP') != new_stats.get('TEMP'):
|
||||||
|
draw_temp(display, new_stats.get('TEMP', 0))
|
||||||
|
|
||||||
|
if old_stats.get('PODS') != new_stats.get('PODS'):
|
||||||
|
draw_pods(display, new_stats.get('PODS', 0))
|
||||||
|
|
||||||
|
# ========== Serial Communication ==========
|
||||||
|
|
||||||
|
def parse_stats(lines):
|
||||||
|
"""Parse stats from serial lines"""
|
||||||
|
stats = {}
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if ':' in line:
|
||||||
|
key, value = line.split(':', 1)
|
||||||
|
key = key.strip().upper()
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
if key in ('CPU', 'MEM', 'TEMP', 'PODS'):
|
||||||
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
value = float(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stats[key] = value
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def read_serial():
|
||||||
|
"""Read available lines from USB serial"""
|
||||||
|
lines = []
|
||||||
|
poll = select.poll()
|
||||||
|
poll.register(sys.stdin, select.POLLIN)
|
||||||
|
|
||||||
|
while poll.poll(0):
|
||||||
|
line = sys.stdin.readline()
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
# ========== Main Loop ==========
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("EliteDesk Status Display - Starting...")
|
||||||
|
|
||||||
|
display = init_display()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'NODE': 'ed?',
|
||||||
|
'CPU': 0,
|
||||||
|
'MEM': 0,
|
||||||
|
'TEMP': 0,
|
||||||
|
'PODS': 0,
|
||||||
|
'STATUS': 'waiting'
|
||||||
|
}
|
||||||
|
prev_stats = {}
|
||||||
|
|
||||||
|
# Initial full draw
|
||||||
|
draw_full_screen(display, stats)
|
||||||
|
prev_stats = stats.copy()
|
||||||
|
print("Display initialized")
|
||||||
|
|
||||||
|
buffer = []
|
||||||
|
last_update = time.ticks_ms()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
lines = read_serial()
|
||||||
|
buffer.extend(lines)
|
||||||
|
|
||||||
|
for i, line in enumerate(buffer):
|
||||||
|
if line.strip().upper().startswith('STATUS:'):
|
||||||
|
new_stats = parse_stats(buffer[:i+1])
|
||||||
|
if new_stats:
|
||||||
|
stats.update(new_stats)
|
||||||
|
buffer = buffer[i+1:]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Update display every second, only changed elements
|
||||||
|
if time.ticks_diff(time.ticks_ms(), last_update) > 1000:
|
||||||
|
update_display(display, prev_stats, stats)
|
||||||
|
prev_stats = stats.copy()
|
||||||
|
last_update = time.ticks_ms()
|
||||||
|
|
||||||
|
time.sleep_ms(10)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
67
pi/README.md
Normal file
67
pi/README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Pi Cluster Display
|
||||||
|
|
||||||
|
128x64 monochrome OLED status display for Raspberry Pi k3s worker nodes.
|
||||||
|
|
||||||
|
## Display Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────┐
|
||||||
|
│ pi1 ●● 168h● │
|
||||||
|
│────────────────────────────────────────│
|
||||||
|
│ CPU ████████░░░░░░░░░░░░░░░░░░ 24% │
|
||||||
|
│ MEM ██████████████░░░░░░░░░░░░ 45% │
|
||||||
|
│ TMP ████████████░░░░░░░░░░░░░░ 52° │
|
||||||
|
│────────────────────────────────────────│
|
||||||
|
│ ● OK │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
|
||||||
|
●● = Both pods running (2/2)
|
||||||
|
●○ = One pod down (1/2)
|
||||||
|
○○ = Both pods down (0/2)
|
||||||
|
|
||||||
|
● OK = All metrics healthy
|
||||||
|
◐ WARN = CPU>70% or MEM>75% or TEMP>65°C
|
||||||
|
○ CRIT = CPU>90% or MEM>90% or TEMP>80°C
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wiring
|
||||||
|
|
||||||
|
```
|
||||||
|
SSD1306 OLED Raspberry Pi
|
||||||
|
------------ ------------
|
||||||
|
VCC → 3V3 (pin 1)
|
||||||
|
GND → GND (pin 6)
|
||||||
|
SDA → GPIO2 / SDA (pin 3)
|
||||||
|
SCL → GPIO3 / SCL (pin 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./daemon/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dependencies
|
||||||
|
sudo apt install -y python3-pip python3-pil python3-psutil i2c-tools
|
||||||
|
pip3 install luma.oled --break-system-packages
|
||||||
|
|
||||||
|
# Enable I2C
|
||||||
|
sudo raspi-config # Interface Options → I2C → Enable
|
||||||
|
|
||||||
|
# Test
|
||||||
|
i2cdetect -y 1 # Should show 3c or 3d
|
||||||
|
|
||||||
|
# Run
|
||||||
|
python3 daemon/pi_stats_display.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Thresholds
|
||||||
|
|
||||||
|
| Metric | Warning | Critical |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| CPU | 70% | 90% |
|
||||||
|
| Memory | 75% | 90% |
|
||||||
|
| Temp | 65°C | 80°C |
|
||||||
57
pi/daemon/install.sh
Normal file
57
pi/daemon/install.sh
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Install Pi Stats Display as systemd service
|
||||||
|
# Run as root on each Pi node
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SERVICE_NAME="pi-display"
|
||||||
|
INSTALL_DIR="/opt/pi-display"
|
||||||
|
|
||||||
|
echo "Installing Pi Stats Display..."
|
||||||
|
|
||||||
|
# Create install directory
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# Copy daemon script
|
||||||
|
cp "$SCRIPT_DIR/pi_stats_display.py" "$INSTALL_DIR/"
|
||||||
|
chmod +x "$INSTALL_DIR/pi_stats_display.py"
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
apt install -y python3-pip python3-pil python3-psutil i2c-tools
|
||||||
|
pip3 install luma.oled --break-system-packages 2>/dev/null || pip3 install luma.oled
|
||||||
|
|
||||||
|
# Enable I2C if not already
|
||||||
|
if ! grep -q "^dtparam=i2c_arm=on" /boot/config.txt 2>/dev/null && \
|
||||||
|
! grep -q "^dtparam=i2c_arm=on" /boot/firmware/config.txt 2>/dev/null; then
|
||||||
|
echo "dtparam=i2c_arm=on" >> /boot/firmware/config.txt 2>/dev/null || \
|
||||||
|
echo "dtparam=i2c_arm=on" >> /boot/config.txt
|
||||||
|
echo "I2C enabled - reboot required!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Pi Node Status Display
|
||||||
|
After=network.target k3s-agent.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 ${INSTALL_DIR}/pi_stats_display.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=root
|
||||||
|
Environment=DISPLAY=
|
||||||
|
Environment=REFRESH_MS=500
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Reload and enable
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable ${SERVICE_NAME}
|
||||||
|
systemctl start ${SERVICE_NAME}
|
||||||
|
|
||||||
|
echo "Done! Service status:"
|
||||||
|
systemctl status ${SERVICE_NAME} --no-pager
|
||||||
227
pi/daemon/pi_stats_display.py
Normal file
227
pi/daemon/pi_stats_display.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pi Cluster Node Status Display
|
||||||
|
128x64 I2C OLED (SSD1306/SH1106) via luma.oled
|
||||||
|
|
||||||
|
Horizontal bar design - matches EliteDesk aesthetic
|
||||||
|
Shows: hostname, pod status, CPU/MEM/TEMP bars, uptime
|
||||||
|
|
||||||
|
Vixy's design - Day 85 (2026-01-25)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
import subprocess
|
||||||
|
import psutil
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from luma.core.interface.serial import i2c
|
||||||
|
|
||||||
|
# ---------- Config ----------
|
||||||
|
HOSTNAME = os.uname().nodename
|
||||||
|
ROTATE_180 = os.getenv("ROTATE_180", "0") == "1"
|
||||||
|
REFRESH_MS = int(os.getenv("REFRESH_MS", "500"))
|
||||||
|
|
||||||
|
# Thresholds for warning/critical
|
||||||
|
CPU_WARN, CPU_CRIT = 70, 90
|
||||||
|
MEM_WARN, MEM_CRIT = 75, 90
|
||||||
|
TEMP_WARN, TEMP_CRIT = 65, 80
|
||||||
|
|
||||||
|
# ---------- I2C / Device ----------
|
||||||
|
def _to_7bit(addr):
|
||||||
|
return addr >> 1 if addr in (0x78, 0x7A) else addr
|
||||||
|
|
||||||
|
def open_device():
|
||||||
|
rotate_val = 2 if ROTATE_180 else 0
|
||||||
|
addrs = []
|
||||||
|
if "OLED_ADDR" in os.environ:
|
||||||
|
try:
|
||||||
|
addrs.append(int(os.environ["OLED_ADDR"], 16))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
addrs += [0x3C, 0x3D, 0x78, 0x7A]
|
||||||
|
|
||||||
|
for raw in addrs:
|
||||||
|
addr = _to_7bit(raw)
|
||||||
|
try:
|
||||||
|
serial = i2c(port=1, address=addr)
|
||||||
|
try:
|
||||||
|
from luma.oled.device import ssd1306
|
||||||
|
return ssd1306(serial, width=128, height=64, rotate=rotate_val)
|
||||||
|
except Exception:
|
||||||
|
from luma.oled.device import sh1106
|
||||||
|
return sh1106(serial, width=128, height=64, rotate=rotate_val)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
raise SystemExit("No OLED found. Check wiring & i2cdetect.")
|
||||||
|
|
||||||
|
device = open_device()
|
||||||
|
W, H = device.width, device.height # 128x64
|
||||||
|
|
||||||
|
# ---------- Fonts ----------
|
||||||
|
FONT_PATHS = [
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
def best_font(size):
|
||||||
|
for p in FONT_PATHS:
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(p, size)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
FONT_SM = best_font(9)
|
||||||
|
FONT_MD = best_font(11)
|
||||||
|
|
||||||
|
# ---------- Helpers ----------
|
||||||
|
def get_temp_c():
|
||||||
|
"""Get CPU temperature in Celsius"""
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["vcgencmd", "measure_temp"], timeout=0.4).decode()
|
||||||
|
return float(out.split("=")[1].split("'")[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
temps = psutil.sensors_temperatures()
|
||||||
|
for key in ("cpu-thermal", "cpu_thermal", "soc_thermal"):
|
||||||
|
if key in temps and temps[key]:
|
||||||
|
return float(temps[key][0].current)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for zone in ("thermal_zone0", "thermal_zone1"):
|
||||||
|
p = f"/sys/class/thermal/{zone}/temp"
|
||||||
|
if os.path.exists(p):
|
||||||
|
try:
|
||||||
|
return int(open(p).read().strip()) / 1000.0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return float("nan")
|
||||||
|
|
||||||
|
def get_uptime_hours():
|
||||||
|
"""Get system uptime in hours"""
|
||||||
|
try:
|
||||||
|
with open("/proc/uptime", "r") as f:
|
||||||
|
return int(float(f.read().split()[0]) // 3600)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_pod_count():
|
||||||
|
"""Get number of pods running on this node"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['kubectl', 'get', 'pods', '--all-namespaces',
|
||||||
|
'--field-selector', f'spec.nodeName={HOSTNAME}',
|
||||||
|
'-o', 'name'],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
pods = [p for p in result.stdout.strip().split('\n') if p]
|
||||||
|
return len(pods)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_status(cpu, mem, temp):
|
||||||
|
"""Determine overall health status"""
|
||||||
|
if cpu >= CPU_CRIT or mem >= MEM_CRIT or temp >= TEMP_CRIT:
|
||||||
|
return 'CRIT'
|
||||||
|
elif cpu >= CPU_WARN or mem >= MEM_WARN or temp >= TEMP_WARN:
|
||||||
|
return 'WARN'
|
||||||
|
return 'OK'
|
||||||
|
|
||||||
|
def draw_bar(draw, x, y, width, height, percent, warn_thresh, crit_thresh):
|
||||||
|
"""Draw a horizontal progress bar"""
|
||||||
|
# Background (empty bar)
|
||||||
|
draw.rectangle([x, y, x + width, y + height], outline=255, fill=0)
|
||||||
|
|
||||||
|
# Fill based on percentage
|
||||||
|
fill_width = int((width - 2) * min(100, percent) / 100)
|
||||||
|
if fill_width > 0:
|
||||||
|
draw.rectangle([x + 1, y + 1, x + 1 + fill_width, y + height - 1], fill=255)
|
||||||
|
|
||||||
|
def draw_pod_indicators(draw, x, y, count, expected=2):
|
||||||
|
"""Draw pod status circles"""
|
||||||
|
for i in range(expected):
|
||||||
|
cx = x + i * 10
|
||||||
|
if i < count:
|
||||||
|
# Filled circle for running pod
|
||||||
|
draw.ellipse([cx, y, cx + 6, y + 6], fill=255, outline=255)
|
||||||
|
else:
|
||||||
|
# Empty circle for missing pod
|
||||||
|
draw.ellipse([cx, y, cx + 6, y + 6], fill=0, outline=255)
|
||||||
|
|
||||||
|
# ---------- Main Loop ----------
|
||||||
|
psutil.cpu_percent(None) # Prime CPU sampler
|
||||||
|
|
||||||
|
# Layout constants
|
||||||
|
BAR_X = 28 # Bar start X (after label)
|
||||||
|
BAR_W = 70 # Bar width
|
||||||
|
BAR_H = 7 # Bar height
|
||||||
|
LINE_H = 14 # Line height
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Gather stats
|
||||||
|
cpu = psutil.cpu_percent(interval=None)
|
||||||
|
mem = psutil.virtual_memory().percent
|
||||||
|
temp = get_temp_c()
|
||||||
|
temp_val = 0 if math.isnan(temp) else int(round(temp))
|
||||||
|
uptime_h = get_uptime_hours()
|
||||||
|
pods = get_pod_count()
|
||||||
|
status = get_status(cpu, mem, temp_val)
|
||||||
|
|
||||||
|
# Create frame
|
||||||
|
image = Image.new("1", (W, H), 0)
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
|
# === Row 0: Header ===
|
||||||
|
# Hostname left, pods center, uptime+status right
|
||||||
|
draw.text((0, 0), HOSTNAME, font=FONT_MD, fill=255)
|
||||||
|
|
||||||
|
# Pod indicators (center-ish)
|
||||||
|
draw_pod_indicators(draw, 58, 2, pods, expected=2)
|
||||||
|
|
||||||
|
# Uptime + status indicator
|
||||||
|
uptime_str = f"{uptime_h}h"
|
||||||
|
status_char = "●" if status == "OK" else ("◐" if status == "WARN" else "○")
|
||||||
|
draw.text((90, 0), f"{uptime_str}{status_char}", font=FONT_SM, fill=255)
|
||||||
|
|
||||||
|
# === Separator line ===
|
||||||
|
draw.line([(0, 12), (W, 12)], fill=255)
|
||||||
|
|
||||||
|
# === Row 1: CPU ===
|
||||||
|
y = 16
|
||||||
|
draw.text((0, y), "CPU", font=FONT_SM, fill=255)
|
||||||
|
draw_bar(draw, BAR_X, y + 1, BAR_W, BAR_H, cpu, CPU_WARN, CPU_CRIT)
|
||||||
|
draw.text((BAR_X + BAR_W + 3, y), f"{int(cpu):2d}%", font=FONT_SM, fill=255)
|
||||||
|
|
||||||
|
# === Row 2: MEM ===
|
||||||
|
y = 16 + LINE_H
|
||||||
|
draw.text((0, y), "MEM", font=FONT_SM, fill=255)
|
||||||
|
draw_bar(draw, BAR_X, y + 1, BAR_W, BAR_H, mem, MEM_WARN, MEM_CRIT)
|
||||||
|
draw.text((BAR_X + BAR_W + 3, y), f"{int(mem):2d}%", font=FONT_SM, fill=255)
|
||||||
|
|
||||||
|
# === Row 3: TMP ===
|
||||||
|
y = 16 + LINE_H * 2
|
||||||
|
draw.text((0, y), "TMP", font=FONT_SM, fill=255)
|
||||||
|
# Temp bar: 0-100°C range
|
||||||
|
temp_pct = min(100, max(0, temp_val))
|
||||||
|
draw_bar(draw, BAR_X, y + 1, BAR_W, BAR_H, temp_pct, TEMP_WARN, TEMP_CRIT)
|
||||||
|
draw.text((BAR_X + BAR_W + 3, y), f"{temp_val:2d}°", font=FONT_SM, fill=255)
|
||||||
|
|
||||||
|
# === Bottom separator ===
|
||||||
|
draw.line([(0, 51), (W, 51)], fill=255)
|
||||||
|
|
||||||
|
# === Row 4: Status summary ===
|
||||||
|
y = 54
|
||||||
|
if status == "OK":
|
||||||
|
draw.text((W // 2 - 10, y), "● OK", font=FONT_SM, fill=255)
|
||||||
|
elif status == "WARN":
|
||||||
|
draw.text((W // 2 - 16, y), "◐ WARN", font=FONT_SM, fill=255)
|
||||||
|
else:
|
||||||
|
draw.text((W // 2 - 16, y), "○ CRIT", font=FONT_SM, fill=255)
|
||||||
|
|
||||||
|
# Display
|
||||||
|
device.display(image)
|
||||||
|
time.sleep(REFRESH_MS / 1000.0)
|
||||||
Reference in New Issue
Block a user