diff --git a/README.md b/README.md index 8eb94bf..20652e5 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,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 파일을 직접 읽습니다. 상태패널, 팬패널, 오른쪽 대시보드 카드/그래프는 50ms 간격으로 갱신합니다. 왼쪽 `DISK I/O`, `WAN`, `LAN`, `2.4G`, `5G` 표시등은 장식용 heartbeat가 아니라 직전 샘플 대비 현재 카운터 증가량이 0 초과인지에 반응합니다. 각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드은 초록, 쓰기/다운로드는 주황, 오류/드롭 증가는 빨강으로 표시합니다. 활동 표시등은 이벤트 감지 후 120ms 동안 유지하고, 오류 표시등은 2초 동안 유지해 너무 짧은 이벤트도 눈에 보이게 합니다. `DISK I/O`의 값은 `/dev/sda` Samsung T7을 `smartctl -d sntasmedia`로 1초마다 읽은 디스크 자체 온도입니다. 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 파일을 직접 읽습니다. 상태패널, 팬패널, 오른쪽 대시보드 카드/그래프는 50ms 간격으로 갱신합니다. 왼쪽 상태패널은 `RASPBERRY`와 `SYNOLOGY` 2열이며, 라즈베리파이 순서는 `DISK I/O`, `LOCAL`, `UPLINK`, `ETH1`, `5G`, `2.4G`입니다. 시놀로지 열은 정상 디스크 `SATA1`, `SATA2`, `SATA4`를 개별 카드로 표시한 뒤 `LOCAL`, `UPLINK`를 표시합니다. `LOCAL`은 `lo`, `UPLINK`는 기본 라우트 인터페이스 기준입니다. 라즈베리파이 `ETH1`은 컴퓨터와 연결된 물리 포트 단독 카운터이며 `br0`는 합산하지 않습니다. 시놀로지는 SSH를 유지한 채 50ms마다 디스크별 sector I/O, `LOCAL`, `UPLINK` 카운터를 스트림으로 가져오고, 각 SATA 카드 값에는 SMART 온도를 표시합니다. 왼쪽 표시등은 장식용 heartbeat가 아니라 직전 샘플 대비 현재 카운터 증가량이 0 초과인지에 반응합니다. 각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드은 초록, 쓰기/다운로드는 주황, 오류/드롭 증가는 빨강으로 표시합니다. 활동 표시등은 이벤트 감지 후 120ms 동안 유지하고, 오류 표시등은 2초 동안 유지해 너무 짧은 이벤트도 눈에 보이게 합니다. `DISK I/O`의 값은 `/dev/sda` Samsung T7을 `smartctl -d sntasmedia`로 1초마다 읽은 디스크 자체 온도입니다. 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 퍼센트, 메모리 사용률을 표시합니다. 아래에는 최근 30초 기준 온도/RPM 그래프와 WAN 트래픽 그래프를 표시합니다. WAN 속도와 그래프 Y축은 byte/sec 기준으로 `B/s`, `KB/s`, `MB/s`, `GB/s` 중 자동 변환하고, 그래프 우측 상단 현재값은 고정폭으로 표시합니다. 위 그래프는 좌측 Y축에 온도, 우측 Y축에 RPM 단위를 표시하고, 아래 WAN 그래프는 기존 형태를 유지합니다. 팬 auto/manual/off 버튼은 없고 PWM 슬라이더는 현재값 위치만 보여 주며 터치 제어는 받지 않습니다. diff --git a/touch_panel/panel.py b/touch_panel/panel.py index 109cf2a..a7d1fa4 100755 --- a/touch_panel/panel.py +++ b/touch_panel/panel.py @@ -21,17 +21,80 @@ from PyQt5.QtWidgets import ( NET_GROUPS = { + "local": ["lo"], "wan": ["eth0"], - "lan": ["br0", "eth1"], + "lan": ["eth1"], "wifi24": ["wlan0"], "wifi5": ["wlan1"], } PC_IP = "192.168.50.73" PC_MAC = "74:56:3C:CD:CD:79" +SYNOLOGY_SSH = [ + "/usr/bin/ssh", + "-i", + "/home/seo/.ssh/id_ed25519", + "-o", + "BatchMode=yes", + "-o", + "IdentitiesOnly=yes", + "-o", + "ConnectTimeout=5", + "-p", + "1026", + "seo@chaegeon.com", +] +SYNOLOGY_STATUS_SCRIPT = r""" +while :; do + wan="$(awk '$2=="00000000"{print $1; exit}' /proc/net/route)" + [ -n "$wan" ] || wan=ovs_eth0 + echo "WAN_IFACE=$wan" + awk -v wan="$wan" ' + NR > 2 { + split($0, row, ":") + iface = row[1] + gsub(/^[ \t]+|[ \t]+$/, "", iface) + data = row[2] + gsub(/^[ \t]+/, "", data) + split(data, f, /[ \t]+/) + } + iface == wan {printf "WAN_RX=%s\nWAN_TX=%s\nWAN_RX_ERR=%s\nWAN_TX_ERR=%s\nWAN_RX_DROP=%s\nWAN_TX_DROP=%s\n", f[1], f[9], f[3], f[11], f[4], f[12]} + iface == "lo" {printf "LOCAL_RX=%s\nLOCAL_TX=%s\nLOCAL_RX_ERR=%s\nLOCAL_TX_ERR=%s\nLOCAL_RX_DROP=%s\nLOCAL_TX_DROP=%s\n", f[1], f[9], f[3], f[11], f[4], f[12]} + ' /proc/net/dev + awk ' + $3 == "sata1" {printf "SATA1_READS=%s\nSATA1_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} + ' /proc/diskstats + echo END=1 + sleep 0.05 +done +""" +SYNOLOGY_DISK_TEMP_SCRIPT = r""" +for disk in /dev/sata1 /dev/sata2 /dev/sata4; do + temp="-" + if [ -b "$disk" ]; then + temp="$( + /usr/bin/timeout 3 sudo -n /usr/bin/smartctl -A "$disk" 2>/dev/null | + awk '/Temperature_Celsius|Airflow_Temperature_Cel/ {print $10; exit}' + )" + case "$temp" in + ''|*[!0-9]*) temp="-" ;; + esac + fi + printf '%s=%s\n' "${disk##/dev/}" "$temp" +done | +awk -F= ' + $1 == "sata1" {printf "SATA1_TEMP=%s\n", $2 ? $2 : "-"} + $1 == "sata2" {printf "SATA2_TEMP=%s\n", $2 ? $2 : "-"} + $1 == "sata4" {printf "SATA4_TEMP=%s\n", $2 ? $2 : "-"} +' +""" SAMPLE_INTERVAL_MS = 50 GRAPH_SECONDS = 30 GRAPH_POINTS = GRAPH_SECONDS * 1000 // SAMPLE_INTERVAL_MS +SYNOLOGY_RESTART_INTERVAL_MS = 1000 +SYNOLOGY_DISK_TEMP_INTERVAL_MS = 10000 def byte_rate_unit(values): @@ -246,16 +309,19 @@ class StatusTile(QFrame): self.last_read_at = 0.0 self.last_write_at = 0.0 self.last_error_at = 0.0 + self.read_steady = None + self.write_steady = None + self.error_steady = None self.title = QLabel(title) self.title.setObjectName("statusTitle") self.detail = QLabel("-") self.detail.setObjectName("statusDetail") self.detail.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self.setFixedHeight(58) + self.setFixedHeight(48) layout = QVBoxLayout(self) - layout.setContentsMargins(6, 5, 6, 5) - layout.setSpacing(2) + layout.setContentsMargins(6, 4, 6, 4) + layout.setSpacing(1) texts = QVBoxLayout() texts.setContentsMargins(0, 0, 0, 0) texts.setSpacing(0) @@ -265,7 +331,7 @@ class StatusTile(QFrame): dots = QHBoxLayout() dots.setContentsMargins(0, 0, 0, 0) - dots.setSpacing(5) + dots.setSpacing(4) dots.addWidget(self.read_dot, 0, Qt.AlignLeft) dots.addWidget(self.write_dot, 0, Qt.AlignLeft) dots.addWidget(self.error_dot, 0, Qt.AlignLeft) @@ -273,8 +339,20 @@ class StatusTile(QFrame): layout.addLayout(dots) self.setObjectName("statusTile") - def set_state(self, detail, error=False, read_activity=False, write_activity=False): + def set_state( + self, + detail, + error=False, + read_activity=False, + write_activity=False, + read_steady=None, + write_steady=None, + error_steady=None, + ): now = time.monotonic() + self.read_steady = read_steady + self.write_steady = write_steady + self.error_steady = error_steady if read_activity: self.last_read_at = now if write_activity: @@ -288,9 +366,12 @@ class StatusTile(QFrame): self.update_dots(time.monotonic()) def update_dots(self, now): - self.read_dot.set_steady(now - self.last_read_at <= self.ACTIVITY_HOLD_SECONDS) - self.write_dot.set_steady(now - self.last_write_at <= self.ACTIVITY_HOLD_SECONDS) - self.error_dot.set_steady(now - self.last_error_at <= self.ERROR_HOLD_SECONDS) + read_on = self.read_steady if self.read_steady is not None else now - self.last_read_at <= self.ACTIVITY_HOLD_SECONDS + write_on = self.write_steady if self.write_steady is not None else now - self.last_write_at <= self.ACTIVITY_HOLD_SECONDS + error_on = self.error_steady if self.error_steady is not None else now - self.last_error_at <= self.ERROR_HOLD_SECONDS + self.read_dot.set_steady(read_on) + self.write_dot.set_steady(write_on) + self.error_dot.set_steady(error_on) class PcWakeButton(StatusTile): @@ -484,6 +565,10 @@ class TouchPanel(QMainWindow): self.wan_iface = default_iface() NET_GROUPS["wan"] = [self.wan_iface] self.last_net = {} + self.last_synology = {} + self.synology_buffer = "" + self.synology_values = {} + self.synology_disk_temps = {} self.last_io = None self.last_sample = None self.last_graph = None @@ -523,12 +608,27 @@ class TouchPanel(QMainWindow): self.disk_temp_timer.timeout.connect(self.update_disk_temp) self.disk_temp_timer.start(1000) + self.synology_proc = QProcess(self) + self.synology_proc.readyReadStandardOutput.connect(self.synology_status_ready) + self.synology_proc.finished.connect(self.synology_status_finished) + self.synology_timer = QTimer(self) + self.synology_timer.timeout.connect(self.update_synology_status) + self.synology_timer.start(SYNOLOGY_RESTART_INTERVAL_MS) + + self.synology_disk_temp_proc = QProcess(self) + self.synology_disk_temp_proc.finished.connect(self.synology_disk_temp_finished) + self.synology_disk_temp_timer = QTimer(self) + self.synology_disk_temp_timer.timeout.connect(self.update_synology_disk_temp) + self.synology_disk_temp_timer.start(SYNOLOGY_DISK_TEMP_INTERVAL_MS) + self.pulse_timer = QTimer(self) self.pulse_timer.timeout.connect(self.tick_pulses) self.pulse_timer.start(25) self.sample() self.ping_pc() self.update_disk_temp() + self.update_synology_status() + self.update_synology_disk_temp() self.update_dashboard() def _build_ui(self): @@ -539,7 +639,7 @@ class TouchPanel(QMainWindow): left = QFrame() left.setObjectName("statusPanel") - left.setFixedWidth(132) + left.setFixedWidth(266) left_layout = QVBoxLayout(left) left_layout.setContentsMargins(6, 8, 6, 8) left_layout.setSpacing(6) @@ -547,10 +647,41 @@ class TouchPanel(QMainWindow): title.setObjectName("sectionTitle") left_layout.addWidget(title) - for key, label in [("io", "DISK I/O"), ("wan", "WAN"), ("lan", "LAN"), ("wifi24", "2.4G"), ("wifi5", "5G")]: + 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") + status_grid.addWidget(pi_title, 0, 0) + status_grid.addWidget(synology_title, 0, 1) + + status_rows = [ + (("io", "DISK I/O"), ("synology_sata1", "SATA1")), + (("local", "LOCAL"), ("synology_sata2", "SATA2")), + (("wan", "UPLINK"), ("synology_sata4", "SATA4")), + (("lan", "ETH1"), ("synology_local", "LOCAL")), + (("wifi5", "5G"), ("synology_wan", "UPLINK")), + (("wifi24", "2.4G"), None), + ] + for index, (pi_row, synology_row) in enumerate(status_rows, 1): + key, label = pi_row tile = StatusTile(label) self.rows[key] = tile - left_layout.addWidget(tile) + status_grid.addWidget(tile, index, 0) + if synology_row is None: + spacer = QWidget() + spacer.setFixedHeight(48) + spacer.setObjectName("statusEmpty") + status_grid.addWidget(spacer, index, 1) + else: + key, label = synology_row + tile = StatusTile(label) + self.rows[key] = tile + status_grid.addWidget(tile, index, 1) + left_layout.addLayout(status_grid) left_layout.addStretch(1) self.pc_button = PcWakeButton() left_layout.addWidget(self.pc_button) @@ -657,10 +788,11 @@ class TouchPanel(QMainWindow): def render_status(self, temp, rpm, pwm, percent, enable, 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" self.rows["io"].set_state(disk_temp_text, disk_error, io_read_activity, io_write_activity) - self.rows["wan"].set_state(self.iface_detail(groups["wan"]), activity["wan"]["error"], activity["wan"]["read"], activity["wan"]["write"]) - self.rows["lan"].set_state(self.iface_detail(groups["lan"]), activity["lan"]["error"], activity["lan"]["read"], activity["lan"]["write"]) - self.rows["wifi24"].set_state(self.iface_detail(groups["wifi24"]), activity["wifi24"]["error"], activity["wifi24"]["read"], activity["wifi24"]["write"]) - self.rows["wifi5"].set_state(self.iface_detail(groups["wifi5"]), activity["wifi5"]["error"], activity["wifi5"]["read"], activity["wifi5"]["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["lan"].set_state(self.iface_detail(groups["lan"], "eth1"), activity["lan"]["error"], activity["lan"]["read"], activity["lan"]["write"]) + self.rows["wifi24"].set_state(self.iface_detail(groups["wifi24"], "wlan0"), activity["wifi24"]["error"], activity["wifi24"]["read"], activity["wifi24"]["write"]) + self.rows["wifi5"].set_state(self.iface_detail(groups["wifi5"], "wlan1"), activity["wifi5"]["error"], activity["wifi5"]["read"], activity["wifi5"]["write"]) self.latest_temp = temp self.latest_rpm = rpm @@ -712,6 +844,102 @@ class TouchPanel(QMainWindow): self.disk_temp = float(part) return + def update_synology_status(self): + if self.synology_proc.state() != QProcess.NotRunning: + return + self.synology_buffer = "" + self.synology_values = {} + self.synology_proc.start(SYNOLOGY_SSH[0], SYNOLOGY_SSH[1:] + [SYNOLOGY_STATUS_SCRIPT]) + + def update_synology_disk_temp(self): + if self.synology_disk_temp_proc.state() != QProcess.NotRunning: + return + self.synology_disk_temp_proc.start(SYNOLOGY_SSH[0], SYNOLOGY_SSH[1:] + [SYNOLOGY_DISK_TEMP_SCRIPT]) + + def synology_disk_temp_finished(self, exit_code, exit_status): + del exit_status + if exit_code != 0: + return + output = bytes(self.synology_disk_temp_proc.readAllStandardOutput()).decode("utf-8", "ignore").strip() + temps = {} + for line in output.splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + temps[key.strip()] = value.strip() + self.synology_disk_temps = temps + + def synology_status_ready(self): + output = bytes(self.synology_proc.readAllStandardOutput()).decode("utf-8", "ignore") + self.synology_buffer += output + while "\n" in self.synology_buffer: + line, self.synology_buffer = self.synology_buffer.split("\n", 1) + if line == "END=1": + self.render_synology_status(self.synology_values) + self.synology_values = {} + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + self.synology_values[key.strip()] = value.strip() + + def synology_status_finished(self, exit_code, exit_status): + del exit_code, exit_status + self.set_synology_offline() + + def set_synology_offline(self): + for key in ["synology_sata1", "synology_sata2", "synology_sata4", "synology_wan", "synology_local"]: + self.rows[key].set_state("-", read_steady=False, write_steady=False, error_steady=True) + + def render_synology_status(self, values): + if not values: + self.set_synology_offline() + return + for disk in ["SATA1", "SATA2", "SATA4"]: + self.set_synology_io_tile( + f"synology_{disk.lower()}", + int(values.get(f"{disk}_READS", "0") or 0), + int(values.get(f"{disk}_WRITES", "0") or 0), + self.synology_disk_temp_detail(disk), + ) + self.set_synology_net_tile( + "synology_wan", + int(values.get("WAN_RX", "0") or 0), + int(values.get("WAN_TX", "0") or 0), + int(values.get("WAN_RX_ERR", "0") or 0) + int(values.get("WAN_TX_ERR", "0") or 0), + int(values.get("WAN_RX_DROP", "0") or 0) + int(values.get("WAN_TX_DROP", "0") or 0), + values.get("WAN_IFACE", "wan"), + ) + self.set_synology_net_tile( + "synology_local", + int(values.get("LOCAL_RX", "0") or 0), + int(values.get("LOCAL_TX", "0") or 0), + int(values.get("LOCAL_RX_ERR", "0") or 0) + int(values.get("LOCAL_TX_ERR", "0") or 0), + int(values.get("LOCAL_RX_DROP", "0") or 0) + int(values.get("LOCAL_TX_DROP", "0") or 0), + "lo", + ) + + def set_synology_io_tile(self, key, reads, writes, detail): + previous = self.last_synology.get(key) + self.last_synology[key] = (reads, writes, 0, 0) + read_activity = previous is not None and reads > previous[0] + write_activity = previous is not None and writes > previous[1] + self.rows[key].set_state(detail, read_activity=read_activity, write_activity=write_activity, error_steady=False) + + def synology_disk_temp_detail(self, disk): + value = self.synology_disk_temps.get(f"{disk}_TEMP", "") + if not value: + return "- C" + return f"{value} C" + + def set_synology_net_tile(self, key, rx, tx, errors, drops, detail): + previous = self.last_synology.get(key) + self.last_synology[key] = (rx, tx, errors, drops) + read_activity = previous is not None and tx > previous[1] + 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(detail, error_activity, read_activity, write_activity) + def update_graphs(self): temp = getattr(self, "latest_temp", cpu_temp()) rpm = getattr(self, "latest_rpm", read_int(self.fan["rpm"])) @@ -733,11 +961,11 @@ class TouchPanel(QMainWindow): row.tick() @staticmethod - def iface_detail(row): + def iface_detail(row, normal_label="정상"): if not row["carrier"]: return "꺼짐" drops = row["rx_dropped"] + row["tx_dropped"] - return "정상" if drops <= 0 else f"드롭 {drops}" + return normal_label if drops <= 0 else f"드롭 {drops}" @staticmethod def iface_warn(row): @@ -752,9 +980,10 @@ def main(): QWidget { background: #0b1220; color: #e5edf7; font-family: "Noto Sans CJK KR", "Noto Sans CJK KR Regular", "Noto Sans KR", sans-serif; } #statusPanel { background: #101827; border-right: 1px solid #263650; } #sectionTitle { font-size: 20px; font-weight: 700; } + #statusGroupTitle { color: #8fa7c5; font-size: 10px; font-weight: 700; background: transparent; } #statusTile { background: #d7d9dc; border: 2px groove #f4f4f4; border-radius: 2px; } #statusTitle { font-size: 13px; font-weight: 700; color: #111827; background: transparent; } - #statusDetail { font-size: 10px; color: #475569; background: transparent; } + #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; }