Sammelthread CachyOS Gaming

Wenn Du diese Anzeige nicht sehen willst, registriere Dich und/oder logge Dich ein.
Was benutzt ihr den als HWInfo Ersatz?
sudo pacman -S htop
klassischer Terminal-Systemmonitor

sudo pacman -S btop
moderner Terminal-Systemmonitor

Allerdings nutze ich die eher selten.
Ingame nutze ich Speziell eher Mangohud.

Und im Desktop nutze ich am meisten:

sudo pacman -S mission-center
quasi grafischer Systemmonitor, etwas in Richtung HWInfo + Windows-Task-Manager, mit CPU, RAM, Laufwerken, Netzwerk und GPU

sudo pacman -S occt
Stabilitätstest für CPU und GPU sowie Monitoring
 
Ich find das Info Center ganz praktisch das in KDE dabei ist.

1789323162621.png
 
Das ist allerdings ein sehr sehr sehr beschränkter HWInfo Ersatz. :fresse:

HWInfo erschlägt einen ja geradezu mit Sensordaten, ermöglicht das Aufzeichnen der Daten, hat Graphen für den Verlauf, Min. Max. Akt. usw. usf. Da ist dein Tool noch sehr weit entfernt von. 😅
 
ABER, es zeigt das wichtigste und ist übersichtlich. ;-)
 
Das tun andere Tools auch, aber was kann wirklich HWInfo ersetzen? ;)
 
Wie oben geschrieben nehme ich Hardinfo2 - Ich hoffe ja das iwann noch nen reiner Sensorreiter rein kommt, gut Ding braucht halt Weile.
 
Sollen die Schreibfehler so?
Creater
CcachyOS

Und was genau ist das jetzt für ein Monitoring-Tool?
Schreibfehler die sollen dich systematisch triggern und Haarausfall bewirken aber nur an den eiern.

Es zeigt dir die wichtigsten Parameter an. Das Soll es fürs erste.
Wenn ich im nächsten Winter mehr zeit habe werde ich steuer Elemente integrieren für Aquero.
Vor allem will ich eine Möglichkeit die profie zu wechseln.

Ein Kleiner Grafik einheit wird auch noch integriert wo ich die die werte von den Letzten 15min sehe sowie min und max.
Beitrag automatisch zusammengeführt:

Das ist allerdings ein sehr sehr sehr beschränkter HWInfo Ersatz. :fresse:

HWInfo erschlägt einen ja geradezu mit Sensordaten, ermöglicht das Aufzeichnen der Daten, hat Graphen für den Verlauf, Min. Max. Akt. usw. usf. Da ist dein Tool noch sehr weit entfernt von. 😅
ich würde es nicht mal Tool nennen finde es aber wirklich sehr pragmatisch und kann einfach erweitert werden. Nim den Code und investiere als anfänger 2 stunden mit Chatgpt, du hast es so wie du willst...
 
Und was genau ist das nun für ein Tool?
 
Und was genau ist das nun für ein Tool?

Zeig Chatgpt diesen Code. zeig ein foto und erkläre das du system daten auslesen möchtest.
Programm so auf bauen möchtest.

Da du andere hardware hast musst du nur die Temperatur singnale auslesen und CHatgpt sagen welche Temperaturen für was ist.
Anfänger aufwand maximal 2H....
Denke mit meiner Vorlage und mit KI schon Erfahrung gesammelt hat läuft das unter 10 min.

Du kannst unten im text direkt schon Mainboards etc alles umschreiben wie es bei dir benamsen sein soll.

Python:
!/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()
 
Hi,

kurze Frage?

Bei mir wird beim Update immer wieder Treiber und Firmware mit rein geladen von Hardware die ich weder im System drin habe, noch jemals drin haben werde. Also so was wie Intel und Nvidia.
Kann man das irgendwie ausstellen das die Treiber mit runter geladen werden?

1789743055453.png
 
Laut ChatGPT ist linux-firmware ein Meta Paket, dass die anderen Pakete mit runter zieht. Deinstalliert man das und die Pakete, die man definitiv nicht braucht, wird nur noch das aktualisiert was du noch drauf hast, also bei deinem System bliebe z.B. linux-firmware-amd und linux-firmware-amdgpu drauf, die würden trotzdem noch akualisiert.

Also einfach linux-firmeware + die Pakete, die du nicht brauchst deinstallieren. Geh aber sicher, dass du wirklich nur die Pakete deinstallierst, die du nicht brauchst. :D
 
Was würde euch am ehesten gefallen ?
Variante 1 sehr klasisch... find ich am besten.

1790150293701.png
 
Mir persönlich würde die 4 mit den Details aus 5 am ehesten gefallen.
 
Variante 1 wäre mein Favorit - ich habs gerne kompakt.
 
nicht schlecht für den anfang...
1790164838540.png
 
Hier noch den den Code dazu:
Hoffe ihr postet euer Monitor-system .
#!/usr/bin/env python3

import subprocess
import re
import os
import shutil
import tkinter as tk
from collections import deque

INTERVAL_MS = 2000
HISTORY_MINUTES = 15
HISTORY_POINTS = int(HISTORY_MINUTES * 60 * 1000 / INTERVAL_MS)
topmost = True
selected_sensors = []
graph_buttons = []
graph_canvases = {}
graph_cards = {}
value_marks = {}
layout_signature = None

# 15-minute ring buffers, one per graphable sensor.
histories = {}
latest_values = {}

RED = "#c01818"
GREEN = "#11a88e"
BG = "#f2f2f2"
TEXT = "#000000"
GRAPH_BG = "#101820"
GRAPH_GRID = "#253340"
GRAPH_TEXT = "#e8eef2"
GRAPH_MUTED = "#9caab4"

# Fixed graph scales requested for the monitor.
# key = (section, sensor name) -> (min, max, unit, graph title, line color, fill color)
GRAPH_CONFIG = {
("MAINBOARD", "MOS"): (0, 100, "°C", "MOS", "#2aa8ff", "#123852"),
("MAINBOARD", "RAM Bank A"): (0, 60, "°C", "RAM Bank A", "#22b8a7", "#123c39"),
("MAINBOARD", "RAM Bank B"): (0, 60, "°C", "RAM Bank B", "#22b8a7", "#123c39"),
("WASSER", "Durchfluss"): (0, 300, "L/h", "Durchfluss", "#32d060", "#153d24"),
("WASSER", "RADI AUS"): (0, 50, "°C", "RADI AUS", "#27b7ff", "#123b50"),
("WASSER", "RADI EIN"): (0, 50, "°C", "RADI EIN", "#16d0e5", "#123f46"),
("AQUAERO", "Lüfter"): (0, 2000, "RPM", "Lüfter", "#b85cff", "#39204d"),
("AQUAERO", "VPP APEX 1"): (0, 5000, "RPM", "VPP APEX 1", "#ff6b57", "#4c211b"),
("AQUAERO", "VPP APEX 2"): (0, 5000, "RPM", "VPP APEX 2", "#ff6b57", "#4c211b"),
("AQUAERO", "DDC-Pumpe"): (0, 5000, "RPM", "DDC-Pumpe", "#ff6b57", "#4c211b"),
("AQUAERO", "Raumtemperatur"): (0, 35, "°C", "Raumtemperatur", "#e0b74f", "#443817"),
("GPU", "edge"): (0, 100, "°C", "GPU Edge", "#ff8c32", "#4a2b13"),
("GPU", "junction"): (0, 100, "°C", "GPU Junction", "#ff8c32", "#4a2b13"),
("GPU", "mem"): (0, 100, "°C", "GPU Memory", "#ff8c32", "#4a2b13"),
("CPU", "Tctl"): (0, 100, "°C", "CPU Tctl", "#e83c3c", "#4a1c1c"),
("CPU", "Tccd1"): (0, 100, "°C", "CPU Tccd1", "#e83c3c", "#4a1c1c"),
("SSD", "Samsung 980 Pro"): (0, 75, "°C", "Samsung 980 Pro", "#d5a84d", "#403518"),
("SSD", "980 Pro NAND"): (0, 75, "°C", "980 Pro NAND", "#d5a84d", "#403518"),
("SSD", "Crucial T705"): (0, 75, "°C", "Crucial T705", "#d5a84d", "#403518"),
("SSD", "T705 NAND"): (0, 75, "°C", "T705 NAND", "#d5a84d", "#403518"),
("SSD", "T-N300 Raid 0"): (0, 60, "°C", "T-N300 Raid 0 MAX", "#d5a84d", "#403518"),
("WLAN", "WLAN"): (0, 100, "°C", "WLAN", "#7099ff", "#202f55"),
}

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(
("RADI AUS", 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 2 speed:\s*(\d+) RPM",
"VPP APEX 1",
" RPM"
),

(
r"\s*Fan 3 speed:\s*(\d+) RPM",
"VPP APEX 2",
" RPM"
),

(
r"\s*Fan 4 speed:\s*(\d+) RPM",
"DDC-Pumpe",
" RPM"
),

(
r"\s*Sensor 1:\s*([+-][0-9.]+)°C",
"Raumtemperatur",
"°C"
),
]

for pattern, name, unit in checks:

m = re.match(pattern, line)

if m:
data["AQUAERO"].append(
(name, f"{m.group(1)}{unit}")
)

# Aquaero internal calculated virtual sensor backed by CaliTemp 4.
# Calc. virtual sensor 4 = hot water before the radiators (radiator inlet).
m = re.match(r"\s*Calc\. virtual sensor 4:\s*([+-][0-9.]+)°C", line)
if m:
data["WASSER"].append(("RADI EIN", f"{m.group(1)}°C"))

# 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



# ---------------------------------------------------------------------------
# UI / live graphs
# ---------------------------------------------------------------------------
# The parser and sensor mapping above stay unchanged. The UI below deliberately
# uses real Label widgets for every live value instead of Text-widget marks.
# This prevents stale values and the stray values that could accumulate at the
# bottom of the old Text widget after repeated in-place edits.

# Blue shimmer palette (top -> bright blue-grey -> dark blue).
BG_TOP = "#1b2a3a"
BG_GLOW = "#29445f"
BG_BOTTOM = "#101923"
PANEL_BG = "#152433"
TEXT = "#eef4f8"
TEXT_MUTED = "#aebdca"
GRAPH_BG = "#0f1923"
GRAPH_GRID = "#263746"
GRAPH_TEXT = "#edf4f8"
GRAPH_MUTED = "#92a5b5"
BUTTON_OFF = "#102d44"
BUTTON_ON = "#174e64"
BUTTON_BORDER = "#2389c9"
BUTTON_BORDER_ON = "#69d7e6"
BUTTON_ICON = "#f0f8ff"
BUTTON_ICON_ON = "#ffffff"

# Final mockup dimensions / spacing.
LEFT_PANEL_WIDTH = 455
GRAPH_BUTTON_SIZE = 24
# Another ~14 % lower than the previous 180 px version: compact but still readable.
GRAPH_CARD_HEIGHT = 155
GRAPH_CARD_GAP = 5
GRAPH_CARD_RADIUS = 9
GRAPH_CARD_PAD = 4
SCROLLBAR_WIDTH = 12
GRAPH_CARD_GLOW = "#082c43"
GRAPH_CARD_BORDER = "#0f6289"
GRAPH_CARD_BORDER_ACTIVE = "#1a91c6"

# Replace the Text-widget mark system with stable widgets.
value_labels = {}
button_widgets = {}
layout_signature = None


def _hex_to_rgb(color):
color = color.lstrip("#")
return tuple(int(color[i:i+2], 16) for i in (0, 2, 4))


def _rgb_to_hex(rgb):
return "#%02x%02x%02x" % tuple(max(0, min(255, int(round(v)))) for v in rgb)


def _blend(c1, c2, t):
a = _hex_to_rgb(c1)
b = _hex_to_rgb(c2)
return _rgb_to_hex(tuple(a + (b - a) * t for i in range(3)))


def shimmer_color(t):
"""Subtle light/dark blue shimmer used by the left sensor column."""
t = max(0.0, min(1.0, t))
if t <= 0.34:
return _blend(BG_TOP, BG_GLOW, t / 0.34)
return _blend(BG_GLOW, BG_BOTTOM, (t - 0.34) / 0.66)


def draw_header():
header.delete("all")
w = max(header.winfo_width(), 1000)
h = 90

# Smooth vertical dark-blue shimmer; drawn only on resize/start, not every sample.
for y in range(h):
t = y / max(1, h - 1)
# slightly brighter around the upper-middle of the header
if t < 0.45:
color = _blend("#22364b", "#314f69", t / 0.45)
else:
color = _blend("#314f69", "#162433", (t - 0.45) / 0.55)
header.create_line(0, y, w, y, fill=color)

header.create_text(55, 32, text="XX", fill=RED,
font=("Arial", 34, "bold"), anchor="center")
header.create_text(120, 26, text="hardware", fill="#f2f6f8",
font=("Arial", 18), anchor="w")
header.create_text(330, 26, text="LUXX", fill="#ff3d45",
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="#31c9ad",
font=("Arial", 10, "bold"), anchor="w")
header.create_text(148, 75, text="Creator Oefianer", fill="#dce6ed",
font=("Arial", 8, "bold"), anchor="w")


def graph_key(section, name):
return (section, name)


def value_for_history(name, value):
"""Convert a displayed sensor value into a float for graphing.

RAID0 shows two HDD temperatures (e.g. 30/31°C). Its graph uses the
warmer drive while the left display continues to show both values.
"""
nums = re.findall(r"[-+]?\d+(?:\.\d+)?", value)
if not nums:
return None
vals = [float(x) for x in nums]
if name == "T-N300 Raid 0" and len(vals) >= 2:
return max(vals[:2])
return vals[0]


def update_histories(data):
for section, values in data.items():
for name, value in values:
key = graph_key(section, name)
if key not in GRAPH_CONFIG:
continue
number = value_for_history(name, value)
if number is None:
continue
if key not in histories:
histories[key] = deque(maxlen=HISTORY_POINTS)
histories[key].append(number)
latest_values[key] = number


def _rounded_rectangle(canvas, x1, y1, x2, y2, radius, **kwargs):
"""Draw a smooth rounded rectangle on a Tk Canvas."""
radius = max(1, min(radius, (x2 - x1) / 2, (y2 - y1) / 2))
points = [
x1 + radius, y1,
x2 - radius, y1,
x2, y1,
x2, y1 + radius,
x2, y2 - radius,
x2, y2,
x2 - radius, y2,
x1 + radius, y2,
x1, y2,
x1, y2 - radius,
x1, y1 + radius,
x1, y1,
]
return canvas.create_polygon(points, smooth=True, splinesteps=18, **kwargs)


def _draw_graph_button(widget, selected):
"""Draw the larger mockup-style rising graph button.

The icon is drawn by Tk itself (no emoji/font dependency): a strong blue
square border with a clear white rising line and arrow head.
"""
widget.delete("all")
w = int(widget.cget("width"))
h = int(widget.cget("height"))
bg = BUTTON_ON if selected else BUTTON_OFF
border = BUTTON_BORDER_ON if selected else BUTTON_BORDER
icon = BUTTON_ICON_ON if selected else BUTTON_ICON

# Canvas highlight is disabled; the visible 2 px frame is ours so it looks
# identical on KDE/Tk themes.
widget.configure(bg=bg, highlightthickness=0)
_rounded_rectangle(
widget, 1, 1, w - 2, h - 2, 5,
fill=bg, outline=border, width=2,
)

# Rising chart line with a real arrow head, matching the preferred button.
widget.create_line(
5, h - 6,
10, h - 11,
13, h - 9,
w - 6, 7,
fill=icon, width=2.4, smooth=False,
arrow=tk.LAST, arrowshape=(6, 7, 2),
)


def _set_button_state(key):
widget = button_widgets.get(key)
if widget is None:
return
_draw_graph_button(widget, key in selected_sensors)


def toggle_graph(key, widget=None):
if key in selected_sensors:
selected_sensors.remove(key)
else:
selected_sensors.append(key)
_set_button_state(key)
sync_graphs()


def graph_button_click(event, key):
toggle_graph(key, event.widget)
return "break"


def draw_single_graph(graph, key):
"""Flicker-free graph update.

Static title/grid/axes are drawn only at creation or resize. Every 2 s we
only move the line/fill and update the current-value text.
"""
if key not in GRAPH_CONFIG:
return

width = max(graph.winfo_width(), 420)
height = max(graph.winfo_height(), 120)
size = (width, height)

ymin, ymax, unit, title, line_color, fill_color = GRAPH_CONFIG[key]
values = list(histories.get(key, []))
current = latest_values.get(key)

left = 58
right = width - 14
top = 35
bottom = height - 29
pw = max(1, right - left)
ph = max(1, bottom - top)

if getattr(graph, "_graph_size", None) != size or getattr(graph, "_graph_key", None) != key:
graph.delete("all")
graph._graph_size = size
graph._graph_key = key

# Deliberately start the title to the right of the Y-axis labels so it
# can never collide with the maximum value (50, 100, 5000, ...).
graph.create_text(
left + 10, 10, text=title, fill=GRAPH_TEXT,
font=("Arial", 12, "bold"), anchor="nw", tags=("static",)
)
graph._current_id = graph.create_text(
width - 12, 10, text="", fill=GRAPH_TEXT,
font=("Arial", 12, "bold"), anchor="ne", tags=("dynamic",)
)

for i in range(5):
frac = i / 4
y = top + frac * ph
val = ymax - frac * (ymax - ymin)
graph.create_line(left, y, right, y, fill=GRAPH_GRID, width=1, tags=("static",))
label = f"{int(round(val))}" if abs(val - round(val)) < 1e-9 else f"{val:.1f}"
graph.create_text(left - 8, y, text=label, fill=GRAPH_MUTED,
font=("Arial", 9), anchor="e", tags=("static",))

for frac, label in [(0.0, "-15 min"), (1/3, "-10"), (2/3, "-5"), (1.0, "0")]:
x = left + frac * pw
graph.create_line(x, top, x, bottom, fill=GRAPH_GRID, width=1, tags=("static",))
anchor = "w" if frac == 0 else ("e" if frac == 1 else "center")
graph.create_text(x, bottom + 8, text=label, fill=GRAPH_MUTED,
font=("Arial", 9), anchor=anchor, tags=("static",))

graph.create_rectangle(left, top, right, bottom, outline="#385062", width=1, tags=("static",))
graph._fill_id = graph.create_polygon(
0, 0, 0, 0, 0, 0, fill=fill_color, outline="",
state="hidden", tags=("dynamic",)
)
graph._line_id = graph.create_line(
0, 0, 0, 0, fill=line_color, width=2,
smooth=False, state="hidden", tags=("dynamic",)
)
graph._point_id = graph.create_oval(
0, 0, 0, 0, fill=line_color, outline=line_color,
state="hidden", tags=("dynamic",)
)

if current is None:
current_text = ""
elif unit == "RPM":
current_text = f"{current:.0f} {unit}"
elif unit == "L/h":
current_text = f"{current:.1f} {unit}"
else:
current_text = f"{current:.1f}{unit}"
graph.itemconfigure(graph._current_id, text=current_text)

if not values:
graph.itemconfigure(graph._fill_id, state="hidden")
graph.itemconfigure(graph._line_id, state="hidden")
graph.itemconfigure(graph._point_id, state="hidden")
return

points = []
n = len(values)
for i, value in enumerate(values):
age_samples = n - 1 - i
x = right - (age_samples / max(1, HISTORY_POINTS - 1)) * pw
clamped = min(max(value, ymin), ymax)
y = bottom - ((clamped - ymin) / (ymax - ymin)) * ph
points.extend((x, y))

if len(points) >= 4:
fill_points = [points[0], bottom] + points + [points[-2], bottom]
graph.coords(graph._fill_id, *fill_points)
graph.coords(graph._line_id, *points)
graph.itemconfigure(graph._fill_id, state="normal")
graph.itemconfigure(graph._line_id, state="normal")
graph.itemconfigure(graph._point_id, state="hidden")
else:
x, y = points[0], points[1]
graph.coords(graph._point_id, x - 2, y - 2, x + 2, y + 2)
graph.itemconfigure(graph._point_id, state="normal")
graph.itemconfigure(graph._fill_id, state="hidden")
graph.itemconfigure(graph._line_id, state="hidden")


def refresh_graph_scrollregion():
graph_list_frame.update_idletasks()
graph_scroll.configure(scrollregion=graph_scroll.bbox("all"))


def _draw_card_shell(card):
"""Draw the rounded cyan/blue card and subtle glow from the SOLL mockup."""
w = max(20, card.winfo_width())
h = max(20, card.winfo_height())
card.delete("card_shell")

# Soft simulated glow: two rounded outlines, deliberately restrained.
_rounded_rectangle(
card, 1, 1, w - 2, h - 2, GRAPH_CARD_RADIUS + 1,
fill=GRAPH_BG, outline=GRAPH_CARD_GLOW, width=3,
tags=("card_shell",),
)
_rounded_rectangle(
card, 3, 3, w - 4, h - 4, GRAPH_CARD_RADIUS,
fill=GRAPH_BG, outline=GRAPH_CARD_BORDER, width=1,
tags=("card_shell",),
)
card.tag_lower("card_shell")


def _layout_graph_card(card, graph, window_id, key):
_draw_card_shell(card)
pad = GRAPH_CARD_PAD
w = max(40, card.winfo_width() - pad * 2)
h = max(40, card.winfo_height() - pad * 2)
card.coords(window_id, pad, pad)
card.itemconfigure(window_id, width=w, height=h)
draw_single_graph(graph, key)


def sync_graphs():
"""Create/remove/reorder rounded graph cards only when selection changes."""
for key in list(graph_canvases):
if key not in selected_sensors:
card = graph_cards.pop(key, None)
if card is not None:
try:
card.destroy()
except Exception:
pass
graph_canvases.pop(key, None)

for key in selected_sensors:
if key not in graph_canvases:
card = tk.Canvas(
graph_list_frame,
bg=BG_BOTTOM,
height=GRAPH_CARD_HEIGHT,
highlightthickness=0,
bd=0,
)
graph = tk.Canvas(
card,
bg=GRAPH_BG,
highlightthickness=0,
bd=0,
)
window_id = card.create_window(
(GRAPH_CARD_PAD, GRAPH_CARD_PAD),
window=graph,
anchor="nw",
)
card._graph_window_id = window_id
graph_canvases[key] = graph
graph_cards[key] = card

card.bind(
"<Configure>",
lambda event, g=graph, wid=window_id, k=key:
_layout_graph_card(event.widget, g, wid, k),
)
graph.bind("<Configure>", lambda event, k=key: draw_single_graph(event.widget, k))

# Scrolling works even while the pointer is over a graph card.
for widget in (card, graph):
widget.bind("<Button-4>", graph_wheel_up)
widget.bind("<Button-5>", graph_wheel_down)

for key in selected_sensors:
card = graph_cards[key]
card.pack_forget()
card.pack(fill="x", expand=True, pady=(0, GRAPH_CARD_GAP))
card.update_idletasks()
_layout_graph_card(card, graph_canvases[key], card._graph_window_id, key)

if not selected_sensors:
empty_graph_label.pack(fill="x", pady=(35, 0))
else:

#!/usr/bin/env python3

import subprocess
import re
import os
import shutil
import tkinter as tk
from collections import deque

INTERVAL_MS = 2000
HISTORY_MINUTES = 15
HISTORY_POINTS = int(HISTORY_MINUTES * 60 * 1000 / INTERVAL_MS)
topmost = True
selected_sensors = []
graph_buttons = []
graph_canvases = {}
graph_cards = {}
value_marks = {}
layout_signature = None

# 15-minute ring buffers, one per graphable sensor.
histories = {}
latest_values = {}

RED = "#c01818"
GREEN = "#11a88e"
BG = "#f2f2f2"
TEXT = "#000000"
GRAPH_BG = "#101820"
GRAPH_GRID = "#253340"
GRAPH_TEXT = "#e8eef2"
GRAPH_MUTED = "#9caab4"

# Fixed graph scales requested for the monitor.
# key = (section, sensor name) -> (min, max, unit, graph title, line color, fill color)
GRAPH_CONFIG = {
("MAINBOARD", "MOS"): (0, 100, "°C", "MOS", "#2aa8ff", "#123852"),
("MAINBOARD", "RAM Bank A"): (0, 60, "°C", "RAM Bank A", "#22b8a7", "#123c39"),
("MAINBOARD", "RAM Bank B"): (0, 60, "°C", "RAM Bank B", "#22b8a7", "#123c39"),
("WASSER", "Durchfluss"): (0, 300, "L/h", "Durchfluss", "#32d060", "#153d24"),
("WASSER", "RADI AUS"): (0, 50, "°C", "RADI AUS", "#27b7ff", "#123b50"),
("WASSER", "RADI EIN"): (0, 50, "°C", "RADI EIN", "#16d0e5", "#123f46"),
("AQUAERO", "Lüfter"): (0, 2000, "RPM", "Lüfter", "#b85cff", "#39204d"),
("AQUAERO", "VPP APEX 1"): (0, 5000, "RPM", "VPP APEX 1", "#ff6b57", "#4c211b"),
("AQUAERO", "VPP APEX 2"): (0, 5000, "RPM", "VPP APEX 2", "#ff6b57", "#4c211b"),
("AQUAERO", "DDC-Pumpe"): (0, 5000, "RPM", "DDC-Pumpe", "#ff6b57", "#4c211b"),
("AQUAERO", "Raumtemperatur"): (0, 35, "°C", "Raumtemperatur", "#e0b74f", "#443817"),
("GPU", "edge"): (0, 100, "°C", "GPU Edge", "#ff8c32", "#4a2b13"),
("GPU", "junction"): (0, 100, "°C", "GPU Junction", "#ff8c32", "#4a2b13"),
("GPU", "mem"): (0, 100, "°C", "GPU Memory", "#ff8c32", "#4a2b13"),
("CPU", "Tctl"): (0, 100, "°C", "CPU Tctl", "#e83c3c", "#4a1c1c"),
("CPU", "Tccd1"): (0, 100, "°C", "CPU Tccd1", "#e83c3c", "#4a1c1c"),
("SSD", "Samsung 980 Pro"): (0, 75, "°C", "Samsung 980 Pro", "#d5a84d", "#403518"),
("SSD", "980 Pro NAND"): (0, 75, "°C", "980 Pro NAND", "#d5a84d", "#403518"),
("SSD", "Crucial T705"): (0, 75, "°C", "Crucial T705", "#d5a84d", "#403518"),
("SSD", "T705 NAND"): (0, 75, "°C", "T705 NAND", "#d5a84d", "#403518"),
("SSD", "T-N300 Raid 0"): (0, 60, "°C", "T-N300 Raid 0 MAX", "#d5a84d", "#403518"),
("WLAN", "WLAN"): (0, 100, "°C", "WLAN", "#7099ff", "#202f55"),
}

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(
("RADI AUS", 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 2 speed:\s*(\d+) RPM",
"VPP APEX 1",
" RPM"
),

(
r"\s*Fan 3 speed:\s*(\d+) RPM",
"VPP APEX 2",
" RPM"
),

(
r"\s*Fan 4 speed:\s*(\d+) RPM",
"DDC-Pumpe",
" RPM"
),

(
r"\s*Sensor 1:\s*([+-][0-9.]+)°C",
"Raumtemperatur",
"°C"
),
]

for pattern, name, unit in checks:

m = re.match(pattern, line)

if m:
data["AQUAERO"].append(
(name, f"{m.group(1)}{unit}")
)

# Aquaero internal calculated virtual sensor backed by CaliTemp 4.
# Calc. virtual sensor 4 = hot water before the radiators (radiator inlet).
m = re.match(r"\s*Calc\. virtual sensor 4:\s*([+-][0-9.]+)°C", line)
if m:
data["WASSER"].append(("RADI EIN", f"{m.group(1)}°C"))

# 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



# ---------------------------------------------------------------------------
# UI / live graphs
# ---------------------------------------------------------------------------
# The parser and sensor mapping above stay unchanged. The UI below deliberately
# uses real Label widgets for every live value instead of Text-widget marks.
# This prevents stale values and the stray values that could accumulate at the
# bottom of the old Text widget after repeated in-place edits.

# Blue shimmer palette (top -> bright blue-grey -> dark blue).
BG_TOP = "#1b2a3a"
BG_GLOW = "#29445f"
BG_BOTTOM = "#101923"
PANEL_BG = "#152433"
TEXT = "#eef4f8"
TEXT_MUTED = "#aebdca"
GRAPH_BG = "#0f1923"
GRAPH_GRID = "#263746"
GRAPH_TEXT = "#edf4f8"
GRAPH_MUTED = "#92a5b5"
BUTTON_OFF = "#102d44"
BUTTON_ON = "#174e64"
BUTTON_BORDER = "#2389c9"
BUTTON_BORDER_ON = "#69d7e6"
BUTTON_ICON = "#f0f8ff"
BUTTON_ICON_ON = "#ffffff"

# Final mockup dimensions / spacing.
LEFT_PANEL_WIDTH = 455
GRAPH_BUTTON_SIZE = 24
# Another ~14 % lower than the previous 180 px version: compact but still readable.
GRAPH_CARD_HEIGHT = 155
GRAPH_CARD_GAP = 5
GRAPH_CARD_RADIUS = 9
GRAPH_CARD_PAD = 4
SCROLLBAR_WIDTH = 12
GRAPH_CARD_GLOW = "#082c43"
GRAPH_CARD_BORDER = "#0f6289"
GRAPH_CARD_BORDER_ACTIVE = "#1a91c6"

# Replace the Text-widget mark system with stable widgets.
value_labels = {}
button_widgets = {}
layout_signature = None


def _hex_to_rgb(color):
color = color.lstrip("#")
return tuple(int(color[i:i+2], 16) for i in (0, 2, 4))


def _rgb_to_hex(rgb):
return "#%02x%02x%02x" % tuple(max(0, min(255, int(round(v)))) for v in rgb)


def _blend(c1, c2, t):
a = _hex_to_rgb(c1)
b = _hex_to_rgb(c2)
return _rgb_to_hex(tuple(a + (b - a) * t for i in range(3)))


def shimmer_color(t):
"""Subtle light/dark blue shimmer used by the left sensor column."""
t = max(0.0, min(1.0, t))
if t <= 0.34:
return _blend(BG_TOP, BG_GLOW, t / 0.34)
return _blend(BG_GLOW, BG_BOTTOM, (t - 0.34) / 0.66)


def draw_header():
header.delete("all")
w = max(header.winfo_width(), 1000)
h = 90

# Smooth vertical dark-blue shimmer; drawn only on resize/start, not every sample.
for y in range(h):
t = y / max(1, h - 1)
# slightly brighter around the upper-middle of the header
if t < 0.45:
color = _blend("#22364b", "#314f69", t / 0.45)
else:
color = _blend("#314f69", "#162433", (t - 0.45) / 0.55)
header.create_line(0, y, w, y, fill=color)

header.create_text(55, 32, text="XX", fill=RED,
font=("Arial", 34, "bold"), anchor="center")
header.create_text(120, 26, text="hardware", fill="#f2f6f8",
font=("Arial", 18), anchor="w")
header.create_text(330, 26, text="LUXX", fill="#ff3d45",
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="#31c9ad",
font=("Arial", 10, "bold"), anchor="w")
header.create_text(148, 75, text="Creator Oefianer", fill="#dce6ed",
font=("Arial", 8, "bold"), anchor="w")


def graph_key(section, name):
return (section, name)


def value_for_history(name, value):
"""Convert a displayed sensor value into a float for graphing.

RAID0 shows two HDD temperatures (e.g. 30/31°C). Its graph uses the
warmer drive while the left display continues to show both values.
"""
nums = re.findall(r"[-+]?\d+(?:\.\d+)?", value)
if not nums:
return None
vals = [float(x) for x in nums]
if name == "T-N300 Raid 0" and len(vals) >= 2:
return max(vals[:2])
return vals[0]


def update_histories(data):
for section, values in data.items():
for name, value in values:
key = graph_key(section, name)
if key not in GRAPH_CONFIG:
continue
number = value_for_history(name, value)
if number is None:
continue
if key not in histories:
histories[key] = deque(maxlen=HISTORY_POINTS)
histories[key].append(number)
latest_values[key] = number


def _rounded_rectangle(canvas, x1, y1, x2, y2, radius, **kwargs):
"""Draw a smooth rounded rectangle on a Tk Canvas."""
radius = max(1, min(radius, (x2 - x1) / 2, (y2 - y1) / 2))
points = [
x1 + radius, y1,
x2 - radius, y1,
x2, y1,
x2, y1 + radius,
x2, y2 - radius,
x2, y2,
x2 - radius, y2,
x1 + radius, y2,
x1, y2,
x1, y2 - radius,
x1, y1 + radius,
x1, y1,
]
return canvas.create_polygon(points, smooth=True, splinesteps=18, **kwargs)


def _draw_graph_button(widget, selected):
"""Draw the larger mockup-style rising graph button.

The icon is drawn by Tk itself (no emoji/font dependency): a strong blue
square border with a clear white rising line and arrow head.
"""
widget.delete("all")
w = int(widget.cget("width"))
h = int(widget.cget("height"))
bg = BUTTON_ON if selected else BUTTON_OFF
border = BUTTON_BORDER_ON if selected else BUTTON_BORDER
icon = BUTTON_ICON_ON if selected else BUTTON_ICON

# Canvas highlight is disabled; the visible 2 px frame is ours so it looks
# identical on KDE/Tk themes.
widget.configure(bg=bg, highlightthickness=0)
_rounded_rectangle(
widget, 1, 1, w - 2, h - 2, 5,
fill=bg, outline=border, width=2,
)

# Rising chart line with a real arrow head, matching the preferred button.
widget.create_line(
5, h - 6,
10, h - 11,
13, h - 9,
w - 6, 7,
fill=icon, width=2.4, smooth=False,
arrow=tk.LAST, arrowshape=(6, 7, 2),
)


def _set_button_state(key):
widget = button_widgets.get(key)
if widget is None:
return
_draw_graph_button(widget, key in selected_sensors)


def toggle_graph(key, widget=None):
if key in selected_sensors:
selected_sensors.remove(key)
else:
selected_sensors.append(key)
_set_button_state(key)
sync_graphs()


def graph_button_click(event, key):
toggle_graph(key, event.widget)
return "break"


def draw_single_graph(graph, key):
"""Flicker-free graph update.

Static title/grid/axes are drawn only at creation or resize. Every 2 s we
only move the line/fill and update the current-value text.
"""
if key not in GRAPH_CONFIG:
return

width = max(graph.winfo_width(), 420)
height = max(graph.winfo_height(), 120)
size = (width, height)

ymin, ymax, unit, title, line_color, fill_color = GRAPH_CONFIG[key]
values = list(histories.get(key, []))
current = latest_values.get(key)

left = 58
right = width - 14
top = 35
bottom = height - 29
pw = max(1, right - left)
ph = max(1, bottom - top)

if getattr(graph, "_graph_size", None) != size or getattr(graph, "_graph_key", None) != key:
graph.delete("all")
graph._graph_size = size
graph._graph_key = key

# Deliberately start the title to the right of the Y-axis labels so it
# can never collide with the maximum value (50, 100, 5000, ...).
graph.create_text(
left + 10, 10, text=title, fill=GRAPH_TEXT,
font=("Arial", 12, "bold"), anchor="nw", tags=("static",)
)
graph._current_id = graph.create_text(
width - 12, 10, text="", fill=GRAPH_TEXT,
font=("Arial", 12, "bold"), anchor="ne", tags=("dynamic",)
)

for i in range(5):
frac = i / 4
y = top + frac * ph
val = ymax - frac * (ymax - ymin)
graph.create_line(left, y, right, y, fill=GRAPH_GRID, width=1, tags=("static",))
label = f"{int(round(val))}" if abs(val - round(val)) < 1e-9 else f"{val:.1f}"
graph.create_text(left - 8, y, text=label, fill=GRAPH_MUTED,
font=("Arial", 9), anchor="e", tags=("static",))

for frac, label in [(0.0, "-15 min"), (1/3, "-10"), (2/3, "-5"), (1.0, "0")]:
x = left + frac * pw
graph.create_line(x, top, x, bottom, fill=GRAPH_GRID, width=1, tags=("static",))
anchor = "w" if frac == 0 else ("e" if frac == 1 else "center")
graph.create_text(x, bottom + 8, text=label, fill=GRAPH_MUTED,
font=("Arial", 9), anchor=anchor, tags=("static",))

graph.create_rectangle(left, top, right, bottom, outline="#385062", width=1, tags=("static",))
graph._fill_id = graph.create_polygon(
0, 0, 0, 0, 0, 0, fill=fill_color, outline="",
state="hidden", tags=("dynamic",)
)
graph._line_id = graph.create_line(
0, 0, 0, 0, fill=line_color, width=2,
smooth=False, state="hidden", tags=("dynamic",)
)
graph._point_id = graph.create_oval(
0, 0, 0, 0, fill=line_color, outline=line_color,
state="hidden", tags=("dynamic",)
)

if current is None:
current_text = ""
elif unit == "RPM":
current_text = f"{current:.0f} {unit}"
elif unit == "L/h":
current_text = f"{current:.1f} {unit}"
else:
current_text = f"{current:.1f}{unit}"
graph.itemconfigure(graph._current_id, text=current_text)

if not values:
graph.itemconfigure(graph._fill_id, state="hidden")
graph.itemconfigure(graph._line_id, state="hidden")
graph.itemconfigure(graph._point_id, state="hidden")
return

points = []
n = len(values)
for i, value in enumerate(values):
age_samples = n - 1 - i
x = right - (age_samples / max(1, HISTORY_POINTS - 1)) * pw
clamped = min(max(value, ymin), ymax)
y = bottom - ((clamped - ymin) / (ymax - ymin)) * ph
points.extend((x, y))

if len(points) >= 4:
fill_points = [points[0], bottom] + points + [points[-2], bottom]
graph.coords(graph._fill_id, *fill_points)
graph.coords(graph._line_id, *points)
graph.itemconfigure(graph._fill_id, state="normal")
graph.itemconfigure(graph._line_id, state="normal")
graph.itemconfigure(graph._point_id, state="hidden")
else:
x, y = points[0], points[1]
graph.coords(graph._point_id, x - 2, y - 2, x + 2, y + 2)
graph.itemconfigure(graph._point_id, state="normal")
graph.itemconfigure(graph._fill_id, state="hidden")
graph.itemconfigure(graph._line_id, state="hidden")


def refresh_graph_scrollregion():
graph_list_frame.update_idletasks()
graph_scroll.configure(scrollregion=graph_scroll.bbox("all"))


def _draw_card_shell(card):
"""Draw the rounded cyan/blue card and subtle glow from the SOLL mockup."""
w = max(20, card.winfo_width())
h = max(20, card.winfo_height())
card.delete("card_shell")

# Soft simulated glow: two rounded outlines, deliberately restrained.
_rounded_rectangle(
card, 1, 1, w - 2, h - 2, GRAPH_CARD_RADIUS + 1,
fill=GRAPH_BG, outline=GRAPH_CARD_GLOW, width=3,
tags=("card_shell",),
)
_rounded_rectangle(
card, 3, 3, w - 4, h - 4, GRAPH_CARD_RADIUS,
fill=GRAPH_BG, outline=GRAPH_CARD_BORDER, width=1,
tags=("card_shell",),
)
card.tag_lower("card_shell")


def _layout_graph_card(card, graph, window_id, key):
_draw_card_shell(card)
pad = GRAPH_CARD_PAD
w = max(40, card.winfo_width() - pad * 2)
h = max(40, card.winfo_height() - pad * 2)
card.coords(window_id, pad, pad)
card.itemconfigure(window_id, width=w, height=h)
draw_single_graph(graph, key)


def sync_graphs():
"""Create/remove/reorder rounded graph cards only when selection changes."""
for key in list(graph_canvases):
if key not in selected_sensors:
card = graph_cards.pop(key, None)
if card is not None:
try:
card.destroy()
except Exception:
pass
graph_canvases.pop(key, None)

for key in selected_sensors:
if key not in graph_canvases:
card = tk.Canvas(
graph_list_frame,
bg=BG_BOTTOM,
height=GRAPH_CARD_HEIGHT,
highlightthickness=0,
bd=0,
)
graph = tk.Canvas(
card,
bg=GRAPH_BG,
highlightthickness=0,
bd=0,
)
window_id = card.create_window(
(GRAPH_CARD_PAD, GRAPH_CARD_PAD),
window=graph,
anchor="nw",
)
card._graph_window_id = window_id
graph_canvases[key] = graph
graph_cards[key] = card

card.bind(
"<Configure>",
lambda event, g=graph, wid=window_id, k=key:
_layout_graph_card(event.widget, g, wid, k),
)
graph.bind("<Configure>", lambda event, k=key: draw_single_graph(event.widget, k))

# Scrolling works even while the pointer is over a graph card.
for widget in (card, graph):
widget.bind("<Button-4>", graph_wheel_up)
widget.bind("<Button-5>", graph_wheel_down)

for key in selected_sensors:
card = graph_cards[key]
card.pack_forget()
card.pack(fill="x", expand=True, pady=(0, GRAPH_CARD_GAP))
card.update_idletasks()
_layout_graph_card(card, graph_canvases[key], card._graph_window_id, key)

if not selected_sensors:
empty_graph_label.pack(fill="x", pady=(35, 0))
else:
empty_graph_label.pack_forget()

root.after_idle(refresh_graph_scrollregion)

def update_graphs():
for key in selected_sensors:
canvas = graph_canvases.get(key)
if canvas is not None and canvas.winfo_exists():
draw_single_graph(canvas, key)


def ordered_values(data, title):
values = data.get(title, [])
if title == "MAINBOARD":
order = {"MOS": 0, "RAM Bank A": 1, "RAM Bank B": 2}
values = sorted(values, key=lambda item: order.get(item[0], 99))
elif title == "WASSER":
order = {"Durchfluss": 0, "RADI AUS": 1, "RADI EIN": 2}
values = sorted(values, key=lambda item: order.get(item[0], 99))
return values


def data_signature(data):
sig = []
for title in ["MAINBOARD", "WASSER", "AQUAERO", "WLAN", "GPU", "CPU", "SSD"]:
values = ordered_values(data, title)
if values:
sig.append((title, tuple(name for name, _ in values)))
return tuple(sig)


def _left_row_count(data):
total = 0
for title in ["MAINBOARD", "WASSER", "AQUAERO", "WLAN", "GPU", "CPU", "SSD"]:
values = ordered_values(data, title)
if values:
total += 1 + len(values)
return max(total, 1)


def rebuild_left_panel(data):
"""Build stable Label rows once; values are later updated via .configure()."""
global value_labels, button_widgets, layout_signature

for child in left_content.winfo_children():
child.destroy()
value_labels = {}
button_widgets = {}

total_rows = _left_row_count(data)
row_index = 0

for title in ["MAINBOARD", "WASSER", "AQUAERO", "WLAN", "GPU", "CPU", "SSD"]:
values = ordered_values(data, title)
if not values:
continue

row_bg = shimmer_color(row_index / max(1, total_rows - 1))
display_title = "WASSER DP ULTRA" if title == "WASSER" else title
sec = tk.Label(
left_content, text=display_title, anchor="w",
bg=row_bg, fg="#ff3b42", font=("Monospace", 12, "bold"),
padx=20, pady=1,
)
sec.pack(fill="x")
row_index += 1

for name, value in values:
row_bg = shimmer_color(row_index / max(1, total_rows - 1))
row = tk.Frame(left_content, bg=row_bg, height=25)
row.pack(fill="x")
row.pack_propagate(False)
row.grid_columnconfigure(0, weight=1)

name_label = tk.Label(
row, text=name, anchor="w", bg=row_bg, fg=TEXT,
font=("Monospace", 12), padx=0, pady=0,
)
name_label.grid(row=0, column=0, sticky="ew", padx=(20, 4))

value_label = tk.Label(
row, text=value, anchor="e", bg=row_bg, fg=TEXT,
font=("Monospace", 12), width=11, padx=1, pady=0,
)
value_label.grid(row=0, column=1, sticky="e")
value_labels[(title, name)] = value_label

key = graph_key(title, name)
if key in GRAPH_CONFIG:
selected = key in selected_sensors
button = tk.Canvas(
row, width=GRAPH_BUTTON_SIZE, height=GRAPH_BUTTON_SIZE,
bg=BUTTON_ON if selected else BUTTON_OFF,
bd=0, relief="flat",
highlightthickness=0,
cursor="hand2",
)
_draw_graph_button(button, selected)
button.grid(row=0, column=2, padx=(6, 12), pady=0, sticky="e")
button.bind("<Button-1>", lambda event, k=key: graph_button_click(event, k))
button_widgets[key] = button
else:
spacer = tk.Frame(row, bg=row_bg, width=GRAPH_BUTTON_SIZE + 18)
spacer.grid(row=0, column=2, padx=(0, 0))

row_index += 1

layout_signature = data_signature(data)


def update_left_values(data):
"""Update every live value without touching layout, rows, or buttons."""
for title in ["MAINBOARD", "WASSER", "AQUAERO", "WLAN", "GPU", "CPU", "SSD"]:
for name, value in ordered_values(data, title):
label = value_labels.get((title, name))
if label is not None and label.winfo_exists():
label.configure(text=value)


def render_data(data):
if data_signature(data) != layout_signature:
rebuild_left_panel(data)
else:
update_left_values(data)


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)

update_histories(data)
render_data(data)
update_graphs()
status_label.configure(text="")
except Exception as e:
status_label.configure(text="Fehler: " + str(e))

root.after(INTERVAL_MS, update)


def toggle_topmost(event=None):
global topmost
topmost = not topmost
root.attributes("-topmost", topmost)


# ---------------------------------------------------------------------------
# Window layout
# ---------------------------------------------------------------------------
root = tk.Tk()
root.title("hardwareLUXX")
root.geometry("1080x780")
root.minsize(980, 700)
root.configure(bg=BG_BOTTOM)
root.attributes("-topmost", True)

header = tk.Canvas(root, height=90, bg=BG_TOP, highlightthickness=0)
header.pack(fill="x")
header.bind("<Configure>", lambda event: draw_header())
# Clicking the logo/header keeps the original convenient always-on-top toggle,
# but interacting with graph buttons no longer changes it accidentally.
header.bind("<Button-1>", toggle_topmost)

body = tk.Frame(root, bg=BG_BOTTOM)
body.pack(fill="both", expand=True)

left_panel = tk.Frame(body, bg=BG_BOTTOM, width=LEFT_PANEL_WIDTH)
left_panel.pack(side="left", fill="y", expand=False)
left_panel.pack_propagate(False)

left_content = tk.Frame(left_panel, bg=BG_BOTTOM)
left_content.pack(fill="both", expand=True, pady=(5, 0))

status_label = tk.Label(
left_panel, text="", anchor="w", bg=BG_BOTTOM,
fg="#ff8a8a", font=("Monospace", 9), padx=20,
)
status_label.pack(fill="x", side="bottom")

right_panel = tk.Frame(body, bg=BG_BOTTOM)
right_panel.pack(side="left", fill="both", expand=True, padx=(6, 12), pady=(5, 12))

graph_scroll = tk.Canvas(right_panel, bg=BG_BOTTOM, highlightthickness=0, bd=0)
graph_scroll.pack(side="left", fill="both", expand=True)

graph_scrollbar = tk.Scrollbar(
right_panel, orient="vertical", command=graph_scroll.yview,
width=SCROLLBAR_WIDTH,
troughcolor="#07131d", bg="#234962", activebackground="#3b7898",
bd=0, relief="flat", highlightthickness=0,
)
graph_scrollbar.pack(side="right", fill="y")
graph_scroll.configure(yscrollcommand=graph_scrollbar.set)

graph_list_frame = tk.Frame(graph_scroll, bg=BG_BOTTOM)
graph_window = graph_scroll.create_window((0, 0), window=graph_list_frame, anchor="nw")


def resize_graph_list(event):
graph_scroll.itemconfigure(graph_window, width=event.width)


graph_scroll.bind("<Configure>", resize_graph_list)
graph_list_frame.bind(
"<Configure>",
lambda event: graph_scroll.configure(scrollregion=graph_scroll.bbox("all")),
)


def graph_wheel_up(event):
graph_scroll.yview_scroll(-3, "units")
return "break"


def graph_wheel_down(event):
graph_scroll.yview_scroll(3, "units")
return "break"


graph_scroll.bind("<Button-4>", graph_wheel_up)
graph_scroll.bind("<Button-5>", graph_wheel_down)
graph_list_frame.bind("<Button-4>", graph_wheel_up)
graph_list_frame.bind("<Button-5>", graph_wheel_down)

empty_graph_label = tk.Label(
graph_list_frame, text="Sensor wählen",
bg=BG_BOTTOM, fg="#8197a8", font=("Arial", 14, "bold"),
)
empty_graph_label.pack(fill="x", pady=(35, 0))

# Initial draw + live update.
root.update_idletasks()
draw_header()
sync_graphs()
update()
root.mainloop()

empty_graph_label.pack_forget()

root.after_idle(refresh_graph_scrollregion)

def update_graphs():
for key in selected_sensors:
canvas = graph_canvases.get(key)
if canvas is not None and canvas.winfo_exists():
draw_single_graph(canvas, key)


def ordered_values(data, title):
values = data.get(title, [])
if title == "MAINBOARD":
order = {"MOS": 0, "RAM Bank A": 1, "RAM Bank B": 2}
values = sorted(values, key=lambda item: order.get(item[0], 99))
elif title == "WASSER":
order = {"Durchfluss": 0, "RADI AUS": 1, "RADI EIN": 2}
values = sorted(values, key=lambda item: order.get(item[0], 99))
return values


def data_signature(data):
sig = []
for title in ["MAINBOARD", "WASSER", "AQUAERO", "WLAN", "GPU", "CPU", "SSD"]:
values = ordered_values(data, title)
if values:
sig.append((title, tuple(name for name, _ in values)))
return tuple(sig)


def _left_row_count(data):
total = 0
for title in ["MAINBOARD", "WASSER", "AQUAERO", "WLAN", "GPU", "CPU", "SSD"]:
values = ordered_values(data, title)
if values:
total += 1 + len(values)
return max(total, 1)


def rebuild_left_panel(data):
"""Build stable Label rows once; values are later updated via .configure()."""
global value_labels, button_widgets, layout_signature

for child in left_content.winfo_children():
child.destroy()
value_labels = {}
button_widgets = {}

total_rows = _left_row_count(data)
row_index = 0

for title in ["MAINBOARD", "WASSER", "AQUAERO", "WLAN", "GPU", "CPU", "SSD"]:
values = ordered_values(data, title)
if not values:
continue

row_bg = shimmer_color(row_index / max(1, total_rows - 1))
display_title = "WASSER DP ULTRA" if title == "WASSER" else title
sec = tk.Label(
left_content, text=display_title, anchor="w",
bg=row_bg, fg="#ff3b42", font=("Monospace", 12, "bold"),
padx=20, pady=1,
)
sec.pack(fill="x")
row_index += 1

for name, value in values:
row_bg = shimmer_color(row_index / max(1, total_rows - 1))
row = tk.Frame(left_content, bg=row_bg, height=25)
row.pack(fill="x")
row.pack_propagate(False)
row.grid_columnconfigure(0, weight=1)

name_label = tk.Label(
row, text=name, anchor="w", bg=row_bg, fg=TEXT,
font=("Monospace", 12), padx=0, pady=0,
)
name_label.grid(row=0, column=0, sticky="ew", padx=(20, 4))

value_label = tk.Label(
row, text=value, anchor="e", bg=row_bg, fg=TEXT,
font=("Monospace", 12), width=11, padx=1, pady=0,
)
value_label.grid(row=0, column=1, sticky="e")
value_labels[(title, name)] = value_label

key = graph_key(title, name)
if key in GRAPH_CONFIG:
selected = key in selected_sensors
button = tk.Canvas(
row, width=GRAPH_BUTTON_SIZE, height=GRAPH_BUTTON_SIZE,
bg=BUTTON_ON if selected else BUTTON_OFF,
bd=0, relief="flat",
highlightthickness=0,
cursor="hand2",
)
_draw_graph_button(button, selected)
button.grid(row=0, column=2, padx=(6, 12), pady=0, sticky="e")
button.bind("<Button-1>", lambda event, k=key: graph_button_click(event, k))
button_widgets[key] = button
else:
spacer = tk.Frame(row, bg=row_bg, width=GRAPH_BUTTON_SIZE + 18)
spacer.grid(row=0, column=2, padx=(0, 0))

row_index += 1

layout_signature = data_signature(data)


def update_left_values(data):
"""Update every live value without touching layout, rows, or buttons."""
for title in ["MAINBOARD", "WASSER", "AQUAERO", "WLAN", "GPU", "CPU", "SSD"]:
for name, value in ordered_values(data, title):
label = value_labels.get((title, name))
if label is not None and label.winfo_exists():
label.configure(text=value)


def render_data(data):
if data_signature(data) != layout_signature:
rebuild_left_panel(data)
else:
update_left_values(data)


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)

update_histories(data)
render_data(data)
update_graphs()
status_label.configure(text="")
except Exception as e:
status_label.configure(text="Fehler: " + str(e))

root.after(INTERVAL_MS, update)


def toggle_topmost(event=None):
global topmost
topmost = not topmost
root.attributes("-topmost", topmost)


# ---------------------------------------------------------------------------
# Window layout
# ---------------------------------------------------------------------------
root = tk.Tk()
root.title("hardwareLUXX")
root.geometry("1080x780")
root.minsize(980, 700)
root.configure(bg=BG_BOTTOM)
root.attributes("-topmost", True)

header = tk.Canvas(root, height=90, bg=BG_TOP, highlightthickness=0)
header.pack(fill="x")
header.bind("<Configure>", lambda event: draw_header())
# Clicking the logo/header keeps the original convenient always-on-top toggle,
# but interacting with graph buttons no longer changes it accidentally.
header.bind("<Button-1>", toggle_topmost)

body = tk.Frame(root, bg=BG_BOTTOM)
body.pack(fill="both", expand=True)

left_panel = tk.Frame(body, bg=BG_BOTTOM, width=LEFT_PANEL_WIDTH)
left_panel.pack(side="left", fill="y", expand=False)
left_panel.pack_propagate(False)

left_content = tk.Frame(left_panel, bg=BG_BOTTOM)
left_content.pack(fill="both", expand=True, pady=(5, 0))

status_label = tk.Label(
left_panel, text="", anchor="w", bg=BG_BOTTOM,
fg="#ff8a8a", font=("Monospace", 9), padx=20,
)
status_label.pack(fill="x", side="bottom")

right_panel = tk.Frame(body, bg=BG_BOTTOM)
right_panel.pack(side="left", fill="both", expand=True, padx=(6, 12), pady=(5, 12))

graph_scroll = tk.Canvas(right_panel, bg=BG_BOTTOM, highlightthickness=0, bd=0)
graph_scroll.pack(side="left", fill="both", expand=True)

graph_scrollbar = tk.Scrollbar(
right_panel, orient="vertical", command=graph_scroll.yview,
width=SCROLLBAR_WIDTH,
troughcolor="#07131d", bg="#234962", activebackground="#3b7898",
bd=0, relief="flat", highlightthickness=0,
)
graph_scrollbar.pack(side="right", fill="y")
graph_scroll.configure(yscrollcommand=graph_scrollbar.set)

graph_list_frame = tk.Frame(graph_scroll, bg=BG_BOTTOM)
graph_window = graph_scroll.create_window((0, 0), window=graph_list_frame, anchor="nw")


def resize_graph_list(event):
graph_scroll.itemconfigure(graph_window, width=event.width)


graph_scroll.bind("<Configure>", resize_graph_list)
graph_list_frame.bind(
"<Configure>",
lambda event: graph_scroll.configure(scrollregion=graph_scroll.bbox("all")),
)


def graph_wheel_up(event):
graph_scroll.yview_scroll(-3, "units")
return "break"


def graph_wheel_down(event):
graph_scroll.yview_scroll(3, "units")
return "break"


graph_scroll.bind("<Button-4>", graph_wheel_up)
graph_scroll.bind("<Button-5>", graph_wheel_down)
graph_list_frame.bind("<Button-4>", graph_wheel_up)
graph_list_frame.bind("<Button-5>", graph_wheel_down)

empty_graph_label = tk.Label(
graph_list_frame, text="Sensor wählen",
bg=BG_BOTTOM, fg="#8197a8", font=("Arial", 14, "bold"),
)
empty_graph_label.pack(fill="x", pady=(35, 0))

# Initial draw + live update.
root.update_idletasks()
draw_header()
sync_graphs()
update()
root.mainloop()

1790402812386.png
 
Hardwareluxx setzt keine externen Werbe- und Tracking-Cookies ein. Auf unserer Webseite finden Sie nur noch Cookies nach berechtigtem Interesse (Art. 6 Abs. 1 Satz 1 lit. f DSGVO) oder eigene funktionelle Cookies. Durch die Nutzung unserer Webseite erklären Sie sich damit einverstanden, dass wir diese Cookies setzen. Mehr Informationen und Möglichkeiten zur Einstellung unserer Cookies finden Sie in unserer Datenschutzerklärung.


Zurück
Oben Unten refresh