Ramblings of an aging IT geek
← Ramblings of an aging IT geek
hardware

prodding a £6 bluetooth thermometer until it talked

How I sniffed, decoded and eventually scripted the BLE protocol of a cheap fridge thermometer so it could log into my own homelab.

A soldering iron and electronics on a bench

I bought a little Bluetooth thermometer to sit in the fridge. Six quid, white plastic puck, comes with an app that wants a login, an account, and presumably my soul. All I wanted was the temperature in Grafana next to everything else. So I decided to skip the app and talk to the thing directly. This is the story of how that went, including the dead ends, because the dead ends are where the learning is.

The starting point: it speaks BLE, but what?

The gadget advertises itself over Bluetooth Low Energy. BLE is, mercifully, fairly structured. A device exposes services, each service contains characteristics, and characteristics are where the data lives. You read them, you write them, or you subscribe to notifications and the device pushes updates. So the first job is just to enumerate what's there.

On Linux this is bluetoothctl and gatttool from BlueZ. Scan for the device, grab its MAC, then dump the GATT table.

$ sudo hcitool lescan
LE Scan ...
A4:C1:38:XX:XX:XX (unknown)

$ gatttool -b A4:C1:38:XX:XX:XX --primary
attr handle: 0x0001, end grp handle: 0x0009 uuid: 00001800-0000-1000-8000-00805f9b34fb
attr handle: 0x000a, end grp handle: 0x000e uuid: 0000180a-0000-1000-8000-00805f9b34fb
attr handle: 0x000f, end grp handle: 0xffff uuid: 0000fff0-0000-1000-8000-00805f9b34fb

The first two are standard: Generic Access and Device Information. The interesting one is fff0, which is a vendor "I couldn't be bothered with a real UUID" service. That's where the temperature is going to live. Dumping its characteristics gave me fff1 and fff2, one of which had the notify property set.

A circuit board close up

Watching it talk

Reading fff1 directly gave back a blob of hex that didn't obviously contain a temperature. So I subscribed to notifications and watched what arrived while I warmed the sensor with my hand.

$ gatttool -b A4:C1:38:XX:XX:XX --char-write-req \
    --handle=0x0011 --value=0100 --listen
Notification handle = 0x0010 value: 02 e8 00 a4 01 5a
Notification handle = 0x0010 value: 02 f2 00 a4 01 5a
Notification handle = 0x0010 value: 02 ff 00 a4 01 5a

Now we're getting somewhere. Writing 0100 to the right handle is the BLE way of saying "please enable notifications for the characteristic just below this one". The handle 0x0011 is the Client Characteristic Configuration Descriptor, the CCCD, which is the bit beginners always miss. You don't subscribe to the characteristic, you write to the little descriptor that hangs off it.

The values changed as I held it. The second byte climbed: e8, f2, ff. That's 232, 242, 255 in decimal, going up as it warmed. The room was about 23 degrees and the fridge readout had said 23.2 earlier. 0x00e8 is 232. Divide by ten: 23.2. The temperature is a little-endian 16-bit integer, in tenths of a degree, sat in bytes two and three.

That left a4 01 and the trailing 5a. 0x01a4 is 420, which after the hand-warming experiment I'm fairly sure is the humidity in tenths of a percent, 42.0%. The final 5a didn't change at all, so it's either a fixed device ID byte or a battery level I couldn't be bothered to drain to confirm. I left it as "probably battery" and moved on. Knowing when to stop reverse-engineering is half the skill.

Another view of the circuit board

Scripting it properly

gatttool is fine for poking but it's deprecated and fiddly to automate. For the actual logger I moved to Python with bluepy, which wraps BlueZ in something you can write a loop around.

from bluepy import btle
import struct

class Delegate(btle.DefaultDelegate):
    def handleNotification(self, cHandle, data):
        temp = struct.unpack("<H", data[1:3])[0] / 10.0
        humid = struct.unpack("<H", data[3:5])[0] / 10.0
        print(f"temp={temp:.1f}C humidity={humid:.1f}%")

dev = btle.Peripheral("A4:C1:38:XX:XX:XX")
dev.setDelegate(Delegate())
# enable notifications by writing to the CCCD
dev.writeCharacteristic(0x0011, b"\x01\x00", withResponse=True)

while True:
    dev.waitForNotifications(30.0)

struct.unpack("<H", ...) is doing the work the hex maths did by hand: little-endian (<) unsigned short (H). From there it's a small step to push the values at a local InfluxDB and forget the whole thing exists, which is the goal of all good homelab projects.

What I'd tell past me

A few things I learned the hard way and would now do first.

  • Get the device's connection interval right. These cheap things sleep aggressively to save battery, so they only show up in a scan when they decide to advertise. If lescan finds nothing, wait, or poke the physical button.
  • The CCCD write is the single most common stumbling block. If notifications never arrive, you almost certainly wrote to the wrong handle. The CCCD is the handle immediately after the characteristic value handle.
  • Don't fight the encryption that isn't there. Cheap gadgets like this one have no pairing and no security at all, which is convenient for me and faintly alarming for everyone who owns one. There was nothing to crack, just a format to read.

The whole thing took an evening, most of which was staring at hex going "what makes you change". For six quid I now have a fridge sensor that answers to me and not to some company's cloud that'll be switched off in two years. That trade is one I'll make every time.