Files
control/touch_panel/panel.py
T

570 lines
21 KiB
Python
Executable File

#!/usr/bin/env python3
import sys
import time
from collections import deque
from pathlib import Path
from PyQt5.QtCore import QPointF, Qt, QTimer
from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPen
from PyQt5.QtWidgets import (
QApplication,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QSlider,
QVBoxLayout,
QWidget,
)
NET_GROUPS = {
"wan": ["eth0"],
"lan": ["br0", "eth1"],
"wifi24": ["wlan0"],
"wifi5": ["wlan1"],
}
def read_text(path, default=""):
try:
return Path(path).read_text(encoding="utf-8").strip()
except OSError:
return default
def read_int(path, default=0):
raw = read_text(path)
return int(raw) if raw.isdigit() else default
def cpu_temp():
raw = read_int("/sys/class/thermal/thermal_zone0/temp")
return round(raw / 1000, 1) if raw > 0 else 0.0
def fan_paths():
for pwm in Path("/sys/devices/platform/cooling_fan/hwmon").glob("hwmon*/pwm1"):
base = pwm.parent
return {
"pwm": str(pwm),
"enable": str(base / "pwm1_enable"),
"rpm": str(base / "fan1_input"),
}
for pwm in Path("/sys/class/hwmon").glob("hwmon*/pwm1"):
base = pwm.parent
return {
"pwm": str(pwm),
"enable": str(base / "pwm1_enable"),
"rpm": str(base / "fan1_input"),
}
return {"pwm": "", "enable": "", "rpm": ""}
def default_iface():
for line in read_text("/proc/net/route").splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[1] == "00000000":
return parts[0]
return "eth0"
def iface_stats(name):
base = Path("/sys/class/net") / name
stats = base / "statistics"
return {
"name": name,
"state": read_text(base / "operstate", "unknown"),
"carrier": read_text(base / "carrier", "0") == "1",
"rx_bytes": read_int(stats / "rx_bytes"),
"tx_bytes": read_int(stats / "tx_bytes"),
"rx_packets": read_int(stats / "rx_packets"),
"tx_packets": read_int(stats / "tx_packets"),
"rx_errors": read_int(stats / "rx_errors"),
"tx_errors": read_int(stats / "tx_errors"),
"rx_dropped": read_int(stats / "rx_dropped"),
"tx_dropped": read_int(stats / "tx_dropped"),
}
def group_stats(names):
rows = [iface_stats(name) for name in names if (Path("/sys/class/net") / name).exists()]
if not rows:
return iface_stats(names[0])
return {
"name": "+".join(row["name"] for row in rows),
"state": "up" if any(row["state"] == "up" for row in rows) else rows[0]["state"],
"carrier": any(row["carrier"] for row in rows),
"rx_bytes": sum(row["rx_bytes"] for row in rows),
"tx_bytes": sum(row["tx_bytes"] for row in rows),
"rx_packets": sum(row["rx_packets"] for row in rows),
"tx_packets": sum(row["tx_packets"] for row in rows),
"rx_errors": sum(row["rx_errors"] for row in rows),
"tx_errors": sum(row["tx_errors"] for row in rows),
"rx_dropped": sum(row["rx_dropped"] for row in rows),
"tx_dropped": sum(row["tx_dropped"] for row in rows),
}
def disk_io():
reads = 0
writes = 0
for line in read_text("/proc/diskstats").splitlines():
parts = line.split()
if len(parts) < 14:
continue
name = parts[2]
if name.startswith(("loop", "ram", "zram", "dm-")):
continue
reads += int(parts[3])
writes += int(parts[7])
return reads, writes
def write_sys(path, value):
if not path:
return False
try:
Path(path).write_text(str(value) + "\n", encoding="utf-8")
return True
except OSError:
return False
class StatusDot(QWidget):
def __init__(self, active_color):
super().__init__()
self.active_color = QColor(active_color)
self.warning = False
self.pulse = 0
self.setFixedSize(15, 15)
def set_warning(self, warning=False):
self.warning = warning
self.update()
def blink(self):
self.pulse = 3
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.pulse > 0:
base = self.active_color
painter.setBrush(base)
painter.setPen(QPen(QColor("#ef4444"), 1) if self.warning else Qt.NoPen)
painter.drawEllipse(2, 2, 11, 11)
painter.setBrush(QColor(255, 255, 255, 55))
painter.drawEllipse(6, 4, 4, 4)
class StatusTile(QFrame):
def __init__(self, title):
super().__init__()
self.read_dot = StatusDot("#16a34a")
self.write_dot = StatusDot("#f97316")
self.title = QLabel(title)
self.title.setObjectName("statusTitle")
self.detail = QLabel("-")
self.detail.setObjectName("statusDetail")
self.detail.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.setFixedHeight(58)
layout = QHBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(4)
dots = QVBoxLayout()
dots.setContentsMargins(0, 0, 0, 0)
dots.setSpacing(4)
dots.addWidget(self.read_dot, 0, Qt.AlignCenter)
dots.addWidget(self.write_dot, 0, Qt.AlignCenter)
layout.addLayout(dots)
texts = QVBoxLayout()
texts.setContentsMargins(0, 0, 0, 0)
texts.setSpacing(1)
texts.addWidget(self.title)
texts.addWidget(self.detail)
layout.addLayout(texts, 1)
self.setObjectName("statusTile")
def set_state(self, detail, warning=False, read_activity=False, write_activity=False):
self.read_dot.set_warning(warning)
self.write_dot.set_warning(warning)
if read_activity:
self.read_dot.blink()
if write_activity:
self.write_dot.blink()
self.detail.setText(detail)
def tick(self):
self.read_dot.tick()
self.write_dot.tick()
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 = list(values)[-80:]
self.second_values = list(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)
if self.second_color and self.second_values:
self.draw_series(painter, rect, self.second_values, low, high, self.second_color)
@staticmethod
def draw_series(painter, rect, values, low, high, color):
path = QPainterPath()
for index, value in enumerate(values):
x = rect.left() + rect.width() * index / max(1, len(values) - 1)
y = rect.bottom() - rect.height() * ((float(value) - low) / (high - low))
point = QPointF(x, y)
path.moveTo(point) if index == 0 else path.lineTo(point)
painter.setPen(QPen(color, 2))
painter.drawPath(path)
class TouchPanel(QMainWindow):
def __init__(self):
super().__init__()
self.rows = {}
self.metrics = {}
self.charts = {}
self.mode_buttons = {}
self.fan = fan_paths()
self.wan_iface = default_iface()
NET_GROUPS["wan"] = [self.wan_iface]
self.last_net = {}
self.last_io = None
self.last_sample = None
self.last_graph = None
self.current_rates = {"wan": {"rx": 0.0, "tx": 0.0}}
self.current_pwm = read_int(self.fan["pwm"])
self.temp_history = deque(maxlen=80)
self.rpm_history = deque(maxlen=80)
self.net_history = {"wan": {"rx": deque(maxlen=80), "tx": deque(maxlen=80)}}
self.setWindowTitle("Standalone Touch Dashboard")
self.setCursor(Qt.BlankCursor)
self.setMinimumSize(800, 480)
self._build_ui()
self.sample_timer = QTimer(self)
self.sample_timer.timeout.connect(self.sample)
self.sample_timer.start(120)
self.graph_timer = QTimer(self)
self.graph_timer.timeout.connect(self.update_graphs)
self.graph_timer.start(500)
self.pulse_timer = QTimer(self)
self.pulse_timer.timeout.connect(self.tick_pulses)
self.pulse_timer.start(70)
self.sample()
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")]:
tile = StatusTile(label)
self.rows[key] = tile
left_layout.addWidget(tile)
left_layout.addStretch(1)
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(8)
header = QHBoxLayout()
heading = QLabel("Dashboard")
heading.setObjectName("sectionTitle")
self.clock = QLabel("--:--:--")
self.clock.setObjectName("clock")
self.status_text = QLabel("")
self.status_text.setObjectName("statusText")
header.addWidget(heading)
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", "CPU 온도"), ("pwm", "PWM"), ("wan", "WAN")]):
card = MetricCard(title)
self.metrics[key] = card
metric_grid.addWidget(card, 0, index)
right_layout.addLayout(metric_grid)
mode_layout = QHBoxLayout()
for mode, label in [("auto", "auto"), ("manual", "manual"), ("off", "off")]:
btn = QPushButton(label)
btn.setCheckable(True)
btn.clicked.connect(lambda checked=False, m=mode: self.set_fan(m))
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(self.current_pwm)
self.slider.valueChanged.connect(self.slider_changed)
self.slider.sliderReleased.connect(lambda: self.set_fan("manual"))
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")),
]):
self.charts[key] = chart
chart_grid.addWidget(chart, index, 0)
right_layout.addLayout(chart_grid, 1)
layout.addWidget(left)
layout.addWidget(right, 1)
self.setCentralWidget(root)
def sample(self):
now = time.monotonic()
dt = max(0.001, now - self.last_sample) if self.last_sample else 0.001
self.last_sample = now
temp = cpu_temp()
rpm = read_int(self.fan["rpm"])
pwm = read_int(self.fan["pwm"])
enable = read_int(self.fan["enable"], 2)
percent = pwm / 255 * 100 if pwm > 0 else 0
reads, writes = disk_io()
io_read_activity = self.last_io is not None and reads != self.last_io[0]
io_write_activity = self.last_io is not None and writes != self.last_io[1]
self.last_io = (reads, writes)
groups = {key: group_stats(names) for key, names in NET_GROUPS.items()}
rates = {}
activity = {}
for key, row in groups.items():
previous = self.last_net.get(key)
current = (row["rx_bytes"], row["tx_bytes"], row["rx_packets"], row["tx_packets"])
activity[key] = {
"read": previous is not None and current[1] != previous[1],
"write": previous is not None and current[0] != previous[0],
}
if previous:
rates[key] = {
"rx": max(0.0, (current[0] - previous[0]) * 8 / dt / 1_000_000),
"tx": max(0.0, (current[1] - previous[1]) * 8 / dt / 1_000_000),
}
else:
rates[key] = {"rx": 0.0, "tx": 0.0}
self.last_net[key] = current
self.current_rates = rates
self.render(temp, rpm, pwm, percent, enable, groups, rates, activity, io_read_activity, io_write_activity)
def render(self, temp, rpm, pwm, percent, enable, groups, rates, activity, io_read_activity, io_write_activity):
self.rows["io"].set_state(f"{temp:.1f} C", temp >= 75, io_read_activity, io_write_activity)
self.rows["wan"].set_state(self.iface_detail(groups["wan"]), self.iface_warn(groups["wan"]), activity["wan"]["read"], activity["wan"]["write"])
self.rows["lan"].set_state(self.iface_detail(groups["lan"]), self.iface_warn(groups["lan"]), activity["lan"]["read"], activity["lan"]["write"])
self.rows["wifi24"].set_state(self.iface_detail(groups["wifi24"]), self.iface_warn(groups["wifi24"]), activity["wifi24"]["read"], activity["wifi24"]["write"])
self.rows["wifi5"].set_state(self.iface_detail(groups["wifi5"]), self.iface_warn(groups["wifi5"]), activity["wifi5"]["read"], activity["wifi5"]["write"])
self.metrics["rpm"].set_value(f"{rpm:,}")
self.metrics["temp"].set_value(f"{temp:.1f} C")
self.metrics["pwm"].set_value(f"{pwm} / {percent:.1f}%")
self.metrics["wan"].set_value(f"{rates['wan']['rx']:.2f}/{rates['wan']['tx']:.2f} Mbps")
self.slider.blockSignals(True)
self.slider.setValue(pwm)
self.slider.blockSignals(False)
self.mode_buttons["off"].setChecked(enable == 0 or pwm == 0)
self.mode_buttons["manual"].setChecked(enable == 1 and pwm > 0)
self.mode_buttons["auto"].setChecked(enable not in [0, 1])
self.clock.setText(time.strftime("%H:%M:%S"))
self.latest_temp = temp
self.latest_rpm = rpm
def update_graphs(self):
temp = getattr(self, "latest_temp", cpu_temp())
rpm = getattr(self, "latest_rpm", read_int(self.fan["rpm"]))
self.temp_history.append(temp)
self.rpm_history.append(rpm / 100)
self.charts["temp"].set_values(self.temp_history, "C / RPMx100", self.rpm_history)
wan = self.current_rates.get("wan", {"rx": 0.0, "tx": 0.0})
self.net_history["wan"]["rx"].append(wan["rx"])
self.net_history["wan"]["tx"].append(wan["tx"])
self.charts["wan"].set_values(
self.net_history["wan"]["rx"],
f"{wan['rx']:.2f}/{wan['tx']:.2f} Mbps",
self.net_history["wan"]["tx"],
)
def set_fan(self, mode):
pwm = self.slider.value()
ok = True
if mode == "off":
ok = write_sys(self.fan["enable"], 0) and write_sys(self.fan["pwm"], 0)
elif mode == "manual":
ok = write_sys(self.fan["enable"], 1) and write_sys(self.fan["pwm"], pwm)
elif mode == "auto":
ok = write_sys(self.fan["enable"], 2)
self.status_text.setText("" if ok else "팬 쓰기 권한 없음")
def step_pwm(self, delta):
self.slider.setValue(max(0, min(255, self.slider.value() + delta)))
self.set_fan("manual")
def slider_changed(self, value):
self.current_pwm = value
self.metrics["pwm"].set_value(f"{value} / {value / 255 * 100:.1f}%")
def tick_pulses(self):
for row in self.rows.values():
row.tick()
@staticmethod
def iface_detail(row):
if not row["carrier"]:
return "down"
drops = row["rx_dropped"] + row["tx_dropped"]
return f"up D{drops}"
@staticmethod
def iface_warn(row):
return row["rx_errors"] > 0 or row["tx_errors"] > 0
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; }
#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()