From b6cdb0f162bef025aa698f11b2c1ca880ba2d0ab Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 26 Jun 2026 02:03:16 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B0=ED=84=B0=EB=A6=AC=20=EC=9E=94?= =?UTF-8?q?=EC=97=AC=20=EC=8B=9C=EA=B0=84=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=A0=84=20=EC=B6=94=EC=84=B8=EB=A1=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 31 +++- public/api.php | 332 ++++++++++++++++++++++++++++++++++++------- public/assets/app.js | 35 +++++ 3 files changed, 338 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 71fb3a7..1f98b35 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - PWM slider 기반 수동 팬 제어 - CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트 - RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인 -- 배터리 잔여 시간은 1S8P 병렬팩 `2200mAh x 8 = 17600mAh`, nominal `3.7V`, 총 `65.12Wh` 기준으로 계산 +- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 30분/1시간/3시간/6시간/12시간/24시간 창으로 비교해 계산하고, CPU 전력 변화로 현재 부하를 보정 - 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시 - WiFi client 목록과 2.4G/5G client 수 표시 - 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정 @@ -68,6 +68,22 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정 - 배터리 용량 설정은 `/home/seo/secret/control.php`의 `battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다. +## 배터리 잔여 시간 계산 + +잔여 시간은 단순히 현재 CPU 전력 평균만으로 계산하지 않습니다. 실제 배터리 SOC가 DB에서 어떤 속도로 떨어졌는지를 먼저 사용합니다. + +1. `sensor_logs`의 최근 24시간 배터리 기록을 1분 단위로 SQL 집계합니다. +2. 30분, 1시간, 3시간, 6시간, 12시간, 24시간 창을 각각 검사합니다. +3. 각 창의 시작/끝 SOC는 평균 한 점이 아니라 가장자리 구간의 중앙값으로 잡아 순간 튐을 줄입니다. +4. SOC가 충분히 줄어든 방전 구간만 후보로 사용하고, 충전 또는 회복처럼 보이는 구간은 제외합니다. +5. 최근 CPU 전력이 해당 창의 평균 전력보다 높거나 낮으면 방전 속도에 완만하게 반영해 현재 부하 변화를 보정합니다. +6. 여러 후보는 신뢰도 가중치로 합산합니다. 신뢰도는 관측 시간, SOC 감소폭, 샘플 수를 기준으로 계산합니다. +7. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다. + +WebSocket 장기 실행 프로세스에서는 1분 단위 집계 결과를 55초 동안 재사용해 DB 부담을 줄입니다. HTTP 요청은 요청 단위로 계산하되, 집계 범위가 최근 24시간으로 제한되어 전체 DB를 훑지 않습니다. + +대시보드의 잔여 시간 값에는 계산 근거 tooltip이 붙습니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다. + ## HA 알림 구조 Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다. @@ -120,9 +136,10 @@ control-wifi-observe.service 8. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. 9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. 10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. -11. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. -12. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다. -13. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다. +11. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 여러 시간창으로 나눠 평가하고, 현재 CPU 전력 변화로 보정합니다. +12. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. +13. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다. +14. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다. ## 주요 함수/모듈 @@ -141,6 +158,9 @@ control-wifi-observe.service - `battery_low_notify_profile()`: 배터리 SOC 단계별 채널, 중요도, 아이콘, 진동 정책 선택 - `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송 - `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회 +- `battery_trend_history()`: 최근 24시간 배터리 기록을 1분 단위로 집계 +- `battery_remaining_estimate()`: SOC 다중 시간창 방전 추세와 현재 부하 보정으로 잔여 시간 계산 +- `battery_power_fallback_estimate()`: SOC 추세가 부족할 때 배터리 용량과 최근 CPU 전력 평균으로 잔여 시간 대체 계산 - `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 CLI - `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 @@ -160,7 +180,8 @@ control-wifi-observe.service ## 운영 체크포인트 - 센서 수집 주기와 DB 증가량을 확인합니다. -- 배터리팩 변경 시 `/home/seo/secret/control.php`의 `battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`입니다. +- 배터리팩 변경 시 `/home/seo/secret/control.php`의 `battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`이며, SOC 추세가 부족한 경우의 fallback 계산에 사용됩니다. +- 잔여 시간이 갑자기 흔들리면 tooltip의 `반영 구간`, `시간당 SOC 감소`, `추세 신뢰도`, `최근 CPU 전력`을 먼저 확인합니다. - 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다. - 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다. - 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다. diff --git a/public/api.php b/public/api.php index 6e2b949..09c7b9e 100644 --- a/public/api.php +++ b/public/api.php @@ -685,7 +685,220 @@ function sensor_history(int $limit = 240): array return array_reverse($stmt->fetchAll()); } -function battery_remaining_estimate(array $battery, array $history): array +function numeric_median(array $values): ?float +{ + $numbers = []; + foreach ($values as $value) { + if ($value !== null && $value !== '' && is_numeric($value)) { + $numbers[] = (float)$value; + } + } + + if ($numbers === []) { + return null; + } + + sort($numbers, SORT_NUMERIC); + $count = count($numbers); + $mid = intdiv($count, 2); + + return ($count % 2) === 1 + ? $numbers[$mid] + : ($numbers[$mid - 1] + $numbers[$mid]) / 2; +} + +function numeric_trimmed_average(array $values, float $trimRatio = 0.1): ?float +{ + $numbers = []; + foreach ($values as $value) { + if ($value !== null && $value !== '' && is_numeric($value)) { + $number = (float)$value; + if ($number > 0) { + $numbers[] = $number; + } + } + } + + if ($numbers === []) { + return null; + } + + sort($numbers, SORT_NUMERIC); + $trim = (int)floor(count($numbers) * max(0.0, min(0.4, $trimRatio))); + if ($trim > 0 && count($numbers) > ($trim * 2 + 2)) { + $numbers = array_slice($numbers, $trim, count($numbers) - ($trim * 2)); + } + + return array_sum($numbers) / count($numbers); +} + +function clamp_float(float $value, float $min, float $max): float +{ + return max($min, min($max, $value)); +} + +function battery_trend_history(int $hours = 24): array +{ + $hours = max(1, min(48, $hours)); + static $cache = []; + $cacheKey = (string)$hours; + $now = time(); + + if ( + isset($cache[$cacheKey]) + && ($now - (int)$cache[$cacheKey]['time']) < 55 + && is_array($cache[$cacheKey]['rows']) + ) { + return $cache[$cacheKey]['rows']; + } + + $stmt = db()->query(" + SELECT + FLOOR(UNIX_TIMESTAMP(recorded_at) / 60) AS bucket, + MIN(recorded_at) AS time, + AVG(battery_percent) AS battery_percent, + AVG(battery_voltage) AS battery_voltage, + AVG(cpu_watts) AS cpu_watts, + AVG(cpu_load_1) AS cpu_load_1, + COUNT(*) AS samples + FROM sensor_logs FORCE INDEX (idx_recorded_at) + WHERE recorded_at >= DATE_SUB(NOW(), INTERVAL {$hours} HOUR) + AND battery_percent IS NOT NULL + GROUP BY bucket + ORDER BY bucket ASC + "); + + $rows = []; + foreach ($stmt->fetchAll() as $row) { + $time = strtotime((string)($row['time'] ?? '')); + if ($time <= 0 || !is_numeric($row['battery_percent'] ?? null)) { + continue; + } + + $rows[] = [ + 'time' => $time, + 'battery_percent' => (float)$row['battery_percent'], + 'battery_voltage' => is_numeric($row['battery_voltage'] ?? null) ? (float)$row['battery_voltage'] : null, + 'cpu_watts' => is_numeric($row['cpu_watts'] ?? null) ? (float)$row['cpu_watts'] : null, + 'cpu_load_1' => is_numeric($row['cpu_load_1'] ?? null) ? (float)$row['cpu_load_1'] : null, + 'samples' => (int)($row['samples'] ?? 0), + ]; + } + + $cache[$cacheKey] = [ + 'time' => $now, + 'rows' => $rows, + ]; + + return $rows; +} + +function battery_trend_candidate(array $trendRows, float $currentPercent, int $windowSeconds, ?float $recentWatts): ?array +{ + if (count($trendRows) < 6) { + return null; + } + + $lastTime = (int)$trendRows[count($trendRows) - 1]['time']; + $rows = array_values(array_filter($trendRows, static function (array $row) use ($lastTime, $windowSeconds): bool { + return (int)$row['time'] >= ($lastTime - $windowSeconds) + && is_numeric($row['battery_percent'] ?? null); + })); + + $count = count($rows); + if ($count < 6) { + return null; + } + + $first = $rows[0]; + $last = $rows[$count - 1]; + $elapsed = max(1, (int)$last['time'] - (int)$first['time']); + if ($elapsed < 600) { + return null; + } + + $edgeCount = max(3, min(10, (int)floor($count * 0.12))); + $startSoc = numeric_median(array_column(array_slice($rows, 0, $edgeCount), 'battery_percent')); + $endSoc = numeric_median(array_column(array_slice($rows, -$edgeCount), 'battery_percent')); + if ($startSoc === null || $endSoc === null) { + return null; + } + + $drop = $startSoc - $endSoc; + $minDrop = $windowSeconds <= 3600 ? 0.12 : 0.22; + if ($drop < $minDrop) { + return null; + } + + $rate = $drop / $elapsed; + if ($rate <= 0) { + return null; + } + + $windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1); + $loadFactor = 1.0; + if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) { + $loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.65, 1.75) - 1) * 0.45); + $rate *= $loadFactor; + } + + $seconds = (int)round($currentPercent / $rate); + if ($seconds <= 0) { + return null; + } + + $confidence = min(1.0, $elapsed / $windowSeconds) + * min(1.0, $drop / 1.2) + * min(1.0, $count / 90); + + return [ + 'seconds' => $seconds, + 'rate_per_second' => $rate, + 'drop_percent' => round($drop, 3), + 'elapsed_seconds' => $elapsed, + 'window_seconds' => $windowSeconds, + 'sample_count' => $count, + 'confidence' => round($confidence, 4), + 'weight' => max(0.05, $confidence), + 'avg_watts' => $windowWatts === null ? null : round($windowWatts, 3), + 'load_factor' => round($loadFactor, 3), + ]; +} + +function battery_power_fallback_estimate(float $percent, array $history): array +{ + $wattValues = []; + + foreach (array_slice($history, -180) as $row) { + $watts = $row['cpu_watts'] ?? null; + if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) { + $wattValues[] = (float)$watts; + } + } + + $avgWatts = numeric_trimmed_average($wattValues, 0.12); + if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) { + $remainingWh = BATTERY_CAPACITY_WH * ($percent / 100); + $seconds = (int)round(($remainingWh / $avgWatts) * 3600); + + return [ + 'display' => human_remaining_seconds($seconds), + 'seconds' => $seconds, + 'source' => 'capacity_power_fallback', + 'avg_watts' => round($avgWatts, 3), + 'capacity_wh' => BATTERY_CAPACITY_WH, + ]; + } + + return [ + 'display' => '-', + 'seconds' => null, + 'source' => BATTERY_CAPACITY_WH > 0 ? 'avg_watts_missing' : 'battery_capacity_missing', + 'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3), + ]; +} + +function battery_remaining_estimate(array $battery, array $history, array $trendRows = []): array { $percent = $battery['percent'] ?? null; if ($percent === null || $percent === '' || !is_numeric($percent)) { @@ -698,91 +911,99 @@ function battery_remaining_estimate(array $battery, array $history): array } $percent = max(0.0, min(100.0, (float)$percent)); - $wattValues = []; + $recentWatts = numeric_trimmed_average(array_column(array_slice($trendRows, -10), 'cpu_watts'), 0.1) + ?? numeric_trimmed_average(array_column(array_slice($history, -90), 'cpu_watts'), 0.1); - foreach (array_slice($history, -90) as $row) { - $watts = $row['cpu_watts'] ?? null; - if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) { - $wattValues[] = (float)$watts; + $candidates = []; + foreach ([1800, 3600, 10800, 21600, 43200, 86400] as $windowSeconds) { + $candidate = battery_trend_candidate($trendRows, $percent, $windowSeconds, $recentWatts); + if ($candidate !== null) { + $candidates[] = $candidate; } } - $avgWatts = $wattValues === [] - ? null - : array_sum($wattValues) / count($wattValues); - - if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) { - $remainingWh = BATTERY_CAPACITY_WH * ($percent / 100); - $seconds = (int)round(($remainingWh / $avgWatts) * 3600); - - return [ - 'display' => human_remaining_seconds($seconds), - 'seconds' => $seconds, - 'source' => 'avg_watts', - 'avg_watts' => round($avgWatts, 3), - 'capacity_wh' => BATTERY_CAPACITY_WH, - ]; - } - - $socRows = []; - foreach ($history as $row) { - $soc = $row['battery_percent'] ?? null; - $time = strtotime((string)($row['time'] ?? '')); - if ($soc !== null && $soc !== '' && is_numeric($soc) && $time > 0) { - $socRows[] = [ - 'time' => $time, - 'soc' => (float)$soc, - ]; + if ($candidates !== []) { + $weightedRate = 0.0; + $weightSum = 0.0; + foreach ($candidates as $candidate) { + $weight = (float)$candidate['weight']; + $weightedRate += (float)$candidate['rate_per_second'] * $weight; + $weightSum += $weight; } - } - if (count($socRows) >= 30) { - $first = $socRows[0]; - $last = $socRows[count($socRows) - 1]; - $elapsed = max(1, (int)$last['time'] - (int)$first['time']); - $drop = (float)$first['soc'] - (float)$last['soc']; - - if ($elapsed >= 120 && $drop >= 0.2) { - $dropPerSecond = $drop / $elapsed; - $seconds = (int)round($percent / $dropPerSecond); + if ($weightedRate > 0 && $weightSum > 0) { + $rate = $weightedRate / $weightSum; + $seconds = (int)round($percent / $rate); + $avgWatts = numeric_trimmed_average(array_column($candidates, 'avg_watts'), 0.0); return [ 'display' => human_remaining_seconds($seconds), 'seconds' => $seconds, - 'source' => 'soc_trend', + 'source' => 'soc_multi_window', 'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3), + 'recent_watts' => $recentWatts === null ? null : round($recentWatts, 3), + 'drop_per_hour' => round($rate * 3600, 3), + 'confidence' => round(array_sum(array_column($candidates, 'confidence')) / count($candidates), 3), + 'windows' => array_map(static function (array $candidate): array { + return [ + 'minutes' => (int)round($candidate['window_seconds'] / 60), + 'drop_percent' => $candidate['drop_percent'], + 'confidence' => $candidate['confidence'], + 'load_factor' => $candidate['load_factor'], + ]; + }, $candidates), ]; } } - return [ - 'display' => '-', - 'seconds' => null, - 'source' => BATTERY_CAPACITY_WH > 0 ? 'avg_watts_missing' : 'battery_capacity_missing', - 'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3), - ]; + return battery_power_fallback_estimate($percent, $history); } function add_battery_remaining_history(array $history): array { $wattWindow = []; + $socWindow = []; foreach ($history as $index => $row) { $watts = $row['cpu_watts'] ?? null; if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) { $wattWindow[] = (float)$watts; - if (count($wattWindow) > 90) { + if (count($wattWindow) > 180) { array_shift($wattWindow); } } $percent = $row['battery_percent'] ?? null; - $avgWatts = $wattWindow === [] - ? null - : array_sum($wattWindow) / count($wattWindow); + $time = strtotime((string)($row['time'] ?? '')); + if ($percent !== null && $percent !== '' && is_numeric($percent) && $time > 0) { + $socWindow[] = [ + 'time' => $time, + 'soc' => (float)$percent, + ]; + if (count($socWindow) > 240) { + array_shift($socWindow); + } + } $history[$index]['battery_remaining_seconds'] = null; - if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0 && is_numeric($percent)) { + if (!is_numeric($percent)) { + continue; + } + + $safePercent = max(0.0, min(100.0, (float)$percent)); + if (count($socWindow) >= 30) { + $first = $socWindow[0]; + $last = $socWindow[count($socWindow) - 1]; + $elapsed = max(1, (int)$last['time'] - (int)$first['time']); + $drop = (float)$first['soc'] - (float)$last['soc']; + if ($elapsed >= 120 && $drop >= 0.12) { + $history[$index]['battery_remaining_seconds'] = (int)round($safePercent / ($drop / $elapsed)); + continue; + } + } + + $avgWatts = numeric_trimmed_average($wattWindow, 0.12); + if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) { $safePercent = max(0.0, min(100.0, (float)$percent)); $remainingWh = BATTERY_CAPACITY_WH * ($safePercent / 100); $history[$index]['battery_remaining_seconds'] = (int)round(($remainingWh / $avgWatts) * 3600); @@ -2124,7 +2345,8 @@ function collect_snapshot(bool $applyFan = true): array ]); $history = add_battery_remaining_history(sensor_history(240)); - $battery['remaining'] = battery_remaining_estimate($battery, $history); + $batteryTrend = battery_trend_history(24); + $battery['remaining'] = battery_remaining_estimate($battery, $history, $batteryTrend); $processes = process_resource_data(6); $fanSpike = fan_spike_analysis($history, $fan, $system, $processes); diff --git a/public/assets/app.js b/public/assets/app.js index f7fe28a..6c5c1ee 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -674,6 +674,38 @@ if (node) node.textContent = value ?? 'N/A'; } + function batteryRemainingTitle(remaining) { + if (!remaining || typeof remaining !== 'object') { + return ''; + } + + const sourceMap = { + soc_multi_window: 'SOC 다중 시간창 추세', + capacity_power_fallback: '배터리 용량과 최근 전력 평균', + battery_soc_missing: 'SOC 없음', + avg_watts_missing: '전력 평균 없음', + battery_capacity_missing: '배터리 용량 설정 없음', + }; + const lines = [sourceMap[remaining.source] || remaining.source || '-']; + + if (remaining.drop_per_hour !== undefined && remaining.drop_per_hour !== null) { + lines.push(`시간당 SOC 감소: ${Number(remaining.drop_per_hour).toFixed(3)}%`); + } + if (remaining.confidence !== undefined && remaining.confidence !== null) { + lines.push(`추세 신뢰도: ${(Number(remaining.confidence) * 100).toFixed(0)}%`); + } + if (remaining.recent_watts !== undefined && remaining.recent_watts !== null) { + lines.push(`최근 CPU 전력: ${Number(remaining.recent_watts).toFixed(3)}W`); + } else if (remaining.avg_watts !== undefined && remaining.avg_watts !== null) { + lines.push(`평균 CPU 전력: ${Number(remaining.avg_watts).toFixed(3)}W`); + } + if (Array.isArray(remaining.windows) && remaining.windows.length > 0) { + lines.push(`반영 구간: ${remaining.windows.map(row => `${row.minutes}분`).join(', ')}`); + } + + return lines.join('\n'); + } + function renderTop(data) { setText(els.updated, data.generated_at || '-'); setText(els.temp, Number(data.system?.temp_c || 0).toFixed(1) + '°C'); @@ -867,6 +899,9 @@ 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 || '-'); + if (els.statusBatteryRemaining) { + els.statusBatteryRemaining.title = batteryRemainingTitle(battery.remaining); + } if (els.statusUsers) { const names = String(activeUsers.names || '').trim(); els.statusUsers.innerHTML = names