Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ Each [release](https://github.com/leaperone/smctl/releases) ships a zip with `sm

Charge limiting and fan control are applied by `smctld`, a root LaunchDaemon. The CLI talks to it over XPC; reading sensors needs no daemon and no root.

`daemon install` copies the helper to `/Library/PrivilegedHelperTools/one.leaper.smctl.daemon`, owned by root, and restarts the service. After `brew upgrade smctl` or rebuilding from source, run `sudo smctl daemon install` again to update this protected copy. Restarting alone does not install the new binary.

Foreground discharge is supervised by the daemon. Reaching the target, losing the CLI connection, or missing renewals restores adapter power. A failed restore is retried; it does not depend on the CLI running an exit handler.

The Homebrew install sets up shell completions (bash/zsh/fish) and a `man smctl` page automatically.

## Safety by design
Expand Down
12 changes: 7 additions & 5 deletions Sources/PolicyEngine/PolicyEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -572,11 +572,7 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable {
private var consecutiveOverCeiling: Int

public init(configuredCeilingCelsius: Double = Self.defaultCeilingCelsius, isLatched: Bool = false) {
if configuredCeilingCelsius <= 0 {
self.configuredCeilingCelsius = Self.defaultCeilingCelsius
} else {
self.configuredCeilingCelsius = min(configuredCeilingCelsius, Self.hardMaximumCeilingCelsius)
}
self.configuredCeilingCelsius = Self.normalizedCeiling(configuredCeilingCelsius)
self.isLatched = isLatched
self.consecutiveOverCeiling = 0
}
Expand All @@ -585,6 +581,8 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable {
/// guard is the only thermal floor (thermalmonitord is hands-off), so *no readable
/// temperature* counts as unsafe — blind manual control is never allowed.
public mutating func evaluate(samples: [FanTemperatureSample], manualPolicyActive: Bool) -> FanSafetyDecision {
configuredCeilingCelsius = Self.normalizedCeiling(configuredCeilingCelsius)
let samples = samples.filter { $0.celsius.isFinite }
// Latch release requires credible readings comfortably below each sensor's
// own effective ceiling. Hotspot-class Tp* readings use a higher ceiling
// than board/skin/proximity sensors.
Expand Down Expand Up @@ -647,6 +645,10 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable {
Self.hotSpotHardMaximumCeilingCelsius
)
}

private static func normalizedCeiling(_ value: Double) -> Double {
value.isFinite && value > 0 ? min(value, hardMaximumCeilingCelsius) : defaultCeilingCelsius
}
}

public struct FanStartupReconciler: Sendable {
Expand Down
45 changes: 35 additions & 10 deletions Sources/SMCCore/FanControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public enum FanControlError: Error, Equatable, CustomStringConvertible {
case unsupported(String)
case missingFan(Int)
case missingModeKey(Int)
case restorationFailed(operation: String, restoration: String)

public var description: String {
switch self {
Expand All @@ -24,6 +25,8 @@ public enum FanControlError: Error, Equatable, CustomStringConvertible {
return "Fan \(index) is not available."
case .missingModeKey(let index):
return "Fan \(index) has no readable mode key; manual fan control is unsupported."
case .restorationFailed(let operation, let restoration):
return "Manual fan operation failed: \(operation). Restoring auto also failed: \(restoration)"
}
}
}
Expand Down Expand Up @@ -144,24 +147,46 @@ public struct FanController {
public func setManual(
index: Int,
rpm: Double,
timing: FanUnlockTiming = .default
timing: FanUnlockTiming = .default,
otherManualFansRemaining: Bool = false
) throws -> FanManualResult {
let fan = try fan(index)
let unlockPath = try unlockManualMode(fan: fan, timing: timing)
try backend.writeKey(
fan.targetKey,
bytes: Self.float32LittleEndianBytes(Float(rpm)),
retryPolicy: Self.singleAttemptRetryPolicy
)
return FanManualResult(index: index, requestedRPM: rpm, unlockPath: unlockPath)
guard fan.modeKey != nil else { throw FanControlError.missingModeKey(index) }
do {
let unlockPath = try unlockManualMode(fan: fan, timing: timing)
try backend.writeKey(
fan.targetKey,
bytes: Self.float32LittleEndianBytes(Float(rpm)),
retryPolicy: Self.singleAttemptRetryPolicy
)
return FanManualResult(index: index, requestedRPM: rpm, unlockPath: unlockPath)
} catch {
try restoreAfterManualFailure(index: index, otherManualFansRemaining: otherManualFansRemaining, operationError: error)
}
}

@discardableResult
public func unlockManualMode(
index: Int,
timing: FanUnlockTiming = .default
timing: FanUnlockTiming = .default,
otherManualFansRemaining: Bool = false
) throws -> FanUnlockPath {
try unlockManualMode(fan: fan(index), timing: timing)
let fan = try fan(index)
guard fan.modeKey != nil else { throw FanControlError.missingModeKey(index) }
do {
return try unlockManualMode(fan: fan, timing: timing)
} catch {
try restoreAfterManualFailure(index: index, otherManualFansRemaining: otherManualFansRemaining, operationError: error)
}
}

private func restoreAfterManualFailure(index: Int, otherManualFansRemaining: Bool, operationError: Error) throws -> Never {
do {
try setAuto(index: index, otherManualFansRemaining: otherManualFansRemaining)
} catch {
throw FanControlError.restorationFailed(operation: String(describing: operationError), restoration: String(describing: error))
}
throw operationError
}

public func setAuto(index: Int, otherManualFansRemaining: Bool) throws {
Expand Down
10 changes: 5 additions & 5 deletions Sources/SMCtlClient/DaemonClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ public final class DaemonClient {
}
}

public func setAdapterEnabled(_ enabled: Bool) throws {
let data = try SMCtlProtocolCoding.encode(SetEnabledRequestDTO(enabled: enabled))
public func setAdapterEnabled(_ enabled: Bool, dischargeTarget: Int? = nil) throws {
let data = try SMCtlProtocolCoding.encode(SetEnabledRequestDTO(enabled: enabled, dischargeTarget: dischargeTarget))
let _: EmptyResponseDTO = try call { proxy, reply in
proxy.setAdapterEnabled(data, withReply: reply)
}
Expand Down Expand Up @@ -174,14 +174,14 @@ public final class DaemonClient {
if ping.version != SMCtlProtocolInfo.version {
emit("""
warning: smctl is \(SMCtlProtocolInfo.version) but the running smctld is \(ping.version).
Fixes in this version are NOT active until the daemon restarts:
sudo smctl daemon restart
Install this version of the protected helper and restart the daemon:
sudo smctl daemon install
""")
} else if let latest = ping.latestVersion,
SMCtlProtocolInfo.isVersion(latest, newerThan: SMCtlProtocolInfo.version) {
emit("""
note: smctl \(latest) is available (you have \(SMCtlProtocolInfo.version)). Upgrade:
brew upgrade smctl && sudo smctl daemon restart
brew upgrade smctl && sudo smctl daemon install
""")
}
}
Expand Down
Loading
Loading