I bought a cheap Bluetooth temperature and humidity sensor, the small white square kind you find for under a tenner. The official app is fine until it isn't: it wants an account, it phones home, and it absolutely will not give me the raw number to put on my own dashboard. So I decided to find out what it was actually saying over the air. This is the whole process, because it's the same process every time and it's genuinely satisfying.
the lay of the land
BLE devices expose their functionality through GATT: a tree of services, each containing characteristics, each with a UUID and a set of permissions (read, write, notify). A sensor like this almost always works one of two ways. Either it advertises its reading in the advertisement packet itself (no connection needed, lovely), or you connect, subscribe to a "notify" characteristic, and it pushes you a few bytes every so often. The job is to find which, and then decode the bytes.
First, just look. bluetoothctl on Linux will scan and show you advertisements:
[bluetooth]# scan on
[NEW] Device A4:C1:38:XX:XX:XX LYWSD03MMC
That MAC prefix (A4:C1:38) is Telink, which powers a lot of these cheap sensors, and a good sign the internals are well-trodden ground. The advertisement here didn't carry the reading in any obvious form, so it was going to be the connect-and-subscribe route.
sniffing the conversation
The fastest way to understand a device is to watch the official app talk to it and copy its homework. On Android, the Bluetooth HCI snoop log is the magic feature: enable it in developer options, open the app, let it fetch a reading, then pull the log and open it in Wireshark.
What you're looking for in the trace is short and specific. The app connects, then does a Write Request to some characteristic handle (often writing 0x01 0x00 to a Client Characteristic Configuration Descriptor, which is the standard "please start sending me notifications" handshake), and then the device starts firing Handle Value Notification packets back. Those notifications are your data. Note the characteristic UUID it subscribed to, and grab a few example payloads.
decoding the bytes
For this sensor the notification payload was five bytes, and the trick to decoding any small sensor payload is to change the world and watch which bytes change. Breathe on it, watch humidity climb. Cup it in your hands, watch temperature rise. With a couple of known readings from the official app to anchor against, the structure fell out:
A8 09 4B 64 0B
└─┬─┘ └�some┘ └─ battery (volts, /1000 → 2.827)
│
└─ temperature, little-endian int16, /100 → 0x09A8 = 2472 = 24.72 C
So bytes 0–1 are a little-endian signed 16-bit temperature in hundredths of a degree, byte 2 is humidity as a plain percentage (0x4B = 75%), and bytes 3–4 are battery millivolts, again little-endian. Little-endian and a fixed scale factor is the single most common pattern in this corner of the world, so it's always the first thing to try.
driving it myself
With the UUID and the format known, I never have to touch the official app again. bleak, the cross-platform Python BLE library, is my tool of choice here because it hides the platform differences and the API maps cleanly onto the GATT model:
import asyncio
from bleak import BleakClient
ADDR = "A4:C1:38:XX:XX:XX"
CHAR = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6" # the notify characteristic
def handle(_, data: bytearray):
temp = int.from_bytes(data[0:2], "little", signed=True) / 100
humidity = data[2]
millivolts = int.from_bytes(data[3:5], "little")
print(f"{temp:.2f} C, {humidity}% RH, {millivolts/1000:.3f} V")
async def main():
async with BleakClient(ADDR) as client:
await client.start_notify(CHAR, handle)
await asyncio.sleep(30)
asyncio.run(main())
Thirty lines, and now the reading goes wherever I want it, which in practice means into MQTT and onto a Grafana panel next to everything else.
the part worth keeping
None of this required a logic analyser or anything you'd call hardware hacking, it was all done with software the sensor's own app handed me by talking out loud. The reusable lesson is the loop: scan to identify, sniff the official app to find the handshake and the characteristic, perturb the physical world to map the bytes, then re-implement. The same four steps work on smart plugs, scales, plant monitors, and most of the cheap IoT tat that wants to own your data. Quite often the thing was honest all along; you just had to learn its dialect.