Add throttling recovery metrics

This commit is contained in:
seo
2026-06-18 04:34:18 +09:00
parent b6d12f9a0e
commit 81a24530d6
4 changed files with 127 additions and 9 deletions
+6 -4
View File
@@ -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()`: 팬 목표값 계산과 적용
+74
View File
@@ -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),
];
}
+44 -4
View File
@@ -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 || '-');
+3 -1
View File
@@ -203,9 +203,11 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
<div class="status-row"><div class="status-key" data-i18n="lowVoltage">Low Voltage</div><div class="status-value" id="statusLowVoltage">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="lowVoltageLast">Last Low Voltage</div><div class="status-value" id="statusLowVoltageLast">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="lowVoltageDuration">LV Duration</div><div class="status-value" id="statusLowVoltageDuration">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="lowVoltageRecent">LV 10m</div><div class="status-value" id="statusLowVoltageRecent">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="throttling">Throttling</div><div class="status-value" id="statusThrottling">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="throttlingLast">Last Throttling</div><div class="status-value" id="statusThrottlingLast">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="throttlingDuration">Throttle Duration</div><div class="status-value" id="statusThrottlingDuration">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="throttlingRecent">Throttle 10m</div><div class="status-value" id="statusThrottlingRecent">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="batteryV">Battery V</div><div class="status-value" id="statusBatteryVoltage">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="batterySoc">Battery SOC</div><div class="status-value" id="statusBatterySoc">-</div></div>
<div class="status-row"><div class="status-key" data-i18n="remaining">Remaining</div><div class="status-value" id="statusBatteryRemaining">-</div></div>
@@ -322,7 +324,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
</div>
</div>
</div>
<script src="/assets/app.js?v=20260618_throttling1"></script>
<script src="/assets/app.js?v=20260618_throttling2"></script>
<script src="/assets/wakelock.js?v=20260607_prefs1"></script>
<script src="/push_subscribe.js?v=20260607_prefs1"></script>
<?php endif; ?>