From a9cdc30a512b5db894039beaff9ca2955b886af3 Mon Sep 17 00:00:00 2001
From: Joerg Schultze-Lutter APRS position data for REPLACE_MESSAGECALLSIGN on the Internet: Proudly made in the district of Holzminden, Lower Saxony, Germany. 73 de DF1JSL APRS position data for REPLACE_MESSAGECALLSIGN on the Internet: MGRS Military Grid Reference System USNG United States National Grid Latitude and Longitude Altitude Last heard on APRS-IS Address data REPLACE_ADDRESS_DATAAutomated email - please do not respond
@@ -141,6 +146,79 @@
Automated email - please do not respond
+
+
+
+
+
+
+
+
+
+Position details
+Values
+
+
+ Maidenhead Grid Locator
+ REPLACE_MAIDENHEAD
+
+
+ DMS Degrees and Decimal Minutes
+ REPLACE_DMS
+
+
+ UTM Universal Transverse Mercator
+ REPLACE_UTM
+
+
+
+
+ REPLACE_MGRS
+
+
+
+
+ REPLACE_LATLON
+
+
+
+
+ REPLACE_ALTITUDE
+
+
+
+
+ REPLACE_LASTHEARD
+
+
+
+
+
+
+
+
+
This position report was requested by REPLACE_USERSCALLSIGN via APRS and was processed by MPAD (Multi-Purpose APRS Daemon). Generated at REPLACE_DATETIME_CREATED
+More info on MPAD can be found here: https://www.github.com/joergschultzelutter/mpad
+Proudly made in the district of Holzminden, Lower Saxony, Germany. 73 de DF1JSL
+""" + mail_subject_template = "APRS Position Report for REPLACE_MESSAGECALLSIGN" @@ -162,14 +240,10 @@ def send_email_position_report(response_parameters: dict): back to the APRS user (does not contain any email content) """ + # get the required email data from our data pool smtpimap_email_address = response_parameters["smtpimap_email_address"] smtpimap_email_password = response_parameters["smtpimap_email_password"] - # copy the templates - plaintext_message = plaintext_template - html_message = html_template - subject_message = mail_subject_template - latitude = response_parameters["latitude"] longitude = response_parameters["longitude"] altitude = response_parameters["altitude"] @@ -178,6 +252,19 @@ def send_email_position_report(response_parameters: dict): users_callsign = response_parameters["users_callsign"] mail_recipient = response_parameters["mail_recipient"] + # Now try to generate an image map of the user's position... + html_image = render_png_map(aprs_latitude=latitude, aprs_longitude=longitude) + # ... and now choose the template according to whether we happen to + # have been able to generate an image or not + html_message = ( + html_template_with_image if html_image else html_template_without_image + ) + + # copy the remaining templates + plaintext_message = plaintext_template + subject_message = mail_subject_template + + # generate the time stamp lasttime = response_parameters["lasttime"] if not isinstance(lasttime, datetime.datetime): lasttime = datetime.datetime.min @@ -311,7 +398,21 @@ def send_email_position_report(response_parameters: dict): msg["From"] = f"MPAD Multi-Purpose APRS Daemon <{smtpimap_email_address}>" msg["To"] = mail_recipient msg.set_content(plaintext_message) - msg.add_alternative(html_message, subtype="html") + + # Image present? Then encode it properly + if html_image: + image_cid = make_msgid() + msg.add_alternative( + html_message.format(image_cid=image_cid[1:-1]), subtype="html" + ) + x = msg.get_payload() + + msg.get_payload()[1].add_related( + html_image, maintype="image", subtype="png", cid=image_cid + ) + else: + # otherwise, send the HTML content without an image + msg.add_alternative(html_message, subtype="html") success, output_message = send_message_via_snmp( smtpimap_email_address=smtpimap_email_address, @@ -369,7 +470,9 @@ def imap_garbage_collector(smtpimap_email_address: str, smtpimap_email_password: # typ, dat = imap.list() # get list of mailboxes typ, dat = imap.select(mailbox=mpad_config.mpad_imap_mailbox_name) if typ == "OK": - logger.info(msg=f"IMAP folder SELECT for {mpad_config.mpad_imap_mailbox_name} successful") + logger.info( + msg=f"IMAP folder SELECT for {mpad_config.mpad_imap_mailbox_name} successful" + ) typ, msgnums = imap.search(None, "ALL", query_parms) if typ == "OK": for num in msgnums[0].split(): diff --git a/src/geo_conversion_modules.py b/src/geo_conversion_modules.py index 6a0608a..3f4bd79 100644 --- a/src/geo_conversion_modules.py +++ b/src/geo_conversion_modules.py @@ -21,7 +21,8 @@ import utm import maidenhead -from mgrs import MGRStoLL, LLtoMGRS + +import mgrs as mg from math import radians, cos, sin, asin, sqrt, atan2, degrees import logging @@ -176,8 +177,9 @@ def convert_latlon_to_mgrs(latitude: float, longitude: float): mgrs_coordinates: 'str' MGRS coordinates for the given set of lat/lon coordinates """ + m = mg.MGRS() - mgrs_coordinates: str = LLtoMGRS(latitude, longitude) + mgrs_coordinates: str = m.toMGRS(latitude=latitude, longitude=longitude) return mgrs_coordinates @@ -201,9 +203,11 @@ def convert_mgrs_to_latlon(mgrs_coordinates: str, output_precision: int = 6): Longitude value """ - response = MGRStoLL(mgrs_coordinates) - latitude = round(response["lat"], output_precision) - longitude = round(response["lon"], output_precision) + m = mg.MGRS() + latitude, longitude = m.toLatLon(MGRS=mgrs_coordinates) + latitude = round(latitude, output_precision) + longitude = round(longitude, output_precision) + return latitude, longitude @@ -441,7 +445,7 @@ def haversine( logger.info(convert_latlon_to_maidenhead(51.838720, 08.326819)) logger.info(convert_maidenhead_to_latlon("JO41du91")) - logger.info(convert_latlon_to_mgrs(51.838720, 08.326819)) + #logger.info(convert_latlon_to_mgrs(51.838720, 08.326819)) logger.info(convert_mgrs_to_latlon("32UMC5362043315")) logger.info(convert_latlon_to_dms(51.838720, 08.326819)) diff --git a/src/mpad_config.py b/src/mpad_config.py index c683b35..291341c 100644 --- a/src/mpad_config.py +++ b/src/mpad_config.py @@ -22,7 +22,7 @@ # # Program version # -mpad_version: str = "0.43" +mpad_version: str = "0.50" # ########################### # Constants, do not change# @@ -291,3 +291,11 @@ # ">Multi-Purpose APRS Daemon", ] # +# Several file names that are used throughout the program and act as local databases +mpad_airport_stations_filename = "airport_stations.txt" +mpad_tle_amateur_satellites_filename = "tle_amateur_satellites.txt" +mpad_satellite_frequencies_filename = "satellite_frequencies.csv" +mpad_hearham_raw_data_filename = "hearham_raw_data.json" +mpad_repeatermap_raw_data_filename = "repeatermap_raw_data.json" +mpad_repeater_data_filename = "mpad_repeater_data.json" +mpad_satellite_data_filename = "mpad_satellite_data.json" diff --git a/src/parser_test.py b/src/parser_test.py index ba7eb3e..10aab4a 100644 --- a/src/parser_test.py +++ b/src/parser_test.py @@ -3,11 +3,28 @@ # The result is equivalent to what would be sent to aprs-is # Populate the main function's 'testcall' parameter with the APRS message that you want to have parsed # -from utility_modules import read_program_config, make_pretty_aprs_messages +from utility_modules import ( + read_program_config, + make_pretty_aprs_messages, + check_if_file_exists, + build_full_pathname, +) from input_parser import parse_input_message from output_generator import generate_output_message import logging from pprint import pformat +from airport_data_modules import update_local_airport_stations_file +from skyfield_modules import update_local_mpad_satellite_data +from repeater_modules import update_local_repeatermap_file +from mpad_config import ( + mpad_airport_stations_filename, + mpad_satellite_frequencies_filename, + mpad_tle_amateur_satellites_filename, +) +from mpad_config import ( + mpad_hearham_raw_data_filename, + mpad_repeatermap_raw_data_filename, +) logging.basicConfig( level=logging.DEBUG, format="%(asctime)s %(module)s -%(levelname)s- %(message)s" @@ -70,5 +87,36 @@ def testcall(message_text: str, from_callsign: str): # logger.info(msg=pformat(response_parameters)) +def download_data_files_if_missing(): + # if the user has never ever run the actual bot, some files might be missing, thus preventing us from + # simulating the bot's actual behavior in real life. As a workaround, check if the files are missing + # and download them, if necessary + # + # check if airport data file is present + if not check_if_file_exists(build_full_pathname(mpad_airport_stations_filename)): + logger.info("Updating local airport data file") + update_local_airport_stations_file(mpad_airport_stations_filename) + + # check if the satellite data is present + if not check_if_file_exists( + build_full_pathname(mpad_satellite_frequencies_filename) + ) or not check_if_file_exists( + build_full_pathname(mpad_tle_amateur_satellites_filename) + ): + logger.info("Updating local satellite data files") + update_local_mpad_satellite_data() + + # check if the repeater data is present + if not check_if_file_exists( + build_full_pathname(mpad_repeatermap_raw_data_filename) + ) or not check_if_file_exists(build_full_pathname(mpad_hearham_raw_data_filename)): + logger.info("Updating local repeater data files") + update_local_repeatermap_file() + + if __name__ == "__main__": - testcall(message_text="metars bli", from_callsign="KI7JOM-10") + # Check if the local database files exist and + # create them, if necessary + download_data_files_if_missing() + + testcall(message_text="posmsg jsl24469@gmail.com", from_callsign="DF1JSL-1") diff --git a/src/repeater_modules.py b/src/repeater_modules.py index a40c3ca..3cebae9 100644 --- a/src/repeater_modules.py +++ b/src/repeater_modules.py @@ -33,6 +33,12 @@ import logging import operator +from mpad_config import ( + mpad_hearham_raw_data_filename, + mpad_repeatermap_raw_data_filename, + mpad_repeater_data_filename, +) + logging.basicConfig( level=logging.INFO, format="%(asctime)s %(module)s -%(levelname)s- %(message)s" ) @@ -40,8 +46,8 @@ def download_repeatermap_raw_data_to_local_file( - url: str = "http://www.repeatermap.de/api.php", - repeatermap_raw_data_file: str = "repeatermap_raw_data.json", + url: str = "http://www.repeatermap.de/apinew.php", + repeatermap_raw_data_file: str = mpad_repeatermap_raw_data_filename, ): """ Downloads the repeatermap.de data and write it to a file 'as is' @@ -82,7 +88,7 @@ def download_repeatermap_raw_data_to_local_file( def read_repeatermap_raw_data_from_disk( - repeatermap_raw_data_file: str = "repeatermap_raw_data.json", + repeatermap_raw_data_file: str = mpad_repeatermap_raw_data_filename, ): """ Read the repeatermap.de raw data from disc. @@ -447,7 +453,7 @@ def process_raw_data_from_hearham_com(mpad_repeater_dict: dict): def write_mpad_repeater_data_to_disc( mpad_repeatermap_json: str, - mpad_repeatermap_filename: str = "mpad_repeater_data.json", + mpad_repeatermap_filename: str = mpad_repeater_data_filename, ): """ writes the processed repeatermap data in enriched MPAD format @@ -480,7 +486,7 @@ def write_mpad_repeater_data_to_disc( def read_mpad_repeatermap_data_from_disc( - mpad_repeatermap_filename: str = "mpad_repeater_data.json", + mpad_repeatermap_filename: str = mpad_repeater_data_filename, ): """ Read the MPAD preprocessed repeatermap file from disc @@ -611,7 +617,6 @@ def get_nearest_repeater( location_dictionary_unsorted = {} for repeater in mpad_repeatermap_dictionary: - # get latitude/longitude from the repeatermap dictionary rm_lat = mpad_repeatermap_dictionary[repeater]["latitude"] rm_lon = mpad_repeatermap_dictionary[repeater]["longitude"] @@ -736,7 +741,7 @@ def get_nearest_repeater( def download_hearham_raw_data_to_local_file( url: str = "https://hearham.com/api/repeaters/v1", - hearham_raw_data_file: str = "hearham_raw_data.json", + hearham_raw_data_file: str = mpad_hearham_raw_data_filename, ): """ Downloads the repeatermap.de data and write it to a file 'as is' @@ -777,7 +782,7 @@ def download_hearham_raw_data_to_local_file( def read_hearham_raw_data_from_disk( - hearham_raw_data_file: str = "hearham_raw_data.json", + hearham_raw_data_file: str = mpad_hearham_raw_data_filename, ): """ Read the repeatermap.de raw data from disc. @@ -821,7 +826,7 @@ def read_hearham_raw_data_from_disk( get_nearest_repeater( latitude=51.8458575, longitude=8.2997425, - mode="c4fm", + mode="fm", units="metric", number_of_results=5, ) diff --git a/src/skyfield_modules.py b/src/skyfield_modules.py index b325bc8..7549c49 100644 --- a/src/skyfield_modules.py +++ b/src/skyfield_modules.py @@ -31,6 +31,11 @@ import json from pprint import pformat from utility_modules import check_if_file_exists +from mpad_config import ( + mpad_tle_amateur_satellites_filename, + mpad_satellite_frequencies_filename, + mpad_satellite_data_filename, +) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(module)s -%(levelname)s- %(message)s" @@ -38,7 +43,9 @@ logger = logging.getLogger(__name__) -def download_and_write_local_tle_file(tle_filename: str = "tle_amateur_satellites.txt"): +def download_and_write_local_tle_file( + tle_filename: str = mpad_tle_amateur_satellites_filename, +): """ Download the amateur radio satellite TLE data and save it to a local file. @@ -82,7 +89,7 @@ def download_and_write_local_tle_file(tle_filename: str = "tle_amateur_satellite def download_and_write_local_satfreq_file( - satfreq_filename: str = "satellite_frequencies.csv", + satfreq_filename: str = mpad_satellite_frequencies_filename, ): """ Download the amateur radio satellite frequency data @@ -124,7 +131,7 @@ def download_and_write_local_satfreq_file( return success -def read_local_tle_file(tle_filename: str = "tle_amateur_satellites.txt"): +def read_local_tle_file(tle_filename: str = mpad_tle_amateur_satellites_filename): """ Imports the Celestrak TLE data from a local file. Create dictionary based on given data. @@ -184,7 +191,6 @@ def read_local_tle_file(tle_filename: str = "tle_amateur_satellites.txt"): lc = 1 # Retrieve the data and create the dictionary for tle_satellite in lines[0::3]: - # Process the key. Try to extract the ID (if present). # Otherwise, replace all blanks with dashes tle_satellite = tle_satellite.rstrip() @@ -214,7 +220,9 @@ def read_local_tle_file(tle_filename: str = "tle_amateur_satellites.txt"): return success, tle_data -def read_local_satfreq_file(satfreq_filename: str = "satellite_frequencies.csv"): +def read_local_satfreq_file( + satfreq_filename: str = mpad_satellite_frequencies_filename, +): """ Reads the local amateur radio satellite frequency data from disc, transforms the data and creates a dictionary @@ -409,7 +417,7 @@ def create_native_satellite_data(): def write_mpad_satellite_data_to_disc( mpad_satellite_json: str, - mpad_satellite_filename: str = "mpad_satellite_data.json", + mpad_satellite_filename: str = mpad_satellite_data_filename, ): """ writes the processed satellite data in enriched MPAD format @@ -443,7 +451,7 @@ def write_mpad_satellite_data_to_disc( def read_mpad_satellite_data_from_disc( - mpad_satellite_filename: str = "mpad_satellite_data.json", + mpad_satellite_filename: str = mpad_satellite_data_filename, ): """ reads the pre-processed satellite data in enriched MPAD format diff --git a/src/staticmap.py b/src/staticmap.py new file mode 100644 index 0000000..77d4ab5 --- /dev/null +++ b/src/staticmap.py @@ -0,0 +1,95 @@ +# +# Multi-Purpose APRS Daemon: Generate a static +# image and indicate the user's coordinates on the map +# Author: Joerg Schultze-Lutter, 2020 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +import sys + +import staticmaps +import io +import logging + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(module)s -%(levelname)s- %(message)s" +) +logger = logging.getLogger(__name__) + + +def render_png_map( + aprs_latitude: float = None, + aprs_longitude: float = None, +): + """ + Render a static PNG image of the user's destination + and add markers based on user's lat/lon data. + Return the binary image object back to the user + Parameters + ========== + aprs_latitude : 'float' + APRS dynamic latitude (if applicable) + aprs_longitude : 'float' + APRS dynamic longitude (if applicable) + + Returns + ======= + iobuffer : 'bytes' + 'None' if not successful, otherwise binary representation + of the image + """ + + assert aprs_latitude, aprs_longitude + + # Create the object + context = staticmaps.Context() + context.set_tile_provider(staticmaps.tile_provider_OSM) + + # Add a green marker for the user's position + marker_color = staticmaps.RED + context.add_object( + staticmaps.Marker( + staticmaps.create_latlng(aprs_latitude, aprs_longitude), + color=marker_color, + size=12, + ) + ) + + # create a buffer as we need to write to write to memory + iobuffer = io.BytesIO() + + try: + # Try to render via pycairo - looks nicer + if staticmaps.cairo_is_supported(): + image = context.render_cairo(800, 500) + image.write_to_png(iobuffer) + else: + # if pycairo is not present, render via pillow + image = context.render_pillow(800, 500) + image.save(iobuffer, format="png") + + # reset the buffer position + iobuffer.seek(0) + + # get the buffer value and return it + view = iobuffer.getvalue() + except Exception as ex: + view = None + + return view + + +if __name__ == "__main__": + render_png_map(aprs_latitude=52.5186729836, aprs_longitude=13.3704687765)