From 93881228929545d9081c2403ee8404c774e400c8 Mon Sep 17 00:00:00 2001 From: seo Date: Thu, 30 Jul 2026 13:04:54 +0900 Subject: [PATCH] =?UTF-8?q?=ED=84=B0=EC=B9=98=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=82=BC=EC=83=89=20=EC=83=81=ED=83=9C=EB=93=B1=EA=B3=BC=20PC?= =?UTF-8?q?=20=EA=B9=A8=EC=9A=B0=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- touch_panel/panel.py | 125 +++++++++++++++++++++++++++++++++---------- 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 718a3d8..5e60501 100644 --- a/README.md +++ b/README.md @@ -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처럼 보일 수 있습니다. diff --git a/touch_panel/panel.py b/touch_panel/panel.py index 93e4e63..07982d0 100755 --- a/touch_panel/panel.py +++ b/touch_panel/panel.py @@ -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; }