diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 02b93db..0dd8f68 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: ["3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/Makefile b/Makefile index ecaa497..e513173 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ docs: build cp Changelog docs/changelog.rst tox -edocs -clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts +clean: clean-build clean-pyc clean-test clean-dev ## remove all build, test, coverage and Python artifacts clean-build: ## remove build artifacts rm -fr build/ @@ -46,6 +46,9 @@ clean-test: ## remove test and coverage artifacts rm -fr htmlcov/ rm -fr .pytest_cache +clean-dev: + rm -rf $(VENVDIR) + test: dev ## Run all the tox tests tox -p all @@ -73,5 +76,9 @@ docker-dev: test ## Make a development docker container tagged with hemna6969/a docker build -t hemna6969/aprsd:master -f docker/Dockerfile-dev docker update-requirements: dev ## Update the requirements.txt and dev-requirements.txt files + rm requirements.txt + rm dev-requirements.txt + touch requirements.txt + touch dev-requirements.txt $(VENV)/pip-compile requirements.in $(VENV)/pip-compile dev-requirements.in diff --git a/aprsd/aprsd.py b/aprsd/aprsd.py index 5d5587c..7a0afb5 100644 --- a/aprsd/aprsd.py +++ b/aprsd/aprsd.py @@ -68,9 +68,9 @@ 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() + cli(auto_envvar_prefix="APRSD") def signal_handler(sig, frame): diff --git a/aprsd/cli_helper.py b/aprsd/cli_helper.py index a52f260..c70f22c 100644 --- a/aprsd/cli_helper.py +++ b/aprsd/cli_helper.py @@ -4,7 +4,8 @@ import typing as t import click from aprsd import config as aprsd_config -from aprsd import log +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"] diff --git a/aprsd/client.py b/aprsd/client.py index 06dc1cd..89f259b 100644 --- a/aprsd/client.py +++ b/aprsd/client.py @@ -6,8 +6,9 @@ import aprslib from aprslib.exceptions import LoginError from aprsd import config as aprsd_config -from aprsd import exception, trace +from aprsd import exception from aprsd.clients import aprsis, kiss +from aprsd.utils import trace LOG = logging.getLogger("APRSD") @@ -51,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): @@ -129,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 @@ -153,8 +156,8 @@ class KISSClient(Client): # Ensure that the config vars are correctly set if KISSClient.is_enabled(config): config.check_option( - "kiss.callsign", - default_fail=aprsd_config.DEFAULT_CONFIG_DICT["kiss"]["callsign"], + "aprsd.callsign", + default_fail=aprsd_config.DEFAULT_CONFIG_DICT["aprsd"]["callsign"], ) transport = KISSClient.transport(config) if transport == TRANSPORT_SERIALKISS: @@ -192,8 +195,8 @@ class KISSClient(Client): @trace.trace def setup_connection(self): - ax25client = kiss.Aioax25Client(self.config) - return ax25client + client = kiss.KISS3Client(self.config) + return client class ClientFactory: @@ -220,7 +223,7 @@ class ClientFactory: elif KISSClient.is_enabled(self.config): key = KISSClient.transport(self.config) - LOG.debug(f"GET client {key}") + LOG.debug(f"GET client '{key}'") builder = self._builders.get(key) if not builder: raise ValueError(key) diff --git a/aprsd/clients/aprsis.py b/aprsd/clients/aprsis.py index ac7bdac..635d27b 100644 --- a/aprsd/clients/aprsis.py +++ b/aprsd/clients/aprsis.py @@ -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) diff --git a/aprsd/clients/kiss.py b/aprsd/clients/kiss.py index 7ea1ce6..f3d85f3 100644 --- a/aprsd/clients/kiss.py +++ b/aprsd/clients/kiss.py @@ -1,20 +1,19 @@ -import asyncio import logging -from aioax25 import interface -from aioax25 import kiss as kiss -from aioax25.aprs import APRSInterface +import aprslib +from ax253 import Frame +import kiss + +from aprsd import messaging +from aprsd.utils import trace LOG = logging.getLogger("APRSD") -class Aioax25Client: +class KISS3Client: def __init__(self, config): self.config = config - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - self.loop = asyncio.get_event_loop() self.setup() def setup(self): @@ -24,71 +23,89 @@ class Aioax25Client: False, ): LOG.debug( - "Setting up Serial KISS connection to {}".format( + "KISS({}) Serial connection to {}".format( + kiss.__version__, 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, + self.kiss = kiss.SerialKISS( + port=self.config["kiss"]["serial"]["device"], + speed=self.config["kiss"]["serial"].get("baudrate", 9600), + strip_df_start=True, ) elif "tcp" in self.config["kiss"] and self.config["kiss"]["tcp"].get( "enabled", False, ): LOG.debug( - "Setting up KISSTCP Connection to {}:{}".format( + "KISS({}) TCP Connection to {}:{}".format( + kiss.__version__, 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.kiss = kiss.TCPKISS( + host=self.config["kiss"]["tcp"]["host"], + port=int(self.config["kiss"]["tcp"]["port"]), + strip_df_start=True, ) - 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, - ) + LOG.debug("Starting KISS interface connection") + self.kiss.start() + @trace.trace def stop(self): - LOG.debug(self.kissdev) - self.kissdev._close() - self.loop.stop() + try: + self.kiss.stop() + self.kiss.loop.call_soon_threadsafe( + self.kiss.protocol.transport.close, + ) + except Exception as ex: + LOG.exception(ex) def set_filter(self, filter): # This does nothing right now. pass - def consumer(self, callback, blocking=True, immortal=False, raw=False): - callsign = self.config["kiss"]["callsign"] - call = callsign.split("-") - if len(call) > 1: - callsign = call[0] - ssid = int(call[1]) - else: - ssid = 0 - self.aprsint.bind(callback=callback, callsign=callsign, ssid=ssid, regex=False) - self.loop.run_forever() + def parse_frame(self, frame_bytes): + frame = Frame.from_bytes(frame_bytes) + # Now parse it with aprslib + packet = aprslib.parse(str(frame)) + kwargs = { + "frame": str(frame), + "packet": packet, + } + self._parse_callback(**kwargs) + + def consumer(self, callback, blocking=False, immortal=False, raw=False): + LOG.debug("Start blocking KISS consumer") + self._parse_callback = callback + self.kiss.read(callback=self.parse_frame, min_frames=None) + LOG.debug("END blocking KISS consumer") def send(self, msg): """Send an APRS Message object.""" - payload = f"{msg._filter_for_send()}" - self.aprsint.send_message( - addressee=msg.tocall, - message=payload, + + # payload = (':%-9s:%s' % ( + # msg.tocall, + # payload + # )).encode('US-ASCII'), + # payload = str(msg).encode('US-ASCII') + if isinstance(msg, messaging.AckMessage): + msg_payload = f"ack{msg.id}" + else: + msg_payload = f"{msg.message}{{{str(msg.id)}" + payload = ( + ":{:<9}:{}".format( + msg.tocall, + msg_payload, + ) + ).encode("US-ASCII") + LOG.debug(f"Send '{payload}' TO KISS") + frame = Frame.ui( + destination=msg.tocall, + source=msg.fromcall, path=["WIDE1-1", "WIDE2-1"], - oneshot=True, + info=payload, ) + self.kiss.write(frame) diff --git a/aprsd/cmds/dev.py b/aprsd/cmds/dev.py index 383ffcf..c3979ed 100644 --- a/aprsd/cmds/dev.py +++ b/aprsd/cmds/dev.py @@ -8,8 +8,9 @@ import logging import click # local imports here -from aprsd import cli_helper, client, messaging, packets, plugin, stats, trace +from aprsd import cli_helper, client, messaging, packets, plugin, stats, utils from aprsd.aprsd import cli +from aprsd.utils import trace LOG = logging.getLogger("APRSD") @@ -69,6 +70,14 @@ def test_plugin( """Test an individual APRSD plugin given a python path.""" config = ctx.obj["config"] + 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]}") + if not aprs_login: if not config.exists("aprs.login"): click.echo("Must set --aprs_login or APRS_LOGIN") diff --git a/aprsd/cmds/listen.py b/aprsd/cmds/listen.py index c3ebe27..1afbcef 100644 --- a/aprsd/cmds/listen.py +++ b/aprsd/cmds/listen.py @@ -14,10 +14,9 @@ from rich.console import Console # local imports here import aprsd -from aprsd import ( - cli_helper, client, messaging, packets, stats, threads, trace, utils, -) +from aprsd import cli_helper, client, messaging, packets, stats, threads, utils from aprsd.aprsd import cli +from aprsd.utils import trace # setup the global logger @@ -140,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) diff --git a/aprsd/cmds/server.py b/aprsd/cmds/server.py index b173bdb..928d00c 100644 --- a/aprsd/cmds/server.py +++ b/aprsd/cmds/server.py @@ -7,10 +7,11 @@ import click import aprsd from aprsd import ( cli_helper, client, flask, messaging, packets, plugin, stats, threads, - trace, utils, + utils, ) from aprsd import aprsd as aprsd_main from aprsd.aprsd import cli +from aprsd.threads import rx LOG = logging.getLogger("APRSD") @@ -58,8 +59,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 @@ -97,7 +96,7 @@ def server(ctx, flush): plugin_manager = plugin.PluginManager(config) plugin_manager.setup_plugins() - rx_thread = threads.APRSDRXThread( + rx_thread = rx.APRSDPluginRXThread( msg_queues=threads.msg_queues, config=config, ) diff --git a/aprsd/cmds/webchat.py b/aprsd/cmds/webchat.py new file mode 100644 index 0000000..15f94fc --- /dev/null +++ b/aprsd/cmds/webchat.py @@ -0,0 +1,617 @@ +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 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, +} + + +def signal_handler(sig, frame): + + click.echo("signal_handler: called") + LOG.info( + f"Ctrl+C, Sending all threads({len(threads.APRSDThreadList())}) exit! " + f"Can take up to 10 seconds {datetime.datetime.now()}", + ) + threads.APRSDThreadList().stop_all() + if "subprocess" not in str(frame): + time.sleep(1.5) + # messaging.MsgTrack().save() + # packets.WatchList().save() + # packets.SeenList().save() + LOG.info(stats.APRSDStats()) + LOG.info("Telling flask to bail.") + signal.signal(signal.SIGTERM, sys.exit(0)) + sys.exit(0) + + +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): + if id in self.data: + 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!""" + if id in self.data: + 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.""" + if id in self.data: + 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/kiss and waits for messages.""" + + def connected(self, connected=True): + self.connected = connected + + def stop(self): + self.thread_stop = True + client.factory.create().client.stop() + + 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: + # When new packets come in the consumer will process + # the packet + + # This call blocks until thread stop() is called. + 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() + return True + return True + + def process_packet(self, *args, **kwargs): + # packet = self._client.decode_packet(*args, **kwargs) + if "packet" in kwargs: + packet = kwargs["packet"] + else: + 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): + LOG.info(f"process PACKET {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["aprsd"]["callsign"] and msg_response == "ack": + self.process_ack_packet(packet) + elif tocall == self.config["aprsd"]["callsign"]: + 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["aprsd"]["callsign"], + fromcall, + msg_id=msg_id, + ) + ack.send() + + 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: " + "{}".format(stats["stats"]["aprs-is"]["server"]) + ) + else: + # We might be connected to a KISS socket? + if client.KISSClient.is_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["aprsd"]["callsign"], + version=aprsd.__version__, + ) + + @auth.login_required + def send_message_status(self): + LOG.debug(request) + msgs = SentMessages() + info = msgs.get_all() + return json.dumps(info) + + 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") + obj = msgs.get(self.msg.id) + socketio.emit( + "sent", obj, + namespace="/sendmsg", + ) + msg.send() + # 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, signal_handler) + signal.signal(signal.SIGTERM, 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) + + (socketio, app) = init_flask(config, loglevel, quiet) + rx_thread = WebChatRXThread( + msg_queues=msg_queues, + config=config, + ) + LOG.info("Start RX Thread") + rx_thread.start() + tx_thread = WebChatTXThread( + msg_queues=msg_queues, + config=config, + socketio=socketio, + ) + LOG.info("Start TX Thread") + tx_thread.start() + + keepalive = threads.KeepAliveThread(config=config) + LOG.info("Start KeepAliveThread") + keepalive.start() + LOG.info("Start socketio.run()") + socketio.run( + app, + host=config["aprsd"]["web"]["host"], + port=port, + ) + + LOG.info("WebChat exiting!!!! Bye.") diff --git a/aprsd/config.py b/aprsd/config.py index 93b56ea..c96abf6 100644 --- a/aprsd/config.py +++ b/aprsd/config.py @@ -57,13 +57,13 @@ DEFAULT_CONFIG_DICT = { "ham": {"callsign": "NOCALL"}, "aprs": { "enabled": True, + # Only used as the login for aprsis. "login": "CALLSIGN", "password": "00000", "host": "rotate.aprs2.net", "port": 14580, }, "kiss": { - "callsign": "NOCALL", "tcp": { "enabled": False, "host": "direwolf.ip.address", @@ -76,11 +76,14 @@ DEFAULT_CONFIG_DICT = { }, }, "aprsd": { + # Callsign to use for all packets to/from aprsd instance + # regardless of the client (aprsis vs kiss) + "callsign": "NOCALL", "logfile": "/tmp/aprsd.log", "logformat": DEFAULT_LOG_FORMAT, "dateformat": DEFAULT_DATE_FORMAT, "save_location": DEFAULT_CONFIG_DIR, - "rich_logging": False, + "rich_logging": True, "trace": False, "enabled_plugins": CORE_MESSAGE_PLUGINS, "units": "imperial", @@ -177,16 +180,35 @@ class Config(collections.UserDict): if not self.exists(path): if type(path) is list: path = ".".join(path) - raise exception.MissingConfigOption(path) + raise exception.MissingConfigOptionException(path) val = self.get(path) if val == default_fail: # We have to fail and bail if the user hasn't edited # this config option. - raise exception.ConfigOptionBogusDefaultException(path, default_fail) + raise exception.ConfigOptionBogusDefaultException( + path, default_fail, + ) def add_config_comments(raw_yaml): + end_idx = utils.end_substr(raw_yaml, "ham:") + if end_idx != -1: + # lets insert a comment + raw_yaml = utils.insert_str( + raw_yaml, + "\n # Callsign that owns this instance of APRSD.", + end_idx, + ) + end_idx = utils.end_substr(raw_yaml, "aprsd:") + if end_idx != -1: + # lets insert a comment + raw_yaml = utils.insert_str( + raw_yaml, + "\n # Callsign to use for all APRSD Packets as the to/from." + "\n # regardless of client type (aprsis vs tcpkiss vs serial)", + end_idx, + ) end_idx = utils.end_substr(raw_yaml, "aprs:") if end_idx != -1: # lets insert a comment @@ -326,6 +348,11 @@ def parse_config(config_file): config, ["aprsd"], ) + check_option( + config, + "aprsd.callsign", + default_fail=DEFAULT_CONFIG_DICT["aprsd"]["callsign"], + ) # Ensure they change the admin password if config.get("aprsd.web.enabled") is True: diff --git a/aprsd/flask.py b/aprsd/flask.py index 77e983b..21de182 100644 --- a/aprsd/flask.py +++ b/aprsd/flask.py @@ -18,9 +18,10 @@ from werkzeug.security import check_password_hash, generate_password_hash import aprsd from aprsd import client from aprsd import config as aprsd_config -from aprsd import log, messaging, packets, plugin, stats, threads, utils +from aprsd import messaging, packets, plugin, stats, threads, utils from aprsd.clients import aprsis -from aprsd.logging import logging as aprsd_logging +from aprsd.logging import log +from aprsd.logging import rich as aprsd_logging LOG = logging.getLogger("APRSD") @@ -600,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() diff --git a/aprsd/log.py b/aprsd/logging/log.py similarity index 98% rename from aprsd/log.py rename to aprsd/logging/log.py index 96135f9..855547d 100644 --- a/aprsd/log.py +++ b/aprsd/logging/log.py @@ -5,7 +5,7 @@ import queue import sys from aprsd import config as aprsd_config -from aprsd.logging import logging as aprsd_logging +from aprsd.logging import rich as aprsd_logging LOG = logging.getLogger("APRSD") diff --git a/aprsd/logging/logging.py b/aprsd/logging/rich.py similarity index 100% rename from aprsd/logging/logging.py rename to aprsd/logging/rich.py diff --git a/aprsd/messaging.py b/aprsd/messaging.py index adb3829..4c3643f 100644 --- a/aprsd/messaging.py +++ b/aprsd/messaging.py @@ -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, diff --git a/aprsd/packets.py b/aprsd/packets.py index b7e6ecc..83d795d 100644 --- a/aprsd/packets.py +++ b/aprsd/packets.py @@ -3,7 +3,10 @@ import logging import threading import time -from aprsd import objectstore, utils +import wrapt + +from aprsd import utils +from aprsd.utils import objectstore LOG = logging.getLogger("APRSD") @@ -17,6 +20,7 @@ class PacketList: """Class to track all of the packets rx'd and tx'd by aprsd.""" _instance = None + lock = threading.Lock() config = None packet_list = {} @@ -28,7 +32,6 @@ class PacketList: if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.packet_list = utils.RingBuffer(1000) - cls._instance.lock = threading.Lock() cls._instance.config = kwargs["config"] return cls._instance @@ -36,50 +39,51 @@ class PacketList: if config: self.config = config + @wrapt.synchronized(lock) def __iter__(self): - with self.lock: - return iter(self.packet_list) + return iter(self.packet_list) + @wrapt.synchronized(lock) def add(self, packet): - with self.lock: - packet["ts"] = time.time() - if ( - "fromcall" in packet - and packet["fromcall"] == self.config["aprs"]["login"] - ): - self.total_tx += 1 - else: - self.total_recv += 1 - self.packet_list.append(packet) - SeenList().update_seen(packet) + packet["ts"] = time.time() + if ( + "fromcall" in packet + and packet["fromcall"] == self.config["aprs"]["login"] + ): + self.total_tx += 1 + else: + self.total_recv += 1 + self.packet_list.append(packet) + SeenList().update_seen(packet) + @wrapt.synchronized(lock) def get(self): - with self.lock: - return self.packet_list.get() + return self.packet_list.get() + @wrapt.synchronized(lock) def total_received(self): - with self.lock: - return self.total_recv + return self.total_recv + @wrapt.synchronized(lock) def total_sent(self): - with self.lock: - return self.total_tx + return self.total_tx class WatchList(objectstore.ObjectStoreMixin): """Global watch list and info for callsigns.""" _instance = None + lock = threading.Lock() data = {} config = None def __new__(cls, *args, **kwargs): 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): @@ -110,12 +114,12 @@ class WatchList(objectstore.ObjectStoreMixin): def callsign_in_watchlist(self, callsign): return callsign in self.data + @wrapt.synchronized(lock) def update_seen(self, packet): - with self.lock: - callsign = packet["from"] - if self.callsign_in_watchlist(callsign): - self.data[callsign]["last"] = datetime.datetime.now() - self.data[callsign]["packets"].append(packet) + callsign = packet["from"] + if self.callsign_in_watchlist(callsign): + self.data[callsign]["last"] = datetime.datetime.now() + self.data[callsign]["packets"].append(packet) def last_seen(self, callsign): if self.callsign_in_watchlist(callsign): @@ -158,18 +162,20 @@ class SeenList(objectstore.ObjectStoreMixin): """Global callsign seen list.""" _instance = None + lock = threading.Lock() data = {} config = None def __new__(cls, *args, **kwargs): 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 + @wrapt.synchronized(lock) def update_seen(self, packet): callsign = None if "fromcall" in packet: diff --git a/aprsd/plugin.py b/aprsd/plugin.py index 7a6aff1..0564085 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -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() diff --git a/aprsd/plugins/email.py b/aprsd/plugins/email.py index d734e06..c5b0430 100644 --- a/aprsd/plugins/email.py +++ b/aprsd/plugins/email.py @@ -11,7 +11,8 @@ import time import imapclient from validate_email import validate_email -from aprsd import messaging, plugin, stats, threads, trace +from aprsd import messaging, plugin, stats, threads +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/fortune.py b/aprsd/plugins/fortune.py index b30fc73..43ff0a7 100644 --- a/aprsd/plugins/fortune.py +++ b/aprsd/plugins/fortune.py @@ -2,7 +2,8 @@ import logging import shutil import subprocess -from aprsd import plugin, trace +from aprsd import plugin +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/location.py b/aprsd/plugins/location.py index 939661b..ddc97ef 100644 --- a/aprsd/plugins/location.py +++ b/aprsd/plugins/location.py @@ -2,7 +2,8 @@ import logging import re import time -from aprsd import plugin, plugin_utils, trace +from aprsd import plugin, plugin_utils +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/notify.py b/aprsd/plugins/notify.py index 09e4a20..878a40f 100644 --- a/aprsd/plugins/notify.py +++ b/aprsd/plugins/notify.py @@ -1,6 +1,7 @@ import logging -from aprsd import messaging, packets, plugin, trace +from aprsd import messaging, packets, plugin +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/ping.py b/aprsd/plugins/ping.py index b709574..6304a45 100644 --- a/aprsd/plugins/ping.py +++ b/aprsd/plugins/ping.py @@ -1,7 +1,8 @@ import logging import time -from aprsd import plugin, trace +from aprsd import plugin +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/query.py b/aprsd/plugins/query.py index bdc273a..a21062e 100644 --- a/aprsd/plugins/query.py +++ b/aprsd/plugins/query.py @@ -2,7 +2,8 @@ import datetime import logging import re -from aprsd import messaging, plugin, trace +from aprsd import messaging, plugin +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/time.py b/aprsd/plugins/time.py index 8cfdd83..f668544 100644 --- a/aprsd/plugins/time.py +++ b/aprsd/plugins/time.py @@ -2,10 +2,10 @@ import logging import re import time -from opencage.geocoder import OpenCageGeocode import pytz -from aprsd import fuzzyclock, plugin, plugin_utils, trace +from aprsd import plugin, plugin_utils +from aprsd.utils import fuzzy, trace LOG = logging.getLogger("APRSD") @@ -32,7 +32,7 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase): local_short_str = local_t.strftime("%H:%M %Z") local_hour = local_t.strftime("%H") local_min = local_t.strftime("%M") - cur_time = fuzzyclock.fuzzy(int(local_hour), int(local_min), 1) + cur_time = fuzzy(int(local_hour), int(local_min), 1) reply = "{} ({})".format( cur_time, @@ -49,68 +49,6 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase): return self.build_date_str(localzone) -class TimeOpenCageDataPlugin(TimePlugin, plugin.APRSFIKEYMixin): - """geocage based timezone fetching.""" - - command_regex = "^[tT]" - command_name = "time" - short_description = "Current time of GPS beacon timezone. Uses OpenCage" - - def setup(self): - self.ensure_aprs_fi_key() - - @trace.trace - def process(self, packet): - fromcall = packet.get("from") - message = packet.get("message_text", None) - # ack = packet.get("msgNo", "0") - - api_key = self.config["services"]["aprs.fi"]["apiKey"] - - # optional second argument is a callsign to search - a = re.search(r"^.*\s+(.*)", message) - if a is not None: - searchcall = a.group(1) - searchcall = searchcall.upper() - else: - # if no second argument, search for calling station - searchcall = fromcall - - try: - aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall) - except Exception as ex: - LOG.error(f"Failed to fetch aprs.fi data {ex}") - return "Failed to fetch location" - - # LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data)) - if not len(aprs_data["entries"]): - LOG.error("Didn't get any entries from aprs.fi") - return "Failed to fetch aprs.fi location" - - lat = aprs_data["entries"][0]["lat"] - lon = aprs_data["entries"][0]["lng"] - - try: - self.config.exists("opencagedata.apiKey") - except Exception as ex: - LOG.error(f"Failed to find config opencage:apiKey {ex}") - return "No opencage apiKey found" - - try: - opencage_key = self.config["opencagedata"]["apiKey"] - geocoder = OpenCageGeocode(opencage_key) - results = geocoder.reverse_geocode(lat, lon) - except Exception as ex: - LOG.error(f"Couldn't fetch opencagedata api '{ex}'") - # Default to UTC instead - localzone = pytz.timezone("UTC") - else: - tzone = results[0]["annotations"]["timezone"]["name"] - localzone = pytz.timezone(tzone) - - return self.build_date_str(localzone) - - class TimeOWMPlugin(TimePlugin, plugin.APRSFIKEYMixin): """OpenWeatherMap based timezone fetching.""" diff --git a/aprsd/plugins/version.py b/aprsd/plugins/version.py index 6a05690..80ce257 100644 --- a/aprsd/plugins/version.py +++ b/aprsd/plugins/version.py @@ -1,7 +1,8 @@ import logging import aprsd -from aprsd import plugin, stats, trace +from aprsd import plugin, stats +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/weather.py b/aprsd/plugins/weather.py index 1880fef..acf1208 100644 --- a/aprsd/plugins/weather.py +++ b/aprsd/plugins/weather.py @@ -4,7 +4,8 @@ import re import requests -from aprsd import plugin, plugin_utils, trace +from aprsd import plugin, plugin_utils +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/stats.py b/aprsd/stats.py index e629157..8562fc3 100644 --- a/aprsd/stats.py +++ b/aprsd/stats.py @@ -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, diff --git a/aprsd/threads/__init__.py b/aprsd/threads/__init__.py new file mode 100644 index 0000000..fd4da3b --- /dev/null +++ b/aprsd/threads/__init__.py @@ -0,0 +1,13 @@ +import queue + +# Make these available to anyone importing +# aprsd.threads +from .aprsd import APRSDThread, APRSDThreadList # noqa: F401 +from .keep_alive import KeepAliveThread # noqa: F401 +from .rx import APRSDRXThread # noqa: F401 + + +rx_msg_queue = queue.Queue(maxsize=20) +msg_queues = { + "rx": rx_msg_queue, +} diff --git a/aprsd/threads/aprsd.py b/aprsd/threads/aprsd.py new file mode 100644 index 0000000..819ee7f --- /dev/null +++ b/aprsd/threads/aprsd.py @@ -0,0 +1,94 @@ +import abc +import logging +from queue import Queue +import threading + +import wrapt + + +LOG = logging.getLogger("APRSD") + + +class APRSDThreadList: + """Singleton class that keeps track of application wide threads.""" + + _instance = None + + threads_list = [] + lock = threading.Lock() + global_queue = Queue() + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls.threads_list = [] + return cls._instance + + @wrapt.synchronized(lock) + def add(self, thread_obj): + thread_obj.set_global_queue(self.global_queue) + self.threads_list.append(thread_obj) + + @wrapt.synchronized(lock) + def remove(self, thread_obj): + self.threads_list.remove(thread_obj) + + @wrapt.synchronized(lock) + def stop_all(self): + self.global_queue.put_nowait({"quit": True}) + """Iterate over all threads and call stop on them.""" + for th in self.threads_list: + LOG.info(f"Stopping Thread {th.name}") + th.stop() + + @wrapt.synchronized(lock) + def __len__(self): + return len(self.threads_list) + + +class APRSDThread(threading.Thread, metaclass=abc.ABCMeta): + + global_queue = None + + def __init__(self, name): + super().__init__(name=name) + self.thread_stop = False + APRSDThreadList().add(self) + + def set_global_queue(self, global_queue): + self.global_queue = global_queue + + def _should_quit(self): + """ see if we have a quit message from the global queue.""" + if self.thread_stop: + return True + if self.global_queue.empty(): + return False + msg = self.global_queue.get(timeout=1) + if not msg: + return False + if "quit" in msg and msg["quit"] is True: + # put the message back on the queue for others + self.global_queue.put_nowait(msg) + self.thread_stop = True + return True + + def stop(self): + self.thread_stop = True + + @abc.abstractmethod + def loop(self): + pass + + def _cleanup(self): + """Add code to subclass to do any cleanup""" + + def run(self): + LOG.debug("Starting") + while not self._should_quit(): + can_loop = self.loop() + if not can_loop: + self.stop() + self._cleanup() + APRSDThreadList().remove(self) + LOG.debug("Exiting") diff --git a/aprsd/threads/keep_alive.py b/aprsd/threads/keep_alive.py new file mode 100644 index 0000000..95c7ea0 --- /dev/null +++ b/aprsd/threads/keep_alive.py @@ -0,0 +1,88 @@ +import datetime +import logging +import time +import tracemalloc + +from aprsd import client, messaging, packets, stats, utils +from aprsd.threads import APRSDThread, APRSDThreadList + + +LOG = logging.getLogger("APRSD") + + +class KeepAliveThread(APRSDThread): + cntr = 0 + checker_time = datetime.datetime.now() + + def __init__(self, config): + tracemalloc.start() + super().__init__("KeepAlive") + self.config = config + max_timeout = {"hours": 0.0, "minutes": 2, "seconds": 0} + self.max_delta = datetime.timedelta(**max_timeout) + + def loop(self): + if self.cntr % 60 == 0: + tracker = messaging.MsgTrack() + stats_obj = stats.APRSDStats() + pl = packets.PacketList() + thread_list = APRSDThreadList() + now = datetime.datetime.now() + last_email = stats_obj.email_thread_time + if last_email: + email_thread_time = utils.strfdelta(now - last_email) + else: + email_thread_time = "N/A" + + last_msg_time = utils.strfdelta(now - stats_obj.aprsis_keepalive) + + current, peak = tracemalloc.get_traced_memory() + stats_obj.set_memory(current) + stats_obj.set_memory_peak(peak) + + try: + login = self.config["aprsd"]["callsign"] + except KeyError: + login = self.config["ham"]["callsign"] + + keepalive = ( + "{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} " + "Last:{} Email: {} - RAM Current:{} Peak:{} Threads:{}" + ).format( + login, + utils.strfdelta(stats_obj.uptime), + pl.total_recv, + pl.total_tx, + len(tracker), + stats_obj.msgs_tx, + stats_obj.msgs_rx, + last_msg_time, + email_thread_time, + utils.human_size(current), + utils.human_size(peak), + len(thread_list), + ) + LOG.info(keepalive) + + # See if we should reset the aprs-is client + # Due to losing a keepalive from them + delta_dict = utils.parse_delta_str(last_msg_time) + delta = datetime.timedelta(**delta_dict) + + if delta > self.max_delta: + # 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(f"Resetting connection to APRS-IS {delta}") + client.factory.create().reset() + + # Check version every hour + delta = now - self.checker_time + if delta > datetime.timedelta(hours=1): + self.checker_time = now + level, msg = utils._check_version() + if level: + LOG.warning(msg) + self.cntr += 1 + time.sleep(1) + return True diff --git a/aprsd/threads.py b/aprsd/threads/rx.py similarity index 55% rename from aprsd/threads.py rename to aprsd/threads/rx.py index c81275e..b28dbff 100644 --- a/aprsd/threads.py +++ b/aprsd/threads/rx.py @@ -1,162 +1,15 @@ import abc -import datetime import logging -import queue -import threading import time -import tracemalloc import aprslib -from aprsd import client, messaging, packets, plugin, stats, utils +from aprsd import client, messaging, packets, plugin, stats +from aprsd.threads import APRSDThread LOG = logging.getLogger("APRSD") -RX_THREAD = "RX" -EMAIL_THREAD = "Email" - -rx_msg_queue = queue.Queue(maxsize=20) -msg_queues = { - "rx": rx_msg_queue, -} - - -class APRSDThreadList: - """Singleton class that keeps track of application wide threads.""" - - _instance = None - - threads_list = [] - lock = None - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls.lock = threading.Lock() - cls.threads_list = [] - return cls._instance - - def add(self, thread_obj): - with self.lock: - self.threads_list.append(thread_obj) - - def remove(self, thread_obj): - with self.lock: - self.threads_list.remove(thread_obj) - - def stop_all(self): - """Iterate over all threads and call stop on them.""" - with self.lock: - for th in self.threads_list: - LOG.debug(f"Stopping Thread {th.name}") - th.stop() - - def __len__(self): - with self.lock: - return len(self.threads_list) - - -class APRSDThread(threading.Thread, metaclass=abc.ABCMeta): - def __init__(self, name): - super().__init__(name=name) - self.thread_stop = False - APRSDThreadList().add(self) - - def stop(self): - self.thread_stop = True - - @abc.abstractmethod - def loop(self): - pass - - def run(self): - LOG.debug("Starting") - while not self.thread_stop: - can_loop = self.loop() - if not can_loop: - self.stop() - APRSDThreadList().remove(self) - LOG.debug("Exiting") - - -class KeepAliveThread(APRSDThread): - cntr = 0 - checker_time = datetime.datetime.now() - - def __init__(self, config): - tracemalloc.start() - super().__init__("KeepAlive") - self.config = config - max_timeout = {"hours": 0.0, "minutes": 2, "seconds": 0} - self.max_delta = datetime.timedelta(**max_timeout) - - def loop(self): - if self.cntr % 60 == 0: - tracker = messaging.MsgTrack() - stats_obj = stats.APRSDStats() - pl = packets.PacketList() - thread_list = APRSDThreadList() - now = datetime.datetime.now() - last_email = stats_obj.email_thread_time - if last_email: - email_thread_time = utils.strfdelta(now - last_email) - else: - email_thread_time = "N/A" - - last_msg_time = utils.strfdelta(now - stats_obj.aprsis_keepalive) - - current, peak = tracemalloc.get_traced_memory() - stats_obj.set_memory(current) - stats_obj.set_memory_peak(peak) - - try: - login = self.config["aprs"]["login"] - except KeyError: - login = self.config["ham"]["callsign"] - - keepalive = ( - "{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} " - "Last:{} Email: {} - RAM Current:{} Peak:{} Threads:{}" - ).format( - login, - utils.strfdelta(stats_obj.uptime), - pl.total_recv, - pl.total_tx, - len(tracker), - stats_obj.msgs_tx, - stats_obj.msgs_rx, - last_msg_time, - email_thread_time, - utils.human_size(current), - utils.human_size(peak), - len(thread_list), - ) - LOG.info(keepalive) - - # See if we should reset the aprs-is client - # Due to losing a keepalive from them - delta_dict = utils.parse_delta_str(last_msg_time) - delta = datetime.timedelta(**delta_dict) - - if delta > self.max_delta: - # 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.") - client.factory.create().reset() - - # Check version every hour - delta = now - self.checker_time - if delta > datetime.timedelta(hours=1): - self.checker_time = now - level, msg = utils._check_version() - if level: - LOG.warning(msg) - self.cntr += 1 - time.sleep(1) - return True - class APRSDRXThread(APRSDThread): def __init__(self, msg_queues, config): @@ -186,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 @@ -196,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) @@ -239,7 +101,7 @@ class APRSDProcessPacketThread(APRSDThread): # We don't put ack packets destined for us through the # plugins. - if tocall == self.config["aprs"]["login"] and msg_response == "ack": + if tocall == self.config["aprsd"]["callsign"] and msg_response == "ack": self.process_ack_packet(packet) else: # It's not an ACK for us, so lets run it through @@ -253,12 +115,12 @@ class APRSDProcessPacketThread(APRSDThread): ) # Only ack messages that were sent directly to us - if tocall == self.config["aprs"]["login"]: + if tocall == self.config["aprsd"]["callsign"]: stats.APRSDStats().msgs_rx_inc() # let any threads do their thing, then ack # send an ack last ack = messaging.AckMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, msg_id=msg_id, ) @@ -280,7 +142,7 @@ class APRSDProcessPacketThread(APRSDThread): subreply.send() else: msg = messaging.TextMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, subreply, ) @@ -300,7 +162,7 @@ class APRSDProcessPacketThread(APRSDThread): LOG.debug(f"Sending '{reply}'") msg = messaging.TextMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, reply, ) @@ -308,10 +170,10 @@ class APRSDProcessPacketThread(APRSDThread): # If the message was for us and we didn't have a # response, then we send a usage statement. - if tocall == self.config["aprs"]["login"] and not replied: + if tocall == self.config["aprsd"]["callsign"] and not replied: LOG.warning("Sending help!") msg = messaging.TextMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, "Unknown command! Send 'help' message for help", ) @@ -320,10 +182,10 @@ class APRSDProcessPacketThread(APRSDThread): LOG.error("Plugin failed!!!") LOG.exception(ex) # Do we need to send a reply? - if tocall == self.config["aprs"]["login"]: + if tocall == self.config["aprsd"]["callsign"]: reply = "A Plugin failed! try again?" msg = messaging.TextMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, reply, ) diff --git a/aprsd/utils.py b/aprsd/utils/__init__.py similarity index 71% rename from aprsd/utils.py rename to aprsd/utils/__init__.py index 124443c..02042f9 100644 --- a/aprsd/utils.py +++ b/aprsd/utils/__init__.py @@ -2,25 +2,17 @@ import collections import errno -import functools import os import re -import threading import update_checker import aprsd - -def synchronized(wrapped): - lock = threading.Lock() - - @functools.wraps(wrapped) - def _wrap(*args, **kwargs): - with lock: - return wrapped(*args, **kwargs) - - return _wrap +from .fuzzyclock import fuzzy # noqa: F401 +# Make these available by anyone importing +# aprsd.utils +from .ring_buffer import RingBuffer # noqa: F401 def env(*vars, **kwargs): @@ -129,42 +121,3 @@ def parse_delta_str(s): else: m = re.match(r"(?P\d+):(?P\d+):(?P\d[\.\d+]*)", s) return {key: float(val) for key, val in m.groupdict().items()} - - -class RingBuffer: - """class that implements a not-yet-full buffer""" - - def __init__(self, size_max): - self.max = size_max - self.data = [] - - class __Full: - """class that implements a full buffer""" - - def append(self, x): - """Append an element overwriting the oldest one.""" - self.data[self.cur] = x - self.cur = (self.cur + 1) % self.max - - def get(self): - """return list of elements in correct order""" - return self.data[self.cur :] + self.data[: self.cur] - - def __len__(self): - return len(self.data) - - def append(self, x): - """append an element at the end of the buffer""" - - self.data.append(x) - if len(self.data) == self.max: - self.cur = 0 - # Permanently change self's class from non-full to full - self.__class__ = self.__Full - - def get(self): - """Return a list of elements from the oldest to the newest.""" - return self.data - - def __len__(self): - return len(self.data) diff --git a/aprsd/fuzzyclock.py b/aprsd/utils/fuzzyclock.py similarity index 100% rename from aprsd/fuzzyclock.py rename to aprsd/utils/fuzzyclock.py diff --git a/aprsd/objectstore.py b/aprsd/utils/objectstore.py similarity index 100% rename from aprsd/objectstore.py rename to aprsd/utils/objectstore.py diff --git a/aprsd/utils/ring_buffer.py b/aprsd/utils/ring_buffer.py new file mode 100644 index 0000000..4029ce4 --- /dev/null +++ b/aprsd/utils/ring_buffer.py @@ -0,0 +1,37 @@ +class RingBuffer: + """class that implements a not-yet-full buffer""" + + def __init__(self, size_max): + self.max = size_max + self.data = [] + + class __Full: + """class that implements a full buffer""" + + def append(self, x): + """Append an element overwriting the oldest one.""" + self.data[self.cur] = x + self.cur = (self.cur + 1) % self.max + + def get(self): + """return list of elements in correct order""" + return self.data[self.cur :] + self.data[: self.cur] + + def __len__(self): + return len(self.data) + + def append(self, x): + """append an element at the end of the buffer""" + + self.data.append(x) + if len(self.data) == self.max: + self.cur = 0 + # Permanently change self's class from non-full to full + self.__class__ = self.__Full + + def get(self): + """Return a list of elements from the oldest to the newest.""" + return self.data + + def __len__(self): + return len(self.data) diff --git a/aprsd/trace.py b/aprsd/utils/trace.py similarity index 100% rename from aprsd/trace.py rename to aprsd/utils/trace.py diff --git a/aprsd/web/__init__.py b/aprsd/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aprsd/web/admin/__init__.py b/aprsd/web/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aprsd/web/static/css/index.css b/aprsd/web/admin/static/css/index.css similarity index 100% rename from aprsd/web/static/css/index.css rename to aprsd/web/admin/static/css/index.css diff --git a/aprsd/web/static/css/prism.css b/aprsd/web/admin/static/css/prism.css similarity index 100% rename from aprsd/web/static/css/prism.css rename to aprsd/web/admin/static/css/prism.css diff --git a/aprsd/web/static/css/tabs.css b/aprsd/web/admin/static/css/tabs.css similarity index 100% rename from aprsd/web/static/css/tabs.css rename to aprsd/web/admin/static/css/tabs.css diff --git a/aprsd/web/admin/static/images/Untitled.png b/aprsd/web/admin/static/images/Untitled.png new file mode 100644 index 0000000..666fbc4 Binary files /dev/null and b/aprsd/web/admin/static/images/Untitled.png differ diff --git a/aprsd/web/static/images/aprs-symbols-16-0.png b/aprsd/web/admin/static/images/aprs-symbols-16-0.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-16-0.png rename to aprsd/web/admin/static/images/aprs-symbols-16-0.png diff --git a/aprsd/web/static/images/aprs-symbols-16-1.png b/aprsd/web/admin/static/images/aprs-symbols-16-1.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-16-1.png rename to aprsd/web/admin/static/images/aprs-symbols-16-1.png diff --git a/aprsd/web/static/images/aprs-symbols-64-0.png b/aprsd/web/admin/static/images/aprs-symbols-64-0.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-64-0.png rename to aprsd/web/admin/static/images/aprs-symbols-64-0.png diff --git a/aprsd/web/static/images/aprs-symbols-64-1.png b/aprsd/web/admin/static/images/aprs-symbols-64-1.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-64-1.png rename to aprsd/web/admin/static/images/aprs-symbols-64-1.png diff --git a/aprsd/web/static/images/aprs-symbols-64-2.png b/aprsd/web/admin/static/images/aprs-symbols-64-2.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-64-2.png rename to aprsd/web/admin/static/images/aprs-symbols-64-2.png diff --git a/aprsd/web/static/js/charts.js b/aprsd/web/admin/static/js/charts.js similarity index 100% rename from aprsd/web/static/js/charts.js rename to aprsd/web/admin/static/js/charts.js diff --git a/aprsd/web/static/js/logs.js b/aprsd/web/admin/static/js/logs.js similarity index 100% rename from aprsd/web/static/js/logs.js rename to aprsd/web/admin/static/js/logs.js diff --git a/aprsd/web/static/js/main.js b/aprsd/web/admin/static/js/main.js similarity index 100% rename from aprsd/web/static/js/main.js rename to aprsd/web/admin/static/js/main.js diff --git a/aprsd/web/static/js/prism.js b/aprsd/web/admin/static/js/prism.js similarity index 100% rename from aprsd/web/static/js/prism.js rename to aprsd/web/admin/static/js/prism.js diff --git a/aprsd/web/static/js/send-message.js b/aprsd/web/admin/static/js/send-message.js similarity index 100% rename from aprsd/web/static/js/send-message.js rename to aprsd/web/admin/static/js/send-message.js diff --git a/aprsd/web/static/js/tabs.js b/aprsd/web/admin/static/js/tabs.js similarity index 100% rename from aprsd/web/static/js/tabs.js rename to aprsd/web/admin/static/js/tabs.js diff --git a/aprsd/web/static/json-viewer/jquery.json-viewer.css b/aprsd/web/admin/static/json-viewer/jquery.json-viewer.css similarity index 100% rename from aprsd/web/static/json-viewer/jquery.json-viewer.css rename to aprsd/web/admin/static/json-viewer/jquery.json-viewer.css diff --git a/aprsd/web/static/json-viewer/jquery.json-viewer.js b/aprsd/web/admin/static/json-viewer/jquery.json-viewer.js similarity index 100% rename from aprsd/web/static/json-viewer/jquery.json-viewer.js rename to aprsd/web/admin/static/json-viewer/jquery.json-viewer.js diff --git a/aprsd/web/templates/index.html b/aprsd/web/admin/templates/index.html similarity index 100% rename from aprsd/web/templates/index.html rename to aprsd/web/admin/templates/index.html diff --git a/aprsd/web/templates/messages.html b/aprsd/web/admin/templates/messages.html similarity index 100% rename from aprsd/web/templates/messages.html rename to aprsd/web/admin/templates/messages.html diff --git a/aprsd/web/templates/send-message.html b/aprsd/web/admin/templates/send-message.html similarity index 100% rename from aprsd/web/templates/send-message.html rename to aprsd/web/admin/templates/send-message.html diff --git a/aprsd/web/chat/static/css/index.css b/aprsd/web/chat/static/css/index.css new file mode 100644 index 0000000..b39acab --- /dev/null +++ b/aprsd/web/chat/static/css/index.css @@ -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; +} diff --git a/aprsd/web/chat/static/css/style.css.map b/aprsd/web/chat/static/css/style.css.map new file mode 100644 index 0000000..299ffa9 --- /dev/null +++ b/aprsd/web/chat/static/css/style.css.map @@ -0,0 +1 @@ +{"version":3,"file":"css/style.css","mappings":"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AC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,UAAa,CACzB,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,UAAa,CACzB,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,UAAa,CACzB,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,o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","sources":["webpack://thelounge/./node_modules/normalize.css/normalize.css","webpack://thelounge/./client/css/fontawesome.css","webpack://thelounge/./node_modules/primer-tooltips/build/build.css","webpack://thelounge/./client/css/style.css","webpack://thelounge/./client/components/NetworkList.vue","webpack://thelounge/./client/components/ConfirmDialog.vue","webpack://thelounge/./client/components/Mentions.vue","webpack://thelounge/./client/components/NetworkForm.vue","webpack://thelounge/./client/components/Session.vue","webpack://thelounge/./client/components/Windows/Settings.vue","webpack://thelounge/./client/components/MessageSearchForm.vue","webpack://thelounge/./client/components/Windows/SearchResults.vue"],"sourcesContent":["/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */\n\n/* Document\n ========================================================================== */\n\n/**\n * 1. Correct the line height in all browsers.\n * 2. Prevent adjustments of font size after orientation changes in iOS.\n */\n\nhtml {\n line-height: 1.15; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n}\n\n/* Sections\n ========================================================================== */\n\n/**\n * Remove the margin in all browsers.\n */\n\nbody {\n margin: 0;\n}\n\n/**\n * Render the `main` element consistently in IE.\n */\n\nmain {\n display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n/* Grouping content\n ========================================================================== */\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n box-sizing: content-box; /* 1 */\n height: 0; /* 1 */\n overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\npre {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/* Text-level semantics\n ========================================================================== */\n\n/**\n * Remove the gray background on active links in IE 10.\n */\n\na {\n background-color: transparent;\n}\n\n/**\n * 1. Remove the bottom border in Chrome 57-\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n */\n\nabbr[title] {\n border-bottom: none; /* 1 */\n text-decoration: underline; /* 2 */\n text-decoration: underline dotted; /* 2 */\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\n/* Embedded content\n ========================================================================== */\n\n/**\n * Remove the border on images inside links in IE 10.\n */\n\nimg {\n border-style: none;\n}\n\n/* Forms\n ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers.\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n font-family: inherit; /* 1 */\n font-size: 100%; /* 1 */\n line-height: 1.15; /* 1 */\n margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput { /* 1 */\n overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect { /* 1 */\n text-transform: none;\n}\n\n/**\n * Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n border-style: none;\n padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule.\n */\n\nbutton:-moz-focusring,\n[type=\"button\"]:-moz-focusring,\n[type=\"reset\"]:-moz-focusring,\n[type=\"submit\"]:-moz-focusring {\n outline: 1px dotted ButtonText;\n}\n\n/**\n * Correct the padding in Firefox.\n */\n\nfieldset {\n padding: 0.35em 0.75em 0.625em;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n * `fieldset` elements in all browsers.\n */\n\nlegend {\n box-sizing: border-box; /* 1 */\n color: inherit; /* 2 */\n display: table; /* 1 */\n max-width: 100%; /* 1 */\n padding: 0; /* 3 */\n white-space: normal; /* 1 */\n}\n\n/**\n * Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n vertical-align: baseline;\n}\n\n/**\n * Remove the default vertical scrollbar in IE 10+.\n */\n\ntextarea {\n overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10.\n * 2. Remove the padding in IE 10.\n */\n\n[type=\"checkbox\"],\n[type=\"radio\"] {\n box-sizing: border-box; /* 1 */\n padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type=\"search\"] {\n -webkit-appearance: textfield; /* 1 */\n outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding in Chrome and Safari on macOS.\n */\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n -webkit-appearance: button; /* 1 */\n font: inherit; /* 2 */\n}\n\n/* Interactive\n ========================================================================== */\n\n/*\n * Add the correct display in Edge, IE 10+, and Firefox.\n */\n\ndetails {\n display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n display: list-item;\n}\n\n/* Misc\n ========================================================================== */\n\n/**\n * Add the correct display in IE 10+.\n */\n\ntemplate {\n display: none;\n}\n\n/**\n * Add the correct display in IE 10.\n */\n\n[hidden] {\n display: none;\n}\n","@font-face {\n\t/* We use free solid icons - https://fontawesome.com/icons?s=solid&m=free */\n\tfont-family: FontAwesome;\n\tfont-weight: normal;\n\tfont-style: normal;\n\tsrc:\n\t\turl(\"../fonts/fa-solid-900.woff2\") format(\"woff2\"),\n\t\turl(\"../fonts/fa-solid-900.woff\") format(\"woff\");\n}\n",".tooltipped{position:relative}.tooltipped::after{position:absolute;z-index:1000000;display:none;padding:.5em .75em;font:normal normal 11px/1.5 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\";-webkit-font-smoothing:subpixel-antialiased;color:#fff;text-align:center;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-wrap:break-word;white-space:pre;pointer-events:none;content:attr(aria-label);background:#1b1f23;border-radius:3px;opacity:0}.tooltipped::before{position:absolute;z-index:1000001;display:none;width:0;height:0;color:#1b1f23;pointer-events:none;content:\"\";border:6px solid transparent;opacity:0}@keyframes tooltip-appear{from{opacity:0}to{opacity:1}}.tooltipped:hover::before,.tooltipped:hover::after,.tooltipped:active::before,.tooltipped:active::after,.tooltipped:focus::before,.tooltipped:focus::after{display:inline-block;text-decoration:none;animation-name:tooltip-appear;animation-duration:.1s;animation-fill-mode:forwards;animation-timing-function:ease-in;animation-delay:.4s}.tooltipped-no-delay:hover::before,.tooltipped-no-delay:hover::after,.tooltipped-no-delay:active::before,.tooltipped-no-delay:active::after,.tooltipped-no-delay:focus::before,.tooltipped-no-delay:focus::after{animation-delay:0s}.tooltipped-multiline:hover::after,.tooltipped-multiline:active::after,.tooltipped-multiline:focus::after{display:table-cell}.tooltipped-s::after,.tooltipped-se::after,.tooltipped-sw::after{top:100%;right:50%;margin-top:6px}.tooltipped-s::before,.tooltipped-se::before,.tooltipped-sw::before{top:auto;right:50%;bottom:-7px;margin-right:-6px;border-bottom-color:#1b1f23}.tooltipped-se::after{right:auto;left:50%;margin-left:-16px}.tooltipped-sw::after{margin-right:-16px}.tooltipped-n::after,.tooltipped-ne::after,.tooltipped-nw::after{right:50%;bottom:100%;margin-bottom:6px}.tooltipped-n::before,.tooltipped-ne::before,.tooltipped-nw::before{top:-7px;right:50%;bottom:auto;margin-right:-6px;border-top-color:#1b1f23}.tooltipped-ne::after{right:auto;left:50%;margin-left:-16px}.tooltipped-nw::after{margin-right:-16px}.tooltipped-s::after,.tooltipped-n::after{transform:translateX(50%)}.tooltipped-w::after{right:100%;bottom:50%;margin-right:6px;transform:translateY(50%)}.tooltipped-w::before{top:50%;bottom:50%;left:-7px;margin-top:-6px;border-left-color:#1b1f23}.tooltipped-e::after{bottom:50%;left:100%;margin-left:6px;transform:translateY(50%)}.tooltipped-e::before{top:50%;right:-7px;bottom:50%;margin-top:-6px;border-right-color:#1b1f23}.tooltipped-align-right-1::after,.tooltipped-align-right-2::after{right:0;margin-right:0}.tooltipped-align-right-1::before{right:10px}.tooltipped-align-right-2::before{right:15px}.tooltipped-align-left-1::after,.tooltipped-align-left-2::after{left:0;margin-left:0}.tooltipped-align-left-1::before{left:5px}.tooltipped-align-left-2::before{left:10px}.tooltipped-multiline::after{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:250px;word-wrap:break-word;white-space:pre-line;border-collapse:separate}.tooltipped-multiline.tooltipped-s::after,.tooltipped-multiline.tooltipped-n::after{right:auto;left:50%;transform:translateX(-50%)}.tooltipped-multiline.tooltipped-w::after,.tooltipped-multiline.tooltipped-e::after{right:100%}@media screen and (min-width: 0\\0){.tooltipped-multiline::after{width:250px}}.tooltipped-sticky::before,.tooltipped-sticky::after{display:inline-block}.tooltipped-sticky.tooltipped-multiline::after{display:table-cell}\n","@import \"../../node_modules/normalize.css/normalize.css\";\n@import \"fontawesome.css\";\n@import \"../../node_modules/primer-tooltips/build/build.css\";\n\n:root {\n\t/* Main text color */\n\t--body-color: #222;\n\n\t/* Secondary text color, dimmed. Make sure to keep contrast WCAG 2.0 AA compliant on var(--window-bg-color) */\n\t--body-color-muted: #767676;\n\n\t/* Background color of the whole page */\n\t--body-bg-color: #415364;\n\n\t/* Main button color. Applies to border, text, and background on hover */\n\t--button-color: #84ce88;\n\t--button-text-color-hover: #fff;\n\n\t/* Color for sidebar overlay and other things that dim the viewport when something else is on top */\n\t--overlay-bg-color: rgb(0 0 0 / 50%);\n\n\t/* Links and link-looking buttons */\n\t--link-color: #50a656;\n\n\t/* Background color of the main window */\n\t--window-bg-color: #fff;\n\n\t/* Text color for

and

headings in windows */\n\t--window-heading-color: #6c797a;\n\n\t/* Color of the date marker, text and separator */\n\t--date-marker-color: rgb(0 107 59 / 50%);\n\n\t/* Color of the unread message marker, text and separator */\n\t--unread-marker-color: rgb(231 76 60 / 50%);\n\n\t/* Background and left-border color of highlight messages */\n\t--highlight-bg-color: #efe8dc;\n\t--highlight-border-color: #b08c4f;\n\n\t/* Color of the progress bar that appears as a file is being uploaded to the server. Defaults to button color */\n\t--upload-progressbar-color: var(--button-color);\n}\n\n::placeholder {\n\tcolor: rgb(0 0 0 / 35%);\n\topacity: 1; /* fix opacity in Firefox */\n}\n\nhtml {\n\tbox-sizing: border-box;\n\t-webkit-tap-highlight-color: transparent; /* remove tap highlight on touch devices */\n}\n\n*,\n*::before,\n*::after {\n\tbox-sizing: inherit;\n}\n\ninput,\nbutton,\nselect,\ntextarea {\n\tfont: inherit;\n\tcolor: inherit;\n}\n\nimg {\n\tvertical-align: middle;\n}\n\n.sr-only {\n\tposition: absolute;\n\twidth: 1px;\n\theight: 1px;\n\tmargin: -1px;\n\tpadding: 0;\n\toverflow: hidden;\n\tclip: rect(0, 0, 0, 0);\n\tborder: 0;\n}\n\nabbr[title] {\n\tcursor: help;\n}\n\nhtml,\nbody {\n\theight: 100%;\n\toverscroll-behavior: none; /* prevent overscroll navigation actions */\n}\n\nbody {\n\tbackground: var(--body-bg-color);\n\tcolor: var(--body-color);\n\tfont: 16px -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n\tmargin: 0;\n\tuser-select: none;\n\tcursor: default;\n\ttouch-action: none;\n\n\t/**\n\t * Disable pull-to-refresh on mobile that conflicts with scrolling the message list.\n\t * See http://stackoverflow.com/a/29313685/1935861\n\t */\n\toverflow: hidden; /* iOS Safari requires overflow rather than overflow-y */\n}\n\nbody.force-no-select * {\n\tuser-select: none !important;\n}\n\na,\na:hover,\na:focus {\n\tcolor: var(--link-color);\n\ttext-decoration: none;\n}\n\na:hover {\n\ttext-decoration: underline;\n}\n\na:focus {\n\toutline: thin dotted;\n\toutline: 5px auto -webkit-focus-ring-color;\n\toutline-offset: -2px;\n}\n\nh1,\nh2,\nh3 {\n\tfont: inherit;\n\tline-height: inherit;\n\tmargin: 0;\n}\n\nbutton {\n\tborder: none;\n\tbackground: none;\n\tmargin: 0;\n\toutline: none;\n\tpadding: 0;\n\tuser-select: inherit;\n\tcursor: pointer;\n}\n\ncode,\npre,\n#chat .msg[data-type=\"monospace_block\"] .text,\n.irc-monospace,\ntextarea#user-specified-css-input {\n\tfont-family: Consolas, Menlo, Monaco, \"Lucida Console\", \"DejaVu Sans Mono\", \"Courier New\", monospace;\n}\n\ncode,\n.irc-monospace {\n\tfont-size: 13px;\n\tpadding: 2px 4px;\n\tcolor: #e74c3c;\n\tbackground-color: #f9f2f4;\n\tborder-radius: 2px;\n}\n\npre {\n\tdisplay: block;\n\tpadding: 9.5px;\n\tmargin: 0 0 10px;\n\tfont-size: 13px;\n\tline-height: 1.4286;\n\tcolor: #333;\n\tword-break: break-all;\n\tword-wrap: break-word;\n\tbackground-color: #f5f5f5;\n\tborder-radius: 4px;\n}\n\nkbd {\n\tdisplay: inline-block;\n\tfont-family: inherit;\n\tline-height: 1em;\n\tmin-width: 28px; /* Ensure 1-char keys have the same width */\n\tmargin: 0 1px;\n\tpadding: 4px 6px;\n\tcolor: #444;\n\ttext-align: center;\n\ttext-shadow: 0 1px 0 #fff;\n\tbackground-color: white;\n\tbackground-image: linear-gradient(180deg, rgb(0 0 0 / 5%), transparent);\n\tborder: 1px solid #bbb;\n\tborder-radius: 4px;\n\tbox-shadow: 0 2px 0 #bbb, inset 0 1px 1px #fff, inset 0 -1px 3px #ccc;\n}\n\np {\n\tmargin: 0 0 10px;\n}\n\n.btn {\n\tborder: 2px solid var(--button-color);\n\tborder-radius: 3px;\n\tcolor: var(--button-color);\n\tdisplay: inline-block;\n\tfont-size: 12px;\n\tfont-weight: bold;\n\tletter-spacing: 1px;\n\tmargin-bottom: 10px;\n\tpadding: 9px 17px;\n\ttext-transform: uppercase;\n\ttransition: background 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;\n\tword-spacing: 3px;\n\tcursor: pointer; /* This is useful for `