Control WiFi 진단과 배터리 예측 보강
This commit is contained in:
+374
-5
@@ -1133,6 +1133,10 @@ function battery_voltage_floor(float $currentVoltage): float
|
||||
|
||||
function battery_voltage_trend_candidate(array $trendRows, float $currentPercent, ?float $currentVoltage, int $windowSeconds, ?float $recentWatts): ?array
|
||||
{
|
||||
if (!(bool)setting_value('battery.voltage_model_enabled')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($currentVoltage === null || $currentVoltage <= 0 || count($trendRows) < 10) {
|
||||
return null;
|
||||
}
|
||||
@@ -1358,6 +1362,7 @@ function battery_capacity_power_candidate(float $currentPercent, ?float $recentW
|
||||
return null;
|
||||
}
|
||||
|
||||
$recentWatts = battery_effective_system_watts($recentWatts);
|
||||
$remainingWh = BATTERY_CAPACITY_WH * ($currentPercent / 100);
|
||||
$seconds = (int)round(($remainingWh / $recentWatts) * 3600);
|
||||
if ($seconds <= 0) {
|
||||
@@ -1382,10 +1387,15 @@ function battery_capacity_power_candidate(float $currentPercent, ?float $recentW
|
||||
|
||||
function battery_energy_profile_candidate(array $profileRows, float $currentPercent, ?float $recentWatts): ?array
|
||||
{
|
||||
if (!(bool)setting_value('battery.energy_model_enabled')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($recentWatts === null || $recentWatts <= 0 || count($profileRows) < 24) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$recentWatts = battery_effective_system_watts($recentWatts);
|
||||
$now = time();
|
||||
$weightedWhPerPercent = 0.0;
|
||||
$weightSum = 0.0;
|
||||
@@ -1469,6 +1479,74 @@ function battery_energy_profile_candidate(array $profileRows, float $currentPerc
|
||||
];
|
||||
}
|
||||
|
||||
function battery_effective_system_watts(?float $watts): ?float
|
||||
{
|
||||
if ($watts === null || $watts <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max($watts, (float)setting_value('battery.minimum_system_watts'));
|
||||
}
|
||||
|
||||
function battery_runtime_cap_seconds(float $percent): int
|
||||
{
|
||||
$fullCapSeconds = max(0.0, (float)setting_value('battery.runtime_cap_hours')) * 3600;
|
||||
if ($fullCapSeconds <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)round($fullCapSeconds * clamp_float($percent, 0.0, 100.0) / 100);
|
||||
}
|
||||
|
||||
function battery_cap_candidate(array $candidate, float $currentPercent): array
|
||||
{
|
||||
$capSeconds = battery_runtime_cap_seconds($currentPercent);
|
||||
$seconds = (int)($candidate['seconds'] ?? 0);
|
||||
if ($capSeconds <= 0 || $seconds <= $capSeconds) {
|
||||
$candidate['runtime_cap_seconds'] = $capSeconds;
|
||||
$candidate['runtime_capped'] = false;
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$candidate['original_seconds'] = $seconds;
|
||||
$candidate['seconds'] = $capSeconds;
|
||||
$candidate['rate_per_second'] = $capSeconds > 0 ? $currentPercent / $capSeconds : (float)($candidate['rate_per_second'] ?? 0);
|
||||
$candidate['confidence'] = round((float)($candidate['confidence'] ?? 0) * (float)setting_value('battery.long_candidate_penalty'), 4);
|
||||
$candidate['weight'] = max(0.02, (float)($candidate['weight'] ?? 0.03) * (float)setting_value('battery.long_candidate_penalty'));
|
||||
$candidate['runtime_cap_seconds'] = $capSeconds;
|
||||
$candidate['runtime_capped'] = true;
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
function battery_cap_candidates(array $candidates, float $currentPercent): array
|
||||
{
|
||||
return array_map(
|
||||
static fn(array $candidate): array => battery_cap_candidate($candidate, $currentPercent),
|
||||
$candidates
|
||||
);
|
||||
}
|
||||
|
||||
function battery_cap_result(array $result, float $percent): array
|
||||
{
|
||||
$capSeconds = battery_runtime_cap_seconds($percent);
|
||||
$seconds = $result['seconds'] ?? null;
|
||||
$result['runtime_cap_seconds'] = $capSeconds;
|
||||
$result['runtime_capped'] = false;
|
||||
|
||||
if ($capSeconds > 0 && is_numeric($seconds) && (int)$seconds > $capSeconds) {
|
||||
$result['original_seconds'] = (int)$seconds;
|
||||
$result['seconds'] = $capSeconds;
|
||||
$result['display'] = human_remaining_seconds($capSeconds);
|
||||
$result['runtime_capped'] = true;
|
||||
if (isset($result['confidence']) && is_numeric($result['confidence'])) {
|
||||
$result['confidence'] = round((float)$result['confidence'] * (float)setting_value('battery.long_candidate_penalty'), 3);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function refine_battery_candidates(array $candidates): array
|
||||
{
|
||||
if (count($candidates) < 3) {
|
||||
@@ -1517,18 +1595,18 @@ function battery_power_fallback_estimate(float $percent, array $history): array
|
||||
}
|
||||
}
|
||||
|
||||
$avgWatts = numeric_trimmed_average($wattValues, 0.12);
|
||||
$avgWatts = battery_effective_system_watts(numeric_trimmed_average($wattValues, 0.12));
|
||||
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) {
|
||||
$remainingWh = BATTERY_CAPACITY_WH * ($percent / 100);
|
||||
$seconds = (int)round(($remainingWh / $avgWatts) * 3600);
|
||||
|
||||
return [
|
||||
return battery_cap_result([
|
||||
'display' => human_remaining_seconds($seconds),
|
||||
'seconds' => $seconds,
|
||||
'source' => 'capacity_power_fallback',
|
||||
'avg_watts' => round($avgWatts, 3),
|
||||
'capacity_wh' => BATTERY_CAPACITY_WH,
|
||||
];
|
||||
], $percent);
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -1590,6 +1668,7 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
|
||||
if ($capacityPower !== null) {
|
||||
$candidates[] = $capacityPower;
|
||||
}
|
||||
$candidates = battery_cap_candidates($candidates, $percent);
|
||||
$candidates = refine_battery_candidates($candidates);
|
||||
|
||||
if ($candidates !== []) {
|
||||
@@ -1614,7 +1693,7 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
|
||||
$rateSpread = (numeric_mad($rates) ?? 0.0) / $medianRate;
|
||||
}
|
||||
|
||||
return [
|
||||
return battery_cap_result([
|
||||
'display' => human_remaining_seconds($seconds),
|
||||
'seconds' => $seconds,
|
||||
'source' => 'soc_multi_window',
|
||||
@@ -1640,9 +1719,11 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
|
||||
'volts_per_hour' => $candidate['volts_per_hour'] ?? null,
|
||||
'wh_per_percent' => $candidate['wh_per_percent'] ?? null,
|
||||
'capacity_wh' => $candidate['capacity_wh'] ?? null,
|
||||
'runtime_capped' => $candidate['runtime_capped'] ?? false,
|
||||
'original_seconds' => $candidate['original_seconds'] ?? null,
|
||||
];
|
||||
}, $candidates),
|
||||
];
|
||||
], $percent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2566,6 +2647,293 @@ function iw_station_dump(string $iface): string
|
||||
return sh(['/usr/sbin/iw', 'dev', $iface, 'station', 'dump'], true, 4)['out'];
|
||||
}
|
||||
|
||||
function wifi_scan_cache_path(): string
|
||||
{
|
||||
return sys_get_temp_dir() . '/control_wifi_scan.json';
|
||||
}
|
||||
|
||||
function wifi_channel_from_freq(int $freq): ?int
|
||||
{
|
||||
if ($freq >= 2412 && $freq <= 2472) {
|
||||
return (int)(($freq - 2407) / 5);
|
||||
}
|
||||
if ($freq === 2484) {
|
||||
return 14;
|
||||
}
|
||||
if ($freq >= 5000 && $freq <= 5900) {
|
||||
return (int)(($freq - 5000) / 5);
|
||||
}
|
||||
if ($freq >= 5925 && $freq <= 7125) {
|
||||
return (int)(($freq - 5950) / 5);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function wifi_band_from_freq(int $freq): string
|
||||
{
|
||||
if ($freq >= 2400 && $freq < 2500) {
|
||||
return '2.4G';
|
||||
}
|
||||
if ($freq >= 4900 && $freq < 5925) {
|
||||
return '5G';
|
||||
}
|
||||
if ($freq >= 5925 && $freq <= 7125) {
|
||||
return '6G';
|
||||
}
|
||||
|
||||
return '기타';
|
||||
}
|
||||
|
||||
function wifi_interfaces_from_iw(): array
|
||||
{
|
||||
$out = sh(['/usr/sbin/iw', 'dev'], true, 4)['out'];
|
||||
$interfaces = [];
|
||||
$current = null;
|
||||
|
||||
foreach (explode("\n", $out) as $line) {
|
||||
$trimmed = trim($line);
|
||||
if (preg_match('/^Interface\s+(.+)$/', $trimmed, $m)) {
|
||||
if ($current !== null && !empty($current['iface'])) {
|
||||
$interfaces[$current['iface']] = $current;
|
||||
}
|
||||
$current = [
|
||||
'iface' => trim($m[1]),
|
||||
'type' => '',
|
||||
'channel' => null,
|
||||
'freq' => null,
|
||||
'band' => '',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($current === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^type\s+(.+)$/', $trimmed, $m)) {
|
||||
$current['type'] = trim($m[1]);
|
||||
} elseif (preg_match('/^channel\s+(\d+)\s+\((\d+)\s+MHz\)/', $trimmed, $m)) {
|
||||
$current['channel'] = (int)$m[1];
|
||||
$current['freq'] = (int)$m[2];
|
||||
$current['band'] = wifi_band_from_freq((int)$m[2]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($current !== null && !empty($current['iface'])) {
|
||||
$interfaces[$current['iface']] = $current;
|
||||
}
|
||||
|
||||
foreach (['wlan0' => '2.4G', 'wlan1' => '5G'] as $iface => $band) {
|
||||
if (!isset($interfaces[$iface])) {
|
||||
$interfaces[$iface] = [
|
||||
'iface' => $iface,
|
||||
'type' => 'unknown',
|
||||
'channel' => null,
|
||||
'freq' => null,
|
||||
'band' => $band,
|
||||
];
|
||||
} elseif ($interfaces[$iface]['band'] === '') {
|
||||
$interfaces[$iface]['band'] = $band;
|
||||
}
|
||||
}
|
||||
|
||||
return $interfaces;
|
||||
}
|
||||
|
||||
function parse_wifi_scan_networks(string $iface, string $text, bool $includeRaw): array
|
||||
{
|
||||
$networks = [];
|
||||
$current = null;
|
||||
$raw = [];
|
||||
|
||||
$push = static function () use (&$networks, &$current, &$raw, $iface, $includeRaw): void {
|
||||
if ($current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$freq = (int)($current['freq'] ?? 0);
|
||||
$current['iface'] = $iface;
|
||||
$current['band'] = $freq > 0 ? wifi_band_from_freq($freq) : '기타';
|
||||
$current['channel'] = $freq > 0 ? wifi_channel_from_freq($freq) : null;
|
||||
$current['security'] = trim(implode(' / ', array_values(array_unique($current['security'] ?? [])))) ?: '개방 또는 미확인';
|
||||
if ($includeRaw) {
|
||||
$current['raw'] = implode("\n", array_slice($raw, 0, 45));
|
||||
}
|
||||
unset($current['security_flags']);
|
||||
$networks[] = $current;
|
||||
};
|
||||
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$trimmed = trim($line);
|
||||
if (preg_match('/^BSS\s+([0-9a-f:]{17})/i', $trimmed, $m)) {
|
||||
$push();
|
||||
$raw = [$trimmed];
|
||||
$current = [
|
||||
'bssid' => strtolower($m[1]),
|
||||
'ssid' => '숨김 SSID',
|
||||
'freq' => null,
|
||||
'signal_dbm' => null,
|
||||
'signal' => 'N/A',
|
||||
'last_seen_ms' => null,
|
||||
'security' => [],
|
||||
'capability' => '',
|
||||
'rates' => '',
|
||||
'ht' => false,
|
||||
'vht' => false,
|
||||
'he' => false,
|
||||
'channel_width' => '',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($current === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw[] = $trimmed;
|
||||
if (preg_match('/^SSID:\s*(.*)$/', $trimmed, $m)) {
|
||||
$ssid = trim($m[1]);
|
||||
$current['ssid'] = $ssid === '' ? '숨김 SSID' : $ssid;
|
||||
} elseif (preg_match('/^freq:\s*(\d+)/', $trimmed, $m)) {
|
||||
$current['freq'] = (int)$m[1];
|
||||
} elseif (preg_match('/^signal:\s*(-?\d+(?:\.\d+)?)\s*dBm/i', $trimmed, $m)) {
|
||||
$dbm = (float)$m[1];
|
||||
$current['signal_dbm'] = $dbm;
|
||||
$current['signal'] = number_format($dbm, 1) . ' dBm';
|
||||
} elseif (preg_match('/^last seen:\s*(\d+)\s*ms/i', $trimmed, $m)) {
|
||||
$current['last_seen_ms'] = (int)$m[1];
|
||||
} elseif (preg_match('/^capability:\s*(.+)$/i', $trimmed, $m)) {
|
||||
$current['capability'] = trim($m[1]);
|
||||
if (stripos($m[1], 'Privacy') !== false) {
|
||||
$current['security'][] = '암호화';
|
||||
}
|
||||
} elseif (preg_match('/^RSN:/i', $trimmed)) {
|
||||
$current['security'][] = 'WPA2/WPA3';
|
||||
} elseif (preg_match('/^WPA:/i', $trimmed)) {
|
||||
$current['security'][] = 'WPA';
|
||||
} elseif (preg_match('/^\*\s+Authentication suites:\s*(.+)$/i', $trimmed, $m)) {
|
||||
$current['security'][] = trim($m[1]);
|
||||
} elseif (preg_match('/^\*\s+Pairwise ciphers:\s*(.+)$/i', $trimmed, $m)) {
|
||||
$current['security'][] = trim($m[1]);
|
||||
} elseif (preg_match('/^Supported rates:\s*(.+)$/i', $trimmed, $m)) {
|
||||
$current['rates'] = trim($m[1]);
|
||||
} elseif (preg_match('/^HT capabilities:/i', $trimmed)) {
|
||||
$current['ht'] = true;
|
||||
} elseif (preg_match('/^VHT capabilities:/i', $trimmed)) {
|
||||
$current['vht'] = true;
|
||||
} elseif (preg_match('/^HE capabilities:/i', $trimmed)) {
|
||||
$current['he'] = true;
|
||||
} elseif (preg_match('/^\*\s+channel width:\s*(.+)$/i', $trimmed, $m)) {
|
||||
$current['channel_width'] = trim($m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
$push();
|
||||
|
||||
usort($networks, static function (array $a, array $b): int {
|
||||
return (float)($b['signal_dbm'] ?? -999) <=> (float)($a['signal_dbm'] ?? -999);
|
||||
});
|
||||
|
||||
return $networks;
|
||||
}
|
||||
|
||||
function wifi_scan_failure_reason(int $code, string $out): string
|
||||
{
|
||||
$lower = strtolower($out);
|
||||
if ($code === 124) {
|
||||
return '스캔 명령이 timeout 되었습니다. AP 모드 중 오프채널 스캔이 오래 걸리거나 드라이버가 응답하지 않는 상태일 수 있습니다.';
|
||||
}
|
||||
if (str_contains($lower, 'device or resource busy') || str_contains($lower, 'resource busy')) {
|
||||
return '인터페이스가 AP로 동작 중이라 같은 무선 칩에서 주변 스캔을 동시에 수행하지 못했습니다.';
|
||||
}
|
||||
if (str_contains($lower, 'operation not supported') || str_contains($lower, 'not supported')) {
|
||||
return '무선 드라이버가 현재 모드에서 주변 스캔을 지원하지 않습니다.';
|
||||
}
|
||||
if (str_contains($lower, 'network is down')) {
|
||||
return '인터페이스가 내려가 있어 스캔할 수 없습니다.';
|
||||
}
|
||||
if ($out !== '') {
|
||||
return trim($out);
|
||||
}
|
||||
|
||||
return '스캔 결과가 비어 있습니다. AP 모드, 드라이버 제한, 권한 문제 중 하나일 수 있습니다.';
|
||||
}
|
||||
|
||||
function wifi_scan_data(): array
|
||||
{
|
||||
if (!(bool)setting_value('wifi.scan_enabled')) {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'cached' => false,
|
||||
'generated_at' => null,
|
||||
'interfaces' => [],
|
||||
'networks' => [],
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$cacheSeconds = (int)setting_value('wifi.scan_cache_seconds');
|
||||
$cachePath = wifi_scan_cache_path();
|
||||
if (is_file($cachePath) && (time() - filemtime($cachePath)) < $cacheSeconds) {
|
||||
$cached = json_decode((string)@file_get_contents($cachePath), true);
|
||||
if (is_array($cached)) {
|
||||
$cached['cached'] = true;
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$interfaces = wifi_interfaces_from_iw();
|
||||
$networks = [];
|
||||
$errors = [];
|
||||
$timeout = (int)setting_value('wifi.scan_timeout_seconds');
|
||||
$includeRaw = (bool)setting_value('wifi.scan_include_raw');
|
||||
|
||||
foreach (['wlan0', 'wlan1'] as $iface) {
|
||||
$meta = $interfaces[$iface] ?? ['iface' => $iface, 'type' => 'unknown', 'band' => $iface === 'wlan0' ? '2.4G' : '5G'];
|
||||
$result = sh(['/usr/sbin/iw', 'dev', $iface, 'scan'], true, $timeout);
|
||||
if ((int)$result['code'] !== 0) {
|
||||
$errors[] = [
|
||||
'iface' => $iface,
|
||||
'band' => $meta['band'] ?? '',
|
||||
'type' => $meta['type'] ?? '',
|
||||
'code' => (int)$result['code'],
|
||||
'reason' => wifi_scan_failure_reason((int)$result['code'], (string)$result['out']),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (parse_wifi_scan_networks($iface, (string)$result['out'], $includeRaw) as $network) {
|
||||
$networks[] = $network;
|
||||
}
|
||||
}
|
||||
|
||||
usort($networks, static function (array $a, array $b): int {
|
||||
$bandOrder = ['2.4G' => 0, '5G' => 1, '6G' => 2, '기타' => 3];
|
||||
$bandCompare = ($bandOrder[$a['band'] ?? '기타'] ?? 9) <=> ($bandOrder[$b['band'] ?? '기타'] ?? 9);
|
||||
if ($bandCompare !== 0) {
|
||||
return $bandCompare;
|
||||
}
|
||||
|
||||
return (float)($b['signal_dbm'] ?? -999) <=> (float)($a['signal_dbm'] ?? -999);
|
||||
});
|
||||
|
||||
$max = (int)setting_value('wifi.scan_max_networks');
|
||||
$payload = [
|
||||
'enabled' => true,
|
||||
'cached' => false,
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'interfaces' => array_values($interfaces),
|
||||
'networks' => array_slice($networks, 0, $max),
|
||||
'total_found' => count($networks),
|
||||
'errors' => $errors,
|
||||
];
|
||||
|
||||
@file_put_contents($cachePath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array
|
||||
{
|
||||
$rows = [];
|
||||
@@ -2951,6 +3319,7 @@ function wifi_data(): array
|
||||
'count24' => count(array_filter($clients, fn($c) => $c['band'] === '2.4G')),
|
||||
'count5' => count(array_filter($clients, fn($c) => $c['band'] === '5G')),
|
||||
'leases' => array_values($leases),
|
||||
'scan' => wifi_scan_data(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user