Add live waterfall during pager and sensor decoding via IQ pipeline

Replace rtl_fm/rtl_433 with rtl_sdr for raw IQ capture when available,
enabling a Python IQ processor to compute FFT for the waterfall while
simultaneously feeding decoded data to multimon-ng (pager) or rtl_433
(sensor). Falls back to the legacy pipeline when rtl_sdr is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-07 23:18:43 +00:00
parent b312eb20aa
commit f04ba7f143
9 changed files with 1053 additions and 434 deletions
+18
View File
@@ -121,6 +121,15 @@ TOOL_DEPENDENCIES = {
'manual': 'https://github.com/EliasOenal/multimon-ng'
}
},
'rtl_sdr': {
'required': False,
'description': 'Raw IQ capture for live waterfall during decoding',
'install': {
'apt': 'sudo apt install rtl-sdr',
'brew': 'brew install librtlsdr',
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
}
},
'rtl_test': {
'required': False,
'description': 'RTL-SDR device detection',
@@ -143,6 +152,15 @@ TOOL_DEPENDENCIES = {
'brew': 'brew install rtl_433',
'manual': 'https://github.com/merbanan/rtl_433'
}
},
'rtl_sdr': {
'required': False,
'description': 'Raw IQ capture for live waterfall during decoding',
'install': {
'apt': 'sudo apt install rtl-sdr',
'brew': 'brew install librtlsdr',
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
}
}
}
},
+230
View File
@@ -0,0 +1,230 @@
"""IQ processing pipelines for live waterfall during SDR decoding.
Provides two pipeline functions:
- run_fm_iq_pipeline: FM demodulates IQ for pager decoding + FFT for waterfall
- run_passthrough_iq_pipeline: Passes raw IQ to rtl_433 + FFT for waterfall
"""
from __future__ import annotations
import logging
import struct
import threading
import queue
from datetime import datetime
from typing import IO, Optional
import numpy as np
logger = logging.getLogger('intercept.iq_processor')
# FFT parameters
FFT_SIZE = 2048
FFT_INTERVAL_SECONDS = 0.1 # ~10 updates/sec
def iq_to_complex(buf: bytes) -> np.ndarray:
"""Convert raw uint8 IQ bytes to complex float samples.
RTL-SDR outputs interleaved uint8 I/Q pairs centered at 127.5.
"""
raw = np.frombuffer(buf, dtype=np.uint8).astype(np.float32)
raw = (raw - 127.5) / 127.5
return raw[0::2] + 1j * raw[1::2]
def compute_fft_bins(samples: np.ndarray, fft_size: int = FFT_SIZE) -> list[float]:
"""Compute power spectral density in dB from complex IQ samples.
Returns a list of power values (dB) for each frequency bin.
"""
if len(samples) < fft_size:
# Pad with zeros if not enough samples
padded = np.zeros(fft_size, dtype=np.complex64)
padded[:len(samples)] = samples[:fft_size]
samples = padded
else:
samples = samples[:fft_size]
# Apply Hanning window to reduce spectral leakage
window = np.hanning(fft_size).astype(np.float32)
windowed = samples * window
# FFT and shift DC to center
spectrum = np.fft.fftshift(np.fft.fft(windowed))
# Power in dB (avoid log of zero)
power = np.abs(spectrum) ** 2
power = np.maximum(power, 1e-20)
power_db = 10.0 * np.log10(power)
return power_db.tolist()
def _push_waterfall(waterfall_queue: queue.Queue, bins: list[float],
center_freq_mhz: float, sample_rate: int) -> None:
"""Push a waterfall sweep message to the queue."""
half_span = (sample_rate / 1e6) / 2.0
msg = {
'type': 'waterfall_sweep',
'start_freq': center_freq_mhz - half_span,
'end_freq': center_freq_mhz + half_span,
'bins': bins,
'timestamp': datetime.now().isoformat(),
}
try:
waterfall_queue.put_nowait(msg)
except queue.Full:
# Drop oldest and retry
try:
waterfall_queue.get_nowait()
except queue.Empty:
pass
try:
waterfall_queue.put_nowait(msg)
except queue.Full:
pass
def run_fm_iq_pipeline(
iq_stdout: IO[bytes],
audio_stdin: IO[bytes],
waterfall_queue: queue.Queue,
center_freq_mhz: float,
sample_rate: int,
stop_event: threading.Event,
) -> None:
"""FM demodulation pipeline: IQ -> FFT + FM demod -> 22050 Hz PCM.
Reads raw uint8 IQ from rtl_sdr stdout, computes FFT for waterfall,
FM demodulates, decimates to 22050 Hz, and writes 16-bit PCM to
multimon-ng stdin.
Args:
iq_stdout: rtl_sdr stdout (raw uint8 IQ)
audio_stdin: multimon-ng stdin (16-bit PCM)
waterfall_queue: Queue for waterfall sweep messages
center_freq_mhz: Center frequency in MHz
sample_rate: IQ sample rate (should be 220500 for 10x decimation to 22050)
stop_event: Threading event to signal shutdown
"""
from scipy.signal import decimate as scipy_decimate
# Decimation factor: sample_rate / 22050
decim_factor = sample_rate // 22050
if decim_factor < 1:
decim_factor = 1
# Read in chunks: ~100ms worth of IQ data (2 bytes per sample: I + Q)
chunk_bytes = int(sample_rate * FFT_INTERVAL_SECONDS) * 2
# Align to even number of bytes (I/Q pairs)
chunk_bytes = (chunk_bytes // 2) * 2
# Previous sample for FM demod continuity
prev_sample = np.complex64(0)
logger.info(f"FM IQ pipeline started: {center_freq_mhz} MHz, "
f"sr={sample_rate}, decim={decim_factor}")
try:
while not stop_event.is_set():
raw = iq_stdout.read(chunk_bytes)
if not raw:
break
# Convert to complex IQ
iq = iq_to_complex(raw)
if len(iq) == 0:
continue
# Compute FFT for waterfall
bins = compute_fft_bins(iq, FFT_SIZE)
_push_waterfall(waterfall_queue, bins, center_freq_mhz, sample_rate)
# FM demodulation via instantaneous phase difference
# Prepend previous sample for continuity
iq_with_prev = np.concatenate(([prev_sample], iq))
prev_sample = iq[-1]
phase_diff = np.angle(iq_with_prev[1:] * np.conj(iq_with_prev[:-1]))
# Decimate to 22050 Hz
if decim_factor > 1:
audio = scipy_decimate(phase_diff, decim_factor, ftype='fir')
else:
audio = phase_diff
# Scale to 16-bit PCM range
audio = np.clip(audio * 10000, -32767, 32767).astype(np.int16)
# Write to multimon-ng
try:
audio_stdin.write(audio.tobytes())
audio_stdin.flush()
except (BrokenPipeError, OSError):
break
except Exception as e:
logger.error(f"FM IQ pipeline error: {e}")
finally:
logger.info("FM IQ pipeline stopped")
try:
audio_stdin.close()
except Exception:
pass
def run_passthrough_iq_pipeline(
iq_stdout: IO[bytes],
decoder_stdin: IO[bytes],
waterfall_queue: queue.Queue,
center_freq_mhz: float,
sample_rate: int,
stop_event: threading.Event,
) -> None:
"""Passthrough pipeline: IQ -> FFT + raw bytes to decoder.
Reads raw uint8 IQ from rtl_sdr stdout, computes FFT for waterfall,
and writes raw IQ bytes unchanged to rtl_433 stdin.
Args:
iq_stdout: rtl_sdr stdout (raw uint8 IQ)
decoder_stdin: rtl_433 stdin (raw cu8 IQ)
waterfall_queue: Queue for waterfall sweep messages
center_freq_mhz: Center frequency in MHz
sample_rate: IQ sample rate (should be 250000 for rtl_433)
stop_event: Threading event to signal shutdown
"""
# Read in chunks: ~100ms worth of IQ data
chunk_bytes = int(sample_rate * FFT_INTERVAL_SECONDS) * 2
chunk_bytes = (chunk_bytes // 2) * 2
logger.info(f"Passthrough IQ pipeline started: {center_freq_mhz} MHz, sr={sample_rate}")
try:
while not stop_event.is_set():
raw = iq_stdout.read(chunk_bytes)
if not raw:
break
# Compute FFT for waterfall
iq = iq_to_complex(raw)
if len(iq) > 0:
bins = compute_fft_bins(iq, FFT_SIZE)
_push_waterfall(waterfall_queue, bins, center_freq_mhz, sample_rate)
# Pass raw bytes unchanged to decoder
try:
decoder_stdin.write(raw)
decoder_stdin.flush()
except (BrokenPipeError, OSError):
break
except Exception as e:
logger.error(f"Passthrough IQ pipeline error: {e}")
finally:
logger.info("Passthrough IQ pipeline stopped")
try:
decoder_stdin.close()
except Exception:
pass
+30
View File
@@ -186,6 +186,36 @@ class CommandBuilder(ABC):
"""Return hardware capabilities for this SDR type."""
pass
def build_raw_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build raw IQ capture command (for IQ-based waterfall during decoding).
Args:
device: The SDR device to use
frequency_mhz: Center frequency in MHz
sample_rate: Sample rate in Hz
gain: Gain in dB (None for auto)
ppm: PPM frequency correction
bias_t: Enable bias-T power (for active antennas)
Returns:
Command as list of strings for subprocess
Raises:
NotImplementedError: If the SDR type does not support raw capture
"""
raise NotImplementedError(
f"Raw IQ capture not supported for {self.get_sdr_type().value}"
)
@classmethod
@abstractmethod
def get_sdr_type(cls) -> SDRType:
+37
View File
@@ -197,6 +197,43 @@ class RTLSDRCommandBuilder(CommandBuilder):
return cmd
def build_raw_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build rtl_sdr command for raw IQ capture.
Outputs raw uint8 IQ to stdout for processing by IQ pipelines.
"""
rtl_sdr_path = get_tool_path('rtl_sdr') or 'rtl_sdr'
freq_hz = int(frequency_mhz * 1e6)
cmd = [
rtl_sdr_path,
'-d', self._get_device_arg(device),
'-f', str(freq_hz),
'-s', str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(['-g', str(gain)])
if ppm is not None and ppm != 0:
cmd.extend(['-p', str(ppm)])
if bias_t:
cmd.extend(['-T'])
# Output to stdout
cmd.append('-')
return cmd
def build_ais_command(
self,
device: SDRDevice,