!/usr/bin/env python3
import subprocess
import re
import os
import shutil
import tkinter as tk
INTERVAL_MS = 2000
topmost = True
RED = "#c01818"
GREEN = "#11a88e"
BG = "#f2f2f2"
TEXT = "#000000"
HDD_DEVICES = [
"/dev/disk/by-id/ata-TOSHIBA_MN10ADA800S_36K2A04RFNHL",
"/dev/disk/by-id/ata-TOSHIBA_MN10ADA800S_36K2A09PFNHL",
]
def human_bytes(num):
"""Convert bytes to a short binary unit string."""
units = ["B", "KiB", "MiB", "GiB", "TiB"]
value = float(num)
for unit in units:
if value < 1024 or unit == units[-1]:
if unit == "B":
return f"{int(value)} {unit}"
return f"{value:.1f} {unit}"
value /= 1024
def read_first(path):
try:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
return None
def get_hdd_temp_smartctl(device):
"""Read only the CURRENT HDD temperature through smartctl.
Toshiba output example:
194 Temperature_Celsius ... - 40 (Min/Max 22/48)
This function returns only 40. It never returns Min/Max values.
"""
try:
out = subprocess.check_output(
["sudo", "-n", "smartctl", "-A", device],
text=True,
stderr=subprocess.DEVNULL,
timeout=2,
)
except Exception:
return None
for line in out.splitlines():
if "Temperature_Celsius" not in line:
continue
# Prefer the SMART RAW_VALUE after the dash.
# Example raw part: "40 (Min/Max 22/48)" -> return "40" only.
raw = line.split("-", 1)[-1].strip()
m = re.match(r"([0-9]{1,3})\b", raw)
if m:
return m.group(1)
return None
def get_hdd_temps_from_hwmon():
"""Read Toshiba HDD temperatures. Prefer drivetemp if available; fallback to smartctl."""
temps = []
# 1) Kernel drivetemp/hwmon, if available.
for name_path in sorted(__import__("glob").glob("/sys/class/hwmon/hwmon*/name")):
name = read_first(name_path)
if name != "drivetemp":
continue
base = os.path.dirname(name_path)
raw = read_first(os.path.join(base, "temp1_input"))
if raw is None:
continue
try:
temp = int(raw) / 1000
except ValueError:
continue
temps.append(("HDD", f"{temp:.0f}°C"))
if len(temps) >= 2:
return temps[:2]
# 2) Fallback: smartctl via exact by-id device names.
temps = []
for dev in HDD_DEVICES:
temp = get_hdd_temp_smartctl(dev)
if temp is not None:
temps.append(("HDD", f"{temp}°C"))
return temps[:2]
def get_raid0_info():
"""Collect md0 RAID0 status, usage, chunk size and optional HDD temperatures."""
values = []
mdstat = read_first("/proc/mdstat") or ""
md0_line = ""
for line in mdstat.splitlines():
if line.startswith("md0 :"):
md0_line = line.strip()
break
if "raid0" in md0_line and "active" in md0_line:
values.append(("Status", "active"))
elif md0_line:
values.append(("Status", "check"))
else:
return values
state = read_first("/sys/block/md0/md/array_state")
if state:
values.append(("Array", state))
chunk = read_first("/sys/block/md0/md/chunk_size")
if chunk:
try:
values.append(("Chunk", human_bytes(int(chunk)).replace(".0", "")))
except ValueError:
values.append(("Chunk", chunk))
if os.path.ismount("/mnt/games"):
usage = shutil.disk_usage("/mnt/games")
used_pct = (usage.used / usage.total) * 100 if usage.total else 0
values.append(("Belegt", f"{human_bytes(usage.used)} / {human_bytes(usage.total)}"))
values.append(("Frei", human_bytes(usage.free)))
values.append(("Auslastung", f"{used_pct:.0f}%"))
else:
values.append(("Mount", "fehlt"))
for i, (_, temp) in enumerate(get_hdd_temps_from_hwmon(), start=1):
if i > 2:
break
values.append((f"HDD {i}", temp))
return values
def get_raid0_ssd_line():
"""Return one compact SSD-section line for the Toshiba N300 RAID0."""
mdstat = read_first("/proc/mdstat") or ""
md0_active = any(
line.startswith("md0 :") and "active" in line and "raid0" in line
for line in mdstat.splitlines()
)
if not md0_active:
return None
temps = [temp for _, temp in get_hdd_temps_from_hwmon()[:2]]
if len(temps) >= 2:
value = temps[0].replace("°C", "") + "/" + temps[1]
elif len(temps) == 1:
value = temps[0]
else:
value = "?/?°C"
return ("T-N300 Raid 0", value)
def get_sensors_text():
return subprocess.check_output(["sensors"], text=True)
def parse_sensors(text):
data = {
"MAINBOARD": [],
"WASSER": [],
"AQUAERO": [],
"WLAN": [],
"GPU": [],
"CPU": [],
"SSD": [],
"RAID0": []
}
current_chip = ""
nvme_count = 0
for line in text.splitlines():
s = line.strip()
if not s or s.startswith("Adapter:"):
continue
if not line.startswith(" ") and ":" not in s:
current_chip = s
if "nvme" in current_chip.lower():
nvme_count += 1
continue
chip = current_chip.lower()
# WATER
if "highflownext" in chip:
m = re.match(r"\s*Coolant temp:\s*([+-][0-9.]+)°C", line)
if m:
data["WASSER"].append(
("DP ULTRA", f"{m.group(1)}°C")
)
m = re.match(r"\s*Flow \[dL/h\]:\s*(\d+)", line)
if m:
flow = int(m.group(1)) / 10
data["WASSER"].append(
("Durchfluss", f"{flow:.1f} L/h")
)
# MAINBOARD / RAM sensor chips
elif "jc42" in chip:
m = re.match(r"\s*temp1:\s*([+-][0-9.]+)°C", line)
if m:
if "10-18" in chip:
data["MAINBOARD"].append(
("RAM Bank A", f"{m.group(1)}°C")
)
elif "10-19" in chip:
data["MAINBOARD"].append(
("RAM Bank B", f"{m.group(1)}°C")
)
# AQUAERO
elif "aquaero" in chip:
checks = [
(
r"\s*Fan 1 speed:\s*(\d+) RPM",
"Lüfter",
" RPM"
),
(
r"\s*Fan 3 speed:\s*(\d+) RPM",
"VPP APEX 1",
" RPM"
),
(
r"\s*Fan 4 speed:\s*(\d+) RPM",
"VPP APEX 2",
" RPM"
),
(
r"\s*Sensor 1:\s*([+-][0-9.]+)°C",
"Raum temperatur",
"°C"
),
]
for pattern, name, unit in checks:
m = re.match(pattern, line)
if m:
data["AQUAERO"].append(
(name, f"{m.group(1)}{unit}")
)
# WLAN
elif "iwlwifi" in chip:
m = re.match(r"\s*temp1:\s*([+-][0-9.]+)°C", line)
if m:
data["WLAN"].append(
("WLAN", f"{m.group(1)}°C")
)
# HDD / SATA drivetemp
elif "drivetemp" in chip:
m = re.match(r"\s*temp1:\s*([+-][0-9.]+)°C", line)
if m:
data["RAID0"].append(
("HDD Temp", f"{m.group(1)}°C")
)
# GPU
elif "amdgpu" in chip:
m = re.match(
r"\s*(edge|junction|mem):\s*([+-][0-9.]+)°C",
line
)
if m:
data["GPU"].append(
(m.group(1), f"{m.group(2)}°C")
)
# CPU
elif "k10temp" in chip:
m = re.match(
r"\s*(Tctl|Tccd1):\s*([+-][0-9.]+)°C",
line
)
if m:
data["CPU"].append(
(m.group(1), f"{m.group(2)}°C")
)
# MAINBOARD / MSI MEG B550 Unify-X Nuvoton NCT6687D
# BIOS MOS ~= Linux "Thermistor 15" on nct6687-isa-0a20.
elif "nct6687" in chip or "nct6683" in chip:
m = re.match(
r"\s*Thermistor 15:\s*([+-][0-9.]+)°C",
line
)
if m:
temp_value = float(m.group(1))
# The chip may expose a second dummy Thermistor 15 at 0.0°C. Ignore it.
if temp_value > 0 and not any(name == "MOS" for name, _ in data["MAINBOARD"]):
data["MAINBOARD"].append(
("MOS", f"{m.group(1)}°C")
)
# SSD
elif "nvme" in chip:
m = re.match(
r"\s*(Composite|Sensor 2):\s*([+-][0-9.]+)°C",
line
)
if m:
sensor = m.group(1)
temp = f"{m.group(2)}°C"
if "nvme-pci-0100" in chip:
if sensor == "Composite":
name = "Samsung 980 Pro"
else:
name = "980 Pro NAND"
elif "nvme-pci-0400" in chip:
if sensor == "Composite":
name = "Crucial T705"
else:
name = "T705 NAND"
else:
name = sensor
data["SSD"].append(
(name, temp)
)
return data
def draw_header():
header.delete("all")
header.create_text(
55,
32,
text="XX",
fill=RED,
font=("Arial", 34, "bold"),
anchor="center"
)
header.create_text(
120,
26,
text="hardware",
fill="#111111",
font=("Arial", 18),
anchor="w"
)
# <<< WEITER NACH RECHTS >>>
header.create_text(
330,
26,
text="LUXX",
fill=RED,
font=("Arial", 18),
anchor="w"
)
header.create_text(
120,
55,
text="C",
fill=GREEN,
font=("Arial", 20, "bold"),
anchor="w"
)
header.create_text(
148,
55,
text="CachyOS Monitor-Systems",
fill=GREEN,
font=("Arial", 10, "bold"),
anchor="w"
)
header.create_text(
148,
75,
text="Creater Oefianer",
fill="black",
font=("Arial", 8, "bold"),
anchor="w"
)
def render_data(data):
output.config(state="normal")
output.delete("1.0", "end")
for title in [
"MAINBOARD",
"WASSER",
"AQUAERO",
"WLAN",
"GPU",
"CPU",
"SSD"
]:
values = data.get(title, [])
if title == "MAINBOARD":
# Show MOS/VRM first, then RAM Bank A/B and other board sensors.
order = {"MOS": 0, "RAM Bank A": 1, "RAM Bank B": 2}
values = sorted(values, key=lambda item: order.get(item[0], 99))
if not values:
continue
output.insert(
"end",
title + "\n",
"section"
)
for name, value in values:
output.insert(
"end",
f"{name:<15}{value:>12}\n",
"normal"
)
output.config(state="disabled")
def update():
try:
data = parse_sensors(
get_sensors_text()
)
raid_line = get_raid0_ssd_line()
if raid_line and raid_line not in data["SSD"]:
data["SSD"].append(raid_line)
render_data(data)
except Exception as e:
output.config(state="normal")
output.delete("1.0", "end")
output.insert(
"end",
"Fehler:\n" + str(e)
)
output.config(state="disabled")
root.after(INTERVAL_MS, update)
def toggle_topmost(event=None):
global topmost
topmost = not topmost
root.attributes("-topmost", topmost)
root = tk.Tk()
root.title("hardwareLUXX")
root.geometry("560x760")
root.configure(bg=BG)
root.attributes("-topmost", True)
header = tk.Canvas(
root,
height=90,
bg=BG,
highlightthickness=0
)
header.pack(fill="x")
output = tk.Text(
root,
bg=BG,
fg=TEXT,
font=("Monospace", 12),
bd=0,
padx=28,
pady=0,
spacing1=0,
spacing2=0,
spacing3=0,
highlightthickness=0
)
output.tag_config(
"section",
foreground=RED,
font=("Monospace", 12, "bold")
)
output.tag_config(
"normal",
foreground=TEXT,
font=("Monospace", 12)
)
output.pack(fill="both", expand=True)
root.bind(
"<Button-1>",
toggle_topmost
)
draw_header()
update()
root.mainloop()