diff --git a/README.md b/README.md index b3d8a8d..eaccf37 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - PWM slider 기반 수동 팬 제어 - CPU/RP1 온도, 팬 RPM, 팬 효율, CPU 전력, 배터리 상태 차트 - 배터리 잔여 시간은 1S8P 병렬팩 `2200mAh x 8 = 17600mAh`, nominal `3.7V`, 총 `65.12Wh` 기준으로 계산 -- 라즈베리파이 저전압/스로틀링 현재 상태, 부팅 후 이력, 최근 감지 시각, 지속시간 표시 +- 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시 - WiFi client 목록과 2.4G/5G client 수 표시 - WiFi client 상태 표시 - 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정 @@ -62,8 +62,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `control_state`: 팬 모드와 PWM 상태 - `sensor_logs`: 온도, RPM, PWM, 배터리, load, memory, disk, uptime -- `/tmp/control-low-voltage-state.json`: `vcgencmd get_throttled` 기반 저전압 감지 상태와 최근 지속시간 -- `/tmp/control-throttling-state.json`: `vcgencmd get_throttled` 기반 스로틀링 감지 상태와 최근 지속시간 +- `/tmp/control-low-voltage-state.json`: `vcgencmd get_throttled` 기반 저전압 감지 상태, 최근 지속시간, 최근 episode 이력 +- `/tmp/control-throttling-state.json`: `vcgencmd get_throttled` 기반 스로틀링 감지 상태, 최근 지속시간, 최근 episode 이력 - `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_logs`: notice 발생 이력 - `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 @@ -156,12 +156,14 @@ control-wifi-observe.service 10. Translate 버튼은 최초 접속 시 디바이스 언어를 기본값으로 사용하고, 변경값은 `localStorage`의 `controlLang`에 저장합니다. 라벨은 `Translate` 또는 `번역`으로만 표시합니다. 11. Theme 버튼은 기본값 `dark`로 시작하고, 변경값은 `localStorage`의 `controlTheme`에 저장합니다. 라벨은 `Theme` 또는 `테마`로만 표시합니다. 12. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. 웹 서버 실행 계정이 `/dev/vcio`를 직접 읽지 못하면 기존 sudo 실행 경로로 한 번 더 조회합니다. -13. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다. +13. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. 현재 감지가 꺼진 뒤 5분 이내는 `복구 중`, 최근 10분에 episode가 2회 이상이면 `반복 발생`으로 표시합니다. +14. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다. ## 주요 함수/모듈 - `collect_snapshot()`: 센서와 fan 상태 snapshot 생성 - `read_throttled_flags()`: `vcgencmd get_throttled`를 직접 조회하고, 권한 문제로 실패하면 sudo 경유 조회를 시도 +- `throttled_event_status()`: 저전압/스로틀링 episode 이력, 복구 중/반복 발생 판정, 최근 10분 통계를 계산 - `throttled_statuses()`: 라즈베리파이 throttled flag를 읽고 저전압/스로틀링 감지 이력을 상태 파일로 추적 - `low_voltage_status()`: 기존 호출 호환을 위한 저전압 상태 wrapper - `apply_fan_policy()`: 팬 목표값 계산과 적용 diff --git a/public/api.php b/public/api.php index 57e3763..5bbc899 100644 --- a/public/api.php +++ b/public/api.php @@ -120,6 +120,8 @@ function dmesg_log(): array function throttled_event_status(?int $flags, string $raw, int $activeBit, int $seenBit, string $statePath): array { $now = time(); + $windowSeconds = 600; + $recoverySeconds = 300; $active = $flags !== null && (bool)($flags & $activeBit); $seen = $flags !== null && (bool)($flags & $seenBit); @@ -134,6 +136,7 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s 'last_cleared_at' => null, 'last_duration_seconds' => 0, 'updated_at' => null, + 'events' => [], ]; $fp = @fopen($statePath, 'c+'); @@ -145,6 +148,10 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s if (is_array($decoded)) { $state = array_merge($state, array_intersect_key($decoded, $state)); } + $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) + )); $wasActive = !empty($state['active']); $activeSince = is_numeric($state['active_since'] ?? null) ? (int)$state['active_since'] : null; @@ -154,6 +161,11 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s if (!$wasActive || $activeSince === null) { $activeSince = $now; $state['last_detected_at'] = $now; + $state['events'][] = [ + 'start' => $now, + 'end' => null, + 'duration_seconds' => 0, + ]; } $state['active'] = true; $state['active_since'] = $activeSince; @@ -161,11 +173,32 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s if ($wasActive && $activeSince !== null) { $state['last_cleared_at'] = $now; $state['last_duration_seconds'] = max(0, $now - $activeSince); + $lastIndex = count($state['events']) - 1; + if ($lastIndex >= 0 && empty($state['events'][$lastIndex]['end'])) { + $state['events'][$lastIndex]['end'] = $now; + $state['events'][$lastIndex]['duration_seconds'] = $state['last_duration_seconds']; + } else { + $state['events'][] = [ + 'start' => $activeSince, + 'end' => $now, + 'duration_seconds' => $state['last_duration_seconds'], + ]; + } } $state['active'] = false; $state['active_since'] = null; } + $keepAfter = $now - 86400; + $state['events'] = array_slice(array_values(array_filter( + $state['events'], + static function ($event) use ($keepAfter, $now): bool { + $start = (int)($event['start'] ?? 0); + $end = is_numeric($event['end'] ?? null) ? (int)$event['end'] : $now; + return $start > 0 && max($start, $end) >= $keepAfter; + } + )), -200); + $state['updated_at'] = $now; ftruncate($fp, 0); rewind($fp); @@ -189,6 +222,39 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s ? date('Y-m-d H:i:s', (int)$ts) : null; + $windowStart = $now - $windowSeconds; + $recentCount = 0; + $recentActiveSeconds = 0; + $events = is_array($state['events'] ?? null) ? $state['events'] : []; + foreach ($events as $event) { + if (!is_array($event) || !is_numeric($event['start'] ?? null)) { + continue; + } + $start = (int)$event['start']; + $end = is_numeric($event['end'] ?? null) ? (int)$event['end'] : $now; + if ($end < $windowStart || $start > $now) { + continue; + } + $overlapStart = max($start, $windowStart); + $overlapEnd = min(max($end, $overlapStart), $now); + if ($overlapEnd > $overlapStart) { + $recentCount++; + $recentActiveSeconds += $overlapEnd - $overlapStart; + } + } + + $lastClearedAt = is_numeric($state['last_cleared_at'] ?? null) ? (int)$state['last_cleared_at'] : null; + $recovering = !$active && $lastClearedAt !== null && ($now - $lastClearedAt) <= $recoverySeconds; + $repeated = $recentCount >= 2; + $stateLabel = 'normal'; + if ($active) { + $stateLabel = 'active'; + } elseif ($repeated) { + $stateLabel = 'repeated'; + } elseif ($recovering) { + $stateLabel = 'recovering'; + } + return [ 'available' => $flags !== null, 'raw' => $raw, @@ -201,6 +267,14 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s 'duration_seconds' => $duration, 'last_duration_seconds' => (int)($state['last_duration_seconds'] ?? 0), 'updated_at' => $dateValue($state['updated_at'] ?? null), + 'state_label' => $stateLabel, + 'recovering' => $recovering, + 'repeated_recently' => $repeated, + 'recovery_window_seconds' => $recoverySeconds, + 'recent_window_seconds' => $windowSeconds, + 'recent_event_count' => $recentCount, + 'recent_active_seconds' => $recentActiveSeconds, + 'recent_active_percent' => round($recentActiveSeconds / $windowSeconds * 100, 1), ]; } diff --git a/public/assets/app.js b/public/assets/app.js index b2c5691..15feca6 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -24,9 +24,11 @@ statusLowVoltage: $('#statusLowVoltage'), statusLowVoltageLast: $('#statusLowVoltageLast'), statusLowVoltageDuration: $('#statusLowVoltageDuration'), + statusLowVoltageRecent: $('#statusLowVoltageRecent'), statusThrottling: $('#statusThrottling'), statusThrottlingLast: $('#statusThrottlingLast'), statusThrottlingDuration: $('#statusThrottlingDuration'), + statusThrottlingRecent: $('#statusThrottlingRecent'), statusBatteryVoltage: $('#statusBatteryVoltage'), statusBatterySoc: $('#statusBatterySoc'), statusBatteryRemaining: $('#statusBatteryRemaining'), @@ -98,19 +100,27 @@ lowVoltage: 'Low Voltage', lowVoltageLast: 'Last Low Voltage', lowVoltageDuration: 'LV Duration', + lowVoltageRecent: 'LV 10m', lowVoltageActive: 'Active', lowVoltageNormal: 'Normal', + lowVoltageRecovering: 'Recovering', + lowVoltageRepeated: 'Repeated', lowVoltageSeen: 'seen since boot', lowVoltageNotSeen: 'not seen since boot', throttling: 'Throttling', throttlingLast: 'Last Throttling', throttlingDuration: 'Throttle Duration', + throttlingRecent: 'Throttle 10m', throttlingActive: 'Throttling', throttlingNormal: 'Normal', + throttlingRecovering: 'Recovering', + throttlingRepeated: 'Repeated', throttlingSeen: 'seen since boot', throttlingNotSeen: 'not seen since boot', currentEpisode: 'current', lastEpisode: 'last', + recentCount: 'events', + recentActive: 'active', batteryV: 'Battery V', batterySoc: 'Battery SOC', sensorHistory: 'Sensor History', @@ -247,19 +257,27 @@ lowVoltage: '저전압', lowVoltageLast: '최근 저전압', lowVoltageDuration: '저전압 지속', + lowVoltageRecent: '저전압 10분', lowVoltageActive: '감지 중', lowVoltageNormal: '정상', + lowVoltageRecovering: '복구 중', + lowVoltageRepeated: '반복 발생', lowVoltageSeen: '부팅 후 이력 있음', lowVoltageNotSeen: '부팅 후 이력 없음', throttling: '스로틀링', throttlingLast: '최근 스로틀링', throttlingDuration: '스로틀링 지속', + throttlingRecent: '스로틀링 10분', throttlingActive: '감지 중', throttlingNormal: '정상', + throttlingRecovering: '복구 중', + throttlingRepeated: '반복 발생', throttlingSeen: '부팅 후 이력 있음', throttlingNotSeen: '부팅 후 이력 없음', currentEpisode: '현재', lastEpisode: '최근', + recentCount: '발생', + recentActive: '감지', batteryV: '배터리 전압', batterySoc: '배터리 SOC', sensorHistory: '센서 이력', @@ -804,16 +822,36 @@ return formatDhms(parseWifiDurationSeconds(value)) || value; } - function eventStateText(eventState, activeKey, normalKey, seenKey, notSeenKey) { + function eventStateText(eventState, activeKey, normalKey, recoveringKey, repeatedKey, seenKey, notSeenKey) { if (!eventState || eventState.available === false) { return t('na'); } - const stateText = eventState.active ? t(activeKey) : t(normalKey); + let stateText = t(normalKey); + if (eventState.state_label === 'repeated') { + stateText = t(repeatedKey); + } else if (eventState.state_label === 'recovering') { + stateText = t(recoveringKey); + } else if (eventState.active) { + stateText = t(activeKey); + } const seenText = eventState.seen_since_boot ? t(seenKey) : t(notSeenKey); return `${stateText} (${seenText})`; } + function eventRecentText(eventState) { + if (!eventState || eventState.available === false) { + return t('na'); + } + + const count = Number(eventState.recent_event_count ?? 0); + const seconds = Number(eventState.recent_active_seconds ?? 0); + const percent = Number(eventState.recent_active_percent ?? 0); + const activeText = formatDhms(seconds) || '0s'; + + return `${count}${state.lang === 'ko' ? '회' : 'x'} / ${t('recentActive')} ${activeText} (${percent.toFixed(1)}%)`; + } + function eventDurationText(eventState) { if (!eventState || eventState.available === false) { return t('na'); @@ -826,11 +864,11 @@ } function lowVoltageStateText(lowVoltage) { - return eventStateText(lowVoltage, 'lowVoltageActive', 'lowVoltageNormal', 'lowVoltageSeen', 'lowVoltageNotSeen'); + return eventStateText(lowVoltage, 'lowVoltageActive', 'lowVoltageNormal', 'lowVoltageRecovering', 'lowVoltageRepeated', 'lowVoltageSeen', 'lowVoltageNotSeen'); } function throttlingStateText(throttling) { - return eventStateText(throttling, 'throttlingActive', 'throttlingNormal', 'throttlingSeen', 'throttlingNotSeen'); + return eventStateText(throttling, 'throttlingActive', 'throttlingNormal', 'throttlingRecovering', 'throttlingRepeated', 'throttlingSeen', 'throttlingNotSeen'); } function timeAgo(seconds) { @@ -877,9 +915,11 @@ setText(els.statusLowVoltage, lowVoltageStateText(lowVoltage)); setText(els.statusLowVoltageLast, lowVoltage.last_detected_at || '-'); setText(els.statusLowVoltageDuration, eventDurationText(lowVoltage)); + setText(els.statusLowVoltageRecent, eventRecentText(lowVoltage)); setText(els.statusThrottling, throttlingStateText(throttling)); setText(els.statusThrottlingLast, throttling.last_detected_at || '-'); setText(els.statusThrottlingDuration, eventDurationText(throttling)); + setText(els.statusThrottlingRecent, eventRecentText(throttling)); setText(els.statusBatteryVoltage, battery.voltage === null || battery.voltage === undefined ? '-' : `${Number(battery.voltage).toFixed(3)} V`); setText(els.statusBatterySoc, battery.percent === null || battery.percent === undefined ? '-' : `${Number(battery.percent).toFixed(2)}%`); setText(els.statusBatteryRemaining, battery.remaining?.display || '-'); diff --git a/public/index.php b/public/index.php index d5aa34b..7317d08 100644 --- a/public/index.php +++ b/public/index.php @@ -203,9 +203,11 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
Low Voltage
-
Last Low Voltage
-
LV Duration
-
+
LV 10m
-
Throttling
-
Last Throttling
-
Throttle Duration
-
+
Throttle 10m
-
Battery V
-
Battery SOC
-
Remaining
-
@@ -322,7 +324,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da - +