From 15c8b18b4845884a29831d776dc4b1b238428ee5 Mon Sep 17 00:00:00 2001 From: hariel1985 Date: Mon, 5 Oct 2026 21:51:42 +0200 Subject: [PATCH] Show the CPU temperature on Apple Silicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TopManager.xcodeproj/project.pbxproj | 12 ++++ TopManager/Models/ThermalInfo.swift | 53 ++++++++++++++ TopManager/Services/SystemMonitor.swift | 15 +++- TopManager/Services/TemperatureMonitor.swift | 71 +++++++++++++++++++ .../Views/PowerStorage/PowerStorageView.swift | 21 ++++++ TopManagerTests/ThermalMathTests.swift | 71 +++++++++++++++++++ 6 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 TopManager/Models/ThermalInfo.swift create mode 100644 TopManager/Services/TemperatureMonitor.swift create mode 100644 TopManagerTests/ThermalMathTests.swift diff --git a/TopManager.xcodeproj/project.pbxproj b/TopManager.xcodeproj/project.pbxproj index 7bcf269..1c84855 100644 --- a/TopManager.xcodeproj/project.pbxproj +++ b/TopManager.xcodeproj/project.pbxproj @@ -23,12 +23,14 @@ 489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */; }; 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.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 */; }; 6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; }; 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; }; 79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; }; 7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.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 */; }; 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.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 */; }; C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.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 */; }; D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.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 = ""; }; 126A382E2755C5061E6D29F5 /* HealthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthView.swift; sourceTree = ""; }; 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = ""; }; + 19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureMonitor.swift; sourceTree = ""; }; 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = ""; }; 279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = ""; }; 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = ""; }; @@ -95,6 +99,7 @@ 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = ""; }; 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = ""; }; 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = ""; }; + 7A81F77752CFF49D5038B06E /* ThermalInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalInfo.swift; sourceTree = ""; }; 7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = ""; }; 813543165B39EBD213EB3BC7 /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.swift; sourceTree = ""; }; 892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; @@ -120,6 +125,7 @@ E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAlertTests.swift; sourceTree = ""; }; F481045B4FC6017F50849FF1 /* GPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUInfo.swift; sourceTree = ""; }; F80A817DCDA254349DEA6F06 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = ""; }; + FD9632A456D316A019CFA313 /* ThermalMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalMathTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -138,6 +144,7 @@ 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */, 295232B4C746511757DCF8FE /* ProcessMonitor.swift */, 279B972C08A878053821285D /* SystemMonitor.swift */, + 19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */, 8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */, ); path = Services; @@ -164,6 +171,7 @@ 3010F605712E09175D9D8878 /* NetworkInfo.swift */, 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */, 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */, + 7A81F77752CFF49D5038B06E /* ThermalInfo.swift */, ); path = Models; sourceTree = ""; @@ -263,6 +271,7 @@ 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 332290DABD5326C87258723C /* ProcessDataTests.swift */, 01C19ABF49697A97F0C40378 /* SettingsTests.swift */, + FD9632A456D316A019CFA313 /* ThermalMathTests.swift */, ); path = TopManagerTests; sourceTree = ""; @@ -387,6 +396,7 @@ 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */, + 802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -430,6 +440,8 @@ 0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */, B89695749D32F394DB459C15 /* SettingsView.swift in Sources */, DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */, + 5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */, + CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */, D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */, 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */, 14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */, diff --git a/TopManager/Models/ThermalInfo.swift b/TopManager/Models/ThermalInfo.swift new file mode 100644 index 0000000..776a6e9 --- /dev/null +++ b/TopManager/Models/ThermalInfo.swift @@ -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 + ) + } +} diff --git a/TopManager/Services/SystemMonitor.swift b/TopManager/Services/SystemMonitor.swift index 5af5f60..8327830 100644 --- a/TopManager/Services/SystemMonitor.swift +++ b/TopManager/Services/SystemMonitor.swift @@ -15,6 +15,7 @@ final class SystemMonitor: ObservableObject { @MainActor @Published var networkInfo: NetworkInfo? @MainActor @Published var gpuInfo: GPUInfo? @MainActor @Published var powerInfo: PowerInfo? + @MainActor @Published var thermalInfo: ThermalInfo? @MainActor @Published var lastError: String? // History for charts @@ -33,6 +34,7 @@ final class SystemMonitor: ObservableObject { var networkInfo: NetworkInfo? var gpuInfo: GPUInfo? var powerInfo: PowerInfo? + var thermalInfo: ThermalInfo? var cpuHistory: [CPUHistoryPoint] = [] var coreHistories: [Int: [CoreHistoryPoint]] = [:] var memoryHistory: [MemoryHistoryPoint] = [] @@ -49,6 +51,7 @@ final class SystemMonitor: ObservableObject { private let networkMonitor = NetworkMonitor() private let gpuMonitor = GPUMonitor() private let powerMonitor = PowerMonitor() + private let temperatureMonitor = TemperatureMonitor() // Timer private var timer: Timer? @@ -87,10 +90,12 @@ final class SystemMonitor: ObservableObject { let diskData = self.diskMonitor.fetchDiskInfo() let gpuData = self.gpuMonitor.fetchGPUInfo() let powerData = self.powerMonitor.fetchPowerInfo() + let thermalData = self.temperatureMonitor.fetchThermalInfo() DispatchQueue.main.async { 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 memData = memoryMonitor.fetchMemoryInfo() let netData = networkMonitor.fetchNetworkInfo() + let thermalData = temperatureMonitor.fetchThermalInfo() // Fetch processes: first 3 cycles always, then every other cycle var processData: [ProcessItem]? = nil @@ -147,13 +153,14 @@ final class SystemMonitor: ObservableObject { // Update UI on main thread DispatchQueue.main.async { [weak self] in 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?, processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?, - power: PowerInfo?) { + power: PowerInfo?, thermal: ThermalInfo?) { if let info = cpu { live.cpuInfo = info live.cpuHistory.append(CPUHistoryPoint( @@ -203,6 +210,7 @@ final class SystemMonitor: ObservableObject { if let info = disk { live.diskInfo = info } if let info = gpu { live.gpuInfo = info } if let info = power { live.powerInfo = info } + if let info = thermal { live.thermalInfo = info } if isUIVisible { publishLive() } MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo) @@ -246,6 +254,7 @@ final class SystemMonitor: ObservableObject { networkInfo = live.networkInfo gpuInfo = live.gpuInfo powerInfo = live.powerInfo + thermalInfo = live.thermalInfo cpuHistory = live.cpuHistory coreHistories = live.coreHistories memoryHistory = live.memoryHistory diff --git a/TopManager/Services/TemperatureMonitor.swift b/TopManager/Services/TemperatureMonitor.swift new file mode 100644 index 0000000..c894811 --- /dev/null +++ b/TopManager/Services/TemperatureMonitor.swift @@ -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? + +@_silgen_name("IOHIDEventSystemClientSetMatching") +private func IOHIDEventSystemClientSetMatching(_ client: AnyObject, _ matching: CFDictionary) -> Int32 + +@_silgen_name("IOHIDEventSystemClientCopyServices") +private func IOHIDEventSystemClientCopyServices(_ client: AnyObject) -> Unmanaged? + +@_silgen_name("IOHIDServiceClientCopyProperty") +private func IOHIDServiceClientCopyProperty(_ service: AnyObject, _ key: CFString) -> Unmanaged? + +@_silgen_name("IOHIDServiceClientCopyEvent") +private func IOHIDServiceClientCopyEvent(_ service: AnyObject, _ type: Int64, _ options: Int32, + _ timestamp: Int64) -> Unmanaged? + +@_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 + } +} diff --git a/TopManager/Views/PowerStorage/PowerStorageView.swift b/TopManager/Views/PowerStorage/PowerStorageView.swift index eea5a0b..1c3b03e 100644 --- a/TopManager/Views/PowerStorage/PowerStorageView.swift +++ b/TopManager/Views/PowerStorage/PowerStorageView.swift @@ -37,6 +37,18 @@ struct PowerStorageView: View { 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 { HStack { Text("CPU Usage:") @@ -190,6 +202,15 @@ struct PowerStorageView: View { .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 { diff --git a/TopManagerTests/ThermalMathTests.swift b/TopManagerTests/ThermalMathTests.swift new file mode 100644 index 0000000..6fc530a --- /dev/null +++ b/TopManagerTests/ThermalMathTests.swift @@ -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) + } +}