diff --git a/SondeHubUploader/SondeHubUploader.py b/SondeHubUploader/SondeHubUploader.py new file mode 100644 index 0000000..067c2bb --- /dev/null +++ b/SondeHubUploader/SondeHubUploader.py @@ -0,0 +1,78 @@ +# SondeHubUploader.py - SondeHubUploader class with init and close functions +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Modules +import logging +import threading +import queue + + +class SondeHubUploader: + + import SondeHubUploader.shuConfig as shuConfig + import SondeHubUploader.logger as logger + import SondeHubUploader.threads as threads + import SondeHubUploader.handleData as handleData + import SondeHubUploader.conversions as conversions + import SondeHubUploader.printData as printData + import SondeHubUploader.writeData as writeData + import SondeHubUploader.telemetryChecks as telemetryChecks + import SondeHubUploader.uploader as uploader + import SondeHubUploader.utils as utils + + # Init function + def __init__(self, args): + # Save the provided configuration parameters + self.__dict__.update(args) + + # Define a logger object + self.loggerObj = logging.getLogger('logger') + # Configure the logger + self.logger.configure_logger(self, self.loggerObj, self.loglevelp, self.loglevelw, self.writel) + + # Used to break out of while-loops when the SondeHubUploader is terminated + self.running = True + + # Queue for storing the received APRS packages after receiving and before parsing + self.aprs_queue = queue.Queue(self.qaprs) + # Queue for storing the telemetry packages after parsing and before uploading + self.upload_queue = queue.Queue(self.qupl) + + # Stores the last time the station was uploaded + self.last_station_upload = 0 + # Stores the last time the telemetry was uploaded + self.last_telemetry_upload = 0 + + # Create a thread for receiving the APRS packages + self.udp_receive_thread = threading.Thread(target=self.threads.udp_receive, args=(self,)) + self.udp_receive_thread.start() + self.loggerObj.debug('udp_receive thread started') + + # Create a thread for processing the received APRS packages + self.process_aprs_queue_thread = threading.Thread(target=self.threads.process_aprs_queue, args=(self,)) + self.process_aprs_queue_thread.start() + self.loggerObj.debug('process_aprs_queue thread started') + + # Create a thread for uploading the station + self.upload_station_thread = threading.Thread(target=self.threads.upload_station, args=(self,)) + self.upload_station_thread.start() + self.loggerObj.debug('upload_station thread started') + + # Create a thread for uploading the telemetry + self.process_upload_queue_thread = threading.Thread(target=self.threads.process_upload_queue, args=(self,)) + self.process_upload_queue_thread.start() + self.loggerObj.debug('process_upload_queue thread started') + + # Close function + def close(self): + # Setting running to 'False' will cause breaking out of the while-loops in the threads + self.running = False + # Join the threads + self.udp_receive_thread.join() + self.process_aprs_queue_thread.join() + self.upload_station_thread.join() + self.process_upload_queue_thread.join() diff --git a/SondeHubUploader/conversions.py b/SondeHubUploader/conversions.py new file mode 100644 index 0000000..6acc742 --- /dev/null +++ b/SondeHubUploader/conversions.py @@ -0,0 +1,126 @@ +# conversions.py - Functions for converting units +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +import datetime + + +# Convert an address to a readable hex string +def address_to_string(address): + addr_str = '0x' + for i in range(len(address)): + addr_str += hex(address[i])[2:] + return addr_str + + +# Convert a framenumber to an uptime in hms +def frame_to_hms(frame, framerate): + hms = str(datetime.timedelta(seconds=frame * framerate)).split(':') + return {'hour': hms[0], 'minute': hms[1], 'second': hms[2]} + + +# Convert an uptime in hms to a framenumber +def hms_to_frame(hour, minute, second, framerate): + return (hour * 3600 + minute * 60 + second) * framerate + + +# Convert a length in feet to meter +def feet_to_meter(feet, precision): + return round(feet * 0.3048, precision) + + +# Convert a length in meter to feet +def meter_to_feet(meters, precision): + return round(meters * 3.28084, precision) + + +# Convert a speed in knot to kph +def knot_to_kph(knots, precision): + return round(knots * 1.852, precision) + + +# Convert a speed in kph to knot +def kph_to_knot(kph, precision): + return round(kph * 0.539957, precision) + + +# Convert a speed in knot to m/s +def knot_to_ms(knots, precision): + return round(knots * 0.514444, precision) + + +# Convert a speed in m/s to knot +def ms_to_knot(ms, precision): + return round(ms * 1.94384, precision) + + +# Convert a speed in kph to m/s +def kph_to_ms(kph, precision): + return round(kph * 0.277778, precision) + + +# Convert a speed in m/s to kph +def ms_to_kph(ms, precision): + return round(ms * 3.6, precision) + + +# Convert coordinates in GMS to DG +def gms_to_dg(degree, minute, second, direction, precision): + dg = round(degree + (minute * 60 + second) / 3600, precision) + if direction in ['S', 'W']: + dg *= -1 + return dg + + +# Convert coordinates in DG to GMS +def dg_to_gms(dg, latlon, precision): + degree = int(dg) + minute = int((dg - degree) * 60) + second = round(round((dg - degree) * 3600) % 60, precision) + if dg > 0: + if latlon: + direction = 'N' + else: + direction = 'E' + else: + if latlon: + direction = 'S' + else: + direction = 'W' + return {'degree': degree, 'minute': minute, 'second': second, 'direction': direction} + + +# Convert coordinates in GMM to DG +def gmm_to_dg(degree, minute, direction, precision): + dg = round(degree + (minute * 60) / 3600, precision) + if direction in ['S', 'W']: + dg *= -1 + return dg + + +# Convert coordinates in DG to GMM +def dg_to_gmm(dg, latlon, precision): + degree = int(dg) + minute = round((dg - degree) * 60, precision) + if dg > 0: + if latlon: + direction = 'N' + else: + direction = 'E' + else: + if latlon: + direction = 'S' + else: + direction = 'W' + return {'degree': degree, 'minute': minute, 'direction': direction} + +# Convert coordinates in GMS to GMM +def gms_to_gmm(degree, minute, second, direction, precision): + return {'degree': degree, 'minute': minute + round(second / 60, precision), 'direction': direction} + +# Convert coordinates in GMM to GMS +def gmm_to_gms(degree, minute, direction, precision): + return {'degree': degree, 'minute': int(minute), 'second': round((minute - int(minute)) * 60, precision), 'direction': direction} \ No newline at end of file diff --git a/SondeHubUploader/handleData.py b/SondeHubUploader/handleData.py new file mode 100644 index 0000000..8542af4 --- /dev/null +++ b/SondeHubUploader/handleData.py @@ -0,0 +1,223 @@ +# handleData.py - Functions for data handling +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Modules +import datetime + + +# Parse an APRS package +def parse_aprs_package(self, aprs_package): + telemetry = {} + + # At first the telemetry parameters with fixed positions inside the APRS package are parsed + # Go through all possible telemetry parameters with fixed positions + for parameter, (index, parse_function) in self.shuConfig.parse_fixed_position.items(): + # The actual parsing is done using the parse function + try: + # Check whether the APRS package actually contains the indices for the telemetry parameters + if ((type(index)) == slice and len(aprs_package) >= index.stop) or ((type(index)) == int and len(aprs_package) >= index): + telemetry[parameter] = parse_function(aprs_package[index]) + self.loggerObj.debug_detail(f'Parameter "{parameter}" parsed ({telemetry[parameter]})') + else: + raise Exception + except Exception: + self.loggerObj.error(f'Error parsing parameter "{parameter}"') + + # From now on it is easier to work with the APRS package cast to a string + aprs_package_string = str(aprs_package) + + # Second, the optional telemetry parameters are parsed + # Go through all possible optional telemetry parameters + for parameter, (start_string, end_string, parse_function) in self.shuConfig.parse_optional.items(): + # The APRS package string is searched for the key of the optional parameter + start_index = self.utils.aprs_package_string_find_key(aprs_package_string, start_string) + # 'aprs_package_string_find_key' will return the first index of the value if the key was found + if start_index != -1: + # For parsing, the end index of the value needs to be found as well + end_index = aprs_package_string[start_index:].find(end_string) + start_index + # The actual parsing is again done using the parse function + try: + telemetry[parameter] = parse_function(aprs_package_string[start_index:end_index]) + self.loggerObj.debug_detail(f'Parameter "{parameter}" parsed ({telemetry[parameter]})') + except Exception: + self.loggerObj.error(f'Error parsing parameter "{parameter}"') + else: + self.loggerObj.debug_detail(f'Parameter "{parameter}" not found in APRS package') + + # Third, the optional multivalue telemetry parameters are parsed + # Go through all possible optional multivalue telemetry parameters + for parameter, (start_string, end_string, parse_function, subparameters, subparameter_parse_function) in self.shuConfig.parse_optional_multivalue.items(): + # Finding the start and end index of the value is not different compared to the optional telemetry parameters + start_index = self.utils.aprs_package_string_find_key(aprs_package_string, start_string) + if start_index != -1: + end_index = aprs_package_string[start_index:].find(end_string) + start_index + # The actual parsing is different compared to the optional telemetry parameters + # Optional multivalue telemetry parameters contain a list of subparameters + # The subparameters are parsed using the subparameter parse function + try: + subparameter_list = subparameter_parse_function(parse_function(aprs_package_string[start_index:end_index])) + for i in range(len(subparameters)): + telemetry[subparameters[i]] = subparameter_list[i] + self.loggerObj.debug_detail(f'Subparameter "{subparameters[i]}" parsed ({telemetry[subparameters[i]]})') + self.loggerObj.debug_detail(f'Parameter "{parameter}" parsed') + except Exception: + self.loggerObj.error(f'Error parsing parameter "{parameter}"') + else: + self.loggerObj.debug_detail(f'Parameter "{parameter}" not found in APRS package') + + # Finally, one last optional special telemetry parameter has to be parsed + # The frequency telemetry parameter does not have a prefix + # It only has a unit attached to it (MHz) + # In order to parse this, the unit is searched first instead of a prefix + end_index = aprs_package_string.find(list(self.shuConfig.parse_optional_special.values())[0][1]) + if end_index != -1: + # Then the beginning of the frequency telemetry parameter is searched + # This is done using a reverse search for the first space character, starting at the unit of the frequency telemetry parameter + start_index = aprs_package_string[:end_index].rfind(list(self.shuConfig.parse_optional_special.values())[0][0]) + 1 + # The actual parsing is again done using the parse function + try: + telemetry[list(self.shuConfig.parse_optional_special.keys())[0]] = list(self.shuConfig.parse_optional_special.values())[0][2](aprs_package_string[start_index:end_index]) + self.loggerObj.debug_detail(f'Parameter "{list(self.shuConfig.parse_optional_special.keys())[0]}" parsed ({telemetry[list(self.shuConfig.parse_optional_special.keys())[0]]})') + except Exception: + self.loggerObj.error(f'Error parsing parameter "{list(self.shuConfig.parse_optional_special.keys())[0]}"') + return telemetry + + +# Parse the minute string of gmm coordinates of an APRS package +def parse_gmm_minute(minute_string): + # The minute string of gmm coordinates inside an APRS package has a fixed length + # But some digits might be replaced with spaces, if only limited precision is available + # This must be considered + # If all digits are replaced with spaces, no minute is available + if minute_string == ' . ': + return 0 + # If all digits but the first one are replaced with spaces, only 10 minute precision is available + elif minute_string[0].isdigit() and minute_string[1:] == ' . ': + return int(minute_string[0]) * 10 + # In all other cases, the minute can be cast to float + else: + return float(minute_string) + + +# Parse a timer string of an APRS package +def parse_timer(timer_string): + # If hour or minute or second might be non-existent in the timer string + # In that case, these values are considered to be zero + hour, minute, second = 0, 0, 0 + # hour, minute and second are separated by the characters 'h', 'm' and 's' + hour_index = timer_string.find('h') + if hour_index != -1: + hour = int(timer_string[:hour_index]) + minute_index = timer_string.find('m') + if minute_index != -1: + minute = int(timer_string[hour_index+1:minute_index]) + second_index = timer_string.find('s') + if second_index != -1: + second = int(timer_string[minute_index+1:second_index]) + return [hour, minute, second] + +# Parse the rx string of an APRS package +def parse_rx(rx_string): + # This example shows the structure of the rx string + # rx=403900(+2/5) + rx_f_end_index = rx_string.find('(') + rx_afc_end_index = rx_string.find('/') + rx_afc_max_end_index = rx_string.find(')') + rx_f = int(rx_string[:rx_f_end_index]) + rx_afc = int(rx_string[rx_f_end_index + 1:rx_afc_end_index]) + rx_afc_max = int(rx_string[rx_afc_end_index + 1:rx_afc_max_end_index]) + return [rx_f, rx_afc, rx_afc_max] + +# Parse the pump string of an APRS package +def parse_pump(pump_string): + # This example shows the structure of the pump string + # Pump=99mA 15.0V + pump_ma_end_index = pump_string.find('mA') + pump_v_end_index = pump_string.find('V') + pump_ma = int(pump_string[:pump_ma_end_index]) + pump_v = float(pump_string[pump_ma_end_index + 3:pump_v_end_index]) + return [pump_ma, pump_v] + + +# Reformat telemetry to the SondeHub telemetry format +# Source: https://github.com/projecthorus/sondehub-infra/wiki/SondeHub-Telemetry-Format +def reformat_telemetry(self, telemetry): + # Create a dictionary for the SondeHub telemetry + # At first, some data that is only station- and software-specific is added + reformatted_telemetry = { + 'software_name': self.shuConfig.software_name, + 'software_version': self.shuConfig.software_version, + 'uploader_callsign': self.call, + 'uploader_position': [round(self.pos[0], 5), round(self.pos[1], 5), round(self.pos[2], 1)], + 'uploader_antenna': self.ant, + 'time_received': datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + } + + # Second, mandatory radiosonde-specific telemetry parameters are added + # Go through all possible radiosonde types + for name, (manufacturer, _type, subtypes, serial, fn, altitude_precision, ref_datetime, ref_position) in self.shuConfig.radiosonde.items(): + # The radiosonde type is compared + if telemetry['type'].startswith(name): + # manufacturer and type can be transferred directly + reformatted_telemetry['manufacturer'] = manufacturer + reformatted_telemetry['type'] = _type + # A subtype might be present for some radiosondes + if subtypes is not None and telemetry['type'] in subtypes: + reformatted_telemetry['subtype'] = telemetry['type'] + # For IMET radiosondes a unique serial must be calculated (based on the convention of SondeHub) + if serial == 'IMET': + reformatted_telemetry['serial'] = self.utils.imet_unique_serial(self, telemetry['hour'], telemetry['minute'], telemetry['second'], telemetry['fn'], telemetry['f']).split('-')[1] + + # For all other radiosondes, the serial can be taken directly from the telemetry + else: + reformatted_telemetry['serial'] = telemetry[serial[0]][serial[1]:] + # For M10 radiosondes, the serial provided by dxlAPRS is missing some dashes + if reformatted_telemetry['type'] == 'M10': + reformatted_telemetry['serial'] = reformatted_telemetry['serial'][0:3] + '-' + reformatted_telemetry['serial'][3] + '-' + reformatted_telemetry['serial'][4:] + # The datetime is composed of the time provided by dxlAPRS and a date that is added manually + reformatted_telemetry['datetime'] = self.utils.fix_datetime(self, telemetry['hour'], telemetry['minute'], telemetry['second'], True if ref_datetime == 'GPS' else False).strftime('%Y-%m-%dT%H:%M:%S.%fZ') + # For most radiosondes, the framenumber can be taken directly from the telemetry + if fn == 'fn': + reformatted_telemetry['frame'] = telemetry['fn'] + # But some radiosondes do not transmit a framenumber + # In this case the GPS seconds (Seconds since 01/06/1980) are used as the framenumber + else: + reformatted_telemetry['frame'] = int((datetime.datetime.strptime(reformatted_telemetry['datetime'], '%Y-%m-%dT%H:%M:%S.%fZ')-datetime.datetime(1980, 1, 6, 0, 0, 0)).total_seconds()) + # For some radiosondes, leap seconds must also be included + if fn == 'gpsleap': + reformatted_telemetry['frame'] += self.shuConfig.leap_seconds + # The altitude must be in meters, but can otherwise be taken directly from the telemetry + reformatted_telemetry['alt'] = float(self.conversions.feet_to_meter(telemetry['altitude'], altitude_precision)) + # ref_datetime and ref_position can be transferred directly + reformatted_telemetry['ref_datetime'] = ref_datetime + reformatted_telemetry['ref_position'] = ref_position + # Break out of the for-loop after the first match of the radiosonde type + break + + # At this point all mandatory radiosonde-specific telemetry parameters were added + # latitude and longitude are mandatory as well, but not radiosonde specific + # latitude and longitude are in decimal degree + # Additional precision is provided using the APRS precision and datum option + reformatted_telemetry['lat'] = self.conversions.gmm_to_dg(telemetry['latitude_degree'], + self.utils.minute_add_precision(self, telemetry['latitude_minute'], telemetry['dao_D'], telemetry['dao_A']), + telemetry['latitude_ns'], + self.shuConfig.reformat_position_precision + ) + reformatted_telemetry['lon'] = self.conversions.gmm_to_dg(telemetry['longitude_degree'], + self.utils.minute_add_precision(self, telemetry['longitude_minute'], telemetry['dao_D'], telemetry['dao_O']), + telemetry['longitude_we'], + self.shuConfig.reformat_position_precision + ) + + # Finally, all optional telemetry parameters are added + # Go through all telemetry parameters + for key, value in telemetry.items(): + # Check for optional telemetry parameters + if self.shuConfig.telemetry[key][2]: + # Reformat the optional telemetry parameters using the reformat function + reformatted_telemetry[self.shuConfig.telemetry[key][3]] = self.shuConfig.telemetry[key][4](value) + return reformatted_telemetry diff --git a/SondeHubUploader/logger.py b/SondeHubUploader/logger.py new file mode 100644 index 0000000..befb848 --- /dev/null +++ b/SondeHubUploader/logger.py @@ -0,0 +1,46 @@ +# logging.py - Functions for logger configuration +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Modules +import logging + + +# Configure logger +def configure_logger(self, logger, loglevelp, loglevelw, savel): + # Add a custom logging level for detailed debugging + add_logging_level('DEBUG_DETAIL', self.shuConfig.loglevel[5]) + + # A 'StreamHandler' iss added to the logger + c_format = logging.Formatter('[%(asctime)s - %(levelname)s] %(message)s') + c_handler = logging.StreamHandler() + c_handler.setFormatter(c_format) + c_handler.setLevel(self.shuConfig.loglevel[loglevelp]) + logger.addHandler(c_handler) + # A optional 'FileHandler' might be added to the logger + if savel: + f_format = logging.Formatter('[%(asctime)s - %(levelname)s] %(message)s') + f_handler = logging.FileHandler(self.filepath + '/' + 'log.log') + f_handler.setFormatter(f_format) + f_handler.setLevel(self.shuConfig.loglevel[loglevelw]) + logger.addHandler(f_handler) + # A level must also be set for the logger itself, not only for the handlers + logger.setLevel(self.shuConfig.loglevel[5]) + + +# Add a custom logging level +def add_logging_level(level_name, level_number): + def log_for_level(self, message, *args, **kwargs): + if self.isEnabledFor(level_number): + self._log(level_number, message, args, **kwargs) + + def log_to_root(message, *args, **kwargs): + logging.log(level_number, message, *args, **kwargs) + + logging.addLevelName(level_number, level_name) + setattr(logging, level_name, level_number) + setattr(logging.getLoggerClass(), level_name.lower(), log_for_level) + setattr(logging, level_name.lower(), log_to_root) diff --git a/SondeHubUploader/printData.py b/SondeHubUploader/printData.py new file mode 100644 index 0000000..7437649 --- /dev/null +++ b/SondeHubUploader/printData.py @@ -0,0 +1,44 @@ +# printData.py - Functions for printing data +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Print raw data +def print_raw_data(raw_data): + print('Raw data:') + print(raw_data) + + +# Print telemetry +def print_telemetry(self, telemetry): + print('Telemetry:') + # The telemetry parameter names are printed at a fixed length + # This fixed length is based on the length of the longest telemetry parameter name + # This results in a nicely formatted and easily readable output + parameter_string = '{:<' + str(len(max(self.shuConfig.print_write_telemetry.keys(), key=len)) + 1) + '} {} {}' + # Go through all possible telemetry parameters + for name, (parameter, unit, conversion_function) in self.shuConfig.print_write_telemetry.items(): + # Print all telemetry parameters that are included in 'telemetry' + if all(item in telemetry.keys() for item in parameter): + # Some printed parameters are composed or calculated from several telemetry parameters + # These telemetry parameters must be added to a list in order to be passed to the conversion function + parameter_list = [] + for element in parameter: + parameter_list.append(telemetry[element]) + # Print the telemetry parameters with their name, value and unit (optional) + print(parameter_string.format(name + ':', conversion_function(*parameter_list), '' if unit is None else unit)) + + +# Print reformatted telemetry +def print_reformatted_telemetry(self, reformatted_telemetry): + print('Reformatted telemetry:') + # The reformatted telemetry parameter names are printed at a fixed length + # This fixed length is based on the length of the longest reformatted telemetry parameter name + # This results in a nicely formatted and easily readable output + parameter_string = '{:<' + str(len(max(reformatted_telemetry.keys(), key=len)) + 1) + '} {} {}' + # Go through all reformatted telemetry parameters existing in 'reformatted_telemetry' + for name, unit in reformatted_telemetry.items(): + # Print the reformatted telemetry parameters with their name, value and unit (optional) + print(parameter_string.format(name + ':', unit, '' if self.shuConfig.write_reformatted[name] is None else self.shuConfig.write_reformatted[name])) diff --git a/SondeHubUploader/shuConfig.py b/SondeHubUploader/shuConfig.py new file mode 100644 index 0000000..249c649 --- /dev/null +++ b/SondeHubUploader/shuConfig.py @@ -0,0 +1,259 @@ +# shuConfig.py - SondeHubUploader configuration parameters +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Third-party modules +import logging +# Own modules +import SondeHubUploader.conversions as conversions +import SondeHubUploader.handleData as handleData +import SondeHubUploader.utils as utils + + +# Logger definitions +loglevel = { + 1: logging.ERROR, + 2: logging.WARNING, + 3: logging.INFO, + 4: logging.DEBUG, + 5: logging.DEBUG - 1 +} + +# URL definitions +sondehub_telemetry_url = 'https://api.v2.sondehub.org/sondes/telemetry' +sondehub_station_url = 'https://api.v2.sondehub.org/listeners' + + +# Software definitions +software_name = 'dxlAPRS-SHUE' +software_version = '1.0.0' + +# Status code definitions +status_code_ok = 200 +status_code_sondehub_error_1 = 201 +status_code_sondehub_error_2 = 202 +status_code_server_error = 500 + +# Other definitions +udp_buffersize = 1024 +thread_sleep = 1 +filename_raw_data = 'rawdata' +filename_prefix_telemetry = 't_' +filename_prefix_reformatted_telemetry = 'r_' +leap_seconds = 18 +reformat_position_precision = 5 + + +# Parser definitions +# Fixed position parameter definitions +# Values: range, parse function +parse_fixed_position = { + 'destination_address': [slice(0, 7), lambda a: bytes(a)], + 'source_address': [slice(7, 14), lambda a: bytes(a)], + 'control': [14, lambda a: hex(a)], + 'protocol_id': [15, lambda a: hex(a)], + 'data_type': [16, lambda a: chr(a)], + 'serial': [slice(17, 26), lambda a: a.decode('utf-8').split(' ', 1)[0]], + 'hour': [slice(27, 29), lambda a: int(a)], + 'minute': [slice(29, 31), lambda a: int(a)], + 'second': [slice(31, 33), lambda a: int(a)], + 'time_format': [33, lambda a: chr(a)], + 'latitude_degree': [slice(34, 36), lambda a: int(a)], + 'latitude_minute': [slice(36, 41), lambda a: handleData.parse_gmm_minute(a.decode('utf-8'))], + 'latitude_ns': [41, lambda a: chr(a)], + 'longitude_degree': [slice(43, 46), lambda a: int(a)], + 'longitude_minute': [slice(46, 51), lambda a: handleData.parse_gmm_minute(a.decode('utf-8'))], + 'longitude_we': [51, lambda a: chr(a)], + 'course': [slice(53, 56), lambda a: int(a)], + 'speed': [slice(57, 60), lambda a: int(a)], + 'altitude': [slice(63, 69), lambda a: int(a)], + 'dao_D': [70, lambda a: a], + 'dao_A': [71, lambda a: a], + 'dao_O': [72, lambda a: a] +} +# Optional parameter definitions +# Values: prefix, unit/end, parse function +parse_optional = { + 'clb': ['Clb=', 'm/s', lambda a: float(a)], + 'p': ['p=', 'hPa', lambda a: float(a)], + 't': ['t=', 'C', lambda a: float(a)], + 'h': ['h=', '%', lambda a: float(a)], + 'batt': ['batt=', 'V', lambda a: float(a)], + 'calibration': ['calibration', '%', lambda a: int(a)], + 'fp': ['fp=', 'hPa', lambda a: float(a)], + 'og': ['OG=', 'm', lambda a: int(a)], + 'rssi': ['rssi=', 'dB', lambda a: float(a)], + 'tx': ['tx=', 'dBm', lambda a: int(a)], + 'hdil': ['hdil=', 'm', lambda a: float(a)], + 'o3': ['o3=', 'mPa', lambda a: float(a)], + 'type': ['Type=', ' ', lambda a: a], + 'sats': ['Sats=', ' ', lambda a: int(a)], + 'fn': ['FN=', ' ', lambda a: int(a)], + 'azimuth': ['azimuth=', ' ', lambda a: int(a)], + 'elevation': ['elevation=', ' ', lambda a: float(a)], + 'dist': ['dist=', ' ', lambda a: float(a)], + 'dev': ['dev=', ' ', lambda a: a], + 'ser': ['ser=', ' ', lambda a: a] +} +# Optional multivalue parameter definitions +# Values: prefix, unit/end, parse function, subparameter, subparameter parse function +parse_optional_multivalue = { + 'tx_past_burst': ['TxPastBurst=', ' ', lambda a: a, ['tx_past_burst_hour', 'tx_past_burst_minute', 'tx_past_burst_second'], lambda a: handleData.parse_timer(a)], + 'powerup': ['powerup=', ' ', lambda a: a, ['powerup_hour', 'powerup_minute', 'powerup_second'], lambda a: handleData.parse_timer(a)], + 'rx': ['rx=', ' ', lambda a: a, ['rx_f', 'rx_afc', 'rx_afc_max'], lambda a: handleData.parse_rx(a)], + 'pump': ['Pump=', 'V', lambda a: a, ['pump_ma', 'pump_v'], lambda a: handleData.parse_pump(a)], +} +# Optional special parameter definitions +# Values: prefix, unit/end, parse function +parse_optional_special = { + 'f': [' ', 'MHz', lambda a: float(a)] +} + +# Telemetry definitions +# Values: check function, mandatory, optional, optional name, reformat function +telemetry = { + 'destination_address': [None, False, False, None, None], + 'source_address': [None, False, False, None, None], + 'control': [lambda a: True if a == hex(0x3) else False, False, False, None, None], + 'protocol_id': [lambda a: True if a == hex(0xF0) else False, False, False, None, None], + 'data_type': [lambda a: True if a == ';' else False, False, False, None, None], + 'serial': [None, ['RS41', 'RS92', 'DFM'], False, None, None], + 'hour': [lambda a: True if a <= 23 else False, True, False, None, None], + 'minute': [lambda a: True if a <= 59 else False, True, False, None, None], + 'second': [lambda a: True if a <= 59 else False, True, False, None, None], + 'time_format': [lambda a: True if a in ['z', '/', 'h'] else False, False, False, None, None], + 'latitude_degree': [lambda a: True if a <= 180 else False, True, False, None, None], + 'latitude_minute': [lambda a: True if a < 60 else False, True, False, None, None], + 'latitude_ns': [lambda a: True if a in ['N', 'S'] else False, True, False, None, None], + 'longitude_degree': [lambda a: True if a <= 180 else False, True, False, None, None], + 'longitude_minute': [lambda a: True if a < 60 else False, True, False, None, None], + 'longitude_we': [lambda a: True if a in ['W', 'E'] else False, True, False, None, None], + 'course': [lambda a: True if a < 360 else False, False, True, 'heading', lambda a: float(a)], + 'speed': [lambda a: True if conversions.knot_to_kph(a, 0) < 1000 else False, False, True, 'vel_h', lambda a: conversions.knot_to_ms(a, 1)], + 'altitude': [lambda a: True if conversions.feet_to_meter(a, 0) < 50000 else False, True, False, None, None], + 'dao_D': [lambda a: True if a in [ord(' '), ord('w'), ord('W')] else False, True, False, None, None], + 'dao_A': [lambda a: True if 33 <= a <= 123 else False, True, False, None, None], + 'dao_O': [lambda a: True if 33 <= a <= 123 else False, True, False, None, None], + 'clb': [lambda a: True if -100 <= a <= 100 else False, False, True, 'vel_v', lambda a: float(a)], + 'p': [lambda a: True if 0 <= a <= 2000 else False, False, True, 'pressure', lambda a: float(a)], + 't': [lambda a: True if -100 <= a <= 100 else False, False, True, 'temp', lambda a: float(a)], + 'h': [lambda a: True if 0 <= a <= 100 else False, False, True, 'humidity', lambda a: float(a)], + 'batt': [lambda a: True if 0 <= a <= 20 else False, False, True, 'batt', lambda a: float(a)], + 'calibration': [lambda a: True if 0 <= a <= 100 else False, False, False, None, None], + 'fp': [lambda a: True if 0 <= a <= 2000 else False, False, False, None, None], + 'og': [lambda a: True if 0 <= a <= 50000 else False, False, False, None, None], + 'rssi': [lambda a: True if 0 <= a <= 200 else False, False, False, 'rssi', lambda a: float(a)], + 'tx': [lambda a: True if 0 <= a <= 200 else False, False, False, None, None], + 'hdil': [lambda a: True if 0 <= a <= 100 else False, False, False, None, None], + 'o3': [lambda a: True if 0 <= a <= 100 else False, False, False, None, None], + 'type': [lambda a: True if a.startswith(tuple(radiosonde.keys())) else False, True, False, None, None], + 'sats': [lambda a: True if 0 <= a <= 30 else False, False, True, 'sats', lambda a: a], + 'fn': [lambda a: True if 0 <= a <= 86400 else False, ['RS41', 'RS92', 'iMET', 'MEISEI'], False, None, None], + 'azimuth': [lambda a: True if 0 <= a < 360 else False, False, False, None, None], + 'elevation': [lambda a: True if 0 <= a <= 90 else False, False, False, None, None], + 'dist': [lambda a: True if 0 <= a <= 1500 else False, False, False, None, None], + 'dev': [None, False, False, None, None], + 'ser': [None, ['M10', 'M20', 'MRZ', 'MEISEI'], False, None, None], + 'tx_past_burst_hour': [lambda a: True if a <= 23 else False, False, False, None, None], + 'tx_past_burst_minute': [lambda a: True if a <= 59 else False, False, False, None, None], + 'tx_past_burst_second': [lambda a: True if a <= 59 else False, False, False, None, None], + 'powerup_hour': [lambda a: True if a <= 23 else False, False, False, None, None], + 'powerup_minute': [lambda a: True if a <= 59 else False, False, False, None, None], + 'powerup_second': [lambda a: True if a <= 59 else False, False, False, None, None], + 'rx_f': [lambda a: True if 400000 <= a <= 406000 else False, False, True, 'frequency', lambda a: float(a / 1000)], + 'rx_afc': [None, False, False, None, None], + 'rx_afc_max': [None, False, False, None, None], + 'pump_ma': [lambda a: True if 0 <= a <= 10000 else False, False, False, None, None], + 'pump_v': [lambda a: True if 0 <= a <= 100 else False, False, False, None, None], + 'f': [lambda a: True if 400 <= a <= 406 else False, ['iMET'], True, 'tx_frequency', lambda a: float(a)] +} + +# Print/Write telemetry definitions +# Values: parameter, unit, conversion function +print_write_telemetry = { + 'Serial': [['serial'], None, lambda a: a], + 'Time': [['hour', 'minute', 'second'], None, lambda a, b, c: '{:02d}:{:02d}:{:02d}'.format(a, b, c)], + 'Latitude': [['latitude_degree', 'latitude_minute', 'latitude_ns', 'dao_D', 'dao_A'], '°', lambda a, b, c, d, e: conversions.gmm_to_dg(a, utils.minute_add_precision(None, b, d, e), c, 5)], + 'Longitude': [['longitude_degree', 'longitude_minute', 'longitude_we', 'dao_D', 'dao_O'], '°', lambda a, b, c, d, e: conversions.gmm_to_dg(a, utils.minute_add_precision(None, b, d, e), c, 5)], + 'Course': [['course'], '°', lambda a: a], + 'Speed': [['speed'], 'kph', lambda a: conversions.knot_to_kph(a, 2)], + 'Altitude': [['altitude'], 'm', lambda a: conversions.feet_to_meter(a, 2)], + 'Climb': [['clb'], 'm/s', lambda a: a], + 'Pressure': [['p'], 'hPa', lambda a: a], + 'Temperature': [['t'], '°C', lambda a: a], + 'Humidity': [['h'], '%', lambda a: a], + 'Frequency': [['f'], 'MHz', lambda a: a], + 'Type': [['type'], None, lambda a: a], + 'TxPastBurst': [['tx_past_burst_hour', 'tx_past_burst_minute', 'tx_past_burst_second'], None, lambda a, b, c: '{:02d}:{:02d}:{:02d}'.format(a, b, c)], + 'Battery': [['batt'], 'V', lambda a: a], + 'PowerUp': [['powerup_hour', 'powerup_minute', 'powerup_second'], None, lambda a, b, c: '{:02d}:{:02d}:{:02d}'.format(a, b, c)], + 'Calibration': [['calibration'], '%', lambda a: a], + 'Satellites': [['sats'], None, lambda a: a], + 'Fakehp': [['fp'], 'hPa', lambda a: a], + 'Framenumber': [['fn'], None, lambda a: a], + 'OverGround': [['og'], 'm', lambda a: a], + 'RSSI': [['rssi'], 'dB', lambda a: a], + 'TxPower': [['tx'], 'dBm', lambda a: a], + 'GPSNoise': [['hdil'], 'm', lambda a: a], + 'Azimuth': [['azimuth'], '°', lambda a: a], + 'Elevation': [['elevation'], '°', lambda a: a], + 'Distance:': [['dist'], 'km', lambda a: a], + 'Device': [['dev'], None, lambda a: a], + 'Serial2': [['ser'], None, lambda a: a], + 'RxSetting': [['rx_f', 'rx_afc', 'rx_afc_max'], None, lambda a, b, c: f'{a} kHz ({b}/{c})'], + 'o3': [['o3'], 'mPa', lambda a: a], + 'PumpVoltage': [['pump_v'], 'V', lambda a: a], + 'PumpCurrent': [['pump_ma'], 'mA', lambda a: a] +} + +# Write reformatted telemetry definitions +# Values: unit +write_reformatted_telemetry = { + 'software_name': None, + 'software_version': None, + 'uploader_callsign': None, + 'uploader_position': None, + 'uploader_antenna': None, + 'time_received': None, + 'manufacturer': None, + 'type': None, + 'subtype': None, + 'serial': None, + 'datetime': None, + 'frame': None, + 'lat': '°', + 'lon': '°', + 'alt': 'm', + 'temp': '°C', + 'pressure': 'hPa', + 'humidity': '%', + 'vel_v': 'm/s', + 'vel_h': 'm/s', + 'heading': '°', + 'sats': None, + 'batt': 'V', + 'burst_timer': 's', + 'xdata': None, + 'frequency': 'MHz', + 'tx_frequency': 'MHz', + 'snr': 'dB', + 'rssi': 'dBm', + 'ref_datetime': None, + 'ref_position': None +} + +# Radiosonde definitions +# Values: manufacturer, type, subtype, serial, framenumber, altitude precision, ref_datetime, ref_position +radiosonde = { + 'RS41': ['Vaisala', 'RS41', ['RS41-SG', 'RS41-SGP', 'RS41-SGM'], ['serial', 0], 'fn', 5, 'GPS', 'GPS'], + 'RS92': ['Vaisala', 'RS92', None, ['serial', 0], 'fn', 5, 'GPS', 'GPS'], + 'DFM': ['Graw', 'DFM', ['DFM06', 'DFM09', 'DFM09P', 'DFM17'], ['serial', 1], 'gps', 2, 'UTC', 'GPS'], + 'iMET': ['Intermet Systems', 'iMet-4', None, 'IMET', 'fn', 0, 'GPS', 'MSL'], + #'M10': ['Meteomodem', 'M10', None, ['ser', 0], 'gpsleap', 2, 'UTC', 'GPS'], + #'M20': ['Meteomodem', 'M20', None, ['ser', 0], 'gps', 2, 'GPS', 'GPS'], + 'MRZ': ['Meteo-Radiy', 'MRZ', None, ['ser', 0], 'gps', 5, 'UTC', 'GPS'], + 'MEISEI': ['Meisei', 'IMS100', None, ['ser', 7], 'fn', 1, 'UTC', 'GPS'] +} diff --git a/SondeHubUploader/telemetryChecks.py b/SondeHubUploader/telemetryChecks.py new file mode 100644 index 0000000..f64c940 --- /dev/null +++ b/SondeHubUploader/telemetryChecks.py @@ -0,0 +1,62 @@ +# telemetryChecks.py - Functions for checking the telemetry +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Check whether telemetry is plausible +def check_plausibility(self, telemetry): + # Go through all telemetry parameters + # 'telemetry' is cast to a list, because the values might be modified during the loop + for key, value in list(telemetry.items()): + # Check all telemetry parameters that have a check function assigned to them + if self.shuConfig.telemetry[key][0] is not None: + # Check the telemetry parameters using the check function + if self.shuConfig.telemetry[key][0](value): + self.loggerObj.debug_detail(f'Parameter "{key}" plausible') + else: + # Telemetry parameters that are not plausible are removed from 'telemetry' + telemetry.pop(key) + self.loggerObj.warning(f'Parameter "{key}" not plausible') + else: + self.loggerObj.debug_detail(f'Parameter "{key}" has no plausibility check') + return telemetry + + +# Check whether telemetry contains all mandatory parameters for SondeHub +def check_mandatory(self, telemetry): + # The check result is initially 'True' and might be set to 'False' by the checks + result = True + # At first, all the telemetry parameters that are mandatory for all radiosondes are checked + # Go through all possible telemetry parameters + for parameter, (check_function, mandatory, optional, optional_name, reformat_function) in self.shuConfig.telemetry.items(): + # 'mandatory' is set to true, if the telemetry parameter is mandatory for all radiosondes + if mandatory == True: + # Check whether the parameter exists in 'telemetry' + if parameter in telemetry: + self.loggerObj.debug_detail(f'Mandatory parameter "{parameter}" exists') + else: + result = False + self.loggerObj.debug_detail(f'Mandatory parameter "{parameter}" is missing') + # The radiosonde type needs to be determined in order to check the mandatory telemetry parameters for specific radiosondes + _type = None + # Go through all possible radiosonde types and compare the type + for key in self.shuConfig.radiosonde.keys(): + if 'type' in telemetry and telemetry['type'].startswith(key): + _type = key + if _type is not None: + # Go through all possible telemetry parameters (again) + for parameter, (check_function, mandatory, optional, optional_name, reformat_function) in self.shuConfig.telemetry.items(): + # 'mandatory' contains a list of radiosonde types, if the telemetry parameter is mandatory for specific radiosondes + if type(mandatory) == list and _type in mandatory: + # Check whether the parameter exists in 'telemetry' + if parameter in telemetry: + self.loggerObj.debug_detail(f'Mandatory parameter "{parameter}" exists') + else: + result = False + self.loggerObj.error(f'Mandatory parameter "{parameter}" is missing') + else: + result = False + self.loggerObj.error('Radiosonde type unknown') + return result diff --git a/SondeHubUploader/threads.py b/SondeHubUploader/threads.py new file mode 100644 index 0000000..6b6a44a --- /dev/null +++ b/SondeHubUploader/threads.py @@ -0,0 +1,111 @@ +# threads.py - Threads of the SondeHubUploader +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Modules +import socket +import queue +import time + + +# Receive APRS packages +def udp_receive(self): + # Create a socket + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind((self.addr, self.port)) + + while self.running: + # Try to receive an APRS package + try: + data, addr = sock.recvfrom(self.shuConfig.udp_buffersize) + self.loggerObj.debug('APRS package received') + # Store the APRS package to the aprs queue + try: + self.aprs_queue.put(data, False) + self.loggerObj.debug('APRS package put in queue') + except queue.Full: + self.loggerObj.warning('APRS package queue full') + # Nothing received + except socket.error: + pass + + +# Process the received APRS packages +def process_aprs_queue(self): + while self.running: + # Get packages, if there are any in the aprs queue + if not self.aprs_queue.empty(): + aprs_package = self.aprs_queue.get(False) + self.loggerObj.debug('APRS package taken from queue') + # Optionally write the raw data + if self.writeo: + self.writeData.write_raw_data(self, aprs_package) + # Parse the APRS package + telemetry = self.handleData.parse_aprs_package(self, aprs_package) + self.loggerObj.debug('APRS package parsed (Serial: %s)', telemetry['serial'] if 'serial' in telemetry else 'N/A') + self.loggerObj.info('Telemetry received (Serial: %s)', telemetry['serial'] if 'serial' in telemetry else 'N/A') + # Check whether the telemetry is plausible + telemetry = self.telemetryChecks.check_plausibility(self, telemetry) + self.loggerObj.debug('Plausibility checks performed (Serial: %s)', telemetry['serial'] if 'serial' in telemetry else 'N/A') + # Optionally write the telemetry + if self.writet: + if 'serial' in telemetry: + self.writeData.write_telemetry(self, telemetry) + else: + self.loggerObj.error('Could not write telemetry (serial missing)') + # Check whether the mandatory telemetry for SondeHub is included + if self.telemetryChecks.check_mandatory(self, telemetry): + self.loggerObj.debug('Mandatory data check successful (Serial: %s)', telemetry['serial']) + # Reformat the telemetry to the SondeHub telemetry format + reformatted_telemetry = self.handleData.reformat_telemetry(self, telemetry) + self.loggerObj.debug('Telemetry reformatted (Serial: %s)', reformatted_telemetry['serial']) + # Optionally write the reformatted telemetry + if self.writer: + self.writeData.write_reformatted_telemetry(self, reformatted_telemetry) + # Store the reformatted telemetry to the upload queue + try: + self.upload_queue.put(reformatted_telemetry, False) + self.loggerObj.debug('Reformatted telemetry put in queue (Serial: %s)', reformatted_telemetry['serial']) + except queue.Full: + self.loggerObj.warning('Upload queue full') + else: + self.loggerObj.error('Mandatory data check failed (Serial: %s)', telemetry['serial'] if 'serial' in telemetry else 'N/A') + + +# Upload the telemetry packages +def process_upload_queue(self): + while self.running: + # Check whether it is time for uploading, based on the configured update rate and the last upload time + if (time.time() - self.last_telemetry_upload) > self.telemu: + self.loggerObj.debug('Telemetry upload') + # Create an empty list that will hold the telemetry packages + to_upload = [] + # Get all packages that are currently stored in the upload queue and append them to the previously created list + while not self.upload_queue.empty(): + to_upload.append(self.upload_queue.get(False)) + # Upload the packages (if there are any) + if len(to_upload) > 0: + self.uploader.upload_telemetry(self, to_upload) + else: + self.loggerObj.debug('No telemetry for uploading') + # Save the upload time in order to determine when it is time for the next upload + self.last_telemetry_upload = time.time() + # This task is performed every second + time.sleep(self.shuConfig.thread_sleep) + + +# Upload the station +def upload_station(self): + while self.running: + # Check whether it is time for uploading, based on the configured update rate and the last upload time + if (time.time() - self.last_station_upload) > (self.posu * 3600): + self.loggerObj.debug('Station upload') + self.uploader.upload_station(self) + # Save the upload time in order to determine when it is time for the next upload + self.last_station_upload = time.time() + # This task is performed every second + time.sleep(self.shuConfig.thread_sleep) diff --git a/SondeHubUploader/uploader.py b/SondeHubUploader/uploader.py new file mode 100644 index 0000000..d013949 --- /dev/null +++ b/SondeHubUploader/uploader.py @@ -0,0 +1,126 @@ +# uploader.py - Functions for uploading to SondeHub +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +import time +import requests +import json +import gzip +from email.utils import formatdate + + +# Upload station to SondeHub +def upload_station(self): + # Create a dictionary that holds all the station data + position = { + 'software_name': self.shuConfig.software_name, + 'software_version': self.shuConfig.software_version, + 'uploader_callsign': self.call, + 'uploader_position': tuple(self.pos), + 'uploader_antenna': self.ant, + 'uploader_contact_email': self.mail, + # This is only for stationary receivers, therefore mobile is hardcoded to 'False' + 'mobile': False + } + + retries = 0 + upload_success = False + start_time = time.time() + # Retry a few times if the upload failed + while retries < self.retry: + # Try uploading + try: + headers = { + 'User-Agent': self.shuConfig.software_name + '-' + self.shuConfig.software_version, + 'Content-Type': 'application/json', + 'Date': formatdate(timeval=None, localtime=False, usegmt=True) + } + req = requests.put( + self.shuConfig.sondehub_station_url, + json=position, + timeout=self.timeout, + headers=headers + ) + except Exception: + self.loggerObj.error('Station upload failed') + return + + # Status code 200 means that everything was ok + if req.status_code == self.shuConfig.status_code_ok: + upload_time = time.time() - start_time + upload_success = True + self.loggerObj.info('Station upload successful (Duration: %.2f ms)', upload_time * 1000) + break + # All other status codes indicate some kind of error + elif req.status_code == self.shuConfig.status_code_server_error: + retries += 1 + self.loggerObj.error('Station upload server error, possbily retry') + continue + else: + self.loggerObj.error('Station upload error') + break + + if not upload_success: + self.loggerObj.error('Station upload failed after %d retries', retries) + + +# Upload telemetry to SondeHub +def upload_telemetry(self, reformatted_telemetry): + # Compress the telemetry + try: + start_time = time.time() + json_telemetry = json.dumps(reformatted_telemetry).encode('utf-8') + compressed_payload = gzip.compress(json_telemetry) + except Exception: + self.loggerObj.error('Error serialising and compressing telemetry list') + return + compression_time = time.time() - start_time + self.loggerObj.debug('Compressed %d bytes to %d bytes, ratio %.2f %% (Duration: %.2f ms)', len(json_telemetry), len(compressed_payload), (len(compressed_payload) / len(json_telemetry)) * 100, compression_time * 1000) + + retries = 0 + upload_success = False + start_time = time.time() + # Retry a few times if the upload failed + while retries < self.retry: + # Try uploading + try: + headers = { + 'User-Agent': self.shuConfig.software_name + '-' + self.shuConfig.software_version, + 'Content-Encoding': 'gzip', + 'Content-Type': 'application/json', + 'Date': formatdate(timeval=None, localtime=False, usegmt=True) + } + req = requests.put( + self.shuConfig.sondehub_telemetry_url, + compressed_payload, + timeout=self.timeout, + headers=headers + ) + except Exception: + self.loggerObj.error('Telemetry upload failed') + return + + # Status code 200 means that everything was ok + if req.status_code == self.shuConfig.status_code_ok: + upload_time = time.time() - start_time + upload_success = True + self.loggerObj.info('{:d} telemetry packages successfully uploaded (Duration: {:.2f} ms)'.format(len(reformatted_telemetry), upload_time * 1000)) + break + # All other status codes indicate some kind of error + elif req.status_code == self.shuConfig.status_code_server_error: + retries += 1 + self.loggerObj.error('Telemetry upload server error, possibly retry') + continue + elif req.status_code == self.shuConfig.status_code_sondehub_error_1 or req.status_code == self.shuConfig.status_code_sondehub_error_2: + upload_success = True + self.loggerObj.error('SondeHub reported issue when adding telemetry to DB') + break + else: + self.loggerObj.error('Telemetry upload error') + break + + if not upload_success: + self.loggerObj.error('Telemetry upload failed after %d retries', retries) diff --git a/SondeHubUploader/utils.py b/SondeHubUploader/utils.py new file mode 100644 index 0000000..4cf237a --- /dev/null +++ b/SondeHubUploader/utils.py @@ -0,0 +1,115 @@ +# utils.py - Utils +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +import datetime +import hashlib +from dateutil.parser import parse + + +# Search for a key inside an aprs package string +def aprs_package_string_find_key(aprs_string, key): + # There might be a space or an exclamation mark before the key + for prefix in [' ', '!']: + index = aprs_string.find(prefix + key) + # Calculate the first index of the value + if index != -1: + return index + len(key) + 1 + return -1 + + +# Add precision to the minutes of latitude or longitude, using the APRS precision and datum option +# Source: http://www.aprs.org/aprs12/datum.txt +def minute_add_precision(self, minute, dao_D, precision): + # There is no additional precision that can be added, if the precision character is space + if precision == ord(' '): + if self is not None: + self.loggerObj.warning('No additional precision') + return minute + # The 'D' character has to be 'w' or 'W' for double or single digit precision + # All other characters are invalid + if dao_D not in [ord('w'), ord('W')]: + if self is not None: self.loggerObj.error('Invalid dao_D character') + return minute + else: + # Precision is added in the form of additional digits for the minute decimal part + # By default the minute contains up to two decimal digits + # In order to add the additional digits properly, the minute is cast to a string with exactly two decimal digits + minute = format(minute, '.2f') + # Two digit precision is used, when the 'D' character is 'w' + if dao_D == ord('w'): + # The two decimal digits are calculated as per the definition of the APRS precision and datum option + minute += '{:03d}'.format(round((precision - 33) * 1.1 * 10)) + if self is not None: + self.loggerObj.debug('Two digit precision added') + # Single digit precision is used, when the 'D' character is 'W' + else: + # Single digit precision can be added to the minute string directly without any additional calculation + minute += str(precision) + if self is not None: + self.loggerObj.debug('Single digit precision added') + # The minute string must be cast back to float before returning + return float(minute) + + +# Adds a date to a provided time and also factors in the leap seconds +def fix_datetime(self, hour, minute, second, leap, local_datetime_str=None): + # If no additional local datetime string is provided, the current utc time is used + if local_datetime_str is None: + now = datetime.datetime.utcnow() + self.loggerObj.debug('Using UTC for datetime fixing (%s)', now) + else: + now = parse(local_datetime_str) + self.loggerObj.debug('Using local datetime string for datetime fixing (%s)', now) + + # A datetime string is generated, using the radiosonde time and the current date + fixed_datetime = parse(f'{hour:02d}:{minute:02d}:{second:02d}', default=now) + + # Everything is fine, if the time is outside the rollover window + if now.hour in [23, 0]: + self.loggerObj.debug('Time is within rollover window') + # There was a rollover according to the system time, but the radiosonde time is still from the last day + # This might happen, since there is a minor time difference between the frame being sent out by the radiosonde and the processing at this point + if fixed_datetime.hour == 23 and now.hour == 0: + # In that case, one day needs to be subtracted from the current date in order to have the correct date for the given radiosonde frame + fixed_datetime = fixed_datetime - datetime.timedelta(days=1) + self.loggerObj.debug('One day was substracted due to a rollover issue (%s)', fixed_datetime) + # There was a rollover according to the radiosonde time, but the system time is still from the last day + # This might happen if the system clock is running slow + elif fixed_datetime.hour == 0 and now.hour == 23: + # In that case, one day needs to be added to the current date in order to have the correct date for the given radiosonde frame + fixed_datetime = fixed_datetime + datetime.timedelta(days=1) + self.loggerObj.debug('One day was added due to a rollover issue (%s)', fixed_datetime) + else: + self.loggerObj.debug('No rollover issue detected') + else: + self.loggerObj.debug('Time is outside rollover window') + # Leap seconds might be added + if leap: + fixed_datetime += datetime.timedelta(seconds=self.shuConfig.leap_seconds) + self.loggerObj.debug('Leap seconds added to datetime (%s)', fixed_datetime) + else: + self.loggerObj.debug('Leap seconds not added to datetime (%s)', fixed_datetime) + return fixed_datetime + + +# Calculate a unique serial for an IMET radiosonde (based on the convention of SondeHub) +# Source: https://github.com/projecthorus/radiosonde_auto_rx/blob/master/auto_rx/autorx/sonde_specific.py +def imet_unique_serial(self, hour, minute, second, fn, f): + # The power on time is calculated using the time and framenumber provided by the IMET radiosonde + # Because IMET radiosondes send one frame per second, the framenumber can be understood as the time since power on in seconds + # time - framenumber = power on time + power_on_time = self.utils.fix_datetime(self, hour, minute, second, True) - datetime.timedelta(seconds=fn) + # A datetime string is generated from the power on time + # The frequency that the IMET radiosonde transmits on is added to the string + # The frequency is rounded to the nearest 100 kHz in order to avoid issues due to frequency drift + # Finally, the string 'SONDE' is added + temp_str = power_on_time.strftime("%Y-%m-%dT%H:%M:%SZ") + '{0:.3f}'.format(round(f, 1)) + ' MHz' + 'SONDE' + # Calculate a SHA256 hash of the string + serial = 'IMET-' + hashlib.sha256(temp_str.encode('ascii')).hexdigest().upper()[-8:] + self.loggerObj.debug('Calculated IMET unique serial (%s)', serial) + # The hash is used as the unique serial + return serial diff --git a/SondeHubUploader/writeData.py b/SondeHubUploader/writeData.py new file mode 100644 index 0000000..53b2bd8 --- /dev/null +++ b/SondeHubUploader/writeData.py @@ -0,0 +1,115 @@ +# writeData.py - Functions for writing data +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Modules +import csv +import os.path + + +# Print raw data +def write_raw_data(self, raw_data): + # A simple text file is used + # The name of the file is hardcoded + filename = self.filepath + '/' + self.shuConfig.filename_raw_data + '.txt' + try: + f = open(filename, 'a', newline='', encoding='utf-8') + f.write(str(raw_data)) + # All entries are separated by a new line + f.write('\n') + f.close() + self.loggerObj.debug('Raw data written (%s.txt)', self.shuConfig.filename_raw_data) + except OSError: + self.loggerObj.error('Error writing raw data (%s.txt)', self.shuConfig.filename_raw_data) + + +# Write telemetry +def write_telemetry(self, telemetry): + # A CSV file is used + # A prefix indicates that the file contains telemetry + # CSV files are named by the serial of the radiosonde + filename = self.filepath + '/' + self.shuConfig.filename_prefix_telemetry + telemetry['serial'] + '.csv' + # It is checked whether the file already exists + exists = os.path.isfile(filename) + status = 'File already exists' if exists else 'File does not exist' + self.loggerObj.debug(status + ' (' + self.shuConfig.filename_prefix_telemetry + telemetry['serial'] + '.csv)') + try: + f = open(filename, 'a', newline='', encoding='utf-8') + writer = csv.writer(f, delimiter=',') + # If the file does not already exist, a headline has to be written + if not exists: + headline_list = [] + # Go through all possible telemetry parameters + for name, (parameter, unit, function) in self.shuConfig.print_write_telemetry.items(): + # Build a headline string, starting with the name of the telemetry parameter + headline_string = name + # Optionally the unit is added to the headline string + if unit is not None: + headline_string += f' [{unit}]' + headline_list.append(headline_string) + writer.writerow(headline_list) + self.loggerObj.debug('Headline written (%s.csv)', self.shuConfig.filename_prefix_telemetry + telemetry['serial']) + row_list = [] + # Go through all possible telemetry parameters + for name, (parameter, unit, conversion_function) in self.shuConfig.print_write_telemetry.items(): + # Write all telemetry parameters that are included in 'telemetry' + if all(item in telemetry.keys() for item in parameter): + # Some written parameters are composed or calculated from several telemetry parameters + # These telemetry parameters must be added to a list in order to be passed to the conversion function + parameter_list = [] + for element in parameter: + parameter_list.append(telemetry[element]) + row_list.append(conversion_function(*parameter_list)) + # Write 'N/A' for all telemetry parameters that are not included in 'telemetry' + else: + row_list.append('N/A') + writer.writerow(row_list) + f.close() + self.loggerObj.debug('Telemetry written (%s.csv)', self.shuConfig.filename_prefix_telemetry + telemetry['serial']) + except OSError: + self.loggerObj.error('Error writing telemetry (%s.csv)', self.shuConfig.filename_prefix_telemetry + telemetry['serial']) + + +# Write reformatted telemetry +def write_reformatted_telemetry(self, reformatted_telemetry): + # A CSV file is used + # A prefix indicates that the file contains reformatted telemetry + # CSV files are named by the serial of the radiosonde + filename = self.filepath + '/' + self.shuConfig.filename_prefix_reformatted_telemetry + reformatted_telemetry['serial'] + '.csv' + # It is checked whether the file already exists + exists = os.path.isfile(filename) + status = 'File already exists' if exists else 'File does not exist' + self.loggerObj.debug(status + ' (' + self.shuConfig.filename_prefix_reformatted_telemetry + reformatted_telemetry['serial'] + '.csv)') + try: + f = open(filename, 'a', newline='', encoding='utf-8') + writer = csv.writer(f, delimiter=',') + # If the file does not already exist, a headline has to be written + if not exists: + headline_list = [] + # Go through all possible reformatted telemetry parameters + for name, unit in self.shuConfig.write_reformatted_telemetry.items(): + # Build a headline string, starting with the name of the reformatted telemetry parameter + headline_string = name + # Optionally the unit is added to the headline string + if unit is not None: + headline_string += f' [{unit}]' + headline_list.append(headline_string) + writer.writerow(headline_list) + self.loggerObj.debug('Headline written (%s.csv)', self.shuConfig.filename_prefix_reformatted_telemetry + reformatted_telemetry['serial']) + row_list = [] + # Go through all possible reformatted telemetry parameters + for name, unit in self.shuConfig.write_reformatted_telemetry.items(): + # Write all reformatted telemetry parameters that are included in 'reformatted_telemetry' + if name in reformatted_telemetry.keys(): + row_list.append(reformatted_telemetry[name]) + # Write 'N/A' for all telemetry parameters that are not included in 'reformatted_telemetry' + else: + row_list.append('N/A') + writer.writerow(row_list) + f.close() + self.loggerObj.debug('Reformatted telemetry written (%s.csv)', self.shuConfig.filename_prefix_reformatted_telemetry + reformatted_telemetry['serial']) + except OSError: + self.loggerObj.error('Error writing reformatted telemetry (%s.csv)', self.shuConfig.filename_prefix_reformatted_telemetry + reformatted_telemetry['serial']) diff --git a/dxlAPRS-SHUE.py b/dxlAPRS-SHUE.py new file mode 100644 index 0000000..37fcbfb --- /dev/null +++ b/dxlAPRS-SHUE.py @@ -0,0 +1,65 @@ +# dxlAPRS-SHUE - dxlAPRS extension for uploading radiosonde telemetry to SondeHub +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Third-party modules +import copy +import time + +import parameterChecks +# Own modules +import mainConfig +import procName +import parameterHandling +import printStartup +import SondeHubUploader.SondeHubUploader as SondeHubUploader + + +# Main Function +if __name__ == '__main__': + # Change the process name, so it can be easily identified within all other processes + procName.set_proc_name('dxlAPRS-SHUE') + + # Parse the command line arguments + raw_parameters = parameterHandling.parse_arguments() + # Perform validity checks on the configuration parameters provided through the command line arguments + checked_parameters = parameterHandling.perform_checks(copy.deepcopy(raw_parameters)) + # Load the defaults for all configuration parameters that were deemed invalid by the validity checks + defaulted_parameters = parameterHandling.load_defaults(copy.deepcopy(checked_parameters)) + # Cast all the configuration parameters to the needed datatypes + casted_parameters = parameterHandling.cast(copy.deepcopy(defaulted_parameters)) + + # Check if all required configuration parameters were provided + if parameterChecks.check_required(casted_parameters): + # Print a prolog message + printStartup.print_prolog(casted_parameters) + # Print an empty line for better readability + print() + # Print warnings for all invalid parameters + printStartup.print_warnings(checked_parameters) + # Check whether any parameter was invalid + if any(not element for element in checked_parameters.values()): + # Print an empty line for better readability + print() + + print('Running...') + + # Create a 'SondeHubUploader' object + shu = SondeHubUploader.SondeHubUploader(casted_parameters) + + # Run forever if the configured 'runtime' is 0 + if casted_parameters['runtime'] == 0: + while True: + # Nothing needs to be done here, since all the processing is handled by the threads of the 'SondeHubUploader' + pass + # Sleep for the configured 'runtime' and close afterwards + elif casted_parameters['runtime'] > 0: + time.sleep(casted_parameters['runtime']) + shu.close() + else: + print('Error: At least one of the required configuration parameters that you provided is invalid') + print(f'The program will close in {mainConfig.closetime_on_error} seconds') + time.sleep(mainConfig.closetime_on_error) diff --git a/log/log.log b/log/log.log new file mode 100644 index 0000000..e69de29 diff --git a/mainConfig.py b/mainConfig.py new file mode 100644 index 0000000..cc8deaa --- /dev/null +++ b/mainConfig.py @@ -0,0 +1,41 @@ +# mainConfig.py - Main configuration parameters +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Third-party modules +import re +import os.path +# Own modules +import parameterChecks + + +# Definition of configuration parameters +# Values: full name, type, default, positional argument, description, check function, required +configuration_parameters = { + 'loglevelp': ['Print Logging Level', int, 3, 'i', 'Logging level for the printed log (Between 1 and 5)', lambda a: str(a).isdigit() and 1 <= int(a) <= 5, False], + 'loglevelw': ['Write Logging Level', int, 3, 'j', 'Logging level for the written log (Between 1 and 5)', lambda a: str(a).isdigit() and 1 <= int(a) <= 5, False], + 'runtime': ['Runtime', int, 0, 't', 'Runtime in seconds (0 for infinite runtime)', lambda a: str(a).isdigit() and int(a) >= 0, False], + 'addr': ['Address', str, '127.0.0.1', 'a', 'Address for the UDP socket', lambda a: parameterChecks.check_address(a), False], + 'port': ['Port', int, 18001, 'p', 'Port for the UDP socket', lambda a: str(a).isdigit() and 1024 <= int(a) <= 65353, False], + 'filepath': ['File Path', str, os.getcwd() + '\log', 'd', 'Path for the files written by the program', lambda a: os.path.exists(a), False], + 'writeo': ['Write Raw Data', int, 0, 's', 'Write setting for the raw data (0 = no / 1 = yes)', lambda a: str(a).isdigit() and 0 <= int(a) <= 1, False], + 'writet': ['Write Telemetry', int, 0, 'w', 'Write setting for the telemetry (0 = no / 1 = yes)', lambda a: str(a).isdigit() and 0 <= int(a) <= 1, False], + 'writer': ['Write Reformatted Telemetry', int, 0, 'z', 'Write setting for the reformatted telemetry (0 = no / 1 = yes)', lambda a: str(a).isdigit() and 0 <= int(a) <= 1, False], + 'writel': ['Write Log', int, 1, 'k', 'Write setting for the log (0 = no / 1 = yes)', lambda a: str(a).isdigit() and 0 <= int(a) <= 1, False], + 'qaprs': ['APRS Queue Size', int, 20, 'q', 'Size of the queue for storing the APRS packages after receiving and before parsing', lambda a: str(a).isdigit() and 1 <= int(a) <= 100, False], + 'qupl': ['Upload Queue Size', int, 200, 'f', 'Size of the queue for storing the radiosonde telemetry packages after parsing and before uploading', lambda a: str(a).isdigit() and 1 <= int(a) <= 600, False], + 'call': ['User Callsign', str, None, 'c', 'User callsign for SondeHub', lambda a: parameterChecks.check_user_callsign(a, 4, 15), True], + 'pos': ['User Position', list, None, 'l', 'User position for SondeHub', lambda a: parameterChecks.check_user_position(a, -100, 8000), True], + 'ant': ['User Antenna', str, '1/4 wave monopole', 'v', 'User antenna for SondeHub', lambda a: 4 <= len(a) <= 25, False], + 'mail': ['Contact E-Mail', str, None, 'u', 'User e-mail for SondeHub', lambda a: bool(re.fullmatch(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', a)), True], + 'posu': ['User Position Update Rate', int, 6, 'g', 'User position update rate for SondeHub', lambda a: str(a).isdigit() and 1 <= int(a) <= 24, False], + 'telemu': ['Telemetry Update Rate', int, 30, 'r', 'Telemetry update rate for SondeHub', lambda a: str(a).isdigit() and 1 <= int(a) <= 600, False], + 'timeout': ['Upload Timeout', int, 20, 'o', 'Upload timeout for SondeHub', lambda a: str(a).isdigit() and 1 <= int(a) <= 60, False], + 'retry': ['Upload Retries', int, 5, 'e', 'Upload retries for SondeHub', lambda a: str(a).isdigit() and 1 <= int(a) <= 60, False] +} + +# Other definitions +closetime_on_error = 5 diff --git a/parameterChecks.py b/parameterChecks.py new file mode 100644 index 0000000..573d784 --- /dev/null +++ b/parameterChecks.py @@ -0,0 +1,70 @@ +# paramChecks.py - Functions for checking the configuration parameter +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Modules +import ipaddress +# Own modules +import mainConfig + + +# Check whether an ip address is valid +def check_address(address): + try: + ipaddress.ip_address(address) + return True + except ValueError: + return False + + +# Check whether a user callsign is valid +def check_user_callsign(user_callsign, min_length, max_length): + # Allowed characters + capital_letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + lowercase_letters = 'abcdefghijklmnopqrstuvwxyz' + numbers = '0123456789' + special_characters = '-_' + + # The user callsign has a minimum and a maximum length (generic definition) + if min_length <= len(user_callsign) <= max_length: + # All characters of the user callsign must be in either of the allowed character lists + if all(c in (capital_letters + lowercase_letters + numbers + special_characters) for c in user_callsign): + return True + return False + + +# Check whether a user position is valid +def check_user_position(user_position, min_altitude, max_altitude): + try: + # The user position is split because the individual values are separated by commas + # The individual values are latitude, longitude and altitude + user_position = [float(x) for x in user_position.split(',')] + # The user position must have 3 elements + # Latitude and longitude must be within a certain range + # Altitude must be within a certain range (generic definition) + if len(user_position) == 3 and\ + -90 <= user_position[0] <= 90 and\ + -180 <= user_position[1] <= 180 and\ + min_altitude <= user_position[2] <= max_altitude: + return True + return False + # Checking the user position could throw several exceptions + # Because of that, they are just handled all + except Exception: + return False + + +def check_required(casted_parameters): + result = True + # Go through all configuration parameters + for parameter, (full_name, _type, default, positional_argument, description, check_function, required) in mainConfig.configuration_parameters.items(): + # Check whether a configuration parameter is required + if required: + # Check whether the configuration parameter still has the default value + if casted_parameters[parameter] == default: + print(f'Error: The configuration parameter {full_name} that you provided is invalid') + result = False + return result diff --git a/parameterHandling.py b/parameterHandling.py new file mode 100644 index 0000000..28137ca --- /dev/null +++ b/parameterHandling.py @@ -0,0 +1,75 @@ +# paramHandling.py - Functions for handling the command line arguments +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Third-party modules +import argparse +# Own modules +import mainConfig + + +# Parse the provided command line arguments +def parse_arguments(): + # Create an 'ArgumentParser' object + # The 'HelpFormatter' of the 'ArgumentParser' is modified using another function + argumentParser = argparse.ArgumentParser( + description='description: Accepts APRS packages from dxlAPRS and uploads the radiosonde telemetry to SondeHub', + formatter_class=make_wide(argparse.HelpFormatter, 1000, 1000)) + # Add all arguments based on the configuration parameters + for parameter, (full_name, _type, default, positional_argument, description, check_function, required) in mainConfig.configuration_parameters.items(): + argumentParser.add_argument('-' + positional_argument, + '--' + parameter, + default=default, + help=description + ' (Default: ' + str(default) + ')', + required=required + ) + return vars(argumentParser.parse_args()) + + +# Modify the width and height of a 'HelpFormatter' object +def make_wide(formatter, width, height): + # The 'HelpFormatter' class is private + # This means that this modification might stop working with future versions of 'argparse' + # This is why this exception needs to be handled + try: + kwargs = {'width': width, 'max_help_position': height} + formatter(None, **kwargs) + return lambda prog: formatter(prog, **kwargs) + except TypeError: + return formatter + +# Perform validity checks on all configuration parameters +def perform_checks(parameters): + for key, value in parameters.items(): + # Validity checks are only carried out on configuration parameters that are different from the default + if parameters[key] != mainConfig.configuration_parameters[key][2]: + # The validity check functions are saved as lambda functions in the 'configuration_parameters' dictionary + if not mainConfig.configuration_parameters[key][5](parameters[key]): + # If a configuration parameter was deemed invalid, it is set to 'False' + parameters[key] = False + return parameters + + +# Load the default for all configuration parameters that were deemed invalid +def load_defaults(parameters): + for key, value in parameters.items(): + # An invalid configuration parameter has 'False' assigned to it by the previous validity checks + if not parameters[key]: + parameters[key] = mainConfig.configuration_parameters[key][2] + return parameters + + +# Cast all configuration parameters from 'str' to the needed datatypes +def cast(parameters): + for key, value in parameters.items(): + # The configuration parameter 'pos' is somewhat special since it is a list of floats + if key == 'pos' and parameters[key] is not None: + parameters[key] = [float(element) for element in value.split(',')] + else: + # All integer configuration parameters are cast to 'int' + if mainConfig.configuration_parameters[key][1] == int and type(parameters[key]) != mainConfig.configuration_parameters[key][1]: + parameters[key] = int(value) + return parameters diff --git a/printStartup.py b/printStartup.py new file mode 100644 index 0000000..770659a --- /dev/null +++ b/printStartup.py @@ -0,0 +1,35 @@ +# printStartup.py - Functions used for printing information at program startup +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Modules +import mainConfig + + +# Print a prolog message that contains all the configuration parameters +def print_prolog(parameters): + # Print the headlines + print('dxlAPRS-SHUE by Eshco93\n') + print('Program configuration parameters:') + # The full configuration parameter names are printed at a fixed length + # This fixed length is based on the length of the longest configuration parameter name + # This results in a nicely formatted and easily readable output + parameter_string = '{:<' + str(max(len(mainConfig.configuration_parameters[key][0]) for key in parameters) + 1) + '} {}' + # Print all the configuration parameters + for key, value in parameters.items(): + # The 'pos' configuration parameter is somewhat special, since it is composed of 3 individual parameters + if key == 'pos': + value = 'Lat: {} / Lon: {} / Alt: {}'.format(*value) + # All other configuration parameters are just printed with their full name and value + print(parameter_string.format(mainConfig.configuration_parameters[key][0] + ':', value)) + + +# Print a warning for all configuration parameters that were deemed invalid +def print_warnings(parameters): + for key, value in parameters.items(): + # An invalid configuration parameter has 'False' assigned to it by the previous validity checks + if not value and type(value) == bool: + print(f'Warning: The configuration parameter "{mainConfig.configuration_parameters[key][0]}" that you provided is invalid. Therefore the default was loaded ({str(mainConfig.configuration_parameters[key][2])})') diff --git a/procName.py b/procName.py new file mode 100644 index 0000000..7c2ff21 --- /dev/null +++ b/procName.py @@ -0,0 +1,23 @@ +# procName.py - Functions for setting/getting the process name +# +# Copyright (C) Simon Schäfer +# +# Released under GNU GPL v3 or later + + +# Set a new process name +def set_proc_name(name): + from ctypes import cdll, byref, create_string_buffer + libc = cdll.LoadLibrary('libc.so.6') + buffer = create_string_buffer(len(name) + 1) + buffer.value = str.encode(name) + libc.prctl(15, byref(buffer), 0, 0, 0) + + +# Get the current process name +def get_proc_name(): + from ctypes import cdll, byref, create_string_buffer + libc = cdll.LoadLibrary('libc.so.6') + buffer = create_string_buffer(128) + libc.prctl(16, byref(buffer), 0, 0, 0) + return buffer.value