From 209b587eace387b78a8b2622a55dc20836e1a730 Mon Sep 17 00:00:00 2001 From: seo Date: Thu, 30 Jul 2026 04:47:08 +0900 Subject: [PATCH] =?UTF-8?q?=ED=84=B0=EC=B9=98=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=EC=99=80=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=90=EB=A9=B8=20=EA=B8=B0=EC=A4=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- touch_panel/control_touch_bridge.php | 48 ++++++ touch_panel/panel.py | 227 +++++++++++++++++++++++---- 2 files changed, 248 insertions(+), 27 deletions(-) diff --git a/touch_panel/control_touch_bridge.php b/touch_panel/control_touch_bridge.php index d3cfe3b..2ff3025 100644 --- a/touch_panel/control_touch_bridge.php +++ b/touch_panel/control_touch_bridge.php @@ -4,6 +4,29 @@ declare(strict_types=1); define('CONTROL_API_LIBRARY', true); require_once __DIR__ . '/../public/api.php'; +function touch_panel_io_counters(): array +{ + $reads = 0; + $writes = 0; + foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) { + $parts = preg_split('/\s+/', trim($line)); + if (count($parts) < 14) { + continue; + } + $name = (string)$parts[2]; + if (preg_match('/^(loop|ram|zram|dm-)/', $name)) { + continue; + } + $reads += (int)$parts[3]; + $writes += (int)$parts[7]; + } + + return [ + 'reads' => $reads, + 'writes' => $writes, + ]; +} + function touch_panel_snapshot(array $snapshot): array { $network = is_array($snapshot['network'] ?? null) ? $snapshot['network'] : []; @@ -25,6 +48,10 @@ function touch_panel_snapshot(array $snapshot): array 'tx_errors' => $row['tx_errors'] ?? 0, 'rx_dropped' => $row['rx_dropped'] ?? 0, 'tx_dropped' => $row['tx_dropped'] ?? 0, + 'rx_bytes' => $row['rx_bytes'] ?? 0, + 'tx_bytes' => $row['tx_bytes'] ?? 0, + 'rx_packets' => $row['rx_packets'] ?? 0, + 'tx_packets' => $row['tx_packets'] ?? 0, ]; } @@ -63,6 +90,27 @@ function touch_panel_snapshot(array $snapshot): array 'touch_display' => [ 'connected' => $touch['connected'] ?? false, ], + 'io' => touch_panel_io_counters(), + 'history' => array_map(static function (array $row): array { + return [ + 'time' => $row['time'] ?? null, + 'temp_c' => $row['temp_c'] ?? null, + 'fan_rpm' => $row['fan_rpm'] ?? null, + 'fan_pwm' => $row['fan_pwm'] ?? null, + 'cpu_load_1' => $row['cpu_load_1'] ?? null, + 'mem_percent' => $row['mem_percent'] ?? null, + ]; + }, array_slice(array_values(array_filter($snapshot['history'] ?? [], 'is_array')), -80)), + 'network_history' => array_map(static function (array $row): array { + return [ + 'time' => $row['time'] ?? null, + 'iface' => $row['iface'] ?? null, + 'rx_mbps' => $row['rx_mbps'] ?? 0, + 'tx_mbps' => $row['tx_mbps'] ?? 0, + 'rx_pps' => $row['rx_pps'] ?? 0, + 'tx_pps' => $row['tx_pps'] ?? 0, + ]; + }, array_slice(array_values(array_filter($snapshot['network_history'] ?? [], 'is_array')), -240)), ]; } diff --git a/touch_panel/panel.py b/touch_panel/panel.py index 870399d..061b119 100755 --- a/touch_panel/panel.py +++ b/touch_panel/panel.py @@ -4,11 +4,12 @@ import subprocess import sys from pathlib import Path -from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtGui import QColor, QFont, QPainter +from PyQt5.QtCore import QPointF, Qt, QTimer +from PyQt5.QtGui import QColor, QFont, QPainter, QPainterPath, QPen from PyQt5.QtWidgets import ( QApplication, QFrame, + QGridLayout, QHBoxLayout, QLabel, QMainWindow, @@ -118,12 +119,105 @@ class FanButton(QPushButton): self.setMinimumHeight(44) +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 = [float(v or 0) for v in values][-80:] + self.second_values = [float(v or 0) for v in (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, 2) + if self.second_color and self.second_values: + self.draw_series(painter, rect, self.second_values, low, high, self.second_color, 2) + + @staticmethod + def draw_series(painter, rect, values, low, high, color, width): + if len(values) < 2: + return + path = QPainterPath() + for index, value in enumerate(values): + x = rect.left() + (rect.width() * index / max(1, len(values) - 1)) + ratio = (float(value) - low) / (high - low) + y = rect.bottom() - (rect.height() * ratio) + point = QPointF(x, y) + if index == 0: + path.moveTo(point) + else: + path.lineTo(point) + painter.setPen(QPen(color, width)) + painter.drawPath(path) + + class TouchPanel(QMainWindow): def __init__(self): super().__init__() self.snapshot = {} self.rows = {} self.mode_buttons = {} + self.metrics = {} + self.charts = {} + self.last_counters = {} self.current_mode = "auto" self.current_pwm = 120 self.in_flight = False @@ -170,14 +264,13 @@ class TouchPanel(QMainWindow): left_layout.addWidget(row) left_layout.addStretch(1) - right = QFrame() - right.setObjectName("panel") + right = QWidget() right_layout = QVBoxLayout(right) - right_layout.setContentsMargins(16, 14, 16, 14) - right_layout.setSpacing(12) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(8) header = QHBoxLayout() - fan_title = QLabel("팬") + fan_title = QLabel("Control") fan_title.setObjectName("sectionTitle") self.clock = QLabel("--:--:--") self.clock.setObjectName("clock") @@ -186,15 +279,19 @@ class TouchPanel(QMainWindow): header.addWidget(self.clock) right_layout.addLayout(header) - self.fan_rpm = QLabel("- RPM") - self.fan_rpm.setObjectName("bigValue") - self.fan_temp = QLabel("- C") - self.fan_temp.setObjectName("subValue") - self.fan_pwm = QLabel("- PWM") - self.fan_pwm.setObjectName("subValue") - right_layout.addWidget(self.fan_rpm) - right_layout.addWidget(self.fan_temp) - right_layout.addWidget(self.fan_pwm) + metric_grid = QGridLayout() + metric_grid.setHorizontalSpacing(8) + metric_grid.setVerticalSpacing(8) + for index, (key, title) in enumerate([ + ("rpm", "팬 RPM"), + ("temp", "판단 온도"), + ("pwm", "PWM"), + ("wan", "WAN"), + ]): + card = MetricCard(title) + self.metrics[key] = card + metric_grid.addWidget(card, index // 4, index % 4) + right_layout.addLayout(metric_grid) mode_layout = QHBoxLayout() for mode in ["auto", "manual", "off"]: @@ -221,6 +318,19 @@ class TouchPanel(QMainWindow): 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) + self.notice = QLabel("프로토타입") self.notice.setObjectName("notice") self.notice.setWordWrap(True) @@ -275,6 +385,9 @@ class TouchPanel(QMainWindow): network = data.get("network", {}) wifi = data.get("wifi", {}) touch = data.get("touch_display", {}) + io = data.get("io", {}) + history = data.get("history", []) + network_history = data.get("network_history", []) interfaces = {row.get("name"): row for row in network.get("interfaces", [])} clients = wifi.get("clients", []) @@ -291,25 +404,74 @@ class TouchPanel(QMainWindow): w24 = interfaces.get("wlan0") or {} w5 = interfaces.get("wlan1") or {} - io_active = bool(touch.get("connected")) and bool(fan.get("policy_ok", True)) + io_active = bool(touch.get("connected")) and bool(fan.get("policy_ok", True)) and self.counter_activity( + "io", + int(io.get("reads") or 0), + int(io.get("writes") or 0), + ) io_detail = f"{temp:.1f} C" self.rows["io"].update_state(io_active, io_detail, temp >= 75) - self.rows["wan"].update_state(self.iface_up(wan), self.iface_detail(wan), self.iface_warn(wan)) - self.rows["lan"].update_state(self.iface_up(lan), f"{len(lan_clients)}대 {self.iface_detail(lan)}", self.iface_warn(lan)) - self.rows["wifi24"].update_state(self.iface_up(w24), f"{len(wifi24)}대 {self.iface_detail(w24)}", self.iface_warn(w24)) - self.rows["wifi5"].update_state(self.iface_up(w5), f"{len(wifi5)}대 {self.iface_detail(w5)}", self.iface_warn(w5)) + self.rows["wan"].update_state(self.iface_activity("wan", wan), self.iface_detail(wan), self.iface_warn(wan)) + self.rows["lan"].update_state(self.iface_activity("lan", lan), f"{len(lan_clients)}대 {self.iface_detail(lan)}", self.iface_warn(lan)) + self.rows["wifi24"].update_state(self.iface_activity("wifi24", w24), f"{len(wifi24)}대 {self.iface_detail(w24)}", self.iface_warn(w24)) + self.rows["wifi5"].update_state(self.iface_activity("wifi5", w5), f"{len(wifi5)}대 {self.iface_detail(w5)}", self.iface_warn(w5)) self.current_mode = mode self.current_pwm = pwm - self.fan_rpm.setText(f"{rpm:,} RPM") - self.fan_temp.setText(f"판단 온도 {float(fan.get('control_temp_c') or temp):.1f} C") - self.fan_pwm.setText(f"{pwm} PWM / {float(fan.get('percent') or 0):.1f}%") + control_temp = float(fan.get("control_temp_c") or temp) + self.metrics["rpm"].set_value(f"{rpm:,}") + self.metrics["temp"].set_value(f"{control_temp:.1f} C") + self.metrics["pwm"].set_value(f"{pwm} / {float(fan.get('percent') or 0):.1f}%") + self.metrics["wan"].set_value(self.traffic_caption(wan)) self.slider.blockSignals(True) self.slider.setValue(pwm) self.slider.blockSignals(False) for key, btn in self.mode_buttons.items(): btn.setChecked(key == mode) self.clock.setText(str(data.get("generated_at", "--:--:--")).split(" ")[-1]) + self.update_charts(history, network_history, network.get("default_interface") or "eth0") + + def iface_activity(self, key, row): + if not self.iface_up(row): + self.last_counters[key] = None + return False + return self.counter_activity( + key, + int(row.get("rx_bytes") or 0), + int(row.get("tx_bytes") or 0), + int(row.get("rx_packets") or 0), + int(row.get("tx_packets") or 0), + ) + + def counter_activity(self, key, *values): + current = tuple(values) + previous = self.last_counters.get(key) + self.last_counters[key] = current + return previous is not None and current != previous + + def update_charts(self, history, network_history, wan_iface): + temps = [row.get("temp_c") for row in history if isinstance(row, dict)] + rpms = [float(row.get("fan_rpm") or 0) / 100 for row in history if isinstance(row, dict)] + self.charts["temp"].set_values(temps, "C / RPMx100", rpms) + + wan_rows = self.iface_history(network_history, [wan_iface]) + lan_rows = self.iface_history(network_history, ["br0", "eth1"]) + wifi_rows = self.iface_history(network_history, ["wlan0", "wlan1"]) + self.set_traffic_chart("wan", wan_rows) + self.set_traffic_chart("lan", lan_rows) + self.set_traffic_chart("wifi", wifi_rows) + + def set_traffic_chart(self, key, rows): + rx = [float(row.get("rx_mbps") or 0) for row in rows] + tx = [float(row.get("tx_mbps") or 0) for row in rows] + latest_rx = rx[-1] if rx else 0 + latest_tx = tx[-1] if tx else 0 + self.charts[key].set_values(rx, f"{latest_rx:.2f}/{latest_tx:.2f} Mbps", tx) + + @staticmethod + def iface_history(rows, names): + names = set(names) + return [row for row in rows if isinstance(row, dict) and row.get("iface") in names] @staticmethod def iface_up(row): @@ -327,20 +489,31 @@ class TouchPanel(QMainWindow): tx_drop = int(row.get("tx_dropped") or 0) return f"{row.get('state', '-')} D{rx_drop + tx_drop}" + @staticmethod + def traffic_caption(row): + if not row: + return "-" + rx = int(row.get("rx_bytes") or 0) + tx = int(row.get("tx_bytes") or 0) + return f"{rx / 1024 / 1024:.0f}/{tx / 1024 / 1024:.0f}MB" + def main(): app = QApplication(sys.argv) app.setStyleSheet( """ - QWidget { background: #0b1220; color: #e5edf7; font-family: "Noto Sans CJK KR", "Noto Sans KR", sans-serif; } + QWidget { background: #0b1220; color: #e5edf7; font-family: "Noto Sans CJK KR", "Noto Sans CJK KR Regular", "Noto Sans KR", sans-serif; } #panel { background: #111a2e; border: 1px solid #27364f; border-radius: 16px; } #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; } - #bigValue { font-size: 62px; font-weight: 800; color: #f8fafc; } - #subValue { font-size: 22px; color: #b8c5d8; } + #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; } #notice { padding: 10px; background: #17233a; border: 1px solid #2b3c59; border-radius: 12px; color: #a8b8d0; } QPushButton { min-height: 42px; border: 1px solid #33445f; border-radius: 14px; background: #1a2942; color: #dce8f8; font-size: 18px; font-weight: 700; }