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, last_send_success_at DATETIME NULL, last_send_failed_at DATETIME NULL, last_received_at DATETIME NULL, last_notification_at DATETIME NULL, last_click_at DATETIME NULL, failure_count INT UNSIGNED NOT NULL DEFAULT 0, last_failure_reason VARCHAR(255) NULL, UNIQUE KEY uniq_endpoint_hash (endpoint_hash), INDEX idx_last_seen_at (last_seen_at), INDEX idx_last_received_at (last_received_at), INDEX idx_last_send_success_at (last_send_success_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 push_subscriptions ADD COLUMN last_send_success_at DATETIME NULL AFTER actor_ip", "ALTER TABLE push_subscriptions ADD COLUMN last_send_failed_at DATETIME NULL AFTER last_send_success_at", "ALTER TABLE push_subscriptions ADD COLUMN last_received_at DATETIME NULL AFTER last_send_failed_at", "ALTER TABLE push_subscriptions ADD COLUMN last_notification_at DATETIME NULL AFTER last_received_at", "ALTER TABLE push_subscriptions ADD COLUMN last_click_at DATETIME NULL AFTER last_notification_at", "ALTER TABLE push_subscriptions ADD COLUMN failure_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_click_at", "ALTER TABLE push_subscriptions ADD COLUMN last_failure_reason VARCHAR(255) NULL AFTER failure_count", "ALTER TABLE push_subscriptions ADD INDEX idx_last_received_at (last_received_at)", "ALTER TABLE push_subscriptions ADD INDEX idx_last_send_success_at (last_send_success_at)", "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), ]); update_push_subscription_health($event, $endpointHash, $context); } catch (Throwable) { } } function update_push_subscription_health(string $event, string $endpointHash, array $context = []): void { if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) { return; } $reason = mb_substr((string)($context['reason'] ?? ''), 0, 255); try { if (in_array($event, ['register', 'register_update'], true)) { $stmt = db()->prepare(" UPDATE push_subscriptions SET last_seen_at = CURRENT_TIMESTAMP, failure_count = 0, last_failure_reason = NULL WHERE endpoint_hash = :endpoint_hash "); $stmt->execute([':endpoint_hash' => $endpointHash]); return; } if ($event === 'send_success') { $stmt = db()->prepare(" UPDATE push_subscriptions SET last_send_success_at = CURRENT_TIMESTAMP, failure_count = 0, last_failure_reason = NULL WHERE endpoint_hash = :endpoint_hash "); $stmt->execute([':endpoint_hash' => $endpointHash]); return; } if ($event === 'send_failed') { $stmt = db()->prepare(" UPDATE push_subscriptions SET last_send_failed_at = CURRENT_TIMESTAMP, failure_count = failure_count + 1, last_failure_reason = :reason WHERE endpoint_hash = :endpoint_hash "); $stmt->execute([ ':endpoint_hash' => $endpointHash, ':reason' => $reason !== '' ? $reason : 'send_failed', ]); return; } $columnByEvent = [ 'push_received' => 'last_received_at', 'notification_shown' => 'last_notification_at', 'notification_click' => 'last_click_at', ]; if (isset($columnByEvent[$event])) { $column = $columnByEvent[$event]; $stmt = db()->prepare(" UPDATE push_subscriptions SET {$column} = CURRENT_TIMESTAMP, failure_count = 0, last_failure_reason = NULL WHERE endpoint_hash = :endpoint_hash "); $stmt->execute([':endpoint_hash' => $endpointHash]); } } 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, last_send_success_at, last_send_failed_at, last_received_at, last_notification_at, last_click_at, failure_count, last_failure_reason, TIMESTAMPDIFF(SECOND, last_seen_at, NOW()) AS last_seen_seconds, TIMESTAMPDIFF(SECOND, last_send_success_at, NOW()) AS last_send_success_seconds, TIMESTAMPDIFF(SECOND, last_received_at, NOW()) AS last_received_seconds, TIMESTAMPDIFF(SECOND, last_notification_at, NOW()) AS last_notification_seconds FROM push_subscriptions ORDER BY last_seen_at DESC LIMIT 200 "); $rows = []; foreach ($stmt->fetchAll() as $row) { $endpoint = (string)($row['endpoint'] ?? ''); $failureCount = (int)($row['failure_count'] ?? 0); $lastReceivedSeconds = isset($row['last_received_seconds']) ? (int)$row['last_received_seconds'] : null; $lastSendSuccessSeconds = isset($row['last_send_success_seconds']) ? (int)$row['last_send_success_seconds'] : null; $status = 'pending'; $statusText = '수신 대기'; if ($failureCount >= 3) { $status = 'failed'; $statusText = '발송 실패 누적'; } elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 86400) { $status = 'healthy'; $statusText = '정상'; } elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 604800) { $status = 'watch'; $statusText = '수신 지연'; } elseif ($lastSendSuccessSeconds !== null) { $status = 'stale'; $statusText = '장기 미수신'; } $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'] ?? ''), 'last_send_success_at' => (string)($row['last_send_success_at'] ?? ''), 'last_send_failed_at' => (string)($row['last_send_failed_at'] ?? ''), 'last_received_at' => (string)($row['last_received_at'] ?? ''), 'last_notification_at' => (string)($row['last_notification_at'] ?? ''), 'last_click_at' => (string)($row['last_click_at'] ?? ''), 'last_seen_seconds' => $row['last_seen_seconds'] !== null ? (int)$row['last_seen_seconds'] : null, 'last_send_success_seconds' => $lastSendSuccessSeconds, 'last_received_seconds' => $lastReceivedSeconds, 'last_notification_seconds' => $row['last_notification_seconds'] !== null ? (int)$row['last_notification_seconds'] : null, 'failure_count' => $failureCount, 'last_failure_reason' => (string)($row['last_failure_reason'] ?? ''), 'health_status' => $status, 'health_text' => $statusText, ]; } return $rows; } function push_health_summary(): array { $devices = push_device_rows(); $summary = [ 'total' => count($devices), 'healthy' => 0, 'watch' => 0, 'stale' => 0, 'failed' => 0, 'pending' => 0, ]; foreach ($devices as $device) { $status = (string)($device['health_status'] ?? 'pending'); if (!array_key_exists($status, $summary)) { $status = 'pending'; } $summary[$status]++; } return $summary; } 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 latest_push_send_epoch_by_tag(string $tagPrefix): int { $stmt = db()->prepare(" SELECT UNIX_TIMESTAMP(MAX(created_at)) FROM push_event_logs WHERE event = 'send_success' AND meta LIKE :tag "); $stmt->execute([ ':tag' => '%\"tag\":\"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagPrefix) . '%', ]); return (int)($stmt->fetchColumn() ?: 0); } function send_push_healthcheck_if_due(int $minHours = 24, bool $force = false): array { $minHours = max(1, min(168, $minHours)); $latest = latest_push_send_epoch_by_tag('control-healthcheck-'); if (!$force && $latest > 0 && time() - $latest < $minHours * 3600) { return [ 'sent' => 0, 'failed' => 0, 'error' => null, 'skipped' => true, 'message' => 'healthcheck_cooldown', 'next_after_seconds' => ($minHours * 3600) - (time() - $latest), ]; } return send_push_payload([ 'title' => 'Seoul Control Center', 'body' => '푸시 기기 상태 확인 알림입니다.', 'url' => '/', 'tag' => 'control-healthcheck-' . date('YmdHi'), 'created_at' => date('Y-m-d H:i:s'), 'silent' => true, 'data' => [ 'kind' => 'push_healthcheck', ], ]); } 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', ]); }