commit e1ca2fc125f102ba2268f5b9989a9feae93a3a43 Author: seo Date: Sun Jun 7 00:33:58 2026 +0900 Initial control project import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c13d62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +vendor/ +composer.phar +.agents/ +.codex/ +.env +*.log +*.db +*.sqlite +*.sql +cache/ +tmp/ +secrets/ +secret/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fb9f1f --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Control + +PHP based control panel for fan control, system monitoring, WiFi control, WakeLock, and Web Push notifications. + +## Main Features + +- Authenticated dashboard for fan, sensor, notice, WiFi, WakeLock, and Push subscription status. +- Background collection and policy application for fan state. +- WebSocket status updates with HTTP fallback. +- Web Push subscription registration, recovery, and notice delivery. + +## Main APIs + +- `public/api.php?action=status` +- `public/api.php?action=collect` +- `public/api.php?action=fan` +- `public/api.php?action=wifi` +- `public/api.php?action=push_devices` + +## Structure + +- `public/index.php`: management UI. +- `public/api.php`: status and control API. +- `config/config.php`: DB, auth, CSRF, push, and shell helpers. +- `config/vapid.php`: VAPID bridge loaded from external secret config. +- `apply_policy.php`: CLI/cron fan policy application. +- `bin/control_ws.php`: WebSocket server. + +## Secrets + +Runtime secrets are loaded from `/home/seo/secret/control.php`. Do not commit that file. + +Expected values include the app password, DB settings, and VAPID keys. + +## Operations + +- Keep sensor collection cadence aligned with DB growth. +- Confirm fan sysfs paths after hardware or OS changes. +- Keep VAPID keys and app password outside the repository. + diff --git a/apply_policy.php b/apply_policy.php new file mode 100644 index 0000000..4a090cb --- /dev/null +++ b/apply_policy.php @@ -0,0 +1,272 @@ + $dir, + 'pwm' => $pwm, + 'enable' => $dir . '/pwm1_enable', + 'rpm' => $dir . '/fan1_input', + 'name' => first_readable([$dir . '/name']) ?: 'cooling_fan', + ]; + } + + return [ + 'base' => '', + 'pwm' => '', + 'enable' => '', + 'rpm' => '', + 'name' => '', + ]; +} + +function local_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 local_target(float $temp): int +{ + if ($temp >= 80) { + return 255; + } + + if ($temp <= 50) { + return 0; + } + + $ratio = ($temp - 50) / 30; + + return max(0, min(255, (int)round($ratio * 255))); +} + +function local_ramped_pwm(int $current, int $desired, float $temp): int +{ + if ($temp >= 80) { + return $desired; + } + + if ($desired > $current) { + return min($desired, $current + 2); + } + + if ($desired < $current) { + return max($desired, $current - 2); + } + + return $desired; +} + +function local_mem(): 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); + + return [ + 'total_mb' => (int)round($total / 1024), + 'used_mb' => (int)round($used / 1024), + 'free_mb' => (int)round($available / 1024), + ]; +} + +function local_disk(string $path = '/'): array +{ + $total = @disk_total_space($path) ?: 0; + $free = @disk_free_space($path) ?: 0; + $used = max(0, $total - $free); + + return [ + 'total_kb' => (int)round($total / 1024), + 'used_kb' => (int)round($used / 1024), + 'free_kb' => (int)round($free / 1024), + ]; +} + +function local_uptime_seconds(): int +{ + $raw = trim((string)@file_get_contents('/proc/uptime')); + + if ($raw === '') { + return 0; + } + + return (int)floor((float)explode(' ', $raw)[0]); +} + +function read_int_file(string $file): int +{ + if ($file === '' || !is_readable($file)) { + return 0; + } + + $raw = trim((string)@file_get_contents($file)); + + return is_numeric($raw) ? (int)$raw : 0; +} + +function write_sys_value(string $path, int $value): bool +{ + if ($path === '') { + return false; + } + + return @file_put_contents($path, $value . "\n", LOCK_EX) !== false; +} + +function dmesg_log(string $message): void +{ + $line = '<6>fanpanel: ' . $message . "\n"; + @file_put_contents('/dev/kmsg', $line, FILE_APPEND); +} + +function dmesg_log_throttled(string $key, string $message, int $seconds = 30): void +{ + $file = '/tmp/fanpanel_dmesg_' . preg_replace('/[^a-zA-Z0-9_.-]/', '_', $key) . '.last'; + $now = time(); + $last = is_file($file) ? (int)trim((string)@file_get_contents($file)) : 0; + + if (($now - $last) < $seconds) { + return; + } + + @file_put_contents($file, (string)$now, LOCK_EX); + dmesg_log($message); +} + +try { + $state = get_control_state(); + + $mode = (string)($state['mode'] ?? 'auto'); + $manualPwm = max(0, min(255, (int)($state['manual_pwm'] ?? 120))); + + if (!in_array($mode, ['auto', 'manual', 'off'], true)) { + $mode = 'auto'; + } + + $paths = local_fan_paths(); + $temp = local_temp(); + + $currentPwm = read_int_file($paths['pwm']); + $desired = match ($mode) { + 'manual' => $manualPwm, + 'off' => 0, + default => local_target($temp), + }; + $target = $mode === 'auto' + ? local_ramped_pwm($currentPwm, $desired, $temp) + : $desired; + + $enableOk = write_sys_value($paths['enable'], $mode === 'off' ? 0 : 1); + $pwmOk = write_sys_value($paths['pwm'], $target); + + usleep(80000); + + $actualPwm = read_int_file($paths['pwm']); + + $rpm = read_int_file($paths['rpm']); + + $load = sys_getloadavg() ?: [0, 0, 0]; + $mem = local_mem(); + $disk = local_disk('/'); + $cpuPower = cpu_power_status(); + $battery = battery_status(); + + $ok = $enableOk && $pwmOk; + + add_sensor_log([ + 'cpu_temp_c' => $temp, + 'fan_rpm' => $rpm, + 'fan_efficiency' => fan_efficiency($rpm, $temp, $cpuPower['watts'] ?? null), + 'rp1_temp_c' => rp1_temp_c(), + 'cpu_voltage' => $cpuPower['voltage'], + 'cpu_watts' => $cpuPower['watts'], + 'battery_voltage' => $battery['voltage'], + 'battery_percent' => $battery['percent'], + + 'pwm_value' => $actualPwm, + 'pwm_percent' => round($actualPwm / 255 * 100, 2), + 'pwm_mode' => $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' => local_uptime_seconds(), + + 'hostname' => gethostname() ?: null, + ]); + + send_battery_low_push_if_needed($battery); + + if (!$ok) { + add_fan_action( + 'apply_policy_failed', + $mode, + $target, + 'enable=' . ($enableOk ? 'ok' : 'fail') + . ', pwm=' . ($pwmOk ? 'ok' : 'fail') + . ', path=' . ($paths['pwm'] ?: 'N/A'), + false + ); + } + + exit($ok ? 0 : 1); +} catch (Throwable $e) { + try { + add_fan_action( + 'apply_policy_exception', + null, + null, + $e->getMessage(), + false + ); + } catch (Throwable) { + } + + fwrite(STDERR, $e->getMessage() . PHP_EOL); + exit(1); +} diff --git a/bin/control_ws.php b/bin/control_ws.php new file mode 100644 index 0000000..cf0cc32 --- /dev/null +++ b/bin/control_ws.php @@ -0,0 +1,275 @@ + */ + private SplObjectStorage $clients; + + private bool $statusBusy = false; + private bool $dmesgBusy = false; + + public function __construct() + { + $this->clients = new SplObjectStorage(); + } + + public function onOpen(ConnectionInterface $conn): void + { + $request = $conn->httpRequest ?? null; + $cookies = $this->cookiesFromHeader($request?->getHeaderLine('Cookie') ?? ''); + $ip = $request?->getHeaderLine('X-Real-IP') + ?: $request?->getHeaderLine('X-Forwarded-For') + ?: '127.0.0.1'; + $ua = $request?->getHeaderLine('User-Agent') ?? ''; + + if (!$this->signedIn($cookies, $ip, $ua)) { + $conn->send($this->json([ + 'type' => 'error', + 'message' => 'login_required', + ])); + $conn->close(); + return; + } + + $this->clients[$conn] = [ + 'dmesg' => false, + 'ip' => $ip, + ]; + $this->sendStatus($conn); + } + + public function onMessage(ConnectionInterface $from, $msg): void + { + $data = json_decode((string)$msg, true); + if (!is_array($data)) { + return; + } + + $state = $this->clients[$from] ?? [ + 'dmesg' => false, + 'ip' => '127.0.0.1', + ]; + + if (($data['type'] ?? '') === 'dmesg') { + $state['dmesg'] = !empty($data['open']); + $this->clients[$from] = $state; + if ($state['dmesg']) { + $this->sendDmesg($from); + } + return; + } + + if (($data['type'] ?? '') === 'status_refresh') { + $this->sendStatus($from); + } + } + + public function onClose(ConnectionInterface $conn): void + { + if ($this->clients->contains($conn)) { + $this->clients->detach($conn); + } + } + + public function onError(ConnectionInterface $conn, Exception $e): void + { + $conn->send($this->json([ + 'type' => 'error', + 'message' => $e->getMessage(), + ])); + $conn->close(); + } + + public function broadcastStatus(): void + { + if ($this->clients->count() === 0 || $this->statusBusy) { + return; + } + + $this->statusBusy = true; + try { + $payload = $this->statusPayload(); + foreach ($this->clients as $client) { + $client->send($payload); + } + } catch (Throwable $e) { + $this->broadcastError($e); + } finally { + $this->statusBusy = false; + } + } + + public function broadcastDmesg(): void + { + if ($this->clients->count() === 0 || $this->dmesgBusy) { + return; + } + + $targets = []; + foreach ($this->clients as $client) { + $state = $this->clients[$client]; + if (!empty($state['dmesg'])) { + $targets[] = $client; + } + } + + if ($targets === []) { + return; + } + + $this->dmesgBusy = true; + try { + $payload = $this->json([ + 'type' => 'dmesg', + 'data' => dmesg_log(), + ]); + foreach ($targets as $client) { + $client->send($payload); + } + } catch (Throwable $e) { + $this->broadcastError($e); + } finally { + $this->dmesgBusy = false; + } + } + + private function sendStatus(ConnectionInterface $conn): void + { + try { + $conn->send($this->statusPayload()); + } catch (Throwable $e) { + $conn->send($this->json([ + 'type' => 'error', + 'message' => $e->getMessage(), + ])); + } + } + + private function sendDmesg(ConnectionInterface $conn): void + { + try { + $conn->send($this->json([ + 'type' => 'dmesg', + 'data' => dmesg_log(), + ])); + } catch (Throwable $e) { + $conn->send($this->json([ + 'type' => 'error', + 'message' => $e->getMessage(), + ])); + } + } + + private function statusPayload(): string + { + try { + $data = collect_snapshot(false); + } catch (PDOException $e) { + if (!db_connection_lost($e)) { + throw $e; + } + + db(true); + $data = collect_snapshot(false); + } + + return $this->json([ + 'type' => 'status', + 'data' => $data, + ]); + } + + private function broadcastError(Throwable $e): void + { + $payload = $this->json([ + 'type' => 'error', + 'message' => $e->getMessage(), + ]); + foreach ($this->clients as $client) { + $client->send($payload); + } + } + + /** @return array */ + private function cookiesFromHeader(string $header): array + { + $cookies = []; + foreach (explode(';', $header) as $chunk) { + $parts = explode('=', trim($chunk), 2); + if (count($parts) !== 2 || $parts[0] === '') { + continue; + } + $cookies[$parts[0]] = urldecode($parts[1]); + } + + return $cookies; + } + + /** @param array $cookies */ + private function signedIn(array $cookies, string $ip, string $ua): bool + { + if ( + empty($cookies[session_name()]) + && empty($cookies[REMEMBER_COOKIE]) + ) { + return false; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + $_COOKIE = $cookies; + $_SERVER['REMOTE_ADDR'] = $ip; + $_SERVER['HTTP_USER_AGENT'] = $ua; + $_SERVER['HTTPS'] = 'on'; + + $sessionId = (string)($cookies[session_name()] ?? ''); + if ($sessionId !== '' && preg_match('/^[A-Za-z0-9,-]{16,128}$/', $sessionId)) { + session_id($sessionId); + } + + session_start(); + $ok = signed_in(); + session_write_close(); + + return $ok; + } + + /** @param array $payload */ + private function json(array $payload): string + { + return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'; + } +} + +$address = getenv('CONTROL_WS_ADDRESS') ?: '127.0.0.1:8088'; +$loop = Loop::get(); +$app = new ControlWebSocket(); + +$loop->addPeriodicTimer(1.0, static fn() => $app->broadcastStatus()); +$loop->addPeriodicTimer(1.0, static fn() => $app->broadcastDmesg()); + +$socket = new SocketServer($address, [], $loop); +$server = new IoServer( + new HttpServer(new WsServer($app)), + $socket, + $loop +); + +fwrite(STDOUT, 'Control WebSocket listening on ' . $address . PHP_EOL); +$server->run(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..94c82f7 --- /dev/null +++ b/composer.json @@ -0,0 +1,7 @@ +{ + "require": { + "minishlink/web-push": "^10.0", + "cboden/ratchet": "^0.4", + "react/event-loop": "^1.5" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..5ae09a9 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2012 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "883a7a92feed43973abe8d0f61398105", + "packages": [ + { + "name": "brick/math", + "version": "0.17.1", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", + "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.17.1" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-04-19T20:55:20+00:00" + }, + { + "name": "cboden/ratchet", + "version": "v0.4.4", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/Ratchet.git", + "reference": "5012dc954541b40c5599d286fd40653f5716a38f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/Ratchet/zipball/5012dc954541b40c5599d286fd40653f5716a38f", + "reference": "5012dc954541b40c5599d286fd40653f5716a38f", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=5.4.2", + "ratchet/rfc6455": "^0.3.1", + "react/event-loop": ">=0.4", + "react/socket": "^1.0 || ^0.8 || ^0.7 || ^0.6 || ^0.5", + "symfony/http-foundation": "^2.6|^3.0|^4.0|^5.0|^6.0", + "symfony/routing": "^2.6|^3.0|^4.0|^5.0|^6.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\": "src/Ratchet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "PHP WebSocket library", + "homepage": "http://socketo.me", + "keywords": [ + "Ratchet", + "WebSockets", + "server", + "sockets", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/Ratchet/issues", + "source": "https://github.com/ratchetphp/Ratchet/tree/v0.4.4" + }, + "time": "2021-12-14T00:20:41+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.9.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-03-10T16:41:02+00:00" + }, + { + "name": "minishlink/web-push", + "version": "v10.0.3", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "547695eb42b062517fc604c85d6f7bb8174d31b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/547695eb42b062517fc604c85d6f7bb8174d31b0", + "reference": "547695eb42b062517fc604c85d6f7bb8174d31b0", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.9.2", + "php": ">=8.2", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php83": "^1.33", + "web-token/jwt-library": "^3.4.9|^4.0.6" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.92.2", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", + "symfony/polyfill-iconv": "^1.33" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v10.0.3" + }, + "time": "2026-03-09T23:16:02+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ratchet/rfc6455", + "version": "v0.3.1", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "7c964514e93456a52a99a20fcfa0de242a43ccdb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/7c964514e93456a52a99a20fcfa0de242a43ccdb", + "reference": "7c964514e93456a52a99a20fcfa0de242a43ccdb", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^2 || ^1.7", + "php": ">=5.4.2" + }, + "require-dev": { + "phpunit/phpunit": "^5.7", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.3.1" + }, + "time": "2021-12-09T23:20:49+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31|^0.32", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0|^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-03-23T22:56:56+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v6.4.41", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "48d76c29a67a301e0f7779a512bf76417395ffef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/48d76c29a67a301e0f7779a512bf76417395ffef", + "reference": "48d76c29a67a301e0f7779a512bf76417395ffef", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v6.4.41" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T10:54:17+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T17:25:58+00:00" + }, + { + "name": "symfony/routing", + "version": "v6.4.41", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "af04c79671fd8df0805a44c83fa2b0ba56c8329e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/af04c79671fd8df0805a44c83fa2b0ba56c8329e", + "reference": "af04c79671fd8df0805a44c83fa2b0ba56c8329e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "symfony/config": "<6.2", + "symfony/dependency-injection": "<5.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.2|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v6.4.41" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:18:16+00:00" + }, + { + "name": "web-token/jwt-library", + "version": "4.1.6", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "e8ab00927a3856f3f0c8218226382cd6a58928a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/e8ab00927a3856f3f0c8218226382cd6a58928a1", + "reference": "e8ab00927a3856f3f0c8218226382cd6a58928a1", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.6" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-04-14T07:44:20+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..8925726 --- /dev/null +++ b/config/config.php @@ -0,0 +1,1616 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); +} + +function db(bool $forceReconnect = false): PDO +{ + static $pdo = null; + static $lastPing = 0.0; + + if ($forceReconnect) { + $pdo = null; + $lastPing = 0.0; + } + + if ($pdo instanceof PDO) { + if ((microtime(true) - $lastPing) < 5.0) { + return $pdo; + } + + try { + $pdo->query('SELECT 1'); + $lastPing = microtime(true); + return $pdo; + } catch (PDOException $e) { + if (db_connection_lost($e)) { + $pdo = null; + $lastPing = 0.0; + } else { + throw $e; + } + } + } + + bootstrap_db(); + + $pdo = db_connect(); + $lastPing = microtime(true); + + return $pdo; +} + +function db_connect(): PDO +{ + $pdo = new PDO( + 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4', + DB_USER, + DB_PASS, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + + $pdo->exec("SET time_zone = '+09:00'"); + + return $pdo; +} + +function db_connection_lost(PDOException $e): bool +{ + $info = $e->errorInfo; + $driverCode = isset($info[1]) ? (int)$info[1] : 0; + $message = $e->getMessage(); + + return in_array($driverCode, [2006, 2013], true) + || str_contains($message, 'server has gone away') + || str_contains($message, 'Lost connection'); +} + +function bootstrap_db(): void +{ + static $done = false; + + if ($done) { + return; + } + + $pdo = db_server(); + + $pdo->exec(" + CREATE DATABASE IF NOT EXISTS `" . DB_NAME . "` + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci + "); + + $pdo->exec("USE `" . DB_NAME . "`"); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS control_state ( + id TINYINT UNSIGNED NOT NULL PRIMARY KEY, + mode VARCHAR(16) NOT NULL DEFAULT 'auto', + manual_pwm INT NOT NULL DEFAULT 120, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS fan_actions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + action_type VARCHAR(64) NOT NULL, + pwm_mode VARCHAR(16) NULL, + pwm_value INT NULL, + note TEXT NULL, + actor_ip VARCHAR(64) NULL, + success TINYINT(1) NOT NULL DEFAULT 1, + INDEX idx_created_at (created_at), + INDEX idx_action_type (action_type) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS sensor_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + recorded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + cpu_temp_c DECIMAL(6,2) NULL, + fan_rpm INT NULL, + fan_efficiency DECIMAL(10,2) NULL, + rp1_temp_c DECIMAL(6,2) NULL, + cpu_voltage DECIMAL(6,3) NULL, + cpu_watts DECIMAL(8,3) NULL, + battery_voltage DECIMAL(6,3) NULL, + battery_percent DECIMAL(6,2) NULL, + + pwm_value INT NULL, + pwm_percent DECIMAL(5,2) NULL, + pwm_mode VARCHAR(16) NULL, + + cpu_load_1 DECIMAL(8,2) NULL, + cpu_load_5 DECIMAL(8,2) NULL, + cpu_load_15 DECIMAL(8,2) NULL, + + mem_total_mb INT NULL, + mem_used_mb INT NULL, + mem_free_mb INT NULL, + + disk_total_kb BIGINT NULL, + disk_used_kb BIGINT NULL, + disk_free_kb BIGINT NULL, + + uptime_seconds BIGINT NULL, + + hostname VARCHAR(255) NULL, + create_ip VARCHAR(64) NULL, + + INDEX idx_recorded_at (recorded_at), + INDEX idx_pwm_mode (pwm_mode) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS fan_spike_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + spike_key VARCHAR(32) NULL, + + summary VARCHAR(255) NULL, + + rpm_delta DECIMAL(10,2) NULL, + pwm_delta DECIMAL(10,2) NULL, + temp_delta DECIMAL(10,2) NULL, + + current_rpm INT NULL, + current_pwm INT NULL, + current_temp DECIMAL(6,2) NULL, + + cpu_process LONGTEXT NULL, + memory_process LONGTEXT NULL, + + UNIQUE KEY uniq_spike_key (spike_key), + INDEX idx_created_at (created_at) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS remember_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + selector CHAR(24) NOT NULL, + validator_hash CHAR(64) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + last_used_at DATETIME NULL, + last_ip VARCHAR(64) NULL, + user_agent VARCHAR(255) NULL, + + UNIQUE KEY uniq_selector (selector), + INDEX idx_expires_at (expires_at) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + + endpoint_hash CHAR(64) NOT NULL, + endpoint TEXT NOT NULL, + p256dh VARCHAR(255) NOT NULL, + auth VARCHAR(255) NOT NULL, + content_encoding VARCHAR(32) NOT NULL DEFAULT 'aes128gcm', + + device_name VARCHAR(64) NULL, + user_agent VARCHAR(255) NULL, + actor_ip VARCHAR(64) NULL, + + UNIQUE KEY uniq_endpoint_hash (endpoint_hash), + INDEX idx_last_seen_at (last_seen_at) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS push_event_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + event VARCHAR(32) NOT NULL, + endpoint_hash CHAR(64) NULL, + device_name VARCHAR(64) NULL, + endpoint_host VARCHAR(128) NULL, + actor_ip VARCHAR(64) NULL, + user_agent VARCHAR(255) NULL, + message VARCHAR(255) NULL, + meta JSON NULL, + + INDEX idx_created_at (created_at), + INDEX idx_event (event), + INDEX idx_endpoint_hash (endpoint_hash) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS system_notice_state ( + id TINYINT UNSIGNED NOT NULL PRIMARY KEY, + state VARCHAR(16) NOT NULL DEFAULT 'normal', + + baseline_temp DECIMAL(8,2) NULL, + baseline_rpm DECIMAL(12,2) NULL, + baseline_pwm DECIMAL(8,2) NULL, + + active_reason VARCHAR(255) NULL, + active_temp_delta DECIMAL(8,2) NULL, + active_rpm_delta DECIMAL(12,2) NULL, + process_signature VARCHAR(255) NULL, + alert_started_at DATETIME NULL, + last_alert_at DATETIME NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + + $pdo->exec(" + CREATE TABLE IF NOT EXISTS battery_push_state ( + id TINYINT UNSIGNED NOT NULL PRIMARY KEY, + last_sent_at DATETIME NULL, + last_percent DECIMAL(6,2) NULL, + last_voltage DECIMAL(6,3) NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + + $pdo->exec(" + INSERT IGNORE INTO control_state + (id, mode, manual_pwm) + VALUES + (1, 'auto', 120) + "); + + foreach ([ + "ALTER TABLE sensor_logs ADD COLUMN create_ip VARCHAR(64) NULL", + "ALTER TABLE sensor_logs ADD COLUMN disk_total_kb BIGINT NULL AFTER mem_free_mb", + "ALTER TABLE sensor_logs ADD COLUMN disk_used_kb BIGINT NULL AFTER disk_total_kb", + "ALTER TABLE sensor_logs ADD COLUMN disk_free_kb BIGINT NULL AFTER disk_used_kb", + "ALTER TABLE sensor_logs ADD COLUMN fan_efficiency DECIMAL(10,2) NULL AFTER fan_rpm", + "ALTER TABLE sensor_logs ADD COLUMN rp1_temp_c DECIMAL(6,2) NULL AFTER fan_efficiency", + "ALTER TABLE sensor_logs ADD COLUMN cpu_voltage DECIMAL(6,3) NULL AFTER rp1_temp_c", + "ALTER TABLE sensor_logs ADD COLUMN cpu_watts DECIMAL(8,3) NULL AFTER cpu_voltage", + "ALTER TABLE sensor_logs ADD COLUMN battery_voltage DECIMAL(6,3) NULL AFTER cpu_watts", + "ALTER TABLE sensor_logs ADD COLUMN battery_percent DECIMAL(6,2) NULL AFTER battery_voltage", + "ALTER TABLE sensor_logs DROP COLUMN battery_current_amps", + "ALTER TABLE sensor_logs DROP COLUMN battery_watts", + "ALTER TABLE sensor_logs DROP COLUMN created_ip", + "ALTER TABLE fan_spike_logs ADD COLUMN spike_key VARCHAR(32) NULL AFTER created_at", + "ALTER TABLE fan_spike_logs ADD UNIQUE KEY uniq_spike_key (spike_key)", + "ALTER TABLE system_notice_state ADD COLUMN active_temp_delta DECIMAL(8,2) NULL AFTER active_reason", + "ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta", + "ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta", + "ALTER TABLE push_subscriptions ADD COLUMN device_name VARCHAR(64) NULL AFTER content_encoding", + "ALTER TABLE sensor_logs DROP COLUMN disk_total_gb", + "ALTER TABLE sensor_logs DROP COLUMN disk_used_gb", + "ALTER TABLE sensor_logs DROP COLUMN disk_free_gb", + "ALTER TABLE sensor_logs DROP COLUMN input_voltage", + "ALTER TABLE sensor_logs DROP COLUMN cpu_core_voltage", + "ALTER TABLE sensor_logs DROP COLUMN cpu_core_amps", + "ALTER TABLE sensor_logs DROP COLUMN system_watts", + "ALTER TABLE sensor_logs DROP COLUMN cpu_freq_mhz", + "ALTER TABLE sensor_logs DROP COLUMN cpu_freq_min_mhz", + "ALTER TABLE sensor_logs DROP COLUMN cpu_freq_max_mhz", + "ALTER TABLE sensor_logs DROP COLUMN arm_clock_mhz", + "ALTER TABLE sensor_logs DROP COLUMN core_clock_mhz", + "ALTER TABLE sensor_logs DROP COLUMN isp_clock_mhz", + "ALTER TABLE sensor_logs DROP COLUMN v3d_clock_mhz", + "ALTER TABLE sensor_logs DROP COLUMN emmc_clock_mhz", + "ALTER TABLE sensor_logs DROP COLUMN uart_clock_mhz", + "ALTER TABLE sensor_logs DROP COLUMN hdmi_clock_mhz", + "ALTER TABLE sensor_logs DROP COLUMN sdram_c_voltage", + "ALTER TABLE sensor_logs DROP COLUMN sdram_i_voltage", + "ALTER TABLE sensor_logs DROP COLUMN sdram_p_voltage", + "ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in1_voltage", + "ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in2_voltage", + "ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in3_voltage", + "ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in4_voltage", + "ALTER TABLE sensor_logs DROP COLUMN cooling_state", + "ALTER TABLE sensor_logs DROP COLUMN cooling_max_state", + "ALTER TABLE sensor_logs DROP COLUMN regulator_3v3_voltage", + "ALTER TABLE sensor_logs DROP COLUMN regulator_5v_voltage", + "ALTER TABLE sensor_logs DROP COLUMN regulator_vdd_3v3_voltage", + "ALTER TABLE sensor_logs DROP COLUMN regulator_wl_on", + "ALTER TABLE sensor_logs DROP COLUMN regulator_sd_io_voltage", + "ALTER TABLE sensor_logs DROP COLUMN pcie_link_speed", + "ALTER TABLE sensor_logs DROP COLUMN pcie_link_width", + "ALTER TABLE sensor_logs DROP COLUMN throttled_raw", + "ALTER TABLE sensor_logs DROP COLUMN under_voltage_now", + "ALTER TABLE sensor_logs DROP COLUMN throttled_now", + "ALTER TABLE sensor_logs DROP COLUMN freq_capped_now", + "ALTER TABLE sensor_logs DROP COLUMN soft_temp_limit_now", + "ALTER TABLE sensor_logs DROP COLUMN under_voltage_seen", + "ALTER TABLE sensor_logs DROP COLUMN throttled_seen", + "ALTER TABLE sensor_logs DROP COLUMN freq_capped_seen", + "ALTER TABLE sensor_logs DROP COLUMN soft_temp_limit_seen", + "ALTER TABLE sensor_logs DROP COLUMN ac_power_ok", + "ALTER TABLE sensor_logs DROP COLUMN charging_enabled", + "ALTER TABLE fan_spike_logs DROP COLUMN io_process", + "DROP TABLE top_process_logs", + "DROP TABLE wifi_station_logs", + "DROP TABLE software_status_logs", + "DROP TABLE helper_command_logs", + "DROP TABLE service_units", + "DROP TABLE failed_units", + "DROP TABLE timer_units", + "DROP TABLE journal_warning_logs", + "DROP TABLE installed_packages", + "DROP TABLE upgradable_packages", + ] as $sql) { + try { + $pdo->exec($sql); + } catch (Throwable) { + } + } + + $done = true; +} + +bootstrap_db(); + +function e(mixed $value): string +{ + return htmlspecialchars( + (string)$value, + ENT_QUOTES | ENT_SUBSTITUTE, + 'UTF-8' + ); +} + +function json_out(array $payload, int $status = 200): never +{ + http_response_code($status); + + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + + echo json_encode( + $payload, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + + exit; +} + +function vapid_public_key(): string +{ + return defined('VAPID_PUBLIC_KEY') ? VAPID_PUBLIC_KEY : ''; +} + +function push_subscription_from_json(string $raw): array +{ + $data = json_decode($raw, true); + + return is_array($data) ? $data : []; +} + +function push_log_event(string $event, array $context = []): void +{ + try { + $endpoint = (string)($context['endpoint'] ?? ''); + $endpointHash = (string)($context['endpoint_hash'] ?? ''); + if ($endpointHash === '' && $endpoint !== '') { + $endpointHash = hash('sha256', $endpoint); + } + + $stmt = db()->prepare(" + INSERT INTO push_event_logs ( + event, + endpoint_hash, + device_name, + endpoint_host, + actor_ip, + user_agent, + message, + meta + ) VALUES ( + :event, + :endpoint_hash, + :device_name, + :endpoint_host, + :actor_ip, + :user_agent, + :message, + :meta + ) + "); + + unset($context['endpoint'], $context['p256dh'], $context['auth']); + $stmt->execute([ + ':event' => mb_substr($event, 0, 32), + ':endpoint_hash' => $endpointHash !== '' ? $endpointHash : null, + ':device_name' => isset($context['device_name']) ? mb_substr((string)$context['device_name'], 0, 64) : null, + ':endpoint_host' => $endpoint !== '' ? mb_substr((string)(parse_url($endpoint, PHP_URL_HOST) ?: ''), 0, 128) : null, + ':actor_ip' => mb_substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64), + ':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ':message' => isset($context['message']) ? mb_substr((string)$context['message'], 0, 255) : null, + ':meta' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + } catch (Throwable) { + } +} + +function save_push_subscription(array $subscription): void +{ + $endpoint = (string)($subscription['endpoint'] ?? ''); + $keys = is_array($subscription['keys'] ?? null) ? $subscription['keys'] : []; + $p256dh = (string)($keys['p256dh'] ?? ''); + $auth = (string)($keys['auth'] ?? ''); + $deviceName = trim((string)($subscription['device_name'] ?? '')); + preg_match_all('/[\x{AC00}-\x{D7A3}\x{3131}-\x{318E}]/u', $deviceName, $hangulMatches); + $hangulCount = count($hangulMatches[0] ?? []); + + if ($endpoint === '' || $p256dh === '' || $auth === '') { + json_out([ + 'ok' => false, + 'error' => 'invalid_subscription', + ], 422); + } + + if ($deviceName === '' || $hangulCount < 2) { + json_out([ + 'ok' => false, + 'error' => 'invalid_device_name', + 'message' => '기기 이름은 한글 2글자 이상이어야 합니다.', + ], 422); + } + + $endpointHash = hash('sha256', $endpoint); + $exists = false; + try { + $check = db()->prepare("SELECT 1 FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash LIMIT 1"); + $check->execute([':endpoint_hash' => $endpointHash]); + $exists = (bool)$check->fetchColumn(); + } catch (Throwable) { + } + + $stmt = db()->prepare(" + INSERT INTO push_subscriptions ( + endpoint_hash, + endpoint, + p256dh, + auth, + content_encoding, + device_name, + user_agent, + actor_ip + ) VALUES ( + :endpoint_hash, + :endpoint, + :p256dh, + :auth, + :content_encoding, + :device_name, + :user_agent, + :actor_ip + ) + ON DUPLICATE KEY UPDATE + endpoint = VALUES(endpoint), + p256dh = VALUES(p256dh), + auth = VALUES(auth), + content_encoding = VALUES(content_encoding), + device_name = VALUES(device_name), + user_agent = VALUES(user_agent), + actor_ip = VALUES(actor_ip), + last_seen_at = CURRENT_TIMESTAMP + "); + + $stmt->execute([ + ':endpoint_hash' => $endpointHash, + ':endpoint' => $endpoint, + ':p256dh' => $p256dh, + ':auth' => $auth, + ':content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'), + ':device_name' => mb_substr($deviceName, 0, 64), + ':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ':actor_ip' => $_SERVER['REMOTE_ADDR'] ?? null, + ]); + + push_log_event($exists ? 'register_update' : 'register', [ + 'endpoint' => $endpoint, + 'device_name' => $deviceName, + 'content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'), + ]); +} + +function push_device_rows(): array +{ + $stmt = db()->query(" + SELECT + endpoint_hash, + endpoint, + device_name, + content_encoding, + user_agent, + actor_ip, + created_at, + last_seen_at + FROM push_subscriptions + ORDER BY last_seen_at DESC + LIMIT 200 + "); + + $rows = []; + foreach ($stmt->fetchAll() as $row) { + $endpoint = (string)($row['endpoint'] ?? ''); + $rows[] = [ + 'hash' => (string)($row['endpoint_hash'] ?? hash('sha256', $endpoint)), + 'endpoint' => $endpoint, + 'host' => parse_url($endpoint, PHP_URL_HOST) ?: 'unknown', + 'device_name' => (string)($row['device_name'] ?? ''), + 'content_encoding' => (string)($row['content_encoding'] ?? ''), + 'user_agent' => (string)($row['user_agent'] ?? ''), + 'actor_ip' => (string)($row['actor_ip'] ?? ''), + 'created_at' => (string)($row['created_at'] ?? ''), + 'last_seen_at' => (string)($row['last_seen_at'] ?? ''), + ]; + } + + return $rows; +} + +function push_subscription_status(string $endpoint): array +{ + $endpointHash = $endpoint !== '' ? hash('sha256', $endpoint) : ''; + $matched = null; + + if ($endpointHash !== '') { + $stmt = db()->prepare(" + SELECT device_name + FROM push_subscriptions + WHERE endpoint_hash = :endpoint_hash + LIMIT 1 + "); + $stmt->execute([':endpoint_hash' => $endpointHash]); + $row = $stmt->fetch(); + if ($row) { + $matched = $row; + } + } + + $count = (int)db()->query("SELECT COUNT(*) FROM push_subscriptions")->fetchColumn(); + + return [ + 'subscriber_count' => $count, + 'subscribed' => $matched !== null, + 'device_name' => $matched['device_name'] ?? null, + ]; +} + +function delete_push_device(string $endpointHash): void +{ + if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) { + json_out([ + 'ok' => false, + 'error' => 'invalid_push_device', + ], 422); + } + + $stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash"); + $stmt->execute([':endpoint_hash' => $endpointHash]); + push_log_event('delete_device', [ + 'endpoint_hash' => $endpointHash, + 'deleted' => $stmt->rowCount(), + ]); +} + +function delete_push_endpoint(string $endpoint): void +{ + if ($endpoint === '') { + json_out([ + 'ok' => false, + 'error' => 'invalid_push_endpoint', + ], 422); + } + + $stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash"); + $endpointHash = hash('sha256', $endpoint); + $stmt->execute([':endpoint_hash' => $endpointHash]); + push_log_event('unregister', [ + 'endpoint' => $endpoint, + 'endpoint_hash' => $endpointHash, + 'deleted' => $stmt->rowCount(), + ]); +} + +function send_push_payload(array $payload): array +{ + $pushId = (string)($payload['push_id'] ?? ''); + if ($pushId === '') { + $pushId = bin2hex(random_bytes(12)); + $payload['push_id'] = $pushId; + } + $tag = (string)($payload['tag'] ?? ''); + + if ( + !class_exists(\Minishlink\WebPush\WebPush::class) + || !defined('VAPID_PUBLIC_KEY') + || !defined('VAPID_PRIVATE_KEY') + ) { + push_log_event('send_config_missing', [ + 'message' => 'web_push_not_configured', + 'push_id' => $pushId, + 'tag' => $tag, + ]); + return [ + 'sent' => 0, + 'failed' => 0, + 'error' => 'web_push_not_configured', + ]; + } + + $stmt = db()->query(" + SELECT endpoint, p256dh, auth, content_encoding + FROM push_subscriptions + ORDER BY last_seen_at DESC + LIMIT 200 + "); + $rows = $stmt->fetchAll(); + + if ($rows === []) { + push_log_event('send_no_subscribers', [ + 'message' => $pushId, + 'push_id' => $pushId, + 'tag' => $tag, + ]); + return [ + 'sent' => 0, + 'failed' => 0, + 'error' => null, + ]; + } + + $webPush = new \Minishlink\WebPush\WebPush([ + 'VAPID' => [ + 'subject' => 'mailto:admin@' . APP_HOST, + 'publicKey' => VAPID_PUBLIC_KEY, + 'privateKey' => VAPID_PRIVATE_KEY, + ], + ]); + + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $sent = 0; + $failed = 0; + + push_log_event('send_request', [ + 'message' => $pushId, + 'push_id' => $pushId, + 'tag' => $tag, + 'subscriber_count' => count($rows), + ]); + + foreach ($rows as $row) { + $subscription = \Minishlink\WebPush\Subscription::create([ + 'endpoint' => (string)$row['endpoint'], + 'publicKey' => (string)$row['p256dh'], + 'authToken' => (string)$row['auth'], + 'contentEncoding' => (string)($row['content_encoding'] ?: 'aes128gcm'), + ]); + + $webPush->queueNotification($subscription, $json); + } + + foreach ($webPush->flush() as $report) { + $endpoint = $report->getEndpoint(); + if ($report->isSuccess()) { + $sent++; + push_log_event('send_success', [ + 'endpoint' => $endpoint, + 'message' => $pushId, + 'push_id' => $pushId, + 'tag' => $tag, + ]); + continue; + } + + $failed++; + $reason = method_exists($report, 'getReason') ? (string)$report->getReason() : 'send_failed'; + + if ($report->isSubscriptionExpired()) { + $delete = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash"); + $delete->execute([':endpoint_hash' => hash('sha256', $endpoint)]); + push_log_event('send_expired', [ + 'endpoint' => $endpoint, + 'message' => $pushId, + 'push_id' => $pushId, + 'tag' => $tag, + 'reason' => $reason, + ]); + continue; + } + + push_log_event('send_failed', [ + 'endpoint' => $endpoint, + 'message' => $pushId, + 'push_id' => $pushId, + 'tag' => $tag, + 'reason' => $reason, + ]); + } + + return [ + 'sent' => $sent, + 'failed' => $failed, + 'error' => null, + ]; +} + +function reset_battery_low_push_state(): void +{ + $stmt = db()->prepare(" + INSERT INTO battery_push_state + (id, last_sent_at, last_percent, last_voltage) + VALUES + (1, NULL, NULL, NULL) + ON DUPLICATE KEY UPDATE + last_sent_at = NULL, + last_percent = NULL, + last_voltage = NULL + "); + $stmt->execute(); +} + +function battery_low_push_due(float $percent, ?float $voltage): bool +{ + $stmt = db()->query(" + SELECT last_sent_at + FROM battery_push_state + WHERE id = 1 + LIMIT 1 + "); + $row = $stmt->fetch() ?: []; + $lastSent = strtotime((string)($row['last_sent_at'] ?? '')) ?: 0; + + if ($lastSent > 0 && time() - $lastSent < 10) { + return false; + } + + $save = db()->prepare(" + INSERT INTO battery_push_state + (id, last_sent_at, last_percent, last_voltage) + VALUES + (1, CURRENT_TIMESTAMP, :last_percent, :last_voltage) + ON DUPLICATE KEY UPDATE + last_sent_at = CURRENT_TIMESTAMP, + last_percent = VALUES(last_percent), + last_voltage = VALUES(last_voltage) + "); + $save->execute([ + ':last_percent' => round($percent, 2), + ':last_voltage' => $voltage === null ? null : round($voltage, 3), + ]); + + return true; +} + +function battery_soc_rising_from_history(float $percent): bool +{ + $stmt = db()->query(" + SELECT battery_percent + FROM sensor_logs + WHERE battery_percent IS NOT NULL + ORDER BY id DESC + LIMIT 20 + "); + $rows = array_reverse($stmt->fetchAll()); + + if (count($rows) < 8) { + return false; + } + + $values = []; + foreach ($rows as $row) { + $value = $row['battery_percent'] ?? null; + if ($value === null || $value === '' || !is_numeric($value)) { + continue; + } + $values[] = (float)$value; + } + + if (count($values) < 8) { + return false; + } + + $older = array_slice($values, 0, 5); + $recent = array_slice($values, -5); + $olderAvg = array_sum($older) / count($older); + $recentAvg = array_sum($recent) / count($recent); + + return ($recentAvg - $olderAvg) >= 0.20 + && ($percent - $olderAvg) >= 0.15; +} + +function send_battery_low_push_if_needed(array $battery): array +{ + $percent = $battery['percent'] ?? null; + if ($percent === null || $percent === '' || !is_numeric($percent)) { + return [ + 'sent' => 0, + 'failed' => 0, + 'skipped' => 'battery_soc_missing', + ]; + } + + $percent = (float)$percent; + $voltage = isset($battery['voltage']) && is_numeric($battery['voltage']) + ? (float)$battery['voltage'] + : null; + + if ($percent > 20.0) { + reset_battery_low_push_state(); + return [ + 'sent' => 0, + 'failed' => 0, + 'skipped' => 'battery_soc_ok', + ]; + } + + if (battery_soc_rising_from_history($percent)) { + return [ + 'sent' => 0, + 'failed' => 0, + 'skipped' => 'battery_soc_rising', + ]; + } + + if (!battery_low_push_due($percent, $voltage)) { + return [ + 'sent' => 0, + 'failed' => 0, + 'skipped' => 'battery_low_cooldown', + ]; + } + + $body = 'Battery SOC ' + . number_format($percent, 2) + . '%' + . ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V') + . "\n20% 이하 상태입니다."; + + return send_push_payload([ + 'title' => '배터리 SOC 경고', + 'body' => $body, + 'url' => '/', + 'tag' => 'control-battery-low', + 'renotify' => false, + 'require_interaction' => true, + 'silent' => false, + 'vibrate' => [900, 250, 900, 250, 1400], + 'created_at' => date('Y-m-d H:i:s'), + 'data' => [ + 'battery_percent' => round($percent, 2), + 'battery_voltage' => $voltage === null ? null : round($voltage, 3), + ], + ]); +} + +function signed_in(): bool +{ + if (isset($_SESSION['control_login']) && $_SESSION['control_login'] === true) { + return true; + } + + return auto_login_from_cookie(); +} + +function require_login(): void +{ + if (!signed_in()) { + header('Location: /'); + exit; + } +} + +function remember_cookie_options(int $expires): array +{ + return [ + 'expires' => $expires, + 'path' => '/', + 'domain' => '', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'httponly' => true, + 'samesite' => 'Lax', + ]; +} + +function issue_remember_token(): void +{ + $selector = bin2hex(random_bytes(12)); + $validator = bin2hex(random_bytes(32)); + $expires = time() + REMEMBER_DAYS * 86400; + + $stmt = db()->prepare(" + INSERT INTO remember_tokens + ( + selector, + validator_hash, + expires_at, + last_ip, + user_agent + ) + VALUES + ( + :selector, + :validator_hash, + FROM_UNIXTIME(:expires_at), + :last_ip, + :user_agent + ) + "); + + $stmt->execute([ + ':selector' => $selector, + ':validator_hash' => hash('sha256', $validator), + ':expires_at' => $expires, + ':last_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli', + ':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + + setcookie( + REMEMBER_COOKIE, + $selector . ':' . $validator, + remember_cookie_options($expires) + ); + + db()->exec(" + DELETE FROM remember_tokens + WHERE expires_at < NOW() + "); +} + +function clear_remember_token(): void +{ + $raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? ''); + $parts = explode(':', $raw, 2); + + if (count($parts) === 2 && preg_match('/^[a-f0-9]{24}$/', $parts[0])) { + $stmt = db()->prepare(" + DELETE FROM remember_tokens + WHERE selector = :selector + "); + + $stmt->execute([ + ':selector' => $parts[0], + ]); + } + + setcookie( + REMEMBER_COOKIE, + '', + remember_cookie_options(time() - 3600) + ); + + unset($_COOKIE[REMEMBER_COOKIE]); +} + +function auto_login_from_cookie(): bool +{ + $raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? ''); + + if ($raw === '') { + return false; + } + + $parts = explode(':', $raw, 2); + + if ( + count($parts) !== 2 + || !preg_match('/^[a-f0-9]{24}$/', $parts[0]) + || !preg_match('/^[a-f0-9]{64}$/', $parts[1]) + ) { + clear_remember_token(); + return false; + } + + [$selector, $validator] = $parts; + + $stmt = db()->prepare(" + SELECT + id, + validator_hash, + UNIX_TIMESTAMP(expires_at) AS expires_ts + FROM remember_tokens + WHERE selector = :selector + LIMIT 1 + "); + + $stmt->execute([ + ':selector' => $selector, + ]); + + $row = $stmt->fetch(); + + if (!$row || (int)$row['expires_ts'] < time()) { + clear_remember_token(); + return false; + } + + if (!hash_equals((string)$row['validator_hash'], hash('sha256', $validator))) { + clear_remember_token(); + return false; + } + + $_SESSION['control_login'] = true; + + $stmt = db()->prepare(" + UPDATE remember_tokens + SET + last_used_at = NOW(), + last_ip = :last_ip, + user_agent = :user_agent + WHERE id = :id + "); + + $stmt->execute([ + ':id' => $row['id'], + ':last_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli', + ':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + + return true; +} + +function csrf_token(): string +{ + if (empty($_SESSION['csrf'])) { + $_SESSION['csrf'] = bin2hex(random_bytes(24)); + } + + return (string)$_SESSION['csrf']; +} + +function require_csrf(): void +{ + $token = $_POST['csrf'] + ?? $_SERVER['HTTP_X_CSRF_TOKEN'] + ?? ''; + + if (!hash_equals(csrf_token(), (string)$token)) { + json_out([ + 'ok' => false, + 'error' => 'bad_csrf', + ], 403); + } +} + +function sh(array $cmd, bool $root = false, int $timeout = 8): array +{ + $full = $cmd; + + if ( + $root + && function_exists('posix_geteuid') + && posix_geteuid() !== 0 + ) { + $full = array_merge( + ['/usr/bin/sudo', '-n'], + $cmd + ); + } + + $escaped = array_map('escapeshellarg', $full); + + $command = + '/usr/bin/timeout ' + . (int)$timeout + . 's ' + . implode(' ', $escaped) + . ' 2>&1'; + + $output = []; + $code = 0; + + exec($command, $output, $code); + + return [ + 'code' => $code, + 'out' => implode("\n", $output), + ]; +} + +function first_readable(array $files): string +{ + foreach ($files as $file) { + if (!is_readable($file)) { + continue; + } + + $value = trim((string)@file_get_contents($file)); + + if ($value !== '') { + return $value; + } + } + + return ''; +} + +function get_control_state(): array +{ + $stmt = db()->query(" + SELECT * + FROM control_state + WHERE id = 1 + LIMIT 1 + "); + + $row = $stmt->fetch() ?: []; + + return [ + 'mode' => $row['mode'] ?? 'auto', + 'manual_pwm' => isset($row['manual_pwm']) ? (int)$row['manual_pwm'] : 120, + 'updated_at' => $row['updated_at'] ?? null, + ]; +} + +function set_control_state(string $mode, int $manualPwm): void +{ + if (!in_array($mode, ['auto', 'manual', 'off'], true)) { + $mode = 'auto'; + } + + $manualPwm = max(0, min(255, $manualPwm)); + + $stmt = db()->prepare(" + INSERT INTO control_state + (id, mode, manual_pwm) + VALUES + (1, :mode, :manual_pwm) + ON DUPLICATE KEY UPDATE + mode = VALUES(mode), + manual_pwm = VALUES(manual_pwm), + updated_at = CURRENT_TIMESTAMP + "); + + $stmt->execute([ + ':mode' => $mode, + ':manual_pwm' => $manualPwm, + ]); +} + +function rp1_temp_c(): ?float +{ + foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $dir) { + if (trim(first_readable([$dir . '/name'])) !== 'rp1_adc') { + continue; + } + + $raw = first_readable([$dir . '/temp1_input']); + if ($raw !== '' && is_numeric($raw)) { + return round(((float)$raw) / 1000, 2); + } + } + + return null; +} + +function cpu_power_status(): array +{ + $out = sh(['/usr/bin/vcgencmd', 'pmic_read_adc'], false, 3)['out']; + $voltage = null; + $amps = null; + + foreach (preg_split('/\R/', trim($out)) ?: [] as $line) { + if (preg_match('/VDD_CORE_V\\s+[^=]+=([-+]?[0-9]*\\.?[0-9]+)V/', $line, $m)) { + $voltage = (float)$m[1]; + } elseif (preg_match('/VDD_CORE_A\\s+[^=]+=([-+]?[0-9]*\\.?[0-9]+)A/', $line, $m)) { + $amps = (float)$m[1]; + } + } + + return [ + 'voltage' => $voltage === null ? null : round($voltage, 3), + 'amps' => $amps === null ? null : round($amps, 3), + 'watts' => ($voltage === null || $amps === null) ? null : round($voltage * $amps, 3), + ]; +} + +function i2cget_byte(int $bus, int $address, int $register): ?int +{ + $bin = null; + + foreach (['/usr/sbin/i2cget', '/usr/bin/i2cget'] as $candidate) { + if (is_executable($candidate)) { + $bin = $candidate; + break; + } + } + + if ($bin === null) { + return null; + } + + $result = sh([ + $bin, + '-y', + (string)$bus, + sprintf('0x%02x', $address), + sprintf('0x%02x', $register), + ], true, 2); + + if ((int)$result['code'] !== 0) { + return null; + } + + $raw = trim((string)$result['out']); + if (!preg_match('/0x([0-9a-f]{1,2})/i', $raw, $m)) { + return null; + } + + return hexdec($m[1]); +} + +function i2cget_word_bytes(int $bus, int $address, int $register): ?array +{ + $msb = i2cget_byte($bus, $address, $register); + $lsb = i2cget_byte($bus, $address, $register + 1); + + if ($msb === null || $lsb === null) { + return null; + } + + return [$msb, $lsb]; +} + +function battery_status(): array +{ + $voltage = null; + $percent = null; + + $vcell = i2cget_word_bytes(1, 0x36, 0x02); + if ($vcell !== null) { + [$msb, $lsb] = $vcell; + $raw = ($msb << 4) | ($lsb >> 4); + $voltage = round($raw * 0.00125, 3); + } + + $soc = i2cget_word_bytes(1, 0x36, 0x04); + if ($soc !== null) { + [$msb, $lsb] = $soc; + $percent = round((($msb << 8) | $lsb) / 256, 2); + } + + return [ + 'voltage' => $voltage, + 'percent' => $percent, + ]; +} + +function fan_efficiency(?int $rpm, ?float $cpuTemp, ?float $cpuWatts): ?int +{ + if ( + $rpm === null + || $cpuTemp === null + || $cpuWatts === null + || $cpuWatts <= 0 + ) { + return null; + } + + if ($rpm <= 0) { + return 0; + } + + $ambientTemp = 25.0; + $maxRpm = 10000.0; + + $fanRatio = max(0.0, min(1.0, $rpm / $maxRpm)); + $deltaTemp = max(0.1, $cpuTemp - $ambientTemp); + $thermalResistance = $deltaTemp / max($cpuWatts, 0.1); + + // 핵심: °C/W 낮을수록 좋음 + if ($thermalResistance <= 6.0) { + $thermalScore = 100; + } elseif ($thermalResistance <= 7.0) { + $thermalScore = 95; + } elseif ($thermalResistance <= 8.0) { + $thermalScore = 88; + } elseif ($thermalResistance <= 9.0) { + $thermalScore = 80; + } elseif ($thermalResistance <= 10.0) { + $thermalScore = 72; + } elseif ($thermalResistance <= 11.0) { + $thermalScore = 64; + } elseif ($thermalResistance <= 12.0) { + $thermalScore = 56; + } elseif ($thermalResistance <= 13.0) { + $thermalScore = 48; + } elseif ($thermalResistance <= 14.0) { + $thermalScore = 40; + } elseif ($thermalResistance <= 16.0) { + $thermalScore = 30; + } elseif ($thermalResistance <= 18.0) { + $thermalScore = 20; + } else { + $thermalScore = 10; + } + + // 목표 온도: 58~65°C + if ($cpuTemp < 45.0) { + $tempScore = 70; + } elseif ($cpuTemp < 50.0) { + $tempScore = 80; + } elseif ($cpuTemp < 55.0) { + $tempScore = 90; + } elseif ($cpuTemp < 58.0) { + $tempScore = 96; + } elseif ($cpuTemp < 65.0) { + $tempScore = 100; + } elseif ($cpuTemp < 70.0) { + $tempScore = 88; + } elseif ($cpuTemp < 75.0) { + $tempScore = 72; + } elseif ($cpuTemp < 80.0) { + $tempScore = 52; + } elseif ($cpuTemp < 85.0) { + $tempScore = 30; + } else { + $tempScore = 10; + } + + // RPM은 비용. 단, 고온에서 고RPM은 정상이라 과하게 깎지 않음. + if ($fanRatio < 0.20) { + $fanScore = 85; + } elseif ($fanRatio < 0.40) { + $fanScore = 100; + } elseif ($fanRatio < 0.60) { + $fanScore = 92; + } elseif ($fanRatio < 0.75) { + $fanScore = 82; + } elseif ($fanRatio < 0.90) { + $fanScore = 70; + } else { + $fanScore = 58; + } + + $penalty = 0.0; + $bonus = 0.0; + + // 고RPM인데도 온도가 높으면 비효율 + if ($fanRatio >= 0.85 && $cpuTemp >= 80.0) { + $penalty += 18; + } elseif ($fanRatio >= 0.75 && $cpuTemp >= 75.0) { + $penalty += 10; + } + + // 저부하인데 팬이 너무 높으면 낭비 + if ($cpuWatts < 2.0 && $fanRatio >= 0.70) { + $penalty += 12; + } elseif ($cpuWatts < 3.0 && $fanRatio >= 0.85) { + $penalty += 8; + } + + // 차가운데 팬이 높으면 과냉각 + if ($cpuTemp < 55.0 && $fanRatio >= 0.70) { + $penalty += 12; + } + + // 고부하인데 온도 잘 잡으면 보너스 + if ($cpuWatts >= 5.0 && $cpuTemp <= 68.0 && $fanRatio <= 0.75) { + $bonus += 8; + } elseif ($cpuWatts >= 4.0 && $cpuTemp <= 65.0 && $fanRatio <= 0.65) { + $bonus += 5; + } + + $score = + ($thermalScore * 0.65) + + ($tempScore * 0.25) + + ($fanScore * 0.10) + - $penalty + + $bonus; + + return max(0, min(100, (int)round($score))); +} + +function add_fan_action( + string $type, + ?string $mode, + ?int $pwm, + ?string $note, + bool $success = true +): void { + $stmt = db()->prepare(" + INSERT INTO fan_actions + ( + action_type, + pwm_mode, + pwm_value, + note, + actor_ip, + success + ) + VALUES + ( + :action_type, + :pwm_mode, + :pwm_value, + :note, + :actor_ip, + :success + ) + "); + + $stmt->execute([ + ':action_type' => $type, + ':pwm_mode' => $mode, + ':pwm_value' => $pwm, + ':note' => $note, + ':actor_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli', + ':success' => $success ? 1 : 0, + ]); +} + +function add_sensor_log(array $r): void +{ + $stmt = db()->prepare(" + INSERT INTO sensor_logs + ( + cpu_temp_c, + fan_rpm, + fan_efficiency, + rp1_temp_c, + cpu_voltage, + cpu_watts, + battery_voltage, + battery_percent, + + pwm_value, + pwm_percent, + pwm_mode, + + cpu_load_1, + cpu_load_5, + cpu_load_15, + + mem_total_mb, + mem_used_mb, + mem_free_mb, + + disk_total_kb, + disk_used_kb, + disk_free_kb, + + uptime_seconds, + + hostname, + create_ip + ) + VALUES + ( + :cpu_temp_c, + :fan_rpm, + :fan_efficiency, + :rp1_temp_c, + :cpu_voltage, + :cpu_watts, + :battery_voltage, + :battery_percent, + + :pwm_value, + :pwm_percent, + :pwm_mode, + + :cpu_load_1, + :cpu_load_5, + :cpu_load_15, + + :mem_total_mb, + :mem_used_mb, + :mem_free_mb, + + :disk_total_kb, + :disk_used_kb, + :disk_free_kb, + + :uptime_seconds, + + :hostname, + :create_ip + ) + "); + + $stmt->execute([ + ':cpu_temp_c' => $r['cpu_temp_c'] ?? null, + ':fan_rpm' => $r['fan_rpm'] ?? null, + ':fan_efficiency' => $r['fan_efficiency'] ?? null, + ':rp1_temp_c' => $r['rp1_temp_c'] ?? null, + ':cpu_voltage' => $r['cpu_voltage'] ?? null, + ':cpu_watts' => $r['cpu_watts'] ?? null, + ':battery_voltage' => $r['battery_voltage'] ?? null, + ':battery_percent' => $r['battery_percent'] ?? null, + + ':pwm_value' => $r['pwm_value'] ?? null, + ':pwm_percent' => $r['pwm_percent'] ?? null, + ':pwm_mode' => $r['pwm_mode'] ?? null, + + ':cpu_load_1' => $r['cpu_load_1'] ?? null, + ':cpu_load_5' => $r['cpu_load_5'] ?? null, + ':cpu_load_15' => $r['cpu_load_15'] ?? null, + + ':mem_total_mb' => $r['mem_total_mb'] ?? null, + ':mem_used_mb' => $r['mem_used_mb'] ?? null, + ':mem_free_mb' => $r['mem_free_mb'] ?? null, + + ':disk_total_kb' => $r['disk_total_kb'] ?? null, + ':disk_used_kb' => $r['disk_used_kb'] ?? null, + ':disk_free_kb' => $r['disk_free_kb'] ?? null, + + ':uptime_seconds' => $r['uptime_seconds'] ?? null, + + ':hostname' => $r['hostname'] ?? null, + ':create_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli', + ]); +} diff --git a/config/vapid.php b/config/vapid.php new file mode 100644 index 0000000..8b52467 --- /dev/null +++ b/config/vapid.php @@ -0,0 +1,13 @@ + false, + 'error' => 'login_required', + ], 401); +} + +function bytes_human(int|float $bytes): string +{ + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $v = (float)$bytes; + $i = 0; + + while ($v >= 1024 && $i < count($units) - 1) { + $v /= 1024; + $i++; + } + + return ($i === 0 ? (string)(int)$v : number_format($v, 2)) . ' ' . $units[$i]; +} + +function command_short(string $value, int $limit = 120): string +{ + $value = trim(preg_replace('/\s+/', ' ', $value) ?? ''); + + if ($value === '') { + return 'N/A'; + } + + return mb_strlen($value) > $limit + ? mb_substr($value, 0, $limit - 1) . '...' + : $value; +} + +function human_seconds(int $s): string +{ + $d = intdiv($s, 86400); + $s %= 86400; + + $h = intdiv($s, 3600); + $s %= 3600; + + $m = intdiv($s, 60); + + return ($d > 0 ? $d . 'd ' : '') . sprintf('%02dh %02dm', $h, $m); +} + +function human_remaining_seconds(int $seconds): string +{ + if ($seconds <= 0) { + return '-'; + } + + $hours = intdiv($seconds, 3600); + $minutes = intdiv($seconds % 3600, 60); + + if ($hours > 0) { + return sprintf('%dh %02dm', $hours, $minutes); + } + + return sprintf('%dm', max(1, $minutes)); +} + +function dmesg_log(): array +{ + $path = '/tmp/dmesg.log'; + + if (!is_readable($path)) { + return [ + 'path' => $path, + 'available' => false, + 'lines' => [], + 'line_count' => 0, + 'size_bytes' => 0, + 'updated_at' => null, + 'message' => 'dmesg log is not available yet.', + ]; + } + + $size = filesize($path) ?: 0; + $chunk = file_get_contents($path); + if ($chunk === false) { + return [ + 'path' => $path, + 'available' => false, + 'lines' => [], + 'line_count' => 0, + 'size_bytes' => $size, + 'updated_at' => null, + 'message' => 'failed to open dmesg log.', + ]; + } + + $lines = array_values(array_filter( + preg_split('/\R/', $chunk) ?: [], + static fn($line): bool => trim((string)$line) !== '' + )); + + return [ + 'path' => $path, + 'available' => true, + 'lines' => array_reverse($lines), + 'line_count' => count($lines), + 'size_bytes' => $size, + 'updated_at' => date('Y-m-d H:i:s', filemtime($path) ?: time()), + ]; +} + +function fan_paths(): array +{ + $candidates = glob('/sys/devices/platform/cooling_fan/hwmon/hwmon*/pwm1') ?: []; + $candidates = array_merge( + $candidates, + glob('/sys/class/hwmon/hwmon*/pwm1') ?: [] + ); + + foreach ($candidates as $pwm) { + $dir = dirname($pwm); + + return [ + 'base' => $dir, + 'pwm' => $pwm, + 'enable' => $dir . '/pwm1_enable', + 'rpm' => $dir . '/fan1_input', + 'name' => first_readable([$dir . '/name']) ?: 'cooling_fan', + ]; + } + + return [ + 'base' => 'N/A', + 'pwm' => 'N/A', + 'enable' => 'N/A', + 'rpm' => 'N/A', + 'name' => 'N/A', + ]; +} + +function read_int_file(string $file): int +{ + if ($file === '' || $file === 'N/A' || !is_readable($file)) { + return 0; + } + + $raw = trim((string)@file_get_contents($file)); + + return is_numeric($raw) ? (int)$raw : 0; +} + +function cpu_temp(): float +{ + $raw = first_readable([ + '/sys/class/thermal/thermal_zone0/temp', + '/sys/devices/virtual/thermal/thermal_zone0/temp', + ]); + + if ($raw !== '' && is_numeric($raw)) { + return round(((float)$raw) / 1000, 2); + } + + $vc = sh(['/usr/bin/vcgencmd', 'measure_temp'], false, 3)['out']; + + if (preg_match('/([0-9.]+)/', $vc, $m)) { + return round((float)$m[1], 2); + } + + return 0.0; +} + +function fan_target_pwm(float $temp): int +{ + if ($temp >= 80) { + return 255; + } + + if ($temp <= 50) { + return 0; + } + + $ratio = ($temp - 50) / 30; + + return max(0, min(255, (int)round($ratio * 255))); +} + +function fan_ramped_pwm(int $current, int $desired, float $temp): int +{ + if ($temp >= 80) { + return $desired; + } + + if ($desired > $current) { + return min($desired, $current + 2); + } + + if ($desired < $current) { + return max($desired, $current - 2); + } + + return $desired; +} + +function write_sys_value(string $path, int $value): bool +{ + if ($path === '' || $path === 'N/A') { + return false; + } + + if (@file_put_contents($path, $value . "\n", LOCK_EX) !== false) { + return true; + } + + $cmd = 'printf ' . escapeshellarg($value . "\n") . ' > ' . escapeshellarg($path); + return sh(['/bin/sh', '-lc', $cmd], true, 5)['code'] === 0; +} + +function apply_fan_policy(): array +{ + $state = get_control_state(); + $paths = fan_paths(); + + $temp = cpu_temp(); + + $mode = (string)($state['mode'] ?? 'auto'); + if (!in_array($mode, ['auto', 'manual', 'off'], true)) { + $mode = 'auto'; + } + + $manualPwm = max(0, min(255, (int)($state['manual_pwm'] ?? 120))); + + $currentPwm = read_int_file($paths['pwm']); + $desired = match ($mode) { + 'manual' => $manualPwm, + 'off' => 0, + default => fan_target_pwm($temp), + }; + $target = $mode === 'auto' + ? fan_ramped_pwm($currentPwm, $desired, $temp) + : $desired; + + $enableOk = write_sys_value($paths['enable'], $mode === 'off' ? 0 : 1); + $pwmOk = write_sys_value($paths['pwm'], $target); + + usleep(60000); + + $actualPwm = read_int_file($paths['pwm']); + + $rpm = read_int_file($paths['rpm']); + + return [ + 'mode' => $mode, + 'target_pwm' => $target, + 'actual_pwm' => $actualPwm, + 'temp_c' => $temp, + 'rpm' => $rpm, + 'ok' => $enableOk && $pwmOk, + 'paths' => $paths, + 'enable_value' => first_readable([$paths['enable']]) ?: 'N/A', + ]; +} + +function mem_info(): array +{ + $rows = []; + + foreach (@file('/proc/meminfo', FILE_IGNORE_NEW_LINES) ?: [] as $line) { + if (preg_match('/^([^:]+):\s+(\d+)/', $line, $m)) { + $rows[$m[1]] = (int)$m[2]; + } + } + + $total = (int)($rows['MemTotal'] ?? 0); + $available = (int)($rows['MemAvailable'] ?? 0); + $used = max(0, $total - $available); + + $swapTotal = (int)($rows['SwapTotal'] ?? 0); + $swapFree = (int)($rows['SwapFree'] ?? 0); + + return [ + 'total_mb' => round($total / 1024), + 'used_mb' => round($used / 1024), + 'free_mb' => round($available / 1024), + 'percent' => $total > 0 ? round($used / $total * 100, 1) : 0, + 'swap_total_mb' => round($swapTotal / 1024), + 'swap_used_mb' => round(max(0, $swapTotal - $swapFree) / 1024), + ]; +} + +function disk_info(string $path = '/var/www/control'): array +{ + $total = @disk_total_space($path) ?: 0; + $free = @disk_free_space($path) ?: 0; + + if ($total <= 0 || $free <= 0) { + $out = trim(sh(['/bin/df', '-kP', $path], false, 3)['out']); + $lines = preg_split('/\R/', $out); + $parts = isset($lines[1]) ? preg_split('/\s+/', trim($lines[1])) : []; + + if (count($parts) >= 6) { + $total = (int)$parts[1] * 1024; + $free = (int)$parts[3] * 1024; + } + } + + $used = max(0, $total - $free); + + return [ + 'path' => '/', + 'total_kb' => (int)round($total / 1024), + 'used_kb' => (int)round($used / 1024), + 'free_kb' => (int)round($free / 1024), + 'percent' => $total > 0 ? round($used / $total * 100, 1) : 0, + ]; +} + +function active_user_info(): array +{ + $out = trim(sh(['/usr/bin/who'], false, 3)['out']); + + if ($out === '') { + return [ + 'users' => 0, + 'sessions' => 0, + 'names' => '', + 'display' => '0 users / 0 sessions', + ]; + } + + $lines = preg_split('/\R/', $out) ?: []; + $lines = array_values(array_filter($lines, fn($line) => trim($line) !== '')); + + $names = []; + + foreach ($lines as $line) { + $parts = preg_split('/\s+/', trim($line)); + if (!empty($parts[0])) { + $names[$parts[0]] = true; + } + } + + $userNames = array_keys($names); + + return [ + 'users' => count($userNames), + 'sessions' => count($lines), + 'names' => implode(', ', $userNames), + 'display' => count($userNames) . ' users / ' . count($lines) . ' sessions', + ]; +} + +function os_info(): array +{ + $os = []; + + foreach (@file('/etc/os-release', FILE_IGNORE_NEW_LINES) ?: [] as $line) { + if (preg_match('/^([A-Z_]+)=(.*)$/', $line, $m)) { + $os[$m[1]] = trim($m[2], '"'); + } + } + + $uptimeRaw = trim((string)@file_get_contents('/proc/uptime')); + $uptimeSec = $uptimeRaw !== '' + ? (int)floor((float)explode(' ', $uptimeRaw)[0]) + : 0; + + return [ + 'hostname' => gethostname() ?: 'N/A', + 'os' => $os['PRETTY_NAME'] ?? 'N/A', + 'kernel' => php_uname('r'), + 'arch' => php_uname('m'), + 'model' => trim(@file_get_contents('/proc/device-tree/model') ?: '') ?: 'N/A', + 'uptime' => human_seconds($uptimeSec), + 'uptime_seconds' => $uptimeSec, + ]; +} + +function network_info(): array +{ + $rows = []; + + foreach (glob('/sys/class/net/*') ?: [] as $dir) { + $name = basename($dir); + + if ($name === 'lo') { + continue; + } + + $rx = (int)first_readable([$dir . '/statistics/rx_bytes']); + $tx = (int)first_readable([$dir . '/statistics/tx_bytes']); + + $rows[] = [ + 'name' => $name, + 'state' => first_readable([$dir . '/operstate']) ?: 'unknown', + 'carrier' => first_readable([$dir . '/carrier']) === '1' ? 'up' : 'down', + 'mac' => first_readable([$dir . '/address']) ?: 'N/A', + 'mtu' => first_readable([$dir . '/mtu']) ?: 'N/A', + 'rx_bytes' => $rx, + 'tx_bytes' => $tx, + 'rx_human' => bytes_human($rx), + 'tx_human' => bytes_human($tx), + 'ipv4' => trim(sh(['/usr/sbin/ip', '-4', '-o', 'addr', 'show', 'dev', $name], false, 3)['out']) ?: 'N/A', + ]; + } + + return $rows; +} + +function latest_sensor(): array +{ + $stmt = db()->query(" + SELECT * + FROM sensor_logs + ORDER BY id DESC + LIMIT 1 + "); + + return $stmt->fetch() ?: []; +} + +function sensor_history(int $limit = 240): array +{ + $limit = max(1, min(1500, $limit)); + + $stmt = db()->query(" + SELECT + sl.recorded_at AS time, + sl.cpu_temp_c AS temp_c, + sl.fan_rpm, + sl.fan_efficiency, + sl.rp1_temp_c, + sl.cpu_voltage, + sl.cpu_watts, + sl.battery_voltage, + sl.battery_percent, + sl.pwm_value AS fan_pwm, + sl.pwm_percent, + sl.pwm_mode, + sl.cpu_load_1, + sl.cpu_load_5, + sl.cpu_load_15, + sl.disk_total_kb, + sl.disk_used_kb, + sl.disk_free_kb, + sl.mem_total_mb, + sl.mem_used_mb, + CASE + WHEN sl.mem_total_mb > 0 THEN ROUND(sl.mem_used_mb / sl.mem_total_mb * 100, 1) + ELSE 0 + END AS mem_percent + FROM sensor_logs sl + ORDER BY sl.id DESC + LIMIT {$limit} + "); + + return array_reverse($stmt->fetchAll()); +} + +function battery_remaining_estimate(array $battery, array $history): array +{ + $percent = $battery['percent'] ?? null; + if ($percent === null || $percent === '' || !is_numeric($percent)) { + return [ + 'display' => '-', + 'seconds' => null, + 'source' => 'battery_soc_missing', + 'avg_watts' => null, + ]; + } + + $percent = max(0.0, min(100.0, (float)$percent)); + $wattValues = []; + + foreach (array_slice($history, -90) as $row) { + $watts = $row['cpu_watts'] ?? null; + if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) { + $wattValues[] = (float)$watts; + } + } + + $avgWatts = $wattValues === [] + ? null + : array_sum($wattValues) / count($wattValues); + + if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) { + $remainingWh = BATTERY_CAPACITY_WH * ($percent / 100); + $seconds = (int)round(($remainingWh / $avgWatts) * 3600); + + return [ + 'display' => human_remaining_seconds($seconds), + 'seconds' => $seconds, + 'source' => 'avg_watts', + 'avg_watts' => round($avgWatts, 3), + 'capacity_wh' => BATTERY_CAPACITY_WH, + ]; + } + + $socRows = []; + foreach ($history as $row) { + $soc = $row['battery_percent'] ?? null; + $time = strtotime((string)($row['time'] ?? '')); + if ($soc !== null && $soc !== '' && is_numeric($soc) && $time > 0) { + $socRows[] = [ + 'time' => $time, + 'soc' => (float)$soc, + ]; + } + } + + if (count($socRows) >= 30) { + $first = $socRows[0]; + $last = $socRows[count($socRows) - 1]; + $elapsed = max(1, (int)$last['time'] - (int)$first['time']); + $drop = (float)$first['soc'] - (float)$last['soc']; + + if ($elapsed >= 120 && $drop >= 0.2) { + $dropPerSecond = $drop / $elapsed; + $seconds = (int)round($percent / $dropPerSecond); + + return [ + 'display' => human_remaining_seconds($seconds), + 'seconds' => $seconds, + 'source' => 'soc_trend', + 'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3), + ]; + } + } + + return [ + 'display' => '-', + 'seconds' => null, + 'source' => BATTERY_CAPACITY_WH > 0 ? 'avg_watts_missing' : 'battery_capacity_missing', + 'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3), + ]; +} + +function add_battery_remaining_history(array $history): array +{ + $wattWindow = []; + + foreach ($history as $index => $row) { + $watts = $row['cpu_watts'] ?? null; + if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) { + $wattWindow[] = (float)$watts; + if (count($wattWindow) > 90) { + array_shift($wattWindow); + } + } + + $percent = $row['battery_percent'] ?? null; + $avgWatts = $wattWindow === [] + ? null + : array_sum($wattWindow) / count($wattWindow); + + $history[$index]['battery_remaining_seconds'] = null; + if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0 && is_numeric($percent)) { + $safePercent = max(0.0, min(100.0, (float)$percent)); + $remainingWh = BATTERY_CAPACITY_WH * ($safePercent / 100); + $history[$index]['battery_remaining_seconds'] = (int)round(($remainingWh / $avgWatts) * 3600); + } + } + + return $history; +} + +function process_service_name(int $pid): string +{ + $cgroup = @file('/proc/' . $pid . '/cgroup', FILE_IGNORE_NEW_LINES) ?: []; + + foreach ($cgroup as $line) { + if (preg_match('/([^\/:]+\.service)(?:\/|$)/', $line, $m)) { + return $m[1]; + } + } + + return 'N/A'; +} + +function process_args(int $pid): string +{ + $raw = @file_get_contents('/proc/' . $pid . '/cmdline'); + + if (is_string($raw) && $raw !== '') { + return command_short(str_replace("\0", ' ', trim($raw, "\0"))); + } + + $comm = trim((string)@file_get_contents('/proc/' . $pid . '/comm')); + + return $comm !== '' ? $comm : 'N/A'; +} + +function process_resource_data(int $limit = 6): array +{ + $limit = max(3, min(12, $limit)); + + $ps = sh([ + '/bin/ps', + '-eo', + 'pid=,ppid=,user=,pcpu=,pmem=,rss=,comm=,args=', + '--sort=-pcpu', + ], false, 4)['out']; + + $cpu = []; + $mem = []; + + foreach (preg_split('/\R/', trim($ps)) ?: [] as $line) { + $line = trim($line); + + if ($line === '') { + continue; + } + + $parts = preg_split('/\s+/', $line, 8); + + if (count($parts) < 8 || !ctype_digit($parts[0])) { + continue; + } + + $pid = (int)$parts[0]; + $row = [ + 'pid' => $pid, + 'ppid' => (int)$parts[1], + 'user' => $parts[2], + 'cpu_percent' => round((float)$parts[3], 1), + 'mem_percent' => round((float)$parts[4], 1), + 'rss_mb' => round(((int)$parts[5]) / 1024, 1), + 'name' => $parts[6], + 'service' => process_service_name($pid), + 'command' => command_short($parts[7]), + ]; + + if ($row['cpu_percent'] > 0 || count($cpu) < $limit) { + $cpu[] = $row; + } + + $mem[] = $row; + } + + usort($mem, fn($a, $b) => ($b['mem_percent'] <=> $a['mem_percent']) ?: ($b['rss_mb'] <=> $a['rss_mb'])); + + return [ + 'cpu' => array_slice($cpu, 0, $limit), + 'memory' => array_slice($mem, 0, $limit), + ]; +} + +function notice_process_signature(array $processes): string +{ + $cpu = null; + foreach (($processes['cpu'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + + $command = (string)($row['command'] ?? $row['name'] ?? ''); + $service = (string)($row['service'] ?? ''); + if ( + str_contains($command, '/bin/ps') + || str_contains($command, 'api.php') + || str_contains($command, 'php-fpm') + || $service === 'fanpanel-apply.service' + ) { + continue; + } + + $cpu = $row; + break; + } + + $mem = null; + foreach (($processes['memory'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + + $mem = $row; + break; + } + + return hash('sha256', json_encode([ + 'cpu' => $cpu === null ? null : [ + 'pid' => $cpu['pid'] ?? null, + 'service' => $cpu['service'] ?? null, + 'command' => $cpu['command'] ?? $cpu['name'] ?? null, + ], + 'mem' => $mem === null ? null : [ + 'pid' => $mem['pid'] ?? null, + 'service' => $mem['service'] ?? null, + 'command' => $mem['command'] ?? $mem['name'] ?? null, + ], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); +} + +function trimmed_average(array $values, float $fallback): float +{ + $values = array_values(array_filter( + $values, + fn($value) => is_numeric($value) && (float)$value > 0 + )); + + if (count($values) < 5) { + return $fallback; + } + + sort($values, SORT_NUMERIC); + $trim = (int)floor(count($values) * 0.1); + if ($trim > 0 && count($values) > $trim * 2) { + $values = array_slice($values, $trim, count($values) - ($trim * 2)); + } + + return array_sum($values) / max(1, count($values)); +} + +function notice_rolling_baseline(array $history, float $temp, float $rpm, float $pwm, array $state = []): array +{ + $rows = array_slice($history, -180); + if (count($rows) > 3) { + $rows = array_slice($rows, 0, -3); + } + + $temps = []; + $rpms = []; + $pwms = []; + + foreach ($rows as $row) { + $rowTemp = (float)($row['temp_c'] ?? 0); + $rowRpm = (float)($row['fan_rpm'] ?? 0); + $rowPwm = (float)($row['fan_pwm'] ?? 0); + + if ($rowTemp > 0) $temps[] = $rowTemp; + if ($rowRpm > 0) $rpms[] = $rowRpm; + if ($rowPwm > 0) $pwms[] = $rowPwm; + } + + $fallbackTemp = (float)($state['baseline_temp'] ?? 0) > 0 ? (float)$state['baseline_temp'] : $temp; + $fallbackRpm = (float)($state['baseline_rpm'] ?? 0) > 0 ? (float)$state['baseline_rpm'] : $rpm; + $fallbackPwm = (float)($state['baseline_pwm'] ?? 0) > 0 ? (float)$state['baseline_pwm'] : $pwm; + + return [ + 'temp' => trimmed_average($temps, $fallbackTemp), + 'rpm' => trimmed_average($rpms, $fallbackRpm), + 'pwm' => trimmed_average($pwms, $fallbackPwm), + 'samples' => count($rows), + ]; +} + +function fan_spike_analysis(array $history, array $fan, array $system, array $processes = []): array +{ + $rpm = (float)($fan['rpm'] ?? 0); + $pwm = (float)($fan['pwm'] ?? 0); + $temp = (float)($system['temp_c'] ?? 0); + $state = system_notice_state(); + $rollingBaseline = notice_rolling_baseline($history, $temp, $rpm, $pwm, $state); + + $currentState = (string)($state['state'] ?? 'normal'); + if (!in_array($currentState, ['normal', 'alert'], true)) { + $currentState = 'normal'; + } + + $bootEpoch = (int)(time() - max(0, (int)($system['uptime_seconds'] ?? 0))); + $alertStartedEpoch = strtotime((string)($state['alert_started_at'] ?? '')) ?: 0; + if ($currentState === 'alert' && $bootEpoch > 0 && ($alertStartedEpoch === 0 || $alertStartedEpoch < $bootEpoch)) { + $currentState = 'normal'; + } + + $rpmAvg = $currentState === 'alert' ? (float)($state['baseline_rpm'] ?? 0) : (float)$rollingBaseline['rpm']; + $pwmAvg = $currentState === 'alert' ? (float)($state['baseline_pwm'] ?? 0) : (float)$rollingBaseline['pwm']; + $tempAvg = $currentState === 'alert' ? (float)($state['baseline_temp'] ?? 0) : (float)$rollingBaseline['temp']; + + if ($rpmAvg <= 0) $rpmAvg = $rpm; + if ($pwmAvg <= 0) $pwmAvg = $pwm; + if ($tempAvg <= 0) $tempAvg = $temp; + + $rpmDelta = $rpm - $rpmAvg; + $pwmDelta = $pwm - $pwmAvg; + $tempDelta = $temp - $tempAvg; + $rollingRpmDelta = $rpm - (float)$rollingBaseline['rpm']; + $rollingTempDelta = $temp - (float)$rollingBaseline['temp']; + + $mode = (string)($fan['mode'] ?? ''); + if ($mode === 'off') { + $offBaselineReason = 'fan_off_baseline'; + $hasOffBaseline = (string)($state['active_reason'] ?? '') === $offBaselineReason; + + $tempAvg = $hasOffBaseline && (float)($state['baseline_temp'] ?? 0) > 0 + ? (float)$state['baseline_temp'] + : $temp; + $rpmAvg = $hasOffBaseline && (float)($state['baseline_rpm'] ?? 0) >= 0 + ? (float)$state['baseline_rpm'] + : $rpm; + $pwmAvg = $hasOffBaseline && (float)($state['baseline_pwm'] ?? 0) >= 0 + ? (float)$state['baseline_pwm'] + : $pwm; + + if (!$hasOffBaseline) { + save_system_notice_state('normal', $tempAvg, $rpmAvg, $pwmAvg, $offBaselineReason); + } + + return [ + 'active' => false, + 'summary' => 'Fan off baseline is fixed.', + 'rpm_delta' => round($rpm - $rpmAvg, 1), + 'pwm_delta' => round($pwm - $pwmAvg, 1), + 'temp_delta' => round($temp - $tempAvg, 1), + 'rpm_avg' => round($rpmAvg, 1), + 'pwm_avg' => round($pwmAvg, 1), + 'temp_avg' => round($tempAvg, 1), + 'notice_state' => 'normal', + 'baseline_source' => 'frozen_off', + 'baseline_samples' => (int)$rollingBaseline['samples'], + ]; + } + + $rpmExpected = $mode !== 'off' && ($pwm >= 20 || $pwmAvg >= 20); + + $reasons = []; + if ($rpmExpected && abs($rpmDelta) >= 1000) $reasons[] = 'RPM ' . signed_delta_text($rpmDelta); + if (abs($tempDelta) >= 3.0) $reasons[] = 'TEMP ' . signed_delta_text($tempDelta, 'C'); + + $spiking = false; + + if ($currentState === 'normal') { + $spiking = $reasons !== []; + if ($spiking) { + save_system_notice_state( + 'alert', + $tempAvg, + $rpmAvg, + $pwmAvg, + implode(', ', $reasons), + true, + $tempDelta, + $rpmDelta, + notice_process_signature($processes) + ); + } else { + save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']); + } + } else { + $recovered = (abs($rpmDelta) <= 500 && abs($tempDelta) <= 1.5) + || (abs($rollingRpmDelta) <= 500 && abs($rollingTempDelta) <= 1.5); + if ($recovered || $reasons === []) { + $currentState = 'normal'; + save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']); + $tempAvg = (float)$rollingBaseline['temp']; + $rpmAvg = (float)$rollingBaseline['rpm']; + $pwmAvg = (float)$rollingBaseline['pwm']; + $rpmDelta = $rpm - $rpmAvg; + $pwmDelta = $pwm - $pwmAvg; + $tempDelta = $temp - $tempAvg; + } else { + save_system_notice_state( + 'alert', + $tempAvg, + $rpmAvg, + $pwmAvg, + implode(', ', $reasons), + false, + $tempDelta, + $rpmDelta, + notice_process_signature($processes) + ); + } + } + + $summary = 'No system notice detected in recent samples.'; + if ($spiking) { + $summary = 'System notice: ' . implode(', ', $reasons); + } elseif ($currentState === 'alert' && $reasons !== []) { + $summary = 'System notice active: ' . implode(', ', $reasons); + } + + return [ + 'active' => $spiking, + 'summary' => $summary, + 'rpm_delta' => round($rpmDelta, 1), + 'pwm_delta' => round($pwmDelta, 1), + 'temp_delta' => round($tempDelta, 1), + 'rpm_avg' => round($rpmAvg, 1), + 'pwm_avg' => round($pwmAvg, 1), + 'temp_avg' => round($tempAvg, 1), + 'notice_state' => $spiking ? 'alert' : $currentState, + 'baseline_source' => $currentState === 'alert' ? 'frozen_alert' : 'rolling', + 'baseline_samples' => (int)$rollingBaseline['samples'], + ]; +} + +function system_notice_state(): array +{ + $stmt = db()->query(" + SELECT + state, + baseline_temp, + baseline_rpm, + baseline_pwm, + active_reason, + active_temp_delta, + active_rpm_delta, + process_signature, + alert_started_at, + last_alert_at, + updated_at + FROM system_notice_state + WHERE id = 1 + LIMIT 1 + "); + + return $stmt->fetch() ?: []; +} + +function save_system_notice_state( + string $state, + float $baselineTemp, + float $baselineRpm, + float $baselinePwm, + ?string $reason = null, + bool $alertStarted = false, + float $activeTempDelta = 0.0, + float $activeRpmDelta = 0.0, + ?string $processSignature = null +): void { + $stmt = db()->prepare(" + INSERT INTO system_notice_state ( + id, + state, + baseline_temp, + baseline_rpm, + baseline_pwm, + active_reason, + active_temp_delta, + active_rpm_delta, + process_signature, + alert_started_at, + last_alert_at + ) VALUES ( + 1, + :state, + :baseline_temp, + :baseline_rpm, + :baseline_pwm, + :active_reason, + :active_temp_delta, + :active_rpm_delta, + :process_signature, + IF(:alert_started = 1, CURRENT_TIMESTAMP, NULL), + IF(:alert_started_last = 1, CURRENT_TIMESTAMP, NULL) + ) + ON DUPLICATE KEY UPDATE + state = VALUES(state), + baseline_temp = VALUES(baseline_temp), + baseline_rpm = VALUES(baseline_rpm), + baseline_pwm = VALUES(baseline_pwm), + active_reason = VALUES(active_reason), + active_temp_delta = VALUES(active_temp_delta), + active_rpm_delta = VALUES(active_rpm_delta), + process_signature = VALUES(process_signature), + alert_started_at = IF(:alert_started_update = 1, CURRENT_TIMESTAMP, alert_started_at), + last_alert_at = IF(:alert_started_last_update = 1, CURRENT_TIMESTAMP, last_alert_at), + updated_at = CURRENT_TIMESTAMP + "); + + $stmt->execute([ + ':state' => in_array($state, ['normal', 'alert'], true) ? $state : 'normal', + ':baseline_temp' => round($baselineTemp, 2), + ':baseline_rpm' => round($baselineRpm, 2), + ':baseline_pwm' => round($baselinePwm, 2), + ':active_reason' => $reason, + ':active_temp_delta' => round($activeTempDelta, 2), + ':active_rpm_delta' => round($activeRpmDelta, 2), + ':process_signature' => $processSignature, + ':alert_started' => $alertStarted ? 1 : 0, + ':alert_started_last' => $alertStarted ? 1 : 0, + ':alert_started_update' => $alertStarted ? 1 : 0, + ':alert_started_last_update' => $alertStarted ? 1 : 0, + ]); +} + +function add_fan_spike_log(array $spike, array $fan, array $system, array $processes): int +{ + $summary = (string)($spike['summary'] ?? ''); + $rpmDelta = round((float)($spike['rpm_delta'] ?? 0), 1); + $pwmDelta = round((float)($spike['pwm_delta'] ?? 0), 1); + $tempDelta = round((float)($spike['temp_delta'] ?? 0), 1); + $spikeKey = 'fan:' . date('YmdHi'); + $loggedProcesses = notice_downward_only($tempDelta, $rpmDelta) + ? ['cpu' => [], 'memory' => []] + : $processes; + + $stmt = db()->prepare(" + INSERT IGNORE INTO fan_spike_logs ( + spike_key, + summary, + rpm_delta, + pwm_delta, + temp_delta, + current_rpm, + current_pwm, + current_temp, + cpu_process, + memory_process + ) VALUES ( + :spike_key, + :summary, + :rpm_delta, + :pwm_delta, + :temp_delta, + :current_rpm, + :current_pwm, + :current_temp, + :cpu_process, + :memory_process + ) + "); + + $stmt->execute([ + ':spike_key' => $spikeKey, + ':summary' => $summary !== '' ? $summary : null, + ':rpm_delta' => $rpmDelta, + ':pwm_delta' => $pwmDelta, + ':temp_delta' => $tempDelta, + ':current_rpm' => $fan['rpm'] ?? null, + ':current_pwm' => $fan['pwm'] ?? null, + ':current_temp' => $system['temp_c'] ?? null, + ':cpu_process' => json_encode($loggedProcesses['cpu'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ':memory_process' => json_encode($loggedProcesses['memory'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + + if ($stmt->rowCount() <= 0) { + return 0; + } + + return (int)db()->lastInsertId(); +} + +function signed_delta_text(float $value, string $suffix = ''): string +{ + $sign = $value >= 0 ? '+' : ''; + + return $sign . number_format($value, 1) . $suffix; +} + +function signed_delta_compact(float $value, int $decimals, string $suffix = ''): string +{ + $sign = $value >= 0 ? '+' : ''; + + return $sign . number_format($value, $decimals) . $suffix; +} + +function push_process_name(array $row): string +{ + $service = (string)($row['service'] ?? ''); + if ($service !== '' && $service !== 'N/A') { + return $service; + } + + $command = (string)($row['command'] ?? $row['name'] ?? ''); + + if ( + str_contains($command, '/codex ') + || str_contains($command, '/codex') + || str_contains($command, 'codex app-server') + ) { + return 'codex'; + } + + if (str_contains($command, '.vscode-server')) { + return 'vscode-server'; + } + + if (str_contains($command, 'python3 -m homeassistant')) { + return 'homeassistant'; + } + + if (str_contains($command, 'firefox')) { + return 'firefox'; + } + + if ($command === '') { + $command = (string)($row['name'] ?? ''); + } + + return $command !== '' ? $command : 'N/A'; +} + +function process_identity(array $row): string +{ + return implode('|', [ + (string)($row['pid'] ?? ''), + (string)($row['service'] ?? ''), + (string)($row['command'] ?? $row['name'] ?? ''), + ]); +} + +function expected_process_text(array $processes): string +{ + $cpu = null; + foreach (($processes['cpu'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + + $command = (string)($row['command'] ?? $row['name'] ?? ''); + $service = (string)($row['service'] ?? ''); + if ( + str_contains($command, '/bin/ps') + || str_contains($command, 'api.php') + || str_contains($command, 'php-fpm') + || $service === 'fanpanel-apply.service' + ) { + continue; + } + + $cpu = $row; + break; + } + + if ($cpu !== null && (float)($cpu['cpu_percent'] ?? 0) >= 1.0) { + return 'CPU ' . push_process_name($cpu); + } + + $mem = null; + foreach (($processes['memory'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + + $mem = $row; + break; + } + + if ($mem !== null) { + return 'MEM ' . push_process_name($mem); + } + + return 'CPU/MEM 원인 후보 없음'; +} + +function expected_process_detail_text(array $processes): string +{ + $cpu = null; + foreach (($processes['cpu'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + + $command = (string)($row['command'] ?? $row['name'] ?? ''); + $service = (string)($row['service'] ?? ''); + if ( + str_contains($command, '/bin/ps') + || str_contains($command, 'api.php') + || str_contains($command, 'php-fpm') + || $service === 'fanpanel-apply.service' + ) { + continue; + } + + $cpu = $row; + break; + } + + $mem = null; + foreach (($processes['memory'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + + $mem = $row; + break; + } + + if ($cpu !== null && $mem !== null && process_identity($mem) === process_identity($cpu)) { + return push_process_name($cpu); + } + + $parts = []; + + if ($cpu !== null) { + $parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), push_process_name($cpu)); + } + + if ($mem !== null) { + $parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), push_process_name($mem)); + } + + return $parts === [] ? '원인 후보 없음' : implode(' / ', $parts); +} + +function delta_state_text(float $value): string +{ + return $value >= 0 ? '높음' : '낮음'; +} + +function notice_downward_only(float $tempDelta, float $rpmDelta): bool +{ + $tempTriggered = abs($tempDelta) >= 3.0; + $rpmTriggered = abs($rpmDelta) >= 1000; + + if (!$tempTriggered && !$rpmTriggered) { + return false; + } + + if ($tempTriggered && $tempDelta >= 0) { + return false; + } + + if ($rpmTriggered && $rpmDelta >= 0) { + return false; + } + + return true; +} + +function send_fan_spike_push(array $spike, array $fan, array $system, array $processes): array +{ + $tempDelta = (float)($spike['temp_delta'] ?? 0); + $rpmDelta = (float)($spike['rpm_delta'] ?? 0); + $tempAvg = (float)($spike['temp_avg'] ?? 0); + $rpmAvg = (float)($spike['rpm_avg'] ?? 0); + $pwmAvg = (float)($spike['pwm_avg'] ?? 0); + $reasons = []; + + if (abs($tempDelta) >= 3.0) { + $reasons[] = '온도 평균보다 ' . ($tempDelta >= 0 ? '상승' : '하강'); + } + + if (abs($rpmDelta) >= 1000) { + $reasons[] = '팬RPM 평균보다 ' . ($rpmDelta >= 0 ? '상승' : '하강'); + } + + if ($reasons === []) { + $reasons[] = '순간 변화'; + } + + $body = '기록된 이유: ' + . implode(', ', $reasons) + . "\n평균: " + . number_format($tempAvg, 1) + . '°C / ' + . number_format($rpmAvg, 0) + . ' RPM / PWM ' + . number_format($pwmAvg, 0) + . "\n현재: " + . number_format((float)($system['temp_c'] ?? 0), 1) + . '°C / ' + . number_format((float)($fan['rpm'] ?? 0), 0) + . ' RPM / PWM ' + . number_format((float)($fan['pwm'] ?? 0), 0) + . (notice_downward_only($tempDelta, $rpmDelta) ? '' : "\n원인 후보: " . expected_process_detail_text($processes)); + + return send_push_payload([ + 'title' => '시스템 유의사항', + 'body' => $body, + 'url' => '/', + 'tag' => 'system-notice-' . date('YmdHi'), + 'created_at' => date('Y-m-d H:i:s'), + 'data' => [ + 'summary' => $spike['summary'] ?? '', + 'rpm_delta' => $spike['rpm_delta'] ?? 0, + 'pwm_delta' => $spike['pwm_delta'] ?? 0, + 'temp_delta' => $spike['temp_delta'] ?? 0, + 'expected_process' => expected_process_text($processes), + ], + ]); +} + +function latest_system_notice_push_epoch(): int +{ + $stmt = db()->query(" + SELECT UNIX_TIMESTAMP(MAX(created_at)) + FROM push_event_logs + WHERE event = 'send_success' + AND meta LIKE '%\"tag\":\"system-notice-%' + "); + + return (int)($stmt->fetchColumn() ?: 0); +} + +function fan_spike_history(int $limit = 100): array +{ + $limit = max(1, min(500, $limit)); + + $stmt = db()->query(" + SELECT + id, + created_at, + summary, + rpm_delta, + pwm_delta, + temp_delta, + current_rpm, + current_pwm, + current_temp, + cpu_process, + memory_process + FROM fan_spike_logs + WHERE id IN ( + SELECT MAX(id) + FROM fan_spike_logs + GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d %H:%i') + ) + ORDER BY id DESC + LIMIT {$limit} + "); + + $rows = $stmt->fetchAll(); + + foreach ($rows as &$row) { + $row['cpu_process'] = json_decode((string)($row['cpu_process'] ?? '[]'), true) ?: []; + $row['memory_process'] = json_decode((string)($row['memory_process'] ?? '[]'), true) ?: []; + } + + unset($row); + + return $rows; +} + +function dnsmasq_leases(): array +{ + $map = []; + + foreach (@file('/var/lib/misc/dnsmasq.leases', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $p = preg_split('/\s+/', trim($line)); + + if (count($p) >= 4) { + $mac = strtolower($p[1]); + + $map[$mac] = [ + 'expire_ts' => (int)$p[0], + 'mac' => $mac, + 'ip' => $p[2], + 'hostname' => $p[3] === '*' ? 'N/A' : $p[3], + ]; + } + } + + return $map; +} + +function iw_station_dump(string $iface): string +{ + if (!preg_match('/^[A-Za-z0-9_.:-]+$/', $iface)) { + return ''; + } + + return sh(['/usr/sbin/iw', 'dev', $iface, 'station', 'dump'], true, 4)['out']; +} + +function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases): array +{ + $rows = []; + $cur = null; + + foreach (explode("\n", $text) as $line) { + $t = trim($line); + + if (preg_match('/^Station\s+([0-9a-f:]+)/i', $t, $m)) { + if ($cur !== null) { + $rows[] = $cur; + } + + $mac = strtolower($m[1]); + $lease = $leases[$mac] ?? []; + $cur = [ + 'band' => $band, + 'iface' => $iface, + 'mac' => $mac, + 'ip' => $lease['ip'] ?? 'N/A', + 'hostname' => $lease['hostname'] ?? 'N/A', + 'name' => $lease['hostname'] ?? $mac, + 'signal' => 'N/A', + 'tx_bitrate' => 'N/A', + 'rx_bitrate' => 'N/A', + 'connected_time' => 'N/A', + 'inactive_time' => 'N/A', + 'rx_bytes' => 0, + 'tx_bytes' => 0, + 'rx_packets' => 0, + 'tx_packets' => 0, + 'tx_failed' => 0, + ]; + continue; + } + + if ($cur === null) { + continue; + } + + if (preg_match('/^signal:\s+(.+)/', $t, $m)) $cur['signal'] = $m[1]; + elseif (preg_match('/^tx bitrate:\s+(.+)/', $t, $m)) $cur['tx_bitrate'] = $m[1]; + elseif (preg_match('/^rx bitrate:\s+(.+)/', $t, $m)) $cur['rx_bitrate'] = $m[1]; + elseif (preg_match('/^connected time:\s+(.+)/', $t, $m)) $cur['connected_time'] = $m[1]; + elseif (preg_match('/^inactive time:\s+(.+)/', $t, $m)) $cur['inactive_time'] = $m[1]; + elseif (preg_match('/^rx bytes:\s+(\d+)/', $t, $m)) $cur['rx_bytes'] = (int)$m[1]; + elseif (preg_match('/^tx bytes:\s+(\d+)/', $t, $m)) $cur['tx_bytes'] = (int)$m[1]; + elseif (preg_match('/^rx packets:\s+(\d+)/', $t, $m)) $cur['rx_packets'] = (int)$m[1]; + elseif (preg_match('/^tx packets:\s+(\d+)/', $t, $m)) $cur['tx_packets'] = (int)$m[1]; + elseif (preg_match('/^tx failed:\s+(\d+)/', $t, $m)) $cur['tx_failed'] = (int)$m[1]; + } + + if ($cur !== null) { + $rows[] = $cur; + } + + return $rows; +} + +function wifi_time_ms(string $value): int +{ + if (preg_match('/(\d+)/', $value, $m)) { + return (int)$m[1]; + } + + return PHP_INT_MAX; +} + +function wifi_signal_dbm(string $value): int +{ + if (preg_match('/-?\d+/', $value, $m)) { + return (int)$m[0]; + } + + return -999; +} + +function dedupe_wifi_clients(array $clients): array +{ + $deduped = []; + + foreach ($clients as $client) { + $mac = strtolower((string)($client['mac'] ?? '')); + $key = $mac !== '' && $mac !== 'n/a' + ? 'mac:' . $mac + : 'row:' . ($client['band'] ?? '') . ':' . ($client['ip'] ?? '') . ':' . ($client['hostname'] ?? ''); + + if (!isset($deduped[$key])) { + $deduped[$key] = $client; + continue; + } + + $currentInactive = wifi_time_ms((string)($deduped[$key]['inactive_time'] ?? '')); + $nextInactive = wifi_time_ms((string)($client['inactive_time'] ?? '')); + $currentSignal = wifi_signal_dbm((string)($deduped[$key]['signal'] ?? '')); + $nextSignal = wifi_signal_dbm((string)($client['signal'] ?? '')); + + if ($nextInactive < $currentInactive || ($nextInactive === $currentInactive && $nextSignal > $currentSignal)) { + $deduped[$key] = $client; + } + } + + return array_values($deduped); +} + +function wifi_data(): array +{ + $leases = dnsmasq_leases(); + $clients = []; + + foreach (['wlan0' => '2.4G', 'wlan1' => '5G'] as $iface => $band) { + $clients = array_merge( + $clients, + parse_live_wifi_rows($iface, $band, iw_station_dump($iface), $leases) + ); + } + + $clients = dedupe_wifi_clients($clients); + + return [ + 'clients' => $clients, + 'count24' => count(array_filter($clients, fn($c) => $c['band'] === '2.4G')), + 'count5' => count(array_filter($clients, fn($c) => $c['band'] === '5G')), + 'leases' => array_values($leases), + ]; +} + +function action_rows(int $limit = 80): array +{ + $limit = max(1, min(300, $limit)); + + $stmt = db()->query(" + SELECT + created_at AS ts, + action_type AS action, + CONCAT( + action_type, + IF(pwm_mode IS NULL, '', CONCAT(' ', pwm_mode)), + IF(pwm_value IS NULL, '', CONCAT(' pwm ', pwm_value)), + IF(note IS NULL OR note = '', '', CONCAT(' / ', note)) + ) AS message, + success, + actor_ip + FROM fan_actions + ORDER BY id DESC + LIMIT {$limit} + "); + + return $stmt->fetchAll(); +} + +function collect_snapshot(bool $applyFan = true): array +{ + if ($applyFan) { + $policy = apply_fan_policy(); + } else { + $state = get_control_state(); + $policy = [ + 'mode' => (string)($state['mode'] ?? 'auto'), + 'target_pwm' => (int)($state['manual_pwm'] ?? 120), + 'actual_pwm' => 0, + 'temp_c' => 0.0, + 'rpm' => 0, + 'ok' => true, + 'paths' => [], + 'enable_value' => 'N/A', + ]; + } + + $latest = latest_sensor(); + + $pwm = isset($latest['pwm_value']) ? (int)$latest['pwm_value'] : 0; + if ($latest === false || $latest === [] || !isset($latest['pwm_value'])) { + $pwm = (int)$policy['actual_pwm']; + } + + $rpm = (int)($latest['fan_rpm'] ?? 0); + if ($rpm <= 0) { + $rpm = (int)$policy['rpm']; + } + + $temp = (float)($latest['cpu_temp_c'] ?? 0); + if ($temp <= 0) { + $temp = (float)$policy['temp_c']; + } + + $load = sys_getloadavg() ?: [0, 0, 0]; + $mem = mem_info(); + $disk = disk_info('/'); + $os = os_info(); + $cpuPower = $applyFan ? cpu_power_status() : [ + 'voltage' => isset($latest['cpu_voltage']) ? (float)$latest['cpu_voltage'] : null, + 'watts' => isset($latest['cpu_watts']) ? (float)$latest['cpu_watts'] : null, + ]; + $battery = $applyFan ? battery_status() : [ + 'voltage' => isset($latest['battery_voltage']) ? (float)$latest['battery_voltage'] : null, + 'percent' => isset($latest['battery_percent']) ? (float)$latest['battery_percent'] : null, + ]; + + if ($applyFan) { + add_sensor_log([ + 'cpu_temp_c' => $temp, + 'fan_rpm' => $rpm, + 'fan_efficiency' => fan_efficiency($rpm, $temp, $cpuPower['watts'] ?? null), + 'rp1_temp_c' => rp1_temp_c(), + 'cpu_voltage' => $cpuPower['voltage'], + 'cpu_watts' => $cpuPower['watts'], + 'battery_voltage' => $battery['voltage'], + 'battery_percent' => $battery['percent'], + 'pwm_value' => $pwm, + 'pwm_percent' => round($pwm / 255 * 100, 2), + 'pwm_mode' => $policy['mode'], + 'cpu_load_1' => round((float)$load[0], 2), + 'cpu_load_5' => round((float)$load[1], 2), + 'cpu_load_15' => round((float)$load[2], 2), + 'mem_total_mb' => $mem['total_mb'], + 'mem_used_mb' => $mem['used_mb'], + 'mem_free_mb' => $mem['free_mb'], + 'disk_total_kb' => $disk['total_kb'], + 'disk_used_kb' => $disk['used_kb'], + 'disk_free_kb' => $disk['free_kb'], + 'uptime_seconds' => $os['uptime_seconds'], + 'hostname' => $os['hostname'], + ]); + } + + $fan = [ + 'mode' => $policy['mode'], + 'pwm' => $pwm, + 'percent' => round($pwm / 255 * 100, 1), + 'rpm' => $rpm, + 'enable' => $policy['enable_value'], + 'target_pwm' => $policy['target_pwm'], + 'policy_ok' => $policy['ok'], + 'paths' => $policy['paths'], + ]; + + $system = array_merge($os, [ + 'temp_c' => $temp, + 'load' => [ + round((float)$load[0], 2), + round((float)$load[1], 2), + round((float)$load[2], 2), + ], + 'active_users' => active_user_info(), + 'memory' => $mem, + 'disk' => $disk, + ]); + + $history = add_battery_remaining_history(sensor_history(240)); + $battery['remaining'] = battery_remaining_estimate($battery, $history); + + $processes = process_resource_data(6); + $fanSpike = fan_spike_analysis($history, $fan, $system, $processes); + + if (!empty($fanSpike['active'])) { + $latestNoticePush = latest_system_notice_push_epoch(); + $spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes); + if ($spikeLogId > 0) { + $fanSpike['log_id'] = $spikeLogId; + if ($latestNoticePush <= 0 || time() - $latestNoticePush >= 600) { + $fanSpike['push'] = send_fan_spike_push($fanSpike, $fan, $system, $processes); + } else { + $fanSpike['push'] = [ + 'sent' => 0, + 'failed' => 0, + 'skipped' => 'system_notice_cooldown', + 'cooldown_seconds' => 600, + ]; + } + } + } + + $snapshot = [ + 'ok' => true, + 'generated_at' => date('Y-m-d H:i:s'), + 'time_ms' => (int)round(microtime(true) * 1000), + + 'fan' => $fan, + 'system' => $system, + 'battery' => $battery, + + 'wifi' => wifi_data(), + 'history' => $history, + 'processes' => $processes, + 'fan_spike' => $fanSpike, + 'fan_spike_history' => fan_spike_history(100), + ]; + + return $snapshot; +} + +function control_api_dispatch(): void +{ + $action = $_GET['action'] ?? $_POST['action'] ?? 'status'; + + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + require_csrf(); + } + + try { + if ($action === 'dmesg') { + json_out([ + 'ok' => true, + 'data' => dmesg_log(), + ]); + } + + if ($action === 'status') { + json_out([ + 'ok' => true, + 'data' => collect_snapshot(false), + ]); + } + + if ($action === 'push_devices') { + json_out([ + 'ok' => true, + 'data' => [ + 'devices' => push_device_rows(), + ], + ]); + } + + if ($action === 'push_status') { + json_out([ + 'ok' => true, + 'data' => push_subscription_status((string)($_GET['endpoint'] ?? $_POST['endpoint'] ?? '')), + ]); + } + + if ($action === 'delete_push_device') { + delete_push_device((string)($_POST['endpoint_hash'] ?? '')); + + json_out([ + 'ok' => true, + 'data' => [ + 'devices' => push_device_rows(), + ], + ]); + } + + if ($action === 'delete_push_endpoint') { + delete_push_endpoint((string)($_POST['endpoint'] ?? '')); + + json_out([ + 'ok' => true, + 'data' => [ + 'devices' => push_device_rows(), + ], + ]); + } + + if ($action === 'fan') { + $mode = (string)($_POST['mode'] ?? 'auto'); + $pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120))); + + if (!in_array($mode, ['auto', 'manual', 'off'], true)) { + json_out([ + 'ok' => false, + 'error' => 'bad_mode', + ], 400); + } + + set_control_state($mode, $pwm); + + add_fan_action( + 'fan_set', + $mode, + $pwm, + 'web fan control', + true + ); + + $data = collect_snapshot(true); + + json_out([ + 'ok' => true, + 'data' => $data, + ]); + } + + if ($action === 'wifi') { + $verb = (string)($_POST['verb'] ?? ''); + $unit = (string)($_POST['unit'] ?? ''); + $allowedUnits = [ + 'hostapd-24g.service', + 'hostapd-5g.service', + 'dnsmasq.service', + ]; + + if ( + !in_array($unit, $allowedUnits, true) + || !in_array($verb, ['restart', 'reload'], true) + ) { + json_out([ + 'ok' => false, + 'error' => 'bad_wifi_request', + ], 400); + } + + $result = sh(['/usr/bin/systemctl', $verb, $unit], true, 25); + $out = $result['out']; + + add_fan_action( + 'wifi_' . $verb, + null, + null, + $unit . "\n" . mb_substr($out, 0, 1000), + true + ); + + json_out([ + 'ok' => true, + 'output' => $out, + 'data' => collect_snapshot(false), + ]); + } + + if ($action === 'collect') { + json_out([ + 'ok' => true, + 'data' => collect_snapshot(true), + ]); + } + + json_out([ + 'ok' => false, + 'error' => 'unknown_action', + ], 404); + } catch (Throwable $e) { + json_out([ + 'ok' => false, + 'error' => 'exception', + 'message' => $e->getMessage(), + ], 500); + } +} + +if (!$controlApiLibrary) { + control_api_dispatch(); +} diff --git a/public/api/push_event.php b/public/api/push_event.php new file mode 100644 index 0000000..116a949 --- /dev/null +++ b/public/api/push_event.php @@ -0,0 +1,44 @@ + false, 'error' => 'method_not_allowed'], 405); +} + +$raw = (string)file_get_contents('php://input'); +$data = json_decode($raw, true); +if (!is_array($data)) { + $data = []; +} + +$event = (string)($data['event'] ?? ''); +$allowedEvents = [ + 'push_received', + 'notification_shown', + 'notification_show_failed', + 'notification_click', + 'notification_close', + 'client_log_failed', +]; + +if (!in_array($event, $allowedEvents, true)) { + json_out(['ok' => false, 'error' => 'invalid_event'], 422); +} + +$endpoint = (string)($data['endpoint'] ?? ''); +$meta = is_array($data['meta'] ?? null) ? $data['meta'] : []; +$context = [ + 'endpoint' => $endpoint, + 'message' => mb_substr((string)($data['push_id'] ?? ''), 0, 255), + 'push_id' => (string)($data['push_id'] ?? ''), + 'tag' => (string)($data['tag'] ?? ''), + 'visibility_state' => (string)($data['visibility_state'] ?? ''), + 'client_count' => isset($data['client_count']) ? (int)$data['client_count'] : null, + 'meta' => $meta, +]; + +push_log_event($event, $context); + +json_out(['ok' => true]); diff --git a/public/api/save_subscription.php b/public/api/save_subscription.php new file mode 100644 index 0000000..deed2c4 --- /dev/null +++ b/public/api/save_subscription.php @@ -0,0 +1,31 @@ + false, + 'error' => 'login_required', + ], 401); +} + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + json_out([ + 'ok' => false, + 'error' => 'method_not_allowed', + ], 405); +} + +require_csrf(); + +$subscription = push_subscription_from_json((string)file_get_contents('php://input')); +save_push_subscription($subscription); + +json_out([ + 'ok' => true, +]); diff --git a/public/api/send_push.php b/public/api/send_push.php new file mode 100644 index 0000000..fd7adac --- /dev/null +++ b/public/api/send_push.php @@ -0,0 +1,38 @@ + false, + 'error' => 'login_required', + ], 401); +} + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + json_out([ + 'ok' => false, + 'error' => 'method_not_allowed', + ], 405); +} + +require_csrf(); + +$data = push_subscription_from_json((string)file_get_contents('php://input')); +$payload = [ + 'title' => (string)($data['title'] ?? 'Seoul Control Center'), + 'body' => (string)($data['body'] ?? 'Push notification test'), + 'url' => '/', + 'tag' => (string)($data['tag'] ?? 'control-test'), + 'created_at' => date('Y-m-d H:i:s'), +]; + +json_out([ + 'ok' => true, + 'data' => send_push_payload($payload), +]); diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..ba0a084 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,1100 @@ +(() => { + 'use strict'; + + const $ = (s, p = document) => p.querySelector(s); + const els = { + updated: $('#updatedAt'), + notice: $('#notice'), + temp: $('#tempValue'), + fanRpm: $('#fanRpm'), + fanPercent: $('#fanPercent'), + fanSlider: $('#fanSlider'), + fanSliderWrap: $('#fanSliderWrap'), + fanSliderValue: $('#fanSliderValue'), + fanModeOptions: $('#fanModeOptions'), + wifi24: $('#wifi24'), + wifi5: $('#wifi5'), + wifiTable: $('#wifiTable'), + statusHost: $('#statusHost'), + statusLoad: $('#statusLoad'), + statusUsers: $('#statusUsers'), + statusDisk: $('#statusDisk'), + statusMemory: $('#statusMemory'), + statusUptime: $('#statusUptime'), + statusBatteryVoltage: $('#statusBatteryVoltage'), + statusBatterySoc: $('#statusBatterySoc'), + statusBatteryRemaining: $('#statusBatteryRemaining'), + spikeLogList: $('#spikeLogList'), + noticeBaseline: $('#noticeBaseline'), + pushStatus: $('#pushStatus'), + pushDeviceList: $('#pushDeviceList'), + processCpuTable: $('#processCpuTable'), + processMemoryTable: $('#processMemoryTable'), + dmesgToggle: $('#dmesgToggle'), + dmesgOutput: $('#dmesgOutput'), + dmesgMeta: $('#dmesgMeta'), + }; + + const state = { + loading: false, + charts: {}, + fanDirty: false, + fanApplying: false, + fanApplyTimer: null, + fanApplyPending: null, + latestFanPwm: 0, + fanCauseTick: 0, + dmesgOpen: false, + dmesgTimer: null, + dmesgLatestKey: null, + ws: null, + wsConnected: false, + wsFallbackTimer: null, + wsReconnectTimer: null, + wsReconnectDelay: 1000, + pushDevicesLastRefresh: 0, + }; + + function markFanDirty() { + state.fanDirty = true; + } + + function isFanEditing() { + return state.fanApplying + || state.fanDirty + || document.activeElement === els.fanSlider; + } + + function fanModeInputs() { + return Array.from(document.querySelectorAll('input[name="fanModeOption"]')); + } + + function selectedFanMode() { + return document.querySelector('input[name="fanModeOption"]:checked')?.value || 'auto'; + } + + function setSelectedFanMode(mode) { + const normalized = ['auto', 'manual', 'off'].includes(mode) ? mode : 'auto'; + fanModeInputs().forEach(input => { + input.checked = input.value === normalized; + }); + updateFanModeUi(normalized); + } + + function updateFanModeUi(mode = selectedFanMode()) { + if (els.fanSliderWrap) { + els.fanSliderWrap.hidden = mode !== 'manual'; + } + } + + function csrf() { + return document.querySelector('meta[name="csrf-token"]')?.content || ''; + } + + function escapeHtml(v) { + return String(v ?? 'N/A') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + } + + function notice(text, type = 'info') { + if (!els.notice) return; + els.notice.textContent = text; + els.notice.dataset.type = type; + clearTimeout(els.notice._timer); + els.notice._timer = setTimeout(() => { + els.notice.textContent = ''; + els.notice.dataset.type = ''; + }, 2600); + } + + async function api(action, body = null) { + const opts = { + method: body ? 'POST' : 'GET', + credentials: 'same-origin', + headers: {}, + cache: 'no-store', + }; + + let url = '/api.php?action=' + encodeURIComponent(action) + '&_=' + Date.now(); + if (body) { + const fd = new URLSearchParams(); + Object.entries(body).forEach(([k, v]) => fd.append(k, String(v))); + fd.append('action', action); + fd.append('csrf', csrf()); + opts.method = 'POST'; + opts.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + opts.headers['X-CSRF-Token'] = csrf(); + opts.body = fd.toString(); + url = '/api.php'; + } + + const res = await fetch(url, opts); + const json = await res.json(); + if (!res.ok || !json.ok) { + throw new Error(json?.message || json?.error || 'API error'); + } + return json.data; + } + + function websocketUrl() { + const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${scheme}//${location.host}/ws`; + } + + function sendWs(payload) { + if (!state.wsConnected || !state.ws || state.ws.readyState !== WebSocket.OPEN) { + return false; + } + + state.ws.send(JSON.stringify(payload)); + return true; + } + + function stopStatusFallback() { + clearInterval(state.wsFallbackTimer); + state.wsFallbackTimer = null; + } + + function startStatusFallback() { + if (state.wsFallbackTimer) return; + + refreshStatus(); + state.wsFallbackTimer = setInterval(() => { + if (!state.wsConnected) { + refreshStatus(); + } + }, 2000); + } + + function connectControlSocket() { + if (!('WebSocket' in window)) { + startStatusFallback(); + return; + } + + clearTimeout(state.wsReconnectTimer); + + try { + state.ws = new WebSocket(websocketUrl()); + } catch (e) { + console.error(e); + startStatusFallback(); + return; + } + + state.ws.addEventListener('open', () => { + state.wsConnected = true; + state.wsReconnectDelay = 1000; + stopStatusFallback(); + if (state.dmesgOpen) { + stopDmesgFallback(); + sendWs({ type: 'dmesg', open: true }); + } + }); + + state.ws.addEventListener('message', event => { + let message = null; + try { + message = JSON.parse(event.data); + } catch (e) { + console.error(e); + return; + } + + if (message?.type === 'status') { + render(message.data || {}); + return; + } + + if (message?.type === 'dmesg') { + renderDmesg(message.data || {}); + return; + } + + if (message?.type === 'error') { + console.error(message.message || 'websocket error'); + } + }); + + state.ws.addEventListener('close', () => { + state.wsConnected = false; + startStatusFallback(); + if (state.dmesgOpen) { + startDmesgFallback(); + } + const delay = state.wsReconnectDelay; + state.wsReconnectDelay = Math.min(15000, state.wsReconnectDelay * 1.7); + state.wsReconnectTimer = setTimeout(connectControlSocket, delay); + }); + + state.ws.addEventListener('error', event => { + console.error(event); + }); + } + + function setText(node, value) { + if (node) node.textContent = value ?? 'N/A'; + } + + function renderTop(data) { + setText(els.updated, data.generated_at || '-'); + setText(els.temp, Number(data.system?.temp_c || 0).toFixed(1) + '°C'); + setText(els.fanRpm, Number(data.fan?.rpm || 0).toLocaleString() + ' RPM'); + setText(els.fanPercent, Number(data.fan?.percent || 0).toFixed(1) + '%'); + setText(els.wifi24, data.wifi?.count24 ?? 0); + setText(els.wifi5, data.wifi?.count5 ?? 0); + state.latestFanPwm = Math.max(0, Math.min(255, Number(data.fan?.pwm || 0))); + + if (!isFanEditing()) { + const serverPwm = data.fan?.target_pwm ?? data.fan?.pwm ?? 120; + if (els.fanSlider) { + els.fanSlider.value = serverPwm; + } + if (els.fanSliderValue) { + els.fanSliderValue.textContent = String(serverPwm); + } + } + if (!state.fanApplying) setSelectedFanMode(data.fan?.mode || 'auto'); + } + + function td(v) { + return `${escapeHtml(v)}`; + } + + function parseWifiDurationSeconds(value) { + const text = String(value ?? '').trim(); + if (text === '' || text.toUpperCase() === 'N/A') { + return null; + } + + if (/^\d+(?:\.\d+)?$/.test(text)) { + return Number(text); + } + + let total = 0; + let matched = false; + const pattern = /(\d+(?:\.\d+)?)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|min|m|seconds?|secs?|sec|s|milliseconds?|msecs?|msec|ms)\b/gi; + let match; + + while ((match = pattern.exec(text)) !== null) { + const amount = Number(match[1]); + const unit = match[2].toLowerCase(); + matched = true; + + if (unit === 'd' || unit.startsWith('day')) { + total += amount * 86400; + } else if (unit === 'h' || unit.startsWith('hour') || unit.startsWith('hr')) { + total += amount * 3600; + } else if (unit === 'm' || unit.startsWith('min')) { + total += amount * 60; + } else if (unit === 'ms' || unit.startsWith('msec') || unit.startsWith('millisecond')) { + total += amount / 1000; + } else { + total += amount; + } + } + + return matched ? total : null; + } + + function formatDhms(seconds) { + if (seconds === null || seconds === undefined || !Number.isFinite(Number(seconds))) { + return null; + } + + const totalSeconds = Math.max(0, Math.round(Number(seconds))); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + const parts = []; + + if (days > 0) parts.push(`${days}d`); + if (days > 0 || hours > 0) parts.push(`${hours}h`); + if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}m`); + parts.push(`${secs}s`); + + return parts.join(' '); + } + + function wifiConnectedTime(value) { + return formatDhms(parseWifiDurationSeconds(value)) || value; + } + + function renderWifi(data) { + const rows = data.wifi?.clients || []; + if (!els.wifiTable) return; + els.wifiTable.innerHTML = rows.length + ? rows.map(row => ` + + ${td(row.band)} + ${td(row.hostname)} + ${td(row.ip)} + ${td(row.mac)} + ${td(row.signal)} + ${td(row.tx_bitrate)} + ${td(row.rx_bitrate)} + ${td(wifiConnectedTime(row.connected_time))} + ${td(row.inactive_time)} + + `).join('') + : '연결된 WiFi 클라이언트 없음'; + } + + function renderSystemStatus(data) { + const system = data.system || {}; + const disk = system.disk || {}; + const memory = system.memory || {}; + const battery = data.battery || {}; + const load = Array.isArray(system.load) ? system.load : []; + const activeUsers = system.active_users || {}; + + setText(els.statusHost, system.hostname || '-'); + setText(els.statusLoad, load.length ? load.map(v => Number(v || 0).toFixed(2)).join(' / ') : '-'); + setText(els.statusUsers, activeUsers.display || `0 users / 0 sessions`); + setText(els.statusDisk, `${Number(disk.used_kb || 0).toLocaleString()} / ${Number(disk.total_kb || 0).toLocaleString()} KB (${disk.percent ?? '-'}%)`); + setText(els.statusMemory, `${memory.used_mb ?? '-'} / ${memory.total_mb ?? '-'} MB (${memory.percent ?? '-'}%)`); + setText(els.statusUptime, system.uptime || '-'); + setText(els.statusBatteryVoltage, battery.voltage === null || battery.voltage === undefined ? '-' : `${Number(battery.voltage).toFixed(3)} V`); + setText(els.statusBatterySoc, battery.percent === null || battery.percent === undefined ? '-' : `${Number(battery.percent).toFixed(2)}%`); + setText(els.statusBatteryRemaining, battery.remaining?.display || '-'); + if (els.statusUsers) { + const names = String(activeUsers.names || '').trim(); + els.statusUsers.innerHTML = names + ? `${escapeHtml(activeUsers.display || '0 users / 0 sessions')}
${escapeHtml(names)}` + : escapeHtml(activeUsers.display || '0 users / 0 sessions'); + } + } + + function renderProcessRows(node, rows, metric) { + if (!node) return; + + node.innerHTML = rows.length + ? rows.map(row => { + const value = metric === 'cpu' + ? `${Number(row.cpu_percent || 0).toFixed(1)}%` + : `${Number(row.mem_percent || 0).toFixed(1)}%`; + + return ` + + ${td(row.pid)} + ${td(value)} + ${td(row.service || 'N/A')} + ${td(row.command || row.name || 'N/A')} + + `; + }).join('') + : 'No process activity'; + } + + function shortProcessName(p) { + if (p?.service && p.service !== 'N/A') { + return p.service; + } + + const cmd = String(p?.command || p?.name || ''); + + if (cmd.includes('/codex ') || cmd.includes('/codex') || cmd.includes('codex app-server')) { + return 'codex'; + } + + if (cmd.includes('.vscode-server')) { + return 'vscode-server'; + } + + if (cmd.includes('python3 -m homeassistant')) { + return 'homeassistant'; + } + + if (cmd.includes('firefox')) { + return 'firefox'; + } + + return cmd || 'N/A'; + } + + function processIdentity(p) { + return [ + p?.pid || '', + p?.service || '', + p?.command || p?.name || '', + ].join('|'); + } + + function spikeProcessText(row) { + const cpuRows = Array.isArray(row.cpu_process) ? row.cpu_process : []; + const memRows = Array.isArray(row.memory_process) ? row.memory_process : []; + + const cpu = cpuRows.find(p => { + const cmd = String(p.command || p.name || ''); + const service = String(p.service || ''); + return !cmd.includes('/bin/ps') + && !cmd.includes('api.php') + && !cmd.includes('php-fpm') + && service !== 'fanpanel-apply.service'; + }); + const mem = memRows[0] || null; + + if (cpu && mem && processIdentity(mem) === processIdentity(cpu)) { + return shortProcessName(cpu); + } + + const parts = []; + + if (cpu) { + parts.push(`CPU ${Number(cpu.cpu_percent || 0).toFixed(1)}% ${shortProcessName(cpu)}`); + } + + if (mem) { + parts.push(`RAM ${Number(mem.mem_percent || 0).toFixed(1)}% ${shortProcessName(mem)}`); + } + + return parts.length ? parts.join(' / ') : '원인 후보 없음'; + } + + function signedCompact(value, digits = 0, suffix = '') { + const n = Number(value || 0); + const sign = n > 0 ? '+' : ''; + + return `${sign}${n.toLocaleString(undefined, { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + })}${suffix}`; + } + + function noticeMetrics(row) { + const rpmDelta = Number(row.rpm_delta || 0); + const pwmDelta = Number(row.pwm_delta || 0); + const tempDelta = Number(row.temp_delta || 0); + const avgTemp = Number(row.current_temp || 0) - tempDelta; + const avgRpm = Number(row.current_rpm || 0) - rpmDelta; + const avgPwm = Number(row.current_pwm || 0) - pwmDelta; + + return { + rpmDelta, + tempDelta, + avgTemp, + avgRpm, + avgPwm, + }; + } + + function noticeChangeText(row) { + const m = noticeMetrics(row); + const reasons = []; + + if (Math.abs(m.tempDelta) >= 3) reasons.push(`온도 평균보다 ${m.tempDelta >= 0 ? '상승' : '하강'}`); + if (Math.abs(m.rpmDelta) >= 1000) reasons.push(`팬RPM 평균보다 ${m.rpmDelta >= 0 ? '상승' : '하강'}`); + + return `기록된 이유: ${reasons.length ? reasons.join(', ') : '순간 변화'}`; + } + + function noticePreviousText(row) { + const m = noticeMetrics(row); + + return `평균: ${m.avgTemp.toFixed(1)}°C / ${Math.round(m.avgRpm).toLocaleString()} RPM / PWM ${Math.round(m.avgPwm)}`; + } + + function noticeCurrentText(row) { + return `현재: ${Number(row.current_temp || 0).toFixed(1)}°C / ${Number(row.current_rpm || 0).toLocaleString()} RPM / PWM ${Number(row.current_pwm || 0).toFixed(0)}`; + } + + function renderSpikeHistory(rows = []) { + if (!els.spikeLogList) return; + + els.spikeLogList.innerHTML = rows.length + ? rows.map((row, index) => { + const processText = spikeProcessText(row); + + return ` +
+ ${escapeHtml(row.created_at || row.time || '-')} + ${escapeHtml(noticeChangeText(row))} + ${escapeHtml(noticePreviousText(row))} + ${escapeHtml(noticeCurrentText(row))} + ${processText === '원인 후보 없음' ? '' : `원인 후보: ${escapeHtml(processText)}`} +
+ `; + }).join('') + : '
No system notice history.
'; + } + + function renderPushDevices(devices = []) { + if (!els.pushDeviceList) return; + + els.pushDeviceList.innerHTML = devices.length + ? devices.map(device => ` +
+
+ ${escapeHtml(device.device_name || '이름 없음')} + Host: ${escapeHtml(device.host || 'unknown')} + IP: ${escapeHtml(device.actor_ip || '-')} + Created: ${escapeHtml(device.created_at || '-')} + Last seen: ${escapeHtml(device.last_seen_at || '-')} + Encoding: ${escapeHtml(device.content_encoding || '-')} + Hash: ${escapeHtml(device.hash || '-')} + Endpoint: ${escapeHtml(device.endpoint || '-')} + UA: ${escapeHtml(device.user_agent || 'unknown device')} +
+
+ `).join('') + : '
No push devices.
'; + } + + function renderPushStatus(detail = {}) { + if (!els.pushStatus) return; + + const supported = detail.supported === true; + const permission = detail.permission || 'unknown'; + const hasBrowserSubscription = detail.browser_subscription === true; + const hasServerSubscription = detail.server_subscription === true; + const manualDisabled = detail.manual_disabled === true; + const serverChecked = detail.server_checked === true; + + let text = ''; + if (!supported) { + text = 'Push 상태: 브라우저 미지원'; + } else if (permission === 'denied') { + text = 'Push 상태: 권한 꺼짐'; + } else if (permission !== 'granted') { + text = 'Push 상태: 권한 미허용'; + } else if (manualDisabled) { + text = 'Push 상태: 사용자가 직접 해제함'; + } else { + const browserText = hasBrowserSubscription ? '브라우저에는 있음' : '브라우저에는 없음'; + const serverText = serverChecked + ? (hasServerSubscription ? '서버에는 있음' : '서버에는 없음') + : '서버 확인 전'; + text = `Push 상태: ${browserText} / ${serverText}`; + } + + els.pushStatus.textContent = text; + } + + async function refreshPushDevices(force = false) { + if (!force && Date.now() - state.pushDevicesLastRefresh < 30000) { + return; + } + + state.pushDevicesLastRefresh = Date.now(); + try { + const data = await api('push_devices'); + renderPushDevices(data.devices || []); + } catch (e) { + console.error(e); + } + } + + window.addEventListener('pushdevices:refresh', () => { + refreshPushDevices(true); + }); + + window.addEventListener('pushstatus:update', event => { + renderPushStatus(event.detail || {}); + }); + + function renderFanCause(data) { + const processes = data.processes || {}; + const baseline = data.fan_spike || {}; + const baselineTemp = Number(baseline.temp_avg || 0); + const baselineRpm = Number(baseline.rpm_avg || 0); + const stateText = baseline.notice_state === 'alert' ? 'ALERT' : 'NORMAL'; + const baselineText = baselineTemp > 0 || baselineRpm > 0 + ? `${baselineTemp.toFixed(1)}°C / ${Math.round(baselineRpm).toLocaleString()} RPM · ${stateText}` + : stateText; + renderSpikeHistory(data.fan_spike_history || []); + setText(els.noticeBaseline, baselineText); + + renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu'); + renderProcessRows(els.processMemoryTable, processes.memory || [], 'memory'); + refreshPushDevices(); + } + + function rollingStats(values, windowSize = 30) { + const avg = []; + const min = []; + const max = []; + const samples = []; + + values.forEach(value => { + if (Number.isFinite(value)) { + samples.push(value); + } + if (samples.length > windowSize) { + samples.shift(); + } + if (!samples.length) { + avg.push(null); + min.push(null); + max.push(null); + return; + } + + avg.push(samples.reduce((sum, v) => sum + v, 0) / samples.length); + min.push(Math.min(...samples)); + max.push(Math.max(...samples)); + }); + + return { avg, min, max }; + } + + function emaValues(values, alpha = 0.18) { + let previous = null; + return values.map(value => { + if (!Number.isFinite(value)) { + return previous; + } + previous = previous === null ? value : (value * alpha) + (previous * (1 - alpha)); + return previous; + }); + } + + function formatDurationSeconds(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) { + return '-'; + } + + const totalSeconds = Math.max(0, Math.round(Number(value))); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const parts = []; + + if (days > 0) { + parts.push(`${days}일`); + } + + if (days > 0 || hours > 0) { + parts.push(`${hours}시`); + } + + parts.push(`${minutes}분`); + + return parts.join(' '); + } + + function chart(canvasId, label, rows, key, color, suffix = '', scaleOptions = {}, options = {}) { + const canvas = $('#' + canvasId); + if (!canvas || typeof Chart === 'undefined') return; + + const labels = rows.map(row => String(row.time || '').slice(11, 19)); + const timestamps = rows.map(row => { + const time = String(row.time || '').replace(' ', 'T'); + const parsed = Date.parse(time); + return Number.isFinite(parsed) ? parsed : null; + }); + const latestTimestamp = [...timestamps].reverse().find(value => value !== null) ?? null; + const tickAgeSeconds = index => { + if (latestTimestamp === null || timestamps[index] === null) return null; + return Math.max(0, Math.round((latestTimestamp - timestamps[index]) / 1000)); + }; + const xMinuteTicks = [60, 120, 180]; + const xTickMarkers = new Map(); + xMinuteTicks.forEach(targetAge => { + let bestIndex = -1; + let bestDistance = Number.POSITIVE_INFINITY; + timestamps.forEach((_timestamp, index) => { + const age = tickAgeSeconds(index); + if (age === null) return; + const distance = Math.abs(age - targetAge); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + }); + if (bestIndex >= 0 && bestDistance <= 15) { + xTickMarkers.set(bestIndex, `${targetAge / 60}m ago`); + } + }); + const isXGridTick = index => { + return xTickMarkers.has(index); + }; + const xTickLabel = index => { + return xTickMarkers.get(index) || ''; + }; + const xTickCallback = (_value, index) => isXGridTick(index) ? xTickLabel(index) : ''; + const xGridColor = context => { + const index = Number(context.index ?? context.tick?.value ?? -1); + return isXGridTick(index) ? 'rgba(203,213,225,.18)' : 'rgba(203,213,225,0)'; + }; + const rawValues = rows.map(row => { + const value = row[key]; + return value === null || value === undefined || value === '' ? null : Number(value); + }); + const values = typeof options.transform === 'function' ? options.transform(rawValues) : rawValues; + const stats = rollingStats(values, options.window || 30); + const datasets = [{ + label: `${label} AVG`, + data: stats.avg, + minData: stats.min, + maxData: stats.max, + borderColor: color, + backgroundColor: 'transparent', + borderWidth: 2.4, + pointRadius: 0, + tension: 0.34, + fill: false, + }]; + + if (options.showRaw) { + datasets.push({ + label, + data: values, + borderColor: color + '66', + backgroundColor: 'transparent', + borderWidth: 1, + pointRadius: 0, + tension: 0.28, + fill: false, + }); + } + + if (!state.charts[canvasId]) { + state.charts[canvasId] = new Chart(canvas, { + type: 'line', + data: { + labels, + datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { + intersect: false, + mode: 'index', + }, + plugins: { + legend: { display: false }, + tooltip: { + displayColors: false, + padding: 8, + callbacks: { + beforeBody: items => { + const row = items.find(item => item.dataset.label.endsWith('AVG')); + if (!row) return []; + const idx = row.dataIndex; + const avgDataset = row.chart.data.datasets.find(ds => ds.label.endsWith('AVG')); + const avg = avgDataset?.data?.[idx]; + const min = avgDataset?.minData?.[idx]; + const max = avgDataset?.maxData?.[idx]; + const format = typeof options.tooltipFormat === 'function' + ? options.tooltipFormat + : value => value === null || value === undefined || !Number.isFinite(Number(value)) + ? '-' + : `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`; + return [ + `MAX: ${format(max)}`, + `AVG: ${format(avg)}`, + `MIN: ${format(min)}`, + ]; + }, + label: () => null, + labelColor: () => ({ + borderColor: 'transparent', + backgroundColor: 'transparent', + }), + }, + filter: item => item.dataset.label.endsWith('AVG'), + }, + }, + scales: { + x: { + ticks: { + color: '#64748b', + maxRotation: 0, + autoSkip: false, + callback: xTickCallback, + }, + grid: { + color: xGridColor, + drawTicks: false, + }, + border: { display: false }, + }, + y: { + min: 0, + ...scaleOptions, + ticks: { color: '#8d98aa', maxTicksLimit: 4 }, + grid: { color: 'rgba(255,255,255,.045)', drawTicks: false }, + border: { display: false }, + }, + }, + }, + }); + return; + } + + const c = state.charts[canvasId]; + c.data.labels = labels; + c.data.datasets = datasets; + c.options.scales.x.ticks.callback = xTickCallback; + c.options.scales.x.grid.color = xGridColor; + c.options.scales.y.min = scaleOptions.min ?? 0; + c.options.scales.y.max = scaleOptions.max; + c.update('none'); + } + + function dynamicScaleForValues(values, options = {}) { + const floor = Number(options.floor ?? 0); + const ceiling = Number.isFinite(Number(options.ceiling)) ? Number(options.ceiling) : null; + const finiteValues = values.filter(value => Number.isFinite(value)); + + if (!finiteValues.length) { + return ceiling === null ? { min: floor } : { min: floor, max: ceiling }; + } + + const minValue = Math.min(...finiteValues); + const maxValue = Math.max(...finiteValues); + const center = (minValue + maxValue) / 2; + const minSpan = Number(options.minSpan ?? 10); + const padding = Number(options.padding ?? Math.max(1, minSpan * 0.2)); + const span = Math.max(minSpan, maxValue - minValue + padding); + const min = Math.max(floor, Math.floor(center - span / 2)); + const naturalMax = Math.ceil(center + span / 2); + const max = ceiling === null ? naturalMax : Math.min(ceiling, naturalMax); + + return { + min, + max: Math.max(max, min + 1), + }; + } + + function chartValues(rows, key, transform = null) { + const rawValues = rows.map(row => { + const value = row[key]; + return value === null || value === undefined || value === '' ? null : Number(value); + }); + + return typeof transform === 'function' ? transform(rawValues) : rawValues; + } + + function dynamicScale(rows, key, options = {}, transform = null) { + return dynamicScaleForValues(chartValues(rows, key, transform), options); + } + + function renderCharts(data) { + const rows = (data.history || []).slice(-180); + const cpuWattSmoothing = values => emaValues(values, 0.18); + + chart('tempChart', 'CPUTEMP', rows, 'temp_c', '#ef4444', '°C', dynamicScale(rows, 'temp_c', { minSpan: 10 })); + chart('rp1TempChart', 'RP1TEMP', rows, 'rp1_temp_c', '#f97316', '°C', dynamicScale(rows, 'rp1_temp_c', { minSpan: 10 })); + chart('fanRpmChart', 'RPM', rows, 'fan_rpm', '#3b82f6', ' RPM', dynamicScale(rows, 'fan_rpm', { minSpan: 1000, padding: 400 })); + chart('fanEfficiencyChart', 'FANEFF', rows, 'fan_efficiency', '#84cc16', '', dynamicScale(rows, 'fan_efficiency', { minSpan: 20, ceiling: 100 })); + chart('cpuWattChart', 'CPUW', rows, 'cpu_watts', '#a855f7', 'W', dynamicScale(rows, 'cpu_watts', { minSpan: 1, padding: 0.4 }, cpuWattSmoothing), { transform: cpuWattSmoothing }); + chart('batterySocChart', 'BATTERYSOC', rows, 'battery_percent', '#f59e0b', '%', dynamicScale(rows, 'battery_percent', { minSpan: 10, ceiling: 110 })); + chart('remainingChart', 'REMAINING', rows, 'battery_remaining_seconds', '#06b6d4', 's', dynamicScale(rows, 'battery_remaining_seconds', { minSpan: 1800, padding: 600 }), { tooltipFormat: formatDurationSeconds }); + chart('batteryVoltageChart', 'BATTERYV', rows, 'battery_voltage', '#14b8a6', 'V', dynamicScale(rows, 'battery_voltage', { minSpan: 0.2, padding: 0.06 })); + } + + function render(data) { + renderTop(data); + renderSystemStatus(data); + renderWifi(data); + renderCharts(data); + + state.fanCauseTick = (state.fanCauseTick + 1) % 2; + + if (state.fanCauseTick === 0) { + renderFanCause(data); + } + } + + function scrollDmesgToTop() { + if (!els.dmesgOutput) return; + if (els.dmesgOutput.hidden) return; + + els.dmesgOutput.scrollTop = 0; + } + + function renderDmesg(data) { + if (!els.dmesgOutput || !els.dmesgMeta) return; + + if (!data?.available) { + els.dmesgOutput.textContent = data?.message || 'dmesg log is not available yet.'; + els.dmesgMeta.textContent = '/tmp/dmesg.log unavailable'; + return; + } + + const lines = data.lines || []; + const latestKey = `${data.line_count || 0}\n${lines[0] || ''}`; + const hasNewLine = state.dmesgLatestKey !== null && latestKey !== state.dmesgLatestKey; + + els.dmesgOutput.textContent = lines.join('\n'); + els.dmesgMeta.textContent = `${data.path || '/tmp/dmesg.log'} · ${data.line_count || 0} lines · updated ${data.updated_at || '-'}`; + state.dmesgLatestKey = latestKey; + + if (hasNewLine) { + requestAnimationFrame(scrollDmesgToTop); + } + } + + async function refreshDmesg() { + if (!state.dmesgOpen) return; + + try { + renderDmesg(await api('dmesg')); + } catch (e) { + console.error(e); + if (els.dmesgOutput) { + els.dmesgOutput.textContent = e.message || 'dmesg refresh failed'; + } + } + } + + function stopDmesgFallback() { + clearInterval(state.dmesgTimer); + state.dmesgTimer = null; + } + + function startDmesgFallback() { + if (state.dmesgTimer) return; + + refreshDmesg(); + state.dmesgTimer = setInterval(refreshDmesg, 1000); + } + + function setDmesgOpen(open) { + state.dmesgOpen = open; + if (els.dmesgOutput) { + els.dmesgOutput.hidden = !open; + } + if (els.dmesgToggle) { + els.dmesgToggle.textContent = open ? 'Hide' : 'Show'; + } + + stopDmesgFallback(); + if (open) { + if (!sendWs({ type: 'dmesg', open: true })) { + startDmesgFallback(); + } + } else { + sendWs({ type: 'dmesg', open: false }); + } + } + + async function refreshStatus() { + if (state.loading) return; + state.loading = true; + try { + render(await api('status')); + } catch (e) { + console.error(e); + notice(e.message || 'refresh failed', 'error'); + } finally { + state.loading = false; + } + } + + function selectedFanPwm() { + const mode = selectedFanMode(); + return mode === 'off' ? 0 : Number(els.fanSlider?.value || 120); + } + + function syncManualPwmFromCurrent() { + const pwm = Math.max(0, Math.min(255, Math.round(state.latestFanPwm))); + if (els.fanSlider) { + els.fanSlider.value = pwm; + } + if (els.fanSliderValue) { + els.fanSliderValue.textContent = String(pwm); + } + + return pwm; + } + + function scheduleFanApply(mode = selectedFanMode(), pwm = selectedFanPwm()) { + clearTimeout(state.fanApplyTimer); + state.fanApplyTimer = setTimeout(() => { + fanApply(mode, pwm, true); + }, 150); + } + + async function fanApply(mode = selectedFanMode(), pwm = selectedFanPwm(), quiet = false) { + if (state.fanApplying) { + state.fanApplyPending = { mode, pwm, quiet }; + return; + } + + try { + state.fanApplying = true; + if (!quiet) notice('Applying fan policy...', 'info'); + const data = await api('fan', { + mode, + pwm, + }); + state.fanDirty = false; + state.fanApplying = false; + render(data); + if (!quiet) notice('Fan updated', 'success'); + } catch (e) { + console.error(e); + notice(e.message || 'fan update failed', 'error'); + } finally { + state.fanApplying = false; + const pending = state.fanApplyPending; + state.fanApplyPending = null; + if (pending) fanApply(pending.mode, pending.pwm, pending.quiet); + } + } + + async function wifiAction(button) { + try { + button.disabled = true; + notice('Applying WiFi command...', 'info'); + render(await api('wifi', { + unit: button.dataset.wifiUnit, + verb: button.dataset.wifiAction, + })); + notice('WiFi command completed', 'success'); + } catch (e) { + console.error(e); + notice(e.message || 'wifi command failed', 'error'); + } finally { + button.disabled = false; + } + } + + if (els.fanSlider && els.fanSliderValue) { + els.fanSlider.addEventListener('input', () => { + markFanDirty(); + els.fanSliderValue.textContent = els.fanSlider.value; + scheduleFanApply(); + }); + els.fanSlider.addEventListener('focus', markFanDirty); + els.fanSlider.addEventListener('pointerdown', markFanDirty); + els.fanSlider.addEventListener('touchstart', markFanDirty); + } + fanModeInputs().forEach(input => { + input.addEventListener('change', () => { + if (!input.checked) return; + updateFanModeUi(input.value); + const pwm = input.value === 'manual' + ? syncManualPwmFromCurrent() + : (input.value === 'off' ? 0 : Number(els.fanSlider?.value || 120)); + fanApply(input.value, pwm, true); + }); + }); + document.querySelectorAll('[data-wifi-action]').forEach(button => { + button.addEventListener('click', () => wifiAction(button)); + }); + els.dmesgToggle?.addEventListener('click', () => { + setDmesgOpen(!state.dmesgOpen); + }); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + if (!sendWs({ type: 'status_refresh' })) { + refreshStatus(); + } + if (state.dmesgOpen && !sendWs({ type: 'dmesg', open: true })) { + refreshDmesg(); + } + } + }); + + connectControlSocket(); + startStatusFallback(); +})(); diff --git a/public/assets/apple-touch-icon.png b/public/assets/apple-touch-icon.png new file mode 100644 index 0000000..4adae2c Binary files /dev/null and b/public/assets/apple-touch-icon.png differ diff --git a/public/assets/console.css b/public/assets/console.css new file mode 100644 index 0000000..3fa5086 --- /dev/null +++ b/public/assets/console.css @@ -0,0 +1,98 @@ +:root { + --bg: #090d12; + --panel: #111821; + --tile: #0c1219; + --line: #263341; + --line2: #3a4a5d; + --text: #eaf1f8; + --muted: #91a0af; + --dim: #6e7c8b; + --blue: #54c7ec; + --green: #69db9a; + --yellow: #f0c860; + --red: #ff7a70; +} +* { box-sizing: border-box; } +html { background: var(--bg); } +body { margin: 0; background: var(--bg); color: var(--text); font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif; } +button, input, select { font: inherit; } +.login-page { min-height: 100vh; display: grid; place-items: center; } +.login-card { width: min(420px, calc(100vw - 32px)); border: 1px solid var(--line); background: var(--panel); padding: 28px; } +.login-card span, .brand span, .top-metrics span, .panel-head h2, .kv span, .fan-stats span, .path-box span, .card-list span, .triple h3 { color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } +.login-card h1 { margin: 8px 0 20px; font-size: 24px; } +.login-card input, .login-card button { width: 100%; min-height: 42px; margin-top: 10px; border: 1px solid var(--line2); background: var(--tile); color: var(--text); padding: 9px 11px; } +.login-card button { background: var(--blue); color: #061018; font-weight: 900; } +.login-card p { color: var(--red); margin-bottom: 0; } +.shell { width: 100%; min-width: 1260px; max-width: 1920px; margin: 0 auto; padding: 12px; } +.topbar { position: sticky; top: 0; z-index: 20; display: grid; grid-template-columns: 250px minmax(0, 1fr) auto; gap: 10px; align-items: stretch; border: 1px solid var(--line); background: rgba(9, 13, 18, .96); padding: 10px; margin-bottom: 12px; } +.brand { display: grid; align-content: center; min-width: 0; } +.brand h1 { margin: 4px 0 0; font-size: 20px; line-height: 1.1; } +.top-metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; } +.top-metrics div, .fan-stats div, .kv div, .path-box div, .card-list article { min-width: 0; border: 1px solid var(--line); background: var(--tile); padding: 9px; } +b, strong, td, th { overflow-wrap: anywhere; } +.top-metrics b, .fan-stats b, .kv b, .path-box b, .card-list b { display: block; margin-top: 4px; font-family: Consolas, Monaco, monospace; font-size: 13px; } +.top-metrics b { font-size: 15px; } +.logout { display: grid; place-items: center; border: 1px solid var(--line2); background: #172130; color: var(--text); padding: 0 12px; text-decoration: none; } +.layout { display: grid; grid-template-columns: 330px minmax(0, 1fr) 410px; gap: 12px; align-items: start; } +.left-col, .center-col, .right-col { display: grid; gap: 12px; min-width: 0; } +.left-col, .right-col { position: sticky; top: 88px; max-height: calc(100vh - 100px); overflow: auto; scrollbar-width: thin; } +.panel { min-width: 0; border: 1px solid var(--line); background: var(--panel); padding: 10px; } +.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 30px; margin-bottom: 10px; } +.panel-head h2 { margin: 0; color: var(--text); font-size: 13px; } +.panel-head h2::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 7px; background: var(--blue); } +.panel-head em { color: var(--green); font-style: normal; font-size: 12px; font-weight: 900; } +.fan-panel { background: #12202a; } +.fan-gauge { display: grid; place-items: center; height: 132px; border: 1px solid var(--line2); background: radial-gradient(circle, #172d39 0%, #0c141c 72%); } +.fan-gauge strong { font-family: Consolas, Monaco, monospace; font-size: 44px; line-height: 1; } +.fan-gauge span { color: var(--muted); font-size: 12px; } +.fan-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 8px; } +.mode-row, .pwm-row { display: flex; gap: 8px; margin-top: 10px; } +.mode-row button, .pwm-row button, .service-cards button, td button { min-height: 28px; border: 1px solid var(--line2); background: #182332; color: var(--text); cursor: pointer; padding: 5px 8px; } +body[data-fan-mode="auto"] #btnFanAuto, body[data-fan-mode="manual"] #btnFanManual, .pwm-row button { background: var(--blue); color: #061018; font-weight: 900; } +.mode-row button { flex: 1 1 0; } +.pwm-row input[type="range"] { flex: 1 1 auto; accent-color: var(--blue); } +.pwm-row input[type="number"], select { width: 82px; border: 1px solid var(--line2); background: var(--tile); color: var(--text); padding: 5px 7px; } +.path-box { display: grid; gap: 6px; margin-top: 10px; max-height: 140px; overflow: auto; } +.kv, .card-list, .service-cards { display: grid; gap: 8px; } +.service-cards { max-height: 430px; overflow: auto; } +.service-cards article { border: 1px solid var(--line); background: var(--tile); padding: 9px; } +.service-cards article.up { border-left: 3px solid var(--green); } +.service-cards article.down { border-left: 3px solid var(--red); } +.service-cards strong, .card-list strong { display: block; font-family: Consolas, Monaco, monospace; } +.service-cards span, .service-cards small { display: block; color: var(--muted); margin-top: 4px; } +.service-cards div { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } +.chart-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.chart-grid div { height: 220px; border: 1px solid var(--line); background: var(--tile); padding: 8px 8px 14px; } +.chart-grid canvas { display: block; max-height: calc(100% - 24px); } +.table-box, .event-box, .triple section { max-width: 100%; overflow: auto; border: 1px solid var(--line); background: var(--tile); } +.table-box.big { height: 360px; } +.table-box.huge { height: 430px; } +.table-box.small { max-height: 220px; } +.triple { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 8px; } +.triple section { height: 170px; padding: 9px; } +.triple h3 { margin: 0 0 8px; } +table { width: 100%; border-collapse: collapse; min-width: 760px; } +th, td { padding: 7px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; white-space: nowrap; font-size: 12px; } +th { position: sticky; top: 0; z-index: 2; background: #1a2532; color: var(--text); } +tr:nth-child(even) td { background: rgba(255,255,255,.025); } +.mono { font-family: Consolas, Monaco, monospace; } +.card-list article { position: relative; } +.card-list em { position: absolute; right: 9px; top: 9px; font-style: normal; font-size: 11px; font-weight: 900; } +.card-list em.up { color: var(--green); } +.card-list em.down { color: var(--red); } +.card-list div { border-top: 1px solid var(--line); margin-top: 8px; padding-top: 7px; } +.event-box { max-height: 220px; margin-top: 8px; } +.event { padding: 7px 8px; border-bottom: 1px solid var(--line); color: #dbe5ef; font-family: Consolas, Monaco, monospace; font-size: 12px; line-height: 1.35; } +.event.bad { color: var(--red); } +.empty { padding: 14px; color: var(--muted); font-size: 13px; } +.bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; } +.bottom .table-box { height: 300px; } +.notice { position: fixed; right: 16px; bottom: 16px; z-index: 80; border: 1px solid var(--line2); background: #142030; color: var(--text); padding: 12px 14px; } +.notice.error { border-color: var(--red); } +.notice.hidden { display: none; } +@media (max-width: 1500px) { + .shell { min-width: 1180px; } + .layout { grid-template-columns: 310px minmax(0, 1fr) 360px; } + .top-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .chart-grid { grid-template-columns: 1fr; } +} diff --git a/public/assets/favicon.svg b/public/assets/favicon.svg new file mode 100644 index 0000000..18b7993 --- /dev/null +++ b/public/assets/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/assets/icon-192.png b/public/assets/icon-192.png new file mode 100644 index 0000000..08e881d Binary files /dev/null and b/public/assets/icon-192.png differ diff --git a/public/assets/icon-512.png b/public/assets/icon-512.png new file mode 100644 index 0000000..0a0601b Binary files /dev/null and b/public/assets/icon-512.png differ diff --git a/public/assets/site.webmanifest b/public/assets/site.webmanifest new file mode 100644 index 0000000..65ab6e3 --- /dev/null +++ b/public/assets/site.webmanifest @@ -0,0 +1,24 @@ +{ + "name": "Seoul Control Center", + "short_name": "Seoul Control", + "description": "Fan and WiFi control panel", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0f1115", + "theme_color": "#0f1115", + "icons": [ + { + "src": "/assets/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/public/assets/wakelock.js b/public/assets/wakelock.js new file mode 100644 index 0000000..26e2669 --- /dev/null +++ b/public/assets/wakelock.js @@ -0,0 +1,70 @@ +(() => { + 'use strict'; + + const button = document.querySelector('#wakeLockBtn'); + if (!button) return; + + let wakeLock = null; + let wanted = false; + + function supported() { + return 'wakeLock' in navigator; + } + + function active() { + return wakeLock !== null && wakeLock.released === false; + } + + function render() { + const isActive = active(); + button.disabled = !supported(); + button.classList.toggle('wake-active', isActive); + button.textContent = 'WakeLock'; + button.title = supported() ? '화면 꺼짐 방지' : '현재 브라우저에서 WakeLock을 지원하지 않습니다.'; + } + + async function requestWakeLock() { + if (!supported()) { + render(); + return; + } + + wakeLock = await navigator.wakeLock.request('screen'); + wakeLock.addEventListener('release', () => { + wakeLock = null; + render(); + }); + render(); + } + + async function releaseWakeLock() { + wanted = false; + if (wakeLock) { + await wakeLock.release(); + } + wakeLock = null; + render(); + } + + button.addEventListener('click', () => { + const job = active() ? releaseWakeLock() : (async () => { + wanted = true; + await requestWakeLock(); + })(); + + job.catch(error => { + wanted = false; + wakeLock = null; + alert(error.message || 'WakeLock failed'); + render(); + }); + }); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && wanted && !active()) { + requestWakeLock().catch(() => render()); + } + }); + + render(); +})(); diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e6a0164 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..4b190d7 --- /dev/null +++ b/public/index.php @@ -0,0 +1,296 @@ + '푸시 알림 테스트', + 'body' => mb_substr($testPushBody, 0, 500), + 'url' => '/', + 'tag' => 'control-test-push-' . time(), + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + + header('Location: /'); + exit; +} +?> + + + + + + + + +<?= e(APP_NAME) ?> + + + + + + + + + + + + + + + + + +
+
+
+

+

Updated: loading...

+
+
+ + + Logout +
+
+ +
+
+
+

Fan Control

+
+ +
Fan RPM
-
+
PWM %
-
+
+
+
+
Mode
+
+ + + +
+
+ +
+ +
+

WiFi Control

+
+
2.4G Clients
-
+
5G Clients
-
+
+
+
+ + + +
+
+ +
+

System Status

+
+
Host
-
+
Load Avg
-
+
Active Users
-
+
Disk /
-
+
Memory
-
+
Uptime
-
+
Battery V
-
+
Battery SOC
-
+
Remaining
-
+
+
+ +
+ +
+
+

Sensor History

+
+

Temperature

+

RP1 Temp

+

Fan RPM

+

Fan Efficiency

+

CPU Watt

+

Battery SOC

+

Remaining

+

Battery Voltage

+
+
+ +
+

WiFi Clients

+
+ + + + + + + + + + + + + + +
BandHostnameIPMACSignalTX RateRX RateConnectedInactive
+
+
+
+
+ +
+
+

System Notice

+
Baseline: -
+
+
+
+

CPU

+
+ + + + +
PIDCPUServiceCommand
loading...
+
+
+
+

Memory

+
+ + + + +
PIDMEMServiceCommand
loading...
+
+
+
+ +
+

Notice History

+
+
No system notice history.
+
+
+ +
+

Push Devices

+
Push status checking...
+
+
No push devices.
+
+
+ +
+
+

dmesg

+ +
+
/tmp/dmesg.log
+ +
+
+
+
+ + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..65ab6e3 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Seoul Control Center", + "short_name": "Seoul Control", + "description": "Fan and WiFi control panel", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0f1115", + "theme_color": "#0f1115", + "icons": [ + { + "src": "/assets/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/public/push_subscribe.js b/public/push_subscribe.js new file mode 100644 index 0000000..9aa0229 --- /dev/null +++ b/public/push_subscribe.js @@ -0,0 +1,332 @@ +(() => { + 'use strict'; + + const button = document.querySelector('#pushEnableBtn'); + const publicKey = document.querySelector('meta[name="vapid-public-key"]')?.content || ''; + const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; + const pushDeviceNameStorageKey = 'controlPushDeviceName'; + const pushDisabledStorageKey = 'controlPushDisabled'; + let pushAutoRepairRunning = false; + + function setButton(text, disabled = false, active = false) { + if (!button) return; + button.textContent = text || 'Push'; + button.disabled = disabled; + button.dataset.active = active ? '1' : '0'; + } + + function publishPushStatus(detail) { + window.dispatchEvent(new CustomEvent('pushstatus:update', { + detail: Object.assign({ + supported: ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window), + permission: ('Notification' in window) ? Notification.permission : 'unsupported', + browser_subscription: false, + server_subscription: false, + server_checked: false, + manual_disabled: localStorage.getItem(pushDisabledStorageKey) === '1', + }, detail || {}), + })); + } + + function hangulCount(value) { + return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length; + } + + function deviceNameFromUser() { + const name = prompt('등록할까요? 등록하려면 디바이스 이름을 지정하세요 최소 2글자.', ''); + + if (name === null) { + return null; + } + + const trimmed = String(name || '').trim(); + + if (hangulCount(trimmed) < 2) { + throw new Error('기기 이름은 한글 2글자 이상이어야 합니다.'); + } + + return trimmed; + } + + function rememberDeviceName(deviceName) { + const trimmed = String(deviceName || '').trim(); + if (hangulCount(trimmed) >= 2) { + localStorage.setItem(pushDeviceNameStorageKey, trimmed); + localStorage.removeItem(pushDisabledStorageKey); + } + return trimmed; + } + + function savedDeviceName() { + const stored = String(localStorage.getItem(pushDeviceNameStorageKey) || '').trim(); + return hangulCount(stored) >= 2 ? stored : '자동복구'; + } + + function urlBase64ToUint8Array(value) { + const padding = '='.repeat((4 - value.length % 4) % 4); + const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + const output = new Uint8Array(raw.length); + + for (let i = 0; i < raw.length; i++) { + output[i] = raw.charCodeAt(i); + } + + return output; + } + + async function postForm(action, body) { + const fd = new URLSearchParams(); + Object.entries(body).forEach(([key, value]) => fd.append(key, String(value))); + fd.append('action', action); + fd.append('csrf', csrf); + + const res = await fetch('/api.php', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': csrf, + }, + body: fd.toString(), + }); + const json = await res.json(); + + if (!res.ok || !json.ok) { + throw new Error(json?.message || json?.error || 'push_request_failed'); + } + + return json.data; + } + + async function saveSubscription(subscription, deviceName) { + const payload = subscription.toJSON(); + payload.device_name = rememberDeviceName(deviceName); + + const res = await fetch('/api/save_subscription.php', { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + body: JSON.stringify(payload), + }); + const json = await res.json(); + + if (!res.ok || !json.ok) { + throw new Error(json?.message || json?.error || 'subscription_save_failed'); + } + } + + async function registration() { + const reg = await navigator.serviceWorker.register('/sw.js', { + scope: '/', + updateViaCache: 'none', + }); + await reg.update().catch(() => {}); + return reg; + } + + async function currentSubscription() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + return null; + } + + const existing = await navigator.serviceWorker.getRegistration('/'); + if (existing) { + await existing.update().catch(() => {}); + } + return existing ? existing.pushManager.getSubscription() : null; + } + + async function subscribePush(reg) { + return reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + } + + async function pushServerStatus(subscription) { + const endpoint = subscription ? subscription.endpoint : ''; + const res = await fetch('/api.php?action=push_status&endpoint=' + encodeURIComponent(endpoint), { + credentials: 'same-origin', + cache: 'no-store', + headers: { + 'X-CSRF-Token': csrf, + }, + }); + const json = await res.json(); + + if (!res.ok || !json.ok) { + throw new Error(json?.message || json?.error || 'push_status_failed'); + } + + return json.data || {}; + } + + async function repairSubscriptionIfNeeded() { + if (pushAutoRepairRunning) return; + if (!publicKey) return; + if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return; + if (Notification.permission !== 'granted') return; + if (localStorage.getItem(pushDisabledStorageKey) === '1') return; + + pushAutoRepairRunning = true; + + try { + const reg = await registration(); + let subscription = await reg.pushManager.getSubscription(); + + if (!subscription) { + subscription = await subscribePush(reg); + await saveSubscription(subscription, savedDeviceName()); + await refreshButton(); + window.dispatchEvent(new CustomEvent('pushdevices:refresh')); + return; + } + + const status = await pushServerStatus(subscription); + publishPushStatus({ + browser_subscription: true, + server_subscription: status.subscribed === true, + server_checked: true, + }); + if (status.device_name) { + rememberDeviceName(status.device_name); + } + + if (status.subscribed) { + await refreshButton(); + return; + } + + await saveSubscription(subscription, savedDeviceName()); + publishPushStatus({ + browser_subscription: true, + server_subscription: true, + server_checked: true, + }); + await refreshButton(); + window.dispatchEvent(new CustomEvent('pushdevices:refresh')); + } catch (error) { + console.warn('push auto repair failed', error); + } finally { + pushAutoRepairRunning = false; + } + } + + async function refreshButton() { + if (!button) return; + + if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) { + setButton('Push', true, false); + publishPushStatus({ supported: false }); + return; + } + + const subscription = await currentSubscription(); + if (subscription) { + setButton('Push', false, true); + publishPushStatus({ + browser_subscription: true, + }); + } else if (Notification.permission === 'denied') { + setButton('Push', true, false); + publishPushStatus({ + browser_subscription: false, + }); + } else { + setButton('Push', false, false); + publishPushStatus({ + browser_subscription: false, + }); + } + } + + async function subscribe() { + if (!publicKey) { + setButton('Push', true, false); + return; + } + + const deviceName = deviceNameFromUser(); + if (deviceName === null) { + await refreshButton(); + return; + } + + setButton('Push', true, false); + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + setButton('Push', false, false); + return; + } + + const reg = await registration(); + let subscription = await reg.pushManager.getSubscription(); + if (!subscription) { + subscription = await subscribePush(reg); + } + + await saveSubscription(subscription, deviceName); + publishPushStatus({ + browser_subscription: true, + server_subscription: true, + server_checked: true, + manual_disabled: false, + }); + await refreshButton(); + window.dispatchEvent(new CustomEvent('pushdevices:refresh')); + } + + async function unsubscribe() { + if (!confirm('푸시 기기 삭제할까요?')) { + await refreshButton(); + return; + } + + const subscription = await currentSubscription(); + if (!subscription) { + await refreshButton(); + return; + } + + setButton('Push', true, true); + localStorage.setItem(pushDisabledStorageKey, '1'); + await subscription.unsubscribe(); + await postForm('delete_push_endpoint', { + endpoint: subscription.endpoint, + }); + publishPushStatus({ + browser_subscription: false, + server_subscription: false, + server_checked: true, + manual_disabled: true, + }); + await refreshButton(); + window.dispatchEvent(new CustomEvent('pushdevices:refresh')); + } + + if (!button) return; + + button.addEventListener('click', () => { + const active = button.dataset.active === '1'; + const job = active ? unsubscribe() : subscribe(); + job.catch(error => { + alert(error.message || 'Push failed'); + refreshButton().catch(() => {}); + }); + }); + + refreshButton().catch(() => setButton('Push', false, false)); + repairSubscriptionIfNeeded(); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + repairSubscriptionIfNeeded(); + } + }); + setInterval(repairSubscriptionIfNeeded, 5 * 60 * 1000); +})(); diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..2dcfbec --- /dev/null +++ b/public/sw.js @@ -0,0 +1,123 @@ +async function currentEndpoint() { + const subscription = await self.registration.pushManager.getSubscription(); + return subscription ? subscription.endpoint : ''; +} + +async function windowClientCount() { + const allClients = await clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + return allClients.length; +} + +function logPushEvent(eventName, payload = {}, extra = {}) { + return Promise.all([ + currentEndpoint().catch(() => ''), + windowClientCount().catch(() => 0), + ]).then(([endpoint, clientCount]) => fetch('/api/push_event.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + cache: 'no-store', + body: JSON.stringify({ + event: eventName, + endpoint, + push_id: payload.push_id || '', + tag: payload.tag || '', + client_count: clientCount, + meta: extra, + }), + })).catch(() => {}); +} + +self.addEventListener('push', event => { + let payload = {}; + + try { + payload = event.data ? event.data.json() : {}; + } catch (e) { + payload = { + body: event.data ? event.data.text() : '', + }; + } + + const title = payload.title || 'Seoul Control Center'; + const vibrate = Array.isArray(payload.vibrate) + ? payload.vibrate.map(value => Number(value)).filter(value => Number.isFinite(value) && value >= 0) + : undefined; + const options = { + body: payload.body || 'System notice detected', + icon: '/assets/icon-192.png', + badge: '/assets/icon-192.png', + tag: payload.tag || 'control-push', + renotify: payload.renotify === true, + requireInteraction: payload.require_interaction === true || payload.requireInteraction === true, + silent: payload.silent === true, + vibrate, + data: { + url: payload.url || '/', + push_id: payload.push_id || '', + tag: payload.tag || 'control-push', + }, + }; + + event.waitUntil((async () => { + await logPushEvent('push_received', payload); + try { + await self.registration.showNotification(title, options); + await logPushEvent('notification_shown', payload); + } catch (error) { + await logPushEvent('notification_show_failed', payload, { + reason: error && error.message ? error.message : 'show_failed', + }); + throw error; + } + })()); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + + const url = event.notification.data?.url || '/'; + const payload = { + push_id: event.notification.data?.push_id || '', + tag: event.notification.data?.tag || event.notification.tag || '', + }; + + event.waitUntil((async () => { + await logPushEvent('notification_click', payload, { + action: event.action || '', + }); + + const allClients = await clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + + for (const client of allClients) { + if ('focus' in client) { + await client.focus(); + if ('navigate' in client) { + return client.navigate(url); + } + return; + } + } + + if (clients.openWindow) { + return clients.openWindow(url); + } + })()); +}); + +self.addEventListener('notificationclose', event => { + const payload = { + push_id: event.notification.data?.push_id || '', + tag: event.notification.data?.tag || event.notification.tag || '', + }; + + event.waitUntil(logPushEvent('notification_close', payload)); +});