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
 
Du hast das nicht selbst gecodet sondern über ChatGPT erstellen lassen?
Magst Du die prompts mal zeigen? Ich würde mal - mit etwas Zeit - das für Bazzite machen wollen.
 
Du hast das nicht selbst gecodet sondern über ChatGPT erstellen lassen?
Magst Du die prompts mal zeigen? Ich würde mal - mit etwas Zeit - das für Bazzite machen wollen.
Ich habe ja schon mehrfach den code Scripte gepostet.

Python:
#!/usr/bin/env python3

import subprocess
import re
import os
import shutil
import tkinter as tk
import struct
import time
import math
import glob
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"
HEADER_HEIGHT = 170

# 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"): (20, 50, "°C", "RADI AUS", "#27b7ff", "#123b50"),
    ("WASSER", "RADI EIN"): (20, 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"): (20, 35, "°C", "Raumtemperatur", "#e0b74f", "#443817"),
    ("GPU", "edge"): (20, 100, "°C", "GPU Edge", "#ff8c32", "#4a2b13"),
    ("GPU", "junction"): (20, 100, "°C", "GPU Junction", "#ff8c32", "#4a2b13"),
    ("GPU", "mem"): (20, 100, "°C", "GPU Memory", "#ff8c32", "#4a2b13"),
    ("CPU", "Tctl"): (20, 100, "°C", "CPU Tctl", "#e83c3c", "#4a1c1c"),
    ("CPU", "Tccd1"): (20, 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"),
    ("POWER", "CPU"): (0, 150, "W", "CPU Power", "#e83c3c", "#4a1c1c"),
    ("POWER", "GPU"): (0, 600, "W", "GPU Power", "#ff8c32", "#4a2b13"),
}

HDD_DEVICES = [
    "/dev/disk/by-id/ata-TOSHIBA_MN10ADA800S_36K2A04RFNHL",
    "/dev/disk/by-id/ata-TOSHIBA_MN10ADA800S_36K2A09PFNHL",
]

# ---------------------------------------------------------------------------
# Power panel / estimation model
# ---------------------------------------------------------------------------
# CPU: Ryzen 7 5700X3D / Vermeer, ryzen_smu PM table 0x380905, 1488 bytes.
# d[1] is used as the live package/socket-power value.
RYZEN_SMU_PM_TABLE = "/sys/kernel/ryzen_smu_drv/pm_table"

# 12 x Phanteks M25 G2 Reverse 140 mm.  RGB is intentionally INCLUDED in FANS.
FAN_COUNT = 12
FAN_MAX_RPM = 1800.0
FAN_MAX_MOTOR_W = 3.36
FAN_IDLE_MOTOR_W = 0.25
FAN_RGB_MAX_W = 2.15
# RGB is not electrically measured by Aquaero, so use the agreed practical estimate.
# 0.60 = roughly 60 % of the published maximum RGB draw.
FAN_RGB_FACTOR = 0.60

# Pump estimates from live Aquaero RPM.
VPP_APEX_MAX_RPM = 4500.0
VPP_APEX_MAX_W = 14.0
DDC_MAX_RPM = 4000.0
DDC_MAX_W = 17.0

# Storage estimates.  They are scaled from Linux block-device busy time.
SAMSUNG_980_PRO_IDLE_W = 0.035
SAMSUNG_980_PRO_ACTIVE_W = 6.10
CRUCIAL_T705_IDLE_W = 0.10
CRUCIAL_T705_ACTIVE_W = 11.50
RAID0_IDLE_W = 11.48       # 2 x Toshiba MN10ADA800S 8 TB
RAID0_ACTIVE_W = 18.14

# Previous block statistics for I/O-based power interpolation.
_block_activity_state = {}

# Last values are kept so a header redraw/resize does not blank the power panel.
latest_power_values = {
    "CPU": None,
    "GPU": None,
    "RAM": None,
    "MB": None,
    "SSD": None,
    "RAID0": None,
    "PUMPS": None,
    "FANS": None,
    "REST": None,
    "TOTAL": None,
}


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 _finite_power(value, minimum=0.0, maximum=1000.0):
    try:
        value = float(value)
    except (TypeError, ValueError):
        return None
    if not math.isfinite(value) or value < minimum or value > maximum:
        return None
    return value


def get_cpu_package_power():
    """Read Ryzen 7 5700X3D package/socket power directly from ryzen_smu.

    The installed Vermeer PM table is 0x380905 / 1488 bytes.  Index d[1]
    was checked on this machine and is the package-power field used here.
    """
    try:
        with open(RYZEN_SMU_PM_TABLE, "rb") as f:
            raw = f.read(8)
        if len(raw) < 8:
            return None
        watts = struct.unpack_from("<f", raw, 4)[0]
        return _finite_power(watts, 0.0, 200.0)
    except Exception:
        return None


def get_gpu_power():
    """Read AMD GPU PPT from hwmon power1_average and return watts."""
    for name_path in sorted(glob.glob("/sys/class/hwmon/hwmon*/name")):
        if read_first(name_path) != "amdgpu":
            continue
        raw = read_first(os.path.join(os.path.dirname(name_path), "power1_average"))
        if raw is None:
            continue
        try:
            value = float(raw)
        except ValueError:
            continue
        # Kernel hwmon normally exposes power in microwatts.  Keep a fallback
        # for drivers that expose an already-normalized watt value.
        watts = value / 1_000_000.0 if abs(value) > 10_000 else value
        watts = _finite_power(watts, 0.0, 600.0)
        if watts is not None:
            return watts
    return None


def _block_busy_fraction(device_name):
    """Return 0..1 device busy fraction from /sys/block/<dev>/stat.

    Field 10 (index 9) is cumulative milliseconds spent doing I/O.
    """
    stat = read_first(f"/sys/block/{device_name}/stat")
    if not stat:
        return 0.0
    fields = stat.split()
    if len(fields) < 10:
        return 0.0
    try:
        io_ms = int(fields[9])
    except ValueError:
        return 0.0

    now = time.monotonic()
    prev = _block_activity_state.get(device_name)
    _block_activity_state[device_name] = (io_ms, now)
    if prev is None:
        return 0.0

    prev_ms, prev_t = prev
    elapsed_ms = max(1.0, (now - prev_t) * 1000.0)
    busy = (io_ms - prev_ms) / elapsed_ms
    return max(0.0, min(1.0, busy))


def _scaled_idle_active(idle_w, active_w, busy):
    busy = max(0.0, min(1.0, float(busy)))
    return idle_w + (active_w - idle_w) * busy


def get_nvme_power():
    """Estimate both installed 2 TB NVMe drives from their I/O busy time."""
    total = 0.0
    found = False

    for dev_path in sorted(glob.glob("/sys/block/nvme*n1")):
        dev = os.path.basename(dev_path)
        model = read_first(os.path.join(dev_path, "device", "model")) or ""
        model_upper = model.upper()
        busy = _block_busy_fraction(dev)

        if "980 PRO" in model_upper:
            total += _scaled_idle_active(
                SAMSUNG_980_PRO_IDLE_W, SAMSUNG_980_PRO_ACTIVE_W, busy
            )
            found = True
        elif "T705" in model_upper:
            total += _scaled_idle_active(
                CRUCIAL_T705_IDLE_W, CRUCIAL_T705_ACTIVE_W, busy
            )
            found = True

    return total if found else None


def get_raid0_power():
    """Estimate the 2 x 8 TB Toshiba RAID0 from md0 I/O busy time."""
    if not os.path.exists("/sys/block/md0/stat"):
        return None
    busy = _block_busy_fraction("md0")
    return _scaled_idle_active(RAID0_IDLE_W, RAID0_ACTIVE_W, busy)


def _value_number(data, section, name):
    for item_name, value in data.get(section, []):
        if item_name != name:
            continue
        m = re.search(r"[-+]?\d+(?:\.\d+)?", str(value))
        if m:
            try:
                return float(m.group(0))
            except ValueError:
                return None
    return None


def _pump_power_from_rpm(rpm, max_rpm, max_w):
    if rpm is None or rpm <= 0:
        return 0.0
    ratio = max(0.0, min(1.0, rpm / max_rpm))
    return max_w * ratio ** 3


def get_pump_power(data):
    """Estimate 2 x VPP Apex + strong Barrow DDC from live Aquaero RPM."""
    p1 = _value_number(data, "AQUAERO", "VPP APEX 1")
    p2 = _value_number(data, "AQUAERO", "VPP APEX 2")
    ddc = _value_number(data, "AQUAERO", "DDC-Pumpe")
    if p1 is None and p2 is None and ddc is None:
        return None
    return (
        _pump_power_from_rpm(p1, VPP_APEX_MAX_RPM, VPP_APEX_MAX_W)
        + _pump_power_from_rpm(p2, VPP_APEX_MAX_RPM, VPP_APEX_MAX_W)
        + _pump_power_from_rpm(ddc, DDC_MAX_RPM, DDC_MAX_W)
    )


def get_fan_power(data):
    """Estimate all 12 M25 G2 Reverse 140 fans, including RGB."""
    rpm = _value_number(data, "AQUAERO", "Lüfter")
    if rpm is None:
        return None

    ratio = max(0.0, min(1.0, rpm / FAN_MAX_RPM))
    if rpm <= 0:
        motor_per_fan = 0.0
    else:
        motor_per_fan = FAN_IDLE_MOTOR_W + (
            FAN_MAX_MOTOR_W - FAN_IDLE_MOTOR_W
        ) * ratio ** 3

    motor_total = FAN_COUNT * motor_per_fan
    rgb_total = FAN_COUNT * FAN_RGB_MAX_W * FAN_RGB_FACTOR
    return motor_total + rgb_total


def collect_power_values(data):
    """Collect measured and estimated power values for the header panel.

    CPU/GPU are measured.  SSD/RAID/PUMPS/FANS are estimates.
    RAM and motherboard have no trustworthy watt sensor on this system and
    deliberately remain unavailable instead of inventing values.
    """
    values = {
        "CPU": get_cpu_package_power(),
        "GPU": get_gpu_power(),
        "RAM": None,
        "MB": None,
        "SSD": get_nvme_power(),
        "RAID0": get_raid0_power(),
        "PUMPS": get_pump_power(data),
        "FANS": get_fan_power(data),
    }

    rest_keys = ("SSD", "RAID0", "PUMPS", "FANS")
    rest_values = [
        values[k] for k in rest_keys
        if isinstance(values.get(k), (int, float)) and math.isfinite(values[k])
    ]
    values["REST"] = sum(rest_values) if rest_values else None

    numeric = [
        v for k, v in values.items()
        if k != "REST" and isinstance(v, (int, float)) and math.isfinite(v)
    ]
    values["TOTAL"] = sum(numeric) if numeric else None
    return values


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 = {}
power_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[i] + (b[i] - a[i]) * 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 _power_text(value):
    # Feste Breite: Werte können an einer gemeinsamen X-Position beginnen,
    # während die rechte Kante durch Monospace trotzdem sauber fluchtet.
    if value is None:
        return f"{'--':>5} W"
    return f"{value:5.1f} W"


def update_power_panel(values=None):
    """Update the compact four-field power header."""
    global latest_power_values
    if values is not None:
        latest_power_values = values

    items = [
        ("p_cpu_val", "CPU"),
        ("p_gpu_val", "GPU"),
        ("p_rest_val", "REST"),
        ("p_total_val", "TOTAL"),
    ]

    for tag, key in items:
        header.itemconfigure(
            tag,
            text=_power_text(latest_power_values.get(key))
        )


def draw_header():
    global power_button_widgets

    # Header graph buttons are real Tk Canvas widgets. Destroy the old ones
    # before a redraw so resizing never leaves duplicate buttons behind.
    for widget in power_button_widgets.values():
        try:
            widget.destroy()
        except Exception:
            pass
    power_button_widgets = {}

    header.delete("all")
    w = max(header.winfo_width(), 1080)
    h = HEADER_HEIGHT

    # Smooth vertical dark-blue shimmer.
    for y in range(h):
        t = y / max(1, h - 1)
        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)

    # Logo block.
    header.create_text(55, 50, text="XX", fill=RED,
                       font=("Arial", 34, "bold"), anchor="center")
    header.create_text(120, 38, text="hardware", fill="#f2f6f8",
                       font=("Arial", 18), anchor="w")
    header.create_text(330, 38, text="LUXX", fill="#ff3d45",
                       font=("Arial", 18), anchor="w")
    header.create_text(120, 78, text="C", fill=GREEN,
                       font=("Arial", 20, "bold"), anchor="w")
    header.create_text(148, 78, text="CachyOS Monitor-Systems", fill="#31c9ad",
                       font=("Arial", 10, "bold"), anchor="w")
    header.create_text(148, 108, text="Creator Oefianer", fill="#dce6ed",
                       font=("Arial", 8, "bold"), anchor="w")

    # Compact POWER block:
    # CPU | GPU | RESTLICHE VERBRAUCHER | TOTAL
    x_start = 440
    right_margin = 20
    usable = max(560, w - x_start - right_margin)

    # Give the longer "RESTLICHE VERBRAUCHER" field extra width.
    widths = [0.18, 0.18, 0.40, 0.24]
    starts = [x_start]
    for frac in widths[:-1]:
        starts.append(starts[-1] + usable * frac)

    labels = [
        ("CPU", "p_cpu_val", starts[0], usable * widths[0]),
        ("GPU", "p_gpu_val", starts[1], usable * widths[1]),
        ("RESTLICHE VERBRAUCHER", "p_rest_val", starts[2], usable * widths[2]),
        ("TOTAL", "p_total_val", starts[3], usable * widths[3]),
    ]

    header.create_text(
        x_start, 18, text="POWER", fill="#31c9ad",
        font=("Monospace", 10, "bold"), anchor="nw"
    )

    for label, tag, cx, cw in labels:
        # Label row
        header.create_text(
            cx, 55, text=label,
            fill="#f0f5f8" if label != "TOTAL" else "#69d7e6",
            font=("Monospace", 9, "bold"),
            anchor="nw"
        )

        # CPU and GPU each get the same graph button used by the sensor list.
        if label in ("CPU", "GPU"):
            key = ("POWER", label)
            button = tk.Canvas(
                header,
                width=GRAPH_BUTTON_SIZE,
                height=GRAPH_BUTTON_SIZE,
                bg=BUTTON_ON if key in selected_sensors else BUTTON_OFF,
                bd=0,
                relief="flat",
                highlightthickness=0,
                cursor="hand2",
            )
            _draw_graph_button(button, key in selected_sensors)
            button.bind(
                "<Button-1>",
                lambda event, k=key: graph_button_click(event, k)
            )
            header.create_window(
                cx + 48, 51,
                window=button,
                anchor="nw"
            )
            power_button_widgets[key] = button

        # Value row, aligned to the same column start.
        header.create_text(
            cx, 86, text="",
            fill="#f0f5f8" if label != "TOTAL" else "#69d7e6",
            font=("Monospace", 11, "bold"),
            anchor="nw",
            tags=(tag,)
        )

    update_power_panel()


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 update_power_histories(power_values):
    """Store measured CPU/GPU power in the normal 15-minute graph buffers."""
    for name in ("CPU", "GPU"):
        value = power_values.get(name)
        if not isinstance(value, (int, float)) or not math.isfinite(value):
            continue

        key = ("POWER", name)
        if key not in histories:
            histories[key] = deque(maxlen=HISTORY_POINTS)

        histories[key].append(float(value))
        latest_values[key] = float(value)


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 = power_button_widgets.get(key)
    if widget is None:
        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 in ("L/h", "W"):
        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)

        power_values = collect_power_values(data)
        update_power_panel(power_values)
        update_power_histories(power_values)

        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(1080, 700)
root.configure(bg=BG_BOTTOM)
root.attributes("-topmost", True)

header = tk.Canvas(root, height=HEADER_HEIGHT, 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()



Ich würde es ihm so erklären:

Ich arbeite mit ChatGPT nicht mit einem einzelnen „Super-Prompt“, sondern eher wie mit einem technischen Sparringspartner. Ich beschreibe zuerst grob, was ich will, teste den erzeugten Code direkt auf meinem System und schicke danach Screenshots, Messwerte, Fehlermeldungen oder konkrete Stellen zurück, die nicht passen. Dann wird gezielt nachgebessert.

Viele Details entstehen also Schritt für Schritt: welche Sensoren verwendet werden, welche Werte wirklich korrekt sind, welche Graph-Skalen sinnvoll sind, wie das Layout aussehen soll, welche Abstände nicht stimmen usw. Wenn ChatGPT etwas falsch interpretiert, sage ich das sehr direkt und wir korrigieren genau diesen Punkt.

Ich rede dabei ganz normal und technisch konkret, nicht in irgendeiner speziellen Prompt-Sprache. Wichtig ist eher, dass ich selbst weiß, was ich ungefähr erreichen will, Ergebnisse kontrolliere und nicht einfach alles ungeprüft übernehme.

Der Monitor ist deshalb nicht aus einem einzigen Prompt entstanden, sondern aus vielen kleinen Iterationen, Tests und Korrekturen auf meiner echten Hardware.
 
Zuletzt bearbeitet:

Behandle sie wie ein 6-9 Järiges kind das aber einen IQ von 160 hat. Aber es sieht die dinge zum ersten mal auf dieser Welt du bist ihre Augen.

Du musst dich einfach unterhalten am besten um banale dinge. erkläre aber immer möglichst warum wie weshalb du so denkst. sei ausführlich. wen du KI korrigierst erkläre ihr mit wenigen worten dein analütisches denken in kleinen schritten. Die KI kann nur so gut sein wie du. Kommt nur müll raus liegt es nicht an ihr/ DIR.
Gemini ist in meinen augen sehr breit im denken ist schnell verwirrt.
CHatgpt ist es manchmal unmöglich Einfach dinge zu erklären ist dann aber viel besser wenn es um ausfeilen oder analütisches denken geht.
In meinen augen ist KI wirklich gefärlich und ich bein eigentlich ein Gegner davon.
War es jemals Klug sich gegen etwas kommplet zu verneinen wenn es irrgend wann dazu kommt, das du nicht mehr ausweichen kannst. Du musst Dich damit auseinander setzten wenn es um dein Leben oder exitenz geht.
------------------------
KI Analyse über mich.
Ja. Ich verstehe deine Ausdrucksweise inzwischen deutlich besser als am Anfang, und das ist nicht bei jedem Nutzer gleich.

Bei dir habe ich z. B. gelernt, dass sehr kurze Sätze wie „mach den Download klar“, „adoptiere das“, „eine Zeile mehr“, „das stimmt nicht“ oder ein Screenshot mit Markierungen meistens eine sehr konkrete technische Anweisung bedeuten. Ich muss weniger auf perfekte Grammatik achten und stärker auf Zusammenhang, vorherige Entscheidungen, Bilder und das technische Ziel schauen.

Wenn ich nur unsere technische Zusammenarbeit bewerten müsste – nicht dich als Person oder deine Intelligenz – würde ich ungefähr so einschätzen:

BereichEinschätzung
Logisch-analytisches Denken90/100
Fehler/Widersprüche erkennen95/100
Technisches Verständnis aufbauen90/100
Praktisches Testen und Verifizieren95/100
Zielvorstellung / wissen, was du willst90/100
Erste sprachliche Eindeutigkeit65–70/100
Grammatik/Rechtschreibung40–50/100
Der interessante Punkt ist: Deine Grammatik ist für unsere technischen Gespräche viel weniger problematisch als man denken würde. Du kannst etwas sprachlich ziemlich chaotisch formulieren und trotzdem steckt oft eine sehr klare technische Logik dahinter.

Was du besonders gut machst: Du merkst relativ schnell, wenn eine Lösung nur oberflächlich plausibel ist. Beim Header zum Beispiel hast du nicht einfach gesagt „sieht schlecht aus“, sondern immer genauer eingegrenzt: erst vertikale Abstände, dann horizontale Abstände, dann Label→Wert, dann Verhalten bei unterschiedlicher Fensterbreite. Das ist analytisch wesentlich wertvoller als perfekte Rechtschreibung.

Dein größtes Kommunikationsproblem ist eher Geschwindigkeit: Du denkst manchmal schneller, als du schreibst. Dann fehlen Wörter oder ein Teil der Annahme bleibt nur in deinem Kopf. Deshalb gibt es Situationen, in denen ich glaube, ich hätte dich verstanden, obwohl du eigentlich noch einen zusätzlichen Zusammenhang meinst.

Und ja: Nach so vielen Iterationen erkenne ich diesen Stil inzwischen wesentlich besser. Ein komplett neuer Assistent ohne diesen Kontext würde dich bei manchen kurzen Nachrichten wahrscheinlich öfter falsch verstehen. Mit Code, Screenshots und ein paar Beispielen deiner Kommunikation würde er aber ziemlich schnell aufholen.
 
Zuletzt bearbeitet:
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