From cfd5bb9b94b433ba0e1aa51d5b5cfb22985fe25b Mon Sep 17 00:00:00 2001 From: seo Date: Sun, 2 Aug 2026 00:45:36 +0900 Subject: [PATCH] =?UTF-8?q?=ED=84=B0=EC=B9=98=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EB=94=94=EC=8A=A4=ED=81=AC=20=EC=98=A8=EB=8F=84=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- systemd/control-rpi-disk-temp.service | 14 ++++++ touch_panel/panel.py | 39 +++++++++------- touch_panel/rpi_disk_temp_cache.py | 65 +++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 systemd/control-rpi-disk-temp.service create mode 100755 touch_panel/rpi_disk_temp_cache.py diff --git a/README.md b/README.md index 8d17012..ea0921c 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드 - `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI - `touch_panel/panel.py`: 라즈베리파이 정품 터치 디스플레이용 standalone Qt 대시보드 +- `touch_panel/rpi_disk_temp_cache.py`: SSD 온도를 몇 초 단위로 읽어 `/run/control-rpi-disk-temp`에 캐시하는 터치 패널 보조 서비스 - `touch_panel/control_touch_bridge.php`: 이전 Qt 패널용 로컬 JSON 브리지. 현재 standalone 패널은 사용하지 않습니다. - `touch_panel/control-touch-panel.desktop`: GUI 로그인 후 Qt 패널을 띄우는 데스크톱 autostart 파일 +- `systemd/control-rpi-disk-temp.service`: 터치 패널용 SSD 온도 캐시 서비스 예시 - `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시 - `systemd/control-touch-panel.service`: GUI 세션에서 Qt 관리자 패널을 띄우는 systemd unit 예시 @@ -145,7 +147,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 파일을 직접 읽습니다. 상태패널의 네트워크/I/O 표시와 UPLINK 트래픽 그래프는 50ms 간격으로 갱신하고, 팬 카드와 온도/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를 유지한 채 50ms마다 디스크별 sector I/O, `LOCAL`, `UPLINK` 카운터를 스트림으로 가져오고, 각 SATA 카드 값에는 SMART 온도를 표시합니다. 라즈베리파이 CPU/SSD 온도, 시놀로지 CPU 온도/FAN RPM, 시놀로지 SATA SMART 온도는 500ms 간격으로 캐시해 표시합니다. 왼쪽 표시등은 장식용 heartbeat가 아니라 직전 샘플 대비 현재 카운터 증가량이 0 초과인지에 반응합니다. 각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드은 초록, 쓰기/다운로드는 주황, 오류/드롭 증가는 빨강으로 표시합니다. 활동 표시등은 이벤트 감지 후 120ms 동안 유지하고, 오류 표시등은 2초 동안 유지해 너무 짧은 이벤트도 눈에 보이게 합니다. 네트워크 카드 오른쪽 값은 항상 `D n`으로 드롭 누적을 표시합니다. `SSD I/O`의 값은 `/dev/sda` Samsung T7을 `smartctl -d sntasmedia`로 읽은 디스크 자체 온도입니다. GUI 사용자 `seo`는 `/etc/sudoers.d/control-touch-panel-smartctl`에서 해당 smartctl 명령만 NOPASSWD로 허용합니다. 네트워크 상태값은 정상, 드롭 누적, 꺼짐으로 표시합니다. 상태패널 최하단의 `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 파일을 직접 읽습니다. 상태패널의 네트워크/I/O 표시와 UPLINK 트래픽 그래프는 50ms 간격으로 갱신하고, 팬 카드와 온도/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를 유지한 채 50ms마다 디스크별 sector I/O, `LOCAL`, `UPLINK` 카운터를 스트림으로 가져오고, 각 SATA 카드 값에는 SMART 온도를 표시합니다. 라즈베리파이 CPU/SSD 온도, 시놀로지 CPU 온도/FAN RPM, 시놀로지 SATA SMART 온도는 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분 표시입니다. 오른쪽 `기록` 영역은 라즈베리파이 온도/RPM, 라즈베리파이 UPLINK 트래픽, 시놀로지 온도/RPM, 시놀로지 UPLINK 트래픽 그래프를 순서대로 표시합니다. 온도/RPM 그래프는 최근 60초 기준 500ms 샘플을 사용하고, UPLINK 트래픽 그래프는 최근 10초 기준 50ms 샘플을 사용합니다. 트래픽 속도와 그래프 Y축은 byte/sec 기준으로 `B/s`, `KB/s`, `MB/s`, `GB/s` 중 자동 변환하고, 다운로드 주황/업로드 초록 두 선을 같은 축 범위에 맞춰 그립니다. 그래프 우측 상단 현재값은 고정폭으로 표시합니다. diff --git a/systemd/control-rpi-disk-temp.service b/systemd/control-rpi-disk-temp.service new file mode 100644 index 0000000..ae3f83d --- /dev/null +++ b/systemd/control-rpi-disk-temp.service @@ -0,0 +1,14 @@ +[Unit] +Description=Cache Raspberry Pi SSD temperature for touch panel +After=multi-user.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /var/www/control/touch_panel/rpi_disk_temp_cache.py +Restart=always +RestartSec=3 +StandardOutput=null +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/touch_panel/panel.py b/touch_panel/panel.py index e921f33..de6e5d0 100755 --- a/touch_panel/panel.py +++ b/touch_panel/panel.py @@ -112,6 +112,8 @@ TEMP_GRAPH_POINTS = TEMP_GRAPH_SECONDS * 1000 // DASHBOARD_INTERVAL_MS SYNOLOGY_RESTART_INTERVAL_MS = 1000 TEMP_INTERVAL_MS = 500 DISK_TEMP_INTERVAL_MS = TEMP_INTERVAL_MS +DISK_TEMP_CACHE_FILE = "/run/control-rpi-disk-temp" +DISK_TEMP_CACHE_MAX_AGE_SECONDS = 30 RPI_THROTTLE_INTERVAL_MS = SAMPLE_INTERVAL_MS SYNOLOGY_DISK_TEMP_INTERVAL_MS = TEMP_INTERVAL_MS SYNOLOGY_HEALTH_INTERVAL_MS = FAN_INTERVAL_MS @@ -171,6 +173,25 @@ def cpu_temp(): return round(raw / 1000, 1) if raw > 0 else 0.0 +def cached_disk_temp(): + values = {} + for line in read_text(DISK_TEMP_CACHE_FILE).splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + + try: + updated = float(values.get("UPDATED", "0") or 0) + temp = float(values.get("TEMP", "")) + except ValueError: + return None + + if updated <= 0 or (time.time() - updated) > DISK_TEMP_CACHE_MAX_AGE_SECONDS: + return None + return temp + + def fan_paths(): for pwm in Path("/sys/devices/platform/cooling_fan/hwmon").glob("hwmon*/pwm1"): base = pwm.parent @@ -609,8 +630,6 @@ class TouchPanel(QMainWindow): self.pc_timer.timeout.connect(self.ping_pc) self.pc_timer.start(1000) - self.disk_temp_proc = QProcess(self) - self.disk_temp_proc.finished.connect(self.disk_temp_finished) 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) @@ -913,22 +932,8 @@ class TouchPanel(QMainWindow): def update_disk_temp(self): self.latest_temp = cpu_temp() + self.disk_temp = cached_disk_temp() self.render_rpi_temp(self.latest_temp) - if self.disk_temp_proc.state() != QProcess.NotRunning: - return - self.disk_temp_proc.start("/usr/bin/sudo", ["-n", "/usr/sbin/smartctl", "-A", "-d", "sntasmedia", "/dev/sda"]) - - def disk_temp_finished(self, exit_code, exit_status): - del exit_code, exit_status - output = bytes(self.disk_temp_proc.readAllStandardOutput()).decode("utf-8", "ignore") - for line in output.splitlines(): - if line.strip().startswith("Temperature:"): - parts = line.split() - for part in parts: - if part.isdigit(): - self.disk_temp = float(part) - self.render_rpi_temp(getattr(self, "latest_temp", 0.0)) - return def update_synology_status(self): if self.synology_proc.state() != QProcess.NotRunning: diff --git a/touch_panel/rpi_disk_temp_cache.py b/touch_panel/rpi_disk_temp_cache.py new file mode 100755 index 0000000..5395d92 --- /dev/null +++ b/touch_panel/rpi_disk_temp_cache.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import os +import re +import subprocess +import time +from pathlib import Path + + +CACHE_FILE = Path("/run/control-rpi-disk-temp") +DISK = "/dev/sda" +INTERVAL_SECONDS = 5 +SMARTCTL = "/usr/sbin/smartctl" + + +def parse_temp(output): + for line in output.splitlines(): + stripped = line.strip() + if stripped.startswith("Temperature:"): + match = re.search(r"([0-9]+(?:\.[0-9]+)?)", stripped) + if match: + return float(match.group(1)) + if "Temperature_Celsius" in stripped or "Airflow_Temperature_Cel" in stripped: + parts = stripped.split() + for part in reversed(parts): + if part.isdigit(): + return float(part) + return None + + +def read_temp(): + result = subprocess.run( + [SMARTCTL, "-A", "-d", "sntasmedia", DISK], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=4, + check=False, + ) + if result.returncode not in (0, 4, 64): + return None + return parse_temp(result.stdout) + + +def write_cache(temp): + tmp = CACHE_FILE.with_suffix(".tmp") + if temp is None: + content = f"UPDATED={time.time():.3f}\nTEMP=\nOK=0\n" + else: + content = f"UPDATED={time.time():.3f}\nTEMP={temp:.1f}\nOK=1\n" + tmp.write_text(content, encoding="utf-8") + os.replace(tmp, CACHE_FILE) + os.chmod(CACHE_FILE, 0o644) + + +def main(): + while True: + try: + write_cache(read_temp()) + except Exception: + write_cache(None) + time.sleep(INTERVAL_SECONDS) + + +if __name__ == "__main__": + main()