commit 7901088155c172a44cc01b360768b1dee011da76 Author: vixy Date: Sun Jan 25 14:44:32 2026 -0600 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..124d244 --- /dev/null +++ b/README.md @@ -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 diff --git a/elitedesk/README.md b/elitedesk/README.md new file mode 100644 index 0000000..7541ce4 --- /dev/null +++ b/elitedesk/README.md @@ -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? diff --git a/elitedesk/daemon/install.sh b/elitedesk/daemon/install.sh new file mode 100644 index 0000000..527e98c --- /dev/null +++ b/elitedesk/daemon/install.sh @@ -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 diff --git a/elitedesk/daemon/node_stats_daemon.py b/elitedesk/daemon/node_stats_daemon.py new file mode 100644 index 0000000..3919b37 --- /dev/null +++ b/elitedesk/daemon/node_stats_daemon.py @@ -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() diff --git a/elitedesk/display/main.py b/elitedesk/display/main.py new file mode 100644 index 0000000..f872068 --- /dev/null +++ b/elitedesk/display/main.py @@ -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() diff --git a/pi/README.md b/pi/README.md new file mode 100644 index 0000000..ec94942 --- /dev/null +++ b/pi/README.md @@ -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 | diff --git a/pi/daemon/install.sh b/pi/daemon/install.sh new file mode 100644 index 0000000..1e7c731 --- /dev/null +++ b/pi/daemon/install.sh @@ -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 diff --git a/pi/daemon/pi_stats_display.py b/pi/daemon/pi_stats_display.py new file mode 100644 index 0000000..bf5bf87 --- /dev/null +++ b/pi/daemon/pi_stats_display.py @@ -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)