Compare commits

...
Author SHA1 Message Date
gronod fda8bf9e49 feat(telemetry): implement motherboard and component thermal provider (fixes #18)
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Canceled after 0s
2026-09-08 12:23:57 +01:00
gronod feceb2412d Merge branch 'feat/4-fan-telemetry' into milestone/m2-primary-telemetry 2026-09-08 12:22:24 +01:00
4 changed files with 297 additions and 0 deletions
@@ -17,5 +17,6 @@
#import "MMStorageTelemetryProvider.h"
#import "MMCPUThermalProvider.h"
#import "MMFanTelemetryProvider.h"
#import "MMComponentThermalProvider.h"
#endif /* MacMonitor_Bridging_Header_h */
@@ -0,0 +1,42 @@
#ifndef MMComponentThermalProvider_h
#define MMComponentThermalProvider_h
#import <Foundation/Foundation.h>
#import "MMTelemetryProvider.h"
#import "MMAppleSMCClient.h"
NS_ASSUME_NONNULL_BEGIN
/**
* MMComponentThermalProvider
* Samples motherboard, PCH chipset, heatsink, memory, and enclosure ambient temperatures
* via AppleSMC.
*/
@interface MMComponentThermalProvider : NSObject <MMTelemetryProvider>
@property (nonatomic, strong, readonly) MMAppleSMCClient *smcClient;
@property (nonatomic, readonly) NSArray<NSString *> *discoveredKeys;
- (instancetype)init;
- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client;
/**
* Categorize SMC temperature key into component domain (e.g., PCH / Chipset, Heatsink, Memory, etc.)
*/
+ (NSString *)categoryForKey:(NSString *)key;
/**
* Human-readable friendly name for known component SMC keys.
*/
+ (NSString *)humanReadableNameForKey:(NSString *)key;
/**
* Static calculator method for deterministic unit testing.
*/
+ (NSDictionary<NSString *, id> *)calculateComponentMetricsFromDictionary:(NSDictionary<NSString *, NSNumber *> *)readings;
@end
NS_ASSUME_NONNULL_END
#endif /* MMComponentThermalProvider_h */
@@ -0,0 +1,174 @@
#import "MMComponentThermalProvider.h"
@interface MMComponentThermalProvider () {
MMAppleSMCClient *_smcClient;
NSMutableArray<NSString *> *_discoveredKeys;
BOOL _probed;
}
@end
@implementation MMComponentThermalProvider
- (instancetype)init {
return [self initWithSMCClient:[MMAppleSMCClient sharedClient]];
}
- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client {
self = [super init];
if (self) {
_smcClient = client;
_discoveredKeys = [NSMutableArray array];
[self probeSensors];
}
return self;
}
- (void)probeSensors {
if (!_smcClient.isAvailable) return;
NSArray<NSString *> *candidates = @[
@"TPCD", @"TP0P", @"TP0D",
@"Th0H", @"Th1H", @"Th2H", @"Th3H",
@"TM0P", @"TM1P", @"TM2P", @"TM3P", @"TM0S", @"TM1S",
@"Tp0P", @"Tp1P", @"Tp2P", @"Tp0C",
@"TA0P", @"TA1P", @"TA0S", @"TA1S",
@"TTLD", @"TT1D", @"TB0T", @"TB1T"
];
[_discoveredKeys removeAllObjects];
for (NSString *key in candidates) {
NSNumber *val = [_smcClient readNumericValueForKey:key error:nil];
if (val && [val doubleValue] > 0.0 && [val doubleValue] < 125.0) {
[_discoveredKeys addObject:key];
}
}
_probed = YES;
}
- (MMTelemetryDomain)domain {
return MMTelemetryDomainThermal;
}
- (NSString *)providerIdentifier {
return @"com.i3omb.macmonitor.telemetry.thermal.components";
}
- (BOOL)isAvailable {
return _smcClient.isAvailable;
}
- (MMAppleSMCClient *)smcClient {
return _smcClient;
}
- (NSArray<NSString *> *)discoveredKeys {
return [_discoveredKeys copy];
}
+ (NSString *)categoryForKey:(NSString *)key {
if ([key hasPrefix:@"TP"]) return @"PCH / Chipset";
if ([key hasPrefix:@"Th"]) return @"Heatsink";
if ([key hasPrefix:@"TM"]) return @"Memory";
if ([key hasPrefix:@"Tp"]) return @"Power Supply";
if ([key hasPrefix:@"TA"]) return @"Ambient / Enclosure";
if ([key hasPrefix:@"TT"] || [key hasPrefix:@"TB"] || [key hasPrefix:@"TI"]) return @"Thunderbolt / IO";
return @"Other";
}
+ (NSString *)humanReadableNameForKey:(NSString *)key {
static NSDictionary<NSString *, NSString *> *names = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
names = @{
@"TPCD": @"Platform Controller Hub (Die)",
@"TP0P": @"Platform Controller Hub (Proximity)",
@"TP0D": @"Platform Controller Hub (Die Alternate)",
@"Th0H": @"Main Heatsink A",
@"Th1H": @"Main Heatsink B",
@"Th2H": @"Main Heatsink C",
@"Th3H": @"Main Heatsink D",
@"TM0P": @"Memory Bank 0 Proximity",
@"TM1P": @"Memory Bank 1 Proximity",
@"TM2P": @"Memory Bank 2 Proximity",
@"TM3P": @"Memory Bank 3 Proximity",
@"TM0S": @"Memory Module 0",
@"TM1S": @"Memory Module 1",
@"Tp0P": @"Power Supply Primary",
@"Tp1P": @"Power Supply Secondary",
@"Tp2P": @"Power Supply Tertiary",
@"Tp0C": @"Power Supply Core",
@"TA0P": @"Ambient Air Intake",
@"TA1P": @"Ambient Air Exhaust",
@"TA0S": @"Ambient Air Enclosure",
@"TA1S": @"Ambient Air Secondary",
@"TTLD": @"Thunderbolt Controller Die",
@"TT1D": @"Thunderbolt Controller Secondary",
@"TB0T": @"Thunderbolt Port 0",
@"TB1T": @"Thunderbolt Port 1"
};
});
NSString *match = names[key];
return match ?: key;
}
+ (NSDictionary<NSString *, id> *)calculateComponentMetricsFromDictionary:(NSDictionary<NSString *, NSNumber *> *)readings {
NSMutableArray<NSDictionary<NSString *, id> *> *components = [NSMutableArray arrayWithCapacity:readings.count];
NSMutableDictionary<NSString *, NSMutableArray<NSDictionary<NSString *, id> *> *> *categorized = [NSMutableDictionary dictionary];
NSArray<NSString *> *sortedKeys = [readings.allKeys sortedArrayUsingSelector:@selector(compare:)];
double peak = 0.0;
double sum = 0.0;
for (NSString *k in sortedKeys) {
double temp = [readings[k] doubleValue];
if (temp > peak) peak = temp;
sum += temp;
NSString *cat = [self categoryForKey:k];
NSString *name = [self humanReadableNameForKey:k];
NSDictionary<NSString *, id> *entry = @{
@"key": k,
@"name": name,
@"category": cat,
@"temperature": @(temp)
};
[components addObject:entry];
if (!categorized[cat]) {
categorized[cat] = [NSMutableArray array];
}
[categorized[cat] addObject:entry];
}
double avg = readings.count > 0 ? (sum / (double)readings.count) : 0.0;
return @{
@"components": components,
@"categorized": categorized,
@"sensorCount": @(readings.count),
@"peakTemperature": @(peak),
@"averageTemperature": @(avg)
};
}
- (nullable NSDictionary<NSString *, id> *)sampleTelemetryWithError:(NSError **)error {
if (!_probed && _smcClient.isAvailable) {
[self probeSensors];
}
NSMutableDictionary<NSString *, NSNumber *> *readings = [NSMutableDictionary dictionary];
for (NSString *key in _discoveredKeys) {
NSNumber *temp = [_smcClient readNumericValueForKey:key error:nil];
if (temp) {
readings[key] = temp;
}
}
return [MMComponentThermalProvider calculateComponentMetricsFromDictionary:readings];
}
@end
+80
View File
@@ -0,0 +1,80 @@
import XCTest
@testable import MacMonitor
final class MMComponentThermalTests: XCTestCase {
func testCategoryClassification() {
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TPCD"), "PCH / Chipset")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TP0P"), "PCH / Chipset")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "Th0H"), "Heatsink")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "Th1H"), "Heatsink")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TM0P"), "Memory")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TM1S"), "Memory")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "Tp0P"), "Power Supply")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TA0P"), "Ambient / Enclosure")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TTLD"), "Thunderbolt / IO")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TB0T"), "Thunderbolt / IO")
XCTAssertEqual(MMComponentThermalProvider.category(forKey: "ZZ99"), "Other")
}
func testHumanReadableNames() {
XCTAssertEqual(MMComponentThermalProvider.humanReadableName(forKey: "TPCD"), "Platform Controller Hub (Die)")
XCTAssertEqual(MMComponentThermalProvider.humanReadableName(forKey: "Th0H"), "Main Heatsink A")
XCTAssertEqual(MMComponentThermalProvider.humanReadableName(forKey: "TA0P"), "Ambient Air Intake")
XCTAssertEqual(MMComponentThermalProvider.humanReadableName(forKey: "CUSTOM"), "CUSTOM")
}
func testComponentMetricsCalculation() {
let readings: [String: NSNumber] = [
"TPCD": NSNumber(value: 65.5),
"Th0H": NSNumber(value: 48.0),
"TM0P": NSNumber(value: 42.0),
"TA0P": NSNumber(value: 28.5)
]
let metrics = MMComponentThermalProvider.calculateComponentMetrics(from: readings)
let peak = metrics["peakTemperature"] as? Double ?? 0
let avg = metrics["averageTemperature"] as? Double ?? 0
let count = metrics["sensorCount"] as? Int ?? 0
let components = metrics["components"] as? [[String: Any]] ?? []
let categorized = metrics["categorized"] as? [String: [[String: Any]]] ?? [:]
XCTAssertEqual(peak, 65.5, accuracy: 0.1)
XCTAssertEqual(avg, (65.5 + 48.0 + 42.0 + 28.5) / 4.0, accuracy: 0.1)
XCTAssertEqual(count, 4)
XCTAssertEqual(components.count, 4)
let pchList = categorized["PCH / Chipset"]
XCTAssertNotNil(pchList)
let firstPchName = pchList?.first?["name"] as? String
XCTAssertEqual(firstPchName, "Platform Controller Hub (Die)")
XCTAssertEqual(categorized["Heatsink"]?.count, 1)
XCTAssertEqual(categorized["Memory"]?.count, 1)
XCTAssertEqual(categorized["Ambient / Enclosure"]?.count, 1)
}
func testEmptyReadingsCalculation() {
let metrics = MMComponentThermalProvider.calculateComponentMetrics(from: [:])
let peak = metrics["peakTemperature"] as? Double ?? -1
let avg = metrics["averageTemperature"] as? Double ?? -1
let count = metrics["sensorCount"] as? Int ?? -1
XCTAssertEqual(peak, 0.0)
XCTAssertEqual(avg, 0.0)
XCTAssertEqual(count, 0)
}
func testLiveComponentThermalProvider() throws {
let provider = MMComponentThermalProvider()
XCTAssertEqual(provider.domain, .thermal)
XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.thermal.components")
let sample = try provider.sampleTelemetry()
XCTAssertNotNil(sample)
XCTAssertNotNil(sample["sensorCount"])
XCTAssertNotNil(sample["components"])
XCTAssertNotNil(sample["categorized"])
}
}