diff --git a/README.md b/README.md index 9e6a233..7b8d037 100644 --- a/README.md +++ b/README.md @@ -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\|` | 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. diff --git a/Sources/SMCCore/PowerStatus.swift b/Sources/SMCCore/PowerStatus.swift new file mode 100644 index 0000000..22a708a --- /dev/null +++ b/Sources/SMCCore/PowerStatus.swift @@ -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) + } +} diff --git a/Sources/smctl/main.swift b/Sources/smctl/main.swift index 169e3c5..2dcbcdc 100644 --- a/Sources/smctl/main.swift +++ b/Sources/smctl/main.swift @@ -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] ) } @@ -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", diff --git a/Tests/SMCCoreTests/PmsetThermParserTests.swift b/Tests/SMCCoreTests/PmsetThermParserTests.swift new file mode 100644 index 0000000..67699d0 --- /dev/null +++ b/Tests/SMCCoreTests/PmsetThermParserTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import SMCCore + +final class PmsetThermParserTests: XCTestCase { + func testIdleMacReportsNotRecorded() { + let output = """ + Note: No thermal warning level has been recorded + Note: No performance warning level has been recorded + Note: No CPU power status has been recorded + """ + let status = PmsetThermParser.parse(output) + XCTAssertFalse(status.recorded) + XCTAssertNil(status.speedLimitPercent) + XCTAssertFalse(status.isThrottled) + } + + func testParsesThrottledOutput() { + let output = """ + CPU_Scheduler_Limit \t= 80 + CPU_Available_CPUs \t= 8 + CPU_Speed_Limit \t= 72 + """ + let status = PmsetThermParser.parse(output) + XCTAssertTrue(status.recorded) + XCTAssertEqual(status.speedLimitPercent, 72) + XCTAssertEqual(status.schedulerLimitPercent, 80) + XCTAssertEqual(status.availableCPUs, 8) + XCTAssertTrue(status.isThrottled) + } + + func testFullSpeedIsNotThrottled() { + let output = "CPU_Speed_Limit = 100" + let status = PmsetThermParser.parse(output) + XCTAssertTrue(status.recorded) + XCTAssertEqual(status.speedLimitPercent, 100) + XCTAssertFalse(status.isThrottled) + } + + func testToleratesVariedSpacingAndTrailingText() { + let output = " CPU_Speed_Limit = 55 % \n CPU_Available_CPUs=10\n" + let status = PmsetThermParser.parse(output) + XCTAssertEqual(status.speedLimitPercent, 55) + XCTAssertEqual(status.availableCPUs, 10) + } + + func testEmptyOutputIsNotRecorded() { + let status = PmsetThermParser.parse("") + XCTAssertFalse(status.recorded) + } +} + +final class PowerReaderTests: XCTestCase { + func testSnapshotComposesInjectedSources() { + let backend = StubBackend(values: [ + "PDTR": 18.4, + "VD0R": 20.1, + "ID0R": 1.1 + ]) + let reader = PowerReader( + backend: backend, + thermalStateProvider: { .serious }, + pmsetThermProvider: { "CPU_Speed_Limit = 72\nCPU_Available_CPUs = 8" }, + now: { Date(timeIntervalSince1970: 0) } + ) + let snapshot = reader.snapshot() + XCTAssertEqual(snapshot.thermalPressure, .serious) + XCTAssertEqual(snapshot.cpu.speedLimitPercent, 72) + XCTAssertEqual(snapshot.packagePowerWatts!, 18.4, accuracy: 0.001) + XCTAssertEqual(snapshot.inputVoltage!, 20.1, accuracy: 0.001) + XCTAssertEqual(snapshot.inputCurrent!, 1.1, accuracy: 0.001) + XCTAssertEqual(snapshot.inputPowerWatts!, 22.11, accuracy: 0.01) + } + + func testMissingPmsetDegradesToNotRecorded() { + let reader = PowerReader( + backend: StubBackend(values: [:]), + thermalStateProvider: { .nominal }, + pmsetThermProvider: { nil } + ) + let snapshot = reader.snapshot() + XCTAssertFalse(snapshot.cpu.recorded) + XCTAssertNil(snapshot.packagePowerWatts) + XCTAssertNil(snapshot.inputPowerWatts) + } +} + +/// Minimal SMCBackend stub returning canned numeric values as little-endian flt bytes. +private struct StubBackend: SMCBackend { + var values: [String: Double] + + private var floatInfo: SMCKeyInfo { + SMCKeyInfo(dataSize: 4, dataType: FourCharCode.unchecked("flt "), dataAttributes: 0) + } + + func readKeyInfo(_ key: String) throws -> SMCKeyInfo { + guard values[key] != nil else { throw SMCError.notFound } + return floatInfo + } + + func readValue(_ key: String) throws -> SMCReadValue { + guard let value = values[key] else { throw SMCError.notFound } + var float = Float(value) + let bytes = withUnsafeBytes(of: &float) { Array($0) } + return SMCReadValue(key: key, info: floatInfo, bytes: bytes) + } +} diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 1884534..1bbce71 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -35,6 +35,7 @@ $ smctl sensors --watch # 实时温度、风扇转速、封装功耗 | `smctl fan status` | 每个风扇的实际/目标/最小/最大转速和控制模式 | | `smctl fan set 2500 [--fan N]` | 手动设定目标转速 | | `smctl fan profile quiet\|full\|auto\|<自定义>` | 声明式风扇曲线(TOML),带滞回和变速率限制 | +| `smctl power status [--watch] [--json]` | 热压制状态、CPU 降频幅度(限速 %)、封装功耗与输入功率 | | `smctl daemon install\|uninstall\|status\|ping` | 管理特权 daemon | 策略配置在 `/etc/smctl/config.toml`——声明式、可 diff、对 dotfiles 友好。