Compare commits

..

1 Commits

Author SHA1 Message Date
Hemna 8e0de9c5ac Fix for sample-config warning
This patch fixes a small issue with the sample-config command
outputting a warning during generation.
2024-03-27 10:28:42 -04:00
97 changed files with 5384 additions and 3553 deletions

View File

@ -43,9 +43,8 @@ jobs:
with:
context: "{{defaultContext}}:docker"
platforms: linux/amd64,linux/arm64
file: ./Dockerfile
file: ./Dockerfile-dev
build-args: |
INSTALL_TYPE=github
BRANCH=${{ steps.extract_branch.outputs.branch }}
BUILDX_QEMU_ENV=true
push: true

View File

@ -53,9 +53,8 @@ jobs:
with:
context: "{{defaultContext}}:docker"
platforms: linux/amd64,linux/arm64
file: ./Dockerfile
file: ./Dockerfile-dev
build-args: |
INSTALL_TYPE=github
BRANCH=${{ steps.branch-name.outputs.current_branch }}
BUILDX_QEMU_ENV=true
push: true

108
ChangeLog
View File

@ -1,116 +1,10 @@
CHANGES
=======
* Put an upper bound on the QueueHandler queue
v3.4.0
v3.3.3
------
* Updated Changelog for 3.4.0
* Change setup.h
* Fixed docker setup.sh comparison
* Fixed unit tests failing with WatchList
* Added config enable\_packet\_logging
* Make all the Objectstore children use the same lock
* Fixed PacketTrack with UnknownPacket
* Removed the requirement on click-completion
* Update Dockerfiles
* Added fox for entry\_points with old python
* Added config for enable\_seen\_list
* Fix APRSDStats start\_time
* Added default\_packet\_send\_count config
* Call packet collecter after prepare during tx
* Added PacketTrack to packet collector
* Webchat Send Beacon uses Path selected in UI
* Added try except blocks in collectors
* Remove error logs from watch list
* Fixed issue with PacketList being empty
* Added new PacketCollector
* Fixed Keepalive access to email stats
* Added support for RX replyacks
* Changed Stats Collector registration
* Added PacketList.set\_maxlen()
* another fix for tx send
* removed Packet.last\_send\_attempt and just use send\_count
* Fix access to PacketList.\_maxlen
* added packet\_count in packet\_list stats
* force uwsgi to 2.0.24
* ismall update
* Added new config optons for PacketList
* Update requirements
* Added threads chart to admin ui graphs
* set packetlist max back to 100
* ensure thread count is updated
* Added threads table in the admin web ui
* Fixed issue with APRSDThreadList stats()
* Added new default\_ack\_send\_count config option
* Remove packet from tracker after max attempts
* Limit packets to 50 in PacketList
* syncronize the add for StatsStore
* Lock on stats for PacketList
* Fixed PacketList maxlen
* Fixed a problem with the webchat tab notification
* Another fix for ACK packets
* Fix issue not tracking RX Ack packets for stats
* Fix time plugin
* add GATE route to webchat along with WIDE1, etc
* Update webchat, include GATE route along with WIDE, ARISS, etc
* Get rid of some useless warning logs
* Added human\_info property to MessagePackets
* Fixed scrolling problem with new webchat sent msg
* Fix some issues with listen command
* Admin interface catch empty stats
* Ensure StatsStore has empty data
* Ensure latest pip is in docker image
* LOG failed requests post to admin ui
* changed admin web\_ip to StrOpt
* Updated prism to 1.29
* Removed json-viewer
* Remove rpyc as a requirement
* Delete more stats from webchat
* Admin UI working again
* Removed RPC Server and client
* Remove the logging of the conf password if not set
* Lock around client reset
* Allow stats collector to serialize upon creation
* Fixed issues with watch list at startup
* Fixed access to log\_monitor
* Got unit tests working again
* Fixed pep8 errors and missing files
* Reworked the stats making the rpc server obsolete
* Update client.py to add consumer in the API
* Fix for sample-config warning
* update requirements
* Put packet.json back in
* Change debug log color
* Fix for filtering curse words
* added packet counter random int
* More packet cleanup and tests
* Show comment in multiline packet output
* Added new config option log\_packet\_format
* Some packet cleanup
* Added new webchat config option for logging
* Fix some pep8 issues
* Completely redo logging of packets!!
* Fixed some logging in webchat
* Added missing packet types in listen command
* Don't call stats so often in webchat
* Eliminated need for from\_aprslib\_dict
* Fix for micE packet decoding with mbits
* updated dev-requirements
* Fixed some tox errors related to mypy
* Refactored packets
* removed print
* small refactor of stats usage in version plugin
* Added type setting on pluging.py for mypy
* Moved Threads list for mypy
* No need to synchronize on stats
* Start to add types
* Update tox for mypy runs
* Bump black from 24.2.0 to 24.3.0
* replaced access to conf from uwsgi
* Fixed call to setup\_logging in uwsgi
* Fixed access to conf.log in logging\_setup
v3.3.2
------

View File

@ -17,7 +17,7 @@ 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: REQUIREMENTS_TXT = requirements.txt requirements-dev.txt
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
@ -39,6 +39,7 @@ clean-build: ## remove build artifacts
clean-pyc: ## remove Python file artifacts
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.pyo' -exec rm -f {} +
find . -name '*~' -exec rm -f {} +
find . -name '__pycache__' -exec rm -fr {} +
clean-test: ## remove test and coverage artifacts
@ -56,7 +57,7 @@ test: dev ## Run all the tox tests
build: test ## Make the build artifact prior to doing an upload
$(VENV)/pip install twine
$(VENV)/python3 -m build
$(VENV)/python3 setup.py sdist bdist_wheel
$(VENV)/twine check dist/*
upload: build ## Upload a new version of the plugin
@ -80,8 +81,8 @@ docker-dev: test ## Make a development docker container tagged with hemna6969/a
update-requirements: dev ## Update the requirements.txt and dev-requirements.txt files
rm requirements.txt
rm requirements-dev.txt
rm dev-requirements.txt
touch requirements.txt
touch requirements-dev.txt
touch dev-requirements.txt
$(VENV)/pip-compile --resolver backtracking --annotation-style=line requirements.in
$(VENV)/pip-compile --resolver backtracking --annotation-style=line requirements-dev.in
$(VENV)/pip-compile --resolver backtracking --annotation-style=line dev-requirements.in

View File

@ -10,10 +10,7 @@
# License for the specific language governing permissions and limitations
# under the License.
from importlib.metadata import PackageNotFoundError, version
import pbr.version
try:
__version__ = version("aprsd")
except PackageNotFoundError:
pass
__version__ = pbr.version.VersionInfo("aprsd").version_string()

348
aprsd/client.py Normal file
View File

@ -0,0 +1,348 @@
import abc
import logging
import time
import aprslib
from aprslib.exceptions import LoginError
from oslo_config import cfg
from aprsd import exception
from aprsd.clients import aprsis, fake, kiss
from aprsd.packets import core, packet_list
from aprsd.utils import trace
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
TRANSPORT_APRSIS = "aprsis"
TRANSPORT_TCPKISS = "tcpkiss"
TRANSPORT_SERIALKISS = "serialkiss"
TRANSPORT_FAKE = "fake"
# Main must create this from the ClientFactory
# object such that it's populated with the
# Correct config
factory = None
class Client:
"""Singleton client class that constructs the aprslib connection."""
_instance = None
_client = None
connected = False
server_string = None
filter = None
def __new__(cls, *args, **kwargs):
"""This magic turns this into a singleton."""
if cls._instance is None:
cls._instance = super().__new__(cls)
# Put any initialization here.
return cls._instance
def set_filter(self, filter):
self.filter = filter
if self._client:
self._client.set_filter(filter)
@property
def client(self):
if not self._client:
LOG.info("Creating APRS client")
self._client = self.setup_connection()
if self.filter:
LOG.info("Creating APRS client filter")
self._client.set_filter(self.filter)
return self._client
def send(self, packet: core.Packet):
packet_list.PacketList().tx(packet)
self.client.send(packet)
def reset(self):
"""Call this to force a rebuild/reconnect."""
if self._client:
del self._client
else:
LOG.warning("Client not initialized, nothing to reset.")
# Recreate the client
LOG.info(f"Creating new client {self.client}")
@abc.abstractmethod
def setup_connection(self):
pass
@staticmethod
@abc.abstractmethod
def is_enabled():
pass
@staticmethod
@abc.abstractmethod
def transport():
pass
@abc.abstractmethod
def decode_packet(self, *args, **kwargs):
pass
class APRSISClient(Client):
_client = None
@staticmethod
def is_enabled():
# Defaults to True if the enabled flag is non existent
try:
return CONF.aprs_network.enabled
except KeyError:
return False
@staticmethod
def is_configured():
if APRSISClient.is_enabled():
# Ensure that the config vars are correctly set
if not CONF.aprs_network.login:
LOG.error("Config aprs_network.login not set.")
raise exception.MissingConfigOptionException(
"aprs_network.login is not set.",
)
if not CONF.aprs_network.password:
LOG.error("Config aprs_network.password not set.")
raise exception.MissingConfigOptionException(
"aprs_network.password is not set.",
)
if not CONF.aprs_network.host:
LOG.error("Config aprs_network.host not set.")
raise exception.MissingConfigOptionException(
"aprs_network.host is not set.",
)
return True
return True
def is_alive(self):
if self._client:
return self._client.is_alive()
else:
return False
@staticmethod
def transport():
return TRANSPORT_APRSIS
def decode_packet(self, *args, **kwargs):
"""APRS lib already decodes this."""
return core.Packet.factory(args[0])
def setup_connection(self):
user = CONF.aprs_network.login
password = CONF.aprs_network.password
host = CONF.aprs_network.host
port = CONF.aprs_network.port
connected = False
backoff = 1
aprs_client = None
while not connected:
try:
LOG.info("Creating aprslib client")
aprs_client = aprsis.Aprsdis(user, passwd=password, host=host, port=port)
# Force the log to be the same
aprs_client.logger = LOG
aprs_client.connect()
connected = True
backoff = 1
except LoginError as e:
LOG.error(f"Failed to login to APRS-IS Server '{e}'")
connected = False
time.sleep(backoff)
except Exception as e:
LOG.error(f"Unable to connect to APRS-IS server. '{e}' ")
connected = False
time.sleep(backoff)
# Don't allow the backoff to go to inifinity.
if backoff > 5:
backoff = 5
else:
backoff += 1
continue
LOG.debug(f"Logging in to APRS-IS with user '{user}'")
self._client = aprs_client
return aprs_client
class KISSClient(Client):
_client = None
@staticmethod
def is_enabled():
"""Return if tcp or serial KISS is enabled."""
if CONF.kiss_serial.enabled:
return True
if CONF.kiss_tcp.enabled:
return True
return False
@staticmethod
def is_configured():
# Ensure that the config vars are correctly set
if KISSClient.is_enabled():
transport = KISSClient.transport()
if transport == TRANSPORT_SERIALKISS:
if not CONF.kiss_serial.device:
LOG.error("KISS serial enabled, but no device is set.")
raise exception.MissingConfigOptionException(
"kiss_serial.device is not set.",
)
elif transport == TRANSPORT_TCPKISS:
if not CONF.kiss_tcp.host:
LOG.error("KISS TCP enabled, but no host is set.")
raise exception.MissingConfigOptionException(
"kiss_tcp.host is not set.",
)
return True
return False
def is_alive(self):
if self._client:
return self._client.is_alive()
else:
return False
@staticmethod
def transport():
if CONF.kiss_serial.enabled:
return TRANSPORT_SERIALKISS
if CONF.kiss_tcp.enabled:
return TRANSPORT_TCPKISS
def decode_packet(self, *args, **kwargs):
"""We get a frame, which has to be decoded."""
LOG.debug(f"kwargs {kwargs}")
frame = kwargs["frame"]
LOG.debug(f"Got an APRS Frame '{frame}'")
# try and nuke the * from the fromcall sign.
# frame.header._source._ch = False
# payload = str(frame.payload.decode())
# msg = f"{str(frame.header)}:{payload}"
# msg = frame.tnc2
# LOG.debug(f"Decoding {msg}")
raw = aprslib.parse(str(frame))
packet = core.Packet.factory(raw)
if isinstance(packet, core.ThirdParty):
return packet.subpacket
else:
return packet
def setup_connection(self):
self._client = kiss.KISS3Client()
return self._client
class APRSDFakeClient(Client, metaclass=trace.TraceWrapperMetaclass):
@staticmethod
def is_enabled():
if CONF.fake_client.enabled:
return True
return False
@staticmethod
def is_configured():
return APRSDFakeClient.is_enabled()
def is_alive(self):
return True
def setup_connection(self):
return fake.APRSDFakeClient()
@staticmethod
def transport():
return TRANSPORT_FAKE
def decode_packet(self, *args, **kwargs):
LOG.debug(f"kwargs {kwargs}")
pkt = kwargs["packet"]
LOG.debug(f"Got an APRS Fake Packet '{pkt}'")
return pkt
class ClientFactory:
_instance = None
def __new__(cls, *args, **kwargs):
"""This magic turns this into a singleton."""
if cls._instance is None:
cls._instance = super().__new__(cls)
# Put any initialization here.
return cls._instance
def __init__(self):
self._builders = {}
def register(self, key, builder):
self._builders[key] = builder
def create(self, key=None):
if not key:
if APRSISClient.is_enabled():
key = TRANSPORT_APRSIS
elif KISSClient.is_enabled():
key = KISSClient.transport()
elif APRSDFakeClient.is_enabled():
key = TRANSPORT_FAKE
builder = self._builders.get(key)
LOG.debug(f"Creating client {key}")
if not builder:
raise ValueError(key)
return builder()
def is_client_enabled(self):
"""Make sure at least one client is enabled."""
enabled = False
for key in self._builders.keys():
try:
enabled |= self._builders[key].is_enabled()
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()
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
@staticmethod
def setup():
"""Create and register all possible client objects."""
global factory
factory = ClientFactory()
factory.register(TRANSPORT_APRSIS, APRSISClient)
factory.register(TRANSPORT_TCPKISS, KISSClient)
factory.register(TRANSPORT_SERIALKISS, KISSClient)
factory.register(TRANSPORT_FAKE, APRSDFakeClient)

View File

@ -1,13 +0,0 @@
from aprsd.client import aprsis, factory, fake, kiss
TRANSPORT_APRSIS = "aprsis"
TRANSPORT_TCPKISS = "tcpkiss"
TRANSPORT_SERIALKISS = "serialkiss"
TRANSPORT_FAKE = "fake"
client_factory = factory.ClientFactory()
client_factory.register(aprsis.APRSISClient)
client_factory.register(kiss.KISSClient)
client_factory.register(fake.APRSDFakeClient)

View File

@ -1,132 +0,0 @@
import datetime
import logging
import time
from aprslib.exceptions import LoginError
from oslo_config import cfg
from aprsd import client, exception
from aprsd.client import base
from aprsd.client.drivers import aprsis
from aprsd.packets import core
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class APRSISClient(base.APRSClient):
_client = None
def __init__(self):
max_timeout = {"hours": 0.0, "minutes": 2, "seconds": 0}
self.max_delta = datetime.timedelta(**max_timeout)
def stats(self) -> dict:
stats = {}
if self.is_configured():
stats = {
"server_string": self._client.server_string,
"sever_keepalive": self._client.aprsd_keepalive,
"filter": self.filter,
}
return stats
@staticmethod
def is_enabled():
# Defaults to True if the enabled flag is non existent
try:
return CONF.aprs_network.enabled
except KeyError:
return False
@staticmethod
def is_configured():
if APRSISClient.is_enabled():
# Ensure that the config vars are correctly set
if not CONF.aprs_network.login:
LOG.error("Config aprs_network.login not set.")
raise exception.MissingConfigOptionException(
"aprs_network.login is not set.",
)
if not CONF.aprs_network.password:
LOG.error("Config aprs_network.password not set.")
raise exception.MissingConfigOptionException(
"aprs_network.password is not set.",
)
if not CONF.aprs_network.host:
LOG.error("Config aprs_network.host not set.")
raise exception.MissingConfigOptionException(
"aprs_network.host is not set.",
)
return True
return True
def _is_stale_connection(self):
delta = datetime.datetime.now() - self._client.aprsd_keepalive
if delta > self.max_delta:
LOG.error(f"Connection is stale, last heard {delta} ago.")
return True
def is_alive(self):
if self._client:
return self._client.is_alive() and not self._is_stale_connection()
else:
LOG.warning(f"APRS_CLIENT {self._client} alive? NO!!!")
return False
def close(self):
if self._client:
self._client.stop()
self._client.close()
@staticmethod
def transport():
return client.TRANSPORT_APRSIS
def decode_packet(self, *args, **kwargs):
"""APRS lib already decodes this."""
return core.factory(args[0])
def setup_connection(self):
user = CONF.aprs_network.login
password = CONF.aprs_network.password
host = CONF.aprs_network.host
port = CONF.aprs_network.port
self.connected = False
backoff = 1
aprs_client = None
while not self.connected:
try:
LOG.info(f"Creating aprslib client({host}:{port}) and logging in {user}.")
aprs_client = aprsis.Aprsdis(user, passwd=password, host=host, port=port)
# Force the log to be the same
aprs_client.logger = LOG
aprs_client.connect()
self.connected = True
backoff = 1
except LoginError as e:
LOG.error(f"Failed to login to APRS-IS Server '{e}'")
self.connected = False
time.sleep(backoff)
except Exception as e:
LOG.error(f"Unable to connect to APRS-IS server. '{e}' ")
self.connected = False
time.sleep(backoff)
# Don't allow the backoff to go to inifinity.
if backoff > 5:
backoff = 5
else:
backoff += 1
continue
self._client = aprs_client
return aprs_client
def consumer(self, callback, blocking=False, immortal=False, raw=False):
self._client.consumer(
callback, blocking=blocking,
immortal=immortal, raw=raw,
)

View File

@ -1,105 +0,0 @@
import abc
import logging
import threading
from oslo_config import cfg
import wrapt
from aprsd.packets import core
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class APRSClient:
"""Singleton client class that constructs the aprslib connection."""
_instance = None
_client = None
connected = False
filter = None
lock = threading.Lock()
def __new__(cls, *args, **kwargs):
"""This magic turns this into a singleton."""
if cls._instance is None:
cls._instance = super().__new__(cls)
# Put any initialization here.
cls._instance._create_client()
return cls._instance
@abc.abstractmethod
def stats(self) -> dict:
pass
def set_filter(self, filter):
self.filter = filter
if self._client:
self._client.set_filter(filter)
@property
def client(self):
if not self._client:
self._create_client()
return self._client
def _create_client(self):
self._client = self.setup_connection()
if self.filter:
LOG.info("Creating APRS client filter")
self._client.set_filter(self.filter)
def stop(self):
if self._client:
LOG.info("Stopping client connection.")
self._client.stop()
def send(self, packet: core.Packet):
"""Send a packet to the network."""
self.client.send(packet)
@wrapt.synchronized(lock)
def reset(self):
"""Call this to force a rebuild/reconnect."""
LOG.info("Resetting client connection.")
if self._client:
self._client.close()
del self._client
self._create_client()
else:
LOG.warning("Client not initialized, nothing to reset.")
# Recreate the client
LOG.info(f"Creating new client {self.client}")
@abc.abstractmethod
def setup_connection(self):
pass
@staticmethod
@abc.abstractmethod
def is_enabled():
pass
@staticmethod
@abc.abstractmethod
def transport():
pass
@abc.abstractmethod
def decode_packet(self, *args, **kwargs):
pass
@abc.abstractmethod
def consumer(self, callback, blocking=False, immortal=False, raw=False):
pass
@abc.abstractmethod
def is_alive(self):
pass
@abc.abstractmethod
def close(self):
pass

View File

@ -1,88 +0,0 @@
import logging
from typing import Callable, Protocol, runtime_checkable
from aprsd import exception
from aprsd.packets import core
LOG = logging.getLogger("APRSD")
@runtime_checkable
class Client(Protocol):
def __init__(self):
pass
def connect(self) -> bool:
pass
def disconnect(self) -> bool:
pass
def decode_packet(self, *args, **kwargs) -> type[core.Packet]:
pass
def is_enabled(self) -> bool:
pass
def is_configured(self) -> bool:
pass
def transport(self) -> str:
pass
def send(self, message: str) -> bool:
pass
def setup_connection(self) -> None:
pass
class ClientFactory:
_instance = None
clients = []
def __new__(cls, *args, **kwargs):
"""This magic turns this into a singleton."""
if cls._instance is None:
cls._instance = super().__new__(cls)
# Put any initialization here.
return cls._instance
def __init__(self):
self.clients: list[Callable] = []
def register(self, aprsd_client: Callable):
if isinstance(aprsd_client, Client):
raise ValueError("Client must be a subclass of Client protocol")
self.clients.append(aprsd_client)
def create(self, key=None):
for client in self.clients:
if client.is_enabled():
return client()
raise Exception("No client is configured!!")
def is_client_enabled(self):
"""Make sure at least one client is enabled."""
enabled = False
for client in self.clients:
if client.is_enabled():
enabled = True
return enabled
def is_client_configured(self):
enabled = False
for client in self.clients:
try:
if client.is_configured():
enabled = True
except exception.MissingConfigOptionException as ex:
LOG.error(ex.message)
return False
except exception.ConfigOptionBogusDefaultException as ex:
LOG.error(ex.message)
return False
return enabled

View File

@ -1,48 +0,0 @@
import logging
from oslo_config import cfg
from aprsd import client
from aprsd.client import base
from aprsd.client.drivers import fake as fake_driver
from aprsd.utils import trace
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class APRSDFakeClient(base.APRSClient, metaclass=trace.TraceWrapperMetaclass):
def stats(self) -> dict:
return {}
@staticmethod
def is_enabled():
if CONF.fake_client.enabled:
return True
return False
@staticmethod
def is_configured():
return APRSDFakeClient.is_enabled()
def is_alive(self):
return True
def close(self):
pass
def setup_connection(self):
self.connected = True
return fake_driver.APRSDFakeClient()
@staticmethod
def transport():
return client.TRANSPORT_FAKE
def decode_packet(self, *args, **kwargs):
LOG.debug(f"kwargs {kwargs}")
pkt = kwargs["packet"]
LOG.debug(f"Got an APRS Fake Packet '{pkt}'")
return pkt

View File

@ -1,103 +0,0 @@
import logging
import aprslib
from oslo_config import cfg
from aprsd import client, exception
from aprsd.client import base
from aprsd.client.drivers import kiss
from aprsd.packets import core
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class KISSClient(base.APRSClient):
_client = None
def stats(self) -> dict:
stats = {}
if self.is_configured():
return {
"transport": self.transport(),
}
return stats
@staticmethod
def is_enabled():
"""Return if tcp or serial KISS is enabled."""
if CONF.kiss_serial.enabled:
return True
if CONF.kiss_tcp.enabled:
return True
return False
@staticmethod
def is_configured():
# Ensure that the config vars are correctly set
if KISSClient.is_enabled():
transport = KISSClient.transport()
if transport == client.TRANSPORT_SERIALKISS:
if not CONF.kiss_serial.device:
LOG.error("KISS serial enabled, but no device is set.")
raise exception.MissingConfigOptionException(
"kiss_serial.device is not set.",
)
elif transport == client.TRANSPORT_TCPKISS:
if not CONF.kiss_tcp.host:
LOG.error("KISS TCP enabled, but no host is set.")
raise exception.MissingConfigOptionException(
"kiss_tcp.host is not set.",
)
return True
return False
def is_alive(self):
if self._client:
return self._client.is_alive()
else:
return False
def close(self):
if self._client:
self._client.stop()
@staticmethod
def transport():
if CONF.kiss_serial.enabled:
return client.TRANSPORT_SERIALKISS
if CONF.kiss_tcp.enabled:
return client.TRANSPORT_TCPKISS
def decode_packet(self, *args, **kwargs):
"""We get a frame, which has to be decoded."""
LOG.debug(f"kwargs {kwargs}")
frame = kwargs["frame"]
LOG.debug(f"Got an APRS Frame '{frame}'")
# try and nuke the * from the fromcall sign.
# frame.header._source._ch = False
# payload = str(frame.payload.decode())
# msg = f"{str(frame.header)}:{payload}"
# msg = frame.tnc2
# LOG.debug(f"Decoding {msg}")
raw = aprslib.parse(str(frame))
packet = core.factory(raw)
if isinstance(packet, core.ThirdParty):
return packet.subpacket
else:
return packet
def setup_connection(self):
self._client = kiss.KISS3Client()
self.connected = True
return self._client
def consumer(self, callback, blocking=False, immortal=False, raw=False):
self._client.consumer(callback)

View File

@ -1,38 +0,0 @@
import threading
from oslo_config import cfg
import wrapt
from aprsd import client
from aprsd.utils import singleton
CONF = cfg.CONF
@singleton
class APRSClientStats:
lock = threading.Lock()
@wrapt.synchronized(lock)
def stats(self, serializable=False):
cl = client.client_factory.create()
stats = {
"transport": cl.transport(),
"filter": cl.filter,
"connected": cl.connected,
}
if cl.transport() == client.TRANSPORT_APRSIS:
stats["server_string"] = cl.client.server_string
keepalive = cl.client.aprsd_keepalive
if serializable:
keepalive = keepalive.isoformat()
stats["server_keepalive"] = keepalive
elif cl.transport() == client.TRANSPORT_TCPKISS:
stats["host"] = CONF.kiss_tcp.host
stats["port"] = CONF.kiss_tcp.port
elif cl.transport() == client.TRANSPORT_SERIALKISS:
stats["device"] = CONF.kiss_serial.device
return stats

View File

@ -1,4 +1,3 @@
import datetime
import logging
import select
import threading
@ -12,6 +11,7 @@ from aprslib.exceptions import (
import wrapt
import aprsd
from aprsd import stats
from aprsd.packets import core
@ -24,9 +24,6 @@ class Aprsdis(aprslib.IS):
# flag to tell us to stop
thread_stop = False
# date for last time we heard from the server
aprsd_keepalive = datetime.datetime.now()
# timeout in seconds
select_timeout = 1
lock = threading.Lock()
@ -145,6 +142,7 @@ class Aprsdis(aprslib.IS):
self.logger.info(f"Connected to {server_string}")
self.server_string = server_string
stats.APRSDStats().set_aprsis_server(server_string)
except LoginError as e:
self.logger.error(str(e))
@ -178,14 +176,13 @@ class Aprsdis(aprslib.IS):
try:
for line in self._socket_readlines(blocking):
if line[0:1] != b"#":
self.aprsd_keepalive = datetime.datetime.now()
if raw:
callback(line)
else:
callback(self._parse(line))
else:
self.logger.debug("Server: %s", line.decode("utf8"))
self.aprsd_keepalive = datetime.datetime.now()
stats.APRSDStats().set_aprsis_keepalive()
except ParseError as exp:
self.logger.log(
11,

View File

@ -67,7 +67,7 @@ class APRSDFakeClient(metaclass=trace.TraceWrapperMetaclass):
# Generate packets here?
raw = "GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW"
pkt_raw = aprslib.parse(raw)
pkt = core.factory(pkt_raw)
pkt = core.Packet.factory(pkt_raw)
callback(packet=pkt)
LOG.debug(f"END blocking FAKE consumer {self}")
time.sleep(8)

View File

@ -81,7 +81,7 @@ class KISS3Client:
LOG.error("Failed to parse bytes received from KISS interface.")
LOG.exception(ex)
def consumer(self, callback):
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)

View File

@ -1,5 +1,5 @@
import click
import click.shell_completion
import click_completion
from aprsd.main import cli
@ -7,16 +7,30 @@ from aprsd.main import cli
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@cli.command()
@click.argument("shell", type=click.Choice(list(click.shell_completion._available_shells)))
def completion(shell):
"""Show the shell completion code"""
from click.utils import _detect_program_name
@cli.group(help="Click Completion subcommands", context_settings=CONTEXT_SETTINGS)
@click.pass_context
def completion(ctx):
pass
cls = click.shell_completion.get_completion_class(shell)
prog_name = _detect_program_name()
complete_var = f"_{prog_name}_COMPLETE".replace("-", "_").upper()
print(cls(cli, {}, prog_name, complete_var).source())
print("# Add the following line to your shell configuration file to have aprsd command line completion")
print("# but remove the leading '#' character.")
print(f"# eval \"$(aprsd completion {shell})\"")
# 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}")

View File

@ -8,9 +8,8 @@ import logging
import click
from oslo_config import cfg
from aprsd import cli_helper, conf, packets, plugin
# local imports here
from aprsd.client import base
from aprsd import cli_helper, client, conf, packets, plugin
from aprsd.main import cli
from aprsd.utils import trace
@ -97,7 +96,7 @@ def test_plugin(
if CONF.trace_enabled:
trace.setup_tracing(["method", "api"])
base.APRSClient()
client.Client()
pm = plugin.PluginManager()
if load_all:

View File

@ -1,9 +1,10 @@
# Fetch active stats from a remote running instance of aprsd admin web interface.
# Fetch active stats from a remote running instance of aprsd server
# This uses the RPC server to fetch the stats from the remote server.
import logging
import click
from oslo_config import cfg
import requests
from rich.console import Console
from rich.table import Table
@ -11,6 +12,7 @@ from rich.table import Table
import aprsd
from aprsd import cli_helper
from aprsd.main import cli
from aprsd.rpc import client as rpc_client
# setup the global logger
@ -24,80 +26,87 @@ CONF = cfg.CONF
@click.option(
"--host", type=str,
default=None,
help="IP address of the remote aprsd admin web ui fetch stats from.",
help="IP address of the remote aprsd server to fetch stats from.",
)
@click.option(
"--port", type=int,
default=None,
help="Port of the remote aprsd web admin interface to fetch stats from.",
help="Port of the remote aprsd server rpc port to fetch stats from.",
)
@click.option(
"--magic-word", type=str,
default=None,
help="Magic word of the remote aprsd server rpc port to fetch stats from.",
)
@click.pass_context
@cli_helper.process_standard_options
def fetch_stats(ctx, host, port):
"""Fetch stats from a APRSD admin web interface."""
console = Console()
console.print(f"APRSD Fetch-Stats started version: {aprsd.__version__}")
def fetch_stats(ctx, host, port, magic_word):
"""Fetch stats from a remote running instance of aprsd server."""
LOG.info(f"APRSD Fetch-Stats started version: {aprsd.__version__}")
CONF.log_opt_values(LOG, logging.DEBUG)
if not host:
host = CONF.admin.web_ip
host = CONF.rpc_settings.ip
if not port:
port = CONF.admin.web_port
port = CONF.rpc_settings.port
if not magic_word:
magic_word = CONF.rpc_settings.magic_word
msg = f"Fetching stats from {host}:{port}"
msg = f"Fetching stats from {host}:{port} with magic word '{magic_word}'"
console = Console()
console.print(msg)
with console.status(msg):
response = requests.get(f"http://{host}:{port}/stats", timeout=120)
if not response:
console.print(
f"Failed to fetch stats from {host}:{port}?",
style="bold red",
)
client = rpc_client.RPCClient(host, port, magic_word)
stats = client.get_stats_dict()
if stats:
console.print_json(data=stats)
else:
LOG.error(f"Failed to fetch stats via RPC aprsd server at {host}:{port}")
return
stats = response.json()
if not stats:
console.print(
f"Failed to fetch stats from aprsd admin ui at {host}:{port}",
style="bold red",
)
return
aprsd_title = (
"APRSD "
f"[bold cyan]v{stats['APRSDStats']['version']}[/] "
f"Callsign [bold green]{stats['APRSDStats']['callsign']}[/] "
f"Uptime [bold yellow]{stats['APRSDStats']['uptime']}[/]"
f"[bold cyan]v{stats['aprsd']['version']}[/] "
f"Callsign [bold green]{stats['aprsd']['callsign']}[/] "
f"Uptime [bold yellow]{stats['aprsd']['uptime']}[/]"
)
console.rule(f"Stats from {host}:{port}")
console.rule(f"Stats from {host}:{port} with magic word '{magic_word}'")
console.print("\n\n")
console.rule(aprsd_title)
# Show the connection to APRS
# It can be a connection to an APRS-IS server or a local TNC via KISS or KISSTCP
if "aprs-is" in stats:
title = f"APRS-IS Connection {stats['APRSClientStats']['server_string']}"
title = f"APRS-IS Connection {stats['aprs-is']['server']}"
table = Table(title=title)
table.add_column("Key")
table.add_column("Value")
for key, value in stats["APRSClientStats"].items():
for key, value in stats["aprs-is"].items():
table.add_row(key, value)
console.print(table)
threads_table = Table(title="Threads")
threads_table.add_column("Name")
threads_table.add_column("Alive?")
for name, alive in stats["APRSDThreadList"].items():
for name, alive in stats["aprsd"]["threads"].items():
threads_table.add_row(name, str(alive))
console.print(threads_table)
msgs_table = Table(title="Messages")
msgs_table.add_column("Key")
msgs_table.add_column("Value")
for key, value in stats["messages"].items():
msgs_table.add_row(key, str(value))
console.print(msgs_table)
packet_totals = Table(title="Packet Totals")
packet_totals.add_column("Key")
packet_totals.add_column("Value")
packet_totals.add_row("Total Received", str(stats["PacketList"]["rx"]))
packet_totals.add_row("Total Sent", str(stats["PacketList"]["tx"]))
packet_totals.add_row("Total Received", str(stats["packets"]["total_received"]))
packet_totals.add_row("Total Sent", str(stats["packets"]["total_sent"]))
packet_totals.add_row("Total Tracked", str(stats["packets"]["total_tracked"]))
console.print(packet_totals)
# Show each of the packet types
@ -105,52 +114,47 @@ def fetch_stats(ctx, host, port):
packets_table.add_column("Packet Type")
packets_table.add_column("TX")
packets_table.add_column("RX")
for key, value in stats["PacketList"]["packets"].items():
for key, value in stats["packets"]["by_type"].items():
packets_table.add_row(key, str(value["tx"]), str(value["rx"]))
console.print(packets_table)
if "plugins" in stats:
count = len(stats["PluginManager"])
count = len(stats["plugins"])
plugins_table = Table(title=f"Plugins ({count})")
plugins_table.add_column("Plugin")
plugins_table.add_column("Enabled")
plugins_table.add_column("Version")
plugins_table.add_column("TX")
plugins_table.add_column("RX")
plugins = stats["PluginManager"]
for key, value in plugins.items():
for key, value in stats["plugins"].items():
plugins_table.add_row(
key,
str(plugins[key]["enabled"]),
plugins[key]["version"],
str(plugins[key]["tx"]),
str(plugins[key]["rx"]),
str(stats["plugins"][key]["enabled"]),
stats["plugins"][key]["version"],
str(stats["plugins"][key]["tx"]),
str(stats["plugins"][key]["rx"]),
)
console.print(plugins_table)
seen_list = stats.get("SeenList")
if seen_list:
count = len(seen_list)
if "seen_list" in stats["aprsd"]:
count = len(stats["aprsd"]["seen_list"])
seen_table = Table(title=f"Seen List ({count})")
seen_table.add_column("Callsign")
seen_table.add_column("Message Count")
seen_table.add_column("Last Heard")
for key, value in seen_list.items():
for key, value in stats["aprsd"]["seen_list"].items():
seen_table.add_row(key, str(value["count"]), value["last"])
console.print(seen_table)
watch_list = stats.get("WatchList")
if watch_list:
count = len(watch_list)
if "watch_list" in stats["aprsd"]:
count = len(stats["aprsd"]["watch_list"])
watch_table = Table(title=f"Watch List ({count})")
watch_table.add_column("Callsign")
watch_table.add_column("Last Heard")
for key, value in watch_list.items():
for key, value in stats["aprsd"]["watch_list"].items():
watch_table.add_row(key, value["last"])
console.print(watch_table)

View File

@ -13,11 +13,11 @@ from oslo_config import cfg
from rich.console import Console
import aprsd
from aprsd import cli_helper
from aprsd import cli_helper, utils
from aprsd import conf # noqa
# local imports here
from aprsd.main import cli
from aprsd.threads import stats as stats_threads
from aprsd.rpc import client as aprsd_rpc_client
# setup the global logger
@ -39,48 +39,46 @@ console = Console()
@cli_helper.process_standard_options
def healthcheck(ctx, timeout):
"""Check the health of the running aprsd server."""
ver_str = f"APRSD HealthCheck version: {aprsd.__version__}"
console.log(ver_str)
console.log(f"APRSD HealthCheck version: {aprsd.__version__}")
if not CONF.rpc_settings.enabled:
LOG.error("Must enable rpc_settings.enabled to use healthcheck")
sys.exit(-1)
if not CONF.rpc_settings.ip:
LOG.error("Must enable rpc_settings.ip to use healthcheck")
sys.exit(-1)
if not CONF.rpc_settings.magic_word:
LOG.error("Must enable rpc_settings.magic_word to use healthcheck")
sys.exit(-1)
with console.status(ver_str):
with console.status(f"APRSD HealthCheck version: {aprsd.__version__}") as status:
try:
stats_obj = stats_threads.StatsStore()
stats_obj.load()
stats = stats_obj.data
# console.print(stats)
status.update(f"Contacting APRSD via RPC {CONF.rpc_settings.ip}")
stats = aprsd_rpc_client.RPCClient().get_stats_dict()
except Exception as ex:
console.log(f"Failed to load stats: '{ex}'")
console.log(f"Failed to fetch healthcheck : '{ex}'")
sys.exit(-1)
else:
now = datetime.datetime.now()
if not stats:
console.log("No stats from aprsd")
sys.exit(-1)
email_thread_last_update = stats["email"]["thread_last_update"]
email_stats = stats.get("EmailStats")
if email_stats:
email_thread_last_update = email_stats["last_check_time"]
if email_thread_last_update != "never":
d = now - email_thread_last_update
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)
client_stats = stats.get("APRSClientStats")
if not client_stats:
console.log("No APRSClientStats")
sys.exit(-1)
else:
aprsis_last_update = client_stats["server_keepalive"]
d = now - aprsis_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:
LOG.error(f"APRS-IS last update is very old! {d}")
console.log(f"Email thread is very old! {d}")
sys.exit(-1)
console.log("OK")
aprsis_last_update = 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)

View File

@ -21,7 +21,7 @@ from aprsd import cli_helper
from aprsd import plugin as aprsd_plugin
from aprsd.main import cli
from aprsd.plugins import (
email, fortune, location, notify, ping, time, version, weather,
email, fortune, location, notify, ping, query, time, version, weather,
)
@ -122,7 +122,7 @@ def get_installed_extensions():
def show_built_in_plugins(console):
modules = [email, fortune, location, notify, ping, time, version, weather]
modules = [email, fortune, location, notify, ping, query, time, version, weather]
plugins = []
for module in modules:

View File

@ -15,15 +15,10 @@ from rich.console import Console
# local imports here
import aprsd
from aprsd import cli_helper, packets, plugin, threads
from aprsd.client import client_factory
from aprsd import cli_helper, client, packets, plugin, stats, threads
from aprsd.main import cli
from aprsd.packets import collector as packet_collector
from aprsd.packets import log as packet_log
from aprsd.packets import seen_list
from aprsd.stats import collector
from aprsd.threads import keep_alive, rx
from aprsd.threads import stats as stats_thread
from aprsd.rpc import server as rpc_server
from aprsd.threads import rx
# setup the global logger
@ -42,7 +37,7 @@ def signal_handler(sig, frame):
),
)
time.sleep(5)
LOG.info(collector.Collector().collect())
LOG.info(stats.APRSDStats())
class APRSDListenThread(rx.APRSDRXThread):
@ -58,33 +53,29 @@ class APRSDListenThread(rx.APRSDRXThread):
filters = {
packets.Packet.__name__: packets.Packet,
packets.AckPacket.__name__: packets.AckPacket,
packets.BeaconPacket.__name__: packets.BeaconPacket,
packets.GPSPacket.__name__: packets.GPSPacket,
packets.MessagePacket.__name__: packets.MessagePacket,
packets.MicEPacket.__name__: packets.MicEPacket,
packets.ObjectPacket.__name__: packets.ObjectPacket,
packets.StatusPacket.__name__: packets.StatusPacket,
packets.ThirdPartyPacket.__name__: packets.ThirdPartyPacket,
packets.WeatherPacket.__name__: packets.WeatherPacket,
packets.UnknownPacket.__name__: packets.UnknownPacket,
}
if self.packet_filter:
filter_class = filters[self.packet_filter]
if isinstance(packet, filter_class):
packet_log.log(packet)
packet.log(header="RX")
if self.plugin_manager:
# Don't do anything with the reply
# This is the listen only command.
self.plugin_manager.run(packet)
else:
packet_log.log(packet)
if self.plugin_manager:
# Don't do anything with the reply.
# This is the listen only command.
self.plugin_manager.run(packet)
else:
packet.log(header="RX")
packet_collector.PacketCollector().rx(packet)
packets.PacketList().rx(packet)
@cli.command()
@ -105,16 +96,11 @@ class APRSDListenThread(rx.APRSDRXThread):
"--packet-filter",
type=click.Choice(
[
packets.Packet.__name__,
packets.AckPacket.__name__,
packets.BeaconPacket.__name__,
packets.GPSPacket.__name__,
packets.MicEPacket.__name__,
packets.MessagePacket.__name__,
packets.ObjectPacket.__name__,
packets.RejectPacket.__name__,
packets.StatusPacket.__name__,
packets.ThirdPartyPacket.__name__,
packets.UnknownPacket.__name__,
packets.WeatherPacket.__name__,
],
case_sensitive=False,
@ -173,32 +159,32 @@ def listen(
LOG.info(f"APRSD Listen Started version: {aprsd.__version__}")
CONF.log_opt_values(LOG, logging.DEBUG)
collector.Collector()
# Try and load saved MsgTrack list
LOG.debug("Loading saved MsgTrack object.")
# Initialize the client factory and create
# The correct client object ready for use
client.ClientFactory.setup()
# Make sure we have 1 client transport enabled
if not client_factory.is_client_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()
aprs_client = client.factory.create()
LOG.info(aprs_client)
LOG.debug(f"Filter by '{filter}'")
aprs_client.set_filter(filter)
keepalive = keep_alive.KeepAliveThread()
# keepalive.start()
keepalive = threads.KeepAliveThread()
keepalive.start()
if not CONF.enable_seen_list:
# just deregister the class from the packet collector
packet_collector.PacketCollector().unregister(seen_list.SeenList)
if CONF.rpc_settings.enabled:
rpc = rpc_server.APRSDRPCThread()
rpc.start()
pm = None
pm = plugin.PluginManager()
@ -210,8 +196,6 @@ def listen(
"Not Loading any plugins use --load-plugins to load what's "
"defined in the config file.",
)
stats = stats_thread.APRSDStatsStoreThread()
stats.start()
LOG.debug("Create APRSDListenThread")
listen_thread = APRSDListenThread(
@ -221,10 +205,10 @@ def listen(
)
LOG.debug("Start APRSDListenThread")
listen_thread.start()
keepalive.start()
LOG.debug("keepalive Join")
keepalive.join()
LOG.debug("listen_thread Join")
listen_thread.join()
stats.join()
if CONF.rpc_settings.enabled:
rpc.join()

View File

@ -8,11 +8,9 @@ import click
from oslo_config import cfg
import aprsd
from aprsd import cli_helper, packets
from aprsd import cli_helper, client, packets
from aprsd import conf # noqa : F401
from aprsd.client import client_factory
from aprsd.main import cli
from aprsd.packets import collector
from aprsd.threads import tx
@ -78,6 +76,7 @@ def send_message(
aprs_login = CONF.aprs_network.login
if not aprs_password:
LOG.warning(CONF.aprs_network.password)
if not CONF.aprs_network.password:
click.echo("Must set --aprs-password or APRS_PASSWORD")
ctx.exit(-1)
@ -103,9 +102,9 @@ def send_message(
def rx_packet(packet):
global got_ack, got_response
cl = client_factory.create()
cl = client.factory.create()
packet = cl.decode_packet(packet)
collector.PacketCollector().rx(packet)
packets.PacketList().rx(packet)
packet.log("RX")
# LOG.debug("Got packet back {}".format(packet))
if isinstance(packet, packets.AckPacket):
@ -131,7 +130,8 @@ def send_message(
sys.exit(0)
try:
client_factory.create().client
client.ClientFactory.setup()
client.factory.create().client
except LoginError:
sys.exit(-1)
@ -163,7 +163,7 @@ def send_message(
# 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 = client.factory.create().client
aprs_client.consumer(rx_packet, raw=False)
except aprslib.exceptions.ConnectionDrop:
LOG.error("Connection dropped, reconnecting")

View File

@ -6,16 +6,12 @@ import click
from oslo_config import cfg
import aprsd
from aprsd import cli_helper
from aprsd import cli_helper, client
from aprsd import main as aprsd_main
from aprsd import packets, plugin, threads, utils
from aprsd.client import client_factory
from aprsd.main import cli
from aprsd.packets import collector as packet_collector
from aprsd.packets import seen_list
from aprsd.threads import keep_alive, log_monitor, registry, rx
from aprsd.threads import stats as stats_thread
from aprsd.threads import tx
from aprsd.rpc import server as rpc_server
from aprsd.threads import registry, rx, tx
CONF = cfg.CONF
@ -50,14 +46,7 @@ def server(ctx, flush):
# Initialize the client factory and create
# The correct client object ready for use
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()
LOG.info(aprs_client)
client.ClientFactory.setup()
# Create the initial PM singleton and Register plugins
# We register plugins first here so we can register each
@ -79,14 +68,18 @@ def server(ctx, flush):
LOG.info(p)
# Make sure we have 1 client transport enabled
if not client_factory.is_client_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():
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()
if flush:
@ -94,25 +87,16 @@ def server(ctx, flush):
packets.PacketTrack().flush()
packets.WatchList().flush()
packets.SeenList().flush()
packets.PacketList().flush()
else:
# Try and load saved MsgTrack list
LOG.debug("Loading saved MsgTrack object.")
packets.PacketTrack().load()
packets.WatchList().load()
packets.SeenList().load()
packets.PacketList().load()
keepalive = keep_alive.KeepAliveThread()
keepalive = threads.KeepAliveThread()
keepalive.start()
if not CONF.enable_seen_list:
# just deregister the class from the packet collector
packet_collector.PacketCollector().unregister(seen_list.SeenList)
stats_store_thread = stats_thread.APRSDStatsStoreThread()
stats_store_thread.start()
rx_thread = rx.APRSDPluginRXThread(
packet_queue=threads.packet_queue,
)
@ -122,6 +106,7 @@ def server(ctx, flush):
rx_thread.start()
process_thread.start()
packets.PacketTrack().restart()
if CONF.enable_beacon:
LOG.info("Beacon Enabled. Starting Beacon thread.")
bcn_thread = tx.BeaconSendThread()
@ -132,9 +117,11 @@ def server(ctx, flush):
registry_thread = registry.APRSRegistryThread()
registry_thread.start()
if CONF.admin.web_enabled:
log_monitor_thread = log_monitor.LogMonitorThread()
log_monitor_thread.start()
if CONF.rpc_settings.enabled:
rpc = rpc_server.APRSDRPCThread()
rpc.start()
log_monitor = threads.log_monitor.LogMonitorThread()
log_monitor.start()
rx_thread.join()
process_thread.join()

View File

@ -7,6 +7,7 @@ import sys
import threading
import time
from aprslib import util as aprslib_util
import click
import flask
from flask import request
@ -21,15 +22,15 @@ import aprsd
from aprsd import (
cli_helper, client, packets, plugin_utils, stats, threads, utils,
)
from aprsd.client import client_factory, kiss
from aprsd.log import log
from aprsd.main import cli
from aprsd.threads import aprsd as aprsd_threads
from aprsd.threads import keep_alive, rx, tx
from aprsd.threads import rx, tx
from aprsd.utils import trace
CONF = cfg.CONF
LOG = logging.getLogger()
LOG = logging.getLogger("APRSD")
auth = HTTPBasicAuth()
users = {}
socketio = None
@ -64,7 +65,7 @@ def signal_handler(sig, frame):
time.sleep(1.5)
# packets.WatchList().save()
# packets.SeenList().save()
LOG.info(stats.stats_collector.collect())
LOG.info(stats.APRSDStats())
LOG.info("Telling flask to bail.")
signal.signal(signal.SIGTERM, sys.exit(0))
@ -334,6 +335,7 @@ class WebChatProcessPacketThread(rx.APRSDProcessPacketThread):
def process_our_message_packet(self, packet: packets.MessagePacket):
global callsign_locations
LOG.info(f"process MessagePacket {repr(packet)}")
# ok lets see if we have the location for the
# person we just sent a message to.
from_call = packet.get("from_call").upper()
@ -379,10 +381,10 @@ def _get_transport(stats):
transport = "aprs-is"
aprs_connection = (
"APRS-IS Server: <a href='http://status.aprs2.net' >"
"{}</a>".format(stats["APRSClientStats"]["server_string"])
"{}</a>".format(stats["stats"]["aprs-is"]["server"])
)
elif kiss.KISSClient.is_enabled():
transport = kiss.KISSClient.transport()
elif client.KISSClient.is_enabled():
transport = client.KISSClient.transport()
if transport == client.TRANSPORT_TCPKISS:
aprs_connection = (
"TCPKISS://{}:{}".format(
@ -420,7 +422,7 @@ def index():
html_template = "index.html"
LOG.debug(f"Template {html_template}")
transport, aprs_connection = _get_transport(stats["stats"])
transport, aprs_connection = _get_transport(stats)
LOG.debug(f"transport {transport} aprs_connection {aprs_connection}")
stats["transport"] = transport
@ -455,28 +457,27 @@ def send_message_status():
def _stats():
stats_obj = stats.APRSDStats()
now = datetime.datetime.now()
time_format = "%m-%d-%Y %H:%M:%S"
stats_dict = stats.stats_collector.collect(serializable=True)
stats_dict = stats_obj.stats()
# Webchat doesnt need these
if "WatchList" in stats_dict:
del stats_dict["WatchList"]
if "SeenList" in stats_dict:
del stats_dict["SeenList"]
if "APRSDThreadList" in stats_dict:
del stats_dict["APRSDThreadList"]
if "PacketList" in stats_dict:
del stats_dict["PacketList"]
if "EmailStats" in stats_dict:
del stats_dict["EmailStats"]
if "PluginManager" in stats_dict:
del stats_dict["PluginManager"]
if "watch_list" in stats_dict["aprsd"]:
del stats_dict["aprsd"]["watch_list"]
if "seen_list" in stats_dict["aprsd"]:
del stats_dict["aprsd"]["seen_list"]
if "threads" in stats_dict["aprsd"]:
del stats_dict["aprsd"]["threads"]
# del stats_dict["email"]
# del stats_dict["plugins"]
# del stats_dict["messages"]
result = {
"time": now.strftime(time_format),
"stats": stats_dict,
}
return result
@ -540,27 +541,18 @@ class SendMessageNamespace(Namespace):
def on_gps(self, data):
LOG.debug(f"WS on_GPS: {data}")
lat = data["latitude"]
long = data["longitude"]
LOG.debug(f"Lat {lat}")
LOG.debug(f"Long {long}")
path = data.get("path", None)
if not path:
path = []
elif "," in path:
path_opts = path.split(",")
path = [x.strip() for x in path_opts]
else:
path = [path]
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}")
tx.send(
packets.BeaconPacket(
packets.GPSPacket(
from_call=CONF.callsign,
to_call="APDW16",
latitude=lat,
longitude=long,
comment="APRSD WebChat Beacon",
path=path,
),
direct=True,
)
@ -580,6 +572,8 @@ class SendMessageNamespace(Namespace):
def init_flask(loglevel, quiet):
global socketio, flask_app
log.setup_logging(loglevel, quiet)
socketio = SocketIO(
flask_app, logger=False, engineio_logger=False,
async_mode="threading",
@ -630,7 +624,7 @@ def webchat(ctx, flush, port):
LOG.info(msg)
LOG.info(f"APRSD Started version: {aprsd.__version__}")
CONF.log_opt_values(logging.getLogger(), logging.DEBUG)
CONF.log_opt_values(LOG, logging.DEBUG)
user = CONF.admin.user
users[user] = generate_password_hash(CONF.admin.password)
if not port:
@ -638,12 +632,13 @@ def webchat(ctx, flush, port):
# Initialize the client factory and create
# The correct client object ready for use
client.ClientFactory.setup()
# Make sure we have 1 client transport enabled
if not client_factory.is_client_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():
if not client.factory.is_client_configured():
LOG.error("APRS client is not properly configured in config file.")
sys.exit(-1)
@ -652,7 +647,7 @@ def webchat(ctx, flush, port):
packets.WatchList()
packets.SeenList()
keepalive = keep_alive.KeepAliveThread()
keepalive = threads.KeepAliveThread()
LOG.info("Start KeepAliveThread")
keepalive.start()

View File

@ -15,6 +15,10 @@ watch_list_group = cfg.OptGroup(
name="watch_list",
title="Watch List settings",
)
rpc_group = cfg.OptGroup(
name="rpc_settings",
title="RPC Settings for admin <--> web",
)
webchat_group = cfg.OptGroup(
name="webchat",
title="Settings specific to the webchat command",
@ -97,45 +101,6 @@ aprsd_opts = [
default=None,
help="Longitude for the GPS Beacon button. If not set, the button will not be enabled.",
),
cfg.StrOpt(
"log_packet_format",
choices=["compact", "multiline", "both"],
default="compact",
help="When logging packets 'compact' will use a single line formatted for each packet."
"'multiline' will use multiple lines for each packet and is the traditional format."
"both will log both compact and multiline.",
),
cfg.IntOpt(
"default_packet_send_count",
default=3,
help="The number of times to send a non ack packet before giving up.",
),
cfg.IntOpt(
"default_ack_send_count",
default=3,
help="The number of times to send an ack packet in response to recieving a packet.",
),
cfg.IntOpt(
"packet_list_maxlen",
default=100,
help="The maximum number of packets to store in the packet list.",
),
cfg.IntOpt(
"packet_list_stats_maxlen",
default=20,
help="The maximum number of packets to send in the stats dict for admin ui.",
),
cfg.BoolOpt(
"enable_seen_list",
default=True,
help="Enable the Callsign seen list tracking feature. This allows aprsd to keep track of "
"callsigns that have been seen and when they were last seen.",
),
cfg.BoolOpt(
"enable_packet_logging",
default=True,
help="Set this to False, to disable logging of packets to the log file.",
),
]
watch_list_opts = [
@ -173,7 +138,7 @@ admin_opts = [
default=False,
help="Enable the Admin Web Interface",
),
cfg.StrOpt(
cfg.IPOpt(
"web_ip",
default="0.0.0.0",
help="The ip address to listen on",
@ -196,6 +161,28 @@ admin_opts = [
),
]
rpc_opts = [
cfg.BoolOpt(
"enabled",
default=True,
help="Enable RPC calls",
),
cfg.StrOpt(
"ip",
default="localhost",
help="The ip address to listen on",
),
cfg.PortOpt(
"port",
default=18861,
help="The port to listen on",
),
cfg.StrOpt(
"magic_word",
default=APRSD_DEFAULT_MAGIC_WORD,
help="Magic word to authenticate requests between client/server",
),
]
enabled_plugins_opts = [
cfg.ListOpt(
@ -218,7 +205,7 @@ enabled_plugins_opts = [
]
webchat_opts = [
cfg.StrOpt(
cfg.IPOpt(
"web_ip",
default="0.0.0.0",
help="The ip address to listen on",
@ -238,11 +225,6 @@ webchat_opts = [
default=None,
help="Longitude for the GPS Beacon button. If not set, the button will not be enabled.",
),
cfg.BoolOpt(
"disable_url_request_logging",
default=False,
help="Disable the logging of url requests in the webchat command.",
),
]
registry_opts = [
@ -286,6 +268,8 @@ def register_opts(config):
config.register_opts(admin_opts, group=admin_group)
config.register_group(watch_list_group)
config.register_opts(watch_list_opts, group=watch_list_group)
config.register_group(rpc_group)
config.register_opts(rpc_opts, group=rpc_group)
config.register_group(webchat_group)
config.register_opts(webchat_opts, group=webchat_group)
config.register_group(registry_group)
@ -297,6 +281,7 @@ def list_opts():
"DEFAULT": (aprsd_opts + enabled_plugins_opts),
admin_group.name: admin_opts,
watch_list_group.name: watch_list_opts,
rpc_group.name: rpc_opts,
webchat_group.name: webchat_opts,
registry_group.name: registry_opts,
}

View File

@ -6,28 +6,12 @@ import sys
from loguru import logger
from oslo_config import cfg
from aprsd.conf import log as conf_log
from aprsd import conf
CONF = cfg.CONF
# LOG = logging.getLogger("APRSD")
LOG = logger
class QueueLatest(queue.Queue):
"""Custom Queue to keep only the latest N items.
This prevents the queue from blowing up in size.
"""
def put(self, *args, **kwargs):
try:
super().put(*args, **kwargs)
except queue.Full:
self.queue.popleft()
super().put(*args, **kwargs)
logging_queue = QueueLatest(maxsize=200)
LOG = logging.getLogger("APRSD")
logging_queue = queue.Queue()
class InterceptHandler(logging.Handler):
@ -54,7 +38,7 @@ def setup_logging(loglevel=None, quiet=False):
if not loglevel:
log_level = CONF.logging.log_level
else:
log_level = conf_log.LOG_LEVELS[loglevel]
log_level = conf.log.LOG_LEVELS[loglevel]
# intercept everything at the root logger
logging.root.handlers = [InterceptHandler()]
@ -69,19 +53,9 @@ def setup_logging(loglevel=None, quiet=False):
"aprslib.parsing",
"aprslib.exceptions",
]
webserver_list = [
"werkzeug",
"werkzeug._internal",
"socketio",
"urllib3.connectionpool",
"chardet",
"chardet.charsetgroupprober",
"chardet.eucjpprober",
"chardet.mbcharsetprober",
]
# We don't really want to see the aprslib parsing debug output.
disable_list = imap_list + aprslib_list + webserver_list
disable_list = imap_list + aprslib_list
# remove every other logger's handlers
# and propagate to root logger
@ -92,29 +66,17 @@ def setup_logging(loglevel=None, quiet=False):
else:
logging.getLogger(name).propagate = True
if CONF.webchat.disable_url_request_logging:
for name in webserver_list:
logging.getLogger(name).handlers = []
logging.getLogger(name).propagate = True
logging.getLogger(name).setLevel(logging.ERROR)
handlers = [
{
"sink": sys.stdout,
"serialize": False,
"sink": sys.stdout, "serialize": False,
"format": CONF.logging.logformat,
"colorize": True,
"level": log_level,
},
]
if CONF.logging.logfile:
handlers.append(
{
"sink": CONF.logging.logfile,
"serialize": False,
"sink": CONF.logging.logfile, "serialize": False,
"format": CONF.logging.logformat,
"colorize": False,
"level": log_level,
},
)
@ -128,11 +90,8 @@ def setup_logging(loglevel=None, quiet=False):
{
"sink": qh, "serialize": False,
"format": CONF.logging.logformat,
"level": log_level,
"colorize": False,
},
)
# configure loguru
logger.configure(handlers=handlers)
logger.level("DEBUG", color="<fg #BABABA>")

View File

@ -24,17 +24,18 @@ import datetime
import importlib.metadata as imp
from importlib.metadata import version as metadata_version
import logging
import os
import signal
import sys
import time
import click
import click_completion
from oslo_config import cfg, generator
# local imports here
import aprsd
from aprsd import cli_helper, packets, threads, utils
from aprsd.stats import collector
from aprsd import cli_helper, packets, stats, threads, utils
# setup the global logger
@ -43,6 +44,19 @@ CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
flask_enabled = False
rpc_serv = None
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(cls=cli_helper.AliasedGroup, context_settings=CONTEXT_SETTINGS)
@ -82,8 +96,7 @@ def signal_handler(sig, frame):
packets.PacketTrack().save()
packets.WatchList().save()
packets.SeenList().save()
packets.PacketList().save()
LOG.info(collector.Collector().collect())
LOG.info(stats.APRSDStats())
# signal.signal(signal.SIGTERM, sys.exit(0))
# sys.exit(0)
@ -109,25 +122,10 @@ def check_version(ctx):
def sample_config(ctx):
"""Generate a sample Config file from aprsd and all installed plugins."""
def _get_selected_entry_points():
import sys
if sys.version_info < (3, 10):
all = imp.entry_points()
selected = []
if "oslo.config.opts" in all:
for x in all["oslo.config.opts"]:
if x.group == "oslo.config.opts":
selected.append(x)
else:
selected = imp.entry_points(group="oslo.config.opts")
return selected
def get_namespaces():
args = []
# selected = imp.entry_points(group="oslo.config.opts")
selected = _get_selected_entry_points()
selected = imp.entry_points(group="oslo.config.opts")
for entry in selected:
if "aprsd" in entry.name:
args.append("--namespace")

View File

@ -1,7 +1,6 @@
from aprsd.packets.core import ( # noqa: F401
AckPacket, BeaconPacket, BulletinPacket, GPSPacket, MessagePacket,
MicEPacket, ObjectPacket, Packet, RejectPacket, StatusPacket,
ThirdPartyPacket, UnknownPacket, WeatherPacket, factory,
AckPacket, BeaconPacket, GPSPacket, MessagePacket, MicEPacket, Packet,
RejectPacket, StatusPacket, WeatherPacket,
)
from aprsd.packets.packet_list import PacketList # noqa: F401
from aprsd.packets.seen_list import SeenList # noqa: F401

View File

@ -1,56 +0,0 @@
import logging
from typing import Callable, Protocol, runtime_checkable
from aprsd.packets import core
from aprsd.utils import singleton
LOG = logging.getLogger("APRSD")
@runtime_checkable
class PacketMonitor(Protocol):
"""Protocol for Monitoring packets in some way."""
def rx(self, packet: type[core.Packet]) -> None:
"""When we get a packet from the network."""
...
def tx(self, packet: type[core.Packet]) -> None:
"""When we send a packet out the network."""
...
@singleton
class PacketCollector:
def __init__(self):
self.monitors: list[Callable] = []
def register(self, monitor: Callable) -> None:
self.monitors.append(monitor)
def unregister(self, monitor: Callable) -> None:
self.monitors.remove(monitor)
def rx(self, packet: type[core.Packet]) -> None:
for name in self.monitors:
cls = name()
if isinstance(cls, PacketMonitor):
try:
cls.rx(packet)
except Exception as e:
LOG.error(f"Error in monitor {name} (rx): {e}")
else:
raise TypeError(f"Monitor {name} is not a PacketMonitor")
def tx(self, packet: type[core.Packet]) -> None:
for name in self.monitors:
cls = name()
if isinstance(cls, PacketMonitor):
try:
cls.tx(packet)
except Exception as e:
LOG.error(f"Error in monitor {name} (tx): {e}")
else:
raise TypeError(f"Monitor {name} is not a PacketMonitor")

File diff suppressed because it is too large Load Diff

View File

@ -1,143 +0,0 @@
import logging
from typing import Optional
from loguru import logger
from oslo_config import cfg
from aprsd.packets.core import AckPacket, RejectPacket
LOG = logging.getLogger()
LOGU = logger
CONF = cfg.CONF
FROM_COLOR = "fg #C70039"
TO_COLOR = "fg #D033FF"
TX_COLOR = "red"
RX_COLOR = "green"
PACKET_COLOR = "cyan"
def log_multiline(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> None:
"""LOG a packet to the logfile."""
if not CONF.enable_packet_logging:
return
if CONF.log_packet_format == "compact":
return
# asdict(packet)
logit = ["\n"]
name = packet.__class__.__name__
if isinstance(packet, AckPacket):
pkt_max_send_count = CONF.default_ack_send_count
else:
pkt_max_send_count = CONF.default_packet_send_count
if header:
if tx:
header_str = f"<{TX_COLOR}>TX</{TX_COLOR}>"
logit.append(
f"{header_str}________(<{PACKET_COLOR}>{name}</{PACKET_COLOR}> "
f"TX:{packet.send_count + 1} of {pkt_max_send_count}",
)
else:
header_str = f"<{RX_COLOR}>RX</{RX_COLOR}>"
logit.append(
f"{header_str}________(<{PACKET_COLOR}>{name}</{PACKET_COLOR}>)",
)
else:
header_str = ""
logit.append(f"__________(<{PACKET_COLOR}>{name}</{PACKET_COLOR}>)")
# log_list.append(f" Packet : {packet.__class__.__name__}")
if packet.msgNo:
logit.append(f" Msg # : {packet.msgNo}")
if packet.from_call:
logit.append(f" From : <{FROM_COLOR}>{packet.from_call}</{FROM_COLOR}>")
if packet.to_call:
logit.append(f" To : <{TO_COLOR}>{packet.to_call}</{TO_COLOR}>")
if hasattr(packet, "path") and packet.path:
logit.append(f" Path : {'=>'.join(packet.path)}")
if hasattr(packet, "via") and packet.via:
logit.append(f" VIA : {packet.via}")
if not isinstance(packet, AckPacket) and not isinstance(packet, RejectPacket):
msg = packet.human_info
if msg:
msg = msg.replace("<", "\\<")
logit.append(f" Info : <light-yellow><b>{msg}</b></light-yellow>")
if hasattr(packet, "comment") and packet.comment:
logit.append(f" Comment : {packet.comment}")
raw = packet.raw.replace("<", "\\<")
logit.append(f" Raw : <fg #828282>{raw}</fg #828282>")
logit.append(f"{header_str}________(<{PACKET_COLOR}>{name}</{PACKET_COLOR}>)")
LOGU.opt(colors=True).info("\n".join(logit))
LOG.debug(repr(packet))
def log(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> None:
if not CONF.enable_packet_logging:
return
if CONF.log_packet_format == "multiline":
log_multiline(packet, tx, header)
return
logit = []
name = packet.__class__.__name__
if isinstance(packet, AckPacket):
pkt_max_send_count = CONF.default_ack_send_count
else:
pkt_max_send_count = CONF.default_packet_send_count
if header:
if tx:
via_color = "red"
arrow = f"<{via_color}>-></{via_color}>"
logit.append(
f"<red>TX {arrow}</red> "
f"<cyan>{name}</cyan>"
f":{packet.msgNo}"
f" ({packet.send_count + 1} of {pkt_max_send_count})",
)
else:
via_color = "fg #828282"
arrow = f"<{via_color}>-></{via_color}>"
left_arrow = f"<{via_color}><-</{via_color}>"
logit.append(
f"<fg #1AA730>RX</fg #1AA730> {left_arrow} "
f"<cyan>{name}</cyan>"
f":{packet.msgNo}",
)
else:
via_color = "green"
arrow = f"<{via_color}>-></{via_color}>"
logit.append(
f"<cyan>{name}</cyan>"
f":{packet.msgNo}",
)
tmp = None
if packet.path:
tmp = f"{arrow}".join(packet.path) + f"{arrow} "
logit.append(
f"<{FROM_COLOR}>{packet.from_call}</{FROM_COLOR}> {arrow}"
f"{tmp if tmp else ' '}"
f"<{TO_COLOR}>{packet.to_call}</{TO_COLOR}>",
)
if not isinstance(packet, AckPacket) and not isinstance(packet, RejectPacket):
logit.append(":")
msg = packet.human_info
if msg:
msg = msg.replace("<", "\\<")
logit.append(f"<light-yellow><b>{msg}</b></light-yellow>")
LOGU.opt(colors=True).info(" ".join(logit))
log_multiline(packet, tx, header)

View File

@ -1,116 +1,99 @@
from collections import OrderedDict
from collections.abc import MutableMapping
import logging
import threading
from oslo_config import cfg
import wrapt
from aprsd.packets import collector, core
from aprsd.utils import objectstore
from aprsd import stats
from aprsd.packets import seen_list
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class PacketList(objectstore.ObjectStoreMixin):
"""Class to keep track of the packets we tx/rx."""
class PacketList(MutableMapping):
_instance = None
lock = threading.Lock()
_total_rx: int = 0
_total_tx: int = 0
maxlen: int = 100
types = {}
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.maxlen = CONF.packet_list_maxlen
cls._instance._init_data()
cls._maxlen = 100
cls.d = OrderedDict()
return cls._instance
def _init_data(self):
self.data = {
"types": {},
"packets": OrderedDict(),
}
def rx(self, packet: type[core.Packet]):
@wrapt.synchronized(lock)
def rx(self, packet):
"""Add a packet that was received."""
with self.lock:
self._total_rx += 1
self._add(packet)
ptype = packet.__class__.__name__
if not ptype in self.data["types"]:
self.data["types"][ptype] = {"tx": 0, "rx": 0}
self.data["types"][ptype]["rx"] += 1
self._total_rx += 1
self._add(packet)
ptype = packet.__class__.__name__
if not ptype in self.types:
self.types[ptype] = {"tx": 0, "rx": 0}
self.types[ptype]["rx"] += 1
seen_list.SeenList().update_seen(packet)
stats.APRSDStats().rx(packet)
def tx(self, packet: type[core.Packet]):
@wrapt.synchronized(lock)
def tx(self, packet):
"""Add a packet that was received."""
with self.lock:
self._total_tx += 1
self._add(packet)
ptype = packet.__class__.__name__
if not ptype in self.data["types"]:
self.data["types"][ptype] = {"tx": 0, "rx": 0}
self.data["types"][ptype]["tx"] += 1
self._total_tx += 1
self._add(packet)
ptype = packet.__class__.__name__
if not ptype in self.types:
self.types[ptype] = {"tx": 0, "rx": 0}
self.types[ptype]["tx"] += 1
seen_list.SeenList().update_seen(packet)
stats.APRSDStats().tx(packet)
@wrapt.synchronized(lock)
def add(self, packet):
with self.lock:
self._add(packet)
self._add(packet)
def _add(self, packet):
if not self.data.get("packets"):
self._init_data()
if packet.key in self.data["packets"]:
self.data["packets"].move_to_end(packet.key)
elif len(self.data["packets"]) == self.maxlen:
self.data["packets"].popitem(last=False)
self.data["packets"][packet.key] = packet
self[packet.key] = packet
def copy(self):
return self.d.copy()
@property
def maxlen(self):
return self._maxlen
@wrapt.synchronized(lock)
def find(self, packet):
with self.lock:
return self.data["packets"][packet.key]
return self.get(packet.key)
def __getitem__(self, key):
# self.d.move_to_end(key)
return self.d[key]
def __setitem__(self, key, value):
if key in self.d:
self.d.move_to_end(key)
elif len(self.d) == self.maxlen:
self.d.popitem(last=False)
self.d[key] = value
def __delitem__(self, key):
del self.d[key]
def __iter__(self):
return self.d.__iter__()
def __len__(self):
with self.lock:
return len(self.data["packets"])
return len(self.d)
@wrapt.synchronized(lock)
def total_rx(self):
with self.lock:
return self._total_rx
return self._total_rx
@wrapt.synchronized(lock)
def total_tx(self):
with self.lock:
return self._total_tx
def stats(self, serializable=False) -> dict:
# limit the number of packets to return to 50
with self.lock:
tmp = OrderedDict(
reversed(
list(
self.data.get("packets", OrderedDict()).items(),
),
),
)
pkts = []
count = 1
for packet in tmp:
pkts.append(tmp[packet])
count += 1
if count > CONF.packet_list_stats_maxlen:
break
stats = {
"total_tracked": self._total_rx + self._total_rx,
"rx": self._total_rx,
"tx": self._total_tx,
"types": self.data.get("types", []),
"packet_count": len(self.data.get("packets", [])),
"maxlen": self.maxlen,
"packets": pkts,
}
return stats
# Now register the PacketList with the collector
# every packet we RX and TX goes through the collector
# for processing for whatever reason is needed.
collector.PacketCollector().register(PacketList)
return self._total_tx

View File

@ -1,9 +1,10 @@
import datetime
import logging
import threading
from oslo_config import cfg
import wrapt
from aprsd.packets import collector, core
from aprsd.utils import objectstore
@ -15,40 +16,28 @@ class SeenList(objectstore.ObjectStoreMixin):
"""Global callsign seen list."""
_instance = None
lock = threading.Lock()
data: dict = {}
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_store()
cls._instance.data = {}
return cls._instance
def stats(self, serializable=False):
"""Return the stats for the PacketTrack class."""
with self.lock:
return self.data
def rx(self, packet: type[core.Packet]):
"""When we get a packet from the network, update the seen list."""
with self.lock:
callsign = None
if packet.from_call:
callsign = packet.from_call
else:
LOG.warning(f"Can't find FROM in packet {packet}")
return
if callsign not in self.data:
self.data[callsign] = {
"last": None,
"count": 0,
}
self.data[callsign]["last"] = datetime.datetime.now()
self.data[callsign]["count"] += 1
def tx(self, packet: type[core.Packet]):
"""We don't care about TX packets."""
# Register with the packet collector so we can process the packet
# when we get it off the client (network)
collector.PacketCollector().register(SeenList)
@wrapt.synchronized(lock)
def update_seen(self, packet):
callsign = None
if packet.from_call:
callsign = packet.from_call
else:
LOG.warning(f"Can't find FROM in packet {packet}")
return
if callsign not in self.data:
self.data[callsign] = {
"last": None,
"count": 0,
}
self.data[callsign]["last"] = str(datetime.datetime.now())
self.data[callsign]["count"] += 1

View File

@ -1,14 +1,14 @@
import datetime
import logging
import threading
from oslo_config import cfg
import wrapt
from aprsd.packets import collector, core
from aprsd.threads import tx
from aprsd.utils import objectstore
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class PacketTrack(objectstore.ObjectStoreMixin):
@ -26,6 +26,7 @@ class PacketTrack(objectstore.ObjectStoreMixin):
_instance = None
_start_time = None
lock = threading.Lock()
data: dict = {}
total_tracked: int = 0
@ -37,73 +38,74 @@ class PacketTrack(objectstore.ObjectStoreMixin):
cls._instance._init_store()
return cls._instance
@wrapt.synchronized(lock)
def __getitem__(self, name):
with self.lock:
return self.data[name]
return self.data[name]
@wrapt.synchronized(lock)
def __iter__(self):
with self.lock:
return iter(self.data)
return iter(self.data)
@wrapt.synchronized(lock)
def keys(self):
with self.lock:
return self.data.keys()
return self.data.keys()
@wrapt.synchronized(lock)
def items(self):
with self.lock:
return self.data.items()
return self.data.items()
@wrapt.synchronized(lock)
def values(self):
with self.lock:
return self.data.values()
return self.data.values()
def stats(self, serializable=False):
with self.lock:
stats = {
"total_tracked": self.total_tracked,
}
pkts = {}
for key in self.data:
last_send_time = self.data[key].last_send_time
pkts[key] = {
"last_send_time": last_send_time,
"send_count": self.data[key].send_count,
"retry_count": self.data[key].retry_count,
"message": self.data[key].raw,
}
stats["packets"] = pkts
return stats
@wrapt.synchronized(lock)
def __len__(self):
return len(self.data)
def rx(self, packet: type[core.Packet]) -> None:
"""When we get a packet from the network, check if we should remove it."""
if isinstance(packet, core.AckPacket):
self._remove(packet.msgNo)
elif isinstance(packet, core.RejectPacket):
self._remove(packet.msgNo)
elif hasattr(packet, "ackMsgNo"):
# Got a piggyback ack, so remove the original message
self._remove(packet.ackMsgNo)
@wrapt.synchronized(lock)
def add(self, packet):
key = packet.msgNo
packet._last_send_attempt = 0
self.data[key] = packet
self.total_tracked += 1
def tx(self, packet: type[core.Packet]) -> None:
"""Add a packet that was sent."""
with self.lock:
key = packet.msgNo
packet.send_count = 0
self.data[key] = packet
self.total_tracked += 1
@wrapt.synchronized(lock)
def get(self, key):
return self.data.get(key, None)
@wrapt.synchronized(lock)
def remove(self, key):
self._remove(key)
try:
del self.data[key]
except KeyError:
pass
def _remove(self, key):
with self.lock:
try:
del self.data[key]
except KeyError:
pass
def restart(self):
"""Walk the list of messages and restart them if any."""
for key in self.data.keys():
pkt = self.data[key]
if pkt._last_send_attempt < pkt.retry_count:
tx.send(pkt)
def _resend(self, packet):
packet._last_send_attempt = 0
tx.send(packet)
# Now register the PacketList with the collector
# every packet we RX and TX goes through the collector
# for processing for whatever reason is needed.
collector.PacketCollector().register(PacketTrack)
def restart_delayed(self, count=None, most_recent=True):
"""Walk the list of delayed messages and restart them if any."""
if not count:
# Send all the delayed messages
for key in self.data.keys():
pkt = self.data[key]
if pkt._last_send_attempt == pkt._retry_count:
self._resend(pkt)
else:
# They want to resend <count> delayed messages
tmp = sorted(
self.data.items(),
reverse=most_recent,
key=lambda x: x[1].last_send_time,
)
pkt_list = tmp[:count]
for (_key, pkt) in pkt_list:
self._resend(pkt)

View File

@ -1,10 +1,11 @@
import datetime
import logging
import threading
from oslo_config import cfg
import wrapt
from aprsd import utils
from aprsd.packets import collector, core
from aprsd.utils import objectstore
@ -16,75 +17,56 @@ class WatchList(objectstore.ObjectStoreMixin):
"""Global watch list and info for callsigns."""
_instance = None
lock = threading.Lock()
data = {}
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_store()
cls._instance.data = {}
return cls._instance
def __init__(self):
super().__init__()
self._update_from_conf()
def __init__(self, config=None):
ring_size = CONF.watch_list.packet_keep_count
def _update_from_conf(self, config=None):
with self.lock:
if CONF.watch_list.enabled and CONF.watch_list.callsigns:
for callsign in CONF.watch_list.callsigns:
call = callsign.replace("*", "")
# FIXME(waboring) - we should fetch the last time we saw
# a beacon from a callsign or some other mechanism to find
# last time a message was seen by aprs-is. For now this
# is all we can do.
if call not in self.data:
self.data[call] = {
"last": None,
"packet": None,
}
def stats(self, serializable=False) -> dict:
stats = {}
with self.lock:
for callsign in self.data:
stats[callsign] = {
"last": self.data[callsign]["last"],
"packet": self.data[callsign]["packet"],
"age": self.age(callsign),
"old": self.is_old(callsign),
if CONF.watch_list.callsigns:
for callsign in CONF.watch_list.callsigns:
call = callsign.replace("*", "")
# FIXME(waboring) - we should fetch the last time we saw
# a beacon from a callsign or some other mechanism to find
# last time a message was seen by aprs-is. For now this
# is all we can do.
self.data[call] = {
"last": datetime.datetime.now(),
"packets": utils.RingBuffer(
ring_size,
),
}
return stats
def is_enabled(self):
return CONF.watch_list.enabled
def callsign_in_watchlist(self, callsign):
with self.lock:
return callsign in self.data
def rx(self, packet: type[core.Packet]) -> None:
"""Track when we got a packet from the network."""
callsign = packet.from_call
return callsign in self.data
@wrapt.synchronized(lock)
def update_seen(self, packet):
if packet.addresse:
callsign = packet.addresse
else:
callsign = packet.from_call
if self.callsign_in_watchlist(callsign):
with self.lock:
self.data[callsign]["last"] = datetime.datetime.now()
self.data[callsign]["packet"] = packet
def tx(self, packet: type[core.Packet]) -> None:
"""We don't care about TX packets."""
self.data[callsign]["last"] = datetime.datetime.now()
self.data[callsign]["packets"].append(packet)
def last_seen(self, callsign):
with self.lock:
if self.callsign_in_watchlist(callsign):
return self.data[callsign]["last"]
if self.callsign_in_watchlist(callsign):
return self.data[callsign]["last"]
def age(self, callsign):
now = datetime.datetime.now()
last_seen_time = self.last_seen(callsign)
if last_seen_time:
return str(now - last_seen_time)
else:
return None
return str(now - self.last_seen(callsign))
def max_delta(self, seconds=None):
if not seconds:
@ -101,22 +83,14 @@ class WatchList(objectstore.ObjectStoreMixin):
We put this here so any notification plugin can use this
same test.
"""
if not self.callsign_in_watchlist(callsign):
return False
age = self.age(callsign)
if age:
delta = utils.parse_delta_str(age)
d = datetime.timedelta(**delta)
max_delta = self.max_delta(seconds=seconds)
delta = utils.parse_delta_str(age)
d = datetime.timedelta(**delta)
if d > max_delta:
return True
else:
return False
max_delta = self.max_delta(seconds=seconds)
if d > max_delta:
return True
else:
return False
collector.PacketCollector().register(WatchList)

View File

@ -1,5 +1,4 @@
from __future__ import annotations
# The base plugin class
import abc
import importlib
import inspect
@ -43,7 +42,7 @@ class APRSDPluginSpec:
"""A hook specification namespace."""
@hookspec
def filter(self, packet: type[packets.Packet]):
def filter(self, packet: packets.core.Packet):
"""My special little hook that you can customize."""
@ -66,7 +65,7 @@ class APRSDPluginBase(metaclass=abc.ABCMeta):
self.threads = self.create_threads() or []
self.start_threads()
def start_threads(self) -> None:
def start_threads(self):
if self.enabled and self.threads:
if not isinstance(self.threads, list):
self.threads = [self.threads]
@ -91,10 +90,10 @@ class APRSDPluginBase(metaclass=abc.ABCMeta):
)
@property
def message_count(self) -> int:
def message_count(self):
return self.message_counter
def help(self) -> str:
def help(self):
return "Help!"
@abc.abstractmethod
@ -119,11 +118,11 @@ class APRSDPluginBase(metaclass=abc.ABCMeta):
thread.stop()
@abc.abstractmethod
def filter(self, packet: type[packets.Packet]) -> str | packets.MessagePacket:
def filter(self, packet: packets.core.Packet):
pass
@abc.abstractmethod
def process(self, packet: type[packets.Packet]):
def process(self, packet: packets.core.Packet):
"""This is called when the filter passes."""
@ -148,14 +147,14 @@ class APRSDWatchListPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta):
watch_list = CONF.watch_list.callsigns
# make sure the timeout is set or this doesn't work
if watch_list:
aprs_client = client.client_factory.create().client
aprs_client = client.factory.create().client
filter_str = "b/{}".format("/".join(watch_list))
aprs_client.set_filter(filter_str)
else:
LOG.warning("Watch list enabled, but no callsigns set.")
@hookimpl
def filter(self, packet: type[packets.Packet]) -> str | packets.MessagePacket:
def filter(self, packet: packets.core.Packet):
result = packets.NULL_MESSAGE
if self.enabled:
wl = watch_list.WatchList()
@ -207,14 +206,14 @@ class APRSDRegexCommandPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta):
self.enabled = True
@hookimpl
def filter(self, packet: packets.MessagePacket) -> str | packets.MessagePacket:
LOG.debug(f"{self.__class__.__name__} called")
def filter(self, packet: packets.core.MessagePacket):
LOG.info(f"{self.__class__.__name__} called")
if not self.enabled:
result = f"{self.__class__.__name__} isn't enabled"
LOG.warning(result)
return result
if not isinstance(packet, packets.MessagePacket):
if not isinstance(packet, packets.core.MessagePacket):
LOG.warning(f"{self.__class__.__name__} Got a {packet.__class__.__name__} ignoring")
return packets.NULL_MESSAGE
@ -227,7 +226,7 @@ class APRSDRegexCommandPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta):
# and is an APRS message format and has a message.
if (
tocall == CONF.callsign
and isinstance(packet, packets.MessagePacket)
and isinstance(packet, packets.core.MessagePacket)
and message
):
if re.search(self.command_regex, message, re.IGNORECASE):
@ -270,7 +269,7 @@ class HelpPlugin(APRSDRegexCommandPluginBase):
def help(self):
return "Help: send APRS help or help <plugin>"
def process(self, packet: packets.MessagePacket):
def process(self, packet: packets.core.MessagePacket):
LOG.info("HelpPlugin")
# fromcall = packet.get("from")
message = packet.message_text
@ -344,28 +343,6 @@ class PluginManager:
self._watchlist_pm = pluggy.PluginManager("aprsd")
self._watchlist_pm.add_hookspecs(APRSDPluginSpec)
def stats(self, serializable=False) -> dict:
"""Collect and return stats for all plugins."""
def full_name_with_qualname(obj):
return "{}.{}".format(
obj.__class__.__module__,
obj.__class__.__qualname__,
)
plugin_stats = {}
plugins = self.get_plugins()
if plugins:
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,
}
return plugin_stats
def is_plugin(self, obj):
for c in inspect.getmro(obj):
if issubclass(c, APRSDPluginBase):
@ -391,9 +368,7 @@ class PluginManager:
try:
module_name, class_name = module_class_string.rsplit(".", 1)
module = importlib.import_module(module_name)
# Commented out because the email thread starts in a different context
# and hence gives a different singleton for the EmailStats
# module = importlib.reload(module)
module = importlib.reload(module)
except Exception as ex:
if not module_name:
LOG.error(f"Failed to load Plugin {module_class_string}")
@ -494,12 +469,12 @@ class PluginManager:
LOG.info("Completed Plugin Loading.")
def run(self, packet: packets.MessagePacket):
def run(self, packet: packets.core.MessagePacket):
"""Execute all the plugins run method."""
with self.lock:
return self._pluggy_pm.hook.filter(packet=packet)
def run_watchlist(self, packet: packets.Packet):
def run_watchlist(self, packet: packets.core.Packet):
with self.lock:
return self._watchlist_pm.hook.filter(packet=packet)

View File

@ -11,7 +11,7 @@ import time
import imapclient
from oslo_config import cfg
from aprsd import packets, plugin, threads, utils
from aprsd import packets, plugin, stats, threads
from aprsd.threads import tx
from aprsd.utils import trace
@ -60,38 +60,6 @@ class EmailInfo:
self._delay = val
@utils.singleton
class EmailStats:
"""Singleton object to store stats related to email."""
_instance = None
tx = 0
rx = 0
email_thread_last_time = None
def stats(self, serializable=False):
if CONF.email_plugin.enabled:
last_check_time = self.email_thread_last_time
if serializable and last_check_time:
last_check_time = last_check_time.isoformat()
stats = {
"tx": self.tx,
"rx": self.rx,
"last_check_time": last_check_time,
}
else:
stats = {}
return stats
def tx_inc(self):
self.tx += 1
def rx_inc(self):
self.rx += 1
def email_thread_update(self):
self.email_thread_last_time = datetime.datetime.now()
class EmailPlugin(plugin.APRSDRegexCommandPluginBase):
"""Email Plugin."""
@ -222,6 +190,10 @@ class EmailPlugin(plugin.APRSDRegexCommandPluginBase):
def _imap_connect():
imap_port = CONF.email_plugin.imap_port
use_ssl = CONF.email_plugin.imap_use_ssl
# host = CONFIG["aprsd"]["email"]["imap"]["host"]
# msg = "{}{}:{}".format("TLS " if use_ssl else "", host, imap_port)
# LOG.debug("Connect to IMAP host {} with user '{}'".
# format(msg, CONFIG['imap']['login']))
try:
server = imapclient.IMAPClient(
@ -468,7 +440,7 @@ def send_email(to_addr, content):
[to_addr],
msg.as_string(),
)
EmailStats().tx_inc()
stats.APRSDStats().email_tx_inc()
except Exception:
LOG.exception("Sendmail Error!!!!")
server.quit()
@ -573,7 +545,7 @@ class APRSDEmailThread(threads.APRSDThread):
def loop(self):
time.sleep(5)
EmailStats().email_thread_update()
stats.APRSDStats().email_thread_update()
# always sleep for 5 seconds and see if we need to check email
# This allows CTRL-C to stop the execution of this loop sooner
# than check_email_delay time

81
aprsd/plugins/query.py Normal file
View File

@ -0,0 +1,81 @@
import datetime
import logging
import re
from oslo_config import cfg
from aprsd import packets, plugin
from aprsd.packets import tracker
from aprsd.utils import trace
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class QueryPlugin(plugin.APRSDRegexCommandPluginBase):
"""Query command."""
command_regex = r"^\!.*"
command_name = "query"
short_description = "APRSD Owner command to query messages in the MsgTrack"
def setup(self):
"""Do any plugin setup here."""
if not CONF.query_plugin.callsign:
LOG.error("Config query_plugin.callsign not set. Disabling plugin")
self.enabled = False
self.enabled = True
@trace.trace
def process(self, packet: packets.MessagePacket):
LOG.info("Query COMMAND")
fromcall = packet.from_call
message = packet.get("message_text", None)
pkt_tracker = tracker.PacketTrack()
now = datetime.datetime.now()
reply = "Pending messages ({}) {}".format(
len(pkt_tracker),
now.strftime("%H:%M:%S"),
)
searchstring = "^" + CONF.query_plugin.callsign + ".*"
# only I can do admin commands
if re.search(searchstring, fromcall):
# resend last N most recent: "!3"
r = re.search(r"^\!([0-9]).*", message)
if r is not None:
if len(pkt_tracker) > 0:
last_n = r.group(1)
reply = packets.NULL_MESSAGE
LOG.debug(reply)
pkt_tracker.restart_delayed(count=int(last_n))
else:
reply = "No pending msgs to resend"
LOG.debug(reply)
return reply
# resend all: "!a"
r = re.search(r"^\![aA].*", message)
if r is not None:
if len(pkt_tracker) > 0:
reply = packets.NULL_MESSAGE
LOG.debug(reply)
pkt_tracker.restart_delayed()
else:
reply = "No pending msgs"
LOG.debug(reply)
return reply
# delete all: "!d"
r = re.search(r"^\![dD].*", message)
if r is not None:
reply = "Deleted ALL pending msgs."
LOG.debug(reply)
pkt_tracker.flush()
return reply
return reply

View File

@ -1,9 +1,9 @@
import logging
import re
import time
from oslo_config import cfg
import pytz
from tzlocal import get_localzone
from aprsd import packets, plugin, plugin_utils
from aprsd.utils import fuzzy, trace
@ -22,8 +22,7 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase):
short_description = "What is the current local time."
def _get_local_tz(self):
lz = get_localzone()
return pytz.timezone(str(lz))
return pytz.timezone(time.strftime("%Z"))
def _get_utcnow(self):
return pytz.datetime.datetime.utcnow()

View File

@ -1,8 +1,7 @@
import logging
import aprsd
from aprsd import plugin
from aprsd.stats import collector
from aprsd import plugin, stats
LOG = logging.getLogger("APRSD")
@ -24,8 +23,10 @@ class VersionPlugin(plugin.APRSDRegexCommandPluginBase):
# fromcall = packet.get("from")
# message = packet.get("message_text", None)
# ack = packet.get("msgNo", "0")
s = collector.Collector().collect()
stats_obj = stats.APRSDStats()
s = stats_obj.stats()
print(s)
return "APRSD ver:{} uptime:{}".format(
aprsd.__version__,
s["APRSDStats"]["uptime"],
s["aprsd"]["uptime"],
)

View File

@ -110,6 +110,7 @@ class USMetarPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
@trace.trace
def process(self, packet):
print("FISTY")
fromcall = packet.get("from")
message = packet.get("message_text", None)
# ack = packet.get("msgNo", "0")

14
aprsd/rpc/__init__.py Normal file
View File

@ -0,0 +1,14 @@
import rpyc
class AuthSocketStream(rpyc.SocketStream):
"""Used to authenitcate the RPC stream to remote."""
@classmethod
def connect(cls, *args, authorizer=None, **kwargs):
stream_obj = super().connect(*args, **kwargs)
if callable(authorizer):
authorizer(stream_obj.sock)
return stream_obj

165
aprsd/rpc/client.py Normal file
View File

@ -0,0 +1,165 @@
import json
import logging
from oslo_config import cfg
import rpyc
from aprsd import conf # noqa
from aprsd import rpc
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class RPCClient:
_instance = None
_rpc_client = None
ip = None
port = None
magic_word = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, ip=None, port=None, magic_word=None):
if ip:
self.ip = ip
else:
self.ip = CONF.rpc_settings.ip
if port:
self.port = int(port)
else:
self.port = CONF.rpc_settings.port
if magic_word:
self.magic_word = magic_word
else:
self.magic_word = CONF.rpc_settings.magic_word
self._check_settings()
self.get_rpc_client()
def _check_settings(self):
if not CONF.rpc_settings.enabled:
LOG.warning("RPC is not enabled, no way to get stats!!")
if self.magic_word == conf.common.APRSD_DEFAULT_MAGIC_WORD:
LOG.warning("You are using the default RPC magic word!!!")
LOG.warning("edit aprsd.conf and change rpc_settings.magic_word")
LOG.debug(f"RPC Client: {self.ip}:{self.port} {self.magic_word}")
def _rpyc_connect(
self, host, port, service=rpyc.VoidService,
config={}, ipv6=False,
keepalive=False, authorizer=None, ):
LOG.info(f"Connecting to RPC host '{host}:{port}'")
try:
s = rpc.AuthSocketStream.connect(
host, port, ipv6=ipv6, keepalive=keepalive,
authorizer=authorizer,
)
return rpyc.utils.factory.connect_stream(s, service, config=config)
except ConnectionRefusedError:
LOG.error(f"Failed to connect to RPC host '{host}:{port}'")
return None
def get_rpc_client(self):
if not self._rpc_client:
self._rpc_client = self._rpyc_connect(
self.ip,
self.port,
authorizer=lambda sock: sock.send(self.magic_word.encode()),
)
return self._rpc_client
def get_stats_dict(self):
cl = self.get_rpc_client()
result = {}
if not cl:
return result
try:
rpc_stats_dict = cl.root.get_stats()
result = json.loads(rpc_stats_dict)
except EOFError:
LOG.error("Lost connection to RPC Host")
self._rpc_client = None
return result
def get_stats(self):
cl = self.get_rpc_client()
result = {}
if not cl:
return result
try:
result = cl.root.get_stats_obj()
except EOFError:
LOG.error("Lost connection to RPC Host")
self._rpc_client = None
return result
def get_packet_track(self):
cl = self.get_rpc_client()
result = None
if not cl:
return result
try:
result = cl.root.get_packet_track()
except EOFError:
LOG.error("Lost connection to RPC Host")
self._rpc_client = None
return result
def get_packet_list(self):
cl = self.get_rpc_client()
result = None
if not cl:
return result
try:
result = cl.root.get_packet_list()
except EOFError:
LOG.error("Lost connection to RPC Host")
self._rpc_client = None
return result
def get_watch_list(self):
cl = self.get_rpc_client()
result = None
if not cl:
return result
try:
result = cl.root.get_watch_list()
except EOFError:
LOG.error("Lost connection to RPC Host")
self._rpc_client = None
return result
def get_seen_list(self):
cl = self.get_rpc_client()
result = None
if not cl:
return result
try:
result = cl.root.get_seen_list()
except EOFError:
LOG.error("Lost connection to RPC Host")
self._rpc_client = None
return result
def get_log_entries(self):
cl = self.get_rpc_client()
result = None
if not cl:
return result
try:
result_str = cl.root.get_log_entries()
result = json.loads(result_str)
except EOFError:
LOG.error("Lost connection to RPC Host")
self._rpc_client = None
return result

99
aprsd/rpc/server.py Normal file
View File

@ -0,0 +1,99 @@
import json
import logging
from oslo_config import cfg
import rpyc
from rpyc.utils.authenticators import AuthenticationError
from rpyc.utils.server import ThreadPoolServer
from aprsd import conf # noqa: F401
from aprsd import packets, stats, threads
from aprsd.threads import log_monitor
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
def magic_word_authenticator(sock):
client_ip = sock.getpeername()[0]
magic = sock.recv(len(CONF.rpc_settings.magic_word)).decode()
if magic != CONF.rpc_settings.magic_word:
LOG.error(
f"wrong magic word passed from {client_ip} "
"'{magic}' != '{CONF.rpc_settings.magic_word}'",
)
raise AuthenticationError(
f"wrong magic word passed in '{magic}'"
f" != '{CONF.rpc_settings.magic_word}'",
)
return sock, None
class APRSDRPCThread(threads.APRSDThread):
def __init__(self):
super().__init__(name="RPCThread")
self.thread = ThreadPoolServer(
APRSDService,
port=CONF.rpc_settings.port,
protocol_config={"allow_public_attrs": True},
authenticator=magic_word_authenticator,
)
def stop(self):
if self.thread:
self.thread.close()
self.thread_stop = True
def loop(self):
# there is no loop as run is blocked
if self.thread and not self.thread_stop:
# This is a blocking call
self.thread.start()
@rpyc.service
class APRSDService(rpyc.Service):
def on_connect(self, conn):
# code that runs when a connection is created
# (to init the service, if needed)
LOG.info("RPC Client Connected")
self._conn = conn
def on_disconnect(self, conn):
# code that runs after the connection has already closed
# (to finalize the service, if needed)
LOG.info("RPC Client Disconnected")
self._conn = None
@rpyc.exposed
def get_stats(self):
stat = stats.APRSDStats()
stats_dict = stat.stats()
return_str = json.dumps(stats_dict, indent=4, sort_keys=True, default=str)
return return_str
@rpyc.exposed
def get_stats_obj(self):
return stats.APRSDStats()
@rpyc.exposed
def get_packet_list(self):
return packets.PacketList()
@rpyc.exposed
def get_packet_track(self):
return packets.PacketTrack()
@rpyc.exposed
def get_watch_list(self):
return packets.WatchList()
@rpyc.exposed
def get_seen_list(self):
return packets.SeenList()
@rpyc.exposed
def get_log_entries(self):
entries = log_monitor.LogEntries().get_all_and_purge()
return json.dumps(entries, default=str)

266
aprsd/stats.py Normal file
View File

@ -0,0 +1,266 @@
import datetime
import logging
import threading
from oslo_config import cfg
import wrapt
import aprsd
from aprsd import packets, plugin, utils
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class APRSDStats:
_instance = None
lock = threading.Lock()
start_time = None
_aprsis_server = None
_aprsis_keepalive = None
_email_thread_last_time = None
_email_tx = 0
_email_rx = 0
_mem_current = 0
_mem_peak = 0
_thread_info = {}
_pkt_cnt = {
"Packet": {
"tx": 0,
"rx": 0,
},
"AckPacket": {
"tx": 0,
"rx": 0,
},
"GPSPacket": {
"tx": 0,
"rx": 0,
},
"StatusPacket": {
"tx": 0,
"rx": 0,
},
"MicEPacket": {
"tx": 0,
"rx": 0,
},
"MessagePacket": {
"tx": 0,
"rx": 0,
},
"WeatherPacket": {
"tx": 0,
"rx": 0,
},
"ObjectPacket": {
"tx": 0,
"rx": 0,
},
}
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
# any init here
cls._instance.start_time = datetime.datetime.now()
cls._instance._aprsis_keepalive = datetime.datetime.now()
return cls._instance
@wrapt.synchronized(lock)
@property
def uptime(self):
return datetime.datetime.now() - self.start_time
@wrapt.synchronized(lock)
@property
def memory(self):
return self._mem_current
@wrapt.synchronized(lock)
def set_memory(self, memory):
self._mem_current = memory
@wrapt.synchronized(lock)
@property
def memory_peak(self):
return self._mem_peak
@wrapt.synchronized(lock)
def set_memory_peak(self, memory):
self._mem_peak = memory
@wrapt.synchronized(lock)
def set_thread_info(self, thread_info):
self._thread_info = thread_info
@wrapt.synchronized(lock)
@property
def thread_info(self):
return self._thread_info
@wrapt.synchronized(lock)
@property
def aprsis_server(self):
return self._aprsis_server
@wrapt.synchronized(lock)
def set_aprsis_server(self, server):
self._aprsis_server = server
@wrapt.synchronized(lock)
@property
def aprsis_keepalive(self):
return self._aprsis_keepalive
@wrapt.synchronized(lock)
def set_aprsis_keepalive(self):
self._aprsis_keepalive = datetime.datetime.now()
def rx(self, packet):
pkt_type = packet.__class__.__name__
if pkt_type not in self._pkt_cnt:
self._pkt_cnt[pkt_type] = {
"tx": 0,
"rx": 0,
}
self._pkt_cnt[pkt_type]["rx"] += 1
def tx(self, packet):
pkt_type = packet.__class__.__name__
if pkt_type not in self._pkt_cnt:
self._pkt_cnt[pkt_type] = {
"tx": 0,
"rx": 0,
}
self._pkt_cnt[pkt_type]["tx"] += 1
@wrapt.synchronized(lock)
@property
def msgs_tracked(self):
return packets.PacketTrack().total_tracked
@wrapt.synchronized(lock)
@property
def email_tx(self):
return self._email_tx
@wrapt.synchronized(lock)
def email_tx_inc(self):
self._email_tx += 1
@wrapt.synchronized(lock)
@property
def email_rx(self):
return self._email_rx
@wrapt.synchronized(lock)
def email_rx_inc(self):
self._email_rx += 1
@wrapt.synchronized(lock)
@property
def email_thread_time(self):
return self._email_thread_last_time
@wrapt.synchronized(lock)
def email_thread_update(self):
self._email_thread_last_time = datetime.datetime.now()
@wrapt.synchronized(lock)
def stats(self):
now = datetime.datetime.now()
if self._email_thread_last_time:
last_update = str(now - self._email_thread_last_time)
else:
last_update = "never"
if self._aprsis_keepalive:
last_aprsis_keepalive = str(now - self._aprsis_keepalive)
else:
last_aprsis_keepalive = "never"
pm = plugin.PluginManager()
plugins = pm.get_plugins()
plugin_stats = {}
if plugins:
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,
}
wl = packets.WatchList()
sl = packets.SeenList()
pl = packets.PacketList()
stats = {
"aprsd": {
"version": aprsd.__version__,
"uptime": utils.strfdelta(self.uptime),
"callsign": CONF.callsign,
"memory_current": int(self.memory),
"memory_current_str": utils.human_size(self.memory),
"memory_peak": int(self.memory_peak),
"memory_peak_str": utils.human_size(self.memory_peak),
"threads": self._thread_info,
"watch_list": wl.get_all(),
"seen_list": sl.get_all(),
},
"aprs-is": {
"server": str(self.aprsis_server),
"callsign": CONF.aprs_network.login,
"last_update": last_aprsis_keepalive,
},
"packets": {
"total_tracked": int(pl.total_tx() + pl.total_rx()),
"total_sent": int(pl.total_tx()),
"total_received": int(pl.total_rx()),
"by_type": self._pkt_cnt,
},
"messages": {
"sent": self._pkt_cnt["MessagePacket"]["tx"],
"received": self._pkt_cnt["MessagePacket"]["tx"],
"ack_sent": self._pkt_cnt["AckPacket"]["tx"],
},
"email": {
"enabled": CONF.email_plugin.enabled,
"sent": int(self._email_tx),
"received": int(self._email_rx),
"thread_last_update": last_update,
},
"plugins": plugin_stats,
}
return stats
def __str__(self):
pl = packets.PacketList()
return (
"Uptime:{} Msgs TX:{} RX:{} "
"ACK: TX:{} RX:{} "
"Email TX:{} RX:{} LastLoop:{} ".format(
self.uptime,
pl.total_tx(),
pl.total_rx(),
self._pkt_cnt["AckPacket"]["tx"],
self._pkt_cnt["AckPacket"]["rx"],
self._email_tx,
self._email_rx,
self._email_thread_last_time,
)
)

View File

@ -1,20 +0,0 @@
from aprsd import plugin
from aprsd.client import stats as client_stats
from aprsd.packets import packet_list, seen_list, tracker, watch_list
from aprsd.plugins import email
from aprsd.stats import app, collector
from aprsd.threads import aprsd
# Create the collector and register all the objects
# that APRSD has that implement the stats protocol
stats_collector = collector.Collector()
stats_collector.register_producer(app.APRSDStats)
stats_collector.register_producer(packet_list.PacketList)
stats_collector.register_producer(watch_list.WatchList)
stats_collector.register_producer(tracker.PacketTrack)
stats_collector.register_producer(plugin.PluginManager)
stats_collector.register_producer(aprsd.APRSDThreadList)
stats_collector.register_producer(email.EmailStats)
stats_collector.register_producer(client_stats.APRSClientStats)
stats_collector.register_producer(seen_list.SeenList)

View File

@ -1,49 +0,0 @@
import datetime
import tracemalloc
from oslo_config import cfg
import aprsd
from aprsd import utils
from aprsd.log import log as aprsd_log
CONF = cfg.CONF
class APRSDStats:
"""The AppStats class is used to collect stats from the application."""
_instance = None
start_time = None
def __new__(cls, *args, **kwargs):
"""Have to override the new method to make this a singleton
instead of using @singletone decorator so the unit tests work.
"""
if not cls._instance:
cls._instance = super().__new__(cls)
cls._instance.start_time = datetime.datetime.now()
return cls._instance
def uptime(self):
return datetime.datetime.now() - self.start_time
def stats(self, serializable=False) -> dict:
current, peak = tracemalloc.get_traced_memory()
uptime = self.uptime()
qsize = aprsd_log.logging_queue.qsize()
if serializable:
uptime = str(uptime)
stats = {
"version": aprsd.__version__,
"uptime": uptime,
"callsign": CONF.callsign,
"memory_current": int(current),
"memory_current_str": utils.human_size(current),
"memory_peak": int(peak),
"memory_peak_str": utils.human_size(peak),
"loging_queue": qsize,
}
return stats

View File

@ -1,38 +0,0 @@
import logging
from typing import Callable, Protocol, runtime_checkable
from aprsd.utils import singleton
LOG = logging.getLogger("APRSD")
@runtime_checkable
class StatsProducer(Protocol):
"""The StatsProducer protocol is used to define the interface for collecting stats."""
def stats(self, serializeable=False) -> dict:
"""provide stats in a dictionary format."""
...
@singleton
class Collector:
"""The Collector class is used to collect stats from multiple StatsProducer instances."""
def __init__(self):
self.producers: list[Callable] = []
def collect(self, serializable=False) -> dict:
stats = {}
for name in self.producers:
cls = name()
if isinstance(cls, StatsProducer):
try:
stats[cls.__class__.__name__] = cls.stats(serializable=serializable).copy()
except Exception as e:
LOG.error(f"Error in producer {name} (stats): {e}")
else:
raise TypeError(f"{cls} is not an instance of StatsProducer")
return stats
def register_producer(self, producer_name: Callable):
self.producers.append(producer_name)

View File

@ -3,9 +3,8 @@ import queue
# Make these available to anyone importing
# aprsd.threads
from .aprsd import APRSDThread, APRSDThreadList # noqa: F401
from .rx import ( # noqa: F401
APRSDDupeRXThread, APRSDProcessPacketThread, APRSDRXThread,
)
from .keep_alive import KeepAliveThread # noqa: F401
from .rx import APRSDRXThread, APRSDDupeRXThread, APRSDProcessPacketThread # noqa: F401
packet_queue = queue.Queue(maxsize=20)

View File

@ -2,7 +2,6 @@ import abc
import datetime
import logging
import threading
from typing import List
import wrapt
@ -10,10 +9,43 @@ import wrapt
LOG = logging.getLogger("APRSD")
class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
"""Base class for all threads in APRSD."""
class APRSDThreadList:
"""Singleton class that keeps track of application wide threads."""
loop_count = 1
_instance = None
threads_list = []
lock = threading.Lock()
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):
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):
"""Iterate over all threads and call stop on them."""
for th in self.threads_list:
LOG.info(f"Stopping Thread {th.name}")
if hasattr(th, "packet"):
LOG.info(F"{th.name} packet {th.packet}")
th.stop()
@wrapt.synchronized(lock)
def __len__(self):
return len(self.threads_list)
class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
def __init__(self, name):
super().__init__(name=name)
@ -47,7 +79,6 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
def run(self):
LOG.debug("Starting")
while not self._should_quit():
self.loop_count += 1
can_loop = self.loop()
self._last_loop = datetime.datetime.now()
if not can_loop:
@ -55,65 +86,3 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
self._cleanup()
APRSDThreadList().remove(self)
LOG.debug("Exiting")
class APRSDThreadList:
"""Singleton class that keeps track of application wide threads."""
_instance = None
threads_list: List[APRSDThread] = []
lock = threading.Lock()
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls.threads_list = []
return cls._instance
def stats(self, serializable=False) -> dict:
stats = {}
for th in self.threads_list:
age = th.loop_age()
if serializable:
age = str(age)
stats[th.name] = {
"name": th.name,
"class": th.__class__.__name__,
"alive": th.is_alive(),
"age": th.loop_age(),
"loop_count": th.loop_count,
}
return stats
@wrapt.synchronized(lock)
def add(self, thread_obj):
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):
"""Iterate over all threads and call stop on them."""
for th in self.threads_list:
LOG.info(f"Stopping Thread {th.name}")
if hasattr(th, "packet"):
LOG.info(F"{th.name} packet {th.packet}")
th.stop()
@wrapt.synchronized(lock)
def info(self):
"""Go through all the threads and collect info about each."""
info = {}
for thread in self.threads_list:
alive = thread.is_alive()
age = thread.loop_age()
key = thread.__class__.__name__
info[key] = {"alive": True if alive else False, "age": age, "name": thread.name}
return info
@wrapt.synchronized(lock)
def __len__(self):
return len(self.threads_list)

View File

@ -5,10 +5,7 @@ import tracemalloc
from oslo_config import cfg
from aprsd import packets, utils
from aprsd.client import client_factory
from aprsd.log import log as aprsd_log
from aprsd.stats import collector
from aprsd import client, packets, stats, utils
from aprsd.threads import APRSDThread, APRSDThreadList
@ -27,70 +24,64 @@ class KeepAliveThread(APRSDThread):
self.max_delta = datetime.timedelta(**max_timeout)
def loop(self):
if self.loop_count % 60 == 0:
stats_json = collector.Collector().collect()
if self.cntr % 60 == 0:
pkt_tracker = packets.PacketTrack()
stats_obj = stats.APRSDStats()
pl = packets.PacketList()
thread_list = APRSDThreadList()
now = datetime.datetime.now()
if "EmailStats" in stats_json:
email_stats = stats_json["EmailStats"]
if email_stats.get("last_check_time"):
email_thread_time = utils.strfdelta(now - email_stats["last_check_time"])
else:
email_thread_time = "N/A"
last_email = stats_obj.email_thread_time
if last_email:
email_thread_time = utils.strfdelta(now - last_email)
else:
email_thread_time = "N/A"
if "APRSClientStats" in stats_json and stats_json["APRSClientStats"].get("transport") == "aprsis":
if stats_json["APRSClientStats"].get("server_keepalive"):
last_msg_time = utils.strfdelta(now - stats_json["APRSClientStats"]["server_keepalive"])
else:
last_msg_time = "N/A"
else:
last_msg_time = "N/A"
last_msg_time = utils.strfdelta(now - stats_obj.aprsis_keepalive)
tracked_packets = stats_json["PacketTrack"]["total_tracked"]
tx_msg = 0
rx_msg = 0
if "PacketList" in stats_json:
msg_packets = stats_json["PacketList"].get("MessagePacket")
if msg_packets:
tx_msg = msg_packets.get("tx", 0)
rx_msg = msg_packets.get("rx", 0)
current, peak = tracemalloc.get_traced_memory()
stats_obj.set_memory(current)
stats_obj.set_memory_peak(peak)
login = CONF.callsign
tracked_packets = len(pkt_tracker)
keepalive = (
"{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} "
"Last:{} Email: {} - RAM Current:{} Peak:{} Threads:{} LoggingQueue:{}"
"Last:{} Email: {} - RAM Current:{} Peak:{} Threads:{}"
).format(
stats_json["APRSDStats"]["callsign"],
stats_json["APRSDStats"]["uptime"],
login,
utils.strfdelta(stats_obj.uptime),
pl.total_rx(),
pl.total_tx(),
tracked_packets,
tx_msg,
rx_msg,
stats_obj._pkt_cnt["MessagePacket"]["tx"],
stats_obj._pkt_cnt["MessagePacket"]["rx"],
last_msg_time,
email_thread_time,
stats_json["APRSDStats"]["memory_current_str"],
stats_json["APRSDStats"]["memory_peak_str"],
utils.human_size(current),
utils.human_size(peak),
len(thread_list),
aprsd_log.logging_queue.qsize(),
)
LOG.info(keepalive)
if "APRSDThreadList" in stats_json:
thread_list = stats_json["APRSDThreadList"]
for thread_name in thread_list:
thread = thread_list[thread_name]
alive = thread["alive"]
age = thread["age"]
key = thread["name"]
if not alive:
LOG.error(f"Thread {thread}")
LOG.info(f"{key: <15} Alive? {str(alive): <5} {str(age): <20}")
thread_out = []
thread_info = {}
for thread in thread_list.threads_list:
alive = thread.is_alive()
age = thread.loop_age()
key = thread.__class__.__name__
thread_out.append(f"{key}:{alive}:{age}")
if key not in thread_info:
thread_info[key] = {}
thread_info[key]["alive"] = alive
thread_info[key]["age"] = age
if not alive:
LOG.error(f"Thread {thread}")
LOG.info(",".join(thread_out))
stats_obj.set_thread_info(thread_info)
# check the APRS connection
cl = client_factory.create()
cl = client.factory.create()
# Reset the connection if it's dead and this isn't our
# First time through the loop.
# The first time through the loop can happen at startup where
@ -98,19 +89,19 @@ class KeepAliveThread(APRSDThread):
# to make it's connection the first time.
if not cl.is_alive() and self.cntr > 0:
LOG.error(f"{cl.__class__.__name__} is not alive!!! Resetting")
client_factory.create().reset()
# else:
# # 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():
# LOG.warning(f"Resetting connection to APRS-IS {delta}")
# client.factory.create().reset()
client.factory.create().reset()
else:
# 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():
LOG.warning(f"Resetting connection to APRS-IS {delta}")
client.factory.create().reset()
# Check version every day
delta = now - self.checker_time
@ -119,6 +110,6 @@ class KeepAliveThread(APRSDThread):
level, msg = utils._check_version()
if level:
LOG.warning(msg)
self.cntr += 1
self.cntr += 1
time.sleep(1)
return True

View File

@ -1,54 +1,25 @@
import datetime
import logging
import threading
from oslo_config import cfg
import requests
import wrapt
from aprsd import threads
from aprsd.log import log
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
def send_log_entries(force=False):
"""Send all of the log entries to the web interface."""
if CONF.admin.web_enabled:
if force or LogEntries().is_purge_ready():
entries = LogEntries().get_all_and_purge()
if entries:
try:
requests.post(
f"http://{CONF.admin.web_ip}:{CONF.admin.web_port}/log_entries",
json=entries,
auth=(CONF.admin.user, CONF.admin.password),
)
except Exception:
LOG.warning(f"Failed to send log entries. len={len(entries)}")
class LogEntries:
entries = []
lock = threading.Lock()
_instance = None
last_purge = datetime.datetime.now()
max_delta = datetime.timedelta(
hours=0.0, minutes=0, seconds=2,
)
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def stats(self) -> dict:
return {
"log_entries": self.entries,
}
@wrapt.synchronized(lock)
def add(self, entry):
self.entries.append(entry)
@ -57,18 +28,8 @@ class LogEntries:
def get_all_and_purge(self):
entries = self.entries.copy()
self.entries = []
self.last_purge = datetime.datetime.now()
return entries
def is_purge_ready(self):
now = datetime.datetime.now()
if (
now - self.last_purge > self.max_delta
and len(self.entries) > 1
):
return True
return False
@wrapt.synchronized(lock)
def __len__(self):
return len(self.entries)
@ -79,10 +40,6 @@ class LogMonitorThread(threads.APRSDThread):
def __init__(self):
super().__init__("LogMonitorThread")
def stop(self):
send_log_entries(force=True)
super().stop()
def loop(self):
try:
record = log.logging_queue.get(block=True, timeout=2)
@ -97,7 +54,6 @@ class LogMonitorThread(threads.APRSDThread):
# Just ignore thi
pass
send_log_entries()
return True
def json_record(self, record):

View File

@ -6,10 +6,7 @@ import time
import aprslib
from oslo_config import cfg
from aprsd import packets, plugin
from aprsd.client import client_factory
from aprsd.packets import collector
from aprsd.packets import log as packet_log
from aprsd import client, packets, plugin
from aprsd.threads import APRSDThread, tx
@ -19,20 +16,15 @@ LOG = logging.getLogger("APRSD")
class APRSDRXThread(APRSDThread):
def __init__(self, packet_queue):
super().__init__("RX_PKT")
super().__init__("RX_MSG")
self.packet_queue = packet_queue
self._client = client_factory.create()
self._client = client.factory.create()
def stop(self):
self.thread_stop = True
if self._client:
self._client.stop()
client.factory.create().client.stop()
def loop(self):
if not self._client:
self._client = client_factory.create()
time.sleep(1)
return True
# setup the consumer of messages and block until a messages
try:
# This will register a packet consumer with aprslib
@ -44,32 +36,23 @@ class APRSDRXThread(APRSDThread):
# and the aprslib developer didn't want to allow a PR to add
# kwargs. :(
# https://github.com/rossengeorgiev/aprs-python/pull/56
self._client.consumer(
self._process_packet, raw=False, blocking=False,
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()
time.sleep(5)
except Exception:
# LOG.exception(ex)
LOG.error("Resetting connection and trying again.")
self._client.reset()
time.sleep(5)
# Continue to loop
return True
def _process_packet(self, *args, **kwargs):
"""Intermediate callback so we can update the keepalive time."""
# Now call the 'real' packet processing for a RX'x packet
self.process_packet(*args, **kwargs)
@abc.abstractmethod
def process_packet(self, *args, **kwargs):
pass
@ -97,8 +80,7 @@ class APRSDDupeRXThread(APRSDRXThread):
"""
packet = self._client.decode_packet(*args, **kwargs)
# LOG.debug(raw)
packet_log.log(packet)
pkt_list = packets.PacketList()
packet.log(header="RX")
if isinstance(packet, packets.AckPacket):
# We don't need to drop AckPackets, those should be
@ -109,6 +91,7 @@ class APRSDDupeRXThread(APRSDRXThread):
# For RF based APRS Clients we can get duplicate packets
# So we need to track them and not process the dupes.
found = False
pkt_list = packets.PacketList()
try:
# Find the packet in the list of already seen packets
# Based on the packet.key
@ -117,11 +100,14 @@ class APRSDDupeRXThread(APRSDRXThread):
found = False
if not found:
# We haven't seen this packet before, so we process it.
collector.PacketCollector().rx(packet)
# If we are in the process of already ack'ing
# a packet, we should drop the packet
# because it's a dupe within the time that
# we send the 3 acks for the packet.
pkt_list.rx(packet)
self.packet_queue.put(packet)
elif packet.timestamp - found.timestamp < CONF.packet_dupe_timeout:
# If the packet came in within N seconds of the
# If the packet came in within 60 seconds of the
# Last time seeing the packet, then we drop it as a dupe.
LOG.warning(f"Packet {packet.from_call}:{packet.msgNo} already tracked, dropping.")
else:
@ -129,7 +115,7 @@ class APRSDDupeRXThread(APRSDRXThread):
f"Packet {packet.from_call}:{packet.msgNo} already tracked "
f"but older than {CONF.packet_dupe_timeout} seconds. processing.",
)
collector.PacketCollector().rx(packet)
pkt_list.rx(packet)
self.packet_queue.put(packet)
@ -151,24 +137,21 @@ class APRSDProcessPacketThread(APRSDThread):
def __init__(self, packet_queue):
self.packet_queue = packet_queue
super().__init__("ProcessPKT")
self._loop_cnt = 1
def process_ack_packet(self, packet):
"""We got an ack for a message, no need to resend it."""
ack_num = packet.msgNo
LOG.debug(f"Got ack for message {ack_num}")
collector.PacketCollector().rx(packet)
def process_piggyback_ack(self, packet):
"""We got an ack embedded in a packet."""
ack_num = packet.ackMsgNo
LOG.debug(f"Got PiggyBackAck for message {ack_num}")
collector.PacketCollector().rx(packet)
LOG.info(f"Got ack for message {ack_num}")
pkt_tracker = packets.PacketTrack()
pkt_tracker.remove(ack_num)
def process_reject_packet(self, packet):
"""We got a reject message for a packet. Stop sending the message."""
ack_num = packet.msgNo
LOG.debug(f"Got REJECT for message {ack_num}")
collector.PacketCollector().rx(packet)
LOG.info(f"Got REJECT for message {ack_num}")
pkt_tracker = packets.PacketTrack()
pkt_tracker.remove(ack_num)
def loop(self):
try:
@ -177,11 +160,12 @@ class APRSDProcessPacketThread(APRSDThread):
self.process_packet(packet)
except queue.Empty:
pass
self._loop_cnt += 1
return True
def process_packet(self, packet):
"""Process a packet received from aprs-is server."""
LOG.debug(f"ProcessPKT-LOOP {self.loop_count}")
LOG.debug(f"ProcessPKT-LOOP {self._loop_cnt}")
our_call = CONF.callsign.lower()
from_call = packet.from_call
@ -204,10 +188,6 @@ class APRSDProcessPacketThread(APRSDThread):
):
self.process_reject_packet(packet)
else:
if hasattr(packet, "ackMsgNo") and packet.ackMsgNo:
# we got an ack embedded in this packet
# we need to handle the ack
self.process_piggyback_ack(packet)
# Only ack messages that were sent directly to us
if isinstance(packet, packets.MessagePacket):
if to_call and to_call.lower() == our_call:

View File

@ -1,44 +0,0 @@
import logging
import threading
import time
from oslo_config import cfg
import wrapt
from aprsd.stats import collector
from aprsd.threads import APRSDThread
from aprsd.utils import objectstore
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
class StatsStore(objectstore.ObjectStoreMixin):
"""Container to save the stats from the collector."""
lock = threading.Lock()
data = {}
@wrapt.synchronized(lock)
def add(self, stats: dict):
self.data = stats
class APRSDStatsStoreThread(APRSDThread):
"""Save APRSD Stats to disk periodically."""
# how often in seconds to write the file
save_interval = 10
def __init__(self):
super().__init__("StatsStore")
def loop(self):
if self.loop_count % self.save_interval == 0:
stats = collector.Collector().collect()
ss = StatsStore()
ss.add(stats)
ss.save()
time.sleep(1)
return True

View File

@ -1,5 +1,4 @@
import logging
import threading
import time
from oslo_config import cfg
@ -7,14 +6,11 @@ from rush import quota, throttle
from rush.contrib import decorator
from rush.limiters import periodic
from rush.stores import dictionary
import wrapt
from aprsd import client
from aprsd import conf # noqa
from aprsd import threads as aprsd_threads
from aprsd.client import client_factory
from aprsd.packets import collector, core
from aprsd.packets import log as packet_log
from aprsd.packets import tracker
from aprsd.packets import core, tracker
CONF = cfg.CONF
@ -39,19 +35,14 @@ ack_t = throttle.Throttle(
msg_throttle_decorator = decorator.ThrottleDecorator(throttle=msg_t)
ack_throttle_decorator = decorator.ThrottleDecorator(throttle=ack_t)
s_lock = threading.Lock()
@wrapt.synchronized(s_lock)
@msg_throttle_decorator.sleep_and_retry
def send(packet: core.Packet, direct=False, aprs_client=None):
"""Send a packet either in a thread or directly to the client."""
# prepare the packet for sending.
# This constructs the packet.raw
packet.prepare()
# Have to call the collector to track the packet
# After prepare, as prepare assigns the msgNo
collector.PacketCollector().tx(packet)
if isinstance(packet, core.AckPacket):
_send_ack(packet, direct=direct, aprs_client=aprs_client)
else:
@ -80,15 +71,11 @@ def _send_direct(packet, aprs_client=None):
if aprs_client:
cl = aprs_client
else:
cl = client_factory.create()
cl = client.factory.create()
packet.update_timestamp()
packet_log.log(packet, tx=True)
try:
cl.send(packet)
except Exception as e:
LOG.error(f"Failed to send packet: {packet}")
LOG.error(e)
packet.log(header="TX")
cl.send(packet)
class SendPacketThread(aprsd_threads.APRSDThread):
@ -96,7 +83,10 @@ class SendPacketThread(aprsd_threads.APRSDThread):
def __init__(self, packet):
self.packet = packet
super().__init__(f"TX-{packet.to_call}-{self.packet.msgNo}")
name = self.packet.raw[:5]
super().__init__(f"TXPKT-{self.packet.msgNo}-{name}")
pkt_tracker = tracker.PacketTrack()
pkt_tracker.add(packet)
def loop(self):
"""Loop until a message is acked or it gets delayed.
@ -122,7 +112,7 @@ class SendPacketThread(aprsd_threads.APRSDThread):
return False
else:
send_now = False
if packet.send_count >= packet.retry_count:
if packet.send_count == packet.retry_count:
# we reached the send limit, don't send again
# TODO(hemna) - Need to put this in a delayed queue?
LOG.info(
@ -131,7 +121,8 @@ class SendPacketThread(aprsd_threads.APRSDThread):
"Message Send Complete. Max attempts reached"
f" {packet.retry_count}",
)
pkt_tracker.remove(packet.msgNo)
if not packet.allow_delay:
pkt_tracker.remove(packet.msgNo)
return False
# Message is still outstanding and needs to be acked.
@ -150,7 +141,7 @@ class SendPacketThread(aprsd_threads.APRSDThread):
# no attempt time, so lets send it, and start
# tracking the time.
packet.last_send_time = int(round(time.time()))
_send_direct(packet)
send(packet, direct=True)
packet.send_count += 1
time.sleep(1)
@ -161,24 +152,22 @@ class SendPacketThread(aprsd_threads.APRSDThread):
class SendAckThread(aprsd_threads.APRSDThread):
loop_count: int = 1
max_retries = 3
def __init__(self, packet):
self.packet = packet
super().__init__(f"TXAck-{packet.to_call}-{self.packet.msgNo}")
self.max_retries = CONF.default_ack_send_count
super().__init__(f"SendAck-{self.packet.msgNo}")
def loop(self):
"""Separate thread to send acks with retries."""
send_now = False
if self.packet.send_count == self.max_retries:
if self.packet.send_count == self.packet.retry_count:
# we reached the send limit, don't send again
# TODO(hemna) - Need to put this in a delayed queue?
LOG.debug(
LOG.info(
f"{self.packet.__class__.__name__}"
f"({self.packet.msgNo}) "
"Send Complete. Max attempts reached"
f" {self.max_retries}",
f" {self.packet.retry_count}",
)
return False
@ -199,7 +188,7 @@ class SendAckThread(aprsd_threads.APRSDThread):
send_now = True
if send_now:
_send_direct(self.packet)
send(self.packet, direct=True)
self.packet.send_count += 1
self.packet.last_send_time = int(round(time.time()))
@ -241,15 +230,7 @@ class BeaconSendThread(aprsd_threads.APRSDThread):
comment="APRSD GPS Beacon",
symbol=CONF.beacon_symbol,
)
try:
# Only send it once
pkt.retry_count = 1
send(pkt, direct=True)
except Exception as e:
LOG.error(f"Failed to send beacon: {e}")
client_factory.create().reset()
time.sleep(5)
send(pkt, direct=True)
self._loop_cnt += 1
time.sleep(1)
return True

View File

@ -1,7 +1,6 @@
"""Utilities and helper functions."""
import errno
import functools
import os
import re
import sys
@ -23,17 +22,6 @@ else:
from collections.abc import MutableMapping
def singleton(cls):
"""Make a class a Singleton class (only one instance)"""
@functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if wrapper_singleton.instance is None:
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
wrapper_singleton.instance = None
return wrapper_singleton
def env(*vars, **kwargs):
"""This returns the first environment variable set.
if none are non-empty, defaults to '' or keyword arg default

View File

@ -1,13 +1,9 @@
from multiprocessing import RawValue
import random
import threading
import wrapt
MAX_PACKET_ID = 9999
class PacketCounter:
"""
Global Packet id counter class.
@ -21,18 +17,19 @@ class PacketCounter:
"""
_instance = None
max_count = 9999
lock = threading.Lock()
def __new__(cls, *args, **kwargs):
"""Make this a singleton class."""
if cls._instance is None:
cls._instance = super().__new__(cls, *args, **kwargs)
cls._instance.val = RawValue("i", random.randint(1, MAX_PACKET_ID))
cls._instance.val = RawValue("i", 1)
return cls._instance
@wrapt.synchronized(lock)
def increment(self):
if self.val.value == MAX_PACKET_ID:
if self.val.value == self.max_count:
self.val.value = 1
else:
self.val.value += 1

View File

@ -3,8 +3,6 @@ import decimal
import json
import sys
from aprsd.packets import core
class EnhancedJSONEncoder(json.JSONEncoder):
def default(self, obj):
@ -44,24 +42,6 @@ class EnhancedJSONEncoder(json.JSONEncoder):
return super().default(obj)
class SimpleJSONEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime.datetime):
return obj.isoformat()
elif isinstance(obj, datetime.date):
return str(obj)
elif isinstance(obj, datetime.time):
return str(obj)
elif isinstance(obj, datetime.timedelta):
return str(obj)
elif isinstance(obj, decimal.Decimal):
return str(obj)
elif isinstance(obj, core.Packet):
return obj.to_dict()
else:
return super().default(obj)
class EnhancedJSONDecoder(json.JSONDecoder):
def __init__(self, *args, **kwargs):

View File

@ -2,7 +2,6 @@ import logging
import os
import pathlib
import pickle
import threading
from oslo_config import cfg
@ -26,28 +25,19 @@ class ObjectStoreMixin:
aprsd server -f (flush) will wipe all saved objects.
"""
def __init__(self):
self.lock = threading.RLock()
def __len__(self):
with self.lock:
return len(self.data)
return len(self.data)
def __iter__(self):
with self.lock:
return iter(self.data)
return iter(self.data)
def get_all(self):
with self.lock:
return self.data
def get(self, key):
def get(self, id):
with self.lock:
return self.data.get(key)
def copy(self):
with self.lock:
return self.data.copy()
return self.data[id]
def _init_store(self):
if not CONF.enable_save:
@ -68,26 +58,31 @@ class ObjectStoreMixin:
self.__class__.__name__.lower(),
)
def _dump(self):
dump = {}
with self.lock:
for key in self.data.keys():
dump[key] = self.data[key]
return dump
def save(self):
"""Save any queued to disk?"""
if not CONF.enable_save:
return
self._init_store()
save_filename = self._save_filename()
if len(self) > 0:
LOG.info(
f"{self.__class__.__name__}::Saving"
f" {len(self)} entries to disk at "
f"{save_filename}",
f" {len(self)} entries to disk at"
f"{CONF.save_location}",
)
with self.lock:
with open(save_filename, "wb+") as fp:
pickle.dump(self.data, fp)
with open(self._save_filename(), "wb+") as fp:
pickle.dump(self._dump(), fp)
else:
LOG.debug(
"{} Nothing to save, flushing old save file '{}'".format(
self.__class__.__name__,
save_filename,
self._save_filename(),
),
)
self.flush()

View File

@ -1,4 +1,189 @@
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+json+json5+log&plugins=show-language+toolbar */
code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}
div.code-toolbar{position:relative}div.code-toolbar>.toolbar{position:absolute;z-index:10;top:.3em;right:.2em;transition:opacity .3s ease-in-out;opacity:0}div.code-toolbar:hover>.toolbar{opacity:1}div.code-toolbar:focus-within>.toolbar{opacity:1}div.code-toolbar>.toolbar>.toolbar-item{display:inline-block}div.code-toolbar>.toolbar>.toolbar-item>a{cursor:pointer}div.code-toolbar>.toolbar>.toolbar-item>button{background:0 0;border:0;color:inherit;font:inherit;line-height:normal;overflow:visible;padding:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}div.code-toolbar>.toolbar>.toolbar-item>a,div.code-toolbar>.toolbar>.toolbar-item>button,div.code-toolbar>.toolbar>.toolbar-item>span{color:#bbb;font-size:.8em;padding:0 .5em;background:#f5f2f0;background:rgba(224,224,224,.2);box-shadow:0 2px 0 0 rgba(0,0,0,.2);border-radius:.5em}div.code-toolbar>.toolbar>.toolbar-item>a:focus,div.code-toolbar>.toolbar>.toolbar-item>a:hover,div.code-toolbar>.toolbar>.toolbar-item>button:focus,div.code-toolbar>.toolbar>.toolbar-item>button:hover,div.code-toolbar>.toolbar>.toolbar-item>span:focus,div.code-toolbar>.toolbar>.toolbar-item>span:hover{color:inherit;text-decoration:none}
/* PrismJS 1.24.1
https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+log&plugins=show-language+toolbar */
/**
* prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML
* Based on https://github.com/chriskempson/tomorrow-theme
* @author Rose Pritchard
*/
code[class*="language-"],
pre[class*="language-"] {
color: #ccc;
background: none;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #2d2d2d;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #999;
}
.token.punctuation {
color: #ccc;
}
.token.tag,
.token.attr-name,
.token.namespace,
.token.deleted {
color: #e2777a;
}
.token.function-name {
color: #6196cc;
}
.token.boolean,
.token.number,
.token.function {
color: #f08d49;
}
.token.property,
.token.class-name,
.token.constant,
.token.symbol {
color: #f8c555;
}
.token.selector,
.token.important,
.token.atrule,
.token.keyword,
.token.builtin {
color: #cc99cd;
}
.token.string,
.token.char,
.token.attr-value,
.token.regex,
.token.variable {
color: #7ec699;
}
.token.operator,
.token.entity,
.token.url {
color: #67cdcc;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.inserted {
color: green;
}
div.code-toolbar {
position: relative;
}
div.code-toolbar > .toolbar {
position: absolute;
top: .3em;
right: .2em;
transition: opacity 0.3s ease-in-out;
opacity: 0;
}
div.code-toolbar:hover > .toolbar {
opacity: 1;
}
/* Separate line b/c rules are thrown out if selector is invalid.
IE11 and old Edge versions don't support :focus-within. */
div.code-toolbar:focus-within > .toolbar {
opacity: 1;
}
div.code-toolbar > .toolbar > .toolbar-item {
display: inline-block;
}
div.code-toolbar > .toolbar > .toolbar-item > a {
cursor: pointer;
}
div.code-toolbar > .toolbar > .toolbar-item > button {
background: none;
border: 0;
color: inherit;
font: inherit;
line-height: normal;
overflow: visible;
padding: 0;
-webkit-user-select: none; /* for button */
-moz-user-select: none;
-ms-user-select: none;
}
div.code-toolbar > .toolbar > .toolbar-item > a,
div.code-toolbar > .toolbar > .toolbar-item > button,
div.code-toolbar > .toolbar > .toolbar-item > span {
color: #bbb;
font-size: .8em;
padding: 0 .5em;
background: #f5f2f0;
background: rgba(224, 224, 224, 0.2);
box-shadow: 0 2px 0 0 rgba(0,0,0,0.2);
border-radius: .5em;
}
div.code-toolbar > .toolbar > .toolbar-item > a:hover,
div.code-toolbar > .toolbar > .toolbar-item > a:focus,
div.code-toolbar > .toolbar > .toolbar-item > button:hover,
div.code-toolbar > .toolbar > .toolbar-item > button:focus,
div.code-toolbar > .toolbar > .toolbar-item > span:hover,
div.code-toolbar > .toolbar > .toolbar-item > span:focus {
color: inherit;
text-decoration: none;
}

View File

@ -219,17 +219,15 @@ function updateQuadData(chart, label, first, second, third, fourth) {
}
function update_stats( data ) {
our_callsign = data["APRSDStats"]["callsign"];
$("#version").text( data["APRSDStats"]["version"] );
our_callsign = data["stats"]["aprsd"]["callsign"];
$("#version").text( data["stats"]["aprsd"]["version"] );
$("#aprs_connection").html( data["aprs_connection"] );
$("#uptime").text( "uptime: " + data["APRSDStats"]["uptime"] );
$("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] );
const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json');
$("#jsonstats").html(html_pretty);
short_time = data["time"].split(/\s(.+)/)[1];
packet_list = data["PacketList"]["packets"];
updateDualData(packets_chart, short_time, data["PacketList"]["sent"], data["PacketList"]["received"]);
updateQuadData(message_chart, short_time, packet_list["MessagePacket"]["tx"], packet_list["MessagePacket"]["rx"],
packet_list["AckPacket"]["tx"], packet_list["AckPacket"]["rx"]);
updateDualData(email_chart, short_time, data["EmailStats"]["sent"], data["EmailStats"]["recieved"]);
updateDualData(memory_chart, short_time, data["APRSDStats"]["memory_peak"], data["APRSDStats"]["memory_current"]);
updateDualData(packets_chart, short_time, data["stats"]["packets"]["sent"], data["stats"]["packets"]["received"]);
updateQuadData(message_chart, short_time, data["stats"]["messages"]["sent"], data["stats"]["messages"]["received"], data["stats"]["messages"]["ack_sent"], data["stats"]["messages"]["ack_recieved"]);
updateDualData(email_chart, short_time, data["stats"]["email"]["sent"], data["stats"]["email"]["recieved"]);
updateDualData(memory_chart, short_time, data["stats"]["aprsd"]["memory_peak"], data["stats"]["aprsd"]["memory_current"]);
}

View File

@ -8,8 +8,6 @@ var packet_types_data = {};
var mem_current = []
var mem_peak = []
var thread_current = []
function start_charts() {
console.log("start_charts() called");
@ -19,7 +17,6 @@ function start_charts() {
create_messages_chart();
create_ack_chart();
create_memory_chart();
create_thread_chart();
}
@ -261,49 +258,6 @@ function create_memory_chart() {
memory_chart.setOption(option);
}
function create_thread_chart() {
thread_canvas = document.getElementById('threadChart');
thread_chart = echarts.init(thread_canvas);
// Specify the configuration items and data for the chart
var option = {
title: {
text: 'Active Threads'
},
legend: {},
tooltip: {
trigger: 'axis'
},
toolbox: {
show: true,
feature: {
mark : {show: true},
dataView : {show: true, readOnly: false},
magicType : {show: true, type: ['line', 'bar']},
restore : {show: true},
saveAsImage : {show: true}
}
},
calculable: true,
xAxis: { type: 'time' },
yAxis: { },
series: [
{
name: 'current',
type: 'line',
smooth: true,
color: 'red',
encode: {
x: 'timestamp',
y: 'current' // refer sensor 1 value
}
}
]
};
thread_chart.setOption(option);
}
@ -373,6 +327,7 @@ function updatePacketTypesChart() {
option = {
series: series
}
console.log(option)
packet_types_chart.setOption(option);
}
@ -417,21 +372,6 @@ function updateMemChart(time, current, peak) {
memory_chart.setOption(option);
}
function updateThreadChart(time, threads) {
keys = Object.keys(threads);
thread_count = keys.length;
thread_current.push([time, thread_count]);
option = {
series: [
{
name: 'current',
data: thread_current,
}
]
}
thread_chart.setOption(option);
}
function updateMessagesChart() {
updateTypeChart(message_chart, "MessagePacket")
}
@ -441,24 +381,22 @@ function updateAcksChart() {
}
function update_stats( data ) {
console.log("update_stats() echarts.js called")
stats = data["stats"];
our_callsign = stats["APRSDStats"]["callsign"];
$("#version").text( stats["APRSDStats"]["version"] );
$("#aprs_connection").html( stats["aprs_connection"] );
$("#uptime").text( "uptime: " + stats["APRSDStats"]["uptime"] );
console.log(data);
our_callsign = data["stats"]["aprsd"]["callsign"];
$("#version").text( data["stats"]["aprsd"]["version"] );
$("#aprs_connection").html( data["aprs_connection"] );
$("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] );
const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json');
$("#jsonstats").html(html_pretty);
t = Date.parse(data["time"]);
ts = new Date(t);
updatePacketData(packets_chart, ts, stats["PacketList"]["tx"], stats["PacketList"]["rx"]);
updatePacketTypesData(ts, stats["PacketList"]["types"]);
updatePacketData(packets_chart, ts, data["stats"]["packets"]["sent"], data["stats"]["packets"]["received"]);
updatePacketTypesData(ts, data["stats"]["packets"]["types"]);
updatePacketTypesChart();
updateMessagesChart();
updateAcksChart();
updateMemChart(ts, stats["APRSDStats"]["memory_current"], stats["APRSDStats"]["memory_peak"]);
updateThreadChart(ts, stats["APRSDThreadList"]);
updateMemChart(ts, data["stats"]["aprsd"]["memory_current"], data["stats"]["aprsd"]["memory_peak"]);
//updateQuadData(message_chart, short_time, data["stats"]["messages"]["sent"], data["stats"]["messages"]["received"], data["stats"]["messages"]["ack_sent"], data["stats"]["messages"]["ack_recieved"]);
//updateDualData(email_chart, short_time, data["stats"]["email"]["sent"], data["stats"]["email"]["recieved"]);
//updateDualData(memory_chart, short_time, data["stats"]["aprsd"]["memory_peak"], data["stats"]["aprsd"]["memory_current"]);

View File

@ -24,15 +24,11 @@ function ord(str){return str.charCodeAt(0);}
function update_watchlist( data ) {
// Update the watch list
stats = data["stats"];
if (stats.hasOwnProperty("WatchList") == false) {
return
}
// Update the watch list
var watchdiv = $("#watchDiv");
var html_str = '<table class="ui celled striped table"><thead><tr><th>HAM Callsign</th><th>Age since last seen by APRSD</th></tr></thead><tbody>'
watchdiv.html('')
jQuery.each(stats["WatchList"], function(i, val) {
jQuery.each(data["stats"]["aprsd"]["watch_list"], function(i, val) {
html_str += '<tr><td class="collapsing"><img id="callsign_'+i+'" class="aprsd_1"></img>' + i + '</td><td>' + val["last"] + '</td></tr>'
});
html_str += "</tbody></table>";
@ -64,16 +60,12 @@ function update_watchlist_from_packet(callsign, val) {
}
function update_seenlist( data ) {
stats = data["stats"];
if (stats.hasOwnProperty("SeenList") == false) {
return
}
var seendiv = $("#seenDiv");
var html_str = '<table class="ui celled striped table">'
html_str += '<thead><tr><th>HAM Callsign</th><th>Age since last seen by APRSD</th>'
html_str += '<th>Number of packets RX</th></tr></thead><tbody>'
seendiv.html('')
var seen_list = stats["SeenList"]
var seen_list = data["stats"]["aprsd"]["seen_list"]
var len = Object.keys(seen_list).length
$('#seen_count').html(len)
jQuery.each(seen_list, function(i, val) {
@ -87,10 +79,6 @@ function update_seenlist( data ) {
}
function update_plugins( data ) {
stats = data["stats"];
if (stats.hasOwnProperty("PluginManager") == false) {
return
}
var plugindiv = $("#pluginDiv");
var html_str = '<table class="ui celled striped table"><thead><tr>'
html_str += '<th>Plugin Name</th><th>Plugin Enabled?</th>'
@ -99,7 +87,7 @@ function update_plugins( data ) {
html_str += '</tr></thead><tbody>'
plugindiv.html('')
var plugins = stats["PluginManager"];
var plugins = data["stats"]["plugins"];
var keys = Object.keys(plugins);
keys.sort();
for (var i=0; i<keys.length; i++) { // now lets iterate in sort order
@ -113,42 +101,14 @@ function update_plugins( data ) {
plugindiv.append(html_str);
}
function update_threads( data ) {
stats = data["stats"];
if (stats.hasOwnProperty("APRSDThreadList") == false) {
return
}
var threadsdiv = $("#threadsDiv");
var countdiv = $("#thread_count");
var html_str = '<table class="ui celled striped table"><thead><tr>'
html_str += '<th>Thread Name</th><th>Alive?</th>'
html_str += '<th>Age</th><th>Loop Count</th>'
html_str += '</tr></thead><tbody>'
threadsdiv.html('')
var threads = stats["APRSDThreadList"];
var keys = Object.keys(threads);
countdiv.html(keys.length);
keys.sort();
for (var i=0; i<keys.length; i++) { // now lets iterate in sort order
var key = keys[i];
var val = threads[key];
html_str += '<tr><td class="collapsing">' + key + '</td>';
html_str += '<td>' + val["alive"] + '</td><td>' + val["age"] + '</td>';
html_str += '<td>' + val["loop_count"] + '</td></tr>';
}
html_str += "</tbody></table>";
threadsdiv.append(html_str);
}
function update_packets( data ) {
var packetsdiv = $("#packetsDiv");
//nuke the contents first, then add to it.
if (size_dict(packet_list) == 0 && size_dict(data) > 0) {
packetsdiv.html('')
}
jQuery.each(data.packets, function(i, val) {
pkt = val;
jQuery.each(data, function(i, val) {
pkt = JSON.parse(val);
update_watchlist_from_packet(pkt['from_call'], pkt);
if ( packet_list.hasOwnProperty(pkt['timestamp']) == false ) {
@ -207,7 +167,6 @@ function start_update() {
update_watchlist(data);
update_seenlist(data);
update_plugins(data);
update_threads(data);
},
complete: function() {
setTimeout(statsworker, 10000);

File diff suppressed because one or more lines are too long

View File

@ -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;
}

View File

@ -0,0 +1,158 @@
/**
* jQuery json-viewer
* @author: Alexandre Bodelot <alexandre.bodelot@gmail.com>
* @link: https://github.com/abodelot/jquery.json-viewer
*/
(function($) {
/**
* Check if arg is either an array with at least 1 element, or a dict with at least 1 key
* @return boolean
*/
function isCollapsable(arg) {
return arg instanceof Object && Object.keys(arg).length > 0;
}
/**
* Check if a string represents a valid url
* @return boolean
*/
function isUrl(string) {
var urlRegexp = /^(https?:\/\/|ftps?:\/\/)?([a-z0-9%-]+\.){1,}([a-z0-9-]+)?(:(\d{1,5}))?(\/([a-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+)?)?$/i;
return urlRegexp.test(string);
}
/**
* Transform a json object into html representation
* @return string
*/
function json2html(json, options) {
var html = '';
if (typeof json === 'string') {
// Escape tags and quotes
json = json
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;');
if (options.withLinks && isUrl(json)) {
html += '<a href="' + json + '" class="json-string" target="_blank">' + json + '</a>';
} else {
// Escape double quotes in the rendered non-URL string.
json = json.replace(/&quot;/g, '\\&quot;');
html += '<span class="json-string">"' + json + '"</span>';
}
} else if (typeof json === 'number') {
html += '<span class="json-literal">' + json + '</span>';
} else if (typeof json === 'boolean') {
html += '<span class="json-literal">' + json + '</span>';
} else if (json === null) {
html += '<span class="json-literal">null</span>';
} else if (json instanceof Array) {
if (json.length > 0) {
html += '[<ol class="json-array">';
for (var i = 0; i < json.length; ++i) {
html += '<li>';
// Add toggle button if item is collapsable
if (isCollapsable(json[i])) {
html += '<a href class="json-toggle"></a>';
}
html += json2html(json[i], options);
// Add comma if item is not last
if (i < json.length - 1) {
html += ',';
}
html += '</li>';
}
html += '</ol>]';
} else {
html += '[]';
}
} else if (typeof json === 'object') {
var keyCount = Object.keys(json).length;
if (keyCount > 0) {
html += '{<ul class="json-dict">';
for (var key in json) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
html += '<li>';
var keyRepr = options.withQuotes ?
'<span class="json-string">"' + key + '"</span>' : key;
// Add toggle button if item is collapsable
if (isCollapsable(json[key])) {
html += '<a href class="json-toggle">' + keyRepr + '</a>';
} else {
html += keyRepr;
}
html += ': ' + json2html(json[key], options);
// Add comma if item is not last
if (--keyCount > 0) {
html += ',';
}
html += '</li>';
}
}
html += '</ul>}';
} else {
html += '{}';
}
}
return html;
}
/**
* jQuery plugin method
* @param json: a javascript object
* @param options: an optional options hash
*/
$.fn.jsonViewer = function(json, options) {
// Merge user options with default options
options = Object.assign({}, {
collapsed: false,
rootCollapsable: true,
withQuotes: false,
withLinks: true
}, options);
// jQuery chaining
return this.each(function() {
// Transform to HTML
var html = json2html(json, options);
if (options.rootCollapsable && isCollapsable(json)) {
html = '<a href class="json-toggle"></a>' + html;
}
// Insert HTML in target DOM element
$(this).html(html);
$(this).addClass('json-document');
// Bind click on toggle buttons
$(this).off('click');
$(this).on('click', 'a.json-toggle', function() {
var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array');
target.toggle();
if (target.is(':visible')) {
target.siblings('.json-placeholder').remove();
} else {
var count = target.children('li').length;
var placeholder = count + (count > 1 ? ' items' : ' item');
target.after('<a href class="json-placeholder">' + placeholder + '</a>');
}
return false;
});
// Simulate click on toggle button when placeholder is clicked
$(this).on('click', 'a.json-placeholder', function() {
$(this).siblings('a.json-toggle').click();
return false;
});
if (options.collapsed == true) {
// Trigger click to collapse all nodes
$(this).find('a.json-toggle').click();
}
});
};
})(jQuery);

View File

@ -30,6 +30,7 @@
var color = Chart.helpers.color;
$(document).ready(function() {
console.log(initial_stats);
start_update();
start_charts();
init_messages();
@ -81,7 +82,6 @@
<div class="item" data-tab="seen-tab">Seen List</div>
<div class="item" data-tab="watch-tab">Watch List</div>
<div class="item" data-tab="plugin-tab">Plugins</div>
<div class="item" data-tab="threads-tab">Threads</div>
<div class="item" data-tab="config-tab">Config</div>
<div class="item" data-tab="log-tab">LogFile</div>
<!-- <div class="item" data-tab="oslo-tab">OSLO CONFIG</div> //-->
@ -97,6 +97,11 @@
<div class="ui segment" style="height: 300px" id="packetsChart"></div>
</div>
</div>
<div class="row">
<div class="column">
<div class="ui segment" style="height: 300px" id="packetTypesChart"></div>
</div>
</div>
<div class="row">
<div class="column">
<div class="ui segment" style="height: 300px" id="messagesChart"></div>
@ -107,17 +112,8 @@
</div>
<div class="row">
<div class="column">
<div class="ui segment" style="height: 300px" id="packetTypesChart"></div>
</div>
</div>
<div class="row">
<div class="column">
<div class="ui segment" style="height: 300px" id="threadChart"></div>
</div>
</div>
<div class="row">
<div class="column">
<div class="ui segment" style="height: 300px" id="memChart"></div>
<div class="ui segment" style="height: 300px" id="memChart">
</div>
</div>
</div>
<!-- <div class="row">
@ -160,13 +156,6 @@
<div id="pluginDiv" class="ui mini text">Loading</div>
</div>
<div class="ui bottom attached tab segment" data-tab="threads-tab">
<h3 class="ui dividing header">
Threads Loaded (<span id="thread_count">{{ thread_count }}</span>)
</h3>
<div id="threadsDiv" class="ui mini text">Loading</div>
</div>
<div class="ui bottom attached tab segment" data-tab="config-tab">
<h3 class="ui dividing header">Config</h3>
<pre id="configjson" class="language-json">{{ config_json|safe }}</pre>
@ -185,7 +174,7 @@
<div class="ui bottom attached tab segment" data-tab="raw-tab">
<h3 class="ui dividing header">Raw JSON</h3>
<pre id="jsonstats" class="language-yaml" style="height:600px;overflow-y:auto;">{{ initial_stats|safe }}</pre>
<pre id="jsonstats" class="language-yaml" style="height:600px;overflow-y:auto;">{{ stats|safe }}</pre>
</div>
<div class="ui text container">

View File

@ -64,11 +64,9 @@ function showError(error) {
function showPosition(position) {
console.log("showPosition Called");
path = $('#pkt_path option:selected').val();
msg = {
'latitude': position.coords.latitude,
'longitude': position.coords.longitude,
'path': path,
'longitude': position.coords.longitude
}
console.log(msg);
$.toast({

View File

@ -19,10 +19,9 @@ function show_aprs_icon(item, symbol) {
function ord(str){return str.charCodeAt(0);}
function update_stats( data ) {
console.log(data);
$("#version").text( data["stats"]["APRSDStats"]["version"] );
$("#version").text( data["stats"]["aprsd"]["version"] );
$("#aprs_connection").html( data["aprs_connection"] );
$("#uptime").text( "uptime: " + data["stats"]["APRSDStats"]["uptime"] );
$("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] );
short_time = data["time"].split(/\s(.+)/)[1];
}
@ -38,7 +37,7 @@ function start_update() {
update_stats(data);
},
complete: function() {
setTimeout(statsworker, 60000);
setTimeout(statsworker, 10000);
}
});
})();

View File

@ -313,7 +313,6 @@ function create_callsign_tab(callsign, active=false) {
//item_html += '<button onClick="callsign_select(\''+callsign+'\');" callsign="'+callsign+'" class="nav-link '+active_str+'" id="'+tab_id+'" data-bs-toggle="tab" data-bs-target="#'+tab_content+'" type="button" role="tab" aria-controls="'+callsign+'" aria-selected="true">';
item_html += '<button onClick="callsign_select(\''+callsign+'\');" callsign="'+callsign+'" class="nav-link position-relative '+active_str+'" id="'+tab_id+'" data-bs-toggle="tab" data-bs-target="#'+tab_content+'" type="button" role="tab" aria-controls="'+callsign+'" aria-selected="true">';
item_html += callsign+'&nbsp;&nbsp;';
item_html += '<span id="'+tab_notify_id+'" class="position-absolute top-0 start-80 translate-middle badge bg-danger border border-light rounded-pill visually-hidden">0</span>';
item_html += '<span onclick="delete_tab(\''+callsign+'\');">×</span>';
item_html += '</button></li>'
@ -408,15 +407,13 @@ function append_message(callsign, msg, msg_html) {
tab_notify_id = tab_notification_id(callsign, true);
// get the current count of notifications
count = parseInt($(tab_notify_id).text());
if (isNaN(count)) {
count = 0;
}
count += 1;
$(tab_notify_id).text(count);
$(tab_notify_id).removeClass('visually-hidden');
}
// Find the right div to place the html
new_callsign = add_callsign(callsign, msg);
update_callsign_path(callsign, msg);
append_message_html(callsign, msg_html, new_callsign);
@ -505,7 +502,7 @@ function sent_msg(msg) {
msg_html = create_message_html(d, t, msg['from_call'], msg['to_call'], msg['message_text'], ack_id, msg, false);
append_message(msg['to_call'], msg, msg_html);
save_data();
scroll_main_content(msg['to_call']);
scroll_main_content(msg['from_call']);
}
function from_msg(msg) {

View File

@ -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;
}

View File

@ -0,0 +1,158 @@
/**
* jQuery json-viewer
* @author: Alexandre Bodelot <alexandre.bodelot@gmail.com>
* @link: https://github.com/abodelot/jquery.json-viewer
*/
(function($) {
/**
* Check if arg is either an array with at least 1 element, or a dict with at least 1 key
* @return boolean
*/
function isCollapsable(arg) {
return arg instanceof Object && Object.keys(arg).length > 0;
}
/**
* Check if a string represents a valid url
* @return boolean
*/
function isUrl(string) {
var urlRegexp = /^(https?:\/\/|ftps?:\/\/)?([a-z0-9%-]+\.){1,}([a-z0-9-]+)?(:(\d{1,5}))?(\/([a-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+)?)?$/i;
return urlRegexp.test(string);
}
/**
* Transform a json object into html representation
* @return string
*/
function json2html(json, options) {
var html = '';
if (typeof json === 'string') {
// Escape tags and quotes
json = json
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;');
if (options.withLinks && isUrl(json)) {
html += '<a href="' + json + '" class="json-string" target="_blank">' + json + '</a>';
} else {
// Escape double quotes in the rendered non-URL string.
json = json.replace(/&quot;/g, '\\&quot;');
html += '<span class="json-string">"' + json + '"</span>';
}
} else if (typeof json === 'number') {
html += '<span class="json-literal">' + json + '</span>';
} else if (typeof json === 'boolean') {
html += '<span class="json-literal">' + json + '</span>';
} else if (json === null) {
html += '<span class="json-literal">null</span>';
} else if (json instanceof Array) {
if (json.length > 0) {
html += '[<ol class="json-array">';
for (var i = 0; i < json.length; ++i) {
html += '<li>';
// Add toggle button if item is collapsable
if (isCollapsable(json[i])) {
html += '<a href class="json-toggle"></a>';
}
html += json2html(json[i], options);
// Add comma if item is not last
if (i < json.length - 1) {
html += ',';
}
html += '</li>';
}
html += '</ol>]';
} else {
html += '[]';
}
} else if (typeof json === 'object') {
var keyCount = Object.keys(json).length;
if (keyCount > 0) {
html += '{<ul class="json-dict">';
for (var key in json) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
html += '<li>';
var keyRepr = options.withQuotes ?
'<span class="json-string">"' + key + '"</span>' : key;
// Add toggle button if item is collapsable
if (isCollapsable(json[key])) {
html += '<a href class="json-toggle">' + keyRepr + '</a>';
} else {
html += keyRepr;
}
html += ': ' + json2html(json[key], options);
// Add comma if item is not last
if (--keyCount > 0) {
html += ',';
}
html += '</li>';
}
}
html += '</ul>}';
} else {
html += '{}';
}
}
return html;
}
/**
* jQuery plugin method
* @param json: a javascript object
* @param options: an optional options hash
*/
$.fn.jsonViewer = function(json, options) {
// Merge user options with default options
options = Object.assign({}, {
collapsed: false,
rootCollapsable: true,
withQuotes: false,
withLinks: true
}, options);
// jQuery chaining
return this.each(function() {
// Transform to HTML
var html = json2html(json, options);
if (options.rootCollapsable && isCollapsable(json)) {
html = '<a href class="json-toggle"></a>' + html;
}
// Insert HTML in target DOM element
$(this).html(html);
$(this).addClass('json-document');
// Bind click on toggle buttons
$(this).off('click');
$(this).on('click', 'a.json-toggle', function() {
var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array');
target.toggle();
if (target.is(':visible')) {
target.siblings('.json-placeholder').remove();
} else {
var count = target.children('li').length;
var placeholder = count + (count > 1 ? ' items' : ' item');
target.after('<a href class="json-placeholder">' + placeholder + '</a>');
}
return false;
});
// Simulate click on toggle button when placeholder is clicked
$(this).on('click', 'a.json-placeholder', function() {
$(this).siblings('a.json-toggle').click();
return false;
});
if (options.collapsed == true) {
// Trigger click to collapse all nodes
$(this).find('a.json-toggle').click();
}
});
};
})(jQuery);

View File

@ -103,7 +103,6 @@
<option value="WIDE1-1">WIDE1-1</option>
<option value="WIDE1-1,WIDE2-1">WIDE1-1,WIDE2-1</option>
<option value="ARISS">ARISS</option>
<option value="GATE">GATE</option>
</select>
</div>
<div class="col-sm-3">

View File

@ -3,10 +3,10 @@ import importlib.metadata as imp
import io
import json
import logging
import queue
import time
import flask
from flask import Flask, request
from flask import Flask
from flask_httpauth import HTTPBasicAuth
from oslo_config import cfg, generator
import socketio
@ -15,16 +15,14 @@ from werkzeug.security import check_password_hash
import aprsd
from aprsd import cli_helper, client, conf, packets, plugin, threads
from aprsd.log import log
from aprsd.threads import stats as stats_threads
from aprsd.utils import json as aprsd_json
from aprsd.rpc import client as aprsd_rpc_client
CONF = cfg.CONF
LOG = logging.getLogger("gunicorn.access")
logging_queue = queue.Queue()
auth = HTTPBasicAuth()
users: dict[str, str] = {}
users = {}
app = Flask(
"aprsd",
static_url_path="/static",
@ -47,40 +45,114 @@ def verify_password(username, password):
def _stats():
stats_obj = stats_threads.StatsStore()
stats_obj.load()
track = aprsd_rpc_client.RPCClient().get_packet_track()
now = datetime.datetime.now()
time_format = "%m-%d-%Y %H:%M:%S"
stats = {
"time": now.strftime(time_format),
"stats": stats_obj.data,
stats_dict = aprsd_rpc_client.RPCClient().get_stats_dict()
if not stats_dict:
stats_dict = {
"aprsd": {},
"aprs-is": {"server": ""},
"messages": {
"sent": 0,
"received": 0,
},
"email": {
"sent": 0,
"received": 0,
},
"seen_list": {
"sent": 0,
"received": 0,
},
}
# Convert the watch_list entries to age
wl = aprsd_rpc_client.RPCClient().get_watch_list()
new_list = {}
if wl:
for call in wl.get_all():
# call_date = datetime.datetime.strptime(
# str(wl.last_seen(call)),
# "%Y-%m-%d %H:%M:%S.%f",
# )
# We have to convert the RingBuffer to a real list
# so that json.dumps works.
# pkts = []
# for pkt in wl.get(call)["packets"].get():
# pkts.append(pkt)
new_list[call] = {
"last": wl.age(call),
# "packets": pkts
}
stats_dict["aprsd"]["watch_list"] = new_list
packet_list = aprsd_rpc_client.RPCClient().get_packet_list()
rx = tx = 0
types = {}
if packet_list:
rx = packet_list.total_rx()
tx = packet_list.total_tx()
types_copy = packet_list.types.copy()
for key in types_copy:
types[str(key)] = dict(types_copy[key])
stats_dict["packets"] = {
"sent": tx,
"received": rx,
"types": types,
}
return stats
if track:
size_tracker = len(track)
else:
size_tracker = 0
result = {
"time": now.strftime(time_format),
"size_tracker": size_tracker,
"stats": stats_dict,
}
return result
@app.route("/stats")
def stats():
LOG.debug("/stats called")
return json.dumps(_stats(), cls=aprsd_json.SimpleJSONEncoder)
return json.dumps(_stats())
@app.route("/")
def index():
stats = _stats()
wl = aprsd_rpc_client.RPCClient().get_watch_list()
if wl and wl.is_enabled():
watch_count = len(wl)
watch_age = wl.max_delta()
else:
watch_count = 0
watch_age = 0
sl = aprsd_rpc_client.RPCClient().get_seen_list()
if sl:
seen_count = len(sl)
else:
seen_count = 0
pm = plugin.PluginManager()
plugins = pm.get_plugins()
plugin_count = len(plugins)
client_stats = stats["stats"].get("APRSClientStats", {})
if CONF.aprs_network.enabled:
transport = "aprs-is"
if client_stats:
aprs_connection = client_stats.get("server_string", "")
else:
aprs_connection = "APRS-IS"
aprs_connection = (
"APRS-IS Server: <a href='http://status.aprs2.net' >"
"{}</a>".format(aprs_connection)
"{}</a>".format(stats["stats"]["aprs-is"]["server"])
)
else:
# We might be connected to a KISS socket?
@ -101,20 +173,13 @@ def index():
)
)
if client_stats:
stats["stats"]["APRSClientStats"]["transport"] = transport
stats["stats"]["APRSClientStats"]["aprs_connection"] = aprs_connection
stats["transport"] = transport
stats["aprs_connection"] = aprs_connection
entries = conf.conf_to_dict()
thread_info = stats["stats"].get("APRSDThreadList", {})
if thread_info:
thread_count = len(thread_info)
else:
thread_count = "unknown"
return flask.render_template(
"index.html",
initial_stats=json.dumps(stats, cls=aprsd_json.SimpleJSONEncoder),
initial_stats=stats,
aprs_connection=aprs_connection,
callsign=CONF.callsign,
version=aprsd.__version__,
@ -122,8 +187,10 @@ def index():
entries, indent=4,
sort_keys=True, default=str,
),
watch_count=watch_count,
watch_age=watch_age,
seen_count=seen_count,
plugin_count=plugin_count,
thread_count=thread_count,
# oslo_out=generate_oslo()
)
@ -142,10 +209,19 @@ def messages():
@auth.login_required
@app.route("/packets")
def get_packets():
stats = _stats()
stats_dict = stats["stats"]
packets = stats_dict.get("PacketList", {})
return json.dumps(packets, cls=aprsd_json.SimpleJSONEncoder)
LOG.debug("/packets called")
packet_list = aprsd_rpc_client.RPCClient().get_packet_list()
if packet_list:
tmp_list = []
pkts = packet_list.copy()
for key in pkts:
pkt = packet_list.get(key)
if pkt:
tmp_list.append(pkt.json)
return json.dumps(tmp_list)
else:
return json.dumps([])
@auth.login_required
@ -197,34 +273,23 @@ def save():
return json.dumps({"messages": "saved"})
@app.route("/log_entries", methods=["POST"])
def log_entries():
"""The url that the server can call to update the logs."""
entries = request.json
LOG.info(f"Log entries called {len(entries)}")
for entry in entries:
logging_queue.put(entry)
return json.dumps({"messages": "saved"})
class LogUpdateThread(threads.APRSDThread):
def __init__(self, logging_queue=None):
def __init__(self):
super().__init__("LogUpdate")
self.logging_queue = logging_queue
def loop(self):
if sio:
try:
log_entry = self.logging_queue.get(block=True, timeout=1)
if log_entry:
log_entries = aprsd_rpc_client.RPCClient().get_log_entries()
if log_entries:
LOG.info(f"Sending log entries! {len(log_entries)}")
for entry in log_entries:
sio.emit(
"log_entry",
log_entry,
"log_entry", entry,
namespace="/logs",
)
except queue.Empty:
pass
time.sleep(5)
return True
@ -232,17 +297,17 @@ class LoggingNamespace(socketio.Namespace):
log_thread = None
def on_connect(self, sid, environ):
global sio, logging_queue
LOG.info(f"LOG on_connect {sid}")
global sio
LOG.debug(f"LOG on_connect {sid}")
sio.emit(
"connected", {"data": "/logs Connected"},
namespace="/logs",
)
self.log_thread = LogUpdateThread(logging_queue=logging_queue)
self.log_thread = LogUpdateThread()
self.log_thread.start()
def on_disconnect(self, sid):
LOG.info(f"LOG Disconnected {sid}")
LOG.debug(f"LOG Disconnected {sid}")
if self.log_thread:
self.log_thread.stop()
@ -267,8 +332,8 @@ if __name__ == "__main__":
async_mode = "threading"
sio = socketio.Server(logger=True, async_mode=async_mode)
app.wsgi_app = socketio.WSGIApp(sio, app.wsgi_app)
log_level = init_app()
log.setup_logging(log_level)
log_level = init_app(log_level="DEBUG")
log.setup_logging(app, log_level)
sio.register_namespace(LoggingNamespace("/logs"))
CONF.log_opt_values(LOG, logging.DEBUG)
app.run(
@ -287,12 +352,12 @@ if __name__ == "uwsgi_file_aprsd_wsgi":
sio = socketio.Server(logger=True, async_mode=async_mode)
app.wsgi_app = socketio.WSGIApp(sio, app.wsgi_app)
log_level = init_app(
# log_level="DEBUG",
log_level="DEBUG",
config_file="/config/aprsd.conf",
# Commented out for local development.
# config_file=cli_helper.DEFAULT_CONFIG_FILE
)
log.setup_logging(log_level)
log.setup_logging(app, log_level)
sio.register_namespace(LoggingNamespace("/logs"))
CONF.log_opt_values(LOG, logging.DEBUG)
@ -306,10 +371,10 @@ if __name__ == "aprsd.wsgi":
app.wsgi_app = socketio.WSGIApp(sio, app.wsgi_app)
log_level = init_app(
# log_level="DEBUG",
log_level="DEBUG",
config_file="/config/aprsd.conf",
# config_file=cli_helper.DEFAULT_CONFIG_FILE,
)
log.setup_logging(log_level)
log.setup_logging(app, log_level)
sio.register_namespace(LoggingNamespace("/logs"))
CONF.log_opt_values(LOG, logging.DEBUG)

View File

@ -1,20 +1,16 @@
build
check-manifest
flake8
gray
isort
mypy
pep8-naming
pytest
pytest-cov
pip
pip-tools
pre-commit
Sphinx
tox
wheel
# Twine is used for uploading packages to pypi
# but it induces an install of cryptography
# This is sucky for rpi systems.
# twine
pre-commit
pytest
pytest-cov
gray
pip
pip-tools

84
dev-requirements.txt Normal file
View File

@ -0,0 +1,84 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --annotation-style=line dev-requirements.in
#
add-trailing-comma==3.1.0 # via gray
alabaster==0.7.16 # via sphinx
autoflake==1.5.3 # via gray
babel==2.14.0 # via sphinx
black==24.2.0 # via gray
build==1.1.1 # via pip-tools
cachetools==5.3.3 # via tox
certifi==2024.2.2 # via requests
cfgv==3.4.0 # via pre-commit
chardet==5.2.0 # via tox
charset-normalizer==3.3.2 # via requests
click==8.1.7 # via black, fixit, moreorless, pip-tools
colorama==0.4.6 # via tox
commonmark==0.9.1 # via rich
configargparse==1.7 # via gray
coverage[toml]==7.4.3 # via pytest-cov
distlib==0.3.8 # via virtualenv
docutils==0.20.1 # via sphinx
exceptiongroup==1.2.0 # via pytest
filelock==3.13.1 # via tox, virtualenv
fixit==2.1.0 # via gray
flake8==7.0.0 # via -r dev-requirements.in, pep8-naming
gray==0.14.0 # via -r dev-requirements.in
identify==2.5.35 # via pre-commit
idna==3.6 # via requests
imagesize==1.4.1 # via sphinx
iniconfig==2.0.0 # via pytest
isort==5.13.2 # via -r dev-requirements.in, gray
jinja2==3.1.3 # via sphinx
libcst==1.2.0 # via fixit
markupsafe==2.1.5 # via jinja2
mccabe==0.7.0 # via flake8
moreorless==0.4.0 # via fixit
mypy==1.8.0 # via -r dev-requirements.in
mypy-extensions==1.0.0 # via black, mypy, typing-inspect
nodeenv==1.8.0 # via pre-commit
packaging==23.2 # via black, build, fixit, pyproject-api, pytest, sphinx, tox
pathspec==0.12.1 # via black, trailrunner
pep8-naming==0.13.3 # via -r dev-requirements.in
pip-tools==7.4.1 # via -r dev-requirements.in
platformdirs==4.2.0 # via black, tox, virtualenv
pluggy==1.4.0 # via pytest, tox
pre-commit==3.6.2 # via -r dev-requirements.in
pycodestyle==2.11.1 # via flake8
pyflakes==3.2.0 # via autoflake, flake8
pygments==2.17.2 # via rich, sphinx
pyproject-api==1.6.1 # via tox
pyproject-hooks==1.0.0 # via build, pip-tools
pytest==8.0.2 # via -r dev-requirements.in, pytest-cov
pytest-cov==4.1.0 # via -r dev-requirements.in
pyupgrade==3.15.1 # via gray
pyyaml==6.0.1 # via libcst, pre-commit
requests==2.31.0 # via sphinx
rich==12.6.0 # via gray
snowballstemmer==2.2.0 # via sphinx
sphinx==7.2.6 # via -r dev-requirements.in
sphinxcontrib-applehelp==1.0.8 # via sphinx
sphinxcontrib-devhelp==1.0.6 # via sphinx
sphinxcontrib-htmlhelp==2.0.5 # via sphinx
sphinxcontrib-jsmath==1.0.1 # via sphinx
sphinxcontrib-qthelp==1.0.7 # via sphinx
sphinxcontrib-serializinghtml==1.1.10 # via sphinx
tokenize-rt==5.2.0 # via add-trailing-comma, pyupgrade
toml==0.10.2 # via autoflake
tomli==2.0.1 # via black, build, coverage, fixit, mypy, pip-tools, pyproject-api, pyproject-hooks, pytest, tox
tox==4.14.0 # via -r dev-requirements.in
trailrunner==1.4.0 # via fixit
typing-extensions==4.10.0 # via black, libcst, mypy, typing-inspect
typing-inspect==0.9.0 # via libcst
unify==0.5 # via gray
untokenize==0.1.1 # via unify
urllib3==2.2.1 # via requests
virtualenv==20.25.1 # via pre-commit, tox
wheel==0.42.0 # via pip-tools
# The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools

View File

@ -1,18 +1,10 @@
FROM python:3.11-slim as build
ARG VERSION=3.4.0
# pass this in as 'dev' if you want to install from github repo vs pypi
ARG INSTALL_TYPE=pypi
ARG BRANCH=master
ARG BUILDX_QEMU_ENV
ENV APRSD_BRANCH=${BRANCH:-master}
ARG VERSION=3.1.0
ENV TZ=${TZ:-US/Eastern}
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
ENV APRSD_PIP_VERSION=${VERSION}
ENV PATH="${PATH}:/app/.local/bin"
ENV PIP_DEFAULT_TIMEOUT=100 \
# Allow statements and log messages to immediately appear
@ -42,33 +34,26 @@ RUN set -ex \
FROM build as final
WORKDIR /app
RUN pip3 install -U pip
RUN pip3 install aprsd==$APRSD_PIP_VERSION
RUN pip install gevent uwsgi
RUN which aprsd
RUN mkdir /config
RUN chown -R appuser:appgroup /app
RUN chown -R appuser:appgroup /config
USER appuser
RUN if [ "$INSTALL_TYPE" = "pypi" ]; then \
pip3 install aprsd==$APRSD_PIP_VERSION; \
elif [ "$INSTALL_TYPE" = "github" ]; then \
git clone -b $APRSD_BRANCH https://github.com/craigerl/aprsd; \
cd /app/aprsd && pip install -e .; \
ls -al /app/.local/lib/python3.11/site-packages/aprsd*; \
fi
RUN pip install gevent uwsgi
RUN echo "PATH=\$PATH:/usr/games:/app/.local/bin" >> /app/.bashrc
RUN echo "PATH=\$PATH:/usr/games" >> /app/.bashrc
RUN which aprsd
RUN aprsd sample-config > /config/aprsd.conf
RUN aprsd --version
ADD bin/setup.sh /app
ADD bin/run.sh /app
ADD bin/listen.sh /app
ADD bin/admin.sh /app
# For the web admin interface
EXPOSE 8001
ENTRYPOINT ["/app/run.sh"]
VOLUME ["/config"]
ENTRYPOINT ["/app/setup.sh"]
CMD ["server"]
# Set the user to run the application
USER appuser

58
docker/Dockerfile-dev Normal file
View File

@ -0,0 +1,58 @@
FROM python:3.11-slim as build
ARG BRANCH=master
ARG BUILDX_QEMU_ENV
ENV APRSD_BRANCH=${BRANCH:-master}
ENV PIP_DEFAULT_TIMEOUT=100 \
# Allow statements and log messages to immediately appear
PYTHONUNBUFFERED=1 \
# disable a pip version check to reduce run-time & log-spam
PIP_DISABLE_PIP_VERSION_CHECK=1 \
# cache is useless in docker image, so disable to reduce image size
PIP_NO_CACHE_DIR=1
RUN set -ex \
# Create a non-root user
&& addgroup --system --gid 1001 appgroup \
&& useradd --uid 1001 --gid 1001 -s /usr/bin/bash -m -d /app appuser \
# Upgrade the package index and install security upgrades
&& apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y git build-essential curl libffi-dev fortune \
python3-dev libssl-dev libxml2-dev libxslt-dev telnet sudo \
# Install dependencies
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y
### Final stage
FROM build as final
WORKDIR /app
RUN git clone -b $APRSD_BRANCH https://github.com/craigerl/aprsd
RUN cd aprsd && pip install --no-cache-dir .
RUN pip install gevent uwsgi
RUN which aprsd
RUN mkdir /config
RUN chown -R appuser:appgroup /app
RUN chown -R appuser:appgroup /config
USER appuser
RUN echo "PATH=\$PATH:/usr/games" >> /app/.bashrc
RUN which aprsd
RUN aprsd sample-config > /config/aprsd.conf
ADD bin/run.sh /app
ADD bin/listen.sh /app
ADD bin/admin.sh /app
EXPOSE 8000
# CMD ["gunicorn", "aprsd.wsgi:app", "--host", "0.0.0.0", "--port", "8000"]
ENTRYPOINT ["/app/run.sh"]
VOLUME ["/config"]
# Set the user to run the application
USER appuser

View File

@ -1,50 +0,0 @@
#!/usr/bin/env bash
set -x
# The default command
# Override the command in docker-compose.yml to change
# what command you want to run in the container
COMMAND="server"
if [ ! -z "${@+x}" ]; then
COMMAND=$@
fi
if [ ! -z "${APRSD_PLUGINS}" ]; then
OLDIFS=$IFS
IFS=','
echo "Installing pypi plugins '$APRSD_PLUGINS'";
for plugin in ${APRSD_PLUGINS}; do
IFS=$OLDIFS
# call your procedure/other scripts here below
echo "Installing '$plugin'"
pip3 install --user $plugin
done
fi
if [ ! -z "${APRSD_EXTENSIONS}" ]; then
OLDIFS=$IFS
IFS=','
echo "Installing APRSD extensions from pypi '$APRSD_EXTENSIONS'";
for extension in ${APRSD_EXTENSIONS}; do
IFS=$OLDIFS
# call your procedure/other scripts here below
echo "Installing '$extension'"
pip3 install --user $extension
done
fi
if [ -z "${LOG_LEVEL}" ] || [[ ! "${LOG_LEVEL}" =~ ^(CRITICAL|ERROR|WARNING|INFO)$ ]]; then
LOG_LEVEL="DEBUG"
fi
echo "Log level is set to ${LOG_LEVEL}";
# check to see if there is a config file
APRSD_CONFIG="/config/aprsd.conf"
if [ ! -e "$APRSD_CONFIG" ]; then
echo "'$APRSD_CONFIG' File does not exist. Creating."
aprsd sample-config > $APRSD_CONFIG
fi
aprsd ${COMMAND} --config ${APRSD_CONFIG} --loglevel ${LOG_LEVEL}

View File

@ -26,7 +26,7 @@ DEV=0
REBUILD_BUILDX=0
TAG="latest"
BRANCH=${BRANCH:-master}
VERSION="3.3.4"
VERSION="3.0.0"
while getopts “hdart:b:v:” OPTION
do
@ -90,8 +90,7 @@ then
# Use this script to locally build the docker image
docker buildx build --push --platform $PLATFORMS \
-t hemna6969/aprsd:$TAG \
--build-arg INSTALL_TYPE=github \
--build-arg branch=$BRANCH \
-f Dockerfile-dev --build-arg branch=$BRANCH \
--build-arg BUILDX_QEMU_ENV=true \
--no-cache .
else
@ -102,5 +101,6 @@ else
--build-arg BUILDX_QEMU_ENV=true \
-t hemna6969/aprsd:$VERSION \
-t hemna6969/aprsd:$TAG \
-t hemna6969/aprsd:latest .
-t hemna6969/aprsd:latest \
-f Dockerfile .
fi

View File

@ -1,162 +1,5 @@
[project]
# This is the name of your project. The first time you publish this
# package, this name will be registered for you. It will determine how
# users can install this project, e.g.:
#
# $ pip install sampleproject
#
# And where it will live on PyPI: https://pypi.org/project/sampleproject/
#
# There are some restrictions on what makes a valid project name
# specification here:
# https://packaging.python.org/specifications/core-metadata/#name
name = "aprsd"
description = "APRSd is a APRS-IS server that can be used to connect to APRS-IS and send and receive APRS packets."
# Specify which Python versions you support. In contrast to the
# 'Programming Language' classifiers in this file, 'pip install' will check this
# and refuse to install the project if the version does not match. See
# https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires
requires-python = ">=3.8"
dynamic = ["version", "dependencies", "optional-dependencies"]
# This is an optional longer description of your project that represents
# the body of text which users will see when they visit PyPI.
#
# Often, this is the same as your README, so you can just read it in from
# that file directly.
#
# This field corresponds to the "Description" metadata field:
# https://packaging.python.org/specifications/core-metadata/#description-optional
readme = {file = "README.rst", content-type = "text/x-rst"}
# This is either text indicating the license for the distribution, or a file
# that contains the license.
# https://packaging.python.org/en/latest/specifications/core-metadata/#license
license = {file = "LICENSE"}
# This should be your name or the name of the organization who originally
# authored the project, and a valid email address corresponding to the name
# listed.
authors = [
{name = "Craig Lamparter", email = "craig@craiger.org"},
{name = "Walter A. Boring IV", email = "waboring@hemna.com"},
{name = "Emre Saglam", email = "emresaglam@gmail.com"},
{name = "Jason Martin", email= "jhmartin@toger.us"},
{name = "John", email="johng42@users.noreply.github.com"},
{name = "Martiros Shakhzadyan", email="vrzh@vrzh.net"},
{name = "Zoe Moore", email="zoenb@mailbox.org"},
{name = "ranguli", email="hello@joshmurphy.ca"},
]
# This should be your name or the names of the organization who currently
# maintains the project, and a valid email address corresponding to the name
# listed.
maintainers = [
{name = "Craig Lamparter", email = "craig@craiger.org"},
{name = "Walter A. Boring IV", email = "waboring@hemna.com"},
]
# This field adds keywords for your project which will appear on the
# project page. What does your project relate to?
#
# Note that this is a list of additional keywords, separated
# by commas, to be used to assist searching for the distribution in a
# larger catalog.
keywords = [
"aprs",
"aprs-is",
"aprsd",
"aprsd-server",
"aprsd-client",
"aprsd-socket",
"aprsd-socket-server",
"aprsd-socket-client",
]
# Classifiers help users find your project by categorizing it.
#
# For a list of valid classifiers, see https://pypi.org/classifiers/
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
"Intended Audience :: Information Technology",
"Topic :: Communications :: Ham Radio",
"Topic :: Communications :: Internet Relay Chat",
"Topic :: Internet",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
]
# This field lists other packages that your project depends on to run.
# Any package you put here will be installed by pip when your project is
# installed, so they must be valid existing projects.
#
# For an analysis of this field vs pip's requirements files see:
# https://packaging.python.org/discussions/install-requires-vs-requirements/
[tool.setuptools.dynamic]
dependencies = {file = ["./requirements.txt"]}
optional-dependencies.dev = {file = ["./requirements-dev.txt"]}
# List additional groups of dependencies here (e.g. development
# dependencies). Users will be able to install these using the "extras"
# syntax, for example:
#
# $ pip install sampleproject[dev]
#
# Optional dependencies the project provides. These are commonly
# referred to as "extras". For a more extensive definition see:
# https://packaging.python.org/en/latest/specifications/dependency-specifiers/#extras
# [project.optional-dependencies]
# List URLs that are relevant to your project
#
# This field corresponds to the "Project-URL" and "Home-Page" metadata fields:
# https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use
# https://packaging.python.org/specifications/core-metadata/#home-page-optional
#
# Examples listed include a pattern for specifying where the package tracks
# issues, where the source is hosted, where to say thanks to the package
# maintainers, and where to support the project financially. The key is
# what's used to render the link text on PyPI.
[project.urls]
"Homepage" = "https://github.com/craigerl/aprsd"
"Bug Reports" = "https://github.com/craigerl/aprsd/issues"
"Source" = "https://github.com/craigerl/aprsd"
# The following would provide a command line executable called `sample`
# which executes the function `main` from this package when invoked.
[project.scripts]
aprsd = "aprsd.main:main"
[project.entry-points."oslo.config.opts"]
"aprsd.conf" = "aprsd.conf.opts:list_opts"
[project.entry-points."oslo.config.opts.defaults"]
"aprsd.conf" = "aprsd.conf:set_lib_defaults"
# If you are using a different build backend, you will need to change this.
[tool.setuptools]
# If there are data files included in your packages that need to be
# installed, specify them here.
py-modules = ["aprsd"]
package-data = {"sample" = ["*.dat"]}
[build-system]
requires = [
"setuptools>=69.5.0",
"setuptools_scm>=0",
"wheel",
]
requires = ["setuptools>=46.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.isort]
@ -171,5 +14,3 @@ skip_gitignore = true
[tool.coverage.run]
branch = true
[tool.setuptools_scm]

View File

@ -1,216 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --annotation-style=line requirements-dev.in
#
add-trailing-comma==3.1.0
# via gray
alabaster==0.7.16
# via sphinx
autoflake==1.5.3
# via gray
babel==2.15.0
# via sphinx
black==24.4.2
# via gray
build==1.2.1
# via
# -r requirements-dev.in
# check-manifest
# pip-tools
cachetools==5.3.3
# via tox
certifi==2024.2.2
# via requests
cfgv==3.4.0
# via pre-commit
chardet==5.2.0
# via tox
charset-normalizer==3.3.2
# via requests
check-manifest==0.49
# via -r requirements-dev.in
click==8.1.7
# via
# black
# fixit
# moreorless
# pip-tools
colorama==0.4.6
# via tox
commonmark==0.9.1
# via rich
configargparse==1.7
# via gray
coverage[toml]==7.5.1
# via pytest-cov
distlib==0.3.8
# via virtualenv
docutils==0.21.2
# via sphinx
exceptiongroup==1.2.1
# via pytest
filelock==3.14.0
# via
# tox
# virtualenv
fixit==2.1.0
# via gray
flake8==7.0.0
# via
# -r requirements-dev.in
# pep8-naming
gray==0.15.0
# via -r requirements-dev.in
identify==2.5.36
# via pre-commit
idna==3.7
# via requests
imagesize==1.4.1
# via sphinx
iniconfig==2.0.0
# via pytest
isort==5.13.2
# via
# -r requirements-dev.in
# gray
jinja2==3.1.4
# via sphinx
libcst==1.3.1
# via fixit
markupsafe==2.1.5
# via jinja2
mccabe==0.7.0
# via flake8
moreorless==0.4.0
# via fixit
mypy==1.10.0
# via -r requirements-dev.in
mypy-extensions==1.0.0
# via
# black
# mypy
nodeenv==1.8.0
# via pre-commit
packaging==24.0
# via
# black
# build
# fixit
# pyproject-api
# pytest
# sphinx
# tox
pathspec==0.12.1
# via
# black
# trailrunner
pep8-naming==0.14.1
# via -r requirements-dev.in
pip-tools==7.4.1
# via -r requirements-dev.in
platformdirs==4.2.2
# via
# black
# tox
# virtualenv
pluggy==1.5.0
# via
# pytest
# tox
pre-commit==3.7.1
# via -r requirements-dev.in
pycodestyle==2.11.1
# via flake8
pyflakes==3.2.0
# via
# autoflake
# flake8
pygments==2.18.0
# via
# rich
# sphinx
pyproject-api==1.6.1
# via tox
pyproject-hooks==1.1.0
# via
# build
# pip-tools
pytest==8.2.0
# via
# -r requirements-dev.in
# pytest-cov
pytest-cov==5.0.0
# via -r requirements-dev.in
pyupgrade==3.15.2
# via gray
pyyaml==6.0.1
# via
# libcst
# pre-commit
requests==2.32.0
# via sphinx
rich==12.6.0
# via gray
snowballstemmer==2.2.0
# via sphinx
sphinx==7.3.7
# via -r requirements-dev.in
sphinxcontrib-applehelp==1.0.8
# via sphinx
sphinxcontrib-devhelp==1.0.6
# via sphinx
sphinxcontrib-htmlhelp==2.0.5
# via sphinx
sphinxcontrib-jsmath==1.0.1
# via sphinx
sphinxcontrib-qthelp==1.0.7
# via sphinx
sphinxcontrib-serializinghtml==1.1.10
# via sphinx
tokenize-rt==5.2.0
# via
# add-trailing-comma
# pyupgrade
toml==0.10.2
# via autoflake
tomli==2.0.1
# via
# black
# build
# check-manifest
# coverage
# fixit
# mypy
# pip-tools
# pyproject-api
# pytest
# sphinx
# tox
tox==4.15.0
# via -r requirements-dev.in
trailrunner==1.4.0
# via fixit
typing-extensions==4.11.0
# via
# black
# mypy
unify==0.5
# via gray
untokenize==0.1.1
# via unify
urllib3==2.2.1
# via requests
virtualenv==20.26.2
# via
# pre-commit
# tox
wheel==0.43.0
# via
# -r requirements-dev.in
# pip-tools
# The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools

View File

@ -1,32 +1,40 @@
aprslib>=0.7.0
# For the list-plugins pypi.org search scraping
beautifulsoup4
click
click-params
dataclasses
dataclasses-json
eventlet
click-completion
flask
werkzeug
flask-httpauth
flask-socketio
geopy
gevent
imapclient
kiss3
loguru
oslo.config
pluggy
python-socketio
pbr
pyyaml
pytz
requests
pytz
six
thesmuggler
update_checker
flask-socketio
python-socketio
gevent
eventlet
tabulate
# Pinned due to gray needing 12.6.0
rich~=12.6.0
rush
shellingham
six
tabulate
thesmuggler
tzlocal
update_checker
# For the list-plugins pypi.org search scraping
beautifulsoup4
wrapt
# kiss3 uses attrs
kiss3
attrs
dataclasses
dacite2
oslo.config
rpyc>=6.0.0
# Pin this here so it doesn't require a compile on
# raspi
shellingham
geopy
rush
dataclasses-json
loguru

View File

@ -4,179 +4,81 @@
#
# pip-compile --annotation-style=line requirements.in
#
aprslib==0.7.2
# via -r requirements.in
attrs==23.2.0
# via
# ax253
# kiss3
# rush
ax253==0.1.5.post1
# via kiss3
beautifulsoup4==4.12.3
# via -r requirements.in
bidict==0.23.1
# via python-socketio
bitarray==2.9.2
# via
# ax253
# kiss3
blinker==1.8.2
# via flask
certifi==2024.2.2
# via requests
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via
# -r requirements.in
# click-params
# flask
click-params==0.5.0
# via -r requirements.in
commonmark==0.9.1
# via rich
dataclasses==0.6
# via -r requirements.in
dataclasses-json==0.6.6
# via -r requirements.in
debtcollector==3.0.0
# via oslo-config
deprecated==1.2.14
# via click-params
dnspython==2.6.1
# via eventlet
eventlet==0.36.1
# via -r requirements.in
flask==3.0.3
# via
# -r requirements.in
# flask-httpauth
# flask-socketio
flask-httpauth==4.8.0
# via -r requirements.in
flask-socketio==5.3.6
# via -r requirements.in
geographiclib==2.0
# via geopy
geopy==2.4.1
# via -r requirements.in
gevent==24.2.1
# via -r requirements.in
greenlet==3.0.3
# via
# eventlet
# gevent
h11==0.14.0
# via wsproto
idna==3.7
# via requests
imapclient==3.0.1
# via -r requirements.in
importlib-metadata==7.1.0
# via
# ax253
# kiss3
itsdangerous==2.2.0
# via flask
jinja2==3.1.4
# via flask
kiss3==8.0.0
# via -r requirements.in
loguru==0.7.2
# via -r requirements.in
markupsafe==2.1.5
# via
# jinja2
# werkzeug
marshmallow==3.21.2
# via dataclasses-json
mypy-extensions==1.0.0
# via typing-inspect
netaddr==1.2.1
# via oslo-config
oslo-config==9.4.0
# via -r requirements.in
oslo-i18n==6.3.0
# via oslo-config
packaging==24.0
# via marshmallow
pbr==6.0.0
# via
# oslo-i18n
# stevedore
pluggy==1.5.0
# via -r requirements.in
pygments==2.18.0
# via rich
pyserial==3.5
# via pyserial-asyncio
pyserial-asyncio==0.6
# via kiss3
python-engineio==4.9.0
# via python-socketio
python-socketio==5.11.2
# via
# -r requirements.in
# flask-socketio
pytz==2024.1
# via -r requirements.in
pyyaml==6.0.1
# via
# -r requirements.in
# oslo-config
requests==2.32.0
# via
# -r requirements.in
# oslo-config
# update-checker
rfc3986==2.0.0
# via oslo-config
rich==12.6.0
# via -r requirements.in
rush==2021.4.0
# via -r requirements.in
shellingham==1.5.4
# via -r requirements.in
simple-websocket==1.0.0
# via python-engineio
six==1.16.0
# via -r requirements.in
soupsieve==2.5
# via beautifulsoup4
stevedore==5.2.0
# via oslo-config
tabulate==0.9.0
# via -r requirements.in
thesmuggler==1.0.1
# via -r requirements.in
typing-extensions==4.11.0
# via typing-inspect
typing-inspect==0.9.0
# via dataclasses-json
tzlocal==5.2
# via -r requirements.in
update-checker==0.18.0
# via -r requirements.in
urllib3==2.2.1
# via requests
validators==0.22.0
# via click-params
werkzeug==3.0.3
# via flask
wrapt==1.16.0
# via
# -r requirements.in
# debtcollector
# deprecated
wsproto==1.2.0
# via simple-websocket
zipp==3.18.2
# via importlib-metadata
zope-event==5.0
# via gevent
zope-interface==6.4
# via gevent
aprslib==0.7.2 # via -r requirements.in
attrs==23.2.0 # via -r requirements.in, ax253, kiss3, rush
ax253==0.1.5.post1 # via kiss3
beautifulsoup4==4.12.3 # via -r requirements.in
bidict==0.23.1 # via python-socketio
bitarray==2.9.2 # via ax253, kiss3
blinker==1.7.0 # via flask
certifi==2024.2.2 # via requests
charset-normalizer==3.3.2 # via requests
click==8.1.7 # via -r requirements.in, click-completion, click-params, flask
click-completion==0.5.2 # via -r requirements.in
click-params==0.5.0 # via -r requirements.in
commonmark==0.9.1 # via rich
dacite2==2.0.0 # via -r requirements.in
dataclasses==0.6 # via -r requirements.in
dataclasses-json==0.6.4 # via -r requirements.in
debtcollector==3.0.0 # via oslo-config
deprecated==1.2.14 # via click-params
dnspython==2.6.1 # via eventlet
eventlet==0.35.2 # via -r requirements.in
flask==3.0.2 # via -r requirements.in, flask-httpauth, flask-socketio
flask-httpauth==4.8.0 # via -r requirements.in
flask-socketio==5.3.6 # via -r requirements.in
geographiclib==2.0 # via geopy
geopy==2.4.1 # via -r requirements.in
gevent==24.2.1 # via -r requirements.in
greenlet==3.0.3 # via eventlet, gevent
h11==0.14.0 # via wsproto
idna==3.6 # via requests
imapclient==3.0.1 # via -r requirements.in
importlib-metadata==7.0.1 # via ax253, kiss3
itsdangerous==2.1.2 # via flask
jinja2==3.1.3 # via click-completion, flask
kiss3==8.0.0 # via -r requirements.in
loguru==0.7.2 # via -r requirements.in
markupsafe==2.1.5 # via jinja2, werkzeug
marshmallow==3.21.1 # via dataclasses-json
mypy-extensions==1.0.0 # via typing-inspect
netaddr==1.2.1 # via oslo-config
oslo-config==9.4.0 # via -r requirements.in
oslo-i18n==6.3.0 # via oslo-config
packaging==23.2 # via marshmallow
pbr==6.0.0 # via -r requirements.in, oslo-i18n, stevedore
pluggy==1.4.0 # via -r requirements.in
plumbum==1.8.2 # via rpyc
pygments==2.17.2 # via rich
pyserial==3.5 # via pyserial-asyncio
pyserial-asyncio==0.6 # via kiss3
python-engineio==4.9.0 # via python-socketio
python-socketio==5.11.1 # via -r requirements.in, flask-socketio
pytz==2024.1 # via -r requirements.in
pyyaml==6.0.1 # via -r requirements.in, oslo-config
requests==2.31.0 # via -r requirements.in, oslo-config, update-checker
rfc3986==2.0.0 # via oslo-config
rich==12.6.0 # via -r requirements.in
rpyc==6.0.0 # via -r requirements.in
rush==2021.4.0 # via -r requirements.in
shellingham==1.5.4 # via -r requirements.in, click-completion
simple-websocket==1.0.0 # via python-engineio
six==1.16.0 # via -r requirements.in, click-completion
soupsieve==2.5 # via beautifulsoup4
stevedore==5.2.0 # via oslo-config
tabulate==0.9.0 # via -r requirements.in
thesmuggler==1.0.1 # via -r requirements.in
typing-extensions==4.10.0 # via typing-inspect
typing-inspect==0.9.0 # via dataclasses-json
update-checker==0.18.0 # via -r requirements.in
urllib3==2.2.1 # via requests
validators==0.22.0 # via click-params
werkzeug==3.0.1 # via -r requirements.in, flask
wrapt==1.16.0 # via -r requirements.in, debtcollector, deprecated
wsproto==1.2.0 # via simple-websocket
zipp==3.17.0 # via importlib-metadata
zope-event==5.0 # via gevent
zope-interface==6.2 # via gevent
# The following packages are considered to be unsafe in a requirements file:
# setuptools

51
setup.cfg Normal file
View File

@ -0,0 +1,51 @@
[metadata]
name = aprsd
long_description = file: README.rst
long_description_content_type = text/x-rst
url = http://aprsd.readthedocs.org
author = Craig Lamparter
author_email = something@somewhere.com
license = Apache
license_file = LICENSE
classifier =
License :: OSI Approved :: Apache Software License
Topic :: Communications :: Ham Radio
Operating System :: POSIX :: Linux
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
description_file =
README.rst
project_urls =
Source=https://github.com/craigerl/aprsd
Tracker=https://github.com/craigerl/aprsd/issues
summary = Amateur radio APRS daemon which listens for messages and responds
[global]
setup-hooks =
pbr.hooks.setup_hook
[files]
packages =
aprsd
[entry_points]
console_scripts =
aprsd = aprsd.main:main
oslo.config.opts =
aprsd.conf = aprsd.conf.opts:list_opts
oslo.config.opts.defaults =
aprsd.conf = aprsd.conf:set_lib_defaults
[build_sphinx]
source-dir = docs
build-dir = docs/_build
all_files = 1
[upload_sphinx]
upload-dir = docs/_build
[bdist_wheel]
universal = 1

View File

@ -17,4 +17,12 @@
import setuptools
setuptools.setup()
# In python < 2.7.4, a lazy loading of package `pbr` will break
# setuptools if some other modules registered functions in `atexit`.
# solution from: http://bugs.python.org/issue15881#msg170215
try:
import multiprocessing # noqa
except ImportError:
pass
setuptools.setup(setup_requires=["pbr"], pbr=True)

View File

@ -51,8 +51,11 @@ class TestSendMessageCommand(unittest.TestCase):
):
self.config_and_init()
mock_socketio.emit = mock.MagicMock()
# Create an ACK packet
packet = fake.fake_ack_packet()
packet = fake.fake_packet(
message="blah",
msg_number=1,
message_format=core.PACKET_TYPE_ACK,
)
mock_queue = mock.MagicMock()
socketio = mock.MagicMock()
wcp = webchat.WebChatProcessPacketThread(mock_queue, socketio)

View File

@ -1,4 +1,4 @@
from aprsd import plugin, threads
from aprsd import packets, plugin, threads
from aprsd.packets import core
@ -13,7 +13,6 @@ def fake_packet(
message=None,
msg_number=None,
message_format=core.PACKET_TYPE_MESSAGE,
response=None,
):
packet_dict = {
"from": fromcall,
@ -28,17 +27,7 @@ def fake_packet(
if msg_number:
packet_dict["msgNo"] = str(msg_number)
if response:
packet_dict["response"] = response
return core.factory(packet_dict)
def fake_ack_packet():
return fake_packet(
msg_number=12,
response=core.PACKET_TYPE_ACK,
)
return packets.Packet.factory(packet_dict)
class FakeBaseNoThreadsPlugin(plugin.APRSDPluginBase):

View File

@ -62,9 +62,9 @@ class TestAPRSDWatchListPluginBase(TestWatchListPlugin):
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.ClientFactory", autospec=True)
@mock.patch("aprsd.client.ClientFactory", autospec=True)
def test_watchlist_not_in_watchlist(self, mock_factory):
client.client_factory = mock_factory
client.factory = mock_factory
self.config_and_init()
plugin = fake.FakeWatchListPlugin()
@ -92,9 +92,9 @@ class TestNotifySeenPlugin(TestWatchListPlugin):
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.ClientFactory", autospec=True)
@mock.patch("aprsd.client.ClientFactory", autospec=True)
def test_callsign_not_in_watchlist(self, mock_factory):
client.client_factory = mock_factory
client.factory = mock_factory
self.config_and_init(watchlist_enabled=False)
plugin = notify_plugin.NotifySeenPlugin()
@ -106,10 +106,10 @@ class TestNotifySeenPlugin(TestWatchListPlugin):
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.ClientFactory", autospec=True)
@mock.patch("aprsd.client.ClientFactory", autospec=True)
@mock.patch("aprsd.packets.WatchList.is_old")
def test_callsign_in_watchlist_not_old(self, mock_is_old, mock_factory):
client.client_factory = mock_factory
client.factory = mock_factory
mock_is_old.return_value = False
self.config_and_init(
watchlist_enabled=True,
@ -126,10 +126,10 @@ class TestNotifySeenPlugin(TestWatchListPlugin):
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.ClientFactory", autospec=True)
@mock.patch("aprsd.client.ClientFactory", autospec=True)
@mock.patch("aprsd.packets.WatchList.is_old")
def test_callsign_in_watchlist_old_same_alert_callsign(self, mock_is_old, mock_factory):
client.client_factory = mock_factory
client.factory = mock_factory
mock_is_old.return_value = True
self.config_and_init(
watchlist_enabled=True,
@ -147,10 +147,10 @@ class TestNotifySeenPlugin(TestWatchListPlugin):
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.ClientFactory", autospec=True)
@mock.patch("aprsd.client.ClientFactory", autospec=True)
@mock.patch("aprsd.packets.WatchList.is_old")
def test_callsign_in_watchlist_old_send_alert(self, mock_is_old, mock_factory):
client.client_factory = mock_factory
client.factory = mock_factory
mock_is_old.return_value = True
notify_callsign = fake.FAKE_TO_CALLSIGN
fromcall = "WB4BOR"

View File

@ -0,0 +1,54 @@
from unittest import mock
from oslo_config import cfg
from aprsd import packets
from aprsd.packets import tracker
from aprsd.plugins import query as query_plugin
from .. import fake, test_plugin
CONF = cfg.CONF
class TestQueryPlugin(test_plugin.TestPlugin):
@mock.patch("aprsd.packets.tracker.PacketTrack.flush")
def test_query_flush(self, mock_flush):
packet = fake.fake_packet(message="!delete")
CONF.callsign = fake.FAKE_TO_CALLSIGN
CONF.save_enabled = True
CONF.query_plugin.callsign = fake.FAKE_FROM_CALLSIGN
query = query_plugin.QueryPlugin()
query.enabled = True
expected = "Deleted ALL pending msgs."
actual = query.filter(packet)
mock_flush.assert_called_once()
self.assertEqual(expected, actual)
@mock.patch("aprsd.packets.tracker.PacketTrack.restart_delayed")
def test_query_restart_delayed(self, mock_restart):
CONF.callsign = fake.FAKE_TO_CALLSIGN
CONF.save_enabled = True
CONF.query_plugin.callsign = fake.FAKE_FROM_CALLSIGN
track = tracker.PacketTrack()
track.data = {}
packet = fake.fake_packet(message="!4")
query = query_plugin.QueryPlugin()
expected = "No pending msgs to resend"
actual = query.filter(packet)
mock_restart.assert_not_called()
self.assertEqual(expected, actual)
mock_restart.reset_mock()
# add a message
pkt = packets.MessagePacket(
from_call=self.fromcall,
to_call="testing",
msgNo=self.ack,
)
track.add(pkt)
actual = query.filter(packet)
mock_restart.assert_called_once()

View File

@ -1,5 +1,3 @@
from unittest import mock
from oslo_config import cfg
import aprsd
@ -13,9 +11,7 @@ CONF = cfg.CONF
class TestVersionPlugin(test_plugin.TestPlugin):
@mock.patch("aprsd.stats.app.APRSDStats.uptime")
def test_version(self, mock_stats):
mock_stats.return_value = "00:00:00"
def test_version(self):
expected = f"APRSD ver:{aprsd.__version__} uptime:00:00:00"
CONF.callsign = fake.FAKE_TO_CALLSIGN
version = version_plugin.VersionPlugin()
@ -35,3 +31,10 @@ class TestVersionPlugin(test_plugin.TestPlugin):
)
actual = version.filter(packet)
self.assertEqual(expected, actual)
packet = fake.fake_packet(
message="Version",
msg_number=1,
)
actual = version.filter(packet)
self.assertEqual(expected, actual)

View File

@ -11,7 +11,7 @@ from .. import fake, test_plugin
CONF = cfg.CONF
class TestUSWeatherPlugin(test_plugin.TestPlugin):
class TestUSWeatherPluginPlugin(test_plugin.TestPlugin):
def test_not_enabled_missing_aprs_fi_key(self):
# When the aprs.fi api key isn't set, then

View File

@ -1,16 +1,13 @@
import unittest
from unittest import mock
import aprslib
from aprslib import util as aprslib_util
from aprsd import packets
from aprsd.packets import core
from . import fake
class TestPacketBase(unittest.TestCase):
class TestPluginBase(unittest.TestCase):
def _fake_dict(
self,
@ -58,7 +55,7 @@ class TestPacketBase(unittest.TestCase):
def test_packet_factory(self):
pkt_dict = self._fake_dict()
pkt = packets.factory(pkt_dict)
pkt = packets.Packet.factory(pkt_dict)
self.assertIsInstance(pkt, packets.MessagePacket)
self.assertEqual(fake.FAKE_FROM_CALLSIGN, pkt.from_call)
@ -74,7 +71,7 @@ class TestPacketBase(unittest.TestCase):
"comment": "Home!",
}
pkt_dict["format"] = core.PACKET_TYPE_UNCOMPRESSED
pkt = packets.factory(pkt_dict)
pkt = packets.Packet.factory(pkt_dict)
self.assertIsInstance(pkt, packets.WeatherPacket)
@mock.patch("aprsd.packets.core.GPSPacket._build_time_zulu")
@ -103,183 +100,3 @@ class TestPacketBase(unittest.TestCase):
wx.prepare()
expected = "KFAKE>KMINE,WIDE1-1,WIDE2-1:@221450z0.0/0.0_000/000g000t000r001p000P000h00b00000"
self.assertEqual(expected, wx.raw)
def test_beacon_factory(self):
"""Test to ensure a beacon packet is created."""
packet_raw = "WB4BOR-12>APZ100,WIDE2-1:@161647z3724.15N107847.58W$ APRSD WebChat"
packet_dict = aprslib.parse(packet_raw)
packet = packets.factory(packet_dict)
self.assertIsInstance(packet, packets.BeaconPacket)
packet_raw = "kd8mey-10>APRS,TCPIP*,qAC,T2SYDNEY:=4247.80N/08539.00WrPHG1210/Making 220 Great Again Allstar# 552191"
packet_dict = aprslib.parse(packet_raw)
packet = packets.factory(packet_dict)
self.assertIsInstance(packet, packets.BeaconPacket)
def test_reject_factory(self):
"""Test to ensure a reject packet is created."""
packet_raw = "HB9FDL-1>APK102,HB9FM-4*,WIDE2,qAR,HB9FEF-11::REPEAT :rej4139"
packet_dict = aprslib.parse(packet_raw)
packet = packets.factory(packet_dict)
self.assertIsInstance(packet, packets.RejectPacket)
self.assertEqual("4139", packet.msgNo)
self.assertEqual("HB9FDL-1", packet.from_call)
self.assertEqual("REPEAT", packet.to_call)
self.assertEqual("reject", packet.packet_type)
self.assertIsNone(packet.payload)
def test_thirdparty_factory(self):
"""Test to ensure a third party packet is created."""
packet_raw = "GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW"
packet_dict = aprslib.parse(packet_raw)
packet = packets.factory(packet_dict)
self.assertIsInstance(packet, packets.ThirdPartyPacket)
def test_weather_factory(self):
"""Test to ensure a weather packet is created."""
packet_raw = "FW9222>APRS,TCPXX*,qAX,CWOP-6:@122025z2953.94N/08423.77W_232/003g006t084r000p032P000h80b10157L745.DsWLL"
packet_dict = aprslib.parse(packet_raw)
packet = packets.factory(packet_dict)
self.assertIsInstance(packet, packets.WeatherPacket)
self.assertEqual(28.88888888888889, packet.temperature)
self.assertEqual(0.0, packet.rain_1h)
self.assertEqual(1015.7, packet.pressure)
self.assertEqual(80, packet.humidity)
self.assertEqual(745, packet.luminosity)
self.assertEqual(3.0, packet.wind_speed)
self.assertEqual(232, packet.wind_direction)
self.assertEqual(6.0, packet.wind_gust)
self.assertEqual(29.899, packet.latitude)
self.assertEqual(-84.39616666666667, packet.longitude)
def test_mice_factory(self):
packet_raw = 'kh2sr-15>S7TSYR,WIDE1-1,WIDE2-1,qAO,KO6KL-1:`1`7\x1c\x1c.#/`"4,}QuirkyQRP 4.6V 35.3C S06'
packet_dict = aprslib.parse(packet_raw)
packet = packets.factory(packet_dict)
self.assertIsInstance(packet, packets.MicEPacket)
# Packet with telemetry and DAO
# http://www.aprs.org/datum.txt
packet_raw = 'KD9YIL>T0PX9W,WIDE1-1,WIDE2-1,qAO,NU9R-10:`sB,l#P>/\'"6+}|#*%U\'a|!whl!|3'
packet_dict = aprslib.parse(packet_raw)
packet = packets.factory(packet_dict)
self.assertIsInstance(packet, packets.MicEPacket)
def test_ack_format(self):
"""Test the ack packet format."""
ack = packets.AckPacket(
from_call=fake.FAKE_FROM_CALLSIGN,
to_call=fake.FAKE_TO_CALLSIGN,
msgNo=123,
)
expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:ack123"
self.assertEqual(expected, str(ack))
def test_reject_format(self):
"""Test the reject packet format."""
reject = packets.RejectPacket(
from_call=fake.FAKE_FROM_CALLSIGN,
to_call=fake.FAKE_TO_CALLSIGN,
msgNo=123,
)
expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:rej123"
self.assertEqual(expected, str(reject))
def test_beacon_format(self):
"""Test the beacon packet format."""
lat = 28.123456
lon = -80.123456
ts = 1711219496.6426
comment = "My Beacon Comment"
packet = packets.BeaconPacket(
from_call=fake.FAKE_FROM_CALLSIGN,
to_call=fake.FAKE_TO_CALLSIGN,
latitude=lat,
longitude=lon,
timestamp=ts,
symbol=">",
comment=comment,
)
expected_lat = aprslib_util.latitude_to_ddm(lat)
expected_lon = aprslib_util.longitude_to_ddm(lon)
expected = f"KFAKE>APZ100:@231844z{expected_lat}/{expected_lon}>{comment}"
self.assertEqual(expected, str(packet))
def test_beacon_format_no_comment(self):
"""Test the beacon packet format."""
lat = 28.123456
lon = -80.123456
ts = 1711219496.6426
packet = packets.BeaconPacket(
from_call=fake.FAKE_FROM_CALLSIGN,
to_call=fake.FAKE_TO_CALLSIGN,
latitude=lat,
longitude=lon,
timestamp=ts,
symbol=">",
)
empty_comment = "APRSD Beacon"
expected_lat = aprslib_util.latitude_to_ddm(lat)
expected_lon = aprslib_util.longitude_to_ddm(lon)
expected = f"KFAKE>APZ100:@231844z{expected_lat}/{expected_lon}>{empty_comment}"
self.assertEqual(expected, str(packet))
def test_bulletin_format(self):
"""Test the bulletin packet format."""
# bulletin id = 0
bid = 0
packet = packets.BulletinPacket(
from_call=fake.FAKE_FROM_CALLSIGN,
message_text="My Bulletin Message",
bid=0,
)
expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::BLN{bid:<9}:{packet.message_text}"
self.assertEqual(expected, str(packet))
# bulletin id = 1
bid = 1
txt = "((((((( CX2SA - Salto Uruguay ))))))) http://www.cx2sa.org"
packet = packets.BulletinPacket(
from_call=fake.FAKE_FROM_CALLSIGN,
message_text=txt,
bid=1,
)
expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::BLN{bid:<9}:{txt}"
self.assertEqual(expected, str(packet))
def test_message_format(self):
"""Test the message packet format."""
message = "My Message"
msgno = "ABX"
packet = packets.MessagePacket(
from_call=fake.FAKE_FROM_CALLSIGN,
to_call=fake.FAKE_TO_CALLSIGN,
message_text=message,
msgNo=msgno,
)
expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:{message}{{{msgno}"
self.assertEqual(expected, str(packet))
# test with bad words
# Currently fails with mixed case
message = "My cunt piss fuck shIt text"
exp_msg = "My **** **** **** **** text"
msgno = "ABX"
packet = packets.MessagePacket(
from_call=fake.FAKE_FROM_CALLSIGN,
to_call=fake.FAKE_TO_CALLSIGN,
message_text=message,
msgNo=msgno,
)
expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:{exp_msg}{{{msgno}"
self.assertEqual(expected, str(packet))

View File

@ -6,7 +6,7 @@ from oslo_config import cfg
from aprsd import conf # noqa: F401
from aprsd import packets
from aprsd import plugin as aprsd_plugin
from aprsd import plugins
from aprsd import plugins, stats
from aprsd.packets import core
from . import fake
@ -45,6 +45,7 @@ class TestPluginManager(unittest.TestCase):
self.assertEqual([], plugin_list)
pm.setup_plugins()
plugin_list = pm.get_plugins()
print(plugin_list)
self.assertIsInstance(plugin_list, list)
self.assertIsInstance(
plugin_list[0],
@ -89,6 +90,7 @@ class TestPlugin(unittest.TestCase):
self.config_and_init()
def tearDown(self) -> None:
stats.APRSDStats._instance = None
packets.WatchList._instance = None
packets.SeenList._instance = None
packets.PacketTrack._instance = None
@ -161,7 +163,9 @@ class TestPluginBase(TestPlugin):
self.assertEqual(expected, actual)
mock_process.assert_not_called()
packet = fake.fake_ack_packet()
packet = fake.fake_packet(
message_format=core.PACKET_TYPE_ACK,
)
expected = packets.NULL_MESSAGE
actual = p.filter(packet)
self.assertEqual(expected, actual)

12
tox.ini
View File

@ -23,7 +23,7 @@ install_command = pip install {opts} {packages}
extras = tests
deps = coverage: coverage
-r{toxinidir}/requirements.txt
-r{toxinidir}/requirements-dev.txt
-r{toxinidir}/dev-requirements.txt
pytestmain: git+https://github.com/pytest-dev/pytest.git@main
commands =
pytest -v --cov-report term-missing --cov=aprsd {posargs}
@ -34,7 +34,7 @@ commands =
skip_install = true
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/requirements-dev.txt
-r{toxinidir}/dev-requirements.txt
{toxinidir}/.
changedir = {toxinidir}/docs
commands =
@ -57,7 +57,7 @@ passenv = FAST8_NUM_COMMITS
[testenv:lint]
skip_install = true
deps =
-r{toxinidir}/requirements-dev.txt
-r{toxinidir}/dev-requirements.txt
commands =
flake8 aprsd tests
@ -85,16 +85,16 @@ python =
# and standard formatting
skip_install = true
deps =
-r{toxinidir}/requirements-dev.txt
-r{toxinidir}/dev-requirements.txt
commands =
gray aprsd tests
[testenv:type-check]
skip_install = true
deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/requirements-dev.txt
-r{toxinidir}/dev-requirements.txt
commands =
mypy --ignore-missing-imports --install-types aprsd
mypy aprsd
[testenv:pre-commit]
skip_install = true