diff --git a/aprsd/client.py b/aprsd/client.py index 40c9d40..a2d5e45 100644 --- a/aprsd/client.py +++ b/aprsd/client.py @@ -90,6 +90,11 @@ class Aprsdis(aprslib.IS): self.thread_stop = True LOG.info("Shutdown Aprsdis client.") + def send(self, msg): + """Send an APRS Message object.""" + line = str(msg) + self.sendall(line) + def _socket_readlines(self, blocking=False): """ Generator for complete lines, received from the server diff --git a/aprsd/flask.py b/aprsd/flask.py index 63014c9..e850899 100644 --- a/aprsd/flask.py +++ b/aprsd/flask.py @@ -11,7 +11,7 @@ from flask_httpauth import HTTPBasicAuth from werkzeug.security import check_password_hash, generate_password_hash import aprsd -from aprsd import messaging, packets, plugin, stats, utils +from aprsd import kissclient, messaging, packets, plugin, stats, utils LOG = logging.getLogger("APRSD") @@ -65,9 +65,38 @@ class APRSDFlask(flask_classful.FlaskView): plugins = pm.get_plugins() plugin_count = len(plugins) + if self.config["aprs"].get("enabled", True): + transport = "aprs-is" + aprs_connection = ( + "APRS-IS Server: " + "{}".format(stats["stats"]["aprs-is"]["server"]) + ) + else: + # We might be connected to a KISS socket? + if kissclient.KISSClient.kiss_enabled(self.config): + transport = kissclient.KISSClient.transport(self.config) + if transport == kissclient.TRANSPORT_TCPKISS: + aprs_connection = ( + "TCPKISS://{}:{}".format( + self.config["kiss"]["tcp"]["host"], + self.config["kiss"]["tcp"]["port"], + ) + ) + elif transport == kissclient.TRANSPORT_SERIALKISS: + aprs_connection = ( + "SerialKISS://{}@{} baud".format( + self.config["kiss"]["serial"]["device"], + self.config["kiss"]["serial"]["baudrate"], + ) + ) + + stats["transport"] = transport + stats["aprs_connection"] = aprs_connection + return flask.render_template( "index.html", initial_stats=stats, + aprs_connection=aprs_connection, callsign=self.config["aprs"]["login"], version=aprsd.__version__, config_json=json.dumps(self.config), diff --git a/aprsd/kissclient.py b/aprsd/kissclient.py new file mode 100644 index 0000000..bed12c2 --- /dev/null +++ b/aprsd/kissclient.py @@ -0,0 +1,152 @@ +import asyncio +import logging + +from aioax25 import interface +from aioax25 import kiss as kiss +from aioax25.aprs import APRSInterface + +from aprsd import trace + + +TRANSPORT_TCPKISS = "tcpkiss" +TRANSPORT_SERIALKISS = "serialkiss" +LOG = logging.getLogger("APRSD") + + +class KISSClient: + + _instance = None + config = None + ax25client = None + loop = None + + def __new__(cls, *args, **kwargs): + """Singleton for this class.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + # initialize shit here + return cls._instance + + def __init__(self, config=None): + if config: + self.config = config + + @staticmethod + def kiss_enabled(config): + """Return if tcp or serial KISS is enabled.""" + if "kiss" not in config: + return False + + if "serial" in config["kiss"]: + if config["kiss"]["serial"].get("enabled", False): + return True + + if "tcp" in config["kiss"]: + if config["kiss"]["tcp"].get("enabled", False): + return True + + @staticmethod + def transport(config): + if "serial" in config["kiss"]: + if config["kiss"]["serial"].get("enabled", False): + return TRANSPORT_SERIALKISS + + if "tcp" in config["kiss"]: + if config["kiss"]["tcp"].get("enabled", False): + return TRANSPORT_TCPKISS + + @property + def client(self): + if not self.ax25client: + self.ax25client = self.setup_connection() + return self.ax25client + + def reset(self): + """Call this to fore a rebuild/reconnect.""" + self.ax25client.stop() + del self.ax25client + + @trace.trace + def setup_connection(self): + ax25client = Aioax25Client(self.config) + LOG.debug("Complete") + return ax25client + + +class Aioax25Client: + def __init__(self, config): + self.config = config + self.setup() + + def setup(self): + # we can be TCP kiss or Serial kiss + + self.loop = asyncio.get_event_loop() + if "serial" in self.config["kiss"] and self.config["kiss"]["serial"].get( + "enabled", + False, + ): + LOG.debug( + "Setting up Serial KISS connection to {}".format( + self.config["kiss"]["serial"]["device"], + ), + ) + self.kissdev = kiss.SerialKISSDevice( + device=self.config["kiss"]["serial"]["device"], + baudrate=self.config["kiss"]["serial"].get("baudrate", 9600), + loop=self.loop, + ) + elif "tcp" in self.config["kiss"] and self.config["kiss"]["tcp"].get( + "enabled", + False, + ): + LOG.debug( + "Setting up KISSTCP Connection to {}:{}".format( + self.config["kiss"]["tcp"]["host"], + self.config["kiss"]["tcp"]["port"], + ), + ) + self.kissdev = kiss.TCPKISSDevice( + self.config["kiss"]["tcp"]["host"], + self.config["kiss"]["tcp"]["port"], + loop=self.loop, + log=LOG, + ) + + self.kissdev.open() + self.kissport0 = self.kissdev[0] + + LOG.debug("Creating AX25Interface") + self.ax25int = interface.AX25Interface(kissport=self.kissport0, loop=self.loop) + + LOG.debug("Creating APRSInterface") + self.aprsint = APRSInterface( + ax25int=self.ax25int, + mycall=self.config["kiss"]["callsign"], + log=LOG, + ) + + def stop(self): + LOG.debug(self.kissdev) + self.kissdev._close() + self.loop.stop() + + def consumer(self, callback, callsign=None): + if not callsign: + callsign = self.config["ham"]["callsign"] + self.aprsint.bind(callback=callback, callsign="WB4BOR", ssid=12, regex=False) + + def send(self, msg): + """Send an APRS Message object.""" + payload = f"{msg._filter_for_send()}" + self.aprsint.send_message( + addressee=msg.tocall, + message=payload, + path=["WIDE1-1", "WIDE2-1"], + oneshot=True, + ) + + +def get_client(): + cl = KISSClient() + return cl.client diff --git a/aprsd/main.py b/aprsd/main.py index d7ee309..bd9a711 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -37,7 +37,8 @@ import click_completion # local imports here import aprsd from aprsd import ( - client, flask, messaging, packets, plugin, stats, threads, trace, utils, + client, flask, kissclient, messaging, packets, plugin, stats, threads, + trace, utils, ) @@ -458,16 +459,28 @@ def server( trace.setup_tracing(["method", "api"]) stats.APRSDStats(config) - try: - cl = client.Client(config) - cl.client - except LoginError: - sys.exit(-1) - # Create the initial PM singleton and Register plugins plugin_manager = plugin.PluginManager(config) plugin_manager.setup_plugins() + if config["aprs"].get("enabled", True): + try: + cl = client.Client(config) + cl.client + except LoginError: + sys.exit(-1) + + rx_thread = threads.APRSDRXThread( + msg_queues=threads.msg_queues, + config=config, + ) + rx_thread.start() + else: + LOG.info( + "APRS network connection Not Enabled in config. This is" + " for setups without internet connectivity.", + ) + # Now load the msgTrack from disk if any if flush: LOG.debug("Deleting saved MsgTrack.") @@ -478,19 +491,15 @@ def server( messaging.MsgTrack().load() packets.PacketList(config=config) + packets.WatchList(config=config) - rx_thread = threads.APRSDRXThread( - msg_queues=threads.msg_queues, - config=config, - ) + if kissclient.KISSClient.kiss_enabled(config): + kcl = kissclient.KISSClient(config=config) + # This initializes the client object. + kcl.client - rx_thread.start() - - if "watch_list" in config["aprsd"] and config["aprsd"]["watch_list"].get( - "enabled", - True, - ): - packets.WatchList(config=config) + kissrx_thread = threads.KISSRXThread(msg_queues=threads.msg_queues, config=config) + kissrx_thread.start() messaging.MsgTrack().restart() diff --git a/aprsd/messaging.py b/aprsd/messaging.py index 1d2c8b3..f53ce0e 100644 --- a/aprsd/messaging.py +++ b/aprsd/messaging.py @@ -9,7 +9,7 @@ import re import threading import time -from aprsd import client, packets, stats, threads, trace, utils +from aprsd import client, kissclient, packets, stats, threads, trace, utils LOG = logging.getLogger("APRSD") @@ -18,6 +18,10 @@ LOG = logging.getLogger("APRSD") # and it's ok, but don't send a usage string back NULL_MESSAGE = -1 +MESSAGE_TRANSPORT_TCPKISS = "tcpkiss" +MESSAGE_TRANSPORT_SERIALKISS = "serialkiss" +MESSAGE_TRANSPORT_APRSIS = "aprsis" + class MsgTrack: """Class to keep track of outstanding text messages. @@ -228,7 +232,15 @@ class Message(metaclass=abc.ABCMeta): last_send_time = 0 last_send_attempt = 0 - def __init__(self, fromcall, tocall, msg_id=None): + transport = None + + def __init__( + self, + fromcall, + tocall, + msg_id=None, + transport=MESSAGE_TRANSPORT_APRSIS, + ): self.fromcall = fromcall self.tocall = tocall if not msg_id: @@ -236,11 +248,18 @@ class Message(metaclass=abc.ABCMeta): c.increment() msg_id = c.value self.id = msg_id + self.transport = transport @abc.abstractmethod def send(self): """Child class must declare.""" + def get_transport(self): + if self.transport == MESSAGE_TRANSPORT_APRSIS: + return client.get_client() + elif self.transport == MESSAGE_TRANSPORT_TCPKISS: + return kissclient.get_client() + class RawMessage(Message): """Send a raw message. @@ -252,8 +271,8 @@ class RawMessage(Message): message = None - def __init__(self, message): - super().__init__(None, None, msg_id=None) + def __init__(self, message, transport=MESSAGE_TRANSPORT_APRSIS): + super().__init__(None, None, msg_id=None, transport=transport) self.message = message def dict(self): @@ -282,7 +301,7 @@ class RawMessage(Message): def send_direct(self): """Send a message without a separate thread.""" - cl = client.get_client() + cl = self.get_transport() log_message( "Sending Message Direct", str(self).rstrip("\n"), @@ -290,7 +309,7 @@ class RawMessage(Message): tocall=self.tocall, fromcall=self.fromcall, ) - cl.sendall(str(self)) + cl.send(self) stats.APRSDStats().msgs_sent_inc() @@ -299,8 +318,16 @@ class TextMessage(Message): message = None - def __init__(self, fromcall, tocall, message, msg_id=None, allow_delay=True): - super().__init__(fromcall, tocall, msg_id) + def __init__( + self, + fromcall, + tocall, + message, + msg_id=None, + allow_delay=True, + transport=MESSAGE_TRANSPORT_APRSIS, + ): + super().__init__(fromcall, tocall, msg_id, transport=transport) self.message = message # do we try and save this message for later if we don't get # an ack? Some messages we don't want to do this ever. @@ -354,7 +381,7 @@ class TextMessage(Message): def send_direct(self): """Send a message without a separate thread.""" - cl = client.get_client() + cl = self.get_transport() log_message( "Sending Message Direct", str(self).rstrip("\n"), @@ -362,7 +389,7 @@ class TextMessage(Message): tocall=self.tocall, fromcall=self.fromcall, ) - cl.sendall(str(self)) + cl.send(self) stats.APRSDStats().msgs_tx_inc() @@ -382,7 +409,6 @@ class SendMessageThread(threads.APRSDThread): last send attempt is old enough. """ - cl = client.get_client() tracker = MsgTrack() # lets see if the message is still in the tracking queue msg = tracker.get(self.msg.id) @@ -392,6 +418,7 @@ class SendMessageThread(threads.APRSDThread): LOG.info("Message Send Complete via Ack.") return False else: + cl = msg.get_transport() send_now = False if msg.last_send_attempt == msg.retry_count: # we reached the send limit, don't send again @@ -422,7 +449,7 @@ class SendMessageThread(threads.APRSDThread): retry_number=msg.last_send_attempt, msg_num=msg.id, ) - cl.sendall(str(msg)) + cl.send(msg) stats.APRSDStats().msgs_tx_inc() packets.PacketList().add(msg.dict()) msg.last_send_time = datetime.datetime.now() @@ -436,8 +463,8 @@ class SendMessageThread(threads.APRSDThread): class AckMessage(Message): """Class for building Acks and sending them.""" - def __init__(self, fromcall, tocall, msg_id): - super().__init__(fromcall, tocall, msg_id=msg_id) + def __init__(self, fromcall, tocall, msg_id, transport=MESSAGE_TRANSPORT_APRSIS): + super().__init__(fromcall, tocall, msg_id=msg_id, transport=transport) def dict(self): now = datetime.datetime.now() @@ -463,6 +490,9 @@ class AckMessage(Message): self.id, ) + def _filter_for_send(self): + return f"ack{self.id}" + def send(self): LOG.debug(f"Send ACK({self.tocall}:{self.id}) to radio.") thread = SendAckThread(self) @@ -470,7 +500,7 @@ class AckMessage(Message): def send_direct(self): """Send an ack message without a separate thread.""" - cl = client.get_client() + cl = self.get_transport() log_message( "Sending ack", str(self).rstrip("\n"), @@ -479,7 +509,7 @@ class AckMessage(Message): tocall=self.tocall, fromcall=self.fromcall, ) - cl.sendall(str(self)) + cl.send(self) class SendAckThread(threads.APRSDThread): @@ -515,7 +545,7 @@ class SendAckThread(threads.APRSDThread): send_now = True if send_now: - cl = client.get_client() + cl = self.ack.get_transport() log_message( "Sending ack", str(self.ack).rstrip("\n"), @@ -524,7 +554,7 @@ class SendAckThread(threads.APRSDThread): tocall=self.ack.tocall, retry_number=self.ack.last_send_attempt, ) - cl.sendall(str(self.ack)) + cl.send(self.ack) stats.APRSDStats().ack_tx_inc() packets.PacketList().add(self.ack.dict()) self.ack.last_send_attempt += 1 diff --git a/aprsd/threads.py b/aprsd/threads.py index e1589bb..8592aa5 100644 --- a/aprsd/threads.py +++ b/aprsd/threads.py @@ -8,7 +8,7 @@ import tracemalloc import aprslib -from aprsd import client, messaging, packets, plugin, stats, utils +from aprsd import client, kissclient, messaging, packets, plugin, stats, utils LOG = logging.getLogger("APRSD") @@ -180,9 +180,10 @@ class APRSDRXThread(APRSDThread): class APRSDProcessPacketThread(APRSDThread): - def __init__(self, packet, config): + def __init__(self, packet, config, transport="aprsis"): self.packet = packet self.config = config + self.transport = transport name = self.packet["raw"][:10] super().__init__(f"RX_PACKET-{name}") @@ -237,6 +238,7 @@ class APRSDProcessPacketThread(APRSDThread): self.config["aprs"]["login"], fromcall, msg_id=msg_id, + transport=self.transport, ) ack.send() @@ -255,6 +257,7 @@ class APRSDProcessPacketThread(APRSDThread): self.config["aprs"]["login"], fromcall, subreply, + transport=self.transport, ) msg.send() @@ -271,6 +274,7 @@ class APRSDProcessPacketThread(APRSDThread): self.config["aprs"]["login"], fromcall, reply, + transport=self.transport, ) msg.send() @@ -283,6 +287,7 @@ class APRSDProcessPacketThread(APRSDThread): self.config["aprs"]["login"], fromcall, reply, + transport=self.transport, ) msg.send() except Exception as ex: @@ -294,6 +299,7 @@ class APRSDProcessPacketThread(APRSDThread): self.config["aprs"]["login"], fromcall, reply, + transport=self.transport, ) msg.send() @@ -314,3 +320,66 @@ class APRSDTXThread(APRSDThread): pass # Continue to loop return True + + +class KISSRXThread(APRSDThread): + """Thread that connects to direwolf's TCPKISS interface. + + All Packets are processed and sent back out the direwolf + interface instead of the aprs-is server. + + """ + + def __init__(self, msg_queues, config): + super().__init__("KISSRX_MSG") + self.msg_queues = msg_queues + self.config = config + + def stop(self): + self.thread_stop = True + kissclient.get_client().stop() + + def loop(self): + kiss_client = kissclient.get_client() + + # setup the consumer of messages and block until a messages + try: + # This will register a packet consumer with aprslib + # When new packets come in the consumer will process + # the packet + + # Do a partial here because the consumer signature doesn't allow + # For kwargs to be passed in to the consumer func we declare + # and the aprslib developer didn't want to allow a PR to add + # kwargs. :( + # https://github.com/rossengeorgiev/aprs-python/pull/56 + kiss_client.consumer(self.process_packet, callsign=self.config["kiss"]["callsign"]) + kiss_client.loop.run_forever() + + except aprslib.exceptions.ConnectionDrop: + LOG.error("Connection dropped, reconnecting") + time.sleep(5) + # Force the deletion of the client object connected to aprs + # This will cause a reconnect, next time client.get_client() + # is called + client.Client().reset() + # Continue to loop + + def process_packet(self, interface, frame): + """Process a packet recieved from aprs-is server.""" + + 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}" + LOG.debug(f"Decoding {msg}") + + packet = aprslib.parse(msg) + LOG.debug(packet) + thread = APRSDProcessPacketThread( + packet=packet, config=self.config, + transport=messaging.MESSAGE_TRANSPORT_TCPKISS, + ) + thread.start() + return diff --git a/aprsd/utils.py b/aprsd/utils.py index 14b47ff..e2acd2b 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -37,11 +37,24 @@ DEFAULT_DATE_FORMAT = "%m/%d/%Y %I:%M:%S %p" DEFAULT_CONFIG_DICT = { "ham": {"callsign": "NOCALL"}, "aprs": { - "login": "NOCALL", + "enabled": True, + "login": "CALLSIGN", "password": "00000", "host": "rotate.aprs2.net", "port": 14580, }, + "kiss": { + "tcp": { + "enabled": False, + "host": "direwolf.ip.address", + "port": "8001", + }, + "serial": { + "enabled": False, + "device": "/dev/ttyS0", + "baudrate": 9600, + }, + }, "aprsd": { "logfile": "/tmp/aprsd.log", "logformat": DEFAULT_LOG_FORMAT, @@ -172,6 +185,9 @@ def add_config_comments(raw_yaml): # lets insert a comment raw_yaml = insert_str( raw_yaml, + "\n # Set enabled to False if there is no internet connectivity." + "\n # This is useful for a direwolf KISS aprs connection only. " + "\n" "\n # Get the passcode for your callsign here: " "\n # https://apps.magicbug.co.uk/passcode", end_idx, diff --git a/aprsd/web/static/js/charts.js b/aprsd/web/static/js/charts.js index 9b42ffa..9d4ed65 100644 --- a/aprsd/web/static/js/charts.js +++ b/aprsd/web/static/js/charts.js @@ -220,7 +220,7 @@ function updateQuadData(chart, label, first, second, third, fourth) { function update_stats( data ) { $("#version").text( data["stats"]["aprsd"]["version"] ); - $("#aprsis").html( "APRS-IS Server: " + data["stats"]["aprs-is"]["server"] + "" ); + $("#aprs_connection").html( data["aprs_connection"] ); $("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] ); const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json'); $("#jsonstats").html(html_pretty); diff --git a/aprsd/web/templates/index.html b/aprsd/web/templates/index.html index 5314a1c..f82be37 100644 --- a/aprsd/web/templates/index.html +++ b/aprsd/web/templates/index.html @@ -58,7 +58,7 @@
{{ callsign }} connected to - NONE + {{ aprs_connection|safe }}
diff --git a/requirements.in b/requirements.in index 3a6f437..7703126 100644 --- a/requirements.in +++ b/requirements.in @@ -1,3 +1,4 @@ +aioax25>=0.0.10 aprslib click click-completion diff --git a/requirements.txt b/requirements.txt index 9e5af8c..f044a69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ # # pip-compile requirements.in # +aioax25==0.0.10 + # via -r requirements.in aprslib==0.6.47 # via -r requirements.in backoff==1.10.0 @@ -21,6 +23,8 @@ click==7.1.2 # -r requirements.in # click-completion # flask +contexter==0.1.4 + # via signalslot cryptography==3.4.7 # via pyopenssl dnspython==2.1.0 @@ -72,6 +76,8 @@ pycparser==2.20 # via cffi pyopenssl==20.0.1 # via opencage +pyserial==3.5 + # via aioax25 python-dateutil==2.8.1 # via pandas pytz==2021.1 @@ -88,6 +94,8 @@ requests==2.25.1 # yfinance shellingham==1.4.0 # via click-completion +signalslot==0.1.2 + # via aioax25 six==1.15.0 # via # -r requirements.in @@ -96,12 +104,15 @@ six==1.15.0 # opencage # pyopenssl # python-dateutil + # signalslot thesmuggler==1.0.1 # via -r requirements.in update-checker==0.18.0 # via -r requirements.in urllib3==1.26.5 # via requests +weakrefmethod==1.0.3 + # via signalslot werkzeug==1.0.1 # via flask yfinance==0.1.59