#!/usr/bin/env python3 import json import subprocess import sys from pathlib import Path 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, 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_state(self, active, warning=False): self.active = active self.warning = warning self.pulse = 4 if active else 0 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 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): super().__init__() 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_state(active, warning) 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 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 self.setWindowTitle("Control Touch Panel") self.setMinimumSize(800, 480) self._build_ui() self.refresh_timer = QTimer(self) self.refresh_timer.timeout.connect(self.refresh) self.refresh_timer.start(1200) 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 key, label in [ ("io", "IO"), ("wan", "WAN"), ("lan", "LAN"), ("wifi24", "2.4G"), ("wifi5", "5G"), ]: row = StatusTile(label) 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") header.addWidget(fan_title) header.addStretch(1) 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) self.notice = QLabel("프로토타입") self.notice.setObjectName("notice") self.notice.setWordWrap(True) right_layout.addWidget(self.notice) 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 self.fan_pwm.setText(f"{value} PWM / {value / 255 * 100:.1f}%") def set_fan(self, mode, pwm): if self.in_flight: return self.in_flight = True self.notice.setText("팬 변경 중") try: self.snapshot = run_bridge("fan", mode, pwm) self.notice.setText("팬 상태를 바꿨어") self.render() except Exception as exc: self.notice.setText(str(exc)) finally: self.in_flight = False 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): if self.in_flight: return try: self.snapshot = run_bridge("status") self.render() except Exception as exc: self.notice.setText(str(exc)) self.rows["io"].update_state(False, "control snapshot 실패", True) 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.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; } #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; } 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()