From c20705f426e0f22677287fc50533be69fca31ff3 Mon Sep 17 00:00:00 2001 From: Hemna Date: Mon, 18 Jan 2021 16:54:35 -0500 Subject: [PATCH] Added basic service abstraction for weather Since there are many weather services that provide an API for fetching weather, and some don't work in other countries, this patch adds a new service abstraction for weather. the user configures which weather service they want to use in the config file, then that service is loaded at start time, and the weather plugin uses the WeatherService object to fetch the weather for a lat,lon combo. The WeatherService itself calls the configured service object that fetches and returns the weather. There is only 1 weather service in this patch, which is the same as it used to be. calling forecast.weather.gov, which is a US government API. --- aprsd/dev.py | 3 +- aprsd/main.py | 5 +- aprsd/plugin.py | 2 + aprsd/plugin_utils.py | 53 ++++++++++++++++++ aprsd/plugins/weather.py | 114 ++++++++++++--------------------------- aprsd/service.py | 52 ++++++++++++++++++ aprsd/utils.py | 41 +++++++++++++- aprsd/weather.py | 66 +++++++++++++++++++++++ 8 files changed, 252 insertions(+), 84 deletions(-) create mode 100644 aprsd/plugin_utils.py create mode 100644 aprsd/service.py create mode 100644 aprsd/weather.py 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