From bed060f1c5c4e2265c90ac9dbb8db4e81a530e0a Mon Sep 17 00:00:00 2001 From: Hemna Date: Wed, 6 Jul 2022 18:14:25 -0400 Subject: [PATCH 01/19] Refactor utils to directory This patch moves the utils.py to utils/__init__.py and fuzzyclock.py to utils and separates the ring_buffer to it's own file in utils --- aprsd/plugins/time.py | 5 ++- aprsd/{utils.py => utils/__init__.py} | 55 ++------------------------- aprsd/{ => utils}/fuzzyclock.py | 0 aprsd/utils/ring_buffer.py | 37 ++++++++++++++++++ tests/plugins/test_time.py | 2 +- 5 files changed, 45 insertions(+), 54 deletions(-) rename aprsd/{utils.py => utils/__init__.py} (71%) rename aprsd/{ => utils}/fuzzyclock.py (100%) create mode 100644 aprsd/utils/ring_buffer.py diff --git a/aprsd/plugins/time.py b/aprsd/plugins/time.py index 8cfdd83..92033c9 100644 --- a/aprsd/plugins/time.py +++ b/aprsd/plugins/time.py @@ -5,7 +5,8 @@ import time from opencage.geocoder import OpenCageGeocode import pytz -from aprsd import fuzzyclock, plugin, plugin_utils, trace +from aprsd import plugin, plugin_utils, trace +from aprsd.utils import fuzzy LOG = logging.getLogger("APRSD") @@ -32,7 +33,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, 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..ae5eef4 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 +# Make these available by anyone importing +# aprsd.utils +from .ring_buffer import RingBuffer 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/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/tests/plugins/test_time.py b/tests/plugins/test_time.py index 52616a4..befba45 100644 --- a/tests/plugins/test_time.py +++ b/tests/plugins/test_time.py @@ -2,8 +2,8 @@ from unittest import mock import pytz -from aprsd.fuzzyclock import fuzzy from aprsd.plugins import time as time_plugin +from aprsd.utils import fuzzy from .. import fake, test_plugin From 347a6d69f7825a408553a4ee08002917c5a6ab73 Mon Sep 17 00:00:00 2001 From: Hemna Date: Wed, 6 Jul 2022 19:31:59 -0400 Subject: [PATCH 02/19] Refactored threads.py This patch creates a threads directory and separates out the contents of threads.py into separate files in the threads directory to make it easier to find and maintain. --- aprsd/threads/__init__.py | 13 +++ aprsd/threads/aprsd.py | 64 ++++++++++++ aprsd/threads/keep_alive.py | 87 ++++++++++++++++ aprsd/{threads.py => threads/rx.py} | 151 +--------------------------- 4 files changed, 166 insertions(+), 149 deletions(-) create mode 100644 aprsd/threads/__init__.py create mode 100644 aprsd/threads/aprsd.py create mode 100644 aprsd/threads/keep_alive.py rename aprsd/{threads.py => threads/rx.py} (60%) diff --git a/aprsd/threads/__init__.py b/aprsd/threads/__init__.py new file mode 100644 index 0000000..0927235 --- /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 +from .keep_alive import KeepAliveThread +from .rx import APRSDRXThread + + +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..d981252 --- /dev/null +++ b/aprsd/threads/aprsd.py @@ -0,0 +1,64 @@ +import abc +import logging +import threading + + +LOG = logging.getLogger("APRSD") + + +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") diff --git a/aprsd/threads/keep_alive.py b/aprsd/threads/keep_alive.py new file mode 100644 index 0000000..b838665 --- /dev/null +++ b/aprsd/threads/keep_alive.py @@ -0,0 +1,87 @@ +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["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 diff --git a/aprsd/threads.py b/aprsd/threads/rx.py similarity index 60% rename from aprsd/threads.py rename to aprsd/threads/rx.py index c81275e..f9c1cb5 100644 --- a/aprsd/threads.py +++ b/aprsd/threads/rx.py @@ -1,161 +1,14 @@ -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): From 29b84b453be5bf1ab4ba0f40bae33bc5057e42c7 Mon Sep 17 00:00:00 2001 From: Hemna Date: Wed, 6 Jul 2022 19:39:41 -0400 Subject: [PATCH 03/19] Fixed pep8 errors --- aprsd/threads/__init__.py | 6 +++--- aprsd/threads/keep_alive.py | 1 + aprsd/threads/rx.py | 1 - aprsd/utils/__init__.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/aprsd/threads/__init__.py b/aprsd/threads/__init__.py index 0927235..fd4da3b 100644 --- a/aprsd/threads/__init__.py +++ b/aprsd/threads/__init__.py @@ -2,9 +2,9 @@ import queue # Make these available to anyone importing # aprsd.threads -from .aprsd import APRSDThread, APRSDThreadList -from .keep_alive import KeepAliveThread -from .rx import APRSDRXThread +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) diff --git a/aprsd/threads/keep_alive.py b/aprsd/threads/keep_alive.py index b838665..cc96f67 100644 --- a/aprsd/threads/keep_alive.py +++ b/aprsd/threads/keep_alive.py @@ -9,6 +9,7 @@ from aprsd.threads import APRSDThread, APRSDThreadList LOG = logging.getLogger("APRSD") + class KeepAliveThread(APRSDThread): cntr = 0 checker_time = datetime.datetime.now() diff --git a/aprsd/threads/rx.py b/aprsd/threads/rx.py index f9c1cb5..d0b8617 100644 --- a/aprsd/threads/rx.py +++ b/aprsd/threads/rx.py @@ -10,7 +10,6 @@ from aprsd.threads import APRSDThread LOG = logging.getLogger("APRSD") - class APRSDRXThread(APRSDThread): def __init__(self, msg_queues, config): super().__init__("RX_MSG") diff --git a/aprsd/utils/__init__.py b/aprsd/utils/__init__.py index ae5eef4..02042f9 100644 --- a/aprsd/utils/__init__.py +++ b/aprsd/utils/__init__.py @@ -9,10 +9,10 @@ import update_checker import aprsd -from .fuzzyclock import fuzzy +from .fuzzyclock import fuzzy # noqa: F401 # Make these available by anyone importing # aprsd.utils -from .ring_buffer import RingBuffer +from .ring_buffer import RingBuffer # noqa: F401 def env(*vars, **kwargs): From a62843920a6742cf22c966e6d2a05155f20aeb17 Mon Sep 17 00:00:00 2001 From: Hemna Date: Thu, 7 Jul 2022 10:47:34 -0400 Subject: [PATCH 04/19] Moved trace.py to utils This patch moves trace.py to the utils directory --- aprsd/client.py | 3 ++- aprsd/cmds/dev.py | 3 ++- aprsd/cmds/listen.py | 5 ++--- aprsd/cmds/server.py | 3 ++- aprsd/plugins/email.py | 3 ++- aprsd/plugins/fortune.py | 3 ++- aprsd/plugins/location.py | 3 ++- aprsd/plugins/notify.py | 3 ++- aprsd/plugins/ping.py | 3 ++- aprsd/plugins/query.py | 3 ++- aprsd/plugins/time.py | 4 ++-- aprsd/plugins/version.py | 3 ++- aprsd/plugins/weather.py | 3 ++- aprsd/{ => utils}/trace.py | 0 14 files changed, 26 insertions(+), 16 deletions(-) rename aprsd/{ => utils}/trace.py (100%) diff --git a/aprsd/client.py b/aprsd/client.py index 06dc1cd..b4cb2e3 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") diff --git a/aprsd/cmds/dev.py b/aprsd/cmds/dev.py index 383ffcf..bbe1e59 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 from aprsd.aprsd import cli +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/cmds/listen.py b/aprsd/cmds/listen.py index c3ebe27..c165650 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 diff --git a/aprsd/cmds/server.py b/aprsd/cmds/server.py index b173bdb..7d9a8cf 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.utils import trace LOG = logging.getLogger("APRSD") 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 92033c9..e446eb2 100644 --- a/aprsd/plugins/time.py +++ b/aprsd/plugins/time.py @@ -5,8 +5,8 @@ import time from opencage.geocoder import OpenCageGeocode import pytz -from aprsd import plugin, plugin_utils, trace -from aprsd.utils import fuzzy +from aprsd import plugin, plugin_utils +from aprsd.utils import fuzzy, trace LOG = logging.getLogger("APRSD") 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/trace.py b/aprsd/utils/trace.py similarity index 100% rename from aprsd/trace.py rename to aprsd/utils/trace.py From 1ccb2f76953d113df482902895d7014aee778195 Mon Sep 17 00:00:00 2001 From: Hemna Date: Thu, 7 Jul 2022 11:02:43 -0400 Subject: [PATCH 05/19] Moved log.py to logging Also renamed logging/logging.py to logging/rich.py --- aprsd/cli_helper.py | 2 +- aprsd/flask.py | 5 +++-- aprsd/{ => logging}/log.py | 2 +- aprsd/logging/{logging.py => rich.py} | 0 tests/cmds/test_dev.py | 4 ++-- tests/cmds/test_send_message.py | 8 ++++---- 6 files changed, 11 insertions(+), 10 deletions(-) rename aprsd/{ => logging}/log.py (98%) rename aprsd/logging/{logging.py => rich.py} (100%) diff --git a/aprsd/cli_helper.py b/aprsd/cli_helper.py index a52f260..2e0fb0d 100644 --- a/aprsd/cli_helper.py +++ b/aprsd/cli_helper.py @@ -4,7 +4,7 @@ import typing as t import click from aprsd import config as aprsd_config -from aprsd import log +from aprsd.logging import log F = t.TypeVar("F", bound=t.Callable[..., t.Any]) diff --git a/aprsd/flask.py b/aprsd/flask.py index 77e983b..0be0667 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") 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/tests/cmds/test_dev.py b/tests/cmds/test_dev.py index fd2b20a..77793c4 100644 --- a/tests/cmds/test_dev.py +++ b/tests/cmds/test_dev.py @@ -25,7 +25,7 @@ class TestDevTestPluginCommand(unittest.TestCase): return aprsd_config.Config(config) @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_login(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no login and config.""" @@ -43,7 +43,7 @@ class TestDevTestPluginCommand(unittest.TestCase): assert "Must set --aprs_login or APRS_LOGIN" in result.output @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_plugin_arg(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no login and config.""" diff --git a/tests/cmds/test_send_message.py b/tests/cmds/test_send_message.py index e67e8f1..7a4b22e 100644 --- a/tests/cmds/test_send_message.py +++ b/tests/cmds/test_send_message.py @@ -25,7 +25,7 @@ class TestSendMessageCommand(unittest.TestCase): return aprsd_config.Config(config) @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_login(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no login and config.""" @@ -43,7 +43,7 @@ class TestSendMessageCommand(unittest.TestCase): assert "Must set --aprs_login or APRS_LOGIN" in result.output @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_password(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no password and config.""" @@ -58,7 +58,7 @@ class TestSendMessageCommand(unittest.TestCase): assert "Must set --aprs-password or APRS_PASSWORD" in result.output @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_tocallsign(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no tocallsign.""" @@ -76,7 +76,7 @@ class TestSendMessageCommand(unittest.TestCase): assert "Error: Missing argument 'TOCALLSIGN'" in result.output @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_command(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no command.""" From 585d55f10dca57b8e1dcdadfe26415b1cfa40d7e Mon Sep 17 00:00:00 2001 From: Hemna Date: Wed, 20 Jul 2022 08:43:57 -0400 Subject: [PATCH 06/19] 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 --- aprsd/aprsd.py | 2 +- aprsd/cli_helper.py | 3 + aprsd/client.py | 4 +- aprsd/clients/aprsis.py | 22 + aprsd/cmds/listen.py | 15 +- aprsd/cmds/server.py | 5 +- aprsd/cmds/webchat.py | 592 ++++++++++++++++++ aprsd/flask.py | 4 +- aprsd/messaging.py | 16 +- aprsd/packets.py | 13 +- aprsd/plugin.py | 3 +- aprsd/stats.py | 162 ++--- aprsd/threads/keep_alive.py | 2 +- aprsd/threads/rx.py | 12 +- aprsd/{ => utils}/objectstore.py | 0 aprsd/web/__init__.py | 0 aprsd/web/admin/__init__.py | 0 aprsd/web/{ => admin}/static/css/index.css | 0 aprsd/web/{ => admin}/static/css/prism.css | 0 aprsd/web/{ => admin}/static/css/tabs.css | 0 aprsd/web/admin/static/images/Untitled.png | Bin 0 -> 37797 bytes .../static/images/aprs-symbols-16-0.png | Bin .../static/images/aprs-symbols-16-1.png | Bin .../static/images/aprs-symbols-64-0.png | Bin .../static/images/aprs-symbols-64-1.png | Bin .../static/images/aprs-symbols-64-2.png | Bin aprsd/web/{ => admin}/static/js/charts.js | 0 aprsd/web/{ => admin}/static/js/logs.js | 0 aprsd/web/{ => admin}/static/js/main.js | 0 aprsd/web/{ => admin}/static/js/prism.js | 0 .../web/{ => admin}/static/js/send-message.js | 0 aprsd/web/{ => admin}/static/js/tabs.js | 0 .../static/json-viewer/jquery.json-viewer.css | 0 .../static/json-viewer/jquery.json-viewer.js | 0 aprsd/web/{ => admin}/templates/index.html | 0 aprsd/web/{ => admin}/templates/messages.html | 0 .../{ => admin}/templates/send-message.html | 0 aprsd/web/chat/static/css/index.css | 94 +++ aprsd/web/chat/static/css/style.css.map | 1 + aprsd/web/chat/static/css/tabs.css | 41 ++ aprsd/web/chat/static/images/Untitled.png | Bin 0 -> 37797 bytes .../chat/static/images/aprs-symbols-16-0.png | Bin 0 -> 52962 bytes .../chat/static/images/aprs-symbols-16-1.png | Bin 0 -> 48951 bytes .../chat/static/images/aprs-symbols-64-0.png | Bin 0 -> 52962 bytes .../chat/static/images/aprs-symbols-64-1.png | Bin 0 -> 48951 bytes .../chat/static/images/aprs-symbols-64-2.png | Bin 0 -> 40716 bytes aprsd/web/chat/static/js/main.js | 44 ++ aprsd/web/chat/static/js/send-message.js | 215 +++++++ aprsd/web/chat/static/js/tabs.js | 28 + .../static/json-viewer/jquery.json-viewer.css | 57 ++ .../static/json-viewer/jquery.json-viewer.js | 158 +++++ aprsd/web/chat/templates/index.html | 86 +++ requirements.in | 1 + requirements.txt | 2 + tests/cmds/test_dev.py | 5 +- tests/cmds/test_send_message.py | 5 +- 56 files changed, 1481 insertions(+), 111 deletions(-) create mode 100644 aprsd/cmds/webchat.py rename aprsd/{ => utils}/objectstore.py (100%) create mode 100644 aprsd/web/__init__.py create mode 100644 aprsd/web/admin/__init__.py rename aprsd/web/{ => admin}/static/css/index.css (100%) rename aprsd/web/{ => admin}/static/css/prism.css (100%) rename aprsd/web/{ => admin}/static/css/tabs.css (100%) create mode 100644 aprsd/web/admin/static/images/Untitled.png rename aprsd/web/{ => admin}/static/images/aprs-symbols-16-0.png (100%) rename aprsd/web/{ => admin}/static/images/aprs-symbols-16-1.png (100%) rename aprsd/web/{ => admin}/static/images/aprs-symbols-64-0.png (100%) rename aprsd/web/{ => admin}/static/images/aprs-symbols-64-1.png (100%) rename aprsd/web/{ => admin}/static/images/aprs-symbols-64-2.png (100%) rename aprsd/web/{ => admin}/static/js/charts.js (100%) rename aprsd/web/{ => admin}/static/js/logs.js (100%) rename aprsd/web/{ => admin}/static/js/main.js (100%) rename aprsd/web/{ => admin}/static/js/prism.js (100%) rename aprsd/web/{ => admin}/static/js/send-message.js (100%) rename aprsd/web/{ => admin}/static/js/tabs.js (100%) rename aprsd/web/{ => admin}/static/json-viewer/jquery.json-viewer.css (100%) rename aprsd/web/{ => admin}/static/json-viewer/jquery.json-viewer.js (100%) rename aprsd/web/{ => admin}/templates/index.html (100%) rename aprsd/web/{ => admin}/templates/messages.html (100%) rename aprsd/web/{ => admin}/templates/send-message.html (100%) create mode 100644 aprsd/web/chat/static/css/index.css create mode 100644 aprsd/web/chat/static/css/style.css.map create mode 100644 aprsd/web/chat/static/css/tabs.css create mode 100644 aprsd/web/chat/static/images/Untitled.png create mode 100644 aprsd/web/chat/static/images/aprs-symbols-16-0.png create mode 100644 aprsd/web/chat/static/images/aprs-symbols-16-1.png create mode 100644 aprsd/web/chat/static/images/aprs-symbols-64-0.png create mode 100644 aprsd/web/chat/static/images/aprs-symbols-64-1.png create mode 100644 aprsd/web/chat/static/images/aprs-symbols-64-2.png create mode 100644 aprsd/web/chat/static/js/main.js create mode 100644 aprsd/web/chat/static/js/send-message.js create mode 100644 aprsd/web/chat/static/js/tabs.js create mode 100644 aprsd/web/chat/static/json-viewer/jquery.json-viewer.css create mode 100644 aprsd/web/chat/static/json-viewer/jquery.json-viewer.js create mode 100644 aprsd/web/chat/templates/index.html diff --git a/aprsd/aprsd.py b/aprsd/aprsd.py index 5d5587c..c97826e 100644 --- a/aprsd/aprsd.py +++ b/aprsd/aprsd.py @@ -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() diff --git a/aprsd/cli_helper.py b/aprsd/cli_helper.py index 2e0fb0d..c70f22c 100644 --- a/aprsd/cli_helper.py +++ b/aprsd/cli_helper.py @@ -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"] diff --git a/aprsd/client.py b/aprsd/client.py index b4cb2e3..f17e027 100644 --- a/aprsd/client.py +++ b/aprsd/client.py @@ -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 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/cmds/listen.py b/aprsd/cmds/listen.py index c165650..1afbcef 100644 --- a/aprsd/cmds/listen.py +++ b/aprsd/cmds/listen.py @@ -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) diff --git a/aprsd/cmds/server.py b/aprsd/cmds/server.py index 7d9a8cf..d53865d 100644 --- a/aprsd/cmds/server.py +++ b/aprsd/cmds/server.py @@ -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, ) diff --git a/aprsd/cmds/webchat.py b/aprsd/cmds/webchat.py new file mode 100644 index 0000000..fc518a8 --- /dev/null +++ b/aprsd/cmds/webchat.py @@ -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: " + "{}".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, + ) diff --git a/aprsd/flask.py b/aprsd/flask.py index 0be0667..21de182 100644 --- a/aprsd/flask.py +++ b/aprsd/flask.py @@ -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() 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..f7d7b11 100644 --- a/aprsd/packets.py +++ b/aprsd/packets.py @@ -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): 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/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/keep_alive.py b/aprsd/threads/keep_alive.py index cc96f67..3b70eeb 100644 --- a/aprsd/threads/keep_alive.py +++ b/aprsd/threads/keep_alive.py @@ -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 diff --git a/aprsd/threads/rx.py b/aprsd/threads/rx.py index d0b8617..5046e20 100644 --- a/aprsd/threads/rx.py +++ b/aprsd/threads/rx.py @@ -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) 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/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 0000000000000000000000000000000000000000..666fbc4bfedf447da0139a03297f12c34f362867 GIT binary patch literal 37797 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 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 `