diff --git a/README.md b/README.md index 5e60501..996e9cf 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,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 파일을 직접 읽습니다. 상태패널과 팬패널은 120ms 간격으로 갱신하고, 그래프는 500ms 간격으로 샘플링합니다. 왼쪽 `DISK I/O`, `WAN`, `LAN`, `2.4G`, `5G` 표시등은 장식용 heartbeat가 아니라 실제 커널 카운터 증가에 반응합니다. 각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드는 초록, 쓰기/다운로드는 주황, 오류는 빨강으로 표시합니다. 활동이 없으면 초록/주황은 회색입니다. 상태패널 최하단 왼쪽의 `PC` 표시등은 `192.168.50.73`에 1초마다 ping을 보내 켜짐 여부를 표시하고, 꺼져 있을 때 누르면 `74:56:3C:CD:CD:79`로 WOL magic packet을 보냅니다. +패널은 Control PHP/API/DB snapshot을 호출하지 않는 standalone 방식입니다. Python이 `/sys/class/net`, `/proc/diskstats`, `/sys/class/thermal`, 팬 hwmon 파일을 직접 읽습니다. 상태패널과 팬패널은 120ms 간격으로 갱신하고, 그래프는 500ms 간격으로 샘플링합니다. 왼쪽 `DISK I/O`, `WAN`, `LAN`, `2.4G`, `5G` 표시등은 장식용 heartbeat가 아니라 실제 커널 카운터 증가에 반응합니다. 각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드는 초록, 쓰기/다운로드는 주황, 오류는 빨강으로 표시합니다. 활동이 없으면 초록/주황은 회색입니다. `DISK I/O`의 값은 `/dev/sda` Samsung T7을 `smartctl -d sntasmedia`로 읽은 디스크 자체 온도입니다. 네트워크 상태값은 정상, 드롭 누적, 꺼짐으로 표시합니다. 상태패널 최하단의 `PC` 타일은 `192.168.50.73`에 1초마다 ping을 보내 켜짐 여부를 표시하고, 꺼져 있을 때 누르면 `74:56:3C:CD:CD:79`로 WOL magic packet을 보냅니다. WOL은 한 번 누르면 30초 동안 추가 전송을 막습니다. -오른쪽에는 팬 RPM, CPU 온도, PWM, WAN 실시간 속도와 온도/RPM, WAN 트래픽 그래프를 표시합니다. 팬 버튼과 슬라이더는 Control DB를 거치지 않고 팬 hwmon sysfs에 직접 쓰기를 시도합니다. GUI 사용자의 sysfs 쓰기 권한이 없으면 대시보드는 계속 동작하되 `팬 쓰기 권한 없음`을 표시합니다. 팬 모드 표시는 hwmon `pwm1_enable` 기준이므로 Control DB가 auto여도 fan policy가 sysfs에 직접 PWM을 쓰는 순간 하드웨어 상태는 manual처럼 보일 수 있습니다. +오른쪽에는 팬 RPM, CPU 온도, PWM, WAN 실시간 속도와 온도/RPM, WAN 트래픽 그래프를 표시합니다. 그래프는 기존 범례/캡션을 유지하고 Y축에 단위를 함께 표시합니다. 팬 버튼과 슬라이더는 Control DB를 거치지 않고 팬 hwmon sysfs에 직접 쓰기를 시도합니다. GUI 사용자의 sysfs 쓰기 권한이 없으면 대시보드는 계속 동작하되 `팬 쓰기 권한 없음`을 표시합니다. 팬 모드 표시는 hwmon `pwm1_enable` 기준이므로 Control DB가 auto여도 fan policy가 sysfs에 직접 PWM을 쓰는 순간 하드웨어 상태는 manual처럼 보일 수 있습니다. GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다. diff --git a/touch_panel/panel.py b/touch_panel/panel.py index 07982d0..86c38d0 100755 --- a/touch_panel/panel.py +++ b/touch_panel/panel.py @@ -132,6 +132,11 @@ def disk_io(): def write_sys(path, value): if not path: return False + try: + Path(path).write_text(str(value) + "\n", encoding="utf-8") + return True + except OSError: + return False def send_magic_packet(mac): @@ -147,11 +152,6 @@ def send_magic_packet(mac): return True except OSError: return False - try: - Path(path).write_text(str(value) + "\n", encoding="utf-8") - return True - except OSError: - return False class StatusDot(QWidget): @@ -236,29 +236,32 @@ class StatusTile(QFrame): self.error_dot.tick() -class PcWakeButton(QFrame): +class PcWakeButton(StatusTile): def __init__(self): - super().__init__() - self.setObjectName("pcWake") + super().__init__("PC") self.online = False - self.dot = StatusDot("#16a34a") - self.label = QLabel("PC") - self.label.setObjectName("pcLabel") - layout = QHBoxLayout(self) - layout.setContentsMargins(5, 4, 5, 4) - layout.setSpacing(5) - layout.addWidget(self.dot) - layout.addWidget(self.label) - layout.addStretch(1) - self.setFixedHeight(28) + self.last_wake_at = 0.0 def set_online(self, online): self.online = online - self.dot.set_steady(online) + self.read_dot.set_steady(online) + self.error_dot.set_steady(False) + if online: + self.detail.setText("켜짐") + elif time.monotonic() - self.last_wake_at < 30: + remaining = int(30 - (time.monotonic() - self.last_wake_at)) + self.detail.setText(f"대기 {remaining}s") + else: + self.detail.setText("꺼짐") def mousePressEvent(self, event): - if event.button() == Qt.LeftButton and not self.online: - send_magic_packet(PC_MAC) + if event.button() == Qt.LeftButton: + self.write_dot.blink() + if not self.online and time.monotonic() - self.last_wake_at >= 30: + self.last_wake_at = time.monotonic() + self.detail.setText("깨우는 중") + if not send_magic_packet(PC_MAC): + self.error_dot.set_steady(True) super().mousePressEvent(event) @@ -281,11 +284,12 @@ class MetricCard(QFrame): class Sparkline(QFrame): - def __init__(self, title, color="#38bdf8", second_color=None): + def __init__(self, title, color="#38bdf8", second_color=None, y_unit=""): super().__init__() self.setObjectName("chartCard") self.color = QColor(color) self.second_color = QColor(second_color) if second_color else None + self.y_unit = y_unit self.values = [] self.second_values = [] self.title = QLabel(title) @@ -316,7 +320,7 @@ class Sparkline(QFrame): del event painter = QPainter(self.canvas) painter.setRenderHint(QPainter.Antialiasing) - rect = self.canvas.rect().adjusted(2, 4, -2, -4) + rect = self.canvas.rect().adjusted(34, 4, -2, -13) painter.fillRect(rect, QColor("#0f172a")) painter.setPen(QPen(QColor("#263650"), 1)) painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom()) @@ -327,10 +331,22 @@ class Sparkline(QFrame): high = max(all_values) if high <= low: high = low + 1 + painter.setPen(QColor("#93a4bd")) + painter.drawText(1, rect.top() + 10, self.axis_text(high)) + painter.drawText(1, rect.bottom(), self.axis_text(low)) self.draw_series(painter, rect, self.values, low, high, self.color) if self.second_color and self.second_values: self.draw_series(painter, rect, self.second_values, low, high, self.second_color) + def axis_text(self, value): + if abs(value) >= 100: + text = f"{value:.0f}" + elif abs(value) >= 10: + text = f"{value:.1f}" + else: + text = f"{value:.2f}" + return f"{text}{self.y_unit}" + @staticmethod def draw_series(painter, rect, values, low, high, color): path = QPainterPath() @@ -358,6 +374,7 @@ class TouchPanel(QMainWindow): self.last_sample = None self.last_graph = None self.current_rates = {"wan": {"rx": 0.0, "tx": 0.0}} + self.disk_temp = None self.current_pwm = read_int(self.fan["pwm"]) self.temp_history = deque(maxlen=80) self.rpm_history = deque(maxlen=80) @@ -382,11 +399,18 @@ 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(5000) + self.pulse_timer = QTimer(self) self.pulse_timer.timeout.connect(self.tick_pulses) self.pulse_timer.start(70) self.sample() self.ping_pc() + self.update_disk_temp() def _build_ui(self): root = QWidget() @@ -410,7 +434,7 @@ class TouchPanel(QMainWindow): left_layout.addWidget(tile) left_layout.addStretch(1) self.pc_button = PcWakeButton() - left_layout.addWidget(self.pc_button, 0, Qt.AlignLeft | Qt.AlignBottom) + left_layout.addWidget(self.pc_button) right = QWidget() right_layout = QVBoxLayout(right) @@ -469,8 +493,8 @@ class TouchPanel(QMainWindow): chart_grid.setHorizontalSpacing(8) chart_grid.setVerticalSpacing(8) for index, (key, chart) in enumerate([ - ("temp", Sparkline("온도 / RPM", "#f97316", "#38bdf8")), - ("wan", Sparkline("WAN 트래픽", "#38bdf8", "#22c55e")), + ("temp", Sparkline("온도 / RPM", "#f97316", "#38bdf8", "C")), + ("wan", Sparkline("WAN 트래픽", "#38bdf8", "#22c55e", "M")), ]): self.charts[key] = chart chart_grid.addWidget(chart, index, 0) @@ -519,7 +543,8 @@ class TouchPanel(QMainWindow): self.render(temp, rpm, pwm, percent, enable, groups, rates, activity, io_read_activity, io_write_activity, disk_error) def render(self, temp, rpm, pwm, percent, enable, groups, rates, activity, io_read_activity, io_write_activity, disk_error): - self.rows["io"].set_state(f"{temp:.1f} C", disk_error, io_read_activity, io_write_activity) + 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"]), self.iface_warn(groups["wan"]), activity["wan"]["read"], activity["wan"]["write"]) self.rows["lan"].set_state(self.iface_detail(groups["lan"]), self.iface_warn(groups["lan"]), activity["lan"]["read"], activity["lan"]["write"]) self.rows["wifi24"].set_state(self.iface_detail(groups["wifi24"]), self.iface_warn(groups["wifi24"]), activity["wifi24"]["read"], activity["wifi24"]["write"]) @@ -550,6 +575,22 @@ class TouchPanel(QMainWindow): del exit_status self.pc_button.set_online(exit_code == 0) + def update_disk_temp(self): + if self.disk_temp_proc.state() != QProcess.NotRunning: + return + self.disk_temp_proc.start("/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) + return + def update_graphs(self): temp = getattr(self, "latest_temp", cpu_temp()) rpm = getattr(self, "latest_rpm", read_int(self.fan["rpm"])) @@ -592,9 +633,9 @@ class TouchPanel(QMainWindow): @staticmethod def iface_detail(row): if not row["carrier"]: - return "down" + return "꺼짐" drops = row["rx_dropped"] + row["tx_dropped"] - return f"up D{drops}" + return "정상" if drops <= 0 else f"드롭 {drops}" @staticmethod def iface_warn(row):