1
0
mirror of https://github.com/craigerl/aprsd.git synced 2024-11-22 16:08:44 -05:00

Added the ability to use direwolf KISS socket

This patch adds APRS KISS connectivity.  I have tested this with
a running Direwolf install via either a serial KISS connection or
the optional new TCPKISS connection, both to Direwolf.

This adds the new required aioax25 python library for the underlying
KISS and AX25 support.

NOTE: For the TCPKISS connection, this patch requires a pull request
patch the aioax25 library to include a TCP Based KISS TNC client to
enable the TCPKISS client  So you will need to pull down this PR
https://github.com/sjlongland/aioax25/pull/7

To enable this,
  Edit your aprsd.yml file and enable one of the 2 KISS connections.
  Only one is supported at a time.

  kiss:
     serial:
         enabled: True
         device: /dev/ttyS1
         baudrate: 9600

  or

  kiss:
      tcp:
          enabled: True
          host: "ip address/hostname of direwolf"
          port: "direwolf configured kiss port"

This patch alters the Message object classes to be able to
send messages out via the aprslib socket connection to the APRS-IS
network on the internet, or via the direwolf KISS TCP socket,
depending on the origination of the initial message coming in.

If an APRS message comes in via APRS-IS, then replies will go out
APRS-IS.  IF an APRS message comes in via direwolf, then replies
will go out via direwolf KISS TCP socket.   Both can work at the same
time.

TODO:  I need some real APRS message packets to verify that
the new thread is processing packets correctly through the plugins
and able to send the resulting messages back out to direwolf.

Have a hard coded callsign for now in the kissclient consumer call,
just so I can see messages coming in from direwolf.  I dont' have an
APRS capable radio at the moment to send messages directly to direwolf.
Might need to write a simple python socket server to send fake APRS
messages to aprsd kiss, just for finishing up development.
This commit is contained in:
Hemna 2021-02-25 21:01:52 -05:00
parent a7d79a6e1b
commit b53e2ba7fe
8 changed files with 411 additions and 27 deletions

View File

@ -90,6 +90,11 @@ class Aprsdis(aprslib.IS):
self.thread_stop = True self.thread_stop = True
LOG.info("Shutdown Aprsdis client.") 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): def _socket_readlines(self, blocking=False):
""" """
Generator for complete lines, received from the server Generator for complete lines, received from the server

138
aprsd/kissclient.py Normal file
View File

@ -0,0 +1,138 @@
import asyncio
import logging
from aioax25 import frame as axframe
from aioax25 import interface
from aioax25 import kiss as kiss
from aioax25.aprs import APRSInterface
from aprsd import trace
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 "serial" in config["kiss"]:
if config["kiss"]["serial"].get("enabled", False):
return True
if "tcp" in config["kiss"]:
if config["kiss"]["serial"].get("enabled", False):
return True
@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"]["host"],
self.config["kiss"]["port"],
),
)
self.kissdev = kiss.TCPKISSDevice(
self.config["kiss"]["host"],
self.config["kiss"]["port"],
loop=self.loop,
)
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["ham"]["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=callsign, regex=True)
def send(self, msg):
"""Send an APRS Message object."""
payload = msg._filter_for_send()
frame = axframe.AX25UnnumberedInformationFrame(
msg.tocall,
msg.fromcall.encode("UTF-8"),
pid=0xF0,
repeaters=b"WIDE2-1",
payload=payload,
)
LOG.debug(frame)
self.ax25int.transmit(frame)
def get_client():
cl = KISSClient()
return cl.client

View File

@ -37,7 +37,7 @@ import click_completion
# local imports here # local imports here
import aprsd import aprsd
from aprsd import ( from aprsd import (
client, flask, messaging, packets, plugin, stats, threads, trace, utils, client, flask, kissclient, messaging, packets, plugin, stats, threads, trace, utils,
) )
@ -458,15 +458,21 @@ def server(
trace.setup_tracing(["method", "api"]) trace.setup_tracing(["method", "api"])
stats.APRSDStats(config) stats.APRSDStats(config)
# Create the initial PM singleton and Register plugins
plugin_manager = plugin.PluginManager(config)
plugin_manager.setup_plugins()
if config["aprs"].get("enabled", True):
try: try:
cl = client.Client(config) cl = client.Client(config)
cl.client cl.client
except LoginError: except LoginError:
sys.exit(-1) sys.exit(-1)
else:
# Create the initial PM singleton and Register plugins LOG.info(
plugin_manager = plugin.PluginManager(config) "APRS network connection Not Enabled in config. This is"
plugin_manager.setup_plugins() " for setups without internet connectivity.",
)
# Now load the msgTrack from disk if any # Now load the msgTrack from disk if any
if flush: if flush:
@ -492,6 +498,14 @@ def server(
): ):
packets.WatchList(config=config) packets.WatchList(config=config)
if kissclient.KISSClient.kiss_enabled(config):
kcl = kissclient.KISSClient(config=config)
kcl.client
kissrx_thread = threads.KISSRXThread(msg_queues=msg_queues, config=config)
kissrx_thread.start()
messaging.MsgTrack().restart() messaging.MsgTrack().restart()
keepalive = threads.KeepAliveThread(config=config) keepalive = threads.KeepAliveThread(config=config)

View File

@ -9,7 +9,7 @@ import re
import threading import threading
import time 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") LOG = logging.getLogger("APRSD")
@ -18,6 +18,9 @@ LOG = logging.getLogger("APRSD")
# and it's ok, but don't send a usage string back # and it's ok, but don't send a usage string back
NULL_MESSAGE = -1 NULL_MESSAGE = -1
MESSAGE_TRANSPORT_TCPKISS = "tcpkiss"
MESSAGE_TRANSPORT_APRSIS = "aprsis"
class MsgTrack: class MsgTrack:
"""Class to keep track of outstanding text messages. """Class to keep track of outstanding text messages.
@ -228,7 +231,15 @@ class Message(metaclass=abc.ABCMeta):
last_send_time = 0 last_send_time = 0
last_send_attempt = 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.fromcall = fromcall
self.tocall = tocall self.tocall = tocall
if not msg_id: if not msg_id:
@ -236,11 +247,18 @@ class Message(metaclass=abc.ABCMeta):
c.increment() c.increment()
msg_id = c.value msg_id = c.value
self.id = msg_id self.id = msg_id
self.transport = transport
@abc.abstractmethod @abc.abstractmethod
def send(self): def send(self):
"""Child class must declare.""" """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): class RawMessage(Message):
"""Send a raw message. """Send a raw message.
@ -252,8 +270,8 @@ class RawMessage(Message):
message = None message = None
def __init__(self, message): def __init__(self, message, transport=MESSAGE_TRANSPORT_APRSIS):
super().__init__(None, None, msg_id=None) super().__init__(None, None, msg_id=None, transport=transport)
self.message = message self.message = message
def dict(self): def dict(self):
@ -282,7 +300,7 @@ class RawMessage(Message):
def send_direct(self): def send_direct(self):
"""Send a message without a separate thread.""" """Send a message without a separate thread."""
cl = client.get_client() cl = self.get_transport()
log_message( log_message(
"Sending Message Direct", "Sending Message Direct",
str(self).rstrip("\n"), str(self).rstrip("\n"),
@ -290,7 +308,7 @@ class RawMessage(Message):
tocall=self.tocall, tocall=self.tocall,
fromcall=self.fromcall, fromcall=self.fromcall,
) )
cl.sendall(str(self)) cl.send(self)
stats.APRSDStats().msgs_sent_inc() stats.APRSDStats().msgs_sent_inc()
@ -299,8 +317,16 @@ class TextMessage(Message):
message = None message = None
def __init__(self, fromcall, tocall, message, msg_id=None, allow_delay=True): def __init__(
super().__init__(fromcall, tocall, msg_id) 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 self.message = message
# do we try and save this message for later if we don't get # 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. # an ack? Some messages we don't want to do this ever.
@ -354,7 +380,7 @@ class TextMessage(Message):
def send_direct(self): def send_direct(self):
"""Send a message without a separate thread.""" """Send a message without a separate thread."""
cl = client.get_client() cl = self.get_transport()
log_message( log_message(
"Sending Message Direct", "Sending Message Direct",
str(self).rstrip("\n"), str(self).rstrip("\n"),
@ -362,7 +388,7 @@ class TextMessage(Message):
tocall=self.tocall, tocall=self.tocall,
fromcall=self.fromcall, fromcall=self.fromcall,
) )
cl.sendall(str(self)) cl.send(self)
stats.APRSDStats().msgs_tx_inc() stats.APRSDStats().msgs_tx_inc()
@ -382,7 +408,6 @@ class SendMessageThread(threads.APRSDThread):
last send attempt is old enough. last send attempt is old enough.
""" """
cl = client.get_client()
tracker = MsgTrack() tracker = MsgTrack()
# lets see if the message is still in the tracking queue # lets see if the message is still in the tracking queue
msg = tracker.get(self.msg.id) msg = tracker.get(self.msg.id)
@ -392,6 +417,7 @@ class SendMessageThread(threads.APRSDThread):
LOG.info("Message Send Complete via Ack.") LOG.info("Message Send Complete via Ack.")
return False return False
else: else:
cl = msg.get_transport()
send_now = False send_now = False
if msg.last_send_attempt == msg.retry_count: if msg.last_send_attempt == msg.retry_count:
# we reached the send limit, don't send again # we reached the send limit, don't send again
@ -422,7 +448,7 @@ class SendMessageThread(threads.APRSDThread):
retry_number=msg.last_send_attempt, retry_number=msg.last_send_attempt,
msg_num=msg.id, msg_num=msg.id,
) )
cl.sendall(str(msg)) cl.send(msg)
stats.APRSDStats().msgs_tx_inc() stats.APRSDStats().msgs_tx_inc()
packets.PacketList().add(msg.dict()) packets.PacketList().add(msg.dict())
msg.last_send_time = datetime.datetime.now() msg.last_send_time = datetime.datetime.now()
@ -436,8 +462,8 @@ class SendMessageThread(threads.APRSDThread):
class AckMessage(Message): class AckMessage(Message):
"""Class for building Acks and sending them.""" """Class for building Acks and sending them."""
def __init__(self, fromcall, tocall, msg_id): def __init__(self, fromcall, tocall, msg_id, transport=MESSAGE_TRANSPORT_APRSIS):
super().__init__(fromcall, tocall, msg_id=msg_id) super().__init__(fromcall, tocall, msg_id=msg_id, transport=transport)
def dict(self): def dict(self):
now = datetime.datetime.now() now = datetime.datetime.now()
@ -470,7 +496,7 @@ class AckMessage(Message):
def send_direct(self): def send_direct(self):
"""Send an ack message without a separate thread.""" """Send an ack message without a separate thread."""
cl = client.get_client() cl = self.get_transport()
log_message( log_message(
"Sending ack", "Sending ack",
str(self).rstrip("\n"), str(self).rstrip("\n"),
@ -479,7 +505,7 @@ class AckMessage(Message):
tocall=self.tocall, tocall=self.tocall,
fromcall=self.fromcall, fromcall=self.fromcall,
) )
cl.sendall(str(self)) cl.send(self)
class SendAckThread(threads.APRSDThread): class SendAckThread(threads.APRSDThread):
@ -515,7 +541,7 @@ class SendAckThread(threads.APRSDThread):
send_now = True send_now = True
if send_now: if send_now:
cl = client.get_client() cl = self.ack.get_transport()
log_message( log_message(
"Sending ack", "Sending ack",
str(self.ack).rstrip("\n"), str(self.ack).rstrip("\n"),
@ -524,7 +550,7 @@ class SendAckThread(threads.APRSDThread):
tocall=self.ack.tocall, tocall=self.ack.tocall,
retry_number=self.ack.last_send_attempt, retry_number=self.ack.last_send_attempt,
) )
cl.sendall(str(self.ack)) cl.send(self.ack)
stats.APRSDStats().ack_tx_inc() stats.APRSDStats().ack_tx_inc()
packets.PacketList().add(self.ack.dict()) packets.PacketList().add(self.ack.dict())
self.ack.last_send_attempt += 1 self.ack.last_send_attempt += 1

View File

@ -8,7 +8,7 @@ import tracemalloc
import aprslib 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") LOG = logging.getLogger("APRSD")
@ -314,3 +314,176 @@ class APRSDTXThread(APRSDThread):
pass pass
# Continue to loop # Continue to loop
return True 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="APN382")
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
@trace.trace
def process_packet(self, interface, frame, match):
"""Process a packet recieved from aprs-is server."""
LOG.debug("Got an APRS Frame '{}'".format(frame))
payload = str(frame.payload.decode())
msg = "{}:{}".format(str(frame.header), payload)
packet = aprslib.parse(msg)
LOG.debug(packet)
try:
stats.APRSDStats().msgs_rx_inc()
msg = packet.get("message_text", None)
msg_format = packet.get("format", None)
msg_response = packet.get("response", None)
if msg_format == "message" and msg:
# we want to send the message through the
# plugins
self.process_message_packet(packet)
return
elif msg_response == "ack":
self.process_ack_packet(packet)
return
if msg_format == "mic-e":
# process a mic-e packet
self.process_mic_e_packet(packet)
return
except (aprslib.ParseError, aprslib.UnknownFormat) as exp:
LOG.exception("Failed to parse packet from aprs-is", exp)
@trace.trace
def process_message_packet(self, packet):
LOG.debug("Message packet rx")
fromcall = packet["from"]
message = packet.get("message_text", None)
msg_id = packet.get("msgNo", "0")
messaging.log_message(
"Received Message",
packet["raw"],
message,
fromcall=fromcall,
msg_num=msg_id,
)
found_command = False
# Get singleton of the PM
pm = plugin.PluginManager()
try:
results = pm.run(fromcall=fromcall, message=message, ack=msg_id)
for reply in results:
found_command = True
# A plugin can return a null message flag which signals
# us that they processed the message correctly, but have
# nothing to reply with, so we avoid replying with a usage string
if reply is not messaging.NULL_MESSAGE:
LOG.debug("Sending '{}'".format(reply))
msg = messaging.TextMessage(
self.config["aprs"]["login"],
fromcall,
reply,
transport=messaging.MESSAGE_TRANSPORT_TCPKISS,
)
self.msg_queues["tx"].put(msg)
else:
LOG.debug("Got NULL MESSAGE from plugin")
if not found_command:
plugins = pm.get_plugins()
names = [x.command_name for x in plugins]
names.sort()
# reply = "Usage: {}".format(", ".join(names))
reply = "Usage: weather, locate [call], time, fortune, ping"
msg = messaging.TextMessage(
self.config["aprs"]["login"],
fromcall,
reply,
transport=messaging.MESSAGE_TRANSPORT_TCPKISS,
)
self.msg_queues["tx"].put(msg)
except Exception as ex:
LOG.exception("Plugin failed!!!", ex)
reply = "A Plugin failed! try again?"
msg = messaging.TextMessage(
self.config["aprs"]["login"],
fromcall,
reply,
transport=messaging.MESSAGE_TRANSPORT_TCPKISS,
)
self.msg_queues["tx"].put(msg)
# let any threads do their thing, then ack
# send an ack last
ack = messaging.AckMessage(
self.config["aprs"]["login"],
fromcall,
msg_id=msg_id,
transport=messaging.MESSAGE_TRANSPORT_TCPKISS,
)
self.msg_queues["tx"].put(ack)
LOG.debug("Packet processing complete")
def process_ack_packet(self, packet):
ack_num = packet.get("msgNo")
LOG.info("Got ack for message {}".format(ack_num))
messaging.log_message(
"ACK",
packet["raw"],
None,
ack=ack_num,
fromcall=packet["from"],
)
tracker = messaging.MsgTrack()
tracker.remove(ack_num)
stats.APRSDStats().ack_rx_inc()
return
def process_mic_e_packet(self, packet):
LOG.info("Mic-E Packet detected. Currenlty unsupported.")
messaging.log_packet(packet)
stats.APRSDStats().msgs_mice_inc()
return

View File

@ -37,11 +37,24 @@ DEFAULT_DATE_FORMAT = "%m/%d/%Y %I:%M:%S %p"
DEFAULT_CONFIG_DICT = { DEFAULT_CONFIG_DICT = {
"ham": {"callsign": "NOCALL"}, "ham": {"callsign": "NOCALL"},
"aprs": { "aprs": {
"login": "NOCALL", "enabled": True,
"login": "CALLSIGN",
"password": "00000", "password": "00000",
"host": "rotate.aprs2.net", "host": "rotate.aprs2.net",
"port": 14580, "port": 14580,
}, },
"kiss": {
"tcp": {
"enabled": False,
"host": "direwolf.ip.address",
"port": "8001",
},
"serial": {
"enabled": False,
"device": "/dev/ttyS0",
"baudrate": 9600,
},
},
"aprsd": { "aprsd": {
"logfile": "/tmp/aprsd.log", "logfile": "/tmp/aprsd.log",
"logformat": DEFAULT_LOG_FORMAT, "logformat": DEFAULT_LOG_FORMAT,
@ -172,6 +185,9 @@ def add_config_comments(raw_yaml):
# lets insert a comment # lets insert a comment
raw_yaml = insert_str( raw_yaml = insert_str(
raw_yaml, 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 # Get the passcode for your callsign here: "
"\n # https://apps.magicbug.co.uk/passcode", "\n # https://apps.magicbug.co.uk/passcode",
end_idx, end_idx,

View File

@ -1,3 +1,4 @@
aioax25
aprslib aprslib
click click
click-completion click-completion

View File

@ -4,6 +4,8 @@
# #
# pip-compile requirements.in # pip-compile requirements.in
# #
aioax25==0.0.9
# via -r requirements.in
aprslib==0.6.47 aprslib==0.6.47
# via -r requirements.in # via -r requirements.in
backoff==1.10.0 backoff==1.10.0
@ -21,6 +23,8 @@ click==7.1.2
# -r requirements.in # -r requirements.in
# click-completion # click-completion
# flask # flask
contexter==0.1.4
# via signalslot
cryptography==3.4.7 cryptography==3.4.7
# via pyopenssl # via pyopenssl
dnspython==2.1.0 dnspython==2.1.0
@ -72,6 +76,8 @@ pycparser==2.20
# via cffi # via cffi
pyopenssl==20.0.1 pyopenssl==20.0.1
# via opencage # via opencage
pyserial==3.5
# via aioax25
python-dateutil==2.8.1 python-dateutil==2.8.1
# via pandas # via pandas
pytz==2021.1 pytz==2021.1
@ -88,6 +94,8 @@ requests==2.25.1
# yfinance # yfinance
shellingham==1.4.0 shellingham==1.4.0
# via click-completion # via click-completion
signalslot==0.1.2
# via aioax25
six==1.15.0 six==1.15.0
# via # via
# -r requirements.in # -r requirements.in
@ -96,12 +104,15 @@ six==1.15.0
# opencage # opencage
# pyopenssl # pyopenssl
# python-dateutil # python-dateutil
# signalslot
thesmuggler==1.0.1 thesmuggler==1.0.1
# via -r requirements.in # via -r requirements.in
update-checker==0.18.0 update-checker==0.18.0
# via -r requirements.in # via -r requirements.in
urllib3==1.26.5 urllib3==1.26.5
# via requests # via requests
weakrefmethod==1.0.3
# via signalslot
werkzeug==1.0.1 werkzeug==1.0.1
# via flask # via flask
yfinance==0.1.59 yfinance==0.1.59