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 payloadSkipping 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.
| Type | Encoding | Decode |
|---|---|---|
sp78 | Signed fixed point, 7 integer and 8 fractional bits | Big-endian int16, divided by 256 |
fpe2 | Unsigned fixed point, 14 integer and 2 fractional bits | Big-endian uint16, divided by 4 |
flt | IEEE 754 single | Native byte order, copied out |
ui8 / ui16 | Unsigned integer | Big-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.0The 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 | Result |
|---|---|
| CPU or package temperature | No candidate key returned a valid reading |
| Temperature actually shown | 29.5 °C from the battery pack, labelled BAT |
| Fans | Two 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.