I bought a five-pound BLE thermometer-hygrometer off the usual marketplace, the little white square ones with the e-ink screen. It works fine with its own app. The app is the problem: it wants an account, it phones home, and it has no way to get the reading out into anything I own. I just wanted the temperature in Home Assistant. So I decided to find out what it was actually saying over the air.
The first job is seeing the traffic. On Android, the developer options have a "Bluetooth HCI snoop log" toggle, which dumps every packet to a file you can open in Wireshark. Turn it on, open the vendor app, let it connect and read for a minute, then pull the log off the device. No special hardware needed for a first look, which is a pleasant surprise the first time you discover it.
In Wireshark, filtered to btatt, the structure is the usual BLE story. The device exposes a handful of GATT services, each with characteristics that are just handles you read from or subscribe to. Most of the noise is the standard stuff: device name, battery service, the generic attribute profile. The interesting one was a custom service with a vendor UUID and a characteristic that the app subscribed to for notifications.
The payload was five bytes. Watching it change as I breathed on the sensor told me most of what I needed:
T2 09 1F 64 0A
The first two bytes were temperature as a little-endian signed 16-bit integer in hundredths of a degree, so 0x09T2 read back as roughly 23.5 °C, which matched the screen. The third byte was humidity as a plain percentage. The fourth was battery percent, and the last was a counter that incremented each notification. No encryption, no obfuscation, no checksum worth the name. For a fiver, you get exactly the engineering you pay for.
From there it is just bleak in Python to confirm it from the laptop:
import asyncio
from bleak import BleakClient
ADDR = "A4:C1:38:xx:xx:xx"
CHAR = "0000fff2-0000-1000-8000-00805f9b34fb"
def handle(_, data: bytearray):
temp = int.from_bytes(data[0:2], "little", signed=True) / 100
hum = data[2]
print(f"{temp:.1f} C {hum}% RH batt {data[3]}%")
async def main():
async with BleakClient(ADDR) as c:
await c.start_notify(CHAR, handle)
await asyncio.sleep(30)
asyncio.run(main())
That printed clean readings within seconds, and from there wiring it into Home Assistant via a passive BLE integration was the easy part. The whole exercise took an evening, most of which was squinting at hex and second-guessing the byte order.
The lesson I keep relearning with these gadgets: there is rarely anything clever to defeat. The "protocol" is a few bytes a junior firmware engineer threw together against a deadline. The skill is not in cracking it, it is in being patient enough to watch the numbers move and trust what they tell you. And the reward is a sensor that answers to me instead of to a server in a datacentre I will never see.