Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions Sources/Juice/BatteryMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -30,10 +30,43 @@ struct BatteryMonitor {
defer { IOObjectRelease(service) }

var propsRef: Unmanaged<CFMutableDictionary>?
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 }
Expand Down
12 changes: 12 additions & 0 deletions Sources/Juice/LivePowerCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand All @@ -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
}

Expand Down Expand Up @@ -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()
}

Expand Down
1 change: 1 addition & 0 deletions Sources/Juice/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 29 additions & 14 deletions Sources/Juice/TopAppsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -91,6 +93,12 @@ struct TopAppsView: View {
} else {
historyList
}

if let footer = attribution() {
LiveAttributionFooter(
appWatts: footer.appWatts,
systemWatts: footer.systemWatts)
}
}
}

Expand Down Expand Up @@ -165,10 +173,6 @@ struct TopAppsView: View {
}
}
}

if let footer = attribution() {
LiveAttributionFooter(appWatts: footer.appWatts, systemWatts: footer.systemWatts)
}
}
.transition(.opacity)
}
Expand Down Expand Up @@ -237,10 +241,6 @@ struct TopAppsView: View {
}
}
}

if let footer = attribution() {
LiveAttributionFooter(appWatts: footer.appWatts, systemWatts: footer.systemWatts)
}
}
.transition(.opacity)
}
Expand All @@ -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)
}
}

Expand Down
22 changes: 22 additions & 0 deletions Tests/JuiceTests/BatteryMonitorTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
20 changes: 20 additions & 0 deletions Tests/JuiceTests/LivePowerCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -126,6 +127,7 @@ import JuiceCore
let coordinator = LivePowerCoordinator(
source: source,
loadToday: { await gated.load() },
loadSystemLoad: { nil },
now: clock,
todayRefreshInterval: interval)
return (coordinator, source)
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions Tests/JuiceTests/TopAppsViewBehaviorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
Expand Down