feat: S.M.A.R.T. disk health monitoring with CLI and GUI

- Add core module with SMART data parsing and health calculation
- Add CLI with Rich-based terminal UI and health bar visualization
- Add GUI with PyQt6 tabs for summary and detailed views
- Support multiple health indicators (ID 231, 169, 233) for different SSD manufacturers
- Add bilingual support (Russian/English) with auto-detection
- Add GitHub Actions workflow for building binaries on Linux, Windows, macOS
- Calculate health based on reallocated sectors, pending sectors, SSD life, and more

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
kilyabin
2026-03-15 00:15:21 +04:00
commit 19b79a4e13
12 changed files with 2333 additions and 0 deletions

4
smart_report/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""Smart Report - S.M.A.R.T. disk health monitoring tool."""
__version__ = "1.0.0"
__author__ = "kilyabin"

328
smart_report/cli.py Normal file
View File

@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""Console application for S.M.A.R.T. disk health monitoring using Rich."""
import argparse
import sys
from datetime import datetime
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich import box
from rich.text import Text
from smart_report.core import (
collect_all_disks_data,
check_smartctl,
is_root,
get_locale,
get_message,
)
def get_health_icon(health: int, lang: str = None) -> str:
"""Get icon based on health percentage."""
if health >= 85:
return ""
elif health >= 60:
return ""
elif health >= 30:
return ""
else:
return "💥"
def get_health_color(health: int) -> str:
"""Get color based on health percentage."""
if health >= 85:
return "green"
elif health >= 60:
return "yellow"
elif health >= 30:
return "red"
else:
return "bold red"
def create_health_bar(health: int, width: int = 40) -> str:
"""Create a visual health bar."""
filled = int(width * health / 100)
bar = "" * filled + "" * (width - filled)
return bar
def print_disk_panel(console: Console, data, lang: str, show_all: bool = False):
"""Print disk information as a rich panel."""
color = get_health_color(data.health)
icon = get_health_icon(data.health, lang)
health_bar = create_health_bar(data.health)
# Header line
header = (
f"[bold]{get_message('disk', lang)}:[/bold] {data.disk:12} "
f"[bold]{get_message('model', lang)}:[/bold] {data.model:25} "
f"[bold]{get_message('size', lang)}:[/bold] {data.size}"
)
# Health bar
health_display = f"[{color}] {icon} {health_bar} {data.health}%[/{color}]"
# Build content
content = f"{header}\n\n"
content += f"{get_message('health', lang)}: {health_display}\n\n"
if data.smart_supported:
# Status line
status_color = (
"green" if data.status == "GOOD" else ("red" if data.status == "BAD" else "yellow")
)
status_line = (
f"[bold]{get_message('status', lang)}:[/bold] [{status_color}]{data.status:8}[/{status_color}] | "
f"[bold]{get_message('temperature', lang)}:[/bold] {data.temp:8} | "
f"[bold]{get_message('power_hours', lang)}:[/bold] {data.power_hours:15} | "
f"[bold]{get_message('power_cycles', lang)}:[/bold] {data.power_cycles}"
)
content += f"{status_line}\n\n"
# Critical attributes
content += f"[bold]{get_message('critical_attrs', lang)}:[/bold]\n"
content += f"{get_message('reallocated', lang)}: [red]{data.reallocated}[/red]\n"
content += f"{get_message('pending', lang)}: [red]{data.pending}[/red]\n"
content += f"{get_message('uncorrectable', lang)}: [red]{data.uncorrectable}[/red]\n"
# Additional attributes for detailed view
if data.remaining_lifetime < 100:
content += f" • Remaining Lifetime: [green]{data.remaining_lifetime}%[/green]\n"
if data.media_wearout_indicator < 100:
content += f" • Media Wearout Indicator: [yellow]{data.media_wearout_indicator}%[/yellow]\n"
if data.ssd_life_left < 100 and data.remaining_lifetime == 100:
content += f" • SSD Life Left: [yellow]{data.ssd_life_left}%[/yellow]\n"
if data.host_writes_gb > 0:
content += f" • Host Writes: [cyan]{data.host_writes_gb} GB[/cyan]\n"
if data.crc_errors > 0:
content += f" • CRC Errors: [yellow]{data.crc_errors}[/yellow]\n"
if data.program_fail_count > 0:
content += f" • Program Failures: [red]{data.program_fail_count}[/red]\n"
if data.erase_fail_count > 0:
content += f" • Erase Failures: [red]{data.erase_fail_count}[/red]\n"
if data.command_timeout > 0:
content += f" • Command Timeouts: [yellow]{data.command_timeout}[/yellow]\n"
if data.reallocated_event_count > 0:
content += f" • Realloc Events: [yellow]{data.reallocated_event_count}[/yellow]\n"
if data.reported_uncorrect > 0:
content += f" • Reported Uncorrect: [red]{data.reported_uncorrect}[/red]\n"
# Remove trailing newline if no additional attributes were added
content = content.rstrip('\n')
# Show all SMART attributes if requested
if show_all and data.attrs:
content += "\n\n[bold]All S.M.A.R.T. Attributes:[/bold]\n"
table = Table(box=box.SIMPLE, show_header=True, header_style="bold cyan")
table.add_column("ID", style="cyan")
table.add_column("Name", style="white")
table.add_column("Value", justify="right")
table.add_column("Worst", justify="right")
table.add_column("Threshold", justify="right")
table.add_column("Raw", style="yellow", justify="right")
for attr_id in sorted(data.attrs.keys(), key=lambda x: int(x)):
attr = data.attrs[attr_id]
table.add_row(
attr_id,
attr['name'],
attr['value'],
attr['worst'],
attr['threshold'],
attr['raw']
)
# Render table to string and add to content
from rich.console import Console as RichConsole
from io import StringIO
output = StringIO()
rich_console = RichConsole(file=output, force_terminal=True)
rich_console.print(table)
content += "\n" + output.getvalue()
else:
content += f"[yellow]{get_message('smart_not_supported', lang)}[/yellow]"
# Warnings panel
if data.warnings:
warnings_title = f"[bold red]⚠️ {get_message('warning_reallocated_10', lang).split(':')[0].replace('🟡', '').strip()}:[/bold red]"
warnings_text = "\n".join([f"{w}" for w in data.warnings])
console.print(
Panel(
f"{content}\n\n{warnings_title}\n{warnings_text}",
title=f"📀 {data.disk} - {data.model}",
border_style="red",
box=box.ROUNDED,
)
)
else:
console.print(
Panel(
content,
title=f"📀 {data.disk} - {data.model}",
border_style=color,
box=box.ROUNDED,
)
)
def print_full_smart_data(console: Console, data, lang: str):
"""Print full SMART data in detailed format."""
color = get_health_color(data.health)
icon = get_health_icon(data.health, lang)
health_bar = create_health_bar(data.health)
# Header
console.print(f"\n{'=' * 80}")
console.print(f"[bold cyan]📀 {data.disk} - {data.model} ({data.size})[/bold cyan]")
console.print(f"{'=' * 80}\n")
# Health summary
health_display = f"[{color}] {icon} {health_bar} {data.health}%[/{color}]"
console.print(f"[bold]{get_message('health', lang)}:[/bold] {health_display}")
console.print()
if not data.smart_supported:
console.print(f"[yellow]{get_message('smart_not_supported', lang)}[/yellow]\n")
return
# Status line
status_color = (
"green" if data.status == "GOOD" else ("red" if data.status == "BAD" else "yellow")
)
console.print(
f"[bold]{get_message('status', lang)}:[/bold] [{status_color}]{data.status:8}[/{status_color}] | "
f"[bold]{get_message('temperature', lang)}:[/bold] {data.temp:8} | "
f"[bold]{get_message('power_hours', lang)}:[/bold] {data.power_hours:15} | "
f"[bold]{get_message('power_cycles', lang)}:[/bold] {data.power_cycles}"
)
console.print()
# Critical attributes
console.print(f"[bold red]{get_message('critical_attrs', lang)}:[/bold red]")
console.print(f"{get_message('reallocated', lang)}: [red]{data.reallocated}[/red]")
console.print(f"{get_message('pending', lang)}: [red]{data.pending}[/red]")
console.print(f"{get_message('uncorrectable', lang)}: [red]{data.uncorrectable}[/red]")
console.print()
# Warnings
if data.warnings:
console.print(f"[bold red]⚠️ WARNINGS / ПРЕДУПРЕЖДЕНИЯ:[/bold red]")
for warning in data.warnings:
console.print(f" [red]• {warning}[/red]")
console.print()
# All SMART attributes table
if data.attrs:
console.print(f"[bold cyan]All S.M.A.R.T. Attributes / Все атрибуты S.M.A.R.T.:[/bold cyan]\n")
table = Table(box=box.ROUNDED, show_header=True, header_style="bold cyan")
table.add_column("ID", style="cyan", justify="right")
table.add_column("Name", style="white")
table.add_column("Value", justify="right", style="green")
table.add_column("Worst", justify="right", style="yellow")
table.add_column("Threshold", justify="right", style="red")
table.add_column("Raw", style="magenta", justify="right")
for attr_id in sorted(data.attrs.keys(), key=lambda x: int(x)):
attr = data.attrs[attr_id]
# Highlight critical attributes
if attr_id in ['5', '197', '198', '194', '9', '12']:
row_style = "bold"
else:
row_style = ""
table.add_row(
attr_id,
attr['name'],
attr['value'],
attr['worst'],
attr['threshold'],
attr['raw'],
style=row_style
)
console.print(table)
console.print()
def main():
"""Main entry point for console application."""
parser = argparse.ArgumentParser(
description=get_message('disk_monitor', 'en'),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
sudo smart-report Show disk health summary
sudo smart-report --all Show all SMART attributes
sudo smart-report -a Show all SMART attributes (short form)
"""
)
parser.add_argument(
'-a', '--all',
action='store_true',
help='Show all S.M.A.R.T. attributes for each disk'
)
args = parser.parse_args()
console = Console()
lang = get_locale()
# Check smartctl
if not check_smartctl():
console.print(f"[bold red]{get_message('smart_not_installed', lang)}[/bold red]")
console.print(f"[green]{get_message('install_command', lang)}[/green]")
sys.exit(1)
# Check root status
if not is_root():
console.print(
f"[bold red]{get_message('run_with_sudo', lang)}:[/bold red] "
f"[green]sudo smart-report[/green]"
)
console.print(
f"[yellow]{get_message('run_with_sudo', lang)}.[/yellow]\n"
)
try:
disks_data = collect_all_disks_data(lang)
except Exception as e:
console.print(f"[bold red]{get_message('error', lang)}:[/bold red] {e}")
sys.exit(1)
if not disks_data:
console.print(f"[bold yellow]{get_message('no_disks_found', lang)}[/bold yellow]")
return
# Header
console.print("\n" + "=" * 75)
console.print(f" [bold]{get_message('disk_monitor', lang)}[/bold]")
console.print(f" {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}")
if args.all:
console.print(f" [cyan]Detailed Mode / Подробный режим[/cyan]")
console.print("=" * 75 + "\n")
# Print each disk
for data in disks_data:
if args.all:
print_full_smart_data(console, data, lang)
else:
print_disk_panel(console, data, lang, show_all=False)
console.print("=" * 75 + "\n")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\nПрограмма прервана / Program interrupted")
except Exception as e:
print(f"❌ Ошибка / Error: {e}")
sys.exit(1)

544
smart_report/core.py Normal file
View File

@@ -0,0 +1,544 @@
"""Core SMART data collection logic with bilingual support."""
import locale
import subprocess
from dataclasses import dataclass, field
from typing import Dict, List, Optional
@dataclass
class DiskSmartData:
"""S.M.A.R.T. data for a single disk."""
disk: str
model: str = "Unknown"
size: str = "Unknown"
status: str = "UNKNOWN"
temp: str = "N/A"
power_hours: str = "N/A"
power_cycles: str = "N/A"
reallocated: int = 0
pending: int = 0
uncorrectable: int = 0
attrs: Dict[str, dict] = field(default_factory=dict)
health: int = 100
warnings: List[str] = field(default_factory=list)
error: Optional[str] = None
smart_supported: bool = True
# Additional SMART attributes for health calculation
ssd_life_left: int = 100
remaining_lifetime: int = 100 # ID 169 - more reliable for some SSDs
media_wearout_indicator: int = 100 # ID 233 - Intel/Crucial
crc_errors: int = 0
program_fail_count: int = 0
erase_fail_count: int = 0
command_timeout: int = 0
spin_retry_count: int = 0
reallocated_event_count: int = 0
reported_uncorrect: int = 0
host_writes_gb: float = 0 # Calculated from attribute 241/233
def get_locale() -> str:
"""Detect system locale and return 'ru' or 'en'."""
try:
loc = locale.getdefaultlocale()[0] or ""
return "ru" if loc.startswith("ru") else "en"
except Exception:
return "en"
MESSAGES = {
"en": {
"smart_not_installed": "❌ smartmontools is not installed!",
"install_command": "Install: sudo pacman -S smartmontools",
"no_disks_found": "❌ No disks found",
"disk_monitor": "DISK HEALTH MONITORING (S.M.A.R.T.)",
"disk": "Disk",
"model": "Model",
"size": "Size",
"health": "Health",
"status": "Status",
"temperature": "Temperature",
"power_hours": "Power-On Hours",
"power_cycles": "Cycles",
"critical_attrs": "Critical Attributes",
"reallocated": "Reallocated Sectors",
"pending": "Pending Sectors",
"uncorrectable": "Uncorrectable Errors",
"smart_status_bad": "🔴 S.M.A.R.T. status: BAD",
"critical_reallocated_500": "🔴 CRITICAL: {0} reallocated sectors! Disk may fail!",
"warning_reallocated_100": "🟠 WARNING: {0} reallocated sectors. Start backup!",
"warning_reallocated_10": "🟡 WARNING: {0} reallocated sectors",
"critical_pending": "🔴 CRITICAL: {0} pending sectors!",
"critical_uncorrectable": "🔴 CRITICAL: {0} uncorrectable errors!",
"smart_not_supported": "S.M.A.R.T.: Not supported",
"running_as_root": "✓ Running as root",
"run_with_sudo": "⚠️ Run with sudo for full access",
"collecting_data": "Collecting data...",
"disks_found": "Found {0} disk(s)",
"error": "Error",
"refresh": "🔄 Refresh",
"disk_health_report": "📊 S.M.A.R.T. Disk Health Report",
# Additional health warnings
"warning_ssd_life": "🟠 SSD life remaining: {0}%",
"warning_crc_errors": "🟡 CRC errors: {0} (check SATA cable)",
"warning_program_fail": "🔴 Program failures: {0}",
"warning_erase_fail": "🔴 Erase failures: {0}",
"warning_command_timeout": "🟡 Command timeouts: {0}",
"warning_spin_retry": "🟡 Spin retry count: {0}",
"warning_reallocated_event": "🟡 Reallocation events: {0}",
"warning_reported_uncorrect": "🔴 Reported uncorrect errors: {0}",
},
"ru": {
"smart_not_installed": "❌ smartmontools не установлен!",
"install_command": "Установите: sudo pacman -S smartmontools",
"no_disks_found": "❌ Диски не найдены",
"disk_monitor": "МОНИТОРИНГ ЗДОРОВЬЯ ДИСКОВ (S.M.A.R.T.)",
"disk": "Диск",
"model": "Модель",
"size": "Размер",
"health": "Здоровье",
"status": "Статус",
"temperature": "Температура",
"power_hours": "Часов работы",
"power_cycles": "Циклов",
"critical_attrs": "Критические атрибуты",
"reallocated": "Переназначенные сектора",
"pending": "Ожидающие сектора",
"uncorrectable": "Неисправимые ошибки",
"smart_status_bad": "🔴 S.M.A.R.T. статус: BAD",
"critical_reallocated_500": "🔴 КРИТИЧНО: {0} переназначенных секторов! Диск может отказать!",
"warning_reallocated_100": "🟠 ВНИМАНИЕ: {0} переназначенных секторов. Начните резервное копирование!",
"warning_reallocated_10": "🟡 ВНИМАНИЕ: {0} переназначенных секторов",
"critical_pending": "🔴 КРИТИЧНО: {0} ожидающих секторов!",
"critical_uncorrectable": "🔴 КРИТИЧНО: {0} неисправимых ошибок!",
"smart_not_supported": "S.M.A.R.T.: Не поддерживается",
"running_as_root": "✓ Запуск от root",
"run_with_sudo": "⚠️ Запустите с sudo для полного доступа",
"collecting_data": "Сбор данных...",
"disks_found": "Найдено дисков: {0}",
"error": "Ошибка",
"refresh": "🔄 Обновить",
"disk_health_report": "📊 Отчет о здоровье дисков (S.M.A.R.T.)",
# Additional health warnings
"warning_ssd_life": "🟠 Остаток ресурса SSD: {0}%",
"warning_crc_errors": "🟡 Ошибки CRC: {0} (проверьте SATA кабель)",
"warning_program_fail": "🔴 Ошибки программирования: {0}",
"warning_erase_fail": "🔴 Ошибки стирания: {0}",
"warning_command_timeout": "🟡 Таймауты команд: {0}",
"warning_spin_retry": "🟡 Повторы раскрутки: {0}",
"warning_reallocated_event": "🟡 События переназначения: {0}",
"warning_reported_uncorrect": "🔴 Сообщённые ошибки: {0}",
},
}
def get_message(key: str, lang: str = None, *args) -> str:
"""Get localized message."""
if lang is None:
lang = get_locale()
msg = MESSAGES.get(lang, MESSAGES["en"]).get(key, MESSAGES["en"].get(key, key))
if args:
return msg.format(*args)
return msg
def check_smartctl() -> bool:
"""Check if smartctl is installed."""
try:
subprocess.run(["which", "smartctl"], capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def get_disk_list() -> List[str]:
"""Get list of all physical disks (/dev/sda, /dev/nvme0n1, etc.)."""
try:
result = subprocess.run(
["lsblk", "-d", "-n", "-o", "NAME"],
capture_output=True,
text=True,
check=True,
)
return [f"/dev/{disk}" for disk in result.stdout.strip().split("\n") if disk]
except Exception:
return []
def get_disk_info(disk: str) -> tuple:
"""Get disk model and size."""
try:
model = subprocess.run(
["lsblk", "-d", "-n", "-o", "MODEL", disk],
capture_output=True,
text=True,
).stdout.strip() or "Unknown"
size = subprocess.run(
["lsblk", "-d", "-n", "-o", "SIZE", disk],
capture_output=True,
text=True,
).stdout.strip() or "Unknown"
return model, size
except Exception:
return "Unknown", "Unknown"
def parse_smart_data(disk: str) -> Optional[DiskSmartData]:
"""Parse S.M.A.R.T. data for a disk (supports both ATA and NVMe)."""
data = DiskSmartData(disk=disk)
try:
result = subprocess.run(
["sudo", "smartctl", "-a", disk],
capture_output=True,
text=True,
)
output = result.stdout
except Exception as e:
data.error = str(e)
data.smart_supported = False
return data
if not output.strip():
data.smart_supported = False
return data
# Parse status
if "PASSED" in output:
data.status = "GOOD"
elif "FAILED" in output:
data.status = "BAD"
else:
data.status = "UNKNOWN"
# Check if NVMe format
is_nvme = "NVMe" in output or "SMART overall-health" not in output
# Parse attributes (ATA format)
for line in output.split("\n"):
parts = line.split()
if len(parts) < 10:
# Try NVMe format parsing
if is_nvme:
# NVMe: "Temperature: 35 Celsius"
if "Temperature:" in line:
try:
temp_val = line.split(":")[1].strip().split()[0]
data.temp = f"{temp_val}°C"
except (IndexError, ValueError):
pass
# NVMe: "Power On Hours: 1234"
if "Power On Hours:" in line:
try:
hours = int(line.split(":")[1].strip())
data.power_hours = f"{hours}h ({hours // 24}d)"
except (IndexError, ValueError):
pass
# NVMe: "Power Cycle Count: 5678"
if "Power Cycle Count:" in line:
try:
data.power_cycles = line.split(":")[1].strip()
except (IndexError, ValueError):
pass
# NVMe: "Media and Data Integrity Errors: 0"
if "Media and Data Integrity Errors:" in line:
try:
data.uncorrectable = int(line.split(":")[1].strip())
except (IndexError, ValueError):
pass
continue
# ATA format parsing
# Temperature (ID 194)
if parts[0] == "194" or "Temperature_Celsius" in line:
try:
data.temp = f"{parts[9]}°C"
except (IndexError, ValueError):
pass
# Power-on hours (ID 9)
if parts[0] == "9" or "Power_On_Hours" in line:
try:
hours = int(parts[9])
data.power_hours = f"{hours}h ({hours // 24}d)"
except (IndexError, ValueError):
pass
# Power cycle count (ID 12)
if parts[0] == "12" or "Power_Cycle_Count" in line:
try:
data.power_cycles = parts[9]
except (IndexError, ValueError):
pass
# Reallocated sectors (ID 5)
if parts[0] == "5" or "Reallocated_Sector_Ct" in line:
try:
data.reallocated = int(parts[9])
except (IndexError, ValueError):
pass
# Current pending sectors (ID 197)
if parts[0] == "197" or "Current_Pending_Sect" in line:
try:
data.pending = int(parts[9])
except (IndexError, ValueError):
pass
# Offline uncorrectable (ID 198)
if parts[0] == "198" or "Offline_Uncorrectable" in line:
try:
data.uncorrectable = int(parts[9])
except (IndexError, ValueError):
pass
# SSD Life Left (ID 231) - crucial for SSD health
if parts[0] == "231" or "SSD_Life_Left" in line:
try:
data.ssd_life_left = int(parts[9])
except (IndexError, ValueError):
pass
# Remaining Lifetime Percent (ID 169) - more reliable for some SSDs
# NOTE: Use normalized VALUE (parts[3]), not raw!
if parts[0] == "169" and "Remaining_Lifetime" in line:
try:
data.remaining_lifetime = int(parts[3]) # Normalized value 0-100
except (IndexError, ValueError):
pass
# Media Wearout Indicator (ID 233) - Intel/Crucial/WD
# NOTE: Use normalized VALUE (parts[3]), not raw!
if parts[0] == "233" and ("Media_Wearout" in line or "Wear_Leveling" in line):
try:
data.media_wearout_indicator = int(parts[3]) # Normalized value 0-100
except (IndexError, ValueError):
pass
# Host Writes (ID 241) - for calculating actual write volume
if parts[0] == "241" or "Host_Writes" in line or "Lifetime_Writes" in line:
try:
raw_value = int(parts[9])
# Convert from 32MiB blocks to GB
data.host_writes_gb = round(raw_value * 32 / 1024, 1)
except (IndexError, ValueError):
pass
# CRC Error Count (ID 199) - indicates cable/connection issues
if parts[0] == "199" or "CRC_Error_Count" in line or "UDMA_CRC_Error" in line:
try:
data.crc_errors = int(parts[9])
except (IndexError, ValueError):
pass
# Program Fail Count (ID 181)
if parts[0] == "181" or "Program_Fail_Count" in line:
try:
data.program_fail_count = int(parts[9])
except (IndexError, ValueError):
pass
# Erase Fail Count (ID 172 or 182)
if parts[0] in ["172", "182"] or "Erase_Fail_Count" in line:
try:
data.erase_fail_count = int(parts[9])
except (IndexError, ValueError):
pass
# Command Timeout (ID 188)
if parts[0] == "188" or "Command_Timeout" in line:
try:
data.command_timeout = int(parts[9])
except (IndexError, ValueError):
pass
# Spin Retry Count (ID 10)
if parts[0] == "10" or "Spin_Retry_Count" in line:
try:
data.spin_retry_count = int(parts[9])
except (IndexError, ValueError):
pass
# Reallocated Event Count (ID 196)
if parts[0] == "196" or "Reallocated_Event_Count" in line:
try:
data.reallocated_event_count = int(parts[9])
except (IndexError, ValueError):
pass
# Reported Uncorrect Errors (ID 187)
if parts[0] == "187" or "Reported_Uncorrect" in line:
try:
data.reported_uncorrect = int(parts[9])
except (IndexError, ValueError):
pass
# Store all attributes
if parts and parts[0].isdigit() and len(parts) >= 10:
try:
attr_id = parts[0]
attr_name = parts[1] if len(parts) > 1 else "Unknown"
data.attrs[attr_id] = {
"name": attr_name,
"value": parts[3],
"worst": parts[4],
"threshold": parts[5],
"raw": parts[9],
}
except (IndexError, ValueError):
pass
return data
def calculate_health(data: DiskSmartData, lang: str = None) -> tuple:
"""Calculate disk health percentage and warnings based on multiple SMART attributes."""
if lang is None:
lang = get_locale()
if data.error or not data.smart_supported:
return 50, []
if data.status == "BAD":
return 5, [get_message("smart_status_bad", lang)]
health = 100
warnings = []
# === SSD WEAR INDICATORS - use the most reliable one ===
# Priority: remaining_lifetime (169) > media_wearout (233) > ssd_life_left (231)
# Some manufacturers (ADATA, Silicon Motion) have unreliable ID 231
ssd_wear_values = []
# ID 169 - Remaining Lifetime (more reliable for ADATA, Silicon Motion)
if data.remaining_lifetime < 100 and data.remaining_lifetime > 0:
ssd_wear_values.append(("Remaining Lifetime (169)", data.remaining_lifetime))
# ID 233 - Media Wearout Indicator (Intel, Crucial, WD)
if data.media_wearout_indicator < 100 and data.media_wearout_indicator > 0:
ssd_wear_values.append(("Media Wearout (233)", data.media_wearout_indicator))
# ID 231 - SSD Life Left (Kingston, Samsung, some others)
# Only use if no other indicators or if consistent with them
if data.ssd_life_left < 100 and data.ssd_life_left > 0:
ssd_wear_values.append(("SSD Life Left (231)", data.ssd_life_left))
# Choose the most reliable indicator
if ssd_wear_values:
# Prefer ID 169 if available (most reliable)
preferred = next((v for n, v in ssd_wear_values if "169" in n), None)
if preferred is not None:
health = min(health, preferred)
if preferred < 50:
warnings.append(get_message("warning_ssd_life", lang, preferred))
else:
# Use minimum of available values
min_wear = min(v for _, v in ssd_wear_values)
health = min(health, min_wear)
if min_wear < 50:
warnings.append(get_message("warning_ssd_life", lang, min_wear))
# === REALLOCATED SECTORS (ID 5) ===
if data.reallocated > 0:
if data.reallocated > 500:
penalty = min(80, data.reallocated * 0.5)
health -= penalty
warnings.append(get_message("critical_reallocated_500", lang, data.reallocated))
elif data.reallocated > 100:
penalty = min(70, data.reallocated * 0.3)
health -= penalty
warnings.append(get_message("warning_reallocated_100", lang, data.reallocated))
elif data.reallocated > 10:
penalty = data.reallocated * 0.2
health -= penalty
warnings.append(get_message("warning_reallocated_10", lang, data.reallocated))
else:
health -= data.reallocated * 0.1
# === REALLOCATION EVENTS (ID 196) ===
if data.reallocated_event_count > 0:
if data.reallocated_event_count > 100:
health -= min(40, data.reallocated_event_count * 0.4)
warnings.append(get_message("warning_reallocated_event", lang, data.reallocated_event_count))
elif data.reallocated_event_count > 0:
health -= min(20, data.reallocated_event_count * 0.2)
# === PENDING SECTORS (ID 197) ===
if data.pending > 0:
health -= min(70, data.pending * 2)
warnings.append(get_message("critical_pending", lang, data.pending))
# === UNCORRECTABLE ERRORS (ID 198) ===
if data.uncorrectable > 0:
health -= min(80, data.uncorrectable * 5)
warnings.append(get_message("critical_uncorrectable", lang, data.uncorrectable))
# === REPORTED UNCORRECT ERRORS (ID 187) ===
if data.reported_uncorrect > 0:
health -= min(60, data.reported_uncorrect * 5)
warnings.append(get_message("warning_reported_uncorrect", lang, data.reported_uncorrect))
# === PROGRAM FAIL COUNT (ID 181) ===
if data.program_fail_count > 0:
health -= min(50, data.program_fail_count * 10)
warnings.append(get_message("warning_program_fail", lang, data.program_fail_count))
# === ERASE FAIL COUNT (ID 172/182) ===
if data.erase_fail_count > 0:
health -= min(50, data.erase_fail_count * 10)
warnings.append(get_message("warning_erase_fail", lang, data.erase_fail_count))
# === CRC ERRORS (ID 199) - Usually cable issue ===
if data.crc_errors > 0:
if data.crc_errors > 100:
health -= min(30, data.crc_errors * 0.3)
elif data.crc_errors > 0:
health -= min(15, data.crc_errors * 0.15)
warnings.append(get_message("warning_crc_errors", lang, data.crc_errors))
# === COMMAND TIMEOUT (ID 188) ===
if data.command_timeout > 0:
health -= min(25, data.command_timeout * 2)
warnings.append(get_message("warning_command_timeout", lang, data.command_timeout))
# === SPIN RETRY COUNT (ID 10) - For HDDs ===
if data.spin_retry_count > 0:
health -= min(30, data.spin_retry_count * 5)
warnings.append(get_message("warning_spin_retry", lang, data.spin_retry_count))
data.health = max(5, int(health))
data.warnings = warnings
return data.health, warnings
def collect_all_disks_data(lang: str = None) -> List[DiskSmartData]:
"""Collect S.M.A.R.T. data for all disks."""
if lang is None:
lang = get_locale()
disks = get_disk_list()
results = []
for disk in disks:
model, size = get_disk_info(disk)
smart_data = parse_smart_data(disk)
if smart_data:
smart_data.model = model
smart_data.size = size
calculate_health(smart_data, lang)
results.append(smart_data)
return results
def is_root() -> bool:
"""Check if running as root."""
try:
result = subprocess.run(["id", "-u"], capture_output=True, text=True)
return result.stdout.strip() == "0"
except Exception:
return False

559
smart_report/gui.py Normal file
View File

@@ -0,0 +1,559 @@
#!/usr/bin/env python3
"""GUI application for S.M.A.R.T. disk health monitoring using PyQt6."""
import sys
from typing import Optional, Dict, List
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QVBoxLayout,
QHBoxLayout,
QTableWidget,
QTableWidgetItem,
QPushButton,
QLabel,
QHeaderView,
QMessageBox,
QProgressBar,
QFrame,
QScrollArea,
QTabWidget,
QTreeWidget,
QTreeWidgetItem,
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QFont, QColor, QPalette
from smart_report.core import (
collect_all_disks_data,
DiskSmartData,
is_root,
get_locale,
get_message,
)
class SmartDataWorker(QThread):
"""Worker thread for collecting SMART data."""
data_ready = pyqtSignal(list)
error = pyqtSignal(str)
def __init__(self, lang: str):
super().__init__()
self.lang = lang
def run(self):
try:
data = collect_all_disks_data(self.lang)
self.data_ready.emit(data)
except Exception as e:
self.error.emit(str(e))
class HealthBarWidget(QFrame):
"""Custom health bar widget with percentage."""
def __init__(self, health: int, parent=None):
super().__init__(parent)
self.health = health
self.setFixedHeight(30)
self.setMinimumWidth(200)
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.setValue(self.health)
self.progress.setFormat(f" {self.health}%")
self.progress.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.progress.setStyleSheet(self.get_stylesheet())
layout.addWidget(self.progress)
def get_stylesheet(self) -> str:
if self.health >= 85:
color = "#27ae60" # Green
elif self.health >= 60:
color = "#f39c12" # Yellow
elif self.health >= 30:
color = "#e74c3c" # Red
else:
color = "#c0392b" # Dark red
return f"""
QProgressBar {{
border: 1px solid #ccc;
border-radius: 3px;
text-align: center;
font-weight: bold;
}}
QProgressBar::chunk {{
background-color: {color};
border-radius: 2px;
}}
"""
class SmartAttributesTree(QTreeWidget):
"""Tree widget for displaying SMART attributes."""
def __init__(self, data: DiskSmartData, lang: str, parent=None):
super().__init__(parent)
self.data = data
self.lang = lang
self.setup_ui()
def setup_ui(self):
self.setHeaderLabels([
get_message("disk", self.lang),
"Value",
"Worst",
"Threshold",
"Raw"
])
self.setAlternatingRowColors(True)
self.setRootIsDecorated(False)
# Populate with attributes
for attr_id in sorted(self.data.attrs.keys(), key=lambda x: int(x)):
attr = self.data.attrs[attr_id]
item = QTreeWidgetItem([
f"{attr_id} - {attr['name']}",
attr['value'],
attr['worst'],
attr['threshold'],
attr['raw']
])
# Highlight critical attributes
if attr_id in ['5', '197', '198']:
item.setForeground(0, QColor("#e74c3c"))
font = item.font(0)
font.setBold(True)
item.setFont(0, font)
elif attr_id in ['194', '9', '12']:
item.setForeground(0, QColor("#3498db"))
font = item.font(0)
font.setBold(True)
item.setFont(0, font)
self.addTopLevelItem(item)
self.resizeColumnToContents(0)
class DiskHealthWindow(QMainWindow):
"""Main window for disk health monitoring."""
def __init__(self):
super().__init__()
self.lang = get_locale()
self.worker: Optional[SmartDataWorker] = None
self.current_data: List[DiskSmartData] = []
self.init_ui()
self.refresh_data()
def init_ui(self):
"""Initialize the user interface."""
self.setWindowTitle(get_message("disk_health_report", self.lang))
self.setMinimumSize(1100, 650)
# Central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
main_layout.setSpacing(10)
main_layout.setContentsMargins(15, 15, 15, 15)
# Header
header_layout = QHBoxLayout()
self.title_label = QLabel(get_message("disk_health_report", self.lang))
self.title_label.setFont(QFont("Arial", 18, QFont.Weight.Bold))
header_layout.addWidget(self.title_label)
header_layout.addStretch()
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #666;")
header_layout.addWidget(self.status_label)
main_layout.addLayout(header_layout)
# Tab widget
self.tabs = QTabWidget()
self.tabs.setStyleSheet("""
QTabWidget::pane {
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
}
QTabBar::tab {
background-color: #f5f5f5;
border: 1px solid #ddd;
border-bottom: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 8px 16px;
margin-right: 2px;
}
QTabBar::tab:selected {
background-color: white;
border-bottom: 1px solid white;
}
QTabBar::tab:hover:!selected {
background-color: #e8e8e8;
}
""")
# Summary tab
self.summary_tab = QWidget()
self.summary_layout = QVBoxLayout(self.summary_tab)
self.summary_layout.setContentsMargins(10, 10, 10, 10)
self.summary_scroll = QScrollArea()
self.summary_scroll.setWidgetResizable(True)
self.summary_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.summary_container = QWidget()
self.summary_container_layout = QVBoxLayout(self.summary_container)
self.summary_container_layout.setSpacing(15)
self.summary_scroll.setWidget(self.summary_container)
self.summary_layout.addWidget(self.summary_scroll)
self.tabs.addTab(self.summary_tab, "📊 Summary / Обзор")
# Detailed tab
self.detailed_tab = QWidget()
self.detailed_layout = QVBoxLayout(self.detailed_tab)
self.detailed_layout.setContentsMargins(10, 10, 10, 10)
self.detailed_scroll = QScrollArea()
self.detailed_scroll.setWidgetResizable(True)
self.detailed_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.detailed_container = QWidget()
self.detailed_container_layout = QVBoxLayout(self.detailed_container)
self.detailed_container_layout.setSpacing(20)
self.detailed_scroll.setWidget(self.detailed_container)
self.detailed_layout.addWidget(self.detailed_scroll)
self.tabs.addTab(self.detailed_tab, "📋 Detailed / Подробно")
main_layout.addWidget(self.tabs)
# Footer with buttons
footer_layout = QHBoxLayout()
self.refresh_btn = QPushButton(get_message("refresh", self.lang))
self.refresh_btn.clicked.connect(self.refresh_data)
self.refresh_btn.setMinimumSize(120, 35)
self.refresh_btn.setStyleSheet(
"QPushButton { background-color: #3498db; color: white; "
"border: none; padding: 8px 16px; border-radius: 4px; font-weight: bold; }"
"QPushButton:hover { background-color: #2980b9; }"
"QPushButton:disabled { background-color: #95a5a6; }"
)
footer_layout.addWidget(self.refresh_btn)
footer_layout.addStretch()
self.root_label = QLabel("")
self.root_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
footer_layout.addWidget(self.root_label)
main_layout.addLayout(footer_layout)
# Check root status
self.update_root_status()
def update_root_status(self):
"""Update root status label."""
if not is_root():
self.root_label.setText(get_message("run_with_sudo", self.lang))
else:
self.root_label.setText(get_message("running_as_root", self.lang))
def refresh_data(self):
"""Start data collection in background thread."""
self.refresh_btn.setEnabled(False)
self.status_label.setText(get_message("collecting_data", self.lang))
# Clear existing widgets
while self.summary_container_layout.count():
item = self.summary_container_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
while self.detailed_container_layout.count():
item = self.detailed_container_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self.worker = SmartDataWorker(self.lang)
self.worker.data_ready.connect(self.on_data_ready)
self.worker.error.connect(self.on_error)
self.worker.start()
def on_data_ready(self, data: list):
"""Handle collected data."""
self.current_data = data
# Populate summary tab
for disk_data in data:
self.summary_container_layout.addWidget(self.create_summary_panel(disk_data))
# Populate detailed tab
for disk_data in data:
self.detailed_container_layout.addWidget(self.create_detailed_panel(disk_data))
self.status_label.setText(get_message("disks_found", self.lang, len(data)))
self.refresh_btn.setEnabled(True)
def create_summary_panel(self, data: DiskSmartData) -> QFrame:
"""Create a summary panel widget for a single disk."""
panel = QFrame()
panel.setFrameStyle(QFrame.Shape.StyledPanel)
panel.setStyleSheet(
"QFrame { background-color: white; border: 1px solid #ddd; "
"border-radius: 8px; padding: 15px; }"
)
layout = QVBoxLayout(panel)
layout.setSpacing(10)
# Header row
header_layout = QHBoxLayout()
header_layout.addWidget(QLabel(f"📀 <b>{data.disk}</b> - {data.model}"))
header_layout.addStretch()
# Health bar
health_bar = HealthBarWidget(data.health)
header_layout.addWidget(health_bar)
layout.addLayout(header_layout)
# Info grid
info_layout = QHBoxLayout()
if data.smart_supported:
# Status
status_color = (
"#27ae60" if data.status == "GOOD" else ("#e74c3c" if data.status == "BAD" else "#f39c12")
)
status_label = QLabel(f"<b>{get_message('status', self.lang)}:</b> {data.status}")
status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
info_layout.addWidget(status_label)
# Temperature
info_layout.addWidget(QLabel(f"<b>{get_message('temperature', self.lang)}:</b> {data.temp}"))
# Power hours
info_layout.addWidget(QLabel(f"<b>{get_message('power_hours', self.lang)}:</b> {data.power_hours}"))
# Power cycles
info_layout.addWidget(QLabel(f"<b>{get_message('power_cycles', self.lang)}:</b> {data.power_cycles}"))
info_layout.addStretch()
layout.addLayout(info_layout)
# Critical attributes
attrs_layout = QHBoxLayout()
attrs_layout.addWidget(
QLabel(f"• <b>{get_message('reallocated', self.lang)}:</b> {data.reallocated}")
)
attrs_layout.addWidget(
QLabel(f"• <b>{get_message('pending', self.lang)}:</b> {data.pending}")
)
attrs_layout.addWidget(
QLabel(f"• <b>{get_message('uncorrectable', self.lang)}:</b> {data.uncorrectable}")
)
attrs_layout.addStretch()
layout.addLayout(attrs_layout)
# Warnings
if data.warnings:
warnings_text = "<br>".join([f"⚠️ {w}" for w in data.warnings])
warnings_label = QLabel(f"<span style='color: #e74c3c; font-weight: bold;'>{warnings_text}</span>")
warnings_label.setWordWrap(True)
warnings_label.setStyleSheet("background-color: #ffeaea; padding: 10px; border-radius: 4px;")
layout.addWidget(warnings_label)
else:
not_supported = QLabel(f"<i>{get_message('smart_not_supported', self.lang)}</i>")
not_supported.setStyleSheet("color: #999;")
layout.addWidget(not_supported)
return panel
def create_detailed_panel(self, data: DiskSmartData) -> QFrame:
"""Create a detailed panel widget for a single disk."""
panel = QFrame()
panel.setFrameStyle(QFrame.Shape.StyledPanel)
panel.setStyleSheet(
"QFrame { background-color: white; border: 1px solid #ddd; "
"border-radius: 8px; padding: 15px; }"
)
layout = QVBoxLayout(panel)
layout.setSpacing(15)
# Header
header_layout = QHBoxLayout()
header_layout.addWidget(QLabel(f"📀 <b style='font-size: 16px;'>{data.disk}</b>"))
header_layout.addWidget(QLabel(f"<b>Model:</b> {data.model}"))
header_layout.addWidget(QLabel(f"<b>Size:</b> {data.size}"))
header_layout.addStretch()
# Health indicator
health_label = QLabel(f"{get_message('health', self.lang)}: {data.health}%")
health_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
if data.health >= 85:
health_label.setStyleSheet("color: #27ae60;")
elif data.health >= 60:
health_label.setStyleSheet("color: #f39c12;")
elif data.health >= 30:
health_label.setStyleSheet("color: #e74c3c;")
else:
health_label.setStyleSheet("color: #c0392b;")
header_layout.addWidget(health_label)
layout.addLayout(header_layout)
if not data.smart_supported:
not_supported = QLabel(f"<i>{get_message('smart_not_supported', self.lang)}</i>")
not_supported.setStyleSheet("color: #999; font-size: 14px;")
layout.addWidget(not_supported)
return panel
# Info section
info_frame = QFrame()
info_frame.setStyleSheet("background-color: #f9f9f9; border-radius: 4px; padding: 10px;")
info_layout = QHBoxLayout(info_frame)
status_color = (
"#27ae60" if data.status == "GOOD" else ("#e74c3c" if data.status == "BAD" else "#f39c12")
)
info_layout.addWidget(QLabel(f"<b>Status:</b> <span style='color: {status_color};'>{data.status}</span>"))
info_layout.addWidget(QLabel(f"<b>Temperature:</b> {data.temp}"))
info_layout.addWidget(QLabel(f"<b>Power-On Hours:</b> {data.power_hours}"))
info_layout.addWidget(QLabel(f"<b>Power Cycles:</b> {data.power_cycles}"))
info_layout.addStretch()
layout.addWidget(info_frame)
# Critical attributes section
critical_frame = QFrame()
critical_frame.setStyleSheet("background-color: #fff5f5; border: 1px solid #ffcccc; border-radius: 4px; padding: 10px;")
critical_layout = QHBoxLayout(critical_frame)
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>⚠️ {get_message('critical_attrs', self.lang)}:</b>"))
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>{get_message('reallocated', self.lang)}:</b> {data.reallocated}"))
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>{get_message('pending', self.lang)}:</b> {data.pending}"))
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>{get_message('uncorrectable', self.lang)}:</b> {data.uncorrectable}"))
# Additional attributes
if data.remaining_lifetime < 100:
critical_layout.addWidget(QLabel(f"<b style='color: #27ae60;'>Rem. Life:</b> {data.remaining_lifetime}%"))
if data.media_wearout_indicator < 100:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>Wearout:</b> {data.media_wearout_indicator}%"))
if data.ssd_life_left < 100 and data.remaining_lifetime == 100:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>SSD Life:</b> {data.ssd_life_left}%"))
if data.host_writes_gb > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #3498db;'>Writes:</b> {data.host_writes_gb}GB"))
if data.crc_errors > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>CRC:</b> {data.crc_errors}"))
if data.program_fail_count > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>Prog Fail:</b> {data.program_fail_count}"))
if data.erase_fail_count > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>Erase Fail:</b> {data.erase_fail_count}"))
if data.command_timeout > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>Timeouts:</b> {data.command_timeout}"))
if data.reallocated_event_count > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>Realloc Ev:</b> {data.reallocated_event_count}"))
if data.reported_uncorrect > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>Uncorrect:</b> {data.reported_uncorrect}"))
critical_layout.addStretch()
layout.addWidget(critical_frame)
# Warnings
if data.warnings:
warnings_frame = QFrame()
warnings_frame.setStyleSheet("background-color: #ffeaea; border: 1px solid #e74c3c; border-radius: 4px; padding: 10px;")
warnings_layout = QVBoxLayout(warnings_frame)
warnings_layout.addWidget(QLabel("<b style='color: #e74c3c;'>⚠️ WARNINGS / ПРЕДУПРЕЖДЕНИЯ:</b>"))
for warning in data.warnings:
warnings_layout.addWidget(QLabel(f"<span style='color: #e74c3c;'>• {warning}</span>"))
layout.addWidget(warnings_frame)
# SMART attributes tree
layout.addWidget(QLabel("<b style='font-size: 14px;'>All S.M.A.R.T. Attributes / Все атрибуты S.M.A.R.T.:</b>"))
attrs_tree = SmartAttributesTree(data, self.lang)
attrs_tree.setMinimumHeight(300)
attrs_tree.setStyleSheet("""
QTreeWidget {
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
gridline-color: #eee;
}
QTreeWidget::item {
padding: 4px;
}
QTreeWidget::item:selected {
background-color: #3498db;
color: white;
}
QHeaderView::section {
background-color: #f5f5f5;
padding: 8px;
border: none;
border-bottom: 1px solid #ddd;
font-weight: bold;
}
""")
layout.addWidget(attrs_tree)
return panel
def on_error(self, error_msg: str):
"""Handle error."""
QMessageBox.critical(
self, get_message("error", self.lang), f"{get_message('error', self.lang)}:\n{error_msg}"
)
self.status_label.setText("")
self.refresh_btn.setEnabled(True)
def closeEvent(self, event):
"""Handle window close."""
if self.worker and self.worker.isRunning():
self.worker.terminate()
self.worker.wait()
event.accept()
def main():
"""Main entry point."""
app = QApplication(sys.argv)
app.setStyle("Fusion")
# Set application palette
palette = app.palette()
palette.setColor(palette.ColorRole.Window, QColor(245, 245, 245))
app.setPalette(palette)
window = DiskHealthWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()