4578 lines
149 KiB
PHP
4578 lines
149 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
$controlApiLibrary = defined('CONTROL_API_LIBRARY') && CONTROL_API_LIBRARY;
|
|
|
|
if (!$controlApiLibrary && session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
|
|
require_once __DIR__ . '/../config/config.php';
|
|
|
|
if (!$controlApiLibrary && !signed_in()) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'login_required',
|
|
], 401);
|
|
}
|
|
|
|
function bytes_human(int|float $bytes): string
|
|
{
|
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
$v = (float)$bytes;
|
|
$i = 0;
|
|
|
|
while ($v >= 1024 && $i < count($units) - 1) {
|
|
$v /= 1024;
|
|
$i++;
|
|
}
|
|
|
|
return ($i === 0 ? (string)(int)$v : number_format($v, 2)) . ' ' . $units[$i];
|
|
}
|
|
|
|
function command_short(string $value, int $limit = 120): string
|
|
{
|
|
$value = trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
|
|
|
if ($value === '') {
|
|
return 'N/A';
|
|
}
|
|
|
|
return mb_strlen($value) > $limit
|
|
? mb_substr($value, 0, $limit - 1) . '...'
|
|
: $value;
|
|
}
|
|
|
|
function human_seconds(int $s): string
|
|
{
|
|
$d = intdiv($s, 86400);
|
|
$s %= 86400;
|
|
|
|
$h = intdiv($s, 3600);
|
|
$s %= 3600;
|
|
|
|
$m = intdiv($s, 60);
|
|
|
|
return ($d > 0 ? $d . 'd ' : '') . sprintf('%02dh %02dm', $h, $m);
|
|
}
|
|
|
|
function human_remaining_seconds(int $seconds): string
|
|
{
|
|
if ($seconds <= 0) {
|
|
return '-';
|
|
}
|
|
|
|
$hours = intdiv($seconds, 3600);
|
|
$minutes = intdiv($seconds % 3600, 60);
|
|
|
|
if ($hours > 0) {
|
|
return sprintf('%dh %02dm', $hours, $minutes);
|
|
}
|
|
|
|
return sprintf('%dm', max(1, $minutes));
|
|
}
|
|
|
|
function dmesg_log(): array
|
|
{
|
|
$path = '/tmp/dmesg.log';
|
|
|
|
if (!is_readable($path)) {
|
|
return [
|
|
'path' => $path,
|
|
'available' => false,
|
|
'lines' => [],
|
|
'line_count' => 0,
|
|
'size_bytes' => 0,
|
|
'updated_at' => null,
|
|
'message' => 'dmesg log is not available yet.',
|
|
];
|
|
}
|
|
|
|
$size = filesize($path) ?: 0;
|
|
$chunk = file_get_contents($path);
|
|
if ($chunk === false) {
|
|
return [
|
|
'path' => $path,
|
|
'available' => false,
|
|
'lines' => [],
|
|
'line_count' => 0,
|
|
'size_bytes' => $size,
|
|
'updated_at' => null,
|
|
'message' => 'failed to open dmesg log.',
|
|
];
|
|
}
|
|
|
|
$lines = array_values(array_filter(
|
|
preg_split('/\R/', $chunk) ?: [],
|
|
static fn($line): bool => trim((string)$line) !== ''
|
|
));
|
|
|
|
return [
|
|
'path' => $path,
|
|
'available' => true,
|
|
'lines' => array_reverse($lines),
|
|
'line_count' => count($lines),
|
|
'size_bytes' => $size,
|
|
'updated_at' => date('Y-m-d H:i:s', filemtime($path) ?: time()),
|
|
];
|
|
}
|
|
|
|
function throttled_event_status(?int $flags, string $raw, int $activeBit, int $seenBit, string $statePath): array
|
|
{
|
|
$now = time();
|
|
$windowSeconds = (int)setting_value('event.recent_window_seconds');
|
|
$recoverySeconds = (int)setting_value('event.recovery_seconds');
|
|
$active = $flags !== null && (bool)($flags & $activeBit);
|
|
$seen = $flags !== null && (bool)($flags & $seenBit);
|
|
|
|
if (is_file($statePath) && !is_writable($statePath)) {
|
|
@chmod($statePath, 0666);
|
|
}
|
|
|
|
$state = [
|
|
'active' => false,
|
|
'active_since' => null,
|
|
'last_detected_at' => null,
|
|
'last_cleared_at' => null,
|
|
'last_duration_seconds' => 0,
|
|
'updated_at' => null,
|
|
'events' => [],
|
|
];
|
|
|
|
$fp = @fopen($statePath, 'c+');
|
|
if ($fp !== false) {
|
|
@chmod($statePath, 0666);
|
|
@flock($fp, LOCK_EX);
|
|
$saved = stream_get_contents($fp);
|
|
$decoded = json_decode((string)$saved, true);
|
|
if (is_array($decoded)) {
|
|
$state = array_merge($state, array_intersect_key($decoded, $state));
|
|
}
|
|
$state['events'] = array_values(array_filter(
|
|
is_array($state['events'] ?? null) ? $state['events'] : [],
|
|
static fn($event): bool => is_array($event) && is_numeric($event['start'] ?? null)
|
|
));
|
|
|
|
$wasActive = !empty($state['active']);
|
|
$activeSince = is_numeric($state['active_since'] ?? null) ? (int)$state['active_since'] : null;
|
|
|
|
if ($flags !== null) {
|
|
if ($active) {
|
|
if (!$wasActive || $activeSince === null) {
|
|
$activeSince = $now;
|
|
$state['last_detected_at'] = $now;
|
|
$state['events'][] = [
|
|
'start' => $now,
|
|
'end' => null,
|
|
'duration_seconds' => 0,
|
|
];
|
|
}
|
|
$state['active'] = true;
|
|
$state['active_since'] = $activeSince;
|
|
} else {
|
|
if ($wasActive && $activeSince !== null) {
|
|
$state['last_cleared_at'] = $now;
|
|
$state['last_duration_seconds'] = max(0, $now - $activeSince);
|
|
$lastIndex = count($state['events']) - 1;
|
|
if ($lastIndex >= 0 && empty($state['events'][$lastIndex]['end'])) {
|
|
$state['events'][$lastIndex]['end'] = $now;
|
|
$state['events'][$lastIndex]['duration_seconds'] = $state['last_duration_seconds'];
|
|
} else {
|
|
$state['events'][] = [
|
|
'start' => $activeSince,
|
|
'end' => $now,
|
|
'duration_seconds' => $state['last_duration_seconds'],
|
|
];
|
|
}
|
|
}
|
|
$state['active'] = false;
|
|
$state['active_since'] = null;
|
|
}
|
|
|
|
$keepAfter = $now - 86400;
|
|
$state['events'] = array_slice(array_values(array_filter(
|
|
$state['events'],
|
|
static function ($event) use ($keepAfter, $now): bool {
|
|
$start = (int)($event['start'] ?? 0);
|
|
$end = is_numeric($event['end'] ?? null) ? (int)$event['end'] : $now;
|
|
return $start > 0 && max($start, $end) >= $keepAfter;
|
|
}
|
|
)), -200);
|
|
|
|
$state['updated_at'] = $now;
|
|
ftruncate($fp, 0);
|
|
rewind($fp);
|
|
fwrite($fp, json_encode($state, JSON_UNESCAPED_SLASHES));
|
|
fflush($fp);
|
|
@chmod($statePath, 0666);
|
|
}
|
|
|
|
@flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
}
|
|
|
|
$duration = 0;
|
|
if ($active && is_numeric($state['active_since'] ?? null)) {
|
|
$duration = max(0, $now - (int)$state['active_since']);
|
|
} elseif (is_numeric($state['last_duration_seconds'] ?? null)) {
|
|
$duration = max(0, (int)$state['last_duration_seconds']);
|
|
}
|
|
|
|
$dateValue = static fn($ts): ?string => is_numeric($ts) && (int)$ts > 0
|
|
? date('Y-m-d H:i:s', (int)$ts)
|
|
: null;
|
|
|
|
$windowStart = $now - $windowSeconds;
|
|
$recentCount = 0;
|
|
$recentActiveSeconds = 0;
|
|
$events = is_array($state['events'] ?? null) ? $state['events'] : [];
|
|
foreach ($events as $event) {
|
|
if (!is_array($event) || !is_numeric($event['start'] ?? null)) {
|
|
continue;
|
|
}
|
|
$start = (int)$event['start'];
|
|
$end = is_numeric($event['end'] ?? null) ? (int)$event['end'] : $now;
|
|
if ($end < $windowStart || $start > $now) {
|
|
continue;
|
|
}
|
|
$overlapStart = max($start, $windowStart);
|
|
$overlapEnd = min(max($end, $overlapStart), $now);
|
|
if ($overlapEnd > $overlapStart) {
|
|
$recentCount++;
|
|
$recentActiveSeconds += $overlapEnd - $overlapStart;
|
|
}
|
|
}
|
|
|
|
$lastClearedAt = is_numeric($state['last_cleared_at'] ?? null) ? (int)$state['last_cleared_at'] : null;
|
|
$recovering = !$active && $lastClearedAt !== null && ($now - $lastClearedAt) <= $recoverySeconds;
|
|
$repeated = $recentCount >= (int)setting_value('event.repeated_count');
|
|
$stateLabel = 'normal';
|
|
if ($active) {
|
|
$stateLabel = 'active';
|
|
} elseif ($repeated) {
|
|
$stateLabel = 'repeated';
|
|
} elseif ($recovering) {
|
|
$stateLabel = 'recovering';
|
|
}
|
|
|
|
return [
|
|
'available' => $flags !== null,
|
|
'raw' => $raw,
|
|
'flags' => $flags,
|
|
'active' => $active,
|
|
'seen_since_boot' => $seen,
|
|
'active_since' => $dateValue($state['active_since'] ?? null),
|
|
'last_detected_at' => $dateValue($state['last_detected_at'] ?? null),
|
|
'last_cleared_at' => $dateValue($state['last_cleared_at'] ?? null),
|
|
'duration_seconds' => $duration,
|
|
'last_duration_seconds' => (int)($state['last_duration_seconds'] ?? 0),
|
|
'updated_at' => $dateValue($state['updated_at'] ?? null),
|
|
'state_label' => $stateLabel,
|
|
'recovering' => $recovering,
|
|
'repeated_recently' => $repeated,
|
|
'recovery_window_seconds' => $recoverySeconds,
|
|
'recent_window_seconds' => $windowSeconds,
|
|
'recent_event_count' => $recentCount,
|
|
'recent_active_seconds' => $recentActiveSeconds,
|
|
'recent_active_percent' => round($recentActiveSeconds / $windowSeconds * 100, 1),
|
|
];
|
|
}
|
|
|
|
function read_throttled_flags(): array
|
|
{
|
|
$result = sh(['/usr/bin/vcgencmd', 'get_throttled'], false, 3);
|
|
$raw = trim((string)$result['out']);
|
|
$flags = null;
|
|
$source = 'direct';
|
|
|
|
if ((int)$result['code'] === 0 && preg_match('/throttled=0x([0-9a-f]+)/i', $raw, $m)) {
|
|
$flags = hexdec($m[1]);
|
|
}
|
|
|
|
if ($flags === null) {
|
|
$sudoResult = sh(['/usr/bin/vcgencmd', 'get_throttled'], true, 3);
|
|
$sudoRaw = trim((string)$sudoResult['out']);
|
|
if ((int)$sudoResult['code'] === 0 && preg_match('/throttled=0x([0-9a-f]+)/i', $sudoRaw, $m)) {
|
|
$result = $sudoResult;
|
|
$raw = $sudoRaw;
|
|
$flags = hexdec($m[1]);
|
|
$source = 'sudo';
|
|
}
|
|
}
|
|
|
|
return [
|
|
'result' => $result,
|
|
'raw' => $raw,
|
|
'flags' => $flags,
|
|
'source' => $source,
|
|
];
|
|
}
|
|
|
|
function throttled_statuses(): array
|
|
{
|
|
$read = read_throttled_flags();
|
|
$raw = (string)$read['raw'];
|
|
$flags = $read['flags'];
|
|
$source = (string)$read['source'];
|
|
|
|
$lowVoltage = throttled_event_status($flags, $raw, 0x1, 0x10000, '/tmp/control-low-voltage-state.json');
|
|
$throttling = throttled_event_status($flags, $raw, 0x4, 0x40000, '/tmp/control-throttling-state.json');
|
|
|
|
$lowVoltage['source'] = $source;
|
|
$lowVoltage['freq_capped'] = $flags !== null && (bool)($flags & 0x2);
|
|
$lowVoltage['throttled'] = $flags !== null && (bool)($flags & 0x4);
|
|
$lowVoltage['soft_temp_limit'] = $flags !== null && (bool)($flags & 0x8);
|
|
$throttling['source'] = $source;
|
|
$throttling['low_voltage'] = $flags !== null && (bool)($flags & 0x1);
|
|
$throttling['freq_capped'] = $flags !== null && (bool)($flags & 0x2);
|
|
$throttling['soft_temp_limit'] = $flags !== null && (bool)($flags & 0x8);
|
|
|
|
return [
|
|
'low_voltage' => $lowVoltage,
|
|
'throttling' => $throttling,
|
|
];
|
|
}
|
|
|
|
function low_voltage_status(): array
|
|
{
|
|
return throttled_statuses()['low_voltage'];
|
|
}
|
|
|
|
function fan_paths(): array
|
|
{
|
|
$candidates = glob('/sys/devices/platform/cooling_fan/hwmon/hwmon*/pwm1') ?: [];
|
|
$candidates = array_merge(
|
|
$candidates,
|
|
glob('/sys/class/hwmon/hwmon*/pwm1') ?: []
|
|
);
|
|
|
|
foreach ($candidates as $pwm) {
|
|
$dir = dirname($pwm);
|
|
|
|
return [
|
|
'base' => $dir,
|
|
'pwm' => $pwm,
|
|
'enable' => $dir . '/pwm1_enable',
|
|
'rpm' => $dir . '/fan1_input',
|
|
'name' => first_readable([$dir . '/name']) ?: 'cooling_fan',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'base' => 'N/A',
|
|
'pwm' => 'N/A',
|
|
'enable' => 'N/A',
|
|
'rpm' => 'N/A',
|
|
'name' => 'N/A',
|
|
];
|
|
}
|
|
|
|
function read_int_file(string $file): int
|
|
{
|
|
if ($file === '' || $file === 'N/A' || !is_readable($file)) {
|
|
return 0;
|
|
}
|
|
|
|
$raw = trim((string)@file_get_contents($file));
|
|
|
|
return is_numeric($raw) ? (int)$raw : 0;
|
|
}
|
|
|
|
function cpu_temp(): float
|
|
{
|
|
$raw = first_readable([
|
|
'/sys/class/thermal/thermal_zone0/temp',
|
|
'/sys/devices/virtual/thermal/thermal_zone0/temp',
|
|
]);
|
|
|
|
if ($raw !== '' && is_numeric($raw)) {
|
|
return round(((float)$raw) / 1000, 2);
|
|
}
|
|
|
|
$vc = sh(['/usr/bin/vcgencmd', 'measure_temp'], false, 3)['out'];
|
|
|
|
if (preg_match('/([0-9.]+)/', $vc, $m)) {
|
|
return round((float)$m[1], 2);
|
|
}
|
|
|
|
return 0.0;
|
|
}
|
|
|
|
function fan_target_pwm(float $temp): int
|
|
{
|
|
$minTemp = (float)setting_value('fan.auto_min_temp');
|
|
$maxTemp = (float)setting_value('fan.auto_max_temp');
|
|
if ($maxTemp <= $minTemp) {
|
|
$maxTemp = $minTemp + 1;
|
|
}
|
|
|
|
if ($temp >= $maxTemp) {
|
|
return 255;
|
|
}
|
|
|
|
if ($temp <= $minTemp) {
|
|
return 0;
|
|
}
|
|
|
|
$ratio = ($temp - $minTemp) / ($maxTemp - $minTemp);
|
|
|
|
return max(0, min(255, (int)round($ratio * 255)));
|
|
}
|
|
|
|
function fan_ramped_pwm(int $current, int $desired, float $temp): int
|
|
{
|
|
if ($temp >= (float)setting_value('fan.full_temp')) {
|
|
return $desired;
|
|
}
|
|
|
|
if ($desired > $current) {
|
|
return min($desired, $current + (int)setting_value('fan.ramp_up_step'));
|
|
}
|
|
|
|
if ($desired < $current) {
|
|
return max($desired, $current - (int)setting_value('fan.ramp_down_step'));
|
|
}
|
|
|
|
return $desired;
|
|
}
|
|
|
|
function write_sys_value(string $path, int $value): bool
|
|
{
|
|
if ($path === '' || $path === 'N/A') {
|
|
return false;
|
|
}
|
|
|
|
if (@file_put_contents($path, $value . "\n", LOCK_EX) !== false) {
|
|
return true;
|
|
}
|
|
|
|
$cmd = 'printf ' . escapeshellarg($value . "\n") . ' > ' . escapeshellarg($path);
|
|
return sh(['/bin/sh', '-lc', $cmd], true, 5)['code'] === 0;
|
|
}
|
|
|
|
function apply_fan_policy(): array
|
|
{
|
|
$state = get_control_state();
|
|
$paths = fan_paths();
|
|
|
|
$temp = cpu_temp();
|
|
|
|
$mode = (string)($state['mode'] ?? 'auto');
|
|
if (!in_array($mode, ['auto', 'manual', 'off'], true)) {
|
|
$mode = 'auto';
|
|
}
|
|
|
|
$manualPwm = max(0, min(255, (int)($state['manual_pwm'] ?? 120)));
|
|
|
|
$currentPwm = read_int_file($paths['pwm']);
|
|
$desired = match ($mode) {
|
|
'manual' => $manualPwm,
|
|
'off' => 0,
|
|
default => fan_target_pwm($temp),
|
|
};
|
|
$target = $mode === 'auto'
|
|
? fan_ramped_pwm($currentPwm, $desired, $temp)
|
|
: $desired;
|
|
|
|
$enableOk = write_sys_value($paths['enable'], $mode === 'off' ? 0 : 1);
|
|
$pwmOk = write_sys_value($paths['pwm'], $target);
|
|
|
|
usleep(60000);
|
|
|
|
$actualPwm = read_int_file($paths['pwm']);
|
|
|
|
$rpm = read_int_file($paths['rpm']);
|
|
|
|
return [
|
|
'mode' => $mode,
|
|
'target_pwm' => $target,
|
|
'actual_pwm' => $actualPwm,
|
|
'temp_c' => $temp,
|
|
'rpm' => $rpm,
|
|
'ok' => $enableOk && $pwmOk,
|
|
'paths' => $paths,
|
|
'enable_value' => first_readable([$paths['enable']]) ?: 'N/A',
|
|
];
|
|
}
|
|
|
|
function mem_info(): array
|
|
{
|
|
$rows = [];
|
|
|
|
foreach (@file('/proc/meminfo', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
|
if (preg_match('/^([^:]+):\s+(\d+)/', $line, $m)) {
|
|
$rows[$m[1]] = (int)$m[2];
|
|
}
|
|
}
|
|
|
|
$total = (int)($rows['MemTotal'] ?? 0);
|
|
$available = (int)($rows['MemAvailable'] ?? 0);
|
|
$used = max(0, $total - $available);
|
|
|
|
$swapTotal = (int)($rows['SwapTotal'] ?? 0);
|
|
$swapFree = (int)($rows['SwapFree'] ?? 0);
|
|
|
|
return [
|
|
'total_mb' => round($total / 1024),
|
|
'used_mb' => round($used / 1024),
|
|
'free_mb' => round($available / 1024),
|
|
'percent' => $total > 0 ? round($used / $total * 100, 1) : 0,
|
|
'swap_total_mb' => round($swapTotal / 1024),
|
|
'swap_used_mb' => round(max(0, $swapTotal - $swapFree) / 1024),
|
|
];
|
|
}
|
|
|
|
function disk_info(string $path = '/var/www/control'): array
|
|
{
|
|
$total = @disk_total_space($path) ?: 0;
|
|
$free = @disk_free_space($path) ?: 0;
|
|
|
|
if ($total <= 0 || $free <= 0) {
|
|
$out = trim(sh(['/bin/df', '-kP', $path], false, 3)['out']);
|
|
$lines = preg_split('/\R/', $out);
|
|
$parts = isset($lines[1]) ? preg_split('/\s+/', trim($lines[1])) : [];
|
|
|
|
if (count($parts) >= 6) {
|
|
$total = (int)$parts[1] * 1024;
|
|
$free = (int)$parts[3] * 1024;
|
|
}
|
|
}
|
|
|
|
$used = max(0, $total - $free);
|
|
|
|
return [
|
|
'path' => '/',
|
|
'total_kb' => (int)round($total / 1024),
|
|
'used_kb' => (int)round($used / 1024),
|
|
'free_kb' => (int)round($free / 1024),
|
|
'percent' => $total > 0 ? round($used / $total * 100, 1) : 0,
|
|
];
|
|
}
|
|
|
|
function active_user_info(): array
|
|
{
|
|
$out = trim(sh(['/usr/bin/who'], false, 3)['out']);
|
|
|
|
if ($out === '') {
|
|
return [
|
|
'users' => 0,
|
|
'sessions' => 0,
|
|
'names' => '',
|
|
'display' => '0 users / 0 sessions',
|
|
];
|
|
}
|
|
|
|
$lines = preg_split('/\R/', $out) ?: [];
|
|
$lines = array_values(array_filter($lines, fn($line) => trim($line) !== ''));
|
|
|
|
$names = [];
|
|
|
|
foreach ($lines as $line) {
|
|
$parts = preg_split('/\s+/', trim($line));
|
|
if (!empty($parts[0])) {
|
|
$names[$parts[0]] = true;
|
|
}
|
|
}
|
|
|
|
$userNames = array_keys($names);
|
|
|
|
return [
|
|
'users' => count($userNames),
|
|
'sessions' => count($lines),
|
|
'names' => implode(', ', $userNames),
|
|
'display' => count($userNames) . ' users / ' . count($lines) . ' sessions',
|
|
];
|
|
}
|
|
|
|
function os_info(): array
|
|
{
|
|
$os = [];
|
|
|
|
foreach (@file('/etc/os-release', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
|
if (preg_match('/^([A-Z_]+)=(.*)$/', $line, $m)) {
|
|
$os[$m[1]] = trim($m[2], '"');
|
|
}
|
|
}
|
|
|
|
$uptimeRaw = trim((string)@file_get_contents('/proc/uptime'));
|
|
$uptimeSec = $uptimeRaw !== ''
|
|
? (int)floor((float)explode(' ', $uptimeRaw)[0])
|
|
: 0;
|
|
|
|
return [
|
|
'hostname' => gethostname() ?: 'N/A',
|
|
'os' => $os['PRETTY_NAME'] ?? 'N/A',
|
|
'kernel' => php_uname('r'),
|
|
'arch' => php_uname('m'),
|
|
'model' => trim(@file_get_contents('/proc/device-tree/model') ?: '') ?: 'N/A',
|
|
'uptime' => human_seconds($uptimeSec),
|
|
'uptime_seconds' => $uptimeSec,
|
|
];
|
|
}
|
|
|
|
function network_info(): array
|
|
{
|
|
$rows = [];
|
|
|
|
foreach (glob('/sys/class/net/*') ?: [] as $dir) {
|
|
$name = basename($dir);
|
|
|
|
if ($name === 'lo') {
|
|
continue;
|
|
}
|
|
|
|
$rx = (int)first_readable([$dir . '/statistics/rx_bytes']);
|
|
$tx = (int)first_readable([$dir . '/statistics/tx_bytes']);
|
|
|
|
$rows[] = [
|
|
'name' => $name,
|
|
'state' => first_readable([$dir . '/operstate']) ?: 'unknown',
|
|
'carrier' => first_readable([$dir . '/carrier']) === '1' ? 'up' : 'down',
|
|
'mac' => first_readable([$dir . '/address']) ?: 'N/A',
|
|
'mtu' => first_readable([$dir . '/mtu']) ?: 'N/A',
|
|
'rx_bytes' => $rx,
|
|
'tx_bytes' => $tx,
|
|
'rx_human' => bytes_human($rx),
|
|
'tx_human' => bytes_human($tx),
|
|
'ipv4' => trim(sh(['/usr/sbin/ip', '-4', '-o', 'addr', 'show', 'dev', $name], false, 3)['out']) ?: 'N/A',
|
|
];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
function latest_sensor(): array
|
|
{
|
|
$stmt = db()->query("
|
|
SELECT *
|
|
FROM sensor_logs
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
");
|
|
|
|
return $stmt->fetch() ?: [];
|
|
}
|
|
|
|
function sensor_history(int $limit = 240): array
|
|
{
|
|
$limit = max(1, min(1500, $limit));
|
|
|
|
$stmt = db()->query("
|
|
SELECT
|
|
sl.recorded_at AS time,
|
|
sl.cpu_temp_c AS temp_c,
|
|
sl.fan_rpm,
|
|
sl.fan_efficiency,
|
|
sl.rp1_temp_c,
|
|
sl.cpu_voltage,
|
|
sl.cpu_watts,
|
|
sl.battery_voltage,
|
|
sl.battery_percent,
|
|
sl.pwm_value AS fan_pwm,
|
|
sl.pwm_percent,
|
|
sl.pwm_mode,
|
|
sl.cpu_load_1,
|
|
sl.cpu_load_5,
|
|
sl.cpu_load_15,
|
|
sl.disk_total_kb,
|
|
sl.disk_used_kb,
|
|
sl.disk_free_kb,
|
|
sl.mem_total_mb,
|
|
sl.mem_used_mb,
|
|
CASE
|
|
WHEN sl.mem_total_mb > 0 THEN ROUND(sl.mem_used_mb / sl.mem_total_mb * 100, 1)
|
|
ELSE 0
|
|
END AS mem_percent
|
|
FROM sensor_logs sl
|
|
ORDER BY sl.id DESC
|
|
LIMIT {$limit}
|
|
");
|
|
|
|
return array_reverse($stmt->fetchAll());
|
|
}
|
|
|
|
function numeric_median(array $values): ?float
|
|
{
|
|
$numbers = [];
|
|
foreach ($values as $value) {
|
|
if ($value !== null && $value !== '' && is_numeric($value)) {
|
|
$numbers[] = (float)$value;
|
|
}
|
|
}
|
|
|
|
if ($numbers === []) {
|
|
return null;
|
|
}
|
|
|
|
sort($numbers, SORT_NUMERIC);
|
|
$count = count($numbers);
|
|
$mid = intdiv($count, 2);
|
|
|
|
return ($count % 2) === 1
|
|
? $numbers[$mid]
|
|
: ($numbers[$mid - 1] + $numbers[$mid]) / 2;
|
|
}
|
|
|
|
function numeric_trimmed_average(array $values, float $trimRatio = 0.1): ?float
|
|
{
|
|
$numbers = [];
|
|
foreach ($values as $value) {
|
|
if ($value !== null && $value !== '' && is_numeric($value)) {
|
|
$number = (float)$value;
|
|
if ($number > 0) {
|
|
$numbers[] = $number;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($numbers === []) {
|
|
return null;
|
|
}
|
|
|
|
sort($numbers, SORT_NUMERIC);
|
|
$trim = (int)floor(count($numbers) * max(0.0, min(0.4, $trimRatio)));
|
|
if ($trim > 0 && count($numbers) > ($trim * 2 + 2)) {
|
|
$numbers = array_slice($numbers, $trim, count($numbers) - ($trim * 2));
|
|
}
|
|
|
|
return array_sum($numbers) / count($numbers);
|
|
}
|
|
|
|
function weighted_linear_regression(array $points): ?array
|
|
{
|
|
$weightSum = 0.0;
|
|
$xSum = 0.0;
|
|
$ySum = 0.0;
|
|
|
|
foreach ($points as $point) {
|
|
$weight = max(0.0001, (float)($point['weight'] ?? 1.0));
|
|
$x = (float)$point['x'];
|
|
$y = (float)$point['y'];
|
|
$weightSum += $weight;
|
|
$xSum += $x * $weight;
|
|
$ySum += $y * $weight;
|
|
}
|
|
|
|
if ($weightSum <= 0 || count($points) < 3) {
|
|
return null;
|
|
}
|
|
|
|
$xMean = $xSum / $weightSum;
|
|
$yMean = $ySum / $weightSum;
|
|
$cov = 0.0;
|
|
$var = 0.0;
|
|
|
|
foreach ($points as $point) {
|
|
$weight = max(0.0001, (float)($point['weight'] ?? 1.0));
|
|
$dx = (float)$point['x'] - $xMean;
|
|
$dy = (float)$point['y'] - $yMean;
|
|
$cov += $weight * $dx * $dy;
|
|
$var += $weight * $dx * $dx;
|
|
}
|
|
|
|
if ($var <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$slope = $cov / $var;
|
|
return [
|
|
'slope' => $slope,
|
|
'intercept' => $yMean - ($slope * $xMean),
|
|
'weight_sum' => $weightSum,
|
|
];
|
|
}
|
|
|
|
function numeric_mad(array $values): ?float
|
|
{
|
|
$median = numeric_median($values);
|
|
if ($median === null) {
|
|
return null;
|
|
}
|
|
|
|
$deviations = [];
|
|
foreach ($values as $value) {
|
|
if ($value !== null && $value !== '' && is_numeric($value)) {
|
|
$deviations[] = abs((float)$value - $median);
|
|
}
|
|
}
|
|
|
|
return numeric_median($deviations);
|
|
}
|
|
|
|
function battery_trend_history(int $hours = 24): array
|
|
{
|
|
$hours = max(1, min(48, $hours));
|
|
static $cache = [];
|
|
$cacheKey = (string)$hours;
|
|
$now = time();
|
|
|
|
if (
|
|
isset($cache[$cacheKey])
|
|
&& ($now - (int)$cache[$cacheKey]['time']) < 55
|
|
&& is_array($cache[$cacheKey]['rows'])
|
|
) {
|
|
return $cache[$cacheKey]['rows'];
|
|
}
|
|
|
|
$stmt = db()->query("
|
|
SELECT
|
|
FLOOR(UNIX_TIMESTAMP(recorded_at) / 60) AS bucket,
|
|
MIN(recorded_at) AS time,
|
|
AVG(battery_percent) AS battery_percent,
|
|
AVG(battery_voltage) AS battery_voltage,
|
|
AVG(cpu_watts) AS cpu_watts,
|
|
AVG(cpu_load_1) AS cpu_load_1,
|
|
COUNT(*) AS samples
|
|
FROM sensor_logs FORCE INDEX (idx_recorded_at)
|
|
WHERE recorded_at >= DATE_SUB(NOW(), INTERVAL {$hours} HOUR)
|
|
AND battery_percent IS NOT NULL
|
|
GROUP BY bucket
|
|
ORDER BY bucket ASC
|
|
");
|
|
|
|
$rows = [];
|
|
foreach ($stmt->fetchAll() as $row) {
|
|
$time = strtotime((string)($row['time'] ?? ''));
|
|
if ($time <= 0 || !is_numeric($row['battery_percent'] ?? null)) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'time' => $time,
|
|
'battery_percent' => (float)$row['battery_percent'],
|
|
'battery_voltage' => is_numeric($row['battery_voltage'] ?? null) ? (float)$row['battery_voltage'] : null,
|
|
'cpu_watts' => is_numeric($row['cpu_watts'] ?? null) ? (float)$row['cpu_watts'] : null,
|
|
'cpu_load_1' => is_numeric($row['cpu_load_1'] ?? null) ? (float)$row['cpu_load_1'] : null,
|
|
'samples' => (int)($row['samples'] ?? 0),
|
|
];
|
|
}
|
|
|
|
$cache[$cacheKey] = [
|
|
'time' => $now,
|
|
'rows' => $rows,
|
|
];
|
|
|
|
return $rows;
|
|
}
|
|
|
|
function battery_profile_history(int $days = 45): array
|
|
{
|
|
$days = max(3, min(90, $days));
|
|
static $cache = [];
|
|
$cacheKey = (string)$days;
|
|
$now = time();
|
|
$cacheFile = sys_get_temp_dir() . '/control-battery-profile-' . $days . 'd.json';
|
|
|
|
if (
|
|
isset($cache[$cacheKey])
|
|
&& ($now - (int)$cache[$cacheKey]['time']) < 600
|
|
&& is_array($cache[$cacheKey]['rows'])
|
|
) {
|
|
return $cache[$cacheKey]['rows'];
|
|
}
|
|
|
|
if (is_readable($cacheFile)) {
|
|
$raw = @file_get_contents($cacheFile);
|
|
$decoded = is_string($raw) ? json_decode($raw, true) : null;
|
|
if (
|
|
is_array($decoded)
|
|
&& isset($decoded['created_at'], $decoded['rows'])
|
|
&& is_array($decoded['rows'])
|
|
&& ($now - (int)$decoded['created_at']) < 21600
|
|
) {
|
|
$cache[$cacheKey] = [
|
|
'time' => $now,
|
|
'rows' => $decoded['rows'],
|
|
];
|
|
return $decoded['rows'];
|
|
}
|
|
}
|
|
|
|
$maxId = (int)db()->query("SELECT MAX(id) FROM sensor_logs")->fetchColumn();
|
|
$minId = max(1, $maxId - 3000000);
|
|
|
|
$stmt = db()->query("
|
|
SELECT
|
|
recorded_at AS time,
|
|
battery_percent,
|
|
battery_voltage,
|
|
cpu_watts,
|
|
cpu_load_1,
|
|
1 AS samples
|
|
FROM sensor_logs FORCE INDEX (idx_recorded_at)
|
|
WHERE recorded_at >= DATE_SUB(NOW(), INTERVAL {$days} DAY)
|
|
AND battery_percent IS NOT NULL
|
|
AND id >= {$minId}
|
|
AND MOD(id, 300) = 0
|
|
ORDER BY recorded_at ASC
|
|
");
|
|
|
|
$rows = [];
|
|
foreach ($stmt->fetchAll() as $row) {
|
|
$time = strtotime((string)($row['time'] ?? ''));
|
|
if ($time <= 0 || !is_numeric($row['battery_percent'] ?? null)) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'time' => $time,
|
|
'battery_percent' => (float)$row['battery_percent'],
|
|
'battery_voltage' => is_numeric($row['battery_voltage'] ?? null) ? (float)$row['battery_voltage'] : null,
|
|
'cpu_watts' => is_numeric($row['cpu_watts'] ?? null) ? (float)$row['cpu_watts'] : null,
|
|
'cpu_load_1' => is_numeric($row['cpu_load_1'] ?? null) ? (float)$row['cpu_load_1'] : null,
|
|
'samples' => (int)($row['samples'] ?? 0),
|
|
];
|
|
}
|
|
|
|
$cache[$cacheKey] = [
|
|
'time' => $now,
|
|
'rows' => $rows,
|
|
];
|
|
@file_put_contents(
|
|
$cacheFile,
|
|
json_encode([
|
|
'created_at' => $now,
|
|
'days' => $days,
|
|
'rows' => $rows,
|
|
], JSON_UNESCAPED_UNICODE)
|
|
);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
function battery_trend_candidate(array $trendRows, float $currentPercent, int $windowSeconds, ?float $recentWatts): ?array
|
|
{
|
|
if (count($trendRows) < 6) {
|
|
return null;
|
|
}
|
|
|
|
$lastTime = (int)$trendRows[count($trendRows) - 1]['time'];
|
|
$rows = array_values(array_filter($trendRows, static function (array $row) use ($lastTime, $windowSeconds): bool {
|
|
return (int)$row['time'] >= ($lastTime - $windowSeconds)
|
|
&& is_numeric($row['battery_percent'] ?? null);
|
|
}));
|
|
|
|
$count = count($rows);
|
|
if ($count < 6) {
|
|
return null;
|
|
}
|
|
|
|
$first = $rows[0];
|
|
$last = $rows[$count - 1];
|
|
$elapsed = max(1, (int)$last['time'] - (int)$first['time']);
|
|
if ($elapsed < 600) {
|
|
return null;
|
|
}
|
|
|
|
$edgeCount = max(3, min(10, (int)floor($count * 0.12)));
|
|
$startSoc = numeric_median(array_column(array_slice($rows, 0, $edgeCount), 'battery_percent'));
|
|
$endSoc = numeric_median(array_column(array_slice($rows, -$edgeCount), 'battery_percent'));
|
|
if ($startSoc === null || $endSoc === null) {
|
|
return null;
|
|
}
|
|
|
|
$drop = $startSoc - $endSoc;
|
|
$minDrop = $windowSeconds <= 3600 ? 0.12 : 0.22;
|
|
if ($drop < $minDrop) {
|
|
return null;
|
|
}
|
|
|
|
$rate = $drop / $elapsed;
|
|
if ($rate <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
|
|
$loadFactor = 1.0;
|
|
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
|
|
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.65, 1.75) - 1) * (float)setting_value('battery.load_adjust_recent'));
|
|
$rate *= $loadFactor;
|
|
}
|
|
|
|
$seconds = (int)round($currentPercent / $rate);
|
|
if ($seconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$confidence = min(1.0, $elapsed / $windowSeconds)
|
|
* min(1.0, $drop / 1.2)
|
|
* min(1.0, $count / 90);
|
|
|
|
return [
|
|
'seconds' => $seconds,
|
|
'rate_per_second' => $rate,
|
|
'drop_percent' => round($drop, 3),
|
|
'elapsed_seconds' => $elapsed,
|
|
'window_seconds' => $windowSeconds,
|
|
'sample_count' => $count,
|
|
'confidence' => round($confidence, 4),
|
|
'weight' => max(0.05, $confidence),
|
|
'avg_watts' => $windowWatts === null ? null : round($windowWatts, 3),
|
|
'load_factor' => round($loadFactor, 3),
|
|
'source' => 'recent_window',
|
|
];
|
|
}
|
|
|
|
function battery_regression_trend_candidate(array $trendRows, float $currentPercent, int $windowSeconds, ?float $recentWatts): ?array
|
|
{
|
|
if (count($trendRows) < 10) {
|
|
return null;
|
|
}
|
|
|
|
$lastTime = (int)$trendRows[count($trendRows) - 1]['time'];
|
|
$rows = array_values(array_filter($trendRows, static function (array $row) use ($lastTime, $windowSeconds): bool {
|
|
return (int)$row['time'] >= ($lastTime - $windowSeconds)
|
|
&& is_numeric($row['battery_percent'] ?? null);
|
|
}));
|
|
|
|
$count = count($rows);
|
|
if ($count < 10) {
|
|
return null;
|
|
}
|
|
|
|
$firstTime = (int)$rows[0]['time'];
|
|
$lastRowTime = (int)$rows[$count - 1]['time'];
|
|
$elapsed = max(1, $lastRowTime - $firstTime);
|
|
if ($elapsed < 900) {
|
|
return null;
|
|
}
|
|
|
|
$points = [];
|
|
$lastIndex = max(1, $count - 1);
|
|
foreach ($rows as $index => $row) {
|
|
$soc = (float)$row['battery_percent'];
|
|
$samples = max(1, (int)($row['samples'] ?? 1));
|
|
$recencyWeight = 0.65 + (0.35 * ($index / $lastIndex));
|
|
$sampleWeight = min(1.0, sqrt($samples) / 4);
|
|
$points[] = [
|
|
'x' => max(0, (int)$row['time'] - $firstTime),
|
|
'y' => $soc,
|
|
'weight' => $recencyWeight * max(0.35, $sampleWeight),
|
|
];
|
|
}
|
|
|
|
$fit = weighted_linear_regression($points);
|
|
if ($fit === null || (float)$fit['slope'] >= 0) {
|
|
return null;
|
|
}
|
|
|
|
$residuals = [];
|
|
foreach ($points as $point) {
|
|
$residuals[] = (float)$point['y'] - ((float)$fit['intercept'] + ((float)$fit['slope'] * (float)$point['x']));
|
|
}
|
|
|
|
$mad = numeric_mad($residuals);
|
|
$residualLimit = max(0.08, ($mad ?? 0.0) * 3.5);
|
|
$filtered = [];
|
|
foreach ($points as $index => $point) {
|
|
if (abs($residuals[$index]) <= $residualLimit) {
|
|
$filtered[] = $point;
|
|
}
|
|
}
|
|
|
|
if (count($filtered) >= max(8, (int)floor($count * 0.62))) {
|
|
$refit = weighted_linear_regression($filtered);
|
|
if ($refit !== null && (float)$refit['slope'] < 0) {
|
|
$fit = $refit;
|
|
$points = $filtered;
|
|
}
|
|
}
|
|
|
|
$rate = -(float)$fit['slope'];
|
|
$drop = $rate * $elapsed;
|
|
$minDrop = $windowSeconds <= 3600 ? 0.10 : 0.18;
|
|
if ($rate <= 0 || $drop < $minDrop) {
|
|
return null;
|
|
}
|
|
|
|
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
|
|
$loadFactor = 1.0;
|
|
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
|
|
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.7, 1.65) - 1) * (float)setting_value('battery.load_adjust_regression'));
|
|
$rate *= $loadFactor;
|
|
}
|
|
|
|
$seconds = (int)round($currentPercent / $rate);
|
|
if ($seconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$keptRatio = count($points) / $count;
|
|
$confidence = min(1.0, $elapsed / $windowSeconds)
|
|
* min(1.0, $drop / 1.0)
|
|
* min(1.0, $count / 120)
|
|
* clamp_float($keptRatio, 0.45, 1.0);
|
|
|
|
return [
|
|
'seconds' => $seconds,
|
|
'rate_per_second' => $rate,
|
|
'drop_percent' => round($drop, 3),
|
|
'elapsed_seconds' => $elapsed,
|
|
'window_seconds' => $windowSeconds,
|
|
'sample_count' => $count,
|
|
'confidence' => round($confidence, 4),
|
|
'weight' => max(0.05, $confidence * 1.18),
|
|
'avg_watts' => $windowWatts === null ? null : round($windowWatts, 3),
|
|
'load_factor' => round($loadFactor, 3),
|
|
'source' => 'robust_regression',
|
|
'kept_ratio' => round($keptRatio, 3),
|
|
];
|
|
}
|
|
|
|
function battery_voltage_floor(float $currentVoltage): float
|
|
{
|
|
if ($currentVoltage >= 3.55) {
|
|
return (float)setting_value('battery.voltage_floor_high');
|
|
}
|
|
if ($currentVoltage >= 3.35) {
|
|
return (float)setting_value('battery.voltage_floor_mid');
|
|
}
|
|
|
|
return (float)setting_value('battery.voltage_floor_low');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
$lastTime = (int)$trendRows[count($trendRows) - 1]['time'];
|
|
$rows = array_values(array_filter($trendRows, static function (array $row) use ($lastTime, $windowSeconds): bool {
|
|
return (int)$row['time'] >= ($lastTime - $windowSeconds)
|
|
&& is_numeric($row['battery_voltage'] ?? null);
|
|
}));
|
|
|
|
$count = count($rows);
|
|
if ($count < 10) {
|
|
return null;
|
|
}
|
|
|
|
$firstTime = (int)$rows[0]['time'];
|
|
$elapsed = max(1, (int)$rows[$count - 1]['time'] - $firstTime);
|
|
if ($elapsed < 1200) {
|
|
return null;
|
|
}
|
|
|
|
$points = [];
|
|
$lastIndex = max(1, $count - 1);
|
|
foreach ($rows as $index => $row) {
|
|
$voltage = (float)$row['battery_voltage'];
|
|
if ($voltage < 2.7 || $voltage > 4.5) {
|
|
continue;
|
|
}
|
|
|
|
$samples = max(1, (int)($row['samples'] ?? 1));
|
|
$recencyWeight = 0.7 + (0.3 * ($index / $lastIndex));
|
|
$sampleWeight = min(1.0, sqrt($samples) / 4);
|
|
$points[] = [
|
|
'x' => max(0, (int)$row['time'] - $firstTime),
|
|
'y' => $voltage,
|
|
'weight' => $recencyWeight * max(0.35, $sampleWeight),
|
|
];
|
|
}
|
|
|
|
if (count($points) < 10) {
|
|
return null;
|
|
}
|
|
|
|
$fit = weighted_linear_regression($points);
|
|
if ($fit === null || (float)$fit['slope'] >= -0.0000003) {
|
|
return null;
|
|
}
|
|
|
|
$residuals = [];
|
|
foreach ($points as $point) {
|
|
$residuals[] = (float)$point['y'] - ((float)$fit['intercept'] + ((float)$fit['slope'] * (float)$point['x']));
|
|
}
|
|
$mad = numeric_mad($residuals);
|
|
$residualLimit = max(0.006, ($mad ?? 0.0) * 3.2);
|
|
$filtered = [];
|
|
foreach ($points as $index => $point) {
|
|
if (abs($residuals[$index]) <= $residualLimit) {
|
|
$filtered[] = $point;
|
|
}
|
|
}
|
|
|
|
if (count($filtered) >= max(8, (int)floor(count($points) * 0.58))) {
|
|
$refit = weighted_linear_regression($filtered);
|
|
if ($refit !== null && (float)$refit['slope'] < 0) {
|
|
$fit = $refit;
|
|
$points = $filtered;
|
|
}
|
|
}
|
|
|
|
$voltsPerSecond = -(float)$fit['slope'];
|
|
$voltageFloor = battery_voltage_floor($currentVoltage);
|
|
$usableVolts = $currentVoltage - $voltageFloor;
|
|
if ($voltsPerSecond <= 0 || $usableVolts < 0.015) {
|
|
return null;
|
|
}
|
|
|
|
$seconds = (int)round($usableVolts / $voltsPerSecond);
|
|
if ($seconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
|
|
$loadFactor = 1.0;
|
|
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
|
|
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.75, 1.55) - 1) * (float)setting_value('battery.load_adjust_voltage'));
|
|
$seconds = (int)round($seconds / $loadFactor);
|
|
}
|
|
if ($seconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$rate = $currentPercent / $seconds;
|
|
$keptRatio = count($points) / $count;
|
|
$confidence = min(1.0, $elapsed / $windowSeconds)
|
|
* min(1.0, (($currentVoltage - (float)$rows[0]['battery_voltage']) * -1) / 0.08)
|
|
* min(1.0, $count / 120)
|
|
* clamp_float($keptRatio, 0.4, 1.0);
|
|
|
|
return [
|
|
'seconds' => $seconds,
|
|
'rate_per_second' => $rate,
|
|
'drop_percent' => round($currentPercent, 3),
|
|
'elapsed_seconds' => $elapsed,
|
|
'window_seconds' => $windowSeconds,
|
|
'sample_count' => $count,
|
|
'confidence' => round(max(0.03, $confidence), 4),
|
|
'weight' => max(0.03, $confidence * 0.72),
|
|
'avg_watts' => $windowWatts === null ? null : round($windowWatts, 3),
|
|
'load_factor' => round($loadFactor, 3),
|
|
'source' => 'voltage_slope',
|
|
'kept_ratio' => round($keptRatio, 3),
|
|
'voltage_floor' => round($voltageFloor, 3),
|
|
'volts_per_hour' => round($voltsPerSecond * 3600, 4),
|
|
];
|
|
}
|
|
|
|
function battery_learned_profile_candidate(array $profileRows, float $currentPercent, ?float $recentWatts): ?array
|
|
{
|
|
if (count($profileRows) < 24) {
|
|
return null;
|
|
}
|
|
|
|
$now = time();
|
|
$rates = [];
|
|
$dropSum = 0.0;
|
|
$sampleSum = 0;
|
|
$socBuckets = [];
|
|
$minSoc = max(0.0, $currentPercent - 70.0);
|
|
$maxSoc = min(100.0, $currentPercent + 8.0);
|
|
|
|
for ($i = 1, $count = count($profileRows); $i < $count; $i++) {
|
|
$prev = $profileRows[$i - 1];
|
|
$row = $profileRows[$i];
|
|
$elapsed = (int)$row['time'] - (int)$prev['time'];
|
|
if ($elapsed < 240 || $elapsed > 900) {
|
|
continue;
|
|
}
|
|
|
|
$startSoc = (float)$prev['battery_percent'];
|
|
$endSoc = (float)$row['battery_percent'];
|
|
$avgSoc = ($startSoc + $endSoc) / 2;
|
|
if ($avgSoc < $minSoc || $avgSoc > $maxSoc) {
|
|
continue;
|
|
}
|
|
|
|
$drop = $startSoc - $endSoc;
|
|
if ($drop < 0.03 || $drop > 8.0) {
|
|
continue;
|
|
}
|
|
|
|
$avgWatts = numeric_trimmed_average([$prev['cpu_watts'] ?? null, $row['cpu_watts'] ?? null], 0.0);
|
|
$loadFactor = 1.0;
|
|
if ($recentWatts !== null && $recentWatts > 0 && $avgWatts !== null && $avgWatts > 0) {
|
|
$loadFactor = 1 + ((clamp_float($recentWatts / $avgWatts, 0.55, 1.95) - 1) * 0.5);
|
|
}
|
|
|
|
$socDistance = abs($currentPercent - $avgSoc);
|
|
$socWeight = 1 / (1 + ($socDistance / 18));
|
|
$ageDays = max(0.0, ($now - (int)$row['time']) / 86400);
|
|
$recencyWeight = 1 / (1 + ($ageDays / 45));
|
|
$sampleWeight = min(1.0, max(1, ((int)$prev['samples'] + (int)$row['samples']) / 2) / 30);
|
|
$weight = max(0.01, $socWeight * $recencyWeight * $sampleWeight);
|
|
|
|
$rates[] = [
|
|
'rate' => ($drop / $elapsed) * $loadFactor,
|
|
'weight' => $weight,
|
|
'soc' => $avgSoc,
|
|
'drop' => $drop,
|
|
'watts' => $avgWatts,
|
|
];
|
|
$dropSum += $drop;
|
|
$sampleSum += (int)$prev['samples'] + (int)$row['samples'];
|
|
$bucket = (int)(floor($avgSoc / 10) * 10);
|
|
$socBuckets[$bucket] = true;
|
|
}
|
|
|
|
if (count($rates) < 12 || $dropSum < 1.0) {
|
|
return null;
|
|
}
|
|
|
|
$weightedRate = 0.0;
|
|
$weightSum = 0.0;
|
|
foreach ($rates as $row) {
|
|
$weightedRate += (float)$row['rate'] * (float)$row['weight'];
|
|
$weightSum += (float)$row['weight'];
|
|
}
|
|
|
|
if ($weightedRate <= 0 || $weightSum <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$rate = $weightedRate / $weightSum;
|
|
$seconds = (int)round($currentPercent / $rate);
|
|
if ($seconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$watts = numeric_trimmed_average(array_column($rates, 'watts'), 0.1);
|
|
$confidence = min(1.0, count($rates) / 240)
|
|
* min(1.0, $dropSum / 18)
|
|
* min(1.0, count($socBuckets) / 5);
|
|
|
|
return [
|
|
'seconds' => $seconds,
|
|
'rate_per_second' => $rate,
|
|
'drop_percent' => round($dropSum, 3),
|
|
'elapsed_seconds' => null,
|
|
'window_seconds' => null,
|
|
'sample_count' => $sampleSum,
|
|
'confidence' => round($confidence, 4),
|
|
'weight' => max(0.05, $confidence * 0.9),
|
|
'avg_watts' => $watts === null ? null : round($watts, 3),
|
|
'load_factor' => null,
|
|
'source' => 'learned_profile',
|
|
'intervals' => count($rates),
|
|
'soc_buckets' => count($socBuckets),
|
|
];
|
|
}
|
|
|
|
function battery_capacity_power_candidate(float $currentPercent, ?float $recentWatts): ?array
|
|
{
|
|
if (BATTERY_CAPACITY_WH <= 0 || $recentWatts === null || $recentWatts <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$recentWatts = battery_effective_system_watts($recentWatts);
|
|
$remainingWh = BATTERY_CAPACITY_WH * ($currentPercent / 100);
|
|
$seconds = (int)round(($remainingWh / $recentWatts) * 3600);
|
|
if ($seconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'seconds' => $seconds,
|
|
'rate_per_second' => $currentPercent / $seconds,
|
|
'drop_percent' => round($currentPercent, 3),
|
|
'elapsed_seconds' => null,
|
|
'window_seconds' => null,
|
|
'sample_count' => null,
|
|
'confidence' => 0.34,
|
|
'weight' => (float)setting_value('battery.capacity_model_weight'),
|
|
'avg_watts' => round($recentWatts, 3),
|
|
'load_factor' => 1.0,
|
|
'source' => 'capacity_power_model',
|
|
'capacity_wh' => BATTERY_CAPACITY_WH,
|
|
];
|
|
}
|
|
|
|
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;
|
|
$dropSum = 0.0;
|
|
$intervals = 0;
|
|
$socBuckets = [];
|
|
|
|
for ($i = 1, $count = count($profileRows); $i < $count; $i++) {
|
|
$prev = $profileRows[$i - 1];
|
|
$row = $profileRows[$i];
|
|
$elapsed = (int)$row['time'] - (int)$prev['time'];
|
|
if ($elapsed < 240 || $elapsed > 1200) {
|
|
continue;
|
|
}
|
|
|
|
if (!is_numeric($prev['battery_percent'] ?? null) || !is_numeric($row['battery_percent'] ?? null)) {
|
|
continue;
|
|
}
|
|
$startSoc = (float)$prev['battery_percent'];
|
|
$endSoc = (float)$row['battery_percent'];
|
|
$drop = $startSoc - $endSoc;
|
|
if ($drop < 0.03 || $drop > 6.0) {
|
|
continue;
|
|
}
|
|
|
|
$avgWatts = numeric_trimmed_average([$prev['cpu_watts'] ?? null, $row['cpu_watts'] ?? null], 0.0);
|
|
if ($avgWatts === null || $avgWatts <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$avgSoc = ($startSoc + $endSoc) / 2;
|
|
$socDistance = abs($currentPercent - $avgSoc);
|
|
$socWeight = 1 / (1 + ($socDistance / 22));
|
|
$ageDays = max(0.0, ($now - (int)$row['time']) / 86400);
|
|
$recencyWeight = 1 / (1 + ($ageDays / 40));
|
|
$sampleWeight = min(1.0, max(1, ((int)$prev['samples'] + (int)$row['samples']) / 2) / 24);
|
|
$weight = max(0.01, $socWeight * $recencyWeight * $sampleWeight);
|
|
$whPerPercent = (($avgWatts * $elapsed) / 3600) / $drop;
|
|
|
|
if ($whPerPercent <= 0 || $whPerPercent > 4.0) {
|
|
continue;
|
|
}
|
|
|
|
$weightedWhPerPercent += $whPerPercent * $weight;
|
|
$weightSum += $weight;
|
|
$dropSum += $drop;
|
|
$intervals++;
|
|
$socBuckets[(int)(floor($avgSoc / 10) * 10)] = true;
|
|
}
|
|
|
|
if ($intervals < 12 || $dropSum < 1.0 || $weightSum <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$whPerPercent = $weightedWhPerPercent / $weightSum;
|
|
$remainingWh = $whPerPercent * $currentPercent;
|
|
$seconds = (int)round(($remainingWh / $recentWatts) * 3600);
|
|
if ($seconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$confidence = min(1.0, $intervals / 180)
|
|
* min(1.0, $dropSum / 14)
|
|
* min(1.0, count($socBuckets) / 5);
|
|
|
|
return [
|
|
'seconds' => $seconds,
|
|
'rate_per_second' => $currentPercent / $seconds,
|
|
'drop_percent' => round($dropSum, 3),
|
|
'elapsed_seconds' => null,
|
|
'window_seconds' => null,
|
|
'sample_count' => $intervals,
|
|
'confidence' => round($confidence, 4),
|
|
'weight' => max(0.04, $confidence * 0.82),
|
|
'avg_watts' => round($recentWatts, 3),
|
|
'load_factor' => 1.0,
|
|
'source' => 'energy_profile',
|
|
'intervals' => $intervals,
|
|
'soc_buckets' => count($socBuckets),
|
|
'wh_per_percent' => round($whPerPercent, 4),
|
|
];
|
|
}
|
|
|
|
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) {
|
|
return $candidates;
|
|
}
|
|
|
|
$rates = array_values(array_filter(
|
|
array_map(static fn(array $candidate): float => (float)($candidate['rate_per_second'] ?? 0), $candidates),
|
|
static fn(float $rate): bool => $rate > 0
|
|
));
|
|
$medianRate = numeric_median($rates);
|
|
if ($medianRate === null || $medianRate <= 0) {
|
|
return $candidates;
|
|
}
|
|
|
|
$refined = [];
|
|
foreach ($candidates as $candidate) {
|
|
$rate = (float)($candidate['rate_per_second'] ?? 0);
|
|
if ($rate <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$ratio = $rate / $medianRate;
|
|
if ($ratio < (float)setting_value('battery.candidate_min_ratio') || $ratio > (float)setting_value('battery.candidate_max_ratio')) {
|
|
continue;
|
|
}
|
|
|
|
$distance = abs(log(max(0.001, $ratio)));
|
|
$stability = 1 / (1 + ($distance * 1.85));
|
|
$candidate['weight'] = max(0.03, (float)$candidate['weight'] * $stability);
|
|
$candidate['stability'] = round($stability, 3);
|
|
$refined[] = $candidate;
|
|
}
|
|
|
|
return $refined !== [] ? $refined : $candidates;
|
|
}
|
|
|
|
function battery_power_fallback_estimate(float $percent, array $history): array
|
|
{
|
|
$wattValues = [];
|
|
|
|
foreach (array_slice($history, -180) as $row) {
|
|
$watts = $row['cpu_watts'] ?? null;
|
|
if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) {
|
|
$wattValues[] = (float)$watts;
|
|
}
|
|
}
|
|
|
|
$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 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 [
|
|
'display' => '-',
|
|
'seconds' => null,
|
|
'source' => BATTERY_CAPACITY_WH > 0 ? 'avg_watts_missing' : 'battery_capacity_missing',
|
|
'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3),
|
|
];
|
|
}
|
|
|
|
function battery_remaining_estimate(array $battery, array $history, array $trendRows = [], array $profileRows = []): array
|
|
{
|
|
$percent = $battery['percent'] ?? null;
|
|
if ($percent === null || $percent === '' || !is_numeric($percent)) {
|
|
return [
|
|
'display' => '-',
|
|
'seconds' => null,
|
|
'source' => 'battery_soc_missing',
|
|
'avg_watts' => null,
|
|
];
|
|
}
|
|
|
|
$percent = max(0.0, min(100.0, (float)$percent));
|
|
$currentVoltage = isset($battery['voltage']) && is_numeric($battery['voltage'])
|
|
? (float)$battery['voltage']
|
|
: null;
|
|
$recentWatts = numeric_trimmed_average(array_column(array_slice($trendRows, -10), 'cpu_watts'), 0.1)
|
|
?? numeric_trimmed_average(array_column(array_slice($history, -90), 'cpu_watts'), 0.1);
|
|
|
|
$candidates = [];
|
|
foreach ([1800, 3600, 10800, 21600, 43200, 86400] as $windowSeconds) {
|
|
$candidate = battery_trend_candidate($trendRows, $percent, $windowSeconds, $recentWatts);
|
|
if ($candidate !== null) {
|
|
$candidates[] = $candidate;
|
|
}
|
|
}
|
|
foreach ([2700, 7200, 14400, 28800, 57600, 86400] as $windowSeconds) {
|
|
$candidate = battery_regression_trend_candidate($trendRows, $percent, $windowSeconds, $recentWatts);
|
|
if ($candidate !== null) {
|
|
$candidates[] = $candidate;
|
|
}
|
|
}
|
|
foreach ([3600, 10800, 21600, 43200, 86400] as $windowSeconds) {
|
|
$candidate = battery_voltage_trend_candidate($trendRows, $percent, $currentVoltage, $windowSeconds, $recentWatts);
|
|
if ($candidate !== null) {
|
|
$candidates[] = $candidate;
|
|
}
|
|
}
|
|
|
|
$learned = battery_learned_profile_candidate($profileRows, $percent, $recentWatts);
|
|
if ($learned !== null) {
|
|
$candidates[] = $learned;
|
|
}
|
|
$energyProfile = battery_energy_profile_candidate($profileRows, $percent, $recentWatts);
|
|
if ($energyProfile !== null) {
|
|
$candidates[] = $energyProfile;
|
|
}
|
|
$capacityPower = battery_capacity_power_candidate($percent, $recentWatts);
|
|
if ($capacityPower !== null) {
|
|
$candidates[] = $capacityPower;
|
|
}
|
|
$candidates = battery_cap_candidates($candidates, $percent);
|
|
$candidates = refine_battery_candidates($candidates);
|
|
|
|
if ($candidates !== []) {
|
|
$weightedRate = 0.0;
|
|
$weightedConfidence = 0.0;
|
|
$weightSum = 0.0;
|
|
foreach ($candidates as $candidate) {
|
|
$weight = (float)$candidate['weight'];
|
|
$weightedRate += (float)$candidate['rate_per_second'] * $weight;
|
|
$weightedConfidence += (float)$candidate['confidence'] * $weight;
|
|
$weightSum += $weight;
|
|
}
|
|
|
|
if ($weightedRate > 0 && $weightSum > 0) {
|
|
$rate = $weightedRate / $weightSum;
|
|
$seconds = (int)round($percent / $rate);
|
|
$avgWatts = numeric_trimmed_average(array_column($candidates, 'avg_watts'), 0.0);
|
|
$rates = array_column($candidates, 'rate_per_second');
|
|
$medianRate = numeric_median($rates);
|
|
$rateSpread = 0.0;
|
|
if ($medianRate !== null && $medianRate > 0) {
|
|
$rateSpread = (numeric_mad($rates) ?? 0.0) / $medianRate;
|
|
}
|
|
|
|
return battery_cap_result([
|
|
'display' => human_remaining_seconds($seconds),
|
|
'seconds' => $seconds,
|
|
'source' => 'soc_multi_window',
|
|
'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3),
|
|
'recent_watts' => $recentWatts === null ? null : round($recentWatts, 3),
|
|
'drop_per_hour' => round($rate * 3600, 3),
|
|
'confidence' => round(($weightedConfidence / $weightSum) * (1 / (1 + $rateSpread)), 3),
|
|
'candidate_count' => count($candidates),
|
|
'rate_spread' => round($rateSpread, 4),
|
|
'sources' => array_values(array_unique(array_column($candidates, 'source'))),
|
|
'windows' => array_map(static function (array $candidate): array {
|
|
return [
|
|
'source' => $candidate['source'],
|
|
'minutes' => $candidate['window_seconds'] === null ? null : (int)round($candidate['window_seconds'] / 60),
|
|
'drop_percent' => $candidate['drop_percent'],
|
|
'confidence' => $candidate['confidence'],
|
|
'load_factor' => $candidate['load_factor'],
|
|
'intervals' => $candidate['intervals'] ?? null,
|
|
'soc_buckets' => $candidate['soc_buckets'] ?? null,
|
|
'kept_ratio' => $candidate['kept_ratio'] ?? null,
|
|
'stability' => $candidate['stability'] ?? null,
|
|
'voltage_floor' => $candidate['voltage_floor'] ?? null,
|
|
'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);
|
|
}
|
|
}
|
|
|
|
return battery_power_fallback_estimate($percent, $history);
|
|
}
|
|
|
|
function add_battery_remaining_history(array $history): array
|
|
{
|
|
$wattWindow = [];
|
|
$socWindow = [];
|
|
|
|
foreach ($history as $index => $row) {
|
|
$watts = $row['cpu_watts'] ?? null;
|
|
if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) {
|
|
$wattWindow[] = (float)$watts;
|
|
if (count($wattWindow) > 180) {
|
|
array_shift($wattWindow);
|
|
}
|
|
}
|
|
|
|
$percent = $row['battery_percent'] ?? null;
|
|
$time = strtotime((string)($row['time'] ?? ''));
|
|
if ($percent !== null && $percent !== '' && is_numeric($percent) && $time > 0) {
|
|
$socWindow[] = [
|
|
'time' => $time,
|
|
'soc' => (float)$percent,
|
|
];
|
|
if (count($socWindow) > 240) {
|
|
array_shift($socWindow);
|
|
}
|
|
}
|
|
|
|
$history[$index]['battery_remaining_seconds'] = null;
|
|
if (!is_numeric($percent)) {
|
|
continue;
|
|
}
|
|
|
|
$safePercent = max(0.0, min(100.0, (float)$percent));
|
|
if (count($socWindow) >= 30) {
|
|
$first = $socWindow[0];
|
|
$last = $socWindow[count($socWindow) - 1];
|
|
$elapsed = max(1, (int)$last['time'] - (int)$first['time']);
|
|
$drop = (float)$first['soc'] - (float)$last['soc'];
|
|
if ($elapsed >= 120 && $drop >= 0.12) {
|
|
$history[$index]['battery_remaining_seconds'] = (int)round($safePercent / ($drop / $elapsed));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$avgWatts = numeric_trimmed_average($wattWindow, 0.12);
|
|
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) {
|
|
$safePercent = max(0.0, min(100.0, (float)$percent));
|
|
$remainingWh = BATTERY_CAPACITY_WH * ($safePercent / 100);
|
|
$history[$index]['battery_remaining_seconds'] = (int)round(($remainingWh / $avgWatts) * 3600);
|
|
}
|
|
}
|
|
|
|
return $history;
|
|
}
|
|
|
|
function sync_current_battery_remaining_history(array $history, array $currentRow, mixed $remainingSeconds): array
|
|
{
|
|
$currentRow['battery_remaining_seconds'] = is_numeric($remainingSeconds)
|
|
? (int)round((float)$remainingSeconds)
|
|
: null;
|
|
|
|
$now = strtotime((string)($currentRow['time'] ?? ''));
|
|
$lastIndex = count($history) - 1;
|
|
if ($lastIndex >= 0) {
|
|
$lastTime = strtotime((string)($history[$lastIndex]['time'] ?? ''));
|
|
if ($now > 0 && $lastTime > 0 && abs($now - $lastTime) <= 5) {
|
|
$history[$lastIndex] = array_merge($history[$lastIndex], $currentRow);
|
|
return $history;
|
|
}
|
|
}
|
|
|
|
$history[] = $currentRow;
|
|
return array_slice($history, -(int)setting_value('battery.history_sync_limit'));
|
|
}
|
|
|
|
function process_service_name(int $pid): string
|
|
{
|
|
$cgroup = @file('/proc/' . $pid . '/cgroup', FILE_IGNORE_NEW_LINES) ?: [];
|
|
|
|
foreach ($cgroup as $line) {
|
|
if (preg_match('/([^\/:]+\.service)(?:\/|$)/', $line, $m)) {
|
|
return $m[1];
|
|
}
|
|
}
|
|
|
|
return 'N/A';
|
|
}
|
|
|
|
function process_args(int $pid): string
|
|
{
|
|
$raw = @file_get_contents('/proc/' . $pid . '/cmdline');
|
|
|
|
if (is_string($raw) && $raw !== '') {
|
|
return command_short(str_replace("\0", ' ', trim($raw, "\0")));
|
|
}
|
|
|
|
$comm = trim((string)@file_get_contents('/proc/' . $pid . '/comm'));
|
|
|
|
return $comm !== '' ? $comm : 'N/A';
|
|
}
|
|
|
|
function process_resource_data(int $limit = 6): array
|
|
{
|
|
$limit = max(3, min(12, $limit));
|
|
|
|
$ps = sh([
|
|
'/bin/ps',
|
|
'-eo',
|
|
'pid=,ppid=,user=,pcpu=,pmem=,rss=,comm=,args=',
|
|
'--sort=-pcpu',
|
|
], false, 4)['out'];
|
|
|
|
$cpu = [];
|
|
$mem = [];
|
|
|
|
foreach (preg_split('/\R/', trim($ps)) ?: [] as $line) {
|
|
$line = trim($line);
|
|
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
|
|
$parts = preg_split('/\s+/', $line, 8);
|
|
|
|
if (count($parts) < 8 || !ctype_digit($parts[0])) {
|
|
continue;
|
|
}
|
|
|
|
$pid = (int)$parts[0];
|
|
$row = [
|
|
'pid' => $pid,
|
|
'ppid' => (int)$parts[1],
|
|
'user' => $parts[2],
|
|
'cpu_percent' => round((float)$parts[3], 1),
|
|
'mem_percent' => round((float)$parts[4], 1),
|
|
'rss_mb' => round(((int)$parts[5]) / 1024, 1),
|
|
'name' => $parts[6],
|
|
'service' => process_service_name($pid),
|
|
'command' => command_short($parts[7]),
|
|
];
|
|
|
|
if ($row['cpu_percent'] > 0 || count($cpu) < $limit) {
|
|
$cpu[] = $row;
|
|
}
|
|
|
|
$mem[] = $row;
|
|
}
|
|
|
|
usort($mem, fn($a, $b) => ($b['mem_percent'] <=> $a['mem_percent']) ?: ($b['rss_mb'] <=> $a['rss_mb']));
|
|
|
|
return [
|
|
'cpu' => array_slice($cpu, 0, $limit),
|
|
'memory' => array_slice($mem, 0, $limit),
|
|
];
|
|
}
|
|
|
|
function notice_process_signature(array $processes): string
|
|
{
|
|
$cpu = null;
|
|
foreach (($processes['cpu'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$command = (string)($row['command'] ?? $row['name'] ?? '');
|
|
$service = (string)($row['service'] ?? '');
|
|
if (
|
|
str_contains($command, '/bin/ps')
|
|
|| str_contains($command, 'api.php')
|
|
|| str_contains($command, 'php-fpm')
|
|
|| $service === 'fanpanel-apply.service'
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$cpu = $row;
|
|
break;
|
|
}
|
|
|
|
$mem = null;
|
|
foreach (($processes['memory'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$mem = $row;
|
|
break;
|
|
}
|
|
|
|
return hash('sha256', json_encode([
|
|
'cpu' => $cpu === null ? null : [
|
|
'pid' => $cpu['pid'] ?? null,
|
|
'service' => $cpu['service'] ?? null,
|
|
'command' => $cpu['command'] ?? $cpu['name'] ?? null,
|
|
],
|
|
'mem' => $mem === null ? null : [
|
|
'pid' => $mem['pid'] ?? null,
|
|
'service' => $mem['service'] ?? null,
|
|
'command' => $mem['command'] ?? $mem['name'] ?? null,
|
|
],
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
function trimmed_average(array $values, float $fallback): float
|
|
{
|
|
$values = array_values(array_filter(
|
|
$values,
|
|
fn($value) => is_numeric($value) && (float)$value > 0
|
|
));
|
|
|
|
if (count($values) < 5) {
|
|
return $fallback;
|
|
}
|
|
|
|
sort($values, SORT_NUMERIC);
|
|
$trim = (int)floor(count($values) * 0.1);
|
|
if ($trim > 0 && count($values) > $trim * 2) {
|
|
$values = array_slice($values, $trim, count($values) - ($trim * 2));
|
|
}
|
|
|
|
return array_sum($values) / max(1, count($values));
|
|
}
|
|
|
|
function notice_rolling_baseline(array $history, float $temp, float $rpm, float $pwm, array $state = []): array
|
|
{
|
|
$sampleCount = (int)setting_value('notice.baseline_samples');
|
|
$skipRecent = (int)setting_value('notice.baseline_skip_recent');
|
|
$rows = array_slice($history, -$sampleCount);
|
|
if ($skipRecent > 0 && count($rows) > $skipRecent) {
|
|
$rows = array_slice($rows, 0, -$skipRecent);
|
|
}
|
|
|
|
$temps = [];
|
|
$rpms = [];
|
|
$pwms = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$rowTemp = (float)($row['temp_c'] ?? 0);
|
|
$rowRpm = (float)($row['fan_rpm'] ?? 0);
|
|
$rowPwm = (float)($row['fan_pwm'] ?? 0);
|
|
|
|
if ($rowTemp > 0) $temps[] = $rowTemp;
|
|
if ($rowRpm > 0) $rpms[] = $rowRpm;
|
|
if ($rowPwm > 0) $pwms[] = $rowPwm;
|
|
}
|
|
|
|
$fallbackTemp = (float)($state['baseline_temp'] ?? 0) > 0 ? (float)$state['baseline_temp'] : $temp;
|
|
$fallbackRpm = (float)($state['baseline_rpm'] ?? 0) > 0 ? (float)$state['baseline_rpm'] : $rpm;
|
|
$fallbackPwm = (float)($state['baseline_pwm'] ?? 0) > 0 ? (float)$state['baseline_pwm'] : $pwm;
|
|
|
|
return [
|
|
'temp' => trimmed_average($temps, $fallbackTemp),
|
|
'rpm' => trimmed_average($rpms, $fallbackRpm),
|
|
'pwm' => trimmed_average($pwms, $fallbackPwm),
|
|
'samples' => count($rows),
|
|
];
|
|
}
|
|
|
|
function fan_spike_analysis(array $history, array $fan, array $system, array $processes = []): array
|
|
{
|
|
$rpm = (float)($fan['rpm'] ?? 0);
|
|
$pwm = (float)($fan['pwm'] ?? 0);
|
|
$temp = (float)($system['temp_c'] ?? 0);
|
|
$state = system_notice_state();
|
|
$rollingBaseline = notice_rolling_baseline($history, $temp, $rpm, $pwm, $state);
|
|
|
|
$currentState = (string)($state['state'] ?? 'normal');
|
|
if (!in_array($currentState, ['normal', 'alert'], true)) {
|
|
$currentState = 'normal';
|
|
}
|
|
|
|
$bootEpoch = (int)(time() - max(0, (int)($system['uptime_seconds'] ?? 0)));
|
|
$alertStartedEpoch = strtotime((string)($state['alert_started_at'] ?? '')) ?: 0;
|
|
if ($currentState === 'alert' && $bootEpoch > 0 && ($alertStartedEpoch === 0 || $alertStartedEpoch < $bootEpoch)) {
|
|
$currentState = 'normal';
|
|
}
|
|
|
|
$rpmAvg = $currentState === 'alert' ? (float)($state['baseline_rpm'] ?? 0) : (float)$rollingBaseline['rpm'];
|
|
$pwmAvg = $currentState === 'alert' ? (float)($state['baseline_pwm'] ?? 0) : (float)$rollingBaseline['pwm'];
|
|
$tempAvg = $currentState === 'alert' ? (float)($state['baseline_temp'] ?? 0) : (float)$rollingBaseline['temp'];
|
|
|
|
if ($rpmAvg <= 0) $rpmAvg = $rpm;
|
|
if ($pwmAvg <= 0) $pwmAvg = $pwm;
|
|
if ($tempAvg <= 0) $tempAvg = $temp;
|
|
|
|
$rpmDelta = $rpm - $rpmAvg;
|
|
$pwmDelta = $pwm - $pwmAvg;
|
|
$tempDelta = $temp - $tempAvg;
|
|
$rollingRpmDelta = $rpm - (float)$rollingBaseline['rpm'];
|
|
$rollingTempDelta = $temp - (float)$rollingBaseline['temp'];
|
|
|
|
$mode = (string)($fan['mode'] ?? '');
|
|
if ($mode === 'off') {
|
|
$offBaselineReason = 'fan_off_baseline';
|
|
$hasOffBaseline = (string)($state['active_reason'] ?? '') === $offBaselineReason;
|
|
|
|
$tempAvg = $hasOffBaseline && (float)($state['baseline_temp'] ?? 0) > 0
|
|
? (float)$state['baseline_temp']
|
|
: $temp;
|
|
$rpmAvg = $hasOffBaseline && (float)($state['baseline_rpm'] ?? 0) >= 0
|
|
? (float)$state['baseline_rpm']
|
|
: $rpm;
|
|
$pwmAvg = $hasOffBaseline && (float)($state['baseline_pwm'] ?? 0) >= 0
|
|
? (float)$state['baseline_pwm']
|
|
: $pwm;
|
|
|
|
if (!$hasOffBaseline) {
|
|
save_system_notice_state('normal', $tempAvg, $rpmAvg, $pwmAvg, $offBaselineReason);
|
|
}
|
|
|
|
return [
|
|
'active' => false,
|
|
'summary' => 'Fan off baseline is fixed.',
|
|
'rpm_delta' => round($rpm - $rpmAvg, 1),
|
|
'pwm_delta' => round($pwm - $pwmAvg, 1),
|
|
'temp_delta' => round($temp - $tempAvg, 1),
|
|
'rpm_avg' => round($rpmAvg, 1),
|
|
'pwm_avg' => round($pwmAvg, 1),
|
|
'temp_avg' => round($tempAvg, 1),
|
|
'notice_state' => 'normal',
|
|
'baseline_source' => 'frozen_off',
|
|
'baseline_samples' => (int)$rollingBaseline['samples'],
|
|
];
|
|
}
|
|
|
|
$rpmExpectedPwm = (float)setting_value('notice.rpm_expected_pwm');
|
|
$rpmExpected = $mode !== 'off' && ($pwm >= $rpmExpectedPwm || $pwmAvg >= $rpmExpectedPwm);
|
|
|
|
$reasons = [];
|
|
$rpmThreshold = (float)setting_value('notice.rpm_delta_threshold');
|
|
$tempThreshold = (float)setting_value('notice.temp_delta_threshold');
|
|
$recoverRpmThreshold = (float)setting_value('notice.recover_rpm_delta');
|
|
$recoverTempThreshold = (float)setting_value('notice.recover_temp_delta');
|
|
|
|
if ($rpmExpected && abs($rpmDelta) >= $rpmThreshold) $reasons[] = 'RPM ' . signed_delta_text($rpmDelta);
|
|
if (abs($tempDelta) >= $tempThreshold) $reasons[] = 'TEMP ' . signed_delta_text($tempDelta, 'C');
|
|
|
|
$alertStarted = false;
|
|
|
|
if ($currentState === 'normal') {
|
|
$alertStarted = $reasons !== [];
|
|
if ($alertStarted) {
|
|
$currentState = 'alert';
|
|
save_system_notice_state(
|
|
'alert',
|
|
$tempAvg,
|
|
$rpmAvg,
|
|
$pwmAvg,
|
|
implode(', ', $reasons),
|
|
true,
|
|
$tempDelta,
|
|
$rpmDelta,
|
|
notice_process_signature($processes)
|
|
);
|
|
} else {
|
|
save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']);
|
|
}
|
|
} else {
|
|
$recovered = (abs($rpmDelta) <= $recoverRpmThreshold && abs($tempDelta) <= $recoverTempThreshold)
|
|
|| (abs($rollingRpmDelta) <= $recoverRpmThreshold && abs($rollingTempDelta) <= $recoverTempThreshold);
|
|
if ($recovered || $reasons === []) {
|
|
$currentState = 'normal';
|
|
save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']);
|
|
$tempAvg = (float)$rollingBaseline['temp'];
|
|
$rpmAvg = (float)$rollingBaseline['rpm'];
|
|
$pwmAvg = (float)$rollingBaseline['pwm'];
|
|
$rpmDelta = $rpm - $rpmAvg;
|
|
$pwmDelta = $pwm - $pwmAvg;
|
|
$tempDelta = $temp - $tempAvg;
|
|
} else {
|
|
save_system_notice_state(
|
|
'alert',
|
|
$tempAvg,
|
|
$rpmAvg,
|
|
$pwmAvg,
|
|
implode(', ', $reasons),
|
|
false,
|
|
$tempDelta,
|
|
$rpmDelta,
|
|
notice_process_signature($processes)
|
|
);
|
|
}
|
|
}
|
|
|
|
$summary = 'No system notice detected in recent samples.';
|
|
if ($alertStarted) {
|
|
$summary = 'System notice: ' . implode(', ', $reasons);
|
|
} elseif ($currentState === 'alert' && $reasons !== []) {
|
|
$summary = 'System notice active: ' . implode(', ', $reasons);
|
|
}
|
|
|
|
$alertActive = $currentState === 'alert';
|
|
|
|
return [
|
|
'active' => $alertActive,
|
|
'alert_started' => $alertStarted,
|
|
'summary' => $summary,
|
|
'rpm_delta' => round($rpmDelta, 1),
|
|
'pwm_delta' => round($pwmDelta, 1),
|
|
'temp_delta' => round($tempDelta, 1),
|
|
'rpm_avg' => round($rpmAvg, 1),
|
|
'pwm_avg' => round($pwmAvg, 1),
|
|
'temp_avg' => round($tempAvg, 1),
|
|
'notice_state' => $currentState,
|
|
'baseline_source' => $currentState === 'alert' ? 'frozen_alert' : 'rolling',
|
|
'baseline_samples' => (int)$rollingBaseline['samples'],
|
|
];
|
|
}
|
|
|
|
function system_notice_state(): array
|
|
{
|
|
$stmt = db()->query("
|
|
SELECT
|
|
state,
|
|
baseline_temp,
|
|
baseline_rpm,
|
|
baseline_pwm,
|
|
active_reason,
|
|
active_temp_delta,
|
|
active_rpm_delta,
|
|
process_signature,
|
|
alert_started_at,
|
|
last_alert_at,
|
|
updated_at
|
|
FROM system_notice_state
|
|
WHERE id = 1
|
|
LIMIT 1
|
|
");
|
|
|
|
return $stmt->fetch() ?: [];
|
|
}
|
|
|
|
function save_system_notice_state(
|
|
string $state,
|
|
float $baselineTemp,
|
|
float $baselineRpm,
|
|
float $baselinePwm,
|
|
?string $reason = null,
|
|
bool $alertStarted = false,
|
|
float $activeTempDelta = 0.0,
|
|
float $activeRpmDelta = 0.0,
|
|
?string $processSignature = null
|
|
): void {
|
|
$stmt = db()->prepare("
|
|
INSERT INTO system_notice_state (
|
|
id,
|
|
state,
|
|
baseline_temp,
|
|
baseline_rpm,
|
|
baseline_pwm,
|
|
active_reason,
|
|
active_temp_delta,
|
|
active_rpm_delta,
|
|
process_signature,
|
|
alert_started_at,
|
|
last_alert_at
|
|
) VALUES (
|
|
1,
|
|
:state,
|
|
:baseline_temp,
|
|
:baseline_rpm,
|
|
:baseline_pwm,
|
|
:active_reason,
|
|
:active_temp_delta,
|
|
:active_rpm_delta,
|
|
:process_signature,
|
|
IF(:alert_started = 1, CURRENT_TIMESTAMP, NULL),
|
|
IF(:alert_started_last = 1, CURRENT_TIMESTAMP, NULL)
|
|
)
|
|
ON DUPLICATE KEY UPDATE
|
|
state = VALUES(state),
|
|
baseline_temp = VALUES(baseline_temp),
|
|
baseline_rpm = VALUES(baseline_rpm),
|
|
baseline_pwm = VALUES(baseline_pwm),
|
|
active_reason = VALUES(active_reason),
|
|
active_temp_delta = VALUES(active_temp_delta),
|
|
active_rpm_delta = VALUES(active_rpm_delta),
|
|
process_signature = VALUES(process_signature),
|
|
alert_started_at = IF(:alert_started_update = 1, CURRENT_TIMESTAMP, alert_started_at),
|
|
last_alert_at = IF(:alert_started_last_update = 1, CURRENT_TIMESTAMP, last_alert_at),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
");
|
|
|
|
$stmt->execute([
|
|
':state' => in_array($state, ['normal', 'alert'], true) ? $state : 'normal',
|
|
':baseline_temp' => round($baselineTemp, 2),
|
|
':baseline_rpm' => round($baselineRpm, 2),
|
|
':baseline_pwm' => round($baselinePwm, 2),
|
|
':active_reason' => $reason,
|
|
':active_temp_delta' => round($activeTempDelta, 2),
|
|
':active_rpm_delta' => round($activeRpmDelta, 2),
|
|
':process_signature' => $processSignature,
|
|
':alert_started' => $alertStarted ? 1 : 0,
|
|
':alert_started_last' => $alertStarted ? 1 : 0,
|
|
':alert_started_update' => $alertStarted ? 1 : 0,
|
|
':alert_started_last_update' => $alertStarted ? 1 : 0,
|
|
]);
|
|
}
|
|
|
|
function add_fan_spike_log(array $spike, array $fan, array $system, array $processes): int
|
|
{
|
|
$summary = (string)($spike['summary'] ?? '');
|
|
$rpmDelta = round((float)($spike['rpm_delta'] ?? 0), 1);
|
|
$pwmDelta = round((float)($spike['pwm_delta'] ?? 0), 1);
|
|
$tempDelta = round((float)($spike['temp_delta'] ?? 0), 1);
|
|
$interval = max(60, (int)setting_value('notice.log_interval_seconds'));
|
|
$spikeKey = 'fan:' . (int)floor(time() / $interval);
|
|
$loggedProcesses = ((bool)setting_value('notice.downward_only_process_hide') && notice_downward_only($tempDelta, $rpmDelta))
|
|
? ['cpu' => [], 'memory' => []]
|
|
: $processes;
|
|
|
|
$stmt = db()->prepare("
|
|
INSERT IGNORE INTO fan_spike_logs (
|
|
spike_key,
|
|
summary,
|
|
rpm_delta,
|
|
pwm_delta,
|
|
temp_delta,
|
|
current_rpm,
|
|
current_pwm,
|
|
current_temp,
|
|
cpu_process,
|
|
memory_process
|
|
) VALUES (
|
|
:spike_key,
|
|
:summary,
|
|
:rpm_delta,
|
|
:pwm_delta,
|
|
:temp_delta,
|
|
:current_rpm,
|
|
:current_pwm,
|
|
:current_temp,
|
|
:cpu_process,
|
|
:memory_process
|
|
)
|
|
");
|
|
|
|
$stmt->execute([
|
|
':spike_key' => $spikeKey,
|
|
':summary' => $summary !== '' ? $summary : null,
|
|
':rpm_delta' => $rpmDelta,
|
|
':pwm_delta' => $pwmDelta,
|
|
':temp_delta' => $tempDelta,
|
|
':current_rpm' => $fan['rpm'] ?? null,
|
|
':current_pwm' => $fan['pwm'] ?? null,
|
|
':current_temp' => $system['temp_c'] ?? null,
|
|
':cpu_process' => json_encode($loggedProcesses['cpu'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
':memory_process' => json_encode($loggedProcesses['memory'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
]);
|
|
|
|
if ($stmt->rowCount() <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return (int)db()->lastInsertId();
|
|
}
|
|
|
|
function signed_delta_text(float $value, string $suffix = ''): string
|
|
{
|
|
$sign = $value >= 0 ? '+' : '';
|
|
|
|
return $sign . number_format($value, 1) . $suffix;
|
|
}
|
|
|
|
function signed_delta_compact(float $value, int $decimals, string $suffix = ''): string
|
|
{
|
|
$sign = $value >= 0 ? '+' : '';
|
|
|
|
return $sign . number_format($value, $decimals) . $suffix;
|
|
}
|
|
|
|
function display_process_name(array $row): string
|
|
{
|
|
$service = (string)($row['service'] ?? '');
|
|
if ($service !== '' && $service !== 'N/A') {
|
|
return $service;
|
|
}
|
|
|
|
$command = (string)($row['command'] ?? $row['name'] ?? '');
|
|
|
|
if (
|
|
str_contains($command, '/codex ')
|
|
|| str_contains($command, '/codex')
|
|
|| str_contains($command, 'codex app-server')
|
|
) {
|
|
return 'codex';
|
|
}
|
|
|
|
if (str_contains($command, '.vscode-server')) {
|
|
return 'vscode-server';
|
|
}
|
|
|
|
if (str_contains($command, 'python3 -m homeassistant')) {
|
|
return 'homeassistant';
|
|
}
|
|
|
|
if (str_contains($command, 'firefox')) {
|
|
return 'firefox';
|
|
}
|
|
|
|
if ($command === '') {
|
|
$command = (string)($row['name'] ?? '');
|
|
}
|
|
|
|
return $command !== '' ? $command : 'N/A';
|
|
}
|
|
|
|
function process_identity(array $row): string
|
|
{
|
|
return implode('|', [
|
|
(string)($row['pid'] ?? ''),
|
|
(string)($row['service'] ?? ''),
|
|
(string)($row['command'] ?? $row['name'] ?? ''),
|
|
]);
|
|
}
|
|
|
|
function expected_process_text(array $processes): string
|
|
{
|
|
$cpu = null;
|
|
foreach (($processes['cpu'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$command = (string)($row['command'] ?? $row['name'] ?? '');
|
|
$service = (string)($row['service'] ?? '');
|
|
if (
|
|
str_contains($command, '/bin/ps')
|
|
|| str_contains($command, 'api.php')
|
|
|| str_contains($command, 'php-fpm')
|
|
|| $service === 'fanpanel-apply.service'
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$cpu = $row;
|
|
break;
|
|
}
|
|
|
|
if ($cpu !== null && (float)($cpu['cpu_percent'] ?? 0) >= 1.0) {
|
|
return 'CPU ' . display_process_name($cpu);
|
|
}
|
|
|
|
$mem = null;
|
|
foreach (($processes['memory'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$mem = $row;
|
|
break;
|
|
}
|
|
|
|
if ($mem !== null) {
|
|
return 'MEM ' . display_process_name($mem);
|
|
}
|
|
|
|
return 'CPU/MEM 원인 후보 없음';
|
|
}
|
|
|
|
function expected_process_detail_text(array $processes): string
|
|
{
|
|
$cpu = null;
|
|
foreach (($processes['cpu'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$command = (string)($row['command'] ?? $row['name'] ?? '');
|
|
$service = (string)($row['service'] ?? '');
|
|
if (
|
|
str_contains($command, '/bin/ps')
|
|
|| str_contains($command, 'api.php')
|
|
|| str_contains($command, 'php-fpm')
|
|
|| $service === 'fanpanel-apply.service'
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$cpu = $row;
|
|
break;
|
|
}
|
|
|
|
$mem = null;
|
|
foreach (($processes['memory'] ?? []) as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$mem = $row;
|
|
break;
|
|
}
|
|
|
|
if ($cpu !== null && $mem !== null && process_identity($mem) === process_identity($cpu)) {
|
|
return display_process_name($cpu);
|
|
}
|
|
|
|
$parts = [];
|
|
|
|
if ($cpu !== null) {
|
|
$parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), display_process_name($cpu));
|
|
}
|
|
|
|
if ($mem !== null) {
|
|
$parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), display_process_name($mem));
|
|
}
|
|
|
|
return $parts === [] ? '원인 후보 없음' : implode(' / ', $parts);
|
|
}
|
|
|
|
function delta_state_text(float $value): string
|
|
{
|
|
return $value >= 0 ? '높음' : '낮음';
|
|
}
|
|
|
|
function notice_downward_only(float $tempDelta, float $rpmDelta): bool
|
|
{
|
|
$tempTriggered = abs($tempDelta) >= (float)setting_value('notice.temp_delta_threshold');
|
|
$rpmTriggered = abs($rpmDelta) >= (float)setting_value('notice.rpm_delta_threshold');
|
|
|
|
if (!$tempTriggered && !$rpmTriggered) {
|
|
return false;
|
|
}
|
|
|
|
if ($tempTriggered && $tempDelta >= 0) {
|
|
return false;
|
|
}
|
|
|
|
if ($rpmTriggered && $rpmDelta >= 0) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function send_fan_spike_notify(array $spike, array $fan, array $system, array $processes): array
|
|
{
|
|
$tempDelta = (float)($spike['temp_delta'] ?? 0);
|
|
$rpmDelta = (float)($spike['rpm_delta'] ?? 0);
|
|
$tempAvg = (float)($spike['temp_avg'] ?? 0);
|
|
$rpmAvg = (float)($spike['rpm_avg'] ?? 0);
|
|
$pwmAvg = (float)($spike['pwm_avg'] ?? 0);
|
|
$reasons = [];
|
|
|
|
if (abs($tempDelta) >= (float)setting_value('notice.temp_delta_threshold')) {
|
|
$reasons[] = '온도 평균보다 ' . ($tempDelta >= 0 ? '상승' : '하강');
|
|
}
|
|
|
|
if (abs($rpmDelta) >= (float)setting_value('notice.rpm_delta_threshold')) {
|
|
$reasons[] = '팬RPM 평균보다 ' . ($rpmDelta >= 0 ? '상승' : '하강');
|
|
}
|
|
|
|
if ($reasons === []) {
|
|
$reasons[] = '순간 변화';
|
|
}
|
|
|
|
$body = '기록된 이유: '
|
|
. implode(', ', $reasons)
|
|
. "\n평균: "
|
|
. number_format($tempAvg, 1)
|
|
. '°C / '
|
|
. number_format($rpmAvg, 0)
|
|
. ' RPM / PWM '
|
|
. number_format($pwmAvg, 0)
|
|
. "\n현재: "
|
|
. number_format((float)($system['temp_c'] ?? 0), 1)
|
|
. '°C / '
|
|
. number_format((float)($fan['rpm'] ?? 0), 0)
|
|
. ' RPM / PWM '
|
|
. number_format((float)($fan['pwm'] ?? 0), 0)
|
|
. (((bool)setting_value('notice.downward_only_process_hide') && notice_downward_only($tempDelta, $rpmDelta)) ? '' : "\n원인 후보: " . expected_process_detail_text($processes));
|
|
|
|
return send_ha_notify([
|
|
'title' => '시스템 유의사항',
|
|
'message' => $body,
|
|
'level' => 'warning',
|
|
'url' => '/',
|
|
'tag' => 'control-system-notice',
|
|
'data' => [
|
|
'channel' => 'control_system',
|
|
'notification_icon' => 'mdi:fan-alert',
|
|
'color' => '#f59e0b',
|
|
'sticky' => 'false',
|
|
'renotify' => 'true',
|
|
'summary' => $spike['summary'] ?? '',
|
|
'rpm_delta' => $spike['rpm_delta'] ?? 0,
|
|
'pwm_delta' => $spike['pwm_delta'] ?? 0,
|
|
'temp_delta' => $spike['temp_delta'] ?? 0,
|
|
'expected_process' => expected_process_text($processes),
|
|
],
|
|
]);
|
|
}
|
|
|
|
function latest_system_notice_notify_epoch(): int
|
|
{
|
|
return latest_ha_notify_epoch_by_tag('control-system-notice');
|
|
}
|
|
|
|
function fan_spike_history(int $limit = 100): array
|
|
{
|
|
$limit = max(1, min(500, $limit));
|
|
|
|
$stmt = db()->query("
|
|
SELECT
|
|
id,
|
|
created_at,
|
|
summary,
|
|
rpm_delta,
|
|
pwm_delta,
|
|
temp_delta,
|
|
current_rpm,
|
|
current_pwm,
|
|
current_temp,
|
|
cpu_process,
|
|
memory_process
|
|
FROM fan_spike_logs
|
|
WHERE id IN (
|
|
SELECT MAX(id)
|
|
FROM fan_spike_logs
|
|
GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d %H:%i')
|
|
)
|
|
ORDER BY id DESC
|
|
LIMIT {$limit}
|
|
");
|
|
|
|
$rows = $stmt->fetchAll();
|
|
|
|
foreach ($rows as &$row) {
|
|
$row['cpu_process'] = json_decode((string)($row['cpu_process'] ?? '[]'), true) ?: [];
|
|
$row['memory_process'] = json_decode((string)($row['memory_process'] ?? '[]'), true) ?: [];
|
|
}
|
|
|
|
unset($row);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
function dnsmasq_leases(): array
|
|
{
|
|
$map = [];
|
|
|
|
foreach (@file('/var/lib/misc/dnsmasq.leases', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
|
$p = preg_split('/\s+/', trim($line));
|
|
|
|
if (count($p) >= 4) {
|
|
$mac = strtolower($p[1]);
|
|
|
|
$map[$mac] = [
|
|
'expire_ts' => (int)$p[0],
|
|
'mac' => $mac,
|
|
'ip' => $p[2],
|
|
'hostname' => $p[3] === '*' ? 'N/A' : $p[3],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
function wifi_client_aliases(): array
|
|
{
|
|
try {
|
|
$rows = db()->query("SELECT mac, hostname FROM wifi_client_aliases")->fetchAll();
|
|
} catch (Throwable) {
|
|
return [];
|
|
}
|
|
|
|
$aliases = [];
|
|
foreach ($rows as $row) {
|
|
$mac = strtolower((string)($row['mac'] ?? ''));
|
|
$hostname = trim((string)($row['hostname'] ?? ''));
|
|
if (wifi_valid_mac($mac) && $hostname !== '') {
|
|
$aliases[$mac] = $hostname;
|
|
}
|
|
}
|
|
|
|
return $aliases;
|
|
}
|
|
|
|
function save_wifi_client_alias(string $mac, string $hostname): array
|
|
{
|
|
$mac = strtolower(trim($mac));
|
|
$hostname = trim(preg_replace('/\s+/', ' ', $hostname) ?? '');
|
|
|
|
if (!wifi_valid_mac($mac)) {
|
|
throw new InvalidArgumentException('bad_mac');
|
|
}
|
|
if ($hostname === '' || mb_strlen($hostname) > 80) {
|
|
throw new InvalidArgumentException('bad_hostname');
|
|
}
|
|
|
|
$stmt = db()->prepare("
|
|
INSERT INTO wifi_client_aliases (mac, hostname)
|
|
VALUES (:mac, :hostname)
|
|
ON DUPLICATE KEY UPDATE
|
|
hostname = VALUES(hostname),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
");
|
|
$stmt->execute([
|
|
':mac' => $mac,
|
|
':hostname' => $hostname,
|
|
]);
|
|
|
|
return [
|
|
'mac' => $mac,
|
|
'hostname' => $hostname,
|
|
];
|
|
}
|
|
|
|
function iw_station_dump(string $iface): string
|
|
{
|
|
if (!preg_match('/^[A-Za-z0-9_.:-]+$/', $iface)) {
|
|
return '';
|
|
}
|
|
|
|
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 = [];
|
|
$cur = null;
|
|
|
|
foreach (explode("\n", $text) as $line) {
|
|
$t = trim($line);
|
|
|
|
if (preg_match('/^Station\s+([0-9a-f:]+)/i', $t, $m)) {
|
|
if ($cur !== null) {
|
|
$rows[] = $cur;
|
|
}
|
|
|
|
$mac = strtolower($m[1]);
|
|
$lease = $leases[$mac] ?? [];
|
|
$leaseHostname = (string)($lease['hostname'] ?? 'N/A');
|
|
$aliasHostname = $aliases[$mac] ?? null;
|
|
$hostname = $aliasHostname ?: $leaseHostname;
|
|
$hostnameSource = $aliasHostname ? 'alias' : ($leaseHostname === 'N/A' ? 'generated' : 'lease');
|
|
$cur = [
|
|
'band' => $band,
|
|
'iface' => $iface,
|
|
'mac' => $mac,
|
|
'ip' => $lease['ip'] ?? 'N/A',
|
|
'hostname' => $hostname === 'N/A' ? '기기-' . strtoupper(substr(str_replace(':', '', $mac), -6)) : $hostname,
|
|
'hostname_source' => $hostnameSource,
|
|
'name' => $hostname === 'N/A' ? $mac : $hostname,
|
|
'signal' => 'N/A',
|
|
'tx_bitrate' => 'N/A',
|
|
'rx_bitrate' => 'N/A',
|
|
'signal_source' => 'missing',
|
|
'tx_bitrate_source' => 'missing',
|
|
'rx_bitrate_source' => 'missing',
|
|
'connected_time' => 'N/A',
|
|
'inactive_time' => 'N/A',
|
|
'rx_bytes' => 0,
|
|
'tx_bytes' => 0,
|
|
'rx_packets' => 0,
|
|
'tx_packets' => 0,
|
|
'tx_failed' => 0,
|
|
];
|
|
continue;
|
|
}
|
|
|
|
if ($cur === null) {
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^signal:\s+(.+)/', $t, $m)) {
|
|
$cur['signal'] = $m[1];
|
|
$cur['signal_source'] = 'iw';
|
|
} elseif (preg_match('/^tx bitrate:\s+(.+)/', $t, $m)) {
|
|
$cur['tx_bitrate'] = $m[1];
|
|
$cur['tx_bitrate_source'] = 'iw';
|
|
} elseif (preg_match('/^rx bitrate:\s+(.+)/', $t, $m)) {
|
|
$cur['rx_bitrate'] = $m[1];
|
|
$cur['rx_bitrate_source'] = 'iw';
|
|
}
|
|
elseif (preg_match('/^connected time:\s+(.+)/', $t, $m)) $cur['connected_time'] = $m[1];
|
|
elseif (preg_match('/^inactive time:\s+(.+)/', $t, $m)) $cur['inactive_time'] = $m[1];
|
|
elseif (preg_match('/^rx bytes:\s+(\d+)/', $t, $m)) $cur['rx_bytes'] = (int)$m[1];
|
|
elseif (preg_match('/^tx bytes:\s+(\d+)/', $t, $m)) $cur['tx_bytes'] = (int)$m[1];
|
|
elseif (preg_match('/^rx packets:\s+(\d+)/', $t, $m)) $cur['rx_packets'] = (int)$m[1];
|
|
elseif (preg_match('/^tx packets:\s+(\d+)/', $t, $m)) $cur['tx_packets'] = (int)$m[1];
|
|
elseif (preg_match('/^tx failed:\s+(\d+)/', $t, $m)) $cur['tx_failed'] = (int)$m[1];
|
|
}
|
|
|
|
if ($cur !== null) {
|
|
$rows[] = $cur;
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
function wifi_time_ms(string $value): int
|
|
{
|
|
if (preg_match('/(\d+)/', $value, $m)) {
|
|
return (int)$m[1];
|
|
}
|
|
|
|
return PHP_INT_MAX;
|
|
}
|
|
|
|
function wifi_unknown(mixed $value): bool
|
|
{
|
|
$text = strtoupper(trim((string)$value));
|
|
return $text === '' || $text === 'N/A' || $text === '-';
|
|
}
|
|
|
|
function wifi_bitrate_mbps(mixed $value): ?float
|
|
{
|
|
if (preg_match('/([0-9]+(?:\.[0-9]+)?)/', (string)$value, $m)) {
|
|
return (float)$m[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function wifi_signal_dbm(string $value): int
|
|
{
|
|
if (preg_match('/-?\d+/', $value, $m)) {
|
|
return (int)$m[0];
|
|
}
|
|
|
|
return -999;
|
|
}
|
|
|
|
function estimate_wifi_rate_from_signal(string $band, int $dbm): float
|
|
{
|
|
if ($band === '5G') {
|
|
if ($dbm >= -50) return 1300.0;
|
|
if ($dbm >= -58) return 866.7;
|
|
if ($dbm >= -66) return 585.0;
|
|
if ($dbm >= -73) return 390.0;
|
|
if ($dbm >= -80) return 173.3;
|
|
return 54.0;
|
|
}
|
|
|
|
if ($dbm >= -50) return 300.0;
|
|
if ($dbm >= -58) return 144.4;
|
|
if ($dbm >= -66) return 72.2;
|
|
if ($dbm >= -73) return 39.0;
|
|
if ($dbm >= -80) return 19.5;
|
|
return 6.5;
|
|
}
|
|
|
|
function estimate_wifi_signal_from_rate(string $band, float $mbps): int
|
|
{
|
|
if ($band === '5G') {
|
|
if ($mbps >= 900) return -48;
|
|
if ($mbps >= 600) return -55;
|
|
if ($mbps >= 350) return -63;
|
|
if ($mbps >= 150) return -71;
|
|
if ($mbps >= 54) return -79;
|
|
return -84;
|
|
}
|
|
|
|
if ($mbps >= 250) return -48;
|
|
if ($mbps >= 120) return -56;
|
|
if ($mbps >= 65) return -64;
|
|
if ($mbps >= 30) return -72;
|
|
if ($mbps >= 10) return -80;
|
|
return -86;
|
|
}
|
|
|
|
function format_estimated_mbps(float $mbps): string
|
|
{
|
|
$value = abs($mbps - round($mbps)) < 0.05
|
|
? (string)(int)round($mbps)
|
|
: number_format($mbps, 1);
|
|
|
|
return $value . ' MBit/s';
|
|
}
|
|
|
|
function apply_wifi_estimates(array $clients): array
|
|
{
|
|
foreach ($clients as &$client) {
|
|
$band = (string)($client['band'] ?? '2.4G');
|
|
$signalDbm = wifi_signal_dbm((string)($client['signal'] ?? ''));
|
|
$txMbps = wifi_bitrate_mbps($client['tx_bitrate'] ?? null);
|
|
$rxMbps = wifi_bitrate_mbps($client['rx_bitrate'] ?? null);
|
|
|
|
if (wifi_unknown($client['signal'] ?? null)) {
|
|
$sourceRate = max($txMbps ?? 0.0, $rxMbps ?? 0.0);
|
|
$signalDbm = $sourceRate > 0
|
|
? estimate_wifi_signal_from_rate($band, $sourceRate)
|
|
: ($band === '5G' ? -67 : -70);
|
|
$client['signal'] = $signalDbm . ' dBm';
|
|
$client['signal_source'] = $sourceRate > 0 ? 'estimated_from_rate' : 'estimated_default';
|
|
}
|
|
|
|
if (wifi_unknown($client['tx_bitrate'] ?? null)) {
|
|
$base = $rxMbps ?? estimate_wifi_rate_from_signal($band, $signalDbm);
|
|
$client['tx_bitrate'] = format_estimated_mbps($base);
|
|
$client['tx_bitrate_source'] = $rxMbps === null ? 'estimated_from_signal' : 'estimated_from_rx';
|
|
}
|
|
|
|
if (wifi_unknown($client['rx_bitrate'] ?? null)) {
|
|
$base = $txMbps ?? estimate_wifi_rate_from_signal($band, $signalDbm);
|
|
$client['rx_bitrate'] = format_estimated_mbps($base);
|
|
$client['rx_bitrate_source'] = $txMbps === null ? 'estimated_from_signal' : 'estimated_from_tx';
|
|
}
|
|
}
|
|
unset($client);
|
|
|
|
return $clients;
|
|
}
|
|
|
|
function dedupe_wifi_clients(array $clients): array
|
|
{
|
|
$deduped = [];
|
|
|
|
foreach ($clients as $client) {
|
|
$mac = strtolower((string)($client['mac'] ?? ''));
|
|
$key = $mac !== '' && $mac !== 'n/a'
|
|
? 'mac:' . $mac
|
|
: 'row:' . ($client['band'] ?? '') . ':' . ($client['ip'] ?? '') . ':' . ($client['hostname'] ?? '');
|
|
|
|
if (!isset($deduped[$key])) {
|
|
$deduped[$key] = $client;
|
|
continue;
|
|
}
|
|
|
|
$currentInactive = wifi_time_ms((string)($deduped[$key]['inactive_time'] ?? ''));
|
|
$nextInactive = wifi_time_ms((string)($client['inactive_time'] ?? ''));
|
|
$currentSignal = wifi_signal_dbm((string)($deduped[$key]['signal'] ?? ''));
|
|
$nextSignal = wifi_signal_dbm((string)($client['signal'] ?? ''));
|
|
|
|
if ($nextInactive < $currentInactive || ($nextInactive === $currentInactive && $nextSignal > $currentSignal)) {
|
|
$deduped[$key] = $client;
|
|
}
|
|
}
|
|
|
|
return array_values($deduped);
|
|
}
|
|
|
|
function wifi_connected_time_missing(mixed $value): bool
|
|
{
|
|
$text = strtoupper(trim((string)$value));
|
|
return $text === '' || $text === 'N/A' || $text === '-';
|
|
}
|
|
|
|
function wifi_valid_mac(string $mac): bool
|
|
{
|
|
return preg_match('/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/', strtolower($mac)) === 1;
|
|
}
|
|
|
|
function prune_observed_wifi_sessions(PDO $pdo, array $visibleMacs): void
|
|
{
|
|
$visibleMacs = array_values(array_unique(array_filter(
|
|
array_map(static fn($mac) => strtolower((string)$mac), $visibleMacs),
|
|
static fn($mac) => wifi_valid_mac($mac)
|
|
)));
|
|
|
|
if ($visibleMacs === []) {
|
|
$pdo->exec("DELETE FROM wifi_observed_sessions WHERE band = '5G'");
|
|
return;
|
|
}
|
|
|
|
$placeholders = implode(',', array_fill(0, count($visibleMacs), '?'));
|
|
$stmt = $pdo->prepare("
|
|
DELETE FROM wifi_observed_sessions
|
|
WHERE band = '5G'
|
|
AND mac NOT IN ($placeholders)
|
|
");
|
|
$stmt->execute($visibleMacs);
|
|
}
|
|
|
|
function observed_wifi_duration_seconds(array $clients): array
|
|
{
|
|
$targets = [];
|
|
$visible5gMacs = [];
|
|
|
|
foreach ($clients as $client) {
|
|
$band = (string)($client['band'] ?? '');
|
|
$mac = strtolower((string)($client['mac'] ?? ''));
|
|
|
|
if ($band !== '5G' || !wifi_valid_mac($mac)) {
|
|
continue;
|
|
}
|
|
|
|
$visible5gMacs[] = $mac;
|
|
|
|
if (!wifi_connected_time_missing($client['connected_time'] ?? null)) {
|
|
continue;
|
|
}
|
|
|
|
$targets[$mac] = [
|
|
'mac' => $mac,
|
|
'band' => $band,
|
|
'iface' => (string)($client['iface'] ?? ''),
|
|
'ip' => (string)($client['ip'] ?? ''),
|
|
'hostname' => (string)($client['hostname'] ?? ''),
|
|
];
|
|
}
|
|
|
|
try {
|
|
$pdo = db();
|
|
$pdo->beginTransaction();
|
|
prune_observed_wifi_sessions($pdo, $visible5gMacs);
|
|
|
|
if ($targets === []) {
|
|
$pdo->commit();
|
|
return [];
|
|
}
|
|
|
|
$upsert = $pdo->prepare("
|
|
INSERT INTO wifi_observed_sessions
|
|
(mac, band, iface, first_seen_at, last_seen_at, last_ip, hostname)
|
|
VALUES
|
|
(:mac, :band, :iface, NOW(3), NOW(3), :last_ip, :hostname)
|
|
ON DUPLICATE KEY UPDATE
|
|
band = VALUES(band),
|
|
iface = VALUES(iface),
|
|
last_seen_at = NOW(3),
|
|
last_ip = VALUES(last_ip),
|
|
hostname = VALUES(hostname)
|
|
");
|
|
|
|
foreach ($targets as $target) {
|
|
$upsert->execute([
|
|
':mac' => $target['mac'],
|
|
':band' => $target['band'],
|
|
':iface' => $target['iface'],
|
|
':last_ip' => $target['ip'] === 'N/A' ? null : $target['ip'],
|
|
':hostname' => $target['hostname'] === 'N/A' ? null : $target['hostname'],
|
|
]);
|
|
}
|
|
|
|
$placeholders = implode(',', array_fill(0, count($targets), '?'));
|
|
$stmt = $pdo->prepare("
|
|
SELECT
|
|
mac,
|
|
GREATEST(0, TIMESTAMPDIFF(SECOND, first_seen_at, NOW(3))) AS observed_seconds
|
|
FROM wifi_observed_sessions
|
|
WHERE mac IN ($placeholders)
|
|
");
|
|
$stmt->execute(array_keys($targets));
|
|
$rows = $stmt->fetchAll();
|
|
|
|
$pdo->commit();
|
|
|
|
$seconds = [];
|
|
foreach ($rows as $row) {
|
|
$seconds[strtolower((string)$row['mac'])] = (int)$row['observed_seconds'];
|
|
}
|
|
|
|
return $seconds;
|
|
} catch (Throwable) {
|
|
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function apply_observed_wifi_connected_time(array $clients): array
|
|
{
|
|
$observedSeconds = observed_wifi_duration_seconds($clients);
|
|
|
|
if ($observedSeconds === []) {
|
|
return $clients;
|
|
}
|
|
|
|
foreach ($clients as &$client) {
|
|
$mac = strtolower((string)($client['mac'] ?? ''));
|
|
|
|
if (($client['band'] ?? '') !== '5G'
|
|
|| !wifi_connected_time_missing($client['connected_time'] ?? null)
|
|
|| !array_key_exists($mac, $observedSeconds)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$client['connected_time'] = (string)$observedSeconds[$mac];
|
|
$client['connected_time_source'] = 'observed';
|
|
}
|
|
unset($client);
|
|
|
|
return $clients;
|
|
}
|
|
|
|
function wifi_data(): array
|
|
{
|
|
$leases = dnsmasq_leases();
|
|
$aliases = wifi_client_aliases();
|
|
$clients = [];
|
|
|
|
foreach (['wlan0' => '2.4G', 'wlan1' => '5G'] as $iface => $band) {
|
|
$clients = array_merge(
|
|
$clients,
|
|
parse_live_wifi_rows($iface, $band, iw_station_dump($iface), $leases, $aliases)
|
|
);
|
|
}
|
|
|
|
$clients = dedupe_wifi_clients($clients);
|
|
$clients = apply_wifi_estimates($clients);
|
|
$clients = apply_observed_wifi_connected_time($clients);
|
|
|
|
return [
|
|
'clients' => $clients,
|
|
'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(),
|
|
];
|
|
}
|
|
|
|
function action_rows(int $limit = 80): array
|
|
{
|
|
$limit = max(1, min(300, $limit));
|
|
|
|
$stmt = db()->query("
|
|
SELECT
|
|
created_at AS ts,
|
|
action_type AS action,
|
|
CONCAT(
|
|
action_type,
|
|
IF(pwm_mode IS NULL, '', CONCAT(' ', pwm_mode)),
|
|
IF(pwm_value IS NULL, '', CONCAT(' pwm ', pwm_value)),
|
|
IF(note IS NULL OR note = '', '', CONCAT(' / ', note))
|
|
) AS message,
|
|
success,
|
|
actor_ip
|
|
FROM fan_actions
|
|
ORDER BY id DESC
|
|
LIMIT {$limit}
|
|
");
|
|
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
function custom_systemd_service_names(): array
|
|
{
|
|
$paths = glob('/etc/systemd/system/*.service') ?: [];
|
|
$names = [];
|
|
|
|
foreach ($paths as $path) {
|
|
if (is_link($path)) {
|
|
continue;
|
|
}
|
|
|
|
$name = basename($path);
|
|
if (preg_match('/^[A-Za-z0-9_.@:-]+\.service$/', $name)) {
|
|
$names[] = $name;
|
|
}
|
|
}
|
|
|
|
sort($names, SORT_NATURAL | SORT_FLAG_CASE);
|
|
return $names;
|
|
}
|
|
|
|
function parse_systemctl_show_records(string $out): array
|
|
{
|
|
$records = [];
|
|
$current = [];
|
|
|
|
foreach (preg_split('/\R/', trim($out)) as $line) {
|
|
if ($line === '') {
|
|
if (!empty($current['Id'])) {
|
|
$records[$current['Id']] = $current;
|
|
}
|
|
$current = [];
|
|
continue;
|
|
}
|
|
|
|
$parts = explode('=', $line, 2);
|
|
if (count($parts) === 2) {
|
|
$current[$parts[0]] = $parts[1];
|
|
}
|
|
}
|
|
|
|
if (!empty($current['Id'])) {
|
|
$records[$current['Id']] = $current;
|
|
}
|
|
|
|
return $records;
|
|
}
|
|
|
|
function systemd_service_logs(string $unit, int $limit = 12): array
|
|
{
|
|
$limit = max(1, min(50, $limit));
|
|
$cmd = [
|
|
'/usr/bin/journalctl',
|
|
'-u',
|
|
$unit,
|
|
'-n',
|
|
(string)$limit,
|
|
'--no-pager',
|
|
'--output=short-iso',
|
|
];
|
|
|
|
$result = sh($cmd, false, 4);
|
|
if ((int)$result['code'] !== 0) {
|
|
$result = sh($cmd, true, 4);
|
|
}
|
|
|
|
$lines = array_values(array_filter(
|
|
preg_split('/\R/', trim((string)$result['out'])),
|
|
static fn($line) => trim((string)$line) !== ''
|
|
));
|
|
|
|
return [
|
|
'ok' => (int)$result['code'] === 0,
|
|
'lines' => $lines,
|
|
'error' => (int)$result['code'] === 0 ? '' : (string)$result['out'],
|
|
];
|
|
}
|
|
|
|
function custom_systemd_services(): array
|
|
{
|
|
$cachePath = '/tmp/control-custom-services.json';
|
|
$cacheSeconds = (int)setting_value('display.custom_service_cache_seconds');
|
|
if (is_file($cachePath) && filemtime($cachePath) >= time() - $cacheSeconds) {
|
|
$cached = json_decode((string)@file_get_contents($cachePath), true);
|
|
if (is_array($cached)) {
|
|
return $cached;
|
|
}
|
|
}
|
|
|
|
$names = custom_systemd_service_names();
|
|
if (!$names) {
|
|
return [];
|
|
}
|
|
|
|
$show = sh(array_merge([
|
|
'/usr/bin/systemctl',
|
|
'show',
|
|
], $names, [
|
|
'--no-pager',
|
|
'-p',
|
|
'Id',
|
|
'-p',
|
|
'LoadState',
|
|
'-p',
|
|
'ActiveState',
|
|
'-p',
|
|
'SubState',
|
|
'-p',
|
|
'UnitFileState',
|
|
'-p',
|
|
'Description',
|
|
'-p',
|
|
'FragmentPath',
|
|
'-p',
|
|
'ActiveEnterTimestamp',
|
|
'-p',
|
|
'InactiveEnterTimestamp',
|
|
'-p',
|
|
'ExecMainPID',
|
|
'-p',
|
|
'NRestarts',
|
|
]), false, 6);
|
|
|
|
$records = parse_systemctl_show_records((string)$show['out']);
|
|
$rows = [];
|
|
|
|
foreach ($names as $name) {
|
|
$record = $records[$name] ?? [];
|
|
$logs = systemd_service_logs($name, (int)setting_value('display.custom_service_log_lines'));
|
|
|
|
$rows[] = [
|
|
'name' => $name,
|
|
'description' => (string)($record['Description'] ?? ''),
|
|
'load' => (string)($record['LoadState'] ?? ''),
|
|
'active' => (string)($record['ActiveState'] ?? 'unknown'),
|
|
'sub' => (string)($record['SubState'] ?? ''),
|
|
'enabled' => (string)($record['UnitFileState'] ?? ''),
|
|
'pid' => (int)($record['ExecMainPID'] ?? 0),
|
|
'restarts' => (int)($record['NRestarts'] ?? 0),
|
|
'active_enter' => (string)($record['ActiveEnterTimestamp'] ?? ''),
|
|
'inactive_enter' => (string)($record['InactiveEnterTimestamp'] ?? ''),
|
|
'fragment' => (string)($record['FragmentPath'] ?? ''),
|
|
'logs' => $logs['lines'],
|
|
'logs_ok' => $logs['ok'],
|
|
'logs_error' => $logs['ok'] ? '' : $logs['error'],
|
|
];
|
|
}
|
|
|
|
@file_put_contents($cachePath, json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
|
|
return $rows;
|
|
}
|
|
|
|
function collect_snapshot(bool $applyFan = true): array
|
|
{
|
|
if ($applyFan) {
|
|
$policy = apply_fan_policy();
|
|
} else {
|
|
$state = get_control_state();
|
|
$policy = [
|
|
'mode' => (string)($state['mode'] ?? 'auto'),
|
|
'target_pwm' => (int)($state['manual_pwm'] ?? 120),
|
|
'actual_pwm' => 0,
|
|
'temp_c' => 0.0,
|
|
'rpm' => 0,
|
|
'ok' => true,
|
|
'paths' => [],
|
|
'enable_value' => 'N/A',
|
|
];
|
|
}
|
|
|
|
$latest = latest_sensor();
|
|
|
|
$pwm = isset($latest['pwm_value']) ? (int)$latest['pwm_value'] : 0;
|
|
if ($latest === false || $latest === [] || !isset($latest['pwm_value'])) {
|
|
$pwm = (int)$policy['actual_pwm'];
|
|
}
|
|
|
|
$rpm = (int)($latest['fan_rpm'] ?? 0);
|
|
if ($rpm <= 0) {
|
|
$rpm = (int)$policy['rpm'];
|
|
}
|
|
|
|
$temp = (float)($latest['cpu_temp_c'] ?? 0);
|
|
if ($temp <= 0) {
|
|
$temp = (float)$policy['temp_c'];
|
|
}
|
|
|
|
$load = sys_getloadavg() ?: [0, 0, 0];
|
|
$mem = mem_info();
|
|
$disk = disk_info('/');
|
|
$os = os_info();
|
|
$throttledStatuses = throttled_statuses();
|
|
$cpuPower = $applyFan ? cpu_power_status() : [
|
|
'voltage' => isset($latest['cpu_voltage']) ? (float)$latest['cpu_voltage'] : null,
|
|
'watts' => isset($latest['cpu_watts']) ? (float)$latest['cpu_watts'] : null,
|
|
];
|
|
$battery = $applyFan ? battery_status() : [
|
|
'voltage' => isset($latest['battery_voltage']) ? (float)$latest['battery_voltage'] : null,
|
|
'percent' => isset($latest['battery_percent']) ? (float)$latest['battery_percent'] : null,
|
|
];
|
|
|
|
if ($applyFan) {
|
|
add_sensor_log([
|
|
'cpu_temp_c' => $temp,
|
|
'fan_rpm' => $rpm,
|
|
'fan_efficiency' => fan_efficiency($rpm, $temp, $cpuPower['watts'] ?? null),
|
|
'rp1_temp_c' => rp1_temp_c(),
|
|
'cpu_voltage' => $cpuPower['voltage'],
|
|
'cpu_watts' => $cpuPower['watts'],
|
|
'battery_voltage' => $battery['voltage'],
|
|
'battery_percent' => $battery['percent'],
|
|
'pwm_value' => $pwm,
|
|
'pwm_percent' => round($pwm / 255 * 100, 2),
|
|
'pwm_mode' => $policy['mode'],
|
|
'cpu_load_1' => round((float)$load[0], 2),
|
|
'cpu_load_5' => round((float)$load[1], 2),
|
|
'cpu_load_15' => round((float)$load[2], 2),
|
|
'mem_total_mb' => $mem['total_mb'],
|
|
'mem_used_mb' => $mem['used_mb'],
|
|
'mem_free_mb' => $mem['free_mb'],
|
|
'disk_total_kb' => $disk['total_kb'],
|
|
'disk_used_kb' => $disk['used_kb'],
|
|
'disk_free_kb' => $disk['free_kb'],
|
|
'uptime_seconds' => $os['uptime_seconds'],
|
|
'hostname' => $os['hostname'],
|
|
]);
|
|
}
|
|
|
|
$fan = [
|
|
'mode' => $policy['mode'],
|
|
'pwm' => $pwm,
|
|
'percent' => round($pwm / 255 * 100, 1),
|
|
'rpm' => $rpm,
|
|
'enable' => $policy['enable_value'],
|
|
'target_pwm' => $policy['target_pwm'],
|
|
'policy_ok' => $policy['ok'],
|
|
'paths' => $policy['paths'],
|
|
];
|
|
|
|
$system = array_merge($os, [
|
|
'temp_c' => $temp,
|
|
'load' => [
|
|
round((float)$load[0], 2),
|
|
round((float)$load[1], 2),
|
|
round((float)$load[2], 2),
|
|
],
|
|
'active_users' => active_user_info(),
|
|
'memory' => $mem,
|
|
'disk' => $disk,
|
|
'low_voltage' => $throttledStatuses['low_voltage'],
|
|
'throttling' => $throttledStatuses['throttling'],
|
|
]);
|
|
|
|
$history = add_battery_remaining_history(sensor_history((int)setting_value('battery.history_chart_limit')));
|
|
$batteryTrend = battery_trend_history((int)setting_value('battery.trend_hours'));
|
|
$batteryProfile = battery_profile_history((int)setting_value('battery.profile_days'));
|
|
$battery['remaining'] = battery_remaining_estimate($battery, $history, $batteryTrend, $batteryProfile);
|
|
$history = sync_current_battery_remaining_history($history, [
|
|
'time' => date('Y-m-d H:i:s'),
|
|
'temp_c' => $temp,
|
|
'fan_rpm' => $rpm,
|
|
'fan_efficiency' => fan_efficiency($rpm, $temp, $cpuPower['watts'] ?? null),
|
|
'rp1_temp_c' => rp1_temp_c(),
|
|
'cpu_voltage' => $cpuPower['voltage'],
|
|
'cpu_watts' => $cpuPower['watts'],
|
|
'battery_voltage' => $battery['voltage'],
|
|
'battery_percent' => $battery['percent'],
|
|
'fan_pwm' => $pwm,
|
|
'pwm_percent' => round($pwm / 255 * 100, 2),
|
|
'pwm_mode' => $policy['mode'],
|
|
'cpu_load_1' => round((float)$load[0], 2),
|
|
'cpu_load_5' => round((float)$load[1], 2),
|
|
'cpu_load_15' => round((float)$load[2], 2),
|
|
'disk_total_kb' => $disk['total_kb'],
|
|
'disk_used_kb' => $disk['used_kb'],
|
|
'disk_free_kb' => $disk['free_kb'],
|
|
'mem_total_mb' => $mem['total_mb'],
|
|
'mem_used_mb' => $mem['used_mb'],
|
|
'mem_percent' => $mem['total_mb'] > 0 ? round($mem['used_mb'] / $mem['total_mb'] * 100, 1) : 0,
|
|
], $battery['remaining']['seconds'] ?? null);
|
|
$history = array_slice($history, -(int)setting_value('battery.history_sync_limit'));
|
|
|
|
$processes = process_resource_data((int)setting_value('display.process_limit'));
|
|
$fanSpike = fan_spike_analysis($history, $fan, $system, $processes);
|
|
|
|
$shouldLogNotice = !empty($fanSpike['alert_started'])
|
|
|| (!empty($fanSpike['active']) && (bool)setting_value('notice.log_during_alert'));
|
|
if ($shouldLogNotice) {
|
|
$latestNoticeNotify = latest_system_notice_notify_epoch();
|
|
$spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes);
|
|
if ($spikeLogId > 0) {
|
|
$fanSpike['log_id'] = $spikeLogId;
|
|
$noticeCooldown = (int)setting_value('notify.system_notice_cooldown_seconds');
|
|
if ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= $noticeCooldown) {
|
|
$fanSpike['notify'] = send_fan_spike_notify($fanSpike, $fan, $system, $processes);
|
|
} else {
|
|
$fanSpike['notify'] = [
|
|
'sent' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 'system_notice_cooldown',
|
|
'cooldown_seconds' => $noticeCooldown,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$snapshot = [
|
|
'ok' => true,
|
|
'generated_at' => date('Y-m-d H:i:s'),
|
|
'time_ms' => (int)round(microtime(true) * 1000),
|
|
|
|
'fan' => $fan,
|
|
'system' => $system,
|
|
'battery' => $battery,
|
|
|
|
'wifi' => wifi_data(),
|
|
'bluetooth' => [
|
|
'scan' => bluetooth_scan_data(),
|
|
],
|
|
'history' => $history,
|
|
'processes' => $processes,
|
|
'custom_services' => custom_systemd_services(),
|
|
'fan_spike' => $fanSpike,
|
|
'fan_spike_history' => fan_spike_history((int)setting_value('display.fan_notice_history_limit')),
|
|
'ha_notify_history' => ha_notify_log_rows((int)setting_value('display.ha_notify_history_limit')),
|
|
'settings' => settings_payload(),
|
|
];
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
function control_api_dispatch(): void
|
|
{
|
|
$action = $_GET['action'] ?? $_POST['action'] ?? 'status';
|
|
|
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
|
require_csrf();
|
|
}
|
|
|
|
try {
|
|
if ($action === 'dmesg') {
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => dmesg_log(),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'status') {
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => collect_snapshot(false),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'settings') {
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => settings_payload(),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'settings_save') {
|
|
$raw = (string)($_POST['settings'] ?? '{}');
|
|
$settings = json_decode($raw, true);
|
|
if (!is_array($settings)) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'bad_settings',
|
|
], 400);
|
|
}
|
|
save_settings($settings);
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => collect_snapshot(false),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'settings_reset') {
|
|
reset_settings();
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => collect_snapshot(false),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'fan') {
|
|
$mode = (string)($_POST['mode'] ?? 'auto');
|
|
$pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120)));
|
|
|
|
if (!in_array($mode, ['auto', 'manual', 'off'], true)) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'bad_mode',
|
|
], 400);
|
|
}
|
|
|
|
set_control_state($mode, $pwm);
|
|
|
|
add_fan_action(
|
|
'fan_set',
|
|
$mode,
|
|
$pwm,
|
|
'web fan control',
|
|
true
|
|
);
|
|
|
|
$data = collect_snapshot(true);
|
|
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => $data,
|
|
]);
|
|
}
|
|
|
|
if ($action === 'reboot') {
|
|
$phrase = trim((string)($_POST['phrase'] ?? ''));
|
|
$password = (string)($_POST['password'] ?? '');
|
|
$expectedPhrase = (string)setting_value('security.reboot_phrase');
|
|
|
|
if (!(bool)setting_value('security.allow_reboot')) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'reboot_disabled',
|
|
'message' => '재부팅 실행이 설정에서 비활성화되어 있습니다.',
|
|
], 403);
|
|
}
|
|
|
|
if ($phrase !== $expectedPhrase) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'bad_reboot_phrase',
|
|
'message' => '재부팅 확인 단어가 올바르지 않습니다.',
|
|
], 400);
|
|
}
|
|
|
|
if (!hash_equals(APP_PASSWORD, $password)) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'bad_reboot_password',
|
|
'message' => '관리자 암호가 올바르지 않습니다.',
|
|
], 403);
|
|
}
|
|
|
|
add_fan_action(
|
|
'system_reboot',
|
|
null,
|
|
null,
|
|
'web reboot requested',
|
|
true
|
|
);
|
|
|
|
$result = sh(['/usr/sbin/reboot'], true, (int)setting_value('security.reboot_timeout_seconds'));
|
|
if ($result['code'] !== 0) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'reboot_failed',
|
|
'message' => trim($result['out']) !== '' ? trim($result['out']) : 'reboot command failed',
|
|
], 500);
|
|
}
|
|
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => [
|
|
'message' => 'reboot_requested',
|
|
],
|
|
]);
|
|
}
|
|
|
|
if ($action === 'wifi') {
|
|
$verb = (string)($_POST['verb'] ?? '');
|
|
$unit = (string)($_POST['unit'] ?? '');
|
|
$allowedUnits = [
|
|
'hostapd-24g.service',
|
|
'hostapd-5g.service',
|
|
'dnsmasq.service',
|
|
];
|
|
|
|
if (
|
|
!in_array($unit, $allowedUnits, true)
|
|
|| !in_array($verb, ['restart', 'reload'], true)
|
|
|| ($verb === 'restart' && !(bool)setting_value('security.allow_wifi_restart'))
|
|
|| ($verb === 'reload' && !(bool)setting_value('security.allow_wifi_reload'))
|
|
) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'bad_wifi_request',
|
|
], 400);
|
|
}
|
|
|
|
$result = sh(['/usr/bin/systemctl', $verb, $unit], true, (int)setting_value('security.wifi_command_timeout_seconds'));
|
|
$out = $result['out'];
|
|
|
|
add_fan_action(
|
|
'wifi_' . $verb,
|
|
null,
|
|
null,
|
|
$unit . "\n" . mb_substr($out, 0, 1000),
|
|
true
|
|
);
|
|
|
|
json_out([
|
|
'ok' => true,
|
|
'output' => $out,
|
|
'data' => collect_snapshot(false),
|
|
]);
|
|
}
|
|
|
|
if ($action === 'wifi_alias') {
|
|
$alias = save_wifi_client_alias(
|
|
(string)($_POST['mac'] ?? ''),
|
|
(string)($_POST['hostname'] ?? '')
|
|
);
|
|
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => [
|
|
'alias' => $alias,
|
|
'status' => collect_snapshot(false),
|
|
],
|
|
]);
|
|
}
|
|
|
|
if ($action === 'collect') {
|
|
json_out([
|
|
'ok' => true,
|
|
'data' => collect_snapshot(true),
|
|
]);
|
|
}
|
|
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'unknown_action',
|
|
], 404);
|
|
} catch (Throwable $e) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'exception',
|
|
'message' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
if (!$controlApiLibrary) {
|
|
control_api_dispatch();
|
|
}
|