A floating thermometer measures the pool and radios the reading to a base station on the kitchen counter every 50 seconds. The number lives on a segment LCD and nowhere else, and Home Assistant runs everything else in this house.
ThermoPro TP211B, sold as the "TempPro TempAir Solar Pool Thermometer" on Amazon (affiliate link). A solar-topped floating water probe and a base station with a display. It transmits FSK at 915 MHz.
I had an RTL-SDR dongle (a Nooelec NESDR SMArt v5) and a Proxmox host, pve3,
within radio range of the pool. I moved antennas and made ice baths; a Claude
Code session drove the dongle over SSH and read hex dumps. Two evenings, start
to sensor.pool_temperature.
Architecture
Two drivers already owned the dongle
Nothing could open the dongle, twice over.
- The RTL2832U in every cheap SDR is a DVB-T TV demodulator that happens to
expose raw samples, so Linux binds
dvb_usb_rtl28xxuto it on sight. - Two seconds later QEMU took it from the kernel. A USB passthrough rule I'd staged on the Home Assistant VM months earlier moved it into the guest.
qm set 200 -delete usb1 got it back from the VM. The DVB driver then won
again, because a modprobe blacklist only affects future loads; a driver already
bound keeps the device until you unbind it through sysfs. The order is unbind,
modprobe -r, then blacklist. Get it wrong and it looks like a broken dongle.
Signal or noise
First listens found smears that read as AGC winding up on an empty band. With a 915 MHz tuned antenna on a USB extension cable, something appeared: RSSI -12.1 dB, SNR 14.1 dB, clean FSK tones about 30 kHz below 915.000. Then a fixed-gain control test found zero detections at any frequency, and we called it threshold noise and went to bed.
The antenna mattered. A 3 dBi 915 MHz SMA whip (affiliate link), marketed for LoRa gateways. In an A/B/A test the dongle's stock antenna caught zero packets, twice; this one caught them all.
The FCC filing (2AATP-TP211B) explains it. The transmitter runs -12.4 dBm into a -5.55 dBi internal antenna, which lands almost exactly on rtl_433's default detection floor of about -12.1 dBFS. At fixed gain it's invisible. On AGC it shows up pinned to the floor with the same RSSI every time, which is also what noise looks like.
The timing gave it away. Four raw captures were 73,428 samples long, identical to within 5 samples, and arrived 50 seconds apart to the second. The manual lists a 50-second refresh.
The ice bath
The base station shows air temperature, humidity and water temperature. I assumed all three were in the packet, and for an evening three variable bytes looked like a fit.
A photo of the display ended that. The air and humidity row has no channel label; those come from sensors inside the base station and never leave it. The float sends one number. That wrong assumption cost more time than anything else in the project.
I dropped the float in a bowl of ice water next to the antenna, and byte 4 fell from 0x42 to 0x3F to 0x3D in a hundred seconds while everything around it held still. Two calibration points gave the formula. Take the 12-bit value, subtract 500 and divide by 10 to get °C. The display's 74.8 °F decoded to 74.84, and the ice bath's 48.4 °F to 48.38.
Carrier 914.971 MHz (nominal 915.000)
Coding FSK, PCM/NRZ, 106 µs/bit (~9.4 kbaud)
Framing 0xAAAA... preamble, 0x2DD4 sync word
Interval 50 seconds
byte 0-2 24-bit sensor ID (mine: 0x037D49)
byte 3 high nibble flags; 0x8 = low battery
byte 3-4 low 12 bits temperature, °C = (raw - 500) / 10
byte 5 0xAA fixed
byte 6-7 16-bit checksum
An aside from that night: pkill -f rtl_433 over SSH matches its own ssh
command line, and it ended the session it was typed in. Use pkill -x.
The quick flex decoder has no checksum support, and near the antenna about a quarter of its frames were corrupt. One would have reported the pool at 130 °F. Nothing without a validated checksum goes to Home Assistant.
The decoder already existed, and rejected every frame
After we'd derived the framing, the formula and a working checksum ourselves, a search of the rtl_433 repo turned up PR #3462, a merged TP211B decoder, in master since February. Two earlier fetches of the tracking issue had both said no decoder existed. Its formula matches the ice bath's character for character.
It also decoded nothing. No tagged release contains protocol 297, so no package
ships it, and a fresh build of master rejected 100% of my frames with
DECODE_FAIL_MIC, which prints nothing at default verbosity.
The checksum is a per-bit XOR table, "more of a custom xor-addition thing" in the words of the upstream issue. Contributors could only measure entries for bits that varied across their samples, roughly the temperature field. The rest was extrapolated from a CRC-16 pattern and labelled a guess, with a standing request for more samples.
Every contributor's sensor ID began 0x00 or 0x01. Mine is 0x037D49. Bit 6 of byte 0 had never been set on any tested unit, and every frame of mine failed by a constant XOR of 0x0440, the signature of exactly one wrong table entry.
The patch
With the measured value in place of the guess, all 17 sample frames published upstream still validate, and all 11 of mine start to. One line:
--- a/src/devices/thermopro_tp211b.c
+++ b/src/devices/thermopro_tp211b.c
@@ -105,7 +105,7 @@
static uint16_t tp211b_checksum(uint8_t const *b)
{
static uint16_t const xor_table[] = {
- 0xC881, 0xC441, 0xC221, 0xC111, 0xC089, 0xC045, 0xC023, 0xC010,
+ 0xC881, 0xC441, 0xC221, 0xC111, 0xC089, 0xC045, 0xC463, 0xC010,
0xC01F, 0xC00E, 0x6007, 0x9002, 0x4801, 0x8401, 0xE201, 0xD101,
0xDE01, 0xCF01, 0xC781, 0xC3C1, 0xC1E1, 0xC0F1, 0xC079, 0xC03D,
0xC029, 0xC015, 0xC00B, 0xC004, 0x6002, 0x3001, 0xB801, 0xFC01,
The wrong value, 0xC023, is what a textbook LFSR chain predicts for that
position. The vendor's firmware apparently differs from the textbook at that
one bit, which no pattern-based extrapolation could have produced. The patch
lives in my homelab repo with the full derivation in its header, because any
git pull of rtl_433 silently reverts it and takes the pool offline.
With it applied:
{"model": "ThermoPro-TP211B", "id": 228681, "battery_ok": 1,
"temperature_C": 18.900, "mic": "CHECKSUM"}
"mic": "CHECKSUM" means the frame passed integrity checking.
Into Home Assistant
A systemd unit on pve3 runs the patched build with -R 297 and publishes
each field to the Mosquitto broker on the Home Assistant VM:
rtl_433/ThermoPro-TP211B/228681/temperature_C 18.3
rtl_433/ThermoPro-TP211B/228681/battery_ok 1
A retained MQTT discovery message creates the entities, with no YAML edit and no restart. Three settings matter.
- Publish °C and let Home Assistant convert for display. Converting first would double-convert.
expire_after: 300, so a dead float goes unavailable instead of reporting a stale value forever.- An availability topic wired to rtl_433's LWT, so a dead service shows up immediately.
Loose ends
- Bits 0 through 5 of byte 0 are still unmeasured guesses. Probing them takes a TP211B with an ID above 0x04xxxx, and mine can't. Does the vendor's firmware wander off the textbook sequence at those bits too? One unit can't say.
- The decoder predicts byte 3 flips from 0x02 to 0x03 when the water crosses 80.2 °F, and the pool hasn't been warm enough to check.
- I still haven't sent the fix upstream, which is the same kind of sitting-on-data that made us re-derive a protocol somebody had already decoded.