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:
2026-01-25 14:44:32 -06:00
commit 7901088155
8 changed files with 1072 additions and 0 deletions

70
elitedesk/README.md Normal file
View 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?

View 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

View 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
View 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()