Files
control/touch_panel/panel.py
T

580 lines
21 KiB
Python
Executable File

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