Skip to content
Open
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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ The Homebrew install sets up shell completions (bash/zsh/fish) and a `man smctl`
Controlling fans and charging from userspace demands paranoia. smctl treats these as first-class invariants:

- **Never leave a brick.** Uninstalling, stopping, or killing the daemon restores system control of fans and charging — enforced by termination hooks, startup reconciliation, and launchd restart.
- **Thermal safety guard.** While fans are under manual control, smctl monitors all temperature sensors every second. Sustained readings above the ceiling force fans back to system control and latch out manual control until things cool down. The base ceiling defaults to 100 °C and is hard-capped at 105 °C; Apple Silicon `Tp*` hot-spot sensors get a narrow allowance up to 110 °C because they can run hotter than board/skin/proximity sensors under ordinary load. The guard cannot be disabled, and losing temperature visibility counts as unsafe.
- **Thermal safety guard.** While fans are under manual control, smctl monitors all temperature sensors every second. Sustained readings above the ceiling — or readings that go unreadable or implausible (corrupt SMC sentinels) — make the guard pin every fan at **maximum RPM under its own control** and latch out new manual commands until the machine recovers, then resume your configured profile automatically. The guard never hands control back to the system and never rewrites your profile: an overheating Mac needs full cooling, not Apple's default curve. The base ceiling defaults to 100 °C and is hard-capped at 105 °C; Apple Silicon hot-spot sensors (`Tp*`, `TC*`, `Tg*`) get a narrow allowance up to 110 °C because they routinely run above 100 °C under ordinary sustained load. The guard cannot be disabled, and losing credible temperature visibility counts as unsafe.
- **Verified writes.** Every SMC write is read back and verified (with a settle window for firmware that applies writes asynchronously) — failures surface as errors, never as silent no-ops.
- **Graceful degradation.** If a macOS update changes SMC behavior, affected features degrade to read-only with an explicit message instead of pretending to work.

Expand Down Expand Up @@ -126,7 +126,7 @@ url = "http://gotify.lan/message?token=..."

[[alert]]
name = "guard-tripped"
on = "guard" # the thermal safety guard forced fans back to auto
on = "guard" # the thermal safety guard engaged (overheat or implausible readings; fans held at max RPM until recovery)
action = "exec"
command = ["/usr/local/bin/notify.sh", "{name}", "{reason}"]
```
Expand Down
94 changes: 66 additions & 28 deletions Sources/PolicyEngine/PolicyEngine.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

public enum PolicyEngine {
public static let version = "0.2.3"
public static let version = "0.2.4"
}

public protocol PolicyClock: Sendable {
Expand Down Expand Up @@ -540,34 +540,50 @@ public struct FanCurveEngine: Sendable {
}

public struct FanSafetyDecision: Equatable, Sendable {
public var forceAuto: Bool
/// The daemon must pin every fan at its maximum RPM right now (fail-safe hold).
/// The landing state for overheat or sensor blindness is *more cooling under the
/// same manual control*, never a hand-back to system defaults: a hand-back also
/// forced the daemon to destroy the user's configured profile (observed in the
/// field as "the fan controller keeps resetting to Apple auto").
public var holdAtMaxRPM: Bool
public var reason: String?

public init(forceAuto: Bool, reason: String? = nil) {
self.forceAuto = forceAuto
public init(holdAtMaxRPM: Bool, reason: String? = nil) {
self.holdAtMaxRPM = holdAtMaxRPM
self.reason = reason
}
}

public struct FanSafetyGuard: Codable, Equatable, Sendable {
/// Calibrated against field data: Apple Silicon junction hot-spot sensors (Tp0E/Tp3P
/// class) can sit above 100C under ordinary sustained load. Non-hotspot sensors keep
/// the stricter base ceiling; Tp* sensors get a narrow allowance and still hard-trip.
/// class, plus the TC*/Tg* core and GPU sensors) can sit above 100C under ordinary
/// sustained load — Apple's own policy throttles toward ~110C rather than treating
/// 100C as an emergency. Non-hotspot sensors keep the stricter base ceiling;
/// hotspot sensors get a narrow allowance and still hard-trip.
public static let defaultCeilingCelsius = 100.0
public static let hardMaximumCeilingCelsius = 105.0
public static let hotSpotSensorPrefix = "Tp"
public static let hotSpotSensorPrefixes = ["Tp", "Tc", "TC", "Tg"]
public static let hotSpotAllowanceCelsius = 10.0
public static let hotSpotHardMaximumCeilingCelsius = 110.0
/// The latch releases only after cooling this far below the ceiling, so a trip
/// cannot be immediately re-armed into a hot system.
/// Physical operating range of the SMC's float temperature sensors on this
/// platform. Readings outside it are not temperatures — they are sentinels or
/// corrupt data. Field evidence: Ts02 reported 279.56C for two consecutive
/// ticks and tripped the guard on a 66C machine. Such readings are ignored:
/// they never trip the guard, and they never block a latch release.
public static let plausibleMinimumCelsius = -40.0
public static let plausibleMaximumCelsius = 150.0
/// The latch releases only after every plausible reading is comfortably below
/// its own effective ceiling, so a trip cannot be immediately re-armed into a
/// hot system.
public static let releaseHysteresisCelsius = 5.0
/// Overheat must persist for this many consecutive ticks (1s cadence) to trip —
/// debounces momentary hot-spot spikes while keeping reaction time under ~2.5s.
/// Sensor blindness is NOT debounced: it trips immediately.
public static let consecutiveTripsRequired = 2

public var configuredCeilingCelsius: Double
/// Set when the guard trips; manual fan control must be rejected while latched.
/// Set when the guard trips; manual fan targets are rejected while latched,
/// and the daemon holds every fan at maximum RPM until the latch releases.
public private(set) var isLatched: Bool
private var consecutiveOverCeiling: Int

Expand All @@ -581,14 +597,28 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable {
self.consecutiveOverCeiling = 0
}

/// One evaluation tick. Fail-safe semantics: while a manual fan policy is active the
/// guard is the only thermal floor (thermalmonitord is hands-off), so *no readable
/// temperature* counts as unsafe — blind manual control is never allowed.
/// A reading the SMC could plausibly be reporting as a temperature. Anything
/// else (NaN, infinities, 279C sentinels) carries no thermal information and
/// must not influence policy in either direction.
public static func isPlausible(_ sample: FanTemperatureSample) -> Bool {
sample.celsius.isFinite
&& sample.celsius >= plausibleMinimumCelsius
&& sample.celsius <= plausibleMaximumCelsius
}

/// One evaluation tick. Fail-safe semantics: while a manual fan policy is active
/// the guard is the only thermal floor (thermalmonitord is hands-off), so it keeps
/// ownership of the fans and drives them to maximum RPM when data is missing or
/// temperatures are genuinely unsafe. The configured profile survives the hold and
/// resumes automatically on release.
public mutating func evaluate(samples: [FanTemperatureSample], manualPolicyActive: Bool) -> FanSafetyDecision {
let valid = samples.filter(Self.isPlausible)

// 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.
if isLatched, !samples.isEmpty, samples.allSatisfy({ sample in
// own effective ceiling. Hotspot-class sensors use a higher ceiling than
// board/skin/proximity sensors; implausible readings are excluded so one
// garbage sensor can never pin the latch forever.
if isLatched, !valid.isEmpty, valid.allSatisfy({ sample in
sample.celsius <= effectiveCeiling(for: sample) - Self.releaseHysteresisCelsius
}) {
isLatched = false
Expand All @@ -598,18 +628,18 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable {
guard manualPolicyActive else {
// System is in control; nothing to enforce.
consecutiveOverCeiling = 0
return FanSafetyDecision(forceAuto: false)
return FanSafetyDecision(holdAtMaxRPM: false)
}

guard !samples.isEmpty else {
guard !valid.isEmpty else {
isLatched = true
consecutiveOverCeiling = 0
return FanSafetyDecision(
forceAuto: true,
reason: "no readable temperature sensors while fans are under manual control"
)
let detail = samples.isEmpty
? "no readable temperature sensors while fans are under manual control"
: "all temperature readings implausible while fans are under manual control (\(implausibleSummary(samples)))"
return FanSafetyDecision(holdAtMaxRPM: true, reason: detail)
}
let offender = samples.compactMap { sample -> (sample: FanTemperatureSample, ceiling: Double)? in
let offender = valid.compactMap { sample -> (sample: FanTemperatureSample, ceiling: Double)? in
let ceiling = effectiveCeiling(for: sample)
guard sample.celsius >= ceiling else { return nil }
return (sample, ceiling)
Expand All @@ -622,7 +652,7 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable {
if consecutiveOverCeiling >= Self.consecutiveTripsRequired {
isLatched = true
return FanSafetyDecision(
forceAuto: true,
holdAtMaxRPM: true,
reason: "temperature \(offender.sample.sensor) \(offender.sample.celsius)C held at/above safety ceiling \(offender.ceiling)C for \(consecutiveOverCeiling) consecutive checks"
)
}
Expand All @@ -631,15 +661,23 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable {
}
if isLatched {
return FanSafetyDecision(
forceAuto: true,
reason: "safety latch active until temperature falls below \(configuredCeilingCelsius - Self.releaseHysteresisCelsius)C"
holdAtMaxRPM: true,
reason: "safety latch active until every plausible sensor falls below its ceiling minus \(Self.releaseHysteresisCelsius)C"
)
}
return FanSafetyDecision(forceAuto: false)
return FanSafetyDecision(holdAtMaxRPM: false)
}

private func implausibleSummary(_ samples: [FanTemperatureSample]) -> String {
samples
.filter { !Self.isPlausible($0) }
.prefix(3)
.map { "\($0.sensor) \($0.celsius)C" }
.joined(separator: ", ")
}

private func effectiveCeiling(for sample: FanTemperatureSample) -> Double {
guard sample.sensor.hasPrefix(Self.hotSpotSensorPrefix) else {
guard Self.hotSpotSensorPrefixes.contains(where: { sample.sensor.hasPrefix($0) }) else {
return configuredCeilingCelsius
}
return min(
Expand Down
63 changes: 50 additions & 13 deletions Sources/SMCtlDaemonCore/Daemon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,12 @@ public final class SmctlDaemon: @unchecked Sendable {
private var runtimeFanProfile: String?
// Stateful: carries the safety latch across ticks; ceiling refreshed from config.
var safetyGuard = FanSafetyGuard()
// Runtime state of the fail-safe hold: guard latched -> fans pinned at maximum
// under the same manual control. The user's configured profile survives the hold
// untouched and resumes on release; these fields only mark the engage/release
// edge for logging and for `smctl fan status`.
var safetyHoldActive = false
var safetyHoldReason: String?
// Stateful: carries per-rule debounce/cooldown across ticks.
var alertEngine = AlertEngine()
private let alertRunner = AlertActionRunner()
Expand Down Expand Up @@ -723,6 +729,10 @@ public final class SmctlDaemon: @unchecked Sendable {
throw DaemonError.unsupported("AppleSMC is not available.")
}
guard let group = capabilities.chargingControl else {
// The hardware exposes no charging-control keys at all. "Enabled" is the
// power-on default, so the policy is already satisfied there; throwing on
// every 10s tick buried real fan faults under battery noise in `daemon status`.
if enabled { return }
throw DaemonError.unsupported("Charging control keys are not available on this Mac/system.")
}
for write in enabled ? group.enableWrites : group.disableWrites {
Expand All @@ -735,6 +745,9 @@ public final class SmctlDaemon: @unchecked Sendable {
throw DaemonError.unsupported("AppleSMC is not available.")
}
guard let group = capabilities.adapterControl else {
// See applyChargingLocked: re-enabling the adapter default is a no-op
// when the hardware has no adapter-control keys.
if enabled { return }
throw DaemonError.unsupported("Adapter control keys are not available on this Mac/system.")
}
for write in enabled ? group.enableWrites : group.disableWrites {
Expand Down Expand Up @@ -831,20 +844,26 @@ public final class SmctlDaemon: @unchecked Sendable {
configuredCeilingCelsius: config.safety.temp_ceiling
).configuredCeilingCelsius
let decision = safetyGuard.evaluate(samples: samples, manualPolicyActive: manualPolicyActive)
if decision.forceAuto {
logger.notice("Fan safety guard tripped: \(decision.reason ?? "unknown", privacy: .public); restoring all fans to auto")
if decision.holdAtMaxRPM {
if !safetyHoldActive {
safetyHoldActive = true
safetyHoldReason = decision.reason
logger.notice("Fan safety guard engaged: \(decision.reason ?? "unknown", privacy: .public); holding all fans at maximum RPM (profile '\(profile, privacy: .public)' resumes on release)")
}
do {
try restoreFansAutoLocked(indices: capabilities.fans.map(\.index), clearPolicy: true)
config.fan.profile = "auto"
runtimeFanProfile = nil
try Self.writeConfig(config, path: configPath)
try holdFansAtMaxRPMLocked()
} catch {
lastError = String(describing: error)
logger.error("Fan safety restore failed: \(String(describing: error), privacy: .public)")
SentryReporter.capture(error, context: "Fan safety restore failed")
logger.error("Fan safety hold failed: \(String(describing: error), privacy: .public)")
SentryReporter.capture(error, context: "Fan safety hold failed")
}
return
}
if safetyHoldActive {
safetyHoldActive = false
safetyHoldReason = nil
logger.notice("Fan safety guard released; resuming profile '\(profile, privacy: .public)'")
}

guard manualPolicyActive else {
return
Expand Down Expand Up @@ -873,7 +892,7 @@ public final class SmctlDaemon: @unchecked Sendable {
let input = AlertConditionInput(
samples: samples,
guardTripped: latched,
guardReason: latched ? "Fan safety guard latched (overheat or unreadable temperature sensors)" : nil,
guardReason: latched ? "Fan safety guard latched (overheat or implausible temperature readings)" : nil,
writeError: lastWriteError
)
let events = alertEngine.evaluate(rules: rules, input: input, now: Date())
Expand Down Expand Up @@ -952,9 +971,9 @@ public final class SmctlDaemon: @unchecked Sendable {
return
}
throw DaemonError.unsupported(
"Fan safety guard is latched (overheat or unreadable temperature sensors). "
+ "Fans stay under system control until temperatures fall at least "
+ "\(Int(FanSafetyGuard.releaseHysteresisCelsius))C below the ceiling."
"Fan safety guard is latched (overheat or implausible temperature sensors). "
+ "Fans are held at maximum RPM by the guard until every plausible reading "
+ "falls at least \(Int(FanSafetyGuard.releaseHysteresisCelsius))C below its ceiling."
)
}

Expand All @@ -971,6 +990,23 @@ public final class SmctlDaemon: @unchecked Sendable {
}
}

/// Fail-safe landing state: keep manual ownership of every fan and pin it to the
/// SMC-reported maximum. Chosen over handing control back to the system because a
/// hand-back (a) reduces cooling exactly when the machine is hottest if the trip
/// cause was a corrupt reading or a spike the curve was already answering with
/// full RPM, and (b) required rewriting the user's configured profile to "auto",
/// which is how the controller kept "resetting to Apple defaults" in normal use.
private func holdFansAtMaxRPMLocked() throws {
let controller = try fanControllerLocked()
for fan in capabilities.fans {
let maximum = readNumericLocked(fan.maximumKey) ?? 6000
_ = try writeSMCOperationLocked {
try controller.setManual(index: fan.index, rpm: maximum)
}
ftstGate.enterManual(index: fan.index)
}
}

private func evaluateFanProfileLocked(profile: String, samples: [FanTemperatureSample]) throws {
guard !samples.isEmpty else {
throw DaemonError.unsupported("No readable temperature sensors are available for fan profile '\(profile)'.")
Expand Down Expand Up @@ -1163,7 +1199,8 @@ public final class SmctlDaemon: @unchecked Sendable {
timestamp: Date(),
profile: currentFanProfileLocked(),
fans: statuses,
message: statusMessage
message: statusMessage,
safetyHold: safetyHoldActive ? (safetyHoldReason ?? "safety hold active") : nil
)
}

Expand Down
8 changes: 6 additions & 2 deletions Sources/SMCtlProtocol/SMCtlProtocol.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

public enum SMCtlProtocolInfo {
public static let version = "0.2.3"
public static let version = "0.2.4"
public static let machServiceName = "one.leaper.smctl.daemon"

/// Numeric-component semver comparison, tolerant of a leading "v".
Expand Down Expand Up @@ -231,12 +231,16 @@ public struct FansStatusDTO: Codable, Equatable, Sendable {
public var profile: String
public var fans: [FanStatusDTO]
public var message: String?
/// Non-nil while the fan safety guard holds every fan at maximum RPM. The
/// profile shown above is what will resume when the hold releases.
public var safetyHold: String?

public init(timestamp: Date, profile: String, fans: [FanStatusDTO], message: String?) {
public init(timestamp: Date, profile: String, fans: [FanStatusDTO], message: String?, safetyHold: String? = nil) {
self.timestamp = timestamp
self.profile = profile
self.fans = fans
self.message = message
self.safetyHold = safetyHold
}
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/smctl/SMCtl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,9 @@ private func hoursMinutes(_ minutes: Int) -> String {
private func printFansStatus(_ status: FansStatusDTO) {
print("Fans")
print(" profile: \(status.profile)")
if let hold = status.safetyHold {
print(" SAFETY HOLD: \(hold) (fans pinned at maximum; profile resumes on release)")
}
if status.fans.isEmpty {
print(" No fans reported by smctld.")
} else {
Expand Down
Loading