Pool thermometer via RTL-SDR.

A floating pool thermometer with no API, sniffed out of the air at 915 MHz with an RTL-SDR and a one-line patch to rtl_433. My sensor turned out to be the first unit to disprove a guessed entry in the upstream decoder's checksum table.

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

TP211B float-12.4 dBm TXone packet / 50 spve3 · rtl_433NESDR SMArt v5 · patched masterxor_table[6]: 0xC023 → 0xC463-R 297 · systemdhass VM 200Mosquittosensor.pool_temperature915 MHz FSKMQTTDashed: a signal weak enough to read as noise at fixed gain. AGC plus a 50-second cadence prove it real.The base station isn't in the chain. Its air temp and humidity numbers never leave it; only water temp is on the air.The patched checksum table is load-bearing. Without it, decoder 297 silently discards every frame this unit sends.
One temperature crosses the air, checksummed, every 50 seconds. Everything else the product displays is theater local to the base station.

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_rtl28xxu to 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.