Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

macos-collector/Sources/CollectorCore/Hardware.swift

166 lines · 6580 bytes

  1import Foundation
  2import IOKit
  3import IOKit.ps
  4
  5// The hardware reads. IOKit-only, no `powermetrics`, no root:
  6//   * CPU load     Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` deltas
  7//   * battery %    IOKit power sources (`IOPSCopyPowerSourcesInfo`)
  8//   * charging     IOKit power sources
  9//   * battery draw IORegistry `AppleSmartBattery` (Amperage * Voltage)
 10//   * thermal      `ProcessInfo.thermalState` (aggregate enum, no root)
 11//
 12// GPU utilization and fan RPM are reachable in principle via `IOReport`/AppleSMC
 13// but only with chip-generation-specific, undocumented channel spelunking. A
 14// metric that cannot be read cleanly without root is *omitted from the schema*
 15// rather than gated behind sudo. They are intentionally absent here.
 16//
 17// Every value produced is an aggregate scalar. There is no code path here that
 18// reads user content  this file cannot, because it only ever touches hardware
 19// counters and power registers.
 20
 21/// Cumulative CPU ticks summed across all cores.
 22private struct CPUTicks {
 23    var used: UInt64
 24    var total: UInt64
 25}
 26
 27/// Read cumulative CPU ticks via Mach `host_processor_info`. Non-privileged.
 28private func readCPUTicks() -> CPUTicks? {
 29    var info: processor_info_array_t?
 30    var infoCount: mach_msg_type_number_t = 0
 31    var cpuCount: natural_t = 0
 32
 33    let result = host_processor_info(
 34        mach_host_self(),
 35        PROCESSOR_CPU_LOAD_INFO,
 36        &cpuCount,
 37        &info,
 38        &infoCount
 39    )
 40    guard result == KERN_SUCCESS, let info else { return nil }
 41    defer {
 42        let addr = vm_address_t(UInt(bitPattern: UnsafeRawPointer(info)))
 43        vm_deallocate(mach_task_self_, addr, vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
 44    }
 45
 46    let states = Int(CPU_STATE_MAX)
 47    var used: UInt64 = 0
 48    var total: UInt64 = 0
 49    for cpu in 0..<Int(cpuCount) {
 50        let base = cpu * states
 51        let user = UInt64(info[base + Int(CPU_STATE_USER)])
 52        let system = UInt64(info[base + Int(CPU_STATE_SYSTEM)])
 53        let idle = UInt64(info[base + Int(CPU_STATE_IDLE)])
 54        let nice = UInt64(info[base + Int(CPU_STATE_NICE)])
 55        used += user + system + nice
 56        total += user + system + nice + idle
 57    }
 58    return CPUTicks(used: used, total: total)
 59}
 60
 61/// Aggregate CPU load sampler. Load is `Δbusy / Δtotal` between successive
 62/// samples, so the first `sample()` primes and returns `nil`.
 63public final class CPUSampler {
 64    private var prev: CPUTicks?
 65
 66    public init() {}
 67
 68    /// Busy fraction in `[0, 1]` since the previous sample, or `nil` on the
 69    /// priming call / if the counters are unavailable.
 70    public func sample() -> Double? {
 71        guard let cur = readCPUTicks() else { return nil }
 72        defer { prev = cur }
 73        guard let prev else { return nil }
 74        let dTotal = cur.total >= prev.total ? cur.total - prev.total : 0
 75        let dUsed = cur.used >= prev.used ? cur.used - prev.used : 0
 76        guard dTotal > 0 else { return nil }
 77        return min(1.0, Double(dUsed) / Double(dTotal))
 78    }
 79}
 80
 81/// A snapshot of the internal battery, if present.
 82public struct BatteryInfo {
 83    public var pct: Double?
 84    public var charging: Bool?
 85}
 86
 87/// Read battery percentage and charging state via IOKit power sources. No root.
 88/// Returns empty fields on a machine with no internal battery (e.g. a desktop).
 89public func readBattery() -> BatteryInfo {
 90    guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
 91          let list = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef]
 92    else {
 93        return BatteryInfo(pct: nil, charging: nil)
 94    }
 95
 96    for source in list {
 97        guard let desc = IOPSGetPowerSourceDescription(blob, source)?.takeUnretainedValue() as? [String: Any]
 98        else { continue }
 99        if let type = desc[kIOPSTypeKey] as? String, type != kIOPSInternalBatteryType {
100            continue
101        }
102        var pct: Double?
103        if let cur = (desc[kIOPSCurrentCapacityKey] as? NSNumber)?.doubleValue,
104           let max = (desc[kIOPSMaxCapacityKey] as? NSNumber)?.doubleValue, max > 0 {
105            pct = cur / max * 100.0
106        }
107        let charging = (desc[kIOPSIsChargingKey] as? NSNumber)?.boolValue
108        return BatteryInfo(pct: pct, charging: charging)
109    }
110    return BatteryInfo(pct: nil, charging: nil)
111}
112
113/// Instantaneous battery draw in watts from the IORegistry `AppleSmartBattery`
114/// node (Amperage in mA, Voltage in mV). No root. `nil` on a machine with no
115/// smart battery.
116public func readBatteryDrawW() -> Double? {
117    let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"))
118    guard service != 0 else { return nil }
119    defer { IOObjectRelease(service) }
120
121    func number(_ key: String) -> Double? {
122        guard let cf = IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0)?
123            .takeRetainedValue() as? NSNumber
124        else { return nil }
125        return cf.doubleValue
126    }
127    guard let amperage = number("Amperage"), let voltage = number("Voltage") else { return nil }
128    // Amperage is signed (negative = discharging); draw magnitude is what we map.
129    return abs(amperage / 1000.0 * voltage / 1000.0)
130}
131
132/// Aggregate thermal state as `0..3` (nominal/fair/serious/critical). Sourced
133/// from `ProcessInfo`, not `powermetrics`; no root, no per-sensor detail.
134public func readThermalState() -> Double {
135    switch ProcessInfo.processInfo.thermalState {
136    case .nominal: return 0
137    case .fair: return 1
138    case .serious: return 2
139    case .critical: return 3
140    @unknown default: return 0
141    }
142}
143
144/// Collect one tick of hardware signals. Signals whose sensor is absent (no
145/// battery, priming CPU sample) are simply omitted  never faked.
146public func collectHardwareSignals(cpu: CPUSampler, now: UInt64) -> [Signal] {
147    var out: [Signal] = []
148
149    if let load = cpu.sample() {
150        out.append(Signal(ts: now, source: .hardware, name: .cpuLoad, value: load))
151    }
152
153    let battery = readBattery()
154    if let pct = battery.pct {
155        out.append(Signal(ts: now, source: .macos, name: .batteryPct, value: pct))
156    }
157    if let charging = battery.charging {
158        out.append(Signal(ts: now, source: .macos, name: .charging, value: charging ? 1.0 : 0.0))
159    }
160    if let watts = readBatteryDrawW() {
161        out.append(Signal(ts: now, source: .hardware, name: .batteryDrawW, value: watts))
162    }
163
164    out.append(Signal(ts: now, source: .macos, name: .thermalState, value: readThermalState()))
165    return out
166}