Files
onyx/OnyxHelper/main.swift
Scarriffle b2f69f34ae Ladelimit: Key gemessen, Schreibpfad gebaut
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>
2026-08-11 12:01:50 +02:00

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()