Objective

The objective was to take an unknown, faint signal around 434 MHz and determine whether it was a real over-the-air RF signal, receiver artifact, local spur, or a decodable short-range-device transmission.

The signal initially looked suspicious because it appeared close to 0 Hz IF in a waterfall view. Signals at the center of a receiver view deserve extra skepticism: they can be real RF exactly at the tuned center, but they can also be DC offset, LO leakage, IQ imbalance, or other receiver artifacts.

The final result was a clean decode:

Protocol : rtl_433 protocol 56
Model    : TFA-Pool
ID       : 110
Channel  : 3
Battery  : OK
Temp     : 26.4 °C
MIC      : CHECKSUM
Mod      : ASK

My opinion: the interesting part of this case was not only the final decode. The valuable part was the route from a weak visual candidate to a validated and decoded RF transmission without jumping to conclusions.

Setup

Hardware and environment:

SDR:      Nuand bladeRF 2.0 micro xA5
Antenna:  Diamond D3000N discone, fixed high-floor location
OS:       Kali Linux
Tools:    bladeRF-cli, SDR++, Python, scipy/numpy/matplotlib, rtl_433
Main raw format: bladeRF sc16q11 interleaved IQ

Important bladeRF capture detail: I avoided low sample rates such as 500 ksps and used 625 ksps for raw bladeRF captures. On this bladeRF xA5 setup, 625 ksps is a practical minimum working rate. RX gain was set only after disabling AGC.

Reliable capture pattern:

bladeRF-cli -e "set frequency rx <FC>; set samplerate rx 625k; set bandwidth rx 300k; set agc rx off; set gain rx 35; rx config file=<file>.sc16q11 format=bin n=<samples>; rx start; rx wait"

Initial observation

The first visual clue was a weak, structured feature close to the center of a 434 MHz recording.

Initial quicklook with the suspicious center-region feature circled.

A focused zoom around the event showed a structured burst near DC rather than a random noise patch.

Focused spectrogram around the original center-frequency event.

Early measurements from the original recording:

Approximate RF, if real: 434.086917 MHz
Event time:               about 25.35 s to 26.30 s
Duration:                 about 0.95 s
Dominant envelope rate:   402.83 Hz
Main harmonics:           805.66, 1208.50, 1611.33 Hz

At this stage the signal was not yet trusted, because it sat almost exactly at 0 Hz IF.

Validation methodology

1. Shifted-center test

The decisive artifact test was to retune the receiver center frequency while keeping the same suspected RF target. If the signal is real RF, it should move to the predicted offset. If it is DC/LO leakage, it should stay at 0 Hz.

Two shifted captures were used:

bladeRF-cli -e "set frequency rx 434036917; set samplerate rx 625k; set bandwidth rx 300k; set agc rx off; set gain rx 35; rx config file=ctrl_434036917_625k_g35_60s.sc16q11 format=bin n=37500000; rx start; rx wait"

bladeRF-cli -e "set frequency rx 434136917; set samplerate rx 625k; set bandwidth rx 300k; set agc rx off; set gain rx 35; rx config file=ctrl_434136917_625k_g35_60s.sc16q11 format=bin n=37500000; rx start; rx wait"

Expected result for a real RF signal near 434.086917 MHz:

FC 434.036917 MHz -> signal should appear near +50 kHz
FC 434.136917 MHz -> signal should appear near -50 kHz

That is exactly what happened.

Shifted-center validation: low-center capture places the signal at +50 kHz.

Shifted-center validation: high-center capture places the signal at -50 kHz.

The same envelope-rate signature appeared at both shifted RF offsets:

402.8 Hz
805.7 Hz
1208.5 Hz
1611.3 Hz

This ruled out a simple DC/LO baseband artifact.

2. Antenna-disconnect control

The second control was an antenna-disconnect test using the same receiver settings. With the antenna disconnected, the signal disappeared.

That moved the classification from “RF-domain candidate” to confirmed external over-the-air RF.

Signal characterization

After shifting the signal to baseband and filtering it, I looked at modulation behavior.

Channelized spectrogram around the target after shifting the signal to baseband.

Instantaneous frequency did not show stable two-tone rails, so I rejected 2-FSK as the primary explanation. The envelope, however, was highly structured.

Filtered envelope of a representative event.

The envelope spectrum had a strong fundamental around 402.83 Hz, equivalent to a period of about 2.482 ms, with harmonics at 2x, 3x, and 4x. This suggested a pulse-timing modulation rather than FSK.

A pulse-grid view initially showed a repeating clock but did not yet produce a clean binary OOK symbol stream by simple median slicing.

Pulse grid folded around the 402.83 Hz timing estimate.

The pulse-power histogram was not strongly bimodal, which was a useful warning: naive “on/off” slicing at the 402.83 Hz grid was not the right protocol representation.

Pulse-power histogram from the pulse-grid experiment.

Event cadence

A 300 second capture showed repeated events with a near-constant cadence.

Strong target-offset windows appeared at approximately:

22 s
58 s
94 s
129–130 s
165 s
200–201 s
236 s
272 s

That corresponds to a repeat interval of about 35.5 to 36 seconds.

Detected event cadence across the 300 second capture.

This cadence is consistent with a low-power sensor periodically reporting a value.

Extracting manageable IQ slices

The full raw capture was large, so I extracted small IQ WAV slices around individual events. The extracted WAV preserved complex IQ; it was not converted to audio or MP3.

Representative extractor command:

./extract_sc16q11_iqwav.py \
  rf434087_off50k_625k_g35_300s.sc16q11 \
  --fs 625000 \
  --fc 434036917 \
  --target 434086917 \
  --start 164.20 \
  --seconds 3.20 \
  --lpf-hz 18000 \
  --out-fs 50000 \
  --out best_434087_event_iq_50ksps.wav

The generated metadata for the representative slice:

source_file=rf434087_off50k_625k_g35_300s.sc16q11
source_format=sc16q11 interleaved IQ
source_fs=625000
source_fc=434036917
target_rf=434086917
target_offset=+50000.0
start=164.200000
seconds=3.200000
out_fs=50000
out_format=stereo WAV IQ, ch0=I, ch1=Q, int16
lpf_hz=18000.0

Custom demodulation path

Before using a known decoder, I built a simple pulse-presence demodulator. It found a stable repeated slot frame.

Representative output:

active: 0.777120s - 1.745080s  duration=0.967960s
clock: 402.830 Hz
slot_period: 2.482 ms
peaks: 253
slots: 382
ones/pulses: 253
zeros/missing: 129

Top slot autocorrelation lags:
  lag= 44  corr=0.861
  lag= 88  corr=0.753
  lag=132  corr=0.637

Best 44-slot framing:
  offset: 0
  blocks: 8
  avg_hamming: 0.25
  template: 10001011011101011010101111101111101111010101
  hex pulse=1: 0x8b75abefbd5
  inverted:    01110100100010100101010000010000010000101010
  hex inv:     0x748a541042a

This was a useful fingerprint, but it was not the final protocol bitstream. It represented pulse presence on a derived timing grid. The actual protocol uses pulse-position coding, so the next step was to feed the extracted IQ into rtl_433.

rtl_433 analysis

The extracted IQ WAV was converted to unsigned 8-bit complex samples for rtl_433:

./iqwav_to_cu8.py best_434087_event_iq_50ksps.wav best_434087_event_iq_50ksps.cu8

Pulse analyzer command:

rtl_433 \
  -r best_434087_event_iq_50ksps.cu8 \
  -s 50000 \
  -A \
  -M time:iso \
  -M level \
  -M protocol \
  -vv

Raw-analyzer-only command:

rtl_433 \
  -r best_434087_event_iq_50ksps.cu8 \
  -s 50000 \
  -R 0 \
  -A \
  -vv

The pulse analyzer identified the modulation as pulse-position modulation with fixed pulse width:

Total count:  251,  width: 945.50 ms
Pulse width distribution:
 [ 0] count: 251, width: 420 us [380;480]

Gap width distribution:
 [ 0] count:   9, width: 9560 us [9540;9600]
 [ 1] count: 104, width: 4560 us [4520;4620]
 [ 2] count: 137, width: 2000 us [1980;2080]

Pulse+gap period distribution:
 [ 0] count:   9, width: 10000 us [9980;10040]
 [ 1] count: 104, width: 5000 us [4940;5060]
 [ 2] count: 137, width: 2440 us [2380;2500]

Guessing modulation: Pulse Position Modulation with fixed pulse width
Use a flex decoder with -X 'n=name,m=OOK_PPM,s=2000,l=4560,g=4640,r=9620'

The important decoded row was:

{28}a6e108e

It repeated about eight times in the extracted burst, plus a partial trailing row:

codes: {0}0, {28}a6e108e, {28}a6e108e, {28}a6e108e, {28}a6e108e,
       {28}a6e108e, {28}a6e108e, {28}a6e108e, {28}a6e108e, {17}a6e10

Final decode

rtl_433 decoded the signal as protocol 56, model TFA-Pool:

{"time":"2026-06-14T19:50:42","protocol":56,"model":"TFA-Pool","id":110,"channel":3,"battery_ok":1,"temperature_C":26.400,"mic":"CHECKSUM","mod":"ASK","freq":433.921,"rssi":-11.242,"snr":17.478,"noise":-28.720}

Across the extracted events, all eight decoded identically:

EventProtocolModelIDChannelBatteryTemperatureMICModSNR
22 s56TFA-Pool1103OK26.4 °CCHECKSUMASK21.162 dB
58 s56TFA-Pool1103OK26.4 °CCHECKSUMASK19.871 dB
94 s56TFA-Pool1103OK26.4 °CCHECKSUMASK19.138 dB
129 s56TFA-Pool1103OK26.4 °CCHECKSUMASK17.770 dB
165 s56TFA-Pool1103OK26.4 °CCHECKSUMASK17.478 dB
201 s56TFA-Pool1103OK26.4 °CCHECKSUMASK21.451 dB
236 s56TFA-Pool1103OK26.4 °CCHECKSUMASK18.688 dB
272 s56TFA-Pool1103OK26.4 °CCHECKSUMASK19.490 dB

SNR of the rtl_433 decodes across extracted events.

The decode was stable and had a valid checksum. This is strong evidence that the signal was a real TFA pool-temperature sensor or a compatible device.

Frequency note

rtl_433 reported freq: 433.921 in the JSON output. I do not treat that as the measured RF frequency here, because rtl_433 was reading a pre-extracted baseband file without the original bladeRF RF-center metadata. The actual observation chain used:

capture center: 434.036917 MHz
target offset:  +50.000 kHz
observed target: ~434.086917 MHz

So the measured local RF observation remains approximately 434.087 MHz. The rtl_433 frequency value is useful context for the common 433.92 MHz sensor ecosystem, but it does not override the SDR capture metadata.

Device protocol notes

rtl_433 documents protocol 56 as TFA pool temperature sensor. The main rtl_433 README describes the project as a generic receiver for radio transmissions from ISM-band devices, including 433.92 MHz and other SRD/ISM bands. It supports reading from files, pulse analysis with -A, JSON output with -F json, and enabling a specific decoder with -R. See the rtl_433 README for the general receiver and command-line behavior.

The specific decoder source is src/devices/tfa_pool_thermometer.c. The source identifies the device as a TFA pool temperature sensor, tested with TFA-Pool-thermometer 30.3160, and gives this data layout:

CCCCIIII IIIITTTT TTTTTTTT DDBF

Field interpretation from the decoder:

C: checksum, sum of nibbles - 1
I: device id, changing only after reset
T: temperature
D: channel number
B: battery status
F: first transmission flag

The decoder requires a repeated 28-bit row and looks for at least 7 repeated rows. The decoder also defines the modulation as OOK_PULSE_PPM with these nominal timings:

short_width = 2000 us
long_width  = 4600 us
gap_limit   = 7800 us
reset_limit = 10000 us

This matches the local pulse analyzer output very closely:

short gap:  ~2000 us
long gap:   ~4560 us
reset gap:  ~9560 us
pulse:      ~420 us

Decoding the observed payload

The repeated protocol row was:

a6e108e

As nibbles:

A 6 E 1 0 8 E

Mapped to the decoder layout:

C    = A
I    = 6E          -> decimal 110
T    = 108         -> decimal 264 -> 26.4 °C
DDBF = E = 1110b   -> channel 3, battery OK, first-transmission flag 0

Checksum check:

checksum = (6 + E + 1 + 0 + 8 + E - 1) & 0xF
         = (6 + 14 + 1 + 0 + 8 + 14 - 1) & 0xF
         = 42 & 0xF
         = A

The checksum nibble matches the received A, so the message integrity check is valid.

Live monitor command

A live decode attempt with bladeRF through SoapySDR can be tried like this:

rtl_433 \
  -d "driver=bladerf" \
  -f 434.087M \
  -s 625k \
  -g 35 \
  -R 56 \
  -F json \
  -M time:iso \
  -M level \
  -M protocol

If direct live decoding is unreliable because of tuning/DC behavior, keep the offset-center method:

bladeRF-cli -e "set frequency rx 434036917; set samplerate rx 625k; set bandwidth rx 300k; set agc rx off; set gain rx 35; rx config file=rf434087_off50k_625k_g35_300s.sc16q11 format=bin n=187500000; rx start; rx wait"

Then extract small IQ slices and decode them with rtl_433 as shown above.

Findings

Confirmed findings:

Signal status:     confirmed external over-the-air RF
Approx. RF:        ~434.087 MHz in this capture chain
Modulation:        ASK/OOK, pulse-position coded
Protocol:          rtl_433 protocol 56, TFA-Pool
Device ID:         110
Channel:           3
Battery:           OK
Temperature:       26.4 °C
Checksum:          valid
Burst cadence:     ~36 s
Burst duration:    ~0.968 s in extracted events
Protocol row:      {28}a6e108e, repeated

Rejected or downgraded hypotheses:

DC/LO artifact:       rejected by shifted-center test and antenna-disconnect control
2-FSK:                not supported by instantaneous-frequency evidence
Random interference:  unlikely because the packets decode consistently with checksum
Naive OOK bit grid:   not the correct representation; the protocol is OOK PPM

Limitations

The biggest caveat is the RF frequency estimate. The measured local target was about 434.087 MHz, while rtl_433 reports the common/default 433.921 MHz context during file replay. The report therefore keeps those separate: the SDR capture metadata supports 434.087 MHz for this observation, while rtl_433 confirms the protocol family and payload.

Another limitation is that I did not physically locate the transmitter. The evidence proves an external antenna-received signal, not the physical device location. Given the model and decoded temperature, a nearby pool thermometer or compatible device is the most plausible source class.

Reproducibility notes

Minimal reproduction path:

  1. Capture offset from DC at 625 ksps.
  2. Validate the target with shifted-center captures.
  3. Confirm external RF with antenna disconnected.
  4. Extract a narrow IQ WAV around the burst.
  5. Convert IQ WAV to CU8.
  6. Run rtl_433 -A and rtl_433 -R 56 -F json.

Commands:

./extract_sc16q11_iqwav.py \
  rf434087_off50k_625k_g35_300s.sc16q11 \
  --fs 625000 \
  --fc 434036917 \
  --target 434086917 \
  --start 164.20 \
  --seconds 3.20 \
  --lpf-hz 18000 \
  --out-fs 50000 \
  --out best_434087_event_iq_50ksps.wav

./iqwav_to_cu8.py \
  best_434087_event_iq_50ksps.wav \
  best_434087_event_iq_50ksps.cu8

rtl_433 \
  -r best_434087_event_iq_50ksps.cu8 \
  -s 50000 \
  -R 56 \
  -F json \
  -M time:iso \
  -M level \
  -M protocol

Expected JSON shape:

{"protocol":56,"model":"TFA-Pool","id":110,"channel":3,"battery_ok":1,"temperature_C":26.400,"mic":"CHECKSUM","mod":"ASK"}

References