#!/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()