From 5117a9ef4dc03ece9289eb440d46c2da926baf66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomislav=20Kopi=C4=87?= Date: Mon, 9 Feb 2026 22:07:10 +0100 Subject: [PATCH] first commit --- PiScope.py | 264 +++++++++++++++++++++++++++++++++++++++++++++++ Readme.md | 27 +++++ install.sh | 43 ++++++++ piscope.service | 14 +++ requirements.txt | 4 + 5 files changed, 352 insertions(+) create mode 100755 PiScope.py create mode 100644 Readme.md create mode 100644 install.sh create mode 100644 piscope.service create mode 100644 requirements.txt diff --git a/PiScope.py b/PiScope.py new file mode 100755 index 0000000..5bd7f85 --- /dev/null +++ b/PiScope.py @@ -0,0 +1,264 @@ +import time +import os +import psutil +import socket +import subprocess +from datetime import datetime +from collections import deque +from luma.core.interface.serial import i2c +from luma.oled.device import ssd1306 +from PIL import Image, ImageDraw, ImageFont + +# ---------------- CONSTANTS ---------------- +I2C_PORT = 3 +I2C_ADDR = 0x3C + +CPU_HISTORY_LEN = 100 +UPDATE_INTERVAL = 0.5 + +GRAPH_HEIGHT = 30 +GRAPH_FOOTER_HEIGHT = 10 + +RAM_BAR_WIDTH = 25 +RAM_BAR_HEIGHT = 52 + +FS_BOX = (64, 42, 100, 63) +FS_PIE_CENTER = (74, 52) +FS_PIE_RADIUS = 9 + +scroll_offset = 0 +SCROLL_SPEED = 2 # pixels per update + +# ---------------- OLED SETUP ---------------- +serial = i2c(port=I2C_PORT, address=I2C_ADDR) +device = ssd1306(serial) +WIDTH, HEIGHT = device.width, device.height + +cpu_history = deque([0] * CPU_HISTORY_LEN, maxlen=CPU_HISTORY_LEN) + +try: + small_font = ImageFont.truetype("DejaVuSans.ttf", 10) +except IOError: + small_font = ImageFont.load_default() + +# ---------------- HELPERS ---------------- +def get_cpu_temp(): + temps = psutil.sensors_temperatures() + if temps: + return next(iter(temps.values()))[0].current + try: + with open("/sys/class/thermal/thermal_zone0/temp") as f: + return int(f.read()) / 1000 + except OSError: + return 0.0 + +def get_root_fs_usage(): + return psutil.disk_usage("/").percent + +def format_uptime(): + boot = datetime.fromtimestamp(psutil.boot_time()) + seconds = int((datetime.now() - boot).total_seconds()) + hours, rem = divmod(seconds, 3600) + minutes, _ = divmod(rem, 60) + return hours, minutes + +def truncate_text(text, max_len=8): + if len(text) <= max_len: + return text + return text[:max_len - 1] + "…" + +def get_wifi_info(interface="wlan0"): + try: + result = subprocess.run( + ["iw", "dev", interface, "link"], + capture_output=True, + text=True + ) + + if "Not connected" in result.stdout: + return "--", 0 + + ssid = "--" + signal_dbm = None + + for line in result.stdout.splitlines(): + line = line.strip() + + if line.startswith("SSID:"): + ssid = line.split("SSID:")[1].strip() + + if line.startswith("signal:"): + signal_dbm = int(line.split()[1]) + + # Convert dBm → percentage (rough but standard) + if signal_dbm is not None: + signal_pct = max(0, min(100, 2 * (signal_dbm + 100))) + else: + signal_pct = 0 + + return truncate_text(ssid), signal_pct + + except Exception: + return "--", 0 + +def estimate_power_w(cpu_percent): + # Constants (from measurement) + BASE_POWER = 1.9 # W + IDLE_FREQ_POWER = 0.3 # W at max freq + MAX_CPU_POWER = 3.0 # W at full load + max freq + + MIN_FREQ = 614.0 # MHz + MAX_FREQ = 1600.0 # MHz + + # Read current frequency (assume all cores similar) + with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") as f: + freq_mhz = int(f.read()) / 1000 + + freq_factor = max( + 0.0, + min(1.0, (freq_mhz - MIN_FREQ) / (MAX_FREQ - MIN_FREQ)) + ) + + load = cpu_percent / 100.0 + + power = ( + BASE_POWER + + IDLE_FREQ_POWER * freq_factor + + MAX_CPU_POWER * freq_factor * load + ) + + return round(power, 2) + +# ---------------- DRAWING ---------------- +def draw_cpu_graph(draw, cpu, load, temp, watts): + draw.rectangle((0, 0, CPU_HISTORY_LEN, GRAPH_HEIGHT), outline=255) + + if len(cpu_history) > 1: + points = [ + (i, GRAPH_HEIGHT - (v / 100 * GRAPH_HEIGHT)) + for i, v in enumerate(cpu_history) + ] + points += [(len(cpu_history) - 1, GRAPH_HEIGHT), (0, GRAPH_HEIGHT)] + draw.polygon(points, fill=255) + + footer_top = GRAPH_HEIGHT + footer_bottom = GRAPH_HEIGHT + GRAPH_FOOTER_HEIGHT + + draw.rectangle((0, footer_top, CPU_HISTORY_LEN, footer_bottom), outline=255) + draw.rectangle((0, footer_top, 25, footer_bottom), fill=255) + + draw.text((1, footer_top - 1), "CPU", font=small_font, fill=0) + draw.line((59, footer_top, 59, footer_bottom), fill=255, width=2) + + load_text = f"{watts:.1f} W" + lw = draw.textbbox((0, 0), load_text, font=small_font)[2] + load_x = 25 + (59 - 25 - lw) // 2 + draw.text((load_x, footer_top - 1), load_text, font=small_font, fill=255) + + cpu_text = f"{cpu:.0f}%" + cw = draw.textbbox((0, 0), cpu_text, font=small_font)[2] + cpu_x = 59 + (CPU_HISTORY_LEN - 59 - cw) // 2 + draw.text((cpu_x, footer_top - 1), cpu_text, font=small_font, fill=255) + + draw.rectangle((0, 0, 26, 10), fill=255) + draw.text((1, -1), f"{temp:.0f}°C", font=small_font, fill=0) + +def draw_ram_bar(draw, mem_percent): + x = WIDTH - 1 - RAM_BAR_WIDTH + y = 0 + + draw.rectangle((x, y, WIDTH - 1, RAM_BAR_HEIGHT), outline=255) + + fill_h = int((mem_percent / 100) * RAM_BAR_HEIGHT) + draw.rectangle( + (x, RAM_BAR_HEIGHT - fill_h, WIDTH - 1, RAM_BAR_HEIGHT), + fill=255 + ) + + label = f"{mem_percent:.0f}%" + tw = draw.textbbox((0, 0), label, font=small_font)[2] + draw.text((x + (RAM_BAR_WIDTH - tw) // 2 + 1, + RAM_BAR_HEIGHT - fill_h - 11), + label, font=small_font, fill=255) + + draw.rectangle((x, RAM_BAR_HEIGHT, WIDTH - 1, HEIGHT - 1), outline=0, fill=255) + draw.text((103, 52), "RAM", font=small_font, fill=0) + +def draw_fs_pie(draw, used_pct): + draw.rectangle(FS_BOX, outline=255) + + cx, cy = FS_PIE_CENTER + r = FS_PIE_RADIUS + bbox = (cx - r, cy - r, cx + r, cy + r) + + draw.ellipse(bbox, outline=255) + angle = int(360 * (used_pct / 100)) + + draw.pieslice(bbox, start=270, end=270 + angle, fill=255) + draw.text((86, 42), "FS", font=small_font, fill=255) + draw.text((90, 51), "/", font=small_font, fill=255) + +def draw_uptime(draw, hours, minutes): + draw.rectangle((-1, 53, 62, HEIGHT), outline=255) + draw.text((0, 53), f"up: {hours}h {minutes}m", + font=small_font, fill=255) + +def draw_wifi_icon(draw, ssid, signal_pct, x=0, y=0): + bars = 4 + bar_width = 2 + bar_spacing = 1 + max_height = 8 # tallest bar + filled_bars = int(signal_pct / 100 * bars + 0.5) + + for i in range(bars): + bar_height = int((i + 1) / bars * max_height) + y_top = y + (max_height - bar_height) + fill = 1 if i < filled_bars else 0 + draw.rectangle( + (x + i * (bar_width + bar_spacing), y_top, + x + i * (bar_width + bar_spacing) + bar_width, + y + max_height), + fill=fill + ) + + # Draw SSID text right next to icon + text_x = x + bars * (bar_width + bar_spacing) + 1 + draw.text((text_x, y-3), ssid, font=small_font, fill=255) + +# ---------------- MAIN UPDATE ---------------- +def update_display(): + cpu = psutil.cpu_percent(interval=0.1) + watts = estimate_power_w(cpu) + mem = psutil.virtual_memory().percent + load1, _, _ = os.getloadavg() + cpu_history.append(cpu) + + temp = get_cpu_temp() + fs_used = get_root_fs_usage() + hours, minutes = format_uptime() + + image = Image.new("1", (WIDTH, HEIGHT)) + draw = ImageDraw.Draw(image) + + draw.rectangle((0, 0, WIDTH, HEIGHT), fill=0) + + ssid, signal = get_wifi_info("wlan0") + draw_wifi_icon(draw, ssid, signal, x=0, y=43) + draw_cpu_graph(draw, cpu, load1, temp, watts) + draw_ram_bar(draw, mem) + draw_fs_pie(draw, fs_used) + draw_uptime(draw, hours, minutes) + + device.display(image) + +# ---------------- MAIN LOOP ---------------- +if __name__ == "__main__": + try: + for _ in range(CPU_HISTORY_LEN): + cpu_history.append(psutil.cpu_percent(interval=0.01)) + + while True: + update_display() + time.sleep(UPDATE_INTERVAL) + except KeyboardInterrupt: + device.clear() diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..eedb1f9 --- /dev/null +++ b/Readme.md @@ -0,0 +1,27 @@ +# PiScope – Orange Pi OLED System Monitor + +![PiScope OLED Setup](./oled.png) +*Example OLED setup on Orange Pi RV2* + +PiScope is a lightweight system monitor for the Orange Pi, displaying real-time CPU, memory, and system stats on a SSD1306 OLED screen via I2C. + +--- +## Installation + +Run the installer as root: + +```bash +sudo ./install.sh +``` + +### Manual Setup Steps + +1. Run `orange-pi-config` +2. Enable **I2C** overlay +3. Reboot the system +4. Connect OLED to I2C3 pins: + + * SDA → I2C SDA + * SCL → I2C SCL + * 3.3V → OLED VCC + * GND → OLED GND diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..3c37ca3 --- /dev/null +++ b/install.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e + +INSTALL_DIR="/opt/PiScope" +VENV_DIR="$INSTALL_DIR/venv" +SERVICE_FILE="piscope.service" + +echo "=== Orange Pi OLED Monitor Installer ===" + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root (sudo ./install.sh)" + exit 1 +fi + +echo "[1/6] Creating $INSTALL_DIR" +mkdir -p "$INSTALL_DIR" + +echo "[2/6] Copying files" +cp PiScope.py requirements.txt "$INSTALL_DIR/" +cp "$SERVICE_FILE" "$INSTALL_DIR/" + +echo "[3/6] Creating Python virtual environment" +python3 -m venv "$VENV_DIR" + +echo "[4/6] Installing Python dependencies" +"$VENV_DIR/bin/pip" install --upgrade pip +"$VENV_DIR/bin/pip" install -r "$INSTALL_DIR/requirements.txt" + +echo "[5/6] Installing systemd service" +cp "$INSTALL_DIR/$SERVICE_FILE" /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now piscope.service + +echo "[6/6] Installation complete!" +echo +echo "MANUAL STEPS REQUIRED:" +echo "1) Run: orange-pi-config" +echo "2) Enable I2C3" +echo "3) Reboot the system" +echo "4) Connect OLED to I2C3 pins (SDA/SCL + 3.3V + GND)" +echo +echo "Check logs with:" +echo " journalctl -u piscope.service -f" diff --git a/piscope.service b/piscope.service new file mode 100644 index 0000000..bcbdf15 --- /dev/null +++ b/piscope.service @@ -0,0 +1,14 @@ +[Unit] +Description=OLED System Stats Monitor +After=multi-user.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/PiScope +ExecStart=/bin/bash -c 'source /opt/PiScope/venv/bin/activate && exec python PiScope.py' +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..84020ea --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +luma.oled +luma.core +Pillow +psutil