roboattic Lab
Published © GPL3+

This Display Shows My PC Stats Using Real Ink

I built a tiny e-ink monitor that sits on my desk and shows live PC stats. No glare, no backlight, just a calm little screen that does its

BeginnerFull instructions provided5 hours50
This Display Shows My PC Stats Using Real Ink

Things used in this project

Hardware components

Seeed Studio XIAO ESP32S3 Sense
Seeed Studio XIAO ESP32S3 Sense
×1

Software apps and online services

Arduino IDE
Arduino IDE
Fusion
Autodesk Fusion

Story

Read more

Schematics

Circuit Diagram

Code

Hardware Firmware

C/C++
/*
 * ============================================================
 *  Code by: Shahbaz Hashmi Ansari
 * ============================================================
 */


#include <GxEPD2_BW.h>
#include <Adafruit_GFX.h>
#include <ArduinoJson.h>
#include <SPI.h>
#include <Fonts/FreeSansBold9pt7b.h>
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeMonoBold9pt7b.h>
#include <Fonts/FreeSansBold12pt7b.h>

// ── Pin Definitions ──
#define EPD_BUSY  1   
#define EPD_RST   2   
#define EPD_DC    3   
#define EPD_CS    4   


GxEPD2_BW<GxEPD2_154_D67, GxEPD2_154_D67::HEIGHT> display(
  GxEPD2_154_D67(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY)
);


float cpu_percent = 0;
int   cpu_freq    = 0;
float ram_percent = 0;
float ram_used    = 0;
float ram_total   = 0;
float dsk_percent = 0;
float dsk_used    = 0;
float dsk_total   = 0;
float net_up      = 0;
float net_down    = 0;
int   bat_percent = -1;
bool  bat_plugged = false;
int   uptime_h    = 0;
int   uptime_m    = 0;

// ── Serial Buffer ──
#define BUF_SIZE 768
char serialBuf[BUF_SIZE];
int  bufIdx      = 0;
int  braceDepth  = 0;
bool inJson      = false;

// ── Refresh Management ──
int  partialCount    = 0;
const int FULL_EVERY = 30;
bool firstDraw       = true;

// ============================================================
//  ICON HELPERS
// ============================================================

void drawIconCPU(int x, int y) {
  display.fillRoundRect(x + 2, y + 2, 10, 10, 1, GxEPD_BLACK);
  display.fillRect(x + 4, y + 4, 6, 6, GxEPD_WHITE);
  display.fillRect(x + 5, y + 5, 4, 4, GxEPD_BLACK);

  for (int i = 0; i < 3; i++) {
    display.fillRect(x + 4 + i * 3, y, 1, 2, GxEPD_BLACK);
    display.fillRect(x + 4 + i * 3, y + 12, 1, 2, GxEPD_BLACK);
    display.fillRect(x, y + 4 + i * 3, 2, 1, GxEPD_BLACK);
    display.fillRect(x + 12, y + 4 + i * 3, 2, 1, GxEPD_BLACK);
  }
}

void drawIconRAM(int x, int y) {
  display.drawRect(x, y + 3, 14, 8, GxEPD_BLACK);
  display.fillRect(x + 2, y + 5, 2, 4, GxEPD_BLACK);
  display.fillRect(x + 6, y + 5, 2, 4, GxEPD_BLACK);
  display.fillRect(x + 10, y + 5, 2, 4, GxEPD_BLACK);
  display.fillRect(x + 5, y + 9, 4, 2, GxEPD_WHITE);
}

void drawIconDisk(int x, int y) {
  display.drawCircle(x + 7, y + 7, 6, GxEPD_BLACK);
  display.drawCircle(x + 7, y + 7, 5, GxEPD_BLACK);
  display.fillCircle(x + 7, y + 7, 2, GxEPD_BLACK);
  display.drawLine(x + 7, y + 7, x + 11, y + 4, GxEPD_BLACK);
}

void drawIconNet(int x, int y) {
  display.fillTriangle(x + 3, y + 6, x, y + 6, x + 3, y, GxEPD_BLACK);
  display.fillRect(x + 2, y + 6, 3, 4, GxEPD_BLACK);

  display.fillTriangle(x + 10, y + 8, x + 7, y + 8, x + 10, y + 14, GxEPD_BLACK);
  display.fillRect(x + 9, y + 4, 3, 4, GxEPD_BLACK);
}

void drawIconBat(int x, int y) {
  display.drawRect(x, y + 3, 11, 8, GxEPD_BLACK);
  display.fillRect(x + 11, y + 5, 2, 4, GxEPD_BLACK);

  if (bat_percent >= 0) {
    int fill = map(constrain(bat_percent, 0, 100), 0, 100, 0, 9);
    display.fillRect(x + 1, y + 4, fill, 6, GxEPD_BLACK);
  }
}

void drawIconClock(int x, int y) {
  display.drawCircle(x + 7, y + 7, 6, GxEPD_BLACK);
  display.drawCircle(x + 7, y + 7, 5, GxEPD_BLACK);
  display.fillCircle(x + 7, y + 7, 1, GxEPD_BLACK);
  display.drawLine(x + 7, y + 7, x + 7, y + 3, GxEPD_BLACK);
  display.drawLine(x + 7, y + 7, x + 11, y + 7, GxEPD_BLACK);
}

// ============================================================
//  UI HELPERS
// ============================================================

void drawProgressBar(int x, int y, int w, int h, float percent) {
  display.drawRect(x, y, w, h, GxEPD_BLACK);
  int filled = (int)((w - 2) * constrain(percent, 0, 100) / 100.0);
  if (filled > 0) {
    display.fillRect(x + 1, y + 1, filled, h - 2, GxEPD_BLACK);
  }
}

void drawRightAligned(const char* text, int rightX, int topY) {
  int16_t x1, y1;
  uint16_t tw, th;
  display.getTextBounds(text, 0, topY, &x1, &y1, &tw, &th);
  display.setCursor(rightX - tw, topY);
  display.print(text);
}

void drawCentered(const char* text, int centerX, int topY) {
  int16_t x1, y1;
  uint16_t tw, th;
  display.getTextBounds(text, 0, topY, &x1, &y1, &tw, &th);
  display.setCursor(centerX - (tw / 2), topY);
  display.print(text);
}

void drawPanel(int x, int y, int w, int h, const char* title) {
  display.fillRoundRect(x, y, w, h, 3, GxEPD_WHITE);
  display.drawRoundRect(x, y, w, h, 3, GxEPD_BLACK);

  display.fillRoundRect(x, y, w, 11, 3, GxEPD_BLACK);
  display.fillRect(x, y + 8, w, 3, GxEPD_BLACK); 
  
  display.setFont(NULL);
  display.setTextColor(GxEPD_WHITE);
  display.setCursor(x + 4, y + 2); 
  display.print(title);
  
  display.setTextColor(GxEPD_BLACK); // Crucial to prevent invisible text later
}

void drawPill(int x, int y, int w, int h, const char* text) {
  display.fillRoundRect(x, y, w, h, 4, GxEPD_WHITE);
  display.drawRoundRect(x, y, w, h, 4, GxEPD_BLACK);
  display.setFont(NULL);
  display.setTextColor(GxEPD_BLACK);
  display.setCursor(x + 6, y + 3); 
  display.print(text);
}

// ============================================================
//  SPLASH SCREEN
// ============================================================

void drawSplashScreen() {
  display.setFullWindow();
  display.firstPage();
  do {
    display.fillScreen(GxEPD_WHITE);

    display.drawRect(0, 0, 200, 200, GxEPD_BLACK);
    display.drawRect(2, 2, 196, 196, GxEPD_BLACK);

    display.fillRect(4, 4, 14, 2, GxEPD_BLACK);
    display.fillRect(4, 4, 2, 14, GxEPD_BLACK);
    display.fillRect(182, 4, 14, 2, GxEPD_BLACK);
    display.fillRect(194, 4, 2, 14, GxEPD_BLACK);
    display.fillRect(4, 194, 14, 2, GxEPD_BLACK);
    display.fillRect(4, 182, 2, 14, GxEPD_BLACK);
    display.fillRect(182, 194, 14, 2, GxEPD_BLACK);
    display.fillRect(194, 182, 2, 14, GxEPD_BLACK);

    display.fillRoundRect(88, 28, 24, 24, 2, GxEPD_BLACK);
    display.fillRect(92, 32, 16, 16, GxEPD_WHITE);
    display.fillRect(95, 35, 10, 10, GxEPD_BLACK);

    for (int i = 0; i < 4; i++) {
      display.fillRect(92 + i * 5, 24, 2, 4, GxEPD_BLACK);
      display.fillRect(92 + i * 5, 52, 2, 4, GxEPD_BLACK);
      display.fillRect(84, 32 + i * 5, 4, 2, GxEPD_BLACK);
      display.fillRect(112, 32 + i * 5, 4, 2, GxEPD_BLACK);
    }

    display.setTextColor(GxEPD_BLACK);

    display.setFont(&FreeSansBold12pt7b);
    drawCentered("PC MONITOR", 100, 90);

    display.fillRect(36, 98, 128, 1, GxEPD_BLACK);
    display.fillRect(56, 101, 88, 1, GxEPD_BLACK);

    display.setFont(&FreeSans9pt7b);
    drawCentered("E-Ink Dashboard", 100, 122);

    display.fillRect(66, 132, 68, 1, GxEPD_BLACK);

    display.setFont(NULL);
    const char* line1 = "XIAO ESP32-S3";
    const char* line2 = "WeAct 1.54\" 200x200 BW";
    const char* line3 = "Awaiting PC Data";

    display.setCursor((200 - strlen(line1) * 6) / 2, 146);
    display.print(line1);
    display.setCursor((200 - strlen(line2) * 6) / 2, 158);
    display.print(line2);
    display.setCursor((200 - strlen(line3) * 6) / 2, 176);
    display.print(line3);

  } while (display.nextPage());
}

// ============================================================
//  DASHBOARD DRAWING
// ============================================================

void drawDashboard() {
  char textBuf[48];

  bool doFullRefresh = firstDraw || (partialCount >= FULL_EVERY);

  if (doFullRefresh) {
    display.setFullWindow();
    partialCount = 0;
  } else {
    display.setPartialWindow(0, 0, 200, 200);
    partialCount++;
  }

  display.firstPage();
  do {
    display.fillScreen(GxEPD_WHITE);
    display.drawRect(0, 0, 200, 200, GxEPD_BLACK);

    // Header
    display.fillRect(0, 0, 200, 20, GxEPD_BLACK);
    display.setFont(&FreeSansBold9pt7b);
    display.setTextColor(GxEPD_WHITE);
    display.setCursor(8, 14);
    display.print("PC MONITOR");
    drawPill(160, 3, 34, 14, "LIVE");

    // ───────────────── CPU HERO CARD ─────────────────
    drawPanel(4, 24, 192, 48, "CPU");

    drawIconCPU(8, 38);

    display.setFont(&FreeSansBold12pt7b);
    snprintf(textBuf, sizeof(textBuf), "%d%%", (int)cpu_percent);
    display.setCursor(28, 56); 
    display.print(textBuf);

    display.setFont(NULL);
    snprintf(textBuf, sizeof(textBuf), "%.2f GHz", cpu_freq / 1000.0);
    drawRightAligned(textBuf, 192, 44); 

    // THICKER CPU BAR (Height 10px)
    drawProgressBar(8, 59, 184, 10, cpu_percent);

    // ───────────────── RAM + DISK ─────────────────
    drawPanel(4, 76, 94, 42, "RAM");
    drawPanel(102, 76, 94, 42, "DISK");

    // RAM Panel
    drawIconRAM(8, 92);
    display.setFont(&FreeSansBold9pt7b);
    snprintf(textBuf, sizeof(textBuf), "%d%%", (int)ram_percent);
    display.setCursor(28, 104);
    display.print(textBuf);

    // THICKER RAM BAR (Height 8px)
    drawProgressBar(8, 107, 80, 8, ram_percent);

    // DISK Panel
    drawIconDisk(106, 92);
    display.setFont(&FreeSansBold9pt7b);
    snprintf(textBuf, sizeof(textBuf), "%d%%", (int)dsk_percent);
    display.setCursor(126, 104); 
    display.print(textBuf);

    // THICKER DISK BAR (Height 8px)
    drawProgressBar(106, 107, 80, 8, dsk_percent);

    // ───────────────── NET + BATTERY ─────────────────
    drawPanel(4, 122, 94, 38, "NET");
    drawPanel(102, 122, 94, 38, "BAT");

    drawIconNet(8, 137);
    display.setFont(NULL);
    
    // NET UP Dynamic GB/MB Check
    display.setCursor(28, 136);
    display.print("UP ");
    if (net_up > 999.0) {
      snprintf(textBuf, sizeof(textBuf), "%.2f GB", net_up / 1024.0);
    } else {
      snprintf(textBuf, sizeof(textBuf), "%.1f MB", net_up);
    }
    display.print(textBuf);

    // NET DOWN Dynamic GB/MB Check
    display.setCursor(28, 148);
    display.print("DN ");
    if (net_down > 999.0) {
      snprintf(textBuf, sizeof(textBuf), "%.2f GB", net_down / 1024.0);
    } else {
      snprintf(textBuf, sizeof(textBuf), "%.1f MB", net_down);
    }
    display.print(textBuf);

    drawIconBat(106, 137);
    if (bat_percent >= 0) {
      display.setFont(&FreeSansBold9pt7b);
      snprintf(textBuf, sizeof(textBuf), "%d%%", bat_percent);
      display.setCursor(126, 148);
      display.print(textBuf);

      display.setFont(NULL);
      display.setCursor(126, 150); 
      display.print(bat_plugged ? "Charging" : "On Battery");
    } else {
      display.setFont(NULL);
      display.setCursor(126, 142);
      display.print("No Battery");
    }

    // ───────────────── UPTIME STRIP ─────────────────
    drawPanel(4, 164, 192, 34, "UPTIME");

    drawIconClock(8, 178);
    display.setFont(&FreeSansBold9pt7b);
    snprintf(textBuf, sizeof(textBuf), "%dh%02dm", uptime_h, uptime_m);
    display.setCursor(28, 192); 
    display.print(textBuf);

    display.setFont(NULL);
    drawRightAligned("System runtime", 192, 180);

  } while (display.nextPage());

  firstDraw = false;
}

// ============================================================
//  JSON PARSING
// ============================================================

void parseStats(const char* json) {
  StaticJsonDocument<512> doc;
  DeserializationError err = deserializeJson(doc, json);
  if (err) {
    Serial.print("JSON Error: ");
    Serial.println(err.c_str());
    return;
  }

  cpu_percent = doc["cpu"]   | 0.0f;
  cpu_freq    = doc["freq"]  | 0;
  ram_percent = doc["ram_p"] | 0.0f;
  ram_used    = doc["ram_u"] | 0.0f;
  ram_total   = doc["ram_t"] | 0.0f;
  dsk_percent = doc["dsk_p"] | 0.0f;
  dsk_used    = doc["dsk_u"] | 0.0f;
  dsk_total   = doc["dsk_t"] | 0.0f;
  net_up      = doc["net_u"] | 0.0f;
  net_down    = doc["net_d"] | 0.0f;
  bat_percent = doc["bat"]   | -1;
  bat_plugged = doc["plug"]  | false;
  uptime_h    = doc["up_h"]  | 0;
  uptime_m    = doc["up_m"]  | 0;

  Serial.println("Data received — updating display...");
  drawDashboard();
}

// ============================================================
//  SERIAL READING
// ============================================================

void readSerial() {
  while (Serial.available()) {
    char ch = Serial.read();

    if (ch == '{') {
      inJson = true;
      braceDepth = 1;
      bufIdx = 0;
      serialBuf[bufIdx++] = ch;
    } else if (inJson) {
      serialBuf[bufIdx++] = ch;

      if (ch == '{') braceDepth++;
      else if (ch == '}') braceDepth--;

      if (braceDepth == 0) {
        serialBuf[bufIdx] = '\0';
        parseStats(serialBuf);
        inJson = false;
        bufIdx = 0;
      }

      if (bufIdx >= BUF_SIZE - 1) {
        bufIdx = 0;
        inJson = false;
        Serial.println("Buffer overflow — reset");
      }
    }
  }
}

// ============================================================
//  SETUP & LOOP
// ============================================================

void setup() {
  Serial.begin(115200);
  delay(1000);

  SPI.begin(7, 8, 9, 4);
  display.init(115200);
  display.setRotation(1);
  display.setTextWrap(false);

  drawSplashScreen();

  Serial.println("PC Stats E-Ink Monitor ready!");
  Serial.println("Waiting for data from main.py...");
}

void loop() {
  readSerial();
}

Computer Script

Python
import serial
import serial.tools.list_ports
import psutil
import time
import json
import sys


# ══════════════════════════════════════════════════════════════
#  CONFIGURATION
# ══════════════════════════════════════════════════════════════

SERIAL_PORT    = None       # Set to 'COM4' or '/dev/ttyACM0' to override auto-detect
BAUD_RATE      = 115200
UPDATE_INTERVAL = 10


# ══════════════════════════════════════════════════════════════
#  AUTO-DETECT ESP32 PORT
# ══════════════════════════════════════════════════════════════

def find_esp32_port():
    """Auto-detect the XIAO ESP32-S3 serial port."""
    ports = serial.tools.list_ports.comports()
    
    # Keywords that identify ESP32/XIAO boards
    keywords = ['esp32', 'esp', 'xiao', 'usb serial', 'cp210', 'ch340', 'usb-serial', 'jtag']
    
    for port in ports:
        desc = (port.description or '').lower()
        hwid = (port.hwid or '').lower()
        
        for kw in keywords:
            if kw in desc or kw in hwid:
                return port.device
    
    # If no keyword match, list available ports
    if ports:
        print("\n⚠  Could not auto-detect ESP32. Available ports:")
        for p in ports:
            print(f"   {p.device}{p.description}")
        print(f"\nUsing first available: {ports[0].device}")
        return ports[0].device
    
    return None


# ══════════════════════════════════════════════════════════════
#  COLLECT PC STATS
# ══════════════════════════════════════════════════════════════

# Globals to track network speed over time
last_net_io = None
last_net_time = None

def get_stats():
    """Gather system performance data and return as a compact dict."""
    global last_net_io, last_net_time
    
    # CPU
    cpu_percent = psutil.cpu_percent(interval=0.5)
    cpu_freq_obj = psutil.cpu_freq()
    cpu_freq = int(cpu_freq_obj.current) if cpu_freq_obj else 0
    
    # RAM
    mem = psutil.virtual_memory()
    ram_percent = round(mem.percent, 1)
    ram_used = round(mem.used / (1024 ** 3), 1)     # GB
    ram_total = round(mem.total / (1024 ** 3), 1)    # GB
    
    # Disk (primary drive)
    try:
        disk = psutil.disk_usage('C:\\' if sys.platform == 'win32' else '/')
    except Exception:
        disk = psutil.disk_usage('/')
    
    dsk_percent = round(disk.percent, 1)
    dsk_used = round(disk.used / (1024 ** 3), 1)    # GB
    dsk_total = round(disk.total / (1024 ** 3), 1)   # GB
    
    # Network (Real-time Speed Calculation in MB/s)
    current_net_io = psutil.net_io_counters()
    current_time = time.time()
    
    if last_net_io is None:
        # First run: establish baseline
        net_up_speed = 0.0
        net_down_speed = 0.0
    else:
        # Calculate delta
        time_delta = current_time - last_net_time
        bytes_sent = current_net_io.bytes_sent - last_net_io.bytes_sent
        bytes_recv = current_net_io.bytes_recv - last_net_io.bytes_recv
        
        # Convert to MB/s
        net_up_speed = (bytes_sent / (1024 ** 2)) / time_delta
        net_down_speed = (bytes_recv / (1024 ** 2)) / time_delta
        
        # Prevent negative spikes on network reset
        net_up_speed = max(0.0, net_up_speed)
        net_down_speed = max(0.0, net_down_speed)

    # Save current state for next loop
    last_net_io = current_net_io
    last_net_time = current_time
    
    # Battery
    battery = psutil.sensors_battery()
    bat_percent = int(battery.percent) if battery else -1
    bat_plugged = battery.power_plugged if battery else False
    
    # Uptime
    uptime_sec = time.time() - psutil.boot_time()
    uptime_h = int(uptime_sec // 3600)
    uptime_m = int((uptime_sec % 3600) // 60)
    
    # Compact JSON keys to reduce serial payload size
    return {
        "cpu":   cpu_percent,
        "freq":  cpu_freq,
        "ram_p": ram_percent,
        "ram_u": ram_used,
        "ram_t": ram_total,
        "dsk_p": dsk_percent,
        "dsk_u": dsk_used,
        "dsk_t": dsk_total,
        "net_u": round(net_up_speed, 1),
        "net_d": round(net_down_speed, 1),
        "bat":   bat_percent,
        "plug":  bat_plugged,
        "up_h":  uptime_h,
        "up_m":  uptime_m
    }


# ══════════════════════════════════════════════════════════════
#  PRETTY PRINT STATS (terminal display)
# ══════════════════════════════════════════════════════════════

def print_stats(stats, count):
    """Display current stats in the terminal."""
    bar = lambda pct: '█' * int(pct / 5) + '░' * (20 - int(pct / 5))
    
    print(f"\n{'─' * 50}")
    print(f"  PC STATS MONITOR  │  Update #{count}")
    print(f"{'─' * 50}")
    print(f"  CPU   {bar(stats['cpu'])}  {stats['cpu']:5.1f}%  {stats['freq']} MHz")
    print(f"  RAM   {bar(stats['ram_p'])}  {stats['ram_p']:5.1f}%  {stats['ram_u']}/{stats['ram_t']} GB")
    print(f"  DISK  {bar(stats['dsk_p'])}  {stats['dsk_p']:5.1f}%  {stats['dsk_u']}/{stats['dsk_t']} GB")
    print(f"  NET   ▲ {stats['net_u']:>6.1f} MB/s  ▼ {stats['net_d']:>6.1f} MB/s")
    
    if stats['bat'] >= 0:
        plug_str = "Charging" if stats['plug'] else " On Battery"
        print(f"  BAT   {bar(stats['bat'])}  {stats['bat']}%  {plug_str}")
    else:
        print(f"  BAT   No battery detected (desktop PC)")
    
    print(f"  UP    {stats['up_h']}h {stats['up_m']:02d}m")
    print(f"{'─' * 50}")


# ══════════════════════════════════════════════════════════════
#  MAIN
# ══════════════════════════════════════════════════════════════

def main():
    # Determine port
    port = SERIAL_PORT or find_esp32_port()
    
    if not port:
        print("No serial port found! Connect your XIAO ESP32-S3 via USB.")
        print("   Or set SERIAL_PORT manually in main.py")
        sys.exit(1)
    
    # Connect
    print(f"""
╔══════════════════════════════════════════════════╗
║     PC Stats → E-Ink Display Monitor            ║
║     XIAO ESP32-S3 + WeAct 1.54" E-Paper         ║
╚══════════════════════════════════════════════════╝

  Port:     {port}
  Baud:     {BAUD_RATE}
  Interval: {UPDATE_INTERVAL}s
    """)
    
    try:
        ser = serial.Serial(port, BAUD_RATE, timeout=1)
        time.sleep(2)  # Wait for ESP32 to reset after serial connection
        print(f"Connected to {port}\n")
        
        # Read any startup messages from ESP32
        while ser.in_waiting:
            line = ser.readline().decode('utf-8', errors='ignore').strip()
            if line:
                print(f"  [ESP32] {line}")
        
        count = 0
        while True:
            count += 1
            stats = get_stats()
            
            # Send JSON + newline
            json_str = json.dumps(stats, separators=(',', ':'))  # compact JSON
            ser.write((json_str + '\n').encode())
            
            # Display in terminal
            print_stats(stats, count)
            print(f"Sent {len(json_str)} bytes to ESP32")
            
            # Read any response from ESP32
            time.sleep(0.5)
            while ser.in_waiting:
                line = ser.readline().decode('utf-8', errors='ignore').strip()
                if line:
                    print(f"  [ESP32] {line}")
            
            # Wait for next update
            time.sleep(UPDATE_INTERVAL - 0.5)
    
    except serial.SerialException as e:
        print(f"\nSerial error: {e}")
        print("   Make sure:")
        print("   1. ESP32 is connected via USB")
        print("   2. Arduino Serial Monitor is CLOSED")
        print(f"   3. Port {port} is correct")
        sys.exit(1)
    
    except KeyboardInterrupt:
        print("\n\nStopped by user. Display will retain last stats.")
    
    finally:
        if 'ser' in locals() and ser.is_open:
            ser.close()
            print("  Serial port closed.")


if __name__ == '__main__':
    main()

Credits

roboattic Lab
24 projects • 19 followers
YouTube Content CreatorRobotics Enthusiast

Comments