주변 스캔 기능을 정리
This commit is contained in:
@@ -21,7 +21,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정
|
- 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정
|
||||||
- WiFi 호스트명이 비어 있으면 MAC 기반 임시명을 표시하고, MAC별 수동 호스트명을 DB에 저장해 이후에도 유지
|
- WiFi 호스트명이 비어 있으면 MAC 기반 임시명을 표시하고, MAC별 수동 호스트명을 DB에 저장해 이후에도 유지
|
||||||
- WiFi 신호/송신 속도/수신 속도가 비어 있으면 대역, dBm, 송수신 속도, 5G RTL8822BU 특성을 기준으로 결측값 보정
|
- WiFi 신호/송신 속도/수신 속도가 비어 있으면 대역, dBm, 송수신 속도, 5G RTL8822BU 특성을 기준으로 결측값 보정
|
||||||
- WiFi client, 주변 WiFi 스캔, 주변 Bluetooth 스캔의 신호는 7단계 한글 등급으로 표시하고, hover/focus/touch 시 커스텀 툴팁에서 실제 dBm을 표시
|
- WiFi client 신호는 7단계 한글 등급으로 표시하고, hover/focus/touch 시 커스텀 툴팁에서 실제 dBm을 표시
|
||||||
- System Notice 최근 이력 표시
|
- System Notice 최근 이력 표시
|
||||||
- process CPU/MEM 후보는 프로세스 상세 접힘 영역에서 표시
|
- process CPU/MEM 후보는 프로세스 상세 접힘 영역에서 표시
|
||||||
- `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시
|
- `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시
|
||||||
@@ -59,7 +59,6 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수
|
- `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수
|
||||||
- `apply_policy.php`: CLI/cron 팬 정책 적용
|
- `apply_policy.php`: CLI/cron 팬 정책 적용
|
||||||
- `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드
|
- `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드
|
||||||
- `bin/control_scan_refresh.php`: 주변 WiFi/Bluetooth 스캔 캐시를 백그라운드에서 갱신하는 CLI
|
|
||||||
- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 전송용 운영 CLI
|
- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 전송용 운영 CLI
|
||||||
- `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI
|
- `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI
|
||||||
- `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시
|
- `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시
|
||||||
@@ -73,13 +72,9 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `system_notice_state`: notice 활성 상태와 기준값
|
- `system_notice_state`: notice 활성 상태와 기준값
|
||||||
- `system_notice_logs`: notice 발생 이력
|
- `system_notice_logs`: notice 발생 이력
|
||||||
- `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간
|
- `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간
|
||||||
- `wifi_scan_observations`: 주변 WiFi AP의 BSSID별 마지막 성공 스캔 관측값, 원문, 파싱 JSON
|
|
||||||
- `bluetooth_scan_observations`: 주변 Bluetooth 장치의 주소별 마지막 성공 스캔 관측값, 원문, 파싱 JSON
|
|
||||||
- `wifi_client_aliases`: MAC별 수동 WiFi 호스트명
|
- `wifi_client_aliases`: MAC별 수동 WiFi 호스트명
|
||||||
- `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드
|
- `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드
|
||||||
- `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보
|
- `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보
|
||||||
- `/tmp/control_wifi_scan.json`: 주변 WiFi 스캔 결과 파일 캐시
|
|
||||||
- `/tmp/control_bluetooth_scan.json`: 주변 Bluetooth 스캔 결과 파일 캐시
|
|
||||||
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정
|
- `/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`를 사용합니다.
|
- 배터리 용량 설정은 `/home/seo/secret/control.php`의 `battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다.
|
||||||
|
|
||||||
@@ -121,7 +116,6 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과
|
|||||||
|
|
||||||
- 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step
|
- 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step
|
||||||
- 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부
|
- 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부
|
||||||
- WiFi/Bluetooth 진단: 주변 WiFi AP와 Bluetooth 장치 스캔 켜기/끄기, 스캔 캐시 시간, 명령 timeout, 표시 개수, 원문 일부 표시 여부
|
|
||||||
- 알림 정책: 배터리 긴급/위험/경고/복구 기준, 배터리 낮음/복구 알림 쿨다운, 시스템 유의사항 알림 쿨다운
|
- 알림 정책: 배터리 긴급/위험/경고/복구 기준, 배터리 낮음/복구 알림 쿨다운, 시스템 유의사항 알림 쿨다운
|
||||||
- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수, HA 알림 이력 수
|
- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수, HA 알림 이력 수
|
||||||
- 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수
|
- 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수
|
||||||
@@ -138,7 +132,6 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과
|
|||||||
- 탭 복귀: 즉시 1회 상태 갱신
|
- 탭 복귀: 즉시 1회 상태 갱신
|
||||||
- 잔여 시간 커스텀 팝오버: 상태 갱신과 동일하며 hover/focus 중에도 내용과 위치를 계속 갱신
|
- 잔여 시간 커스텀 팝오버: 상태 갱신과 동일하며 hover/focus 중에도 내용과 위치를 계속 갱신
|
||||||
- dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지
|
- dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지
|
||||||
- 주변 WiFi/Bluetooth 스캔: 상태 snapshot에 포함되지만 파일 캐시를 사용합니다. 캐시가 없거나 만료되어도 첫 응답은 실제 스캔을 기다리지 않고 DB의 마지막 성공 관측 목록을 즉시 반환하며, `bin/control_scan_refresh.php`를 백그라운드로 실행해 캐시를 갱신합니다. 화면의 마지막 관측 경과 문구는 1초 상태 렌더마다 현재 시각 기준으로 다시 계산합니다.
|
|
||||||
- WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행
|
- WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행
|
||||||
- WebSocket 소스 변경 감지: 15초마다 확인 후 재시작
|
- WebSocket 소스 변경 감지: 15초마다 확인 후 재시작
|
||||||
- 팬 슬라이더 자동 적용 debounce: 약 450ms
|
- 팬 슬라이더 자동 적용 debounce: 약 450ms
|
||||||
@@ -198,14 +191,6 @@ control-wifi-observe.service
|
|||||||
|
|
||||||
신호/송신 속도/수신 속도는 실제 `iw station dump` 값이 있으면 그대로 사용합니다. 값이 비어 있으면 2.4G는 일반 802.11n 계열 링크 속도와 dBm 범위를 기준으로, 5G는 Realtek RTL8822BU/802.11ac 계열 동글의 최대 1300Mbps 특성을 참고해 dBm과 링크 속도 사이를 보정합니다. 화면의 신호 칸은 숫자 대신 `최상`, `매우 좋음`, `좋음`, `양호`, `보통`, `약함`, `매우 약함` 7단계 등급으로 표시하고, 마우스 hover/키보드 focus/모바일 touch 시 커스텀 툴팁으로 실제 dBm을 표시합니다. 툴팁 내용과 위치는 1초 상태 렌더마다 다시 갱신됩니다.
|
신호/송신 속도/수신 속도는 실제 `iw station dump` 값이 있으면 그대로 사용합니다. 값이 비어 있으면 2.4G는 일반 802.11n 계열 링크 속도와 dBm 범위를 기준으로, 5G는 Realtek RTL8822BU/802.11ac 계열 동글의 최대 1300Mbps 특성을 참고해 dBm과 링크 속도 사이를 보정합니다. 화면의 신호 칸은 숫자 대신 `최상`, `매우 좋음`, `좋음`, `양호`, `보통`, `약함`, `매우 약함` 7단계 등급으로 표시하고, 마우스 hover/키보드 focus/모바일 touch 시 커스텀 툴팁으로 실제 dBm을 표시합니다. 툴팁 내용과 위치는 1초 상태 렌더마다 다시 갱신됩니다.
|
||||||
|
|
||||||
주변 WiFi 스캔은 대시보드 snapshot에 포함되지만 기본 60초 파일 캐시를 사용합니다. `iw dev wlan0 scan`, `iw dev wlan1 scan`을 시도하고 SSID, BSSID, 주파수, 채널, 신호, 보안, 마지막 관측, WiFi 세대, 채널 폭, 지원 속도, capability를 표시합니다. 캐시가 없거나 만료된 첫 요청에서는 실제 스캔을 기다리지 않고 DB의 마지막 성공 관측값을 즉시 반환한 뒤, 백그라운드 CLI가 캐시를 갱신합니다. 화면은 WiFi client 섹션 아래 별도 카드로 분리하며, 2.4G와 5G를 나란히 두지 않고 각각 독립 표로 세로 배치합니다.
|
|
||||||
|
|
||||||
스캔에 성공한 AP는 `wifi_scan_observations`에 BSSID 기준으로 저장합니다. SSID, BSSID, 신호, 보안, 채널 폭 같은 화면 표시값뿐 아니라 `iw` 원문과 파싱 결과 JSON도 함께 저장해 이후 분석 근거를 남깁니다. 다음 스캔이 실패하거나 특정 대역이 AP 모드라 스캔할 수 없어도 목록을 비우지 않고, DB에 누적된 BSSID별 마지막 성공 관측값을 계속 표시합니다. 화면의 마지막 관측 경과는 캐시된 API 값에 고정하지 않고 1초 상태 렌더마다 현재 시각 기준으로 다시 계산합니다. live 스캔 결과와 DB 관측값 모두 마지막 관측 시각과 경과 시간을 함께 표시합니다.
|
|
||||||
|
|
||||||
스캔 실패 사유는 API payload에는 남지만 대시보드에는 노란 경고 박스로 표시하지 않습니다. 스캔 표의 보안, 속도, capability 같은 긴 문자열은 말줄임으로 자르지 않고 표 전체를 가로 스크롤해 확인합니다. 대역 제목 옆 숫자는 해당 대역에 표시 중인 AP 개수입니다. 스캔 표에서 가로 스크롤을 드래그하거나 스크롤 중일 때는 1초 상태 렌더가 표 컨테이너를 교체하지 않도록 짧게 보류해 스크롤바 클릭/드래그가 끊기지 않게 합니다.
|
|
||||||
|
|
||||||
주변 Bluetooth 스캔은 WiFi 스캔 카드 아래 별도 카드로 표시합니다. `bluetoothctl scan on`, `bluetoothctl devices`, `bluetoothctl info <address>`를 조합해 주소, 이름, alias, RSSI, TxPower, 연결/페어링/신뢰/차단 상태, class, icon, UUID, 제조사 데이터, 서비스 데이터, modalias, appearance를 가능한 범위에서 수집합니다. 성공한 장치는 `bluetooth_scan_observations`에 주소 기준으로 저장하며 원문과 파싱 결과 JSON도 함께 남깁니다. Bluetooth 실시간 스캔은 10초 이상 걸릴 수 있으므로 화면 첫 응답에서는 기다리지 않고 마지막 관측값을 먼저 표시하며, 백그라운드 CLI가 새 캐시를 채웁니다. 스캔이 실패하거나 주변 장치가 일시적으로 사라져도 마지막 성공 관측값을 계속 표시하고, 마지막 관측 경과는 1초 상태 렌더마다 다시 계산합니다.
|
|
||||||
|
|
||||||
## 처리 흐름
|
## 처리 흐름
|
||||||
|
|
||||||
1. 화면 진입 시 로그인 세션과 remember token을 확인합니다.
|
1. 화면 진입 시 로그인 세션과 remember token을 확인합니다.
|
||||||
@@ -215,19 +200,16 @@ control-wifi-observe.service
|
|||||||
5. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, 5G 연결 시간이 `N/A`이면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다.
|
5. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, 5G 연결 시간이 `N/A`이면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다.
|
||||||
6. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다.
|
6. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다.
|
||||||
7. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다.
|
7. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다.
|
||||||
8. 주변 WiFi 스캔은 캐시 주기를 확인한 뒤 `iw dev <iface> scan`을 시도하고, 성공 결과를 DB에 저장한 뒤 live 값과 마지막 관측값을 주변 WiFi 스캔 전용 카드에 표시합니다.
|
8. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다.
|
||||||
9. 주변 Bluetooth 스캔은 캐시 주기를 확인한 뒤 `bluetoothctl`로 scan/devices/info를 시도하고, 성공 결과를 DB에 저장한 뒤 live 값과 마지막 관측값을 주변 Bluetooth 스캔 전용 카드에 표시합니다.
|
9. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
|
||||||
10. 스캔 캐시가 만료된 경우 status snapshot은 스캔 완료를 기다리지 않고 `refresh_pending=true`와 마지막 관측값을 반환하며, 백그라운드 refresh가 다음 렌더에서 사용할 캐시를 생성합니다.
|
10. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
|
||||||
9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다.
|
11. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
|
||||||
10. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
|
12. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
|
||||||
11. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
|
13. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화와 현실 상한을 반영합니다.
|
||||||
12. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
|
14. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 잔여 시간과 같은 값을 사용하도록 history 응답을 동기화합니다.
|
||||||
13. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
|
15. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
|
||||||
14. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화와 현실 상한을 반영합니다.
|
16. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다.
|
||||||
15. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 잔여 시간과 같은 값을 사용하도록 history 응답을 동기화합니다.
|
17. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다.
|
||||||
16. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
|
|
||||||
17. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다.
|
|
||||||
18. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다.
|
|
||||||
|
|
||||||
## 주요 함수/모듈
|
## 주요 함수/모듈
|
||||||
|
|
||||||
@@ -242,11 +224,6 @@ control-wifi-observe.service
|
|||||||
- `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용
|
- `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용
|
||||||
- `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장
|
- `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장
|
||||||
- `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정
|
- `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정
|
||||||
- `wifi_scan_data()`, `parse_wifi_scan_networks()`: 주변 WiFi AP 스캔, 실패 사유 정리, 파일 캐시 구성
|
|
||||||
- `save_wifi_scan_observations()`, `wifi_scan_observed_networks()`, `merge_wifi_scan_networks()`: 주변 WiFi 마지막 성공 관측값 저장, 조회, live/DB 결과 병합
|
|
||||||
- `bluetooth_scan_data()`, `parse_bluetooth_info()`: 주변 Bluetooth 장치 스캔, 세부 정보 수집, 파일 캐시 구성
|
|
||||||
- `save_bluetooth_scan_observations()`, `bluetooth_scan_observed_devices()`, `merge_bluetooth_scan_devices()`: 주변 Bluetooth 마지막 성공 관측값 저장, 조회, live/DB 결과 병합
|
|
||||||
- `wifi_scan_refresh_payload()`, `bluetooth_scan_refresh_payload()`, `scan_start_background_refresh()`: 느린 실제 스캔을 백그라운드 캐시 갱신으로 분리
|
|
||||||
- `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장
|
- `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장
|
||||||
- `ha_notify_due()`: 동일 tag 기준 쿨다운 판정
|
- `ha_notify_due()`: 동일 tag 기준 쿨다운 판정
|
||||||
- `clear_ha_notification()`: 같은 tag의 Android 알림 제거 명령 발송
|
- `clear_ha_notification()`: 같은 tag의 Android 알림 제거 명령 발송
|
||||||
@@ -292,7 +269,7 @@ control-wifi-observe.service
|
|||||||
- 센서 수집 주기와 DB 증가량을 확인합니다.
|
- 센서 수집 주기와 DB 증가량을 확인합니다.
|
||||||
- 배터리팩 변경 시 `/home/seo/secret/control.php`의 `battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`이며, SOC 추세가 부족한 경우의 fallback 계산에 사용됩니다.
|
- 배터리팩 변경 시 `/home/seo/secret/control.php`의 `battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`이며, SOC 추세가 부족한 경우의 fallback 계산에 사용됩니다.
|
||||||
- 잔여 시간이 갑자기 흔들리면 커스텀 팝오버의 `계산 출처`, `반영 구간`, `시간당 SOC 감소`, `추세 신뢰도`, `최근 CPU 전력`을 먼저 확인합니다.
|
- 잔여 시간이 갑자기 흔들리면 커스텀 팝오버의 `계산 출처`, `반영 구간`, `시간당 SOC 감소`, `추세 신뢰도`, `최근 CPU 전력`을 먼저 확인합니다.
|
||||||
- WiFi client 표와 주변 WiFi/Bluetooth 스캔 표는 갱신 중에도 가로 스크롤 위치를 보존합니다. 주변 WiFi 스캔은 별도 카드에서 대역별 독립 표로 렌더링하고, Bluetooth 스캔은 그 아래 독립 카드에서 장치별 상세 표로 렌더링해 client 표 스크롤과 서로 간섭하지 않도록 합니다. 스캔 표는 스크롤/드래그 중 DOM 재생성을 잠시 보류해 사용자의 좌우 스크롤 조작을 우선합니다.
|
- WiFi client 표는 갱신 중에도 가로 스크롤 위치를 보존합니다.
|
||||||
- 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다.
|
- 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다.
|
||||||
- 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다.
|
- 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다.
|
||||||
- 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다.
|
- 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다.
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
define('CONTROL_API_LIBRARY', true);
|
|
||||||
|
|
||||||
require __DIR__ . '/../public/api.php';
|
|
||||||
|
|
||||||
$type = (string)($argv[1] ?? '');
|
|
||||||
if ($type === 'wifi') {
|
|
||||||
wifi_scan_refresh_payload();
|
|
||||||
} elseif ($type === 'bluetooth') {
|
|
||||||
bluetooth_scan_refresh_payload();
|
|
||||||
}
|
|
||||||
@@ -153,17 +153,6 @@ 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.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_restart' => ['group' => 'security', 'label' => 'WiFi restart 허용', 'type' => 'boolean', 'default' => 1],
|
||||||
'security.allow_wifi_reload' => ['group' => 'security', 'label' => 'WiFi reload 허용', '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],
|
|
||||||
'bluetooth.scan_enabled' => ['group' => 'wifi', 'label' => '주변 Bluetooth 스캔', 'type' => 'boolean', 'default' => 1],
|
|
||||||
'bluetooth.scan_cache_seconds' => ['group' => 'wifi', 'label' => '주변 Bluetooth 스캔 캐시', 'type' => 'number', 'default' => 60, 'min' => 10, 'max' => 600, 'step' => 5, 'unit' => '초'],
|
|
||||||
'bluetooth.scan_timeout_seconds' => ['group' => 'wifi', 'label' => '주변 Bluetooth 스캔 timeout', 'type' => 'number', 'default' => 12, 'min' => 5, 'max' => 60, 'step' => 1, 'unit' => '초'],
|
|
||||||
'bluetooth.scan_max_devices' => ['group' => 'wifi', 'label' => '주변 Bluetooth 표시 개수', 'type' => 'number', 'default' => 80, 'min' => 10, 'max' => 300, 'step' => 10, 'unit' => '개'],
|
|
||||||
'bluetooth.scan_include_raw' => ['group' => 'wifi', 'label' => '주변 Bluetooth 원문 일부 표시', '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_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'],
|
'fan.auto_max_temp' => ['group' => 'fan', 'label' => '팬 최대 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'],
|
||||||
'fan.full_temp' => ['group' => 'fan', 'label' => '즉시 최대 PWM 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'],
|
'fan.full_temp' => ['group' => 'fan', 'label' => '즉시 최대 PWM 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'],
|
||||||
@@ -535,73 +524,6 @@ function bootstrap_db(): void
|
|||||||
COLLATE=utf8mb4_unicode_ci
|
COLLATE=utf8mb4_unicode_ci
|
||||||
");
|
");
|
||||||
|
|
||||||
$pdo->exec("
|
|
||||||
CREATE TABLE IF NOT EXISTS wifi_scan_observations (
|
|
||||||
bssid VARCHAR(17) NOT NULL PRIMARY KEY,
|
|
||||||
iface VARCHAR(32) NOT NULL,
|
|
||||||
band VARCHAR(16) NOT NULL,
|
|
||||||
ssid VARCHAR(255) NULL,
|
|
||||||
freq INT NULL,
|
|
||||||
channel INT NULL,
|
|
||||||
signal_dbm DECIMAL(6,2) NULL,
|
|
||||||
signal_text VARCHAR(32) NULL,
|
|
||||||
security_text VARCHAR(255) NULL,
|
|
||||||
capability_text VARCHAR(255) NULL,
|
|
||||||
rates_text VARCHAR(255) NULL,
|
|
||||||
channel_width VARCHAR(64) NULL,
|
|
||||||
raw_text MEDIUMTEXT NULL,
|
|
||||||
detail_json LONGTEXT NULL,
|
|
||||||
ht TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
vht TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
he TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_last_seen_at (last_seen_at),
|
|
||||||
INDEX idx_band (band),
|
|
||||||
INDEX idx_iface (iface)
|
|
||||||
) ENGINE=InnoDB
|
|
||||||
DEFAULT CHARSET=utf8mb4
|
|
||||||
COLLATE=utf8mb4_unicode_ci
|
|
||||||
");
|
|
||||||
|
|
||||||
$pdo->exec("
|
|
||||||
CREATE TABLE IF NOT EXISTS bluetooth_scan_observations (
|
|
||||||
address VARCHAR(17) NOT NULL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NULL,
|
|
||||||
alias VARCHAR(255) NULL,
|
|
||||||
address_type VARCHAR(32) NULL,
|
|
||||||
rssi INT NULL,
|
|
||||||
tx_power INT NULL,
|
|
||||||
class_text VARCHAR(64) NULL,
|
|
||||||
icon VARCHAR(64) NULL,
|
|
||||||
paired TINYINT(1) NULL,
|
|
||||||
bonded TINYINT(1) NULL,
|
|
||||||
trusted TINYINT(1) NULL,
|
|
||||||
blocked TINYINT(1) NULL,
|
|
||||||
connected TINYINT(1) NULL,
|
|
||||||
legacy_pairing TINYINT(1) NULL,
|
|
||||||
uuids_text MEDIUMTEXT NULL,
|
|
||||||
manufacturer_data MEDIUMTEXT NULL,
|
|
||||||
service_data MEDIUMTEXT NULL,
|
|
||||||
service_uuids_text MEDIUMTEXT NULL,
|
|
||||||
modalias VARCHAR(255) NULL,
|
|
||||||
appearance VARCHAR(64) NULL,
|
|
||||||
raw_text MEDIUMTEXT NULL,
|
|
||||||
detail_json LONGTEXT NULL,
|
|
||||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_last_seen_at (last_seen_at),
|
|
||||||
INDEX idx_rssi (rssi),
|
|
||||||
INDEX idx_connected (connected)
|
|
||||||
) ENGINE=InnoDB
|
|
||||||
DEFAULT CHARSET=utf8mb4
|
|
||||||
COLLATE=utf8mb4_unicode_ci
|
|
||||||
");
|
|
||||||
|
|
||||||
$pdo->exec("
|
$pdo->exec("
|
||||||
CREATE TABLE IF NOT EXISTS remember_tokens (
|
CREATE TABLE IF NOT EXISTS remember_tokens (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -691,8 +613,6 @@ function bootstrap_db(): void
|
|||||||
"ALTER TABLE system_notice_state ADD COLUMN active_temp_delta DECIMAL(8,2) NULL AFTER active_reason",
|
"ALTER TABLE system_notice_state ADD COLUMN active_temp_delta DECIMAL(8,2) NULL AFTER active_reason",
|
||||||
"ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta",
|
"ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta",
|
||||||
"ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta",
|
"ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta",
|
||||||
"ALTER TABLE wifi_scan_observations ADD COLUMN raw_text MEDIUMTEXT NULL AFTER channel_width",
|
|
||||||
"ALTER TABLE wifi_scan_observations ADD COLUMN detail_json LONGTEXT NULL AFTER raw_text",
|
|
||||||
"ALTER TABLE sensor_logs DROP COLUMN disk_total_gb",
|
"ALTER TABLE sensor_logs DROP COLUMN disk_total_gb",
|
||||||
"ALTER TABLE sensor_logs DROP COLUMN disk_used_gb",
|
"ALTER TABLE sensor_logs DROP COLUMN disk_used_gb",
|
||||||
"ALTER TABLE sensor_logs DROP COLUMN disk_free_gb",
|
"ALTER TABLE sensor_logs DROP COLUMN disk_free_gb",
|
||||||
|
|||||||
-974
@@ -2647,976 +2647,6 @@ function iw_station_dump(string $iface): string
|
|||||||
return sh(['/usr/sbin/iw', 'dev', $iface, 'station', 'dump'], true, 4)['out'];
|
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 scan_refresh_lock_path(string $type): string
|
|
||||||
{
|
|
||||||
return sys_get_temp_dir() . '/control_' . preg_replace('/[^a-z0-9_]/i', '', $type) . '_scan_refresh.lock';
|
|
||||||
}
|
|
||||||
|
|
||||||
function scan_start_background_refresh(string $type): void
|
|
||||||
{
|
|
||||||
$lockPath = scan_refresh_lock_path($type);
|
|
||||||
$script = dirname(__DIR__) . '/bin/control_scan_refresh.php';
|
|
||||||
if (!is_file($script)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cmd = '/usr/bin/flock -n '
|
|
||||||
. escapeshellarg($lockPath)
|
|
||||||
. ' /usr/bin/php '
|
|
||||||
. escapeshellarg($script)
|
|
||||||
. ' '
|
|
||||||
. escapeshellarg($type)
|
|
||||||
. ' >/dev/null 2>&1 &';
|
|
||||||
exec($cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scan_cached_payload(string $cachePath, int $cacheSeconds): ?array
|
|
||||||
{
|
|
||||||
if (!is_file($cachePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cached = json_decode((string)@file_get_contents($cachePath), true);
|
|
||||||
if (!is_array($cached)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cached['cached'] = true;
|
|
||||||
$cached['stale'] = (time() - filemtime($cachePath)) >= $cacheSeconds;
|
|
||||||
|
|
||||||
return $cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scan_write_cache(string $cachePath, array $payload): void
|
|
||||||
{
|
|
||||||
$tmp = $cachePath . '.' . getmypid() . '.tmp';
|
|
||||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
if ($json === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (@file_put_contents($tmp, $json) === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
@chmod($tmp, 0666);
|
|
||||||
if (!@rename($tmp, $cachePath)) {
|
|
||||||
@unlink($tmp);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
@chmod($cachePath, 0666);
|
|
||||||
}
|
|
||||||
|
|
||||||
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'] ?? [])))) ?: '개방 또는 미확인';
|
|
||||||
$current['raw_text'] = implode("\n", $raw);
|
|
||||||
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 save_wifi_scan_observations(array $networks): void
|
|
||||||
{
|
|
||||||
if ($networks === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$stmt = db()->prepare("
|
|
||||||
INSERT INTO wifi_scan_observations
|
|
||||||
(bssid, iface, band, ssid, freq, channel, signal_dbm, signal_text, security_text, capability_text, rates_text, channel_width, raw_text, detail_json, ht, vht, he, first_seen_at, last_seen_at)
|
|
||||||
VALUES
|
|
||||||
(:bssid, :iface, :band, :ssid, :freq, :channel, :signal_dbm, :signal_text, :security_text, :capability_text, :rates_text, :channel_width, :raw_text, :detail_json, :ht, :vht, :he, NOW(), NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
iface = VALUES(iface),
|
|
||||||
band = VALUES(band),
|
|
||||||
ssid = VALUES(ssid),
|
|
||||||
freq = VALUES(freq),
|
|
||||||
channel = VALUES(channel),
|
|
||||||
signal_dbm = VALUES(signal_dbm),
|
|
||||||
signal_text = VALUES(signal_text),
|
|
||||||
security_text = VALUES(security_text),
|
|
||||||
capability_text = VALUES(capability_text),
|
|
||||||
rates_text = VALUES(rates_text),
|
|
||||||
channel_width = VALUES(channel_width),
|
|
||||||
raw_text = VALUES(raw_text),
|
|
||||||
detail_json = VALUES(detail_json),
|
|
||||||
ht = VALUES(ht),
|
|
||||||
vht = VALUES(vht),
|
|
||||||
he = VALUES(he),
|
|
||||||
last_seen_at = NOW()
|
|
||||||
");
|
|
||||||
|
|
||||||
foreach ($networks as $network) {
|
|
||||||
$bssid = strtolower((string)($network['bssid'] ?? ''));
|
|
||||||
if (!wifi_valid_mac($bssid)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt->execute([
|
|
||||||
':bssid' => $bssid,
|
|
||||||
':iface' => mb_substr((string)($network['iface'] ?? ''), 0, 32),
|
|
||||||
':band' => mb_substr((string)($network['band'] ?? ''), 0, 16),
|
|
||||||
':ssid' => mb_substr((string)($network['ssid'] ?? ''), 0, 255),
|
|
||||||
':freq' => is_numeric($network['freq'] ?? null) ? (int)$network['freq'] : null,
|
|
||||||
':channel' => is_numeric($network['channel'] ?? null) ? (int)$network['channel'] : null,
|
|
||||||
':signal_dbm' => is_numeric($network['signal_dbm'] ?? null) ? (float)$network['signal_dbm'] : null,
|
|
||||||
':signal_text' => mb_substr((string)($network['signal'] ?? ''), 0, 32),
|
|
||||||
':security_text' => mb_substr((string)($network['security'] ?? ''), 0, 255),
|
|
||||||
':capability_text' => mb_substr((string)($network['capability'] ?? ''), 0, 255),
|
|
||||||
':rates_text' => mb_substr((string)($network['rates'] ?? ''), 0, 255),
|
|
||||||
':channel_width' => mb_substr((string)($network['channel_width'] ?? ''), 0, 64),
|
|
||||||
':raw_text' => (string)($network['raw_text'] ?? $network['raw'] ?? ''),
|
|
||||||
':detail_json' => json_encode($network, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
||||||
':ht' => !empty($network['ht']) ? 1 : 0,
|
|
||||||
':vht' => !empty($network['vht']) ? 1 : 0,
|
|
||||||
':he' => !empty($network['he']) ? 1 : 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (Throwable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function wifi_scan_observed_networks(int $limit): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$limit = max(1, min(500, $limit));
|
|
||||||
$stmt = db()->query("
|
|
||||||
SELECT
|
|
||||||
bssid,
|
|
||||||
iface,
|
|
||||||
band,
|
|
||||||
ssid,
|
|
||||||
freq,
|
|
||||||
channel,
|
|
||||||
signal_dbm,
|
|
||||||
signal_text,
|
|
||||||
security_text,
|
|
||||||
capability_text,
|
|
||||||
rates_text,
|
|
||||||
channel_width,
|
|
||||||
raw_text,
|
|
||||||
detail_json,
|
|
||||||
ht,
|
|
||||||
vht,
|
|
||||||
he,
|
|
||||||
first_seen_at,
|
|
||||||
last_seen_at,
|
|
||||||
TIMESTAMPDIFF(SECOND, last_seen_at, NOW()) AS observed_age_seconds
|
|
||||||
FROM wifi_scan_observations
|
|
||||||
ORDER BY
|
|
||||||
FIELD(band, '2.4G', '5G', '6G', '기타'),
|
|
||||||
last_seen_at DESC,
|
|
||||||
signal_dbm DESC
|
|
||||||
LIMIT {$limit}
|
|
||||||
");
|
|
||||||
} catch (Throwable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = [];
|
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
|
||||||
$signalDbm = is_numeric($row['signal_dbm'] ?? null) ? (float)$row['signal_dbm'] : null;
|
|
||||||
$rows[] = [
|
|
||||||
'bssid' => (string)($row['bssid'] ?? ''),
|
|
||||||
'iface' => (string)($row['iface'] ?? ''),
|
|
||||||
'band' => (string)($row['band'] ?? ''),
|
|
||||||
'ssid' => (string)($row['ssid'] ?? ''),
|
|
||||||
'freq' => is_numeric($row['freq'] ?? null) ? (int)$row['freq'] : null,
|
|
||||||
'channel' => is_numeric($row['channel'] ?? null) ? (int)$row['channel'] : null,
|
|
||||||
'signal_dbm' => $signalDbm,
|
|
||||||
'signal' => (string)($row['signal_text'] ?? ($signalDbm === null ? 'N/A' : number_format($signalDbm, 1) . ' dBm')),
|
|
||||||
'security' => (string)($row['security_text'] ?? ''),
|
|
||||||
'capability' => (string)($row['capability_text'] ?? ''),
|
|
||||||
'rates' => (string)($row['rates_text'] ?? ''),
|
|
||||||
'channel_width' => (string)($row['channel_width'] ?? ''),
|
|
||||||
'raw' => (bool)setting_value('wifi.scan_include_raw') ? mb_substr((string)($row['raw_text'] ?? ''), 0, 3000) : null,
|
|
||||||
'ht' => !empty($row['ht']),
|
|
||||||
'vht' => !empty($row['vht']),
|
|
||||||
'he' => !empty($row['he']),
|
|
||||||
'first_seen_at' => (string)($row['first_seen_at'] ?? ''),
|
|
||||||
'last_seen_at' => (string)($row['last_seen_at'] ?? ''),
|
|
||||||
'observed_age_seconds' => is_numeric($row['observed_age_seconds'] ?? null) ? (int)$row['observed_age_seconds'] : null,
|
|
||||||
'observed_source' => 'db',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function merge_wifi_scan_networks(array $freshNetworks, array $observedNetworks): array
|
|
||||||
{
|
|
||||||
$merged = [];
|
|
||||||
|
|
||||||
foreach ($observedNetworks as $network) {
|
|
||||||
$bssid = strtolower((string)($network['bssid'] ?? ''));
|
|
||||||
if ($bssid !== '') {
|
|
||||||
$merged[$bssid] = $network;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($freshNetworks as $network) {
|
|
||||||
$bssid = strtolower((string)($network['bssid'] ?? ''));
|
|
||||||
if ($bssid === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$network['observed_source'] = 'live';
|
|
||||||
$network['last_seen_at'] = date('Y-m-d H:i:s');
|
|
||||||
$network['observed_age_seconds'] = 0;
|
|
||||||
$merged[$bssid] = $network;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = array_values($merged);
|
|
||||||
usort($rows, 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;
|
|
||||||
}
|
|
||||||
$sourceCompare = (($a['observed_source'] ?? '') === 'live' ? 0 : 1) <=> (($b['observed_source'] ?? '') === 'live' ? 0 : 1);
|
|
||||||
if ($sourceCompare !== 0) {
|
|
||||||
return $sourceCompare;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (float)($b['signal_dbm'] ?? -999) <=> (float)($a['signal_dbm'] ?? -999);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wifi_scan_refresh_payload(): array
|
|
||||||
{
|
|
||||||
$cachePath = wifi_scan_cache_path();
|
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$max = (int)setting_value('wifi.scan_max_networks');
|
|
||||||
save_wifi_scan_observations($networks);
|
|
||||||
$observedNetworks = wifi_scan_observed_networks($max);
|
|
||||||
$mergedNetworks = merge_wifi_scan_networks($networks, $observedNetworks);
|
|
||||||
$payload = [
|
|
||||||
'enabled' => true,
|
|
||||||
'cached' => false,
|
|
||||||
'generated_at' => date('Y-m-d H:i:s'),
|
|
||||||
'interfaces' => array_values($interfaces),
|
|
||||||
'networks' => array_slice($mergedNetworks, 0, $max),
|
|
||||||
'live_found' => count($networks),
|
|
||||||
'total_found' => count($mergedNetworks),
|
|
||||||
'observed_hours' => null,
|
|
||||||
'observed_mode' => 'all',
|
|
||||||
'errors' => $errors,
|
|
||||||
];
|
|
||||||
|
|
||||||
scan_write_cache($cachePath, $payload);
|
|
||||||
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wifi_scan_data(): array
|
|
||||||
{
|
|
||||||
$max = (int)setting_value('wifi.scan_max_networks');
|
|
||||||
if (!(bool)setting_value('wifi.scan_enabled')) {
|
|
||||||
return [
|
|
||||||
'enabled' => false,
|
|
||||||
'cached' => false,
|
|
||||||
'stale' => false,
|
|
||||||
'generated_at' => null,
|
|
||||||
'interfaces' => [],
|
|
||||||
'networks' => wifi_scan_observed_networks($max),
|
|
||||||
'observed_hours' => null,
|
|
||||||
'observed_mode' => 'all',
|
|
||||||
'errors' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cacheSeconds = (int)setting_value('wifi.scan_cache_seconds');
|
|
||||||
$cachePath = wifi_scan_cache_path();
|
|
||||||
$cached = scan_cached_payload($cachePath, $cacheSeconds);
|
|
||||||
if ($cached !== null && empty($cached['stale'])) {
|
|
||||||
return $cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
scan_start_background_refresh('wifi');
|
|
||||||
$observedNetworks = wifi_scan_observed_networks($max);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'enabled' => true,
|
|
||||||
'cached' => $cached !== null,
|
|
||||||
'stale' => true,
|
|
||||||
'refresh_pending' => true,
|
|
||||||
'generated_at' => is_array($cached) ? (string)($cached['generated_at'] ?? '') : date('Y-m-d H:i:s'),
|
|
||||||
'interfaces' => is_array($cached) ? (array)($cached['interfaces'] ?? []) : [],
|
|
||||||
'networks' => $observedNetworks,
|
|
||||||
'live_found' => is_array($cached) ? (int)($cached['live_found'] ?? 0) : 0,
|
|
||||||
'total_found' => count($observedNetworks),
|
|
||||||
'observed_hours' => null,
|
|
||||||
'observed_mode' => 'all',
|
|
||||||
'errors' => is_array($cached) ? (array)($cached['errors'] ?? []) : [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetooth_scan_cache_path(): string
|
|
||||||
{
|
|
||||||
return sys_get_temp_dir() . '/control_bluetooth_scan.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetoothctl_path(): string
|
|
||||||
{
|
|
||||||
foreach (['/usr/bin/bluetoothctl', '/bin/bluetoothctl'] as $path) {
|
|
||||||
if (is_executable($path)) {
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bluetoothctl';
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetooth_bool_value(?string $value): ?bool
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lower = strtolower(trim($value));
|
|
||||||
if (in_array($lower, ['yes', 'true', '1'], true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (in_array($lower, ['no', 'false', '0'], true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetooth_scan_devices_from_text(string $text): array
|
|
||||||
{
|
|
||||||
$devices = [];
|
|
||||||
|
|
||||||
foreach (explode("\n", $text) as $line) {
|
|
||||||
$trimmed = trim($line);
|
|
||||||
if (preg_match('/(?:^|\s)Device\s+([0-9A-F:]{17})(?:\s+(.+))?$/i', $trimmed, $m)) {
|
|
||||||
$address = strtoupper($m[1]);
|
|
||||||
$devices[$address] = [
|
|
||||||
'address' => $address,
|
|
||||||
'name' => isset($m[2]) ? trim($m[2]) : '',
|
|
||||||
'scan_line' => $trimmed,
|
|
||||||
];
|
|
||||||
} elseif (preg_match('/Device\s+([0-9A-F:]{17})\s+(RSSI|TxPower):\s*(-?\d+)/i', $trimmed, $m)) {
|
|
||||||
$address = strtoupper($m[1]);
|
|
||||||
$devices[$address] = $devices[$address] ?? ['address' => $address, 'name' => '', 'scan_line' => ''];
|
|
||||||
$key = strtolower($m[2]) === 'rssi' ? 'rssi' : 'tx_power';
|
|
||||||
$devices[$address][$key] = (int)$m[3];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $devices;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parse_bluetooth_info(string $address, string $text, array $base, bool $includeRaw): array
|
|
||||||
{
|
|
||||||
$device = [
|
|
||||||
'address' => strtoupper($address),
|
|
||||||
'name' => (string)($base['name'] ?? ''),
|
|
||||||
'alias' => '',
|
|
||||||
'address_type' => '',
|
|
||||||
'rssi' => is_numeric($base['rssi'] ?? null) ? (int)$base['rssi'] : null,
|
|
||||||
'tx_power' => is_numeric($base['tx_power'] ?? null) ? (int)$base['tx_power'] : null,
|
|
||||||
'class' => '',
|
|
||||||
'icon' => '',
|
|
||||||
'paired' => null,
|
|
||||||
'bonded' => null,
|
|
||||||
'trusted' => null,
|
|
||||||
'blocked' => null,
|
|
||||||
'connected' => null,
|
|
||||||
'legacy_pairing' => null,
|
|
||||||
'uuids' => [],
|
|
||||||
'manufacturer_data' => [],
|
|
||||||
'service_data' => [],
|
|
||||||
'service_uuids' => [],
|
|
||||||
'modalias' => '',
|
|
||||||
'appearance' => '',
|
|
||||||
'raw_text' => trim(($base['scan_line'] ?? '') . "\n" . $text),
|
|
||||||
'extra' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach (explode("\n", $text) as $line) {
|
|
||||||
$trimmed = trim($line);
|
|
||||||
if ($trimmed === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/^Device\s+([0-9A-F:]{17})(?:\s+\(([^)]+)\))?/i', $trimmed, $m)) {
|
|
||||||
$device['address'] = strtoupper($m[1]);
|
|
||||||
if (!empty($m[2])) {
|
|
||||||
$device['address_type'] = trim($m[2]);
|
|
||||||
}
|
|
||||||
} elseif (preg_match('/^Name:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$device['name'] = trim($m[1]);
|
|
||||||
} elseif (preg_match('/^Alias:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$device['alias'] = trim($m[1]);
|
|
||||||
} elseif (preg_match('/^Class:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$device['class'] = trim($m[1]);
|
|
||||||
} elseif (preg_match('/^Icon:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$device['icon'] = trim($m[1]);
|
|
||||||
} elseif (preg_match('/^RSSI:\s*(-?\d+)/i', $trimmed, $m)) {
|
|
||||||
$device['rssi'] = (int)$m[1];
|
|
||||||
} elseif (preg_match('/^TxPower:\s*(-?\d+)/i', $trimmed, $m)) {
|
|
||||||
$device['tx_power'] = (int)$m[1];
|
|
||||||
} elseif (preg_match('/^UUID:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$device['uuids'][] = trim($m[1]);
|
|
||||||
} elseif (preg_match('/^ServiceData(?:\s+([^:]+))?:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$key = trim((string)($m[1] ?? ''));
|
|
||||||
$device['service_data'][] = ($key !== '' ? $key . ': ' : '') . trim($m[2]);
|
|
||||||
} elseif (preg_match('/^ManufacturerData(?:\s+([^:]+))?:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$key = trim((string)($m[1] ?? ''));
|
|
||||||
$device['manufacturer_data'][] = ($key !== '' ? $key . ': ' : '') . trim($m[2]);
|
|
||||||
} elseif (preg_match('/^ServiceUUIDs:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$device['service_uuids'][] = trim($m[1]);
|
|
||||||
} elseif (preg_match('/^Modalias:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$device['modalias'] = trim($m[1]);
|
|
||||||
} elseif (preg_match('/^Appearance:\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$device['appearance'] = trim($m[1]);
|
|
||||||
} elseif (preg_match('/^(Paired|Bonded|Trusted|Blocked|Connected|LegacyPairing):\s*(.*)$/i', $trimmed, $m)) {
|
|
||||||
$key = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $m[1]));
|
|
||||||
$device[$key] = bluetooth_bool_value($m[2]);
|
|
||||||
} else {
|
|
||||||
$device['extra'][] = $trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$device['display_name'] = $device['alias'] !== '' ? $device['alias'] : ($device['name'] !== '' ? $device['name'] : '이름 없음');
|
|
||||||
if ($includeRaw) {
|
|
||||||
$device['raw'] = mb_substr($device['raw_text'], 0, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $device;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetooth_scan_failure_reason(int $code, string $out): string
|
|
||||||
{
|
|
||||||
$lower = strtolower($out);
|
|
||||||
if ($code === 124) {
|
|
||||||
return 'Bluetooth 스캔 명령이 timeout 되었습니다.';
|
|
||||||
}
|
|
||||||
if (str_contains($lower, 'no default controller') || str_contains($lower, 'not available')) {
|
|
||||||
return 'Bluetooth 컨트롤러를 찾지 못했거나 사용할 수 없는 상태입니다.';
|
|
||||||
}
|
|
||||||
if (str_contains($lower, 'permission') || str_contains($lower, 'not permitted')) {
|
|
||||||
return 'Bluetooth 스캔 권한이 부족합니다.';
|
|
||||||
}
|
|
||||||
if (trim($out) !== '') {
|
|
||||||
return trim($out);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Bluetooth 스캔 결과가 비어 있습니다. 어댑터 전원, 권한, 주변 장치 노출 상태를 확인해야 합니다.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function save_bluetooth_scan_observations(array $devices): void
|
|
||||||
{
|
|
||||||
if ($devices === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$stmt = db()->prepare("
|
|
||||||
INSERT INTO bluetooth_scan_observations
|
|
||||||
(address, name, alias, address_type, rssi, tx_power, class_text, icon, paired, bonded, trusted, blocked, connected, legacy_pairing, uuids_text, manufacturer_data, service_data, service_uuids_text, modalias, appearance, raw_text, detail_json, first_seen_at, last_seen_at)
|
|
||||||
VALUES
|
|
||||||
(:address, :name, :alias, :address_type, :rssi, :tx_power, :class_text, :icon, :paired, :bonded, :trusted, :blocked, :connected, :legacy_pairing, :uuids_text, :manufacturer_data, :service_data, :service_uuids_text, :modalias, :appearance, :raw_text, :detail_json, NOW(), NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
name = VALUES(name),
|
|
||||||
alias = VALUES(alias),
|
|
||||||
address_type = VALUES(address_type),
|
|
||||||
rssi = VALUES(rssi),
|
|
||||||
tx_power = VALUES(tx_power),
|
|
||||||
class_text = VALUES(class_text),
|
|
||||||
icon = VALUES(icon),
|
|
||||||
paired = VALUES(paired),
|
|
||||||
bonded = VALUES(bonded),
|
|
||||||
trusted = VALUES(trusted),
|
|
||||||
blocked = VALUES(blocked),
|
|
||||||
connected = VALUES(connected),
|
|
||||||
legacy_pairing = VALUES(legacy_pairing),
|
|
||||||
uuids_text = VALUES(uuids_text),
|
|
||||||
manufacturer_data = VALUES(manufacturer_data),
|
|
||||||
service_data = VALUES(service_data),
|
|
||||||
service_uuids_text = VALUES(service_uuids_text),
|
|
||||||
modalias = VALUES(modalias),
|
|
||||||
appearance = VALUES(appearance),
|
|
||||||
raw_text = VALUES(raw_text),
|
|
||||||
detail_json = VALUES(detail_json),
|
|
||||||
last_seen_at = NOW()
|
|
||||||
");
|
|
||||||
|
|
||||||
foreach ($devices as $device) {
|
|
||||||
$address = strtoupper((string)($device['address'] ?? ''));
|
|
||||||
if (!wifi_valid_mac(strtolower($address))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$bool = static fn($value): ?int => $value === null ? null : ($value ? 1 : 0);
|
|
||||||
$stmt->execute([
|
|
||||||
':address' => $address,
|
|
||||||
':name' => mb_substr((string)($device['name'] ?? ''), 0, 255),
|
|
||||||
':alias' => mb_substr((string)($device['alias'] ?? ''), 0, 255),
|
|
||||||
':address_type' => mb_substr((string)($device['address_type'] ?? ''), 0, 32),
|
|
||||||
':rssi' => is_numeric($device['rssi'] ?? null) ? (int)$device['rssi'] : null,
|
|
||||||
':tx_power' => is_numeric($device['tx_power'] ?? null) ? (int)$device['tx_power'] : null,
|
|
||||||
':class_text' => mb_substr((string)($device['class'] ?? ''), 0, 64),
|
|
||||||
':icon' => mb_substr((string)($device['icon'] ?? ''), 0, 64),
|
|
||||||
':paired' => $bool($device['paired'] ?? null),
|
|
||||||
':bonded' => $bool($device['bonded'] ?? null),
|
|
||||||
':trusted' => $bool($device['trusted'] ?? null),
|
|
||||||
':blocked' => $bool($device['blocked'] ?? null),
|
|
||||||
':connected' => $bool($device['connected'] ?? null),
|
|
||||||
':legacy_pairing' => $bool($device['legacy_pairing'] ?? null),
|
|
||||||
':uuids_text' => implode("\n", array_unique(array_map('strval', $device['uuids'] ?? []))),
|
|
||||||
':manufacturer_data' => implode("\n", array_unique(array_map('strval', $device['manufacturer_data'] ?? []))),
|
|
||||||
':service_data' => implode("\n", array_unique(array_map('strval', $device['service_data'] ?? []))),
|
|
||||||
':service_uuids_text' => implode("\n", array_unique(array_map('strval', $device['service_uuids'] ?? []))),
|
|
||||||
':modalias' => mb_substr((string)($device['modalias'] ?? ''), 0, 255),
|
|
||||||
':appearance' => mb_substr((string)($device['appearance'] ?? ''), 0, 64),
|
|
||||||
':raw_text' => (string)($device['raw_text'] ?? $device['raw'] ?? ''),
|
|
||||||
':detail_json' => json_encode($device, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (Throwable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetooth_scan_observed_devices(int $limit): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$limit = max(1, min(500, $limit));
|
|
||||||
$stmt = db()->query("
|
|
||||||
SELECT
|
|
||||||
address,
|
|
||||||
name,
|
|
||||||
alias,
|
|
||||||
address_type,
|
|
||||||
rssi,
|
|
||||||
tx_power,
|
|
||||||
class_text,
|
|
||||||
icon,
|
|
||||||
paired,
|
|
||||||
bonded,
|
|
||||||
trusted,
|
|
||||||
blocked,
|
|
||||||
connected,
|
|
||||||
legacy_pairing,
|
|
||||||
uuids_text,
|
|
||||||
manufacturer_data,
|
|
||||||
service_data,
|
|
||||||
service_uuids_text,
|
|
||||||
modalias,
|
|
||||||
appearance,
|
|
||||||
raw_text,
|
|
||||||
first_seen_at,
|
|
||||||
last_seen_at,
|
|
||||||
TIMESTAMPDIFF(SECOND, last_seen_at, NOW()) AS observed_age_seconds
|
|
||||||
FROM bluetooth_scan_observations
|
|
||||||
ORDER BY last_seen_at DESC, rssi DESC
|
|
||||||
LIMIT {$limit}
|
|
||||||
");
|
|
||||||
} catch (Throwable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = [];
|
|
||||||
$includeRaw = (bool)setting_value('bluetooth.scan_include_raw');
|
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
|
||||||
$name = (string)($row['name'] ?? '');
|
|
||||||
$alias = (string)($row['alias'] ?? '');
|
|
||||||
$rows[] = [
|
|
||||||
'address' => (string)($row['address'] ?? ''),
|
|
||||||
'name' => $name,
|
|
||||||
'alias' => $alias,
|
|
||||||
'display_name' => $alias !== '' ? $alias : ($name !== '' ? $name : '이름 없음'),
|
|
||||||
'address_type' => (string)($row['address_type'] ?? ''),
|
|
||||||
'rssi' => is_numeric($row['rssi'] ?? null) ? (int)$row['rssi'] : null,
|
|
||||||
'tx_power' => is_numeric($row['tx_power'] ?? null) ? (int)$row['tx_power'] : null,
|
|
||||||
'class' => (string)($row['class_text'] ?? ''),
|
|
||||||
'icon' => (string)($row['icon'] ?? ''),
|
|
||||||
'paired' => $row['paired'] === null ? null : (bool)$row['paired'],
|
|
||||||
'bonded' => $row['bonded'] === null ? null : (bool)$row['bonded'],
|
|
||||||
'trusted' => $row['trusted'] === null ? null : (bool)$row['trusted'],
|
|
||||||
'blocked' => $row['blocked'] === null ? null : (bool)$row['blocked'],
|
|
||||||
'connected' => $row['connected'] === null ? null : (bool)$row['connected'],
|
|
||||||
'legacy_pairing' => $row['legacy_pairing'] === null ? null : (bool)$row['legacy_pairing'],
|
|
||||||
'uuids' => array_values(array_filter(explode("\n", (string)($row['uuids_text'] ?? '')))),
|
|
||||||
'manufacturer_data' => array_values(array_filter(explode("\n", (string)($row['manufacturer_data'] ?? '')))),
|
|
||||||
'service_data' => array_values(array_filter(explode("\n", (string)($row['service_data'] ?? '')))),
|
|
||||||
'service_uuids' => array_values(array_filter(explode("\n", (string)($row['service_uuids_text'] ?? '')))),
|
|
||||||
'modalias' => (string)($row['modalias'] ?? ''),
|
|
||||||
'appearance' => (string)($row['appearance'] ?? ''),
|
|
||||||
'raw' => $includeRaw ? mb_substr((string)($row['raw_text'] ?? ''), 0, 3000) : null,
|
|
||||||
'first_seen_at' => (string)($row['first_seen_at'] ?? ''),
|
|
||||||
'last_seen_at' => (string)($row['last_seen_at'] ?? ''),
|
|
||||||
'observed_age_seconds' => is_numeric($row['observed_age_seconds'] ?? null) ? (int)$row['observed_age_seconds'] : null,
|
|
||||||
'observed_source' => 'db',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function merge_bluetooth_scan_devices(array $freshDevices, array $observedDevices): array
|
|
||||||
{
|
|
||||||
$merged = [];
|
|
||||||
|
|
||||||
foreach ($observedDevices as $device) {
|
|
||||||
$address = strtoupper((string)($device['address'] ?? ''));
|
|
||||||
if ($address !== '') {
|
|
||||||
$merged[$address] = $device;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($freshDevices as $device) {
|
|
||||||
$address = strtoupper((string)($device['address'] ?? ''));
|
|
||||||
if ($address === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$device['observed_source'] = 'live';
|
|
||||||
$device['last_seen_at'] = date('Y-m-d H:i:s');
|
|
||||||
$device['observed_age_seconds'] = 0;
|
|
||||||
$merged[$address] = $device;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = array_values($merged);
|
|
||||||
usort($rows, static function (array $a, array $b): int {
|
|
||||||
$sourceCompare = (($a['observed_source'] ?? '') === 'live' ? 0 : 1) <=> (($b['observed_source'] ?? '') === 'live' ? 0 : 1);
|
|
||||||
if ($sourceCompare !== 0) {
|
|
||||||
return $sourceCompare;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int)($b['rssi'] ?? -999) <=> (int)($a['rssi'] ?? -999);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetooth_scan_refresh_payload(): array
|
|
||||||
{
|
|
||||||
$max = (int)setting_value('bluetooth.scan_max_devices');
|
|
||||||
$cachePath = bluetooth_scan_cache_path();
|
|
||||||
$timeout = (int)setting_value('bluetooth.scan_timeout_seconds');
|
|
||||||
$includeRaw = (bool)setting_value('bluetooth.scan_include_raw');
|
|
||||||
$ctl = bluetoothctl_path();
|
|
||||||
$errors = [];
|
|
||||||
$devices = [];
|
|
||||||
|
|
||||||
$scan = sh([$ctl, 'scan', 'on'], true, $timeout);
|
|
||||||
if ((int)$scan['code'] !== 0 && (int)$scan['code'] !== 124) {
|
|
||||||
$errors[] = [
|
|
||||||
'code' => (int)$scan['code'],
|
|
||||||
'reason' => bluetooth_scan_failure_reason((int)$scan['code'], (string)$scan['out']),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidates = bluetooth_scan_devices_from_text((string)$scan['out']);
|
|
||||||
$known = sh([$ctl, 'devices'], true, 6);
|
|
||||||
if ((int)$known['code'] === 0) {
|
|
||||||
$candidates = array_replace($candidates, bluetooth_scan_devices_from_text((string)$known['out']));
|
|
||||||
} elseif ($candidates === []) {
|
|
||||||
$errors[] = [
|
|
||||||
'code' => (int)$known['code'],
|
|
||||||
'reason' => bluetooth_scan_failure_reason((int)$known['code'], (string)$known['out']),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (array_slice($candidates, 0, max(1, min(300, $max))) as $address => $base) {
|
|
||||||
$info = sh([$ctl, 'info', $address], true, 5);
|
|
||||||
$devices[] = parse_bluetooth_info($address, (string)$info['out'], $base, $includeRaw);
|
|
||||||
}
|
|
||||||
|
|
||||||
save_bluetooth_scan_observations($devices);
|
|
||||||
$observedDevices = bluetooth_scan_observed_devices($max);
|
|
||||||
$mergedDevices = merge_bluetooth_scan_devices($devices, $observedDevices);
|
|
||||||
$payload = [
|
|
||||||
'enabled' => true,
|
|
||||||
'cached' => false,
|
|
||||||
'generated_at' => date('Y-m-d H:i:s'),
|
|
||||||
'devices' => array_slice($mergedDevices, 0, $max),
|
|
||||||
'live_found' => count($devices),
|
|
||||||
'total_found' => count($mergedDevices),
|
|
||||||
'observed_mode' => 'all',
|
|
||||||
'errors' => $errors,
|
|
||||||
];
|
|
||||||
|
|
||||||
scan_write_cache($cachePath, $payload);
|
|
||||||
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetooth_scan_data(): array
|
|
||||||
{
|
|
||||||
$max = (int)setting_value('bluetooth.scan_max_devices');
|
|
||||||
if (!(bool)setting_value('bluetooth.scan_enabled')) {
|
|
||||||
return [
|
|
||||||
'enabled' => false,
|
|
||||||
'cached' => false,
|
|
||||||
'stale' => false,
|
|
||||||
'generated_at' => null,
|
|
||||||
'devices' => bluetooth_scan_observed_devices($max),
|
|
||||||
'observed_mode' => 'all',
|
|
||||||
'errors' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cacheSeconds = (int)setting_value('bluetooth.scan_cache_seconds');
|
|
||||||
$cachePath = bluetooth_scan_cache_path();
|
|
||||||
$cached = scan_cached_payload($cachePath, $cacheSeconds);
|
|
||||||
if ($cached !== null && empty($cached['stale'])) {
|
|
||||||
return $cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
scan_start_background_refresh('bluetooth');
|
|
||||||
$observedDevices = bluetooth_scan_observed_devices($max);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'enabled' => true,
|
|
||||||
'cached' => $cached !== null,
|
|
||||||
'stale' => true,
|
|
||||||
'refresh_pending' => true,
|
|
||||||
'generated_at' => is_array($cached) ? (string)($cached['generated_at'] ?? '') : date('Y-m-d H:i:s'),
|
|
||||||
'devices' => $observedDevices,
|
|
||||||
'live_found' => is_array($cached) ? (int)($cached['live_found'] ?? 0) : 0,
|
|
||||||
'total_found' => count($observedDevices),
|
|
||||||
'observed_mode' => 'all',
|
|
||||||
'errors' => is_array($cached) ? (array)($cached['errors'] ?? []) : [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array
|
function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array
|
||||||
{
|
{
|
||||||
$rows = [];
|
$rows = [];
|
||||||
@@ -4002,7 +3032,6 @@ function wifi_data(): array
|
|||||||
'count24' => count(array_filter($clients, fn($c) => $c['band'] === '2.4G')),
|
'count24' => count(array_filter($clients, fn($c) => $c['band'] === '2.4G')),
|
||||||
'count5' => count(array_filter($clients, fn($c) => $c['band'] === '5G')),
|
'count5' => count(array_filter($clients, fn($c) => $c['band'] === '5G')),
|
||||||
'leases' => array_values($leases),
|
'leases' => array_values($leases),
|
||||||
'scan' => wifi_scan_data(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4346,9 +3375,6 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
'battery' => $battery,
|
'battery' => $battery,
|
||||||
|
|
||||||
'wifi' => wifi_data(),
|
'wifi' => wifi_data(),
|
||||||
'bluetooth' => [
|
|
||||||
'scan' => bluetooth_scan_data(),
|
|
||||||
],
|
|
||||||
'history' => $history,
|
'history' => $history,
|
||||||
'processes' => $processes,
|
'processes' => $processes,
|
||||||
'custom_services' => custom_systemd_services(),
|
'custom_services' => custom_systemd_services(),
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
wifi24: $('#wifi24'),
|
wifi24: $('#wifi24'),
|
||||||
wifi5: $('#wifi5'),
|
wifi5: $('#wifi5'),
|
||||||
wifiTable: $('#wifiTable'),
|
wifiTable: $('#wifiTable'),
|
||||||
wifiScan: $('#wifiScan'),
|
|
||||||
bluetoothScan: $('#bluetoothScan'),
|
|
||||||
signalTooltip: $('#signalTooltip'),
|
signalTooltip: $('#signalTooltip'),
|
||||||
statusHost: $('#statusHost'),
|
statusHost: $('#statusHost'),
|
||||||
statusLoad: $('#statusLoad'),
|
statusLoad: $('#statusLoad'),
|
||||||
@@ -153,20 +151,6 @@
|
|||||||
remaining: 'Remaining',
|
remaining: 'Remaining',
|
||||||
batteryVoltage: 'Battery Voltage',
|
batteryVoltage: 'Battery Voltage',
|
||||||
wifiClients: 'WiFi Clients',
|
wifiClients: 'WiFi Clients',
|
||||||
wifiScan: 'Nearby WiFi Scan',
|
|
||||||
wifiScanDisabled: 'Nearby WiFi scan is disabled.',
|
|
||||||
wifiScanEmpty: 'No nearby WiFi scan results.',
|
|
||||||
wifiScanCached: 'cached',
|
|
||||||
wifiScanFresh: 'fresh',
|
|
||||||
wifiScanDisabledWithHistory: 'scan disabled, showing last observed networks',
|
|
||||||
wifiScanObservedAll: 'all observed BSSID history',
|
|
||||||
bluetoothScan: 'Nearby Bluetooth Scan',
|
|
||||||
bluetoothScanDisabled: 'Nearby Bluetooth scan is disabled.',
|
|
||||||
bluetoothScanEmpty: 'No nearby Bluetooth scan results.',
|
|
||||||
bluetoothScanCached: 'cached',
|
|
||||||
bluetoothScanFresh: 'fresh',
|
|
||||||
bluetoothScanDisabledWithHistory: 'scan disabled, showing last observed devices',
|
|
||||||
bluetoothScanObservedAll: 'all observed devices',
|
|
||||||
deviceName: 'Device',
|
deviceName: 'Device',
|
||||||
address: 'Address',
|
address: 'Address',
|
||||||
paired: 'Paired',
|
paired: 'Paired',
|
||||||
@@ -318,20 +302,6 @@
|
|||||||
remaining: '잔여 시간',
|
remaining: '잔여 시간',
|
||||||
batteryVoltage: '배터리 전압',
|
batteryVoltage: '배터리 전압',
|
||||||
wifiClients: 'WiFi 클라이언트',
|
wifiClients: 'WiFi 클라이언트',
|
||||||
wifiScan: '주변 WiFi 스캔',
|
|
||||||
wifiScanDisabled: '주변 WiFi 스캔이 꺼져 있습니다.',
|
|
||||||
wifiScanEmpty: '주변 WiFi 스캔 결과가 없습니다.',
|
|
||||||
wifiScanCached: '캐시',
|
|
||||||
wifiScanFresh: '실시간',
|
|
||||||
wifiScanDisabledWithHistory: '스캔 꺼짐, 마지막 관측 목록 표시',
|
|
||||||
wifiScanObservedAll: '전체 BSSID 누적 관측',
|
|
||||||
bluetoothScan: '주변 Bluetooth 스캔',
|
|
||||||
bluetoothScanDisabled: '주변 Bluetooth 스캔이 꺼져 있습니다.',
|
|
||||||
bluetoothScanEmpty: '주변 Bluetooth 스캔 결과가 없습니다.',
|
|
||||||
bluetoothScanCached: '캐시',
|
|
||||||
bluetoothScanFresh: '실시간',
|
|
||||||
bluetoothScanDisabledWithHistory: '스캔 꺼짐, 마지막 관측 장치 표시',
|
|
||||||
bluetoothScanObservedAll: '전체 장치 누적 관측',
|
|
||||||
deviceName: '장치',
|
deviceName: '장치',
|
||||||
address: '주소',
|
address: '주소',
|
||||||
paired: '페어링',
|
paired: '페어링',
|
||||||
@@ -1197,14 +1167,6 @@
|
|||||||
els.signalTooltip.setAttribute('aria-hidden', 'true');
|
els.signalTooltip.setAttribute('aria-hidden', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
function holdScanRender(ms = 900) {
|
|
||||||
state.scanScrollHoldUntil = Math.max(state.scanScrollHoldUntil, Date.now() + ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanRenderHeld() {
|
|
||||||
return Date.now() < state.scanScrollHoldUntil;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cssEscape(value) {
|
function cssEscape(value) {
|
||||||
if (window.CSS?.escape) {
|
if (window.CSS?.escape) {
|
||||||
return window.CSS.escape(value);
|
return window.CSS.escape(value);
|
||||||
@@ -1465,191 +1427,6 @@
|
|||||||
if (tableWrap) {
|
if (tableWrap) {
|
||||||
tableWrap.scrollLeft = scrollLeft;
|
tableWrap.scrollLeft = scrollLeft;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.wifi && Object.prototype.hasOwnProperty.call(data.wifi, 'scan')) {
|
|
||||||
renderWifiScan(data.wifi.scan || null);
|
|
||||||
}
|
|
||||||
if (data.bluetooth && Object.prototype.hasOwnProperty.call(data.bluetooth, 'scan')) {
|
|
||||||
renderBluetoothScan(data.bluetooth.scan || null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWifiScan(scan) {
|
|
||||||
if (!els.wifiScan) return;
|
|
||||||
if (scanRenderHeld()) return;
|
|
||||||
const previousScroll = {};
|
|
||||||
els.wifiScan.querySelectorAll('.wifi-scan-band').forEach(section => {
|
|
||||||
const band = section.dataset.band || '';
|
|
||||||
const wrap = section.querySelector('.table-wrap');
|
|
||||||
if (band && wrap) previousScroll[band] = wrap.scrollLeft;
|
|
||||||
});
|
|
||||||
|
|
||||||
const networks = Array.isArray(scan?.networks) ? scan.networks : [];
|
|
||||||
if (!scan || (scan.enabled === false && networks.length === 0)) {
|
|
||||||
els.wifiScan.innerHTML = `<div class="scan-empty">${escapeHtml(t('wifiScanDisabled'))}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observedMode = scan.observed_mode === 'all' ? t('wifiScanObservedAll') : `${scan.observed_hours || '-'}h`;
|
|
||||||
const status = scan.enabled === false
|
|
||||||
? `${t('wifiScanDisabledWithHistory')} · ${observedMode}`
|
|
||||||
: `${scan.cached ? t('wifiScanCached') : t('wifiScanFresh')} · ${escapeHtml(scan.generated_at || '-')} · ${observedMode}`;
|
|
||||||
const bandOrder = ['2.4G', '5G', '6G', '기타'];
|
|
||||||
const extraBands = networks
|
|
||||||
.map(row => row.band || '기타')
|
|
||||||
.filter(band => !bandOrder.includes(band));
|
|
||||||
const bands = [...bandOrder, ...Array.from(new Set(extraBands))]
|
|
||||||
.filter((band, index) => index < 2 || networks.some(row => (row.band || '기타') === band));
|
|
||||||
const groups = bands.map(band => {
|
|
||||||
const bandRows = networks.filter(row => row.band === band);
|
|
||||||
return `
|
|
||||||
<section class="wifi-scan-band" data-band="${attr(band)}">
|
|
||||||
<h3>${escapeHtml(band)} <span>${bandRows.length}${state.lang === 'ko' ? '개' : ''}</span></h3>
|
|
||||||
<div class="table-wrap compact">
|
|
||||||
<table class="wifi-scan-table">
|
|
||||||
<thead><tr><th>${escapeHtml(t('ssid'))}</th><th>BSSID</th><th>${escapeHtml(t('channel'))}</th><th>${escapeHtml(t('signal'))}</th><th>${escapeHtml(t('security'))}</th><th>${escapeHtml(t('observed'))}</th><th>WiFi</th><th>폭</th><th>속도</th><th>기능</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${bandRows.length ? bandRows.map(row => {
|
|
||||||
const dbm = wifiSignalDbm(row.signal);
|
|
||||||
return `<tr>
|
|
||||||
<td>${escapeHtml(row.ssid || '-')}</td>
|
|
||||||
<td>${escapeHtml(row.bssid || '-')}</td>
|
|
||||||
<td>${escapeHtml(row.channel ?? '-')} ${row.freq ? `(${escapeHtml(row.freq)}MHz)` : ''}</td>
|
|
||||||
<td>${signalBadgeHtml(dbm, row.signal || '', `scan:${row.bssid || row.ssid || ''}`)}</td>
|
|
||||||
<td>${escapeHtml(row.security || '-')}</td>
|
|
||||||
<td>${escapeHtml(wifiObservedText(row))}</td>
|
|
||||||
<td>${escapeHtml(wifiStandardText(row))}</td>
|
|
||||||
<td>${escapeHtml(row.channel_width || '-')}</td>
|
|
||||||
<td>${escapeHtml(row.rates || '-')}</td>
|
|
||||||
<td>${escapeHtml(row.capability || '-')}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('') : `<tr><td colspan="10">${escapeHtml(t('wifiScanEmpty'))}</td></tr>`}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
els.wifiScan.innerHTML = `
|
|
||||||
<div class="wifi-scan-head">
|
|
||||||
<span>${status}</span>
|
|
||||||
</div>
|
|
||||||
<div class="wifi-scan-grid">${groups}</div>
|
|
||||||
`;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
els.wifiScan.querySelectorAll('.wifi-scan-band').forEach(section => {
|
|
||||||
const band = section.dataset.band || '';
|
|
||||||
const wrap = section.querySelector('.table-wrap');
|
|
||||||
if (band && wrap && previousScroll[band] !== undefined) {
|
|
||||||
wrap.scrollLeft = previousScroll[band];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBluetoothScan(scan) {
|
|
||||||
if (!els.bluetoothScan) return;
|
|
||||||
if (scanRenderHeld()) return;
|
|
||||||
const wrap = els.bluetoothScan.querySelector('.table-wrap');
|
|
||||||
const scrollLeft = wrap ? wrap.scrollLeft : 0;
|
|
||||||
const devices = Array.isArray(scan?.devices) ? scan.devices : [];
|
|
||||||
|
|
||||||
if (!scan || (scan.enabled === false && devices.length === 0)) {
|
|
||||||
els.bluetoothScan.innerHTML = `<div class="scan-empty">${escapeHtml(t('bluetoothScanDisabled'))}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observedMode = scan.observed_mode === 'all' ? t('bluetoothScanObservedAll') : '-';
|
|
||||||
const status = scan.enabled === false
|
|
||||||
? `${t('bluetoothScanDisabledWithHistory')} · ${observedMode}`
|
|
||||||
: `${scan.cached ? t('bluetoothScanCached') : t('bluetoothScanFresh')} · ${escapeHtml(scan.generated_at || '-')} · ${observedMode}`;
|
|
||||||
const rows = devices.length ? devices.map(device => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(device.display_name || device.alias || device.name || '-')}</td>
|
|
||||||
<td>${escapeHtml(device.address || '-')}</td>
|
|
||||||
<td>${escapeHtml(device.address_type || '-')}</td>
|
|
||||||
<td>${signalBadgeHtml(bluetoothSignalDbm(device.rssi), bluetoothRssiText(device.rssi), `bt:${device.address || device.name || ''}`)}</td>
|
|
||||||
<td>${escapeHtml(device.tx_power ?? '-')}</td>
|
|
||||||
<td>${escapeHtml(booleanStateText(device.connected))}</td>
|
|
||||||
<td>${escapeHtml(booleanStateText(device.paired))}</td>
|
|
||||||
<td>${escapeHtml(booleanStateText(device.trusted))}</td>
|
|
||||||
<td>${escapeHtml(booleanStateText(device.blocked))}</td>
|
|
||||||
<td>${escapeHtml(device.icon || '-')}</td>
|
|
||||||
<td>${escapeHtml(device.class || '-')}</td>
|
|
||||||
<td>${escapeHtml(joinDetailLines(device.uuids))}</td>
|
|
||||||
<td>${escapeHtml(joinDetailLines(device.manufacturer_data))}</td>
|
|
||||||
<td>${escapeHtml(joinDetailLines(device.service_data))}</td>
|
|
||||||
<td>${escapeHtml(device.modalias || '-')}</td>
|
|
||||||
<td>${escapeHtml(device.appearance || '-')}</td>
|
|
||||||
<td>${escapeHtml(wifiObservedText(device))}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('') : `<tr><td colspan="17">${escapeHtml(t('bluetoothScanEmpty'))}</td></tr>`;
|
|
||||||
|
|
||||||
els.bluetoothScan.innerHTML = `
|
|
||||||
<div class="wifi-scan-head">
|
|
||||||
<span>${status}</span>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap compact">
|
|
||||||
<table class="wifi-scan-table">
|
|
||||||
<thead><tr><th>${escapeHtml(t('deviceName'))}</th><th>${escapeHtml(t('address'))}</th><th>주소 종류</th><th>RSSI</th><th>TxPower</th><th>${escapeHtml(t('connected'))}</th><th>${escapeHtml(t('paired'))}</th><th>${escapeHtml(t('trusted'))}</th><th>${escapeHtml(t('blocked'))}</th><th>Icon</th><th>Class</th><th>UUID</th><th>제조사 데이터</th><th>서비스 데이터</th><th>Modalias</th><th>Appearance</th><th>${escapeHtml(t('observed'))}</th></tr></thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const nextWrap = els.bluetoothScan.querySelector('.table-wrap');
|
|
||||||
if (nextWrap) nextWrap.scrollLeft = scrollLeft;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetoothRssiText(value) {
|
|
||||||
return Number.isFinite(Number(value)) ? `${Number(value)} dBm` : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function bluetoothSignalDbm(value) {
|
|
||||||
const numeric = Number(value);
|
|
||||||
return Number.isFinite(numeric) ? numeric : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function booleanStateText(value) {
|
|
||||||
if (value === true) return state.lang === 'ko' ? '예' : 'yes';
|
|
||||||
if (value === false) return state.lang === 'ko' ? '아니오' : 'no';
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinDetailLines(value) {
|
|
||||||
if (!Array.isArray(value) || value.length === 0) return '-';
|
|
||||||
return value.join(' / ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function wifiObservedText(row) {
|
|
||||||
if (row?.observed_source === 'live') {
|
|
||||||
return row?.last_seen_at ? `${timeAgo(secondsSinceLocalTime(row.last_seen_at))} · ${row.last_seen_at}` : '방금';
|
|
||||||
}
|
|
||||||
if (row?.last_seen_at) {
|
|
||||||
return `${timeAgo(secondsSinceLocalTime(row.last_seen_at))} · ${row.last_seen_at}`;
|
|
||||||
}
|
|
||||||
if (row?.observed_age_seconds !== null && row?.observed_age_seconds !== undefined) {
|
|
||||||
return `${timeAgo(row.observed_age_seconds)} · -`;
|
|
||||||
}
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function secondsSinceLocalTime(value) {
|
|
||||||
const text = String(value || '').trim();
|
|
||||||
if (!text) return 0;
|
|
||||||
const parsed = new Date(text.includes('T') ? text : text.replace(' ', 'T'));
|
|
||||||
if (Number.isNaN(parsed.getTime())) return 0;
|
|
||||||
return Math.max(0, Math.floor((Date.now() - parsed.getTime()) / 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
function renderSystemStatus(data) {
|
||||||
@@ -2503,27 +2280,6 @@
|
|||||||
});
|
});
|
||||||
window.addEventListener('resize', positionSignalTooltip);
|
window.addEventListener('resize', positionSignalTooltip);
|
||||||
window.addEventListener('scroll', positionSignalTooltip, true);
|
window.addEventListener('scroll', positionSignalTooltip, true);
|
||||||
document.addEventListener('pointerdown', event => {
|
|
||||||
if (event.target?.closest?.('.nearby-scan .table-wrap')) {
|
|
||||||
holdScanRender(1200);
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
document.addEventListener('pointermove', event => {
|
|
||||||
if (event.target?.closest?.('.nearby-scan .table-wrap')) {
|
|
||||||
holdScanRender(900);
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
document.addEventListener('pointerup', () => {
|
|
||||||
holdScanRender(450);
|
|
||||||
}, true);
|
|
||||||
document.addEventListener('pointercancel', () => {
|
|
||||||
holdScanRender(450);
|
|
||||||
}, true);
|
|
||||||
document.addEventListener('scroll', event => {
|
|
||||||
if (event.target?.closest?.('.nearby-scan .table-wrap')) {
|
|
||||||
holdScanRender(900);
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
[els.secondaryChartDetails, els.processDetails, els.diagnosticDetails].forEach(node => {
|
[els.secondaryChartDetails, els.processDetails, els.diagnosticDetails].forEach(node => {
|
||||||
node?.addEventListener('toggle', resizeChartsSoon);
|
node?.addEventListener('toggle', resizeChartsSoon);
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-10
@@ -127,7 +127,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
|||||||
.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}
|
.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)}.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}
|
.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-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;cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:3px}.wifi-signal.best{color:#16a34a}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fine{color:#0ea5e9}.wifi-signal.normal{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub);text-decoration:none;cursor:default}.nearby-scan{display:grid;gap:12px}.wifi-scan-head{display:flex;align-items:baseline;justify-content:flex-end;gap:12px}.wifi-scan-head span{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums}.wifi-scan-grid{display:grid;grid-template-columns:1fr;gap:14px}.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:max-content;min-width:100%;table-layout:auto;border-collapse:collapse}.wifi-scan-table th,.wifi-scan-table td{width:auto!important;max-width:none!important;padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.16);font-size:12px;text-align:left;white-space:nowrap;overflow:visible;text-overflow:clip}.wifi-scan-table th{background:var(--table-head);color:var(--sub);font-weight:500}.scan-empty{border:1px solid rgba(148,163,184,.35);background:rgba(148,163,184,.1);color:var(--text);border-radius:12px;padding:10px 12px;font-size:13px;line-height:1.5}
|
.wifi-signal{font-weight:700;font-variant-numeric:tabular-nums;cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:3px}.wifi-signal.best{color:#16a34a}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fine{color:#0ea5e9}.wifi-signal.normal{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub);text-decoration:none;cursor:default}
|
||||||
.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}
|
.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}
|
.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}
|
.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}
|
||||||
@@ -272,15 +272,6 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2 data-i18n="wifiScan">Nearby WiFi Scan</h2>
|
|
||||||
<div id="wifiScan" class="nearby-scan"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2 data-i18n="bluetoothScan">Nearby Bluetooth Scan</h2>
|
|
||||||
<div id="bluetoothScan" class="nearby-scan"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user