radiosonde_auto_rx/auto_rx/autorx/scan.py

1283 wiersze
47 KiB
Python

#!/usr/bin/env python
#
# radiosonde_auto_rx - Radiosonde Scanner
#
# Copyright (C) 2018 Mark Jessop <vk5qi@rfhead.net>
# Released under GNU GPL v3 or later
#
import autorx
import datetime
import logging
import numpy as np
import os
import sys
import platform
import subprocess
import time
import traceback
from io import StringIO
from threading import Thread, Lock
from types import FunctionType, MethodType
from .utils import (
detect_peaks,
rtlsdr_test,
reset_rtlsdr_by_serial,
reset_all_rtlsdrs,
peak_decimation,
timeout_cmd
)
from .sdr_wrappers import test_sdr, reset_sdr, get_sdr_name, get_sdr_iq_cmd, get_sdr_fm_cmd, get_power_spectrum
try:
from .web import flask_emit_event
except ImportError:
# Running in a test scenario. Make a dummy flask_emit_event function.
def flask_emit_event(event_name, data):
print("Running in a test scenario, no data emitted to flask.")
pass
# Global for latest scan result
scan_result = {
"freq": [],
"power": [],
"peak_freq": [],
"peak_lvl": [],
"timestamp": "No data yet.",
"threshold": 0,
}
def run_rtl_power(
start,
stop,
step,
filename="log_power.csv",
dwell=20,
rtl_power_path="rtl_power",
device_idx=0,
ppm=0,
gain=-1,
bias=False,
):
"""Capture spectrum data using rtl_power (or drop-in equivalent), and save to a file.
Args:
start (int): Start of search window, in Hz.
stop (int): End of search window, in Hz.
step (int): Search step, in Hz.
filename (str): Output results to this file. Defaults to ./log_power.csv
dwell (int): How long to average on the frequency range for.
rtl_power_path (str): Path to the rtl_power utility.
device_idx (int or str): Device index or serial number of the RTLSDR. Defaults to 0 (the first SDR found).
ppm (int): SDR Frequency accuracy correction, in ppm.
gain (float): SDR Gain setting, in dB.
bias (bool): If True, enable the bias tee on the SDR.
Returns:
bool: True if rtl_power ran successfuly, False otherwise.
"""
# Example: rtl_power -f 400400000:403500000:800 -i20 -1 -c 25% -p 0 -d 0 -g 26.0 log_power.csv
# Add a -T option if bias is enabled
bias_option = "-T " if bias else ""
# Add a gain parameter if we have been provided one.
if gain != -1:
gain_param = "-g %.1f " % gain
else:
gain_param = ""
# If the output log file exists, remove it.
if os.path.exists(filename):
os.remove(filename)
rtl_power_cmd = (
"%s %d %s %s-f %d:%d:%d -i %d -1 -c 25%% -p %d -d %s %s%s"
% (
timeout_cmd(),
dwell + 10,
rtl_power_path,
bias_option,
start,
stop,
step,
dwell,
int(ppm), # Should this be an int?
str(device_idx),
gain_param,
filename,
)
)
logging.info("Scanner #%s - Running frequency scan." % str(device_idx))
logging.debug(
"Scanner #%s - Running command: %s" % (str(device_idx), rtl_power_cmd)
)
try:
_output = subprocess.check_output(
rtl_power_cmd, shell=True, stderr=subprocess.STDOUT
)
except subprocess.CalledProcessError as e:
# Something went wrong...
logging.critical(
"Scanner #%s - rtl_power call failed with return code %s."
% (str(device_idx), e.returncode)
)
# Look at the error output in a bit more details.
_output = e.output.decode("ascii")
if "No supported devices found" in _output:
logging.critical(
"Scanner #%s - rtl_power could not find device with ID %s, is your configuration correct?"
% (str(device_idx), str(device_idx))
)
elif "illegal option" in _output:
if bias:
logging.critical(
"Scanner #%s - rtl_power reported an illegal option was used. Are you using a rtl_power version with bias tee support?"
% str(device_idx)
)
else:
logging.critical(
"Scanner #%s - rtl_power reported an illegal option was used. (This shouldn't happen... are you running an ancient version?)"
% str(device_idx)
)
else:
# Something else odd happened, dump the entire error output to the log for further analysis.
logging.critical(
"Scanner #%s - rtl_power reported error: %s"
% (str(device_idx), _output)
)
return False
else:
# No errors reported!
return True
def read_rtl_power(filename):
"""Read in frequency samples from a single-shot log file produced by rtl_power
Args:
filename (str): Filename to read in.
Returns:
tuple: A tuple consisting of:
freq (np.array): List of centre frequencies in Hz
power (np.array): List of measured signal powers, in dB.
freq_step (float): Frequency step between points, in Hz
"""
# Output buffers.
freq = np.array([])
power = np.array([])
freq_step = 0
# Open file.
f = open(filename, "r")
# rtl_power log files are csv's, with the first 6 fields in each line describing the time and frequency scan parameters
# for the remaining fields, which contain the power samples.
for line in f:
# Split line into fields.
fields = line.split(",")
if len(fields) < 6:
logging.error(
"Scanner - Invalid number of samples in input file - corrupt?"
)
raise Exception(
"Scanner - Invalid number of samples in input file - corrupt?"
)
start_date = fields[0]
start_time = fields[1]
start_freq = float(fields[2])
stop_freq = float(fields[3])
freq_step = float(fields[4])
n_samples = int(fields[5])
# freq_range = np.arange(start_freq,stop_freq,freq_step)
samples = np.loadtxt(StringIO(",".join(fields[6:])), delimiter=",")
freq_range = np.linspace(start_freq, stop_freq, len(samples))
# Add frequency range and samples to output buffers.
freq = np.append(freq, freq_range)
power = np.append(power, samples)
f.close()
# Sanitize power values, to remove the nan's that rtl_power puts in there occasionally.
power = np.nan_to_num(power)
return (freq, power, freq_step)
def detect_sonde(
frequency,
rs_path="./",
dwell_time=10,
sdr_type="RTLSDR",
sdr_hostname="localhost",
sdr_port=5555,
ss_iq_path = "./ss_iq",
rtl_fm_path="rtl_fm",
rtl_device_idx=0,
ppm=0,
gain=-1,
bias=False,
save_detection_audio=False,
ngp_tweak=False,
wideband_sondes=False
):
"""Receive some FM and attempt to detect the presence of a radiosonde.
Args:
frequency (int): Frequency to perform the detection on, in Hz.
rs_path (str): Path to the RS binaries (i.e rs_detect). Defaults to ./
dwell_time (int): Timeout before giving up detection.
rtl_fm_path (str): Path to rtl_fm, or drop-in equivalent. Defaults to 'rtl_fm'
rtl_device_idx (int or str): Device index or serial number of the RTLSDR. Defaults to 0 (the first SDR found).
ppm (int): SDR Frequency accuracy correction, in ppm.
gain (int): SDR Gain setting, in dB. A gain setting of -1 enables the RTLSDR AGC.
bias (bool): If True, enable the bias tee on the SDR.
save_detection_audio (bool): Save the audio used in detection to a file.
ngp_tweak (bool): When scanning in the 1680 MHz sonde band, use a narrower FM filter for better RS92-NGP detection.
wideband_sondes (bool): Use a wider detection filter to allow detection of Weathex and wideband iMet sondes.
Returns:
str/None: Returns None if no sonde found, otherwise returns a sonde type, from the following:
'RS41' - Vaisala RS41
'RS92' - Vaisala RS92
'DFM' - Graw DFM06 / DFM09 (similar telemetry formats)
'M10' - MeteoModem M10
'M20' - MeteoModem M20
'iMet' - interMet iMet
'MK2LMS' - LMS6, 1680 MHz variant (using MK2A 9600 baud telemetry)
"""
# Notes:
# 400 MHz sondes
# Normal mode: 48 kHz sample rate, 20 kHz IF BW
# Wideband mode: 96 kHz sample rate, 64 kHz IF BW
# 1680 MHz RS92 Setting: --bw 32
# 1680 MHz LMS6-1680: Use FM demod. as usual.
# Example command (for command-line testing):
# rtl_fm -T -p 0 -M fm -g 26.0 -s 15k -f 401500000 | sox -t raw -r 15k -e s -b 16 -c 1 - -r 48000 -t wav - highpass 20 | ./rs_detect -z -t 8
# Add a -T option if bias is enabled
bias_option = "-T " if bias else ""
# Add a gain parameter if we have been provided one.
if gain != -1:
gain_param = "-g %.1f " % gain
else:
gain_param = ""
# Adjust the detection bandwidth based on the band the scanning is occuring in.
if frequency < 1000e6:
# 400-406 MHz sondes
_mode = "IQ"
if wideband_sondes:
_iq_bw = 96000
_if_bw = 64
else:
_iq_bw = 48000
_if_bw = 20
# Try and avoid the RTLSDR 403.2 MHz spur.
# Note that this is only goign to work if we are detecting on 403.210 or 403.190 MHz.
if (abs(403200000 - frequency) < 20000) and (sdr_type == "RTLSDR"):
logging.debug("Scanner - Narrowing detection IF BW to avoid RTLSDR spur.")
_if_bw = 15
else:
# 1680 MHz sondes
# Both the RS92-NGP and 1680 MHz LMS6 have a much wider bandwidth than their 400 MHz counterparts.
# The RS92-NGP is maybe 25 kHz wide, and the LMS6 is 175 kHz (!!) wide.
# Given the huge difference between these two, we default to using a very wide FM bandwidth, but allow the user
# to narrow this if only RS92-NGPs are expected.
if ngp_tweak:
# RS92-NGP detection
_mode = "IQ"
_iq_bw = 48000
_if_bw = 32
else:
# LMS6-1680 Detection
_mode = "FM"
_rx_bw = 250000 # Expanded to 250 kHz 2021-07-17. Results in better off-freq detection.
if _mode == "IQ":
# IQ decoding
rx_test_command = f"{timeout_cmd()} {dwell_time * 2} "
rx_test_command += get_sdr_iq_cmd(
sdr_type=sdr_type,
frequency=frequency,
sample_rate=_iq_bw,
rtl_device_idx = rtl_device_idx,
rtl_fm_path = rtl_fm_path,
ppm = ppm,
gain = gain,
bias = bias,
sdr_hostname = sdr_hostname,
sdr_port = sdr_port,
ss_iq_path = ss_iq_path
)
# rx_test_command = (
# "%s %ds %s %s-p %d -d %s %s-M raw -F9 -s %d -f %d 2>/dev/null |"
# % (
# timeout_cmd(),
# dwell_time * 2,
# rtl_fm_path,
# bias_option,
# int(ppm),
# str(device_idx),
# gain_param,
# _iq_bw,
# frequency,
# )
# )
# Saving of Debug audio, if enabled,
if save_detection_audio:
detect_iq_path = os.path.join(autorx.logging_path, f"detect_IQ_{frequency}_{_iq_bw}_{str(rtl_device_idx)}.raw")
rx_test_command += f" tee {detect_iq_path} |"
rx_test_command += os.path.join(
rs_path, "dft_detect"
) + " -t %d --iq --bw %d --dc - %d 16 2>/dev/null" % (
dwell_time,
_if_bw,
_iq_bw,
)
elif _mode == "FM":
# FM decoding
# Sample Source (rtl_fm)
rx_test_command = f"{timeout_cmd()} {dwell_time * 2} "
rx_test_command += get_sdr_fm_cmd(
sdr_type=sdr_type,
frequency=frequency,
filter_bandwidth=_rx_bw,
sample_rate=48000,
highpass = 20,
lowpass = None,
rtl_device_idx = rtl_device_idx,
rtl_fm_path = rtl_fm_path,
ppm = ppm,
gain = gain,
bias = bias,
sdr_hostname = "",
sdr_port = 1234,
)
# rx_test_command = (
# "%s %ds %s %s-p %d -d %s %s-M fm -F9 -s %d -f %d 2>/dev/null |"
# % (
# timeout_cmd(),
# dwell_time * 2,
# rtl_fm_path,
# bias_option,
# int(ppm),
# str(device_idx),
# gain_param,
# _rx_bw,
# frequency,
# )
# )
# # Sample filtering
# rx_test_command += (
# "sox -t raw -r %d -e s -b 16 -c 1 - -r 48000 -t wav - highpass 20 2>/dev/null | "
# % _rx_bw
# )
# Saving of Debug audio, if enabled,
if save_detection_audio:
detect_audio_path = os.path.join(autorx.logging_path, f"detect_audio_{frequency}_{str(rtl_device_idx)}.wav")
rx_test_command += f" tee {detect_audio_path} |"
# Sample decoding / detection
# Note that we detect for dwell_time seconds, and timeout after dwell_time*2, to catch if no samples are being passed through.
rx_test_command += (
os.path.join(rs_path, "dft_detect") + " -t %d 2>/dev/null" % dwell_time
)
_sdr_name = get_sdr_name(
sdr_type,
rtl_device_idx = rtl_device_idx,
sdr_hostname = sdr_hostname,
sdr_port = sdr_port
)
logging.debug(
f"Scanner ({_sdr_name}) - Using detection command: {rx_test_command}"
)
logging.debug(
f"Scanner ({_sdr_name})- Attempting sonde detection on {frequency/1e6 :.3f} MHz"
)
try:
FNULL = open(os.devnull, "w")
_start = time.time()
ret_output = subprocess.check_output(rx_test_command, shell=True, stderr=FNULL)
FNULL.close()
ret_output = ret_output.decode("utf8")
except subprocess.CalledProcessError as e:
# dft_detect returns a code of 1 if no sonde is detected.
# logging.debug("Scanner - dfm_detect return code: %s" % e.returncode)
if e.returncode == 124:
logging.error(f"Scanner ({_sdr_name}) - dft_detect timed out.")
raise IOError("Possible SDR lockup.")
elif e.returncode >= 2:
ret_output = e.output.decode("utf8")
else:
_runtime = time.time() - _start
logging.debug(
f"Scanner ({_sdr_name}) - dft_detect exited in {_runtime:.1f} seconds with return code {e.returncode}."
)
return (None, 0.0)
except Exception as e:
# Something broke when running the detection function.
logging.error(
f"Scanner ({_sdr_name}) - Error when running dft_detect - {sdr(e)}"
)
return (None, 0.0)
_runtime = time.time() - _start
logging.debug(
"Scanner - dft_detect exited in %.1f seconds with return code 1." % _runtime
)
# Check for no output from dft_detect.
if ret_output is None or ret_output == "":
# logging.error("Scanner - dft_detect returned no output?")
return (None, 0.0)
# Split the line into sonde type and correlation score.
_fields = ret_output.split(":")
if len(_fields) < 2:
logging.error(
"Scanner - malformed output from dft_detect: %s" % ret_output.strip()
)
return (None, 0.0)
_type = _fields[0]
_score = _fields[1]
# Detect any frequency correction information:
try:
if "," in _score:
_offset_est = float(_score.split(",")[1].split("Hz")[0].strip())
_score = float(_score.split(",")[0].strip())
else:
_score = float(_score.strip())
_offset_est = 0.0
except Exception as e:
logging.error(
"Scanner - Error parsing dft_detect output: %s" % ret_output.strip()
)
return (None, 0.0)
_sonde_type = None
if "RS41" in _type:
logging.debug(
"Scanner (%s) - Detected a RS41! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
_sonde_type = "RS41"
elif "RS92" in _type:
logging.debug(
"Scanner (%s) - Detected a RS92! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
_sonde_type = "RS92"
elif "DFM" in _type:
logging.debug(
"Scanner (%s) - Detected a DFM Sonde! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
_sonde_type = "DFM"
elif "M10" in _type:
logging.debug(
"Scanner (%s) - Detected a M10 Sonde! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
_sonde_type = "M10"
elif "M20" in _type:
logging.debug(
"Scanner (%s) - Detected a M20 Sonde! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
_sonde_type = "M20"
elif "IMET4" in _type:
logging.debug(
"Scanner (%s) - Detected a iMet-4 Sonde! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
_sonde_type = "IMET"
elif "IMET1" in _type:
logging.debug(
"Scanner (%s) - Detected a iMet Sonde! (Type %s - Unsupported) (Score: %.2f)"
% (_sdr_name, _type, _score)
)
_sonde_type = "IMET1"
elif "IMETafsk" in _type:
logging.debug(
"Scanner (%s) - Detected a iMet Sonde! (Type %s - Unsupported) (Score: %.2f)"
% (_sdr_name, _type, _score)
)
_sonde_type = "IMET1"
elif "IMET5" in _type:
logging.debug(
"Scanner (%s) - Detected a iMet-54 Sonde! (Score: %.2f)"
% (_sdr_name, _score)
)
_sonde_type = "IMET5"
elif "LMS6" in _type:
logging.debug(
"Scanner (%s) - Detected a LMS6 Sonde! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
_sonde_type = "LMS6"
elif "C34" in _type:
logging.debug(
"Scanner (%s) - Detected a Meteolabor C34/C50 Sonde! (Not yet supported...) (Score: %.2f)"
% (_sdr_name, _score)
)
_sonde_type = "C34C50"
elif "MRZ" in _type:
logging.debug(
"Scanner (%s) - Detected a Meteo-Radiy MRZ Sonde! (Score: %.2f)"
% (_sdr_name, _score)
)
if _score < 0:
_sonde_type = "-MRZ"
else:
_sonde_type = "MRZ"
elif "MK2LMS" in _type:
logging.debug(
"Scanner (%s) - Detected a 1680 MHz LMS6 Sonde (MK2A Telemetry)! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
if _score < 0:
_sonde_type = "-MK2LMS"
else:
_sonde_type = "MK2LMS"
elif "MEISEI" in _type:
logging.debug(
"Scanner (%s) - Detected a Meisei Sonde! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
# Not currently sure if we expect to see inverted Meisei sondes.
if _score < 0:
_sonde_type = "-MEISEI"
else:
_sonde_type = "MEISEI"
elif "MTS01" in _type:
logging.debug(
"Scanner (%s) - Detected a Meteosis MTS01 Sonde! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
# Not currently sure if we expect to see inverted Meteosis sondes.
if _score < 0:
_sonde_type = "-MTS01"
else:
_sonde_type = "MTS01"
elif "WXR301" in _type:
logging.debug(
"Scanner (%s) - Detected a Weathex WxR-301D Sonde! (Score: %.2f, Offset: %.1f Hz)"
% (_sdr_name, _score, _offset_est)
)
_sonde_type = "WXR301"
else:
_sonde_type = None
return (_sonde_type, _offset_est)
#
# Radiosonde Scanner Class
#
class SondeScanner(object):
"""Radiosonde Scanner
Continuously scan for radiosondes using a SDR, and pass results onto a callback function
"""
# Allow up to X consecutive scan errors before giving up.
SONDE_SCANNER_MAX_ERRORS = 5
def __init__(
self,
callback=None,
auto_start=True,
min_freq=400.0,
max_freq=403.0,
search_step=800.0,
only_scan=[],
always_scan=[],
never_scan=[],
snr_threshold=10,
min_distance=1000,
quantization=10000,
scan_dwell_time=20,
detect_dwell_time=5,
scan_delay=10,
max_peaks=10,
scan_check_interval=10,
rs_path="./",
sdr_type="RTLSDR",
sdr_hostname="localhost",
sdr_port=5555,
ss_iq_path = "./ss_iq",
ss_power_path = "./ss_power",
rtl_power_path="rtl_power",
rtl_fm_path="rtl_fm",
rtl_device_idx=0,
gain=-1,
ppm=0,
bias=False,
save_detection_audio=False,
temporary_block_list={},
temporary_block_time=60,
ngp_tweak=False,
wideband_sondes=False
):
"""Initialise a Sonde Scanner Object.
Apologies for the huge number of args...
Args:
callback (function): A function to pass results from the sonde scanner to (when a sonde is found).
auto_start (bool): Start up the scanner automatically.
min_freq (float): Minimum search frequency, in MHz.
max_freq (float): Maximum search frequency, in MHz.
search_step (float): Search step, in *Hz*. Defaults to 800 Hz, which seems to work well.
only_scan (list): If provided, *only* scan on these frequencies. Frequencies provided as a list in MHz.
always_scan (list): If provided, add these frequencies to the start of each scan attempt.
never_scan (list): If provided, remove these frequencies from the detected peaks before scanning.
snr_threshold (float): SNR to threshold detections at. (dB)
min_distance (float): Minimum allowable distance between detected peaks, in Hz.
Helps avoid detection of numerous peaks due to ripples within the signal bandwidth.
quantization (float): Quantize search results to this value in Hz. Defaults to 10 kHz.
Essentially all radiosondes transmit on 10 kHz channel steps.
scan_dwell_time (int): Number of seconds for rtl_power to average spectrum over. Default = 20 seconds.
detect_dwell_time (int): Number of seconds to allow rs_detect to attempt to detect a sonde. Default = 5 seconds.
scan_delay (int): Delay X seconds between scan runs.
max_peaks (int): Maximum number of peaks to search over. Peaks are ordered by signal power before being limited to this number.
scan_check_interval (int): If we are using a only_scan list, re-check the RTLSDR works every X scan runs.
rs_path (str): Path to the RS binaries (i.e rs_detect). Defaults to ./
sdr_type (str): 'RTLSDR', 'Spyserver' or 'KA9Q'
Arguments for KA9Q SDR Server / SpyServer:
sdr_hostname (str): Hostname of KA9Q Server
sdr_port (int): Port number of KA9Q Server
Arguments for RTLSDRs:
rtl_power_path (str): Path to rtl_power, or drop-in equivalent. Defaults to 'rtl_power'
rtl_fm_path (str): Path to rtl_fm, or drop-in equivalent. Defaults to 'rtl_fm'
rtl_device_idx (int or str): Device index or serial number of the RTLSDR. Defaults to 0 (the first SDR found).
ppm (int): SDR Frequency accuracy correction, in ppm.
gain (int): SDR Gain setting, in dB. A gain setting of -1 enables the RTLSDR AGC.
bias (bool): If True, enable the bias tee on the SDR.
device_idx (int): SDR Device index. Defaults to 0 (the first SDR found).
ppm (int): SDR Frequency accuracy correction, in ppm.
gain (int): SDR Gain setting, in dB. A gain setting of -1 enables the RTLSDR AGC.
bias (bool): If True, enable the bias tee on the SDR.
save_detection_audio (bool): Save the audio used in each detecton to detect_<device_idx>.wav
temporary_block_list (dict): A dictionary where each attribute represents a frequency that should be blocked for a set time.
temporary_block_time (int): How long (minutes) frequencies in the temporary block list should remain blocked for.
ngp_tweak (bool): Narrow the detection filter when searching for 1680 MHz sondes, to enhance detection of RS92-NGPs.
wideband_sondes (bool): Use a wider detection filter to allow detection of Weathex and wideband iMet sondes.
"""
# Thread flag. This is set to True when a scan is running.
self.sonde_scanner_running = True
# Copy parameters
self.min_freq = min_freq
self.max_freq = max_freq
self.search_step = search_step
self.only_scan = only_scan
self.always_scan = always_scan
self.never_scan = never_scan
self.snr_threshold = snr_threshold
self.min_distance = min_distance
self.quantization = quantization
self.scan_dwell_time = scan_dwell_time
self.detect_dwell_time = detect_dwell_time
self.scan_delay = scan_delay
self.max_peaks = max_peaks
self.rs_path = rs_path
self.sdr_type = sdr_type
self.sdr_hostname = sdr_hostname
self.sdr_port = sdr_port
self.ss_iq_path = ss_iq_path
self.ss_power_path = ss_power_path
self.rtl_power_path = rtl_power_path
self.rtl_fm_path = rtl_fm_path
self.rtl_device_idx = rtl_device_idx
self.gain = gain
self.ppm = ppm
self.bias = bias
self.callback = callback
self.save_detection_audio = save_detection_audio
self.wideband_sondes = wideband_sondes
# Temporary block list.
self.temporary_block_list = temporary_block_list.copy()
self.temporary_block_list_lock = Lock()
self.temporary_block_time = temporary_block_time
# Alert the user if there are temporary blocks in place.
if len(self.temporary_block_list.keys()) > 0:
self.log_info(
"Temporary blocks in place for frequencies: %s"
% str(list(self.temporary_block_list.keys()))
)
# Error counter.
self.error_retries = 0
# Count how many scans we have performed.
self.scan_counter = 0
# If we run a only_scan list, check the SDR every X scan loops.
self.scan_check_interval = scan_check_interval
# This will become our scanner thread.
self.sonde_scan_thread = None
# Test if the supplied SDR is working.
_sdr_ok = test_sdr(
self.sdr_type,
rtl_device_idx = self.rtl_device_idx,
sdr_hostname = self.sdr_hostname,
sdr_port = self.sdr_port,
ss_iq_path = self.ss_iq_path,
check_freq = 1e6*(self.max_freq+self.min_freq)/2.0
)
if not _sdr_ok:
self.sonde_scanner_running = False
self.exit_state = "FAILED SDR"
return
self.exit_state = "OK"
if auto_start:
self.start()
def start(self):
# Start the scan loop (if not already running)
if self.sonde_scan_thread is None:
self.sonde_scan_thread = Thread(target=self.scan_loop)
self.sonde_scan_thread.start()
self.sonde_scanner_running = True
else:
self.log_warning("Sonde scan already running!")
def send_to_callback(self, results):
"""Send scan results to a callback.
Args:
results (list): List consisting of [freq, type)]
"""
try:
# Only send scan results to the callback if we are still running.
# This avoids sending scan results when the scanner is being shutdown.
if (self.callback != None) and self.sonde_scanner_running:
self.callback(results)
except Exception as e:
self.log_error("Error handling scan results - %s" % str(e))
def scan_loop(self):
"""Continually perform scans, and pass any results onto the callback function"""
self.log_info("Starting Scanner Thread")
while self.sonde_scanner_running:
# If we have hit the maximum number of permissable errors, quit.
if self.error_retries > self.SONDE_SCANNER_MAX_ERRORS:
self.log_error(
"Exceeded maximum number of consecutive RTLSDR errors. Closing scan thread."
)
break
# If we are using a only_scan list, we don't have an easy way of checking the RTLSDR
# is producing useful data, so, test it.
if len(self.only_scan) > 0:
self.scan_counter += 1
if (self.scan_counter % self.scan_check_interval) == 0:
self.log_debug("Performing periodic check of SDR.")
_sdr_ok = test_sdr(
self.sdr_type,
rtl_device_idx = self.rtl_device_idx,
sdr_hostname = self.sdr_hostname,
sdr_port = self.sdr_port,
ss_iq_path = self.ss_iq_path,
check_freq = 1e6*(self.max_freq+self.min_freq)/2.0
)
if not _sdr_ok:
self.log_error(
"Unrecoverable SDR error. Closing scan thread."
)
break
try:
_results = self.sonde_search()
except (IOError, ValueError) as e:
# No log file produced. Reset the SDR and try again.
# traceback.print_exc()
self.log_warning("SDR produced no output... resetting and retrying.")
self.error_retries += 1
# Attempt to reset the SDR, if possible.
reset_sdr(
self.sdr_type,
rtl_device_idx = self.rtl_device_idx,
sdr_hostname = self.sdr_hostname,
sdr_port = self.sdr_port
)
for _ in range(10):
if not self.sonde_scanner_running:
break
time.sleep(1)
continue
except Exception as e:
traceback.print_exc()
self.log_error("Caught other error: %s" % str(e))
for _ in range(10):
if not self.sonde_scanner_running:
break
time.sleep(1)
else:
# Scan completed successfuly! Reset the error counter.
self.error_retries = 0
# Sleep before starting the next scan.
for _ in range(self.scan_delay):
if not self.sonde_scanner_running:
self.log_debug("Breaking out of scan loop.")
break
time.sleep(1)
self.log_info("Scanner Thread Closed.")
self.sonde_scanner_running = False
self.sonde_scanner_thread = None
def sonde_search(self, first_only=False):
"""Perform a frequency scan across a defined frequency range, and test each detected peak for the presence of a radiosonde.
In order, this function:
- Runs rtl_power to capture spectrum data across the frequency range of interest.
- Thresholds and quantises peaks detected in the spectrum.
- On each peak run rs_detect to determine if a radiosonce is present.
- Returns either the first, or a list of all detected sondes.
Performing a search can take some time (many minutes if there are lots of peaks detected). This function can be exited quickly
by setting self.sonde_scanner_running to False, which will also close the sonde scanning thread if running.
Args:
first_only (bool): If True, return after detecting the first sonde. Otherwise continue to scan through all peaks.
Returns:
list: An empty list [] if no sondes are detected otherwise, a list of list, containing entries of [frequency (Hz), Sonde Type],
i.e. [[402500000,'RS41'],[402040000,'RS92']]
"""
global scan_result
_search_results = []
if len(self.only_scan) == 0:
# No only_scan frequencies provided - perform a scan.
(freq, power, step) = get_power_spectrum(
sdr_type=self.sdr_type,
frequency_start=self.min_freq * 1e6,
frequency_stop=self.max_freq * 1e6,
step=self.search_step,
integration_time=self.scan_dwell_time,
rtl_device_idx=self.rtl_device_idx,
rtl_power_path=self.rtl_power_path,
ppm=self.ppm,
gain=self.gain,
bias=self.bias,
sdr_hostname=self.sdr_hostname,
sdr_port=self.sdr_port,
ss_power_path = self.ss_power_path
)
# Exit opportunity.
if self.sonde_scanner_running == False:
return []
# Sanity check results.
if step == None or len(freq) == 0 or len(power) == 0:
# Otherwise, if a file has been written but contains no data, it can indicate
# an issue with the RTLSDR. Sometimes these issues can be resolved by issuing a usb reset to the RTLSDR.
raise ValueError("Error getting PSD")
# Update the global scan result
(_freq_decimate, _power_decimate) = peak_decimation(freq / 1e6, power, 10)
scan_result["freq"] = list(_freq_decimate)
scan_result["power"] = list(_power_decimate)
scan_result["timestamp"] = datetime.datetime.utcnow().isoformat()
scan_result["peak_freq"] = []
scan_result["peak_lvl"] = []
# Rough approximation of the noise floor of the received power spectrum.
# Switched to use a Median instead of a Mean 2022-04-02. Should remove outliers better.
power_nf = np.median(power)
logging.debug(f"Noise Floor Estimate: {power_nf:.1f} dB uncal")
# Pass the threshold data to the web client for plotting
scan_result["threshold"] = power_nf
# Detect peaks.
peak_indices = detect_peaks(
power,
mph=(power_nf + self.snr_threshold),
mpd=(self.min_distance / step),
show=False,
)
# If we have found no peaks, and no always_scan list has been provided, re-scan.
if (len(peak_indices) == 0) and (len(self.always_scan) == 0):
self.log_debug("No peaks found.")
# Emit a notification to the client that a scan is complete.
flask_emit_event("scan_event")
return []
# Sort peaks by power.
peak_powers = power[peak_indices]
peak_freqs = freq[peak_indices]
peak_frequencies = peak_freqs[np.argsort(peak_powers)][::-1]
# Quantize to nearest x Hz
peak_frequencies = (
np.round(peak_frequencies / self.quantization) * self.quantization
)
# Remove any duplicate entries after quantization, but preserve order.
_, peak_idx = np.unique(peak_frequencies, return_index=True)
peak_frequencies = peak_frequencies[np.sort(peak_idx)]
# Remove outside min_freq and max_freq.
_index = np.argwhere(
(peak_frequencies < (self.min_freq * 1e6 - (self.quantization / 2.0))) |
(peak_frequencies > (self.max_freq * 1e6 + (self.quantization / 2.0)))
)
peak_frequencies = np.delete(peak_frequencies, _index)
# Never scan list & Temporary block list behaviour change as of v1.2.3
# Was: peak_frequencies==_frequency (This only matched an exact frequency in the never_scan list)
# Now (1.2.3): Block if the peak frequency is within +/-quantization/2.0 of a never_scan or blocklist frequency.
# Remove any frequencies in the never_scan list.
for _frequency in np.array(self.never_scan) * 1e6:
_index = np.argwhere(
np.abs(peak_frequencies - _frequency) < (self.quantization / 2.0)
)
peak_frequencies = np.delete(peak_frequencies, _index)
# Limit to the user-defined number of peaks to search over.
if len(peak_frequencies) > self.max_peaks:
peak_frequencies = peak_frequencies[: self.max_peaks]
# Append on any frequencies in the supplied always_scan list
peak_frequencies = np.append(
np.array(self.always_scan) * 1e6, peak_frequencies
)
# Remove any frequencies in the temporary block list
self.temporary_block_list_lock.acquire()
for _frequency in self.temporary_block_list.copy().keys():
# Check the time the block was added.
if self.temporary_block_list[_frequency] > (
time.time() - self.temporary_block_time * 60
):
# We should still be blocking this frequency, so remove any peaks with this frequency.
_index = np.argwhere(
np.abs(peak_frequencies - _frequency)
< (self.quantization / 2.0)
)
peak_frequencies = np.delete(peak_frequencies, _index)
if len(_index) > 0:
self.log_debug(
"Peak on %.3f MHz was removed due to temporary block."
% (_frequency / 1e6)
)
else:
# This frequency doesn't need to be blocked any more, remove it from the block list.
self.temporary_block_list.pop(_frequency)
self.log_info(
"Removed %.3f MHz from temporary block list."
% (_frequency / 1e6)
)
self.temporary_block_list_lock.release()
# Get the level of our peak search results, to send to the web client.
# This is actually a bit of a pain to do...
_peak_freq = []
_peak_lvl = []
for _peak in peak_frequencies:
try:
# Find the index of the peak within our decimated frequency array.
_peak_power_idx = np.argmin(
np.abs(scan_result["freq"] - _peak / 1e6)
)
# Because we've decimated the freq & power data, the peak location may
# not be exactly at this frequency, so we take the maximum of an area
# around this location.
_peak_search_min = max(0, _peak_power_idx - 5)
_peak_search_max = min(
len(scan_result["freq"]) - 1, _peak_power_idx + 5
)
# Grab the maximum value, and append it and the frequency to the output arrays
_peak_lvl.append(
max(scan_result["power"][_peak_search_min:_peak_search_max])
)
_peak_freq.append(_peak / 1e6)
except:
pass
# Add the peak results to our global scan result dictionary.
scan_result["peak_freq"] = _peak_freq
scan_result["peak_lvl"] = _peak_lvl
# Tell the web client we have new data.
flask_emit_event("scan_event")
if len(peak_frequencies) == 0:
self.log_debug("No peaks found after never_scan frequencies removed.")
return []
else:
self.log_info(
"Detected peaks on %d frequencies (MHz): %s"
% (len(peak_frequencies), str(peak_frequencies / 1e6))
)
else:
# We have been provided a only_scan list - scan through the supplied frequencies.
peak_frequencies = np.array(self.only_scan) * 1e6
self.log_info(
"Scanning only frequencies (MHz): %s" % str(peak_frequencies / 1e6)
)
# Run rs_detect on each peak frequency, to determine if there is a sonde there.
for freq in peak_frequencies:
_freq = float(freq)
# Exit opportunity.
if self.sonde_scanner_running == False:
return []
(detected, offset_est) = detect_sonde(
_freq,
sdr_type=self.sdr_type,
sdr_hostname=self.sdr_hostname,
sdr_port=self.sdr_port,
ss_iq_path = self.ss_iq_path,
rtl_fm_path=self.rtl_fm_path,
rtl_device_idx=self.rtl_device_idx,
ppm=self.ppm,
gain=self.gain,
bias=self.bias,
dwell_time=self.detect_dwell_time,
save_detection_audio=self.save_detection_audio,
wideband_sondes=self.wideband_sondes
)
if detected != None:
# Quantize the detected frequency (with offset) to 1 kHz
_freq = round((_freq + offset_est) / 1000.0) * 1000.0
# Add a detected sonde to the output array
_search_results.append([_freq, detected])
# Immediately send this result to the callback.
self.send_to_callback([[_freq, detected]])
# If we only want the first detected sonde, then return now.
if first_only:
return _search_results
# Otherwise, we continue....
if len(_search_results) == 0:
self.log_debug("No sondes detected.")
else:
self.log_debug("Scan Detected Sondes: %s" % str(_search_results))
return _search_results
def oneshot(self, first_only=False):
"""Perform a once-off scan attempt
Args:
first_only (bool): If True, return after detecting the first sonde. Otherwise continue to scan through all peaks.
Returns:
list: An empty list [] if no sondes are detected otherwise, a list of list, containing entries of [frequency (Hz), Sonde Type],
i.e. [[402500000,'RS41'],[402040000,'RS92']]
"""
# If we already have a scanner thread active, bomb out.
if self.sonde_scanner_running:
self.log_error("Oneshot scan attempted with scan thread running!")
return []
else:
# Otherwise, attempt a scan.
self.sonde_scanner_running = True
_result = self.sonde_search(first_only=first_only)
self.sonde_scanner_running = False
return _result
def stop(self, nowait=False):
"""Stop the Scan Loop"""
if self.sonde_scanner_running:
self.log_info("Waiting for current scan to finish...")
self.sonde_scanner_running = False
# Wait for the sonde scanner thread to close, if there is one.
if self.sonde_scan_thread != None and (not nowait):
self.sonde_scan_thread.join(60)
if self.sonde_scan_thread.is_alive():
self.log_error("Scanning thread did not finish, terminating")
sys.exit(4)
def running(self):
"""Check if the scanner is running"""
return self.sonde_scanner_running
def add_temporary_block(self, frequency):
"""Add a frequency to the temporary block list.
Args:
frequency (float): Frequency to be blocked, in Hz
"""
# Acquire a lock on the block list, so we don't accidentally modify it
# while it is being used in a scan.
self.temporary_block_list_lock.acquire()
self.temporary_block_list[frequency] = time.time()
self.temporary_block_list_lock.release()
self.log_info(
"Adding temporary block for frequency %.3f MHz." % (frequency / 1e6)
)
def log_debug(self, line):
"""Helper function to log a debug message with a descriptive heading.
Args:
line (str): Message to be logged.
"""
_sdr_name = get_sdr_name(
self.sdr_type,
rtl_device_idx = self.rtl_device_idx,
sdr_hostname = self.sdr_hostname,
sdr_port = self.sdr_port
)
logging.debug(f"Scanner ({_sdr_name}) - {line}")
def log_info(self, line):
"""Helper function to log an informational message with a descriptive heading.
Args:
line (str): Message to be logged.
"""
_sdr_name = get_sdr_name(
self.sdr_type,
rtl_device_idx = self.rtl_device_idx,
sdr_hostname = self.sdr_hostname,
sdr_port = self.sdr_port
)
logging.info(f"Scanner ({_sdr_name}) - {line}")
def log_error(self, line):
"""Helper function to log an error message with a descriptive heading.
Args:
line (str): Message to be logged.
"""
_sdr_name = get_sdr_name(
self.sdr_type,
rtl_device_idx = self.rtl_device_idx,
sdr_hostname = self.sdr_hostname,
sdr_port = self.sdr_port
)
logging.error(f"Scanner ({_sdr_name}) - {line}")
def log_warning(self, line):
"""Helper function to log a warning message with a descriptive heading.
Args:
line (str): Message to be logged.
"""
_sdr_name = get_sdr_name(
self.sdr_type,
rtl_device_idx = self.rtl_device_idx,
sdr_hostname = self.sdr_hostname,
sdr_port = self.sdr_port
)
logging.warning(f"Scanner ({_sdr_name}) - {line}")
if __name__ == "__main__":
# Basic test script - run a scan using default parameters.
logging.basicConfig(
format="%(asctime)s %(levelname)s:%(message)s", level=logging.DEBUG
)
# Callback to handle scan results
def print_result(scan_result):
print("SCAN RESULT: " + str(scan_result))
# Local spurs at my house :-)
never_scan = [401.7, 401.32, 402.09, 402.47, 400.17, 402.85]
# Instantiate scanner with default parameters.
_scanner = SondeScanner(callback=print_result, never_scan=never_scan)
try:
# Oneshot approach.
_result = _scanner.oneshot(first_only=True)
print("Oneshot search result: %s" % str(_result))
# Continuous scanning:
_scanner.start()
# Run until Ctrl-C, then exit cleanly.
while True:
time.sleep(1)
except KeyboardInterrupt:
_scanner.stop()
print("Exited cleanly.")