diff --git a/README.md b/README.md index 9464cf1..6eb2f72 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,15 @@ 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 파일을 직접 읽습니다. 상태패널의 네트워크/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차 기준입니다. diff --git a/touch_panel/panel.py b/touch_panel/panel.py index 6a101dd..f4099bb 100755 --- a/touch_panel/panel.py +++ b/touch_panel/panel.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 import sys import time -import socket +import os +import shutil from collections import deque from pathlib import Path @@ -27,8 +28,13 @@ 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"), + ("rpi_mariadb", "mariadb.service"), + ("rpi_control", "control-websocket.service"), + ("rpi_fanpolicy", "fanpanel-apply.service"), +] SYNOLOGY_PING_HOST = "chaegeon.com" SYNOLOGY_SSH = [ "/usr/bin/ssh", @@ -66,6 +72,32 @@ while :; do $3 == "sata2" {printf "SATA2_READS=%s\nSATA2_WRITES=%s\n", $6, $10} $3 == "sata4" {printf "SATA4_READS=%s\nSATA4_WRITES=%s\n", $6, $10} ' /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 sleep 0.1 done @@ -97,6 +129,7 @@ DISK_TEMP_CACHE_FILE = "/run/control-rpi-disk-temp" DISK_TEMP_CACHE_MAX_AGE_SECONDS = 30 RPI_THROTTLE_INTERVAL_MS = SAMPLE_INTERVAL_MS SYNOLOGY_HEALTH_INTERVAL_MS = FAN_INTERVAL_MS +RPI_SERVICE_INTERVAL_MS = 5000 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" +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=""): try: return Path(path).read_text(encoding="utf-8").strip() @@ -252,21 +340,6 @@ 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__() @@ -389,72 +462,6 @@ 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__() @@ -603,16 +610,16 @@ 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) + 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.readyReadStandardOutput.connect(self.synology_status_ready) self.synology_proc.finished.connect(self.synology_status_finished) @@ -646,8 +653,8 @@ 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() self.update_synology_health() self.ping_synology() @@ -660,85 +667,71 @@ class TouchPanel(QMainWindow): layout.setContentsMargins(4, 8, 10, 8) layout.setSpacing(8) - left = QFrame() - left.setObjectName("statusPanel") - left.setFixedWidth(398) - left_layout = QVBoxLayout(left) - left_layout.setContentsMargins(6, 8, 6, 8) - left_layout.setSpacing(6) - title = QLabel("상태") - title.setObjectName("sectionTitle") - left_layout.addWidget(title) - - status_grid = QGridLayout() - status_grid.setContentsMargins(0, 0, 0, 0) - status_grid.setHorizontalSpacing(6) - status_grid.setVerticalSpacing(6) - pi_title = QLabel("RASPBERRY") - 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")), + rpi_rows = [ + (("io", "SSD I/O"), ("rpi_temp", "TEMP"), ("rpi_fan", "FAN")), + (("rpi_throttle", "THROT"), ("rpi_load", "LOAD"), ("rpi_mem", "MEM")), + (("rpi_root", "ROOT"), ("rpi_uptime", "UPTIME"), ("local", "LOCAL")), + (("wan", "UPLINK"), ("lan", "ETH1"), ("wifi5", "5G")), + (("wifi24", "2.4G"), ("rpi_docker", "DOCKER"), ("rpi_nginx", "NGINX")), + (("rpi_mariadb", "MARIADB"), ("rpi_control", "CONTROL"), ("rpi_fanpolicy", "FANPOL")), + ] + synology_rows = [ + (("synology_ping", "PING"), ("synology_temp", "TEMP"), ("synology_fan", "FAN")), + (("synology_guard", "GUARD"), ("synology_pwm", "PWM"), ("synology_load", "LOAD")), + (("synology_mem", "MEM"), ("synology_swap", "SWAP"), ("synology_uptime", "UPTIME")), + (("synology_root", "ROOT"), ("synology_vol1", "VOL1"), ("synology_vol2", "VOL2")), + (("synology_local", "LOCAL"), ("synology_wan", "UPLINK"), ("synology_ssh", "SSH")), + (("synology_sata1", "SATA1 I/O"), ("synology_sata2", "SATA2 I/O"), ("synology_sata4", "SATA4 I/O")), ] - 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() - right_layout = QVBoxLayout(right) - right_layout.setContentsMargins(0, 0, 0, 0) - right_layout.setSpacing(8) + left = self.build_status_panel("RASPBERRY", rpi_rows, fixed_width=398, bottom_blank=True) + right = self.build_status_panel("SYNOLOGY", synology_rows) header = QHBoxLayout() - heading = QLabel("기록") - heading.setObjectName("sectionTitle") self.clock = QLabel("--:--:--") self.clock.setObjectName("clock") self.status_text = QLabel("") self.status_text.setObjectName("statusText") - header.addWidget(heading) header.addStretch(1) header.addWidget(self.status_text) header.addWidget(self.clock) - right_layout.addLayout(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) + right.layout().insertLayout(1, header) layout.addWidget(left) layout.addWidget(right, 1) 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): if spec is None: spacer = QWidget() @@ -795,7 +788,7 @@ class TouchPanel(QMainWindow): 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): - 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["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"]) @@ -810,20 +803,38 @@ class TouchPanel(QMainWindow): self.latest_rpm = rpm 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.update_temp_graphs(temp, rpm) + self.render_rpi_system() - def ping_pc(self): - if self.pc_ping.state() != QProcess.NotRunning: + def render_rpi_system(self): + 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 - 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): - del exit_status - self.pc_button.set_online(exit_code == 0) + def rpi_services_finished(self, exit_code, exit_status): + del exit_code, exit_status + 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): if self.synology_ping_proc.state() != QProcess.NotRunning: @@ -958,7 +969,23 @@ class TouchPanel(QMainWindow): self.set_synology_offline() 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) 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), "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): 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) else: 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): detail = f"{int(rpm):,} rpm" if rpm > 0 else "0 rpm" @@ -1077,6 +1130,8 @@ class TouchPanel(QMainWindow): self.update_synology_wan_graph() 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_rpm = getattr(self, "synology_fan_rpm", 0) @@ -1097,6 +1152,8 @@ class TouchPanel(QMainWindow): ) 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}) self.net_history["wan"]["rx"].append(wan["rx"]) self.net_history["wan"]["tx"].append(wan["tx"]) @@ -1107,6 +1164,8 @@ class TouchPanel(QMainWindow): ) 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}) self.net_history["synology_wan"]["rx"].append(wan["rx"]) self.net_history["synology_wan"]["tx"].append(wan["tx"]) @@ -1132,6 +1191,25 @@ class TouchPanel(QMainWindow): del normal_label 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 def iface_warn(row): return row["rx_errors"] > 0 or row["tx_errors"] > 0