138 lines
7.2 KiB
Swift
138 lines
7.2 KiB
Swift
import Foundation
|
||
import IOKit
|
||
|
||
/// 启动磁盘的 NVMe SMART 健康信息
|
||
public struct DiskHealth: Sendable, Equatable {
|
||
public var model: String
|
||
/// 已消耗的寿命(厂商估算,可以超过 100)
|
||
public var percentageUsed: Int
|
||
public var availableSpare: Int
|
||
public var availableSpareThreshold: Int
|
||
public var temperature: Double?
|
||
public var bytesRead: UInt64
|
||
public var bytesWritten: UInt64
|
||
public var powerOnHours: UInt64
|
||
public var powerCycles: UInt64
|
||
public var unsafeShutdowns: UInt64
|
||
public var mediaErrors: UInt64
|
||
public var criticalWarning: UInt8
|
||
|
||
public var remainingLife: Int { max(0, 100 - percentageUsed) }
|
||
}
|
||
|
||
/// 通过系统自带的 NVMeSMARTLib 插件读取 SMART,不需要 root
|
||
public enum DiskHealthReader {
|
||
public static func read() -> DiskHealth? {
|
||
var iterator: io_iterator_t = 0
|
||
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOBlockStorageDevice"), &iterator) == KERN_SUCCESS else {
|
||
return nil
|
||
}
|
||
defer { IOObjectRelease(iterator) }
|
||
while case let service = IOIteratorNext(iterator), service != 0 {
|
||
defer { IOObjectRelease(service) }
|
||
guard let capable = IORegistryEntryCreateCFProperty(service, "NVMe SMART Capable" as CFString, kCFAllocatorDefault, 0)?
|
||
.takeRetainedValue() as? Bool, capable,
|
||
let bytes = smartData(for: service) else { continue }
|
||
// 型号在上层的 NVMe 控制器上
|
||
let model = (IORegistryEntrySearchCFProperty(service, kIOServicePlane, "Model Number" as CFString, kCFAllocatorDefault,
|
||
IOOptionBits(kIORegistryIterateRecursively | kIORegistryIterateParents)) as? String)?
|
||
.trimmingCharacters(in: .whitespaces) ?? "SSD"
|
||
return parse(bytes, model: model)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// NVMe 1.0c 5.10.1.2:512 字节 SMART / Health 日志,小端,无对齐填充
|
||
static func parse(_ data: [UInt8], model: String) -> DiskHealth? {
|
||
guard data.count >= 192 else { return nil }
|
||
func u64(_ offset: Int) -> UInt64 {
|
||
(0..<8).reduce(UInt64(0)) { $0 | UInt64(data[offset + $1]) << (8 * UInt64($1)) }
|
||
}
|
||
let kelvin = Double(UInt16(data[1]) | UInt16(data[2]) << 8)
|
||
// 数据单位为 1000 个 512 字节扇区
|
||
let unit: UInt64 = 512 * 1000
|
||
return DiskHealth(model: model,
|
||
percentageUsed: Int(data[5]),
|
||
availableSpare: Int(data[3]),
|
||
availableSpareThreshold: Int(data[4]),
|
||
temperature: kelvin > 200 ? kelvin - 273.15 : nil,
|
||
bytesRead: u64(32).multipliedReportingOverflow(by: unit).partialValue,
|
||
bytesWritten: u64(48).multipliedReportingOverflow(by: unit).partialValue,
|
||
powerOnHours: u64(128),
|
||
powerCycles: u64(112),
|
||
unsafeShutdowns: u64(144),
|
||
mediaErrors: u64(160),
|
||
criticalWarning: data[0])
|
||
}
|
||
|
||
private static func smartData(for service: io_service_t) -> [UInt8]? {
|
||
// kIOCFPlugInInterfaceID、kIONVMeSMARTUserClientTypeID、kIONVMeSMARTInterfaceID 是 C 宏,Swift 里按字节重建
|
||
let pluginInterfaceID = CFUUIDGetConstantUUIDWithBytes(nil, 0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4,
|
||
0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F)
|
||
let userClientTypeID = CFUUIDGetConstantUUIDWithBytes(nil, 0xAA, 0x0F, 0xA6, 0xF9, 0xC2, 0xD6, 0x45, 0x7F,
|
||
0xB1, 0x0B, 0x59, 0xA1, 0x32, 0x53, 0x29, 0x2F)
|
||
let smartInterfaceID = CFUUIDGetConstantUUIDWithBytes(nil, 0xCC, 0xD1, 0xDB, 0x19, 0xFD, 0x9A, 0x4D, 0xAF,
|
||
0xBF, 0x95, 0x12, 0x45, 0x4B, 0x23, 0x0A, 0xB6)
|
||
var plugin: UnsafeMutablePointer<UnsafeMutablePointer<IOCFPlugInInterface>?>?
|
||
var score: Int32 = 0
|
||
guard IOCreatePlugInInterfaceForService(service, userClientTypeID, pluginInterfaceID, &plugin, &score) == KERN_SUCCESS,
|
||
let plugin, let vtable = plugin.pointee?.pointee else { return nil }
|
||
defer { _ = vtable.Release(plugin) }
|
||
|
||
var smart: LPVOID?
|
||
guard vtable.QueryInterface(plugin, CFUUIDGetUUIDBytes(smartInterfaceID), &smart) == S_OK, let smart else { return nil }
|
||
// IONVMeSMARTInterface:IUNKNOWN_C_GUTS(4 个指针)、version、revision,之后第一个函数是 SMARTReadData
|
||
let interface = smart.assumingMemoryBound(to: UnsafeMutableRawPointer.self)
|
||
let table = interface.pointee
|
||
typealias Release = @convention(c) (UnsafeMutableRawPointer?) -> UInt32
|
||
typealias ReadData = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> IOReturn
|
||
defer { _ = table.load(fromByteOffset: 24, as: Release.self)(smart) }
|
||
let readData = table.load(fromByteOffset: 40, as: ReadData.self)
|
||
var buffer = [UInt8](repeating: 0, count: 512)
|
||
let result = buffer.withUnsafeMutableBytes { readData(smart, $0.baseAddress) }
|
||
return result == kIOReturnSuccess ? buffer : nil
|
||
}
|
||
}
|
||
|
||
/// 所有块设备的读写速率(字节 / 秒)
|
||
public struct DiskActivity: Sendable, Equatable {
|
||
public var readRate: Double
|
||
public var writeRate: Double
|
||
}
|
||
|
||
/// 读取 IOBlockStorageDriver 的累计读写字节数,两次采样相减得到速率
|
||
public struct DiskActivitySampler {
|
||
private var previous: (date: Date, read: UInt64, write: UInt64)?
|
||
|
||
public init() {}
|
||
|
||
public mutating func sample(now: Date = Date()) -> DiskActivity? {
|
||
guard let totals = Self.totals() else { return nil }
|
||
defer { previous = (now, totals.read, totals.write) }
|
||
guard let previous, now > previous.date else { return nil }
|
||
let seconds = now.timeIntervalSince(previous.date)
|
||
// 磁盘被推出后累计值会变小,这一次不计
|
||
guard totals.read >= previous.read, totals.write >= previous.write, seconds < 60 else { return nil }
|
||
return DiskActivity(readRate: Double(totals.read - previous.read) / seconds,
|
||
writeRate: Double(totals.write - previous.write) / seconds)
|
||
}
|
||
|
||
static func totals() -> (read: UInt64, write: UInt64)? {
|
||
var iterator: io_iterator_t = 0
|
||
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOBlockStorageDriver"), &iterator) == KERN_SUCCESS else {
|
||
return nil
|
||
}
|
||
defer { IOObjectRelease(iterator) }
|
||
var read: UInt64 = 0
|
||
var write: UInt64 = 0
|
||
while case let driver = IOIteratorNext(iterator), driver != 0 {
|
||
defer { IOObjectRelease(driver) }
|
||
guard let statistics = IORegistryEntryCreateCFProperty(driver, "Statistics" as CFString, kCFAllocatorDefault, 0)?
|
||
.takeRetainedValue() as? [String: Any] else { continue }
|
||
read &+= (statistics["Bytes (Read)"] as? NSNumber)?.uint64Value ?? 0
|
||
write &+= (statistics["Bytes (Write)"] as? NSNumber)?.uint64Value ?? 0
|
||
}
|
||
return (read, write)
|
||
}
|
||
}
|