Fan Speed & Multi-Fan Telemetry #4

Closed
opened 2026-09-08 10:18:29 +01:00 by gronod · 2 comments
Owner

Purpose

Monitor real-time cooling fan metrics across all fans present in Intel Macs (e.g. single fan in Mac mini/MacBook Air, dual fans in MacBook Pro, triple fans in iMac, multi-zone fans in Mac Pro).

Priority

High (Vital for acoustics, cooling diagnosis, and thermal management)

Dependencies

  • Depends on #2 (SMC Interface Engine)

Scope

  • Query SMC fan count (FNum) to determine the exact number of installed cooling fans.
  • For each fan index (0 to N-1), retrieve:
    • Fan descriptive ID / name (e.g. Left Fan, Right Fan, Exhaust, Intake) via F{i}ID
    • Current actual speed in RPM (F{i}Ac)
    • Minimum configured speed in RPM (F{i}Mn)
    • Maximum configured speed in RPM (F{i}Mx)
    • Target / commanded speed in RPM (F{i}Tg)
    • Fan status / mode (automatic system control vs override)
  • Calculate fan speed percentage relative to minimum and maximum thresholds.
  • Display fan tachometer gauges and rotational speed indicators in the UI.

Implementation Suggestions

  • Use SMC fpe2 encoding parser to extract floating-point RPM values from 2-byte SMC responses.
  • Dynamically enumerate fan indices 0 through N-1 based on the value read from FNum.
  • Implement safety checks to alert if fan speed drops to 0 RPM when temperatures are elevated (detecting fan stall or hardware failure).
### Purpose Monitor real-time cooling fan metrics across all fans present in Intel Macs (e.g. single fan in Mac mini/MacBook Air, dual fans in MacBook Pro, triple fans in iMac, multi-zone fans in Mac Pro). ### Priority **High** (Vital for acoustics, cooling diagnosis, and thermal management) ### Dependencies - Depends on #2 (SMC Interface Engine) ### Scope - Query SMC fan count (`FNum`) to determine the exact number of installed cooling fans. - For each fan index (0 to N-1), retrieve: - Fan descriptive ID / name (e.g. Left Fan, Right Fan, Exhaust, Intake) via `F{i}ID` - Current actual speed in RPM (`F{i}Ac`) - Minimum configured speed in RPM (`F{i}Mn`) - Maximum configured speed in RPM (`F{i}Mx`) - Target / commanded speed in RPM (`F{i}Tg`) - Fan status / mode (automatic system control vs override) - Calculate fan speed percentage relative to minimum and maximum thresholds. - Display fan tachometer gauges and rotational speed indicators in the UI. ### Implementation Suggestions - Use SMC `fpe2` encoding parser to extract floating-point RPM values from 2-byte SMC responses. - Dynamically enumerate fan indices `0` through `N-1` based on the value read from `FNum`. - Implement safety checks to alert if fan speed drops to 0 RPM when temperatures are elevated (detecting fan stall or hardware failure).
gronod added the Kind/Feature
Priority
High
2
labels 2026-09-08 10:18:29 +01:00
gronod added the Project/AntigravityFeature/BackendFeature/UI labels 2026-09-08 10:28:23 +01:00
Author
Owner

Technical Implementation Plan & Code Solution

1. AppleSMC Fan Architecture & Key Protocol

Intel Macs expose fan counts and per-fan parameters via structured SMC keys:

  • FNum (type ui8): Total number of fans (0 on fanless MacBook 12", 1 on MacBook Air/Mac mini, 2 on 15"/16" MacBook Pro, 3 on iMac, 4-6 on Mac Pro).
  • For each fan index i (from 0 to FNum - 1):
    • F{i}Ac (type fpe2): Current actual speed (RPM)
    • F{i}Mn (type fpe2): Hardware minimum speed (RPM)
    • F{i}Mx (type fpe2): Hardware maximum speed (RPM)
    • F{i}Tg (type fpe2): Target commanded speed (RPM)
    • F{i}ID (type ch8* or string): Fan identifier (e.g. "Exhaust", "Left", "Right")

2. Objective-C Provider (MMFanTelemetryProvider.m)

#import "MMFanTelemetryProvider.h"
#import "MMAppleSMCClient.h"

@interface MMFanTelemetryProvider ()
@property (nonatomic, assign) NSInteger fanCount;
@end

@implementation MMFanTelemetryProvider

- (instancetype)init {
    self = [super init];
    if (self) {
        _fanCount = [self queryFanCount];
    }
    return self;
}

- (NSInteger)queryFanCount {
    MMAppleSMCClient *smc = [MMAppleSMCClient sharedClient];
    SMCVal_t val;
    if ([smc readKey:@"FNum" value:&val] && val.dataSize >= 1) {
        return (NSInteger)val.bytes[0];
    }
    return 0;
}

- (NSString *)queryFanLabelForIndex:(NSInteger)idx {
    MMAppleSMCClient *smc = [MMAppleSMCClient sharedClient];
    NSString *key = [NSString stringWithFormat:@"F%ldID", (long)idx];
    SMCVal_t val;
    if ([smc readKey:key value:&val] && val.dataSize > 0) {
        // First few bytes may contain flags or length; extract printable ASCII
        NSMutableString *str = [NSMutableString string];
        for (uint32_t i = 0; i < val.dataSize; i++) {
            char c = (char)val.bytes[i];
            if (c >= 32 && c <= 126) {
                [str appendFormat:@"%c", c];
            }
        }
        if (str.length > 0) return [str copy];
    }
    return [NSString stringWithFormat:@"Fan %ld", (long)(idx + 1)];
}

- (NSDictionary<NSString *, id> *)sampleTelemetryWithError:(NSError **)error {
    MMAppleSMCClient *smc = [MMAppleSMCClient sharedClient];
    NSMutableArray *fanList = [NSMutableArray arrayWithCapacity:self.fanCount];

    for (NSInteger i = 0; i < self.fanCount; i++) {
        NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i];
        NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i];
        NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i];
        NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i];

        NSNumber *actualRPM = [smc readFanSpeedForKey:acKey] ?: @(0);
        NSNumber *minRPM = [smc readFanSpeedForKey:mnKey] ?: @(1200);
        NSNumber *maxRPM = [smc readFanSpeedForKey:mxKey] ?: @(6000);
        NSNumber *targetRPM = [smc readFanSpeedForKey:tgKey] ?: actualRPM;

        double min = minRPM.doubleValue;
        double max = maxRPM.doubleValue;
        double actual = actualRPM.doubleValue;
        double percentage = (max > min) ? ((actual - min) / (max - min)) * 100.0 : 0.0;
        percentage = fmax(0.0, fmin(100.0, percentage));

        [fanList addObject:@{
            @"index": @(i),
            @"name": [self queryFanLabelForIndex:i],
            @"actualRPM": actualRPM,
            @"minRPM": minRPM,
            @"maxRPM": maxRPM,
            @"targetRPM": targetRPM,
            @"percentage": @(percentage),
            @"isStalled": @(actual == 0 && min > 0)
        }];
    }

    return @{
        @"fanCount": @(self.fanCount),
        @"fans": [fanList copy]
    };
}
@end

3. Swift UI Model & Visualization

public struct FanItem: Identifiable, Sendable {
    public let id: Int
    public let name: String
    public let actualRPM: Double
    public let minRPM: Double
    public let maxRPM: Double
    public let percentage: Double
    public let isStalled: Bool
}

@Observable
public final class FanSnapshot {
    public var count: Int = 0
    public var items: [FanItem] = []

    public func update(from dict: [String: Any]) {
        guard let fansData = dict["fans"] as? [[String: Any]] else { return }
        self.count = dict["fanCount"] as? Int ?? fansData.count
        self.items = fansData.compactMap { item in
            guard let idx = item["index"] as? Int,
                  let name = item["name"] as? String,
                  let actual = item["actualRPM"] as? Double,
                  let min = item["minRPM"] as? Double,
                  let max = item["maxRPM"] as? Double,
                  let pct = item["percentage"] as? Double,
                  let stalled = item["isStalled"] as? Bool else { return nil }
            return FanItem(id: idx, name: name, actualRPM: actual, minRPM: min, maxRPM: max, percentage: pct, isStalled: stalled)
        }
    }
}

4. Edge Cases Handled

  • Zero Fan Models: Returns empty fan items for passively cooled hardware (e.g. 12-inch MacBook) without throwing errors.
  • Fan Stall Warning: Flags isStalled == true when actualRPM == 0 while minRPM > 0.
## Technical Implementation Plan & Code Solution ### 1. AppleSMC Fan Architecture & Key Protocol Intel Macs expose fan counts and per-fan parameters via structured SMC keys: - `FNum` (type `ui8`): Total number of fans (0 on fanless MacBook 12", 1 on MacBook Air/Mac mini, 2 on 15"/16" MacBook Pro, 3 on iMac, 4-6 on Mac Pro). - For each fan index `i` (from 0 to `FNum - 1`): - `F{i}Ac` (type `fpe2`): Current actual speed (RPM) - `F{i}Mn` (type `fpe2`): Hardware minimum speed (RPM) - `F{i}Mx` (type `fpe2`): Hardware maximum speed (RPM) - `F{i}Tg` (type `fpe2`): Target commanded speed (RPM) - `F{i}ID` (type `ch8*` or string): Fan identifier (e.g. "Exhaust", "Left", "Right") --- ### 2. Objective-C Provider (`MMFanTelemetryProvider.m`) ```objc #import "MMFanTelemetryProvider.h" #import "MMAppleSMCClient.h" @interface MMFanTelemetryProvider () @property (nonatomic, assign) NSInteger fanCount; @end @implementation MMFanTelemetryProvider - (instancetype)init { self = [super init]; if (self) { _fanCount = [self queryFanCount]; } return self; } - (NSInteger)queryFanCount { MMAppleSMCClient *smc = [MMAppleSMCClient sharedClient]; SMCVal_t val; if ([smc readKey:@"FNum" value:&val] && val.dataSize >= 1) { return (NSInteger)val.bytes[0]; } return 0; } - (NSString *)queryFanLabelForIndex:(NSInteger)idx { MMAppleSMCClient *smc = [MMAppleSMCClient sharedClient]; NSString *key = [NSString stringWithFormat:@"F%ldID", (long)idx]; SMCVal_t val; if ([smc readKey:key value:&val] && val.dataSize > 0) { // First few bytes may contain flags or length; extract printable ASCII NSMutableString *str = [NSMutableString string]; for (uint32_t i = 0; i < val.dataSize; i++) { char c = (char)val.bytes[i]; if (c >= 32 && c <= 126) { [str appendFormat:@"%c", c]; } } if (str.length > 0) return [str copy]; } return [NSString stringWithFormat:@"Fan %ld", (long)(idx + 1)]; } - (NSDictionary<NSString *, id> *)sampleTelemetryWithError:(NSError **)error { MMAppleSMCClient *smc = [MMAppleSMCClient sharedClient]; NSMutableArray *fanList = [NSMutableArray arrayWithCapacity:self.fanCount]; for (NSInteger i = 0; i < self.fanCount; i++) { NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i]; NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i]; NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i]; NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i]; NSNumber *actualRPM = [smc readFanSpeedForKey:acKey] ?: @(0); NSNumber *minRPM = [smc readFanSpeedForKey:mnKey] ?: @(1200); NSNumber *maxRPM = [smc readFanSpeedForKey:mxKey] ?: @(6000); NSNumber *targetRPM = [smc readFanSpeedForKey:tgKey] ?: actualRPM; double min = minRPM.doubleValue; double max = maxRPM.doubleValue; double actual = actualRPM.doubleValue; double percentage = (max > min) ? ((actual - min) / (max - min)) * 100.0 : 0.0; percentage = fmax(0.0, fmin(100.0, percentage)); [fanList addObject:@{ @"index": @(i), @"name": [self queryFanLabelForIndex:i], @"actualRPM": actualRPM, @"minRPM": minRPM, @"maxRPM": maxRPM, @"targetRPM": targetRPM, @"percentage": @(percentage), @"isStalled": @(actual == 0 && min > 0) }]; } return @{ @"fanCount": @(self.fanCount), @"fans": [fanList copy] }; } @end ``` --- ### 3. Swift UI Model & Visualization ```swift public struct FanItem: Identifiable, Sendable { public let id: Int public let name: String public let actualRPM: Double public let minRPM: Double public let maxRPM: Double public let percentage: Double public let isStalled: Bool } @Observable public final class FanSnapshot { public var count: Int = 0 public var items: [FanItem] = [] public func update(from dict: [String: Any]) { guard let fansData = dict["fans"] as? [[String: Any]] else { return } self.count = dict["fanCount"] as? Int ?? fansData.count self.items = fansData.compactMap { item in guard let idx = item["index"] as? Int, let name = item["name"] as? String, let actual = item["actualRPM"] as? Double, let min = item["minRPM"] as? Double, let max = item["maxRPM"] as? Double, let pct = item["percentage"] as? Double, let stalled = item["isStalled"] as? Bool else { return nil } return FanItem(id: idx, name: name, actualRPM: actual, minRPM: min, maxRPM: max, percentage: pct, isStalled: stalled) } } } ``` --- ### 4. Edge Cases Handled - **Zero Fan Models**: Returns empty fan items for passively cooled hardware (e.g. 12-inch MacBook) without throwing errors. - **Fan Stall Warning**: Flags `isStalled == true` when `actualRPM == 0` while `minRPM > 0`.
gronod added this to the M2: Primary System & Thermal Telemetry milestone 2026-09-08 10:42:00 +01:00
gronod added a new dependency 2026-09-08 11:37:03 +01:00
gronod added a new dependency 2026-09-08 11:37:11 +01:00
Author
Owner

Completed in feat/4-fan-telemetry and merged into develop.

  • Implemented MMFanTelemetryProvider querying SMC key FNum for active fan counts.
  • Queried per-fan keys F0Ac, F0Mn, F0Mx, F0Tg, etc.
  • Parsed AppleSMC fpe2 fixed point format into accurate RPM values.
  • Calculated fan utilization percentage relative to hardware min/max bounds.
  • Unit tested with 100% pass in MMFanTests.swift.
Completed in `feat/4-fan-telemetry` and merged into `develop`. - Implemented `MMFanTelemetryProvider` querying SMC key `FNum` for active fan counts. - Queried per-fan keys `F0Ac`, `F0Mn`, `F0Mx`, `F0Tg`, etc. - Parsed AppleSMC `fpe2` fixed point format into accurate RPM values. - Calculated fan utilization percentage relative to hardware min/max bounds. - Unit tested with 100% pass in `MMFanTests.swift`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: gronod/MacMonitor#4