Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions Modules/Sensors/fanProfile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//
// fanProfile.swift
// Sensors
//
// Created for Stats fan profile engine.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2024 Serhiy Mytrovtsiy. All rights reserved.
//

#if arch(arm64)

import Foundation
import Kit

// A single point on the temperature→RPM curve.
public struct CurvePoint: Codable, Equatable {
public var temperatureC: Double
public var rpm: Int

public init(temperatureC: Double, rpm: Int) {
self.temperatureC = temperatureC
self.rpm = rpm
}
}

// A fan profile binding a curve to one fan (by id) or all fans (fanID == -1).
public struct FanProfile: Codable, Identifiable {
public var id: UUID
public var name: String
// fanID == -1 means "all fans"
public var fanID: Int
// Curve points must be ordered ascending by temperatureC.
public var points: [CurvePoint]

public init(id: UUID = UUID(), name: String, fanID: Int, points: [CurvePoint]) {
self.id = id
self.name = name
self.fanID = fanID
self.points = points
}

// Whether this profile is currently controlling fans.
// Stored in Store.shared so it survives restarts without rewriting the JSON.
public var enabled: Bool {
get { Store.shared.bool(key: "fanProfile_\(self.id.uuidString)_enabled", defaultValue: false) }
set { Store.shared.set(key: "fanProfile_\(self.id.uuidString)_enabled", value: newValue) }
}

// Linear interpolation: given a temperature, return the target RPM.
// Below the first point → first point's RPM. Above the last → last point's RPM.
public func targetRPM(forTemperature temp: Double) -> Int {
guard !points.isEmpty else { return 0 }
let sorted = points.sorted { $0.temperatureC < $1.temperatureC }
if temp <= sorted.first!.temperatureC { return sorted.first!.rpm }
if temp >= sorted.last!.temperatureC { return sorted.last!.rpm }

for i in 0..<(sorted.count - 1) {
let lo = sorted[i]
let hi = sorted[i + 1]
if temp >= lo.temperatureC && temp <= hi.temperatureC {
let ratio = (temp - lo.temperatureC) / (hi.temperatureC - lo.temperatureC)
return Int(Double(lo.rpm) + ratio * Double(hi.rpm - lo.rpm))
}
}
return sorted.last!.rpm
}
}

// MARK: - Built-in presets (templates the user can duplicate and edit)

public enum FanProfilePreset: CaseIterable {
case silent, balanced, performance

public var profile: FanProfile {
switch self {
case .silent:
return FanProfile(
name: "Silent",
fanID: -1,
points: [
CurvePoint(temperatureC: 40, rpm: 1200),
CurvePoint(temperatureC: 60, rpm: 1800),
CurvePoint(temperatureC: 75, rpm: 2800),
CurvePoint(temperatureC: 90, rpm: 4000)
]
)
case .balanced:
return FanProfile(
name: "Balanced",
fanID: -1,
points: [
CurvePoint(temperatureC: 40, rpm: 1500),
CurvePoint(temperatureC: 60, rpm: 2500),
CurvePoint(temperatureC: 75, rpm: 3500),
CurvePoint(temperatureC: 90, rpm: 5000)
]
)
case .performance:
// Ramps aggressively from 50°C onward
return FanProfile(
name: "Performance",
fanID: -1,
points: [
CurvePoint(temperatureC: 40, rpm: 2000),
CurvePoint(temperatureC: 50, rpm: 3000),
CurvePoint(temperatureC: 65, rpm: 4500),
CurvePoint(temperatureC: 80, rpm: 6000)
]
)
}
}
}

// MARK: - JSON persistence

// Profile bodies (curve data) live in a JSON file in Application Support.
// Enabled flags live in Store.shared so they round-trip through Stats' normal prefs.
public class FanProfileStore {
private static let fileName = "fan-profiles.json"

private static var fileURL: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("Stats")
try? FileManager.default.createDirectory(at: support, withIntermediateDirectories: true)
return support.appendingPathComponent(fileName)
}

public static func load() -> [FanProfile] {
guard let data = try? Data(contentsOf: fileURL) else { return [] }
return (try? JSONDecoder().decode([FanProfile].self, from: data)) ?? []
}

public static func save(_ profiles: [FanProfile]) {
guard let data = try? JSONEncoder().encode(profiles) else { return }
try? data.write(to: fileURL, options: .atomic)
}
}

#endif
130 changes: 130 additions & 0 deletions Modules/Sensors/fanProfileEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//
// fanProfileEngine.swift
// Sensors
//
// Created for Stats fan profile engine.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2024 Serhiy Mytrovtsiy. All rights reserved.
//

#if arch(arm64)

import Foundation
import Kit

// Minimum RPM delta before we issue a new setFanSpeed call.
// Prevents constant SMC writes when the temperature hovers around a curve knee.
private let hysteresisThreshold: Int = 100

// Cap how often the engine acts on a temperature tick regardless of how fast
// the sensor reader fires.
private let minTickInterval: TimeInterval = 1.0

public class FanProfileEngine {
public static let shared = FanProfileEngine()

private var profiles: [FanProfile] = []
// Last RPM written to each fan id, used for hysteresis.
private var lastSetRPM: [Int: Int] = [:]
private var lastTickDate: Date = .distantPast
private let queue = DispatchQueue(label: "eu.exelban.Stats.FanProfileEngine", qos: .utility)

// Fan min/max bounds populated when the sensor list is available, so we can
// clamp RPM to hardware limits without reaching back to the sensor reader.
// TODO: per-fan minSpeed/maxSpeed — currently clamped to 0…maxSpeed from
// the first fan seen; multi-fan machines may have different ranges.
private var fanBounds: [Int: (min: Double, max: Double)] = [:]

private init() {
self.profiles = FanProfileStore.load()
}

// MARK: - Public API

public var allProfiles: [FanProfile] { profiles }

public func profileForFan(_ fanID: Int) -> FanProfile? {
profiles.first(where: { $0.enabled && ($0.fanID == fanID || $0.fanID == -1) })
}

public func addProfile(_ profile: FanProfile) {
profiles.append(profile)
FanProfileStore.save(profiles)
}

public func updateProfile(_ profile: FanProfile) {
guard let idx = profiles.firstIndex(where: { $0.id == profile.id }) else { return }
profiles[idx] = profile
FanProfileStore.save(profiles)
}

public func removeProfile(id: UUID) {
profiles.removeAll { $0.id == id }
// Clean up the enabled key from Store.
Store.shared.remove("fanProfile_\(id.uuidString)_enabled")
FanProfileStore.save(profiles)
}

// Called by main.swift once the sensor list is known.
public func registerFans(_ fans: [Fan]) {
for fan in fans {
fanBounds[fan.id] = (min: fan.minSpeed, max: fan.maxSpeed)
}
}

// Called from the sensor reader callback in main.swift on every tick.
// sensors: the full Sensors_List.sensors array from the reader callback.
public func processTick(_ sensors: [Sensor_p]) {
queue.async { [weak self] in
guard let self else { return }

let now = Date()
guard now.timeIntervalSince(self.lastTickDate) >= minTickInterval else { return }

let enabledProfiles = self.profiles.filter { $0.enabled }
guard !enabledProfiles.isEmpty else { return }

// Use max CPU temperature, not average. Apple Silicon parks efficiency
// cores under low load and parked cores report very low values (~1.5°C)
// that pull a naive average far below the actual hot-core temperature
// that drives thermals.
let cpuTemps = sensors
.filter { $0.type == .temperature && $0.group == .CPU && $0.value > 5 }
.map { $0.value }
guard let driverTemp = cpuTemps.max() else { return }

self.lastTickDate = now

// Skip pseudo-fans (id < 0 represents aggregates like "fastest fan").
let fans = sensors.compactMap { $0 as? Fan }.filter { $0.id >= 0 }
for fan in fans {
guard let profile = self.profileForFan(fan.id) else { continue }
let target = profile.targetRPM(forTemperature: driverTemp)
let bounds = self.fanBounds[fan.id]
let minRPM = Int(bounds?.min ?? 0)
let maxRPM = bounds.map { Int($0.max) } ?? target
let clamped = max(minRPM, min(maxRPM == 0 ? target : maxRPM, target))

if let last = self.lastSetRPM[fan.id], abs(clamped - last) < hysteresisThreshold { continue }

self.lastSetRPM[fan.id] = clamped
SMCHelper.shared.setFanMode(fan.id, mode: FanMode.forced.rawValue)
SMCHelper.shared.setFanSpeed(fan.id, speed: clamped)
}
}
}

public func releaseAll(fans: [Fan]) {
queue.async {
for fan in fans {
guard self.lastSetRPM[fan.id] != nil else { continue }
SMCHelper.shared.setFanMode(fan.id, mode: FanMode.automatic.rawValue)
}
self.lastSetRPM = [:]
}
}
}

#endif
Loading