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 text_log_tail(string $path, string $label, int $limit = 500, ?callable $lineFilter = null, int $displayLimit = 500): array { $limit = max(1, min(10000, $limit)); $displayLimit = max(1, min(1000, $displayLimit)); $stat = sh(['/usr/bin/stat', '-c', '%s|%Y', $path], true, 3); if ((int)$stat['code'] !== 0) { return [ 'path' => $path, 'available' => false, 'lines' => [], 'line_count' => 0, 'size_bytes' => 0, 'updated_at' => null, 'message' => $label . ' log is not available yet.', ]; } $parts = explode('|', trim((string)$stat['out']), 2); $size = (int)($parts[0] ?? 0); $mtime = (int)($parts[1] ?? 0); $tail = sh(['/usr/bin/tail', '-n', (string)$limit, $path], true, 4); if ((int)$tail['code'] !== 0) { return [ 'path' => $path, 'available' => false, 'lines' => [], 'line_count' => 0, 'size_bytes' => $size, 'updated_at' => $mtime > 0 ? date('Y-m-d H:i:s', $mtime) : null, 'message' => 'failed to open ' . $label . ' log.', ]; } $lines = array_values(array_filter( preg_split('/\R/', trim((string)$tail['out'])) ?: [], static fn($line): bool => trim((string)$line) !== '' )); $rawLineCount = count($lines); if ($lineFilter !== null) { $lines = array_values(array_filter($lines, $lineFilter)); } if (count($lines) > $displayLimit) { $lines = array_slice($lines, -$displayLimit); } return [ 'path' => $path, 'available' => true, 'lines' => array_reverse($lines), 'line_count' => count($lines), 'raw_line_count' => $rawLineCount, 'size_bytes' => $size, 'updated_at' => $mtime > 0 ? date('Y-m-d H:i:s', $mtime) : null, ]; } function synology_remote_log_visible(string $line): bool { if (str_contains($line, 'Last message ') && str_contains($line, 'suppressed by syslog-ng')) { return false; } if (preg_match('/\ssynotifyd\[\d+\]\s/', $line)) { return false; } if (preg_match('/\ssynoelasticd\[\d+\]\s/', $line)) { $noise = [ '(UpsertByID)', '(SetByID)', '(DelByID)', '(DelByQuery)', '(Commit) Commit: fileindex_', ]; foreach ($noise as $needle) { if (str_contains($line, $needle)) { return false; } } } if (preg_match('/\sfileindexd\[\d+\]\s/', $line)) { $noise = [ 'ProcessOP BEGIN: IndexUpsert', 'ProcessOP DONE!: IndexUpsert', 'ProcessOP SKIPPED: IndexUpsert', 'ProcessOP BEGIN: IndexReindex', 'ProcessOP DONE!: IndexReindex', 'ProcessOP SKIPPED: IndexReindex', '(Process) Worker<', 'PrepareTmpOPtree', 'CheckClearOldTmpOPtree', 'BuildTree', 'Commit [fileindex_', ]; foreach ($noise as $needle) { if (str_contains($line, $needle)) { return false; } } } if (preg_match('/\sscemd\[\d+\]\s/', $line)) { $noise = [ 'Could not open file `/dev/i2c-1', 'Could not open file `/dev/i2c/1', 'Fail to initLedBrightnessConfig', 'No brightness setting', 'Fail to init config list', 'Fail to set led brightness.', ]; foreach ($noise as $needle) { if (str_contains($line, $needle)) { return false; } } } return true; } function synology_remote_log(): array { return text_log_tail('/var/log/remote/synology/chaegeon.log', 'synology remote', 5000, 'synology_remote_log_visible'); } function command_first_line(array $cmd, bool $root = false, int $timeout = 3): string { $result = sh($cmd, $root, $timeout); $lines = array_values(array_filter( preg_split('/\R/', trim((string)$result['out'])) ?: [], static fn($line): bool => trim((string)$line) !== '' )); return (int)$result['code'] === 0 && $lines ? (string)$lines[0] : ''; } function remote_log_file_status(string $dir): array { $files = []; $totalBytes = 0; $newest = null; foreach (glob(rtrim($dir, '/') . '/*.log') ?: [] as $path) { $size = filesize($path) ?: 0; $mtime = filemtime($path) ?: 0; $totalBytes += $size; $row = [ 'name' => basename($path), 'path' => $path, 'size_bytes' => $size, 'size' => bytes_human($size), 'updated_at' => $mtime > 0 ? date('Y-m-d H:i:s', $mtime) : null, 'age_seconds' => $mtime > 0 ? max(0, time() - $mtime) : null, ]; $files[] = $row; if ($newest === null || (int)$row['age_seconds'] < (int)($newest['age_seconds'] ?? PHP_INT_MAX)) { $newest = $row; } } usort($files, static fn($a, $b): int => (int)($a['age_seconds'] ?? PHP_INT_MAX) <=> (int)($b['age_seconds'] ?? PHP_INT_MAX)); return [ 'path' => $dir, 'available' => is_dir($dir), 'file_count' => count($files), 'total_bytes' => $totalBytes, 'total_size' => bytes_human($totalBytes), 'newest' => $newest, 'files' => array_slice($files, 0, 5), ]; } function remote_log_queue_status(): array { $result = sh([ '/usr/bin/find', '/var/spool/rsyslog', '-maxdepth', '1', '-name', 'control_synology_fwd*', '-printf', '%s\n', ], true, 3); if ((int)$result['code'] !== 0) { return [ 'available' => false, 'file_count' => null, 'total_bytes' => null, 'total_size' => '-', ]; } $sizes = array_values(array_filter( array_map('trim', preg_split('/\R/', trim((string)$result['out'])) ?: []), static fn($line): bool => $line !== '' )); $total = array_sum(array_map('intval', $sizes)); return [ 'available' => true, 'file_count' => count($sizes), 'total_bytes' => $total, 'total_size' => bytes_human($total), ]; } function remote_log_status(): array { $journalDir = '/var/log/journal'; $journalDisk = command_first_line(['/usr/bin/journalctl', '--disk-usage', '--no-pager'], false, 3); $journalBoots = command_first_line(['/usr/bin/bash', '-lc', 'journalctl --list-boots --no-pager 2>/dev/null | wc -l'], false, 3); $rsyslogActive = command_first_line(['/usr/bin/systemctl', 'is-active', 'rsyslog'], false, 3) ?: 'unknown'; $rsyslogEnabled = command_first_line(['/usr/bin/systemctl', 'is-enabled', 'rsyslog'], false, 3) ?: 'unknown'; $pstoreActive = command_first_line(['/usr/bin/systemctl', 'is-active', 'control-pstore-collect.service'], false, 3) ?: 'inactive'; $listener = sh(['/usr/bin/ss', '-lnt', 'sport', '=', ':6514'], false, 3); $listenerOk = (int)$listener['code'] === 0 && str_contains((string)$listener['out'], ':6514'); $remoteFiles = remote_log_file_status('/var/log/remote/synology'); $queue = remote_log_queue_status(); $savedCrashFiles = glob('/var/log/pstore/*') ?: []; $pstoreFiles = glob('/sys/fs/pstore/*') ?: []; return [ 'raspberry_to_synology' => [ 'target' => 'chaegeon.com:6514', 'transport' => 'TCP/TLS', 'rsyslog_active' => $rsyslogActive, 'rsyslog_enabled' => $rsyslogEnabled, 'queue' => $queue, 'ok' => $rsyslogActive === 'active', ], 'synology_to_raspberry' => [ 'listen_port' => 6514, 'transport' => 'TCP/TLS', 'listener' => $listenerOk, 'logs' => $remoteFiles, 'ok' => $listenerOk && $remoteFiles['file_count'] > 0, ], 'journal' => [ 'path' => $journalDir, 'persistent' => is_dir($journalDir), 'disk_usage' => $journalDisk ?: '-', 'boot_count' => is_numeric(trim($journalBoots)) ? (int)trim($journalBoots) : null, 'ok' => is_dir($journalDir), ], 'pstore' => [ 'mounted' => is_dir('/sys/fs/pstore'), 'collector_active' => $pstoreActive, 'pending_files' => count($pstoreFiles), 'saved_files' => count($savedCrashFiles), 'saved_path' => '/var/log/pstore', 'ok' => is_dir('/sys/fs/pstore'), ], ]; } function power_event_state_read(string $eventKey): array { $state = [ 'active' => false, 'active_since' => null, 'last_detected_at' => null, 'last_cleared_at' => null, 'last_duration_seconds' => 0, 'updated_at' => null, 'events' => [], ]; $stmt = db()->prepare(" SELECT * FROM power_event_states WHERE event_key = :event_key LIMIT 1 "); $stmt->execute([':event_key' => $eventKey]); $row = $stmt->fetch(); if (!$row) { return $state; } $events = json_decode((string)($row['event_history'] ?? '[]'), true); $state['active'] = (bool)($row['active'] ?? false); $state['active_since'] = is_numeric($row['active_since'] ?? null) ? (int)$row['active_since'] : null; $state['last_detected_at'] = is_numeric($row['last_detected_at'] ?? null) ? (int)$row['last_detected_at'] : null; $state['last_cleared_at'] = is_numeric($row['last_cleared_at'] ?? null) ? (int)$row['last_cleared_at'] : null; $state['last_duration_seconds'] = (int)($row['last_duration_seconds'] ?? 0); $state['updated_at'] = is_numeric($row['refreshed_at'] ?? null) ? (int)$row['refreshed_at'] : null; $state['events'] = is_array($events) ? $events : []; return $state; } function power_event_state_write(string $eventKey, array $state): void { $stmt = db()->prepare(" INSERT INTO power_event_states (event_key, active, active_since, last_detected_at, last_cleared_at, last_duration_seconds, event_history, refreshed_at) VALUES (:event_key, :active, :active_since, :last_detected_at, :last_cleared_at, :last_duration_seconds, :event_history, :refreshed_at) ON DUPLICATE KEY UPDATE active = VALUES(active), active_since = VALUES(active_since), last_detected_at = VALUES(last_detected_at), last_cleared_at = VALUES(last_cleared_at), last_duration_seconds = VALUES(last_duration_seconds), event_history = VALUES(event_history), refreshed_at = VALUES(refreshed_at), updated_at = CURRENT_TIMESTAMP "); $stmt->execute([ ':event_key' => $eventKey, ':active' => !empty($state['active']) ? 1 : 0, ':active_since' => is_numeric($state['active_since'] ?? null) ? (int)$state['active_since'] : null, ':last_detected_at' => is_numeric($state['last_detected_at'] ?? null) ? (int)$state['last_detected_at'] : null, ':last_cleared_at' => is_numeric($state['last_cleared_at'] ?? null) ? (int)$state['last_cleared_at'] : null, ':last_duration_seconds' => (int)($state['last_duration_seconds'] ?? 0), ':event_history' => json_encode($state['events'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ':refreshed_at' => is_numeric($state['updated_at'] ?? null) ? (int)$state['updated_at'] : time(), ]); } function throttled_event_status(?int $flags, string $raw, int $activeBit, int $seenBit, string $eventKey): 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); try { $state = power_event_state_read($eventKey); $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; power_event_state_write($eventKey, $state); } } catch (Throwable) { $state = [ 'active' => false, 'active_since' => null, 'last_detected_at' => null, 'last_cleared_at' => null, 'last_duration_seconds' => 0, 'updated_at' => null, 'events' => [], ]; } $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, 'low_voltage'); $throttling = throttled_event_status($flags, $raw, 0x4, 0x40000, 'throttling'); $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_control_temp(float $cpuTemp, ?float $rp1Temp): float { if ($cpuTemp <= 0) { return $rp1Temp !== null ? round($rp1Temp, 2) : 0.0; } if ($rp1Temp === null || $rp1Temp <= 0) { return round($cpuTemp, 2); } $rp1Weight = clamp_float((float)setting_value('fan.rp1_temp_weight'), 0.0, 1.0); $mixed = ($cpuTemp * (1.0 - $rp1Weight)) + ($rp1Temp * $rp1Weight); $headroom = (float)setting_value('fan.control_temp_headroom'); return round(max($cpuTemp, $mixed + $headroom), 2); } 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 { sync_presence_fan_policy(); $state = get_control_state(); $paths = fan_paths(); $cpuTemp = cpu_temp(); $rp1Temp = rp1_temp_c(); $controlTemp = fan_control_temp($cpuTemp, $rp1Temp); $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($controlTemp), }; $target = $mode === 'auto' ? fan_ramped_pwm($currentPwm, $desired, $controlTemp) : $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' => $cpuTemp, 'rp1_temp_c' => $rp1Temp, 'control_temp_c' => $controlTemp, '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 = []; $defaultInterface = ''; $route = trim((string)sh(['/usr/sbin/ip', 'route', 'show', 'default'], false, 3)['out']); if (preg_match('/\bdev\s+([^\s]+)/', $route, $matches)) { $defaultInterface = (string)$matches[1]; } 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']); $rxPackets = (int)first_readable([$dir . '/statistics/rx_packets']); $txPackets = (int)first_readable([$dir . '/statistics/tx_packets']); $rxErrors = (int)first_readable([$dir . '/statistics/rx_errors']); $txErrors = (int)first_readable([$dir . '/statistics/tx_errors']); $rxDropped = (int)first_readable([$dir . '/statistics/rx_dropped']); $txDropped = (int)first_readable([$dir . '/statistics/tx_dropped']); $rows[] = [ 'name' => $name, 'default' => $name === $defaultInterface, '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_packets' => $rxPackets, 'tx_packets' => $txPackets, 'rx_errors' => $rxErrors, 'tx_errors' => $txErrors, 'rx_dropped' => $rxDropped, 'tx_dropped' => $txDropped, '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 [ 'default_interface' => $defaultInterface, 'interfaces' => $rows, ]; } function primary_network_interface(array $network): ?array { $rows = is_array($network['interfaces'] ?? null) ? $network['interfaces'] : []; $preferred = (string)($network['default_interface'] ?? 'eth0'); foreach ($rows as $row) { if (($row['name'] ?? '') === $preferred) { return $row; } } foreach ($rows as $row) { if (($row['name'] ?? '') === 'eth0') { return $row; } } foreach ($rows as $row) { if (($row['carrier'] ?? '') === 'up' && ($row['name'] ?? '') !== 'br0') { return $row; } } return $rows[0] ?? null; } function network_log_interfaces(array $network): array { $targets = []; $primary = primary_network_interface($network); if ($primary) { $targets[(string)($primary['name'] ?? '')] = $primary; } foreach ((array)($network['interfaces'] ?? []) as $row) { $name = (string)($row['name'] ?? ''); if (in_array($name, ['eth1', 'wlan0', 'wlan1'], true)) { $targets[$name] = $row; } } unset($targets['']); return array_values($targets); } function add_network_usage_log_for_interface(array $iface): void { $name = (string)($iface['name'] ?? ''); $rxBytes = isset($iface['rx_bytes']) ? (int)$iface['rx_bytes'] : -1; $txBytes = isset($iface['tx_bytes']) ? (int)$iface['tx_bytes'] : -1; $rxPackets = isset($iface['rx_packets']) ? (int)$iface['rx_packets'] : -1; $txPackets = isset($iface['tx_packets']) ? (int)$iface['tx_packets'] : -1; $rxErrors = isset($iface['rx_errors']) ? (int)$iface['rx_errors'] : 0; $txErrors = isset($iface['tx_errors']) ? (int)$iface['tx_errors'] : 0; $rxDropped = isset($iface['rx_dropped']) ? (int)$iface['rx_dropped'] : 0; $txDropped = isset($iface['tx_dropped']) ? (int)$iface['tx_dropped'] : 0; if ($name === '' || $rxBytes < 0 || $txBytes < 0 || $rxPackets < 0 || $txPackets < 0) { return; } $pdo = db(); update_network_quality_totals($name, $rxErrors, $txErrors, $rxDropped, $txDropped); $stmt = $pdo->prepare(" SELECT recorded_at, rx_bytes, tx_bytes, rx_packets, tx_packets, rx_errors, tx_errors, rx_dropped, tx_dropped, TIMESTAMPDIFF(MICROSECOND, recorded_at, NOW(3)) AS elapsed_us FROM network_usage_logs WHERE iface = :iface ORDER BY id DESC LIMIT 1 "); $stmt->execute([':iface' => $name]); $previous = $stmt->fetch() ?: null; $rxMbps = null; $txMbps = null; $rxPps = null; $txPps = null; $rxErrorsPerSec = null; $txErrorsPerSec = null; $rxDroppedPerSec = null; $txDroppedPerSec = null; if ($previous) { $seconds = max(0.0, (float)($previous['elapsed_us'] ?? 0) / 1000000); if ($seconds < 0.8) { return; } $rxDelta = $rxBytes - (int)$previous['rx_bytes']; $txDelta = $txBytes - (int)$previous['tx_bytes']; $rxPacketDelta = $rxPackets - (int)$previous['rx_packets']; $txPacketDelta = $txPackets - (int)$previous['tx_packets']; $rxErrorDelta = $rxErrors - (int)$previous['rx_errors']; $txErrorDelta = $txErrors - (int)$previous['tx_errors']; $rxDroppedDelta = $rxDropped - (int)$previous['rx_dropped']; $txDroppedDelta = $txDropped - (int)$previous['tx_dropped']; if ($seconds > 0 && $seconds <= 60 && $rxDelta >= 0 && $txDelta >= 0 && $rxPacketDelta >= 0 && $txPacketDelta >= 0) { $rxMbps = round(($rxDelta * 8) / $seconds / 1000000, 4); $txMbps = round(($txDelta * 8) / $seconds / 1000000, 4); $rxPps = round($rxPacketDelta / $seconds, 2); $txPps = round($txPacketDelta / $seconds, 2); } if ($seconds > 0 && $seconds <= 60 && $rxErrorDelta >= 0 && $txErrorDelta >= 0 && $rxDroppedDelta >= 0 && $txDroppedDelta >= 0) { $rxErrorsPerSec = round($rxErrorDelta / $seconds, 2); $txErrorsPerSec = round($txErrorDelta / $seconds, 2); $rxDroppedPerSec = round($rxDroppedDelta / $seconds, 2); $txDroppedPerSec = round($txDroppedDelta / $seconds, 2); } } $stmt = $pdo->prepare(" INSERT INTO network_usage_logs ( iface, rx_bytes, tx_bytes, rx_packets, tx_packets, rx_errors, tx_errors, rx_dropped, tx_dropped, rx_mbps, tx_mbps, rx_pps, tx_pps, rx_errors_per_sec, tx_errors_per_sec, rx_dropped_per_sec, tx_dropped_per_sec, create_ip ) VALUES ( :iface, :rx_bytes, :tx_bytes, :rx_packets, :tx_packets, :rx_errors, :tx_errors, :rx_dropped, :tx_dropped, :rx_mbps, :tx_mbps, :rx_pps, :tx_pps, :rx_errors_per_sec, :tx_errors_per_sec, :rx_dropped_per_sec, :tx_dropped_per_sec, :create_ip ) "); $stmt->execute([ ':iface' => $name, ':rx_bytes' => $rxBytes, ':tx_bytes' => $txBytes, ':rx_packets' => $rxPackets, ':tx_packets' => $txPackets, ':rx_errors' => $rxErrors, ':tx_errors' => $txErrors, ':rx_dropped' => $rxDropped, ':tx_dropped' => $txDropped, ':rx_mbps' => $rxMbps, ':tx_mbps' => $txMbps, ':rx_pps' => $rxPps, ':tx_pps' => $txPps, ':rx_errors_per_sec' => $rxErrorsPerSec, ':tx_errors_per_sec' => $txErrorsPerSec, ':rx_dropped_per_sec' => $rxDroppedPerSec, ':tx_dropped_per_sec' => $txDroppedPerSec, ':create_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli', ]); } function update_network_quality_totals(string $iface, int $rxErrors, int $txErrors, int $rxDropped, int $txDropped): void { $pdo = db(); $stmt = $pdo->prepare(" SELECT last_rx_errors, last_tx_errors, last_rx_dropped, last_tx_dropped FROM network_quality_totals WHERE iface = :iface LIMIT 1 "); $stmt->execute([':iface' => $iface]); $previous = $stmt->fetch() ?: null; if (!$previous) { $stmt = $pdo->prepare(" INSERT INTO network_quality_totals ( iface, rx_errors_total, tx_errors_total, rx_dropped_total, tx_dropped_total, last_rx_errors, last_tx_errors, last_rx_dropped, last_tx_dropped ) VALUES ( :iface, :rx_errors_total, :tx_errors_total, :rx_dropped_total, :tx_dropped_total, :last_rx_errors, :last_tx_errors, :last_rx_dropped, :last_tx_dropped ) "); $stmt->execute([ ':iface' => $iface, ':rx_errors_total' => $rxErrors, ':tx_errors_total' => $txErrors, ':rx_dropped_total' => $rxDropped, ':tx_dropped_total' => $txDropped, ':last_rx_errors' => $rxErrors, ':last_tx_errors' => $txErrors, ':last_rx_dropped' => $rxDropped, ':last_tx_dropped' => $txDropped, ]); return; } $rxErrorDelta = $rxErrors >= (int)$previous['last_rx_errors'] ? $rxErrors - (int)$previous['last_rx_errors'] : $rxErrors; $txErrorDelta = $txErrors >= (int)$previous['last_tx_errors'] ? $txErrors - (int)$previous['last_tx_errors'] : $txErrors; $rxDroppedDelta = $rxDropped >= (int)$previous['last_rx_dropped'] ? $rxDropped - (int)$previous['last_rx_dropped'] : $rxDropped; $txDroppedDelta = $txDropped >= (int)$previous['last_tx_dropped'] ? $txDropped - (int)$previous['last_tx_dropped'] : $txDropped; $stmt = $pdo->prepare(" UPDATE network_quality_totals SET rx_errors_total = rx_errors_total + :rx_error_delta, tx_errors_total = tx_errors_total + :tx_error_delta, rx_dropped_total = rx_dropped_total + :rx_dropped_delta, tx_dropped_total = tx_dropped_total + :tx_dropped_delta, last_rx_errors = :last_rx_errors, last_tx_errors = :last_tx_errors, last_rx_dropped = :last_rx_dropped, last_tx_dropped = :last_tx_dropped, updated_at = NOW(3) WHERE iface = :iface "); $stmt->execute([ ':rx_error_delta' => $rxErrorDelta, ':tx_error_delta' => $txErrorDelta, ':rx_dropped_delta' => $rxDroppedDelta, ':tx_dropped_delta' => $txDroppedDelta, ':last_rx_errors' => $rxErrors, ':last_tx_errors' => $txErrors, ':last_rx_dropped' => $rxDropped, ':last_tx_dropped' => $txDropped, ':iface' => $iface, ]); } function add_network_usage_log(array $network): void { foreach (network_log_interfaces($network) as $iface) { add_network_usage_log_for_interface($iface); } } function network_quality_totals(): array { $stmt = db()->query(" SELECT iface, rx_errors_total, tx_errors_total, rx_dropped_total, tx_dropped_total, last_rx_errors, last_tx_errors, last_rx_dropped, last_tx_dropped, updated_at FROM network_quality_totals ORDER BY iface "); $totals = []; foreach ($stmt->fetchAll() as $row) { $iface = (string)($row['iface'] ?? ''); if ($iface === '') { continue; } $totals[$iface] = [ 'iface' => $iface, 'rx_errors_total' => (int)$row['rx_errors_total'], 'tx_errors_total' => (int)$row['tx_errors_total'], 'rx_dropped_total' => (int)$row['rx_dropped_total'], 'tx_dropped_total' => (int)$row['tx_dropped_total'], 'last_rx_errors' => (int)$row['last_rx_errors'], 'last_tx_errors' => (int)$row['last_tx_errors'], 'last_rx_dropped' => (int)$row['last_rx_dropped'], 'last_tx_dropped' => (int)$row['last_tx_dropped'], 'updated_at' => $row['updated_at'], ]; } return $totals; } function network_usage_history(int $limit = 240): array { $limit = max(1, min(1500, $limit)); $stmt = db()->query(" SELECT recorded_at AS time, iface, rx_mbps, tx_mbps, ROUND(rx_mbps / 8, 4) AS rx_mbytes, ROUND(tx_mbps / 8, 4) AS tx_mbytes, rx_pps, tx_pps, rx_errors_per_sec, tx_errors_per_sec, rx_dropped_per_sec, tx_dropped_per_sec FROM network_usage_logs WHERE rx_mbps IS NOT NULL AND tx_mbps IS NOT NULL AND rx_pps IS NOT NULL AND tx_pps IS NOT NULL ORDER BY id DESC LIMIT {$limit} "); return array_reverse($stmt->fetchAll()); } 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(); if ( isset($cache[$cacheKey]) && ($now - (int)$cache[$cacheKey]['time']) < 600 && is_array($cache[$cacheKey]['rows']) ) { return $cache[$cacheKey]['rows']; } $stmt = db()->prepare(" SELECT created_at_epoch, sample_rows FROM battery_profile_cache WHERE days = :days AND expires_at >= NOW() LIMIT 1 "); $stmt->execute([':days' => $days]); $cached = $stmt->fetch(); if ($cached) { $rows = json_decode((string)$cached['sample_rows'], true); if (is_array($rows)) { $cache[$cacheKey] = [ 'time' => $now, 'rows' => $rows, ]; return $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, ]; $stmt = db()->prepare(" INSERT INTO battery_profile_cache (days, created_at_epoch, sample_rows, expires_at) VALUES (:days, :created_at_epoch, :sample_rows, DATE_ADD(NOW(), INTERVAL 6 HOUR)) ON DUPLICATE KEY UPDATE created_at_epoch = VALUES(created_at_epoch), sample_rows = VALUES(sample_rows), expires_at = VALUES(expires_at), updated_at = CURRENT_TIMESTAMP "); $stmt->execute([ ':days' => $days, ':created_at_epoch' => $now, ':sample_rows' => json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); 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 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_iface_by_driver(string $driver): ?string { foreach (glob('/sys/class/net/wlan*') ?: [] as $path) { $iface = basename($path); $driverPath = @readlink($path . '/device/driver'); if ($driverPath !== false && basename($driverPath) === $driver) { return $iface; } } return null; } 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 array_values(array_filter($rows, static fn(array $row): bool => !wifi_client_stale($row))); } function lan_fdb_macs(string $iface = 'eth1'): array { $rows = sh(['/usr/sbin/bridge', 'fdb', 'show', 'br', 'br0'], false, 4)['out']; $macs = []; foreach (explode("\n", $rows) as $line) { if (!preg_match('/^([0-9a-f]{2}(?::[0-9a-f]{2}){5})\s+dev\s+' . preg_quote($iface, '/') . '\b/i', trim($line), $m)) { continue; } $mac = strtolower($m[1]); if (str_starts_with($mac, '01:') || str_starts_with($mac, '33:') || str_contains($line, 'permanent')) { continue; } $macs[$mac] = true; } return array_keys($macs); } function lan_link_speed_label(string $iface = 'eth1'): string { $speed = trim((string)@file_get_contents('/sys/class/net/' . $iface . '/speed')); if ($speed === '' || !ctype_digit($speed) || (int)$speed <= 0) { return '-'; } $mbps = (int)$speed; if ($mbps >= 1000 && $mbps % 1000 === 0) { return (int)($mbps / 1000) . ' Gbps'; } if ($mbps >= 1000) { return rtrim(rtrim(number_format($mbps / 1000, 1), '0'), '.') . ' Gbps'; } return $mbps . ' Mbps'; } function observed_lan_macs(array $leases): array { if ($leases === []) { return []; } try { $stmt = db()->query(" SELECT mac FROM wifi_observed_sessions WHERE band = 'LAN' "); } catch (Throwable) { return []; } $macs = []; foreach ($stmt->fetchAll() as $row) { $mac = strtolower((string)($row['mac'] ?? '')); if (wifi_valid_mac($mac) && isset($leases[$mac])) { $macs[$mac] = true; } } return array_keys($macs); } function lan_client_rows(array $leases, array $aliases, array $wifiMacs = [], string $iface = 'eth1'): array { $rows = []; $linkSpeed = lan_link_speed_label($iface); $lanFdbList = lan_fdb_macs($iface); $lanFdbMacs = array_fill_keys($lanFdbList, true); $wifiMacs = array_fill_keys(array_map('strtolower', $wifiMacs), true); $lanMacs = array_values(array_unique(array_merge($lanFdbList, observed_lan_macs($leases)))); foreach ($lanMacs as $mac) { $mac = strtolower((string)$mac); if (!wifi_valid_mac($mac) || isset($wifiMacs[$mac])) { continue; } $lease = $leases[$mac] ?? []; $leaseHostname = (string)($lease['hostname'] ?? 'N/A'); $aliasHostname = $aliases[$mac] ?? null; $hostname = $aliasHostname ?: $leaseHostname; $hostnameSource = $aliasHostname ? 'alias' : ($leaseHostname === 'N/A' ? 'generated' : 'lease'); $isFdbVisible = isset($lanFdbMacs[$mac]); $rows[] = [ 'band' => 'LAN', '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' => '-', 'tx_bitrate' => $linkSpeed, 'rx_bitrate' => '-', 'signal_source' => 'lan', 'tx_bitrate_source' => 'lan_link', 'rx_bitrate_source' => 'lan', 'connected_time' => $isFdbVisible ? 'N/A' : '-', 'inactive_time' => '-', 'presence' => $isFdbVisible ? 'fdb' : 'lease', 'wol_available' => !$isFdbVisible, 'rx_bytes' => 0, 'tx_bytes' => 0, 'rx_packets' => 0, 'tx_packets' => 0, 'tx_failed' => 0, ]; } return $rows; } function send_wake_on_lan(string $mac, string $broadcast = '192.168.50.255', int $port = 9): array { $mac = strtolower(trim($mac)); if (!wifi_valid_mac($mac)) { throw new InvalidArgumentException('bad_mac'); } $leases = dnsmasq_leases(); if (!isset($leases[$mac])) { throw new InvalidArgumentException('unknown_dhcp_client'); } $activeLanMacs = array_fill_keys(lan_fdb_macs(), true); if (isset($activeLanMacs[$mac])) { throw new InvalidArgumentException('device_already_online'); } $lanMacs = array_fill_keys(array_merge(array_keys($activeLanMacs), observed_lan_macs($leases)), true); if (!isset($lanMacs[$mac])) { throw new InvalidArgumentException('not_lan_client'); } $macBytes = hex2bin(str_replace(':', '', $mac)); if ($macBytes === false || strlen($macBytes) !== 6) { throw new InvalidArgumentException('bad_mac'); } $packet = str_repeat("\xFF", 6) . str_repeat($macBytes, 16); $socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); if ($socket === false) { throw new RuntimeException('socket_create_failed'); } try { socket_set_option($socket, SOL_SOCKET, SO_BROADCAST, 1); $sent = socket_sendto($socket, $packet, strlen($packet), 0, $broadcast, $port); if ($sent === false || $sent !== strlen($packet)) { throw new RuntimeException('wake_packet_failed'); } } finally { socket_close($socket); } return [ 'mac' => $mac, 'ip' => $leases[$mac]['ip'] ?? null, 'hostname' => $leases[$mac]['hostname'] ?? null, 'broadcast' => $broadcast, 'port' => $port, ]; } function wake_lan_status(string $mac): array { $mac = strtolower(trim($mac)); if (!wifi_valid_mac($mac)) { throw new InvalidArgumentException('bad_mac'); } $leases = dnsmasq_leases(); $activeLanMacs = array_fill_keys(lan_fdb_macs(), true); $observedLanMacs = array_fill_keys(observed_lan_macs($leases), true); $known = isset($leases[$mac]) || isset($activeLanMacs[$mac]) || isset($observedLanMacs[$mac]); if (!$known) { throw new InvalidArgumentException('unknown_dhcp_client'); } $lease = $leases[$mac] ?? []; $isOnline = isset($activeLanMacs[$mac]); return [ 'mac' => $mac, 'ip' => $lease['ip'] ?? null, 'hostname' => $lease['hostname'] ?? null, 'state' => $isOnline ? 'on' : 'off', 'presence' => $isOnline ? 'fdb' : (isset($observedLanMacs[$mac]) ? 'observed' : 'lease'), 'wol_available' => !$isOnline, ]; } function wifi_time_ms(string $value): int { if (preg_match('/(\d+)/', $value, $m)) { return (int)$m[1]; } return PHP_INT_MAX; } function wifi_client_stale(array $client): bool { $inactiveMs = wifi_time_ms((string)($client['inactive_time'] ?? '')); return $inactiveMs >= 3600000; } 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'); if ($band === 'LAN') { continue; } $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_connected_seconds(mixed $value): ?float { $text = strtolower(trim((string)$value)); if ($text === '' || $text === 'n/a' || $text === '-') { return null; } if (is_numeric($text)) { return (float)$text; } $total = 0.0; $matched = false; if (preg_match_all('/([0-9]+(?:\.[0-9]+)?)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|min|m|seconds?|secs?|sec|s|milliseconds?|msecs?|msec|ms)\b/i', $text, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { $amount = (float)$match[1]; $unit = strtolower($match[2]); $matched = true; if ($unit === 'd' || str_starts_with($unit, 'day')) { $total += $amount * 86400; } elseif ($unit === 'h' || str_starts_with($unit, 'hour') || str_starts_with($unit, 'hr')) { $total += $amount * 3600; } elseif ($unit === 'm' || str_starts_with($unit, 'min')) { $total += $amount * 60; } elseif ($unit === 'ms' || str_starts_with($unit, 'msec') || str_starts_with($unit, 'millisecond')) { $total += $amount / 1000; } else { $total += $amount; } } } return $matched ? $total : null; } 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 $visibleMacsByBand): void { foreach (['5G', 'LAN'] as $band) { $visibleMacs = array_values(array_unique(array_filter( array_map(static fn($mac) => strtolower((string)$mac), $visibleMacsByBand[$band] ?? []), static fn($mac) => wifi_valid_mac($mac) ))); if ($visibleMacs === []) { $stmt = $pdo->prepare("DELETE FROM wifi_observed_sessions WHERE band = ?"); $stmt->execute([$band]); continue; } $placeholders = implode(',', array_fill(0, count($visibleMacs), '?')); $stmt = $pdo->prepare(" DELETE FROM wifi_observed_sessions WHERE band = ? AND mac NOT IN ($placeholders) "); $stmt->execute(array_merge([$band], $visibleMacs)); } } function observed_wifi_duration_seconds(array $clients): array { $targets = []; $visibleMacsByBand = [ '5G' => [], 'LAN' => [], ]; foreach ($clients as $client) { $band = (string)($client['band'] ?? ''); $mac = strtolower((string)($client['mac'] ?? '')); if (!in_array($band, ['5G', 'LAN'], true) || !wifi_valid_mac($mac)) { continue; } $visibleMacsByBand[$band][] = $mac; if (!wifi_connected_time_missing($client['connected_time'] ?? null)) { continue; } $targets[$band . ':' . $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, $visibleMacsByBand); 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'], ]); } $targetMacs = array_values(array_unique(array_column($targets, 'mac'))); $placeholders = implode(',', array_fill(0, count($targetMacs), '?')); $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($targetMacs); $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 (!in_array((string)($client['band'] ?? ''), ['5G', 'LAN'], true) || !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 sort_clients_by_connected_time(array $clients): array { usort($clients, static function (array $a, array $b): int { $aSeconds = wifi_connected_seconds($a['connected_time'] ?? null); $bSeconds = wifi_connected_seconds($b['connected_time'] ?? null); if ($aSeconds === null && $bSeconds === null) { return strcmp((string)($a['band'] ?? ''), (string)($b['band'] ?? '')) ?: strcmp((string)($a['hostname'] ?? ''), (string)($b['hostname'] ?? '')); } if ($aSeconds === null) { return 1; } if ($bSeconds === null) { return -1; } return $bSeconds <=> $aSeconds; }); return $clients; } function wifi_data(): array { $leases = dnsmasq_leases(); $aliases = wifi_client_aliases(); $clients = []; $wifiIfaces = array_filter([ 'wlan0' => '2.4G', wifi_iface_by_driver('rtl88x2bu') ?: 'wlan1' => '5G', ]); foreach ($wifiIfaces as $iface => $band) { $clients = array_merge( $clients, parse_live_wifi_rows($iface, $band, iw_station_dump($iface), $leases, $aliases) ); } $wifiMacs = array_values(array_unique(array_filter( array_map( static fn(array $client): string => strtolower((string)($client['mac'] ?? '')), $clients ), static fn(string $mac): bool => wifi_valid_mac($mac) ))); $clients = array_merge($clients, lan_client_rows($leases, $aliases, $wifiMacs)); $clients = dedupe_wifi_clients($clients); $clients = apply_wifi_estimates($clients); $clients = apply_observed_wifi_connected_time($clients); $clients = sort_clients_by_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')), 'countLan' => count(array_filter($clients, fn($c) => $c['band'] === 'LAN')), '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 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(500, $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' => array_reverse($lines), 'error' => (int)$result['code'] === 0 ? '' : (string)$result['out'], ]; } function custom_systemd_services(): array { $cacheSeconds = (int)setting_value('display.custom_service_cache_seconds'); $stmt = db()->prepare(" SELECT service_rows FROM systemd_service_cache WHERE cache_key = 1 AND expires_at >= NOW() LIMIT 1 "); $stmt->execute(); $cached = $stmt->fetch(); if ($cached) { $rows = json_decode((string)$cached['service_rows'], true); if (is_array($rows)) { return $rows; } } $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'], ]; } $stmt = db()->prepare(" INSERT INTO systemd_service_cache (cache_key, captured_at, service_rows, expires_at) VALUES (1, :captured_at, :service_rows, :expires_at) ON DUPLICATE KEY UPDATE captured_at = VALUES(captured_at), service_rows = VALUES(service_rows), expires_at = VALUES(expires_at), updated_at = CURRENT_TIMESTAMP "); $stmt->execute([ ':captured_at' => time(), ':service_rows' => json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ':expires_at' => date('Y-m-d H:i:s', time() + max(1, $cacheSeconds)), ]); return $rows; } function touch_display_bool(mixed $value): bool { return in_array((string)$value, ['1', 'true', 'yes', 'on'], true); } function touch_display_file(string $kind): string { return $kind === 'cmdline' ? '/boot/firmware/cmdline.txt' : '/boot/firmware/config.txt'; } function touch_display_backlight_path(): ?string { $candidates = glob('/sys/class/backlight/*') ?: []; foreach ($candidates as $path) { if (is_readable($path . '/brightness') && is_readable($path . '/max_brightness')) { return $path; } } $fallback = '/sys/devices/platform/axi/1000120000.pcie/1f00088000.i2c/i2c-10/10-0045/backlight/10-0045'; return is_readable($fallback . '/brightness') && is_readable($fallback . '/max_brightness') ? $fallback : null; } function touch_display_read_int(?string $path): ?int { if ($path === null || !is_readable($path)) { return null; } $raw = trim((string)@file_get_contents($path)); return is_numeric($raw) ? (int)$raw : null; } function touch_display_read_file(string $kind): string { $path = touch_display_file($kind); $content = @file_get_contents($path); return is_string($content) ? $content : ''; } function touch_display_backup_file(string $path): void { if (!is_file($path)) { return; } $backup = $path . '.control-' . date('Ymd-His') . '.bak'; if (function_exists('posix_geteuid') && posix_geteuid() === 0) { @copy($path, $backup); return; } sh(['/bin/cp', '-a', $path, $backup], true, 5); } function touch_display_write_file(string $path, string $content): void { if (function_exists('posix_geteuid') && posix_geteuid() === 0) { if (@file_put_contents($path, $content) === false) { throw new RuntimeException(basename($path) . ' 저장에 실패했습니다.'); } return; } $tmp = sys_get_temp_dir() . '/control-touch-display-' . bin2hex(random_bytes(6)); if (@file_put_contents($tmp, $content) === false) { throw new RuntimeException('임시 설정 파일 생성에 실패했습니다.'); } @chmod($tmp, 0644); $result = sh(['/bin/cp', '-f', $tmp, $path], true, 5); @unlink($tmp); if ($result['code'] !== 0) { throw new RuntimeException(trim($result['out']) !== '' ? trim($result['out']) : basename($path) . ' 저장에 실패했습니다.'); } } function touch_display_write_value(string $path, string $value): void { if (function_exists('posix_geteuid') && posix_geteuid() === 0) { if (@file_put_contents($path, $value) === false) { throw new RuntimeException(basename($path) . ' 저장에 실패했습니다.'); } return; } $result = sh(['/bin/sh', '-c', 'printf %s ' . escapeshellarg($value) . ' > ' . escapeshellarg($path)], true, 5); if ($result['code'] !== 0) { throw new RuntimeException(trim($result['out']) !== '' ? trim($result['out']) : basename($path) . ' 저장에 실패했습니다.'); } } function touch_display_active_config_lines(string $config): array { return array_values(array_filter( preg_split('/\R/', $config) ?: [], static function (string $line): bool { $trimmed = trim($line); return $trimmed !== '' && !str_starts_with($trimmed, '#'); } )); } function touch_display_config_flag(array $lines, string $key): bool { foreach ($lines as $line) { if (preg_match('/^' . preg_quote($key, '/') . '\s*=\s*1\s*$/', $line)) { return true; } } return false; } function touch_display_overlay(array $lines): array { $overlay = [ 'enabled' => false, 'params' => [], ]; foreach ($lines as $line) { if (!preg_match('/^dtoverlay\s*=\s*vc4-kms-dsi-7inch(?(?:,.*)?)\s*$/', $line, $m)) { continue; } $overlay['enabled'] = true; $params = trim((string)($m['params'] ?? ''), ','); foreach (array_filter(array_map('trim', explode(',', $params))) as $param) { if (str_contains($param, '=')) { [$key, $value] = array_map('trim', explode('=', $param, 2)); $overlay['params'][$key] = $value; } else { $overlay['params'][$param] = true; } } } return $overlay; } function touch_display_rotation(string $cmdline): int { if (preg_match('/(?:^|\s)video=DSI-1:\d+x\d+@\d+(?:\.\d+)?,rotate=(0|90|180|270)(?:\s|$)/', $cmdline, $m)) { return (int)$m[1]; } return 0; } function touch_display_status(): array { $connector = '/sys/class/drm/card0-DSI-1'; $config = touch_display_read_file('config'); $cmdline = touch_display_read_file('cmdline'); $lines = touch_display_active_config_lines($config); $overlay = touch_display_overlay($lines); $params = $overlay['params']; $statusPath = $connector . '/status'; $modesPath = $connector . '/modes'; $enabledPath = $connector . '/enabled'; $dpmsPath = $connector . '/dpms'; $modes = is_readable($modesPath) ? array_values(array_filter(array_map('trim', file($modesPath, FILE_IGNORE_NEW_LINES) ?: []))) : []; $backlightPath = touch_display_backlight_path(); $brightness = touch_display_read_int($backlightPath !== null ? $backlightPath . '/brightness' : null); $actualBrightness = touch_display_read_int($backlightPath !== null ? $backlightPath . '/actual_brightness' : null); $maxBrightness = touch_display_read_int($backlightPath !== null ? $backlightPath . '/max_brightness' : null); $blPower = touch_display_read_int($backlightPath !== null ? $backlightPath . '/bl_power' : null); return [ 'connector' => 'DSI-1', 'detected' => is_dir($connector), 'connected' => trim((string)@file_get_contents($statusPath)) === 'connected', 'status' => is_readable($statusPath) ? trim((string)@file_get_contents($statusPath)) : 'unknown', 'enabled_state' => is_readable($enabledPath) ? trim((string)@file_get_contents($enabledPath)) : 'unknown', 'dpms' => is_readable($dpmsPath) ? trim((string)@file_get_contents($dpmsPath)) : 'unknown', 'modes' => $modes, 'current_mode' => $modes[0] ?? '800x480', 'framebuffer' => is_dir('/sys/class/graphics/fb0') ? 'fb0' : '-', 'touch_input' => trim((string)shell_exec("for e in /sys/class/input/event*/device/name; do cat \"\$e\" 2>/dev/null; done | grep -i 'ft5x06' | head -n 1")) ?: '-', 'backlight' => [ 'available' => $backlightPath !== null, 'path' => $backlightPath, 'brightness' => $brightness, 'actual_brightness' => $actualBrightness, 'max_brightness' => $maxBrightness, 'percent' => $maxBrightness !== null && $maxBrightness > 0 && $brightness !== null ? round($brightness / $maxBrightness * 100, 1) : null, 'power' => $blPower, 'enabled' => $blPower === null || $blPower === 0, ], 'config' => [ 'display_auto_detect' => touch_display_config_flag($lines, 'display_auto_detect'), 'manual_overlay' => (bool)$overlay['enabled'], 'display_enabled' => !touch_display_config_flag($lines, 'ignore_lcd'), 'touch_enabled' => !touch_display_config_flag($lines, 'disable_touchscreen') && empty($params['disable_touch']), 'rotation' => touch_display_rotation($cmdline), 'touch_invx' => !empty($params['invx']) && (string)$params['invx'] !== '0', 'touch_invy' => !empty($params['invy']) && (string)$params['invy'] !== '0', 'touch_swapxy' => !empty($params['swapxy']) && (string)$params['swapxy'] !== '0', 'touch_sizex' => is_numeric($params['sizex'] ?? null) ? (int)$params['sizex'] : 800, 'touch_sizey' => is_numeric($params['sizey'] ?? null) ? (int)$params['sizey'] : 480, ], 'paths' => [ 'config' => touch_display_file('config'), 'cmdline' => touch_display_file('cmdline'), ], ]; } function touch_display_set_runtime(array $input): array { $backlightPath = touch_display_backlight_path(); if ($backlightPath === null) { throw new RuntimeException('백라이트 제어 장치를 찾을 수 없습니다.'); } $maxBrightness = touch_display_read_int($backlightPath . '/max_brightness') ?? 255; $brightness = max(0, min($maxBrightness, (int)($input['brightness'] ?? $maxBrightness))); $enabled = touch_display_bool($input['backlight_enabled'] ?? '1'); if (!$enabled) { $brightness = 0; } touch_display_write_value($backlightPath . '/brightness', (string)$brightness); if (is_writable($backlightPath . '/bl_power') || is_readable($backlightPath . '/bl_power')) { touch_display_write_value($backlightPath . '/bl_power', $enabled ? '0' : '4'); } add_fan_action( 'touch_display_runtime', null, null, 'brightness=' . $brightness . '/' . $maxBrightness . ', backlight_enabled=' . ($enabled ? '1' : '0'), true ); return [ 'reboot_required' => false, 'display' => touch_display_status(), ]; } function touch_display_comment_directives(string $config, array $patterns): string { $lines = preg_split('/\R/', $config) ?: []; $cleaned = []; $skipControlBlock = false; foreach ($lines as $line) { $trimmed = trim($line); if ($trimmed === '# Control touch display settings') { $skipControlBlock = true; continue; } if ($skipControlBlock) { if ( $trimmed === '' || preg_match('/^(display_auto_detect|ignore_lcd|disable_touchscreen)\s*=/', $trimmed) || preg_match('/^dtoverlay\s*=\s*vc4-kms-dsi-7inch(?:,.*)?$/', $trimmed) ) { continue; } $skipControlBlock = false; } $cleaned[] = $line; } $lines = $cleaned; foreach ($lines as &$line) { $trimmed = trim($line); if ($trimmed === '' || str_starts_with($trimmed, '#')) { continue; } foreach ($patterns as $pattern) { if (preg_match($pattern, $trimmed)) { $line = '# control disabled: ' . $line; break; } } } unset($line); return implode("\n", $lines); } function touch_display_set_config(array $input): array { $rotation = (int)($input['rotation'] ?? 0); if (!in_array($rotation, [0, 90, 180, 270], true)) { throw new RuntimeException('지원하지 않는 회전 값입니다.'); } $displayEnabled = touch_display_bool($input['display_enabled'] ?? '1'); $touchEnabled = touch_display_bool($input['touch_enabled'] ?? '1'); $invx = touch_display_bool($input['touch_invx'] ?? '0'); $invy = touch_display_bool($input['touch_invy'] ?? '0'); $swapxy = touch_display_bool($input['touch_swapxy'] ?? '0'); $sizeX = max(100, min(4096, (int)($input['touch_sizex'] ?? 800))); $sizeY = max(100, min(4096, (int)($input['touch_sizey'] ?? 480))); $manualOverlay = touch_display_bool($input['manual_overlay'] ?? '0') || $invx || $invy || $swapxy || !$touchEnabled || $sizeX !== 800 || $sizeY !== 480; $configPath = touch_display_file('config'); $cmdlinePath = touch_display_file('cmdline'); $config = touch_display_read_file('config'); $cmdline = trim(touch_display_read_file('cmdline')); if ($config === '' || $cmdline === '') { throw new RuntimeException('부팅 설정 파일을 읽을 수 없습니다.'); } $newConfig = touch_display_comment_directives($config, [ '/^display_auto_detect\s*=/', '/^ignore_lcd\s*=/', '/^disable_touchscreen\s*=/', '/^dtoverlay\s*=\s*vc4-kms-dsi-7inch(?:,.*)?$/', ]); $append = []; $append[] = ''; $append[] = '# Control touch display settings'; if ($manualOverlay) { $params = ['vc4-kms-dsi-7inch']; if ($sizeX !== 800) { $params[] = 'sizex=' . $sizeX; } if ($sizeY !== 480) { $params[] = 'sizey=' . $sizeY; } if ($invx) { $params[] = 'invx'; } if ($invy) { $params[] = 'invy'; } if ($swapxy) { $params[] = 'swapxy'; } if (!$touchEnabled) { $params[] = 'disable_touch'; } $append[] = 'dtoverlay=' . implode(',', $params); } else { $append[] = 'display_auto_detect=1'; } if (!$displayEnabled) { $append[] = 'ignore_lcd=1'; } if (!$touchEnabled) { $append[] = 'disable_touchscreen=1'; } $newConfig = rtrim($newConfig) . "\n" . implode("\n", $append) . "\n"; $newCmdline = preg_replace('/(?:^|\s)video=DSI-1:\d+x\d+@\d+(?:\.\d+)?,rotate=(?:0|90|180|270)(?=\s|$)/', '', $cmdline) ?? $cmdline; $newCmdline = trim(preg_replace('/\s+/', ' ', $newCmdline) ?? $newCmdline); if ($rotation !== 0) { $newCmdline .= ' video=DSI-1:800x480@60,rotate=' . $rotation; } $newCmdline .= "\n"; $changed = []; if ($newConfig !== $config) { touch_display_backup_file($configPath); touch_display_write_file($configPath, $newConfig); $changed[] = $configPath; } if ($newCmdline !== touch_display_read_file('cmdline')) { touch_display_backup_file($cmdlinePath); touch_display_write_file($cmdlinePath, $newCmdline); $changed[] = $cmdlinePath; } add_fan_action( 'touch_display_config', null, null, 'rotation=' . $rotation . ', manual_overlay=' . ($manualOverlay ? '1' : '0') . ', display_enabled=' . ($displayEnabled ? '1' : '0') . ', touch_enabled=' . ($touchEnabled ? '1' : '0'), true ); return [ 'changed' => $changed, 'reboot_required' => $changed !== [], 'display' => touch_display_status(), ]; } 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']; } $rp1Temp = isset($policy['rp1_temp_c']) && is_numeric($policy['rp1_temp_c']) ? (float)$policy['rp1_temp_c'] : rp1_temp_c(); $controlTemp = isset($policy['control_temp_c']) && is_numeric($policy['control_temp_c']) ? (float)$policy['control_temp_c'] : fan_control_temp($temp, $rp1Temp); $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() : [ 'enabled' => CONTROL_BATTERY_ENABLED, 'removed' => !CONTROL_BATTERY_ENABLED, 'voltage' => CONTROL_BATTERY_ENABLED && isset($latest['battery_voltage']) ? (float)$latest['battery_voltage'] : null, 'percent' => CONTROL_BATTERY_ENABLED && 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' => $rp1Temp, 'cpu_voltage' => $cpuPower['voltage'], 'cpu_watts' => $cpuPower['watts'], 'battery_voltage' => CONTROL_BATTERY_ENABLED ? $battery['voltage'] : null, 'battery_percent' => CONTROL_BATTERY_ENABLED ? $battery['percent'] : null, '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'], 'control_temp_c' => $controlTemp, 'policy_ok' => $policy['ok'], 'paths' => $policy['paths'], ]; $system = array_merge($os, [ 'temp_c' => $temp, 'rp1_temp_c' => $rp1Temp, 'fan_control_temp_c' => $controlTemp, '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'], ]); if (CONTROL_BATTERY_ENABLED) { $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' => $rp1Temp, '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')); } else { $history = sensor_history(240); $battery['remaining'] = [ 'display' => 'UPS 제거됨', 'seconds' => null, 'source' => 'battery_removed', ]; } $processes = process_resource_data((int)setting_value('display.process_limit')); $fanSpike = fan_spike_analysis($history, $fan, $system, $processes); $network = network_info(); add_network_usage_log($network); $networkHistory = network_usage_history(1000); $networkQualityTotals = network_quality_totals(); $shouldLogNotice = !empty($fanSpike['alert_started']) || (!empty($fanSpike['active']) && (bool)setting_value('notice.log_during_alert')); if ($shouldLogNotice) { $spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes); if ($spikeLogId > 0) { $fanSpike['log_id'] = $spikeLogId; } } $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(), 'network' => $network, 'network_history' => $networkHistory, 'network_quality_totals' => $networkQualityTotals, 'history' => $history, 'processes' => $processes, 'custom_services' => custom_systemd_services(), 'remote_logs' => remote_log_status(), 'touch_display' => touch_display_status(), 'fan_spike' => $fanSpike, 'fan_spike_history' => fan_spike_history((int)setting_value('display.fan_notice_history_limit')), 'settings' => array_merge(settings_payload(), [ 'touch_display' => touch_display_status(), ]), ]; return $snapshot; } function control_api_dispatch(): void { global $controlApiAction, $controlApiTokenAuthenticated; $action = $controlApiAction; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && !$controlApiTokenAuthenticated) { require_csrf(); } try { if ($action === 'dmesg') { json_out([ 'ok' => true, 'data' => dmesg_log(), ]); } if ($action === 'synology_log') { json_out([ 'ok' => true, 'data' => synology_remote_log(), ]); } if ($action === 'status') { json_out([ 'ok' => true, 'data' => collect_snapshot(false), ]); } if ($action === 'settings') { json_out([ 'ok' => true, 'data' => array_merge(settings_payload(), [ 'touch_display' => touch_display_status(), ]), ]); } 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 === 'touch_display_save') { $raw = (string)($_POST['display'] ?? '{}'); $display = json_decode($raw, true); if (!is_array($display)) { json_out([ 'ok' => false, 'error' => 'bad_display_settings', ], 400); } $result = touch_display_set_config($display); $data = collect_snapshot(false); $data['touch_display_save'] = $result; json_out([ 'ok' => true, 'data' => $data, ]); } if ($action === 'touch_display_runtime_save') { $raw = (string)($_POST['runtime'] ?? '{}'); $runtime = json_decode($raw, true); if (!is_array($runtime)) { json_out([ 'ok' => false, 'error' => 'bad_display_runtime', ], 400); } $result = touch_display_set_runtime($runtime); $data = collect_snapshot(false); $data['touch_display_runtime_save'] = $result; json_out([ 'ok' => true, 'data' => $data, ]); } 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 === 'wake_lan') { $wake = send_wake_on_lan((string)($_POST['mac'] ?? '')); add_fan_action( 'wake_lan', null, null, 'WOL packet sent to ' . $wake['mac'] . ' ' . ($wake['hostname'] ?? ''), true ); json_out([ 'ok' => true, 'data' => [ 'wake' => $wake, 'status' => collect_snapshot(false), ], ]); } if ($action === 'wake_lan_status') { json_out([ 'ok' => true, 'data' => [ 'wake' => wake_lan_status((string)($_GET['mac'] ?? $_POST['mac'] ?? '')), ], ]); } 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(); }