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

Compare commits

...

34 Commits

Author SHA1 Message Date
hemna 49202569a8 Fixed an issue with client.reset
The reset for the aprsd client was accessing the older
delay_connect, which has been renamed to auto_connect.
2025-10-08 08:33:39 -04:00
hemna 9bf4bfd92c Update Changelog for 4.2.1 release 2025-10-07 14:22:59 -04:00
hemna af0feaf9c8 Fixed some unit tests
Fixed unit tests related to the updated static method signatures
of the client and drivers.
2025-10-07 14:18:50 -04:00
hemna 328c027ad3 Some client and driver cleanup.
Fixed the declarations of some of the client and driver methods
to be staticmethod.
2025-10-07 14:11:54 -04:00
hemna e15322ede3 Added package
refactored some package related code to the new
utils/package.py
2025-10-05 16:27:51 -04:00
hemna c7c9a92b15 refactored list-plugins
created new package module that gets information from installed
plugins and extensions so we can print it out to log at startup.

Updated commands to output the the installed extensions and plugins
2025-09-27 17:21:32 -04:00
hemna 3961e1d1ad Added ThirdPartyPacket decoding in tcpkiss driver
This patch adds a check in the tcpkiss driver to
check to see if the packet is a 3rd party packet
and automatically return the subpacket if it is.
2025-09-26 11:55:40 -04:00
hemna 8cd61a72c8 Fixed missing f string
This updates a string output in process_other_packet where
the string being output wasn't prefixed with the f character
to denote that python needed to format the contents with the object.
2025-09-26 11:27:03 -04:00
hemna 556554b1a7 Fixed stats issue with tcpkiss client.
The tcpkiss client's stats method wasn't serializing all of
the datetime objects.  This patch ensures that all the dates
in the stats are serializable when requested.
2025-09-26 10:57:23 -04:00
hemna f3039ebfa1 Added CONF.is_digipi
This will help Craig with custom code changes that might be
needed only on the digipi.  He likes to modify the UI for
webchat.  So in the new webchat extension simply add a call
if CONF.is_digipi:
  # Do your awesome changes here
else:
  # normal aprsd/webchat stuffs here.
2025-09-22 10:52:28 -04:00
hemna 58cb046b31 Sanity check around decoding packet
This adds another sanity try, except block at the
APRSDClient level call for decode_packet, just in
case the driver itself fails in it's decode_packet()
call.
2025-09-22 10:25:31 -04:00
hemna b9fea982f9 Updated Changelog for 4.2.0 2025-08-12 20:59:17 -04:00
hemna 2c476d8a04 Updated requirements for 4.2.0 2025-08-12 19:39:11 -04:00
hemna fa5d0c643a log the exception when tx fails.
If _send_direct fails, we log the exception so we can find out why
it failed to send.
2025-06-17 22:23:06 -04:00
hemna 74887af507 Fix tox failures
With the UnknownPacket
2025-06-17 10:51:42 -04:00
hemna 034f11b4f9 Make sure packet has addressee field
Before dereferencing it in the rx thread.
This covers the case for an Unknown Packet
2025-06-06 17:18:19 -04:00
hemna 1ae437581f Honor quiet setting for log.setup_logging
This updates the setup_logging() to honor the quiet setting.

This means that aprsd won't log to stdout when quiet is passed in
as True.
2025-04-24 20:45:13 -04:00
hemna d41064ba05 Don't log an IOError on shutdown
This updates the consumer loop to only log an IOError if we are not
in the process of shutting down.
2025-04-24 20:44:07 -04:00
hemna b201117e96 Merge pull request #191 from craigerl/client-refactor
Reworked the entire client and drivers
2025-04-23 21:06:13 -04:00
hemna 211ac1132b Updated requirements 2025-04-23 20:59:18 -04:00
hemna acd639a910 Make some catchall fields non hashable.
This makes some of the packet fields non-hashable that
will eventually end up being dicts or lists.
2025-04-23 20:57:16 -04:00
hemna ce79f0112f Remove flask_enabled 2025-04-23 20:52:58 -04:00
hemna 4c53c13e79 Ensure filter is set
Ensure the filter is set when a client reset happens
2025-04-23 20:52:02 -04:00
hemna 5469610779 Fixed a problem with WeatherPacket
WeatherPacket was calling self.filter_for_send, which doesn't
exist.  It's self._filter_for_send.
2025-04-23 20:52:02 -04:00
hemna 1c39546bb9 Reworked the entire client and drivers
This patch includes a completely reworked client structure.
There is now only 1 client object, that loads the appropriate
drivers.  The drivers are fake, aprsis and tcpkiss.

The TCPKISS client was written from scratch to avoid using asyncio.
Asyncion is nothing but a pain in the ass.
2025-04-23 20:52:02 -04:00
hemna 8f471c229c removed old flask_enabled global 2025-04-23 10:06:44 -04:00
hemna 2fcd574f12 Fixed setup for AVWXWeatherPlugin
This ensures that during setup() the plugin sets itself
to enabled if all checks pass.
2025-04-23 09:59:53 -04:00
hemna 44e2898c3f Merge pull request #193 from craigerl/docker-compose
Update the docker-compose.yml
2025-04-22 09:16:37 -04:00
hemna 5a2bf6f3ba Update the docker-compose.yml
This updates the docker-compose.yml example file to show
how to install 2 plugins for the aprsd-server container
and how to deploy the admin interface container.
2025-04-21 15:53:14 -04:00
hemna ad1e62b17c Merge pull request #186 from pheezer/master
Use OpenWeatherMap One Call v3.0 API
2025-03-14 07:52:18 -04:00
Philip Duvall bd83e53838 Merge branch 'master' into master 2025-03-07 22:06:44 -07:00
hemna 6dba56f74d Changelog for 4.1.2 2025-03-06 17:11:57 -05:00
hemna d262589313 Allow passing in a custom handler to setup_logging
This adds the ability to pass in a custom log handler during
startup in the log.setup_logging().
2025-03-06 17:09:40 -05:00
Philip Duvall 16bfda431c use OWM onecall v3.0 2025-03-01 09:29:05 -07:00
56 changed files with 3852 additions and 2490 deletions
+45
View File
@@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [4.2.1](https://github.com/craigerl/aprsd/compare/4.2.0...4.2.1)
> 7 October 2025
- Sanity check around decoding packet [`58cb046`](https://github.com/craigerl/aprsd/commit/58cb046b3131f4300255c527dee4c7291a4aeed2)
- Added CONF.is_digipi [`f3039eb`](https://github.com/craigerl/aprsd/commit/f3039ebfa153d0fbad4a390089fce360e6148854)
- Fixed stats issue with tcpkiss client. [`556554b`](https://github.com/craigerl/aprsd/commit/556554b1a76743989fa88877e7f694b918f770cf)
- Fixed missing f string [`8cd61a7`](https://github.com/craigerl/aprsd/commit/8cd61a72c8f16e8651533baf2d677d3345bd3712)
- Added ThirdPartyPacket decoding in tcpkiss driver [`3961e1d`](https://github.com/craigerl/aprsd/commit/3961e1d1adf428ff0fa66ed5fe0d6192adf164bf)
- refactored list-plugins [`c7c9a92`](https://github.com/craigerl/aprsd/commit/c7c9a92b153ccc673275dca628d485d1df88db12)
- Added package [`e15322e`](https://github.com/craigerl/aprsd/commit/e15322ede384d6e463c42e9496502e2c6ea0886c)
- Some client and driver cleanup. [`328c027`](https://github.com/craigerl/aprsd/commit/328c027ad3db594f3d5622a2cab7cafb21d1cfd6)
- Fixed some unit tests [`af0feaf`](https://github.com/craigerl/aprsd/commit/af0feaf9c81b8b9f443e58fbe7d23f1ff7ba63d2)
#### [4.2.0](https://github.com/craigerl/aprsd/compare/4.1.2...4.2.0)
> 12 August 2025
- Reworked the entire client and drivers [`#191`](https://github.com/craigerl/aprsd/pull/191)
- Update the docker-compose.yml [`#193`](https://github.com/craigerl/aprsd/pull/193)
- Use OpenWeatherMap One Call v3.0 API [`#186`](https://github.com/craigerl/aprsd/pull/186)
- use OWM onecall v3.0 [`16bfda4`](https://github.com/craigerl/aprsd/commit/16bfda431c54a2ec6fbd011edd710c1d787ae1de)
- Fixed a problem with WeatherPacket [`5469610`](https://github.com/craigerl/aprsd/commit/54696107797c3d788e5d0b2dad164e1a7cf439f2)
- Ensure filter is set [`4c53c13`](https://github.com/craigerl/aprsd/commit/4c53c13e793356228f97e26a275d3d4c54b4bf54)
- Remove flask_enabled [`ce79f01`](https://github.com/craigerl/aprsd/commit/ce79f0112ff0f80db8adfff03c9ec8e62655bcc0)
- Make some catchall fields non hashable. [`acd639a`](https://github.com/craigerl/aprsd/commit/acd639a9100893a2e1d08d9ba65ef65ce6ed87e6)
- Fixed setup for AVWXWeatherPlugin [`2fcd574`](https://github.com/craigerl/aprsd/commit/2fcd574f12d48eaec122f96b6b15e9408d395b72)
- removed old flask_enabled global [`8f471c2`](https://github.com/craigerl/aprsd/commit/8f471c229c1073834c2ac66522fbaf2a561e296a)
- Updated requirements [`211ac11`](https://github.com/craigerl/aprsd/commit/211ac1132b2f1e2736b1c680936711e114556e90)
- Don't log an IOError on shutdown [`d41064b`](https://github.com/craigerl/aprsd/commit/d41064ba05faf6167c0d26db2ab525d0bb410bf8)
- Honor quiet setting for log.setup_logging [`1ae4375`](https://github.com/craigerl/aprsd/commit/1ae437581f753d903c20a6ef54868438eb034800)
- Make sure packet has addressee field [`034f11b`](https://github.com/craigerl/aprsd/commit/034f11b4f9f3392e003cfbd0e5f8b9fb5f594089)
- Fix tox failures [`74887af`](https://github.com/craigerl/aprsd/commit/74887af507755cd7ee27f13129b177f4d58ca8e0)
- log the exception when tx fails. [`fa5d0c6`](https://github.com/craigerl/aprsd/commit/fa5d0c643ae85fc9ad3f615f9d0afc590f422283)
- Updated requirements for 4.2.0 [`2c476d8`](https://github.com/craigerl/aprsd/commit/2c476d8a04df8cebbaa50780688e58d8b9df2bc9)
- Updated Changelog for 4.2.0 [`b9fea98`](https://github.com/craigerl/aprsd/commit/b9fea982f977e9cf60c4695ca1892adaeee64298)
#### [4.1.2](https://github.com/craigerl/aprsd/compare/4.1.1...4.1.2)
> 6 March 2025
- Allow passing in a custom handler to setup_logging [`d262589`](https://github.com/craigerl/aprsd/commit/d2625893134f498748859da3b1684b04d456f790)
- Changelog for 4.1.2 [`6dba56f`](https://github.com/craigerl/aprsd/commit/6dba56f74d27e418ed2e124e7a3592c274b2da1e)
#### [4.1.1](https://github.com/craigerl/aprsd/compare/4.1.0...4.1.1)
> 5 March 2025
@@ -12,6 +56,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added threads.service [`c1c89fd`](https://github.com/craigerl/aprsd/commit/c1c89fd2c2c69c5e6c5d29a736a7b89e3d45cfe2)
- Update requirements [`2b185ee`](https://github.com/craigerl/aprsd/commit/2b185ee1b84598c832d8a5d73753cb428854b932)
- Fixed some more ruff checks [`94ba915`](https://github.com/craigerl/aprsd/commit/94ba915ed44b11eaabc885e033669d67d8c341a5)
- 4.1.1 release [`7ed8028`](https://github.com/craigerl/aprsd/commit/7ed80283071c1ccebf1e3373727608edd0a56ee9)
#### [4.1.0](https://github.com/craigerl/aprsd/compare/4.0.2...4.1.0)
+5 -13
View File
@@ -1,13 +1,5 @@
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)
# define the client transports here
TRANSPORT_APRSIS = 'aprsis'
TRANSPORT_TCPKISS = 'tcpkiss'
TRANSPORT_SERIALKISS = 'serialkiss'
TRANSPORT_FAKE = 'fake'
-183
View File
@@ -1,183 +0,0 @@
import datetime
import logging
import time
import timeago
from aprslib.exceptions import LoginError
from loguru import logger
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")
LOGU = logger
class APRSISClient(base.APRSClient):
_client = None
_checks = False
def __init__(self):
max_timeout = {"hours": 0.0, "minutes": 2, "seconds": 0}
self.max_delta = datetime.timedelta(**max_timeout)
def stats(self, serializable=False) -> dict:
stats = {}
if self.is_configured():
if self._client:
keepalive = self._client.aprsd_keepalive
server_string = self._client.server_string
if serializable:
keepalive = keepalive.isoformat()
else:
keepalive = "None"
server_string = "None"
stats = {
"connected": self.is_connected,
"filter": self.filter,
"login_status": self.login_status,
"connection_keepalive": keepalive,
"server_string": server_string,
"transport": self.transport(),
}
return stats
def keepalive_check(self):
# Don't check the first time through.
if not self.is_alive() and self._checks:
LOG.warning("Resetting client. It's not alive.")
self.reset()
self._checks = True
def keepalive_log(self):
if ka := self._client.aprsd_keepalive:
keepalive = timeago.format(ka)
else:
keepalive = "N/A"
LOGU.opt(colors=True).info(f"<green>Client keepalive {keepalive}</green>")
@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
return False
def is_alive(self):
if not self._client:
LOG.warning(f"APRS_CLIENT {self._client} alive? NO!!!")
return False
return self._client.is_alive() and not self._is_stale_connection()
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
retries = 3
retry_count = 0
while not self.connected:
retry_count += 1
if retry_count >= retries:
break
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 = self.login_status["success"] = True
self.login_status["message"] = aprs_client.server_string
backoff = 1
except LoginError as e:
LOG.error(f"Failed to login to APRS-IS Server '{e}'")
self.connected = self.login_status["success"] = False
self.login_status["message"] = e.message
LOG.error(e.message)
time.sleep(backoff)
except Exception as e:
LOG.error(f"Unable to connect to APRS-IS server. '{e}' ")
self.connected = self.login_status["success"] = False
self.login_status["message"] = e.message
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):
if self._client:
try:
self._client.consumer(
callback,
blocking=blocking,
immortal=immortal,
raw=raw,
)
except Exception as e:
LOG.error(e)
LOG.info(e.__cause__)
raise e
else:
LOG.warning("client is None, might be resetting.")
self.connected = False
-156
View File
@@ -1,156 +0,0 @@
import abc
import logging
import threading
import wrapt
from oslo_config import cfg
from aprsd.packets import core
from aprsd.utils import keepalive_collector
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
class APRSClient:
"""Singleton client class that constructs the aprslib connection."""
_instance = None
_client = None
connected = False
login_status = {
'success': False,
'message': None,
}
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)
keepalive_collector.KeepAliveCollector().register(cls)
# Put any initialization here.
cls._instance._create_client()
return cls._instance
@abc.abstractmethod
def stats(self) -> dict:
"""Return statistics about the client connection.
Returns:
dict: Statistics about the connection and packet handling
"""
@abc.abstractmethod
def keepalive_check(self) -> None:
"""Called during keepalive run to check status."""
...
@abc.abstractmethod
def keepalive_log(self) -> None:
"""Log any keepalive information."""
...
@property
def is_connected(self):
return self.connected
@property
def login_success(self):
return self.login_status.get('success', False)
@property
def login_failure(self):
return self.login_status['message']
def set_filter(self, filter):
self.filter = filter
if self._client:
self._client.set_filter(filter)
def get_filter(self):
return self.filter
@property
def client(self):
if not self._client:
self._create_client()
return self._client
def _create_client(self):
try:
self._client = self.setup_connection()
if self.filter:
LOG.info('Creating APRS client filter')
self._client.set_filter(self.filter)
except Exception as e:
LOG.error(f'Failed to create APRS client: {e}')
self._client = None
raise
def stop(self):
if self._client:
LOG.info('Stopping client connection.')
self._client.stop()
def send(self, packet: core.Packet) -> None:
"""Send a packet to the network.
Args:
packet: The APRS packet to send
"""
self.client.send(packet)
@wrapt.synchronized(lock)
def reset(self) -> None:
"""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):
"""Initialize and return the underlying APRS connection.
Returns:
object: The initialized connection object
"""
@staticmethod
@abc.abstractmethod
def is_enabled():
pass
@staticmethod
@abc.abstractmethod
def transport():
pass
@abc.abstractmethod
def decode_packet(self, *args, **kwargs):
"""Decode raw APRS packet data into a Packet object.
Returns:
Packet: Decoded APRS packet
"""
@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
+156
View File
@@ -0,0 +1,156 @@
import logging
import threading
from typing import Callable
import timeago
import wrapt
from loguru import logger
from oslo_config import cfg
from aprsd.client import drivers # noqa - ensure drivers are registered
from aprsd.client.drivers.registry import DriverRegistry
from aprsd.packets import core
from aprsd.utils import keepalive_collector
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
LOGU = logger
class APRSDClient:
"""APRSD client class.
This is a singleton class that provides a single instance of the APRSD client.
It is responsible for connecting to the appropriate APRSD client driver based on
the configuration.
"""
_instance = None
driver = None
lock = threading.Lock()
filter = None
def __new__(cls, *args, **kwargs):
"""This magic turns this into a singleton."""
if cls._instance is None:
cls._instance = super().__new__(cls)
keepalive_collector.KeepAliveCollector().register(cls)
return cls._instance
def __init__(self, auto_connect: bool = True):
self.auto_connect = auto_connect
self.connected = False
self.login_status = {
'success': False,
'message': None,
}
self.driver = DriverRegistry().get_driver()
if self.auto_connect:
self.connect()
def stats(self, serializable=False) -> dict:
stats = {}
if self.driver:
stats = self.driver.stats(serializable=serializable)
return stats
@staticmethod
def is_enabled():
for driver in DriverRegistry().drivers:
if driver.is_enabled():
return True
return False
@staticmethod
def is_configured():
"""Check if ANY driver is configured."""
for driver in DriverRegistry().drivers:
if driver.is_configured():
return True
return False
# @property
# def is_connected(self):
# if not self.driver:
# return False
# return self.driver.is_connected()
@property
def login_success(self):
if not self.driver:
return False
return self.driver.login_success
@property
def login_failure(self):
if not self.driver:
return None
return self.driver.login_failure
def set_filter(self, filter):
self.filter = filter
if not self.driver:
return
self.driver.set_filter(filter)
def get_filter(self):
if not self.driver:
return None
return self.driver.filter
def is_alive(self):
return self.driver.is_alive()
def connect(self):
if not self.driver:
self.driver = DriverRegistry().get_driver()
self.driver.setup_connection()
def close(self):
if not self.driver:
return
self.driver.close()
@wrapt.synchronized(lock)
def reset(self):
"""Call this to force a rebuild/reconnect."""
LOG.info('Resetting client connection.')
if self.driver:
self.driver.close()
if self.auto_connect:
self.driver.setup_connection()
if self.filter:
self.driver.set_filter(self.filter)
else:
LOG.warning('Client not initialized, nothing to reset.')
def send(self, packet: core.Packet) -> bool:
return self.driver.send(packet)
# For the keepalive collector
def keepalive_check(self):
# Don't check the first time through.
if not self.driver.is_alive and self._checks:
LOG.warning("Resetting client. It's not alive.")
self.reset()
self._checks = True
# For the keepalive collector
def keepalive_log(self):
if ka := self.driver.keepalive:
keepalive = timeago.format(ka)
else:
keepalive = 'N/A'
LOGU.opt(colors=True).info(f'<green>Client keepalive {keepalive}</green>')
def consumer(self, callback: Callable, raw: bool = False):
return self.driver.consumer(callback=callback, raw=raw)
def decode_packet(self, *args, **kwargs) -> core.Packet:
try:
packet = self.driver.decode_packet(*args, **kwargs)
except Exception as e:
LOG.error(f'Error decoding packet: {e}')
return None
return packet
+10
View File
@@ -0,0 +1,10 @@
# All client drivers must be registered here
from aprsd.client.drivers.aprsis import APRSISDriver
from aprsd.client.drivers.fake import APRSDFakeDriver
from aprsd.client.drivers.registry import DriverRegistry
from aprsd.client.drivers.tcpkiss import TCPKISSDriver
driver_registry = DriverRegistry()
driver_registry.register(APRSDFakeDriver)
driver_registry.register(APRSISDriver)
driver_registry.register(TCPKISSDriver)
+185 -266
View File
@@ -1,286 +1,205 @@
import datetime
import logging
import select
import socket
import threading
import time
from typing import Callable
import aprslib
import wrapt
from aprslib import is_py3
from aprslib.exceptions import (
ConnectionDrop,
ConnectionError,
GenericError,
LoginError,
ParseError,
UnknownFormat,
)
from aprslib.exceptions import LoginError
from loguru import logger
from oslo_config import cfg
import aprsd
from aprsd import client, exception
from aprsd.client.drivers.lib.aprslib import APRSLibClient
from aprsd.packets import core
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
LOGU = logger
class Aprsdis(aprslib.IS):
"""Extend the aprslib class so we can exit properly."""
# class APRSISDriver(metaclass=trace.TraceWrapperMetaclass):
class APRSISDriver:
"""This is the APRS-IS driver for the APRSD client.
# flag to tell us to stop
thread_stop = False
This driver uses our modified aprslib.IS class to connect to the APRS-IS server.
# date for last time we heard from the server
aprsd_keepalive = datetime.datetime.now()
"""
# Which server we are connected to?
server_string = 'None'
_client = None
_checks = False
connected = False
# timeout in seconds
select_timeout = 1
lock = threading.Lock()
def __init__(self):
max_timeout = {'hours': 0.0, 'minutes': 2, 'seconds': 0}
self.max_delta = datetime.timedelta(**max_timeout)
self.login_status = {
'success': False,
'message': None,
}
def stop(self):
self.thread_stop = True
LOG.warning('Shutdown Aprsdis client.')
@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 APRSISDriver.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
@property
def is_alive(self):
if not self._client:
LOG.warning(f'APRS_CLIENT {self._client} alive? NO!!!')
return False
return self._client.is_alive() and not self._is_stale_connection()
def close(self):
LOG.warning('Closing Aprsdis client.')
super().close()
if self._client:
self._client.stop()
self._client.close()
@wrapt.synchronized(lock)
def send(self, packet: core.Packet):
"""Send an APRS Message object."""
self.sendall(packet.raw)
def send(self, packet: core.Packet) -> bool:
return self._client.send(packet)
def is_alive(self):
"""If the connection is alive or not."""
return self._connected
def _connect(self):
"""
Attemps connection to the server
"""
self.logger.info(
'Attempting connection to %s:%s', self.server[0], self.server[1]
)
try:
self._open_socket()
peer = self.sock.getpeername()
self.logger.info('Connected to %s', str(peer))
# 5 second timeout to receive server banner
self.sock.setblocking(1)
self.sock.settimeout(5)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# MACOS doesn't have TCP_KEEPIDLE
if hasattr(socket, 'TCP_KEEPIDLE'):
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1)
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3)
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)
banner = self.sock.recv(512)
if is_py3:
banner = banner.decode('latin-1')
if banner[0] == '#':
self.logger.debug('Banner: %s', banner.rstrip())
else:
raise ConnectionError('invalid banner from server')
except ConnectionError as e:
self.logger.error(str(e))
self.close()
raise
except (socket.error, socket.timeout) as e:
self.close()
self.logger.error('Socket error: %s' % str(e))
if str(e) == 'timed out':
raise ConnectionError('no banner from server') from e
else:
raise ConnectionError(e) from e
self._connected = True
def _socket_readlines(self, blocking=False):
"""
Generator for complete lines, received from the server
"""
try:
self.sock.setblocking(0)
except OSError as e:
self.logger.error(f'socket error when setblocking(0): {str(e)}')
raise aprslib.ConnectionDrop('connection dropped') from e
while not self.thread_stop:
short_buf = b''
newline = b'\r\n'
# set a select timeout, so we get a chance to exit
# when user hits CTRL-C
readable, writable, exceptional = select.select(
[self.sock],
[],
[],
self.select_timeout,
)
if not readable:
if not blocking:
break
else:
continue
try:
short_buf = self.sock.recv(4096)
# sock.recv returns empty if the connection drops
if not short_buf:
if not blocking:
# We could just not be blocking, so empty is expected
continue
else:
self.logger.error('socket.recv(): returned empty')
raise aprslib.ConnectionDrop('connection dropped')
except OSError as e:
# self.logger.error("socket error on recv(): %s" % str(e))
if 'Resource temporarily unavailable' in str(e):
if not blocking:
if len(self.buf) == 0:
break
self.buf += short_buf
while newline in self.buf:
line, self.buf = self.buf.split(newline, 1)
yield line
def _send_login(self):
"""
Sends login string to server
"""
login_str = 'user {0} pass {1} vers Python-APRSD {3}{2}\r\n'
login_str = login_str.format(
self.callsign,
self.passwd,
(' filter ' + self.filter) if self.filter != '' else '',
aprsd.__version__,
)
self.logger.debug('Sending login information')
try:
self._sendall(login_str)
self.sock.settimeout(5)
test = self.sock.recv(len(login_str) + 100)
if is_py3:
test = test.decode('latin-1')
test = test.rstrip()
self.logger.debug("Server: '%s'", test)
if not test:
raise LoginError(f"Server Response Empty: '{test}'")
_, _, callsign, status, e = test.split(' ', 4)
s = e.split(',')
if len(s):
server_string = s[0].replace('server ', '')
else:
server_string = e.replace('server ', '')
if callsign == '':
raise LoginError('Server responded with empty callsign???')
if callsign != self.callsign:
raise LoginError(f'Server: {test}')
if status != 'verified,' and self.passwd != '-1':
raise LoginError('Password is incorrect')
if self.passwd == '-1':
self.logger.info('Login successful (receive only)')
else:
self.logger.info('Login successful')
self.logger.info(f'Connected to {server_string}')
self.server_string = server_string
except LoginError as e:
self.logger.error(str(e))
self.close()
raise
except Exception as e:
self.close()
self.logger.error(f"Failed to login '{e}'")
self.logger.exception(e)
raise LoginError('Failed to login') from e
def consumer(self, callback, blocking=True, immortal=False, raw=False):
"""
When a position sentence is received, it will be passed to the callback function
blocking: if true (default), runs forever, otherwise will return after one sentence
You can still exit the loop, by raising StopIteration in the callback function
immortal: When true, consumer will try to reconnect and stop propagation of Parse exceptions
if false (default), consumer will return
raw: when true, raw packet is passed to callback, otherwise the result from aprs.parse()
"""
if not self._connected:
raise ConnectionError('not connected to a server')
line = b''
while True and not self.thread_stop:
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()
except ParseError as exp:
self.logger.log(
11,
"%s Packet: '%s'",
exp,
exp.packet,
)
except UnknownFormat as exp:
self.logger.log(
9,
"%s Packet: '%s'",
exp,
exp.packet,
)
except LoginError as exp:
self.logger.error('%s: %s', exp.__class__.__name__, exp)
except (KeyboardInterrupt, SystemExit):
raise
except (ConnectionDrop, ConnectionError):
self.close()
if not immortal:
raise
else:
self.connect(blocking=blocking)
continue
except GenericError:
pass
except StopIteration:
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
retries = 3
retry_count = 0
while not self.connected:
retry_count += 1
if retry_count >= retries:
break
except Exception:
self.logger.error('APRS Packet: %s', line)
raise
try:
LOG.info(
f'Creating aprslib client({host}:{port}) and logging in {user}.'
)
self._client = APRSLibClient(
user, passwd=password, host=host, port=port
)
# Force the log to be the same
self._client.logger = LOG
self._client.connect()
self.connected = self.login_status['success'] = True
self.login_status['message'] = self._client.server_string
backoff = 1
except LoginError as e:
LOG.error(f"Failed to login to APRS-IS Server '{e}'")
self.connected = self.login_status['success'] = False
self.login_status['message'] = (
e.message if hasattr(e, 'message') else str(e)
)
LOG.error(self.login_status['message'])
time.sleep(backoff)
except Exception as e:
LOG.error(f"Unable to connect to APRS-IS server. '{e}' ")
self.connected = self.login_status['success'] = False
self.login_status['message'] = getattr(e, 'message', str(e))
time.sleep(backoff)
# Don't allow the backoff to go to inifinity.
if backoff > 5:
backoff = 5
else:
backoff += 1
continue
if not blocking:
break
def set_filter(self, filter):
self._client.set_filter(filter)
def login_success(self) -> bool:
return self.login_status.get('success', False)
def login_failure(self) -> str:
return self.login_status.get('message', None)
@property
def filter(self):
return self._client.filter
@property
def server_string(self):
return self._client.server_string
@property
def keepalive(self):
return self._client.aprsd_keepalive
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
return False
@staticmethod
def transport():
return client.TRANSPORT_APRSIS
def decode_packet(self, *args, **kwargs):
"""APRS lib already decodes this."""
return core.factory(args[0])
def consumer(self, callback: Callable, raw: bool = False):
if self._client and self.connected:
try:
self._client.consumer(
callback,
blocking=False,
immortal=False,
raw=raw,
)
except Exception as e:
LOG.error(e)
LOG.info(e.__cause__)
raise e
else:
self.connected = False
def stats(self, serializable: bool = False) -> dict:
stats = {}
if self.is_configured():
if self._client:
keepalive = self._client.aprsd_keepalive
server_string = self._client.server_string
if serializable:
keepalive = keepalive.isoformat()
filter = self.filter
else:
keepalive = 'None'
server_string = 'None'
filter = 'None'
stats = {
'connected': self.is_alive,
'filter': filter,
'login_status': self.login_status,
'connection_keepalive': keepalive,
'server_string': server_string,
'transport': self.transport(),
}
return stats
+59 -11
View File
@@ -2,6 +2,7 @@ import datetime
import logging
import threading
import time
from typing import Callable
import aprslib
import wrapt
@@ -15,7 +16,7 @@ CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
class APRSDFakeClient(metaclass=trace.TraceWrapperMetaclass):
class APRSDFakeDriver(metaclass=trace.TraceWrapperMetaclass):
"""Fake client for testing."""
# flag to tell us to stop
@@ -28,17 +29,40 @@ class APRSDFakeClient(metaclass=trace.TraceWrapperMetaclass):
path = []
def __init__(self):
LOG.info('Starting APRSDFakeClient client.')
LOG.info('Starting APRSDFakeDriver driver.')
self.path = ['WIDE1-1', 'WIDE2-1']
def stop(self):
self.thread_stop = True
LOG.info('Shutdown APRSDFakeClient client.')
@staticmethod
def is_enabled():
if CONF.fake_client.enabled:
return True
return False
@staticmethod
def is_configured():
return APRSDFakeDriver.is_enabled
def is_alive(self):
"""If the connection is alive or not."""
return not self.thread_stop
def close(self):
self.thread_stop = True
LOG.info('Shutdown APRSDFakeDriver driver.')
def setup_connection(self):
# It's fake....
pass
def set_filter(self, filter: str) -> None:
pass
def login_success(self) -> bool:
return True
def login_failure(self) -> str:
return None
@wrapt.synchronized(lock)
def send(self, packet: core.Packet):
"""Send an APRS Message object."""
@@ -61,13 +85,37 @@ class APRSDFakeClient(metaclass=trace.TraceWrapperMetaclass):
f'\'{packet.from_call}\' with PATH "{self.path}"',
)
def consumer(self, callback, blocking=False, immortal=False, raw=False):
def consumer(self, callback: Callable, raw: bool = False):
LOG.debug('Start non blocking FAKE consumer')
# 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)
raw_str = 'GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW'
self.aprsd_keepalive = datetime.datetime.now()
callback(packet=pkt)
if raw:
callback(raw=raw_str)
else:
pkt_raw = aprslib.parse(raw_str)
pkt = core.factory(pkt_raw)
callback(packet=pkt)
LOG.debug(f'END blocking FAKE consumer {self}')
time.sleep(8)
time.sleep(1)
def decode_packet(self, *args, **kwargs):
"""APRS lib already decodes this."""
if not kwargs:
return None
if kwargs.get('packet'):
return kwargs.get('packet')
if kwargs.get('raw'):
pkt_raw = aprslib.parse(kwargs.get('raw'))
pkt = core.factory(pkt_raw)
return pkt
def stats(self, serializable: bool = False) -> dict:
return {
'driver': self.__class__.__name__,
'is_alive': self.is_alive(),
'transport': 'fake',
}
-144
View File
@@ -1,144 +0,0 @@
import datetime
import logging
import kiss
from ax253 import Frame
from oslo_config import cfg
from aprsd import conf # noqa
from aprsd.packets import core
from aprsd.utils import trace
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
class KISS3Client:
path = []
# date for last time we heard from the server
aprsd_keepalive = datetime.datetime.now()
_connected = False
def __init__(self):
self.setup()
def is_alive(self):
return self._connected
def setup(self):
# we can be TCP kiss or Serial kiss
if CONF.kiss_serial.enabled:
LOG.debug(
'KISS({}) Serial connection to {}'.format(
kiss.__version__,
CONF.kiss_serial.device,
),
)
self.kiss = kiss.SerialKISS(
port=CONF.kiss_serial.device,
speed=CONF.kiss_serial.baudrate,
strip_df_start=True,
)
self.path = CONF.kiss_serial.path
elif CONF.kiss_tcp.enabled:
LOG.debug(
'KISS({}) TCP Connection to {}:{}'.format(
kiss.__version__,
CONF.kiss_tcp.host,
CONF.kiss_tcp.port,
),
)
self.kiss = kiss.TCPKISS(
host=CONF.kiss_tcp.host,
port=CONF.kiss_tcp.port,
strip_df_start=True,
)
self.path = CONF.kiss_tcp.path
LOG.debug('Starting KISS interface connection')
try:
self.kiss.start()
if self.kiss.protocol.transport.is_closing():
LOG.warning('KISS transport is closing, not setting consumer callback')
self._connected = False
else:
self._connected = True
except Exception:
LOG.error('Failed to start KISS interface.')
self._connected = False
@trace.trace
def stop(self):
if not self._connected:
# do nothing since we aren't connected
return
try:
self.kiss.stop()
self.kiss.loop.call_soon_threadsafe(
self.kiss.protocol.transport.close,
)
except Exception:
LOG.error('Failed to stop KISS interface.')
def close(self):
self.stop()
def set_filter(self, filter):
# This does nothing right now.
pass
def parse_frame(self, frame_bytes):
try:
frame = Frame.from_bytes(frame_bytes)
# Now parse it with aprslib
kwargs = {
'frame': frame,
}
self._parse_callback(**kwargs)
self.aprsd_keepalive = datetime.datetime.now()
except Exception as ex:
LOG.error('Failed to parse bytes received from KISS interface.')
LOG.exception(ex)
def consumer(self, callback):
if not self._connected:
raise Exception('KISS transport is not connected')
self._parse_callback = callback
if not self.kiss.protocol.transport.is_closing():
self.kiss.read(callback=self.parse_frame, min_frames=1)
else:
self._connected = False
def send(self, packet):
"""Send an APRS Message object."""
payload = None
path = self.path
if isinstance(packet, core.Packet):
packet.prepare()
payload = packet.payload.encode('US-ASCII')
if packet.path:
path = packet.path
else:
msg_payload = f'{packet.raw}{{{str(packet.msgNo)}'
payload = (
':{:<9}:{}'.format(
packet.to_call,
msg_payload,
)
).encode('US-ASCII')
LOG.debug(
f"KISS Send '{payload}' TO '{packet.to_call}' From "
f"'{packet.from_call}' with PATH '{path}'",
)
frame = Frame.ui(
destination='APZ100',
source=packet.from_call,
path=path,
info=payload,
)
self.kiss.write(frame)
+296
View File
@@ -0,0 +1,296 @@
import datetime
import logging
import select
import socket
import threading
import aprslib
import wrapt
from aprslib import is_py3
from aprslib.exceptions import (
ConnectionDrop,
ConnectionError,
GenericError,
LoginError,
ParseError,
UnknownFormat,
)
import aprsd
from aprsd.packets import core
LOG = logging.getLogger('APRSD')
class APRSLibClient(aprslib.IS):
"""Extend the aprslib class so we can exit properly.
This is a modified version of the aprslib.IS class that adds a stop method to
allow the client to exit cleanly.
The aprsis driver uses this class to connect to the APRS-IS server.
"""
# flag to tell us to stop
thread_stop = False
# date for last time we heard from the server
aprsd_keepalive = datetime.datetime.now()
# Which server we are connected to?
server_string = 'None'
# timeout in seconds
select_timeout = 1
lock = threading.Lock()
def stop(self):
self.thread_stop = True
LOG.warning('Shutdown Aprsdis client.')
def close(self):
LOG.warning('Closing Aprsdis client.')
super().close()
@wrapt.synchronized(lock)
def send(self, packet: core.Packet):
"""Send an APRS Message object."""
self.sendall(packet.raw)
def is_alive(self):
"""If the connection is alive or not."""
return self._connected
def _connect(self):
"""
Attemps connection to the server
"""
self.logger.info(
'Attempting connection to %s:%s', self.server[0], self.server[1]
)
try:
self._open_socket()
peer = self.sock.getpeername()
self.logger.info('Connected to %s', str(peer))
# 5 second timeout to receive server banner
self.sock.setblocking(1)
self.sock.settimeout(5)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# MACOS doesn't have TCP_KEEPIDLE
if hasattr(socket, 'TCP_KEEPIDLE'):
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1)
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3)
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)
banner = self.sock.recv(512)
if is_py3:
banner = banner.decode('latin-1')
if banner[0] == '#':
self.logger.debug('Banner: %s', banner.rstrip())
else:
raise ConnectionError('invalid banner from server')
except ConnectionError as e:
self.logger.error(str(e))
self.close()
raise
except (socket.error, socket.timeout) as e:
self.close()
self.logger.error('Socket error: %s' % str(e))
if str(e) == 'timed out':
raise ConnectionError('no banner from server') from e
else:
raise ConnectionError(e) from e
self._connected = True
def _socket_readlines(self, blocking=False):
"""
Generator for complete lines, received from the server
"""
try:
self.sock.setblocking(0)
except OSError as e:
self.logger.error(f'socket error when setblocking(0): {str(e)}')
raise aprslib.ConnectionDrop('connection dropped') from e
while not self.thread_stop:
short_buf = b''
newline = b'\r\n'
# set a select timeout, so we get a chance to exit
# when user hits CTRL-C
readable, writable, exceptional = select.select(
[self.sock],
[],
[],
self.select_timeout,
)
if not readable:
if not blocking:
break
else:
continue
try:
short_buf = self.sock.recv(4096)
# sock.recv returns empty if the connection drops
if not short_buf:
if not blocking:
# We could just not be blocking, so empty is expected
continue
else:
self.logger.error('socket.recv(): returned empty')
raise aprslib.ConnectionDrop('connection dropped')
except OSError as e:
# self.logger.error("socket error on recv(): %s" % str(e))
if 'Resource temporarily unavailable' in str(e):
if not blocking:
if len(self.buf) == 0:
break
self.buf += short_buf
while newline in self.buf:
line, self.buf = self.buf.split(newline, 1)
yield line
def _send_login(self):
"""
Sends login string to server
"""
login_str = 'user {0} pass {1} vers Python-APRSD {3}{2}\r\n'
login_str = login_str.format(
self.callsign,
self.passwd,
(' filter ' + self.filter) if self.filter != '' else '',
aprsd.__version__,
)
self.logger.debug('Sending login information')
try:
self._sendall(login_str)
self.sock.settimeout(5)
test = self.sock.recv(len(login_str) + 100)
if is_py3:
test = test.decode('latin-1')
test = test.rstrip()
self.logger.debug("Server: '%s'", test)
if not test:
raise LoginError(f"Server Response Empty: '{test}'")
_, _, callsign, status, e = test.split(' ', 4)
s = e.split(',')
if len(s):
server_string = s[0].replace('server ', '')
else:
server_string = e.replace('server ', '')
if callsign == '':
raise LoginError('Server responded with empty callsign???')
if callsign != self.callsign:
raise LoginError(f'Server: {test}')
if status != 'verified,' and self.passwd != '-1':
raise LoginError('Password is incorrect')
if self.passwd == '-1':
self.logger.info('Login successful (receive only)')
else:
self.logger.info('Login successful')
self.logger.info(f'Connected to {server_string}')
self.server_string = server_string
except LoginError as e:
self.logger.error(str(e))
self.close()
raise
except Exception as e:
self.close()
self.logger.error(f"Failed to login '{e}'")
self.logger.exception(e)
raise LoginError('Failed to login') from e
def consumer(self, callback, blocking=True, immortal=False, raw=False):
"""
When a position sentence is received, it will be passed to the callback function
blocking: if true (default), runs forever, otherwise will return after one sentence
You can still exit the loop, by raising StopIteration in the callback function
immortal: When true, consumer will try to reconnect and stop propagation of Parse exceptions
if false (default), consumer will return
raw: when true, raw packet is passed to callback, otherwise the result from aprs.parse()
"""
if not self._connected:
raise ConnectionError('not connected to a server')
line = b''
while not self.thread_stop:
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()
except ParseError as exp:
self.logger.log(
11,
"%s Packet: '%s'",
exp,
exp.packet,
)
except UnknownFormat as exp:
self.logger.log(
9,
"%s Packet: '%s'",
exp,
exp.packet,
)
except LoginError as exp:
self.logger.error('%s: %s', exp.__class__.__name__, exp)
except (KeyboardInterrupt, SystemExit):
raise
except (ConnectionDrop, ConnectionError):
self.close()
if not immortal:
raise
else:
self.connect(blocking=blocking)
continue
except GenericError:
pass
except StopIteration:
break
except IOError:
if not self.thread_stop:
self.logger.error('IOError')
break
except Exception:
self.logger.error('APRS Packet: %s', line)
raise
if not blocking:
break
+86
View File
@@ -0,0 +1,86 @@
from typing import Callable, Protocol, runtime_checkable
from aprsd.packets import core
from aprsd.utils import singleton, trace
@runtime_checkable
class ClientDriver(Protocol):
"""Protocol for APRSD client drivers.
This protocol defines the methods that must be
implemented by APRSD client drivers.
"""
@staticmethod
def is_enabled(self) -> bool:
pass
@staticmethod
def is_configured(self) -> bool:
pass
def is_alive(self) -> bool:
pass
def close(self) -> None:
pass
def send(self, packet: core.Packet) -> bool:
pass
def setup_connection(self) -> None:
pass
def set_filter(self, filter: str) -> None:
pass
def login_success(self) -> bool:
pass
def login_failure(self) -> str:
pass
def consumer(self, callback: Callable, raw: bool = False) -> None:
pass
def decode_packet(self, *args, **kwargs) -> core.Packet:
pass
def stats(self, serializable: bool = False) -> dict:
pass
@singleton
class DriverRegistry(metaclass=trace.TraceWrapperMetaclass):
"""Registry for APRSD client drivers.
This registry is used to register and unregister APRSD client drivers.
This allows us to dynamically load the configured driver at runtime.
All drivers are registered, then when aprsd needs the client, the
registry provides the configured driver for the single instance of the
single APRSD client.
"""
def __init__(self):
self.drivers = []
def register(self, driver: Callable):
if not isinstance(driver, ClientDriver):
raise ValueError('Driver must be of ClientDriver type')
self.drivers.append(driver)
def unregister(self, driver: Callable):
if driver in self.drivers:
self.drivers.remove(driver)
else:
raise ValueError(f'Driver {driver} not found')
def get_driver(self) -> ClientDriver:
"""Get the first enabled driver."""
for driver in self.drivers:
if driver.is_enabled() and driver.is_configured():
return driver()
raise ValueError('No enabled driver found')
+423
View File
@@ -0,0 +1,423 @@
"""
APRSD KISS Client Driver using native KISS implementation.
This module provides a KISS client driver for APRSD using the new
non-asyncio KISSInterface implementation.
"""
import datetime
import logging
import select
import socket
import time
from typing import Any, Callable, Dict
import aprslib
from ax253 import frame as ax25frame
from kiss import constants as kiss_constants
from kiss import util as kissutil
from kiss.kiss import Command
from oslo_config import cfg
from aprsd import ( # noqa
client,
conf, # noqa
exception,
)
from aprsd.packets import core
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
def handle_fend(buffer: bytes, strip_df_start: bool = True) -> bytes:
"""
Handle FEND (end of frame) encountered in a KISS data stream.
:param buffer: the buffer containing the frame
:param strip_df_start: remove leading null byte (DATA_FRAME opcode)
:return: the bytes of the frame without escape characters or frame
end markers (FEND)
"""
frame = kissutil.recover_special_codes(kissutil.strip_nmea(bytes(buffer)))
if strip_df_start:
frame = kissutil.strip_df_start(frame)
LOG.warning(f'handle_fend {" ".join(f"{b:02X}" for b in bytes(frame))}')
return bytes(frame)
# class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass):
class TCPKISSDriver:
"""APRSD client driver for TCP KISS connections."""
# Class level attributes required by Client protocol
packets_received = 0
packets_sent = 0
last_packet_sent = None
last_packet_received = None
keepalive = None
client_name = None
socket = None
# timeout in seconds
select_timeout = 1
path = None
def __init__(self):
"""Initialize the KISS client.
Args:
client_name: Name of the client instance
"""
super().__init__()
self._connected = False
self.keepalive = datetime.datetime.now()
self._running = False
# This is initialized in setup_connection()
self.socket = None
@property
def transport(self) -> str:
return client.TRANSPORT_TCPKISS
@staticmethod
def is_enabled() -> bool:
"""Check if KISS is enabled in configuration.
Returns:
bool: True if either TCP is enabled
"""
return CONF.kiss_tcp.enabled
@staticmethod
def is_configured():
# Ensure that the config vars are correctly set
if TCPKISSDriver.is_enabled():
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
@property
def is_alive(self) -> bool:
"""Check if the client is connected.
Returns:
bool: True if connected to KISS TNC, False otherwise
"""
return self._connected
def close(self):
"""Close the connection."""
self.stop()
def send(self, packet: core.Packet):
"""Send an APRS packet.
Args:
packet: APRS packet to send (Packet or Message object)
Raises:
Exception: If not connected or send fails
"""
if not self.socket:
raise Exception('KISS interface not initialized')
payload = None
path = self.path
packet.prepare()
payload = packet.payload.encode('US-ASCII')
if packet.path:
path = packet.path
LOG.debug(
f"KISS Send '{payload}' TO '{packet.to_call}' From "
f"'{packet.from_call}' with PATH '{path}'",
)
frame = ax25frame.Frame.ui(
destination='APZ100',
# destination=packet.to_call,
source=packet.from_call,
path=path,
info=payload,
)
# now escape the frame special characters
frame_escaped = kissutil.escape_special_codes(bytes(frame))
# and finally wrap the frame in KISS protocol
command = Command.DATA_FRAME
frame_kiss = b''.join(
[kiss_constants.FEND, command.value, frame_escaped, kiss_constants.FEND]
)
self.socket.send(frame_kiss)
# Update last packet sent time
self.last_packet_sent = datetime.datetime.now()
# Increment packets sent counter
self.packets_sent += 1
def setup_connection(self):
"""Set up the KISS interface."""
if not self.is_enabled():
LOG.error('KISS is not enabled in configuration')
return
try:
# Configure for TCP KISS
if self.is_enabled():
LOG.info(
f'KISS TCP Connection to {CONF.kiss_tcp.host}:{CONF.kiss_tcp.port}'
)
self.path = CONF.kiss_tcp.path
self.connect()
if self._connected:
LOG.info('KISS interface initialized')
else:
LOG.error('Failed to connect to KISS interface')
except Exception as ex:
LOG.error('Failed to initialize KISS interface')
LOG.exception(ex)
self._connected = False
def set_filter(self, filter_text: str):
"""Set packet filter (not implemented for KISS).
Args:
filter_text: Filter specification (ignored for KISS)
"""
# KISS doesn't support filtering at the TNC level
pass
@property
def filter(self) -> str:
"""Get packet filter (not implemented for KISS).
Returns:
str: Empty string (not implemented for KISS)
"""
return ''
def login_success(self) -> bool:
"""There is no login for KISS."""
if not self._connected:
return False
return True
def login_failure(self) -> str:
"""There is no login for KISS."""
return 'Login successful'
def consumer(self, callback: Callable, raw: bool = False):
"""Start consuming frames with the given callback.
Args:
callback: Function to call with received packets
Raises:
Exception: If not connected to KISS TNC
"""
self._running = True
while self._running:
# Ensure connection
if not self._connected:
if not self.connect():
time.sleep(1)
continue
# Read frame
frame = self.read_frame()
if frame:
LOG.warning(f'GOT FRAME: {frame} calling {callback}')
kwargs = {
'frame': frame,
}
callback(**kwargs)
def decode_packet(self, *args, **kwargs) -> core.Packet:
"""Decode a packet from an AX.25 frame.
Args:
frame: Received AX.25 frame
"""
frame = kwargs.get('frame')
if not frame:
LOG.warning('No frame received to decode?!?!')
return None
LOG.warning(f'FRAME: {str(frame)}')
try:
aprslib_frame = aprslib.parse(str(frame))
packet = core.factory(aprslib_frame)
if isinstance(packet, core.ThirdPartyPacket):
return packet.subpacket
else:
return packet
except Exception as e:
LOG.error(f'Error decoding packet: {e}')
return None
def stop(self):
"""Stop the KISS interface."""
self._running = False
self._connected = False
if self.socket:
try:
self.socket.close()
except Exception:
pass
def stats(self, serializable: bool = False) -> Dict[str, Any]:
"""Get client statistics.
Returns:
Dict containing client statistics
"""
if serializable:
keepalive = self.keepalive.isoformat()
if self.last_packet_sent:
last_packet_sent = self.last_packet_sent.isoformat()
else:
last_packet_sent = 'None'
if self.last_packet_received:
last_packet_received = self.last_packet_received.isoformat()
else:
last_packet_received = 'None'
else:
keepalive = self.keepalive
last_packet_sent = self.last_packet_sent
last_packet_received = self.last_packet_received
stats = {
'client': self.__class__.__name__,
'transport': self.transport,
'connected': self._connected,
'path': self.path,
'packets_sent': self.packets_sent,
'packets_received': self.packets_received,
'last_packet_sent': last_packet_sent,
'last_packet_received': last_packet_received,
'connection_keepalive': keepalive,
'host': CONF.kiss_tcp.host,
'port': CONF.kiss_tcp.port,
}
return stats
def connect(self) -> bool:
"""Establish TCP connection to the KISS host.
Returns:
bool: True if connection successful, False otherwise
"""
try:
if self.socket:
try:
self.socket.close()
except Exception:
pass
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.settimeout(5.0) # 5 second timeout for connection
self.socket.connect((CONF.kiss_tcp.host, CONF.kiss_tcp.port))
self.socket.settimeout(0.1) # Reset to shorter timeout for reads
self._connected = True
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# MACOS doesn't have TCP_KEEPIDLE
if hasattr(socket, 'TCP_KEEPIDLE'):
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)
return True
except ConnectionError as e:
LOG.error(
f'Failed to connect to {CONF.kiss_tcp.host}:{CONF.kiss_tcp.port} - {str(e)}'
)
self._connected = False
return False
except Exception as e:
LOG.error(
f'Failed to connect to {CONF.kiss_tcp.host}:{CONF.kiss_tcp.port} - {str(e)}'
)
self._connected = False
return False
def fix_raw_frame(self, raw_frame: bytes) -> bytes:
"""Fix the raw frame by recalculating the FCS."""
ax25_data = raw_frame[2:-1] # Remove KISS markers
return handle_fend(ax25_data)
def read_frame(self, blocking=False):
"""
Generator for complete lines, received from the server
"""
try:
self.socket.setblocking(0)
except OSError as e:
LOG.error(f'socket error when setblocking(0): {str(e)}')
raise aprslib.ConnectionDrop('connection dropped') from e
while self._running:
short_buf = b''
try:
readable, _, _ = select.select(
[self.socket],
[],
[],
self.select_timeout,
)
if not readable:
if not blocking:
break
else:
continue
except Exception as e:
LOG.error(f'Error in read loop: {e}')
self._connected = False
break
try:
print('reading from socket')
short_buf = self.socket.recv(1024)
print(f'short_buf: {short_buf}')
# sock.recv returns empty if the connection drops
if not short_buf:
if not blocking:
# We could just not be blocking, so empty is expected
continue
else:
self.logger.error('socket.recv(): returned empty')
raise aprslib.ConnectionDrop('connection dropped')
raw_frame = self.fix_raw_frame(short_buf)
return ax25frame.Frame.from_bytes(raw_frame)
except OSError as e:
# self.logger.error("socket error on recv(): %s" % str(e))
if 'Resource temporarily unavailable' in str(e):
if not blocking:
if len(short_buf) == 0:
break
except socket.timeout:
continue
except (KeyboardInterrupt, SystemExit):
raise
except ConnectionError:
self.close()
if not self.auto_reconnect:
raise
else:
self.connect()
continue
except StopIteration:
break
except IOError:
LOG.error('IOError')
break
except Exception as e:
LOG.error(f'Error in read loop: {e}')
self._connected = False
if not self.auto_reconnect:
break
-91
View File
@@ -1,91 +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 = []
client = 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.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():
self.client = client()
return self.client
raise Exception("No client is configured!!")
def client_exists(self):
return bool(self.client)
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
-49
View File
@@ -1,49 +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, serializable=False) -> dict:
return {
"transport": "Fake",
"connected": True,
}
@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
-143
View File
@@ -1,143 +0,0 @@
import datetime
import logging
import aprslib
import timeago
from loguru import logger
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')
LOGU = logger
class KISSClient(base.APRSClient):
_client = None
keepalive = datetime.datetime.now()
def stats(self, serializable=False) -> dict:
stats = {}
if self.is_configured():
keepalive = self.keepalive
if serializable:
keepalive = keepalive.isoformat()
stats = {
'connected': self.is_connected,
'connection_keepalive': keepalive,
'transport': self.transport(),
}
if self.transport() == client.TRANSPORT_TCPKISS:
stats['host'] = CONF.kiss_tcp.host
stats['port'] = CONF.kiss_tcp.port
elif self.transport() == client.TRANSPORT_SERIALKISS:
stats['device'] = CONF.kiss_serial.device
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()
def keepalive_check(self):
# Don't check the first time through.
if not self.is_alive() and self._checks:
LOG.warning("Resetting client. It's not alive.")
self.reset()
self._checks = True
def keepalive_log(self):
if ka := self._client.aprsd_keepalive:
keepalive = timeago.format(ka)
else:
keepalive = 'N/A'
LOGU.opt(colors=True).info(f'<green>Client keepalive {keepalive}</green>')
@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}")
try:
raw = aprslib.parse(str(frame))
packet = core.factory(raw)
if isinstance(packet, core.ThirdPartyPacket):
return packet.subpacket
else:
return packet
except Exception as ex:
LOG.error(f'Error decoding packet: {ex}')
def setup_connection(self):
try:
self._client = kiss.KISS3Client()
self.connected = self.login_status['success'] = True
except Exception as ex:
self.connected = self.login_status['success'] = False
self.login_status['message'] = str(ex)
return self._client
def consumer(self, callback, blocking=False, immortal=False, raw=False):
try:
self._client.consumer(callback)
self.keepalive = datetime.datetime.now()
except Exception as ex:
LOG.error(f'Consumer failed {ex}')
LOG.error(ex)
raise ex
+2 -2
View File
@@ -3,7 +3,7 @@ import threading
import wrapt
from oslo_config import cfg
from aprsd import client
from aprsd.client.client import APRSDClient
from aprsd.utils import singleton
CONF = cfg.CONF
@@ -15,4 +15,4 @@ class APRSClientStats:
@wrapt.synchronized(lock)
def stats(self, serializable=False):
return client.client_factory.create().stats(serializable=serializable)
return APRSDClient().stats(serializable=serializable)
+6 -4
View File
@@ -4,14 +4,15 @@
#
# python included libs
import logging
import sys
import click
from oslo_config import cfg
from aprsd import cli_helper, conf, packets, plugin
import aprsd
from aprsd import cli_helper, conf, packets, plugin, utils
# local imports here
from aprsd.client import base
from aprsd.main import cli
from aprsd.utils import trace
@@ -72,6 +73,9 @@ def test_plugin(
):
"""Test an individual APRSD plugin given a python path."""
LOG.info(f'Python version: {sys.version}')
LOG.info(f'APRSD DEV Started version: {aprsd.__version__}')
utils.package.log_installed_extensions_and_plugins()
CONF.log_opt_values(LOG, logging.DEBUG)
if not aprs_login:
@@ -97,8 +101,6 @@ def test_plugin(
if CONF.trace_enabled:
trace.setup_tracing(['method', 'api'])
base.APRSClient()
pm = plugin.PluginManager()
if load_all:
pm.setup_plugins(load_help_plugin=CONF.load_help_plugin)
+2
View File
@@ -1,5 +1,6 @@
# Fetch active stats from a remote running instance of aprsd admin web interface.
import logging
import sys
import click
import requests
@@ -38,6 +39,7 @@ CONF = cfg.CONF
def fetch_stats(ctx, host, port):
"""Fetch stats from a APRSD admin web interface."""
console = Console()
console.print(f'Python version: {sys.version}')
console.print(f'APRSD Fetch-Stats started version: {aprsd.__version__}')
CONF.log_opt_values(LOG, logging.DEBUG)
+6 -133
View File
@@ -1,119 +1,18 @@
import fnmatch
import importlib
import inspect
import logging
import os
import pkgutil
import sys
from traceback import print_tb
import click
import requests
from rich.console import Console
from rich.table import Table
from rich.text import Text
from thesmuggler import smuggle
from aprsd import cli_helper
from aprsd import plugin as aprsd_plugin
from aprsd.main import cli
from aprsd.plugins import fortune, notify, ping, time, version, weather
from aprsd.utils import package as aprsd_package
LOG = logging.getLogger('APRSD')
PYPI_URL = 'https://pypi.org/search/'
def onerror(name):
print(f'Error importing module {name}')
type, value, traceback = sys.exc_info()
print_tb(traceback)
def is_plugin(obj):
for c in inspect.getmro(obj):
if issubclass(c, aprsd_plugin.APRSDPluginBase):
return True
return False
def plugin_type(obj):
for c in inspect.getmro(obj):
if issubclass(c, aprsd_plugin.APRSDRegexCommandPluginBase):
return 'RegexCommand'
if issubclass(c, aprsd_plugin.APRSDWatchListPluginBase):
return 'WatchList'
if issubclass(c, aprsd_plugin.APRSDPluginBase):
return 'APRSDPluginBase'
return 'Unknown'
def walk_package(package):
return pkgutil.walk_packages(
package.__path__,
package.__name__ + '.',
onerror=onerror,
)
def get_module_info(package_name, module_name, module_path):
if not os.path.exists(module_path):
return None
dir_path = os.path.realpath(module_path)
pattern = '*.py'
obj_list = []
for path, _subdirs, files in os.walk(dir_path):
for name in files:
if fnmatch.fnmatch(name, pattern):
module = smuggle(f'{path}/{name}')
for mem_name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and is_plugin(obj):
obj_list.append(
{
'package': package_name,
'name': mem_name,
'obj': obj,
'version': obj.version,
'path': f'{".".join([module_name, obj.__name__])}',
},
)
return obj_list
def _get_installed_aprsd_items():
# installed plugins
plugins = {}
extensions = {}
for _finder, name, ispkg in pkgutil.iter_modules():
if ispkg and name.startswith('aprsd_'):
module = importlib.import_module(name)
pkgs = walk_package(module)
for pkg in pkgs:
pkg_info = get_module_info(
module.__name__, pkg.name, module.__path__[0]
)
if 'plugin' in name:
plugins[name] = pkg_info
elif 'extension' in name:
extensions[name] = pkg_info
return plugins, extensions
def get_installed_plugins():
# installed plugins
plugins, extensions = _get_installed_aprsd_items()
return plugins
def get_installed_extensions():
# installed plugins
plugins, extensions = _get_installed_aprsd_items()
return extensions
def show_built_in_plugins(console):
@@ -157,34 +56,8 @@ def show_built_in_plugins(console):
console.print(table)
def _get_pypi_packages():
if simple_r := requests.get(
'https://pypi.org/simple',
headers={'Accept': 'application/vnd.pypi.simple.v1+json'},
):
simple_response = simple_r.json()
else:
simple_response = {}
key = 'aprsd'
matches = [
p['name'] for p in simple_response['projects'] if p['name'].startswith(key)
]
packages = []
for pkg in matches:
# Get info for first match
if r := requests.get(
f'https://pypi.org/pypi/{pkg}/json',
headers={'Accept': 'application/json'},
):
packages.append(r.json())
return packages
def show_pypi_plugins(installed_plugins, console):
packages = _get_pypi_packages()
packages = aprsd_package.get_pypi_packages()
title = Text.assemble(
('Pypi.org APRSD Installable Plugin Packages\n\n', 'bold magenta'),
@@ -225,7 +98,7 @@ def show_pypi_plugins(installed_plugins, console):
def show_pypi_extensions(installed_extensions, console):
packages = _get_pypi_packages()
packages = aprsd_package.get_pypi_packages()
title = Text.assemble(
('Pypi.org APRSD Installable Extension Packages\n\n', 'bold magenta'),
@@ -282,7 +155,7 @@ def show_installed_plugins(installed_plugins, console):
name.replace('_', '-'),
plugin['name'],
plugin['version'],
plugin_type(plugin['obj']),
aprsd_package.plugin_type(plugin['obj']),
plugin['path'],
)
@@ -302,7 +175,7 @@ def list_plugins(ctx):
show_built_in_plugins(console)
status.update('Fetching pypi.org plugins')
installed_plugins = get_installed_plugins()
installed_plugins = aprsd_package.get_installed_plugins()
show_pypi_plugins(installed_plugins, console)
status.update('Looking for installed APRSD plugins')
@@ -321,5 +194,5 @@ def list_extensions(ctx):
status.update('Fetching pypi.org APRSD Extensions')
status.update('Looking for installed APRSD Extensions')
installed_extensions = get_installed_extensions()
installed_extensions = aprsd_package.get_installed_extensions()
show_pypi_extensions(installed_extensions, console)
+5 -3
View File
@@ -17,7 +17,7 @@ from rich.console import Console
# local imports here
import aprsd
from aprsd import cli_helper, packets, plugin, threads, utils
from aprsd.client import client_factory
from aprsd.client.client import APRSDClient
from aprsd.main import cli
from aprsd.packets import collector as packet_collector
from aprsd.packets import core, seen_list
@@ -221,7 +221,9 @@ def listen(
# CONF.aprs_network.login = aprs_login
# config["aprs"]["password"] = aprs_password
LOG.info(f'Python version: {sys.version}')
LOG.info(f'APRSD Listen Started version: {aprsd.__version__}')
utils.package.log_installed_extensions_and_plugins()
CONF.log_opt_values(LOG, logging.DEBUG)
collector.Collector()
@@ -232,13 +234,13 @@ def listen(
# Initialize the client factory and create
# The correct client object ready for use
# Make sure we have 1 client transport enabled
if not client_factory.is_client_enabled():
if not APRSDClient().is_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 = APRSDClient()
LOG.info(aprs_client)
if not aprs_client.login_success:
# We failed to login, will just quit!
+8 -5
View File
@@ -13,8 +13,9 @@ from aprsd import (
cli_helper,
conf, # noqa : F401
packets,
utils,
)
from aprsd.client import client_factory
from aprsd.client.client import APRSDClient
from aprsd.main import cli
from aprsd.packets import collector
from aprsd.packets import log as packet_log
@@ -89,7 +90,9 @@ def send_message(
else:
aprs_password = CONF.aprs_network.password
LOG.info(f'APRSD LISTEN Started version: {aprsd.__version__}')
LOG.info(f'Python version: {sys.version}')
LOG.info(f'APRSD SEND_MESSAGE Started version: {aprsd.__version__}')
utils.package.log_installed_extensions_and_plugins()
if type(command) is tuple:
command = ' '.join(command)
if not quiet:
@@ -103,7 +106,7 @@ def send_message(
def rx_packet(packet):
global got_ack, got_response
cl = client_factory.create()
cl = APRSDClient()
packet = cl.decode_packet(packet)
collector.PacketCollector().rx(packet)
packet_log.log(packet, tx=False)
@@ -131,7 +134,7 @@ def send_message(
sys.exit(0)
try:
client_factory.create().client # noqa: B018
APRSDClient().client # noqa: B018
except LoginError:
sys.exit(-1)
@@ -163,7 +166,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 = APRSDClient()
aprs_client.consumer(rx_packet, raw=False)
except aprslib.exceptions.ConnectionDrop:
LOG.error('Connection dropped, reconnecting')
+7 -11
View File
@@ -8,7 +8,7 @@ from oslo_config import cfg
import aprsd
from aprsd import cli_helper, plugin, threads, utils
from aprsd import main as aprsd_main
from aprsd.client import client_factory
from aprsd.client.client import APRSDClient
from aprsd.main import cli
from aprsd.packets import collector as packet_collector
from aprsd.packets import seen_list
@@ -40,31 +40,27 @@ def server(ctx, flush):
service_threads = service.ServiceThreads()
LOG.info(f'Python version: {sys.version}')
LOG.info(f'APRSD Started version: {aprsd.__version__}')
level, msg = utils._check_version()
if level:
LOG.warning(msg)
else:
LOG.info(msg)
LOG.info(f'APRSD Started version: {aprsd.__version__}')
# 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)
utils.package.log_installed_extensions_and_plugins()
# Make sure we have 1 client transport enabled
if not client_factory.is_client_enabled():
if not APRSDClient().is_enabled:
LOG.error('No Clients are enabled in config.')
sys.exit(-1)
if not client_factory.is_client_configured():
if not APRSDClient().is_configured:
LOG.error('APRS client is not properly configured in config file.')
sys.exit(-1)
# Creates the client object
LOG.info('Creating client connection')
aprs_client = client_factory.create()
aprs_client = APRSDClient()
LOG.info(aprs_client)
if not aprs_client.login_success:
# We failed to login, will just quit!
+7 -1
View File
@@ -19,7 +19,7 @@ registry_group = cfg.OptGroup(
aprsd_opts = [
cfg.StrOpt(
'callsign',
required=True,
default='NOCALL',
help='Callsign to use for messages sent by APRSD',
),
cfg.BoolOpt(
@@ -137,6 +137,12 @@ aprsd_opts = [
help='Set this to False, to disable sending of ack packets. This will entirely stop'
'APRSD from sending ack packets.',
),
cfg.BoolOpt(
'is_digipi',
default=False,
help='Set this to True, if APRSD is running on a Digipi.'
'This is useful for changing the behavior of APRSD to work with Digipi.',
),
]
watch_list_opts = [
+7
View File
@@ -13,3 +13,10 @@ class ConfigOptionBogusDefaultException(Exception):
f"Config file option '{config_option}' needs to be "
f"changed from provided default of '{default_fail}'"
)
class APRSClientNotConfiguredException(Exception):
"""APRS client is not configured."""
def __init__(self):
self.message = 'APRS client is not configured.'
+5 -2
View File
@@ -51,7 +51,7 @@ class InterceptHandler(logging.Handler):
# Setup the log faciility
# to disable log to stdout, but still log to file
# use the --quiet option on the cmdln
def setup_logging(loglevel=None, quiet=False):
def setup_logging(loglevel=None, quiet=False, custom_handler=None):
if not loglevel:
log_level = CONF.logging.log_level
else:
@@ -85,7 +85,7 @@ def setup_logging(loglevel=None, quiet=False):
logging.getLogger(name).propagate = name not in disable_list
handlers = []
if CONF.logging.enable_console_stdout:
if CONF.logging.enable_console_stdout and not quiet:
handlers.append(
{
'sink': sys.stdout,
@@ -107,6 +107,9 @@ def setup_logging(loglevel=None, quiet=False):
},
)
if custom_handler:
handlers.append(custom_handler)
# configure loguru
logger.configure(handlers=handlers)
logger.level('DEBUG', color='<fg #BABABA>')
-7
View File
@@ -23,7 +23,6 @@
import datetime
import importlib.metadata as imp
import logging
import signal
import sys
import time
from importlib.metadata import version as metadata_version
@@ -41,7 +40,6 @@ from aprsd.stats import collector
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
flask_enabled = False
@click.group(cls=cli_helper.AliasedGroup, context_settings=CONTEXT_SETTINGS)
@@ -73,8 +71,6 @@ def main():
def signal_handler(sig, frame):
global flask_enabled
click.echo('signal_handler: called')
threads.APRSDThreadList().stop_all()
if 'subprocess' not in str(frame):
@@ -96,9 +92,6 @@ def signal_handler(sig, frame):
# signal.signal(signal.SIGTERM, sys.exit(0))
# sys.exit(0)
if flask_enabled:
signal.signal(signal.SIGTERM, sys.exit(0))
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
+168 -169
View File
@@ -19,26 +19,26 @@ from loguru import logger
from aprsd.utils import counter
# For mypy to be happy
A = TypeVar("A", bound="DataClassJsonMixin")
A = TypeVar('A', bound='DataClassJsonMixin')
Json = Union[dict, list, str, int, float, bool, None]
LOG = logging.getLogger()
LOGU = logger
PACKET_TYPE_BULLETIN = "bulletin"
PACKET_TYPE_MESSAGE = "message"
PACKET_TYPE_ACK = "ack"
PACKET_TYPE_REJECT = "reject"
PACKET_TYPE_MICE = "mic-e"
PACKET_TYPE_WX = "wx"
PACKET_TYPE_WEATHER = "weather"
PACKET_TYPE_OBJECT = "object"
PACKET_TYPE_UNKNOWN = "unknown"
PACKET_TYPE_STATUS = "status"
PACKET_TYPE_BEACON = "beacon"
PACKET_TYPE_THIRDPARTY = "thirdparty"
PACKET_TYPE_TELEMETRY = "telemetry-message"
PACKET_TYPE_UNCOMPRESSED = "uncompressed"
PACKET_TYPE_BULLETIN = 'bulletin'
PACKET_TYPE_MESSAGE = 'message'
PACKET_TYPE_ACK = 'ack'
PACKET_TYPE_REJECT = 'reject'
PACKET_TYPE_MICE = 'mic-e'
PACKET_TYPE_WX = 'wx'
PACKET_TYPE_WEATHER = 'weather'
PACKET_TYPE_OBJECT = 'object'
PACKET_TYPE_UNKNOWN = 'unknown'
PACKET_TYPE_STATUS = 'status'
PACKET_TYPE_BEACON = 'beacon'
PACKET_TYPE_THIRDPARTY = 'thirdparty'
PACKET_TYPE_TELEMETRY = 'telemetry-message'
PACKET_TYPE_UNCOMPRESSED = 'uncompressed'
NO_DATE = datetime(1900, 10, 24)
@@ -67,14 +67,14 @@ def _init_msgNo(): # noqa: N802
def _translate_fields(raw: dict) -> dict:
# Direct key checks instead of iteration
if "from" in raw:
raw["from_call"] = raw.pop("from")
if "to" in raw:
raw["to_call"] = raw.pop("to")
if 'from' in raw:
raw['from_call'] = raw.pop('from')
if 'to' in raw:
raw['to_call'] = raw.pop('to')
# addresse overrides to_call
if "addresse" in raw:
raw["to_call"] = raw["addresse"]
if 'addresse' in raw:
raw['to_call'] = raw['addresse']
return raw
@@ -82,7 +82,7 @@ def _translate_fields(raw: dict) -> dict:
@dataclass_json
@dataclass(unsafe_hash=True)
class Packet:
_type: str = field(default="Packet", hash=False)
_type: str = field(default='Packet', hash=False)
from_call: Optional[str] = field(default=None)
to_call: Optional[str] = field(default=None)
addresse: Optional[str] = field(default=None)
@@ -120,7 +120,7 @@ class Packet:
@property
def key(self) -> str:
"""Build a key for finding this packet in a dict."""
return f"{self.from_call}:{self.addresse}:{self.msgNo}"
return f'{self.from_call}:{self.addresse}:{self.msgNo}'
def update_timestamp(self) -> None:
self.timestamp = _init_timestamp()
@@ -133,7 +133,7 @@ class Packet:
the human readable payload.
"""
self.prepare()
msg = self._filter_for_send(self.raw).rstrip("\n")
msg = self._filter_for_send(self.raw).rstrip('\n')
return msg
def prepare(self, create_msg_number=False) -> None:
@@ -152,11 +152,11 @@ class Packet:
)
# The base packet class has no real payload
self.payload = f":{self.to_call.ljust(9)}"
self.payload = f':{self.to_call.ljust(9)}'
def _build_raw(self) -> None:
"""Build the self.raw which is what is sent over the air."""
self.raw = "{}>APZ100:{}".format(
self.raw = '{}>APZ100:{}'.format(
self.from_call,
self.payload,
)
@@ -168,13 +168,13 @@ class Packet:
# 67 displays 64 on the ftm400. (+3 {01 suffix)
# feature req: break long ones into two msgs
if not msg:
return ""
return ''
message = msg[:67]
# We all miss George Carlin
return re.sub(
"fuck|shit|cunt|piss|cock|bitch",
"****",
'fuck|shit|cunt|piss|cock|bitch',
'****',
message,
flags=re.IGNORECASE,
)
@@ -183,100 +183,98 @@ class Packet:
"""Show the raw version of the packet"""
self.prepare()
if not self.raw:
raise ValueError("self.raw is unset")
raise ValueError('self.raw is unset')
return self.raw
def __repr__(self) -> str:
"""Build the repr version of the packet."""
return (
f"{self.__class__.__name__}:"
f" From: {self.from_call} "
f" To: {self.to_call}"
f'{self.__class__.__name__}: From: {self.from_call} To: {self.to_call}'
)
@dataclass_json
@dataclass(unsafe_hash=True)
class AckPacket(Packet):
_type: str = field(default="AckPacket", hash=False)
_type: str = field(default='AckPacket', hash=False)
def _build_payload(self):
self.payload = f":{self.to_call: <9}:ack{self.msgNo}"
self.payload = f':{self.to_call: <9}:ack{self.msgNo}'
@dataclass_json
@dataclass(unsafe_hash=True)
class BulletinPacket(Packet):
_type: str = "BulletinPacket"
_type: str = 'BulletinPacket'
# Holds the encapsulated packet
bid: Optional[str] = field(default="1")
bid: Optional[str] = field(default='1')
message_text: Optional[str] = field(default=None)
@property
def key(self) -> str:
"""Build a key for finding this packet in a dict."""
return f"{self.from_call}:BLN{self.bid}"
return f'{self.from_call}:BLN{self.bid}'
@property
def human_info(self) -> str:
return f"BLN{self.bid} {self.message_text}"
return f'BLN{self.bid} {self.message_text}'
def _build_payload(self) -> None:
self.payload = f":BLN{self.bid:<9}" f":{self.message_text}"
self.payload = f':BLN{self.bid:<9}:{self.message_text}'
@dataclass_json
@dataclass(unsafe_hash=True)
class RejectPacket(Packet):
_type: str = field(default="RejectPacket", hash=False)
_type: str = field(default='RejectPacket', hash=False)
response: Optional[str] = field(default=None)
def __post__init__(self):
if self.response:
LOG.warning("Response set!")
LOG.warning('Response set!')
def _build_payload(self):
self.payload = f":{self.to_call: <9}:rej{self.msgNo}"
self.payload = f':{self.to_call: <9}:rej{self.msgNo}'
@dataclass_json
@dataclass(unsafe_hash=True)
class MessagePacket(Packet):
_type: str = field(default="MessagePacket", hash=False)
_type: str = field(default='MessagePacket', hash=False)
message_text: Optional[str] = field(default=None)
@property
def human_info(self) -> str:
self.prepare()
return self._filter_for_send(self.message_text).rstrip("\n")
return self._filter_for_send(self.message_text).rstrip('\n')
def _build_payload(self):
if self.msgNo:
self.payload = ":{}:{}{{{}".format(
self.payload = ':{}:{}{{{}'.format(
self.to_call.ljust(9),
self._filter_for_send(self.message_text).rstrip("\n"),
self._filter_for_send(self.message_text).rstrip('\n'),
str(self.msgNo),
)
else:
self.payload = ":{}:{}".format(
self.payload = ':{}:{}'.format(
self.to_call.ljust(9),
self._filter_for_send(self.message_text).rstrip("\n"),
self._filter_for_send(self.message_text).rstrip('\n'),
)
@dataclass_json
@dataclass(unsafe_hash=True)
class StatusPacket(Packet):
_type: str = field(default="StatusPacket", hash=False)
_type: str = field(default='StatusPacket', hash=False)
status: Optional[str] = field(default=None)
messagecapable: bool = field(default=False)
comment: Optional[str] = field(default=None)
raw_timestamp: Optional[str] = field(default=None)
def _build_payload(self):
self.payload = ":{}:{}{{{}".format(
self.payload = ':{}:{}{{{}'.format(
self.to_call.ljust(9),
self._filter_for_send(self.status).rstrip("\n"),
self._filter_for_send(self.status).rstrip('\n'),
str(self.msgNo),
)
@@ -289,7 +287,7 @@ class StatusPacket(Packet):
@dataclass_json
@dataclass(unsafe_hash=True)
class GPSPacket(Packet):
_type: str = field(default="GPSPacket", hash=False)
_type: str = field(default='GPSPacket', hash=False)
latitude: float = field(default=0.00)
longitude: float = field(default=0.00)
altitude: float = field(default=0.00)
@@ -297,8 +295,8 @@ class GPSPacket(Packet):
posambiguity: int = field(default=0)
messagecapable: bool = field(default=False)
comment: Optional[str] = field(default=None)
symbol: str = field(default="l")
symbol_table: str = field(default="/")
symbol: str = field(default='l')
symbol_table: str = field(default='/')
raw_timestamp: Optional[str] = field(default=None)
object_name: Optional[str] = field(default=None)
object_format: Optional[str] = field(default=None)
@@ -318,7 +316,7 @@ class GPSPacket(Packet):
def _build_time_zulu(self):
"""Build the timestamp in UTC/zulu."""
if self.timestamp:
return datetime.utcfromtimestamp(self.timestamp).strftime("%d%H%M")
return datetime.utcfromtimestamp(self.timestamp).strftime('%d%H%M')
def _build_payload(self):
"""The payload is the non headers portion of the packet."""
@@ -326,7 +324,7 @@ class GPSPacket(Packet):
lat = aprslib_util.latitude_to_ddm(self.latitude)
long = aprslib_util.longitude_to_ddm(self.longitude)
payload = [
"@" if self.timestamp else "!",
'@' if self.timestamp else '!',
time_zulu,
lat,
self.symbol_table,
@@ -337,34 +335,34 @@ class GPSPacket(Packet):
if self.comment:
payload.append(self._filter_for_send(self.comment))
self.payload = "".join(payload)
self.payload = ''.join(payload)
def _build_raw(self):
self.raw = f"{self.from_call}>{self.to_call},WIDE2-1:" f"{self.payload}"
self.raw = f'{self.from_call}>{self.to_call},WIDE2-1:{self.payload}'
@property
def human_info(self) -> str:
h_str = []
h_str.append(f"Lat:{self.latitude:03.3f}")
h_str.append(f"Lon:{self.longitude:03.3f}")
h_str.append(f'Lat:{self.latitude:03.3f}')
h_str.append(f'Lon:{self.longitude:03.3f}')
if self.altitude:
h_str.append(f"Altitude {self.altitude:03.0f}")
h_str.append(f'Altitude {self.altitude:03.0f}')
if self.speed:
h_str.append(f"Speed {self.speed:03.0f}MPH")
h_str.append(f'Speed {self.speed:03.0f}MPH')
if self.course:
h_str.append(f"Course {self.course:03.0f}")
h_str.append(f'Course {self.course:03.0f}')
if self.rng:
h_str.append(f"RNG {self.rng:03.0f}")
h_str.append(f'RNG {self.rng:03.0f}')
if self.phg:
h_str.append(f"PHG {self.phg}")
h_str.append(f'PHG {self.phg}')
return " ".join(h_str)
return ' '.join(h_str)
@dataclass_json
@dataclass(unsafe_hash=True)
class BeaconPacket(GPSPacket):
_type: str = field(default="BeaconPacket", hash=False)
_type: str = field(default='BeaconPacket', hash=False)
def _build_payload(self):
"""The payload is the non headers portion of the packet."""
@@ -372,42 +370,42 @@ class BeaconPacket(GPSPacket):
lat = aprslib_util.latitude_to_ddm(self.latitude)
lon = aprslib_util.longitude_to_ddm(self.longitude)
self.payload = f"@{time_zulu}z{lat}{self.symbol_table}" f"{lon}"
self.payload = f'@{time_zulu}z{lat}{self.symbol_table}{lon}'
if self.comment:
comment = self._filter_for_send(self.comment)
self.payload = f"{self.payload}{self.symbol}{comment}"
self.payload = f'{self.payload}{self.symbol}{comment}'
else:
self.payload = f"{self.payload}{self.symbol}APRSD Beacon"
self.payload = f'{self.payload}{self.symbol}APRSD Beacon'
def _build_raw(self):
self.raw = f"{self.from_call}>APZ100:" f"{self.payload}"
self.raw = f'{self.from_call}>APZ100:{self.payload}'
@property
def key(self) -> str:
"""Build a key for finding this packet in a dict."""
if self.raw_timestamp:
return f"{self.from_call}:{self.raw_timestamp}"
return f'{self.from_call}:{self.raw_timestamp}'
else:
return f"{self.from_call}:{self.human_info.replace(' ', '')}"
return f'{self.from_call}:{self.human_info.replace(" ", "")}'
@property
def human_info(self) -> str:
h_str = []
h_str.append(f"Lat:{self.latitude:03.3f}")
h_str.append(f"Lon:{self.longitude:03.3f}")
h_str.append(f"{self.comment}")
return " ".join(h_str)
h_str.append(f'Lat:{self.latitude:03.3f}')
h_str.append(f'Lon:{self.longitude:03.3f}')
h_str.append(f'{self.comment}')
return ' '.join(h_str)
@dataclass_json
@dataclass(unsafe_hash=True)
class MicEPacket(GPSPacket):
_type: str = field(default="MicEPacket", hash=False)
_type: str = field(default='MicEPacket', hash=False)
messagecapable: bool = False
mbits: Optional[str] = None
mtype: Optional[str] = None
telemetry: Optional[dict] = field(default=None)
telemetry: Optional[dict] = field(default=None, hash=False)
# in MPH
speed: float = 0.00
# 0 to 360
@@ -416,24 +414,24 @@ class MicEPacket(GPSPacket):
@property
def key(self) -> str:
"""Build a key for finding this packet in a dict."""
return f"{self.from_call}:{self.human_info.replace(' ', '')}"
return f'{self.from_call}:{self.human_info.replace(" ", "")}'
@property
def human_info(self) -> str:
h_info = super().human_info
return f"{h_info} {self.mbits} mbits"
return f'{h_info} {self.mbits} mbits'
@dataclass_json
@dataclass(unsafe_hash=True)
class TelemetryPacket(GPSPacket):
_type: str = field(default="TelemetryPacket", hash=False)
_type: str = field(default='TelemetryPacket', hash=False)
messagecapable: bool = False
mbits: Optional[str] = None
mtype: Optional[str] = None
telemetry: Optional[dict] = field(default=None)
tPARM: Optional[list[str]] = field(default=None) # noqa: N815
tUNIT: Optional[list[str]] = field(default=None) # noqa: N815
tPARM: Optional[list[str]] = field(default=None, hash=False) # noqa: N815
tUNIT: Optional[list[str]] = field(default=None, hash=False) # noqa: N815
# in MPH
speed: float = 0.00
# 0 to 360
@@ -443,23 +441,23 @@ class TelemetryPacket(GPSPacket):
def key(self) -> str:
"""Build a key for finding this packet in a dict."""
if self.raw_timestamp:
return f"{self.from_call}:{self.raw_timestamp}"
return f'{self.from_call}:{self.raw_timestamp}'
else:
return f"{self.from_call}:{self.human_info.replace(' ', '')}"
return f'{self.from_call}:{self.human_info.replace(" ", "")}'
@property
def human_info(self) -> str:
h_info = super().human_info
return f"{h_info} {self.telemetry}"
return f'{h_info} {self.telemetry}'
@dataclass_json
@dataclass(unsafe_hash=True)
class ObjectPacket(GPSPacket):
_type: str = field(default="ObjectPacket", hash=False)
_type: str = field(default='ObjectPacket', hash=False)
alive: bool = True
raw_timestamp: Optional[str] = None
symbol: str = field(default="r")
symbol: str = field(default='r')
# in MPH
speed: float = 0.00
# 0 to 360
@@ -470,11 +468,11 @@ class ObjectPacket(GPSPacket):
lat = aprslib_util.latitude_to_ddm(self.latitude)
long = aprslib_util.longitude_to_ddm(self.longitude)
self.payload = f"*{time_zulu}z{lat}{self.symbol_table}" f"{long}{self.symbol}"
self.payload = f'*{time_zulu}z{lat}{self.symbol_table}{long}{self.symbol}'
if self.comment:
comment = self._filter_for_send(self.comment)
self.payload = f"{self.payload}{comment}"
self.payload = f'{self.payload}{comment}'
def _build_raw(self):
"""
@@ -487,18 +485,18 @@ class ObjectPacket(GPSPacket):
The frequency, uplink_tone, offset is part of the comment
"""
self.raw = f"{self.from_call}>APZ100:;{self.to_call:9s}" f"{self.payload}"
self.raw = f'{self.from_call}>APZ100:;{self.to_call:9s}{self.payload}'
@property
def human_info(self) -> str:
h_info = super().human_info
return f"{h_info} {self.comment}"
return f'{h_info} {self.comment}'
@dataclass(unsafe_hash=True)
class WeatherPacket(GPSPacket, DataClassJsonMixin):
_type: str = field(default="WeatherPacket", hash=False)
symbol: str = "_"
_type: str = field(default='WeatherPacket', hash=False)
symbol: str = '_'
wind_speed: float = 0.00
wind_direction: int = 0
wind_gust: float = 0.00
@@ -516,8 +514,8 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin):
speed: Optional[float] = field(default=None)
def _translate(self, raw: dict) -> dict:
for key in raw["weather"]:
raw[key] = raw["weather"][key]
for key in raw['weather']:
raw[key] = raw['weather'][key]
# If we have the broken aprslib, then we need to
# Convert the course and speed to wind_speed and wind_direction
@@ -525,36 +523,36 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin):
# https://github.com/rossengeorgiev/aprs-python/issues/80
# Wind speed and course is option in the SPEC.
# For some reason aprslib multiplies the speed by 1.852.
if "wind_speed" not in raw and "wind_direction" not in raw:
if 'wind_speed' not in raw and 'wind_direction' not in raw:
# Most likely this is the broken aprslib
# So we need to convert the wind_gust speed
raw["wind_gust"] = round(raw.get("wind_gust", 0) / 0.44704, 3)
if "wind_speed" not in raw:
wind_speed = raw.get("speed")
raw['wind_gust'] = round(raw.get('wind_gust', 0) / 0.44704, 3)
if 'wind_speed' not in raw:
wind_speed = raw.get('speed')
if wind_speed:
raw["wind_speed"] = round(wind_speed / 1.852, 3)
raw["weather"]["wind_speed"] = raw["wind_speed"]
if "speed" in raw:
del raw["speed"]
raw['wind_speed'] = round(wind_speed / 1.852, 3)
raw['weather']['wind_speed'] = raw['wind_speed']
if 'speed' in raw:
del raw['speed']
# Let's adjust the rain numbers as well, since it's wrong
raw["rain_1h"] = round((raw.get("rain_1h", 0) / 0.254) * 0.01, 3)
raw["weather"]["rain_1h"] = raw["rain_1h"]
raw["rain_24h"] = round((raw.get("rain_24h", 0) / 0.254) * 0.01, 3)
raw["weather"]["rain_24h"] = raw["rain_24h"]
raw["rain_since_midnight"] = round(
(raw.get("rain_since_midnight", 0) / 0.254) * 0.01, 3
raw['rain_1h'] = round((raw.get('rain_1h', 0) / 0.254) * 0.01, 3)
raw['weather']['rain_1h'] = raw['rain_1h']
raw['rain_24h'] = round((raw.get('rain_24h', 0) / 0.254) * 0.01, 3)
raw['weather']['rain_24h'] = raw['rain_24h']
raw['rain_since_midnight'] = round(
(raw.get('rain_since_midnight', 0) / 0.254) * 0.01, 3
)
raw["weather"]["rain_since_midnight"] = raw["rain_since_midnight"]
raw['weather']['rain_since_midnight'] = raw['rain_since_midnight']
if "wind_direction" not in raw:
wind_direction = raw.get("course")
if 'wind_direction' not in raw:
wind_direction = raw.get('course')
if wind_direction:
raw["wind_direction"] = wind_direction
raw["weather"]["wind_direction"] = raw["wind_direction"]
if "course" in raw:
del raw["course"]
raw['wind_direction'] = wind_direction
raw['weather']['wind_direction'] = raw['wind_direction']
if 'course' in raw:
del raw['course']
del raw["weather"]
del raw['weather']
return raw
@classmethod
@@ -567,20 +565,20 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin):
def key(self) -> str:
"""Build a key for finding this packet in a dict."""
if self.raw_timestamp:
return f"{self.from_call}:{self.raw_timestamp}"
return f'{self.from_call}:{self.raw_timestamp}'
elif self.wx_raw_timestamp:
return f"{self.from_call}:{self.wx_raw_timestamp}"
return f'{self.from_call}:{self.wx_raw_timestamp}'
@property
def human_info(self) -> str:
h_str = []
h_str.append(f"Temp {self.temperature:03.0f}F")
h_str.append(f"Humidity {self.humidity}%")
h_str.append(f"Wind {self.wind_speed:03.0f}MPH@{self.wind_direction}")
h_str.append(f"Pressure {self.pressure}mb")
h_str.append(f"Rain {self.rain_24h}in/24hr")
h_str.append(f'Temp {self.temperature:03.0f}F')
h_str.append(f'Humidity {self.humidity}%')
h_str.append(f'Wind {self.wind_speed:03.0f}MPH@{self.wind_direction}')
h_str.append(f'Pressure {self.pressure}mb')
h_str.append(f'Rain {self.rain_24h}in/24hr')
return " ".join(h_str)
return ' '.join(h_str)
def _build_payload(self):
"""Build an uncompressed weather packet
@@ -610,49 +608,49 @@ class WeatherPacket(GPSPacket, DataClassJsonMixin):
time_zulu = self._build_time_zulu()
contents = [
f"@{time_zulu}z{self.latitude}{self.symbol_table}",
f"{self.longitude}{self.symbol}",
f"{self.wind_direction:03d}",
f'@{time_zulu}z{self.latitude}{self.symbol_table}',
f'{self.longitude}{self.symbol}',
f'{self.wind_direction:03d}',
# Speed = sustained 1 minute wind speed in mph
f"{self.symbol_table}",
f"{self.wind_speed:03.0f}",
f'{self.symbol_table}',
f'{self.wind_speed:03.0f}',
# wind gust (peak wind speed in mph in the last 5 minutes)
f"g{self.wind_gust:03.0f}",
f'g{self.wind_gust:03.0f}',
# Temperature in degrees F
f"t{self.temperature:03.0f}",
f't{self.temperature:03.0f}',
# Rainfall (in hundredths of an inch) in the last hour
f"r{self.rain_1h * 100:03.0f}",
f'r{self.rain_1h * 100:03.0f}',
# Rainfall (in hundredths of an inch) in last 24 hours
f"p{self.rain_24h * 100:03.0f}",
f'p{self.rain_24h * 100:03.0f}',
# Rainfall (in hundredths of an inch) since midnigt
f"P{self.rain_since_midnight * 100:03.0f}",
f'P{self.rain_since_midnight * 100:03.0f}',
# Humidity
f"h{self.humidity:02d}",
f'h{self.humidity:02d}',
# Barometric pressure (in tenths of millibars/tenths of hPascal)
f"b{self.pressure:05.0f}",
f'b{self.pressure:05.0f}',
]
if self.comment:
comment = self.filter_for_send(self.comment)
comment = self._filter_for_send(self.comment)
contents.append(comment)
self.payload = "".join(contents)
self.payload = ''.join(contents)
def _build_raw(self):
self.raw = f"{self.from_call}>{self.to_call},WIDE1-1,WIDE2-1:" f"{self.payload}"
self.raw = f'{self.from_call}>{self.to_call},WIDE1-1,WIDE2-1:{self.payload}'
@dataclass(unsafe_hash=True)
class ThirdPartyPacket(Packet, DataClassJsonMixin):
_type: str = "ThirdPartyPacket"
_type: str = 'ThirdPartyPacket'
# Holds the encapsulated packet
subpacket: Optional[type[Packet]] = field(default=None, compare=True, hash=False)
def __repr__(self):
"""Build the repr version of the packet."""
repr_str = (
f"{self.__class__.__name__}:"
f" From: {self.from_call} "
f" To: {self.to_call} "
f" Subpacket: {repr(self.subpacket)}"
f'{self.__class__.__name__}:'
f' From: {self.from_call} '
f' To: {self.to_call} '
f' Subpacket: {repr(self.subpacket)}'
)
return repr_str
@@ -666,12 +664,12 @@ class ThirdPartyPacket(Packet, DataClassJsonMixin):
@property
def key(self) -> str:
"""Build a key for finding this packet in a dict."""
return f"{self.from_call}:{self.subpacket.key}"
return f'{self.from_call}:{self.subpacket.key}'
@property
def human_info(self) -> str:
sub_info = self.subpacket.human_info
return f"{self.from_call}->{self.to_call} {sub_info}"
return f'{self.from_call}->{self.to_call} {sub_info}'
@dataclass_json(undefined=Undefined.INCLUDE)
@@ -683,11 +681,12 @@ class UnknownPacket:
"""
unknown_fields: CatchAll
_type: str = "UnknownPacket"
_type: str = 'UnknownPacket'
from_call: Optional[str] = field(default=None)
to_call: Optional[str] = field(default=None)
msgNo: str = field(default_factory=_init_msgNo) # noqa: N815
format: Optional[str] = field(default=None)
timestamp: float = field(default_factory=_init_timestamp, compare=False, hash=False)
raw: Optional[str] = field(default=None)
raw_dict: dict = field(
repr=False, default_factory=lambda: {}, compare=False, hash=False
@@ -701,7 +700,7 @@ class UnknownPacket:
@property
def key(self) -> str:
"""Build a key for finding this packet in a dict."""
return f"{self.from_call}:{self.packet_type}:{self.to_call}"
return f'{self.from_call}:{self.packet_type}:{self.to_call}'
@property
def human_info(self) -> str:
@@ -728,20 +727,20 @@ TYPE_LOOKUP: dict[str, type[Packet]] = {
def get_packet_type(packet: dict) -> str:
"""Decode the packet type from the packet."""
pkt_format = packet.get("format")
msg_response = packet.get("response")
pkt_format = packet.get('format')
msg_response = packet.get('response')
packet_type = PACKET_TYPE_UNKNOWN
if pkt_format == "message" and msg_response == "ack":
if pkt_format == 'message' and msg_response == 'ack':
packet_type = PACKET_TYPE_ACK
elif pkt_format == "message" and msg_response == "rej":
elif pkt_format == 'message' and msg_response == 'rej':
packet_type = PACKET_TYPE_REJECT
elif pkt_format == "message":
elif pkt_format == 'message':
packet_type = PACKET_TYPE_MESSAGE
elif pkt_format == "mic-e":
elif pkt_format == 'mic-e':
packet_type = PACKET_TYPE_MICE
elif pkt_format == "object":
elif pkt_format == 'object':
packet_type = PACKET_TYPE_OBJECT
elif pkt_format == "status":
elif pkt_format == 'status':
packet_type = PACKET_TYPE_STATUS
elif pkt_format == PACKET_TYPE_BULLETIN:
packet_type = PACKET_TYPE_BULLETIN
@@ -752,13 +751,13 @@ def get_packet_type(packet: dict) -> str:
elif pkt_format == PACKET_TYPE_WX:
packet_type = PACKET_TYPE_WEATHER
elif pkt_format == PACKET_TYPE_UNCOMPRESSED:
if packet.get("symbol") == "_":
if packet.get('symbol') == '_':
packet_type = PACKET_TYPE_WEATHER
elif pkt_format == PACKET_TYPE_THIRDPARTY:
packet_type = PACKET_TYPE_THIRDPARTY
if packet_type == PACKET_TYPE_UNKNOWN:
if "latitude" in packet:
if 'latitude' in packet:
packet_type = PACKET_TYPE_BEACON
else:
packet_type = PACKET_TYPE_UNKNOWN
@@ -780,32 +779,32 @@ def is_mice_packet(packet: dict[Any, Any]) -> bool:
def factory(raw_packet: dict[Any, Any]) -> type[Packet]:
"""Factory method to create a packet from a raw packet string."""
raw = raw_packet
if "_type" in raw:
cls = globals()[raw["_type"]]
if '_type' in raw:
cls = globals()[raw['_type']]
return cls.from_dict(raw)
raw["raw_dict"] = raw.copy()
raw['raw_dict'] = raw.copy()
raw = _translate_fields(raw)
packet_type = get_packet_type(raw)
raw["packet_type"] = packet_type
raw['packet_type'] = packet_type
packet_class = TYPE_LOOKUP[packet_type]
if packet_type == PACKET_TYPE_WX:
# the weather information is in a dict
# this brings those values out to the outer dict
packet_class = WeatherPacket
elif packet_type == PACKET_TYPE_OBJECT and "weather" in raw:
elif packet_type == PACKET_TYPE_OBJECT and 'weather' in raw:
packet_class = WeatherPacket
elif packet_type == PACKET_TYPE_UNKNOWN:
# Try and figure it out here
if "latitude" in raw:
if 'latitude' in raw:
packet_class = GPSPacket
else:
# LOG.warning(raw)
packet_class = UnknownPacket
raw.get("addresse", raw.get("to_call"))
raw.get('addresse', raw.get('to_call'))
# TODO: Find a global way to enable/disable this
# LOGU.opt(colors=True).info(
+69 -59
View File
@@ -12,13 +12,13 @@ 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"
DISTANCE_COLOR = "fg #FF5733"
DEGREES_COLOR = "fg #FFA900"
FROM_COLOR = 'fg #C70039'
TO_COLOR = 'fg #D033FF'
TX_COLOR = 'red'
RX_COLOR = 'green'
PACKET_COLOR = 'cyan'
DISTANCE_COLOR = 'fg #FF5733'
DEGREES_COLOR = 'fg #FFA900'
def log_multiline(
@@ -27,11 +27,11 @@ def log_multiline(
"""LOG a packet to the logfile."""
if not CONF.enable_packet_logging:
return
if CONF.log_packet_format == "compact":
if CONF.log_packet_format == 'compact':
return
# asdict(packet)
logit = ["\n"]
logit = ['\n']
name = packet.__class__.__name__
if isinstance(packet, AckPacket):
@@ -41,57 +41,67 @@ def log_multiline(
if header:
if tx:
header_str = f"<{TX_COLOR}>TX</{TX_COLOR}>"
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}",
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}>"
header_str = f'<{RX_COLOR}>RX</{RX_COLOR}>'
logit.append(
f"{header_str}________(<{PACKET_COLOR}>{name}</{PACKET_COLOR}>)",
f'{header_str}________(<{PACKET_COLOR}>{name}</{PACKET_COLOR}>)',
)
else:
header_str = ""
logit.append(f"__________(<{PACKET_COLOR}>{name}</{PACKET_COLOR}>)")
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}")
logit.append(f' Msg # : {packet.msgNo}')
if packet.from_call:
logit.append(f" From : <{FROM_COLOR}>{packet.from_call}</{FROM_COLOR}>")
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}")
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>")
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}")
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}>)")
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))
LOGU.opt(colors=True).info('\n'.join(logit))
LOG.debug(repr(packet))
def log(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> None:
def log(
packet,
tx: Optional[bool] = False,
header: Optional[bool] = True,
packet_count: Optional[int] = None,
) -> None:
if not CONF.enable_packet_logging:
return
if CONF.log_packet_format == "multiline":
if CONF.log_packet_format == 'multiline':
log_multiline(packet, tx, header)
return
if not packet_count:
packet_count = ''
else:
packet_count = f'({packet_count:d})'
logit = []
name = packet.__class__.__name__
if isinstance(packet, AckPacket):
@@ -101,47 +111,47 @@ def log(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> No
if header:
if tx:
via_color = "red"
arrow = f"<{via_color}>\u2192</{via_color}>"
via_color = 'red'
arrow = f'<{via_color}>\u2192</{via_color}>'
logit.append(
f"<red>TX\u2191</red> "
f"<cyan>{name}</cyan>"
f":{packet.msgNo}"
f" ({packet.send_count + 1} of {pkt_max_send_count})",
f'<red>TX{packet_count}\u2191</red> '
f'<cyan>{name}</cyan>'
f':{packet.msgNo}'
f' ({packet.send_count + 1} of {pkt_max_send_count})',
)
else:
via_color = "fg #1AA730"
arrow = f"<{via_color}>\u2192</{via_color}>"
f"<{via_color}><-</{via_color}>"
via_color = 'fg #1AA730'
arrow = f'<{via_color}>\u2192</{via_color}>'
f'<{via_color}><-</{via_color}>'
logit.append(
f"<fg #1AA730>RX\u2193</fg #1AA730> "
f"<cyan>{name}</cyan>"
f":{packet.msgNo}",
f'<fg #1AA730>RX{packet_count}\u2193</fg #1AA730> '
f'<cyan>{name}</cyan>'
f':{packet.msgNo}',
)
else:
via_color = "green"
arrow = f"<{via_color}>-></{via_color}>"
via_color = 'green'
arrow = f'<{via_color}>-></{via_color}>'
logit.append(
f"<cyan>{name}</cyan>" f":{packet.msgNo}",
f'<cyan>{name}</cyan>:{packet.msgNo}',
)
tmp = None
if packet.path:
tmp = f"{arrow}".join(packet.path) + f"{arrow} "
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}>",
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(":")
logit.append(':')
msg = packet.human_info
if msg:
msg = msg.replace("<", "\\<")
logit.append(f"<light-yellow><b>{msg}</b></light-yellow>")
msg = msg.replace('<', '\\<')
logit.append(f'<light-yellow><b>{msg}</b></light-yellow>')
# is there distance information?
if isinstance(packet, GPSPacket) and CONF.latitude and CONF.longitude:
@@ -150,12 +160,12 @@ def log(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> No
try:
bearing = utils.calculate_initial_compass_bearing(my_coords, packet_coords)
except Exception as e:
LOG.error(f"Failed to calculate bearing: {e}")
LOG.error(f'Failed to calculate bearing: {e}')
bearing = 0
logit.append(
f" : <{DEGREES_COLOR}>{utils.degrees_to_cardinal(bearing, full_string=True)}</{DEGREES_COLOR}>"
f"<{DISTANCE_COLOR}>@{haversine(my_coords, packet_coords, unit=Unit.MILES):.2f}miles</{DISTANCE_COLOR}>",
f' : <{DEGREES_COLOR}>{utils.degrees_to_cardinal(bearing, full_string=True)}</{DEGREES_COLOR}>'
f'<{DISTANCE_COLOR}>@{haversine(my_coords, packet_coords, unit=Unit.MILES):.2f}miles</{DISTANCE_COLOR}>',
)
LOGU.opt(colors=True).info(" ".join(logit))
LOGU.opt(colors=True).info(' '.join(logit))
log_multiline(packet, tx, header)
+3 -2
View File
@@ -12,7 +12,8 @@ import pluggy
from oslo_config import cfg
import aprsd
from aprsd import client, packets, threads
from aprsd import packets, threads
from aprsd.client.client import APRSDClient
from aprsd.packets import watch_list
# setup the global logger
@@ -146,7 +147,7 @@ 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 = APRSDClient()
filter_str = 'b/{}'.format('/'.join(watch_list))
aprs_client.set_filter(filter_str)
else:
+2 -2
View File
@@ -66,8 +66,8 @@ def fetch_openweathermap(api_key, lat, lon, units='metric', exclude=None):
exclude = 'minutely,hourly,daily,alerts'
try:
url = (
'https://api.openweathermap.org/data/2.5/onecall?'
'lat={}&lon={}&appid={}&units={}&exclude={}'.format(
"https://api.openweathermap.org/data/3.0/onecall?"
"lat={}&lon={}&appid={}&units={}&exclude={}".format(
lat,
lon,
api_key,
+2 -2
View File
@@ -323,8 +323,8 @@ class AVWXWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
elif not CONF.avwx_plugin.apiKey:
LOG.error('Config avwx_plugin.apiKey not specified. Disabling')
return False
else:
return True
self.enabled = True
def help(self):
_help = [
+5 -4
View File
@@ -3,7 +3,7 @@ from typing import Callable, Protocol, runtime_checkable
from aprsd.utils import singleton
LOG = logging.getLogger("APRSD")
LOG = logging.getLogger('APRSD')
@runtime_checkable
@@ -31,15 +31,16 @@ class Collector:
serializable=serializable
).copy()
except Exception as e:
LOG.error(f"Error in producer {name} (stats): {e}")
LOG.error(f'Error in producer {name} (stats): {e}')
raise e
return stats
def register_producer(self, producer_name: Callable):
if not isinstance(producer_name, StatsProducer):
raise TypeError(f"Producer {producer_name} is not a StatsProducer")
raise TypeError(f'Producer {producer_name} is not a StatsProducer')
self.producers.append(producer_name)
def unregister_producer(self, producer_name: Callable):
if not isinstance(producer_name, StatsProducer):
raise TypeError(f"Producer {producer_name} is not a StatsProducer")
raise TypeError(f'Producer {producer_name} is not a StatsProducer')
self.producers.remove(producer_name)
+13 -11
View File
@@ -7,7 +7,7 @@ import aprslib
from oslo_config import cfg
from aprsd import packets, plugin
from aprsd.client import client_factory
from aprsd.client.client import APRSDClient
from aprsd.packets import collector, filter
from aprsd.packets import log as packet_log
from aprsd.threads import APRSDThread, tx
@@ -32,6 +32,8 @@ class APRSDRXThread(APRSDThread):
# getting blocked by the APRS server trying to send us packets.
packet_queue = None
pkt_count = 0
def __init__(self, packet_queue):
super().__init__('RX_PKT')
self.packet_queue = packet_queue
@@ -39,16 +41,16 @@ class APRSDRXThread(APRSDThread):
def stop(self):
self.thread_stop = True
if self._client:
self._client.stop()
self._client.close()
def loop(self):
if not self._client:
self._client = client_factory.create()
self._client = APRSDClient()
time.sleep(1)
return True
if not self._client.is_connected:
self._client = client_factory.create()
if not self._client.is_alive:
self._client = APRSDClient()
time.sleep(1)
return True
@@ -66,7 +68,6 @@ class APRSDRXThread(APRSDThread):
self._client.consumer(
self.process_packet,
raw=False,
blocking=False,
)
except (
aprslib.exceptions.ConnectionDrop,
@@ -78,8 +79,8 @@ class APRSDRXThread(APRSDThread):
# is called
self._client.reset()
time.sleep(5)
except Exception:
# LOG.exception(ex)
except Exception as ex:
LOG.exception(ex)
LOG.error('Resetting connection and trying again.')
self._client.reset()
time.sleep(5)
@@ -92,7 +93,8 @@ class APRSDRXThread(APRSDThread):
'No packet received from decode_packet. Most likely a failure to parse'
)
return
packet_log.log(packet)
self.pkt_count += 1
packet_log.log(packet, packet_count=self.pkt_count)
pkt_list = packets.PacketList()
if isinstance(packet, packets.AckPacket):
@@ -216,7 +218,7 @@ class APRSDProcessPacketThread(APRSDFilterThread):
our_call = CONF.callsign.lower()
from_call = packet.from_call
if packet.addresse:
if hasattr(packet, 'addresse') and packet.addresse:
to_call = packet.addresse
else:
to_call = packet.to_call
@@ -273,7 +275,7 @@ class APRSDProcessPacketThread(APRSDFilterThread):
def process_other_packet(self, packet, for_us=False):
"""Process an APRS Packet that isn't a message or ack"""
if not for_us:
LOG.info("Got a packet meant for someone else '{packet.to_call}'")
LOG.info(f"Got a packet meant for someone else '{packet.to_call}'")
else:
LOG.info('Got a non AckPacket/MessagePacket')
+32 -31
View File
@@ -11,12 +11,12 @@ from rush.stores import dictionary
from aprsd import conf # noqa
from aprsd import threads as aprsd_threads
from aprsd.client import client_factory
from aprsd.client.client import APRSDClient
from aprsd.packets import collector, core, tracker
from aprsd.packets import log as packet_log
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
LOG = logging.getLogger('APRSD')
msg_t = throttle.Throttle(
limiter=periodic.PeriodicLimiter(
@@ -54,7 +54,7 @@ def send(packet: core.Packet, direct=False, aprs_client=None):
if CONF.enable_sending_ack_packets:
_send_ack(packet, direct=direct, aprs_client=aprs_client)
else:
LOG.info("Sending ack packets is disabled. Not sending AckPacket.")
LOG.info('Sending ack packets is disabled. Not sending AckPacket.')
else:
_send_packet(packet, direct=direct, aprs_client=aprs_client)
@@ -81,14 +81,14 @@ def _send_direct(packet, aprs_client=None):
if aprs_client:
cl = aprs_client
else:
cl = client_factory.create()
cl = APRSDClient()
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(f'Failed to send packet: {packet}')
LOG.error(e)
return False
else:
@@ -100,7 +100,7 @@ class SendPacketThread(aprsd_threads.APRSDThread):
def __init__(self, packet):
self.packet = packet
super().__init__(f"TX-{packet.to_call}-{self.packet.msgNo}")
super().__init__(f'TX-{packet.to_call}-{self.packet.msgNo}')
def loop(self):
"""Loop until a message is acked or it gets delayed.
@@ -119,9 +119,9 @@ class SendPacketThread(aprsd_threads.APRSDThread):
# The message has been removed from the tracking queue
# So it got acked and we are done.
LOG.info(
f"{self.packet.__class__.__name__}"
f"({self.packet.msgNo}) "
"Message Send Complete via Ack.",
f'{self.packet.__class__.__name__}'
f'({self.packet.msgNo}) '
'Message Send Complete via Ack.',
)
return False
else:
@@ -130,10 +130,10 @@ class SendPacketThread(aprsd_threads.APRSDThread):
# we reached the send limit, don't send again
# TODO(hemna) - Need to put this in a delayed queue?
LOG.info(
f"{packet.__class__.__name__} "
f"({packet.msgNo}) "
"Message Send Complete. Max attempts reached"
f" {packet.retry_count}",
f'{packet.__class__.__name__} '
f'({packet.msgNo}) '
'Message Send Complete. Max attempts reached'
f' {packet.retry_count}',
)
pkt_tracker.remove(packet.msgNo)
return False
@@ -157,8 +157,9 @@ class SendPacketThread(aprsd_threads.APRSDThread):
sent = False
try:
sent = _send_direct(packet)
except Exception:
LOG.error(f"Failed to send packet: {packet}")
except Exception as ex:
LOG.error(f'Failed to send packet: {packet}')
LOG.error(ex)
else:
# If an exception happens while sending
# we don't want this attempt to count
@@ -178,7 +179,7 @@ class SendAckThread(aprsd_threads.APRSDThread):
def __init__(self, packet):
self.packet = packet
super().__init__(f"TXAck-{packet.to_call}-{self.packet.msgNo}")
super().__init__(f'TXAck-{packet.to_call}-{self.packet.msgNo}')
self.max_retries = CONF.default_ack_send_count
def loop(self):
@@ -188,10 +189,10 @@ class SendAckThread(aprsd_threads.APRSDThread):
# we reached the send limit, don't send again
# TODO(hemna) - Need to put this in a delayed queue?
LOG.debug(
f"{self.packet.__class__.__name__}"
f"({self.packet.msgNo}) "
"Send Complete. Max attempts reached"
f" {self.max_retries}",
f'{self.packet.__class__.__name__}'
f'({self.packet.msgNo}) '
'Send Complete. Max attempts reached'
f' {self.max_retries}',
)
return False
@@ -207,7 +208,7 @@ class SendAckThread(aprsd_threads.APRSDThread):
# It's time to try to send it again
send_now = True
elif self.loop_count % 10 == 0:
LOG.debug(f"Still wating. {delta}")
LOG.debug(f'Still wating. {delta}')
else:
send_now = True
@@ -216,7 +217,7 @@ class SendAckThread(aprsd_threads.APRSDThread):
try:
sent = _send_direct(self.packet)
except Exception:
LOG.error(f"Failed to send packet: {self.packet}")
LOG.error(f'Failed to send packet: {self.packet}')
else:
# If an exception happens while sending
# we don't want this attempt to count
@@ -240,18 +241,18 @@ class BeaconSendThread(aprsd_threads.APRSDThread):
_loop_cnt: int = 1
def __init__(self):
super().__init__("BeaconSendThread")
super().__init__('BeaconSendThread')
self._loop_cnt = 1
# Make sure Latitude and Longitude are set.
if not CONF.latitude or not CONF.longitude:
LOG.error(
"Latitude and Longitude are not set in the config file."
"Beacon will not be sent and thread is STOPPED.",
'Latitude and Longitude are not set in the config file.'
'Beacon will not be sent and thread is STOPPED.',
)
self.stop()
LOG.info(
"Beacon thread is running and will send "
f"beacons every {CONF.beacon_interval} seconds.",
'Beacon thread is running and will send '
f'beacons every {CONF.beacon_interval} seconds.',
)
def loop(self):
@@ -259,10 +260,10 @@ class BeaconSendThread(aprsd_threads.APRSDThread):
if self._loop_cnt % CONF.beacon_interval == 0:
pkt = core.BeaconPacket(
from_call=CONF.callsign,
to_call="APRS",
to_call='APRS',
latitude=float(CONF.latitude),
longitude=float(CONF.longitude),
comment="APRSD GPS Beacon",
comment='APRSD GPS Beacon',
symbol=CONF.beacon_symbol,
)
try:
@@ -270,8 +271,8 @@ class BeaconSendThread(aprsd_threads.APRSDThread):
pkt.retry_count = 1
send(pkt, direct=True)
except Exception as e:
LOG.error(f"Failed to send beacon: {e}")
client_factory.create().reset()
LOG.error(f'Failed to send beacon: {e}')
APRSDClient().reset()
time.sleep(5)
self._loop_cnt += 1
+7 -5
View File
@@ -3,7 +3,7 @@ from typing import Callable, Protocol, runtime_checkable
from aprsd.utils import singleton
LOG = logging.getLogger("APRSD")
LOG = logging.getLogger('APRSD')
@runtime_checkable
@@ -33,7 +33,8 @@ class KeepAliveCollector:
try:
cls.keepalive_check()
except Exception as e:
LOG.error(f"Error in producer {name} (check): {e}")
LOG.error(f'Error in producer {name} (check): {e}')
raise e
def log(self) -> None:
"""Log any relevant information during a KeepAlive check"""
@@ -42,14 +43,15 @@ class KeepAliveCollector:
try:
cls.keepalive_log()
except Exception as e:
LOG.error(f"Error in producer {name} (check): {e}")
LOG.error(f'Error in producer {name} (check): {e}')
raise e
def register(self, producer_name: Callable):
if not isinstance(producer_name, KeepAliveProducer):
raise TypeError(f"Producer {producer_name} is not a KeepAliveProducer")
raise TypeError(f'Producer {producer_name} is not a KeepAliveProducer')
self.producers.append(producer_name)
def unregister(self, producer_name: Callable):
if not isinstance(producer_name, KeepAliveProducer):
raise TypeError(f"Producer {producer_name} is not a KeepAliveProducer")
raise TypeError(f'Producer {producer_name} is not a KeepAliveProducer')
self.producers.remove(producer_name)
+176
View File
@@ -0,0 +1,176 @@
import fnmatch
import importlib
import inspect
import logging
import os
import pkgutil
import sys
from traceback import print_tb
import requests
from thesmuggler import smuggle
from aprsd import plugin as aprsd_plugin
LOG = logging.getLogger()
def onerror(name):
type, value, traceback = sys.exc_info()
print_tb(traceback)
def plugin_type(obj):
for c in inspect.getmro(obj):
if issubclass(c, aprsd_plugin.APRSDRegexCommandPluginBase):
return 'RegexCommand'
if issubclass(c, aprsd_plugin.APRSDWatchListPluginBase):
return 'WatchList'
if issubclass(c, aprsd_plugin.APRSDPluginBase):
return 'APRSDPluginBase'
return 'Unknown'
def is_plugin(obj):
for c in inspect.getmro(obj):
if issubclass(c, aprsd_plugin.APRSDPluginBase):
return True
return False
def walk_package(package):
return pkgutil.walk_packages(
package.__path__,
package.__name__ + '.',
onerror=onerror,
)
def get_module_info(package_name, module_name, module_path):
if not os.path.exists(module_path):
return None
dir_path = os.path.realpath(module_path)
pattern = '*.py'
obj_list = []
for path, _subdirs, files in os.walk(dir_path):
for name in files:
if fnmatch.fnmatch(name, pattern):
module = smuggle(f'{path}/{name}')
for mem_name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and is_plugin(obj):
obj_list.append(
{
'package': package_name,
'name': mem_name,
'obj': obj,
'version': obj.version,
'path': f'{".".join([module_name, obj.__name__])}',
},
)
return obj_list
def is_aprsd_package(name):
if name.startswith('aprsd_'):
return True
def is_aprsd_extension(name):
if name.startswith('aprsd_') and 'extension' in name:
# This is an installed package that is an extension of
# APRSD
return True
else:
# We might have an editable install of an extension
# of APRSD.
return '__editable__' in name and 'aprsd_' in name and 'extension' in name
def get_installed_aprsd_items():
# installed plugins
plugins = {}
extensions = {}
for _finder, name, ispkg in pkgutil.iter_modules():
if ispkg and is_aprsd_package(name):
module = importlib.import_module(name)
pkgs = walk_package(module)
for pkg in pkgs:
pkg_info = get_module_info(
module.__name__, pkg.name, module.__path__[0]
)
if 'plugin' in name:
plugins[name] = pkg_info
elif 'extension' in name:
mod = importlib.import_module(name)
extensions[name] = mod
elif is_aprsd_extension(name):
# This isn't a package, so it could be an editable install
module = importlib.import_module(name)
key_name = next(iter(module.MAPPING.keys()))
module = importlib.import_module(key_name)
pkg_info = get_module_info(module.__name__, key_name, module.__path__[0])
extensions[key_name] = module
return plugins, extensions
def get_installed_plugins():
# installed plugins
plugins, _ = get_installed_aprsd_items()
return plugins
def get_installed_extensions():
# installed plugins
_, extensions = get_installed_aprsd_items()
return extensions
def get_pypi_packages():
if simple_r := requests.get(
'https://pypi.org/simple',
headers={'Accept': 'application/vnd.pypi.simple.v1+json'},
):
simple_response = simple_r.json()
else:
simple_response = {}
key = 'aprsd'
matches = [
p['name'] for p in simple_response['projects'] if p['name'].startswith(key)
]
packages = []
for pkg in matches:
# Get info for first match
if r := requests.get(
f'https://pypi.org/pypi/{pkg}/json',
headers={'Accept': 'application/json'},
):
packages.append(r.json())
return packages
def log_installed_extensions_and_plugins():
plugins, extensions = get_installed_aprsd_items()
for name in extensions:
ext = extensions[name]
# print(f"Extension: {ext}")
# print(f"Extension: {ext.__dict__}")
if hasattr(ext, '__version__'):
version = ext.__version__
elif hasattr(ext, 'version'):
version = ext.version
else:
version = ext['version']
LOG.info(f'Extension: {name} version: {version}')
for plugin in plugins:
LOG.info(f'Plugin: {plugin} version: {plugins[plugin][0]["version"]}')
+26 -13
View File
@@ -1,14 +1,27 @@
version: "3"
services:
aprsd:
image: hemna6969/aprsd:latest
container_name: aprsd
ports:
- "8001:8001"
volumes:
- $HOME/.config/aprsd:/config
restart: unless-stopped
environment:
- TZ=America/New_York
- APRSD_PLUGINS=aprsd-slack-plugin>=1.0.2
- LOG_LEVEL=ERROR
aprsd:
image: hemna6969/aprsd:latest
container_name: aprsd-server
volumes:
- $HOME/.config/aprsd/:/config # left side of the : is your directory where your config is
# outside of your container. Your normal filesystem.
restart: unless-stopped
environment:
- TZ=America/New_York
- APRSD_PLUGINS=aprsd-email-plugin,aprsd-borat-plugin
- LOG_LEVEL=ERROR
aprsd-admin: # Admin interface
image: hemna6969/aprsd:latest
container_name: aprsd-admin
volumes:
- $HOME/.config/aprsd/:/config # left side of the : is your directory where your config is
# outside of your container. Your normal filesystem.
restart: unless-stopped
ports:
- 8001:8001 # left side of the : is your port on your host that you can access
# the web interface for the admin interface.
entrypoint: /app/admin.sh
environment:
- TZ=America/New_York
- APRSD_EXTENSIONS=git+https://github.com/hemna/aprsd-admin-extension.git
+1 -1
View File
@@ -2,7 +2,7 @@ build
pip
pip-tools
pre-commit
Sphinx
Sphinx<=8.2
tox
wheel
+24 -24
View File
@@ -2,38 +2,38 @@
# uv pip compile --resolver backtracking --annotation-style=line requirements-dev.in -o requirements-dev.txt
alabaster==1.0.0 # via sphinx
babel==2.17.0 # via sphinx
build==1.2.2.post1 # via pip-tools, -r requirements-dev.in
cachetools==5.5.2 # via tox
certifi==2025.1.31 # via requests
build==1.3.0 # via pip-tools, -r requirements-dev.in
cachetools==6.1.0 # via tox
certifi==2025.8.3 # via requests
cfgv==3.4.0 # via pre-commit
chardet==5.2.0 # via tox
charset-normalizer==3.4.1 # via requests
click==8.1.8 # via pip-tools
charset-normalizer==3.4.3 # via requests
click==8.2.1 # via pip-tools
colorama==0.4.6 # via tox
distlib==0.3.9 # via virtualenv
distlib==0.4.0 # via virtualenv
docutils==0.21.2 # via m2r, sphinx
filelock==3.17.0 # via tox, virtualenv
identify==2.6.8 # via pre-commit
filelock==3.18.0 # via tox, virtualenv
identify==2.6.13 # via pre-commit
idna==3.10 # via requests
imagesize==1.4.1 # via sphinx
jinja2==3.1.5 # via sphinx
jinja2==3.1.6 # via sphinx
m2r==0.3.1 # via -r requirements-dev.in
markupsafe==3.0.2 # via jinja2
mistune==0.8.4 # via m2r
nodeenv==1.9.1 # via pre-commit
packaging==24.2 # via build, pyproject-api, sphinx, tox
pip==25.0.1 # via pip-tools, -r requirements-dev.in
pip-tools==7.4.1 # via -r requirements-dev.in
platformdirs==4.3.6 # via tox, virtualenv
pluggy==1.5.0 # via tox
pre-commit==4.1.0 # via -r requirements-dev.in
pygments==2.19.1 # via sphinx
pyproject-api==1.9.0 # via tox
packaging==25.0 # via build, pyproject-api, sphinx, tox
pip==25.2 # via pip-tools, -r requirements-dev.in
pip-tools==7.5.0 # via -r requirements-dev.in
platformdirs==4.3.8 # via tox, virtualenv
pluggy==1.6.0 # via tox
pre-commit==4.3.0 # via -r requirements-dev.in
pygments==2.19.2 # via sphinx
pyproject-api==1.9.1 # via tox
pyproject-hooks==1.2.0 # via build, pip-tools
pyyaml==6.0.2 # via pre-commit
requests==2.32.3 # via sphinx
setuptools==75.8.2 # via pip-tools
snowballstemmer==2.2.0 # via sphinx
requests==2.32.4 # via sphinx
setuptools==80.9.0 # via pip-tools
snowballstemmer==3.0.1 # via sphinx
sphinx==8.1.3 # via -r requirements-dev.in
sphinxcontrib-applehelp==2.0.0 # via sphinx
sphinxcontrib-devhelp==2.0.0 # via sphinx
@@ -42,8 +42,8 @@ sphinxcontrib-jsmath==1.0.1 # via sphinx
sphinxcontrib-qthelp==2.0.0 # via sphinx
sphinxcontrib-serializinghtml==2.0.0 # via sphinx
tomli==2.2.1 # via build, pip-tools, pyproject-api, sphinx, tox
tox==4.24.1 # via -r requirements-dev.in
typing-extensions==4.12.2 # via tox
urllib3==2.3.0 # via requests
virtualenv==20.29.2 # via pre-commit, tox
tox==4.28.4 # via -r requirements-dev.in
typing-extensions==4.14.1 # via tox
urllib3==2.5.0 # via requests
virtualenv==20.33.1 # via pre-commit, tox
wheel==0.45.1 # via pip-tools, -r requirements-dev.in
+21 -22
View File
@@ -1,46 +1,45 @@
# This file was autogenerated by uv via the following command:
# uv pip compile --resolver backtracking --annotation-style=line requirements.in -o requirements.txt
aprslib==0.7.2 # via -r requirements.in
attrs==25.1.0 # via ax253, kiss3, rush
attrs==25.3.0 # via ax253, kiss3, rush
ax253==0.1.5.post1 # via kiss3
bitarray==3.1.0 # via ax253, kiss3
certifi==2025.1.31 # via requests
charset-normalizer==3.4.1 # via requests
click==8.1.8 # via -r requirements.in
bitarray==3.6.1 # via ax253, kiss3
certifi==2025.8.3 # via requests
charset-normalizer==3.4.3 # via requests
click==8.2.1 # via -r requirements.in
dataclasses-json==0.6.7 # via -r requirements.in
debtcollector==3.0.0 # via oslo-config
haversine==2.9.0 # via -r requirements.in
idna==3.10 # via requests
importlib-metadata==8.6.1 # via ax253, kiss3
importlib-metadata==8.7.0 # via ax253, kiss3
kiss3==8.0.0 # via -r requirements.in
loguru==0.7.3 # via -r requirements.in
markdown-it-py==3.0.0 # via rich
markdown-it-py==4.0.0 # via rich
marshmallow==3.26.1 # via dataclasses-json
mdurl==0.1.2 # via markdown-it-py
mypy-extensions==1.0.0 # via typing-inspect
mypy-extensions==1.1.0 # via typing-inspect
netaddr==1.3.0 # via oslo-config
oslo-config==9.7.1 # via -r requirements.in
oslo-config==10.0.0 # via -r requirements.in
oslo-i18n==6.5.1 # via oslo-config
packaging==24.2 # via marshmallow
packaging==25.0 # via marshmallow
pbr==6.1.1 # via oslo-i18n, stevedore
pluggy==1.5.0 # via -r requirements.in
pygments==2.19.1 # via rich
pluggy==1.6.0 # via -r requirements.in
pygments==2.19.2 # via rich
pyserial==3.5 # via pyserial-asyncio
pyserial-asyncio==0.6 # via kiss3
pytz==2025.1 # via -r requirements.in
pytz==2025.2 # via -r requirements.in
pyyaml==6.0.2 # via oslo-config
requests==2.32.3 # via oslo-config, update-checker, -r requirements.in
requests==2.32.4 # via oslo-config, update-checker, -r requirements.in
rfc3986==2.0.0 # via oslo-config
rich==13.9.4 # via -r requirements.in
rich==14.1.0 # via -r requirements.in
rush==2021.4.0 # via -r requirements.in
setuptools==75.8.2 # via pbr
setuptools==80.9.0 # via pbr
stevedore==5.4.1 # via oslo-config
thesmuggler==1.0.1 # via -r requirements.in
timeago==1.0.16 # via -r requirements.in
typing-extensions==4.12.2 # via rich, typing-inspect
typing-extensions==4.14.1 # via typing-inspect
typing-inspect==0.9.0 # via dataclasses-json
tzlocal==5.3 # via -r requirements.in
tzlocal==5.3.1 # via -r requirements.in
update-checker==0.18.0 # via -r requirements.in
urllib3==2.3.0 # via requests
wrapt==1.17.2 # via debtcollector, -r requirements.in
zipp==3.21.0 # via importlib-metadata
urllib3==2.5.0 # via requests
wrapt==1.17.3 # via -r requirements.in
zipp==3.23.0 # via importlib-metadata
View File
View File
+440
View File
@@ -0,0 +1,440 @@
import datetime
import unittest
from unittest import mock
from aprslib.exceptions import LoginError
from aprsd import exception
from aprsd.client.drivers.aprsis import APRSISDriver
from aprsd.client.drivers.registry import ClientDriver
from aprsd.packets import core
class TestAPRSISDriver(unittest.TestCase):
"""Unit tests for the APRSISDriver class."""
def setUp(self):
# Mock configuration
self.conf_patcher = mock.patch('aprsd.client.drivers.aprsis.CONF')
self.mock_conf = self.conf_patcher.start()
# Configure APRS-IS settings
self.mock_conf.aprs_network.enabled = True
self.mock_conf.aprs_network.login = 'TEST'
self.mock_conf.aprs_network.password = '12345'
self.mock_conf.aprs_network.host = 'rotate.aprs.net'
self.mock_conf.aprs_network.port = 14580
# Mock APRS Lib Client
self.aprslib_patcher = mock.patch('aprsd.client.drivers.aprsis.APRSLibClient')
self.mock_aprslib = self.aprslib_patcher.start()
self.mock_client = mock.MagicMock()
self.mock_aprslib.return_value = self.mock_client
# Create an instance of the driver
self.driver = APRSISDriver()
def tearDown(self):
self.conf_patcher.stop()
self.aprslib_patcher.stop()
def test_implements_client_driver_protocol(self):
"""Test that APRSISDriver implements the ClientDriver Protocol."""
# Verify the instance is recognized as implementing the Protocol
self.assertIsInstance(self.driver, ClientDriver)
# Verify all required methods are present with correct signatures
required_methods = [
'is_enabled',
'is_configured',
'is_alive',
'close',
'send',
'setup_connection',
'set_filter',
'login_success',
'login_failure',
'consumer',
'decode_packet',
'stats',
]
for method_name in required_methods:
self.assertTrue(
hasattr(self.driver, method_name),
f'Missing required method: {method_name}',
)
def test_init(self):
"""Test initialization sets default values."""
self.assertIsInstance(self.driver.max_delta, datetime.timedelta)
self.assertEqual(self.driver.max_delta, datetime.timedelta(minutes=2))
self.assertFalse(self.driver.login_status['success'])
self.assertIsNone(self.driver.login_status['message'])
self.assertIsNone(self.driver._client)
def test_is_enabled_true(self):
"""Test is_enabled returns True when APRS-IS is enabled."""
self.mock_conf.aprs_network.enabled = True
self.assertTrue(APRSISDriver.is_enabled())
def test_is_enabled_false(self):
"""Test is_enabled returns False when APRS-IS is disabled."""
self.mock_conf.aprs_network.enabled = False
self.assertFalse(APRSISDriver.is_enabled())
def test_is_enabled_key_error(self):
"""Test is_enabled returns False when enabled flag doesn't exist."""
self.mock_conf.aprs_network = mock.MagicMock()
type(self.mock_conf.aprs_network).enabled = mock.PropertyMock(
side_effect=KeyError
)
self.assertFalse(APRSISDriver.is_enabled())
def test_is_configured_true(self):
"""Test is_configured returns True when properly configured."""
with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True):
self.mock_conf.aprs_network.login = 'TEST'
self.mock_conf.aprs_network.password = '12345'
self.mock_conf.aprs_network.host = 'rotate.aprs.net'
self.assertTrue(APRSISDriver.is_configured())
def test_is_configured_no_login(self):
"""Test is_configured raises exception when login not set."""
with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True):
self.mock_conf.aprs_network.login = None
with self.assertRaises(exception.MissingConfigOptionException):
APRSISDriver.is_configured()
def test_is_configured_no_password(self):
"""Test is_configured raises exception when password not set."""
with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True):
self.mock_conf.aprs_network.login = 'TEST'
self.mock_conf.aprs_network.password = None
with self.assertRaises(exception.MissingConfigOptionException):
APRSISDriver.is_configured()
def test_is_configured_no_host(self):
"""Test is_configured raises exception when host not set."""
with mock.patch.object(APRSISDriver, 'is_enabled', return_value=True):
self.mock_conf.aprs_network.login = 'TEST'
self.mock_conf.aprs_network.password = '12345'
self.mock_conf.aprs_network.host = None
with self.assertRaises(exception.MissingConfigOptionException):
APRSISDriver.is_configured()
def test_is_configured_disabled(self):
"""Test is_configured returns True when not enabled."""
with mock.patch.object(APRSISDriver, 'is_enabled', return_value=False):
self.assertTrue(APRSISDriver.is_configured())
def test_is_alive_no_client(self):
"""Test is_alive returns False when no client."""
self.driver._client = None
self.assertFalse(self.driver.is_alive)
def test_is_alive_true(self):
"""Test is_alive returns True when client is alive and connection is not stale."""
self.driver._client = self.mock_client
self.mock_client.is_alive.return_value = True
with mock.patch.object(self.driver, '_is_stale_connection', return_value=False):
self.assertTrue(self.driver.is_alive)
def test_is_alive_client_not_alive(self):
"""Test is_alive returns False when client is not alive."""
self.driver._client = self.mock_client
self.mock_client.is_alive.return_value = False
with mock.patch.object(self.driver, '_is_stale_connection', return_value=False):
self.assertFalse(self.driver.is_alive)
def test_is_alive_stale_connection(self):
"""Test is_alive returns False when connection is stale."""
self.driver._client = self.mock_client
self.mock_client.is_alive.return_value = True
with mock.patch.object(self.driver, '_is_stale_connection', return_value=True):
self.assertFalse(self.driver.is_alive)
def test_close(self):
"""Test close method stops and closes the client."""
self.driver._client = self.mock_client
self.driver.close()
self.mock_client.stop.assert_called_once()
self.mock_client.close.assert_called_once()
def test_close_no_client(self):
"""Test close method handles no client gracefully."""
self.driver._client = None
# Should not raise exception
self.driver.close()
def test_send(self):
"""Test send passes packet to client."""
self.driver._client = self.mock_client
mock_packet = mock.MagicMock(spec=core.Packet)
self.driver.send(mock_packet)
self.mock_client.send.assert_called_once_with(mock_packet)
@mock.patch('aprsd.client.drivers.aprsis.LOG')
def test_setup_connection_success(self, mock_log):
"""Test setup_connection successfully connects."""
# Configure successful connection
self.mock_client.server_string = 'Test APRS-IS Server'
self.driver.setup_connection()
# Check client created with correct parameters
self.mock_aprslib.assert_called_once_with(
self.mock_conf.aprs_network.login,
passwd=self.mock_conf.aprs_network.password,
host=self.mock_conf.aprs_network.host,
port=self.mock_conf.aprs_network.port,
)
# Check logger set and connection initialized
self.assertEqual(self.mock_client.logger, mock_log)
self.mock_client.connect.assert_called_once()
# Check status updated
self.assertTrue(self.driver.connected)
self.assertTrue(self.driver.login_status['success'])
self.assertEqual(self.driver.login_status['message'], 'Test APRS-IS Server')
@mock.patch('aprsd.client.drivers.aprsis.LOG')
@mock.patch('aprsd.client.drivers.aprsis.time.sleep')
def test_setup_connection_login_error(self, mock_sleep, mock_log):
"""Test setup_connection handles login error."""
# Configure login error
login_error = LoginError('Bad login')
login_error.message = 'Invalid login credentials'
self.mock_client.connect.side_effect = login_error
self.driver.setup_connection()
# Check error logged
mock_log.error.assert_any_call("Failed to login to APRS-IS Server 'Bad login'")
mock_log.error.assert_any_call('Invalid login credentials')
# Check status updated
self.assertFalse(self.driver.connected)
self.assertFalse(self.driver.login_status['success'])
self.assertEqual(
self.driver.login_status['message'], 'Invalid login credentials'
)
# Check backoff used
mock_sleep.assert_called()
@mock.patch('aprsd.client.drivers.aprsis.LOG')
@mock.patch('aprsd.client.drivers.aprsis.time.sleep')
def test_setup_connection_general_error(self, mock_sleep, mock_log):
"""Test setup_connection handles general error."""
# Configure general exception
error_message = 'Connection error'
error = Exception(error_message)
# Standard exceptions don't have a message attribute
self.mock_client.connect.side_effect = error
self.driver.setup_connection()
# Check error logged
mock_log.error.assert_any_call(
f"Unable to connect to APRS-IS server. '{error_message}' "
)
# Check status updated
self.assertFalse(self.driver.connected)
self.assertFalse(self.driver.login_status['success'])
# Check login message contains the error message (more flexible than exact equality)
self.assertIn(error_message, self.driver.login_status['message'])
# Check backoff used
mock_sleep.assert_called()
def test_set_filter(self):
"""Test set_filter passes filter to client."""
self.driver._client = self.mock_client
test_filter = 'm/50'
self.driver.set_filter(test_filter)
self.mock_client.set_filter.assert_called_once_with(test_filter)
def test_login_success(self):
"""Test login_success returns login status."""
self.driver.login_status['success'] = True
self.assertTrue(self.driver.login_success())
self.driver.login_status['success'] = False
self.assertFalse(self.driver.login_success())
def test_login_failure(self):
"""Test login_failure returns error message."""
self.driver.login_status['message'] = None
self.assertIsNone(self.driver.login_failure())
self.driver.login_status['message'] = 'Test error'
self.assertEqual(self.driver.login_failure(), 'Test error')
def test_filter_property(self):
"""Test filter property returns client filter."""
self.driver._client = self.mock_client
test_filter = 'm/50'
self.mock_client.filter = test_filter
self.assertEqual(self.driver.filter, test_filter)
def test_server_string_property(self):
"""Test server_string property returns client server string."""
self.driver._client = self.mock_client
test_string = 'Test APRS-IS Server'
self.mock_client.server_string = test_string
self.assertEqual(self.driver.server_string, test_string)
def test_keepalive_property(self):
"""Test keepalive property returns client keepalive."""
self.driver._client = self.mock_client
test_time = datetime.datetime.now()
self.mock_client.aprsd_keepalive = test_time
self.assertEqual(self.driver.keepalive, test_time)
@mock.patch('aprsd.client.drivers.aprsis.LOG')
def test_is_stale_connection_true(self, mock_log):
"""Test _is_stale_connection returns True when connection is stale."""
self.driver._client = self.mock_client
# Set keepalive to 3 minutes ago (exceeds max_delta of 2 minutes)
self.mock_client.aprsd_keepalive = datetime.datetime.now() - datetime.timedelta(
minutes=3
)
result = self.driver._is_stale_connection()
self.assertTrue(result)
mock_log.error.assert_called_once()
def test_is_stale_connection_false(self):
"""Test _is_stale_connection returns False when connection is not stale."""
self.driver._client = self.mock_client
# Set keepalive to 1 minute ago (within max_delta of 2 minutes)
self.mock_client.aprsd_keepalive = datetime.datetime.now() - datetime.timedelta(
minutes=1
)
result = self.driver._is_stale_connection()
self.assertFalse(result)
def test_transport(self):
"""Test transport returns appropriate transport type."""
self.assertEqual(APRSISDriver.transport(), 'aprsis')
def test_decode_packet(self):
"""Test decode_packet uses core.factory."""
with mock.patch('aprsd.client.drivers.aprsis.core.factory') as mock_factory:
raw_packet = {'from': 'TEST', 'to': 'APRS'}
self.driver.decode_packet(raw_packet)
mock_factory.assert_called_once_with(raw_packet)
@mock.patch('aprsd.client.drivers.aprsis.LOG')
def test_consumer_success(self, mock_log):
"""Test consumer forwards callback to client."""
self.driver._client = self.mock_client
self.driver.connected = True
mock_callback = mock.MagicMock()
self.driver.consumer(mock_callback, raw=True)
self.mock_client.consumer.assert_called_once_with(
mock_callback, blocking=False, immortal=False, raw=True
)
@mock.patch('aprsd.client.drivers.aprsis.LOG')
def test_consumer_exception(self, mock_log):
"""Test consumer handles exceptions."""
self.driver._client = self.mock_client
self.driver.connected = True
mock_callback = mock.MagicMock()
test_error = Exception('Test error')
self.mock_client.consumer.side_effect = test_error
with self.assertRaises(Exception): # noqa: B017
self.driver.consumer(mock_callback)
mock_log.error.assert_called_with(test_error)
@mock.patch('aprsd.client.drivers.aprsis.LOG')
def test_consumer_no_client(self, mock_log):
"""Test consumer handles no client gracefully."""
self.driver._client = None
mock_callback = mock.MagicMock()
self.driver.consumer(mock_callback)
self.assertFalse(self.driver.connected)
def test_stats_configured_with_client(self):
"""Test stats returns correct data when configured with client."""
# Configure driver
with mock.patch.object(self.driver, 'is_configured', return_value=True):
self.driver._client = self.mock_client
self.mock_client.aprsd_keepalive = datetime.datetime.now()
self.mock_client.server_string = 'Test Server'
self.mock_client.filter = 'm/50'
stats = self.driver.stats()
self.assertEqual(stats['connected'], True)
self.assertEqual(stats['filter'], 'm/50')
self.assertEqual(stats['server_string'], 'Test Server')
self.assertEqual(stats['transport'], 'aprsis')
def test_stats_serializable(self):
"""Test stats with serializable=True converts datetime to ISO format."""
# Configure driver
with mock.patch.object(self.driver, 'is_configured', return_value=True):
self.driver._client = self.mock_client
test_time = datetime.datetime.now()
self.mock_client.aprsd_keepalive = test_time
stats = self.driver.stats(serializable=True)
# Check keepalive is a string in ISO format
self.assertIsInstance(stats['connection_keepalive'], str)
# Try parsing it to verify it's a valid ISO format
try:
datetime.datetime.fromisoformat(stats['connection_keepalive'])
except ValueError:
self.fail('keepalive is not in valid ISO format')
def test_stats_no_client(self):
"""Test stats with no client."""
with mock.patch.object(self.driver, 'is_configured', return_value=True):
self.driver._client = None
stats = self.driver.stats()
self.assertEqual(stats['connection_keepalive'], 'None')
self.assertEqual(stats['server_string'], 'None')
def test_stats_not_configured(self):
"""Test stats when not configured returns empty dict."""
with mock.patch.object(self.driver, 'is_configured', return_value=False):
stats = self.driver.stats()
self.assertEqual(stats, {})
if __name__ == '__main__':
unittest.main()
+191
View File
@@ -0,0 +1,191 @@
import unittest
from unittest import mock
from aprsd.client.drivers.fake import APRSDFakeDriver
from aprsd.packets import core
class TestAPRSDFakeDriver(unittest.TestCase):
"""Unit tests for the APRSDFakeDriver class."""
def setUp(self):
# Mock CONF for testing
self.conf_patcher = mock.patch('aprsd.client.drivers.fake.CONF')
self.mock_conf = self.conf_patcher.start()
# Configure fake_client.enabled
self.mock_conf.fake_client.enabled = True
# Create an instance of the driver
self.driver = APRSDFakeDriver()
def tearDown(self):
self.conf_patcher.stop()
def test_init(self):
"""Test initialization sets default values."""
self.assertEqual(self.driver.path, ['WIDE1-1', 'WIDE2-1'])
self.assertFalse(self.driver.thread_stop)
def test_is_enabled_true(self):
"""Test is_enabled returns True when configured."""
self.mock_conf.fake_client.enabled = True
self.assertTrue(APRSDFakeDriver.is_enabled())
def test_is_enabled_false(self):
"""Test is_enabled returns False when not configured."""
self.mock_conf.fake_client.enabled = False
self.assertFalse(APRSDFakeDriver.is_enabled())
def test_is_alive(self):
"""Test is_alive returns True when thread_stop is False."""
self.driver.thread_stop = False
self.assertTrue(self.driver.is_alive())
self.driver.thread_stop = True
self.assertFalse(self.driver.is_alive())
def test_close(self):
"""Test close sets thread_stop to True."""
self.driver.thread_stop = False
self.driver.close()
self.assertTrue(self.driver.thread_stop)
@mock.patch('aprsd.client.drivers.fake.LOG')
def test_setup_connection(self, mock_log):
"""Test setup_connection does nothing (it's fake)."""
self.driver.setup_connection()
# Method doesn't do anything, so just verify it doesn't crash
def test_set_filter(self):
"""Test set_filter method does nothing (it's fake)."""
# Just test it doesn't fail
self.driver.set_filter('test/filter')
def test_login_success(self):
"""Test login_success always returns True."""
self.assertTrue(self.driver.login_success())
def test_login_failure(self):
"""Test login_failure always returns None."""
self.assertIsNone(self.driver.login_failure())
@mock.patch('aprsd.client.drivers.fake.LOG')
def test_send_with_packet_object(self, mock_log):
"""Test send with a Packet object."""
mock_packet = mock.MagicMock(spec=core.Packet)
mock_packet.payload = 'Test payload'
mock_packet.to_call = 'TEST'
mock_packet.from_call = 'FAKE'
self.driver.send(mock_packet)
mock_log.info.assert_called_once()
mock_packet.prepare.assert_called_once()
@mock.patch('aprsd.client.drivers.fake.LOG')
def test_send_with_non_packet_object(self, mock_log):
"""Test send with a non-Packet object."""
# Create a mock message-like object
mock_msg = mock.MagicMock()
mock_msg.raw = 'Test'
mock_msg.msgNo = '123'
mock_msg.to_call = 'TEST'
mock_msg.from_call = 'FAKE'
self.driver.send(mock_msg)
mock_log.info.assert_called_once()
mock_log.debug.assert_called_once()
@mock.patch('aprsd.client.drivers.fake.LOG')
@mock.patch('aprsd.client.drivers.fake.time.sleep')
def test_consumer_with_raw_true(self, mock_sleep, mock_log):
"""Test consumer with raw=True."""
mock_callback = mock.MagicMock()
self.driver.consumer(mock_callback, raw=True)
# Verify callback was called with raw data
mock_callback.assert_called_once()
call_args = mock_callback.call_args[1]
self.assertIn('raw', call_args)
mock_sleep.assert_called_once_with(1)
@mock.patch('aprsd.client.drivers.fake.LOG')
@mock.patch('aprsd.client.drivers.fake.aprslib.parse')
@mock.patch('aprsd.client.drivers.fake.core.factory')
@mock.patch('aprsd.client.drivers.fake.time.sleep')
def test_consumer_with_raw_false(
self, mock_sleep, mock_factory, mock_parse, mock_log
):
"""Test consumer with raw=False."""
mock_callback = mock.MagicMock()
mock_packet = mock.MagicMock(spec=core.Packet)
mock_factory.return_value = mock_packet
self.driver.consumer(mock_callback, raw=False)
# Verify the packet was created and passed to callback
mock_parse.assert_called_once()
mock_factory.assert_called_once()
mock_callback.assert_called_once_with(packet=mock_packet)
mock_sleep.assert_called_once_with(1)
def test_consumer_updates_keepalive(self):
"""Test consumer updates keepalive timestamp."""
mock_callback = mock.MagicMock()
old_keepalive = self.driver.aprsd_keepalive
# Force a small delay to ensure timestamp changes
import time
time.sleep(0.01)
with mock.patch('aprsd.client.drivers.fake.time.sleep'):
self.driver.consumer(mock_callback)
self.assertNotEqual(old_keepalive, self.driver.aprsd_keepalive)
self.assertGreater(self.driver.aprsd_keepalive, old_keepalive)
def test_decode_packet_with_empty_kwargs(self):
"""Test decode_packet with empty kwargs."""
result = self.driver.decode_packet()
self.assertIsNone(result)
def test_decode_packet_with_packet(self):
"""Test decode_packet with packet in kwargs."""
mock_packet = mock.MagicMock(spec=core.Packet)
result = self.driver.decode_packet(packet=mock_packet)
self.assertEqual(result, mock_packet)
@mock.patch('aprsd.client.drivers.fake.aprslib.parse')
@mock.patch('aprsd.client.drivers.fake.core.factory')
def test_decode_packet_with_raw(self, mock_factory, mock_parse):
"""Test decode_packet with raw in kwargs."""
mock_packet = mock.MagicMock(spec=core.Packet)
mock_factory.return_value = mock_packet
raw_data = 'raw packet data'
result = self.driver.decode_packet(raw=raw_data)
mock_parse.assert_called_once_with(raw_data)
mock_factory.assert_called_once_with(mock_parse.return_value)
self.assertEqual(result, mock_packet)
def test_stats(self):
"""Test stats returns correct information."""
self.driver.thread_stop = False
result = self.driver.stats()
self.assertEqual(result['driver'], 'APRSDFakeDriver')
self.assertTrue(result['is_alive'])
# Test with serializable parameter
result_serializable = self.driver.stats(serializable=True)
self.assertEqual(result_serializable['driver'], 'APRSDFakeDriver')
self.assertTrue(result_serializable['is_alive'])
if __name__ == '__main__':
unittest.main()
+498
View File
@@ -0,0 +1,498 @@
import datetime
import socket
import unittest
from unittest import mock
import aprslib
from aprsd import exception
from aprsd.client.drivers.registry import ClientDriver
from aprsd.client.drivers.tcpkiss import TCPKISSDriver
from aprsd.packets import core
class TestTCPKISSDriver(unittest.TestCase):
"""Unit tests for the TCPKISSDriver class."""
def setUp(self):
# Mock configuration
self.conf_patcher = mock.patch('aprsd.client.drivers.tcpkiss.CONF')
self.mock_conf = self.conf_patcher.start()
# Configure KISS settings
self.mock_conf.kiss_tcp.enabled = True
self.mock_conf.kiss_tcp.host = '127.0.0.1'
self.mock_conf.kiss_tcp.port = 8001
self.mock_conf.kiss_tcp.path = ['WIDE1-1', 'WIDE2-1']
# Mock socket
self.socket_patcher = mock.patch('aprsd.client.drivers.tcpkiss.socket')
self.mock_socket_module = self.socket_patcher.start()
self.mock_socket = mock.MagicMock()
self.mock_socket_module.socket.return_value = self.mock_socket
# Mock select
self.select_patcher = mock.patch('aprsd.client.drivers.tcpkiss.select')
self.mock_select = self.select_patcher.start()
# Create an instance of the driver
self.driver = TCPKISSDriver()
def tearDown(self):
self.conf_patcher.stop()
self.socket_patcher.stop()
self.select_patcher.stop()
def test_implements_client_driver_protocol(self):
"""Test that TCPKISSDriver implements the ClientDriver Protocol."""
# Verify the instance is recognized as implementing the Protocol
self.assertIsInstance(self.driver, ClientDriver)
# Verify all required methods are present with correct signatures
required_methods = [
'is_enabled',
'is_configured',
'is_alive',
'close',
'send',
'setup_connection',
'set_filter',
'login_success',
'login_failure',
'consumer',
'decode_packet',
'stats',
]
for method_name in required_methods:
self.assertTrue(
hasattr(self.driver, method_name),
f'Missing required method: {method_name}',
)
def test_init(self):
"""Test initialization sets default values."""
self.assertFalse(self.driver._connected)
self.assertIsInstance(self.driver.keepalive, datetime.datetime)
self.assertFalse(self.driver._running)
def test_transport_property(self):
"""Test transport property returns correct value."""
self.assertEqual(self.driver.transport, 'tcpkiss')
def test_is_enabled_true(self):
"""Test is_enabled returns True when KISS TCP is enabled."""
self.mock_conf.kiss_tcp.enabled = True
self.assertTrue(TCPKISSDriver.is_enabled())
def test_is_enabled_false(self):
"""Test is_enabled returns False when KISS TCP is disabled."""
self.mock_conf.kiss_tcp.enabled = False
self.assertFalse(TCPKISSDriver.is_enabled())
def test_is_configured_true(self):
"""Test is_configured returns True when properly configured."""
with mock.patch.object(TCPKISSDriver, 'is_enabled', return_value=True):
self.mock_conf.kiss_tcp.host = '127.0.0.1'
self.assertTrue(TCPKISSDriver.is_configured())
def test_is_configured_false_no_host(self):
"""Test is_configured returns False when host not set."""
with mock.patch.object(TCPKISSDriver, 'is_enabled', return_value=True):
self.mock_conf.kiss_tcp.host = None
with self.assertRaises(exception.MissingConfigOptionException):
TCPKISSDriver.is_configured()
def test_is_configured_false_not_enabled(self):
"""Test is_configured returns False when not enabled."""
with mock.patch.object(TCPKISSDriver, 'is_enabled', return_value=False):
self.assertFalse(TCPKISSDriver.is_configured())
def test_is_alive(self):
"""Test is_alive property returns connection state."""
self.driver._connected = True
self.assertTrue(self.driver.is_alive)
self.driver._connected = False
self.assertFalse(self.driver.is_alive)
def test_close(self):
"""Test close method calls stop."""
with mock.patch.object(self.driver, 'stop') as mock_stop:
self.driver.close()
mock_stop.assert_called_once()
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_setup_connection_success(self, mock_log):
"""Test setup_connection successfully connects."""
# Mock the connect method to succeed
is_en = self.driver.is_enabled
is_con = self.driver.is_configured
self.driver.is_enabled = mock.MagicMock(return_value=True)
self.driver.is_configured = mock.MagicMock(return_value=True)
with mock.patch.object(
self.driver, 'connect', return_value=True
) as mock_connect:
self.driver.setup_connection()
mock_connect.assert_called_once()
mock_log.info.assert_called_with('KISS TCP Connection to 127.0.0.1:8001')
self.driver.is_enabled = is_en
self.driver.is_configured = is_con
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_setup_connection_failure(self, mock_log):
"""Test setup_connection handles connection failure."""
# Mock the connect method to fail
with mock.patch.object(
self.driver, 'connect', return_value=False
) as mock_connect:
self.driver.setup_connection()
mock_connect.assert_called_once()
mock_log.error.assert_called_with('Failed to connect to KISS interface')
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_setup_connection_exception(self, mock_log):
"""Test setup_connection handles exceptions."""
# Mock the connect method to raise an exception
with mock.patch.object(
self.driver, 'connect', side_effect=Exception('Test error')
) as mock_connect:
self.driver.setup_connection()
mock_connect.assert_called_once()
mock_log.error.assert_any_call('Failed to initialize KISS interface')
mock_log.exception.assert_called_once()
self.assertFalse(self.driver._connected)
def test_set_filter(self):
"""Test set_filter does nothing for KISS."""
# Just ensure it doesn't fail
self.driver.set_filter('test/filter')
def test_login_success_when_connected(self):
"""Test login_success returns True when connected."""
self.driver._connected = True
self.assertTrue(self.driver.login_success())
def test_login_success_when_not_connected(self):
"""Test login_success returns False when not connected."""
self.driver._connected = False
self.assertFalse(self.driver.login_success())
def test_login_failure(self):
"""Test login_failure returns success message."""
self.assertEqual(self.driver.login_failure(), 'Login successful')
@mock.patch('aprsd.client.drivers.tcpkiss.ax25frame.Frame.ui')
def test_send_packet(self, mock_frame_ui):
"""Test sending an APRS packet."""
# Create a mock frame
mock_frame = mock.MagicMock()
mock_frame_bytes = b'mock_frame_data'
mock_frame.__bytes__ = mock.MagicMock(return_value=mock_frame_bytes)
mock_frame_ui.return_value = mock_frame
# Set up the driver
self.driver.socket = self.mock_socket
self.driver.path = ['WIDE1-1', 'WIDE2-1']
# Create a mock packet
mock_packet = mock.MagicMock(spec=core.Packet)
mock_bytes = b'Test packet data'
mock_packet.__bytes__ = mock.MagicMock(return_value=mock_bytes)
# Add path attribute to the mock packet
mock_packet.path = None
# Send the packet
self.driver.send(mock_packet)
# Check that frame was created correctly
mock_frame_ui.assert_called_once_with(
destination='APZ100',
source=mock_packet.from_call,
path=self.driver.path,
info=mock_packet.payload.encode('US-ASCII'),
)
# Check that socket send was called
self.mock_socket.send.assert_called_once()
# Verify packet counters updated
self.assertEqual(self.driver.packets_sent, 1)
self.assertIsNotNone(self.driver.last_packet_sent)
def test_send_with_no_socket(self):
"""Test send raises exception when socket not initialized."""
self.driver.socket = None
mock_packet = mock.MagicMock(spec=core.Packet)
with self.assertRaises(Exception) as context:
self.driver.send(mock_packet)
self.assertIn('KISS interface not initialized', str(context.exception))
def test_stop(self):
"""Test stop method cleans up properly."""
self.driver._running = True
self.driver._connected = True
self.driver.socket = self.mock_socket
self.driver.stop()
self.assertFalse(self.driver._running)
self.assertFalse(self.driver._connected)
self.mock_socket.close.assert_called_once()
def test_stats(self):
"""Test stats method returns correct data."""
# Set up test data
self.driver._connected = True
self.driver.path = ['WIDE1-1', 'WIDE2-1']
self.driver.packets_sent = 5
self.driver.packets_received = 3
self.driver.last_packet_sent = datetime.datetime.now()
self.driver.last_packet_received = datetime.datetime.now()
# Get stats
stats = self.driver.stats()
# Check stats contains expected keys
expected_keys = [
'client',
'transport',
'connected',
'path',
'packets_sent',
'packets_received',
'last_packet_sent',
'last_packet_received',
'connection_keepalive',
'host',
'port',
]
for key in expected_keys:
self.assertIn(key, stats)
# Check some specific values
self.assertEqual(stats['client'], 'TCPKISSDriver')
self.assertEqual(stats['transport'], 'tcpkiss')
self.assertEqual(stats['connected'], True)
self.assertEqual(stats['packets_sent'], 5)
self.assertEqual(stats['packets_received'], 3)
def test_stats_serializable(self):
"""Test stats with serializable=True converts datetime to ISO format."""
self.driver.keepalive = datetime.datetime.now()
stats = self.driver.stats(serializable=True)
# Check keepalive is a string in ISO format
self.assertIsInstance(stats['connection_keepalive'], str)
# Try parsing it to verify it's a valid ISO format
try:
datetime.datetime.fromisoformat(stats['connection_keepalive'])
except ValueError:
self.fail('keepalive is not in valid ISO format')
def test_connect_success(self):
"""Test successful connection."""
result = self.driver.connect()
self.assertTrue(result)
self.assertTrue(self.driver._connected)
self.mock_socket.connect.assert_called_once_with(
(self.mock_conf.kiss_tcp.host, self.mock_conf.kiss_tcp.port)
)
self.mock_socket.settimeout.assert_any_call(5.0)
self.mock_socket.settimeout.assert_any_call(0.1)
def test_connect_failure_socket_error(self):
"""Test connection failure due to socket error."""
self.mock_socket.connect.side_effect = socket.error('Test socket error')
result = self.driver.connect()
self.assertFalse(result)
self.assertFalse(self.driver._connected)
def test_connect_failure_timeout(self):
"""Test connection failure due to timeout."""
self.mock_socket.connect.side_effect = socket.timeout('Test timeout')
result = self.driver.connect()
self.assertFalse(result)
self.assertFalse(self.driver._connected)
def test_fix_raw_frame(self):
"""Test fix_raw_frame removes KISS markers and handles FEND."""
# Create a test frame with KISS markers
with mock.patch(
'aprsd.client.drivers.tcpkiss.handle_fend', return_value=b'fixed_frame'
) as mock_handle_fend:
raw_frame = b'\xc0\x00some_frame_data\xc0' # \xc0 is FEND
result = self.driver.fix_raw_frame(raw_frame)
mock_handle_fend.assert_called_once_with(b'some_frame_data')
self.assertEqual(result, b'fixed_frame')
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_decode_packet_success(self, mock_log):
"""Test successful packet decoding."""
mock_frame = 'test frame data'
mock_aprs_data = {'from': 'TEST-1', 'to': 'APRS'}
mock_packet = mock.MagicMock(spec=core.Packet)
with mock.patch(
'aprsd.client.drivers.tcpkiss.aprslib.parse', return_value=mock_aprs_data
) as mock_parse:
with mock.patch(
'aprsd.client.drivers.tcpkiss.core.factory', return_value=mock_packet
) as mock_factory:
result = self.driver.decode_packet(frame=mock_frame)
mock_parse.assert_called_once_with(str(mock_frame))
mock_factory.assert_called_once_with(mock_aprs_data)
self.assertEqual(result, mock_packet)
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_decode_packet_no_frame(self, mock_log):
"""Test decode_packet with no frame returns None."""
result = self.driver.decode_packet()
self.assertIsNone(result)
mock_log.warning.assert_called_once()
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_decode_packet_exception(self, mock_log):
"""Test decode_packet handles exceptions."""
mock_frame = 'invalid frame'
with mock.patch(
'aprsd.client.drivers.tcpkiss.aprslib.parse',
side_effect=Exception('Test error'),
) as mock_parse:
result = self.driver.decode_packet(frame=mock_frame)
mock_parse.assert_called_once()
self.assertIsNone(result)
mock_log.error.assert_called_once()
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_consumer_with_frame(self, mock_log):
"""Test consumer processes frames and calls callback."""
mock_callback = mock.MagicMock()
mock_frame = mock.MagicMock()
# Configure driver for test
self.driver._connected = True
self.driver._running = True
# Set up read_frame to return one frame then stop
def side_effect():
self.driver._running = False
return mock_frame
with mock.patch.object(
self.driver, 'read_frame', side_effect=side_effect
) as mock_read_frame:
self.driver.consumer(mock_callback)
mock_read_frame.assert_called_once()
mock_callback.assert_called_once_with(frame=mock_frame)
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_consumer_with_connect_reconnect(self, mock_log):
"""Test consumer tries to reconnect when not connected."""
mock_callback = mock.MagicMock()
# Configure driver for test
self.driver._connected = False
# Setup to run once then stop
call_count = 0
def connect_side_effect():
nonlocal call_count
call_count += 1
# On second call, connect successfully
if call_count == 2:
self.driver._running = False
self.driver.socket = self.mock_socket
return True
return False
with mock.patch.object(
self.driver, 'connect', side_effect=connect_side_effect
) as mock_connect:
with mock.patch('aprsd.client.drivers.tcpkiss.time.sleep') as mock_sleep:
self.driver.consumer(mock_callback)
self.assertEqual(mock_connect.call_count, 2)
mock_sleep.assert_called_once_with(1)
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_read_frame_success(self, mock_log):
"""Test read_frame successfully reads a frame."""
# Set up driver
self.driver.socket = self.mock_socket
self.driver._running = True
# Mock socket recv to return data
raw_data = b'\xc0\x00test_frame\xc0'
self.mock_socket.recv.return_value = raw_data
# Mock select to indicate socket is readable
self.mock_select.select.return_value = ([self.mock_socket], [], [])
# Mock fix_raw_frame and Frame.from_bytes
mock_fixed_frame = b'fixed_frame'
mock_ax25_frame = mock.MagicMock()
with mock.patch.object(
self.driver, 'fix_raw_frame', return_value=mock_fixed_frame
) as mock_fix:
with mock.patch(
'aprsd.client.drivers.tcpkiss.ax25frame.Frame.from_bytes',
return_value=mock_ax25_frame,
) as mock_from_bytes:
result = self.driver.read_frame()
self.mock_socket.setblocking.assert_called_once_with(0)
self.mock_select.select.assert_called_once()
self.mock_socket.recv.assert_called_once()
mock_fix.assert_called_once_with(raw_data)
mock_from_bytes.assert_called_once_with(mock_fixed_frame)
self.assertEqual(result, mock_ax25_frame)
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_read_frame_select_timeout(self, mock_log):
"""Test read_frame handles select timeout."""
# Set up driver
self.driver.socket = self.mock_socket
self.driver._running = True
# Mock select to indicate no readable sockets
self.mock_select.select.return_value = ([], [], [])
result = self.driver.read_frame()
self.assertIsNone(result)
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_read_frame_socket_error(self, mock_log):
"""Test read_frame handles socket error."""
# Set up driver
self.driver.socket = self.mock_socket
self.driver._running = True
# Mock setblocking to raise OSError
self.mock_socket.setblocking.side_effect = OSError('Test error')
with self.assertRaises(aprslib.ConnectionDrop):
self.driver.read_frame()
mock_log.error.assert_called_once()
if __name__ == '__main__':
unittest.main()
-89
View File
@@ -1,89 +0,0 @@
import datetime
import unittest
from unittest import mock
from aprsd import exception
from aprsd.client.aprsis import APRSISClient
class TestAPRSISClient(unittest.TestCase):
"""Test cases for APRSISClient."""
def setUp(self):
"""Set up test fixtures."""
super().setUp()
# Mock the config
self.mock_conf = mock.MagicMock()
self.mock_conf.aprs_network.enabled = True
self.mock_conf.aprs_network.login = "TEST"
self.mock_conf.aprs_network.password = "12345"
self.mock_conf.aprs_network.host = "localhost"
self.mock_conf.aprs_network.port = 14580
@mock.patch("aprsd.client.base.APRSClient")
@mock.patch("aprsd.client.drivers.aprsis.Aprsdis")
def test_stats_not_configured(self, mock_aprsdis, mock_base):
"""Test stats when client is not configured."""
mock_client = mock.MagicMock()
mock_aprsdis.return_value = mock_client
with mock.patch("aprsd.client.aprsis.cfg.CONF", self.mock_conf):
self.client = APRSISClient()
with mock.patch.object(APRSISClient, "is_configured", return_value=False):
stats = self.client.stats()
self.assertEqual({}, stats)
@mock.patch("aprsd.client.base.APRSClient")
@mock.patch("aprsd.client.drivers.aprsis.Aprsdis")
def test_stats_configured(self, mock_aprsdis, mock_base):
"""Test stats when client is configured."""
mock_client = mock.MagicMock()
mock_aprsdis.return_value = mock_client
with mock.patch("aprsd.client.aprsis.cfg.CONF", self.mock_conf):
self.client = APRSISClient()
mock_client = mock.MagicMock()
mock_client.server_string = "test.server:14580"
mock_client.aprsd_keepalive = datetime.datetime.now()
self.client._client = mock_client
self.client.filter = "m/50"
with mock.patch.object(APRSISClient, "is_configured", return_value=True):
stats = self.client.stats()
from rich.console import Console
c = Console()
c.print(stats)
self.assertEqual(
{
"connected": True,
"filter": "m/50",
"login_status": {"message": mock.ANY, "success": True},
"connection_keepalive": mock_client.aprsd_keepalive,
"server_string": mock_client.server_string,
"transport": "aprsis",
},
stats,
)
def test_is_configured_missing_login(self):
"""Test is_configured with missing login."""
self.mock_conf.aprs_network.login = None
with self.assertRaises(exception.MissingConfigOptionException):
APRSISClient.is_configured()
def test_is_configured_missing_password(self):
"""Test is_configured with missing password."""
self.mock_conf.aprs_network.password = None
with self.assertRaises(exception.MissingConfigOptionException):
APRSISClient.is_configured()
def test_is_configured_missing_host(self):
"""Test is_configured with missing host."""
self.mock_conf.aprs_network.host = None
with mock.patch("aprsd.client.aprsis.cfg.CONF", self.mock_conf):
with self.assertRaises(exception.MissingConfigOptionException):
APRSISClient.is_configured()
-141
View File
@@ -1,141 +0,0 @@
import unittest
from unittest import mock
from aprsd.client.base import APRSClient
from aprsd.packets import core
class MockAPRSClient(APRSClient):
"""Concrete implementation of APRSClient for testing."""
def stats(self):
return {"packets_received": 0, "packets_sent": 0}
def setup_connection(self):
mock_connection = mock.MagicMock()
# Configure the mock with required methods
mock_connection.close = mock.MagicMock()
mock_connection.stop = mock.MagicMock()
mock_connection.set_filter = mock.MagicMock()
mock_connection.send = mock.MagicMock()
self._client = mock_connection
return mock_connection
def decode_packet(self, *args, **kwargs):
return mock.MagicMock()
def consumer(self, callback, blocking=False, immortal=False, raw=False):
pass
def is_alive(self):
return True
def close(self):
pass
@staticmethod
def is_enabled():
return True
@staticmethod
def transport():
return "mock"
def reset(self):
"""Mock implementation of reset."""
if self._client:
self._client.close()
self._client = self.setup_connection()
if self.filter:
self._client.set_filter(self.filter)
class TestAPRSClient(unittest.TestCase):
def setUp(self):
# Reset the singleton instance before each test
APRSClient._instance = None
APRSClient._client = None
self.client = MockAPRSClient()
def test_singleton_pattern(self):
"""Test that multiple instantiations return the same instance."""
client1 = MockAPRSClient()
client2 = MockAPRSClient()
self.assertIs(client1, client2)
def test_set_filter(self):
"""Test setting APRS filter."""
# Get the existing mock client that was created in __init__
mock_client = self.client._client
test_filter = "m/50"
self.client.set_filter(test_filter)
self.assertEqual(self.client.filter, test_filter)
# The filter is set once during set_filter() and once during reset()
mock_client.set_filter.assert_called_with(test_filter)
@mock.patch("aprsd.client.base.LOG")
def test_reset(self, mock_log):
"""Test client reset functionality."""
# Create a new mock client with the necessary methods
old_client = mock.MagicMock()
self.client._client = old_client
self.client.reset()
# Verify the old client was closed
old_client.close.assert_called_once()
# Verify a new client was created
self.assertIsNotNone(self.client._client)
self.assertNotEqual(old_client, self.client._client)
def test_send_packet(self):
"""Test sending an APRS packet."""
mock_packet = mock.Mock(spec=core.Packet)
self.client.send(mock_packet)
self.client._client.send.assert_called_once_with(mock_packet)
def test_stop(self):
"""Test stopping the client."""
# Ensure client is created first
self.client._create_client()
self.client.stop()
self.client._client.stop.assert_called_once()
@mock.patch("aprsd.client.base.LOG")
def test_create_client_failure(self, mock_log):
"""Test handling of client creation failure."""
# Make setup_connection raise an exception
with mock.patch.object(
self.client,
"setup_connection",
side_effect=Exception("Connection failed"),
):
with self.assertRaises(Exception):
self.client._create_client()
self.assertIsNone(self.client._client)
mock_log.error.assert_called_once()
def test_client_property(self):
"""Test the client property creates client if none exists."""
self.client._client = None
client = self.client.client
self.assertIsNotNone(client)
def test_filter_applied_on_creation(self):
"""Test that filter is applied when creating new client."""
test_filter = "m/50"
self.client.set_filter(test_filter)
# Force client recreation
self.client.reset()
# Verify filter was applied to new client
self.client._client.set_filter.assert_called_with(test_filter)
if __name__ == "__main__":
unittest.main()
-75
View File
@@ -1,75 +0,0 @@
import unittest
from unittest import mock
from aprsd.client.factory import Client, ClientFactory
class MockClient:
"""Mock client for testing."""
@classmethod
def is_enabled(cls):
return True
@classmethod
def is_configured(cls):
return True
class TestClientFactory(unittest.TestCase):
"""Test cases for ClientFactory."""
def setUp(self):
"""Set up test fixtures."""
self.factory = ClientFactory()
# Clear any registered clients from previous tests
self.factory.clients = []
def test_singleton(self):
"""Test that ClientFactory is a singleton."""
factory2 = ClientFactory()
self.assertEqual(self.factory, factory2)
def test_register_client(self):
"""Test registering a client."""
self.factory.register(MockClient)
self.assertIn(MockClient, self.factory.clients)
def test_register_invalid_client(self):
"""Test registering an invalid client raises error."""
invalid_client = mock.MagicMock(spec=Client)
with self.assertRaises(ValueError):
self.factory.register(invalid_client)
def test_create_client(self):
"""Test creating a client."""
self.factory.register(MockClient)
client = self.factory.create()
self.assertIsInstance(client, MockClient)
def test_create_no_clients(self):
"""Test creating a client with no registered clients."""
with self.assertRaises(Exception):
self.factory.create()
def test_is_client_enabled(self):
"""Test checking if any client is enabled."""
self.factory.register(MockClient)
self.assertTrue(self.factory.is_client_enabled())
def test_is_client_enabled_none(self):
"""Test checking if any client is enabled when none are."""
MockClient.is_enabled = classmethod(lambda cls: False)
self.factory.register(MockClient)
self.assertFalse(self.factory.is_client_enabled())
def test_is_client_configured(self):
"""Test checking if any client is configured."""
self.factory.register(MockClient)
self.assertTrue(self.factory.is_client_configured())
def test_is_client_configured_none(self):
"""Test checking if any client is configured when none are."""
MockClient.is_configured = classmethod(lambda cls: False)
self.factory.register(MockClient)
self.assertFalse(self.factory.is_client_configured())
+100
View File
@@ -0,0 +1,100 @@
import unittest
from unittest import mock
from aprsd.client.drivers.registry import DriverRegistry
from ..mock_client_driver import MockClientDriver
class TestDriverRegistry(unittest.TestCase):
"""Unit tests for the DriverRegistry class."""
def setUp(self):
# Reset the singleton instance before each test
DriverRegistry._singleton_instances = {}
self.registry = DriverRegistry()
self.registry.drivers = []
# Mock APRSISDriver completely
self.aprsis_patcher = mock.patch('aprsd.client.drivers.aprsis.APRSISDriver')
mock_aprsis_class = self.aprsis_patcher.start()
mock_aprsis_class.is_enabled.return_value = False
mock_aprsis_class.is_configured.return_value = False
# Mock the instance methods as well
mock_instance = mock_aprsis_class.return_value
mock_instance.is_enabled.return_value = False
mock_instance.is_configured.return_value = False
# Mock CONF to prevent password check
self.conf_patcher = mock.patch('aprsd.client.drivers.aprsis.CONF')
mock_conf = self.conf_patcher.start()
mock_conf.aprs_network.password = 'dummy'
mock_conf.aprs_network.login = 'dummy'
def tearDown(self):
# Reset the singleton instance after each test
DriverRegistry().drivers = []
self.aprsis_patcher.stop()
self.conf_patcher.stop()
def test_get_driver_with_valid_driver(self):
"""Test getting an enabled and configured driver."""
# Add an enabled and configured driver
driver = MockClientDriver
driver.is_enabled = mock.MagicMock(return_value=True)
driver.is_configured = mock.MagicMock(return_value=True)
self.registry.register(MockClientDriver)
# Get the driver
result = self.registry.get_driver()
print(result)
self.assertTrue(isinstance(result, MockClientDriver))
def test_get_driver_with_disabled_driver(self):
"""Test getting a driver when only disabled drivers exist."""
driver = MockClientDriver
driver.is_enabled = mock.MagicMock(return_value=False)
driver.is_configured = mock.MagicMock(return_value=False)
self.registry.register(driver)
with self.assertRaises(ValueError) as context:
self.registry.get_driver()
self.assertIn('No enabled driver found', str(context.exception))
def test_get_driver_with_unconfigured_driver(self):
"""Test getting a driver when only unconfigured drivers exist."""
driver = MockClientDriver
driver.is_enabled = mock.MagicMock(return_value=True)
driver.is_configured = mock.MagicMock(return_value=False)
self.registry.register(driver)
with self.assertRaises(ValueError) as context:
self.registry.get_driver()
self.assertIn('No enabled driver found', str(context.exception))
def test_get_driver_with_no_drivers(self):
"""Test getting a driver when no drivers exist."""
# Try to get a driver
with self.assertRaises(ValueError) as context:
self.registry.get_driver()
self.assertIn('No enabled driver found', str(context.exception))
def test_get_driver_with_multiple_drivers(self):
"""Test getting a driver when multiple valid drivers exist."""
# Add multiple drivers
driver1 = MockClientDriver
driver1.is_enabled = mock.MagicMock(return_value=True)
driver1.is_configured = mock.MagicMock(return_value=True)
driver2 = MockClientDriver
self.registry.register(driver1)
self.registry.register(driver2)
# Get the driver - should return the first one
result = self.registry.get_driver()
# We can only check that it's a MockDriver instance
self.assertTrue(isinstance(result, MockClientDriver))
if __name__ == '__main__':
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
from unittest import mock
from aprsd.packets import core
class MockClientDriver:
"""Mock implementation of ClientDriver for testing."""
def __init__(self, enabled=True, configured=True):
self.connected = False
self._alive = True
self._keepalive = None
self.filter = None
self._enabled = enabled
self._configured = configured
self.path = '/dev/ttyUSB0'
self.login_status = {
'success': True,
'message': None,
}
@staticmethod
def is_enabled():
"""Static method to check if driver is enabled."""
return True
@staticmethod
def is_configured():
"""Static method to check if driver is configured."""
return True
def is_alive(self):
"""Instance method to check if driver is alive."""
return self._alive
def stats(self, serializable=False):
"""Return mock stats."""
stats = {'packets_received': 0, 'packets_sent': 0}
if serializable:
stats['path'] = self.path
return stats
@property
def login_success(self):
"""Property to get login success status."""
return self.login_status['success']
@property
def login_failure(self):
"""Property to get login failure message."""
return self.login_status['message']
def decode_packet(self, *args, **kwargs):
"""Mock packet decoding."""
packet = mock.MagicMock(spec=core.Packet)
packet.raw = 'test packet'
return packet
def close(self):
self.connected = False
def setup_connection(self):
self.connected = True
def send(self, packet):
return True
def set_filter(self, filter_str):
self.filter = filter_str
@property
def keepalive(self):
return self._keepalive
def consumer(self, callback, raw=False):
pass
+43 -36
View File
@@ -7,9 +7,11 @@ from aprsd import ( # noqa: F401
conf,
packets,
)
from aprsd.client.drivers.registry import DriverRegistry
from aprsd.plugins import notify as notify_plugin
from .. import fake, test_plugin
from ..mock_client_driver import MockClientDriver
CONF = cfg.CONF
DEFAULT_WATCHLIST_CALLSIGNS = fake.FAKE_FROM_CALLSIGN
@@ -17,9 +19,24 @@ DEFAULT_WATCHLIST_CALLSIGNS = fake.FAKE_FROM_CALLSIGN
class TestWatchListPlugin(test_plugin.TestPlugin):
def setUp(self):
super().setUp()
self.fromcall = fake.FAKE_FROM_CALLSIGN
self.ack = 1
# Mock APRSISDriver
self.aprsis_patcher = mock.patch('aprsd.client.drivers.aprsis.APRSISDriver')
self.mock_aprsis = self.aprsis_patcher.start()
self.mock_aprsis.is_enabled.return_value = False
self.mock_aprsis.is_configured.return_value = False
# Register the mock driver
DriverRegistry().register(MockClientDriver)
def tearDown(self):
super().tearDown()
if hasattr(self, 'aprsis_patcher'):
self.aprsis_patcher.stop()
def config_and_init(
self,
watchlist_enabled=True,
@@ -30,7 +47,9 @@ class TestWatchListPlugin(test_plugin.TestPlugin):
):
CONF.callsign = self.fromcall
CONF.aprs_network.login = self.fromcall
CONF.aprs_fi.apiKey = "something"
CONF.aprs_fi.apiKey = 'something'
# Add mock password
CONF.aprs_network.password = '12345'
# Set the watchlist specific config options
CONF.watch_list.enabled = watchlist_enabled
@@ -56,22 +75,20 @@ class TestAPRSDWatchListPluginBase(TestWatchListPlugin):
plugin = fake.FakeWatchListPlugin()
packet = fake.fake_packet(
message="version",
message='version',
msg_number=1,
)
actual = plugin.filter(packet)
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.ClientFactory", autospec=True)
def test_watchlist_not_in_watchlist(self, mock_factory):
client.client_factory = mock_factory
def test_watchlist_not_in_watchlist(self):
self.config_and_init()
plugin = fake.FakeWatchListPlugin()
packet = fake.fake_packet(
fromcall="FAKE",
message="version",
fromcall='FAKE',
message='version',
msg_number=1,
)
actual = plugin.filter(packet)
@@ -85,87 +102,77 @@ class TestNotifySeenPlugin(TestWatchListPlugin):
plugin = notify_plugin.NotifySeenPlugin()
packet = fake.fake_packet(
message="version",
message='version',
msg_number=1,
)
actual = plugin.filter(packet)
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.ClientFactory", autospec=True)
def test_callsign_not_in_watchlist(self, mock_factory):
client.client_factory = mock_factory
def test_callsign_not_in_watchlist(self):
self.config_and_init(watchlist_enabled=False)
plugin = notify_plugin.NotifySeenPlugin()
packet = fake.fake_packet(
message="version",
message='version',
msg_number=1,
)
actual = plugin.filter(packet)
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.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
@mock.patch('aprsd.packets.WatchList.is_old')
def test_callsign_in_watchlist_not_old(self, mock_is_old):
mock_is_old.return_value = False
self.config_and_init(
watchlist_enabled=True,
watchlist_callsigns=["WB4BOR"],
watchlist_callsigns=['WB4BOR'],
)
plugin = notify_plugin.NotifySeenPlugin()
packet = fake.fake_packet(
fromcall="WB4BOR",
message="ping",
fromcall='WB4BOR',
message='ping',
msg_number=1,
)
actual = plugin.filter(packet)
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.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
@mock.patch('aprsd.packets.WatchList.is_old')
def test_callsign_in_watchlist_old_same_alert_callsign(self, mock_is_old):
mock_is_old.return_value = True
self.config_and_init(
watchlist_enabled=True,
watchlist_alert_callsign="WB4BOR",
watchlist_callsigns=["WB4BOR"],
watchlist_alert_callsign='WB4BOR',
watchlist_callsigns=['WB4BOR'],
)
plugin = notify_plugin.NotifySeenPlugin()
packet = fake.fake_packet(
fromcall="WB4BOR",
message="ping",
fromcall='WB4BOR',
message='ping',
msg_number=1,
)
actual = plugin.filter(packet)
expected = packets.NULL_MESSAGE
self.assertEqual(expected, actual)
@mock.patch("aprsd.client.factory.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
@mock.patch('aprsd.packets.WatchList.is_old')
def test_callsign_in_watchlist_old_send_alert(self, mock_is_old):
mock_is_old.return_value = True
notify_callsign = fake.FAKE_TO_CALLSIGN
fromcall = "WB4BOR"
fromcall = 'WB4BOR'
self.config_and_init(
watchlist_enabled=True,
watchlist_alert_callsign=notify_callsign,
watchlist_callsigns=["WB4BOR"],
watchlist_callsigns=['WB4BOR'],
)
plugin = notify_plugin.NotifySeenPlugin()
packet = fake.fake_packet(
fromcall=fromcall,
message="ping",
message='ping',
msg_number=1,
)
packet_type = packet.__class__.__name__
+35 -6
View File
@@ -3,6 +3,7 @@ from unittest import mock
from oslo_config import cfg
import aprsd
from aprsd.client.drivers.fake import APRSDFakeDriver
from aprsd.plugins import version as version_plugin
from .. import fake, test_plugin
@@ -11,16 +12,41 @@ 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"
expected = f"APRSD ver:{aprsd.__version__} uptime:00:00:00"
def setUp(self):
# make sure the fake client driver is enabled
# Mock CONF for testing
super().setUp()
self.conf_patcher = mock.patch('aprsd.client.drivers.fake.CONF')
self.mock_conf = self.conf_patcher.start()
# Configure fake_client.enabled
self.mock_conf.fake_client.enabled = True
# Create an instance of the driver
self.driver = APRSDFakeDriver()
self.fromcall = fake.FAKE_FROM_CALLSIGN
def tearDown(self):
self.conf_patcher.stop()
super().tearDown()
@mock.patch('aprsd.stats.collector.Collector')
def test_version(self, mock_collector_class):
# Set up the mock collector instance
mock_collector_instance = mock_collector_class.return_value
mock_collector_instance.collect.return_value = {
'APRSDStats': {
'uptime': '00:00:00',
}
}
expected = f'APRSD ver:{aprsd.__version__} uptime:00:00:00'
CONF.callsign = fake.FAKE_TO_CALLSIGN
version = version_plugin.VersionPlugin()
version.enabled = True
packet = fake.fake_packet(
message="No",
message='No',
msg_number=1,
)
@@ -28,8 +54,11 @@ class TestVersionPlugin(test_plugin.TestPlugin):
self.assertEqual(None, actual)
packet = fake.fake_packet(
message="version",
message='version',
msg_number=1,
)
actual = version.filter(packet)
self.assertEqual(expected, actual)
# Verify the mock was called exactly once
mock_collector_instance.collect.assert_called_once()
+38 -18
View File
@@ -9,9 +9,11 @@ from aprsd import ( # noqa: F401
plugins,
)
from aprsd import plugin as aprsd_plugin
from aprsd.client.drivers.registry import DriverRegistry
from aprsd.packets import core
from . import fake
from .mock_client_driver import MockClientDriver
CONF = cfg.CONF
@@ -21,15 +23,24 @@ class TestPluginManager(unittest.TestCase):
self.fromcall = fake.FAKE_FROM_CALLSIGN
self.config_and_init()
self.mock_driver = MockClientDriver()
# Mock the DriverRegistry to return our mock driver
self.registry_patcher = mock.patch.object(
DriverRegistry, 'get_driver', return_value=self.mock_driver
)
self.mock_registry = self.registry_patcher.start()
def tearDown(self) -> None:
self.config = None
aprsd_plugin.PluginManager._instance = None
self.registry_patcher.stop()
self.mock_registry.stop()
def config_and_init(self):
CONF.callsign = self.fromcall
CONF.aprs_network.login = fake.FAKE_TO_CALLSIGN
CONF.aprs_fi.apiKey = "something"
CONF.enabled_plugins = "aprsd.plugins.ping.PingPlugin"
CONF.aprs_fi.apiKey = 'something'
CONF.enabled_plugins = 'aprsd.plugins.ping.PingPlugin'
CONF.enable_save = False
def test_get_plugins_no_plugins(self):
@@ -39,7 +50,7 @@ class TestPluginManager(unittest.TestCase):
self.assertEqual([], plugin_list)
def test_get_plugins_with_plugins(self):
CONF.enabled_plugins = ["aprsd.plugins.ping.PingPlugin"]
CONF.enabled_plugins = ['aprsd.plugins.ping.PingPlugin']
pm = aprsd_plugin.PluginManager()
plugin_list = pm.get_plugins()
self.assertEqual([], plugin_list)
@@ -64,7 +75,7 @@ class TestPluginManager(unittest.TestCase):
self.assertEqual(0, len(plugin_list))
def test_get_message_plugins(self):
CONF.enabled_plugins = ["aprsd.plugins.ping.PingPlugin"]
CONF.enabled_plugins = ['aprsd.plugins.ping.PingPlugin']
pm = aprsd_plugin.PluginManager()
plugin_list = pm.get_plugins()
self.assertEqual([], plugin_list)
@@ -87,22 +98,31 @@ class TestPlugin(unittest.TestCase):
self.ack = 1
self.config_and_init()
self.mock_driver = MockClientDriver()
# Mock the DriverRegistry to return our mock driver
self.registry_patcher = mock.patch.object(
DriverRegistry, 'get_driver', return_value=self.mock_driver
)
self.mock_registry = self.registry_patcher.start()
def tearDown(self) -> None:
packets.WatchList._instance = None
packets.SeenList._instance = None
packets.PacketTrack._instance = None
self.config = None
self.registry_patcher.stop()
self.mock_registry.stop()
def config_and_init(self):
CONF.callsign = self.fromcall
CONF.aprs_network.login = fake.FAKE_TO_CALLSIGN
CONF.aprs_fi.apiKey = "something"
CONF.enabled_plugins = "aprsd.plugins.ping.PingPlugin"
CONF.aprs_fi.apiKey = 'something'
CONF.enabled_plugins = 'aprsd.plugins.ping.PingPlugin'
CONF.enable_save = False
class TestPluginBase(TestPlugin):
@mock.patch.object(fake.FakeBaseNoThreadsPlugin, "process")
@mock.patch.object(fake.FakeBaseNoThreadsPlugin, 'process')
def test_base_plugin_no_threads(self, mock_process):
p = fake.FakeBaseNoThreadsPlugin()
@@ -110,7 +130,7 @@ class TestPluginBase(TestPlugin):
actual = p.create_threads()
self.assertEqual(expected, actual)
expected = "1.0"
expected = '1.0'
actual = p.version
self.assertEqual(expected, actual)
@@ -123,7 +143,7 @@ class TestPluginBase(TestPlugin):
self.assertEqual(expected, actual)
mock_process.assert_not_called()
@mock.patch.object(fake.FakeBaseThreadsPlugin, "create_threads")
@mock.patch.object(fake.FakeBaseThreadsPlugin, 'create_threads')
def test_base_plugin_threads_created(self, mock_create):
p = fake.FakeBaseThreadsPlugin()
mock_create.assert_called_once()
@@ -135,17 +155,17 @@ class TestPluginBase(TestPlugin):
self.assertTrue(isinstance(actual, fake.FakeThread))
p.stop_threads()
@mock.patch.object(fake.FakeRegexCommandPlugin, "process")
@mock.patch.object(fake.FakeRegexCommandPlugin, 'process')
def test_regex_base_not_called(self, mock_process):
CONF.callsign = fake.FAKE_TO_CALLSIGN
p = fake.FakeRegexCommandPlugin()
packet = fake.fake_packet(message="a")
packet = fake.fake_packet(message='a')
expected = None
actual = p.filter(packet)
self.assertEqual(expected, actual)
mock_process.assert_not_called()
packet = fake.fake_packet(tocall="notMe", message="f")
packet = fake.fake_packet(tocall='notMe', message='f')
expected = None
actual = p.filter(packet)
self.assertEqual(expected, actual)
@@ -165,11 +185,11 @@ class TestPluginBase(TestPlugin):
self.assertEqual(expected, actual)
mock_process.assert_not_called()
@mock.patch.object(fake.FakeRegexCommandPlugin, "process")
@mock.patch.object(fake.FakeRegexCommandPlugin, 'process')
def test_regex_base_assert_called(self, mock_process):
CONF.callsign = fake.FAKE_TO_CALLSIGN
p = fake.FakeRegexCommandPlugin()
packet = fake.fake_packet(message="f")
packet = fake.fake_packet(message='f')
p.filter(packet)
mock_process.assert_called_once()
@@ -177,22 +197,22 @@ class TestPluginBase(TestPlugin):
CONF.callsign = fake.FAKE_TO_CALLSIGN
p = fake.FakeRegexCommandPlugin()
packet = fake.fake_packet(message="f")
packet = fake.fake_packet(message='f')
expected = fake.FAKE_MESSAGE_TEXT
actual = p.filter(packet)
self.assertEqual(expected, actual)
packet = fake.fake_packet(message="F")
packet = fake.fake_packet(message='F')
expected = fake.FAKE_MESSAGE_TEXT
actual = p.filter(packet)
self.assertEqual(expected, actual)
packet = fake.fake_packet(message="fake")
packet = fake.fake_packet(message='fake')
expected = fake.FAKE_MESSAGE_TEXT
actual = p.filter(packet)
self.assertEqual(expected, actual)
packet = fake.fake_packet(message="FAKE")
packet = fake.fake_packet(message='FAKE')
expected = fake.FAKE_MESSAGE_TEXT
actual = p.filter(packet)
self.assertEqual(expected, actual)
+1 -1
View File
@@ -25,7 +25,7 @@ deps =
pytest-cov
pytest
commands =
pytest -v --cov-report term-missing --cov=aprsd {posargs}
pytest -s -v --cov-report term-missing --cov=aprsd {posargs}
coverage: coverage report -m
coverage: coverage xml
Generated
+561 -555
View File
File diff suppressed because it is too large Load Diff