터치 패널 상태 카드 재배치

This commit is contained in:
seo committed 2026-08-14 02:23:11 +09:00
1 parent d10682708e
commit 637739386e
2 files changed
+252 -168

No files matched your search

+8 -2
View File
@@ -155,9 +155,15 @@ cd /var/www/control
/usr/bin/python3 touch_panel/panel.py /usr/bin/python3 touch_panel/panel.py
``` ```
패널은 Control PHP/API/DB snapshot을 호출하지 않는 standalone 방식입니다. Python이 `/sys/class/net`, `/proc/diskstats`, `/sys/class/thermal`, 팬 hwmon 파일을 직접 읽습니다. 상태패널의 네트워크/I/O 표시와 UPLINK 트래픽 그래프는 100ms 간격으로 갱신하고, 팬 카드와 온도/RPM 그래프는 500ms 간격으로 갱신합니다. 왼쪽 상태패널은 `RASPBERRY`, `SYNOLOGY`, `CHECK` 3열이며, 라즈베리파이 순서는 `SSD I/O`, `LOCAL`, `UPLINK`, `ETH1`, `5G`, `2.4G`입니다. 시놀로지 열은 `PING`, 정상 디스크 `SATA1 I/O`, `SATA2 I/O`, `SATA4 I/O`, `LOCAL`, `UPLINK`를 표시합니다. `CHECK` 열은 `RPI TEMP`, `RPI FAN`, `RPI THROT`, `SYNO TEMP`, `SYNO FAN`, `SYNO GUARD`를 표시합니다. `RPI TEMP`와 `SYNO TEMP`는 80도 이상부터 빨간 표시등을 500ms 간격으로 점멸하고, 팬 카드는 RPM 0 또는 7000 초과를 빨강, 4500 초과를 주황으로 표시합니다. 시놀로지 `PING`은 장기 실행 `ping -i 0.5` 출력 파싱으로 읽고, 15ms 이하 초록, 30ms 이하 주황, 50ms 이하 빨강, 50ms 초과 빨강 500ms 점멸입니다. `RPI THROT`은 정상 초록, 부팅 후 이력 주황, 현재 언더볼트 빨강, 현재 스로틀 빨강 500ms 점멸, 현재 언더볼트와 스로틀 동시 발생 빨강 100ms 점멸로 표시합니다. `SYNO GUARD`는 `synology-fan-guard.service`가 active이고 DSM 자동제어면 초록 `AUTO`, 고온으로 PWM 최대 강제 중이면 주황 `FULL`, 서비스 실패나 팬 RPM 0이면 빨강 `FAIL`로 표시합니다. `LOCAL`은 `lo`, `UPLINK`는 기본 라우트 인터페이스 기준입니다. 라즈베리파이 `ETH1`은 컴퓨터와 연결된 물리 포트 단독 카운터이며 `br0`는 합산하지 않습니다. 시놀로지는 SSH를 유지한 채 100ms마다 디스크별 sector I/O, `LOCAL`, `UPLINK` 카운터만 스트림으로 가져옵니다. SATA SMART/온도 직접 조회는 응답 지연 위험 때문에 대시보드에서 실행하지 않고, 각 SATA 카드에는 `조회 중지`를 표시합니다. 라즈베리파이 CPU/SSD 온도와 시놀로지 CPU 온도/FAN RPM은 500ms 간격으로 캐시해 표시합니다. 왼쪽 표시등은 장식용 heartbeat가 아니라 직전 샘플 대비 현재 카운터 증가량이 0 초과인지에 반응합니다. 각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드은 초록, 쓰기/다운로드는 주황, 오류/드롭 증가는 빨강으로 표시합니다. 활동 표시등은 이벤트 감지 후 120ms 동안 유지하고, 오류 표시등은 2초 동안 유지해 너무 짧은 이벤트도 눈에 보이게 합니다. 네트워크 카드 오른쪽 값은 항상 `D n`으로 드롭 누적을 표시합니다. `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을 보냅니다. PC 타일의 주황은 WOL 후 최대 1분 동안 컴퓨터 응답 대기 표시, 빨강은 magic packet 전송 실패 시 5초 또는 1분 대기 후 응답 실패 시 1분 표시입니다. 패널은 Control PHP/API/DB snapshot을 호출하지 않는 standalone 방식입니다. Python이 `/sys/class/net`, `/proc/diskstats`, `/sys/class/thermal`, 팬 hwmon 파일을 직접 읽습니다. 화면은 왼쪽 `RASPBERRY` 18칸, 오른쪽 `SYNOLOGY` 18칸 카드로 나눕니다. 왼쪽 하단의 1칸짜리 영역은 아직 기능을 두지 않고 빈칸으로 둡니다.
오른쪽 `기록` 영역은 라즈베리파이 온도/RPM, 라즈베리파이 UPLINK 트래픽, 시놀로지 온도/RPM, 시놀로지 UPLINK 트래픽 그래프를 순서대로 표시합니다. 온도/RPM 그래프는 최근 60초 기준 500ms 샘플을 사용하고, UPLINK 트래픽 그래프는 100ms 샘플과 200포인트 기준으로 표시합니다. 트래픽 속도와 그래프 Y축은 byte/sec 기준으로 `B/s`, `KB/s`, `MB/s`, `GB/s` 중 자동 변환하고, 다운로드 주황/업로드 초록 두 선을 같은 축 범위에 맞춰 그립니다. 그래프 우측 상단 현재값은 고정폭으로 표시합니다. `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`로 확인합니다.
`SYNOLOGY` 카드는 `PING`, `TEMP`, `FAN`, `GUARD`, `PWM`, `LOAD`, `MEM`, `SWAP`, `UPTIME`, `ROOT`, `VOL1`, `VOL2`, `LOCAL`, `UPLINK`, `SSH`, `SATA1 I/O`, `SATA2 I/O`, `SATA4 I/O`를 표시합니다. 시놀로지는 SSH를 유지한 채 100ms마다 네트워크, 디스크 sector I/O, load, memory, swap, uptime, filesystem 사용률을 스트림으로 가져옵니다. SATA SMART/온도 직접 조회는 응답 지연 위험 때문에 대시보드에서 실행하지 않고, SATA I/O 카드에는 `조회 중지`를 표시합니다. CPU 온도, 팬 RPM, PWM, fan guard 상태는 500ms 간격으로 별도 조회합니다.
온도 카드는 80도 이상부터 빨간 표시등을 500ms 간격으로 점멸하고, 팬 카드는 RPM 0 또는 7000 초과를 빨강, 4500 초과를 주황으로 표시합니다. 시놀로지 `PING`은 장기 실행 `ping -i 0.5` 출력 파싱으로 읽고, 15ms 이하 초록, 30ms 이하 주황, 50ms 이하 빨강, 50ms 초과 빨강 500ms 점멸입니다. `RPI THROT`은 정상 초록, 부팅 후 이력 주황, 현재 언더볼트 빨강, 현재 스로틀 빨강 500ms 점멸, 현재 언더볼트와 스로틀 동시 발생 빨강 100ms 점멸로 표시합니다. `SYNO GUARD`는 `synology-fan-guard.service`가 active이고 DSM 자동제어면 초록 `AUTO`, 고온으로 PWM 최대 강제 중이면 주황 `FULL`, 서비스 실패나 팬 RPM 0이면 빨강 `FAIL`로 표시합니다.
각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드는 초록, 쓰기/다운로드는 주황, 오류/드롭 증가는 빨강으로 표시합니다. 활동 표시등은 이벤트 감지 후 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를 반복 실행하지 않습니다.
GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다. GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다.
+244 -166
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import sys import sys
import time import time
import socket import os
import shutil
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
@@ -27,8 +28,13 @@ NET_GROUPS = {
"wifi5": ["wlan1"], "wifi5": ["wlan1"],
} }
PC_IP = "192.168.50.73" RPI_SERVICE_UNITS = [
PC_MAC = "74:56:3C:CD:CD:79" ("rpi_docker", "docker.service"),
("rpi_nginx", "nginx.service"),
("rpi_mariadb", "mariadb.service"),
("rpi_control", "control-websocket.service"),
("rpi_fanpolicy", "fanpanel-apply.service"),
]
SYNOLOGY_PING_HOST = "chaegeon.com" SYNOLOGY_PING_HOST = "chaegeon.com"
SYNOLOGY_SSH = [ SYNOLOGY_SSH = [
"/usr/bin/ssh", "/usr/bin/ssh",
@@ -66,6 +72,32 @@ while :; do
$3 == "sata2" {printf "SATA2_READS=%s\nSATA2_WRITES=%s\n", $6, $10} $3 == "sata2" {printf "SATA2_READS=%s\nSATA2_WRITES=%s\n", $6, $10}
$3 == "sata4" {printf "SATA4_READS=%s\nSATA4_WRITES=%s\n", $6, $10} $3 == "sata4" {printf "SATA4_READS=%s\nSATA4_WRITES=%s\n", $6, $10}
' /proc/diskstats ' /proc/diskstats
awk '{printf "LOAD1=%s\nLOAD5=%s\nLOAD15=%s\n", $1, $2, $3}' /proc/loadavg
awk '
/MemTotal:/ {mem_total=$2}
/MemAvailable:/ {mem_avail=$2}
/SwapTotal:/ {swap_total=$2}
/SwapFree:/ {swap_free=$2}
END {
if (mem_total > 0) printf "MEM_USED_PCT=%.0f\n", (mem_total - mem_avail) * 100 / mem_total
if (swap_total > 0) printf "SWAP_USED_PCT=%.0f\n", (swap_total - swap_free) * 100 / swap_total
else printf "SWAP_USED_PCT=0\n"
}
' /proc/meminfo
awk '{printf "UPTIME=%d\n", $1}' /proc/uptime
df -P / /volume1 /volume2 2>/dev/null | awk '
NR > 1 {
key = ""
if ($6 == "/") key = "ROOT"
if ($6 == "/volume1") key = "VOL1"
if ($6 == "/volume2") key = "VOL2"
if (key != "") {
pct = $5
gsub("%", "", pct)
printf "%s_FS_PCT=%s\n", key, pct
}
}
'
echo END=1 echo END=1
sleep 0.1 sleep 0.1
done done
@@ -97,6 +129,7 @@ DISK_TEMP_CACHE_FILE = "/run/control-rpi-disk-temp"
DISK_TEMP_CACHE_MAX_AGE_SECONDS = 30 DISK_TEMP_CACHE_MAX_AGE_SECONDS = 30
RPI_THROTTLE_INTERVAL_MS = SAMPLE_INTERVAL_MS RPI_THROTTLE_INTERVAL_MS = SAMPLE_INTERVAL_MS
SYNOLOGY_HEALTH_INTERVAL_MS = FAN_INTERVAL_MS SYNOLOGY_HEALTH_INTERVAL_MS = FAN_INTERVAL_MS
RPI_SERVICE_INTERVAL_MS = 5000
def byte_rate_unit(values): def byte_rate_unit(values):
@@ -136,6 +169,61 @@ def format_temp_rpm_caption(temp, rpm):
return f"{temp:5.1f} C / {int(rpm):5d} rpm" return f"{temp:5.1f} C / {int(rpm):5d} rpm"
def format_uptime(seconds):
seconds = max(0, int(float(seconds or 0)))
days = seconds // 86400
hours = (seconds % 86400) // 3600
minutes = (seconds % 3600) // 60
if days > 0:
return f"{days}d {hours}h"
if hours > 0:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def load_average():
parts = read_text("/proc/loadavg").split()
try:
return float(parts[0])
except (IndexError, ValueError):
return 0.0
def memory_used_percent(swap=False):
values = {}
for line in read_text("/proc/meminfo").splitlines():
if ":" not in line:
continue
key, rest = line.split(":", 1)
parts = rest.strip().split()
if parts and parts[0].isdigit():
values[key] = int(parts[0])
if swap:
total = values.get("SwapTotal", 0)
free = values.get("SwapFree", 0)
return 0.0 if total <= 0 else (total - free) * 100 / total
total = values.get("MemTotal", 0)
available = values.get("MemAvailable", 0)
return 0.0 if total <= 0 else (total - available) * 100 / total
def disk_used_percent(path):
try:
usage = shutil.disk_usage(path)
except OSError:
return None
return usage.used * 100 / usage.total if usage.total > 0 else None
def uptime_seconds():
try:
return float(read_text("/proc/uptime").split()[0])
except (IndexError, ValueError):
return 0.0
def read_text(path, default=""): def read_text(path, default=""):
try: try:
return Path(path).read_text(encoding="utf-8").strip() return Path(path).read_text(encoding="utf-8").strip()
@@ -252,21 +340,6 @@ def disk_io():
return reads, writes, not found 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): class StatusDot(QWidget):
def __init__(self, active_color): def __init__(self, active_color):
super().__init__() super().__init__()
@@ -389,72 +462,6 @@ class StatusTile(QFrame):
self.error_dot.set_steady(error_on) 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): class Sparkline(QFrame):
def __init__(self, title, color="#38bdf8", second_color=None, y_unit="", right_unit="", auto_byte_unit=False, max_points=120): def __init__(self, title, color="#38bdf8", second_color=None, y_unit="", right_unit="", auto_byte_unit=False, max_points=120):
super().__init__() super().__init__()
@@ -603,16 +610,16 @@ class TouchPanel(QMainWindow):
self.fan_timer.timeout.connect(self.update_fan_status) self.fan_timer.timeout.connect(self.update_fan_status)
self.fan_timer.start(FAN_INTERVAL_MS) 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 = QTimer(self)
self.disk_temp_timer.timeout.connect(self.update_disk_temp) self.disk_temp_timer.timeout.connect(self.update_disk_temp)
self.disk_temp_timer.start(DISK_TEMP_INTERVAL_MS) self.disk_temp_timer.start(DISK_TEMP_INTERVAL_MS)
self.rpi_services_proc = QProcess(self)
self.rpi_services_proc.finished.connect(self.rpi_services_finished)
self.rpi_services_timer = QTimer(self)
self.rpi_services_timer.timeout.connect(self.update_rpi_services)
self.rpi_services_timer.start(RPI_SERVICE_INTERVAL_MS)
self.synology_proc = QProcess(self) self.synology_proc = QProcess(self)
self.synology_proc.readyReadStandardOutput.connect(self.synology_status_ready) self.synology_proc.readyReadStandardOutput.connect(self.synology_status_ready)
self.synology_proc.finished.connect(self.synology_status_finished) self.synology_proc.finished.connect(self.synology_status_finished)
@@ -646,8 +653,8 @@ class TouchPanel(QMainWindow):
self.pulse_timer.start(25) self.pulse_timer.start(25)
self.sample() self.sample()
self.update_fan_status() self.update_fan_status()
self.ping_pc()
self.update_disk_temp() self.update_disk_temp()
self.update_rpi_services()
self.update_synology_status() self.update_synology_status()
self.update_synology_health() self.update_synology_health()
self.ping_synology() self.ping_synology()
@@ -660,85 +667,71 @@ class TouchPanel(QMainWindow):
layout.setContentsMargins(4, 8, 10, 8) layout.setContentsMargins(4, 8, 10, 8)
layout.setSpacing(8) layout.setSpacing(8)
left = QFrame() rpi_rows = [
left.setObjectName("statusPanel") (("io", "SSD I/O"), ("rpi_temp", "TEMP"), ("rpi_fan", "FAN")),
left.setFixedWidth(398) (("rpi_throttle", "THROT"), ("rpi_load", "LOAD"), ("rpi_mem", "MEM")),
left_layout = QVBoxLayout(left) (("rpi_root", "ROOT"), ("rpi_uptime", "UPTIME"), ("local", "LOCAL")),
left_layout.setContentsMargins(6, 8, 6, 8) (("wan", "UPLINK"), ("lan", "ETH1"), ("wifi5", "5G")),
left_layout.setSpacing(6) (("wifi24", "2.4G"), ("rpi_docker", "DOCKER"), ("rpi_nginx", "NGINX")),
title = QLabel("상태") (("rpi_mariadb", "MARIADB"), ("rpi_control", "CONTROL"), ("rpi_fanpolicy", "FANPOL")),
title.setObjectName("sectionTitle") ]
left_layout.addWidget(title) synology_rows = [
(("synology_ping", "PING"), ("synology_temp", "TEMP"), ("synology_fan", "FAN")),
status_grid = QGridLayout() (("synology_guard", "GUARD"), ("synology_pwm", "PWM"), ("synology_load", "LOAD")),
status_grid.setContentsMargins(0, 0, 0, 0) (("synology_mem", "MEM"), ("synology_swap", "SWAP"), ("synology_uptime", "UPTIME")),
status_grid.setHorizontalSpacing(6) (("synology_root", "ROOT"), ("synology_vol1", "VOL1"), ("synology_vol2", "VOL2")),
status_grid.setVerticalSpacing(6) (("synology_local", "LOCAL"), ("synology_wan", "UPLINK"), ("synology_ssh", "SSH")),
pi_title = QLabel("RASPBERRY") (("synology_sata1", "SATA1 I/O"), ("synology_sata2", "SATA2 I/O"), ("synology_sata4", "SATA4 I/O")),
pi_title.setObjectName("statusGroupTitle")
synology_title = QLabel("SYNOLOGY")
synology_title.setObjectName("statusGroupTitle")
extra_title = QLabel("CHECK")
extra_title.setObjectName("statusGroupTitle")
status_grid.addWidget(pi_title, 0, 0)
status_grid.addWidget(synology_title, 0, 1)
status_grid.addWidget(extra_title, 0, 2)
status_rows = [
(("io", "SSD I/O"), ("synology_ping", "PING"), ("rpi_temp", "RPI TEMP")),
(("local", "LOCAL"), ("synology_sata1", "SATA1 I/O"), ("rpi_fan", "RPI FAN")),
(("wan", "UPLINK"), ("synology_sata2", "SATA2 I/O"), ("rpi_throttle", "RPI THROT")),
(("lan", "ETH1"), ("synology_sata4", "SATA4 I/O"), ("synology_temp", "SYNO TEMP")),
(("wifi5", "5G"), ("synology_local", "LOCAL"), ("synology_fan", "SYNO FAN")),
(("wifi24", "2.4G"), ("synology_wan", "UPLINK"), ("synology_guard", "SYNO GUARD")),
] ]
for index, (pi_row, synology_row, extra_row) in enumerate(status_rows, 1):
key, label = pi_row
tile = StatusTile(label)
self.rows[key] = tile
status_grid.addWidget(tile, index, 0)
self.add_status_grid_cell(status_grid, index, 1, synology_row)
self.add_status_grid_cell(status_grid, index, 2, extra_row)
left_layout.addLayout(status_grid)
left_layout.addStretch(1)
self.pc_button = PcWakeButton()
left_layout.addWidget(self.pc_button)
right = QWidget() left = self.build_status_panel("RASPBERRY", rpi_rows, fixed_width=398, bottom_blank=True)
right_layout = QVBoxLayout(right) right = self.build_status_panel("SYNOLOGY", synology_rows)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(8)
header = QHBoxLayout() header = QHBoxLayout()
heading = QLabel("기록")
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(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)
right_layout.addLayout(header) right.layout().insertLayout(1, header)
chart_grid = QGridLayout()
chart_grid.setHorizontalSpacing(8)
chart_grid.setVerticalSpacing(8)
for index, (key, chart) in enumerate([
("rpi_temp", Sparkline("라즈베리파이 온도 / RPM", "#f97316", "#38bdf8", "C", "rpm", max_points=TEMP_GRAPH_POINTS)),
("rpi_wan", Sparkline("라즈베리파이 UPLINK 트래픽", "#f97316", "#22c55e", auto_byte_unit=True, max_points=WAN_GRAPH_POINTS)),
("synology_temp", Sparkline("시놀로지 온도 / RPM", "#f97316", "#38bdf8", "C", "rpm", max_points=TEMP_GRAPH_POINTS)),
("synology_wan", Sparkline("시놀로지 UPLINK 트래픽", "#f97316", "#22c55e", auto_byte_unit=True, max_points=WAN_GRAPH_POINTS)),
]):
self.charts[key] = chart
chart_grid.addWidget(chart, index, 0)
right_layout.addLayout(chart_grid, 1)
layout.addWidget(left) layout.addWidget(left)
layout.addWidget(right, 1) layout.addWidget(right, 1)
self.setCentralWidget(root) self.setCentralWidget(root)
def build_status_panel(self, title_text, rows, fixed_width=None, bottom_blank=False):
panel = QFrame()
panel.setObjectName("statusPanel")
if fixed_width:
panel.setFixedWidth(fixed_width)
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(6, 8, 6, 8)
panel_layout.setSpacing(6)
title = QLabel(title_text)
title.setObjectName("sectionTitle")
panel_layout.addWidget(title)
grid = QGridLayout()
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(6)
grid.setVerticalSpacing(6)
for row_index, row in enumerate(rows):
for column_index, spec in enumerate(row):
self.add_status_grid_cell(grid, row_index, column_index, spec)
panel_layout.addLayout(grid)
panel_layout.addStretch(1)
if bottom_blank:
blank = QWidget()
blank.setFixedHeight(48)
blank.setObjectName("statusEmpty")
panel_layout.addWidget(blank)
return panel
def add_status_grid_cell(self, grid, row, column, spec): def add_status_grid_cell(self, grid, row, column, spec):
if spec is None: if spec is None:
spacer = QWidget() spacer = QWidget()
@@ -795,7 +788,7 @@ class TouchPanel(QMainWindow):
self.render_status(groups, rates, activity, io_read_activity, io_write_activity, disk_error) self.render_status(groups, rates, activity, io_read_activity, io_write_activity, disk_error)
def render_status(self, groups, rates, activity, io_read_activity, io_write_activity, disk_error): def render_status(self, groups, rates, activity, io_read_activity, io_write_activity, disk_error):
disk_temp_text = f"{self.disk_temp:.1f} C" if self.disk_temp is not None else "- C" disk_temp_text = f"{self.disk_temp:.1f} C" if self.disk_temp is not None else ""
self.rows["io"].set_state(disk_temp_text, disk_error, io_read_activity, io_write_activity) self.rows["io"].set_state(disk_temp_text, disk_error, io_read_activity, io_write_activity)
self.rows["local"].set_state(self.iface_detail(groups["local"], "lo"), activity["local"]["error"], activity["local"]["read"], activity["local"]["write"]) self.rows["local"].set_state(self.iface_detail(groups["local"], "lo"), activity["local"]["error"], activity["local"]["read"], activity["local"]["write"])
self.rows["wan"].set_state(self.iface_detail(groups["wan"], self.wan_iface), activity["wan"]["error"], activity["wan"]["read"], activity["wan"]["write"]) self.rows["wan"].set_state(self.iface_detail(groups["wan"], self.wan_iface), activity["wan"]["error"], activity["wan"]["read"], activity["wan"]["write"])
@@ -810,20 +803,38 @@ class TouchPanel(QMainWindow):
self.latest_rpm = rpm self.latest_rpm = rpm
def update_dashboard(self): def update_dashboard(self):
rpm = getattr(self, "latest_rpm", read_int(self.fan["rpm"]))
temp = getattr(self, "latest_temp", 0.0)
self.clock.setText(time.strftime("%H:%M:%S")) self.clock.setText(time.strftime("%H:%M:%S"))
self.update_temp_graphs(temp, rpm) self.render_rpi_system()
def ping_pc(self): def render_rpi_system(self):
if self.pc_ping.state() != QProcess.NotRunning: cores = max(1, os.cpu_count() or 1)
load = load_average()
self.rows["rpi_load"].set_state(
f"{load:.2f}",
read_steady=load < cores * 0.7,
write_steady=cores * 0.7 <= load < cores * 1.2,
error_steady=load >= cores * 1.2,
)
self.set_percent_tile("rpi_mem", memory_used_percent(), warn=80, critical=92)
root_used = disk_used_percent("/")
self.set_percent_tile("rpi_root", root_used, warn=80, critical=92)
self.rows["rpi_uptime"].set_state(format_uptime(uptime_seconds()), read_steady=True, write_steady=False, error_steady=False)
def update_rpi_services(self):
if self.rpi_services_proc.state() != QProcess.NotRunning:
return return
self.pc_ping.start("/usr/bin/ping", ["-c", "1", "-W", "1", PC_IP]) units = [unit for _key, unit in RPI_SERVICE_UNITS]
self.rpi_services_proc.start("/usr/bin/systemctl", ["is-active"] + units)
def pc_ping_finished(self, exit_code, exit_status): def rpi_services_finished(self, exit_code, exit_status):
del exit_status del exit_code, exit_status
self.pc_button.set_online(exit_code == 0) output = bytes(self.rpi_services_proc.readAllStandardOutput()).decode("utf-8", "ignore")
states = output.splitlines()
for index, (key, _unit) in enumerate(RPI_SERVICE_UNITS):
state = states[index].strip() if index < len(states) else "unknown"
active = state == "active"
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_synology(self): def ping_synology(self):
if self.synology_ping_proc.state() != QProcess.NotRunning: if self.synology_ping_proc.state() != QProcess.NotRunning:
@@ -958,7 +969,23 @@ class TouchPanel(QMainWindow):
self.set_synology_offline() self.set_synology_offline()
def set_synology_offline(self): def set_synology_offline(self):
for key in ["synology_sata1", "synology_sata2", "synology_sata4", "synology_wan", "synology_local", "synology_guard"]: for key in [
"synology_sata1",
"synology_sata2",
"synology_sata4",
"synology_wan",
"synology_local",
"synology_guard",
"synology_pwm",
"synology_load",
"synology_mem",
"synology_swap",
"synology_uptime",
"synology_root",
"synology_vol1",
"synology_vol2",
"synology_ssh",
]:
self.rows[key].set_state("-", read_steady=False, write_steady=False, error_steady=True) self.rows[key].set_state("-", read_steady=False, write_steady=False, error_steady=True)
def render_synology_status(self, values): def render_synology_status(self, values):
@@ -988,6 +1015,8 @@ class TouchPanel(QMainWindow):
int(values.get("LOCAL_RX_DROP", "0") or 0) + int(values.get("LOCAL_TX_DROP", "0") or 0), int(values.get("LOCAL_RX_DROP", "0") or 0) + int(values.get("LOCAL_TX_DROP", "0") or 0),
"lo", "lo",
) )
self.render_synology_system(values)
self.rows["synology_ssh"].set_state("OK", read_steady=True, write_steady=False, error_steady=False)
def set_synology_io_tile(self, key, reads, writes, detail): def set_synology_io_tile(self, key, reads, writes, detail):
previous = self.last_synology.get(key) previous = self.last_synology.get(key)
@@ -1039,6 +1068,30 @@ class TouchPanel(QMainWindow):
self.rows["synology_guard"].set_state("FULL", read_steady=False, write_steady=True, error_steady=False) self.rows["synology_guard"].set_state("FULL", read_steady=False, write_steady=True, error_steady=False)
else: else:
self.rows["synology_guard"].set_state("AUTO", read_steady=True, write_steady=False, error_steady=False) self.rows["synology_guard"].set_state("AUTO", read_steady=True, write_steady=False, error_steady=False)
self.rows["synology_pwm"].set_state(
f"{round(pwm * 100 / 255):.0f}%",
read_steady=0 < pwm < 170,
write_steady=170 <= pwm <= 255,
error_steady=pwm <= 0 or enable != 5,
)
def render_synology_system(self, values):
try:
load = float(values.get("LOAD1", "0") or 0)
except ValueError:
load = 0.0
self.rows["synology_load"].set_state(
f"{load:.2f}",
read_steady=load < 2.8,
write_steady=2.8 <= load < 4.8,
error_steady=load >= 4.8,
)
self.set_percent_tile("synology_mem", self.float_value(values.get("MEM_USED_PCT")), warn=80, critical=92)
self.set_percent_tile("synology_swap", self.float_value(values.get("SWAP_USED_PCT")), warn=50, critical=80)
self.set_percent_tile("synology_root", self.float_value(values.get("ROOT_FS_PCT")), warn=80, critical=92)
self.set_percent_tile("synology_vol1", self.float_value(values.get("VOL1_FS_PCT")), warn=80, critical=92)
self.set_percent_tile("synology_vol2", self.float_value(values.get("VOL2_FS_PCT")), warn=80, critical=92)
self.rows["synology_uptime"].set_state(format_uptime(self.float_value(values.get("UPTIME"))), read_steady=True, write_steady=False, error_steady=False)
def render_fan_tile(self, key, rpm): def render_fan_tile(self, key, rpm):
detail = f"{int(rpm):,} rpm" if rpm > 0 else "0 rpm" detail = f"{int(rpm):,} rpm" if rpm > 0 else "0 rpm"
@@ -1077,6 +1130,8 @@ class TouchPanel(QMainWindow):
self.update_synology_wan_graph() self.update_synology_wan_graph()
def update_temp_graphs(self, rpi_temp, rpi_rpm): def update_temp_graphs(self, rpi_temp, rpi_rpm):
if "rpi_temp" not in self.charts or "synology_temp" not in self.charts:
return
synology_temp = getattr(self, "synology_cpu_temp", 0.0) synology_temp = getattr(self, "synology_cpu_temp", 0.0)
synology_rpm = getattr(self, "synology_fan_rpm", 0) synology_rpm = getattr(self, "synology_fan_rpm", 0)
@@ -1097,6 +1152,8 @@ class TouchPanel(QMainWindow):
) )
def update_wan_graph(self): def update_wan_graph(self):
if "rpi_wan" not in self.charts:
return
wan = self.current_rates.get("wan", {"rx": 0.0, "tx": 0.0}) wan = self.current_rates.get("wan", {"rx": 0.0, "tx": 0.0})
self.net_history["wan"]["rx"].append(wan["rx"]) self.net_history["wan"]["rx"].append(wan["rx"])
self.net_history["wan"]["tx"].append(wan["tx"]) self.net_history["wan"]["tx"].append(wan["tx"])
@@ -1107,6 +1164,8 @@ class TouchPanel(QMainWindow):
) )
def update_synology_wan_graph(self): def update_synology_wan_graph(self):
if "synology_wan" not in self.charts:
return
wan = self.current_synology_rates.get("wan", {"rx": 0.0, "tx": 0.0}) wan = self.current_synology_rates.get("wan", {"rx": 0.0, "tx": 0.0})
self.net_history["synology_wan"]["rx"].append(wan["rx"]) self.net_history["synology_wan"]["rx"].append(wan["rx"])
self.net_history["synology_wan"]["tx"].append(wan["tx"]) self.net_history["synology_wan"]["tx"].append(wan["tx"])
@@ -1132,6 +1191,25 @@ class TouchPanel(QMainWindow):
del normal_label del normal_label
return f"D {drops}" return f"D {drops}"
@staticmethod
def float_value(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def set_percent_tile(self, key, percent, warn=80, critical=92):
if percent is None:
self.rows[key].set_state("-", read_steady=False, write_steady=False, error_steady=True)
return
detail = f"{percent:.0f}%"
self.rows[key].set_state(
detail,
read_steady=0 <= percent < warn,
write_steady=warn <= percent < critical,
error_steady=percent >= critical,
)
@staticmethod @staticmethod
def iface_warn(row): def iface_warn(row):
return row["rx_errors"] > 0 or row["tx_errors"] > 0 return row["rx_errors"] > 0 or row["tx_errors"] > 0