Increment 3: battery health + power monitoring (IOKit)
- PowerInfo model + BatteryMath (health %, condition, watts, time fmt) — unit-tested - PowerMonitor reads AppleSmartBattery via IORegistry (no root/entitlements): charge, cycles, design/raw-max capacity, temperature, voltage, amperage, time-to-empty/full, adapter watts; graceful no-battery (desktop) path - Wire powerInfo into SystemMonitor (every 3rd cycle) - Battery & Power section in Power & Storage tab; battery in menu-bar dropdown - 10 new tests (42 total); cross-checked vs pmset/ioreg: health 96.9%, 114 cycles, 30.8C, matches ground truth exactly Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; };
|
||||
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */; };
|
||||
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332290DABD5326C87258723C /* ProcessDataTests.swift */; };
|
||||
2DE14B65EA1D4E9B4D9DA5A6 /* BatteryMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */; };
|
||||
341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; };
|
||||
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; };
|
||||
4427B8406B25B8FEDF27CDBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 334573CEB47D60570A40B55D /* Assets.xcassets */; };
|
||||
@@ -28,11 +29,13 @@
|
||||
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */; };
|
||||
A0649565A902BA48DB10F592 /* CPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */; };
|
||||
A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; };
|
||||
B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */; };
|
||||
B62B18DF32A8734D5908B16B /* DiskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */; };
|
||||
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; };
|
||||
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; };
|
||||
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; };
|
||||
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; };
|
||||
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; };
|
||||
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279B972C08A878053821285D /* SystemMonitor.swift */; };
|
||||
DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */; };
|
||||
E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */; };
|
||||
@@ -72,7 +75,9 @@
|
||||
352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskInfo.swift; sourceTree = "<group>"; };
|
||||
44281079654C7E1C9E16806C /* CPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUInfo.swift; sourceTree = "<group>"; };
|
||||
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfo.swift; sourceTree = "<group>"; };
|
||||
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerInfo.swift; sourceTree = "<group>"; };
|
||||
5E1D2EA8E1AD11B42FF225EB /* TopManager.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TopManager.entitlements; sourceTree = "<group>"; };
|
||||
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = "<group>"; };
|
||||
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = "<group>"; };
|
||||
78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = "<group>"; };
|
||||
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = "<group>"; };
|
||||
@@ -81,6 +86,7 @@
|
||||
8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDonutView.swift; sourceTree = "<group>"; };
|
||||
94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = "<group>"; };
|
||||
95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskUsageView.swift; sourceTree = "<group>"; };
|
||||
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMonitor.swift; sourceTree = "<group>"; };
|
||||
9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendChartView.swift; sourceTree = "<group>"; };
|
||||
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
|
||||
A3A3D0B1EB522C9975F71DA8 /* TopManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TopManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -104,6 +110,7 @@
|
||||
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */,
|
||||
05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */,
|
||||
347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */,
|
||||
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */,
|
||||
295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
|
||||
279B972C08A878053821285D /* SystemMonitor.swift */,
|
||||
);
|
||||
@@ -127,6 +134,7 @@
|
||||
F481045B4FC6017F50849FF1 /* GPUInfo.swift */,
|
||||
BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */,
|
||||
3010F605712E09175D9D8878 /* NetworkInfo.swift */,
|
||||
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
|
||||
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
|
||||
);
|
||||
path = Models;
|
||||
@@ -210,6 +218,7 @@
|
||||
D078BA97D4F498E5282AC5E0 /* TopManagerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */,
|
||||
301352EB5B362C732CE1A246 /* FormattingTests.swift */,
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
|
||||
332290DABD5326C87258723C /* ProcessDataTests.swift */,
|
||||
@@ -330,6 +339,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2DE14B65EA1D4E9B4D9DA5A6 /* BatteryMathTests.swift in Sources */,
|
||||
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */,
|
||||
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
|
||||
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
|
||||
@@ -361,6 +371,8 @@
|
||||
E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */,
|
||||
EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */,
|
||||
00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */,
|
||||
B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */,
|
||||
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */,
|
||||
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */,
|
||||
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */,
|
||||
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */,
|
||||
|
||||
83
TopManager/Models/PowerInfo.swift
Normal file
83
TopManager/Models/PowerInfo.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
/// Battery / power state. Mirrors what Activity Monitor's Energy tab and the
|
||||
/// macOS battery menu surface, which TopManager currently lacks entirely.
|
||||
struct PowerInfo {
|
||||
let hasBattery: Bool
|
||||
let currentCharge: Int // 0–100 %
|
||||
let isCharging: Bool
|
||||
let isPluggedIn: Bool // external power connected
|
||||
let fullyCharged: Bool
|
||||
let cycleCount: Int?
|
||||
let designCapacity: Int? // mAh
|
||||
let maxCapacity: Int? // mAh (current full-charge capacity)
|
||||
let temperature: Double? // °C
|
||||
let voltage: Double? // volts
|
||||
let amperage: Int? // mA (negative while discharging)
|
||||
let timeToEmpty: Int? // minutes (nil if unknown/charging)
|
||||
let timeToFull: Int? // minutes (nil if unknown/discharging)
|
||||
let adapterWatts: Int? // power adapter rating
|
||||
|
||||
/// Battery health as current-full-charge ÷ design capacity.
|
||||
var healthPercent: Double? {
|
||||
guard let maxCapacity, let designCapacity, designCapacity > 0 else { return nil }
|
||||
return BatteryMath.healthPercent(maxCapacity: maxCapacity, designCapacity: designCapacity)
|
||||
}
|
||||
|
||||
var condition: BatteryCondition {
|
||||
BatteryMath.condition(healthPercent: healthPercent, cycleCount: cycleCount)
|
||||
}
|
||||
|
||||
/// Instantaneous power flow in watts (positive = charging, negative = draining).
|
||||
var powerWatts: Double? {
|
||||
guard let voltage, let amperage else { return nil }
|
||||
return BatteryMath.watts(volts: voltage, milliAmps: amperage)
|
||||
}
|
||||
|
||||
var powerSourceLabel: String {
|
||||
isPluggedIn ? "AC Power" : "Battery Power"
|
||||
}
|
||||
}
|
||||
|
||||
enum BatteryCondition: String {
|
||||
case normal = "Normal"
|
||||
case serviceRecommended = "Service Recommended"
|
||||
case unknown = "Unknown"
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .normal: return "green"
|
||||
case .serviceRecommended: return "orange"
|
||||
case .unknown: return "gray"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure, testable battery math (no IOKit, no clock).
|
||||
enum BatteryMath {
|
||||
static func healthPercent(maxCapacity: Int, designCapacity: Int) -> Double {
|
||||
guard designCapacity > 0 else { return 0 }
|
||||
return Double(maxCapacity) / Double(designCapacity) * 100
|
||||
}
|
||||
|
||||
/// Apple flags "Service Recommended" as health degrades or cycles climb.
|
||||
/// This approximates that classification from the raw numbers.
|
||||
static func condition(healthPercent: Double?, cycleCount: Int?) -> BatteryCondition {
|
||||
guard let healthPercent else { return .unknown }
|
||||
if healthPercent < 80 { return .serviceRecommended }
|
||||
if let cycleCount, cycleCount > 1000 { return .serviceRecommended }
|
||||
return .normal
|
||||
}
|
||||
|
||||
static func watts(volts: Double, milliAmps: Int) -> Double {
|
||||
volts * Double(milliAmps) / 1000.0
|
||||
}
|
||||
|
||||
/// Format a minutes duration as "2h 15m" / "45m"; nil-safe caller.
|
||||
static func formatMinutes(_ minutes: Int) -> String {
|
||||
guard minutes > 0 else { return "—" }
|
||||
let h = minutes / 60
|
||||
let m = minutes % 60
|
||||
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
|
||||
}
|
||||
}
|
||||
77
TopManager/Services/PowerMonitor.swift
Normal file
77
TopManager/Services/PowerMonitor.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
|
||||
/// Reads battery / power state from the AppleSmartBattery IORegistry entry.
|
||||
/// No entitlements or root required. On a desktop Mac (no battery) it reports
|
||||
/// `hasBattery == false` and AC power.
|
||||
final class PowerMonitor {
|
||||
|
||||
func fetchPowerInfo() -> PowerInfo {
|
||||
let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"))
|
||||
guard service != 0 else {
|
||||
// No battery (e.g. Mac mini / Studio / Pro): assume AC power.
|
||||
return PowerInfo(
|
||||
hasBattery: false, currentCharge: 100, isCharging: false, isPluggedIn: true,
|
||||
fullyCharged: true, cycleCount: nil, designCapacity: nil, maxCapacity: nil,
|
||||
temperature: nil, voltage: nil, amperage: nil, timeToEmpty: nil,
|
||||
timeToFull: nil, adapterWatts: nil
|
||||
)
|
||||
}
|
||||
defer { IOObjectRelease(service) }
|
||||
|
||||
var unmanagedProps: Unmanaged<CFMutableDictionary>?
|
||||
guard IORegistryEntryCreateCFProperties(service, &unmanagedProps, kCFAllocatorDefault, 0) == KERN_SUCCESS,
|
||||
let props = unmanagedProps?.takeRetainedValue() as? [String: Any] else {
|
||||
return PowerInfo(
|
||||
hasBattery: false, currentCharge: 100, isCharging: false, isPluggedIn: true,
|
||||
fullyCharged: true, cycleCount: nil, designCapacity: nil, maxCapacity: nil,
|
||||
temperature: nil, voltage: nil, amperage: nil, timeToEmpty: nil,
|
||||
timeToFull: nil, adapterWatts: nil
|
||||
)
|
||||
}
|
||||
|
||||
let int = { (key: String) -> Int? in props[key] as? Int }
|
||||
let bool = { (key: String) -> Bool? in props[key] as? Bool }
|
||||
|
||||
// Percent charge: CurrentCapacity is 0–100 on Apple Silicon.
|
||||
let currentCharge = int("CurrentCapacity") ?? 0
|
||||
let isCharging = bool("IsCharging") ?? false
|
||||
let isPluggedIn = bool("ExternalConnected") ?? false
|
||||
let fullyCharged = bool("FullyCharged") ?? false
|
||||
let cycleCount = int("CycleCount")
|
||||
let designCapacity = int("DesignCapacity")
|
||||
// Raw (mAh) capacity is the meaningful "full charge capacity" on AS.
|
||||
let maxCapacity = int("AppleRawMaxCapacity") ?? int("MaxCapacity")
|
||||
let temperature = int("Temperature").map { Double($0) / 100.0 } // centi-°C
|
||||
let voltage = int("Voltage").map { Double($0) / 1000.0 } // mV → V
|
||||
let amperage = int("Amperage")
|
||||
|
||||
let timeToEmpty = normalizedMinutes(int("AvgTimeToEmpty"), valid: !isPluggedIn)
|
||||
let timeToFull = normalizedMinutes(int("AvgTimeToFull"), valid: isCharging)
|
||||
|
||||
let adapterWatts = (props["AdapterDetails"] as? [String: Any])?["Watts"] as? Int
|
||||
|
||||
return PowerInfo(
|
||||
hasBattery: true,
|
||||
currentCharge: currentCharge,
|
||||
isCharging: isCharging,
|
||||
isPluggedIn: isPluggedIn,
|
||||
fullyCharged: fullyCharged,
|
||||
cycleCount: cycleCount,
|
||||
designCapacity: designCapacity,
|
||||
maxCapacity: maxCapacity,
|
||||
temperature: temperature,
|
||||
voltage: voltage,
|
||||
amperage: amperage,
|
||||
timeToEmpty: timeToEmpty,
|
||||
timeToFull: timeToFull,
|
||||
adapterWatts: adapterWatts
|
||||
)
|
||||
}
|
||||
|
||||
/// AppleSmartBattery reports 65535 (or 0) for "still calculating"; treat as unknown.
|
||||
private func normalizedMinutes(_ value: Int?, valid: Bool) -> Int? {
|
||||
guard valid, let value, value > 0, value != 65535 else { return nil }
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ final class SystemMonitor: ObservableObject {
|
||||
@MainActor @Published var diskInfo: DiskInfo?
|
||||
@MainActor @Published var networkInfo: NetworkInfo?
|
||||
@MainActor @Published var gpuInfo: GPUInfo?
|
||||
@MainActor @Published var powerInfo: PowerInfo?
|
||||
@MainActor @Published var lastError: String?
|
||||
|
||||
// History for charts
|
||||
@@ -28,6 +29,7 @@ final class SystemMonitor: ObservableObject {
|
||||
private let diskMonitor = DiskMonitor()
|
||||
private let networkMonitor = NetworkMonitor()
|
||||
private let gpuMonitor = GPUMonitor()
|
||||
private let powerMonitor = PowerMonitor()
|
||||
|
||||
// Timer
|
||||
private var timer: Timer?
|
||||
@@ -63,10 +65,11 @@ final class SystemMonitor: ObservableObject {
|
||||
let processData = self.processMonitor.fetchProcesses()
|
||||
let diskData = self.diskMonitor.fetchDiskInfo()
|
||||
let gpuData = self.gpuMonitor.fetchGPUInfo()
|
||||
let powerData = self.powerMonitor.fetchPowerInfo()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.updateUI(cpu: cpuData, memory: memData, network: netData,
|
||||
processes: processData, disk: diskData, gpu: gpuData)
|
||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,23 +102,26 @@ final class SystemMonitor: ObservableObject {
|
||||
processData = processMonitor.fetchProcesses()
|
||||
}
|
||||
|
||||
// Fetch disk and GPU every 3 cycles
|
||||
// Fetch disk, GPU and power every 3 cycles
|
||||
var diskData: DiskInfo? = nil
|
||||
var gpuData: GPUInfo? = nil
|
||||
var powerData: PowerInfo? = nil
|
||||
if currentCount % 3 == 0 {
|
||||
diskData = diskMonitor.fetchDiskInfo()
|
||||
gpuData = gpuMonitor.fetchGPUInfo()
|
||||
powerData = powerMonitor.fetchPowerInfo()
|
||||
}
|
||||
|
||||
// Update UI on main thread
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updateUI(cpu: cpuData, memory: memData, network: netData,
|
||||
processes: processData, disk: diskData, gpu: gpuData)
|
||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?,
|
||||
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?) {
|
||||
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?,
|
||||
power: PowerInfo?) {
|
||||
if let info = cpu {
|
||||
cpuInfo = info
|
||||
let historyPoint = CPUHistoryPoint(
|
||||
@@ -179,6 +185,10 @@ final class SystemMonitor: ObservableObject {
|
||||
gpuInfo = info
|
||||
}
|
||||
|
||||
if let info = power {
|
||||
powerInfo = info
|
||||
}
|
||||
|
||||
// Persist a system-wide sample so history survives restarts and long ranges.
|
||||
if let c = cpuInfo, let m = memoryInfo {
|
||||
MetricsStore.shared.record(MetricsSample(
|
||||
|
||||
@@ -58,6 +58,21 @@ struct MenuBarView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let power = monitor.powerInfo, power.hasBattery {
|
||||
HStack {
|
||||
Image(systemName: power.isCharging ? "battery.100.bolt" : "battery.100")
|
||||
.frame(width: 16)
|
||||
Text("Battery:")
|
||||
Spacer()
|
||||
Text("\(power.currentCharge)%")
|
||||
.monospacedDigit()
|
||||
if let toEmpty = power.timeToEmpty {
|
||||
Text("(\(BatteryMath.formatMinutes(toEmpty)))")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Open TopManager") {
|
||||
|
||||
@@ -141,6 +141,11 @@ struct PowerStorageView: View {
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Battery & Power
|
||||
if let power = monitor.powerInfo {
|
||||
BatteryStatusView(power: power)
|
||||
}
|
||||
|
||||
// Storage
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -184,6 +189,109 @@ struct PowerStorageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct BatteryStatusView: View {
|
||||
let power: PowerInfo
|
||||
|
||||
var body: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Label("Battery & Power", systemImage: batteryIcon)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: power.isPluggedIn ? "powerplug.fill" : "battery.100")
|
||||
.foregroundColor(.secondary)
|
||||
Text(power.powerSourceLabel)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if power.hasBattery {
|
||||
HStack {
|
||||
Text("Charge:")
|
||||
Spacer()
|
||||
if power.isCharging {
|
||||
Image(systemName: "bolt.fill").foregroundColor(.yellow)
|
||||
}
|
||||
Text("\(power.currentCharge)%").monospacedDigit()
|
||||
}
|
||||
ProgressView(value: Double(power.currentCharge), total: 100)
|
||||
.tint(chargeColor)
|
||||
|
||||
row("Condition") {
|
||||
Text(power.condition.rawValue)
|
||||
.foregroundColor(conditionColor)
|
||||
}
|
||||
|
||||
if let health = power.healthPercent {
|
||||
row("Health (max/design)") {
|
||||
Text(String(format: "%.0f%%", health)).monospacedDigit()
|
||||
}
|
||||
}
|
||||
if let cycles = power.cycleCount {
|
||||
row("Cycle Count") { Text("\(cycles)").monospacedDigit() }
|
||||
}
|
||||
if let temp = power.temperature {
|
||||
row("Temperature") { Text(String(format: "%.1f °C", temp)).monospacedDigit() }
|
||||
}
|
||||
if let watts = power.powerWatts {
|
||||
row(power.isCharging ? "Charge Power" : "Power Draw") {
|
||||
Text(String(format: "%.1f W", abs(watts))).monospacedDigit()
|
||||
}
|
||||
}
|
||||
if let toEmpty = power.timeToEmpty {
|
||||
row("Time Remaining") { Text(BatteryMath.formatMinutes(toEmpty)).monospacedDigit() }
|
||||
}
|
||||
if let toFull = power.timeToFull {
|
||||
row("Time to Full") { Text(BatteryMath.formatMinutes(toFull)).monospacedDigit() }
|
||||
}
|
||||
if let adapter = power.adapterWatts, adapter > 0 {
|
||||
row("Power Adapter") { Text("\(adapter) W").monospacedDigit() }
|
||||
}
|
||||
} else {
|
||||
Text("No battery detected — running on AC power.")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func row<Content: View>(_ label: String, @ViewBuilder value: () -> Content) -> some View {
|
||||
HStack {
|
||||
Text(label + ":")
|
||||
Spacer()
|
||||
value()
|
||||
}
|
||||
}
|
||||
|
||||
private var batteryIcon: String {
|
||||
if !power.hasBattery { return "powerplug" }
|
||||
if power.isCharging { return "battery.100.bolt" }
|
||||
switch power.currentCharge {
|
||||
case ..<15: return "battery.25"
|
||||
case ..<50: return "battery.50"
|
||||
case ..<85: return "battery.75"
|
||||
default: return "battery.100"
|
||||
}
|
||||
}
|
||||
|
||||
private var chargeColor: Color {
|
||||
if power.currentCharge < 15 { return .red }
|
||||
if power.currentCharge < 30 { return .orange }
|
||||
return .green
|
||||
}
|
||||
|
||||
private var conditionColor: Color {
|
||||
switch power.condition {
|
||||
case .normal: return .green
|
||||
case .serviceRecommended: return .orange
|
||||
case .unknown: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThermalStateView: View {
|
||||
let state: ProcessInfo.ThermalState
|
||||
|
||||
|
||||
58
TopManagerTests/BatteryMathTests.swift
Normal file
58
TopManagerTests/BatteryMathTests.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class BatteryMathTests: XCTestCase {
|
||||
|
||||
func testHealthPercent() {
|
||||
// 4000 mAh current full-charge vs 5000 mAh design = 80%
|
||||
XCTAssertEqual(BatteryMath.healthPercent(maxCapacity: 4000, designCapacity: 5000), 80, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testHealthPercentZeroDesignIsSafe() {
|
||||
XCTAssertEqual(BatteryMath.healthPercent(maxCapacity: 100, designCapacity: 0), 0)
|
||||
}
|
||||
|
||||
func testConditionNormalWhenHealthy() {
|
||||
XCTAssertEqual(BatteryMath.condition(healthPercent: 92, cycleCount: 120), .normal)
|
||||
}
|
||||
|
||||
func testConditionServiceWhenHealthLow() {
|
||||
XCTAssertEqual(BatteryMath.condition(healthPercent: 74, cycleCount: 120), .serviceRecommended)
|
||||
}
|
||||
|
||||
func testConditionServiceWhenCyclesHigh() {
|
||||
XCTAssertEqual(BatteryMath.condition(healthPercent: 95, cycleCount: 1200), .serviceRecommended)
|
||||
}
|
||||
|
||||
func testConditionUnknownWhenNoHealth() {
|
||||
XCTAssertEqual(BatteryMath.condition(healthPercent: nil, cycleCount: 100), .unknown)
|
||||
}
|
||||
|
||||
func testWattsPositiveWhenCharging() {
|
||||
// 12 V * 2000 mA = 24 W
|
||||
XCTAssertEqual(BatteryMath.watts(volts: 12, milliAmps: 2000), 24, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testWattsNegativeWhenDischarging() {
|
||||
XCTAssertEqual(BatteryMath.watts(volts: 12, milliAmps: -1500), -18, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testFormatMinutes() {
|
||||
XCTAssertEqual(BatteryMath.formatMinutes(135), "2h 15m")
|
||||
XCTAssertEqual(BatteryMath.formatMinutes(45), "45m")
|
||||
XCTAssertEqual(BatteryMath.formatMinutes(0), "—")
|
||||
}
|
||||
|
||||
func testPowerInfoDerivedValues() {
|
||||
let p = PowerInfo(
|
||||
hasBattery: true, currentCharge: 66, isCharging: false, isPluggedIn: false,
|
||||
fullyCharged: false, cycleCount: 150, designCapacity: 5000, maxCapacity: 4600,
|
||||
temperature: 30.5, voltage: 12.0, amperage: -1500, timeToEmpty: 200,
|
||||
timeToFull: nil, adapterWatts: nil
|
||||
)
|
||||
XCTAssertEqual(p.healthPercent ?? 0, 92, accuracy: 0.001)
|
||||
XCTAssertEqual(p.condition, .normal)
|
||||
XCTAssertEqual(p.powerWatts ?? 0, -18, accuracy: 0.001)
|
||||
XCTAssertEqual(p.powerSourceLabel, "Battery Power")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user