mirror of
https://github.com/craigerl/aprsd.git
synced 2026-08-16 08:35:34 -04:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4713b2694 | |||
| b606495fbf | |||
| 2fceba10e1 | |||
| 3d38402be2 | |||
| 90a44bb5ed | |||
| 7dc4fb3e77 | |||
| f31a4c07b4 | |||
| 1a1fcba1c4 | |||
| 562ae52c1e | |||
| 3c45d8bd0f | |||
| 5afc7fb664 | |||
| ccaab72124 | |||
| de62579852 | |||
| 1c66555450 | |||
| 13be30f772 | |||
| 9a1ab1c0d6 | |||
| 3ae5717452 | |||
| d8950f0995 | |||
| 6fb16421e8 | |||
| c14b1bc985 | |||
| 17302aa76d | |||
| 2f7fa0c3d5 | |||
| 9de0df31eb | |||
| b8dc6a329b | |||
| 970b32f238 | |||
| 2a5ef58295 | |||
| 2696a399cb | |||
| 55862a2790 | |||
| fc1ee19516 | |||
| 4aac17dc98 | |||
| a4a06c9763 | |||
| 23c219f0d2 | |||
| 7b019d24f0 | |||
| 3f21934c0f | |||
| cabe374909 | |||
| 3ac42edd82 | |||
| d6806c429c | |||
| bf8d2c6088 | |||
| 123266c9ad | |||
| 34d2c31d90 | |||
| d1a2a14370 | |||
| fb979eda94 | |||
| 6297ebeb67 | |||
| c8484eb195 | |||
| 5f8db9df37 | |||
| 0e6b46555a | |||
| f10372b320 | |||
| c7d10f53a3 | |||
| f211e5cabb | |||
| 6f3486bc72 | |||
| 53cdccde10 | |||
| a657eeb390 | |||
| d9536434b0 | |||
| 5f3e067c96 | |||
| 0a038dae44 | |||
| 239e784d51 | |||
| 933917bf9d | |||
| e6cafeb3d2 | |||
| 9f66774541 | |||
| c177748340 | |||
| f0034fc517 | |||
| 2d5bb85071 | |||
| b6ba90de53 | |||
| a266c987fd | |||
| e2d8cc60e5 | |||
| 3a6316fa8a | |||
| 7df6462d91 | |||
| 24edcad60a | |||
| 9ba44a076c | |||
| afc53624e1 | |||
| 131919bdfb | |||
| d0d0aef077 | |||
| a5cc274ff5 | |||
| d71937b176 | |||
| 47135c6086 | |||
| db2b537317 | |||
| 0b44fc08eb | |||
| af48c43eb2 | |||
| 94bad95e26 | |||
| 57d768e010 | |||
| 030b02551f | |||
| cfb172481d | |||
| 3ca0eeff56 | |||
| c1e6792721 | |||
| aa290692ab | |||
| 0d18e54969 | |||
| 51894bbab8 | |||
| 8bfdefd5ad | |||
| f5ae161ab8 | |||
| c870207a96 | |||
| f932c203d7 | |||
| cae8746690 | |||
| 5c949343ec | |||
| 9630279d14 | |||
| c686543323 | |||
| 982f24c5f5 | |||
| a656508ba6 | |||
| ce5b09233c | |||
| 2f7c1bfcc1 | |||
| a35cb04ca7 | |||
| fefb626c97 | |||
| 2349024539 | |||
| f8c001dc49 | |||
| fc3a747aa4 | |||
| fdd5a6ba41 | |||
| 9f38fd179e | |||
| ca05676c98 | |||
| 83f42dd7b7 | |||
| 5fb363c9e7 | |||
| 7de2820caa | |||
| 55360ba5d0 | |||
| b9f6fcfa0c | |||
| cc8fd178ce |
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.6, 3.7, 3.8, 3.9]
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
|
||||
@@ -44,6 +44,7 @@ output/*/index.html
|
||||
|
||||
# Sphinx
|
||||
doc/build
|
||||
docs/_build
|
||||
|
||||
# pbr generates these
|
||||
AUTHORS
|
||||
@@ -55,3 +56,5 @@ AUTHORS
|
||||
.*sw?
|
||||
.ropeproject
|
||||
.idea
|
||||
|
||||
Makefile.venv
|
||||
|
||||
@@ -1,10 +1,112 @@
|
||||
CHANGES
|
||||
=======
|
||||
|
||||
v2.0.0
|
||||
------
|
||||
|
||||
* Reworked the notification threads and admin ui
|
||||
* Fixed small bug with packets get\_packet\_type
|
||||
* Updated overview images
|
||||
* Move version string output to top of log
|
||||
* Add new watchlist feature
|
||||
* Fixed the Ack thread not resending acks
|
||||
* reworked the admin ui to use semenatic ui more
|
||||
* Added messages count to admin messages list
|
||||
* Add admin UI tabs for charts, messages, config
|
||||
* Removed a noisy debug log
|
||||
* Dump out the config during startup
|
||||
* Added message counts for each plugin
|
||||
* Bump urllib3 from 1.26.4 to 1.26.5
|
||||
* Added aprsd version checking
|
||||
* Updated INSTALL.txt
|
||||
* Update my callsign
|
||||
* Update README.rst
|
||||
* Update README.rst
|
||||
* Bump urllib3 from 1.26.3 to 1.26.4
|
||||
* Prep for v1.6.1 release
|
||||
|
||||
v1.6.1
|
||||
------
|
||||
|
||||
* Removed debug log for KeepAlive thread
|
||||
* ignore Makefile.venv
|
||||
* Reworked Makefile to use Makefile.venv
|
||||
* Fixed version unit tests
|
||||
* Updated stats output for KeepAlive thread
|
||||
* Update Dockerfile-dev to work with startup
|
||||
* Force all the graphs to 0 minimum
|
||||
* Added email messages graphs
|
||||
* Reworked the stats dict output and healthcheck
|
||||
* Added callsign to the web index page
|
||||
* Added log config for flask and lnav config file
|
||||
* Added showing APRS-IS server to stats
|
||||
* Provide an initial datapoint on rendering index
|
||||
* Make the index page behind auth
|
||||
* Bump pygments from 2.7.3 to 2.7.4
|
||||
* Added acks with messages graphs
|
||||
* Updated web stats index to show messages and ram usage
|
||||
* Added aprsd web index page
|
||||
* Bump lxml from 4.6.2 to 4.6.3
|
||||
* Bump jinja2 from 2.11.2 to 2.11.3
|
||||
* Bump urllib3 from 1.26.2 to 1.26.3
|
||||
* Added log format and dateformat to config file
|
||||
* Added Dockerfile-dev and updated build.sh
|
||||
* Require python 3.7 and >
|
||||
* Added plugin live reload and StockPlugin
|
||||
* Updated Dockerfile and build.sh
|
||||
* Updated Dockerfile for multiplatform builds
|
||||
* Updated Dockerfile for multiplatform builds
|
||||
* Dockerfile: Make creation of /config quiet failure
|
||||
* Updated README docs
|
||||
|
||||
v1.6.0
|
||||
------
|
||||
|
||||
* 1.6.0 release prep
|
||||
* 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
|
||||
------
|
||||
|
||||
-42
@@ -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"]
|
||||
+20
-8
@@ -1,24 +1,36 @@
|
||||
|
||||
# installation instructions for the short-attention-span user like me
|
||||
|
||||
mkdir -p ~/.aprsd
|
||||
# First off the easiest way is to use the official pypi release.
|
||||
# To use the official release:
|
||||
pip install aprsd
|
||||
|
||||
# For developers, there are a few ways:
|
||||
# The EASY way? Use the makefile:
|
||||
git clone https://github.com/craigerl/aprsd.git
|
||||
cd aprsd
|
||||
make dev
|
||||
source .venv/bin/activate
|
||||
|
||||
|
||||
# The HARD way?
|
||||
cd ~
|
||||
virtualenv .venv_aprsd
|
||||
cd .venv_aprsd/
|
||||
source ./bin/activate
|
||||
|
||||
sudo apt get install virtualenv
|
||||
virtualenv ~/.venv_aprsd
|
||||
source ~/.venv_aprsd/bin/activate
|
||||
mkdir ~/aprsd2
|
||||
cd ~/aprsd2
|
||||
git clone https://github.com/craigerl/aprsd.git
|
||||
cd aprsd
|
||||
pip install -e .
|
||||
cd ~/.venv_aprsd/bin
|
||||
|
||||
./aprsd sample-config # generates a config.yml template
|
||||
|
||||
# CONFIGURE
|
||||
# Now configure aprsd HERE
|
||||
./aprsd sample-config # generates a config.yml template
|
||||
|
||||
vi ~/.config/aprsd/config.yml # copy/edit config here
|
||||
|
||||
./aprsd server
|
||||
aprsd server
|
||||
|
||||
# profit! #
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
.PHONY: virtual dev build-requirements black isort flake8
|
||||
REQUIREMENTS_TXT ?= requirements.txt dev-requirements.txt
|
||||
|
||||
include Makefile.venv
|
||||
Makefile.venv:
|
||||
curl \
|
||||
-o Makefile.fetched \
|
||||
-L "https://github.com/sio/Makefile.venv/raw/v2020.08.14/Makefile.venv"
|
||||
echo "5afbcf51a82f629cd65ff23185acde90ebe4dec889ef80bbdc12562fbd0b2611 *Makefile.fetched" \
|
||||
| sha256sum --check - \
|
||||
&& mv Makefile.fetched Makefile.venv
|
||||
|
||||
all: pip dev
|
||||
|
||||
virtual: .venv/bin/pip # Creates an isolated python 3 environment
|
||||
.PHONY: dev
|
||||
dev: venv
|
||||
$(VENV)/pre-commit install
|
||||
|
||||
.venv/bin/pip:
|
||||
virtualenv -p /usr/bin/python3 .venv
|
||||
.PHONY: docs
|
||||
docs: build
|
||||
cp README.rst docs/readme.rst
|
||||
cp Changelog docs/changelog.rst
|
||||
tox -edocs
|
||||
|
||||
.venv/bin/aprsd: virtual
|
||||
test -s .venv/bin/aprsd || .venv/bin/pip install -q -e .
|
||||
.PHONY: server
|
||||
server: venv
|
||||
$(VENV)/aprsd server --loglevel DEBUG
|
||||
|
||||
install: .venv/bin/aprsd
|
||||
.venv/bin/pip install -Ur requirements.txt
|
||||
|
||||
dev-pre-commit:
|
||||
test -s .git/hooks/pre-commit || .venv/bin/pre-commit install
|
||||
|
||||
dev-requirements:
|
||||
test -s .venv/bin/twine || .venv/bin/pip install -q -r dev-requirements.txt
|
||||
|
||||
pip: virtual
|
||||
.venv/bin/pip install -q -U pip
|
||||
|
||||
dev: pip .venv/bin/aprsd dev-requirements dev-pre-commit
|
||||
|
||||
pip-tools:
|
||||
test -s .venv/bin/pip-compile || .venv/bin/pip install pip-tools
|
||||
|
||||
clean:
|
||||
clean: clean-venv
|
||||
rm -rf dist/*
|
||||
rm -rf .venv
|
||||
|
||||
.PHONY: test
|
||||
test: dev
|
||||
.venv/bin/pre-commit run --all-files
|
||||
tox -p all
|
||||
|
||||
build: test
|
||||
rm -rf dist/*
|
||||
.venv/bin/python3 setup.py sdist bdist_wheel
|
||||
.venv/bin/twine check dist/*
|
||||
$(VENV)/python3 setup.py sdist bdist_wheel
|
||||
$(VENV)/twine check dist/*
|
||||
|
||||
upload: build
|
||||
.venv/bin/twine upload dist/*
|
||||
$(VENV)/twine upload dist/*
|
||||
|
||||
update-requirements: dev pip-tools
|
||||
.venv/bin/pip-compile -q -U requirements.in
|
||||
.venv/bin/pip-compile -q -U dev-requirements.in
|
||||
docker: test
|
||||
docker build -t hemna6969/aprsd:latest -f docker/Dockerfile docker
|
||||
|
||||
.venv/bin/tox: # install tox
|
||||
test -s .venv/bin/tox || .venv/bin/pip install -q -U tox
|
||||
docker-dev: test
|
||||
docker build -t hemna6969/aprsd:master -f docker/Dockerfile-dev docker
|
||||
|
||||
check: .venv/bin/tox # Code format check with isort and black
|
||||
update-requirements: dev
|
||||
$(VENV)/pip-compile requirements.in
|
||||
$(VENV)/pip-compile dev-requirements.in
|
||||
|
||||
|
||||
check: dev # Code format check with isort and black
|
||||
tox -efmt-check
|
||||
tox -epep8
|
||||
|
||||
fix: .venv/bin/tox # fixes code formatting with isort and black
|
||||
fix: dev # fixes code formatting with isort and black
|
||||
tox -efmt
|
||||
|
||||
+103
-42
@@ -1,6 +1,7 @@
|
||||
=====
|
||||
APRSD
|
||||
=====
|
||||
by KM6LYW and WB4BOR
|
||||
|
||||
.. image:: https://badge.fury.io/py/aprsd.svg
|
||||
:target: https://badge.fury.io/py/aprsd
|
||||
@@ -36,6 +37,8 @@ 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
|
||||
----------------------
|
||||
@@ -51,7 +54,9 @@ the weather. an APRS message is sent, and then picked up by APRSD. The
|
||||
APRS packet is decoded, and the message is sent through the list of plugins
|
||||
for processing. For example, the WeatherPlugin picks up the message, fetches the weather
|
||||
for the area around the user who sent the request, and then responds with
|
||||
the weather conditions in that area.
|
||||
the weather conditions in that area. Also includes a watch list of HAM
|
||||
callsigns to look out for. The watch list can notify you when a HAM callsign
|
||||
in the list is seen and now available to message on the APRS network.
|
||||
|
||||
|
||||
APRSD Capabilities
|
||||
@@ -79,6 +84,18 @@ If it matches, the plugin runs. IF the regex doesn't match, the plugin is skipp
|
||||
* VersionPlugin - Reports the version information for aprsd
|
||||
|
||||
|
||||
List of core notification plugins
|
||||
=================================
|
||||
|
||||
These plugins see all APRS messages from ham callsigns in the config's watch
|
||||
list.
|
||||
|
||||
* NotifySeenPlugin - Send a message when a message is seen from a callsign in
|
||||
the watch list. This is helpful when you want to know
|
||||
when a friend is online in the ARPS network, but haven't
|
||||
been seen in a while.
|
||||
|
||||
|
||||
Current messages this will respond to:
|
||||
======================================
|
||||
|
||||
@@ -156,43 +173,82 @@ Output
|
||||
======
|
||||
::
|
||||
|
||||
└─[$] > aprsd sample-config
|
||||
|
||||
└─> aprsd sample-config
|
||||
aprs:
|
||||
host: rotate.aprs.net
|
||||
logfile: /tmp/arsd.log
|
||||
login: someusername
|
||||
password: somepassword
|
||||
port: 14580
|
||||
# Get the passcode for your callsign here:
|
||||
# https://apps.magicbug.co.uk/passcode
|
||||
host: rotate.aprs2.net
|
||||
login: CALLSIGN
|
||||
password: '00000'
|
||||
port: 14580
|
||||
aprsd:
|
||||
enabled_plugins:
|
||||
- aprsd.plugin.EmailPlugin
|
||||
- aprsd.plugin.FortunePlugin
|
||||
- aprsd.plugin.LocationPlugin
|
||||
- aprsd.plugin.PingPlugin
|
||||
- aprsd.plugin.TimePlugin
|
||||
- aprsd.plugin.WeatherPlugin
|
||||
- aprsd.plugin.VersionPlugin
|
||||
plugin_dir: ~/.config/aprsd/plugins
|
||||
dateformat: '%m/%d/%Y %I:%M:%S %p'
|
||||
email:
|
||||
enabled: true
|
||||
imap:
|
||||
debug: false
|
||||
host: imap.gmail.com
|
||||
login: IMAP_USERNAME
|
||||
password: IMAP_PASSWORD
|
||||
port: 993
|
||||
use_ssl: true
|
||||
shortcuts:
|
||||
aa: 5551239999@vtext.com
|
||||
cl: craiglamparter@somedomain.org
|
||||
wb: 555309@vtext.com
|
||||
smtp:
|
||||
debug: false
|
||||
host: smtp.gmail.com
|
||||
login: SMTP_USERNAME
|
||||
password: SMTP_PASSWORD
|
||||
port: 465
|
||||
use_ssl: false
|
||||
enabled_plugins:
|
||||
- aprsd.plugins.email.EmailPlugin
|
||||
- aprsd.plugins.fortune.FortunePlugin
|
||||
- aprsd.plugins.location.LocationPlugin
|
||||
- aprsd.plugins.ping.PingPlugin
|
||||
- aprsd.plugins.query.QueryPlugin
|
||||
- aprsd.plugins.stock.StockPlugin
|
||||
- aprsd.plugins.time.TimePlugin
|
||||
- aprsd.plugins.weather.USWeatherPlugin
|
||||
- aprsd.plugins.version.VersionPlugin
|
||||
logfile: /tmp/aprsd.log
|
||||
logformat: '[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s] %(message)s - [%(pathname)s:%(lineno)d]'
|
||||
trace: false
|
||||
units: imperial
|
||||
web:
|
||||
enabled: true
|
||||
host: 0.0.0.0
|
||||
logging_enabled: true
|
||||
port: 8001
|
||||
users:
|
||||
admin: aprsd
|
||||
ham:
|
||||
callsign: KFART
|
||||
imap:
|
||||
host: imap.gmail.com
|
||||
login: imapuser
|
||||
password: something here too
|
||||
port: 993
|
||||
use_ssl: true
|
||||
shortcuts:
|
||||
aa: 5551239999@vtext.com
|
||||
cl: craiglamparter@somedomain.org
|
||||
wb: 555309@vtext.com
|
||||
smtp:
|
||||
host: imap.gmail.com
|
||||
login: something
|
||||
password: some lame password
|
||||
port: 465
|
||||
use_ssl: false
|
||||
|
||||
callsign: CALLSIGN
|
||||
services:
|
||||
aprs.fi:
|
||||
# Get the apiKey from your aprs.fi account here:
|
||||
# http://aprs.fi/account
|
||||
apiKey: APIKEYVALUE
|
||||
avwx:
|
||||
# (Optional for AVWXWeatherPlugin)
|
||||
# Use hosted avwx-api here: https://avwx.rest
|
||||
# or deploy your own from here:
|
||||
# https://github.com/avwx-rest/avwx-api
|
||||
apiKey: APIKEYVALUE
|
||||
base_url: http://host:port
|
||||
opencagedata:
|
||||
# (Optional for TimeOpenCageDataPlugin)
|
||||
# Get the apiKey from your opencagedata account here:
|
||||
# https://opencagedata.com/dashboard#api-keys
|
||||
apiKey: APIKEYVALUE
|
||||
openweathermap:
|
||||
# (Optional for OWMWeatherPlugin)
|
||||
# Get the apiKey from your
|
||||
# openweathermap account here:
|
||||
# https://home.openweathermap.org/api_keys
|
||||
apiKey: APIKEYVALUE
|
||||
|
||||
server
|
||||
======
|
||||
@@ -210,7 +266,7 @@ look for incomming commands to the callsign configured in the config file
|
||||
Options:
|
||||
--loglevel [CRITICAL|ERROR|WARNING|INFO|DEBUG]
|
||||
The log level to use for aprsd.log
|
||||
[default: DEBUG]
|
||||
[default: INFO]
|
||||
|
||||
--quiet Don't log to stdout
|
||||
--disable-validation Disable email shortcut validation. Bad
|
||||
@@ -218,15 +274,20 @@ look for incomming commands to the callsign configured in the config file
|
||||
responses!!
|
||||
|
||||
-c, --config TEXT The aprsd config file to use for options.
|
||||
[default: ~/.config/aprsd/aprsd.yml]
|
||||
[default:
|
||||
/home/waboring/.config/aprsd/aprsd.yml]
|
||||
|
||||
-f, --flush Flush out all old aged messages on disk.
|
||||
[default: False]
|
||||
|
||||
-h, --help Show this message and exit.
|
||||
(.venv3) ┌─[waboring@dl360-1] - [~/devel/aprsd] - [Sun Dec 20, 12:32] -
|
||||
└─[$] <git:(master*)> aprsd server
|
||||
|
||||
$ aprsd server
|
||||
Load config
|
||||
[12/20/2020 12:33:03 PM] [MainThread ] [INFO ] APRSD Started version: 1.0.2
|
||||
[12/20/2020 12:33:03 PM] [MainThread ] [INFO ] Checking IMAP configuration
|
||||
[12/20/2020 12:33:04 PM] [MainThread ] [INFO ] Checking SMTP configuration
|
||||
[02/13/2021 09:22:09 AM] [MainThread ] [INFO ] APRSD Started version: 1.6.0
|
||||
[02/13/2021 09:22:09 AM] [MainThread ] [INFO ] Checking IMAP configuration
|
||||
[02/13/2021 09:22:09 AM] [MainThread ] [INFO ] Checking SMTP configuration
|
||||
[02/13/2021 09:22:10 AM] [MainThread ] [INFO ] Validating 2 Email shortcuts. This can take up to 10 seconds per shortcut
|
||||
|
||||
|
||||
send-message
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"aprsd" : {
|
||||
"title" : "APRSD APRS-IS server log format",
|
||||
"description" : "Log formats used by ARPRSD server",
|
||||
"url" : "http://github.com/craigerl/aprsd",
|
||||
"regex" : {
|
||||
"std" : {
|
||||
"pattern" : "^\\[(?<timestamp>\\d{2}\\/\\d{2}\\/\\d{4} \\d{2}:\\d{2}:\\d{2} ([AaPp][Mm]))\\] \\[(?<thread>\\w+\\s*)\\] \\[(?<alert_level>\\w+\\s*)\\] (?<body>([^-]*)-*)\\s\\[(?<file>([^:]*))\\:(?<line>\\d+)\\]"
|
||||
}
|
||||
},
|
||||
"level-field" : "alert_level",
|
||||
"level" : {
|
||||
"info" : "INFO",
|
||||
"error" : "ERROR",
|
||||
"warning" : "WARN",
|
||||
"debug" : "DEBUG",
|
||||
"fatal" : "FATAL",
|
||||
"info" : "UNKNOWN"
|
||||
},
|
||||
"value" : {
|
||||
"alert_level": { "kind" : "string", "identifier" : true },
|
||||
"thread": { "kind" : "string", "identifier" : true },
|
||||
"body" : { "kind" : "string" },
|
||||
"file" : { "kind" : "string" }
|
||||
},
|
||||
"timestamp-field" : "timestamp",
|
||||
"timestamp-format" : [
|
||||
"%m/%d/%Y %I:%M:%S %p"
|
||||
],
|
||||
"sample" : [
|
||||
{
|
||||
"line" : "[03/30/2021 08:57:44 PM] [MainThread ] [INFO ] Skipping Custom Plugins directory. - [/home/waboring/devel/aprsd/aprsd/plugin.py:232]"
|
||||
},
|
||||
{
|
||||
"line" : "[03/30/2021 08:57:44 PM] [KeepAlive ] [DEBUG] Uptime (0:00:00.577754) Tracker(0) Msgs: TX:0 RX:0 EmailThread: N/A RAM: Current:50289 Peak:99697 - [/home/waboring/devel/aprsd/aprsd/threads.py:89]"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+78
-4
@@ -3,9 +3,17 @@ import select
|
||||
import time
|
||||
|
||||
import aprsd
|
||||
from aprsd import stats
|
||||
import aprslib
|
||||
from aprslib import is_py3
|
||||
from aprslib.exceptions import LoginError
|
||||
from aprslib.exceptions import (
|
||||
ConnectionDrop,
|
||||
ConnectionError,
|
||||
GenericError,
|
||||
LoginError,
|
||||
ParseError,
|
||||
UnknownFormat,
|
||||
)
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
@@ -18,6 +26,7 @@ class Client:
|
||||
config = None
|
||||
|
||||
connected = False
|
||||
server_string = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""This magic turns this into a singleton."""
|
||||
@@ -153,7 +162,16 @@ class Aprsdis(aprslib.IS):
|
||||
|
||||
self.logger.debug("Server: %s", test)
|
||||
|
||||
_, _, callsign, status, _ = test.split(" ", 4)
|
||||
a, b, callsign, status, e = test.split(" ", 4)
|
||||
s = e.split(",")
|
||||
if len(s):
|
||||
server_string = s[0].replace("server ", "")
|
||||
else:
|
||||
server_string = e.replace("server ", "")
|
||||
|
||||
self.logger.info("Connected to {}".format(server_string))
|
||||
self.server_string = server_string
|
||||
stats.APRSDStats().set_aprsis_server(server_string)
|
||||
|
||||
if callsign == "":
|
||||
raise LoginError("Server responded with empty callsign???")
|
||||
@@ -171,11 +189,67 @@ class Aprsdis(aprslib.IS):
|
||||
self.logger.error(str(e))
|
||||
self.close()
|
||||
raise
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
self.close()
|
||||
self.logger.error("Failed to login")
|
||||
self.logger.error("Failed to login '{}'".format(e))
|
||||
raise LoginError("Failed to login")
|
||||
|
||||
def consumer(self, callback, blocking=True, immortal=False, raw=False):
|
||||
"""
|
||||
When a position sentence is received, it will be passed to the callback function
|
||||
|
||||
blocking: if true (default), runs forever, otherwise will return after one sentence
|
||||
You can still exit the loop, by raising StopIteration in the callback function
|
||||
|
||||
immortal: When true, consumer will try to reconnect and stop propagation of Parse exceptions
|
||||
if false (default), consumer will return
|
||||
|
||||
raw: when true, raw packet is passed to callback, otherwise the result from aprs.parse()
|
||||
"""
|
||||
|
||||
if not self._connected:
|
||||
raise ConnectionError("not connected to a server")
|
||||
|
||||
line = b""
|
||||
|
||||
while True:
|
||||
try:
|
||||
for line in self._socket_readlines(blocking):
|
||||
if line[0:1] != b"#":
|
||||
if raw:
|
||||
callback(line)
|
||||
else:
|
||||
callback(self._parse(line))
|
||||
else:
|
||||
self.logger.debug("Server: %s", line.decode("utf8"))
|
||||
stats.APRSDStats().set_aprsis_keepalive()
|
||||
except ParseError as exp:
|
||||
self.logger.log(11, "%s\n Packet: %s", exp.args[0], exp.args[1])
|
||||
except UnknownFormat as exp:
|
||||
self.logger.log(9, "unknown format %s", exp.args)
|
||||
except LoginError as exp:
|
||||
self.logger.error("%s: %s", exp.__class__.__name__, exp.args[0])
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
raise
|
||||
except (ConnectionDrop, ConnectionError):
|
||||
self.close()
|
||||
|
||||
if not immortal:
|
||||
raise
|
||||
else:
|
||||
self.connect(blocking=blocking)
|
||||
continue
|
||||
except GenericError:
|
||||
pass
|
||||
except StopIteration:
|
||||
break
|
||||
except Exception:
|
||||
self.logger.error("APRS Packet: %s", line)
|
||||
raise
|
||||
|
||||
if not blocking:
|
||||
break
|
||||
|
||||
|
||||
def get_client():
|
||||
cl = Client()
|
||||
|
||||
+197
@@ -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
@@ -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()
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from logging import NullHandler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import sys
|
||||
|
||||
import aprsd
|
||||
from aprsd import messaging, packets, plugin, stats, utils
|
||||
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
|
||||
|
||||
@auth.login_required
|
||||
def index(self):
|
||||
stats = self._stats()
|
||||
LOG.debug(
|
||||
"watch list? {}".format(
|
||||
self.config["aprsd"]["watch_list"],
|
||||
),
|
||||
)
|
||||
wl = packets.WatchList()
|
||||
if wl.is_enabled():
|
||||
watch_count = len(wl.callsigns)
|
||||
watch_age = wl.max_delta()
|
||||
else:
|
||||
watch_count = 0
|
||||
watch_age = 0
|
||||
|
||||
return flask.render_template(
|
||||
"index.html",
|
||||
initial_stats=stats,
|
||||
callsign=self.config["aprs"]["login"],
|
||||
version=aprsd.__version__,
|
||||
config_json=json.dumps(self.config),
|
||||
watch_count=watch_count,
|
||||
watch_age=watch_age,
|
||||
)
|
||||
|
||||
@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 packets(self):
|
||||
packet_list = packets.PacketList().get()
|
||||
return json.dumps(packet_list)
|
||||
|
||||
@auth.login_required
|
||||
def plugins(self):
|
||||
pm = plugin.PluginManager()
|
||||
pm.reload_plugins()
|
||||
|
||||
return "reloaded"
|
||||
|
||||
@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()
|
||||
now = datetime.datetime.now()
|
||||
|
||||
time_format = "%m-%d-%Y %H:%M:%S"
|
||||
|
||||
stats_dict = stats_obj.stats()
|
||||
|
||||
# Convert the watch_list entries to age
|
||||
wl = packets.WatchList()
|
||||
new_list = {}
|
||||
for call in wl.callsigns:
|
||||
# call_date = datetime.datetime.strptime(
|
||||
# str(wl.last_seen(call)),
|
||||
# "%Y-%m-%d %H:%M:%S.%f",
|
||||
# )
|
||||
new_list[call] = {
|
||||
"last": wl.age(call),
|
||||
"packets": wl.callsigns[call]["packets"].get(),
|
||||
}
|
||||
|
||||
stats_dict["aprsd"]["watch_list"] = new_list
|
||||
|
||||
result = {
|
||||
"time": now.strftime(time_format),
|
||||
"size_tracker": len(track),
|
||||
"stats": stats_dict,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def stats(self):
|
||||
return json.dumps(self._stats())
|
||||
|
||||
|
||||
def setup_logging(config, flask_app, loglevel, quiet):
|
||||
flask_log = logging.getLogger("werkzeug")
|
||||
|
||||
if not config["aprsd"]["web"].get("logging_enabled", False):
|
||||
# disable web logging
|
||||
flask_log.disabled = True
|
||||
flask_app.logger.disabled = True
|
||||
return
|
||||
|
||||
log_level = utils.LOG_LEVELS[loglevel]
|
||||
LOG.setLevel(log_level)
|
||||
log_format = config["aprsd"].get("logformat", utils.DEFAULT_LOG_FORMAT)
|
||||
date_format = config["aprsd"].get("dateformat", utils.DEFAULT_DATE_FORMAT)
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
log_file = config["aprsd"].get("logfile", None)
|
||||
if log_file:
|
||||
fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4)
|
||||
else:
|
||||
fh = NullHandler()
|
||||
|
||||
fh.setFormatter(log_formatter)
|
||||
for handler in flask_app.logger.handlers:
|
||||
handler.setFormatter(log_formatter)
|
||||
print(handler)
|
||||
|
||||
flask_log.addHandler(fh)
|
||||
|
||||
if not quiet:
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(log_formatter)
|
||||
flask_log.addHandler(sh)
|
||||
|
||||
|
||||
def init_flask(config, loglevel, quiet):
|
||||
flask_app = flask.Flask(
|
||||
"aprsd",
|
||||
static_url_path="",
|
||||
static_folder="web/static",
|
||||
template_folder="web/templates",
|
||||
)
|
||||
setup_logging(config, flask_app, loglevel, quiet)
|
||||
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("/packets", methods=["GET"])(server.packets)
|
||||
flask_app.route("/save", methods=["GET"])(server.save)
|
||||
flask_app.route("/plugins", methods=["GET"])(server.plugins)
|
||||
return flask_app
|
||||
@@ -0,0 +1,231 @@
|
||||
#
|
||||
# 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)
|
||||
|
||||
aprsis_last_update = stats["stats"]["aprs-is"]["last_update"]
|
||||
delta = parse_delta_str(aprsis_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("APRS-IS last update is very old! {}".format(d))
|
||||
sys.exit(-1)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
#
|
||||
# Listen on amateur radio aprs-is network for messages and respond to them.
|
||||
# You must have an amateur radio callsign to use this software. You must
|
||||
# create an ~/.aprsd/config.yml file with all of the required settings. To
|
||||
# generate an example config.yml, just run aprsd, then copy the sample config
|
||||
# to ~/.aprsd/config.yml and edit the settings.
|
||||
#
|
||||
# APRS messages:
|
||||
# l(ocation) = descriptive location of calling station
|
||||
# w(eather) = temp, (hi/low) forecast, later forecast
|
||||
# t(ime) = respond with the current time
|
||||
# f(ortune) = respond with a short fortune
|
||||
# -email_addr email text = send an email
|
||||
# -2 = display the last 2 emails received
|
||||
# p(ing) = respond with Pong!/time
|
||||
# anything else = respond with usage
|
||||
#
|
||||
# (C)2018 Craig Lamparter
|
||||
# License GPLv2
|
||||
#
|
||||
|
||||
# python included libs
|
||||
import datetime
|
||||
import logging
|
||||
from logging import NullHandler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
# local imports here
|
||||
import aprsd
|
||||
from aprsd import client, messaging, stats, threads, trace, utils
|
||||
import aprslib
|
||||
from aprslib.exceptions import LoginError
|
||||
import click
|
||||
import click_completion
|
||||
|
||||
# setup the global logger
|
||||
# logging.basicConfig(level=logging.DEBUG) # level=10
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
flask_enabled = False
|
||||
|
||||
# server_event = threading.Event()
|
||||
|
||||
# localization, please edit:
|
||||
# HOST = "noam.aprs2.net" # north america tier2 servers round robin
|
||||
# USER = "KM6XXX-9" # callsign of this aprs client with SSID
|
||||
# PASS = "99999" # google how to generate this
|
||||
# BASECALLSIGN = "KM6XXX" # callsign of radio in the field to send email
|
||||
# shortcuts = {
|
||||
# "aa" : "5551239999@vtext.com",
|
||||
# "cl" : "craiglamparter@somedomain.org",
|
||||
# "wb" : "5553909472@vtext.com"
|
||||
# }
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
global flask_enabled
|
||||
|
||||
threads.APRSDThreadList().stop_all()
|
||||
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
|
||||
# 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 = utils.LOG_LEVELS[loglevel]
|
||||
LOG.setLevel(log_level)
|
||||
log_format = config["aprsd"].get("logformat", utils.DEFAULT_LOG_FORMAT)
|
||||
date_format = config["aprsd"].get("dateformat", utils.DEFAULT_DATE_FORMAT)
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
log_file = config["aprsd"].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)
|
||||
|
||||
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()
|
||||
@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("--quiet", is_flag=True, default=False, help="Don't log to stdout")
|
||||
@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(
|
||||
"--aprs-login",
|
||||
envvar="APRS_LOGIN",
|
||||
show_envvar=True,
|
||||
help="What callsign to send the message from.",
|
||||
)
|
||||
@click.option(
|
||||
"--aprs-password",
|
||||
envvar="APRS_PASSWORD",
|
||||
show_envvar=True,
|
||||
help="the APRS-IS password for APRS_LOGIN",
|
||||
)
|
||||
@click.option(
|
||||
"--no-ack",
|
||||
"-n",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
default=False,
|
||||
help="Don't wait for an ack, just sent it to APRS-IS and bail.",
|
||||
)
|
||||
@click.option("--raw", default=None, help="Send a raw message. Implies --no-ack")
|
||||
@click.argument("tocallsign", required=False)
|
||||
@click.argument("command", nargs=-1, required=False)
|
||||
def listen(
|
||||
loglevel,
|
||||
quiet,
|
||||
config_file,
|
||||
aprs_login,
|
||||
aprs_password,
|
||||
no_ack,
|
||||
raw,
|
||||
tocallsign,
|
||||
command,
|
||||
):
|
||||
"""Send a message to a callsign via APRS_IS."""
|
||||
global got_ack, got_response
|
||||
|
||||
config = utils.parse_config(config_file)
|
||||
if not aprs_login:
|
||||
click.echo("Must set --aprs_login or APRS_LOGIN")
|
||||
return
|
||||
|
||||
if not aprs_password:
|
||||
click.echo("Must set --aprs-password or APRS_PASSWORD")
|
||||
return
|
||||
|
||||
config["aprs"]["login"] = aprs_login
|
||||
config["aprs"]["password"] = aprs_password
|
||||
messaging.CONFIG = config
|
||||
|
||||
setup_logging(config, loglevel, quiet)
|
||||
LOG.info("APRSD TEST Started version: {}".format(aprsd.__version__))
|
||||
if type(command) is tuple:
|
||||
command = " ".join(command)
|
||||
if not quiet:
|
||||
if raw:
|
||||
LOG.info("L'{}' R'{}'".format(aprs_login, raw))
|
||||
else:
|
||||
LOG.info("L'{}' To'{}' C'{}'".format(aprs_login, tocallsign, command))
|
||||
|
||||
flat_config = utils.flatten_dict(config)
|
||||
LOG.info("Using CONFIG values:")
|
||||
for x in flat_config:
|
||||
if "password" in x or "aprsd.web.users.admin" in x:
|
||||
LOG.info("{} = XXXXXXXXXXXXXXXXXXX".format(x))
|
||||
else:
|
||||
LOG.info("{} = {}".format(x, flat_config[x]))
|
||||
|
||||
got_ack = False
|
||||
got_response = False
|
||||
|
||||
# TODO(walt) - manually edit this list
|
||||
# prior to running aprsd-listen listen
|
||||
watch_list = []
|
||||
|
||||
# build last seen list
|
||||
last_seen = {}
|
||||
for callsign in watch_list:
|
||||
call = callsign.replace("*", "")
|
||||
last_seen[call] = datetime.datetime.now()
|
||||
|
||||
LOG.debug("Last seen list")
|
||||
LOG.debug(last_seen)
|
||||
|
||||
@trace.trace
|
||||
def rx_packet(packet):
|
||||
global got_ack, got_response
|
||||
LOG.debug("Got packet back {}".format(packet["raw"]))
|
||||
|
||||
if packet["from"] in last_seen:
|
||||
now = datetime.datetime.now()
|
||||
age = str(now - last_seen[packet["from"]])
|
||||
|
||||
delta = utils.parse_delta_str(age)
|
||||
d = datetime.timedelta(**delta)
|
||||
|
||||
max_timeout = {
|
||||
"seconds": config["aprsd"]["watch_list"]["alert_time_seconds"],
|
||||
}
|
||||
max_delta = datetime.timedelta(**max_timeout)
|
||||
if d > max_delta:
|
||||
LOG.debug(
|
||||
"NOTIFY!!! {} last seen {} max age={}".format(
|
||||
packet["from"],
|
||||
age,
|
||||
max_delta,
|
||||
),
|
||||
)
|
||||
else:
|
||||
LOG.debug("Not old enough to notify {} < {}".format(d, max_delta))
|
||||
LOG.debug("Update last seen from {}".format(packet["from"]))
|
||||
last_seen[packet["from"]] = now
|
||||
else:
|
||||
LOG.debug(
|
||||
"ignoring packet because {} not in watch list".format(packet["from"]),
|
||||
)
|
||||
|
||||
resp = packet.get("response", None)
|
||||
if resp == "ack":
|
||||
ack_num = packet.get("msgNo")
|
||||
LOG.info("We saw an ACK {} Ignoring".format(ack_num))
|
||||
# messaging.log_packet(packet)
|
||||
got_ack = True
|
||||
else:
|
||||
message = packet.get("message_text", None)
|
||||
fromcall = packet["from"]
|
||||
msg_number = packet.get("msgNo", "0")
|
||||
messaging.log_message(
|
||||
"Received Message",
|
||||
packet["raw"],
|
||||
message,
|
||||
fromcall=fromcall,
|
||||
ack=msg_number,
|
||||
)
|
||||
|
||||
try:
|
||||
cl = client.Client(config)
|
||||
cl.setup_connection()
|
||||
except LoginError:
|
||||
sys.exit(-1)
|
||||
|
||||
aprs_client = client.get_client()
|
||||
|
||||
# filter_str = 'b/{}'.format('/'.join(watch_list))
|
||||
# LOG.debug("Filter by '{}'".format(filter_str))
|
||||
# aprs_client.set_filter(filter_str)
|
||||
filter_str = "p/{}".format("/".join(watch_list))
|
||||
LOG.debug("Filter by '{}'".format(filter_str))
|
||||
aprs_client.set_filter(filter_str)
|
||||
|
||||
while True:
|
||||
try:
|
||||
# This will register a packet consumer with aprslib
|
||||
# When new packets come in the consumer will process
|
||||
# the packet
|
||||
aprs_client.consumer(rx_packet, raw=False)
|
||||
except aprslib.exceptions.ConnectionDrop:
|
||||
LOG.error("Connection dropped, reconnecting")
|
||||
time.sleep(5)
|
||||
# Force the deletion of the client object connected to aprs
|
||||
# This will cause a reconnect, next time client.get_client()
|
||||
# is called
|
||||
cl.reset()
|
||||
except aprslib.exceptions.UnknownFormat:
|
||||
LOG.error("Got a shitty packet")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+139
-55
@@ -20,6 +20,7 @@
|
||||
#
|
||||
|
||||
# python included libs
|
||||
import datetime
|
||||
import logging
|
||||
from logging import NullHandler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
@@ -27,33 +28,26 @@ 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
|
||||
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"])
|
||||
|
||||
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,30 +145,35 @@ 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
|
||||
# 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_level = utils.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_format = config["aprsd"].get("logformat", utils.DEFAULT_LOG_FORMAT)
|
||||
date_format = config["aprsd"].get("dateformat", utils.DEFAULT_DATE_FORMAT)
|
||||
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 +182,64 @@ 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()
|
||||
@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.",
|
||||
)
|
||||
def check_version(loglevel, config_file):
|
||||
config = utils.parse_config(config_file)
|
||||
|
||||
# Force setting the config to the modules that need it
|
||||
# TODO(Walt): convert these modules to classes that can
|
||||
# Accept the config as a constructor param, instead of this
|
||||
# hacky global setting
|
||||
email.CONFIG = config
|
||||
|
||||
setup_logging(config, loglevel, False)
|
||||
level, msg = utils._check_version()
|
||||
if level:
|
||||
LOG.warning(msg)
|
||||
else:
|
||||
LOG.info(msg)
|
||||
|
||||
|
||||
@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,28 +432,20 @@ 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:
|
||||
click.echo("Load config")
|
||||
|
||||
config = utils.parse_config(config_file)
|
||||
|
||||
# Force setting the config to the modules that need it
|
||||
@@ -416,14 +455,36 @@ def server(
|
||||
email.CONFIG = config
|
||||
|
||||
setup_logging(config, loglevel, quiet)
|
||||
level, msg = utils._check_version()
|
||||
if level:
|
||||
LOG.warning(msg)
|
||||
else:
|
||||
LOG.info(msg)
|
||||
LOG.info("APRSD Started version: {}".format(aprsd.__version__))
|
||||
|
||||
# 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)
|
||||
flat_config = utils.flatten_dict(config)
|
||||
LOG.info("Using CONFIG values:")
|
||||
for x in flat_config:
|
||||
if "password" in x or "aprsd.web.users.admin" in x:
|
||||
LOG.info("{} = XXXXXXXXXXXXXXXXXXX".format(x))
|
||||
else:
|
||||
LOG.info("{} = {}".format(x, flat_config[x]))
|
||||
|
||||
if config["aprsd"].get("trace", False):
|
||||
trace.setup_tracing(["method", "api"])
|
||||
stats.APRSDStats(config)
|
||||
|
||||
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)
|
||||
@@ -443,32 +504,55 @@ def server(
|
||||
LOG.debug("Loading saved MsgTrack object.")
|
||||
messaging.MsgTrack().load()
|
||||
|
||||
rx_notify_queue = queue.Queue(maxsize=20)
|
||||
rx_msg_queue = queue.Queue(maxsize=20)
|
||||
tx_msg_queue = queue.Queue(maxsize=20)
|
||||
msg_queues = {"rx": rx_msg_queue, "tx": tx_msg_queue}
|
||||
msg_queues = {
|
||||
"rx": rx_msg_queue,
|
||||
"tx": tx_msg_queue,
|
||||
"notify": rx_notify_queue,
|
||||
}
|
||||
|
||||
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 "watch_list" in config["aprsd"] and config["aprsd"]["watch_list"].get(
|
||||
"enabled",
|
||||
True,
|
||||
):
|
||||
notify_thread = threads.APRSDNotifyThread(
|
||||
msg_queues=msg_queues,
|
||||
config=config,
|
||||
)
|
||||
notify_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, loglevel, quiet)
|
||||
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__":
|
||||
|
||||
+116
-66
@@ -9,7 +9,7 @@ import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aprsd import client, threads, utils
|
||||
from aprsd import client, stats, threads, trace, 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):
|
||||
@@ -159,11 +186,12 @@ class MessageCounter:
|
||||
|
||||
_instance = None
|
||||
max_count = 9999
|
||||
lock = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""Make this a singleton class."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance = super().__new__(cls, *args, **kwargs)
|
||||
cls._instance.val = RawValue("i", 1)
|
||||
cls._instance.lock = threading.Lock()
|
||||
return cls._instance
|
||||
@@ -196,7 +224,7 @@ class Message(metaclass=abc.ABCMeta):
|
||||
id = 0
|
||||
|
||||
retry_count = 3
|
||||
last_send_time = None
|
||||
last_send_time = 0
|
||||
last_send_attempt = 0
|
||||
|
||||
def __init__(self, fromcall, tocall, msg_id=None):
|
||||
@@ -228,8 +256,20 @@ 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()
|
||||
last_send_age = None
|
||||
if self.last_send_time:
|
||||
last_send_age = str(now - self.last_send_time)
|
||||
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": last_send_age,
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
@@ -245,12 +285,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 +306,35 @@ 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()
|
||||
|
||||
last_send_age = None
|
||||
if self.last_send_time:
|
||||
last_send_age = str(now - self.last_send_time)
|
||||
|
||||
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": last_send_age,
|
||||
}
|
||||
|
||||
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 +357,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 +416,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,34 +438,30 @@ 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()
|
||||
last_send_age = None
|
||||
if self.last_send_time:
|
||||
last_send_age = str(now - self.last_send_time)
|
||||
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": last_send_age,
|
||||
}
|
||||
|
||||
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"),
|
||||
None,
|
||||
ack=self.id,
|
||||
tocall=self.tocall,
|
||||
retry_number=i,
|
||||
)
|
||||
cl.sendall(repr(self))
|
||||
# aprs duplicate detection is 30 secs?
|
||||
# (21 only sends first, 28 skips middle)
|
||||
time.sleep(31)
|
||||
# end_send_ack_thread
|
||||
|
||||
def send(self):
|
||||
LOG.debug("Send ACK({}:{}) to radio.".format(self.tocall, self.id))
|
||||
thread = SendAckThread(self)
|
||||
@@ -428,13 +474,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):
|
||||
@@ -442,8 +488,10 @@ class SendAckThread(threads.APRSDThread):
|
||||
self.ack = ack
|
||||
super().__init__("SendAck-{}".format(self.ack.id))
|
||||
|
||||
@trace.trace
|
||||
def loop(self):
|
||||
"""Separate thread to send acks with retries."""
|
||||
LOG.debug("SendAckThread loop start")
|
||||
send_now = False
|
||||
if self.ack.last_send_attempt == self.ack.retry_count:
|
||||
# we reached the send limit, don't send again
|
||||
@@ -471,16 +519,18 @@ 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)
|
||||
return True
|
||||
|
||||
|
||||
def log_packet(packet):
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import datetime
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aprsd import utils
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
PACKET_TYPE_MESSAGE = "message"
|
||||
PACKET_TYPE_ACK = "ack"
|
||||
PACKET_TYPE_MICE = "mic-e"
|
||||
|
||||
|
||||
class PacketList:
|
||||
"""Class to track all of the packets rx'd and tx'd by aprsd."""
|
||||
|
||||
_instance = None
|
||||
|
||||
packet_list = {}
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.packet_list = utils.RingBuffer(100)
|
||||
cls._instance.lock = threading.Lock()
|
||||
return cls._instance
|
||||
|
||||
def __iter__(self):
|
||||
with self.lock:
|
||||
return iter(self.packet_list)
|
||||
|
||||
def add(self, packet):
|
||||
with self.lock:
|
||||
packet["ts"] = time.time()
|
||||
self.packet_list.append(packet)
|
||||
|
||||
def get(self):
|
||||
with self.lock:
|
||||
return self.packet_list.get()
|
||||
|
||||
|
||||
class WatchList:
|
||||
"""Global watch list and info for callsigns."""
|
||||
|
||||
_instance = None
|
||||
callsigns = {}
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.lock = threading.Lock()
|
||||
cls.callsigns = {}
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, config=None):
|
||||
if config:
|
||||
self.config = config
|
||||
|
||||
ring_size = config["aprsd"]["watch_list"]["packet_keep_count"]
|
||||
|
||||
for callsign in config["aprsd"]["watch_list"].get("callsigns", []):
|
||||
call = callsign.replace("*", "")
|
||||
# FIXME(waboring) - we should fetch the last time we saw
|
||||
# a beacon from a callsign or some other mechanism to find
|
||||
# last time a message was seen by aprs-is. For now this
|
||||
# is all we can do.
|
||||
self.callsigns[call] = {
|
||||
"last": datetime.datetime.now(),
|
||||
"packets": utils.RingBuffer(
|
||||
ring_size,
|
||||
),
|
||||
}
|
||||
|
||||
def is_enabled(self):
|
||||
if "watch_list" in self.config["aprsd"]:
|
||||
return self.config["aprsd"]["watch_list"].get("enabled", False)
|
||||
else:
|
||||
return False
|
||||
|
||||
def callsign_in_watchlist(self, callsign):
|
||||
return callsign in self.callsigns
|
||||
|
||||
def update_seen(self, packet):
|
||||
callsign = packet["from"]
|
||||
if self.callsign_in_watchlist(callsign):
|
||||
self.callsigns[callsign]["last"] = datetime.datetime.now()
|
||||
self.callsigns[callsign]["packets"].append(packet)
|
||||
|
||||
def last_seen(self, callsign):
|
||||
if self.callsign_in_watchlist(callsign):
|
||||
return self.callsigns[callsign]["last"]
|
||||
|
||||
def age(self, callsign):
|
||||
now = datetime.datetime.now()
|
||||
return str(now - self.last_seen(callsign))
|
||||
|
||||
def max_delta(self, seconds=None):
|
||||
watch_list_conf = self.config["aprsd"]["watch_list"]
|
||||
if not seconds:
|
||||
seconds = watch_list_conf["alert_time_seconds"]
|
||||
max_timeout = {"seconds": seconds}
|
||||
return datetime.timedelta(**max_timeout)
|
||||
|
||||
def is_old(self, callsign, seconds=None):
|
||||
"""Watch list callsign last seen is old compared to now?
|
||||
|
||||
This tests to see if the last time we saw a callsign packet,
|
||||
if that is older than the allowed timeout in the config.
|
||||
|
||||
We put this here so any notification plugin can use this
|
||||
same test.
|
||||
"""
|
||||
age = self.age(callsign)
|
||||
|
||||
delta = utils.parse_delta_str(age)
|
||||
d = datetime.timedelta(**delta)
|
||||
|
||||
max_delta = self.max_delta(seconds=seconds)
|
||||
|
||||
if d > max_delta:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def get_packet_type(packet):
|
||||
"""Decode the packet type from the packet."""
|
||||
|
||||
msg_format = packet.get("format", None)
|
||||
msg_response = packet.get("response", None)
|
||||
packet_type = "unknown"
|
||||
if msg_format == "message":
|
||||
packet_type = PACKET_TYPE_MESSAGE
|
||||
elif msg_response == "ack":
|
||||
packet_type = PACKET_TYPE_ACK
|
||||
elif msg_format == "mic-e":
|
||||
packet_type = PACKET_TYPE_MICE
|
||||
return packet_type
|
||||
+135
-48
@@ -6,6 +6,7 @@ import inspect
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
import pluggy
|
||||
from thesmuggler import smuggle
|
||||
@@ -16,31 +17,74 @@ LOG = logging.getLogger("APRSD")
|
||||
hookspec = pluggy.HookspecMarker("aprsd")
|
||||
hookimpl = pluggy.HookimplMarker("aprsd")
|
||||
|
||||
CORE_PLUGINS = [
|
||||
CORE_MESSAGE_PLUGINS = [
|
||||
"aprsd.plugins.email.EmailPlugin",
|
||||
"aprsd.plugins.fortune.FortunePlugin",
|
||||
"aprsd.plugins.location.LocationPlugin",
|
||||
"aprsd.plugins.ping.PingPlugin",
|
||||
"aprsd.plugins.query.QueryPlugin",
|
||||
"aprsd.plugins.stock.StockPlugin",
|
||||
"aprsd.plugins.time.TimePlugin",
|
||||
"aprsd.plugins.weather.WeatherPlugin",
|
||||
"aprsd.plugins.weather.USWeatherPlugin",
|
||||
"aprsd.plugins.version.VersionPlugin",
|
||||
]
|
||||
|
||||
CORE_NOTIFY_PLUGINS = [
|
||||
"aprsd.plugins.notify.NotifySeenPlugin",
|
||||
]
|
||||
|
||||
|
||||
class APRSDCommandSpec:
|
||||
"""A hook specification namespace."""
|
||||
|
||||
@hookspec
|
||||
def run(self, fromcall, message, ack):
|
||||
def run(self, packet):
|
||||
"""My special little hook that you can customize."""
|
||||
pass
|
||||
|
||||
|
||||
class APRSDPluginBase(metaclass=abc.ABCMeta):
|
||||
class APRSDNotificationPluginBase(metaclass=abc.ABCMeta):
|
||||
"""Base plugin class for all notification ased plugins.
|
||||
|
||||
All these plugins will get every packet seen by APRSD's
|
||||
registered list of HAM callsigns in the config file's
|
||||
watch_list.
|
||||
|
||||
When you want to 'notify' something when a packet is seen
|
||||
by a particular HAM callsign, write a plugin based off of
|
||||
this class.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
"""The aprsd config object is stored."""
|
||||
self.config = config
|
||||
self.message_counter = 0
|
||||
|
||||
@hookimpl
|
||||
def run(self, packet):
|
||||
return self.notify(packet)
|
||||
|
||||
@abc.abstractmethod
|
||||
def notify(self, packet):
|
||||
"""This is the main method called when a packet is rx.
|
||||
|
||||
This will get called when a packet is seen by a callsign
|
||||
registered in the watch list in the config file."""
|
||||
pass
|
||||
|
||||
|
||||
class APRSDMessagePluginBase(metaclass=abc.ABCMeta):
|
||||
"""Base Message plugin class.
|
||||
|
||||
When you want to search for a particular command in an
|
||||
APRSD message and send a direct reply, write a plugin
|
||||
based off of this class.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
"""The aprsd config object is stored."""
|
||||
self.config = config
|
||||
self.message_counter = 0
|
||||
|
||||
@property
|
||||
def command_name(self):
|
||||
@@ -57,13 +101,19 @@ class APRSDPluginBase(metaclass=abc.ABCMeta):
|
||||
"""Version"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def message_count(self):
|
||||
return self.message_counter
|
||||
|
||||
@hookimpl
|
||||
def run(self, fromcall, message, ack):
|
||||
def run(self, packet):
|
||||
message = packet.get("message_text", None)
|
||||
if re.search(self.command_regex, message):
|
||||
return self.command(fromcall, message, ack)
|
||||
self.message_counter += 1
|
||||
return self.command(packet)
|
||||
|
||||
@abc.abstractmethod
|
||||
def command(self, fromcall, message, ack):
|
||||
def command(self, packet):
|
||||
"""This is the command that runs when the regex matches.
|
||||
|
||||
To reply with a message over the air, return a string
|
||||
@@ -76,17 +126,23 @@ class PluginManager:
|
||||
# The singleton instance object for this class
|
||||
_instance = None
|
||||
|
||||
# the pluggy PluginManager
|
||||
_pluggy_pm = None
|
||||
# the pluggy PluginManager for all Message plugins
|
||||
_pluggy_msg_pm = None
|
||||
|
||||
# the pluggy PluginManager for all Notification plugins
|
||||
_pluggy_notify_pm = None
|
||||
|
||||
# aprsd config dict
|
||||
config = None
|
||||
|
||||
lock = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""This magic turns this into a singleton."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
# Put any initialization here.
|
||||
cls._instance.lock = threading.Lock()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, config=None):
|
||||
@@ -119,7 +175,10 @@ class PluginManager:
|
||||
|
||||
def is_plugin(self, obj):
|
||||
for c in inspect.getmro(obj):
|
||||
if issubclass(c, APRSDPluginBase):
|
||||
if issubclass(c, APRSDMessagePluginBase) or issubclass(
|
||||
c,
|
||||
APRSDNotificationPluginBase,
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -135,6 +194,7 @@ class PluginManager:
|
||||
module_name, class_name = module_class_string.rsplit(".", 1)
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
module = importlib.reload(module)
|
||||
except Exception as ex:
|
||||
LOG.error("Failed to load Plugin '{}' : '{}'".format(module_name, ex))
|
||||
return
|
||||
@@ -155,7 +215,7 @@ class PluginManager:
|
||||
obj = cls(**kwargs)
|
||||
return obj
|
||||
|
||||
def _load_plugin(self, plugin_name):
|
||||
def _load_msg_plugin(self, plugin_name):
|
||||
"""
|
||||
Given a python fully qualified class path.name,
|
||||
Try importing the path, then creating the object,
|
||||
@@ -165,69 +225,96 @@ class PluginManager:
|
||||
try:
|
||||
plugin_obj = self._create_class(
|
||||
plugin_name,
|
||||
APRSDPluginBase,
|
||||
APRSDMessagePluginBase,
|
||||
config=self.config,
|
||||
)
|
||||
if plugin_obj:
|
||||
LOG.info(
|
||||
"Registering Command plugin '{}'({}) '{}'".format(
|
||||
"Registering Message plugin '{}'({}) '{}'".format(
|
||||
plugin_name,
|
||||
plugin_obj.version,
|
||||
plugin_obj.command_regex,
|
||||
),
|
||||
)
|
||||
self._pluggy_pm.register(plugin_obj)
|
||||
self._pluggy_msg_pm.register(plugin_obj)
|
||||
except Exception as ex:
|
||||
LOG.exception("Couldn't load plugin '{}'".format(plugin_name), ex)
|
||||
|
||||
def _load_notify_plugin(self, plugin_name):
|
||||
"""
|
||||
Given a python fully qualified class path.name,
|
||||
Try importing the path, then creating the object,
|
||||
then registering it as a aprsd Command Plugin
|
||||
"""
|
||||
plugin_obj = None
|
||||
try:
|
||||
plugin_obj = self._create_class(
|
||||
plugin_name,
|
||||
APRSDNotificationPluginBase,
|
||||
config=self.config,
|
||||
)
|
||||
if plugin_obj:
|
||||
LOG.info(
|
||||
"Registering Notification plugin '{}'({})".format(
|
||||
plugin_name,
|
||||
plugin_obj.version,
|
||||
),
|
||||
)
|
||||
self._pluggy_notify_pm.register(plugin_obj)
|
||||
except Exception as ex:
|
||||
LOG.exception("Couldn't load plugin '{}'".format(plugin_name), ex)
|
||||
|
||||
def reload_plugins(self):
|
||||
with self.lock:
|
||||
del self._pluggy_msg_pm
|
||||
del self._pluggy_notify_pm
|
||||
self.setup_plugins()
|
||||
|
||||
def setup_plugins(self):
|
||||
"""Create the plugin manager and register plugins."""
|
||||
|
||||
LOG.info("Loading Core APRSD Command Plugins")
|
||||
enabled_plugins = self.config["aprsd"].get("enabled_plugins", None)
|
||||
self._pluggy_pm = pluggy.PluginManager("aprsd")
|
||||
self._pluggy_pm.add_hookspecs(APRSDCommandSpec)
|
||||
if enabled_plugins:
|
||||
for p_name in enabled_plugins:
|
||||
self._load_plugin(p_name)
|
||||
LOG.info("Loading APRSD Message Plugins")
|
||||
enabled_msg_plugins = self.config["aprsd"].get("enabled_plugins", None)
|
||||
self._pluggy_msg_pm = pluggy.PluginManager("aprsd")
|
||||
self._pluggy_msg_pm.add_hookspecs(APRSDCommandSpec)
|
||||
if enabled_msg_plugins:
|
||||
for p_name in enabled_msg_plugins:
|
||||
self._load_msg_plugin(p_name)
|
||||
else:
|
||||
# Enabled plugins isn't set, so we default to loading all of
|
||||
# the core plugins.
|
||||
for p_name in CORE_PLUGINS:
|
||||
for p_name in CORE_MESSAGE_PLUGINS:
|
||||
self._load_plugin(p_name)
|
||||
|
||||
plugin_dir = self.config["aprsd"].get("plugin_dir", None)
|
||||
if plugin_dir:
|
||||
LOG.info("Trying to load custom plugins from '{}'".format(plugin_dir))
|
||||
plugins_list = self.load_plugins_from_path(plugin_dir)
|
||||
if plugins_list:
|
||||
LOG.info("Discovered {} modules to load".format(len(plugins_list)))
|
||||
for o in plugins_list:
|
||||
plugin_obj = None
|
||||
# not setting enabled plugins means load all?
|
||||
plugin_obj = o["obj"]
|
||||
|
||||
if plugin_obj:
|
||||
LOG.info(
|
||||
"Registering Command plugin '{}'({}) '{}'".format(
|
||||
o["name"],
|
||||
o["obj"].version,
|
||||
o["obj"].command_regex,
|
||||
),
|
||||
)
|
||||
self._pluggy_pm.register(o["obj"])
|
||||
if self.config["aprsd"]["watch_list"].get("enabled", False):
|
||||
LOG.info("Loading APRSD Notification Plugins")
|
||||
enabled_notify_plugins = self.config["aprsd"]["watch_list"].get(
|
||||
"enabled_plugins",
|
||||
None,
|
||||
)
|
||||
self._pluggy_notify_pm = pluggy.PluginManager("aprsd")
|
||||
self._pluggy_notify_pm.add_hookspecs(APRSDCommandSpec)
|
||||
if enabled_notify_plugins:
|
||||
for p_name in enabled_notify_plugins:
|
||||
self._load_notify_plugin(p_name)
|
||||
|
||||
else:
|
||||
LOG.info("Skipping Custom Plugins directory.")
|
||||
LOG.info("Completed Plugin Loading.")
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
def run(self, packet):
|
||||
"""Execute all the pluguns run method."""
|
||||
return self._pluggy_pm.hook.run(*args, **kwargs)
|
||||
with self.lock:
|
||||
return self._pluggy_msg_pm.hook.run(packet=packet)
|
||||
|
||||
def register(self, obj):
|
||||
def notify(self, packet):
|
||||
"""Execute all the notify pluguns run method."""
|
||||
with self.lock:
|
||||
return self._pluggy_notify_pm.hook.run(packet=packet)
|
||||
|
||||
def register_msg(self, obj):
|
||||
"""Register the plugin."""
|
||||
self._pluggy_pm.register(obj)
|
||||
self._pluggy_msg_pm.register(obj)
|
||||
|
||||
def get_plugins(self):
|
||||
return self._pluggy_pm.get_plugins()
|
||||
def get_msg_plugins(self):
|
||||
return self._pluggy_msg_pm.get_plugins()
|
||||
|
||||
@@ -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)
|
||||
+13
-3
@@ -2,12 +2,12 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from aprsd import email, messaging, plugin
|
||||
from aprsd import email, messaging, plugin, trace
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class EmailPlugin(plugin.APRSDPluginBase):
|
||||
class EmailPlugin(plugin.APRSDMessagePluginBase):
|
||||
"""Email Plugin."""
|
||||
|
||||
version = "1.0"
|
||||
@@ -18,9 +18,18 @@ class EmailPlugin(plugin.APRSDPluginBase):
|
||||
# five mins {int:int}
|
||||
email_sent_dict = {}
|
||||
|
||||
def command(self, fromcall, message, ack):
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
LOG.info("Email COMMAND")
|
||||
|
||||
fromcall = packet.get("from")
|
||||
message = packet.get("message_text", None)
|
||||
ack = packet.get("msgNo", "0")
|
||||
|
||||
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 +85,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
|
||||
|
||||
@@ -2,20 +2,26 @@ import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from aprsd import plugin
|
||||
from aprsd import plugin, trace
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class FortunePlugin(plugin.APRSDPluginBase):
|
||||
class FortunePlugin(plugin.APRSDMessagePluginBase):
|
||||
"""Fortune."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[fF]"
|
||||
command_name = "fortune"
|
||||
|
||||
def command(self, fromcall, message, ack):
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
LOG.info("FortunePlugin")
|
||||
|
||||
# fromcall = packet.get("from")
|
||||
# message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
|
||||
reply = None
|
||||
|
||||
fortune_path = shutil.which("fortune")
|
||||
@@ -32,6 +38,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:
|
||||
|
||||
+68
-60
@@ -1,78 +1,86 @@
|
||||
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")
|
||||
|
||||
|
||||
class LocationPlugin(plugin.APRSDPluginBase):
|
||||
class LocationPlugin(plugin.APRSDMessagePluginBase):
|
||||
"""Location!"""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[lL]"
|
||||
command_name = "location"
|
||||
|
||||
config_items = {"apikey": "aprs.fi api key here"}
|
||||
|
||||
def command(self, fromcall, message, ack):
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
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)
|
||||
fromcall = packet.get("from")
|
||||
message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
|
||||
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?"
|
||||
# get last location of a callsign, get descriptive name from weather service
|
||||
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"]
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
|
||||
from aprsd import messaging, packets, plugin
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class NotifySeenPlugin(plugin.APRSDNotificationPluginBase):
|
||||
"""Notification plugin to send seen message for callsign.
|
||||
|
||||
|
||||
This plugin will track callsigns in the watch list and report
|
||||
when a callsign has been seen when the last time they were
|
||||
seen was older than the configured age limit.
|
||||
"""
|
||||
|
||||
version = "1.0"
|
||||
|
||||
def __init__(self, config):
|
||||
"""The aprsd config object is stored."""
|
||||
super().__init__(config)
|
||||
|
||||
def notify(self, packet):
|
||||
LOG.info("BaseNotifyPlugin")
|
||||
|
||||
notify_callsign = self.config["aprsd"]["watch_list"]["alert_callsign"]
|
||||
fromcall = packet.get("from")
|
||||
|
||||
wl = packets.WatchList()
|
||||
age = wl.age(fromcall)
|
||||
|
||||
if wl.is_old(packet["from"]):
|
||||
LOG.info(
|
||||
"NOTIFY {} last seen {} max age={}".format(
|
||||
fromcall,
|
||||
age,
|
||||
wl.max_delta(),
|
||||
),
|
||||
)
|
||||
packet_type = packets.get_packet_type(packet)
|
||||
# we shouldn't notify the alert user that they are online.
|
||||
if fromcall != notify_callsign:
|
||||
return "{} was just seen by type:'{}'".format(fromcall, packet_type)
|
||||
else:
|
||||
LOG.debug(
|
||||
"Not old enough to notify callsign '{}' : {} < {}".format(
|
||||
fromcall,
|
||||
age,
|
||||
wl.max_delta(),
|
||||
),
|
||||
)
|
||||
return messaging.NULL_MESSAGE
|
||||
@@ -1,20 +1,24 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from aprsd import plugin
|
||||
from aprsd import plugin, trace
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class PingPlugin(plugin.APRSDPluginBase):
|
||||
class PingPlugin(plugin.APRSDMessagePluginBase):
|
||||
"""Ping."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[pP]"
|
||||
command_name = "ping"
|
||||
|
||||
def command(self, fromcall, message, ack):
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
LOG.info("PINGPlugin")
|
||||
# fromcall = packet.get("from")
|
||||
# message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
stm = time.localtime()
|
||||
h = stm.tm_hour
|
||||
m = stm.tm_min
|
||||
|
||||
+15
-10
@@ -2,21 +2,26 @@ import datetime
|
||||
import logging
|
||||
import re
|
||||
|
||||
from aprsd import messaging, plugin
|
||||
from aprsd import messaging, plugin, trace
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class QueryPlugin(plugin.APRSDPluginBase):
|
||||
class QueryPlugin(plugin.APRSDMessagePluginBase):
|
||||
"""Query command."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = r"^\?.*"
|
||||
command_regex = r"^\!.*"
|
||||
command_name = "query"
|
||||
|
||||
def command(self, fromcall, message, ack):
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
LOG.info("Query COMMAND")
|
||||
|
||||
fromcall = packet.get("from")
|
||||
message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
|
||||
tracker = messaging.MsgTrack()
|
||||
now = datetime.datetime.now()
|
||||
reply = "Pending messages ({}) {}".format(
|
||||
@@ -28,8 +33,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 +46,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 +58,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)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from aprsd import plugin, trace
|
||||
import yfinance as yf
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class StockPlugin(plugin.APRSDMessagePluginBase):
|
||||
"""Stock market plugin for fetching stock quotes"""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[sS]"
|
||||
command_name = "stock"
|
||||
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
LOG.info("StockPlugin")
|
||||
|
||||
# fromcall = packet.get("from")
|
||||
message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
|
||||
a = re.search(r"^.*\s+(.*)", message)
|
||||
if a is not None:
|
||||
searchcall = a.group(1)
|
||||
stock_symbol = searchcall.upper()
|
||||
else:
|
||||
reply = "No stock symbol"
|
||||
return reply
|
||||
|
||||
LOG.info("Fetch stock quote for '{}'".format(stock_symbol))
|
||||
|
||||
try:
|
||||
stock = yf.Ticker(stock_symbol)
|
||||
reply = "{} - ask: {} high: {} low: {}".format(
|
||||
stock_symbol,
|
||||
stock.info["ask"],
|
||||
stock.info["dayHigh"],
|
||||
stock.info["dayLow"],
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(
|
||||
"Failed to fetch stock '{}' from yahoo '{}'".format(stock_symbol, e),
|
||||
)
|
||||
reply = "Failed to fetch stock '{}'".format(stock_symbol)
|
||||
|
||||
return reply.rstrip()
|
||||
+121
-12
@@ -1,28 +1,137 @@
|
||||
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")
|
||||
|
||||
|
||||
class TimePlugin(plugin.APRSDPluginBase):
|
||||
class TimePlugin(plugin.APRSDMessagePluginBase):
|
||||
"""Time command."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[tT]"
|
||||
command_name = "time"
|
||||
|
||||
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(
|
||||
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,
|
||||
str(h),
|
||||
str(m).rjust(2, "0"),
|
||||
message.rstrip(),
|
||||
local_short_str,
|
||||
)
|
||||
|
||||
return reply
|
||||
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
LOG.info("TIME COMMAND")
|
||||
# So we can mock this in unit tests
|
||||
localzone = self._get_local_tz()
|
||||
return self.build_date_str(localzone)
|
||||
|
||||
|
||||
class TimeOpenCageDataPlugin(TimePlugin):
|
||||
"""geocage based timezone fetching."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[tT]"
|
||||
command_name = "Time"
|
||||
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
fromcall = packet.get("from")
|
||||
# message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
|
||||
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, packet):
|
||||
fromcall = packet.get("from")
|
||||
# message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
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)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import logging
|
||||
|
||||
import aprsd
|
||||
from aprsd import plugin
|
||||
from aprsd import plugin, stats, trace
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class VersionPlugin(plugin.APRSDPluginBase):
|
||||
class VersionPlugin(plugin.APRSDMessagePluginBase):
|
||||
"""Version of APRSD Plugin."""
|
||||
|
||||
version = "1.0"
|
||||
@@ -17,6 +17,15 @@ class VersionPlugin(plugin.APRSDPluginBase):
|
||||
# five mins {int:int}
|
||||
email_sent_dict = {}
|
||||
|
||||
def command(self, fromcall, message, ack):
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
LOG.info("Version COMMAND")
|
||||
return "APRSD version '{}'".format(aprsd.__version__)
|
||||
# fromcall = packet.get("from")
|
||||
# message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
stats_obj = stats.APRSDStats()
|
||||
s = stats_obj.stats()
|
||||
return "APRSD ver:{} uptime:{}".format(
|
||||
aprsd.__version__,
|
||||
s["aprsd"]["uptime"],
|
||||
)
|
||||
|
||||
+377
-34
@@ -1,54 +1,397 @@
|
||||
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.APRSDMessagePluginBase):
|
||||
"""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"
|
||||
|
||||
def command(self, fromcall, message, ack):
|
||||
@trace.trace
|
||||
def command(self, packet):
|
||||
LOG.info("Weather Plugin")
|
||||
api_key = self.config["aprs.fi"]["apiKey"]
|
||||
fromcall = packet.get("from")
|
||||
# message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
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.APRSDMessagePluginBase):
|
||||
"""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, packet):
|
||||
fromcall = packet.get("from")
|
||||
message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
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.APRSDMessagePluginBase):
|
||||
"""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, packet):
|
||||
fromcall = packet.get("from")
|
||||
message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
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.APRSDMessagePluginBase):
|
||||
"""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, packet):
|
||||
fromcall = packet.get("from")
|
||||
message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
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"]
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import datetime
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import aprsd
|
||||
from aprsd import packets, plugin, utils
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class APRSDStats:
|
||||
|
||||
_instance = None
|
||||
lock = None
|
||||
config = None
|
||||
|
||||
start_time = None
|
||||
_aprsis_server = None
|
||||
_aprsis_keepalive = 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
|
||||
|
||||
_mem_current = 0
|
||||
_mem_peak = 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()
|
||||
cls._instance._aprsis_keepalive = datetime.datetime.now()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, config=None):
|
||||
if config:
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def uptime(self):
|
||||
with self.lock:
|
||||
return datetime.datetime.now() - self.start_time
|
||||
|
||||
@property
|
||||
def memory(self):
|
||||
with self.lock:
|
||||
return self._mem_current
|
||||
|
||||
def set_memory(self, memory):
|
||||
with self.lock:
|
||||
self._mem_current = memory
|
||||
|
||||
@property
|
||||
def memory_peak(self):
|
||||
with self.lock:
|
||||
return self._mem_peak
|
||||
|
||||
def set_memory_peak(self, memory):
|
||||
with self.lock:
|
||||
self._mem_peak = memory
|
||||
|
||||
@property
|
||||
def aprsis_server(self):
|
||||
with self.lock:
|
||||
return self._aprsis_server
|
||||
|
||||
def set_aprsis_server(self, server):
|
||||
with self.lock:
|
||||
self._aprsis_server = server
|
||||
|
||||
@property
|
||||
def aprsis_keepalive(self):
|
||||
with self.lock:
|
||||
return self._aprsis_keepalive
|
||||
|
||||
def set_aprsis_keepalive(self):
|
||||
with self.lock:
|
||||
self._aprsis_keepalive = datetime.datetime.now()
|
||||
|
||||
@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()
|
||||
if self._email_thread_last_time:
|
||||
last_update = str(now - self._email_thread_last_time)
|
||||
else:
|
||||
last_update = "never"
|
||||
|
||||
if self._aprsis_keepalive:
|
||||
last_aprsis_keepalive = str(now - self._aprsis_keepalive)
|
||||
else:
|
||||
last_aprsis_keepalive = "never"
|
||||
|
||||
pm = plugin.PluginManager()
|
||||
plugins = pm.get_msg_plugins()
|
||||
plugin_stats = {}
|
||||
|
||||
def full_name_with_qualname(obj):
|
||||
return "{}.{}".format(
|
||||
obj.__class__.__module__,
|
||||
obj.__class__.__qualname__,
|
||||
)
|
||||
|
||||
for p in plugins:
|
||||
plugin_stats[full_name_with_qualname(p)] = p.message_count
|
||||
|
||||
wl = packets.WatchList()
|
||||
|
||||
stats = {
|
||||
"aprsd": {
|
||||
"version": aprsd.__version__,
|
||||
"uptime": utils.strfdelta(self.uptime),
|
||||
"memory_current": self.memory,
|
||||
"memory_current_str": utils.human_size(self.memory),
|
||||
"memory_peak": self.memory_peak,
|
||||
"memory_peak_str": utils.human_size(self.memory_peak),
|
||||
"watch_list": wl.callsigns,
|
||||
},
|
||||
"aprs-is": {
|
||||
"server": self.aprsis_server,
|
||||
"callsign": self.config["aprs"]["login"],
|
||||
"last_update": last_aprsis_keepalive,
|
||||
},
|
||||
"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": {
|
||||
"enabled": self.config["aprsd"]["email"]["enabled"],
|
||||
"sent": self._email_tx,
|
||||
"recieved": self._email_rx,
|
||||
"thread_last_update": last_update,
|
||||
},
|
||||
"plugins": plugin_stats,
|
||||
}
|
||||
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,
|
||||
)
|
||||
)
|
||||
+154
-28
@@ -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, packets, plugin, stats, utils
|
||||
import aprslib
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -63,6 +65,100 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
|
||||
LOG.debug("Exiting")
|
||||
|
||||
|
||||
class KeepAliveThread(APRSDThread):
|
||||
cntr = 0
|
||||
checker_time = datetime.datetime.now()
|
||||
|
||||
def __init__(self):
|
||||
tracemalloc.start()
|
||||
super().__init__("KeepAlive")
|
||||
|
||||
def loop(self):
|
||||
if self.cntr % 6 == 0:
|
||||
tracker = messaging.MsgTrack()
|
||||
stats_obj = stats.APRSDStats()
|
||||
packets_list = packets.PacketList().packet_list
|
||||
now = datetime.datetime.now()
|
||||
last_email = stats_obj.email_thread_time
|
||||
if last_email:
|
||||
email_thread_time = utils.strfdelta(now - last_email)
|
||||
else:
|
||||
email_thread_time = "N/A"
|
||||
|
||||
last_msg_time = utils.strfdelta(now - stats_obj.aprsis_keepalive)
|
||||
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
stats_obj.set_memory(current)
|
||||
stats_obj.set_memory_peak(peak)
|
||||
keepalive = "Uptime {} Tracker {} " "Msgs TX:{} RX:{} Last:{} Email:{} Packets:{} RAM Current:{} Peak:{}".format(
|
||||
utils.strfdelta(stats_obj.uptime),
|
||||
len(tracker),
|
||||
stats_obj.msgs_tx,
|
||||
stats_obj.msgs_rx,
|
||||
last_msg_time,
|
||||
email_thread_time,
|
||||
len(packets_list),
|
||||
utils.human_size(current),
|
||||
utils.human_size(peak),
|
||||
)
|
||||
LOG.debug(keepalive)
|
||||
# Check version every hour
|
||||
delta = now - self.checker_time
|
||||
if delta > datetime.timedelta(hours=1):
|
||||
self.checker_time = now
|
||||
level, msg = utils._check_version()
|
||||
if level:
|
||||
LOG.warning(msg)
|
||||
self.cntr += 1
|
||||
time.sleep(10)
|
||||
return True
|
||||
|
||||
|
||||
class APRSDNotifyThread(APRSDThread):
|
||||
last_seen = {}
|
||||
|
||||
def __init__(self, msg_queues, config):
|
||||
super().__init__("NOTIFY_MSG")
|
||||
self.msg_queues = msg_queues
|
||||
self.config = config
|
||||
packets.WatchList(config=config)
|
||||
|
||||
def loop(self):
|
||||
try:
|
||||
packet = self.msg_queues["notify"].get(timeout=5)
|
||||
wl = packets.WatchList()
|
||||
if wl.callsign_in_watchlist(packet["from"]):
|
||||
# NOW WE RUN through the notify plugins.
|
||||
# If they return a msg, then we queue it for sending.
|
||||
pm = plugin.PluginManager()
|
||||
results = pm.notify(packet)
|
||||
for reply in results:
|
||||
if reply is not messaging.NULL_MESSAGE:
|
||||
watch_list_conf = self.config["aprsd"]["watch_list"]
|
||||
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprs"]["login"],
|
||||
watch_list_conf["alert_callsign"],
|
||||
reply,
|
||||
)
|
||||
self.msg_queues["tx"].put(msg)
|
||||
|
||||
wl.update_seen(packet)
|
||||
else:
|
||||
LOG.debug(
|
||||
"Ignoring packet from '{}'. Not in watch list.".format(
|
||||
packet["from"],
|
||||
),
|
||||
)
|
||||
|
||||
# Allows stats object to have latest info from the last_seen dict
|
||||
LOG.debug("Packet processing complete")
|
||||
except queue.Empty:
|
||||
pass
|
||||
# Continue to loop
|
||||
return True
|
||||
|
||||
|
||||
class APRSDRXThread(APRSDThread):
|
||||
def __init__(self, msg_queues, config):
|
||||
super().__init__("RX_MSG")
|
||||
@@ -73,16 +169,26 @@ 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()
|
||||
|
||||
# if we have a watch list enabled, we need to add filtering
|
||||
# to enable seeing packets from the watch list.
|
||||
if "watch_list" in self.config["aprsd"] and self.config["aprsd"][
|
||||
"watch_list"
|
||||
].get("enabled", False):
|
||||
# watch list is enabled
|
||||
watch_list = self.config["aprsd"]["watch_list"].get(
|
||||
"callsigns",
|
||||
[],
|
||||
)
|
||||
# make sure the timeout is set or this doesn't work
|
||||
if watch_list:
|
||||
filter_str = "p/{}".format("/".join(watch_list))
|
||||
aprs_client.set_filter(filter_str)
|
||||
else:
|
||||
LOG.warning("Watch list enabled, but no callsigns set.")
|
||||
|
||||
# setup the consumer of messages and block until a messages
|
||||
try:
|
||||
# This will register a packet consumer with aprslib
|
||||
@@ -118,11 +224,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):
|
||||
@@ -143,7 +251,7 @@ class APRSDRXThread(APRSDThread):
|
||||
# Get singleton of the PM
|
||||
pm = plugin.PluginManager()
|
||||
try:
|
||||
results = pm.run(fromcall=fromcall, message=message, ack=msg_id)
|
||||
results = pm.run(packet)
|
||||
for reply in results:
|
||||
found_command = True
|
||||
# A plugin can return a null message flag which signals
|
||||
@@ -162,7 +270,7 @@ class APRSDRXThread(APRSDThread):
|
||||
LOG.debug("Got NULL MESSAGE from plugin")
|
||||
|
||||
if not found_command:
|
||||
plugins = pm.get_plugins()
|
||||
plugins = pm.get_msg_plugins()
|
||||
names = [x.command_name for x in plugins]
|
||||
names.sort()
|
||||
|
||||
@@ -189,33 +297,50 @@ class APRSDRXThread(APRSDThread):
|
||||
msg_id=msg_id,
|
||||
)
|
||||
self.msg_queues["tx"].put(ack)
|
||||
LOG.debug("Packet processing complete")
|
||||
|
||||
def process_packet(self, packet):
|
||||
"""Process a packet recieved from aprs-is server."""
|
||||
|
||||
try:
|
||||
LOG.info("Got message: {}".format(packet))
|
||||
LOG.debug("Adding packet to notify queue {}".format(packet["raw"]))
|
||||
self.msg_queues["notify"].put(packet)
|
||||
packets.PacketList().add(packet)
|
||||
|
||||
msg = packet.get("message_text", None)
|
||||
msg_format = packet.get("format", None)
|
||||
msg_response = packet.get("response", None)
|
||||
if msg_format == "message" and msg:
|
||||
# we want to send the message through the
|
||||
# plugins
|
||||
self.process_message_packet(packet)
|
||||
return
|
||||
elif msg_response == "ack":
|
||||
self.process_ack_packet(packet)
|
||||
return
|
||||
# since we can see packets from anyone now with the
|
||||
# watch list, we need to filter messages directly only to us.
|
||||
tocall = packet.get("addresse", None)
|
||||
if tocall == self.config["aprs"]["login"]:
|
||||
stats.APRSDStats().msgs_rx_inc()
|
||||
packets.PacketList().add(packet)
|
||||
|
||||
if msg_format == "mic-e":
|
||||
# process a mic-e packet
|
||||
self.process_mic_e_packet(packet)
|
||||
return
|
||||
msg = packet.get("message_text", None)
|
||||
msg_format = packet.get("format", None)
|
||||
msg_response = packet.get("response", None)
|
||||
if msg_format == "message" and msg:
|
||||
# we want to send the message through the
|
||||
# plugins
|
||||
self.process_message_packet(packet)
|
||||
return
|
||||
elif msg_response == "ack":
|
||||
self.process_ack_packet(packet)
|
||||
return
|
||||
|
||||
if msg_format == "mic-e":
|
||||
# process a mic-e packet
|
||||
self.process_mic_e_packet(packet)
|
||||
return
|
||||
else:
|
||||
LOG.debug(
|
||||
"Ignoring '{}' packet from '{}' to '{}'".format(
|
||||
packets.get_packet_type(packet),
|
||||
packet["from"],
|
||||
tocall,
|
||||
),
|
||||
)
|
||||
|
||||
except (aprslib.ParseError, aprslib.UnknownFormat) as exp:
|
||||
LOG.exception("Failed to parse packet from aprs-is", exp)
|
||||
LOG.debug("Packet processing complete")
|
||||
|
||||
|
||||
class APRSDTXThread(APRSDThread):
|
||||
@@ -226,7 +351,8 @@ class APRSDTXThread(APRSDThread):
|
||||
|
||||
def loop(self):
|
||||
try:
|
||||
msg = self.msg_queues["tx"].get(timeout=0.1)
|
||||
msg = self.msg_queues["tx"].get(timeout=5)
|
||||
packets.PacketList().add(msg.dict())
|
||||
msg.send()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
+181
@@ -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
|
||||
+322
-71
@@ -1,49 +1,103 @@
|
||||
"""Utilities and helper functions."""
|
||||
|
||||
import collections
|
||||
import errno
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import aprsd
|
||||
from aprsd import plugin
|
||||
import click
|
||||
import update_checker
|
||||
import yaml
|
||||
|
||||
LOG_LEVELS = {
|
||||
"CRITICAL": logging.CRITICAL,
|
||||
"ERROR": logging.ERROR,
|
||||
"WARNING": logging.WARNING,
|
||||
"INFO": logging.INFO,
|
||||
"DEBUG": logging.DEBUG,
|
||||
}
|
||||
|
||||
DEFAULT_LOG_FORMAT = (
|
||||
"[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s]"
|
||||
" %(message)s - [%(pathname)s:%(lineno)d]"
|
||||
)
|
||||
|
||||
DEFAULT_DATE_FORMAT = "%m/%d/%Y %I:%M:%S %p"
|
||||
|
||||
# an example of what should be in the ~/.aprsd/config.yml
|
||||
DEFAULT_CONFIG_DICT = {
|
||||
"ham": {"callsign": "CALLSIGN"},
|
||||
"ham": {"callsign": "NOCALL"},
|
||||
"aprs": {
|
||||
"login": "CALLSIGN",
|
||||
"login": "NOCALL",
|
||||
"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": {
|
||||
"plugin_dir": "~/.config/aprsd/plugins",
|
||||
"enabled_plugins": plugin.CORE_PLUGINS,
|
||||
"logfile": "/tmp/aprsd.log",
|
||||
"logformat": DEFAULT_LOG_FORMAT,
|
||||
"dateformat": DEFAULT_DATE_FORMAT,
|
||||
"trace": False,
|
||||
"enabled_plugins": plugin.CORE_MESSAGE_PLUGINS,
|
||||
"units": "imperial",
|
||||
"watch_list": {
|
||||
"enabled": False,
|
||||
# Who gets the alert?
|
||||
"alert_callsign": "NOCALL",
|
||||
# 43200 is 12 hours
|
||||
"alert_time_seconds": 43200,
|
||||
# How many packets to save in a ring Buffer
|
||||
# for a particular callsign
|
||||
"packet_keep_count": 10,
|
||||
"callsigns": [],
|
||||
"enabled_plugins": plugin.CORE_NOTIFY_PLUGINS,
|
||||
},
|
||||
"web": {
|
||||
"enabled": True,
|
||||
"logging_enabled": True,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8001,
|
||||
"users": {
|
||||
"admin": "password-here",
|
||||
},
|
||||
},
|
||||
"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 +155,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 +232,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 +262,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 +302,174 @@ 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"]["watch_list"]["enabled"] is True:
|
||||
check_option(
|
||||
config,
|
||||
["aprsd", "watch_list", "alert_callsign"],
|
||||
default_fail=DEFAULT_CONFIG_DICT["aprsd"]["watch_list"]["alert_callsign"],
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def human_size(bytes, units=None):
|
||||
"""Returns a human readable string representation of bytes"""
|
||||
if not units:
|
||||
units = [" bytes", "KB", "MB", "GB", "TB", "PB", "EB"]
|
||||
return str(bytes) + units[0] if bytes < 1024 else human_size(bytes >> 10, units[1:])
|
||||
|
||||
|
||||
def strfdelta(tdelta, fmt="{hours}:{minutes}:{seconds}"):
|
||||
d = {"days": tdelta.days}
|
||||
d["hours"], rem = divmod(tdelta.seconds, 3600)
|
||||
d["minutes"], d["seconds"] = divmod(rem, 60)
|
||||
return fmt.format(**d)
|
||||
|
||||
|
||||
def _check_version():
|
||||
# check for a newer version
|
||||
try:
|
||||
check = update_checker.UpdateChecker()
|
||||
result = check.check("aprsd", aprsd.__version__)
|
||||
if result:
|
||||
# Looks like there is an updated version.
|
||||
return 1, result
|
||||
else:
|
||||
return 0, "APRSD is up to date"
|
||||
except Exception:
|
||||
# probably can't get in touch with pypi for some reason
|
||||
# Lets put up an error and move on. We might not
|
||||
# have internet in this aprsd deployment.
|
||||
return 1, "Couldn't check for new version of APRSD"
|
||||
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
"""Flatten a dict to key.key.key = value."""
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = parent_key + sep + k if parent_key else k
|
||||
if isinstance(v, collections.MutableMapping):
|
||||
items.extend(flatten_dict(v, new_key, sep=sep).items())
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
return dict(items)
|
||||
|
||||
|
||||
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()}
|
||||
|
||||
|
||||
class RingBuffer:
|
||||
"""class that implements a not-yet-full buffer"""
|
||||
|
||||
def __init__(self, size_max):
|
||||
self.max = size_max
|
||||
self.data = []
|
||||
|
||||
class __Full:
|
||||
"""class that implements a full buffer"""
|
||||
|
||||
def append(self, x):
|
||||
"""Append an element overwriting the oldest one."""
|
||||
self.data[self.cur] = x
|
||||
self.cur = (self.cur + 1) % self.max
|
||||
|
||||
def get(self):
|
||||
"""return list of elements in correct order"""
|
||||
return self.data[self.cur :] + self.data[: self.cur]
|
||||
|
||||
def append(self, x):
|
||||
"""append an element at the end of the buffer"""
|
||||
|
||||
self.data.append(x)
|
||||
if len(self.data) == self.max:
|
||||
self.cur = 0
|
||||
# Permanently change self's class from non-full to full
|
||||
self.__class__ = self.__Full
|
||||
|
||||
def get(self):
|
||||
"""Return a list of elements from the oldest to the newest."""
|
||||
return self.data
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
body {
|
||||
background: #eeeeee;
|
||||
margin: 2em;
|
||||
text-align: center;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
height: 10vh;
|
||||
}
|
||||
|
||||
.ui.segment {
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
#graphs {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
#graphs_center {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
#left {
|
||||
margin-right: 2px;
|
||||
height: 300px;
|
||||
}
|
||||
#right {
|
||||
height: 300px;
|
||||
}
|
||||
#center {
|
||||
height: 300px;
|
||||
}
|
||||
#messageChart, #emailChart, #memChart {
|
||||
border: 1px solid #ccc;
|
||||
background: #ddd;
|
||||
}
|
||||
#stats {
|
||||
margin: auto;
|
||||
width: 80%;
|
||||
}
|
||||
#jsonstats {
|
||||
display: none;
|
||||
}
|
||||
#title {
|
||||
font-size: 4em;
|
||||
}
|
||||
#version{
|
||||
font-size: .5em;
|
||||
}
|
||||
#uptime, #aprsis {
|
||||
font-size: 1em;
|
||||
}
|
||||
#callsign {
|
||||
font-size: 1.4em;
|
||||
color: #00F;
|
||||
padding-top: 8px;
|
||||
margin:10px;
|
||||
}
|
||||
|
||||
#title_rx {
|
||||
background-color: darkseagreen;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#title_tx {
|
||||
background-color: lightcoral;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Style the tab */
|
||||
.tab {
|
||||
overflow: hidden;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
/* Style the buttons that are used to open the tab content */
|
||||
.tab button {
|
||||
background-color: inherit;
|
||||
float: left;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 14px 16px;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
/* Change background color of buttons on hover */
|
||||
.tab button:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
/* Create an active/current tablink class */
|
||||
.tab button.active {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* Style the tab content */
|
||||
.tabcontent {
|
||||
display: none;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-top: none;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
var packet_list = {};
|
||||
|
||||
window.chartColors = {
|
||||
red: 'rgb(255, 99, 132)',
|
||||
orange: 'rgb(255, 159, 64)',
|
||||
yellow: 'rgb(255, 205, 86)',
|
||||
green: 'rgb(26, 181, 77)',
|
||||
blue: 'rgb(54, 162, 235)',
|
||||
purple: 'rgb(153, 102, 255)',
|
||||
grey: 'rgb(201, 203, 207)',
|
||||
black: 'rgb(0, 0, 0)'
|
||||
};
|
||||
|
||||
function size_dict(d){c=0; for (i in d) ++c; return c}
|
||||
|
||||
function start_charts() {
|
||||
Chart.scaleService.updateScaleDefaults('linear', {
|
||||
ticks: {
|
||||
min: 0
|
||||
}
|
||||
});
|
||||
|
||||
memory_chart = new Chart($("#memChart"), {
|
||||
label: 'Memory Usage',
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Peak Ram usage',
|
||||
borderColor: window.chartColors.red,
|
||||
data: [],
|
||||
},
|
||||
{
|
||||
label: 'Current Ram usage',
|
||||
borderColor: window.chartColors.blue,
|
||||
data: [],
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Memory Usage',
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'timeseries',
|
||||
offset: true,
|
||||
ticks: {
|
||||
major: { enabled: true },
|
||||
fontStyle: context => context.tick.major ? 'bold' : undefined,
|
||||
source: 'data',
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 75,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
message_chart = new Chart($("#messageChart"), {
|
||||
label: 'Messages',
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Messages Sent',
|
||||
borderColor: window.chartColors.green,
|
||||
data: [],
|
||||
},
|
||||
{
|
||||
label: 'Messages Recieved',
|
||||
borderColor: window.chartColors.yellow,
|
||||
data: [],
|
||||
},
|
||||
{
|
||||
label: 'Ack Sent',
|
||||
borderColor: window.chartColors.purple,
|
||||
data: [],
|
||||
},
|
||||
{
|
||||
label: 'Ack Recieved',
|
||||
borderColor: window.chartColors.black,
|
||||
data: [],
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'APRS Messages',
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'timeseries',
|
||||
offset: true,
|
||||
ticks: {
|
||||
major: { enabled: true },
|
||||
fontStyle: context => context.tick.major ? 'bold' : undefined,
|
||||
source: 'data',
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 75,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
email_chart = new Chart($("#emailChart"), {
|
||||
label: 'Email Messages',
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Sent',
|
||||
borderColor: window.chartColors.green,
|
||||
data: [],
|
||||
},
|
||||
{
|
||||
label: 'Recieved',
|
||||
borderColor: window.chartColors.yellow,
|
||||
data: [],
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Email Messages',
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'timeseries',
|
||||
offset: true,
|
||||
ticks: {
|
||||
major: { enabled: true },
|
||||
fontStyle: context => context.tick.major ? 'bold' : undefined,
|
||||
source: 'data',
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 75,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function addData(chart, label, newdata) {
|
||||
chart.data.labels.push(label);
|
||||
chart.data.datasets.forEach((dataset) => {
|
||||
dataset.data.push(newdata);
|
||||
});
|
||||
chart.update();
|
||||
}
|
||||
|
||||
function updateDualData(chart, label, first, second) {
|
||||
chart.data.labels.push(label);
|
||||
chart.data.datasets[0].data.push(first);
|
||||
chart.data.datasets[1].data.push(second);
|
||||
chart.update();
|
||||
}
|
||||
function updateQuadData(chart, label, first, second, third, fourth) {
|
||||
chart.data.labels.push(label);
|
||||
chart.data.datasets[0].data.push(first);
|
||||
chart.data.datasets[1].data.push(second);
|
||||
chart.data.datasets[2].data.push(third);
|
||||
chart.data.datasets[3].data.push(fourth);
|
||||
chart.update();
|
||||
}
|
||||
|
||||
function update_stats( data ) {
|
||||
$("#version").text( data["stats"]["aprsd"]["version"] );
|
||||
$("#aprsis").html( "APRS-IS Server: <a href='http://status.aprs2.net' >" + data["stats"]["aprs-is"]["server"] + "</a>" );
|
||||
$("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] );
|
||||
const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json');
|
||||
$("#jsonstats").html(html_pretty);
|
||||
short_time = data["time"].split(/\s(.+)/)[1];
|
||||
updateQuadData(message_chart, short_time, data["stats"]["messages"]["sent"], data["stats"]["messages"]["recieved"], data["stats"]["messages"]["ack_sent"], data["stats"]["messages"]["ack_recieved"]);
|
||||
updateDualData(email_chart, short_time, data["stats"]["email"]["sent"], data["stats"]["email"]["recieved"]);
|
||||
updateDualData(memory_chart, short_time, data["stats"]["aprsd"]["memory_peak"], data["stats"]["aprsd"]["memory_current"]);
|
||||
|
||||
// Update the watch list
|
||||
var watchdiv = $("#watchDiv");
|
||||
var html_str = '<table class="ui celled striped table"><thead><tr><th>HAM Callsign</th><th>Age since last seen by APRSD</th></tr></thead><tbody>'
|
||||
watchdiv.html('')
|
||||
jQuery.each(data["stats"]["aprsd"]["watch_list"], function(i, val) {
|
||||
html_str += '<tr><td class="collapsing"><i class="phone volume icon"></i>' + i + '</td><td>' + val["last"] + '</td></tr>'
|
||||
});
|
||||
html_str += "</tbody></table>";
|
||||
watchdiv.append(html_str);
|
||||
}
|
||||
|
||||
|
||||
function update_packets( data ) {
|
||||
var packetsdiv = $("#packetsDiv");
|
||||
//nuke the contents first, then add to it.
|
||||
if (size_dict(packet_list) == 0 && size_dict(data) > 0) {
|
||||
packetsdiv.html('')
|
||||
}
|
||||
jQuery.each(data, function(i, val) {
|
||||
if ( packet_list.hasOwnProperty(val["ts"]) == false ) {
|
||||
// Store the packet
|
||||
packet_list[val["ts"]] = val;
|
||||
ts_str = val["ts"].toString();
|
||||
ts = ts_str.split(".")[0]*1000;
|
||||
var d = new Date(ts).toLocaleDateString("en-US")
|
||||
var t = new Date(ts).toLocaleTimeString("en-US")
|
||||
if (val.hasOwnProperty('from') == false) {
|
||||
from = val['fromcall']
|
||||
title_id = 'title_tx'
|
||||
} else {
|
||||
from = val['from']
|
||||
title_id = 'title_rx'
|
||||
}
|
||||
var from_to = d + " " + t + " " + from + " > "
|
||||
|
||||
if (val.hasOwnProperty('addresse')) {
|
||||
from_to = from_to + val['addresse']
|
||||
} else if (val.hasOwnProperty('tocall')) {
|
||||
from_to = from_to + val['tocall']
|
||||
} else if (val.hasOwnProperty('format') && val['format'] == 'mic-e') {
|
||||
from_to = from_to + "Mic-E"
|
||||
}
|
||||
|
||||
from_to = from_to + " - " + val['raw']
|
||||
|
||||
json_pretty = Prism.highlight(JSON.stringify(val, null, '\t'), Prism.languages.json, 'json');
|
||||
pkt_html = '<div class="title" id="' + title_id + '"><i class="dropdown icon"></i>' + from_to + '</div><div class="content"><p class="transition hidden"><pre class="language-json">' + json_pretty + '</p></p></div>'
|
||||
packetsdiv.prepend(pkt_html);
|
||||
}
|
||||
});
|
||||
|
||||
$('.ui.accordion').accordion('refresh');
|
||||
|
||||
// Update the count of messages shown
|
||||
cnt = size_dict(packet_list);
|
||||
console.log("packets list " + cnt)
|
||||
$('#packets_count').html(cnt);
|
||||
|
||||
const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json');
|
||||
$("#packetsjson").html(html_pretty);
|
||||
}
|
||||
|
||||
|
||||
function start_update() {
|
||||
|
||||
(function statsworker() {
|
||||
$.ajax({
|
||||
url: "/stats",
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
update_stats(data);
|
||||
},
|
||||
complete: function() {
|
||||
setTimeout(statsworker, 10000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
(function packetsworker() {
|
||||
$.ajax({
|
||||
url: "/packets",
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
update_packets(data);
|
||||
},
|
||||
complete: function() {
|
||||
setTimeout(packetsworker, 10000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
function openTab(evt, tabName) {
|
||||
// Declare all variables
|
||||
var i, tabcontent, tablinks;
|
||||
|
||||
if (typeof tabName == 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all elements with class="tabcontent" and hide them
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
|
||||
// Get all elements with class="tablinks" and remove the class "active"
|
||||
tablinks = document.getElementsByClassName("tablinks");
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
||||
}
|
||||
|
||||
// Show the current tab, and add an "active" class to the button that opened the tab
|
||||
document.getElementById(tabName).style.display = "block";
|
||||
if (typeof evt.currentTarget == 'undefined') {
|
||||
return
|
||||
} else {
|
||||
evt.currentTarget.className += " active";
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
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(/"/g, '\\"');
|
||||
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);
|
||||
@@ -0,0 +1,117 @@
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
|
||||
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.23.0/prism.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.23.0/components/prism-json.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.23.0/themes/prism-tomorrow.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.bundle.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/css/index.css">
|
||||
<link rel="stylesheet" href="/css/tabs.css">
|
||||
<script src="/js/charts.js"></script>
|
||||
<script src="/js/tabs.js"></script>
|
||||
|
||||
|
||||
<script type="text/javascript"">
|
||||
var initial_stats = {{ initial_stats|tojson|safe }};
|
||||
|
||||
var memory_chart = null
|
||||
var message_chart = null
|
||||
var color = Chart.helpers.color;
|
||||
|
||||
$(document).ready(function() {
|
||||
console.log(initial_stats);
|
||||
start_update();
|
||||
start_charts();
|
||||
|
||||
$("#toggleStats").click(function() {
|
||||
$("#jsonstats").fadeToggle(1000);
|
||||
});
|
||||
|
||||
// Pretty print the config json so it's readable
|
||||
var cfg_data = $("#configjson").text();
|
||||
var cfg_json = JSON.parse(cfg_data);
|
||||
var cfg_pretty = JSON.stringify(cfg_json, null, '\t');
|
||||
const html_pretty = Prism.highlight( cfg_pretty, Prism.languages.json, 'json');
|
||||
$("#configjson").html(html_pretty);
|
||||
|
||||
$('.ui.accordion').accordion({exclusive: false});
|
||||
$('.menu .item').tab('change tab', 'charts-tab');
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='ui text container'>
|
||||
<h1 class='ui dividing header'>APRSD {{ version }}</h1>
|
||||
</div>
|
||||
|
||||
<div class='ui grid text container'>
|
||||
<div class='left floated ten wide column'>
|
||||
<span style='color: green'>{{ callsign }}</span>
|
||||
connected to
|
||||
<span style='color: blue' id='aprsis'>NONE</span>
|
||||
</div>
|
||||
|
||||
<div class='right floated four wide column'>
|
||||
<span id='uptime'>NONE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab links -->
|
||||
<div class="ui top attached tabular menu">
|
||||
<div class="active item" data-tab="charts-tab">Charts</div>
|
||||
<div class="item" data-tab="msgs-tab">Messages</div>
|
||||
<div class="item" data-tab="watch-tab">Watch List</div>
|
||||
<div class="item" data-tab="config-tab">Config</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div class="ui bottom attached active tab segment" data-tab="charts-tab">
|
||||
<h3 class="ui dividing header">Charts</h3>
|
||||
<div id="graphs">
|
||||
<div id="left"><canvas id="messageChart"></canvas></div>
|
||||
<div id="right"><canvas class="right" id="emailChart"></canvas></div>
|
||||
</div>
|
||||
<div id="graphs_center">
|
||||
<div id="center"><canvas id="memChart"></canvas></div>
|
||||
</div>
|
||||
<div id="stats">
|
||||
<button class="ui button" id="toggleStats">Toggle raw json</button>
|
||||
<pre id="jsonstats" class="language-json">{{ stats }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui bottom attached tab segment" data-tab="msgs-tab">
|
||||
<h3 class="ui dividing header">Messages (<span id="packets_count">0</span>)</h3>
|
||||
<div class="ui styled fluid accordion" id="accordion">
|
||||
<div id="packetsDiv" class="ui mini text">Loading</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui bottom attached tab segment" data-tab="watch-tab">
|
||||
<h3 class="ui dividing header">
|
||||
Callsign Watch List (<span id="watch_count">{{ watch_count }}</span>)
|
||||
|
||||
Notification age - <span id="watch_age">{{ watch_age }}</span>
|
||||
</h3>
|
||||
<div id="watchDiv" class="ui mini text">Loading</div>
|
||||
</div>
|
||||
|
||||
<div class="ui bottom attached tab segment" data-tab="config-tab">
|
||||
<h3 class="ui dividing header">Config</h3>
|
||||
<pre id="configjson" class="language-json">{{ config_json|safe }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="ui text container">
|
||||
<a href="https://badge.fury.io/py/aprsd"><img src="https://badge.fury.io/py/aprsd.svg" alt="PyPI version" height="18"></a>
|
||||
<a href="https://github.com/craigerl/aprsd"><img src="https://img.shields.io/badge/Made%20with-Python-1f425f.svg" height="18"></a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Use this script to locally build the docker image
|
||||
docker build --no-cache -t hemna6969/aprsd:latest ..
|
||||
@@ -8,3 +8,5 @@ pep8-naming
|
||||
Sphinx
|
||||
tox
|
||||
twine
|
||||
pre-commit
|
||||
pip-tools
|
||||
|
||||
+71
-36
@@ -12,22 +12,30 @@ appdirs==1.4.4
|
||||
# virtualenv
|
||||
attrs==20.3.0
|
||||
# via pytest
|
||||
babel==2.9.0
|
||||
babel==2.9.1
|
||||
# via sphinx
|
||||
black==20.8b1
|
||||
black==21.4b2
|
||||
# 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.5
|
||||
# via cryptography
|
||||
cfgv==3.2.0
|
||||
# via pre-commit
|
||||
chardet==4.0.0
|
||||
# via requests
|
||||
click==7.1.2
|
||||
# via black
|
||||
# via
|
||||
# black
|
||||
# pip-tools
|
||||
colorama==0.4.4
|
||||
# via twine
|
||||
coverage==5.3.1
|
||||
coverage==5.5
|
||||
# via pytest-cov
|
||||
cryptography==3.4.7
|
||||
# via secretstorage
|
||||
distlib==0.3.1
|
||||
# via virtualenv
|
||||
docutils==0.16
|
||||
@@ -40,21 +48,31 @@ filelock==3.0.12
|
||||
# virtualenv
|
||||
flake8-polyfill==1.0.2
|
||||
# via pep8-naming
|
||||
flake8==3.8.4
|
||||
flake8==3.9.1
|
||||
# via
|
||||
# -r dev-requirements.in
|
||||
# flake8-polyfill
|
||||
identify==2.2.4
|
||||
# via pre-commit
|
||||
idna==2.10
|
||||
# via requests
|
||||
imagesize==1.2.0
|
||||
# via sphinx
|
||||
importlib-metadata==4.0.1
|
||||
# via
|
||||
# keyring
|
||||
# twine
|
||||
iniconfig==1.1.1
|
||||
# via pytest
|
||||
isort==5.7.0
|
||||
isort==5.8.0
|
||||
# via -r dev-requirements.in
|
||||
jinja2==2.11.2
|
||||
jeepney==0.6.0
|
||||
# via
|
||||
# keyring
|
||||
# secretstorage
|
||||
jinja2==2.11.3
|
||||
# via sphinx
|
||||
keyring==21.8.0
|
||||
keyring==23.0.1
|
||||
# via twine
|
||||
markupsafe==1.1.1
|
||||
# via jinja2
|
||||
@@ -64,9 +82,11 @@ mypy-extensions==0.4.3
|
||||
# via
|
||||
# black
|
||||
# mypy
|
||||
mypy==0.790
|
||||
mypy==0.812
|
||||
# via -r dev-requirements.in
|
||||
packaging==20.8
|
||||
nodeenv==1.6.0
|
||||
# via pre-commit
|
||||
packaging==20.9
|
||||
# via
|
||||
# bleach
|
||||
# pytest
|
||||
@@ -74,39 +94,49 @@ packaging==20.8
|
||||
# tox
|
||||
pathspec==0.8.1
|
||||
# via black
|
||||
pep517==0.10.0
|
||||
# via pip-tools
|
||||
pep8-naming==0.11.1
|
||||
# via -r dev-requirements.in
|
||||
pkginfo==1.6.1
|
||||
pip-tools==6.1.0
|
||||
# via -r dev-requirements.in
|
||||
pkginfo==1.7.0
|
||||
# via twine
|
||||
pluggy==0.13.1
|
||||
# via
|
||||
# pytest
|
||||
# tox
|
||||
pre-commit==2.12.1
|
||||
# via -r dev-requirements.in
|
||||
py==1.10.0
|
||||
# via
|
||||
# pytest
|
||||
# tox
|
||||
pycodestyle==2.6.0
|
||||
pycodestyle==2.7.0
|
||||
# via flake8
|
||||
pyflakes==2.2.0
|
||||
pycparser==2.20
|
||||
# via cffi
|
||||
pyflakes==2.3.1
|
||||
# via flake8
|
||||
pygments==2.7.3
|
||||
pygments==2.9.0
|
||||
# via
|
||||
# readme-renderer
|
||||
# sphinx
|
||||
pyparsing==2.4.7
|
||||
# via packaging
|
||||
pytest-cov==2.10.1
|
||||
pytest-cov==2.11.1
|
||||
# via -r dev-requirements.in
|
||||
pytest==6.2.1
|
||||
pytest==6.2.3
|
||||
# via
|
||||
# -r dev-requirements.in
|
||||
# pytest-cov
|
||||
pytz==2020.5
|
||||
pytz==2021.1
|
||||
# via babel
|
||||
readme-renderer==28.0
|
||||
pyyaml==5.4.1
|
||||
# via pre-commit
|
||||
readme-renderer==29.0
|
||||
# via twine
|
||||
regex==2020.11.13
|
||||
regex==2021.4.4
|
||||
# via black
|
||||
requests-toolbelt==0.9.1
|
||||
# via twine
|
||||
@@ -117,15 +147,17 @@ requests==2.25.1
|
||||
# twine
|
||||
rfc3986==1.4.0
|
||||
# via twine
|
||||
secretstorage==3.3.1
|
||||
# via keyring
|
||||
six==1.15.0
|
||||
# via
|
||||
# bleach
|
||||
# readme-renderer
|
||||
# tox
|
||||
# virtualenv
|
||||
snowballstemmer==2.0.0
|
||||
snowballstemmer==2.1.0
|
||||
# via sphinx
|
||||
sphinx==3.4.3
|
||||
sphinx==3.5.4
|
||||
# via -r dev-requirements.in
|
||||
sphinxcontrib-applehelp==1.0.2
|
||||
# via sphinx
|
||||
@@ -142,28 +174,31 @@ sphinxcontrib-serializinghtml==1.1.4
|
||||
toml==0.10.2
|
||||
# via
|
||||
# black
|
||||
# pep517
|
||||
# pre-commit
|
||||
# pytest
|
||||
# tox
|
||||
tox==3.21.0
|
||||
tox==3.23.0
|
||||
# via -r dev-requirements.in
|
||||
tqdm==4.55.1
|
||||
tqdm==4.60.0
|
||||
# via twine
|
||||
twine==3.3.0
|
||||
twine==3.4.1
|
||||
# via -r dev-requirements.in
|
||||
typed-ast==1.4.2
|
||||
# via
|
||||
# black
|
||||
# mypy
|
||||
typing-extensions==3.7.4.3
|
||||
# via
|
||||
# black
|
||||
# mypy
|
||||
urllib3==1.26.2
|
||||
typed-ast==1.4.3
|
||||
# via mypy
|
||||
typing-extensions==3.10.0.0
|
||||
# via mypy
|
||||
urllib3==1.26.5
|
||||
# via requests
|
||||
virtualenv==20.2.2
|
||||
# via tox
|
||||
virtualenv==20.4.4
|
||||
# via
|
||||
# pre-commit
|
||||
# tox
|
||||
webencodings==0.5.1
|
||||
# via bleach
|
||||
zipp==3.4.1
|
||||
# via importlib-metadata
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# pip
|
||||
# setuptools
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM python:3.8-slim as aprsd
|
||||
|
||||
# Dockerfile for building a container during aprsd development.
|
||||
|
||||
ARG UID
|
||||
ARG GID
|
||||
ARG TZ
|
||||
ENV APRS_USER=aprs
|
||||
ENV HOME=/home/aprs
|
||||
ENV TZ=${TZ:-US/Eastern}
|
||||
ENV UID=${UID:-1000}
|
||||
ENV GID=${GID:-1000}
|
||||
ENV LC_ALL=C.UTF-8
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
RUN apt update
|
||||
RUN apt install -y git build-essential
|
||||
RUN apt install -y libffi-dev python3-dev libssl-dev
|
||||
#RUN apt-get install -y apt-utils
|
||||
#RUN apt-get install -y pkg-config
|
||||
#RUN apt upgrade -y
|
||||
#RUN apk add --update git wget bash
|
||||
#RUN apk add --update gcc linux-headers musl-dev libffi-dev libc-dev
|
||||
#RUN apk add --update openssl-dev
|
||||
#RUN add cmd:pip3 lsb-release
|
||||
|
||||
RUN addgroup --gid $GID $APRS_USER
|
||||
RUN useradd -m -u $UID -g $APRS_USER $APRS_USER
|
||||
|
||||
# Install aprsd
|
||||
RUN /usr/local/bin/pip3 install aprsd==1.6.0
|
||||
|
||||
# Ensure /config is there with a default config file
|
||||
USER root
|
||||
RUN mkdir -p /config
|
||||
RUN aprsd sample-config > /config/aprsd.yml
|
||||
RUN chown -R $APRS_USER:$APRS_USER /config
|
||||
|
||||
# override this to run another configuration
|
||||
ENV CONF default
|
||||
VOLUME ["/config", "/plugins"]
|
||||
|
||||
USER $APRS_USER
|
||||
ADD bin/run.sh /usr/local/bin
|
||||
ENTRYPOINT ["/usr/local/bin/run.sh"]
|
||||
@@ -1,48 +1,50 @@
|
||||
FROM alpine:latest as aprsd
|
||||
FROM python:3.8-slim as aprsd
|
||||
|
||||
# Dockerfile for building a container during aprsd development.
|
||||
ARG BRANCH
|
||||
ARG UID
|
||||
ARG GID
|
||||
|
||||
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 UID=${UID:-1000}
|
||||
ENV GID=${GID:-1000}
|
||||
|
||||
ENV INSTALL=$HOME/install
|
||||
RUN apk add --update git vim 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 apt update
|
||||
RUN apt install -y git build-essential
|
||||
RUN apt install -y libffi-dev python3-dev libssl-dev
|
||||
RUN apt install -y bash fortune
|
||||
|
||||
RUN addgroup --gid 1001 $APRS_USER
|
||||
RUN adduser -h $HOME -D -u 1001 -G $APRS_USER $APRS_USER
|
||||
RUN useradd -m -u $UID -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
|
||||
#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"]
|
||||
|
||||
USER root
|
||||
WORKDIR $HOME
|
||||
RUN mkdir $INSTALL
|
||||
RUN git clone -b $APRSD_BRANCH $APRSD $INSTALL/aprsd
|
||||
RUN cd $INSTALL/aprsd && pip3 install .
|
||||
RUN which aprsd
|
||||
USER root
|
||||
RUN mkdir -p /config
|
||||
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
|
||||
VOLUME ["/config", "/plugins"]
|
||||
|
||||
ADD build/bin/run.sh $HOME/
|
||||
ADD bin/run.sh $HOME/
|
||||
ENTRYPOINT ["/home/aprs/run.sh"]
|
||||
@@ -1,10 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -x
|
||||
|
||||
export PATH=$PATH:$HOME/.local/bin
|
||||
export VIRTUAL_ENV=$HOME/.venv3
|
||||
source $VIRTUAL_ENV/bin/activate
|
||||
|
||||
if [ ! -z "${APRSD_PLUGINS}" ]; then
|
||||
OLDIFS=$IFS
|
||||
IFS=','
|
||||
@@ -23,4 +19,5 @@ 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
|
||||
|
||||
/usr/local/bin/aprsd server -c $APRSD_CONFIG --loglevel DEBUG
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# Official docker image build script.
|
||||
|
||||
|
||||
usage() {
|
||||
cat << EOF
|
||||
usage: $0 options
|
||||
|
||||
OPTIONS:
|
||||
-t The tag/version (${TAG}) (default = master)
|
||||
-d Use Dockerfile-dev for a git clone build
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
ALL_PLATFORMS=0
|
||||
DEV=0
|
||||
TAG="master"
|
||||
|
||||
while getopts “t:da” OPTION
|
||||
do
|
||||
case $OPTION in
|
||||
t)
|
||||
TAG=$OPTARG
|
||||
;;
|
||||
a)
|
||||
ALL_PLATFORMS=1
|
||||
;;
|
||||
d)
|
||||
DEV=1
|
||||
;;
|
||||
?)
|
||||
usage
|
||||
exit
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
VERSION="1.6.0"
|
||||
|
||||
if [ $ALL_PLATFORMS -eq 1 ]
|
||||
then
|
||||
PLATFORMS="linux/arm/v7,linux/arm/v6,linux/arm64,linux/amd64"
|
||||
else
|
||||
PLATFORMS="linux/amd64"
|
||||
fi
|
||||
|
||||
if [ $DEV -eq 1 ]
|
||||
then
|
||||
# Use this script to locally build the docker image
|
||||
docker buildx build --push --platform $PLATFORMS \
|
||||
-t harbor.hemna.com/hemna6969/aprsd:$TAG \
|
||||
-f Dockerfile-dev --no-cache .
|
||||
else
|
||||
# Use this script to locally build the docker image
|
||||
docker buildx build --push --platform $PLATFORMS \
|
||||
-t hemna6969/aprsd:$VERSION \
|
||||
-t hemna6969/aprsd:latest \
|
||||
-t harbor.hemna.com/hemna6969/aprsd:latest \
|
||||
-t harbor.hemna.com/hemna6969/aprsd:$VERSION \
|
||||
-f Dockerfile .
|
||||
|
||||
|
||||
fi
|
||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 84 KiB |
Vendored
+1
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 34 KiB |
@@ -28,6 +28,14 @@ aprsd.plugins.location module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.plugins.notify module
|
||||
---------------------------
|
||||
|
||||
.. automodule:: aprsd.plugins.notify
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.plugins.ping module
|
||||
-------------------------
|
||||
|
||||
@@ -44,6 +52,14 @@ aprsd.plugins.query module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.plugins.stock module
|
||||
--------------------------
|
||||
|
||||
.. automodule:: aprsd.plugins.stock
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.plugins.time module
|
||||
-------------------------
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ aprsd.client module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.dev module
|
||||
----------------
|
||||
|
||||
.. automodule:: aprsd.dev
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.email module
|
||||
------------------
|
||||
|
||||
@@ -36,6 +44,14 @@ aprsd.fake\_aprs module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.flask module
|
||||
------------------
|
||||
|
||||
.. automodule:: aprsd.flask
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.fuzzyclock module
|
||||
-----------------------
|
||||
|
||||
@@ -44,6 +60,22 @@ aprsd.fuzzyclock module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.healthcheck module
|
||||
------------------------
|
||||
|
||||
.. automodule:: aprsd.healthcheck
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.listen module
|
||||
-------------------
|
||||
|
||||
.. automodule:: aprsd.listen
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.main module
|
||||
-----------------
|
||||
|
||||
@@ -60,6 +92,14 @@ aprsd.messaging module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.packets module
|
||||
--------------------
|
||||
|
||||
.. automodule:: aprsd.packets
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.plugin module
|
||||
-------------------
|
||||
|
||||
@@ -68,6 +108,22 @@ aprsd.plugin module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.plugin\_utils module
|
||||
--------------------------
|
||||
|
||||
.. automodule:: aprsd.plugin_utils
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.stats module
|
||||
------------------
|
||||
|
||||
.. automodule:: aprsd.stats
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.threads module
|
||||
--------------------
|
||||
|
||||
@@ -76,6 +132,14 @@ aprsd.threads module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.trace module
|
||||
------------------
|
||||
|
||||
.. automodule:: aprsd.trace
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
aprsd.utils module
|
||||
------------------
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<mxfile host="Electron" modified="2021-07-16T15:12:57.292Z" agent="5.0 (Macintosh; Intel Mac OS X 11_4_0) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/14.6.13 Chrome/89.0.4389.128 Electron/12.0.7 Safari/537.36" etag="AlvthIbz79qatFisVqeK" version="14.6.13" type="device"><diagram id="WCGkvdbYsMb_VYvvfK7z" name="Page-1">7V1bm5o4GP41cznzcEYvHavtPNvp2nG2214iRKVF4gCO2l+/QQlCEuRggtp1ejHygZnw5s13ypf0Tu0vNh8Dazl/hg7w7hTJ2dypH+4URZYlA/2KJdu9pGsqe8EscJ3koYNg7P4GiVBKpCvXAWHuwQhCL3KXeaENfR/YUU5mBQFc5x+bQi//V5fWDFCCsW15tPRf14nme2lHlw7yT8CdzaP0hZM7Cws/nAjCueXAdUakDu7UfgBhtP+02PSBF4OHcdl/b1hwN+1YAPyoyhfe/vqi2HO98yJpwf3v9U+r+1O6l5Phebe8VfLGSW+jLYYANYPQRheP6BWWsdD24Aq1+rieuxEYLy07Fq4RAZBsHi08dCWjj1PX8/rQg8GuHbXfH+hD1LtHuufJy7yDIAKbjCh5k48ALkAUbNEjyV2Mf8IqNblcH4ZIwwMxzwwPkibUSGgxS1s+IIc+JODVAVIpBzKAK98BcStSbfCGw2Fn8MgHPI0Ej0bPNBjoqR1h6DHAM7wohgGi18yiaLytIL5xH+70RQ89ICvLzeEm+jSLfz9bro9bmgRYOo6sIArj99j1LJoHwHJC+jksQa+07wYWcxzXqR7/i+dWFMBfIHPH2P0kb5qR7384TSNiHhkyTQVZYVDBEMUEehqNge88gzBEmvZ1N1Jc8Tceh0OTk1Iy8mBqjHnVLpg6BeZgYbnehcOoy4R+0s+NI20kX4DtgndwHbxMXZBjgGptAmpSgFrLIHQetgiOYvejHoJdbTjsckLQlEg9qdMmkwGgLgrATrm3gdRmL3aAd/6aFYaunYcLoRFsv2cvfsRMfdDx5YdNwtz91RZfbdzoe+Zz5lvo6vCl+AJ/p3AIQrgKbFDOFGSxZyAq9yCAk3Pn6QHNDJh+ZMAC4FmR+54PAlijmPyFEXR3fgpWYYSLpSsEEfbvnXwr67STDXUJm2ISDe2BoRrakSp97RMcM5VBtJqemcHyzFJvCwt6o5cxakQagwDNy4zrNanrjqE5HeWpnvetfOgDQl0kIstzZ348XxBNUR/Ux1hDuCgY7CU3Fq7jeEWqKK/uOWgdgkSGxlDbLD9dXJCjUWx4eu6NUFvj59cRV/vn6KDjaCy/uKNM1J1fzMMymnmI5S4NsanTCKvCEKYtYzwx7p/GjDAmmSmc7GU7iMuEQutU9O3EhZ5NTWkYR5GNLKzZzMQqomysUdHG4nG5ECOrEU6ZSuq9qkZWI6Jgpduyke0Ko2BzUig3UpyVFBhXIkby3AlXO9vt9vu8krJE/kNVKup2cckkVl6Rs27PBEw/svfKVHsaMf3IKnPOmh2jXR4+mbdZLGQWs+Kn/xMD9aoE1G4EFEJAOmTjT8C8e6tUdW/ly3Jv9RsDRTBQpVchuDGQqcrkozwqUptSCWnF808xLpqAGummNSWgJrdMQDqnQhFwhnzoZeEQUy5wWm9iTXALEnusCtIeGivv0WEMpizOOa5Q+nFixcKw32fOnOPDVB5vnBE0mku7JVXOyPX7gpDDd4m0JyPryaqeEYYqnYMbwiBa+eC6cWWUJbWLK51Y+gxtZBSgf93AMhawWwUWD2wG2JHrz64bVPPcoNLZmq8rkDR8taiyVpRaRVWhUH11F1euWOVzWyyVzut8A0F49YpVPrfJUul8RR8uFpbv0AugI281c/2QQvxSCwA4DVmn1HnrMJashRUFaO1nmMoSTDT0VWN1uWq9kXZZBUdUtoisPGsarKfF4S0F61qF5XjRwbpmlDpGrKiTLM3iN8Nob5PCBG/NWGxm8UacB9sNbfiwdhGvQBg+WEif+b6Vn17LeBBBMHhHEIaJzLHCearCCGskqUana7IqRKa7n/QO3imjZDToZ2sCvBEM3V30oX6YwCiCC4aKjeCSpYnhKvJcH/1RvM+nho5NSVV7f0lmyFkrlzi7zn/IaV84rgNCkqePvdfBWZ2MGmimlfCVJpCwqiqtXgo6cQaYiWbzlEWzo3CW2ibMiXLbdFklGUSNXdM0clo6QPg9LRkmvYISFm2YDNL1M85tmSrkkG+WqQqrahfZnM0y0RnwT71nJHixHBee1TDVAPNSDFO9yj+GYTqsZnaMbjZKupceJKl0JT6+GoHARa8Tc7kJdcvtVtUFUO2yipAKEnl17Ra1K7BdsyVu9T1VyKcVmmJAyulxWevj5N5ZmcyJVSYIadXJhgooggbH2mYe2xnNsLjDBpnrS5Av7FdB0vXA0H0PmvJ1Onsyopf3IXz7af29/rz49vZ1xdj/+wVG7nRLZ//Eb7pkOGwUwYuTCGT5BGM3cJfBTh5L10xkKyiCjP9a2Vk9CRPWdnNhC/pMVKps/zgdluIBKXRNGEknJjSkWeKGTLvlISVz7cjY1S8PEQYZ7Ro/WiHACkycZ8wTvLNXiLB7Sy8yYFil61wYEjBm51sYYveWtjhqgAy2NEKe5ZXPhxaXSdm9FbdlLBNSKrUqZJtU496xPPrSOOKY55gNI4rt2qWs0+lkFrNxUS3pIfGLK9loV0iHlp5AwdwHcHwbQMGScKUF4bo0q1rNXUzHG81OpRkdDzamWX43tVmRZ/Ll8Kx4n+6NZ6fyrOk+vVKeNaJZxQoXUXbzRjRxRKMDmZ6ziE8plP55oih3Kcksg0SN4f8K29rNxlHgrrKDA9w18tPSbLKewtQLrXnFlQ/QuvnFXOY3nTYbR9butFE0Nz1gR2h+kkQtmdhWuNwvLE/dTawMqDNEp1PFtlmr344xMXSDkw7AByGlQ1TtDDwexQdHqZ3dNYTm8y+uWpQ4H4OrFm31gAw2hAIPP2pwzEDTIw146kuN1pdH2Hf1+tIkE1vidoczYWTspKrGwD+AapVTCdofQTVxphldHs7R3z9++N8I1MF/</diagram></mxfile>
|
||||
@@ -0,0 +1,332 @@
|
||||
CHANGES
|
||||
=======
|
||||
|
||||
v2.0.0
|
||||
------
|
||||
|
||||
* Reworked the notification threads and admin ui
|
||||
* Fixed small bug with packets get\_packet\_type
|
||||
* Updated overview images
|
||||
* Move version string output to top of log
|
||||
* Add new watchlist feature
|
||||
* Fixed the Ack thread not resending acks
|
||||
* reworked the admin ui to use semenatic ui more
|
||||
* Added messages count to admin messages list
|
||||
* Add admin UI tabs for charts, messages, config
|
||||
* Removed a noisy debug log
|
||||
* Dump out the config during startup
|
||||
* Added message counts for each plugin
|
||||
* Bump urllib3 from 1.26.4 to 1.26.5
|
||||
* Added aprsd version checking
|
||||
* Updated INSTALL.txt
|
||||
* Update my callsign
|
||||
* Update README.rst
|
||||
* Update README.rst
|
||||
* Bump urllib3 from 1.26.3 to 1.26.4
|
||||
* Prep for v1.6.1 release
|
||||
|
||||
v1.6.1
|
||||
------
|
||||
|
||||
* Removed debug log for KeepAlive thread
|
||||
* ignore Makefile.venv
|
||||
* Reworked Makefile to use Makefile.venv
|
||||
* Fixed version unit tests
|
||||
* Updated stats output for KeepAlive thread
|
||||
* Update Dockerfile-dev to work with startup
|
||||
* Force all the graphs to 0 minimum
|
||||
* Added email messages graphs
|
||||
* Reworked the stats dict output and healthcheck
|
||||
* Added callsign to the web index page
|
||||
* Added log config for flask and lnav config file
|
||||
* Added showing APRS-IS server to stats
|
||||
* Provide an initial datapoint on rendering index
|
||||
* Make the index page behind auth
|
||||
* Bump pygments from 2.7.3 to 2.7.4
|
||||
* Added acks with messages graphs
|
||||
* Updated web stats index to show messages and ram usage
|
||||
* Added aprsd web index page
|
||||
* Bump lxml from 4.6.2 to 4.6.3
|
||||
* Bump jinja2 from 2.11.2 to 2.11.3
|
||||
* Bump urllib3 from 1.26.2 to 1.26.3
|
||||
* Added log format and dateformat to config file
|
||||
* Added Dockerfile-dev and updated build.sh
|
||||
* Require python 3.7 and >
|
||||
* Added plugin live reload and StockPlugin
|
||||
* Updated Dockerfile and build.sh
|
||||
* Updated Dockerfile for multiplatform builds
|
||||
* Updated Dockerfile for multiplatform builds
|
||||
* Dockerfile: Make creation of /config quiet failure
|
||||
* Updated README docs
|
||||
|
||||
v1.6.0
|
||||
------
|
||||
|
||||
* 1.6.0 release prep
|
||||
* 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
|
||||
* Fixed main server client initialization
|
||||
* test plugin expect responses update to match query output
|
||||
* Fixed the queryPlugin unit test
|
||||
* Removed flask code
|
||||
* Changed default log level to INFO
|
||||
* fix plugin tests to expect new strings
|
||||
* fix query command syntax ?, ?3, ?d(elete), ?a(ll)
|
||||
* Fixed latitude reporting in locationPlugin
|
||||
* get rid of some debug noise from tracker and email delay
|
||||
* fixed sample-config double print
|
||||
* make sample config easier to interpret
|
||||
* Fixed comments
|
||||
* Added the ability to add comments to the config file
|
||||
* Updated docker run.sh script
|
||||
* Added --raw format for sending messages
|
||||
* Fixed --quiet option
|
||||
* Added send-message login checking and --no-ack
|
||||
* Added new config for aprs.fi API Key
|
||||
* Added a fix for failed logins to APRS-IS
|
||||
* Fixed unit test for fortune plugin
|
||||
* Fixed fortune plugin failures
|
||||
* getting out of git hell with client.py problems
|
||||
* Extend APRS.IS object to change login string
|
||||
* Extend APRS.IS object to change login string
|
||||
* expect different reply from query plugin
|
||||
* update query plugin to resend last N messages. syntax: ?rN
|
||||
* Added unit test for QueryPlugin
|
||||
* Updated MsgTrack restart\_delayed
|
||||
* refactor Plugin objects to plugins directory
|
||||
* Updated README with more workflow details
|
||||
* change query character syntax, don't reply that we're resending stuff
|
||||
* Added APRSD system diagram to docs
|
||||
* Disable MX record validation
|
||||
* Added some more badges to readme files
|
||||
* Updated build for docs tox -edocs
|
||||
* switch command characters for query plugin
|
||||
* Fix broken test
|
||||
* undo git disaster
|
||||
* swap Query command characters a bit
|
||||
* Added Sphinx based documentation
|
||||
* refactor Plugin objects to plugins directory
|
||||
* Updated Makefile
|
||||
* removed double-quote-string-fixer
|
||||
* Lots of fixes
|
||||
* Added more pre-commit hook tests
|
||||
* Fixed email shortcut lookup
|
||||
* Added Makefile for easy dev setup
|
||||
* Added Makefile for easy dev setup
|
||||
* Cleaned out old ack\_dict
|
||||
* add null reply for send\_email
|
||||
* Updated README with more workflow details
|
||||
* backout my patch that broke tox, trying to push to craiger-test branch
|
||||
* Fixed failures caused by last commit
|
||||
* don't tell radio emails were sent, ack is enuf
|
||||
* Updated README to include development env
|
||||
* Added pre-commit hooks
|
||||
* Update Changelog for v1.5.0
|
||||
* Added QueryPlugin resend all delayed msgs or Flush
|
||||
* Added QueryPlugin
|
||||
* Added support to save/load MsgTrack on exit/start
|
||||
* Creation of MsgTrack object and other stuff
|
||||
* Added FortunePlugin unit test
|
||||
* Added some plugin unit tests
|
||||
* reworked threading
|
||||
* Reworked messaging lib
|
||||
|
||||
v1.1.0
|
||||
------
|
||||
|
||||
* Refactored the main process\_packet method
|
||||
* Update README with version 1.1.0 related info
|
||||
* Added fix for an unknown packet type
|
||||
* Ensure fortune is installed
|
||||
* Updated docker-compose
|
||||
* Added Changelog
|
||||
* Fixed issue when RX ack
|
||||
* Updated the aprsd-slack-plugin required version
|
||||
* Updated README.rst
|
||||
* Fixed send-message with email command and others
|
||||
* Update .gitignore
|
||||
* Big patch
|
||||
* Major refactor
|
||||
* Updated the Dockerfile to use alpine
|
||||
|
||||
v1.0.1
|
||||
------
|
||||
|
||||
* Fix unknown characterset emails
|
||||
* Updated loggin timestamp to include []
|
||||
* Updated README with a TOC
|
||||
* Updates for building containers
|
||||
* Don't use the dirname for the plugin path search
|
||||
* Reworked Plugin loading
|
||||
* Updated README with development information
|
||||
* Fixed an issue with weather plugin
|
||||
|
||||
v1.0.0
|
||||
------
|
||||
|
||||
* Rewrote the README.md to README.rst
|
||||
* Fixed the usage string after plugins introduced
|
||||
* Created plugin.py for Command Plugins
|
||||
* Refactor networking and commands
|
||||
* get rid of some debug statements
|
||||
* yet another unicode problem, in resend\_email fixed
|
||||
* reset default email check delay to 60, fix a few comments
|
||||
* Update tox environment to fix formatting python errors
|
||||
* fixed fortune. yet another unicode issue, tested in py3 and py2
|
||||
* lose some logging statements
|
||||
* completely off urllib now, tested locate/weather in py2 and py3
|
||||
* add urllib import back until i replace all calls with requests
|
||||
* cleaned up weather code after switch to requests ... from urllib. works on py2 and py3
|
||||
* switch from urlib to requests for weather, tested in py3 and py2. still need to update locate, and all other http calls
|
||||
* imap tags are unicode in py3. .decode tags
|
||||
* Update INSTALL.txt
|
||||
* Initial conversion to click
|
||||
* Reconnect on socket timeout
|
||||
* clean up code around closed\_socket and reconnect
|
||||
* Update INSTALL.txt
|
||||
* Fixed all pep8 errors and some py3 errors
|
||||
* fix check\_email\_thread to do proper threading, take delay as arg
|
||||
* found another .decode that didn't include errors='ignore'
|
||||
* some failed attempts at getting the first txt or html from a multipart message, currently sends the last
|
||||
* fix parse\_email unicode probs by using body.decode(errors='ignore').. again
|
||||
* fix parse\_email unicode probs by using body.decode(errors='ignore')
|
||||
* clean up code around closed\_socket and reconnect
|
||||
* socket timeout 5 minutes
|
||||
* Detect closed socket, reconnect, with a bit more grace
|
||||
* can detect closed socket and reconnect now
|
||||
* Update INSTALL.txt
|
||||
* more debugging messages trying to find rare tight loop in main
|
||||
* Update INSTALL.txt
|
||||
* main loop went into tight loop, more debug prints
|
||||
* main loop went into tight loop, added debug print before every continue
|
||||
* Update INSTALL.txt
|
||||
* Update INSTALL.txt
|
||||
* George Carlin profanity filter
|
||||
* added decaying email check timer which resets with activity
|
||||
* Fixed all pep8 errors and some py3 errors
|
||||
* Fixed all pep8 errors and some py3 errors
|
||||
* Reconnect on socket timeout
|
||||
* socket reconnect on timeout testing
|
||||
* socket timeout of 300 instead of 60
|
||||
* Reconnect on socket timeout
|
||||
* socket reconnect on timeout testing
|
||||
* Fixed all pep8 errors and some py3 errors
|
||||
* fix check\_email\_thread to do proper threading, take delay as arg
|
||||
* INSTALL.txt for the average person
|
||||
* fix bugs after beautification and yaml config additions. Convert to sockets. case insensitive commands
|
||||
* fix INBOX
|
||||
* Update README.md
|
||||
* Added tox support
|
||||
* Fixed SMTP settings
|
||||
* Created fake\_aprs.py
|
||||
* select inbox if gmail server
|
||||
* removed ASS
|
||||
* Added a try block around imap login
|
||||
* Added port and fixed telnet user
|
||||
* Require ~/.aprsd/config.yml
|
||||
* updated README for install and usage instructions
|
||||
* added test to ensure shortcuts in config.yml
|
||||
* added exit if missing config file
|
||||
* Added reading of a config file
|
||||
* update readme
|
||||
* update readme
|
||||
* sanitize readme
|
||||
* readme again again
|
||||
* readme again again
|
||||
* readme again
|
||||
* readme
|
||||
* readme update
|
||||
* First stab at migrating this to a pytpi repo
|
||||
* First stab at migrating this to a pytpi repo
|
||||
* Added password, callsign and host
|
||||
* Added argparse for cli options
|
||||
* comments
|
||||
* Cleaned up trailing whitespace
|
||||
* add tweaked fuzzyclock
|
||||
* make tn a global
|
||||
* Added standard python main()
|
||||
* tweaks to readme
|
||||
* drop virtenv on first line
|
||||
* sanitize readme a bit more
|
||||
* sanitize readme a bit more
|
||||
* sanitize readme
|
||||
* added weather and location 3
|
||||
* added weather and location 2
|
||||
* added weather and location
|
||||
* mapme
|
||||
* de-localize
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* de-localize
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update aprsd.py
|
||||
* Add files via upload
|
||||
* Update README.md
|
||||
* Update aprsd.py
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Update README.md
|
||||
* Add files via upload
|
||||
* Initial commit
|
||||
@@ -13,6 +13,7 @@
|
||||
:caption: Contents:
|
||||
|
||||
readme
|
||||
changelog
|
||||
install
|
||||
configure
|
||||
server
|
||||
|
||||
+132
-64
@@ -1,5 +1,7 @@
|
||||
=====
|
||||
APRSD
|
||||
-----
|
||||
=====
|
||||
by KM6LYW and WB4BOR
|
||||
|
||||
.. image:: https://badge.fury.io/py/aprsd.svg
|
||||
:target: https://badge.fury.io/py/aprsd
|
||||
@@ -7,12 +9,6 @@ APRSD
|
||||
.. image:: https://github.com/craigerl/aprsd/workflows/python/badge.svg
|
||||
:target: https://github.com/craigerl/aprsd/actions
|
||||
|
||||
.. image:: https://img.shields.io/pypi/pyversions/aprsd.svg
|
||||
:target: https://pypi.python.org/pypi/aprsd
|
||||
|
||||
.. image:: https://img.shields.io/:license-apache-blue.svg
|
||||
:target: http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
|
||||
:target: https://black.readthedocs.io/en/stable/
|
||||
|
||||
@@ -26,9 +22,7 @@ APRSD
|
||||
.. image:: https://static.pepy.tech/personalized-badge/aprsd?period=month&units=international_system&left_color=black&right_color=orange&left_text=Downloads
|
||||
:target: https://pepy.tech/project/aprsd
|
||||
|
||||
|
||||
Summary
|
||||
=======
|
||||
.. contents:: :local:
|
||||
|
||||
`APRSD <http://github.com/craigerl/aprsd>`_ is a Ham radio `APRS <http://aprs.org>`_ message command gateway built on python.
|
||||
|
||||
@@ -43,12 +37,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.
|
||||
|
||||
APRSD overview diagram
|
||||
Documentation: https://aprsd.readthedocs.io
|
||||
|
||||
|
||||
APRSD Overview Diagram
|
||||
----------------------
|
||||
|
||||
.. figure:: _static/aprsd_overview.svg
|
||||
:align: center
|
||||
:width: 800px
|
||||
.. image:: https://raw.githubusercontent.com/craigerl/aprsd/master/docs/_static/aprsd_overview.svg?sanitize=true
|
||||
|
||||
|
||||
Typical use case
|
||||
@@ -59,7 +54,9 @@ the weather. an APRS message is sent, and then picked up by APRSD. The
|
||||
APRS packet is decoded, and the message is sent through the list of plugins
|
||||
for processing. For example, the WeatherPlugin picks up the message, fetches the weather
|
||||
for the area around the user who sent the request, and then responds with
|
||||
the weather conditions in that area.
|
||||
the weather conditions in that area. Also includes a watch list of HAM
|
||||
callsigns to look out for. The watch list can notify you when a HAM callsign
|
||||
in the list is seen and now available to message on the APRS network.
|
||||
|
||||
|
||||
APRSD Capabilities
|
||||
@@ -87,6 +84,18 @@ If it matches, the plugin runs. IF the regex doesn't match, the plugin is skipp
|
||||
* VersionPlugin - Reports the version information for aprsd
|
||||
|
||||
|
||||
List of core notification plugins
|
||||
=================================
|
||||
|
||||
These plugins see all APRS messages from ham callsigns in the config's watch
|
||||
list.
|
||||
|
||||
* NotifySeenPlugin - Send a message when a message is seen from a callsign in
|
||||
the watch list. This is helpful when you want to know
|
||||
when a friend is online in the ARPS network, but haven't
|
||||
been seen in a while.
|
||||
|
||||
|
||||
Current messages this will respond to:
|
||||
======================================
|
||||
|
||||
@@ -116,6 +125,11 @@ email server, and associated logins, passwords. search for "yourdomain",
|
||||
"password". Search for "shortcuts" to setup email aliases as well.
|
||||
|
||||
|
||||
Installation:
|
||||
=============
|
||||
|
||||
pip install aprsd
|
||||
|
||||
Example usage:
|
||||
==============
|
||||
|
||||
@@ -145,9 +159,11 @@ Help
|
||||
|
||||
|
||||
|
||||
Commands
|
||||
========
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
=============
|
||||
This command outputs a sample config yml formatted block that you can edit
|
||||
and use to pass in to aprsd with -c. By default aprsd looks in ~/.config/aprsd/aprsd.yml
|
||||
|
||||
@@ -157,46 +173,85 @@ Output
|
||||
======
|
||||
::
|
||||
|
||||
└─[$] > aprsd sample-config
|
||||
|
||||
└─> aprsd sample-config
|
||||
aprs:
|
||||
host: rotate.aprs.net
|
||||
logfile: /tmp/arsd.log
|
||||
login: someusername
|
||||
password: somepassword
|
||||
port: 14580
|
||||
# Get the passcode for your callsign here:
|
||||
# https://apps.magicbug.co.uk/passcode
|
||||
host: rotate.aprs2.net
|
||||
login: CALLSIGN
|
||||
password: '00000'
|
||||
port: 14580
|
||||
aprsd:
|
||||
enabled_plugins:
|
||||
- aprsd.plugin.EmailPlugin
|
||||
- aprsd.plugin.FortunePlugin
|
||||
- aprsd.plugin.LocationPlugin
|
||||
- aprsd.plugin.PingPlugin
|
||||
- aprsd.plugin.TimePlugin
|
||||
- aprsd.plugin.WeatherPlugin
|
||||
- aprsd.plugin.VersionPlugin
|
||||
plugin_dir: ~/.config/aprsd/plugins
|
||||
dateformat: '%m/%d/%Y %I:%M:%S %p'
|
||||
email:
|
||||
enabled: true
|
||||
imap:
|
||||
debug: false
|
||||
host: imap.gmail.com
|
||||
login: IMAP_USERNAME
|
||||
password: IMAP_PASSWORD
|
||||
port: 993
|
||||
use_ssl: true
|
||||
shortcuts:
|
||||
aa: 5551239999@vtext.com
|
||||
cl: craiglamparter@somedomain.org
|
||||
wb: 555309@vtext.com
|
||||
smtp:
|
||||
debug: false
|
||||
host: smtp.gmail.com
|
||||
login: SMTP_USERNAME
|
||||
password: SMTP_PASSWORD
|
||||
port: 465
|
||||
use_ssl: false
|
||||
enabled_plugins:
|
||||
- aprsd.plugins.email.EmailPlugin
|
||||
- aprsd.plugins.fortune.FortunePlugin
|
||||
- aprsd.plugins.location.LocationPlugin
|
||||
- aprsd.plugins.ping.PingPlugin
|
||||
- aprsd.plugins.query.QueryPlugin
|
||||
- aprsd.plugins.stock.StockPlugin
|
||||
- aprsd.plugins.time.TimePlugin
|
||||
- aprsd.plugins.weather.USWeatherPlugin
|
||||
- aprsd.plugins.version.VersionPlugin
|
||||
logfile: /tmp/aprsd.log
|
||||
logformat: '[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s] %(message)s - [%(pathname)s:%(lineno)d]'
|
||||
trace: false
|
||||
units: imperial
|
||||
web:
|
||||
enabled: true
|
||||
host: 0.0.0.0
|
||||
logging_enabled: true
|
||||
port: 8001
|
||||
users:
|
||||
admin: aprsd
|
||||
ham:
|
||||
callsign: KFART
|
||||
imap:
|
||||
host: imap.gmail.com
|
||||
login: imapuser
|
||||
password: something here too
|
||||
port: 993
|
||||
use_ssl: true
|
||||
shortcuts:
|
||||
aa: 5551239999@vtext.com
|
||||
cl: craiglamparter@somedomain.org
|
||||
wb: 555309@vtext.com
|
||||
smtp:
|
||||
host: imap.gmail.com
|
||||
login: something
|
||||
password: some lame password
|
||||
port: 465
|
||||
use_ssl: false
|
||||
|
||||
callsign: CALLSIGN
|
||||
services:
|
||||
aprs.fi:
|
||||
# Get the apiKey from your aprs.fi account here:
|
||||
# http://aprs.fi/account
|
||||
apiKey: APIKEYVALUE
|
||||
avwx:
|
||||
# (Optional for AVWXWeatherPlugin)
|
||||
# Use hosted avwx-api here: https://avwx.rest
|
||||
# or deploy your own from here:
|
||||
# https://github.com/avwx-rest/avwx-api
|
||||
apiKey: APIKEYVALUE
|
||||
base_url: http://host:port
|
||||
opencagedata:
|
||||
# (Optional for TimeOpenCageDataPlugin)
|
||||
# Get the apiKey from your opencagedata account here:
|
||||
# https://opencagedata.com/dashboard#api-keys
|
||||
apiKey: APIKEYVALUE
|
||||
openweathermap:
|
||||
# (Optional for OWMWeatherPlugin)
|
||||
# Get the apiKey from your
|
||||
# openweathermap account here:
|
||||
# https://home.openweathermap.org/api_keys
|
||||
apiKey: APIKEYVALUE
|
||||
|
||||
server
|
||||
------
|
||||
======
|
||||
|
||||
This is the main server command that will listen to APRS-IS servers and
|
||||
look for incomming commands to the callsign configured in the config file
|
||||
@@ -211,7 +266,7 @@ look for incomming commands to the callsign configured in the config file
|
||||
Options:
|
||||
--loglevel [CRITICAL|ERROR|WARNING|INFO|DEBUG]
|
||||
The log level to use for aprsd.log
|
||||
[default: DEBUG]
|
||||
[default: INFO]
|
||||
|
||||
--quiet Don't log to stdout
|
||||
--disable-validation Disable email shortcut validation. Bad
|
||||
@@ -219,19 +274,24 @@ look for incomming commands to the callsign configured in the config file
|
||||
responses!!
|
||||
|
||||
-c, --config TEXT The aprsd config file to use for options.
|
||||
[default: ~/.config/aprsd/aprsd.yml]
|
||||
[default:
|
||||
/home/waboring/.config/aprsd/aprsd.yml]
|
||||
|
||||
-f, --flush Flush out all old aged messages on disk.
|
||||
[default: False]
|
||||
|
||||
-h, --help Show this message and exit.
|
||||
(.venv3) ┌─[waboring@dl360-1] - [~/devel/aprsd] - [Sun Dec 20, 12:32] -
|
||||
└─[$] <git:(master*)> aprsd server
|
||||
|
||||
$ aprsd server
|
||||
Load config
|
||||
[12/20/2020 12:33:03 PM] [MainThread ] [INFO ] APRSD Started version: 1.0.2
|
||||
[12/20/2020 12:33:03 PM] [MainThread ] [INFO ] Checking IMAP configuration
|
||||
[12/20/2020 12:33:04 PM] [MainThread ] [INFO ] Checking SMTP configuration
|
||||
[02/13/2021 09:22:09 AM] [MainThread ] [INFO ] APRSD Started version: 1.6.0
|
||||
[02/13/2021 09:22:09 AM] [MainThread ] [INFO ] Checking IMAP configuration
|
||||
[02/13/2021 09:22:09 AM] [MainThread ] [INFO ] Checking SMTP configuration
|
||||
[02/13/2021 09:22:10 AM] [MainThread ] [INFO ] Validating 2 Email shortcuts. This can take up to 10 seconds per shortcut
|
||||
|
||||
|
||||
send-message
|
||||
------------
|
||||
============
|
||||
|
||||
This command is typically used for development to send another aprsd instance
|
||||
test messages
|
||||
@@ -261,8 +321,8 @@ test messages
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
|
||||
Example Message output:
|
||||
-----------------------
|
||||
Example output:
|
||||
===============
|
||||
|
||||
|
||||
SEND EMAIL (radio to smtp server)
|
||||
@@ -333,7 +393,7 @@ AND... ping, fortune, time.....
|
||||
|
||||
|
||||
Development
|
||||
-----------
|
||||
===========
|
||||
|
||||
* git clone git@github.com:craigerl/aprsd.git
|
||||
* cd aprsd
|
||||
@@ -344,10 +404,18 @@ Workflow
|
||||
|
||||
While working aprsd, The workflow is as follows
|
||||
|
||||
* Edit code, save file
|
||||
* checkout a new branch to work on
|
||||
* git checkout -b mybranch
|
||||
* Edit code
|
||||
* run tox -epep8
|
||||
* run tox -efmt
|
||||
* run tox -p
|
||||
* git commit ( This will run the pre-commit hooks which does checks too )
|
||||
* Once you are done with all of your commits, then push up the branch to
|
||||
github
|
||||
* git push -u origin mybranch
|
||||
* Create a pull request from your branch so github tests can run and we can do
|
||||
a code review.
|
||||
|
||||
|
||||
Release
|
||||
@@ -373,7 +441,7 @@ To do release to pypi:
|
||||
|
||||
|
||||
Docker Container
|
||||
----------------
|
||||
================
|
||||
|
||||
Building
|
||||
========
|
||||
|
||||
+12
-4
@@ -1,12 +1,20 @@
|
||||
aprslib
|
||||
click
|
||||
click-completion
|
||||
flask
|
||||
flask-classful
|
||||
flask-httpauth
|
||||
imapclient
|
||||
opencage
|
||||
pluggy
|
||||
pbr
|
||||
pyyaml
|
||||
six
|
||||
# Allowing a newer version can lead to a conflict with
|
||||
# requests.
|
||||
py3-validate-email==0.2.16
|
||||
pytz
|
||||
requests
|
||||
six
|
||||
thesmuggler
|
||||
aprslib
|
||||
py3-validate-email
|
||||
pre-commit
|
||||
yfinance
|
||||
update_checker
|
||||
|
||||
+63
-29
@@ -4,14 +4,14 @@
|
||||
#
|
||||
# pip-compile requirements.in
|
||||
#
|
||||
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
|
||||
cfgv==3.2.0
|
||||
# via pre-commit
|
||||
cffi==1.14.5
|
||||
# via cryptography
|
||||
chardet==4.0.0
|
||||
# via requests
|
||||
click-completion==0.5.2
|
||||
@@ -20,55 +20,89 @@ click==7.1.2
|
||||
# via
|
||||
# -r requirements.in
|
||||
# click-completion
|
||||
distlib==0.3.1
|
||||
# via virtualenv
|
||||
# flask
|
||||
cryptography==3.4.7
|
||||
# via pyopenssl
|
||||
dnspython==2.1.0
|
||||
# via py3-validate-email
|
||||
filelock==3.0.12
|
||||
# via py3-validate-email
|
||||
flask-classful==0.14.2
|
||||
# via -r requirements.in
|
||||
flask-httpauth==4.3.0
|
||||
# via -r requirements.in
|
||||
flask==1.1.2
|
||||
# via
|
||||
# py3-validate-email
|
||||
# virtualenv
|
||||
identify==1.5.11
|
||||
# via pre-commit
|
||||
# -r requirements.in
|
||||
# flask-classful
|
||||
# flask-httpauth
|
||||
idna==2.10
|
||||
# via
|
||||
# py3-validate-email
|
||||
# requests
|
||||
imapclient==2.1.0
|
||||
imapclient==2.2.0
|
||||
# via -r requirements.in
|
||||
jinja2==2.11.2
|
||||
# via click-completion
|
||||
itsdangerous==1.1.0
|
||||
# via flask
|
||||
jinja2==2.11.3
|
||||
# via
|
||||
# click-completion
|
||||
# flask
|
||||
lxml==4.6.3
|
||||
# via yfinance
|
||||
markupsafe==1.1.1
|
||||
# via jinja2
|
||||
nodeenv==1.5.0
|
||||
# via pre-commit
|
||||
pbr==5.5.1
|
||||
multitasking==0.0.9
|
||||
# via yfinance
|
||||
numpy==1.20.2
|
||||
# via
|
||||
# pandas
|
||||
# yfinance
|
||||
opencage==1.2.2
|
||||
# via -r requirements.in
|
||||
pandas==1.2.4
|
||||
# via yfinance
|
||||
pbr==5.6.0
|
||||
# via -r requirements.in
|
||||
pluggy==0.13.1
|
||||
# via -r requirements.in
|
||||
pre-commit==2.9.3
|
||||
py3-validate-email==0.2.16
|
||||
# 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
|
||||
python-dateutil==2.8.1
|
||||
# via pandas
|
||||
pytz==2021.1
|
||||
# via
|
||||
# -r requirements.in
|
||||
# pre-commit
|
||||
requests==2.25.1
|
||||
# pandas
|
||||
pyyaml==5.4.1
|
||||
# via -r requirements.in
|
||||
shellingham==1.3.2
|
||||
requests==2.25.1
|
||||
# via
|
||||
# -r requirements.in
|
||||
# opencage
|
||||
# update-checker
|
||||
# yfinance
|
||||
shellingham==1.4.0
|
||||
# via click-completion
|
||||
six==1.15.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# click-completion
|
||||
# imapclient
|
||||
# virtualenv
|
||||
# opencage
|
||||
# pyopenssl
|
||||
# python-dateutil
|
||||
thesmuggler==1.0.1
|
||||
# via -r requirements.in
|
||||
toml==0.10.2
|
||||
# via pre-commit
|
||||
urllib3==1.26.2
|
||||
update-checker==0.18.0
|
||||
# via -r requirements.in
|
||||
urllib3==1.26.5
|
||||
# via requests
|
||||
virtualenv==20.2.2
|
||||
# via pre-commit
|
||||
werkzeug==1.0.1
|
||||
# via flask
|
||||
yfinance==0.1.59
|
||||
# via -r requirements.in
|
||||
|
||||
@@ -13,7 +13,6 @@ classifier =
|
||||
Operating System :: POSIX :: Linux
|
||||
Programming Language :: Python :: 3 :: Only
|
||||
Programming Language :: Python :: 3
|
||||
Programming Language :: Python :: 3.6
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
@@ -35,6 +34,9 @@ packages =
|
||||
[entry_points]
|
||||
console_scripts =
|
||||
aprsd = aprsd.main:main
|
||||
aprsd-listen = aprsd.listen:main
|
||||
aprsd-dev = aprsd.dev:main
|
||||
aprsd-healthcheck = aprsd.healthcheck:main
|
||||
fake_aprs = aprsd.fake_aprs:main
|
||||
|
||||
[build_sphinx]
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+94
-46
@@ -2,28 +2,42 @@ import unittest
|
||||
from unittest import mock
|
||||
|
||||
import aprsd
|
||||
from aprsd import messaging
|
||||
from aprsd import messaging, stats, utils
|
||||
from aprsd.fuzzyclock import fuzzy
|
||||
from aprsd.plugins import fortune as fortune_plugin
|
||||
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):
|
||||
def setUp(self):
|
||||
self.fromcall = "KFART"
|
||||
self.ack = 1
|
||||
self.config = {"ham": {"callsign": self.fromcall}}
|
||||
self.config = utils.DEFAULT_CONFIG_DICT
|
||||
self.config["ham"]["callsign"] = self.fromcall
|
||||
# Inintialize the stats object with the config
|
||||
stats.APRSDStats(self.config)
|
||||
|
||||
def fake_packet(self, fromcall="KFART", message=None, msg_number=None):
|
||||
packet = {"from": fromcall}
|
||||
if message:
|
||||
packet["message_text"] = message
|
||||
|
||||
if msg_number:
|
||||
packet["msgNo"] = msg_number
|
||||
|
||||
return packet
|
||||
|
||||
@mock.patch("shutil.which")
|
||||
def test_fortune_fail(self, mock_which):
|
||||
fortune = fortune_plugin.FortunePlugin(self.config)
|
||||
mock_which.return_value = None
|
||||
message = "fortune"
|
||||
expected = "Fortune command not installed"
|
||||
actual = fortune.run(self.fromcall, message, self.ack)
|
||||
packet = self.fake_packet(message="fortune")
|
||||
actual = fortune.run(packet)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@mock.patch("subprocess.check_output")
|
||||
@@ -34,18 +48,18 @@ class TestPlugin(unittest.TestCase):
|
||||
|
||||
mock_output.return_value = "Funny fortune"
|
||||
|
||||
message = "fortune"
|
||||
expected = "Funny fortune"
|
||||
actual = fortune.run(self.fromcall, message, self.ack)
|
||||
packet = self.fake_packet(message="fortune")
|
||||
actual = fortune.run(packet)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@mock.patch("aprsd.messaging.MsgTrack.flush")
|
||||
def test_query_flush(self, mock_flush):
|
||||
message = "?delete"
|
||||
packet = self.fake_packet(message="!delete")
|
||||
query = query_plugin.QueryPlugin(self.config)
|
||||
|
||||
expected = "Deleted ALL pending msgs."
|
||||
actual = query.run(self.fromcall, message, self.ack)
|
||||
actual = query.run(packet)
|
||||
mock_flush.assert_called_once()
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@@ -53,11 +67,11 @@ class TestPlugin(unittest.TestCase):
|
||||
def test_query_restart_delayed(self, mock_restart):
|
||||
track = messaging.MsgTrack()
|
||||
track.track = {}
|
||||
message = "?4"
|
||||
packet = self.fake_packet(message="!4")
|
||||
query = query_plugin.QueryPlugin(self.config)
|
||||
|
||||
expected = "No pending msgs to resend"
|
||||
actual = query.run(self.fromcall, message, self.ack)
|
||||
actual = query.run(packet)
|
||||
mock_restart.assert_not_called()
|
||||
self.assertEqual(expected, actual)
|
||||
mock_restart.reset_mock()
|
||||
@@ -65,35 +79,48 @@ class TestPlugin(unittest.TestCase):
|
||||
# add a message
|
||||
msg = messaging.TextMessage(self.fromcall, "testing", self.ack)
|
||||
track.add(msg)
|
||||
actual = query.run(self.fromcall, message, self.ack)
|
||||
actual = query.run(packet)
|
||||
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"
|
||||
message = "location"
|
||||
ack = 1
|
||||
packet = self.fake_packet(
|
||||
fromcall="KFART",
|
||||
message="location",
|
||||
msg_number=1,
|
||||
)
|
||||
|
||||
actual = time.run(fromcall, message, ack)
|
||||
actual = time.run(packet)
|
||||
self.assertEqual(None, actual)
|
||||
|
||||
cur_time = fuzzy(h, m, 1)
|
||||
|
||||
message = "time"
|
||||
expected = "{} ({}:{} PDT) ({})".format(
|
||||
cur_time,
|
||||
str(h),
|
||||
str(m).rjust(2, "0"),
|
||||
message.rstrip(),
|
||||
packet = self.fake_packet(
|
||||
fromcall="KFART",
|
||||
message="time",
|
||||
msg_number=1,
|
||||
)
|
||||
actual = time.run(fromcall, message, ack)
|
||||
local_short_str = local_t.strftime("%H:%M %Z")
|
||||
expected = "{} ({})".format(
|
||||
cur_time,
|
||||
local_short_str,
|
||||
)
|
||||
actual = time.run(packet)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@mock.patch("time.localtime")
|
||||
@@ -106,11 +133,13 @@ class TestPlugin(unittest.TestCase):
|
||||
|
||||
ping = ping_plugin.PingPlugin(self.config)
|
||||
|
||||
fromcall = "KFART"
|
||||
message = "location"
|
||||
ack = 1
|
||||
packet = self.fake_packet(
|
||||
fromcall="KFART",
|
||||
message="location",
|
||||
msg_number=1,
|
||||
)
|
||||
|
||||
result = ping.run(fromcall, message, ack)
|
||||
result = ping.run(packet)
|
||||
self.assertEqual(None, result)
|
||||
|
||||
def ping_str(h, m, s):
|
||||
@@ -123,30 +152,49 @@ class TestPlugin(unittest.TestCase):
|
||||
+ str(s).zfill(2)
|
||||
)
|
||||
|
||||
message = "Ping"
|
||||
actual = ping.run(fromcall, message, ack)
|
||||
packet = self.fake_packet(
|
||||
fromcall="KFART",
|
||||
message="Ping",
|
||||
msg_number=1,
|
||||
)
|
||||
actual = ping.run(packet)
|
||||
expected = ping_str(h, m, s)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
message = "ping"
|
||||
actual = ping.run(fromcall, message, ack)
|
||||
packet = self.fake_packet(
|
||||
fromcall="KFART",
|
||||
message="ping",
|
||||
msg_number=1,
|
||||
)
|
||||
actual = ping.run(packet)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_version(self):
|
||||
expected = "APRSD version '{}'".format(aprsd.__version__)
|
||||
@mock.patch("aprsd.plugin.PluginManager.get_msg_plugins")
|
||||
def test_version(self, mock_get_plugins):
|
||||
expected = "APRSD ver:{} uptime:0:0:0".format(aprsd.__version__)
|
||||
version = version_plugin.VersionPlugin(self.config)
|
||||
|
||||
fromcall = "KFART"
|
||||
message = "No"
|
||||
ack = 1
|
||||
packet = self.fake_packet(
|
||||
fromcall="KFART",
|
||||
message="No",
|
||||
msg_number=1,
|
||||
)
|
||||
|
||||
actual = version.run(fromcall, message, ack)
|
||||
actual = version.run(packet)
|
||||
self.assertEqual(None, actual)
|
||||
|
||||
message = "version"
|
||||
actual = version.run(fromcall, message, ack)
|
||||
packet = self.fake_packet(
|
||||
fromcall="KFART",
|
||||
message="version",
|
||||
msg_number=1,
|
||||
)
|
||||
actual = version.run(packet)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
message = "Version"
|
||||
actual = version.run(fromcall, message, ack)
|
||||
packet = self.fake_packet(
|
||||
fromcall="KFART",
|
||||
message="Version",
|
||||
msg_number=1,
|
||||
)
|
||||
actual = version.run(packet)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
Reference in New Issue
Block a user