1
0
mirror of https://github.com/craigerl/aprsd.git synced 2026-08-16 00:23:34 -04:00

Compare commits

..

53 Commits

Author SHA1 Message Date
hemna a266c987fd 1.6.0 release prep 2021-02-12 19:46:31 -05:00
hemna e2d8cc60e5 Merge pull request #49 from craigerl/dependabot/pip/cryptography-3.3.2
Bump cryptography from 3.3.1 to 3.3.2
2021-02-12 14:10:04 -05:00
hemna 3a6316fa8a Merge pull request #47 from craigerl/stabilize_1_6_0
Branch to stabilize for the 1.6.0 release.
2021-02-12 14:09:48 -05:00
hemna 7df6462d91 Updated path of run.sh for docker build 2021-02-10 12:04:24 -05:00
hemna 24edcad60a Moved docker related stuffs to docker dir 2021-02-10 11:58:02 -05:00
hemna 9ba44a076c Removed some noisy debug log.
Use the tracing instead to enable the debugging of
email calls
2021-02-10 10:37:39 -05:00
dependabot[bot] afc53624e1 Bump cryptography from 3.3.1 to 3.3.2
Bumps [cryptography](https://github.com/pyca/cryptography) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/pyca/cryptography/releases)
- [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/3.3.1...3.3.2)

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-10 02:38:28 +00:00
hemna 131919bdfb Wrap another server call with try except
Dreamhost email is total garbage.  Stop using it.
2021-02-05 15:32:36 -05:00
hemna d0d0aef077 Merge pull request #48 from craigerl/dependabot/pip/bleach-3.3.0
Bump bleach from 3.2.1 to 3.3.0
2021-02-03 11:12:04 -05:00
hemna a5cc274ff5 Wrap all imap calls with try except blocks
The Email Thread has been unstable due to some IMAP servers
being crap.  This patch wraps more of the imap server calls
in try except blocks to try and trap errors.
2021-02-03 11:00:20 -05:00
dependabot[bot] d71937b176 Bump bleach from 3.2.1 to 3.3.0
Bumps [bleach](https://github.com/mozilla/bleach) from 3.2.1 to 3.3.0.
- [Release notes](https://github.com/mozilla/bleach/releases)
- [Changelog](https://github.com/mozilla/bleach/blob/master/CHANGES)
- [Commits](https://github.com/mozilla/bleach/compare/v3.2.1...v3.3.0)

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-02 23:17:59 +00:00
Craig Lamparter 47135c6086 EmailThread was exiting because of IMAP timeout, added exceptions for this 2021-02-02 11:13:17 -08:00
hemna db2b537317 Added memory tracing in keeplive 2021-01-29 11:02:21 -05:00
hemna 0b44fc08eb Fixed tox pep8 failure for trace 2021-01-29 10:15:20 -05:00
hemna af48c43eb2 Added tracing facility
You can enable debug tracing iff loglevel == DEBUG AND
config file has aprsd:trace:True
2021-01-29 10:07:49 -05:00
hemna 94bad95e26 Fixed email login issue.
This patch undoes an overzealous reworking of the
config.  the arps login didn't move.
2021-01-26 13:33:39 -05:00
Craig Lamparter 57d768e010 duplicate email messages from RF would generate usage response 2021-01-26 09:18:43 -08:00
hemna 030b02551f Enable debug logging for smtp and imap
Add the new config options for
aprsd:
  email:
    imap:
      debug: True

    smtp:
      debug: True
2021-01-25 16:16:08 -05:00
Craig Lamparter cfb172481d more debug around email thread 2021-01-25 12:54:24 -08:00
Craig Lamparter 3ca0eeff56 debug around EmailThread hanging or vanishing 2021-01-25 12:24:20 -08:00
hemna c1e6792721 Fixed resend email after config rework
This patch fixes 1 missed access to the shortcuts after
the restructuring of the config file
2021-01-25 15:15:53 -05:00
hemna aa290692ab Added flask messages web UI and basic auth
This patch fixes the CTRL-C signal_handler.
This patch also adds the new Messages WEB UI page
as well as the save url, which are both behind an
http basic auth.

The flask web service now has users in the config file
aprsd:
  web:
    users:
      admin: <password>
2021-01-25 11:24:39 -05:00
hemna 0d18e54969 Fixed an issue with LocationPlugin
When calling LocationPlugin with a callsign outside of the US,
the forecast.weather gov wasn't raising an exception.  A valid json
dict was coming back, but it didn't have location data we were
expecting.
2021-01-22 16:32:49 -05:00
hemna 51894bbab8 Cleaned up the KeepAlive output
This patch cleans up the KeepAlive output a bit.
2021-01-22 16:05:48 -05:00
hemna 8bfdefd5ad updated .gitignore
This patch updates .gitignore to ignore the docs/_build output dir
2021-01-22 15:36:22 -05:00
hemna f5ae161ab8 Merge pull request #46 from craigerl/healthcheck
Added healthcheck app
2021-01-22 14:30:44 -05:00
hemna c870207a96 Added healthcheck app
This patch adds the healthcheck app that uses the flask stats url
to fetch the internal stats of a running aprsd server.  If the server is
up the stats will return and be checked for 'healthy' status.
IF the url fails to return, healthcheck will exit with -1.  You can use
this script to restart aprsd if healthcheck exits with -1 status.

There is a check against the email thread.  The email thread updates a
deadman's timer every 5 seconds.   If that time gets older than 5
minutes, then healthcheck will say that's a failure and exit with -1.

You can call healthcheck and restart aprsd if it fails (exit -1)
2021-01-22 12:51:11 -05:00
hemna f932c203d7 Merge pull request #45 from craigerl/flask
Flask
2021-01-22 08:21:20 -05:00
hemna cae8746690 Add flask and flask_classful reqs 2021-01-21 21:11:41 -05:00
hemna 5c949343ec Added Flask web thread and stats collection
This patch adds the stats object to collect statistics of
the running server.  This also optionally adds the ability
to run a flask web service on a port to use as a keepalive
healthcheck.
2021-01-21 20:58:47 -05:00
hemna 9630279d14 First hack at flask 2021-01-21 15:11:44 -05:00
hemna c686543323 Merge pull request #44 from craigerl/optional_email
Allow email to be disabled.
2021-01-21 13:53:59 -05:00
hemna 982f24c5f5 Allow email to be disabled.
The config file defaults to email being off now.  This requires
the user to set the email settings anyway, so the default is off.
2021-01-21 13:50:19 -05:00
hemna a656508ba6 Merge pull request #43 from craigerl/rework_config
Reworked the config file and options
2021-01-21 13:36:44 -05:00
hemna ce5b09233c Reworked the config file and options
This patch reorganizes the config file layout and options
to make more logical sense as well as make it more readable.

This breaks backwards compatibility.
2021-01-21 13:32:19 -05:00
hemna 2f7c1bfcc1 Merge pull request #41 from craigerl/openweathermap
Added openweathermap weather plugin
2021-01-21 10:10:26 -05:00
hemna a35cb04ca7 Updated documentation and config output
This patch reformats the sample-config output for more
informative comments for the 3 external services:
openweathermap
opencagedata
avwx-api
2021-01-21 10:05:49 -05:00
hemna fefb626c97 Fixed extracting lat/lon
This patch fixes an issue when aprs.fi returns a non error, but
doesn't have any real entries as the response
2021-01-20 19:51:59 -05:00
hemna 2349024539 Added openweathermap weather plugin
This patch adds the openweathermap weather plugin.
Also adds a new config option to set the overall
units setting from imperial (default) to metric.

to change it add the following to the ~/.config/aprsd/aprsd.yaml

...
aprsd:
  units: metric
2021-01-20 16:12:17 -05:00
Craig Lamparter f8c001dc49 Merge pull request #40 from craigerl/new_plugins
Added new time plugins
2021-01-20 08:26:48 -08:00
hemna fc3a747aa4 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>
2021-01-20 10:19:49 -05:00
hemna fdd5a6ba41 Merge pull request #38 from craigerl/timezone-fix
Fixed TimePlugin timezone issue
2021-01-19 11:29:59 -05:00
hemna 9f38fd179e Fixed TimePlugin timezone issue
The existing time plugin had a hard coded PDT for pacific timezone,
when it wasn't.   This patch adds some real timezone conversion from
utc to the tz of the running aprsd server.   This will eventually allow
us to use either the tz of the running aprsd and/or the tz of the
calling callsign if we can just get the tz string from the location
beacon of the caller's callsign.
2021-01-19 11:26:07 -05:00
Craig Lamparter ca05676c98 remove fortune white space 2021-01-17 08:02:45 -08:00
Craig Lamparter 83f42dd7b7 Merge branch 'master' of https://github.com/craigerl/aprsd 2021-01-17 07:57:10 -08:00
Craig Lamparter 5fb363c9e7 fix git with install.txt 2021-01-17 07:56:59 -08:00
Craig Lamparter 7de2820caa change query char from ? to ! 2021-01-17 07:55:59 -08:00
hemna 55360ba5d0 Merge pull request #35 from craigerl/aprsd-dev
Added aprsd-dev plugin test cli and WxPlugin
2021-01-17 08:10:06 -05:00
hemna b9f6fcfa0c Updated readme to include readthedocs link 2021-01-16 10:46:00 -05:00
hemna cc8fd178ce Added aprsd-dev plugin test cli and WxPlugin
This patch adds a new CLI app called aprsd-dev.  arpsd-dev is
used specifically for developing plugins.  It allows you to run a
plugin directly without the need to run aprsd server.

This patch also adds the Weather Metar plugin called WxPlugin.
You can use it to fetch METAR from the nearest station for a callsign
or from a known METAR station id.  Call WxPlugin with a message of
'wx' for closest metar station or 'wx KAUN' for metar at KAUN wx station
2021-01-15 22:30:34 -05:00
hemna 658e6b6202 Updated Changelog for v1.5.1 2021-01-15 13:35:04 -05:00
hemna 98e3c246ad Updated README to fix pypi page
This patch updates the README to fix the overview image for
pypi.  This patch also updates the makefile to force broken
tox -p commands
2021-01-15 13:35:04 -05:00
Craig Lamparter 47ccce53ea Update INSTALL.txt 2021-01-15 09:36:30 -08:00
42 changed files with 2478 additions and 411 deletions
+1
View File
@@ -44,6 +44,7 @@ output/*/index.html
# Sphinx
doc/build
docs/_build
# pbr generates these
AUTHORS
+49
View File
@@ -1,9 +1,58 @@
CHANGES
=======
v1.6.0
------
* Updated path of run.sh for docker build
* Moved docker related stuffs to docker dir
* Removed some noisy debug log
* Bump cryptography from 3.3.1 to 3.3.2
* Wrap another server call with try except
* Wrap all imap calls with try except blocks
* Bump bleach from 3.2.1 to 3.3.0
* EmailThread was exiting because of IMAP timeout, added exceptions for this
* Added memory tracing in keeplive
* Fixed tox pep8 failure for trace
* Added tracing facility
* Fixed email login issue
* duplicate email messages from RF would generate usage response
* Enable debug logging for smtp and imap
* more debug around email thread
* debug around EmailThread hanging or vanishing
* Fixed resend email after config rework
* Added flask messages web UI and basic auth
* Fixed an issue with LocationPlugin
* Cleaned up the KeepAlive output
* updated .gitignore
* Added healthcheck app
* Add flask and flask\_classful reqs
* Added Flask web thread and stats collection
* First hack at flask
* Allow email to be disabled
* Reworked the config file and options
* Updated documentation and config output
* Fixed extracting lat/lon
* Added openweathermap weather plugin
* Added new time plugins
* Fixed TimePlugin timezone issue
* remove fortune white space
* fix git with install.txt
* change query char from ? to !
* Updated readme to include readthedocs link
* Added aprsd-dev plugin test cli and WxPlugin
v1.5.1
------
* Updated Changelog for v1.5.1
* Updated README to fix pypi page
* Update INSTALL.txt
v1.5.0
------
* Updated Changelog for v1.5.0 release
* Fix tox tests
* fix usage statement
* Enabled some emailthread messages and added timestamp
-42
View File
@@ -1,42 +0,0 @@
FROM alpine:latest as aprsd
ENV VERSION=1.0.0
ENV APRS_USER=aprs
ENV HOME=/home/aprs
ENV VIRTUAL_ENV=$HOME/.venv3
ENV INSTALL=$HOME/install
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 addgroup --gid 1000 $APRS_USER
RUN adduser -h $HOME -D -u 1001 -G $APRS_USER $APRS_USER
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
USER $APRS_USER
RUN pip3 install wheel
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN echo "export PATH=\$PATH:\$HOME/.local/bin" >> $HOME/.bashrc
VOLUME ["/config", "/plugins"]
WORKDIR $HOME
RUN pip install aprsd
USER root
RUN aprsd sample-config > /config/aprsd.yml
RUN chown -R $APRS_USER:$APRS_USER /config
# override this to run another configuration
ENV CONF default
USER $APRS_USER
ADD build/bin/run.sh $HOME/
ENTRYPOINT ["/home/aprs/run.sh"]
+4 -4
View File
@@ -13,12 +13,12 @@ cd ~/aprsd2
git clone https://github.com/craigerl/aprsd.git
cd aprsd
pip install -e .
cd ~/.venv_aprsd
cd ~/.venv_aprsd/bin
./bin/aprsd # generates a config.yml template on first run
./aprsd sample-config # generates a config.yml template
vi ~/.aprsd/config.yml
vi ~/.config/aprsd/config.yml # copy/edit config here
./bin/aprsd
./aprsd server
# profit! #
+4 -1
View File
@@ -33,7 +33,7 @@ clean:
test: dev
.venv/bin/pre-commit run --all-files
tox -p
tox -p all
build: test
rm -rf dist/*
@@ -43,6 +43,9 @@ build: test
upload: build
.venv/bin/twine upload dist/*
docker: test
docker build -t hemna6969/aprsd:latest -f docker/Dockerfile docker
update-requirements: dev pip-tools
.venv/bin/pip-compile -q -U requirements.in
.venv/bin/pip-compile -q -U dev-requirements.in
+3 -1
View File
@@ -36,11 +36,13 @@ provide responding to messages to check email, get location, ping,
time of day, get weather, and fortune telling as well as version information
of aprsd itself.
Documentation: https://aprsd.readthedocs.io
APRSD Overview Diagram
----------------------
.. image:: https://raw.githubusercontent.com/craigerl/aprsd/crager-stable/docs/_static/aprsd_overview.svg?sanitize=true
.. image:: https://raw.githubusercontent.com/craigerl/aprsd/master/docs/_static/aprsd_overview.svg?sanitize=true
Typical use case
+197
View File
@@ -0,0 +1,197 @@
#
# Dev.py is used to help develop plugins
#
#
# python included libs
import logging
from logging import NullHandler
from logging.handlers import RotatingFileHandler
import os
import sys
# local imports here
import aprsd
from aprsd import client, email, plugin, utils
import click
import click_completion
# setup the global logger
# logging.basicConfig(level=logging.DEBUG) # level=10
LOG = logging.getLogger("APRSD")
LOG_LEVELS = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
}
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def custom_startswith(string, incomplete):
"""A custom completion match that supports case insensitive matching."""
if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"):
string = string.lower()
incomplete = incomplete.lower()
return string.startswith(incomplete)
click_completion.core.startswith = custom_startswith
click_completion.init()
cmd_help = """Shell completion for click-completion-command
Available shell types:
\b
%s
Default type: auto
""" % "\n ".join(
"{:<12} {}".format(k, click_completion.core.shells[k])
for k in sorted(click_completion.core.shells.keys())
)
@click.group(help=cmd_help, context_settings=CONTEXT_SETTINGS)
@click.version_option()
def main():
pass
@main.command()
@click.option(
"-i",
"--case-insensitive/--no-case-insensitive",
help="Case insensitive completion",
)
@click.argument(
"shell",
required=False,
type=click_completion.DocumentedChoice(click_completion.core.shells),
)
def show(shell, case_insensitive):
"""Show the click-completion-command completion code"""
extra_env = (
{"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"}
if case_insensitive
else {}
)
click.echo(click_completion.core.get_code(shell, extra_env=extra_env))
@main.command()
@click.option(
"--append/--overwrite",
help="Append the completion code to the file",
default=None,
)
@click.option(
"-i",
"--case-insensitive/--no-case-insensitive",
help="Case insensitive completion",
)
@click.argument(
"shell",
required=False,
type=click_completion.DocumentedChoice(click_completion.core.shells),
)
@click.argument("path", required=False)
def install(append, case_insensitive, shell, path):
"""Install the click-completion-command completion"""
extra_env = (
{"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"}
if case_insensitive
else {}
)
shell, path = click_completion.core.install(
shell=shell,
path=path,
append=append,
extra_env=extra_env,
)
click.echo("{} completion installed in {}".format(shell, path))
# Setup the logging faciility
# to disable logging to stdout, but still log to file
# use the --quiet option on the cmdln
def setup_logging(config, loglevel, quiet):
log_level = LOG_LEVELS[loglevel]
LOG.setLevel(log_level)
log_format = "[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s]" " %(message)s"
date_format = "%m/%d/%Y %I:%M:%S %p"
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
log_file = config["aprs"].get("logfile", None)
if log_file:
fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4)
else:
fh = NullHandler()
fh.setFormatter(log_formatter)
LOG.addHandler(fh)
if not quiet:
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(log_formatter)
LOG.addHandler(sh)
@main.command()
@click.option(
"--loglevel",
default="DEBUG",
show_default=True,
type=click.Choice(
["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"],
case_sensitive=False,
),
show_choices=True,
help="The log level to use for aprsd.log",
)
@click.option(
"-c",
"--config",
"config_file",
show_default=True,
default=utils.DEFAULT_CONFIG_FILE,
help="The aprsd config file to use for options.",
)
@click.option(
"-p",
"--plugin",
"plugin_path",
show_default=True,
default="aprsd.plugins.wx.WxPlugin",
help="The plugin to run",
)
@click.argument("fromcall")
@click.argument("message", nargs=-1, required=True)
def test_plugin(
loglevel,
config_file,
plugin_path,
fromcall,
message,
):
"""APRSD Plugin test app."""
config = utils.parse_config(config_file)
email.CONFIG = config
setup_logging(config, loglevel, False)
LOG.info("Test APRSD PLugin version: {}".format(aprsd.__version__))
if type(message) is tuple:
message = " ".join(message)
LOG.info("P'{}' F'{}' C'{}'".format(plugin_path, fromcall, message))
client.Client(config)
pm = plugin.PluginManager(config)
obj = pm._create_class(plugin_path, plugin.APRSDPluginBase, config=config)
reply = obj.run(fromcall, message, 1)
LOG.info("Result = '{}'".format(reply))
if __name__ == "__main__":
main()
+152 -49
View File
@@ -7,7 +7,7 @@ import re
import smtplib
import time
from aprsd import messaging, threads
from aprsd import messaging, stats, threads, trace
import imapclient
from validate_email import validate_email
@@ -17,58 +17,88 @@ LOG = logging.getLogger("APRSD")
CONFIG = None
@trace.trace
def _imap_connect():
imap_port = CONFIG["imap"].get("port", 143)
use_ssl = CONFIG["imap"].get("use_ssl", False)
host = CONFIG["imap"]["host"]
imap_port = CONFIG["aprsd"]["email"]["imap"].get("port", 143)
use_ssl = CONFIG["aprsd"]["email"]["imap"].get("use_ssl", False)
host = CONFIG["aprsd"]["email"]["imap"]["host"]
msg = "{}{}:{}".format("TLS " if use_ssl else "", host, imap_port)
# LOG.debug("Connect to IMAP host {} with user '{}'".
# format(msg, CONFIG['imap']['login']))
try:
server = imapclient.IMAPClient(
CONFIG["imap"]["host"],
CONFIG["aprsd"]["email"]["imap"]["host"],
port=imap_port,
use_uid=True,
ssl=use_ssl,
timeout=30,
)
except Exception:
LOG.error("Failed to connect IMAP server")
except Exception as e:
LOG.error("Failed to connect IMAP server", e)
return
try:
server.login(CONFIG["imap"]["login"], CONFIG["imap"]["password"])
server.login(
CONFIG["aprsd"]["email"]["imap"]["login"],
CONFIG["aprsd"]["email"]["imap"]["password"],
)
except (imaplib.IMAP4.error, Exception) as e:
msg = getattr(e, "message", repr(e))
LOG.error("Failed to login {}".format(msg))
return
server.select_folder("INBOX")
server.fetch = trace.trace(server.fetch)
server.search = trace.trace(server.search)
server.remove_flags = trace.trace(server.remove_flags)
server.add_flags = trace.trace(server.add_flags)
return server
@trace.trace
def _smtp_connect():
host = CONFIG["smtp"]["host"]
smtp_port = CONFIG["smtp"]["port"]
use_ssl = CONFIG["smtp"].get("use_ssl", False)
host = CONFIG["aprsd"]["email"]["smtp"]["host"]
smtp_port = CONFIG["aprsd"]["email"]["smtp"]["port"]
use_ssl = CONFIG["aprsd"]["email"]["smtp"].get("use_ssl", False)
msg = "{}{}:{}".format("SSL " if use_ssl else "", host, smtp_port)
LOG.debug(
"Connect to SMTP host {} with user '{}'".format(msg, CONFIG["imap"]["login"]),
"Connect to SMTP host {} with user '{}'".format(
msg,
CONFIG["aprsd"]["email"]["imap"]["login"],
),
)
try:
if use_ssl:
server = smtplib.SMTP_SSL(host=host, port=smtp_port)
server = smtplib.SMTP_SSL(
host=host,
port=smtp_port,
timeout=30,
)
else:
server = smtplib.SMTP(host=host, port=smtp_port)
server = smtplib.SMTP(
host=host,
port=smtp_port,
timeout=30,
)
except Exception:
LOG.error("Couldn't connect to SMTP Server")
return
LOG.debug("Connected to smtp host {}".format(msg))
debug = CONFIG["aprsd"]["email"]["smtp"].get("debug", False)
if debug:
server.set_debuglevel(5)
server.sendmail = trace.trace(server.sendmail)
try:
server.login(CONFIG["smtp"]["login"], CONFIG["smtp"]["password"])
server.login(
CONFIG["aprsd"]["email"]["smtp"]["login"],
CONFIG["aprsd"]["email"]["smtp"]["password"],
)
except Exception:
LOG.error("Couldn't connect to SMTP Server")
return
@@ -78,7 +108,7 @@ def _smtp_connect():
def validate_shortcuts(config):
shortcuts = config.get("shortcuts", None)
shortcuts = config["aprsd"]["email"].get("shortcuts", None)
if not shortcuts:
return
@@ -93,8 +123,8 @@ def validate_shortcuts(config):
email_address=shortcuts[key],
check_regex=True,
check_mx=False,
from_address=config["smtp"]["login"],
helo_host=config["smtp"]["host"],
from_address=config["aprsd"]["email"]["smtp"]["login"],
helo_host=config["aprsd"]["email"]["smtp"]["host"],
smtp_timeout=10,
dns_timeout=10,
use_blacklist=True,
@@ -109,14 +139,14 @@ def validate_shortcuts(config):
delete_keys.append(key)
for key in delete_keys:
del config["shortcuts"][key]
del config["aprsd"]["email"]["shortcuts"][key]
LOG.info("Available shortcuts: {}".format(config["shortcuts"]))
LOG.info("Available shortcuts: {}".format(config["aprsd"]["email"]["shortcuts"]))
def get_email_from_shortcut(addr):
if CONFIG.get("shortcuts", False):
return CONFIG["shortcuts"].get(addr, addr)
if CONFIG["aprsd"]["email"].get("shortcuts", False):
return CONFIG["aprsd"]["email"]["shortcuts"].get(addr, addr)
else:
return addr
@@ -143,6 +173,7 @@ def validate_email_config(config, disable_validation=False):
return False
@trace.trace
def parse_email(msgid, data, server):
envelope = data[b"ENVELOPE"]
# email address match
@@ -153,7 +184,12 @@ def parse_email(msgid, data, server):
else:
from_addr = "noaddr"
LOG.debug("Got a message from '{}'".format(from_addr))
m = server.fetch([msgid], ["RFC822"])
try:
m = server.fetch([msgid], ["RFC822"])
except Exception as e:
LOG.exception("Couldn't fetch email from server in parse_email", e)
return
msg = email.message_from_string(m[msgid][b"RFC822"].decode(errors="ignore"))
if msg.is_multipart():
text = ""
@@ -229,10 +265,11 @@ def parse_email(msgid, data, server):
# end parse_email
@trace.trace
def send_email(to_addr, content):
global check_email_delay
shortcuts = CONFIG["shortcuts"]
shortcuts = CONFIG["aprsd"]["email"]["shortcuts"]
email_address = get_email_from_shortcut(to_addr)
LOG.info("Sending Email_________________")
@@ -250,12 +287,17 @@ def send_email(to_addr, content):
msg = MIMEText(content)
msg["Subject"] = subject
msg["From"] = CONFIG["smtp"]["login"]
msg["From"] = CONFIG["aprsd"]["email"]["smtp"]["login"]
msg["To"] = to_addr
server = _smtp_connect()
if server:
try:
server.sendmail(CONFIG["smtp"]["login"], [to_addr], msg.as_string())
server.sendmail(
CONFIG["aprsd"]["email"]["smtp"]["login"],
[to_addr],
msg.as_string(),
)
stats.APRSDStats().email_tx_inc()
except Exception as e:
msg = getattr(e, "message", repr(e))
LOG.error("Sendmail Error!!!! '{}'", msg)
@@ -268,6 +310,7 @@ def send_email(to_addr, content):
# end send_email
@trace.trace
def resend_email(count, fromcall):
global check_email_delay
date = datetime.datetime.now()
@@ -276,7 +319,7 @@ def resend_email(count, fromcall):
year = date.year
today = "{}-{}-{}".format(day, month, year)
shortcuts = CONFIG["shortcuts"]
shortcuts = CONFIG["aprsd"]["email"]["shortcuts"]
# swap key/value
shortcuts_inverted = {v: k for k, v in shortcuts.items()}
@@ -286,7 +329,12 @@ def resend_email(count, fromcall):
LOG.exception("Failed to Connect to IMAP. Cannot resend email ", e)
return
messages = server.search(["SINCE", today])
try:
messages = server.search(["SINCE", today])
except Exception as e:
LOG.exception("Couldn't search for emails in resend_email ", e)
return
# LOG.debug("%d messages received today" % len(messages))
msgexists = False
@@ -294,18 +342,32 @@ def resend_email(count, fromcall):
messages.sort(reverse=True)
del messages[int(count) :] # only the latest "count" messages
for message in messages:
for msgid, data in list(server.fetch(message, ["ENVELOPE"]).items()):
try:
parts = server.fetch(message, ["ENVELOPE"]).items()
except Exception as e:
LOG.exception("Couldn't fetch email parts in resend_email", e)
continue
for msgid, data in list(parts):
# one at a time, otherwise order is random
(body, from_addr) = parse_email(msgid, data, server)
# unset seen flag, will stay bold in email client
server.remove_flags(msgid, [imapclient.SEEN])
try:
server.remove_flags(msgid, [imapclient.SEEN])
except Exception as e:
LOG.exception("Failed to remove SEEN flag in resend_email", e)
if from_addr in shortcuts_inverted:
# reverse lookup of a shortcut
from_addr = shortcuts_inverted[from_addr]
# asterisk indicates a resend
reply = "-" + from_addr + " * " + body.decode(errors="ignore")
# messaging.send_message(fromcall, reply)
msg = messaging.TextMessage(CONFIG["aprs"]["login"], fromcall, reply)
msg = messaging.TextMessage(
CONFIG["aprs"]["login"],
fromcall,
reply,
)
msg.send()
msgexists = True
@@ -340,6 +402,7 @@ class APRSDEmailThread(threads.APRSDThread):
self.msg_queues = msg_queues
self.config = config
@trace.trace
def run(self):
global check_email_delay
@@ -349,6 +412,7 @@ class APRSDEmailThread(threads.APRSDThread):
past = datetime.datetime.now()
while not self.thread_stop:
time.sleep(5)
stats.APRSDStats().email_thread_update()
# always sleep for 5 seconds and see if we need to check email
# This allows CTRL-C to stop the execution of this loop sooner
# than check_email_delay time
@@ -362,7 +426,7 @@ class APRSDEmailThread(threads.APRSDThread):
check_email_delay += 1
LOG.debug("check_email_delay is " + str(check_email_delay) + " seconds")
shortcuts = CONFIG["shortcuts"]
shortcuts = CONFIG["aprsd"]["email"]["shortcuts"]
# swap key/value
shortcuts_inverted = {v: k for k, v in shortcuts.items()}
@@ -376,17 +440,30 @@ class APRSDEmailThread(threads.APRSDThread):
try:
server = _imap_connect()
except Exception as e:
LOG.exception("Failed to get IMAP server Can't check email.", e)
LOG.exception("IMAP failed to connect.", e)
if not server:
continue
messages = server.search(["SINCE", today])
try:
messages = server.search(["SINCE", today])
except Exception as e:
LOG.exception("IMAP failed to search for messages since today.", e)
continue
LOG.debug("{} messages received today".format(len(messages)))
for msgid, data in server.fetch(messages, ["ENVELOPE"]).items():
try:
_msgs = server.fetch(messages, ["ENVELOPE"])
except Exception as e:
LOG.exception("IMAP failed to fetch/flag messages: ", e)
continue
for msgid, data in _msgs.items():
envelope = data[b"ENVELOPE"]
# LOG.debug('ID:%d "%s" (%s)' % (msgid, envelope.subject.decode(), envelope.date))
LOG.debug(
'ID:%d "%s" (%s)'
% (msgid, envelope.subject.decode(), envelope.date),
)
f = re.search(
r"'([[A-a][0-9]_-]+@[[A-a][0-9]_-\.]+)",
str(envelope.from_[0]),
@@ -399,16 +476,31 @@ class APRSDEmailThread(threads.APRSDThread):
# LOG.debug("Message flags/tags: " + str(server.get_flags(msgid)[msgid]))
# if "APRS" not in server.get_flags(msgid)[msgid]:
# in python3, imap tags are unicode. in py2 they're strings. so .decode them to handle both
taglist = [
x.decode(errors="ignore")
for x in server.get_flags(msgid)[msgid]
]
try:
taglist = [
x.decode(errors="ignore")
for x in server.get_flags(msgid)[msgid]
]
except Exception as e:
LOG.exception("Failed to get flags.", e)
break
if "APRS" not in taglist:
# if msg not flagged as sent via aprs
server.fetch([msgid], ["RFC822"])
try:
server.fetch([msgid], ["RFC822"])
except Exception as e:
LOG.exception("Failed single server fetch for RFC822", e)
break
(body, from_addr) = parse_email(msgid, data, server)
# unset seen flag, will stay bold in email client
server.remove_flags(msgid, [imapclient.SEEN])
try:
server.remove_flags(msgid, [imapclient.SEEN])
except Exception as e:
LOG.exception("Failed to remove flags SEEN", e)
# Not much we can do here, so lets try and
# send the aprs message anyway
if from_addr in shortcuts_inverted:
# reverse lookup of a shortcut
@@ -422,14 +514,28 @@ class APRSDEmailThread(threads.APRSDThread):
)
self.msg_queues["tx"].put(msg)
# flag message as sent via aprs
server.add_flags(msgid, ["APRS"])
# unset seen flag, will stay bold in email client
server.remove_flags(msgid, [imapclient.SEEN])
try:
server.add_flags(msgid, ["APRS"])
# unset seen flag, will stay bold in email client
except Exception as e:
LOG.exception("Couldn't add APRS flag to email", e)
try:
server.remove_flags(msgid, [imapclient.SEEN])
except Exception as e:
LOG.exception("Couldn't remove seen flag from email", e)
# check email more often since we just received an email
check_email_delay = 60
# reset clock
LOG.debug("Done looping over Server.fetch, logging out.")
past = datetime.datetime.now()
server.logout()
try:
server.logout()
except Exception as e:
LOG.exception("IMAP failed to logout: ", e)
continue
else:
# We haven't hit the email delay yet.
# LOG.debug("Delta({}) < {}".format(now - past, check_email_delay))
@@ -438,6 +544,3 @@ class APRSDEmailThread(threads.APRSDThread):
# Remove ourselves from the global threads list
threads.APRSDThreadList().remove(self)
LOG.info("Exiting")
# end check_email()
+89
View File
@@ -0,0 +1,89 @@
import json
import logging
import aprsd
from aprsd import messaging, stats
import flask
import flask_classful
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import check_password_hash, generate_password_hash
LOG = logging.getLogger("APRSD")
auth = HTTPBasicAuth()
users = None
# HTTPBasicAuth doesn't work on a class method.
# This has to be out here. Rely on the APRSDFlask
# class to initialize the users from the config
@auth.verify_password
def verify_password(username, password):
global users
if username in users and check_password_hash(users.get(username), password):
return username
class APRSDFlask(flask_classful.FlaskView):
config = None
def set_config(self, config):
global users
self.config = config
self.users = {}
for user in self.config["aprsd"]["web"]["users"]:
self.users[user] = generate_password_hash(
self.config["aprsd"]["web"]["users"][user],
)
users = self.users
def index(self):
return "Hello"
# return flask.render_template("index.html", message=msg)
@auth.login_required
def messages(self):
track = messaging.MsgTrack()
msgs = []
for id in track:
LOG.info(track[id].dict())
msgs.append(track[id].dict())
return flask.render_template("messages.html", messages=json.dumps(msgs))
@auth.login_required
def save(self):
"""Save the existing queue to disk."""
track = messaging.MsgTrack()
track.save()
return json.dumps({"messages": "saved"})
def stats(self):
stats_obj = stats.APRSDStats()
track = messaging.MsgTrack()
result = {
"version": aprsd.__version__,
"uptime": stats_obj.uptime,
"size_tracker": len(track),
"stats": stats_obj.stats(),
}
return json.dumps(result)
def init_flask(config):
flask_app = flask.Flask(
"aprsd",
static_url_path="",
static_folder="web/static",
template_folder="web/templates",
)
server = APRSDFlask()
server.set_config(config)
# flask_app.route('/', methods=['GET'])(server.index)
flask_app.route("/stats", methods=["GET"])(server.stats)
flask_app.route("/messages", methods=["GET"])(server.messages)
flask_app.route("/save", methods=["GET"])(server.save)
return flask_app
+222
View File
@@ -0,0 +1,222 @@
#
# Used to fetch the stats url and determine if
# aprsd server is 'healthy'
#
#
# python included libs
import datetime
import json
import logging
from logging import NullHandler
from logging.handlers import RotatingFileHandler
import os
import re
import sys
# local imports here
import aprsd
from aprsd import utils
import click
import click_completion
import requests
# setup the global logger
# logging.basicConfig(level=logging.DEBUG) # level=10
LOG = logging.getLogger("APRSD")
LOG_LEVELS = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
}
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def custom_startswith(string, incomplete):
"""A custom completion match that supports case insensitive matching."""
if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"):
string = string.lower()
incomplete = incomplete.lower()
return string.startswith(incomplete)
click_completion.core.startswith = custom_startswith
click_completion.init()
cmd_help = """Shell completion for click-completion-command
Available shell types:
\b
%s
Default type: auto
""" % "\n ".join(
"{:<12} {}".format(k, click_completion.core.shells[k])
for k in sorted(click_completion.core.shells.keys())
)
@click.group(help=cmd_help, context_settings=CONTEXT_SETTINGS)
@click.version_option()
def main():
pass
@main.command()
@click.option(
"-i",
"--case-insensitive/--no-case-insensitive",
help="Case insensitive completion",
)
@click.argument(
"shell",
required=False,
type=click_completion.DocumentedChoice(click_completion.core.shells),
)
def show(shell, case_insensitive):
"""Show the click-completion-command completion code"""
extra_env = (
{"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"}
if case_insensitive
else {}
)
click.echo(click_completion.core.get_code(shell, extra_env=extra_env))
@main.command()
@click.option(
"--append/--overwrite",
help="Append the completion code to the file",
default=None,
)
@click.option(
"-i",
"--case-insensitive/--no-case-insensitive",
help="Case insensitive completion",
)
@click.argument(
"shell",
required=False,
type=click_completion.DocumentedChoice(click_completion.core.shells),
)
@click.argument("path", required=False)
def install(append, case_insensitive, shell, path):
"""Install the click-completion-command completion"""
extra_env = (
{"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"}
if case_insensitive
else {}
)
shell, path = click_completion.core.install(
shell=shell,
path=path,
append=append,
extra_env=extra_env,
)
click.echo("{} completion installed in {}".format(shell, path))
# Setup the logging faciility
# to disable logging to stdout, but still log to file
# use the --quiet option on the cmdln
def setup_logging(config, loglevel, quiet):
log_level = LOG_LEVELS[loglevel]
LOG.setLevel(log_level)
log_format = "[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s]" " %(message)s"
date_format = "%m/%d/%Y %I:%M:%S %p"
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
log_file = config["aprs"].get("logfile", None)
if log_file:
fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4)
else:
fh = NullHandler()
fh.setFormatter(log_formatter)
LOG.addHandler(fh)
if not quiet:
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(log_formatter)
LOG.addHandler(sh)
def parse_delta_str(s):
if "day" in s:
m = re.match(
r"(?P<days>[-\d]+) day[s]*, (?P<hours>\d+):(?P<minutes>\d+):(?P<seconds>\d[\.\d+]*)",
s,
)
else:
m = re.match(r"(?P<hours>\d+):(?P<minutes>\d+):(?P<seconds>\d[\.\d+]*)", s)
return {key: float(val) for key, val in m.groupdict().items()}
@main.command()
@click.option(
"--loglevel",
default="INFO",
show_default=True,
type=click.Choice(
["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"],
case_sensitive=False,
),
show_choices=True,
help="The log level to use for aprsd.log",
)
@click.option(
"-c",
"--config",
"config_file",
show_default=True,
default=utils.DEFAULT_CONFIG_FILE,
help="The aprsd config file to use for options.",
)
@click.option(
"--url",
"health_url",
show_default=True,
default="http://localhost:8001/stats",
help="The aprsd url to call for checking health/stats",
)
@click.option(
"--timeout",
show_default=True,
default=3,
help="How long to wait for healtcheck url to come back",
)
def check(loglevel, config_file, health_url, timeout):
"""APRSD Plugin test app."""
config = utils.parse_config(config_file)
setup_logging(config, loglevel, False)
LOG.debug("APRSD HealthCheck version: {}".format(aprsd.__version__))
try:
url = health_url
response = requests.get(url, timeout=timeout)
response.raise_for_status()
except Exception as ex:
LOG.error("Failed to fetch healthcheck url '{}' : '{}'".format(url, ex))
sys.exit(-1)
else:
stats = json.loads(response.text)
LOG.debug(stats)
email_thread_last_update = stats["stats"]["email"]["thread_last_update"]
delta = parse_delta_str(email_thread_last_update)
d = datetime.timedelta(**delta)
max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 0}
max_delta = datetime.timedelta(**max_timeout)
if d > max_delta:
LOG.error("Email thread is very old! {}".format(d))
sys.exit(-1)
sys.exit(0)
if __name__ == "__main__":
main()
+69 -44
View File
@@ -20,6 +20,7 @@
#
# python included libs
import datetime
import logging
from logging import NullHandler
from logging.handlers import RotatingFileHandler
@@ -27,17 +28,15 @@ import os
import queue
import signal
import sys
import threading
import time
# local imports here
import aprsd
from aprsd import client, email, messaging, plugin, threads, utils
from aprsd import client, email, flask, messaging, plugin, stats, threads, trace, utils
import aprslib
from aprslib.exceptions import LoginError
import click
import click_completion
import yaml
# setup the global logger
# logging.basicConfig(level=logging.DEBUG) # level=10
@@ -53,7 +52,9 @@ LOG_LEVELS = {
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
server_event = threading.Event()
flask_enabled = False
# server_event = threading.Event()
# localization, please edit:
# HOST = "noam.aprs2.net" # north america tier2 servers round robin
@@ -151,18 +152,23 @@ def install(append, case_insensitive, shell, path):
def signal_handler(sig, frame):
global server_vent
global flask_enabled
LOG.info(
"Ctrl+C, Sending all threads exit! Can take up to 10 seconds to exit all threads",
)
threads.APRSDThreadList().stop_all()
server_event.set()
time.sleep(1)
signal.signal(signal.SIGTERM, sys.exit(0))
# end signal_handler
if "subprocess" not in str(frame):
LOG.info(
"Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}".format(
datetime.datetime.now(),
),
)
time.sleep(5)
tracker = messaging.MsgTrack()
tracker.save()
LOG.info(stats.APRSDStats())
# signal.signal(signal.SIGTERM, sys.exit(0))
# sys.exit(0)
if flask_enabled:
signal.signal(signal.SIGTERM, sys.exit(0))
# Setup the logging faciility
@@ -174,7 +180,7 @@ def setup_logging(config, loglevel, quiet):
log_format = "[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s]" " %(message)s"
date_format = "%m/%d/%Y %I:%M:%S %p"
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
log_file = config["aprs"].get("logfile", None)
log_file = config["aprsd"].get("logfile", None)
if log_file:
fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4)
else:
@@ -183,16 +189,27 @@ def setup_logging(config, loglevel, quiet):
fh.setFormatter(log_formatter)
LOG.addHandler(fh)
imap_logger = None
if config["aprsd"]["email"].get("enabled", False) and config["aprsd"]["email"][
"imap"
].get("debug", False):
imap_logger = logging.getLogger("imapclient.imaplib")
imap_logger.setLevel(log_level)
imap_logger.addHandler(fh)
if not quiet:
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(log_formatter)
LOG.addHandler(sh)
if imap_logger:
imap_logger.addHandler(sh)
@main.command()
def sample_config():
"""This dumps the config to stdout."""
click.echo(utils.add_config_comments(yaml.dump(utils.DEFAULT_CONFIG_DICT)))
click.echo(utils.dump_default_cfg())
@main.command()
@@ -385,24 +402,15 @@ def send_message(
default=False,
help="Flush out all old aged messages on disk.",
)
@click.option(
"--stats-server",
is_flag=True,
default=False,
help="Run a stats web server on port 5001?",
)
def server(
loglevel,
quiet,
disable_validation,
config_file,
flush,
stats_server,
):
"""Start the aprsd server process."""
global event
event = threading.Event()
global flask_enabled
signal.signal(signal.SIGINT, signal_handler)
if not quiet:
@@ -416,14 +424,22 @@ def server(
email.CONFIG = config
setup_logging(config, loglevel, quiet)
if config["aprsd"].get("trace", False):
trace.setup_tracing(["method", "api"])
LOG.info("APRSD Started version: {}".format(aprsd.__version__))
stats.APRSDStats(config)
# TODO(walt): Make email processing/checking optional?
# Maybe someone only wants this to process messages with plugins only.
valid = email.validate_email_config(config, disable_validation)
if not valid:
LOG.error("Failed to validate email config options")
sys.exit(-1)
email_enabled = config["aprsd"]["email"].get("enabled", False)
if email_enabled:
# TODO(walt): Make email processing/checking optional?
# Maybe someone only wants this to process messages with plugins only.
valid = email.validate_email_config(config, disable_validation)
if not valid:
LOG.error("Failed to validate email config options")
sys.exit(-1)
else:
LOG.info("Email services not enabled.")
# Create the initial PM singleton and Register plugins
plugin_manager = plugin.PluginManager(config)
@@ -449,26 +465,35 @@ def server(
rx_thread = threads.APRSDRXThread(msg_queues=msg_queues, config=config)
tx_thread = threads.APRSDTXThread(msg_queues=msg_queues, config=config)
email_thread = email.APRSDEmailThread(msg_queues=msg_queues, config=config)
email_thread.start()
if email_enabled:
email_thread = email.APRSDEmailThread(msg_queues=msg_queues, config=config)
email_thread.start()
rx_thread.start()
tx_thread.start()
messaging.MsgTrack().restart()
cntr = 0
while not server_event.is_set():
# to keep the log noise down
if cntr % 12 == 0:
tracker = messaging.MsgTrack()
LOG.debug("KeepAlive Tracker({}): {}".format(len(tracker), str(tracker)))
cntr += 1
time.sleep(10)
keepalive = threads.KeepAliveThread()
keepalive.start()
try:
web_enabled = utils.check_config_option(config, ["aprsd", "web", "enabled"])
except Exception:
web_enabled = False
if web_enabled:
flask_enabled = True
app = flask.init_flask(config)
app.run(
host=config["aprsd"]["web"]["host"],
port=config["aprsd"]["web"]["port"],
)
# If there are items in the msgTracker, then save them
tracker = messaging.MsgTrack()
tracker.save()
LOG.info("APRSD Exiting.")
return 0
if __name__ == "__main__":
+102 -48
View File
@@ -9,7 +9,7 @@ import re
import threading
import time
from aprsd import client, threads, utils
from aprsd import client, stats, threads, utils
LOG = logging.getLogger("APRSD")
@@ -49,26 +49,29 @@ class MsgTrack:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.track = {}
cls._start_time = datetime.datetime.now()
cls._instance._start_time = datetime.datetime.now()
cls._instance.lock = threading.Lock()
return cls._instance
def add(self, msg):
def __getitem__(self, name):
with self.lock:
key = int(msg.id)
self.track[key] = msg
self.total_messages_tracked += 1
return self.track[name]
def get(self, id):
def __iter__(self):
with self.lock:
if id in self.track:
return self.track[id]
return iter(self.track)
def remove(self, id):
def keys(self):
with self.lock:
key = int(id)
if key in self.track.keys():
del self.track[key]
return self.track.keys()
def items(self):
with self.lock:
return self.track.items()
def values(self):
with self.lock:
return self.track.values()
def __len__(self):
with self.lock:
@@ -82,12 +85,36 @@ class MsgTrack:
result += "}"
return result
def add(self, msg):
with self.lock:
key = int(msg.id)
self.track[key] = msg
stats.APRSDStats().msgs_tracked_inc()
self.total_messages_tracked += 1
def get(self, id):
with self.lock:
if id in self.track:
return self.track[id]
def remove(self, id):
with self.lock:
key = int(id)
if key in self.track.keys():
del self.track[key]
def save(self):
"""Save this shit to disk?"""
"""Save any queued to disk?"""
LOG.debug("Save tracker to disk? {}".format(len(self)))
if len(self) > 0:
LOG.info("Saving {} tracking messages to disk".format(len(self)))
pickle.dump(self.dump(), open(utils.DEFAULT_SAVE_FILE, "wb+"))
else:
LOG.debug(
"Nothing to save, flushing old save file '{}'".format(
utils.DEFAULT_SAVE_FILE,
),
)
self.flush()
def dump(self):
@@ -228,8 +255,17 @@ class RawMessage(Message):
super().__init__(None, None, msg_id=None)
self.message = message
def __repr__(self):
return self.message
def dict(self):
now = datetime.datetime.now()
return {
"type": "raw",
"message": self.message.rstrip("\n"),
"raw": self.message.rstrip("\n"),
"retry_count": self.retry_count,
"last_send_attempt": self.last_send_attempt,
"last_send_time": str(self.last_send_time),
"last_send_age": str(now - self.last_send_time),
}
def __str__(self):
return self.message
@@ -245,12 +281,13 @@ class RawMessage(Message):
cl = client.get_client()
log_message(
"Sending Message Direct",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
self.message,
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(repr(self))
cl.sendall(str(self))
stats.APRSDStats().msgs_sent_inc()
class TextMessage(Message):
@@ -265,28 +302,30 @@ class TextMessage(Message):
# an ack? Some messages we don't want to do this ever.
self.allow_delay = allow_delay
def __repr__(self):
def dict(self):
now = datetime.datetime.now()
return {
"id": self.id,
"type": "text-message",
"fromcall": self.fromcall,
"tocall": self.tocall,
"message": self.message.rstrip("\n"),
"raw": str(self).rstrip("\n"),
"retry_count": self.retry_count,
"last_send_attempt": self.last_send_attempt,
"last_send_time": str(self.last_send_time),
"last_send_age": str(now - self.last_send_time),
}
def __str__(self):
"""Build raw string to send over the air."""
return "{}>APRS::{}:{}{{{}\n".format(
return "{}>APZ100::{}:{}{{{}\n".format(
self.fromcall,
self.tocall.ljust(9),
self._filter_for_send(),
str(self.id),
)
def __str__(self):
delta = "Never"
if self.last_send_time:
now = datetime.datetime.now()
delta = now - self.last_send_time
return "{}>{} Msg({})({}): '{}'".format(
self.fromcall,
self.tocall,
self.id,
delta,
self.message,
)
def _filter_for_send(self):
"""Filter and format message string for FCC."""
# max? ftm400 displays 64, raw msg shows 74
@@ -309,12 +348,13 @@ class TextMessage(Message):
cl = client.get_client()
log_message(
"Sending Message Direct",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
self.message,
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(repr(self))
cl.sendall(str(self))
stats.APRSDStats().msgs_tx_inc()
class SendMessageThread(threads.APRSDThread):
@@ -367,13 +407,14 @@ class SendMessageThread(threads.APRSDThread):
# tracking the time.
log_message(
"Sending Message",
repr(msg).rstrip("\n"),
str(msg).rstrip("\n"),
msg.message,
tocall=self.msg.tocall,
retry_number=msg.last_send_attempt,
msg_num=msg.id,
)
cl.sendall(repr(msg))
cl.sendall(str(msg))
stats.APRSDStats().msgs_tx_inc()
msg.last_send_time = datetime.datetime.now()
msg.last_send_attempt += 1
@@ -388,29 +429,41 @@ class AckMessage(Message):
def __init__(self, fromcall, tocall, msg_id):
super().__init__(fromcall, tocall, msg_id=msg_id)
def __repr__(self):
return "{}>APRS::{}:ack{}\n".format(
def dict(self):
now = datetime.datetime.now()
return {
"id": self.id,
"type": "ack",
"fromcall": self.fromcall,
"tocall": self.tocall,
"raw": str(self).rstrip("\n"),
"retry_count": self.retry_count,
"last_send_attempt": self.last_send_attempt,
"last_send_time": str(self.last_send_time),
"last_send_age": str(now - self.last_send_time),
}
def __str__(self):
return "{}>APZ100::{}:ack{}\n".format(
self.fromcall,
self.tocall.ljust(9),
self.id,
)
def __str__(self):
return "From({}) TO({}) Ack ({})".format(self.fromcall, self.tocall, self.id)
def send_thread(self):
"""Separate thread to send acks with retries."""
cl = client.get_client()
for i in range(self.retry_count, 0, -1):
log_message(
"Sending ack",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
None,
ack=self.id,
tocall=self.tocall,
retry_number=i,
)
cl.sendall(repr(self))
cl.sendall(str(self))
stats.APRSDStats().ack_tx_inc()
# aprs duplicate detection is 30 secs?
# (21 only sends first, 28 skips middle)
time.sleep(31)
@@ -428,13 +481,13 @@ class AckMessage(Message):
cl = client.get_client()
log_message(
"Sending ack",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
None,
ack=self.id,
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(repr(self))
cl.sendall(str(self))
class SendAckThread(threads.APRSDThread):
@@ -471,13 +524,14 @@ class SendAckThread(threads.APRSDThread):
cl = client.get_client()
log_message(
"Sending ack",
repr(self.ack).rstrip("\n"),
str(self.ack).rstrip("\n"),
None,
ack=self.ack.id,
tocall=self.ack.tocall,
retry_number=self.ack.last_send_attempt,
)
cl.sendall(repr(self.ack))
cl.sendall(str(self.ack))
stats.APRSDStats().ack_tx_inc()
self.ack.last_send_attempt += 1
self.ack.last_send_time = datetime.datetime.now()
time.sleep(5)
+1 -1
View File
@@ -23,7 +23,7 @@ CORE_PLUGINS = [
"aprsd.plugins.ping.PingPlugin",
"aprsd.plugins.query.QueryPlugin",
"aprsd.plugins.time.TimePlugin",
"aprsd.plugins.weather.WeatherPlugin",
"aprsd.plugins.weather.USWeatherPlugin",
"aprsd.plugins.version.VersionPlugin",
]
+78
View File
@@ -0,0 +1,78 @@
# 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, 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={}&units={}&exclude={}".format(
lat,
lon,
api_key,
units,
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)
+6 -1
View File
@@ -2,7 +2,7 @@ import logging
import re
import time
from aprsd import email, messaging, plugin
from aprsd import email, messaging, plugin, trace
LOG = logging.getLogger("APRSD")
@@ -18,9 +18,13 @@ class EmailPlugin(plugin.APRSDPluginBase):
# five mins {int:int}
email_sent_dict = {}
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("Email COMMAND")
reply = None
if not self.config["aprsd"]["email"].get("enabled", False):
LOG.debug("Email is not enabled in config file ignoring.")
return "Email not enabled."
searchstring = "^" + self.config["ham"]["callsign"] + ".*"
# only I can do email
@@ -76,6 +80,7 @@ class EmailPlugin(plugin.APRSDPluginBase):
self.email_sent_dict.clear()
self.email_sent_dict[ack] = now
else:
reply = messaging.NULL_MESSAGE
LOG.info(
"Email for message number "
+ ack
+8 -1
View File
@@ -2,7 +2,7 @@ import logging
import shutil
import subprocess
from aprsd import plugin
from aprsd import plugin, trace
LOG = logging.getLogger("APRSD")
@@ -14,6 +14,7 @@ class FortunePlugin(plugin.APRSDPluginBase):
command_regex = "^[fF]"
command_name = "fortune"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("FortunePlugin")
reply = None
@@ -32,6 +33,12 @@ class FortunePlugin(plugin.APRSDPluginBase):
timeout=3,
universal_newlines=True,
)
output = (
output.replace("\r", "")
.replace("\n", "")
.replace(" ", "")
.replace("\t", " ")
)
except subprocess.CalledProcessError as ex:
reply = "Fortune command failed '{}'".format(ex.output)
else:
+60 -56
View File
@@ -1,10 +1,8 @@
import json
import logging
import re
import time
from aprsd import plugin
import requests
from aprsd import plugin, plugin_utils, trace, utils
LOG = logging.getLogger("APRSD")
@@ -16,63 +14,69 @@ class LocationPlugin(plugin.APRSDPluginBase):
command_regex = "^[lL]"
command_name = "location"
config_items = {"apikey": "aprs.fi api key here"}
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("Location Plugin")
# get last location of a callsign, get descriptive name from weather service
api_key = self.config["aprs.fi"]["apiKey"]
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
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)
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
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)
utils.check_config_option(self.config, ["services", "aprs.fi", "apiKey"])
except Exception as ex:
LOG.error("Failed to find config aprs.fi:apikey {}".format(ex))
return "No aprs.fi apikey found"
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?"
api_key = self.config["services"]["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:
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"
lat = aprs_data["entries"][0]["lat"]
lon = aprs_data["entries"][0]["lng"]
try: # altitude not always provided
alt = float(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": "Unknown Location"}}
if "location" not in wx_data:
LOG.error("Couldn't fetch forecast.weather.gov '{}'".format(wx_data))
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()
return reply
+2 -1
View File
@@ -1,7 +1,7 @@
import logging
import time
from aprsd import plugin
from aprsd import plugin, trace
LOG = logging.getLogger("APRSD")
@@ -13,6 +13,7 @@ class PingPlugin(plugin.APRSDPluginBase):
command_regex = "^[pP]"
command_name = "ping"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("PINGPlugin")
stm = time.localtime()
+9 -8
View File
@@ -2,7 +2,7 @@ import datetime
import logging
import re
from aprsd import messaging, plugin
from aprsd import messaging, plugin, trace
LOG = logging.getLogger("APRSD")
@@ -11,9 +11,10 @@ class QueryPlugin(plugin.APRSDPluginBase):
"""Query command."""
version = "1.0"
command_regex = r"^\?.*"
command_regex = r"^\!.*"
command_name = "query"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("Query COMMAND")
@@ -28,8 +29,8 @@ class QueryPlugin(plugin.APRSDPluginBase):
# only I can do admin commands
if re.search(searchstring, fromcall):
# resend last N most recent: "?3"
r = re.search(r"^\?([0-9]).*", message)
# resend last N most recent: "!3"
r = re.search(r"^\!([0-9]).*", message)
if r is not None:
if len(tracker) > 0:
last_n = r.group(1)
@@ -41,8 +42,8 @@ class QueryPlugin(plugin.APRSDPluginBase):
LOG.debug(reply)
return reply
# resend all: "?a"
r = re.search(r"^\?[aA].*", message)
# resend all: "!a"
r = re.search(r"^\![aA].*", message)
if r is not None:
if len(tracker) > 0:
reply = messaging.NULL_MESSAGE
@@ -53,8 +54,8 @@ class QueryPlugin(plugin.APRSDPluginBase):
LOG.debug(reply)
return reply
# delete all: "?d"
r = re.search(r"^\?[dD].*", message)
# delete all: "!d"
r = re.search(r"^\![dD].*", message)
if r is not None:
reply = "Deleted ALL pending msgs."
LOG.debug(reply)
+114 -12
View File
@@ -1,7 +1,9 @@
import logging
import time
from aprsd import fuzzyclock, plugin
from aprsd import fuzzyclock, plugin, plugin_utils, trace, utils
from opencage.geocoder import OpenCageGeocode
import pytz
LOG = logging.getLogger("APRSD")
@@ -13,16 +15,116 @@ class TimePlugin(plugin.APRSDPluginBase):
command_regex = "^[tT]"
command_name = "time"
def _get_local_tz(self):
return pytz.timezone(time.strftime("%Z"))
def _get_utcnow(self):
return pytz.datetime.datetime.utcnow()
def build_date_str(self, localzone):
utcnow = self._get_utcnow()
gmt_t = pytz.utc.localize(utcnow)
local_t = gmt_t.astimezone(localzone)
local_short_str = local_t.strftime("%H:%M %Z")
local_hour = local_t.strftime("%H")
local_min = local_t.strftime("%M")
cur_time = fuzzyclock.fuzzy(int(local_hour), int(local_min), 1)
reply = "{} ({})".format(
cur_time,
local_short_str,
)
return reply
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("TIME COMMAND")
stm = time.localtime()
h = stm.tm_hour
m = stm.tm_min
cur_time = fuzzyclock.fuzzy(h, m, 1)
reply = "{} ({}:{} PDT) ({})".format(
cur_time,
str(h),
str(m).rjust(2, "0"),
message.rstrip(),
)
return reply
# 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"
@trace.trace
def command(self, fromcall, message, ack):
api_key = self.config["services"]["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"
@trace.trace
def command(self, fromcall, message, ack):
api_key = self.config["services"]["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,
["services", "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["services"]["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)
+2 -1
View File
@@ -1,7 +1,7 @@
import logging
import aprsd
from aprsd import plugin
from aprsd import plugin, trace
LOG = logging.getLogger("APRSD")
@@ -17,6 +17,7 @@ class VersionPlugin(plugin.APRSDPluginBase):
# five mins {int:int}
email_sent_dict = {}
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("Version COMMAND")
return "APRSD version '{}'".format(aprsd.__version__)
+364 -33
View File
@@ -1,54 +1,385 @@
import json
import logging
import re
from aprsd import plugin
from aprsd import plugin, plugin_utils, trace, 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]"
command_name = "weather"
@trace.trace
def command(self, fromcall, message, ack):
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)
utils.check_config_option(self.config, ["services", "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["services"]["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:
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],
)
response = requests.get(url)
# aprs_data = json.loads(response.read())
aprs_data = json.loads(response.text)
).rstrip()
LOG.debug("reply: '{}' ".format(reply))
return reply
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 = "^[metar]"
command_name = "Metar"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("WX Plugin '{}'".format(message))
a = re.search(r"^.*\s+(.*)", message)
if a is not None:
searchcall = a.group(1)
station = searchcall.upper()
try:
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"
else:
station_data = json.loads(resp.text)
reply = station_data["properties"]["rawMessage"]
return reply
else:
# if no second argument, search for calling station
fromcall = fromcall
try:
utils.check_config_option(
self.config,
["services", "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["services"]["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"]
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?)"
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 = plugin_utils.get_weather_gov_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"
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"
@trace.trace
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
try:
utils.check_config_option(self.config, ["services", "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["services"]["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,
["services", "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["services"]["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 <CALLSIGN> - fetches metar for <CALLSIGN>
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"
@trace.trace
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
try:
utils.check_config_option(self.config, ["services", "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["services"]["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, ["services", "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, ["services", "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["services"]["avwx"]["base_url"]
api_key = self.config["services"]["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"]
+160
View File
@@ -0,0 +1,160 @@
import datetime
import logging
import threading
LOG = logging.getLogger("APRSD")
class APRSDStats:
_instance = None
lock = None
config = None
start_time = None
_msgs_tracked = 0
_msgs_tx = 0
_msgs_rx = 0
_msgs_mice_rx = 0
_ack_tx = 0
_ack_rx = 0
_email_thread_last_time = None
_email_tx = 0
_email_rx = 0
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
# any initializetion here
cls._instance.lock = threading.Lock()
cls._instance.start_time = datetime.datetime.now()
return cls._instance
def __init__(self, config=None):
if config:
self.config = config
@property
def uptime(self):
with self.lock:
return str(datetime.datetime.now() - self.start_time)
@property
def msgs_tx(self):
with self.lock:
return self._msgs_tx
def msgs_tx_inc(self):
with self.lock:
self._msgs_tx += 1
@property
def msgs_rx(self):
with self.lock:
return self._msgs_rx
def msgs_rx_inc(self):
with self.lock:
self._msgs_rx += 1
@property
def msgs_mice_rx(self):
with self.lock:
return self._msgs_mice_rx
def msgs_mice_inc(self):
with self.lock:
self._msgs_mice_rx += 1
@property
def ack_tx(self):
with self.lock:
return self._ack_tx
def ack_tx_inc(self):
with self.lock:
self._ack_tx += 1
@property
def ack_rx(self):
with self.lock:
return self._ack_rx
def ack_rx_inc(self):
with self.lock:
self._ack_rx += 1
@property
def msgs_tracked(self):
with self.lock:
return self._msgs_tracked
def msgs_tracked_inc(self):
with self.lock:
self._msgs_tracked += 1
@property
def email_tx(self):
with self.lock:
return self._email_tx
def email_tx_inc(self):
with self.lock:
self._email_tx += 1
@property
def email_rx(self):
with self.lock:
return self._email_rx
def email_rx_inc(self):
with self.lock:
self._email_rx += 1
@property
def email_thread_time(self):
with self.lock:
return self._email_thread_last_time
def email_thread_update(self):
with self.lock:
self._email_thread_last_time = datetime.datetime.now()
def stats(self):
now = datetime.datetime.now()
stats = {
"messages": {
"tracked": self.msgs_tracked,
"sent": self.msgs_tx,
"recieved": self.msgs_rx,
"ack_sent": self.ack_tx,
"ack_recieved": self.ack_rx,
"mic-e recieved": self.msgs_mice_rx,
},
"email": {
"sent": self._email_tx,
"recieved": self._email_rx,
"thread_last_update": str(now - self._email_thread_last_time),
},
}
return stats
def __str__(self):
return (
"Uptime:{} Msgs TX:{} RX:{} "
"ACK: TX:{} RX:{} "
"Email TX:{} RX:{} LastLoop:{} ".format(
self.uptime,
self._msgs_tx,
self._msgs_rx,
self._ack_tx,
self._ack_rx,
self._email_tx,
self._email_rx,
self._email_thread_last_time,
)
)
+43 -9
View File
@@ -1,10 +1,12 @@
import abc
import datetime
import logging
import queue
import threading
import time
import tracemalloc
from aprsd import client, messaging, plugin
from aprsd import client, messaging, plugin, stats, trace
import aprslib
LOG = logging.getLogger("APRSD")
@@ -63,6 +65,42 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
LOG.debug("Exiting")
class KeepAliveThread(APRSDThread):
cntr = 0
def __init__(self):
super().__init__("KeepAlive")
tracemalloc.start()
def loop(self):
if self.cntr % 6 == 0:
tracker = messaging.MsgTrack()
stats_obj = stats.APRSDStats()
now = datetime.datetime.now()
last_email = stats.APRSDStats().email_thread_time
if last_email:
email_thread_time = str(now - last_email)
else:
email_thread_time = "N/A"
current, peak = tracemalloc.get_traced_memory()
LOG.debug(
"Uptime ({}) Tracker({}) "
"Msgs: TX:{} RX:{} EmailThread: {} RAM: Current:{} Peak:{}".format(
stats_obj.uptime,
len(tracker),
stats_obj.msgs_tx,
stats_obj.msgs_rx,
email_thread_time,
current,
peak,
),
)
self.cntr += 1
time.sleep(10)
return True
class APRSDRXThread(APRSDThread):
def __init__(self, msg_queues, config):
super().__init__("RX_MSG")
@@ -73,13 +111,6 @@ class APRSDRXThread(APRSDThread):
self.thread_stop = True
client.get_client().stop()
def callback(self, packet):
try:
packet = aprslib.parse(packet)
print(packet)
except (aprslib.ParseError, aprslib.UnknownFormat):
pass
def loop(self):
aprs_client = client.get_client()
@@ -118,11 +149,13 @@ class APRSDRXThread(APRSDThread):
)
tracker = messaging.MsgTrack()
tracker.remove(ack_num)
stats.APRSDStats().ack_rx_inc()
return
def process_mic_e_packet(self, packet):
LOG.info("Mic-E Packet detected. Currenlty unsupported.")
messaging.log_packet(packet)
stats.APRSDStats().msgs_mice_inc()
return
def process_message_packet(self, packet):
@@ -191,11 +224,12 @@ class APRSDRXThread(APRSDThread):
self.msg_queues["tx"].put(ack)
LOG.debug("Packet processing complete")
@trace.trace
def process_packet(self, packet):
"""Process a packet recieved from aprs-is server."""
try:
LOG.info("Got message: {}".format(packet))
stats.APRSDStats().msgs_rx_inc()
msg = packet.get("message_text", None)
msg_format = packet.get("format", None)
+181
View File
@@ -0,0 +1,181 @@
import abc
import functools
import inspect
import logging
import time
import types
VALID_TRACE_FLAGS = {"method", "api"}
TRACE_API = False
TRACE_METHOD = False
TRACE_ENABLED = False
LOG = logging.getLogger("APRSD")
def trace(*dec_args, **dec_kwargs):
"""Trace calls to the decorated function.
This decorator should always be defined as the outermost decorator so it
is defined last. This is important so it does not interfere
with other decorators.
Using this decorator on a function will cause its execution to be logged at
`DEBUG` level with arguments, return values, and exceptions.
:returns: a function decorator
"""
def _decorator(f):
func_name = f.__name__
@functools.wraps(f)
def trace_logging_wrapper(*args, **kwargs):
filter_function = dec_kwargs.get("filter_function")
logger = LOG
# NOTE(ameade): Don't bother going any further if DEBUG log level
# is not enabled for the logger.
if not logger.isEnabledFor(logging.DEBUG) or not TRACE_ENABLED:
return f(*args, **kwargs)
all_args = inspect.getcallargs(f, *args, **kwargs)
pass_filter = filter_function is None or filter_function(all_args)
if pass_filter:
logger.debug(
"==> %(func)s: call %(all_args)r",
{
"func": func_name,
"all_args": str(all_args),
},
)
start_time = time.time() * 1000
try:
result = f(*args, **kwargs)
except Exception as exc:
total_time = int(round(time.time() * 1000)) - start_time
logger.debug(
"<== %(func)s: exception (%(time)dms) %(exc)r",
{
"func": func_name,
"time": total_time,
"exc": exc,
},
)
raise
total_time = int(round(time.time() * 1000)) - start_time
if isinstance(result, dict):
mask_result = result
elif isinstance(result, str):
mask_result = result
else:
mask_result = result
if pass_filter:
logger.debug(
"<== %(func)s: return (%(time)dms) %(result)r",
{
"func": func_name,
"time": total_time,
"result": mask_result,
},
)
return result
return trace_logging_wrapper
if len(dec_args) == 0:
# filter_function is passed and args does not contain f
return _decorator
else:
# filter_function is not passed
return _decorator(dec_args[0])
def trace_api(*dec_args, **dec_kwargs):
"""Decorates a function if TRACE_API is true."""
def _decorator(f):
@functools.wraps(f)
def trace_api_logging_wrapper(*args, **kwargs):
if TRACE_API:
return trace(f, *dec_args, **dec_kwargs)(*args, **kwargs)
return f(*args, **kwargs)
return trace_api_logging_wrapper
if len(dec_args) == 0:
# filter_function is passed and args does not contain f
return _decorator
else:
# filter_function is not passed
return _decorator(dec_args[0])
def trace_method(f):
"""Decorates a function if TRACE_METHOD is true."""
@functools.wraps(f)
def trace_method_logging_wrapper(*args, **kwargs):
if TRACE_METHOD:
return trace(f)(*args, **kwargs)
return f(*args, **kwargs)
return trace_method_logging_wrapper
class TraceWrapperMetaclass(type):
"""Metaclass that wraps all methods of a class with trace_method.
This metaclass will cause every function inside of the class to be
decorated with the trace_method decorator.
To use the metaclass you define a class like so:
class MyClass(object, metaclass=utils.TraceWrapperMetaclass):
"""
def __new__(cls, classname, bases, class_dict):
new_class_dict = {}
for attribute_name, attribute in class_dict.items():
if isinstance(attribute, types.FunctionType):
# replace it with a wrapped version
attribute = functools.update_wrapper(
trace_method(attribute),
attribute,
)
new_class_dict[attribute_name] = attribute
return type.__new__(cls, classname, bases, new_class_dict)
class TraceWrapperWithABCMetaclass(abc.ABCMeta, TraceWrapperMetaclass):
"""Metaclass that wraps all methods of a class with trace."""
pass
def setup_tracing(trace_flags):
"""Set global variables for each trace flag.
Sets variables TRACE_METHOD and TRACE_API, which represent
whether to log methods or api traces.
:param trace_flags: a list of strings
"""
global TRACE_METHOD
global TRACE_API
global TRACE_ENABLED
try:
trace_flags = [flag.strip() for flag in trace_flags]
except TypeError: # Handle when trace_flags is None or a test mock
trace_flags = []
for invalid_flag in set(trace_flags) - VALID_TRACE_FLAGS:
LOG.warning("Invalid trace flag: %s", invalid_flag)
TRACE_METHOD = "method" in trace_flags
TRACE_API = "api" in trace_flags
TRACE_ENABLED = True
+187 -67
View File
@@ -17,33 +17,53 @@ DEFAULT_CONFIG_DICT = {
"aprs": {
"login": "CALLSIGN",
"password": "00000",
"host": "rotate.aprs.net",
"host": "rotate.aprs2.net",
"port": 14580,
"logfile": "/tmp/aprsd.log",
},
"aprs.fi": {"apiKey": "set me"},
"shortcuts": {
"aa": "5551239999@vtext.com",
"cl": "craiglamparter@somedomain.org",
"wb": "555309@vtext.com",
},
"smtp": {
"login": "SMTP_USERNAME",
"password": "SMTP_PASSWORD",
"host": "smtp.gmail.com",
"port": 465,
"use_ssl": False,
},
"imap": {
"login": "IMAP_USERNAME",
"password": "IMAP_PASSWORD",
"host": "imap.gmail.com",
"port": 993,
"use_ssl": True,
},
"aprsd": {
"logfile": "/tmp/aprsd.log",
"trace": False,
"plugin_dir": "~/.config/aprsd/plugins",
"enabled_plugins": plugin.CORE_PLUGINS,
"units": "imperial",
"web": {
"enabled": True,
"host": "0.0.0.0",
"port": 8001,
"users": {
"admin": "aprsd",
},
},
"email": {
"enabled": True,
"shortcuts": {
"aa": "5551239999@vtext.com",
"cl": "craiglamparter@somedomain.org",
"wb": "555309@vtext.com",
},
"smtp": {
"login": "SMTP_USERNAME",
"password": "SMTP_PASSWORD",
"host": "smtp.gmail.com",
"port": 465,
"use_ssl": False,
"debug": False,
},
"imap": {
"login": "IMAP_USERNAME",
"password": "IMAP_PASSWORD",
"host": "imap.gmail.com",
"port": 993,
"use_ssl": True,
"debug": False,
},
},
},
"services": {
"aprs.fi": {"apiKey": "APIKEYVALUE"},
"openweathermap": {"apiKey": "APIKEYVALUE"},
"opencagedata": {"apiKey": "APIKEYVALUE"},
"avwx": {"base_url": "http://host:port", "apiKey": "APIKEYVALUE"},
},
}
@@ -101,13 +121,68 @@ def end_substr(original, substr):
return idx
def dump_default_cfg():
return add_config_comments(
yaml.dump(
DEFAULT_CONFIG_DICT,
indent=4,
),
)
def add_config_comments(raw_yaml):
end_idx = end_substr(raw_yaml, "aprs:")
if end_idx != -1:
# lets insert a comment
raw_yaml = insert_str(
raw_yaml,
"\n # Get the passcode for your callsign here: "
"\n # https://apps.magicbug.co.uk/passcode",
end_idx,
)
end_idx = end_substr(raw_yaml, "aprs.fi:")
if end_idx != -1:
# 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,
)
end_idx = end_substr(raw_yaml, "opencagedata:")
if end_idx != -1:
# lets insert a comment
raw_yaml = insert_str(
raw_yaml,
"\n # (Optional for TimeOpenCageDataPlugin) "
"\n # Get the apiKey from your opencagedata account here: "
"\n # 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 # (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,
)
@@ -123,8 +198,7 @@ def create_default_config():
click.echo("Config dir '{}' doesn't exist, creating.".format(config_dir))
mkdir_p(config_dir)
with open(config_file_expanded, "w+") as cf:
raw_yaml = yaml.dump(DEFAULT_CONFIG_DICT)
cf.write(add_config_comments(raw_yaml))
cf.write(dump_default_cfg())
def get_config(config_file):
@@ -154,6 +228,34 @@ def get_config(config_file):
sys.exit(-1)
def conf_option_exists(conf, chain):
_key = chain.pop(0)
if _key in conf:
return conf_option_exists(conf[_key], chain) if chain else conf[_key]
def check_config_option(config, chain, default_fail=None):
result = conf_option_exists(config, chain.copy())
if not result:
raise Exception(
"'{}' was not in config file".format(
chain,
),
)
else:
if default_fail:
if result == 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 for {}.".format(
chain,
),
)
else:
return config
# This method tries to parse the config yaml file
# and consume the settings.
# If the required params don't exist,
@@ -166,59 +268,77 @@ def parse_config(config_file):
click.echo(msg)
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.")
def check_option(config, chain, default_fail=None):
try:
config = check_config_option(config, chain, default_fail=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")
# special check here to make sure user has edited the config file
# and changed the ham callsign
check_option(
config,
"ham",
"callsign",
[
"ham",
"callsign",
],
default_fail=DEFAULT_CONFIG_DICT["ham"]["callsign"],
)
check_option(
config,
"aprs.fi",
"apiKey",
default_fail=DEFAULT_CONFIG_DICT["aprs.fi"]["apiKey"],
["services", "aprs.fi", "apiKey"],
default_fail=DEFAULT_CONFIG_DICT["services"]["aprs.fi"]["apiKey"],
)
check_option(config, "aprs", "login")
check_option(config, "aprs", "password")
# check_option(config, "aprs", "host")
# check_option(config, "aprs", "port")
check_option(config, "aprs", "logfile", "./aprsd.log")
check_option(config, "imap", "host")
check_option(config, "imap", "login")
check_option(config, "imap", "password")
check_option(config, "smtp", "host")
check_option(config, "smtp", "port")
check_option(config, "smtp", "login")
check_option(config, "smtp", "password")
check_option(
config,
["aprs", "login"],
default_fail=DEFAULT_CONFIG_DICT["aprs"]["login"],
)
check_option(
config,
["aprs", "password"],
default_fail=DEFAULT_CONFIG_DICT["aprs"]["password"],
)
# Ensure they change the admin password
if config["aprsd"]["web"]["enabled"] is True:
check_option(
config,
["aprsd", "web", "users", "admin"],
default_fail=DEFAULT_CONFIG_DICT["aprsd"]["web"]["users"]["admin"],
)
if config["aprsd"]["email"]["enabled"] is True:
# Check IMAP server settings
check_option(config, ["aprsd", "email", "imap", "host"])
check_option(config, ["aprsd", "email", "imap", "port"])
check_option(
config,
["aprsd", "email", "imap", "login"],
default_fail=DEFAULT_CONFIG_DICT["aprsd"]["email"]["imap"]["login"],
)
check_option(
config,
["aprsd", "email", "imap", "password"],
default_fail=DEFAULT_CONFIG_DICT["aprsd"]["email"]["imap"]["password"],
)
# Check SMTP server settings
check_option(config, ["aprsd", "email", "smtp", "host"])
check_option(config, ["aprsd", "email", "smtp", "port"])
check_option(
config,
["aprsd", "email", "smtp", "login"],
default_fail=DEFAULT_CONFIG_DICT["aprsd"]["email"]["smtp"]["login"],
)
check_option(
config,
["aprsd", "email", "smtp", "password"],
default_fail=DEFAULT_CONFIG_DICT["aprsd"]["email"]["smtp"]["password"],
)
return config
@@ -0,0 +1,57 @@
/* Root element */
.json-document {
padding: 1em 2em;
}
/* Syntax highlighting for JSON objects */
ul.json-dict, ol.json-array {
list-style-type: none;
margin: 0 0 0 1px;
border-left: 1px dotted #ccc;
padding-left: 2em;
}
.json-string {
color: #0B7500;
}
.json-literal {
color: #1A01CC;
font-weight: bold;
}
/* Toggle button */
a.json-toggle {
position: relative;
color: inherit;
text-decoration: none;
}
a.json-toggle:focus {
outline: none;
}
a.json-toggle:before {
font-size: 1.1em;
color: #c0c0c0;
content: "\25BC"; /* down arrow */
position: absolute;
display: inline-block;
width: 1em;
text-align: center;
line-height: 1em;
left: -1.2em;
}
a.json-toggle:hover:before {
color: #aaa;
}
a.json-toggle.collapsed:before {
/* Use rotated down arrow, prevents right arrow appearing smaller than down arrow in some browsers */
transform: rotate(-90deg);
}
/* Collapsable placeholder links */
a.json-placeholder {
color: #aaa;
padding: 0 1em;
text-decoration: none;
}
a.json-placeholder:hover {
text-decoration: underline;
}
@@ -0,0 +1,158 @@
/**
* jQuery json-viewer
* @author: Alexandre Bodelot <alexandre.bodelot@gmail.com>
* @link: https://github.com/abodelot/jquery.json-viewer
*/
(function($) {
/**
* Check if arg is either an array with at least 1 element, or a dict with at least 1 key
* @return boolean
*/
function isCollapsable(arg) {
return arg instanceof Object && Object.keys(arg).length > 0;
}
/**
* Check if a string represents a valid url
* @return boolean
*/
function isUrl(string) {
var urlRegexp = /^(https?:\/\/|ftps?:\/\/)?([a-z0-9%-]+\.){1,}([a-z0-9-]+)?(:(\d{1,5}))?(\/([a-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+)?)?$/i;
return urlRegexp.test(string);
}
/**
* Transform a json object into html representation
* @return string
*/
function json2html(json, options) {
var html = '';
if (typeof json === 'string') {
// Escape tags and quotes
json = json
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;');
if (options.withLinks && isUrl(json)) {
html += '<a href="' + json + '" class="json-string" target="_blank">' + json + '</a>';
} else {
// Escape double quotes in the rendered non-URL string.
json = json.replace(/&quot;/g, '\\&quot;');
html += '<span class="json-string">"' + json + '"</span>';
}
} else if (typeof json === 'number') {
html += '<span class="json-literal">' + json + '</span>';
} else if (typeof json === 'boolean') {
html += '<span class="json-literal">' + json + '</span>';
} else if (json === null) {
html += '<span class="json-literal">null</span>';
} else if (json instanceof Array) {
if (json.length > 0) {
html += '[<ol class="json-array">';
for (var i = 0; i < json.length; ++i) {
html += '<li>';
// Add toggle button if item is collapsable
if (isCollapsable(json[i])) {
html += '<a href class="json-toggle"></a>';
}
html += json2html(json[i], options);
// Add comma if item is not last
if (i < json.length - 1) {
html += ',';
}
html += '</li>';
}
html += '</ol>]';
} else {
html += '[]';
}
} else if (typeof json === 'object') {
var keyCount = Object.keys(json).length;
if (keyCount > 0) {
html += '{<ul class="json-dict">';
for (var key in json) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
html += '<li>';
var keyRepr = options.withQuotes ?
'<span class="json-string">"' + key + '"</span>' : key;
// Add toggle button if item is collapsable
if (isCollapsable(json[key])) {
html += '<a href class="json-toggle">' + keyRepr + '</a>';
} else {
html += keyRepr;
}
html += ': ' + json2html(json[key], options);
// Add comma if item is not last
if (--keyCount > 0) {
html += ',';
}
html += '</li>';
}
}
html += '</ul>}';
} else {
html += '{}';
}
}
return html;
}
/**
* jQuery plugin method
* @param json: a javascript object
* @param options: an optional options hash
*/
$.fn.jsonViewer = function(json, options) {
// Merge user options with default options
options = Object.assign({}, {
collapsed: false,
rootCollapsable: true,
withQuotes: false,
withLinks: true
}, options);
// jQuery chaining
return this.each(function() {
// Transform to HTML
var html = json2html(json, options);
if (options.rootCollapsable && isCollapsable(json)) {
html = '<a href class="json-toggle"></a>' + html;
}
// Insert HTML in target DOM element
$(this).html(html);
$(this).addClass('json-document');
// Bind click on toggle buttons
$(this).off('click');
$(this).on('click', 'a.json-toggle', function() {
var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array');
target.toggle();
if (target.is(':visible')) {
target.siblings('.json-placeholder').remove();
} else {
var count = target.children('li').length;
var placeholder = count + (count > 1 ? ' items' : ' item');
target.after('<a href class="json-placeholder">' + placeholder + '</a>');
}
return false;
});
// Simulate click on toggle button when placeholder is clicked
$(this).on('click', 'a.json-placeholder', function() {
$(this).siblings('a.json-toggle').click();
return false;
});
if (options.collapsed == true) {
// Trigger click to collapse all nodes
$(this).find('a.json-toggle').click();
}
});
};
})(jQuery);
+4
View File
@@ -0,0 +1,4 @@
<html>
<body><h1>{{ message }}</h1></body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<html>
<head>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="json-viewer/jquery.json-viewer.js"></script>
<link href="json-viewer/jquery.json-viewer.css" type="text/css" rel="stylesheet" />
</head>
<pre id="json-viewer"></pre>
<script>
var data = {{ messages | safe }}
$('#json-viewer').jsonViewer(data)
</script>
</html>
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
# Use this script to locally build the docker image
docker build --no-cache -t hemna6969/aprsd:latest ..
+15 -2
View File
@@ -16,10 +16,12 @@ babel==2.9.0
# via sphinx
black==20.8b1
# via -r dev-requirements.in
bleach==3.2.1
bleach==3.3.0
# via readme-renderer
certifi==2020.12.5
# via requests
cffi==1.14.4
# via cryptography
chardet==4.0.0
# via requests
click==7.1.2
@@ -28,6 +30,8 @@ colorama==0.4.4
# via twine
coverage==5.3.1
# via pytest-cov
cryptography==3.3.2
# via secretstorage
distlib==0.3.1
# via virtualenv
docutils==0.16
@@ -52,6 +56,10 @@ iniconfig==1.1.1
# via pytest
isort==5.7.0
# via -r dev-requirements.in
jeepney==0.6.0
# via
# keyring
# secretstorage
jinja2==2.11.2
# via sphinx
keyring==21.8.0
@@ -88,6 +96,8 @@ py==1.10.0
# tox
pycodestyle==2.6.0
# via flake8
pycparser==2.20
# via cffi
pyflakes==2.2.0
# via flake8
pygments==2.7.3
@@ -117,9 +127,12 @@ requests==2.25.1
# twine
rfc3986==1.4.0
# via twine
secretstorage==3.3.0
# via keyring
six==1.15.0
# via
# bleach
# cryptography
# readme-renderer
# tox
# virtualenv
@@ -160,7 +173,7 @@ typing-extensions==3.7.4.3
# mypy
urllib3==1.26.2
# via requests
virtualenv==20.2.2
virtualenv==20.4.0
# via tox
webencodings==0.5.1
# via bleach
+5 -3
View File
@@ -1,16 +1,18 @@
FROM alpine:latest as aprsd
# Dockerfile for building a container during aprsd development.
ARG BRANCH
ENV VERSION=1.0.0
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=${BRANCH:-master}
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
@@ -44,5 +46,5 @@ RUN chown -R $APRS_USER:$APRS_USER /config
ENV CONF default
USER $APRS_USER
ADD build/bin/run.sh $HOME/
ADD bin/run.sh $HOME/
ENTRYPOINT ["/home/aprs/run.sh"]
+1 -1
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# Use this script to locally build the docker image
docker build --no-cache -t hemna6969/aprsd:latest -f ./Dockerfile .
+40
View File
@@ -20,6 +20,22 @@ aprsd.client module
:undoc-members:
:show-inheritance:
aprsd.dev module
----------------
.. automodule:: aprsd.dev
:members:
:undoc-members:
:show-inheritance:
aprsd.dev module
----------------
.. automodule:: aprsd.dev
:members:
:undoc-members:
:show-inheritance:
aprsd.email module
------------------
@@ -36,6 +52,14 @@ aprsd.fake\_aprs module
:undoc-members:
:show-inheritance:
aprsd.flask module
------------------
.. automodule:: aprsd.flask
:members:
:undoc-members:
:show-inheritance:
aprsd.fuzzyclock module
-----------------------
@@ -68,6 +92,22 @@ aprsd.plugin module
:undoc-members:
:show-inheritance:
aprsd.plugin\_utils module
--------------------------
.. automodule:: aprsd.plugin_utils
:members:
:undoc-members:
:show-inheritance:
aprsd.plugin\_utils module
--------------------------
.. automodule:: aprsd.plugin_utils
:members:
:undoc-members:
:show-inheritance:
aprsd.threads module
--------------------
+5
View File
@@ -10,3 +10,8 @@ thesmuggler
aprslib
py3-validate-email
pre-commit
pytz
opencage
flask
flask-classful
flask-httpauth
+41 -6
View File
@@ -8,8 +8,12 @@ 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,9 @@ click==7.1.2
# via
# -r requirements.in
# click-completion
# flask
cryptography==3.3.2
# via pyopenssl
distlib==0.3.1
# via virtualenv
dnspython==2.1.0
@@ -28,20 +35,35 @@ filelock==3.0.12
# via
# py3-validate-email
# virtualenv
identify==1.5.11
flask-classful==0.14.2
# via -r requirements.in
flask-httpauth==4.2.0
# via -r requirements.in
flask==1.1.2
# via
# -r requirements.in
# flask-classful
# flask-httpauth
identify==1.5.13
# via pre-commit
idna==2.10
# via
# py3-validate-email
# requests
imapclient==2.1.0
imapclient==2.2.0
# via -r requirements.in
itsdangerous==1.1.0
# via flask
jinja2==2.11.2
# via click-completion
# via
# click-completion
# flask
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,19 +72,30 @@ pre-commit==2.9.3
# via -r requirements.in
py3-validate-email==0.2.12
# via -r requirements.in
pyyaml==5.3.1
pycparser==2.20
# via cffi
pyopenssl==20.0.1
# via opencage
pytz==2020.5
# via -r requirements.in
pyyaml==5.4.1
# via
# -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
@@ -70,5 +103,7 @@ toml==0.10.2
# via pre-commit
urllib3==1.26.2
# via requests
virtualenv==20.2.2
virtualenv==20.4.0
# via pre-commit
werkzeug==1.0.1
# via flask
+2
View File
@@ -35,6 +35,8 @@ packages =
[entry_points]
console_scripts =
aprsd = aprsd.main:main
aprsd-dev = aprsd.dev:main
aprsd-healthcheck = aprsd.healthcheck:main
fake_aprs = aprsd.fake_aprs:main
[build_sphinx]
+4 -4
View File
@@ -5,21 +5,21 @@ from aprsd import email
class TestEmail(unittest.TestCase):
def test_get_email_from_shortcut(self):
email.CONFIG = {"shortcuts": {}}
email.CONFIG = {"aprsd": {"email": {"shortcuts": {}}}}
email_address = "something@something.com"
addr = "-{}".format(email_address)
actual = email.get_email_from_shortcut(addr)
self.assertEqual(addr, actual)
email.CONFIG = {"nothing": "nothing"}
email.CONFIG = {"aprsd": {"email": {"nothing": "nothing"}}}
actual = email.get_email_from_shortcut(addr)
self.assertEqual(addr, actual)
email.CONFIG = {"shortcuts": {"not_used": "empty"}}
email.CONFIG = {"aprsd": {"email": {"shortcuts": {"not_used": "empty"}}}}
actual = email.get_email_from_shortcut(addr)
self.assertEqual(addr, actual)
email.CONFIG = {"shortcuts": {"-wb": email_address}}
email.CONFIG = {"aprsd": {"email": {"shortcuts": {"-wb": email_address}}}}
short = "-wb"
actual = email.get_email_from_shortcut(short)
self.assertEqual(email_address, actual)
+20 -12
View File
@@ -9,6 +9,7 @@ from aprsd.plugins import ping as ping_plugin
from aprsd.plugins import query as query_plugin
from aprsd.plugins import time as time_plugin
from aprsd.plugins import version as version_plugin
import pytz
class TestPlugin(unittest.TestCase):
@@ -41,7 +42,7 @@ class TestPlugin(unittest.TestCase):
@mock.patch("aprsd.messaging.MsgTrack.flush")
def test_query_flush(self, mock_flush):
message = "?delete"
message = "!delete"
query = query_plugin.QueryPlugin(self.config)
expected = "Deleted ALL pending msgs."
@@ -53,7 +54,7 @@ class TestPlugin(unittest.TestCase):
def test_query_restart_delayed(self, mock_restart):
track = messaging.MsgTrack()
track.track = {}
message = "?4"
message = "!4"
query = query_plugin.QueryPlugin(self.config)
expected = "No pending msgs to resend"
@@ -68,13 +69,21 @@ class TestPlugin(unittest.TestCase):
actual = query.run(self.fromcall, message, self.ack)
mock_restart.assert_called_once()
@mock.patch("time.localtime")
def test_time(self, mock_time):
@mock.patch("aprsd.plugins.time.TimePlugin._get_local_tz")
@mock.patch("aprsd.plugins.time.TimePlugin._get_utcnow")
def test_time(self, mock_utcnow, mock_localtz):
utcnow = pytz.datetime.datetime.utcnow()
mock_utcnow.return_value = utcnow
tz = pytz.timezone("US/Pacific")
mock_localtz.return_value = tz
gmt_t = pytz.utc.localize(utcnow)
local_t = gmt_t.astimezone(tz)
fake_time = mock.MagicMock()
h = fake_time.tm_hour = 16
m = fake_time.tm_min = 12
fake_time.tm_sec = 55
mock_time.return_value = fake_time
h = int(local_t.strftime("%H"))
m = int(local_t.strftime("%M"))
fake_time.tm_sec = 13
time = time_plugin.TimePlugin(self.config)
fromcall = "KFART"
@@ -87,11 +96,10 @@ class TestPlugin(unittest.TestCase):
cur_time = fuzzy(h, m, 1)
message = "time"
expected = "{} ({}:{} PDT) ({})".format(
local_short_str = local_t.strftime("%H:%M %Z")
expected = "{} ({})".format(
cur_time,
str(h),
str(m).rjust(2, "0"),
message.rstrip(),
local_short_str,
)
actual = time.run(fromcall, message, ack)
self.assertEqual(expected, actual)