주변 블루투스 스캔을 추가
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 신호는 dBm이 약할수록 빨간색에 가깝게 표시하고, client 섹션 아래 별도 주변 WiFi 스캔 섹션에서 2.4G/5G AP 관측 결과를 대역별로 분리 표시
|
- WiFi client 신호는 dBm이 약할수록 빨간색에 가깝게 표시하고, client 섹션 아래 별도 주변 WiFi/Bluetooth 스캔 섹션에서 마지막 관측 결과를 계속 표시
|
||||||
- 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 로그를 진단 접힘 영역에서 표시
|
||||||
@@ -72,11 +72,13 @@ 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별 마지막 성공 스캔 관측값
|
- `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_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`를 사용합니다.
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ 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 진단: 주변 WiFi 스캔 켜기/끄기, 스캔 캐시 시간, 명령 timeout, 표시 개수, 원문 일부 표시 여부
|
- WiFi/Bluetooth 진단: 주변 WiFi AP와 Bluetooth 장치 스캔 켜기/끄기, 스캔 캐시 시간, 명령 timeout, 표시 개수, 원문 일부 표시 여부
|
||||||
- 알림 정책: 배터리 긴급/위험/경고/복구 기준, 배터리 낮음/복구 알림 쿨다운, 시스템 유의사항 알림 쿨다운
|
- 알림 정책: 배터리 긴급/위험/경고/복구 기준, 배터리 낮음/복구 알림 쿨다운, 시스템 유의사항 알림 쿨다운
|
||||||
- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수, HA 알림 이력 수
|
- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수, HA 알림 이력 수
|
||||||
- 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수
|
- 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수
|
||||||
@@ -135,7 +137,7 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과
|
|||||||
- 탭 복귀: 즉시 1회 상태 갱신
|
- 탭 복귀: 즉시 1회 상태 갱신
|
||||||
- 잔여 시간 커스텀 팝오버: 상태 갱신과 동일하며 hover/focus 중에도 내용과 위치를 계속 갱신
|
- 잔여 시간 커스텀 팝오버: 상태 갱신과 동일하며 hover/focus 중에도 내용과 위치를 계속 갱신
|
||||||
- dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지
|
- dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지
|
||||||
- 주변 WiFi 스캔: 상태 snapshot에 포함되지만 파일 캐시를 사용하며 기본 60초마다 실제 `iw dev <iface> scan` 명령을 다시 시도, 실패 시 DB의 마지막 성공 관측 목록 표시. 화면의 마지막 관측 경과 문구는 1초 상태 렌더마다 현재 시각 기준으로 다시 계산
|
- 주변 WiFi/Bluetooth 스캔: 상태 snapshot에 포함되지만 파일 캐시를 사용하며 기본 60초마다 실제 스캔 명령을 다시 시도, 실패 시 DB의 마지막 성공 관측 목록 표시. 화면의 마지막 관측 경과 문구는 1초 상태 렌더마다 현재 시각 기준으로 다시 계산
|
||||||
- WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행
|
- WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행
|
||||||
- WebSocket 소스 변경 감지: 15초마다 확인 후 재시작
|
- WebSocket 소스 변경 감지: 15초마다 확인 후 재시작
|
||||||
- 팬 슬라이더 자동 적용 debounce: 약 450ms
|
- 팬 슬라이더 자동 적용 debounce: 약 450ms
|
||||||
@@ -197,10 +199,12 @@ control-wifi-observe.service
|
|||||||
|
|
||||||
주변 WiFi 스캔은 대시보드 snapshot에 포함되지만 기본 60초 파일 캐시를 사용합니다. `iw dev wlan0 scan`, `iw dev wlan1 scan`을 시도하고 SSID, BSSID, 주파수, 채널, 신호, 보안, 마지막 관측, WiFi 세대, 채널 폭, 지원 속도, capability를 표시합니다. 화면은 WiFi client 섹션 아래 별도 카드로 분리하며, 2.4G와 5G를 나란히 두지 않고 각각 독립 표로 세로 배치합니다.
|
주변 WiFi 스캔은 대시보드 snapshot에 포함되지만 기본 60초 파일 캐시를 사용합니다. `iw dev wlan0 scan`, `iw dev wlan1 scan`을 시도하고 SSID, BSSID, 주파수, 채널, 신호, 보안, 마지막 관측, WiFi 세대, 채널 폭, 지원 속도, capability를 표시합니다. 화면은 WiFi client 섹션 아래 별도 카드로 분리하며, 2.4G와 5G를 나란히 두지 않고 각각 독립 표로 세로 배치합니다.
|
||||||
|
|
||||||
스캔에 성공한 AP는 `wifi_scan_observations`에 BSSID 기준으로 저장합니다. 다음 스캔이 실패하거나 특정 대역이 AP 모드라 스캔할 수 없어도 목록을 비우지 않고, DB에 누적된 BSSID별 마지막 성공 관측값을 계속 표시합니다. 화면의 마지막 관측 경과는 캐시된 API 값에 고정하지 않고 1초 상태 렌더마다 현재 시각 기준으로 다시 계산합니다. live 스캔 결과와 DB 관측값 모두 마지막 관측 시각과 경과 시간을 함께 표시합니다.
|
스캔에 성공한 AP는 `wifi_scan_observations`에 BSSID 기준으로 저장합니다. SSID, BSSID, 신호, 보안, 채널 폭 같은 화면 표시값뿐 아니라 `iw` 원문과 파싱 결과 JSON도 함께 저장해 이후 분석 근거를 남깁니다. 다음 스캔이 실패하거나 특정 대역이 AP 모드라 스캔할 수 없어도 목록을 비우지 않고, DB에 누적된 BSSID별 마지막 성공 관측값을 계속 표시합니다. 화면의 마지막 관측 경과는 캐시된 API 값에 고정하지 않고 1초 상태 렌더마다 현재 시각 기준으로 다시 계산합니다. live 스캔 결과와 DB 관측값 모두 마지막 관측 시각과 경과 시간을 함께 표시합니다.
|
||||||
|
|
||||||
스캔 실패 사유는 API payload에는 남지만 대시보드에는 노란 경고 박스로 표시하지 않습니다. 스캔 표의 보안, 속도, capability 같은 긴 문자열은 말줄임으로 자르지 않고 표 전체를 가로 스크롤해 확인합니다. 대역 제목 옆 숫자는 해당 대역에 표시 중인 AP 개수입니다.
|
스캔 실패 사유는 API payload에는 남지만 대시보드에는 노란 경고 박스로 표시하지 않습니다. 스캔 표의 보안, 속도, capability 같은 긴 문자열은 말줄임으로 자르지 않고 표 전체를 가로 스크롤해 확인합니다. 대역 제목 옆 숫자는 해당 대역에 표시 중인 AP 개수입니다.
|
||||||
|
|
||||||
|
주변 Bluetooth 스캔은 WiFi 스캔 카드 아래 별도 카드로 표시합니다. `bluetoothctl scan on`, `bluetoothctl devices`, `bluetoothctl info <address>`를 조합해 주소, 이름, alias, RSSI, TxPower, 연결/페어링/신뢰/차단 상태, class, icon, UUID, 제조사 데이터, 서비스 데이터, modalias, appearance를 가능한 범위에서 수집합니다. 성공한 장치는 `bluetooth_scan_observations`에 주소 기준으로 저장하며 원문과 파싱 결과 JSON도 함께 남깁니다. 스캔이 실패하거나 주변 장치가 일시적으로 사라져도 마지막 성공 관측값을 계속 표시하고, 마지막 관측 경과는 1초 상태 렌더마다 다시 계산합니다.
|
||||||
|
|
||||||
## 처리 흐름
|
## 처리 흐름
|
||||||
|
|
||||||
1. 화면 진입 시 로그인 세션과 remember token을 확인합니다.
|
1. 화면 진입 시 로그인 세션과 remember token을 확인합니다.
|
||||||
@@ -211,6 +215,7 @@ control-wifi-observe.service
|
|||||||
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. 주변 WiFi 스캔은 캐시 주기를 확인한 뒤 `iw dev <iface> scan`을 시도하고, 성공 결과를 DB에 저장한 뒤 live 값과 마지막 관측값을 주변 WiFi 스캔 전용 카드에 표시합니다.
|
||||||
|
9. 주변 Bluetooth 스캔은 캐시 주기를 확인한 뒤 `bluetoothctl`로 scan/devices/info를 시도하고, 성공 결과를 DB에 저장한 뒤 live 값과 마지막 관측값을 주변 Bluetooth 스캔 전용 카드에 표시합니다.
|
||||||
9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다.
|
9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다.
|
||||||
10. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
|
10. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
|
||||||
11. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
|
11. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
|
||||||
@@ -237,6 +242,8 @@ control-wifi-observe.service
|
|||||||
- `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정
|
- `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정
|
||||||
- `wifi_scan_data()`, `parse_wifi_scan_networks()`: 주변 WiFi AP 스캔, 실패 사유 정리, 파일 캐시 구성
|
- `wifi_scan_data()`, `parse_wifi_scan_networks()`: 주변 WiFi AP 스캔, 실패 사유 정리, 파일 캐시 구성
|
||||||
- `save_wifi_scan_observations()`, `wifi_scan_observed_networks()`, `merge_wifi_scan_networks()`: 주변 WiFi 마지막 성공 관측값 저장, 조회, live/DB 결과 병합
|
- `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 결과 병합
|
||||||
- `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 알림 제거 명령 발송
|
||||||
@@ -282,7 +289,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 스캔 표는 갱신 중에도 가로 스크롤 위치를 보존합니다. 주변 WiFi 스캔은 별도 카드에서 대역별 독립 표로 렌더링해 client 표 스크롤과 서로 간섭하지 않도록 합니다.
|
- WiFi client 표와 주변 WiFi/Bluetooth 스캔 표는 갱신 중에도 가로 스크롤 위치를 보존합니다. 주변 WiFi 스캔은 별도 카드에서 대역별 독립 표로 렌더링하고, Bluetooth 스캔은 그 아래 독립 카드에서 장치별 상세 표로 렌더링해 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부터 다시 누적됩니다.
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ function setting_definitions(): array
|
|||||||
'wifi.scan_timeout_seconds' => ['group' => 'wifi', 'label' => '주변 WiFi 스캔 timeout', 'type' => 'number', 'default' => 8, 'min' => 3, 'max' => 30, 'step' => 1, '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_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],
|
'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'],
|
||||||
@@ -544,6 +549,8 @@ function bootstrap_db(): void
|
|||||||
capability_text VARCHAR(255) NULL,
|
capability_text VARCHAR(255) NULL,
|
||||||
rates_text VARCHAR(255) NULL,
|
rates_text VARCHAR(255) NULL,
|
||||||
channel_width VARCHAR(64) NULL,
|
channel_width VARCHAR(64) NULL,
|
||||||
|
raw_text MEDIUMTEXT NULL,
|
||||||
|
detail_json LONGTEXT NULL,
|
||||||
ht TINYINT(1) NOT NULL DEFAULT 0,
|
ht TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
vht TINYINT(1) NOT NULL DEFAULT 0,
|
vht TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
he TINYINT(1) NOT NULL DEFAULT 0,
|
he TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
@@ -559,6 +566,42 @@ function bootstrap_db(): void
|
|||||||
COLLATE=utf8mb4_unicode_ci
|
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,
|
||||||
@@ -648,6 +691,8 @@ 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",
|
||||||
|
|||||||
+427
-2
@@ -2757,6 +2757,7 @@ function parse_wifi_scan_networks(string $iface, string $text, bool $includeRaw)
|
|||||||
$current['band'] = $freq > 0 ? wifi_band_from_freq($freq) : '기타';
|
$current['band'] = $freq > 0 ? wifi_band_from_freq($freq) : '기타';
|
||||||
$current['channel'] = $freq > 0 ? wifi_channel_from_freq($freq) : null;
|
$current['channel'] = $freq > 0 ? wifi_channel_from_freq($freq) : null;
|
||||||
$current['security'] = trim(implode(' / ', array_values(array_unique($current['security'] ?? [])))) ?: '개방 또는 미확인';
|
$current['security'] = trim(implode(' / ', array_values(array_unique($current['security'] ?? [])))) ?: '개방 또는 미확인';
|
||||||
|
$current['raw_text'] = implode("\n", $raw);
|
||||||
if ($includeRaw) {
|
if ($includeRaw) {
|
||||||
$current['raw'] = implode("\n", array_slice($raw, 0, 45));
|
$current['raw'] = implode("\n", array_slice($raw, 0, 45));
|
||||||
}
|
}
|
||||||
@@ -2869,9 +2870,9 @@ function save_wifi_scan_observations(array $networks): void
|
|||||||
try {
|
try {
|
||||||
$stmt = db()->prepare("
|
$stmt = db()->prepare("
|
||||||
INSERT INTO wifi_scan_observations
|
INSERT INTO wifi_scan_observations
|
||||||
(bssid, iface, band, ssid, freq, channel, signal_dbm, signal_text, security_text, capability_text, rates_text, channel_width, ht, vht, he, first_seen_at, last_seen_at)
|
(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
|
VALUES
|
||||||
(:bssid, :iface, :band, :ssid, :freq, :channel, :signal_dbm, :signal_text, :security_text, :capability_text, :rates_text, :channel_width, :ht, :vht, :he, NOW(), NOW())
|
(: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
|
ON DUPLICATE KEY UPDATE
|
||||||
iface = VALUES(iface),
|
iface = VALUES(iface),
|
||||||
band = VALUES(band),
|
band = VALUES(band),
|
||||||
@@ -2884,6 +2885,8 @@ function save_wifi_scan_observations(array $networks): void
|
|||||||
capability_text = VALUES(capability_text),
|
capability_text = VALUES(capability_text),
|
||||||
rates_text = VALUES(rates_text),
|
rates_text = VALUES(rates_text),
|
||||||
channel_width = VALUES(channel_width),
|
channel_width = VALUES(channel_width),
|
||||||
|
raw_text = VALUES(raw_text),
|
||||||
|
detail_json = VALUES(detail_json),
|
||||||
ht = VALUES(ht),
|
ht = VALUES(ht),
|
||||||
vht = VALUES(vht),
|
vht = VALUES(vht),
|
||||||
he = VALUES(he),
|
he = VALUES(he),
|
||||||
@@ -2909,6 +2912,8 @@ function save_wifi_scan_observations(array $networks): void
|
|||||||
':capability_text' => mb_substr((string)($network['capability'] ?? ''), 0, 255),
|
':capability_text' => mb_substr((string)($network['capability'] ?? ''), 0, 255),
|
||||||
':rates_text' => mb_substr((string)($network['rates'] ?? ''), 0, 255),
|
':rates_text' => mb_substr((string)($network['rates'] ?? ''), 0, 255),
|
||||||
':channel_width' => mb_substr((string)($network['channel_width'] ?? ''), 0, 64),
|
':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,
|
':ht' => !empty($network['ht']) ? 1 : 0,
|
||||||
':vht' => !empty($network['vht']) ? 1 : 0,
|
':vht' => !empty($network['vht']) ? 1 : 0,
|
||||||
':he' => !empty($network['he']) ? 1 : 0,
|
':he' => !empty($network['he']) ? 1 : 0,
|
||||||
@@ -2937,6 +2942,8 @@ function wifi_scan_observed_networks(int $limit): array
|
|||||||
capability_text,
|
capability_text,
|
||||||
rates_text,
|
rates_text,
|
||||||
channel_width,
|
channel_width,
|
||||||
|
raw_text,
|
||||||
|
detail_json,
|
||||||
ht,
|
ht,
|
||||||
vht,
|
vht,
|
||||||
he,
|
he,
|
||||||
@@ -2970,6 +2977,7 @@ function wifi_scan_observed_networks(int $limit): array
|
|||||||
'capability' => (string)($row['capability_text'] ?? ''),
|
'capability' => (string)($row['capability_text'] ?? ''),
|
||||||
'rates' => (string)($row['rates_text'] ?? ''),
|
'rates' => (string)($row['rates_text'] ?? ''),
|
||||||
'channel_width' => (string)($row['channel_width'] ?? ''),
|
'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']),
|
'ht' => !empty($row['ht']),
|
||||||
'vht' => !empty($row['vht']),
|
'vht' => !empty($row['vht']),
|
||||||
'he' => !empty($row['he']),
|
'he' => !empty($row['he']),
|
||||||
@@ -3095,6 +3103,420 @@ function wifi_scan_data(): array
|
|||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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_data(): array
|
||||||
|
{
|
||||||
|
$max = (int)setting_value('bluetooth.scan_max_devices');
|
||||||
|
if (!(bool)setting_value('bluetooth.scan_enabled')) {
|
||||||
|
return [
|
||||||
|
'enabled' => false,
|
||||||
|
'cached' => 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();
|
||||||
|
if (is_file($cachePath) && (time() - filemtime($cachePath)) < $cacheSeconds) {
|
||||||
|
$cached = json_decode((string)@file_get_contents($cachePath), true);
|
||||||
|
if (is_array($cached)) {
|
||||||
|
$cached['cached'] = true;
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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,
|
||||||
|
];
|
||||||
|
|
||||||
|
@file_put_contents($cachePath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array
|
function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array
|
||||||
{
|
{
|
||||||
$rows = [];
|
$rows = [];
|
||||||
@@ -3824,6 +4246,9 @@ 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(),
|
||||||
|
|||||||
+106
-1
@@ -16,6 +16,7 @@
|
|||||||
wifi5: $('#wifi5'),
|
wifi5: $('#wifi5'),
|
||||||
wifiTable: $('#wifiTable'),
|
wifiTable: $('#wifiTable'),
|
||||||
wifiScan: $('#wifiScan'),
|
wifiScan: $('#wifiScan'),
|
||||||
|
bluetoothScan: $('#bluetoothScan'),
|
||||||
statusHost: $('#statusHost'),
|
statusHost: $('#statusHost'),
|
||||||
statusLoad: $('#statusLoad'),
|
statusLoad: $('#statusLoad'),
|
||||||
statusUsers: $('#statusUsers'),
|
statusUsers: $('#statusUsers'),
|
||||||
@@ -153,6 +154,18 @@
|
|||||||
wifiScanFresh: 'fresh',
|
wifiScanFresh: 'fresh',
|
||||||
wifiScanDisabledWithHistory: 'scan disabled, showing last observed networks',
|
wifiScanDisabledWithHistory: 'scan disabled, showing last observed networks',
|
||||||
wifiScanObservedAll: 'all observed BSSID history',
|
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',
|
||||||
|
address: 'Address',
|
||||||
|
paired: 'Paired',
|
||||||
|
trusted: 'Trusted',
|
||||||
|
blocked: 'Blocked',
|
||||||
observed: 'Observed',
|
observed: 'Observed',
|
||||||
ssid: 'SSID',
|
ssid: 'SSID',
|
||||||
channel: 'Channel',
|
channel: 'Channel',
|
||||||
@@ -306,6 +319,18 @@
|
|||||||
wifiScanFresh: '실시간',
|
wifiScanFresh: '실시간',
|
||||||
wifiScanDisabledWithHistory: '스캔 꺼짐, 마지막 관측 목록 표시',
|
wifiScanDisabledWithHistory: '스캔 꺼짐, 마지막 관측 목록 표시',
|
||||||
wifiScanObservedAll: '전체 BSSID 누적 관측',
|
wifiScanObservedAll: '전체 BSSID 누적 관측',
|
||||||
|
bluetoothScan: '주변 Bluetooth 스캔',
|
||||||
|
bluetoothScanDisabled: '주변 Bluetooth 스캔이 꺼져 있습니다.',
|
||||||
|
bluetoothScanEmpty: '주변 Bluetooth 스캔 결과가 없습니다.',
|
||||||
|
bluetoothScanCached: '캐시',
|
||||||
|
bluetoothScanFresh: '실시간',
|
||||||
|
bluetoothScanDisabledWithHistory: '스캔 꺼짐, 마지막 관측 장치 표시',
|
||||||
|
bluetoothScanObservedAll: '전체 장치 누적 관측',
|
||||||
|
deviceName: '장치',
|
||||||
|
address: '주소',
|
||||||
|
paired: '페어링',
|
||||||
|
trusted: '신뢰',
|
||||||
|
blocked: '차단',
|
||||||
observed: '관측',
|
observed: '관측',
|
||||||
ssid: 'SSID',
|
ssid: 'SSID',
|
||||||
channel: '채널',
|
channel: '채널',
|
||||||
@@ -1329,6 +1354,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderWifiScan(data.wifi?.scan || null);
|
renderWifiScan(data.wifi?.scan || null);
|
||||||
|
renderBluetoothScan(data.bluetooth?.scan || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWifiScan(scan) {
|
function renderWifiScan(scan) {
|
||||||
@@ -1342,7 +1368,7 @@
|
|||||||
|
|
||||||
const networks = Array.isArray(scan?.networks) ? scan.networks : [];
|
const networks = Array.isArray(scan?.networks) ? scan.networks : [];
|
||||||
if (!scan || (scan.enabled === false && networks.length === 0)) {
|
if (!scan || (scan.enabled === false && networks.length === 0)) {
|
||||||
els.wifiScan.innerHTML = `<div class="wifi-scan-empty">${escapeHtml(t('wifiScanDisabled'))}</div>`;
|
els.wifiScan.innerHTML = `<div class="scan-empty">${escapeHtml(t('wifiScanDisabled'))}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1404,6 +1430,85 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBluetoothScan(scan) {
|
||||||
|
if (!els.bluetoothScan) 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><span class="wifi-signal ${bluetoothSignalClass(device.rssi)}">${escapeHtml(bluetoothRssiText(device.rssi))}</span></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 bluetoothSignalClass(value) {
|
||||||
|
const dbm = Number(value);
|
||||||
|
if (!Number.isFinite(dbm)) return 'unknown';
|
||||||
|
if (dbm >= -55) return 'excellent';
|
||||||
|
if (dbm >= -67) return 'good';
|
||||||
|
if (dbm >= -75) return 'fair';
|
||||||
|
if (dbm >= -85) return 'weak';
|
||||||
|
return 'bad';
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function wifiObservedText(row) {
|
||||||
if (row?.observed_source === 'live') {
|
if (row?.observed_source === 'live') {
|
||||||
return row?.last_seen_at ? `${timeAgo(secondsSinceLocalTime(row.last_seen_at))} · ${row.last_seen_at}` : '방금';
|
return row?.last_seen_at ? `${timeAgo(secondsSinceLocalTime(row.last_seen_at))} · ${row.last_seen_at}` : '방금';
|
||||||
|
|||||||
+7
-2
@@ -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}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fair{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub)}.wifi-scan{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}.wifi-scan-errors,.wifi-scan-empty{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);color:var(--text);border-radius:12px;padding:10px 12px;font-size:13px;line-height:1.5}
|
.wifi-signal{font-weight:700;font-variant-numeric:tabular-nums}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fair{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub)}.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}
|
||||||
.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}
|
||||||
@@ -274,7 +274,12 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 data-i18n="wifiScan">Nearby WiFi Scan</h2>
|
<h2 data-i18n="wifiScan">Nearby WiFi Scan</h2>
|
||||||
<div id="wifiScan" class="wifi-scan"></div>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user