From 11e29d995446bb448fdd6f6e900bc5a9b964bafc Mon Sep 17 00:00:00 2001 From: seo Date: Sat, 1 Aug 2026 11:48:23 +0900 Subject: [PATCH] =?UTF-8?q?=ED=84=B0=EC=B9=98=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- touch_panel/panel.py | 157 +++++++++++++++++-------------------------- 2 files changed, 65 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 54bd1a5..8d17012 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,9 @@ 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 표시는 50ms 간격으로 갱신하고, 팬 카드와 오른쪽 대시보드 카드/그래프는 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`의 값은 `/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분 표시입니다. -오른쪽 상단 카드는 팬 RPM, CPU 온도, PWM 퍼센트, 메모리 사용률을 표시합니다. 아래에는 최근 60초 기준 온도/RPM 그래프와 최근 10초 기준 WAN 트래픽 그래프를 표시합니다. 온도/RPM 그래프는 500ms 샘플을 사용하고, WAN 그래프는 50ms 샘플을 사용합니다. WAN 속도와 그래프 Y축은 byte/sec 기준으로 `B/s`, `KB/s`, `MB/s`, `GB/s` 중 자동 변환하고, 다운로드 주황/업로드 초록 두 선을 같은 축 범위에 맞춰 그립니다. 그래프 우측 상단 현재값은 고정폭으로 표시합니다. 위 그래프는 좌측 Y축에 온도, 우측 Y축에 RPM 단위를 표시하고, 아래 WAN 그래프는 기존 형태를 유지합니다. 팬 auto/manual/off 버튼은 없고 PWM 슬라이더는 현재값 위치만 보여 주며 터치 제어는 받지 않습니다. +오른쪽 `기록` 영역은 라즈베리파이 온도/RPM, 라즈베리파이 UPLINK 트래픽, 시놀로지 온도/RPM, 시놀로지 UPLINK 트래픽 그래프를 순서대로 표시합니다. 온도/RPM 그래프는 최근 60초 기준 500ms 샘플을 사용하고, UPLINK 트래픽 그래프는 최근 10초 기준 50ms 샘플을 사용합니다. 트래픽 속도와 그래프 Y축은 byte/sec 기준으로 `B/s`, `KB/s`, `MB/s`, `GB/s` 중 자동 변환하고, 다운로드 주황/업로드 초록 두 선을 같은 축 범위에 맞춰 그립니다. 그래프 우측 상단 현재값은 고정폭으로 표시합니다. GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다. diff --git a/touch_panel/panel.py b/touch_panel/panel.py index 2d25479..e921f33 100755 --- a/touch_panel/panel.py +++ b/touch_panel/panel.py @@ -14,7 +14,6 @@ from PyQt5.QtWidgets import ( QHBoxLayout, QLabel, QMainWindow, - QSlider, QVBoxLayout, QWidget, ) @@ -172,22 +171,6 @@ def cpu_temp(): return round(raw / 1000, 1) if raw > 0 else 0.0 -def memory_percent(): - rows = {} - for line in read_text("/proc/meminfo").splitlines(): - if ":" not in line: - continue - key, value = line.split(":", 1) - parts = value.strip().split() - if parts and parts[0].isdigit(): - rows[key] = int(parts[0]) - total = rows.get("MemTotal", 0) - available = rows.get("MemAvailable", 0) - if total <= 0: - return 0.0 - return round((total - available) / total * 100, 1) - - def fan_paths(): for pwm in Path("/sys/devices/platform/cooling_fan/hwmon").glob("hwmon*/pwm1"): base = pwm.parent @@ -471,24 +454,6 @@ class PcWakeButton(StatusTile): self.error_dot.tick() -class MetricCard(QFrame): - def __init__(self, title): - super().__init__() - self.setObjectName("metricCard") - self.title = QLabel(title) - self.title.setObjectName("metricTitle") - self.value = QLabel("-") - self.value.setObjectName("metricValue") - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 8, 10, 8) - layout.setSpacing(2) - layout.addWidget(self.title) - layout.addWidget(self.value) - - def set_value(self, text): - self.value.setText(text) - - 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__() @@ -592,13 +557,13 @@ class TouchPanel(QMainWindow): def __init__(self): super().__init__() self.rows = {} - self.metrics = {} self.charts = {} self.fan = fan_paths() self.wan_iface = default_iface() NET_GROUPS["wan"] = [self.wan_iface] self.last_net = {} self.last_synology = {} + self.last_synology_net_rate = {} self.synology_buffer = "" self.synology_values = {} self.synology_disk_temps = {} @@ -608,17 +573,20 @@ class TouchPanel(QMainWindow): self.last_sample = None self.last_graph = None self.current_rates = {"wan": {"rx": 0.0, "tx": 0.0}} + self.current_synology_rates = {"wan": {"rx": 0.0, "tx": 0.0}} self.disk_temp = None - self.current_pwm = read_int(self.fan["pwm"]) self.latest_temp = 0.0 self.latest_rpm = 0 - self.latest_pwm = self.current_pwm - self.latest_percent = 0.0 - self.temp_history = deque(maxlen=TEMP_GRAPH_POINTS) - self.rpm_history = deque(maxlen=TEMP_GRAPH_POINTS) - self.net_history = {"wan": {"rx": deque(maxlen=WAN_GRAPH_POINTS), "tx": deque(maxlen=WAN_GRAPH_POINTS)}} + self.rpi_temp_history = deque(maxlen=TEMP_GRAPH_POINTS) + self.rpi_rpm_history = deque(maxlen=TEMP_GRAPH_POINTS) + self.synology_temp_history = deque(maxlen=TEMP_GRAPH_POINTS) + self.synology_rpm_history = deque(maxlen=TEMP_GRAPH_POINTS) + self.net_history = { + "wan": {"rx": deque(maxlen=WAN_GRAPH_POINTS), "tx": deque(maxlen=WAN_GRAPH_POINTS)}, + "synology_wan": {"rx": deque(maxlen=WAN_GRAPH_POINTS), "tx": deque(maxlen=WAN_GRAPH_POINTS)}, + } - self.setWindowTitle("Standalone Touch Dashboard") + self.setWindowTitle("Standalone Touch Records") self.setCursor(Qt.BlankCursor) self.setMinimumSize(800, 480) self._build_ui() @@ -751,7 +719,7 @@ class TouchPanel(QMainWindow): right_layout.setSpacing(8) header = QHBoxLayout() - heading = QLabel("Dashboard") + heading = QLabel("기록") heading.setObjectName("sectionTitle") self.clock = QLabel("--:--:--") self.clock.setObjectName("clock") @@ -763,30 +731,14 @@ class TouchPanel(QMainWindow): header.addWidget(self.clock) right_layout.addLayout(header) - metric_grid = QGridLayout() - metric_grid.setHorizontalSpacing(8) - metric_grid.setVerticalSpacing(8) - for index, (key, title) in enumerate([("rpm", "팬 RPM"), ("temp", "CPU 온도"), ("pwm", "PWM"), ("mem", "메모리")]): - card = MetricCard(title) - self.metrics[key] = card - metric_grid.addWidget(card, 0, index) - right_layout.addLayout(metric_grid) - - slider_wrap = QHBoxLayout() - self.slider = QSlider(Qt.Horizontal) - self.slider.setRange(0, 255) - self.slider.setValue(self.current_pwm) - self.slider.setFocusPolicy(Qt.NoFocus) - self.slider.setAttribute(Qt.WA_TransparentForMouseEvents, True) - slider_wrap.addWidget(self.slider, 1) - right_layout.addLayout(slider_wrap) - chart_grid = QGridLayout() chart_grid.setHorizontalSpacing(8) chart_grid.setVerticalSpacing(8) for index, (key, chart) in enumerate([ - ("temp", Sparkline("온도 / RPM", "#f97316", "#38bdf8", "C", "rpm", max_points=TEMP_GRAPH_POINTS)), - ("wan", Sparkline("WAN 트래픽", "#f97316", "#22c55e", auto_byte_unit=True, max_points=WAN_GRAPH_POINTS)), + ("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) @@ -862,32 +814,16 @@ class TouchPanel(QMainWindow): def update_fan_status(self): rpm = read_int(self.fan["rpm"]) - pwm = read_int(self.fan["pwm"]) - percent = pwm / 255 * 100 if pwm > 0 else 0 self.render_rpi_fan(rpm) self.latest_rpm = rpm - self.latest_pwm = pwm - self.latest_percent = percent def update_dashboard(self): rpm = getattr(self, "latest_rpm", read_int(self.fan["rpm"])) temp = getattr(self, "latest_temp", 0.0) - pwm = getattr(self, "latest_pwm", read_int(self.fan["pwm"])) - percent = getattr(self, "latest_percent", pwm / 255 * 100 if pwm > 0 else 0) - mem_percent = memory_percent() - - self.metrics["rpm"].set_value(f"{rpm:,}") - self.metrics["temp"].set_value(f"{temp:.1f} C") - self.metrics["pwm"].set_value(f"{percent:.1f}%") - self.metrics["mem"].set_value(f"{mem_percent:.1f}%") - - self.slider.blockSignals(True) - self.slider.setValue(pwm) - self.slider.blockSignals(False) self.clock.setText(time.strftime("%H:%M:%S")) - self.update_temp_graph() + self.update_temp_graphs(temp, rpm) def ping_pc(self): if self.pc_ping.state() != QProcess.NotRunning: @@ -1167,24 +1103,62 @@ class TouchPanel(QMainWindow): write_activity = previous is not None and rx > previous[0] error_activity = previous is not None and (errors > previous[2] or drops > previous[3]) self.rows[key].set_state(self.drop_detail(drops, detail), error_activity, read_activity, write_activity) + if key == "synology_wan": + self.update_synology_wan_rate(rx, tx) - def update_temp_graph(self): - temp = getattr(self, "latest_temp", 0.0) - rpm = getattr(self, "latest_rpm", read_int(self.fan["rpm"])) - self.temp_history.append(temp) - self.rpm_history.append(rpm) - self.charts["temp"].set_values(self.temp_history, format_temp_rpm_caption(temp, rpm), self.rpm_history) + def update_synology_wan_rate(self, rx, tx): + now = time.monotonic() + previous = self.last_synology_net_rate.get("wan") + if previous is not None: + prev_rx, prev_tx, prev_time = previous + dt = max(0.001, now - prev_time) + self.current_synology_rates["wan"] = { + "rx": max(0.0, (rx - prev_rx) / dt), + "tx": max(0.0, (tx - prev_tx) / dt), + } + self.last_synology_net_rate["wan"] = (rx, tx, now) + self.update_synology_wan_graph() + + def update_temp_graphs(self, rpi_temp, rpi_rpm): + synology_temp = getattr(self, "synology_cpu_temp", 0.0) + synology_rpm = getattr(self, "synology_fan_rpm", 0) + + self.rpi_temp_history.append(rpi_temp) + self.rpi_rpm_history.append(rpi_rpm) + self.charts["rpi_temp"].set_values( + self.rpi_temp_history, + format_temp_rpm_caption(rpi_temp, rpi_rpm), + self.rpi_rpm_history, + ) + + self.synology_temp_history.append(synology_temp) + self.synology_rpm_history.append(synology_rpm) + self.charts["synology_temp"].set_values( + self.synology_temp_history, + format_temp_rpm_caption(synology_temp, synology_rpm), + self.synology_rpm_history, + ) def update_wan_graph(self): 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"]) - self.charts["wan"].set_values( + self.charts["rpi_wan"].set_values( self.net_history["wan"]["rx"], format_wan_caption(wan["rx"], wan["tx"]), self.net_history["wan"]["tx"], ) + def update_synology_wan_graph(self): + 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"]) + self.charts["synology_wan"].set_values( + self.net_history["synology_wan"]["rx"], + format_wan_caption(wan["rx"], wan["tx"]), + self.net_history["synology_wan"]["tx"], + ) + def tick_pulses(self): for row in self.rows.values(): row.tick() @@ -1220,16 +1194,11 @@ def main(): #statusDetail { font-size: 9px; 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; } + #chartCard { background: #111a2e; border: 1px solid #27364f; border-radius: 12px; } #chartTitle { color: #dce8f8; font-size: 14px; font-weight: 700; background: transparent; } #chartCaption { color: #93a4bd; font-size: 11px; background: transparent; font-family: "DejaVu Sans Mono", monospace; } #clock { font-size: 18px; color: #8fb3ff; font-weight: 700; } #statusText { color: #93a4bd; font-size: 13px; background: transparent; } - QSlider::groove:horizontal { height: 10px; background: #263650; border-radius: 5px; } - QSlider::sub-page:horizontal { background: #22c55e; border-radius: 5px; } - QSlider::handle:horizontal { width: 28px; margin: -10px 0; border-radius: 14px; background: #f8fafc; } """ ) win = TouchPanel()