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 clamp_float(float $value, float $min, float $max): float { return max($min, min($max, $value)); } function setting_definitions(): array { $definitions = [ 'security.remember_days' => ['group' => 'security', 'label' => '자동 로그인 유지 기간', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365, 'step' => 1, 'unit' => '일'], 'security.cookie_secure_mode' => ['group' => 'security', 'label' => 'Remember 쿠키 Secure', 'type' => 'select', 'default' => 'auto', 'options' => ['auto' => 'HTTPS일 때 자동', 'always' => '항상 사용', 'never' => '사용 안함']], 'security.cookie_samesite' => ['group' => 'security', 'label' => 'Remember 쿠키 SameSite', 'type' => 'select', 'default' => 'Lax', 'options' => ['Lax' => 'Lax', 'Strict' => 'Strict', 'None' => 'None']], 'security.csrf_bytes' => ['group' => 'security', 'label' => 'CSRF 토큰 바이트', 'type' => 'number', 'default' => 24, 'min' => 16, 'max' => 64, 'step' => 1, 'unit' => 'bytes'], 'security.remember_selector_bytes' => ['group' => 'security', 'label' => 'Remember selector 바이트', 'type' => 'number', 'default' => 12, 'min' => 8, 'max' => 32, 'step' => 1, 'unit' => 'bytes'], 'security.remember_validator_bytes' => ['group' => 'security', 'label' => 'Remember validator 바이트', 'type' => 'number', 'default' => 32, 'min' => 16, 'max' => 64, 'step' => 1, 'unit' => 'bytes'], 'security.remember_bind_user_agent' => ['group' => 'security', 'label' => 'Remember User-Agent 고정', 'type' => 'boolean', 'default' => 1], 'security.remember_bind_ip_prefix_v4' => ['group' => 'security', 'label' => 'Remember IPv4 접두사 고정', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 32, 'step' => 1, 'unit' => 'bit'], 'security.session_regenerate_auto_login' => ['group' => 'security', 'label' => '자동 로그인 시 세션 재발급', 'type' => 'boolean', 'default' => 1], 'security.prune_expired_remember_tokens' => ['group' => 'security', 'label' => '만료 Remember 토큰 자동 정리', 'type' => 'boolean', 'default' => 1], 'security.login_csrf_required' => ['group' => 'security', 'label' => '로그인 CSRF 검사', 'type' => 'boolean', 'default' => 1], 'security.login_max_failures' => ['group' => 'security', 'label' => '로그인 실패 허용 횟수', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 30, 'step' => 1, 'unit' => '회'], 'security.login_window_seconds' => ['group' => 'security', 'label' => '로그인 실패 집계 시간', 'type' => 'number', 'default' => 600, 'min' => 60, 'max' => 86400, 'step' => 60, 'unit' => '초'], 'security.login_lock_seconds' => ['group' => 'security', 'label' => '로그인 잠금 시간', 'type' => 'number', 'default' => 300, 'min' => 10, 'max' => 86400, 'step' => 10, 'unit' => '초'], 'security.reboot_phrase' => ['group' => 'security', 'label' => '재부팅 확인 문구', 'type' => 'text', 'default' => '재부팅', 'min_length' => 2, 'max_length' => 32], 'security.allow_reboot' => ['group' => 'security', 'label' => '재부팅 실행 허용', 'type' => 'boolean', 'default' => 1], 'security.reboot_timeout_seconds' => ['group' => 'security', 'label' => '재부팅 명령 timeout', 'type' => 'number', 'default' => 5, 'min' => 2, 'max' => 30, 'step' => 1, 'unit' => '초'], 'security.wifi_command_timeout_seconds' => ['group' => 'security', 'label' => 'WiFi 명령 timeout', 'type' => 'number', 'default' => 25, 'min' => 5, 'max' => 90, 'step' => 1, 'unit' => '초'], 'security.shell_default_timeout_seconds' => ['group' => 'security', 'label' => '기본 명령 timeout', 'type' => 'number', 'default' => 8, 'min' => 2, 'max' => 60, 'step' => 1, 'unit' => '초'], 'security.allow_wifi_restart' => ['group' => 'security', 'label' => 'WiFi restart 허용', 'type' => 'boolean', 'default' => 1], 'security.allow_wifi_reload' => ['group' => 'security', 'label' => 'WiFi reload 허용', 'type' => 'boolean', 'default' => 1], 'fan.auto_min_temp' => ['group' => 'fan', 'label' => '팬 시작 온도', 'type' => 'number', 'default' => 50, 'min' => 30, 'max' => 90, 'step' => 0.5, 'unit' => 'C'], 'fan.auto_max_temp' => ['group' => 'fan', 'label' => '팬 최대 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'], 'fan.full_temp' => ['group' => 'fan', 'label' => '즉시 최대 PWM 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'], 'fan.rp1_temp_weight' => ['group' => 'fan', 'label' => 'RP1 온도 반영 비율', 'type' => 'number', 'default' => 0.35, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], 'fan.control_temp_headroom' => ['group' => 'fan', 'label' => '팬 판단 온도 여유', 'type' => 'number', 'default' => 1.5, 'min' => 0, 'max' => 8, 'step' => 0.1, 'unit' => 'C'], 'fan.ramp_up_step' => ['group' => 'fan', 'label' => '자동 상승 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'], 'fan.ramp_down_step' => ['group' => 'fan', 'label' => '자동 하강 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'], 'fan.presence_policy_enabled' => ['group' => 'fan', 'label' => '외출/귀가 팬 모드 자동 전환', 'type' => 'boolean', 'default' => 1], 'fan.presence_check_seconds' => ['group' => 'fan', 'label' => '외출/귀가 확인 주기', 'type' => 'number', 'default' => 30, 'min' => 10, 'max' => 300, 'step' => 5, 'unit' => '초'], 'fan.away_manual_pwm' => ['group' => 'fan', 'label' => '외출 시 수동 PWM', 'type' => 'number', 'default' => 255, 'min' => 0, 'max' => 255, 'step' => 1, 'unit' => 'PWM'], 'event.recent_window_seconds' => ['group' => 'event', 'label' => '저전압/스로틀링 최근 판정창', 'type' => 'number', 'default' => 600, 'min' => 60, 'max' => 7200, 'step' => 30, 'unit' => '초'], 'event.recovery_seconds' => ['group' => 'event', 'label' => '복구 중 유지 시간', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'], 'event.repeated_count' => ['group' => 'event', 'label' => '반복 발생 기준 횟수', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 20, 'step' => 1, 'unit' => '회'], 'notice.baseline_samples' => ['group' => 'notice', 'label' => '팬 이상 기준 표본 수', 'type' => 'number', 'default' => 180, 'min' => 30, 'max' => 600, 'step' => 10, 'unit' => '개'], 'notice.baseline_skip_recent' => ['group' => 'notice', 'label' => '기준 계산 제외 최신 표본', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 30, 'step' => 1, 'unit' => '개'], 'notice.rpm_delta_threshold' => ['group' => 'notice', 'label' => '팬 RPM 이상 감지 차이', 'type' => 'number', 'default' => 1000, 'min' => 100, 'max' => 5000, 'step' => 50, 'unit' => 'RPM'], 'notice.temp_delta_threshold' => ['group' => 'notice', 'label' => '온도 이상 감지 차이', 'type' => 'number', 'default' => 3.0, 'min' => 0.5, 'max' => 15, 'step' => 0.1, 'unit' => 'C'], 'notice.recover_rpm_delta' => ['group' => 'notice', 'label' => '팬 RPM 복구 차이', 'type' => 'number', 'default' => 500, 'min' => 50, 'max' => 3000, 'step' => 50, 'unit' => 'RPM'], 'notice.recover_temp_delta' => ['group' => 'notice', 'label' => '온도 복구 차이', 'type' => 'number', 'default' => 1.5, 'min' => 0.2, 'max' => 10, 'step' => 0.1, 'unit' => 'C'], 'notice.rpm_expected_pwm' => ['group' => 'notice', 'label' => 'RPM 감시 시작 PWM', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 255, 'step' => 1, 'unit' => 'PWM'], 'notice.log_during_alert' => ['group' => 'notice', 'label' => 'Alert 유지 중 반복 기록', 'type' => 'boolean', 'default' => 0], 'notice.log_interval_seconds' => ['group' => 'notice', 'label' => 'Alert 반복 기록 간격', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'], 'notice.downward_only_process_hide' => ['group' => 'notice', 'label' => '하강 변화 원인 후보 숨김', 'type' => 'boolean', 'default' => 1], 'display.process_limit' => ['group' => 'display', 'label' => '프로세스 후보 표시 수', 'type' => 'number', 'default' => 6, 'min' => 1, 'max' => 30, 'step' => 1, 'unit' => '개'], 'display.custom_service_log_lines' => ['group' => 'display', 'label' => '사용자 서비스 로그 줄 수', 'type' => 'number', 'default' => 500, 'min' => 1, 'max' => 500, 'step' => 10, 'unit' => '줄'], 'display.custom_service_cache_seconds' => ['group' => 'display', 'label' => '사용자 서비스 캐시 시간', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 60, 'step' => 1, 'unit' => '초'], 'display.fan_notice_history_limit' => ['group' => 'display', 'label' => '팬 이상 이력 표시 수', 'type' => 'number', 'default' => 100, 'min' => 10, 'max' => 500, 'step' => 10, 'unit' => '개'], 'battery.trend_hours' => ['group' => 'battery', 'label' => '단기 배터리 학습 범위', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 48, 'step' => 1, 'unit' => '시간'], 'battery.profile_days' => ['group' => 'battery', 'label' => '장기 배터리 학습 범위', 'type' => 'number', 'default' => 45, 'min' => 3, 'max' => 90, 'step' => 1, 'unit' => '일'], 'battery.load_adjust_recent' => ['group' => 'battery', 'label' => '최근 추세 부하 보정 강도', 'type' => 'number', 'default' => 0.45, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], 'battery.load_adjust_regression' => ['group' => 'battery', 'label' => '강건 회귀 부하 보정 강도', 'type' => 'number', 'default' => 0.38, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], 'battery.load_adjust_voltage' => ['group' => 'battery', 'label' => '전압 후보 부하 보정 강도', 'type' => 'number', 'default' => 0.28, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], 'battery.voltage_floor_high' => ['group' => 'battery', 'label' => '전압 하한 높음', 'type' => 'number', 'default' => 3.28, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'], 'battery.voltage_floor_mid' => ['group' => 'battery', 'label' => '전압 하한 중간', 'type' => 'number', 'default' => 3.18, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'], 'battery.voltage_floor_low' => ['group' => 'battery', 'label' => '전압 하한 낮음', 'type' => 'number', 'default' => 3.05, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'], 'battery.candidate_min_ratio' => ['group' => 'battery', 'label' => '후보 최소 속도 비율', 'type' => 'number', 'default' => 0.38, 'min' => 0.05, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], 'battery.candidate_max_ratio' => ['group' => 'battery', 'label' => '후보 최대 속도 비율', 'type' => 'number', 'default' => 2.65, 'min' => 1, 'max' => 8, 'step' => 0.05, 'unit' => 'x'], 'battery.capacity_model_weight' => ['group' => 'battery', 'label' => '용량 모델 가중치', 'type' => 'number', 'default' => 0.18, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], 'battery.runtime_cap_hours' => ['group' => 'battery', 'label' => '잔여시간 현실 상한', 'type' => 'number', 'default' => 18, 'min' => 1, 'max' => 48, 'step' => 0.5, 'unit' => '시간'], 'battery.minimum_system_watts' => ['group' => 'battery', 'label' => '최소 시스템 전력', 'type' => 'number', 'default' => 5.2, 'min' => 1.5, 'max' => 25, 'step' => 0.1, 'unit' => 'W'], 'battery.long_candidate_penalty' => ['group' => 'battery', 'label' => '상한 초과 후보 감쇠', 'type' => 'number', 'default' => 0.62, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], 'battery.energy_model_enabled' => ['group' => 'battery', 'label' => '에너지 모델 사용', 'type' => 'boolean', 'default' => 1], 'battery.voltage_model_enabled' => ['group' => 'battery', 'label' => '전압 모델 사용', 'type' => 'boolean', 'default' => 1], 'battery.history_chart_limit' => ['group' => 'battery', 'label' => '상태 차트 표본 수', 'type' => 'number', 'default' => 240, 'min' => 60, 'max' => 1500, 'step' => 10, 'unit' => '개'], 'battery.history_sync_limit' => ['group' => 'battery', 'label' => '잔여시간 차트 표본 수', 'type' => 'number', 'default' => 240, 'min' => 60, 'max' => 1500, 'step' => 10, 'unit' => '개'], ]; if (!CONTROL_BATTERY_ENABLED) { foreach (array_keys($definitions) as $key) { if (str_starts_with($key, 'battery.')) { unset($definitions[$key]); } } } return $definitions; } function setting_rows(bool $reload = false): array { static $cache = null; if (!$reload && $cache !== null) { return $cache; } try { $rows = db()->query("SELECT setting_key, setting_value FROM app_settings")->fetchAll(); } catch (Throwable) { $cache = []; return $cache; } $cache = []; foreach ($rows as $row) { $cache[(string)$row['setting_key']] = (string)$row['setting_value']; } return $cache; } function setting_value(string $key): int|float|string|bool|null { $defs = setting_definitions(); if (!isset($defs[$key])) { return null; } $def = $defs[$key]; $raw = setting_rows()[$key] ?? $def['default']; $type = (string)($def['type'] ?? ''); if ($type === 'number') { $value = is_numeric($raw) ? (float)$raw : (float)$def['default']; $value = clamp_float($value, (float)$def['min'], (float)$def['max']); return fmod((float)$def['step'], 1.0) === 0.0 ? (int)round($value) : $value; } if ($type === 'boolean') { return in_array((string)$raw, ['1', 'true', 'yes', 'on'], true); } if ($type === 'select') { $options = is_array($def['options'] ?? null) ? $def['options'] : []; return array_key_exists((string)$raw, $options) ? (string)$raw : (string)$def['default']; } if ($type === 'text') { $value = trim((string)$raw); $min = (int)($def['min_length'] ?? 0); $max = (int)($def['max_length'] ?? 255); if (mb_strlen($value) < $min) { return (string)$def['default']; } return mb_substr($value, 0, $max); } return $raw; } function settings_payload(): array { $values = setting_rows(); $groups = [ 'security' => '보안 정책', 'notify' => '알림 정책', 'display' => '화면/진단 표시', 'wifi' => 'WiFi 진단', 'fan' => '팬 자동 제어', 'battery' => '배터리 예측', 'event' => '저전압/스로틀링', 'notice' => '팬 이상 감지', ]; $items = []; foreach (setting_definitions() as $key => $def) { $items[] = array_merge($def, [ 'key' => $key, 'value' => setting_value($key), 'default' => $def['default'], 'custom' => array_key_exists($key, $values), ]); } return [ 'groups' => $groups, 'items' => $items, ]; } function save_settings(array $input): void { $defs = setting_definitions(); $stmt = db()->prepare(" INSERT INTO app_settings (setting_key, setting_value) VALUES (:setting_key, :setting_value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = CURRENT_TIMESTAMP "); foreach ($input as $key => $value) { if (!isset($defs[$key])) { continue; } $def = $defs[$key]; $type = (string)($def['type'] ?? ''); if ($type === 'number') { if (!is_numeric($value)) { continue; } $value = clamp_float((float)$value, (float)$def['min'], (float)$def['max']); } elseif ($type === 'boolean') { $value = in_array((string)$value, ['1', 'true', 'yes', 'on'], true) ? '1' : '0'; } elseif ($type === 'select') { $options = is_array($def['options'] ?? null) ? $def['options'] : []; if (!array_key_exists((string)$value, $options)) { continue; } } elseif ($type === 'text') { $value = trim((string)$value); $min = (int)($def['min_length'] ?? 0); $max = (int)($def['max_length'] ?? 255); if (mb_strlen($value) < $min) { continue; } $value = mb_substr($value, 0, $max); } $stmt->execute([ ':setting_key' => $key, ':setting_value' => (string)$value, ]); } setting_rows(true); } function reset_settings(): void { $keys = array_keys(setting_definitions()); if ($keys === []) { return; } $placeholders = implode(',', array_fill(0, count($keys), '?')); db()->prepare("DELETE FROM app_settings WHERE setting_key IN ($placeholders)")->execute($keys); setting_rows(true); } function ip_prefix_match(string $left, string $right, int $bits): bool { if ($bits <= 0) { return true; } $leftLong = ip2long($left); $rightLong = ip2long($right); if ($leftLong === false || $rightLong === false) { return false; } $mask = $bits >= 32 ? 0xffffffff : (~((1 << (32 - $bits)) - 1) & 0xffffffff); return (((int)$leftLong & $mask) === ((int)$rightLong & $mask)); } 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 app_settings ( setting_key VARCHAR(96) NOT NULL PRIMARY KEY, setting_value VARCHAR(255) NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_updated_at (updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci "); $pdo->exec(" CREATE TABLE IF NOT EXISTS presence_fan_policy_state ( id TINYINT UNSIGNED NOT NULL PRIMARY KEY, last_presence VARCHAR(16) NULL, last_checked_at DATETIME NULL, last_seen_at DATETIME NULL, last_applied_presence VARCHAR(16) NULL, last_applied_at DATETIME NULL, last_error VARCHAR(255) 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 power_event_states ( event_key VARCHAR(32) NOT NULL PRIMARY KEY, active TINYINT(1) NOT NULL DEFAULT 0, active_since BIGINT NULL, last_detected_at BIGINT NULL, last_cleared_at BIGINT NULL, last_duration_seconds INT NOT NULL DEFAULT 0, event_history LONGTEXT NOT NULL, refreshed_at BIGINT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 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_profile_cache ( days SMALLINT UNSIGNED NOT NULL PRIMARY KEY, created_at_epoch BIGINT NOT NULL, sample_rows LONGTEXT NOT NULL, expires_at DATETIME NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_expires_at (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci "); $pdo->exec(" CREATE TABLE IF NOT EXISTS systemd_service_cache ( cache_key TINYINT UNSIGNED NOT NULL PRIMARY KEY, captured_at BIGINT NOT NULL, service_rows LONGTEXT NOT NULL, expires_at DATETIME NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_expires_at (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci "); $pdo->exec(" CREATE TABLE IF NOT EXISTS wifi_client_aliases ( mac VARCHAR(17) NOT NULL PRIMARY KEY, hostname VARCHAR(255) NOT NULL, note VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_updated_at (updated_at) ) 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 wifi_observed_sessions ( mac VARCHAR(17) NOT NULL PRIMARY KEY, band VARCHAR(16) NOT NULL, iface VARCHAR(32) NOT NULL, first_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), last_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), last_ip VARCHAR(64) NULL, hostname VARCHAR(255) NULL, INDEX idx_last_seen_at (last_seen_at), INDEX idx_band (band) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci "); $pdo->exec(" CREATE TABLE IF NOT EXISTS network_usage_logs ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, recorded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), iface VARCHAR(32) NOT NULL, rx_bytes BIGINT UNSIGNED NOT NULL, tx_bytes BIGINT UNSIGNED NOT NULL, rx_packets BIGINT UNSIGNED NOT NULL, tx_packets BIGINT UNSIGNED NOT NULL, rx_errors BIGINT UNSIGNED NOT NULL DEFAULT 0, tx_errors BIGINT UNSIGNED NOT NULL DEFAULT 0, rx_dropped BIGINT UNSIGNED NOT NULL DEFAULT 0, tx_dropped BIGINT UNSIGNED NOT NULL DEFAULT 0, rx_mbps DECIMAL(12,4) NULL, tx_mbps DECIMAL(12,4) NULL, rx_pps DECIMAL(12,2) NULL, tx_pps DECIMAL(12,2) NULL, rx_errors_per_sec DECIMAL(12,2) NULL, tx_errors_per_sec DECIMAL(12,2) NULL, rx_dropped_per_sec DECIMAL(12,2) NULL, tx_dropped_per_sec DECIMAL(12,2) NULL, create_ip VARCHAR(64) NULL, INDEX idx_recorded_at (recorded_at), INDEX idx_iface_recorded_at (iface, recorded_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 VARCHAR(64) 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 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(" INSERT IGNORE INTO control_state (id, mode, manual_pwm) VALUES (1, 'auto', 120) "); $pdo->exec(" INSERT IGNORE INTO presence_fan_policy_state (id) VALUES (1) "); 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 remember_tokens MODIFY selector VARCHAR(64) NOT NULL", "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 network_usage_logs ADD COLUMN rx_errors BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tx_packets", "ALTER TABLE network_usage_logs ADD COLUMN tx_errors BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER rx_errors", "ALTER TABLE network_usage_logs ADD COLUMN rx_dropped BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tx_errors", "ALTER TABLE network_usage_logs ADD COLUMN tx_dropped BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER rx_dropped", "ALTER TABLE network_usage_logs ADD COLUMN rx_errors_per_sec DECIMAL(12,2) NULL AFTER tx_pps", "ALTER TABLE network_usage_logs ADD COLUMN tx_errors_per_sec DECIMAL(12,2) NULL AFTER rx_errors_per_sec", "ALTER TABLE network_usage_logs ADD COLUMN rx_dropped_per_sec DECIMAL(12,2) NULL AFTER tx_errors_per_sec", "ALTER TABLE network_usage_logs ADD COLUMN tx_dropped_per_sec DECIMAL(12,2) NULL AFTER rx_dropped_per_sec", "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 signed_in(): bool { if (isset($_SESSION['control_login']) && $_SESSION['control_login'] === true) { return true; } if (control_admin_session_from_rhymix_or_custom()) { $_SESSION['control_login'] = true; return true; } return auto_login_from_cookie(); } function control_api_token_valid(string $scope): bool { global $controlSecretConfig; $provided = (string)( $_SERVER['HTTP_X_CONTROL_API_TOKEN'] ?? $_POST['api_token'] ?? '' ); if ($provided === '') { return false; } $tokens = is_array($controlSecretConfig['api_tokens'] ?? null) ? $controlSecretConfig['api_tokens'] : []; $expected = (string)($tokens[$scope] ?? ''); return $expected !== '' && hash_equals($expected, $provided); } function control_admin_session_from_rhymix_or_custom(): bool { static $checked = null; if ($checked !== null) { return $checked; } $checked = false; $commonAuth = '/mnt/synology-web/custom/common/auth.php'; if (!is_file($commonAuth) || !is_readable($commonAuth)) { return false; } try { require_once $commonAuth; $checked = (function_exists('custom_auth_cookie_is_admin') && custom_auth_cookie_is_admin()) || (function_exists('custom_is_site_admin') && custom_is_site_admin()); } catch (Throwable) { $checked = false; } return $checked; } function clear_common_admin_auth_cookie_if_available(): void { $commonAuth = '/mnt/synology-web/custom/common/auth.php'; if (!is_file($commonAuth) || !is_readable($commonAuth)) { return; } try { require_once $commonAuth; if (function_exists('custom_clear_auth_cookie')) { custom_clear_auth_cookie(); } } catch (Throwable) { } } function require_login(): void { if (!signed_in()) { header('Location: /'); exit; } } function remember_cookie_options(int $expires): array { $secureMode = (string)setting_value('security.cookie_secure_mode'); $secure = match ($secureMode) { 'always' => true, 'never' => false, default => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', }; $sameSite = (string)setting_value('security.cookie_samesite'); if ($sameSite === 'None') { $secure = true; } return [ 'expires' => $expires, 'path' => '/', 'domain' => '', 'secure' => $secure, 'httponly' => true, 'samesite' => $sameSite, ]; } function issue_remember_token(): void { $selectorBytes = (int)setting_value('security.remember_selector_bytes'); $validatorBytes = (int)setting_value('security.remember_validator_bytes'); $selector = bin2hex(random_bytes($selectorBytes)); $validator = bin2hex(random_bytes($validatorBytes)); $expires = time() + ((int)setting_value('security.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) ); if ((bool)setting_value('security.prune_expired_remember_tokens')) { 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]{16,64}$/', $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]{16,64}$/', $parts[0]) || !preg_match('/^[a-f0-9]{32,128}$/', $parts[1]) ) { clear_remember_token(); return false; } [$selector, $validator] = $parts; $stmt = db()->prepare(" SELECT id, validator_hash, UNIX_TIMESTAMP(expires_at) AS expires_ts, last_ip, user_agent 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; } $currentUserAgent = mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); if ((bool)setting_value('security.remember_bind_user_agent') && (string)($row['user_agent'] ?? '') !== $currentUserAgent) { clear_remember_token(); return false; } $ipPrefix = (int)setting_value('security.remember_bind_ip_prefix_v4'); $currentIp = (string)($_SERVER['REMOTE_ADDR'] ?? 'cli'); if ($ipPrefix > 0 && !ip_prefix_match((string)($row['last_ip'] ?? ''), $currentIp, $ipPrefix)) { clear_remember_token(); return false; } $_SESSION['control_login'] = true; if ((bool)setting_value('security.session_regenerate_auto_login') && PHP_SAPI !== 'cli') { session_regenerate_id(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((int)setting_value('security.csrf_bytes'))); } 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 { if ($timeout === 8) { $timeout = (int)setting_value('security.shell_default_timeout_seconds'); } $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 presence_fan_policy_state(): array { $stmt = db()->query(" SELECT * FROM presence_fan_policy_state WHERE id = 1 LIMIT 1 "); return $stmt->fetch() ?: []; } function update_presence_fan_policy_state(array $values): void { $allowed = [ 'last_presence', 'last_checked_at', 'last_seen_at', 'last_applied_presence', 'last_applied_at', 'last_error', ]; $sets = []; $params = []; foreach ($allowed as $key) { if (!array_key_exists($key, $values)) { continue; } $sets[] = $key . ' = :' . $key; $params[':' . $key] = $values[$key]; } if ($sets === []) { return; } $stmt = db()->prepare(" UPDATE presence_fan_policy_state SET " . implode(', ', $sets) . " WHERE id = 1 "); $stmt->execute($params); } function control_common_config(): array { static $config = null; if ($config !== null) { return $config; } $file = '/mnt/synology-web/custom/common/config.php'; if (!is_file($file)) { return $config = []; } $loaded = require $file; return $config = is_array($loaded) ? $loaded : []; } function control_ha_state(string $base, string $token, string $entity): array { $ch = curl_init(rtrim($base, '/') . '/api/states/' . rawurlencode($entity)); if (!$ch) { throw new RuntimeException('HA curl init failed.'); } curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $token, 'Content-Type: application/json', ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, ]); $response = curl_exec($ch); $errno = curl_errno($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno !== 0 || $status < 200 || $status >= 300) { throw new RuntimeException('HA state read failed: status=' . $status . ', errno=' . $errno); } $json = json_decode((string)$response, true); if (!is_array($json)) { throw new RuntimeException('HA state JSON parse failed.'); } return $json; } function control_distance_m(float $lat1, float $lon1, float $lat2, float $lon2): float { $earth = 6371000.0; $dLat = deg2rad($lat2 - $lat1); $dLon = deg2rad($lon2 - $lon1); $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2; return $earth * 2 * atan2(sqrt($a), sqrt(1 - $a)); } function current_control_presence(): array { $config = control_common_config(); $ha = $config['ha']['seoul'] ?? []; $entity = (string)($config['entities']['device'] ?? ''); $home = $config['findmydevice_home'] ?? []; $base = (string)($ha['base'] ?? ''); $token = (string)($ha['token'] ?? ''); if ($base === '' || $token === '' || $entity === '') { throw new RuntimeException('Seoul HA presence config is missing.'); } $state = control_ha_state($base, $token, $entity); $rawState = strtolower(trim((string)($state['state'] ?? ''))); $attrs = is_array($state['attributes'] ?? null) ? $state['attributes'] : []; if (in_array($rawState, ['home'], true)) { return ['presence' => 'home', 'source' => 'device_tracker', 'raw_state' => $rawState]; } if (in_array($rawState, ['not_home', 'away'], true)) { return ['presence' => 'away', 'source' => 'device_tracker', 'raw_state' => $rawState]; } $lat = $attrs['latitude'] ?? null; $lon = $attrs['longitude'] ?? null; if (is_numeric($lat) && is_numeric($lon) && is_numeric($home['latitude'] ?? null) && is_numeric($home['longitude'] ?? null)) { $radius = max(1.0, (float)($home['radius_m'] ?? 150)); $distance = control_distance_m((float)$home['latitude'], (float)$home['longitude'], (float)$lat, (float)$lon); return [ 'presence' => $distance <= $radius ? 'home' : 'away', 'source' => 'gps_distance', 'raw_state' => $rawState, 'distance_m' => round($distance, 1), ]; } throw new RuntimeException('Presence state is unavailable: ' . ($rawState ?: 'empty')); } function sync_presence_fan_policy(bool $force = false): array { if (!(bool)setting_value('fan.presence_policy_enabled')) { return ['ok' => true, 'skipped' => 'disabled']; } $state = presence_fan_policy_state(); $lastChecked = strtotime((string)($state['last_checked_at'] ?? '')) ?: 0; $interval = max(10, (int)setting_value('fan.presence_check_seconds')); if (!$force && $lastChecked > 0 && time() - $lastChecked < $interval) { return ['ok' => true, 'skipped' => 'throttled']; } $now = date('Y-m-d H:i:s'); try { $presence = current_control_presence(); } catch (Throwable $e) { update_presence_fan_policy_state([ 'last_checked_at' => $now, 'last_error' => mb_substr($e->getMessage(), 0, 255, 'UTF-8'), ]); return ['ok' => false, 'error' => $e->getMessage()]; } $currentPresence = (string)$presence['presence']; $previousPresence = (string)($state['last_presence'] ?? ''); $changed = $previousPresence === '' || $previousPresence !== $currentPresence; $update = [ 'last_presence' => $currentPresence, 'last_checked_at' => $now, 'last_seen_at' => $now, 'last_error' => null, ]; $applied = false; if ($changed) { $fan = get_control_state(); $mode = (string)($fan['mode'] ?? 'auto'); $manualPwm = max(0, min(255, (int)($fan['manual_pwm'] ?? 120))); $targetPwm = max(0, min(255, (int)setting_value('fan.away_manual_pwm'))); if ($currentPresence === 'away' && ($mode !== 'manual' || $manualPwm !== $targetPwm)) { set_control_state('manual', $targetPwm); add_fan_action( 'presence_fan_policy', 'manual', $targetPwm, 'presence=away, previous_presence=' . ($previousPresence ?: 'unknown') . ', previous_fan=' . $mode . ':' . $manualPwm . ', source=' . ($presence['source'] ?? 'unknown'), true ); $applied = true; } elseif ($currentPresence === 'home' && $mode !== 'auto') { set_control_state('auto', $manualPwm); add_fan_action( 'presence_fan_policy', 'auto', $manualPwm, 'presence=home, previous_presence=' . ($previousPresence ?: 'unknown') . ', previous_fan=' . $mode . ':' . $manualPwm . ', source=' . ($presence['source'] ?? 'unknown'), true ); $applied = true; } if ($applied) { $update['last_applied_presence'] = $currentPresence; $update['last_applied_at'] = $now; } } update_presence_fan_policy_state($update); return [ 'ok' => true, 'presence' => $currentPresence, 'changed' => $changed, 'applied' => $applied, 'source' => $presence['source'] ?? 'unknown', ]; } 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 { if (!CONTROL_BATTERY_ENABLED) { return [ 'enabled' => false, 'removed' => true, 'voltage' => null, 'percent' => null, ]; } $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 [ 'enabled' => true, 'removed' => false, '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', ]); }