diff --git a/README.md b/README.md index 7f1bace..8e3b238 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `control_state`: 팬 모드와 PWM 상태 - `sensor_logs`: 온도, RPM, PWM, load, memory, disk, uptime. 과거 배터리 컬럼은 호환을 위해 유지하지만 UPS 제거 상태에서는 새 배터리값을 기록하지 않습니다. -- `/tmp/control-low-voltage-state.json`: `vcgencmd get_throttled` 기반 저전압 감지 상태, 최근 지속시간, 최근 episode 이력 -- `/tmp/control-throttling-state.json`: `vcgencmd get_throttled` 기반 스로틀링 감지 상태, 최근 지속시간, 최근 episode 이력 +- `app_json_store`: 기존 JSON 파일 저장소를 대체하는 DB 기반 JSON 상태/캐시 저장 테이블. 저전압/스로틀링 episode 상태, 배터리 프로파일 캐시, 사용자 서비스 캐시를 저장합니다. - `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_logs`: notice 발생 이력 - `wifi_observed_sessions`: 5G WiFi/LAN client의 최초/마지막 감지 시간 @@ -187,11 +186,11 @@ control-wifi-observe.service ## 주요 함수/모듈 - `collect_snapshot()`: 센서와 fan 상태 snapshot 생성 -- `custom_systemd_services()`: `/etc/systemd/system/*.service` 단위를 조회해 사용자 생성 서비스 상태와 최근 journal 로그를 구성 +- `custom_systemd_services()`: `/etc/systemd/system/*.service` 단위를 조회해 사용자 생성 서비스 상태와 최근 journal 로그를 구성하고 `app_json_store`에 짧은 TTL 캐시로 저장 - `systemd_service_logs()`: 서비스별 최근 journal 로그 조회 - `read_throttled_flags()`: `vcgencmd get_throttled`를 직접 조회하고, 권한 문제로 실패하면 sudo 경유 조회를 시도 - `throttled_event_status()`: 저전압/스로틀링 episode 이력, 복구 중/반복 발생 판정, 최근 10분 통계를 계산 -- `throttled_statuses()`: 라즈베리파이 throttled flag를 읽고 저전압/스로틀링 감지 이력을 상태 파일로 추적 +- `throttled_statuses()`: 라즈베리파이 throttled flag를 읽고 저전압/스로틀링 감지 이력을 `app_json_store`로 추적 - `apply_fan_policy()`: 팬 목표값 계산과 적용 - `fan_control_temp()`: CPU/RP1 혼합 팬 판단 온도 계산 - `json_out()`: API JSON 응답 표준화 diff --git a/config/config.php b/config/config.php index fb0b23f..5a2b052 100644 --- a/config/config.php +++ b/config/config.php @@ -426,6 +426,21 @@ function bootstrap_db(): void COLLATE=utf8mb4_unicode_ci "); + $pdo->exec(" + CREATE TABLE IF NOT EXISTS app_json_store ( + store_key VARCHAR(128) NOT NULL PRIMARY KEY, + payload LONGTEXT NOT NULL, + expires_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_expires_at (expires_at), + INDEX idx_updated_at (updated_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, diff --git a/public/api.php b/public/api.php index ab0417e..0fbaadf 100644 --- a/public/api.php +++ b/public/api.php @@ -121,6 +121,62 @@ function dmesg_log(): array ]; } +function json_store_key_from_path(string $path): string +{ + $name = preg_replace('/[^a-zA-Z0-9_.-]+/', '-', basename($path, '.json')) ?: md5($path); + return 'file:' . $name; +} + +function json_store_read(string $key, ?string $legacyPath = null): ?array +{ + try { + $stmt = db()->prepare(" + SELECT payload, expires_at + FROM app_json_store + WHERE store_key = :store_key + AND (expires_at IS NULL OR expires_at >= NOW()) + LIMIT 1 + "); + $stmt->execute([':store_key' => $key]); + $row = $stmt->fetch(); + if ($row) { + $decoded = json_decode((string)$row['payload'], true); + if (is_array($decoded)) { + return $decoded; + } + } + } catch (Throwable) { + } + + if ($legacyPath !== null && is_readable($legacyPath)) { + $decoded = json_decode((string)@file_get_contents($legacyPath), true); + if (is_array($decoded)) { + json_store_write($key, $decoded); + return $decoded; + } + } + + return null; +} + +function json_store_write(string $key, array $payload, ?int $ttlSeconds = null): void +{ + $expiresAt = $ttlSeconds !== null ? date('Y-m-d H:i:s', time() + max(1, $ttlSeconds)) : null; + $stmt = db()->prepare(" + INSERT INTO app_json_store (store_key, payload, expires_at) + VALUES (:store_key, :payload, :expires_at) + ON DUPLICATE KEY UPDATE + payload = VALUES(payload), + expires_at = VALUES(expires_at), + updated_at = CURRENT_TIMESTAMP + "); + $stmt->execute([ + ':store_key' => $key, + ':payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ':expires_at' => $expiresAt, + ]); +} + function throttled_event_status(?int $flags, string $raw, int $activeBit, int $seenBit, string $statePath): array { $now = time(); @@ -129,10 +185,6 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s $active = $flags !== null && (bool)($flags & $activeBit); $seen = $flags !== null && (bool)($flags & $seenBit); - if (is_file($statePath) && !is_writable($statePath)) { - @chmod($statePath, 0666); - } - $state = [ 'active' => false, 'active_since' => null, @@ -143,12 +195,8 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s 'events' => [], ]; - $fp = @fopen($statePath, 'c+'); - if ($fp !== false) { - @chmod($statePath, 0666); - @flock($fp, LOCK_EX); - $saved = stream_get_contents($fp); - $decoded = json_decode((string)$saved, true); + try { + $decoded = json_store_read(json_store_key_from_path($statePath), $statePath); if (is_array($decoded)) { $state = array_merge($state, array_intersect_key($decoded, $state)); } @@ -204,15 +252,9 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s )), -200); $state['updated_at'] = $now; - ftruncate($fp, 0); - rewind($fp); - fwrite($fp, json_encode($state, JSON_UNESCAPED_SLASHES)); - fflush($fp); - @chmod($statePath, 0666); + json_store_write(json_store_key_from_path($statePath), $state); } - - @flock($fp, LOCK_UN); - fclose($fp); + } catch (Throwable) { } $duration = 0; @@ -885,7 +927,8 @@ function battery_profile_history(int $days = 45): array static $cache = []; $cacheKey = (string)$days; $now = time(); - $cacheFile = sys_get_temp_dir() . '/control-battery-profile-' . $days . 'd.json'; + $storeKey = 'battery_profile:' . $days . 'd'; + $legacyCacheFile = sys_get_temp_dir() . '/control-battery-profile-' . $days . 'd.json'; if ( isset($cache[$cacheKey]) @@ -895,21 +938,18 @@ function battery_profile_history(int $days = 45): array return $cache[$cacheKey]['rows']; } - if (is_readable($cacheFile)) { - $raw = @file_get_contents($cacheFile); - $decoded = is_string($raw) ? json_decode($raw, true) : null; - if ( - is_array($decoded) - && isset($decoded['created_at'], $decoded['rows']) - && is_array($decoded['rows']) - && ($now - (int)$decoded['created_at']) < 21600 - ) { - $cache[$cacheKey] = [ - 'time' => $now, - 'rows' => $decoded['rows'], - ]; - return $decoded['rows']; - } + $decoded = json_store_read($storeKey, $legacyCacheFile); + if ( + is_array($decoded) + && isset($decoded['created_at'], $decoded['rows']) + && is_array($decoded['rows']) + && ($now - (int)$decoded['created_at']) < 21600 + ) { + $cache[$cacheKey] = [ + 'time' => $now, + 'rows' => $decoded['rows'], + ]; + return $decoded['rows']; } $maxId = (int)db()->query("SELECT MAX(id) FROM sensor_logs")->fetchColumn(); @@ -952,14 +992,11 @@ function battery_profile_history(int $days = 45): array 'time' => $now, 'rows' => $rows, ]; - @file_put_contents( - $cacheFile, - json_encode([ - 'created_at' => $now, - 'days' => $days, - 'rows' => $rows, - ], JSON_UNESCAPED_UNICODE) - ); + json_store_write($storeKey, [ + 'created_at' => $now, + 'days' => $days, + 'rows' => $rows, + ], 21600); return $rows; } @@ -3405,12 +3442,12 @@ function systemd_service_logs(string $unit, int $limit = 12): array function custom_systemd_services(): array { - $cachePath = '/tmp/control-custom-services.json'; $cacheSeconds = (int)setting_value('display.custom_service_cache_seconds'); - if (is_file($cachePath) && filemtime($cachePath) >= time() - $cacheSeconds) { - $cached = json_decode((string)@file_get_contents($cachePath), true); - if (is_array($cached)) { - return $cached; + $cacheKey = 'custom_services'; + $cached = json_store_read($cacheKey, '/tmp/control-custom-services.json'); + if (is_array($cached) && isset($cached['created_at'], $cached['rows']) && is_array($cached['rows'])) { + if (time() - (int)$cached['created_at'] <= $cacheSeconds) { + return $cached['rows']; } } @@ -3473,7 +3510,10 @@ function custom_systemd_services(): array ]; } - @file_put_contents($cachePath, json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + json_store_write($cacheKey, [ + 'created_at' => time(), + 'rows' => $rows, + ], $cacheSeconds); return $rows; }