Show the CPU temperature on Apple Silicon
Power & Storage only had macOS's thermal state (a level, no degrees) and the battery temperature. The CPU die sensors are now read through IOHIDEventSystemClient (no root or entitlement needed outside the sandbox): "PMU tdie*" from M2 on, "pACC/eACC MTR Temp Sensor*" on M1. System Status shows the hottest one, with the average and sensor count in the tooltip, coloured from 80/90/100 °C. Intel Macs have no such sensors and the row stays hidden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,12 +23,14 @@
|
|||||||
489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */; };
|
489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */; };
|
||||||
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.swift */; };
|
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.swift */; };
|
||||||
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */; };
|
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */; };
|
||||||
|
5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */; };
|
||||||
67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C96471F109FF1C20ABF59E /* AlertCenter.swift */; };
|
67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C96471F109FF1C20ABF59E /* AlertCenter.swift */; };
|
||||||
6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; };
|
6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; };
|
||||||
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; };
|
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; };
|
||||||
79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; };
|
79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; };
|
||||||
7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; };
|
7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; };
|
||||||
7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892247B0F17363DFC146EDF8 /* AppSettings.swift */; };
|
7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892247B0F17363DFC146EDF8 /* AppSettings.swift */; };
|
||||||
|
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9632A456D316A019CFA313 /* ThermalMathTests.swift */; };
|
||||||
833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; };
|
833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; };
|
||||||
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.swift */; };
|
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.swift */; };
|
||||||
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; };
|
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; };
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; };
|
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; };
|
||||||
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; };
|
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; };
|
||||||
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; };
|
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; };
|
||||||
|
CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A81F77752CFF49D5038B06E /* ThermalInfo.swift */; };
|
||||||
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; };
|
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; };
|
||||||
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; };
|
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; };
|
||||||
DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */; };
|
DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */; };
|
||||||
@@ -76,6 +79,7 @@
|
|||||||
110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = "<group>"; };
|
110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = "<group>"; };
|
||||||
126A382E2755C5061E6D29F5 /* HealthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthView.swift; sourceTree = "<group>"; };
|
126A382E2755C5061E6D29F5 /* HealthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthView.swift; sourceTree = "<group>"; };
|
||||||
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = "<group>"; };
|
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = "<group>"; };
|
||||||
|
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureMonitor.swift; sourceTree = "<group>"; };
|
||||||
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = "<group>"; };
|
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = "<group>"; };
|
||||||
279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; };
|
279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; };
|
||||||
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = "<group>"; };
|
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = "<group>"; };
|
||||||
@@ -95,6 +99,7 @@
|
|||||||
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = "<group>"; };
|
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = "<group>"; };
|
||||||
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = "<group>"; };
|
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = "<group>"; };
|
||||||
78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = "<group>"; };
|
78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = "<group>"; };
|
||||||
|
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalInfo.swift; sourceTree = "<group>"; };
|
||||||
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = "<group>"; };
|
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = "<group>"; };
|
||||||
813543165B39EBD213EB3BC7 /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.swift; sourceTree = "<group>"; };
|
813543165B39EBD213EB3BC7 /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.swift; sourceTree = "<group>"; };
|
||||||
892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
||||||
@@ -120,6 +125,7 @@
|
|||||||
E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAlertTests.swift; sourceTree = "<group>"; };
|
E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAlertTests.swift; sourceTree = "<group>"; };
|
||||||
F481045B4FC6017F50849FF1 /* GPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUInfo.swift; sourceTree = "<group>"; };
|
F481045B4FC6017F50849FF1 /* GPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUInfo.swift; sourceTree = "<group>"; };
|
||||||
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = "<group>"; };
|
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = "<group>"; };
|
||||||
|
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalMathTests.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -138,6 +144,7 @@
|
|||||||
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */,
|
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */,
|
||||||
295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
|
295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
|
||||||
279B972C08A878053821285D /* SystemMonitor.swift */,
|
279B972C08A878053821285D /* SystemMonitor.swift */,
|
||||||
|
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */,
|
||||||
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
|
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
@@ -164,6 +171,7 @@
|
|||||||
3010F605712E09175D9D8878 /* NetworkInfo.swift */,
|
3010F605712E09175D9D8878 /* NetworkInfo.swift */,
|
||||||
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
|
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
|
||||||
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
|
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
|
||||||
|
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -263,6 +271,7 @@
|
|||||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
|
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
|
||||||
332290DABD5326C87258723C /* ProcessDataTests.swift */,
|
332290DABD5326C87258723C /* ProcessDataTests.swift */,
|
||||||
01C19ABF49697A97F0C40378 /* SettingsTests.swift */,
|
01C19ABF49697A97F0C40378 /* SettingsTests.swift */,
|
||||||
|
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */,
|
||||||
);
|
);
|
||||||
path = TopManagerTests;
|
path = TopManagerTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -387,6 +396,7 @@
|
|||||||
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
|
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
|
||||||
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
|
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
|
||||||
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */,
|
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */,
|
||||||
|
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -430,6 +440,8 @@
|
|||||||
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */,
|
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */,
|
||||||
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */,
|
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */,
|
||||||
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */,
|
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */,
|
||||||
|
5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */,
|
||||||
|
CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */,
|
||||||
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */,
|
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */,
|
||||||
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */,
|
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */,
|
||||||
14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */,
|
14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */,
|
||||||
|
|||||||
53
TopManager/Models/ThermalInfo.swift
Normal file
53
TopManager/Models/ThermalInfo.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One temperature sensor as reported by the system (name as the hardware names it).
|
||||||
|
struct TemperatureReading: Equatable {
|
||||||
|
let name: String
|
||||||
|
let celsius: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CPU temperature summarised from the die sensors.
|
||||||
|
struct ThermalInfo: Equatable {
|
||||||
|
let cpuMax: Double // °C, hottest CPU die sensor
|
||||||
|
let cpuAverage: Double // °C, mean of the CPU die sensors
|
||||||
|
let cpuSensorCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure, testable selection of CPU sensors (no IOKit).
|
||||||
|
enum ThermalMath {
|
||||||
|
/// CPU die sensors on Apple Silicon: "PMU tdie*" / "PMU2 tdie*" (M2 and later)
|
||||||
|
/// and "pACC/eACC MTR Temp Sensor*" (M1). The "tdev" ones sit elsewhere on the
|
||||||
|
/// package, "tcal" is a calibration value, "gas gauge battery" is the battery.
|
||||||
|
static func isCPUDie(_ name: String) -> Bool {
|
||||||
|
let n = name.lowercased()
|
||||||
|
return n.contains("tdie") || n.contains("acc mtr temp")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Readings no real sensor produces (unconnected ones report ≤ 0).
|
||||||
|
static func isPlausible(_ celsius: Double) -> Bool {
|
||||||
|
celsius > 0 && celsius < 150
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How hot the CPU die is. Apple Silicon runs into the 90s under sustained load
|
||||||
|
/// and only throttles near 105 °C, so the bands sit high on purpose.
|
||||||
|
static func level(cpuCelsius t: Double) -> ThermalLevel {
|
||||||
|
switch t {
|
||||||
|
case 100...: return .critical
|
||||||
|
case 90..<100: return .serious
|
||||||
|
case 80..<90: return .fair
|
||||||
|
default: return .nominal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func summarize(_ readings: [TemperatureReading]) -> ThermalInfo? {
|
||||||
|
let cpu = readings
|
||||||
|
.filter { isCPUDie($0.name) && isPlausible($0.celsius) }
|
||||||
|
.map(\.celsius)
|
||||||
|
guard let hottest = cpu.max() else { return nil }
|
||||||
|
return ThermalInfo(
|
||||||
|
cpuMax: hottest,
|
||||||
|
cpuAverage: cpu.reduce(0, +) / Double(cpu.count),
|
||||||
|
cpuSensorCount: cpu.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ final class SystemMonitor: ObservableObject {
|
|||||||
@MainActor @Published var networkInfo: NetworkInfo?
|
@MainActor @Published var networkInfo: NetworkInfo?
|
||||||
@MainActor @Published var gpuInfo: GPUInfo?
|
@MainActor @Published var gpuInfo: GPUInfo?
|
||||||
@MainActor @Published var powerInfo: PowerInfo?
|
@MainActor @Published var powerInfo: PowerInfo?
|
||||||
|
@MainActor @Published var thermalInfo: ThermalInfo?
|
||||||
@MainActor @Published var lastError: String?
|
@MainActor @Published var lastError: String?
|
||||||
|
|
||||||
// History for charts
|
// History for charts
|
||||||
@@ -33,6 +34,7 @@ final class SystemMonitor: ObservableObject {
|
|||||||
var networkInfo: NetworkInfo?
|
var networkInfo: NetworkInfo?
|
||||||
var gpuInfo: GPUInfo?
|
var gpuInfo: GPUInfo?
|
||||||
var powerInfo: PowerInfo?
|
var powerInfo: PowerInfo?
|
||||||
|
var thermalInfo: ThermalInfo?
|
||||||
var cpuHistory: [CPUHistoryPoint] = []
|
var cpuHistory: [CPUHistoryPoint] = []
|
||||||
var coreHistories: [Int: [CoreHistoryPoint]] = [:]
|
var coreHistories: [Int: [CoreHistoryPoint]] = [:]
|
||||||
var memoryHistory: [MemoryHistoryPoint] = []
|
var memoryHistory: [MemoryHistoryPoint] = []
|
||||||
@@ -49,6 +51,7 @@ final class SystemMonitor: ObservableObject {
|
|||||||
private let networkMonitor = NetworkMonitor()
|
private let networkMonitor = NetworkMonitor()
|
||||||
private let gpuMonitor = GPUMonitor()
|
private let gpuMonitor = GPUMonitor()
|
||||||
private let powerMonitor = PowerMonitor()
|
private let powerMonitor = PowerMonitor()
|
||||||
|
private let temperatureMonitor = TemperatureMonitor()
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
@@ -87,10 +90,12 @@ final class SystemMonitor: ObservableObject {
|
|||||||
let diskData = self.diskMonitor.fetchDiskInfo()
|
let diskData = self.diskMonitor.fetchDiskInfo()
|
||||||
let gpuData = self.gpuMonitor.fetchGPUInfo()
|
let gpuData = self.gpuMonitor.fetchGPUInfo()
|
||||||
let powerData = self.powerMonitor.fetchPowerInfo()
|
let powerData = self.powerMonitor.fetchPowerInfo()
|
||||||
|
let thermalData = self.temperatureMonitor.fetchThermalInfo()
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.updateUI(cpu: cpuData, memory: memData, network: netData,
|
self.updateUI(cpu: cpuData, memory: memData, network: netData,
|
||||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData)
|
processes: processData, disk: diskData, gpu: gpuData, power: powerData,
|
||||||
|
thermal: thermalData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +132,7 @@ final class SystemMonitor: ObservableObject {
|
|||||||
let cpuData = cpuMonitor.fetchCPUInfo()
|
let cpuData = cpuMonitor.fetchCPUInfo()
|
||||||
let memData = memoryMonitor.fetchMemoryInfo()
|
let memData = memoryMonitor.fetchMemoryInfo()
|
||||||
let netData = networkMonitor.fetchNetworkInfo()
|
let netData = networkMonitor.fetchNetworkInfo()
|
||||||
|
let thermalData = temperatureMonitor.fetchThermalInfo()
|
||||||
|
|
||||||
// Fetch processes: first 3 cycles always, then every other cycle
|
// Fetch processes: first 3 cycles always, then every other cycle
|
||||||
var processData: [ProcessItem]? = nil
|
var processData: [ProcessItem]? = nil
|
||||||
@@ -147,13 +153,14 @@ final class SystemMonitor: ObservableObject {
|
|||||||
// Update UI on main thread
|
// Update UI on main thread
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.updateUI(cpu: cpuData, memory: memData, network: netData,
|
self?.updateUI(cpu: cpuData, memory: memData, network: netData,
|
||||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData)
|
processes: processData, disk: diskData, gpu: gpuData, power: powerData,
|
||||||
|
thermal: thermalData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?,
|
@MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?,
|
||||||
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?,
|
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?,
|
||||||
power: PowerInfo?) {
|
power: PowerInfo?, thermal: ThermalInfo?) {
|
||||||
if let info = cpu {
|
if let info = cpu {
|
||||||
live.cpuInfo = info
|
live.cpuInfo = info
|
||||||
live.cpuHistory.append(CPUHistoryPoint(
|
live.cpuHistory.append(CPUHistoryPoint(
|
||||||
@@ -203,6 +210,7 @@ final class SystemMonitor: ObservableObject {
|
|||||||
if let info = disk { live.diskInfo = info }
|
if let info = disk { live.diskInfo = info }
|
||||||
if let info = gpu { live.gpuInfo = info }
|
if let info = gpu { live.gpuInfo = info }
|
||||||
if let info = power { live.powerInfo = info }
|
if let info = power { live.powerInfo = info }
|
||||||
|
if let info = thermal { live.thermalInfo = info }
|
||||||
|
|
||||||
if isUIVisible { publishLive() }
|
if isUIVisible { publishLive() }
|
||||||
MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo)
|
MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo)
|
||||||
@@ -246,6 +254,7 @@ final class SystemMonitor: ObservableObject {
|
|||||||
networkInfo = live.networkInfo
|
networkInfo = live.networkInfo
|
||||||
gpuInfo = live.gpuInfo
|
gpuInfo = live.gpuInfo
|
||||||
powerInfo = live.powerInfo
|
powerInfo = live.powerInfo
|
||||||
|
thermalInfo = live.thermalInfo
|
||||||
cpuHistory = live.cpuHistory
|
cpuHistory = live.cpuHistory
|
||||||
coreHistories = live.coreHistories
|
coreHistories = live.coreHistories
|
||||||
memoryHistory = live.memoryHistory
|
memoryHistory = live.memoryHistory
|
||||||
|
|||||||
71
TopManager/Services/TemperatureMonitor.swift
Normal file
71
TopManager/Services/TemperatureMonitor.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
|
||||||
|
// IOHIDEventSystemClient is the interface macOS itself uses for its thermal
|
||||||
|
// sensors. It is not in the public SDK headers, but the symbols are exported by
|
||||||
|
// IOKit and need neither root nor an entitlement (the app is not sandboxed).
|
||||||
|
@_silgen_name("IOHIDEventSystemClientCreate")
|
||||||
|
private func IOHIDEventSystemClientCreate(_ allocator: CFAllocator?) -> Unmanaged<AnyObject>?
|
||||||
|
|
||||||
|
@_silgen_name("IOHIDEventSystemClientSetMatching")
|
||||||
|
private func IOHIDEventSystemClientSetMatching(_ client: AnyObject, _ matching: CFDictionary) -> Int32
|
||||||
|
|
||||||
|
@_silgen_name("IOHIDEventSystemClientCopyServices")
|
||||||
|
private func IOHIDEventSystemClientCopyServices(_ client: AnyObject) -> Unmanaged<CFArray>?
|
||||||
|
|
||||||
|
@_silgen_name("IOHIDServiceClientCopyProperty")
|
||||||
|
private func IOHIDServiceClientCopyProperty(_ service: AnyObject, _ key: CFString) -> Unmanaged<AnyObject>?
|
||||||
|
|
||||||
|
@_silgen_name("IOHIDServiceClientCopyEvent")
|
||||||
|
private func IOHIDServiceClientCopyEvent(_ service: AnyObject, _ type: Int64, _ options: Int32,
|
||||||
|
_ timestamp: Int64) -> Unmanaged<AnyObject>?
|
||||||
|
|
||||||
|
@_silgen_name("IOHIDEventGetFloatValue")
|
||||||
|
private func IOHIDEventGetFloatValue(_ event: AnyObject, _ field: Int32) -> Double
|
||||||
|
|
||||||
|
/// Reads the Apple Silicon temperature sensors. On an Intel Mac there are no
|
||||||
|
/// such sensors (those sit behind the SMC) and it reports nil.
|
||||||
|
final class TemperatureMonitor {
|
||||||
|
private static let temperatureEvent: Int64 = 15 // kIOHIDEventTypeTemperature
|
||||||
|
private static let temperatureField = Int32(temperatureEvent << 16) // IOHIDEventFieldBase(type)
|
||||||
|
|
||||||
|
private let client: AnyObject?
|
||||||
|
private var cachedSensors: [(name: String, service: AnyObject)]?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
client = IOHIDEventSystemClientCreate(kCFAllocatorDefault)?.takeRetainedValue()
|
||||||
|
if let client {
|
||||||
|
// Usage page 0xff00 (Apple vendor), usage 5: temperature sensors.
|
||||||
|
_ = IOHIDEventSystemClientSetMatching(
|
||||||
|
client, ["PrimaryUsagePage": 0xff00, "PrimaryUsage": 5] as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSensors() -> [TemperatureReading] {
|
||||||
|
sensors().compactMap { sensor in
|
||||||
|
guard let event = IOHIDServiceClientCopyEvent(sensor.service, Self.temperatureEvent, 0, 0)?
|
||||||
|
.takeRetainedValue() else { return nil }
|
||||||
|
return TemperatureReading(name: sensor.name,
|
||||||
|
celsius: IOHIDEventGetFloatValue(event, Self.temperatureField))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchThermalInfo() -> ThermalInfo? {
|
||||||
|
ThermalMath.summarize(readSensors())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sensor list doesn't change while the system runs, so it is built once.
|
||||||
|
private func sensors() -> [(name: String, service: AnyObject)] {
|
||||||
|
if let cachedSensors { return cachedSensors }
|
||||||
|
guard let client,
|
||||||
|
let services = IOHIDEventSystemClientCopyServices(client)?.takeRetainedValue() as? [AnyObject]
|
||||||
|
else { return [] }
|
||||||
|
let found = services.map { service in
|
||||||
|
let name = IOHIDServiceClientCopyProperty(service, "Product" as CFString)?
|
||||||
|
.takeRetainedValue() as? String
|
||||||
|
return (name: name ?? "", service: service)
|
||||||
|
}
|
||||||
|
cachedSensors = found
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,18 @@ struct PowerStorageView: View {
|
|||||||
ThermalStateView(state: monitor.thermalState)
|
ThermalStateView(state: monitor.thermalState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let thermal = monitor.thermalInfo {
|
||||||
|
HStack {
|
||||||
|
Text("CPU Temperature:")
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.0f °C", thermal.cpuMax))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundColor(cpuTemperatureColor(thermal.cpuMax))
|
||||||
|
}
|
||||||
|
.help(String(format: "Hottest of %d CPU die sensors · average %.0f °C",
|
||||||
|
thermal.cpuSensorCount, thermal.cpuAverage))
|
||||||
|
}
|
||||||
|
|
||||||
if let cpu = monitor.cpuInfo {
|
if let cpu = monitor.cpuInfo {
|
||||||
HStack {
|
HStack {
|
||||||
Text("CPU Usage:")
|
Text("CPU Usage:")
|
||||||
@@ -190,6 +202,15 @@ struct PowerStorageView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func cpuTemperatureColor(_ celsius: Double) -> Color {
|
||||||
|
switch ThermalMath.level(cpuCelsius: celsius) {
|
||||||
|
case .nominal: return .primary
|
||||||
|
case .fair: return .yellow
|
||||||
|
case .serious: return .orange
|
||||||
|
case .critical: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BatteryStatusView: View {
|
struct BatteryStatusView: View {
|
||||||
|
|||||||
71
TopManagerTests/ThermalMathTests.swift
Normal file
71
TopManagerTests/ThermalMathTests.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import TopManager
|
||||||
|
|
||||||
|
final class ThermalMathTests: XCTestCase {
|
||||||
|
|
||||||
|
// Names as an M3 reports them (PMU / PMU2 dies, package, calibration, NAND, battery).
|
||||||
|
private let m3: [TemperatureReading] = [
|
||||||
|
.init(name: "PMU tdie1", celsius: 48.8),
|
||||||
|
.init(name: "PMU tdie2", celsius: 46.4),
|
||||||
|
.init(name: "PMU2 tdie1", celsius: 43.4),
|
||||||
|
.init(name: "PMU tdev2", celsius: 36.2),
|
||||||
|
.init(name: "PMU tdev1", celsius: -1.5),
|
||||||
|
.init(name: "PMU tcal", celsius: 51.9),
|
||||||
|
.init(name: "NAND CH0 temp", celsius: 34.0),
|
||||||
|
.init(name: "gas gauge battery", celsius: 31.3),
|
||||||
|
]
|
||||||
|
|
||||||
|
func testOnlyDieSensorsCount() {
|
||||||
|
let info = ThermalMath.summarize(m3)
|
||||||
|
XCTAssertEqual(info?.cpuSensorCount, 3)
|
||||||
|
// tcal (51.9) is hotter than every die but is a calibration value, not the CPU.
|
||||||
|
XCTAssertEqual(info?.cpuMax ?? 0, 48.8, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(info?.cpuAverage ?? 0, (48.8 + 46.4 + 43.4) / 3, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testM1ClusterSensorsCount() {
|
||||||
|
let m1: [TemperatureReading] = [
|
||||||
|
.init(name: "pACC MTR Temp Sensor0", celsius: 55),
|
||||||
|
.init(name: "eACC MTR Temp Sensor1", celsius: 41),
|
||||||
|
.init(name: "GPU MTR Temp Sensor1", celsius: 60),
|
||||||
|
.init(name: "SOC MTR Temp Sensor0", celsius: 50),
|
||||||
|
]
|
||||||
|
let info = ThermalMath.summarize(m1)
|
||||||
|
XCTAssertEqual(info?.cpuSensorCount, 2)
|
||||||
|
XCTAssertEqual(info?.cpuMax ?? 0, 55, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testImplausibleDieReadingsAreDropped() {
|
||||||
|
let readings: [TemperatureReading] = [
|
||||||
|
.init(name: "PMU tdie1", celsius: 0),
|
||||||
|
.init(name: "PMU tdie2", celsius: -2),
|
||||||
|
.init(name: "PMU tdie3", celsius: 170),
|
||||||
|
.init(name: "PMU tdie4", celsius: 44),
|
||||||
|
]
|
||||||
|
XCTAssertEqual(ThermalMath.summarize(readings)?.cpuSensorCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNoCPUSensorsMeansNil() {
|
||||||
|
// An Intel Mac: no IOHID die sensors at all, or only non-CPU ones.
|
||||||
|
XCTAssertNil(ThermalMath.summarize([]))
|
||||||
|
XCTAssertNil(ThermalMath.summarize([.init(name: "gas gauge battery", celsius: 30)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReadsRealSensorsOnAppleSilicon() throws {
|
||||||
|
#if !arch(arm64)
|
||||||
|
throw XCTSkip("Apple Silicon only: Intel Macs expose these sensors through the SMC")
|
||||||
|
#else
|
||||||
|
let info = try XCTUnwrap(TemperatureMonitor().fetchThermalInfo())
|
||||||
|
XCTAssertGreaterThan(info.cpuSensorCount, 0)
|
||||||
|
XCTAssertTrue((15...110).contains(info.cpuMax), "implausible CPU temperature \(info.cpuMax)")
|
||||||
|
XCTAssertLessThanOrEqual(info.cpuAverage, info.cpuMax)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLevels() {
|
||||||
|
XCTAssertEqual(ThermalMath.level(cpuCelsius: 48), .nominal)
|
||||||
|
XCTAssertEqual(ThermalMath.level(cpuCelsius: 85), .fair)
|
||||||
|
XCTAssertEqual(ThermalMath.level(cpuCelsius: 95), .serious)
|
||||||
|
XCTAssertEqual(ThermalMath.level(cpuCelsius: 104), .critical)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user