diff --git a/Dockerfile-dev b/Dockerfile-dev index fbd8007..cc968d3 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -11,6 +11,8 @@ ENV VIRTUAL_ENV=$HOME/.venv3 ENV INSTALL=$HOME/install RUN apk add --update git vim wget py3-pip py3-virtualenv bash fortune +RUN apk add --update gcc python3-dev linux-headers musl-dev libffi-dev libc-dev +RUN apk add --update openssl-dev # Setup Timezone ENV TZ=US/Eastern diff --git a/aprsd/plugin.py b/aprsd/plugin.py index 4b3aff3..bd7182a 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -23,8 +23,7 @@ CORE_PLUGINS = [ "aprsd.plugins.ping.PingPlugin", "aprsd.plugins.query.QueryPlugin", "aprsd.plugins.time.TimePlugin", - "aprsd.plugins.weather.WeatherPlugin", - "aprsd.plugins.weather.WxPlugin", + "aprsd.plugins.weather.USWeatherPlugin", "aprsd.plugins.version.VersionPlugin", ] diff --git a/aprsd/plugin_utils.py b/aprsd/plugin_utils.py index 9118369..d6af3b8 100644 --- a/aprsd/plugin_utils.py +++ b/aprsd/plugin_utils.py @@ -54,14 +54,20 @@ def get_weather_gov_metar(station): return json.loads(response) -def fetch_openweathermap(api_key, lat, lon, exclude=None): +def fetch_openweathermap(api_key, lat, lon, units="metric", 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) + "lat={}&lon={}&appid={}&units={}&exclude={}".format( + lat, + lon, + api_key, + units, + exclude, + ) ) response = requests.get(url) except Exception as e: diff --git a/aprsd/plugins/location.py b/aprsd/plugins/location.py index 60a5c19..d8bd5d7 100644 --- a/aprsd/plugins/location.py +++ b/aprsd/plugins/location.py @@ -24,53 +24,53 @@ class LocationPlugin(plugin.APRSDPluginBase): return "No aprs.fi apikey found" api_key = self.config["aprs.fi"]["apiKey"] + # optional second argument is a callsign to search + a = re.search(r"^.*\s+(.*)", message) + if a is not None: + searchcall = a.group(1) + searchcall = searchcall.upper() + else: + # if no second argument, search for calling station + searchcall = fromcall + try: - # optional second argument is a callsign to search - a = re.search(r"^.*\s+(.*)", message) - if a is not None: - searchcall = a.group(1) - searchcall = searchcall.upper() - else: - # if no second argument, search for calling station - searchcall = fromcall + 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" - 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)) + if not len(aprs_data["entries"]): + LOG.error("Didn't get any entries from aprs.fi") + 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"] - try: # altitude not always provided - alt = aprs_data["entries"][0]["altitude"] - except Exception: - alt = 0 - altfeet = int(alt * 3.28084) - aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"] - # aprs_lasttime_seconds = aprs_lasttime_seconds.encode( - # "ascii", errors="ignore" - # ) # unicode to ascii - delta_seconds = time.time() - int(aprs_lasttime_seconds) - delta_hours = delta_seconds / 60 / 60 + lat = aprs_data["entries"][0]["lat"] + lon = aprs_data["entries"][0]["lng"] + try: # altitude not always provided + alt = aprs_data["entries"][0]["altitude"] + except Exception: + alt = 0 + altfeet = int(alt * 3.28084) + aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"] + # aprs_lasttime_seconds = aprs_lasttime_seconds.encode( + # "ascii", errors="ignore" + # ) # unicode to ascii + delta_seconds = time.time() - int(aprs_lasttime_seconds) + delta_hours = delta_seconds / 60 / 60 - 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" + 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": "Unknown Location"}} - reply = "{}: {} {}' {},{} {}h ago".format( - searchcall, - wx_data["location"]["areaDescription"], - str(altfeet), - str(lat), - str(lon), - str("%.1f" % round(delta_hours, 1)), - ).rstrip() - except Exception as e: - LOG.debug("Locate failed with: " + "%s" % str(e)) - reply = "Unable to find station " + searchcall + ". Sending beacons?" + reply = "{}: {} {}' {},{} {}h ago".format( + searchcall, + wx_data["location"]["areaDescription"], + str(altfeet), + str(lat), + str(lon), + str("%.1f" % round(delta_hours, 1)), + ).rstrip() return reply diff --git a/aprsd/plugins/weather.py b/aprsd/plugins/weather.py index 6d0d601..048ca6d 100644 --- a/aprsd/plugins/weather.py +++ b/aprsd/plugins/weather.py @@ -2,14 +2,24 @@ import json import logging import re -from aprsd import plugin +from aprsd import plugin, plugin_utils, utils import requests LOG = logging.getLogger("APRSD") -class WeatherPlugin(plugin.APRSDPluginBase): - """Weather Command""" +class USWeatherPlugin(plugin.APRSDPluginBase): + """USWeather Command + + Returns a weather report for the calling weather station + inside the United States only. This uses the + forecast.weather.gov API to fetch the weather. + + This service does not require an apiKey. + + How to Call: Send a message to aprsd + "weather" - returns weather near the calling callsign + """ version = "1.0" command_regex = "^[wW]" @@ -19,91 +29,54 @@ class WeatherPlugin(plugin.APRSDPluginBase): LOG.info("Weather Plugin") api_key = self.config["aprs.fi"]["apiKey"] try: - url = ( - "http://api.aprs.fi/api/get?" - "&what=loc&apikey={}&format=json" - "&name={}".format(api_key, fromcall) - ) - response = requests.get(url) - # aprs_data = json.loads(response.read()) - aprs_data = json.loads(response.text) - lat = aprs_data["entries"][0]["lat"] - lon = aprs_data["entries"][0]["lng"] - url2 = ( - "https://forecast.weather.gov/MapClick.php?lat=%s" - "&lon=%s&FcstType=json" % (lat, lon) - ) - response2 = requests.get(url2) - # wx_data = json.loads(response2.read()) - wx_data = json.loads(response2.text) - reply = ( - "%sF(%sF/%sF) %s. %s, %s." - % ( - wx_data["currentobservation"]["Temp"], - wx_data["data"]["temperature"][0], - wx_data["data"]["temperature"][1], - wx_data["data"]["weather"][0], - wx_data["time"]["startPeriodName"][1], - wx_data["data"]["weather"][1], - ) - ).rstrip() - LOG.debug("reply: '{}' ".format(reply)) - except Exception as e: - LOG.debug("Weather failed with: " + "%s" % str(e)) - reply = "Unable to find you (send beacon?)" + 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: + 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)) + return "Unable to get weather" + + reply = ( + "%sF(%sF/%sF) %s. %s, %s." + % ( + wx_data["currentobservation"]["Temp"], + wx_data["data"]["temperature"][0], + wx_data["data"]["temperature"][1], + wx_data["data"]["weather"][0], + wx_data["time"]["startPeriodName"][1], + wx_data["data"]["weather"][1], + ) + ).rstrip() + LOG.debug("reply: '{}' ".format(reply)) return reply -class WxPlugin(plugin.APRSDPluginBase): - """METAR Command""" +class USMetarPlugin(plugin.APRSDPluginBase): + """METAR Command + + This provides a METAR weather report from a station near the caller + or callsign using the forecast.weather.gov api. This only works + for stations inside the United States. + + This service does not require an apiKey. + + How to Call: Send a message to aprsd + "metar" - returns metar report near the calling callsign + "metar CALLSIGN" - returns metar report near CALLSIGN + + """ version = "1.0" - command_regex = "^[wx]" - command_name = "wx (Metar)" - - def get_aprs(self, fromcall): - LOG.debug("Fetch aprs.fi location for '{}'".format(fromcall)) - api_key = self.config["aprs.fi"]["apiKey"] - try: - url = ( - "http://api.aprs.fi/api/get?" - "&what=loc&apikey={}&format=json" - "&name={}".format(api_key, fromcall) - ) - response = requests.get(url) - except Exception: - raise Exception("Failed to get aprs.fi location") - else: - response.raise_for_status() - return response - - def get_station(self, 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) - ) - response = requests.get(url2) - except Exception: - raise Exception("Failed to get metar station") - else: - response.raise_for_status() - return response - - def get_metar(self, 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 response + command_regex = "^[metar]" + command_name = "Metar" def command(self, fromcall, message, ack): LOG.info("WX Plugin '{}'".format(message)) @@ -112,7 +85,7 @@ class WxPlugin(plugin.APRSDPluginBase): searchcall = a.group(1) station = searchcall.upper() try: - resp = self.get_metar(station) + resp = plugin_utils.get_weather_gov_metar(station) except Exception as e: LOG.debug("Weather failed with: {}".format(str(e))) reply = "Unable to find station METAR" @@ -124,36 +97,254 @@ class WxPlugin(plugin.APRSDPluginBase): else: # if no second argument, search for calling station fromcall = fromcall - try: - resp = self.get_aprs(fromcall) - except Exception as e: - LOG.debug("Weather failed with: {}".format(str(e))) - reply = "Unable to find you (send beacon?)" - else: - aprs_data = json.loads(resp.text) - lat = aprs_data["entries"][0]["lat"] - lon = aprs_data["entries"][0]["lng"] + 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)) + if not len(aprs_data["entries"]): + LOG.error("Found no entries from aprs.fi!") + return "Failed to fetch location" + + lat = aprs_data["entries"][0]["lat"] + lon = aprs_data["entries"][0]["lng"] + + 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)) + return "Unable to metar find station." + + if wx_data["location"]["metar"]: + station = wx_data["location"]["metar"] try: - resp = self.get_station(lat, lon) + resp = plugin_utils.get_weather_gov_metar(station) except Exception as e: LOG.debug("Weather failed with: {}".format(str(e))) - reply = "Unable to find you (send beacon?)" + reply = "Failed to get Metar" else: - wx_data = json.loads(resp.text) - - if wx_data["location"]["metar"]: - station = wx_data["location"]["metar"] - try: - resp = self.get_metar(station) - except Exception as e: - LOG.debug("Weather failed with: {}".format(str(e))) - reply = "Failed to get Metar" - else: - station_data = json.loads(resp.text) - reply = station_data["properties"]["rawMessage"] - else: - # Couldn't find a station - reply = "No Metar station found" + station_data = json.loads(resp.text) + reply = station_data["properties"]["rawMessage"] + else: + # Couldn't find a station + reply = "No Metar station found" return reply + + +class OWMWeatherPlugin(plugin.APRSDPluginBase): + """OpenWeatherMap Weather Command + + This provides weather near the caller or callsign. + + How to Call: Send a message to aprsd + "weather" - returns the weather near the calling callsign + "weather CALLSIGN" - returns the weather near CALLSIGN + + This plugin uses the openweathermap API to fetch + location and weather information. + + To use this plugin you need to get an openweathermap + account and apikey. + + https://home.openweathermap.org/api_keys + + """ + + version = "1.0" + command_regex = "^[wW]" + command_name = "Weather" + + def command(self, fromcall, message, ack): + LOG.info("OWMWeather Plugin '{}'".format(message)) + a = re.search(r"^.*\s+(.*)", message) + if a is not None: + searchcall = a.group(1) + searchcall = searchcall.upper() + else: + searchcall = fromcall + + api_key = self.config["aprs.fi"]["apiKey"] + try: + aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall) + 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)) + if not len(aprs_data["entries"]): + LOG.error("Found no entries from aprs.fi!") + return "Failed to fetch location" + + 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" + + try: + utils.check_config_option(self.config, "aprsd", "units") + except Exception: + LOG.debug("Couldn't find untis in aprsd:services:units") + units = "metric" + else: + units = self.config["aprsd"]["units"] + + api_key = self.config["openweathermap"]["apiKey"] + try: + wx_data = plugin_utils.fetch_openweathermap( + api_key, + lat, + lon, + units=units, + exclude="minutely,hourly", + ) + except Exception as ex: + LOG.error("Couldn't fetch openweathermap api '{}'".format(ex)) + # default to UTC + return "Unable to get weather" + + if units == "metric": + degree = "C" + else: + degree = "F" + + if "wind_gust" in wx_data["current"]: + wind = "{:.0f}@{}G{:.0f}".format( + wx_data["current"]["wind_speed"], + wx_data["current"]["wind_deg"], + wx_data["current"]["wind_gust"], + ) + else: + wind = "{:.0f}@{}".format( + wx_data["current"]["wind_speed"], + wx_data["current"]["wind_deg"], + ) + + # LOG.debug(wx_data["current"]) + # LOG.debug(wx_data["daily"]) + reply = "{} {:.1f}{}/{:.1f}{} Wind {} {}%".format( + wx_data["current"]["weather"][0]["description"], + wx_data["current"]["temp"], + degree, + wx_data["current"]["dew_point"], + degree, + wind, + wx_data["current"]["humidity"], + ) + + return reply + + +class AVWXWeatherPlugin(plugin.APRSDPluginBase): + """AVWXWeatherMap Weather Command + + Fetches a METAR weather report for the nearest + weather station from the callsign + Can be called with: + metar - fetches metar for caller + metar - fetches metar for + + This plugin requires the avwx-api service + to provide the metar for a station near + the callsign. + + avwx-api is an opensource project that has + a hosted service here: https://avwx.rest/ + + You can launch your own avwx-api in a container + by cloning the githug repo here: https://github.com/avwx-rest/AVWX-API + + Then build the docker container with: + docker build -f Dockerfile -t avwx-api:master . + """ + + version = "1.0" + command_regex = "^[metar]" + command_name = "Weather" + + def command(self, fromcall, message, ack): + LOG.info("OWMWeather Plugin '{}'".format(message)) + a = re.search(r"^.*\s+(.*)", message) + if a is not None: + searchcall = a.group(1) + searchcall = searchcall.upper() + else: + searchcall = fromcall + + api_key = self.config["aprs.fi"]["apiKey"] + try: + aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall) + 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)) + if not len(aprs_data["entries"]): + LOG.error("Found no entries from aprs.fi!") + return "Failed to fetch location" + + lat = aprs_data["entries"][0]["lat"] + lon = aprs_data["entries"][0]["lng"] + + try: + utils.check_config_option(self.config, "avwx", "apiKey") + except Exception as ex: + LOG.error("Failed to find config avwx:apiKey {}".format(ex)) + return "No avwx apiKey found" + + try: + utils.check_config_option(self.config, "avwx", "base_url") + except Exception as ex: + LOG.debut("Didn't find avwx:base_url {}".format(ex)) + base_url = "https://avwx.rest" + else: + base_url = self.config["avwx"]["base_url"] + + api_key = self.config["avwx"]["apiKey"] + token = "TOKEN {}".format(api_key) + headers = {"Authorization": token} + try: + coord = "{},{}".format(lat, lon) + url = ( + "{}/api/station/near/{}?" + "n=1&airport=false&reporting=true&format=json".format(base_url, coord) + ) + + LOG.debug("Get stations near me '{}'".format(url)) + response = requests.get(url, headers=headers) + except Exception as ex: + LOG.error(ex) + raise Exception("Failed to get the weather '{}'".format(ex)) + else: + wx_data = json.loads(response.text) + + # LOG.debug(wx_data) + station = wx_data[0]["station"]["icao"] + + try: + url = ( + "{}/api/metar/{}?options=info,translate,summary" + "&airport=true&reporting=true&format=json&onfail=cache".format( + base_url, + station, + ) + ) + + LOG.debug("Get METAR '{}'".format(url)) + response = requests.get(url, headers=headers) + except Exception as ex: + LOG.error(ex) + raise Exception("Failed to get metar {}".format(ex)) + else: + metar_data = json.loads(response.text) + + # LOG.debug(metar_data) + return metar_data["raw"] diff --git a/aprsd/utils.py b/aprsd/utils.py index c0e78c2..d088657 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -24,6 +24,7 @@ DEFAULT_CONFIG_DICT = { "aprs.fi": {"apiKey": "set me"}, "openweathermap": {"apiKey": "set me"}, "opencagedata": {"apiKey": "set me"}, + "avwx": {"base_url": "http://host:port", "apiKey": "set me"}, "shortcuts": { "aa": "5551239999@vtext.com", "cl": "craiglamparter@somedomain.org", @@ -46,6 +47,7 @@ DEFAULT_CONFIG_DICT = { "aprsd": { "plugin_dir": "~/.config/aprsd/plugins", "enabled_plugins": plugin.CORE_PLUGINS, + "units": "imperial", }, } @@ -109,7 +111,8 @@ def add_config_comments(raw_yaml): # lets insert a comment raw_yaml = insert_str( raw_yaml, - "\n # Get the apiKey from your aprs.fi account here: http://aprs.fi/account", + "\n # Get the apiKey from your aprs.fi account here: " + "\n # http://aprs.fi/account", end_idx, ) @@ -118,7 +121,9 @@ def add_config_comments(raw_yaml): # 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", + "\n # (Optional for TimeOpenCageDataPlugin) " + "\n # Get the apiKey from your opencagedata account here: " + "\n # https://opencagedata.com/dashboard#api-keys", end_idx, ) @@ -127,7 +132,22 @@ def add_config_comments(raw_yaml): # 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", + "\n # (Optional for OWMWeatherPlugin) " + "\n # Get the apiKey from your " + "\n # openweathermap account here: " + "\n # https://home.openweathermap.org/api_keys", + end_idx, + ) + + end_idx = end_substr(raw_yaml, "avwx:") + if end_idx != -1: + # lets insert a comment + raw_yaml = insert_str( + raw_yaml, + "\n # (Optional for AVWXWeatherPlugin) " + "\n # Use hosted avwx-api here: https://avwx.rest " + "\n # or deploy your own from here: " + "\n # https://github.com/avwx-rest/avwx-api", end_idx, ) diff --git a/docs/apidoc/aprsd.rst b/docs/apidoc/aprsd.rst index 2056c90..d3c74fa 100644 --- a/docs/apidoc/aprsd.rst +++ b/docs/apidoc/aprsd.rst @@ -20,6 +20,14 @@ aprsd.client module :undoc-members: :show-inheritance: +aprsd.dev module +---------------- + +.. automodule:: aprsd.dev + :members: + :undoc-members: + :show-inheritance: + aprsd.email module ------------------ @@ -68,6 +76,14 @@ aprsd.plugin module :undoc-members: :show-inheritance: +aprsd.plugin\_utils module +-------------------------- + +.. automodule:: aprsd.plugin_utils + :members: + :undoc-members: + :show-inheritance: + aprsd.threads module --------------------