터치 패널 삼색 상태등과 PC 깨우기 추가

This commit is contained in:
seo committed 2026-07-30 13:04:54 +09:00
1 parent e89f1da109
commit 9388122892
2 files changed
+98 -29

No files matched your search

+1 -1
View File
@@ -146,7 +146,7 @@ cd /var/www/control
/usr/bin/python3 touch_panel/panel.py
```
패널은 Control PHP/API/DB snapshot을 호출하지 않는 standalone 방식입니다. Python이 `/sys/class/net`, `/proc/diskstats`, `/sys/class/thermal`, 팬 hwmon 파일을 직접 읽습니다. 상태패널과 팬패널은 120ms 간격으로 갱신하고, 그래프는 500ms 간격으로 샘플링합니다. 왼쪽 `IO`, `WAN`, `LAN`, `2.4G`, `5G` 표시등은 장식용 heartbeat가 아니라 실제 커널 카운터 증가에 반응합니다. 각 타일에는 두 개의 작은 표시등을 두며 읽기/업로드는 초록, 쓰기/다운로드는 주황으로 점멸합니다. 둘 다 활동이 없으면 둘 다 회색입니다.
패널은 Control PHP/API/DB snapshot을 호출하지 않는 standalone 방식입니다. Python이 `/sys/class/net`, `/proc/diskstats`, `/sys/class/thermal`, 팬 hwmon 파일을 직접 읽습니다. 상태패널과 팬패널은 120ms 간격으로 갱신하고, 그래프는 500ms 간격으로 샘플링합니다. 왼쪽 `DISK I/O`, `WAN`, `LAN`, `2.4G`, `5G` 표시등은 장식용 heartbeat가 아니라 실제 커널 카운터 증가에 반응합니다. 각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드는 초록, 쓰기/다운로드는 주황, 오류는 빨강으로 표시합니다. 활동이 없으면 초록/주황은 회색입니다. 상태패널 최하단 왼쪽의 `PC` 표시등은 `192.168.50.73`에 1초마다 ping을 보내 켜짐 여부를 표시하고, 꺼져 있을 때 누르면 `74:56:3C:CD:CD:79`로 WOL magic packet을 보냅니다.
오른쪽에는 팬 RPM, CPU 온도, PWM, WAN 실시간 속도와 온도/RPM, WAN 트래픽 그래프를 표시합니다. 팬 버튼과 슬라이더는 Control DB를 거치지 않고 팬 hwmon sysfs에 직접 쓰기를 시도합니다. GUI 사용자의 sysfs 쓰기 권한이 없으면 대시보드는 계속 동작하되 `팬 쓰기 권한 없음`을 표시합니다. 팬 모드 표시는 hwmon `pwm1_enable` 기준이므로 Control DB가 auto여도 fan policy가 sysfs에 직접 PWM을 쓰는 순간 하드웨어 상태는 manual처럼 보일 수 있습니다.
+97 -28
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
import sys
import time
import socket
from collections import deque
from pathlib import Path
from PyQt5.QtCore import QPointF, Qt, QTimer
from PyQt5.QtCore import QPointF, QProcess, Qt, QTimer
from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPen
from PyQt5.QtWidgets import (
QApplication,
@@ -27,6 +28,9 @@ NET_GROUPS = {
"wifi5": ["wlan1"],
}
PC_IP = "192.168.50.73"
PC_MAC = "74:56:3C:CD:CD:79"
def read_text(path, default=""):
try:
@@ -111,6 +115,7 @@ def group_stats(names):
def disk_io():
reads = 0
writes = 0
found = False
for line in read_text("/proc/diskstats").splitlines():
parts = line.split()
if len(parts) < 14:
@@ -118,14 +123,30 @@ def disk_io():
name = parts[2]
if name.startswith(("loop", "ram", "zram", "dm-")):
continue
found = True
reads += int(parts[3])
writes += int(parts[7])
return reads, writes
return reads, writes, not found
def write_sys(path, value):
if not path:
return False
def send_magic_packet(mac):
clean = mac.replace(":", "").replace("-", "")
if len(clean) != 12:
return False
packet = bytes.fromhex("ff" * 6 + clean * 16)
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
for target in ["192.168.50.255", "255.255.255.255"]:
sock.sendto(packet, (target, 9))
return True
except OSError:
return False
try:
Path(path).write_text(str(value) + "\n", encoding="utf-8")
return True
@@ -137,12 +158,12 @@ class StatusDot(QWidget):
def __init__(self, active_color):
super().__init__()
self.active_color = QColor(active_color)
self.warning = False
self.steady = False
self.pulse = 0
self.setFixedSize(15, 15)
def set_warning(self, warning=False):
self.warning = warning
def set_steady(self, steady=False):
self.steady = steady
self.update()
def blink(self):
@@ -159,10 +180,10 @@ class StatusDot(QWidget):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
base = QColor("#8fa0aa")
if self.pulse > 0:
if self.steady or self.pulse > 0:
base = self.active_color
painter.setBrush(base)
painter.setPen(QPen(QColor("#ef4444"), 1) if self.warning else Qt.NoPen)
painter.setPen(Qt.NoPen)
painter.drawEllipse(2, 2, 11, 11)
painter.setBrush(QColor(255, 255, 255, 55))
painter.drawEllipse(6, 4, 4, 4)
@@ -173,6 +194,7 @@ class StatusTile(QFrame):
super().__init__()
self.read_dot = StatusDot("#16a34a")
self.write_dot = StatusDot("#f97316")
self.error_dot = StatusDot("#dc2626")
self.title = QLabel(title)
self.title.setObjectName("statusTitle")
self.detail = QLabel("-")
@@ -180,28 +202,28 @@ class StatusTile(QFrame):
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)
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 5, 6, 5)
layout.setSpacing(2)
texts = QVBoxLayout()
texts.setContentsMargins(0, 0, 0, 0)
texts.setSpacing(1)
texts.setSpacing(0)
texts.addWidget(self.title)
texts.addWidget(self.detail)
layout.addLayout(texts, 1)
layout.addLayout(texts)
dots = QHBoxLayout()
dots.setContentsMargins(0, 0, 0, 0)
dots.setSpacing(5)
dots.addWidget(self.read_dot, 0, Qt.AlignLeft)
dots.addWidget(self.write_dot, 0, Qt.AlignLeft)
dots.addWidget(self.error_dot, 0, Qt.AlignLeft)
dots.addStretch(1)
layout.addLayout(dots)
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)
def set_state(self, detail, error=False, read_activity=False, write_activity=False):
self.error_dot.set_steady(error)
if read_activity:
self.read_dot.blink()
if write_activity:
@@ -211,6 +233,33 @@ class StatusTile(QFrame):
def tick(self):
self.read_dot.tick()
self.write_dot.tick()
self.error_dot.tick()
class PcWakeButton(QFrame):
def __init__(self):
super().__init__()
self.setObjectName("pcWake")
self.online = False
self.dot = StatusDot("#16a34a")
self.label = QLabel("PC")
self.label.setObjectName("pcLabel")
layout = QHBoxLayout(self)
layout.setContentsMargins(5, 4, 5, 4)
layout.setSpacing(5)
layout.addWidget(self.dot)
layout.addWidget(self.label)
layout.addStretch(1)
self.setFixedHeight(28)
def set_online(self, online):
self.online = online
self.dot.set_steady(online)
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton and not self.online:
send_magic_packet(PC_MAC)
super().mousePressEvent(event)
class MetricCard(QFrame):
@@ -327,10 +376,17 @@ class TouchPanel(QMainWindow):
self.graph_timer.timeout.connect(self.update_graphs)
self.graph_timer.start(500)
self.pc_ping = QProcess(self)
self.pc_ping.finished.connect(self.pc_ping_finished)
self.pc_timer = QTimer(self)
self.pc_timer.timeout.connect(self.ping_pc)
self.pc_timer.start(1000)
self.pulse_timer = QTimer(self)
self.pulse_timer.timeout.connect(self.tick_pulses)
self.pulse_timer.start(70)
self.sample()
self.ping_pc()
def _build_ui(self):
root = QWidget()
@@ -348,11 +404,13 @@ class TouchPanel(QMainWindow):
title.setObjectName("sectionTitle")
left_layout.addWidget(title)
for key, label in [("io", "IO"), ("wan", "WAN"), ("lan", "LAN"), ("wifi24", "2.4G"), ("wifi5", "5G")]:
for key, label in [("io", "DISK I/O"), ("wan", "WAN"), ("lan", "LAN"), ("wifi24", "2.4G"), ("wifi5", "5G")]:
tile = StatusTile(label)
self.rows[key] = tile
left_layout.addWidget(tile)
left_layout.addStretch(1)
self.pc_button = PcWakeButton()
left_layout.addWidget(self.pc_button, 0, Qt.AlignLeft | Qt.AlignBottom)
right = QWidget()
right_layout = QVBoxLayout(right)
@@ -433,7 +491,7 @@ class TouchPanel(QMainWindow):
enable = read_int(self.fan["enable"], 2)
percent = pwm / 255 * 100 if pwm > 0 else 0
reads, writes = disk_io()
reads, writes, disk_error = 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)
@@ -458,10 +516,10 @@ class TouchPanel(QMainWindow):
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)
self.render(temp, rpm, pwm, percent, enable, groups, rates, activity, io_read_activity, io_write_activity, disk_error)
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)
def render(self, temp, rpm, pwm, percent, enable, groups, rates, activity, io_read_activity, io_write_activity, disk_error):
self.rows["io"].set_state(f"{temp:.1f} C", disk_error, 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"])
@@ -483,6 +541,15 @@ class TouchPanel(QMainWindow):
self.latest_temp = temp
self.latest_rpm = rpm
def ping_pc(self):
if self.pc_ping.state() != QProcess.NotRunning:
return
self.pc_ping.start("/usr/bin/ping", ["-c", "1", "-W", "1", PC_IP])
def pc_ping_finished(self, exit_code, exit_status):
del exit_status
self.pc_button.set_online(exit_code == 0)
def update_graphs(self):
temp = getattr(self, "latest_temp", cpu_temp())
rpm = getattr(self, "latest_rpm", read_int(self.fan["rpm"]))
@@ -545,6 +612,8 @@ def main():
#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; }
#pcWake { background: transparent; border: 0; }
#pcLabel { color: #dce8f8; font-size: 13px; font-weight: 700; 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; }