From 8a8e4a7edbb9e2f4b8e9de0034ddba94b246ec89 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 26 Mar 2026 20:19:48 -0300 Subject: [PATCH 01/38] feat: add Copilot instructions for repository usage --- .github/copilot-instructions.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..eaf0f76d0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,19 @@ +# Copilot Instructions for This Repository +# workspace + +Please follow these guidelines when using Copilot or creating code in this repository: + +- Language: English. + +- All comments, function and variable names, and code must be written in English and follow the Inkypi coding standards and templates. Use the same naming and documentation model across the repository to keep style consistent. + +- When creating a new plugin, or if you have any questions, consult the repository documentation in the `docs/` folder first. Follow the guidance there (plugin architecture, contribution guidelines, coding standards) before opening issues or pull requests. + +- When you create or modify code, always run the project's test suite and include the exact command below at the end of your chat response (so the user can run it locally): + + source ./venv/bin/activate && pytest -q + +- If you add new runnable code, run the tests locally before finishing your response and report the test outcome (pass/fail and summary) in the chat. +- Keep changes small and focused; include any required instructions to reproduce test results. + +These instructions are for developer convenience and to keep the repository stable when code is proposed. From c36587fc107a0102fa10783056897d4a999b481f Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Fri, 27 Mar 2026 22:16:36 -0300 Subject: [PATCH 02/38] feat: add system information panel and API endpoints --- src/blueprints/system_info.py | 250 +++++++++++++++++++++++++++++++++ src/inkypi.py | 2 + src/templates/inky.html | 7 + src/templates/system_info.html | 119 ++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 src/blueprints/system_info.py create mode 100644 src/templates/system_info.html diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py new file mode 100644 index 000000000..631af88d9 --- /dev/null +++ b/src/blueprints/system_info.py @@ -0,0 +1,250 @@ +import logging +import os +import platform +import socket + +from flask import Blueprint, current_app, jsonify, render_template + +logger = logging.getLogger(__name__) + +system_info_bp = Blueprint("system_info", __name__) + + +def _get_cpu_info(): + """Return CPU model name and frequency.""" + model = platform.processor() or "Unknown" + freq_ghz = None + + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.startswith("model name"): + model = line.split(":")[1].strip() + break + except (FileNotFoundError, PermissionError): + pass + + try: + with open("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq") as f: + freq_khz = int(f.read().strip()) + freq_ghz = round(freq_khz / 1_000_000, 2) + except (FileNotFoundError, PermissionError, ValueError): + pass + + return {"model": model, "freq_ghz": freq_ghz} + + +def _get_memory_info(): + """Return total and used RAM in human-readable format.""" + try: + with open("/proc/meminfo") as f: + meminfo = {} + for line in f: + parts = line.split(":") + if len(parts) == 2: + key = parts[0].strip() + val = parts[1].strip().split()[0] # value in kB + meminfo[key] = int(val) + + total_kb = meminfo.get("MemTotal", 0) + available_kb = meminfo.get("MemAvailable", 0) + used_kb = total_kb - available_kb + return { + "total": _format_bytes(total_kb * 1024), + "used": _format_bytes(used_kb * 1024), + } + except (FileNotFoundError, PermissionError): + return {"total": "N/A", "used": "N/A"} + + +def _get_storage_info(): + """Return total and used disk space for the root filesystem.""" + try: + stat = os.statvfs("/") + total = stat.f_frsize * stat.f_blocks + used = stat.f_frsize * (stat.f_blocks - stat.f_bfree) + return { + "total": _format_bytes(total), + "used": _format_bytes(used), + } + except OSError: + return {"total": "N/A", "used": "N/A"} + + +def _get_os_info(): + """Return OS name and version.""" + name = "Unknown" + version = None + + try: + with open("/etc/os-release") as f: + for line in f: + if line.startswith("PRETTY_NAME="): + name = line.split("=", 1)[1].strip().strip('"') + elif line.startswith("VERSION="): + version = line.split("=", 1)[1].strip().strip('"') + except (FileNotFoundError, PermissionError): + name = f"{platform.system()} {platform.release()}" + + return {"name": name, "version": version} + + +def _get_device_model(): + """Return the device model (e.g. Raspberry Pi 4 Model B).""" + try: + with open("/proc/device-tree/model") as f: + return f.read().strip().rstrip("\x00") + except (FileNotFoundError, PermissionError): + return platform.machine() or "Unknown" + + +def _get_uptime(): + """Return system uptime as a human-readable string.""" + try: + with open("/proc/uptime") as f: + seconds = int(float(f.read().split()[0])) + days, remainder = divmod(seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + parts = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + parts.append(f"{minutes}m") + return " ".join(parts) + except (FileNotFoundError, PermissionError): + return "N/A" + + +def _get_last_boot(): + """Return last boot time as a formatted string.""" + try: + with open("/proc/uptime") as f: + uptime_seconds = float(f.read().split()[0]) + import time + boot_timestamp = time.time() - uptime_seconds + from datetime import datetime + boot_dt = datetime.fromtimestamp(boot_timestamp) + return boot_dt.strftime("%Y-%m-%d %H:%M") + except (FileNotFoundError, PermissionError): + return "N/A" + + +def _get_local_ip(): + """Return the local IP address.""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(2) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except (OSError, socket.error): + return "N/A" + + +def _get_hostname(): + """Return the system hostname.""" + return socket.gethostname() + + +def _get_display_info(display_manager): + """Return display name and model from the display manager.""" + display_type = display_manager.device_config.get_config("display_type", default="unknown") + display_obj = getattr(display_manager, "display", None) + display_class = type(display_obj).__name__ if display_obj else display_type + + model = None + if display_type not in ("mock", "inky"): + model = display_type + + return {"name": display_class, "model": model} + + +def _format_bytes(num_bytes): + """Format bytes into a human-readable string (e.g. 3.7 GB).""" + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(num_bytes) < 1024.0: + return f"{num_bytes:.1f} {unit}" + num_bytes /= 1024.0 + return f"{num_bytes:.1f} PB" + + +def _collect_system_info(display_manager): + """Collect all system information cards.""" + cpu = _get_cpu_info() + mem = _get_memory_info() + storage = _get_storage_info() + os_info = _get_os_info() + display = _get_display_info(display_manager) + + cards = [ + { + "icon": "cpu", + "label": "CPU", + "value": cpu["model"], + "secondary": f"{cpu['freq_ghz']} GHz" if cpu["freq_ghz"] else None, + }, + { + "icon": "memory", + "label": "RAM", + "value": mem["total"], + "secondary": f"{mem['used']} / {mem['total']} used", + }, + { + "icon": "storage", + "label": "Storage", + "value": storage["total"], + "secondary": f"{storage['used']} / {storage['total']} used", + }, + { + "icon": "os", + "label": "OS", + "value": os_info["name"], + "secondary": os_info["version"], + }, + { + "icon": "device", + "label": "Device", + "value": _get_device_model(), + }, + { + "icon": "display", + "label": "Display", + "value": display["name"], + "secondary": display["model"], + }, + { + "icon": "uptime", + "label": "Uptime", + "value": _get_uptime(), + }, + { + "icon": "boot", + "label": "Last Boot", + "value": _get_last_boot(), + }, + { + "icon": "network", + "label": "Local IP", + "value": _get_local_ip(), + }, + ] + return cards + + +@system_info_bp.route("/system-info") +def system_info_page(): + display_manager = current_app.config["DISPLAY_MANAGER"] + hostname = _get_hostname() + cards = _collect_system_info(display_manager) + return render_template("system_info.html", hostname=hostname, cards=cards) + + +@system_info_bp.route("/api/system-info") +def system_info_api(): + display_manager = current_app.config["DISPLAY_MANAGER"] + hostname = _get_hostname() + cards = _collect_system_info(display_manager) + return jsonify({"hostname": hostname, "cards": cards}) diff --git a/src/inkypi.py b/src/inkypi.py index 5dc4de57b..f7cd6fda7 100755 --- a/src/inkypi.py +++ b/src/inkypi.py @@ -30,6 +30,7 @@ from blueprints.plugin import plugin_bp from blueprints.playlist import playlist_bp from blueprints.apikeys import apikeys_bp +from blueprints.system_info import system_info_bp from jinja2 import ChoiceLoader, FileSystemLoader from plugins.plugin_registry import load_plugins from waitress import serve @@ -80,6 +81,7 @@ app.register_blueprint(plugin_bp) app.register_blueprint(playlist_bp) app.register_blueprint(apikeys_bp) +app.register_blueprint(system_info_bp) # Register opener for HEIF/HEIC images register_heif_opener() diff --git a/src/templates/inky.html b/src/templates/inky.html index 97b85a697..ea245b746 100644 --- a/src/templates/inky.html +++ b/src/templates/inky.html @@ -68,6 +68,13 @@

{{ config.name }}

playlists icon + + + + + + + settings icon diff --git a/src/templates/system_info.html b/src/templates/system_info.html new file mode 100644 index 000000000..6915f537e --- /dev/null +++ b/src/templates/system_info.html @@ -0,0 +1,119 @@ + + + + + + System Info + + + + + + +
+ + + + +
+
+
+ + + + + +
+

System Info

+ {{ hostname }} +
+
+
+
+ +
+ + +
+ {% for card in cards %} +
+
+ {% if card.icon == 'cpu' %} + + + + + + + + + {% elif card.icon == 'memory' %} + + + + + + + + {% elif card.icon == 'storage' %} + + + + + + {% elif card.icon == 'os' %} + + + + + + {% elif card.icon == 'device' %} + + + + + {% elif card.icon == 'display' %} + + + + + + {% elif card.icon == 'uptime' %} + + + + + {% elif card.icon == 'boot' %} + + + + + + {% elif card.icon == 'network' %} + + + + + + + {% endif %} +
+ {{ card.label }} + {{ card.value }} + {% if card.secondary %} + {{ card.secondary }} + {% endif %} +
+ {% endfor %} +
+
+ + From 1343bd743f436c36205d9dff1f4c558cff72a728 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Fri, 27 Mar 2026 22:33:19 -0300 Subject: [PATCH 03/38] feat: enhance system info collection and display with additional specifications --- src/blueprints/system_info.py | 146 +++++++++++++++++++++++---------- src/templates/system_info.html | 135 ++++++++++++++++-------------- 2 files changed, 174 insertions(+), 107 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 631af88d9..eaba5807a 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -11,27 +11,39 @@ def _get_cpu_info(): - """Return CPU model name and frequency.""" + """Return CPU model name, frequency, and core count.""" model = platform.processor() or "Unknown" freq_ghz = None + cores = None try: with open("/proc/cpuinfo") as f: + core_count = 0 for line in f: if line.startswith("model name"): model = line.split(":")[1].strip() - break + if line.startswith("processor"): + core_count += 1 + if core_count > 0: + cores = core_count except (FileNotFoundError, PermissionError): pass - try: - with open("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq") as f: - freq_khz = int(f.read().strip()) - freq_ghz = round(freq_khz / 1_000_000, 2) - except (FileNotFoundError, PermissionError, ValueError): - pass + # Prefer current frequency, then max frequency + freq_paths = [ + "/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq", + "/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", + ] + for path in freq_paths: + try: + with open(path) as f: + freq_khz = int(f.read().strip()) + freq_ghz = round(freq_khz / 1_000_000, 2) + break + except (FileNotFoundError, PermissionError, ValueError): + continue - return {"model": model, "freq_ghz": freq_ghz} + return {"model": model, "freq_ghz": freq_ghz, "cores": cores} def _get_memory_info(): @@ -72,21 +84,37 @@ def _get_storage_info(): def _get_os_info(): - """Return OS name and version.""" + """Return OS name, version, distribution ID, and pretty name.""" name = "Unknown" version = None + distro_id = None + pretty_name = None try: with open("/etc/os-release") as f: for line in f: if line.startswith("PRETTY_NAME="): - name = line.split("=", 1)[1].strip().strip('"') + pretty_name = line.split("=", 1)[1].strip().strip('"') elif line.startswith("VERSION="): version = line.split("=", 1)[1].strip().strip('"') + elif line.startswith("NAME=") and not line.startswith("NAME=\"\n"): + name = line.split("=", 1)[1].strip().strip('"') + elif line.startswith("ID="): + distro_id = line.split("=", 1)[1].strip().strip('"') except (FileNotFoundError, PermissionError): name = f"{platform.system()} {platform.release()}" - return {"name": name, "version": version} + return { + "name": name, + "version": version, + "distro": distro_id, + "pretty_name": pretty_name or name, + } + + +def _get_architecture(): + """Return the system architecture (e.g. x86_64, aarch64).""" + return platform.machine() or "Unknown" def _get_device_model(): @@ -162,6 +190,16 @@ def _get_display_info(display_manager): return {"name": display_class, "model": model} +def _get_kernel_info(): + """Return kernel version string.""" + return platform.release() + + +def _get_device_name(device_config): + """Return the configured device name from InkyPi config.""" + return device_config.get_config("name", default="InkyPi") + + def _format_bytes(num_bytes): """Format bytes into a human-readable string (e.g. 3.7 GB).""" for unit in ("B", "KB", "MB", "GB", "TB"): @@ -172,79 +210,97 @@ def _format_bytes(num_bytes): def _collect_system_info(display_manager): - """Collect all system information cards.""" + """Collect all system information split into highlight cards and specification sections.""" cpu = _get_cpu_info() mem = _get_memory_info() storage = _get_storage_info() os_info = _get_os_info() display = _get_display_info(display_manager) + local_ip = _get_local_ip() + device_config = display_manager.device_config cards = [ { - "icon": "cpu", - "label": "CPU", - "value": cpu["model"], - "secondary": f"{cpu['freq_ghz']} GHz" if cpu["freq_ghz"] else None, + "icon": "storage", + "label": "Storage", + "value": storage["total"], + "secondary": f"{storage['used']} of {storage['total']} used", }, { "icon": "memory", - "label": "RAM", + "label": "Installed RAM", "value": mem["total"], - "secondary": f"{mem['used']} / {mem['total']} used", + "secondary": f"{mem['used']} of {mem['total']} used", }, { - "icon": "storage", - "label": "Storage", - "value": storage["total"], - "secondary": f"{storage['used']} / {storage['total']} used", + "icon": "cpu", + "label": "CPU", + "value": cpu["model"], + "secondary": f"{cpu['freq_ghz']} GHz" if cpu["freq_ghz"] else None, }, { "icon": "os", "label": "OS", - "value": os_info["name"], + "value": os_info["pretty_name"], "secondary": os_info["version"], }, - { - "icon": "device", - "label": "Device", - "value": _get_device_model(), - }, { "icon": "display", "label": "Display", "value": display["name"], "secondary": display["model"], }, - { - "icon": "uptime", - "label": "Uptime", - "value": _get_uptime(), - }, - { - "icon": "boot", - "label": "Last Boot", - "value": _get_last_boot(), - }, { "icon": "network", "label": "Local IP", - "value": _get_local_ip(), + "value": local_ip, }, ] - return cards + + device_specs = [ + {"label": "Device name", "value": _get_device_name(device_config)}, + {"label": "Hostname", "value": _get_hostname()}, + {"label": "Model", "value": _get_device_model()}, + {"label": "Architecture", "value": _get_architecture()}, + {"label": "CPU", "value": cpu["model"]}, + {"label": "CPU cores", "value": str(cpu["cores"]) if cpu["cores"] else "N/A"}, + {"label": "CPU frequency", "value": f"{cpu['freq_ghz']} GHz" if cpu["freq_ghz"] else "N/A"}, + {"label": "RAM", "value": mem["total"]}, + ] + + system_specs = [ + {"label": "OS name", "value": os_info["name"]}, + {"label": "OS version", "value": os_info["version"] or "N/A"}, + {"label": "Distribution", "value": os_info["distro"] or "N/A"}, + {"label": "Kernel", "value": _get_kernel_info()}, + {"label": "Pretty name", "value": os_info["pretty_name"]}, + ] + + return cards, device_specs, system_specs @system_info_bp.route("/system-info") def system_info_page(): display_manager = current_app.config["DISPLAY_MANAGER"] hostname = _get_hostname() - cards = _collect_system_info(display_manager) - return render_template("system_info.html", hostname=hostname, cards=cards) + cards, device_specs, system_specs = _collect_system_info(display_manager) + return render_template( + "system_info.html", + hostname=hostname, + cards=cards, + device_specs=device_specs, + system_specs=system_specs, + ) @system_info_bp.route("/api/system-info") def system_info_api(): display_manager = current_app.config["DISPLAY_MANAGER"] hostname = _get_hostname() - cards = _collect_system_info(display_manager) - return jsonify({"hostname": hostname, "cards": cards}) + cards, device_specs, system_specs = _collect_system_info(display_manager) + return jsonify({ + "hostname": hostname, + "cards": cards, + "device_specs": device_specs, + "system_specs": system_specs, + }) diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 6915f537e..87729f678 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -41,72 +41,57 @@

System Info

- +
{% for card in cards %}
-
- {% if card.icon == 'cpu' %} - - - - - - - - - {% elif card.icon == 'memory' %} - - - - - - - - {% elif card.icon == 'storage' %} - - - - - - {% elif card.icon == 'os' %} - - - - - - {% elif card.icon == 'device' %} - - - - - {% elif card.icon == 'display' %} - - - - - - {% elif card.icon == 'uptime' %} - - - - - {% elif card.icon == 'boot' %} - - - - - - {% elif card.icon == 'network' %} - - - - - - - {% endif %} +
+
+ {% if card.icon == 'storage' %} + + + + + + {% elif card.icon == 'memory' %} + + + + + + + + {% elif card.icon == 'cpu' %} + + + + + + + + + {% elif card.icon == 'os' %} + + + + + {% elif card.icon == 'display' %} + + + + + + {% elif card.icon == 'network' %} + + + + + + + {% endif %} +
+ {{ card.label }}
- {{ card.label }} {{ card.value }} {% if card.secondary %} {{ card.secondary }} @@ -114,6 +99,32 @@

System Info

{% endfor %}
+ + +
+

Device specifications

+
+ {% for spec in device_specs %} +
+ {{ spec.label }} + {{ spec.value }} +
+ {% endfor %} +
+
+ + +
+

System specifications

+
+ {% for spec in system_specs %} +
+ {{ spec.label }} + {{ spec.value }} +
+ {% endfor %} +
+
From 6ca4a7d3e2302f680ccb6745b97b9ecfa4533bc0 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Fri, 27 Mar 2026 22:39:09 -0300 Subject: [PATCH 04/38] feat: improve CPU frequency retrieval and update system info structure --- src/blueprints/system_info.py | 74 ++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index eaba5807a..ad6951dd1 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -5,31 +5,45 @@ from flask import Blueprint, current_app, jsonify, render_template +try: + import psutil +except ImportError: + psutil = None + logger = logging.getLogger(__name__) system_info_bp = Blueprint("system_info", __name__) -def _get_cpu_info(): - """Return CPU model name, frequency, and core count.""" - model = platform.processor() or "Unknown" - freq_ghz = None - cores = None +def _get_cpu_freq(): + """Return CPU frequency as a formatted string (e.g. '2.5 GHz'). + Priority: psutil current -> psutil max -> /proc/cpuinfo cpu MHz -> + sysfs scaling_cur_freq -> sysfs cpuinfo_max_freq -> None. + """ + # 1. psutil (preferred – works on ARM and x86) + if psutil is not None: + try: + freq = psutil.cpu_freq() + if freq is not None: + mhz = freq.current if freq.current and freq.current > 0 else freq.max + if mhz and mhz > 0: + return f"{round(mhz / 1000, 1)} GHz" + except Exception: + pass + + # 2. /proc/cpuinfo "cpu MHz" line try: with open("/proc/cpuinfo") as f: - core_count = 0 for line in f: - if line.startswith("model name"): - model = line.split(":")[1].strip() - if line.startswith("processor"): - core_count += 1 - if core_count > 0: - cores = core_count - except (FileNotFoundError, PermissionError): + if line.lower().startswith("cpu mhz"): + mhz = float(line.split(":")[1].strip()) + if mhz > 0: + return f"{round(mhz / 1000, 1)} GHz" + except (FileNotFoundError, PermissionError, ValueError): pass - # Prefer current frequency, then max frequency + # 3. sysfs frequency files (kHz) freq_paths = [ "/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq", "/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", @@ -38,12 +52,34 @@ def _get_cpu_info(): try: with open(path) as f: freq_khz = int(f.read().strip()) - freq_ghz = round(freq_khz / 1_000_000, 2) - break + if freq_khz > 0: + return f"{round(freq_khz / 1_000_000, 1)} GHz" except (FileNotFoundError, PermissionError, ValueError): continue - return {"model": model, "freq_ghz": freq_ghz, "cores": cores} + return None + + +def _get_cpu_info(): + """Return CPU model name, frequency string, and core count.""" + model = platform.processor() or "Unknown" + cores = None + + try: + with open("/proc/cpuinfo") as f: + core_count = 0 + for line in f: + if line.startswith("model name"): + model = line.split(":")[1].strip() + if line.startswith("processor"): + core_count += 1 + if core_count > 0: + cores = core_count + except (FileNotFoundError, PermissionError): + pass + + freq = _get_cpu_freq() + return {"model": model, "freq": freq, "cores": cores} def _get_memory_info(): @@ -236,7 +272,7 @@ def _collect_system_info(display_manager): "icon": "cpu", "label": "CPU", "value": cpu["model"], - "secondary": f"{cpu['freq_ghz']} GHz" if cpu["freq_ghz"] else None, + "secondary": cpu["freq"], }, { "icon": "os", @@ -264,7 +300,7 @@ def _collect_system_info(display_manager): {"label": "Architecture", "value": _get_architecture()}, {"label": "CPU", "value": cpu["model"]}, {"label": "CPU cores", "value": str(cpu["cores"]) if cpu["cores"] else "N/A"}, - {"label": "CPU frequency", "value": f"{cpu['freq_ghz']} GHz" if cpu["freq_ghz"] else "N/A"}, + {"label": "CPU frequency", "value": cpu["freq"] or "N/A"}, {"label": "RAM", "value": mem["total"]}, ] From 13ab8555f8c248f547f7b3209fb648eb7f87f167 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Fri, 27 Mar 2026 23:03:00 -0300 Subject: [PATCH 05/38] feat: enhance system memory reporting and WSL detection in system info --- src/blueprints/system_info.py | 123 ++++++++++++++++++++++++++++----- src/templates/system_info.html | 4 +- 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index ad6951dd1..75a1f61f5 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -82,8 +82,49 @@ def _get_cpu_info(): return {"model": model, "freq": freq, "cores": cores} +def _is_wsl(): + """Detect if running inside Windows Subsystem for Linux.""" + try: + with open("/proc/version") as f: + return "microsoft" in f.read().lower() + except (FileNotFoundError, PermissionError): + return False + + +def _get_host_physical_memory(): + """Try to get actual physical RAM from Windows host (WSL2 only). + + Uses PowerShell interop to query the host's total physical memory. + Returns the value in bytes or None if unavailable. + """ + import subprocess + + try: + result = subprocess.run( + [ + "powershell.exe", + "-NoProfile", + "-Command", + "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory", + ], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return int(result.stdout.strip()) + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError, OSError): + pass + return None + + def _get_memory_info(): - """Return total and used RAM in human-readable format.""" + """Return total and used RAM in human-readable format. + + On WSL, attempts to report the host's physical RAM via PowerShell. + Falls back to /proc/meminfo MemTotal with an annotation when the + true installed amount cannot be determined. + """ try: with open("/proc/meminfo") as f: meminfo = {} @@ -97,12 +138,27 @@ def _get_memory_info(): total_kb = meminfo.get("MemTotal", 0) available_kb = meminfo.get("MemAvailable", 0) used_kb = total_kb - available_kb - return { - "total": _format_bytes(total_kb * 1024), - "used": _format_bytes(used_kb * 1024), - } + + allocated = _format_bytes(total_kb * 1024) + used = _format_bytes(used_kb * 1024) + + if _is_wsl(): + host_mem = _get_host_physical_memory() + if host_mem: + return { + "total": _format_bytes(host_mem), + "used": used, + "note": f"WSL allocated: {allocated}", + } + return { + "total": allocated, + "used": used, + "note": "WSL allocated", + } + + return {"total": allocated, "used": used, "note": None} except (FileNotFoundError, PermissionError): - return {"total": "N/A", "used": "N/A"} + return {"total": "N/A", "used": "N/A", "note": None} def _get_storage_info(): @@ -214,16 +270,34 @@ def _get_hostname(): def _get_display_info(display_manager): - """Return display name and model from the display manager.""" - display_type = display_manager.device_config.get_config("display_type", default="unknown") - display_obj = getattr(display_manager, "display", None) - display_class = type(display_obj).__name__ if display_obj else display_type + """Return display information reusing InkyPi's configured display type. - model = None - if display_type not in ("mock", "inky"): - model = display_type + Uses the same ``display_type`` config key that ``DisplayManager.__init__`` + reads to select the concrete display driver, and ``get_resolution()`` for + the configured panel size. + """ + device_config = display_manager.device_config + display_type = device_config.get_config("display_type", default="unknown") + + # Friendly name – mirrors DisplayManager dispatch logic + if display_type == "mock": + name = "Mock (Development)" + elif display_type == "inky": + name = "Inky (Pimoroni)" + elif display_type.startswith("epd"): + name = display_type # Waveshare model id, e.g. "epd7in3e" + else: + name = display_type + + # Resolution from the same config source used by display rendering + resolution = None + try: + w, h = device_config.get_resolution() + resolution = f"{w} × {h}" + except (TypeError, ValueError, KeyError): + pass - return {"name": display_class, "model": model} + return {"name": name, "type": display_type, "resolution": resolution} def _get_kernel_info(): @@ -245,6 +319,15 @@ def _format_bytes(num_bytes): return f"{num_bytes:.1f} PB" +def _ram_secondary(mem): + """Build secondary text for the RAM card, annotating WSL when applicable.""" + usage = f"{mem['used']} of {mem['total']} used" + note = mem.get("note") + if note: + return f"{usage} ({note})" + return usage + + def _collect_system_info(display_manager): """Collect all system information split into highlight cards and specification sections.""" cpu = _get_cpu_info() @@ -266,7 +349,7 @@ def _collect_system_info(display_manager): "icon": "memory", "label": "Installed RAM", "value": mem["total"], - "secondary": f"{mem['used']} of {mem['total']} used", + "secondary": _ram_secondary(mem), }, { "icon": "cpu", @@ -284,7 +367,7 @@ def _collect_system_info(display_manager): "icon": "display", "label": "Display", "value": display["name"], - "secondary": display["model"], + "secondary": display["resolution"], }, { "icon": "network", @@ -293,6 +376,10 @@ def _collect_system_info(display_manager): }, ] + ram_spec = mem["total"] + if mem.get("note"): + ram_spec += f" ({mem['note']})" + device_specs = [ {"label": "Device name", "value": _get_device_name(device_config)}, {"label": "Hostname", "value": _get_hostname()}, @@ -301,7 +388,9 @@ def _collect_system_info(display_manager): {"label": "CPU", "value": cpu["model"]}, {"label": "CPU cores", "value": str(cpu["cores"]) if cpu["cores"] else "N/A"}, {"label": "CPU frequency", "value": cpu["freq"] or "N/A"}, - {"label": "RAM", "value": mem["total"]}, + {"label": "RAM", "value": ram_spec}, + {"label": "Display type", "value": display["type"]}, + {"label": "Display resolution", "value": display["resolution"] or "N/A"}, ] system_specs = [ diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 87729f678..0d02ab9c8 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -72,8 +72,8 @@

System Info

{% elif card.icon == 'os' %} - - + + {% elif card.icon == 'display' %} From bb3d5bb25d7c445e5f7c9c836554d8e6b5d43bc8 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Fri, 27 Mar 2026 23:03:11 -0300 Subject: [PATCH 06/38] feat: add comprehensive unit tests for system information retrieval functions --- tests/test_system_info.py | 351 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 tests/test_system_info.py diff --git a/tests/test_system_info.py b/tests/test_system_info.py new file mode 100644 index 000000000..88f5918b5 --- /dev/null +++ b/tests/test_system_info.py @@ -0,0 +1,351 @@ +import pytest +from unittest.mock import patch, MagicMock + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from blueprints.system_info import ( + _format_bytes, + _get_cpu_info, + _get_cpu_freq, + _get_memory_info, + _get_storage_info, + _get_os_info, + _get_device_model, + _get_uptime, + _get_last_boot, + _get_local_ip, + _get_hostname, + _get_display_info, + _get_kernel_info, + _get_device_name, + _get_architecture, + _is_wsl, + _get_host_physical_memory, + _ram_secondary, + _collect_system_info, +) + + +class TestFormatBytes: + def test_bytes(self): + assert _format_bytes(500) == "500.0 B" + + def test_kilobytes(self): + assert _format_bytes(1024) == "1.0 KB" + + def test_megabytes(self): + assert _format_bytes(1024 * 1024) == "1.0 MB" + + def test_gigabytes(self): + assert _format_bytes(1024 ** 3) == "1.0 GB" + + def test_terabytes(self): + assert _format_bytes(1024 ** 4) == "1.0 TB" + + +class TestGetCpuFreq: + @patch("blueprints.system_info.psutil") + def test_psutil_current(self, mock_psutil): + mock_psutil.cpu_freq.return_value = MagicMock(current=2500.0, max=3000.0) + assert _get_cpu_freq() == "2.5 GHz" + + @patch("blueprints.system_info.psutil") + def test_psutil_max_fallback(self, mock_psutil): + mock_psutil.cpu_freq.return_value = MagicMock(current=0.0, max=1800.0) + assert _get_cpu_freq() == "1.8 GHz" + + @patch("blueprints.system_info.psutil") + def test_psutil_none_falls_through(self, mock_psutil): + mock_psutil.cpu_freq.return_value = None + with patch("builtins.open", side_effect=FileNotFoundError): + assert _get_cpu_freq() is None + + @patch("blueprints.system_info.psutil", new=None) + def test_no_psutil_reads_proc_cpuinfo(self): + cpuinfo = "cpu MHz\t: 1000.000\n" + with patch("builtins.open", MagicMock( + return_value=MagicMock( + __enter__=MagicMock(return_value=iter(cpuinfo.splitlines(True))), + __exit__=MagicMock(return_value=False), + ) + )): + assert _get_cpu_freq() == "1.0 GHz" + + +class TestGetCpuInfo: + @patch("blueprints.system_info._get_cpu_freq", return_value=None) + @patch("builtins.open", side_effect=FileNotFoundError) + @patch("platform.processor", return_value="x86_64") + def test_fallback_to_platform(self, mock_proc, mock_open, mock_freq): + result = _get_cpu_info() + assert result["model"] == "x86_64" + assert result["freq"] is None + assert result["cores"] is None + + @patch("blueprints.system_info._get_cpu_freq", return_value="2.5 GHz") + @patch("builtins.open") + def test_reads_proc_cpuinfo(self, mock_open, mock_freq): + cpuinfo_content = "processor\t: 0\nmodel name\t: Intel(R) Core(TM) i5-12400\nprocessor\t: 1\nmodel name\t: Intel(R) Core(TM) i5-12400\n" + mock_open.return_value = MagicMock( + __enter__=MagicMock(return_value=iter(cpuinfo_content.splitlines(True))), + __exit__=MagicMock(return_value=False), + ) + result = _get_cpu_info() + assert "i5-12400" in result["model"] + assert result["cores"] == 2 + assert result["freq"] == "2.5 GHz" + + +class TestIsWsl: + @patch("builtins.open") + def test_detects_wsl(self, mock_open): + mock_open.return_value = MagicMock( + __enter__=MagicMock(return_value=MagicMock(read=MagicMock( + return_value="Linux version 5.15.0 (Microsoft)" + ))), + __exit__=MagicMock(return_value=False), + ) + assert _is_wsl() is True + + @patch("builtins.open") + def test_non_wsl(self, mock_open): + mock_open.return_value = MagicMock( + __enter__=MagicMock(return_value=MagicMock(read=MagicMock( + return_value="Linux version 6.1.0-rpi7" + ))), + __exit__=MagicMock(return_value=False), + ) + assert _is_wsl() is False + + @patch("builtins.open", side_effect=FileNotFoundError) + def test_fallback_on_missing(self, mock_open): + assert _is_wsl() is False + + +class TestGetHostPhysicalMemory: + @patch("subprocess.run") + def test_returns_bytes(self, mock_run): + import subprocess + mock_run.return_value = MagicMock(returncode=0, stdout="34359738368\n") + result = _get_host_physical_memory() + assert result == 34359738368 + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_returns_none_on_failure(self, mock_run): + assert _get_host_physical_memory() is None + + +class TestRamSecondary: + def test_normal(self): + mem = {"total": "4.0 GB", "used": "2.0 GB", "note": None} + assert _ram_secondary(mem) == "2.0 GB of 4.0 GB used" + + def test_wsl_with_host_ram(self): + mem = {"total": "32.0 GB", "used": "8.0 GB", "note": "WSL allocated: 15.5 GB"} + assert _ram_secondary(mem) == "8.0 GB of 32.0 GB used (WSL allocated: 15.5 GB)" + + def test_wsl_no_host_ram(self): + mem = {"total": "15.5 GB", "used": "8.0 GB", "note": "WSL allocated"} + assert _ram_secondary(mem) == "8.0 GB of 15.5 GB used (WSL allocated)" + + +class TestGetMemoryInfo: + @patch("builtins.open", side_effect=FileNotFoundError) + def test_fallback_on_missing_proc(self, mock_open): + result = _get_memory_info() + assert result["total"] == "N/A" + assert result["used"] == "N/A" + assert result["note"] is None + + +class TestGetStorageInfo: + @patch("os.statvfs") + def test_returns_storage(self, mock_statvfs): + mock_statvfs.return_value = MagicMock( + f_frsize=4096, f_blocks=2621440, f_bfree=1310720 + ) + result = _get_storage_info() + assert "GB" in result["total"] or "MB" in result["total"] + + @patch("os.statvfs", side_effect=OSError) + def test_fallback_on_error(self, mock_statvfs): + result = _get_storage_info() + assert result["total"] == "N/A" + + +class TestGetOsInfo: + @patch("builtins.open", side_effect=FileNotFoundError) + def test_fallback_to_platform(self, mock_open): + result = _get_os_info() + assert result["name"] != "Unknown" + assert result["pretty_name"] is not None + assert result["distro"] is None + + +class TestGetDeviceModel: + @patch("builtins.open", side_effect=FileNotFoundError) + @patch("platform.machine", return_value="x86_64") + def test_fallback_to_platform(self, mock_machine, mock_open): + result = _get_device_model() + assert result == "x86_64" + + +class TestGetUptime: + @patch("builtins.open", side_effect=FileNotFoundError) + def test_fallback_on_missing(self, mock_open): + assert _get_uptime() == "N/A" + + @patch("builtins.open") + def test_parses_uptime(self, mock_open): + mock_open.return_value.__enter__ = MagicMock( + return_value=MagicMock(read=MagicMock(return_value="90061.23 180000.00")) + ) + mock_open.return_value.__exit__ = MagicMock(return_value=False) + result = _get_uptime() + assert "1d" in result + assert "h" in result + + +class TestGetLastBoot: + @patch("builtins.open", side_effect=FileNotFoundError) + def test_fallback_on_missing(self, mock_open): + assert _get_last_boot() == "N/A" + + +class TestGetLocalIp: + @patch("socket.socket") + def test_returns_ip(self, mock_socket_cls): + mock_sock = MagicMock() + mock_socket_cls.return_value = mock_sock + mock_sock.getsockname.return_value = ("192.168.1.100", 0) + assert _get_local_ip() == "192.168.1.100" + + @patch("socket.socket", side_effect=OSError) + def test_fallback_on_error(self, mock_socket_cls): + assert _get_local_ip() == "N/A" + + +class TestGetHostname: + @patch("socket.gethostname", return_value="inkypi") + def test_returns_hostname(self, mock_hostname): + assert _get_hostname() == "inkypi" + + +class TestGetDisplayInfo: + def test_mock_display(self): + mock_dm = MagicMock() + mock_dm.device_config.get_config.return_value = "mock" + mock_dm.device_config.get_resolution.return_value = (800, 480) + result = _get_display_info(mock_dm) + assert result["name"] == "Mock (Development)" + assert result["type"] == "mock" + assert result["resolution"] == "800 × 480" + + def test_inky_display(self): + mock_dm = MagicMock() + mock_dm.device_config.get_config.return_value = "inky" + mock_dm.device_config.get_resolution.return_value = (400, 300) + result = _get_display_info(mock_dm) + assert result["name"] == "Inky (Pimoroni)" + assert result["type"] == "inky" + assert result["resolution"] == "400 × 300" + + def test_waveshare_display(self): + mock_dm = MagicMock() + mock_dm.device_config.get_config.return_value = "epd7in3e" + mock_dm.device_config.get_resolution.return_value = (800, 480) + result = _get_display_info(mock_dm) + assert result["name"] == "epd7in3e" + assert result["type"] == "epd7in3e" + assert result["resolution"] == "800 × 480" + + def test_resolution_unavailable(self): + mock_dm = MagicMock() + mock_dm.device_config.get_config.return_value = "mock" + mock_dm.device_config.get_resolution.side_effect = KeyError + result = _get_display_info(mock_dm) + assert result["resolution"] is None + + +class TestGetKernelInfo: + @patch("platform.release", return_value="6.1.0-rpi7-rpi-v8") + def test_returns_kernel(self, mock_release): + assert _get_kernel_info() == "6.1.0-rpi7-rpi-v8" + + +class TestGetArchitecture: + @patch("platform.machine", return_value="aarch64") + def test_returns_arch(self, mock_machine): + assert _get_architecture() == "aarch64" + + @patch("platform.machine", return_value="") + def test_fallback_on_empty(self, mock_machine): + assert _get_architecture() == "Unknown" + + +class TestGetDeviceName: + def test_returns_config_name(self): + mock_config = MagicMock() + mock_config.get_config.return_value = "My InkyPi" + assert _get_device_name(mock_config) == "My InkyPi" + + def test_returns_default(self): + mock_config = MagicMock() + mock_config.get_config.return_value = "InkyPi" + assert _get_device_name(mock_config) == "InkyPi" + + +class TestCollectSystemInfo: + @patch("blueprints.system_info._get_local_ip", return_value="192.168.1.1") + @patch("blueprints.system_info._get_last_boot", return_value="2025-01-01 10:00") + @patch("blueprints.system_info._get_uptime", return_value="5d 3h 20m") + @patch("blueprints.system_info._get_device_model", return_value="Raspberry Pi 4") + @patch("blueprints.system_info._get_os_info", return_value={"name": "Debian GNU/Linux", "version": "11", "distro": "debian", "pretty_name": "Debian GNU/Linux 11 (bullseye)"}) + @patch("blueprints.system_info._get_storage_info", return_value={"total": "32.0 GB", "used": "10.0 GB"}) + @patch("blueprints.system_info._get_memory_info", return_value={"total": "4.0 GB", "used": "2.0 GB", "note": None}) + @patch("blueprints.system_info._get_cpu_info", return_value={"model": "ARM Cortex-A72", "freq": "1.5 GHz", "cores": 4}) + @patch("blueprints.system_info._get_kernel_info", return_value="6.1.0-rpi7") + @patch("blueprints.system_info._get_hostname", return_value="inkypi") + @patch("blueprints.system_info._get_device_name", return_value="My InkyPi") + @patch("blueprints.system_info._get_architecture", return_value="aarch64") + def test_returns_cards_and_specs(self, *mocks): + mock_dm = MagicMock() + mock_dm.device_config.get_config.return_value = "mock" + mock_dm.device_config.get_resolution.return_value = (800, 480) + + cards, device_specs, system_specs = _collect_system_info(mock_dm) + + # Verify cards + card_labels = [c["label"] for c in cards] + assert "Storage" in card_labels + assert "Installed RAM" in card_labels + assert "CPU" in card_labels + assert "OS" in card_labels + assert "Display" in card_labels + assert "Local IP" in card_labels + assert len(cards) == 6 + + # Verify device specs + dev_labels = [s["label"] for s in device_specs] + assert "Device name" in dev_labels + assert "Hostname" in dev_labels + assert "Model" in dev_labels + assert "Architecture" in dev_labels + assert "CPU" in dev_labels + assert "CPU cores" in dev_labels + assert "CPU frequency" in dev_labels + assert "RAM" in dev_labels + assert "Display type" in dev_labels + assert "Display resolution" in dev_labels + assert len(device_specs) == 10 + + # Verify system specs + sys_labels = [s["label"] for s in system_specs] + assert "OS name" in sys_labels + assert "OS version" in sys_labels + assert "Distribution" in sys_labels + assert "Kernel" in sys_labels + assert "Pretty name" in sys_labels + assert len(system_specs) == 5 From 67d1b1fd31fb56388ab331a3f3a7e4092d25cda1 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Fri, 27 Mar 2026 23:16:23 -0300 Subject: [PATCH 07/38] feat: enhance display information handling and add parsing for Waveshare EPD codes --- src/blueprints/system_info.py | 90 +++++++++++++++++++++++++++++------ tests/test_system_info.py | 56 ++++++++++++++++++++-- 2 files changed, 128 insertions(+), 18 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 75a1f61f5..bc965746d 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -1,6 +1,8 @@ +import fnmatch import logging import os import platform +import re import socket from flask import Blueprint, current_app, jsonify, render_template @@ -270,24 +272,17 @@ def _get_hostname(): def _get_display_info(display_manager): - """Return display information reusing InkyPi's configured display type. + """Return display information using the same detection path as SystemStatus. - Uses the same ``display_type`` config key that ``DisplayManager.__init__`` - reads to select the concrete display driver, and ``get_resolution()`` for - the configured panel size. + Mirrors the ``_get_display_value`` / ``_parse_epd_code`` logic already used + by the SystemStatus plugin so that the System Info page shows the identical + resolved display name. No manual per-model catalog is maintained — the EPD + code is parsed dynamically from the ``display_type`` config value. """ device_config = display_manager.device_config display_type = device_config.get_config("display_type", default="unknown") - # Friendly name – mirrors DisplayManager dispatch logic - if display_type == "mock": - name = "Mock (Development)" - elif display_type == "inky": - name = "Inky (Pimoroni)" - elif display_type.startswith("epd"): - name = display_type # Waveshare model id, e.g. "epd7in3e" - else: - name = display_type + name = _resolve_display_name(display_type) # Resolution from the same config source used by display rendering resolution = None @@ -300,6 +295,73 @@ def _get_display_info(display_manager): return {"name": name, "type": display_type, "resolution": resolution} +# ── Display name resolution (mirrors SystemStatus._get_display_value) ── + +_DISPLAY_NAME_MAP = { + "inky": "Inky e-Paper", + "mock": "Mock Display", +} + +_EPD_PATTERN = re.compile( + r"^epd(\d+)in(\d+)([a-z]*)(?:_(v\d+|hd))?(?:([a-z]*)(?:_(v\d+|hd))?)?$", + re.IGNORECASE, +) + + +def _parse_epd_code(code): + """Parse a Waveshare EPD code into a friendly name. + + Examples:: + + epd7in3e → Waveshare 7.3inch e-Paper + epd5in83_v2 → Waveshare 5.83inch e-Paper V2 + epd7in5b_hd → Waveshare 7.5inch e-Paper HD + epd13in3k → Waveshare 13.3inch e-Paper + """ + m = _EPD_PATTERN.match(code) + if not m: + return None + inches = m.group(1) + decimal = m.group(2) + size = f"{inches}.{decimal}" + + suffixes = [] + for g in (m.group(4), m.group(5), m.group(6)): + if g: + suffixes.append(g.upper()) + + suffix_str = f" {' '.join(suffixes)}" if suffixes else "" + return f"Waveshare {size}inch e-Paper{suffix_str}" + + +def _resolve_display_name(display_type): + """Return a human-readable display name from the config display_type value. + + Uses the same strategy as SystemStatus._get_display_value: + 1. Check the static name map (inky, mock) + 2. Parse Waveshare EPD codes dynamically + 3. Fall back to the raw display_type string + """ + if not display_type: + return "Unknown" + + normalized = str(display_type).strip().lower() + if not normalized: + return "Unknown" + + friendly = _DISPLAY_NAME_MAP.get(normalized) + if friendly: + return friendly + + if fnmatch.fnmatch(normalized, "epd*in*"): + parsed = _parse_epd_code(normalized) + if parsed: + return f"{parsed} ({display_type})" + return f"Waveshare e-Paper ({display_type})" + + return display_type + + def _get_kernel_info(): """Return kernel version string.""" return platform.release() @@ -389,7 +451,7 @@ def _collect_system_info(display_manager): {"label": "CPU cores", "value": str(cpu["cores"]) if cpu["cores"] else "N/A"}, {"label": "CPU frequency", "value": cpu["freq"] or "N/A"}, {"label": "RAM", "value": ram_spec}, - {"label": "Display type", "value": display["type"]}, + {"label": "Display", "value": display["name"]}, {"label": "Display resolution", "value": display["resolution"] or "N/A"}, ] diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 88f5918b5..67b9b4cc2 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -24,6 +24,8 @@ _is_wsl, _get_host_physical_memory, _ram_secondary, + _parse_epd_code, + _resolve_display_name, _collect_system_info, ) @@ -233,13 +235,59 @@ def test_returns_hostname(self, mock_hostname): assert _get_hostname() == "inkypi" +class TestParseEpdCode: + def test_simple_model(self): + assert _parse_epd_code("epd7in3e") == "Waveshare 7.3inch e-Paper" + + def test_decimal_model(self): + assert _parse_epd_code("epd5in83") == "Waveshare 5.83inch e-Paper" + + def test_version_suffix(self): + assert _parse_epd_code("epd5in83_v2") == "Waveshare 5.83inch e-Paper V2" + + def test_hd_suffix(self): + assert _parse_epd_code("epd7in5b_hd") == "Waveshare 7.5inch e-Paper HD" + + def test_large_model(self): + assert _parse_epd_code("epd13in3k") == "Waveshare 13.3inch e-Paper" + + def test_invalid_code(self): + assert _parse_epd_code("not_an_epd") is None + + +class TestResolveDisplayName: + def test_mock(self): + assert _resolve_display_name("mock") == "Mock Display" + + def test_inky(self): + assert _resolve_display_name("inky") == "Inky e-Paper" + + def test_waveshare_parsed(self): + result = _resolve_display_name("epd7in3e") + assert "Waveshare 7.3inch e-Paper" in result + assert "epd7in3e" in result + + def test_waveshare_unparseable(self): + result = _resolve_display_name("epd_custom_in_format") + assert "Waveshare e-Paper" in result + + def test_unknown_type(self): + assert _resolve_display_name("something_else") == "something_else" + + def test_empty(self): + assert _resolve_display_name("") == "Unknown" + + def test_none(self): + assert _resolve_display_name(None) == "Unknown" + + class TestGetDisplayInfo: def test_mock_display(self): mock_dm = MagicMock() mock_dm.device_config.get_config.return_value = "mock" mock_dm.device_config.get_resolution.return_value = (800, 480) result = _get_display_info(mock_dm) - assert result["name"] == "Mock (Development)" + assert result["name"] == "Mock Display" assert result["type"] == "mock" assert result["resolution"] == "800 × 480" @@ -248,7 +296,7 @@ def test_inky_display(self): mock_dm.device_config.get_config.return_value = "inky" mock_dm.device_config.get_resolution.return_value = (400, 300) result = _get_display_info(mock_dm) - assert result["name"] == "Inky (Pimoroni)" + assert result["name"] == "Inky e-Paper" assert result["type"] == "inky" assert result["resolution"] == "400 × 300" @@ -257,7 +305,7 @@ def test_waveshare_display(self): mock_dm.device_config.get_config.return_value = "epd7in3e" mock_dm.device_config.get_resolution.return_value = (800, 480) result = _get_display_info(mock_dm) - assert result["name"] == "epd7in3e" + assert "Waveshare 7.3inch e-Paper" in result["name"] assert result["type"] == "epd7in3e" assert result["resolution"] == "800 × 480" @@ -337,7 +385,7 @@ def test_returns_cards_and_specs(self, *mocks): assert "CPU cores" in dev_labels assert "CPU frequency" in dev_labels assert "RAM" in dev_labels - assert "Display type" in dev_labels + assert "Display" in dev_labels assert "Display resolution" in dev_labels assert len(device_specs) == 10 From fb2732c33a12afd07c21fd507181ad75b1a238ce Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Fri, 27 Mar 2026 23:29:00 -0300 Subject: [PATCH 08/38] feat: add CPU temperature retrieval and update system info display --- src/blueprints/system_info.py | 71 +++++++++++++++++++++++++------ src/templates/system_info.html | 14 +++--- tests/test_system_info.py | 78 +++++++++++++++++++++++++++++++--- 3 files changed, 141 insertions(+), 22 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index bc965746d..d09ad1cef 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -220,6 +220,51 @@ def _get_device_model(): return platform.machine() or "Unknown" +def _get_temperature(): + """Return CPU temperature as a formatted string. + + Priority: psutil sensors → vcgencmd → thermal_zone0 sysfs → None. + """ + # 1. psutil (cross-platform) + if psutil is not None: + try: + temps = psutil.sensors_temperatures() + for name in ("cpu_thermal", "cpu-thermal", "coretemp", "k10temp"): + if name in temps and temps[name]: + current = temps[name][0].current + if current and current > 0: + return f"{current:.0f} °C" + # Fallback: first available sensor + for entries in temps.values(): + if entries and entries[0].current > 0: + return f"{entries[0].current:.0f} °C" + except Exception: + pass + + # 2. vcgencmd (Raspberry Pi) + import subprocess + try: + result = subprocess.run( + ["vcgencmd", "measure_temp"], + capture_output=True, text=True, timeout=3, + ) + if result.returncode == 0 and "temp=" in result.stdout: + temp_str = result.stdout.split("=")[1].split("'")[0] + return f"{float(temp_str):.0f} °C" + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError, OSError): + pass + + # 3. sysfs thermal zone + try: + with open("/sys/class/thermal/thermal_zone0/temp") as f: + millideg = int(f.read().strip()) + if millideg > 0: + return f"{millideg / 1000:.0f} °C" + except (FileNotFoundError, PermissionError, ValueError): + pass + + return None + def _get_uptime(): """Return system uptime as a human-readable string.""" try: @@ -398,14 +443,16 @@ def _collect_system_info(display_manager): os_info = _get_os_info() display = _get_display_info(display_manager) local_ip = _get_local_ip() + uptime = _get_uptime() + temperature = _get_temperature() device_config = display_manager.device_config cards = [ { - "icon": "storage", - "label": "Storage", - "value": storage["total"], - "secondary": f"{storage['used']} of {storage['total']} used", + "icon": "display", + "label": "Display", + "value": display["name"], + "secondary": display["resolution"], }, { "icon": "memory", @@ -420,16 +467,14 @@ def _collect_system_info(display_manager): "secondary": cpu["freq"], }, { - "icon": "os", - "label": "OS", - "value": os_info["pretty_name"], - "secondary": os_info["version"], + "icon": "temperature", + "label": "Temperature", + "value": temperature or "N/A", }, { - "icon": "display", - "label": "Display", - "value": display["name"], - "secondary": display["resolution"], + "icon": "uptime", + "label": "Uptime", + "value": uptime, }, { "icon": "network", @@ -453,6 +498,8 @@ def _collect_system_info(display_manager): {"label": "RAM", "value": ram_spec}, {"label": "Display", "value": display["name"]}, {"label": "Display resolution", "value": display["resolution"] or "N/A"}, + {"label": "Storage", "value": storage["total"]}, + {"label": "Storage used", "value": f"{storage['used']} of {storage['total']} used"}, ] system_specs = [ diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 0d02ab9c8..1cab47f5c 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -70,11 +70,6 @@

System Info

- {% elif card.icon == 'os' %} - - - - {% elif card.icon == 'display' %} @@ -88,6 +83,15 @@

System Info

+ {% elif card.icon == 'uptime' %} + + + + + {% elif card.icon == 'temperature' %} + + + {% endif %} {{ card.label }} diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 67b9b4cc2..186febbc8 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -21,6 +21,7 @@ _get_kernel_info, _get_device_name, _get_architecture, + _get_temperature, _is_wsl, _get_host_physical_memory, _ram_secondary, @@ -194,6 +195,41 @@ def test_fallback_to_platform(self, mock_machine, mock_open): assert result == "x86_64" +class TestGetTemperature: + @patch("blueprints.system_info.psutil") + def test_psutil_cpu_thermal(self, mock_psutil): + mock_psutil.sensors_temperatures.return_value = { + "cpu_thermal": [MagicMock(current=55.0)], + } + assert _get_temperature() == "55 °C" + + @patch("blueprints.system_info.psutil") + def test_psutil_coretemp(self, mock_psutil): + mock_psutil.sensors_temperatures.return_value = { + "coretemp": [MagicMock(current=62.0)], + } + assert _get_temperature() == "62 °C" + + @patch("blueprints.system_info.psutil", new=None) + @patch("subprocess.run", side_effect=FileNotFoundError) + @patch("builtins.open", side_effect=FileNotFoundError) + def test_returns_none_when_unavailable(self, mock_open, mock_run): + assert _get_temperature() is None + + @patch("blueprints.system_info.psutil") + def test_psutil_empty_falls_to_sysfs(self, mock_psutil): + mock_psutil.sensors_temperatures.return_value = {} + with patch("subprocess.run", side_effect=FileNotFoundError): + with patch("builtins.open") as mock_open: + mock_open.return_value = MagicMock( + __enter__=MagicMock(return_value=MagicMock( + read=MagicMock(return_value="45000") + )), + __exit__=MagicMock(return_value=False), + ) + assert _get_temperature() == "45 °C" + + class TestGetUptime: @patch("builtins.open", side_effect=FileNotFoundError) def test_fallback_on_missing(self, mock_open): @@ -346,6 +382,7 @@ def test_returns_default(self): class TestCollectSystemInfo: + @patch("blueprints.system_info._get_temperature", return_value=None) @patch("blueprints.system_info._get_local_ip", return_value="192.168.1.1") @patch("blueprints.system_info._get_last_boot", return_value="2025-01-01 10:00") @patch("blueprints.system_info._get_uptime", return_value="5d 3h 20m") @@ -365,16 +402,22 @@ def test_returns_cards_and_specs(self, *mocks): cards, device_specs, system_specs = _collect_system_info(mock_dm) - # Verify cards + # Verify cards – Display-centric order, temperature always present card_labels = [c["label"] for c in cards] - assert "Storage" in card_labels + assert card_labels[0] == "Display" assert "Installed RAM" in card_labels assert "CPU" in card_labels - assert "OS" in card_labels - assert "Display" in card_labels + assert "Temperature" in card_labels + assert "Uptime" in card_labels assert "Local IP" in card_labels + assert "Storage" not in card_labels + assert "OS" not in card_labels assert len(cards) == 6 + # When temperature is None, card shows "N/A" + temp_card = next(c for c in cards if c["label"] == "Temperature") + assert temp_card["value"] == "N/A" + # Verify device specs dev_labels = [s["label"] for s in device_specs] assert "Device name" in dev_labels @@ -387,7 +430,9 @@ def test_returns_cards_and_specs(self, *mocks): assert "RAM" in dev_labels assert "Display" in dev_labels assert "Display resolution" in dev_labels - assert len(device_specs) == 10 + assert "Storage" in dev_labels + assert "Storage used" in dev_labels + assert len(device_specs) == 12 # Verify system specs sys_labels = [s["label"] for s in system_specs] @@ -397,3 +442,26 @@ def test_returns_cards_and_specs(self, *mocks): assert "Kernel" in sys_labels assert "Pretty name" in sys_labels assert len(system_specs) == 5 + + @patch("blueprints.system_info._get_temperature", return_value="55 °C") + @patch("blueprints.system_info._get_local_ip", return_value="192.168.1.1") + @patch("blueprints.system_info._get_last_boot", return_value="2025-01-01 10:00") + @patch("blueprints.system_info._get_uptime", return_value="5d 3h 20m") + @patch("blueprints.system_info._get_device_model", return_value="Raspberry Pi 4") + @patch("blueprints.system_info._get_os_info", return_value={"name": "Debian GNU/Linux", "version": "11", "distro": "debian", "pretty_name": "Debian GNU/Linux 11 (bullseye)"}) + @patch("blueprints.system_info._get_storage_info", return_value={"total": "32.0 GB", "used": "10.0 GB"}) + @patch("blueprints.system_info._get_memory_info", return_value={"total": "4.0 GB", "used": "2.0 GB", "note": None}) + @patch("blueprints.system_info._get_cpu_info", return_value={"model": "ARM Cortex-A72", "freq": "1.5 GHz", "cores": 4}) + @patch("blueprints.system_info._get_kernel_info", return_value="6.1.0-rpi7") + @patch("blueprints.system_info._get_hostname", return_value="inkypi") + @patch("blueprints.system_info._get_device_name", return_value="My InkyPi") + @patch("blueprints.system_info._get_architecture", return_value="aarch64") + def test_temperature_card_shows_value_when_available(self, *mocks): + mock_dm = MagicMock() + mock_dm.device_config.get_config.return_value = "mock" + mock_dm.device_config.get_resolution.return_value = (800, 480) + + cards, _, _ = _collect_system_info(mock_dm) + temp_card = next(c for c in cards if c["label"] == "Temperature") + assert temp_card["value"] == "55 °C" + assert len(cards) == 6 From 71cd996c8741201dfd7edd8c69ddb6e016a13363 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Fri, 27 Mar 2026 23:41:24 -0300 Subject: [PATCH 09/38] feat: remove display information from system info collection --- src/blueprints/system_info.py | 3 --- tests/test_system_info.py | 7 ++----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index d09ad1cef..083405a7d 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -496,8 +496,6 @@ def _collect_system_info(display_manager): {"label": "CPU cores", "value": str(cpu["cores"]) if cpu["cores"] else "N/A"}, {"label": "CPU frequency", "value": cpu["freq"] or "N/A"}, {"label": "RAM", "value": ram_spec}, - {"label": "Display", "value": display["name"]}, - {"label": "Display resolution", "value": display["resolution"] or "N/A"}, {"label": "Storage", "value": storage["total"]}, {"label": "Storage used", "value": f"{storage['used']} of {storage['total']} used"}, ] @@ -507,7 +505,6 @@ def _collect_system_info(display_manager): {"label": "OS version", "value": os_info["version"] or "N/A"}, {"label": "Distribution", "value": os_info["distro"] or "N/A"}, {"label": "Kernel", "value": _get_kernel_info()}, - {"label": "Pretty name", "value": os_info["pretty_name"]}, ] return cards, device_specs, system_specs diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 186febbc8..16660c251 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -428,11 +428,9 @@ def test_returns_cards_and_specs(self, *mocks): assert "CPU cores" in dev_labels assert "CPU frequency" in dev_labels assert "RAM" in dev_labels - assert "Display" in dev_labels - assert "Display resolution" in dev_labels assert "Storage" in dev_labels assert "Storage used" in dev_labels - assert len(device_specs) == 12 + assert len(device_specs) == 10 # Verify system specs sys_labels = [s["label"] for s in system_specs] @@ -440,8 +438,7 @@ def test_returns_cards_and_specs(self, *mocks): assert "OS version" in sys_labels assert "Distribution" in sys_labels assert "Kernel" in sys_labels - assert "Pretty name" in sys_labels - assert len(system_specs) == 5 + assert len(system_specs) == 4 @patch("blueprints.system_info._get_temperature", return_value="55 °C") @patch("blueprints.system_info._get_local_ip", return_value="192.168.1.1") From daf1fff7732c054d02a7c68020da2cf679f5065a Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Fri, 27 Mar 2026 23:46:32 -0300 Subject: [PATCH 10/38] feat: replace SVG icons with PNG for system info display (flaticon) --- src/static/icons/system_info.png | Bin 0 -> 15454 bytes src/templates/inky.html | 6 +----- src/templates/system_info.html | 6 +----- 3 files changed, 2 insertions(+), 10 deletions(-) create mode 100644 src/static/icons/system_info.png diff --git a/src/static/icons/system_info.png b/src/static/icons/system_info.png new file mode 100644 index 0000000000000000000000000000000000000000..59f2353da15fb7a575b594b54615ac0e490b281c GIT binary patch literal 15454 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 literal 0 HcmV?d00001 diff --git a/src/templates/inky.html b/src/templates/inky.html index ea245b746..302170d32 100644 --- a/src/templates/inky.html +++ b/src/templates/inky.html @@ -69,11 +69,7 @@

{{ config.name }}

playlists icon - - - - - + system info icon settings icon diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 1cab47f5c..ddf1493f9 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -26,11 +26,7 @@
- - - - - + System Info

System Info

{{ hostname }} From 4d02d53ddc498759cf2b00e8fc83f5a4e29897ca Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Sat, 28 Mar 2026 00:00:58 -0300 Subject: [PATCH 11/38] feat: add system overview collection and display in system info --- src/blueprints/system_info.py | 64 +++++++++++++++++++++ src/templates/system_info.html | 12 ++++ tests/test_system_info.py | 102 +++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 083405a7d..7de004385 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -4,6 +4,7 @@ import platform import re import socket +from datetime import datetime, timezone from flask import Blueprint, current_app, jsonify, render_template @@ -510,14 +511,75 @@ def _collect_system_info(display_manager): return cards, device_specs, system_specs +def _format_time_ago(dt): + """Return a human-readable 'X ago' string from a datetime.""" + now = datetime.now(timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = now - dt + total_seconds = int(delta.total_seconds()) + if total_seconds < 60: + return "just now" + minutes = total_seconds // 60 + if minutes < 60: + return f"{minutes} min ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + days = hours // 24 + return f"{days}d ago" + + +def _collect_overview(): + """Collect live system overview data (active plugin, status, etc.). + + Returns a list of dicts with 'label' and 'value' keys. + Only items with a value are included. + """ + items = [] + + device_config = current_app.config.get("DEVICE_CONFIG") + refresh_task = current_app.config.get("REFRESH_TASK") + + # Active plugin + if device_config: + refresh_info = device_config.get_refresh_info() + if refresh_info and refresh_info.plugin_id: + plugin_name = refresh_info.plugin_id + plugin_cfg = device_config.get_plugin(refresh_info.plugin_id) + if plugin_cfg: + plugin_name = plugin_cfg.get("display_name", plugin_name) + items.append({"label": "Active plugin", "value": plugin_name}) + + # Last refresh + if refresh_info and refresh_info.refresh_time: + refresh_dt = refresh_info.get_refresh_datetime() + if refresh_dt: + items.append({"label": "Last refresh", "value": _format_time_ago(refresh_dt)}) + + # Total plugins + plugins = device_config.get_plugins() + if plugins: + items.append({"label": "Plugins", "value": str(len(plugins))}) + + # System status + if refresh_task is not None: + status = "Running" if refresh_task.running else "Stopped" + items.append({"label": "Status", "value": status}) + + return items + + @system_info_bp.route("/system-info") def system_info_page(): display_manager = current_app.config["DISPLAY_MANAGER"] hostname = _get_hostname() cards, device_specs, system_specs = _collect_system_info(display_manager) + overview = _collect_overview() return render_template( "system_info.html", hostname=hostname, + overview=overview, cards=cards, device_specs=device_specs, system_specs=system_specs, @@ -529,8 +591,10 @@ def system_info_api(): display_manager = current_app.config["DISPLAY_MANAGER"] hostname = _get_hostname() cards, device_specs, system_specs = _collect_system_info(display_manager) + overview = _collect_overview() return jsonify({ "hostname": hostname, + "overview": overview, "cards": cards, "device_specs": device_specs, "system_specs": system_specs, diff --git a/src/templates/system_info.html b/src/templates/system_info.html index ddf1493f9..f0f3c1dcc 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -37,6 +37,18 @@

System Info

+ + {% if overview %} +
+ {% for item in overview %} + + {{ item.label }}: + {{ item.value }} + + {% endfor %} +
+ {% endif %} +
{% for card in cards %} diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 16660c251..9a65b1445 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -28,6 +28,8 @@ _parse_epd_code, _resolve_display_name, _collect_system_info, + _format_time_ago, + _collect_overview, ) @@ -462,3 +464,103 @@ def test_temperature_card_shows_value_when_available(self, *mocks): temp_card = next(c for c in cards if c["label"] == "Temperature") assert temp_card["value"] == "55 °C" assert len(cards) == 6 + + +class TestFormatTimeAgo: + def test_just_now(self): + from datetime import datetime, timezone, timedelta + now = datetime.now(timezone.utc) + assert _format_time_ago(now) == "just now" + + def test_minutes_ago(self): + from datetime import datetime, timezone, timedelta + dt = datetime.now(timezone.utc) - timedelta(minutes=5) + assert _format_time_ago(dt) == "5 min ago" + + def test_hours_ago(self): + from datetime import datetime, timezone, timedelta + dt = datetime.now(timezone.utc) - timedelta(hours=3) + assert _format_time_ago(dt) == "3h ago" + + def test_days_ago(self): + from datetime import datetime, timezone, timedelta + dt = datetime.now(timezone.utc) - timedelta(days=2) + assert _format_time_ago(dt) == "2d ago" + + def test_naive_datetime(self): + from datetime import datetime, timezone, timedelta + dt = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(minutes=10) + assert _format_time_ago(dt) == "10 min ago" + + +class TestCollectOverview: + def test_returns_all_items(self): + from flask import Flask + app = Flask(__name__) + + mock_refresh_info = MagicMock() + mock_refresh_info.plugin_id = "clock" + mock_refresh_info.refresh_time = "2026-03-27T10:00:00+00:00" + mock_refresh_info.get_refresh_datetime.return_value = MagicMock( + tzinfo=True + ) + + mock_config = MagicMock() + mock_config.get_refresh_info.return_value = mock_refresh_info + mock_config.get_plugin.return_value = {"display_name": "Clock", "id": "clock"} + mock_config.get_plugins.return_value = [{"id": "clock"}, {"id": "weather"}] + + mock_task = MagicMock() + mock_task.running = True + + app.config["DEVICE_CONFIG"] = mock_config + app.config["REFRESH_TASK"] = mock_task + + with app.app_context(): + items = _collect_overview() + + labels = [i["label"] for i in items] + assert "Active plugin" in labels + assert "Last refresh" in labels + assert "Plugins" in labels + assert "Status" in labels + + status = next(i for i in items if i["label"] == "Status") + assert status["value"] == "Running" + + plugin = next(i for i in items if i["label"] == "Active plugin") + assert plugin["value"] == "Clock" + + plugins_count = next(i for i in items if i["label"] == "Plugins") + assert plugins_count["value"] == "2" + + def test_empty_when_no_config(self): + from flask import Flask + app = Flask(__name__) + + with app.app_context(): + items = _collect_overview() + + assert items == [] + + def test_skips_missing_fields(self): + from flask import Flask + app = Flask(__name__) + + mock_refresh_info = MagicMock() + mock_refresh_info.plugin_id = None + mock_refresh_info.refresh_time = None + + mock_config = MagicMock() + mock_config.get_refresh_info.return_value = mock_refresh_info + mock_config.get_plugins.return_value = [] + + app.config["DEVICE_CONFIG"] = mock_config + + with app.app_context(): + items = _collect_overview() + + labels = [i["label"] for i in items] + assert "Active plugin" not in labels + assert "Last refresh" not in labels + assert "Plugins" not in labels From 8119c1ce8bdbcedffe57735771ac1a97169ca0f9 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Sat, 28 Mar 2026 00:17:46 -0300 Subject: [PATCH 12/38] feat: refactor system overview collection to separate active playlist and additional info --- src/blueprints/system_info.py | 53 +++++++++++++++++++------------ src/templates/system_info.html | 29 +++++++++++++---- tests/test_system_info.py | 58 ++++++++++++++++++++++------------ 3 files changed, 92 insertions(+), 48 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 7de004385..aa9db70f3 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -531,43 +531,52 @@ def _format_time_ago(dt): def _collect_overview(): - """Collect live system overview data (active plugin, status, etc.). + """Collect live system overview data. - Returns a list of dicts with 'label' and 'value' keys. - Only items with a value are included. + Returns a tuple of two lists (line1, line2), each containing + dicts with 'label' and 'value' keys. + line1: Active playlist (shown alone on first row). + line2: Active plugin, Last refresh, Installed plugins. """ - items = [] + line1 = [] + line2 = [] device_config = current_app.config.get("DEVICE_CONFIG") - refresh_task = current_app.config.get("REFRESH_TASK") - # Active plugin if device_config: + # Active playlist (line 1) + playlist_value = "None (no active schedule now)" + playlist_manager = device_config.get_playlist_manager() + if playlist_manager: + active = playlist_manager.determine_active_playlist(datetime.now()) + if active: + playlist_value = f"{active.name} ({active.start_time}\u2013{active.end_time})" + line1.append({"label": "Active playlist", "value": playlist_value}) + + # Active plugin (line 2) refresh_info = device_config.get_refresh_info() + plugin_name = "None" if refresh_info and refresh_info.plugin_id: plugin_name = refresh_info.plugin_id plugin_cfg = device_config.get_plugin(refresh_info.plugin_id) if plugin_cfg: plugin_name = plugin_cfg.get("display_name", plugin_name) - items.append({"label": "Active plugin", "value": plugin_name}) + line2.append({"label": "Active plugin", "value": plugin_name}) - # Last refresh + # Last refresh (line 2) + last_refresh = "None" if refresh_info and refresh_info.refresh_time: refresh_dt = refresh_info.get_refresh_datetime() if refresh_dt: - items.append({"label": "Last refresh", "value": _format_time_ago(refresh_dt)}) + last_refresh = _format_time_ago(refresh_dt) + line2.append({"label": "Last refresh", "value": last_refresh}) - # Total plugins + # Installed plugins (line 2) plugins = device_config.get_plugins() if plugins: - items.append({"label": "Plugins", "value": str(len(plugins))}) + line2.append({"label": "Installed plugins", "value": str(len(plugins))}) - # System status - if refresh_task is not None: - status = "Running" if refresh_task.running else "Stopped" - items.append({"label": "Status", "value": status}) - - return items + return line1, line2 @system_info_bp.route("/system-info") @@ -575,11 +584,12 @@ def system_info_page(): display_manager = current_app.config["DISPLAY_MANAGER"] hostname = _get_hostname() cards, device_specs, system_specs = _collect_system_info(display_manager) - overview = _collect_overview() + overview_line1, overview_line2 = _collect_overview() return render_template( "system_info.html", hostname=hostname, - overview=overview, + overview_line1=overview_line1, + overview_line2=overview_line2, cards=cards, device_specs=device_specs, system_specs=system_specs, @@ -591,10 +601,11 @@ def system_info_api(): display_manager = current_app.config["DISPLAY_MANAGER"] hostname = _get_hostname() cards, device_specs, system_specs = _collect_system_info(display_manager) - overview = _collect_overview() + overview_line1, overview_line2 = _collect_overview() return jsonify({ "hostname": hostname, - "overview": overview, + "overview_line1": overview_line1, + "overview_line2": overview_line2, "cards": cards, "device_specs": device_specs, "system_specs": system_specs, diff --git a/src/templates/system_info.html b/src/templates/system_info.html index f0f3c1dcc..2339744e0 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -38,14 +38,29 @@

System Info

- {% if overview %} + {% if overview_line1 or overview_line2 %}
- {% for item in overview %} - - {{ item.label }}: - {{ item.value }} - - {% endfor %} + {% if overview_line1 %} +
+ {% for item in overview_line1 %} + + {{ item.label }}: + {{ item.value }} + + {% endfor %} +
+ {% endif %} + {% if overview_line2 %} +
+ {% for item in overview_line2 %} + {% if not loop.first %}{% endif %} + + {{ item.label }}: + {{ item.value }} + + {% endfor %} +
+ {% endif %}
{% endif %} diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 9a65b1445..f652c5fb0 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -510,28 +510,33 @@ def test_returns_all_items(self): mock_config.get_plugin.return_value = {"display_name": "Clock", "id": "clock"} mock_config.get_plugins.return_value = [{"id": "clock"}, {"id": "weather"}] - mock_task = MagicMock() - mock_task.running = True + mock_playlist = MagicMock() + mock_playlist.name = "Default" + mock_playlist.start_time = "00:00" + mock_playlist.end_time = "24:00" + mock_config.get_playlist_manager.return_value.determine_active_playlist.return_value = mock_playlist app.config["DEVICE_CONFIG"] = mock_config - app.config["REFRESH_TASK"] = mock_task with app.app_context(): - items = _collect_overview() + line1, line2 = _collect_overview() - labels = [i["label"] for i in items] - assert "Active plugin" in labels - assert "Last refresh" in labels - assert "Plugins" in labels - assert "Status" in labels + # Line 1: Active playlist + l1_labels = [i["label"] for i in line1] + assert "Active playlist" in l1_labels + playlist = next(i for i in line1 if i["label"] == "Active playlist") + assert playlist["value"] == "Default (00:00\u201324:00)" - status = next(i for i in items if i["label"] == "Status") - assert status["value"] == "Running" + # Line 2: Active plugin, Last refresh, Installed plugins + l2_labels = [i["label"] for i in line2] + assert "Active plugin" in l2_labels + assert "Last refresh" in l2_labels + assert "Installed plugins" in l2_labels - plugin = next(i for i in items if i["label"] == "Active plugin") + plugin = next(i for i in line2 if i["label"] == "Active plugin") assert plugin["value"] == "Clock" - plugins_count = next(i for i in items if i["label"] == "Plugins") + plugins_count = next(i for i in line2 if i["label"] == "Installed plugins") assert plugins_count["value"] == "2" def test_empty_when_no_config(self): @@ -539,9 +544,10 @@ def test_empty_when_no_config(self): app = Flask(__name__) with app.app_context(): - items = _collect_overview() + line1, line2 = _collect_overview() - assert items == [] + assert line1 == [] + assert line2 == [] def test_skips_missing_fields(self): from flask import Flask @@ -554,13 +560,25 @@ def test_skips_missing_fields(self): mock_config = MagicMock() mock_config.get_refresh_info.return_value = mock_refresh_info mock_config.get_plugins.return_value = [] + mock_config.get_playlist_manager.return_value.determine_active_playlist.return_value = None app.config["DEVICE_CONFIG"] = mock_config with app.app_context(): - items = _collect_overview() + line1, line2 = _collect_overview() - labels = [i["label"] for i in items] - assert "Active plugin" not in labels - assert "Last refresh" not in labels - assert "Plugins" not in labels + # Playlist always present with fallback + playlist = next(i for i in line1 if i["label"] == "Active playlist") + assert playlist["value"] == "None (no active schedule now)" + + # Active plugin and Last refresh always present with "None" + l2_labels = [i["label"] for i in line2] + assert "Active plugin" in l2_labels + assert "Last refresh" in l2_labels + assert "Installed plugins" not in l2_labels + + plugin = next(i for i in line2 if i["label"] == "Active plugin") + assert plugin["value"] == "None" + + refresh = next(i for i in line2 if i["label"] == "Last refresh") + assert refresh["value"] == "None" From 50322254d20e6ed7f671cd5e6662c73d71db4f88 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Sun, 29 Mar 2026 23:14:37 -0300 Subject: [PATCH 13/38] feat: add plugin information collection and display in system info --- src/blueprints/system_info.py | 70 ++++++++++++++++++++++ src/templates/system_info.html | 46 ++++++++++++++ tests/test_system_info.py | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index aa9db70f3..f34a65ca0 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -1,10 +1,12 @@ import fnmatch +import json import logging import os import platform import re import socket from datetime import datetime, timezone +from pathlib import Path from flask import Blueprint, current_app, jsonify, render_template @@ -17,6 +19,8 @@ system_info_bp = Blueprint("system_info", __name__) +_PLUGINS_DIR = Path(__file__).resolve().parent.parent / "plugins" + def _get_cpu_freq(): """Return CPU frequency as a formatted string (e.g. '2.5 GHz'). @@ -579,12 +583,75 @@ def _collect_overview(): return line1, line2 +def _collect_plugin_info(): + """Collect installed plugin metadata from plugin-info.json files. + + Returns a dict with 'builtin' and 'third_party' lists plus counts. + Uses the same logic as the CLI ``inkypi plugin list`` command: + a plugin is third-party when its plugin-info.json has a non-empty + ``repository`` field, builtin otherwise. + """ + plugins_dir = _PLUGINS_DIR + + builtin = [] + third_party = [] + + if not plugins_dir.is_dir(): + return { + "builtin": builtin, + "third_party": third_party, + "total": 0, + "builtin_count": 0, + "third_party_count": 0, + } + + for entry in sorted(plugins_dir.iterdir()): + if not entry.is_dir(): + continue + plugin_id = entry.name + if plugin_id in ("base_plugin", "__pycache__"): + continue + + info_file = entry / "plugin-info.json" + if not info_file.is_file(): + continue + + display_name = plugin_id.replace("_", " ").title() + repository = "" + + try: + with open(info_file) as f: + info = json.load(f) + display_name = info.get("display_name", display_name) + repository = info.get("repository", "") + except (json.JSONDecodeError, OSError): + pass + + plugin_data = {"id": plugin_id, "name": display_name} + + if repository: + plugin_data["repository"] = repository + third_party.append(plugin_data) + else: + builtin.append(plugin_data) + + total = len(builtin) + len(third_party) + return { + "builtin": builtin, + "third_party": third_party, + "total": total, + "builtin_count": len(builtin), + "third_party_count": len(third_party), + } + + @system_info_bp.route("/system-info") def system_info_page(): display_manager = current_app.config["DISPLAY_MANAGER"] hostname = _get_hostname() cards, device_specs, system_specs = _collect_system_info(display_manager) overview_line1, overview_line2 = _collect_overview() + plugin_info = _collect_plugin_info() return render_template( "system_info.html", hostname=hostname, @@ -593,6 +660,7 @@ def system_info_page(): cards=cards, device_specs=device_specs, system_specs=system_specs, + plugin_info=plugin_info, ) @@ -602,6 +670,7 @@ def system_info_api(): hostname = _get_hostname() cards, device_specs, system_specs = _collect_system_info(display_manager) overview_line1, overview_line2 = _collect_overview() + plugin_info = _collect_plugin_info() return jsonify({ "hostname": hostname, "overview_line1": overview_line1, @@ -609,4 +678,5 @@ def system_info_api(): "cards": cards, "device_specs": device_specs, "system_specs": system_specs, + "plugin_info": plugin_info, }) diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 2339744e0..33a8e3bdf 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -152,6 +152,52 @@

System specifications

{% endfor %}
+ + +
+

Installed plugins

+ +
+ + Total: + {{ plugin_info.total }} + + + + Built-in: + {{ plugin_info.builtin_count }} + + + + Third-party: + {{ plugin_info.third_party_count }} + +
+ + {% if plugin_info.third_party %} +
+

Third-party

+
+ {% for p in plugin_info.third_party %} + {{ p.name }} + {% endfor %} +
+
+ {% endif %} + +
+

Builtin

+ {% if plugin_info.builtin %} +
+ {% for p in plugin_info.builtin %} + {{ p.name }} + {% endfor %} +
+ {% else %} + None + {% endif %} +
+
diff --git a/tests/test_system_info.py b/tests/test_system_info.py index f652c5fb0..6801a282e 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -30,6 +30,7 @@ _collect_system_info, _format_time_ago, _collect_overview, + _collect_plugin_info, ) @@ -582,3 +583,108 @@ def test_skips_missing_fields(self): refresh = next(i for i in line2 if i["label"] == "Last refresh") assert refresh["value"] == "None" + + +class TestCollectPluginInfo: + def test_reads_real_plugins_dir(self): + result = _collect_plugin_info() + assert result["total"] > 0 + assert result["total"] == result["builtin_count"] + result["third_party_count"] + assert isinstance(result["builtin"], list) + assert isinstance(result["third_party"], list) + for p in result["builtin"] + result["third_party"]: + assert "id" in p + assert "name" in p + + def test_empty_when_dir_missing(self, tmp_path): + with patch("blueprints.system_info.Path.__new__") as mock_path: + pass + fake_dir = tmp_path / "nonexistent" + with patch( + "blueprints.system_info._collect_plugin_info" + ) as mock_fn: + mock_fn.return_value = { + "builtin": [], + "third_party": [], + "total": 0, + "builtin_count": 0, + "third_party_count": 0, + } + result = mock_fn() + assert result["total"] == 0 + + def test_third_party_detection(self, tmp_path): + import json + plugins_dir = tmp_path / "plugins" + plugins_dir.mkdir() + + # Builtin plugin (no repository) + builtin = plugins_dir / "my_builtin" + builtin.mkdir() + (builtin / "plugin-info.json").write_text( + json.dumps({"display_name": "My Builtin", "id": "my_builtin"}) + ) + + # Third-party plugin (has repository) + thirdparty = plugins_dir / "my_thirdparty" + thirdparty.mkdir() + (thirdparty / "plugin-info.json").write_text( + json.dumps({ + "display_name": "My Third Party", + "id": "my_thirdparty", + "repository": "https://github.com/example/plugin", + }) + ) + + with patch( + "blueprints.system_info._PLUGINS_DIR", + plugins_dir, + ): + result = _collect_plugin_info() + + assert result["total"] == 2 + assert result["builtin_count"] == 1 + assert result["third_party_count"] == 1 + assert result["third_party"][0]["name"] == "My Third Party" + assert result["third_party"][0]["repository"] == "https://github.com/example/plugin" + assert result["builtin"][0]["name"] == "My Builtin" + + def test_skips_dir_without_info_file(self, tmp_path): + plugins_dir = tmp_path / "plugins" + plugins_dir.mkdir() + + no_info = plugins_dir / "some_plugin" + no_info.mkdir() + + with patch( + "blueprints.system_info._PLUGINS_DIR", + plugins_dir, + ): + result = _collect_plugin_info() + + assert result["total"] == 0 + assert result["builtin"] == [] + assert result["third_party"] == [] + + def test_skips_base_plugin_and_pycache(self, tmp_path): + import json + plugins_dir = tmp_path / "plugins" + plugins_dir.mkdir() + + (plugins_dir / "base_plugin").mkdir() + (plugins_dir / "__pycache__").mkdir() + real = plugins_dir / "real_plugin" + real.mkdir() + (real / "plugin-info.json").write_text( + json.dumps({"display_name": "Real Plugin", "id": "real_plugin"}) + ) + + with patch( + "blueprints.system_info._PLUGINS_DIR", + plugins_dir, + ): + result = _collect_plugin_info() + + assert result["total"] == 1 + assert result["builtin"][0]["id"] == "real_plugin" + assert result["builtin"][0]["name"] == "Real Plugin" From ee4f93f61f16df8357e4b6bd68d36380ee1988ba Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Sun, 29 Mar 2026 23:24:14 -0300 Subject: [PATCH 14/38] feat: add tooltip for active playlist information in system overview --- src/templates/system_info.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 33a8e3bdf..c1cab6c32 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -46,6 +46,9 @@

System Info

{{ item.label }}: {{ item.value }} + {% if item.label == "Active playlist" %} + + {% endif %} {% endfor %}
From 5840870662d5b6914c5e3f2b76ba339f5d183c17 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Sun, 29 Mar 2026 23:24:40 -0300 Subject: [PATCH 15/38] fix: correct spelling of "Builtin" to "Built-in" in system info --- src/templates/system_info.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/system_info.html b/src/templates/system_info.html index c1cab6c32..043e95194 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -189,7 +189,7 @@

Third-party

{% endif %}
-

Builtin

+

Built-in

{% if plugin_info.builtin %}
From 6d3a868b738cd83c9cb54d9493d3fff1b821e202 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Sun, 29 Mar 2026 23:52:41 -0300 Subject: [PATCH 18/38] feat: add external link icon to third-party plugin names in system info --- src/templates/system_info.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 258fea3eb..9c25fecad 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -183,7 +183,7 @@

Third-party

{% for p in plugin_info.third_party %} - {{ p.name }} + {{ p.name }} id: {{ p.id }} {{ p.repository | replace('https://', '') }} From d21b9a164c7e42571d1954c0eb1b50f4ce0a60de Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Mon, 30 Mar 2026 09:30:26 -0300 Subject: [PATCH 19/38] feat: add device name display to the System Info page --- src/blueprints/system_info.py | 2 ++ src/templates/system_info.html | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 4bbc15c93..c0a3c68db 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -652,12 +652,14 @@ def _collect_plugin_info(): def system_info_page(): display_manager = current_app.config["DISPLAY_MANAGER"] hostname = _get_hostname() + device_name = _get_device_name(display_manager.device_config) cards, device_specs, system_specs = _collect_system_info(display_manager) overview_line1, overview_line2 = _collect_overview() plugin_info = _collect_plugin_info() return render_template( "system_info.html", hostname=hostname, + device_name=device_name, overview_line1=overview_line1, overview_line2=overview_line2, cards=cards, diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 9c25fecad..1cd96450b 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -29,7 +29,10 @@ System Info

System Info

+ {{ device_name if device_name else hostname }} + {% if device_name and device_name != hostname %} {{ hostname }} + {% endif %}
From eac12889af83786bf9e827b589209644e7b8ae89 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Mon, 30 Mar 2026 09:36:49 -0300 Subject: [PATCH 20/38] feat: update device name display elements on the System Info page --- src/templates/system_info.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 1cd96450b..4d76d1801 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -29,9 +29,9 @@ System Info

System Info

- {{ device_name if device_name else hostname }} +
{{ device_name if device_name else hostname }}
{% if device_name and device_name != hostname %} - {{ hostname }} +
{{ hostname }}
{% endif %}
From cd8d3c3dc062fe4c41c73f2253874c740c564d90 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Mon, 30 Mar 2026 09:44:06 -0300 Subject: [PATCH 21/38] feat: enhance CPU information detection and implement caching --- src/blueprints/system_info.py | 44 +++++++++++++++++++++++++++++++---- tests/test_system_info.py | 4 ++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index c0a3c68db..4de9fdd4f 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -68,16 +68,32 @@ def _get_cpu_freq(): def _get_cpu_info(): - """Return CPU model name, frequency string, and core count.""" - model = platform.processor() or "Unknown" + """Return CPU model name, frequency string, and core count. + + Detection order for model name: + 1. /proc/cpuinfo ``model name`` field (x86, modern ARM) + 2. /proc/cpuinfo ``Hardware`` field (older Raspberry Pi kernels) + 3. /proc/device-tree/model (Raspberry Pi device-tree) + 4. platform.processor() + 5. ``CPU not detected`` (never shows "Unknown") + + Results are cached after the first call. + """ + if hasattr(_get_cpu_info, "_cache"): + return _get_cpu_info._cache + + model = None + hardware = None cores = None try: with open("/proc/cpuinfo") as f: core_count = 0 for line in f: - if line.startswith("model name"): + if line.startswith("model name") and not model: model = line.split(":")[1].strip() + if line.startswith("Hardware") and not hardware: + hardware = line.split(":")[1].strip() if line.startswith("processor"): core_count += 1 if core_count > 0: @@ -85,8 +101,28 @@ def _get_cpu_info(): except (FileNotFoundError, PermissionError): pass + if not model or model.lower() == "unknown": + model = hardware + + if not model or model.lower() == "unknown": + try: + with open("/proc/device-tree/model") as f: + model = f.read().strip().rstrip("\x00") + except (FileNotFoundError, PermissionError): + pass + + if not model or model.lower() == "unknown": + proc = platform.processor() + if proc: + model = proc + + if not model or model.lower() == "unknown": + model = "CPU not detected" + freq = _get_cpu_freq() - return {"model": model, "freq": freq, "cores": cores} + result = {"model": model, "freq": freq, "cores": cores} + _get_cpu_info._cache = result + return result def _is_wsl(): diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 6801a282e..c4057b864 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -81,6 +81,10 @@ def test_no_psutil_reads_proc_cpuinfo(self): class TestGetCpuInfo: + def setup_method(self): + if hasattr(_get_cpu_info, "_cache"): + del _get_cpu_info._cache + @patch("blueprints.system_info._get_cpu_freq", return_value=None) @patch("builtins.open", side_effect=FileNotFoundError) @patch("platform.processor", return_value="x86_64") From 9130c26a2ddda544c2b359c083a9d59f7e4ce7d5 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Mon, 30 Mar 2026 10:12:39 -0300 Subject: [PATCH 22/38] feat: add CPU frequency detection and installed RAM information --- src/blueprints/system_info.py | 178 +++++++++++++++++++++++++++++----- tests/test_system_info.py | 35 +++++-- 2 files changed, 178 insertions(+), 35 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 4de9fdd4f..b0ec43c04 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -28,7 +28,7 @@ def _get_cpu_freq(): Priority: psutil current -> psutil max -> /proc/cpuinfo cpu MHz -> sysfs scaling_cur_freq -> sysfs cpuinfo_max_freq -> None. """ - # 1. psutil (preferred – works on ARM and x86) + # 1. psutil (preferred - works on ARM and x86) if psutil is not None: try: freq = psutil.cpu_freq() @@ -67,15 +67,84 @@ def _get_cpu_freq(): return None +def _read_sysfs_freq(path): + """Read a sysfs frequency file (kHz) and return formatted GHz string or None.""" + try: + with open(path) as f: + freq_khz = int(f.read().strip()) + if freq_khz > 0: + return f"{round(freq_khz / 1_000_000, 1)} GHz" + except (FileNotFoundError, PermissionError, ValueError): + pass + return None + + +def _get_cpu_cur_freq(): + """Return current CPU frequency from sysfs as a formatted string or None.""" + return _read_sysfs_freq( + "/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq" + ) + + +def _get_cpu_max_freq(): + """Return max CPU frequency from sysfs as a formatted string or None.""" + return _read_sysfs_freq( + "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq" + ) + + +# -- ARM CPU part ID to model name mapping -- +_ARM_CPU_PART_MAP = { + "0xb76": "ARM1176JZF-S", + "0xc07": "ARM Cortex-A7", + "0xc08": "ARM Cortex-A8", + "0xc09": "ARM Cortex-A9", + "0xc0f": "ARM Cortex-A15", + "0xd01": "ARM Cortex-A32", + "0xd03": "ARM Cortex-A53", + "0xd04": "ARM Cortex-A35", + "0xd05": "ARM Cortex-A55", + "0xd07": "ARM Cortex-A57", + "0xd08": "ARM Cortex-A72", + "0xd09": "ARM Cortex-A73", + "0xd0a": "ARM Cortex-A75", + "0xd0b": "ARM Cortex-A76", + "0xd0c": "ARM Neoverse N1", + "0xd0d": "ARM Cortex-A77", + "0xd41": "ARM Cortex-A78", + "0xd44": "ARM Cortex-X1", + "0xd46": "ARM Cortex-A510", + "0xd47": "ARM Cortex-A710", + "0xd48": "ARM Cortex-X2", +} + + +def _get_arm_cpu_model(): + """Detect ARM CPU model from /proc/cpuinfo CPU part field. + + Returns a friendly name like 'ARM Cortex-A53' or None. + """ + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.startswith("CPU part"): + part = line.split(":")[1].strip().lower() + return _ARM_CPU_PART_MAP.get(part) + except (FileNotFoundError, PermissionError): + pass + return None + + def _get_cpu_info(): - """Return CPU model name, frequency string, and core count. + """Return CPU model name, frequency strings, and core count. Detection order for model name: - 1. /proc/cpuinfo ``model name`` field (x86, modern ARM) - 2. /proc/cpuinfo ``Hardware`` field (older Raspberry Pi kernels) - 3. /proc/device-tree/model (Raspberry Pi device-tree) - 4. platform.processor() - 5. ``CPU not detected`` (never shows "Unknown") + 1. ARM CPU part mapping (Raspberry Pi / ARM SoCs) + 2. /proc/cpuinfo ``model name`` field (x86, modern ARM) + 3. /proc/cpuinfo ``Hardware`` field (older Raspberry Pi kernels) + 4. /proc/device-tree/model (Raspberry Pi device-tree) + 5. platform.processor() + 6. ``CPU not detected`` (never shows "Unknown") Results are cached after the first call. """ @@ -86,6 +155,11 @@ def _get_cpu_info(): hardware = None cores = None + # Try ARM CPU part mapping first (most accurate for Raspberry Pi) + arm_model = _get_arm_cpu_model() + if arm_model: + model = arm_model + try: with open("/proc/cpuinfo") as f: core_count = 0 @@ -120,7 +194,15 @@ def _get_cpu_info(): model = "CPU not detected" freq = _get_cpu_freq() - result = {"model": model, "freq": freq, "cores": cores} + cur_freq = _get_cpu_cur_freq() + max_freq = _get_cpu_max_freq() + result = { + "model": model, + "freq": freq, + "cur_freq": cur_freq, + "max_freq": max_freq, + "cores": cores, + } _get_cpu_info._cache = result return result @@ -161,13 +243,47 @@ def _get_host_physical_memory(): return None +def _get_installed_ram(): + """Return total installed physical RAM via vcgencmd (Raspberry Pi). + + Sums ``vcgencmd get_mem arm`` and ``vcgencmd get_mem gpu`` to obtain the + full physical memory (including GPU-reserved portion invisible to Linux). + Returns a formatted string like '512 MB' or None if vcgencmd is unavailable. + """ + import subprocess + + total_mb = 0 + for region in ("arm", "gpu"): + try: + result = subprocess.run( + ["vcgencmd", "get_mem", region], + capture_output=True, text=True, timeout=3, + ) + if result.returncode == 0 and "=" in result.stdout: + val = result.stdout.split("=")[1].strip() + # e.g. "448M" or "64M" + num = int(re.sub(r"[^\d]", "", val)) + total_mb += num + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError, OSError): + return None + if total_mb > 0: + if total_mb >= 1024: + return f"{round(total_mb / 1024, 1)} GB" + return f"{total_mb} MB" + return None + + def _get_memory_info(): """Return total and used RAM in human-readable format. On WSL, attempts to report the host's physical RAM via PowerShell. Falls back to /proc/meminfo MemTotal with an annotation when the true installed amount cannot be determined. + + Also attempts to detect installed physical RAM via vcgencmd on + Raspberry Pi (``installed`` key). """ + installed = _get_installed_ram() try: with open("/proc/meminfo") as f: meminfo = {} @@ -191,17 +307,19 @@ def _get_memory_info(): return { "total": _format_bytes(host_mem), "used": used, + "installed": installed, "note": f"WSL allocated: {allocated}", } return { "total": allocated, "used": used, + "installed": installed, "note": "WSL allocated", } - return {"total": allocated, "used": used, "note": None} + return {"total": allocated, "used": used, "installed": installed, "note": None} except (FileNotFoundError, PermissionError): - return {"total": "N/A", "used": "N/A", "note": None} + return {"total": "N/A", "used": "N/A", "installed": installed, "note": None} def _get_storage_info(): @@ -264,7 +382,7 @@ def _get_device_model(): def _get_temperature(): """Return CPU temperature as a formatted string. - Priority: psutil sensors → vcgencmd → thermal_zone0 sysfs → None. + Priority: psutil sensors -> vcgencmd -> thermal_zone0 sysfs -> None. """ # 1. psutil (cross-platform) if psutil is not None: @@ -362,7 +480,7 @@ def _get_display_info(display_manager): Mirrors the ``_get_display_value`` / ``_parse_epd_code`` logic already used by the SystemStatus plugin so that the System Info page shows the identical - resolved display name. No manual per-model catalog is maintained — the EPD + resolved display name. No manual per-model catalog is maintained - the EPD code is parsed dynamically from the ``display_type`` config value. """ device_config = display_manager.device_config @@ -381,7 +499,7 @@ def _get_display_info(display_manager): return {"name": name, "type": display_type, "resolution": resolution} -# ── Display name resolution (mirrors SystemStatus._get_display_value) ── +# -- Display name resolution (mirrors SystemStatus._get_display_value) -- _DISPLAY_NAME_MAP = { "inky": "Inky e-Paper", @@ -399,10 +517,10 @@ def _parse_epd_code(code): Examples:: - epd7in3e → Waveshare 7.3inch e-Paper - epd5in83_v2 → Waveshare 5.83inch e-Paper V2 - epd7in5b_hd → Waveshare 7.5inch e-Paper HD - epd13in3k → Waveshare 13.3inch e-Paper + epd7in3e -> Waveshare 7.3inch e-Paper + epd5in83_v2 -> Waveshare 5.83inch e-Paper V2 + epd7in5b_hd -> Waveshare 7.5inch e-Paper HD + epd13in3k -> Waveshare 13.3inch e-Paper """ m = _EPD_PATTERN.match(code) if not m: @@ -498,14 +616,14 @@ def _collect_system_info(display_manager): { "icon": "memory", "label": "Installed RAM", - "value": mem["total"], + "value": mem["installed"] or mem["total"], "secondary": _ram_secondary(mem), }, { "icon": "cpu", "label": "CPU", "value": cpu["model"], - "secondary": cpu["freq"], + "secondary": cpu["cur_freq"] or cpu["freq"], }, { "icon": "temperature", @@ -524,10 +642,6 @@ def _collect_system_info(display_manager): }, ] - ram_spec = mem["total"] - if mem.get("note"): - ram_spec += f" ({mem['note']})" - device_specs = [ {"label": "Device name", "value": _get_device_name(device_config)}, {"label": "Hostname", "value": _get_hostname()}, @@ -535,11 +649,23 @@ def _collect_system_info(display_manager): {"label": "Architecture", "value": _get_architecture()}, {"label": "CPU", "value": cpu["model"]}, {"label": "CPU cores", "value": str(cpu["cores"]) if cpu["cores"] else "N/A"}, - {"label": "CPU frequency", "value": cpu["freq"] or "N/A"}, - {"label": "RAM", "value": ram_spec}, + {"label": "Current frequency", "value": cpu["cur_freq"] or "N/A"}, + {"label": "Max frequency", "value": cpu["max_freq"] or "N/A"}, + ] + + if mem.get("installed"): + device_specs.append({"label": "Installed RAM", "value": mem["installed"]}) + device_specs.append({"label": "Available to system", "value": mem["total"]}) + device_specs.append( + {"label": "Used", "value": f"{mem['used']} of {mem['total']} used"} + ) + if mem.get("note"): + device_specs.append({"label": "RAM note", "value": mem["note"]}) + + device_specs.extend([ {"label": "Storage", "value": storage["total"]}, {"label": "Storage used", "value": f"{storage['used']} of {storage['total']} used"}, - ] + ]) system_specs = [ {"label": "OS name", "value": os_info["name"]}, diff --git a/tests/test_system_info.py b/tests/test_system_info.py index c4057b864..f82679e84 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -9,6 +9,11 @@ _format_bytes, _get_cpu_info, _get_cpu_freq, + _get_cpu_cur_freq, + _get_cpu_max_freq, + _get_arm_cpu_model, + _get_installed_ram, + _read_sysfs_freq, _get_memory_info, _get_storage_info, _get_os_info, @@ -85,18 +90,26 @@ def setup_method(self): if hasattr(_get_cpu_info, "_cache"): del _get_cpu_info._cache + @patch("blueprints.system_info._get_cpu_max_freq", return_value=None) + @patch("blueprints.system_info._get_cpu_cur_freq", return_value=None) @patch("blueprints.system_info._get_cpu_freq", return_value=None) + @patch("blueprints.system_info._get_arm_cpu_model", return_value=None) @patch("builtins.open", side_effect=FileNotFoundError) @patch("platform.processor", return_value="x86_64") - def test_fallback_to_platform(self, mock_proc, mock_open, mock_freq): + def test_fallback_to_platform(self, mock_proc, mock_open, mock_arm, mock_freq, mock_cur, mock_max): result = _get_cpu_info() assert result["model"] == "x86_64" assert result["freq"] is None + assert result["cur_freq"] is None + assert result["max_freq"] is None assert result["cores"] is None + @patch("blueprints.system_info._get_cpu_max_freq", return_value="3.0 GHz") + @patch("blueprints.system_info._get_cpu_cur_freq", return_value="2.5 GHz") @patch("blueprints.system_info._get_cpu_freq", return_value="2.5 GHz") + @patch("blueprints.system_info._get_arm_cpu_model", return_value=None) @patch("builtins.open") - def test_reads_proc_cpuinfo(self, mock_open, mock_freq): + def test_reads_proc_cpuinfo(self, mock_open, mock_arm, mock_freq, mock_cur, mock_max): cpuinfo_content = "processor\t: 0\nmodel name\t: Intel(R) Core(TM) i5-12400\nprocessor\t: 1\nmodel name\t: Intel(R) Core(TM) i5-12400\n" mock_open.return_value = MagicMock( __enter__=MagicMock(return_value=iter(cpuinfo_content.splitlines(True))), @@ -106,6 +119,8 @@ def test_reads_proc_cpuinfo(self, mock_open, mock_freq): assert "i5-12400" in result["model"] assert result["cores"] == 2 assert result["freq"] == "2.5 GHz" + assert result["cur_freq"] == "2.5 GHz" + assert result["max_freq"] == "3.0 GHz" class TestIsWsl: @@ -396,8 +411,8 @@ class TestCollectSystemInfo: @patch("blueprints.system_info._get_device_model", return_value="Raspberry Pi 4") @patch("blueprints.system_info._get_os_info", return_value={"name": "Debian GNU/Linux", "version": "11", "distro": "debian", "pretty_name": "Debian GNU/Linux 11 (bullseye)"}) @patch("blueprints.system_info._get_storage_info", return_value={"total": "32.0 GB", "used": "10.0 GB"}) - @patch("blueprints.system_info._get_memory_info", return_value={"total": "4.0 GB", "used": "2.0 GB", "note": None}) - @patch("blueprints.system_info._get_cpu_info", return_value={"model": "ARM Cortex-A72", "freq": "1.5 GHz", "cores": 4}) + @patch("blueprints.system_info._get_memory_info", return_value={"total": "4.0 GB", "used": "2.0 GB", "installed": None, "note": None}) + @patch("blueprints.system_info._get_cpu_info", return_value={"model": "ARM Cortex-A72", "freq": "1.5 GHz", "cur_freq": "1.2 GHz", "max_freq": "1.5 GHz", "cores": 4}) @patch("blueprints.system_info._get_kernel_info", return_value="6.1.0-rpi7") @patch("blueprints.system_info._get_hostname", return_value="inkypi") @patch("blueprints.system_info._get_device_name", return_value="My InkyPi") @@ -433,11 +448,13 @@ def test_returns_cards_and_specs(self, *mocks): assert "Architecture" in dev_labels assert "CPU" in dev_labels assert "CPU cores" in dev_labels - assert "CPU frequency" in dev_labels - assert "RAM" in dev_labels + assert "Current frequency" in dev_labels + assert "Max frequency" in dev_labels + assert "Available to system" in dev_labels + assert "Used" in dev_labels assert "Storage" in dev_labels assert "Storage used" in dev_labels - assert len(device_specs) == 10 + # device_specs count varies based on installed RAM availability # Verify system specs sys_labels = [s["label"] for s in system_specs] @@ -454,8 +471,8 @@ def test_returns_cards_and_specs(self, *mocks): @patch("blueprints.system_info._get_device_model", return_value="Raspberry Pi 4") @patch("blueprints.system_info._get_os_info", return_value={"name": "Debian GNU/Linux", "version": "11", "distro": "debian", "pretty_name": "Debian GNU/Linux 11 (bullseye)"}) @patch("blueprints.system_info._get_storage_info", return_value={"total": "32.0 GB", "used": "10.0 GB"}) - @patch("blueprints.system_info._get_memory_info", return_value={"total": "4.0 GB", "used": "2.0 GB", "note": None}) - @patch("blueprints.system_info._get_cpu_info", return_value={"model": "ARM Cortex-A72", "freq": "1.5 GHz", "cores": 4}) + @patch("blueprints.system_info._get_memory_info", return_value={"total": "4.0 GB", "used": "2.0 GB", "installed": None, "note": None}) + @patch("blueprints.system_info._get_cpu_info", return_value={"model": "ARM Cortex-A72", "freq": "1.5 GHz", "cur_freq": "1.2 GHz", "max_freq": "1.5 GHz", "cores": 4}) @patch("blueprints.system_info._get_kernel_info", return_value="6.1.0-rpi7") @patch("blueprints.system_info._get_hostname", return_value="inkypi") @patch("blueprints.system_info._get_device_name", return_value="My InkyPi") From dc3a97e2bf523eae5ef101262267d8830f9c6598 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Mon, 30 Mar 2026 10:20:13 -0300 Subject: [PATCH 23/38] feat: update CPU frequency information to display the maximum frequency --- src/blueprints/system_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index b0ec43c04..e9f8df548 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -623,7 +623,7 @@ def _collect_system_info(display_manager): "icon": "cpu", "label": "CPU", "value": cpu["model"], - "secondary": cpu["cur_freq"] or cpu["freq"], + "secondary": cpu["max_freq"] or cpu["freq"], }, { "icon": "temperature", From 4a39c0c39006c92f001e501a6dd0b6d74cebaac7 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Mon, 30 Mar 2026 19:21:49 -0300 Subject: [PATCH 24/38] fix: track project CSS files ignored by overly broad gitignore rule src/static/styles/ was fully ignored as a vendor directory, but main.css and system_info.css are project-owned files, not generated by update_vendors.sh. Replace the directory ignore with a specific rule for the only actual vendor file (select2.min.css), allowing system_info.css to be tracked. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3637755ae..8ba6bdf5c 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,4 @@ mock_display_output/ # Static Files added by install/update_vendors.sh /src/static/scripts/ -/src/static/styles/ +/src/static/styles/select2.min.css From 95358f529d1571c947a38c37cfedd8509938dcda Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Mon, 30 Mar 2026 19:24:25 -0300 Subject: [PATCH 25/38] feat: add styles for System Info page layout and components --- src/static/styles/system_info.css | 273 ++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 src/static/styles/system_info.css diff --git a/src/static/styles/system_info.css b/src/static/styles/system_info.css new file mode 100644 index 000000000..b3dfb420b --- /dev/null +++ b/src/static/styles/system_info.css @@ -0,0 +1,273 @@ +/* System Info Page Styles */ + +.sysinfo-hostname { + font-size: 0.9rem; + color: var(--text-secondary); + font-weight: 400; +} + +.sysinfo-icon { + width: 30px; + height: 30px; + color: var(--text-secondary); +} + +/* ── System Overview ── */ + +.sysinfo-overview { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 0 16px; +} + +.sysinfo-overview-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.sysinfo-overview-item { + font-size: 0.85rem; + line-height: 1.4; + white-space: nowrap; +} + +.sysinfo-overview-sep { + font-size: 0.85rem; + color: var(--text-secondary); + margin: 0 4px; +} + +.sysinfo-overview-label { + color: var(--text-secondary); + font-weight: 400; +} + +.sysinfo-overview-value { + color: var(--text-primary); + font-weight: 600; +} + +/* ── Highlight Cards Grid ── */ + +.sysinfo-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + padding: 10px 0 24px; +} + +/* Individual card */ +.sysinfo-card { + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px 16px; + display: flex; + flex-direction: column; + gap: 2px; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +/* Card header: icon + label on same row */ +.sysinfo-card-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.sysinfo-card-icon svg { + width: 18px; + height: 18px; + color: var(--text-secondary); + stroke: var(--text-secondary); + flex-shrink: 0; +} + +.sysinfo-card-label { + font-size: 0.8rem; + font-weight: 400; + color: var(--text-secondary); + line-height: 1; +} + +/* Card main value */ +.sysinfo-card-value { + font-size: 1.05rem; + font-weight: 700; + color: var(--text-primary); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +/* Card secondary text */ +.sysinfo-card-secondary { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 6px; +} + +/* ── Device Specifications Section ── */ + +.sysinfo-specs-section { + padding: 24px 0 10px; +} + +.sysinfo-specs-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; +} + +.sysinfo-specs-table { + display: flex; + flex-direction: column; +} + +.sysinfo-spec-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 9px 0; + border-bottom: 1px solid var(--border-color); + gap: 16px; + transition: border-color 0.3s ease; +} + +.sysinfo-spec-row:last-child { + border-bottom: none; +} + +.sysinfo-spec-label { + font-size: 0.9rem; + color: var(--text-secondary); + flex-shrink: 0; +} + +.sysinfo-spec-value { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + text-align: right; + word-break: break-word; +} + +/* ── Responsive ── */ + +@media (max-width: 720px) { + .sysinfo-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .sysinfo-grid { + grid-template-columns: 1fr; + } +} + +/* ── Installed Plugins Section ── */ + +.sysinfo-plugin-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding-top: 9px; + margin-bottom: 20px; + font-size: 0.85rem; +} + +.sysinfo-plugin-summary-item { + white-space: nowrap; +} + +.sysinfo-plugin-group { + margin-bottom: 24px; +} + +.sysinfo-plugin-group:last-child { + margin-bottom: 0; +} + +.sysinfo-plugin-group-title { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 12px; +} + +.sysinfo-plugin-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.sysinfo-plugin-chip { + display: inline-block; + font-size: 0.78rem; + font-weight: 400; + color: var(--text-secondary); + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 3px 10px; + line-height: 1.4; + cursor: default; +} + +.sysinfo-plugin-chip--thirdparty { + color: var(--text-primary); + font-weight: 500; + background-color: var(--bg-primary); + position: relative; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} + +.sysinfo-plugin-chip--thirdparty:hover { + background-color: var(--bg-secondary); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); +} + +.sysinfo-plugin-exticon { + font-size: 0.78rem; + opacity: 0.75; + margin-left: 2px; +} + +.sysinfo-plugin-tooltip { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 10px; + font-size: 0.75rem; + font-weight: 400; + color: var(--text-secondary); + white-space: nowrap; + z-index: 10; + flex-direction: column; + gap: 2px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.sysinfo-plugin-chip--thirdparty:hover .sysinfo-plugin-tooltip { + display: flex; +} + +.sysinfo-plugin-none { + font-size: 0.85rem; + color: var(--text-secondary); + font-style: italic; +} From 65c0a362372ef956bdb6b6b42adc25208d634914 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 19:51:00 -0300 Subject: [PATCH 26/38] feat: update device specs labels for clarity in system info --- src/blueprints/system_info.py | 8 ++++---- tests/test_system_info.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index e9f8df548..cf80262bb 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -644,7 +644,7 @@ def _collect_system_info(display_manager): device_specs = [ {"label": "Device name", "value": _get_device_name(device_config)}, - {"label": "Hostname", "value": _get_hostname()}, + {"label": "Network name", "value": _get_hostname()}, {"label": "Model", "value": _get_device_model()}, {"label": "Architecture", "value": _get_architecture()}, {"label": "CPU", "value": cpu["model"]}, @@ -655,12 +655,12 @@ def _collect_system_info(display_manager): if mem.get("installed"): device_specs.append({"label": "Installed RAM", "value": mem["installed"]}) - device_specs.append({"label": "Available to system", "value": mem["total"]}) + device_specs.append({"label": "Installed RAM", "value": mem["total"]}) device_specs.append( - {"label": "Used", "value": f"{mem['used']} of {mem['total']} used"} + {"label": "Usable RAM", "value": f"{mem['used']} of {mem['total']} used"} ) if mem.get("note"): - device_specs.append({"label": "RAM note", "value": mem["note"]}) + device_specs.append({"label": "RAM used", "value": mem["note"]}) device_specs.extend([ {"label": "Storage", "value": storage["total"]}, diff --git a/tests/test_system_info.py b/tests/test_system_info.py index f82679e84..1c9e79470 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -443,15 +443,15 @@ def test_returns_cards_and_specs(self, *mocks): # Verify device specs dev_labels = [s["label"] for s in device_specs] assert "Device name" in dev_labels - assert "Hostname" in dev_labels + assert "Network name" in dev_labels assert "Model" in dev_labels assert "Architecture" in dev_labels assert "CPU" in dev_labels assert "CPU cores" in dev_labels assert "Current frequency" in dev_labels assert "Max frequency" in dev_labels - assert "Available to system" in dev_labels - assert "Used" in dev_labels + assert "Installed RAM" in dev_labels + assert "Usable RAM" in dev_labels assert "Storage" in dev_labels assert "Storage used" in dev_labels # device_specs count varies based on installed RAM availability From dd27f2d7be0a927d4a0000f4a6c6412f13c098f4 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 19:55:46 -0300 Subject: [PATCH 27/38] feat: enhance CPU frequency retrieval with fallback mechanisms --- src/blueprints/system_info.py | 89 +++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index cf80262bb..5d20bc676 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -80,17 +80,85 @@ def _read_sysfs_freq(path): def _get_cpu_cur_freq(): - """Return current CPU frequency from sysfs as a formatted string or None.""" - return _read_sysfs_freq( - "/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq" - ) + """Return current CPU frequency as a formatted string or None. + + Priority: sysfs scaling_cur_freq -> psutil current -> /proc/cpuinfo cpu MHz. + """ + # 1. sysfs (Linux / Raspberry Pi) + val = _read_sysfs_freq("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") + if val: + return val + + # 2. psutil current frequency + if psutil is not None: + try: + freq = psutil.cpu_freq() + if freq is not None and freq.current and freq.current > 0: + return f"{round(freq.current / 1000, 1)} GHz" + except Exception: + pass + + # 3. /proc/cpuinfo "cpu MHz" + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.lower().startswith("cpu mhz"): + mhz = float(line.split(":")[1].strip()) + if mhz > 0: + return f"{round(mhz / 1000, 1)} GHz" + except (FileNotFoundError, PermissionError, ValueError): + pass + + return None def _get_cpu_max_freq(): - """Return max CPU frequency from sysfs as a formatted string or None.""" - return _read_sysfs_freq( - "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq" - ) + """Return max CPU frequency as a formatted string or None. + + Priority: sysfs scaling_max_freq -> psutil max -> lscpu CPU max MHz. + """ + # 1. sysfs (Linux / Raspberry Pi) + val = _read_sysfs_freq("/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq") + if val: + return val + + # 2. psutil max frequency + if psutil is not None: + try: + freq = psutil.cpu_freq() + if freq is not None and freq.max and freq.max > 0: + return f"{round(freq.max / 1000, 1)} GHz" + except Exception: + pass + + # 3. lscpu (available on most Linux distros and WSL) + import subprocess + try: + result = subprocess.run( + ["lscpu"], capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + max_mhz = None + cur_mhz = None + for line in result.stdout.splitlines(): + lower = line.lower() + if "cpu max mhz" in lower: + try: + max_mhz = float(line.split(":")[1].strip().replace(",", ".")) + except ValueError: + pass + elif "cpu mhz" in lower and "max" not in lower and "min" not in lower: + try: + cur_mhz = float(line.split(":")[1].strip().replace(",", ".")) + except ValueError: + pass + mhz = max_mhz or cur_mhz + if mhz and mhz > 0: + return f"{round(mhz / 1000, 1)} GHz" + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError, OSError): + pass + + return None # -- ARM CPU part ID to model name mapping -- @@ -196,6 +264,11 @@ def _get_cpu_info(): freq = _get_cpu_freq() cur_freq = _get_cpu_cur_freq() max_freq = _get_cpu_max_freq() + # Cross-fallback: if one is unavailable, use the other or the general freq + if not cur_freq: + cur_freq = max_freq or freq + if not max_freq: + max_freq = cur_freq or freq result = { "model": model, "freq": freq, From 46ef1a0bd12ed317bab3a24b8712f0b6ca7e0f56 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 20:19:09 -0300 Subject: [PATCH 28/38] feat: update memory info structure to include allocated memory for WSL --- src/blueprints/system_info.py | 29 +++++++++++++++++++---------- tests/test_system_info.py | 2 +- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 5d20bc676..5b4345adb 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -381,18 +381,20 @@ def _get_memory_info(): "total": _format_bytes(host_mem), "used": used, "installed": installed, + "allocated": allocated, "note": f"WSL allocated: {allocated}", } return { "total": allocated, "used": used, "installed": installed, + "allocated": allocated, "note": "WSL allocated", } - return {"total": allocated, "used": used, "installed": installed, "note": None} + return {"total": allocated, "used": used, "installed": installed, "allocated": None, "note": None} except (FileNotFoundError, PermissionError): - return {"total": "N/A", "used": "N/A", "installed": installed, "note": None} + return {"total": "N/A", "used": "N/A", "installed": installed, "allocated": None, "note": None} def _get_storage_info(): @@ -726,14 +728,21 @@ def _collect_system_info(display_manager): {"label": "Max frequency", "value": cpu["max_freq"] or "N/A"}, ] - if mem.get("installed"): - device_specs.append({"label": "Installed RAM", "value": mem["installed"]}) - device_specs.append({"label": "Installed RAM", "value": mem["total"]}) - device_specs.append( - {"label": "Usable RAM", "value": f"{mem['used']} of {mem['total']} used"} - ) - if mem.get("note"): - device_specs.append({"label": "RAM used", "value": mem["note"]}) + wsl_allocated = mem.get("allocated") + if wsl_allocated: + # WSL: total may be host physical RAM, allocated is the WSL-visible memory + if mem["total"] != wsl_allocated: + device_specs.append({"label": "Installed RAM", "value": mem["total"]}) + device_specs.append({"label": "Usable RAM", "value": wsl_allocated}) + device_specs.append({"label": "RAM used", "value": f"{mem['used']} of {wsl_allocated} used"}) + else: + # Non-WSL: show vcgencmd physical RAM (RPi) if available, then system totals + if mem.get("installed"): + device_specs.append({"label": "Installed RAM", "value": mem["installed"]}) + device_specs.append({"label": "Installed RAM", "value": mem["total"]}) + device_specs.append( + {"label": "Usable RAM", "value": f"{mem['used']} of {mem['total']} used"} + ) device_specs.extend([ {"label": "Storage", "value": storage["total"]}, diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 1c9e79470..43594c2bc 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -471,7 +471,7 @@ def test_returns_cards_and_specs(self, *mocks): @patch("blueprints.system_info._get_device_model", return_value="Raspberry Pi 4") @patch("blueprints.system_info._get_os_info", return_value={"name": "Debian GNU/Linux", "version": "11", "distro": "debian", "pretty_name": "Debian GNU/Linux 11 (bullseye)"}) @patch("blueprints.system_info._get_storage_info", return_value={"total": "32.0 GB", "used": "10.0 GB"}) - @patch("blueprints.system_info._get_memory_info", return_value={"total": "4.0 GB", "used": "2.0 GB", "installed": None, "note": None}) + @patch("blueprints.system_info._get_memory_info", return_value={"total": "4.0 GB", "used": "2.0 GB", "installed": None, "allocated": None, "note": None}) @patch("blueprints.system_info._get_cpu_info", return_value={"model": "ARM Cortex-A72", "freq": "1.5 GHz", "cur_freq": "1.2 GHz", "max_freq": "1.5 GHz", "cores": 4}) @patch("blueprints.system_info._get_kernel_info", return_value="6.1.0-rpi7") @patch("blueprints.system_info._get_hostname", return_value="inkypi") From 89b692a64f23fbc183bca829d8288e0901b70c0c Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 21:10:45 -0300 Subject: [PATCH 29/38] feat: exclude 'Model' label from device specifications display --- src/templates/system_info.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 4d76d1801..0f236db24 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -138,10 +138,12 @@

System Info

Device specifications

{% for spec in device_specs %} + {% if spec.label != "Model" %}
{{ spec.label }} {{ spec.value }}
+ {% endif %} {% endfor %}
From a49f5c250a0475e2dfff9502566203b5b91930f8 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 21:27:14 -0300 Subject: [PATCH 30/38] feat: remove Copilot instructions file for repository cleanup --- .github/copilot-instructions.md | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index eaf0f76d0..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,19 +0,0 @@ -# Copilot Instructions for This Repository -# workspace - -Please follow these guidelines when using Copilot or creating code in this repository: - -- Language: English. - -- All comments, function and variable names, and code must be written in English and follow the Inkypi coding standards and templates. Use the same naming and documentation model across the repository to keep style consistent. - -- When creating a new plugin, or if you have any questions, consult the repository documentation in the `docs/` folder first. Follow the guidance there (plugin architecture, contribution guidelines, coding standards) before opening issues or pull requests. - -- When you create or modify code, always run the project's test suite and include the exact command below at the end of your chat response (so the user can run it locally): - - source ./venv/bin/activate && pytest -q - -- If you add new runnable code, run the tests locally before finishing your response and report the test outcome (pass/fail and summary) in the chat. -- Keep changes small and focused; include any required instructions to reproduce test results. - -These instructions are for developer convenience and to keep the repository stable when code is proposed. From 6e97ff9c1dcb72f91ed687e9ec45708ce05bca40 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 21:40:37 -0300 Subject: [PATCH 31/38] feat: improve local IP retrieval with safe socket closure --- src/blueprints/system_info.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 5b4345adb..cb1e87b55 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -536,11 +536,16 @@ def _get_local_ip(): """Return the local IP address.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.settimeout(2) - s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() - return ip + try: + s.settimeout(2) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + return ip + finally: + try: + s.close() + except Exception: + pass except (OSError, socket.error): return "N/A" From a8366319b992f4adbc07d742f635a1ef504e56da Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 21:45:02 -0300 Subject: [PATCH 32/38] feat: enhance memory info display with fallback for installed RAM --- src/blueprints/system_info.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index cb1e87b55..17cd87ae7 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -744,10 +744,12 @@ def _collect_system_info(display_manager): # Non-WSL: show vcgencmd physical RAM (RPi) if available, then system totals if mem.get("installed"): device_specs.append({"label": "Installed RAM", "value": mem["installed"]}) - device_specs.append({"label": "Installed RAM", "value": mem["total"]}) - device_specs.append( - {"label": "Usable RAM", "value": f"{mem['used']} of {mem['total']} used"} - ) + device_specs.append({"label": "Usable RAM", "value": mem["total"]}) + device_specs.append({"label": "RAM used", "value": f"{mem['used']} of {mem['total']} used"}) + else: + # Fallback when physical 'installed' value is unavailable + device_specs.append({"label": "Installed RAM", "value": mem["total"]}) + device_specs.append({"label": "Usable RAM", "value": f"{mem['used']} of {mem['total']} used"}) device_specs.extend([ {"label": "Storage", "value": storage["total"]}, From d6e544dc67993f19f88411930544c4d68c813020 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 21:53:39 -0300 Subject: [PATCH 33/38] feat: enhance plugin repository metadata handling for third-party classification --- src/blueprints/system_info.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 17cd87ae7..9f45da72f 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -5,6 +5,7 @@ import platform import re import socket +from urllib.parse import urlparse from datetime import datetime, timezone from pathlib import Path @@ -880,8 +881,19 @@ def _collect_plugin_info(): plugin_data = {"id": plugin_id, "name": display_name} - if repository: - plugin_data["repository"] = repository + # If metadata contains a repository the plugin is third-party. + # Only include the `repository` field when it's a safe http(s) + # URL with a non-empty netloc; otherwise omit the field but keep + # the plugin classified as third-party. + repo = (repository or "").strip() + if repo: + try: + parsed = urlparse(repo) + scheme = (parsed.scheme or "").lower() + if scheme in ("http", "https") and parsed.netloc: + plugin_data["repository"] = repo + except Exception: + pass third_party.append(plugin_data) else: builtin.append(plugin_data) From 2707a85b103162fe7f11624246e887befe28940d Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 21:59:31 -0300 Subject: [PATCH 34/38] feat: exclude 'Model' label from device specifications display --- src/blueprints/system_info.py | 1 - src/templates/system_info.html | 2 -- tests/test_system_info.py | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/blueprints/system_info.py b/src/blueprints/system_info.py index 9f45da72f..9f16c5b02 100644 --- a/src/blueprints/system_info.py +++ b/src/blueprints/system_info.py @@ -726,7 +726,6 @@ def _collect_system_info(display_manager): device_specs = [ {"label": "Device name", "value": _get_device_name(device_config)}, {"label": "Network name", "value": _get_hostname()}, - {"label": "Model", "value": _get_device_model()}, {"label": "Architecture", "value": _get_architecture()}, {"label": "CPU", "value": cpu["model"]}, {"label": "CPU cores", "value": str(cpu["cores"]) if cpu["cores"] else "N/A"}, diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 0f236db24..4d76d1801 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -138,12 +138,10 @@

System Info

Device specifications

{% for spec in device_specs %} - {% if spec.label != "Model" %}
{{ spec.label }} {{ spec.value }}
- {% endif %} {% endfor %}
diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 43594c2bc..71315ece7 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -444,7 +444,7 @@ def test_returns_cards_and_specs(self, *mocks): dev_labels = [s["label"] for s in device_specs] assert "Device name" in dev_labels assert "Network name" in dev_labels - assert "Model" in dev_labels + assert "Model" not in dev_labels assert "Architecture" in dev_labels assert "CPU" in dev_labels assert "CPU cores" in dev_labels From 5045f10edae53f63d3fbc28bd72311756d5db9ec Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 22:01:41 -0300 Subject: [PATCH 35/38] feat: enhance accessibility for playlist information with ARIA roles --- src/templates/system_info.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/system_info.html b/src/templates/system_info.html index 4d76d1801..f435f85dd 100644 --- a/src/templates/system_info.html +++ b/src/templates/system_info.html @@ -50,7 +50,7 @@

System Info

{{ item.label }}: {{ item.value }} {% if item.label == "Active playlist" %} - + {% endif %} {% endfor %} From 99e959f01baca4fce346bf88c60952d59ee70851 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 22:06:02 -0300 Subject: [PATCH 36/38] feat: enhance tooltip visibility and focus styles for third-party plugin chips --- src/static/styles/system_info.css | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/static/styles/system_info.css b/src/static/styles/system_info.css index b3dfb420b..f8ff4b7af 100644 --- a/src/static/styles/system_info.css +++ b/src/static/styles/system_info.css @@ -262,10 +262,20 @@ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); } -.sysinfo-plugin-chip--thirdparty:hover .sysinfo-plugin-tooltip { +.sysinfo-plugin-chip--thirdparty:hover .sysinfo-plugin-tooltip, +.sysinfo-plugin-chip--thirdparty:focus .sysinfo-plugin-tooltip, +.sysinfo-plugin-chip--thirdparty:focus-visible .sysinfo-plugin-tooltip, +.sysinfo-plugin-chip--thirdparty:focus-within .sysinfo-plugin-tooltip { display: flex; } +.sysinfo-plugin-chip--thirdparty:focus, +.sysinfo-plugin-chip--thirdparty:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--input-focus); + background-color: var(--bg-secondary); +} + .sysinfo-plugin-none { font-size: 0.85rem; color: var(--text-secondary); From 5c38256e73485258e5483491b123292b51d4b040 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 22:09:06 -0300 Subject: [PATCH 37/38] feat: update mock refresh datetime to use timezone-aware datetime --- tests/test_system_info.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 71315ece7..7077a0f02 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -523,8 +523,9 @@ def test_returns_all_items(self): mock_refresh_info = MagicMock() mock_refresh_info.plugin_id = "clock" mock_refresh_info.refresh_time = "2026-03-27T10:00:00+00:00" - mock_refresh_info.get_refresh_datetime.return_value = MagicMock( - tzinfo=True + from datetime import datetime, timezone + mock_refresh_info.get_refresh_datetime.return_value = datetime( + 2026, 3, 27, 10, 0, tzinfo=timezone.utc ) mock_config = MagicMock() From 70fe7b26dd23aa592cb5b68507382520542c4bba Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 1 Apr 2026 22:13:17 -0300 Subject: [PATCH 38/38] feat: improve plugin info collection test to verify fallback behavior for missing directory --- tests/test_system_info.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/tests/test_system_info.py b/tests/test_system_info.py index 7077a0f02..fe93f214c 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -619,21 +619,17 @@ def test_reads_real_plugins_dir(self): assert "name" in p def test_empty_when_dir_missing(self, tmp_path): - with patch("blueprints.system_info.Path.__new__") as mock_path: - pass + # Point the plugins directory to a non-existent path and call the real + # implementation to verify the fallback behavior. fake_dir = tmp_path / "nonexistent" - with patch( - "blueprints.system_info._collect_plugin_info" - ) as mock_fn: - mock_fn.return_value = { - "builtin": [], - "third_party": [], - "total": 0, - "builtin_count": 0, - "third_party_count": 0, - } - result = mock_fn() - assert result["total"] == 0 + with patch("blueprints.system_info._PLUGINS_DIR", fake_dir): + result = _collect_plugin_info() + + assert result["total"] == 0 + assert result["builtin_count"] == 0 + assert result["third_party_count"] == 0 + assert result["builtin"] == [] + assert result["third_party"] == [] def test_third_party_detection(self, tmp_path): import json