From 3bc00abccc591b22f08570434af47db58f0d1d3c Mon Sep 17 00:00:00 2001 From: Dinanath Dash Date: Wed, 13 May 2026 17:20:02 +0530 Subject: [PATCH 1/2] feat: Add outgoing power visualization to Sankey diagram --- Stasis/DefaultsKeys.swift | 12 ++ Stasis/L10n/Localizable.xcstrings | 18 ++ Stasis/Models/BatteryMetrics.swift | 11 ++ Stasis/Services/IOKitService.swift | 96 ++++++++- Stasis/ViewModels/MenuViewModel.swift | 37 +++- Stasis/Views/MenuBuilder.swift | 43 +++- Stasis/Views/PowerSankeyView.swift | 185 ++++++++++++++++-- .../Settings/DashboardSettingsView.swift | 11 ++ 8 files changed, 383 insertions(+), 30 deletions(-) diff --git a/Stasis/DefaultsKeys.swift b/Stasis/DefaultsKeys.swift index a04f427..020cbc3 100644 --- a/Stasis/DefaultsKeys.swift +++ b/Stasis/DefaultsKeys.swift @@ -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 +} + extension Defaults.Keys { // General static let launchAtLogin = Key("launchAtLogin", default: false) @@ -30,6 +37,11 @@ extension Defaults.Keys { static let showInternalPower = Key("showInternalPower", default: true) static let showExternalPower = Key("showExternalPower", default: true) static let showPowerDistribution = Key("showPowerDistribution", default: false) + static let showOutputPortsText = Key("showOutputPortsText", default: true) + static let outputVisualizationMode = Key( + "outputVisualizationMode", + default: .always + ) // Charging static let manageCharging = Key("manageCharging", default: false) diff --git a/Stasis/L10n/Localizable.xcstrings b/Stasis/L10n/Localizable.xcstrings index 1b1bf5b..043e648 100644 --- a/Stasis/L10n/Localizable.xcstrings +++ b/Stasis/L10n/Localizable.xcstrings @@ -118,6 +118,9 @@ } } } + }, + "Always" : { + }, "Approve Stasis in System Settings → Login Items to continue." : { @@ -264,6 +267,9 @@ } } } + }, + "Battery Only" : { + }, "Battery Power Metrics" : { "localizations" : { @@ -1377,6 +1383,12 @@ } } } + }, + "Output Ports" : { + + }, + "Output ports text row" : { + }, "Pause charging when the battery temperature exceeds the threshold." : { "localizations" : { @@ -1461,6 +1473,9 @@ } } } + }, + "Power Only" : { + }, "Power source" : { "localizations" : { @@ -1610,6 +1625,9 @@ }, "Show battery state" : { + }, + "Show outgoing output" : { + }, "Sleep Prevention" : { diff --git a/Stasis/Models/BatteryMetrics.swift b/Stasis/Models/BatteryMetrics.swift index eb2e9f6..07db63c 100644 --- a/Stasis/Models/BatteryMetrics.swift +++ b/Stasis/Models/BatteryMetrics.swift @@ -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 @@ -9,6 +16,9 @@ struct BatteryMetrics: Codable, Equatable { var batteryVoltage: Double = 0 var batteryCurrent: Double = 0 var batteryPower: Double = 0 + var systemInputPower: Double = 0 + var outputPower: Double = 0 + var outputPorts: [OutputPortPower] = [] var batteryTemperature: Double = 0 var batteryHealth: Int = 0 @@ -19,6 +29,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 diff --git a/Stasis/Services/IOKitService.swift b/Stasis/Services/IOKitService.swift index fc7885e..2225dc9 100644 --- a/Stasis/Services/IOKitService.swift +++ b/Stasis/Services/IOKitService.swift @@ -11,6 +11,7 @@ class IOKitService { private var batteryService: io_service_t = 0 private var continuation: AsyncStream<(BatteryMetrics, AdapterMetrics)>.Continuation? + private var refreshTask: Task? private let logger = Logger( subsystem: "com.srimanachanta.stasis", @@ -82,9 +83,12 @@ class IOKitService { } emitMetrics() + startRefreshLoop() } private func stop() { + refreshTask?.cancel() + refreshTask = nil if interestNotification != 0 { IOObjectRelease(interestNotification) interestNotification = 0 @@ -128,8 +132,13 @@ class IOKitService { batteryMetrics.externalConnected = getPropertyValue(batteryService, key: "ExternalConnected") ?? false + batteryMetrics.systemInputPower = getSystemInputPowerWatts() + 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 @@ -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? { @@ -258,4 +269,75 @@ class IOKitService { return (currentCapacity, maxCapacity, designCapacity) } + + private func getSystemInputPowerWatts() -> Double { + guard + let telemetry: [String: Any] = getPropertyValue( + batteryService, + key: "PowerTelemetryData" + ) + else { + return 0 + } + + if let systemPowerMilliwatts = telemetry["SystemPowerIn"] as? NSNumber { + return max(0, systemPowerMilliwatts.doubleValue / 1000.0) + } + + if let systemLoadMilliwatts = telemetry["SystemLoad"] as? NSNumber { + return max(0, systemLoadMilliwatts.doubleValue / 1000.0) + } + + return 0 + } + + private func getOutputPowerWatts() -> Double { + getOutputPortPowers().reduce(0) { $0 + $1.powerWatts } + } + + private func getOutputPortPowers() -> [OutputPortPower] { + 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 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() + } + } + } + } } diff --git a/Stasis/ViewModels/MenuViewModel.swift b/Stasis/ViewModels/MenuViewModel.swift index 4b2b56a..626e911 100644 --- a/Stasis/ViewModels/MenuViewModel.swift +++ b/Stasis/ViewModels/MenuViewModel.swift @@ -26,6 +26,9 @@ class MenuViewModel { var batteryPower: Double = 0 var adapterPower: Double = 0 var systemPower: Double = 0 + var outputPower: Double = 0 + var outputPortPowers: [OutputPortPower] = [] + var outputPortDetailsText: String = "None" var powerSource: PowerSource = .battery var isCharging: Bool = false @@ -37,6 +40,8 @@ class MenuViewModel { private var metricsObservation: Task? private var settingsObservation: Task? private var uptimeTask: Task? + private var stableOutputPorts: [OutputPortPower] = [] + private var outputPortsHoldUntil: Date = .distantPast init(batteryService: BatteryService, chargeManager: ChargeManager) { self.batteryService = batteryService @@ -139,7 +144,37 @@ class MenuViewModel { batteryPower = metrics.batteryPower adapterPower = adapter.adapterPower - systemPower = adapter.adapterPower - metrics.batteryPower + let totalLoadPower: Double = { + if adapter.adapterConnected { + return max(0, adapter.adapterPower - metrics.batteryPower) + } + return max(0, -metrics.batteryPower) + }() + let preferredOutputPower = max(0, metrics.outputPower) + let rawOutputPower = preferredOutputPower + + let now = Date() + if metrics.outputPorts.isEmpty, now < outputPortsHoldUntil, !stableOutputPorts.isEmpty { + outputPortPowers = stableOutputPorts + } else { + outputPortPowers = metrics.outputPorts + if !outputPortPowers.isEmpty { + stableOutputPorts = outputPortPowers + outputPortsHoldUntil = now.addingTimeInterval(2.5) + } + } + + let portsOutputPower = outputPortPowers.reduce(0) { $0 + $1.powerWatts } + outputPower = min(totalLoadPower, max(portsOutputPower, min(totalLoadPower, rawOutputPower))) + systemPower = max(0, totalLoadPower - outputPower) + + if outputPortPowers.isEmpty { + outputPortDetailsText = "None" + } else { + outputPortDetailsText = outputPortPowers + .map { "Port \($0.portIndex): \(Int($0.powerWatts.rounded())) W" } + .joined(separator: " • ") + } powerSource = derivedPowerSource isCharging = metrics.isCharging adapterConnected = adapter.adapterConnected diff --git a/Stasis/Views/MenuBuilder.swift b/Stasis/Views/MenuBuilder.swift index e4535ac..146c068 100644 --- a/Stasis/Views/MenuBuilder.swift +++ b/Stasis/Views/MenuBuilder.swift @@ -144,11 +144,35 @@ class MenuBuilder { view: PowerSankeyViewWrapper(viewModel: viewModel) ) ) + if shouldShowOutputPortsTextRow { + items.append( + createInfoItem( + label: String(localized: "Output Ports"), + keyPath: \.outputPortDetailsText + ) + ) + } } return items } + private var shouldShowOutputPortsTextRow: Bool { + guard Defaults[.showOutputPortsText] else { + return false + } + switch Defaults[.outputVisualizationMode] { + case .off: + return false + case .powerOnly: + return viewModel.adapterConnected + case .batteryOnly: + return !viewModel.adapterConnected + case .always: + return true + } + } + private func buildHardwareSection() -> [NSMenuItem] { var items: [NSMenuItem] = [] @@ -233,13 +257,30 @@ struct BatteryAdditionalInfoObserverView: View { struct PowerSankeyViewWrapper: View { let viewModel: MenuViewModel + private var shouldShowOutput: Bool { + switch Defaults[.outputVisualizationMode] { + case .off: + return false + case .powerOnly: + return viewModel.adapterConnected + case .batteryOnly: + return !viewModel.adapterConnected + case .always: + return true + } + } + var body: some View { PowerSankeyView( powerSource: viewModel.powerSource, isCharging: viewModel.isCharging, batteryPower: viewModel.batteryPower, adapterPower: viewModel.adapterPower, - systemPower: viewModel.systemPower + systemPower: viewModel.systemPower, + outputPower: shouldShowOutput ? viewModel.outputPower : 0, + outputPortPowers: shouldShowOutput + ? viewModel.outputPortPowers.map(\.powerWatts) + : [] ) } } diff --git a/Stasis/Views/PowerSankeyView.swift b/Stasis/Views/PowerSankeyView.swift index 5a35aa2..7fa389c 100644 --- a/Stasis/Views/PowerSankeyView.swift +++ b/Stasis/Views/PowerSankeyView.swift @@ -6,6 +6,15 @@ struct PowerSankeyView: View { let batteryPower: Double let adapterPower: Double let systemPower: Double + let outputPower: Double + let outputPortPowers: [Double] + + private var twoOutputIcons: (first: String, second: String) { + guard outputPortPowers.count >= 2 else { return ("iphone", "display") } + return outputPortPowers[0] >= outputPortPowers[1] + ? ("iphone", "display") + : ("display", "iphone") + } private enum Layout { static let nodeWidth: CGFloat = 60 @@ -37,21 +46,49 @@ struct PowerSankeyView: View { @ViewBuilder private var flowsAndLabels: some View { + let hasAnyOutput = outputPower > 0 + let hasTwoOutputs = outputPortPowers.count >= 2 switch powerSource { case .acAdapter: if batteryPower > 0 { Canvas { context, size in - drawSplitSankeyFlow(context: context, size: size) + if outputPower > 0 { + drawTripleSplitSankeyFlow(context: context, size: size) + } else { + drawSplitSankeyFlow(context: context, size: size) + } } - VStack(spacing: Layout.powerLabelSpacing) { + VStack(spacing: outputPower > 0 ? 22 : Layout.powerLabelSpacing) { PowerLabel(power: batteryPower) PowerLabel(power: systemPower) + if outputPower > 0 { + PowerLabel(power: outputPower) + } } } else { Canvas { context, size in - drawSimpleFlow(context: context, size: size) + if outputPortPowers.count >= 2 { + drawTripleSplitSankeyFlow(context: context, size: size) + } else if outputPower > 0 { + drawSplitSankeyFlow(context: context, size: size) + } else { + drawSimpleFlow(context: context, size: size) + } + } + if outputPortPowers.count >= 2 { + VStack(spacing: 22) { + PowerLabel(power: systemPower) + PowerLabel(power: outputPortPowers[0]) + PowerLabel(power: outputPortPowers[1]) + } + } else if outputPower > 0 { + VStack(spacing: Layout.powerLabelSpacing) { + PowerLabel(power: systemPower) + PowerLabel(power: outputPower) + } + } else { + PowerLabel(power: adapterPower) } - PowerLabel(power: adapterPower) } case .both: @@ -65,15 +102,35 @@ struct PowerSankeyView: View { case .battery: Canvas { context, size in - drawSimpleFlow(context: context, size: size) + if hasTwoOutputs { + drawTripleSplitSankeyFlow(context: context, size: size) + } else if hasAnyOutput { + drawSplitSankeyFlow(context: context, size: size) + } else { + drawSimpleFlow(context: context, size: size) + } + } + if hasTwoOutputs { + VStack(spacing: 22) { + PowerLabel(power: systemPower) + PowerLabel(power: outputPortPowers[0]) + PowerLabel(power: outputPortPowers[1]) + } + } else if hasAnyOutput { + VStack(spacing: Layout.powerLabelSpacing) { + PowerLabel(power: systemPower) + PowerLabel(power: outputPower) + } + } else { + PowerLabel(power: systemPower) } - PowerLabel(power: systemPower) } } @ViewBuilder private var leftNodes: some View { - VStack { + let hasAnyOutput = outputPower > 0 + VStack(spacing: 0) { switch powerSource { case .acAdapter: if batteryPower > 0 { @@ -84,25 +141,40 @@ struct PowerSankeyView: View { ) .frame(height: Layout.largeNodeHeight) } else { - NodeView( - icon: "powerplug.fill", - value: nil, - isLeftSide: true - ) + if hasAnyOutput { + NodeView( + icon: "powerplug.fill", + value: nil, + isLeftSide: true + ) + .frame(height: Layout.largeNodeHeight) + } else { + NodeView( + icon: "powerplug.fill", + value: nil, + isLeftSide: true + ) + } } case .both: NodeView(icon: "battery.100", value: nil, isLeftSide: true) Spacer(minLength: Layout.spacerHeight) NodeView(icon: "powerplug.fill", value: nil, isLeftSide: true) case .battery: - NodeView(icon: "battery.100", value: nil, isLeftSide: true) + if hasAnyOutput { + NodeView(icon: "battery.100", value: nil, isLeftSide: true) + .frame(height: Layout.largeNodeHeight) + } else { + NodeView(icon: "battery.100", value: nil, isLeftSide: true) + } } } } @ViewBuilder private var rightNodes: some View { - VStack { + VStack(spacing: 0) { + let hasTwoOutputs = outputPortPowers.count >= 2 switch powerSource { case .acAdapter: if batteryPower > 0 { @@ -117,12 +189,38 @@ struct PowerSankeyView: View { value: nil, isLeftSide: false ) + if outputPower > 0 { + Spacer(minLength: Layout.spacerHeight) + NodeView( + icon: "iphone", + value: nil, + isLeftSide: false + ) + } else { + Spacer(minLength: Layout.spacerHeight) + } } else { NodeView( icon: "laptopcomputer", value: nil, isLeftSide: false ) + if outputPower > 0 { + Spacer(minLength: Layout.spacerHeight) + NodeView( + icon: hasTwoOutputs ? twoOutputIcons.first : "iphone", + value: nil, + isLeftSide: false + ) + if hasTwoOutputs { + Spacer(minLength: Layout.spacerHeight) + NodeView( + icon: twoOutputIcons.second, + value: nil, + isLeftSide: false + ) + } + } } case .both: NodeView( @@ -133,6 +231,15 @@ struct PowerSankeyView: View { .frame(height: Layout.largeNodeHeight) case .battery: NodeView(icon: "laptopcomputer", value: nil, isLeftSide: false) + if outputPower > 0 { + if hasTwoOutputs { + NodeView(icon: "iphone", value: nil, isLeftSide: false) + NodeView(icon: "display", value: nil, isLeftSide: false) + } else { + Spacer(minLength: Layout.spacerHeight) + NodeView(icon: "iphone", value: nil, isLeftSide: false) + } + } } } } @@ -193,6 +300,40 @@ struct PowerSankeyView: View { ) } + private func drawTripleSplitSankeyFlow(context: GraphicsContext, size: CGSize) { + let leftX = Layout.nodeWidth + Layout.gap + let rightX = size.width - Layout.nodeWidth - Layout.gap + + let totalGap = Layout.spacerHeight * 2 + let segmentHeight = (size.height - totalGap) / 3 + let midY = size.height / 2 + let leftTop = midY - Layout.largeNodeHeight / 2 + + drawTube( + context: context, + topLeft: CGPoint(x: leftX, y: leftTop), + bottomLeft: CGPoint(x: leftX, y: leftTop + Layout.largeNodeHeight / 3), + topRight: CGPoint(x: rightX, y: 0), + bottomRight: CGPoint(x: rightX, y: segmentHeight) + ) + + drawTube( + context: context, + topLeft: CGPoint(x: leftX, y: leftTop + Layout.largeNodeHeight / 3), + bottomLeft: CGPoint(x: leftX, y: leftTop + (2 * Layout.largeNodeHeight / 3)), + topRight: CGPoint(x: rightX, y: segmentHeight + Layout.spacerHeight), + bottomRight: CGPoint(x: rightX, y: (2 * segmentHeight) + Layout.spacerHeight) + ) + + drawTube( + context: context, + topLeft: CGPoint(x: leftX, y: leftTop + (2 * Layout.largeNodeHeight / 3)), + bottomLeft: CGPoint(x: leftX, y: leftTop + Layout.largeNodeHeight), + topRight: CGPoint(x: rightX, y: (2 * segmentHeight) + (2 * Layout.spacerHeight)), + bottomRight: CGPoint(x: rightX, y: size.height) + ) + } + private func drawSimpleFlow(context: GraphicsContext, size: CGSize) { let leftX = Layout.nodeWidth + Layout.gap let rightX = size.width - Layout.nodeWidth - Layout.gap @@ -297,12 +438,12 @@ struct NodeView: View { } #Preview { - let items: [(PowerSource, Bool, Double, Double, Double)] = [ - (.both, false, -20.16, 36.0, 56.16), - (.acAdapter, true, 20.0, 30.0, 10.0), - (.battery, false, -18.63, 0.0, 18.63), - (.acAdapter, false, 0.0, 25.0, 25.0), - (.acAdapter, false, 23, 39, 16), + let items: [(PowerSource, Bool, Double, Double, Double, Double, [Double])] = [ + (.both, false, -20.16, 36.0, 56.16, 0, []), + (.acAdapter, true, 20.0, 30.0, 7.0, 3.0, [3.0]), + (.battery, false, -18.63, 0.0, 18.63, 0, []), + (.acAdapter, false, 0.0, 25.0, 11.0, 14.0, [9.0, 5.0]), + (.acAdapter, false, 23, 39, 16, 0, []), ] LazyVGrid( columns: [ @@ -316,7 +457,9 @@ struct NodeView: View { isCharging: item.1, batteryPower: item.2, adapterPower: item.3, - systemPower: item.4 + systemPower: item.4, + outputPower: item.5, + outputPortPowers: item.6 ) .frame(height: 125) } diff --git a/Stasis/Views/Settings/DashboardSettingsView.swift b/Stasis/Views/Settings/DashboardSettingsView.swift index 3a6c463..88dcf4b 100644 --- a/Stasis/Views/Settings/DashboardSettingsView.swift +++ b/Stasis/Views/Settings/DashboardSettingsView.swift @@ -12,6 +12,8 @@ struct DashboardSettingsView: View { @Default(.showInternalPower) var showInternalPower @Default(.showExternalPower) var showExternalPower @Default(.showPowerDistribution) var showPowerDistribution + @Default(.showOutputPortsText) var showOutputPortsText + @Default(.outputVisualizationMode) var outputVisualizationMode var body: some View { Form { @@ -44,6 +46,15 @@ struct DashboardSettingsView: View { Section("Visuals") { Toggle("Power distribution diagram", isOn: $showPowerDistribution) + Picker("Show outgoing output", selection: $outputVisualizationMode) { + Text("Off").tag(OutputVisualizationMode.off) + Text("Power Only").tag(OutputVisualizationMode.powerOnly) + Text("Battery Only").tag(OutputVisualizationMode.batteryOnly) + Text("Always").tag(OutputVisualizationMode.always) + } + .disabled(!showPowerDistribution) + Toggle("Output ports text row", isOn: $showOutputPortsText) + .disabled(!showPowerDistribution || outputVisualizationMode == .off) } } .formStyle(.grouped) From aa4f07412ababea59f90003c2f46bd2bf2d45739 Mon Sep 17 00:00:00 2001 From: Dinanath Dash Date: Mon, 18 May 2026 20:35:13 +0530 Subject: [PATCH 2/2] fix: address PR #20 review feedback and menu/output regressions --- Helper/Helper.swift | 14 +++++ Helper/HelperProtocol.swift | 2 + SMCPower/SMCPowerTelemetry.swift | 24 +++++++ Stasis/AppDelegate.swift | 3 +- Stasis/Models/BatteryMetrics.swift | 1 - Stasis/Services/BatteryService.swift | 33 ++++++++++ Stasis/Services/IOKitService.swift | 63 +++++++++++-------- Stasis/Views/MenuBuilder.swift | 14 +---- Stasis/Views/PowerSankeyView.swift | 8 +-- .../Settings/DashboardSettingsView.swift | 2 +- 10 files changed, 119 insertions(+), 45 deletions(-) create mode 100644 SMCPower/SMCPowerTelemetry.swift diff --git a/Helper/Helper.swift b/Helper/Helper.swift index a1ee619..bc5c71c 100644 --- a/Helper/Helper.swift +++ b/Helper/Helper.swift @@ -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([]) + } + } } diff --git a/Helper/HelperProtocol.swift b/Helper/HelperProtocol.swift index d9b9bbf..7dd3b78 100644 --- a/Helper/HelperProtocol.swift +++ b/Helper/HelperProtocol.swift @@ -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) } diff --git a/SMCPower/SMCPowerTelemetry.swift b/SMCPower/SMCPowerTelemetry.swift new file mode 100644 index 0000000..4931c35 --- /dev/null +++ b/SMCPower/SMCPowerTelemetry.swift @@ -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) } + } +} diff --git a/Stasis/AppDelegate.swift b/Stasis/AppDelegate.swift index fad913d..cea9f9a 100644 --- a/Stasis/AppDelegate.swift +++ b/Stasis/AppDelegate.swift @@ -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 ) { diff --git a/Stasis/Models/BatteryMetrics.swift b/Stasis/Models/BatteryMetrics.swift index 07db63c..e34427c 100644 --- a/Stasis/Models/BatteryMetrics.swift +++ b/Stasis/Models/BatteryMetrics.swift @@ -16,7 +16,6 @@ struct BatteryMetrics: Codable, Equatable { var batteryVoltage: Double = 0 var batteryCurrent: Double = 0 var batteryPower: Double = 0 - var systemInputPower: Double = 0 var outputPower: Double = 0 var outputPorts: [OutputPortPower] = [] var batteryTemperature: Double = 0 diff --git a/Stasis/Services/BatteryService.swift b/Stasis/Services/BatteryService.swift index ac1d97b..aea5e75 100644 --- a/Stasis/Services/BatteryService.swift +++ b/Stasis/Services/BatteryService.swift @@ -48,6 +48,9 @@ class BatteryService { logger.info("BatteryService initialized") xpcManager.connect() startIOKitMonitoring() + Task { [weak self] in + await self?.logOutputTelemetrySMCKeyAvailability() + } } func loadCapabilities() async { @@ -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() diff --git a/Stasis/Services/IOKitService.swift b/Stasis/Services/IOKitService.swift index 2225dc9..2daedee 100644 --- a/Stasis/Services/IOKitService.swift +++ b/Stasis/Services/IOKitService.swift @@ -17,6 +17,7 @@ class IOKitService { subsystem: "com.srimanachanta.stasis", category: "IOKitService" ) + private var outputTelemetryAvailabilityLogged = false func metricsStream() -> AsyncStream<(BatteryMetrics, AdapterMetrics)> { AsyncStream { continuation in @@ -132,7 +133,6 @@ class IOKitService { batteryMetrics.externalConnected = getPropertyValue(batteryService, key: "ExternalConnected") ?? false - batteryMetrics.systemInputPower = getSystemInputPowerWatts() batteryMetrics.outputPorts = getOutputPortPowers() batteryMetrics.outputPower = batteryMetrics.outputPorts.reduce(0) { $0 + $1.powerWatts } @@ -270,32 +270,11 @@ class IOKitService { return (currentCapacity, maxCapacity, designCapacity) } - private func getSystemInputPowerWatts() -> Double { - guard - let telemetry: [String: Any] = getPropertyValue( - batteryService, - key: "PowerTelemetryData" - ) - else { - return 0 - } - - if let systemPowerMilliwatts = telemetry["SystemPowerIn"] as? NSNumber { - return max(0, systemPowerMilliwatts.doubleValue / 1000.0) - } - - if let systemLoadMilliwatts = telemetry["SystemLoad"] as? NSNumber { - return max(0, systemLoadMilliwatts.doubleValue / 1000.0) + private func getOutputPortPowers() -> [OutputPortPower] { + guard supportsOutputTelemetry() else { + return [] } - return 0 - } - - private func getOutputPowerWatts() -> Double { - getOutputPortPowers().reduce(0) { $0 + $1.powerWatts } - } - - private func getOutputPortPowers() -> [OutputPortPower] { guard let powerOutDetails: [[String: Any]] = getPropertyValue( batteryService, @@ -328,6 +307,40 @@ class IOKitService { .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 diff --git a/Stasis/Views/MenuBuilder.swift b/Stasis/Views/MenuBuilder.swift index 146c068..89574e0 100644 --- a/Stasis/Views/MenuBuilder.swift +++ b/Stasis/Views/MenuBuilder.swift @@ -158,19 +158,7 @@ class MenuBuilder { } private var shouldShowOutputPortsTextRow: Bool { - guard Defaults[.showOutputPortsText] else { - return false - } - switch Defaults[.outputVisualizationMode] { - case .off: - return false - case .powerOnly: - return viewModel.adapterConnected - case .batteryOnly: - return !viewModel.adapterConnected - case .always: - return true - } + Defaults[.showOutputPortsText] } private func buildHardwareSection() -> [NSMenuItem] { diff --git a/Stasis/Views/PowerSankeyView.swift b/Stasis/Views/PowerSankeyView.swift index 7fa389c..a2d08f7 100644 --- a/Stasis/Views/PowerSankeyView.swift +++ b/Stasis/Views/PowerSankeyView.swift @@ -175,6 +175,7 @@ struct PowerSankeyView: View { private var rightNodes: some View { VStack(spacing: 0) { let hasTwoOutputs = outputPortPowers.count >= 2 + let hasAnyOutput = outputPower > 0 switch powerSource { case .acAdapter: if batteryPower > 0 { @@ -196,8 +197,6 @@ struct PowerSankeyView: View { value: nil, isLeftSide: false ) - } else { - Spacer(minLength: Layout.spacerHeight) } } else { NodeView( @@ -231,12 +230,13 @@ struct PowerSankeyView: View { .frame(height: Layout.largeNodeHeight) case .battery: NodeView(icon: "laptopcomputer", value: nil, isLeftSide: false) - if outputPower > 0 { + if hasAnyOutput { + Spacer(minLength: Layout.spacerHeight) if hasTwoOutputs { NodeView(icon: "iphone", value: nil, isLeftSide: false) + Spacer(minLength: Layout.spacerHeight) NodeView(icon: "display", value: nil, isLeftSide: false) } else { - Spacer(minLength: Layout.spacerHeight) NodeView(icon: "iphone", value: nil, isLeftSide: false) } } diff --git a/Stasis/Views/Settings/DashboardSettingsView.swift b/Stasis/Views/Settings/DashboardSettingsView.swift index 88dcf4b..990e8ac 100644 --- a/Stasis/Views/Settings/DashboardSettingsView.swift +++ b/Stasis/Views/Settings/DashboardSettingsView.swift @@ -54,7 +54,7 @@ struct DashboardSettingsView: View { } .disabled(!showPowerDistribution) Toggle("Output ports text row", isOn: $showOutputPortsText) - .disabled(!showPowerDistribution || outputVisualizationMode == .off) + .disabled(!showPowerDistribution) } } .formStyle(.grouped)