From 44ed8041214ee18343c3a06f76d423f6ff2b90b1 Mon Sep 17 00:00:00 2001 From: seo Date: Thu, 30 Jul 2026 04:29:49 +0900 Subject: [PATCH] =?UTF-8?q?=ED=84=B0=EC=B9=98=20=ED=8C=A8=EB=84=90=20Qt=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=86=A0=ED=83=80=EC=9E=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++ systemd/control-touch-panel.service | 17 ++ touch_panel/control_touch_bridge.php | 58 +++++ touch_panel/panel.py | 360 +++++++++++++++++++++++++++ 4 files changed, 458 insertions(+) create mode 100644 systemd/control-touch-panel.service create mode 100644 touch_panel/control_touch_bridge.php create mode 100755 touch_panel/panel.py diff --git a/README.md b/README.md index f4c31e3..86e36cf 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,10 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `apply_policy.php`: CLI/cron 팬 정책 적용 - `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드 - `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI +- `touch_panel/panel.py`: 라즈베리파이 정품 터치 디스플레이용 Qt 관리자 패널 프로토타입 +- `touch_panel/control_touch_bridge.php`: Qt 패널이 기존 Control PHP 함수를 호출하는 로컬 JSON 브리지 - `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시 +- `systemd/control-touch-panel.service`: GUI 세션에서 Qt 관리자 패널을 띄우는 systemd unit 예시 `asset-version.php`는 `public`의 PHP/JS/CSS/manifest/icon 파일과 `config`의 PHP 파일을 읽어 크기, 수정 시각, sha256 hash를 묶은 전체 버전과 파일별 버전을 만듭니다. `index.php`는 `app.js`, `asset-reload.js`, `wakelock.js`, manifest, icon 경로에 실제 파일 수정 시각과 hash 기반 `v` 값을 붙여 정적 파일 캐시가 오래 남지 않게 합니다. `asset-reload.js`는 화면 복귀, 포커스 복귀, 온라인 복귀, 모바일 터치/클릭 복귀, 30초 간격으로 이 값을 다시 확인하고, 전체 버전 또는 현재 로드된 script/link의 파일별 `v` 값이 다르면 브라우저 Cache Storage를 비우고 service worker 등록을 해제한 뒤 현재 화면을 다시 엽니다. 현재 URL의 `assetReload` 토큰이 최신 파일 묶음 hash와 다르면 첫 진입도 토큰을 붙인 URL로 다시 열어 낡은 JS/CSS가 먼저 실행되는 상황을 줄입니다. 삼성 브라우저 등에서 Web Storage나 `Promise.finally()` 호환성이 부족해도 동작하도록 쿠키 fallback과 timeout 기반 캐시 삭제를 사용합니다. `index.php`는 `no-store` 헤더를 내려 문서 자체가 브라우저 캐시에 오래 남지 않게 합니다. @@ -124,6 +127,26 @@ API 응답의 `battery.enabled`는 false, `battery.removed`는 true로 내려가 설정 모달이 열려 있는 동안 1초 상태 갱신은 폼 DOM을 다시 만들지 않습니다. 입력 중인 값은 저장, 기본값 초기화, 닫기 전까지 유지되며, 백그라운드 snapshot은 내부 설정 payload만 갱신합니다. 단순 boolean 설정은 `켜기`/`끄기` 세그먼트로 표시합니다. +## 터치 패널 프로토타입 + +라즈베리파이 정품 7인치 DSI 터치 디스플레이에 띄울 로컬 Qt 관리자 패널 초안을 둡니다. 현재 라즈베리파이는 `multi-user.target` 콘솔 부팅이며 PyQt5는 설치되어 있습니다. GUI로 원복하려면 X/데스크톱 패키지를 다시 설치한 뒤 graphical target으로 바꾸고 재부팅해야 합니다. + +```bash +sudo raspi-config +sudo systemctl set-default graphical.target +``` + +패키지가 제거된 상태면 Raspberry Pi OS 데스크톱 패키지와 X 서버를 먼저 복구해야 합니다. 운영 패키지 설치는 사용자가 확인한 뒤 진행합니다. + +프로토타입 수동 실행은 GUI 세션에서 다음처럼 합니다. + +```bash +cd /var/www/control +/usr/bin/python3 touch_panel/panel.py +``` + +패널은 `touch_panel/control_touch_bridge.php`를 통해 기존 `collect_snapshot()`, `set_control_state()`를 재사용합니다. 왼쪽에는 `IO`, `WAN`, `LAN`, `2.4G`, `5G` 상태 원을 표시하고 active 상태면 초록 원이 갱신 때 짧게 깜빡입니다. 오른쪽에는 팬 RPM, 판단 온도, PWM, `auto`/`manual`/`off`, PWM 증감 버튼을 둡니다. 팬 버튼은 우선 Control DB의 팬 상태를 저장하고, 실제 적용은 기존 Control 팬 정책 루프가 이어받는 구조입니다. + ## 갱신 주기 - WebSocket 상태 갱신: 1초마다 diff --git a/systemd/control-touch-panel.service b/systemd/control-touch-panel.service new file mode 100644 index 0000000..651d89b --- /dev/null +++ b/systemd/control-touch-panel.service @@ -0,0 +1,17 @@ +[Unit] +Description=Control Qt touch panel +After=graphical.target mariadb.service +Wants=graphical.target + +[Service] +Type=simple +User=seo +WorkingDirectory=/var/www/control +Environment=DISPLAY=:0 +Environment=XAUTHORITY=/home/seo/.Xauthority +ExecStart=/usr/bin/python3 /var/www/control/touch_panel/panel.py +Restart=always +RestartSec=3 + +[Install] +WantedBy=graphical.target diff --git a/touch_panel/control_touch_bridge.php b/touch_panel/control_touch_bridge.php new file mode 100644 index 0000000..af8c86b --- /dev/null +++ b/touch_panel/control_touch_bridge.php @@ -0,0 +1,58 @@ + true, + 'data' => collect_snapshot(false), + ]); + } + + if ($command === 'fan') { + $mode = (string)($argv[2] ?? 'auto'); + $pwm = max(0, min(255, (int)($argv[3] ?? 120))); + if (!in_array($mode, ['auto', 'manual', 'off'], true)) { + touch_panel_json([ + 'ok' => false, + 'error' => 'bad_mode', + ], 2); + } + + set_control_state($mode, $pwm); + add_fan_action( + 'touch_panel_fan_set', + $mode, + $pwm, + 'qt touch panel fan control', + true + ); + + touch_panel_json([ + 'ok' => true, + 'data' => collect_snapshot(false), + ]); + } + + touch_panel_json([ + 'ok' => false, + 'error' => 'unknown_command', + ], 2); +} catch (Throwable $e) { + touch_panel_json([ + 'ok' => false, + 'error' => 'exception', + 'message' => $e->getMessage(), + ], 1); +} diff --git a/touch_panel/panel.py b/touch_panel/panel.py new file mode 100755 index 0000000..bfd8730 --- /dev/null +++ b/touch_panel/panel.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +import json +import subprocess +import sys +from pathlib import Path + +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QColor, QFont, QPainter +from PyQt5.QtWidgets import ( + QApplication, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QSlider, + QVBoxLayout, + QWidget, +) + + +ROOT = Path(__file__).resolve().parents[1] +BRIDGE = ROOT / "touch_panel" / "control_touch_bridge.php" +PHP = "/usr/bin/php" + + +def run_bridge(*args): + proc = subprocess.run( + [PHP, str(BRIDGE), *map(str, args)], + cwd=str(ROOT), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=8, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError((proc.stderr or proc.stdout or "bridge failed").strip()) + payload = json.loads(proc.stdout) + if not payload.get("ok"): + raise RuntimeError(payload.get("message") or payload.get("error") or "bridge error") + return payload["data"] + + +class StatusDot(QWidget): + def __init__(self): + super().__init__() + self.active = False + self.warning = False + self.pulse = 0 + self.setFixedSize(34, 34) + + def set_state(self, active, warning=False): + changed = self.active != active or self.warning != warning + self.active = active + self.warning = warning + if active and changed: + self.pulse = 6 + self.update() + + def tick(self): + if self.active: + self.pulse = 6 if self.pulse <= 0 else self.pulse - 1 + self.update() + + def paintEvent(self, event): + del event + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + base = QColor("#22c55e") if self.active else QColor("#64748b") + if self.warning: + base = QColor("#f59e0b") + if self.pulse > 0 and self.active and not self.warning: + base = QColor("#86efac") if self.pulse % 2 == 0 else QColor("#16a34a") + painter.setBrush(base) + painter.setPen(Qt.NoPen) + painter.drawEllipse(4, 4, 26, 26) + painter.setBrush(QColor(255, 255, 255, 55)) + painter.drawEllipse(10, 8, 8, 8) + + +class StatusRow(QFrame): + def __init__(self, title): + super().__init__() + self.dot = StatusDot() + self.title = QLabel(title) + self.title.setObjectName("statusTitle") + self.detail = QLabel("-") + self.detail.setObjectName("statusDetail") + self.detail.setWordWrap(True) + + texts = QVBoxLayout() + texts.setContentsMargins(0, 0, 0, 0) + texts.setSpacing(2) + texts.addWidget(self.title) + texts.addWidget(self.detail) + + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 9, 12, 9) + layout.setSpacing(10) + layout.addWidget(self.dot) + layout.addLayout(texts, 1) + self.setObjectName("statusRow") + + def update_state(self, active, detail, warning=False): + self.dot.set_state(active, warning) + self.detail.setText(detail) + + def tick(self): + self.dot.tick() + + +class FanButton(QPushButton): + def __init__(self, text): + super().__init__(text) + self.setCheckable(True) + self.setMinimumHeight(44) + + +class TouchPanel(QMainWindow): + def __init__(self): + super().__init__() + self.snapshot = {} + self.rows = {} + self.mode_buttons = {} + self.current_mode = "auto" + self.current_pwm = 120 + self.in_flight = False + + self.setWindowTitle("Control Touch Panel") + self.setMinimumSize(800, 480) + self._build_ui() + + self.refresh_timer = QTimer(self) + self.refresh_timer.timeout.connect(self.refresh) + self.refresh_timer.start(1200) + + self.pulse_timer = QTimer(self) + self.pulse_timer.timeout.connect(self.tick_pulses) + self.pulse_timer.start(180) + self.refresh() + + def _build_ui(self): + root = QWidget() + grid = QGridLayout(root) + grid.setContentsMargins(16, 14, 16, 14) + grid.setHorizontalSpacing(14) + grid.setVerticalSpacing(10) + + left = QFrame() + left.setObjectName("panel") + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(14, 14, 14, 14) + left_layout.setSpacing(10) + + title = QLabel("상태") + title.setObjectName("sectionTitle") + left_layout.addWidget(title) + for key, label in [ + ("io", "IO"), + ("wan", "WAN"), + ("lan", "LAN"), + ("wifi24", "2.4G"), + ("wifi5", "5G"), + ]: + row = StatusRow(label) + self.rows[key] = row + left_layout.addWidget(row) + left_layout.addStretch(1) + + right = QFrame() + right.setObjectName("panel") + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(16, 14, 16, 14) + right_layout.setSpacing(12) + + header = QHBoxLayout() + fan_title = QLabel("팬") + fan_title.setObjectName("sectionTitle") + self.clock = QLabel("--:--:--") + self.clock.setObjectName("clock") + header.addWidget(fan_title) + header.addStretch(1) + header.addWidget(self.clock) + right_layout.addLayout(header) + + self.fan_rpm = QLabel("- RPM") + self.fan_rpm.setObjectName("bigValue") + self.fan_temp = QLabel("- C") + self.fan_temp.setObjectName("subValue") + self.fan_pwm = QLabel("- PWM") + self.fan_pwm.setObjectName("subValue") + right_layout.addWidget(self.fan_rpm) + right_layout.addWidget(self.fan_temp) + right_layout.addWidget(self.fan_pwm) + + mode_layout = QHBoxLayout() + for mode in ["auto", "manual", "off"]: + btn = FanButton(mode) + btn.clicked.connect(lambda checked=False, m=mode: self.set_fan(m, self.current_pwm)) + self.mode_buttons[mode] = btn + mode_layout.addWidget(btn) + right_layout.addLayout(mode_layout) + + slider_wrap = QHBoxLayout() + minus = QPushButton("-") + minus.setObjectName("stepButton") + minus.clicked.connect(lambda: self.step_pwm(-10)) + plus = QPushButton("+") + plus.setObjectName("stepButton") + plus.clicked.connect(lambda: self.step_pwm(10)) + self.slider = QSlider(Qt.Horizontal) + self.slider.setRange(0, 255) + self.slider.setValue(120) + self.slider.sliderReleased.connect(lambda: self.set_fan("manual", self.slider.value())) + self.slider.valueChanged.connect(self.slider_changed) + slider_wrap.addWidget(minus) + slider_wrap.addWidget(self.slider, 1) + slider_wrap.addWidget(plus) + right_layout.addLayout(slider_wrap) + + self.notice = QLabel("프로토타입") + self.notice.setObjectName("notice") + self.notice.setWordWrap(True) + right_layout.addWidget(self.notice) + right_layout.addStretch(1) + + grid.addWidget(left, 0, 0) + grid.addWidget(right, 0, 1) + grid.setColumnStretch(0, 3) + grid.setColumnStretch(1, 4) + self.setCentralWidget(root) + + def tick_pulses(self): + for row in self.rows.values(): + row.tick() + + def slider_changed(self, value): + self.current_pwm = value + self.fan_pwm.setText(f"{value} PWM / {value / 255 * 100:.1f}%") + + def set_fan(self, mode, pwm): + if self.in_flight: + return + self.in_flight = True + self.notice.setText("팬 변경 중") + try: + self.snapshot = run_bridge("fan", mode, pwm) + self.notice.setText("팬 상태를 바꿨어") + self.render() + except Exception as exc: + self.notice.setText(str(exc)) + finally: + self.in_flight = False + + def step_pwm(self, delta): + value = max(0, min(255, self.slider.value() + delta)) + self.slider.setValue(value) + self.set_fan("manual", value) + + def refresh(self): + if self.in_flight: + return + try: + self.snapshot = run_bridge("status") + self.render() + except Exception as exc: + self.notice.setText(str(exc)) + self.rows["io"].update_state(False, "control snapshot 실패", True) + + def render(self): + data = self.snapshot + fan = data.get("fan", {}) + system = data.get("system", {}) + network = data.get("network", {}) + wifi = data.get("wifi", {}) + touch = data.get("touch_display", {}) + + interfaces = {row.get("name"): row for row in network.get("interfaces", [])} + clients = wifi.get("clients", []) + wifi24 = [c for c in clients if c.get("band") == "2.4G"] + wifi5 = [c for c in clients if c.get("band") == "5G"] + lan_clients = [c for c in clients if c.get("band") == "LAN"] + + temp = float(system.get("temp_c") or 0) + rpm = int(fan.get("rpm") or 0) + pwm = int(fan.get("pwm") or 0) + mode = str(fan.get("mode") or "auto") + wan = interfaces.get(network.get("default_interface") or "eth0", {}) + lan = interfaces.get("br0") or interfaces.get("eth1") or {} + w24 = interfaces.get("wlan0") or {} + w5 = interfaces.get("wlan1") or {} + + io_active = bool(touch.get("connected")) and bool(fan.get("policy_ok", True)) + io_detail = f"{system.get('hostname', '-')} / {temp:.1f} C" + self.rows["io"].update_state(io_active, io_detail, temp >= 75) + self.rows["wan"].update_state(self.iface_up(wan), self.iface_detail(wan), self.iface_warn(wan)) + self.rows["lan"].update_state(self.iface_up(lan), f"{len(lan_clients)}대 / {self.iface_detail(lan)}", self.iface_warn(lan)) + self.rows["wifi24"].update_state(self.iface_up(w24), f"{len(wifi24)}대 / {self.iface_detail(w24)}", self.iface_warn(w24)) + self.rows["wifi5"].update_state(self.iface_up(w5), f"{len(wifi5)}대 / {self.iface_detail(w5)}", self.iface_warn(w5)) + + self.current_mode = mode + self.current_pwm = pwm + self.fan_rpm.setText(f"{rpm:,} RPM") + self.fan_temp.setText(f"판단 온도 {float(fan.get('control_temp_c') or temp):.1f} C") + self.fan_pwm.setText(f"{pwm} PWM / {float(fan.get('percent') or 0):.1f}%") + self.slider.blockSignals(True) + self.slider.setValue(pwm) + self.slider.blockSignals(False) + for key, btn in self.mode_buttons.items(): + btn.setChecked(key == mode) + self.clock.setText(str(data.get("generated_at", "--:--:--")).split(" ")[-1]) + + @staticmethod + def iface_up(row): + return row.get("state") == "up" and row.get("carrier") == "up" + + @staticmethod + def iface_warn(row): + return int(row.get("rx_errors") or 0) > 0 or int(row.get("tx_errors") or 0) > 0 + + @staticmethod + def iface_detail(row): + if not row: + return "없음" + rx_drop = int(row.get("rx_dropped") or 0) + tx_drop = int(row.get("tx_dropped") or 0) + return f"{row.get('name', '-')} {row.get('state', '-')} / drop {rx_drop + tx_drop}" + + +def main(): + app = QApplication(sys.argv) + app.setStyleSheet( + """ + QWidget { background: #0b1220; color: #e5edf7; font-family: "Noto Sans CJK KR", "Noto Sans KR", sans-serif; } + #panel { background: #111a2e; border: 1px solid #27364f; border-radius: 18px; } + #sectionTitle { font-size: 24px; font-weight: 700; } + #statusRow { background: #18243a; border: 1px solid #263650; border-radius: 16px; } + #statusTitle { font-size: 18px; font-weight: 700; color: #f8fafc; } + #statusDetail { font-size: 13px; color: #9fb0c8; } + #bigValue { font-size: 56px; font-weight: 800; color: #f8fafc; } + #subValue { font-size: 22px; color: #b8c5d8; } + #clock { font-size: 18px; color: #8fb3ff; font-weight: 700; } + #notice { padding: 10px; background: #17233a; border: 1px solid #2b3c59; border-radius: 12px; color: #a8b8d0; } + QPushButton { min-height: 42px; border: 1px solid #33445f; border-radius: 14px; background: #1a2942; color: #dce8f8; font-size: 18px; font-weight: 700; } + QPushButton:checked { background: #16a34a; color: white; border-color: #22c55e; } + QPushButton#stepButton { min-width: 56px; font-size: 28px; } + 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() + win.showFullScreen() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main()