I bought a cheap BLE temperature and humidity sensor, the little white square sort that everyone has seen, and it came with the usual deal: lovely hardware, dreadful app. The app wanted an account. The app wanted the cloud. The app, for a device whose entire job is to say "it is 19 degrees in here", wanted permissions I would not grant a banking app. So I decided to skip it.
The good news with BLE is that it is rarely a fortress. These devices are built to a price, and that price does not include a security engineer. Most of them advertise their readings in the clear, either in the advertisement packets themselves or behind a GATT characteristic that anyone can read once they know its handle.
I started with bluetoothctl and a scan, then moved to nRF Connect on a phone to walk the GATT table, because nothing beats a tool that just lists every service and characteristic the device exposes. There it was: a custom service with a notify characteristic. Subscribe to it, and every few seconds the gadget pushes a handful of bytes.
The fun part is the bytes. You get something like:
T (notify) value: 0x9d 0x07 0x4e 0x16 0x64 ...
and now it is a puzzle. The trick is to make the value change in a way you control and watch which bytes move. I breathed on it. Humidity climbed, and the fourth and fifth bytes climbed with it, so those are humidity. I cupped it in my hands to warm it, and 0x9d 0x07 crept upward together. Two bytes, little-endian: 0x079d is 1949, and 1949 divided by 100 is 19.49 degrees. That is a textbook pattern, a signed 16-bit value in hundredths, little-endian because almost everything is. Humidity turned out to be a single byte as a whole-number percentage, which the breath test confirmed when it hit 64 hex, exactly 100, when I held it against my mouth.
There was a battery byte too, sitting near the end and not changing, which is what battery bytes do until the day they matter.
From there it was a short script. A tiny bit of Python with bleak, subscribe to the characteristic, unpack the bytes, and publish them to my MQTT broker so the rest of my home setup can see them. No account, no cloud, no app that wants my location to tell me the temperature.
def decode(data: bytes) -> dict:
temp = int.from_bytes(data[0:2], "little", signed=True) / 100
humidity = data[4]
battery = data[-1]
return {"temp_c": temp, "humidity": humidity, "battery": battery}
None of this is clever. That is rather the point. The whole exercise took an evening, most of it spent breathing on a small plastic square and watching numbers, and at the end I had a sensor that does exactly what I bought it for and nothing else. The vendor app is uninstalled. The gadget never noticed.