diff --git a/README.md b/README.md index d732457..e94a993 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - 팬 모드 `auto`, `manual`, `off` 제어 - PWM slider 기반 수동 팬 제어 - CPU/RP1 온도, 팬 RPM, 팬 효율, CPU 전력, 배터리 상태 차트 +- 라즈베리파이 저전압 현재 상태, 최근 감지 시각, 지속시간 표시 - WiFi client 목록과 2.4G/5G client 수 표시 - WiFi client 상태 표시 - 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정 @@ -59,6 +60,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `control_state`: 팬 모드와 PWM 상태 - `sensor_logs`: 온도, RPM, PWM, 배터리, load, memory, disk, uptime +- `/tmp/control-low-voltage-state.json`: `vcgencmd get_throttled` 기반 저전압 감지 상태와 최근 지속시간 - `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_logs`: notice 발생 이력 - `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 @@ -149,11 +151,13 @@ control-wifi-observe.service 9. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. 10. Translate 버튼은 최초 접속 시 디바이스 언어를 기본값으로 사용하고, 변경값은 `localStorage`의 `controlLang`에 저장합니다. 라벨은 `Translate` 또는 `번역`으로만 표시합니다. 11. Theme 버튼은 기본값 `dark`로 시작하고, 변경값은 `localStorage`의 `controlTheme`에 저장합니다. 라벨은 `Theme` 또는 `테마`로만 표시합니다. -12. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다. +12. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. +13. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다. ## 주요 함수/모듈 - `collect_snapshot()`: 센서와 fan 상태 snapshot 생성 +- `low_voltage_status()`: 라즈베리파이 throttled flag를 읽고 저전압 감지 이력을 상태 파일로 추적 - `apply_fan_policy()`: 팬 목표값 계산과 적용 - `json_out()`: API JSON 응답 표준화 - `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용 diff --git a/public/api.php b/public/api.php index 1ab4dd6..61096b5 100644 --- a/public/api.php +++ b/public/api.php @@ -117,6 +117,107 @@ function dmesg_log(): array ]; } +function low_voltage_status(): array +{ + $result = sh(['/usr/bin/vcgencmd', 'get_throttled'], false, 3); + $raw = trim((string)$result['out']); + $flags = null; + + if ((int)$result['code'] === 0 && preg_match('/throttled=0x([0-9a-f]+)/i', $raw, $m)) { + $flags = hexdec($m[1]); + } + + $now = time(); + $active = $flags !== null && (bool)($flags & 0x1); + $seen = $flags !== null && (bool)($flags & 0x10000); + $freqCapped = $flags !== null && (bool)($flags & 0x2); + $throttled = $flags !== null && (bool)($flags & 0x4); + $softTempLimit = $flags !== null && (bool)($flags & 0x8); + $statePath = '/tmp/control-low-voltage-state.json'; + if (is_file($statePath) && !is_writable($statePath)) { + @chmod($statePath, 0666); + } + + $state = [ + 'active' => false, + 'active_since' => null, + 'last_detected_at' => null, + 'last_cleared_at' => null, + 'last_duration_seconds' => 0, + 'updated_at' => null, + ]; + + $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); + if (is_array($decoded)) { + $state = array_merge($state, array_intersect_key($decoded, $state)); + } + + $wasActive = !empty($state['active']); + $activeSince = is_numeric($state['active_since'] ?? null) ? (int)$state['active_since'] : null; + + if ($flags !== null) { + if ($active) { + if (!$wasActive || $activeSince === null) { + $activeSince = $now; + $state['last_detected_at'] = $now; + } + $state['active'] = true; + $state['active_since'] = $activeSince; + } else { + if ($wasActive && $activeSince !== null) { + $state['last_cleared_at'] = $now; + $state['last_duration_seconds'] = max(0, $now - $activeSince); + } + $state['active'] = false; + $state['active_since'] = null; + } + + $state['updated_at'] = $now; + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($state, JSON_UNESCAPED_SLASHES)); + fflush($fp); + @chmod($statePath, 0666); + } + + @flock($fp, LOCK_UN); + fclose($fp); + } + + $duration = 0; + if ($active && is_numeric($state['active_since'] ?? null)) { + $duration = max(0, $now - (int)$state['active_since']); + } elseif (is_numeric($state['last_duration_seconds'] ?? null)) { + $duration = max(0, (int)$state['last_duration_seconds']); + } + + $dateValue = static fn($ts): ?string => is_numeric($ts) && (int)$ts > 0 + ? date('Y-m-d H:i:s', (int)$ts) + : null; + + return [ + 'available' => $flags !== null, + 'raw' => $raw, + 'flags' => $flags, + 'active' => $active, + 'seen_since_boot' => $seen, + 'freq_capped' => $freqCapped, + 'throttled' => $throttled, + 'soft_temp_limit' => $softTempLimit, + 'active_since' => $dateValue($state['active_since'] ?? null), + 'last_detected_at' => $dateValue($state['last_detected_at'] ?? null), + 'last_cleared_at' => $dateValue($state['last_cleared_at'] ?? null), + 'duration_seconds' => $duration, + 'last_duration_seconds' => (int)($state['last_duration_seconds'] ?? 0), + 'updated_at' => $dateValue($state['updated_at'] ?? null), + ]; +} + function fan_paths(): array { $candidates = glob('/sys/devices/platform/cooling_fan/hwmon/hwmon*/pwm1') ?: []; @@ -1748,6 +1849,7 @@ function collect_snapshot(bool $applyFan = true): array 'active_users' => active_user_info(), 'memory' => $mem, 'disk' => $disk, + 'low_voltage' => low_voltage_status(), ]); $history = add_battery_remaining_history(sensor_history(240)); diff --git a/public/assets/app.js b/public/assets/app.js index 03ae984..78396fb 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -21,6 +21,9 @@ statusDisk: $('#statusDisk'), statusMemory: $('#statusMemory'), statusUptime: $('#statusUptime'), + statusLowVoltage: $('#statusLowVoltage'), + statusLowVoltageLast: $('#statusLowVoltageLast'), + statusLowVoltageDuration: $('#statusLowVoltageDuration'), statusBatteryVoltage: $('#statusBatteryVoltage'), statusBatterySoc: $('#statusBatterySoc'), statusBatteryRemaining: $('#statusBatteryRemaining'), @@ -88,6 +91,15 @@ diskRoot: 'Disk', memory: 'Memory', uptime: 'Uptime', + lowVoltage: 'Low Voltage', + lowVoltageLast: 'Last Low Voltage', + lowVoltageDuration: 'LV Duration', + lowVoltageActive: 'Active', + lowVoltageNormal: 'Normal', + lowVoltageSeen: 'seen since boot', + lowVoltageNotSeen: 'not seen since boot', + currentEpisode: 'current', + lastEpisode: 'last', batteryV: 'Battery V', batterySoc: 'Battery SOC', sensorHistory: 'Sensor History', @@ -221,6 +233,15 @@ diskRoot: '디스크', memory: '메모리', uptime: '가동 시간', + lowVoltage: '저전압', + lowVoltageLast: '최근 저전압', + lowVoltageDuration: '저전압 지속', + lowVoltageActive: '감지 중', + lowVoltageNormal: '정상', + lowVoltageSeen: '부팅 후 이력 있음', + lowVoltageNotSeen: '부팅 후 이력 없음', + currentEpisode: '현재', + lastEpisode: '최근', batteryV: '배터리 전압', batterySoc: '배터리 SOC', sensorHistory: '센서 이력', @@ -756,6 +777,27 @@ return formatDhms(parseWifiDurationSeconds(value)) || value; } + function lowVoltageStateText(lowVoltage) { + if (!lowVoltage || lowVoltage.available === false) { + return t('na'); + } + + const stateText = lowVoltage.active ? t('lowVoltageActive') : t('lowVoltageNormal'); + const seenText = lowVoltage.seen_since_boot ? t('lowVoltageSeen') : t('lowVoltageNotSeen'); + return `${stateText} (${seenText})`; + } + + function lowVoltageDurationText(lowVoltage) { + if (!lowVoltage || lowVoltage.available === false) { + return t('na'); + } + + const seconds = Number(lowVoltage.duration_seconds ?? 0); + const label = lowVoltage.active ? t('currentEpisode') : t('lastEpisode'); + const formatted = formatDhms(seconds) || '-'; + return `${label}: ${formatted}`; + } + function timeAgo(seconds) { const formatted = formatDhms(seconds); return formatted ? `${formatted} ${t('ago')}` : '-'; @@ -786,6 +828,7 @@ const disk = system.disk || {}; const memory = system.memory || {}; const battery = data.battery || {}; + const lowVoltage = system.low_voltage || {}; const load = Array.isArray(system.load) ? system.load : []; const activeUsers = system.active_users || {}; @@ -795,6 +838,9 @@ setText(els.statusDisk, `${Number(disk.used_kb || 0).toLocaleString()} / ${Number(disk.total_kb || 0).toLocaleString()} KB (${disk.percent ?? '-'}%)`); setText(els.statusMemory, `${memory.used_mb ?? '-'} / ${memory.total_mb ?? '-'} MB (${memory.percent ?? '-'}%)`); setText(els.statusUptime, system.uptime || '-'); + setText(els.statusLowVoltage, lowVoltageStateText(lowVoltage)); + setText(els.statusLowVoltageLast, lowVoltage.last_detected_at || '-'); + setText(els.statusLowVoltageDuration, lowVoltageDurationText(lowVoltage)); 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 12abee2..0ff6c0b 100644 --- a/public/index.php +++ b/public/index.php @@ -200,6 +200,9 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da