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/DefaultsKeys.swift b/Stasis/DefaultsKeys.swift index e0796f2..2434b29 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 +} + enum PercentageDisplayLocation: String, Defaults.Serializable, CaseIterable, Identifiable { case hidden case nextToIcon @@ -38,6 +45,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 642f685..b00e718 100644 --- a/Stasis/L10n/Localizable.xcstrings +++ b/Stasis/L10n/Localizable.xcstrings @@ -142,6 +142,9 @@ } } } + }, + "Always" : { + }, "Approve Stasis in System Settings → Login Items to continue." : { @@ -318,6 +321,9 @@ } } } + }, + "Battery Only" : { + }, "Battery Power Metrics" : { "localizations" : { @@ -1643,6 +1649,12 @@ } } } + }, + "Output Ports" : { + + }, + "Output ports text row" : { + }, "Pause charging when the battery temperature exceeds the threshold." : { "localizations" : { @@ -1745,6 +1757,9 @@ } } } + }, + "Power Only" : { + }, "Power source" : { "localizations" : { @@ -1890,6 +1905,9 @@ }, "Show battery state" : { + }, + "Show outgoing output" : { + }, "Show percentage" : { "localizations" : { @@ -2190,4 +2208,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Stasis/Models/BatteryMetrics.swift b/Stasis/Models/BatteryMetrics.swift index eb2e9f6..e34427c 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,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 @@ -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 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 fc7885e..2daedee 100644 --- a/Stasis/Services/IOKitService.swift +++ b/Stasis/Services/IOKitService.swift @@ -11,11 +11,13 @@ 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", category: "IOKitService" ) + private var outputTelemetryAvailabilityLogged = false func metricsStream() -> AsyncStream<(BatteryMetrics, AdapterMetrics)> { AsyncStream { continuation in @@ -82,9 +84,12 @@ class IOKitService { } emitMetrics() + startRefreshLoop() } private func stop() { + refreshTask?.cancel() + refreshTask = nil if interestNotification != 0 { IOObjectRelease(interestNotification) interestNotification = 0 @@ -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 @@ -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,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() + } + } + } + } } diff --git a/Stasis/ViewModels/MenuViewModel.swift b/Stasis/ViewModels/MenuViewModel.swift index 1810947..acd923c 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 var isLowPowerModeEnabled: Bool = false @@ -38,6 +41,8 @@ class MenuViewModel { private var metricsObservation: Task? private var settingsObservation: Task? private var uptimeTask: Task? + private var stableOutputPorts: [OutputPortPower] = [] + private var outputPortsHoldUntil: Date = .distantPast private var powerModeObservation: Task? init(batteryService: BatteryService, chargeManager: ChargeManager) { @@ -156,7 +161,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..89574e0 100644 --- a/Stasis/Views/MenuBuilder.swift +++ b/Stasis/Views/MenuBuilder.swift @@ -144,11 +144,23 @@ class MenuBuilder { view: PowerSankeyViewWrapper(viewModel: viewModel) ) ) + if shouldShowOutputPortsTextRow { + items.append( + createInfoItem( + label: String(localized: "Output Ports"), + keyPath: \.outputPortDetailsText + ) + ) + } } return items } + private var shouldShowOutputPortsTextRow: Bool { + Defaults[.showOutputPortsText] + } + private func buildHardwareSection() -> [NSMenuItem] { var items: [NSMenuItem] = [] @@ -233,13 +245,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..a2d08f7 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,41 @@ 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 + let hasAnyOutput = outputPower > 0 switch powerSource { case .acAdapter: if batteryPower > 0 { @@ -117,12 +190,36 @@ struct PowerSankeyView: View { value: nil, isLeftSide: false ) + if outputPower > 0 { + Spacer(minLength: Layout.spacerHeight) + NodeView( + icon: "iphone", + value: nil, + isLeftSide: false + ) + } } 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 +230,16 @@ struct PowerSankeyView: View { .frame(height: Layout.largeNodeHeight) case .battery: NodeView(icon: "laptopcomputer", value: nil, isLeftSide: false) + 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 { + 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..990e8ac 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) } } .formStyle(.grouped)