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 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 { if ($temp >= 80) { return 255; } if ($temp <= 50) { return 0; } $ratio = ($temp - 50) / 30; return max(0, min(255, (int)round($ratio * 255))); } function fan_ramped_pwm(int $current, int $desired, float $temp): int { if ($temp >= 80) { return $desired; } if ($desired > $current) { return min($desired, $current + 2); } if ($desired < $current) { return max($desired, $current - 2); } 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 battery_remaining_estimate(array $battery, array $history): 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)); $wattValues = []; foreach (array_slice($history, -90) as $row) { $watts = $row['cpu_watts'] ?? null; if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) { $wattValues[] = (float)$watts; } } $avgWatts = $wattValues === [] ? null : array_sum($wattValues) / count($wattValues); if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) { $remainingWh = BATTERY_CAPACITY_WH * ($percent / 100); $seconds = (int)round(($remainingWh / $avgWatts) * 3600); return [ 'display' => human_remaining_seconds($seconds), 'seconds' => $seconds, 'source' => 'avg_watts', 'avg_watts' => round($avgWatts, 3), 'capacity_wh' => BATTERY_CAPACITY_WH, ]; } $socRows = []; foreach ($history as $row) { $soc = $row['battery_percent'] ?? null; $time = strtotime((string)($row['time'] ?? '')); if ($soc !== null && $soc !== '' && is_numeric($soc) && $time > 0) { $socRows[] = [ 'time' => $time, 'soc' => (float)$soc, ]; } } if (count($socRows) >= 30) { $first = $socRows[0]; $last = $socRows[count($socRows) - 1]; $elapsed = max(1, (int)$last['time'] - (int)$first['time']); $drop = (float)$first['soc'] - (float)$last['soc']; if ($elapsed >= 120 && $drop >= 0.2) { $dropPerSecond = $drop / $elapsed; $seconds = (int)round($percent / $dropPerSecond); return [ 'display' => human_remaining_seconds($seconds), 'seconds' => $seconds, 'source' => 'soc_trend', 'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3), ]; } } return [ 'display' => '-', 'seconds' => null, 'source' => BATTERY_CAPACITY_WH > 0 ? 'avg_watts_missing' : 'battery_capacity_missing', 'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3), ]; } function add_battery_remaining_history(array $history): array { $wattWindow = []; 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) > 90) { array_shift($wattWindow); } } $percent = $row['battery_percent'] ?? null; $avgWatts = $wattWindow === [] ? null : array_sum($wattWindow) / count($wattWindow); $history[$index]['battery_remaining_seconds'] = null; if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0 && is_numeric($percent)) { $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 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 { $rows = array_slice($history, -180); if (count($rows) > 3) { $rows = array_slice($rows, 0, -3); } $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'], ]; } $rpmExpected = $mode !== 'off' && ($pwm >= 20 || $pwmAvg >= 20); $reasons = []; if ($rpmExpected && abs($rpmDelta) >= 1000) $reasons[] = 'RPM ' . signed_delta_text($rpmDelta); if (abs($tempDelta) >= 3.0) $reasons[] = 'TEMP ' . signed_delta_text($tempDelta, 'C'); $spiking = false; if ($currentState === 'normal') { $spiking = $reasons !== []; if ($spiking) { 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) <= 500 && abs($tempDelta) <= 1.5) || (abs($rollingRpmDelta) <= 500 && abs($rollingTempDelta) <= 1.5); 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 ($spiking) { $summary = 'System notice: ' . implode(', ', $reasons); } elseif ($currentState === 'alert' && $reasons !== []) { $summary = 'System notice active: ' . implode(', ', $reasons); } return [ 'active' => $spiking, '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' => $spiking ? 'alert' : $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); $spikeKey = 'fan:' . date('YmdHi'); $loggedProcesses = 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 push_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 ' . push_process_name($cpu); } $mem = null; foreach (($processes['memory'] ?? []) as $row) { if (!is_array($row)) { continue; } $mem = $row; break; } if ($mem !== null) { return 'MEM ' . push_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 push_process_name($cpu); } $parts = []; if ($cpu !== null) { $parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), push_process_name($cpu)); } if ($mem !== null) { $parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), push_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) >= 3.0; $rpmTriggered = abs($rpmDelta) >= 1000; if (!$tempTriggered && !$rpmTriggered) { return false; } if ($tempTriggered && $tempDelta >= 0) { return false; } if ($rpmTriggered && $rpmDelta >= 0) { return false; } return true; } function send_fan_spike_push(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) >= 3.0) { $reasons[] = '온도 평균보다 ' . ($tempDelta >= 0 ? '상승' : '하강'); } if (abs($rpmDelta) >= 1000) { $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) . (notice_downward_only($tempDelta, $rpmDelta) ? '' : "\n원인 후보: " . expected_process_detail_text($processes)); return send_push_payload([ 'title' => '시스템 유의사항', 'body' => $body, 'url' => '/', 'tag' => 'system-notice-' . date('YmdHi'), 'created_at' => date('Y-m-d H:i:s'), 'data' => [ '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_push_epoch(): int { $stmt = db()->query(" SELECT UNIX_TIMESTAMP(MAX(created_at)) FROM push_event_logs WHERE event = 'send_success' AND meta LIKE '%\"tag\":\"system-notice-%' "); return (int)($stmt->fetchColumn() ?: 0); } 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 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 parse_live_wifi_rows(string $iface, string $band, string $text, array $leases): 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] ?? []; $cur = [ 'band' => $band, 'iface' => $iface, 'mac' => $mac, 'ip' => $lease['ip'] ?? 'N/A', 'hostname' => $lease['hostname'] ?? 'N/A', 'name' => $lease['hostname'] ?? $mac, 'signal' => 'N/A', 'tx_bitrate' => 'N/A', 'rx_bitrate' => 'N/A', '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]; elseif (preg_match('/^tx bitrate:\s+(.+)/', $t, $m)) $cur['tx_bitrate'] = $m[1]; elseif (preg_match('/^rx bitrate:\s+(.+)/', $t, $m)) $cur['rx_bitrate'] = $m[1]; 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_signal_dbm(string $value): int { if (preg_match('/-?\d+/', $value, $m)) { return (int)$m[0]; } return -999; } 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_data(): array { $leases = dnsmasq_leases(); $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) ); } $clients = dedupe_wifi_clients($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), ]; } 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 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(); $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, ]); $history = add_battery_remaining_history(sensor_history(240)); $battery['remaining'] = battery_remaining_estimate($battery, $history); $processes = process_resource_data(6); $fanSpike = fan_spike_analysis($history, $fan, $system, $processes); if (!empty($fanSpike['active'])) { $latestNoticePush = latest_system_notice_push_epoch(); $spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes); if ($spikeLogId > 0) { $fanSpike['log_id'] = $spikeLogId; if ($latestNoticePush <= 0 || time() - $latestNoticePush >= 600) { $fanSpike['push'] = send_fan_spike_push($fanSpike, $fan, $system, $processes); } else { $fanSpike['push'] = [ 'sent' => 0, 'failed' => 0, 'skipped' => 'system_notice_cooldown', 'cooldown_seconds' => 600, ]; } } } $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(), 'history' => $history, 'processes' => $processes, 'fan_spike' => $fanSpike, 'fan_spike_history' => fan_spike_history(100), ]; 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 === 'push_devices') { json_out([ 'ok' => true, 'data' => [ 'devices' => push_device_rows(), ], ]); } if ($action === 'push_status') { json_out([ 'ok' => true, 'data' => push_subscription_status((string)($_GET['endpoint'] ?? $_POST['endpoint'] ?? '')), ]); } if ($action === 'delete_push_device') { delete_push_device((string)($_POST['endpoint_hash'] ?? '')); json_out([ 'ok' => true, 'data' => [ 'devices' => push_device_rows(), ], ]); } if ($action === 'delete_push_endpoint') { delete_push_endpoint((string)($_POST['endpoint'] ?? '')); json_out([ 'ok' => true, 'data' => [ 'devices' => push_device_rows(), ], ]); } 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 === '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) ) { json_out([ 'ok' => false, 'error' => 'bad_wifi_request', ], 400); } $result = sh(['/usr/bin/systemctl', $verb, $unit], true, 25); $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 === '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(); }