功耗(整机、电源输入、电池、GPU)与各类核心频率
This commit is contained in:
1 parent
1e8c2f0773
commit
722b278832
14 files changed
+409
-5
No files matched your search
@@ -13,6 +13,7 @@
|
||||
- 辅助工具版本检查:启动时核对正在运行的辅助工具版本;旧进程还在运行时先断开让它退出,由系统启动新版本,仍然旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。
|
||||
- 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。
|
||||
- 系统通知(设置 · 通知):CPU 过热(持续 1 分钟高于设定温度)、内存压力严重(持续 30 秒)、磁盘空间不足(低于 10% 或 10 GB)、网络断开超过 20 秒(恢复后再提示)、电池最大容量低于 80%,逐项开关,默认关闭;同一状况持续期间只提醒一次并有冷却时间,点通知打开对应页面;可发送测试通知,通知被关闭时提示去系统设置打开。
|
||||
- 功耗与频率:“温度与风扇”页和温度详情新增功耗,显示整机功耗走势、电源输入、电池充放电功率和 GPU 功耗(读 SMC 与系统能耗统计,不需要辅助工具);CPU 详情的核心分工显示各类核心工作时的平均频率。读取能耗统计有一定开销,只在这些界面打开时每 2 秒读一次。
|
||||
|
||||
#### 调整
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<CFMutableDictionary>?
|
||||
|
||||
@_silgen_name("IOReportMergeChannels")
|
||||
func IOReportMergeChannels(_ into: CFMutableDictionary, _ from: CFDictionary, _ unused: CFTypeRef?)
|
||||
|
||||
@_silgen_name("IOReportCreateSubscription")
|
||||
func IOReportCreateSubscription(_ allocator: UnsafeRawPointer?, _ channels: CFMutableDictionary,
|
||||
_ subscribed: UnsafeMutablePointer<Unmanaged<CFMutableDictionary>?>,
|
||||
_ channelID: UInt64, _ options: CFTypeRef?) -> IOReportSubscriptionRef?
|
||||
|
||||
@_silgen_name("IOReportCreateSamples")
|
||||
func IOReportCreateSamples(_ subscription: IOReportSubscriptionRef, _ channels: CFMutableDictionary, _ options: CFTypeRef?) -> Unmanaged<CFDictionary>?
|
||||
|
||||
@_silgen_name("IOReportCreateSamplesDelta")
|
||||
func IOReportCreateSamplesDelta(_ previous: CFDictionary, _ current: CFDictionary, _ options: CFTypeRef?) -> Unmanaged<CFDictionary>?
|
||||
|
||||
@_silgen_name("IOReportChannelGetGroup")
|
||||
func IOReportChannelGetGroup(_ channel: CFDictionary) -> Unmanaged<CFString>?
|
||||
|
||||
@_silgen_name("IOReportChannelGetSubGroup")
|
||||
func IOReportChannelGetSubGroup(_ channel: CFDictionary) -> Unmanaged<CFString>?
|
||||
|
||||
@_silgen_name("IOReportChannelGetChannelName")
|
||||
func IOReportChannelGetChannelName(_ channel: CFDictionary) -> Unmanaged<CFString>?
|
||||
|
||||
@_silgen_name("IOReportChannelGetUnitLabel")
|
||||
func IOReportChannelGetUnitLabel(_ channel: CFDictionary) -> Unmanaged<CFString>?
|
||||
|
||||
@_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<CFString>?
|
||||
|
||||
@_silgen_name("IOReportStateGetResidency")
|
||||
func IOReportStateGetResidency(_ channel: CFDictionary, _ index: Int32) -> Int64
|
||||
@@ -14,6 +14,10 @@ public struct MetricsDemand: Sendable, Equatable {
|
||||
public var systemProcesses = false
|
||||
public var temperatures: Set<TemperatureGroup> = []
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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..<IOReportStateGetCount(channel) {
|
||||
let stateName = IOReportStateGetNameForIndex(channel, index)?.takeUnretainedValue() as String? ?? ""
|
||||
states.append((stateName, IOReportStateGetResidency(channel, index)))
|
||||
}
|
||||
complexes.append(ComplexResidency(letter: letter, states: states))
|
||||
}
|
||||
let levels = Sysctl.int("hw.nperflevels") ?? 1
|
||||
return Self.clusterFrequencies(complexes, tables: frequencyTables, perfLevels: levels)
|
||||
}
|
||||
|
||||
struct ComplexResidency {
|
||||
let letter: Character
|
||||
let states: [(name: String, residency: Int64)]
|
||||
}
|
||||
|
||||
/// “ECPU”“PCPU”“MCPU0” 这类核心组通道;“PCPM”“MCPM0_IDLE” 是电源管理通道,不计入
|
||||
static func complexLetter(_ name: String) -> 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..<offset + 4).withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) }
|
||||
return Double(UInt32(littleEndian: raw))
|
||||
}
|
||||
guard let first = values.first, first > 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<CFMutableDictionary>?
|
||||
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<CFMutableDictionary>?
|
||||
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..<CFArrayGetCount(array)).map { unsafeBitCast(CFArrayGetValueAtIndex(array, $0), to: CFDictionary.self) }
|
||||
}
|
||||
|
||||
static func name(_ channel: CFDictionary) -> String {
|
||||
IOReportChannelGetChannelName(channel)?.takeUnretainedValue() as String? ?? ""
|
||||
}
|
||||
|
||||
static func unit(_ channel: CFDictionary) -> String {
|
||||
(IOReportChannelGetUnitLabel(channel)?.takeUnretainedValue() as String? ?? "").trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "功耗"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Double>(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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user