# 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.

- Source: https://nabkey.com/tinkerings/pool-thermometer
- Author: Chris Nabkey
- Published: 2026-08-09
- Built: 2026
- Status: shipped
- Stack: RTL-SDR, rtl_433, FSK, MQTT, Home Assistant, Proxmox

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](https://www.amazon.com/dp/B0FLCCLFWR?tag=nabkey-20) (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

<figure class="diagram">
  *Diagram: Pool thermometer architecture: a TP211B float transmits FSK at 915 MHz every 50 seconds; an RTL-SDR on the Proxmox host pve3 feeds a locally patched rtl_433 build, which publishes over MQTT to Mosquitto on the Home Assistant VM, appearing as sensor.pool_temperature.* ([view it on nabkey.com](https://nabkey.com/tinkerings/pool-thermometer))
  <figcaption>One temperature crosses the air, checksummed, every 50 seconds. Everything else the product displays is theater local to the base station.</figcaption>
</figure>

## 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](https://amzn.to/3TQ5jXo)
> (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:

```diff
--- 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:

```json
{"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.
