diff --git a/Sources/Juice/BatteryMonitor.swift b/Sources/Juice/BatteryMonitor.swift index e5c3565..1e4c17d 100644 --- a/Sources/Juice/BatteryMonitor.swift +++ b/Sources/Juice/BatteryMonitor.swift @@ -21,7 +21,7 @@ enum BatteryMonitorError: Error { /// Reads battery state from the AppleSmartBattery IORegistry service. /// No special permissions required. struct BatteryMonitor { - static func read() throws -> BatteryReading { + private static func properties() throws -> [String: Any] { let service = IOServiceGetMatchingService( kIOMainPortDefault, IOServiceMatching("AppleSmartBattery") @@ -30,10 +30,43 @@ struct BatteryMonitor { defer { IOObjectRelease(service) } var propsRef: Unmanaged? - guard IORegistryEntryCreateCFProperties(service, &propsRef, kCFAllocatorDefault, 0) == KERN_SUCCESS, - let props = propsRef?.takeRetainedValue() as? [String: Any] else { + guard IORegistryEntryCreateCFProperties( + service, + &propsRef, + kCFAllocatorDefault, + 0 + ) == KERN_SUCCESS, + let properties = propsRef?.takeRetainedValue() as? [String: Any] else { throw BatteryMonitorError.propertiesUnreadable } + return properties + } + + /// `PowerTelemetryData.SystemLoad` is reported in milliwatts. It is a + /// whole-system load rather than the battery's signed charge/discharge + /// rate, so it can back live attribution while connected to AC power. + static func systemLoadWatts(from properties: [String: Any]) -> Double? { + guard + let telemetry = properties["PowerTelemetryData"] as? [String: Any], + let milliwatts = telemetry["SystemLoad"] as? NSNumber + else { + return nil + } + + let watts = milliwatts.doubleValue / 1_000 + return watts.isFinite && watts >= 0 ? watts : nil + } + + /// Reads only the current whole-system load for the live attribution loop. + /// Failures leave the optional footer unavailable rather than disturbing + /// the primary battery reading. + static func currentSystemLoadWatts() -> Double? { + guard let properties = try? properties() else { return nil } + return systemLoadWatts(from: properties) + } + + static func read() throws -> BatteryReading { + let props = try properties() func int(_ key: String) -> Int? { props[key] as? Int } func signedInt64(_ key: String) -> Int64? { (props[key] as? NSNumber)?.int64Value } diff --git a/Sources/Juice/LivePowerCoordinator.swift b/Sources/Juice/LivePowerCoordinator.swift index ded7a10..f6b0e9f 100644 --- a/Sources/Juice/LivePowerCoordinator.swift +++ b/Sources/Juice/LivePowerCoordinator.swift @@ -90,6 +90,10 @@ final class LivePowerCoordinator: ObservableObject { /// The latest per-tick live reading (nil until two snapshots establish a /// delta, or while stopped). Drives the attribution footers. @Published private(set) var reading: LivePowerReading? + /// Whole-system load captured with the latest live app reading so the + /// attribution subtraction never mixes the 2-second app cadence with the + /// battery model's slower background refresh. + @Published private(set) var systemLoadWatts: Double? /// The controller's sampling status, driving the "Live" hints and the /// outdated-helper notice. @Published private(set) var status: LivePowerController.Status = .warmingUp @@ -103,6 +107,7 @@ final class LivePowerCoordinator: ObservableObject { private let source: LivePowerSource private let loadToday: () async -> EnergySourceSelector.TopAppsResult + private let loadSystemLoad: () -> Double? private let now: () -> Date private let todayRefreshInterval: Duration private var merger = LiveTodayMerger() @@ -131,11 +136,15 @@ final class LivePowerCoordinator: ObservableObject { result.apps.sort { $0.energyWh > $1.energyWh } return result }, + loadSystemLoad: @escaping () -> Double? = { + BatteryMonitor.currentSystemLoadWatts() + }, now: @escaping () -> Date = { Date() }, todayRefreshInterval: Duration = .seconds(30) ) { self.source = source ?? LivePowerController() self.loadToday = loadToday + self.loadSystemLoad = loadSystemLoad self.now = now self.todayRefreshInterval = todayRefreshInterval } @@ -207,6 +216,7 @@ final class LivePowerCoordinator: ObservableObject { // Re-age grace immediately so a cached active row from a previous // session cannot linger past its window before the first fresh tick. reading = nil + systemLoadWatts = nil recomputeHybrid() source.start() @@ -225,6 +235,7 @@ final class LivePowerCoordinator: ObservableObject { // back to "warming up" on reattach. The merger is deliberately NOT // reset: grace state persists across close/reopen. reading = nil + systemLoadWatts = nil status = source.status } @@ -300,6 +311,7 @@ final class LivePowerCoordinator: ObservableObject { /// the stream observer and the tests share one deterministic path. func apply(reading: LivePowerReading?) { self.reading = reading + systemLoadWatts = reading == nil ? nil : loadSystemLoad() recomputeHybrid() } diff --git a/Sources/Juice/PopoverView.swift b/Sources/Juice/PopoverView.swift index fab257c..0a1d385 100644 --- a/Sources/Juice/PopoverView.swift +++ b/Sources/Juice/PopoverView.swift @@ -145,6 +145,7 @@ struct PopoverView: View { ranges: visibleRanges, hybrid: showsLivePower ? live.hybrid : nil, batteryWatts: model.reading.map { abs($0.watts) }, + systemLoadWatts: live.systemLoadWatts, onAC: model.reading?.onAC ?? false, totalAppWatts: showsLivePower ? live.reading?.totalAppWatts : nil, session: batterySession.result?.session) diff --git a/Sources/Juice/TopAppsView.swift b/Sources/Juice/TopAppsView.swift index e970c29..ae80d71 100644 --- a/Sources/Juice/TopAppsView.swift +++ b/Sources/Juice/TopAppsView.swift @@ -17,7 +17,9 @@ struct TopAppsView: View { var hybrid: HybridTodayList? /// Battery draw in watts for the live attribution footer. var batteryWatts: Double? - /// The footer is omitted on AC, where battery watts mean charging rate. + /// Whole-system consumption in watts, available even while charging. + var systemLoadWatts: Double? + /// Selects the appropriate total-power source for the attribution footer. var onAC: Bool = false /// Total smoothed app watts for the live attribution footer. var totalAppWatts: Double? @@ -91,6 +93,12 @@ struct TopAppsView: View { } else { historyList } + + if let footer = attribution() { + LiveAttributionFooter( + appWatts: footer.appWatts, + systemWatts: footer.systemWatts) + } } } @@ -165,10 +173,6 @@ struct TopAppsView: View { } } } - - if let footer = attribution() { - LiveAttributionFooter(appWatts: footer.appWatts, systemWatts: footer.systemWatts) - } } .transition(.opacity) } @@ -237,10 +241,6 @@ struct TopAppsView: View { } } } - - if let footer = attribution() { - LiveAttributionFooter(appWatts: footer.appWatts, systemWatts: footer.systemWatts) - } } .transition(.opacity) } @@ -254,12 +254,27 @@ struct TopAppsView: View { session: range == .session ? session : nil) } - /// Apps versus system-and-display split for the footer, or nil when the - /// battery watts are unavailable or we are on AC (where watts mean charge). + /// Apps versus system-and-display split for the footer. Battery draw is the + /// total while unplugged; the power controller's system load is the total + /// on AC, where battery watts instead describe charging. private func attribution() -> (appWatts: Double, systemWatts: Double)? { - guard !onAC, let batteryWatts, batteryWatts > 0, let totalAppWatts else { return nil } - let systemWatts = max(0, batteryWatts - totalAppWatts) - return (totalAppWatts, systemWatts) + Self.attribution( + appWatts: totalAppWatts, + batteryWatts: batteryWatts, + systemLoadWatts: systemLoadWatts, + onAC: onAC) + } + + static func attribution( + appWatts: Double?, + batteryWatts: Double?, + systemLoadWatts: Double?, + onAC: Bool + ) -> (appWatts: Double, systemWatts: Double)? { + let totalWatts = onAC ? systemLoadWatts : batteryWatts + guard let totalWatts, totalWatts > 0, let appWatts else { return nil } + let systemWatts = max(0, totalWatts - appWatts) + return (appWatts, systemWatts) } } diff --git a/Tests/JuiceTests/BatteryMonitorTests.swift b/Tests/JuiceTests/BatteryMonitorTests.swift new file mode 100644 index 0000000..0279006 --- /dev/null +++ b/Tests/JuiceTests/BatteryMonitorTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import Juice + +@Suite("Battery monitor") +struct BatteryMonitorTests { + @Test("reads whole-system load from power telemetry") + func readsSystemLoad() { + let properties: [String: Any] = [ + "PowerTelemetryData": [ + "SystemLoad": NSNumber(value: 24_055) + ] + ] + + #expect(BatteryMonitor.systemLoadWatts(from: properties) == 24.055) + } + + @Test("leaves system load unavailable when telemetry is absent") + func missingSystemLoad() { + #expect(BatteryMonitor.systemLoadWatts(from: [:]) == nil) + } +} diff --git a/Tests/JuiceTests/LivePowerCoordinatorTests.swift b/Tests/JuiceTests/LivePowerCoordinatorTests.swift index 5a7de0b..abf9e1c 100644 --- a/Tests/JuiceTests/LivePowerCoordinatorTests.swift +++ b/Tests/JuiceTests/LivePowerCoordinatorTests.swift @@ -112,6 +112,7 @@ import JuiceCore let coordinator = LivePowerCoordinator( source: source, loadToday: { await loader.load() }, + loadSystemLoad: { nil }, now: clock, todayRefreshInterval: .seconds(3600)) return (coordinator, source) @@ -126,6 +127,7 @@ import JuiceCore let coordinator = LivePowerCoordinator( source: source, loadToday: { await gated.load() }, + loadSystemLoad: { nil }, now: clock, todayRefreshInterval: interval) return (coordinator, source) @@ -147,6 +149,24 @@ import JuiceCore for _ in 0..<5 { await Task.yield() } } + @Test("System load is captured with each live app reading") + func systemLoadTracksLiveReadingCadence() { + var loads = [24.0, 31.0] + let coordinator = LivePowerCoordinator( + source: FakeSource(), + loadToday: { self.todayResult([]) }, + loadSystemLoad: { loads.removeFirst() }) + + coordinator.apply(reading: reading([liveApp("editor", watts: 4)])) + #expect(coordinator.systemLoadWatts == 24) + + coordinator.apply(reading: reading([liveApp("editor", watts: 7)])) + #expect(coordinator.systemLoadWatts == 31) + + coordinator.apply(reading: nil) + #expect(coordinator.systemLoadWatts == nil) + } + @Test("Reference counting: the loop starts once and stops only on the last detach") func referenceCountedStartStop() { let now = t0 diff --git a/Tests/JuiceTests/TopAppsViewBehaviorTests.swift b/Tests/JuiceTests/TopAppsViewBehaviorTests.swift index 31e6dde..065ba35 100644 --- a/Tests/JuiceTests/TopAppsViewBehaviorTests.swift +++ b/Tests/JuiceTests/TopAppsViewBehaviorTests.swift @@ -38,6 +38,30 @@ struct TopAppsViewBehaviorTests { #expect(plan.folded == 19) } + @Test("live attribution uses whole-system load while charging") + func chargingAttributionUsesSystemLoad() { + let attribution = TopAppsView.attribution( + appWatts: 5, + batteryWatts: 14, + systemLoadWatts: 24, + onAC: true) + + #expect(attribution?.appWatts == 5) + #expect(attribution?.systemWatts == 19) + } + + @Test("live attribution continues using battery draw while unplugged") + func batteryAttributionUsesBatteryDraw() { + let attribution = TopAppsView.attribution( + appWatts: 5, + batteryWatts: 20, + systemLoadWatts: 100, + onAC: false) + + #expect(attribution?.appWatts == 5) + #expect(attribution?.systemWatts == 15) + } + @MainActor @Test("range picker renders only the configured tabs") func rangePickerUsesConfiguredTabs() { @@ -49,6 +73,7 @@ struct TopAppsViewBehaviorTests { ranges: [.session, .today, .week, .allTime], hybrid: nil, batteryWatts: nil, + systemLoadWatts: nil, totalAppWatts: nil, session: nil) .frame(width: 320, height: 100)