#!/usr/bin/env python3 import sys import time from collections import deque from pathlib import Path from PyQt5.QtCore import QPointF, Qt, QTimer from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPen from PyQt5.QtWidgets import ( QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QMainWindow, QPushButton, QSlider, QVBoxLayout, QWidget, ) NET_GROUPS = { "wan": ["eth0"], "lan": ["br0", "eth1"], "wifi24": ["wlan0"], "wifi5": ["wlan1"], } def read_text(path, default=""): try: return Path(path).read_text(encoding="utf-8").strip() except OSError: return default def read_int(path, default=0): raw = read_text(path) return int(raw) if raw.isdigit() else default def cpu_temp(): raw = read_int("/sys/class/thermal/thermal_zone0/temp") return round(raw / 1000, 1) if raw > 0 else 0.0 def fan_paths(): for pwm in Path("/sys/devices/platform/cooling_fan/hwmon").glob("hwmon*/pwm1"): base = pwm.parent return { "pwm": str(pwm), "enable": str(base / "pwm1_enable"), "rpm": str(base / "fan1_input"), } for pwm in Path("/sys/class/hwmon").glob("hwmon*/pwm1"): base = pwm.parent return { "pwm": str(pwm), "enable": str(base / "pwm1_enable"), "rpm": str(base / "fan1_input"), } return {"pwm": "", "enable": "", "rpm": ""} def default_iface(): for line in read_text("/proc/net/route").splitlines()[1:]: parts = line.split() if len(parts) >= 2 and parts[1] == "00000000": return parts[0] return "eth0" def iface_stats(name): base = Path("/sys/class/net") / name stats = base / "statistics" return { "name": name, "state": read_text(base / "operstate", "unknown"), "carrier": read_text(base / "carrier", "0") == "1", "rx_bytes": read_int(stats / "rx_bytes"), "tx_bytes": read_int(stats / "tx_bytes"), "rx_packets": read_int(stats / "rx_packets"), "tx_packets": read_int(stats / "tx_packets"), "rx_errors": read_int(stats / "rx_errors"), "tx_errors": read_int(stats / "tx_errors"), "rx_dropped": read_int(stats / "rx_dropped"), "tx_dropped": read_int(stats / "tx_dropped"), } def group_stats(names): rows = [iface_stats(name) for name in names if (Path("/sys/class/net") / name).exists()] if not rows: return iface_stats(names[0]) return { "name": "+".join(row["name"] for row in rows), "state": "up" if any(row["state"] == "up" for row in rows) else rows[0]["state"], "carrier": any(row["carrier"] for row in rows), "rx_bytes": sum(row["rx_bytes"] for row in rows), "tx_bytes": sum(row["tx_bytes"] for row in rows), "rx_packets": sum(row["rx_packets"] for row in rows), "tx_packets": sum(row["tx_packets"] for row in rows), "rx_errors": sum(row["rx_errors"] for row in rows), "tx_errors": sum(row["tx_errors"] for row in rows), "rx_dropped": sum(row["rx_dropped"] for row in rows), "tx_dropped": sum(row["tx_dropped"] for row in rows), } def disk_io(): reads = 0 writes = 0 for line in read_text("/proc/diskstats").splitlines(): parts = line.split() if len(parts) < 14: continue name = parts[2] if name.startswith(("loop", "ram", "zram", "dm-")): continue reads += int(parts[3]) writes += int(parts[7]) return reads, writes def write_sys(path, value): if not path: return False try: Path(path).write_text(str(value) + "\n", encoding="utf-8") return True except OSError: return False class StatusDot(QWidget): def __init__(self): super().__init__() self.warning = False self.pulse = 0 self.phase = False self.setFixedSize(22, 22) def set_warning(self, warning=False): self.warning = warning self.update() def blink(self): self.pulse = 3 self.phase = not self.phase self.update() def tick(self): if self.pulse > 0: self.pulse -= 1 self.update() def paintEvent(self, event): del event painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) base = QColor("#8fa0aa") if self.warning: base = QColor("#f59e0b") if self.pulse > 0: base = QColor("#86efac") if self.phase else QColor("#16a34a") painter.setBrush(base) painter.setPen(Qt.NoPen) painter.drawEllipse(3, 3, 16, 16) painter.setBrush(QColor(255, 255, 255, 55)) painter.drawEllipse(8, 6, 5, 5) class StatusTile(QFrame): def __init__(self, title): super().__init__() self.dot = StatusDot() self.title = QLabel(title) self.title.setObjectName("statusTitle") self.detail = QLabel("-") self.detail.setObjectName("statusDetail") self.detail.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.setFixedHeight(58) layout = QHBoxLayout(self) layout.setContentsMargins(7, 6, 7, 6) layout.setSpacing(6) layout.addWidget(self.dot, 0, Qt.AlignLeft | Qt.AlignVCenter) texts = QVBoxLayout() texts.setContentsMargins(0, 0, 0, 0) texts.setSpacing(1) texts.addWidget(self.title) texts.addWidget(self.detail) layout.addLayout(texts, 1) self.setObjectName("statusTile") def set_state(self, detail, warning=False, activity=False): self.dot.set_warning(warning) if activity: self.dot.blink() self.detail.setText(detail) def tick(self): self.dot.tick() class MetricCard(QFrame): def __init__(self, title): super().__init__() self.setObjectName("metricCard") self.title = QLabel(title) self.title.setObjectName("metricTitle") self.value = QLabel("-") self.value.setObjectName("metricValue") layout = QVBoxLayout(self) layout.setContentsMargins(10, 8, 10, 8) layout.setSpacing(2) layout.addWidget(self.title) layout.addWidget(self.value) def set_value(self, text): self.value.setText(text) class Sparkline(QFrame): def __init__(self, title, color="#38bdf8", second_color=None): super().__init__() self.setObjectName("chartCard") self.color = QColor(color) self.second_color = QColor(second_color) if second_color else None self.values = [] self.second_values = [] self.title = QLabel(title) self.title.setObjectName("chartTitle") self.caption = QLabel("-") self.caption.setObjectName("chartCaption") self.canvas = QWidget() self.canvas.paintEvent = self.paint_chart self.canvas.setMinimumHeight(58) layout = QVBoxLayout(self) layout.setContentsMargins(10, 8, 10, 8) layout.setSpacing(4) top = QHBoxLayout() top.addWidget(self.title) top.addStretch(1) top.addWidget(self.caption) layout.addLayout(top) layout.addWidget(self.canvas, 1) def set_values(self, values, caption, second_values=None): self.values = list(values)[-80:] self.second_values = list(second_values or [])[-80:] self.caption.setText(caption) self.canvas.update() def paint_chart(self, event): del event painter = QPainter(self.canvas) painter.setRenderHint(QPainter.Antialiasing) rect = self.canvas.rect().adjusted(2, 4, -2, -4) painter.fillRect(rect, QColor("#0f172a")) painter.setPen(QPen(QColor("#263650"), 1)) painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom()) all_values = self.values + self.second_values if len(all_values) < 2: return low = min(all_values) high = max(all_values) if high <= low: high = low + 1 self.draw_series(painter, rect, self.values, low, high, self.color) if self.second_color and self.second_values: self.draw_series(painter, rect, self.second_values, low, high, self.second_color) @staticmethod def draw_series(painter, rect, values, low, high, color): path = QPainterPath() for index, value in enumerate(values): x = rect.left() + rect.width() * index / max(1, len(values) - 1) y = rect.bottom() - rect.height() * ((float(value) - low) / (high - low)) point = QPointF(x, y) path.moveTo(point) if index == 0 else path.lineTo(point) painter.setPen(QPen(color, 2)) painter.drawPath(path) class TouchPanel(QMainWindow): def __init__(self): super().__init__() self.rows = {} self.metrics = {} self.charts = {} self.mode_buttons = {} self.fan = fan_paths() self.wan_iface = default_iface() NET_GROUPS["wan"] = [self.wan_iface] self.last_net = {} self.last_io = None self.last_sample = None self.current_pwm = read_int(self.fan["pwm"]) self.temp_history = deque(maxlen=80) self.rpm_history = deque(maxlen=80) self.net_history = {key: {"rx": deque(maxlen=80), "tx": deque(maxlen=80)} for key in ["wan", "lan", "wifi"]} self.setWindowTitle("Standalone Touch Dashboard") self.setCursor(Qt.BlankCursor) self.setMinimumSize(800, 480) self._build_ui() self.sample_timer = QTimer(self) self.sample_timer.timeout.connect(self.sample) self.sample_timer.start(120) self.pulse_timer = QTimer(self) self.pulse_timer.timeout.connect(self.tick_pulses) self.pulse_timer.start(70) self.sample() def _build_ui(self): root = QWidget() layout = QHBoxLayout(root) layout.setContentsMargins(4, 8, 10, 8) layout.setSpacing(8) left = QFrame() left.setObjectName("statusPanel") left.setFixedWidth(132) left_layout = QVBoxLayout(left) left_layout.setContentsMargins(6, 8, 6, 8) left_layout.setSpacing(6) title = QLabel("상태") title.setObjectName("sectionTitle") left_layout.addWidget(title) for key, label in [("io", "IO"), ("wan", "WAN"), ("lan", "LAN"), ("wifi24", "2.4G"), ("wifi5", "5G")]: tile = StatusTile(label) self.rows[key] = tile left_layout.addWidget(tile) left_layout.addStretch(1) right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(8) header = QHBoxLayout() heading = QLabel("Dashboard") heading.setObjectName("sectionTitle") self.clock = QLabel("--:--:--") self.clock.setObjectName("clock") self.status_text = QLabel("") self.status_text.setObjectName("statusText") header.addWidget(heading) header.addStretch(1) header.addWidget(self.status_text) header.addWidget(self.clock) right_layout.addLayout(header) metric_grid = QGridLayout() metric_grid.setHorizontalSpacing(8) metric_grid.setVerticalSpacing(8) for index, (key, title) in enumerate([("rpm", "팬 RPM"), ("temp", "CPU 온도"), ("pwm", "PWM"), ("wan", "WAN")]): card = MetricCard(title) self.metrics[key] = card metric_grid.addWidget(card, 0, index) right_layout.addLayout(metric_grid) mode_layout = QHBoxLayout() for mode, label in [("auto", "auto"), ("manual", "manual"), ("off", "off")]: btn = QPushButton(label) btn.setCheckable(True) btn.clicked.connect(lambda checked=False, m=mode: self.set_fan(m)) self.mode_buttons[mode] = btn mode_layout.addWidget(btn) right_layout.addLayout(mode_layout) slider_wrap = QHBoxLayout() minus = QPushButton("-") minus.setObjectName("stepButton") minus.clicked.connect(lambda: self.step_pwm(-10)) plus = QPushButton("+") plus.setObjectName("stepButton") plus.clicked.connect(lambda: self.step_pwm(10)) self.slider = QSlider(Qt.Horizontal) self.slider.setRange(0, 255) self.slider.setValue(self.current_pwm) self.slider.valueChanged.connect(self.slider_changed) self.slider.sliderReleased.connect(lambda: self.set_fan("manual")) slider_wrap.addWidget(minus) slider_wrap.addWidget(self.slider, 1) slider_wrap.addWidget(plus) right_layout.addLayout(slider_wrap) chart_grid = QGridLayout() chart_grid.setHorizontalSpacing(8) chart_grid.setVerticalSpacing(8) for index, (key, chart) in enumerate([ ("temp", Sparkline("온도 / RPM", "#f97316", "#38bdf8")), ("wan", Sparkline("WAN 트래픽", "#38bdf8", "#22c55e")), ("lan", Sparkline("LAN 트래픽", "#38bdf8", "#22c55e")), ("wifi", Sparkline("WiFi 트래픽", "#38bdf8", "#22c55e")), ]): self.charts[key] = chart chart_grid.addWidget(chart, index // 2, index % 2) right_layout.addLayout(chart_grid, 1) layout.addWidget(left) layout.addWidget(right, 1) self.setCentralWidget(root) def sample(self): now = time.monotonic() dt = max(0.001, now - self.last_sample) if self.last_sample else 0.001 self.last_sample = now temp = cpu_temp() rpm = read_int(self.fan["rpm"]) pwm = read_int(self.fan["pwm"]) enable = read_int(self.fan["enable"], 2) percent = pwm / 255 * 100 if pwm > 0 else 0 reads, writes = disk_io() io_activity = self.last_io is not None and (reads, writes) != self.last_io self.last_io = (reads, writes) groups = {key: group_stats(names) for key, names in NET_GROUPS.items()} rates = {} activity = {} for key, row in groups.items(): previous = self.last_net.get(key) current = (row["rx_bytes"], row["tx_bytes"], row["rx_packets"], row["tx_packets"]) activity[key] = previous is not None and current != previous if previous: rates[key] = { "rx": max(0.0, (current[0] - previous[0]) * 8 / dt / 1_000_000), "tx": max(0.0, (current[1] - previous[1]) * 8 / dt / 1_000_000), } else: rates[key] = {"rx": 0.0, "tx": 0.0} self.last_net[key] = current self.render(temp, rpm, pwm, percent, enable, groups, rates, activity, io_activity) def render(self, temp, rpm, pwm, percent, enable, groups, rates, activity, io_activity): self.rows["io"].set_state(f"{temp:.1f} C", temp >= 75, io_activity) self.rows["wan"].set_state(self.iface_detail(groups["wan"]), self.iface_warn(groups["wan"]), activity["wan"]) self.rows["lan"].set_state(self.iface_detail(groups["lan"]), self.iface_warn(groups["lan"]), activity["lan"]) self.rows["wifi24"].set_state(self.iface_detail(groups["wifi24"]), self.iface_warn(groups["wifi24"]), activity["wifi24"]) self.rows["wifi5"].set_state(self.iface_detail(groups["wifi5"]), self.iface_warn(groups["wifi5"]), activity["wifi5"]) self.metrics["rpm"].set_value(f"{rpm:,}") self.metrics["temp"].set_value(f"{temp:.1f} C") self.metrics["pwm"].set_value(f"{pwm} / {percent:.1f}%") self.metrics["wan"].set_value(f"{rates['wan']['rx']:.2f}/{rates['wan']['tx']:.2f} Mbps") self.slider.blockSignals(True) self.slider.setValue(pwm) self.slider.blockSignals(False) self.mode_buttons["off"].setChecked(enable == 0 or pwm == 0) self.mode_buttons["manual"].setChecked(enable == 1 and pwm > 0) self.mode_buttons["auto"].setChecked(enable not in [0, 1]) self.clock.setText(time.strftime("%H:%M:%S")) self.temp_history.append(temp) self.rpm_history.append(rpm / 100) self.charts["temp"].set_values(self.temp_history, "C / RPMx100", self.rpm_history) for key, chart_key in [("wan", "wan"), ("lan", "lan")]: self.net_history[key]["rx"].append(rates[key]["rx"]) self.net_history[key]["tx"].append(rates[key]["tx"]) self.charts[chart_key].set_values( self.net_history[key]["rx"], f"{rates[key]['rx']:.2f}/{rates[key]['tx']:.2f} Mbps", self.net_history[key]["tx"], ) wifi_rx = rates["wifi24"]["rx"] + rates["wifi5"]["rx"] wifi_tx = rates["wifi24"]["tx"] + rates["wifi5"]["tx"] self.net_history["wifi"]["rx"].append(wifi_rx) self.net_history["wifi"]["tx"].append(wifi_tx) self.charts["wifi"].set_values(self.net_history["wifi"]["rx"], f"{wifi_rx:.2f}/{wifi_tx:.2f} Mbps", self.net_history["wifi"]["tx"]) def set_fan(self, mode): pwm = self.slider.value() ok = True if mode == "off": ok = write_sys(self.fan["enable"], 0) and write_sys(self.fan["pwm"], 0) elif mode == "manual": ok = write_sys(self.fan["enable"], 1) and write_sys(self.fan["pwm"], pwm) elif mode == "auto": ok = write_sys(self.fan["enable"], 2) self.status_text.setText("" if ok else "팬 쓰기 권한 없음") def step_pwm(self, delta): self.slider.setValue(max(0, min(255, self.slider.value() + delta))) self.set_fan("manual") def slider_changed(self, value): self.current_pwm = value self.metrics["pwm"].set_value(f"{value} / {value / 255 * 100:.1f}%") def tick_pulses(self): for row in self.rows.values(): row.tick() @staticmethod def iface_detail(row): if not row["carrier"]: return "down" drops = row["rx_dropped"] + row["tx_dropped"] return f"up D{drops}" @staticmethod def iface_warn(row): return row["rx_errors"] > 0 or row["tx_errors"] > 0 def main(): app = QApplication(sys.argv) app.setOverrideCursor(Qt.BlankCursor) app.setStyleSheet( """ QWidget { background: #0b1220; color: #e5edf7; font-family: "Noto Sans CJK KR", "Noto Sans CJK KR Regular", "Noto Sans KR", sans-serif; } #statusPanel { background: #101827; border-right: 1px solid #263650; } #sectionTitle { font-size: 20px; font-weight: 700; } #statusTile { background: #d7d9dc; border: 2px groove #f4f4f4; border-radius: 2px; } #statusTitle { font-size: 13px; font-weight: 700; color: #111827; background: transparent; } #statusDetail { font-size: 10px; color: #475569; background: transparent; } #metricCard, #chartCard { background: #111a2e; border: 1px solid #27364f; border-radius: 12px; } #metricTitle { color: #8fa7c5; font-size: 13px; background: transparent; } #metricValue { color: #f8fafc; font-size: 24px; font-weight: 800; background: transparent; } #chartTitle { color: #dce8f8; font-size: 14px; font-weight: 700; background: transparent; } #chartCaption { color: #93a4bd; font-size: 11px; background: transparent; } #clock { font-size: 18px; color: #8fb3ff; font-weight: 700; } #statusText { color: #93a4bd; font-size: 13px; background: transparent; } QPushButton { min-height: 42px; border: 1px solid #33445f; border-radius: 14px; background: #1a2942; color: #dce8f8; font-size: 18px; font-weight: 700; } QPushButton:checked { background: #16a34a; color: white; border-color: #22c55e; } QPushButton#stepButton { min-width: 56px; font-size: 28px; } QSlider::groove:horizontal { height: 10px; background: #263650; border-radius: 5px; } QSlider::sub-page:horizontal { background: #22c55e; border-radius: 5px; } QSlider::handle:horizontal { width: 28px; margin: -10px 0; border-radius: 14px; background: #f8fafc; } """ ) win = TouchPanel() win.showFullScreen() sys.exit(app.exec_()) if __name__ == "__main__": main()