diff --git a/.github/release-notes/v1.1.6.md b/.github/release-notes/v1.1.6.md
new file mode 100644
index 0000000..7fd7f3f
--- /dev/null
+++ b/.github/release-notes/v1.1.6.md
@@ -0,0 +1,27 @@
+## WinBat Lens v1.1.6
+
+This release adds a battery voltage curve recorded against state of charge, and
+fixes a tray icon that hid real sub-watt power flow.
+
+### Highlights
+
+- **New "Battery Voltage History" tab.** The monitor records the battery
+ driver's measured terminal voltage each time the integer battery percentage
+ changes, and plots the resulting 0–100% curve.
+- Per-percentage average, minimum and maximum voltage, change-event count and
+ last-recorded time are kept in an exact-value table alongside the chart, with
+ a clear action.
+- The history is persisted per user in LocalAppData, so the curve keeps
+ building across sessions.
+- Percentages that have never been observed are left as gaps in the curve
+ rather than interpolated — an unmeasured voltage is never drawn as if it had
+ been read.
+- Only voltages the battery driver actually reported are recorded. The internal
+ nominal voltage used for power math is excluded.
+- **Tray icon: sub-watt power is no longer rounded away.** A real charge or
+ discharge rate between 0.1 W and 0.9 W used to render as `0`, hiding the fact
+ that power was flowing. Rates below 1 W now show one decimal place; whole-watt
+ and `99+` display above 1 W is unchanged.
+
+The published executables are unsigned. Windows SmartScreen may show a warning
+on first launch.
diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md
index f57e44a..1d56db1 100644
--- a/AI_HANDOFF.md
+++ b/AI_HANDOFF.md
@@ -1,10 +1,58 @@
# Project State & Handoff
-## v1.1.5 release preparation (2026-08-05)
+## v1.1.6: battery voltage history by percentage (2026-08-05)
+
+Added a battery voltage history feature. The monitor records a real
+battery-driver voltage only when the integer battery percentage changes; the
+same percentage is ignored on subsequent one-second polls. The first
+observation seeds a percentage that has no prior record, while a percentage
+seen again after the battery moves is aggregated as another change event.
+
+`BatteryVoltageHistoryService` persists per-percentage average/minimum/maximum
+voltage, event count, and last timestamp in the user's LocalAppData. The new
+Battery Voltage History tab plots the observed 0-100% curve, leaves gaps for
+unmeasured percentages, and includes an exact-value table plus clear action.
+The chart never uses `RealTimePowerState.BatteryVoltageV` when
+`IsVoltageMeasured` is false, because that state can carry an internal nominal
+voltage used only for power math.
+
+Shipped together with the sub-watt tray icon fix below as **v1.1.6**; release
+notes are in `.github/release-notes/v1.1.6.md`.
+
+### Review pass before release
+
+Four defects were found and fixed in the new tab before publication, all in
+presentation rather than in the recorded measurements:
+
+1. **The vertical gridlines did not line up with the data.** They were drawn
+ across the whole container in tenths (`width/10*i`), but the curve is
+ plotted into an inset area (`plotLeft = 44`, `plotRight = 8`) that leaves
+ room for the Y-axis labels. `DrawVoltageChartGridlines` now takes
+ `plotLeft`/`plotWidth` and draws `i = 0..10` across the plot area, so a
+ gridline sits exactly under the percentage it marks and the plot's own
+ 0%/100% edges are visible.
+2. **The 25% and 75% X-axis labels were in the wrong place.** Five equal
+ columns with the outer two edge-aligned put their centres at 30% and 70%.
+ The end columns are now `0.5*`, which lands every label on its true
+ position.
+3. **`LastRecordedAt` was bound raw**, so the column rendered a
+ culture-dependent `DateTime.ToString()` instead of the app's
+ `yyyy-MM-dd HH:mm:ss` convention. A `LastRecordedAtText` property was added
+ (and change-notified from `AddSample`) to match `PowerHistoryRecord`.
+4. **The XAML placeholder text and the samples-column header disagreed with
+ the localization strings** they are overwritten with at runtime — the header
+ said 樣本數 where the string is 變化次數, which is the more accurate name for
+ what is counted.
+
+Validation after the fixes: Debug and Release solution builds passed with
+`-warnaserror` at 0 warnings / 0 errors, `dotnet test` passed 17/17, and
+`git diff --check` passed.
+
+## v1.1.5 release and publication (2026-08-05)
The post-v1.1.4 background-startup and comprehensive audit changes are being
-prepared for publication as v1.1.5. The project version is now 1.1.5 and the
-release notes are in `.github/release-notes/v1.1.5.md`.
+published as v1.1.5. The project version is 1.1.5 and the release notes are in
+`.github/release-notes/v1.1.5.md`.
The intended release scope is the complete existing working-tree change set:
background startup, tray warmup/idle behavior, removal of unsupported or dead
@@ -23,8 +71,31 @@ three assets; they are unsigned by design in this environment:
- `WinBatLens_v1.1.5_Setup_x64.exe`: SHA-256
`B4F5C013BA3F6CEAF1D1E917C068304A2CD993E3CB8FE90A2A830DAFA4DDA4B9`
-The ZIP contains `WinBatLens.exe`, `README.md`, and `LICENSE`. Publication to
-GitHub is the remaining step.
+The ZIP contains `WinBatLens.exe`, `README.md`, and `LICENSE`. PR #8 was merged
+to `main` at `c9fdbed`, tag `v1.1.5` triggered Release workflow run
+`30962533960`, and the normal GitHub Release is published at
+`https://github.com/nojackno2-ctrl/WinBat-Lens/releases/tag/v1.1.5`.
+
+The GitHub runner rebuilt the release assets, so the authoritative downloaded
+asset hashes are:
+
+- `WinBatLens_v1.1.5_Portable_x64.exe`: SHA-256
+ `1c8e0757439a0bde196149022bf048f6f7faf91574ff5496d640a07abaf798c9`
+- `WinBatLens_v1.1.5_Portable_x64.zip`: SHA-256
+ `1cdd0edf0908228021ef1ae34e0beec903c670fab2dc4b72ca889d16d6b35d2a`
+- `WinBatLens_v1.1.5_Setup_x64.exe`: SHA-256
+ `c66ca8fe3e8928240917a29d4df8a4364c2b1a3fe571fe443244fd973164cd76`
+
+## Sub-watt tray icon power display (2026-08-05)
+
+`DynamicTrayIconService` previously rounded every measured charge/discharge
+rate to an integer before drawing it. A real rate between 0.1W and 0.9W was
+therefore rendered as `0`, hiding the fact that power was flowing. The shared
+`FormatWattageForIcon` formatter now keeps one decimal place below 1W while
+preserving the existing integer and `99+` display rules at higher values.
+
+Added `TrayIconPowerFormattingTests` covering sub-watt, whole-watt, and
+overflow formatting. Released as part of v1.1.6.
## v1.1.4 released (latest)
@@ -131,10 +202,12 @@ run during this pass.
## Branch consolidation (2026-08-05)
-After refreshing `origin`, every branch had no commits outside `main`. The
-already-merged local `feat/show-version-and-handle-duplicate-launch` branch
-and remote `claude/memory-cpu-optimization-p0z5ve` branch were deleted. Only `main` remains locally and on `origin`; the working-tree audit changes remain
-uncommitted.
+After refreshing `origin`, `codex/v1.1.5-release` was confirmed to have no
+commits outside `main`; `main` already contained its release commit via the
+`c9fdbed` merge. The local and `origin` copies of that already-merged branch
+were deleted. Only `main` remains locally and on `origin`. The battery-voltage
+history and sub-watt tray-formatting changes in the working tree remain
+uncommitted and were preserved.
## Complete Traditional Chinese XML Documentation Pass (2026-08-05)
diff --git a/MainWindow.xaml b/MainWindow.xaml
index dead3f8..0ceda8d 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -505,7 +505,91 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -523,7 +607,7 @@
-
+
@@ -536,7 +620,7 @@
-
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 92cab1f..b12c325 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -69,6 +69,7 @@ public partial class MainWindow : Window
private readonly PointCollection _chargePoints = new PointCollection(MAX_CHART_POINTS);
private readonly PointCollection _gpuPoints = new PointCollection(MAX_CHART_POINTS);
private double _lastAxisScaleW = -1.0;
+ private static readonly SolidColorBrush BrushVoltage = CreateFrozen(MediaColor.FromRgb(0x38, 0xBD, 0xF8));
// Frozen, shared brushes reused across timer ticks. UpdateLivePowerUI
// runs once per second; allocating fresh SolidColorBrush objects every
@@ -190,6 +191,8 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
// Bind Power History List
LvPowerHistory.ItemsSource = RealTimePowerHistoryService.Records;
+ BatteryVoltageHistoryService.Load();
+ LvBatteryVoltageHistory.ItemsSource = BatteryVoltageHistoryService.Points;
// Initialize System Tray Icon & AutoStart State
InitSystemTrayIcon();
@@ -204,6 +207,7 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
// Draw Background Gridlines
DrawChartGridlines();
+ RedrawBatteryVoltageChart();
await warmup;
@@ -253,6 +257,7 @@ private void ApplyLanguage()
// Tabs
TabRealTime.Header = LocalizationService.Get("TabRealTime");
TabHistoryLogs.Header = LocalizationService.Get("TabHistoryLogs");
+ TabVoltageHistory.Header = LocalizationService.Get("TabVoltageHistory");
TabDiagnostics.Header = LocalizationService.Get("TabDiagnostics");
TabLifeEstimates.Header = LocalizationService.Get("TabLifeEstimates");
TabRecentUsage.Header = LocalizationService.Get("TabRecentUsage");
@@ -265,6 +270,15 @@ private void ApplyLanguage()
TxtLegendCharge.Text = LocalizationService.Get("LegendCharge");
TxtLegendGpu.Text = LocalizationService.Get("LegendGpu");
+ TxtVoltageHistoryTitle.Text = LocalizationService.Get("VoltageHistoryTitle");
+ TxtVoltageHistoryHint.Text = LocalizationService.Get("VoltageHistoryHint");
+ BtnClearVoltageHistory.Content = LocalizationService.Get("BtnClearVoltageHistory");
+ VoltagePercentColumn.Header = LocalizationService.Get("VoltagePercentColumn");
+ VoltageAverageColumn.Header = LocalizationService.Get("VoltageAverageColumn");
+ VoltageRangeColumn.Header = LocalizationService.Get("VoltageRangeColumn");
+ VoltageSamplesColumn.Header = LocalizationService.Get("VoltageEventsColumn");
+ VoltageLastColumn.Header = LocalizationService.Get("VoltageLastColumn");
+
TxtHardwareTitle.Text = LocalizationService.Get("HardwareTitle");
LblHwBatteryTelemetry.Text = LocalizationService.Get("HwBatteryTelemetry");
LblHwEnergy.Text = LocalizationService.Get("HwEnergy");
@@ -279,6 +293,167 @@ private void ApplyLanguage()
if (_trayItemCheck != null) _trayItemCheck.Text = LocalizationService.Get("TrayCheck");
if (_trayItemAutoStart != null) _trayItemAutoStart.Text = LocalizationService.Get("TrayAutoStart");
if (_trayItemExit != null) _trayItemExit.Text = LocalizationService.Get("TrayExit");
+ RedrawBatteryVoltageChart();
+ }
+
+ private void GridVoltageChartContainer_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ RedrawBatteryVoltageChart();
+ }
+
+ ///
+ /// Draws the voltage curve against a fixed 0–100% horizontal axis.
+ /// Missing percentages are left as gaps rather than being interpolated
+ /// into values that the battery driver never reported.
+ ///
+ private void RedrawBatteryVoltageChart()
+ {
+ try
+ {
+ int pointCount = BatteryVoltageHistoryService.RecordedPercentCount;
+ int eventCount = BatteryVoltageHistoryService.TotalSampleCount;
+ TxtVoltageHistorySummary.Text = string.Format(
+ LocalizationService.Get("VoltageHistorySummary"),
+ pointCount,
+ eventCount);
+
+ CanvasVoltageGridlines.Children.Clear();
+ CanvasVoltageLines.Children.Clear();
+ CanvasVoltageMarkers.Children.Clear();
+
+ double width = GridVoltageChartContainer.ActualWidth;
+ double height = GridVoltageChartContainer.ActualHeight;
+ if (width <= 0 || height <= 0 || pointCount == 0)
+ {
+ SetVoltageAxisLabels(null, null);
+ return;
+ }
+
+ var points = BatteryVoltageHistoryService.Points;
+ double measuredMin = points.Min(point => point.AverageVoltageV);
+ double measuredMax = points.Max(point => point.AverageVoltageV);
+ double voltageSpan = Math.Max(1.0, measuredMax - measuredMin);
+ double padding = Math.Max(0.2, voltageSpan * 0.1);
+ double axisMin = Math.Floor((measuredMin - padding) * 10.0) / 10.0;
+ double axisMax = Math.Ceiling((measuredMax + padding) * 10.0) / 10.0;
+
+ if (axisMax - axisMin < 1.0)
+ {
+ double center = (measuredMin + measuredMax) / 2.0;
+ axisMin = Math.Floor((center - 0.5) * 10.0) / 10.0;
+ axisMax = axisMin + 1.0;
+ }
+
+ const double plotLeft = 44.0;
+ const double plotRight = 8.0;
+ double plotWidth = Math.Max(1.0, width - plotLeft - plotRight);
+
+ DrawVoltageChartGridlines(plotLeft, plotWidth, width, height);
+ SetVoltageAxisLabels(axisMin, axisMax);
+
+ double axisRange = axisMax - axisMin;
+ Polyline? currentSegment = null;
+ int previousPercent = -2;
+
+ foreach (var item in points)
+ {
+ double x = plotLeft + (item.BatteryPercent / 100.0 * plotWidth);
+ double normalized = (item.AverageVoltageV - axisMin) / axisRange;
+ double y = height - Math.Clamp(normalized, 0.0, 1.0) * height;
+
+ // Only adjacent percentage events share a segment. A gap
+ // in the curve therefore remains visibly unmeasured.
+ if (item.BatteryPercent != previousPercent + 1)
+ {
+ currentSegment = new Polyline
+ {
+ Stroke = BrushVoltage,
+ StrokeThickness = 2.5,
+ };
+ currentSegment.StrokeLineJoin = PenLineJoin.Round;
+ CanvasVoltageLines.Children.Add(currentSegment);
+ }
+
+ currentSegment!.Points.Add(new WpfPoint(x, y));
+
+ var marker = new System.Windows.Shapes.Ellipse
+ {
+ Width = 6,
+ Height = 6,
+ Fill = BrushVoltage,
+ Stroke = BrushVoltage,
+ StrokeThickness = 1
+ };
+ System.Windows.Controls.Canvas.SetLeft(marker, x - 3);
+ System.Windows.Controls.Canvas.SetTop(marker, y - 3);
+ CanvasVoltageMarkers.Children.Add(marker);
+ previousPercent = item.BatteryPercent;
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Voltage chart redraw error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Draws the voltage chart's background grid. The vertical divisions are
+ /// placed across the plot area rather than the whole container, so a
+ /// gridline sits exactly under the percentage it labels.
+ ///
+ private void DrawVoltageChartGridlines(double plotLeft, double plotWidth, double width, double height)
+ {
+ for (int i = 1; i <= 3; i++)
+ {
+ double y = (height / 4.0) * i;
+ CanvasVoltageGridlines.Children.Add(new Line
+ {
+ X1 = 0,
+ Y1 = y,
+ X2 = width,
+ Y2 = y,
+ Stroke = BrushGridStrong,
+ StrokeThickness = 1,
+ StrokeDashArray = DashStrong
+ });
+ }
+
+ // 0% and 100% included: the plot area is inset from the container,
+ // so its own edges are not otherwise visible.
+ for (int i = 0; i <= 10; i++)
+ {
+ double x = plotLeft + (plotWidth / 10.0) * i;
+ CanvasVoltageGridlines.Children.Add(new Line
+ {
+ X1 = x,
+ Y1 = 0,
+ X2 = x,
+ Y2 = height,
+ Stroke = BrushGridFaint,
+ StrokeThickness = 1,
+ StrokeDashArray = DashFaint
+ });
+ }
+ }
+
+ private void SetVoltageAxisLabels(double? axisMin, double? axisMax)
+ {
+ if (!axisMin.HasValue || !axisMax.HasValue)
+ {
+ TxtVoltageYAxis100.Text = "-- V";
+ TxtVoltageYAxis75.Text = "-- V";
+ TxtVoltageYAxis50.Text = "-- V";
+ TxtVoltageYAxis25.Text = "-- V";
+ TxtVoltageYAxis0.Text = "-- V";
+ return;
+ }
+
+ double range = axisMax.Value - axisMin.Value;
+ TxtVoltageYAxis100.Text = $"{axisMax.Value:F2} V";
+ TxtVoltageYAxis75.Text = $"{(axisMin.Value + range * 0.75):F2} V";
+ TxtVoltageYAxis50.Text = $"{(axisMin.Value + range * 0.50):F2} V";
+ TxtVoltageYAxis25.Text = $"{(axisMin.Value + range * 0.25):F2} V";
+ TxtVoltageYAxis0.Text = $"{axisMin.Value:F2} V";
}
private void GridChartContainer_SizeChanged(object sender, SizeChangedEventArgs e)
@@ -601,6 +776,7 @@ private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEven
// Real exit: Unloaded is not reliably raised for a top-level
// Window, so stop the polling loop and release sensors/tray here.
_livePowerCts?.Cancel();
+ BatteryVoltageHistoryService.Flush();
try { HardwareSensorService.Shutdown(); } catch { }
try { BatteryTelemetryService.Shutdown(); } catch { }
try { PowerSupplyService.Shutdown(); } catch { }
@@ -797,6 +973,19 @@ private void UpdateLivePowerUI(RealTimePowerState state, bool recordSample = tru
// Add to Power & Battery Event History Service
RealTimePowerHistoryService.AddRecordFromPowerState(state);
+ // Voltage history is event-based: a second-by-second poll
+ // at the same battery percentage is intentionally ignored.
+ bool voltageEventRecorded = false;
+ if (state.IsVoltageMeasured)
+ {
+ voltageEventRecorded = BatteryVoltageHistoryService.RecordOnPercentChange(
+ state.BatteryPercent,
+ state.BatteryVoltageV,
+ DateTime.Now);
+ }
+ if (voltageEventRecorded)
+ RedrawBatteryVoltageChart();
+
// Update 60-Second Waveform Chart
UpdateWaveformChart(state);
}
@@ -1140,6 +1329,24 @@ private void BtnClearPowerHistory_Click(object sender, RoutedEventArgs e)
}
}
+ private void BtnClearVoltageHistory_Click(object sender, RoutedEventArgs e)
+ {
+ bool en = LocalizationService.CurrentLanguage == AppLanguage.English;
+ var result = MessageBox.Show(
+ en
+ ? "Clear all battery voltage-by-percentage history?"
+ : "要清除所有電量百分比與電壓紀錄嗎?",
+ en ? "Confirm Clear" : "確認清除",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ {
+ BatteryVoltageHistoryService.Clear();
+ RedrawBatteryVoltageChart();
+ }
+ }
+
private async void BtnGenerateReport_Click(object sender, RoutedEventArgs e)
{
await RunBatteryCheckAsync();
diff --git a/Models/BatteryVoltageHistoryPoint.cs b/Models/BatteryVoltageHistoryPoint.cs
new file mode 100644
index 0000000..0667904
--- /dev/null
+++ b/Models/BatteryVoltageHistoryPoint.cs
@@ -0,0 +1,110 @@
+using System;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Text.Json.Serialization;
+
+namespace WinBatLens.Models
+{
+ ///
+ /// 一個電量百分比的電池端實測電壓彙總。
+ ///
+ public sealed class BatteryVoltageHistoryPoint : INotifyPropertyChanged
+ {
+ private double _averageVoltageV;
+ private double _minimumVoltageV;
+ private double _maximumVoltageV;
+ private int _sampleCount;
+ private DateTime _lastRecordedAt;
+
+ /// 電池電量百分比,範圍為 0–100。
+ public int BatteryPercent { get; set; }
+
+ /// 此電量區間的平均實測電壓。
+ public double AverageVoltageV
+ {
+ get => _averageVoltageV;
+ set => SetField(ref _averageVoltageV, value);
+ }
+
+ /// 此電量區間的最低實測電壓。
+ public double MinimumVoltageV
+ {
+ get => _minimumVoltageV;
+ set => SetField(ref _minimumVoltageV, value);
+ }
+
+ /// 此電量區間的最高實測電壓。
+ public double MaximumVoltageV
+ {
+ get => _maximumVoltageV;
+ set => SetField(ref _maximumVoltageV, value);
+ }
+
+ /// 此電量百分比累積的實測樣本數。
+ public int SampleCount
+ {
+ get => _sampleCount;
+ set => SetField(ref _sampleCount, value);
+ }
+
+ /// 最後一次取得此電量區間電壓的時間。
+ public DateTime LastRecordedAt
+ {
+ get => _lastRecordedAt;
+ set => SetField(ref _lastRecordedAt, value);
+ }
+
+ [JsonIgnore]
+ public string BatteryPercentText => $"{BatteryPercent}%";
+
+ [JsonIgnore]
+ public string AverageVoltageText => $"{AverageVoltageV:F2} V";
+
+ [JsonIgnore]
+ public string VoltageRangeText => SampleCount > 1
+ ? $"{MinimumVoltageV:F2}–{MaximumVoltageV:F2} V"
+ : AverageVoltageText;
+
+ /// 最後紀錄時間文字(格式 yyyy-MM-dd HH:mm:ss),與其他歷史清單一致。
+ [JsonIgnore]
+ public string LastRecordedAtText => LastRecordedAt.ToString("yyyy-MM-dd HH:mm:ss");
+
+ ///
+ /// 將一筆新的實測電壓加入此電量區間,並以累進平均避免保留大量原始樣本。
+ ///
+ public void AddSample(double voltageV, DateTime recordedAt)
+ {
+ if (!double.IsFinite(voltageV) || voltageV <= 0)
+ throw new ArgumentOutOfRangeException(nameof(voltageV));
+
+ if (SampleCount <= 0)
+ {
+ AverageVoltageV = voltageV;
+ MinimumVoltageV = voltageV;
+ MaximumVoltageV = voltageV;
+ SampleCount = 1;
+ }
+ else
+ {
+ AverageVoltageV += (voltageV - AverageVoltageV) / (SampleCount + 1);
+ MinimumVoltageV = Math.Min(MinimumVoltageV, voltageV);
+ MaximumVoltageV = Math.Max(MaximumVoltageV, voltageV);
+ SampleCount++;
+ }
+
+ LastRecordedAt = recordedAt;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AverageVoltageText)));
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(VoltageRangeText)));
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(LastRecordedAtText)));
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void SetField(ref T field, T value, [CallerMemberName] string? propertyName = null)
+ {
+ if (Equals(field, value)) return;
+ field = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+ }
+}
diff --git a/Services/BatteryVoltageHistoryService.cs b/Services/BatteryVoltageHistoryService.cs
new file mode 100644
index 0000000..5f64aef
--- /dev/null
+++ b/Services/BatteryVoltageHistoryService.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using WinBatLens.Models;
+
+namespace WinBatLens.Services
+{
+ ///
+ /// 累積並保存「電量百分比 → 電池端電壓」的實測紀錄。
+ ///
+ public static class BatteryVoltageHistoryService
+ {
+ private const int CurrentFileVersion = 1;
+ private static readonly ObservableCollection _points = new();
+ private static readonly string _historyDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "WinBatLens");
+ private static readonly string _historyFilePath = Path.Combine(
+ _historyDirectory,
+ "battery-voltage-history.json");
+
+ private static bool _loaded;
+ private static int? _lastObservedPercent;
+
+ /// 依電量百分比遞增排序的歷史紀錄。
+ public static ReadOnlyObservableCollection Points { get; } =
+ new ReadOnlyObservableCollection(_points);
+
+ /// 目前已記錄的電量百分比區間數。
+ public static int RecordedPercentCount => _points.Count;
+
+ /// 目前累積的硬體實測樣本總數。
+ public static int TotalSampleCount => _points.Sum(point => Math.Max(0, point.SampleCount));
+
+ /// 載入安裝版使用者的持久化電壓紀錄;損毀檔案會被忽略。
+ public static void Load()
+ {
+ if (_loaded) return;
+ _loaded = true;
+
+ try
+ {
+ if (!File.Exists(_historyFilePath)) return;
+
+ var file = JsonSerializer.Deserialize(
+ File.ReadAllText(_historyFilePath));
+
+ if (file?.Version != CurrentFileVersion || file.Points == null) return;
+
+ foreach (var point in file.Points
+ .Where(IsValidPoint)
+ .OrderBy(point => point.BatteryPercent))
+ {
+ _points.Add(point);
+ }
+
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Battery voltage history load failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// 只在電量百分比變化時記錄一筆已確認為硬體實測的電壓。相同百分比
+ /// 的每秒輪詢會被忽略;不同充放電循環再次經過同一百分比時,才會
+ /// 將該次變化加入同一個百分比的彙總資料。
+ ///
+ /// 若這次百分比變化已加入紀錄則回傳 true。
+ public static bool RecordOnPercentChange(int batteryPercent, double voltageV, DateTime? recordedAt = null)
+ {
+ if (batteryPercent is < 0 or > 100 || !double.IsFinite(voltageV) || voltageV <= 0)
+ return false;
+
+ Load();
+ bool isFirstObservation = !_lastObservedPercent.HasValue;
+ if (!HasPercentChanged(_lastObservedPercent, batteryPercent))
+ return false;
+
+ DateTime timestamp = recordedAt ?? DateTime.Now;
+ var point = _points.FirstOrDefault(item => item.BatteryPercent == batteryPercent);
+ _lastObservedPercent = batteryPercent;
+
+ // An existing point already represents this percentage from an
+ // earlier run. The first observation after a restart is not a
+ // percentage change, so do not create a duplicate event. Once the
+ // percentage moves and comes back, the event is recorded.
+ if (isFirstObservation && point != null)
+ return false;
+
+ if (point == null)
+ {
+ point = new BatteryVoltageHistoryPoint { BatteryPercent = batteryPercent };
+ int insertAt = 0;
+ while (insertAt < _points.Count && _points[insertAt].BatteryPercent < batteryPercent)
+ insertAt++;
+ _points.Insert(insertAt, point);
+ }
+
+ point.AddSample(voltageV, timestamp);
+
+ // Percentage changes are infrequent compared with the one-second
+ // monitor loop, so each accepted event is durable immediately.
+ Persist();
+
+ return true;
+ }
+
+ /// 判斷目前百分比是否與上一筆觀察不同。
+ internal static bool HasPercentChanged(int? lastObservedPercent, int currentPercent) =>
+ currentPercent is >= 0 and <= 100 &&
+ (!lastObservedPercent.HasValue || lastObservedPercent.Value != currentPercent);
+
+ /// 清除畫面與磁碟上的所有電壓紀錄。
+ public static void Clear()
+ {
+ Load();
+ _points.Clear();
+ _lastObservedPercent = null;
+ Persist();
+ }
+
+ /// 確保程式結束前把最近的彙總寫入磁碟。
+ public static void Flush()
+ {
+ Load();
+ Persist();
+ }
+
+ private static bool IsValidPoint(BatteryVoltageHistoryPoint point) =>
+ point != null &&
+ point.BatteryPercent is >= 0 and <= 100 &&
+ point.SampleCount > 0 &&
+ double.IsFinite(point.AverageVoltageV) && point.AverageVoltageV > 0 &&
+ double.IsFinite(point.MinimumVoltageV) && point.MinimumVoltageV > 0 &&
+ double.IsFinite(point.MaximumVoltageV) && point.MaximumVoltageV > 0 &&
+ point.MinimumVoltageV <= point.MaximumVoltageV;
+
+ private static void Persist()
+ {
+ try
+ {
+ Directory.CreateDirectory(_historyDirectory);
+ var file = new HistoryFile
+ {
+ Version = CurrentFileVersion,
+ SavedAt = DateTime.Now,
+ Points = _points.ToList()
+ };
+ string json = JsonSerializer.Serialize(file, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+ string tempPath = _historyFilePath + ".tmp";
+ File.WriteAllText(tempPath, json);
+
+ if (File.Exists(_historyFilePath))
+ File.Replace(tempPath, _historyFilePath, null);
+ else
+ File.Move(tempPath, _historyFilePath);
+
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Battery voltage history save failed: {ex.Message}");
+ }
+ }
+
+ private sealed class HistoryFile
+ {
+ public int Version { get; set; }
+ public DateTime SavedAt { get; set; }
+ public List Points { get; set; } = new();
+ }
+ }
+}
diff --git a/Services/DynamicTrayIconService.cs b/Services/DynamicTrayIconService.cs
index 483dd66..52da0dc 100644
--- a/Services/DynamicTrayIconService.cs
+++ b/Services/DynamicTrayIconService.cs
@@ -2,6 +2,7 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Text;
+using System.Globalization;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using WinBatLens.Models;
@@ -39,15 +40,13 @@ public static void UpdateTrayIcon(NotifyIcon notifyIcon, RealTimePowerState stat
if (state.IsCharging && state.IsChargeRateMeasured)
{
// 充電中:綠色顯示充電功率(如 56W)
- int wattVal = (int)Math.Round(state.ChargingRateW);
- textToDraw = wattVal > 99 ? "99+" : wattVal.ToString();
+ textToDraw = FormatWattageForIcon(state.ChargingRateW);
textColor = Color.FromArgb(255, 16, 185, 129); // #10B981 翡翠綠
}
else if (state.IsDischargeRateMeasured)
{
// 放電中:紅色顯示放電功率(如 49W)
- int wattVal = (int)Math.Round(state.DischargeRateW);
- textToDraw = wattVal > 99 ? "99+" : wattVal.ToString();
+ textToDraw = FormatWattageForIcon(state.DischargeRateW);
textColor = Color.FromArgb(255, 244, 63, 94); // #F43F5E 玫瑰紅
}
else
@@ -143,5 +142,20 @@ public static void Dispose()
_lastDrawnText = null;
_lastDrawnColor = default;
}
+
+ ///
+ /// Formats tray-icon wattage. Keep sub-watt measurements visible with
+ /// one decimal place instead of rounding them to a misleading zero.
+ ///
+ internal static string FormatWattageForIcon(double watts)
+ {
+ if (watts < 1.0)
+ {
+ return watts.ToString("F1", CultureInfo.InvariantCulture);
+ }
+
+ int wattVal = (int)Math.Round(watts);
+ return wattVal > 99 ? "99+" : wattVal.ToString(CultureInfo.InvariantCulture);
+ }
}
}
diff --git a/Services/LocalizationService.cs b/Services/LocalizationService.cs
index 3b56f76..c096b9e 100644
--- a/Services/LocalizationService.cs
+++ b/Services/LocalizationService.cs
@@ -25,6 +25,16 @@ public class LocalizationService
private static readonly Dictionary ZhTwStrings = new Dictionary
{
+ ["TabVoltageHistory"] = " 🔋 電池電壓紀錄 ",
+ ["VoltageHistoryTitle"] = "🔋 電池電壓紀錄(0–100%)",
+ ["VoltageHistorySummary"] = "已記錄 {0} / 101 個電量區間,累積 {1} 次電量變化事件",
+ ["VoltageHistoryHint"] = "僅顯示電池驅動回報的實測電壓;同一電量未變化時不重複記錄。",
+ ["BtnClearVoltageHistory"] = "清除電壓紀錄",
+ ["VoltagePercentColumn"] = "電量 (%)",
+ ["VoltageAverageColumn"] = "平均電壓",
+ ["VoltageRangeColumn"] = "實測範圍",
+ ["VoltageEventsColumn"] = "變化次數",
+ ["VoltageLastColumn"] = "最後紀錄",
["AppTitle"] = "WinBat Lens - Windows 電池健康度與即時硬體耗電監測",
["SubTitle"] = "Windows 電池健康診斷與全系統耗電監測",
["AutoStart"] = "🚀 開機自動啟動",
@@ -95,6 +105,16 @@ public class LocalizationService
private static readonly Dictionary EnUsStrings = new Dictionary
{
+ ["TabVoltageHistory"] = " 🔋 Battery Voltage History ",
+ ["VoltageHistoryTitle"] = "🔋 Battery Voltage by Charge Level (0–100%)",
+ ["VoltageHistorySummary"] = "Recorded {0} / 101 charge-level ranges, {1} percentage-change events",
+ ["VoltageHistoryHint"] = "Only battery-driver measurements are shown; the same percentage is not recorded again until it changes.",
+ ["BtnClearVoltageHistory"] = "Clear Voltage History",
+ ["VoltagePercentColumn"] = "Battery (%)",
+ ["VoltageAverageColumn"] = "Average voltage",
+ ["VoltageRangeColumn"] = "Measured range",
+ ["VoltageEventsColumn"] = "Change events",
+ ["VoltageLastColumn"] = "Last recorded",
["AppTitle"] = "WinBat Lens - Windows Battery Health & Real-Time Hardware Power Monitor",
["SubTitle"] = "Windows Battery Health Diagnostics & Full System Power Monitor",
["AutoStart"] = "🚀 Launch on Windows Startup",
diff --git a/WinBatLens.csproj b/WinBatLens.csproj
index 1f0d558..95b0b24 100644
--- a/WinBatLens.csproj
+++ b/WinBatLens.csproj
@@ -13,9 +13,9 @@
WinBat Lens
WinBat Lens
Windows 電池健康度視覺化儀表板與 powercfg /batteryreport 解析工具
- 1.1.5
- 1.1.5.0
- 1.1.5.0
+ 1.1.6
+ 1.1.6.0
+ 1.1.6.0
@@ -65,4 +65,8 @@
+
+
+
+
diff --git a/tests/WinBatLens.Tests/BatteryVoltageHistoryPointTests.cs b/tests/WinBatLens.Tests/BatteryVoltageHistoryPointTests.cs
new file mode 100644
index 0000000..5ffccd5
--- /dev/null
+++ b/tests/WinBatLens.Tests/BatteryVoltageHistoryPointTests.cs
@@ -0,0 +1,46 @@
+using System;
+using WinBatLens.Models;
+using WinBatLens.Services;
+using Xunit;
+
+namespace WinBatLens.Tests;
+
+public sealed class BatteryVoltageHistoryPointTests
+{
+ [Fact]
+ public void AggregatesAverageRangeAndSampleCount()
+ {
+ var point = new BatteryVoltageHistoryPoint { BatteryPercent = 42 };
+ var first = new DateTime(2026, 8, 5, 12, 0, 0);
+ point.AddSample(12.10, first);
+ point.AddSample(12.30, first.AddSeconds(1));
+
+ Assert.Equal(42, point.BatteryPercent);
+ Assert.Equal(12.20, point.AverageVoltageV, 2);
+ Assert.Equal(12.10, point.MinimumVoltageV, 2);
+ Assert.Equal(12.30, point.MaximumVoltageV, 2);
+ Assert.Equal(2, point.SampleCount);
+ Assert.Equal(first.AddSeconds(1), point.LastRecordedAt);
+ Assert.Equal("12.10–12.30 V", point.VoltageRangeText);
+ }
+
+ [Theory]
+ [InlineData(0.0)]
+ [InlineData(-1.0)]
+ public void RejectsInvalidVoltage(double voltage)
+ {
+ var point = new BatteryVoltageHistoryPoint { BatteryPercent = 42 };
+
+ Assert.Throws(() => point.AddSample(voltage, DateTime.Now));
+ }
+
+ [Theory]
+ [InlineData(50, 50, false)]
+ [InlineData(50, 49, true)]
+ [InlineData(50, 51, true)]
+ [InlineData(null, 50, true)]
+ public void RecordsOnlyWhenBatteryPercentChanges(int? previous, int current, bool expected)
+ {
+ Assert.Equal(expected, BatteryVoltageHistoryService.HasPercentChanged(previous, current));
+ }
+}
diff --git a/tests/WinBatLens.Tests/TrayIconPowerFormattingTests.cs b/tests/WinBatLens.Tests/TrayIconPowerFormattingTests.cs
new file mode 100644
index 0000000..de07c6d
--- /dev/null
+++ b/tests/WinBatLens.Tests/TrayIconPowerFormattingTests.cs
@@ -0,0 +1,24 @@
+using WinBatLens.Services;
+using Xunit;
+
+namespace WinBatLens.Tests;
+
+public sealed class TrayIconPowerFormattingTests
+{
+ [Theory]
+ [InlineData(0.1, "0.1")]
+ [InlineData(0.9, "0.9")]
+ public void KeepsSubWattPowerVisible(double watts, string expected)
+ {
+ Assert.Equal(expected, DynamicTrayIconService.FormatWattageForIcon(watts));
+ }
+
+ [Theory]
+ [InlineData(1.4, "1")]
+ [InlineData(49.6, "50")]
+ [InlineData(100.0, "99+")]
+ public void KeepsWholeWattAndOverflowFormatting(double watts, string expected)
+ {
+ Assert.Equal(expected, DynamicTrayIconService.FormatWattageForIcon(watts));
+ }
+}