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}