Added webchat command

This patch adds the new aprsd webchat command which shows
a new webpage that allows you to aprsd chat with multiple
callsigns
This commit is contained in:
Hemna 2022-07-20 08:43:57 -04:00
parent 1ccb2f7695
commit 585d55f10d
56 changed files with 1481 additions and 111 deletions

View File

@ -68,7 +68,7 @@ def main():
# The commands themselves live in the cmds directory
from .cmds import ( # noqa
completion, dev, healthcheck, list_plugins, listen, send_message,
server,
server, webchat,
)
cli()

View File

@ -5,6 +5,7 @@ import click
from aprsd import config as aprsd_config
from aprsd.logging import log
from aprsd.utils import trace
F = t.TypeVar("F", bound=t.Callable[..., t.Any])
@ -59,6 +60,8 @@ def process_standard_options(f: F) -> F:
ctx.obj["loglevel"],
ctx.obj["quiet"],
)
if ctx.obj["config"]["aprsd"].get("trace", False):
trace.setup_tracing(["method", "api"])
del kwargs["loglevel"]
del kwargs["config_file"]

View File

@ -52,7 +52,8 @@ class Client:
def reset(self):
"""Call this to force a rebuild/reconnect."""
del self._client
if self._client:
del self._client
@abc.abstractmethod
def setup_connection(self):
@ -130,6 +131,7 @@ class APRSISClient(Client):
backoff = backoff * 2
continue
LOG.debug(f"Logging in to APRS-IS with user '{user}'")
self._client = aprs_client
return aprs_client

View File

@ -1,5 +1,7 @@
import logging
import select
import socket
import threading
import aprslib
from aprslib import is_py3
@ -7,6 +9,7 @@ from aprslib.exceptions import (
ConnectionDrop, ConnectionError, GenericError, LoginError, ParseError,
UnknownFormat,
)
import wrapt
import aprsd
from aprsd import stats
@ -23,11 +26,30 @@ class Aprsdis(aprslib.IS):
# timeout in seconds
select_timeout = 1
lock = threading.Lock()
def stop(self):
self.thread_stop = True
LOG.info("Shutdown Aprsdis client.")
def is_socket_closed(self, sock: socket.socket) -> bool:
try:
# this will try to read bytes without blocking and also without removing them from buffer (peek only)
data = sock.recv(16, socket.MSG_DONTWAIT | socket.MSG_PEEK)
if len(data) == 0:
return True
except BlockingIOError:
return False # socket is open and reading from it would block
except ConnectionResetError:
return True # socket was closed for some other reason
except Exception:
self.logger.exception(
"unexpected exception when checking if a socket is closed",
)
return False
return False
@wrapt.synchronized(lock)
def send(self, msg):
"""Send an APRS Message object."""
line = str(msg)

View File

@ -139,19 +139,24 @@ def listen(
# Creates the client object
LOG.info("Creating client connection")
client.factory.create().client
aprs_client = client.factory.create().client
aprs_client = client.factory.create()
console.log(aprs_client)
LOG.debug(f"Filter by '{filter}'")
aprs_client.set_filter(filter)
aprs_client.client.set_filter(filter)
packets.PacketList(config=config)
keepalive = threads.KeepAliveThread(config=config)
keepalive.start()
while True:
try:
# This will register a packet consumer with aprslib
# When new packets come in the consumer will process
# the packet
with console.status("Listening for packets"):
aprs_client.consumer(rx_packet, raw=False)
# with console.status("Listening for packets"):
aprs_client.client.consumer(rx_packet, raw=False)
except aprslib.exceptions.ConnectionDrop:
LOG.error("Connection dropped, reconnecting")
time.sleep(5)

View File

@ -11,7 +11,6 @@ from aprsd import (
)
from aprsd import aprsd as aprsd_main
from aprsd.aprsd import cli
from aprsd.utils import trace
LOG = logging.getLogger("APRSD")
@ -59,8 +58,6 @@ def server(ctx, flush):
else:
LOG.info(f"{x} = {flat_config[x]}")
if config["aprsd"].get("trace", False):
trace.setup_tracing(["method", "api"])
stats.APRSDStats(config)
# Initialize the client factory and create
@ -98,7 +95,7 @@ def server(ctx, flush):
plugin_manager = plugin.PluginManager(config)
plugin_manager.setup_plugins()
rx_thread = threads.APRSDRXThread(
rx_thread = threads.APRSDPluginRXThread(
msg_queues=threads.msg_queues,
config=config,
)

592
aprsd/cmds/webchat.py Normal file
View File

@ -0,0 +1,592 @@
import datetime
import json
import logging
from logging.handlers import RotatingFileHandler
import queue
import signal
import sys
import threading
import time
import aprslib
import click
import flask
from flask import request
from flask.logging import default_handler
import flask_classful
from flask_httpauth import HTTPBasicAuth
from flask_socketio import Namespace, SocketIO
from werkzeug.security import check_password_hash, generate_password_hash
import wrapt
import aprsd
from aprsd import aprsd as aprsd_main
from aprsd import cli_helper, client
from aprsd import config as aprsd_config
from aprsd import messaging, packets, stats, threads, utils
from aprsd.aprsd import cli
from aprsd.logging import rich as aprsd_logging
from aprsd.threads import aprsd as aprsd_thread
from aprsd.threads import rx
from aprsd.utils import objectstore, trace
LOG = logging.getLogger("APRSD")
auth = HTTPBasicAuth()
users = None
rx_msg_queue = queue.Queue(maxsize=20)
tx_msg_queue = queue.Queue(maxsize=20)
control_queue = queue.Queue(maxsize=20)
msg_queues = {
"rx": rx_msg_queue,
"control": control_queue,
"tx": tx_msg_queue,
}
class SentMessages(objectstore.ObjectStoreMixin):
_instance = None
lock = threading.Lock()
data = {}
def __new__(cls, *args, **kwargs):
"""This magic turns this into a singleton."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@wrapt.synchronized(lock)
def add(self, msg):
self.data[msg.id] = self.create(msg.id)
self.data[msg.id]["from"] = msg.fromcall
self.data[msg.id]["to"] = msg.tocall
self.data[msg.id]["message"] = msg.message.rstrip("\n")
self.data[msg.id]["raw"] = str(msg).rstrip("\n")
def create(self, id):
return {
"id": id,
"ts": time.time(),
"ack": False,
"from": None,
"to": None,
"raw": None,
"message": None,
"status": None,
"last_update": None,
"reply": None,
}
@wrapt.synchronized(lock)
def __len__(self):
return len(self.data.keys())
@wrapt.synchronized(lock)
def get(self, id):
if id in self.data:
return self.data[id]
@wrapt.synchronized(lock)
def get_all(self):
return self.data
@wrapt.synchronized(lock)
def set_status(self, id, status):
self.data[id]["last_update"] = str(datetime.datetime.now())
self.data[id]["status"] = status
@wrapt.synchronized(lock)
def ack(self, id):
"""The message got an ack!"""
self.data[id]["last_update"] = str(datetime.datetime.now())
self.data[id]["ack"] = True
@wrapt.synchronized(lock)
def reply(self, id, packet):
"""We got a packet back from the sent message."""
self.data[id]["reply"] = packet
# HTTPBasicAuth doesn't work on a class method.
# This has to be out here. Rely on the APRSDFlask
# class to initialize the users from the config
@auth.verify_password
def verify_password(username, password):
global users
if username in users and check_password_hash(users.get(username), password):
return username
class WebChatRXThread(rx.APRSDRXThread):
"""Class that connects to aprsis and waits for messages."""
def connected(self, connected=True):
self.connected = connected
def loop(self):
# setup the consumer of messages and block until a messages
msg = None
try:
msg = self.msg_queues["tx"].get_nowait()
except queue.Empty:
pass
try:
if msg:
LOG.debug("GOT msg from TX queue!!")
msg.send()
except (
aprslib.exceptions.ConnectionDrop,
aprslib.exceptions.ConnectionError,
):
LOG.error("Connection dropped, reconnecting")
# Put it back on the queue to send.
self.msg_queues["tx"].put(msg)
# Force the deletion of the client object connected to aprs
# This will cause a reconnect, next time client.get_client()
# is called
self._client.reset()
time.sleep(2)
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
self._client.client.consumer(
self.process_packet, raw=False, blocking=False,
)
except (
aprslib.exceptions.ConnectionDrop,
aprslib.exceptions.ConnectionError,
):
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
self._client.reset()
# Continue to loop
time.sleep(1)
return True
def process_packet(self, *args, **kwargs):
packet = self._client.decode_packet(*args, **kwargs)
LOG.debug(f"GOT Packet {packet}")
self.msg_queues["rx"].put(packet)
class WebChatTXThread(aprsd_thread.APRSDThread):
"""Class that """
def __init__(self, msg_queues, config, socketio):
super().__init__("_TXThread_")
self.msg_queues = msg_queues
self.config = config
self.socketio = socketio
self.connected = False
def loop(self):
try:
msg = self.msg_queues["control"].get_nowait()
self.connected = msg["connected"]
except queue.Empty:
pass
try:
packet = self.msg_queues["rx"].get_nowait()
if packet:
# we got a packet and we need to send it to the
# web socket
self.process_packet(packet)
except queue.Empty:
pass
except Exception as ex:
LOG.exception(ex)
time.sleep(1)
return True
def process_ack_packet(self, packet):
ack_num = packet.get("msgNo")
LOG.info(f"We got ack for our sent message {ack_num}")
messaging.log_packet(packet)
SentMessages().ack(int(ack_num))
self.socketio.emit(
"ack", SentMessages().get(int(ack_num)),
namespace="/sendmsg",
)
stats.APRSDStats().ack_rx_inc()
self.got_ack = True
def process_packet(self, packet):
tocall = packet.get("addresse", None)
fromcall = packet["from"]
msg = packet.get("message_text", None)
msg_id = packet.get("msgNo", "0")
msg_response = packet.get("response", None)
if tocall == self.config["aprs"]["login"] and msg_response == "ack":
self.process_ack_packet(packet)
elif tocall == self.config["aprs"]["login"]:
messaging.log_message(
"Received Message",
packet["raw"],
msg,
fromcall=fromcall,
msg_num=msg_id,
)
# let any threads do their thing, then ack
# send an ack last
ack = messaging.AckMessage(
self.config["aprs"]["login"],
fromcall,
msg_id=msg_id,
)
self.msg_queues["tx"].put(ack)
packets.PacketList().add(packet)
stats.APRSDStats().msgs_rx_inc()
message = packet.get("message_text", None)
msg = {
"id": 0,
"ts": time.time(),
"ack": False,
"from": fromcall,
"to": packet["to"],
"raw": packet["raw"],
"message": message,
"status": None,
"last_update": None,
"reply": None,
}
self.socketio.emit(
"new", msg,
namespace="/sendmsg",
)
class WebChatFlask(flask_classful.FlaskView):
config = None
def set_config(self, config):
global users
self.config = config
self.users = {}
for user in self.config["aprsd"]["web"]["users"]:
self.users[user] = generate_password_hash(
self.config["aprsd"]["web"]["users"][user],
)
users = self.users
@auth.login_required
def index(self):
stats = self._stats()
if self.config["aprs"].get("enabled", True):
transport = "aprs-is"
aprs_connection = (
"APRS-IS Server: <a href='http://status.aprs2.net' >"
"{}</a>".format(stats["stats"]["aprs-is"]["server"])
)
else:
# We might be connected to a KISS socket?
if client.KISSClient.kiss_enabled(self.config):
transport = client.KISSClient.transport(self.config)
if transport == client.TRANSPORT_TCPKISS:
aprs_connection = (
"TCPKISS://{}:{}".format(
self.config["kiss"]["tcp"]["host"],
self.config["kiss"]["tcp"]["port"],
)
)
elif transport == client.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
LOG.debug(f"initial stats = {stats}")
return flask.render_template(
"index.html",
initial_stats=stats,
aprs_connection=aprs_connection,
callsign=self.config["aprs"]["login"],
version=aprsd.__version__,
)
@auth.login_required
def send_message_status(self):
LOG.debug(request)
msgs = SentMessages()
info = msgs.get_all()
return json.dumps(info)
@trace.trace
def _stats(self):
stats_obj = stats.APRSDStats()
now = datetime.datetime.now()
time_format = "%m-%d-%Y %H:%M:%S"
stats_dict = stats_obj.stats()
# Webchat doesnt need these
del stats_dict["aprsd"]["watch_list"]
del stats_dict["aprsd"]["seen_list"]
# del stats_dict["email"]
# del stats_dict["plugins"]
# del stats_dict["messages"]
result = {
"time": now.strftime(time_format),
"stats": stats_dict,
}
return result
def stats(self):
return json.dumps(self._stats())
class SendMessageNamespace(Namespace):
"""Class to handle the socketio interactions."""
_config = None
got_ack = False
reply_sent = False
msg = None
request = None
def __init__(self, namespace=None, config=None, msg_queues=None):
self._config = config
self._msg_queues = msg_queues
super().__init__(namespace)
def on_connect(self):
global socketio
LOG.debug("Web socket connected")
socketio.emit(
"connected", {"data": "/sendmsg Connected"},
namespace="/sendmsg",
)
msg = {"connected": True}
self._msg_queues["control"].put(msg)
def on_disconnect(self):
LOG.debug("WS Disconnected")
msg = {"connected": False}
self._msg_queues["control"].put(msg)
def on_send(self, data):
global socketio
LOG.debug(f"WS: on_send {data}")
self.request = data
data["from"] = self._config["aprs"]["login"]
msg = messaging.TextMessage(
data["from"],
data["to"],
data["message"],
)
self.msg = msg
msgs = SentMessages()
msgs.add(msg)
msgs.set_status(msg.id, "Sending")
socketio.emit(
"sent", SentMessages().get(self.msg.id),
namespace="/sendmsg",
)
self._msg_queues["tx"].put(msg)
def handle_message(self, data):
LOG.debug(f"WS Data {data}")
def handle_json(self, data):
LOG.debug(f"WS json {data}")
def setup_logging(config, flask_app, loglevel, quiet):
flask_log = logging.getLogger("werkzeug")
flask_app.logger.removeHandler(default_handler)
flask_log.removeHandler(default_handler)
log_level = aprsd_config.LOG_LEVELS[loglevel]
flask_log.setLevel(log_level)
date_format = config["aprsd"].get(
"dateformat",
aprsd_config.DEFAULT_DATE_FORMAT,
)
if not config["aprsd"]["web"].get("logging_enabled", False):
# disable web logging
flask_log.disabled = True
flask_app.logger.disabled = True
# return
if config["aprsd"].get("rich_logging", False) and not quiet:
log_format = "%(message)s"
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
rh = aprsd_logging.APRSDRichHandler(
show_thread=True, thread_width=15,
rich_tracebacks=True, omit_repeated_times=False,
)
rh.setFormatter(log_formatter)
flask_log.addHandler(rh)
log_file = config["aprsd"].get("logfile", None)
if log_file:
log_format = config["aprsd"].get(
"logformat",
aprsd_config.DEFAULT_LOG_FORMAT,
)
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
fh = RotatingFileHandler(
log_file, maxBytes=(10248576 * 5),
backupCount=4,
)
fh.setFormatter(log_formatter)
flask_log.addHandler(fh)
@trace.trace
def init_flask(config, loglevel, quiet):
global socketio
flask_app = flask.Flask(
"aprsd",
static_url_path="/static",
static_folder="web/chat/static",
template_folder="web/chat/templates",
)
setup_logging(config, flask_app, loglevel, quiet)
server = WebChatFlask()
server.set_config(config)
flask_app.route("/", methods=["GET"])(server.index)
flask_app.route("/stats", methods=["GET"])(server.stats)
# flask_app.route("/send-message", methods=["GET"])(server.send_message)
flask_app.route("/send-message-status", methods=["GET"])(server.send_message_status)
socketio = SocketIO(
flask_app, logger=False, engineio_logger=False,
async_mode="threading",
)
# async_mode="gevent",
# async_mode="eventlet",
# import eventlet
# eventlet.monkey_patch()
socketio.on_namespace(
SendMessageNamespace(
"/sendmsg", config=config,
msg_queues=msg_queues,
),
)
return socketio, flask_app
# main() ###
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.option(
"-f",
"--flush",
"flush",
is_flag=True,
show_default=True,
default=False,
help="Flush out all old aged messages on disk.",
)
@click.option(
"-p",
"--port",
"port",
show_default=True,
default=80,
help="Port to listen to web requests",
)
@click.pass_context
@cli_helper.process_standard_options
def webchat(ctx, flush, port):
"""Web based HAM Radio chat program!"""
ctx.obj["config_file"]
loglevel = ctx.obj["loglevel"]
quiet = ctx.obj["quiet"]
config = ctx.obj["config"]
signal.signal(signal.SIGINT, aprsd_main.signal_handler)
signal.signal(signal.SIGTERM, aprsd_main.signal_handler)
if not quiet:
click.echo("Load config")
level, msg = utils._check_version()
if level:
LOG.warning(msg)
else:
LOG.info(msg)
LOG.info(f"APRSD Started version: {aprsd.__version__}")
flat_config = utils.flatten_dict(config)
LOG.info("Using CONFIG values:")
for x in flat_config:
if "password" in x or "aprsd.web.users.admin" in x:
LOG.info(f"{x} = XXXXXXXXXXXXXXXXXXX")
else:
LOG.info(f"{x} = {flat_config[x]}")
stats.APRSDStats(config)
# Initialize the client factory and create
# The correct client object ready for use
client.ClientFactory.setup(config)
# Make sure we have 1 client transport enabled
if not client.factory.is_client_enabled():
LOG.error("No Clients are enabled in config.")
sys.exit(-1)
if not client.factory.is_client_configured():
LOG.error("APRS client is not properly configured in config file.")
sys.exit(-1)
packets.PacketList(config=config)
messaging.MsgTrack(config=config)
packets.WatchList(config=config)
packets.SeenList(config=config)
aprsd_main.flask_enabled = True
(socketio, app) = init_flask(config, loglevel, quiet)
rx_thread = WebChatRXThread(
msg_queues=msg_queues,
config=config,
)
LOG.warning("Start RX Thread")
rx_thread.start()
tx_thread = WebChatTXThread(
msg_queues=msg_queues,
config=config,
socketio=socketio,
)
LOG.warning("Start TX Thread")
tx_thread.start()
keepalive = threads.KeepAliveThread(config=config)
LOG.warning("Start KeepAliveThread")
keepalive.start()
LOG.warning("Start socketio.run()")
socketio.run(
app,
host=config["aprsd"]["web"]["host"],
port=port,
)

View File

@ -601,8 +601,8 @@ def init_flask(config, loglevel, quiet):
flask_app = flask.Flask(
"aprsd",
static_url_path="/static",
static_folder="web/static",
template_folder="web/templates",
static_folder="web/admin/static",
template_folder="web/admin/templates",
)
setup_logging(config, flask_app, loglevel, quiet)
server = APRSDFlask()

View File

@ -6,7 +6,8 @@ import re
import threading
import time
from aprsd import client, objectstore, packets, stats, threads
from aprsd import client, packets, stats, threads
from aprsd.utils import objectstore
LOG = logging.getLogger("APRSD")
@ -238,7 +239,10 @@ class RawMessage(Message):
last_send_age = last_send_time = None
def __init__(self, message, allow_delay=True):
super().__init__(fromcall=None, tocall=None, msg_id=None, allow_delay=allow_delay)
super().__init__(
fromcall=None, tocall=None, msg_id=None,
allow_delay=allow_delay,
)
self._raw_message = message
def dict(self):
@ -282,12 +286,8 @@ class TextMessage(Message):
last_send_time = last_send_age = None
def __init__(
self,
fromcall,
tocall,
message,
msg_id=None,
allow_delay=True,
self, fromcall, tocall, message,
msg_id=None, allow_delay=True,
):
super().__init__(
fromcall=fromcall, tocall=tocall,

View File

@ -3,7 +3,8 @@ import logging
import threading
import time
from aprsd import objectstore, utils
from aprsd import utils
from aprsd.utils import objectstore
LOG = logging.getLogger("APRSD")
@ -77,9 +78,10 @@ class WatchList(objectstore.ObjectStoreMixin):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.lock = threading.Lock()
cls._instance.config = kwargs["config"]
if "config" in kwargs:
cls._instance.config = kwargs["config"]
cls._instance._init_store()
cls._instance.data = {}
cls._instance._init_store()
return cls._instance
def __init__(self, config=None):
@ -165,9 +167,10 @@ class SeenList(objectstore.ObjectStoreMixin):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.lock = threading.Lock()
cls._instance.config = kwargs["config"]
if "config" in kwargs:
cls._instance.config = kwargs["config"]
cls._instance._init_store()
cls._instance.data = {}
cls._instance._init_store()
return cls._instance
def update_seen(self, packet):

View File

@ -492,4 +492,5 @@ class PluginManager:
self._pluggy_pm.register(obj)
def get_plugins(self):
return self._pluggy_pm.get_plugins()
if self._pluggy_pm:
return self._pluggy_pm.get_plugins()

View File

@ -2,6 +2,8 @@ import datetime
import logging
import threading
import wrapt
import aprsd
from aprsd import packets, plugin, utils
@ -12,7 +14,7 @@ LOG = logging.getLogger("APRSD")
class APRSDStats:
_instance = None
lock = None
lock = threading.Lock()
config = None
start_time = None
@ -39,7 +41,6 @@ class APRSDStats:
if cls._instance is None:
cls._instance = super().__new__(cls)
# any initializetion here
cls._instance.lock = threading.Lock()
cls._instance.start_time = datetime.datetime.now()
cls._instance._aprsis_keepalive = datetime.datetime.now()
return cls._instance
@ -48,128 +49,129 @@ class APRSDStats:
if config:
self.config = config
@wrapt.synchronized(lock)
@property
def uptime(self):
with self.lock:
return datetime.datetime.now() - self.start_time
return datetime.datetime.now() - self.start_time
@wrapt.synchronized(lock)
@property
def memory(self):
with self.lock:
return self._mem_current
return self._mem_current
@wrapt.synchronized(lock)
def set_memory(self, memory):
with self.lock:
self._mem_current = memory
self._mem_current = memory
@wrapt.synchronized(lock)
@property
def memory_peak(self):
with self.lock:
return self._mem_peak
return self._mem_peak
@wrapt.synchronized(lock)
def set_memory_peak(self, memory):
with self.lock:
self._mem_peak = memory
self._mem_peak = memory
@wrapt.synchronized(lock)
@property
def aprsis_server(self):
with self.lock:
return self._aprsis_server
return self._aprsis_server
@wrapt.synchronized(lock)
def set_aprsis_server(self, server):
with self.lock:
self._aprsis_server = server
self._aprsis_server = server
@wrapt.synchronized(lock)
@property
def aprsis_keepalive(self):
with self.lock:
return self._aprsis_keepalive
return self._aprsis_keepalive
@wrapt.synchronized(lock)
def set_aprsis_keepalive(self):
with self.lock:
self._aprsis_keepalive = datetime.datetime.now()
self._aprsis_keepalive = datetime.datetime.now()
@wrapt.synchronized(lock)
@property
def msgs_tx(self):
with self.lock:
return self._msgs_tx
return self._msgs_tx
@wrapt.synchronized(lock)
def msgs_tx_inc(self):
with self.lock:
self._msgs_tx += 1
self._msgs_tx += 1
@wrapt.synchronized(lock)
@property
def msgs_rx(self):
with self.lock:
return self._msgs_rx
return self._msgs_rx
@wrapt.synchronized(lock)
def msgs_rx_inc(self):
with self.lock:
self._msgs_rx += 1
self._msgs_rx += 1
@wrapt.synchronized(lock)
@property
def msgs_mice_rx(self):
with self.lock:
return self._msgs_mice_rx
return self._msgs_mice_rx
@wrapt.synchronized(lock)
def msgs_mice_inc(self):
with self.lock:
self._msgs_mice_rx += 1
self._msgs_mice_rx += 1
@wrapt.synchronized(lock)
@property
def ack_tx(self):
with self.lock:
return self._ack_tx
return self._ack_tx
@wrapt.synchronized(lock)
def ack_tx_inc(self):
with self.lock:
self._ack_tx += 1
self._ack_tx += 1
@wrapt.synchronized(lock)
@property
def ack_rx(self):
with self.lock:
return self._ack_rx
return self._ack_rx
@wrapt.synchronized(lock)
def ack_rx_inc(self):
with self.lock:
self._ack_rx += 1
self._ack_rx += 1
@wrapt.synchronized(lock)
@property
def msgs_tracked(self):
with self.lock:
return self._msgs_tracked
return self._msgs_tracked
@wrapt.synchronized(lock)
def msgs_tracked_inc(self):
with self.lock:
self._msgs_tracked += 1
self._msgs_tracked += 1
@wrapt.synchronized(lock)
@property
def email_tx(self):
with self.lock:
return self._email_tx
return self._email_tx
@wrapt.synchronized(lock)
def email_tx_inc(self):
with self.lock:
self._email_tx += 1
self._email_tx += 1
@wrapt.synchronized(lock)
@property
def email_rx(self):
with self.lock:
return self._email_rx
return self._email_rx
@wrapt.synchronized(lock)
def email_rx_inc(self):
with self.lock:
self._email_rx += 1
self._email_rx += 1
@wrapt.synchronized(lock)
@property
def email_thread_time(self):
with self.lock:
return self._email_thread_last_time
return self._email_thread_last_time
@wrapt.synchronized(lock)
def email_thread_update(self):
with self.lock:
self._email_thread_last_time = datetime.datetime.now()
self._email_thread_last_time = datetime.datetime.now()
@wrapt.synchronized(lock)
def stats(self):
now = datetime.datetime.now()
if self._email_thread_last_time:
@ -185,20 +187,20 @@ class APRSDStats:
pm = plugin.PluginManager()
plugins = pm.get_plugins()
plugin_stats = {}
if plugins:
def full_name_with_qualname(obj):
return "{}.{}".format(
obj.__class__.__module__,
obj.__class__.__qualname__,
)
def full_name_with_qualname(obj):
return "{}.{}".format(
obj.__class__.__module__,
obj.__class__.__qualname__,
)
for p in plugins:
plugin_stats[full_name_with_qualname(p)] = {
"enabled": p.enabled,
"rx": p.rx_count,
"tx": p.tx_count,
"version": p.version,
}
for p in plugins:
plugin_stats[full_name_with_qualname(p)] = {
"enabled": p.enabled,
"rx": p.rx_count,
"tx": p.tx_count,
"version": p.version,
}
wl = packets.WatchList()
sl = packets.SeenList()
@ -207,30 +209,30 @@ class APRSDStats:
"aprsd": {
"version": aprsd.__version__,
"uptime": utils.strfdelta(self.uptime),
"memory_current": self.memory,
"memory_current": int(self.memory),
"memory_current_str": utils.human_size(self.memory),
"memory_peak": self.memory_peak,
"memory_peak": int(self.memory_peak),
"memory_peak_str": utils.human_size(self.memory_peak),
"watch_list": wl.get_all(),
"seen_list": sl.get_all(),
},
"aprs-is": {
"server": self.aprsis_server,
"server": str(self.aprsis_server),
"callsign": self.config["aprs"]["login"],
"last_update": last_aprsis_keepalive,
},
"messages": {
"tracked": self.msgs_tracked,
"sent": self.msgs_tx,
"recieved": self.msgs_rx,
"ack_sent": self.ack_tx,
"ack_recieved": self.ack_rx,
"mic-e recieved": self.msgs_mice_rx,
"tracked": int(self.msgs_tracked),
"sent": int(self.msgs_tx),
"recieved": int(self.msgs_rx),
"ack_sent": int(self.ack_tx),
"ack_recieved": int(self.ack_rx),
"mic-e recieved": int(self.msgs_mice_rx),
},
"email": {
"enabled": self.config["aprsd"]["email"]["enabled"],
"sent": self._email_tx,
"recieved": self._email_rx,
"sent": int(self._email_tx),
"recieved": int(self._email_rx),
"thread_last_update": last_update,
},
"plugins": plugin_stats,

View File

@ -73,7 +73,7 @@ class KeepAliveThread(APRSDThread):
# We haven't gotten a keepalive from aprs-is in a while
# reset the connection.a
if not client.KISSClient.is_enabled(self.config):
LOG.warning("Resetting connection to APRS-IS.")
LOG.warning(f"Resetting connection to APRS-IS {delta}")
client.factory.create().reset()
# Check version every hour

View File

@ -1,3 +1,4 @@
import abc
import logging
import time
@ -38,7 +39,10 @@ class APRSDRXThread(APRSDThread):
self.process_packet, raw=False, blocking=False,
)
except aprslib.exceptions.ConnectionDrop:
except (
aprslib.exceptions.ConnectionDrop,
aprslib.exceptions.ConnectionError,
):
LOG.error("Connection dropped, reconnecting")
time.sleep(5)
# Force the deletion of the client object connected to aprs
@ -48,6 +52,12 @@ class APRSDRXThread(APRSDThread):
# Continue to loop
return True
@abc.abstractmethod
def process_packet(self, *args, **kwargs):
pass
class APRSDPluginRXThread(APRSDRXThread):
def process_packet(self, *args, **kwargs):
packet = self._client.decode_packet(*args, **kwargs)
thread = APRSDProcessPacketThread(packet=packet, config=self.config)

0
aprsd/web/__init__.py Normal file
View File

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -0,0 +1,94 @@
body {
background: #eeeeee;
margin: 2em;
text-align: center;
font-family: system-ui, sans-serif;
}
footer {
padding: 2em;
text-align: center;
height: 10vh;
}
.ui.segment {
background: #eeeeee;
}
ul.list {
list-style-type: disc;
}
ul.list li {
list-style-position: outside;
}
#left {
margin-right: 2px;
height: 300px;
}
#right {
height: 300px;
}
#center {
height: 300px;
}
#title {
font-size: 4em;
}
#version{
font-size: .5em;
}
#uptime, #aprsis {
font-size: 1em;
}
#callsign {
font-size: 1.4em;
color: #00F;
padding-top: 8px;
margin:10px;
}
#title_rx {
background-color: darkseagreen;
text-align: left;
}
#title_tx {
background-color: lightcoral;
text-align: left;
}
.aprsd_1 {
background-image: url(/static/images/aprs-symbols-16-0.png);
background-repeat: no-repeat;
background-position: -160px -48px;
width: 16px;
height: 16px;
}
#msgsTabsDiv .ui.tab {
margin:0px;
padding:0px;
display: block;
}
#msgsTabsDiv .header, .tiny.text, .content, .break,
.thumbs.down.outline.icon,
.phone.volume.icon
{
display: inline-block;
float: left;
position: relative;
}
#msgsTabsDiv .tiny.text {
width:100px;
}
#msgsTabsDiv .tiny.header {
width:100px;
text-align: left;
}
#msgsTabsDiv .break {
margin: 2px;
text-align: left;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,41 @@
* {box-sizing: border-box}
/* Style the tab */
.tab {
border: 1px solid #ccc;
background-color: #f1f1f1;
height: 450px;
}
/* Style the buttons inside the tab */
.tab div {
display: block;
background-color: inherit;
color: black;
padding: 10px;
width: 100%;
border: none;
outline: none;
text-align: left;
cursor: pointer;
transition: 0.3s;
font-size: 17px;
}
/* Change background color of buttons on hover */
.tab div:hover {
background-color: #ddd;
}
/* Create an active/current "tab button" class */
.tab div.active {
background-color: #ccc;
}
/* Style the tab content */
.tabcontent {
border: 1px solid #ccc;
height: 450px;
overflow-y: scroll;
background-color: white;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -0,0 +1,44 @@
function aprs_img(item, x_offset, y_offset) {
var x = x_offset * -16;
if (y_offset > 5) {
y_offset = 5;
}
var y = y_offset * -16;
var loc = x + 'px '+ y + 'px'
item.css('background-position', loc);
}
function show_aprs_icon(item, symbol) {
var offset = ord(symbol) - 33;
var col = Math.floor(offset / 16);
var row = offset % 16;
//console.log("'" + symbol+"' off: "+offset+" row: "+ row + " col: " + col)
aprs_img(item, row, col);
}
function ord(str){return str.charCodeAt(0);}
function update_stats( data ) {
$("#version").text( data["stats"]["aprsd"]["version"] );
$("#aprs_connection").html( data["aprs_connection"] );
$("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] );
short_time = data["time"].split(/\s(.+)/)[1];
}
function start_update() {
(function statsworker() {
$.ajax({
url: "/stats",
type: 'GET',
dataType: 'json',
success: function(data) {
update_stats(data);
},
complete: function() {
setTimeout(statsworker, 10000);
}
});
})();
}

View File

@ -0,0 +1,215 @@
var cleared = false;
var callsign_list = {};
var message_list = {};
function size_dict(d){c=0; for (i in d) ++c; return c}
function init_chat() {
const socket = io("/sendmsg");
socket.on('connect', function () {
console.log("Connected to socketio");
});
socket.on('connected', function(msg) {
console.log("Connected!");
console.log(msg);
});
socket.on("sent", function(msg) {
if (cleared == false) {
var msgsdiv = $("#msgsTabsDiv");
msgsdiv.html('')
cleared = true
}
sent_msg(msg);
});
socket.on("ack", function(msg) {
update_msg(msg);
});
socket.on("new", function(msg) {
if (cleared == false) {
var msgsdiv = $("#msgsTabsDiv");
msgsdiv.html('')
cleared = true
}
from_msg(msg);
});
$("#sendform").submit(function(event) {
event.preventDefault();
msg = {'to': $('#to_call').val(),
'message': $('#message').val(),
}
socket.emit("send", msg);
$('#message').val('');
});
}
function add_callsign(callsign) {
/* Ensure a callsign exists in the left hand nav */
if (callsign in callsign_list) {
return false
}
var callsignTabs = $("#callsignTabs");
tab_name = tab_string(callsign);
tab_content = tab_content_name(callsign);
divname = content_divname(callsign);
item_html = '<div class="tablinks" id="'+tab_name+'" onclick="openCallsign(event, \''+callsign+'\');">'+callsign+'</div>';
callsignTabs.append(item_html);
callsign_list[callsign] = true;
return true
}
function append_message(callsign, msg, msg_html) {
new_callsign = false
if (!message_list.hasOwnProperty(callsign)) {
message_list[callsign] = new Array();
}
message_list[callsign].push(msg);
// Find the right div to place the html
new_callsign = add_callsign(callsign);
append_message_html(callsign, msg_html, new_callsign);
if (new_callsign) {
//click on the new tab
click_div = '#'+tab_string(callsign);
$(click_div).click();
}
}
function tab_string(callsign) {
return "msgs"+callsign;
}
function tab_content_name(callsign) {
return tab_string(callsign)+"Content";
}
function content_divname(callsign) {
return "#"+tab_content_name(callsign);
}
function append_message_html(callsign, msg_html, new_callsign) {
var msgsTabs = $('#msgsTabsDiv');
divname_str = tab_content_name(callsign);
divname = content_divname(callsign);
if (new_callsign) {
// we have to add a new DIV
msg_div_html = '<div class="tabcontent" id="'+divname_str+'" style="height:450px;">'+msg_html+'</div>';
msgsTabs.append(msg_div_html);
} else {
var msgDiv = $(divname);
msgDiv.append(msg_html);
}
$(divname).animate({scrollTop: $(divname)[0].scrollHeight}, "slow");
}
function create_message_html(time, from, to, message, ack) {
msg_html = '<div class="item">';
msg_html += '<div class="tiny text">'+time+'</div>';
msg_html += '<div class="middle aligned content">';
msg_html += '<div class="tiny red header">'+from+'</div>';
if (ack) {
msg_html += '<i class="thumbs down outline icon" id="' + ack_id + '" data-content="Waiting for ACK"></i>';
} else {
msg_html += '<i class="phone volume icon" data-content="Recieved Message"></i>';
}
msg_html += '<div class="middle aligned content">>&nbsp;&nbsp;&nbsp;</div>';
msg_html += '</div>';
msg_html += '<div class="middle aligned content">'+message+'</div>';
msg_html += '</div><br>';
return msg_html
}
function sent_msg(msg) {
var msgsdiv = $("#sendMsgsDiv");
ts_str = msg["ts"].toString();
ts = ts_str.split(".")[0]*1000;
id = ts_str.split('.')[0]
ack_id = "ack_" + id
var d = new Date(ts).toLocaleDateString("en-US")
var t = new Date(ts).toLocaleTimeString("en-US")
msg_html = create_message_html(t, msg['from'], msg['to'], msg['message'], ack_id);
append_message(msg['to'], msg, msg_html);
}
function from_msg(msg) {
var msgsdiv = $("#sendMsgsDiv");
// We have an existing entry
ts_str = msg["ts"].toString();
ts = ts_str.split(".")[0]*1000;
id = ts_str.split('.')[0]
ack_id = "ack_" + id
var d = new Date(ts).toLocaleDateString("en-US")
var t = new Date(ts).toLocaleTimeString("en-US")
from = msg['from']
msg_html = create_message_html(t, from, false, msg['message'], false);
append_message(from, msg, msg_html);
}
function update_msg(msg) {
var msgsdiv = $("#sendMsgsDiv");
// We have an existing entry
ts_str = msg["ts"].toString();
id = ts_str.split('.')[0]
pretty_id = "pretty_" + id
loader_id = "loader_" + id
ack_id = "ack_" + id
span_id = "span_" + id
if (msg['ack'] == true) {
var loader_div = $('#' + loader_id);
var ack_div = $('#' + ack_id);
loader_div.removeClass('ui active inline loader');
loader_div.addClass('ui disabled loader');
ack_div.removeClass('thumbs up outline icon');
ack_div.addClass('thumbs up outline icon');
}
$('.ui.accordion').accordion('refresh');
}
function callsign_select(callsign) {
var tocall = $("#to_call");
tocall.val(callsign);
}
function reset_Tabs() {
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
}
function openCallsign(evt, callsign) {
var i, tabcontent, tablinks;
tab_content = tab_content_name(callsign);
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
document.getElementById(tab_content).style.display = "block";
evt.target.className += " active";
callsign_select(callsign);
}

View File

@ -0,0 +1,28 @@
function openTab(evt, tabName) {
// Declare all variables
var i, tabcontent, tablinks;
if (typeof tabName == 'undefined') {
return
}
// Get all elements with class="tabcontent" and hide them
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
// Get all elements with class="tablinks" and remove the class "active"
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
// Show the current tab, and add an "active" class to the button that opened the tab
document.getElementById(tabName).style.display = "block";
if (typeof evt.currentTarget == 'undefined') {
return
} else {
evt.currentTarget.className += " active";
}
}

View File

@ -0,0 +1,57 @@
/* Root element */
.json-document {
padding: 1em 2em;
}
/* Syntax highlighting for JSON objects */
ul.json-dict, ol.json-array {
list-style-type: none;
margin: 0 0 0 1px;
border-left: 1px dotted #ccc;
padding-left: 2em;
}
.json-string {
color: #0B7500;
}
.json-literal {
color: #1A01CC;
font-weight: bold;
}
/* Toggle button */
a.json-toggle {
position: relative;
color: inherit;
text-decoration: none;
}
a.json-toggle:focus {
outline: none;
}
a.json-toggle:before {
font-size: 1.1em;
color: #c0c0c0;
content: "\25BC"; /* down arrow */
position: absolute;
display: inline-block;
width: 1em;
text-align: center;
line-height: 1em;
left: -1.2em;
}
a.json-toggle:hover:before {
color: #aaa;
}
a.json-toggle.collapsed:before {
/* Use rotated down arrow, prevents right arrow appearing smaller than down arrow in some browsers */
transform: rotate(-90deg);
}
/* Collapsable placeholder links */
a.json-placeholder {
color: #aaa;
padding: 0 1em;
text-decoration: none;
}
a.json-placeholder:hover {
text-decoration: underline;
}

View File

@ -0,0 +1,158 @@
/**
* jQuery json-viewer
* @author: Alexandre Bodelot <alexandre.bodelot@gmail.com>
* @link: https://github.com/abodelot/jquery.json-viewer
*/
(function($) {
/**
* Check if arg is either an array with at least 1 element, or a dict with at least 1 key
* @return boolean
*/
function isCollapsable(arg) {
return arg instanceof Object && Object.keys(arg).length > 0;
}
/**
* Check if a string represents a valid url
* @return boolean
*/
function isUrl(string) {
var urlRegexp = /^(https?:\/\/|ftps?:\/\/)?([a-z0-9%-]+\.){1,}([a-z0-9-]+)?(:(\d{1,5}))?(\/([a-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+)?)?$/i;
return urlRegexp.test(string);
}
/**
* Transform a json object into html representation
* @return string
*/
function json2html(json, options) {
var html = '';
if (typeof json === 'string') {
// Escape tags and quotes
json = json
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;');
if (options.withLinks && isUrl(json)) {
html += '<a href="' + json + '" class="json-string" target="_blank">' + json + '</a>';
} else {
// Escape double quotes in the rendered non-URL string.
json = json.replace(/&quot;/g, '\\&quot;');
html += '<span class="json-string">"' + json + '"</span>';
}
} else if (typeof json === 'number') {
html += '<span class="json-literal">' + json + '</span>';
} else if (typeof json === 'boolean') {
html += '<span class="json-literal">' + json + '</span>';
} else if (json === null) {
html += '<span class="json-literal">null</span>';
} else if (json instanceof Array) {
if (json.length > 0) {
html += '[<ol class="json-array">';
for (var i = 0; i < json.length; ++i) {
html += '<li>';
// Add toggle button if item is collapsable
if (isCollapsable(json[i])) {
html += '<a href class="json-toggle"></a>';
}
html += json2html(json[i], options);
// Add comma if item is not last
if (i < json.length - 1) {
html += ',';
}
html += '</li>';
}
html += '</ol>]';
} else {
html += '[]';
}
} else if (typeof json === 'object') {
var keyCount = Object.keys(json).length;
if (keyCount > 0) {
html += '{<ul class="json-dict">';
for (var key in json) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
html += '<li>';
var keyRepr = options.withQuotes ?
'<span class="json-string">"' + key + '"</span>' : key;
// Add toggle button if item is collapsable
if (isCollapsable(json[key])) {
html += '<a href class="json-toggle">' + keyRepr + '</a>';
} else {
html += keyRepr;
}
html += ': ' + json2html(json[key], options);
// Add comma if item is not last
if (--keyCount > 0) {
html += ',';
}
html += '</li>';
}
}
html += '</ul>}';
} else {
html += '{}';
}
}
return html;
}
/**
* jQuery plugin method
* @param json: a javascript object
* @param options: an optional options hash
*/
$.fn.jsonViewer = function(json, options) {
// Merge user options with default options
options = Object.assign({}, {
collapsed: false,
rootCollapsable: true,
withQuotes: false,
withLinks: true
}, options);
// jQuery chaining
return this.each(function() {
// Transform to HTML
var html = json2html(json, options);
if (options.rootCollapsable && isCollapsable(json)) {
html = '<a href class="json-toggle"></a>' + html;
}
// Insert HTML in target DOM element
$(this).html(html);
$(this).addClass('json-document');
// Bind click on toggle buttons
$(this).off('click');
$(this).on('click', 'a.json-toggle', function() {
var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array');
target.toggle();
if (target.is(':visible')) {
target.siblings('.json-placeholder').remove();
} else {
var count = target.children('li').length;
var placeholder = count + (count > 1 ? ' items' : ' item');
target.after('<a href class="json-placeholder">' + placeholder + '</a>');
}
return false;
});
// Simulate click on toggle button when placeholder is clicked
$(this).on('click', 'a.json-placeholder', function() {
$(this).siblings('a.json-toggle').click();
return false;
});
if (options.collapsed == true) {
// Trigger click to collapse all nodes
$(this).find('a.json-toggle').click();
}
});
};
})(jQuery);

View File

@ -0,0 +1,86 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="https://cdn.socket.io/4.1.2/socket.io.min.js" integrity="sha384-toS6mmwu70G0fw54EGlWWeA4z3dyJ+dlXBtSURSKN4vyRFOcxd3Bzjj/AoOwY+Rg" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css">
<script src="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.js"></script>
<link rel="stylesheet" href="/static/css/index.css">
<link rel="stylesheet" href="/static/css/tabs.css">
<script src="/static/js/main.js"></script>
<script src="/static/js/send-message.js"></script>
<script type="text/javascript">
var initial_stats = {{ initial_stats|tojson|safe }};
var memory_chart = null
var message_chart = null
$(document).ready(function() {
console.log(initial_stats);
start_update();
init_chat();
reset_Tabs();
});
</script>
</head>
<body>
<div class='ui text container'>
<h1 class='ui dividing header'>APRSD WebChat {{ version }}</h1>
</div>
<div class='ui grid text container'>
<div class='left floated ten wide column'>
<span style='color: green'>{{ callsign }}</span>
connected to
<span style='color: blue' id='aprs_connection'>{{ aprs_connection|safe }}</span>
</div>
<div class='right floated four wide column'>
<span id='uptime'>NONE</span>
</div>
</div>
<div class="ui container">
<h3 class="ui dividing header">Send Message</h3>
<div id="sendMsgDiv" class="ui mini text">
<form id="sendform" name="sendmsg" action="">
<div class="ui corner labeled input">
<label for="to_call" class="ui label">Callsign</label>
<input type="text" name="to_call" id="to_call" placeholder="To Callsign" size="11" maxlength="9">
<div class="ui corner label">
<i class="asterisk icon"></i>
</div>
</div>
<div class="ui labeled input">
<label for="message" class="ui label">Message</label>
<input type="text" name="message" id="message" size="40" maxlength="40">
</div>
<input type="submit" name="submit" class="ui button" id="send_msg" value="Send" />
</form>
</div>
</div>
<div class="ui grid">
<div class="three wide column">
<div class="tab" id="callsignTabs">
</div>
</div>
<div class="ten wide column ui raised segment" id="msgsTabsDiv" style="height:450px;padding:0px;">
&nbsp;
</div>
</div>
</div>
<div class="ui text container" style="padding-top: 40px">
<a href="https://badge.fury.io/py/aprsd"><img src="https://badge.fury.io/py/aprsd.svg" alt="PyPI version" height="18"></a>
<a href="https://github.com/craigerl/aprsd"><img src="https://img.shields.io/badge/Made%20with-Python-1f425f.svg" height="18"></a>
</div>
</body>
</html>

View File

@ -24,3 +24,4 @@ tabulate
rich
# For the list-plugins pypi.org search scraping
beautifulsoup4
wrapt

View File

@ -128,3 +128,5 @@ weakrefmethod==1.0.3
# via signalslot
werkzeug==2.0.0
# via flask
wrapt==1.14.1
# via -r requirements.in

View File

@ -15,7 +15,10 @@ F = t.TypeVar("F", bound=t.Callable[..., t.Any])
class TestDevTestPluginCommand(unittest.TestCase):
def _build_config(self, login=None, password=None):
config = {"aprs": {}}
config = {
"aprs": {},
"aprsd": {"trace": False},
}
if login:
config["aprs"]["login"] = login

View File

@ -15,7 +15,10 @@ F = t.TypeVar("F", bound=t.Callable[..., t.Any])
class TestSendMessageCommand(unittest.TestCase):
def _build_config(self, login=None, password=None):
config = {"aprs": {}}
config = {
"aprs": {},
"aprsd": {"trace": False},
}
if login:
config["aprs"]["login"] = login