터치 패널 단독 실시간 대시보드 전환

This commit is contained in:
seo committed 2026-07-30 04:57:25 +09:00
1 parent ed76621201
commit 5061a03135
2 files changed
+274 -300

No files matched your search

+9 -7
View File
@@ -68,8 +68,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- `apply_policy.php`: CLI/cron 팬 정책 적용 - `apply_policy.php`: CLI/cron 팬 정책 적용
- `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드 - `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드
- `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI - `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI
- `touch_panel/panel.py`: 라즈베리파이 정품 터치 디스플레이용 Qt 관리자 패널 프로토타입 - `touch_panel/panel.py`: 라즈베리파이 정품 터치 디스플레이용 standalone Qt 대시보드
- `touch_panel/control_touch_bridge.php`: Qt 패널이 기존 Control PHP 함수를 호출하는 로컬 JSON 브리지 - `touch_panel/control_touch_bridge.php`: 이전 Qt 패널용 로컬 JSON 브리지. 현재 standalone 패널은 사용하지 않습니다.
- `touch_panel/control-touch-panel.desktop`: GUI 로그인 후 Qt 패널을 띄우는 데스크톱 autostart 파일 - `touch_panel/control-touch-panel.desktop`: GUI 로그인 후 Qt 패널을 띄우는 데스크톱 autostart 파일
- `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시 - `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시
- `systemd/control-touch-panel.service`: GUI 세션에서 Qt 관리자 패널을 띄우는 systemd unit 예시 - `systemd/control-touch-panel.service`: GUI 세션에서 Qt 관리자 패널을 띄우는 systemd unit 예시
@@ -128,25 +128,27 @@ API 응답의 `battery.enabled`는 false, `battery.removed`는 true로 내려가
설정 모달이 열려 있는 동안 1초 상태 갱신은 폼 DOM을 다시 만들지 않습니다. 입력 중인 값은 저장, 기본값 초기화, 닫기 전까지 유지되며, 백그라운드 snapshot은 내부 설정 payload만 갱신합니다. 단순 boolean 설정은 `켜기`/`끄기` 세그먼트로 표시합니다. 설정 모달이 열려 있는 동안 1초 상태 갱신은 폼 DOM을 다시 만들지 않습니다. 입력 중인 값은 저장, 기본값 초기화, 닫기 전까지 유지되며, 백그라운드 snapshot은 내부 설정 payload만 갱신합니다. 단순 boolean 설정은 `켜기`/`끄기` 세그먼트로 표시합니다.
## 터치 패널 프로토타입 ## 터치 패널 대시보드
라즈베리파이 정품 7인치 DSI 터치 디스플레이에 띄울 로컬 Qt 관리자 패널 초안을 둡니다. 현재 라즈베리파이는 `multi-user.target` 콘솔 부팅이며 PyQt5는 설치되어 있습니다. GUI로 원복하려면 X/데스크톱 패키지를 다시 설치한 뒤 graphical target으로 바꾸고 재부팅해야 합니다. 라즈베리파이 정품 7인치 DSI 터치 디스플레이에 띄울 로컬 Qt 대시보드를 둡니다. 현재 GUI 패키지와 `lightdm`이 설치되어 있고 기본 부팅 target은 `graphical.target`입니다. `seo` GUI 세션의 데스크톱 autostart로 패널을 띄웁니다.
```bash ```bash
sudo raspi-config sudo raspi-config
sudo systemctl set-default graphical.target sudo systemctl set-default graphical.target
``` ```
패키지가 제거된 상태면 Raspberry Pi OS 데스크톱 패키지와 X 서버를 먼저 복구해야 합니다. 운영 패키지 설치는 사용자가 확인한 뒤 진행합니다. 패키지가 제거된 상태면 Raspberry Pi OS 데스크톱 패키지와 X 서버를 먼저 복구해야 합니다.
프로토타입 수동 실행은 GUI 세션에서 다음처럼 합니다. 수동 실행은 GUI 세션에서 다음처럼 합니다.
```bash ```bash
cd /var/www/control cd /var/www/control
/usr/bin/python3 touch_panel/panel.py /usr/bin/python3 touch_panel/panel.py
``` ```
패널은 `touch_panel/control_touch_bridge.php`를 통해 기존 `collect_snapshot()`, `set_control_state()`를 재사용합니다. 왼쪽에는 `IO`, `WAN`, `LAN`, `2.4G`, `5G` 상태 타일을 두고, 원은 평소 회색으로 두었다가 해당 상태가 살아 있는 갱신 순간에만 초록으로 짧게 깜빡입니다. 오른쪽에는 팬 RPM, 판단 온도, PWM, `auto`/`manual`/`off`, PWM 증감 버튼을 둡니다. 팬 버튼은 우선 Control DB의 팬 상태를 저장하고, 실제 적용은 기존 Control 팬 정책 루프가 이어받는 구조입니다. 패널은 Control PHP/API/DB snapshot을 호출하지 않는 standalone 방식입니다. Python이 `/sys/class/net`, `/proc/diskstats`, `/sys/class/thermal`, 팬 hwmon 파일을 직접 읽어 120ms 간격으로 상태를 갱신합니다. 왼쪽 `IO`, `WAN`, `LAN`, `2.4G`, `5G` 원은 장식용 heartbeat가 아니라 실제 커널 카운터 증가에 반응합니다. `IO`는 디스크 read/write 카운터가 증가할 때, 네트워크는 RX/TX byte 또는 packet 카운터가 증가할 때 짧게 점멸합니다.
오른쪽에는 팬 RPM, CPU 온도, PWM, WAN 실시간 속도와 온도/RPM, WAN/LAN/WiFi 트래픽 그래프를 표시합니다. 팬 버튼과 슬라이더는 Control DB를 거치지 않고 팬 hwmon sysfs에 직접 쓰기를 시도합니다. GUI 사용자의 sysfs 쓰기 권한이 없으면 대시보드는 계속 동작하되 `팬 쓰기 권한 없음`을 표시합니다.
GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다. GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다.
Executable → Regular
+265 -293
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
import subprocess
import sys import sys
import time
from collections import deque
from pathlib import Path from pathlib import Path
from PyQt5.QtCore import QObject, QPointF, Qt, QThread, QTimer, pyqtSignal from PyQt5.QtCore import QPointF, Qt, QTimer
from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPen from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPen
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QApplication,
@@ -20,51 +20,139 @@ from PyQt5.QtWidgets import (
) )
ROOT = Path(__file__).resolve().parents[1] NET_GROUPS = {
BRIDGE = ROOT / "touch_panel" / "control_touch_bridge.php" "wan": ["eth0"],
PHP = "/usr/bin/php" "lan": ["br0", "eth1"],
"wifi24": ["wlan0"],
"wifi5": ["wlan1"],
}
def run_bridge(*args): def read_text(path, default=""):
proc = subprocess.run( try:
[PHP, str(BRIDGE), *map(str, args)], return Path(path).read_text(encoding="utf-8").strip()
cwd=str(ROOT), except OSError:
text=True, return default
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=8, def read_int(path, default=0):
check=False, raw = read_text(path)
) return int(raw) if raw.isdigit() else default
if proc.returncode != 0:
raise RuntimeError((proc.stderr or proc.stdout or "bridge failed").strip())
payload = json.loads(proc.stdout) def cpu_temp():
if not payload.get("ok"): raw = read_int("/sys/class/thermal/thermal_zone0/temp")
raise RuntimeError(payload.get("message") or payload.get("error") or "bridge error") return round(raw / 1000, 1) if raw > 0 else 0.0
return payload["data"]
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): class StatusDot(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.active = False
self.warning = False self.warning = False
self.pulse = 0 self.pulse = 0
self.phase = False
self.setFixedSize(22, 22) self.setFixedSize(22, 22)
def set_warning(self, warning=False): def set_warning(self, warning=False):
self.warning = warning self.warning = warning
self.update() self.update()
def start_pulse(self): def blink(self):
self.active = True self.pulse = 3
self.pulse = 4 self.phase = not self.phase
self.update() self.update()
def tick(self): def tick(self):
if self.pulse > 0: if self.pulse > 0:
self.pulse -= 1 self.pulse -= 1
if self.pulse <= 0:
self.active = False
self.update() self.update()
def paintEvent(self, event): def paintEvent(self, event):
@@ -74,8 +162,8 @@ class StatusDot(QWidget):
base = QColor("#8fa0aa") base = QColor("#8fa0aa")
if self.warning: if self.warning:
base = QColor("#f59e0b") base = QColor("#f59e0b")
if self.pulse > 0 and self.active: if self.pulse > 0:
base = QColor("#86efac") if self.pulse % 2 == 0 else QColor("#16a34a") base = QColor("#86efac") if self.phase else QColor("#16a34a")
painter.setBrush(base) painter.setBrush(base)
painter.setPen(Qt.NoPen) painter.setPen(Qt.NoPen)
painter.drawEllipse(3, 3, 16, 16) painter.drawEllipse(3, 3, 16, 16)
@@ -84,16 +172,13 @@ class StatusDot(QWidget):
class StatusTile(QFrame): class StatusTile(QFrame):
def __init__(self, title, pulse_delay_ms=0): def __init__(self, title):
super().__init__() super().__init__()
self.pulse_delay_ms = pulse_delay_ms
self.dot = StatusDot() self.dot = StatusDot()
self.title = QLabel(title) self.title = QLabel(title)
self.title.setObjectName("statusTitle") self.title.setObjectName("statusTitle")
self.detail = QLabel("-") self.detail = QLabel("-")
self.detail.setObjectName("statusDetail") self.detail.setObjectName("statusDetail")
self.detail.setWordWrap(True)
self.detail.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.detail.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.setFixedHeight(58) self.setFixedHeight(58)
@@ -102,47 +187,24 @@ class StatusTile(QFrame):
layout.setSpacing(6) layout.setSpacing(6)
layout.addWidget(self.dot, 0, Qt.AlignLeft | Qt.AlignVCenter) layout.addWidget(self.dot, 0, Qt.AlignLeft | Qt.AlignVCenter)
text_layout = QVBoxLayout() texts = QVBoxLayout()
text_layout.setContentsMargins(0, 0, 0, 0) texts.setContentsMargins(0, 0, 0, 0)
text_layout.setSpacing(1) texts.setSpacing(1)
text_layout.addWidget(self.title) texts.addWidget(self.title)
text_layout.addWidget(self.detail) texts.addWidget(self.detail)
layout.addLayout(text_layout, 1) layout.addLayout(texts, 1)
self.setObjectName("statusTile") self.setObjectName("statusTile")
def update_state(self, active, detail, warning=False): def set_state(self, detail, warning=False, activity=False):
self.dot.set_warning(warning) self.dot.set_warning(warning)
if active: if activity:
QTimer.singleShot(self.pulse_delay_ms, self.dot.start_pulse) self.dot.blink()
self.detail.setText(detail) self.detail.setText(detail)
def tick(self): def tick(self):
self.dot.tick() 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): class MetricCard(QFrame):
def __init__(self, title): def __init__(self, title):
super().__init__() super().__init__()
@@ -151,7 +213,6 @@ class MetricCard(QFrame):
self.title.setObjectName("metricTitle") self.title.setObjectName("metricTitle")
self.value = QLabel("-") self.value = QLabel("-")
self.value.setObjectName("metricValue") self.value.setObjectName("metricValue")
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(10, 8, 10, 8) layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(2) layout.setSpacing(2)
@@ -189,8 +250,8 @@ class Sparkline(QFrame):
layout.addWidget(self.canvas, 1) layout.addWidget(self.canvas, 1)
def set_values(self, values, caption, second_values=None): def set_values(self, values, caption, second_values=None):
self.values = [float(v or 0) for v in values][-80:] self.values = list(values)[-80:]
self.second_values = [float(v or 0) for v in (second_values or [])][-80:] self.second_values = list(second_values or [])[-80:]
self.caption.setText(caption) self.caption.setText(caption)
self.canvas.update() self.canvas.update()
@@ -202,7 +263,6 @@ class Sparkline(QFrame):
painter.fillRect(rect, QColor("#0f172a")) painter.fillRect(rect, QColor("#0f172a"))
painter.setPen(QPen(QColor("#263650"), 1)) painter.setPen(QPen(QColor("#263650"), 1))
painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom()) painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom())
all_values = self.values + self.second_values all_values = self.values + self.second_values
if len(all_values) < 2: if len(all_values) < 2:
return return
@@ -210,57 +270,53 @@ class Sparkline(QFrame):
high = max(all_values) high = max(all_values)
if high <= low: if high <= low:
high = low + 1 high = low + 1
self.draw_series(painter, rect, self.values, low, high, self.color)
self.draw_series(painter, rect, self.values, low, high, self.color, 2)
if self.second_color and self.second_values: if self.second_color and self.second_values:
self.draw_series(painter, rect, self.second_values, low, high, self.second_color, 2) self.draw_series(painter, rect, self.second_values, low, high, self.second_color)
@staticmethod @staticmethod
def draw_series(painter, rect, values, low, high, color, width): def draw_series(painter, rect, values, low, high, color):
if len(values) < 2:
return
path = QPainterPath() path = QPainterPath()
for index, value in enumerate(values): for index, value in enumerate(values):
x = rect.left() + (rect.width() * index / max(1, len(values) - 1)) x = rect.left() + rect.width() * index / max(1, len(values) - 1)
ratio = (float(value) - low) / (high - low) y = rect.bottom() - rect.height() * ((float(value) - low) / (high - low))
y = rect.bottom() - (rect.height() * ratio)
point = QPointF(x, y) point = QPointF(x, y)
if index == 0: path.moveTo(point) if index == 0 else path.lineTo(point)
path.moveTo(point) painter.setPen(QPen(color, 2))
else:
path.lineTo(point)
painter.setPen(QPen(color, width))
painter.drawPath(path) painter.drawPath(path)
class TouchPanel(QMainWindow): class TouchPanel(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.snapshot = {}
self.rows = {} self.rows = {}
self.mode_buttons = {}
self.metrics = {} self.metrics = {}
self.charts = {} self.charts = {}
self.last_counters = {} self.mode_buttons = {}
self.bridge_busy = False self.fan = fan_paths()
self.worker_thread = None self.wan_iface = default_iface()
self.worker = None NET_GROUPS["wan"] = [self.wan_iface]
self.current_mode = "auto" self.last_net = {}
self.current_pwm = 120 self.last_io = None
self.last_sample = None
self.current_pwm = read_int(self.fan["pwm"])
self.temp_history = deque(maxlen=80)
self.rpm_history = deque(maxlen=80)
self.net_history = {key: {"rx": deque(maxlen=80), "tx": deque(maxlen=80)} for key in ["wan", "lan", "wifi"]}
self.setWindowTitle("Control Touch Panel") self.setWindowTitle("Standalone Touch Dashboard")
self.setCursor(Qt.BlankCursor) self.setCursor(Qt.BlankCursor)
self.setMinimumSize(800, 480) self.setMinimumSize(800, 480)
self._build_ui() self._build_ui()
self.refresh_timer = QTimer(self) self.sample_timer = QTimer(self)
self.refresh_timer.timeout.connect(self.refresh) self.sample_timer.timeout.connect(self.sample)
self.refresh_timer.start(1500) self.sample_timer.start(120)
self.pulse_timer = QTimer(self) self.pulse_timer = QTimer(self)
self.pulse_timer.timeout.connect(self.tick_pulses) self.pulse_timer.timeout.connect(self.tick_pulses)
self.pulse_timer.start(180) self.pulse_timer.start(70)
self.refresh() self.sample()
def _build_ui(self): def _build_ui(self):
root = QWidget() root = QWidget()
@@ -274,21 +330,14 @@ class TouchPanel(QMainWindow):
left_layout = QVBoxLayout(left) left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(6, 8, 6, 8) left_layout.setContentsMargins(6, 8, 6, 8)
left_layout.setSpacing(6) left_layout.setSpacing(6)
title = QLabel("상태") title = QLabel("상태")
title.setObjectName("sectionTitle") title.setObjectName("sectionTitle")
left_layout.addWidget(title) left_layout.addWidget(title)
for index, (key, label) in enumerate([ for key, label in [("io", "IO"), ("wan", "WAN"), ("lan", "LAN"), ("wifi24", "2.4G"), ("wifi5", "5G")]:
("io", "IO"), tile = StatusTile(label)
("wan", "WAN"), self.rows[key] = tile
("lan", "LAN"), left_layout.addWidget(tile)
("wifi24", "2.4G"),
("wifi5", "5G"),
]):
row = StatusTile(label, index * 70)
self.rows[key] = row
left_layout.addWidget(row)
left_layout.addStretch(1) left_layout.addStretch(1)
right = QWidget() right = QWidget()
@@ -297,13 +346,13 @@ class TouchPanel(QMainWindow):
right_layout.setSpacing(8) right_layout.setSpacing(8)
header = QHBoxLayout() header = QHBoxLayout()
fan_title = QLabel("Control") heading = QLabel("Dashboard")
fan_title.setObjectName("sectionTitle") heading.setObjectName("sectionTitle")
self.clock = QLabel("--:--:--") self.clock = QLabel("--:--:--")
self.clock.setObjectName("clock") self.clock.setObjectName("clock")
self.status_text = QLabel("") self.status_text = QLabel("")
self.status_text.setObjectName("statusText") self.status_text.setObjectName("statusText")
header.addWidget(fan_title) header.addWidget(heading)
header.addStretch(1) header.addStretch(1)
header.addWidget(self.status_text) header.addWidget(self.status_text)
header.addWidget(self.clock) header.addWidget(self.clock)
@@ -312,21 +361,17 @@ class TouchPanel(QMainWindow):
metric_grid = QGridLayout() metric_grid = QGridLayout()
metric_grid.setHorizontalSpacing(8) metric_grid.setHorizontalSpacing(8)
metric_grid.setVerticalSpacing(8) metric_grid.setVerticalSpacing(8)
for index, (key, title) in enumerate([ for index, (key, title) in enumerate([("rpm", "팬 RPM"), ("temp", "CPU 온도"), ("pwm", "PWM"), ("wan", "WAN")]):
("rpm", "팬 RPM"),
("temp", "판단 온도"),
("pwm", "PWM"),
("wan", "WAN"),
]):
card = MetricCard(title) card = MetricCard(title)
self.metrics[key] = card self.metrics[key] = card
metric_grid.addWidget(card, index // 4, index % 4) metric_grid.addWidget(card, 0, index)
right_layout.addLayout(metric_grid) right_layout.addLayout(metric_grid)
mode_layout = QHBoxLayout() mode_layout = QHBoxLayout()
for mode in ["auto", "manual", "off"]: for mode, label in [("auto", "auto"), ("manual", "manual"), ("off", "off")]:
btn = FanButton(mode) btn = QPushButton(label)
btn.clicked.connect(lambda checked=False, m=mode: self.set_fan(m, self.current_pwm)) btn.setCheckable(True)
btn.clicked.connect(lambda checked=False, m=mode: self.set_fan(m))
self.mode_buttons[mode] = btn self.mode_buttons[mode] = btn
mode_layout.addWidget(btn) mode_layout.addWidget(btn)
right_layout.addLayout(mode_layout) right_layout.addLayout(mode_layout)
@@ -340,9 +385,9 @@ class TouchPanel(QMainWindow):
plus.clicked.connect(lambda: self.step_pwm(10)) plus.clicked.connect(lambda: self.step_pwm(10))
self.slider = QSlider(Qt.Horizontal) self.slider = QSlider(Qt.Horizontal)
self.slider.setRange(0, 255) self.slider.setRange(0, 255)
self.slider.setValue(120) self.slider.setValue(self.current_pwm)
self.slider.sliderReleased.connect(lambda: self.set_fan("manual", self.slider.value()))
self.slider.valueChanged.connect(self.slider_changed) self.slider.valueChanged.connect(self.slider_changed)
self.slider.sliderReleased.connect(lambda: self.set_fan("manual"))
slider_wrap.addWidget(minus) slider_wrap.addWidget(minus)
slider_wrap.addWidget(self.slider, 1) slider_wrap.addWidget(self.slider, 1)
slider_wrap.addWidget(plus) slider_wrap.addWidget(plus)
@@ -361,186 +406,114 @@ class TouchPanel(QMainWindow):
chart_grid.addWidget(chart, index // 2, index % 2) chart_grid.addWidget(chart, index // 2, index % 2)
right_layout.addLayout(chart_grid, 1) right_layout.addLayout(chart_grid, 1)
right_layout.addStretch(1)
layout.addWidget(left) layout.addWidget(left)
layout.addWidget(right, 1) layout.addWidget(right, 1)
self.setCentralWidget(root) 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_activity = self.last_io is not None and (reads, writes) != self.last_io
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] = previous is not None and current != previous
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.render(temp, rpm, pwm, percent, enable, groups, rates, activity, io_activity)
def render(self, temp, rpm, pwm, percent, enable, groups, rates, activity, io_activity):
self.rows["io"].set_state(f"{temp:.1f} C", temp >= 75, io_activity)
self.rows["wan"].set_state(self.iface_detail(groups["wan"]), self.iface_warn(groups["wan"]), activity["wan"])
self.rows["lan"].set_state(self.iface_detail(groups["lan"]), self.iface_warn(groups["lan"]), activity["lan"])
self.rows["wifi24"].set_state(self.iface_detail(groups["wifi24"]), self.iface_warn(groups["wifi24"]), activity["wifi24"])
self.rows["wifi5"].set_state(self.iface_detail(groups["wifi5"]), self.iface_warn(groups["wifi5"]), activity["wifi5"])
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.temp_history.append(temp)
self.rpm_history.append(rpm / 100)
self.charts["temp"].set_values(self.temp_history, "C / RPMx100", self.rpm_history)
for key, chart_key in [("wan", "wan"), ("lan", "lan")]:
self.net_history[key]["rx"].append(rates[key]["rx"])
self.net_history[key]["tx"].append(rates[key]["tx"])
self.charts[chart_key].set_values(
self.net_history[key]["rx"],
f"{rates[key]['rx']:.2f}/{rates[key]['tx']:.2f} Mbps",
self.net_history[key]["tx"],
)
wifi_rx = rates["wifi24"]["rx"] + rates["wifi5"]["rx"]
wifi_tx = rates["wifi24"]["tx"] + rates["wifi5"]["tx"]
self.net_history["wifi"]["rx"].append(wifi_rx)
self.net_history["wifi"]["tx"].append(wifi_tx)
self.charts["wifi"].set_values(self.net_history["wifi"]["rx"], f"{wifi_rx:.2f}/{wifi_tx:.2f} Mbps", self.net_history["wifi"]["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): def tick_pulses(self):
for row in self.rows.values(): for row in self.rows.values():
row.tick() 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 @staticmethod
def iface_history(rows, names): def iface_detail(row):
names = set(names) if not row["carrier"]:
return [row for row in rows if isinstance(row, dict) and row.get("iface") in names] return "down"
drops = row["rx_dropped"] + row["tx_dropped"]
@staticmethod return f"up D{drops}"
def iface_up(row):
return row.get("state") == "up" and row.get("carrier") == "up"
@staticmethod @staticmethod
def iface_warn(row): def iface_warn(row):
return int(row.get("rx_errors") or 0) > 0 or int(row.get("tx_errors") or 0) > 0 return row["rx_errors"] > 0 or row["tx_errors"] > 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(): def main():
@@ -549,7 +522,6 @@ def main():
app.setStyleSheet( app.setStyleSheet(
""" """
QWidget { background: #0b1220; color: #e5edf7; font-family: "Noto Sans CJK KR", "Noto Sans CJK KR Regular", "Noto Sans KR", sans-serif; } 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; } #statusPanel { background: #101827; border-right: 1px solid #263650; }
#sectionTitle { font-size: 20px; font-weight: 700; } #sectionTitle { font-size: 20px; font-weight: 700; }
#statusTile { background: #d7d9dc; border: 2px groove #f4f4f4; border-radius: 2px; } #statusTile { background: #d7d9dc; border: 2px groove #f4f4f4; border-radius: 2px; }