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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ $ smctl sensors --watch # live temperatures, fan RPM, package power
| `smctl fan status` | Per-fan actual/target/min/max RPM and control mode |
| `smctl fan set 2500 [--fan N]` | Manual fan target |
| `smctl fan profile quiet\|full\|auto\|<custom>` | Declarative fan curves (TOML), hysteresis + slew-rate limited |
| `smctl power status [--watch] [--json]` | Thermal pressure, CPU throttling (% speed limit), package + input power |
| `smctl daemon install\|uninstall\|status\|ping` | Manage the privileged helper |

Policies live in `/etc/smctl/config.toml` — declarative, diffable, dotfiles-friendly.
Expand Down
211 changes: 211 additions & 0 deletions Sources/SMCCore/PowerStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import Foundation

/// macOS thermal pressure level, mirrored from `ProcessInfo.ThermalState`.
/// This is the system's own assessment of thermal headroom — the cleanest
/// "are we being throttled?" signal, available without root or a subprocess.
public enum ThermalPressure: String, Codable, Equatable, Sendable {
case nominal
case fair
case serious
case critical
case unknown

init(_ state: ProcessInfo.ThermalState) {
switch state {
case .nominal: self = .nominal
case .fair: self = .fair
case .serious: self = .serious
case .critical: self = .critical
@unknown default: self = .unknown
}
}
}

/// CPU throttling quantified from `pmset -g therm`.
///
/// Apple Silicon does not expose `IOPMCopyCPUPowerStatus` (it returns
/// kIOReturnNotFound — verified on M4 mini), so `pmset` is the only public,
/// non-root source for the actual speed-limit percentage. When the system has
/// recorded no thermal pressure, `pmset` reports "No CPU power status has been
/// recorded" and `recorded` is false — which means "not throttled", not "unknown".
public struct CPUThrottleStatus: Codable, Equatable, Sendable {
/// True when `pmset` has a CPU power status to report. False means the system
/// has recorded no throttling — treat the CPU as running at full speed.
public var recorded: Bool
/// CPU_Speed_Limit: percent of full clock available (100 = no throttling).
public var speedLimitPercent: Int?
/// CPU_Scheduler_Limit: percent of scheduler capacity available.
public var schedulerLimitPercent: Int?
/// CPU_Available_CPUs: cores the scheduler is currently allowed to use.
public var availableCPUs: Int?

public init(
recorded: Bool,
speedLimitPercent: Int? = nil,
schedulerLimitPercent: Int? = nil,
availableCPUs: Int? = nil
) {
self.recorded = recorded
self.speedLimitPercent = speedLimitPercent
self.schedulerLimitPercent = schedulerLimitPercent
self.availableCPUs = availableCPUs
}

/// True when the CPU is demonstrably below full speed.
public var isThrottled: Bool {
guard let speedLimitPercent else { return false }
return speedLimitPercent < 100
}
}

public struct PowerSnapshot: Codable, Equatable, Sendable {
public var timestamp: Date
public var thermalPressure: ThermalPressure
public var cpu: CPUThrottleStatus
/// PDTR — package power in watts.
public var packagePowerWatts: Double?
/// VD0R — DC input voltage in volts.
public var inputVoltage: Double?
/// ID0R — DC input current in amps.
public var inputCurrent: Double?

public init(
timestamp: Date,
thermalPressure: ThermalPressure,
cpu: CPUThrottleStatus,
packagePowerWatts: Double?,
inputVoltage: Double?,
inputCurrent: Double?
) {
self.timestamp = timestamp
self.thermalPressure = thermalPressure
self.cpu = cpu
self.packagePowerWatts = packagePowerWatts
self.inputVoltage = inputVoltage
self.inputCurrent = inputCurrent
}

/// Input power in watts, computed when both voltage and current are readable.
public var inputPowerWatts: Double? {
guard let inputVoltage, let inputCurrent else { return nil }
return inputVoltage * inputCurrent
}

private enum CodingKeys: String, CodingKey {
case timestamp, thermalPressure, cpu, packagePowerWatts
case inputVoltage, inputCurrent, inputPowerWatts
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(timestamp, forKey: .timestamp)
try container.encode(thermalPressure, forKey: .thermalPressure)
try container.encode(cpu, forKey: .cpu)
try container.encodeIfPresent(packagePowerWatts, forKey: .packagePowerWatts)
try container.encodeIfPresent(inputVoltage, forKey: .inputVoltage)
try container.encodeIfPresent(inputCurrent, forKey: .inputCurrent)
try container.encodeIfPresent(inputPowerWatts, forKey: .inputPowerWatts)
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
timestamp = try container.decode(Date.self, forKey: .timestamp)
thermalPressure = try container.decode(ThermalPressure.self, forKey: .thermalPressure)
cpu = try container.decode(CPUThrottleStatus.self, forKey: .cpu)
packagePowerWatts = try container.decodeIfPresent(Double.self, forKey: .packagePowerWatts)
inputVoltage = try container.decodeIfPresent(Double.self, forKey: .inputVoltage)
inputCurrent = try container.decodeIfPresent(Double.self, forKey: .inputCurrent)
}
}

/// Pure parser for `pmset -g therm` output. Kept side-effect free so it can be
/// unit-tested against captured fixtures without forking a subprocess.
public enum PmsetThermParser {
public static func parse(_ output: String) -> CPUThrottleStatus {
let speed = value(for: "CPU_Speed_Limit", in: output)
let scheduler = value(for: "CPU_Scheduler_Limit", in: output)
let cpus = value(for: "CPU_Available_CPUs", in: output)
let recorded = speed != nil || scheduler != nil || cpus != nil
return CPUThrottleStatus(
recorded: recorded,
speedLimitPercent: speed,
schedulerLimitPercent: scheduler,
availableCPUs: cpus
)
}

/// Matches a line like `CPU_Speed_Limit = 100` (whitespace around `=` varies).
private static func value(for label: String, in output: String) -> Int? {
for rawLine in output.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix(label) else { continue }
guard let equals = line.firstIndex(of: "=") else { continue }
let rhs = line[line.index(after: equals)...].trimmingCharacters(in: .whitespaces)
// Take the leading integer; tolerate trailing units/garbage.
let digits = rhs.prefix { $0.isNumber }
if let number = Int(digits) {
return number
}
}
return nil
}
}

/// Reads the unified power/thermal picture: thermal pressure (ProcessInfo),
/// CPU throttling (pmset), and DC power rails (SMC). Pure-read, no root, no
/// daemon — mirrors `SensorReader`.
public final class PowerReader {
private let backend: SMCBackend
private let thermalStateProvider: () -> ProcessInfo.ThermalState
private let pmsetThermProvider: () -> String?
private let now: () -> Date

public init(
backend: SMCBackend,
thermalStateProvider: @escaping () -> ProcessInfo.ThermalState = { ProcessInfo.processInfo.thermalState },
pmsetThermProvider: @escaping () -> String? = PowerReader.runPmsetTherm,
now: @escaping () -> Date = { Date() }
) {
self.backend = backend
self.thermalStateProvider = thermalStateProvider
self.pmsetThermProvider = pmsetThermProvider
self.now = now
}

public func snapshot() -> PowerSnapshot {
let cpu = pmsetThermProvider().map(PmsetThermParser.parse)
?? CPUThrottleStatus(recorded: false)
return PowerSnapshot(
timestamp: now(),
thermalPressure: ThermalPressure(thermalStateProvider()),
cpu: cpu,
packagePowerWatts: numericValue("PDTR"),
inputVoltage: numericValue("VD0R"),
inputCurrent: numericValue("ID0R")
)
}

private func numericValue(_ key: String) -> Double? {
guard let value = try? backend.readValue(key) else { return nil }
return value.decoded?.doubleValue
}

/// Runs `/usr/bin/pmset -g therm` and returns its stdout, or nil on failure.
/// pmset needs no privileges to read thermal state.
public static func runPmsetTherm() -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/pmset")
process.arguments = ["-g", "therm"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
} catch {
return nil
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return String(data: data, encoding: .utf8)
}
}
87 changes: 86 additions & 1 deletion Sources/smctl/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct SMCtl: ParsableCommand {
commandName: "smctl",
abstract: "Mac hardware control utility.",
version: SMCtlProtocolInfo.version,
subcommands: [Sensors.self, Fan.self, Battery.self, Daemon.self, Debug.self]
subcommands: [Sensors.self, Fan.self, Battery.self, Power.self, Daemon.self, Debug.self]
)
}

Expand Down Expand Up @@ -343,6 +343,91 @@ struct Discharge: ParsableCommand {
}
}

struct Power: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "power",
abstract: "Thermal pressure, CPU throttling, and power draw.",
subcommands: [PowerStatus.self],
defaultSubcommand: PowerStatus.self
)
}

struct PowerStatus: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "status",
abstract: "Show thermal pressure, CPU throttling, and power draw (no root, no daemon)."
)

@Flag(name: .long, help: "Print machine-readable JSON.")
var json = false

@Flag(name: .long, help: "Refresh every second until interrupted.")
var watch = false

func run() throws {
let reader = PowerReader(backend: try SMCConnection())

repeat {
let snapshot = reader.snapshot()
if json {
print(try CLIJSON.encodeString(snapshot))
} else {
if watch {
print("\u{001B}[2J\u{001B}[H", terminator: "")
}
printPower(snapshot)
}

if watch {
fflush(stdout)
sleep(1)
}
} while watch
}
}

private func printPower(_ snapshot: PowerSnapshot) {
print("smctl power \(CLIFormatters.iso8601.string(from: snapshot.timestamp))")
print("")
print(" Thermal pressure \(snapshot.thermalPressure.rawValue)")
print(" CPU throttling \(throttleDescription(snapshot.cpu))")
print(" Package power \(watts(snapshot.packagePowerWatts))")
print(" Input \(inputDescription(snapshot))")
}

private func throttleDescription(_ cpu: CPUThrottleStatus) -> String {
guard cpu.recorded else {
return "none"
}
var parts: [String] = []
if let speed = cpu.speedLimitPercent {
let throttled = max(0, 100 - speed)
parts.append(throttled > 0 ? "speed \(speed)% (\(throttled)% throttled)" : "speed \(speed)%")
}
if let scheduler = cpu.schedulerLimitPercent {
parts.append("scheduler \(scheduler)%")
}
if let cpus = cpu.availableCPUs {
parts.append("cores \(cpus)")
}
return parts.isEmpty ? "recorded (no detail)" : parts.joined(separator: ", ")
}

private func inputDescription(_ snapshot: PowerSnapshot) -> String {
let power = watts(snapshot.inputPowerWatts)
guard let voltage = snapshot.inputVoltage, let current = snapshot.inputCurrent else {
return power
}
return "\(power) (\(String(format: "%.2f", voltage)) V, \(String(format: "%.2f", current)) A)"
}

private func watts(_ value: Double?) -> String {
guard let value else {
return "unavailable"
}
return "\(String(format: "%.2f", value)) W"
}

struct Daemon: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "daemon",
Expand Down
Loading
Loading