Files
control/touch_panel/panel.py
T

534 lines
19 KiB
Python
Executable File

#!/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()