Der Key heißt CHLT, drei Bytes. Identifiziert per Differenzmessung gegen macOS' eigene Ladebegrenzung, ohne einen einzigen Schreibzugriff: 80 % → 50 01 00 95 % → 5F 01 00 85 % → 55 01 00 Erstes Byte ist die Prozentzahl, zweites offenbar „Limit aktiv", drittes unbenutzt. Drei Messpunkte, weil einer Zufall sein kann: BACC hatte beim ersten Vergleich ebenfalls gepasst — ein Zähler, in dem gerade 50 stand. ChargerConfiguration aus ioreg sah zunächst wie eine zweite Quelle aus (niedriges Byte 0x50 = 80), blieb aber unverändert, als CHLT längst auf 95 stand. Also Zufall, und gut, dass darauf nichts gebaut wurde. Geschrieben wird nur das erste Byte, und nur zwischen 80 und 100 — dem Bereich, den macOS selbst anbietet und in dem gemessen wurde. Darunter ist ungemessenes Gebiet. Bei den Lüftern liefert die Firmware mit F0Mn/F0Mx eigene Grenzen mit, an denen sich ein Sicherheitsnetz festhalten kann; hier gibt es das nicht, hier gibt es nur die Messung. Ein falscher Wert im Lade-Subsystem ist die eine Operation in diesem Projekt, die den Akku dauerhaft beschädigen kann. Nach jedem Schreiben wird zurückgelesen und gemeldet, was tatsächlich steht — nicht, was gewünscht war. „Kein Limit" heißt 100 Prozent und nicht das Aktiv-Byte auf null: diese Kodierung hat macOS nie geschrieben, sie wäre also ungemessen. Bedienung im Lüfter-Bereich der Einstellungen und in der Lüfterkachel, also auch im Menüleisten-Popover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
8.7 KiB
Swift
228 lines
8.7 KiB
Swift
import Foundation
|
|
import OSLog
|
|
import OnyxHelperProtocol
|
|
|
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx.helper", category: "Helper")
|
|
|
|
/// Der privilegierte Helfer.
|
|
///
|
|
/// Läuft als root und tut genau eine Sache: Lüfterdrehzahlen setzen. Kein
|
|
/// Netzwerk, keine Oberfläche, kein Dateizugriff. Je weniger hier steht, desto
|
|
/// kleiner die Angriffsfläche eines Dienstes mit Systemrechten.
|
|
///
|
|
/// **Er entscheidet selbst, was zulässig ist.** Die App darf einen Wunsch
|
|
/// äußern; ob er ausgeführt wird, prüft ausschließlich `FanSafety` hier — mit
|
|
/// Temperaturen, die der Helfer selbst misst.
|
|
final class FanService: NSObject, OnyxHelperProtocol {
|
|
|
|
private let smc: SMCAccess?
|
|
/// Wunschdrehzahl je Lüfter. Leer heißt Automatik.
|
|
private var requests: [Int: FanRequest] = [:]
|
|
private var lastHeartbeat = Date.distantPast
|
|
private var lastDecision: [Int: FanDecision] = [:]
|
|
private let queue = DispatchQueue(label: "com.scarriffleservices.onyx.helper.fans")
|
|
|
|
private lazy var temperatureKeys: [String] = smc?.keys(withPrefix: "Tp") ?? []
|
|
private lazy var fanCount: Int = {
|
|
guard let value = smc?.float("FNum") else { return Int(smc?.uint8("FNum") ?? 0) }
|
|
return Int(value)
|
|
}()
|
|
|
|
override init() {
|
|
smc = SMCAccess()
|
|
super.init()
|
|
startControlLoop()
|
|
}
|
|
|
|
// MARK: - Regelschleife
|
|
|
|
/// Läuft ununterbrochen, solange der Helfer lebt.
|
|
///
|
|
/// Die Entscheidung wird **jede Sekunde neu** getroffen, nicht nur beim
|
|
/// Setzen. Nur so greifen Wachhund und Temperaturwächter auch dann, wenn
|
|
/// von der App nie wieder etwas kommt.
|
|
private func startControlLoop() {
|
|
queue.async { [weak self] in
|
|
let timer = Timer(timeInterval: 1, repeats: true) { _ in self?.apply() }
|
|
RunLoop.current.add(timer, forMode: .common)
|
|
RunLoop.current.run()
|
|
}
|
|
}
|
|
|
|
private func apply() {
|
|
guard let smc, fanCount > 0 else { return }
|
|
let hottest = hottestTemperature()
|
|
|
|
for index in 0..<fanCount {
|
|
let limits = FanLimits(minimum: smc.float("F\(index)Mn") ?? 0,
|
|
maximum: smc.float("F\(index)Mx") ?? 0)
|
|
let decision = FanSafety.decide(request: requests[index],
|
|
lastHeartbeat: lastHeartbeat,
|
|
now: Date(),
|
|
hottestCelsius: hottest,
|
|
limits: limits)
|
|
|
|
// Nur schreiben, wenn sich etwas ändert: jeder SMC-Schreibvorgang
|
|
// ist ein Eingriff, und eine Sekunde später denselben Wert erneut
|
|
// zu setzen bringt nichts.
|
|
guard decision != lastDecision[index] else { continue }
|
|
lastDecision[index] = decision
|
|
|
|
switch decision {
|
|
case .manual(let rpm):
|
|
smc.writeUInt8("F\(index)md", 1)
|
|
smc.writeFloat("F\(index)Tg", rpm)
|
|
log.notice("Lüfter \(index): manuell \(Int(rpm)) U/min")
|
|
case .automatic(let reason):
|
|
smc.writeUInt8("F\(index)md", 0)
|
|
if reason != .noRequest {
|
|
// Ein erzwungener Rückfall ist ein Ereignis, das man später
|
|
// nachvollziehen können muss.
|
|
log.error("Lüfter \(index): Rückfall auf Automatik — \(reason.rawValue, privacy: .public)")
|
|
requests[index] = nil
|
|
} else {
|
|
log.notice("Lüfter \(index): Automatik")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func hottestTemperature() -> Double? {
|
|
guard let smc, !temperatureKeys.isEmpty else { return nil }
|
|
return temperatureKeys.compactMap { key -> Double? in
|
|
guard let value = smc.float(key), value > 5, value < 150 else { return nil }
|
|
return value
|
|
}.max()
|
|
}
|
|
|
|
/// Beim Beenden alles zurückstellen.
|
|
///
|
|
/// Ohne das blieben die Lüfter nach einem Abmelden oder Neustart des
|
|
/// Dienstes auf ihrem letzten Wert stehen — bis zum nächsten Neustart des
|
|
/// Rechners.
|
|
func restoreAutomatic() {
|
|
guard let smc else { return }
|
|
for index in 0..<fanCount { smc.writeUInt8("F\(index)md", 0) }
|
|
log.notice("Alle Lüfter auf Automatik zurückgestellt")
|
|
}
|
|
|
|
// MARK: - Schnittstelle
|
|
|
|
func version(reply: @escaping (String) -> Void) { reply(OnyxHelper.version) }
|
|
|
|
func heartbeat(reply: @escaping (Bool) -> Void) {
|
|
lastHeartbeat = Date()
|
|
reply(true)
|
|
}
|
|
|
|
func fanStatus(reply: @escaping (Data?) -> Void) { reply(report()) }
|
|
|
|
func setFan(index: Int, targetRPM: Double, reply: @escaping (Data?) -> Void) {
|
|
lastHeartbeat = Date()
|
|
requests[index] = FanRequest(targetRPM: targetRPM)
|
|
apply()
|
|
reply(report())
|
|
}
|
|
|
|
// MARK: - Ladelimit
|
|
|
|
/// Der Key trägt drei Bytes; nur das erste wird verändert. Siehe
|
|
/// `ChargeSafety` für die Messung, auf der das beruht.
|
|
func chargeLimit(reply: @escaping (Int) -> Void) {
|
|
lastHeartbeat = Date()
|
|
guard let smc, let bytes = smc.readBytes(ChargeSafety.key),
|
|
let percent = ChargeSafety.decode(bytes) else {
|
|
reply(-1)
|
|
return
|
|
}
|
|
reply(percent)
|
|
}
|
|
|
|
func setChargeLimit(percent: Int, reply: @escaping (Int) -> Void) {
|
|
lastHeartbeat = Date()
|
|
guard let smc else { reply(-1); return }
|
|
|
|
let wanted = ChargeSafety.clamp(percent)
|
|
let written = smc.writeBytes(ChargeSafety.key, ChargeSafety.encode(wanted))
|
|
|
|
// Immer zurücklesen. Ein Schreibvorgang, den der SMC stillschweigend
|
|
// verwirft, darf nicht als Erfolg gemeldet werden — sonst zeigt die
|
|
// Oberfläche eine Grenze an, die es nicht gibt.
|
|
let readBack = smc.readBytes(ChargeSafety.key).flatMap(ChargeSafety.decode) ?? -1
|
|
log.notice("Ladelimit: \(percent, privacy: .public) % gewünscht, \(wanted, privacy: .public) % geschrieben (\(written, privacy: .public)), zurückgelesen \(readBack, privacy: .public) %")
|
|
reply(readBack)
|
|
}
|
|
|
|
func setFanAutomatic(index: Int, reply: @escaping (Data?) -> Void) {
|
|
lastHeartbeat = Date()
|
|
requests[index] = nil
|
|
apply()
|
|
reply(report())
|
|
}
|
|
|
|
private func report() -> Data? {
|
|
guard let smc else { return nil }
|
|
let fans = (0..<fanCount).map { index -> FanStatus in
|
|
let decision = lastDecision[index]
|
|
var reason: String?
|
|
var manual = false
|
|
if case .manual = decision { manual = true }
|
|
if case .automatic(let value) = decision { reason = value.rawValue }
|
|
|
|
return FanStatus(index: index,
|
|
currentRPM: smc.float("F\(index)Ac") ?? 0,
|
|
targetRPM: smc.float("F\(index)Tg") ?? 0,
|
|
limits: FanLimits(minimum: smc.float("F\(index)Mn") ?? 0,
|
|
maximum: smc.float("F\(index)Mx") ?? 0),
|
|
isManual: manual,
|
|
automaticReason: reason)
|
|
}
|
|
return try? JSONEncoder().encode(
|
|
FanStatusReport(fans: fans, hottestCelsius: hottestTemperature(),
|
|
helperVersion: OnyxHelper.version))
|
|
}
|
|
}
|
|
|
|
// MARK: - XPC
|
|
|
|
final class Listener: NSObject, NSXPCListenerDelegate {
|
|
let service = FanService()
|
|
|
|
func listener(_ listener: NSXPCListener,
|
|
shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
|
|
// Ohne diese Prüfung könnte **jedes** Programm auf dem Rechner die
|
|
// Lüfter steuern: der Mach-Dienst eines root-Daemons ist systemweit
|
|
// sichtbar, und wer ihn anspricht, spricht mit root.
|
|
do {
|
|
try connection.setCodeSigningRequirement(OnyxHelper.clientRequirement)
|
|
} catch {
|
|
log.error("Verbindung abgelehnt: Signaturanforderung nicht setzbar")
|
|
return false
|
|
}
|
|
|
|
connection.exportedInterface = NSXPCInterface(with: OnyxHelperProtocol.self)
|
|
connection.exportedObject = service
|
|
connection.resume()
|
|
return true
|
|
}
|
|
}
|
|
|
|
let listener = NSXPCListener(machServiceName: OnyxHelper.machServiceName)
|
|
let delegate = Listener()
|
|
listener.delegate = delegate
|
|
listener.resume()
|
|
|
|
// Beim Beenden — auch durch launchd — die Lüfter freigeben.
|
|
for signalNumber in [SIGTERM, SIGINT] {
|
|
signal(signalNumber, SIG_IGN)
|
|
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main)
|
|
source.setEventHandler {
|
|
delegate.service.restoreAutomatic()
|
|
exit(0)
|
|
}
|
|
source.resume()
|
|
}
|
|
|
|
log.notice("Onyx-Helfer bereit")
|
|
RunLoop.main.run()
|