Control WiFi 진단과 배터리 예측 보강

This commit is contained in:
seo
2026-06-26 18:09:29 +09:00
parent ebef879634
commit 6665b37975
5 changed files with 557 additions and 26 deletions
+374 -5
View File
@@ -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(),
];
}