From 6665b3797510085f23023d05ece8fbc9b593741f Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 26 Jun 2026 18:09:29 +0900 Subject: [PATCH] =?UTF-8?q?Control=20WiFi=20=EC=A7=84=EB=8B=A8=EA=B3=BC=20?= =?UTF-8?q?=EB=B0=B0=ED=84=B0=EB=A6=AC=20=EC=98=88=EC=B8=A1=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 49 ++++-- config/config.php | 11 ++ public/api.php | 379 ++++++++++++++++++++++++++++++++++++++++++- public/assets/app.js | 138 +++++++++++++++- public/index.php | 6 +- 5 files changed, 557 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 9bfcce0..7138398 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정 - WiFi 호스트명이 비어 있으면 MAC 기반 임시명을 표시하고, MAC별 수동 호스트명을 DB에 저장해 이후에도 유지 - WiFi 신호/송신 속도/수신 속도가 비어 있으면 대역, dBm, 송수신 속도, 5G RTL8822BU 특성을 기준으로 결측값 보정 +- WiFi client 신호는 dBm이 약할수록 빨간색에 가깝게 표시하고, client 표 아래에 주변 2.4G/5G AP 스캔 결과와 실패 사유를 함께 표시 - System Notice 최근 이력 표시 - process CPU/MEM 후보는 프로세스 상세 접힘 영역에서 표시 - `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시 @@ -74,6 +75,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `wifi_client_aliases`: MAC별 수동 WiFi 호스트명 - `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드 - `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보 +- `/tmp/control_wifi_scan.json`: 주변 WiFi 스캔 결과 파일 캐시 - `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정 - 배터리 용량 설정은 `/home/seo/secret/control.php`의 `battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다. @@ -94,13 +96,16 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 11. 단기 중앙값 후보, 강건 회귀 후보, 전압 기울기 후보, 에너지 학습 후보, 용량 전력 후보, 장기 학습 후보를 신뢰도와 안정도 가중치로 합산합니다. 12. 후보끼리 방전 속도가 크게 어긋나면 중앙값 기준으로 튄 후보를 제외하거나 가중치를 낮춥니다. 13. 최종 신뢰도는 후보별 신뢰도 가중 평균에 후보 간 방전 속도 분산을 반영해 계산합니다. -14. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다. +14. 용량 기반 후보와 fallback은 설정된 최소 시스템 전력보다 낮은 순간 전력값을 그대로 믿지 않고 최소 전력으로 끌어올려 계산합니다. +15. 1s8p 18650 배터리팩에서 비현실적으로 긴 값이 나오지 않도록 `잔여시간 현실 상한`을 100% 기준 최대 시간으로 두고, 현재 SOC에 비례해 최종 후보를 제한합니다. +16. 상한을 넘은 후보는 버리지 않고 상한값으로 눌러 반영하며, 신뢰도와 가중치를 낮춰 다른 실제 방전 후보를 더 우선하게 합니다. +17. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다. WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과를 55초 동안, 약 5분 간격 장기 학습 샘플을 10분 동안 재사용해 DB 부담을 줄입니다. 장기 학습 샘플은 파일 캐시로도 보관해 다른 PHP 프로세스에서도 빠르게 재사용합니다. 대시보드의 잔여 시간 값에는 Control UI 스타일의 커스텀 계산 근거 팝오버가 붙습니다. 기본 브라우저 `title` tooltip은 사용하지 않으며, 마우스 hover 또는 키보드 focus 중에도 상태 갱신이 들어오면 팝오버 내용과 위치가 즉시 갱신됩니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, 계산 출처에 `장기 학습`이 포함되면 누적 기록에서 학습한 SOC/부하 구간별 방전 경험도 반영된 상태입니다. `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다. -팝오버는 계산 잔여 시간, 시간당 SOC 감소율, 추세 신뢰도, 후보 수, 후보 분산, 최근 CPU 전력, 계산 출처, 반영 구간을 표시합니다. 각 후보별로 최근 30분/1시간/3시간/6시간/12시간/24시간 감소율, 신뢰도, 부하 보정 배율을 표시하고, 강건 회귀 후보는 튐 제거 후 남은 유효점 비율과 후보 안정도를 함께 표시합니다. 전압 후보는 하한 전압과 시간당 전압 하강량을, 에너지 학습 후보는 SOC 1%당 실제 Wh를, 장기 학습 후보는 반영된 학습 구간 수와 SOC 구간 수를 함께 표시합니다. +팝오버는 계산 잔여 시간, 현실 상한, 상한 전 계산값, 시간당 SOC 감소율, 추세 신뢰도, 후보 수, 후보 분산, 최근 CPU 전력, 계산 출처, 반영 구간을 표시합니다. 각 후보별로 최근 30분/1시간/3시간/6시간/12시간/24시간 감소율, 신뢰도, 부하 보정 배율을 표시하고, 강건 회귀 후보는 튐 제거 후 남은 유효점 비율과 후보 안정도를 함께 표시합니다. 전압 후보는 하한 전압과 시간당 전압 하강량을, 에너지 학습 후보는 SOC 1%당 실제 Wh를, 장기 학습 후보는 반영된 학습 구간 수와 SOC 구간 수를 함께 표시합니다. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 `battery.remaining.seconds`와 동기화합니다. DB에 저장된 마지막 센서 기록이 몇 초 늦어도 좌측 상태 영역과 차트의 최신 잔여 시간이 서로 다른 값을 보여주지 않도록, API 응답 history 끝에 현재 snapshot row를 반영합니다. @@ -112,11 +117,12 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과 - 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step - 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부 +- WiFi 진단: 주변 WiFi 스캔 켜기/끄기, 스캔 캐시 시간, 명령 timeout, 표시 개수, 원문 일부 표시 여부 - 알림 정책: 배터리 긴급/위험/경고/복구 기준, 배터리 낮음/복구 알림 쿨다운, 시스템 유의사항 알림 쿨다운 - 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수, HA 알림 이력 수 - 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수 - 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이, RPM 감시 시작 PWM, alert 유지 중 반복 기록, 반복 기록 간격, 하강 변화 원인 후보 표시 여부 -- 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치, 상태/잔여시간 차트 표본 수 +- 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치, 잔여시간 현실 상한, 최소 시스템 전력, 상한 초과 후보 감쇠, 에너지/전압 모델 사용 여부, 상태/잔여시간 차트 표본 수 설정 모달이 열려 있는 동안 1초 상태 갱신은 폼 DOM을 다시 만들지 않습니다. 입력 중인 값은 저장, 기본값 초기화, 닫기 전까지 유지되며, 백그라운드 snapshot은 내부 설정 payload만 갱신합니다. 단순 boolean 설정은 `켜기`/`끄기` 세그먼트로 표시합니다. @@ -128,6 +134,7 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과 - 탭 복귀: 즉시 1회 상태 갱신 - 잔여 시간 커스텀 팝오버: 상태 갱신과 동일하며 hover/focus 중에도 내용과 위치를 계속 갱신 - dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지 +- 주변 WiFi 스캔: 상태 snapshot에 포함되지만 파일 캐시를 사용하며 기본 60초마다 실제 `iw dev scan` 명령을 다시 시도 - WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행 - WebSocket 소스 변경 감지: 15초마다 확인 후 재시작 - 팬 슬라이더 자동 적용 debounce: 약 450ms @@ -147,6 +154,17 @@ Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고 System Notice는 `control-system-notice` tag를 사용하며, 설정된 쿨다운으로 급격한 팬/온도 변화 알림을 제한합니다. alert 상태는 신규 진입과 유지 상태를 분리해 관리하며, alert 유지 중 반복 기록 여부와 기록 간격도 설정 모달에서 조정합니다. +### 시스템 알림 조건 + +배터리 알림과 System Notice는 서로 다른 조건으로 동작합니다. + +- 배터리 알림: 현재 배터리 SOC가 설정값 이하로 내려가면 발송합니다. 기본 단계는 20% 이하 주의, 15% 이하 경고, 10% 이하 위험, 5% 이하 긴급입니다. 같은 `control-battery-low` tag를 계속 갱신하므로 SOC 값이 1%씩 바뀌어도 알림 카드가 계속 쌓이지 않습니다. SOC가 복구 기준을 넘으면 같은 tag를 `clear_notification`으로 제거합니다. +- System Notice 시작: 팬 모드가 `off`가 아닐 때, 최근 안정 기준선 대비 팬 RPM 차이가 `팬 RPM 이상 감지 차이` 이상이거나 온도 차이가 `온도 이상 감지 차이` 이상이면 alert로 진입합니다. RPM 감시는 현재 PWM 또는 기준 PWM이 `RPM 감시 시작 PWM` 이상일 때만 의미 있는 변화로 봅니다. +- System Notice 기준선: normal 상태에서는 최근 sensor history에서 최신 몇 개 표본을 제외하고 절사 평균으로 기준선을 계속 갱신합니다. alert로 들어가면 진입 당시 온도/RPM/PWM 기준선을 고정해, alert 중 기준선이 문제 상태를 따라가며 사라지는 일을 막습니다. +- System Notice 복구: 고정 기준선 또는 최신 rolling 기준선 대비 RPM/온도 차이가 각각 복구 차이 이하로 내려가면 normal로 돌아갑니다. 팬 모드가 `off`이면 fan off 기준선을 별도로 고정하고 System Notice alert를 만들지 않습니다. +- System Notice 기록/발송: alert 신규 진입 시 `system_notice_logs`에 기록합니다. `Alert 유지 중 반복 기록`을 켜면 유지 중에도 설정한 반복 기록 간격마다 다시 기록할 수 있습니다. HA 알림은 `시스템 유의사항 알림 쿨다운`을 통과할 때만 발송합니다. +- 저전압/스로틀링 표시: `vcgencmd get_throttled`의 현재 비트와 부팅 후 이력 비트를 읽어 현재 상태, 최근 감지, 지속시간, 최근 10분 통계를 표시합니다. 이 값은 System Notice와 별개이며, 팬/온도 alert 조건에는 직접 섞지 않습니다. + Android 알림 채널은 생성 후 휴대폰 설정에 의해 중요도와 진동 패턴이 고정될 수 있습니다. 채널을 다시 만들 필요가 있을 때는 운영 CLI로 제거 명령을 보낸 뒤 다음 알림에서 재생성합니다. ```bash @@ -176,6 +194,8 @@ control-wifi-observe.service 신호/송신 속도/수신 속도는 실제 `iw station dump` 값이 있으면 그대로 표시합니다. 값이 비어 있으면 2.4G는 일반 802.11n 계열 링크 속도와 dBm 범위를 기준으로, 5G는 Realtek RTL8822BU/802.11ac 계열 동글의 최대 1300Mbps 특성을 참고해 dBm과 링크 속도 사이를 보정합니다. 화면에는 별도 표시 없이 숫자만 보여 N/A 노출을 줄입니다. +주변 WiFi 스캔은 대시보드 snapshot에 포함되지만 기본 60초 파일 캐시를 사용합니다. `iw dev wlan0 scan`, `iw dev wlan1 scan`을 시도하고 SSID, BSSID, 주파수, 채널, 신호, 보안, 마지막 감지, WiFi 세대, 채널 폭, 지원 속도, capability를 표시합니다. AP 모드 또는 드라이버 제한으로 스캔이 실패하면 해당 인터페이스와 실패 사유를 같은 섹션에 표시합니다. + ## 처리 흐름 1. 화면 진입 시 로그인 세션과 remember token을 확인합니다. @@ -185,16 +205,17 @@ control-wifi-observe.service 5. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, 5G 연결 시간이 `N/A`이면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. 6. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다. 7. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다. -8. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. -9. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. -10. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. -11. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. -12. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. -13. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화로 보정합니다. -14. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 잔여 시간과 같은 값을 사용하도록 history 응답을 동기화합니다. -15. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. -16. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다. -17. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다. +8. 주변 WiFi 스캔은 캐시 주기를 확인한 뒤 `iw dev scan`을 시도하고, 성공 결과 또는 실패 사유를 client 표 아래에 표시합니다. +9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. +10. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. +11. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. +12. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. +13. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. +14. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화와 현실 상한을 반영합니다. +15. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 잔여 시간과 같은 값을 사용하도록 history 응답을 동기화합니다. +16. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. +17. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다. +18. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다. ## 주요 함수/모듈 @@ -209,6 +230,7 @@ control-wifi-observe.service - `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용 - `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장 - `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정 +- `wifi_scan_data()`, `parse_wifi_scan_networks()`: 주변 WiFi AP 스캔, 실패 사유 정리, 파일 캐시 구성 - `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장 - `ha_notify_due()`: 동일 tag 기준 쿨다운 판정 - `clear_ha_notification()`: 같은 tag의 Android 알림 제거 명령 발송 @@ -222,6 +244,7 @@ control-wifi-observe.service - `battery_voltage_trend_candidate()`: 전압 기울기와 동적 하한 전압으로 잔여 시간 후보 계산 - `battery_energy_profile_candidate()`: 장기 기록에서 SOC 1%당 실제 사용 Wh를 학습해 잔여 시간 후보 계산 - `battery_capacity_power_candidate()`: 설정 배터리 용량과 최근 전력으로 용량 기반 후보 계산 +- `battery_runtime_cap_seconds()`, `battery_cap_candidate()`, `battery_cap_result()`: SOC 비례 현실 상한과 상한 초과 후보 감쇠 적용 - `refine_battery_candidates()`: 후보 방전 속도의 중앙값에서 크게 벗어난 값을 제외하거나 가중치 축소 - `battery_learned_profile_candidate()`: 누적 기록에서 SOC/부하 구간별 장기 방전 프로파일 후보 계산 - `battery_remaining_estimate()`: SOC 다중 시간창 방전 추세, 장기 학습 프로파일, 현재 부하 보정으로 잔여 시간 계산 diff --git a/config/config.php b/config/config.php index 29e38da..cdefcd2 100644 --- a/config/config.php +++ b/config/config.php @@ -153,6 +153,11 @@ function setting_definitions(): array 'security.shell_default_timeout_seconds' => ['group' => 'security', 'label' => '기본 명령 timeout', 'type' => 'number', 'default' => 8, 'min' => 2, 'max' => 60, 'step' => 1, 'unit' => '초'], 'security.allow_wifi_restart' => ['group' => 'security', 'label' => 'WiFi restart 허용', 'type' => 'boolean', 'default' => 1], 'security.allow_wifi_reload' => ['group' => 'security', 'label' => 'WiFi reload 허용', 'type' => 'boolean', 'default' => 1], + 'wifi.scan_enabled' => ['group' => 'wifi', 'label' => '주변 WiFi 스캔', 'type' => 'boolean', 'default' => 1], + 'wifi.scan_cache_seconds' => ['group' => 'wifi', 'label' => '주변 WiFi 스캔 캐시', 'type' => 'number', 'default' => 60, 'min' => 10, 'max' => 600, 'step' => 5, 'unit' => '초'], + 'wifi.scan_timeout_seconds' => ['group' => 'wifi', 'label' => '주변 WiFi 스캔 timeout', 'type' => 'number', 'default' => 8, 'min' => 3, 'max' => 30, 'step' => 1, 'unit' => '초'], + 'wifi.scan_max_networks' => ['group' => 'wifi', 'label' => '주변 WiFi 표시 개수', 'type' => 'number', 'default' => 80, 'min' => 10, 'max' => 300, 'step' => 10, 'unit' => '개'], + 'wifi.scan_include_raw' => ['group' => 'wifi', 'label' => '주변 WiFi 원문 일부 표시', 'type' => 'boolean', 'default' => 0], 'fan.auto_min_temp' => ['group' => 'fan', 'label' => '팬 시작 온도', 'type' => 'number', 'default' => 50, 'min' => 30, 'max' => 90, 'step' => 0.5, 'unit' => 'C'], 'fan.auto_max_temp' => ['group' => 'fan', 'label' => '팬 최대 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'], @@ -195,6 +200,11 @@ function setting_definitions(): array 'battery.candidate_min_ratio' => ['group' => 'battery', 'label' => '후보 최소 속도 비율', 'type' => 'number', 'default' => 0.38, 'min' => 0.05, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], 'battery.candidate_max_ratio' => ['group' => 'battery', 'label' => '후보 최대 속도 비율', 'type' => 'number', 'default' => 2.65, 'min' => 1, 'max' => 8, 'step' => 0.05, 'unit' => 'x'], 'battery.capacity_model_weight' => ['group' => 'battery', 'label' => '용량 모델 가중치', 'type' => 'number', 'default' => 0.18, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], + 'battery.runtime_cap_hours' => ['group' => 'battery', 'label' => '잔여시간 현실 상한', 'type' => 'number', 'default' => 18, 'min' => 1, 'max' => 48, 'step' => 0.5, 'unit' => '시간'], + 'battery.minimum_system_watts' => ['group' => 'battery', 'label' => '최소 시스템 전력', 'type' => 'number', 'default' => 5.2, 'min' => 1.5, 'max' => 25, 'step' => 0.1, 'unit' => 'W'], + 'battery.long_candidate_penalty' => ['group' => 'battery', 'label' => '상한 초과 후보 감쇠', 'type' => 'number', 'default' => 0.62, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], + 'battery.energy_model_enabled' => ['group' => 'battery', 'label' => '에너지 모델 사용', 'type' => 'boolean', 'default' => 1], + 'battery.voltage_model_enabled' => ['group' => 'battery', 'label' => '전압 모델 사용', 'type' => 'boolean', 'default' => 1], 'battery.history_chart_limit' => ['group' => 'battery', 'label' => '상태 차트 표본 수', 'type' => 'number', 'default' => 240, 'min' => 60, 'max' => 1500, 'step' => 10, 'unit' => '개'], 'battery.history_sync_limit' => ['group' => 'battery', 'label' => '잔여시간 차트 표본 수', 'type' => 'number', 'default' => 240, 'min' => 60, 'max' => 1500, 'step' => 10, 'unit' => '개'], ]; @@ -267,6 +277,7 @@ function settings_payload(): array 'security' => '보안 정책', 'notify' => '알림 정책', 'display' => '화면/진단 표시', + 'wifi' => 'WiFi 진단', 'fan' => '팬 자동 제어', 'battery' => '배터리 예측', 'event' => '저전압/스로틀링', diff --git a/public/api.php b/public/api.php index 517f209..4a47b43 100644 --- a/public/api.php +++ b/public/api.php @@ -1133,6 +1133,10 @@ function battery_voltage_floor(float $currentVoltage): float function battery_voltage_trend_candidate(array $trendRows, float $currentPercent, ?float $currentVoltage, int $windowSeconds, ?float $recentWatts): ?array { + if (!(bool)setting_value('battery.voltage_model_enabled')) { + return null; + } + if ($currentVoltage === null || $currentVoltage <= 0 || count($trendRows) < 10) { return null; } @@ -1358,6 +1362,7 @@ function battery_capacity_power_candidate(float $currentPercent, ?float $recentW return null; } + $recentWatts = battery_effective_system_watts($recentWatts); $remainingWh = BATTERY_CAPACITY_WH * ($currentPercent / 100); $seconds = (int)round(($remainingWh / $recentWatts) * 3600); if ($seconds <= 0) { @@ -1382,10 +1387,15 @@ function battery_capacity_power_candidate(float $currentPercent, ?float $recentW function battery_energy_profile_candidate(array $profileRows, float $currentPercent, ?float $recentWatts): ?array { + if (!(bool)setting_value('battery.energy_model_enabled')) { + return null; + } + if ($recentWatts === null || $recentWatts <= 0 || count($profileRows) < 24) { return null; } + $recentWatts = battery_effective_system_watts($recentWatts); $now = time(); $weightedWhPerPercent = 0.0; $weightSum = 0.0; @@ -1469,6 +1479,74 @@ function battery_energy_profile_candidate(array $profileRows, float $currentPerc ]; } +function battery_effective_system_watts(?float $watts): ?float +{ + if ($watts === null || $watts <= 0) { + return null; + } + + return max($watts, (float)setting_value('battery.minimum_system_watts')); +} + +function battery_runtime_cap_seconds(float $percent): int +{ + $fullCapSeconds = max(0.0, (float)setting_value('battery.runtime_cap_hours')) * 3600; + if ($fullCapSeconds <= 0) { + return 0; + } + + return (int)round($fullCapSeconds * clamp_float($percent, 0.0, 100.0) / 100); +} + +function battery_cap_candidate(array $candidate, float $currentPercent): array +{ + $capSeconds = battery_runtime_cap_seconds($currentPercent); + $seconds = (int)($candidate['seconds'] ?? 0); + if ($capSeconds <= 0 || $seconds <= $capSeconds) { + $candidate['runtime_cap_seconds'] = $capSeconds; + $candidate['runtime_capped'] = false; + return $candidate; + } + + $candidate['original_seconds'] = $seconds; + $candidate['seconds'] = $capSeconds; + $candidate['rate_per_second'] = $capSeconds > 0 ? $currentPercent / $capSeconds : (float)($candidate['rate_per_second'] ?? 0); + $candidate['confidence'] = round((float)($candidate['confidence'] ?? 0) * (float)setting_value('battery.long_candidate_penalty'), 4); + $candidate['weight'] = max(0.02, (float)($candidate['weight'] ?? 0.03) * (float)setting_value('battery.long_candidate_penalty')); + $candidate['runtime_cap_seconds'] = $capSeconds; + $candidate['runtime_capped'] = true; + + return $candidate; +} + +function battery_cap_candidates(array $candidates, float $currentPercent): array +{ + return array_map( + static fn(array $candidate): array => battery_cap_candidate($candidate, $currentPercent), + $candidates + ); +} + +function battery_cap_result(array $result, float $percent): array +{ + $capSeconds = battery_runtime_cap_seconds($percent); + $seconds = $result['seconds'] ?? null; + $result['runtime_cap_seconds'] = $capSeconds; + $result['runtime_capped'] = false; + + if ($capSeconds > 0 && is_numeric($seconds) && (int)$seconds > $capSeconds) { + $result['original_seconds'] = (int)$seconds; + $result['seconds'] = $capSeconds; + $result['display'] = human_remaining_seconds($capSeconds); + $result['runtime_capped'] = true; + if (isset($result['confidence']) && is_numeric($result['confidence'])) { + $result['confidence'] = round((float)$result['confidence'] * (float)setting_value('battery.long_candidate_penalty'), 3); + } + } + + return $result; +} + function refine_battery_candidates(array $candidates): array { if (count($candidates) < 3) { @@ -1517,18 +1595,18 @@ function battery_power_fallback_estimate(float $percent, array $history): array } } - $avgWatts = numeric_trimmed_average($wattValues, 0.12); + $avgWatts = battery_effective_system_watts(numeric_trimmed_average($wattValues, 0.12)); if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) { $remainingWh = BATTERY_CAPACITY_WH * ($percent / 100); $seconds = (int)round(($remainingWh / $avgWatts) * 3600); - return [ + return battery_cap_result([ 'display' => human_remaining_seconds($seconds), 'seconds' => $seconds, 'source' => 'capacity_power_fallback', 'avg_watts' => round($avgWatts, 3), 'capacity_wh' => BATTERY_CAPACITY_WH, - ]; + ], $percent); } return [ @@ -1590,6 +1668,7 @@ function battery_remaining_estimate(array $battery, array $history, array $trend if ($capacityPower !== null) { $candidates[] = $capacityPower; } + $candidates = battery_cap_candidates($candidates, $percent); $candidates = refine_battery_candidates($candidates); if ($candidates !== []) { @@ -1614,7 +1693,7 @@ function battery_remaining_estimate(array $battery, array $history, array $trend $rateSpread = (numeric_mad($rates) ?? 0.0) / $medianRate; } - return [ + return battery_cap_result([ 'display' => human_remaining_seconds($seconds), 'seconds' => $seconds, 'source' => 'soc_multi_window', @@ -1640,9 +1719,11 @@ function battery_remaining_estimate(array $battery, array $history, array $trend 'volts_per_hour' => $candidate['volts_per_hour'] ?? null, 'wh_per_percent' => $candidate['wh_per_percent'] ?? null, 'capacity_wh' => $candidate['capacity_wh'] ?? null, + 'runtime_capped' => $candidate['runtime_capped'] ?? false, + 'original_seconds' => $candidate['original_seconds'] ?? null, ]; }, $candidates), - ]; + ], $percent); } } @@ -2566,6 +2647,293 @@ function iw_station_dump(string $iface): string return sh(['/usr/sbin/iw', 'dev', $iface, 'station', 'dump'], true, 4)['out']; } +function wifi_scan_cache_path(): string +{ + return sys_get_temp_dir() . '/control_wifi_scan.json'; +} + +function wifi_channel_from_freq(int $freq): ?int +{ + if ($freq >= 2412 && $freq <= 2472) { + return (int)(($freq - 2407) / 5); + } + if ($freq === 2484) { + return 14; + } + if ($freq >= 5000 && $freq <= 5900) { + return (int)(($freq - 5000) / 5); + } + if ($freq >= 5925 && $freq <= 7125) { + return (int)(($freq - 5950) / 5); + } + + return null; +} + +function wifi_band_from_freq(int $freq): string +{ + if ($freq >= 2400 && $freq < 2500) { + return '2.4G'; + } + if ($freq >= 4900 && $freq < 5925) { + return '5G'; + } + if ($freq >= 5925 && $freq <= 7125) { + return '6G'; + } + + return '기타'; +} + +function wifi_interfaces_from_iw(): array +{ + $out = sh(['/usr/sbin/iw', 'dev'], true, 4)['out']; + $interfaces = []; + $current = null; + + foreach (explode("\n", $out) as $line) { + $trimmed = trim($line); + if (preg_match('/^Interface\s+(.+)$/', $trimmed, $m)) { + if ($current !== null && !empty($current['iface'])) { + $interfaces[$current['iface']] = $current; + } + $current = [ + 'iface' => trim($m[1]), + 'type' => '', + 'channel' => null, + 'freq' => null, + 'band' => '', + ]; + continue; + } + + if ($current === null) { + continue; + } + + if (preg_match('/^type\s+(.+)$/', $trimmed, $m)) { + $current['type'] = trim($m[1]); + } elseif (preg_match('/^channel\s+(\d+)\s+\((\d+)\s+MHz\)/', $trimmed, $m)) { + $current['channel'] = (int)$m[1]; + $current['freq'] = (int)$m[2]; + $current['band'] = wifi_band_from_freq((int)$m[2]); + } + } + + if ($current !== null && !empty($current['iface'])) { + $interfaces[$current['iface']] = $current; + } + + foreach (['wlan0' => '2.4G', 'wlan1' => '5G'] as $iface => $band) { + if (!isset($interfaces[$iface])) { + $interfaces[$iface] = [ + 'iface' => $iface, + 'type' => 'unknown', + 'channel' => null, + 'freq' => null, + 'band' => $band, + ]; + } elseif ($interfaces[$iface]['band'] === '') { + $interfaces[$iface]['band'] = $band; + } + } + + return $interfaces; +} + +function parse_wifi_scan_networks(string $iface, string $text, bool $includeRaw): array +{ + $networks = []; + $current = null; + $raw = []; + + $push = static function () use (&$networks, &$current, &$raw, $iface, $includeRaw): void { + if ($current === null) { + return; + } + + $freq = (int)($current['freq'] ?? 0); + $current['iface'] = $iface; + $current['band'] = $freq > 0 ? wifi_band_from_freq($freq) : '기타'; + $current['channel'] = $freq > 0 ? wifi_channel_from_freq($freq) : null; + $current['security'] = trim(implode(' / ', array_values(array_unique($current['security'] ?? [])))) ?: '개방 또는 미확인'; + if ($includeRaw) { + $current['raw'] = implode("\n", array_slice($raw, 0, 45)); + } + unset($current['security_flags']); + $networks[] = $current; + }; + + foreach (explode("\n", $text) as $line) { + $trimmed = trim($line); + if (preg_match('/^BSS\s+([0-9a-f:]{17})/i', $trimmed, $m)) { + $push(); + $raw = [$trimmed]; + $current = [ + 'bssid' => strtolower($m[1]), + 'ssid' => '숨김 SSID', + 'freq' => null, + 'signal_dbm' => null, + 'signal' => 'N/A', + 'last_seen_ms' => null, + 'security' => [], + 'capability' => '', + 'rates' => '', + 'ht' => false, + 'vht' => false, + 'he' => false, + 'channel_width' => '', + ]; + continue; + } + + if ($current === null) { + continue; + } + + $raw[] = $trimmed; + if (preg_match('/^SSID:\s*(.*)$/', $trimmed, $m)) { + $ssid = trim($m[1]); + $current['ssid'] = $ssid === '' ? '숨김 SSID' : $ssid; + } elseif (preg_match('/^freq:\s*(\d+)/', $trimmed, $m)) { + $current['freq'] = (int)$m[1]; + } elseif (preg_match('/^signal:\s*(-?\d+(?:\.\d+)?)\s*dBm/i', $trimmed, $m)) { + $dbm = (float)$m[1]; + $current['signal_dbm'] = $dbm; + $current['signal'] = number_format($dbm, 1) . ' dBm'; + } elseif (preg_match('/^last seen:\s*(\d+)\s*ms/i', $trimmed, $m)) { + $current['last_seen_ms'] = (int)$m[1]; + } elseif (preg_match('/^capability:\s*(.+)$/i', $trimmed, $m)) { + $current['capability'] = trim($m[1]); + if (stripos($m[1], 'Privacy') !== false) { + $current['security'][] = '암호화'; + } + } elseif (preg_match('/^RSN:/i', $trimmed)) { + $current['security'][] = 'WPA2/WPA3'; + } elseif (preg_match('/^WPA:/i', $trimmed)) { + $current['security'][] = 'WPA'; + } elseif (preg_match('/^\*\s+Authentication suites:\s*(.+)$/i', $trimmed, $m)) { + $current['security'][] = trim($m[1]); + } elseif (preg_match('/^\*\s+Pairwise ciphers:\s*(.+)$/i', $trimmed, $m)) { + $current['security'][] = trim($m[1]); + } elseif (preg_match('/^Supported rates:\s*(.+)$/i', $trimmed, $m)) { + $current['rates'] = trim($m[1]); + } elseif (preg_match('/^HT capabilities:/i', $trimmed)) { + $current['ht'] = true; + } elseif (preg_match('/^VHT capabilities:/i', $trimmed)) { + $current['vht'] = true; + } elseif (preg_match('/^HE capabilities:/i', $trimmed)) { + $current['he'] = true; + } elseif (preg_match('/^\*\s+channel width:\s*(.+)$/i', $trimmed, $m)) { + $current['channel_width'] = trim($m[1]); + } + } + + $push(); + + usort($networks, static function (array $a, array $b): int { + return (float)($b['signal_dbm'] ?? -999) <=> (float)($a['signal_dbm'] ?? -999); + }); + + return $networks; +} + +function wifi_scan_failure_reason(int $code, string $out): string +{ + $lower = strtolower($out); + if ($code === 124) { + return '스캔 명령이 timeout 되었습니다. AP 모드 중 오프채널 스캔이 오래 걸리거나 드라이버가 응답하지 않는 상태일 수 있습니다.'; + } + if (str_contains($lower, 'device or resource busy') || str_contains($lower, 'resource busy')) { + return '인터페이스가 AP로 동작 중이라 같은 무선 칩에서 주변 스캔을 동시에 수행하지 못했습니다.'; + } + if (str_contains($lower, 'operation not supported') || str_contains($lower, 'not supported')) { + return '무선 드라이버가 현재 모드에서 주변 스캔을 지원하지 않습니다.'; + } + if (str_contains($lower, 'network is down')) { + return '인터페이스가 내려가 있어 스캔할 수 없습니다.'; + } + if ($out !== '') { + return trim($out); + } + + return '스캔 결과가 비어 있습니다. AP 모드, 드라이버 제한, 권한 문제 중 하나일 수 있습니다.'; +} + +function wifi_scan_data(): array +{ + if (!(bool)setting_value('wifi.scan_enabled')) { + return [ + 'enabled' => false, + 'cached' => false, + 'generated_at' => null, + 'interfaces' => [], + 'networks' => [], + 'errors' => [], + ]; + } + + $cacheSeconds = (int)setting_value('wifi.scan_cache_seconds'); + $cachePath = wifi_scan_cache_path(); + if (is_file($cachePath) && (time() - filemtime($cachePath)) < $cacheSeconds) { + $cached = json_decode((string)@file_get_contents($cachePath), true); + if (is_array($cached)) { + $cached['cached'] = true; + return $cached; + } + } + + $interfaces = wifi_interfaces_from_iw(); + $networks = []; + $errors = []; + $timeout = (int)setting_value('wifi.scan_timeout_seconds'); + $includeRaw = (bool)setting_value('wifi.scan_include_raw'); + + foreach (['wlan0', 'wlan1'] as $iface) { + $meta = $interfaces[$iface] ?? ['iface' => $iface, 'type' => 'unknown', 'band' => $iface === 'wlan0' ? '2.4G' : '5G']; + $result = sh(['/usr/sbin/iw', 'dev', $iface, 'scan'], true, $timeout); + if ((int)$result['code'] !== 0) { + $errors[] = [ + 'iface' => $iface, + 'band' => $meta['band'] ?? '', + 'type' => $meta['type'] ?? '', + 'code' => (int)$result['code'], + 'reason' => wifi_scan_failure_reason((int)$result['code'], (string)$result['out']), + ]; + continue; + } + + foreach (parse_wifi_scan_networks($iface, (string)$result['out'], $includeRaw) as $network) { + $networks[] = $network; + } + } + + usort($networks, static function (array $a, array $b): int { + $bandOrder = ['2.4G' => 0, '5G' => 1, '6G' => 2, '기타' => 3]; + $bandCompare = ($bandOrder[$a['band'] ?? '기타'] ?? 9) <=> ($bandOrder[$b['band'] ?? '기타'] ?? 9); + if ($bandCompare !== 0) { + return $bandCompare; + } + + return (float)($b['signal_dbm'] ?? -999) <=> (float)($a['signal_dbm'] ?? -999); + }); + + $max = (int)setting_value('wifi.scan_max_networks'); + $payload = [ + 'enabled' => true, + 'cached' => false, + 'generated_at' => date('Y-m-d H:i:s'), + 'interfaces' => array_values($interfaces), + 'networks' => array_slice($networks, 0, $max), + 'total_found' => count($networks), + 'errors' => $errors, + ]; + + @file_put_contents($cachePath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + return $payload; +} + function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array { $rows = []; @@ -2951,6 +3319,7 @@ function wifi_data(): array 'count24' => count(array_filter($clients, fn($c) => $c['band'] === '2.4G')), 'count5' => count(array_filter($clients, fn($c) => $c['band'] === '5G')), 'leases' => array_values($leases), + 'scan' => wifi_scan_data(), ]; } diff --git a/public/assets/app.js b/public/assets/app.js index 6b0e642..a0d7f72 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -15,6 +15,7 @@ wifi24: $('#wifi24'), wifi5: $('#wifi5'), wifiTable: $('#wifiTable'), + wifiScan: $('#wifiScan'), statusHost: $('#statusHost'), statusLoad: $('#statusLoad'), statusUsers: $('#statusUsers'), @@ -145,6 +146,15 @@ remaining: 'Remaining', batteryVoltage: 'Battery Voltage', wifiClients: 'WiFi Clients', + wifiScan: 'Nearby WiFi Scan', + wifiScanDisabled: 'Nearby WiFi scan is disabled.', + wifiScanEmpty: 'No nearby WiFi scan results.', + wifiScanCached: 'cached', + wifiScanFresh: 'fresh', + ssid: 'SSID', + channel: 'Channel', + security: 'Security', + lastSeen: 'Last Seen', band: 'Band', hostname: 'Hostname', signal: 'Signal', @@ -286,6 +296,15 @@ remaining: '잔여 시간', batteryVoltage: '배터리 전압', wifiClients: 'WiFi 클라이언트', + wifiScan: '주변 WiFi 스캔', + wifiScanDisabled: '주변 WiFi 스캔이 꺼져 있습니다.', + wifiScanEmpty: '주변 WiFi 스캔 결과가 없습니다.', + wifiScanCached: '캐시', + wifiScanFresh: '실시간', + ssid: 'SSID', + channel: '채널', + security: '보안', + lastSeen: '마지막 감지', band: '대역', hostname: '호스트명', signal: '신호', @@ -909,6 +928,14 @@ if (remaining.seconds !== undefined && remaining.seconds !== null) { lines.push(`계산 잔여: ${formatDurationSeconds(remaining.seconds)}`); } + if (remaining.runtime_cap_seconds !== undefined && remaining.runtime_cap_seconds !== null) { + const capText = formatDurationSeconds(remaining.runtime_cap_seconds); + const cappedText = remaining.runtime_capped ? ' / 현실 상한 적용' : ''; + lines.push(`현실 상한: ${capText}${cappedText}`); + } + if (remaining.original_seconds !== undefined && remaining.original_seconds !== null) { + lines.push(`상한 전 계산값: ${formatDurationSeconds(remaining.original_seconds)}`); + } if (remaining.drop_per_hour !== undefined && remaining.drop_per_hour !== null) { lines.push(`시간당 SOC 감소: ${Number(remaining.drop_per_hour).toFixed(3)}%`); } @@ -960,28 +987,33 @@ lines.push('세부 후보'); remaining.windows.forEach(row => { if (row.source === 'learned_profile') { - lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%`); + const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : ''; + lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${cap}`); } else if (row.source === 'energy_profile') { const wh = row.wh_per_percent === null || row.wh_per_percent === undefined ? '-' : `${Number(row.wh_per_percent).toFixed(4)}Wh/%`; const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`; - lines.push(`- 에너지 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / ${wh} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`); + const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : ''; + lines.push(`- 에너지 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / ${wh} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}${cap}`); } else if (row.source === 'capacity_power_model') { const capacity = row.capacity_wh === null || row.capacity_wh === undefined ? '-' : `${Number(row.capacity_wh).toFixed(2)}Wh`; const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`; - lines.push(`- 용량 전력 모델: ${capacity} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`); + const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : ''; + lines.push(`- 용량 전력 모델: ${capacity} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}${cap}`); } else if (row.source === 'voltage_slope') { const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`; const kept = row.kept_ratio === null || row.kept_ratio === undefined ? '' : ` / 유효점 ${(Number(row.kept_ratio) * 100).toFixed(0)}%`; const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`; const floor = row.voltage_floor === null || row.voltage_floor === undefined ? '' : ` / 하한 ${Number(row.voltage_floor).toFixed(3)}V`; const volts = row.volts_per_hour === null || row.volts_per_hour === undefined ? '' : ` / ${Number(row.volts_per_hour).toFixed(4)}V/h`; - lines.push(`- 전압 ${row.minutes}분: 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${floor}${volts}`); + const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : ''; + lines.push(`- 전압 ${row.minutes}분: 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${floor}${volts}${cap}`); } else { const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`; const prefix = row.source === 'robust_regression' ? '강건 회귀' : '최근 추세'; const kept = row.kept_ratio === null || row.kept_ratio === undefined ? '' : ` / 유효점 ${(Number(row.kept_ratio) * 100).toFixed(0)}%`; const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`; - lines.push(`- ${prefix} ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}`); + const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : ''; + lines.push(`- ${prefix} ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${cap}`); } }); } @@ -1091,7 +1123,36 @@ } function wifiMetricCell(row, key) { - return `${escapeHtml(row[key] || '-')}`; + if (key !== 'signal') { + return `${escapeHtml(row[key] || '-')}`; + } + + const dbm = wifiSignalDbm(row[key]); + const cls = wifiSignalClass(dbm); + const title = dbm === null ? '' : `title="${attr(signalQualityText(dbm))}"`; + return `${escapeHtml(row[key] || '-')}`; + } + + function wifiSignalDbm(value) { + const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/); + return match ? Number(match[0]) : null; + } + + function wifiSignalClass(dbm) { + if (dbm === null || !Number.isFinite(dbm)) return 'unknown'; + if (dbm >= -55) return 'excellent'; + if (dbm >= -67) return 'good'; + if (dbm >= -76) return 'fair'; + if (dbm >= -84) return 'weak'; + return 'bad'; + } + + function signalQualityText(dbm) { + if (dbm >= -55) return '매우 강함'; + if (dbm >= -67) return '강함'; + if (dbm >= -76) return '보통'; + if (dbm >= -84) return '약함'; + return '매우 약함'; } async function editWifiHostname(mac, currentName) { @@ -1255,6 +1316,71 @@ `).join('') : `${escapeHtml(t('noWifiClients'))}`; + + renderWifiScan(data.wifi?.scan || null); + } + + function renderWifiScan(scan) { + if (!els.wifiScan) return; + if (!scan || scan.enabled === false) { + els.wifiScan.innerHTML = `
${escapeHtml(t('wifiScanDisabled'))}
`; + return; + } + + const networks = Array.isArray(scan.networks) ? scan.networks : []; + const errors = Array.isArray(scan.errors) ? scan.errors : []; + const status = `${scan.cached ? t('wifiScanCached') : t('wifiScanFresh')} · ${escapeHtml(scan.generated_at || '-')}`; + const groups = ['2.4G', '5G'].map(band => { + const bandRows = networks.filter(row => row.band === band); + return ` +
+

${escapeHtml(band)} ${bandRows.length}${scan.total_found ? ` / ${scan.total_found}` : ''}

+
+ + + + ${bandRows.length ? bandRows.map(row => { + const dbm = wifiSignalDbm(row.signal); + return ` + + + + + + + + + + + `; + }).join('') : ``} + +
${escapeHtml(t('ssid'))}BSSID${escapeHtml(t('channel'))}${escapeHtml(t('signal'))}${escapeHtml(t('security'))}${escapeHtml(t('lastSeen'))}WiFi속도기능
${escapeHtml(row.ssid || '-')}${escapeHtml(row.bssid || '-')}${escapeHtml(row.channel ?? '-')} ${row.freq ? `(${escapeHtml(row.freq)}MHz)` : ''}${escapeHtml(row.signal || '-')}${escapeHtml(row.security || '-')}${row.last_seen_ms === null || row.last_seen_ms === undefined ? '-' : `${escapeHtml(row.last_seen_ms)}ms`}${escapeHtml(wifiStandardText(row))}${escapeHtml(row.channel_width || '-')}${escapeHtml(row.rates || '-')}${escapeHtml(row.capability || '-')}
${escapeHtml(t('wifiScanEmpty'))}
+
+
+ `; + }).join(''); + + const errorHtml = errors.length + ? `
${errors.map(row => `
${escapeHtml(row.band || row.iface || '-')} ${escapeHtml(row.reason || '-')}
`).join('')}
` + : ''; + + els.wifiScan.innerHTML = ` +
+

${escapeHtml(t('wifiScan'))}

+ ${status} +
+ ${errorHtml} +
${groups}
+ `; + } + + function wifiStandardText(row) { + const parts = []; + if (row?.he) parts.push('WiFi 6'); + if (row?.vht) parts.push('WiFi 5'); + if (row?.ht) parts.push('WiFi 4'); + return parts.join(' / ') || '-'; } function renderSystemStatus(data) { diff --git a/public/index.php b/public/index.php index d9f07a7..daf58a9 100644 --- a/public/index.php +++ b/public/index.php @@ -125,8 +125,9 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da .select:focus,.slider:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px} .btn:disabled{opacity:.55;cursor:not-allowed} .status-list{display:grid;gap:10px}.status-row{display:grid;grid-template-columns:112px minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 12px;background:var(--card2);border-radius:14px}.status-key{color:var(--sub);font-size:13px}.status-value{overflow:visible;white-space:normal;word-break:break-word;font-variant-numeric:tabular-nums} -.table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px} +.table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.table-wrap.compact{border-radius:12px}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px} .wifi-host-edit{appearance:none;border:0;background:transparent;color:inherit;font:inherit;max-width:100%;padding:0;margin:0;cursor:pointer;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px}.wifi-host-edit:hover{color:var(--blue)} +.wifi-signal{font-weight:700;font-variant-numeric:tabular-nums}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fair{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub)}.wifi-scan{margin-top:16px;display:grid;gap:12px}.wifi-scan-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}.wifi-scan-head h3{margin:0;color:var(--text);font-size:15px;font-weight:600}.wifi-scan-head span{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums}.wifi-scan-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.wifi-scan-band{min-width:0;display:grid;gap:8px}.wifi-scan-band h3{margin:0;color:var(--sub);font-size:13px;font-weight:600}.wifi-scan-band h3 span{font-weight:500;color:var(--sub)}.wifi-scan-table{width:100%;min-width:1180px;table-layout:fixed;border-collapse:collapse}.wifi-scan-table th,.wifi-scan-table td{padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.16);font-size:12px;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-scan-table th{background:var(--table-head);color:var(--sub);font-weight:500}.wifi-scan-table th:nth-child(1),.wifi-scan-table td:nth-child(1){width:190px}.wifi-scan-table th:nth-child(2),.wifi-scan-table td:nth-child(2){width:145px}.wifi-scan-table th:nth-child(3),.wifi-scan-table td:nth-child(3){width:112px}.wifi-scan-table th:nth-child(4),.wifi-scan-table td:nth-child(4){width:92px}.wifi-scan-table th:nth-child(5),.wifi-scan-table td:nth-child(5){width:210px}.wifi-scan-table th:nth-child(6),.wifi-scan-table td:nth-child(6){width:90px}.wifi-scan-table th:nth-child(7),.wifi-scan-table td:nth-child(7){width:105px}.wifi-scan-table th:nth-child(8),.wifi-scan-table td:nth-child(8){width:90px}.wifi-scan-table th:nth-child(9),.wifi-scan-table td:nth-child(9){width:150px}.wifi-scan-errors,.wifi-scan-empty{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);color:var(--text);border-radius:12px;padding:10px 12px;font-size:13px;line-height:1.5}.wifi-scan-errors{display:grid;gap:5px}.wifi-scan-errors strong{color:#fbbf24;margin-right:5px} .chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:var(--input);border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:var(--sub);font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important}.chart-secondary{margin-top:14px} .details-panel{border:1px solid rgba(84,101,128,.52);border-radius:16px;background:rgba(128,145,170,.06);padding:0;margin-top:14px;overflow:hidden}.details-panel>summary{cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:48px;padding:0 14px;color:var(--text);font-weight:500}.details-panel>summary::-webkit-details-marker{display:none}.details-panel>summary::after{content:"+";display:grid;place-items:center;width:24px;height:24px;border:1px solid var(--line);border-radius:999px;color:var(--sub);font-weight:700}.details-panel[open]>summary::after{content:"-"}.details-body{padding:0 14px 14px}.section-note{margin:0;color:var(--sub);font-size:12px} .resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:var(--text);font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-list.compact{max-height:180px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid var(--notice-border);background:var(--notice-bg);border-radius:12px}.spike-log-item.latest{border:1px solid var(--notice-border-strong);background:var(--notice-bg-strong);box-shadow:0 0 0 1px var(--notice-border) inset;}.spike-log-item.alert{border-color:rgba(255,95,87,.58);background:rgba(150,38,38,.18)}.spike-log-item.alert strong{color:#ffb4ad}.spike-log-item strong{font-size:13px;font-weight:500;color:var(--notice-title)}.spike-log-item span{font-size:12px;color:var(--notice-copy);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.service-list{display:grid;gap:10px;max-height:520px;overflow:auto;padding-right:4px}.service-item{border:1px solid var(--line);border-radius:12px;background:var(--card2);padding:12px;display:grid;gap:9px}.service-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.service-title{display:grid;gap:3px;min-width:0}.service-name{font-size:14px;color:var(--text);font-weight:600;word-break:break-word}.service-desc,.service-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums;word-break:break-word}.service-state{border:1px solid var(--line);border-radius:999px;padding:4px 9px;font-size:12px;color:var(--sub);white-space:nowrap}.service-state.active{color:#bbf7d0;border-color:rgba(34,197,94,.35);background:rgba(22,163,74,.14)}.service-state.failed{color:#fecaca;border-color:rgba(239,68,68,.4);background:rgba(185,28,28,.16)}.service-log{margin:0;max-height:160px;overflow:auto;border:1px solid var(--line);border-radius:10px;background:var(--code-bg);color:var(--mono-text);padding:10px;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--code-bg);color:var(--mono-text);padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none} @@ -137,7 +138,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} .dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:var(--card);box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828} .settings-layer{align-items:stretch}.settings-box{width:min(980px,100%);max-height:calc(100vh - 44px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;padding:0;overflow:hidden}.settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:18px 20px;border-bottom:1px solid var(--line)}.settings-sub{margin:0;color:var(--sub);font-size:13px}.settings-body{overflow:auto;padding:16px 20px;display:grid;gap:14px}.settings-group{border:1px solid var(--line);border-radius:16px;background:var(--card2);padding:14px}.settings-group h4{margin:0 0 12px;font-size:15px}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.settings-field{display:grid;gap:7px;min-width:0}.settings-label{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--sub);font-size:13px}.settings-label span:last-child{white-space:nowrap;color:var(--sub)}.settings-control{display:flex;align-items:center;gap:9px}.settings-control input,.settings-select{width:100%;height:42px;border-radius:12px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 12px;outline:none}.settings-control input:focus,.settings-select:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.settings-toggle{display:grid;grid-template-columns:1fr 1fr;align-items:center;width:100%;height:42px;border:1px solid var(--line);border-radius:12px;background:var(--input);padding:4px;cursor:pointer;gap:4px}.settings-toggle input{position:absolute;opacity:0;pointer-events:none}.settings-toggle span{display:grid;place-items:center;min-width:0;height:32px;border-radius:10px;color:var(--sub);font-weight:700;font-size:12px;transition:background .12s ease,color .12s ease}.settings-toggle[data-checked="1"] .settings-toggle-on,.settings-toggle[data-checked="0"] .settings-toggle-off{background:var(--blue);color:#fff}.settings-toggle[data-checked="0"] .settings-toggle-off{background:#64748b}.settings-default{font-size:12px;color:var(--sub);white-space:nowrap}.settings-actions{display:flex;justify-content:flex-end;gap:10px;padding:14px 20px;border-top:1px solid var(--line)} @media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} -@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}} +@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid,.wifi-scan-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}} @media(max-width:720px){.settings-grid{grid-template-columns:1fr}.settings-head{align-items:stretch;flex-direction:column}.settings-actions{display:grid;grid-template-columns:1fr 1fr}.settings-box{max-height:calc(100vh - 24px)}} @@ -269,6 +270,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} +