#!/usr/bin/env python3 import json import subprocess import sys from pathlib import Path from PyQt5.QtCore import QObject, QPointF, Qt, QThread, QTimer, pyqtSignal from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPen from PyQt5.QtWidgets import ( QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QMainWindow, QPushButton, QSlider, QVBoxLayout, QWidget, ) ROOT = Path(__file__).resolve().parents[1] BRIDGE = ROOT / "touch_panel" / "control_touch_bridge.php" PHP = "/usr/bin/php" def run_bridge(*args): proc = subprocess.run( [PHP, str(BRIDGE), *map(str, args)], cwd=str(ROOT), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=8, check=False, ) if proc.returncode != 0: raise RuntimeError((proc.stderr or proc.stdout or "bridge failed").strip()) payload = json.loads(proc.stdout) if not payload.get("ok"): raise RuntimeError(payload.get("message") or payload.get("error") or "bridge error") return payload["data"] class StatusDot(QWidget): def __init__(self): super().__init__() self.active = False self.warning = False self.pulse = 0 self.setFixedSize(22, 22) def set_warning(self, warning=False): self.warning = warning self.update() def start_pulse(self): self.active = True self.pulse = 4 self.update() def tick(self): if self.pulse > 0: self.pulse -= 1 if self.pulse <= 0: self.active = False 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 and self.active: base = QColor("#86efac") if self.pulse % 2 == 0 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, pulse_delay_ms=0): super().__init__() self.pulse_delay_ms = pulse_delay_ms self.dot = StatusDot() self.title = QLabel(title) self.title.setObjectName("statusTitle") self.detail = QLabel("-") self.detail.setObjectName("statusDetail") self.detail.setWordWrap(True) 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) text_layout = QVBoxLayout() text_layout.setContentsMargins(0, 0, 0, 0) text_layout.setSpacing(1) text_layout.addWidget(self.title) text_layout.addWidget(self.detail) layout.addLayout(text_layout, 1) self.setObjectName("statusTile") def update_state(self, active, detail, warning=False): self.dot.set_warning(warning) if active: QTimer.singleShot(self.pulse_delay_ms, self.dot.start_pulse) self.detail.setText(detail) def tick(self): self.dot.tick() class FanButton(QPushButton): def __init__(self, text): super().__init__(text) self.setCheckable(True) self.setMinimumHeight(44) class BridgeWorker(QObject): finished = pyqtSignal(str, object) failed = pyqtSignal(str, str) def __init__(self, action, args): super().__init__() self.action = action self.args = args def run(self): try: self.finished.emit(self.action, run_bridge(*self.args)) except Exception as exc: self.failed.emit(self.action, str(exc)) 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.bridge_busy = False self.worker_thread = None self.worker = None self.current_mode = "auto" self.current_pwm = 120 self.setWindowTitle("Control Touch Panel") self.setCursor(Qt.BlankCursor) self.setMinimumSize(800, 480) self._build_ui() self.refresh_timer = QTimer(self) self.refresh_timer.timeout.connect(self.refresh) self.refresh_timer.start(1500) self.pulse_timer = QTimer(self) self.pulse_timer.timeout.connect(self.tick_pulses) self.pulse_timer.start(180) self.refresh() 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 index, (key, label) in enumerate([ ("io", "IO"), ("wan", "WAN"), ("lan", "LAN"), ("wifi24", "2.4G"), ("wifi5", "5G"), ]): row = StatusTile(label, index * 70) self.rows[key] = row left_layout.addWidget(row) left_layout.addStretch(1) right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(8) header = QHBoxLayout() fan_title = QLabel("Control") fan_title.setObjectName("sectionTitle") self.clock = QLabel("--:--:--") self.clock.setObjectName("clock") self.status_text = QLabel("") self.status_text.setObjectName("statusText") header.addWidget(fan_title) 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", "판단 온도"), ("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"]: btn = FanButton(mode) btn.clicked.connect(lambda checked=False, m=mode: self.set_fan(m, self.current_pwm)) 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(120) self.slider.sliderReleased.connect(lambda: self.set_fan("manual", self.slider.value())) self.slider.valueChanged.connect(self.slider_changed) 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) right_layout.addStretch(1) layout.addWidget(left) layout.addWidget(right, 1) self.setCentralWidget(root) def tick_pulses(self): for row in self.rows.values(): row.tick() def slider_changed(self, value): self.current_pwm = value if "pwm" in self.metrics: self.metrics["pwm"].set_value(f"{value} / {value / 255 * 100:.1f}%") def set_fan(self, mode, pwm): if self.start_bridge("fan", "fan", mode, pwm): self.status_text.setText("팬 변경 중") def step_pwm(self, delta): value = max(0, min(255, self.slider.value() + delta)) self.slider.setValue(value) self.set_fan("manual", value) def refresh(self): self.start_bridge("status", "status") def start_bridge(self, action, *args): if self.bridge_busy: return False self.bridge_busy = True self.worker_thread = QThread(self) self.worker = BridgeWorker(action, args) self.worker.moveToThread(self.worker_thread) self.worker_thread.started.connect(self.worker.run) self.worker.finished.connect(self.bridge_done) self.worker.failed.connect(self.bridge_failed) self.worker.finished.connect(self.worker_thread.quit) self.worker.failed.connect(self.worker_thread.quit) self.worker.finished.connect(self.worker.deleteLater) self.worker.failed.connect(self.worker.deleteLater) self.worker_thread.finished.connect(self.worker_thread.deleteLater) self.worker_thread.finished.connect(self.bridge_finished) self.worker_thread.start() return True def bridge_done(self, action, data): self.snapshot = data self.status_text.setText("팬 변경 완료" if action == "fan" else "") self.render() def bridge_failed(self, action, message): del action self.status_text.setText("통신 오류") self.rows["io"].update_state(False, "snapshot 실패", True) print(message, file=sys.stderr) def bridge_finished(self): self.bridge_busy = False self.worker_thread = None self.worker = None def render(self): data = self.snapshot fan = data.get("fan", {}) system = data.get("system", {}) 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", []) wifi24 = [c for c in clients if c.get("band") == "2.4G"] wifi5 = [c for c in clients if c.get("band") == "5G"] lan_clients = [c for c in clients if c.get("band") == "LAN"] temp = float(system.get("temp_c") or 0) rpm = int(fan.get("rpm") or 0) pwm = int(fan.get("pwm") or 0) mode = str(fan.get("mode") or "auto") wan = interfaces.get(network.get("default_interface") or "eth0", {}) lan = interfaces.get("br0") or interfaces.get("eth1") or {} w24 = interfaces.get("wlan0") or {} w5 = interfaces.get("wlan1") or {} 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_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 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): return row.get("state") == "up" and row.get("carrier") == "up" @staticmethod def iface_warn(row): return int(row.get("rx_errors") or 0) > 0 or int(row.get("tx_errors") or 0) > 0 @staticmethod def iface_detail(row): if not row: return "없음" rx_drop = int(row.get("rx_dropped") or 0) 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.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; } #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; } #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()