From 64ded5e566fbc8db0b70e371ee6dd133021ba60c Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 14 Aug 2026 02:26:55 +0900 Subject: [PATCH] =?UTF-8?q?=ED=84=B0=EC=B9=98=20=ED=8C=A8=EB=84=90=20PC=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=99=95=EC=9E=A5=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- touch_panel/panel.py | 119 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6eb2f72..66a408c 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,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 파일을 직접 읽습니다. 화면은 왼쪽 `RASPBERRY` 18칸, 오른쪽 `SYNOLOGY` 18칸 카드로 나눕니다. 왼쪽 하단의 1칸짜리 영역은 아직 기능을 두지 않고 빈칸으로 둡니다. +패널은 Control PHP/API/DB snapshot을 호출하지 않는 standalone 방식입니다. Python이 `/sys/class/net`, `/proc/diskstats`, `/sys/class/thermal`, 팬 hwmon 파일을 직접 읽습니다. 화면은 왼쪽 `RASPBERRY` 18칸, 오른쪽 `SYNOLOGY` 18칸 카드로 나눕니다. 하단에는 좌우 6칸 폭을 한 번에 쓰는 긴 `PC` 버튼을 둡니다. `RASPBERRY` 카드는 `SSD I/O`, `TEMP`, `FAN`, `THROT`, `LOAD`, `MEM`, `ROOT`, `UPTIME`, `LOCAL`, `UPLINK`, `ETH1`, `5G`, `2.4G`, `DOCKER`, `NGINX`, `MARIADB`, `CONTROL`, `FANPOL`을 표시합니다. 네트워크와 SSD I/O 점멸은 100ms 샘플에서 직전 카운터보다 증가했는지를 보고, CPU 온도/SSD 온도와 팬 RPM은 500ms 간격으로 표시합니다. systemd 서비스 상태는 5초마다 `systemctl is-active`로 확인합니다. @@ -165,6 +165,8 @@ cd /var/www/control 각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드는 초록, 쓰기/다운로드는 주황, 오류/드롭 증가는 빨강으로 표시합니다. 활동 표시등은 이벤트 감지 후 120ms 동안 유지하고, 오류 표시등은 2초 동안 유지해 너무 짧은 이벤트도 눈에 보이게 합니다. 네트워크 카드 오른쪽 값은 항상 `D n`으로 드롭 누적을 표시합니다. `LOCAL`은 `lo`, `UPLINK`는 기본 라우트 인터페이스 기준입니다. 라즈베리파이 `ETH1`은 컴퓨터와 연결된 물리 포트 단독 카운터이며 `br0`는 합산하지 않습니다. `SSD I/O`의 값은 `/run/control-rpi-disk-temp` 캐시 파일의 `/dev/sda` Samsung T7 디스크 자체 온도입니다. 캐시 파일은 root로 실행되는 `control-rpi-disk-temp.service`가 몇 초마다 `smartctl -d sntasmedia`로 갱신하므로 GUI 패널은 sudo를 반복 실행하지 않습니다. +하단 `PC` 버튼은 `192.168.50.73`에 1초마다 ping을 보내 켜짐 여부를 표시하고, 꺼져 있을 때 누르면 `74:56:3C:CD:CD:79`로 WOL magic packet을 보냅니다. 주황은 WOL 후 최대 1분 동안 컴퓨터 응답 대기 표시, 빨강은 magic packet 전송 실패 시 5초 또는 1분 대기 후 응답 실패 시 1분 표시입니다. + GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다. ```bash diff --git a/touch_panel/panel.py b/touch_panel/panel.py index f4099bb..735f4d3 100755 --- a/touch_panel/panel.py +++ b/touch_panel/panel.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import sys import time +import socket import os import shutil from collections import deque @@ -28,6 +29,8 @@ NET_GROUPS = { "wifi5": ["wlan1"], } +PC_IP = "192.168.50.73" +PC_MAC = "74:56:3C:CD:CD:79" RPI_SERVICE_UNITS = [ ("rpi_docker", "docker.service"), ("rpi_nginx", "nginx.service"), @@ -340,6 +343,21 @@ def disk_io(): return reads, writes, not found +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 + + class StatusDot(QWidget): def __init__(self, active_color): super().__init__() @@ -462,6 +480,72 @@ class StatusTile(QFrame): self.error_dot.set_steady(error_on) +class PcWakeButton(StatusTile): + def __init__(self): + super().__init__("PC") + self.online = False + self.last_wake_at = 0.0 + self.wait_until = 0.0 + self.error_until = 0.0 + self.wait_phase = False + self.blink_timer = QTimer(self) + self.blink_timer.timeout.connect(self.tick_waiting) + self.blink_timer.start(250) + + def set_online(self, online): + self.online = online + self.read_dot.set_steady(online) + now = time.monotonic() + self.error_dot.set_steady(now < self.error_until) + if online: + self.wait_until = 0.0 + self.wait_phase = False + self.write_dot.set_steady(False) + self.detail.setText("켜짐") + elif now < self.wait_until: + remaining = int(self.wait_until - now) + self.detail.setText(f"대기 {remaining}s") + else: + self.write_dot.set_steady(False) + self.detail.setText("꺼짐") + + def tick_waiting(self): + now = time.monotonic() + if not self.online and now < self.wait_until: + self.wait_phase = not self.wait_phase + self.write_dot.set_steady(self.wait_phase) + if not self.online and self.wait_until > 0 and now >= self.wait_until: + self.wait_until = 0.0 + self.wait_phase = False + self.write_dot.set_steady(False) + self.error_until = now + 60 + self.error_dot.set_steady(True) + self.detail.setText("실패") + if self.error_until > 0 and now >= self.error_until: + self.error_until = 0.0 + self.error_dot.set_steady(False) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + now = time.monotonic() + if not self.online and now >= self.wait_until: + self.last_wake_at = now + self.wait_until = now + 60 + self.wait_phase = True + self.write_dot.set_steady(True) + self.error_until = 0.0 + self.error_dot.set_steady(False) + self.detail.setText("깨우는 중") + if not send_magic_packet(PC_MAC): + self.wait_until = 0.0 + self.error_until = now + 5 + self.error_dot.set_steady(True) + super().mousePressEvent(event) + + def tick(self): + self.error_dot.tick() + + class Sparkline(QFrame): def __init__(self, title, color="#38bdf8", second_color=None, y_unit="", right_unit="", auto_byte_unit=False, max_points=120): super().__init__() @@ -610,6 +694,12 @@ class TouchPanel(QMainWindow): self.fan_timer.timeout.connect(self.update_fan_status) self.fan_timer.start(FAN_INTERVAL_MS) + 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.disk_temp_timer = QTimer(self) self.disk_temp_timer.timeout.connect(self.update_disk_temp) self.disk_temp_timer.start(DISK_TEMP_INTERVAL_MS) @@ -653,6 +743,7 @@ class TouchPanel(QMainWindow): self.pulse_timer.start(25) self.sample() self.update_fan_status() + self.ping_pc() self.update_disk_temp() self.update_rpi_services() self.update_synology_status() @@ -663,9 +754,13 @@ class TouchPanel(QMainWindow): def _build_ui(self): root = QWidget() - layout = QHBoxLayout(root) + layout = QVBoxLayout(root) layout.setContentsMargins(4, 8, 10, 8) - layout.setSpacing(8) + layout.setSpacing(6) + + panel_layout = QHBoxLayout() + panel_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.setSpacing(8) rpi_rows = [ (("io", "SSD I/O"), ("rpi_temp", "TEMP"), ("rpi_fan", "FAN")), @@ -684,7 +779,7 @@ class TouchPanel(QMainWindow): (("synology_sata1", "SATA1 I/O"), ("synology_sata2", "SATA2 I/O"), ("synology_sata4", "SATA4 I/O")), ] - left = self.build_status_panel("RASPBERRY", rpi_rows, fixed_width=398, bottom_blank=True) + left = self.build_status_panel("RASPBERRY", rpi_rows, fixed_width=398) right = self.build_status_panel("SYNOLOGY", synology_rows) header = QHBoxLayout() @@ -697,8 +792,13 @@ class TouchPanel(QMainWindow): header.addWidget(self.clock) right.layout().insertLayout(1, header) - layout.addWidget(left) - layout.addWidget(right, 1) + panel_layout.addWidget(left) + panel_layout.addWidget(right, 1) + layout.addLayout(panel_layout, 1) + + self.pc_button = PcWakeButton() + self.pc_button.setFixedHeight(48) + layout.addWidget(self.pc_button) self.setCentralWidget(root) def build_status_panel(self, title_text, rows, fixed_width=None, bottom_blank=False): @@ -836,6 +936,15 @@ class TouchPanel(QMainWindow): detail = "OK" if active else state.upper()[:6] self.rows[key].set_state(detail, read_steady=active, write_steady=False, error_steady=not active) + 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 ping_synology(self): if self.synology_ping_proc.state() != QProcess.NotRunning: return