diff --git a/aprsd/dev.py b/aprsd/dev.py index ea3b318..1d04cb5 100644 --- a/aprsd/dev.py +++ b/aprsd/dev.py @@ -11,7 +11,7 @@ import sys # local imports here import aprsd -from aprsd import client, email, plugin, utils +from aprsd import client, email, plugin, service, utils import click import click_completion @@ -185,6 +185,7 @@ def test_plugin( message = " ".join(message) LOG.info("P'{}' F'{}' C'{}'".format(plugin_path, fromcall, message)) client.Client(config) + service.WeatherService(config) pm = plugin.PluginManager(config) obj = pm._create_class(plugin_path, plugin.APRSDPluginBase, config=config) diff --git a/aprsd/main.py b/aprsd/main.py index 0f19016..aa098ed 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -32,7 +32,7 @@ import time # local imports here import aprsd -from aprsd import client, email, messaging, plugin, threads, utils +from aprsd import client, email, messaging, plugin, service, threads, utils import aprslib from aprslib.exceptions import LoginError import click @@ -443,6 +443,9 @@ def server( LOG.debug("Loading saved MsgTrack object.") messaging.MsgTrack().load() + LOG.info("Loading weather service") + service.WeatherService(config) + rx_msg_queue = queue.Queue(maxsize=20) tx_msg_queue = queue.Queue(maxsize=20) msg_queues = {"rx": rx_msg_queue, "tx": tx_msg_queue} diff --git a/aprsd/plugin.py b/aprsd/plugin.py index 4b3aff3..5746f9c 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -60,7 +60,9 @@ class APRSDPluginBase(metaclass=abc.ABCMeta): @hookimpl def run(self, fromcall, message, ack): + LOG.debug("F({}) M({})".format(fromcall, message)) if re.search(self.command_regex, message): + LOG.debug("call command F{} M{}".format(fromcall, message)) return self.command(fromcall, message, ack) @abc.abstractmethod diff --git a/aprsd/plugin_utils.py b/aprsd/plugin_utils.py new file mode 100644 index 0000000..21fc304 --- /dev/null +++ b/aprsd/plugin_utils.py @@ -0,0 +1,53 @@ +# Utilities for plugins to use +import logging + +import requests + +LOG = logging.getLogger("APRSD") + + +def get_aprs_fi(api_key, callsign): + LOG.info("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 response + + +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 response + + +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 response diff --git a/aprsd/plugins/weather.py b/aprsd/plugins/weather.py index 6d0d601..e046db7 100644 --- a/aprsd/plugins/weather.py +++ b/aprsd/plugins/weather.py @@ -2,8 +2,7 @@ import json import logging import re -from aprsd import plugin -import requests +from aprsd import plugin, plugin_utils, service LOG = logging.getLogger("APRSD") @@ -18,101 +17,54 @@ class WeatherPlugin(plugin.APRSDPluginBase): def command(self, fromcall, message, ack): LOG.info("Weather Plugin") api_key = self.config["aprs.fi"]["apiKey"] + + # Fetching weather for someone else? + a = re.search(r"^.*\s+(.*)", message) + if a is not None: + searchcall = a.group(1) + else: + searchcall = fromcall + 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) + resp = plugin_utils.get_aprs_fi(api_key, searchcall) + 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"] - 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?)" - return reply + try: + wx_service = service.WeatherService(self.config) + reply = wx_service.forecast_short(lat, lon) + # resp = plugin_utils.get_weather_gov_for_gps(lat, lon) + except Exception as e: + LOG.debug("Weather failed with: {}".format(str(e))) + return "Unable to Lookup weather" + else: + # wx_data = json.loads(resp.text) + + LOG.debug("reply: '{}' ".format(reply)) + return reply -class WxPlugin(plugin.APRSDPluginBase): +class WxPlugin(WeatherPlugin): """METAR Command""" version = "1.0" - command_regex = "^[wx]" + command_regex = "^[mx]" 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 - def command(self, fromcall, message, ack): LOG.info("WX Plugin '{}'".format(message)) + api_key = self.config["aprs.fi"]["apiKey"] a = re.search(r"^.*\s+(.*)", message) if a is not None: 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" @@ -125,7 +77,7 @@ class WxPlugin(plugin.APRSDPluginBase): # if no second argument, search for calling station fromcall = fromcall try: - resp = self.get_aprs(fromcall) + resp = plugin_utils.get_aprs_fi(api_key, fromcall) except Exception as e: LOG.debug("Weather failed with: {}".format(str(e))) reply = "Unable to find you (send beacon?)" @@ -135,7 +87,7 @@ class WxPlugin(plugin.APRSDPluginBase): lon = aprs_data["entries"][0]["lng"] try: - resp = self.get_station(lat, lon) + resp = self.get_weather_gov_for_gps(lat, lon) except Exception as e: LOG.debug("Weather failed with: {}".format(str(e))) reply = "Unable to find you (send beacon?)" diff --git a/aprsd/service.py b/aprsd/service.py new file mode 100644 index 0000000..3ec3b5e --- /dev/null +++ b/aprsd/service.py @@ -0,0 +1,52 @@ +# Base services class +# this is the service mechanism used to manage +# weather and location services from the config. +# There are many weather and location services +# that we could support. +import abc +import logging + +from aprsd import utils, weather + +LOG = logging.getLogger("APRSD") + + +class APRSDService(metaclass=abc.ABCMeta): + + config = None + + def __init__(self, config): + LOG.debug("Service set config") + self.config = config + self.load() + + @abc.abstractmethod + def load(self): + """Load and configure the service""" + pass + + +class WeatherService(APRSDService): + _instance = None + wx = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + # Put any init here + return cls._instance + + def load(self): + """Load the correct weather """ + wx_shortcut = self.config["aprsd"]["services"].get( + "weather", + weather.DEFAULT_PROVIDER, + ) + wx_class = weather.PROVIDER_MAPPING[wx_shortcut] + self.wx = utils.create_class(wx_class, weather.APRSDWeather, config=self.config) + + def forecast_short(self, lat, lon): + return self.wx.forecast_short(lat, lon) + + def forecast_raw(self, lat, lon): + return self.wx.forecast_raw(lat, lon) diff --git a/aprsd/utils.py b/aprsd/utils.py index 5cbaec4..1e1c49a 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -2,12 +2,14 @@ import errno import functools +import importlib +import logging import os from pathlib import Path import sys import threading -from aprsd import plugin +from aprsd import plugin, weather import click import yaml @@ -44,6 +46,9 @@ DEFAULT_CONFIG_DICT = { "aprsd": { "plugin_dir": "~/.config/aprsd/plugins", "enabled_plugins": plugin.CORE_PLUGINS, + "services": { + "weather": weather.PROVIDER_MAPPING, + }, }, } @@ -52,6 +57,8 @@ DEFAULT_CONFIG_DIR = "{}/.config/aprsd/".format(home) DEFAULT_SAVE_FILE = "{}/.config/aprsd/aprsd.p".format(home) DEFAULT_CONFIG_FILE = "{}/.config/aprsd/aprsd.yml".format(home) +LOG = logging.getLogger("APRSD") + def synchronized(wrapped): lock = threading.Lock() @@ -222,3 +229,35 @@ def parse_config(config_file): check_option(config, "smtp", "password") return config + + +def create_class(module_class_string, super_cls: type = None, **kwargs): + """ + Method to create a class from a fqn python string. + :param module_class_string: full name of the class to create an object of + :param super_cls: expected super class for validity, None if bypass + :param kwargs: parameters to pass + :return: + """ + module_name, class_name = module_class_string.rsplit(".", 1) + try: + module = importlib.import_module(module_name) + except Exception as ex: + LOG.error("Failed to load Plugin '{}' : '{}'".format(module_name, ex)) + return + + assert hasattr(module, class_name), "class {} is not in {}".format( + class_name, + module_name, + ) + # click.echo('reading class {} from module {}'.format( + # class_name, module_name)) + cls = getattr(module, class_name) + if super_cls is not None: + assert issubclass(cls, super_cls), "class {} should inherit from {}".format( + class_name, + super_cls.__name__, + ) + # click.echo('initialising {} with params {}'.format(class_name, kwargs)) + obj = cls(**kwargs) + return obj diff --git a/aprsd/weather.py b/aprsd/weather.py new file mode 100644 index 0000000..e04b8ad --- /dev/null +++ b/aprsd/weather.py @@ -0,0 +1,66 @@ +import abc +import json +import logging + +import requests + +LOG = logging.getLogger("APRSD") + +DEFAULT_PROVIDER = "us-gov" +PROVIDER_MAPPING = { + "us-gov": "aprsd.weather.USWeatherGov", +} + + +class APRSDWeather(metaclass=abc.ABCMeta): + confg = None + + def __init__(self, config): + self.config = config + + @abc.abstractmethod + def forecast_raw(self, lat, lon): + """Get a raw forecast json for latitude, longitude. + + The format of the json response is entirely + depentent on the service itself. + """ + pass + + @abc.abstractmethod + def forecast_short(self, lat, lon): + """Get a short form forecast for latitude, longitude.""" + pass + + +class USWeatherGov(APRSDWeather): + def forecast_raw(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) + ) + 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 forecast_short(self, lat, lon): + """Return a short string for the forecast.""" + wx_data = self.forecast_raw(lat, lon) + reply = ( + "{}F({}F/{}F) {}. {}, {}.".format( + 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() + return reply