diff --git a/README.md b/README.md index 8e3b238..6654dca 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `control_state`: 팬 모드와 PWM 상태 - `sensor_logs`: 온도, RPM, PWM, load, memory, disk, uptime. 과거 배터리 컬럼은 호환을 위해 유지하지만 UPS 제거 상태에서는 새 배터리값을 기록하지 않습니다. -- `app_json_store`: 기존 JSON 파일 저장소를 대체하는 DB 기반 JSON 상태/캐시 저장 테이블. 저전압/스로틀링 episode 상태, 배터리 프로파일 캐시, 사용자 서비스 캐시를 저장합니다. +- `power_event_states`: 저전압/스로틀링 episode 상태, 최근 지속시간, 최근 이벤트 이력 +- `battery_profile_cache`: 배터리 잔여시간 계산용 장기 프로파일 캐시 +- `systemd_service_cache`: 사용자 생성 systemd 서비스 목록과 최근 journal 로그의 짧은 TTL 캐시 - `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_logs`: notice 발생 이력 - `wifi_observed_sessions`: 5G WiFi/LAN client의 최초/마지막 감지 시간 @@ -186,11 +188,11 @@ control-wifi-observe.service ## 주요 함수/모듈 - `collect_snapshot()`: 센서와 fan 상태 snapshot 생성 -- `custom_systemd_services()`: `/etc/systemd/system/*.service` 단위를 조회해 사용자 생성 서비스 상태와 최근 journal 로그를 구성하고 `app_json_store`에 짧은 TTL 캐시로 저장 +- `custom_systemd_services()`: `/etc/systemd/system/*.service` 단위를 조회해 사용자 생성 서비스 상태와 최근 journal 로그를 구성하고 `systemd_service_cache`에 짧은 TTL 캐시로 저장 - `systemd_service_logs()`: 서비스별 최근 journal 로그 조회 - `read_throttled_flags()`: `vcgencmd get_throttled`를 직접 조회하고, 권한 문제로 실패하면 sudo 경유 조회를 시도 - `throttled_event_status()`: 저전압/스로틀링 episode 이력, 복구 중/반복 발생 판정, 최근 10분 통계를 계산 -- `throttled_statuses()`: 라즈베리파이 throttled flag를 읽고 저전압/스로틀링 감지 이력을 `app_json_store`로 추적 +- `throttled_statuses()`: 라즈베리파이 throttled flag를 읽고 저전압/스로틀링 감지 이력을 `power_event_states`로 추적 - `apply_fan_policy()`: 팬 목표값 계산과 적용 - `fan_control_temp()`: CPU/RP1 혼합 팬 판단 온도 계산 - `json_out()`: API JSON 응답 표준화 @@ -199,7 +201,7 @@ control-wifi-observe.service - `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정 - `battery_status()`: `CONTROL_BATTERY_ENABLED`가 true일 때만 배터리 센서를 읽고, 현재 기본 구성에서는 제거 상태를 반환 - `battery_trend_history()`: 배터리 기능을 다시 켤 때 최근 24시간 배터리 기록을 1분 단위로 집계 -- `battery_profile_history()`: 배터리 기능을 다시 켤 때 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 파일 캐시로 보관 +- `battery_profile_history()`: 배터리 기능을 다시 켤 때 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 `battery_profile_cache`에 보관 - `weighted_linear_regression()`, `numeric_mad()`: SOC 기울기 계산과 회귀 잔차 튐 제거 - `battery_regression_trend_candidate()`: 1분 SOC 집계를 강건 회귀로 분석해 정밀 방전 속도 후보 계산 - `battery_voltage_trend_candidate()`: 전압 기울기와 동적 하한 전압으로 잔여 시간 후보 계산 diff --git a/config/config.php b/config/config.php index 5a2b052..c0469d7 100644 --- a/config/config.php +++ b/config/config.php @@ -427,20 +427,106 @@ function bootstrap_db(): void "); $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, + 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, - INDEX idx_expires_at (expires_at), - INDEX idx_updated_at (updated_at) + 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 + "); + + $legacyJsonStoreExists = (bool)$pdo->query(" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'app_json_store' + ")->fetchColumn(); + + if ($legacyJsonStoreExists) { + $pdo->exec(" + INSERT IGNORE INTO power_event_states + (event_key, active, active_since, last_detected_at, last_cleared_at, last_duration_seconds, event_history, refreshed_at) + SELECT + CASE store_key + WHEN 'file:control-low-voltage-state' THEN 'low_voltage' + WHEN 'file:control-throttling-state' THEN 'throttling' + ELSE store_key + END, + JSON_UNQUOTE(JSON_EXTRACT(payload, '$.active')) IN ('true', '1'), + JSON_EXTRACT(payload, '$.active_since'), + JSON_EXTRACT(payload, '$.last_detected_at'), + JSON_EXTRACT(payload, '$.last_cleared_at'), + COALESCE(JSON_EXTRACT(payload, '$.last_duration_seconds'), 0), + COALESCE(JSON_EXTRACT(payload, '$.events'), JSON_ARRAY()), + JSON_EXTRACT(payload, '$.updated_at') + FROM app_json_store + WHERE store_key IN ('file:control-low-voltage-state', 'file:control-throttling-state') + "); + + $pdo->exec(" + INSERT IGNORE INTO battery_profile_cache + (days, created_at_epoch, sample_rows, expires_at) + SELECT + CAST(REPLACE(REPLACE(store_key, 'battery_profile:', ''), 'd', '') AS UNSIGNED), + COALESCE(JSON_EXTRACT(payload, '$.created_at'), UNIX_TIMESTAMP()), + COALESCE(JSON_EXTRACT(payload, '$.rows'), JSON_ARRAY()), + COALESCE(expires_at, DATE_ADD(NOW(), INTERVAL 6 HOUR)) + FROM app_json_store + WHERE store_key LIKE 'battery_profile:%' + "); + + $pdo->exec(" + INSERT IGNORE INTO systemd_service_cache + (cache_key, captured_at, service_rows, expires_at) + SELECT + 1, + COALESCE(JSON_EXTRACT(payload, '$.created_at'), UNIX_TIMESTAMP()), + COALESCE(JSON_EXTRACT(payload, '$.rows'), JSON_ARRAY()), + COALESCE(expires_at, DATE_ADD(NOW(), INTERVAL 5 SECOND)) + FROM app_json_store + WHERE store_key = 'custom_services' + "); + + $pdo->exec("DROP TABLE app_json_store"); + } + $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 0fbaadf..1ece5d5 100644 --- a/public/api.php +++ b/public/api.php @@ -121,70 +121,8 @@ function dmesg_log(): array ]; } -function json_store_key_from_path(string $path): string +function power_event_state_read(string $eventKey): array { - $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(); - $windowSeconds = (int)setting_value('event.recent_window_seconds'); - $recoverySeconds = (int)setting_value('event.recovery_seconds'); - $active = $flags !== null && (bool)($flags & $activeBit); - $seen = $flags !== null && (bool)($flags & $seenBit); - $state = [ 'active' => false, 'active_since' => null, @@ -195,11 +133,69 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s 'events' => [], ]; + $stmt = db()->prepare(" + SELECT * + FROM power_event_states + WHERE event_key = :event_key + LIMIT 1 + "); + $stmt->execute([':event_key' => $eventKey]); + $row = $stmt->fetch(); + if (!$row) { + return $state; + } + + $events = json_decode((string)($row['event_history'] ?? '[]'), true); + $state['active'] = (bool)($row['active'] ?? false); + $state['active_since'] = is_numeric($row['active_since'] ?? null) ? (int)$row['active_since'] : null; + $state['last_detected_at'] = is_numeric($row['last_detected_at'] ?? null) ? (int)$row['last_detected_at'] : null; + $state['last_cleared_at'] = is_numeric($row['last_cleared_at'] ?? null) ? (int)$row['last_cleared_at'] : null; + $state['last_duration_seconds'] = (int)($row['last_duration_seconds'] ?? 0); + $state['updated_at'] = is_numeric($row['refreshed_at'] ?? null) ? (int)$row['refreshed_at'] : null; + $state['events'] = is_array($events) ? $events : []; + + return $state; +} + +function power_event_state_write(string $eventKey, array $state): void +{ + $stmt = db()->prepare(" + INSERT INTO power_event_states + (event_key, active, active_since, last_detected_at, last_cleared_at, last_duration_seconds, event_history, refreshed_at) + VALUES + (:event_key, :active, :active_since, :last_detected_at, :last_cleared_at, :last_duration_seconds, :event_history, :refreshed_at) + ON DUPLICATE KEY UPDATE + active = VALUES(active), + active_since = VALUES(active_since), + last_detected_at = VALUES(last_detected_at), + last_cleared_at = VALUES(last_cleared_at), + last_duration_seconds = VALUES(last_duration_seconds), + event_history = VALUES(event_history), + refreshed_at = VALUES(refreshed_at), + updated_at = CURRENT_TIMESTAMP + "); + $stmt->execute([ + ':event_key' => $eventKey, + ':active' => !empty($state['active']) ? 1 : 0, + ':active_since' => is_numeric($state['active_since'] ?? null) ? (int)$state['active_since'] : null, + ':last_detected_at' => is_numeric($state['last_detected_at'] ?? null) ? (int)$state['last_detected_at'] : null, + ':last_cleared_at' => is_numeric($state['last_cleared_at'] ?? null) ? (int)$state['last_cleared_at'] : null, + ':last_duration_seconds' => (int)($state['last_duration_seconds'] ?? 0), + ':event_history' => json_encode($state['events'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ':refreshed_at' => is_numeric($state['updated_at'] ?? null) ? (int)$state['updated_at'] : time(), + ]); +} + +function throttled_event_status(?int $flags, string $raw, int $activeBit, int $seenBit, string $eventKey): array +{ + $now = time(); + $windowSeconds = (int)setting_value('event.recent_window_seconds'); + $recoverySeconds = (int)setting_value('event.recovery_seconds'); + $active = $flags !== null && (bool)($flags & $activeBit); + $seen = $flags !== null && (bool)($flags & $seenBit); + try { - $decoded = json_store_read(json_store_key_from_path($statePath), $statePath); - if (is_array($decoded)) { - $state = array_merge($state, array_intersect_key($decoded, $state)); - } + $state = power_event_state_read($eventKey); $state['events'] = array_values(array_filter( is_array($state['events'] ?? null) ? $state['events'] : [], static fn($event): bool => is_array($event) && is_numeric($event['start'] ?? null) @@ -252,9 +248,18 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s )), -200); $state['updated_at'] = $now; - json_store_write(json_store_key_from_path($statePath), $state); + power_event_state_write($eventKey, $state); } } catch (Throwable) { + $state = [ + 'active' => false, + 'active_since' => null, + 'last_detected_at' => null, + 'last_cleared_at' => null, + 'last_duration_seconds' => 0, + 'updated_at' => null, + 'events' => [], + ]; } $duration = 0; @@ -361,8 +366,8 @@ function throttled_statuses(): array $flags = $read['flags']; $source = (string)$read['source']; - $lowVoltage = throttled_event_status($flags, $raw, 0x1, 0x10000, '/tmp/control-low-voltage-state.json'); - $throttling = throttled_event_status($flags, $raw, 0x4, 0x40000, '/tmp/control-throttling-state.json'); + $lowVoltage = throttled_event_status($flags, $raw, 0x1, 0x10000, 'low_voltage'); + $throttling = throttled_event_status($flags, $raw, 0x4, 0x40000, 'throttling'); $lowVoltage['source'] = $source; $lowVoltage['freq_capped'] = $flags !== null && (bool)($flags & 0x2); @@ -927,8 +932,6 @@ function battery_profile_history(int $days = 45): array static $cache = []; $cacheKey = (string)$days; $now = time(); - $storeKey = 'battery_profile:' . $days . 'd'; - $legacyCacheFile = sys_get_temp_dir() . '/control-battery-profile-' . $days . 'd.json'; if ( isset($cache[$cacheKey]) @@ -938,18 +941,24 @@ function battery_profile_history(int $days = 45): array return $cache[$cacheKey]['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']; + $stmt = db()->prepare(" + SELECT created_at_epoch, sample_rows + FROM battery_profile_cache + WHERE days = :days + AND expires_at >= NOW() + LIMIT 1 + "); + $stmt->execute([':days' => $days]); + $cached = $stmt->fetch(); + if ($cached) { + $rows = json_decode((string)$cached['sample_rows'], true); + if (is_array($rows)) { + $cache[$cacheKey] = [ + 'time' => $now, + 'rows' => $rows, + ]; + return $rows; + } } $maxId = (int)db()->query("SELECT MAX(id) FROM sensor_logs")->fetchColumn(); @@ -992,11 +1001,22 @@ function battery_profile_history(int $days = 45): array 'time' => $now, 'rows' => $rows, ]; - json_store_write($storeKey, [ - 'created_at' => $now, - 'days' => $days, - 'rows' => $rows, - ], 21600); + $stmt = db()->prepare(" + INSERT INTO battery_profile_cache + (days, created_at_epoch, sample_rows, expires_at) + VALUES + (:days, :created_at_epoch, :sample_rows, DATE_ADD(NOW(), INTERVAL 6 HOUR)) + ON DUPLICATE KEY UPDATE + created_at_epoch = VALUES(created_at_epoch), + sample_rows = VALUES(sample_rows), + expires_at = VALUES(expires_at), + updated_at = CURRENT_TIMESTAMP + "); + $stmt->execute([ + ':days' => $days, + ':created_at_epoch' => $now, + ':sample_rows' => json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); return $rows; } @@ -3443,11 +3463,19 @@ function systemd_service_logs(string $unit, int $limit = 12): array function custom_systemd_services(): array { $cacheSeconds = (int)setting_value('display.custom_service_cache_seconds'); - $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']; + $stmt = db()->prepare(" + SELECT service_rows + FROM systemd_service_cache + WHERE cache_key = 1 + AND expires_at >= NOW() + LIMIT 1 + "); + $stmt->execute(); + $cached = $stmt->fetch(); + if ($cached) { + $rows = json_decode((string)$cached['service_rows'], true); + if (is_array($rows)) { + return $rows; } } @@ -3510,10 +3538,22 @@ function custom_systemd_services(): array ]; } - json_store_write($cacheKey, [ - 'created_at' => time(), - 'rows' => $rows, - ], $cacheSeconds); + $stmt = db()->prepare(" + INSERT INTO systemd_service_cache + (cache_key, captured_at, service_rows, expires_at) + VALUES + (1, :captured_at, :service_rows, :expires_at) + ON DUPLICATE KEY UPDATE + captured_at = VALUES(captured_at), + service_rows = VALUES(service_rows), + expires_at = VALUES(expires_at), + updated_at = CURRENT_TIMESTAMP + "); + $stmt->execute([ + ':captured_at' => time(), + ':service_rows' => json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ':expires_at' => date('Y-m-d H:i:s', time() + max(1, $cacheSeconds)), + ]); return $rows; }