#!/usr/bin/env python # # Radiosonde Auto RX Service - V2.0 # # Copyright (C) 2018 Mark Jessop # 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 from dateutil.parser import parse from queue import Queue if sys.version_info < (3, 6): print("CRITICAL - radiosonde_auto_rx requires Python 3.6 or newer!") sys.exit(1) 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.sondehub import SondehubUploader from autorx.rotator import Rotator from autorx.utils import ( rtlsdr_test, position_info, check_rs_utils, version_startup_check, ) 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 from autorx.sdr_wrappers import shutdown_sdr # 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"], only_scan=config["only_scan"], always_scan=config["always_scan"], never_scan=config["never_scan"], snr_threshold=config["snr_threshold"], min_distance=config["min_distance"], quantization=config["quantization"], scan_dwell_time=config["scan_dwell_time"], scan_delay=config["scan_delay"], detect_dwell_time=config["detect_dwell_time"], max_peaks=config["max_peaks"], rs_path=RS_PATH, sdr_type=config["sdr_type"], # Network SDR Options sdr_hostname=config["sdr_hostname"], sdr_port=config["sdr_port"], ss_iq_path=config["ss_iq_path"], ss_power_path=config["ss_power_path"], rtl_power_path=config["sdr_power"], rtl_fm_path=config["sdr_fm"], rtl_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, continuous=False): """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') continuous (bool): If true, don't use a decode timeout. """ 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 if continuous: _timeout = 0 else: _timeout = config["rx_timeout"] # Initialise a decoder. autorx.task_list[freq]["task"] = SondeDecoder( sonde_type=sonde_type, sonde_freq=freq, rs_path=RS_PATH, sdr_type=config["sdr_type"], # Network SDR Options sdr_hostname=config["sdr_hostname"], sdr_port=config["sdr_port"], ss_iq_path=config["ss_iq_path"], # RTLSDR Options rtl_fm_path=config["sdr_fm"], rtl_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"], # Other options save_decode_audio=config["save_decode_audio"], save_decode_iq=config["save_decode_iq"], exporter=exporter_functions, timeout=_timeout, telem_filter=telemetry_filter, rs92_ephemeris=rs92_ephemeris, rs41_drift_tweak=config["rs41_drift_tweak"], experimental_decoder=config["experimental_decoders"][_exp_sonde_type], save_raw_hex=config["save_raw_hex"] ) 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.info( "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.warning( "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.warning( "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: # Shutdown the SDR, if required for the particular SDR type. shutdown_sdr(config["sdr_type"], _task_sdr) # 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() # Always-on decoders. if len(config["always_decode"]) > 0: for _entry in config["always_decode"]: try: _freq_hz = float(_entry[0])*1e6 _type = str(_entry[1]) except: logging.warning(f"Task Manager - Invalid entry found in always_decode list, skipping.") continue if _freq_hz in autorx.task_list: # Already running a decoder here. continue else: # Try and start up a decoder. if (allocate_sdr(check_only=True) is not None): logging.info(f"Task Manager - Starting Always-On Decoder: {_type}, {_freq_hz/1e6:.3f} MHz") start_decoder(_freq_hz, _type, continuous=True) 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. - Abnormal date (more than 6 hours from utcnow) 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 GNSS sats - 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" # DateTime Check _delta_time = ( datetime.datetime.now(datetime.timezone.utc) - parse(telemetry["datetime"]) ).total_seconds() logging.debug("Delta time: %d" % _delta_time) if abs(_delta_time) > (3600 * config["sonde_time_threshold"]): logging.warning( "Sonde reported time too far from current UTC time. Either sonde time or system time is invalid. (Threshold: %d hours)" % config["sonde_time_threshold"] ) return False # 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) # Modified 2021-06 to be more flexible and match older sondes, and reprogrammed sondes. # Still needs a letter at the start, but the numbers don't need to match the format exactly. vaisala_callsign_valid = re.match(r"[C-Z][\d][\d][\d]\d{4}", _serial) # Just make sure we're not getting the 'xxxxxxxx' unknown serial from the DFM decoder. if "DFM" in telemetry["type"]: dfm_callsign_valid = "x" not in _serial.split("-")[1] else: dfm_callsign_valid = False # 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 "MRZ" in telemetry["type"]: mrz_callsign_valid = "x" not in _serial.split("-")[1] else: mrz_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 mrz_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." if "MRZ" in telemetry["id"]: _id_msg += " Note: MRZ 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 only_scan list 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, MRZ)", ) 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?") # Update Global logging path, used by other modules. autorx.logging_path = logging_path # 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) # If a sonde type has been provided, insert an entry into the scan results, # and immediately start a decoder. This also sets the decoder time to 0, which # allows it to run indefinitely. if args.type != None: if args.type in VALID_SONDE_TYPES: logging.warning( "Overriding RX timeout for manually specified radiosonde type. Decoders will not automatically stop!" ) config["rx_timeout"] = 0 autorx.scan_results.put([[args.frequency * 1e6, args.type]]) else: logging.error("Unknown Radiosonde Type: %s. Exiting." % args.type) 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 only_scan list settings # to only include the supplied frequency. if args.frequency != 0.0: config["only_scan"] = [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"], mail_nearby_landing_subject=config["email_nearby_landing_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 - DEPRECATED - Sondehub DB now in use (>1.5.0) # 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"] == "") 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"], 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) # Sondehub v2 Database if config["sondehub_enabled"]: if config["habitat_upload_listener_position"] is False: _sondehub_station_position = None else: _sondehub_station_position = ( config["station_lat"], config["station_lon"], config["station_alt"], ) _sondehub = SondehubUploader( user_callsign=config["habitat_uploader_callsign"], user_position=_sondehub_station_position, user_antenna=config["habitat_uploader_antenna"], contact_email=config["sondehub_contact_email"], upload_rate=config["sondehub_upload_rate"], ) exporter_objects.append(_sondehub) exporter_functions.append(_sondehub.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, ) version_startup_check() # Note the start time. _start_time = time.time() # If we have been asked to start decoding a specific radiosonde type, we need to start up # the decoder immediately, before a scanner thread is started. if args.type != None: 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()