주변 스캔 기능을 정리
This commit is contained in:
-974
@@ -2647,976 +2647,6 @@ function iw_station_dump(string $iface): string
|
||||
return sh(['/usr/sbin/iw', 'dev', $iface, 'station', 'dump'], true, 4)['out'];
|
||||
}
|
||||
|
||||
function wifi_scan_cache_path(): string
|
||||
{
|
||||
return sys_get_temp_dir() . '/control_wifi_scan.json';
|
||||
}
|
||||
|
||||
function 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
|
||||
{
|
||||
$rows = [];
|
||||
@@ -4002,7 +3032,6 @@ function wifi_data(): array
|
||||
'count24' => count(array_filter($clients, fn($c) => $c['band'] === '2.4G')),
|
||||
'count5' => count(array_filter($clients, fn($c) => $c['band'] === '5G')),
|
||||
'leases' => array_values($leases),
|
||||
'scan' => wifi_scan_data(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4346,9 +3375,6 @@ function collect_snapshot(bool $applyFan = true): array
|
||||
'battery' => $battery,
|
||||
|
||||
'wifi' => wifi_data(),
|
||||
'bluetooth' => [
|
||||
'scan' => bluetooth_scan_data(),
|
||||
],
|
||||
'history' => $history,
|
||||
'processes' => $processes,
|
||||
'custom_services' => custom_systemd_services(),
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
wifi24: $('#wifi24'),
|
||||
wifi5: $('#wifi5'),
|
||||
wifiTable: $('#wifiTable'),
|
||||
wifiScan: $('#wifiScan'),
|
||||
bluetoothScan: $('#bluetoothScan'),
|
||||
signalTooltip: $('#signalTooltip'),
|
||||
statusHost: $('#statusHost'),
|
||||
statusLoad: $('#statusLoad'),
|
||||
@@ -153,20 +151,6 @@
|
||||
remaining: 'Remaining',
|
||||
batteryVoltage: 'Battery Voltage',
|
||||
wifiClients: 'WiFi Clients',
|
||||
wifiScan: 'Nearby WiFi Scan',
|
||||
wifiScanDisabled: 'Nearby WiFi scan is disabled.',
|
||||
wifiScanEmpty: 'No nearby WiFi scan results.',
|
||||
wifiScanCached: 'cached',
|
||||
wifiScanFresh: 'fresh',
|
||||
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',
|
||||
address: 'Address',
|
||||
paired: 'Paired',
|
||||
@@ -318,20 +302,6 @@
|
||||
remaining: '잔여 시간',
|
||||
batteryVoltage: '배터리 전압',
|
||||
wifiClients: 'WiFi 클라이언트',
|
||||
wifiScan: '주변 WiFi 스캔',
|
||||
wifiScanDisabled: '주변 WiFi 스캔이 꺼져 있습니다.',
|
||||
wifiScanEmpty: '주변 WiFi 스캔 결과가 없습니다.',
|
||||
wifiScanCached: '캐시',
|
||||
wifiScanFresh: '실시간',
|
||||
wifiScanDisabledWithHistory: '스캔 꺼짐, 마지막 관측 목록 표시',
|
||||
wifiScanObservedAll: '전체 BSSID 누적 관측',
|
||||
bluetoothScan: '주변 Bluetooth 스캔',
|
||||
bluetoothScanDisabled: '주변 Bluetooth 스캔이 꺼져 있습니다.',
|
||||
bluetoothScanEmpty: '주변 Bluetooth 스캔 결과가 없습니다.',
|
||||
bluetoothScanCached: '캐시',
|
||||
bluetoothScanFresh: '실시간',
|
||||
bluetoothScanDisabledWithHistory: '스캔 꺼짐, 마지막 관측 장치 표시',
|
||||
bluetoothScanObservedAll: '전체 장치 누적 관측',
|
||||
deviceName: '장치',
|
||||
address: '주소',
|
||||
paired: '페어링',
|
||||
@@ -1197,14 +1167,6 @@
|
||||
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) {
|
||||
if (window.CSS?.escape) {
|
||||
return window.CSS.escape(value);
|
||||
@@ -1465,191 +1427,6 @@
|
||||
if (tableWrap) {
|
||||
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) {
|
||||
@@ -2503,27 +2280,6 @@
|
||||
});
|
||||
window.addEventListener('resize', positionSignalTooltip);
|
||||
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 => {
|
||||
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}
|
||||
.table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.table-wrap.compact{border-radius:12px}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px}
|
||||
.wifi-host-edit{appearance:none;border:0;background:transparent;color:inherit;font:inherit;max-width:100%;padding:0;margin:0;cursor:pointer;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px}.wifi-host-edit:hover{color:var(--blue)}
|
||||
.wifi-signal{font-weight:700;font-variant-numeric:tabular-nums;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}
|
||||
.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}
|
||||
@@ -272,15 +272,6 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
||||
</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>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user