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:
hariel1985
2026-07-02 20:12:20 +02:00
szülő 1390eda468
commit 9b92fb715a
7 fájl változott, egészen pontosan 367 új sor hozzáadva és 4 régi sor törölve

Fájl megtekintése

@@ -15,6 +15,7 @@
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; }; 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; };
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */; }; 266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */; };
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332290DABD5326C87258723C /* ProcessDataTests.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 */; }; 341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; };
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; }; 35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; };
4427B8406B25B8FEDF27CDBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 334573CEB47D60570A40B55D /* Assets.xcassets */; }; 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 */; }; 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */; };
A0649565A902BA48DB10F592 /* CPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */; }; A0649565A902BA48DB10F592 /* CPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */; };
A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.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 */; }; B62B18DF32A8734D5908B16B /* DiskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */; };
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; }; BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; };
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; }; C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; };
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; }; CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; };
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.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 */; }; DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279B972C08A878053821285D /* SystemMonitor.swift */; };
DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */; }; DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */; };
E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE3BB62AE0DC43777AD00658 /* GPUMonitor.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; A3A3D0B1EB522C9975F71DA8 /* TopManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TopManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -104,6 +110,7 @@
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */, 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */,
05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */, 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */,
347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */, 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */,
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */,
295232B4C746511757DCF8FE /* ProcessMonitor.swift */, 295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
279B972C08A878053821285D /* SystemMonitor.swift */, 279B972C08A878053821285D /* SystemMonitor.swift */,
); );
@@ -127,6 +134,7 @@
F481045B4FC6017F50849FF1 /* GPUInfo.swift */, F481045B4FC6017F50849FF1 /* GPUInfo.swift */,
BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */, BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */,
3010F605712E09175D9D8878 /* NetworkInfo.swift */, 3010F605712E09175D9D8878 /* NetworkInfo.swift */,
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */, 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
); );
path = Models; path = Models;
@@ -210,6 +218,7 @@
D078BA97D4F498E5282AC5E0 /* TopManagerTests */ = { D078BA97D4F498E5282AC5E0 /* TopManagerTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */,
301352EB5B362C732CE1A246 /* FormattingTests.swift */, 301352EB5B362C732CE1A246 /* FormattingTests.swift */,
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
332290DABD5326C87258723C /* ProcessDataTests.swift */, 332290DABD5326C87258723C /* ProcessDataTests.swift */,
@@ -330,6 +339,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
2DE14B65EA1D4E9B4D9DA5A6 /* BatteryMathTests.swift in Sources */,
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */, 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */,
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
@@ -361,6 +371,8 @@
E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */, E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */,
EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */, EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */,
00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */, 00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */,
B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */,
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */,
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */, 501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */,
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */, C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */,
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */, 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */,

Fájl megtekintése

@@ -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"
}
}

Fájl megtekintése

@@ -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
}
}

Fájl megtekintése

@@ -13,6 +13,7 @@ final class SystemMonitor: ObservableObject {
@MainActor @Published var diskInfo: DiskInfo? @MainActor @Published var diskInfo: DiskInfo?
@MainActor @Published var networkInfo: NetworkInfo? @MainActor @Published var networkInfo: NetworkInfo?
@MainActor @Published var gpuInfo: GPUInfo? @MainActor @Published var gpuInfo: GPUInfo?
@MainActor @Published var powerInfo: PowerInfo?
@MainActor @Published var lastError: String? @MainActor @Published var lastError: String?
// History for charts // History for charts
@@ -28,6 +29,7 @@ final class SystemMonitor: ObservableObject {
private let diskMonitor = DiskMonitor() private let diskMonitor = DiskMonitor()
private let networkMonitor = NetworkMonitor() private let networkMonitor = NetworkMonitor()
private let gpuMonitor = GPUMonitor() private let gpuMonitor = GPUMonitor()
private let powerMonitor = PowerMonitor()
// Timer // Timer
private var timer: Timer? private var timer: Timer?
@@ -63,10 +65,11 @@ final class SystemMonitor: ObservableObject {
let processData = self.processMonitor.fetchProcesses() let processData = self.processMonitor.fetchProcesses()
let diskData = self.diskMonitor.fetchDiskInfo() let diskData = self.diskMonitor.fetchDiskInfo()
let gpuData = self.gpuMonitor.fetchGPUInfo() let gpuData = self.gpuMonitor.fetchGPUInfo()
let powerData = self.powerMonitor.fetchPowerInfo()
DispatchQueue.main.async { DispatchQueue.main.async {
self.updateUI(cpu: cpuData, memory: memData, network: netData, 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() processData = processMonitor.fetchProcesses()
} }
// Fetch disk and GPU every 3 cycles // Fetch disk, GPU and power every 3 cycles
var diskData: DiskInfo? = nil var diskData: DiskInfo? = nil
var gpuData: GPUInfo? = nil var gpuData: GPUInfo? = nil
var powerData: PowerInfo? = nil
if currentCount % 3 == 0 { if currentCount % 3 == 0 {
diskData = diskMonitor.fetchDiskInfo() diskData = diskMonitor.fetchDiskInfo()
gpuData = gpuMonitor.fetchGPUInfo() gpuData = gpuMonitor.fetchGPUInfo()
powerData = powerMonitor.fetchPowerInfo()
} }
// Update UI on main thread // Update UI on main thread
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.updateUI(cpu: cpuData, memory: memData, network: netData, 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?, @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 { if let info = cpu {
cpuInfo = info cpuInfo = info
let historyPoint = CPUHistoryPoint( let historyPoint = CPUHistoryPoint(
@@ -179,6 +185,10 @@ final class SystemMonitor: ObservableObject {
gpuInfo = info gpuInfo = info
} }
if let info = power {
powerInfo = info
}
// Persist a system-wide sample so history survives restarts and long ranges. // Persist a system-wide sample so history survives restarts and long ranges.
if let c = cpuInfo, let m = memoryInfo { if let c = cpuInfo, let m = memoryInfo {
MetricsStore.shared.record(MetricsSample( MetricsStore.shared.record(MetricsSample(

Fájl megtekintése

@@ -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() Divider()
Button("Open TopManager") { Button("Open TopManager") {

Fájl megtekintése

@@ -141,6 +141,11 @@ struct PowerStorageView: View {
} }
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
// Battery & Power
if let power = monitor.powerInfo {
BatteryStatusView(power: power)
}
// Storage // Storage
GroupBox { GroupBox {
VStack(alignment: .leading, spacing: 12) { 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 { struct ThermalStateView: View {
let state: ProcessInfo.ThermalState let state: ProcessInfo.ThermalState

Fájl megtekintése

@@ -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")
}
}