Skip to content
Open
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
14 changes: 14 additions & 0 deletions Helper/Helper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,18 @@ final class Helper: NSObject, HelperProtocol {
reply(false, false, false, false)
}
}

func getOutputTelemetrySMCKeyAvailability(
reply: @escaping @Sendable ([String]) -> Void
) {
do {
let available = try SMCPowerTelemetry.availableCandidateKeys()
reply(available)
} catch {
logger.error(
"Failed to probe output telemetry SMC key availability: \(error.localizedDescription)"
)
reply([])
}
}
}
2 changes: 2 additions & 0 deletions Helper/HelperProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Foundation
reply: @escaping @Sendable (Double, Double, Double) -> Void)
func readAdapterMetrics(
reply: @escaping @Sendable (Double, Double, Double) -> Void)
func getOutputTelemetrySMCKeyAvailability(
reply: @escaping @Sendable ([String]) -> Void)
func getCapabilities(
reply: @escaping @Sendable (Bool, Bool, Bool, Bool) -> Void)
}
24 changes: 24 additions & 0 deletions SMCPower/SMCPowerTelemetry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import SMCKit

public struct SMCPowerTelemetry: Sendable {
// Candidate keys based on public reverse-engineering references
// (e.g. Asahi macsmc-power) for adapter/system power telemetry.
public static let candidateKeys: [String] = [
"PDTR", // Input power
"PSTR", // System load
"PMVR", // Rail power sample used alongside PDTR/PSTR
"AC-W", // Active charging port index
]

public static func availableCandidateKeys() throws -> [String] {
try candidateKeys.filter {
guard let code = fourCharCode(from: $0) else { return false }
return try SMCKit.shared.isKeyFound(code)
}
}

private static func fourCharCode(from key: String) -> UInt32? {
guard key.utf8.count == 4 else { return nil }
return key.utf8.reduce(0) { ($0 << 8) | UInt32($1) }
}
}
3 changes: 2 additions & 1 deletion Stasis/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
.showPowerSource, .showTimeTillDischarge, .showBatteryCycleCount,
.showBatteryHealth, .showBatteryTemperature, .showUptime,
.showBatteryMode, .showInternalPower, .showExternalPower,
.showPowerDistribution, .manageCharging,
.showPowerDistribution, .showOutputPortsText,
.outputVisualizationMode, .manageCharging,
],
initial: false
) {
Expand Down
12 changes: 12 additions & 0 deletions Stasis/DefaultsKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import smc_power

extension MagSafeLEDState: Defaults.Serializable {}

enum OutputVisualizationMode: String, CaseIterable, Defaults.Serializable {
case off
case powerOnly
case batteryOnly
case always
}

enum PercentageDisplayLocation: String, Defaults.Serializable, CaseIterable, Identifiable {
case hidden
case nextToIcon
Expand Down Expand Up @@ -38,6 +45,11 @@ extension Defaults.Keys {
static let showInternalPower = Key<Bool>("showInternalPower", default: true)
static let showExternalPower = Key<Bool>("showExternalPower", default: true)
static let showPowerDistribution = Key<Bool>("showPowerDistribution", default: false)
static let showOutputPortsText = Key<Bool>("showOutputPortsText", default: true)
static let outputVisualizationMode = Key<OutputVisualizationMode>(
"outputVisualizationMode",
default: .always
)

// Charging
static let manageCharging = Key<Bool>("manageCharging", default: false)
Expand Down
20 changes: 19 additions & 1 deletion Stasis/L10n/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@
}
}
}
},
"Always" : {

},
"Approve Stasis in System Settings → Login Items to continue." : {

Expand Down Expand Up @@ -318,6 +321,9 @@
}
}
}
},
"Battery Only" : {

},
"Battery Power Metrics" : {
"localizations" : {
Expand Down Expand Up @@ -1643,6 +1649,12 @@
}
}
}
},
"Output Ports" : {

},
"Output ports text row" : {

},
"Pause charging when the battery temperature exceeds the threshold." : {
"localizations" : {
Expand Down Expand Up @@ -1745,6 +1757,9 @@
}
}
}
},
"Power Only" : {

},
"Power source" : {
"localizations" : {
Expand Down Expand Up @@ -1890,6 +1905,9 @@
},
"Show battery state" : {

},
"Show outgoing output" : {

},
"Show percentage" : {
"localizations" : {
Expand Down Expand Up @@ -2190,4 +2208,4 @@
}
},
"version" : "1.1"
}
}
10 changes: 10 additions & 0 deletions Stasis/Models/BatteryMetrics.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import Foundation

struct OutputPortPower: Codable, Equatable, Identifiable {
var portIndex: Int
var powerWatts: Double

var id: Int { portIndex }
}

struct BatteryMetrics: Codable, Equatable {
var batteryPercentage: Int = 0
var hardwareBatteryPercentage: Int = 0
Expand All @@ -9,6 +16,8 @@ struct BatteryMetrics: Codable, Equatable {
var batteryVoltage: Double = 0
var batteryCurrent: Double = 0
var batteryPower: Double = 0
var outputPower: Double = 0
var outputPorts: [OutputPortPower] = []
var batteryTemperature: Double = 0

var batteryHealth: Int = 0
Expand All @@ -19,6 +28,7 @@ struct BatteryMetrics: Codable, Equatable {

struct AdapterMetrics: Equatable {
var adapterConnected: Bool = false
var adapterCapacityWatts: Int = 0
var adapterVoltage: Double = 0
var adapterCurrent: Double = 0
var adapterPower: Double = 0
Expand Down
33 changes: 33 additions & 0 deletions Stasis/Services/BatteryService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ class BatteryService {
logger.info("BatteryService initialized")
xpcManager.connect()
startIOKitMonitoring()
Task { [weak self] in
await self?.logOutputTelemetrySMCKeyAvailability()
}
}

func loadCapabilities() async {
Expand Down Expand Up @@ -303,6 +306,36 @@ class BatteryService {
return helper
}

private func logOutputTelemetrySMCKeyAvailability() async {
let logger = self.logger
guard
let helper = xpcManager.getHelper(errorHandler: { error in
logger.error(
"XPC error probing output telemetry SMC keys: \(error.localizedDescription)"
)
})
else {
logger.warning("Helper unavailable for output telemetry SMC key probe")
return
}

let keys = await withCheckedContinuation { continuation in
helper.getOutputTelemetrySMCKeyAvailability { keys in
continuation.resume(returning: keys)
}
}

if keys.isEmpty {
logger.info(
"Output telemetry SMC candidate keys not found (PDTR/PSTR/PMVR/AC-W)"
)
} else {
logger.info(
"Output telemetry SMC candidate keys available: \(keys.joined(separator: ", "))"
)
}
}

func stop() {
logger.info("BatteryService stopping")
ioKitMonitorTask?.cancel()
Expand Down
109 changes: 102 additions & 7 deletions Stasis/Services/IOKitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ class IOKitService {
private var batteryService: io_service_t = 0

private var continuation: AsyncStream<(BatteryMetrics, AdapterMetrics)>.Continuation?
private var refreshTask: Task<Void, Never>?

private let logger = Logger(
subsystem: "com.srimanachanta.stasis",
category: "IOKitService"
)
private var outputTelemetryAvailabilityLogged = false

func metricsStream() -> AsyncStream<(BatteryMetrics, AdapterMetrics)> {
AsyncStream { continuation in
Expand Down Expand Up @@ -82,9 +84,12 @@ class IOKitService {
}

emitMetrics()
startRefreshLoop()
}

private func stop() {
refreshTask?.cancel()
refreshTask = nil
if interestNotification != 0 {
IOObjectRelease(interestNotification)
interestNotification = 0
Expand Down Expand Up @@ -128,8 +133,12 @@ class IOKitService {

batteryMetrics.externalConnected =
getPropertyValue(batteryService, key: "ExternalConnected") ?? false
batteryMetrics.outputPorts = getOutputPortPowers()
batteryMetrics.outputPower = batteryMetrics.outputPorts.reduce(0) { $0 + $1.powerWatts }

adapterMetrics.adapterConnected = isAdapterConnected()
let adapterRatedWatts = getAdapterRatedWatts()
adapterMetrics.adapterCapacityWatts = adapterRatedWatts ?? 0
adapterMetrics.adapterConnected = (adapterRatedWatts ?? 0) > 0

if let temp = getBatteryTemperature(powerInfo: powerInfo) {
batteryMetrics.batteryTemperature = temp
Expand Down Expand Up @@ -212,13 +221,15 @@ class IOKitService {
return timeToFull
}

private func isAdapterConnected() -> Bool {
guard let adapterDetails: [String: Any] = getPropertyValue(batteryService, key: "AdapterDetails"),
let watts = adapterDetails["Watts"] as? Int else {
return false
private func getAdapterRatedWatts() -> Int? {
guard
let adapterDetails: [String: Any] = getPropertyValue(batteryService, key: "AdapterDetails"),
let watts = adapterDetails["Watts"] as? Int,
watts > 0
else {
return nil
}

return watts > 0
return watts
}

private func getBatteryTemperature(powerInfo: [String: Any]?) -> Double? {
Expand Down Expand Up @@ -258,4 +269,88 @@ class IOKitService {

return (currentCapacity, maxCapacity, designCapacity)
}

private func getOutputPortPowers() -> [OutputPortPower] {
guard supportsOutputTelemetry() else {
return []
}

guard
let powerOutDetails: [[String: Any]] = getPropertyValue(
batteryService,
key: "PowerOutDetails"
)
else {
return []
}

return powerOutDetails.compactMap { detail in
guard let portIndex = (detail["PortIndex"] as? NSNumber)?.intValue else {
return nil
}

let milliwatts: Double
if let wattsMilliwatts = detail["Watts"] as? NSNumber {
milliwatts = wattsMilliwatts.doubleValue
} else if let currentMilliamps = detail["Current"] as? NSNumber,
let voltageMillivolts = detail["AdapterVoltage"] as? NSNumber
{
milliwatts = currentMilliamps.doubleValue * voltageMillivolts.doubleValue / 1000.0
} else {
milliwatts = 0
}

let watts = max(0, milliwatts / 1000.0)
guard watts > 0.1 else { return nil }
return OutputPortPower(portIndex: portIndex, powerWatts: watts)
}
.sorted { $0.portIndex < $1.portIndex }
}

private func supportsOutputTelemetry() -> Bool {
let osMajor = ProcessInfo.processInfo.operatingSystemVersion.majorVersion
guard osMajor >= 26 else {
if !outputTelemetryAvailabilityLogged {
logger.info("Outgoing output telemetry disabled: requires macOS 26+")
outputTelemetryAvailabilityLogged = true
}
return false
}

let hasTelemetryData: [String: Any]? = getPropertyValue(
batteryService,
key: "PowerTelemetryData"
)
let hasPowerOutDetails: [[String: Any]]? = getPropertyValue(
batteryService,
key: "PowerOutDetails"
)
let supported = (hasTelemetryData != nil) && (hasPowerOutDetails != nil)

if !outputTelemetryAvailabilityLogged {
if supported {
logger.info("Outgoing output telemetry enabled")
} else {
logger.info(
"Outgoing output telemetry disabled: PowerTelemetryData/PowerOutDetails unavailable"
)
}
outputTelemetryAvailabilityLogged = true
}

return supported
}

private func startRefreshLoop() {
guard refreshTask == nil else { return }
refreshTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { break }
await MainActor.run {
self?.emitMetrics()
}
}
}
}
}
Loading