Field note · engineering

Reading SMC sensors from Swift without a privileged helper

Temperature and fan readings on a Mac come from an undocumented interface with no stable key set. Here is how to read it honestly, including what to do when the key is not there.

Implementation described is Searoom 0.1.1. Sensor availability was observed on a MacBook Pro, Apple M5 Pro, 48 GB, macOS 27.0 (build 26A5421a). Key names and availability vary by Mac model and macOS release, which is the central point of this article.

On the Mac this was written on, Searoom cannot read CPU temperature. None of the twelve candidate SMC keys it tries returned a valid reading, so it falls back to the battery pack sensor and labels the source BAT. Both fans are readable and both report 0 RPM, because the machine is cool enough not to spin them.

That is not a bug report. It is the normal condition of sensor access on macOS, and designing around it is most of the work.

The interface

The System Management Controller is reachable through IOKit as a service named AppleSMC. Opening it needs no entitlement, no privileged helper, and no subprocess. It is read-only access to a device that is already there. The two calls that get you there, IOServiceGetMatchingService and IOServiceOpen, are documented Apple APIs.

io_service_t service = IOServiceGetMatchingService(
    kIOMainPortDefault,
    IOServiceMatching("AppleSMC")
);
kern_return_t result = IOServiceOpen(service, mach_task_self(), 0, &connection);

Every read is a struct-based call through selector 2, which the community has long called kSMCHandleYPCEvent. Keys are four-character codes packed into a uint32_t: TC0P for a CPU proximity temperature, F0Ac for the actual speed of fan zero.

The transport is documented. The key set is not. Apple publishes no list of SMC keys, no encodings, and no stability guarantee, so the names and types below come from reading the device and from the open-source projects that have catalogued it over the years, principally VirtualSMC's key documentation and libsmc. Treat all of it as observed behaviour rather than as an interface contract, because that is exactly what it is.

A read takes two calls, not one. The first asks for key info, which returns the payload's size and type. The second asks for the bytes. You need the type from the first call to decode the second, and you need the size to know how much of the buffer is meaningful.

input.data8 = kSMCCmdReadKeyInfo;   // 9, returns dataSize and dataType
// ...then...
input.keyInfo.dataSize = key_info.dataSize;
input.data8 = kSMCCmdReadBytes;     // 5, returns the payload

Skipping the first call and assuming a type is where most SMC code goes wrong, because the type genuinely varies by machine for the same logical sensor.

Decoding is per type, and one of them is a trap

The SMC returns several encodings. Four cover almost everything a monitor needs.

SMC payload types and their decoding
TypeEncodingDecode
sp78Signed fixed point, 7 integer and 8 fractional bitsBig-endian int16, divided by 256
fpe2Unsigned fixed point, 14 integer and 2 fractional bitsBig-endian uint16, divided by 4
flt IEEE 754 singleNative byte order, copied out
ui8  / ui16Unsigned integerBig-endian, used directly

The trap is that sp78, fpe2, and ui16 are big-endian, while flt  is the processor's native little-endian float. Decoding a flt  payload with the same byte-swapping used for the fixed-point types produces a plausible-looking number that is entirely wrong.

The float case also needs memcpy rather than a pointer cast. The payload lives in a uint8_t buffer with no alignment guarantee, and reinterpreting that pointer as a float* is undefined behaviour that happens to work until a compiler decides otherwise.

float decoded;
memcpy(&decoded, value->bytes, sizeof(decoded));
return decoded;

Searoom's own self-test fixtures encode both encodings for the same logical sensor, named m5_fan for the flt  case and fixed_fan for the fpe2 case. Whichever machine produces which, both paths have to work, and the type returned by the key-info call is what decides.

Testing a decoder with no hardware

Sensor code is awkward to test because the values depend on the machine. The decoding, however, is pure. Given known bytes and a known type, there is exactly one correct answer, and that can be asserted anywhere.

const SMCValue m5_fan = {
    .dataSize = 4,
    .dataType = four_char_code("flt "),
    .bytes = {0x00, 0xe8, 0x9a, 0x45}
};
// 0x459AE800 == 4957.0

const SMCValue fixed_fan = {
    .dataSize = 2,
    .dataType = four_char_code("fpe2"),
    .bytes = {0x5d, 0xc0}
};
// 0x5DC0 == 24000, / 4 == 6000.0

The self-test also asserts that a zero-length payload decodes to NaN rather than to zero. That distinction runs through the whole design: an absent reading must never arrive as a plausible number.

This check runs through Searoom --self-test, which is deliberately independent of any test framework so it works on a machine with only the command-line developer tools installed.

Discovery has to be defensive, and it has to give up

There is no universal CPU temperature key. Intel Macs commonly expose TC0P or TC0E. Apple Silicon Macs use performance-core keys such as Tp01 or Tp05. Some machines expose none that a third-party process can read.

Searoom tries a list in order, validates the result, and remembers the index that worked:

static const char *keys[] = {
    "TC0P", "TC0E", "TC0F", "TC0D", "TC0H", "TC0C",
    "Tp01", "Tp05", "Tp09", "Tp0P", "Te05", "Te0P"
};
static int selected_key = -1;
static bool sensor_search_complete = false;

Validation is a plausibility range: a reading is accepted only if it is finite and between 10 and 125 degrees Celsius. A key can exist, return successfully, and contain garbage. A silicon temperature of 0.02 or 3000 is not a temperature, and forwarding it because the call returned KERN_SUCCESS would put a fabricated number on screen.

The sensor_search_complete latch is the part that is easy to leave out. Without it, a Mac with no readable temperature key retries all twelve keys on every sampling tick, forever. That converts an unsupported sensor into a permanent stream of IOKit calls. One failed search is enough to conclude the answer is no.

Fan discovery works the same way, probing F0Ac through F9Ac and recording which indices responded in a bitmask, so subsequent reads skip the fans that do not exist.

The connection is process-wide state

The SMC connection and the discovered key index are both static. They are cached once and reused for the life of the process, which is the right call for a value read every few seconds.

It also means this code is not thread-safe and was never intended to be. Searoom reaches it only from a single serial dispatch queue that owns the entire sampling pipeline. There is one timer, one collector, and no parallel path to these functions. Calling them from two queues would race on the connection handle and on the discovery latches.

This constraint is written into the project's contributor contract rather than left implicit, because it is the kind of invariant that a well-meaning refactor breaks silently.

What best-effort actually means

On the M5 Pro used here, the outcome is:

Sensor availability, M5 Pro, macOS 27.0
SensorResult
CPU or package temperatureNo candidate key returned a valid reading
Temperature actually shown29.5 °C from the battery pack, labelled BAT
FansTwo discovered, both reporting 0 RPM

The temperature source travels with the temperature so that a battery reading is never presented as a CPU reading. They are different physical quantities and a battery pack at 29.5 °C tells you nothing about what a performance core is doing.

Where nothing is readable, the value is unavailable and the interface says so. Substituting zero would be a measurement claim, and it would be false. That rule is worth more than any individual sensor: a monitor that admits what it cannot see is the only kind whose other numbers you have reason to trust.

Update, 15 September 2026

The key list grew. The probe now also tries Tp04, Tp08 and Tp0C, the performance-core temperature keys this M-series generation exposes as flt payloads, and on the same M5 Pro the package sensor reads directly: around 52 °C when lightly loaded, rising past 70 °C under a twelve-core synthetic load, labelled CPU PACKAGE in the UI. The earlier M-series keys stay ahead of the newer ones in the probe order, so each generation still finds its own sensor, and everything else in this article stands: the fallback to the battery pack when no key decodes, the source label travelling with the reading, and the plausibility gate that rejects a fabricated zero.

The source for all of this is Sources/CSearoomSensors/SearoomSensors.c in the Searoom repository, under the MIT licence. Definitions for the values it produces are in the metric reference.

Sources