Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2089b2575e | |||
| 9571b0bb38 | |||
| 87cbcaa47f | |||
| 19e5cfa9cc | |||
| 24b16a29e8 | |||
| 321c5a2c25 | |||
| 9d19502dd8 | |||
| a6015adecc | |||
| 4fe99c35b5 | |||
| c1db238719 | |||
| 40f23dcb48 | |||
| 5891c71483 | |||
| 68472b0d84 | |||
| 935f820271 | |||
| 576301ca20 | |||
| 6d34d9c514 | |||
| deeee71f8f | |||
| f2b1ad35f9 | |||
| 1e65af2dea | |||
| 83370689b9 | |||
| e4f93a2ab4 | |||
| acecba27e8 | |||
| 51b80cd4ea | |||
| 480094b0d4 | |||
| 726c8f4f2f | |||
| ee96108324 | |||
| 5067f745ca | |||
| 98fe9daac5 | |||
| f9e7195e25 | |||
| 44696fbc56 | |||
| 78329f79f4 | |||
| 5add0f958d | |||
| d40927d1c3 | |||
| d5e56b553e | |||
| 1a1d00242b | |||
| 19f804bf68 | |||
| 4111d16aaf | |||
| d1a0a988f2 | |||
| d9b39734e6 | |||
| d4bf0f1e3c | |||
| 117f81f55f | |||
| b41e4a9ef3 | |||
| e66dc344b8 | |||
| 5acddbd466 | |||
| 17e784629e | |||
| 528bdb99e7 | |||
| fc1ca52593 | |||
| 075078b520 | |||
| 7d970cbe70 | |||
| d717a22717 | |||
| 9b0c626b59 | |||
| 967959e7b3 | |||
| e5f60b5ce1 | |||
| 2ce50d8861 | |||
| ad79ed1261 | |||
| 5f28788180 | |||
| 585d55f10d | |||
| 1ccb2f7695 | |||
| a62843920a | |||
| 29b84b453b | |||
| 347a6d69f7 | |||
| bed060f1c5 | |||
| ab6583666f | |||
| 3580425ca3 | |||
| 358aa59042 | |||
| 9671dacb1c | |||
| f9d3bc433f | |||
| 1383352e75 | |||
| b50f343440 | |||
| 4c7c90b947 | |||
| bb09296efa | |||
| 7db2242060 | |||
| 61655a0a85 | |||
| fdc8bfafc0 | |||
| 0e5f7aa211 | |||
| c16886263f | |||
| eb4b67d9b8 | |||
| 389304c3f2 | |||
| 9ffd320353 | |||
| 74e4e2c4f5 | |||
| b1db08a08c | |||
| cc2918377e | |||
| f339ee3ebf | |||
| 9d39b030fb | |||
| 1c052a63c0 | |||
| e739441268 | |||
| 03a20ebb5c | |||
| 6257c9ea90 | |||
| b00c8db3d6 | |||
| 79270f95be | |||
| 29a60b7ed0 | |||
| e8100d8777 | |||
| 764730c123 | |||
| 610e40aecd | |||
| 2f6e7e17e8 | |||
| a7bbde4a43 | |||
| 7530bcf55c | |||
| ab37a5e7a7 | |||
| 3b9970c0e7 | |||
| e57a2e2ffc | |||
| 6a1cea63e4 | |||
| 592b328956 | |||
| 450bacfe99 | |||
| cd62db95c1 | |||
| 28b54c330d | |||
| 7c653cc100 | |||
| b7791eb4fa | |||
| 440c8d54ad | |||
| bcc1b4e309 | |||
| 8ea00e9888 | |||
| 5d6ac5cf31 | |||
| e0e75149a9 | |||
| a5184fb98c | |||
| 0ad791bdd9 | |||
| 96cc07d15f | |||
| d3dd08714b | |||
| 055835cb3c | |||
| ff8bf02e26 | |||
| b5b286e75c | |||
| 1233137caf | |||
| 1d5f76defc | |||
| 950c62f49b | |||
| 7aaa002a0e | |||
| e27887db1a | |||
| 5e50792e80 | |||
| deec249c45 | |||
| ade3c49e93 | |||
| 6fb610582d | |||
| bda2ef00dd | |||
| 446484e631 | |||
| a8a6b1aa07 | |||
| 8842fb1b44 | |||
| 152132b0ed | |||
| 7787dc1be4 | |||
| 10e34d8634 | |||
| 9469410929 | |||
| 998bc32c27 | |||
| 88db485eb4 | |||
| 5d17809895 | |||
| 059cc86a11 | |||
| ffdd1e47b2 | |||
| cdcb98e438 | |||
| 89727e2b8e | |||
| 617973f561 | |||
| 9187b9781a | |||
| 8287c09ce5 | |||
| 82def598f0 | |||
| 3463c6eb96 | |||
| 2ead6a97da | |||
| 7d0006b0a6 | |||
| 30df452e00 | |||
| 49f3ea8339 | |||
| 0d5b7166b3 | |||
| cefb581bb8 | |||
| d2e8fe660f |
@@ -0,0 +1,62 @@
|
||||
name: Test and Build Latest Container Image
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 10 * * *"
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "master"
|
||||
|
||||
jobs:
|
||||
tox:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox tox-gh-actions
|
||||
- name: Test with tox
|
||||
run: tox
|
||||
|
||||
build:
|
||||
needs: tox
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Get Branch Name
|
||||
id: branch-name
|
||||
uses: tj-actions/branch-names@v6
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Login to Docker HUB
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build the Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: "{{defaultContext}}:docker"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: ./Dockerfile-dev
|
||||
build-args: |
|
||||
BRANCH=${{ steps.branch-name.outputs.current_branch }}
|
||||
BUILDX_QEMU_ENV=true
|
||||
push: true
|
||||
tags: |
|
||||
hemna6969/aprsd:${{ steps.branch-name.outputs.current_branch }}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: python
|
||||
name: TOX Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Build specific version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
aprsd_version:
|
||||
required: true
|
||||
options:
|
||||
- 2.5.9
|
||||
- 2.6.0
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
required: true
|
||||
default: 'warning'
|
||||
type: choice
|
||||
options:
|
||||
- info
|
||||
- warning
|
||||
- debug
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Get Branch Name
|
||||
id: branch-name
|
||||
uses: tj-actions/branch-names@v6
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Login to Docker HUB
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build the Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: "{{defaultContext}}:docker"
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./Dockerfile
|
||||
build-args: |
|
||||
VERSION=${{ inputs.aprsd_version }}
|
||||
BUILDX_QEMU_ENV=true
|
||||
push: true
|
||||
tags: |
|
||||
hemna6969/aprsd:v${{ inputs.aprsd_version }}
|
||||
hemna6969/aprsd:latest
|
||||
@@ -1,9 +1,200 @@
|
||||
CHANGES
|
||||
=======
|
||||
|
||||
v2.6.1
|
||||
------
|
||||
|
||||
* Fixed position report for webchat beacon
|
||||
* Try and fix broken 32bit qemu builds on 64bit system
|
||||
* Add unit tests for webchat
|
||||
* remove armv7 build RUST sucks
|
||||
* Fix for Collections change in 3.10
|
||||
|
||||
v2.6.0
|
||||
------
|
||||
|
||||
* Update workflow again
|
||||
* Update Dockerfile to 22.04
|
||||
* Update Dockerfile and build.sh
|
||||
* Update workflow
|
||||
* Prep for 2.6.0 release
|
||||
* Update requirements
|
||||
* Removed Makefile comment
|
||||
* Update Makefile for dev vs. run environments
|
||||
* Added pyopenssl for https for webchat
|
||||
* change from device-detector to user-agents
|
||||
* Remove twine from dev-requirements
|
||||
* Update to latest Makefile.venv
|
||||
* Refactored threads a bit
|
||||
* Mark packets as acked in MsgTracker
|
||||
* remove dev setting for template
|
||||
* Add GPS beacon to mobile page
|
||||
* Allow werkzeug for admin interface
|
||||
* Allow werkzeug for admin interface
|
||||
* Add support for mobile browsers for webchat
|
||||
* Ignore callsign case while processing packets
|
||||
* remove linux/arm/v7 for official builds for now
|
||||
* added workflow for building specific version
|
||||
* Allow passing in version to the Dockerfile
|
||||
* Send GPS Beacon from webchat interface
|
||||
* specify Dockerfile-dev
|
||||
* Fixed build.sh
|
||||
* Build on the source not released aprsd
|
||||
* Remove email validation
|
||||
* Add support for building linux/arm/v7
|
||||
* Remove python 3.7 from docker build github
|
||||
* Fixed failing unit tests
|
||||
* change github workflow
|
||||
* Removed TimeOpenCageDataPlugin
|
||||
* Dump config with aprsd dev test-plugin
|
||||
* Updated requirements
|
||||
* Got webchat working with KISS tcp
|
||||
* Added click auto\_envvar\_prefix
|
||||
* Update aprsd thread base class to use queue
|
||||
* Update packets to use wrapt
|
||||
* Add remving existing requirements
|
||||
* Try sending raw APRSFrames to aioax25
|
||||
* Use new aprsd.callsign as the main callsign
|
||||
* Fixed access to threads refactor
|
||||
* Added webchat command
|
||||
* Moved log.py to logging
|
||||
* Moved trace.py to utils
|
||||
* Fixed pep8 errors
|
||||
* Refactored threads.py
|
||||
* Refactor utils to directory
|
||||
* remove arm build for now
|
||||
* Added rustc and cargo to Dockerfile
|
||||
* remove linux/arm/v6 from docker platform build
|
||||
* Only tag master build as master
|
||||
* Remove docker build from test
|
||||
* create master-build.yml
|
||||
* Added container build action
|
||||
* Update docs on using Docker
|
||||
* Update dev-requirements pip-tools
|
||||
* Fix typo in docker-compose.yml
|
||||
* Fix PyPI scraping
|
||||
* Allow web interface when running in Docker
|
||||
* Fix typo on exception
|
||||
* README formatting fixes
|
||||
* Bump dependencies to fix python 3.10
|
||||
* Fixed up config option checking for KISS
|
||||
* Fix logging issue with log messages
|
||||
* for 2.5.9
|
||||
|
||||
v2.5.9
|
||||
------
|
||||
|
||||
* FIX: logging exceptions
|
||||
* Updated build and run for rich lib
|
||||
* update build for 2.5.8
|
||||
|
||||
v2.5.8
|
||||
------
|
||||
|
||||
* For 2.5.8
|
||||
* Removed debug code
|
||||
* Updated list-plugins
|
||||
* Renamed virtualenv dir to .aprsd-venv
|
||||
* Added unit tests for dev test-plugin
|
||||
* Send Message command defaults to config
|
||||
|
||||
v2.5.7
|
||||
------
|
||||
|
||||
* Updated Changelog
|
||||
* Fixed an KISS config disabled issue
|
||||
* Fixed a bug with multiple notify plugins enabled
|
||||
* Unify the logging to file and stdout
|
||||
* Added new feature to list-plugins command
|
||||
* more README.rst cleanup
|
||||
* Updated README examples
|
||||
|
||||
v2.5.6
|
||||
------
|
||||
|
||||
* Changelog
|
||||
* Tightened up the packet logging
|
||||
* Added unit tests for USWeatherPlugin, USMetarPlugin
|
||||
* Added test\_location to test LocationPlugin
|
||||
* Updated pytest output
|
||||
* Added py39 to tox for tests
|
||||
* Added NotifyPlugin unit tests and more
|
||||
* Small cleanup on packet logging
|
||||
* Reduced the APRSIS connection reset to 2 minutes
|
||||
* Fixed the NotifyPlugin
|
||||
* Fixed some pep8 errors
|
||||
* Add tracing for dev command
|
||||
* Added python rich library based logging
|
||||
* Added LOG\_LEVEL env variable for the docker
|
||||
|
||||
v2.5.5
|
||||
------
|
||||
|
||||
* Update requirements to use aprslib 0.7.0
|
||||
* fixed the failure during loading for objectstore
|
||||
* updated docker build
|
||||
|
||||
v2.5.4
|
||||
------
|
||||
|
||||
* Updated Changelog
|
||||
* Fixed dev command missing initialization
|
||||
|
||||
v2.5.3
|
||||
------
|
||||
|
||||
* Fix admin logging tab
|
||||
|
||||
v2.5.2
|
||||
------
|
||||
|
||||
* Added new list-plugins command
|
||||
* Don't require check-version command to have a config
|
||||
* Healthcheck command doesn't need the aprsd.yml config
|
||||
* Fix test failures
|
||||
* Removed requirement for aprs.fi key
|
||||
* Updated Changelog
|
||||
|
||||
v2.5.1
|
||||
------
|
||||
|
||||
* Removed stock plugin
|
||||
* Removed the stock plugin
|
||||
|
||||
v2.5.0
|
||||
------
|
||||
|
||||
* Updated for v2.5.0
|
||||
* Updated Dockerfile's and build script for docker
|
||||
* Cleaned up some verbose output & colorized output
|
||||
* Reworked all the common arguments
|
||||
* Fixed test-plugin
|
||||
* Ensure common params are honored
|
||||
* pep8
|
||||
* Added healthcheck to the cmds
|
||||
* Removed the need for FROMCALL in dev test-plugin
|
||||
* Pep8 failures
|
||||
* Refactor the cli
|
||||
* Updated Changelog for 4.2.3
|
||||
* Fixed a problem with send-message command
|
||||
|
||||
v2.4.2
|
||||
------
|
||||
|
||||
* Updated Changelog
|
||||
* Be more careful picking data to/from disk
|
||||
* Updated Changelog
|
||||
|
||||
v2.4.1
|
||||
------
|
||||
|
||||
* Ensure plugins are last to be loaded
|
||||
* Fixed email connecting to smtp server
|
||||
|
||||
v2.4.0
|
||||
------
|
||||
|
||||
* Updated Changelog for 2.4.0 release
|
||||
* Converted MsgTrack to ObjectStoreMixin
|
||||
* Fixed unit tests
|
||||
* Make sure SeenList update has a from in packet
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
REQUIREMENTS_TXT ?= requirements.txt dev-requirements.txt
|
||||
WORKDIR?=.
|
||||
VENVDIR ?= $(WORKDIR)/.aprsd-venv
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: dev docs server test
|
||||
|
||||
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
|
||||
-o Makefile.fetched \
|
||||
-L "https://github.com/sio/Makefile.venv/raw/v2022.07.20/Makefile.venv"
|
||||
echo "147b164f0cbbbe4a2740dcca6c9adb6e9d8d15b895be3998697aa6a821a277d8 *Makefile.fetched" \
|
||||
| sha256sum --check - \
|
||||
&& mv Makefile.fetched Makefile.venv
|
||||
|
||||
help: # Help for the Makefile
|
||||
@egrep -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
dev: venv ## Create the virtualenv with all the requirements installed
|
||||
dev: REQUIREMENTS_TXT = requirements.txt dev-requirements.txt
|
||||
dev: venv ## Create a python virtual environment for development of aprsd
|
||||
|
||||
run: venv ## Create a virtual environment for running aprsd commands
|
||||
|
||||
docs: build
|
||||
cp README.rst docs/readme.rst
|
||||
cp Changelog docs/changelog.rst
|
||||
tox -edocs
|
||||
|
||||
clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts
|
||||
clean: clean-build clean-pyc clean-test clean-dev ## remove all build, test, coverage and Python artifacts
|
||||
|
||||
clean-build: ## remove build artifacts
|
||||
rm -fr build/
|
||||
@@ -42,10 +48,15 @@ clean-test: ## remove test and coverage artifacts
|
||||
rm -fr htmlcov/
|
||||
rm -fr .pytest_cache
|
||||
|
||||
clean-dev:
|
||||
rm -rf $(VENVDIR)
|
||||
rm Makefile.venv
|
||||
|
||||
test: dev ## Run all the tox tests
|
||||
tox -p all
|
||||
|
||||
build: test ## Make the build artifact prior to doing an upload
|
||||
$(VENV)/pip install twine
|
||||
$(VENV)/python3 setup.py sdist bdist_wheel
|
||||
$(VENV)/twine check dist/*
|
||||
|
||||
@@ -69,5 +80,9 @@ docker-dev: test ## Make a development docker container tagged with hemna6969/a
|
||||
docker build -t hemna6969/aprsd:master -f docker/Dockerfile-dev docker
|
||||
|
||||
update-requirements: dev ## Update the requirements.txt and dev-requirements.txt files
|
||||
$(VENV)/pip-compile requirements.in
|
||||
$(VENV)/pip-compile dev-requirements.in
|
||||
rm requirements.txt
|
||||
rm dev-requirements.txt
|
||||
touch requirements.txt
|
||||
touch dev-requirements.txt
|
||||
$(VENV)/pip-compile --resolver backtracking --annotation-style line requirements.in
|
||||
$(VENV)/pip-compile --resolver backtracking --annotation-style line dev-requirements.in
|
||||
|
||||
@@ -28,7 +28,7 @@ Please `read the docs`_ to learn more!
|
||||
|
||||
|
||||
APRSD Overview Diagram
|
||||
----------------------
|
||||
======================
|
||||
|
||||
.. image:: https://raw.githubusercontent.com/craigerl/aprsd/master/docs/_static/aprsd_overview.svg?sanitize=true
|
||||
|
||||
@@ -46,107 +46,103 @@ 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.
|
||||
|
||||
|
||||
List of core server plugins
|
||||
===========================
|
||||
|
||||
Plugins function by specifying a regex that is searched for in the APRS message.
|
||||
If it matches, the plugin runs. IF the regex doesn't match, the plugin is skipped.
|
||||
|
||||
* EmailPlugin - Check email and reply with contents. Have to configure IMAP and SMTP settings in aprs.yml
|
||||
* FortunePlugin - Replies with old unix fortune random fortune!
|
||||
* LocationPlugin - Checks location of ham operator
|
||||
* PingPlugin - Sends pong with timestamp
|
||||
* QueryPlugin - Allows querying the list of delayed messages that were not ACK'd by radio
|
||||
* TimePlugin - Current time of day
|
||||
* WeatherPlugin - Get weather conditions for current location of HAM callsign
|
||||
* VersionPlugin - Reports the version information for aprsd
|
||||
* 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:
|
||||
Current list of built-in plugins
|
||||
======================================
|
||||
|
||||
::
|
||||
|
||||
APRS messages:
|
||||
l(ocation) [callsign] = descriptive current location of your radio
|
||||
8 Miles E Auburn CA 1673' 39.92150,-120.93950 0.1h ago
|
||||
w(eather) = weather forecast for your radio's current position
|
||||
58F(58F/46F) Partly Cloudy. Tonight, Heavy Rain.
|
||||
t(ime) = respond with the current time
|
||||
f(ortune) = respond with a short fortune
|
||||
-email_addr email text = send an email, say "mapme" to send a current position/map
|
||||
-2 = resend the last 2 emails from your imap inbox to this radio
|
||||
p(ing) = respond with Pong!/time
|
||||
v(ersion) = Respond with current APRSD Version string
|
||||
anything else = respond with usage
|
||||
└─> aprsd list-plugins
|
||||
🐍 APRSD Built-in Plugins 🐍
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ Plugin Name ┃ Info ┃ Type ┃ Plugin Path ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ AVWXWeatherPlugin │ AVWX weather of GPS Beacon location │ RegexCommand │ aprsd.plugins.weather.AVWXWeatherPlugin │
|
||||
│ EmailPlugin │ Send and Receive email │ RegexCommand │ aprsd.plugins.email.EmailPlugin │
|
||||
│ FortunePlugin │ Give me a fortune │ RegexCommand │ aprsd.plugins.fortune.FortunePlugin │
|
||||
│ LocationPlugin │ Where in the world is a CALLSIGN's last GPS beacon? │ RegexCommand │ aprsd.plugins.location.LocationPlugin │
|
||||
│ NotifySeenPlugin │ Notify me when a CALLSIGN is recently seen on APRS-IS │ WatchList │ aprsd.plugins.notify.NotifySeenPlugin │
|
||||
│ OWMWeatherPlugin │ OpenWeatherMap weather of GPS Beacon location │ RegexCommand │ aprsd.plugins.weather.OWMWeatherPlugin │
|
||||
│ PingPlugin │ reply with a Pong! │ RegexCommand │ aprsd.plugins.ping.PingPlugin │
|
||||
│ QueryPlugin │ APRSD Owner command to query messages in the MsgTrack │ RegexCommand │ aprsd.plugins.query.QueryPlugin │
|
||||
│ TimeOWMPlugin │ Current time of GPS beacon's timezone. Uses OpenWeatherMap │ RegexCommand │ aprsd.plugins.time.TimeOWMPlugin │
|
||||
│ TimeOpenCageDataPlugin │ Current time of GPS beacon timezone. Uses OpenCage │ RegexCommand │ aprsd.plugins.time.TimeOpenCageDataPlugin │
|
||||
│ TimePlugin │ What is the current local time. │ RegexCommand │ aprsd.plugins.time.TimePlugin │
|
||||
│ USMetarPlugin │ USA only METAR of GPS Beacon location │ RegexCommand │ aprsd.plugins.weather.USMetarPlugin │
|
||||
│ USWeatherPlugin │ Provide USA only weather of GPS Beacon location │ RegexCommand │ aprsd.plugins.weather.USWeatherPlugin │
|
||||
│ VersionPlugin │ What is the APRSD Version │ RegexCommand │ aprsd.plugins.version.VersionPlugin │
|
||||
└────────────────────────┴────────────────────────────────────────────────────────────┴──────────────┴───────────────────────────────────────────┘
|
||||
|
||||
|
||||
Meanwhile this code will monitor a single imap mailbox and forward email
|
||||
to your BASECALLSIGN over the air. Only radios using the BASECALLSIGN are allowed
|
||||
to send email, so consider this security risk before using this (or Amatuer radio in
|
||||
general). Email is single user at this time.
|
||||
Pypi.org APRSD Installable Plugin Packages
|
||||
|
||||
There are additional parameters in the code (sorry), so be sure to set your
|
||||
email server, and associated logins, passwords. search for "yourdomain",
|
||||
"password". Search for "shortcuts" to setup email aliases as well.
|
||||
Install any of the following plugins with pip install <Plugin Package Name>
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
|
||||
┃ Plugin Package Name ┃ Description ┃ Version ┃ Released ┃ Installed? ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
|
||||
│ 📂 aprsd-stock-plugin │ Ham Radio APRSD Plugin for fetching stock quotes │ 0.1.2 │ Nov 9, 2021 │ No │
|
||||
│ 📂 aprsd-weewx-plugin │ HAM Radio APRSD that reports weather from a weewx weather station. │ 0.1.4 │ Dec 7, 2021 │ No │
|
||||
│ 📂 aprsd-telegram-plugin │ Ham Radio APRS APRSD plugin for Telegram IM service │ 0.1.2 │ Nov 9, 2021 │ No │
|
||||
│ 📂 aprsd-twitter-plugin │ Python APRSD plugin to send tweets │ 0.3.0 │ Dec 7, 2021 │ No │
|
||||
│ 📂 aprsd-slack-plugin │ Amateur radio APRS daemon which listens for messages and responds │ 1.0.4 │ Jan 15, 2021 │ No │
|
||||
└──────────────────────────┴────────────────────────────────────────────────────────────────────┴─────────┴──────────────┴────────────┘
|
||||
|
||||
|
||||
Installation:
|
||||
Installation
|
||||
=============
|
||||
|
||||
pip install aprsd
|
||||
To install ``aprsd``, use Pip:
|
||||
|
||||
Example usage:
|
||||
``pip install aprsd``
|
||||
|
||||
Example usage
|
||||
==============
|
||||
|
||||
aprsd -h
|
||||
``aprsd -h``
|
||||
|
||||
Help
|
||||
====
|
||||
----
|
||||
::
|
||||
|
||||
└─[$] > aprsd -h
|
||||
Usage: aprsd [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Shell completion for click-completion-command Available shell types:
|
||||
bash Bourne again shell fish Friendly interactive shell
|
||||
powershell Windows PowerShell zsh Z shell Default type: auto
|
||||
|
||||
Options:
|
||||
--version Show the version and exit.
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
Commands:
|
||||
install Install the click-completion-command completion
|
||||
check-version Check this version against the latest in pypi.org.
|
||||
completion Click Completion subcommands
|
||||
dev Development type subcommands
|
||||
healthcheck Check the health of the running aprsd server.
|
||||
list-plugins List the built in plugins available to APRSD.
|
||||
listen Listen to packets on the APRS-IS Network based on FILTER.
|
||||
sample-config This dumps the config to stdout.
|
||||
send-message Send a message to a callsign via APRS_IS.
|
||||
server Start the aprsd server process.
|
||||
show Show the click-completion-command completion code
|
||||
server Start the aprsd server gateway process.
|
||||
version Show the APRSD version.
|
||||
|
||||
|
||||
|
||||
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
|
||||
and use to pass in to ``aprsd`` with ``-c``. By default aprsd looks in ``~/.config/aprsd/aprsd.yml``
|
||||
|
||||
aprsd sample-config
|
||||
``aprsd sample-config``
|
||||
|
||||
Output
|
||||
======
|
||||
::
|
||||
|
||||
└─> aprsd sample-config
|
||||
aprs:
|
||||
# Set enabled to False if there is no internet connectivity.
|
||||
# This is useful for a direwolf KISS aprs connection only.
|
||||
|
||||
# Get the passcode for your callsign here:
|
||||
# https://apps.magicbug.co.uk/passcode
|
||||
enabled: true
|
||||
host: rotate.aprs2.net
|
||||
login: CALLSIGN
|
||||
password: '00000'
|
||||
@@ -184,18 +180,38 @@ Output
|
||||
- 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]'
|
||||
logformat: '[%(asctime)s] [%(threadName)-20.20s] [%(levelname)-5.5s] %(message)s
|
||||
- [%(pathname)s:%(lineno)d]'
|
||||
rich_logging: false
|
||||
save_location: /Users/i530566/.config/aprsd/
|
||||
trace: false
|
||||
units: imperial
|
||||
watch_list:
|
||||
alert_callsign: NOCALL
|
||||
alert_time_seconds: 43200
|
||||
callsigns: []
|
||||
enabled: false
|
||||
enabled_plugins:
|
||||
- aprsd.plugins.notify.NotifySeenPlugin
|
||||
packet_keep_count: 10
|
||||
web:
|
||||
enabled: true
|
||||
host: 0.0.0.0
|
||||
logging_enabled: true
|
||||
port: 8001
|
||||
users:
|
||||
admin: aprsd
|
||||
admin: password-here
|
||||
ham:
|
||||
callsign: CALLSIGN
|
||||
callsign: NOCALL
|
||||
kiss:
|
||||
serial:
|
||||
baudrate: 9600
|
||||
device: /dev/ttyS0
|
||||
enabled: false
|
||||
tcp:
|
||||
enabled: false
|
||||
host: direwolf.ip.address
|
||||
port: '8001'
|
||||
services:
|
||||
aprs.fi:
|
||||
# Get the apiKey from your aprs.fi account here:
|
||||
@@ -221,7 +237,7 @@ Output
|
||||
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
|
||||
@@ -229,39 +245,39 @@ look for incomming commands to the callsign configured in the config file
|
||||
::
|
||||
|
||||
└─[$] > aprsd server --help
|
||||
Usage: aprsd server [OPTIONS]
|
||||
Usage: aprsd server [OPTIONS]
|
||||
|
||||
Start the aprsd server process.
|
||||
Start the aprsd server gateway process.
|
||||
|
||||
Options:
|
||||
--loglevel [CRITICAL|ERROR|WARNING|INFO|DEBUG]
|
||||
The log level to use for aprsd.log
|
||||
[default: INFO]
|
||||
Options:
|
||||
--loglevel [CRITICAL|ERROR|WARNING|INFO|DEBUG]
|
||||
The log level to use for aprsd.log
|
||||
[default: INFO]
|
||||
-c, --config TEXT The aprsd config file to use for options.
|
||||
[default:
|
||||
/Users/i530566/.config/aprsd/aprsd.yml]
|
||||
--quiet Don't log to stdout
|
||||
-f, --flush Flush out all old aged messages on disk.
|
||||
[default: False]
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
--quiet Don't log to stdout
|
||||
--disable-validation Disable email shortcut validation. Bad
|
||||
email addresses can result in broken email
|
||||
responses!!
|
||||
|
||||
-c, --config TEXT The aprsd config file to use for options.
|
||||
[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.
|
||||
|
||||
$ aprsd server
|
||||
└─> aprsd server
|
||||
Load config
|
||||
[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
|
||||
12/07/2021 03:16:17 PM MainThread INFO APRSD is up to date server.py:51
|
||||
12/07/2021 03:16:17 PM MainThread INFO APRSD Started version: 2.5.6 server.py:52
|
||||
12/07/2021 03:16:17 PM MainThread INFO Using CONFIG values: server.py:55
|
||||
12/07/2021 03:16:17 PM MainThread INFO ham.callsign = WB4BOR server.py:60
|
||||
12/07/2021 03:16:17 PM MainThread INFO aprs.login = WB4BOR-12 server.py:60
|
||||
12/07/2021 03:16:17 PM MainThread INFO aprs.password = XXXXXXXXXXXXXXXXXXX server.py:58
|
||||
12/07/2021 03:16:17 PM MainThread INFO aprs.host = noam.aprs2.net server.py:60
|
||||
12/07/2021 03:16:17 PM MainThread INFO aprs.port = 14580 server.py:60
|
||||
12/07/2021 03:16:17 PM MainThread INFO aprs.logfile = /tmp/aprsd.log server.py:60
|
||||
|
||||
|
||||
|
||||
|
||||
send-message
|
||||
============
|
||||
^^^^^^^^^^^^
|
||||
|
||||
This command is typically used for development to send another aprsd instance
|
||||
test messages
|
||||
@@ -269,34 +285,32 @@ test messages
|
||||
::
|
||||
|
||||
└─[$] > aprsd send-message -h
|
||||
Usage: aprsd send-message [OPTIONS] TOCALLSIGN [COMMAND]...
|
||||
Usage: aprsd send-message [OPTIONS] TOCALLSIGN COMMAND...
|
||||
|
||||
Send a message to a callsign via APRS_IS.
|
||||
|
||||
Options:
|
||||
--loglevel [CRITICAL|ERROR|WARNING|INFO|DEBUG]
|
||||
The log level to use for aprsd.log
|
||||
[default: DEBUG]
|
||||
|
||||
--quiet Don't log to stdout
|
||||
[default: INFO]
|
||||
-c, --config TEXT The aprsd config file to use for options.
|
||||
[default: ~/.config/aprsd/aprsd.yml]
|
||||
|
||||
[default:
|
||||
/Users/i530566/.config/aprsd/aprsd.yml]
|
||||
--quiet Don't log to stdout
|
||||
--aprs-login TEXT What callsign to send the message from.
|
||||
[env var: APRS_LOGIN]
|
||||
|
||||
--aprs-password TEXT the APRS-IS password for APRS_LOGIN [env
|
||||
var: APRS_PASSWORD]
|
||||
|
||||
-n, --no-ack Don't wait for an ack, just sent it to APRS-
|
||||
IS and bail. [default: False]
|
||||
-w, --wait-response Wait for a response to the message?
|
||||
[default: False]
|
||||
--raw TEXT Send a raw message. Implies --no-ack
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
|
||||
Example output:
|
||||
===============
|
||||
|
||||
|
||||
SEND EMAIL (radio to smtp server)
|
||||
=================================
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
@@ -318,7 +332,7 @@ SEND EMAIL (radio to smtp server)
|
||||
|
||||
|
||||
RECEIVE EMAIL (imap server to radio)
|
||||
====================================
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
@@ -335,7 +349,7 @@ RECEIVE EMAIL (imap server to radio)
|
||||
|
||||
|
||||
LOCATION
|
||||
========
|
||||
^^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
@@ -365,56 +379,66 @@ AND... ping, fortune, time.....
|
||||
Development
|
||||
===========
|
||||
|
||||
* git clone git@github.com:craigerl/aprsd.git
|
||||
* cd aprsd
|
||||
* make
|
||||
* ``git clone git@github.com:craigerl/aprsd.git``
|
||||
* ``cd aprsd``
|
||||
* ``make``
|
||||
|
||||
Workflow
|
||||
========
|
||||
--------
|
||||
|
||||
While working aprsd, The workflow is as follows
|
||||
While working aprsd, The workflow is as follows:
|
||||
|
||||
* Checkout a new branch to work on by running
|
||||
|
||||
``git checkout -b mybranch``
|
||||
|
||||
* Make your changes to the code
|
||||
* Run Tox with the following options:
|
||||
|
||||
- ``tox -epep8``
|
||||
- ``tox -efmt``
|
||||
- ``tox -p``
|
||||
|
||||
* Commit your changes. This will run the pre-commit hooks which does checks too
|
||||
|
||||
``git commit``
|
||||
|
||||
* 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
|
||||
github with:
|
||||
|
||||
``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
|
||||
=======
|
||||
-------
|
||||
|
||||
To do release to pypi:
|
||||
|
||||
* Tag release with
|
||||
* Tag release with:
|
||||
|
||||
git tag -v1.XX -m "New release"
|
||||
``git tag -v1.XX -m "New release"``
|
||||
|
||||
* push release tag up
|
||||
* Push release tag:
|
||||
|
||||
git push origin master --tags
|
||||
``git push origin master --tags``
|
||||
|
||||
* Do a test build and verify build is valid
|
||||
* Do a test build and verify build is valid by running:
|
||||
|
||||
make build
|
||||
``make build``
|
||||
|
||||
* Once twine is happy, upload release to pypi
|
||||
* Once twine is happy, upload release to pypi:
|
||||
|
||||
make upload
|
||||
``make upload``
|
||||
|
||||
|
||||
Docker Container
|
||||
================
|
||||
----------------
|
||||
|
||||
Building
|
||||
========
|
||||
^^^^^^^^
|
||||
|
||||
There are 2 versions of the container Dockerfile that can be used.
|
||||
The main Dockerfile, which is for building the official release container
|
||||
@@ -423,29 +447,38 @@ which is used for building a container based off of a git branch of
|
||||
the repo.
|
||||
|
||||
Official Build
|
||||
==============
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
docker build -t hemna6969/aprsd:latest .
|
||||
``docker build -t hemna6969/aprsd:latest .``
|
||||
|
||||
Development Build
|
||||
=================
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
docker build -t hemna6969/aprsd:latest -f Dockerfile-dev .
|
||||
``docker build -t hemna6969/aprsd:latest -f Dockerfile-dev .``
|
||||
|
||||
|
||||
Running the container
|
||||
=====================
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
There is a docker-compose.yml file that can be used to run your container.
|
||||
There are 2 volumes defined that can be used to store your configuration
|
||||
and the plugins directory: /config and /plugins
|
||||
There is a ``docker-compose.yml`` file in the ``docker/`` directory
|
||||
that can be used to run your container. To provide the container
|
||||
an ``aprsd.conf`` configuration file, change your
|
||||
``docker-compose.yml`` as shown below:
|
||||
|
||||
If you want to install plugins at container start time, then use the
|
||||
environment var in docker-compose.yml specified as APRS_PLUGINS
|
||||
Provide a csv list of pypi installable plugins. Then make sure the plugin
|
||||
python file is in your /plugins volume and the plugin will be installed at
|
||||
container startup. The plugin may have dependencies that are required.
|
||||
The plugin file should be copied to /plugins for loading by aprsd
|
||||
::
|
||||
|
||||
volumes:
|
||||
- $HOME/.config/aprsd:/config
|
||||
|
||||
To install plugins at container start time, pass in a list of
|
||||
comma-separated list of plugins on PyPI using the ``APRSD_PLUGINS``
|
||||
environment variable in the ``docker-compose.yml`` file. Note that
|
||||
version constraints may also be provided. For example:
|
||||
|
||||
::
|
||||
|
||||
environment:
|
||||
- APRSD_PLUGINS=aprsd-slack-plugin>=1.0.2,aprsd-twitter-plugin
|
||||
|
||||
|
||||
.. badges
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#
|
||||
# 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
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
import click
|
||||
import click_completion
|
||||
|
||||
# local imports here
|
||||
import aprsd
|
||||
from aprsd import cli_helper
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd import messaging, packets, stats, threads, utils
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||
@click.version_option()
|
||||
@click.pass_context
|
||||
def cli(ctx):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
# First import all the possible commands for the CLI
|
||||
# The commands themselves live in the cmds directory
|
||||
from .cmds import ( # noqa
|
||||
completion, dev, healthcheck, list_plugins, listen, send_message,
|
||||
server, webchat,
|
||||
)
|
||||
cli(auto_envvar_prefix="APRSD")
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
global flask_enabled
|
||||
|
||||
click.echo("signal_handler: called")
|
||||
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(1.5)
|
||||
messaging.MsgTrack().save()
|
||||
packets.WatchList().save()
|
||||
packets.SeenList().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))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@cli_helper.add_options(cli_helper.common_options)
|
||||
@click.pass_context
|
||||
@cli_helper.process_standard_options_no_config
|
||||
def check_version(ctx):
|
||||
"""Check this version against the latest in pypi.org."""
|
||||
level, msg = utils._check_version()
|
||||
if level:
|
||||
click.secho(msg, fg="yellow")
|
||||
else:
|
||||
click.secho(msg, fg="green")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_context
|
||||
def sample_config(ctx):
|
||||
"""This dumps the config to stdout."""
|
||||
click.echo(aprsd_config.dump_default_cfg())
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_context
|
||||
def version(ctx):
|
||||
"""Show the APRSD version."""
|
||||
click.echo(click.style("APRSD Version : ", fg="white"), nl=False)
|
||||
click.secho(f"{aprsd.__version__}", fg="yellow", bold=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,92 @@
|
||||
from functools import update_wrapper
|
||||
import typing as t
|
||||
|
||||
import click
|
||||
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd.logging import log
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
F = t.TypeVar("F", bound=t.Callable[..., t.Any])
|
||||
|
||||
common_options = [
|
||||
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=aprsd_config.DEFAULT_CONFIG_FILE,
|
||||
help="The aprsd config file to use for options.",
|
||||
),
|
||||
click.option(
|
||||
"--quiet",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Don't log to stdout",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def add_options(options):
|
||||
def _add_options(func):
|
||||
for option in reversed(options):
|
||||
func = option(func)
|
||||
return func
|
||||
return _add_options
|
||||
|
||||
|
||||
def process_standard_options(f: F) -> F:
|
||||
def new_func(*args, **kwargs):
|
||||
ctx = args[0]
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["loglevel"] = kwargs["loglevel"]
|
||||
ctx.obj["config_file"] = kwargs["config_file"]
|
||||
ctx.obj["quiet"] = kwargs["quiet"]
|
||||
ctx.obj["config"] = aprsd_config.parse_config(kwargs["config_file"])
|
||||
log.setup_logging(
|
||||
ctx.obj["config"],
|
||||
ctx.obj["loglevel"],
|
||||
ctx.obj["quiet"],
|
||||
)
|
||||
if ctx.obj["config"]["aprsd"].get("trace", False):
|
||||
trace.setup_tracing(["method", "api"])
|
||||
|
||||
del kwargs["loglevel"]
|
||||
del kwargs["config_file"]
|
||||
del kwargs["quiet"]
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return update_wrapper(t.cast(F, new_func), f)
|
||||
|
||||
|
||||
def process_standard_options_no_config(f: F) -> F:
|
||||
"""Use this as a decorator when config isn't needed."""
|
||||
def new_func(*args, **kwargs):
|
||||
ctx = args[0]
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["loglevel"] = kwargs["loglevel"]
|
||||
ctx.obj["config_file"] = kwargs["config_file"]
|
||||
ctx.obj["quiet"] = kwargs["quiet"]
|
||||
log.setup_logging_no_config(
|
||||
ctx.obj["loglevel"],
|
||||
ctx.obj["quiet"],
|
||||
)
|
||||
|
||||
del kwargs["loglevel"]
|
||||
del kwargs["config_file"]
|
||||
del kwargs["quiet"]
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return update_wrapper(t.cast(F, new_func), f)
|
||||
@@ -5,8 +5,10 @@ import time
|
||||
import aprslib
|
||||
from aprslib.exceptions import LoginError
|
||||
|
||||
from aprsd import trace
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd import exception
|
||||
from aprsd.clients import aprsis, kiss
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -50,7 +52,8 @@ class Client:
|
||||
|
||||
def reset(self):
|
||||
"""Call this to force a rebuild/reconnect."""
|
||||
del self._client
|
||||
if self._client:
|
||||
del self._client
|
||||
|
||||
@abc.abstractmethod
|
||||
def setup_connection(self):
|
||||
@@ -76,7 +79,21 @@ class APRSISClient(Client):
|
||||
@staticmethod
|
||||
def is_enabled(config):
|
||||
# Defaults to True if the enabled flag is non existent
|
||||
return config["aprs"].get("enabled", True)
|
||||
try:
|
||||
return config["aprs"].get("enabled", True)
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_configured(config):
|
||||
if APRSISClient.is_enabled(config):
|
||||
# Ensure that the config vars are correctly set
|
||||
config.check_option("aprs.login")
|
||||
config.check_option("aprs.password")
|
||||
config.check_option("aprs.host")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def transport(config):
|
||||
@@ -114,6 +131,7 @@ class APRSISClient(Client):
|
||||
backoff = backoff * 2
|
||||
continue
|
||||
LOG.debug(f"Logging in to APRS-IS with user '{user}'")
|
||||
self._client = aprs_client
|
||||
return aprs_client
|
||||
|
||||
|
||||
@@ -131,6 +149,28 @@ class KISSClient(Client):
|
||||
if config.get("kiss.tcp.enabled", default=False):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_configured(config):
|
||||
# Ensure that the config vars are correctly set
|
||||
if KISSClient.is_enabled(config):
|
||||
config.check_option(
|
||||
"aprsd.callsign",
|
||||
default_fail=aprsd_config.DEFAULT_CONFIG_DICT["aprsd"]["callsign"],
|
||||
)
|
||||
transport = KISSClient.transport(config)
|
||||
if transport == TRANSPORT_SERIALKISS:
|
||||
config.check_option("kiss.serial")
|
||||
config.check_option("kiss.serial.device")
|
||||
elif transport == TRANSPORT_TCPKISS:
|
||||
config.check_option("kiss.tcp")
|
||||
config.check_option("kiss.tcp.host")
|
||||
config.check_option("kiss.tcp.port")
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def transport(config):
|
||||
if config.get("kiss.serial.enabled", default=False):
|
||||
@@ -155,8 +195,8 @@ class KISSClient(Client):
|
||||
|
||||
@trace.trace
|
||||
def setup_connection(self):
|
||||
ax25client = kiss.Aioax25Client(self.config)
|
||||
return ax25client
|
||||
client = kiss.KISS3Client(self.config)
|
||||
return client
|
||||
|
||||
|
||||
class ClientFactory:
|
||||
@@ -183,7 +223,7 @@ class ClientFactory:
|
||||
elif KISSClient.is_enabled(self.config):
|
||||
key = KISSClient.transport(self.config)
|
||||
|
||||
LOG.debug(f"GET client {key}")
|
||||
LOG.debug(f"GET client '{key}'")
|
||||
builder = self._builders.get(key)
|
||||
if not builder:
|
||||
raise ValueError(key)
|
||||
@@ -193,7 +233,26 @@ class ClientFactory:
|
||||
"""Make sure at least one client is enabled."""
|
||||
enabled = False
|
||||
for key in self._builders.keys():
|
||||
enabled |= self._builders[key].is_enabled(self.config)
|
||||
try:
|
||||
enabled |= self._builders[key].is_enabled(self.config)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return enabled
|
||||
|
||||
def is_client_configured(self):
|
||||
enabled = False
|
||||
for key in self._builders.keys():
|
||||
try:
|
||||
enabled |= self._builders[key].is_configured(self.config)
|
||||
except KeyError:
|
||||
pass
|
||||
except exception.MissingConfigOptionException as ex:
|
||||
LOG.error(ex.message)
|
||||
return False
|
||||
except exception.ConfigOptionBogusDefaultException as ex:
|
||||
LOG.error(ex.message)
|
||||
return False
|
||||
|
||||
return enabled
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import select
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import aprslib
|
||||
from aprslib import is_py3
|
||||
@@ -7,6 +9,7 @@ from aprslib.exceptions import (
|
||||
ConnectionDrop, ConnectionError, GenericError, LoginError, ParseError,
|
||||
UnknownFormat,
|
||||
)
|
||||
import wrapt
|
||||
|
||||
import aprsd
|
||||
from aprsd import stats
|
||||
@@ -23,11 +26,30 @@ class Aprsdis(aprslib.IS):
|
||||
|
||||
# timeout in seconds
|
||||
select_timeout = 1
|
||||
lock = threading.Lock()
|
||||
|
||||
def stop(self):
|
||||
self.thread_stop = True
|
||||
LOG.info("Shutdown Aprsdis client.")
|
||||
|
||||
def is_socket_closed(self, sock: socket.socket) -> bool:
|
||||
try:
|
||||
# this will try to read bytes without blocking and also without removing them from buffer (peek only)
|
||||
data = sock.recv(16, socket.MSG_DONTWAIT | socket.MSG_PEEK)
|
||||
if len(data) == 0:
|
||||
return True
|
||||
except BlockingIOError:
|
||||
return False # socket is open and reading from it would block
|
||||
except ConnectionResetError:
|
||||
return True # socket was closed for some other reason
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"unexpected exception when checking if a socket is closed",
|
||||
)
|
||||
return False
|
||||
return False
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def send(self, msg):
|
||||
"""Send an APRS Message object."""
|
||||
line = str(msg)
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aioax25 import interface
|
||||
from aioax25 import kiss as kiss
|
||||
from aioax25.aprs import APRSInterface
|
||||
import aprslib
|
||||
from ax253 import Frame
|
||||
import kiss
|
||||
|
||||
from aprsd import messaging
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class Aioax25Client:
|
||||
class KISS3Client:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
self.loop = asyncio.get_event_loop()
|
||||
self.setup()
|
||||
|
||||
def setup(self):
|
||||
@@ -24,71 +23,97 @@ class Aioax25Client:
|
||||
False,
|
||||
):
|
||||
LOG.debug(
|
||||
"Setting up Serial KISS connection to {}".format(
|
||||
"KISS({}) Serial connection to {}".format(
|
||||
kiss.__version__,
|
||||
self.config["kiss"]["serial"]["device"],
|
||||
),
|
||||
)
|
||||
self.kissdev = kiss.SerialKISSDevice(
|
||||
device=self.config["kiss"]["serial"]["device"],
|
||||
baudrate=self.config["kiss"]["serial"].get("baudrate", 9600),
|
||||
loop=self.loop,
|
||||
self.kiss = kiss.SerialKISS(
|
||||
port=self.config["kiss"]["serial"]["device"],
|
||||
speed=self.config["kiss"]["serial"].get("baudrate", 9600),
|
||||
strip_df_start=True,
|
||||
)
|
||||
elif "tcp" in self.config["kiss"] and self.config["kiss"]["tcp"].get(
|
||||
"enabled",
|
||||
False,
|
||||
):
|
||||
LOG.debug(
|
||||
"Setting up KISSTCP Connection to {}:{}".format(
|
||||
"KISS({}) TCP Connection to {}:{}".format(
|
||||
kiss.__version__,
|
||||
self.config["kiss"]["tcp"]["host"],
|
||||
self.config["kiss"]["tcp"]["port"],
|
||||
),
|
||||
)
|
||||
self.kissdev = kiss.TCPKISSDevice(
|
||||
self.config["kiss"]["tcp"]["host"],
|
||||
self.config["kiss"]["tcp"]["port"],
|
||||
loop=self.loop,
|
||||
log=LOG,
|
||||
self.kiss = kiss.TCPKISS(
|
||||
host=self.config["kiss"]["tcp"]["host"],
|
||||
port=int(self.config["kiss"]["tcp"]["port"]),
|
||||
strip_df_start=True,
|
||||
)
|
||||
|
||||
self.kissdev.open()
|
||||
self.kissport0 = self.kissdev[0]
|
||||
|
||||
LOG.debug("Creating AX25Interface")
|
||||
self.ax25int = interface.AX25Interface(kissport=self.kissport0, loop=self.loop)
|
||||
|
||||
LOG.debug("Creating APRSInterface")
|
||||
self.aprsint = APRSInterface(
|
||||
ax25int=self.ax25int,
|
||||
mycall=self.config["kiss"]["callsign"],
|
||||
log=LOG,
|
||||
)
|
||||
LOG.debug("Starting KISS interface connection")
|
||||
self.kiss.start()
|
||||
|
||||
@trace.trace
|
||||
def stop(self):
|
||||
LOG.debug(self.kissdev)
|
||||
self.kissdev._close()
|
||||
self.loop.stop()
|
||||
try:
|
||||
self.kiss.stop()
|
||||
self.kiss.loop.call_soon_threadsafe(
|
||||
self.kiss.protocol.transport.close,
|
||||
)
|
||||
except Exception as ex:
|
||||
LOG.exception(ex)
|
||||
|
||||
def set_filter(self, filter):
|
||||
# This does nothing right now.
|
||||
pass
|
||||
|
||||
def consumer(self, callback, blocking=True, immortal=False, raw=False):
|
||||
callsign = self.config["kiss"]["callsign"]
|
||||
call = callsign.split("-")
|
||||
if len(call) > 1:
|
||||
callsign = call[0]
|
||||
ssid = int(call[1])
|
||||
else:
|
||||
ssid = 0
|
||||
self.aprsint.bind(callback=callback, callsign=callsign, ssid=ssid, regex=False)
|
||||
self.loop.run_forever()
|
||||
def parse_frame(self, frame_bytes):
|
||||
frame = Frame.from_bytes(frame_bytes)
|
||||
# Now parse it with aprslib
|
||||
packet = aprslib.parse(str(frame))
|
||||
kwargs = {
|
||||
"frame": str(frame),
|
||||
"packet": packet,
|
||||
}
|
||||
self._parse_callback(**kwargs)
|
||||
|
||||
def consumer(self, callback, blocking=False, immortal=False, raw=False):
|
||||
LOG.debug("Start blocking KISS consumer")
|
||||
self._parse_callback = callback
|
||||
self.kiss.read(callback=self.parse_frame, min_frames=None)
|
||||
LOG.debug("END blocking KISS consumer")
|
||||
|
||||
def send(self, msg):
|
||||
"""Send an APRS Message object."""
|
||||
payload = f"{msg._filter_for_send()}"
|
||||
self.aprsint.send_message(
|
||||
addressee=msg.tocall,
|
||||
message=payload,
|
||||
path=["WIDE1-1", "WIDE2-1"],
|
||||
oneshot=True,
|
||||
|
||||
# payload = (':%-9s:%s' % (
|
||||
# msg.tocall,
|
||||
# payload
|
||||
# )).encode('US-ASCII'),
|
||||
# payload = str(msg).encode('US-ASCII')
|
||||
payload = None
|
||||
path = ["WIDE1-1", "WIDE2-1"]
|
||||
if isinstance(msg, messaging.AckMessage):
|
||||
msg_payload = f"ack{msg.id}"
|
||||
elif isinstance(msg, messaging.RawMessage):
|
||||
payload = msg.message.encode("US-ASCII")
|
||||
path = ["WIDE2-1"]
|
||||
else:
|
||||
msg_payload = f"{msg.message}{{{str(msg.id)}"
|
||||
|
||||
if not payload:
|
||||
payload = (
|
||||
":{:<9}:{}".format(
|
||||
msg.tocall,
|
||||
msg_payload,
|
||||
)
|
||||
).encode("US-ASCII")
|
||||
|
||||
LOG.debug(f"Send '{payload}' TO KISS")
|
||||
frame = Frame.ui(
|
||||
destination=msg.tocall,
|
||||
source=msg.fromcall,
|
||||
path=path,
|
||||
info=payload,
|
||||
)
|
||||
self.kiss.write(frame)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import click
|
||||
import click_completion
|
||||
|
||||
from aprsd.aprsd import cli
|
||||
|
||||
|
||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
|
||||
@cli.group(help="Click Completion subcommands", context_settings=CONTEXT_SETTINGS)
|
||||
@click.pass_context
|
||||
def completion(ctx):
|
||||
pass
|
||||
|
||||
|
||||
# show dumps out the completion code for a particular shell
|
||||
@completion.command(help="Show completion code for shell", name="show")
|
||||
@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))
|
||||
|
||||
|
||||
# install will install the completion code for a particular shell
|
||||
@completion.command(help="Install completion code for a shell", name="install")
|
||||
@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(f"{shell} completion installed in {path}")
|
||||
@@ -0,0 +1,143 @@
|
||||
#
|
||||
# Dev.py is used to help develop plugins
|
||||
#
|
||||
#
|
||||
# python included libs
|
||||
import logging
|
||||
|
||||
import click
|
||||
|
||||
# local imports here
|
||||
from aprsd import cli_helper, client, messaging, packets, plugin, stats, utils
|
||||
from aprsd.aprsd import cli
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
|
||||
@cli.group(help="Development type subcommands", context_settings=CONTEXT_SETTINGS)
|
||||
@click.pass_context
|
||||
def dev(ctx):
|
||||
pass
|
||||
|
||||
|
||||
@dev.command()
|
||||
@cli_helper.add_options(cli_helper.common_options)
|
||||
@click.option(
|
||||
"--aprs-login",
|
||||
envvar="APRS_LOGIN",
|
||||
show_envvar=True,
|
||||
help="What callsign to send the message from.",
|
||||
)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--plugin",
|
||||
"plugin_path",
|
||||
show_default=True,
|
||||
default=None,
|
||||
help="The plugin to run. Ex: aprsd.plugins.ping.PingPlugin",
|
||||
)
|
||||
@click.option(
|
||||
"-a",
|
||||
"--all",
|
||||
"load_all",
|
||||
show_default=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Load all the plugins in config?",
|
||||
)
|
||||
@click.option(
|
||||
"-n",
|
||||
"--num",
|
||||
"number",
|
||||
show_default=True,
|
||||
default=1,
|
||||
help="Number of times to call the plugin",
|
||||
)
|
||||
@click.argument("message", nargs=-1, required=True)
|
||||
@click.pass_context
|
||||
@cli_helper.process_standard_options
|
||||
def test_plugin(
|
||||
ctx,
|
||||
aprs_login,
|
||||
plugin_path,
|
||||
load_all,
|
||||
number,
|
||||
message,
|
||||
):
|
||||
"""Test an individual APRSD plugin given a python path."""
|
||||
config = ctx.obj["config"]
|
||||
|
||||
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(f"{x} = XXXXXXXXXXXXXXXXXXX")
|
||||
else:
|
||||
LOG.info(f"{x} = {flat_config[x]}")
|
||||
|
||||
if not aprs_login:
|
||||
if not config.exists("aprs.login"):
|
||||
click.echo("Must set --aprs_login or APRS_LOGIN")
|
||||
ctx.exit(-1)
|
||||
return
|
||||
else:
|
||||
fromcall = config.get("aprs.login")
|
||||
else:
|
||||
fromcall = aprs_login
|
||||
|
||||
if not plugin_path:
|
||||
click.echo(ctx.get_help())
|
||||
click.echo("")
|
||||
ctx.fail("Failed to provide -p option to test a plugin")
|
||||
return
|
||||
|
||||
if type(message) is tuple:
|
||||
message = " ".join(message)
|
||||
|
||||
if config["aprsd"].get("trace", False):
|
||||
trace.setup_tracing(["method", "api"])
|
||||
|
||||
client.Client(config)
|
||||
stats.APRSDStats(config)
|
||||
messaging.MsgTrack(config=config)
|
||||
packets.WatchList(config=config)
|
||||
packets.SeenList(config=config)
|
||||
|
||||
pm = plugin.PluginManager(config)
|
||||
if load_all:
|
||||
pm.setup_plugins()
|
||||
else:
|
||||
pm._init()
|
||||
obj = pm._create_class(plugin_path, plugin.APRSDPluginBase, config=config)
|
||||
if not obj:
|
||||
click.echo(ctx.get_help())
|
||||
click.echo("")
|
||||
ctx.fail(f"Failed to create object from plugin path '{plugin_path}'")
|
||||
ctx.exit()
|
||||
|
||||
# Register the plugin they wanted tested.
|
||||
LOG.info(
|
||||
"Testing plugin {} Version {}".format(
|
||||
obj.__class__, obj.version,
|
||||
),
|
||||
)
|
||||
pm._pluggy_pm.register(obj)
|
||||
login = config["aprs"]["login"]
|
||||
|
||||
packet = {
|
||||
"from": fromcall, "addresse": login,
|
||||
"message_text": message,
|
||||
"format": "message",
|
||||
"msgNo": 1,
|
||||
}
|
||||
LOG.info(f"P'{plugin_path}' F'{fromcall}' C'{message}'")
|
||||
|
||||
for x in range(number):
|
||||
reply = pm.run(packet)
|
||||
# Plugin might have threads, so lets stop them so we can exit.
|
||||
# obj.stop_threads()
|
||||
LOG.info(f"Result{x} = '{reply}'")
|
||||
pm.stop()
|
||||
@@ -0,0 +1,80 @@
|
||||
#
|
||||
# Used to fetch the stats url and determine if
|
||||
# aprsd server is 'healthy'
|
||||
#
|
||||
#
|
||||
# python included libs
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import click
|
||||
import requests
|
||||
from rich.console import Console
|
||||
|
||||
import aprsd
|
||||
from aprsd import cli_helper, utils
|
||||
# local imports here
|
||||
from aprsd.aprsd import cli
|
||||
|
||||
|
||||
# setup the global logger
|
||||
# logging.basicConfig(level=logging.DEBUG) # level=10
|
||||
LOG = logging.getLogger("APRSD")
|
||||
console = Console()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@cli_helper.add_options(cli_helper.common_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",
|
||||
)
|
||||
@click.pass_context
|
||||
@cli_helper.process_standard_options_no_config
|
||||
def healthcheck(ctx, health_url, timeout):
|
||||
"""Check the health of the running aprsd server."""
|
||||
console.log(f"APRSD HealthCheck version: {aprsd.__version__}")
|
||||
with console.status(f"APRSD HealthCheck version: {aprsd.__version__}") as status:
|
||||
try:
|
||||
status.update(f"Contacting APRSD on {health_url}")
|
||||
url = health_url
|
||||
response = requests.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
except Exception as ex:
|
||||
console.log(f"Failed to fetch healthcheck url '{url}' : '{ex}'")
|
||||
sys.exit(-1)
|
||||
else:
|
||||
status.update("Contacted APRSD. Parsing results.")
|
||||
stats = json.loads(response.text)
|
||||
email_thread_last_update = stats["stats"]["email"]["thread_last_update"]
|
||||
|
||||
if email_thread_last_update != "never":
|
||||
delta = utils.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:
|
||||
console.log(f"Email thread is very old! {d}")
|
||||
sys.exit(-1)
|
||||
|
||||
aprsis_last_update = stats["stats"]["aprs-is"]["last_update"]
|
||||
delta = utils.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(f"APRS-IS last update is very old! {d}")
|
||||
sys.exit(-1)
|
||||
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,242 @@
|
||||
import fnmatch
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import pkgutil
|
||||
import re
|
||||
import sys
|
||||
from traceback import print_tb
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
import click
|
||||
import requests
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from thesmuggler import smuggle
|
||||
|
||||
from aprsd import cli_helper
|
||||
from aprsd import plugin as aprsd_plugin
|
||||
from aprsd.aprsd import cli
|
||||
from aprsd.plugins import (
|
||||
email, fortune, location, notify, ping, query, time, version, weather,
|
||||
)
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
def onerror(name):
|
||||
print(f"Error importing module {name}")
|
||||
type, value, traceback = sys.exc_info()
|
||||
print_tb(traceback)
|
||||
|
||||
|
||||
def is_plugin(obj):
|
||||
for c in inspect.getmro(obj):
|
||||
if issubclass(c, aprsd_plugin.APRSDPluginBase):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def plugin_type(obj):
|
||||
for c in inspect.getmro(obj):
|
||||
if issubclass(c, aprsd_plugin.APRSDRegexCommandPluginBase):
|
||||
return "RegexCommand"
|
||||
if issubclass(c, aprsd_plugin.APRSDWatchListPluginBase):
|
||||
return "WatchList"
|
||||
if issubclass(c, aprsd_plugin.APRSDPluginBase):
|
||||
return "APRSDPluginBase"
|
||||
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def walk_package(package):
|
||||
return pkgutil.walk_packages(
|
||||
package.__path__,
|
||||
package.__name__ + ".",
|
||||
onerror=onerror,
|
||||
)
|
||||
|
||||
|
||||
def get_module_info(package_name, module_name, module_path):
|
||||
if not os.path.exists(module_path):
|
||||
return None
|
||||
|
||||
dir_path = os.path.realpath(module_path)
|
||||
pattern = "*.py"
|
||||
|
||||
obj_list = []
|
||||
|
||||
for path, _subdirs, files in os.walk(dir_path):
|
||||
for name in files:
|
||||
if fnmatch.fnmatch(name, pattern):
|
||||
module = smuggle(f"{path}/{name}")
|
||||
for mem_name, obj in inspect.getmembers(module):
|
||||
if inspect.isclass(obj) and is_plugin(obj):
|
||||
obj_list.append(
|
||||
{
|
||||
"package": package_name,
|
||||
"name": mem_name, "obj": obj,
|
||||
"version": obj.version,
|
||||
"path": f"{'.'.join([module_name, obj.__name__])}",
|
||||
},
|
||||
)
|
||||
|
||||
return obj_list
|
||||
|
||||
|
||||
def get_installed_plugins():
|
||||
# installed plugins
|
||||
ip = {}
|
||||
for finder, name, ispkg in pkgutil.iter_modules():
|
||||
if name.startswith("aprsd_"):
|
||||
if ispkg:
|
||||
module = importlib.import_module(name)
|
||||
pkgs = walk_package(module)
|
||||
for pkg in pkgs:
|
||||
pkg_info = get_module_info(module.__name__, pkg.name, module.__path__[0])
|
||||
ip[name] = pkg_info
|
||||
return ip
|
||||
|
||||
|
||||
def show_built_in_plugins(console):
|
||||
modules = [email, fortune, location, notify, ping, query, time, version, weather]
|
||||
plugins = []
|
||||
|
||||
for module in modules:
|
||||
entries = inspect.getmembers(module, inspect.isclass)
|
||||
for entry in entries:
|
||||
cls = entry[1]
|
||||
if issubclass(cls, aprsd_plugin.APRSDPluginBase):
|
||||
info = {
|
||||
"name": cls.__qualname__,
|
||||
"path": f"{cls.__module__}.{cls.__qualname__}",
|
||||
"version": cls.version,
|
||||
"docstring": cls.__doc__,
|
||||
"short_desc": cls.short_description,
|
||||
}
|
||||
|
||||
if issubclass(cls, aprsd_plugin.APRSDRegexCommandPluginBase):
|
||||
info["command_regex"] = cls.command_regex
|
||||
info["type"] = "RegexCommand"
|
||||
|
||||
if issubclass(cls, aprsd_plugin.APRSDWatchListPluginBase):
|
||||
info["type"] = "WatchList"
|
||||
|
||||
plugins.append(info)
|
||||
|
||||
plugins = sorted(plugins, key=lambda i: i["name"])
|
||||
|
||||
table = Table(
|
||||
title="[not italic]:snake:[/] [bold][magenta]APRSD Built-in Plugins [not italic]:snake:[/]",
|
||||
)
|
||||
table.add_column("Plugin Name", style="cyan", no_wrap=True)
|
||||
table.add_column("Info", style="bold yellow")
|
||||
table.add_column("Type", style="bold green")
|
||||
table.add_column("Plugin Path", style="bold blue")
|
||||
for entry in plugins:
|
||||
table.add_row(entry["name"], entry["short_desc"], entry["type"], entry["path"])
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def show_pypi_plugins(installed_plugins, console):
|
||||
query = "aprsd"
|
||||
api_url = "https://pypi.org/search/"
|
||||
snippets = []
|
||||
s = requests.Session()
|
||||
for page in range(1, 3):
|
||||
params = {"q": query, "page": page}
|
||||
r = s.get(api_url, params=params)
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
snippets += soup.select('a[class*="snippet"]')
|
||||
if not hasattr(s, "start_url"):
|
||||
s.start_url = r.url.rsplit("&page", maxsplit=1).pop(0)
|
||||
|
||||
title = Text.assemble(
|
||||
("Pypi.org APRSD Installable Plugin Packages\n\n", "bold magenta"),
|
||||
("Install any of the following plugins with ", "bold yellow"),
|
||||
("'pip install ", "bold white"),
|
||||
("<Plugin Package Name>'", "cyan"),
|
||||
)
|
||||
|
||||
table = Table(title=title)
|
||||
table.add_column("Plugin Package Name", style="cyan", no_wrap=True)
|
||||
table.add_column("Description", style="yellow")
|
||||
table.add_column("Version", style="yellow", justify="center")
|
||||
table.add_column("Released", style="bold green", justify="center")
|
||||
table.add_column("Installed?", style="red", justify="center")
|
||||
for snippet in snippets:
|
||||
link = urljoin(api_url, snippet.get("href"))
|
||||
package = re.sub(r"\s+", " ", snippet.select_one('span[class*="name"]').text.strip())
|
||||
version = re.sub(r"\s+", " ", snippet.select_one('span[class*="version"]').text.strip())
|
||||
created = re.sub(r"\s+", " ", snippet.select_one('span[class*="created"]').text.strip())
|
||||
description = re.sub(r"\s+", " ", snippet.select_one('p[class*="description"]').text.strip())
|
||||
emoji = ":open_file_folder:"
|
||||
|
||||
if "aprsd-" not in package or "-plugin" not in package:
|
||||
continue
|
||||
|
||||
under = package.replace("-", "_")
|
||||
if under in installed_plugins:
|
||||
installed = "Yes"
|
||||
else:
|
||||
installed = "No"
|
||||
|
||||
table.add_row(
|
||||
f"[link={link}]{emoji}[/link] {package}",
|
||||
description, version, created, installed,
|
||||
)
|
||||
|
||||
console.print("\n")
|
||||
console.print(table)
|
||||
return
|
||||
|
||||
|
||||
def show_installed_plugins(installed_plugins, console):
|
||||
if not installed_plugins:
|
||||
return
|
||||
|
||||
table = Table(
|
||||
title="[not italic]:snake:[/] [bold][magenta]APRSD Installed 3rd party Plugins [not italic]:snake:[/]",
|
||||
)
|
||||
table.add_column("Package Name", style=" bold white", no_wrap=True)
|
||||
table.add_column("Plugin Name", style="cyan", no_wrap=True)
|
||||
table.add_column("Version", style="yellow", justify="center")
|
||||
table.add_column("Type", style="bold green")
|
||||
table.add_column("Plugin Path", style="bold blue")
|
||||
for name in installed_plugins:
|
||||
for plugin in installed_plugins[name]:
|
||||
table.add_row(
|
||||
name.replace("_", "-"),
|
||||
plugin["name"],
|
||||
plugin["version"],
|
||||
plugin_type(plugin["obj"]),
|
||||
plugin["path"],
|
||||
)
|
||||
|
||||
console.print("\n")
|
||||
console.print(table)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@cli_helper.add_options(cli_helper.common_options)
|
||||
@click.pass_context
|
||||
@cli_helper.process_standard_options_no_config
|
||||
def list_plugins(ctx):
|
||||
"""List the built in plugins available to APRSD."""
|
||||
console = Console()
|
||||
|
||||
with console.status("Show Built-in Plugins") as status:
|
||||
show_built_in_plugins(console)
|
||||
|
||||
status.update("Fetching pypi.org plugins")
|
||||
installed_plugins = get_installed_plugins()
|
||||
show_pypi_plugins(installed_plugins, console)
|
||||
|
||||
status.update("Looking for installed APRSD plugins")
|
||||
show_installed_plugins(installed_plugins, console)
|
||||
@@ -0,0 +1,168 @@
|
||||
#
|
||||
# License GPLv2
|
||||
#
|
||||
|
||||
# python included libs
|
||||
import datetime
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aprslib
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
# local imports here
|
||||
import aprsd
|
||||
from aprsd import cli_helper, client, messaging, packets, stats, threads, utils
|
||||
from aprsd.aprsd import cli
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
# setup the global logger
|
||||
# logging.basicConfig(level=logging.DEBUG) # level=10
|
||||
LOG = logging.getLogger("APRSD")
|
||||
console = Console()
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
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)
|
||||
LOG.info(stats.APRSDStats())
|
||||
|
||||
|
||||
@cli.command()
|
||||
@cli_helper.add_options(cli_helper.common_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.argument(
|
||||
"filter",
|
||||
nargs=-1,
|
||||
required=True,
|
||||
)
|
||||
@click.pass_context
|
||||
@cli_helper.process_standard_options
|
||||
def listen(
|
||||
ctx,
|
||||
aprs_login,
|
||||
aprs_password,
|
||||
filter,
|
||||
):
|
||||
"""Listen to packets on the APRS-IS Network based on FILTER.
|
||||
|
||||
FILTER is the APRS Filter to use.\n
|
||||
see http://www.aprs-is.net/javAPRSFilter.aspx\n
|
||||
r/lat/lon/dist - Range Filter Pass posits and objects within dist km from lat/lon.\n
|
||||
p/aa/bb/cc... - Prefix Filter Pass traffic with fromCall that start with aa or bb or cc.\n
|
||||
b/call1/call2... - Budlist Filter Pass all traffic from exact call: call1, call2, ... (* wild card allowed) \n
|
||||
o/obj1/obj2... - Object Filter Pass all objects with the exact name of obj1, obj2, ... (* wild card allowed)\n
|
||||
|
||||
"""
|
||||
config = ctx.obj["config"]
|
||||
|
||||
if not aprs_login:
|
||||
click.echo(ctx.get_help())
|
||||
click.echo("")
|
||||
ctx.fail("Must set --aprs_login or APRS_LOGIN")
|
||||
ctx.exit()
|
||||
|
||||
if not aprs_password:
|
||||
click.echo(ctx.get_help())
|
||||
click.echo("")
|
||||
ctx.fail("Must set --aprs-password or APRS_PASSWORD")
|
||||
ctx.exit()
|
||||
|
||||
config["aprs"]["login"] = aprs_login
|
||||
config["aprs"]["password"] = aprs_password
|
||||
|
||||
LOG.info(f"APRSD Listen Started version: {aprsd.__version__}")
|
||||
|
||||
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(f"{x} = XXXXXXXXXXXXXXXXXXX")
|
||||
else:
|
||||
LOG.info(f"{x} = {flat_config[x]}")
|
||||
|
||||
stats.APRSDStats(config)
|
||||
|
||||
# Try and load saved MsgTrack list
|
||||
LOG.debug("Loading saved MsgTrack object.")
|
||||
messaging.MsgTrack(config=config).load()
|
||||
packets.WatchList(config=config).load()
|
||||
packets.SeenList(config=config).load()
|
||||
|
||||
@trace.trace
|
||||
def rx_packet(packet):
|
||||
resp = packet.get("response", None)
|
||||
if resp == "ack":
|
||||
ack_num = packet.get("msgNo")
|
||||
console.log(f"We saw an ACK {ack_num} Ignoring")
|
||||
messaging.log_packet(packet)
|
||||
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,
|
||||
console=console,
|
||||
)
|
||||
|
||||
# Initialize the client factory and create
|
||||
# The correct client object ready for use
|
||||
client.ClientFactory.setup(config)
|
||||
# Make sure we have 1 client transport enabled
|
||||
if not client.factory.is_client_enabled():
|
||||
LOG.error("No Clients are enabled in config.")
|
||||
sys.exit(-1)
|
||||
|
||||
# Creates the client object
|
||||
LOG.info("Creating client connection")
|
||||
aprs_client = client.factory.create()
|
||||
console.log(aprs_client)
|
||||
|
||||
LOG.debug(f"Filter by '{filter}'")
|
||||
aprs_client.client.set_filter(filter)
|
||||
|
||||
packets.PacketList(config=config)
|
||||
|
||||
keepalive = threads.KeepAliveThread(config=config)
|
||||
keepalive.start()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# This will register a packet consumer with aprslib
|
||||
# When new packets come in the consumer will process
|
||||
# the packet
|
||||
# with console.status("Listening for packets"):
|
||||
aprs_client.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
|
||||
aprs_client.reset()
|
||||
except aprslib.exceptions.UnknownFormat:
|
||||
LOG.error("Got a Bad packet")
|
||||
@@ -0,0 +1,169 @@
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aprslib
|
||||
from aprslib.exceptions import LoginError
|
||||
import click
|
||||
|
||||
import aprsd
|
||||
from aprsd import cli_helper, client, messaging, packets
|
||||
from aprsd.aprsd import cli
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@cli_helper.add_options(cli_helper.common_options)
|
||||
@click.option(
|
||||
"--aprs-login",
|
||||
envvar="APRS_LOGIN",
|
||||
show_envvar=True,
|
||||
help="What callsign to send the message from. Defaults to config entry.",
|
||||
)
|
||||
@click.option(
|
||||
"--aprs-password",
|
||||
envvar="APRS_PASSWORD",
|
||||
show_envvar=True,
|
||||
help="the APRS-IS password for APRS_LOGIN. Defaults to config entry.",
|
||||
)
|
||||
@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(
|
||||
"--wait-response",
|
||||
"-w",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
default=False,
|
||||
help="Wait for a response to the message?",
|
||||
)
|
||||
@click.option("--raw", default=None, help="Send a raw message. Implies --no-ack")
|
||||
@click.argument("tocallsign", required=True)
|
||||
@click.argument("command", nargs=-1, required=True)
|
||||
@click.pass_context
|
||||
@cli_helper.process_standard_options
|
||||
def send_message(
|
||||
ctx,
|
||||
aprs_login,
|
||||
aprs_password,
|
||||
no_ack,
|
||||
wait_response,
|
||||
raw,
|
||||
tocallsign,
|
||||
command,
|
||||
):
|
||||
"""Send a message to a callsign via APRS_IS."""
|
||||
global got_ack, got_response
|
||||
config = ctx.obj["config"]
|
||||
quiet = ctx.obj["quiet"]
|
||||
|
||||
if not aprs_login:
|
||||
if not config.exists("aprs.login"):
|
||||
click.echo("Must set --aprs_login or APRS_LOGIN")
|
||||
ctx.exit(-1)
|
||||
return
|
||||
else:
|
||||
config["aprs"]["login"] = aprs_login
|
||||
|
||||
if not aprs_password:
|
||||
if not config.exists("aprs.password"):
|
||||
click.echo("Must set --aprs-password or APRS_PASSWORD")
|
||||
ctx.exit(-1)
|
||||
return
|
||||
else:
|
||||
config["aprs"]["password"] = aprs_password
|
||||
|
||||
LOG.info(f"APRSD LISTEN Started version: {aprsd.__version__}")
|
||||
if type(command) is tuple:
|
||||
command = " ".join(command)
|
||||
if not quiet:
|
||||
if raw:
|
||||
LOG.info(f"L'{aprs_login}' R'{raw}'")
|
||||
else:
|
||||
LOG.info(f"L'{aprs_login}' To'{tocallsign}' C'{command}'")
|
||||
|
||||
packets.PacketList(config=config)
|
||||
packets.WatchList(config=config)
|
||||
packets.SeenList(config=config)
|
||||
|
||||
got_ack = False
|
||||
got_response = False
|
||||
|
||||
def rx_packet(packet):
|
||||
global got_ack, got_response
|
||||
# LOG.debug("Got packet back {}".format(packet))
|
||||
resp = packet.get("response", None)
|
||||
if resp == "ack":
|
||||
ack_num = packet.get("msgNo")
|
||||
LOG.info(f"We got ack for our sent message {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,
|
||||
)
|
||||
got_response = True
|
||||
# Send the ack back?
|
||||
ack = messaging.AckMessage(
|
||||
config["aprs"]["login"],
|
||||
fromcall,
|
||||
msg_id=msg_number,
|
||||
)
|
||||
ack.send_direct()
|
||||
|
||||
if got_ack:
|
||||
if wait_response:
|
||||
if got_response:
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
client.ClientFactory.setup(config)
|
||||
client.factory.create().client
|
||||
except LoginError:
|
||||
sys.exit(-1)
|
||||
|
||||
# Send a message
|
||||
# then we setup a consumer to rx messages
|
||||
# We should get an ack back as well as a new message
|
||||
# we should bail after we get the ack and send an ack back for the
|
||||
# message
|
||||
if raw:
|
||||
msg = messaging.RawMessage(raw)
|
||||
msg.send_direct()
|
||||
sys.exit(0)
|
||||
else:
|
||||
msg = messaging.TextMessage(aprs_login, tocallsign, command)
|
||||
msg.send_direct()
|
||||
|
||||
if no_ack:
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
# This will register a packet consumer with aprslib
|
||||
# When new packets come in the consumer will process
|
||||
# the packet
|
||||
aprs_client = client.factory.create().client
|
||||
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
|
||||
aprs_client.reset()
|
||||
@@ -0,0 +1,124 @@
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
import aprsd
|
||||
from aprsd import (
|
||||
cli_helper, client, flask, messaging, packets, plugin, stats, threads,
|
||||
utils,
|
||||
)
|
||||
from aprsd import aprsd as aprsd_main
|
||||
from aprsd.aprsd import cli
|
||||
from aprsd.threads import rx
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
# main() ###
|
||||
@cli.command()
|
||||
@cli_helper.add_options(cli_helper.common_options)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--flush",
|
||||
"flush",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
default=False,
|
||||
help="Flush out all old aged messages on disk.",
|
||||
)
|
||||
@click.pass_context
|
||||
@cli_helper.process_standard_options
|
||||
def server(ctx, flush):
|
||||
"""Start the aprsd server gateway process."""
|
||||
ctx.obj["config_file"]
|
||||
loglevel = ctx.obj["loglevel"]
|
||||
quiet = ctx.obj["quiet"]
|
||||
config = ctx.obj["config"]
|
||||
|
||||
signal.signal(signal.SIGINT, aprsd_main.signal_handler)
|
||||
signal.signal(signal.SIGTERM, aprsd_main.signal_handler)
|
||||
|
||||
if not quiet:
|
||||
click.echo("Load config")
|
||||
|
||||
level, msg = utils._check_version()
|
||||
if level:
|
||||
LOG.warning(msg)
|
||||
else:
|
||||
LOG.info(msg)
|
||||
LOG.info(f"APRSD Started version: {aprsd.__version__}")
|
||||
|
||||
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(f"{x} = XXXXXXXXXXXXXXXXXXX")
|
||||
else:
|
||||
LOG.info(f"{x} = {flat_config[x]}")
|
||||
|
||||
stats.APRSDStats(config)
|
||||
|
||||
# Initialize the client factory and create
|
||||
# The correct client object ready for use
|
||||
client.ClientFactory.setup(config)
|
||||
# Make sure we have 1 client transport enabled
|
||||
if not client.factory.is_client_enabled():
|
||||
LOG.error("No Clients are enabled in config.")
|
||||
sys.exit(-1)
|
||||
|
||||
if not client.factory.is_client_configured():
|
||||
LOG.error("APRS client is not properly configured in config file.")
|
||||
sys.exit(-1)
|
||||
|
||||
# Creates the client object
|
||||
LOG.info("Creating client connection")
|
||||
client.factory.create().client
|
||||
|
||||
# Now load the msgTrack from disk if any
|
||||
packets.PacketList(config=config)
|
||||
if flush:
|
||||
LOG.debug("Deleting saved MsgTrack.")
|
||||
messaging.MsgTrack(config=config).flush()
|
||||
packets.WatchList(config=config)
|
||||
packets.SeenList(config=config)
|
||||
else:
|
||||
# Try and load saved MsgTrack list
|
||||
LOG.debug("Loading saved MsgTrack object.")
|
||||
messaging.MsgTrack(config=config).load()
|
||||
packets.WatchList(config=config).load()
|
||||
packets.SeenList(config=config).load()
|
||||
|
||||
# Create the initial PM singleton and Register plugins
|
||||
LOG.info("Loading Plugin Manager and registering plugins")
|
||||
plugin_manager = plugin.PluginManager(config)
|
||||
plugin_manager.setup_plugins()
|
||||
|
||||
rx_thread = rx.APRSDPluginRXThread(
|
||||
msg_queues=threads.msg_queues,
|
||||
config=config,
|
||||
)
|
||||
rx_thread.start()
|
||||
|
||||
messaging.MsgTrack().restart()
|
||||
|
||||
keepalive = threads.KeepAliveThread(config=config)
|
||||
keepalive.start()
|
||||
|
||||
web_enabled = config.get("aprsd.web.enabled", default=False)
|
||||
|
||||
if web_enabled:
|
||||
aprsd_main.flask_enabled = True
|
||||
(socketio, app) = flask.init_flask(config, loglevel, quiet)
|
||||
socketio.run(
|
||||
app,
|
||||
allow_unsafe_werkzeug=True,
|
||||
host=config["aprsd"]["web"]["host"],
|
||||
port=config["aprsd"]["web"]["port"],
|
||||
)
|
||||
|
||||
# If there are items in the msgTracker, then save them
|
||||
LOG.info("APRSD Exiting.")
|
||||
return 0
|
||||
@@ -0,0 +1,560 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aprslib import util as aprslib_util
|
||||
import click
|
||||
import flask
|
||||
from flask import request
|
||||
from flask.logging import default_handler
|
||||
import flask_classful
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from flask_socketio import Namespace, SocketIO
|
||||
from user_agents import parse as ua_parse
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
import wrapt
|
||||
|
||||
import aprsd
|
||||
from aprsd import cli_helper, client
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd import messaging, packets, stats, threads, utils
|
||||
from aprsd.aprsd import cli
|
||||
from aprsd.logging import rich as aprsd_logging
|
||||
from aprsd.threads import rx
|
||||
from aprsd.utils import objectstore, trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
auth = HTTPBasicAuth()
|
||||
users = None
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
|
||||
click.echo("signal_handler: called")
|
||||
LOG.info(
|
||||
f"Ctrl+C, Sending all threads({len(threads.APRSDThreadList())}) exit! "
|
||||
f"Can take up to 10 seconds {datetime.datetime.now()}",
|
||||
)
|
||||
threads.APRSDThreadList().stop_all()
|
||||
if "subprocess" not in str(frame):
|
||||
time.sleep(1.5)
|
||||
# messaging.MsgTrack().save()
|
||||
# packets.WatchList().save()
|
||||
# packets.SeenList().save()
|
||||
LOG.info(stats.APRSDStats())
|
||||
LOG.info("Telling flask to bail.")
|
||||
signal.signal(signal.SIGTERM, sys.exit(0))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
class SentMessages(objectstore.ObjectStoreMixin):
|
||||
_instance = None
|
||||
lock = threading.Lock()
|
||||
|
||||
data = {}
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""This magic turns this into a singleton."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def add(self, msg):
|
||||
self.data[msg.id] = self.create(msg.id)
|
||||
self.data[msg.id]["from"] = msg.fromcall
|
||||
self.data[msg.id]["to"] = msg.tocall
|
||||
self.data[msg.id]["message"] = msg.message.rstrip("\n")
|
||||
self.data[msg.id]["raw"] = str(msg).rstrip("\n")
|
||||
|
||||
def create(self, id):
|
||||
return {
|
||||
"id": id,
|
||||
"ts": time.time(),
|
||||
"ack": False,
|
||||
"from": None,
|
||||
"to": None,
|
||||
"raw": None,
|
||||
"message": None,
|
||||
"status": None,
|
||||
"last_update": None,
|
||||
"reply": None,
|
||||
}
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def __len__(self):
|
||||
return len(self.data.keys())
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def get(self, id):
|
||||
if id in self.data:
|
||||
return self.data[id]
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def get_all(self):
|
||||
return self.data
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_status(self, id, status):
|
||||
if id in self.data:
|
||||
self.data[id]["last_update"] = str(datetime.datetime.now())
|
||||
self.data[id]["status"] = status
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def ack(self, id):
|
||||
"""The message got an ack!"""
|
||||
if id in self.data:
|
||||
self.data[id]["last_update"] = str(datetime.datetime.now())
|
||||
self.data[id]["ack"] = True
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def reply(self, id, packet):
|
||||
"""We got a packet back from the sent message."""
|
||||
if id in self.data:
|
||||
self.data[id]["reply"] = packet
|
||||
|
||||
|
||||
# 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 WebChatRXThread(rx.APRSDRXThread):
|
||||
"""Class that connects to APRISIS/kiss and waits for messages.
|
||||
|
||||
After the packet is received from APRSIS/KISS, the packet is
|
||||
sent to processing in the WebChatProcessPacketThread.
|
||||
"""
|
||||
def __init__(self, config, socketio):
|
||||
super().__init__(None, config)
|
||||
self.socketio = socketio
|
||||
self.connected = False
|
||||
|
||||
def connected(self, connected=True):
|
||||
self.connected = connected
|
||||
|
||||
def process_packet(self, *args, **kwargs):
|
||||
# packet = self._client.decode_packet(*args, **kwargs)
|
||||
if "packet" in kwargs:
|
||||
packet = kwargs["packet"]
|
||||
else:
|
||||
packet = self._client.decode_packet(*args, **kwargs)
|
||||
|
||||
LOG.debug(f"GOT Packet {packet}")
|
||||
thread = WebChatProcessPacketThread(
|
||||
config=self.config,
|
||||
packet=packet,
|
||||
socketio=self.socketio,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
class WebChatProcessPacketThread(rx.APRSDProcessPacketThread):
|
||||
"""Class that handles packets being sent to us."""
|
||||
def __init__(self, config, packet, socketio):
|
||||
self.socketio = socketio
|
||||
self.connected = False
|
||||
super().__init__(config, packet)
|
||||
|
||||
def process_ack_packet(self, packet):
|
||||
super().process_ack_packet(packet)
|
||||
ack_num = packet.get("msgNo")
|
||||
SentMessages().ack(int(ack_num))
|
||||
self.socketio.emit(
|
||||
"ack", SentMessages().get(int(ack_num)),
|
||||
namespace="/sendmsg",
|
||||
)
|
||||
self.got_ack = True
|
||||
|
||||
def process_non_ack_packet(self, packet):
|
||||
LOG.info(f"process non ack PACKET {packet}")
|
||||
packet.get("addresse", None)
|
||||
fromcall = packet["from"]
|
||||
|
||||
packets.PacketList().add(packet)
|
||||
stats.APRSDStats().msgs_rx_inc()
|
||||
message = packet.get("message_text", None)
|
||||
msg = {
|
||||
"id": 0,
|
||||
"ts": time.time(),
|
||||
"ack": False,
|
||||
"from": fromcall,
|
||||
"to": packet["to"],
|
||||
"raw": packet["raw"],
|
||||
"message": message,
|
||||
"status": None,
|
||||
"last_update": None,
|
||||
"reply": None,
|
||||
}
|
||||
self.socketio.emit(
|
||||
"new", msg,
|
||||
namespace="/sendmsg",
|
||||
)
|
||||
|
||||
|
||||
class WebChatFlask(flask_classful.FlaskView):
|
||||
config = None
|
||||
|
||||
def set_config(self, config):
|
||||
global users
|
||||
self.config = config
|
||||
self.users = {}
|
||||
for user in self.config["aprsd"]["web"]["users"]:
|
||||
self.users[user] = generate_password_hash(
|
||||
self.config["aprsd"]["web"]["users"][user],
|
||||
)
|
||||
|
||||
users = self.users
|
||||
|
||||
def _get_transport(self, stats):
|
||||
if self.config["aprs"].get("enabled", True):
|
||||
transport = "aprs-is"
|
||||
aprs_connection = (
|
||||
"APRS-IS Server: <a href='http://status.aprs2.net' >"
|
||||
"{}</a>".format(stats["stats"]["aprs-is"]["server"])
|
||||
)
|
||||
else:
|
||||
# We might be connected to a KISS socket?
|
||||
if client.KISSClient.is_enabled(self.config):
|
||||
transport = client.KISSClient.transport(self.config)
|
||||
if transport == client.TRANSPORT_TCPKISS:
|
||||
aprs_connection = (
|
||||
"TCPKISS://{}:{}".format(
|
||||
self.config["kiss"]["tcp"]["host"],
|
||||
self.config["kiss"]["tcp"]["port"],
|
||||
)
|
||||
)
|
||||
elif transport == client.TRANSPORT_SERIALKISS:
|
||||
# for pep8 violation
|
||||
kiss_default = aprsd_config.DEFAULT_DATE_FORMAT["kiss"]
|
||||
default_baudrate = kiss_default["serial"]["baudrate"]
|
||||
aprs_connection = (
|
||||
"SerialKISS://{}@{} baud".format(
|
||||
self.config["kiss"]["serial"]["device"],
|
||||
self.config["kiss"]["serial"].get(
|
||||
"baudrate",
|
||||
default_baudrate,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return transport, aprs_connection
|
||||
|
||||
@auth.login_required
|
||||
def index(self):
|
||||
ua_str = request.headers.get("User-Agent")
|
||||
# this takes about 2 seconds :(
|
||||
user_agent = ua_parse(ua_str)
|
||||
LOG.debug(f"Is mobile? {user_agent.is_mobile}")
|
||||
stats = self._stats()
|
||||
|
||||
if user_agent.is_mobile:
|
||||
html_template = "mobile.html"
|
||||
else:
|
||||
html_template = "index.html"
|
||||
|
||||
# For development
|
||||
# html_template = "mobile.html"
|
||||
|
||||
LOG.debug(f"Template {html_template}")
|
||||
|
||||
transport, aprs_connection = self._get_transport(stats)
|
||||
LOG.debug(f"transport {transport} aprs_connection {aprs_connection}")
|
||||
|
||||
stats["transport"] = transport
|
||||
stats["aprs_connection"] = aprs_connection
|
||||
LOG.debug(f"initial stats = {stats}")
|
||||
|
||||
return flask.render_template(
|
||||
html_template,
|
||||
initial_stats=stats,
|
||||
aprs_connection=aprs_connection,
|
||||
callsign=self.config["aprsd"]["callsign"],
|
||||
version=aprsd.__version__,
|
||||
)
|
||||
|
||||
@auth.login_required
|
||||
def send_message_status(self):
|
||||
LOG.debug(request)
|
||||
msgs = SentMessages()
|
||||
info = msgs.get_all()
|
||||
return json.dumps(info)
|
||||
|
||||
def _stats(self):
|
||||
stats_obj = stats.APRSDStats()
|
||||
now = datetime.datetime.now()
|
||||
|
||||
time_format = "%m-%d-%Y %H:%M:%S"
|
||||
stats_dict = stats_obj.stats()
|
||||
# Webchat doesnt need these
|
||||
del stats_dict["aprsd"]["watch_list"]
|
||||
del stats_dict["aprsd"]["seen_list"]
|
||||
# del stats_dict["email"]
|
||||
# del stats_dict["plugins"]
|
||||
# del stats_dict["messages"]
|
||||
|
||||
result = {
|
||||
"time": now.strftime(time_format),
|
||||
"stats": stats_dict,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def stats(self):
|
||||
return json.dumps(self._stats())
|
||||
|
||||
|
||||
class SendMessageNamespace(Namespace):
|
||||
"""Class to handle the socketio interactions."""
|
||||
_config = None
|
||||
got_ack = False
|
||||
reply_sent = False
|
||||
msg = None
|
||||
request = None
|
||||
|
||||
def __init__(self, namespace=None, config=None):
|
||||
self._config = config
|
||||
super().__init__(namespace)
|
||||
|
||||
def on_connect(self):
|
||||
global socketio
|
||||
LOG.debug("Web socket connected")
|
||||
socketio.emit(
|
||||
"connected", {"data": "/sendmsg Connected"},
|
||||
namespace="/sendmsg",
|
||||
)
|
||||
|
||||
def on_disconnect(self):
|
||||
LOG.debug("WS Disconnected")
|
||||
|
||||
def on_send(self, data):
|
||||
global socketio
|
||||
LOG.debug(f"WS: on_send {data}")
|
||||
self.request = data
|
||||
data["from"] = self._config["aprs"]["login"]
|
||||
msg = messaging.TextMessage(
|
||||
data["from"],
|
||||
data["to"].upper(),
|
||||
data["message"],
|
||||
)
|
||||
self.msg = msg
|
||||
msgs = SentMessages()
|
||||
msgs.add(msg)
|
||||
msgs.set_status(msg.id, "Sending")
|
||||
obj = msgs.get(self.msg.id)
|
||||
socketio.emit(
|
||||
"sent", obj,
|
||||
namespace="/sendmsg",
|
||||
)
|
||||
msg.send()
|
||||
|
||||
def on_gps(self, data):
|
||||
LOG.debug(f"WS on_GPS: {data}")
|
||||
lat = aprslib_util.latitude_to_ddm(data["latitude"])
|
||||
long = aprslib_util.longitude_to_ddm(data["longitude"])
|
||||
LOG.debug(f"Lat DDM {lat}")
|
||||
LOG.debug(f"Long DDM {long}")
|
||||
|
||||
local_datetime = datetime.datetime.now()
|
||||
utc_offset_timedelta = datetime.datetime.utcnow() - local_datetime
|
||||
result_utc_datetime = local_datetime + utc_offset_timedelta
|
||||
time_zulu = result_utc_datetime.strftime("%d%H%M")
|
||||
|
||||
# now construct a beacon to send over the client connection
|
||||
txt = (
|
||||
f"{self._config['aprs']['login']}>APZ100,WIDE2-1"
|
||||
f":@{time_zulu}z{lat}/{long}l APRSD WebChat Beacon"
|
||||
)
|
||||
|
||||
beacon_msg = messaging.RawMessage(txt)
|
||||
beacon_msg.fromcall = self._config["aprs"]["login"]
|
||||
beacon_msg.tocall = "APDW16"
|
||||
beacon_msg.send_direct()
|
||||
|
||||
def handle_message(self, data):
|
||||
LOG.debug(f"WS Data {data}")
|
||||
|
||||
def handle_json(self, data):
|
||||
LOG.debug(f"WS json {data}")
|
||||
|
||||
|
||||
def setup_logging(config, flask_app, loglevel, quiet):
|
||||
flask_log = logging.getLogger("werkzeug")
|
||||
flask_app.logger.removeHandler(default_handler)
|
||||
flask_log.removeHandler(default_handler)
|
||||
|
||||
log_level = aprsd_config.LOG_LEVELS[loglevel]
|
||||
flask_log.setLevel(log_level)
|
||||
date_format = config["aprsd"].get(
|
||||
"dateformat",
|
||||
aprsd_config.DEFAULT_DATE_FORMAT,
|
||||
)
|
||||
|
||||
if not config["aprsd"]["web"].get("logging_enabled", False):
|
||||
# disable web logging
|
||||
flask_log.disabled = True
|
||||
flask_app.logger.disabled = True
|
||||
# return
|
||||
|
||||
if config["aprsd"].get("rich_logging", False) and not quiet:
|
||||
log_format = "%(message)s"
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
rh = aprsd_logging.APRSDRichHandler(
|
||||
show_thread=True, thread_width=15,
|
||||
rich_tracebacks=True, omit_repeated_times=False,
|
||||
)
|
||||
rh.setFormatter(log_formatter)
|
||||
flask_log.addHandler(rh)
|
||||
|
||||
log_file = config["aprsd"].get("logfile", None)
|
||||
|
||||
if log_file:
|
||||
log_format = config["aprsd"].get(
|
||||
"logformat",
|
||||
aprsd_config.DEFAULT_LOG_FORMAT,
|
||||
)
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
fh = RotatingFileHandler(
|
||||
log_file, maxBytes=(10248576 * 5),
|
||||
backupCount=4,
|
||||
)
|
||||
fh.setFormatter(log_formatter)
|
||||
flask_log.addHandler(fh)
|
||||
|
||||
|
||||
@trace.trace
|
||||
def init_flask(config, loglevel, quiet):
|
||||
global socketio
|
||||
|
||||
flask_app = flask.Flask(
|
||||
"aprsd",
|
||||
static_url_path="/static",
|
||||
static_folder="web/chat/static",
|
||||
template_folder="web/chat/templates",
|
||||
)
|
||||
setup_logging(config, flask_app, loglevel, quiet)
|
||||
server = WebChatFlask()
|
||||
server.set_config(config)
|
||||
flask_app.route("/", methods=["GET"])(server.index)
|
||||
flask_app.route("/stats", methods=["GET"])(server.stats)
|
||||
# flask_app.route("/send-message", methods=["GET"])(server.send_message)
|
||||
flask_app.route("/send-message-status", methods=["GET"])(server.send_message_status)
|
||||
|
||||
socketio = SocketIO(
|
||||
flask_app, logger=False, engineio_logger=False,
|
||||
async_mode="threading",
|
||||
)
|
||||
# async_mode="gevent",
|
||||
# async_mode="eventlet",
|
||||
# import eventlet
|
||||
# eventlet.monkey_patch()
|
||||
|
||||
socketio.on_namespace(
|
||||
SendMessageNamespace(
|
||||
"/sendmsg", config=config,
|
||||
),
|
||||
)
|
||||
return socketio, flask_app
|
||||
|
||||
|
||||
# main() ###
|
||||
@cli.command()
|
||||
@cli_helper.add_options(cli_helper.common_options)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--flush",
|
||||
"flush",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
default=False,
|
||||
help="Flush out all old aged messages on disk.",
|
||||
)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--port",
|
||||
"port",
|
||||
show_default=True,
|
||||
default=80,
|
||||
help="Port to listen to web requests",
|
||||
)
|
||||
@click.pass_context
|
||||
@cli_helper.process_standard_options
|
||||
def webchat(ctx, flush, port):
|
||||
"""Web based HAM Radio chat program!"""
|
||||
ctx.obj["config_file"]
|
||||
loglevel = ctx.obj["loglevel"]
|
||||
quiet = ctx.obj["quiet"]
|
||||
config = ctx.obj["config"]
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
if not quiet:
|
||||
click.echo("Load config")
|
||||
|
||||
level, msg = utils._check_version()
|
||||
if level:
|
||||
LOG.warning(msg)
|
||||
else:
|
||||
LOG.info(msg)
|
||||
LOG.info(f"APRSD Started version: {aprsd.__version__}")
|
||||
|
||||
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(f"{x} = XXXXXXXXXXXXXXXXXXX")
|
||||
else:
|
||||
LOG.info(f"{x} = {flat_config[x]}")
|
||||
|
||||
stats.APRSDStats(config)
|
||||
|
||||
# Initialize the client factory and create
|
||||
# The correct client object ready for use
|
||||
client.ClientFactory.setup(config)
|
||||
# Make sure we have 1 client transport enabled
|
||||
if not client.factory.is_client_enabled():
|
||||
LOG.error("No Clients are enabled in config.")
|
||||
sys.exit(-1)
|
||||
|
||||
if not client.factory.is_client_configured():
|
||||
LOG.error("APRS client is not properly configured in config file.")
|
||||
sys.exit(-1)
|
||||
|
||||
packets.PacketList(config=config)
|
||||
messaging.MsgTrack(config=config)
|
||||
packets.WatchList(config=config)
|
||||
packets.SeenList(config=config)
|
||||
|
||||
(socketio, app) = init_flask(config, loglevel, quiet)
|
||||
rx_thread = WebChatRXThread(
|
||||
config=config,
|
||||
socketio=socketio,
|
||||
)
|
||||
LOG.info("Start RX Thread")
|
||||
rx_thread.start()
|
||||
|
||||
keepalive = threads.KeepAliveThread(config=config)
|
||||
LOG.info("Start KeepAliveThread")
|
||||
keepalive.start()
|
||||
LOG.info("Start socketio.run()")
|
||||
socketio.run(
|
||||
app,
|
||||
ssl_context="adhoc",
|
||||
host=config["aprsd"]["web"]["host"],
|
||||
port=port,
|
||||
)
|
||||
|
||||
LOG.info("WebChat exiting!!!! Bye.")
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
import click
|
||||
import yaml
|
||||
|
||||
from aprsd import utils
|
||||
from aprsd import exception, utils
|
||||
|
||||
|
||||
home = str(Path.home())
|
||||
@@ -57,6 +57,7 @@ DEFAULT_CONFIG_DICT = {
|
||||
"ham": {"callsign": "NOCALL"},
|
||||
"aprs": {
|
||||
"enabled": True,
|
||||
# Only used as the login for aprsis.
|
||||
"login": "CALLSIGN",
|
||||
"password": "00000",
|
||||
"host": "rotate.aprs2.net",
|
||||
@@ -75,10 +76,14 @@ DEFAULT_CONFIG_DICT = {
|
||||
},
|
||||
},
|
||||
"aprsd": {
|
||||
# Callsign to use for all packets to/from aprsd instance
|
||||
# regardless of the client (aprsis vs kiss)
|
||||
"callsign": "NOCALL",
|
||||
"logfile": "/tmp/aprsd.log",
|
||||
"logformat": DEFAULT_LOG_FORMAT,
|
||||
"dateformat": DEFAULT_DATE_FORMAT,
|
||||
"save_location": DEFAULT_CONFIG_DIR,
|
||||
"rich_logging": True,
|
||||
"trace": False,
|
||||
"enabled_plugins": CORE_MESSAGE_PLUGINS,
|
||||
"units": "imperial",
|
||||
@@ -173,25 +178,37 @@ class Config(collections.UserDict):
|
||||
def check_option(self, path, default_fail=None):
|
||||
"""Make sure the config option doesn't have default value."""
|
||||
if not self.exists(path):
|
||||
raise Exception(
|
||||
"Option '{}' was not in config file".format(
|
||||
path,
|
||||
),
|
||||
)
|
||||
if type(path) is list:
|
||||
path = ".".join(path)
|
||||
raise exception.MissingConfigOptionException(path)
|
||||
|
||||
val = self.get(path)
|
||||
if val == default_fail:
|
||||
# We have to fail and bail if the user hasn't edited
|
||||
# this config option.
|
||||
raise Exception(
|
||||
"Config file needs to be changed from provided"
|
||||
" defaults for '{}'".format(
|
||||
path,
|
||||
),
|
||||
raise exception.ConfigOptionBogusDefaultException(
|
||||
path, default_fail,
|
||||
)
|
||||
|
||||
|
||||
def add_config_comments(raw_yaml):
|
||||
end_idx = utils.end_substr(raw_yaml, "ham:")
|
||||
if end_idx != -1:
|
||||
# lets insert a comment
|
||||
raw_yaml = utils.insert_str(
|
||||
raw_yaml,
|
||||
"\n # Callsign that owns this instance of APRSD.",
|
||||
end_idx,
|
||||
)
|
||||
end_idx = utils.end_substr(raw_yaml, "aprsd:")
|
||||
if end_idx != -1:
|
||||
# lets insert a comment
|
||||
raw_yaml = utils.insert_str(
|
||||
raw_yaml,
|
||||
"\n # Callsign to use for all APRSD Packets as the to/from."
|
||||
"\n # regardless of client type (aprsis vs tcpkiss vs serial)",
|
||||
end_idx,
|
||||
)
|
||||
end_idx = utils.end_substr(raw_yaml, "aprs:")
|
||||
if end_idx != -1:
|
||||
# lets insert a comment
|
||||
@@ -327,21 +344,14 @@ def parse_config(config_file):
|
||||
"ham.callsign",
|
||||
default_fail=DEFAULT_CONFIG_DICT["ham"]["callsign"],
|
||||
)
|
||||
|
||||
check_option(
|
||||
config,
|
||||
["services", "aprs.fi", "apiKey"],
|
||||
default_fail=DEFAULT_CONFIG_DICT["services"]["aprs.fi"]["apiKey"],
|
||||
["aprsd"],
|
||||
)
|
||||
check_option(
|
||||
config,
|
||||
"aprs.login",
|
||||
default_fail=DEFAULT_CONFIG_DICT["aprs"]["login"],
|
||||
)
|
||||
check_option(
|
||||
config,
|
||||
["aprs", "password"],
|
||||
default_fail=DEFAULT_CONFIG_DICT["aprs"]["password"],
|
||||
"aprsd.callsign",
|
||||
default_fail=DEFAULT_CONFIG_DICT["aprsd"]["callsign"],
|
||||
)
|
||||
|
||||
# Ensure they change the admin password
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
import click
|
||||
import click_completion
|
||||
|
||||
# local imports here
|
||||
import aprsd
|
||||
from aprsd import client
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd import plugin
|
||||
|
||||
|
||||
# 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(
|
||||
f"{k:<12} {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(f"{shell} completion installed in {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 = aprsd_config.LOG_LEVELS[loglevel]
|
||||
LOG.setLevel(log_level)
|
||||
log_format = config["aprsd"].get(
|
||||
"logformat",
|
||||
aprsd_config.DEFAULT_LOG_FORMAT,
|
||||
)
|
||||
date_format = config["aprsd"].get(
|
||||
"dateformat",
|
||||
aprsd_config.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)
|
||||
|
||||
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=aprsd_config.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.option(
|
||||
"-a",
|
||||
"--all",
|
||||
"load_all",
|
||||
show_default=True,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Load all the plugins in config?",
|
||||
)
|
||||
@click.option(
|
||||
"-n",
|
||||
"--num",
|
||||
"number",
|
||||
show_default=True,
|
||||
default=1,
|
||||
help="Number of times to call the plugin",
|
||||
)
|
||||
@click.argument("fromcall")
|
||||
@click.argument("message", nargs=-1, required=True)
|
||||
def test_plugin(
|
||||
loglevel,
|
||||
config_file,
|
||||
plugin_path,
|
||||
load_all,
|
||||
number,
|
||||
fromcall,
|
||||
message,
|
||||
):
|
||||
"""APRSD Plugin test app."""
|
||||
|
||||
config = aprsd_config.parse_config(config_file)
|
||||
setup_logging(config, loglevel, False)
|
||||
|
||||
LOG.info(f"Test APRSD Plgin version: {aprsd.__version__}")
|
||||
if type(message) is tuple:
|
||||
message = " ".join(message)
|
||||
client.Client(config)
|
||||
|
||||
pm = plugin.PluginManager(config)
|
||||
if load_all:
|
||||
pm.setup_plugins()
|
||||
else:
|
||||
pm._init()
|
||||
obj = pm._create_class(plugin_path, plugin.APRSDPluginBase, config=config)
|
||||
# Register the plugin they wanted tested.
|
||||
LOG.info(
|
||||
"Testing plugin {} Version {}".format(
|
||||
obj.__class__, obj.version,
|
||||
),
|
||||
)
|
||||
pm._pluggy_pm.register(obj)
|
||||
login = config["aprs"]["login"]
|
||||
|
||||
packet = {
|
||||
"from": fromcall, "addresse": login,
|
||||
"message_text": message,
|
||||
"format": "message",
|
||||
"msgNo": 1,
|
||||
}
|
||||
LOG.info(f"P'{plugin_path}' F'{fromcall}' C'{message}'")
|
||||
|
||||
for x in range(number):
|
||||
reply = pm.run(packet)
|
||||
# Plugin might have threads, so lets stop them so we can exit.
|
||||
# obj.stop_threads()
|
||||
LOG.info(f"Result{x} = '{reply}'")
|
||||
pm.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
class MissingConfigOptionException(Exception):
|
||||
"""Missing a config option."""
|
||||
def __init__(self, config_option):
|
||||
self.message = f"Option '{config_option}' was not in config file"
|
||||
|
||||
|
||||
class ConfigOptionBogusDefaultException(Exception):
|
||||
"""Missing a config option."""
|
||||
def __init__(self, config_option, default_fail):
|
||||
self.message = (
|
||||
f"Config file option '{config_option}' needs to be "
|
||||
f"changed from provided default of '{default_fail}'"
|
||||
)
|
||||
@@ -1,84 +0,0 @@
|
||||
import argparse
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import socketserver
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aprsd import utils
|
||||
|
||||
|
||||
# command line args
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--loglevel",
|
||||
default="DEBUG",
|
||||
choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"],
|
||||
help="The log level to use for aprsd.log",
|
||||
)
|
||||
parser.add_argument("--quiet", action="store_true", help="Don't log to stdout")
|
||||
|
||||
parser.add_argument("--port", default=9099, type=int, help="The port to listen on .")
|
||||
parser.add_argument("--ip", default="127.0.0.1", help="The IP to listen on ")
|
||||
|
||||
CONFIG = None
|
||||
LOG = logging.getLogger("ARPSSERVER")
|
||||
|
||||
|
||||
# Setup the logging faciility
|
||||
# to disable logging to stdout, but still log to file
|
||||
# use the --quiet option on the cmdln
|
||||
def setup_logging(args):
|
||||
global LOG
|
||||
levels = {
|
||||
"CRITICAL": logging.CRITICAL,
|
||||
"ERROR": logging.ERROR,
|
||||
"WARNING": logging.WARNING,
|
||||
"INFO": logging.INFO,
|
||||
"DEBUG": logging.DEBUG,
|
||||
}
|
||||
log_level = levels[args.loglevel]
|
||||
|
||||
LOG.setLevel(log_level)
|
||||
log_format = "%(asctime)s [%(threadName)-12.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)
|
||||
fh = RotatingFileHandler("aprs-server.log", maxBytes=(10248576 * 5), backupCount=4)
|
||||
fh.setFormatter(log_formatter)
|
||||
LOG.addHandler(fh)
|
||||
|
||||
if not args.quiet:
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(log_formatter)
|
||||
LOG.addHandler(sh)
|
||||
|
||||
|
||||
class MyAPRSTCPHandler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
# self.request is the TCP socket connected to the client
|
||||
self.data = self.request.recv(1024).strip()
|
||||
LOG.debug(f"{self.client_address[0]} wrote:")
|
||||
LOG.debug(self.data)
|
||||
# just send back the same data, but upper-cased
|
||||
self.request.sendall(self.data.upper())
|
||||
|
||||
|
||||
def main():
|
||||
global CONFIG
|
||||
args = parser.parse_args()
|
||||
setup_logging(args)
|
||||
LOG.info("Test APRS server starting.")
|
||||
time.sleep(1)
|
||||
|
||||
CONFIG = utils.parse_config(args)
|
||||
|
||||
ip = CONFIG["aprs"]["host"]
|
||||
port = CONFIG["aprs"]["port"]
|
||||
LOG.info(f"Start server listening on {args.ip}:{args.port}")
|
||||
|
||||
with socketserver.TCPServer((ip, port), MyAPRSTCPHandler) as server:
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,9 +1,7 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from logging import NullHandler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
@@ -11,6 +9,7 @@ import aprslib
|
||||
from aprslib.exceptions import LoginError
|
||||
import flask
|
||||
from flask import request
|
||||
from flask.logging import default_handler
|
||||
import flask_classful
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from flask_socketio import Namespace, SocketIO
|
||||
@@ -21,6 +20,8 @@ from aprsd import client
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd import messaging, packets, plugin, stats, threads, utils
|
||||
from aprsd.clients import aprsis
|
||||
from aprsd.logging import log
|
||||
from aprsd.logging import rich as aprsd_logging
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -500,7 +501,7 @@ class LogMonitorThread(threads.APRSDThread):
|
||||
def loop(self):
|
||||
global socketio
|
||||
try:
|
||||
record = threads.logging_queue.get(block=True, timeout=5)
|
||||
record = log.logging_queue.get(block=True, timeout=5)
|
||||
json_record = self.json_record(record)
|
||||
socketio.emit(
|
||||
"log_entry", json_record,
|
||||
@@ -552,6 +553,15 @@ class LoggingNamespace(Namespace):
|
||||
|
||||
def setup_logging(config, flask_app, loglevel, quiet):
|
||||
flask_log = logging.getLogger("werkzeug")
|
||||
flask_app.logger.removeHandler(default_handler)
|
||||
flask_log.removeHandler(default_handler)
|
||||
|
||||
log_level = aprsd_config.LOG_LEVELS[loglevel]
|
||||
flask_log.setLevel(log_level)
|
||||
date_format = config["aprsd"].get(
|
||||
"dateformat",
|
||||
aprsd_config.DEFAULT_DATE_FORMAT,
|
||||
)
|
||||
|
||||
if not config["aprsd"]["web"].get("logging_enabled", False):
|
||||
# disable web logging
|
||||
@@ -559,28 +569,30 @@ def setup_logging(config, flask_app, loglevel, quiet):
|
||||
flask_app.logger.disabled = True
|
||||
return
|
||||
|
||||
log_level = aprsd_config.LOG_LEVELS[loglevel]
|
||||
LOG.setLevel(log_level)
|
||||
log_format = config["aprsd"].get("logformat", aprsd_config.DEFAULT_LOG_FORMAT)
|
||||
date_format = config["aprsd"].get("dateformat", aprsd_config.DEFAULT_DATE_FORMAT)
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
if config["aprsd"].get("rich_logging", False) and not quiet:
|
||||
log_format = "%(message)s"
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
rh = aprsd_logging.APRSDRichHandler(
|
||||
show_thread=True, thread_width=15,
|
||||
rich_tracebacks=True, omit_repeated_times=False,
|
||||
)
|
||||
rh.setFormatter(log_formatter)
|
||||
flask_log.addHandler(rh)
|
||||
|
||||
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)
|
||||
log_format = config["aprsd"].get(
|
||||
"logformat",
|
||||
aprsd_config.DEFAULT_LOG_FORMAT,
|
||||
)
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
fh = RotatingFileHandler(
|
||||
log_file, maxBytes=(10248576 * 5),
|
||||
backupCount=4,
|
||||
)
|
||||
fh.setFormatter(log_formatter)
|
||||
flask_log.addHandler(fh)
|
||||
|
||||
|
||||
def init_flask(config, loglevel, quiet):
|
||||
@@ -589,8 +601,8 @@ def init_flask(config, loglevel, quiet):
|
||||
flask_app = flask.Flask(
|
||||
"aprsd",
|
||||
static_url_path="/static",
|
||||
static_folder="web/static",
|
||||
template_folder="web/templates",
|
||||
static_folder="web/admin/static",
|
||||
template_folder="web/admin/templates",
|
||||
)
|
||||
setup_logging(config, flask_app, loglevel, quiet)
|
||||
server = APRSDFlask()
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
import click
|
||||
import click_completion
|
||||
import requests
|
||||
|
||||
# local imports here
|
||||
import aprsd
|
||||
from aprsd import config as aprsd_config
|
||||
|
||||
|
||||
# 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(
|
||||
f"{k:<12} {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(f"{shell} completion installed in {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=aprsd_config.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 = aprsd_config.parse_config(config_file)
|
||||
|
||||
setup_logging(config, loglevel, False)
|
||||
LOG.debug(f"APRSD HealthCheck version: {aprsd.__version__}")
|
||||
|
||||
try:
|
||||
url = health_url
|
||||
response = requests.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
except Exception as ex:
|
||||
LOG.error(f"Failed to fetch healthcheck url '{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(f"Email thread is very old! {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(f"APRS-IS last update is very old! {d}")
|
||||
sys.exit(-1)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,394 +0,0 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
import aprslib
|
||||
from aprslib.exceptions import LoginError
|
||||
import click
|
||||
import click_completion
|
||||
|
||||
# local imports here
|
||||
import aprsd
|
||||
from aprsd import client
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd import messaging, stats, threads, trace, utils
|
||||
|
||||
|
||||
# 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(
|
||||
f"{k:<12} {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(f"{shell} completion installed in {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 = aprsd_config.LOG_LEVELS[loglevel]
|
||||
LOG.setLevel(log_level)
|
||||
log_format = config["aprsd"].get("logformat", aprsd_config.DEFAULT_LOG_FORMAT)
|
||||
date_format = config["aprsd"].get("dateformat", aprsd_config.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=aprsd_config.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 = aprsd_config.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(f"APRSD TEST Started version: {aprsd.__version__}")
|
||||
if type(command) is tuple:
|
||||
command = " ".join(command)
|
||||
if not quiet:
|
||||
if raw:
|
||||
LOG.info(f"L'{aprs_login}' R'{raw}'")
|
||||
else:
|
||||
LOG.info(f"L'{aprs_login}' To'{tocallsign}' C'{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(f"{x} = XXXXXXXXXXXXXXXXXXX")
|
||||
else:
|
||||
LOG.info(f"{x} = {flat_config[x]}")
|
||||
|
||||
got_ack = False
|
||||
got_response = False
|
||||
|
||||
# TODO(walt) - manually edit this list
|
||||
# prior to running aprsd-listen listen
|
||||
watch_list = ["k*"]
|
||||
|
||||
# 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(f"Not old enough to notify {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(f"We saw an ACK {ack_num} Ignoring")
|
||||
# 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(f"Filter by '{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()
|
||||
@@ -0,0 +1,81 @@
|
||||
import logging
|
||||
from logging import NullHandler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import queue
|
||||
import sys
|
||||
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd.logging import rich as aprsd_logging
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
logging_queue = queue.Queue()
|
||||
|
||||
|
||||
# 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 = aprsd_config.LOG_LEVELS[loglevel]
|
||||
LOG.setLevel(log_level)
|
||||
date_format = config["aprsd"].get("dateformat", aprsd_config.DEFAULT_DATE_FORMAT)
|
||||
|
||||
rich_logging = False
|
||||
if config["aprsd"].get("rich_logging", False) and not quiet:
|
||||
log_format = "%(message)s"
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
rh = aprsd_logging.APRSDRichHandler(
|
||||
show_thread=True, thread_width=20,
|
||||
rich_tracebacks=True, omit_repeated_times=False,
|
||||
)
|
||||
rh.setFormatter(log_formatter)
|
||||
LOG.addHandler(rh)
|
||||
rich_logging = True
|
||||
|
||||
log_file = config["aprsd"].get("logfile", None)
|
||||
log_format = config["aprsd"].get("logformat", aprsd_config.DEFAULT_LOG_FORMAT)
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
|
||||
if log_file:
|
||||
fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4)
|
||||
fh.setFormatter(log_formatter)
|
||||
LOG.addHandler(fh)
|
||||
|
||||
imap_logger = None
|
||||
if config.get("aprsd.email.enabled", default=False) and config.get("aprsd.email.imap.debug", default=False):
|
||||
imap_logger = logging.getLogger("imapclient.imaplib")
|
||||
imap_logger.setLevel(log_level)
|
||||
imap_logger.addHandler(fh)
|
||||
|
||||
if config.get("aprsd.web.enabled", default=False):
|
||||
qh = logging.handlers.QueueHandler(logging_queue)
|
||||
q_log_formatter = logging.Formatter(
|
||||
fmt=aprsd_config.QUEUE_LOG_FORMAT,
|
||||
datefmt=aprsd_config.QUEUE_DATE_FORMAT,
|
||||
)
|
||||
qh.setFormatter(q_log_formatter)
|
||||
LOG.addHandler(qh)
|
||||
|
||||
if not quiet and not rich_logging:
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(log_formatter)
|
||||
LOG.addHandler(sh)
|
||||
if imap_logger:
|
||||
imap_logger.addHandler(sh)
|
||||
|
||||
|
||||
def setup_logging_no_config(loglevel, quiet):
|
||||
log_level = aprsd_config.LOG_LEVELS[loglevel]
|
||||
LOG.setLevel(log_level)
|
||||
log_format = aprsd_config.DEFAULT_LOG_FORMAT
|
||||
date_format = aprsd_config.DEFAULT_DATE_FORMAT
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
fh = NullHandler()
|
||||
|
||||
fh.setFormatter(log_formatter)
|
||||
LOG.addHandler(fh)
|
||||
|
||||
if not quiet:
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(log_formatter)
|
||||
LOG.addHandler(sh)
|
||||
@@ -0,0 +1,160 @@
|
||||
from datetime import datetime
|
||||
from logging import LogRecord
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, Union
|
||||
|
||||
from rich._log_render import LogRender
|
||||
from rich.logging import RichHandler
|
||||
from rich.text import Text, TextType
|
||||
from rich.traceback import Traceback
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import Console, ConsoleRenderable, RenderableType
|
||||
from rich.table import Table
|
||||
|
||||
from aprsd import utils
|
||||
|
||||
|
||||
FormatTimeCallable = Callable[[datetime], Text]
|
||||
|
||||
|
||||
class APRSDRichLogRender(LogRender):
|
||||
|
||||
def __init__(
|
||||
self, *args,
|
||||
show_thread: bool = False,
|
||||
thread_width: Optional[int] = 10,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.show_thread = show_thread
|
||||
self.thread_width = thread_width
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
console: "Console",
|
||||
renderables: Iterable["ConsoleRenderable"],
|
||||
log_time: Optional[datetime] = None,
|
||||
time_format: Optional[Union[str, FormatTimeCallable]] = None,
|
||||
level: TextType = "",
|
||||
path: Optional[str] = None,
|
||||
line_no: Optional[int] = None,
|
||||
link_path: Optional[str] = None,
|
||||
thread_name: Optional[str] = None,
|
||||
) -> "Table":
|
||||
from rich.containers import Renderables
|
||||
from rich.table import Table
|
||||
|
||||
output = Table.grid(padding=(0, 1))
|
||||
output.expand = True
|
||||
if self.show_time:
|
||||
output.add_column(style="log.time")
|
||||
if self.show_thread:
|
||||
rgb = str(utils.rgb_from_name(thread_name)).replace(" ", "")
|
||||
output.add_column(style=f"rgb{rgb}", width=self.thread_width)
|
||||
if self.show_level:
|
||||
output.add_column(style="log.level", width=self.level_width)
|
||||
output.add_column(ratio=1, style="log.message", overflow="fold")
|
||||
if self.show_path and path:
|
||||
output.add_column(style="log.path")
|
||||
row: List["RenderableType"] = []
|
||||
if self.show_time:
|
||||
log_time = log_time or console.get_datetime()
|
||||
time_format = time_format or self.time_format
|
||||
if callable(time_format):
|
||||
log_time_display = time_format(log_time)
|
||||
else:
|
||||
log_time_display = Text(log_time.strftime(time_format))
|
||||
if log_time_display == self._last_time and self.omit_repeated_times:
|
||||
row.append(Text(" " * len(log_time_display)))
|
||||
else:
|
||||
row.append(log_time_display)
|
||||
self._last_time = log_time_display
|
||||
if self.show_thread:
|
||||
row.append(thread_name)
|
||||
if self.show_level:
|
||||
row.append(level)
|
||||
|
||||
row.append(Renderables(renderables))
|
||||
if self.show_path and path:
|
||||
path_text = Text()
|
||||
path_text.append(
|
||||
path, style=f"link file://{link_path}" if link_path else "",
|
||||
)
|
||||
if line_no:
|
||||
path_text.append(":")
|
||||
path_text.append(
|
||||
f"{line_no}",
|
||||
style=f"link file://{link_path}#{line_no}" if link_path else "",
|
||||
)
|
||||
row.append(path_text)
|
||||
|
||||
output.add_row(*row)
|
||||
return output
|
||||
|
||||
|
||||
class APRSDRichHandler(RichHandler):
|
||||
"""APRSD's extension of rich's RichHandler to show threads.
|
||||
|
||||
show_thread (bool, optional): Show the name of the thread in log entry. Defaults to False.
|
||||
thread_width (int, optional): The number of characters to show for thread name. Defaults to 10.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *args,
|
||||
show_thread: bool = True,
|
||||
thread_width: Optional[int] = 10,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.show_thread = show_thread
|
||||
self.thread_width = thread_width
|
||||
kwargs["show_thread"] = show_thread
|
||||
kwargs["thread_width"] = thread_width
|
||||
self._log_render = APRSDRichLogRender(
|
||||
show_time=True,
|
||||
show_level=True,
|
||||
show_path=True,
|
||||
omit_repeated_times=False,
|
||||
level_width=None,
|
||||
show_thread=show_thread,
|
||||
thread_width=thread_width,
|
||||
)
|
||||
|
||||
def render(
|
||||
self, *, record: LogRecord,
|
||||
traceback: Optional[Traceback],
|
||||
message_renderable: "ConsoleRenderable",
|
||||
) -> "ConsoleRenderable":
|
||||
"""Render log for display.
|
||||
|
||||
Args:
|
||||
record (LogRecord): logging Record.
|
||||
traceback (Optional[Traceback]): Traceback instance or None for no Traceback.
|
||||
message_renderable (ConsoleRenderable): Renderable (typically Text) containing log message contents.
|
||||
|
||||
Returns:
|
||||
ConsoleRenderable: Renderable to display log.
|
||||
"""
|
||||
path = Path(record.pathname).name
|
||||
level = self.get_level_text(record)
|
||||
time_format = None if self.formatter is None else self.formatter.datefmt
|
||||
log_time = datetime.fromtimestamp(record.created)
|
||||
thread_name = record.threadName
|
||||
|
||||
log_renderable = self._log_render(
|
||||
self.console,
|
||||
[message_renderable] if not traceback else [
|
||||
message_renderable,
|
||||
traceback,
|
||||
],
|
||||
log_time=log_time,
|
||||
time_format=time_format,
|
||||
level=level,
|
||||
path=path,
|
||||
line_no=record.lineno,
|
||||
link_path=record.pathname if self.enable_link_path else None,
|
||||
thread_name=thread_name,
|
||||
)
|
||||
return log_renderable
|
||||
@@ -1,520 +0,0 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
import aprslib
|
||||
from aprslib.exceptions import LoginError
|
||||
import click
|
||||
import click_completion
|
||||
|
||||
# local imports here
|
||||
import aprsd
|
||||
from aprsd import (
|
||||
flask, messaging, packets, plugin, stats, threads, trace, utils,
|
||||
)
|
||||
from aprsd import client
|
||||
from aprsd import config as aprsd_config
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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(
|
||||
f"{k:<12} {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(f"{shell} completion installed in {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(1.5)
|
||||
messaging.MsgTrack().save()
|
||||
packets.WatchList().save()
|
||||
packets.SeenList().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 = aprsd_config.LOG_LEVELS[loglevel]
|
||||
LOG.setLevel(log_level)
|
||||
log_format = config["aprsd"].get("logformat", aprsd_config.DEFAULT_LOG_FORMAT)
|
||||
date_format = config["aprsd"].get("dateformat", aprsd_config.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.get("aprsd.email.enabled", default=False) and config.get("aprsd.email.imap.debug", default=False):
|
||||
|
||||
imap_logger = logging.getLogger("imapclient.imaplib")
|
||||
imap_logger.setLevel(log_level)
|
||||
imap_logger.addHandler(fh)
|
||||
|
||||
if config.get("aprsd.web.enabled", default=False):
|
||||
qh = logging.handlers.QueueHandler(threads.logging_queue)
|
||||
q_log_formatter = logging.Formatter(
|
||||
fmt=aprsd_config.QUEUE_LOG_FORMAT,
|
||||
datefmt=aprsd_config.QUEUE_DATE_FORMAT,
|
||||
)
|
||||
qh.setFormatter(q_log_formatter)
|
||||
LOG.addHandler(qh)
|
||||
|
||||
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=aprsd_config.DEFAULT_CONFIG_FILE,
|
||||
help="The aprsd config file to use for options.",
|
||||
)
|
||||
def check_version(loglevel, config_file):
|
||||
config = aprsd_config.parse_config(config_file)
|
||||
|
||||
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(aprsd_config.dump_default_cfg())
|
||||
|
||||
|
||||
@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=aprsd_config.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 send_message(
|
||||
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 = aprsd_config.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(f"APRSD Started version: {aprsd.__version__}")
|
||||
if type(command) is tuple:
|
||||
command = " ".join(command)
|
||||
if not quiet:
|
||||
if raw:
|
||||
LOG.info(f"L'{aprs_login}' R'{raw}'")
|
||||
else:
|
||||
LOG.info(f"L'{aprs_login}' To'{tocallsign}' C'{command}'")
|
||||
|
||||
got_ack = False
|
||||
got_response = False
|
||||
|
||||
def rx_packet(packet):
|
||||
global got_ack, got_response
|
||||
# LOG.debug("Got packet back {}".format(packet))
|
||||
resp = packet.get("response", None)
|
||||
if resp == "ack":
|
||||
ack_num = packet.get("msgNo")
|
||||
LOG.info(f"We got ack for our sent message {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,
|
||||
)
|
||||
got_response = True
|
||||
# Send the ack back?
|
||||
ack = messaging.AckMessage(
|
||||
config["aprs"]["login"],
|
||||
fromcall,
|
||||
msg_id=msg_number,
|
||||
)
|
||||
ack.send_direct()
|
||||
|
||||
if got_ack and got_response:
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
client.ClientFactory.setup(config)
|
||||
client.factory.create().client
|
||||
except LoginError:
|
||||
sys.exit(-1)
|
||||
|
||||
packets.PacketList(config=config)
|
||||
packets.WatchList(config=config)
|
||||
|
||||
# Send a message
|
||||
# then we setup a consumer to rx messages
|
||||
# We should get an ack back as well as a new message
|
||||
# we should bail after we get the ack and send an ack back for the
|
||||
# message
|
||||
if raw:
|
||||
msg = messaging.RawMessage(raw)
|
||||
msg.send_direct()
|
||||
sys.exit(0)
|
||||
else:
|
||||
msg = messaging.TextMessage(aprs_login, tocallsign, command)
|
||||
msg.send_direct()
|
||||
|
||||
if no_ack:
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
# This will register a packet consumer with aprslib
|
||||
# When new packets come in the consumer will process
|
||||
# the packet
|
||||
aprs_client = client.factory.create().client
|
||||
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
|
||||
aprs_client.reset()
|
||||
|
||||
|
||||
# main() ###
|
||||
@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("--quiet", is_flag=True, default=False, help="Don't log to stdout")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
show_default=True,
|
||||
default=aprsd_config.DEFAULT_CONFIG_FILE,
|
||||
help="The aprsd config file to use for options.",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--flush",
|
||||
"flush",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
default=False,
|
||||
help="Flush out all old aged messages on disk.",
|
||||
)
|
||||
def server(
|
||||
loglevel,
|
||||
quiet,
|
||||
config_file,
|
||||
flush,
|
||||
):
|
||||
"""Start the aprsd server process."""
|
||||
global flask_enabled
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
if not quiet:
|
||||
click.echo("Load config")
|
||||
|
||||
config = aprsd_config.parse_config(config_file)
|
||||
|
||||
setup_logging(config, loglevel, quiet)
|
||||
level, msg = utils._check_version()
|
||||
if level:
|
||||
LOG.warning(msg)
|
||||
else:
|
||||
LOG.info(msg)
|
||||
LOG.info(f"APRSD Started version: {aprsd.__version__}")
|
||||
|
||||
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(f"{x} = XXXXXXXXXXXXXXXXXXX")
|
||||
else:
|
||||
LOG.info(f"{x} = {flat_config[x]}")
|
||||
|
||||
if config["aprsd"].get("trace", False):
|
||||
trace.setup_tracing(["method", "api"])
|
||||
stats.APRSDStats(config)
|
||||
|
||||
# Initialize the client factory and create
|
||||
# The correct client object ready for use
|
||||
client.ClientFactory.setup(config)
|
||||
# Make sure we have 1 client transport enabled
|
||||
if not client.factory.is_client_enabled():
|
||||
LOG.error("No Clients are enabled in config.")
|
||||
sys.exit(-1)
|
||||
|
||||
# Creates the client object
|
||||
LOG.info("Creating client connection")
|
||||
client.factory.create().client
|
||||
|
||||
# Now load the msgTrack from disk if any
|
||||
packets.PacketList(config=config)
|
||||
if flush:
|
||||
LOG.debug("Deleting saved MsgTrack.")
|
||||
messaging.MsgTrack(config=config).flush()
|
||||
packets.WatchList(config=config)
|
||||
packets.SeenList(config=config)
|
||||
else:
|
||||
# Try and load saved MsgTrack list
|
||||
LOG.debug("Loading saved MsgTrack object.")
|
||||
messaging.MsgTrack(config=config).load()
|
||||
packets.WatchList(config=config).load()
|
||||
packets.SeenList(config=config).load()
|
||||
|
||||
# Create the initial PM singleton and Register plugins
|
||||
LOG.info("Loading Plugin Manager and registering plugins")
|
||||
plugin_manager = plugin.PluginManager(config)
|
||||
plugin_manager.setup_plugins()
|
||||
|
||||
rx_thread = threads.APRSDRXThread(
|
||||
msg_queues=threads.msg_queues,
|
||||
config=config,
|
||||
)
|
||||
rx_thread.start()
|
||||
|
||||
messaging.MsgTrack().restart()
|
||||
|
||||
keepalive = threads.KeepAliveThread(config=config)
|
||||
keepalive.start()
|
||||
|
||||
web_enabled = config.get("aprsd.web.enabled", default=False)
|
||||
|
||||
if web_enabled:
|
||||
flask_enabled = True
|
||||
(socketio, app) = flask.init_flask(config, loglevel, quiet)
|
||||
socketio.run(
|
||||
app,
|
||||
host=config["aprsd"]["web"]["host"],
|
||||
port=config["aprsd"]["web"]["port"],
|
||||
)
|
||||
|
||||
# If there are items in the msgTracker, then save them
|
||||
LOG.info("APRSD Exiting.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,7 +6,8 @@ import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aprsd import client, objectstore, packets, stats, threads
|
||||
from aprsd import client, packets, stats, threads
|
||||
from aprsd.utils import objectstore
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -184,12 +185,14 @@ class Message(metaclass=abc.ABCMeta):
|
||||
last_send_attempt = 0
|
||||
|
||||
transport = None
|
||||
_raw_message = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fromcall,
|
||||
tocall,
|
||||
msg_id=None,
|
||||
allow_delay=True,
|
||||
):
|
||||
self.fromcall = fromcall
|
||||
self.tocall = tocall
|
||||
@@ -199,10 +202,31 @@ class Message(metaclass=abc.ABCMeta):
|
||||
msg_id = c.value
|
||||
self.id = msg_id
|
||||
|
||||
# do we try and save this message for later if we don't get
|
||||
# an ack? Some messages we don't want to do this ever.
|
||||
self.allow_delay = allow_delay
|
||||
|
||||
@abc.abstractmethod
|
||||
def send(self):
|
||||
"""Child class must declare."""
|
||||
|
||||
def _filter_for_send(self):
|
||||
"""Filter and format message string for FCC."""
|
||||
# max? ftm400 displays 64, raw msg shows 74
|
||||
# and ftm400-send is max 64. setting this to
|
||||
# 67 displays 64 on the ftm400. (+3 {01 suffix)
|
||||
# feature req: break long ones into two msgs
|
||||
message = self._raw_message[:67]
|
||||
# We all miss George Carlin
|
||||
return re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message)
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return self._filter_for_send().rstrip("\n")
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
|
||||
class RawMessage(Message):
|
||||
"""Send a raw message.
|
||||
@@ -212,11 +236,14 @@ class RawMessage(Message):
|
||||
|
||||
"""
|
||||
|
||||
message = None
|
||||
last_send_age = last_send_time = None
|
||||
|
||||
def __init__(self, message):
|
||||
super().__init__(None, None, msg_id=None)
|
||||
self.message = message
|
||||
def __init__(self, message, allow_delay=True):
|
||||
super().__init__(
|
||||
fromcall=None, tocall=None, msg_id=None,
|
||||
allow_delay=allow_delay,
|
||||
)
|
||||
self._raw_message = message
|
||||
|
||||
def dict(self):
|
||||
now = datetime.datetime.now()
|
||||
@@ -225,17 +252,14 @@ class RawMessage(Message):
|
||||
last_send_age = str(now - self.last_send_time)
|
||||
return {
|
||||
"type": "raw",
|
||||
"message": self.message.rstrip("\n"),
|
||||
"raw": self.message.rstrip("\n"),
|
||||
"message": self.message,
|
||||
"raw": str(self),
|
||||
"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
|
||||
|
||||
def send(self):
|
||||
tracker = MsgTrack()
|
||||
tracker.add(self)
|
||||
@@ -247,7 +271,7 @@ class RawMessage(Message):
|
||||
cl = client.factory.create().client
|
||||
log_message(
|
||||
"Sending Message Direct",
|
||||
str(self).rstrip("\n"),
|
||||
str(self),
|
||||
self.message,
|
||||
tocall=self.tocall,
|
||||
fromcall=self.fromcall,
|
||||
@@ -259,21 +283,17 @@ class RawMessage(Message):
|
||||
class TextMessage(Message):
|
||||
"""Send regular ARPS text/command messages/replies."""
|
||||
|
||||
message = None
|
||||
last_send_time = last_send_age = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fromcall,
|
||||
tocall,
|
||||
message,
|
||||
msg_id=None,
|
||||
allow_delay=True,
|
||||
self, fromcall, tocall, message,
|
||||
msg_id=None, allow_delay=True,
|
||||
):
|
||||
super().__init__(fromcall, tocall, msg_id)
|
||||
self.message = message
|
||||
# do we try and save this message for later if we don't get
|
||||
# an ack? Some messages we don't want to do this ever.
|
||||
self.allow_delay = allow_delay
|
||||
super().__init__(
|
||||
fromcall=fromcall, tocall=tocall,
|
||||
msg_id=msg_id, allow_delay=allow_delay,
|
||||
)
|
||||
self._raw_message = message
|
||||
|
||||
def dict(self):
|
||||
now = datetime.datetime.now()
|
||||
@@ -287,8 +307,8 @@ class TextMessage(Message):
|
||||
"type": "text-message",
|
||||
"fromcall": self.fromcall,
|
||||
"tocall": self.tocall,
|
||||
"message": self.message.rstrip("\n"),
|
||||
"raw": str(self).rstrip("\n"),
|
||||
"message": self.message,
|
||||
"raw": str(self),
|
||||
"retry_count": self.retry_count,
|
||||
"last_send_attempt": self.last_send_attempt,
|
||||
"last_send_time": str(self.last_send_time),
|
||||
@@ -300,20 +320,10 @@ class TextMessage(Message):
|
||||
return "{}>APZ100::{}:{}{{{}\n".format(
|
||||
self.fromcall,
|
||||
self.tocall.ljust(9),
|
||||
self._filter_for_send(),
|
||||
self.message,
|
||||
str(self.id),
|
||||
)
|
||||
|
||||
def _filter_for_send(self):
|
||||
"""Filter and format message string for FCC."""
|
||||
# max? ftm400 displays 64, raw msg shows 74
|
||||
# and ftm400-send is max 64. setting this to
|
||||
# 67 displays 64 on the ftm400. (+3 {01 suffix)
|
||||
# feature req: break long ones into two msgs
|
||||
message = self.message[:67]
|
||||
# We all miss George Carlin
|
||||
return re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message)
|
||||
|
||||
def send(self):
|
||||
tracker = MsgTrack()
|
||||
tracker.add(self)
|
||||
@@ -329,7 +339,7 @@ class TextMessage(Message):
|
||||
cl = client.factory.create().client
|
||||
log_message(
|
||||
"Sending Message Direct",
|
||||
str(self).rstrip("\n"),
|
||||
str(self),
|
||||
self.message,
|
||||
tocall=self.tocall,
|
||||
fromcall=self.fromcall,
|
||||
@@ -342,8 +352,8 @@ class TextMessage(Message):
|
||||
class SendMessageThread(threads.APRSDThread):
|
||||
def __init__(self, message):
|
||||
self.msg = message
|
||||
name = self.msg.message[:5]
|
||||
super().__init__(f"SendMessage-{self.msg.id}-{name}")
|
||||
name = self.msg._raw_message[:5]
|
||||
super().__init__(f"TXPKT-{self.msg.id}-{name}")
|
||||
|
||||
def loop(self):
|
||||
"""Loop until a message is acked or it gets delayed.
|
||||
@@ -369,6 +379,8 @@ class SendMessageThread(threads.APRSDThread):
|
||||
# we reached the send limit, don't send again
|
||||
# TODO(hemna) - Need to put this in a delayed queue?
|
||||
LOG.info("Message Send Complete. Max attempts reached.")
|
||||
if not msg.allow_delay:
|
||||
tracker.remove(msg.id)
|
||||
return False
|
||||
|
||||
# Message is still outstanding and needs to be acked.
|
||||
@@ -388,7 +400,7 @@ class SendMessageThread(threads.APRSDThread):
|
||||
# tracking the time.
|
||||
log_message(
|
||||
"Sending Message",
|
||||
str(msg).rstrip("\n"),
|
||||
str(msg),
|
||||
msg.message,
|
||||
tocall=self.msg.tocall,
|
||||
retry_number=msg.last_send_attempt,
|
||||
@@ -527,6 +539,7 @@ def log_packet(packet):
|
||||
def log_message(
|
||||
header, raw, message, tocall=None, fromcall=None, msg_num=None,
|
||||
retry_number=None, ack=None, packet_type=None, uuid=None,
|
||||
console=None,
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -542,37 +555,30 @@ def log_message(
|
||||
|
||||
log_list = [""]
|
||||
if retry_number:
|
||||
# LOG.info(" {} _______________(TX:{})".format(header, retry_number))
|
||||
log_list.append(f" {header} _______________(TX:{retry_number})")
|
||||
log_list.append(f"{header} _______________(TX:{retry_number})")
|
||||
else:
|
||||
# LOG.info(" {} _______________".format(header))
|
||||
log_list.append(f" {header} _______________")
|
||||
log_list.append(f"{header} _______________")
|
||||
|
||||
# LOG.info(" Raw : {}".format(raw))
|
||||
log_list.append(f" Raw : {raw}")
|
||||
log_list.append(f" Raw : {raw}")
|
||||
|
||||
if packet_type:
|
||||
# LOG.info(" Packet : {}".format(packet_type))
|
||||
log_list.append(f" Packet : {packet_type}")
|
||||
log_list.append(f" Packet : {packet_type}")
|
||||
if tocall:
|
||||
# LOG.info(" To : {}".format(tocall))
|
||||
log_list.append(f" To : {tocall}")
|
||||
log_list.append(f" To : {tocall}")
|
||||
if fromcall:
|
||||
# LOG.info(" From : {}".format(fromcall))
|
||||
log_list.append(f" From : {fromcall}")
|
||||
log_list.append(f" From : {fromcall}")
|
||||
|
||||
if ack:
|
||||
# LOG.info(" Ack : {}".format(ack))
|
||||
log_list.append(f" Ack : {ack}")
|
||||
log_list.append(f" Ack : {ack}")
|
||||
else:
|
||||
# LOG.info(" Message : {}".format(message))
|
||||
log_list.append(f" Message : {message}")
|
||||
log_list.append(f" Message : {message}")
|
||||
if msg_num:
|
||||
# LOG.info(" Msg number : {}".format(msg_num))
|
||||
log_list.append(f" Msg number : {msg_num}")
|
||||
log_list.append(f" Msg # : {msg_num}")
|
||||
if uuid:
|
||||
log_list.append(f" UUID : {uuid}")
|
||||
# LOG.info(" {} _______________ Complete".format(header))
|
||||
log_list.append(f" {header} _______________ Complete")
|
||||
log_list.append(f" UUID : {uuid}")
|
||||
log_list.append(f"{header} _______________ Complete")
|
||||
|
||||
LOG.info("\n".join(log_list))
|
||||
if console:
|
||||
console.log("\n".join(log_list))
|
||||
else:
|
||||
LOG.info("\n".join(log_list))
|
||||
|
||||
@@ -3,7 +3,10 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aprsd import objectstore, utils
|
||||
import wrapt
|
||||
|
||||
from aprsd import utils
|
||||
from aprsd.utils import objectstore
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -17,6 +20,7 @@ class PacketList:
|
||||
"""Class to track all of the packets rx'd and tx'd by aprsd."""
|
||||
|
||||
_instance = None
|
||||
lock = threading.Lock()
|
||||
config = None
|
||||
|
||||
packet_list = {}
|
||||
@@ -28,7 +32,6 @@ class PacketList:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.packet_list = utils.RingBuffer(1000)
|
||||
cls._instance.lock = threading.Lock()
|
||||
cls._instance.config = kwargs["config"]
|
||||
return cls._instance
|
||||
|
||||
@@ -36,57 +39,58 @@ class PacketList:
|
||||
if config:
|
||||
self.config = config
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def __iter__(self):
|
||||
with self.lock:
|
||||
return iter(self.packet_list)
|
||||
return iter(self.packet_list)
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def add(self, packet):
|
||||
with self.lock:
|
||||
packet["ts"] = time.time()
|
||||
if (
|
||||
"fromcall" in packet
|
||||
and packet["fromcall"] == self.config["aprs"]["login"]
|
||||
):
|
||||
self.total_tx += 1
|
||||
else:
|
||||
self.total_recv += 1
|
||||
self.packet_list.append(packet)
|
||||
SeenList().update_seen(packet)
|
||||
packet["ts"] = time.time()
|
||||
if (
|
||||
"fromcall" in packet
|
||||
and packet["fromcall"] == self.config["aprs"]["login"]
|
||||
):
|
||||
self.total_tx += 1
|
||||
else:
|
||||
self.total_recv += 1
|
||||
self.packet_list.append(packet)
|
||||
SeenList().update_seen(packet)
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def get(self):
|
||||
with self.lock:
|
||||
return self.packet_list.get()
|
||||
return self.packet_list.get()
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def total_received(self):
|
||||
with self.lock:
|
||||
return self.total_recv
|
||||
return self.total_recv
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def total_sent(self):
|
||||
with self.lock:
|
||||
return self.total_tx
|
||||
return self.total_tx
|
||||
|
||||
|
||||
class WatchList(objectstore.ObjectStoreMixin):
|
||||
"""Global watch list and info for callsigns."""
|
||||
|
||||
_instance = None
|
||||
lock = threading.Lock()
|
||||
data = {}
|
||||
config = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.lock = threading.Lock()
|
||||
cls._instance.config = kwargs["config"]
|
||||
if "config" in kwargs:
|
||||
cls._instance.config = kwargs["config"]
|
||||
cls._instance._init_store()
|
||||
cls._instance.data = {}
|
||||
cls._instance._init_store()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, config=None):
|
||||
if config:
|
||||
self.config = config
|
||||
|
||||
ring_size = config["aprsd"]["watch_list"]["packet_keep_count"]
|
||||
ring_size = config["aprsd"]["watch_list"].get("packet_keep_count", 10)
|
||||
|
||||
for callsign in config["aprsd"]["watch_list"].get("callsigns", []):
|
||||
call = callsign.replace("*", "")
|
||||
@@ -110,12 +114,12 @@ class WatchList(objectstore.ObjectStoreMixin):
|
||||
def callsign_in_watchlist(self, callsign):
|
||||
return callsign in self.data
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def update_seen(self, packet):
|
||||
with self.lock:
|
||||
callsign = packet["from"]
|
||||
if self.callsign_in_watchlist(callsign):
|
||||
self.data[callsign]["last"] = datetime.datetime.now()
|
||||
self.data[callsign]["packets"].append(packet)
|
||||
callsign = packet["from"]
|
||||
if self.callsign_in_watchlist(callsign):
|
||||
self.data[callsign]["last"] = datetime.datetime.now()
|
||||
self.data[callsign]["packets"].append(packet)
|
||||
|
||||
def last_seen(self, callsign):
|
||||
if self.callsign_in_watchlist(callsign):
|
||||
@@ -158,18 +162,20 @@ class SeenList(objectstore.ObjectStoreMixin):
|
||||
"""Global callsign seen list."""
|
||||
|
||||
_instance = None
|
||||
lock = threading.Lock()
|
||||
data = {}
|
||||
config = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.lock = threading.Lock()
|
||||
cls._instance.config = kwargs["config"]
|
||||
if "config" in kwargs:
|
||||
cls._instance.config = kwargs["config"]
|
||||
cls._instance._init_store()
|
||||
cls._instance.data = {}
|
||||
cls._instance._init_store()
|
||||
return cls._instance
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def update_seen(self, packet):
|
||||
callsign = None
|
||||
if "fromcall" in packet:
|
||||
|
||||
@@ -12,6 +12,7 @@ import threading
|
||||
import pluggy
|
||||
from thesmuggler import smuggle
|
||||
|
||||
import aprsd
|
||||
from aprsd import client, messaging, packets, threads
|
||||
|
||||
|
||||
@@ -24,7 +25,6 @@ CORE_MESSAGE_PLUGINS = [
|
||||
"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",
|
||||
@@ -52,7 +52,7 @@ class APRSDPluginBase(metaclass=abc.ABCMeta):
|
||||
config = None
|
||||
rx_count = 0
|
||||
tx_count = 0
|
||||
version = "1.0"
|
||||
version = aprsd.__version__
|
||||
|
||||
# Holds the list of APRSDThreads that the plugin creates
|
||||
threads = []
|
||||
@@ -177,7 +177,6 @@ class APRSDWatchListPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta):
|
||||
)
|
||||
if result:
|
||||
self.tx_inc()
|
||||
wl.update_seen(packet)
|
||||
else:
|
||||
LOG.warning(f"{self.__class__} plugin is not enabled")
|
||||
|
||||
@@ -242,11 +241,24 @@ class APRSDRegexCommandPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta):
|
||||
if result:
|
||||
self.tx_inc()
|
||||
else:
|
||||
LOG.warning(f"{self.__class__} isn't enabled.")
|
||||
result = f"{self.__class__.__name__} isn't enabled"
|
||||
LOG.warning(result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class APRSFIKEYMixin:
|
||||
"""Mixin class to enable checking the existence of the aprs.fi apiKey."""
|
||||
|
||||
def ensure_aprs_fi_key(self):
|
||||
try:
|
||||
self.config.check_option(["services", "aprs.fi", "apiKey"])
|
||||
self.enabled = True
|
||||
except Exception as ex:
|
||||
LOG.error(f"Failed to find config aprs.fi:apikey {ex}")
|
||||
self.enabled = False
|
||||
|
||||
|
||||
class HelpPlugin(APRSDRegexCommandPluginBase):
|
||||
"""Help Plugin that is always enabled.
|
||||
|
||||
@@ -425,7 +437,8 @@ class PluginManager:
|
||||
)
|
||||
self._pluggy_pm.register(plugin_obj)
|
||||
except Exception as ex:
|
||||
LOG.exception(f"Couldn't load plugin '{plugin_name}'", ex)
|
||||
LOG.error(f"Couldn't load plugin '{plugin_name}'")
|
||||
LOG.exception(ex)
|
||||
|
||||
def reload_plugins(self):
|
||||
with self.lock:
|
||||
@@ -479,4 +492,5 @@ class PluginManager:
|
||||
self._pluggy_pm.register(obj)
|
||||
|
||||
def get_plugins(self):
|
||||
return self._pluggy_pm.get_plugins()
|
||||
if self._pluggy_pm:
|
||||
return self._pluggy_pm.get_plugins()
|
||||
|
||||
@@ -9,9 +9,9 @@ import threading
|
||||
import time
|
||||
|
||||
import imapclient
|
||||
from validate_email import validate_email
|
||||
|
||||
from aprsd import messaging, plugin, stats, threads, trace
|
||||
from aprsd import messaging, plugin, stats, threads
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -59,9 +59,9 @@ class EmailInfo:
|
||||
class EmailPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
"""Email Plugin."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^-.*"
|
||||
command_name = "email"
|
||||
short_description = "Send and Receive email"
|
||||
|
||||
# message_number:time combos so we don't resend the same email in
|
||||
# five mins {int:int}
|
||||
@@ -72,15 +72,8 @@ class EmailPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
"""Ensure that email is enabled and start the thread."""
|
||||
|
||||
email_enabled = self.config["aprsd"]["email"].get("enabled", False)
|
||||
validation = self.config["aprsd"]["email"].get("validate", False)
|
||||
|
||||
if email_enabled:
|
||||
valid = validate_email_config(self.config, validation)
|
||||
if not valid:
|
||||
LOG.error("Failed to validate email config options.")
|
||||
LOG.error("EmailPlugin DISABLED!!!!")
|
||||
else:
|
||||
self.enabled = True
|
||||
self.enabled = True
|
||||
else:
|
||||
LOG.info("Email services not enabled.")
|
||||
|
||||
@@ -193,8 +186,8 @@ def _imap_connect(config):
|
||||
ssl=use_ssl,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error("Failed to connect IMAP server", e)
|
||||
except Exception:
|
||||
LOG.exception("Failed to connect IMAP server")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -265,47 +258,6 @@ def _smtp_connect(config):
|
||||
return server
|
||||
|
||||
|
||||
def validate_shortcuts(config):
|
||||
shortcuts = config["aprsd"]["email"].get("shortcuts", None)
|
||||
if not shortcuts:
|
||||
return
|
||||
|
||||
LOG.info(
|
||||
"Validating {} Email shortcuts. This can take up to 10 seconds"
|
||||
" per shortcut".format(len(shortcuts)),
|
||||
)
|
||||
delete_keys = []
|
||||
for key in shortcuts:
|
||||
LOG.info(f"Validating {key}:{shortcuts[key]}")
|
||||
is_valid = validate_email(
|
||||
email_address=shortcuts[key],
|
||||
check_format=True,
|
||||
check_dns=True,
|
||||
check_smtp=True,
|
||||
smtp_from_address=config["aprsd"]["email"]["smtp"]["login"],
|
||||
smtp_helo_host=config["aprsd"]["email"]["smtp"]["host"],
|
||||
smtp_timeout=10,
|
||||
dns_timeout=10,
|
||||
smtp_debug=False,
|
||||
)
|
||||
if not is_valid:
|
||||
LOG.error(
|
||||
"'{}' is an invalid email address. Removing shortcut".format(
|
||||
shortcuts[key],
|
||||
),
|
||||
)
|
||||
delete_keys.append(key)
|
||||
|
||||
for key in delete_keys:
|
||||
del config["aprsd"]["email"]["shortcuts"][key]
|
||||
|
||||
LOG.info(
|
||||
"Available shortcuts: {}".format(
|
||||
config["aprsd"]["email"]["shortcuts"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_email_from_shortcut(config, addr):
|
||||
if config["aprsd"]["email"].get("shortcuts", False):
|
||||
return config["aprsd"]["email"]["shortcuts"].get(addr, addr)
|
||||
@@ -323,12 +275,6 @@ def validate_email_config(config, disable_validation=False):
|
||||
LOG.info("Checking SMTP configuration")
|
||||
smtp_server = _smtp_connect(config)
|
||||
|
||||
# Now validate and flag any shortcuts as invalid
|
||||
if not disable_validation:
|
||||
validate_shortcuts(config)
|
||||
else:
|
||||
LOG.info("Shortcuts email validation is Disabled!!, you were warned.")
|
||||
|
||||
if imap_server and smtp_server:
|
||||
return True
|
||||
else:
|
||||
@@ -348,8 +294,8 @@ def parse_email(msgid, data, server):
|
||||
LOG.debug(f"Got a message from '{from_addr}'")
|
||||
try:
|
||||
m = server.fetch([msgid], ["RFC822"])
|
||||
except Exception as e:
|
||||
LOG.exception("Couldn't fetch email from server in parse_email", e)
|
||||
except Exception:
|
||||
LOG.exception("Couldn't fetch email from server in parse_email")
|
||||
return
|
||||
|
||||
msg = email.message_from_string(m[msgid][b"RFC822"].decode(errors="ignore"))
|
||||
@@ -414,9 +360,9 @@ def parse_email(msgid, data, server):
|
||||
# it below, also with errors='ignore'
|
||||
try:
|
||||
body = body.decode(errors="ignore")
|
||||
except Exception as e:
|
||||
LOG.error("Unicode decode failure: " + str(e))
|
||||
LOG.error("Unidoce decode failed: " + str(body))
|
||||
except Exception:
|
||||
LOG.exception("Unicode decode failure")
|
||||
LOG.error(f"Unidoce decode failed: {str(body)}")
|
||||
body = "Unreadable unicode msg"
|
||||
# strip all html tags
|
||||
body = re.sub("<[^<]+?>", "", body)
|
||||
@@ -437,13 +383,13 @@ def send_email(config, to_addr, content):
|
||||
LOG.info("Sending Email_________________")
|
||||
|
||||
if to_addr in shortcuts:
|
||||
LOG.info("To : " + to_addr)
|
||||
LOG.info(f"To : {to_addr}")
|
||||
to_addr = email_address
|
||||
LOG.info(" (" + to_addr + ")")
|
||||
LOG.info(f" ({to_addr})")
|
||||
subject = config["ham"]["callsign"]
|
||||
# content = content + "\n\n(NOTE: reply with one line)"
|
||||
LOG.info("Subject : " + subject)
|
||||
LOG.info("Body : " + content)
|
||||
LOG.info(f"Subject : {subject}")
|
||||
LOG.info(f"Body : {content}")
|
||||
|
||||
# check email more often since there's activity right now
|
||||
EmailInfo().delay = 60
|
||||
@@ -461,9 +407,8 @@ def send_email(config, to_addr, content):
|
||||
msg.as_string(),
|
||||
)
|
||||
stats.APRSDStats().email_tx_inc()
|
||||
except Exception as e:
|
||||
msg = getattr(e, "message", repr(e))
|
||||
LOG.error("Sendmail Error!!!! '{}'", msg)
|
||||
except Exception:
|
||||
LOG.exception("Sendmail Error!!!!")
|
||||
server.quit()
|
||||
return -1
|
||||
server.quit()
|
||||
@@ -484,14 +429,14 @@ def resend_email(config, count, fromcall):
|
||||
|
||||
try:
|
||||
server = _imap_connect(config)
|
||||
except Exception as e:
|
||||
LOG.exception("Failed to Connect to IMAP. Cannot resend email ", e)
|
||||
except Exception:
|
||||
LOG.exception("Failed to Connect to IMAP. Cannot resend email ")
|
||||
return
|
||||
|
||||
try:
|
||||
messages = server.search(["SINCE", today])
|
||||
except Exception as e:
|
||||
LOG.exception("Couldn't search for emails in resend_email ", e)
|
||||
except Exception:
|
||||
LOG.exception("Couldn't search for emails in resend_email ")
|
||||
return
|
||||
|
||||
# LOG.debug("%d messages received today" % len(messages))
|
||||
@@ -503,8 +448,8 @@ def resend_email(config, count, fromcall):
|
||||
for message in messages:
|
||||
try:
|
||||
parts = server.fetch(message, ["ENVELOPE"]).items()
|
||||
except Exception as e:
|
||||
LOG.exception("Couldn't fetch email parts in resend_email", e)
|
||||
except Exception:
|
||||
LOG.exception("Couldn't fetch email parts in resend_email")
|
||||
continue
|
||||
|
||||
for msgid, data in list(parts):
|
||||
@@ -513,8 +458,8 @@ def resend_email(config, count, fromcall):
|
||||
# unset seen flag, will stay bold in email client
|
||||
try:
|
||||
server.remove_flags(msgid, [imapclient.SEEN])
|
||||
except Exception as e:
|
||||
LOG.exception("Failed to remove SEEN flag in resend_email", e)
|
||||
except Exception:
|
||||
LOG.exception("Failed to remove SEEN flag in resend_email")
|
||||
|
||||
if from_addr in shortcuts_inverted:
|
||||
# reverse lookup of a shortcut
|
||||
@@ -592,24 +537,21 @@ class APRSDEmailThread(threads.APRSDThread):
|
||||
|
||||
try:
|
||||
server = _imap_connect(self.config)
|
||||
except Exception as e:
|
||||
LOG.exception("IMAP failed to connect.", e)
|
||||
except Exception:
|
||||
LOG.exception("IMAP Failed to connect")
|
||||
return True
|
||||
|
||||
try:
|
||||
messages = server.search(["SINCE", today])
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
"IMAP failed to search for messages since today.",
|
||||
e,
|
||||
)
|
||||
except Exception:
|
||||
LOG.exception("IMAP failed to search for messages since today.")
|
||||
return True
|
||||
LOG.debug(f"{len(messages)} messages received today")
|
||||
|
||||
try:
|
||||
_msgs = server.fetch(messages, ["ENVELOPE"])
|
||||
except Exception as e:
|
||||
LOG.exception("IMAP failed to fetch/flag messages: ", e)
|
||||
except Exception:
|
||||
LOG.exception("IMAP failed to fetch/flag messages: ")
|
||||
return True
|
||||
|
||||
for msgid, data in _msgs.items():
|
||||
@@ -637,27 +579,24 @@ class APRSDEmailThread(threads.APRSDThread):
|
||||
x.decode(errors="ignore")
|
||||
for x in server.get_flags(msgid)[msgid]
|
||||
]
|
||||
except Exception as e:
|
||||
LOG.exception("Failed to get flags.", e)
|
||||
except Exception:
|
||||
LOG.error("Failed to get flags.")
|
||||
break
|
||||
|
||||
if "APRS" not in taglist:
|
||||
# if msg not flagged as sent via aprs
|
||||
try:
|
||||
server.fetch([msgid], ["RFC822"])
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
"Failed single server fetch for RFC822",
|
||||
e,
|
||||
)
|
||||
except Exception:
|
||||
LOG.exception("Failed single server fetch for RFC822")
|
||||
break
|
||||
|
||||
(body, from_addr) = parse_email(msgid, data, server)
|
||||
# unset seen flag, will stay bold in email client
|
||||
try:
|
||||
server.remove_flags(msgid, [imapclient.SEEN])
|
||||
except Exception as e:
|
||||
LOG.exception("Failed to remove flags SEEN", e)
|
||||
except Exception:
|
||||
LOG.exception("Failed to remove flags SEEN")
|
||||
# Not much we can do here, so lets try and
|
||||
# send the aprs message anyway
|
||||
|
||||
@@ -676,13 +615,13 @@ class APRSDEmailThread(threads.APRSDThread):
|
||||
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)
|
||||
except Exception:
|
||||
LOG.exception("Couldn't add APRS flag to email")
|
||||
|
||||
try:
|
||||
server.remove_flags(msgid, [imapclient.SEEN])
|
||||
except Exception as e:
|
||||
LOG.exception("Couldn't remove seen flag from email", e)
|
||||
except Exception:
|
||||
LOG.exception("Couldn't remove seen flag from email")
|
||||
|
||||
# check email more often since we just received an email
|
||||
EmailInfo().delay = 60
|
||||
@@ -692,8 +631,8 @@ class APRSDEmailThread(threads.APRSDThread):
|
||||
self.past = datetime.datetime.now()
|
||||
try:
|
||||
server.logout()
|
||||
except Exception as e:
|
||||
LOG.exception("IMAP failed to logout: ", e)
|
||||
except Exception:
|
||||
LOG.exception("IMAP failed to logout: ")
|
||||
return True
|
||||
else:
|
||||
# We haven't hit the email delay yet.
|
||||
|
||||
@@ -2,7 +2,8 @@ import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from aprsd import plugin, trace
|
||||
from aprsd import plugin
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -11,9 +12,18 @@ LOG = logging.getLogger("APRSD")
|
||||
class FortunePlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
"""Fortune."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[fF]"
|
||||
command_name = "fortune"
|
||||
short_description = "Give me a fortune"
|
||||
|
||||
fortune_path = None
|
||||
|
||||
def setup(self):
|
||||
self.fortune_path = shutil.which("fortune")
|
||||
if not self.fortune_path:
|
||||
self.enabled = False
|
||||
else:
|
||||
self.enabled = True
|
||||
|
||||
@trace.trace
|
||||
def process(self, packet):
|
||||
@@ -25,13 +35,8 @@ class FortunePlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
|
||||
reply = None
|
||||
|
||||
fortune_path = shutil.which("fortune")
|
||||
if not fortune_path:
|
||||
reply = "Fortune command not installed"
|
||||
return reply
|
||||
|
||||
try:
|
||||
cmnd = [fortune_path, "-s", "-n 60"]
|
||||
cmnd = [self.fortune_path, "-s", "-n 60"]
|
||||
command = " ".join(cmnd)
|
||||
output = subprocess.check_output(
|
||||
command,
|
||||
|
||||
@@ -2,18 +2,22 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from aprsd import plugin, plugin_utils, trace
|
||||
from aprsd import plugin, plugin_utils
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class LocationPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
class LocationPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
|
||||
"""Location!"""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[lL]"
|
||||
command_name = "location"
|
||||
short_description = "Where in the world is a CALLSIGN's last GPS beacon?"
|
||||
|
||||
def setup(self):
|
||||
self.ensure_aprs_fi_key()
|
||||
|
||||
@trace.trace
|
||||
def process(self, packet):
|
||||
@@ -22,13 +26,6 @@ class LocationPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
|
||||
# get last location of a callsign, get descriptive name from weather service
|
||||
try:
|
||||
self.config.check_option(["services", "aprs.fi", "apiKey"])
|
||||
except Exception as ex:
|
||||
LOG.error(f"Failed to find config aprs.fi:apikey {ex}")
|
||||
return "No aprs.fi apikey found"
|
||||
|
||||
api_key = self.config["services"]["aprs.fi"]["apiKey"]
|
||||
|
||||
# optional second argument is a callsign to search
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from aprsd import packets, plugin
|
||||
from aprsd import messaging, packets, plugin
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -15,8 +16,9 @@ class NotifySeenPlugin(plugin.APRSDWatchListPluginBase):
|
||||
seen was older than the configured age limit.
|
||||
"""
|
||||
|
||||
version = "1.0"
|
||||
short_description = "Notify me when a CALLSIGN is recently seen on APRS-IS"
|
||||
|
||||
@trace.trace
|
||||
def process(self, packet):
|
||||
LOG.info("NotifySeenPlugin")
|
||||
|
||||
@@ -37,12 +39,23 @@ class NotifySeenPlugin(plugin.APRSDWatchListPluginBase):
|
||||
packet_type = packets.get_packet_type(packet)
|
||||
# we shouldn't notify the alert user that they are online.
|
||||
if fromcall != notify_callsign:
|
||||
return f"{fromcall} was just seen by type:'{packet_type}'"
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprs"]["login"],
|
||||
notify_callsign,
|
||||
f"{fromcall} was just seen by type:'{packet_type}'",
|
||||
# We don't need to keep this around if it doesn't go thru
|
||||
allow_delay=False,
|
||||
)
|
||||
return msg
|
||||
else:
|
||||
LOG.debug("fromcall and notify_callsign are the same, not notifying")
|
||||
return messaging.NULL_MESSAGE
|
||||
else:
|
||||
LOG.debug(
|
||||
"Not old enough to notify callsign '{}' : {} < {}".format(
|
||||
"Not old enough to notify on callsign '{}' : {} < {}".format(
|
||||
fromcall,
|
||||
age,
|
||||
wl.max_delta(),
|
||||
),
|
||||
)
|
||||
return messaging.NULL_MESSAGE
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from aprsd import plugin, trace
|
||||
from aprsd import plugin
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -10,9 +11,9 @@ LOG = logging.getLogger("APRSD")
|
||||
class PingPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
"""Ping."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[pP]"
|
||||
command_name = "ping"
|
||||
short_description = "reply with a Pong!"
|
||||
|
||||
@trace.trace
|
||||
def process(self, packet):
|
||||
|
||||
@@ -2,7 +2,8 @@ import datetime
|
||||
import logging
|
||||
import re
|
||||
|
||||
from aprsd import messaging, plugin, trace
|
||||
from aprsd import messaging, plugin
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -11,9 +12,9 @@ LOG = logging.getLogger("APRSD")
|
||||
class QueryPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
"""Query command."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = r"^\!.*"
|
||||
command_name = "query"
|
||||
short_description = "APRSD Owner command to query messages in the MsgTrack"
|
||||
|
||||
@trace.trace
|
||||
def process(self, packet):
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
import yfinance as yf
|
||||
|
||||
from aprsd import plugin, trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class StockPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
"""Stock market plugin for fetching stock quotes"""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[sS]"
|
||||
command_name = "stock"
|
||||
|
||||
@trace.trace
|
||||
def process(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(f"Fetch stock quote for '{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(
|
||||
f"Failed to fetch stock '{stock_symbol}' from yahoo '{e}'",
|
||||
)
|
||||
reply = f"Failed to fetch stock '{stock_symbol}'"
|
||||
|
||||
return reply.rstrip()
|
||||
@@ -2,10 +2,10 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from opencage.geocoder import OpenCageGeocode
|
||||
import pytz
|
||||
|
||||
from aprsd import fuzzyclock, plugin, plugin_utils, trace
|
||||
from aprsd import plugin, plugin_utils
|
||||
from aprsd.utils import fuzzy, trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -14,9 +14,9 @@ LOG = logging.getLogger("APRSD")
|
||||
class TimePlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
"""Time command."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[tT]"
|
||||
command_name = "time"
|
||||
short_description = "What is the current local time."
|
||||
|
||||
def _get_local_tz(self):
|
||||
return pytz.timezone(time.strftime("%Z"))
|
||||
@@ -32,7 +32,7 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
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)
|
||||
cur_time = fuzzy(int(local_hour), int(local_min), 1)
|
||||
|
||||
reply = "{} ({})".format(
|
||||
cur_time,
|
||||
@@ -49,78 +49,15 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
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 process(self, packet):
|
||||
fromcall = packet.get("from")
|
||||
message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
|
||||
# get last location of a callsign, get descriptive name from weather service
|
||||
try:
|
||||
self.config.exists(["services", "aprs.fi", "apiKey"])
|
||||
except Exception as ex:
|
||||
LOG.error(f"Failed to find config aprs.fi:apikey {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(f"Failed to fetch aprs.fi data {ex}")
|
||||
return "Failed to fetch 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:
|
||||
self.config.exists("opencagedata.apiKey")
|
||||
except Exception as ex:
|
||||
LOG.error(f"Failed to find config opencage:apiKey {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(f"Couldn't fetch opencagedata api '{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):
|
||||
class TimeOWMPlugin(TimePlugin, plugin.APRSFIKEYMixin):
|
||||
"""OpenWeatherMap based timezone fetching."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[tT]"
|
||||
command_name = "time"
|
||||
short_description = "Current time of GPS beacon's timezone. Uses OpenWeatherMap"
|
||||
|
||||
def setup(self):
|
||||
self.ensure_aprs_fi_key()
|
||||
|
||||
@trace.trace
|
||||
def process(self, packet):
|
||||
@@ -128,13 +65,6 @@ class TimeOWMPlugin(TimePlugin):
|
||||
message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
|
||||
# get last location of a callsign, get descriptive name from weather service
|
||||
try:
|
||||
self.config.exists(["services", "aprs.fi", "apiKey"])
|
||||
except Exception as ex:
|
||||
LOG.error(f"Failed to find config aprs.fi:apikey {ex}")
|
||||
return "No aprs.fi apikey found"
|
||||
|
||||
# optional second argument is a callsign to search
|
||||
a = re.search(r"^.*\s+(.*)", message)
|
||||
if a is not None:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
|
||||
import aprsd
|
||||
from aprsd import plugin, stats, trace
|
||||
from aprsd import plugin, stats
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@@ -10,9 +11,9 @@ LOG = logging.getLogger("APRSD")
|
||||
class VersionPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
"""Version of APRSD Plugin."""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[vV]"
|
||||
command_name = "version"
|
||||
short_description = "What is the APRSD Version"
|
||||
|
||||
# message_number:time combos so we don't resend the same email in
|
||||
# five mins {int:int}
|
||||
|
||||
@@ -4,13 +4,14 @@ import re
|
||||
|
||||
import requests
|
||||
|
||||
from aprsd import plugin, plugin_utils, trace
|
||||
from aprsd import plugin, plugin_utils
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
|
||||
"""USWeather Command
|
||||
|
||||
Returns a weather report for the calling weather station
|
||||
@@ -23,9 +24,12 @@ class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
"weather" - returns weather near the calling callsign
|
||||
"""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[wW]"
|
||||
command_name = "USWeather"
|
||||
short_description = "Provide USA only weather of GPS Beacon location"
|
||||
|
||||
def setup(self):
|
||||
self.ensure_aprs_fi_key()
|
||||
|
||||
@trace.trace
|
||||
def process(self, packet):
|
||||
@@ -33,20 +37,18 @@ class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
fromcall = packet.get("from")
|
||||
# message = packet.get("message_text", None)
|
||||
# ack = packet.get("msgNo", "0")
|
||||
try:
|
||||
self.config.exists(["services", "aprs.fi", "apiKey"])
|
||||
except Exception as ex:
|
||||
LOG.error(f"Failed to find config aprs.fi:apikey {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(f"Failed to fetch aprs.fi data {ex}")
|
||||
return "Failed to fetch location"
|
||||
return "Failed to fetch aprs.fi location"
|
||||
|
||||
LOG.debug(f"LocationPlugin: aprs_data = {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"
|
||||
|
||||
# LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
|
||||
lat = aprs_data["entries"][0]["lat"]
|
||||
lon = aprs_data["entries"][0]["lng"]
|
||||
|
||||
@@ -71,7 +73,7 @@ class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
return reply
|
||||
|
||||
|
||||
class USMetarPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
class USMetarPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
|
||||
"""METAR Command
|
||||
|
||||
This provides a METAR weather report from a station near the caller
|
||||
@@ -86,9 +88,12 @@ class USMetarPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
|
||||
"""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[metar]"
|
||||
command_name = "USMetar"
|
||||
short_description = "USA only METAR of GPS Beacon location"
|
||||
|
||||
def setup(self):
|
||||
self.ensure_aprs_fi_key()
|
||||
|
||||
@trace.trace
|
||||
def process(self, packet):
|
||||
@@ -126,12 +131,12 @@ class USMetarPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall)
|
||||
except Exception as ex:
|
||||
LOG.error(f"Failed to fetch aprs.fi data {ex}")
|
||||
return "Failed to fetch location"
|
||||
return "Failed to fetch aprs.fi 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"
|
||||
return "Failed to fetch aprs.fi location"
|
||||
|
||||
lat = aprs_data["entries"][0]["lat"]
|
||||
lon = aprs_data["entries"][0]["lng"]
|
||||
@@ -178,9 +183,9 @@ class OWMWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
|
||||
"""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[wW]"
|
||||
command_name = "OpenWeatherMap"
|
||||
short_description = "OpenWeatherMap weather of GPS Beacon location"
|
||||
|
||||
def help(self):
|
||||
_help = [
|
||||
@@ -308,9 +313,9 @@ class AVWXWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
|
||||
docker build -f Dockerfile -t avwx-api:master .
|
||||
"""
|
||||
|
||||
version = "1.0"
|
||||
command_regex = "^[mM]"
|
||||
command_name = "AVWXWeather"
|
||||
short_description = "AVWX weather of GPS Beacon location"
|
||||
|
||||
def help(self):
|
||||
_help = [
|
||||
|
||||
@@ -2,6 +2,8 @@ import datetime
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import wrapt
|
||||
|
||||
import aprsd
|
||||
from aprsd import packets, plugin, utils
|
||||
|
||||
@@ -12,7 +14,7 @@ LOG = logging.getLogger("APRSD")
|
||||
class APRSDStats:
|
||||
|
||||
_instance = None
|
||||
lock = None
|
||||
lock = threading.Lock()
|
||||
config = None
|
||||
|
||||
start_time = None
|
||||
@@ -39,7 +41,6 @@ class APRSDStats:
|
||||
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
|
||||
@@ -48,128 +49,129 @@ class APRSDStats:
|
||||
if config:
|
||||
self.config = config
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def uptime(self):
|
||||
with self.lock:
|
||||
return datetime.datetime.now() - self.start_time
|
||||
return datetime.datetime.now() - self.start_time
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def memory(self):
|
||||
with self.lock:
|
||||
return self._mem_current
|
||||
return self._mem_current
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_memory(self, memory):
|
||||
with self.lock:
|
||||
self._mem_current = memory
|
||||
self._mem_current = memory
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def memory_peak(self):
|
||||
with self.lock:
|
||||
return self._mem_peak
|
||||
return self._mem_peak
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_memory_peak(self, memory):
|
||||
with self.lock:
|
||||
self._mem_peak = memory
|
||||
self._mem_peak = memory
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def aprsis_server(self):
|
||||
with self.lock:
|
||||
return self._aprsis_server
|
||||
return self._aprsis_server
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_aprsis_server(self, server):
|
||||
with self.lock:
|
||||
self._aprsis_server = server
|
||||
self._aprsis_server = server
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def aprsis_keepalive(self):
|
||||
with self.lock:
|
||||
return self._aprsis_keepalive
|
||||
return self._aprsis_keepalive
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_aprsis_keepalive(self):
|
||||
with self.lock:
|
||||
self._aprsis_keepalive = datetime.datetime.now()
|
||||
self._aprsis_keepalive = datetime.datetime.now()
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def msgs_tx(self):
|
||||
with self.lock:
|
||||
return self._msgs_tx
|
||||
return self._msgs_tx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def msgs_tx_inc(self):
|
||||
with self.lock:
|
||||
self._msgs_tx += 1
|
||||
self._msgs_tx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def msgs_rx(self):
|
||||
with self.lock:
|
||||
return self._msgs_rx
|
||||
return self._msgs_rx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def msgs_rx_inc(self):
|
||||
with self.lock:
|
||||
self._msgs_rx += 1
|
||||
self._msgs_rx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def msgs_mice_rx(self):
|
||||
with self.lock:
|
||||
return self._msgs_mice_rx
|
||||
return self._msgs_mice_rx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def msgs_mice_inc(self):
|
||||
with self.lock:
|
||||
self._msgs_mice_rx += 1
|
||||
self._msgs_mice_rx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def ack_tx(self):
|
||||
with self.lock:
|
||||
return self._ack_tx
|
||||
return self._ack_tx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def ack_tx_inc(self):
|
||||
with self.lock:
|
||||
self._ack_tx += 1
|
||||
self._ack_tx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def ack_rx(self):
|
||||
with self.lock:
|
||||
return self._ack_rx
|
||||
return self._ack_rx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def ack_rx_inc(self):
|
||||
with self.lock:
|
||||
self._ack_rx += 1
|
||||
self._ack_rx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def msgs_tracked(self):
|
||||
with self.lock:
|
||||
return self._msgs_tracked
|
||||
return self._msgs_tracked
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def msgs_tracked_inc(self):
|
||||
with self.lock:
|
||||
self._msgs_tracked += 1
|
||||
self._msgs_tracked += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def email_tx(self):
|
||||
with self.lock:
|
||||
return self._email_tx
|
||||
return self._email_tx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def email_tx_inc(self):
|
||||
with self.lock:
|
||||
self._email_tx += 1
|
||||
self._email_tx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def email_rx(self):
|
||||
with self.lock:
|
||||
return self._email_rx
|
||||
return self._email_rx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def email_rx_inc(self):
|
||||
with self.lock:
|
||||
self._email_rx += 1
|
||||
self._email_rx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def email_thread_time(self):
|
||||
with self.lock:
|
||||
return self._email_thread_last_time
|
||||
return self._email_thread_last_time
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def email_thread_update(self):
|
||||
with self.lock:
|
||||
self._email_thread_last_time = datetime.datetime.now()
|
||||
self._email_thread_last_time = datetime.datetime.now()
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def stats(self):
|
||||
now = datetime.datetime.now()
|
||||
if self._email_thread_last_time:
|
||||
@@ -185,20 +187,20 @@ class APRSDStats:
|
||||
pm = plugin.PluginManager()
|
||||
plugins = pm.get_plugins()
|
||||
plugin_stats = {}
|
||||
if plugins:
|
||||
def full_name_with_qualname(obj):
|
||||
return "{}.{}".format(
|
||||
obj.__class__.__module__,
|
||||
obj.__class__.__qualname__,
|
||||
)
|
||||
|
||||
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)] = {
|
||||
"enabled": p.enabled,
|
||||
"rx": p.rx_count,
|
||||
"tx": p.tx_count,
|
||||
"version": p.version,
|
||||
}
|
||||
for p in plugins:
|
||||
plugin_stats[full_name_with_qualname(p)] = {
|
||||
"enabled": p.enabled,
|
||||
"rx": p.rx_count,
|
||||
"tx": p.tx_count,
|
||||
"version": p.version,
|
||||
}
|
||||
|
||||
wl = packets.WatchList()
|
||||
sl = packets.SeenList()
|
||||
@@ -207,30 +209,30 @@ class APRSDStats:
|
||||
"aprsd": {
|
||||
"version": aprsd.__version__,
|
||||
"uptime": utils.strfdelta(self.uptime),
|
||||
"memory_current": self.memory,
|
||||
"memory_current": int(self.memory),
|
||||
"memory_current_str": utils.human_size(self.memory),
|
||||
"memory_peak": self.memory_peak,
|
||||
"memory_peak": int(self.memory_peak),
|
||||
"memory_peak_str": utils.human_size(self.memory_peak),
|
||||
"watch_list": wl.get_all(),
|
||||
"seen_list": sl.get_all(),
|
||||
},
|
||||
"aprs-is": {
|
||||
"server": self.aprsis_server,
|
||||
"server": str(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,
|
||||
"tracked": int(self.msgs_tracked),
|
||||
"sent": int(self.msgs_tx),
|
||||
"recieved": int(self.msgs_rx),
|
||||
"ack_sent": int(self.ack_tx),
|
||||
"ack_recieved": int(self.ack_rx),
|
||||
"mic-e recieved": int(self.msgs_mice_rx),
|
||||
},
|
||||
"email": {
|
||||
"enabled": self.config["aprsd"]["email"]["enabled"],
|
||||
"sent": self._email_tx,
|
||||
"recieved": self._email_rx,
|
||||
"sent": int(self._email_tx),
|
||||
"recieved": int(self._email_rx),
|
||||
"thread_last_update": last_update,
|
||||
},
|
||||
"plugins": plugin_stats,
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import abc
|
||||
import datetime
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import tracemalloc
|
||||
|
||||
import aprslib
|
||||
|
||||
from aprsd import client, messaging, packets, plugin, stats, utils
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
RX_THREAD = "RX"
|
||||
EMAIL_THREAD = "Email"
|
||||
|
||||
rx_msg_queue = queue.Queue(maxsize=20)
|
||||
logging_queue = queue.Queue()
|
||||
msg_queues = {
|
||||
"rx": rx_msg_queue,
|
||||
}
|
||||
|
||||
|
||||
class APRSDThreadList:
|
||||
"""Singleton class that keeps track of application wide threads."""
|
||||
|
||||
_instance = None
|
||||
|
||||
threads_list = []
|
||||
lock = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls.lock = threading.Lock()
|
||||
cls.threads_list = []
|
||||
return cls._instance
|
||||
|
||||
def add(self, thread_obj):
|
||||
with self.lock:
|
||||
self.threads_list.append(thread_obj)
|
||||
|
||||
def remove(self, thread_obj):
|
||||
with self.lock:
|
||||
self.threads_list.remove(thread_obj)
|
||||
|
||||
def stop_all(self):
|
||||
"""Iterate over all threads and call stop on them."""
|
||||
with self.lock:
|
||||
for th in self.threads_list:
|
||||
LOG.debug(f"Stopping Thread {th.name}")
|
||||
th.stop()
|
||||
|
||||
def __len__(self):
|
||||
with self.lock:
|
||||
return len(self.threads_list)
|
||||
|
||||
|
||||
class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
|
||||
def __init__(self, name):
|
||||
super().__init__(name=name)
|
||||
self.thread_stop = False
|
||||
APRSDThreadList().add(self)
|
||||
|
||||
def stop(self):
|
||||
self.thread_stop = True
|
||||
|
||||
@abc.abstractmethod
|
||||
def loop(self):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
LOG.debug("Starting")
|
||||
while not self.thread_stop:
|
||||
can_loop = self.loop()
|
||||
if not can_loop:
|
||||
self.stop()
|
||||
APRSDThreadList().remove(self)
|
||||
LOG.debug("Exiting")
|
||||
|
||||
|
||||
class KeepAliveThread(APRSDThread):
|
||||
cntr = 0
|
||||
checker_time = datetime.datetime.now()
|
||||
|
||||
def __init__(self, config):
|
||||
tracemalloc.start()
|
||||
super().__init__("KeepAlive")
|
||||
self.config = config
|
||||
max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 0}
|
||||
self.max_delta = datetime.timedelta(**max_timeout)
|
||||
|
||||
def loop(self):
|
||||
if self.cntr % 60 == 0:
|
||||
tracker = messaging.MsgTrack()
|
||||
stats_obj = stats.APRSDStats()
|
||||
pl = packets.PacketList()
|
||||
thread_list = APRSDThreadList()
|
||||
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 {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} "
|
||||
"Last:{} Email: {} - RAM Current:{} Peak:{} Threads:{}"
|
||||
).format(
|
||||
self.config["aprs"]["login"],
|
||||
utils.strfdelta(stats_obj.uptime),
|
||||
pl.total_recv,
|
||||
pl.total_tx,
|
||||
len(tracker),
|
||||
stats_obj.msgs_tx,
|
||||
stats_obj.msgs_rx,
|
||||
last_msg_time,
|
||||
email_thread_time,
|
||||
utils.human_size(current),
|
||||
utils.human_size(peak),
|
||||
len(thread_list),
|
||||
)
|
||||
LOG.info(keepalive)
|
||||
|
||||
# See if we should reset the aprs-is client
|
||||
# Due to losing a keepalive from them
|
||||
delta_dict = utils.parse_delta_str(last_msg_time)
|
||||
delta = datetime.timedelta(**delta_dict)
|
||||
|
||||
if delta > self.max_delta:
|
||||
# We haven't gotten a keepalive from aprs-is in a while
|
||||
# reset the connection.a
|
||||
if not client.KISSClient.is_enabled(self.config):
|
||||
LOG.warning("Resetting connection to APRS-IS.")
|
||||
client.factory.create().reset()
|
||||
|
||||
# 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(1)
|
||||
return True
|
||||
|
||||
|
||||
class APRSDRXThread(APRSDThread):
|
||||
def __init__(self, msg_queues, config):
|
||||
super().__init__("RX_MSG")
|
||||
self.msg_queues = msg_queues
|
||||
self.config = config
|
||||
self._client = client.factory.create()
|
||||
|
||||
def stop(self):
|
||||
self.thread_stop = True
|
||||
client.factory.create().client.stop()
|
||||
|
||||
def loop(self):
|
||||
|
||||
# setup the consumer of messages and block until a messages
|
||||
try:
|
||||
# This will register a packet consumer with aprslib
|
||||
# When new packets come in the consumer will process
|
||||
# the packet
|
||||
|
||||
# Do a partial here because the consumer signature doesn't allow
|
||||
# For kwargs to be passed in to the consumer func we declare
|
||||
# and the aprslib developer didn't want to allow a PR to add
|
||||
# kwargs. :(
|
||||
# https://github.com/rossengeorgiev/aprs-python/pull/56
|
||||
self._client.client.consumer(
|
||||
self.process_packet, raw=False, blocking=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
|
||||
self._client.reset()
|
||||
# Continue to loop
|
||||
return True
|
||||
|
||||
def process_packet(self, *args, **kwargs):
|
||||
packet = self._client.decode_packet(*args, **kwargs)
|
||||
thread = APRSDProcessPacketThread(packet=packet, config=self.config)
|
||||
thread.start()
|
||||
|
||||
|
||||
class APRSDProcessPacketThread(APRSDThread):
|
||||
|
||||
def __init__(self, packet, config):
|
||||
self.packet = packet
|
||||
self.config = config
|
||||
name = self.packet["raw"][:10]
|
||||
super().__init__(f"RX_PACKET-{name}")
|
||||
|
||||
def process_ack_packet(self, packet):
|
||||
ack_num = packet.get("msgNo")
|
||||
LOG.info(f"Got ack for message {ack_num}")
|
||||
messaging.log_message(
|
||||
"ACK",
|
||||
packet["raw"],
|
||||
None,
|
||||
ack=ack_num,
|
||||
fromcall=packet["from"],
|
||||
)
|
||||
tracker = messaging.MsgTrack()
|
||||
tracker.remove(ack_num)
|
||||
stats.APRSDStats().ack_rx_inc()
|
||||
return
|
||||
|
||||
def loop(self):
|
||||
"""Process a packet recieved from aprs-is server."""
|
||||
packet = self.packet
|
||||
packets.PacketList().add(packet)
|
||||
|
||||
fromcall = packet["from"]
|
||||
tocall = packet.get("addresse", None)
|
||||
msg = packet.get("message_text", None)
|
||||
msg_id = packet.get("msgNo", "0")
|
||||
msg_response = packet.get("response", None)
|
||||
# LOG.debug(f"Got packet from '{fromcall}' - {packet}")
|
||||
|
||||
# We don't put ack packets destined for us through the
|
||||
# plugins.
|
||||
if tocall == self.config["aprs"]["login"] and msg_response == "ack":
|
||||
self.process_ack_packet(packet)
|
||||
else:
|
||||
# It's not an ACK for us, so lets run it through
|
||||
# the plugins.
|
||||
messaging.log_message(
|
||||
"Received Message",
|
||||
packet["raw"],
|
||||
msg,
|
||||
fromcall=fromcall,
|
||||
msg_num=msg_id,
|
||||
)
|
||||
|
||||
# Only ack messages that were sent directly to us
|
||||
if tocall == self.config["aprs"]["login"]:
|
||||
stats.APRSDStats().msgs_rx_inc()
|
||||
# let any threads do their thing, then ack
|
||||
# send an ack last
|
||||
ack = messaging.AckMessage(
|
||||
self.config["aprs"]["login"],
|
||||
fromcall,
|
||||
msg_id=msg_id,
|
||||
)
|
||||
ack.send()
|
||||
|
||||
pm = plugin.PluginManager()
|
||||
try:
|
||||
results = pm.run(packet)
|
||||
replied = False
|
||||
for reply in results:
|
||||
if isinstance(reply, list):
|
||||
# one of the plugins wants to send multiple messages
|
||||
replied = True
|
||||
for subreply in reply:
|
||||
LOG.debug(f"Sending '{subreply}'")
|
||||
if isinstance(subreply, messaging.Message):
|
||||
subreply.send()
|
||||
else:
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprs"]["login"],
|
||||
fromcall,
|
||||
subreply,
|
||||
)
|
||||
msg.send()
|
||||
elif isinstance(reply, messaging.Message):
|
||||
# We have a message based object.
|
||||
LOG.debug(f"Sending '{reply}'")
|
||||
reply.send()
|
||||
replied = True
|
||||
else:
|
||||
replied = True
|
||||
# A plugin can return a null message flag which signals
|
||||
# us that they processed the message correctly, but have
|
||||
# nothing to reply with, so we avoid replying with a
|
||||
# usage string
|
||||
if reply is not messaging.NULL_MESSAGE:
|
||||
LOG.debug(f"Sending '{reply}'")
|
||||
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprs"]["login"],
|
||||
fromcall,
|
||||
reply,
|
||||
)
|
||||
msg.send()
|
||||
|
||||
# If the message was for us and we didn't have a
|
||||
# response, then we send a usage statement.
|
||||
if tocall == self.config["aprs"]["login"] and not replied:
|
||||
LOG.warning("Sending help!")
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprs"]["login"],
|
||||
fromcall,
|
||||
"Unknown command! Send 'help' message for help",
|
||||
)
|
||||
msg.send()
|
||||
except Exception as ex:
|
||||
LOG.exception("Plugin failed!!!", ex)
|
||||
# Do we need to send a reply?
|
||||
if tocall == self.config["aprs"]["login"]:
|
||||
reply = "A Plugin failed! try again?"
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprs"]["login"],
|
||||
fromcall,
|
||||
reply,
|
||||
)
|
||||
msg.send()
|
||||
|
||||
LOG.debug("Packet processing complete")
|
||||
@@ -0,0 +1,13 @@
|
||||
import queue
|
||||
|
||||
# Make these available to anyone importing
|
||||
# aprsd.threads
|
||||
from .aprsd import APRSDThread, APRSDThreadList # noqa: F401
|
||||
from .keep_alive import KeepAliveThread # noqa: F401
|
||||
from .rx import APRSDRXThread # noqa: F401
|
||||
|
||||
|
||||
rx_msg_queue = queue.Queue(maxsize=20)
|
||||
msg_queues = {
|
||||
"rx": rx_msg_queue,
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import abc
|
||||
import logging
|
||||
from queue import Queue
|
||||
import threading
|
||||
|
||||
import wrapt
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class APRSDThreadList:
|
||||
"""Singleton class that keeps track of application wide threads."""
|
||||
|
||||
_instance = None
|
||||
|
||||
threads_list = []
|
||||
lock = threading.Lock()
|
||||
global_queue = Queue()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls.threads_list = []
|
||||
return cls._instance
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def add(self, thread_obj):
|
||||
thread_obj.set_global_queue(self.global_queue)
|
||||
self.threads_list.append(thread_obj)
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def remove(self, thread_obj):
|
||||
self.threads_list.remove(thread_obj)
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def stop_all(self):
|
||||
self.global_queue.put_nowait({"quit": True})
|
||||
"""Iterate over all threads and call stop on them."""
|
||||
for th in self.threads_list:
|
||||
LOG.info(f"Stopping Thread {th.name}")
|
||||
th.stop()
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def __len__(self):
|
||||
return len(self.threads_list)
|
||||
|
||||
|
||||
class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
|
||||
|
||||
global_queue = None
|
||||
|
||||
def __init__(self, name):
|
||||
super().__init__(name=name)
|
||||
self.thread_stop = False
|
||||
APRSDThreadList().add(self)
|
||||
|
||||
def set_global_queue(self, global_queue):
|
||||
self.global_queue = global_queue
|
||||
|
||||
def _should_quit(self):
|
||||
""" see if we have a quit message from the global queue."""
|
||||
if self.thread_stop:
|
||||
return True
|
||||
if self.global_queue.empty():
|
||||
return False
|
||||
msg = self.global_queue.get(timeout=1)
|
||||
if not msg:
|
||||
return False
|
||||
if "quit" in msg and msg["quit"] is True:
|
||||
# put the message back on the queue for others
|
||||
self.global_queue.put_nowait(msg)
|
||||
self.thread_stop = True
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
self.thread_stop = True
|
||||
|
||||
@abc.abstractmethod
|
||||
def loop(self):
|
||||
pass
|
||||
|
||||
def _cleanup(self):
|
||||
"""Add code to subclass to do any cleanup"""
|
||||
|
||||
def run(self):
|
||||
LOG.debug("Starting")
|
||||
while not self._should_quit():
|
||||
can_loop = self.loop()
|
||||
if not can_loop:
|
||||
self.stop()
|
||||
self._cleanup()
|
||||
APRSDThreadList().remove(self)
|
||||
LOG.debug("Exiting")
|
||||
@@ -0,0 +1,88 @@
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
import tracemalloc
|
||||
|
||||
from aprsd import client, messaging, packets, stats, utils
|
||||
from aprsd.threads import APRSDThread, APRSDThreadList
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class KeepAliveThread(APRSDThread):
|
||||
cntr = 0
|
||||
checker_time = datetime.datetime.now()
|
||||
|
||||
def __init__(self, config):
|
||||
tracemalloc.start()
|
||||
super().__init__("KeepAlive")
|
||||
self.config = config
|
||||
max_timeout = {"hours": 0.0, "minutes": 2, "seconds": 0}
|
||||
self.max_delta = datetime.timedelta(**max_timeout)
|
||||
|
||||
def loop(self):
|
||||
if self.cntr % 60 == 0:
|
||||
tracker = messaging.MsgTrack()
|
||||
stats_obj = stats.APRSDStats()
|
||||
pl = packets.PacketList()
|
||||
thread_list = APRSDThreadList()
|
||||
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)
|
||||
|
||||
try:
|
||||
login = self.config["aprsd"]["callsign"]
|
||||
except KeyError:
|
||||
login = self.config["ham"]["callsign"]
|
||||
|
||||
keepalive = (
|
||||
"{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} "
|
||||
"Last:{} Email: {} - RAM Current:{} Peak:{} Threads:{}"
|
||||
).format(
|
||||
login,
|
||||
utils.strfdelta(stats_obj.uptime),
|
||||
pl.total_recv,
|
||||
pl.total_tx,
|
||||
len(tracker),
|
||||
stats_obj.msgs_tx,
|
||||
stats_obj.msgs_rx,
|
||||
last_msg_time,
|
||||
email_thread_time,
|
||||
utils.human_size(current),
|
||||
utils.human_size(peak),
|
||||
len(thread_list),
|
||||
)
|
||||
LOG.info(keepalive)
|
||||
|
||||
# See if we should reset the aprs-is client
|
||||
# Due to losing a keepalive from them
|
||||
delta_dict = utils.parse_delta_str(last_msg_time)
|
||||
delta = datetime.timedelta(**delta_dict)
|
||||
|
||||
if delta > self.max_delta:
|
||||
# We haven't gotten a keepalive from aprs-is in a while
|
||||
# reset the connection.a
|
||||
if not client.KISSClient.is_enabled(self.config):
|
||||
LOG.warning(f"Resetting connection to APRS-IS {delta}")
|
||||
client.factory.create().reset()
|
||||
|
||||
# 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(1)
|
||||
return True
|
||||
@@ -0,0 +1,236 @@
|
||||
import abc
|
||||
import logging
|
||||
import time
|
||||
|
||||
import aprslib
|
||||
|
||||
from aprsd import client, messaging, packets, plugin, stats
|
||||
from aprsd.threads import APRSDThread
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
|
||||
|
||||
class APRSDRXThread(APRSDThread):
|
||||
def __init__(self, msg_queues, config):
|
||||
super().__init__("RX_MSG")
|
||||
self.msg_queues = msg_queues
|
||||
self.config = config
|
||||
self._client = client.factory.create()
|
||||
|
||||
def stop(self):
|
||||
self.thread_stop = True
|
||||
client.factory.create().client.stop()
|
||||
|
||||
def loop(self):
|
||||
|
||||
# setup the consumer of messages and block until a messages
|
||||
try:
|
||||
# This will register a packet consumer with aprslib
|
||||
# When new packets come in the consumer will process
|
||||
# the packet
|
||||
|
||||
# Do a partial here because the consumer signature doesn't allow
|
||||
# For kwargs to be passed in to the consumer func we declare
|
||||
# and the aprslib developer didn't want to allow a PR to add
|
||||
# kwargs. :(
|
||||
# https://github.com/rossengeorgiev/aprs-python/pull/56
|
||||
self._client.client.consumer(
|
||||
self.process_packet, raw=False, blocking=False,
|
||||
)
|
||||
|
||||
except (
|
||||
aprslib.exceptions.ConnectionDrop,
|
||||
aprslib.exceptions.ConnectionError,
|
||||
):
|
||||
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
|
||||
self._client.reset()
|
||||
# Continue to loop
|
||||
return True
|
||||
|
||||
@abc.abstractmethod
|
||||
def process_packet(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class APRSDPluginRXThread(APRSDRXThread):
|
||||
"""Process received packets.
|
||||
|
||||
This is the main APRSD Server command thread that
|
||||
receives packets from APRIS and then sends them for
|
||||
processing in the PluginProcessPacketThread.
|
||||
"""
|
||||
def process_packet(self, *args, **kwargs):
|
||||
packet = self._client.decode_packet(*args, **kwargs)
|
||||
thread = APRSDPluginProcessPacketThread(
|
||||
config=self.config,
|
||||
packet=packet,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
class APRSDProcessPacketThread(APRSDThread):
|
||||
"""Base class for processing received packets.
|
||||
|
||||
This is the base class for processing packets coming from
|
||||
the consumer. This base class handles sending ack packets and
|
||||
will ack a message before sending the packet to the subclass
|
||||
for processing."""
|
||||
|
||||
def __init__(self, config, packet):
|
||||
self.config = config
|
||||
self.packet = packet
|
||||
name = self.packet["raw"][:10]
|
||||
super().__init__(f"RXPKT-{name}")
|
||||
|
||||
def process_ack_packet(self, packet):
|
||||
ack_num = packet.get("msgNo")
|
||||
LOG.info(f"Got ack for message {ack_num}")
|
||||
messaging.log_message(
|
||||
"RXACK",
|
||||
packet["raw"],
|
||||
None,
|
||||
ack=ack_num,
|
||||
fromcall=packet["from"],
|
||||
)
|
||||
tracker = messaging.MsgTrack()
|
||||
tracker.remove(ack_num)
|
||||
stats.APRSDStats().ack_rx_inc()
|
||||
return
|
||||
|
||||
def loop(self):
|
||||
"""Process a packet received from aprs-is server."""
|
||||
packet = self.packet
|
||||
packets.PacketList().add(packet)
|
||||
|
||||
fromcall = packet["from"]
|
||||
tocall = packet.get("addresse", None)
|
||||
msg = packet.get("message_text", None)
|
||||
msg_id = packet.get("msgNo", "0")
|
||||
msg_response = packet.get("response", None)
|
||||
# LOG.debug(f"Got packet from '{fromcall}' - {packet}")
|
||||
|
||||
# We don't put ack packets destined for us through the
|
||||
# plugins.
|
||||
if (
|
||||
tocall
|
||||
and tocall.lower() == self.config["aprsd"]["callsign"].lower()
|
||||
and msg_response == "ack"
|
||||
):
|
||||
self.process_ack_packet(packet)
|
||||
else:
|
||||
# It's not an ACK for us, so lets run it through
|
||||
# the plugins.
|
||||
messaging.log_message(
|
||||
"Received Message",
|
||||
packet["raw"],
|
||||
msg,
|
||||
fromcall=fromcall,
|
||||
msg_num=msg_id,
|
||||
)
|
||||
|
||||
# Only ack messages that were sent directly to us
|
||||
if (
|
||||
tocall
|
||||
and tocall.lower() == self.config["aprsd"]["callsign"].lower()
|
||||
):
|
||||
stats.APRSDStats().msgs_rx_inc()
|
||||
# let any threads do their thing, then ack
|
||||
# send an ack last
|
||||
ack = messaging.AckMessage(
|
||||
self.config["aprsd"]["callsign"],
|
||||
fromcall,
|
||||
msg_id=msg_id,
|
||||
)
|
||||
ack.send()
|
||||
|
||||
self.process_non_ack_packet(packet)
|
||||
else:
|
||||
LOG.info("Packet was not for us.")
|
||||
LOG.debug("Packet processing complete")
|
||||
|
||||
@abc.abstractmethod
|
||||
def process_non_ack_packet(self, *args, **kwargs):
|
||||
"""Ack packets already dealt with here."""
|
||||
|
||||
|
||||
class APRSDPluginProcessPacketThread(APRSDProcessPacketThread):
|
||||
"""Process the packet through the plugin manager.
|
||||
|
||||
This is the main aprsd server plugin processing thread."""
|
||||
|
||||
def process_non_ack_packet(self, packet):
|
||||
"""Send the packet through the plugins."""
|
||||
fromcall = packet["from"]
|
||||
tocall = packet.get("addresse", None)
|
||||
msg = packet.get("message_text", None)
|
||||
packet.get("msgNo", "0")
|
||||
packet.get("response", None)
|
||||
pm = plugin.PluginManager()
|
||||
try:
|
||||
results = pm.run(packet)
|
||||
wl = packets.WatchList()
|
||||
wl.update_seen(packet)
|
||||
replied = False
|
||||
for reply in results:
|
||||
if isinstance(reply, list):
|
||||
# one of the plugins wants to send multiple messages
|
||||
replied = True
|
||||
for subreply in reply:
|
||||
LOG.debug(f"Sending '{subreply}'")
|
||||
if isinstance(subreply, messaging.Message):
|
||||
subreply.send()
|
||||
else:
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprsd"]["callsign"],
|
||||
fromcall,
|
||||
subreply,
|
||||
)
|
||||
msg.send()
|
||||
elif isinstance(reply, messaging.Message):
|
||||
# We have a message based object.
|
||||
LOG.debug(f"Sending '{reply}'")
|
||||
reply.send()
|
||||
replied = True
|
||||
else:
|
||||
replied = True
|
||||
# A plugin can return a null message flag which signals
|
||||
# us that they processed the message correctly, but have
|
||||
# nothing to reply with, so we avoid replying with a
|
||||
# usage string
|
||||
if reply is not messaging.NULL_MESSAGE:
|
||||
LOG.debug(f"Sending '{reply}'")
|
||||
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprsd"]["callsign"],
|
||||
fromcall,
|
||||
reply,
|
||||
)
|
||||
msg.send()
|
||||
|
||||
# If the message was for us and we didn't have a
|
||||
# response, then we send a usage statement.
|
||||
if tocall == self.config["aprsd"]["callsign"] and not replied:
|
||||
LOG.warning("Sending help!")
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprsd"]["callsign"],
|
||||
fromcall,
|
||||
"Unknown command! Send 'help' message for help",
|
||||
)
|
||||
msg.send()
|
||||
except Exception as ex:
|
||||
LOG.error("Plugin failed!!!")
|
||||
LOG.exception(ex)
|
||||
# Do we need to send a reply?
|
||||
if tocall == self.config["aprsd"]["callsign"]:
|
||||
reply = "A Plugin failed! try again?"
|
||||
msg = messaging.TextMessage(
|
||||
self.config["aprsd"]["callsign"],
|
||||
fromcall,
|
||||
reply,
|
||||
)
|
||||
msg.send()
|
||||
@@ -1,26 +1,24 @@
|
||||
"""Utilities and helper functions."""
|
||||
|
||||
import collections
|
||||
import errno
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import sys
|
||||
|
||||
import update_checker
|
||||
|
||||
import aprsd
|
||||
|
||||
from .fuzzyclock import fuzzy # noqa: F401
|
||||
# Make these available by anyone importing
|
||||
# aprsd.utils
|
||||
from .ring_buffer import RingBuffer # noqa: F401
|
||||
|
||||
def synchronized(wrapped):
|
||||
lock = threading.Lock()
|
||||
|
||||
@functools.wraps(wrapped)
|
||||
def _wrap(*args, **kwargs):
|
||||
with lock:
|
||||
return wrapped(*args, **kwargs)
|
||||
|
||||
return _wrap
|
||||
if sys.version_info.major == 3 and sys.version_info.minor >= 3:
|
||||
from collections.abc import MutableMapping
|
||||
else:
|
||||
from collections import MutableMapping
|
||||
|
||||
|
||||
def env(*vars, **kwargs):
|
||||
@@ -60,6 +58,17 @@ def end_substr(original, substr):
|
||||
return idx
|
||||
|
||||
|
||||
def rgb_from_name(name):
|
||||
"""Create an rgb tuple from a string."""
|
||||
hash = 0
|
||||
for char in name:
|
||||
hash = ord(char) + ((hash << 5) - hash)
|
||||
red = hash & 255
|
||||
green = (hash >> 8) & 255
|
||||
blue = (hash >> 16) & 255
|
||||
return red, green, blue
|
||||
|
||||
|
||||
def human_size(bytes, units=None):
|
||||
"""Returns a human readable string representation of bytes"""
|
||||
if not units:
|
||||
@@ -102,7 +111,7 @@ def flatten_dict(d, parent_key="", sep="."):
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = parent_key + sep + k if parent_key else k
|
||||
if isinstance(v, collections.MutableMapping):
|
||||
if isinstance(v, MutableMapping):
|
||||
items.extend(flatten_dict(v, new_key, sep=sep).items())
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
@@ -118,42 +127,3 @@ def parse_delta_str(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 __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
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)
|
||||
@@ -70,7 +70,8 @@ class ObjectStoreMixin:
|
||||
"""Save any queued to disk?"""
|
||||
if len(self) > 0:
|
||||
LOG.info(f"{self.__class__.__name__}::Saving {len(self)} entries to disk at {self._save_location()}")
|
||||
pickle.dump(self._dump(), open(self._save_filename(), "wb+"))
|
||||
with open(self._save_filename(), "wb+") as fp:
|
||||
pickle.dump(self._dump(), fp)
|
||||
else:
|
||||
LOG.debug(
|
||||
"{} Nothing to save, flushing old save file '{}'".format(
|
||||
@@ -82,11 +83,19 @@ class ObjectStoreMixin:
|
||||
|
||||
def load(self):
|
||||
if os.path.exists(self._save_filename()):
|
||||
raw = pickle.load(open(self._save_filename(), "rb"))
|
||||
if raw:
|
||||
self.data = raw
|
||||
LOG.debug(f"{self.__class__.__name__}::Loaded {len(self)} entries from disk.")
|
||||
LOG.debug(f"{self.data}")
|
||||
try:
|
||||
with open(self._save_filename(), "rb") as fp:
|
||||
raw = pickle.load(fp)
|
||||
if raw:
|
||||
self.data = raw
|
||||
LOG.debug(
|
||||
f"{self.__class__.__name__}::Loaded {len(self)} entries from disk.",
|
||||
)
|
||||
LOG.debug(f"{self.data}")
|
||||
except (pickle.UnpicklingError, Exception) as ex:
|
||||
LOG.error(f"Failed to UnPickle {self._save_filename()}")
|
||||
LOG.error(ex)
|
||||
self.data = {}
|
||||
|
||||
def flush(self):
|
||||
"""Nuke the old pickle file that stored the old results from last aprsd run."""
|
||||
@@ -0,0 +1,37 @@
|
||||
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 __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
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)
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,94 @@
|
||||
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;
|
||||
}
|
||||
|
||||
ul.list {
|
||||
list-style-type: disc;
|
||||
}
|
||||
ul.list li {
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
#left {
|
||||
margin-right: 2px;
|
||||
height: 300px;
|
||||
}
|
||||
#right {
|
||||
height: 300px;
|
||||
}
|
||||
#center {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
.aprsd_1 {
|
||||
background-image: url(/static/images/aprs-symbols-16-0.png);
|
||||
background-repeat: no-repeat;
|
||||
background-position: -160px -48px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
#msgsTabsDiv .ui.tab {
|
||||
margin:0px;
|
||||
padding:0px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#msgsTabsDiv .header, .tiny.text, .content, .break,
|
||||
.thumbs.down.outline.icon,
|
||||
.phone.volume.icon
|
||||
{
|
||||
display: inline-block;
|
||||
float: left;
|
||||
position: relative;
|
||||
}
|
||||
#msgsTabsDiv .tiny.text {
|
||||
width:100px;
|
||||
}
|
||||
#msgsTabsDiv .tiny.header {
|
||||
width:100px;
|
||||
text-align: left;
|
||||
}
|
||||
#msgsTabsDiv .break {
|
||||
margin: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
* {box-sizing: border-box}
|
||||
|
||||
/* Style the tab */
|
||||
.tab {
|
||||
border: 1px solid #ccc;
|
||||
background-color: #f1f1f1;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
/* Style the buttons inside the tab */
|
||||
.tab div {
|
||||
display: block;
|
||||
background-color: inherit;
|
||||
color: black;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* Change background color of buttons on hover */
|
||||
.tab div:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
/* Create an active/current "tab button" class */
|
||||
.tab div.active {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* Style the tab content */
|
||||
.tabcontent {
|
||||
border: 1px solid #ccc;
|
||||
height: 450px;
|
||||
overflow-y: scroll;
|
||||
background-color: white;
|
||||
}
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,73 @@
|
||||
|
||||
function init_gps() {
|
||||
console.log("init_gps Called.")
|
||||
$("#send_beacon").click(function() {
|
||||
console.log("Send a beacon!")
|
||||
getLocation();
|
||||
});
|
||||
}
|
||||
|
||||
function getLocation() {
|
||||
if (navigator.geolocation) {
|
||||
console.log("getCurrentPosition");
|
||||
try {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
showPosition, showError,
|
||||
{timeout:3000});
|
||||
} catch(err) {
|
||||
console.log("Failed to getCurrentPosition");
|
||||
console.log(err);
|
||||
}
|
||||
} else {
|
||||
var msg = "Geolocation is not supported by this browser."
|
||||
console.log(msg);
|
||||
alert(msg)
|
||||
}
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
console.log("showError");
|
||||
console.log(error);
|
||||
var msg = "";
|
||||
switch(error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
msg = "User denied the request for Geolocation."
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
msg = "Location information is unavailable."
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
msg = "The location fix timed out."
|
||||
break;
|
||||
case error.UNKNOWN_ERROR:
|
||||
msg = "An unknown error occurred."
|
||||
break;
|
||||
}
|
||||
console.log(msg);
|
||||
$.toast({
|
||||
title: 'GPS Error',
|
||||
class: 'warning',
|
||||
position: 'middle center',
|
||||
message: msg,
|
||||
showProgress: 'top',
|
||||
classProgress: 'blue',
|
||||
});
|
||||
}
|
||||
|
||||
function showPosition(position) {
|
||||
console.log("showPosition Called");
|
||||
msg = {
|
||||
'latitude': position.coords.latitude,
|
||||
'longitude': position.coords.longitude
|
||||
}
|
||||
console.log(msg);
|
||||
$.toast({
|
||||
title: 'Sending GPS Beacon',
|
||||
message: "Latitude: "+position.coords.latitude+"<br>Longitude: "+position.coords.longitude,
|
||||
showProgress: 'bottom',
|
||||
classProgress: 'red'
|
||||
});
|
||||
|
||||
console.log("Sending GPS msg")
|
||||
socket.emit("gps", msg);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
function aprs_img(item, x_offset, y_offset) {
|
||||
var x = x_offset * -16;
|
||||
if (y_offset > 5) {
|
||||
y_offset = 5;
|
||||
}
|
||||
var y = y_offset * -16;
|
||||
var loc = x + 'px '+ y + 'px'
|
||||
item.css('background-position', loc);
|
||||
}
|
||||
|
||||
function show_aprs_icon(item, symbol) {
|
||||
var offset = ord(symbol) - 33;
|
||||
var col = Math.floor(offset / 16);
|
||||
var row = offset % 16;
|
||||
//console.log("'" + symbol+"' off: "+offset+" row: "+ row + " col: " + col)
|
||||
aprs_img(item, row, col);
|
||||
}
|
||||
|
||||
function ord(str){return str.charCodeAt(0);}
|
||||
|
||||
function update_stats( data ) {
|
||||
$("#version").text( data["stats"]["aprsd"]["version"] );
|
||||
$("#aprs_connection").html( data["aprs_connection"] );
|
||||
$("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] );
|
||||
short_time = data["time"].split(/\s(.+)/)[1];
|
||||
}
|
||||
|
||||
|
||||
function start_update() {
|
||||
|
||||
(function statsworker() {
|
||||
$.ajax({
|
||||
url: "/stats",
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
update_stats(data);
|
||||
},
|
||||
complete: function() {
|
||||
setTimeout(statsworker, 10000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
var cleared = false;
|
||||
var callsign_list = {};
|
||||
var message_list = {};
|
||||
|
||||
function size_dict(d){c=0; for (i in d) ++c; return c}
|
||||
|
||||
function init_chat() {
|
||||
const socket = io("/sendmsg");
|
||||
socket.on('connect', function () {
|
||||
console.log("Connected to socketio");
|
||||
});
|
||||
socket.on('connected', function(msg) {
|
||||
console.log("Connected!");
|
||||
console.log(msg);
|
||||
});
|
||||
|
||||
socket.on("sent", function(msg) {
|
||||
if (cleared == false) {
|
||||
var msgsdiv = $("#msgsTabsDiv");
|
||||
msgsdiv.html('')
|
||||
cleared = true
|
||||
}
|
||||
sent_msg(msg);
|
||||
});
|
||||
|
||||
socket.on("ack", function(msg) {
|
||||
update_msg(msg);
|
||||
});
|
||||
|
||||
socket.on("new", function(msg) {
|
||||
if (cleared == false) {
|
||||
var msgsdiv = $("#msgsTabsDiv");
|
||||
msgsdiv.html('')
|
||||
cleared = true
|
||||
}
|
||||
from_msg(msg);
|
||||
});
|
||||
|
||||
$("#sendform").submit(function(event) {
|
||||
event.preventDefault();
|
||||
msg = {'to': $('#to_call').val().toUpperCase(),
|
||||
'message': $('#message').val(),
|
||||
}
|
||||
socket.emit("send", msg);
|
||||
$('#message').val('');
|
||||
$('#to_call').val('');
|
||||
});
|
||||
|
||||
init_gps();
|
||||
}
|
||||
|
||||
|
||||
function add_callsign(callsign) {
|
||||
/* Ensure a callsign exists in the left hand nav */
|
||||
dropdown = $('#callsign_dropdown')
|
||||
|
||||
if (callsign in callsign_list) {
|
||||
console.log(callsign+' already in list.')
|
||||
return false
|
||||
}
|
||||
|
||||
var callsignTabs = $("#callsignTabs");
|
||||
tab_name = tab_string(callsign);
|
||||
tab_content = tab_content_name(callsign);
|
||||
divname = content_divname(callsign);
|
||||
|
||||
item_html = '<div class="active item" id="'+tab_name+'" onclick="openCallsign(event, \''+callsign+'\');">'+callsign+'</div>';
|
||||
callsignTabs.append(item_html);
|
||||
|
||||
callsign_list[callsign] = {'name': callsign, 'value': callsign, 'text': callsign}
|
||||
return true
|
||||
}
|
||||
|
||||
function append_message(callsign, msg, msg_html) {
|
||||
console.log('append_message');
|
||||
new_callsign = false
|
||||
if (!message_list.hasOwnProperty(callsign)) {
|
||||
message_list[callsign] = new Array();
|
||||
}
|
||||
message_list[callsign].push(msg);
|
||||
|
||||
// Find the right div to place the html
|
||||
new_callsign = add_callsign(callsign);
|
||||
append_message_html(callsign, msg_html, new_callsign);
|
||||
if (new_callsign) {
|
||||
//click on the new tab
|
||||
click_div = '#'+tab_string(callsign);
|
||||
console.log("Click on "+click_div);
|
||||
$(click_div).click();
|
||||
}
|
||||
}
|
||||
|
||||
function tab_string(callsign) {
|
||||
return "msgs"+callsign;
|
||||
}
|
||||
|
||||
function tab_content_name(callsign) {
|
||||
return tab_string(callsign)+"Content";
|
||||
}
|
||||
|
||||
function content_divname(callsign) {
|
||||
return "#"+tab_content_name(callsign);
|
||||
}
|
||||
|
||||
function append_message_html(callsign, msg_html, new_callsign) {
|
||||
var msgsTabs = $('#msgsTabsDiv');
|
||||
divname_str = tab_content_name(callsign);
|
||||
divname = content_divname(callsign);
|
||||
if (new_callsign) {
|
||||
// we have to add a new DIV
|
||||
msg_div_html = '<div class="tabcontent" id="'+divname_str+'" style="height:450px;">'+msg_html+'</div>';
|
||||
msgsTabs.append(msg_div_html);
|
||||
} else {
|
||||
var msgDiv = $(divname);
|
||||
msgDiv.append(msg_html);
|
||||
}
|
||||
|
||||
$(divname).animate({scrollTop: $(divname)[0].scrollHeight}, "slow");
|
||||
}
|
||||
|
||||
function create_message_html(time, from, to, message, ack) {
|
||||
msg_html = '<div class="item">';
|
||||
msg_html += '<div class="tiny text">'+time+'</div>';
|
||||
msg_html += '<div class="middle aligned content">';
|
||||
msg_html += '<div class="tiny red header">'+from+'</div>';
|
||||
if (ack) {
|
||||
msg_html += '<i class="thumbs down outline icon" id="' + ack_id + '" data-content="Waiting for ACK"></i>';
|
||||
} else {
|
||||
msg_html += '<i class="phone volume icon" data-content="Recieved Message"></i>';
|
||||
}
|
||||
msg_html += '<div class="middle aligned content">> </div>';
|
||||
msg_html += '</div>';
|
||||
msg_html += '<div class="middle aligned content">'+message+'</div>';
|
||||
msg_html += '</div><br>';
|
||||
|
||||
return msg_html
|
||||
}
|
||||
|
||||
function sent_msg(msg) {
|
||||
var msgsdiv = $("#sendMsgsDiv");
|
||||
|
||||
ts_str = msg["ts"].toString();
|
||||
ts = ts_str.split(".")[0]*1000;
|
||||
id = ts_str.split('.')[0]
|
||||
ack_id = "ack_" + id
|
||||
|
||||
var d = new Date(ts).toLocaleDateString("en-US")
|
||||
var t = new Date(ts).toLocaleTimeString("en-US")
|
||||
|
||||
msg_html = create_message_html(t, msg['from'], msg['to'], msg['message'], ack_id);
|
||||
append_message(msg['to'], msg, msg_html);
|
||||
}
|
||||
|
||||
function from_msg(msg) {
|
||||
var msgsdiv = $("#sendMsgsDiv");
|
||||
|
||||
// We have an existing entry
|
||||
ts_str = msg["ts"].toString();
|
||||
ts = ts_str.split(".")[0]*1000;
|
||||
id = ts_str.split('.')[0]
|
||||
ack_id = "ack_" + id
|
||||
|
||||
var d = new Date(ts).toLocaleDateString("en-US")
|
||||
var t = new Date(ts).toLocaleTimeString("en-US")
|
||||
|
||||
from = msg['from']
|
||||
msg_html = create_message_html(t, from, false, msg['message'], false);
|
||||
append_message(from, msg, msg_html);
|
||||
}
|
||||
|
||||
function update_msg(msg) {
|
||||
var msgsdiv = $("#sendMsgsDiv");
|
||||
// We have an existing entry
|
||||
ts_str = msg["ts"].toString();
|
||||
id = ts_str.split('.')[0]
|
||||
pretty_id = "pretty_" + id
|
||||
loader_id = "loader_" + id
|
||||
ack_id = "ack_" + id
|
||||
span_id = "span_" + id
|
||||
|
||||
|
||||
|
||||
if (msg['ack'] == true) {
|
||||
var loader_div = $('#' + loader_id);
|
||||
var ack_div = $('#' + ack_id);
|
||||
loader_div.removeClass('ui active inline loader');
|
||||
loader_div.addClass('ui disabled loader');
|
||||
ack_div.removeClass('thumbs up outline icon');
|
||||
ack_div.addClass('thumbs up outline icon');
|
||||
}
|
||||
|
||||
$('.ui.accordion').accordion('refresh');
|
||||
}
|
||||
|
||||
function callsign_select(callsign) {
|
||||
var tocall = $("#to_call");
|
||||
tocall.val(callsign);
|
||||
}
|
||||
|
||||
function reset_Tabs() {
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function openCallsign(evt, callsign) {
|
||||
var i, tabcontent, tablinks;
|
||||
|
||||
tab_content = tab_content_name(callsign);
|
||||
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
tablinks = document.getElementsByClassName("tablinks");
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
||||
}
|
||||
document.getElementById(tab_content).style.display = "block";
|
||||
evt.target.className += " active";
|
||||
callsign_select(callsign);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
var cleared = false;
|
||||
var callsign_list = {};
|
||||
var message_list = {};
|
||||
const socket = io("/sendmsg");
|
||||
|
||||
function size_dict(d){c=0; for (i in d) ++c; return c}
|
||||
|
||||
function init_chat() {
|
||||
socket.on('connect', function () {
|
||||
console.log("Connected to socketio");
|
||||
});
|
||||
socket.on('connected', function(msg) {
|
||||
console.log("Connected!");
|
||||
console.log(msg);
|
||||
});
|
||||
|
||||
socket.on("sent", function(msg) {
|
||||
if (cleared == false) {
|
||||
var msgsdiv = $("#msgsTabsDiv");
|
||||
msgsdiv.html('')
|
||||
cleared = true
|
||||
}
|
||||
sent_msg(msg);
|
||||
});
|
||||
|
||||
socket.on("ack", function(msg) {
|
||||
update_msg(msg);
|
||||
});
|
||||
|
||||
socket.on("new", function(msg) {
|
||||
if (cleared == false) {
|
||||
var msgsdiv = $("#msgsTabsDiv");
|
||||
msgsdiv.html('')
|
||||
cleared = true
|
||||
}
|
||||
from_msg(msg);
|
||||
});
|
||||
|
||||
$("#sendform").submit(function(event) {
|
||||
event.preventDefault();
|
||||
msg = {'to': $('#to_call').val(),
|
||||
'message': $('#message').val(),
|
||||
}
|
||||
socket.emit("send", msg);
|
||||
$('#message').val('');
|
||||
});
|
||||
|
||||
init_gps();
|
||||
}
|
||||
|
||||
function add_callsign(callsign) {
|
||||
/* Ensure a callsign exists in the left hand nav */
|
||||
|
||||
if (callsign in callsign_list) {
|
||||
return false
|
||||
}
|
||||
|
||||
var callsignTabs = $("#callsignTabs");
|
||||
tab_name = tab_string(callsign);
|
||||
tab_content = tab_content_name(callsign);
|
||||
divname = content_divname(callsign);
|
||||
|
||||
item_html = '<div class="tablinks" id="'+tab_name+'" onclick="openCallsign(event, \''+callsign+'\');">'+callsign+'</div>';
|
||||
callsignTabs.append(item_html);
|
||||
callsign_list[callsign] = true;
|
||||
return true
|
||||
}
|
||||
|
||||
function append_message(callsign, msg, msg_html) {
|
||||
new_callsign = false
|
||||
if (!message_list.hasOwnProperty(callsign)) {
|
||||
message_list[callsign] = new Array();
|
||||
}
|
||||
message_list[callsign].push(msg);
|
||||
|
||||
// Find the right div to place the html
|
||||
new_callsign = add_callsign(callsign);
|
||||
append_message_html(callsign, msg_html, new_callsign);
|
||||
if (new_callsign) {
|
||||
//click on the new tab
|
||||
click_div = '#'+tab_string(callsign);
|
||||
$(click_div).click();
|
||||
}
|
||||
}
|
||||
|
||||
function tab_string(callsign) {
|
||||
return "msgs"+callsign;
|
||||
}
|
||||
|
||||
function tab_content_name(callsign) {
|
||||
return tab_string(callsign)+"Content";
|
||||
}
|
||||
|
||||
function content_divname(callsign) {
|
||||
return "#"+tab_content_name(callsign);
|
||||
}
|
||||
|
||||
function append_message_html(callsign, msg_html, new_callsign) {
|
||||
var msgsTabs = $('#msgsTabsDiv');
|
||||
divname_str = tab_content_name(callsign);
|
||||
divname = content_divname(callsign);
|
||||
if (new_callsign) {
|
||||
// we have to add a new DIV
|
||||
msg_div_html = '<div class="tabcontent" id="'+divname_str+'" style="height:450px;">'+msg_html+'</div>';
|
||||
msgsTabs.append(msg_div_html);
|
||||
} else {
|
||||
var msgDiv = $(divname);
|
||||
msgDiv.append(msg_html);
|
||||
}
|
||||
|
||||
$(divname).animate({scrollTop: $(divname)[0].scrollHeight}, "slow");
|
||||
}
|
||||
|
||||
function create_message_html(time, from, to, message, ack) {
|
||||
msg_html = '<div class="item">';
|
||||
msg_html += '<div class="tiny text">'+time+'</div>';
|
||||
msg_html += '<div class="middle aligned content">';
|
||||
msg_html += '<div class="tiny red header">'+from+'</div>';
|
||||
if (ack) {
|
||||
msg_html += '<i class="thumbs down outline icon" id="' + ack_id + '" data-content="Waiting for ACK"></i>';
|
||||
} else {
|
||||
msg_html += '<i class="phone volume icon" data-content="Recieved Message"></i>';
|
||||
}
|
||||
msg_html += '<div class="middle aligned content">> </div>';
|
||||
msg_html += '</div>';
|
||||
msg_html += '<div class="middle aligned content">'+message+'</div>';
|
||||
msg_html += '</div><br>';
|
||||
|
||||
return msg_html
|
||||
}
|
||||
|
||||
function sent_msg(msg) {
|
||||
var msgsdiv = $("#sendMsgsDiv");
|
||||
|
||||
ts_str = msg["ts"].toString();
|
||||
ts = ts_str.split(".")[0]*1000;
|
||||
id = ts_str.split('.')[0]
|
||||
ack_id = "ack_" + id
|
||||
|
||||
var d = new Date(ts).toLocaleDateString("en-US")
|
||||
var t = new Date(ts).toLocaleTimeString("en-US")
|
||||
|
||||
msg_html = create_message_html(t, msg['from'], msg['to'], msg['message'], ack_id);
|
||||
append_message(msg['to'], msg, msg_html);
|
||||
}
|
||||
|
||||
function from_msg(msg) {
|
||||
var msgsdiv = $("#sendMsgsDiv");
|
||||
|
||||
// We have an existing entry
|
||||
ts_str = msg["ts"].toString();
|
||||
ts = ts_str.split(".")[0]*1000;
|
||||
id = ts_str.split('.')[0]
|
||||
ack_id = "ack_" + id
|
||||
|
||||
var d = new Date(ts).toLocaleDateString("en-US")
|
||||
var t = new Date(ts).toLocaleTimeString("en-US")
|
||||
|
||||
from = msg['from']
|
||||
msg_html = create_message_html(t, from, false, msg['message'], false);
|
||||
append_message(from, msg, msg_html);
|
||||
}
|
||||
|
||||
function update_msg(msg) {
|
||||
var msgsdiv = $("#sendMsgsDiv");
|
||||
// We have an existing entry
|
||||
ts_str = msg["ts"].toString();
|
||||
id = ts_str.split('.')[0]
|
||||
pretty_id = "pretty_" + id
|
||||
loader_id = "loader_" + id
|
||||
ack_id = "ack_" + id
|
||||
span_id = "span_" + id
|
||||
|
||||
|
||||
|
||||
if (msg['ack'] == true) {
|
||||
var loader_div = $('#' + loader_id);
|
||||
var ack_div = $('#' + ack_id);
|
||||
loader_div.removeClass('ui active inline loader');
|
||||
loader_div.addClass('ui disabled loader');
|
||||
ack_div.removeClass('thumbs up outline icon');
|
||||
ack_div.addClass('thumbs up outline icon');
|
||||
}
|
||||
|
||||
$('.ui.accordion').accordion('refresh');
|
||||
}
|
||||
|
||||
function callsign_select(callsign) {
|
||||
var tocall = $("#to_call");
|
||||
tocall.val(callsign);
|
||||
}
|
||||
|
||||
function reset_Tabs() {
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function openCallsign(evt, callsign) {
|
||||
var i, tabcontent, tablinks;
|
||||
|
||||
tab_content = tab_content_name(callsign);
|
||||
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
tablinks = document.getElementsByClassName("tablinks");
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
||||
}
|
||||
document.getElementById(tab_content).style.display = "block";
|
||||
evt.target.className += " active";
|
||||
callsign_select(callsign);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
function openTab(evt, tabName) {
|
||||
// Declare all variables
|
||||
var i, tabcontent, tablinks;
|
||||
|
||||
if (typeof tabName == 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all elements with class="tabcontent" and hide them
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
|
||||
// Get all elements with class="tablinks" and remove the class "active"
|
||||
tablinks = document.getElementsByClassName("tablinks");
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
||||
}
|
||||
|
||||
// Show the current tab, and add an "active" class to the button that opened the tab
|
||||
document.getElementById(tabName).style.display = "block";
|
||||
if (typeof evt.currentTarget == 'undefined') {
|
||||
return
|
||||
} else {
|
||||
evt.currentTarget.className += " active";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Root element */
|
||||
.json-document {
|
||||
padding: 1em 2em;
|
||||
}
|
||||
|
||||
/* Syntax highlighting for JSON objects */
|
||||
ul.json-dict, ol.json-array {
|
||||
list-style-type: none;
|
||||
margin: 0 0 0 1px;
|
||||
border-left: 1px dotted #ccc;
|
||||
padding-left: 2em;
|
||||
}
|
||||
.json-string {
|
||||
color: #0B7500;
|
||||
}
|
||||
.json-literal {
|
||||
color: #1A01CC;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Toggle button */
|
||||
a.json-toggle {
|
||||
position: relative;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.json-toggle:focus {
|
||||
outline: none;
|
||||
}
|
||||
a.json-toggle:before {
|
||||
font-size: 1.1em;
|
||||
color: #c0c0c0;
|
||||
content: "\25BC"; /* down arrow */
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
line-height: 1em;
|
||||
left: -1.2em;
|
||||
}
|
||||
a.json-toggle:hover:before {
|
||||
color: #aaa;
|
||||
}
|
||||
a.json-toggle.collapsed:before {
|
||||
/* Use rotated down arrow, prevents right arrow appearing smaller than down arrow in some browsers */
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Collapsable placeholder links */
|
||||
a.json-placeholder {
|
||||
color: #aaa;
|
||||
padding: 0 1em;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.json-placeholder:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* jQuery json-viewer
|
||||
* @author: Alexandre Bodelot <alexandre.bodelot@gmail.com>
|
||||
* @link: https://github.com/abodelot/jquery.json-viewer
|
||||
*/
|
||||
(function($) {
|
||||
|
||||
/**
|
||||
* Check if arg is either an array with at least 1 element, or a dict with at least 1 key
|
||||
* @return boolean
|
||||
*/
|
||||
function isCollapsable(arg) {
|
||||
return arg instanceof Object && Object.keys(arg).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string represents a valid url
|
||||
* @return boolean
|
||||
*/
|
||||
function isUrl(string) {
|
||||
var urlRegexp = /^(https?:\/\/|ftps?:\/\/)?([a-z0-9%-]+\.){1,}([a-z0-9-]+)?(:(\d{1,5}))?(\/([a-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+)?)?$/i;
|
||||
return urlRegexp.test(string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a json object into html representation
|
||||
* @return string
|
||||
*/
|
||||
function json2html(json, options) {
|
||||
var html = '';
|
||||
if (typeof json === 'string') {
|
||||
// Escape tags and quotes
|
||||
json = json
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
if (options.withLinks && isUrl(json)) {
|
||||
html += '<a href="' + json + '" class="json-string" target="_blank">' + json + '</a>';
|
||||
} else {
|
||||
// Escape double quotes in the rendered non-URL string.
|
||||
json = json.replace(/"/g, '\\"');
|
||||
html += '<span class="json-string">"' + json + '"</span>';
|
||||
}
|
||||
} else if (typeof json === 'number') {
|
||||
html += '<span class="json-literal">' + json + '</span>';
|
||||
} else if (typeof json === 'boolean') {
|
||||
html += '<span class="json-literal">' + json + '</span>';
|
||||
} else if (json === null) {
|
||||
html += '<span class="json-literal">null</span>';
|
||||
} else if (json instanceof Array) {
|
||||
if (json.length > 0) {
|
||||
html += '[<ol class="json-array">';
|
||||
for (var i = 0; i < json.length; ++i) {
|
||||
html += '<li>';
|
||||
// Add toggle button if item is collapsable
|
||||
if (isCollapsable(json[i])) {
|
||||
html += '<a href class="json-toggle"></a>';
|
||||
}
|
||||
html += json2html(json[i], options);
|
||||
// Add comma if item is not last
|
||||
if (i < json.length - 1) {
|
||||
html += ',';
|
||||
}
|
||||
html += '</li>';
|
||||
}
|
||||
html += '</ol>]';
|
||||
} else {
|
||||
html += '[]';
|
||||
}
|
||||
} else if (typeof json === 'object') {
|
||||
var keyCount = Object.keys(json).length;
|
||||
if (keyCount > 0) {
|
||||
html += '{<ul class="json-dict">';
|
||||
for (var key in json) {
|
||||
if (Object.prototype.hasOwnProperty.call(json, key)) {
|
||||
html += '<li>';
|
||||
var keyRepr = options.withQuotes ?
|
||||
'<span class="json-string">"' + key + '"</span>' : key;
|
||||
// Add toggle button if item is collapsable
|
||||
if (isCollapsable(json[key])) {
|
||||
html += '<a href class="json-toggle">' + keyRepr + '</a>';
|
||||
} else {
|
||||
html += keyRepr;
|
||||
}
|
||||
html += ': ' + json2html(json[key], options);
|
||||
// Add comma if item is not last
|
||||
if (--keyCount > 0) {
|
||||
html += ',';
|
||||
}
|
||||
html += '</li>';
|
||||
}
|
||||
}
|
||||
html += '</ul>}';
|
||||
} else {
|
||||
html += '{}';
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* jQuery plugin method
|
||||
* @param json: a javascript object
|
||||
* @param options: an optional options hash
|
||||
*/
|
||||
$.fn.jsonViewer = function(json, options) {
|
||||
// Merge user options with default options
|
||||
options = Object.assign({}, {
|
||||
collapsed: false,
|
||||
rootCollapsable: true,
|
||||
withQuotes: false,
|
||||
withLinks: true
|
||||
}, options);
|
||||
|
||||
// jQuery chaining
|
||||
return this.each(function() {
|
||||
|
||||
// Transform to HTML
|
||||
var html = json2html(json, options);
|
||||
if (options.rootCollapsable && isCollapsable(json)) {
|
||||
html = '<a href class="json-toggle"></a>' + html;
|
||||
}
|
||||
|
||||
// Insert HTML in target DOM element
|
||||
$(this).html(html);
|
||||
$(this).addClass('json-document');
|
||||
|
||||
// Bind click on toggle buttons
|
||||
$(this).off('click');
|
||||
$(this).on('click', 'a.json-toggle', function() {
|
||||
var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array');
|
||||
target.toggle();
|
||||
if (target.is(':visible')) {
|
||||
target.siblings('.json-placeholder').remove();
|
||||
} else {
|
||||
var count = target.children('li').length;
|
||||
var placeholder = count + (count > 1 ? ' items' : ' item');
|
||||
target.after('<a href class="json-placeholder">' + placeholder + '</a>');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Simulate click on toggle button when placeholder is clicked
|
||||
$(this).on('click', 'a.json-placeholder', function() {
|
||||
$(this).siblings('a.json-toggle').click();
|
||||
return false;
|
||||
});
|
||||
|
||||
if (options.collapsed == true) {
|
||||
// Trigger click to collapse all nodes
|
||||
$(this).find('a.json-toggle').click();
|
||||
}
|
||||
});
|
||||
};
|
||||
})(jQuery);
|
||||
@@ -0,0 +1,85 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
||||
<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.socket.io/4.1.2/socket.io.min.js" integrity="sha384-toS6mmwu70G0fw54EGlWWeA4z3dyJ+dlXBtSURSKN4vyRFOcxd3Bzjj/AoOwY+Rg" crossorigin="anonymous"></script>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.0/semantic.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.0/semantic.min.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/index.css">
|
||||
<link rel="stylesheet" href="/static/css/tabs.css">
|
||||
<script src="/static/js/main.js"></script>
|
||||
<script src="/static/js/gps.js"></script>
|
||||
<script src="/static/js/send-message.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
var initial_stats = {{ initial_stats|tojson|safe }};
|
||||
|
||||
var memory_chart = null
|
||||
var message_chart = null
|
||||
|
||||
$(document).ready(function() {
|
||||
console.log(initial_stats);
|
||||
start_update();
|
||||
init_chat();
|
||||
reset_Tabs();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='ui text container'>
|
||||
<h1 class='ui dividing header'>APRSD WebChat {{ 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='aprs_connection'>{{ aprs_connection|safe }}</span>
|
||||
</div>
|
||||
|
||||
<div class='right floated four wide column'>
|
||||
<span id='uptime'>NONE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui container">
|
||||
<h3 class="ui dividing header">Send Message</h3>
|
||||
<div id="sendMsgDiv" class="ui mini text">
|
||||
<form id="sendform" name="sendmsg" action="">
|
||||
<div class="ui corner labeled input">
|
||||
<label for="to_call" class="ui label">Callsign</label>
|
||||
<input type="text" name="to_call" id="to_call" placeholder="To Callsign" size="11" maxlength="9">
|
||||
<div class="ui corner label">
|
||||
<i class="asterisk icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui labeled input">
|
||||
<label for="message" class="ui label">Message</label>
|
||||
<input type="text" name="message" id="message" size="40" maxlength="40">
|
||||
</div>
|
||||
<input type="submit" name="submit" class="ui button" id="send_msg" value="Send" />
|
||||
<button type="button" class="ui button" id="send_beacon" value="Send GPS Beacon">Send GPS Beacon</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui grid">
|
||||
<div class="three wide column">
|
||||
<div class="tab" id="callsignTabs"></div>
|
||||
</div>
|
||||
<div class="ten wide column ui raised segment" id="msgsTabsDiv" style="height:450px;padding:0px;">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui text container" style="padding-top: 40px">
|
||||
<a href="https://badge.fury.io/py/aprsd"><img src="https://badge.fury.io/py/aprsd.svg" alt="PyPI version" height="18"></a>
|
||||
<a href="https://github.com/craigerl/aprsd"><img src="https://img.shields.io/badge/Made%20with-Python-1f425f.svg" height="18"></a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/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.socket.io/4.1.2/socket.io.min.js" integrity="sha384-toS6mmwu70G0fw54EGlWWeA4z3dyJ+dlXBtSURSKN4vyRFOcxd3Bzjj/AoOwY+Rg" crossorigin="anonymous"></script>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.0/semantic.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.0/semantic.min.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/index.css">
|
||||
<script src="/static/js/main.js"></script>
|
||||
<script src="/static/js/gps.js"></script>
|
||||
<script src="/static/js/send-message-mobile.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
var initial_stats = {{ initial_stats|tojson|safe }};
|
||||
|
||||
var memory_chart = null
|
||||
var message_chart = null
|
||||
|
||||
$(document).ready(function() {
|
||||
console.log(initial_stats);
|
||||
start_update();
|
||||
init_chat();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='ui text container'>
|
||||
<h1 class='ui dividing header'>APRSD WebChat {{ version }}</h1>
|
||||
</div>
|
||||
|
||||
<div class='ui grid text container' style="padding-bottom: 5px;">
|
||||
<div class='left floated twelve wide column'>
|
||||
<span style='color: green'>{{ callsign }}</span>
|
||||
connected to
|
||||
<span style='color: blue' id='aprs_connection'>{{ aprs_connection|safe }}</span>
|
||||
</div>
|
||||
|
||||
<div class='right floated four wide column'>
|
||||
<span id='uptime'>NONE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sendMsgDiv" class="ui grid" align="left" style="padding-top: 2px;">
|
||||
<h3 class="sixteen wide column ui dividing header">Send Message</h3>
|
||||
<form id="sendform" name="sendmsg" action="">
|
||||
<div class="sixteen wide column ui left labeled icon input">
|
||||
<div class="ui label">Callsign</div>
|
||||
<input type="text" name="to_call" id="to_call" placeholder="To Callsign" size="11" maxlength="9">
|
||||
<i class="users icon"></i>
|
||||
</div>
|
||||
<div class="sixteen wide column ui left labeled icon input" style="padding-bottom: 5px;">
|
||||
<label for="message" class="ui label">Message</label>
|
||||
<input type="text" name="message" id="message" maxlength="40" placeholder="Message">
|
||||
<i class="comment icon"></i>
|
||||
</div>
|
||||
<div class="right floated column">
|
||||
<input type="submit" name="submit" class="ui button" id="send_msg" value="Send" />
|
||||
<button type="button" class="ui button" id="send_beacon" value="Send GPS Beacon">Send GPS Beacon</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="ui grid">
|
||||
<div class="ui top attached tabular raised menu" id="callsignTabs">
|
||||
</div>
|
||||
<div class="sixteen wide column ui bottom attached raised tab segment" id="msgsTabsDiv" style="height:250px;padding:5px;">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui text container" style="padding-top: 40px">
|
||||
<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>
|
||||
@@ -4,9 +4,13 @@ mypy
|
||||
pep8-naming
|
||||
Sphinx
|
||||
tox
|
||||
twine
|
||||
# Twine is used for uploading packages to pypi
|
||||
# but it induces an install of cryptography
|
||||
# This is sucky for rpi systems.
|
||||
# twine
|
||||
pre-commit
|
||||
pip-tools
|
||||
pytest
|
||||
pytest-cov
|
||||
gray
|
||||
pip
|
||||
pip-tools
|
||||
|
||||
@@ -1,253 +1,88 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with python 3.8
|
||||
# To update, run:
|
||||
# This file is autogenerated by pip-compile with Python 3.9
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile dev-requirements.in
|
||||
# pip-compile --annotation-style=line --resolver=backtracking dev-requirements.in
|
||||
#
|
||||
add-trailing-comma==2.1.0
|
||||
# via gray
|
||||
alabaster==0.7.12
|
||||
# via sphinx
|
||||
appdirs==1.4.4
|
||||
# via black
|
||||
attrs==21.2.0
|
||||
# via
|
||||
# jsonschema
|
||||
# pytest
|
||||
autoflake==1.4
|
||||
# via gray
|
||||
babel==2.9.1
|
||||
# via sphinx
|
||||
backports.entry-points-selectable==1.1.0
|
||||
# via virtualenv
|
||||
black==21.7b0
|
||||
# via gray
|
||||
bleach==4.1.0
|
||||
# via readme-renderer
|
||||
certifi==2021.5.30
|
||||
# via requests
|
||||
cfgv==3.3.1
|
||||
# via pre-commit
|
||||
charset-normalizer==2.0.4
|
||||
# via requests
|
||||
click==8.0.1
|
||||
# via
|
||||
# black
|
||||
# pip-tools
|
||||
colorama==0.4.4
|
||||
# via twine
|
||||
colorlog==6.4.1
|
||||
# via prettylog
|
||||
configargparse==1.5.2
|
||||
# via gray
|
||||
coverage==5.5
|
||||
# via pytest-cov
|
||||
distlib==0.3.2
|
||||
# via virtualenv
|
||||
docutils==0.17.1
|
||||
# via
|
||||
# readme-renderer
|
||||
# sphinx
|
||||
fast-json==0.3.2
|
||||
# via prettylog
|
||||
filelock==3.0.12
|
||||
# via
|
||||
# tox
|
||||
# virtualenv
|
||||
fixit==0.1.4
|
||||
# via gray
|
||||
flake8==3.9.2
|
||||
# via
|
||||
# -r dev-requirements.in
|
||||
# fixit
|
||||
# flake8-polyfill
|
||||
# pep8-naming
|
||||
flake8-polyfill==1.0.2
|
||||
# via pep8-naming
|
||||
gray==0.10.1
|
||||
# via -r dev-requirements.in
|
||||
identify==2.2.13
|
||||
# via pre-commit
|
||||
idna==3.2
|
||||
# via requests
|
||||
imagesize==1.2.0
|
||||
# via sphinx
|
||||
importlib-metadata==4.7.1
|
||||
# via
|
||||
# keyring
|
||||
# twine
|
||||
importlib-resources==5.2.2
|
||||
# via fixit
|
||||
iniconfig==1.1.1
|
||||
# via pytest
|
||||
isort==5.9.3
|
||||
# via
|
||||
# -r dev-requirements.in
|
||||
# gray
|
||||
jinja2==3.0.1
|
||||
# via sphinx
|
||||
jsonschema==3.2.0
|
||||
# via fixit
|
||||
keyring==23.1.0
|
||||
# via twine
|
||||
libcst==0.3.20
|
||||
# via fixit
|
||||
markupsafe==2.0.1
|
||||
# via jinja2
|
||||
mccabe==0.6.1
|
||||
# via flake8
|
||||
mypy==0.910
|
||||
# via -r dev-requirements.in
|
||||
mypy-extensions==0.4.3
|
||||
# via
|
||||
# black
|
||||
# mypy
|
||||
# typing-inspect
|
||||
nodeenv==1.6.0
|
||||
# via pre-commit
|
||||
packaging==21.0
|
||||
# via
|
||||
# bleach
|
||||
# pytest
|
||||
# sphinx
|
||||
# tox
|
||||
pathspec==0.9.0
|
||||
# via black
|
||||
pep517==0.11.0
|
||||
# via pip-tools
|
||||
pep8-naming==0.12.1
|
||||
# via -r dev-requirements.in
|
||||
pip-tools==6.2.0
|
||||
# via -r dev-requirements.in
|
||||
pkginfo==1.7.1
|
||||
# via twine
|
||||
platformdirs==2.2.0
|
||||
# via virtualenv
|
||||
pluggy==1.0.0
|
||||
# via
|
||||
# pytest
|
||||
# tox
|
||||
pre-commit==2.14.0
|
||||
# via -r dev-requirements.in
|
||||
prettylog==0.3.0
|
||||
# via gray
|
||||
py==1.10.0
|
||||
# via
|
||||
# pytest
|
||||
# tox
|
||||
pycodestyle==2.7.0
|
||||
# via flake8
|
||||
pyflakes==2.3.1
|
||||
# via
|
||||
# autoflake
|
||||
# flake8
|
||||
pygments==2.10.0
|
||||
# via
|
||||
# readme-renderer
|
||||
# sphinx
|
||||
pyparsing==2.4.7
|
||||
# via packaging
|
||||
pyrsistent==0.18.0
|
||||
# via jsonschema
|
||||
pytest==6.2.5
|
||||
# via
|
||||
# -r dev-requirements.in
|
||||
# pytest-cov
|
||||
pytest-cov==2.12.1
|
||||
# via -r dev-requirements.in
|
||||
pytz==2021.1
|
||||
# via babel
|
||||
pyupgrade==2.24.0
|
||||
# via gray
|
||||
pyyaml==5.4.1
|
||||
# via
|
||||
# fixit
|
||||
# libcst
|
||||
# pre-commit
|
||||
readme-renderer==29.0
|
||||
# via twine
|
||||
regex==2021.8.27
|
||||
# via black
|
||||
requests==2.26.0
|
||||
# via
|
||||
# requests-toolbelt
|
||||
# sphinx
|
||||
# twine
|
||||
requests-toolbelt==0.9.1
|
||||
# via twine
|
||||
rfc3986==1.5.0
|
||||
# via twine
|
||||
six==1.16.0
|
||||
# via
|
||||
# bleach
|
||||
# jsonschema
|
||||
# readme-renderer
|
||||
# tox
|
||||
# virtualenv
|
||||
snowballstemmer==2.1.0
|
||||
# via sphinx
|
||||
sphinx==4.1.2
|
||||
# via -r dev-requirements.in
|
||||
sphinxcontrib-applehelp==1.0.2
|
||||
# via sphinx
|
||||
sphinxcontrib-devhelp==1.0.2
|
||||
# via sphinx
|
||||
sphinxcontrib-htmlhelp==2.0.0
|
||||
# via sphinx
|
||||
sphinxcontrib-jsmath==1.0.1
|
||||
# via sphinx
|
||||
sphinxcontrib-qthelp==1.0.3
|
||||
# via sphinx
|
||||
sphinxcontrib-serializinghtml==1.1.5
|
||||
# via sphinx
|
||||
tokenize-rt==4.1.0
|
||||
# via
|
||||
# add-trailing-comma
|
||||
# pyupgrade
|
||||
toml==0.10.2
|
||||
# via
|
||||
# mypy
|
||||
# pre-commit
|
||||
# pytest
|
||||
# pytest-cov
|
||||
# tox
|
||||
tomli==1.2.1
|
||||
# via
|
||||
# black
|
||||
# pep517
|
||||
tox==3.24.3
|
||||
# via -r dev-requirements.in
|
||||
tqdm==4.62.2
|
||||
# via twine
|
||||
twine==3.4.2
|
||||
# via -r dev-requirements.in
|
||||
typing-extensions==3.10.0.0
|
||||
# via
|
||||
# libcst
|
||||
# mypy
|
||||
# typing-inspect
|
||||
typing-inspect==0.7.1
|
||||
# via libcst
|
||||
ujson==4.1.0
|
||||
# via fast-json
|
||||
unify==0.5
|
||||
# via gray
|
||||
untokenize==0.1.1
|
||||
# via unify
|
||||
urllib3==1.26.6
|
||||
# via requests
|
||||
virtualenv==20.7.2
|
||||
# via
|
||||
# pre-commit
|
||||
# tox
|
||||
webencodings==0.5.1
|
||||
# via bleach
|
||||
wheel==0.37.0
|
||||
# via pip-tools
|
||||
zipp==3.5.0
|
||||
# via
|
||||
# importlib-metadata
|
||||
# importlib-resources
|
||||
add-trailing-comma==2.3.0 # via gray
|
||||
alabaster==0.7.12 # via sphinx
|
||||
attrs==22.1.0 # via jsonschema, pytest
|
||||
autoflake==1.5.3 # via gray
|
||||
babel==2.11.0 # via sphinx
|
||||
black==22.12.0 # via gray
|
||||
build==0.9.0 # via pip-tools
|
||||
cachetools==5.2.0 # via tox
|
||||
certifi==2022.12.7 # via requests
|
||||
cfgv==3.3.1 # via pre-commit
|
||||
chardet==5.1.0 # via tox
|
||||
charset-normalizer==2.1.1 # via requests
|
||||
click==8.1.3 # via black, pip-tools
|
||||
colorama==0.4.6 # via tox
|
||||
commonmark==0.9.1 # via rich
|
||||
configargparse==1.5.3 # via gray
|
||||
coverage[toml]==6.5.0 # via pytest-cov
|
||||
distlib==0.3.6 # via virtualenv
|
||||
docutils==0.19 # via sphinx
|
||||
exceptiongroup==1.0.4 # via pytest
|
||||
filelock==3.8.2 # via tox, virtualenv
|
||||
fixit==0.1.4 # via gray
|
||||
flake8==6.0.0 # via -r dev-requirements.in, fixit, pep8-naming
|
||||
gray==0.13.0 # via -r dev-requirements.in
|
||||
identify==2.5.9 # via pre-commit
|
||||
idna==3.4 # via requests
|
||||
imagesize==1.4.1 # via sphinx
|
||||
importlib-metadata==5.1.0 # via sphinx
|
||||
importlib-resources==5.10.1 # via fixit
|
||||
iniconfig==1.1.1 # via pytest
|
||||
isort==5.10.1 # via -r dev-requirements.in, gray
|
||||
jinja2==3.1.2 # via sphinx
|
||||
jsonschema==4.17.3 # via fixit
|
||||
libcst==0.4.9 # via fixit
|
||||
markupsafe==2.1.1 # via jinja2
|
||||
mccabe==0.7.0 # via flake8
|
||||
mypy==0.991 # via -r dev-requirements.in
|
||||
mypy-extensions==0.4.3 # via black, mypy, typing-inspect
|
||||
nodeenv==1.7.0 # via pre-commit
|
||||
packaging==22.0 # via build, pyproject-api, pytest, sphinx, tox
|
||||
pathspec==0.10.3 # via black
|
||||
pep517==0.13.0 # via build
|
||||
pep8-naming==0.13.2 # via -r dev-requirements.in
|
||||
pip-tools==6.11.0 # via -r dev-requirements.in
|
||||
platformdirs==2.6.0 # via black, tox, virtualenv
|
||||
pluggy==1.0.0 # via pytest, tox
|
||||
pre-commit==2.20.0 # via -r dev-requirements.in
|
||||
pycodestyle==2.10.0 # via flake8
|
||||
pyflakes==3.0.1 # via autoflake, flake8
|
||||
pygments==2.13.0 # via rich, sphinx
|
||||
pyproject-api==1.2.1 # via tox
|
||||
pyrsistent==0.19.2 # via jsonschema
|
||||
pytest==7.2.0 # via -r dev-requirements.in, pytest-cov
|
||||
pytest-cov==4.0.0 # via -r dev-requirements.in
|
||||
pytz==2022.6 # via babel
|
||||
pyupgrade==3.3.1 # via gray
|
||||
pyyaml==6.0 # via fixit, libcst, pre-commit
|
||||
requests==2.28.1 # via sphinx
|
||||
rich==12.6.0 # via gray
|
||||
snowballstemmer==2.2.0 # via sphinx
|
||||
sphinx==5.3.0 # via -r dev-requirements.in
|
||||
sphinxcontrib-applehelp==1.0.2 # via sphinx
|
||||
sphinxcontrib-devhelp==1.0.2 # via sphinx
|
||||
sphinxcontrib-htmlhelp==2.0.0 # via sphinx
|
||||
sphinxcontrib-jsmath==1.0.1 # via sphinx
|
||||
sphinxcontrib-qthelp==1.0.3 # via sphinx
|
||||
sphinxcontrib-serializinghtml==1.1.5 # via sphinx
|
||||
tokenize-rt==5.0.0 # via add-trailing-comma, pyupgrade
|
||||
toml==0.10.2 # via autoflake, pre-commit
|
||||
tomli==2.0.1 # via black, build, coverage, mypy, pep517, pyproject-api, pytest, tox
|
||||
tox==4.0.8 # via -r dev-requirements.in
|
||||
typing-extensions==4.4.0 # via black, libcst, mypy, typing-inspect
|
||||
typing-inspect==0.8.0 # via libcst
|
||||
unify==0.5 # via gray
|
||||
untokenize==0.1.1 # via unify
|
||||
urllib3==1.26.13 # via requests
|
||||
virtualenv==20.17.1 # via pre-commit, tox
|
||||
wheel==0.38.4 # via pip-tools
|
||||
zipp==3.11.0 # via importlib-metadata, importlib-resources
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# pip
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
FROM python:3-bullseye as aprsd
|
||||
#FROM python:3-bullseye as aprsd
|
||||
FROM ubuntu:22.04 as aprsd
|
||||
|
||||
# Dockerfile for building a container during aprsd development.
|
||||
|
||||
ARG UID
|
||||
ARG GID
|
||||
ARG TZ
|
||||
ARG VERSION=2.6.0
|
||||
ARG BUILDX_QEMU_ENV
|
||||
ENV APRS_USER=aprs
|
||||
ENV HOME=/home/aprs
|
||||
ENV TZ=${TZ:-US/Eastern}
|
||||
@@ -12,23 +15,28 @@ ENV UID=${UID:-1000}
|
||||
ENV GID=${GID:-1000}
|
||||
ENV LC_ALL=C.UTF-8
|
||||
ENV LANG=C.UTF-8
|
||||
ENV APRSD_PIP_VERSION=${VERSION}
|
||||
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
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 apt install -y libffi-dev python3-dev libssl-dev libxml2-dev libxslt-dev
|
||||
RUN apt install -y python3 python3-pip python3-dev python3-lxml
|
||||
|
||||
RUN addgroup --gid $GID $APRS_USER
|
||||
RUN useradd -m -u $UID -g $APRS_USER $APRS_USER
|
||||
|
||||
# Handle an extremely specific issue when building the cryptography package for
|
||||
# 32-bit architectures within QEMU running on a 64-bit host (issue #30).
|
||||
RUN if [ "${BUILDX_QEMU_ENV}" = "true" -a "$(getconf LONG_BIT)" = "32" ]; then \
|
||||
pip3 install -U cryptography==3.3.2; \
|
||||
else \
|
||||
pip3 install cryptography ;\
|
||||
fi
|
||||
|
||||
# Install aprsd
|
||||
RUN /usr/local/bin/pip3 install aprsd==2.3.1
|
||||
RUN pip install aprsd==$APRSD_PIP_VERSION
|
||||
|
||||
# Ensure /config is there with a default config file
|
||||
USER root
|
||||
@@ -43,3 +51,6 @@ VOLUME ["/config", "/plugins"]
|
||||
USER $APRS_USER
|
||||
ADD bin/run.sh /usr/local/bin
|
||||
ENTRYPOINT ["/usr/local/bin/run.sh"]
|
||||
|
||||
HEALTHCHECK --interval=5m --timeout=12s --start-period=30s \
|
||||
CMD aprsd healthcheck --config /config/aprsd.yml --url http://localhost:8001/stats
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
FROM python:3.8-slim as aprsd
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM ubuntu:focal as aprsd
|
||||
|
||||
# Dockerfile for building a container during aprsd development.
|
||||
ARG branch
|
||||
ARG BRANCH=master
|
||||
ARG UID
|
||||
ARG GID
|
||||
|
||||
ARG BUILDX_QEMU_ENV
|
||||
|
||||
ENV APRS_USER=aprs
|
||||
ENV HOME=/home/aprs
|
||||
ENV APRSD=http://github.com/craigerl/aprsd.git
|
||||
ENV APRSD_BRANCH=${branch:-master}
|
||||
ENV APRSD_BRANCH=${BRANCH:-master}
|
||||
ENV VIRTUAL_ENV=$HOME/.venv3
|
||||
ENV UID=${UID:-1000}
|
||||
ENV GID=${GID:-1000}
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV INSTALL=$HOME/install
|
||||
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 apt install -y --no-install-recommends git build-essential bash fortune
|
||||
RUN apt install -y libffi-dev python3-dev libssl-dev libxml2-dev libxslt-dev
|
||||
RUN apt install -y python3 python3-pip python3-dev python3-lxml
|
||||
#RUN apt-get clean
|
||||
RUN apt-get -o Dpkg::Options::="--force-confmiss" install --reinstall netbase
|
||||
|
||||
RUN addgroup --gid 1001 $APRS_USER
|
||||
RUN useradd -m -u $UID -g $APRS_USER $APRS_USER
|
||||
@@ -25,17 +31,28 @@ RUN useradd -m -u $UID -g $APRS_USER $APRS_USER
|
||||
ENV LC_ALL=C.UTF-8
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
WORKDIR $HOME
|
||||
USER $APRS_USER
|
||||
RUN pip3 install wheel
|
||||
RUN pip install wheel
|
||||
#RUN python3 -m venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
#ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
RUN echo "export PATH=\$PATH:\$HOME/.local/bin" >> $HOME/.bashrc
|
||||
RUN cat $HOME/.bashrc
|
||||
|
||||
USER root
|
||||
WORKDIR $HOME
|
||||
# Handle an extremely specific issue when building the cryptography package for
|
||||
# 32-bit architectures within QEMU running on a 64-bit host (issue #30).
|
||||
RUN if [ "${BUILDX_QEMU_ENV}" = "true" -a "$(getconf LONG_BIT)" = "32" ]; then \
|
||||
pip3 install -U cryptography==3.3.2; \
|
||||
else \
|
||||
pip3 install cryptography ;\
|
||||
fi
|
||||
RUN mkdir $INSTALL
|
||||
RUN git clone -b $APRSD_BRANCH $APRSD $INSTALL/aprsd
|
||||
RUN cd $INSTALL/aprsd && pip3 install .
|
||||
RUN git clone -b $BRANCH $APRSD $INSTALL/aprsd
|
||||
RUN cd $INSTALL/aprsd && pip3 install -v .
|
||||
RUN ls -al /usr/local/bin
|
||||
RUN ls -al /usr/bin
|
||||
RUN which aprsd
|
||||
RUN mkdir -p /config
|
||||
RUN aprsd sample-config > /config/aprsd.yml
|
||||
@@ -48,3 +65,6 @@ VOLUME ["/config", "/plugins"]
|
||||
|
||||
ADD bin/run.sh $HOME/
|
||||
ENTRYPOINT ["/home/aprs/run.sh"]
|
||||
|
||||
HEALTHCHECK --interval=5m --timeout=12s --start-period=30s \
|
||||
CMD aprsd healthcheck --config /config/aprsd.yml --url http://localhost:8001/stats
|
||||
|
||||