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.
This commit is contained in:
Hemna 2021-01-18 16:54:35 -05:00
parent ca05676c98
commit c20705f426
8 changed files with 252 additions and 84 deletions

View File

@ -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)

View File

@ -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}

View File

@ -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

53
aprsd/plugin_utils.py Normal file
View File

@ -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

View File

@ -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?)"

52
aprsd/service.py Normal file
View File

@ -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)

View File

@ -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

66
aprsd/weather.py Normal file
View File

@ -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