kopia lustrzana https://github.com/projecthorus/radiosonde_auto_rx
951 wiersze
35 KiB
Python
951 wiersze
35 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Radiosonde Auto RX Service - V2.0
|
|
#
|
|
# Copyright (C) 2018 Mark Jessop <vk5qi@rfhead.net>
|
|
# Released under GNU GPL v3 or later
|
|
#
|
|
# Refer github page for instructions on setup and usage.
|
|
# https://github.com/projecthorus/radiosonde_auto_rx/
|
|
#
|
|
import argparse
|
|
import datetime
|
|
import logging
|
|
import re
|
|
import sys
|
|
import time
|
|
import traceback
|
|
import os
|
|
|
|
import autorx
|
|
from autorx.scan import SondeScanner
|
|
from autorx.decode import SondeDecoder, VALID_SONDE_TYPES, DRIFTY_SONDE_TYPES
|
|
from autorx.logger import TelemetryLogger
|
|
from autorx.email_notification import EmailNotification
|
|
from autorx.habitat import HabitatUploader
|
|
from autorx.aprs import APRSUploader
|
|
from autorx.ozimux import OziUploader
|
|
from autorx.rotator import Rotator
|
|
from autorx.utils import (
|
|
rtlsdr_test,
|
|
position_info,
|
|
check_rs_utils,
|
|
check_autorx_version,
|
|
)
|
|
from autorx.config import read_auto_rx_config
|
|
from autorx.web import (
|
|
start_flask,
|
|
stop_flask,
|
|
flask_emit_event,
|
|
WebHandler,
|
|
WebExporter,
|
|
)
|
|
from autorx.gpsd import GPSDAdaptor
|
|
|
|
try:
|
|
# Python 2
|
|
from Queue import Queue
|
|
except ImportError:
|
|
# Python 3
|
|
from queue import Queue
|
|
|
|
|
|
# Logging level
|
|
# INFO = Basic status messages
|
|
# DEBUG = Adds detailed information on submodule operations.
|
|
logging_level = logging.INFO
|
|
|
|
|
|
#
|
|
# Global Variables
|
|
#
|
|
|
|
RS_PATH = "./"
|
|
|
|
# Optional override for RS92 ephemeris data.
|
|
rs92_ephemeris = None
|
|
|
|
# Global configuration dictionary. Populated on startup.
|
|
config = None
|
|
|
|
# Exporter Lists
|
|
exporter_objects = (
|
|
[]
|
|
) # This list will hold references to each exporter instance that is created.
|
|
exporter_functions = (
|
|
[]
|
|
) # This list will hold references to the exporter add functions, which will be passed onto the decoders.
|
|
|
|
# Separate reference to the e-mail exporter, as we may want to use this for error notifications.
|
|
email_exporter = None
|
|
|
|
# GPSDAdaptor Instance, if used.
|
|
gpsd_adaptor = None
|
|
|
|
# Temporary frequency block list
|
|
# This contains frequncies that should be blocked for a short amount of time.
|
|
temporary_block_list = {}
|
|
|
|
|
|
def allocate_sdr(check_only=False, task_description=""):
|
|
""" Allocate an un-used SDR for a task.
|
|
|
|
Args:
|
|
check_only (bool) : If True, don't set the free SDR as in-use. Used to check if there are any free SDRs.
|
|
|
|
Returns:
|
|
(str): The device index/serial number of the free/allocated SDR, if one is free, else None.
|
|
"""
|
|
|
|
for _idx in sorted(autorx.sdr_list.keys()):
|
|
if autorx.sdr_list[_idx]["in_use"] == False:
|
|
# Found a free SDR!
|
|
if check_only:
|
|
# If we are just checking to see if there are any SDRs free, we don't allocate it.
|
|
pass
|
|
else:
|
|
# Otherwise, set the SDR as in-use.
|
|
autorx.sdr_list[_idx]["in_use"] = True
|
|
logging.info(
|
|
"Task Manager - SDR #%s has been allocated to %s."
|
|
% (str(_idx), task_description)
|
|
)
|
|
|
|
return _idx
|
|
|
|
# Otherwise, no SDRs are free.
|
|
return None
|
|
|
|
|
|
def start_scanner():
|
|
""" Start a scanner thread on the first available SDR """
|
|
global config, RS_PATH, temporary_block_list
|
|
|
|
if "SCAN" in autorx.task_list:
|
|
# Already a scanner running! Return.
|
|
logging.debug(
|
|
"Task Manager - Attempted to start a scanner, but one already running."
|
|
)
|
|
return
|
|
|
|
# Attempt to allocate a SDR.
|
|
_device_idx = allocate_sdr(task_description="Scanner")
|
|
if _device_idx is None:
|
|
logging.debug("Task Manager - No SDRs free to run Scanner.")
|
|
return
|
|
else:
|
|
# Create entry in task list.
|
|
autorx.task_list["SCAN"] = {"device_idx": _device_idx, "task": None}
|
|
|
|
# Init Scanner using settings from the global config.
|
|
# TODO: Nicer way of passing in the huge list of args.
|
|
autorx.task_list["SCAN"]["task"] = SondeScanner(
|
|
callback=autorx.scan_results.put,
|
|
auto_start=True,
|
|
min_freq=config["min_freq"],
|
|
max_freq=config["max_freq"],
|
|
search_step=config["search_step"],
|
|
whitelist=config["whitelist"],
|
|
greylist=config["greylist"],
|
|
blacklist=config["blacklist"],
|
|
snr_threshold=config["snr_threshold"],
|
|
min_distance=config["min_distance"],
|
|
quantization=config["quantization"],
|
|
scan_dwell_time=config["scan_dwell_time"],
|
|
detect_dwell_time=config["detect_dwell_time"],
|
|
max_peaks=config["max_peaks"],
|
|
rs_path=RS_PATH,
|
|
sdr_power=config["sdr_power"],
|
|
sdr_fm=config["sdr_fm"],
|
|
device_idx=_device_idx,
|
|
gain=autorx.sdr_list[_device_idx]["gain"],
|
|
ppm=autorx.sdr_list[_device_idx]["ppm"],
|
|
bias=autorx.sdr_list[_device_idx]["bias"],
|
|
save_detection_audio=config["save_detection_audio"],
|
|
temporary_block_list=temporary_block_list,
|
|
temporary_block_time=config["temporary_block_time"],
|
|
)
|
|
|
|
# Add a reference into the sdr_list entry
|
|
autorx.sdr_list[_device_idx]["task"] = autorx.task_list["SCAN"]["task"]
|
|
|
|
# Indicate to the web client that the task list has been updated.
|
|
flask_emit_event("task_event")
|
|
|
|
|
|
def stop_scanner():
|
|
""" Stop a currently running scan thread, and release the SDR it was using. """
|
|
|
|
if "SCAN" not in autorx.task_list:
|
|
# No scanner thread running!
|
|
# This means we likely have a SDR free already.
|
|
return
|
|
else:
|
|
logging.info("Halting Scanner to decode detected radiosonde.")
|
|
_scan_sdr = autorx.task_list["SCAN"]["device_idx"]
|
|
# Stop the scanner.
|
|
autorx.task_list["SCAN"]["task"].stop()
|
|
# Relase the SDR.
|
|
autorx.sdr_list[_scan_sdr]["in_use"] = False
|
|
autorx.sdr_list[_scan_sdr]["task"] = None
|
|
# Remove the scanner task from the task list
|
|
autorx.task_list.pop("SCAN")
|
|
|
|
|
|
def start_decoder(freq, sonde_type):
|
|
""" Attempt to start a decoder thread for a given sonde.
|
|
|
|
Args:
|
|
freq (float): Radiosonde frequency in Hz.
|
|
sonde_type (str): The radiosonde type ('RS41', 'RS92', 'DFM', 'M10, 'iMet')
|
|
|
|
"""
|
|
global config, RS_PATH, exporter_functions, rs92_ephemeris, temporary_block_list
|
|
|
|
# Allocate a SDR.
|
|
_device_idx = allocate_sdr(
|
|
task_description="Decoder (%s, %.3f MHz)" % (sonde_type, freq / 1e6)
|
|
)
|
|
|
|
if _device_idx is None:
|
|
logging.error("Could not allocate SDR for decoder!")
|
|
return
|
|
else:
|
|
# Add an entry to the task list
|
|
autorx.task_list[freq] = {"device_idx": _device_idx, "task": None}
|
|
|
|
# Set the SDR to in-use
|
|
autorx.sdr_list[_device_idx]["in_use"] = True
|
|
|
|
if sonde_type.startswith("-"):
|
|
_exp_sonde_type = sonde_type[1:]
|
|
else:
|
|
_exp_sonde_type = sonde_type
|
|
|
|
# Initialise a decoder.
|
|
autorx.task_list[freq]["task"] = SondeDecoder(
|
|
sonde_type=sonde_type,
|
|
sonde_freq=freq,
|
|
rs_path=RS_PATH,
|
|
sdr_fm=config["sdr_fm"],
|
|
device_idx=_device_idx,
|
|
gain=autorx.sdr_list[_device_idx]["gain"],
|
|
ppm=autorx.sdr_list[_device_idx]["ppm"],
|
|
bias=autorx.sdr_list[_device_idx]["bias"],
|
|
save_decode_audio=config["save_decode_audio"],
|
|
save_decode_iq=config["save_decode_iq"],
|
|
exporter=exporter_functions,
|
|
timeout=config["rx_timeout"],
|
|
telem_filter=telemetry_filter,
|
|
rs92_ephemeris=rs92_ephemeris,
|
|
imet_location=config["station_code"],
|
|
rs41_drift_tweak=config["rs41_drift_tweak"],
|
|
experimental_decoder=config["experimental_decoders"][_exp_sonde_type],
|
|
)
|
|
autorx.sdr_list[_device_idx]["task"] = autorx.task_list[freq]["task"]
|
|
|
|
# Indicate to the web client that the task list has been updated.
|
|
flask_emit_event("task_event")
|
|
|
|
|
|
def handle_scan_results():
|
|
""" Read in Scan results via the scan results Queue.
|
|
|
|
Depending on how many SDRs are available, two things can happen:
|
|
- If there is a free SDR, allocate it to a decoder.
|
|
- If there is no free SDR, but a scanner is running, stop the scanner and start decoding.
|
|
"""
|
|
global config, temporary_block_list
|
|
|
|
if autorx.scan_results.qsize() > 0:
|
|
# Grab the latest detections from the scan result queue.
|
|
_scan_data = autorx.scan_results.get()
|
|
for _sonde in _scan_data:
|
|
# Extract frequency & type info
|
|
_freq = _sonde[0]
|
|
_type = _sonde[1]
|
|
|
|
if _freq in autorx.task_list:
|
|
# Already decoding this sonde, continue.
|
|
continue
|
|
else:
|
|
|
|
# Check that we are not attempting to start a decoder too close to an existing decoder for known 'drifty' radiosonde types.
|
|
# 'Too close' is defined by the 'decoder_spacing_limit' advanced coniguration option.
|
|
_too_close = False
|
|
for _key in autorx.task_list.keys():
|
|
# Iterate through the task list, and only attempt to compare with those that are a decoder task.
|
|
# This is indicated by the task key being an integer (the sonde frequency).
|
|
if (type(_key) == int) or (type(_key) == float):
|
|
# Extract the currently decoded sonde type from the currently running decoder.
|
|
_decoding_sonde_type = autorx.task_list[_key]["task"].sonde_type
|
|
|
|
# Only check the frequency spacing if we have a known 'drifty' sonde type, *and* the new sonde type is of the same type.
|
|
if (_decoding_sonde_type in DRIFTY_SONDE_TYPES) and (
|
|
_decoding_sonde_type == _type
|
|
):
|
|
if abs(_key - _freq) < config["decoder_spacing_limit"]:
|
|
# At this point, we can be pretty sure that there is another decoder already decoding this particular sonde ID.
|
|
# Without actually starting another decoder and matching IDs, we can't be 100% sure, but it's a good chance.
|
|
logging.error(
|
|
"Task Manager - Detected %s sonde on %.3f MHz, but this is within %d kHz of an already running decoder. (This limit can be set using the 'decoder_spacing_limit' advanced config option.)"
|
|
% (
|
|
_type,
|
|
_freq / 1e6,
|
|
config["decoder_spacing_limit"] / 1e3,
|
|
)
|
|
)
|
|
_too_close = True
|
|
continue
|
|
|
|
# Continue to the next scan result if this one is too close to a currently running decoder.
|
|
if _too_close:
|
|
continue
|
|
|
|
# Check the frequency is not in our temporary block list
|
|
# (This may happen from time-to-time depending on the timing of the scan thread)
|
|
if _freq in temporary_block_list.keys():
|
|
if temporary_block_list[_freq] > (
|
|
time.time() - config["temporary_block_time"] * 60
|
|
):
|
|
logging.error(
|
|
"Task Manager - Attempted to start a decoder on a temporarily blocked frequency (%.3f MHz)"
|
|
% (_freq / 1e6)
|
|
)
|
|
continue
|
|
else:
|
|
# This frequency should not be blocked any more, remove it from the block list.
|
|
logging.info(
|
|
"Task Manager - Removed %.3f MHz from temporary block list."
|
|
% (_freq / 1e6)
|
|
)
|
|
temporary_block_list.pop(_freq)
|
|
|
|
# Handle an inverted sonde detection.
|
|
if _type.startswith("-"):
|
|
_inverted = " (Inverted)"
|
|
_check_type = _type[1:]
|
|
else:
|
|
_check_type = _type
|
|
_inverted = ""
|
|
|
|
# Note: We don't indicate if it's been detected as inverted here.
|
|
logging.info(
|
|
"Task Manager - Detected new %s sonde on %.3f MHz!"
|
|
% (_check_type, _freq / 1e6)
|
|
)
|
|
|
|
# Break if we don't support this sonde type.
|
|
if _check_type not in VALID_SONDE_TYPES:
|
|
logging.error(
|
|
"Task Manager - Unsupported sonde type: %s" % _check_type
|
|
)
|
|
# TODO - Potentially add the frequency of the unsupported sonde to the temporary block list?
|
|
continue
|
|
|
|
if allocate_sdr(check_only=True) is not None:
|
|
# There is a SDR free! Start the decoder on that SDR
|
|
start_decoder(_freq, _type)
|
|
|
|
elif (allocate_sdr(check_only=True) is None) and (
|
|
"SCAN" in autorx.task_list
|
|
):
|
|
# We have run out of SDRs, but a scan thread is running.
|
|
# Stop the scan thread and take that receiver!
|
|
stop_scanner()
|
|
start_decoder(_freq, _type)
|
|
else:
|
|
# We have no SDRs free.
|
|
# TODO: Alert the user that a sonde was detected, but no SDR was available,
|
|
# but don't do this EVERY time we detect the sonde...
|
|
pass
|
|
|
|
|
|
def clean_task_list():
|
|
""" Check the task list to see if any tasks have stopped running. If so, release the associated SDR """
|
|
|
|
for _key in autorx.task_list.copy().keys():
|
|
# Attempt to get the state of the task
|
|
try:
|
|
_running = autorx.task_list[_key]["task"].running()
|
|
_task_sdr = autorx.task_list[_key]["device_idx"]
|
|
_exit_state = autorx.task_list[_key]["task"].exit_state
|
|
except Exception as e:
|
|
logging.error(
|
|
"Task Manager - Error getting task %s state - %s" % (str(_key), str(e))
|
|
)
|
|
continue
|
|
|
|
if _running == False:
|
|
# This task has stopped.
|
|
# Check the exit state of the task for any abnormalities:
|
|
if (_exit_state == "Encrypted") or (_exit_state == "TempBlock"):
|
|
# This task was a decoder, and it has encountered an encrypted sonde, or one too far away.
|
|
logging.info(
|
|
"Task Manager - Adding temporary block for frequency %.3f MHz"
|
|
% (_key / 1e6)
|
|
)
|
|
# Add the sonde's frequency to the global temporary block-list
|
|
temporary_block_list[_key] = time.time()
|
|
# If there is a scanner currently running, add it to the scanners internal block list.
|
|
if "SCAN" in autorx.task_list:
|
|
autorx.task_list["SCAN"]["task"].add_temporary_block(_key)
|
|
|
|
if _exit_state == "FAILED SDR":
|
|
# The SDR was not able to be recovered after many attempts.
|
|
# Remove it from the SDR list and flag an error.
|
|
autorx.sdr_list.pop(_task_sdr)
|
|
_error_msg = (
|
|
"Task Manager - Removed SDR %s from SDR list due to repeated failures."
|
|
% (str(_task_sdr))
|
|
)
|
|
logging.error(_error_msg)
|
|
|
|
# Send email if configured.
|
|
email_error(_error_msg)
|
|
|
|
else:
|
|
# Release its associated SDR.
|
|
autorx.sdr_list[_task_sdr]["in_use"] = False
|
|
autorx.sdr_list[_task_sdr]["task"] = None
|
|
|
|
# Pop the task from the task list.
|
|
autorx.task_list.pop(_key)
|
|
# Indicate to the web client that the task list has been updated.
|
|
flask_emit_event("task_event")
|
|
|
|
# Clean out the temporary block list of old entries.
|
|
for _freq in temporary_block_list.copy().keys():
|
|
if temporary_block_list[_freq] < (
|
|
time.time() - config["temporary_block_time"] * 60
|
|
):
|
|
temporary_block_list.pop(_freq)
|
|
logging.info(
|
|
"Task Manager - Removed %.3f MHz from temporary block list."
|
|
% (_freq / 1e6)
|
|
)
|
|
|
|
# Check if there is a scanner thread still running.
|
|
# If not, and if there is a SDR free, start one up again.
|
|
# Also check for a global scan inhibit flag.
|
|
if (
|
|
("SCAN" not in autorx.task_list)
|
|
and (not autorx.scan_inhibit)
|
|
and (allocate_sdr(check_only=True) is not None)
|
|
):
|
|
# We have a SDR free, and we are not running a scan thread. Start one.
|
|
start_scanner()
|
|
|
|
|
|
def stop_all():
|
|
""" Shut-down all decoders, scanners, and exporters. """
|
|
global exporter_objects
|
|
logging.info("Starting shutdown of all threads.")
|
|
for _task in autorx.task_list.keys():
|
|
try:
|
|
autorx.task_list[_task]["task"].stop()
|
|
except Exception as e:
|
|
logging.error("Error stopping task - %s" % str(e))
|
|
|
|
for _exporter in exporter_objects:
|
|
try:
|
|
_exporter.close()
|
|
except Exception as e:
|
|
logging.error("Error stopping exporter - %s" % str(e))
|
|
|
|
if gpsd_adaptor != None:
|
|
gpsd_adaptor.close()
|
|
|
|
|
|
def telemetry_filter(telemetry):
|
|
""" Filter incoming radiosonde telemetry based on various factors,
|
|
- Invalid Position
|
|
- Invalid Altitude
|
|
- Abnormal range from receiver.
|
|
- Invalid serial number.
|
|
|
|
This function is defined within this script to avoid passing around large amounts of configuration data.
|
|
|
|
"""
|
|
global config
|
|
|
|
# First Check: zero lat/lon
|
|
if (telemetry["lat"] == 0.0) and (telemetry["lon"] == 0.0):
|
|
logging.warning(
|
|
"Zero Lat/Lon. Sonde %s does not have GPS lock." % telemetry["id"]
|
|
)
|
|
return False
|
|
|
|
# Second check: Altitude cap.
|
|
if telemetry["alt"] > config["max_altitude"]:
|
|
_altitude_breach = telemetry["alt"] - config["max_altitude"]
|
|
logging.warning(
|
|
"Sonde %s position breached altitude cap by %d m."
|
|
% (telemetry["id"], _altitude_breach)
|
|
)
|
|
return False
|
|
|
|
# Third check: Number of satellites visible.
|
|
if "sats" in telemetry:
|
|
if telemetry["sats"] < 4:
|
|
logging.warning(
|
|
"Sonde %s can only see %d SVs - discarding position as bad."
|
|
% (telemetry["id"], telemetry["sats"])
|
|
)
|
|
return False
|
|
|
|
# Fourth check - is the payload more than x km from our listening station.
|
|
# Only run this check if a station location has been provided.
|
|
if (config["station_lat"] != 0.0) and (config["station_lon"] != 0.0):
|
|
# Calculate the distance from the station to the payload.
|
|
_listener = (
|
|
config["station_lat"],
|
|
config["station_lon"],
|
|
config["station_alt"],
|
|
)
|
|
_payload = (telemetry["lat"], telemetry["lon"], telemetry["alt"])
|
|
# Calculate using positon_info function from rotator_utils.py
|
|
_info = position_info(_listener, _payload)
|
|
|
|
if _info["straight_distance"] > config["max_radius_km"] * 1000:
|
|
_radius_breach = (
|
|
_info["straight_distance"] / 1000.0 - config["max_radius_km"]
|
|
)
|
|
logging.warning(
|
|
"Sonde %s position breached radius cap by %.1f km."
|
|
% (telemetry["id"], _radius_breach)
|
|
)
|
|
|
|
if config["radius_temporary_block"]:
|
|
logging.warning(
|
|
"Blocking for %d minutes." % config["temporary_block_time"]
|
|
)
|
|
return "TempBlock"
|
|
else:
|
|
return False
|
|
|
|
if (_info["straight_distance"] < config["min_radius_km"] * 1000) and config[
|
|
"radius_temporary_block"
|
|
]:
|
|
logging.warning(
|
|
"Sonde %s within minimum radius limit (%.1f km). Blocking for %d minutes."
|
|
% (
|
|
telemetry["id"],
|
|
config["min_radius_km"],
|
|
config["temporary_block_time"],
|
|
)
|
|
)
|
|
return "TempBlock"
|
|
|
|
# Payload Serial Number Checks
|
|
_serial = telemetry["id"]
|
|
# Run a Regex to match known Vaisala RS92/RS41 serial numbers (YWWDxxxx)
|
|
# RS92: https://www.vaisala.com/sites/default/files/documents/Vaisala%20Radiosonde%20RS92%20Serial%20Number.pdf
|
|
# RS41: https://www.vaisala.com/sites/default/files/documents/Vaisala%20Radiosonde%20RS41%20Serial%20Number.pdf
|
|
# This will need to be re-evaluated if we're still using this code in 2021!
|
|
# UPDATE: Had some confirmation that Vaisala will continue to use the alphanumeric numbering up until
|
|
# ~2025-2030, so have expanded the regex to match (and also support some older RS92s)
|
|
vaisala_callsign_valid = re.match(r"[E-Z][0-5][\d][1-7]\d{4}", _serial)
|
|
|
|
# Regex to check DFM callsigns are valid.
|
|
# DFM serial numbers have at least 6 numbers (newer sondes have 8)
|
|
dfm_callsign_valid = re.match(r"DFM-\d{6}", _serial)
|
|
|
|
# Check Meisei sonde callsigns for validity.
|
|
# meisei_ims returns a callsign of IMS100-xxxxxx until it receives the serial number, so we filter based on the x's being present or not.
|
|
if "MEISEI" in telemetry["type"]:
|
|
meisei_callsign_valid = "x" not in _serial.split("-")[1]
|
|
else:
|
|
meisei_callsign_valid = False
|
|
|
|
# If Vaisala or DFMs, check the callsigns are valid. If M10, iMet or LMS6, just pass it through - we get callsigns immediately and reliably from these.
|
|
if (
|
|
vaisala_callsign_valid
|
|
or dfm_callsign_valid
|
|
or meisei_callsign_valid
|
|
or ("M10" in telemetry["type"])
|
|
or ("M20" in telemetry["type"])
|
|
or ("LMS" in telemetry["type"])
|
|
or ("IMET" in telemetry["type"])
|
|
):
|
|
return "OK"
|
|
else:
|
|
_id_msg = "Payload ID %s is invalid." % telemetry["id"]
|
|
# Add in a note about DFM sondes and their oddness...
|
|
if "DFM" in telemetry["id"]:
|
|
_id_msg += " Note: DFM sondes may take a while to get an ID."
|
|
|
|
logging.warning(_id_msg)
|
|
return False
|
|
|
|
|
|
def station_position_update(position):
|
|
""" Handle a callback from GPSDAdaptor object, and update each exporter object. """
|
|
global exporter_objects
|
|
# Quick sanity check of the incoming data
|
|
if "valid" not in position:
|
|
return
|
|
|
|
for _exporter in exporter_objects:
|
|
try:
|
|
_exporter.update_station_position(
|
|
position["latitude"], position["longitude"], position["altitude"]
|
|
)
|
|
except AttributeError:
|
|
# This exporter does not require station position data.
|
|
pass
|
|
except Exception as e:
|
|
traceback.print_exc()
|
|
logging.error("Error updating exporter station position.")
|
|
|
|
|
|
def email_error(message="foo"):
|
|
""" Helper function to email an error message, if the email exporter is available """
|
|
global email_exporter
|
|
|
|
if email_exporter and config["email_error_notifications"]:
|
|
try:
|
|
email_exporter.send_notification_email(message=message)
|
|
except Exception as e:
|
|
logging.error("Error attempting to send notification email: %s" % str(e))
|
|
else:
|
|
logging.debug("Not sending Email notification, as Email not configured.")
|
|
|
|
|
|
def main():
|
|
""" Main Loop """
|
|
global config, exporter_objects, exporter_functions, logging_level, rs92_ephemeris, gpsd_adaptor, email_exporter
|
|
|
|
# Command line arguments.
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"-c",
|
|
"--config",
|
|
default="station.cfg",
|
|
help="Receive Station Configuration File. Default: station.cfg",
|
|
)
|
|
parser.add_argument(
|
|
"-l",
|
|
"--log",
|
|
default="./log/",
|
|
help="Receive Station Log Path. Default: ./log/",
|
|
)
|
|
parser.add_argument(
|
|
"-f",
|
|
"--frequency",
|
|
type=float,
|
|
default=0.0,
|
|
help="Sonde Frequency Override (MHz). This overrides the scan whitelist with the supplied frequency.",
|
|
)
|
|
parser.add_argument(
|
|
"-m",
|
|
"--type",
|
|
type=str,
|
|
default=None,
|
|
help="Immediately start a decoder for a provided sonde type (Valid Types: RS41, RS92, DFM, M10, M20, IMET, IMET5, LMS6, MK2LMS, MEISEI)",
|
|
)
|
|
parser.add_argument(
|
|
"-t",
|
|
"--timeout",
|
|
type=int,
|
|
default=0,
|
|
help="Close auto_rx system after N minutes. Use 0 to run continuously.",
|
|
)
|
|
parser.add_argument(
|
|
"-v", "--verbose", help="Enable debug output.", action="store_true"
|
|
)
|
|
parser.add_argument(
|
|
"-e",
|
|
"--ephemeris",
|
|
type=str,
|
|
default="None",
|
|
help="Use a manually obtained ephemeris file when decoding RS92 Sondes.",
|
|
)
|
|
parser.add_argument(
|
|
"--systemlog",
|
|
action="store_true",
|
|
default=False,
|
|
help="Write a auto_rx system log-file to ./log/ (default=False)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Copy out timeout value, and convert to seconds,
|
|
_timeout = args.timeout * 60
|
|
|
|
# Copy out RS92 ephemeris value, if provided.
|
|
if args.ephemeris != "None":
|
|
rs92_ephemeris = args.ephemeris
|
|
|
|
# Set log-level to DEBUG if requested
|
|
if args.verbose:
|
|
logging_level = logging.DEBUG
|
|
|
|
# Define the default logging path
|
|
logging_path = "./log/"
|
|
|
|
# Validate the user supplied log path
|
|
if os.path.isdir(args.log):
|
|
logging_path = os.path.abspath(args.log)
|
|
else:
|
|
# Using print because logging may not be established yet
|
|
print("Invalid logging path, using default. Does the folder exist?")
|
|
|
|
# Configure logging
|
|
_log_suffix = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S_system.log")
|
|
_log_path = os.path.join(logging_path, _log_suffix)
|
|
|
|
if args.systemlog:
|
|
# Only write out a logs to a system log file if we have been asked to.
|
|
# Systemd will capture and logrotate our logs anyway, so writing to our own log file is less useful.
|
|
logging.basicConfig(
|
|
format="%(asctime)s %(levelname)s:%(message)s",
|
|
filename=_log_path,
|
|
level=logging_level,
|
|
)
|
|
logging.info("Opened new system log file: %s" % _log_path)
|
|
# Also add a separate stdout logger.
|
|
stdout_format = logging.Formatter("%(asctime)s %(levelname)s:%(message)s")
|
|
stdout_handler = logging.StreamHandler(sys.stdout)
|
|
stdout_handler.setFormatter(stdout_format)
|
|
logging.getLogger().addHandler(stdout_handler)
|
|
else:
|
|
# Otherwise, we only need the stdout logger, which if we don't specify a filename to logging.basicConfig,
|
|
# is the default...
|
|
logging.basicConfig(
|
|
format="%(asctime)s %(levelname)s:%(message)s", level=logging_level
|
|
)
|
|
|
|
# Add the web interface logging handler.
|
|
web_handler = WebHandler()
|
|
logging.getLogger().addHandler(web_handler)
|
|
|
|
# Set the requests/socketio loggers (and related) to only display critical log messages.
|
|
logging.getLogger("requests").setLevel(logging.CRITICAL)
|
|
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
|
|
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
|
logging.getLogger("socketio").setLevel(logging.ERROR)
|
|
logging.getLogger("engineio").setLevel(logging.ERROR)
|
|
logging.getLogger("geventwebsocket").setLevel(logging.ERROR)
|
|
|
|
# Attempt to read in config file
|
|
logging.info("Reading configuration file...")
|
|
_temp_cfg = read_auto_rx_config(args.config)
|
|
if _temp_cfg is None:
|
|
logging.critical("Error in configuration file! Exiting...")
|
|
sys.exit(1)
|
|
else:
|
|
config = _temp_cfg
|
|
autorx.sdr_list = config["sdr_settings"]
|
|
|
|
# Check all the RS utilities exist.
|
|
if not check_rs_utils():
|
|
sys.exit(1)
|
|
|
|
# Start up the flask server.
|
|
# This needs to occur AFTER logging is setup, else logging breaks horribly for some reason.
|
|
start_flask(host=config["web_host"], port=config["web_port"])
|
|
|
|
# If we have been supplied a frequency via the command line, override the whitelist settings
|
|
# to only include the supplied frequency.
|
|
if args.frequency != 0.0:
|
|
config["whitelist"] = [args.frequency]
|
|
|
|
# Start our exporter options
|
|
# Telemetry Logger
|
|
if config["per_sonde_log"]:
|
|
_logger = TelemetryLogger(log_directory=logging_path)
|
|
exporter_objects.append(_logger)
|
|
exporter_functions.append(_logger.add)
|
|
|
|
if config["email_enabled"]:
|
|
|
|
_email_notification = EmailNotification(
|
|
smtp_server=config["email_smtp_server"],
|
|
smtp_port=config["email_smtp_port"],
|
|
smtp_authentication=config["email_smtp_authentication"],
|
|
smtp_login=config["email_smtp_login"],
|
|
smtp_password=config["email_smtp_password"],
|
|
mail_from=config["email_from"],
|
|
mail_to=config["email_to"],
|
|
mail_subject=config["email_subject"],
|
|
station_position=(
|
|
config["station_lat"],
|
|
config["station_lon"],
|
|
config["station_alt"],
|
|
),
|
|
launch_notifications=config["email_launch_notifications"],
|
|
landing_notifications=config["email_landing_notifications"],
|
|
landing_range_threshold=config["email_landing_range_threshold"],
|
|
landing_altitude_threshold=config["email_landing_altitude_threshold"]
|
|
)
|
|
email_exporter = _email_notification
|
|
|
|
exporter_objects.append(_email_notification)
|
|
exporter_functions.append(_email_notification.add)
|
|
|
|
# Habitat Uploader
|
|
if config["habitat_enabled"]:
|
|
|
|
if config["habitat_upload_listener_position"] is False:
|
|
_habitat_station_position = None
|
|
else:
|
|
_habitat_station_position = (
|
|
config["station_lat"],
|
|
config["station_lon"],
|
|
config["station_alt"],
|
|
)
|
|
|
|
_habitat = HabitatUploader(
|
|
user_callsign=config["habitat_uploader_callsign"],
|
|
user_antenna=config["habitat_uploader_antenna"],
|
|
station_position=_habitat_station_position,
|
|
synchronous_upload_time=config["habitat_upload_rate"],
|
|
callsign_validity_threshold=config["payload_id_valid"],
|
|
url=config["habitat_url"],
|
|
)
|
|
|
|
exporter_objects.append(_habitat)
|
|
exporter_functions.append(_habitat.add)
|
|
|
|
# APRS Uploader
|
|
if config["aprs_enabled"]:
|
|
|
|
if (config["aprs_object_id"] == "<id>") or (
|
|
config["aprs_use_custom_object_id"] == False
|
|
):
|
|
_aprs_object = None
|
|
else:
|
|
_aprs_object = config["aprs_object_id"]
|
|
|
|
_aprs = APRSUploader(
|
|
aprs_callsign=config["aprs_user"],
|
|
aprs_passcode=config["aprs_pass"],
|
|
object_name_override=_aprs_object,
|
|
object_comment=config["aprs_custom_comment"],
|
|
position_report=config["aprs_position_report"],
|
|
aprsis_host=config["aprs_server"],
|
|
aprsis_port=config["aprs_port"],
|
|
synchronous_upload_time=config["aprs_upload_rate"],
|
|
callsign_validity_threshold=config["payload_id_valid"],
|
|
station_beacon=config["station_beacon_enabled"],
|
|
station_beacon_rate=config["station_beacon_rate"],
|
|
station_beacon_position=(
|
|
config["station_lat"],
|
|
config["station_lon"],
|
|
config["station_alt"],
|
|
),
|
|
station_beacon_comment=config["station_beacon_comment"],
|
|
station_beacon_icon=config["station_beacon_icon"],
|
|
)
|
|
|
|
exporter_objects.append(_aprs)
|
|
exporter_functions.append(_aprs.add)
|
|
|
|
# OziExplorer
|
|
if config["ozi_enabled"] or config["payload_summary_enabled"]:
|
|
if config["ozi_enabled"]:
|
|
_ozi_port = config["ozi_port"]
|
|
else:
|
|
_ozi_port = None
|
|
|
|
if config["payload_summary_enabled"]:
|
|
_summary_port = config["payload_summary_port"]
|
|
else:
|
|
_summary_port = None
|
|
|
|
_ozimux = OziUploader(
|
|
ozimux_port=_ozi_port,
|
|
payload_summary_port=_summary_port,
|
|
update_rate=config["ozi_update_rate"],
|
|
station=config["habitat_uploader_callsign"],
|
|
)
|
|
|
|
exporter_objects.append(_ozimux)
|
|
exporter_functions.append(_ozimux.add)
|
|
|
|
# Rotator
|
|
if config["rotator_enabled"]:
|
|
_rotator = Rotator(
|
|
station_position=(
|
|
config["station_lat"],
|
|
config["station_lon"],
|
|
config["station_alt"],
|
|
),
|
|
rotctld_host=config["rotator_hostname"],
|
|
rotctld_port=config["rotator_port"],
|
|
rotator_update_rate=config["rotator_update_rate"],
|
|
rotator_update_threshold=config["rotation_threshold"],
|
|
rotator_homing_enabled=config["rotator_homing_enabled"],
|
|
rotator_homing_delay=config["rotator_homing_delay"],
|
|
rotator_home_position=[
|
|
config["rotator_home_azimuth"],
|
|
config["rotator_home_elevation"],
|
|
],
|
|
)
|
|
|
|
exporter_objects.append(_rotator)
|
|
exporter_functions.append(_rotator.add)
|
|
|
|
_web_exporter = WebExporter(max_age=config["web_archive_age"])
|
|
exporter_objects.append(_web_exporter)
|
|
exporter_functions.append(_web_exporter.add)
|
|
|
|
# GPSD Startup
|
|
if config["gpsd_enabled"]:
|
|
gpsd_adaptor = GPSDAdaptor(
|
|
hostname=config["gpsd_host"],
|
|
port=config["gpsd_port"],
|
|
callback=station_position_update,
|
|
)
|
|
|
|
check_autorx_version()
|
|
|
|
# Note the start time.
|
|
_start_time = time.time()
|
|
|
|
# If a sonde type has been provided, insert an entry into the scan results,
|
|
# and immediately start a decoder. If decoding fails, then we continue into
|
|
# the main scanning loop.
|
|
if args.type != None:
|
|
autorx.scan_results.put([[args.frequency * 1e6, args.type]])
|
|
handle_scan_results()
|
|
|
|
# Loop.
|
|
while True:
|
|
# Check for finished tasks.
|
|
clean_task_list()
|
|
# Handle any new scan results.
|
|
handle_scan_results()
|
|
# Sleep a little bit.
|
|
time.sleep(2)
|
|
|
|
if len(autorx.sdr_list) == 0:
|
|
# No Functioning SDRs!
|
|
logging.critical("Task Manager - No SDRs available! Cannot continue...")
|
|
email_error("auto_rx exited due to all SDRs being marked as failed.")
|
|
raise IOError("No SDRs available!")
|
|
|
|
# Allow a timeout after a set time, for users who wish to run auto_rx
|
|
# within a cronjob.
|
|
if (_timeout > 0) and ((time.time() - _start_time) > _timeout):
|
|
logging.info("Shutdown time reached. Closing.")
|
|
stop_flask(host=config["web_host"], port=config["web_port"])
|
|
stop_all()
|
|
break
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
# Upon CTRL+C, shutdown all threads and exit.
|
|
stop_flask(host=config["web_host"], port=config["web_port"])
|
|
stop_all()
|
|
except Exception as e:
|
|
# Upon exceptions, attempt to shutdown threads and exit.
|
|
traceback.print_exc()
|
|
print("Main Loop Error - %s" % str(e))
|
|
stop_flask(host=config["web_host"], port=config["web_port"])
|
|
stop_all()
|