#!/usr/bin/env python3 import sys import time import socket from collections import deque from pathlib import Path from PyQt5.QtCore import QPointF, QProcess, Qt, QTimer from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPen from PyQt5.QtWidgets import ( QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QMainWindow, QSlider, QVBoxLayout, QWidget, ) NET_GROUPS = { "wan": ["eth0"], "lan": ["br0", "eth1"], "wifi24": ["wlan0"], "wifi5": ["wlan1"], } PC_IP = "192.168.50.73" PC_MAC = "74:56:3C:CD:CD:79" 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 found = False 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 found = True reads += int(parts[3]) writes += int(parts[7]) return reads, writes, not found def send_magic_packet(mac): clean = mac.replace(":", "").replace("-", "") if len(clean) != 12: return False packet = bytes.fromhex("ff" * 6 + clean * 16) try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) for target in ["192.168.50.255", "255.255.255.255"]: sock.sendto(packet, (target, 9)) return True except OSError: return False class StatusDot(QWidget): def __init__(self, active_color): super().__init__() self.active_color = QColor(active_color) self.steady = False self.pulse = 0 self.setFixedSize(15, 15) def set_steady(self, steady=False): self.steady = steady self.update() def blink(self): self.pulse = 1 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.steady or self.pulse > 0: base = self.active_color painter.setBrush(base) painter.setPen(Qt.NoPen) painter.drawEllipse(2, 2, 11, 11) painter.setBrush(QColor(255, 255, 255, 55)) painter.drawEllipse(6, 4, 4, 4) class StatusTile(QFrame): def __init__(self, title): super().__init__() self.read_dot = StatusDot("#16a34a") self.write_dot = StatusDot("#f97316") self.error_dot = StatusDot("#dc2626") 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 = QVBoxLayout(self) layout.setContentsMargins(6, 5, 6, 5) layout.setSpacing(2) texts = QVBoxLayout() texts.setContentsMargins(0, 0, 0, 0) texts.setSpacing(0) texts.addWidget(self.title) texts.addWidget(self.detail) layout.addLayout(texts) dots = QHBoxLayout() dots.setContentsMargins(0, 0, 0, 0) dots.setSpacing(5) dots.addWidget(self.read_dot, 0, Qt.AlignLeft) dots.addWidget(self.write_dot, 0, Qt.AlignLeft) dots.addWidget(self.error_dot, 0, Qt.AlignLeft) dots.addStretch(1) layout.addLayout(dots) self.setObjectName("statusTile") def set_state(self, detail, error=False, read_activity=False, write_activity=False): self.error_dot.set_steady(error) self.read_dot.set_steady(read_activity) self.write_dot.set_steady(write_activity) self.detail.setText(detail) def tick(self): self.read_dot.tick() self.write_dot.tick() self.error_dot.tick() class PcWakeButton(StatusTile): def __init__(self): super().__init__("PC") self.online = False self.last_wake_at = 0.0 self.wait_until = 0.0 self.error_until = 0.0 self.blink_timer = QTimer(self) self.blink_timer.timeout.connect(self.tick_waiting) self.blink_timer.start(250) def set_online(self, online): self.online = online self.read_dot.set_steady(online) now = time.monotonic() self.error_dot.set_steady(now < self.error_until) if online: self.wait_until = 0.0 self.detail.setText("켜짐") elif now < self.wait_until: remaining = int(self.wait_until - now) self.detail.setText(f"대기 {remaining}s") else: self.detail.setText("꺼짐") def tick_waiting(self): now = time.monotonic() if not self.online and now < self.wait_until: self.write_dot.blink() if not self.online and self.wait_until > 0 and now >= self.wait_until: self.wait_until = 0.0 self.error_until = now + 60 self.error_dot.set_steady(True) self.detail.setText("실패") if self.error_until > 0 and now >= self.error_until: self.error_until = 0.0 self.error_dot.set_steady(False) def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.write_dot.blink() now = time.monotonic() if not self.online and now >= self.wait_until: self.last_wake_at = now self.wait_until = now + 60 self.error_until = 0.0 self.error_dot.set_steady(False) self.detail.setText("깨우는 중") if not send_magic_packet(PC_MAC): self.wait_until = 0.0 self.error_until = now + 5 self.error_dot.set_steady(True) super().mousePressEvent(event) 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, y_unit="", right_unit=""): super().__init__() self.setObjectName("chartCard") self.color = QColor(color) self.second_color = QColor(second_color) if second_color else None self.y_unit = y_unit self.right_unit = right_unit 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) right_margin = 34 if self.second_values and self.right_unit else 2 rect = self.canvas.rect().adjusted(34, 4, -right_margin, -13) painter.fillRect(rect, QColor("#0f172a")) painter.setPen(QPen(QColor("#263650"), 1)) painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom()) if len(self.values) < 2: return low = min(self.values) high = max(self.values) if high <= low: high = low + 1 painter.setPen(QColor("#93a4bd")) painter.drawText(1, rect.top() + 10, self.axis_text(high)) painter.drawText(1, rect.bottom(), self.axis_text(low)) self.draw_series(painter, rect, self.values, low, high, self.color) if self.second_color and self.second_values: second_low = min(self.second_values) second_high = max(self.second_values) if second_high <= second_low: second_high = second_low + 1 if self.right_unit: painter.setPen(QColor("#93a4bd")) right_x = self.canvas.rect().right() - 31 painter.drawText(right_x, rect.top() + 10, self.axis_text(second_high, self.right_unit)) painter.drawText(right_x, rect.bottom(), self.axis_text(second_low, self.right_unit)) self.draw_series(painter, rect, self.second_values, second_low, second_high, self.second_color) def axis_text(self, value, unit=None): if abs(value) >= 100: text = f"{value:.0f}" elif abs(value) >= 10: text = f"{value:.1f}" else: text = f"{value:.2f}" return f"{text}{self.y_unit if unit is None else unit}" @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.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.last_graph = None self.current_rates = {"wan": {"rx": 0.0, "tx": 0.0}} self.disk_temp = None self.current_pwm = read_int(self.fan["pwm"]) self.temp_history = deque(maxlen=80) self.rpm_history = deque(maxlen=80) self.net_history = {"wan": {"rx": deque(maxlen=80), "tx": deque(maxlen=80)}} 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(50) self.graph_timer = QTimer(self) self.graph_timer.timeout.connect(self.update_graphs) self.graph_timer.start(500) self.pc_ping = QProcess(self) self.pc_ping.finished.connect(self.pc_ping_finished) self.pc_timer = QTimer(self) self.pc_timer.timeout.connect(self.ping_pc) self.pc_timer.start(1000) self.disk_temp_proc = QProcess(self) self.disk_temp_proc.finished.connect(self.disk_temp_finished) self.disk_temp_timer = QTimer(self) self.disk_temp_timer.timeout.connect(self.update_disk_temp) self.disk_temp_timer.start(1000) self.pulse_timer = QTimer(self) self.pulse_timer.timeout.connect(self.tick_pulses) self.pulse_timer.start(25) self.sample() self.ping_pc() self.update_disk_temp() 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", "DISK I/O"), ("wan", "WAN"), ("lan", "LAN"), ("wifi24", "2.4G"), ("wifi5", "5G")]: tile = StatusTile(label) self.rows[key] = tile left_layout.addWidget(tile) left_layout.addStretch(1) self.pc_button = PcWakeButton() left_layout.addWidget(self.pc_button) 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) slider_wrap = QHBoxLayout() self.slider = QSlider(Qt.Horizontal) self.slider.setRange(0, 255) self.slider.setValue(self.current_pwm) self.slider.setFocusPolicy(Qt.NoFocus) self.slider.setAttribute(Qt.WA_TransparentForMouseEvents, True) slider_wrap.addWidget(self.slider, 1) 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", "C", "rpm")), ("wan", Sparkline("WAN 트래픽", "#38bdf8", "#22c55e", "M")), ]): self.charts[key] = chart chart_grid.addWidget(chart, index, 0) 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_error = disk_io() io_read_activity = self.last_io is not None and reads != self.last_io[0] io_write_activity = self.last_io is not None and writes != self.last_io[1] 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"], row["rx_errors"], row["tx_errors"], row["rx_dropped"], row["tx_dropped"], ) activity[key] = { "read": previous is not None and (current[1] > previous[1] or current[3] > previous[3]), "write": previous is not None and (current[0] > previous[0] or current[2] > previous[2]), "error": previous is not None and any(current[index] > previous[index] for index in [4, 5, 6, 7]), } 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.current_rates = rates self.render(temp, rpm, pwm, percent, enable, groups, rates, activity, io_read_activity, io_write_activity, disk_error) def render(self, temp, rpm, pwm, percent, enable, groups, rates, activity, io_read_activity, io_write_activity, disk_error): disk_temp_text = f"{self.disk_temp:.1f} C" if self.disk_temp is not None else "- C" self.rows["io"].set_state(disk_temp_text, disk_error, io_read_activity, io_write_activity) self.rows["wan"].set_state(self.iface_detail(groups["wan"]), activity["wan"]["error"], activity["wan"]["read"], activity["wan"]["write"]) self.rows["lan"].set_state(self.iface_detail(groups["lan"]), activity["lan"]["error"], activity["lan"]["read"], activity["lan"]["write"]) self.rows["wifi24"].set_state(self.iface_detail(groups["wifi24"]), activity["wifi24"]["error"], activity["wifi24"]["read"], activity["wifi24"]["write"]) self.rows["wifi5"].set_state(self.iface_detail(groups["wifi5"]), activity["wifi5"]["error"], activity["wifi5"]["read"], activity["wifi5"]["write"]) 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) del enable self.clock.setText(time.strftime("%H:%M:%S")) self.latest_temp = temp self.latest_rpm = rpm def ping_pc(self): if self.pc_ping.state() != QProcess.NotRunning: return self.pc_ping.start("/usr/bin/ping", ["-c", "1", "-W", "1", PC_IP]) def pc_ping_finished(self, exit_code, exit_status): del exit_status self.pc_button.set_online(exit_code == 0) def update_disk_temp(self): if self.disk_temp_proc.state() != QProcess.NotRunning: return self.disk_temp_proc.start("/usr/bin/sudo", ["-n", "/usr/sbin/smartctl", "-A", "-d", "sntasmedia", "/dev/sda"]) def disk_temp_finished(self, exit_code, exit_status): del exit_code, exit_status output = bytes(self.disk_temp_proc.readAllStandardOutput()).decode("utf-8", "ignore") for line in output.splitlines(): if line.strip().startswith("Temperature:"): parts = line.split() for part in parts: if part.isdigit(): self.disk_temp = float(part) return def update_graphs(self): temp = getattr(self, "latest_temp", cpu_temp()) rpm = getattr(self, "latest_rpm", read_int(self.fan["rpm"])) self.temp_history.append(temp) self.rpm_history.append(rpm) self.charts["temp"].set_values(self.temp_history, "C / RPM", self.rpm_history) wan = self.current_rates.get("wan", {"rx": 0.0, "tx": 0.0}) self.net_history["wan"]["rx"].append(wan["rx"]) self.net_history["wan"]["tx"].append(wan["tx"]) self.charts["wan"].set_values( self.net_history["wan"]["rx"], f"{wan['rx']:.2f}/{wan['tx']:.2f} Mbps", self.net_history["wan"]["tx"], ) def tick_pulses(self): for row in self.rows.values(): row.tick() @staticmethod def iface_detail(row): if not row["carrier"]: return "꺼짐" drops = row["rx_dropped"] + row["tx_dropped"] return "정상" if drops <= 0 else f"드롭 {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; } #pcWake { background: transparent; border: 0; } #pcLabel { color: #dce8f8; font-size: 13px; font-weight: 700; 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; } 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()