Skip to content

Commit a8242e1

Browse files
committed
Add individual manual fan control
1 parent 49b780c commit a8242e1

4 files changed

Lines changed: 157 additions & 17 deletions

File tree

Core-Monitor.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@
456456
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
457457
CODE_SIGN_STYLE = Automatic;
458458
COMBINE_HIDPI_IMAGES = YES;
459-
CURRENT_PROJECT_VERSION = 15000;
459+
CURRENT_PROJECT_VERSION = 15100;
460460
DEAD_CODE_STRIPPING = YES;
461461
DEVELOPMENT_TEAM = 6VDP675K4L;
462462
ENABLE_APP_SANDBOX = NO;
@@ -473,7 +473,7 @@
473473
"@executable_path/../Frameworks",
474474
);
475475
LIBRARY_SEARCH_PATHS = "$(inherited)";
476-
MARKETING_VERSION = 15;
476+
MARKETING_VERSION = 15.1;
477477
OTHER_LDFLAGS = "";
478478
PRODUCT_BUNDLE_IDENTIFIER = "CoreTools.Core-Monitor";
479479
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -504,7 +504,7 @@
504504
CODE_SIGN_ENTITLEMENTS = "Core-Monitor-WeatherKit.entitlements";
505505
CODE_SIGN_STYLE = Automatic;
506506
COMBINE_HIDPI_IMAGES = YES;
507-
CURRENT_PROJECT_VERSION = 15000;
507+
CURRENT_PROJECT_VERSION = 15100;
508508
DEAD_CODE_STRIPPING = YES;
509509
DEVELOPMENT_TEAM = 6VDP675K4L;
510510
ENABLE_APP_SANDBOX = NO;
@@ -521,7 +521,7 @@
521521
"@executable_path/../Frameworks",
522522
);
523523
LIBRARY_SEARCH_PATHS = "$(inherited)";
524-
MARKETING_VERSION = 15;
524+
MARKETING_VERSION = 15.1;
525525
OTHER_LDFLAGS = "";
526526
PRODUCT_BUNDLE_IDENTIFIER = "$(CORE_MONITOR_APP_BUNDLE_IDENTIFIER)";
527527
PRODUCT_NAME = "$(TARGET_NAME)";

Core-Monitor/ContentView.swift

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,19 @@ private struct NativeFansPage: View {
390390
if fanController.mode == .manual {
391391
NativeDivider()
392392
VStack(alignment: .leading, spacing: 8) {
393-
LabeledContent("Target Speed", value: "\(fanController.manualSpeed) RPM")
394-
Slider(value: manualSpeedBinding, in: Double(fanController.minSpeed)...Double(fanController.maxSpeed), step: 50)
393+
if manualFanCount > 1 {
394+
ForEach(0..<manualFanCount, id: \.self) { fanID in
395+
LabeledContent("Fan \(fanID + 1) Target", value: "\(fanController.manualSpeed(for: fanID)) RPM")
396+
Slider(
397+
value: manualSpeedBinding(for: fanID),
398+
in: manualSpeedRange(for: fanID),
399+
step: 50
400+
)
401+
}
402+
} else {
403+
LabeledContent("Target Speed", value: "\(fanController.manualSpeed) RPM")
404+
Slider(value: manualSpeedBinding, in: Double(fanController.minSpeed)...Double(fanController.maxSpeed), step: 50)
405+
}
395406
}
396407
}
397408

@@ -465,6 +476,28 @@ private struct NativeFansPage: View {
465476
}
466477
}
467478

479+
private var manualFanCount: Int {
480+
max(snapshot.fanSpeeds.count, snapshot.numberOfFans, fanController.manualFanSpeeds.count)
481+
}
482+
483+
private func manualSpeedBinding(for fanID: Int) -> Binding<Double> {
484+
Binding {
485+
Double(fanController.manualSpeed(for: fanID))
486+
} set: { value in
487+
fanController.setManualFanSpeed(Int(value.rounded()), for: fanID)
488+
}
489+
}
490+
491+
private func manualSpeedRange(for fanID: Int) -> ClosedRange<Double> {
492+
let minimum = snapshot.fanMinSpeeds.indices.contains(fanID)
493+
? snapshot.fanMinSpeeds[fanID]
494+
: fanController.minSpeed
495+
let maximum = snapshot.fanMaxSpeeds.indices.contains(fanID)
496+
? snapshot.fanMaxSpeeds[fanID]
497+
: fanController.maxSpeed
498+
return Double(min(minimum, maximum))...Double(max(minimum, maximum))
499+
}
500+
468501
private var autoAggressivenessBinding: Binding<Double> {
469502
Binding {
470503
fanController.autoAggressiveness

Core-Monitor/FanController.swift

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ enum FanControlMode: String, CaseIterable {
105105
)
106106
case .manual:
107107
return FanModeGuidance(
108-
summary: "Writes one fixed RPM target across every controllable fan until you reset or quit.",
109-
detail: "Best for short debugging sessions when you know the exact airflow target you want.",
108+
summary: "Writes fixed RPM targets per controllable fan until you reset or quit.",
109+
detail: "Best for short debugging sessions when you know the exact airflow target each fan should hold.",
110110
ownership: .coreMonitor,
111111
helperRequirement: .managedControl,
112112
restoresSystemAutomaticOnExit: true,
@@ -381,6 +381,7 @@ final class FanController: ObservableObject {
381381

382382
@Published var mode: FanControlMode = FanController.defaultMode
383383
@Published var manualSpeed: Int = 2200
384+
@Published var manualFanSpeeds: [Int] = []
384385
@Published var autoAggressiveness: Double = 1.5
385386
@Published var autoMaxSpeed: Int = 6500
386387
@Published var statusMessage: String = "Idle"
@@ -397,6 +398,7 @@ final class FanController: ObservableObject {
397398
private weak var systemMonitor: SystemMonitor?
398399
private var controlTimer: Timer?
399400
private var lastAppliedSpeed: Int = 0
401+
private var lastAppliedManualSpeeds: [Int] = []
400402
private let helperManager = SMCHelperManager.shared
401403
private var workspaceObservers: [NSObjectProtocol] = []
402404
private var customPreset: CustomFanPreset?
@@ -428,6 +430,20 @@ final class FanController: ObservableObject {
428430
return String(decoding: data, as: UTF8.self)
429431
}
430432

433+
static func normalizedManualFanSpeeds(
434+
_ speeds: [Int],
435+
fanCount: Int,
436+
fallback: Int,
437+
minSpeed: Int,
438+
maxSpeed: Int
439+
) -> [Int] {
440+
guard fanCount > 0 else { return [] }
441+
return (0..<fanCount).map { index in
442+
let rawSpeed = index < speeds.count ? speeds[index] : fallback
443+
return max(minSpeed, min(maxSpeed, rawSpeed))
444+
}
445+
}
446+
431447
var customPresetFilePath: String {
432448
customPresetFileURL().path
433449
}
@@ -457,15 +473,42 @@ final class FanController: ObservableObject {
457473
}
458474
self.mode = resolvedMode
459475
lastAppliedSpeed = 0
476+
lastAppliedManualSpeeds = []
460477
saveSettings()
461478
applyCurrentMode(force: true)
462479
}
463480

464481
func setManualSpeed(_ speed: Int) {
465482
manualSpeed = max(minSpeed, min(maxSpeed, speed))
483+
let fanCount = max(resolvedFanCount(), manualFanSpeeds.count, 1)
484+
manualFanSpeeds = Array(repeating: manualSpeed, count: fanCount)
485+
lastAppliedManualSpeeds = []
466486
saveSettings()
467487
guard mode == .manual else { return }
468-
applyFanSpeed(manualSpeed)
488+
applyManualFanSpeeds(force: true)
489+
}
490+
491+
func manualSpeed(for fanID: Int) -> Int {
492+
guard fanID >= 0 else { return manualSpeed }
493+
return manualFanSpeeds.indices.contains(fanID) ? manualFanSpeeds[fanID] : manualSpeed
494+
}
495+
496+
func setManualFanSpeed(_ speed: Int, for fanID: Int) {
497+
guard fanID >= 0 else { return }
498+
let fanCount = max(resolvedFanCount(), fanID + 1)
499+
var speeds = Self.normalizedManualFanSpeeds(
500+
manualFanSpeeds,
501+
fanCount: fanCount,
502+
fallback: manualSpeed,
503+
minSpeed: minSpeed,
504+
maxSpeed: maxSpeed
505+
)
506+
speeds[fanID] = max(minSpeed, min(maxSpeed, speed))
507+
manualFanSpeeds = speeds
508+
manualSpeed = speeds.first ?? manualSpeed
509+
saveSettings()
510+
guard mode == .manual else { return }
511+
applyManualFanSpeeds(force: true)
469512
}
470513

471514
func setAutoAggressiveness(_ value: Double) {
@@ -717,9 +760,7 @@ final class FanController: ObservableObject {
717760
}
718761
lastAppliedSpeed = -1
719762
case .manual:
720-
applyFanSpeed(manualSpeed)
721-
lastAppliedSpeed = manualSpeed
722-
statusMessage = "Manual: \(manualSpeed) RPM"
763+
applyManualFanSpeeds(force: true)
723764
startControlLoop()
724765
case .smart, .balanced, .performance, .max, .custom:
725766
startControlLoop()
@@ -731,11 +772,7 @@ final class FanController: ObservableObject {
731772

732773
switch mode {
733774
case .manual:
734-
if abs(manualSpeed - lastAppliedSpeed) >= 50 || lastAppliedSpeed == 0 {
735-
applyFanSpeed(manualSpeed)
736-
lastAppliedSpeed = manualSpeed
737-
}
738-
statusMessage = "Manual: \(manualSpeed) RPM"
775+
applyManualFanSpeeds(force: false)
739776

740777
case .automatic, .silent:
741778
if Self.shouldRequestSystemAutomaticHandoff(lastAppliedSpeed: lastAppliedSpeed) {
@@ -916,6 +953,42 @@ final class FanController: ObservableObject {
916953
_ = applyPerFanSpeeds(speeds, successMessage: "Applied \(speed) RPM")
917954
}
918955

956+
private func applyManualFanSpeeds(force: Bool) {
957+
let fanCount = max(resolvedFanCount(), 1)
958+
let speeds = Self.normalizedManualFanSpeeds(
959+
manualFanSpeeds,
960+
fanCount: fanCount,
961+
fallback: manualSpeed,
962+
minSpeed: minSpeed,
963+
maxSpeed: maxSpeed
964+
)
965+
966+
manualFanSpeeds = speeds
967+
manualSpeed = speeds.first ?? manualSpeed
968+
969+
let shouldApply = force
970+
|| lastAppliedManualSpeeds.count != speeds.count
971+
|| zip(speeds, lastAppliedManualSpeeds).contains { current, previous in
972+
abs(current - previous) >= 50
973+
}
974+
|| lastAppliedSpeed == 0
975+
976+
if shouldApply {
977+
_ = applyPerFanSpeeds(speeds, successMessage: manualStatusMessage(for: speeds))
978+
lastAppliedManualSpeeds = speeds
979+
lastAppliedSpeed = speeds.max() ?? 0
980+
} else {
981+
statusMessage = manualStatusMessage(for: speeds)
982+
}
983+
}
984+
985+
private func manualStatusMessage(for speeds: [Int]) -> String {
986+
guard speeds.count > 1 else {
987+
return "Manual: \(speeds.first ?? manualSpeed) RPM"
988+
}
989+
return "Manual: " + speeds.map { "\($0)" }.joined(separator: " / ") + " RPM"
990+
}
991+
919992
@discardableResult
920993
private func applyPerFanSpeeds(_ requestedSpeeds: [Int], successMessage: String?) -> Bool {
921994
guard let monitor = systemMonitor else { return false }
@@ -1150,6 +1223,15 @@ final class FanController: ObservableObject {
11501223
manualSpeed = savedSpeed
11511224
}
11521225

1226+
let savedPerFanSpeeds = defaults.array(forKey: "manualFanSpeeds") as? [Int] ?? []
1227+
manualFanSpeeds = Self.normalizedManualFanSpeeds(
1228+
savedPerFanSpeeds,
1229+
fanCount: savedPerFanSpeeds.count,
1230+
fallback: manualSpeed,
1231+
minSpeed: minSpeed,
1232+
maxSpeed: maxSpeed
1233+
)
1234+
11531235
let savedAggr = defaults.double(forKey: "autoAggressiveness")
11541236
if savedAggr >= 0.0 && savedAggr <= 3.0 {
11551237
autoAggressiveness = savedAggr
@@ -1165,6 +1247,7 @@ final class FanController: ObservableObject {
11651247
let defaults = UserDefaults.standard
11661248
defaults.set(mode.canonicalMode.rawValue, forKey: "fanControlMode")
11671249
defaults.set(manualSpeed, forKey: "manualFanSpeed")
1250+
defaults.set(manualFanSpeeds, forKey: "manualFanSpeeds")
11681251
defaults.set(autoAggressiveness, forKey: "autoAggressiveness")
11691252
defaults.set(autoMaxSpeed, forKey: "autoMaxSpeed")
11701253
}

Core-MonitorTests/CustomFanPresetTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,30 @@ final class CustomFanPresetTests: XCTestCase {
199199
XCTAssertTrue(FanController.shouldRequestSystemAutomaticHandoff(lastAppliedSpeed: 1800))
200200
}
201201

202+
func testManualFanSpeedsExpandFromSharedFallback() {
203+
let speeds = FanController.normalizedManualFanSpeeds(
204+
[1800],
205+
fanCount: 3,
206+
fallback: 2200,
207+
minSpeed: 1000,
208+
maxSpeed: 6500
209+
)
210+
211+
XCTAssertEqual(speeds, [1800, 2200, 2200])
212+
}
213+
214+
func testManualFanSpeedsClampEachFanTarget() {
215+
let speeds = FanController.normalizedManualFanSpeeds(
216+
[600, 2400, 7200],
217+
fanCount: 3,
218+
fallback: 2200,
219+
minSpeed: 1000,
220+
maxSpeed: 6500
221+
)
222+
223+
XCTAssertEqual(speeds, [1000, 2400, 6500])
224+
}
225+
202226
func testSilentCanonicalizesToSystemAutomatic() {
203227
XCTAssertEqual(FanControlMode.silent.canonicalMode, .automatic)
204228
XCTAssertFalse(FanControlMode.silent.requiresPrivilegedHelper)

0 commit comments

Comments
 (0)