diff --git a/aprsd/client.py b/aprsd/client.py index 3cfed8a..6a8b26d 100644 --- a/aprsd/client.py +++ b/aprsd/client.py @@ -137,7 +137,7 @@ class APRSISClient(Client): def decode_packet(self, *args, **kwargs): """APRS lib already decodes this.""" - return core.Packet.factory(args[0]) + return core.factory(args[0]) def setup_connection(self): user = CONF.aprs_network.login @@ -238,7 +238,7 @@ class KISSClient(Client): # LOG.debug(f"Decoding {msg}") raw = aprslib.parse(str(frame)) - packet = core.Packet.factory(raw) + packet = core.factory(raw) if isinstance(packet, core.ThirdParty): return packet.subpacket else: diff --git a/aprsd/clients/fake.py b/aprsd/clients/fake.py index b468808..c93a6c3 100644 --- a/aprsd/clients/fake.py +++ b/aprsd/clients/fake.py @@ -67,7 +67,7 @@ class APRSDFakeClient(metaclass=trace.TraceWrapperMetaclass): # Generate packets here? raw = "GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW" pkt_raw = aprslib.parse(raw) - pkt = core.Packet.factory(pkt_raw) + pkt = core.factory(pkt_raw) callback(packet=pkt) LOG.debug(f"END blocking FAKE consumer {self}") time.sleep(8) diff --git a/aprsd/cmds/listen.py b/aprsd/cmds/listen.py index 92c0007..93564ac 100644 --- a/aprsd/cmds/listen.py +++ b/aprsd/cmds/listen.py @@ -17,6 +17,7 @@ from rich.console import Console import aprsd from aprsd import cli_helper, client, packets, plugin, stats, threads from aprsd.main import cli +from aprsd.packets import log as packet_log from aprsd.rpc import server as rpc_server from aprsd.threads import rx @@ -53,27 +54,31 @@ class APRSDListenThread(rx.APRSDRXThread): filters = { packets.Packet.__name__: packets.Packet, packets.AckPacket.__name__: packets.AckPacket, + packets.BeaconPacket.__name__: packets.BeaconPacket, packets.GPSPacket.__name__: packets.GPSPacket, packets.MessagePacket.__name__: packets.MessagePacket, packets.MicEPacket.__name__: packets.MicEPacket, + packets.ObjectPacket.__name__: packets.ObjectPacket, + packets.StatusPacket.__name__: packets.StatusPacket, + packets.ThirdPartyPacket.__name__: packets.ThirdPartyPacket, packets.WeatherPacket.__name__: packets.WeatherPacket, + packets.UnknownPacket.__name__: packets.UnknownPacket, } if self.packet_filter: filter_class = filters[self.packet_filter] if isinstance(packet, filter_class): - packet.log(header="RX") + packet_log.log(packet) if self.plugin_manager: # Don't do anything with the reply # This is the listen only command. self.plugin_manager.run(packet) else: + packet_log.log(packet) if self.plugin_manager: # Don't do anything with the reply. # This is the listen only command. self.plugin_manager.run(packet) - else: - packet.log(header="RX") packets.PacketList().rx(packet) @@ -96,11 +101,16 @@ class APRSDListenThread(rx.APRSDRXThread): "--packet-filter", type=click.Choice( [ - packets.Packet.__name__, packets.AckPacket.__name__, + packets.BeaconPacket.__name__, packets.GPSPacket.__name__, packets.MicEPacket.__name__, packets.MessagePacket.__name__, + packets.ObjectPacket.__name__, + packets.RejectPacket.__name__, + packets.StatusPacket.__name__, + packets.ThirdPartyPacket.__name__, + packets.UnknownPacket.__name__, packets.WeatherPacket.__name__, ], case_sensitive=False, @@ -180,7 +190,7 @@ def listen( aprs_client.set_filter(filter) keepalive = threads.KeepAliveThread() - keepalive.start() + # keepalive.start() if CONF.rpc_settings.enabled: rpc = rpc_server.APRSDRPCThread() @@ -205,6 +215,8 @@ def listen( ) LOG.debug("Start APRSDListenThread") listen_thread.start() + + keepalive.start() LOG.debug("keepalive Join") keepalive.join() LOG.debug("listen_thread Join") diff --git a/aprsd/cmds/webchat.py b/aprsd/cmds/webchat.py index d6bd1f3..0a3f68e 100644 --- a/aprsd/cmds/webchat.py +++ b/aprsd/cmds/webchat.py @@ -7,7 +7,6 @@ import sys import threading import time -from aprslib import util as aprslib_util import click import flask from flask import request @@ -22,7 +21,6 @@ import aprsd from aprsd import ( cli_helper, client, packets, plugin_utils, stats, threads, utils, ) -from aprsd.log import log from aprsd.main import cli from aprsd.threads import aprsd as aprsd_threads from aprsd.threads import rx, tx @@ -30,7 +28,7 @@ from aprsd.utils import trace CONF = cfg.CONF -LOG = logging.getLogger("APRSD") +LOG = logging.getLogger() auth = HTTPBasicAuth() users = {} socketio = None @@ -335,7 +333,6 @@ class WebChatProcessPacketThread(rx.APRSDProcessPacketThread): def process_our_message_packet(self, packet: packets.MessagePacket): global callsign_locations - LOG.info(f"process MessagePacket {repr(packet)}") # ok lets see if we have the location for the # person we just sent a message to. from_call = packet.get("from_call").upper() @@ -541,10 +538,10 @@ class SendMessageNamespace(Namespace): def on_gps(self, data): LOG.debug(f"WS on_GPS: {data}") - lat = aprslib_util.latitude_to_ddm(data["latitude"]) - long = aprslib_util.longitude_to_ddm(data["longitude"]) - LOG.debug(f"Lat DDM {lat}") - LOG.debug(f"Long DDM {long}") + lat = data["latitude"] + long = data["longitude"] + LOG.debug(f"Lat {lat}") + LOG.debug(f"Long {long}") tx.send( packets.GPSPacket( @@ -572,8 +569,6 @@ class SendMessageNamespace(Namespace): def init_flask(loglevel, quiet): global socketio, flask_app - log.setup_logging(loglevel, quiet) - socketio = SocketIO( flask_app, logger=False, engineio_logger=False, async_mode="threading", @@ -624,7 +619,7 @@ def webchat(ctx, flush, port): LOG.info(msg) LOG.info(f"APRSD Started version: {aprsd.__version__}") - CONF.log_opt_values(LOG, logging.DEBUG) + CONF.log_opt_values(logging.getLogger(), logging.DEBUG) user = CONF.admin.user users[user] = generate_password_hash(CONF.admin.password) if not port: diff --git a/aprsd/conf/common.py b/aprsd/conf/common.py index dd2794a..877d49a 100644 --- a/aprsd/conf/common.py +++ b/aprsd/conf/common.py @@ -101,6 +101,14 @@ aprsd_opts = [ default=None, help="Longitude for the GPS Beacon button. If not set, the button will not be enabled.", ), + cfg.StrOpt( + "log_packet_format", + choices=["compact", "multiline", "both"], + default="compact", + help="When logging packets 'compact' will use a single line formatted for each packet." + "'multiline' will use multiple lines for each packet and is the traditional format." + "both will log both compact and multiline.", + ), ] watch_list_opts = [ @@ -225,6 +233,11 @@ webchat_opts = [ default=None, help="Longitude for the GPS Beacon button. If not set, the button will not be enabled.", ), + cfg.BoolOpt( + "disable_url_request_logging", + default=False, + help="Disable the logging of url requests in the webchat command.", + ), ] registry_opts = [ diff --git a/aprsd/log/log.py b/aprsd/log/log.py index e1ee3e7..fcdb620 100644 --- a/aprsd/log/log.py +++ b/aprsd/log/log.py @@ -10,7 +10,8 @@ from aprsd.conf import log as conf_log CONF = cfg.CONF -LOG = logging.getLogger("APRSD") +# LOG = logging.getLogger("APRSD") +LOG = logger logging_queue = queue.Queue() @@ -35,6 +36,7 @@ class InterceptHandler(logging.Handler): # to disable log to stdout, but still log to file # use the --quiet option on the cmdln def setup_logging(loglevel=None, quiet=False): + print(f"setup_logging: loglevel={loglevel}, quiet={quiet}") if not loglevel: log_level = CONF.logging.log_level else: @@ -53,9 +55,13 @@ def setup_logging(loglevel=None, quiet=False): "aprslib.parsing", "aprslib.exceptions", ] + webserver_list = [ + "werkzeug", + "werkzeug._internal", + ] # We don't really want to see the aprslib parsing debug output. - disable_list = imap_list + aprslib_list + disable_list = imap_list + aprslib_list + webserver_list # remove every other logger's handlers # and propagate to root logger @@ -66,17 +72,29 @@ def setup_logging(loglevel=None, quiet=False): else: logging.getLogger(name).propagate = True + if CONF.webchat.disable_url_request_logging: + for name in webserver_list: + logging.getLogger(name).handlers = [] + logging.getLogger(name).propagate = True + logging.getLogger(name).setLevel(logging.ERROR) + handlers = [ { - "sink": sys.stdout, "serialize": False, + "sink": sys.stdout, + "serialize": False, "format": CONF.logging.logformat, + "colorize": True, + "level": log_level, }, ] if CONF.logging.logfile: handlers.append( { - "sink": CONF.logging.logfile, "serialize": False, + "sink": CONF.logging.logfile, + "serialize": False, "format": CONF.logging.logformat, + "colorize": False, + "level": log_level, }, ) @@ -90,8 +108,11 @@ def setup_logging(loglevel=None, quiet=False): { "sink": qh, "serialize": False, "format": CONF.logging.logformat, + "level": log_level, + "colorize": False, }, ) # configure loguru logger.configure(handlers=handlers) + logger.level("DEBUG", color="") diff --git a/aprsd/packets/__init__.py b/aprsd/packets/__init__.py index dcb2064..c83d002 100644 --- a/aprsd/packets/__init__.py +++ b/aprsd/packets/__init__.py @@ -1,6 +1,7 @@ from aprsd.packets.core import ( # noqa: F401 - AckPacket, BeaconPacket, GPSPacket, MessagePacket, MicEPacket, Packet, - RejectPacket, StatusPacket, WeatherPacket, + AckPacket, BeaconPacket, BulletinPacket, GPSPacket, MessagePacket, + MicEPacket, ObjectPacket, Packet, RejectPacket, StatusPacket, + ThirdPartyPacket, UnknownPacket, WeatherPacket, factory, ) from aprsd.packets.packet_list import PacketList # noqa: F401 from aprsd.packets.seen_list import SeenList # noqa: F401 diff --git a/aprsd/packets/core.py b/aprsd/packets/core.py index 17b1234..1665e29 100644 --- a/aprsd/packets/core.py +++ b/aprsd/packets/core.py @@ -1,30 +1,40 @@ -import abc -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from datetime import datetime import logging import re import time # Due to a failure in python 3.8 -from typing import List +from typing import Any, List, Optional, Type, TypeVar, Union -import dacite -from dataclasses_json import dataclass_json +from aprslib import util as aprslib_util +from dataclasses_json import ( + CatchAll, DataClassJsonMixin, Undefined, dataclass_json, +) +from loguru import logger from aprsd.utils import counter -LOG = logging.getLogger("APRSD") +# For mypy to be happy +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 = "weather" +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) @@ -52,68 +62,62 @@ def _init_msgNo(): # noqa: N802 return c.value -def factory_from_dict(packet_dict): - pkt_type = get_packet_type(packet_dict) - if pkt_type: - cls = TYPE_LOOKUP[pkt_type] - return cls.from_dict(packet_dict) +def _translate_fields(raw: dict) -> dict: + translate_fields = { + "from": "from_call", + "to": "to_call", + } + # First translate some fields + for key in translate_fields: + if key in raw: + raw[translate_fields[key]] = raw[key] + del raw[key] + # addresse overrides to_call + if "addresse" in raw: + raw["to_call"] = raw["addresse"] -def factory_from_json(packet_dict): - pkt_type = get_packet_type(packet_dict) - if pkt_type: - return TYPE_LOOKUP[pkt_type].from_json(packet_dict) + return raw @dataclass_json @dataclass(unsafe_hash=True) -class Packet(metaclass=abc.ABCMeta): - from_call: str = field(default=None) - to_call: str = field(default=None) - addresse: str = field(default=None) - format: str = field(default=None) - msgNo: str = field(default_factory=_init_msgNo) # noqa: N815 - packet_type: str = field(default=None) +class Packet: + _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) + format: Optional[str] = field(default=None) + msgNo: Optional[str] = field(default=None) # noqa: N815 + packet_type: Optional[str] = field(default=None) timestamp: float = field(default_factory=_init_timestamp, compare=False, hash=False) # Holds the raw text string to be sent over the wire # or holds the raw string from input packet - raw: str = field(default=None, compare=False, hash=False) + raw: Optional[str] = field(default=None, compare=False, hash=False) raw_dict: dict = field(repr=False, default_factory=lambda: {}, compare=False, hash=False) # Built by calling prepare(). raw needs this built first. - payload: str = field(default=None) + payload: Optional[str] = field(default=None) # Fields related to sending packets out send_count: int = field(repr=False, default=0, compare=False, hash=False) retry_count: int = field(repr=False, default=3, compare=False, hash=False) - # last_send_time: datetime = field( - # metadata=dc_json_config( - # encoder=datetime.isoformat, - # decoder=datetime.fromisoformat, - # ), - # repr=True, - # default_factory=_init_send_time, - # compare=False, - # hash=False - # ) last_send_time: float = field(repr=False, default=0, compare=False, hash=False) last_send_attempt: int = field(repr=False, default=0, compare=False, hash=False) # Do we allow this packet to be saved to send later? allow_delay: bool = field(repr=False, default=True, compare=False, hash=False) path: List[str] = field(default_factory=list, compare=False, hash=False) - via: str = field(default=None, compare=False, hash=False) - - def __post__init__(self): - LOG.warning(f"POST INIT {self}") + via: Optional[str] = field(default=None, compare=False, hash=False) @property def json(self): - """ - get the json formated string + """get the json formated string. + + This is used soley by the rpc server to return json over the wire. """ return self.to_json() - def get(self, key, default=None): + def get(self, key: str, default: Optional[str] = None): """Emulate a getter on a dict.""" if hasattr(self, key): return getattr(self, key) @@ -121,342 +125,212 @@ class Packet(metaclass=abc.ABCMeta): return default @property - def key(self): + def key(self) -> str: """Build a key for finding this packet in a dict.""" return f"{self.from_call}:{self.addresse}:{self.msgNo}" - def update_timestamp(self): + def update_timestamp(self) -> None: self.timestamp = _init_timestamp() - def prepare(self): + @property + def human_info(self) -> str: + """Build a human readable string for this packet. + + This doesn't include the from to and type, but just + the human readable payload. + """ + self.prepare() + msg = self._filter_for_send(self.raw).rstrip("\n") + return msg + + def prepare(self) -> None: """Do stuff here that is needed prior to sending over the air.""" # now build the raw message for sending + if not self.msgNo: + self.msgNo = _init_msgNo() self._build_payload() self._build_raw() - def _build_payload(self): + def _build_payload(self) -> None: """The payload is the non headers portion of the packet.""" - msg = self._filter_for_send().rstrip("\n") + if not self.to_call: + raise ValueError("to_call isn't set. Must set to_call before calling prepare()") + + # The base packet class has no real payload self.payload = ( f":{self.to_call.ljust(9)}" - f":{msg}" ) - def _build_raw(self): + def _build_raw(self) -> None: """Build the self.raw which is what is sent over the air.""" self.raw = "{}>APZ100:{}".format( self.from_call, self.payload, ) - @staticmethod - def factory(raw_packet): - """Factory method to create a packet from a raw packet string.""" - raw = raw_packet - raw["raw_dict"] = raw.copy() - translate_fields = { - "from": "from_call", - "to": "to_call", - } - # First translate some fields - for key in translate_fields: - if key in raw: - raw[translate_fields[key]] = raw[key] - del raw[key] - - if "addresse" in raw: - raw["to_call"] = raw["addresse"] - - packet_type = get_packet_type(raw) - raw["packet_type"] = packet_type - class_name = TYPE_LOOKUP[packet_type] - if packet_type == PACKET_TYPE_THIRDPARTY: - # We have an encapsulated packet! - # So we need to decode it and return the inner packet - # as the packet we are going to process. - # This is a recursive call to the factory - subpacket_raw = raw["subpacket"] - subpacket = Packet.factory(subpacket_raw) - del raw["subpacket"] - # raw["subpacket"] = subpacket - packet = dacite.from_dict(data_class=class_name, data=raw) - packet.subpacket = subpacket - return packet - - if packet_type == PACKET_TYPE_UNKNOWN: - # Try and figure it out here - if "latitude" in raw: - class_name = GPSPacket - - if packet_type == PACKET_TYPE_WX: - # the weather information is in a dict - # this brings those values out to the outer dict - - 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 - # aprslib issue #80 - # 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: - # 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") - 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"] - # Let's adjust the rain numbers as well, since it's wrong - raw["rain_1h"] = round((raw.get("rain_1h", 0) / .254) * .01, 3) - raw["weather"]["rain_1h"] = raw["rain_1h"] - raw["rain_24h"] = round((raw.get("rain_24h", 0) / .254) * .01, 3) - raw["weather"]["rain_24h"] = raw["rain_24h"] - raw["rain_since_midnight"] = round((raw.get("rain_since_midnight", 0) / .254) * .01, 3) - raw["weather"]["rain_since_midnight"] = raw["rain_since_midnight"] - - 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"] - - return dacite.from_dict(data_class=class_name, data=raw) - - def log(self, header=None): - """LOG a packet to the logfile.""" - asdict(self) - log_list = ["\n"] - name = self.__class__.__name__ - if header: - if "tx" in header.lower(): - log_list.append( - f"{header}________({name} " - f"TX:{self.send_count+1} of {self.retry_count})", - ) - else: - log_list.append(f"{header}________({name})") - # log_list.append(f" Packet : {self.__class__.__name__}") - log_list.append(f" Raw : {self.raw}") - if self.to_call: - log_list.append(f" To : {self.to_call}") - if self.from_call: - log_list.append(f" From : {self.from_call}") - if hasattr(self, "path") and self.path: - log_list.append(f" Path : {'=>'.join(self.path)}") - if hasattr(self, "via") and self.via: - log_list.append(f" VIA : {self.via}") - - elif isinstance(self, MessagePacket): - log_list.append(f" Message : {self.message_text}") - - if hasattr(self, "comment") and self.comment: - log_list.append(f" Comment : {self.comment}") - - if self.msgNo: - log_list.append(f" Msg # : {self.msgNo}") - log_list.append(f"{header}________({name})") - - LOG.info("\n".join(log_list)) - LOG.debug(repr(self)) - - def _filter_for_send(self) -> str: + def _filter_for_send(self, msg) -> str: """Filter and format message string for FCC.""" # max? ftm400 displays 64, raw msg shows 74 # and ftm400-send is max 64. setting this to # 67 displays 64 on the ftm400. (+3 {01 suffix) # feature req: break long ones into two msgs - message = self.raw[:67] - # We all miss George Carlin - return re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message) + if not msg: + return "" - def __str__(self): + message = msg[:67] + # We all miss George Carlin + return re.sub( + "fuck|shit|cunt|piss|cock|bitch", "****", + message, flags=re.IGNORECASE, + ) + + def __str__(self) -> str: """Show the raw version of the packet""" self.prepare() + if not self.raw: + raise ValueError("self.raw is unset") return self.raw - def __repr__(self): + def __repr__(self) -> str: """Build the repr version of the packet.""" repr = ( f"{self.__class__.__name__}:" f" From: {self.from_call} " - " To: " + f" To: {self.to_call}" ) - return repr +@dataclass_json @dataclass(unsafe_hash=True) class AckPacket(Packet): - response: str = field(default=None) - - def __post__init__(self): - if self.response: - LOG.warning("Response set!") + _type: str = field(default="AckPacket", hash=False) def _build_payload(self): - self.payload = f":{self.to_call.ljust(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" + # Holds the encapsulated packet + bid: Optional[str] = field(default="1") + message_text: Optional[str] = field(default=None) + + @property + def human_info(self) -> str: + return f"BLN{self.bid} {self.message_text}" + + def _build_payload(self) -> None: + self.payload = ( + f":BLN{self.bid:<9}" + f":{self.message_text}" + ) + + +@dataclass_json @dataclass(unsafe_hash=True) class RejectPacket(Packet): - response: str = field(default=None) + _type: str = field(default="RejectPacket", hash=False) + response: Optional[str] = field(default=None) def __post__init__(self): if self.response: LOG.warning("Response set!") def _build_payload(self): - self.payload = f":{self.to_call.ljust(9)} :rej{self.msgNo}" + self.payload = f":{self.to_call: <9}:rej{self.msgNo}" @dataclass_json @dataclass(unsafe_hash=True) class MessagePacket(Packet): - message_text: str = field(default=None) - - def _filter_for_send(self) -> str: - """Filter and format message string for FCC.""" - # max? ftm400 displays 64, raw msg shows 74 - # and ftm400-send is max 64. setting this to - # 67 displays 64 on the ftm400. (+3 {01 suffix) - # feature req: break long ones into two msgs - message = self.message_text[:67] - # We all miss George Carlin - return re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message) + _type: str = field(default="MessagePacket", hash=False) + message_text: Optional[str] = field(default=None) def _build_payload(self): self.payload = ":{}:{}{{{}".format( self.to_call.ljust(9), - self._filter_for_send().rstrip("\n"), + self._filter_for_send(self.message_text).rstrip("\n"), str(self.msgNo), ) +@dataclass_json @dataclass(unsafe_hash=True) class StatusPacket(Packet): - status: str = field(default=None) + _type: str = field(default="StatusPacket", hash=False) + status: Optional[str] = field(default=None) messagecapable: bool = field(default=False) - comment: str = field(default=None) + comment: Optional[str] = field(default=None) + raw_timestamp: Optional[str] = field(default=None) def _build_payload(self): - raise NotImplementedError + self.payload = ":{}:{}{{{}".format( + self.to_call.ljust(9), + self._filter_for_send(self.status).rstrip("\n"), + str(self.msgNo), + ) + + @property + def human_info(self) -> str: + self.prepare() + return self.status +@dataclass_json @dataclass(unsafe_hash=True) class GPSPacket(Packet): + _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) rng: float = field(default=0.00) posambiguity: int = field(default=0) - comment: str = field(default=None) + messagecapable: bool = field(default=False) + comment: Optional[str] = field(default=None) symbol: str = field(default="l") symbol_table: str = field(default="/") - - def decdeg2dms(self, degrees_decimal): - is_positive = degrees_decimal >= 0 - degrees_decimal = abs(degrees_decimal) - minutes, seconds = divmod(degrees_decimal * 3600, 60) - degrees, minutes = divmod(minutes, 60) - degrees = degrees if is_positive else -degrees - - degrees = str(int(degrees)).replace("-", "0") - minutes = str(int(minutes)).replace("-", "0") - seconds = str(int(round(seconds * 0.01, 2) * 100)) - - return {"degrees": degrees, "minutes": minutes, "seconds": seconds} - - def decdeg2dmm_m(self, degrees_decimal): - is_positive = degrees_decimal >= 0 - degrees_decimal = abs(degrees_decimal) - minutes, seconds = divmod(degrees_decimal * 3600, 60) - degrees, minutes = divmod(minutes, 60) - degrees = degrees if is_positive else -degrees - - degrees = abs(int(degrees)) - minutes = int(round(minutes + (seconds / 60), 2)) - hundredths = round(seconds / 60, 2) - - return { - "degrees": degrees, "minutes": minutes, "seconds": seconds, - "hundredths": hundredths, - } - - def convert_latitude(self, degrees_decimal): - det = self.decdeg2dmm_m(degrees_decimal) - if degrees_decimal > 0: - direction = "N" - else: - direction = "S" - - degrees = str(det.get("degrees")).zfill(2) - minutes = str(det.get("minutes")).zfill(2) - seconds = det.get("seconds") - hun = det.get("hundredths") - hundredths = f"{hun:.2f}".split(".")[1] - - LOG.debug( - f"LAT degress {degrees} minutes {str(minutes)} " - f"seconds {seconds} hundredths {hundredths} direction {direction}", - ) - - lat = f"{degrees}{str(minutes)}.{hundredths}{direction}" - return lat - - def convert_longitude(self, degrees_decimal): - det = self.decdeg2dmm_m(degrees_decimal) - if degrees_decimal > 0: - direction = "E" - else: - direction = "W" - - degrees = str(det.get("degrees")).zfill(3) - minutes = str(det.get("minutes")).zfill(2) - seconds = det.get("seconds") - hun = det.get("hundredths") - hundredths = f"{hun:.2f}".split(".")[1] - - LOG.debug( - f"LON degress {degrees} minutes {str(minutes)} " - f"seconds {seconds} hundredths {hundredths} direction {direction}", - ) - - lon = f"{degrees}{str(minutes)}.{hundredths}{direction}" - return lon + raw_timestamp: Optional[str] = field(default=None) + object_name: Optional[str] = field(default=None) + object_format: Optional[str] = field(default=None) + alive: Optional[bool] = field(default=None) + course: Optional[int] = field(default=None) + speed: Optional[float] = field(default=None) + phg: Optional[str] = field(default=None) + phg_power: Optional[int] = field(default=None) + phg_height: Optional[float] = field(default=None) + phg_gain: Optional[int] = field(default=None) + phg_dir: Optional[str] = field(default=None) + phg_range: Optional[float] = field(default=None) + phg_rate: Optional[int] = field(default=None) + # http://www.aprs.org/datum.txt + daodatumbyte: Optional[str] = field(default=None) def _build_time_zulu(self): """Build the timestamp in UTC/zulu.""" if self.timestamp: - local_dt = datetime.fromtimestamp(self.timestamp) - else: - local_dt = datetime.now() - self.timestamp = datetime.timestamp(local_dt) - - utc_offset_timedelta = datetime.utcnow() - local_dt - result_utc_datetime = local_dt + utc_offset_timedelta - time_zulu = result_utc_datetime.strftime("%d%H%M") - return time_zulu + return datetime.utcfromtimestamp(self.timestamp).strftime("%d%H%M") def _build_payload(self): """The payload is the non headers portion of the packet.""" time_zulu = self._build_time_zulu() - lat = self.latitude - long = self.longitude - self.payload = ( - f"@{time_zulu}z{lat}{self.symbol_table}" - f"{long}{self.symbol}" - ) + lat = aprslib_util.latitude_to_ddm(self.latitude) + long = aprslib_util.longitude_to_ddm(self.longitude) + payload = [ + "@" if self.timestamp else "!", + time_zulu, + lat, + self.symbol_table, + long, + self.symbol, + ] if self.comment: - self.payload = f"{self.payload}{self.comment}" + payload.append(self._filter_for_send(self.comment)) + + self.payload = "".join(payload) def _build_raw(self): self.raw = ( @@ -464,45 +338,108 @@ class GPSPacket(Packet): f"{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}") + if self.altitude: + h_str.append(f"Altitude {self.altitude:03.0f}") + if self.speed: + h_str.append(f"Speed {self.speed:03.0f}MPH") + if self.course: + h_str.append(f"Course {self.course:03.0f}") + if self.rng: + h_str.append(f"RNG {self.rng:03.0f}") + if self.phg: + h_str.append(f"PHG {self.phg}") + return " ".join(h_str) + + +@dataclass_json @dataclass(unsafe_hash=True) class BeaconPacket(GPSPacket): + _type: str = field(default="BeaconPacket", hash=False) + def _build_payload(self): """The payload is the non headers portion of the packet.""" time_zulu = self._build_time_zulu() - lat = self.convert_latitude(self.latitude) - long = self.convert_longitude(self.longitude) + 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"{long}{self.symbol}APRSD Beacon" + f"{lon}" ) + if self.comment: + comment = self._filter_for_send(self.comment) + self.payload = f"{self.payload}{self.symbol}{comment}" + else: + self.payload = f"{self.payload}{self.symbol}APRSD Beacon" + def _build_raw(self): self.raw = ( f"{self.from_call}>APZ100:" f"{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"{self.comment}") + return " ".join(h_str) -@dataclass + +@dataclass_json +@dataclass(unsafe_hash=True) class MicEPacket(GPSPacket): + _type: str = field(default="MicEPacket", hash=False) messagecapable: bool = False - mbits: str = None - mtype: str = None + mbits: Optional[str] = None + mtype: Optional[str] = None + telemetry: Optional[dict] = field(default=None) # in MPH speed: float = 0.00 # 0 to 360 course: int = 0 - def _build_payload(self): - raise NotImplementedError + @property + def human_info(self) -> str: + h_info = super().human_info + return f"{h_info} {self.mbits} mbits" -@dataclass +@dataclass_json +@dataclass(unsafe_hash=True) +class TelemetryPacket(GPSPacket): + _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 + # in MPH + speed: float = 0.00 + # 0 to 360 + course: int = 0 + + @property + def human_info(self) -> str: + h_info = super().human_info + return f"{h_info} {self.telemetry}" + + +@dataclass_json +@dataclass(unsafe_hash=True) class ObjectPacket(GPSPacket): + _type: str = field(default="ObjectPacket", hash=False) alive: bool = True - raw_timestamp: str = None + raw_timestamp: Optional[str] = None symbol: str = field(default="r") # in MPH speed: float = 0.00 @@ -511,8 +448,8 @@ class ObjectPacket(GPSPacket): def _build_payload(self): time_zulu = self._build_time_zulu() - lat = self.convert_latitude(self.latitude) - long = self.convert_longitude(self.longitude) + 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}" @@ -520,7 +457,8 @@ class ObjectPacket(GPSPacket): ) if self.comment: - self.payload = f"{self.payload}{self.comment}" + comment = self._filter_for_send(self.comment) + self.payload = f"{self.payload}{comment}" def _build_raw(self): """ @@ -538,9 +476,15 @@ class ObjectPacket(GPSPacket): f"{self.payload}" ) + @property + def human_info(self) -> str: + h_info = super().human_info + return f"{h_info} {self.comment}" -@dataclass() -class WeatherPacket(GPSPacket): + +@dataclass(unsafe_hash=True) +class WeatherPacket(GPSPacket, DataClassJsonMixin): + _type: str = field(default="WeatherPacket", hash=False) symbol: str = "_" wind_speed: float = 0.00 wind_direction: int = 0 @@ -552,7 +496,68 @@ class WeatherPacket(GPSPacket): rain_since_midnight: float = 0.00 humidity: int = 0 pressure: float = 0.00 - comment: str = None + comment: Optional[str] = field(default=None) + luminosity: Optional[int] = field(default=None) + wx_raw_timestamp: Optional[str] = field(default=None) + course: Optional[int] = field(default=None) + speed: Optional[float] = field(default=None) + + def _translate(self, raw: dict) -> dict: + 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 + # aprslib issue #80 + # 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: + # 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") + 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"] + # Let's adjust the rain numbers as well, since it's wrong + raw["rain_1h"] = round((raw.get("rain_1h", 0) / .254) * .01, 3) + raw["weather"]["rain_1h"] = raw["rain_1h"] + raw["rain_24h"] = round((raw.get("rain_24h", 0) / .254) * .01, 3) + raw["weather"]["rain_24h"] = raw["rain_24h"] + raw["rain_since_midnight"] = round((raw.get("rain_since_midnight", 0) / .254) * .01, 3) + raw["weather"]["rain_since_midnight"] = raw["rain_since_midnight"] + + 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"] + + del raw["weather"] + return raw + + @classmethod + def from_dict(cls: Type[A], kvs: Json, *, infer_missing=False) -> A: + """Create from a dictionary that has come directly from aprslib parse""" + raw = cls._translate(cls, kvs) # type: ignore + return super().from_dict(raw) + + @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") + + return " ".join(h_str) def _build_payload(self): """Build an uncompressed weather packet @@ -603,7 +608,8 @@ class WeatherPacket(GPSPacket): f"b{self.pressure:05.0f}", ] if self.comment: - contents.append(self.comment) + comment = self.filter_for_send(self.comment) + contents.append(comment) self.payload = "".join(contents) def _build_raw(self): @@ -614,9 +620,11 @@ class WeatherPacket(GPSPacket): ) -class ThirdParty(Packet): +@dataclass(unsafe_hash=True) +class ThirdPartyPacket(Packet, DataClassJsonMixin): + _type: str = "ThirdPartyPacket" # Holds the encapsulated packet - subpacket: Packet = field(default=None, compare=True, hash=False) + subpacket: Optional[type[Packet]] = field(default=None, compare=True, hash=False) def __repr__(self): """Build the repr version of the packet.""" @@ -629,26 +637,69 @@ class ThirdParty(Packet): return repr_str + @classmethod + def from_dict(cls: Type[A], kvs: Json, *, infer_missing=False) -> A: + obj = super().from_dict(kvs) + obj.subpacket = factory(obj.subpacket) # type: ignore + return obj -TYPE_LOOKUP = { + @property + def human_info(self) -> str: + sub_info = self.subpacket.human_info + return f"{self.from_call}->{self.to_call} {sub_info}" + + +@dataclass_json(undefined=Undefined.INCLUDE) +@dataclass(unsafe_hash=True) +class UnknownPacket: + """Catchall Packet for things we don't know about. + + All of the unknown attributes are stored in the unknown_fields + """ + unknown_fields: CatchAll + _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) + raw: Optional[str] = field(default=None) + raw_dict: dict = field(repr=False, default_factory=lambda: {}, compare=False, hash=False) + path: List[str] = field(default_factory=list, compare=False, hash=False) + packet_type: Optional[str] = field(default=None) + via: Optional[str] = field(default=None, compare=False, hash=False) + + @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}" + + @property + def human_info(self) -> str: + return str(self.unknown_fields) + + +TYPE_LOOKUP: dict[str, type[Packet]] = { + PACKET_TYPE_BULLETIN: BulletinPacket, PACKET_TYPE_WX: WeatherPacket, + PACKET_TYPE_WEATHER: WeatherPacket, PACKET_TYPE_MESSAGE: MessagePacket, PACKET_TYPE_ACK: AckPacket, PACKET_TYPE_REJECT: RejectPacket, PACKET_TYPE_MICE: MicEPacket, PACKET_TYPE_OBJECT: ObjectPacket, PACKET_TYPE_STATUS: StatusPacket, - PACKET_TYPE_BEACON: GPSPacket, - PACKET_TYPE_UNKNOWN: Packet, - PACKET_TYPE_THIRDPARTY: ThirdParty, + PACKET_TYPE_BEACON: BeaconPacket, + PACKET_TYPE_UNKNOWN: UnknownPacket, + PACKET_TYPE_THIRDPARTY: ThirdPartyPacket, + PACKET_TYPE_TELEMETRY: TelemetryPacket, } -def get_packet_type(packet: dict): +def get_packet_type(packet: dict) -> str: """Decode the packet type from the packet.""" - pkt_format = packet.get("format", None) - msg_response = packet.get("response", None) + pkt_format = packet.get("format") + msg_response = packet.get("response") packet_type = PACKET_TYPE_UNKNOWN if pkt_format == "message" and msg_response == "ack": packet_type = PACKET_TYPE_ACK @@ -662,27 +713,76 @@ def get_packet_type(packet: dict): packet_type = PACKET_TYPE_OBJECT elif pkt_format == "status": packet_type = PACKET_TYPE_STATUS + elif pkt_format == PACKET_TYPE_BULLETIN: + packet_type = PACKET_TYPE_BULLETIN elif pkt_format == PACKET_TYPE_BEACON: packet_type = PACKET_TYPE_BEACON + elif pkt_format == PACKET_TYPE_TELEMETRY: + packet_type = PACKET_TYPE_TELEMETRY + elif pkt_format == PACKET_TYPE_WX: + packet_type = PACKET_TYPE_WEATHER elif pkt_format == PACKET_TYPE_UNCOMPRESSED: - if packet.get("symbol", None) == "_": - packet_type = PACKET_TYPE_WX + 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: packet_type = PACKET_TYPE_BEACON + else: + packet_type = PACKET_TYPE_UNKNOWN return packet_type -def is_message_packet(packet): +def is_message_packet(packet: dict) -> bool: return get_packet_type(packet) == PACKET_TYPE_MESSAGE -def is_ack_packet(packet): +def is_ack_packet(packet: dict) -> bool: return get_packet_type(packet) == PACKET_TYPE_ACK -def is_mice_packet(packet): +def is_mice_packet(packet: dict[Any, Any]) -> bool: return get_packet_type(packet) == PACKET_TYPE_MICE + + +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"]] + return cls.from_dict(raw) + + raw["raw_dict"] = raw.copy() + raw = _translate_fields(raw) + + packet_type = get_packet_type(raw) + + 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: + packet_class = WeatherPacket + elif packet_type == PACKET_TYPE_UNKNOWN: + # Try and figure it out here + if "latitude" in raw: + packet_class = GPSPacket + else: + LOG.warning(f"Unknown packet type {packet_type}") + LOG.warning(raw) + packet_class = UnknownPacket + + raw.get("addresse", raw.get("to_call")) + + # TODO: Find a global way to enable/disable this + # LOGU.opt(colors=True).info( + # f"factory({packet_type: <8}):" + # f"({packet_class.__name__: <13}): " + # f"{raw.get('from_call'): <9} -> {to: <9}") + # LOG.info(raw.get('msgNo')) + + return packet_class().from_dict(raw) # type: ignore diff --git a/aprsd/packets/log.py b/aprsd/packets/log.py new file mode 100644 index 0000000..ff4704a --- /dev/null +++ b/aprsd/packets/log.py @@ -0,0 +1,128 @@ +import logging +from typing import Optional + +from loguru import logger +from oslo_config import cfg + +from aprsd.packets.core import AckPacket, RejectPacket + + +LOG = logging.getLogger() +LOGU = logger +CONF = cfg.CONF + +FROM_COLOR = "fg #C70039" +TO_COLOR = "fg #D033FF" +TX_COLOR = "red" +RX_COLOR = "green" +PACKET_COLOR = "cyan" + + +def log_multiline(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> None: + """LOG a packet to the logfile.""" + if CONF.log_packet_format == "compact": + return + # asdict(packet) + logit = ["\n"] + name = packet.__class__.__name__ + if header: + if tx: + header_str = f"<{TX_COLOR}>TX" + logit.append( + f"{header_str}________(<{PACKET_COLOR}>{name} " + f"TX:{packet.send_count + 1} of {packet.retry_count})", + ) + else: + header_str = f"<{RX_COLOR}>RX" + logit.append( + f"{header_str}________(<{PACKET_COLOR}>{name})", + ) + + else: + header_str = "" + logit.append(f"__________(<{PACKET_COLOR}>{name})") + # log_list.append(f" Packet : {packet.__class__.__name__}") + if packet.msgNo: + logit.append(f" Msg # : {packet.msgNo}") + if packet.from_call: + logit.append(f" From : <{FROM_COLOR}>{packet.from_call}") + if packet.to_call: + logit.append(f" To : <{TO_COLOR}>{packet.to_call}") + 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 : {msg}") + + if hasattr(packet, "comment") and packet.comment: + logit.append(f" Comment : {packet.comment}") + + raw = packet.raw.replace("<", "\\<") + logit.append(f" Raw : {raw}") + logit.append(f"{header_str}________(<{PACKET_COLOR}>{name})") + + LOGU.opt(colors=True).info("\n".join(logit)) + LOG.debug(repr(packet)) + + +def log(packet, tx: Optional[bool] = False, header: Optional[bool] = True) -> None: + if CONF.log_packet_format == "multiline": + log_multiline(packet, tx, header) + return + + logit = [] + name = packet.__class__.__name__ + + if header: + if tx: + via_color = "red" + arrow = f"<{via_color}>->" + logit.append( + f"TX {arrow} " + f"{name}" + f":{packet.msgNo}" + f" ({packet.send_count + 1} of {packet.retry_count})", + ) + else: + via_color = "fg #828282" + arrow = f"<{via_color}>->" + left_arrow = f"<{via_color}><-" + logit.append( + f"RX {left_arrow} " + f"{name}" + f":{packet.msgNo}", + ) + else: + via_color = "green" + arrow = f"<{via_color}>->" + logit.append( + f"{name}" + f":{packet.msgNo}", + ) + + tmp = None + if packet.path: + tmp = f"{arrow}".join(packet.path) + f"{arrow} " + + logit.append( + f"<{FROM_COLOR}>{packet.from_call} {arrow}" + f"{tmp if tmp else ' '}" + f"<{TO_COLOR}>{packet.to_call}", + ) + + if not isinstance(packet, AckPacket) and not isinstance(packet, RejectPacket): + logit.append(":") + msg = packet.human_info + + if msg: + msg = msg.replace("<", "\\<") + logit.append(f"{msg}") + + LOGU.opt(colors=True).info(" ".join(logit)) + log_multiline(packet, tx, header) diff --git a/aprsd/plugin.py b/aprsd/plugin.py index 0d164f5..f73e0c6 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -1,4 +1,5 @@ -# The base plugin class +from __future__ import annotations + import abc import importlib import inspect @@ -42,7 +43,7 @@ class APRSDPluginSpec: """A hook specification namespace.""" @hookspec - def filter(self, packet: packets.core.Packet): + def filter(self, packet: type[packets.Packet]): """My special little hook that you can customize.""" @@ -65,7 +66,7 @@ class APRSDPluginBase(metaclass=abc.ABCMeta): self.threads = self.create_threads() or [] self.start_threads() - def start_threads(self): + def start_threads(self) -> None: if self.enabled and self.threads: if not isinstance(self.threads, list): self.threads = [self.threads] @@ -90,10 +91,10 @@ class APRSDPluginBase(metaclass=abc.ABCMeta): ) @property - def message_count(self): + def message_count(self) -> int: return self.message_counter - def help(self): + def help(self) -> str: return "Help!" @abc.abstractmethod @@ -118,11 +119,11 @@ class APRSDPluginBase(metaclass=abc.ABCMeta): thread.stop() @abc.abstractmethod - def filter(self, packet: packets.core.Packet): + def filter(self, packet: type[packets.Packet]) -> str | packets.MessagePacket: pass @abc.abstractmethod - def process(self, packet: packets.core.Packet): + def process(self, packet: type[packets.Packet]): """This is called when the filter passes.""" @@ -154,7 +155,7 @@ class APRSDWatchListPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta): LOG.warning("Watch list enabled, but no callsigns set.") @hookimpl - def filter(self, packet: packets.core.Packet): + def filter(self, packet: type[packets.Packet]) -> str | packets.MessagePacket: result = packets.NULL_MESSAGE if self.enabled: wl = watch_list.WatchList() @@ -206,14 +207,14 @@ class APRSDRegexCommandPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta): self.enabled = True @hookimpl - def filter(self, packet: packets.core.MessagePacket): - LOG.info(f"{self.__class__.__name__} called") + def filter(self, packet: packets.MessagePacket) -> str | packets.MessagePacket: + LOG.debug(f"{self.__class__.__name__} called") if not self.enabled: result = f"{self.__class__.__name__} isn't enabled" LOG.warning(result) return result - if not isinstance(packet, packets.core.MessagePacket): + if not isinstance(packet, packets.MessagePacket): LOG.warning(f"{self.__class__.__name__} Got a {packet.__class__.__name__} ignoring") return packets.NULL_MESSAGE @@ -226,7 +227,7 @@ class APRSDRegexCommandPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta): # and is an APRS message format and has a message. if ( tocall == CONF.callsign - and isinstance(packet, packets.core.MessagePacket) + and isinstance(packet, packets.MessagePacket) and message ): if re.search(self.command_regex, message, re.IGNORECASE): @@ -269,7 +270,7 @@ class HelpPlugin(APRSDRegexCommandPluginBase): def help(self): return "Help: send APRS help or help " - def process(self, packet: packets.core.MessagePacket): + def process(self, packet: packets.MessagePacket): LOG.info("HelpPlugin") # fromcall = packet.get("from") message = packet.message_text @@ -469,12 +470,12 @@ class PluginManager: LOG.info("Completed Plugin Loading.") - def run(self, packet: packets.core.MessagePacket): + def run(self, packet: packets.MessagePacket): """Execute all the plugins run method.""" with self.lock: return self._pluggy_pm.hook.filter(packet=packet) - def run_watchlist(self, packet: packets.core.Packet): + def run_watchlist(self, packet: packets.Packet): with self.lock: return self._watchlist_pm.hook.filter(packet=packet) diff --git a/aprsd/plugins/version.py b/aprsd/plugins/version.py index 2b4b552..32037a0 100644 --- a/aprsd/plugins/version.py +++ b/aprsd/plugins/version.py @@ -23,9 +23,7 @@ class VersionPlugin(plugin.APRSDRegexCommandPluginBase): # fromcall = packet.get("from") # message = packet.get("message_text", None) # ack = packet.get("msgNo", "0") - stats_obj = stats.APRSDStats() - s = stats_obj.stats() - print(s) + s = stats.APRSDStats().stats() return "APRSD ver:{} uptime:{}".format( aprsd.__version__, s["aprsd"]["uptime"], diff --git a/aprsd/plugins/weather.py b/aprsd/plugins/weather.py index 4af33e4..1e57115 100644 --- a/aprsd/plugins/weather.py +++ b/aprsd/plugins/weather.py @@ -110,7 +110,6 @@ class USMetarPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin): @trace.trace def process(self, packet): - print("FISTY") fromcall = packet.get("from") message = packet.get("message_text", None) # ack = packet.get("msgNo", "0") diff --git a/aprsd/stats.py b/aprsd/stats.py index 5c8380a..cb20eb8 100644 --- a/aprsd/stats.py +++ b/aprsd/stats.py @@ -174,7 +174,6 @@ class APRSDStats: def email_thread_update(self): self._email_thread_last_time = datetime.datetime.now() - @wrapt.synchronized(lock) def stats(self): now = datetime.datetime.now() if self._email_thread_last_time: diff --git a/aprsd/threads/aprsd.py b/aprsd/threads/aprsd.py index 51d7960..306abd5 100644 --- a/aprsd/threads/aprsd.py +++ b/aprsd/threads/aprsd.py @@ -2,6 +2,7 @@ import abc import datetime import logging import threading +from typing import List import wrapt @@ -9,42 +10,6 @@ import wrapt LOG = logging.getLogger("APRSD") -class APRSDThreadList: - """Singleton class that keeps track of application wide threads.""" - - _instance = None - - threads_list = [] - lock = threading.Lock() - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls.threads_list = [] - return cls._instance - - @wrapt.synchronized(lock) - def add(self, thread_obj): - self.threads_list.append(thread_obj) - - @wrapt.synchronized(lock) - def remove(self, thread_obj): - self.threads_list.remove(thread_obj) - - @wrapt.synchronized(lock) - def stop_all(self): - """Iterate over all threads and call stop on them.""" - for th in self.threads_list: - LOG.info(f"Stopping Thread {th.name}") - if hasattr(th, "packet"): - LOG.info(F"{th.name} packet {th.packet}") - th.stop() - - @wrapt.synchronized(lock) - def __len__(self): - return len(self.threads_list) - - class APRSDThread(threading.Thread, metaclass=abc.ABCMeta): def __init__(self, name): @@ -86,3 +51,39 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta): self._cleanup() APRSDThreadList().remove(self) LOG.debug("Exiting") + + +class APRSDThreadList: + """Singleton class that keeps track of application wide threads.""" + + _instance = None + + threads_list: List[APRSDThread] = [] + lock = threading.Lock() + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls.threads_list = [] + return cls._instance + + @wrapt.synchronized(lock) + def add(self, thread_obj): + self.threads_list.append(thread_obj) + + @wrapt.synchronized(lock) + def remove(self, thread_obj): + self.threads_list.remove(thread_obj) + + @wrapt.synchronized(lock) + def stop_all(self): + """Iterate over all threads and call stop on them.""" + for th in self.threads_list: + LOG.info(f"Stopping Thread {th.name}") + if hasattr(th, "packet"): + LOG.info(F"{th.name} packet {th.packet}") + th.stop() + + @wrapt.synchronized(lock) + def __len__(self): + return len(self.threads_list) diff --git a/aprsd/threads/rx.py b/aprsd/threads/rx.py index 3353523..5613368 100644 --- a/aprsd/threads/rx.py +++ b/aprsd/threads/rx.py @@ -7,6 +7,7 @@ import aprslib from oslo_config import cfg from aprsd import client, packets, plugin +from aprsd.packets import log as packet_log from aprsd.threads import APRSDThread, tx @@ -80,7 +81,7 @@ class APRSDDupeRXThread(APRSDRXThread): """ packet = self._client.decode_packet(*args, **kwargs) # LOG.debug(raw) - packet.log(header="RX") + packet_log.log(packet) if isinstance(packet, packets.AckPacket): # We don't need to drop AckPackets, those should be @@ -142,14 +143,14 @@ class APRSDProcessPacketThread(APRSDThread): def process_ack_packet(self, packet): """We got an ack for a message, no need to resend it.""" ack_num = packet.msgNo - LOG.info(f"Got ack for message {ack_num}") + LOG.debug(f"Got ack for message {ack_num}") pkt_tracker = packets.PacketTrack() pkt_tracker.remove(ack_num) def process_reject_packet(self, packet): """We got a reject message for a packet. Stop sending the message.""" ack_num = packet.msgNo - LOG.info(f"Got REJECT for message {ack_num}") + LOG.debug(f"Got REJECT for message {ack_num}") pkt_tracker = packets.PacketTrack() pkt_tracker.remove(ack_num) diff --git a/aprsd/threads/tx.py b/aprsd/threads/tx.py index bfd95f9..c84b4bf 100644 --- a/aprsd/threads/tx.py +++ b/aprsd/threads/tx.py @@ -10,7 +10,9 @@ from rush.stores import dictionary from aprsd import client from aprsd import conf # noqa from aprsd import threads as aprsd_threads -from aprsd.packets import core, tracker +from aprsd.packets import core +from aprsd.packets import log as packet_log +from aprsd.packets import tracker CONF = cfg.CONF @@ -74,7 +76,7 @@ def _send_direct(packet, aprs_client=None): cl = client.factory.create() packet.update_timestamp() - packet.log(header="TX") + packet_log.log(packet, tx=True) cl.send(packet) @@ -163,7 +165,7 @@ class SendAckThread(aprsd_threads.APRSDThread): if self.packet.send_count == self.packet.retry_count: # we reached the send limit, don't send again # TODO(hemna) - Need to put this in a delayed queue? - LOG.info( + LOG.debug( f"{self.packet.__class__.__name__}" f"({self.packet.msgNo}) " "Send Complete. Max attempts reached" diff --git a/aprsd/utils/counter.py b/aprsd/utils/counter.py index 5f569f4..30b6b75 100644 --- a/aprsd/utils/counter.py +++ b/aprsd/utils/counter.py @@ -1,9 +1,13 @@ from multiprocessing import RawValue +import random import threading import wrapt +MAX_PACKET_ID = 9999 + + class PacketCounter: """ Global Packet id counter class. @@ -17,19 +21,18 @@ class PacketCounter: """ _instance = None - max_count = 9999 lock = threading.Lock() def __new__(cls, *args, **kwargs): """Make this a singleton class.""" if cls._instance is None: cls._instance = super().__new__(cls, *args, **kwargs) - cls._instance.val = RawValue("i", 1) + cls._instance.val = RawValue("i", random.randint(1, MAX_PACKET_ID)) return cls._instance @wrapt.synchronized(lock) def increment(self): - if self.val.value == self.max_count: + if self.val.value == MAX_PACKET_ID: self.val.value = 1 else: self.val.value += 1 diff --git a/aprsd/web/chat/static/js/main.js b/aprsd/web/chat/static/js/main.js index dc27310..a0c505c 100644 --- a/aprsd/web/chat/static/js/main.js +++ b/aprsd/web/chat/static/js/main.js @@ -37,7 +37,7 @@ function start_update() { update_stats(data); }, complete: function() { - setTimeout(statsworker, 10000); + setTimeout(statsworker, 60000); } }); })(); diff --git a/aprsd/wsgi.py b/aprsd/wsgi.py index e81ee89..04d7464 100644 --- a/aprsd/wsgi.py +++ b/aprsd/wsgi.py @@ -22,7 +22,7 @@ CONF = cfg.CONF LOG = logging.getLogger("gunicorn.access") auth = HTTPBasicAuth() -users = {} +users: dict[str, str] = {} app = Flask( "aprsd", static_url_path="/static", diff --git a/dev-requirements.txt b/dev-requirements.txt index dec7d53..cdcab43 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -4,209 +4,80 @@ # # pip-compile --annotation-style=line dev-requirements.in # -add-trailing-comma==3.1.0 - # via gray -alabaster==0.7.16 - # via sphinx -autoflake==1.5.3 - # via gray -babel==2.14.0 - # via sphinx -black==24.3.0 - # via gray -build==1.1.1 - # via pip-tools -cachetools==5.3.3 - # via tox -certifi==2024.2.2 - # via requests -cfgv==3.4.0 - # via pre-commit -chardet==5.2.0 - # via tox -charset-normalizer==3.3.2 - # via requests -click==8.1.7 - # via - # black - # fixit - # moreorless - # pip-tools -colorama==0.4.6 - # via tox -commonmark==0.9.1 - # via rich -configargparse==1.7 - # via gray -coverage[toml]==7.4.3 - # via pytest-cov -distlib==0.3.8 - # via virtualenv -docutils==0.20.1 - # via sphinx -exceptiongroup==1.2.0 - # via pytest -filelock==3.13.1 - # via - # tox - # virtualenv -fixit==2.1.0 - # via gray -flake8==7.0.0 - # via - # -r dev-requirements.in - # pep8-naming -gray==0.14.0 - # via -r dev-requirements.in -identify==2.5.35 - # via pre-commit -idna==3.6 - # via requests -imagesize==1.4.1 - # via sphinx -iniconfig==2.0.0 - # via pytest -isort==5.13.2 - # via - # -r dev-requirements.in - # gray -jinja2==3.1.3 - # via sphinx -libcst==1.2.0 - # via fixit -markupsafe==2.1.5 - # via jinja2 -mccabe==0.7.0 - # via flake8 -moreorless==0.4.0 - # via fixit -mypy==1.8.0 - # via -r dev-requirements.in -mypy-extensions==1.0.0 - # via - # black - # mypy - # typing-inspect -nodeenv==1.8.0 - # via pre-commit -packaging==23.2 - # via - # black - # build - # fixit - # pyproject-api - # pytest - # sphinx - # tox -pathspec==0.12.1 - # via - # black - # trailrunner -pep8-naming==0.13.3 - # via -r dev-requirements.in -pip-tools==7.4.1 - # via -r dev-requirements.in -platformdirs==4.2.0 - # via - # black - # tox - # virtualenv -pluggy==1.4.0 - # via - # pytest - # tox -pre-commit==3.6.2 - # via -r dev-requirements.in -pycodestyle==2.11.1 - # via flake8 -pyflakes==3.2.0 - # via - # autoflake - # flake8 -pygments==2.17.2 - # via - # rich - # sphinx -pyproject-api==1.6.1 - # via tox -pyproject-hooks==1.0.0 - # via - # build - # pip-tools -pytest==8.0.2 - # via - # -r dev-requirements.in - # pytest-cov -pytest-cov==4.1.0 - # via -r dev-requirements.in -pyupgrade==3.15.1 - # via gray -pyyaml==6.0.1 - # via - # libcst - # pre-commit -requests==2.31.0 - # via sphinx -rich==12.6.0 - # via gray -snowballstemmer==2.2.0 - # via sphinx -sphinx==7.2.6 - # via -r dev-requirements.in -sphinxcontrib-applehelp==1.0.8 - # via sphinx -sphinxcontrib-devhelp==1.0.6 - # via sphinx -sphinxcontrib-htmlhelp==2.0.5 - # via sphinx -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.7 - # via sphinx -sphinxcontrib-serializinghtml==1.1.10 - # via sphinx -tokenize-rt==5.2.0 - # via - # add-trailing-comma - # pyupgrade -toml==0.10.2 - # via autoflake -tomli==2.0.1 - # via - # black - # build - # coverage - # fixit - # mypy - # pip-tools - # pyproject-api - # pyproject-hooks - # pytest - # tox -tox==4.14.0 - # via -r dev-requirements.in -trailrunner==1.4.0 - # via fixit -typing-extensions==4.10.0 - # via - # black - # libcst - # mypy - # typing-inspect -typing-inspect==0.9.0 - # via libcst -unify==0.5 - # via gray -untokenize==0.1.1 - # via unify -urllib3==2.2.1 - # via requests -virtualenv==20.25.1 - # via - # pre-commit - # tox -wheel==0.42.0 - # via pip-tools +add-trailing-comma==3.1.0 # via gray +alabaster==0.7.16 # via sphinx +autoflake==1.5.3 # via gray +babel==2.14.0 # via sphinx +black==24.3.0 # via gray +build==1.1.1 # via pip-tools +cachetools==5.3.3 # via tox +certifi==2024.2.2 # via requests +cfgv==3.4.0 # via pre-commit +chardet==5.2.0 # via tox +charset-normalizer==3.3.2 # via requests +click==8.1.7 # via black, fixit, moreorless, pip-tools +colorama==0.4.6 # via tox +commonmark==0.9.1 # via rich +configargparse==1.7 # via gray +coverage[toml]==7.4.4 # via pytest-cov +distlib==0.3.8 # via virtualenv +docutils==0.20.1 # via sphinx +exceptiongroup==1.2.0 # via pytest +filelock==3.13.1 # via tox, virtualenv +fixit==2.1.0 # via gray +flake8==7.0.0 # via -r dev-requirements.in, pep8-naming +gray==0.14.0 # via -r dev-requirements.in +identify==2.5.35 # via pre-commit +idna==3.6 # via requests +imagesize==1.4.1 # via sphinx +iniconfig==2.0.0 # via pytest +isort==5.13.2 # via -r dev-requirements.in, gray +jinja2==3.1.3 # via sphinx +libcst==1.2.0 # via fixit +markupsafe==2.1.5 # via jinja2 +mccabe==0.7.0 # via flake8 +moreorless==0.4.0 # via fixit +mypy==1.9.0 # via -r dev-requirements.in +mypy-extensions==1.0.0 # via black, mypy, typing-inspect +nodeenv==1.8.0 # via pre-commit +packaging==24.0 # via black, build, fixit, pyproject-api, pytest, sphinx, tox +pathspec==0.12.1 # via black, trailrunner +pep8-naming==0.13.3 # via -r dev-requirements.in +pip-tools==7.4.1 # via -r dev-requirements.in +platformdirs==4.2.0 # via black, tox, virtualenv +pluggy==1.4.0 # via pytest, tox +pre-commit==3.6.2 # via -r dev-requirements.in +pycodestyle==2.11.1 # via flake8 +pyflakes==3.2.0 # via autoflake, flake8 +pygments==2.17.2 # via rich, sphinx +pyproject-api==1.6.1 # via tox +pyproject-hooks==1.0.0 # via build, pip-tools +pytest==8.1.1 # via -r dev-requirements.in, pytest-cov +pytest-cov==4.1.0 # via -r dev-requirements.in +pyupgrade==3.15.1 # via gray +pyyaml==6.0.1 # via libcst, pre-commit +requests==2.31.0 # via sphinx +rich==12.6.0 # via gray +snowballstemmer==2.2.0 # via sphinx +sphinx==7.2.6 # via -r dev-requirements.in +sphinxcontrib-applehelp==1.0.8 # via sphinx +sphinxcontrib-devhelp==1.0.6 # via sphinx +sphinxcontrib-htmlhelp==2.0.5 # via sphinx +sphinxcontrib-jsmath==1.0.1 # via sphinx +sphinxcontrib-qthelp==1.0.7 # via sphinx +sphinxcontrib-serializinghtml==1.1.10 # via sphinx +tokenize-rt==5.2.0 # via add-trailing-comma, pyupgrade +toml==0.10.2 # via autoflake +tomli==2.0.1 # via black, build, coverage, fixit, mypy, pip-tools, pyproject-api, pyproject-hooks, pytest, tox +tox==4.14.1 # via -r dev-requirements.in +trailrunner==1.4.0 # via fixit +typing-extensions==4.10.0 # via black, libcst, mypy, typing-inspect +typing-inspect==0.9.0 # via libcst +unify==0.5 # via gray +untokenize==0.1.1 # via unify +urllib3==2.2.1 # via requests +virtualenv==20.25.1 # via pre-commit, tox +wheel==0.43.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements.in b/requirements.in index e5599fb..dcd231d 100644 --- a/requirements.in +++ b/requirements.in @@ -28,7 +28,6 @@ wrapt kiss3 attrs dataclasses -dacite2 oslo.config rpyc>=6.0.0 # Pin this here so it doesn't require a compile on diff --git a/requirements.txt b/requirements.txt index 6122800..1799187 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,6 @@ click==8.1.7 # via -r requirements.in, click-completion, click-para click-completion==0.5.2 # via -r requirements.in click-params==0.5.0 # via -r requirements.in commonmark==0.9.1 # via rich -dacite2==2.0.0 # via -r requirements.in dataclasses==0.6 # via -r requirements.in dataclasses-json==0.6.4 # via -r requirements.in debtcollector==3.0.0 # via oslo-config @@ -34,7 +33,7 @@ greenlet==3.0.3 # via eventlet, gevent h11==0.14.0 # via wsproto idna==3.6 # via requests imapclient==3.0.1 # via -r requirements.in -importlib-metadata==7.0.1 # via ax253, kiss3 +importlib-metadata==7.0.2 # via ax253, kiss3 itsdangerous==2.1.2 # via flask jinja2==3.1.3 # via click-completion, flask kiss3==8.0.0 # via -r requirements.in @@ -45,7 +44,7 @@ mypy-extensions==1.0.0 # via typing-inspect netaddr==1.2.1 # via oslo-config oslo-config==9.4.0 # via -r requirements.in oslo-i18n==6.3.0 # via oslo-config -packaging==23.2 # via marshmallow +packaging==24.0 # via marshmallow pbr==6.0.0 # via -r requirements.in, oslo-i18n, stevedore pluggy==1.4.0 # via -r requirements.in plumbum==1.8.2 # via rpyc @@ -76,7 +75,7 @@ validators==0.22.0 # via click-params werkzeug==3.0.1 # via -r requirements.in, flask wrapt==1.16.0 # via -r requirements.in, debtcollector, deprecated wsproto==1.2.0 # via simple-websocket -zipp==3.17.0 # via importlib-metadata +zipp==3.18.1 # via importlib-metadata zope-event==5.0 # via gevent zope-interface==6.2 # via gevent diff --git a/tests/cmds/test_webchat.py b/tests/cmds/test_webchat.py index 88e38e1..8280899 100644 --- a/tests/cmds/test_webchat.py +++ b/tests/cmds/test_webchat.py @@ -51,11 +51,8 @@ class TestSendMessageCommand(unittest.TestCase): ): self.config_and_init() mock_socketio.emit = mock.MagicMock() - packet = fake.fake_packet( - message="blah", - msg_number=1, - message_format=core.PACKET_TYPE_ACK, - ) + # Create an ACK packet + packet = fake.fake_ack_packet() mock_queue = mock.MagicMock() socketio = mock.MagicMock() wcp = webchat.WebChatProcessPacketThread(mock_queue, socketio) diff --git a/tests/fake.py b/tests/fake.py index 1912157..4a7c816 100644 --- a/tests/fake.py +++ b/tests/fake.py @@ -1,4 +1,4 @@ -from aprsd import packets, plugin, threads +from aprsd import plugin, threads from aprsd.packets import core @@ -13,6 +13,7 @@ def fake_packet( message=None, msg_number=None, message_format=core.PACKET_TYPE_MESSAGE, + response=None, ): packet_dict = { "from": fromcall, @@ -27,7 +28,17 @@ def fake_packet( if msg_number: packet_dict["msgNo"] = str(msg_number) - return packets.Packet.factory(packet_dict) + if response: + packet_dict["response"] = response + + return core.factory(packet_dict) + + +def fake_ack_packet(): + return fake_packet( + msg_number=12, + response=core.PACKET_TYPE_ACK, + ) class FakeBaseNoThreadsPlugin(plugin.APRSDPluginBase): diff --git a/tests/plugins/test_weather.py b/tests/plugins/test_weather.py index 8a85e0b..c8d814d 100644 --- a/tests/plugins/test_weather.py +++ b/tests/plugins/test_weather.py @@ -11,7 +11,7 @@ from .. import fake, test_plugin CONF = cfg.CONF -class TestUSWeatherPluginPlugin(test_plugin.TestPlugin): +class TestUSWeatherPlugin(test_plugin.TestPlugin): def test_not_enabled_missing_aprs_fi_key(self): # When the aprs.fi api key isn't set, then diff --git a/tests/test_packets.py b/tests/test_packets.py index 7b8fdca..9fe7990 100644 --- a/tests/test_packets.py +++ b/tests/test_packets.py @@ -1,13 +1,16 @@ import unittest from unittest import mock +import aprslib +from aprslib import util as aprslib_util + from aprsd import packets from aprsd.packets import core from . import fake -class TestPluginBase(unittest.TestCase): +class TestPacketBase(unittest.TestCase): def _fake_dict( self, @@ -55,7 +58,7 @@ class TestPluginBase(unittest.TestCase): def test_packet_factory(self): pkt_dict = self._fake_dict() - pkt = packets.Packet.factory(pkt_dict) + pkt = packets.factory(pkt_dict) self.assertIsInstance(pkt, packets.MessagePacket) self.assertEqual(fake.FAKE_FROM_CALLSIGN, pkt.from_call) @@ -71,7 +74,7 @@ class TestPluginBase(unittest.TestCase): "comment": "Home!", } pkt_dict["format"] = core.PACKET_TYPE_UNCOMPRESSED - pkt = packets.Packet.factory(pkt_dict) + pkt = packets.factory(pkt_dict) self.assertIsInstance(pkt, packets.WeatherPacket) @mock.patch("aprsd.packets.core.GPSPacket._build_time_zulu") @@ -100,3 +103,183 @@ class TestPluginBase(unittest.TestCase): wx.prepare() expected = "KFAKE>KMINE,WIDE1-1,WIDE2-1:@221450z0.0/0.0_000/000g000t000r001p000P000h00b00000" self.assertEqual(expected, wx.raw) + + def test_beacon_factory(self): + """Test to ensure a beacon packet is created.""" + packet_raw = "WB4BOR-12>APZ100,WIDE2-1:@161647z3724.15N107847.58W$ APRSD WebChat" + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.BeaconPacket) + + packet_raw = "kd8mey-10>APRS,TCPIP*,qAC,T2SYDNEY:=4247.80N/08539.00WrPHG1210/Making 220 Great Again Allstar# 552191" + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.BeaconPacket) + + def test_reject_factory(self): + """Test to ensure a reject packet is created.""" + packet_raw = "HB9FDL-1>APK102,HB9FM-4*,WIDE2,qAR,HB9FEF-11::REPEAT :rej4139" + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.RejectPacket) + + self.assertEqual("4139", packet.msgNo) + self.assertEqual("HB9FDL-1", packet.from_call) + self.assertEqual("REPEAT", packet.to_call) + self.assertEqual("reject", packet.packet_type) + self.assertIsNone(packet.payload) + + def test_thirdparty_factory(self): + """Test to ensure a third party packet is created.""" + packet_raw = "GTOWN>APDW16,WIDE1-1,WIDE2-1:}KM6LYW-9>APZ100,TCPIP,GTOWN*::KM6LYW :KM6LYW: 19 Miles SW" + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.ThirdPartyPacket) + + def test_weather_factory(self): + """Test to ensure a weather packet is created.""" + packet_raw = "FW9222>APRS,TCPXX*,qAX,CWOP-6:@122025z2953.94N/08423.77W_232/003g006t084r000p032P000h80b10157L745.DsWLL" + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.WeatherPacket) + + self.assertEqual(28.88888888888889, packet.temperature) + self.assertEqual(0.0, packet.rain_1h) + self.assertEqual(1015.7, packet.pressure) + self.assertEqual(80, packet.humidity) + self.assertEqual(745, packet.luminosity) + self.assertEqual(3.0, packet.wind_speed) + self.assertEqual(232, packet.wind_direction) + self.assertEqual(6.0, packet.wind_gust) + self.assertEqual(29.899, packet.latitude) + self.assertEqual(-84.39616666666667, packet.longitude) + + def test_mice_factory(self): + packet_raw = 'kh2sr-15>S7TSYR,WIDE1-1,WIDE2-1,qAO,KO6KL-1:`1`7\x1c\x1c.#/`"4,}QuirkyQRP 4.6V 35.3C S06' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.MicEPacket) + + # Packet with telemetry and DAO + # http://www.aprs.org/datum.txt + packet_raw = 'KD9YIL>T0PX9W,WIDE1-1,WIDE2-1,qAO,NU9R-10:`sB,l#P>/\'"6+}|#*%U\'a|!whl!|3' + packet_dict = aprslib.parse(packet_raw) + packet = packets.factory(packet_dict) + self.assertIsInstance(packet, packets.MicEPacket) + + def test_ack_format(self): + """Test the ack packet format.""" + ack = packets.AckPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + msgNo=123, + ) + + expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:ack123" + self.assertEqual(expected, str(ack)) + + def test_reject_format(self): + """Test the reject packet format.""" + reject = packets.RejectPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + msgNo=123, + ) + + expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:rej123" + self.assertEqual(expected, str(reject)) + + def test_beacon_format(self): + """Test the beacon packet format.""" + lat = 28.123456 + lon = -80.123456 + ts = 1711219496.6426 + comment = "My Beacon Comment" + packet = packets.BeaconPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=lat, + longitude=lon, + timestamp=ts, + symbol=">", + comment=comment, + ) + + expected_lat = aprslib_util.latitude_to_ddm(lat) + expected_lon = aprslib_util.longitude_to_ddm(lon) + expected = f"KFAKE>APZ100:@231844z{expected_lat}/{expected_lon}>{comment}" + self.assertEqual(expected, str(packet)) + + def test_beacon_format_no_comment(self): + """Test the beacon packet format.""" + lat = 28.123456 + lon = -80.123456 + ts = 1711219496.6426 + packet = packets.BeaconPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + latitude=lat, + longitude=lon, + timestamp=ts, + symbol=">", + ) + empty_comment = "APRSD Beacon" + + expected_lat = aprslib_util.latitude_to_ddm(lat) + expected_lon = aprslib_util.longitude_to_ddm(lon) + expected = f"KFAKE>APZ100:@231844z{expected_lat}/{expected_lon}>{empty_comment}" + self.assertEqual(expected, str(packet)) + + def test_bulletin_format(self): + """Test the bulletin packet format.""" + # bulletin id = 0 + bid = 0 + packet = packets.BulletinPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + message_text="My Bulletin Message", + bid=0, + ) + + expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::BLN{bid:<9}:{packet.message_text}" + self.assertEqual(expected, str(packet)) + + # bulletin id = 1 + bid = 1 + txt = "((((((( CX2SA - Salto Uruguay ))))))) http://www.cx2sa.org" + packet = packets.BulletinPacket( + from_call=fake.FAKE_FROM_CALLSIGN, + message_text=txt, + bid=1, + ) + + expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::BLN{bid:<9}:{txt}" + self.assertEqual(expected, str(packet)) + + def test_message_format(self): + """Test the message packet format.""" + + message = "My Message" + msgno = "ABX" + packet = packets.MessagePacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + message_text=message, + msgNo=msgno, + ) + + expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:{message}{{{msgno}" + self.assertEqual(expected, str(packet)) + + # test with bad words + # Currently fails with mixed case + message = "My cunt piss fuck shIt text" + exp_msg = "My **** **** **** **** text" + msgno = "ABX" + packet = packets.MessagePacket( + from_call=fake.FAKE_FROM_CALLSIGN, + to_call=fake.FAKE_TO_CALLSIGN, + message_text=message, + msgNo=msgno, + ) + expected = f"{fake.FAKE_FROM_CALLSIGN}>APZ100::{fake.FAKE_TO_CALLSIGN:<9}:{exp_msg}{{{msgno}" + self.assertEqual(expected, str(packet)) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index e69ede3..e9a17eb 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -45,7 +45,6 @@ class TestPluginManager(unittest.TestCase): self.assertEqual([], plugin_list) pm.setup_plugins() plugin_list = pm.get_plugins() - print(plugin_list) self.assertIsInstance(plugin_list, list) self.assertIsInstance( plugin_list[0], @@ -163,9 +162,7 @@ class TestPluginBase(TestPlugin): self.assertEqual(expected, actual) mock_process.assert_not_called() - packet = fake.fake_packet( - message_format=core.PACKET_TYPE_ACK, - ) + packet = fake.fake_ack_packet() expected = packets.NULL_MESSAGE actual = p.filter(packet) self.assertEqual(expected, actual) diff --git a/tox.ini b/tox.ini index cdce1e7..eed3e9d 100644 --- a/tox.ini +++ b/tox.ini @@ -94,7 +94,7 @@ skip_install = true deps = -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt commands = - mypy aprsd + mypy --ignore-missing-imports --install-types aprsd [testenv:pre-commit] skip_install = true