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
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

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
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 +458,22 @@ 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)
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.")
@ -492,6 +498,14 @@ def server(
):
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()
keepalive = threads.KeepAliveThread(config=config)

View File

@ -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,9 @@ 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_APRSIS = "aprsis"
class MsgTrack:
"""Class to keep track of outstanding text messages.
@ -228,7 +231,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 +247,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 +270,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 +300,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 +308,7 @@ class RawMessage(Message):
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(str(self))
cl.send(self)
stats.APRSDStats().msgs_sent_inc()
@ -299,8 +317,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 +380,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 +388,7 @@ class TextMessage(Message):
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(str(self))
cl.send(self)
stats.APRSDStats().msgs_tx_inc()
@ -382,7 +408,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 +417,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 +448,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 +462,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()
@ -470,7 +496,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 +505,7 @@ class AckMessage(Message):
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(str(self))
cl.send(self)
class SendAckThread(threads.APRSDThread):
@ -515,7 +541,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 +550,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

View File

@ -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")
@ -314,3 +314,176 @@ 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="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 = {
"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,

View File

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

View File

@ -4,6 +4,8 @@
#
# pip-compile requirements.in
#
aioax25==0.0.9
# 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