mirror of
https://github.com/craigerl/aprsd.git
synced 2024-11-17 13:51:54 -05:00
Added new time plugins
This patch adds 2 new time plugins to allow admins to use their opencagedata APIkey or openweathermap API key to fetch the timezone from the lat/lon GPS coordinates for the callsign requesting the time. This will enable fetching the time local to the ham radio's last beacon, and not time local to the aprsd server instance running. If the location is not found, then the timezone will default to UTC. The 2 new plugins are - aprsd.plugins.time.TimeOpenCageDataPlugin Fetches timezone from lat/lon using the opencagedata api that can be found here: https://opencagedata.com/dashboard#api-keys This requires a new ~/.config/aprsd/aprsd.yml entry to specify the api key. opencagedata: apiKey: <the api key hash here> - aprsd.plugins.time.TimeOWMPlugin Fetches the timezone from lat/lon using the openweathermap api that can be found here: https://home.openweathermap.org/api_keys This requires a new ~/.config/aprsd/aprsd.yml entry to specify the api key. openweathermap: apiKey: <the api key hash here>
This commit is contained in:
parent
fdd5a6ba41
commit
fc3a747aa4
@ -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
|
||||
|
@ -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
|
||||
|
72
aprsd/plugin_utils.py
Normal file
72
aprsd/plugin_utils.py
Normal file
@ -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)
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -11,3 +11,4 @@ aprslib
|
||||
py3-validate-email
|
||||
pre-commit
|
||||
pytz
|
||||
opencage
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user