From 722b278832f5eeae4cba4f06a210dc42aaf25f87 Mon Sep 17 00:00:00 2001 From: gentpan Date: Sun, 13 Sep 2026 08:29:00 +0500 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=80=97=EF=BC=88=E6=95=B4=E6=9C=BA?= =?UTF-8?q?=E3=80=81=E7=94=B5=E6=BA=90=E8=BE=93=E5=85=A5=E3=80=81=E7=94=B5?= =?UTF-8?q?=E6=B1=A0=E3=80=81GPU=EF=BC=89=E4=B8=8E=E5=90=84=E7=B1=BB?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E9=A2=91=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + Packages/OpenStatsKit/Package.swift | 2 +- .../Sources/Metrics/Formatting.swift | 9 + .../Sources/Metrics/IOReport.swift | 46 ++++ .../Sources/Metrics/MetricsHub.swift | 17 ++ .../OpenStatsKit/Sources/Metrics/Models.swift | 1 + .../Sources/Metrics/PowerSampler.swift | 226 ++++++++++++++++++ .../OpenStatsUI/Panel/ThermalPage.swift | 13 + .../OpenStatsUI/Popover/MetricPopovers.swift | 36 ++- .../Snapshot/SnapshotRenderer.swift | 2 + .../Sources/OpenStatsUI/State/AppModel.swift | 2 + .../OpenStatsUI/State/AppSettings.swift | 7 +- .../OpenStatsUI/State/MetricsStore.swift | 6 + .../MetricsTests/PowerSamplerTests.swift | 46 ++++ 14 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Metrics/IOReport.swift create mode 100644 Packages/OpenStatsKit/Sources/Metrics/PowerSampler.swift create mode 100644 Packages/OpenStatsKit/Tests/MetricsTests/PowerSamplerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f9330d6..cd3b772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - 辅助工具版本检查:启动时核对正在运行的辅助工具版本;旧进程还在运行时先断开让它退出,由系统启动新版本,仍然旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。 - 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。 - 系统通知(设置 · 通知):CPU 过热(持续 1 分钟高于设定温度)、内存压力严重(持续 30 秒)、磁盘空间不足(低于 10% 或 10 GB)、网络断开超过 20 秒(恢复后再提示)、电池最大容量低于 80%,逐项开关,默认关闭;同一状况持续期间只提醒一次并有冷却时间,点通知打开对应页面;可发送测试通知,通知被关闭时提示去系统设置打开。 +- 功耗与频率:“温度与风扇”页和温度详情新增功耗,显示整机功耗走势、电源输入、电池充放电功率和 GPU 功耗(读 SMC 与系统能耗统计,不需要辅助工具);CPU 详情的核心分工显示各类核心工作时的平均频率。读取能耗统计有一定开销,只在这些界面打开时每 2 秒读一次。 #### 调整 diff --git a/Packages/OpenStatsKit/Package.swift b/Packages/OpenStatsKit/Package.swift index af68e71..84dc51d 100644 --- a/Packages/OpenStatsKit/Package.swift +++ b/Packages/OpenStatsKit/Package.swift @@ -15,7 +15,7 @@ let package = Package( ], targets: [ .target(name: "SMC"), - .target(name: "Metrics", dependencies: ["SMC"]), + .target(name: "Metrics", dependencies: ["SMC"], linkerSettings: [.linkedLibrary("IOReport")]), .target(name: "HelperShared"), .target(name: "Cleaner"), .target(name: "Updates"), diff --git a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift index 5bd7055..3f8cd59 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift @@ -65,6 +65,15 @@ public enum Format { fahrenheit ? "\(Int((celsius * 9 / 5 + 32).rounded()))°F" : "\(Int(celsius.rounded()))°C" } + public static func watts(_ value: Double) -> String { + value < 10 ? "\(value.formatted(.number.precision(.fractionLength(1)))) W" : "\(Int(value.rounded())) W" + } + + /// 频率:MHz 输入,1000 以上显示为 GHz + public static func frequency(megahertz value: Double) -> String { + value >= 1000 ? "\((value / 1000).formatted(.number.precision(.fractionLength(2)))) GHz" : "\(Int(value.rounded())) MHz" + } + public static func rpm(_ value: Double) -> String { "\(Int(value.rounded())) RPM" } diff --git a/Packages/OpenStatsKit/Sources/Metrics/IOReport.swift b/Packages/OpenStatsKit/Sources/Metrics/IOReport.swift new file mode 100644 index 0000000..eb3aa05 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/IOReport.swift @@ -0,0 +1,46 @@ +import Foundation + +// libIOReport 没有公开头文件,按 powermetrics / macmon 使用的签名声明。只读取统计,不需要 root。 + +typealias IOReportSubscriptionRef = OpaquePointer + +@_silgen_name("IOReportCopyChannelsInGroup") +func IOReportCopyChannelsInGroup(_ group: CFString?, _ subgroup: CFString?, _ a: UInt64, _ b: UInt64, _ c: UInt64) -> Unmanaged? + +@_silgen_name("IOReportMergeChannels") +func IOReportMergeChannels(_ into: CFMutableDictionary, _ from: CFDictionary, _ unused: CFTypeRef?) + +@_silgen_name("IOReportCreateSubscription") +func IOReportCreateSubscription(_ allocator: UnsafeRawPointer?, _ channels: CFMutableDictionary, + _ subscribed: UnsafeMutablePointer?>, + _ channelID: UInt64, _ options: CFTypeRef?) -> IOReportSubscriptionRef? + +@_silgen_name("IOReportCreateSamples") +func IOReportCreateSamples(_ subscription: IOReportSubscriptionRef, _ channels: CFMutableDictionary, _ options: CFTypeRef?) -> Unmanaged? + +@_silgen_name("IOReportCreateSamplesDelta") +func IOReportCreateSamplesDelta(_ previous: CFDictionary, _ current: CFDictionary, _ options: CFTypeRef?) -> Unmanaged? + +@_silgen_name("IOReportChannelGetGroup") +func IOReportChannelGetGroup(_ channel: CFDictionary) -> Unmanaged? + +@_silgen_name("IOReportChannelGetSubGroup") +func IOReportChannelGetSubGroup(_ channel: CFDictionary) -> Unmanaged? + +@_silgen_name("IOReportChannelGetChannelName") +func IOReportChannelGetChannelName(_ channel: CFDictionary) -> Unmanaged? + +@_silgen_name("IOReportChannelGetUnitLabel") +func IOReportChannelGetUnitLabel(_ channel: CFDictionary) -> Unmanaged? + +@_silgen_name("IOReportSimpleGetIntegerValue") +func IOReportSimpleGetIntegerValue(_ channel: CFDictionary, _ unused: Int32) -> Int64 + +@_silgen_name("IOReportStateGetCount") +func IOReportStateGetCount(_ channel: CFDictionary) -> Int32 + +@_silgen_name("IOReportStateGetNameForIndex") +func IOReportStateGetNameForIndex(_ channel: CFDictionary, _ index: Int32) -> Unmanaged? + +@_silgen_name("IOReportStateGetResidency") +func IOReportStateGetResidency(_ channel: CFDictionary, _ index: Int32) -> Int64 diff --git a/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift index dd0ce01..4daa280 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift @@ -14,6 +14,10 @@ public struct MetricsDemand: Sendable, Equatable { public var systemProcesses = false public var temperatures: Set = [] public var fans = false + /// 整机功耗与 GPU 功耗 + public var power = false + /// 各类核心的频率 + public var cpuFrequency = false public init() {} } @@ -24,6 +28,7 @@ public actor MetricsHub { private var network = NetworkSampler() private var processes = ProcessSampler() private let sensors = SensorSampler() + private let power = PowerSampler() private var demand = MetricsDemand() private var handler: (@MainActor @Sendable (MetricsSnapshot) -> Void)? @@ -35,6 +40,7 @@ public actor MetricsHub { private var lastDisk = Date.distantPast private var lastBattery = Date.distantPast private var lastInterface = Date.distantPast + private var lastPower = Date.distantPast public init() {} @@ -48,12 +54,16 @@ public actor MetricsHub { let needsImmediateRefresh = newDemand.interval < demand.interval || (newDemand.processes && !demand.processes) || (newDemand.disk && !demand.disk) + if (newDemand.power && !demand.power) || (newDemand.cpuFrequency && !demand.cpuFrequency) { + lastPower = .distantPast + } demand = newDemand if needsImmediateRefresh { lastDisk = .distantPast lastBattery = .distantPast lastInterface = .distantPast } + restart() } @@ -81,6 +91,8 @@ public actor MetricsHub { demand.processes = true demand.temperatures = Set(TemperatureGroup.allCases) demand.fans = true + demand.power = true + demand.cpuFrequency = true return demand } @@ -133,6 +145,11 @@ public actor MetricsHub { if !demand.temperatures.isEmpty || demand.fans { snapshot.sensors = sensors.sample(groups: demand.temperatures, includeFans: demand.fans) } + // IOReport 采样有一定开销,至少间隔 2 秒 + if demand.power || demand.cpuFrequency, now.timeIntervalSince(lastPower) >= 1.9 { + snapshot.power = power.sample(gpu: demand.power, frequency: demand.cpuFrequency, now: now) + lastPower = now + } return snapshot } } diff --git a/Packages/OpenStatsKit/Sources/Metrics/Models.swift b/Packages/OpenStatsKit/Sources/Metrics/Models.swift index 8de9459..ddf8f2f 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Models.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Models.swift @@ -192,6 +192,7 @@ public struct MetricsSnapshot: Sendable { public var processes: [ProcessUsage]? public var systemCounts: SystemCounts? public var sensors: SensorReadings? + public var power: PowerReading? public init() {} } diff --git a/Packages/OpenStatsKit/Sources/Metrics/PowerSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/PowerSampler.swift new file mode 100644 index 0000000..b2df07c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/PowerSampler.swift @@ -0,0 +1,226 @@ +import Foundation +import IOKit +import SMC + +/// 功耗与 CPU 频率 +public struct PowerReading: Sendable, Equatable { + /// 整机功耗(SMC PSTR) + public var system: Double? + /// 电源适配器输入(SMC PDTR),使用电池时为 nil + public var adapter: Double? + /// 电池充放电功率(SMC PPBR) + public var battery: Double? + /// GPU 功耗(IOReport Energy Model,两次采样之间的平均值) + public var gpu: Double? + /// 各类核心在工作时的平均频率(MHz),键为 perflevel,0 为最高性能档 + public var clusterFrequency: [Int: Double] = [:] + + public init(system: Double? = nil, adapter: Double? = nil, battery: Double? = nil, gpu: Double? = nil, + clusterFrequency: [Int: Double] = [:]) { + self.system = system + self.adapter = adapter + self.battery = battery + self.gpu = gpu + self.clusterFrequency = clusterFrequency + } +} + +/// 功耗读 SMC,CPU 频率与 GPU 能耗读 IOReport,都不需要 root。 +/// 注:新款芯片上 IOReport 的 CPU 能耗计数器(mJ)长时间不更新,因此不显示 CPU 功耗。 +public final class PowerSampler: @unchecked Sendable { + private let smc = try? SMCConnection() + private var energy: Subscription? + private var cpuStates: Subscription? + private var lastEnergy: (date: Date, sample: CFDictionary)? + private var lastStates: (date: Date, sample: CFDictionary)? + private lazy var frequencyTables: [[Double]] = Self.readFrequencyTables() + + public init() {} + + /// SMC 读数几乎没有开销;IOReport 每次采样约 5 毫秒 CPU,GPU 功耗与 CPU 频率只在界面需要时读 + public func sample(gpu includeGPU: Bool = true, frequency includeFrequency: Bool = true, now: Date = Date()) -> PowerReading { + var reading = PowerReading() + reading.system = positive(smc?.double(SMCKey("PSTR"))) + reading.adapter = positive(smc?.double(SMCKey("PDTR"))) + reading.battery = smc?.double(SMCKey("PPBR")).flatMap { $0.isFinite && abs($0) >= 0.01 ? $0 : nil } + if includeGPU { reading.gpu = sampleGPU(now: now) } + if includeFrequency { reading.clusterFrequency = sampleFrequencies(now: now) } + return reading + } + + private func positive(_ value: Double?) -> Double? { + guard let value, value.isFinite, value > 0.05, value < 1000 else { return nil } + return value + } + + // MARK: GPU + + private func sampleGPU(now: Date) -> Double? { + if energy == nil { energy = Subscription(group: "Energy Model", subgroup: nil) } + guard let energy, let current = energy.sample() else { return nil } + defer { lastEnergy = (now, current) } + // 间隔太短噪声大;太长(界面关闭过一段时间)的平均值没有意义 + guard let last = lastEnergy, (0.2...15).contains(now.timeIntervalSince(last.date)), + let delta = IOReportCreateSamplesDelta(last.sample, current, nil)?.takeRetainedValue() else { + return nil + } + let seconds = now.timeIntervalSince(last.date) + for channel in Subscription.channels(in: delta) where Subscription.name(channel) == "GPU Energy" { + let value = Double(IOReportSimpleGetIntegerValue(channel, 0)) + let joules = value * Self.joulesPerUnit(Subscription.unit(channel)) + return joules / seconds + } + return nil + } + + static func joulesPerUnit(_ unit: String) -> Double { + switch unit { + case "nJ": 1e-9 + case "uJ", "µJ": 1e-6 + case "mJ": 1e-3 + default: 1 + } + } + + // MARK: CPU 频率 + + private func sampleFrequencies(now: Date) -> [Int: Double] { + if cpuStates == nil { cpuStates = Subscription(group: "CPU Stats", subgroup: "CPU Complex Performance States") } + guard let cpuStates, let current = cpuStates.sample() else { return [:] } + defer { lastStates = (now, current) } + guard let last = lastStates, now.timeIntervalSince(last.date) <= 15, + let delta = IOReportCreateSamplesDelta(last.sample, current, nil)?.takeRetainedValue() else { return [:] } + + var complexes: [ComplexResidency] = [] + for channel in Subscription.channels(in: delta) { + let name = Subscription.name(channel) + guard let letter = Self.complexLetter(name) else { continue } + var states: [(String, Int64)] = [] + for index in 0.. Character? { + guard name.count >= 4, let first = name.first, first.isUppercase, + name.dropFirst().hasPrefix("CPU"), name.dropFirst(4).allSatisfy(\.isNumber) else { return nil } + return first + } + + /// 按工作状态(V{n}P{m})的驻留时间加权平均频率。同一字母的核心组合并; + /// 各组按频率表的最高频率从高到低对应 perflevel 0、1、2 + static func clusterFrequencies(_ complexes: [ComplexResidency], tables: [[Double]], perfLevels: Int) -> [Int: Double] { + var weighted: [Character: (sum: Double, time: Double, top: Double)] = [:] + for complex in complexes { + let active = complex.states.filter { $0.name.hasPrefix("V") && $0.name.contains("P") } + guard let table = table(forStateCount: active.count, letter: complex.letter, in: tables) else { continue } + var entry = weighted[complex.letter] ?? (0, 0, table.max() ?? 0) + for (index, state) in active.enumerated() where index < table.count { + entry.sum += table[index] * Double(state.residency) + entry.time += Double(state.residency) + } + weighted[complex.letter] = entry + } + let ordered = weighted.sorted { $0.value.top > $1.value.top } + guard ordered.count == perfLevels else { return [:] } + var result: [Int: Double] = [:] + for (level, item) in ordered.enumerated() where item.value.time > 0 { + result[level] = item.value.sum / item.value.time + } + return result + } + + /// 状态数与频率表项数一致的表;有多张时能效核(E)取最低、其他取最高 + static func table(forStateCount count: Int, letter: Character, in tables: [[Double]]) -> [Double]? { + let candidates = tables.filter { $0.count == count } + return letter == "E" ? candidates.min { ($0.max() ?? 0) < ($1.max() ?? 0) } + : candidates.max { ($0.max() ?? 0) < ($1.max() ?? 0) } + } + + /// pmgr 的 voltage-states*-sram 属性:每项 8 字节(频率、电压,小端 UInt32)。 + /// 旧芯片单位是 Hz,新芯片直接是 MHz;第一项为 0 的是 GPU 等其他模块的表 + static func parseFrequencyTable(_ data: Data) -> [Double]? { + guard data.count >= 16, data.count % 8 == 0 else { return nil } + let values = stride(from: 0, to: data.count, by: 8).map { offset -> Double in + let raw = data.subdata(in: offset.. 0, let top = values.max() else { return nil } + let scale = top > 100_000_000 ? 1e-6 : top > 100_000 ? 1e-3 : 1 + let megahertz = values.map { $0 * scale } + // 合理的 CPU 频率范围 + guard (200...10_000).contains(megahertz.max() ?? 0) else { return nil } + return megahertz + } + + private static func readFrequencyTables() -> [[Double]] { + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("AppleARMIODevice"), &iterator) == KERN_SUCCESS else { + return [] + } + defer { IOObjectRelease(iterator) } + var tables: [[Double]] = [] + while case let entry = IOIteratorNext(iterator), entry != 0 { + defer { IOObjectRelease(entry) } + var name = [CChar](repeating: 0, count: 128) + guard IORegistryEntryGetName(entry, &name) == KERN_SUCCESS, + String(decoding: name.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) == "pmgr" else { continue } + var properties: Unmanaged? + guard IORegistryEntryCreateCFProperties(entry, &properties, kCFAllocatorDefault, 0) == KERN_SUCCESS, + let dictionary = properties?.takeRetainedValue() as? [String: Any] else { continue } + for (key, value) in dictionary where key.hasPrefix("voltage-states") && key.hasSuffix("-sram") { + if let data = value as? Data, let table = parseFrequencyTable(data), !tables.contains(table) { + tables.append(table) + } + } + } + return tables + } +} + +/// IOReport 订阅:只订阅需要的分组,减少每次采样的开销 +private final class Subscription { + private let channels: CFMutableDictionary + private let subscription: IOReportSubscriptionRef + + init?(group: String, subgroup: String?) { + guard let channels = IOReportCopyChannelsInGroup(group as CFString, subgroup as CFString?, 0, 0, 0)?.takeRetainedValue() else { + return nil + } + var subscribed: Unmanaged? + guard let subscription = IOReportCreateSubscription(nil, channels, &subscribed, 0, nil) else { return nil } + subscribed?.release() + self.channels = channels + self.subscription = subscription + } + + func sample() -> CFDictionary? { + IOReportCreateSamples(subscription, channels, nil)?.takeRetainedValue() + } + + static func channels(in sample: CFDictionary) -> [CFDictionary] { + let key = "IOReportChannels" as CFString + guard let raw = CFDictionaryGetValue(sample, Unmanaged.passUnretained(key).toOpaque()) else { return [] } + let array = unsafeBitCast(raw, to: CFArray.self) + return (0.. String { + IOReportChannelGetChannelName(channel)?.takeUnretainedValue() as String? ?? "" + } + + static func unit(_ channel: CFDictionary) -> String { + (IOReportChannelGetUnitLabel(channel)?.takeUnretainedValue() as String? ?? "").trimmingCharacters(in: .whitespaces) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift index 0efac71..0f93521 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift @@ -12,6 +12,7 @@ struct ThermalPage: View { FanCard().frame(maxWidth: .infinity) } .fixedSize(horizontal: false, vertical: true) + PowerCard() FanSafetySettings() } } @@ -67,6 +68,18 @@ private struct TemperatureRow: View { } } +private struct PowerCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + let power = model.store.power + Card { + CardHeader(icon: "bolt", title: "功耗", detail: power?.system.map { "整机 \(Format.watts($0))" } ?? "读取中") + PowerRows(power: power, history: model.store.powerHistory.elements) + } + } +} + private struct FanCard: View { @Environment(AppModel.self) private var model diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift index bd4a07b..4ba15f0 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift @@ -46,7 +46,8 @@ struct CPUPopover: View { SectionCard(title: section.title) { ForEach(store.topology.clusters) { cluster in let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0 - ShareRow(label: "\(cluster.name) · \(cluster.coreIndices.count) 核", value: Format.percent(average), + let frequency = store.power?.clusterFrequency[cluster.id].map { " · \(Format.frequency(megahertz: $0))" } ?? "" + ShareRow(label: "\(cluster.name) · \(cluster.coreIndices.count) 核\(frequency)", value: Format.percent(average), fraction: average, color: DS.Palette.cluster(cluster.id)) } if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) { @@ -552,6 +553,10 @@ struct ThermalPopover: View { } } } + case .thermalPower: + SectionCard(title: section.title, trailing: { Text(store.power?.system.map(Format.watts) ?? "—") }) { + PowerRows(power: store.power, history: store.powerHistory.elements, compact: true) + } default: EmptyView() } @@ -566,3 +571,32 @@ func fanStatusText(fans: [FanState], mode: FanController.Mode) -> String { guard fans.contains(where: \.isManual) else { return "由 macOS 调节" } return mode == .automatic ? "其他程序手动控制" : "OpenStats 控制中" } + +/// 功耗明细:整机、电源输入、电池、GPU,附整机功耗走势 +struct PowerRows: View { + let power: PowerReading? + let history: [Double] + var compact = false + + var body: some View { + if let power, power.system != nil || power.gpu != nil { + if history.count > 1 { + LineHistoryChart(values: history, maxValue: max(history.max() ?? 0, 10) * 1.2, + color: DS.Palette.warning, height: compact ? DS.Size.tileChart : DS.Size.chartHeight) + } + if let system = power.system { InfoRow(label: "整机", text: Format.watts(system)) } + if let adapter = power.adapter { InfoRow(label: "电源输入", text: Format.watts(adapter)) } + if let battery = power.battery { + // PPBR 为正表示电池在放电供电,接通电源时是充电功率 + InfoRow(label: power.adapter == nil ? "电池放电" : "电池充电", text: Format.watts(abs(battery))) + } + if let gpu = power.gpu { InfoRow(label: "GPU", text: Format.watts(gpu)) } + Text("由 SMC 与系统能耗统计读取;新款芯片不提供可靠的 CPU 单独功耗") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("这台 Mac 不提供功耗读数").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index daa13b5..3d9a031 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -29,6 +29,8 @@ enum SnapshotRenderer { demand.processes = true demand.temperatures = Set(TemperatureGroup.allCases) demand.fans = true + demand.power = true + demand.cpuFrequency = true await model.hub.update(demand) await model.hub.start { snapshot in model.handle(snapshot) } try? await Task.sleep(for: .seconds(12)) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 39eafd0..69c6d95 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -77,6 +77,8 @@ public final class AppModel { if showing(.gpu, .gpu) { groups.insert(.gpu) } if settings.enabledAlerts.contains(.cpuTemperature) { groups.insert(.cpu) } demand.temperatures = groups + demand.power = (window && tab == .thermal) || thermalPopover + demand.cpuFrequency = showing(.cpu, .cpu) demand.fans = (window && (tab == .thermal || tab == .overview)) || menu.contains(.fan) || fans.mode != .automatic || thermalPopover return demand diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index f2fd63f..707e8d7 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -59,8 +59,8 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .memory: [.memoryWaterline, .memoryCompression, .memoryApps] case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses] case .gpu: [.gpuHistory, .gpuDetails] - case .temperature: [.thermalSensors, .thermalFans] - case .fan: [.thermalFans, .thermalSensors] + case .temperature: [.thermalSensors, .thermalFans, .thermalPower] + case .fan: [.thermalFans, .thermalSensors, .thermalPower] } } @@ -85,7 +85,7 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { case memoryWaterline, memoryCompression, memoryApps case networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses case gpuHistory, gpuDetails - case thermalSensors, thermalFans + case thermalSensors, thermalFans, thermalPower public var id: String { rawValue } @@ -107,6 +107,7 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { case .gpuDetails: "显卡信息" case .thermalSensors: "温度" case .thermalFans: "风扇" + case .thermalPower: "功耗" } } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift index e5a9470..04f3d81 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift @@ -36,12 +36,18 @@ public final class MetricsStore { public private(set) var processes: [ProcessUsage] = [] public private(set) var systemCounts: SystemCounts? public private(set) var sensors: SensorReadings? + public private(set) var power: PowerReading? + public private(set) var powerHistory = History(capacity: historyCapacity) public private(set) var lastUpdate: Date? public init() {} public func apply(_ snapshot: MetricsSnapshot) { lastUpdate = snapshot.date + if let power = snapshot.power { + self.power = power + if let system = power.system { powerHistory.append(system) } + } if let cpu = snapshot.cpu { self.cpu = cpu cpuTotal.append(cpu.total) diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/PowerSamplerTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/PowerSamplerTests.swift new file mode 100644 index 0000000..a441f48 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/MetricsTests/PowerSamplerTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import Metrics + +@Suite struct PowerSamplerTests { + private func table(_ values: [UInt32]) -> Data { + var data = Data() + for value in values { + withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } + withUnsafeBytes(of: UInt32(800).littleEndian) { data.append(contentsOf: $0) } + } + return data + } + + @Test func parsesTablesInHertzAndMegahertz() { + #expect(PowerSampler.parseFrequencyTable(table([600_000_000, 1_200_000_000, 3_200_000_000])) == [600, 1200, 3200]) + #expect(PowerSampler.parseFrequencyTable(table([1308, 2292, 4608])) == [1308, 2292, 4608]) + // 第一项为 0 的是 GPU 等模块的表 + #expect(PowerSampler.parseFrequencyTable(table([0, 338_000, 1_620_000])) == nil) + #expect(PowerSampler.parseFrequencyTable(Data([1, 2, 3])) == nil) + } + + @Test func recognizesComplexChannels() { + #expect(PowerSampler.complexLetter("ECPU") == "E") + #expect(PowerSampler.complexLetter("PCPU") == "P") + #expect(PowerSampler.complexLetter("MCPU1") == "M") + #expect(PowerSampler.complexLetter("PCPM") == nil) + #expect(PowerSampler.complexLetter("MCPM0_IDLE") == nil) + } + + @Test func weightsFrequencyByResidencyAndOrdersClusters() { + let tables: [[Double]] = [[600, 1000, 2000], [1000, 2000, 3000, 4000]] + let efficiency = PowerSampler.ComplexResidency(letter: "E", states: [("IDLE", 900), ("V0P2", 50), ("V1P1", 0), ("V2P0", 50)]) + let performance = PowerSampler.ComplexResidency(letter: "P", states: [("DOWN", 10), ("IDLE", 10), ("V0P3", 0), ("V1P2", 0), ("V2P1", 0), ("V3P0", 100)]) + let result = PowerSampler.clusterFrequencies([efficiency, performance], tables: tables, perfLevels: 2) + #expect(result[0] == 4000) + #expect(result[1] == 1300) + // 核心组数与 perflevel 数对不上时不猜 + #expect(PowerSampler.clusterFrequencies([efficiency], tables: tables, perfLevels: 2).isEmpty) + } + + @Test func convertsEnergyUnits() { + #expect(PowerSampler.joulesPerUnit("nJ") == 1e-9) + #expect(PowerSampler.joulesPerUnit("mJ") == 1e-3) + } +}