Files
control/public/api.php
T

4691 lines
149 KiB
PHP

<?php
declare(strict_types=1);
$controlApiLibrary = defined('CONTROL_API_LIBRARY') && CONTROL_API_LIBRARY;
if (!$controlApiLibrary && session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../config/config.php';
$controlApiAction = $_GET['action'] ?? $_POST['action'] ?? 'status';
$controlApiTokenScope = in_array($controlApiAction, ['wake_lan', 'wake_lan_status'], true) ? 'wake_lan' : '';
$controlApiTokenAuthenticated = $controlApiTokenScope !== '' && control_api_token_valid($controlApiTokenScope);
if (!$controlApiLibrary && !$controlApiTokenAuthenticated && !signed_in()) {
json_out([
'ok' => false,
'error' => 'login_required',
], 401);
}
function bytes_human(int|float $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$v = (float)$bytes;
$i = 0;
while ($v >= 1024 && $i < count($units) - 1) {
$v /= 1024;
$i++;
}
return ($i === 0 ? (string)(int)$v : number_format($v, 2)) . ' ' . $units[$i];
}
function command_short(string $value, int $limit = 120): string
{
$value = trim(preg_replace('/\s+/', ' ', $value) ?? '');
if ($value === '') {
return 'N/A';
}
return mb_strlen($value) > $limit
? mb_substr($value, 0, $limit - 1) . '...'
: $value;
}
function human_seconds(int $s): string
{
$d = intdiv($s, 86400);
$s %= 86400;
$h = intdiv($s, 3600);
$s %= 3600;
$m = intdiv($s, 60);
return ($d > 0 ? $d . 'd ' : '') . sprintf('%02dh %02dm', $h, $m);
}
function human_remaining_seconds(int $seconds): string
{
if ($seconds <= 0) {
return '-';
}
$hours = intdiv($seconds, 3600);
$minutes = intdiv($seconds % 3600, 60);
if ($hours > 0) {
return sprintf('%dh %02dm', $hours, $minutes);
}
return sprintf('%dm', max(1, $minutes));
}
function dmesg_log(): array
{
$path = '/tmp/dmesg.log';
if (!is_readable($path)) {
return [
'path' => $path,
'available' => false,
'lines' => [],
'line_count' => 0,
'size_bytes' => 0,
'updated_at' => null,
'message' => 'dmesg log is not available yet.',
];
}
$size = filesize($path) ?: 0;
$chunk = file_get_contents($path);
if ($chunk === false) {
return [
'path' => $path,
'available' => false,
'lines' => [],
'line_count' => 0,
'size_bytes' => $size,
'updated_at' => null,
'message' => 'failed to open dmesg log.',
];
}
$lines = array_values(array_filter(
preg_split('/\R/', $chunk) ?: [],
static fn($line): bool => trim((string)$line) !== ''
));
return [
'path' => $path,
'available' => true,
'lines' => array_reverse($lines),
'line_count' => count($lines),
'size_bytes' => $size,
'updated_at' => date('Y-m-d H:i:s', filemtime($path) ?: time()),
];
}
function 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 = [];
foreach (glob('/sys/class/net/*') ?: [] as $dir) {
$name = basename($dir);
if ($name === 'lo') {
continue;
}
$rx = (int)first_readable([$dir . '/statistics/rx_bytes']);
$tx = (int)first_readable([$dir . '/statistics/tx_bytes']);
$rows[] = [
'name' => $name,
'state' => first_readable([$dir . '/operstate']) ?: 'unknown',
'carrier' => first_readable([$dir . '/carrier']) === '1' ? 'up' : 'down',
'mac' => first_readable([$dir . '/address']) ?: 'N/A',
'mtu' => first_readable([$dir . '/mtu']) ?: 'N/A',
'rx_bytes' => $rx,
'tx_bytes' => $tx,
'rx_human' => bytes_human($rx),
'tx_human' => bytes_human($tx),
'ipv4' => trim(sh(['/usr/sbin/ip', '-4', '-o', 'addr', 'show', 'dev', $name], false, 3)['out']) ?: 'N/A',
];
}
return $rows;
}
function latest_sensor(): array
{
$stmt = db()->query("
SELECT *
FROM sensor_logs
ORDER BY id DESC
LIMIT 1
");
return $stmt->fetch() ?: [];
}
function sensor_history(int $limit = 240): array
{
$limit = max(1, min(1500, $limit));
$stmt = db()->query("
SELECT
sl.recorded_at AS time,
sl.cpu_temp_c AS temp_c,
sl.fan_rpm,
sl.fan_efficiency,
sl.rp1_temp_c,
sl.cpu_voltage,
sl.cpu_watts,
sl.battery_voltage,
sl.battery_percent,
sl.pwm_value AS fan_pwm,
sl.pwm_percent,
sl.pwm_mode,
sl.cpu_load_1,
sl.cpu_load_5,
sl.cpu_load_15,
sl.disk_total_kb,
sl.disk_used_kb,
sl.disk_free_kb,
sl.mem_total_mb,
sl.mem_used_mb,
CASE
WHEN sl.mem_total_mb > 0 THEN ROUND(sl.mem_used_mb / sl.mem_total_mb * 100, 1)
ELSE 0
END AS mem_percent
FROM sensor_logs sl
ORDER BY sl.id DESC
LIMIT {$limit}
");
return array_reverse($stmt->fetchAll());
}
function numeric_median(array $values): ?float
{
$numbers = [];
foreach ($values as $value) {
if ($value !== null && $value !== '' && is_numeric($value)) {
$numbers[] = (float)$value;
}
}
if ($numbers === []) {
return null;
}
sort($numbers, SORT_NUMERIC);
$count = count($numbers);
$mid = intdiv($count, 2);
return ($count % 2) === 1
? $numbers[$mid]
: ($numbers[$mid - 1] + $numbers[$mid]) / 2;
}
function numeric_trimmed_average(array $values, float $trimRatio = 0.1): ?float
{
$numbers = [];
foreach ($values as $value) {
if ($value !== null && $value !== '' && is_numeric($value)) {
$number = (float)$value;
if ($number > 0) {
$numbers[] = $number;
}
}
}
if ($numbers === []) {
return null;
}
sort($numbers, SORT_NUMERIC);
$trim = (int)floor(count($numbers) * max(0.0, min(0.4, $trimRatio)));
if ($trim > 0 && count($numbers) > ($trim * 2 + 2)) {
$numbers = array_slice($numbers, $trim, count($numbers) - ($trim * 2));
}
return array_sum($numbers) / count($numbers);
}
function weighted_linear_regression(array $points): ?array
{
$weightSum = 0.0;
$xSum = 0.0;
$ySum = 0.0;
foreach ($points as $point) {
$weight = max(0.0001, (float)($point['weight'] ?? 1.0));
$x = (float)$point['x'];
$y = (float)$point['y'];
$weightSum += $weight;
$xSum += $x * $weight;
$ySum += $y * $weight;
}
if ($weightSum <= 0 || count($points) < 3) {
return null;
}
$xMean = $xSum / $weightSum;
$yMean = $ySum / $weightSum;
$cov = 0.0;
$var = 0.0;
foreach ($points as $point) {
$weight = max(0.0001, (float)($point['weight'] ?? 1.0));
$dx = (float)$point['x'] - $xMean;
$dy = (float)$point['y'] - $yMean;
$cov += $weight * $dx * $dy;
$var += $weight * $dx * $dx;
}
if ($var <= 0) {
return null;
}
$slope = $cov / $var;
return [
'slope' => $slope,
'intercept' => $yMean - ($slope * $xMean),
'weight_sum' => $weightSum,
];
}
function numeric_mad(array $values): ?float
{
$median = numeric_median($values);
if ($median === null) {
return null;
}
$deviations = [];
foreach ($values as $value) {
if ($value !== null && $value !== '' && is_numeric($value)) {
$deviations[] = abs((float)$value - $median);
}
}
return numeric_median($deviations);
}
function battery_trend_history(int $hours = 24): array
{
$hours = max(1, min(48, $hours));
static $cache = [];
$cacheKey = (string)$hours;
$now = time();
if (
isset($cache[$cacheKey])
&& ($now - (int)$cache[$cacheKey]['time']) < 55
&& is_array($cache[$cacheKey]['rows'])
) {
return $cache[$cacheKey]['rows'];
}
$stmt = db()->query("
SELECT
FLOOR(UNIX_TIMESTAMP(recorded_at) / 60) AS bucket,
MIN(recorded_at) AS time,
AVG(battery_percent) AS battery_percent,
AVG(battery_voltage) AS battery_voltage,
AVG(cpu_watts) AS cpu_watts,
AVG(cpu_load_1) AS cpu_load_1,
COUNT(*) AS samples
FROM sensor_logs FORCE INDEX (idx_recorded_at)
WHERE recorded_at >= DATE_SUB(NOW(), INTERVAL {$hours} HOUR)
AND battery_percent IS NOT NULL
GROUP BY bucket
ORDER BY bucket ASC
");
$rows = [];
foreach ($stmt->fetchAll() as $row) {
$time = strtotime((string)($row['time'] ?? ''));
if ($time <= 0 || !is_numeric($row['battery_percent'] ?? null)) {
continue;
}
$rows[] = [
'time' => $time,
'battery_percent' => (float)$row['battery_percent'],
'battery_voltage' => is_numeric($row['battery_voltage'] ?? null) ? (float)$row['battery_voltage'] : null,
'cpu_watts' => is_numeric($row['cpu_watts'] ?? null) ? (float)$row['cpu_watts'] : null,
'cpu_load_1' => is_numeric($row['cpu_load_1'] ?? null) ? (float)$row['cpu_load_1'] : null,
'samples' => (int)($row['samples'] ?? 0),
];
}
$cache[$cacheKey] = [
'time' => $now,
'rows' => $rows,
];
return $rows;
}
function battery_profile_history(int $days = 45): array
{
$days = max(3, min(90, $days));
static $cache = [];
$cacheKey = (string)$days;
$now = time();
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(?<params>(?:,.*)?)\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);
$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(),
'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();
}