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) + } +}