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

Compare commits

..

113 Commits

Author SHA1 Message Date
hemna b4713b2694 Updated docs for 2.0.0 2021-07-17 15:15:52 -04:00
hemna b606495fbf Merge pull request #66 from craigerl/notify_rework
Reworked the notification threads and admin ui.
2021-07-17 14:45:18 -04:00
hemna 2fceba10e1 Reworked the notification threads and admin ui.
This patch updates the notification thread to send all packets
through the notification plugins.   The plugins themselves need to
do smart filter to not reply to every packet.  This allows for
more interesting plugins.

Also fixed an issue with the messages tab in the admin ui, not
showing all of the recieved packets.   The messages tab now also
sees all the packets that aprsd recieves.
2021-07-17 14:30:29 -04:00
hemna 3d38402be2 Fixed small bug with packets get_packet_type
This fixes an issue with trying to decode the packet type.
Also updated some of the log entries.
2021-07-16 12:15:04 -04:00
hemna 90a44bb5ed Updated overview images
This patch includes updated aprsd_overview diagram images
which now includes the new watch list feature.
2021-07-16 12:12:34 -04:00
hemna 7dc4fb3e77 Move version string output to top of log 2021-07-16 12:11:51 -04:00
hemna f31a4c07b4 Merge pull request #65 from craigerl/plugin_interface_change
Refactor the plugin interface and manager
2021-07-16 08:43:24 -04:00
hemna 1a1fcba1c4 Add new watchlist feature
This patch adds a new optional feature called Watch list.
Aprsd will filter IN all aprs packets from a list of callsigns.
APRSD will keep track of the last time a callsign has been seen.
When the configured timeout value has been reached, the next time
a callsign is seen, APRSD will send the next packet from that callsign
through the new notification plugins list.

The new BaseNotifyPlugin is the default core APRSD notify based plugin.
When it gets a packet it will construct a reply message to be sent
to the configured alert callsign to alert them that the seen callsign
is now on the APRS network.

This basically acts as a notification that your watched callsign list is
available on APRS.

The new configuration options:
aprsd:
    watch_list:
        # The callsign to send a message to once a watch list callsign
        # is now seen on APRS-IS
        alert_callsign: NOCALL
        # The time in seconds to wait for notification.
        # The default is 12 hours.
        alert_time_seconds: 43200
        # The list of callsigns to watch for
        callsigns:
          - WB4BOR
          - KFART
        # Enable/disable this feature
        enabled: false
        # The list of notify based plugins to load for
        # processing a new seen packet from a callsign.
        enabled_plugins:
        - aprsd.plugins.notify.BaseNotifyPlugin

This patch also adds a new section in the Admin UI for showing the
watch list and the age of the last seen packet for each callsing since
APRSD startup.
2021-07-16 08:31:38 -04:00
hemna 562ae52c1e Fixed the Ack thread not resending acks
This patch fixes a bug in the AckThread.  The thread loop
was exiting after the first attempt to send the ack.
Thread loops have to return True, in order to be called again
as this is the mechanism in which aprsd gracefully shuts down all
threads.
2021-07-15 14:11:30 -04:00
hemna 3c45d8bd0f reworked the admin ui to use semenatic ui more 2021-07-14 15:00:23 -04:00
hemna 5afc7fb664 Added messages count to admin messages list.
This patch adds a simple count of packets shown in the
messages list on the admin ui.
2021-07-14 10:29:12 -04:00
hemna ccaab72124 Merge pull request #64 from craigerl/web_tabs
Add admin UI tabs for charts, messages, config
2021-07-12 12:17:51 -04:00
hemna de62579852 Add admin UI tabs for charts, messages, config
This patch updates the admin UI to include 3 tabs
of content.
Charts
messages
config

The charts tab is the existing line charts.
The messages tab shows a list of RX (green) and TX (red) messages
from/to aprsd.
The config tab shows the config loaded at startup time.
2021-07-12 12:12:14 -04:00
hemna 1c66555450 Removed a noisy debug log 2021-07-09 15:22:53 -04:00
hemna 13be30f772 Merge pull request #63 from craigerl/dump_config
Dump out the config during startup
2021-07-05 11:02:17 -04:00
hemna 9a1ab1c0d6 Dump out the config during startup
This patch adds the dumping out of a flattened config to the log
at startup.  This is helpful for seeing what aprsd server is actually
using for config entries at startup and since it's in the log, you can
reference it.
2021-07-05 10:57:22 -04:00
hemna 3ae5717452 Added message counts for each plugin.
This patch adds a message counter for each plugin.  When the regex for
a plugin passes and the message is pass into the plugin for processing,
that message is tracked.  This message count is reported by the stats
tracking object now for the web admin ui.
2021-06-17 16:37:47 -04:00
hemna d8950f0995 Merge pull request #61 from craigerl/dependabot/pip/urllib3-1.26.5
Bump urllib3 from 1.26.4 to 1.26.5
2021-06-03 17:20:54 -04:00
dependabot[bot] 6fb16421e8 Bump urllib3 from 1.26.4 to 1.26.5
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.4 to 1.26.5.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.4...1.26.5)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-02 03:40:23 +00:00
hemna c14b1bc985 Merge pull request #60 from craigerl/update_checker
Added aprsd version checking
2021-05-04 10:10:09 -04:00
hemna 17302aa76d Added aprsd version checking
This patch adds usage of update_checker to check to make sure the
version of APRSD being launched is the latest version.  Also added a
call to upate_checker as part of the KeepAlive thread.  It will
call update_check every hour.  If there is no aprsd connectivitity,
the update check will silently fail.
2021-05-04 10:06:43 -04:00
hemna 2f7fa0c3d5 Merge pull request #56 from craigerl/dependabot/pip/urllib3-1.26.4
Bump urllib3 from 1.26.3 to 1.26.4
2021-05-03 09:54:54 -04:00
hemna 9de0df31eb Updated INSTALL.txt
This patch added some changes to the INSTALL.txt file to fix a user
issue: https://github.com/craigerl/aprsd/issues/58

Added documentation to the INSTALL.txt to use the makefile.  It's
far easier and superior in every way to setup and install than the
manual instructions.   Use pypi for the official package.
2021-04-30 21:28:22 -04:00
hemna b8dc6a329b Update my callsign
This patch updates my callsign for the README.rst
2021-04-21 21:56:32 -04:00
Craig Lamparter 970b32f238 Update README.rst 2021-04-12 16:30:20 -07:00
Craig Lamparter 2a5ef58295 Update README.rst 2021-04-12 16:29:31 -07:00
dependabot[bot] 2696a399cb Bump urllib3 from 1.26.3 to 1.26.4
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.3 to 1.26.4.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.3...1.26.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-06 18:24:15 +00:00
hemna 55862a2790 Prep for v1.6.1 release 2021-04-05 14:32:36 -04:00
hemna fc1ee19516 Removed debug log for KeepAlive thread
No need to dump out the length of the keepalive string for now.
2021-04-05 14:14:33 -04:00
hemna 4aac17dc98 ignore Makefile.venv 2021-04-05 12:47:13 -04:00
hemna a4a06c9763 Reworked Makefile to use Makefile.venv
Completely reworked the Makefile to make use
of an existing 'library' to manage python
virtual environments.

https://github.com/sio/Makefile.venv
2021-04-05 12:38:38 -04:00
hemna 23c219f0d2 Fixed version unit tests 2021-04-05 08:55:46 -04:00
hemna 7b019d24f0 Updated stats output for KeepAlive thread
Also added the aprsd uptime to the VersionPlugin
2021-04-02 18:54:00 -04:00
hemna 3f21934c0f Update Dockerfile-dev to work with startup 2021-04-02 12:51:50 -04:00
hemna cabe374909 Merge pull request #54 from craigerl/stats-web-ui
Added aprsd web index page
2021-04-02 12:00:55 -04:00
hemna 3ac42edd82 Force all the graphs to 0 minimum
This patch updates all the graphs to have a minimum
Y value of 0.  Doesn't make sense to have negative messages.
2021-04-02 11:57:06 -04:00
hemna d6806c429c Added email messages graphs
This patch cleans up the layout of the admin web page stats graphs
as well as adds in the email stats.  Added the titles to each
graph, so you know what you are looking at.
2021-04-02 11:47:52 -04:00
hemna bf8d2c6088 Reworked the stats dict output and healthcheck
This patch reworks the stats object dict and includes more data.
Also includes aprsis last update timestamp (from last recieved message).
This is used to help determine if the aprsis server connection is still
alive and well.
2021-04-01 23:12:25 -04:00
hemna 123266c9ad Added callsign to the web index page
This patch adds the aprs-is server callsign that aprsd is listening
on for messages.
2021-03-31 11:32:09 -04:00
hemna 34d2c31d90 Added log config for flask and lnav config file
This patch adds the aprsd-lnav.json formatting file.
This is useful when you want to tail the logfile with the lnav
log tailing app.

http://lnav.org/

To install the aprsd-lnav.json formatter
1) install lnav
2) lnav -i aprsd-lnav.json
3) lnav -C  -- just to test it out

The next time you launch aprsd do it with this
aprsd server --loglevel DEBUG | lnav

This patch also updates the logging output from the flask
web service to 1) disable flask web url logging and 2)
use the same output format as the rest of the app.
2021-03-31 11:07:39 -04:00
hemna d1a2a14370 Added showing APRS-IS server to stats
This patch updates the client.py to collect which APRS-IS server
that aprsd is connected to and displays that info on the stats web page.
2021-03-30 10:43:31 -04:00
hemna fb979eda94 Provide an initial datapoint on rendering index
This patch adds a single data point when rendering the
initial stats for the index page.
2021-03-30 10:18:56 -04:00
hemna 6297ebeb67 Make the index page behind auth
This patch makes the index page ask for login/password in order
to see the stats.
2021-03-30 09:55:14 -04:00
hemna c8484eb195 Merge pull request #53 from craigerl/dependabot/pip/lxml-4.6.3
Bump lxml from 4.6.2 to 4.6.3
2021-03-30 09:52:27 -04:00
hemna 5f8db9df37 Merge pull request #55 from craigerl/dependabot/pip/pygments-2.7.4
Bump pygments from 2.7.3 to 2.7.4
2021-03-30 09:52:13 -04:00
dependabot[bot] 0e6b46555a Bump pygments from 2.7.3 to 2.7.4
Bumps [pygments](https://github.com/pygments/pygments) from 2.7.3 to 2.7.4.
- [Release notes](https://github.com/pygments/pygments/releases)
- [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES)
- [Commits](https://github.com/pygments/pygments/compare/2.7.3...2.7.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-30 02:29:11 +00:00
hemna f10372b320 Added acks with messages graphs 2021-03-26 11:13:32 -04:00
hemna c7d10f53a3 Updated web stats index to show messages and ram usage
This patch updates the main index page to show both the
graph of tx/rx messages as well as peak/current ram usage.
2021-03-24 16:07:09 -04:00
hemna f211e5cabb Added aprsd web index page
This patch adds an index page for the flask web server that users
can hit at /
2021-03-24 10:45:03 -04:00
dependabot[bot] 6f3486bc72 Bump lxml from 4.6.2 to 4.6.3
Bumps [lxml](https://github.com/lxml/lxml) from 4.6.2 to 4.6.3.
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-4.6.2...lxml-4.6.3)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-23 14:16:10 +00:00
hemna 53cdccde10 Merge pull request #52 from craigerl/dependabot/pip/jinja2-2.11.3
Bump jinja2 from 2.11.2 to 2.11.3
2021-03-23 10:15:34 -04:00
hemna a657eeb390 Merge pull request #51 from craigerl/dependabot/pip/urllib3-1.26.3
Bump urllib3 from 1.26.2 to 1.26.3
2021-03-23 10:15:12 -04:00
dependabot[bot] d9536434b0 Bump jinja2 from 2.11.2 to 2.11.3
Bumps [jinja2](https://github.com/pallets/jinja) from 2.11.2 to 2.11.3.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/master/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/2.11.2...2.11.3)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-20 05:37:19 +00:00
dependabot[bot] 5f3e067c96 Bump urllib3 from 1.26.2 to 1.26.3
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.2 to 1.26.3.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.2...1.26.3)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-19 20:14:09 +00:00
hemna 0a038dae44 Added log format and dateformat to config file
This patch moves the default log format string and date format string
to the config file, so users can format the logs as they see fit.
The default log format also includes the file and line number that
posted the log entry.

The new entries in the config are here:
aprsd:
  logformat: "String here"
  dateformat: "string here"
2021-02-25 13:32:50 -05:00
hemna 239e784d51 Added Dockerfile-dev and updated build.sh
Build.sh is used for multi-architecture building of docker images.
2021-02-18 18:59:08 -05:00
hemna 933917bf9d Require python 3.7 and > 2021-02-18 16:51:31 -05:00
hemna e6cafeb3d2 Added plugin live reload and StockPlugin
This patch adds 2 items.  First it adds the new StockPlugin,
which fetches stock quotes from yahoo finance rest API using
the yfinance python module.

2nd, the web interface contains a new url /plugins, which allows
aprsd to reload all of it's plugins from disk.  This is useful for
development where the dev is editing an existing plugin and wants to
run the edited plugin without restarting aprsd itself.  The /plugins
url requires admin login credentials.

TODO: would be nice to live reload the aprsd.yml config file, so plugin
reloading can start new plugins defined in aprsd.yml between /plugins
being reloaded.
2021-02-18 16:31:52 -05:00
hemna 9f66774541 Updated Dockerfile and build.sh
This patch updates the main Dockerfile to work in multi-architecture
build.  Dockerfile now builds and install aprsd from pypi, not github.
2021-02-18 16:15:49 -05:00
hemna c177748340 Updated Dockerfile for multiplatform builds
This patch updates the main Dockerfile container build to use
the python:3.8-slim base image and installs aprsd from pypi.
This also results in a much smaller image.

Also added support for multiarchitecture builds so the same Dockerfile
builds for raspberry pi and linux/amd64
2021-02-16 10:20:34 -05:00
hemna f0034fc517 Updated Dockerfile for multiplatform builds
This patch updates the main Dockerfile container build to use
the python:3.8-slim base image and installs aprsd from pypi.
This also results in a much smaller image.

Also added support for multiarchitecture builds so the same Dockerfile
builds for raspberry pi and linux/amd64
2021-02-16 10:05:11 -05:00
hemna 2d5bb85071 Dockerfile: Make creation of /config quiet failure
This patch adds -p to mkdir command in the Dockerfile
to quiet the failure if /config already exists.
2021-02-13 10:41:43 -05:00
hemna b6ba90de53 Updated README docs
This patch updates the 2 readme files with copies for the latest
sample-config output to match the 1.6.0 release's config.

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

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

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

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

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

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

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

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

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

...
aprsd:
  units: metric
2021-01-20 16:12:17 -05:00
Craig Lamparter f8c001dc49 Merge pull request #40 from craigerl/new_plugins
Added new time plugins
2021-01-20 08:26:48 -08:00
hemna fc3a747aa4 Added new time plugins
This patch adds 2 new time plugins to allow admins to use their
opencagedata APIkey or openweathermap API key to fetch the timezone
from the lat/lon GPS coordinates for the callsign requesting the time.

This will enable fetching the time local to the ham radio's last beacon,
and not time local to the aprsd server instance running.  If the
location is not found, then the timezone will default to UTC.

The 2 new plugins are
- aprsd.plugins.time.TimeOpenCageDataPlugin
   Fetches timezone from lat/lon using the opencagedata api that can be
   found here:  https://opencagedata.com/dashboard#api-keys

   This requires a new ~/.config/aprsd/aprsd.yml entry to specify the
   api key.
   opencagedata:
       apiKey: <the api key hash here>

- aprsd.plugins.time.TimeOWMPlugin
   Fetches the timezone from lat/lon using the openweathermap api
   that can be found here:  https://home.openweathermap.org/api_keys

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

This patch also adds the Weather Metar plugin called WxPlugin.
You can use it to fetch METAR from the nearest station for a callsign
or from a known METAR station id.  Call WxPlugin with a message of
'wx' for closest metar station or 'wx KAUN' for metar at KAUN wx station
2021-01-15 22:30:34 -05:00
62 changed files with 5470 additions and 789 deletions
+1 -1
View File
@@ -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 }}
+3
View File
@@ -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
+102
View File
@@ -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
View File
@@ -1,42 +0,0 @@
FROM alpine:latest as aprsd
ENV VERSION=1.0.0
ENV APRS_USER=aprs
ENV HOME=/home/aprs
ENV VIRTUAL_ENV=$HOME/.venv3
ENV INSTALL=$HOME/install
RUN apk add --update git wget py3-pip py3-virtualenv bash fortune
# Setup Timezone
ENV TZ=US/Eastern
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN apt-get install -y tzdata
RUN dpkg-reconfigure --frontend noninteractive tzdata
RUN addgroup --gid 1000 $APRS_USER
RUN adduser -h $HOME -D -u 1001 -G $APRS_USER $APRS_USER
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
USER $APRS_USER
RUN pip3 install wheel
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN echo "export PATH=\$PATH:\$HOME/.local/bin" >> $HOME/.bashrc
VOLUME ["/config", "/plugins"]
WORKDIR $HOME
RUN pip install aprsd
USER root
RUN aprsd sample-config > /config/aprsd.yml
RUN chown -R $APRS_USER:$APRS_USER /config
# override this to run another configuration
ENV CONF default
USER $APRS_USER
ADD build/bin/run.sh $HOME/
ENTRYPOINT ["/home/aprs/run.sh"]
+20 -8
View File
@@ -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! #
+37 -37
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,197 @@
#
# Dev.py is used to help develop plugins
#
#
# python included libs
import logging
from logging import NullHandler
from logging.handlers import RotatingFileHandler
import os
import sys
# local imports here
import aprsd
from aprsd import client, email, plugin, utils
import click
import click_completion
# setup the global logger
# logging.basicConfig(level=logging.DEBUG) # level=10
LOG = logging.getLogger("APRSD")
LOG_LEVELS = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
}
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def custom_startswith(string, incomplete):
"""A custom completion match that supports case insensitive matching."""
if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"):
string = string.lower()
incomplete = incomplete.lower()
return string.startswith(incomplete)
click_completion.core.startswith = custom_startswith
click_completion.init()
cmd_help = """Shell completion for click-completion-command
Available shell types:
\b
%s
Default type: auto
""" % "\n ".join(
"{:<12} {}".format(k, click_completion.core.shells[k])
for k in sorted(click_completion.core.shells.keys())
)
@click.group(help=cmd_help, context_settings=CONTEXT_SETTINGS)
@click.version_option()
def main():
pass
@main.command()
@click.option(
"-i",
"--case-insensitive/--no-case-insensitive",
help="Case insensitive completion",
)
@click.argument(
"shell",
required=False,
type=click_completion.DocumentedChoice(click_completion.core.shells),
)
def show(shell, case_insensitive):
"""Show the click-completion-command completion code"""
extra_env = (
{"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"}
if case_insensitive
else {}
)
click.echo(click_completion.core.get_code(shell, extra_env=extra_env))
@main.command()
@click.option(
"--append/--overwrite",
help="Append the completion code to the file",
default=None,
)
@click.option(
"-i",
"--case-insensitive/--no-case-insensitive",
help="Case insensitive completion",
)
@click.argument(
"shell",
required=False,
type=click_completion.DocumentedChoice(click_completion.core.shells),
)
@click.argument("path", required=False)
def install(append, case_insensitive, shell, path):
"""Install the click-completion-command completion"""
extra_env = (
{"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"}
if case_insensitive
else {}
)
shell, path = click_completion.core.install(
shell=shell,
path=path,
append=append,
extra_env=extra_env,
)
click.echo("{} completion installed in {}".format(shell, path))
# Setup the logging faciility
# to disable logging to stdout, but still log to file
# use the --quiet option on the cmdln
def setup_logging(config, loglevel, quiet):
log_level = LOG_LEVELS[loglevel]
LOG.setLevel(log_level)
log_format = "[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s]" " %(message)s"
date_format = "%m/%d/%Y %I:%M:%S %p"
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
log_file = config["aprs"].get("logfile", None)
if log_file:
fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4)
else:
fh = NullHandler()
fh.setFormatter(log_formatter)
LOG.addHandler(fh)
if not quiet:
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(log_formatter)
LOG.addHandler(sh)
@main.command()
@click.option(
"--loglevel",
default="DEBUG",
show_default=True,
type=click.Choice(
["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"],
case_sensitive=False,
),
show_choices=True,
help="The log level to use for aprsd.log",
)
@click.option(
"-c",
"--config",
"config_file",
show_default=True,
default=utils.DEFAULT_CONFIG_FILE,
help="The aprsd config file to use for options.",
)
@click.option(
"-p",
"--plugin",
"plugin_path",
show_default=True,
default="aprsd.plugins.wx.WxPlugin",
help="The plugin to run",
)
@click.argument("fromcall")
@click.argument("message", nargs=-1, required=True)
def test_plugin(
loglevel,
config_file,
plugin_path,
fromcall,
message,
):
"""APRSD Plugin test app."""
config = utils.parse_config(config_file)
email.CONFIG = config
setup_logging(config, loglevel, False)
LOG.info("Test APRSD PLugin version: {}".format(aprsd.__version__))
if type(message) is tuple:
message = " ".join(message)
LOG.info("P'{}' F'{}' C'{}'".format(plugin_path, fromcall, message))
client.Client(config)
pm = plugin.PluginManager(config)
obj = pm._create_class(plugin_path, plugin.APRSDPluginBase, config=config)
reply = obj.run(fromcall, message, 1)
LOG.info("Result = '{}'".format(reply))
if __name__ == "__main__":
main()
+152 -49
View File
@@ -7,7 +7,7 @@ import re
import smtplib
import time
from aprsd import messaging, threads
from aprsd import messaging, stats, threads, trace
import imapclient
from validate_email import validate_email
@@ -17,58 +17,88 @@ LOG = logging.getLogger("APRSD")
CONFIG = None
@trace.trace
def _imap_connect():
imap_port = CONFIG["imap"].get("port", 143)
use_ssl = CONFIG["imap"].get("use_ssl", False)
host = CONFIG["imap"]["host"]
imap_port = CONFIG["aprsd"]["email"]["imap"].get("port", 143)
use_ssl = CONFIG["aprsd"]["email"]["imap"].get("use_ssl", False)
host = CONFIG["aprsd"]["email"]["imap"]["host"]
msg = "{}{}:{}".format("TLS " if use_ssl else "", host, imap_port)
# LOG.debug("Connect to IMAP host {} with user '{}'".
# format(msg, CONFIG['imap']['login']))
try:
server = imapclient.IMAPClient(
CONFIG["imap"]["host"],
CONFIG["aprsd"]["email"]["imap"]["host"],
port=imap_port,
use_uid=True,
ssl=use_ssl,
timeout=30,
)
except Exception:
LOG.error("Failed to connect IMAP server")
except Exception as e:
LOG.error("Failed to connect IMAP server", e)
return
try:
server.login(CONFIG["imap"]["login"], CONFIG["imap"]["password"])
server.login(
CONFIG["aprsd"]["email"]["imap"]["login"],
CONFIG["aprsd"]["email"]["imap"]["password"],
)
except (imaplib.IMAP4.error, Exception) as e:
msg = getattr(e, "message", repr(e))
LOG.error("Failed to login {}".format(msg))
return
server.select_folder("INBOX")
server.fetch = trace.trace(server.fetch)
server.search = trace.trace(server.search)
server.remove_flags = trace.trace(server.remove_flags)
server.add_flags = trace.trace(server.add_flags)
return server
@trace.trace
def _smtp_connect():
host = CONFIG["smtp"]["host"]
smtp_port = CONFIG["smtp"]["port"]
use_ssl = CONFIG["smtp"].get("use_ssl", False)
host = CONFIG["aprsd"]["email"]["smtp"]["host"]
smtp_port = CONFIG["aprsd"]["email"]["smtp"]["port"]
use_ssl = CONFIG["aprsd"]["email"]["smtp"].get("use_ssl", False)
msg = "{}{}:{}".format("SSL " if use_ssl else "", host, smtp_port)
LOG.debug(
"Connect to SMTP host {} with user '{}'".format(msg, CONFIG["imap"]["login"]),
"Connect to SMTP host {} with user '{}'".format(
msg,
CONFIG["aprsd"]["email"]["imap"]["login"],
),
)
try:
if use_ssl:
server = smtplib.SMTP_SSL(host=host, port=smtp_port)
server = smtplib.SMTP_SSL(
host=host,
port=smtp_port,
timeout=30,
)
else:
server = smtplib.SMTP(host=host, port=smtp_port)
server = smtplib.SMTP(
host=host,
port=smtp_port,
timeout=30,
)
except Exception:
LOG.error("Couldn't connect to SMTP Server")
return
LOG.debug("Connected to smtp host {}".format(msg))
debug = CONFIG["aprsd"]["email"]["smtp"].get("debug", False)
if debug:
server.set_debuglevel(5)
server.sendmail = trace.trace(server.sendmail)
try:
server.login(CONFIG["smtp"]["login"], CONFIG["smtp"]["password"])
server.login(
CONFIG["aprsd"]["email"]["smtp"]["login"],
CONFIG["aprsd"]["email"]["smtp"]["password"],
)
except Exception:
LOG.error("Couldn't connect to SMTP Server")
return
@@ -78,7 +108,7 @@ def _smtp_connect():
def validate_shortcuts(config):
shortcuts = config.get("shortcuts", None)
shortcuts = config["aprsd"]["email"].get("shortcuts", None)
if not shortcuts:
return
@@ -93,8 +123,8 @@ def validate_shortcuts(config):
email_address=shortcuts[key],
check_regex=True,
check_mx=False,
from_address=config["smtp"]["login"],
helo_host=config["smtp"]["host"],
from_address=config["aprsd"]["email"]["smtp"]["login"],
helo_host=config["aprsd"]["email"]["smtp"]["host"],
smtp_timeout=10,
dns_timeout=10,
use_blacklist=True,
@@ -109,14 +139,14 @@ def validate_shortcuts(config):
delete_keys.append(key)
for key in delete_keys:
del config["shortcuts"][key]
del config["aprsd"]["email"]["shortcuts"][key]
LOG.info("Available shortcuts: {}".format(config["shortcuts"]))
LOG.info("Available shortcuts: {}".format(config["aprsd"]["email"]["shortcuts"]))
def get_email_from_shortcut(addr):
if CONFIG.get("shortcuts", False):
return CONFIG["shortcuts"].get(addr, addr)
if CONFIG["aprsd"]["email"].get("shortcuts", False):
return CONFIG["aprsd"]["email"]["shortcuts"].get(addr, addr)
else:
return addr
@@ -143,6 +173,7 @@ def validate_email_config(config, disable_validation=False):
return False
@trace.trace
def parse_email(msgid, data, server):
envelope = data[b"ENVELOPE"]
# email address match
@@ -153,7 +184,12 @@ def parse_email(msgid, data, server):
else:
from_addr = "noaddr"
LOG.debug("Got a message from '{}'".format(from_addr))
m = server.fetch([msgid], ["RFC822"])
try:
m = server.fetch([msgid], ["RFC822"])
except Exception as e:
LOG.exception("Couldn't fetch email from server in parse_email", e)
return
msg = email.message_from_string(m[msgid][b"RFC822"].decode(errors="ignore"))
if msg.is_multipart():
text = ""
@@ -229,10 +265,11 @@ def parse_email(msgid, data, server):
# end parse_email
@trace.trace
def send_email(to_addr, content):
global check_email_delay
shortcuts = CONFIG["shortcuts"]
shortcuts = CONFIG["aprsd"]["email"]["shortcuts"]
email_address = get_email_from_shortcut(to_addr)
LOG.info("Sending Email_________________")
@@ -250,12 +287,17 @@ def send_email(to_addr, content):
msg = MIMEText(content)
msg["Subject"] = subject
msg["From"] = CONFIG["smtp"]["login"]
msg["From"] = CONFIG["aprsd"]["email"]["smtp"]["login"]
msg["To"] = to_addr
server = _smtp_connect()
if server:
try:
server.sendmail(CONFIG["smtp"]["login"], [to_addr], msg.as_string())
server.sendmail(
CONFIG["aprsd"]["email"]["smtp"]["login"],
[to_addr],
msg.as_string(),
)
stats.APRSDStats().email_tx_inc()
except Exception as e:
msg = getattr(e, "message", repr(e))
LOG.error("Sendmail Error!!!! '{}'", msg)
@@ -268,6 +310,7 @@ def send_email(to_addr, content):
# end send_email
@trace.trace
def resend_email(count, fromcall):
global check_email_delay
date = datetime.datetime.now()
@@ -276,7 +319,7 @@ def resend_email(count, fromcall):
year = date.year
today = "{}-{}-{}".format(day, month, year)
shortcuts = CONFIG["shortcuts"]
shortcuts = CONFIG["aprsd"]["email"]["shortcuts"]
# swap key/value
shortcuts_inverted = {v: k for k, v in shortcuts.items()}
@@ -286,7 +329,12 @@ def resend_email(count, fromcall):
LOG.exception("Failed to Connect to IMAP. Cannot resend email ", e)
return
messages = server.search(["SINCE", today])
try:
messages = server.search(["SINCE", today])
except Exception as e:
LOG.exception("Couldn't search for emails in resend_email ", e)
return
# LOG.debug("%d messages received today" % len(messages))
msgexists = False
@@ -294,18 +342,32 @@ def resend_email(count, fromcall):
messages.sort(reverse=True)
del messages[int(count) :] # only the latest "count" messages
for message in messages:
for msgid, data in list(server.fetch(message, ["ENVELOPE"]).items()):
try:
parts = server.fetch(message, ["ENVELOPE"]).items()
except Exception as e:
LOG.exception("Couldn't fetch email parts in resend_email", e)
continue
for msgid, data in list(parts):
# one at a time, otherwise order is random
(body, from_addr) = parse_email(msgid, data, server)
# unset seen flag, will stay bold in email client
server.remove_flags(msgid, [imapclient.SEEN])
try:
server.remove_flags(msgid, [imapclient.SEEN])
except Exception as e:
LOG.exception("Failed to remove SEEN flag in resend_email", e)
if from_addr in shortcuts_inverted:
# reverse lookup of a shortcut
from_addr = shortcuts_inverted[from_addr]
# asterisk indicates a resend
reply = "-" + from_addr + " * " + body.decode(errors="ignore")
# messaging.send_message(fromcall, reply)
msg = messaging.TextMessage(CONFIG["aprs"]["login"], fromcall, reply)
msg = messaging.TextMessage(
CONFIG["aprs"]["login"],
fromcall,
reply,
)
msg.send()
msgexists = True
@@ -340,6 +402,7 @@ class APRSDEmailThread(threads.APRSDThread):
self.msg_queues = msg_queues
self.config = config
@trace.trace
def run(self):
global check_email_delay
@@ -349,6 +412,7 @@ class APRSDEmailThread(threads.APRSDThread):
past = datetime.datetime.now()
while not self.thread_stop:
time.sleep(5)
stats.APRSDStats().email_thread_update()
# always sleep for 5 seconds and see if we need to check email
# This allows CTRL-C to stop the execution of this loop sooner
# than check_email_delay time
@@ -362,7 +426,7 @@ class APRSDEmailThread(threads.APRSDThread):
check_email_delay += 1
LOG.debug("check_email_delay is " + str(check_email_delay) + " seconds")
shortcuts = CONFIG["shortcuts"]
shortcuts = CONFIG["aprsd"]["email"]["shortcuts"]
# swap key/value
shortcuts_inverted = {v: k for k, v in shortcuts.items()}
@@ -376,17 +440,30 @@ class APRSDEmailThread(threads.APRSDThread):
try:
server = _imap_connect()
except Exception as e:
LOG.exception("Failed to get IMAP server Can't check email.", e)
LOG.exception("IMAP failed to connect.", e)
if not server:
continue
messages = server.search(["SINCE", today])
try:
messages = server.search(["SINCE", today])
except Exception as e:
LOG.exception("IMAP failed to search for messages since today.", e)
continue
LOG.debug("{} messages received today".format(len(messages)))
for msgid, data in server.fetch(messages, ["ENVELOPE"]).items():
try:
_msgs = server.fetch(messages, ["ENVELOPE"])
except Exception as e:
LOG.exception("IMAP failed to fetch/flag messages: ", e)
continue
for msgid, data in _msgs.items():
envelope = data[b"ENVELOPE"]
# LOG.debug('ID:%d "%s" (%s)' % (msgid, envelope.subject.decode(), envelope.date))
LOG.debug(
'ID:%d "%s" (%s)'
% (msgid, envelope.subject.decode(), envelope.date),
)
f = re.search(
r"'([[A-a][0-9]_-]+@[[A-a][0-9]_-\.]+)",
str(envelope.from_[0]),
@@ -399,16 +476,31 @@ class APRSDEmailThread(threads.APRSDThread):
# LOG.debug("Message flags/tags: " + str(server.get_flags(msgid)[msgid]))
# if "APRS" not in server.get_flags(msgid)[msgid]:
# in python3, imap tags are unicode. in py2 they're strings. so .decode them to handle both
taglist = [
x.decode(errors="ignore")
for x in server.get_flags(msgid)[msgid]
]
try:
taglist = [
x.decode(errors="ignore")
for x in server.get_flags(msgid)[msgid]
]
except Exception as e:
LOG.exception("Failed to get flags.", e)
break
if "APRS" not in taglist:
# if msg not flagged as sent via aprs
server.fetch([msgid], ["RFC822"])
try:
server.fetch([msgid], ["RFC822"])
except Exception as e:
LOG.exception("Failed single server fetch for RFC822", e)
break
(body, from_addr) = parse_email(msgid, data, server)
# unset seen flag, will stay bold in email client
server.remove_flags(msgid, [imapclient.SEEN])
try:
server.remove_flags(msgid, [imapclient.SEEN])
except Exception as e:
LOG.exception("Failed to remove flags SEEN", e)
# Not much we can do here, so lets try and
# send the aprs message anyway
if from_addr in shortcuts_inverted:
# reverse lookup of a shortcut
@@ -422,14 +514,28 @@ class APRSDEmailThread(threads.APRSDThread):
)
self.msg_queues["tx"].put(msg)
# flag message as sent via aprs
server.add_flags(msgid, ["APRS"])
# unset seen flag, will stay bold in email client
server.remove_flags(msgid, [imapclient.SEEN])
try:
server.add_flags(msgid, ["APRS"])
# unset seen flag, will stay bold in email client
except Exception as e:
LOG.exception("Couldn't add APRS flag to email", e)
try:
server.remove_flags(msgid, [imapclient.SEEN])
except Exception as e:
LOG.exception("Couldn't remove seen flag from email", e)
# check email more often since we just received an email
check_email_delay = 60
# reset clock
LOG.debug("Done looping over Server.fetch, logging out.")
past = datetime.datetime.now()
server.logout()
try:
server.logout()
except Exception as e:
LOG.exception("IMAP failed to logout: ", e)
continue
else:
# We haven't hit the email delay yet.
# LOG.debug("Delta({}) < {}".format(now - past, check_email_delay))
@@ -438,6 +544,3 @@ class APRSDEmailThread(threads.APRSDThread):
# Remove ourselves from the global threads list
threads.APRSDThreadList().remove(self)
LOG.info("Exiting")
# end check_email()
+186
View File
@@ -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
+231
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+139
View File
@@ -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
View File
@@ -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()
+78
View File
@@ -0,0 +1,78 @@
# Utilities for plugins to use
import json
import logging
import requests
LOG = logging.getLogger("APRSD")
def get_aprs_fi(api_key, callsign):
LOG.debug("Fetch aprs.fi location for '{}'".format(callsign))
try:
url = (
"http://api.aprs.fi/api/get?"
"&what=loc&apikey={}&format=json"
"&name={}".format(api_key, callsign)
)
response = requests.get(url)
except Exception:
raise Exception("Failed to get aprs.fi location")
else:
response.raise_for_status()
return json.loads(response.text)
def get_weather_gov_for_gps(lat, lon):
LOG.debug("Fetch station at {}, {}".format(lat, lon))
try:
url2 = (
"https://forecast.weather.gov/MapClick.php?lat=%s"
"&lon=%s&FcstType=json" % (lat, lon)
)
LOG.debug("Fetching weather '{}'".format(url2))
response = requests.get(url2)
except Exception as e:
LOG.error(e)
raise Exception("Failed to get weather")
else:
response.raise_for_status()
return json.loads(response.text)
def get_weather_gov_metar(station):
LOG.debug("Fetch metar for station '{}'".format(station))
try:
url = "https://api.weather.gov/stations/{}/observations/latest".format(
station,
)
response = requests.get(url)
except Exception:
raise Exception("Failed to fetch metar")
else:
response.raise_for_status()
return json.loads(response)
def fetch_openweathermap(api_key, lat, lon, units="metric", exclude=None):
LOG.debug("Fetch openweathermap for {}, {}".format(lat, lon))
if not exclude:
exclude = "minutely,hourly,daily,alerts"
try:
url = (
"https://api.openweathermap.org/data/2.5/onecall?"
"lat={}&lon={}&appid={}&units={}&exclude={}".format(
lat,
lon,
api_key,
units,
exclude,
)
)
response = requests.get(url)
except Exception as e:
LOG.error(e)
raise Exception("Failed to get weather")
else:
response.raise_for_status()
return json.loads(response.text)
+13 -3
View File
@@ -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
+15 -3
View File
@@ -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
View File
@@ -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
+52
View File
@@ -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
+7 -3
View File
@@ -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
View File
@@ -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)
+49
View File
@@ -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
View File
@@ -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)
+13 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,10 +1,12 @@
import abc
import datetime
import logging
import queue
import threading
import time
import tracemalloc
from aprsd import client, messaging, plugin
from aprsd import client, messaging, 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
View File
@@ -0,0 +1,181 @@
import abc
import functools
import inspect
import logging
import time
import types
VALID_TRACE_FLAGS = {"method", "api"}
TRACE_API = False
TRACE_METHOD = False
TRACE_ENABLED = False
LOG = logging.getLogger("APRSD")
def trace(*dec_args, **dec_kwargs):
"""Trace calls to the decorated function.
This decorator should always be defined as the outermost decorator so it
is defined last. This is important so it does not interfere
with other decorators.
Using this decorator on a function will cause its execution to be logged at
`DEBUG` level with arguments, return values, and exceptions.
:returns: a function decorator
"""
def _decorator(f):
func_name = f.__name__
@functools.wraps(f)
def trace_logging_wrapper(*args, **kwargs):
filter_function = dec_kwargs.get("filter_function")
logger = LOG
# NOTE(ameade): Don't bother going any further if DEBUG log level
# is not enabled for the logger.
if not logger.isEnabledFor(logging.DEBUG) or not TRACE_ENABLED:
return f(*args, **kwargs)
all_args = inspect.getcallargs(f, *args, **kwargs)
pass_filter = filter_function is None or filter_function(all_args)
if pass_filter:
logger.debug(
"==> %(func)s: call %(all_args)r",
{
"func": func_name,
"all_args": str(all_args),
},
)
start_time = time.time() * 1000
try:
result = f(*args, **kwargs)
except Exception as exc:
total_time = int(round(time.time() * 1000)) - start_time
logger.debug(
"<== %(func)s: exception (%(time)dms) %(exc)r",
{
"func": func_name,
"time": total_time,
"exc": exc,
},
)
raise
total_time = int(round(time.time() * 1000)) - start_time
if isinstance(result, dict):
mask_result = result
elif isinstance(result, str):
mask_result = result
else:
mask_result = result
if pass_filter:
logger.debug(
"<== %(func)s: return (%(time)dms) %(result)r",
{
"func": func_name,
"time": total_time,
"result": mask_result,
},
)
return result
return trace_logging_wrapper
if len(dec_args) == 0:
# filter_function is passed and args does not contain f
return _decorator
else:
# filter_function is not passed
return _decorator(dec_args[0])
def trace_api(*dec_args, **dec_kwargs):
"""Decorates a function if TRACE_API is true."""
def _decorator(f):
@functools.wraps(f)
def trace_api_logging_wrapper(*args, **kwargs):
if TRACE_API:
return trace(f, *dec_args, **dec_kwargs)(*args, **kwargs)
return f(*args, **kwargs)
return trace_api_logging_wrapper
if len(dec_args) == 0:
# filter_function is passed and args does not contain f
return _decorator
else:
# filter_function is not passed
return _decorator(dec_args[0])
def trace_method(f):
"""Decorates a function if TRACE_METHOD is true."""
@functools.wraps(f)
def trace_method_logging_wrapper(*args, **kwargs):
if TRACE_METHOD:
return trace(f)(*args, **kwargs)
return f(*args, **kwargs)
return trace_method_logging_wrapper
class TraceWrapperMetaclass(type):
"""Metaclass that wraps all methods of a class with trace_method.
This metaclass will cause every function inside of the class to be
decorated with the trace_method decorator.
To use the metaclass you define a class like so:
class MyClass(object, metaclass=utils.TraceWrapperMetaclass):
"""
def __new__(cls, classname, bases, class_dict):
new_class_dict = {}
for attribute_name, attribute in class_dict.items():
if isinstance(attribute, types.FunctionType):
# replace it with a wrapped version
attribute = functools.update_wrapper(
trace_method(attribute),
attribute,
)
new_class_dict[attribute_name] = attribute
return type.__new__(cls, classname, bases, new_class_dict)
class TraceWrapperWithABCMetaclass(abc.ABCMeta, TraceWrapperMetaclass):
"""Metaclass that wraps all methods of a class with trace."""
pass
def setup_tracing(trace_flags):
"""Set global variables for each trace flag.
Sets variables TRACE_METHOD and TRACE_API, which represent
whether to log methods or api traces.
:param trace_flags: a list of strings
"""
global TRACE_METHOD
global TRACE_API
global TRACE_ENABLED
try:
trace_flags = [flag.strip() for flag in trace_flags]
except TypeError: # Handle when trace_flags is None or a test mock
trace_flags = []
for invalid_flag in set(trace_flags) - VALID_TRACE_FLAGS:
LOG.warning("Invalid trace flag: %s", invalid_flag)
TRACE_METHOD = "method" in trace_flags
TRACE_API = "api" in trace_flags
TRACE_ENABLED = True
+322 -71
View File
@@ -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)
+76
View File
@@ -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;
}
+35
View File
@@ -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;
}
+281
View File
@@ -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 + "&nbsp;&nbsp;&nbsp;&nbsp;" + 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 + "&nbsp;&nbsp;-&nbsp;&nbsp;" + 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);
}
});
})();
}
+28
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;');
if (options.withLinks && isUrl(json)) {
html += '<a href="' + json + '" class="json-string" target="_blank">' + json + '</a>';
} else {
// Escape double quotes in the rendered non-URL string.
json = json.replace(/&quot;/g, '\\&quot;');
html += '<span class="json-string">"' + json + '"</span>';
}
} else if (typeof json === 'number') {
html += '<span class="json-literal">' + json + '</span>';
} else if (typeof json === 'boolean') {
html += '<span class="json-literal">' + json + '</span>';
} else if (json === null) {
html += '<span class="json-literal">null</span>';
} else if (json instanceof Array) {
if (json.length > 0) {
html += '[<ol class="json-array">';
for (var i = 0; i < json.length; ++i) {
html += '<li>';
// Add toggle button if item is collapsable
if (isCollapsable(json[i])) {
html += '<a href class="json-toggle"></a>';
}
html += json2html(json[i], options);
// Add comma if item is not last
if (i < json.length - 1) {
html += ',';
}
html += '</li>';
}
html += '</ol>]';
} else {
html += '[]';
}
} else if (typeof json === 'object') {
var keyCount = Object.keys(json).length;
if (keyCount > 0) {
html += '{<ul class="json-dict">';
for (var key in json) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
html += '<li>';
var keyRepr = options.withQuotes ?
'<span class="json-string">"' + key + '"</span>' : key;
// Add toggle button if item is collapsable
if (isCollapsable(json[key])) {
html += '<a href class="json-toggle">' + keyRepr + '</a>';
} else {
html += keyRepr;
}
html += ': ' + json2html(json[key], options);
// Add comma if item is not last
if (--keyCount > 0) {
html += ',';
}
html += '</li>';
}
}
html += '</ul>}';
} else {
html += '{}';
}
}
return html;
}
/**
* jQuery plugin method
* @param json: a javascript object
* @param options: an optional options hash
*/
$.fn.jsonViewer = function(json, options) {
// Merge user options with default options
options = Object.assign({}, {
collapsed: false,
rootCollapsable: true,
withQuotes: false,
withLinks: true
}, options);
// jQuery chaining
return this.each(function() {
// Transform to HTML
var html = json2html(json, options);
if (options.rootCollapsable && isCollapsable(json)) {
html = '<a href class="json-toggle"></a>' + html;
}
// Insert HTML in target DOM element
$(this).html(html);
$(this).addClass('json-document');
// Bind click on toggle buttons
$(this).off('click');
$(this).on('click', 'a.json-toggle', function() {
var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array');
target.toggle();
if (target.is(':visible')) {
target.siblings('.json-placeholder').remove();
} else {
var count = target.children('li').length;
var placeholder = count + (count > 1 ? ' items' : ' item');
target.after('<a href class="json-placeholder">' + placeholder + '</a>');
}
return false;
});
// Simulate click on toggle button when placeholder is clicked
$(this).on('click', 'a.json-placeholder', function() {
$(this).siblings('a.json-toggle').click();
return false;
});
if (options.collapsed == true) {
// Trigger click to collapse all nodes
$(this).find('a.json-toggle').click();
}
});
};
})(jQuery);
+117
View File
@@ -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>)
&nbsp;&nbsp;&nbsp;
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>
+15
View File
@@ -0,0 +1,15 @@
<html>
<head>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="json-viewer/jquery.json-viewer.js"></script>
<link href="json-viewer/jquery.json-viewer.css" type="text/css" rel="stylesheet" />
</head>
<pre id="json-viewer"></pre>
<script>
var data = {{ messages | safe }}
$('#json-viewer').jsonViewer(data)
</script>
</html>
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
# Use this script to locally build the docker image
docker build --no-cache -t hemna6969/aprsd:latest ..
+2
View File
@@ -8,3 +8,5 @@ pep8-naming
Sphinx
tox
twine
pre-commit
pip-tools
+71 -36
View File
@@ -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
+45
View File
@@ -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"]
+17 -15
View File
@@ -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"]
+2 -5
View File
@@ -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
+64
View File
@@ -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
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+16
View File
@@ -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
-------------------------
+64
View File
@@ -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
------------------
+1
View File
@@ -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>
+332
View File
@@ -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
+1
View File
@@ -13,6 +13,7 @@
:caption: Contents:
readme
changelog
install
configure
server
+132 -64
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -5,21 +5,21 @@ from aprsd import email
class TestEmail(unittest.TestCase):
def test_get_email_from_shortcut(self):
email.CONFIG = {"shortcuts": {}}
email.CONFIG = {"aprsd": {"email": {"shortcuts": {}}}}
email_address = "something@something.com"
addr = "-{}".format(email_address)
actual = email.get_email_from_shortcut(addr)
self.assertEqual(addr, actual)
email.CONFIG = {"nothing": "nothing"}
email.CONFIG = {"aprsd": {"email": {"nothing": "nothing"}}}
actual = email.get_email_from_shortcut(addr)
self.assertEqual(addr, actual)
email.CONFIG = {"shortcuts": {"not_used": "empty"}}
email.CONFIG = {"aprsd": {"email": {"shortcuts": {"not_used": "empty"}}}}
actual = email.get_email_from_shortcut(addr)
self.assertEqual(addr, actual)
email.CONFIG = {"shortcuts": {"-wb": email_address}}
email.CONFIG = {"aprsd": {"email": {"shortcuts": {"-wb": email_address}}}}
short = "-wb"
actual = email.get_email_from_shortcut(short)
self.assertEqual(email_address, actual)
+94 -46
View File
@@ -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)