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)