From fc3a747aa4792614957843cf3c60b6539644aaec Mon Sep 17 00:00:00 2001 From: Hemna Date: Wed, 20 Jan 2021 10:19:49 -0500 Subject: [PATCH] Added new time plugins This patch adds 2 new time plugins to allow admins to use their opencagedata APIkey or openweathermap API key to fetch the timezone from the lat/lon GPS coordinates for the callsign requesting the time. This will enable fetching the time local to the ham radio's last beacon, and not time local to the aprsd server instance running. If the location is not found, then the timezone will default to UTC. The 2 new plugins are - aprsd.plugins.time.TimeOpenCageDataPlugin Fetches timezone from lat/lon using the opencagedata api that can be found here: https://opencagedata.com/dashboard#api-keys This requires a new ~/.config/aprsd/aprsd.yml entry to specify the api key. opencagedata: apiKey: - aprsd.plugins.time.TimeOWMPlugin Fetches the timezone from lat/lon using the openweathermap api that can be found here: https://home.openweathermap.org/api_keys This requires a new ~/.config/aprsd/aprsd.yml entry to specify the api key. openweathermap: apiKey: --- Dockerfile | 6 +-- Dockerfile-dev | 4 +- aprsd/plugin_utils.py | 72 +++++++++++++++++++++++++++ aprsd/plugins/location.py | 42 ++++++++-------- aprsd/plugins/time.py | 101 +++++++++++++++++++++++++++++++++----- aprsd/utils.py | 77 ++++++++++++++++++++--------- build/bin/run.sh | 2 +- requirements.in | 1 + requirements.txt | 21 +++++++- tests/test_plugin.py | 3 +- 10 files changed, 262 insertions(+), 67 deletions(-) create mode 100644 aprsd/plugin_utils.py diff --git a/Dockerfile b/Dockerfile index db69a13..1e78d1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,9 +10,9 @@ RUN apk add --update git wget py3-pip py3-virtualenv bash fortune # Setup Timezone ENV TZ=US/Eastern -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -RUN apt-get install -y tzdata -RUN dpkg-reconfigure --frontend noninteractive tzdata +#RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +#RUN apt-get install -y tzdata +#RUN dpkg-reconfigure --frontend noninteractive tzdata RUN addgroup --gid 1000 $APRS_USER diff --git a/Dockerfile-dev b/Dockerfile-dev index b50bc5b..fbd8007 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -2,11 +2,11 @@ FROM alpine:latest as aprsd # Dockerfile for building a container during aprsd development. -ENV VERSION=1.0.0 +ENV VERSION=1.5.1 ENV APRS_USER=aprs ENV HOME=/home/aprs ENV APRSD=http://github.com/craigerl/aprsd.git -ENV APRSD_BRANCH="v1.1.0" +ENV APRSD_BRANCH="master" ENV VIRTUAL_ENV=$HOME/.venv3 ENV INSTALL=$HOME/install diff --git a/aprsd/plugin_utils.py b/aprsd/plugin_utils.py new file mode 100644 index 0000000..9118369 --- /dev/null +++ b/aprsd/plugin_utils.py @@ -0,0 +1,72 @@ +# Utilities for plugins to use +import json +import logging + +import requests + +LOG = logging.getLogger("APRSD") + + +def get_aprs_fi(api_key, callsign): + LOG.debug("Fetch aprs.fi location for '{}'".format(callsign)) + try: + url = ( + "http://api.aprs.fi/api/get?" + "&what=loc&apikey={}&format=json" + "&name={}".format(api_key, callsign) + ) + response = requests.get(url) + except Exception: + raise Exception("Failed to get aprs.fi location") + else: + response.raise_for_status() + return json.loads(response.text) + + +def get_weather_gov_for_gps(lat, lon): + LOG.debug("Fetch station at {}, {}".format(lat, lon)) + try: + url2 = ( + "https://forecast.weather.gov/MapClick.php?lat=%s" + "&lon=%s&FcstType=json" % (lat, lon) + ) + LOG.debug("Fetching weather '{}'".format(url2)) + response = requests.get(url2) + except Exception as e: + LOG.error(e) + raise Exception("Failed to get weather") + else: + response.raise_for_status() + return json.loads(response.text) + + +def get_weather_gov_metar(station): + LOG.debug("Fetch metar for station '{}'".format(station)) + try: + url = "https://api.weather.gov/stations/{}/observations/latest".format( + station, + ) + response = requests.get(url) + except Exception: + raise Exception("Failed to fetch metar") + else: + response.raise_for_status() + return json.loads(response) + + +def fetch_openweathermap(api_key, lat, lon, exclude=None): + LOG.debug("Fetch openweathermap for {}, {}".format(lat, lon)) + if not exclude: + exclude = "minutely,hourly,daily,alerts" + try: + url = ( + "https://api.openweathermap.org/data/2.5/onecall?" + "lat={}&lon={}&appid={}&exclude={}".format(lat, lon, api_key, exclude) + ) + response = requests.get(url) + except Exception as e: + LOG.error(e) + raise Exception("Failed to get weather") + else: + response.raise_for_status() + return json.loads(response.text) diff --git a/aprsd/plugins/location.py b/aprsd/plugins/location.py index 031b678..60a5c19 100644 --- a/aprsd/plugins/location.py +++ b/aprsd/plugins/location.py @@ -1,10 +1,8 @@ -import json import logging import re import time -from aprsd import plugin -import requests +from aprsd import plugin, plugin_utils, utils LOG = logging.getLogger("APRSD") @@ -16,11 +14,15 @@ class LocationPlugin(plugin.APRSDPluginBase): command_regex = "^[lL]" command_name = "location" - config_items = {"apikey": "aprs.fi api key here"} - def command(self, fromcall, message, ack): LOG.info("Location Plugin") # get last location of a callsign, get descriptive name from weather service + try: + utils.check_config_option(self.config, "aprs.fi", "apiKey") + except Exception as ex: + LOG.error("Failed to find config aprs.fi:apikey {}".format(ex)) + return "No aprs.fi apikey found" + api_key = self.config["aprs.fi"]["apiKey"] try: # optional second argument is a callsign to search @@ -31,14 +33,13 @@ class LocationPlugin(plugin.APRSDPluginBase): else: # if no second argument, search for calling station searchcall = fromcall - url = ( - "http://api.aprs.fi/api/get?name=" - + searchcall - + "&what=loc&apikey={}&format=json".format(api_key) - ) - response = requests.get(url) - # aprs_data = json.loads(response.read()) - aprs_data = json.loads(response.text) + + try: + aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall) + except Exception as ex: + LOG.error("Failed to fetch aprs.fi '{}'".format(ex)) + return "Failed to fetch aprs.fi location" + LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data)) lat = aprs_data["entries"][0]["lat"] lon = aprs_data["entries"][0]["lng"] @@ -53,15 +54,12 @@ class LocationPlugin(plugin.APRSDPluginBase): # ) # unicode to ascii delta_seconds = time.time() - int(aprs_lasttime_seconds) delta_hours = delta_seconds / 60 / 60 - url2 = ( - "https://forecast.weather.gov/MapClick.php?lat=" - + str(lat) - + "&lon=" - + str(lon) - + "&FcstType=json" - ) - response2 = requests.get(url2) - wx_data = json.loads(response2.text) + + try: + wx_data = plugin_utils.get_weather_gov_for_gps(lat, lon) + except Exception as ex: + LOG.error("Couldn't fetch forecast.weather.gov '{}'".format(ex)) + wx_data["location"]["areaDescription"] = "Unkown Location" reply = "{}: {} {}' {},{} {}h ago".format( searchcall, diff --git a/aprsd/plugins/time.py b/aprsd/plugins/time.py index 4510ec3..7807b99 100644 --- a/aprsd/plugins/time.py +++ b/aprsd/plugins/time.py @@ -1,7 +1,8 @@ import logging import time -from aprsd import fuzzyclock, plugin +from aprsd import fuzzyclock, plugin, plugin_utils, utils +from opencage.geocoder import OpenCageGeocode import pytz LOG = logging.getLogger("APRSD") @@ -20,15 +21,7 @@ class TimePlugin(plugin.APRSDPluginBase): def _get_utcnow(self): return pytz.datetime.datetime.utcnow() - def command(self, fromcall, message, ack): - LOG.info("TIME COMMAND") - # So we can mock this in unit tests - localzone = self._get_local_tz() - - # This is inefficient for now, but this enables - # us to add the ability to provide time in the TZ - # of the caller, if we can get the TZ from their callsign location - # This also accounts for running aprsd in different timezones + def build_date_str(self, localzone): utcnow = self._get_utcnow() gmt_t = pytz.utc.localize(utcnow) local_t = gmt_t.astimezone(localzone) @@ -38,10 +31,94 @@ class TimePlugin(plugin.APRSDPluginBase): local_min = local_t.strftime("%M") cur_time = fuzzyclock.fuzzy(int(local_hour), int(local_min), 1) - reply = "{} ({}) ({})".format( + reply = "{} ({})".format( cur_time, local_short_str, - message.rstrip(), ) return reply + + def command(self, fromcall, message, ack): + LOG.info("TIME COMMAND") + # So we can mock this in unit tests + localzone = self._get_local_tz() + return self.build_date_str(localzone) + + +class TimeOpenCageDataPlugin(TimePlugin): + """geocage based timezone fetching.""" + + version = "1.0" + command_regex = "^[tT]" + command_name = "Time" + + def command(self, fromcall, message, ack): + api_key = self.config["aprs.fi"]["apiKey"] + try: + aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall) + except Exception as ex: + LOG.error("Failed to fetch aprs.fi data {}".format(ex)) + return "Failed to fetch location" + + # LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data)) + lat = aprs_data["entries"][0]["lat"] + lon = aprs_data["entries"][0]["lng"] + + try: + utils.check_config_option(self.config, "opencagedata", "apiKey") + except Exception as ex: + LOG.error("Failed to find config opencage:apiKey {}".format(ex)) + return "No opencage apiKey found" + + try: + opencage_key = self.config["opencagedata"]["apiKey"] + geocoder = OpenCageGeocode(opencage_key) + results = geocoder.reverse_geocode(lat, lon) + except Exception as ex: + LOG.error("Couldn't fetch opencagedata api '{}'".format(ex)) + # Default to UTC instead + localzone = pytz.timezone("UTC") + else: + tzone = results[0]["annotations"]["timezone"]["name"] + localzone = pytz.timezone(tzone) + + return self.build_date_str(localzone) + + +class TimeOWMPlugin(TimePlugin): + """OpenWeatherMap based timezone fetching.""" + + version = "1.0" + command_regex = "^[tT]" + command_name = "Time" + + def command(self, fromcall, message, ack): + api_key = self.config["aprs.fi"]["apiKey"] + try: + aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall) + except Exception as ex: + LOG.error("Failed to fetch aprs.fi data {}".format(ex)) + return "Failed to fetch location" + + # LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data)) + lat = aprs_data["entries"][0]["lat"] + lon = aprs_data["entries"][0]["lng"] + + try: + utils.check_config_option(self.config, "openweathermap", "apiKey") + except Exception as ex: + LOG.error("Failed to find config openweathermap:apiKey {}".format(ex)) + return "No openweathermap apiKey found" + + api_key = self.config["openweathermap"]["apiKey"] + try: + results = plugin_utils.fetch_openweathermap(api_key, lat, lon) + except Exception as ex: + LOG.error("Couldn't fetch openweathermap api '{}'".format(ex)) + # default to UTC + localzone = pytz.timezone("UTC") + else: + tzone = results["timezone"] + localzone = pytz.timezone(tzone) + + return self.build_date_str(localzone) diff --git a/aprsd/utils.py b/aprsd/utils.py index 5cbaec4..c0e78c2 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -22,6 +22,8 @@ DEFAULT_CONFIG_DICT = { "logfile": "/tmp/aprsd.log", }, "aprs.fi": {"apiKey": "set me"}, + "openweathermap": {"apiKey": "set me"}, + "opencagedata": {"apiKey": "set me"}, "shortcuts": { "aa": "5551239999@vtext.com", "cl": "craiglamparter@somedomain.org", @@ -111,6 +113,24 @@ def add_config_comments(raw_yaml): end_idx, ) + end_idx = end_substr(raw_yaml, "opencagedata:") + if end_idx != -1: + # lets insert a comment + raw_yaml = insert_str( + raw_yaml, + "\n # Get the apiKey from your opencagedata account here: https://opencagedata.com/dashboard#api-keys", + end_idx, + ) + + end_idx = end_substr(raw_yaml, "openweathermap:") + if end_idx != -1: + # lets insert a comment + raw_yaml = insert_str( + raw_yaml, + "\n # Get the apiKey from your openweathermap account here: https://home.openweathermap.org/api_keys", + end_idx, + ) + return raw_yaml @@ -154,6 +174,35 @@ def get_config(config_file): sys.exit(-1) +def check_config_option(config, section, name=None, default=None, default_fail=None): + if section in config: + + if name and name not in config[section]: + if not default: + raise Exception( + "'{}' was not in '{}' section of config file".format( + name, + section, + ), + ) + else: + config[section][name] = default + else: + if ( + default_fail + and name in config[section] + and config[section][name] == default_fail + ): + # We have to fail and bail if the user hasn't edited + # this config option. + raise Exception( + "Config file needs to be edited from provided defaults.", + ) + else: + raise Exception("'%s' section wasn't in config file" % section) + return config + + # This method tries to parse the config yaml file # and consume the settings. # If the required params don't exist, @@ -167,30 +216,12 @@ def parse_config(config_file): sys.exit(-1) def check_option(config, section, name=None, default=None, default_fail=None): - if section in config: - - if name and name not in config[section]: - if not default: - fail( - "'{}' was not in '{}' section of config file".format( - name, - section, - ), - ) - else: - config[section][name] = default - else: - if ( - default_fail - and name in config[section] - and config[section][name] == default_fail - ): - # We have to fail and bail if the user hasn't edited - # this config option. - fail("Config file needs to be edited from provided defaults.") + try: + config = check_config_option(config, section, name, default, default_fail) + except Exception as ex: + fail(repr(ex)) else: - fail("'%s' section wasn't in config file" % section) - return config + return config config = get_config(config_file) check_option(config, "shortcuts") diff --git a/build/bin/run.sh b/build/bin/run.sh index be6b8c6..23e9aa1 100755 --- a/build/bin/run.sh +++ b/build/bin/run.sh @@ -23,4 +23,4 @@ if [ ! -e "$APRSD_CONFIG" ]; then echo "'$APRSD_CONFIG' File does not exist. Creating." aprsd sample-config > $APRSD_CONFIG fi -$VIRTUAL_ENV/bin/aprsd server -c $APRSD_CONFIG +$VIRTUAL_ENV/bin/aprsd server -c $APRSD_CONFIG --loglevel DEBUG diff --git a/requirements.in b/requirements.in index c023737..6164b3e 100644 --- a/requirements.in +++ b/requirements.in @@ -11,3 +11,4 @@ aprslib py3-validate-email pre-commit pytz +opencage diff --git a/requirements.txt b/requirements.txt index 5eba174..ad84d96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,14 +2,18 @@ # This file is autogenerated by pip-compile # To update, run: # -# pip-compile requirements.in +# pip-compile # appdirs==1.4.4 # via virtualenv aprslib==0.6.47 # via -r requirements.in +backoff==1.10.0 + # via opencage certifi==2020.12.5 # via requests +cffi==1.14.4 + # via cryptography cfgv==3.2.0 # via pre-commit chardet==4.0.0 @@ -20,6 +24,8 @@ click==7.1.2 # via # -r requirements.in # click-completion +cryptography==3.3.1 + # via pyopenssl distlib==0.3.1 # via virtualenv dnspython==2.1.0 @@ -42,6 +48,8 @@ markupsafe==1.1.1 # via jinja2 nodeenv==1.5.0 # via pre-commit +opencage==1.2.2 + # via -r requirements.in pbr==5.5.1 # via -r requirements.in pluggy==0.13.1 @@ -50,6 +58,10 @@ pre-commit==2.9.3 # via -r requirements.in py3-validate-email==0.2.12 # via -r requirements.in +pycparser==2.20 + # via cffi +pyopenssl==20.0.1 + # via opencage pytz==2020.5 # via -r requirements.in pyyaml==5.3.1 @@ -57,14 +69,19 @@ pyyaml==5.3.1 # -r requirements.in # pre-commit requests==2.25.1 - # via -r requirements.in + # via + # -r requirements.in + # opencage shellingham==1.3.2 # via click-completion six==1.15.0 # via # -r requirements.in # click-completion + # cryptography # imapclient + # opencage + # pyopenssl # virtualenv thesmuggler==1.0.1 # via -r requirements.in diff --git a/tests/test_plugin.py b/tests/test_plugin.py index fecd16f..73fad4c 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -97,10 +97,9 @@ class TestPlugin(unittest.TestCase): message = "time" local_short_str = local_t.strftime("%H:%M %Z") - expected = "{} ({}) ({})".format( + expected = "{} ({})".format( cur_time, local_short_str, - message.rstrip(), ) actual = time.run(fromcall, message, ack) self.assertEqual(expected, actual)